Files
space-booking/frontend/src/views/Properties.vue
Claude Agent e21cf03a16 feat: add multi-tenant system with properties, organizations, and public booking
Implement complete multi-property architecture:
- Properties (groups of spaces) with public/private visibility
- Property managers (many-to-many) with role-based permissions
- Organizations with member management
- Anonymous/guest booking support via public API (/api/public/*)
- Property-scoped spaces, bookings, and settings
- Frontend: property selector, organization management, public booking views
- Migration script and updated seed data

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 00:17:21 +00:00

631 lines
15 KiB
Vue

<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>