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,217 @@
<template>
<div class="booking-row" :class="[`booking-row-${booking.status}`]">
<div class="row-time">
{{ formatTimeRange(booking.start_datetime, booking.end_datetime) }}
</div>
<div class="row-space">{{ booking.space?.name || 'Space' }}</div>
<div v-if="showUser && booking.user" class="row-user">
{{ booking.user.full_name }}
</div>
<div class="row-title" :title="booking.title">{{ booking.title }}</div>
<span :class="['row-badge', `row-badge-${booking.status}`]">
{{ statusLabel }}
</span>
<ActionMenu
v-if="rowActions.length > 0"
:actions="rowActions"
@select="handleAction"
/>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { Check, XCircle, Pencil, Ban } from 'lucide-vue-next'
import { formatTime as formatTimeUtil } from '@/utils/datetime'
import { useAuthStore } from '@/stores/auth'
import ActionMenu from '@/components/ActionMenu.vue'
import type { ActionItem } from '@/components/ActionMenu.vue'
import type { Booking } from '@/types'
const props = defineProps<{
booking: Booking
isAdmin: boolean
showUser?: boolean
}>()
const emit = defineEmits<{
approve: [booking: Booking]
reject: [booking: Booking]
cancel: [booking: Booking]
edit: [booking: Booking]
}>()
const authStore = useAuthStore()
const userTimezone = computed(() => authStore.user?.timezone || 'UTC')
const STATUS_LABELS: Record<string, string> = {
pending: 'Pending',
approved: 'Approved',
rejected: 'Rejected',
canceled: 'Canceled'
}
const statusLabel = computed(() => STATUS_LABELS[props.booking.status] || props.booking.status)
const rowActions = computed<ActionItem[]>(() => {
const actions: ActionItem[] = []
const status = props.booking.status
if (props.isAdmin && status === 'pending') {
actions.push({ key: 'approve', label: 'Approve', icon: Check, color: 'var(--color-success)' })
actions.push({ key: 'reject', label: 'Reject', icon: XCircle, color: 'var(--color-danger)' })
}
if (status === 'pending' || status === 'approved') {
actions.push({ key: 'edit', label: 'Edit', icon: Pencil })
}
if (status === 'pending' || status === 'approved') {
actions.push({ key: 'cancel', label: 'Cancel', icon: Ban, color: 'var(--color-danger)' })
}
return actions
})
const handleAction = (key: string) => {
switch (key) {
case 'approve': emit('approve', props.booking); break
case 'reject': emit('reject', props.booking); break
case 'edit': emit('edit', props.booking); break
case 'cancel': emit('cancel', props.booking); break
}
}
const formatTimeRange = (start: string, end: string): string => {
return `${formatTimeUtil(start, userTimezone.value)}${formatTimeUtil(end, userTimezone.value)}`
}
</script>
<style scoped>
.booking-row {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 14px;
min-height: 44px;
background: var(--color-bg-secondary);
border-radius: var(--radius-sm);
border-left: 3px solid transparent;
transition: background var(--transition-fast);
}
.booking-row:hover {
background: var(--color-bg-tertiary);
}
.booking-row-pending {
border-left-color: var(--color-warning);
}
.booking-row-approved {
border-left-color: var(--color-success);
}
.booking-row-rejected {
border-left-color: var(--color-danger);
opacity: 0.7;
}
.booking-row-canceled {
border-left-color: var(--color-text-muted);
opacity: 0.7;
}
.row-time {
font-size: 13px;
font-weight: 600;
color: var(--color-text-primary);
white-space: nowrap;
min-width: 100px;
}
.row-space {
font-size: 13px;
font-weight: 500;
color: var(--color-accent);
white-space: nowrap;
min-width: 80px;
}
.row-user {
font-size: 13px;
color: var(--color-text-secondary);
white-space: nowrap;
min-width: 80px;
max-width: 120px;
overflow: hidden;
text-overflow: ellipsis;
}
.row-title {
flex: 1;
font-size: 13px;
color: var(--color-text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.row-badge {
display: inline-flex;
align-items: center;
padding: 2px 10px;
font-size: 11px;
font-weight: 600;
border-radius: 10px;
white-space: nowrap;
flex-shrink: 0;
}
.row-badge-pending {
background: color-mix(in srgb, var(--color-warning) 15%, transparent);
color: var(--color-warning);
}
.row-badge-approved {
background: color-mix(in srgb, var(--color-success) 15%, transparent);
color: var(--color-success);
}
.row-badge-rejected {
background: color-mix(in srgb, var(--color-danger) 15%, transparent);
color: var(--color-danger);
}
.row-badge-canceled {
background: color-mix(in srgb, var(--color-text-muted) 15%, transparent);
color: var(--color-text-muted);
}
@media (max-width: 640px) {
.booking-row {
flex-wrap: wrap;
gap: 6px 10px;
padding: 10px 12px;
}
.row-time {
min-width: auto;
}
.row-space {
min-width: auto;
}
.row-user {
min-width: auto;
max-width: none;
}
.row-title {
flex-basis: 100%;
order: 10;
}
}
</style>