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>
631 lines
15 KiB
Vue
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>
|