Files
space-booking/frontend/src/components/DashboardCalendar.vue
Claude Agent d245c72757 feat: complete UI/UX overhaul - dashboard unification, calendar UX, mobile optimization
- Dashboard redesign as command center with filters, quick actions, inline approve/reject
- Reusable components: BookingRow, BookingFilters, ActionMenu, BookingPreviewModal, BookingEditModal
- Calendar: drag & drop reschedule, eventClick preview modal, grid/list toggle
- Mobile: segmented control bookings/calendar toggle, compact pills, responsive layout
- Collapsible filters with active count badge
- Smart menu positioning with Teleport
- Calendar/list bidirectional data sync
- Navigation: unified History page, removed AdminPending
- Google Calendar OAuth integration
- Dark mode contrast improvements, breadcrumb navigation
- useLocalStorage composable for state persistence

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 15:34:47 +00:00

607 lines
15 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="dashboard-calendar">
<div v-if="error" class="error">{{ error }}</div>
<div class="calendar-wrapper" :class="{ 'calendar-loading': loading }">
<FullCalendar ref="calendarRef" :options="calendarOptions" />
<div v-if="loading && !confirmModal.show" class="loading-overlay">Loading calendar...</div>
</div>
<!-- Reschedule Confirmation Modal -->
<div v-if="confirmModal.show" class="modal-overlay" @click.self="cancelReschedule">
<div class="modal-content">
<h3>Confirm Reschedule</h3>
<p>Reschedule this booking?</p>
<div class="time-comparison">
<div class="old-time">
<strong>Old Time:</strong><br />
{{ formatModalDateTime(confirmModal.oldStart) }} {{ formatModalDateTime(confirmModal.oldEnd) }}
</div>
<div class="arrow">&rarr;</div>
<div class="new-time">
<strong>New Time:</strong><br />
{{ formatModalDateTime(confirmModal.newStart) }} {{ formatModalDateTime(confirmModal.newEnd) }}
</div>
</div>
<div class="modal-actions">
<button @click="confirmReschedule" :disabled="modalLoading" class="btn-primary">
{{ modalLoading ? 'Saving...' : 'Confirm' }}
</button>
<button @click="cancelReschedule" :disabled="modalLoading" class="btn-secondary">
Cancel
</button>
</div>
</div>
</div>
<!-- Booking Preview Modal -->
<BookingPreviewModal
:booking="selectedBooking"
:is-admin="isAdmin"
:show="showPreview"
@close="showPreview = false"
@approve="handlePreviewApprove"
@reject="handlePreviewReject"
@cancel="handlePreviewCancel"
@edit="handlePreviewEdit"
/>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch, nextTick } from 'vue'
import FullCalendar from '@fullcalendar/vue3'
import dayGridPlugin from '@fullcalendar/daygrid'
import timeGridPlugin from '@fullcalendar/timegrid'
import listPlugin from '@fullcalendar/list'
import interactionPlugin from '@fullcalendar/interaction'
import type { CalendarOptions, EventInput, DatesSetArg, EventDropArg } from '@fullcalendar/core'
import { bookingsApi, adminBookingsApi, handleApiError } from '@/services/api'
import { useAuthStore } from '@/stores/auth'
import { useIsMobile } from '@/composables/useMediaQuery'
import { formatDateTime as formatDateTimeUtil } from '@/utils/datetime'
import BookingPreviewModal from '@/components/BookingPreviewModal.vue'
import type { Booking } from '@/types'
const props = withDefaults(defineProps<{
viewMode?: 'grid' | 'list'
}>(), {
viewMode: 'grid'
})
const emit = defineEmits<{
approve: [booking: Booking]
reject: [booking: Booking]
cancel: [booking: Booking]
edit: [booking: Booking]
changed: []
}>()
const authStore = useAuthStore()
const isMobile = useIsMobile()
const isAdmin = computed(() => authStore.user?.role === 'admin')
const isEditable = computed(() => isAdmin.value)
const userTimezone = computed(() => authStore.user?.timezone || 'UTC')
const bookings = ref<Booking[]>([])
const loading = ref(true)
const initialLoad = ref(true)
const modalLoading = ref(false)
const error = ref('')
const calendarRef = ref<InstanceType<typeof FullCalendar> | null>(null)
// Preview modal state
const selectedBooking = ref<Booking | null>(null)
const showPreview = ref(false)
// Reschedule confirmation modal
interface ConfirmModal {
show: boolean
booking: any
oldStart: Date | null
oldEnd: Date | null
newStart: Date | null
newEnd: Date | null
revertFunc: (() => void) | null
}
const confirmModal = ref<ConfirmModal>({
show: false,
booking: null,
oldStart: null,
oldEnd: null,
newStart: null,
newEnd: null,
revertFunc: null
})
const STATUS_COLORS: Record<string, string> = {
pending: '#FFA500',
approved: '#4CAF50',
rejected: '#F44336',
canceled: '#9E9E9E'
}
const events = computed<EventInput[]>(() => {
return bookings.value.map((booking) => ({
id: String(booking.id),
title: booking.space?.name ? `${booking.space.name} - ${booking.title}` : booking.title,
start: booking.start_datetime,
end: booking.end_datetime,
backgroundColor: STATUS_COLORS[booking.status] || '#9E9E9E',
borderColor: STATUS_COLORS[booking.status] || '#9E9E9E',
extendedProps: {
status: booking.status,
description: booking.description
}
}))
})
let currentStart: Date | null = null
let currentEnd: Date | null = null
const loadBookings = async (start: Date, end: Date) => {
currentStart = start
currentEnd = end
error.value = ''
try {
const startStr = start.toISOString()
const endStr = end.toISOString()
if (isAdmin.value) {
bookings.value = await adminBookingsApi.getAll({
start: startStr,
limit: 100
})
} else {
bookings.value = await bookingsApi.getMyCalendar(startStr, endStr)
}
} catch (err) {
error.value = handleApiError(err)
} finally {
if (initialLoad.value) {
loading.value = false
initialLoad.value = false
}
}
}
const handleDatesSet = (arg: DatesSetArg) => {
loadBookings(arg.start, arg.end)
}
// Event click → open preview modal
const handleEventClick = (info: any) => {
const bookingId = parseInt(info.event.id)
const booking = bookings.value.find((b) => b.id === bookingId)
if (booking) {
selectedBooking.value = booking
showPreview.value = true
}
}
// Drag & drop handlers
const handleEventDrop = (info: EventDropArg) => {
confirmModal.value = {
show: true,
booking: info.event,
oldStart: info.oldEvent.start,
oldEnd: info.oldEvent.end,
newStart: info.event.start,
newEnd: info.event.end,
revertFunc: info.revert
}
}
const handleEventResize = (info: any) => {
confirmModal.value = {
show: true,
booking: info.event,
oldStart: info.oldEvent.start,
oldEnd: info.oldEvent.end,
newStart: info.event.start,
newEnd: info.event.end,
revertFunc: info.revert
}
}
const confirmReschedule = async () => {
if (!confirmModal.value.newStart || !confirmModal.value.newEnd) return
try {
modalLoading.value = true
await adminBookingsApi.reschedule(parseInt(confirmModal.value.booking.id), {
start_datetime: confirmModal.value.newStart.toISOString(),
end_datetime: confirmModal.value.newEnd.toISOString()
})
// Reload bookings for the full calendar view range, not just the event's old/new range
if (currentStart && currentEnd) {
await loadBookings(currentStart, currentEnd)
}
confirmModal.value.show = false
emit('changed')
} catch (err: any) {
if (confirmModal.value.revertFunc) {
confirmModal.value.revertFunc()
}
error.value = err.response?.data?.detail || 'Failed to reschedule booking'
setTimeout(() => { error.value = '' }, 5000)
confirmModal.value.show = false
} finally {
modalLoading.value = false
}
}
const cancelReschedule = () => {
if (confirmModal.value.revertFunc) {
confirmModal.value.revertFunc()
}
confirmModal.value.show = false
}
const formatModalDateTime = (date: Date | null) => {
if (!date) return ''
return formatDateTimeUtil(date.toISOString(), userTimezone.value)
}
// Preview modal action handlers
const handlePreviewApprove = (booking: Booking) => {
showPreview.value = false
emit('approve', booking)
}
const handlePreviewReject = (booking: Booking) => {
showPreview.value = false
emit('reject', booking)
}
const handlePreviewCancel = (booking: Booking) => {
showPreview.value = false
emit('cancel', booking)
}
const handlePreviewEdit = (booking: Booking) => {
showPreview.value = false
emit('edit', booking)
}
// Stable callback references (avoid new functions on every computed recompute)
const handleEventDidMount = (info: any) => {
if (info.event.extendedProps.status === 'approved' && isEditable.value) {
info.el.style.cursor = 'move'
} else {
info.el.style.cursor = 'pointer'
}
}
const handleEventAllow = (_dropInfo: any, draggedEvent: any) => {
return draggedEvent?.extendedProps?.status === 'approved'
}
// Resolve initial view from props (not reactive - only used at init)
const resolveDesktopView = (mode: 'grid' | 'list') =>
mode === 'list' ? 'listMonth' : 'dayGridMonth'
const calendarOptions = computed<CalendarOptions>(() => ({
plugins: [dayGridPlugin, timeGridPlugin, listPlugin, interactionPlugin],
initialView: isMobile.value ? 'listWeek' : resolveDesktopView(props.viewMode),
headerToolbar: isMobile.value
? { left: 'prev,next', center: 'title', right: 'listWeek,dayGridMonth' }
: { left: 'prev,next today', center: 'title', right: 'dayGridMonth,timeGridWeek' },
timeZone: userTimezone.value,
firstDay: 1,
events: events.value,
datesSet: handleDatesSet,
editable: isEditable.value,
eventStartEditable: isEditable.value,
eventDurationEditable: isEditable.value,
selectable: false,
dayMaxEvents: true,
height: 'auto',
noEventsText: 'No bookings this period',
eventTimeFormat: {
hour: '2-digit',
minute: '2-digit',
hour12: false
},
slotLabelFormat: {
hour: '2-digit',
minute: '2-digit',
hour12: false
},
eventClick: handleEventClick,
eventDrop: handleEventDrop,
eventResize: handleEventResize,
eventDidMount: handleEventDidMount,
eventAllow: handleEventAllow
}))
// Switch view dynamically when screen size changes
watch(isMobile, (mobile) => {
const calendarApi = calendarRef.value?.getApi()
if (calendarApi) {
calendarApi.changeView(mobile ? 'listWeek' : 'dayGridMonth')
nextTick(() => calendarApi.updateSize())
}
})
// Switch view when viewMode prop changes (desktop toggle)
watch(() => props.viewMode, (newView) => {
if (isMobile.value) return
const calendarApi = calendarRef.value?.getApi()
if (calendarApi) {
calendarApi.changeView(newView === 'list' ? 'listMonth' : 'dayGridMonth')
nextTick(() => calendarApi.updateSize())
}
})
const refresh = () => {
if (currentStart && currentEnd) {
loadBookings(currentStart, currentEnd)
} else {
const now = new Date()
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1)
const endOfMonth = new Date(now.getFullYear(), now.getMonth() + 1, 0)
loadBookings(startOfMonth, endOfMonth)
}
}
defineExpose({ refresh })
</script>
<style scoped>
.dashboard-calendar {
background: var(--color-surface);
border-radius: var(--radius-md);
padding: 24px;
}
.error {
padding: 12px;
background: color-mix(in srgb, var(--color-danger) 10%, transparent);
color: var(--color-danger);
border-radius: var(--radius-sm);
margin-bottom: 16px;
}
.calendar-wrapper {
position: relative;
min-height: 200px;
}
.loading-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
background: var(--color-surface);
color: var(--color-text-secondary);
z-index: 10;
border-radius: var(--radius-md);
}
/* Reschedule Modal */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal-content {
background: var(--color-surface);
padding: 24px;
border-radius: var(--radius-md);
max-width: 500px;
width: 90%;
box-shadow: var(--shadow-lg);
}
.modal-content h3 {
margin-top: 0;
margin-bottom: 16px;
color: var(--color-text-primary);
}
.modal-content p {
margin-bottom: 20px;
color: var(--color-text-secondary);
}
.time-comparison {
display: flex;
align-items: center;
gap: 16px;
margin: 20px 0;
padding: 16px;
background: var(--color-bg-secondary);
border-radius: var(--radius-sm);
}
.old-time,
.new-time {
flex: 1;
font-size: 14px;
color: var(--color-text-secondary);
}
.old-time strong,
.new-time strong {
color: var(--color-text-primary);
display: block;
margin-bottom: 4px;
}
.arrow {
font-size: 24px;
color: var(--color-text-muted);
}
.modal-actions {
display: flex;
gap: 12px;
justify-content: flex-end;
margin-top: 24px;
}
.btn-primary {
background: var(--color-accent);
color: white;
border: none;
padding: 10px 20px;
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 14px;
font-weight: 500;
font-family: inherit;
transition: background var(--transition-fast);
}
.btn-primary:hover:not(:disabled) {
background: var(--color-accent-hover);
}
.btn-primary:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.btn-secondary {
background: var(--color-bg-tertiary);
color: var(--color-text-primary);
border: 1px solid var(--color-border);
padding: 10px 20px;
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 14px;
font-weight: 500;
font-family: inherit;
transition: background var(--transition-fast);
}
.btn-secondary:hover:not(:disabled) {
background: var(--color-border);
}
.btn-secondary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* FullCalendar custom styles */
:deep(.fc) {
font-family: inherit;
}
:deep(.fc-button) {
background: var(--color-accent);
border-color: var(--color-accent);
text-transform: capitalize;
}
:deep(.fc-button:hover) {
background: var(--color-accent-hover);
border-color: var(--color-accent-hover);
}
:deep(.fc-button-active) {
background: var(--color-accent-hover) !important;
border-color: var(--color-accent-hover) !important;
}
:deep(.fc-daygrid-day-number) {
color: var(--color-text-primary);
font-weight: 500;
}
:deep(.fc-col-header-cell-cushion) {
color: var(--color-text-primary);
font-weight: 600;
}
:deep(.fc-event) {
cursor: pointer;
}
:deep(.fc-event-title) {
font-weight: 500;
}
:deep(.fc-event.fc-draggable) {
cursor: move;
}
/* List view theming */
:deep(.fc-list) {
border-color: var(--color-border);
}
:deep(.fc-list-day-cushion) {
background: var(--color-bg-secondary);
color: var(--color-text-primary);
}
:deep(.fc-list-event td) {
border-color: var(--color-border);
color: var(--color-text-primary);
}
:deep(.fc-list-event:hover td) {
background: var(--color-bg-tertiary);
}
:deep(.fc-list-empty-cushion) {
color: var(--color-text-muted);
}
/* Mobile optimizations */
@media (max-width: 768px) {
.dashboard-calendar {
padding: 12px;
}
:deep(.fc .fc-toolbar) {
flex-direction: column;
gap: 8px;
align-items: stretch !important;
}
:deep(.fc .fc-toolbar-chunk) {
display: flex;
justify-content: center;
}
:deep(.fc .fc-toolbar-title) {
font-size: 1rem;
margin: 0;
}
:deep(.fc .fc-button) {
padding: 4px 8px;
font-size: 0.75rem;
}
.time-comparison {
flex-direction: column;
gap: 8px;
}
.arrow {
transform: rotate(90deg);
}
}
</style>