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:
@@ -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' },
|
||||
|
||||
@@ -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">
|
||||
· {{ 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 }}
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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[]>([])
|
||||
|
||||
87
frontend/src/components/PropertySelector.vue
Normal file
87
frontend/src/components/PropertySelector.vue
Normal 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>
|
||||
@@ -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'
|
||||
}
|
||||
}))
|
||||
|
||||
|
||||
Reference in New Issue
Block a user