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

250 lines
5.8 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>
<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' }}
<span v-if="booking.space?.property_name" class="row-property">{{ booking.space.property_name }}</span>
</div>
<div v-if="showUser" class="row-user">
<template v-if="booking.is_anonymous && booking.guest_name">
{{ booking.guest_name }} <span class="guest-badge">Guest</span>
</template>
<template v-else-if="booking.user">
{{ booking.user.full_name }}
</template>
</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;
display: flex;
align-items: center;
gap: 6px;
}
.row-property {
font-size: 10px;
font-weight: 600;
color: var(--color-text-muted);
background: var(--color-bg-tertiary);
padding: 1px 6px;
border-radius: 6px;
}
.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);
}
.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;
}
@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>