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:
224
frontend/src/components/ActionMenu.vue
Normal file
224
frontend/src/components/ActionMenu.vue
Normal file
@@ -0,0 +1,224 @@
|
||||
<template>
|
||||
<div class="action-menu" ref="menuRef">
|
||||
<button
|
||||
class="action-menu-trigger"
|
||||
ref="triggerRef"
|
||||
@click.stop="toggleMenu"
|
||||
title="Actions"
|
||||
>
|
||||
<MoreVertical :size="16" />
|
||||
</button>
|
||||
|
||||
<Teleport to="body">
|
||||
<Transition name="menu-fade">
|
||||
<div
|
||||
v-if="open"
|
||||
class="action-menu-dropdown"
|
||||
:class="{ upward: openUpward }"
|
||||
:style="dropdownStyle"
|
||||
ref="dropdownRef"
|
||||
@click.stop
|
||||
>
|
||||
<button
|
||||
v-for="action in visibleActions"
|
||||
:key="action.key"
|
||||
class="action-menu-item"
|
||||
:style="action.color ? { color: action.color } : {}"
|
||||
@click.stop="selectAction(action.key)"
|
||||
>
|
||||
<component :is="action.icon" :size="15" />
|
||||
<span>{{ action.label }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted, type Component } from 'vue'
|
||||
import { MoreVertical } from 'lucide-vue-next'
|
||||
|
||||
export interface ActionItem {
|
||||
key: string
|
||||
label: string
|
||||
icon: Component
|
||||
color?: string
|
||||
show?: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
actions: ActionItem[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: [key: string]
|
||||
}>()
|
||||
|
||||
const open = ref(false)
|
||||
const openUpward = ref(false)
|
||||
const menuRef = ref<HTMLElement | null>(null)
|
||||
const triggerRef = ref<HTMLElement | null>(null)
|
||||
const dropdownRef = ref<HTMLElement | null>(null)
|
||||
const dropdownStyle = ref<Record<string, string>>({})
|
||||
|
||||
const visibleActions = computed(() =>
|
||||
props.actions.filter((a) => a.show !== false)
|
||||
)
|
||||
|
||||
const updateDropdownPosition = () => {
|
||||
if (!triggerRef.value) return
|
||||
|
||||
const rect = triggerRef.value.getBoundingClientRect()
|
||||
const menuHeight = 200 // estimated max dropdown height
|
||||
const spaceBelow = window.innerHeight - rect.bottom
|
||||
const shouldOpenUp = spaceBelow < menuHeight && rect.top > spaceBelow
|
||||
|
||||
openUpward.value = shouldOpenUp
|
||||
|
||||
if (shouldOpenUp) {
|
||||
dropdownStyle.value = {
|
||||
position: 'fixed',
|
||||
bottom: `${window.innerHeight - rect.top + 4}px`,
|
||||
right: `${window.innerWidth - rect.right}px`
|
||||
}
|
||||
} else {
|
||||
dropdownStyle.value = {
|
||||
position: 'fixed',
|
||||
top: `${rect.bottom + 4}px`,
|
||||
right: `${window.innerWidth - rect.right}px`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const toggleMenu = () => {
|
||||
const wasOpen = open.value
|
||||
// Close all other open menus first
|
||||
document.dispatchEvent(new CustomEvent('action-menu:close-all'))
|
||||
if (!wasOpen) {
|
||||
updateDropdownPosition()
|
||||
open.value = true
|
||||
}
|
||||
}
|
||||
|
||||
const selectAction = (key: string) => {
|
||||
open.value = false
|
||||
emit('select', key)
|
||||
}
|
||||
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (!open.value) return
|
||||
const target = e.target as Node
|
||||
const inMenu = menuRef.value?.contains(target)
|
||||
const inDropdown = dropdownRef.value?.contains(target)
|
||||
if (!inMenu && !inDropdown) {
|
||||
open.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && open.value) {
|
||||
open.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleCloseAll = () => {
|
||||
open.value = false
|
||||
}
|
||||
|
||||
const handleScroll = () => {
|
||||
if (open.value) {
|
||||
open.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('click', handleClickOutside)
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
document.addEventListener('action-menu:close-all', handleCloseAll)
|
||||
window.addEventListener('scroll', handleScroll, true)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('click', handleClickOutside)
|
||||
document.removeEventListener('keydown', handleKeyDown)
|
||||
document.removeEventListener('action-menu:close-all', handleCloseAll)
|
||||
window.removeEventListener('scroll', handleScroll, true)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/* Not scoped - dropdown is teleported to body */
|
||||
.action-menu {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.action-menu-trigger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.action-menu-trigger:hover {
|
||||
background: var(--color-bg-tertiary);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.action-menu-dropdown {
|
||||
min-width: 160px;
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-lg);
|
||||
z-index: 9999;
|
||||
overflow: hidden;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.action-menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--color-text-primary);
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
transition: background var(--transition-fast);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.action-menu-item:hover {
|
||||
background: var(--color-bg-secondary);
|
||||
}
|
||||
|
||||
/* Transition */
|
||||
.menu-fade-enter-active,
|
||||
.menu-fade-leave-active {
|
||||
transition: opacity 0.15s ease, transform 0.15s ease;
|
||||
}
|
||||
|
||||
.menu-fade-enter-from,
|
||||
.menu-fade-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
|
||||
.upward.menu-fade-enter-from,
|
||||
.upward.menu-fade-leave-to {
|
||||
transform: translateY(4px);
|
||||
}
|
||||
</style>
|
||||
@@ -26,6 +26,26 @@
|
||||
:style="{ width: getProgress(booking.start_datetime, booking.end_datetime) + '%' }"
|
||||
/>
|
||||
</div>
|
||||
<div class="active-actions">
|
||||
<router-link :to="`/history`" class="action-btn action-btn-view" title="View bookings">
|
||||
<Eye :size="16" />
|
||||
</router-link>
|
||||
<button
|
||||
v-if="booking.status === 'pending'"
|
||||
class="action-btn action-btn-edit"
|
||||
title="Edit booking"
|
||||
@click="$emit('refresh')"
|
||||
>
|
||||
<Pencil :size="16" />
|
||||
</button>
|
||||
<button
|
||||
class="action-btn action-btn-cancel"
|
||||
title="Cancel booking"
|
||||
@click="$emit('cancel', booking)"
|
||||
>
|
||||
<X :size="16" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -33,7 +53,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, onMounted, onUnmounted } from 'vue'
|
||||
import { Zap } from 'lucide-vue-next'
|
||||
import { Zap, Eye, Pencil, X } from 'lucide-vue-next'
|
||||
import { isBookingActive, getBookingProgress, formatRemainingTime } from '@/utils/datetime'
|
||||
import { formatTime as formatTimeUtil } from '@/utils/datetime'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
@@ -43,6 +63,11 @@ const props = defineProps<{
|
||||
bookings: Booking[]
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
cancel: [booking: Booking]
|
||||
refresh: []
|
||||
}>()
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const userTimezone = computed(() => authStore.user?.timezone || 'UTC')
|
||||
|
||||
@@ -175,6 +200,50 @@ onUnmounted(() => {
|
||||
transition: width 1s ease;
|
||||
}
|
||||
|
||||
.active-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.action-btn-view:hover {
|
||||
color: var(--color-info);
|
||||
border-color: var(--color-info);
|
||||
background: color-mix(in srgb, var(--color-info) 10%, transparent);
|
||||
}
|
||||
|
||||
.action-btn-edit:hover {
|
||||
color: var(--color-warning);
|
||||
border-color: var(--color-warning);
|
||||
background: color-mix(in srgb, var(--color-warning) 10%, transparent);
|
||||
}
|
||||
|
||||
.action-btn-cancel:hover {
|
||||
color: var(--color-danger);
|
||||
border-color: var(--color-danger);
|
||||
background: color-mix(in srgb, var(--color-danger) 10%, transparent);
|
||||
}
|
||||
|
||||
/* Mobile responsive */
|
||||
@media (max-width: 768px) {
|
||||
.active-card-top {
|
||||
|
||||
460
frontend/src/components/AdminBookingForm.vue
Normal file
460
frontend/src/components/AdminBookingForm.vue
Normal file
@@ -0,0 +1,460 @@
|
||||
<template>
|
||||
<div class="admin-booking-form">
|
||||
<div class="admin-banner">
|
||||
Admin Direct Booking — will be approved immediately
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="handleSubmit">
|
||||
<!-- User Selection -->
|
||||
<div class="form-group">
|
||||
<label for="admin-user" class="form-label">Book for User *</label>
|
||||
<select
|
||||
id="admin-user"
|
||||
v-model="formData.user_id"
|
||||
class="form-input"
|
||||
:disabled="loadingUsers"
|
||||
>
|
||||
<option :value="null">Select a user</option>
|
||||
<optgroup v-for="group in groupedUsers" :key="group.org" :label="group.org">
|
||||
<option v-for="user in group.users" :key="user.id" :value="user.id">
|
||||
{{ user.full_name }} ({{ user.email }})
|
||||
</option>
|
||||
</optgroup>
|
||||
</select>
|
||||
<span v-if="errors.user_id" class="form-error">{{ errors.user_id }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Space Selection -->
|
||||
<div class="form-group">
|
||||
<label for="admin-space" class="form-label">Space *</label>
|
||||
<select
|
||||
v-if="!spaceId"
|
||||
id="admin-space"
|
||||
v-model="formData.space_id"
|
||||
class="form-input"
|
||||
:disabled="loadingSpaces"
|
||||
>
|
||||
<option :value="null">Select a space</option>
|
||||
<option v-for="space in activeSpaces" :key="space.id" :value="space.id">
|
||||
{{ space.name }} ({{ space.type }}, Capacity: {{ space.capacity }})
|
||||
</option>
|
||||
</select>
|
||||
<input
|
||||
v-else
|
||||
type="text"
|
||||
class="form-input"
|
||||
:value="selectedSpaceName"
|
||||
readonly
|
||||
disabled
|
||||
/>
|
||||
<span v-if="errors.space_id" class="form-error">{{ errors.space_id }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Title -->
|
||||
<div class="form-group">
|
||||
<label for="admin-title" class="form-label">Title *</label>
|
||||
<input
|
||||
id="admin-title"
|
||||
v-model="formData.title"
|
||||
type="text"
|
||||
class="form-input"
|
||||
placeholder="Booking title"
|
||||
maxlength="200"
|
||||
/>
|
||||
<span v-if="errors.title" class="form-error">{{ errors.title }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div class="form-group">
|
||||
<label for="admin-desc" class="form-label">Description (optional)</label>
|
||||
<textarea
|
||||
id="admin-desc"
|
||||
v-model="formData.description"
|
||||
class="form-textarea"
|
||||
rows="2"
|
||||
placeholder="Additional details..."
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Start Date & Time -->
|
||||
<div class="form-group">
|
||||
<label class="form-label">Start *</label>
|
||||
<div class="datetime-row">
|
||||
<div class="datetime-field">
|
||||
<label for="admin-start-date" class="form-sublabel">Date</label>
|
||||
<input
|
||||
id="admin-start-date"
|
||||
v-model="formData.start_date"
|
||||
type="date"
|
||||
class="form-input"
|
||||
:min="minDate"
|
||||
/>
|
||||
</div>
|
||||
<div class="datetime-field">
|
||||
<label for="admin-start-time" class="form-sublabel">Time</label>
|
||||
<input
|
||||
id="admin-start-time"
|
||||
v-model="formData.start_time"
|
||||
type="time"
|
||||
class="form-input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<span v-if="errors.start" class="form-error">{{ errors.start }}</span>
|
||||
</div>
|
||||
|
||||
<!-- End Date & Time -->
|
||||
<div class="form-group">
|
||||
<label class="form-label">End *</label>
|
||||
<div class="datetime-row">
|
||||
<div class="datetime-field">
|
||||
<label for="admin-end-date" class="form-sublabel">Date</label>
|
||||
<input
|
||||
id="admin-end-date"
|
||||
v-model="formData.end_date"
|
||||
type="date"
|
||||
class="form-input"
|
||||
:min="formData.start_date || minDate"
|
||||
/>
|
||||
</div>
|
||||
<div class="datetime-field">
|
||||
<label for="admin-end-time" class="form-sublabel">Time</label>
|
||||
<input
|
||||
id="admin-end-time"
|
||||
v-model="formData.end_time"
|
||||
type="time"
|
||||
class="form-input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<span v-if="errors.end" class="form-error">{{ errors.end }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Booking As indicator -->
|
||||
<div v-if="selectedUserName" class="booking-as">
|
||||
Booking as: <strong>{{ selectedUserName }}</strong>
|
||||
</div>
|
||||
|
||||
<!-- API Error -->
|
||||
<div v-if="apiError" class="api-error">{{ apiError }}</div>
|
||||
|
||||
<!-- Success -->
|
||||
<div v-if="successMessage" class="success-message">{{ successMessage }}</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="form-actions">
|
||||
<button type="button" class="btn btn-secondary" :disabled="submitting" @click="$emit('cancel')">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" class="btn btn-primary" :disabled="submitting">
|
||||
{{ submitting ? 'Creating...' : 'Create Booking' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { spacesApi, usersApi, adminBookingsApi, handleApiError } from '@/services/api'
|
||||
import type { Space, User, BookingAdminCreate } from '@/types'
|
||||
|
||||
interface Props {
|
||||
spaceId?: number
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<{
|
||||
(e: 'submit'): void
|
||||
(e: 'cancel'): void
|
||||
}>()
|
||||
|
||||
const spaces = ref<Space[]>([])
|
||||
const users = ref<User[]>([])
|
||||
const loadingSpaces = ref(false)
|
||||
const loadingUsers = ref(false)
|
||||
const submitting = ref(false)
|
||||
const apiError = ref('')
|
||||
const successMessage = ref('')
|
||||
const errors = ref<Record<string, string>>({})
|
||||
|
||||
const now = new Date()
|
||||
const startHour = now.getHours() + 1
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
const todayStr = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`
|
||||
|
||||
const formData = ref({
|
||||
user_id: null as number | null,
|
||||
space_id: (props.spaceId || null) as number | null,
|
||||
title: '',
|
||||
description: '',
|
||||
start_date: todayStr,
|
||||
start_time: `${pad(startHour)}:00`,
|
||||
end_date: todayStr,
|
||||
end_time: `${pad(startHour + 1)}:00`
|
||||
})
|
||||
|
||||
const activeSpaces = computed(() => spaces.value.filter(s => s.is_active))
|
||||
|
||||
const selectedSpaceName = computed(() => {
|
||||
if (!props.spaceId) return ''
|
||||
const space = spaces.value.find(s => s.id === props.spaceId)
|
||||
return space ? `${space.name} (${space.type})` : ''
|
||||
})
|
||||
|
||||
const selectedUserName = computed(() => {
|
||||
if (!formData.value.user_id) return ''
|
||||
const user = users.value.find(u => u.id === formData.value.user_id)
|
||||
return user ? `${user.full_name} (${user.email})` : ''
|
||||
})
|
||||
|
||||
const minDate = computed(() => todayStr)
|
||||
|
||||
// Group users by organization
|
||||
const groupedUsers = computed(() => {
|
||||
const groups = new Map<string, User[]>()
|
||||
for (const user of users.value.filter(u => u.is_active)) {
|
||||
const org = user.organization || 'No Organization'
|
||||
if (!groups.has(org)) groups.set(org, [])
|
||||
groups.get(org)!.push(user)
|
||||
}
|
||||
return Array.from(groups.entries())
|
||||
.map(([org, users]) => ({ org, users: users.sort((a, b) => a.full_name.localeCompare(b.full_name)) }))
|
||||
.sort((a, b) => a.org.localeCompare(b.org))
|
||||
})
|
||||
|
||||
const loadData = async () => {
|
||||
loadingSpaces.value = true
|
||||
loadingUsers.value = true
|
||||
try {
|
||||
const [spaceData, userData] = await Promise.all([
|
||||
spacesApi.list(),
|
||||
usersApi.list()
|
||||
])
|
||||
spaces.value = spaceData
|
||||
users.value = userData
|
||||
} catch (err) {
|
||||
apiError.value = handleApiError(err)
|
||||
} finally {
|
||||
loadingSpaces.value = false
|
||||
loadingUsers.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
errors.value = {}
|
||||
apiError.value = ''
|
||||
successMessage.value = ''
|
||||
|
||||
// Validate
|
||||
if (!formData.value.user_id) {
|
||||
errors.value.user_id = 'Please select a user'
|
||||
}
|
||||
if (!formData.value.space_id) {
|
||||
errors.value.space_id = 'Please select a space'
|
||||
}
|
||||
if (!formData.value.title.trim()) {
|
||||
errors.value.title = 'Title is required'
|
||||
}
|
||||
if (!formData.value.start_date || !formData.value.start_time) {
|
||||
errors.value.start = 'Start date and time are required'
|
||||
}
|
||||
if (!formData.value.end_date || !formData.value.end_time) {
|
||||
errors.value.end = 'End date and time are required'
|
||||
}
|
||||
|
||||
if (formData.value.start_date && formData.value.start_time && formData.value.end_date && formData.value.end_time) {
|
||||
const start = new Date(`${formData.value.start_date}T${formData.value.start_time}`)
|
||||
const end = new Date(`${formData.value.end_date}T${formData.value.end_time}`)
|
||||
if (end <= start) {
|
||||
errors.value.end = 'End must be after start'
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(errors.value).length > 0) return
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
const payload: BookingAdminCreate = {
|
||||
space_id: formData.value.space_id!,
|
||||
user_id: formData.value.user_id!,
|
||||
start_datetime: `${formData.value.start_date}T${formData.value.start_time}:00`,
|
||||
end_datetime: `${formData.value.end_date}T${formData.value.end_time}:00`,
|
||||
title: formData.value.title.trim(),
|
||||
description: formData.value.description.trim() || undefined
|
||||
}
|
||||
|
||||
await adminBookingsApi.create(payload)
|
||||
successMessage.value = 'Booking created and approved!'
|
||||
|
||||
setTimeout(() => {
|
||||
emit('submit')
|
||||
}, 1000)
|
||||
} catch (err) {
|
||||
apiError.value = handleApiError(err)
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadData)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.admin-banner {
|
||||
background: color-mix(in srgb, var(--color-info) 15%, transparent);
|
||||
color: var(--color-info);
|
||||
padding: 10px 14px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-primary);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.form-sublabel {
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
font-weight: 400;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.datetime-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.datetime-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.form-input,
|
||||
.form-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);
|
||||
transition: border-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.form-input:focus,
|
||||
.form-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-input:disabled {
|
||||
background: var(--color-bg-tertiary);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.form-textarea {
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.form-error {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
color: var(--color-danger);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.booking-as {
|
||||
padding: 10px 14px;
|
||||
background: color-mix(in srgb, var(--color-accent) 10%, transparent);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 13px;
|
||||
color: var(--color-text-secondary);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.api-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;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.success-message {
|
||||
padding: 12px;
|
||||
background: color-mix(in srgb, var(--color-success) 10%, transparent);
|
||||
color: var(--color-success);
|
||||
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;
|
||||
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);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.datetime-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
flex-direction: column-reverse;
|
||||
}
|
||||
|
||||
.btn {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,13 +1,13 @@
|
||||
<template>
|
||||
<aside class="sidebar" :class="{ collapsed, 'mobile-open': mobileOpen }">
|
||||
<div class="sidebar-header">
|
||||
<div class="sidebar-header" @click="handleHeaderClick" :title="collapsed ? 'Expand sidebar' : 'Collapse sidebar'">
|
||||
<LayoutDashboard :size="24" class="sidebar-logo-icon" />
|
||||
<span v-show="!collapsed" class="sidebar-title">Space Booking</span>
|
||||
<span v-show="showLabels" class="sidebar-title">Space Booking</span>
|
||||
</div>
|
||||
|
||||
<nav class="sidebar-nav">
|
||||
<div class="nav-section">
|
||||
<span v-show="!collapsed" class="nav-section-label">Main</span>
|
||||
<span v-show="showLabels" class="nav-section-label">Main</span>
|
||||
<router-link
|
||||
v-for="item in mainNav"
|
||||
:key="item.to"
|
||||
@@ -17,12 +17,12 @@
|
||||
@click="closeMobile"
|
||||
>
|
||||
<component :is="item.icon" :size="20" class="nav-icon" />
|
||||
<span v-show="!collapsed" class="nav-label">{{ item.label }}</span>
|
||||
<span v-show="showLabels" class="nav-label">{{ item.label }}</span>
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<div v-if="authStore.isAdmin" class="nav-section">
|
||||
<span v-show="!collapsed" class="nav-section-label">Admin</span>
|
||||
<span v-show="showLabels" class="nav-section-label">Admin</span>
|
||||
<router-link
|
||||
v-for="item in adminNav"
|
||||
:key="item.to"
|
||||
@@ -32,13 +32,13 @@
|
||||
@click="closeMobile"
|
||||
>
|
||||
<component :is="item.icon" :size="20" class="nav-icon" />
|
||||
<span v-show="!collapsed" class="nav-label">{{ item.label }}</span>
|
||||
<span v-show="showLabels" class="nav-label">{{ item.label }}</span>
|
||||
</router-link>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="sidebar-footer">
|
||||
<div v-show="!collapsed" class="user-info">
|
||||
<div v-show="showLabels" class="user-info">
|
||||
<div class="user-avatar">
|
||||
{{ authStore.user?.email?.charAt(0).toUpperCase() }}
|
||||
</div>
|
||||
@@ -79,7 +79,6 @@ import {
|
||||
User,
|
||||
Settings2,
|
||||
Users,
|
||||
ClipboardCheck,
|
||||
Sliders,
|
||||
BarChart3,
|
||||
ScrollText,
|
||||
@@ -96,6 +95,9 @@ const router = useRouter()
|
||||
const { collapsed, mobileOpen, toggle, closeMobile } = useSidebar()
|
||||
const { theme, resolvedTheme, toggleTheme } = useTheme()
|
||||
|
||||
// On mobile, always show labels when sidebar is open (even if collapsed on desktop)
|
||||
const showLabels = computed(() => !collapsed.value || mobileOpen.value)
|
||||
|
||||
const themeTitle = computed(() => {
|
||||
if (theme.value === 'light') return 'Switch to dark mode'
|
||||
if (theme.value === 'dark') return 'Switch to auto mode'
|
||||
@@ -105,14 +107,13 @@ const themeTitle = computed(() => {
|
||||
const mainNav = [
|
||||
{ to: '/dashboard', icon: LayoutDashboard, label: 'Dashboard' },
|
||||
{ to: '/spaces', icon: Building2, label: 'Spaces' },
|
||||
{ to: '/my-bookings', icon: CalendarDays, label: 'My Bookings' },
|
||||
{ to: '/history', icon: CalendarDays, label: 'History' },
|
||||
{ to: '/profile', icon: User, label: 'Profile' },
|
||||
]
|
||||
|
||||
const adminNav = [
|
||||
{ to: '/admin', icon: Settings2, label: 'Spaces Admin' },
|
||||
{ to: '/users', icon: Users, label: 'Users' },
|
||||
{ to: '/admin/pending', icon: ClipboardCheck, label: 'Pending' },
|
||||
{ to: '/admin/settings', icon: Sliders, label: 'Settings' },
|
||||
{ to: '/admin/reports', icon: BarChart3, label: 'Reports' },
|
||||
{ to: '/admin/audit-log', icon: ScrollText, label: 'Audit Log' },
|
||||
@@ -123,6 +124,13 @@ const isActive = (path: string) => {
|
||||
return route.path.startsWith(path)
|
||||
}
|
||||
|
||||
const handleHeaderClick = () => {
|
||||
// Only toggle on desktop (≥768px)
|
||||
if (window.innerWidth >= 768) {
|
||||
toggle()
|
||||
}
|
||||
}
|
||||
|
||||
const handleLogout = () => {
|
||||
authStore.logout()
|
||||
router.push('/login')
|
||||
@@ -166,6 +174,12 @@ const handleLogout = () => {
|
||||
padding: 1.25rem 1.25rem 1rem;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
min-height: 60px;
|
||||
cursor: pointer;
|
||||
transition: background var(--transition-fast);
|
||||
}
|
||||
|
||||
.sidebar-header:hover {
|
||||
background: var(--sidebar-hover-bg);
|
||||
}
|
||||
|
||||
.sidebar-logo-icon {
|
||||
@@ -323,6 +337,14 @@ const handleLogout = () => {
|
||||
width: var(--sidebar-width);
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.sidebar-header:hover {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.desktop-only {
|
||||
display: none;
|
||||
}
|
||||
|
||||
329
frontend/src/components/BookingEditModal.vue
Normal file
329
frontend/src/components/BookingEditModal.vue
Normal 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>
|
||||
206
frontend/src/components/BookingFilters.vue
Normal file
206
frontend/src/components/BookingFilters.vue
Normal file
@@ -0,0 +1,206 @@
|
||||
<template>
|
||||
<div class="booking-filters">
|
||||
<button v-if="collapsible" class="filter-toggle-btn" @click="collapsed = !collapsed">
|
||||
<SlidersHorizontal :size="15" />
|
||||
<span>Filters</span>
|
||||
<span v-if="activeCount > 0" class="filter-badge">{{ activeCount }}</span>
|
||||
<ChevronDown :size="14" class="toggle-chevron" :class="{ rotated: !collapsed }" />
|
||||
</button>
|
||||
|
||||
<div class="filters-content" :class="{ 'filters-collapsed': collapsible && collapsed }">
|
||||
<div class="filter-fields">
|
||||
<div class="filter-field">
|
||||
<label for="filter-space">Space</label>
|
||||
<select id="filter-space" :value="modelValue.space_id" @change="updateFilter('space_id', ($event.target as HTMLSelectElement).value || null)">
|
||||
<option :value="null">All Spaces</option>
|
||||
<option v-for="space in spaces" :key="space.id" :value="space.id">
|
||||
{{ space.name }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="filter-field">
|
||||
<label for="filter-status">Status</label>
|
||||
<select id="filter-status" :value="modelValue.status" @change="updateFilter('status', ($event.target as HTMLSelectElement).value || null)">
|
||||
<option :value="null">All</option>
|
||||
<option value="pending">Pending</option>
|
||||
<option value="approved">Approved</option>
|
||||
<option value="rejected">Rejected</option>
|
||||
<option value="canceled">Canceled</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div v-if="showUserFilter" class="filter-field">
|
||||
<label for="filter-user">User</label>
|
||||
<input
|
||||
id="filter-user"
|
||||
type="text"
|
||||
:value="modelValue.user_search"
|
||||
placeholder="Search user..."
|
||||
@input="updateFilter('user_search', ($event.target as HTMLInputElement).value || null)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { SlidersHorizontal, ChevronDown } from 'lucide-vue-next'
|
||||
import { useLocalStorage } from '@/composables/useLocalStorage'
|
||||
import type { Space } from '@/types'
|
||||
|
||||
export interface FilterValues {
|
||||
space_id: number | null
|
||||
status: string | null
|
||||
user_search: string | null
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
spaces: Space[]
|
||||
showUserFilter?: boolean
|
||||
modelValue: FilterValues
|
||||
collapsible?: boolean
|
||||
defaultCollapsed?: boolean
|
||||
}>(), {
|
||||
collapsible: true,
|
||||
defaultCollapsed: true
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: FilterValues]
|
||||
}>()
|
||||
|
||||
const collapsed = useLocalStorage('sb-filters-collapsed', props.defaultCollapsed)
|
||||
|
||||
const activeCount = computed(() => {
|
||||
let count = 0
|
||||
if (props.modelValue.space_id !== null) count++
|
||||
if (props.modelValue.status !== null) count++
|
||||
if (props.modelValue.user_search !== null) count++
|
||||
return count
|
||||
})
|
||||
|
||||
const updateFilter = (key: keyof FilterValues, value: string | number | null) => {
|
||||
const parsed = key === 'space_id' && value !== null ? Number(value) : value
|
||||
emit('update:modelValue', { ...props.modelValue, [key]: parsed || null })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.booking-filters {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.filter-toggle-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
background: var(--color-bg-secondary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.filter-toggle-btn:hover {
|
||||
background: var(--color-bg-tertiary);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.filter-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
padding: 0 5px;
|
||||
background: var(--color-accent);
|
||||
color: #fff;
|
||||
border-radius: 9px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.toggle-chevron {
|
||||
transition: transform var(--transition-fast);
|
||||
}
|
||||
|
||||
.toggle-chevron.rotated {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
/* Collapse animation */
|
||||
.filters-content {
|
||||
max-height: 200px;
|
||||
overflow: hidden;
|
||||
transition: max-height 0.25s ease, opacity 0.2s ease, margin 0.25s ease;
|
||||
opacity: 1;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.filters-content.filters-collapsed {
|
||||
max-height: 0;
|
||||
opacity: 0;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.filter-fields {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: flex-end;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.filter-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 140px;
|
||||
}
|
||||
|
||||
.filter-field label {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.filter-field select,
|
||||
.filter-field input {
|
||||
padding: 7px 10px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text-primary);
|
||||
transition: border-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.filter-field select:focus,
|
||||
.filter-field input:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-accent);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-accent) 15%, transparent);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.filter-fields {
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.filter-field {
|
||||
min-width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
417
frontend/src/components/BookingPreviewModal.vue
Normal file
417
frontend/src/components/BookingPreviewModal.vue
Normal 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">
|
||||
· {{ 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>
|
||||
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>
|
||||
53
frontend/src/components/Breadcrumb.vue
Normal file
53
frontend/src/components/Breadcrumb.vue
Normal file
@@ -0,0 +1,53 @@
|
||||
<template>
|
||||
<nav class="breadcrumbs">
|
||||
<template v-for="(item, index) in items" :key="index">
|
||||
<router-link v-if="item.to && index < items.length - 1" :to="item.to" class="breadcrumb-link">
|
||||
{{ item.label }}
|
||||
</router-link>
|
||||
<span v-else class="current">{{ item.label }}</span>
|
||||
<span v-if="index < items.length - 1" class="separator">/</span>
|
||||
</template>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface BreadcrumbItem {
|
||||
label: string
|
||||
to?: string
|
||||
}
|
||||
|
||||
defineProps<{
|
||||
items: BreadcrumbItem[]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.breadcrumbs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 24px;
|
||||
font-size: 14px;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.breadcrumb-link {
|
||||
color: var(--color-accent);
|
||||
text-decoration: none;
|
||||
transition: color var(--transition-fast);
|
||||
}
|
||||
|
||||
.breadcrumb-link:hover {
|
||||
color: var(--color-accent-hover);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.separator {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.current {
|
||||
color: var(--color-text-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
</style>
|
||||
@@ -1,8 +1,51 @@
|
||||
<template>
|
||||
<div class="dashboard-calendar">
|
||||
<div v-if="error" class="error">{{ error }}</div>
|
||||
<div v-if="loading" class="loading">Loading calendar...</div>
|
||||
<FullCalendar ref="calendarRef" v-show="!loading" :options="calendarOptions" />
|
||||
<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">→</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>
|
||||
|
||||
@@ -11,20 +54,67 @@ 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, CalendarApi } from '@fullcalendar/core'
|
||||
import { bookingsApi, handleApiError } from '@/services/api'
|
||||
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',
|
||||
@@ -47,17 +137,6 @@ const events = computed<EventInput[]>(() => {
|
||||
}))
|
||||
})
|
||||
|
||||
// Watch events and update FullCalendar
|
||||
watch(events, (newEvents) => {
|
||||
nextTick(() => {
|
||||
const calendarApi = calendarRef.value?.getApi()
|
||||
if (calendarApi) {
|
||||
calendarApi.removeAllEvents()
|
||||
calendarApi.addEventSource(newEvents)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
let currentStart: Date | null = null
|
||||
let currentEnd: Date | null = null
|
||||
|
||||
@@ -69,7 +148,15 @@ const loadBookings = async (start: Date, end: Date) => {
|
||||
try {
|
||||
const startStr = start.toISOString()
|
||||
const endStr = end.toISOString()
|
||||
bookings.value = await bookingsApi.getMyCalendar(startStr, endStr)
|
||||
|
||||
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 {
|
||||
@@ -84,22 +171,137 @@ const handleDatesSet = (arg: DatesSetArg) => {
|
||||
loadBookings(arg.start, arg.end)
|
||||
}
|
||||
|
||||
const calendarOptions: CalendarOptions = {
|
||||
plugins: [dayGridPlugin, timeGridPlugin, interactionPlugin],
|
||||
initialView: 'dayGridMonth',
|
||||
headerToolbar: {
|
||||
left: 'prev,next today',
|
||||
center: 'title',
|
||||
right: 'dayGridMonth,timeGridWeek'
|
||||
},
|
||||
// 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: events.value,
|
||||
datesSet: handleDatesSet,
|
||||
editable: false,
|
||||
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',
|
||||
@@ -109,8 +311,32 @@ const calendarOptions: CalendarOptions = {
|
||||
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) {
|
||||
@@ -141,10 +367,138 @@ defineExpose({ refresh })
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
.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 */
|
||||
@@ -179,10 +533,74 @@ defineExpose({ refresh })
|
||||
}
|
||||
|
||||
:deep(.fc-event) {
|
||||
cursor: default;
|
||||
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>
|
||||
|
||||
@@ -5,7 +5,19 @@
|
||||
</div>
|
||||
<div v-if="error" class="error">{{ error }}</div>
|
||||
<div v-if="loading && !confirmModal.show" class="loading">Loading calendar...</div>
|
||||
<FullCalendar v-show="!loading" :options="calendarOptions" />
|
||||
<FullCalendar ref="calendarRef" v-show="!loading" :options="calendarOptions" />
|
||||
|
||||
<!-- Booking Preview Modal -->
|
||||
<BookingPreviewModal
|
||||
:booking="selectedBooking"
|
||||
:is-admin="isEditable"
|
||||
:show="showPreview"
|
||||
@close="showPreview = false"
|
||||
@approve="handlePreviewAction('approve', $event)"
|
||||
@reject="handlePreviewAction('reject', $event)"
|
||||
@cancel="handlePreviewAction('cancel', $event)"
|
||||
@edit="handlePreviewAction('edit', $event)"
|
||||
/>
|
||||
|
||||
<!-- Confirmation Modal -->
|
||||
<div v-if="confirmModal.show" class="modal-overlay" @click.self="cancelReschedule">
|
||||
@@ -39,15 +51,18 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { ref, computed, watch, onMounted } 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, EventResizeDoneArg } from '@fullcalendar/core'
|
||||
import { bookingsApi, adminBookingsApi, handleApiError } from '@/services/api'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { formatDateTime as formatDateTimeUtil } from '@/utils/datetime'
|
||||
import { useIsMobile } from '@/composables/useMediaQuery'
|
||||
import BookingPreviewModal from '@/components/BookingPreviewModal.vue'
|
||||
import type { Booking } from '@/types'
|
||||
|
||||
interface Props {
|
||||
@@ -57,12 +72,14 @@ interface Props {
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const isMobile = useIsMobile()
|
||||
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)
|
||||
|
||||
interface ConfirmModal {
|
||||
show: boolean
|
||||
@@ -87,6 +104,15 @@ const confirmModal = ref<ConfirmModal>({
|
||||
// Admin can edit, users see read-only
|
||||
const isEditable = computed(() => authStore.user?.role === 'admin')
|
||||
|
||||
// Preview modal state
|
||||
const selectedBooking = ref<Booking | null>(null)
|
||||
const showPreview = ref(false)
|
||||
|
||||
const handlePreviewAction = (_action: string, _booking: Booking) => {
|
||||
showPreview.value = false
|
||||
refresh()
|
||||
}
|
||||
|
||||
// Status to color mapping
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
pending: '#FFA500',
|
||||
@@ -243,13 +269,11 @@ const handleDatesSet = (arg: DatesSetArg) => {
|
||||
|
||||
// FullCalendar options
|
||||
const calendarOptions = computed<CalendarOptions>(() => ({
|
||||
plugins: [dayGridPlugin, timeGridPlugin, interactionPlugin],
|
||||
initialView: 'dayGridMonth',
|
||||
headerToolbar: {
|
||||
left: 'prev,next today',
|
||||
center: 'title',
|
||||
right: 'dayGridMonth,timeGridWeek,timeGridDay'
|
||||
},
|
||||
plugins: [dayGridPlugin, timeGridPlugin, listPlugin, interactionPlugin],
|
||||
initialView: isMobile.value ? 'listWeek' : 'dayGridMonth',
|
||||
headerToolbar: isMobile.value
|
||||
? { left: 'prev,next', center: 'title', right: 'listWeek,dayGridMonth' }
|
||||
: { left: 'prev,next today', center: 'title', right: 'dayGridMonth,timeGridWeek,timeGridDay' },
|
||||
timeZone: userTimezone.value,
|
||||
firstDay: 1, // Start week on Monday (0=Sunday, 1=Monday)
|
||||
events: events.value,
|
||||
@@ -262,6 +286,7 @@ const calendarOptions = computed<CalendarOptions>(() => ({
|
||||
dayMaxEvents: true,
|
||||
weekends: true,
|
||||
height: 'auto',
|
||||
noEventsText: 'No bookings this period',
|
||||
eventTimeFormat: {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
@@ -272,6 +297,15 @@ const calendarOptions = computed<CalendarOptions>(() => ({
|
||||
minute: '2-digit',
|
||||
hour12: false
|
||||
},
|
||||
// Click handler
|
||||
eventClick: (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 callback
|
||||
eventDrop: handleEventDrop,
|
||||
// Resize callback
|
||||
@@ -290,6 +324,14 @@ const calendarOptions = computed<CalendarOptions>(() => ({
|
||||
}
|
||||
}))
|
||||
|
||||
// Switch view dynamically when screen size changes
|
||||
watch(isMobile, (mobile) => {
|
||||
const calendarApi = calendarRef.value?.getApi()
|
||||
if (calendarApi) {
|
||||
calendarApi.changeView(mobile ? 'listWeek' : 'dayGridMonth')
|
||||
}
|
||||
})
|
||||
|
||||
// Public refresh method for parent components
|
||||
const refresh = () => {
|
||||
if (currentStart && currentEnd) {
|
||||
@@ -501,4 +543,55 @@ defineExpose({ refresh })
|
||||
:deep(.fc-event:not(.fc-draggable)) {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* 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) {
|
||||
.space-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;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user