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>
This commit is contained in:
Claude Agent
2026-02-12 15:34:47 +00:00
parent a4d3f862d2
commit d245c72757
36 changed files with 5275 additions and 1569 deletions

View File

@@ -0,0 +1,417 @@
<template>
<Transition name="modal-fade">
<div v-if="show && booking" class="preview-overlay" @click.self="$emit('close')">
<div class="preview-modal" @keydown.escape="$emit('close')">
<button class="preview-close" @click="$emit('close')" title="Close">
<X :size="18" />
</button>
<div class="preview-header">
<h3>{{ booking.title }}</h3>
<span :class="['preview-badge', `preview-badge-${booking.status}`]">
{{ booking.status }}
</span>
</div>
<div class="preview-details">
<div class="detail-row">
<Building2 :size="16" class="detail-icon" />
<span>{{ booking.space?.name || 'Unknown Space' }}</span>
</div>
<div v-if="isAdmin && booking.user" class="detail-row">
<UserIcon :size="16" class="detail-icon" />
<span>
{{ booking.user.full_name }}
<span v-if="booking.user.organization" class="detail-muted">
&middot; {{ booking.user.organization }}
</span>
</span>
</div>
<div class="detail-row">
<CalendarDays :size="16" class="detail-icon" />
<span>{{ formatDate(booking.start_datetime) }}</span>
</div>
<div class="detail-row">
<Clock :size="16" class="detail-icon" />
<span>{{ formatTimeRange(booking.start_datetime, booking.end_datetime) }}</span>
</div>
</div>
<div v-if="booking.description" class="preview-description">
<p :class="{ truncated: !showFullDesc && isLongDesc }">
{{ booking.description }}
</p>
<button
v-if="isLongDesc"
class="show-more-btn"
@click="showFullDesc = !showFullDesc"
>
{{ showFullDesc ? 'Show less' : 'Show more' }}
</button>
</div>
<div v-if="actionButtons.length > 0" class="preview-actions">
<button
v-for="action in actionButtons"
:key="action.key"
:class="['preview-action-btn', `preview-action-${action.key}`]"
@click="$emit(action.key as any, booking)"
>
<component :is="action.icon" :size="16" />
{{ action.label }}
</button>
</div>
</div>
</div>
</Transition>
</template>
<script setup lang="ts">
import { ref, computed, watch, onMounted, onUnmounted, type Component } from 'vue'
import { X, Building2, User as UserIcon, CalendarDays, Clock, Check, XCircle, Pencil, Ban } from 'lucide-vue-next'
import { formatDate as formatDateUtil, formatTime as formatTimeUtil } from '@/utils/datetime'
import { useAuthStore } from '@/stores/auth'
import type { Booking } from '@/types'
const props = defineProps<{
booking: Booking | null
isAdmin: boolean
show: boolean
}>()
const emit = defineEmits<{
close: []
approve: [booking: Booking]
reject: [booking: Booking]
cancel: [booking: Booking]
edit: [booking: Booking]
}>()
const authStore = useAuthStore()
const userTimezone = computed(() => authStore.user?.timezone || 'UTC')
const showFullDesc = ref(false)
const DESC_MAX_LENGTH = 150
const isLongDesc = computed(() =>
(props.booking?.description?.length || 0) > DESC_MAX_LENGTH
)
// Reset show more when booking changes
watch(() => props.booking?.id, () => {
showFullDesc.value = false
})
const formatDate = (datetime: string): string =>
formatDateUtil(datetime, userTimezone.value)
const formatTimeRange = (start: string, end: string): string =>
`${formatTimeUtil(start, userTimezone.value)} ${formatTimeUtil(end, userTimezone.value)}`
interface ActionButton {
key: string
label: string
icon: Component
}
const actionButtons = computed<ActionButton[]>(() => {
if (!props.booking) return []
const status = props.booking.status
const buttons: ActionButton[] = []
if (props.isAdmin && status === 'pending') {
buttons.push({ key: 'approve', label: 'Approve', icon: Check })
buttons.push({ key: 'reject', label: 'Reject', icon: XCircle })
}
if (status === 'pending') {
buttons.push({ key: 'edit', label: 'Edit', icon: Pencil })
}
if (status === 'pending' || status === 'approved') {
buttons.push({ key: 'cancel', label: 'Cancel', icon: Ban })
}
return buttons
})
// ESC key handler
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape' && props.show) {
emit('close')
}
}
onMounted(() => {
document.addEventListener('keydown', handleKeyDown)
})
onUnmounted(() => {
document.removeEventListener('keydown', handleKeyDown)
})
</script>
<style scoped>
.preview-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.45);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.preview-modal {
background: var(--color-surface);
border-radius: var(--radius-lg);
padding: 24px;
max-width: 400px;
width: 90%;
box-shadow: var(--shadow-lg);
position: relative;
}
.preview-close {
position: absolute;
top: 12px;
right: 12px;
background: transparent;
border: none;
color: var(--color-text-muted);
cursor: pointer;
padding: 4px;
border-radius: var(--radius-sm);
display: flex;
align-items: center;
justify-content: center;
transition: all var(--transition-fast);
}
.preview-close:hover {
background: var(--color-bg-secondary);
color: var(--color-text-primary);
}
.preview-header {
display: flex;
align-items: flex-start;
gap: 10px;
margin-bottom: 16px;
padding-right: 28px;
}
.preview-header h3 {
margin: 0;
font-size: 18px;
font-weight: 600;
color: var(--color-text-primary);
flex: 1;
line-height: 1.3;
}
.preview-badge {
display: inline-flex;
align-items: center;
padding: 3px 10px;
font-size: 11px;
font-weight: 600;
border-radius: 10px;
text-transform: capitalize;
white-space: nowrap;
flex-shrink: 0;
}
.preview-badge-pending {
background: color-mix(in srgb, var(--color-warning) 15%, transparent);
color: var(--color-warning);
}
.preview-badge-approved {
background: color-mix(in srgb, var(--color-success) 15%, transparent);
color: var(--color-success);
}
.preview-badge-rejected {
background: color-mix(in srgb, var(--color-danger) 15%, transparent);
color: var(--color-danger);
}
.preview-badge-canceled {
background: var(--color-bg-tertiary);
color: var(--color-text-muted);
}
/* Details */
.preview-details {
display: flex;
flex-direction: column;
gap: 10px;
margin-bottom: 16px;
}
.detail-row {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
color: var(--color-text-primary);
}
.detail-icon {
color: var(--color-text-muted);
flex-shrink: 0;
}
.detail-muted {
color: var(--color-text-muted);
}
/* Description */
.preview-description {
background: var(--color-bg-secondary);
border-radius: var(--radius-sm);
padding: 12px;
margin-bottom: 16px;
}
.preview-description p {
margin: 0;
font-size: 13px;
color: var(--color-text-secondary);
line-height: 1.5;
white-space: pre-wrap;
}
.preview-description p.truncated {
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
.show-more-btn {
margin-top: 6px;
padding: 0;
background: none;
border: none;
color: var(--color-accent);
font-size: 12px;
font-weight: 500;
cursor: pointer;
}
.show-more-btn:hover {
text-decoration: underline;
}
/* Actions */
.preview-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.preview-action-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 14px;
border-radius: var(--radius-sm);
border: 1px solid transparent;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: all var(--transition-fast);
font-family: inherit;
}
.preview-action-approve {
background: color-mix(in srgb, var(--color-success) 12%, transparent);
color: var(--color-success);
border-color: color-mix(in srgb, var(--color-success) 25%, transparent);
}
.preview-action-approve:hover {
background: var(--color-success);
color: #fff;
}
.preview-action-reject {
background: color-mix(in srgb, var(--color-danger) 12%, transparent);
color: var(--color-danger);
border-color: color-mix(in srgb, var(--color-danger) 25%, transparent);
}
.preview-action-reject:hover {
background: var(--color-danger);
color: #fff;
}
.preview-action-edit {
background: color-mix(in srgb, var(--color-warning) 12%, transparent);
color: var(--color-warning);
border-color: color-mix(in srgb, var(--color-warning) 25%, transparent);
}
.preview-action-edit:hover {
background: var(--color-warning);
color: #fff;
}
.preview-action-cancel {
background: var(--color-bg-tertiary);
color: var(--color-text-secondary);
border-color: var(--color-border);
}
.preview-action-cancel:hover {
background: var(--color-border);
color: var(--color-text-primary);
}
/* Modal transition */
.modal-fade-enter-active,
.modal-fade-leave-active {
transition: opacity 0.2s ease;
}
.modal-fade-enter-active .preview-modal,
.modal-fade-leave-active .preview-modal {
transition: transform 0.2s ease, opacity 0.2s ease;
}
.modal-fade-enter-from,
.modal-fade-leave-to {
opacity: 0;
}
.modal-fade-enter-from .preview-modal,
.modal-fade-leave-to .preview-modal {
transform: scale(0.95);
opacity: 0;
}
/* Mobile */
@media (max-width: 640px) {
.preview-modal {
max-width: none;
width: calc(100% - 32px);
margin: 16px;
}
.preview-actions {
flex-direction: column;
}
.preview-action-btn {
justify-content: center;
}
}
</style>