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

@@ -117,7 +117,7 @@ const handleNotificationClick = async (notification: Notification) => {
if (notification.booking_id) {
closeNotifications()
router.push('/my-bookings')
router.push('/history')
}
}

View File

@@ -52,24 +52,24 @@
/* Dark Theme */
[data-theme="dark"] {
--color-bg-primary: #0f0f1a;
--color-bg-secondary: #1a1a2e;
--color-bg-tertiary: #232340;
--color-surface: #1a1a2e;
--color-surface-hover: #232340;
--color-text-primary: #e5e5ef;
--color-text-secondary: #9ca3af;
--color-text-muted: #6b7280;
--color-accent-light: #1e1b4b;
--color-border: #2d2d4a;
--color-border-light: #232340;
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.2);
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.3);
--shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.4);
--color-bg-primary: #0a0a12;
--color-bg-secondary: #151520;
--color-bg-tertiary: #1f1f2e;
--color-surface: #151520;
--color-surface-hover: #1f1f2e;
--color-text-primary: #f0f0f5;
--color-text-secondary: #b8bac5;
--color-text-muted: #8b8d9a;
--color-accent-light: #2a2650;
--color-border: #3a3a52;
--color-border-light: #2d2d42;
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.4);
--shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.5);
/* Sidebar - Dark theme */
--sidebar-bg: #1a1a2e;
--sidebar-text: #a1a1b5;
--sidebar-bg: #151520;
--sidebar-text: #b8bac5;
--sidebar-text-active: #ffffff;
--sidebar-hover-bg: rgba(255, 255, 255, 0.08);
--sidebar-hover-bg: rgba(255, 255, 255, 0.1);
}

View 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>

View File

@@ -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 {

View 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>

View File

@@ -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;
}

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>

View 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>

View File

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

View File

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

View 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>

View File

@@ -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">&rarr;</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>

View File

@@ -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>

View File

@@ -0,0 +1,21 @@
import { ref, watch, type Ref } from 'vue'
export function useLocalStorage<T>(key: string, defaultValue: T): Ref<T> {
let initial = defaultValue
try {
const stored = localStorage.getItem(key)
if (stored !== null) {
initial = JSON.parse(stored)
}
} catch {
// Invalid JSON in storage, use default
}
const value = ref<T>(initial) as Ref<T>
watch(value, (newVal) => {
localStorage.setItem(key, JSON.stringify(newVal))
}, { deep: true })
return value
}

View File

@@ -0,0 +1,28 @@
import { ref, onMounted, onUnmounted } from 'vue'
export function useMediaQuery(query: string) {
const matches = ref(false)
let mediaQuery: MediaQueryList | null = null
const update = () => {
if (mediaQuery) {
matches.value = mediaQuery.matches
}
}
onMounted(() => {
mediaQuery = window.matchMedia(query)
matches.value = mediaQuery.matches
mediaQuery.addEventListener('change', update)
})
onUnmounted(() => {
mediaQuery?.removeEventListener('change', update)
})
return matches
}
export function useIsMobile() {
return useMediaQuery('(max-width: 768px)')
}

View File

@@ -47,11 +47,15 @@ const router = createRouter({
meta: { requiresAuth: true }
},
{
path: '/my-bookings',
name: 'MyBookings',
path: '/history',
name: 'BookingHistory',
component: () => import('@/views/MyBookings.vue'),
meta: { requiresAuth: true }
},
{
path: '/my-bookings',
redirect: '/history'
},
{
path: '/profile',
name: 'UserProfile',
@@ -78,9 +82,7 @@ const router = createRouter({
},
{
path: '/admin/pending',
name: 'AdminPending',
component: () => import('@/views/AdminPending.vue'),
meta: { requiresAuth: true, requiresAdmin: true }
redirect: '/history?status=pending'
},
{
path: '/admin/audit-log',

View File

@@ -12,6 +12,7 @@ import type {
Booking,
BookingCreate,
BookingUpdate,
BookingAdminCreate,
BookingTemplate,
BookingTemplateCreate,
Notification,
@@ -202,6 +203,17 @@ export const bookingsApi = {
// Admin Bookings API
export const adminBookingsApi = {
getAll: async (params?: {
status?: string
space_id?: number
user_id?: number
start?: string
limit?: number
}): Promise<Booking[]> => {
const response = await api.get<Booking[]>('/admin/bookings/all', { params })
return response.data
},
getPending: async (filters?: { space_id?: number; user_id?: number }): Promise<Booking[]> => {
const response = await api.get<Booking[]>('/admin/bookings/pending', { params: filters })
return response.data
@@ -228,6 +240,11 @@ export const adminBookingsApi = {
): Promise<Booking> => {
const response = await api.put<Booking>(`/admin/bookings/${id}/reschedule`, data)
return response.data
},
create: async (data: BookingAdminCreate): Promise<Booking> => {
const response = await api.post<Booking>('/admin/bookings', data)
return response.data
}
}
@@ -354,7 +371,14 @@ export const googleCalendarApi = {
},
disconnect: async (): Promise<{ message: string }> => {
const response = await api.delete<{ message: string }>('/integrations/google/disconnect')
const response = await api.post<{ message: string }>('/integrations/google/disconnect')
return response.data
},
sync: async (): Promise<{ synced: number; created: number; updated: number; failed: number; total_bookings: number }> => {
const response = await api.post<{ synced: number; created: number; updated: number; failed: number; total_bookings: number }>(
'/integrations/google/sync'
)
return response.data
},

View File

@@ -91,6 +91,15 @@ export interface BookingUpdate {
end_datetime?: string // ISO format
}
export interface BookingAdminCreate {
space_id: number
user_id?: number
start_datetime: string // ISO format
end_datetime: string // ISO format
title: string
description?: string
}
export interface Notification {
id: number
user_id: number

View File

@@ -1,61 +1,82 @@
<template>
<div class="admin">
<Breadcrumb :items="breadcrumbItems" />
<div class="page-header">
<h2>Admin Dashboard - Space Management</h2>
<h2>Space Management</h2>
<button class="btn btn-primary" @click="openCreateModal">
<Plus :size="16" />
Create New Space
New Space
</button>
</div>
<!-- Spaces List -->
<CollapsibleSection title="All Spaces" :icon="Building2">
<div v-if="loadingSpaces" class="loading">Loading spaces...</div>
<div v-else-if="spaces.length === 0" class="empty">
No spaces created yet. Create one above!
<!-- Stats Pills -->
<div class="stats-pills">
<span class="stat-pill stat-pill-primary">
<span class="stat-pill-number">{{ spaces.length }}</span>
<span class="stat-pill-label">Total Spaces</span>
</span>
<span class="stat-pill stat-pill-success">
<span class="stat-pill-number">{{ activeCount }}</span>
<span class="stat-pill-label">Active</span>
</span>
<span class="stat-pill stat-pill-danger">
<span class="stat-pill-number">{{ inactiveCount }}</span>
<span class="stat-pill-label">Inactive</span>
</span>
</div>
<!-- Loading State -->
<div v-if="loadingSpaces" class="loading-state">
<div class="spinner"></div>
<p>Loading spaces...</p>
</div>
<!-- Empty State -->
<div v-else-if="spaces.length === 0" class="empty-state">
<Building2 :size="48" class="empty-icon" />
<p>No spaces created yet</p>
<button class="btn btn-primary" @click="openCreateModal">Create your first space</button>
</div>
<!-- Space Cards Grid -->
<div v-else class="space-cards">
<div v-for="space in spaces" :key="space.id" class="space-card">
<div class="space-card-header">
<div class="space-card-title">
<h3>{{ space.name }}</h3>
<span :class="['badge', space.is_active ? 'badge-active' : 'badge-inactive']">
{{ space.is_active ? 'Active' : 'Inactive' }}
</span>
</div>
<div class="space-card-actions">
<button
class="icon-btn"
title="Edit space"
@click="startEdit(space)"
:disabled="loading"
>
<Pencil :size="16" />
</button>
<button
:class="['icon-btn', space.is_active ? 'icon-btn-warning' : 'icon-btn-success']"
:title="space.is_active ? 'Deactivate' : 'Activate'"
@click="toggleStatus(space)"
:disabled="loading"
>
<Power :size="16" />
</button>
</div>
</div>
<div class="space-card-meta">
<span class="meta-badge meta-type">{{ space.type === 'sala' ? 'Sala' : 'Birou' }}</span>
<span class="meta-item">
<UsersIcon :size="14" />
{{ space.capacity }}
</span>
</div>
<p v-if="space.description" class="space-card-desc">{{ space.description }}</p>
</div>
<div v-else class="table-responsive">
<table class="data-table">
<thead>
<tr>
<th>Name</th>
<th>Type</th>
<th>Capacity</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="space in spaces" :key="space.id">
<td>{{ space.name }}</td>
<td>{{ space.type === 'sala' ? 'Sala' : 'Birou' }}</td>
<td>{{ space.capacity }}</td>
<td>
<span :class="['badge', space.is_active ? 'badge-active' : 'badge-inactive']">
{{ space.is_active ? 'Active' : 'Inactive' }}
</span>
</td>
<td class="actions">
<button
class="btn btn-sm btn-secondary"
@click="startEdit(space)"
:disabled="loading"
>
Edit
</button>
<button
:class="['btn', 'btn-sm', space.is_active ? 'btn-warning' : 'btn-success']"
@click="toggleStatus(space)"
:disabled="loading"
>
{{ space.is_active ? 'Deactivate' : 'Activate' }}
</button>
</td>
</tr>
</tbody>
</table>
</div>
</CollapsibleSection>
</div>
<!-- Create/Edit Space Modal -->
<div v-if="showModal" class="modal" @click.self="closeModal">
@@ -179,14 +200,21 @@
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ref, computed, onMounted } from 'vue'
import { spacesApi, handleApiError } from '@/services/api'
import CollapsibleSection from '@/components/CollapsibleSection.vue'
import { Building2, Plus } from 'lucide-vue-next'
import Breadcrumb from '@/components/Breadcrumb.vue'
import { Building2, Plus, Pencil, Power, Users as UsersIcon } from 'lucide-vue-next'
import type { Space } from '@/types'
const breadcrumbItems = [
{ label: 'Dashboard', to: '/dashboard' },
{ label: 'Admin' }
]
const spaces = ref<Space[]>([])
const loadingSpaces = ref(false)
const activeCount = computed(() => spaces.value.filter(s => s.is_active).length)
const inactiveCount = computed(() => spaces.value.filter(s => !s.is_active).length)
const loading = ref(false)
const error = ref('')
const success = ref('')
@@ -320,9 +348,273 @@ onMounted(() => {
.page-header h2 {
margin: 0;
font-size: 28px;
font-weight: 700;
color: var(--color-text-primary);
}
/* Stats Pills */
.stats-pills {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 24px;
}
.stat-pill {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 16px;
border-radius: 20px;
font-size: 13px;
font-weight: 500;
border: 1px solid transparent;
height: 40px;
box-sizing: border-box;
}
.stat-pill-number {
font-weight: 700;
font-size: 15px;
}
.stat-pill-primary {
background: var(--color-accent-light);
color: var(--color-accent);
border-color: color-mix(in srgb, var(--color-accent) 20%, transparent);
}
.stat-pill-success {
background: color-mix(in srgb, var(--color-success) 12%, transparent);
color: var(--color-success);
border-color: color-mix(in srgb, var(--color-success) 20%, transparent);
}
.stat-pill-danger {
background: color-mix(in srgb, var(--color-danger) 12%, transparent);
color: var(--color-danger);
border-color: color-mix(in srgb, var(--color-danger) 20%, transparent);
}
/* Loading & Empty States */
.loading-state {
display: flex;
flex-direction: column;
align-items: center;
padding: 60px 20px;
color: var(--color-text-secondary);
}
.spinner {
width: 40px;
height: 40px;
border: 3px solid var(--color-border);
border-top-color: var(--color-accent);
border-radius: 50%;
animation: spin 0.8s linear infinite;
margin-bottom: 12px;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
padding: 60px 20px;
color: var(--color-text-muted);
gap: 16px;
}
.empty-icon {
color: var(--color-border);
}
.empty-state p {
color: var(--color-text-secondary);
font-size: 15px;
}
/* Space Cards Grid */
.space-cards {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 16px;
}
.space-card {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
padding: 20px;
transition: all var(--transition-fast);
}
.space-card:hover {
box-shadow: var(--shadow-md);
border-color: var(--color-accent);
}
.space-card-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 12px;
margin-bottom: 12px;
}
.space-card-title {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.space-card-title h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
color: var(--color-text-primary);
}
.space-card-actions {
display: flex;
gap: 6px;
flex-shrink: 0;
}
.icon-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);
}
.icon-btn:hover {
color: var(--color-accent);
border-color: var(--color-accent);
background: var(--color-accent-light);
}
.icon-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.icon-btn-warning:hover {
color: var(--color-warning);
border-color: var(--color-warning);
background: color-mix(in srgb, var(--color-warning) 10%, transparent);
}
.icon-btn-success:hover {
color: var(--color-success);
border-color: var(--color-success);
background: color-mix(in srgb, var(--color-success) 10%, transparent);
}
.space-card-meta {
display: flex;
align-items: center;
gap: 10px;
}
.meta-badge {
display: inline-block;
padding: 3px 10px;
border-radius: 10px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.meta-type {
background: var(--color-accent-light);
color: var(--color-accent);
}
.meta-item {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 13px;
color: var(--color-text-secondary);
}
.space-card-desc {
margin: 10px 0 0;
font-size: 13px;
color: var(--color-text-muted);
line-height: 1.4;
}
.badge {
display: inline-block;
padding: 3px 10px;
border-radius: 10px;
font-size: 11px;
font-weight: 600;
}
.badge-active {
background: color-mix(in srgb, var(--color-success) 15%, transparent);
color: var(--color-success);
}
.badge-inactive {
background: color-mix(in srgb, var(--color-danger) 15%, transparent);
color: var(--color-danger);
}
/* Buttons */
.btn {
display: inline-flex;
align-items: center;
gap: 6px;
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);
}
/* Form Styles */
.space-form {
display: flex;
flex-direction: column;
@@ -355,12 +647,6 @@ onMounted(() => {
gap: 16px;
}
@media (max-width: 640px) {
.form-row {
grid-template-columns: 1fr;
}
}
.form-group {
display: flex;
flex-direction: column;
@@ -398,65 +684,6 @@ onMounted(() => {
margin-top: 8px;
}
.btn {
display: inline-flex;
align-items: center;
gap: 6px;
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);
}
.btn-success {
background: var(--color-success);
color: white;
}
.btn-success:hover:not(:disabled) {
background: color-mix(in srgb, var(--color-success) 85%, black);
}
.btn-warning {
background: var(--color-warning);
color: white;
}
.btn-warning:hover:not(:disabled) {
background: color-mix(in srgb, var(--color-warning) 85%, black);
}
.btn-sm {
padding: 6px 12px;
font-size: 12px;
}
.error {
padding: 12px;
background: color-mix(in srgb, var(--color-danger) 10%, transparent);
@@ -473,69 +700,7 @@ onMounted(() => {
margin-top: 12px;
}
.loading {
text-align: center;
color: var(--color-text-secondary);
padding: 24px;
}
.empty {
text-align: center;
color: var(--color-text-muted);
padding: 24px;
}
.table-responsive {
overflow-x: auto;
}
.data-table {
width: 100%;
border-collapse: collapse;
}
.data-table th {
text-align: left;
padding: 12px;
background: var(--color-bg-secondary);
font-weight: 600;
color: var(--color-text-primary);
border-bottom: 2px solid var(--color-border);
}
.data-table td {
padding: 12px;
border-bottom: 1px solid var(--color-border);
color: var(--color-text-primary);
}
.data-table tr:hover {
background: var(--color-surface-hover);
}
.badge {
display: inline-block;
padding: 4px 12px;
border-radius: 12px;
font-size: 12px;
font-weight: 500;
}
.badge-active {
background: color-mix(in srgb, var(--color-success) 15%, transparent);
color: var(--color-success);
}
.badge-inactive {
background: color-mix(in srgb, var(--color-danger) 15%, transparent);
color: var(--color-danger);
}
.actions {
display: flex;
gap: 8px;
}
/* Modal */
.modal {
position: fixed;
top: 0;
@@ -551,8 +716,8 @@ onMounted(() => {
.modal-content {
background: var(--color-surface);
border-radius: var(--radius-md);
padding: 24px;
border-radius: var(--radius-lg);
padding: 28px;
max-width: 600px;
width: 90%;
max-height: 90vh;
@@ -566,14 +731,30 @@ onMounted(() => {
color: var(--color-text-primary);
}
/* Responsive */
@media (max-width: 768px) {
.page-header {
flex-direction: column;
align-items: stretch;
}
.actions {
flex-direction: column;
.space-cards {
grid-template-columns: 1fr;
}
.stats-pills {
display: grid;
grid-template-columns: repeat(3, 1fr);
}
.stat-pill {
justify-content: center;
}
}
@media (max-width: 640px) {
.form-row {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -1,524 +0,0 @@
<template>
<div class="admin-pending">
<h2>Admin Dashboard - Pending Booking Requests</h2>
<!-- Filters Card -->
<CollapsibleSection title="Filters" :icon="Filter">
<div class="filters">
<div class="form-group">
<label for="filter-space">Filter by Space</label>
<select id="filter-space" v-model="filterSpaceId" @change="loadPendingBookings">
<option value="">All Spaces</option>
<option v-for="space in spaces" :key="space.id" :value="space.id">
{{ space.name }}
</option>
</select>
</div>
</div>
</CollapsibleSection>
<!-- Loading State -->
<div v-if="loading" class="card">
<div class="loading">Loading pending requests...</div>
</div>
<!-- Empty State -->
<div v-else-if="bookings.length === 0" class="card">
<div class="empty">
No pending requests found.
{{ filterSpaceId ? 'Try different filters.' : 'All bookings have been processed.' }}
</div>
</div>
<!-- Bookings Table -->
<CollapsibleSection v-else :title="`Pending Requests (${bookings.length})`" :icon="ClipboardCheck">
<div class="table-responsive">
<table class="data-table">
<thead>
<tr>
<th>User</th>
<th>Space</th>
<th>Date</th>
<th>Time</th>
<th>Title</th>
<th>Description</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="booking in bookings" :key="booking.id">
<td>
<div class="user-info">
<div class="user-name">{{ booking.user?.full_name || 'Unknown' }}</div>
<div class="user-email">{{ booking.user?.email || '-' }}</div>
<div class="user-org" v-if="booking.user?.organization">
{{ booking.user.organization }}
</div>
</div>
</td>
<td>
<div class="space-info">
<div class="space-name">{{ booking.space?.name || 'Unknown Space' }}</div>
<div class="space-type">{{ formatType(booking.space?.type || '') }}</div>
</div>
</td>
<td>{{ formatDate(booking.start_datetime) }}</td>
<td>{{ formatTime(booking.start_datetime, booking.end_datetime) }}</td>
<td>{{ booking.title }}</td>
<td>
<div class="description" :title="booking.description || '-'">
{{ truncateText(booking.description || '-', 40) }}
</div>
</td>
<td class="actions">
<button
class="btn btn-sm btn-success"
@click="handleApprove(booking)"
:disabled="processing === booking.id"
>
{{ processing === booking.id ? 'Processing...' : 'Approve' }}
</button>
<button
class="btn btn-sm btn-danger"
@click="showRejectModal(booking)"
:disabled="processing === booking.id"
>
Reject
</button>
</td>
</tr>
</tbody>
</table>
</div>
</CollapsibleSection>
<!-- Reject Modal -->
<div v-if="rejectingBooking" class="modal" @click.self="closeRejectModal">
<div class="modal-content">
<h3>Reject Booking Request</h3>
<div class="booking-summary">
<p><strong>User:</strong> {{ rejectingBooking.user?.full_name }}</p>
<p><strong>Space:</strong> {{ rejectingBooking.space?.name }}</p>
<p><strong>Title:</strong> {{ rejectingBooking.title }}</p>
<p>
<strong>Date:</strong> {{ formatDate(rejectingBooking.start_datetime) }} -
{{ formatTime(rejectingBooking.start_datetime, rejectingBooking.end_datetime) }}
</p>
</div>
<form @submit.prevent="handleReject">
<div class="form-group">
<label for="reject_reason">Rejection Reason (optional)</label>
<textarea
id="reject_reason"
v-model="rejectReason"
rows="4"
placeholder="Provide a reason for rejection..."
></textarea>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-danger" :disabled="processing !== null">
{{ processing !== null ? 'Rejecting...' : 'Confirm Rejection' }}
</button>
<button type="button" class="btn btn-secondary" @click="closeRejectModal">
Cancel
</button>
</div>
</form>
</div>
</div>
<!-- Error Message -->
<div v-if="error" class="card">
<div class="error">{{ error }}</div>
</div>
<!-- Success Message -->
<div v-if="success" class="card">
<div class="success-msg">{{ success }}</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { adminBookingsApi, spacesApi, handleApiError } from '@/services/api'
import { useAuthStore } from '@/stores/auth'
import { formatDate as formatDateUtil, formatTime as formatTimeUtil } from '@/utils/datetime'
import CollapsibleSection from '@/components/CollapsibleSection.vue'
import { Filter, ClipboardCheck } from 'lucide-vue-next'
import type { Booking, Space } from '@/types'
const authStore = useAuthStore()
const userTimezone = computed(() => authStore.user?.timezone || 'UTC')
const bookings = ref<Booking[]>([])
const spaces = ref<Space[]>([])
const loading = ref(false)
const error = ref('')
const success = ref('')
const processing = ref<number | null>(null)
const filterSpaceId = ref<string>('')
const rejectingBooking = ref<Booking | null>(null)
const rejectReason = ref('')
const loadPendingBookings = async () => {
loading.value = true
error.value = ''
try {
const filters: { space_id?: number } = {}
if (filterSpaceId.value) {
filters.space_id = Number(filterSpaceId.value)
}
bookings.value = await adminBookingsApi.getPending(filters)
} catch (err) {
error.value = handleApiError(err)
} finally {
loading.value = false
}
}
const loadSpaces = async () => {
try {
spaces.value = await spacesApi.list()
} catch (err) {
console.error('Failed to load spaces:', err)
}
}
const formatDate = (datetime: string): string => {
return formatDateUtil(datetime, userTimezone.value)
}
const formatTime = (start: string, end: string): string => {
const startTime = formatTimeUtil(start, userTimezone.value)
const endTime = formatTimeUtil(end, userTimezone.value)
return `${startTime} - ${endTime}`
}
const formatType = (type: string): string => {
const typeMap: Record<string, string> = {
sala: 'Sala',
birou: 'Birou'
}
return typeMap[type] || type
}
const truncateText = (text: string, maxLength: number): string => {
if (text.length <= maxLength) return text
return text.substring(0, maxLength) + '...'
}
const handleApprove = async (booking: Booking) => {
if (!confirm('Are you sure you want to approve this booking?')) {
return
}
processing.value = booking.id
error.value = ''
success.value = ''
try {
await adminBookingsApi.approve(booking.id)
success.value = `Booking "${booking.title}" approved successfully!`
// Remove from list
bookings.value = bookings.value.filter((b) => b.id !== booking.id)
// Clear success message after 3 seconds
setTimeout(() => {
success.value = ''
}, 3000)
} catch (err) {
error.value = handleApiError(err)
} finally {
processing.value = null
}
}
const showRejectModal = (booking: Booking) => {
rejectingBooking.value = booking
rejectReason.value = ''
}
const closeRejectModal = () => {
rejectingBooking.value = null
rejectReason.value = ''
}
const handleReject = async () => {
if (!rejectingBooking.value) return
processing.value = rejectingBooking.value.id
error.value = ''
success.value = ''
try {
await adminBookingsApi.reject(
rejectingBooking.value.id,
rejectReason.value || undefined
)
success.value = `Booking "${rejectingBooking.value.title}" rejected successfully!`
// Remove from list
bookings.value = bookings.value.filter((b) => b.id !== rejectingBooking.value!.id)
closeRejectModal()
// Clear success message after 3 seconds
setTimeout(() => {
success.value = ''
}, 3000)
} catch (err) {
error.value = handleApiError(err)
} finally {
processing.value = null
}
}
onMounted(() => {
loadSpaces()
loadPendingBookings()
})
</script>
<style scoped>
h2 {
margin-bottom: 24px;
color: var(--color-text-primary);
}
.card {
background: var(--color-surface);
border-radius: var(--radius-md);
padding: 24px;
margin-top: 16px;
box-shadow: var(--shadow-sm);
border: 1px solid var(--color-border);
}
.collapsible-section + .collapsible-section,
.card + .collapsible-section,
.collapsible-section + .card {
margin-top: 16px;
}
.filters {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 16px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 4px;
}
.form-group label {
font-weight: 500;
color: var(--color-text-primary);
font-size: 14px;
}
.form-group select,
.form-group textarea {
padding: 8px 12px;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
font-size: 14px;
background: var(--color-surface);
color: var(--color-text-primary);
}
.form-group select: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-actions {
display: flex;
gap: 12px;
margin-top: 16px;
}
.loading {
text-align: center;
color: var(--color-text-secondary);
padding: 24px;
}
.empty {
text-align: center;
color: var(--color-text-muted);
padding: 24px;
}
.error {
padding: 12px;
background: color-mix(in srgb, var(--color-danger) 10%, transparent);
color: var(--color-danger);
border-radius: var(--radius-sm);
}
.success-msg {
padding: 12px;
background: color-mix(in srgb, var(--color-success) 10%, transparent);
color: var(--color-success);
border-radius: var(--radius-sm);
}
.table-responsive {
overflow-x: auto;
}
.data-table {
width: 100%;
border-collapse: collapse;
}
.data-table th {
text-align: left;
padding: 12px;
background: var(--color-bg-secondary);
font-weight: 600;
color: var(--color-text-primary);
border-bottom: 2px solid var(--color-border);
white-space: nowrap;
}
.data-table td {
padding: 12px;
border-bottom: 1px solid var(--color-border);
vertical-align: top;
color: var(--color-text-primary);
}
.data-table tr:hover {
background: var(--color-surface-hover);
}
.user-info,
.space-info {
display: flex;
flex-direction: column;
gap: 4px;
}
.user-name,
.space-name {
font-weight: 500;
color: var(--color-text-primary);
}
.user-email,
.user-org,
.space-type {
font-size: 12px;
color: var(--color-text-secondary);
}
.description {
max-width: 200px;
word-wrap: break-word;
}
.actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
white-space: nowrap;
}
.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-sm {
padding: 6px 12px;
font-size: 12px;
}
.btn-success {
background: var(--color-success);
color: white;
}
.btn-success:hover:not(:disabled) {
background: color-mix(in srgb, var(--color-success) 85%, black);
}
.btn-danger {
background: var(--color-danger);
color: white;
}
.btn-danger:hover:not(:disabled) {
background: color-mix(in srgb, var(--color-danger) 85%, black);
}
.btn-secondary {
background: var(--color-bg-tertiary);
color: var(--color-text-primary);
}
.btn-secondary:hover:not(:disabled) {
background: var(--color-border);
}
.modal {
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);
border-radius: var(--radius-md);
padding: 24px;
max-width: 600px;
width: 90%;
box-shadow: var(--shadow-lg);
}
.modal-content h3 {
margin-top: 0;
margin-bottom: 16px;
color: var(--color-text-primary);
}
.booking-summary {
background: var(--color-bg-secondary);
border-radius: var(--radius-sm);
padding: 12px;
margin-bottom: 16px;
}
.booking-summary p {
margin: 8px 0;
font-size: 14px;
color: var(--color-text-secondary);
}
.booking-summary strong {
color: var(--color-text-primary);
}
</style>

View File

@@ -1,5 +1,6 @@
<template>
<div class="admin-reports">
<Breadcrumb :items="breadcrumbItems" />
<h2>Booking Reports</h2>
<!-- Date Range Filter -->
@@ -150,10 +151,17 @@
import { ref, onMounted, watch, nextTick } from 'vue'
import { reportsApi } from '@/services/api'
import Chart from 'chart.js/auto'
import Breadcrumb from '@/components/Breadcrumb.vue'
import CollapsibleSection from '@/components/CollapsibleSection.vue'
import { CalendarDays } from 'lucide-vue-next'
import type { SpaceUsageReport, TopUsersReport, ApprovalRateReport } from '@/types'
const breadcrumbItems = [
{ label: 'Dashboard', to: '/dashboard' },
{ label: 'Admin', to: '/admin' },
{ label: 'Reports' }
]
const activeTab = ref('usage')
const startDate = ref('')
const endDate = ref('')

View File

@@ -1,5 +1,6 @@
<template>
<div class="audit-log">
<Breadcrumb :items="breadcrumbItems" />
<h2>Jurnal Acțiuni Administrative</h2>
<!-- Filters -->
@@ -77,10 +78,17 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { auditLogApi } from '@/services/api'
import Breadcrumb from '@/components/Breadcrumb.vue'
import { useAuthStore } from '@/stores/auth'
import { formatDateTime as formatDateTimeUtil } from '@/utils/datetime'
import type { AuditLog } from '@/types'
const breadcrumbItems = [
{ label: 'Dashboard', to: '/dashboard' },
{ label: 'Admin', to: '/admin' },
{ label: 'Audit Log' }
]
const authStore = useAuthStore()
const userTimezone = computed(() => authStore.user?.timezone || 'UTC')
const logs = ref<AuditLog[]>([])

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,6 @@
<template>
<div class="settings">
<Breadcrumb :items="breadcrumbItems" />
<h2>Global Booking Settings</h2>
<!-- Settings Form -->
@@ -124,10 +125,17 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { settingsApi, handleApiError } from '@/services/api'
import Breadcrumb from '@/components/Breadcrumb.vue'
import CollapsibleSection from '@/components/CollapsibleSection.vue'
import { Sliders, Info } from 'lucide-vue-next'
import type { Settings } from '@/types'
const breadcrumbItems = [
{ label: 'Dashboard', to: '/dashboard' },
{ label: 'Admin', to: '/admin' },
{ label: 'Settings' }
]
const loadingSettings = ref(true)
const loading = ref(false)
const error = ref('')

View File

@@ -1,13 +1,7 @@
<template>
<div class="space-detail">
<!-- Breadcrumbs -->
<nav class="breadcrumbs">
<router-link to="/">Home</router-link>
<span class="separator">/</span>
<router-link to="/spaces">Spaces</router-link>
<span class="separator">/</span>
<span class="current">{{ space?.name || 'Loading...' }}</span>
</nav>
<Breadcrumb :items="breadcrumbItems" />
<!-- Loading State -->
<div v-if="loading" class="loading">
@@ -41,14 +35,25 @@
</span>
</div>
</div>
<button
class="btn btn-primary btn-reserve"
:disabled="!space.is_active"
@click="handleReserve"
>
<Plus :size="18" />
{{ showBookingForm ? 'Cancel Reservation' : 'Reserve Space' }}
</button>
<div class="header-actions">
<button
class="btn btn-primary btn-reserve"
:disabled="!space.is_active"
@click="handleReserve"
>
<Plus :size="18" />
{{ showBookingForm ? 'Cancel' : 'Reserve Space' }}
</button>
<button
v-if="isAdmin"
class="btn btn-secondary btn-reserve"
:disabled="!space.is_active"
@click="showAdminBookingForm = true"
>
<UserPlus :size="18" />
Book for User
</button>
</div>
</div>
<!-- Description -->
@@ -76,24 +81,48 @@
/>
</div>
</div>
<!-- Admin Booking Modal -->
<div v-if="showAdminBookingForm && space" class="modal" @click.self="showAdminBookingForm = false">
<div class="modal-content">
<h3>Admin: Book for User</h3>
<AdminBookingForm
:space-id="space.id"
@submit="handleAdminBookingSubmit"
@cancel="showAdminBookingForm = false"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ref, computed, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { spacesApi, handleApiError } from '@/services/api'
import Breadcrumb from '@/components/Breadcrumb.vue'
import SpaceCalendar from '@/components/SpaceCalendar.vue'
import BookingForm from '@/components/BookingForm.vue'
import { Users, Plus } from 'lucide-vue-next'
import AdminBookingForm from '@/components/AdminBookingForm.vue'
import { useAuthStore } from '@/stores/auth'
import { Users, Plus, UserPlus } from 'lucide-vue-next'
import type { Space } from '@/types'
const route = useRoute()
const authStore = useAuthStore()
const isAdmin = computed(() => authStore.user?.role === 'admin')
const breadcrumbItems = computed(() => [
{ label: 'Dashboard', to: '/dashboard' },
{ label: 'Spaces', to: '/spaces' },
{ label: space.value?.name || 'Loading...' }
])
const space = ref<Space | null>(null)
const loading = ref(true)
const error = ref('')
const showBookingForm = ref(false)
const showAdminBookingForm = ref(false)
const calendarRef = ref<InstanceType<typeof SpaceCalendar> | null>(null)
// Format space type for display
@@ -150,42 +179,18 @@ const handleBookingSubmit = () => {
calendarRef.value?.refresh()
}
// Handle admin booking form submit
const handleAdminBookingSubmit = () => {
showAdminBookingForm.value = false
calendarRef.value?.refresh()
}
onMounted(() => {
loadSpace()
})
</script>
<style scoped>
/* Breadcrumbs */
.breadcrumbs {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 24px;
font-size: 14px;
color: var(--color-text-secondary);
}
.breadcrumbs a {
color: var(--color-accent);
text-decoration: none;
transition: color var(--transition-fast);
}
.breadcrumbs a:hover {
color: var(--color-accent-hover);
text-decoration: underline;
}
.breadcrumbs .separator {
color: var(--color-text-muted);
}
.breadcrumbs .current {
color: var(--color-text-primary);
font-weight: 500;
}
/* Loading State */
.loading {
display: flex;
@@ -335,8 +340,14 @@ onMounted(() => {
box-shadow: var(--shadow-md);
}
.header-actions {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
.btn-reserve {
min-width: 180px;
min-width: 160px;
justify-content: center;
}
@@ -413,31 +424,6 @@ onMounted(() => {
font-size: 24px;
}
:deep(.fc .fc-toolbar) {
flex-direction: column;
gap: 8px;
align-items: stretch !important;
}
:deep(.fc .fc-toolbar-chunk) {
width: 100%;
display: flex;
justify-content: center;
}
:deep(.fc .fc-toolbar-title) {
font-size: 1.2em;
margin: 0;
}
:deep(.fc .fc-button) {
padding: 6px 10px;
font-size: 0.85em;
}
:deep(.fc .fc-col-header-cell) {
font-size: 0.75em;
padding: 4px 2px;
}
/* Calendar mobile styles handled by SpaceCalendar component */
}
</style>

View File

@@ -1,5 +1,6 @@
<template>
<div class="spaces">
<Breadcrumb :items="breadcrumbItems" />
<div class="spaces-header">
<div>
<h2>Available Spaces</h2>
@@ -84,6 +85,24 @@
</div>
</div>
<!-- Upcoming Bookings Preview -->
<div class="bookings-preview">
<div v-if="getUpcomingBookings(space.id).length > 0" class="bookings-preview-list">
<div
v-for="booking in getUpcomingBookings(space.id)"
:key="booking.id"
class="booking-preview-item"
>
<Clock :size="14" class="booking-preview-icon" />
<div class="booking-preview-info">
<span class="booking-preview-title">{{ booking.title }}</span>
<span class="booking-preview-time">{{ formatBookingDate(booking.start_datetime) }} {{ formatBookingTime(booking.start_datetime, booking.end_datetime) }}</span>
</div>
</div>
</div>
<div v-else class="bookings-preview-empty">No upcoming bookings</div>
</div>
<div class="space-card-footer">
<button class="btn btn-secondary">
View Details
@@ -98,17 +117,28 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { spacesApi, handleApiError } from '@/services/api'
import { Building2, Tag, Users, ChevronRight, MapPin } from 'lucide-vue-next'
import type { Space } from '@/types'
import { spacesApi, bookingsApi, handleApiError } from '@/services/api'
import { useAuthStore } from '@/stores/auth'
import { formatDate as formatDateTZ, formatTime as formatTimeTZ } from '@/utils/datetime'
import Breadcrumb from '@/components/Breadcrumb.vue'
import { Building2, Tag, Users, ChevronRight, MapPin, Clock } from 'lucide-vue-next'
import type { Space, Booking } from '@/types'
const router = useRouter()
const authStore = useAuthStore()
const userTimezone = computed(() => authStore.user?.timezone || 'UTC')
const breadcrumbItems = [
{ label: 'Dashboard', to: '/dashboard' },
{ label: 'Spaces' }
]
const spaces = ref<Space[]>([])
const loading = ref(true)
const error = ref('')
const selectedType = ref('')
const selectedStatus = ref('')
const spaceBookings = ref<Map<number, Booking[]>>(new Map())
// Format space type for display
const formatType = (type: string): string => {
@@ -143,6 +173,44 @@ const filteredSpaces = computed(() => {
})
})
// Get upcoming bookings for a space (max 3)
const getUpcomingBookings = (spaceId: number): Booking[] => {
return spaceBookings.value.get(spaceId) || []
}
const formatBookingDate = (datetime: string): string => {
return formatDateTZ(datetime, userTimezone.value)
}
const formatBookingTime = (start: string, end: string): string => {
return `${formatTimeTZ(start, userTimezone.value)} - ${formatTimeTZ(end, userTimezone.value)}`
}
// Load bookings preview for all spaces
const loadSpaceBookings = async (spaceList: Space[]) => {
const now = new Date()
const future = new Date(now.getTime() + 14 * 24 * 60 * 60 * 1000) // 2 weeks ahead
const results = await Promise.allSettled(
spaceList.map(async (space) => {
const bookings = await bookingsApi.getForSpace(space.id, now.toISOString(), future.toISOString())
const upcoming = bookings
.filter(b => b.status === 'approved')
.sort((a, b) => new Date(a.start_datetime).getTime() - new Date(b.start_datetime).getTime())
.slice(0, 3)
return { spaceId: space.id, bookings: upcoming }
})
)
const newMap = new Map<number, Booking[]>()
for (const result of results) {
if (result.status === 'fulfilled') {
newMap.set(result.value.spaceId, result.value.bookings)
}
}
spaceBookings.value = newMap
}
// Load spaces from API
const loadSpaces = async () => {
loading.value = true
@@ -151,6 +219,8 @@ const loadSpaces = async () => {
try {
const data = await spacesApi.list()
spaces.value = data
// Load bookings preview in background (non-blocking)
loadSpaceBookings(data)
} catch (err) {
error.value = handleApiError(err)
} finally {
@@ -402,6 +472,58 @@ onMounted(() => {
margin-top: 12px;
}
/* Bookings Preview */
.bookings-preview {
border-top: 1px solid var(--color-border-light, var(--color-border));
padding-top: 12px;
margin-bottom: 16px;
}
.bookings-preview-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.booking-preview-item {
display: flex;
align-items: flex-start;
gap: 8px;
}
.booking-preview-icon {
color: var(--color-accent);
flex-shrink: 0;
margin-top: 2px;
}
.booking-preview-info {
display: flex;
flex-direction: column;
gap: 1px;
min-width: 0;
}
.booking-preview-title {
font-size: 13px;
font-weight: 500;
color: var(--color-text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.booking-preview-time {
font-size: 11px;
color: var(--color-text-muted);
}
.bookings-preview-empty {
font-size: 12px;
color: var(--color-text-muted);
font-style: italic;
}
.space-card-footer {
display: flex;
justify-content: flex-end;

View File

@@ -1,5 +1,6 @@
<template>
<div class="user-profile">
<Breadcrumb :items="breadcrumbItems" />
<h2>User Profile</h2>
<!-- Profile Information Card -->
@@ -74,9 +75,18 @@
Your approved bookings will automatically sync to your Google Calendar.
</p>
<button @click="disconnectGoogle" class="btn btn-danger" :disabled="disconnecting">
{{ disconnecting ? 'Disconnecting...' : 'Disconnect Google Calendar' }}
</button>
<div class="button-group">
<button @click="syncGoogle" class="btn btn-primary" :disabled="syncing">
{{ syncing ? 'Syncing...' : 'Sync Now' }}
</button>
<button @click="disconnectGoogle" class="btn btn-danger" :disabled="disconnecting">
{{ disconnecting ? 'Disconnecting...' : 'Disconnect' }}
</button>
</div>
<div v-if="syncResult" class="sync-result">
Synced {{ syncResult.synced }} bookings ({{ syncResult.created }} created, {{ syncResult.updated }} updated<span v-if="syncResult.failed">, {{ syncResult.failed }} failed</span>)
</div>
</div>
<div v-else class="google-disconnected">
@@ -125,10 +135,16 @@ import { ref, computed, onMounted } from 'vue'
import { usersApi, googleCalendarApi, handleApiError } from '@/services/api'
import { useAuthStore } from '@/stores/auth'
import { formatDateTime as formatDateTimeUtil } from '@/utils/datetime'
import Breadcrumb from '@/components/Breadcrumb.vue'
import CollapsibleSection from '@/components/CollapsibleSection.vue'
import { User as UserIcon, Globe, CalendarDays, CheckCircle, Info } from 'lucide-vue-next'
import type { User } from '@/types'
const breadcrumbItems = [
{ label: 'Dashboard', to: '/dashboard' },
{ label: 'Profile' }
]
const authStore = useAuthStore()
const userTimezone = computed(() => authStore.user?.timezone || 'UTC')
@@ -136,6 +152,8 @@ const user = ref<User | null>(null)
const loadingGoogleStatus = ref(true)
const connecting = ref(false)
const disconnecting = ref(false)
const syncing = ref(false)
const syncResult = ref<{ synced: number; created: number; updated: number; failed: number } | null>(null)
const error = ref('')
const success = ref('')
@@ -296,6 +314,27 @@ const disconnectGoogle = async () => {
}
}
const syncGoogle = async () => {
error.value = ''
success.value = ''
syncResult.value = null
syncing.value = true
try {
const result = await googleCalendarApi.sync()
syncResult.value = result
success.value = 'Calendar synced successfully!'
setTimeout(() => {
success.value = ''
syncResult.value = null
}, 5000)
} catch (err) {
error.value = handleApiError(err)
} finally {
syncing.value = false
}
}
const formatDate = (dateString: string): string => {
return formatDateTimeUtil(dateString, userTimezone.value)
}
@@ -420,6 +459,20 @@ h2 {
cursor: not-allowed;
}
.button-group {
display: flex;
gap: 0.75rem;
align-items: center;
}
.sync-result {
padding: 0.5rem 0.75rem;
background: color-mix(in srgb, var(--color-info) 10%, transparent);
border-radius: var(--radius-sm);
color: var(--color-info);
font-size: 0.9rem;
}
.error {
padding: 0.75rem;
background: color-mix(in srgb, var(--color-danger) 10%, transparent);

View File

@@ -1,5 +1,6 @@
<template>
<div class="users">
<Breadcrumb :items="breadcrumbItems" />
<div class="page-header">
<h2>Admin Dashboard - User Management</h2>
<button class="btn btn-primary" @click="openCreateModal">
@@ -201,10 +202,17 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { usersApi, handleApiError } from '@/services/api'
import Breadcrumb from '@/components/Breadcrumb.vue'
import CollapsibleSection from '@/components/CollapsibleSection.vue'
import { Users as UsersIcon, UserPlus, Filter } from 'lucide-vue-next'
import type { User } from '@/types'
const breadcrumbItems = [
{ label: 'Dashboard', to: '/dashboard' },
{ label: 'Admin', to: '/admin' },
{ label: 'Users' }
]
const users = ref<User[]>([])
const loadingUsers = ref(false)
const loading = ref(false)