Files
space-booking/frontend/src/components/BookingPreviewModal.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

444 lines
10 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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="booking.is_anonymous && booking.guest_name" class="detail-row">
<UserIcon :size="16" class="detail-icon" />
<span>
{{ booking.guest_name }}
<span class="detail-guest-badge">Guest</span>
<span v-if="booking.guest_email" class="detail-muted">
&middot; {{ booking.guest_email }}
</span>
<span v-if="booking.guest_organization" class="detail-muted">
&middot; {{ booking.guest_organization }}
</span>
</span>
</div>
<div v-else-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' || status === 'approved') {
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);
}
.detail-guest-badge {
display: inline-block;
padding: 1px 6px;
font-size: 10px;
font-weight: 600;
border-radius: 6px;
background: color-mix(in srgb, var(--color-warning) 15%, transparent);
color: var(--color-warning);
vertical-align: middle;
margin-left: 4px;
}
/* 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>