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:
217
frontend/src/components/BookingRow.vue
Normal file
217
frontend/src/components/BookingRow.vue
Normal 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>
|
||||
Reference in New Issue
Block a user