Implement complete multi-property architecture: - Properties (groups of spaces) with public/private visibility - Property managers (many-to-many) with role-based permissions - Organizations with member management - Anonymous/guest booking support via public API (/api/public/*) - Property-scoped spaces, bookings, and settings - Frontend: property selector, organization management, public booking views - Migration script and updated seed data Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1044 lines
27 KiB
Vue
1044 lines
27 KiB
Vue
<template>
|
|
<div class="space-detail">
|
|
<!-- Breadcrumbs -->
|
|
<Breadcrumb :items="breadcrumbItems" />
|
|
|
|
<!-- Loading State -->
|
|
<div v-if="loading" class="loading">
|
|
<div class="spinner"></div>
|
|
<p>Loading space details...</p>
|
|
</div>
|
|
|
|
<!-- Error State -->
|
|
<div v-else-if="error" class="error-container">
|
|
<div class="error-card">
|
|
<h3>Error Loading Space</h3>
|
|
<p>{{ error }}</p>
|
|
<router-link to="/spaces" class="btn btn-primary">Back to Spaces</router-link>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Space Details -->
|
|
<div v-else-if="space" class="space-content">
|
|
<!-- Header Section -->
|
|
<div class="space-header">
|
|
<div class="header-info">
|
|
<h1>{{ space.name }}</h1>
|
|
<div class="space-meta">
|
|
<span class="badge badge-type">{{ formatType(space.type) }}</span>
|
|
<span class="badge badge-capacity">
|
|
<Users :size="16" />
|
|
Capacity: {{ space.capacity }}
|
|
</span>
|
|
<span :class="['badge', space.is_active ? 'badge-active' : 'badge-inactive']">
|
|
{{ space.is_active ? 'Active' : 'Inactive' }}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<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 -->
|
|
<div v-if="space.description" class="card description-card">
|
|
<h3>Description</h3>
|
|
<p>{{ space.description }}</p>
|
|
</div>
|
|
|
|
<!-- Calendar Section -->
|
|
<div class="card calendar-card">
|
|
<h3>Availability Calendar</h3>
|
|
<p class="calendar-subtitle">View existing bookings and available time slots</p>
|
|
<SpaceCalendar
|
|
ref="calendarRef"
|
|
:space-id="space.id"
|
|
:space-name="space.name"
|
|
@edit-booking="openEditBookingModal"
|
|
@cancel-booking="handleCancelBooking"
|
|
@approve-booking="handleApproveBooking"
|
|
@reject-booking="openRejectBookingModal"
|
|
/>
|
|
</div>
|
|
|
|
<!-- Bookings List Section -->
|
|
<div class="card bookings-card">
|
|
<div class="bookings-card-header">
|
|
<h3>Bookings</h3>
|
|
<span class="result-count" v-if="!bookingsLoading">{{ spaceBookings.length }} bookings</span>
|
|
</div>
|
|
|
|
<div v-if="bookingsLoading" class="bookings-loading">Loading bookings...</div>
|
|
<div v-else-if="spaceBookings.length === 0" class="bookings-empty">No bookings found for this space.</div>
|
|
|
|
<table v-else class="bookings-table">
|
|
<thead>
|
|
<tr>
|
|
<th>User</th>
|
|
<th>Date</th>
|
|
<th>Time</th>
|
|
<th>Title</th>
|
|
<th>Status</th>
|
|
<th v-if="isAdmin">Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-for="b in spaceBookings" :key="b.id">
|
|
<td class="cell-user">{{ b.user?.full_name || b.guest_name || 'Unknown' }}</td>
|
|
<td>{{ formatBookingDate(b.start_datetime) }}</td>
|
|
<td class="cell-time">{{ formatBookingTime(b.start_datetime) }} - {{ formatBookingTime(b.end_datetime) }}</td>
|
|
<td class="cell-title">{{ b.title }}</td>
|
|
<td><span :class="['badge-status', `badge-${b.status}`]">{{ b.status }}</span></td>
|
|
<td v-if="isAdmin" class="cell-actions">
|
|
<button
|
|
v-if="b.status === 'pending'"
|
|
class="btn-action btn-action-approve"
|
|
title="Approve"
|
|
@click="handleApproveBooking(b)"
|
|
>
|
|
<Check :size="14" />
|
|
</button>
|
|
<button
|
|
v-if="b.status === 'pending'"
|
|
class="btn-action btn-action-reject"
|
|
title="Reject"
|
|
@click="openRejectBookingModal(b)"
|
|
>
|
|
<XIcon :size="14" />
|
|
</button>
|
|
<button
|
|
v-if="b.status === 'pending' || b.status === 'approved'"
|
|
class="btn-action btn-action-edit"
|
|
title="Edit"
|
|
@click="openEditBookingModal(b)"
|
|
>
|
|
<Pencil :size="14" />
|
|
</button>
|
|
<button
|
|
v-if="b.status === 'pending' || b.status === 'approved'"
|
|
class="btn-action btn-action-cancel"
|
|
title="Cancel"
|
|
@click="handleCancelBooking(b)"
|
|
>
|
|
<Ban :size="14" />
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Booking Modal -->
|
|
<div v-if="showBookingForm && space" class="modal" @click.self="closeBookingModal">
|
|
<div class="modal-content">
|
|
<h3>Create Booking</h3>
|
|
<BookingForm
|
|
:space-id="space.id"
|
|
@submit="handleBookingSubmit"
|
|
@cancel="closeBookingModal"
|
|
/>
|
|
</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>
|
|
|
|
<!-- Edit Booking Modal -->
|
|
<div v-if="showEditModal" class="modal" @click.self="closeEditModal">
|
|
<div class="modal-content">
|
|
<h3>Edit Booking</h3>
|
|
<form @submit.prevent="saveEdit">
|
|
<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..."></textarea>
|
|
</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="closeEditModal">Cancel</button>
|
|
<button type="submit" class="btn btn-primary" :disabled="editSaving">{{ editSaving ? 'Saving...' : 'Save Changes' }}</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Confirm Modal -->
|
|
<div v-if="showConfirmModal" class="modal" @click.self="showConfirmModal = false">
|
|
<div class="modal-content">
|
|
<h3>{{ confirmTitle }}</h3>
|
|
<p class="confirm-text">{{ confirmMessage }}</p>
|
|
<div class="form-actions">
|
|
<button type="button" class="btn btn-secondary" @click="showConfirmModal = false" :disabled="confirmLoading">Cancel</button>
|
|
<button type="button" :class="['btn', confirmDanger ? 'btn-danger' : 'btn-primary']" @click="executeConfirm" :disabled="confirmLoading">
|
|
{{ confirmLoading ? 'Processing...' : confirmLabel }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Reject Modal -->
|
|
<div v-if="showRejectModal" class="modal" @click.self="showRejectModal = false">
|
|
<div class="modal-content">
|
|
<h3>Reject Booking</h3>
|
|
<p class="confirm-text">Rejecting "{{ rejectBooking?.title }}"</p>
|
|
<div class="form-group">
|
|
<label for="reject-reason">Reason (optional)</label>
|
|
<textarea id="reject-reason" v-model="rejectReason" rows="3" placeholder="Enter rejection reason..."></textarea>
|
|
</div>
|
|
<div class="form-actions">
|
|
<button type="button" class="btn btn-secondary" @click="showRejectModal = false">Cancel</button>
|
|
<button type="button" class="btn btn-danger" @click="doReject" :disabled="rejectLoading">
|
|
{{ rejectLoading ? 'Rejecting...' : 'Reject' }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Toast -->
|
|
<div v-if="toastMsg" :class="['toast', `toast-${toastType}`]">{{ toastMsg }}</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, computed, onMounted } from 'vue'
|
|
import { useRoute } from 'vue-router'
|
|
import { spacesApi, bookingsApi, adminBookingsApi, handleApiError } from '@/services/api'
|
|
import {
|
|
formatDate as formatDateTZ,
|
|
formatTime as formatTimeTZ,
|
|
isoToLocalDateTime,
|
|
localDateTimeToISO
|
|
} from '@/utils/datetime'
|
|
import Breadcrumb from '@/components/Breadcrumb.vue'
|
|
import SpaceCalendar from '@/components/SpaceCalendar.vue'
|
|
import BookingForm from '@/components/BookingForm.vue'
|
|
import AdminBookingForm from '@/components/AdminBookingForm.vue'
|
|
import { useAuthStore } from '@/stores/auth'
|
|
import { Users, Plus, UserPlus, Check, X as XIcon, Pencil, Ban } from 'lucide-vue-next'
|
|
import type { Space, Booking } from '@/types'
|
|
|
|
const route = useRoute()
|
|
const authStore = useAuthStore()
|
|
const isAdmin = computed(() => ['admin', 'superadmin', 'manager'].includes(authStore.user?.role || ''))
|
|
const userTimezone = computed(() => authStore.user?.timezone || 'UTC')
|
|
|
|
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)
|
|
|
|
// Bookings list
|
|
const spaceBookings = ref<Booking[]>([])
|
|
const bookingsLoading = ref(false)
|
|
|
|
// Toast
|
|
const toastMsg = ref('')
|
|
const toastType = ref<'success' | 'error'>('success')
|
|
const showToast = (msg: string, type: 'success' | 'error') => {
|
|
toastMsg.value = msg
|
|
toastType.value = type
|
|
setTimeout(() => { toastMsg.value = '' }, type === 'success' ? 3000 : 5000)
|
|
}
|
|
|
|
// Edit modal
|
|
const showEditModal = ref(false)
|
|
const editingBooking = ref<Booking | null>(null)
|
|
const editForm = ref({ title: '', description: '', start_date: '', start_time: '', end_date: '', end_time: '' })
|
|
const editError = ref('')
|
|
const editSaving = ref(false)
|
|
|
|
// Confirm modal
|
|
const showConfirmModal = ref(false)
|
|
const confirmTitle = ref('')
|
|
const confirmMessage = ref('')
|
|
const confirmDanger = ref(false)
|
|
const confirmLabel = ref('Yes')
|
|
const confirmLoading = ref(false)
|
|
const onConfirm = ref<(() => Promise<void>) | null>(null)
|
|
|
|
// Reject modal
|
|
const showRejectModal = ref(false)
|
|
const rejectBooking = ref<Booking | null>(null)
|
|
const rejectReason = ref('')
|
|
const rejectLoading = ref(false)
|
|
|
|
// Format helpers
|
|
const formatBookingDate = (datetime: string): string => formatDateTZ(datetime, userTimezone.value)
|
|
const formatBookingTime = (datetime: string): string => formatTimeTZ(datetime, userTimezone.value)
|
|
|
|
// Format space type for display
|
|
const formatType = (type: string): string => {
|
|
const typeMap: Record<string, string> = {
|
|
sala: 'Sala',
|
|
birou: 'Birou'
|
|
}
|
|
return typeMap[type] || type
|
|
}
|
|
|
|
// Load space details
|
|
const loadSpace = async () => {
|
|
loading.value = true
|
|
error.value = ''
|
|
|
|
try {
|
|
const spaceId = Number(route.params.id)
|
|
|
|
if (isNaN(spaceId)) {
|
|
error.value = 'Invalid space ID'
|
|
return
|
|
}
|
|
|
|
// Fetch all spaces and filter by ID
|
|
const spaces = await spacesApi.list()
|
|
const foundSpace = spaces.find((s) => s.id === spaceId)
|
|
|
|
if (!foundSpace) {
|
|
error.value = 'Space not found (404). The space may not exist or has been removed.'
|
|
} else {
|
|
space.value = foundSpace
|
|
loadSpaceBookings()
|
|
}
|
|
} catch (err) {
|
|
error.value = handleApiError(err)
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
|
|
// Load bookings for this space
|
|
const loadSpaceBookings = async () => {
|
|
if (!space.value) return
|
|
bookingsLoading.value = true
|
|
try {
|
|
const now = new Date()
|
|
const start = new Date(now.getFullYear(), now.getMonth() - 1, 1)
|
|
const end = new Date(now.getFullYear(), now.getMonth() + 3, 0)
|
|
spaceBookings.value = await bookingsApi.getForSpace(space.value.id, start.toISOString(), end.toISOString())
|
|
// Sort by date descending
|
|
spaceBookings.value.sort((a, b) => new Date(b.start_datetime).getTime() - new Date(a.start_datetime).getTime())
|
|
} catch (err) {
|
|
// Non-critical
|
|
} finally {
|
|
bookingsLoading.value = false
|
|
}
|
|
}
|
|
|
|
const refreshAll = () => {
|
|
calendarRef.value?.refresh()
|
|
loadSpaceBookings()
|
|
}
|
|
|
|
// Handle reserve button click
|
|
const handleReserve = () => {
|
|
showBookingForm.value = !showBookingForm.value
|
|
}
|
|
|
|
// Close booking modal
|
|
const closeBookingModal = () => {
|
|
showBookingForm.value = false
|
|
}
|
|
|
|
// Handle booking form submit
|
|
const handleBookingSubmit = () => {
|
|
showBookingForm.value = false
|
|
refreshAll()
|
|
}
|
|
|
|
// Handle admin booking form submit
|
|
const handleAdminBookingSubmit = () => {
|
|
showAdminBookingForm.value = false
|
|
refreshAll()
|
|
}
|
|
|
|
// --- Calendar action handlers ---
|
|
|
|
const openConfirm = (opts: { title: string; message: string; danger?: boolean; label?: string; action: () => Promise<void> }) => {
|
|
confirmTitle.value = opts.title
|
|
confirmMessage.value = opts.message
|
|
confirmDanger.value = opts.danger ?? false
|
|
confirmLabel.value = opts.label ?? 'Yes'
|
|
onConfirm.value = opts.action
|
|
confirmLoading.value = false
|
|
showConfirmModal.value = true
|
|
}
|
|
|
|
const executeConfirm = async () => {
|
|
if (!onConfirm.value) return
|
|
confirmLoading.value = true
|
|
try {
|
|
await onConfirm.value()
|
|
} finally {
|
|
confirmLoading.value = false
|
|
showConfirmModal.value = false
|
|
}
|
|
}
|
|
|
|
const handleApproveBooking = (booking: Booking) => {
|
|
openConfirm({
|
|
title: 'Approve Booking',
|
|
message: `Approve booking "${booking.title}"?`,
|
|
label: 'Approve',
|
|
action: async () => {
|
|
await adminBookingsApi.approve(booking.id)
|
|
showToast(`Booking "${booking.title}" approved!`, 'success')
|
|
refreshAll()
|
|
}
|
|
})
|
|
}
|
|
|
|
const handleCancelBooking = (booking: Booking) => {
|
|
openConfirm({
|
|
title: 'Cancel Booking',
|
|
message: `Cancel booking "${booking.title}"?`,
|
|
danger: true,
|
|
label: 'Cancel Booking',
|
|
action: async () => {
|
|
await adminBookingsApi.cancel(booking.id)
|
|
showToast(`Booking "${booking.title}" canceled.`, 'success')
|
|
refreshAll()
|
|
}
|
|
})
|
|
}
|
|
|
|
const openRejectBookingModal = (booking: Booking) => {
|
|
rejectBooking.value = booking
|
|
rejectReason.value = ''
|
|
rejectLoading.value = false
|
|
showRejectModal.value = true
|
|
}
|
|
|
|
const doReject = async () => {
|
|
if (!rejectBooking.value) return
|
|
rejectLoading.value = true
|
|
try {
|
|
await adminBookingsApi.reject(rejectBooking.value.id, rejectReason.value || undefined)
|
|
showToast(`Booking "${rejectBooking.value.title}" rejected.`, 'success')
|
|
showRejectModal.value = false
|
|
refreshAll()
|
|
} catch (err) {
|
|
showToast(handleApiError(err), 'error')
|
|
} finally {
|
|
rejectLoading.value = false
|
|
}
|
|
}
|
|
|
|
const openEditBookingModal = (booking: Booking) => {
|
|
editingBooking.value = booking
|
|
const startLocal = isoToLocalDateTime(booking.start_datetime, userTimezone.value)
|
|
const endLocal = isoToLocalDateTime(booking.end_datetime, userTimezone.value)
|
|
const [startDate, startTime] = startLocal.split('T')
|
|
const [endDate, endTime] = endLocal.split('T')
|
|
editForm.value = {
|
|
title: booking.title,
|
|
description: booking.description || '',
|
|
start_date: startDate,
|
|
start_time: startTime,
|
|
end_date: endDate,
|
|
end_time: endTime
|
|
}
|
|
editError.value = ''
|
|
showEditModal.value = true
|
|
}
|
|
|
|
const closeEditModal = () => {
|
|
showEditModal.value = false
|
|
editingBooking.value = null
|
|
editError.value = ''
|
|
}
|
|
|
|
const saveEdit = async () => {
|
|
if (!editingBooking.value) return
|
|
editSaving.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}`
|
|
if (isAdmin.value) {
|
|
await adminBookingsApi.update(editingBooking.value.id, {
|
|
title: editForm.value.title,
|
|
description: editForm.value.description,
|
|
start_datetime: localDateTimeToISO(startDateTime),
|
|
end_datetime: localDateTimeToISO(endDateTime)
|
|
})
|
|
} else {
|
|
await bookingsApi.update(editingBooking.value.id, {
|
|
title: editForm.value.title,
|
|
description: editForm.value.description,
|
|
start_datetime: localDateTimeToISO(startDateTime),
|
|
end_datetime: localDateTimeToISO(endDateTime)
|
|
})
|
|
}
|
|
closeEditModal()
|
|
showToast('Booking updated successfully!', 'success')
|
|
refreshAll()
|
|
} catch (err) {
|
|
editError.value = handleApiError(err)
|
|
} finally {
|
|
editSaving.value = false
|
|
}
|
|
}
|
|
|
|
onMounted(() => {
|
|
loadSpace()
|
|
})
|
|
</script>
|
|
|
|
<style scoped>
|
|
/* Loading State */
|
|
.loading {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
padding: 80px 20px;
|
|
color: var(--color-text-secondary);
|
|
}
|
|
|
|
.spinner {
|
|
width: 48px;
|
|
height: 48px;
|
|
border: 4px solid var(--color-border);
|
|
border-top-color: var(--color-accent);
|
|
border-radius: 50%;
|
|
animation: spin 0.8s linear infinite;
|
|
margin-bottom: 16px;
|
|
}
|
|
|
|
@keyframes spin {
|
|
to {
|
|
transform: rotate(360deg);
|
|
}
|
|
}
|
|
|
|
/* Error State */
|
|
.error-container {
|
|
display: flex;
|
|
justify-content: center;
|
|
padding: 40px 20px;
|
|
}
|
|
|
|
.error-card {
|
|
background: var(--color-surface);
|
|
border-radius: var(--radius-md);
|
|
padding: 40px;
|
|
box-shadow: var(--shadow-sm);
|
|
border: 1px solid var(--color-border);
|
|
text-align: center;
|
|
max-width: 500px;
|
|
}
|
|
|
|
.error-card h3 {
|
|
color: var(--color-danger);
|
|
margin-bottom: 12px;
|
|
}
|
|
|
|
.error-card p {
|
|
color: var(--color-text-secondary);
|
|
margin-bottom: 24px;
|
|
}
|
|
|
|
/* Space Content */
|
|
.space-content {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 24px;
|
|
}
|
|
|
|
/* Header Section */
|
|
.space-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: flex-start;
|
|
gap: 24px;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.header-info {
|
|
flex: 1;
|
|
min-width: 300px;
|
|
}
|
|
|
|
.header-info h1 {
|
|
font-size: 32px;
|
|
font-weight: 700;
|
|
color: var(--color-text-primary);
|
|
margin-bottom: 16px;
|
|
}
|
|
|
|
.space-meta {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 12px;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.badge {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
padding: 6px 14px;
|
|
border-radius: 16px;
|
|
font-size: 14px;
|
|
font-weight: 500;
|
|
}
|
|
|
|
.badge-type {
|
|
background: var(--color-accent-light);
|
|
color: var(--color-accent);
|
|
}
|
|
|
|
.badge-capacity {
|
|
background: var(--color-bg-tertiary);
|
|
color: var(--color-text-primary);
|
|
}
|
|
|
|
.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: 8px;
|
|
padding: 12px 24px;
|
|
border: none;
|
|
border-radius: var(--radius-md);
|
|
font-size: 16px;
|
|
font-weight: 600;
|
|
cursor: pointer;
|
|
transition: all var(--transition-fast);
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.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);
|
|
transform: translateY(-1px);
|
|
box-shadow: var(--shadow-md);
|
|
}
|
|
|
|
.header-actions {
|
|
display: flex;
|
|
gap: 12px;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.btn-reserve {
|
|
min-width: 160px;
|
|
justify-content: center;
|
|
}
|
|
|
|
/* Cards */
|
|
.card {
|
|
background: var(--color-surface);
|
|
border-radius: var(--radius-md);
|
|
padding: 24px;
|
|
box-shadow: var(--shadow-sm);
|
|
border: 1px solid var(--color-border);
|
|
}
|
|
|
|
.card h3 {
|
|
font-size: 20px;
|
|
font-weight: 600;
|
|
color: var(--color-text-primary);
|
|
margin-bottom: 12px;
|
|
}
|
|
|
|
/* Description Card */
|
|
.description-card p {
|
|
color: var(--color-text-secondary);
|
|
line-height: 1.6;
|
|
}
|
|
|
|
/* Calendar Card */
|
|
.calendar-subtitle {
|
|
color: var(--color-text-secondary);
|
|
font-size: 14px;
|
|
margin-bottom: 20px;
|
|
}
|
|
|
|
/* Bookings List Card */
|
|
.bookings-card-header {
|
|
display: flex;
|
|
align-items: baseline;
|
|
gap: 12px;
|
|
margin-bottom: 16px;
|
|
}
|
|
|
|
.result-count {
|
|
font-size: 13px;
|
|
color: var(--color-text-muted);
|
|
font-weight: 400;
|
|
}
|
|
|
|
.bookings-loading,
|
|
.bookings-empty {
|
|
text-align: center;
|
|
padding: 24px;
|
|
color: var(--color-text-muted);
|
|
font-size: 14px;
|
|
}
|
|
|
|
.bookings-table {
|
|
width: 100%;
|
|
border-collapse: collapse;
|
|
}
|
|
|
|
.bookings-table th {
|
|
text-align: left;
|
|
padding: 10px 12px;
|
|
background: var(--color-bg-secondary);
|
|
font-size: 11px;
|
|
font-weight: 600;
|
|
color: var(--color-text-secondary);
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.5px;
|
|
border-bottom: 1px solid var(--color-border);
|
|
}
|
|
|
|
.bookings-table td {
|
|
padding: 8px 12px;
|
|
border-bottom: 1px solid var(--color-border-light);
|
|
font-size: 13px;
|
|
color: var(--color-text-primary);
|
|
vertical-align: middle;
|
|
}
|
|
|
|
.bookings-table tbody tr:hover {
|
|
background: var(--color-surface-hover);
|
|
}
|
|
|
|
.bookings-table tbody tr:last-child td {
|
|
border-bottom: none;
|
|
}
|
|
|
|
.cell-user {
|
|
font-weight: 500;
|
|
}
|
|
|
|
.cell-time {
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.cell-title {
|
|
max-width: 200px;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.cell-actions {
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.badge-status {
|
|
display: inline-block;
|
|
padding: 3px 10px;
|
|
border-radius: 10px;
|
|
font-size: 11px;
|
|
font-weight: 600;
|
|
text-transform: capitalize;
|
|
}
|
|
|
|
.badge-pending {
|
|
background: color-mix(in srgb, var(--color-warning) 15%, transparent);
|
|
color: var(--color-warning);
|
|
}
|
|
|
|
.badge-approved {
|
|
background: color-mix(in srgb, var(--color-success) 15%, transparent);
|
|
color: var(--color-success);
|
|
}
|
|
|
|
.badge-rejected {
|
|
background: color-mix(in srgb, var(--color-danger) 15%, transparent);
|
|
color: var(--color-danger);
|
|
}
|
|
|
|
.badge-canceled {
|
|
background: var(--color-bg-tertiary);
|
|
color: var(--color-text-muted);
|
|
}
|
|
|
|
.btn-action {
|
|
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;
|
|
margin-right: 4px;
|
|
}
|
|
|
|
.btn-action:hover {
|
|
transform: translateY(-1px);
|
|
box-shadow: var(--shadow-sm);
|
|
}
|
|
|
|
.btn-action-approve:hover { color: var(--color-success); border-color: var(--color-success); }
|
|
.btn-action-reject:hover { color: var(--color-danger); border-color: var(--color-danger); }
|
|
.btn-action-edit:hover { color: var(--color-warning); border-color: var(--color-warning); }
|
|
.btn-action-cancel:hover { color: var(--color-danger); border-color: var(--color-danger); }
|
|
|
|
/* Form styles for modals */
|
|
.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 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-secondary {
|
|
background: var(--color-bg-tertiary);
|
|
color: var(--color-text-primary);
|
|
padding: 10px 20px;
|
|
border: none;
|
|
border-radius: var(--radius-md);
|
|
font-size: 14px;
|
|
font-weight: 500;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.btn-secondary:hover:not(:disabled) {
|
|
background: var(--color-border);
|
|
}
|
|
|
|
.btn-danger {
|
|
background: var(--color-danger);
|
|
color: white;
|
|
padding: 10px 20px;
|
|
border: none;
|
|
border-radius: var(--radius-md);
|
|
font-size: 14px;
|
|
font-weight: 500;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.btn-danger:hover:not(:disabled) {
|
|
background: color-mix(in srgb, var(--color-danger) 85%, black);
|
|
}
|
|
|
|
.confirm-text {
|
|
color: var(--color-text-secondary);
|
|
margin-bottom: 20px;
|
|
line-height: 1.5;
|
|
}
|
|
|
|
/* Toast */
|
|
.toast {
|
|
position: fixed;
|
|
bottom: 24px;
|
|
right: 24px;
|
|
padding: 12px 20px;
|
|
border-radius: var(--radius-md);
|
|
font-size: 14px;
|
|
font-weight: 500;
|
|
z-index: 1100;
|
|
animation: slideUp 0.3s ease;
|
|
box-shadow: var(--shadow-lg);
|
|
}
|
|
|
|
.toast-success { background: var(--color-success); color: #fff; }
|
|
.toast-error { background: var(--color-danger); color: #fff; }
|
|
|
|
@keyframes slideUp {
|
|
from { transform: translateY(20px); opacity: 0; }
|
|
to { transform: translateY(0); opacity: 1; }
|
|
}
|
|
|
|
/* Modal */
|
|
.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%;
|
|
max-height: 90vh;
|
|
overflow-y: auto;
|
|
box-shadow: var(--shadow-lg);
|
|
}
|
|
|
|
.modal-content h3 {
|
|
margin-top: 0;
|
|
margin-bottom: 20px;
|
|
}
|
|
|
|
/* Responsive */
|
|
@media (max-width: 768px) {
|
|
.space-header {
|
|
flex-direction: column;
|
|
}
|
|
|
|
.btn-reserve {
|
|
width: 100%;
|
|
}
|
|
|
|
.header-info h1 {
|
|
font-size: 24px;
|
|
}
|
|
|
|
/* Calendar mobile styles handled by SpaceCalendar component */
|
|
}
|
|
</style>
|