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

Last change on this file since f6ed6e4 was f6ed6e4, checked in by veronika-ils <ilioskaveronika@…>, 23 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
Line 
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>
10 <div v-if="canUseSchedule" class="toolbar">
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>
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>
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">
102 <div v-if="daySlots.length === 0" class="panel-empty full-width">
103 This is not a working day for your clinic.
104 </div>
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>
177 </template>
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,
194 updateMyClinicSchedule,
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)
222const isSavingSchedule = ref(false)
223const updatingAppointmentId = ref<number | null>(null)
224const accessError = ref('')
225const scheduleError = ref('')
226const scheduleSetupError = ref('')
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')
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]
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
270 for (const time of getClinicTimesForDate(selectedDate.value)) {
271 const dateTime = `${selectedDate.value}T${time}`
272 const label = time
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
337 for (const time of getClinicTimesForDate(date)) {
338 const dateTime = `${date}T${time}`
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,
344 label: time,
345 })
346 }
347
348 return slots
349}
350
351async function loadSchedule() {
352 if (!auth.user?.userId || !canUseDashboard.value || !canUseSchedule.value || !selectedDate.value) return
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
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
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
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
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)
575 hydrateScheduleForm()
576 accessError.value = ''
577 } catch (error) {
578 accessError.value = error instanceof Error ? error.message : 'Unable to load your clinic profile'
579 return
580 }
581
582 if (canUseSchedule.value) {
583 await Promise.all([loadSchedule(), loadNotifications()])
584 } else {
585 await loadNotifications()
586 }
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,
651.appointments-panel,
652.setup-panel {
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
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
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
838.full-width {
839 grid-column: 1 / -1;
840}
841
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.