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>
This commit is contained in:
Claude Agent
2026-02-15 00:17:21 +00:00
parent d637513d92
commit e21cf03a16
51 changed files with 6324 additions and 273 deletions

View File

@@ -5,6 +5,8 @@
<span v-show="showLabels" class="sidebar-title">Space Booking</span>
</div>
<PropertySelector v-show="showLabels" />
<nav class="sidebar-nav">
<div class="nav-section">
<span v-show="showLabels" class="nav-section-label">Main</span>
@@ -21,10 +23,25 @@
</router-link>
</div>
<div v-if="authStore.isAdmin" class="nav-section">
<div v-if="authStore.isAdminOrManager" class="nav-section">
<span v-show="showLabels" class="nav-section-label">Management</span>
<router-link
v-for="item in managerNav"
:key="item.to"
:to="item.to"
class="nav-link"
:class="{ active: isActive(item.to) }"
@click="closeMobile"
>
<component :is="item.icon" :size="20" class="nav-icon" />
<span v-show="showLabels" class="nav-label">{{ item.label }}</span>
</router-link>
</div>
<div v-if="authStore.isSuperadmin" class="nav-section">
<span v-show="showLabels" class="nav-section-label">Admin</span>
<router-link
v-for="item in adminNav"
v-for="item in superadminNav"
:key="item.to"
:to="item.to"
class="nav-link"
@@ -72,6 +89,7 @@ import { useRoute, useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { useSidebar } from '@/composables/useSidebar'
import { useTheme } from '@/composables/useTheme'
import PropertySelector from '@/components/PropertySelector.vue'
import {
LayoutDashboard,
Building2,
@@ -86,7 +104,8 @@ import {
Moon,
ChevronLeft,
ChevronRight,
LogOut
LogOut,
Landmark
} from 'lucide-vue-next'
const authStore = useAuthStore()
@@ -111,8 +130,12 @@ const mainNav = [
{ to: '/profile', icon: User, label: 'Profile' },
]
const adminNav = [
const managerNav = [
{ to: '/properties', icon: Landmark, label: 'Properties' },
{ to: '/admin', icon: Settings2, label: 'Spaces Admin' },
]
const superadminNav = [
{ to: '/users', icon: Users, label: 'Users' },
{ to: '/admin/settings', icon: Sliders, label: 'Settings' },
{ to: '/admin/reports', icon: BarChart3, label: 'Reports' },

View File

@@ -19,7 +19,21 @@
<span>{{ booking.space?.name || 'Unknown Space' }}</span>
</div>
<div v-if="isAdmin && booking.user" class="detail-row">
<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 }}
@@ -127,7 +141,7 @@ const actionButtons = computed<ActionButton[]>(() => {
buttons.push({ key: 'reject', label: 'Reject', icon: XCircle })
}
if (status === 'pending') {
if (status === 'pending' || status === 'approved') {
buttons.push({ key: 'edit', label: 'Edit', icon: Pencil })
}
@@ -273,6 +287,18 @@ onUnmounted(() => {
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);

View File

@@ -3,9 +3,17 @@
<div class="row-time">
{{ formatTimeRange(booking.start_datetime, booking.end_datetime) }}
</div>
<div class="row-space">{{ booking.space?.name || 'Space' }}</div>
<div v-if="showUser && booking.user" class="row-user">
{{ booking.user.full_name }}
<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}`]">
@@ -136,6 +144,18 @@ const formatTimeRange = (start: string, end: string): string => {
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 {
@@ -188,6 +208,18 @@ const formatTimeRange = (start: string, end: string): string => {
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;

View File

@@ -80,7 +80,7 @@ const emit = defineEmits<{
const authStore = useAuthStore()
const isMobile = useIsMobile()
const isAdmin = computed(() => authStore.user?.role === 'admin')
const isAdmin = computed(() => authStore.user?.role === 'admin' || authStore.user?.role === 'superadmin' || authStore.user?.role === 'manager')
const isEditable = computed(() => isAdmin.value)
const userTimezone = computed(() => authStore.user?.timezone || 'UTC')
const bookings = ref<Booking[]>([])

View File

@@ -0,0 +1,87 @@
<template>
<div v-if="authStore.isAdminOrManager && propertyStore.properties.length > 0" class="property-selector">
<label class="selector-label">
<Landmark :size="14" />
<span>Property</span>
</label>
<select
:value="propertyStore.currentPropertyId"
@change="handleChange"
class="property-select"
>
<option :value="null">All Properties</option>
<option
v-for="prop in propertyStore.properties"
:key="prop.id"
:value="prop.id"
>
{{ prop.name }}
</option>
</select>
</div>
</template>
<script setup lang="ts">
import { onMounted } from 'vue'
import { useAuthStore } from '@/stores/auth'
import { usePropertyStore } from '@/stores/property'
import { Landmark } from 'lucide-vue-next'
const authStore = useAuthStore()
const propertyStore = usePropertyStore()
const handleChange = (event: Event) => {
const value = (event.target as HTMLSelectElement).value
propertyStore.setCurrentProperty(value ? Number(value) : null)
}
onMounted(() => {
if (authStore.isAdminOrManager && propertyStore.properties.length === 0) {
propertyStore.fetchMyProperties()
}
})
</script>
<style scoped>
.property-selector {
padding: 0.75rem 1rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.selector-label {
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--sidebar-text);
opacity: 0.6;
margin-bottom: 6px;
}
.property-select {
width: 100%;
padding: 8px 10px;
border-radius: var(--radius-sm);
border: 1px solid rgba(255, 255, 255, 0.15);
background: var(--sidebar-hover-bg);
color: var(--sidebar-text-active);
font-size: 13px;
font-weight: 500;
cursor: pointer;
outline: none;
transition: border-color var(--transition-fast);
}
.property-select:focus {
border-color: var(--color-accent);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--color-accent) 20%, transparent);
}
.property-select option {
background: var(--color-surface);
color: var(--color-text-primary);
}
</style>

View File

@@ -57,7 +57,8 @@ import dayGridPlugin from '@fullcalendar/daygrid'
import timeGridPlugin from '@fullcalendar/timegrid'
import listPlugin from '@fullcalendar/list'
import interactionPlugin from '@fullcalendar/interaction'
import type { CalendarOptions, EventInput, DatesSetArg, EventDropArg, EventResizeDoneArg } from '@fullcalendar/core'
import type { CalendarOptions, EventInput, DatesSetArg, EventDropArg } from '@fullcalendar/core'
import type { EventResizeDoneArg } from '@fullcalendar/interaction'
import { bookingsApi, adminBookingsApi, handleApiError } from '@/services/api'
import { useAuthStore } from '@/stores/auth'
import { formatDateTime as formatDateTimeUtil } from '@/utils/datetime'
@@ -67,6 +68,7 @@ import type { Booking } from '@/types'
interface Props {
spaceId: number
spaceName?: string
}
const props = defineProps<Props>()
@@ -101,16 +103,24 @@ const confirmModal = ref<ConfirmModal>({
revertFunc: null
})
// Admin can edit, users see read-only
const isEditable = computed(() => authStore.user?.role === 'admin')
// Admin/superadmin/manager can edit, users see read-only
const isEditable = computed(() => ['admin', 'superadmin', 'manager'].includes(authStore.user?.role || ''))
// Emits for parent to handle actions
const emit = defineEmits<{
(e: 'edit-booking', booking: Booking): void
(e: 'cancel-booking', booking: Booking): void
(e: 'approve-booking', booking: Booking): void
(e: 'reject-booking', booking: Booking): void
}>()
// Preview modal state
const selectedBooking = ref<Booking | null>(null)
const showPreview = ref(false)
const handlePreviewAction = (_action: string, _booking: Booking) => {
const handlePreviewAction = (action: string, booking: Booking) => {
showPreview.value = false
refresh()
emit(`${action}-booking` as any, booking)
}
// Status to color mapping
@@ -302,7 +312,12 @@ const calendarOptions = computed<CalendarOptions>(() => ({
const bookingId = parseInt(info.event.id)
const booking = bookings.value.find((b) => b.id === bookingId)
if (booking) {
selectedBooking.value = booking
// Inject space name if not present and we have it from props
if (!booking.space && props.spaceName) {
selectedBooking.value = { ...booking, space: { id: props.spaceId, name: props.spaceName } as any }
} else {
selectedBooking.value = booking
}
showPreview.value = true
}
},
@@ -318,9 +333,9 @@ const calendarOptions = computed<CalendarOptions>(() => ({
}
},
// Event allow callback
eventAllow: (dropInfo, draggedEvent) => {
eventAllow: (_dropInfo, draggedEvent) => {
// Only allow dragging approved bookings
return draggedEvent.extendedProps.status === 'approved'
return draggedEvent != null && draggedEvent.extendedProps.status === 'approved'
}
}))