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,329 @@
<template>
<Transition name="modal-fade">
<div v-if="show && booking" class="edit-overlay" @click.self="$emit('close')">
<div class="edit-modal">
<h3>Edit Booking</h3>
<form @submit.prevent="saveEdit">
<div class="form-group">
<label for="edit-space">Space</label>
<input
id="edit-space"
type="text"
:value="booking.space?.name || 'Unknown'"
readonly
disabled
/>
</div>
<div class="form-group">
<label for="edit-title">Title *</label>
<input
id="edit-title"
v-model="editForm.title"
type="text"
required
maxlength="200"
placeholder="Booking title"
/>
</div>
<div class="form-group">
<label for="edit-description">Description (optional)</label>
<textarea
id="edit-description"
v-model="editForm.description"
rows="3"
placeholder="Additional details..."
/>
</div>
<div class="form-group">
<label>Start *</label>
<div class="datetime-row">
<div class="datetime-field">
<label for="edit-start-date" class="sublabel">Date</label>
<input id="edit-start-date" v-model="editForm.start_date" type="date" required />
</div>
<div class="datetime-field">
<label for="edit-start-time" class="sublabel">Time</label>
<input id="edit-start-time" v-model="editForm.start_time" type="time" required />
</div>
</div>
</div>
<div class="form-group">
<label>End *</label>
<div class="datetime-row">
<div class="datetime-field">
<label for="edit-end-date" class="sublabel">Date</label>
<input id="edit-end-date" v-model="editForm.end_date" type="date" required />
</div>
<div class="datetime-field">
<label for="edit-end-time" class="sublabel">Time</label>
<input id="edit-end-time" v-model="editForm.end_time" type="time" required />
</div>
</div>
</div>
<div v-if="editError" class="error-msg">{{ editError }}</div>
<div class="form-actions">
<button type="button" class="btn btn-secondary" @click="$emit('close')">Cancel</button>
<button type="submit" class="btn btn-primary" :disabled="saving">
{{ saving ? 'Saving...' : 'Save Changes' }}
</button>
</div>
</form>
</div>
</div>
</Transition>
</template>
<script setup lang="ts">
import { ref, watch, computed } from 'vue'
import { bookingsApi, handleApiError } from '@/services/api'
import { useAuthStore } from '@/stores/auth'
import { isoToLocalDateTime, localDateTimeToISO } from '@/utils/datetime'
import type { Booking } from '@/types'
const props = defineProps<{
booking: Booking | null
show: boolean
}>()
const emit = defineEmits<{
close: []
saved: []
}>()
const authStore = useAuthStore()
const userTimezone = computed(() => authStore.user?.timezone || 'UTC')
const editForm = ref({
title: '',
description: '',
start_date: '',
start_time: '',
end_date: '',
end_time: ''
})
const editError = ref('')
const saving = ref(false)
// Populate form when booking changes or modal opens
watch(() => [props.booking, props.show], () => {
if (props.show && props.booking) {
const startLocal = isoToLocalDateTime(props.booking.start_datetime, userTimezone.value)
const endLocal = isoToLocalDateTime(props.booking.end_datetime, userTimezone.value)
const [startDate, startTime] = startLocal.split('T')
const [endDate, endTime] = endLocal.split('T')
editForm.value = {
title: props.booking.title,
description: props.booking.description || '',
start_date: startDate,
start_time: startTime,
end_date: endDate,
end_time: endTime
}
editError.value = ''
}
}, { immediate: true })
const saveEdit = async () => {
if (!props.booking) return
saving.value = true
editError.value = ''
try {
const startDateTime = `${editForm.value.start_date}T${editForm.value.start_time}`
const endDateTime = `${editForm.value.end_date}T${editForm.value.end_time}`
await bookingsApi.update(props.booking.id, {
title: editForm.value.title,
description: editForm.value.description,
start_datetime: localDateTimeToISO(startDateTime),
end_datetime: localDateTimeToISO(endDateTime)
})
emit('saved')
} catch (err) {
editError.value = handleApiError(err)
} finally {
saving.value = false
}
}
</script>
<style scoped>
.edit-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;
}
.edit-modal {
background: var(--color-surface);
border-radius: var(--radius-lg);
padding: 24px;
max-width: 500px;
width: 90%;
max-height: 90vh;
overflow-y: auto;
box-shadow: var(--shadow-lg);
}
.edit-modal h3 {
margin: 0 0 20px;
font-size: 20px;
font-weight: 600;
color: var(--color-text-primary);
}
.form-group {
margin-bottom: 16px;
}
.form-group > label {
display: block;
margin-bottom: 6px;
font-weight: 500;
font-size: 14px;
color: var(--color-text-primary);
}
.sublabel {
display: block;
margin-bottom: 4px;
font-weight: 400;
font-size: 12px;
color: var(--color-text-secondary);
}
.datetime-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.datetime-field {
display: flex;
flex-direction: column;
}
.form-group input,
.form-group textarea {
width: 100%;
padding: 8px 12px;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
font-size: 14px;
font-family: inherit;
background: var(--color-surface);
color: var(--color-text-primary);
box-sizing: border-box;
}
.form-group input:focus,
.form-group textarea:focus {
outline: none;
border-color: var(--color-accent);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-accent) 15%, transparent);
}
.form-group input:disabled {
background: var(--color-bg-tertiary);
cursor: not-allowed;
}
.form-group textarea {
resize: vertical;
}
.error-msg {
padding: 12px;
background: color-mix(in srgb, var(--color-danger) 10%, transparent);
color: var(--color-danger);
border-radius: var(--radius-sm);
margin-bottom: 16px;
font-size: 14px;
}
.form-actions {
display: flex;
gap: 12px;
justify-content: flex-end;
}
.btn {
padding: 10px 20px;
border: none;
border-radius: var(--radius-sm);
font-size: 14px;
font-weight: 500;
font-family: inherit;
cursor: pointer;
transition: all var(--transition-fast);
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-primary {
background: var(--color-accent);
color: white;
}
.btn-primary:hover:not(:disabled) {
background: var(--color-accent-hover);
}
.btn-secondary {
background: var(--color-bg-tertiary);
color: var(--color-text-primary);
}
.btn-secondary:hover:not(:disabled) {
background: var(--color-border);
}
/* Modal transition */
.modal-fade-enter-active,
.modal-fade-leave-active {
transition: opacity 0.2s ease;
}
.modal-fade-enter-active .edit-modal,
.modal-fade-leave-active .edit-modal {
transition: transform 0.2s ease, opacity 0.2s ease;
}
.modal-fade-enter-from,
.modal-fade-leave-to {
opacity: 0;
}
.modal-fade-enter-from .edit-modal,
.modal-fade-leave-to .edit-modal {
transform: scale(0.95);
opacity: 0;
}
@media (max-width: 640px) {
.edit-modal {
max-width: none;
width: calc(100% - 32px);
margin: 16px;
}
}
</style>