| 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">
|
|---|
| 139 | import { computed, onMounted, ref, watch } from 'vue'
|
|---|
| 140 | import { useRouter } from 'vue-router'
|
|---|
| 141 | import {
|
|---|
| 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'
|
|---|
| 155 | import { useAuthStore } from '../stores/auth'
|
|---|
| 156 |
|
|---|
| 157 | type ScheduleSlot = {
|
|---|
| 158 | dateTime: string
|
|---|
| 159 | label: string
|
|---|
| 160 | kind: 'available' | 'booked' | 'unavailable' | 'past'
|
|---|
| 161 | statusText: string
|
|---|
| 162 | detail?: string
|
|---|
| 163 | unavailableSlotId?: number
|
|---|
| 164 | }
|
|---|
| 165 |
|
|---|
| 166 | const router = useRouter()
|
|---|
| 167 | const auth = useAuthStore()
|
|---|
| 168 |
|
|---|
| 169 | const clinic = ref<VetClinic | null>(null)
|
|---|
| 170 | const selectedDate = ref(toDateKey(new Date()))
|
|---|
| 171 | const availableSlots = ref<AppointmentSlot[]>([])
|
|---|
| 172 | const unavailableSlots = ref<ClinicUnavailableSlot[]>([])
|
|---|
| 173 | const appointments = ref<ClinicAppointment[]>([])
|
|---|
| 174 | const notifications = ref<AppNotification[]>([])
|
|---|
| 175 | const isLoading = ref(false)
|
|---|
| 176 | const updatingAppointmentId = ref<number | null>(null)
|
|---|
| 177 | const accessError = ref('')
|
|---|
| 178 | const scheduleError = ref('')
|
|---|
| 179 | const notificationsError = ref('')
|
|---|
| 180 | const NON_BLOCKING_STATUSES = new Set(['CANCELLED', 'CANCELED', 'NO_SHOW'])
|
|---|
| 181 | let latestScheduleRequest = 0
|
|---|
| 182 |
|
|---|
| 183 | const canUseDashboard = computed(() => auth.isAuthenticated && auth.user?.userType === 'CLINIC')
|
|---|
| 184 |
|
|---|
| 185 | const 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 |
|
|---|
| 193 | const availableDateTimes = computed(() => {
|
|---|
| 194 | return new Set(availableSlots.value.map((slot) => normalizeDateTime(slot.dateTime)))
|
|---|
| 195 | })
|
|---|
| 196 |
|
|---|
| 197 | const 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 |
|
|---|
| 203 | const 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 |
|
|---|
| 260 | function 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 |
|
|---|
| 292 | async 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 |
|
|---|
| 321 | async 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 |
|
|---|
| 333 | async 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 |
|
|---|
| 350 | async 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 |
|
|---|
| 362 | function canMarkNoShow(appointment: ClinicAppointment): boolean {
|
|---|
| 363 | return ['CONFIRMED', 'DONE'].includes(appointment.status) && new Date(appointment.dateTime).getTime() <= Date.now()
|
|---|
| 364 | }
|
|---|
| 365 |
|
|---|
| 366 | async 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 |
|
|---|
| 384 | function 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 |
|
|---|
| 390 | function normalizeDateTime(value: string): string {
|
|---|
| 391 | return value.length >= 16 ? value.slice(0, 16) : value
|
|---|
| 392 | }
|
|---|
| 393 |
|
|---|
| 394 | function 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 |
|
|---|
| 401 | function 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 |
|
|---|
| 407 | function goToPreviousDay() {
|
|---|
| 408 | shiftSelectedDate(-1)
|
|---|
| 409 | }
|
|---|
| 410 |
|
|---|
| 411 | function goToNextDay() {
|
|---|
| 412 | shiftSelectedDate(1)
|
|---|
| 413 | }
|
|---|
| 414 |
|
|---|
| 415 | function goToToday() {
|
|---|
| 416 | selectedDate.value = toDateKey(new Date())
|
|---|
| 417 | }
|
|---|
| 418 |
|
|---|
| 419 | function 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 |
|
|---|
| 428 | onMounted(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 |
|
|---|
| 450 | watch(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>
|
|---|