source: petify-frontend/src/views/ClinicDashboardView.vue@ f6ed6e4

Last change on this file since f6ed6e4 was f6ed6e4, checked in by veronika-ils <ilioskaveronika@…>, 12 hours ago

feat: The app is consistent with the changes made in phase 1 and phase 2

  • Property mode set to 100644
File size: 25.4 KB
RevLine 
[92e7c7a]1<template>
2 <main class="clinic-dashboard">
3 <section class="dashboard-header">
4 <div class="container">
5 <div>
6 <p class="eyebrow">Clinic workspace</p>
7 <h1 class="page-title">Appointments & Availability</h1>
8 <p v-if="clinic" class="clinic-subtitle">{{ clinic.name }} - {{ clinic.city }}, {{ clinic.address }}</p>
9 </div>
[f6ed6e4]10 <div v-if="canUseSchedule" class="toolbar">
[92e7c7a]11 <button class="btn btn-outline-secondary btn-sm" type="button" @click="goToPreviousDay">
12 Previous day
13 </button>
14 <input v-model="selectedDate" type="date" class="form-control date-input" />
15 <button class="btn btn-outline-secondary btn-sm" type="button" @click="goToToday">
16 Today
17 </button>
18 <button class="btn btn-outline-secondary btn-sm" type="button" @click="goToNextDay">
19 Next day
20 </button>
21 </div>
22 </div>
23 </section>
24
25 <section class="container dashboard-body">
26 <div v-if="accessError" class="alert alert-danger">{{ accessError }}</div>
27 <div v-if="scheduleError" class="alert alert-danger">{{ scheduleError }}</div>
28 <div v-if="notificationsError" class="alert alert-danger">{{ notificationsError }}</div>
29
30 <div v-if="!canUseDashboard" class="empty-state">
31 <h2>Clinic login required</h2>
32 <p>This dashboard is only available for logged-in clinic accounts.</p>
33 </div>
34
35 <template v-else>
[f6ed6e4]36 <section v-if="clinic && !canUseSchedule" class="setup-panel">
37 <div>
38 <p class="eyebrow">Required setup</p>
39 <h2>Add your working schedule</h2>
40 <p class="setup-copy">
41 Users can book appointment slots only after your clinic adds working days and opening hours.
42 </p>
43 </div>
44
45 <form class="schedule-form" @submit.prevent="saveSchedule">
46 <fieldset class="days-fieldset">
47 <legend>Working days</legend>
48 <label v-for="day in workDayOptions" :key="day.value" class="day-check">
49 <input
50 v-model="scheduleForm.workDays"
51 type="checkbox"
52 :value="day.value"
53 />
54 <span>{{ day.label }}</span>
55 </label>
56 </fieldset>
57
58 <div class="time-grid">
59 <div class="form-group">
60 <label class="form-label" for="startTime">Start time</label>
61 <input id="startTime" v-model="scheduleForm.startTime" class="form-control" type="time" required />
62 </div>
63 <div class="form-group">
64 <label class="form-label" for="endTime">End time</label>
65 <input id="endTime" v-model="scheduleForm.endTime" class="form-control" type="time" required />
66 </div>
67 </div>
68
69 <div v-if="scheduleSetupError" class="alert alert-danger">{{ scheduleSetupError }}</div>
70 <button class="btn btn-primary" type="submit" :disabled="isSavingSchedule">
71 {{ isSavingSchedule ? 'Saving...' : 'Save schedule' }}
72 </button>
73 </form>
74 </section>
75
76 <template v-else>
[92e7c7a]77 <div class="summary-strip">
78 <div class="summary-item">
79 <span class="summary-value">{{ appointments.length }}</span>
80 <span class="summary-label">Appointments</span>
81 </div>
82 <div class="summary-item">
83 <span class="summary-value">{{ availableSlots.length }}</span>
84 <span class="summary-label">Available</span>
85 </div>
86 <div class="summary-item">
87 <span class="summary-value">{{ unavailableSlots.length }}</span>
88 <span class="summary-label">Not working</span>
89 </div>
90 </div>
91
92 <div v-if="isLoading" class="alert alert-info">Loading clinic schedule...</div>
93
94 <div v-else class="schedule-layout">
95 <section class="schedule-section">
96 <div class="section-heading">
97 <h2>Slots for {{ selectedDate }}</h2>
98 <button class="btn btn-outline-secondary btn-sm" type="button" @click="loadSchedule">Refresh</button>
99 </div>
100
101 <div class="slot-grid">
[f6ed6e4]102 <div v-if="daySlots.length === 0" class="panel-empty full-width">
103 This is not a working day for your clinic.
104 </div>
[92e7c7a]105 <div
106 v-for="slot in daySlots"
107 :key="slot.dateTime"
108 class="slot-card"
109 :class="slot.kind"
110 >
111 <div class="slot-time">{{ slot.label }}</div>
112 <div class="slot-main">
113 <span class="slot-status">{{ slot.statusText }}</span>
114 <span v-if="slot.detail" class="slot-detail">{{ slot.detail }}</span>
115 </div>
116 <button
117 v-if="slot.kind === 'available'"
118 type="button"
119 class="btn btn-sm btn-outline-danger"
120 @click="blockSlot(slot.dateTime)"
121 >
122 Mark not working
123 </button>
124 <button
125 v-else-if="slot.kind === 'unavailable' && slot.unavailableSlotId"
126 type="button"
127 class="btn btn-sm btn-outline-secondary"
128 @click="unblockSlot(slot.unavailableSlotId)"
129 >
130 Make available
131 </button>
132 </div>
133 </div>
134 </section>
135
136 <aside class="appointments-panel">
137 <section class="notifications-panel">
138 <div class="section-heading compact">
139 <h2>Notifications</h2>
140 <button class="btn btn-outline-secondary btn-sm" type="button" @click="loadNotifications">Refresh</button>
141 </div>
142 <div v-if="notifications.length === 0" class="panel-empty">No notifications yet.</div>
143 <div v-else class="notification-list">
144 <article v-for="notification in notifications.slice(0, 5)" :key="notification.notificationId" class="notification-row">
145 <div class="notification-message">{{ notification.message }}</div>
146 <div class="notification-date">{{ formatDateTime(notification.createdAt) }}</div>
147 </article>
148 </div>
149 </section>
150
151 <h2>Appointments</h2>
152 <div v-if="appointments.length === 0" class="panel-empty">No appointments on this date.</div>
153 <div v-else class="appointment-list">
154 <article v-for="appointment in appointments" :key="appointment.appointmentId" class="appointment-row">
155 <div class="appointment-time">{{ appointment.label }}</div>
156 <div>
157 <div class="appointment-title">{{ appointment.petName || 'Pet' }}</div>
158 <div class="appointment-meta">
159 {{ appointment.petSpecies || 'Species unknown' }} with {{ appointment.ownerName || 'owner' }}
160 </div>
161 <div v-if="appointment.notes" class="appointment-notes">{{ appointment.notes }}</div>
162 <button
163 v-if="canMarkNoShow(appointment)"
164 type="button"
165 class="btn btn-sm btn-outline-danger appointment-action"
166 :disabled="updatingAppointmentId === appointment.appointmentId"
167 @click="markNoShow(appointment)"
168 >
169 {{ updatingAppointmentId === appointment.appointmentId ? 'Updating...' : 'Mark no-show' }}
170 </button>
171 </div>
172 <span class="badge" :class="getStatusClass(appointment.status)">{{ appointment.status }}</span>
173 </article>
174 </div>
175 </aside>
176 </div>
[f6ed6e4]177 </template>
[92e7c7a]178 </template>
179 </section>
180 </main>
181</template>
182
183<script setup lang="ts">
184import { computed, onMounted, ref, watch } from 'vue'
185import { useRouter } from 'vue-router'
186import {
187 createMyClinicUnavailableSlot,
188 deleteMyClinicUnavailableSlot,
189 getMyClinic,
190 getMyClinicAppointments,
191 getMyClinicUnavailableSlots,
192 getMyNotifications,
193 markMyClinicAppointmentNoShow,
[f6ed6e4]194 updateMyClinicSchedule,
[92e7c7a]195 type AppNotification,
196 type AppointmentSlot,
197 type ClinicAppointment,
198 type ClinicUnavailableSlot,
199 type VetClinic,
200} from '../api/profile'
201import { useAuthStore } from '../stores/auth'
202
203type ScheduleSlot = {
204 dateTime: string
205 label: string
206 kind: 'available' | 'booked' | 'unavailable' | 'past'
207 statusText: string
208 detail?: string
209 unavailableSlotId?: number
210}
211
212const router = useRouter()
213const auth = useAuthStore()
214
215const clinic = ref<VetClinic | null>(null)
216const selectedDate = ref(toDateKey(new Date()))
217const availableSlots = ref<AppointmentSlot[]>([])
218const unavailableSlots = ref<ClinicUnavailableSlot[]>([])
219const appointments = ref<ClinicAppointment[]>([])
220const notifications = ref<AppNotification[]>([])
221const isLoading = ref(false)
[f6ed6e4]222const isSavingSchedule = ref(false)
[92e7c7a]223const updatingAppointmentId = ref<number | null>(null)
224const accessError = ref('')
225const scheduleError = ref('')
[f6ed6e4]226const scheduleSetupError = ref('')
[92e7c7a]227const notificationsError = ref('')
228const NON_BLOCKING_STATUSES = new Set(['CANCELLED', 'CANCELED', 'NO_SHOW'])
229let latestScheduleRequest = 0
230
231const canUseDashboard = computed(() => auth.isAuthenticated && auth.user?.userType === 'CLINIC')
[f6ed6e4]232const canUseSchedule = computed(() => Boolean(clinic.value?.scheduleComplete))
233const scheduleForm = ref({
234 workDays: [] as string[],
235 startTime: '09:00',
236 endTime: '17:00',
237})
238const workDayOptions = [
239 { value: 'MONDAY', label: 'Mon' },
240 { value: 'TUESDAY', label: 'Tue' },
241 { value: 'WEDNESDAY', label: 'Wed' },
242 { value: 'THURSDAY', label: 'Thu' },
243 { value: 'FRIDAY', label: 'Fri' },
244 { value: 'SATURDAY', label: 'Sat' },
245 { value: 'SUNDAY', label: 'Sun' },
246]
[92e7c7a]247
248const appointmentsByDateTime = computed(() => {
249 const map = new Map<string, ClinicAppointment>()
250 appointments.value
251 .filter((appointment) => !NON_BLOCKING_STATUSES.has(String(appointment.status || '').toUpperCase()))
252 .forEach((appointment) => map.set(normalizeDateTime(appointment.dateTime), appointment))
253 return map
254})
255
256const availableDateTimes = computed(() => {
257 return new Set(availableSlots.value.map((slot) => normalizeDateTime(slot.dateTime)))
258})
259
260const unavailableByDateTime = computed(() => {
261 const map = new Map<string, ClinicUnavailableSlot>()
262 unavailableSlots.value.forEach((slot) => map.set(normalizeDateTime(slot.dateTime), slot))
263 return map
264})
265
266const daySlots = computed<ScheduleSlot[]>(() => {
267 const slots: ScheduleSlot[] = []
268 const now = new Date()
269
[f6ed6e4]270 for (const time of getClinicTimesForDate(selectedDate.value)) {
271 const dateTime = `${selectedDate.value}T${time}`
272 const label = time
[92e7c7a]273 const key = normalizeDateTime(dateTime)
274 const appointment = appointmentsByDateTime.value.get(key)
275 const unavailable = unavailableByDateTime.value.get(key)
276
277 if (appointment) {
278 slots.push({
279 dateTime,
280 label,
281 kind: 'booked',
282 statusText: 'Booked',
283 detail: appointment.petName || undefined,
284 })
285 } else if (unavailable) {
286 slots.push({
287 dateTime,
288 label,
289 kind: 'unavailable',
290 statusText: 'Not working',
291 detail: unavailable.reason || undefined,
292 unavailableSlotId: unavailable.slotId,
293 })
294 } else if (availableDateTimes.value.has(key)) {
295 slots.push({
296 dateTime,
297 label,
298 kind: 'available',
299 statusText: 'Available',
300 })
301 } else if (new Date(dateTime).getTime() < now.getTime()) {
302 slots.push({
303 dateTime,
304 label,
305 kind: 'past',
306 statusText: 'Past',
307 })
308 } else {
309 slots.push({
310 dateTime,
311 label,
312 kind: 'unavailable',
313 statusText: 'Unavailable',
314 })
315 }
316 }
317
318 return slots
319})
320
321function buildAvailableSlots(
322 date: string,
323 clinicAppointments: ClinicAppointment[],
324 clinicUnavailableSlots: ClinicUnavailableSlot[]
325): AppointmentSlot[] {
326 const now = new Date()
327 const booked = new Set(
328 clinicAppointments
329 .filter((appointment) => !NON_BLOCKING_STATUSES.has(String(appointment.status || '').toUpperCase()))
330 .map((appointment) => normalizeDateTime(appointment.dateTime))
331 )
332 const unavailable = new Set(
333 clinicUnavailableSlots.map((slot) => normalizeDateTime(slot.dateTime))
334 )
335 const slots: AppointmentSlot[] = []
336
[f6ed6e4]337 for (const time of getClinicTimesForDate(date)) {
338 const dateTime = `${date}T${time}`
[92e7c7a]339 const key = normalizeDateTime(dateTime)
340 if (new Date(dateTime).getTime() < now.getTime()) continue
341 if (booked.has(key) || unavailable.has(key)) continue
342 slots.push({
343 dateTime,
[f6ed6e4]344 label: time,
[92e7c7a]345 })
346 }
347
348 return slots
349}
350
351async function loadSchedule() {
[f6ed6e4]352 if (!auth.user?.userId || !canUseDashboard.value || !canUseSchedule.value || !selectedDate.value) return
[92e7c7a]353
354 const requestId = ++latestScheduleRequest
355 try {
356 isLoading.value = true
357 scheduleError.value = ''
358 const [unavailable, clinicAppointments] = await Promise.all([
359 getMyClinicUnavailableSlots(auth.user.userId, selectedDate.value),
360 getMyClinicAppointments(auth.user.userId, selectedDate.value),
361 ])
362
363 if (requestId !== latestScheduleRequest) return
364 unavailableSlots.value = unavailable
365 appointments.value = clinicAppointments
366 availableSlots.value = buildAvailableSlots(selectedDate.value, clinicAppointments, unavailable)
367 } catch (error) {
368 if (requestId !== latestScheduleRequest) return
369 availableSlots.value = []
370 unavailableSlots.value = []
371 appointments.value = []
372 scheduleError.value = error instanceof Error ? error.message : 'Failed to load clinic schedule'
373 } finally {
374 if (requestId === latestScheduleRequest) {
375 isLoading.value = false
376 }
377 }
378}
379
[f6ed6e4]380async function saveSchedule() {
381 if (!auth.user?.userId) return
382 if (scheduleForm.value.workDays.length === 0) {
383 scheduleSetupError.value = 'Choose at least one working day'
384 return
385 }
386
387 if (!scheduleForm.value.startTime || !scheduleForm.value.endTime) {
388 scheduleSetupError.value = 'Start and end time are required'
389 return
390 }
391
392 if (scheduleForm.value.startTime >= scheduleForm.value.endTime) {
393 scheduleSetupError.value = 'Start time must be before end time'
394 return
395 }
396
397 try {
398 isSavingSchedule.value = true
399 scheduleSetupError.value = ''
400 clinic.value = await updateMyClinicSchedule(auth.user.userId, {
401 workDays: scheduleForm.value.workDays.join(','),
402 startTime: scheduleForm.value.startTime,
403 endTime: scheduleForm.value.endTime,
404 })
405 hydrateScheduleForm()
406 await loadSchedule()
407 } catch (error) {
408 scheduleSetupError.value = error instanceof Error ? error.message : 'Failed to save clinic schedule'
409 } finally {
410 isSavingSchedule.value = false
411 }
412}
413
[92e7c7a]414async function loadNotifications() {
415 if (!auth.user?.userId || !canUseDashboard.value) return
416
417 try {
418 notificationsError.value = ''
419 notifications.value = await getMyNotifications(auth.user.userId)
420 } catch (error) {
421 notifications.value = []
422 notificationsError.value = error instanceof Error ? error.message : 'Failed to load notifications'
423 }
424}
425
426async function blockSlot(dateTime: string) {
427 if (!auth.user?.userId) return
428 const reason = window.prompt('Reason for blocking this slot?', 'Not working')
429 if (reason === null) return
430
431 try {
432 scheduleError.value = ''
433 await createMyClinicUnavailableSlot(auth.user.userId, {
434 dateTime,
435 reason: reason.trim() || 'Not working',
436 })
437 await loadSchedule()
438 } catch (error) {
439 scheduleError.value = error instanceof Error ? error.message : 'Failed to block slot'
440 }
441}
442
443async function unblockSlot(slotId: number) {
444 if (!auth.user?.userId) return
445
446 try {
447 scheduleError.value = ''
448 await deleteMyClinicUnavailableSlot(auth.user.userId, slotId)
449 await loadSchedule()
450 } catch (error) {
451 scheduleError.value = error instanceof Error ? error.message : 'Failed to unblock slot'
452 }
453}
454
455function canMarkNoShow(appointment: ClinicAppointment): boolean {
456 return ['CONFIRMED', 'DONE'].includes(appointment.status) && new Date(appointment.dateTime).getTime() <= Date.now()
457}
458
459async function markNoShow(appointment: ClinicAppointment) {
460 if (!auth.user?.userId) return
461 if (!window.confirm('Mark this appointment as no-show?')) return
462
463 try {
464 updatingAppointmentId.value = appointment.appointmentId
465 scheduleError.value = ''
466 const updated = await markMyClinicAppointmentNoShow(auth.user.userId, appointment.appointmentId)
467 appointments.value = appointments.value.map((item) =>
468 item.appointmentId === updated.appointmentId ? updated : item
469 )
470 } catch (error) {
471 scheduleError.value = error instanceof Error ? error.message : 'Failed to mark appointment as no-show'
472 } finally {
473 updatingAppointmentId.value = null
474 }
475}
476
477function getStatusClass(status: string): string {
478 if (status === 'CONFIRMED' || status === 'DONE') return 'bg-success'
479 if (status === 'CANCELLED' || status === 'CANCELED' || status === 'NO_SHOW') return 'bg-secondary'
480 return 'bg-warning'
481}
482
483function normalizeDateTime(value: string): string {
484 return value.length >= 16 ? value.slice(0, 16) : value
485}
486
487function toDateKey(date: Date): string {
488 const year = date.getFullYear()
489 const month = String(date.getMonth() + 1).padStart(2, '0')
490 const day = String(date.getDate()).padStart(2, '0')
491 return `${year}-${month}-${day}`
492}
493
494function shiftSelectedDate(days: number) {
495 const date = new Date(`${selectedDate.value}T00:00:00`)
496 date.setDate(date.getDate() + days)
497 selectedDate.value = toDateKey(date)
498}
499
500function goToPreviousDay() {
501 shiftSelectedDate(-1)
502}
503
504function goToNextDay() {
505 shiftSelectedDate(1)
506}
507
508function goToToday() {
509 selectedDate.value = toDateKey(new Date())
510}
511
[f6ed6e4]512function hydrateScheduleForm() {
513 scheduleForm.value = {
514 workDays: clinic.value?.workDays ? clinic.value.workDays.split(',').map((day) => day.trim()).filter(Boolean) : [],
515 startTime: clinic.value?.startTime?.slice(0, 5) || '09:00',
516 endTime: clinic.value?.endTime?.slice(0, 5) || '17:00',
517 }
518}
519
520function getClinicTimesForDate(date: string): string[] {
521 if (!clinic.value?.workDays || !clinic.value.startTime || !clinic.value.endTime) return []
522 if (!isClinicWorkingDay(date)) return []
523
524 const times: string[] = []
525 const start = timeToMinutes(clinic.value.startTime)
526 const end = timeToMinutes(clinic.value.endTime)
527
528 for (let minutes = start; minutes < end; minutes += 30) {
529 times.push(minutesToTime(minutes))
530 }
531
532 return times
533}
534
535function isClinicWorkingDay(date: string): boolean {
536 const day = new Date(`${date}T00:00:00`).toLocaleDateString('en-US', { weekday: 'long' }).toUpperCase()
537 return clinic.value?.workDays?.split(',').map((item) => item.trim().toUpperCase()).includes(day) ?? false
538}
539
540function timeToMinutes(time: string): number {
541 const parts = time.slice(0, 5).split(':').map(Number)
542 const hours = parts[0] ?? 0
543 const minutes = parts[1] ?? 0
544 return hours * 60 + minutes
545}
546
547function minutesToTime(total: number): string {
548 const hours = Math.floor(total / 60)
549 const minutes = total % 60
550 return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}`
551}
552
[92e7c7a]553function formatDateTime(value: string): string {
554 return new Date(value).toLocaleString('en-US', {
555 month: 'short',
556 day: 'numeric',
557 hour: '2-digit',
558 minute: '2-digit',
559 })
560}
561
562onMounted(async () => {
563 if (!auth.isAuthenticated) {
564 router.push('/login')
565 return
566 }
567
568 if (auth.user?.userType !== 'CLINIC') {
569 accessError.value = 'This dashboard is only available for clinic accounts.'
570 return
571 }
572
573 try {
574 clinic.value = await getMyClinic(auth.user.userId)
[f6ed6e4]575 hydrateScheduleForm()
[92e7c7a]576 accessError.value = ''
577 } catch (error) {
578 accessError.value = error instanceof Error ? error.message : 'Unable to load your clinic profile'
579 return
580 }
581
[f6ed6e4]582 if (canUseSchedule.value) {
583 await Promise.all([loadSchedule(), loadNotifications()])
584 } else {
585 await loadNotifications()
586 }
[92e7c7a]587})
588
589watch(selectedDate, () => {
590 loadSchedule()
591})
592</script>
593
594<style scoped>
595.clinic-dashboard {
596 min-height: 100vh;
597 background: #f7fafc;
598 padding-bottom: 56px;
599}
600
601.dashboard-header {
602 background: white;
603 border-bottom: 1px solid #e2e8f0;
604 padding: 32px 0;
605}
606
607.dashboard-header .container {
608 display: flex;
609 align-items: end;
610 justify-content: space-between;
611 gap: 24px;
612}
613
614.eyebrow {
615 color: #f97316;
616 font-weight: 700;
617 margin: 0 0 8px;
618 text-transform: uppercase;
619 font-size: 0.78rem;
620}
621
622.page-title {
623 color: #1a202c;
624 font-size: 2rem;
625 margin: 0;
626}
627
628.clinic-subtitle {
629 color: #718096;
630 margin: 8px 0 0;
631 font-weight: 600;
632}
633
634.toolbar {
635 display: flex;
636 gap: 12px;
637 align-items: center;
638}
639
640.date-input {
641 width: 180px;
642}
643
644.dashboard-body {
645 padding-top: 32px;
646}
647
648.empty-state,
649.summary-strip,
650.schedule-section,
[f6ed6e4]651.appointments-panel,
652.setup-panel {
[92e7c7a]653 background: white;
654 border: 1px solid #e2e8f0;
655 border-radius: 8px;
656}
657
658.empty-state {
659 padding: 48px;
660 text-align: center;
661}
662
663.empty-state h2 {
664 font-size: 1.35rem;
665 margin: 0 0 8px;
666}
667
668.empty-state p {
669 color: #718096;
670 margin: 0;
671}
672
[f6ed6e4]673.setup-panel {
674 display: grid;
675 gap: 24px;
676 padding: 28px;
677}
678
679.setup-panel h2 {
680 color: #1a202c;
681 font-size: 1.45rem;
682 margin: 0 0 8px;
683}
684
685.setup-copy {
686 color: #718096;
687 margin: 0;
688}
689
690.schedule-form {
691 display: grid;
692 gap: 20px;
693 max-width: 720px;
694}
695
696.days-fieldset {
697 border: 0;
698 display: flex;
699 flex-wrap: wrap;
700 gap: 10px;
701 margin: 0;
702 padding: 0;
703}
704
705.days-fieldset legend {
706 color: #2d3748;
707 font-weight: 700;
708 margin-bottom: 8px;
709 width: 100%;
710}
711
712.day-check {
713 align-items: center;
714 border: 1px solid #cbd5e0;
715 border-radius: 8px;
716 cursor: pointer;
717 display: inline-flex;
718 gap: 8px;
719 padding: 9px 12px;
720}
721
722.day-check:has(input:checked) {
723 background: #fff7ed;
724 border-color: #f97316;
725 color: #9a3412;
726}
727
728.time-grid {
729 display: grid;
730 grid-template-columns: repeat(2, minmax(0, 180px));
731 gap: 16px;
732}
733
734.form-group {
735 display: grid;
736 gap: 8px;
737}
738
739.form-label {
740 color: #2d3748;
741 font-weight: 700;
742}
743
[92e7c7a]744.summary-strip {
745 display: grid;
746 grid-template-columns: repeat(3, 1fr);
747 margin-bottom: 20px;
748}
749
750.summary-item {
751 padding: 20px 24px;
752 border-right: 1px solid #e2e8f0;
753}
754
755.summary-item:last-child {
756 border-right: none;
757}
758
759.summary-value {
760 display: block;
761 font-size: 1.8rem;
762 color: #1a202c;
763 font-weight: 800;
764}
765
766.summary-label {
767 color: #718096;
768 font-weight: 600;
769}
770
771.schedule-layout {
772 display: grid;
773 grid-template-columns: minmax(0, 1fr) 380px;
774 gap: 20px;
775 align-items: start;
776}
777
778.schedule-section,
779.appointments-panel {
780 padding: 24px;
781}
782
783.section-heading {
784 display: flex;
785 align-items: center;
786 justify-content: space-between;
787 margin-bottom: 18px;
788 gap: 16px;
789}
790
791.section-heading.compact {
792 margin-bottom: 12px;
793}
794
795.notifications-panel {
796 border-bottom: 1px solid #e2e8f0;
797 margin-bottom: 20px;
798 padding-bottom: 20px;
799}
800
801.notification-list {
802 display: grid;
803 gap: 10px;
804}
805
806.notification-row {
807 background: #fff7ed;
808 border: 1px solid #fed7aa;
809 border-radius: 8px;
810 padding: 10px 12px;
811}
812
813.notification-message {
814 color: #2d3748;
815 font-weight: 600;
816 line-height: 1.35;
817}
818
819.notification-date {
820 color: #718096;
821 font-size: 0.82rem;
822 margin-top: 4px;
823}
824
825.section-heading h2,
826.appointments-panel h2 {
827 font-size: 1.2rem;
828 margin: 0;
829 color: #1a202c;
830}
831
832.slot-grid {
833 display: grid;
834 grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
835 gap: 12px;
836}
837
[f6ed6e4]838.full-width {
839 grid-column: 1 / -1;
840}
841
[92e7c7a]842.slot-card {
843 border: 1px solid #e2e8f0;
844 border-radius: 8px;
845 padding: 14px;
846 display: grid;
847 gap: 10px;
848 min-height: 136px;
849 align-content: start;
850}
851
852.slot-card.available {
853 border-color: #9ae6b4;
854 background: #f0fff4;
855}
856
857.slot-card.booked {
858 border-color: #90cdf4;
859 background: #ebf8ff;
860}
861
862.slot-card.unavailable {
863 border-color: #fed7d7;
864 background: #fff5f5;
865}
866
867.slot-card.past {
868 color: #718096;
869 background: #edf2f7;
870}
871
872.slot-time {
873 font-size: 1.25rem;
874 font-weight: 800;
875 color: #1a202c;
876}
877
878.slot-main {
879 display: flex;
880 flex-direction: column;
881 gap: 3px;
882}
883
884.slot-status {
885 font-weight: 700;
886}
887
888.slot-detail {
889 color: #4a5568;
890 font-size: 0.9rem;
891}
892
893.appointment-list {
894 display: grid;
895 gap: 12px;
896 margin-top: 16px;
897}
898
899.appointment-row {
900 border: 1px solid #e2e8f0;
901 border-radius: 8px;
902 padding: 14px;
903 display: grid;
904 grid-template-columns: 58px minmax(0, 1fr) auto;
905 gap: 12px;
906 align-items: start;
907}
908
909.appointment-time {
910 font-weight: 800;
911 color: #f97316;
912}
913
914.appointment-title {
915 font-weight: 800;
916 color: #1a202c;
917}
918
919.appointment-meta,
920.appointment-notes,
921.panel-empty {
922 color: #718096;
923 font-size: 0.9rem;
924}
925
926.appointment-notes {
927 margin-top: 6px;
928 background: #f7fafc;
929 padding: 8px;
930 border-radius: 6px;
931}
932
933.appointment-action {
934 margin-top: 10px;
935}
936
937.badge {
938 border-radius: 6px;
939 padding: 4px 8px;
940 font-size: 0.75rem;
941}
942
943.bg-success {
944 background: #c6f6d5;
945 color: #22543d;
946}
947
948.bg-secondary {
949 background: #e2e8f0;
950 color: #2d3748;
951}
952
953.bg-warning {
954 background: #fefcbf;
955 color: #744210;
956}
957
958@media (max-width: 980px) {
959 .dashboard-header .container,
960 .toolbar {
961 align-items: stretch;
962 flex-direction: column;
963 }
964
965 .date-input {
966 min-width: 0;
967 width: 100%;
968 }
969
970 .schedule-layout {
971 grid-template-columns: 1fr;
972 }
973}
974
975@media (max-width: 640px) {
976 .summary-strip {
977 grid-template-columns: 1fr;
978 }
979
980 .summary-item {
981 border-right: none;
982 border-bottom: 1px solid #e2e8f0;
983 }
984
985 .summary-item:last-child {
986 border-bottom: none;
987 }
988}
989</style>
Note: See TracBrowser for help on using the repository browser.