Files
space-booking/frontend/src/views/SpaceDetail.vue
Claude Agent e21cf03a16 feat: add multi-tenant system with properties, organizations, and public booking
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>
2026-02-15 00:17:21 +00:00

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>