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:
@@ -1,10 +1,10 @@
|
||||
<template>
|
||||
<div id="app">
|
||||
<AppSidebar v-if="authStore.isAuthenticated" />
|
||||
<AppSidebar v-if="showSidebar" />
|
||||
|
||||
<div class="app-main" :class="{ 'with-sidebar': authStore.isAuthenticated, 'sidebar-collapsed': collapsed }">
|
||||
<div class="app-main" :class="{ 'with-sidebar': showSidebar, 'sidebar-collapsed': collapsed }">
|
||||
<!-- Mobile header bar -->
|
||||
<div v-if="authStore.isAuthenticated" class="mobile-header">
|
||||
<div v-if="showSidebar" class="mobile-header">
|
||||
<button class="mobile-hamburger" @click="toggleMobile" aria-label="Open menu">
|
||||
<Menu :size="22" />
|
||||
</button>
|
||||
@@ -59,7 +59,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { notificationsApi } from '@/services/api'
|
||||
import { useSidebar } from '@/composables/useSidebar'
|
||||
import type { Notification } from '@/types'
|
||||
@@ -68,6 +68,10 @@ import { Menu, Bell, X } from 'lucide-vue-next'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
const isPublicRoute = computed(() => route.meta.isPublic === true)
|
||||
const showSidebar = computed(() => authStore.isAuthenticated && !isPublicRoute.value)
|
||||
const { collapsed, toggleMobile } = useSidebar()
|
||||
|
||||
const notifications = ref<Notification[]>([])
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
}))
|
||||
|
||||
|
||||
@@ -28,6 +28,12 @@ const router = createRouter({
|
||||
component: () => import('@/views/VerifyEmail.vue'),
|
||||
meta: { requiresAuth: false }
|
||||
},
|
||||
{
|
||||
path: '/book/:propertyId?',
|
||||
name: 'PublicBooking',
|
||||
component: () => import('@/views/PublicBooking.vue'),
|
||||
meta: { requiresAuth: false, isPublic: true }
|
||||
},
|
||||
{
|
||||
path: '/dashboard',
|
||||
name: 'Dashboard',
|
||||
@@ -62,11 +68,29 @@ const router = createRouter({
|
||||
component: () => import('@/views/UserProfile.vue'),
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/properties',
|
||||
name: 'Properties',
|
||||
component: () => import('@/views/Properties.vue'),
|
||||
meta: { requiresAuth: true, requiresManager: true }
|
||||
},
|
||||
{
|
||||
path: '/properties/:id',
|
||||
name: 'PropertyDetail',
|
||||
component: () => import('@/views/PropertyDetail.vue'),
|
||||
meta: { requiresAuth: true, requiresManager: true }
|
||||
},
|
||||
{
|
||||
path: '/organization',
|
||||
name: 'Organization',
|
||||
component: () => import('@/views/Organization.vue'),
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/admin',
|
||||
name: 'Admin',
|
||||
component: () => import('@/views/Admin.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
meta: { requiresAuth: true, requiresManager: true }
|
||||
},
|
||||
{
|
||||
path: '/users',
|
||||
@@ -103,9 +127,13 @@ const router = createRouter({
|
||||
router.beforeEach((to, _from, next) => {
|
||||
const authStore = useAuthStore()
|
||||
|
||||
if (to.meta.requiresAuth && !authStore.isAuthenticated) {
|
||||
if (to.meta.isPublic) {
|
||||
next()
|
||||
} else if (to.meta.requiresAuth && !authStore.isAuthenticated) {
|
||||
next('/login')
|
||||
} else if (to.meta.requiresAdmin && !authStore.isAdmin) {
|
||||
} else if (to.meta.requiresAdmin && !authStore.isSuperadmin) {
|
||||
next('/dashboard')
|
||||
} else if (to.meta.requiresManager && !authStore.isAdminOrManager) {
|
||||
next('/dashboard')
|
||||
} else if (to.path === '/login' && authStore.isAuthenticated) {
|
||||
next('/dashboard')
|
||||
|
||||
@@ -22,7 +22,13 @@ import type {
|
||||
RecurringBookingResult,
|
||||
SpaceUsageReport,
|
||||
TopUsersReport,
|
||||
ApprovalRateReport
|
||||
ApprovalRateReport,
|
||||
Property,
|
||||
PropertySettings,
|
||||
PropertyAccess,
|
||||
Organization,
|
||||
OrganizationMember,
|
||||
AnonymousBookingCreate
|
||||
} from '@/types'
|
||||
|
||||
const api = axios.create({
|
||||
@@ -120,8 +126,8 @@ export const usersApi = {
|
||||
|
||||
// Spaces API
|
||||
export const spacesApi = {
|
||||
list: async (): Promise<Space[]> => {
|
||||
const response = await api.get<Space[]>('/spaces')
|
||||
list: async (params?: { property_id?: number }): Promise<Space[]> => {
|
||||
const response = await api.get<Space[]>('/spaces', { params })
|
||||
return response.data
|
||||
},
|
||||
|
||||
@@ -198,6 +204,11 @@ export const bookingsApi = {
|
||||
params: { start, end }
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
|
||||
cancel: async (id: number): Promise<Booking> => {
|
||||
const response = await api.put<Booking>(`/bookings/${id}/cancel`)
|
||||
return response.data
|
||||
}
|
||||
}
|
||||
|
||||
@@ -209,12 +220,13 @@ export const adminBookingsApi = {
|
||||
user_id?: number
|
||||
start?: string
|
||||
limit?: number
|
||||
property_id?: number
|
||||
}): Promise<Booking[]> => {
|
||||
const response = await api.get<Booking[]>('/admin/bookings/all', { params })
|
||||
return response.data
|
||||
},
|
||||
|
||||
getPending: async (filters?: { space_id?: number; user_id?: number }): Promise<Booking[]> => {
|
||||
getPending: async (filters?: { space_id?: number; user_id?: number; property_id?: number }): Promise<Booking[]> => {
|
||||
const response = await api.get<Booking[]>('/admin/bookings/pending', { params: filters })
|
||||
return response.data
|
||||
},
|
||||
@@ -242,6 +254,11 @@ export const adminBookingsApi = {
|
||||
return response.data
|
||||
},
|
||||
|
||||
cancel: async (id: number, reason?: string): Promise<Booking> => {
|
||||
const response = await api.put<Booking>(`/admin/bookings/${id}/cancel`, { cancellation_reason: reason })
|
||||
return response.data
|
||||
},
|
||||
|
||||
create: async (data: BookingAdminCreate): Promise<Booking> => {
|
||||
const response = await api.post<Booking>('/admin/bookings', data)
|
||||
return response.data
|
||||
@@ -390,6 +407,128 @@ export const googleCalendarApi = {
|
||||
}
|
||||
}
|
||||
|
||||
// Public API instance (no auth required)
|
||||
const publicApiInstance = axios.create({
|
||||
baseURL: '/api',
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
})
|
||||
|
||||
// Properties API
|
||||
export const propertiesApi = {
|
||||
list: async (params?: { managed_only?: boolean }): Promise<Property[]> => {
|
||||
const response = await api.get<Property[]>('/properties', { params })
|
||||
return response.data
|
||||
},
|
||||
listAll: async (): Promise<Property[]> => {
|
||||
const response = await api.get<Property[]>('/admin/properties', { params: { include_inactive: true } })
|
||||
return response.data
|
||||
},
|
||||
get: async (id: number): Promise<Property> => {
|
||||
const response = await api.get<Property>(`/properties/${id}`)
|
||||
return response.data
|
||||
},
|
||||
getSpaces: async (id: number, params?: { include_inactive?: boolean }): Promise<Space[]> => {
|
||||
const response = await api.get<Space[]>(`/properties/${id}/spaces`, { params })
|
||||
return response.data
|
||||
},
|
||||
create: async (data: { name: string; description?: string; address?: string; is_public?: boolean }): Promise<Property> => {
|
||||
const response = await api.post<Property>('/manager/properties', data)
|
||||
return response.data
|
||||
},
|
||||
update: async (id: number, data: { name?: string; description?: string; address?: string; is_public?: boolean }): Promise<Property> => {
|
||||
const response = await api.put<Property>(`/manager/properties/${id}`, data)
|
||||
return response.data
|
||||
},
|
||||
updateStatus: async (id: number, is_active: boolean): Promise<Property> => {
|
||||
const response = await api.patch<Property>(`/manager/properties/${id}/status`, { is_active })
|
||||
return response.data
|
||||
},
|
||||
getAccess: async (id: number): Promise<PropertyAccess[]> => {
|
||||
const response = await api.get<PropertyAccess[]>(`/manager/properties/${id}/access`)
|
||||
return response.data
|
||||
},
|
||||
grantAccess: async (id: number, data: { user_id?: number; organization_id?: number }): Promise<PropertyAccess> => {
|
||||
const response = await api.post<PropertyAccess>(`/manager/properties/${id}/access`, data)
|
||||
return response.data
|
||||
},
|
||||
revokeAccess: async (propertyId: number, accessId: number): Promise<void> => {
|
||||
await api.delete(`/manager/properties/${propertyId}/access/${accessId}`)
|
||||
},
|
||||
getSettings: async (id: number): Promise<PropertySettings> => {
|
||||
const response = await api.get<PropertySettings>(`/manager/properties/${id}/settings`)
|
||||
return response.data
|
||||
},
|
||||
updateSettings: async (id: number, data: Partial<PropertySettings>): Promise<PropertySettings> => {
|
||||
const response = await api.put<PropertySettings>(`/manager/properties/${id}/settings`, data)
|
||||
return response.data
|
||||
},
|
||||
assignManager: async (propertyId: number, userId: number): Promise<void> => {
|
||||
await api.post(`/admin/properties/${propertyId}/managers`, { user_id: userId })
|
||||
},
|
||||
removeManager: async (propertyId: number, userId: number): Promise<void> => {
|
||||
await api.delete(`/admin/properties/${propertyId}/managers/${userId}`)
|
||||
},
|
||||
delete: async (id: number): Promise<void> => {
|
||||
await api.delete(`/manager/properties/${id}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Organizations API
|
||||
export const organizationsApi = {
|
||||
list: async (): Promise<Organization[]> => {
|
||||
const response = await api.get<Organization[]>('/organizations')
|
||||
return response.data
|
||||
},
|
||||
get: async (id: number): Promise<Organization> => {
|
||||
const response = await api.get<Organization>(`/organizations/${id}`)
|
||||
return response.data
|
||||
},
|
||||
create: async (data: { name: string; description?: string }): Promise<Organization> => {
|
||||
const response = await api.post<Organization>('/admin/organizations', data)
|
||||
return response.data
|
||||
},
|
||||
update: async (id: number, data: { name?: string; description?: string }): Promise<Organization> => {
|
||||
const response = await api.put<Organization>(`/admin/organizations/${id}`, data)
|
||||
return response.data
|
||||
},
|
||||
getMembers: async (id: number): Promise<OrganizationMember[]> => {
|
||||
const response = await api.get<OrganizationMember[]>(`/organizations/${id}/members`)
|
||||
return response.data
|
||||
},
|
||||
addMember: async (orgId: number, data: { user_id: number; role?: string }): Promise<OrganizationMember> => {
|
||||
const response = await api.post<OrganizationMember>(`/organizations/${orgId}/members`, data)
|
||||
return response.data
|
||||
},
|
||||
removeMember: async (orgId: number, userId: number): Promise<void> => {
|
||||
await api.delete(`/organizations/${orgId}/members/${userId}`)
|
||||
},
|
||||
updateMemberRole: async (orgId: number, userId: number, role: string): Promise<void> => {
|
||||
await api.put(`/organizations/${orgId}/members/${userId}`, { role })
|
||||
}
|
||||
}
|
||||
|
||||
// Public API (no auth required)
|
||||
export const publicApi = {
|
||||
getProperties: async (): Promise<Property[]> => {
|
||||
const response = await publicApiInstance.get<Property[]>('/public/properties')
|
||||
return response.data
|
||||
},
|
||||
getPropertySpaces: async (propertyId: number): Promise<Space[]> => {
|
||||
const response = await publicApiInstance.get<Space[]>(`/public/properties/${propertyId}/spaces`)
|
||||
return response.data
|
||||
},
|
||||
getSpaceAvailability: async (spaceId: number, start: string, end: string) => {
|
||||
const response = await publicApiInstance.get(`/public/spaces/${spaceId}/availability`, {
|
||||
params: { start_datetime: start, end_datetime: end }
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
createBooking: async (data: AnonymousBookingCreate): Promise<Booking> => {
|
||||
const response = await publicApiInstance.post<Booking>('/public/bookings', data)
|
||||
return response.data
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to handle API errors
|
||||
export const handleApiError = (error: unknown): string => {
|
||||
if (error instanceof AxiosError) {
|
||||
|
||||
@@ -8,14 +8,16 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
const user = ref<User | null>(null)
|
||||
|
||||
const isAuthenticated = computed(() => !!token.value)
|
||||
const isAdmin = computed(() => user.value?.role === 'admin')
|
||||
const isSuperadmin = computed(() => user.value?.role === 'superadmin' || user.value?.role === 'admin')
|
||||
const isManager = computed(() => user.value?.role === 'manager')
|
||||
const isAdminOrManager = computed(() => isSuperadmin.value || isManager.value)
|
||||
// Keep isAdmin for backward compatibility (now means superadmin OR manager for nav visibility)
|
||||
const isAdmin = computed(() => isSuperadmin.value || isManager.value)
|
||||
|
||||
const login = async (credentials: LoginRequest) => {
|
||||
const response = await authApi.login(credentials)
|
||||
token.value = response.access_token
|
||||
localStorage.setItem('token', response.access_token)
|
||||
|
||||
// Fetch user data from API
|
||||
user.value = await usersApi.me()
|
||||
}
|
||||
|
||||
@@ -25,13 +27,11 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
localStorage.removeItem('token')
|
||||
}
|
||||
|
||||
// Initialize user from token on page load
|
||||
const initFromToken = async () => {
|
||||
if (token.value) {
|
||||
try {
|
||||
user.value = await usersApi.me()
|
||||
} catch (error) {
|
||||
// Invalid token
|
||||
logout()
|
||||
}
|
||||
}
|
||||
@@ -44,6 +44,9 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
user,
|
||||
isAuthenticated,
|
||||
isAdmin,
|
||||
isSuperadmin,
|
||||
isManager,
|
||||
isAdminOrManager,
|
||||
login,
|
||||
logout
|
||||
}
|
||||
|
||||
55
frontend/src/stores/property.ts
Normal file
55
frontend/src/stores/property.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import { propertiesApi } from '@/services/api'
|
||||
import { useAuthStore } from './auth'
|
||||
import type { Property } from '@/types'
|
||||
|
||||
export const usePropertyStore = defineStore('property', () => {
|
||||
const properties = ref<Property[]>([])
|
||||
const currentPropertyId = ref<number | null>(
|
||||
localStorage.getItem('currentPropertyId')
|
||||
? Number(localStorage.getItem('currentPropertyId'))
|
||||
: null
|
||||
)
|
||||
const loading = ref(false)
|
||||
|
||||
const currentProperty = computed(() =>
|
||||
properties.value.find(p => p.id === currentPropertyId.value) || null
|
||||
)
|
||||
|
||||
const setCurrentProperty = (id: number | null) => {
|
||||
currentPropertyId.value = id
|
||||
if (id) {
|
||||
localStorage.setItem('currentPropertyId', String(id))
|
||||
} else {
|
||||
localStorage.removeItem('currentPropertyId')
|
||||
}
|
||||
}
|
||||
|
||||
const fetchMyProperties = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const authStore = useAuthStore()
|
||||
if (authStore.isSuperadmin) {
|
||||
properties.value = await propertiesApi.listAll()
|
||||
} else {
|
||||
properties.value = await propertiesApi.list()
|
||||
}
|
||||
// Auto-select first property if none selected
|
||||
if (!currentPropertyId.value && properties.value.length > 0) {
|
||||
setCurrentProperty(properties.value[0].id)
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
properties,
|
||||
currentPropertyId,
|
||||
currentProperty,
|
||||
loading,
|
||||
setCurrentProperty,
|
||||
fetchMyProperties
|
||||
}
|
||||
})
|
||||
@@ -46,6 +46,8 @@ export interface Space {
|
||||
capacity: number
|
||||
description?: string
|
||||
is_active: boolean
|
||||
property_id?: number | null
|
||||
property_name?: string
|
||||
working_hours_start?: number | null
|
||||
working_hours_end?: number | null
|
||||
min_duration_minutes?: number | null
|
||||
@@ -55,7 +57,7 @@ export interface Space {
|
||||
export interface Booking {
|
||||
id: number
|
||||
space_id: number
|
||||
user_id: number
|
||||
user_id?: number | null
|
||||
start_datetime: string
|
||||
end_datetime: string
|
||||
title: string
|
||||
@@ -64,6 +66,10 @@ export interface Booking {
|
||||
created_at: string
|
||||
space?: Space
|
||||
user?: User
|
||||
guest_name?: string
|
||||
guest_email?: string
|
||||
guest_organization?: string
|
||||
is_anonymous?: boolean
|
||||
}
|
||||
|
||||
export interface Settings {
|
||||
@@ -230,3 +236,78 @@ export interface ApprovalRateReport {
|
||||
rejection_rate: number
|
||||
date_range: { start: string | null; end: string | null }
|
||||
}
|
||||
|
||||
export interface PropertyManagerInfo {
|
||||
user_id: number
|
||||
full_name: string
|
||||
email: string
|
||||
}
|
||||
|
||||
export interface Property {
|
||||
id: number
|
||||
name: string
|
||||
description?: string
|
||||
address?: string
|
||||
is_public: boolean
|
||||
is_active: boolean
|
||||
created_at: string
|
||||
space_count?: number
|
||||
managers?: PropertyManagerInfo[]
|
||||
}
|
||||
|
||||
export interface PropertyWithSpaces extends Property {
|
||||
spaces: Space[]
|
||||
}
|
||||
|
||||
export interface PropertySettings {
|
||||
id: number
|
||||
property_id: number
|
||||
working_hours_start?: number | null
|
||||
working_hours_end?: number | null
|
||||
min_duration_minutes?: number | null
|
||||
max_duration_minutes?: number | null
|
||||
max_bookings_per_day_per_user?: number | null
|
||||
require_approval: boolean
|
||||
min_hours_before_cancel?: number | null
|
||||
}
|
||||
|
||||
export interface PropertyAccess {
|
||||
id: number
|
||||
property_id: number
|
||||
user_id?: number | null
|
||||
organization_id?: number | null
|
||||
granted_by?: number | null
|
||||
user_name?: string
|
||||
user_email?: string
|
||||
organization_name?: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface Organization {
|
||||
id: number
|
||||
name: string
|
||||
description?: string
|
||||
is_active: boolean
|
||||
created_at: string
|
||||
member_count?: number
|
||||
}
|
||||
|
||||
export interface OrganizationMember {
|
||||
id: number
|
||||
organization_id: number
|
||||
user_id: number
|
||||
role: string
|
||||
user_name?: string
|
||||
user_email?: string
|
||||
}
|
||||
|
||||
export interface AnonymousBookingCreate {
|
||||
space_id: number
|
||||
start_datetime: string
|
||||
end_datetime: string
|
||||
title: string
|
||||
description?: string
|
||||
guest_name: string
|
||||
guest_email: string
|
||||
guest_organization?: string
|
||||
}
|
||||
|
||||
@@ -2,7 +2,12 @@
|
||||
<div class="admin">
|
||||
<Breadcrumb :items="breadcrumbItems" />
|
||||
<div class="page-header">
|
||||
<h2>Space Management</h2>
|
||||
<div>
|
||||
<h2>Space Management</h2>
|
||||
<p v-if="authStore.isManager && propertyStore.currentProperty" class="property-context">
|
||||
Property: <strong>{{ propertyStore.currentProperty.name }}</strong>
|
||||
</p>
|
||||
</div>
|
||||
<button class="btn btn-primary" @click="openCreateModal">
|
||||
<Plus :size="16" />
|
||||
New Space
|
||||
@@ -73,6 +78,10 @@
|
||||
<UsersIcon :size="14" />
|
||||
{{ space.capacity }}
|
||||
</span>
|
||||
<span v-if="space.property_name" class="meta-badge meta-property" :title="'Property: ' + space.property_name">
|
||||
<Landmark :size="11" />
|
||||
{{ space.property_name }}
|
||||
</span>
|
||||
</div>
|
||||
<p v-if="space.description" class="space-card-desc">{{ space.description }}</p>
|
||||
</div>
|
||||
@@ -94,6 +103,15 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="property">Property *</label>
|
||||
<select id="property" v-model.number="formData.property_id" required>
|
||||
<option v-for="prop in availableProperties" :key="prop.id" :value="prop.id">
|
||||
{{ prop.name }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="type">Type *</label>
|
||||
<select id="type" v-model="formData.type" required>
|
||||
@@ -201,10 +219,15 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { spacesApi, handleApiError } from '@/services/api'
|
||||
import { spacesApi, propertiesApi, handleApiError } from '@/services/api'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { usePropertyStore } from '@/stores/property'
|
||||
import Breadcrumb from '@/components/Breadcrumb.vue'
|
||||
import { Building2, Plus, Pencil, Power, Users as UsersIcon } from 'lucide-vue-next'
|
||||
import type { Space } from '@/types'
|
||||
import { Building2, Plus, Pencil, Power, Users as UsersIcon, Landmark } from 'lucide-vue-next'
|
||||
import type { Space, Property } from '@/types'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const propertyStore = usePropertyStore()
|
||||
|
||||
const breadcrumbItems = [
|
||||
{ label: 'Dashboard', to: '/dashboard' },
|
||||
@@ -212,6 +235,7 @@ const breadcrumbItems = [
|
||||
]
|
||||
|
||||
const spaces = ref<Space[]>([])
|
||||
const availableProperties = ref<Property[]>([])
|
||||
const loadingSpaces = ref(false)
|
||||
const activeCount = computed(() => spaces.value.filter(s => s.is_active).length)
|
||||
const inactiveCount = computed(() => spaces.value.filter(s => !s.is_active).length)
|
||||
@@ -226,12 +250,23 @@ const formData = ref({
|
||||
type: 'sala',
|
||||
capacity: 1,
|
||||
description: '',
|
||||
property_id: null as number | null,
|
||||
working_hours_start: null as number | null,
|
||||
working_hours_end: null as number | null,
|
||||
min_duration_minutes: null as number | null,
|
||||
max_duration_minutes: null as number | null
|
||||
})
|
||||
|
||||
const loadProperties = async () => {
|
||||
try {
|
||||
if (authStore.isSuperadmin) {
|
||||
availableProperties.value = await propertiesApi.listAll()
|
||||
} else {
|
||||
availableProperties.value = await propertiesApi.list()
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const loadSpaces = async () => {
|
||||
loadingSpaces.value = true
|
||||
error.value = ''
|
||||
@@ -274,6 +309,12 @@ const handleSubmit = async () => {
|
||||
|
||||
const openCreateModal = () => {
|
||||
resetForm()
|
||||
// Auto-select current property context or first available
|
||||
if (propertyStore.currentPropertyId) {
|
||||
formData.value.property_id = propertyStore.currentPropertyId
|
||||
} else if (availableProperties.value.length > 0) {
|
||||
formData.value.property_id = availableProperties.value[0].id
|
||||
}
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
@@ -284,6 +325,7 @@ const startEdit = (space: Space) => {
|
||||
type: space.type,
|
||||
capacity: space.capacity,
|
||||
description: space.description || '',
|
||||
property_id: space.property_id ?? null,
|
||||
working_hours_start: space.working_hours_start ?? null,
|
||||
working_hours_end: space.working_hours_end ?? null,
|
||||
min_duration_minutes: space.min_duration_minutes ?? null,
|
||||
@@ -307,7 +349,8 @@ const resetForm = () => {
|
||||
working_hours_start: null,
|
||||
working_hours_end: null,
|
||||
min_duration_minutes: null,
|
||||
max_duration_minutes: null
|
||||
max_duration_minutes: null,
|
||||
property_id: null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -333,6 +376,7 @@ const toggleStatus = async (space: Space) => {
|
||||
|
||||
onMounted(() => {
|
||||
loadSpaces()
|
||||
loadProperties()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -353,6 +397,16 @@ onMounted(() => {
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.property-context {
|
||||
font-size: 14px;
|
||||
color: var(--color-text-secondary);
|
||||
margin: 4px 0 0;
|
||||
}
|
||||
|
||||
.property-context strong {
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
/* Stats Pills */
|
||||
.stats-pills {
|
||||
display: flex;
|
||||
@@ -544,6 +598,14 @@ onMounted(() => {
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.meta-property {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
background: color-mix(in srgb, var(--color-warning) 12%, transparent);
|
||||
color: var(--color-warning);
|
||||
}
|
||||
|
||||
.meta-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -312,7 +312,7 @@ const filters = ref<FilterValues>({
|
||||
user_search: null
|
||||
})
|
||||
|
||||
const isAdmin = computed(() => currentUser.value?.role === 'admin')
|
||||
const isAdmin = computed(() => currentUser.value?.role === 'admin' || currentUser.value?.role === 'superadmin' || currentUser.value?.role === 'manager')
|
||||
|
||||
const hasActiveFilters = computed(() =>
|
||||
filters.value.space_id !== null ||
|
||||
@@ -454,7 +454,7 @@ const loadDashboard = async () => {
|
||||
// Load spaces for filter dropdown
|
||||
spaces.value = await spacesApi.list()
|
||||
|
||||
if (currentUser.value.role === 'admin') {
|
||||
if (currentUser.value.role === 'admin' || currentUser.value.role === 'superadmin' || currentUser.value.role === 'manager') {
|
||||
const results = await Promise.allSettled([
|
||||
adminBookingsApi.getPending(),
|
||||
adminBookingsApi.getAll({
|
||||
@@ -535,7 +535,11 @@ const handleCancel = async (booking: Booking) => {
|
||||
|
||||
processing.value = booking.id
|
||||
try {
|
||||
await bookingsApi.update(booking.id, { status: 'canceled' } as any)
|
||||
if (isAdmin.value) {
|
||||
await adminBookingsApi.cancel(booking.id)
|
||||
} else {
|
||||
await bookingsApi.update(booking.id, { status: 'canceled' } as any)
|
||||
}
|
||||
showToast(`Booking "${booking.title}" canceled.`, 'success')
|
||||
await loadDashboard()
|
||||
calendarRef.value?.refresh()
|
||||
|
||||
@@ -234,6 +234,43 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Confirm Modal -->
|
||||
<div v-if="showConfirmModal" class="modal" @click.self="showConfirmModal = false">
|
||||
<div class="modal-content">
|
||||
<h3>{{ confirmTitle }}</h3>
|
||||
<p class="confirm-message">{{ confirmMessage }}</p>
|
||||
<div class="form-actions">
|
||||
<button type="button" class="btn btn-secondary" @click="showConfirmModal = false" :disabled="confirmLoading">Cancel</button>
|
||||
<button
|
||||
type="button"
|
||||
:class="['btn', confirmDanger ? 'btn-danger' : 'btn-primary']"
|
||||
@click="executeConfirm"
|
||||
:disabled="confirmLoading"
|
||||
>
|
||||
{{ confirmLoading ? 'Processing...' : confirmLabel }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Reject Modal -->
|
||||
<div v-if="showRejectModal" class="modal" @click.self="showRejectModal = false">
|
||||
<div class="modal-content">
|
||||
<h3>Reject Booking</h3>
|
||||
<p class="confirm-message">Rejecting "{{ rejectBooking?.title }}"</p>
|
||||
<div class="form-group">
|
||||
<label for="reject-reason">Reason (optional)</label>
|
||||
<textarea id="reject-reason" v-model="rejectReason" rows="3" placeholder="Enter rejection reason..."></textarea>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button type="button" class="btn btn-secondary" @click="showRejectModal = false">Cancel</button>
|
||||
<button type="button" class="btn btn-danger" @click="doReject" :disabled="processing !== null">
|
||||
{{ processing !== null ? 'Rejecting...' : 'Reject' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Toast -->
|
||||
<div v-if="toastMsg" :class="['toast', `toast-${toastType}`]">{{ toastMsg }}</div>
|
||||
</div>
|
||||
@@ -266,7 +303,7 @@ import type { Booking, Space } from '@/types'
|
||||
const route = useRoute()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const isAdmin = computed(() => authStore.user?.role === 'admin')
|
||||
const isAdmin = computed(() => ['admin', 'superadmin', 'manager'].includes(authStore.user?.role || ''))
|
||||
const userTimezone = computed(() => authStore.user?.timezone || 'UTC')
|
||||
|
||||
const breadcrumbItems = computed(() => [
|
||||
@@ -325,6 +362,62 @@ const showToast = (msg: string, type: 'success' | 'error') => {
|
||||
setTimeout(() => { toastMsg.value = '' }, type === 'success' ? 3000 : 5000)
|
||||
}
|
||||
|
||||
// Confirm modal (for cancel / approve)
|
||||
const showConfirmModal = ref(false)
|
||||
const confirmTitle = ref('')
|
||||
const confirmMessage = ref('')
|
||||
const confirmDanger = ref(false)
|
||||
const confirmLabel = ref('Yes')
|
||||
const confirmLoading = ref(false)
|
||||
const onConfirm = ref<(() => Promise<void>) | null>(null)
|
||||
|
||||
const openConfirm = (opts: { title: string; message: string; danger?: boolean; label?: string; action: () => Promise<void> }) => {
|
||||
confirmTitle.value = opts.title
|
||||
confirmMessage.value = opts.message
|
||||
confirmDanger.value = opts.danger ?? false
|
||||
confirmLabel.value = opts.label ?? 'Yes'
|
||||
onConfirm.value = opts.action
|
||||
confirmLoading.value = false
|
||||
showConfirmModal.value = true
|
||||
}
|
||||
|
||||
const executeConfirm = async () => {
|
||||
if (!onConfirm.value) return
|
||||
confirmLoading.value = true
|
||||
try {
|
||||
await onConfirm.value()
|
||||
} finally {
|
||||
confirmLoading.value = false
|
||||
showConfirmModal.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Reject modal
|
||||
const showRejectModal = ref(false)
|
||||
const rejectBooking = ref<Booking | null>(null)
|
||||
const rejectReason = ref('')
|
||||
|
||||
const openRejectModal = (booking: Booking) => {
|
||||
rejectBooking.value = booking
|
||||
rejectReason.value = ''
|
||||
showRejectModal.value = true
|
||||
}
|
||||
|
||||
const doReject = async () => {
|
||||
if (!rejectBooking.value) return
|
||||
processing.value = rejectBooking.value.id
|
||||
try {
|
||||
await adminBookingsApi.reject(rejectBooking.value.id, rejectReason.value || undefined)
|
||||
showToast(`Booking "${rejectBooking.value.title}" rejected.`, 'success')
|
||||
showRejectModal.value = false
|
||||
await loadBookings()
|
||||
} catch (err) {
|
||||
showToast(handleApiError(err), 'error')
|
||||
} finally {
|
||||
processing.value = null
|
||||
}
|
||||
}
|
||||
|
||||
const hasActiveFilters = computed(() =>
|
||||
filters.value.space_id !== null ||
|
||||
filters.value.status !== null ||
|
||||
@@ -413,50 +506,53 @@ const clearAllFilters = () => {
|
||||
}
|
||||
|
||||
// Actions
|
||||
const handleCancel = async (booking: Booking) => {
|
||||
if (!confirm(`Cancel booking "${booking.title}"?`)) return
|
||||
|
||||
processing.value = booking.id
|
||||
try {
|
||||
await bookingsApi.update(booking.id, { status: 'canceled' } as any)
|
||||
showToast(`Booking "${booking.title}" canceled.`, 'success')
|
||||
await loadBookings()
|
||||
} catch (err) {
|
||||
showToast(handleApiError(err), 'error')
|
||||
} finally {
|
||||
processing.value = null
|
||||
}
|
||||
const handleCancel = (booking: Booking) => {
|
||||
openConfirm({
|
||||
title: 'Cancel Booking',
|
||||
message: `Cancel booking "${booking.title}"?`,
|
||||
danger: true,
|
||||
label: 'Cancel Booking',
|
||||
action: async () => {
|
||||
processing.value = booking.id
|
||||
try {
|
||||
if (isAdmin.value) {
|
||||
await adminBookingsApi.cancel(booking.id)
|
||||
} else {
|
||||
await bookingsApi.cancel(booking.id)
|
||||
}
|
||||
showToast(`Booking "${booking.title}" canceled.`, 'success')
|
||||
await loadBookings()
|
||||
} catch (err) {
|
||||
showToast(handleApiError(err), 'error')
|
||||
} finally {
|
||||
processing.value = null
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleApprove = async (booking: Booking) => {
|
||||
if (!confirm(`Approve booking "${booking.title}"?`)) return
|
||||
|
||||
processing.value = booking.id
|
||||
try {
|
||||
await adminBookingsApi.approve(booking.id)
|
||||
showToast(`Booking "${booking.title}" approved!`, 'success')
|
||||
await loadBookings()
|
||||
} catch (err) {
|
||||
showToast(handleApiError(err), 'error')
|
||||
} finally {
|
||||
processing.value = null
|
||||
}
|
||||
const handleApprove = (booking: Booking) => {
|
||||
openConfirm({
|
||||
title: 'Approve Booking',
|
||||
message: `Approve booking "${booking.title}"?`,
|
||||
label: 'Approve',
|
||||
action: async () => {
|
||||
processing.value = booking.id
|
||||
try {
|
||||
await adminBookingsApi.approve(booking.id)
|
||||
showToast(`Booking "${booking.title}" approved!`, 'success')
|
||||
await loadBookings()
|
||||
} catch (err) {
|
||||
showToast(handleApiError(err), 'error')
|
||||
} finally {
|
||||
processing.value = null
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleReject = async (booking: Booking) => {
|
||||
const reason = prompt('Rejection reason (optional):')
|
||||
if (reason === null) return // User clicked cancel
|
||||
|
||||
processing.value = booking.id
|
||||
try {
|
||||
await adminBookingsApi.reject(booking.id, reason || undefined)
|
||||
showToast(`Booking "${booking.title}" rejected.`, 'success')
|
||||
await loadBookings()
|
||||
} catch (err) {
|
||||
showToast(handleApiError(err), 'error')
|
||||
} finally {
|
||||
processing.value = null
|
||||
}
|
||||
const handleReject = (booking: Booking) => {
|
||||
openRejectModal(booking)
|
||||
}
|
||||
|
||||
// Edit modal
|
||||
@@ -1009,6 +1105,21 @@ onMounted(() => {
|
||||
background: var(--color-border);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: var(--color-danger);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-danger:hover:not(:disabled) {
|
||||
background: color-mix(in srgb, var(--color-danger) 85%, black);
|
||||
}
|
||||
|
||||
.confirm-message {
|
||||
color: var(--color-text-secondary);
|
||||
margin-bottom: 20px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Toast */
|
||||
.toast {
|
||||
position: fixed;
|
||||
|
||||
469
frontend/src/views/Organization.vue
Normal file
469
frontend/src/views/Organization.vue
Normal file
@@ -0,0 +1,469 @@
|
||||
<template>
|
||||
<div class="organization">
|
||||
<Breadcrumb :items="breadcrumbItems" />
|
||||
<div class="page-header">
|
||||
<h2>Organizations</h2>
|
||||
<button v-if="authStore.isSuperadmin" class="btn btn-primary" @click="openCreateModal">
|
||||
<Plus :size="16" />
|
||||
New Organization
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="loading-state">
|
||||
<div class="spinner"></div>
|
||||
<p>Loading organizations...</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="organizations.length === 0" class="empty-state">
|
||||
<Building2 :size="48" class="empty-icon" />
|
||||
<p>No organizations found</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="org-list">
|
||||
<div v-for="org in organizations" :key="org.id" class="org-card">
|
||||
<div class="org-header">
|
||||
<div>
|
||||
<h3>{{ org.name }}</h3>
|
||||
<p v-if="org.description" class="org-desc">{{ org.description }}</p>
|
||||
</div>
|
||||
<div class="org-actions">
|
||||
<span class="member-count">{{ org.member_count || 0 }} members</span>
|
||||
<button
|
||||
class="btn btn-sm btn-secondary"
|
||||
@click="toggleExpanded(org.id)"
|
||||
>
|
||||
{{ expandedOrg === org.id ? 'Hide' : 'Members' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Expanded Members -->
|
||||
<div v-if="expandedOrg === org.id" class="org-members">
|
||||
<div v-if="loadingMembers" class="loading-inline">Loading members...</div>
|
||||
<div v-else>
|
||||
<div class="members-header">
|
||||
<h4>Members</h4>
|
||||
</div>
|
||||
<div v-if="members.length === 0" class="empty-inline">No members yet</div>
|
||||
<table v-else class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Email</th>
|
||||
<th>Role</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="member in members" :key="member.id">
|
||||
<td>{{ member.user_name }}</td>
|
||||
<td>{{ member.user_email }}</td>
|
||||
<td>
|
||||
<span :class="['badge', member.role === 'admin' ? 'badge-admin' : 'badge-user']">
|
||||
{{ member.role }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="actions">
|
||||
<button
|
||||
v-if="authStore.isSuperadmin"
|
||||
class="btn btn-sm btn-danger"
|
||||
@click="removeMember(org.id, member.user_id)"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create Org Modal -->
|
||||
<div v-if="showCreateModal" class="modal" @click.self="showCreateModal = false">
|
||||
<div class="modal-content">
|
||||
<h3>Create Organization</h3>
|
||||
<form @submit.prevent="handleCreate" class="org-form">
|
||||
<div class="form-group">
|
||||
<label>Name *</label>
|
||||
<input v-model="createForm.name" type="text" required placeholder="Organization name" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Description</label>
|
||||
<textarea v-model="createForm.description" rows="3" placeholder="Optional"></textarea>
|
||||
</div>
|
||||
<div v-if="formError" class="error">{{ formError }}</div>
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary" :disabled="submitting">Create</button>
|
||||
<button type="button" class="btn btn-secondary" @click="showCreateModal = false">Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="toast" class="toast toast-success">{{ toast }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { organizationsApi, handleApiError } from '@/services/api'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import Breadcrumb from '@/components/Breadcrumb.vue'
|
||||
import { Building2, Plus } from 'lucide-vue-next'
|
||||
import type { Organization, OrganizationMember } from '@/types'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const breadcrumbItems = [
|
||||
{ label: 'Dashboard', to: '/dashboard' },
|
||||
{ label: 'Organizations' }
|
||||
]
|
||||
|
||||
const organizations = ref<Organization[]>([])
|
||||
const loading = ref(true)
|
||||
const expandedOrg = ref<number | null>(null)
|
||||
const members = ref<OrganizationMember[]>([])
|
||||
const loadingMembers = ref(false)
|
||||
const showCreateModal = ref(false)
|
||||
const submitting = ref(false)
|
||||
const formError = ref('')
|
||||
const toast = ref('')
|
||||
|
||||
const createForm = ref({ name: '', description: '' })
|
||||
|
||||
const loadOrganizations = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
organizations.value = await organizationsApi.list()
|
||||
} catch (err) {
|
||||
formError.value = handleApiError(err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const toggleExpanded = async (orgId: number) => {
|
||||
if (expandedOrg.value === orgId) {
|
||||
expandedOrg.value = null
|
||||
return
|
||||
}
|
||||
expandedOrg.value = orgId
|
||||
loadingMembers.value = true
|
||||
try {
|
||||
members.value = await organizationsApi.getMembers(orgId)
|
||||
} catch {}
|
||||
finally {
|
||||
loadingMembers.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const openCreateModal = () => {
|
||||
createForm.value = { name: '', description: '' }
|
||||
formError.value = ''
|
||||
showCreateModal.value = true
|
||||
}
|
||||
|
||||
const handleCreate = async () => {
|
||||
submitting.value = true
|
||||
formError.value = ''
|
||||
try {
|
||||
await organizationsApi.create(createForm.value)
|
||||
showCreateModal.value = false
|
||||
toast.value = 'Organization created!'
|
||||
setTimeout(() => { toast.value = '' }, 3000)
|
||||
await loadOrganizations()
|
||||
} catch (err) {
|
||||
formError.value = handleApiError(err)
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const removeMember = async (orgId: number, userId: number) => {
|
||||
if (!confirm('Remove this member?')) return
|
||||
try {
|
||||
await organizationsApi.removeMember(orgId, userId)
|
||||
members.value = members.value.filter(m => m.user_id !== userId)
|
||||
toast.value = 'Member removed'
|
||||
setTimeout(() => { toast.value = '' }, 3000)
|
||||
} catch (err) {
|
||||
formError.value = handleApiError(err)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => loadOrganizations())
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.page-header h2 {
|
||||
margin: 0;
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.loading-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 60px 20px;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 40px; height: 40px;
|
||||
border: 3px solid var(--color-border);
|
||||
border-top-color: var(--color-accent);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 60px 20px;
|
||||
color: var(--color-text-muted);
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.empty-icon { color: var(--color-border); }
|
||||
|
||||
.org-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.org-card {
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.org-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.org-header h3 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.org-desc {
|
||||
font-size: 14px;
|
||||
color: var(--color-text-secondary);
|
||||
margin: 4px 0 0;
|
||||
}
|
||||
|
||||
.org-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.member-count {
|
||||
font-size: 13px;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.org-members {
|
||||
margin-top: 16px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.members-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.members-header h4 {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.loading-inline, .empty-inline {
|
||||
text-align: center;
|
||||
padding: 16px;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.data-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.data-table th {
|
||||
text-align: left;
|
||||
padding: 8px 12px;
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
color: var(--color-text-secondary);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.data-table td {
|
||||
padding: 8px 12px;
|
||||
font-size: 14px;
|
||||
color: var(--color-text-primary);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 2px 10px;
|
||||
border-radius: 10px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.badge-admin {
|
||||
background: color-mix(in srgb, var(--color-accent) 15%, transparent);
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.badge-user {
|
||||
background: var(--color-bg-tertiary);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 10px 20px;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.btn-primary { background: var(--color-accent); color: white; }
|
||||
.btn-primary:hover:not(:disabled) { background: var(--color-accent-hover); }
|
||||
.btn-secondary { background: var(--color-bg-tertiary); color: var(--color-text-primary); }
|
||||
.btn-secondary:hover:not(:disabled) { background: var(--color-border); }
|
||||
.btn-danger { background: var(--color-danger); color: white; }
|
||||
.btn-sm { padding: 6px 12px; font-size: 12px; }
|
||||
|
||||
/* Modal */
|
||||
.modal {
|
||||
position: fixed;
|
||||
top: 0; left: 0; right: 0; bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: var(--color-surface);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 28px;
|
||||
max-width: 500px;
|
||||
width: 90%;
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.modal-content h3 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 20px;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.org-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.form-group input,
|
||||
.form-group textarea {
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 14px;
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text-primary);
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.form-group input:focus,
|
||||
.form-group textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-accent);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-accent) 15%, transparent);
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.error {
|
||||
padding: 12px;
|
||||
background: color-mix(in srgb, var(--color-danger) 10%, transparent);
|
||||
color: var(--color-danger);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.toast {
|
||||
position: fixed;
|
||||
bottom: 24px;
|
||||
right: 24px;
|
||||
padding: 12px 20px;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
z-index: 1100;
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.toast-success { background: var(--color-success); color: #fff; }
|
||||
</style>
|
||||
630
frontend/src/views/Properties.vue
Normal file
630
frontend/src/views/Properties.vue
Normal file
@@ -0,0 +1,630 @@
|
||||
<template>
|
||||
<div class="properties">
|
||||
<Breadcrumb :items="breadcrumbItems" />
|
||||
<div class="page-header">
|
||||
<h2>Properties</h2>
|
||||
<button class="btn btn-primary" @click="openCreateModal">
|
||||
<Plus :size="16" />
|
||||
New Property
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Loading -->
|
||||
<div v-if="loading" class="loading-state">
|
||||
<div class="spinner"></div>
|
||||
<p>Loading properties...</p>
|
||||
</div>
|
||||
|
||||
<!-- Empty -->
|
||||
<div v-else-if="properties.length === 0" class="empty-state">
|
||||
<Landmark :size="48" class="empty-icon" />
|
||||
<p>No properties yet</p>
|
||||
<button class="btn btn-primary" @click="openCreateModal">Create your first property</button>
|
||||
</div>
|
||||
|
||||
<!-- Property Grid -->
|
||||
<div v-else class="property-grid">
|
||||
<div
|
||||
v-for="prop in properties"
|
||||
:key="prop.id"
|
||||
:class="['property-card', { 'property-card-inactive': !prop.is_active }]"
|
||||
@click="goToProperty(prop.id)"
|
||||
>
|
||||
<div class="property-card-header">
|
||||
<h3>{{ prop.name }}</h3>
|
||||
<div class="badges">
|
||||
<span :class="['badge', prop.is_public ? 'badge-public' : 'badge-private']">
|
||||
{{ prop.is_public ? 'Public' : 'Private' }}
|
||||
</span>
|
||||
<span :class="['badge', prop.is_active ? 'badge-active' : 'badge-inactive']">
|
||||
{{ prop.is_active ? 'Active' : 'Inactive' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="prop.description" class="property-desc">{{ prop.description }}</p>
|
||||
<p v-if="prop.address" class="property-address">{{ prop.address }}</p>
|
||||
<div v-if="prop.managers && prop.managers.length > 0" class="property-managers">
|
||||
<span class="managers-label">Managed by:</span>
|
||||
<div class="manager-chips">
|
||||
<span v-for="mgr in prop.managers" :key="mgr.user_id" class="manager-chip" :title="mgr.email">
|
||||
<span class="manager-avatar">{{ mgr.full_name.charAt(0).toUpperCase() }}</span>
|
||||
{{ mgr.full_name }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="property-footer">
|
||||
<span class="space-count">{{ prop.space_count || 0 }} spaces</span>
|
||||
<div class="property-actions" @click.stop>
|
||||
<button
|
||||
class="btn-icon"
|
||||
:title="prop.is_active ? 'Deactivate' : 'Activate'"
|
||||
@click="togglePropertyStatus(prop)"
|
||||
>
|
||||
<PowerOff :size="15" />
|
||||
</button>
|
||||
<button
|
||||
class="btn-icon btn-icon-danger"
|
||||
title="Delete property"
|
||||
@click="confirmDelete(prop)"
|
||||
>
|
||||
<Trash2 :size="15" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create Property Modal -->
|
||||
<div v-if="showModal" class="modal" @click.self="closeModal">
|
||||
<div class="modal-content">
|
||||
<h3>Create New Property</h3>
|
||||
<form @submit.prevent="handleCreate" class="property-form">
|
||||
<div class="form-group">
|
||||
<label for="name">Name *</label>
|
||||
<input id="name" v-model="formData.name" type="text" required placeholder="Property name" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="description">Description</label>
|
||||
<textarea id="description" v-model="formData.description" rows="3" placeholder="Optional description"></textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="address">Address</label>
|
||||
<input id="address" v-model="formData.address" type="text" placeholder="Optional address" />
|
||||
</div>
|
||||
<div class="form-group form-checkbox">
|
||||
<label>
|
||||
<input type="checkbox" v-model="formData.is_public" />
|
||||
Public (allows anonymous bookings)
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="error">{{ error }}</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary" :disabled="submitting">
|
||||
{{ submitting ? 'Creating...' : 'Create' }}
|
||||
</button>
|
||||
<button type="button" class="btn btn-secondary" @click="closeModal">Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Confirm Modal -->
|
||||
<div v-if="showConfirm" class="modal" @click.self="showConfirm = false">
|
||||
<div class="modal-content modal-confirm">
|
||||
<h3>{{ confirmTitle }}</h3>
|
||||
<p>{{ confirmMessage }}</p>
|
||||
<div v-if="error" class="error" style="margin-bottom: 12px;">{{ error }}</div>
|
||||
<div class="form-actions">
|
||||
<button class="btn btn-danger" :disabled="submitting" @click="executeConfirm">
|
||||
{{ submitting ? 'Processing...' : confirmAction }}
|
||||
</button>
|
||||
<button class="btn btn-secondary" @click="showConfirm = false">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Toast -->
|
||||
<div v-if="successMsg" class="toast toast-success">{{ successMsg }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { propertiesApi, handleApiError } from '@/services/api'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import Breadcrumb from '@/components/Breadcrumb.vue'
|
||||
import { Landmark, Plus, PowerOff, Trash2 } from 'lucide-vue-next'
|
||||
import type { Property } from '@/types'
|
||||
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const breadcrumbItems = [
|
||||
{ label: 'Dashboard', to: '/dashboard' },
|
||||
{ label: 'Properties' }
|
||||
]
|
||||
|
||||
const properties = ref<Property[]>([])
|
||||
const loading = ref(true)
|
||||
const showModal = ref(false)
|
||||
const submitting = ref(false)
|
||||
const error = ref('')
|
||||
const successMsg = ref('')
|
||||
|
||||
// Confirm modal state
|
||||
const showConfirm = ref(false)
|
||||
const confirmTitle = ref('')
|
||||
const confirmMessage = ref('')
|
||||
const confirmAction = ref('')
|
||||
const confirmCallback = ref<(() => Promise<void>) | null>(null)
|
||||
|
||||
const formData = ref({
|
||||
name: '',
|
||||
description: '',
|
||||
address: '',
|
||||
is_public: false
|
||||
})
|
||||
|
||||
const loadProperties = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
if (authStore.isSuperadmin) {
|
||||
properties.value = await propertiesApi.listAll()
|
||||
} else {
|
||||
properties.value = await propertiesApi.list({ managed_only: true })
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = handleApiError(err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const openCreateModal = () => {
|
||||
formData.value = { name: '', description: '', address: '', is_public: false }
|
||||
error.value = ''
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
const closeModal = () => {
|
||||
showModal.value = false
|
||||
}
|
||||
|
||||
const handleCreate = async () => {
|
||||
submitting.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
await propertiesApi.create(formData.value)
|
||||
closeModal()
|
||||
showToast('Property created!')
|
||||
await loadProperties()
|
||||
} catch (err) {
|
||||
error.value = handleApiError(err)
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const togglePropertyStatus = (prop: Property) => {
|
||||
const newStatus = !prop.is_active
|
||||
confirmTitle.value = newStatus ? 'Activate Property' : 'Deactivate Property'
|
||||
confirmMessage.value = newStatus
|
||||
? `Activate "${prop.name}"? Users will be able to see and book spaces in this property.`
|
||||
: `Deactivate "${prop.name}"? This will hide the property from users. Existing bookings will not be affected.`
|
||||
confirmAction.value = newStatus ? 'Activate' : 'Deactivate'
|
||||
error.value = ''
|
||||
confirmCallback.value = async () => {
|
||||
await propertiesApi.updateStatus(prop.id, newStatus)
|
||||
showToast(`Property ${newStatus ? 'activated' : 'deactivated'}!`)
|
||||
await loadProperties()
|
||||
}
|
||||
showConfirm.value = true
|
||||
}
|
||||
|
||||
const confirmDelete = (prop: Property) => {
|
||||
confirmTitle.value = 'Delete Property'
|
||||
confirmMessage.value = `Are you sure you want to delete "${prop.name}"? This action cannot be undone. Spaces will be unlinked (not deleted). Active bookings must be cancelled first.`
|
||||
confirmAction.value = 'Delete'
|
||||
error.value = ''
|
||||
confirmCallback.value = async () => {
|
||||
await propertiesApi.delete(prop.id)
|
||||
showToast('Property deleted!')
|
||||
await loadProperties()
|
||||
}
|
||||
showConfirm.value = true
|
||||
}
|
||||
|
||||
const executeConfirm = async () => {
|
||||
if (!confirmCallback.value) return
|
||||
submitting.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
await confirmCallback.value()
|
||||
showConfirm.value = false
|
||||
} catch (err) {
|
||||
error.value = handleApiError(err)
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const showToast = (msg: string) => {
|
||||
successMsg.value = msg
|
||||
setTimeout(() => { successMsg.value = '' }, 3000)
|
||||
}
|
||||
|
||||
const goToProperty = (id: number) => {
|
||||
router.push(`/properties/${id}`)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadProperties()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.page-header h2 {
|
||||
margin: 0;
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.loading-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 60px 20px;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 3px solid var(--color-border);
|
||||
border-top-color: var(--color-accent);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 60px 20px;
|
||||
color: var(--color-text-muted);
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.empty-icon { color: var(--color-border); }
|
||||
|
||||
.property-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.property-card {
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 20px;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.property-card:hover {
|
||||
box-shadow: var(--shadow-md);
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
|
||||
.property-card-inactive {
|
||||
opacity: 0.6;
|
||||
border-style: dashed;
|
||||
}
|
||||
|
||||
.property-card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.property-card-header h3 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.badges {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 3px 10px;
|
||||
border-radius: 10px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.badge-public {
|
||||
background: color-mix(in srgb, var(--color-accent) 15%, transparent);
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.badge-private {
|
||||
background: var(--color-bg-tertiary);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.badge-active {
|
||||
background: color-mix(in srgb, var(--color-success) 15%, transparent);
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.badge-inactive {
|
||||
background: color-mix(in srgb, var(--color-danger) 15%, transparent);
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.property-desc {
|
||||
font-size: 14px;
|
||||
color: var(--color-text-secondary);
|
||||
margin: 0 0 4px;
|
||||
}
|
||||
|
||||
.property-address {
|
||||
font-size: 13px;
|
||||
color: var(--color-text-muted);
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.property-managers {
|
||||
margin-top: 8px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.managers-label {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
color: var(--color-text-muted);
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.manager-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.manager-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 3px 10px 3px 3px;
|
||||
border-radius: 16px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
background: color-mix(in srgb, var(--color-accent) 10%, transparent);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.manager-avatar {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-accent);
|
||||
color: white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.property-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.space-count {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.property-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.btn-icon:hover {
|
||||
border-color: var(--color-accent);
|
||||
color: var(--color-accent);
|
||||
background: color-mix(in srgb, var(--color-accent) 8%, transparent);
|
||||
}
|
||||
|
||||
.btn-icon-danger:hover {
|
||||
border-color: var(--color-danger);
|
||||
color: var(--color-danger);
|
||||
background: color-mix(in srgb, var(--color-danger) 8%, transparent);
|
||||
}
|
||||
|
||||
/* Modal */
|
||||
.modal {
|
||||
position: fixed;
|
||||
top: 0; left: 0; right: 0; bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: var(--color-surface);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 28px;
|
||||
max-width: 500px;
|
||||
width: 90%;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.modal-content h3 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 20px;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.modal-confirm p {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
margin: 0 0 20px;
|
||||
}
|
||||
|
||||
.property-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.form-group input[type="text"],
|
||||
.form-group input[type="email"],
|
||||
.form-group textarea {
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 14px;
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text-primary);
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.form-group input:focus,
|
||||
.form-group textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-accent);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-accent) 15%, transparent);
|
||||
}
|
||||
|
||||
.form-checkbox label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 10px 20px;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
.btn-primary { background: var(--color-accent); color: white; }
|
||||
.btn-primary:hover:not(:disabled) { background: var(--color-accent-hover); }
|
||||
|
||||
.btn-secondary { background: var(--color-bg-tertiary); color: var(--color-text-primary); }
|
||||
.btn-secondary:hover:not(:disabled) { background: var(--color-border); }
|
||||
|
||||
.btn-danger { background: var(--color-danger); color: white; }
|
||||
.btn-danger:hover:not(:disabled) { opacity: 0.9; }
|
||||
|
||||
.error {
|
||||
padding: 12px;
|
||||
background: color-mix(in srgb, var(--color-danger) 10%, transparent);
|
||||
color: var(--color-danger);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.toast {
|
||||
position: fixed;
|
||||
bottom: 24px;
|
||||
right: 24px;
|
||||
padding: 12px 20px;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
z-index: 1100;
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.toast-success {
|
||||
background: var(--color-success);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.property-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
</style>
|
||||
1096
frontend/src/views/PropertyDetail.vue
Normal file
1096
frontend/src/views/PropertyDetail.vue
Normal file
File diff suppressed because it is too large
Load Diff
493
frontend/src/views/PublicBooking.vue
Normal file
493
frontend/src/views/PublicBooking.vue
Normal file
@@ -0,0 +1,493 @@
|
||||
<template>
|
||||
<div class="public-booking-container">
|
||||
<div class="public-booking-card card">
|
||||
<h2>Book a Space</h2>
|
||||
<p class="subtitle">Reserve a meeting room or workspace without an account</p>
|
||||
|
||||
<!-- Step 1: Select Property -->
|
||||
<div v-if="step === 'property'">
|
||||
<div v-if="loadingProperties" class="loading-inline">Loading properties...</div>
|
||||
<div v-else-if="properties.length === 0" class="empty-msg">No public properties available.</div>
|
||||
<div v-else class="property-list">
|
||||
<div
|
||||
v-for="prop in properties"
|
||||
:key="prop.id"
|
||||
class="selectable-card"
|
||||
@click="selectProperty(prop)"
|
||||
>
|
||||
<h4>{{ prop.name }}</h4>
|
||||
<p v-if="prop.description" class="card-desc">{{ prop.description }}</p>
|
||||
<p v-if="prop.address" class="card-meta">{{ prop.address }}</p>
|
||||
<span class="card-count">{{ prop.space_count || 0 }} spaces</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 2: Select Space -->
|
||||
<div v-else-if="step === 'space'">
|
||||
<button class="btn-back" @click="step = 'property'">Back to properties</button>
|
||||
<h3 class="step-title">{{ selectedProperty?.name }} - Choose a Space</h3>
|
||||
<div v-if="loadingSpaces" class="loading-inline">Loading spaces...</div>
|
||||
<div v-else-if="spaces.length === 0" class="empty-msg">No spaces available.</div>
|
||||
<div v-else class="space-list">
|
||||
<div
|
||||
v-for="sp in spaces"
|
||||
:key="sp.id"
|
||||
class="selectable-card"
|
||||
@click="selectSpace(sp)"
|
||||
>
|
||||
<h4>{{ sp.name }}</h4>
|
||||
<div class="card-meta-row">
|
||||
<span>{{ formatType(sp.type) }}</span>
|
||||
<span>Capacity: {{ sp.capacity }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 3: Booking Form -->
|
||||
<div v-else-if="step === 'form'">
|
||||
<button class="btn-back" @click="step = 'space'">Back to spaces</button>
|
||||
<h3 class="step-title">Book {{ selectedSpace?.name }}</h3>
|
||||
|
||||
<form @submit.prevent="handleSubmit" class="booking-form">
|
||||
<div class="form-group">
|
||||
<label for="guest_name">Your Name *</label>
|
||||
<input id="guest_name" v-model="form.guest_name" type="text" required placeholder="John Doe" />
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="guest_email">Your Email *</label>
|
||||
<input id="guest_email" v-model="form.guest_email" type="email" required placeholder="john@example.com" />
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="guest_organization">Organization (optional)</label>
|
||||
<input id="guest_organization" v-model="form.guest_organization" type="text" placeholder="Company name" />
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="title">Booking Title *</label>
|
||||
<input id="title" v-model="form.title" type="text" required placeholder="Team meeting" />
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="description">Description (optional)</label>
|
||||
<textarea id="description" v-model="form.description" rows="2" placeholder="Additional details..."></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="date">Date *</label>
|
||||
<input id="date" v-model="form.date" type="date" required :min="minDate" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="start_time">Start Time *</label>
|
||||
<input id="start_time" v-model="form.start_time" type="time" required />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="end_time">End Time *</label>
|
||||
<input id="end_time" v-model="form.end_time" type="time" required />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="error">{{ error }}</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary btn-block" :disabled="submitting">
|
||||
{{ submitting ? 'Submitting...' : 'Submit Booking Request' }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Step 4: Success -->
|
||||
<div v-else-if="step === 'success'" class="success-state">
|
||||
<div class="success-icon">✓</div>
|
||||
<h3>Booking Request Sent!</h3>
|
||||
<p>Your booking request has been submitted. You will receive updates at <strong>{{ form.guest_email }}</strong>.</p>
|
||||
<button class="btn btn-primary" @click="resetForm">Book Another</button>
|
||||
</div>
|
||||
|
||||
<p class="login-hint">
|
||||
Already have an account? <router-link to="/login">Sign in</router-link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { publicApi, handleApiError } from '@/services/api'
|
||||
import type { Property, Space } from '@/types'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const step = ref<'property' | 'space' | 'form' | 'success'>('property')
|
||||
const loadingProperties = ref(false)
|
||||
const loadingSpaces = ref(false)
|
||||
const submitting = ref(false)
|
||||
const error = ref('')
|
||||
const properties = ref<Property[]>([])
|
||||
const spaces = ref<Space[]>([])
|
||||
const selectedProperty = ref<Property | null>(null)
|
||||
const selectedSpace = ref<Space | null>(null)
|
||||
|
||||
const form = ref({
|
||||
guest_name: '',
|
||||
guest_email: '',
|
||||
guest_organization: '',
|
||||
title: '',
|
||||
description: '',
|
||||
date: '',
|
||||
start_time: '',
|
||||
end_time: ''
|
||||
})
|
||||
|
||||
const minDate = computed(() => new Date().toISOString().split('T')[0])
|
||||
|
||||
const formatType = (type: string): string => {
|
||||
const map: Record<string, string> = {
|
||||
desk: 'Desk', meeting_room: 'Meeting Room', conference_room: 'Conference Room',
|
||||
sala: 'Sala', birou: 'Birou'
|
||||
}
|
||||
return map[type] || type
|
||||
}
|
||||
|
||||
const loadProperties = async () => {
|
||||
loadingProperties.value = true
|
||||
try {
|
||||
properties.value = await publicApi.getProperties()
|
||||
// If propertyId in route, auto-select
|
||||
const pid = route.params.propertyId
|
||||
if (pid) {
|
||||
const prop = properties.value.find(p => p.id === Number(pid))
|
||||
if (prop) {
|
||||
selectProperty(prop)
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = handleApiError(err)
|
||||
} finally {
|
||||
loadingProperties.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const selectProperty = async (prop: Property) => {
|
||||
selectedProperty.value = prop
|
||||
step.value = 'space'
|
||||
loadingSpaces.value = true
|
||||
try {
|
||||
spaces.value = await publicApi.getPropertySpaces(prop.id)
|
||||
} catch (err) {
|
||||
error.value = handleApiError(err)
|
||||
} finally {
|
||||
loadingSpaces.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const selectSpace = (sp: Space) => {
|
||||
selectedSpace.value = sp
|
||||
step.value = 'form'
|
||||
error.value = ''
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
error.value = ''
|
||||
if (!selectedSpace.value) return
|
||||
|
||||
if (form.value.start_time >= form.value.end_time) {
|
||||
error.value = 'End time must be after start time'
|
||||
return
|
||||
}
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
await publicApi.createBooking({
|
||||
space_id: selectedSpace.value.id,
|
||||
start_datetime: `${form.value.date}T${form.value.start_time}:00`,
|
||||
end_datetime: `${form.value.date}T${form.value.end_time}:00`,
|
||||
title: form.value.title,
|
||||
description: form.value.description || undefined,
|
||||
guest_name: form.value.guest_name,
|
||||
guest_email: form.value.guest_email,
|
||||
guest_organization: form.value.guest_organization || undefined
|
||||
})
|
||||
step.value = 'success'
|
||||
} catch (err) {
|
||||
error.value = handleApiError(err)
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const resetForm = () => {
|
||||
step.value = 'property'
|
||||
selectedProperty.value = null
|
||||
selectedSpace.value = null
|
||||
form.value = {
|
||||
guest_name: '',
|
||||
guest_email: '',
|
||||
guest_organization: '',
|
||||
title: '',
|
||||
description: '',
|
||||
date: '',
|
||||
start_time: '',
|
||||
end_time: ''
|
||||
}
|
||||
error.value = ''
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadProperties()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.public-booking-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
min-height: 100vh;
|
||||
padding: 2rem 1rem;
|
||||
background: var(--color-bg-primary);
|
||||
}
|
||||
|
||||
.public-booking-card {
|
||||
width: 100%;
|
||||
max-width: 560px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
text-align: center;
|
||||
margin-bottom: 0.25rem;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
text-align: center;
|
||||
color: var(--color-text-secondary);
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.step-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.btn-back {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-accent);
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
margin-bottom: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.btn-back:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.loading-inline {
|
||||
text-align: center;
|
||||
padding: 24px;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.empty-msg {
|
||||
text-align: center;
|
||||
padding: 24px;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.property-list, .space-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.selectable-card {
|
||||
padding: 16px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
background: var(--color-bg-secondary);
|
||||
}
|
||||
|
||||
.selectable-card:hover {
|
||||
border-color: var(--color-accent);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.selectable-card h4 {
|
||||
margin: 0 0 4px;
|
||||
font-size: 16px;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.card-desc {
|
||||
font-size: 14px;
|
||||
color: var(--color-text-secondary);
|
||||
margin: 0 0 4px;
|
||||
}
|
||||
|
||||
.card-meta {
|
||||
font-size: 13px;
|
||||
color: var(--color-text-muted);
|
||||
margin: 0 0 4px;
|
||||
}
|
||||
|
||||
.card-meta-row {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
font-size: 13px;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.card-count {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.booking-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.form-group input,
|
||||
.form-group textarea,
|
||||
.form-group select {
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 14px;
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text-primary);
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.form-group input:focus,
|
||||
.form-group textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-accent);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-accent) 15%, transparent);
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 10px 20px;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--color-accent);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background: var(--color-accent-hover);
|
||||
}
|
||||
|
||||
.btn-block {
|
||||
width: 100%;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.error {
|
||||
padding: 10px 14px;
|
||||
background: color-mix(in srgb, var(--color-danger) 10%, transparent);
|
||||
border-left: 3px solid var(--color-danger);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-danger);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.success-state {
|
||||
text-align: center;
|
||||
padding: 24px 0;
|
||||
}
|
||||
|
||||
.success-icon {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 50%;
|
||||
background: color-mix(in srgb, var(--color-success) 15%, transparent);
|
||||
color: var(--color-success);
|
||||
font-size: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 0 auto 16px;
|
||||
}
|
||||
|
||||
.success-state h3 {
|
||||
color: var(--color-success);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.success-state p {
|
||||
color: var(--color-text-secondary);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.login-hint {
|
||||
text-align: center;
|
||||
margin-top: 1.5rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid var(--color-border);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.login-hint a {
|
||||
color: var(--color-accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.login-hint a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.form-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -66,7 +66,82 @@
|
||||
<div class="card calendar-card">
|
||||
<h3>Availability Calendar</h3>
|
||||
<p class="calendar-subtitle">View existing bookings and available time slots</p>
|
||||
<SpaceCalendar ref="calendarRef" :space-id="space.id" />
|
||||
<SpaceCalendar
|
||||
ref="calendarRef"
|
||||
:space-id="space.id"
|
||||
:space-name="space.name"
|
||||
@edit-booking="openEditBookingModal"
|
||||
@cancel-booking="handleCancelBooking"
|
||||
@approve-booking="handleApproveBooking"
|
||||
@reject-booking="openRejectBookingModal"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Bookings List Section -->
|
||||
<div class="card bookings-card">
|
||||
<div class="bookings-card-header">
|
||||
<h3>Bookings</h3>
|
||||
<span class="result-count" v-if="!bookingsLoading">{{ spaceBookings.length }} bookings</span>
|
||||
</div>
|
||||
|
||||
<div v-if="bookingsLoading" class="bookings-loading">Loading bookings...</div>
|
||||
<div v-else-if="spaceBookings.length === 0" class="bookings-empty">No bookings found for this space.</div>
|
||||
|
||||
<table v-else class="bookings-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>User</th>
|
||||
<th>Date</th>
|
||||
<th>Time</th>
|
||||
<th>Title</th>
|
||||
<th>Status</th>
|
||||
<th v-if="isAdmin">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="b in spaceBookings" :key="b.id">
|
||||
<td class="cell-user">{{ b.user?.full_name || b.guest_name || 'Unknown' }}</td>
|
||||
<td>{{ formatBookingDate(b.start_datetime) }}</td>
|
||||
<td class="cell-time">{{ formatBookingTime(b.start_datetime) }} - {{ formatBookingTime(b.end_datetime) }}</td>
|
||||
<td class="cell-title">{{ b.title }}</td>
|
||||
<td><span :class="['badge-status', `badge-${b.status}`]">{{ b.status }}</span></td>
|
||||
<td v-if="isAdmin" class="cell-actions">
|
||||
<button
|
||||
v-if="b.status === 'pending'"
|
||||
class="btn-action btn-action-approve"
|
||||
title="Approve"
|
||||
@click="handleApproveBooking(b)"
|
||||
>
|
||||
<Check :size="14" />
|
||||
</button>
|
||||
<button
|
||||
v-if="b.status === 'pending'"
|
||||
class="btn-action btn-action-reject"
|
||||
title="Reject"
|
||||
@click="openRejectBookingModal(b)"
|
||||
>
|
||||
<XIcon :size="14" />
|
||||
</button>
|
||||
<button
|
||||
v-if="b.status === 'pending' || b.status === 'approved'"
|
||||
class="btn-action btn-action-edit"
|
||||
title="Edit"
|
||||
@click="openEditBookingModal(b)"
|
||||
>
|
||||
<Pencil :size="14" />
|
||||
</button>
|
||||
<button
|
||||
v-if="b.status === 'pending' || b.status === 'approved'"
|
||||
class="btn-action btn-action-cancel"
|
||||
title="Cancel"
|
||||
@click="handleCancelBooking(b)"
|
||||
>
|
||||
<Ban :size="14" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -93,24 +168,114 @@
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Edit Booking Modal -->
|
||||
<div v-if="showEditModal" class="modal" @click.self="closeEditModal">
|
||||
<div class="modal-content">
|
||||
<h3>Edit Booking</h3>
|
||||
<form @submit.prevent="saveEdit">
|
||||
<div class="form-group">
|
||||
<label for="edit-title">Title *</label>
|
||||
<input id="edit-title" v-model="editForm.title" type="text" required maxlength="200" placeholder="Booking title" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="edit-description">Description (optional)</label>
|
||||
<textarea id="edit-description" v-model="editForm.description" rows="3" placeholder="Additional details..."></textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Start *</label>
|
||||
<div class="datetime-row">
|
||||
<div class="datetime-field">
|
||||
<label for="edit-start-date" class="sublabel">Date</label>
|
||||
<input id="edit-start-date" v-model="editForm.start_date" type="date" required />
|
||||
</div>
|
||||
<div class="datetime-field">
|
||||
<label for="edit-start-time" class="sublabel">Time</label>
|
||||
<input id="edit-start-time" v-model="editForm.start_time" type="time" required />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>End *</label>
|
||||
<div class="datetime-row">
|
||||
<div class="datetime-field">
|
||||
<label for="edit-end-date" class="sublabel">Date</label>
|
||||
<input id="edit-end-date" v-model="editForm.end_date" type="date" required />
|
||||
</div>
|
||||
<div class="datetime-field">
|
||||
<label for="edit-end-time" class="sublabel">Time</label>
|
||||
<input id="edit-end-time" v-model="editForm.end_time" type="time" required />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="editError" class="error-msg">{{ editError }}</div>
|
||||
<div class="form-actions">
|
||||
<button type="button" class="btn btn-secondary" @click="closeEditModal">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary" :disabled="editSaving">{{ editSaving ? 'Saving...' : 'Save Changes' }}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Confirm Modal -->
|
||||
<div v-if="showConfirmModal" class="modal" @click.self="showConfirmModal = false">
|
||||
<div class="modal-content">
|
||||
<h3>{{ confirmTitle }}</h3>
|
||||
<p class="confirm-text">{{ confirmMessage }}</p>
|
||||
<div class="form-actions">
|
||||
<button type="button" class="btn btn-secondary" @click="showConfirmModal = false" :disabled="confirmLoading">Cancel</button>
|
||||
<button type="button" :class="['btn', confirmDanger ? 'btn-danger' : 'btn-primary']" @click="executeConfirm" :disabled="confirmLoading">
|
||||
{{ confirmLoading ? 'Processing...' : confirmLabel }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Reject Modal -->
|
||||
<div v-if="showRejectModal" class="modal" @click.self="showRejectModal = false">
|
||||
<div class="modal-content">
|
||||
<h3>Reject Booking</h3>
|
||||
<p class="confirm-text">Rejecting "{{ rejectBooking?.title }}"</p>
|
||||
<div class="form-group">
|
||||
<label for="reject-reason">Reason (optional)</label>
|
||||
<textarea id="reject-reason" v-model="rejectReason" rows="3" placeholder="Enter rejection reason..."></textarea>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button type="button" class="btn btn-secondary" @click="showRejectModal = false">Cancel</button>
|
||||
<button type="button" class="btn btn-danger" @click="doReject" :disabled="rejectLoading">
|
||||
{{ rejectLoading ? 'Rejecting...' : 'Reject' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Toast -->
|
||||
<div v-if="toastMsg" :class="['toast', `toast-${toastType}`]">{{ toastMsg }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { spacesApi, handleApiError } from '@/services/api'
|
||||
import { spacesApi, bookingsApi, adminBookingsApi, handleApiError } from '@/services/api'
|
||||
import {
|
||||
formatDate as formatDateTZ,
|
||||
formatTime as formatTimeTZ,
|
||||
isoToLocalDateTime,
|
||||
localDateTimeToISO
|
||||
} from '@/utils/datetime'
|
||||
import Breadcrumb from '@/components/Breadcrumb.vue'
|
||||
import SpaceCalendar from '@/components/SpaceCalendar.vue'
|
||||
import BookingForm from '@/components/BookingForm.vue'
|
||||
import AdminBookingForm from '@/components/AdminBookingForm.vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { Users, Plus, UserPlus } from 'lucide-vue-next'
|
||||
import type { Space } from '@/types'
|
||||
import { Users, Plus, UserPlus, Check, X as XIcon, Pencil, Ban } from 'lucide-vue-next'
|
||||
import type { Space, Booking } from '@/types'
|
||||
|
||||
const route = useRoute()
|
||||
const authStore = useAuthStore()
|
||||
const isAdmin = computed(() => authStore.user?.role === 'admin')
|
||||
const isAdmin = computed(() => ['admin', 'superadmin', 'manager'].includes(authStore.user?.role || ''))
|
||||
const userTimezone = computed(() => authStore.user?.timezone || 'UTC')
|
||||
|
||||
const breadcrumbItems = computed(() => [
|
||||
{ label: 'Dashboard', to: '/dashboard' },
|
||||
@@ -125,6 +290,45 @@ const showBookingForm = ref(false)
|
||||
const showAdminBookingForm = ref(false)
|
||||
const calendarRef = ref<InstanceType<typeof SpaceCalendar> | null>(null)
|
||||
|
||||
// Bookings list
|
||||
const spaceBookings = ref<Booking[]>([])
|
||||
const bookingsLoading = ref(false)
|
||||
|
||||
// Toast
|
||||
const toastMsg = ref('')
|
||||
const toastType = ref<'success' | 'error'>('success')
|
||||
const showToast = (msg: string, type: 'success' | 'error') => {
|
||||
toastMsg.value = msg
|
||||
toastType.value = type
|
||||
setTimeout(() => { toastMsg.value = '' }, type === 'success' ? 3000 : 5000)
|
||||
}
|
||||
|
||||
// Edit modal
|
||||
const showEditModal = ref(false)
|
||||
const editingBooking = ref<Booking | null>(null)
|
||||
const editForm = ref({ title: '', description: '', start_date: '', start_time: '', end_date: '', end_time: '' })
|
||||
const editError = ref('')
|
||||
const editSaving = ref(false)
|
||||
|
||||
// Confirm modal
|
||||
const showConfirmModal = ref(false)
|
||||
const confirmTitle = ref('')
|
||||
const confirmMessage = ref('')
|
||||
const confirmDanger = ref(false)
|
||||
const confirmLabel = ref('Yes')
|
||||
const confirmLoading = ref(false)
|
||||
const onConfirm = ref<(() => Promise<void>) | null>(null)
|
||||
|
||||
// Reject modal
|
||||
const showRejectModal = ref(false)
|
||||
const rejectBooking = ref<Booking | null>(null)
|
||||
const rejectReason = ref('')
|
||||
const rejectLoading = ref(false)
|
||||
|
||||
// Format helpers
|
||||
const formatBookingDate = (datetime: string): string => formatDateTZ(datetime, userTimezone.value)
|
||||
const formatBookingTime = (datetime: string): string => formatTimeTZ(datetime, userTimezone.value)
|
||||
|
||||
// Format space type for display
|
||||
const formatType = (type: string): string => {
|
||||
const typeMap: Record<string, string> = {
|
||||
@@ -155,6 +359,7 @@ const loadSpace = async () => {
|
||||
error.value = 'Space not found (404). The space may not exist or has been removed.'
|
||||
} else {
|
||||
space.value = foundSpace
|
||||
loadSpaceBookings()
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = handleApiError(err)
|
||||
@@ -163,6 +368,29 @@ const loadSpace = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
// Load bookings for this space
|
||||
const loadSpaceBookings = async () => {
|
||||
if (!space.value) return
|
||||
bookingsLoading.value = true
|
||||
try {
|
||||
const now = new Date()
|
||||
const start = new Date(now.getFullYear(), now.getMonth() - 1, 1)
|
||||
const end = new Date(now.getFullYear(), now.getMonth() + 3, 0)
|
||||
spaceBookings.value = await bookingsApi.getForSpace(space.value.id, start.toISOString(), end.toISOString())
|
||||
// Sort by date descending
|
||||
spaceBookings.value.sort((a, b) => new Date(b.start_datetime).getTime() - new Date(a.start_datetime).getTime())
|
||||
} catch (err) {
|
||||
// Non-critical
|
||||
} finally {
|
||||
bookingsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const refreshAll = () => {
|
||||
calendarRef.value?.refresh()
|
||||
loadSpaceBookings()
|
||||
}
|
||||
|
||||
// Handle reserve button click
|
||||
const handleReserve = () => {
|
||||
showBookingForm.value = !showBookingForm.value
|
||||
@@ -176,13 +404,141 @@ const closeBookingModal = () => {
|
||||
// Handle booking form submit
|
||||
const handleBookingSubmit = () => {
|
||||
showBookingForm.value = false
|
||||
calendarRef.value?.refresh()
|
||||
refreshAll()
|
||||
}
|
||||
|
||||
// Handle admin booking form submit
|
||||
const handleAdminBookingSubmit = () => {
|
||||
showAdminBookingForm.value = false
|
||||
calendarRef.value?.refresh()
|
||||
refreshAll()
|
||||
}
|
||||
|
||||
// --- Calendar action handlers ---
|
||||
|
||||
const openConfirm = (opts: { title: string; message: string; danger?: boolean; label?: string; action: () => Promise<void> }) => {
|
||||
confirmTitle.value = opts.title
|
||||
confirmMessage.value = opts.message
|
||||
confirmDanger.value = opts.danger ?? false
|
||||
confirmLabel.value = opts.label ?? 'Yes'
|
||||
onConfirm.value = opts.action
|
||||
confirmLoading.value = false
|
||||
showConfirmModal.value = true
|
||||
}
|
||||
|
||||
const executeConfirm = async () => {
|
||||
if (!onConfirm.value) return
|
||||
confirmLoading.value = true
|
||||
try {
|
||||
await onConfirm.value()
|
||||
} finally {
|
||||
confirmLoading.value = false
|
||||
showConfirmModal.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleApproveBooking = (booking: Booking) => {
|
||||
openConfirm({
|
||||
title: 'Approve Booking',
|
||||
message: `Approve booking "${booking.title}"?`,
|
||||
label: 'Approve',
|
||||
action: async () => {
|
||||
await adminBookingsApi.approve(booking.id)
|
||||
showToast(`Booking "${booking.title}" approved!`, 'success')
|
||||
refreshAll()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleCancelBooking = (booking: Booking) => {
|
||||
openConfirm({
|
||||
title: 'Cancel Booking',
|
||||
message: `Cancel booking "${booking.title}"?`,
|
||||
danger: true,
|
||||
label: 'Cancel Booking',
|
||||
action: async () => {
|
||||
await adminBookingsApi.cancel(booking.id)
|
||||
showToast(`Booking "${booking.title}" canceled.`, 'success')
|
||||
refreshAll()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const openRejectBookingModal = (booking: Booking) => {
|
||||
rejectBooking.value = booking
|
||||
rejectReason.value = ''
|
||||
rejectLoading.value = false
|
||||
showRejectModal.value = true
|
||||
}
|
||||
|
||||
const doReject = async () => {
|
||||
if (!rejectBooking.value) return
|
||||
rejectLoading.value = true
|
||||
try {
|
||||
await adminBookingsApi.reject(rejectBooking.value.id, rejectReason.value || undefined)
|
||||
showToast(`Booking "${rejectBooking.value.title}" rejected.`, 'success')
|
||||
showRejectModal.value = false
|
||||
refreshAll()
|
||||
} catch (err) {
|
||||
showToast(handleApiError(err), 'error')
|
||||
} finally {
|
||||
rejectLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const openEditBookingModal = (booking: Booking) => {
|
||||
editingBooking.value = booking
|
||||
const startLocal = isoToLocalDateTime(booking.start_datetime, userTimezone.value)
|
||||
const endLocal = isoToLocalDateTime(booking.end_datetime, userTimezone.value)
|
||||
const [startDate, startTime] = startLocal.split('T')
|
||||
const [endDate, endTime] = endLocal.split('T')
|
||||
editForm.value = {
|
||||
title: booking.title,
|
||||
description: booking.description || '',
|
||||
start_date: startDate,
|
||||
start_time: startTime,
|
||||
end_date: endDate,
|
||||
end_time: endTime
|
||||
}
|
||||
editError.value = ''
|
||||
showEditModal.value = true
|
||||
}
|
||||
|
||||
const closeEditModal = () => {
|
||||
showEditModal.value = false
|
||||
editingBooking.value = null
|
||||
editError.value = ''
|
||||
}
|
||||
|
||||
const saveEdit = async () => {
|
||||
if (!editingBooking.value) return
|
||||
editSaving.value = true
|
||||
editError.value = ''
|
||||
try {
|
||||
const startDateTime = `${editForm.value.start_date}T${editForm.value.start_time}`
|
||||
const endDateTime = `${editForm.value.end_date}T${editForm.value.end_time}`
|
||||
if (isAdmin.value) {
|
||||
await adminBookingsApi.update(editingBooking.value.id, {
|
||||
title: editForm.value.title,
|
||||
description: editForm.value.description,
|
||||
start_datetime: localDateTimeToISO(startDateTime),
|
||||
end_datetime: localDateTimeToISO(endDateTime)
|
||||
})
|
||||
} else {
|
||||
await bookingsApi.update(editingBooking.value.id, {
|
||||
title: editForm.value.title,
|
||||
description: editForm.value.description,
|
||||
start_datetime: localDateTimeToISO(startDateTime),
|
||||
end_datetime: localDateTimeToISO(endDateTime)
|
||||
})
|
||||
}
|
||||
closeEditModal()
|
||||
showToast('Booking updated successfully!', 'success')
|
||||
refreshAll()
|
||||
} catch (err) {
|
||||
editError.value = handleApiError(err)
|
||||
} finally {
|
||||
editSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
@@ -380,6 +736,264 @@ onMounted(() => {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
/* Bookings List Card */
|
||||
.bookings-card-header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.result-count {
|
||||
font-size: 13px;
|
||||
color: var(--color-text-muted);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.bookings-loading,
|
||||
.bookings-empty {
|
||||
text-align: center;
|
||||
padding: 24px;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.bookings-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.bookings-table th {
|
||||
text-align: left;
|
||||
padding: 10px 12px;
|
||||
background: var(--color-bg-secondary);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.bookings-table td {
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--color-border-light);
|
||||
font-size: 13px;
|
||||
color: var(--color-text-primary);
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.bookings-table tbody tr:hover {
|
||||
background: var(--color-surface-hover);
|
||||
}
|
||||
|
||||
.bookings-table tbody tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.cell-user {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.cell-time {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.cell-title {
|
||||
max-width: 200px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.cell-actions {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.badge-status {
|
||||
display: inline-block;
|
||||
padding: 3px 10px;
|
||||
border-radius: 10px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.badge-pending {
|
||||
background: color-mix(in srgb, var(--color-warning) 15%, transparent);
|
||||
color: var(--color-warning);
|
||||
}
|
||||
|
||||
.badge-approved {
|
||||
background: color-mix(in srgb, var(--color-success) 15%, transparent);
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.badge-rejected {
|
||||
background: color-mix(in srgb, var(--color-danger) 15%, transparent);
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.badge-canceled {
|
||||
background: var(--color-bg-tertiary);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.btn-action {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
padding: 0;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.btn-action:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.btn-action-approve:hover { color: var(--color-success); border-color: var(--color-success); }
|
||||
.btn-action-reject:hover { color: var(--color-danger); border-color: var(--color-danger); }
|
||||
.btn-action-edit:hover { color: var(--color-warning); border-color: var(--color-warning); }
|
||||
.btn-action-cancel:hover { color: var(--color-danger); border-color: var(--color-danger); }
|
||||
|
||||
/* Form styles for modals */
|
||||
.form-group {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.form-group > label {
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.sublabel {
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
font-weight: 400;
|
||||
font-size: 12px;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.datetime-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.datetime-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.form-group input,
|
||||
.form-group textarea {
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 14px;
|
||||
font-family: inherit;
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text-primary);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.form-group input:focus,
|
||||
.form-group textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-accent);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-accent) 15%, transparent);
|
||||
}
|
||||
|
||||
.form-group textarea {
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.error-msg {
|
||||
padding: 12px;
|
||||
background: color-mix(in srgb, var(--color-danger) 10%, transparent);
|
||||
color: var(--color-danger);
|
||||
border-radius: var(--radius-sm);
|
||||
margin-bottom: 16px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--color-bg-tertiary);
|
||||
color: var(--color-text-primary);
|
||||
padding: 10px 20px;
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
background: var(--color-border);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: var(--color-danger);
|
||||
color: white;
|
||||
padding: 10px 20px;
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-danger:hover:not(:disabled) {
|
||||
background: color-mix(in srgb, var(--color-danger) 85%, black);
|
||||
}
|
||||
|
||||
.confirm-text {
|
||||
color: var(--color-text-secondary);
|
||||
margin-bottom: 20px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Toast */
|
||||
.toast {
|
||||
position: fixed;
|
||||
bottom: 24px;
|
||||
right: 24px;
|
||||
padding: 12px 20px;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
z-index: 1100;
|
||||
animation: slideUp 0.3s ease;
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.toast-success { background: var(--color-success); color: #fff; }
|
||||
.toast-error { background: var(--color-danger); color: #fff; }
|
||||
|
||||
@keyframes slideUp {
|
||||
from { transform: translateY(20px); opacity: 0; }
|
||||
to { transform: translateY(0); opacity: 1; }
|
||||
}
|
||||
|
||||
/* Modal */
|
||||
.modal {
|
||||
position: fixed;
|
||||
|
||||
@@ -58,9 +58,12 @@
|
||||
>
|
||||
<div class="space-card-header">
|
||||
<h3>{{ space.name }}</h3>
|
||||
<span :class="['badge', space.is_active ? 'badge-active' : 'badge-inactive']">
|
||||
{{ space.is_active ? 'Active' : 'Inactive' }}
|
||||
</span>
|
||||
<div class="header-badges">
|
||||
<span v-if="space.property_name" class="badge badge-property">{{ space.property_name }}</span>
|
||||
<span :class="['badge', space.is_active ? 'badge-active' : 'badge-inactive']">
|
||||
{{ space.is_active ? 'Active' : 'Inactive' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-card-body">
|
||||
@@ -431,6 +434,18 @@ onMounted(() => {
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.badge-property {
|
||||
background: color-mix(in srgb, var(--color-accent) 12%, transparent);
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.header-badges {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.space-card-body {
|
||||
flex: 1;
|
||||
margin-bottom: 20px;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<div class="users">
|
||||
<Breadcrumb :items="breadcrumbItems" />
|
||||
<div class="page-header">
|
||||
<h2>Admin Dashboard - User Management</h2>
|
||||
<h2>User Management</h2>
|
||||
<button class="btn btn-primary" @click="openCreateModal">
|
||||
<UserPlus :size="16" />
|
||||
Create New User
|
||||
@@ -16,7 +16,8 @@
|
||||
<label for="filter-role">Filter by Role</label>
|
||||
<select id="filter-role" v-model="filterRole" @change="loadUsers">
|
||||
<option value="">All Roles</option>
|
||||
<option value="admin">Admin</option>
|
||||
<option value="admin">Superadmin</option>
|
||||
<option value="manager">Manager</option>
|
||||
<option value="user">User</option>
|
||||
</select>
|
||||
</div>
|
||||
@@ -57,8 +58,8 @@
|
||||
<td>{{ user.email }}</td>
|
||||
<td>{{ user.full_name }}</td>
|
||||
<td>
|
||||
<span :class="['badge', user.role === 'admin' ? 'badge-admin' : 'badge-user']">
|
||||
{{ user.role }}
|
||||
<span :class="['badge', user.role === 'admin' || user.role === 'superadmin' ? 'badge-admin' : user.role === 'manager' ? 'badge-manager' : 'badge-user']">
|
||||
{{ user.role === 'admin' ? 'superadmin' : user.role }}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ user.organization || '-' }}</td>
|
||||
@@ -140,7 +141,8 @@
|
||||
<label for="role">Role *</label>
|
||||
<select id="role" v-model="formData.role" required>
|
||||
<option value="user">User</option>
|
||||
<option value="admin">Admin</option>
|
||||
<option value="manager">Manager</option>
|
||||
<option value="admin">Superadmin</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@@ -268,7 +270,8 @@ const handleSubmit = async () => {
|
||||
full_name: formData.value.full_name,
|
||||
password: formData.value.password,
|
||||
role: formData.value.role,
|
||||
organization: formData.value.organization || undefined
|
||||
organization: formData.value.organization || undefined,
|
||||
timezone: 'UTC'
|
||||
})
|
||||
success.value = 'User created successfully!'
|
||||
}
|
||||
@@ -389,6 +392,8 @@ onMounted(() => {
|
||||
|
||||
.page-header h2 {
|
||||
margin: 0;
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
@@ -593,6 +598,11 @@ onMounted(() => {
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.badge-manager {
|
||||
background: color-mix(in srgb, var(--color-warning) 15%, transparent);
|
||||
color: var(--color-warning);
|
||||
}
|
||||
|
||||
.badge-user {
|
||||
background: var(--color-bg-tertiary);
|
||||
color: var(--color-text-primary);
|
||||
@@ -619,15 +629,18 @@ onMounted(() => {
|
||||
|
||||
.modal-content {
|
||||
background: var(--color-surface);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 24px;
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 28px;
|
||||
max-width: 500px;
|
||||
width: 90%;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.modal-content h3 {
|
||||
margin-bottom: 16px;
|
||||
margin-top: 0;
|
||||
margin-bottom: 20px;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user