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>
444 lines
10 KiB
Vue
444 lines
10 KiB
Vue
<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">
|
||
· {{ booking.guest_email }}
|
||
</span>
|
||
<span v-if="booking.guest_organization" class="detail-muted">
|
||
· {{ 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">
|
||
· {{ 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>
|