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

Last change on this file since fa32d0f was 92e7c7a, checked in by veronika-ils <ilioskaveronika@…>, 11 hours ago

Petify fullstack project

  • Property mode set to 100644
File size: 19.8 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 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 <div class="summary-strip">
37 <div class="summary-item">
38 <span class="summary-value">{{ appointments.length }}</span>
39 <span class="summary-label">Appointments</span>
40 </div>
41 <div class="summary-item">
42 <span class="summary-value">{{ availableSlots.length }}</span>
43 <span class="summary-label">Available</span>
44 </div>
45 <div class="summary-item">
46 <span class="summary-value">{{ unavailableSlots.length }}</span>
47 <span class="summary-label">Not working</span>
48 </div>
49 </div>
50
51 <div v-if="isLoading" class="alert alert-info">Loading clinic schedule...</div>
52
53 <div v-else class="schedule-layout">
54 <section class="schedule-section">
55 <div class="section-heading">
56 <h2>Slots for {{ selectedDate }}</h2>
57 <button class="btn btn-outline-secondary btn-sm" type="button" @click="loadSchedule">Refresh</button>
58 </div>
59
60 <div class="slot-grid">
61 <div
62 v-for="slot in daySlots"
63 :key="slot.dateTime"
64 class="slot-card"
65 :class="slot.kind"
66 >
67 <div class="slot-time">{{ slot.label }}</div>
68 <div class="slot-main">
69 <span class="slot-status">{{ slot.statusText }}</span>
70 <span v-if="slot.detail" class="slot-detail">{{ slot.detail }}</span>
71 </div>
72 <button
73 v-if="slot.kind === 'available'"
74 type="button"
75 class="btn btn-sm btn-outline-danger"
76 @click="blockSlot(slot.dateTime)"
77 >
78 Mark not working
79 </button>
80 <button
81 v-else-if="slot.kind === 'unavailable' && slot.unavailableSlotId"
82 type="button"
83 class="btn btn-sm btn-outline-secondary"
84 @click="unblockSlot(slot.unavailableSlotId)"
85 >
86 Make available
87 </button>
88 </div>
89 </div>
90 </section>
91
92 <aside class="appointments-panel">
93 <section class="notifications-panel">
94 <div class="section-heading compact">
95 <h2>Notifications</h2>
96 <button class="btn btn-outline-secondary btn-sm" type="button" @click="loadNotifications">Refresh</button>
97 </div>
98 <div v-if="notifications.length === 0" class="panel-empty">No notifications yet.</div>
99 <div v-else class="notification-list">
100 <article v-for="notification in notifications.slice(0, 5)" :key="notification.notificationId" class="notification-row">
101 <div class="notification-message">{{ notification.message }}</div>
102 <div class="notification-date">{{ formatDateTime(notification.createdAt) }}</div>
103 </article>
104 </div>
105 </section>
106
107 <h2>Appointments</h2>
108 <div v-if="appointments.length === 0" class="panel-empty">No appointments on this date.</div>
109 <div v-else class="appointment-list">
110 <article v-for="appointment in appointments" :key="appointment.appointmentId" class="appointment-row">
111 <div class="appointment-time">{{ appointment.label }}</div>
112 <div>
113 <div class="appointment-title">{{ appointment.petName || 'Pet' }}</div>
114 <div class="appointment-meta">
115 {{ appointment.petSpecies || 'Species unknown' }} with {{ appointment.ownerName || 'owner' }}
116 </div>
117 <div v-if="appointment.notes" class="appointment-notes">{{ appointment.notes }}</div>
118 <button
119 v-if="canMarkNoShow(appointment)"
120 type="button"
121 class="btn btn-sm btn-outline-danger appointment-action"
122 :disabled="updatingAppointmentId === appointment.appointmentId"
123 @click="markNoShow(appointment)"
124 >
125 {{ updatingAppointmentId === appointment.appointmentId ? 'Updating...' : 'Mark no-show' }}
126 </button>
127 </div>
128 <span class="badge" :class="getStatusClass(appointment.status)">{{ appointment.status }}</span>
129 </article>
130 </div>
131 </aside>
132 </div>
133 </template>
134 </section>
135 </main>
136</template>
137
138<script setup lang="ts">
139import { computed, onMounted, ref, watch } from 'vue'
140import { useRouter } from 'vue-router'
141import {
142 createMyClinicUnavailableSlot,
143 deleteMyClinicUnavailableSlot,
144 getMyClinic,
145 getMyClinicAppointments,
146 getMyClinicUnavailableSlots,
147 getMyNotifications,
148 markMyClinicAppointmentNoShow,
149 type AppNotification,
150 type AppointmentSlot,
151 type ClinicAppointment,
152 type ClinicUnavailableSlot,
153 type VetClinic,
154} from '../api/profile'
155import { useAuthStore } from '../stores/auth'
156
157type ScheduleSlot = {
158 dateTime: string
159 label: string
160 kind: 'available' | 'booked' | 'unavailable' | 'past'
161 statusText: string
162 detail?: string
163 unavailableSlotId?: number
164}
165
166const router = useRouter()
167const auth = useAuthStore()
168
169const clinic = ref<VetClinic | null>(null)
170const selectedDate = ref(toDateKey(new Date()))
171const availableSlots = ref<AppointmentSlot[]>([])
172const unavailableSlots = ref<ClinicUnavailableSlot[]>([])
173const appointments = ref<ClinicAppointment[]>([])
174const notifications = ref<AppNotification[]>([])
175const isLoading = ref(false)
176const updatingAppointmentId = ref<number | null>(null)
177const accessError = ref('')
178const scheduleError = ref('')
179const notificationsError = ref('')
180const NON_BLOCKING_STATUSES = new Set(['CANCELLED', 'CANCELED', 'NO_SHOW'])
181let latestScheduleRequest = 0
182
183const canUseDashboard = computed(() => auth.isAuthenticated && auth.user?.userType === 'CLINIC')
184
185const appointmentsByDateTime = computed(() => {
186 const map = new Map<string, ClinicAppointment>()
187 appointments.value
188 .filter((appointment) => !NON_BLOCKING_STATUSES.has(String(appointment.status || '').toUpperCase()))
189 .forEach((appointment) => map.set(normalizeDateTime(appointment.dateTime), appointment))
190 return map
191})
192
193const availableDateTimes = computed(() => {
194 return new Set(availableSlots.value.map((slot) => normalizeDateTime(slot.dateTime)))
195})
196
197const unavailableByDateTime = computed(() => {
198 const map = new Map<string, ClinicUnavailableSlot>()
199 unavailableSlots.value.forEach((slot) => map.set(normalizeDateTime(slot.dateTime), slot))
200 return map
201})
202
203const daySlots = computed<ScheduleSlot[]>(() => {
204 const slots: ScheduleSlot[] = []
205 const now = new Date()
206
207 for (let hour = 9; hour < 17; hour += 1) {
208 for (const minute of [0, 30]) {
209 const dateTime = `${selectedDate.value}T${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`
210 const label = `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`
211 const key = normalizeDateTime(dateTime)
212 const appointment = appointmentsByDateTime.value.get(key)
213 const unavailable = unavailableByDateTime.value.get(key)
214
215 if (appointment) {
216 slots.push({
217 dateTime,
218 label,
219 kind: 'booked',
220 statusText: 'Booked',
221 detail: appointment.petName || undefined,
222 })
223 } else if (unavailable) {
224 slots.push({
225 dateTime,
226 label,
227 kind: 'unavailable',
228 statusText: 'Not working',
229 detail: unavailable.reason || undefined,
230 unavailableSlotId: unavailable.slotId,
231 })
232 } else if (availableDateTimes.value.has(key)) {
233 slots.push({
234 dateTime,
235 label,
236 kind: 'available',
237 statusText: 'Available',
238 })
239 } else if (new Date(dateTime).getTime() < now.getTime()) {
240 slots.push({
241 dateTime,
242 label,
243 kind: 'past',
244 statusText: 'Past',
245 })
246 } else {
247 slots.push({
248 dateTime,
249 label,
250 kind: 'unavailable',
251 statusText: 'Unavailable',
252 })
253 }
254 }
255 }
256
257 return slots
258})
259
260function buildAvailableSlots(
261 date: string,
262 clinicAppointments: ClinicAppointment[],
263 clinicUnavailableSlots: ClinicUnavailableSlot[]
264): AppointmentSlot[] {
265 const now = new Date()
266 const booked = new Set(
267 clinicAppointments
268 .filter((appointment) => !NON_BLOCKING_STATUSES.has(String(appointment.status || '').toUpperCase()))
269 .map((appointment) => normalizeDateTime(appointment.dateTime))
270 )
271 const unavailable = new Set(
272 clinicUnavailableSlots.map((slot) => normalizeDateTime(slot.dateTime))
273 )
274 const slots: AppointmentSlot[] = []
275
276 for (let hour = 9; hour < 17; hour += 1) {
277 for (const minute of [0, 30]) {
278 const dateTime = `${date}T${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`
279 const key = normalizeDateTime(dateTime)
280 if (new Date(dateTime).getTime() < now.getTime()) continue
281 if (booked.has(key) || unavailable.has(key)) continue
282 slots.push({
283 dateTime,
284 label: `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`,
285 })
286 }
287 }
288
289 return slots
290}
291
292async function loadSchedule() {
293 if (!auth.user?.userId || !canUseDashboard.value || !selectedDate.value) return
294
295 const requestId = ++latestScheduleRequest
296 try {
297 isLoading.value = true
298 scheduleError.value = ''
299 const [unavailable, clinicAppointments] = await Promise.all([
300 getMyClinicUnavailableSlots(auth.user.userId, selectedDate.value),
301 getMyClinicAppointments(auth.user.userId, selectedDate.value),
302 ])
303
304 if (requestId !== latestScheduleRequest) return
305 unavailableSlots.value = unavailable
306 appointments.value = clinicAppointments
307 availableSlots.value = buildAvailableSlots(selectedDate.value, clinicAppointments, unavailable)
308 } catch (error) {
309 if (requestId !== latestScheduleRequest) return
310 availableSlots.value = []
311 unavailableSlots.value = []
312 appointments.value = []
313 scheduleError.value = error instanceof Error ? error.message : 'Failed to load clinic schedule'
314 } finally {
315 if (requestId === latestScheduleRequest) {
316 isLoading.value = false
317 }
318 }
319}
320
321async function loadNotifications() {
322 if (!auth.user?.userId || !canUseDashboard.value) return
323
324 try {
325 notificationsError.value = ''
326 notifications.value = await getMyNotifications(auth.user.userId)
327 } catch (error) {
328 notifications.value = []
329 notificationsError.value = error instanceof Error ? error.message : 'Failed to load notifications'
330 }
331}
332
333async function blockSlot(dateTime: string) {
334 if (!auth.user?.userId) return
335 const reason = window.prompt('Reason for blocking this slot?', 'Not working')
336 if (reason === null) return
337
338 try {
339 scheduleError.value = ''
340 await createMyClinicUnavailableSlot(auth.user.userId, {
341 dateTime,
342 reason: reason.trim() || 'Not working',
343 })
344 await loadSchedule()
345 } catch (error) {
346 scheduleError.value = error instanceof Error ? error.message : 'Failed to block slot'
347 }
348}
349
350async function unblockSlot(slotId: number) {
351 if (!auth.user?.userId) return
352
353 try {
354 scheduleError.value = ''
355 await deleteMyClinicUnavailableSlot(auth.user.userId, slotId)
356 await loadSchedule()
357 } catch (error) {
358 scheduleError.value = error instanceof Error ? error.message : 'Failed to unblock slot'
359 }
360}
361
362function canMarkNoShow(appointment: ClinicAppointment): boolean {
363 return ['CONFIRMED', 'DONE'].includes(appointment.status) && new Date(appointment.dateTime).getTime() <= Date.now()
364}
365
366async function markNoShow(appointment: ClinicAppointment) {
367 if (!auth.user?.userId) return
368 if (!window.confirm('Mark this appointment as no-show?')) return
369
370 try {
371 updatingAppointmentId.value = appointment.appointmentId
372 scheduleError.value = ''
373 const updated = await markMyClinicAppointmentNoShow(auth.user.userId, appointment.appointmentId)
374 appointments.value = appointments.value.map((item) =>
375 item.appointmentId === updated.appointmentId ? updated : item
376 )
377 } catch (error) {
378 scheduleError.value = error instanceof Error ? error.message : 'Failed to mark appointment as no-show'
379 } finally {
380 updatingAppointmentId.value = null
381 }
382}
383
384function getStatusClass(status: string): string {
385 if (status === 'CONFIRMED' || status === 'DONE') return 'bg-success'
386 if (status === 'CANCELLED' || status === 'CANCELED' || status === 'NO_SHOW') return 'bg-secondary'
387 return 'bg-warning'
388}
389
390function normalizeDateTime(value: string): string {
391 return value.length >= 16 ? value.slice(0, 16) : value
392}
393
394function toDateKey(date: Date): string {
395 const year = date.getFullYear()
396 const month = String(date.getMonth() + 1).padStart(2, '0')
397 const day = String(date.getDate()).padStart(2, '0')
398 return `${year}-${month}-${day}`
399}
400
401function shiftSelectedDate(days: number) {
402 const date = new Date(`${selectedDate.value}T00:00:00`)
403 date.setDate(date.getDate() + days)
404 selectedDate.value = toDateKey(date)
405}
406
407function goToPreviousDay() {
408 shiftSelectedDate(-1)
409}
410
411function goToNextDay() {
412 shiftSelectedDate(1)
413}
414
415function goToToday() {
416 selectedDate.value = toDateKey(new Date())
417}
418
419function formatDateTime(value: string): string {
420 return new Date(value).toLocaleString('en-US', {
421 month: 'short',
422 day: 'numeric',
423 hour: '2-digit',
424 minute: '2-digit',
425 })
426}
427
428onMounted(async () => {
429 if (!auth.isAuthenticated) {
430 router.push('/login')
431 return
432 }
433
434 if (auth.user?.userType !== 'CLINIC') {
435 accessError.value = 'This dashboard is only available for clinic accounts.'
436 return
437 }
438
439 try {
440 clinic.value = await getMyClinic(auth.user.userId)
441 accessError.value = ''
442 } catch (error) {
443 accessError.value = error instanceof Error ? error.message : 'Unable to load your clinic profile'
444 return
445 }
446
447 await Promise.all([loadSchedule(), loadNotifications()])
448})
449
450watch(selectedDate, () => {
451 loadSchedule()
452})
453</script>
454
455<style scoped>
456.clinic-dashboard {
457 min-height: 100vh;
458 background: #f7fafc;
459 padding-bottom: 56px;
460}
461
462.dashboard-header {
463 background: white;
464 border-bottom: 1px solid #e2e8f0;
465 padding: 32px 0;
466}
467
468.dashboard-header .container {
469 display: flex;
470 align-items: end;
471 justify-content: space-between;
472 gap: 24px;
473}
474
475.eyebrow {
476 color: #f97316;
477 font-weight: 700;
478 margin: 0 0 8px;
479 text-transform: uppercase;
480 font-size: 0.78rem;
481}
482
483.page-title {
484 color: #1a202c;
485 font-size: 2rem;
486 margin: 0;
487}
488
489.clinic-subtitle {
490 color: #718096;
491 margin: 8px 0 0;
492 font-weight: 600;
493}
494
495.toolbar {
496 display: flex;
497 gap: 12px;
498 align-items: center;
499}
500
501.date-input {
502 width: 180px;
503}
504
505.dashboard-body {
506 padding-top: 32px;
507}
508
509.empty-state,
510.summary-strip,
511.schedule-section,
512.appointments-panel {
513 background: white;
514 border: 1px solid #e2e8f0;
515 border-radius: 8px;
516}
517
518.empty-state {
519 padding: 48px;
520 text-align: center;
521}
522
523.empty-state h2 {
524 font-size: 1.35rem;
525 margin: 0 0 8px;
526}
527
528.empty-state p {
529 color: #718096;
530 margin: 0;
531}
532
533.summary-strip {
534 display: grid;
535 grid-template-columns: repeat(3, 1fr);
536 margin-bottom: 20px;
537}
538
539.summary-item {
540 padding: 20px 24px;
541 border-right: 1px solid #e2e8f0;
542}
543
544.summary-item:last-child {
545 border-right: none;
546}
547
548.summary-value {
549 display: block;
550 font-size: 1.8rem;
551 color: #1a202c;
552 font-weight: 800;
553}
554
555.summary-label {
556 color: #718096;
557 font-weight: 600;
558}
559
560.schedule-layout {
561 display: grid;
562 grid-template-columns: minmax(0, 1fr) 380px;
563 gap: 20px;
564 align-items: start;
565}
566
567.schedule-section,
568.appointments-panel {
569 padding: 24px;
570}
571
572.section-heading {
573 display: flex;
574 align-items: center;
575 justify-content: space-between;
576 margin-bottom: 18px;
577 gap: 16px;
578}
579
580.section-heading.compact {
581 margin-bottom: 12px;
582}
583
584.notifications-panel {
585 border-bottom: 1px solid #e2e8f0;
586 margin-bottom: 20px;
587 padding-bottom: 20px;
588}
589
590.notification-list {
591 display: grid;
592 gap: 10px;
593}
594
595.notification-row {
596 background: #fff7ed;
597 border: 1px solid #fed7aa;
598 border-radius: 8px;
599 padding: 10px 12px;
600}
601
602.notification-message {
603 color: #2d3748;
604 font-weight: 600;
605 line-height: 1.35;
606}
607
608.notification-date {
609 color: #718096;
610 font-size: 0.82rem;
611 margin-top: 4px;
612}
613
614.section-heading h2,
615.appointments-panel h2 {
616 font-size: 1.2rem;
617 margin: 0;
618 color: #1a202c;
619}
620
621.slot-grid {
622 display: grid;
623 grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
624 gap: 12px;
625}
626
627.slot-card {
628 border: 1px solid #e2e8f0;
629 border-radius: 8px;
630 padding: 14px;
631 display: grid;
632 gap: 10px;
633 min-height: 136px;
634 align-content: start;
635}
636
637.slot-card.available {
638 border-color: #9ae6b4;
639 background: #f0fff4;
640}
641
642.slot-card.booked {
643 border-color: #90cdf4;
644 background: #ebf8ff;
645}
646
647.slot-card.unavailable {
648 border-color: #fed7d7;
649 background: #fff5f5;
650}
651
652.slot-card.past {
653 color: #718096;
654 background: #edf2f7;
655}
656
657.slot-time {
658 font-size: 1.25rem;
659 font-weight: 800;
660 color: #1a202c;
661}
662
663.slot-main {
664 display: flex;
665 flex-direction: column;
666 gap: 3px;
667}
668
669.slot-status {
670 font-weight: 700;
671}
672
673.slot-detail {
674 color: #4a5568;
675 font-size: 0.9rem;
676}
677
678.appointment-list {
679 display: grid;
680 gap: 12px;
681 margin-top: 16px;
682}
683
684.appointment-row {
685 border: 1px solid #e2e8f0;
686 border-radius: 8px;
687 padding: 14px;
688 display: grid;
689 grid-template-columns: 58px minmax(0, 1fr) auto;
690 gap: 12px;
691 align-items: start;
692}
693
694.appointment-time {
695 font-weight: 800;
696 color: #f97316;
697}
698
699.appointment-title {
700 font-weight: 800;
701 color: #1a202c;
702}
703
704.appointment-meta,
705.appointment-notes,
706.panel-empty {
707 color: #718096;
708 font-size: 0.9rem;
709}
710
711.appointment-notes {
712 margin-top: 6px;
713 background: #f7fafc;
714 padding: 8px;
715 border-radius: 6px;
716}
717
718.appointment-action {
719 margin-top: 10px;
720}
721
722.badge {
723 border-radius: 6px;
724 padding: 4px 8px;
725 font-size: 0.75rem;
726}
727
728.bg-success {
729 background: #c6f6d5;
730 color: #22543d;
731}
732
733.bg-secondary {
734 background: #e2e8f0;
735 color: #2d3748;
736}
737
738.bg-warning {
739 background: #fefcbf;
740 color: #744210;
741}
742
743@media (max-width: 980px) {
744 .dashboard-header .container,
745 .toolbar {
746 align-items: stretch;
747 flex-direction: column;
748 }
749
750 .date-input {
751 min-width: 0;
752 width: 100%;
753 }
754
755 .schedule-layout {
756 grid-template-columns: 1fr;
757 }
758}
759
760@media (max-width: 640px) {
761 .summary-strip {
762 grid-template-columns: 1fr;
763 }
764
765 .summary-item {
766 border-right: none;
767 border-bottom: 1px solid #e2e8f0;
768 }
769
770 .summary-item:last-child {
771 border-bottom: none;
772 }
773}
774</style>
Note: See TracBrowser for help on using the repository browser.