Files
space-booking/frontend/src/views/PropertyDetail.vue
Claude Agent 212b66c758 fix(qa): randează codul QR în cardul de link public
Două probleme, ambele găsite la QA în browser:
- `qrcode` e CommonJS; sub Vite metodele stau pe `.default`, deci
  `QRCode.toCanvas` era undefined → throw prins de un catch gol (blank canvas).
- `renderQr()` era apelat în load() înainte ca ref-ul canvas să fie montat,
  și nu se relua. Adăugat un watch care randează când canvas + is_public sunt gata.

Verificat live: canvas 512x512, QR desenat, buton „Descarcă QR" activ.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 19:15:31 +00:00

1400 lines
38 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="property-detail">
<Breadcrumb :items="breadcrumbItems" />
<div v-if="loading" class="loading-state">
<div class="spinner"></div>
<p>Loading property...</p>
</div>
<div v-else-if="error" class="error-card">
<h3>Error</h3>
<p>{{ error }}</p>
<router-link to="/properties" class="btn btn-primary">Back to Properties</router-link>
</div>
<div v-else-if="property" class="property-content">
<!-- Header -->
<div class="property-header">
<div class="header-info">
<h1>{{ property.name }}</h1>
<div class="header-badges">
<span :class="['badge', property.is_public ? 'badge-public' : 'badge-private']">
{{ property.is_public ? 'Public' : 'Private' }}
</span>
<span :class="['badge', property.is_active ? 'badge-active' : 'badge-inactive']">
{{ property.is_active ? 'Active' : 'Inactive' }}
</span>
</div>
<p v-if="property.description" class="property-desc">{{ property.description }}</p>
<p v-if="property.address" class="property-address">{{ property.address }}</p>
<div v-if="property.managers && property.managers.length > 0" class="header-managers">
<span class="managers-label">Managed by:</span>
<span v-for="mgr in property.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="header-actions">
<button class="btn btn-secondary" @click="showEditModal = true">Edit</button>
<button
:class="['btn', property.is_active ? 'btn-warning' : 'btn-success']"
@click="toggleStatus"
>
{{ property.is_active ? 'Deactivate' : 'Activate' }}
</button>
</div>
</div>
<!-- Public Link Card -->
<div class="public-link-card">
<template v-if="property.is_public">
<div class="public-link-header">
<h3>Link public de rezervare</h3>
</div>
<div class="public-link-row">
<code class="public-link-url">{{ publicBookingUrl }}</code>
<button class="btn btn-secondary btn-sm" @click="copyPublicLink">
{{ copied ? 'Copiat ' : 'Copiază' }}
</button>
<a :href="publicBookingUrl" target="_blank" rel="noopener" class="btn btn-secondary btn-sm">
Deschide pagina publică
</a>
</div>
<div class="slug-edit">
<template v-if="!editingSlug">
<button class="btn-link" @click="startEditSlug">Editează slug</button>
</template>
<template v-else>
<div class="slug-edit-row">
<input
v-model="slugInput"
type="text"
class="slug-input"
placeholder="slug-proprietate"
:disabled="savingSlug"
/>
<button class="btn btn-primary btn-sm" :disabled="savingSlug" @click="saveSlug">
{{ savingSlug ? 'Se salvează...' : 'Salvează' }}
</button>
<button class="btn btn-secondary btn-sm" :disabled="savingSlug" @click="cancelEditSlug">
Anulează
</button>
</div>
<div class="slug-hint">Doar litere mici, cifre și cratime, 364 caractere.</div>
<div v-if="slugError" class="error-inline">{{ slugError }}</div>
</template>
</div>
<div class="qr-section">
<canvas ref="qrCanvas" class="qr-canvas"></canvas>
<button class="btn btn-secondary btn-sm" :disabled="!qrReady" @click="downloadQr">
Descarcă QR (PNG)
</button>
</div>
</template>
<template v-else>
<div class="public-link-header">
<h3>Link public de rezervare</h3>
</div>
<p class="info-msg">Această proprietate este privată. -o publică pentru a obține un link de rezervare și un cod QR.</p>
<button class="btn btn-primary btn-sm" :disabled="makingPublic" @click="makePublic">
{{ makingPublic ? 'Se procesează...' : '-o publică' }}
</button>
</template>
</div>
<!-- Tabs -->
<div class="tabs">
<button
v-for="tab in tabs"
:key="tab.key"
:class="['tab', { active: activeTab === tab.key }]"
@click="activeTab = tab.key"
>
{{ tab.label }}
</button>
</div>
<!-- Spaces Tab -->
<div v-if="activeTab === 'spaces'" class="tab-content">
<div class="tab-header">
<h3>Spaces ({{ spaces.length }})</h3>
<button class="btn btn-primary btn-sm" @click="openCreateSpaceModal">+ Add Space</button>
</div>
<div v-if="spaces.length === 0" class="empty-tab">No spaces in this property yet. Click "Add Space" to create one.</div>
<div v-else class="space-cards">
<div v-for="sp in spaces" :key="sp.id" :class="['space-card', { 'space-card-inactive': !sp.is_active }]">
<div class="space-card-header">
<div>
<h4>{{ sp.name }}</h4>
<span :class="['badge', sp.is_active ? 'badge-active' : 'badge-inactive']">
{{ sp.is_active ? 'Active' : 'Inactive' }}
</span>
</div>
<div class="space-card-actions">
<button
class="btn-icon"
:title="sp.is_active ? 'Deactivate' : 'Activate'"
@click="toggleSpaceStatus(sp)"
>
<Power :size="14" />
</button>
<button
class="btn-icon btn-icon-danger"
title="Delete space"
@click="confirmDeleteSpace(sp)"
>
<Trash2 :size="14" />
</button>
</div>
</div>
<div class="space-meta">
<span>{{ sp.type === 'sala' ? 'Sala' : 'Birou' }}</span>
<span>Capacity: {{ sp.capacity }}</span>
</div>
<p v-if="sp.description" class="space-desc">{{ sp.description }}</p>
</div>
</div>
</div>
<!-- Bookings Tab -->
<div v-if="activeTab === 'bookings'" class="tab-content">
<h3>Bookings for this Property</h3>
<div v-if="bookings.length === 0" class="empty-tab">No bookings found.</div>
<div v-else class="bookings-list">
<div v-for="bk in bookings" :key="bk.id" class="booking-item">
<div class="booking-info">
<strong>{{ bk.title }}</strong>
<div class="booking-meta-row">
<span class="booking-space">{{ bk.space?.name }}</span>
<span class="booking-date">{{ formatDate(bk.start_datetime) }} - {{ formatTime(bk.end_datetime) }}</span>
</div>
<span v-if="bk.is_anonymous" class="booking-guest">Guest: {{ bk.guest_name }}</span>
<span v-else-if="bk.user" class="booking-user">{{ bk.user.full_name }}</span>
</div>
<span :class="['badge', `badge-${bk.status}`]">{{ bk.status }}</span>
</div>
</div>
</div>
<!-- Access Tab -->
<div v-if="activeTab === 'access'" class="tab-content">
<h3>Access Control</h3>
<div class="access-explainer">
<p v-if="property.is_public" class="info-msg">This property is <strong>public</strong>. All users (including anonymous guests) can view spaces and create bookings.</p>
<p v-else class="info-msg">This property is <strong>private</strong>. Only users with explicit access below can view spaces and create bookings.</p>
<p class="info-msg-detail">Users with access can: browse available spaces, check availability, and submit booking requests. They still need approval if "Require approval" is enabled in Settings.</p>
</div>
<!-- Grant Access Form -->
<div class="grant-access-form">
<div class="grant-access-row">
<select v-model.number="grantUserId" class="grant-select">
<option :value="null">Select a user to grant access...</option>
<option v-for="u in availableUsers" :key="u.id" :value="u.id">
{{ u.full_name }} ({{ u.email }})
</option>
</select>
<button
class="btn btn-primary btn-sm"
:disabled="!grantUserId || grantingAccess"
@click="grantAccess"
>
{{ grantingAccess ? 'Granting...' : 'Grant Access' }}
</button>
</div>
<div v-if="grantError" class="error-inline">{{ grantError }}</div>
</div>
<div v-if="accessList.length === 0" class="empty-tab">No access grants yet.</div>
<div v-else class="access-list">
<div v-for="acc in accessList" :key="acc.id" class="access-item">
<div>
<strong v-if="acc.user_name">{{ acc.user_name }}</strong>
<strong v-else-if="acc.organization_name">{{ acc.organization_name }} (Org)</strong>
<span v-if="acc.user_email" class="access-email">{{ acc.user_email }}</span>
</div>
<button class="btn btn-sm btn-danger" @click="revokeAccess(acc.id)">Revoke</button>
</div>
</div>
</div>
<!-- Settings Tab -->
<div v-if="activeTab === 'settings'" class="tab-content">
<h3>Property Settings</h3>
<p class="settings-help">Override global defaults for this property. Leave blank to use global settings.</p>
<form v-if="settings" @submit.prevent="saveSettings" class="settings-form">
<div class="form-row">
<div class="form-group">
<label>Working Hours Start</label>
<input v-model.number="settings.working_hours_start" type="number" min="0" max="23" placeholder="Global default" />
</div>
<div class="form-group">
<label>Working Hours End</label>
<input v-model.number="settings.working_hours_end" type="number" min="0" max="23" placeholder="Global default" />
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>Min Duration (min)</label>
<input v-model.number="settings.min_duration_minutes" type="number" min="15" step="15" placeholder="Global default" />
</div>
<div class="form-group">
<label>Max Duration (min)</label>
<input v-model.number="settings.max_duration_minutes" type="number" min="15" step="15" placeholder="Global default" />
</div>
</div>
<div class="settings-checkbox">
<label>
<input type="checkbox" v-model="settings.require_approval" />
<span>Require approval for bookings</span>
</label>
</div>
<div class="settings-actions">
<button type="submit" class="btn btn-primary" :disabled="savingSettings">
{{ savingSettings ? 'Saving...' : 'Save Settings' }}
</button>
</div>
</form>
</div>
</div>
<!-- Edit Modal -->
<div v-if="showEditModal && property" class="modal" @click.self="showEditModal = false">
<div class="modal-content">
<h3>Edit Property</h3>
<form @submit.prevent="handleEdit" class="property-form">
<div class="form-group">
<label>Name *</label>
<input v-model="editForm.name" type="text" required />
</div>
<div class="form-group">
<label>Description</label>
<textarea v-model="editForm.description" rows="3"></textarea>
</div>
<div class="form-group">
<label>Address</label>
<input v-model="editForm.address" type="text" />
</div>
<div class="form-group form-checkbox">
<label>
<input type="checkbox" v-model="editForm.is_public" />
Public
</label>
</div>
<div class="form-group form-checkbox">
<label>
<input type="checkbox" v-model="editForm.list_on_landing" />
Afișează pe pagina principală
</label>
</div>
<div v-if="editError" class="error">{{ editError }}</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary" :disabled="editSubmitting">Save</button>
<button type="button" class="btn btn-secondary" @click="showEditModal = false">Cancel</button>
</div>
</form>
</div>
</div>
<!-- Create Space Modal -->
<div v-if="showCreateSpaceModal" class="modal" @click.self="showCreateSpaceModal = false">
<div class="modal-content">
<h3>Add Space to {{ property?.name }}</h3>
<form @submit.prevent="handleCreateSpace" class="property-form">
<div class="form-group">
<label>Name *</label>
<input v-model="spaceForm.name" type="text" required placeholder="Conference Room A" />
</div>
<div class="form-group">
<label>Type *</label>
<select v-model="spaceForm.type" required>
<option value="sala">Sala</option>
<option value="birou">Birou</option>
</select>
</div>
<div class="form-group">
<label>Capacity *</label>
<input v-model.number="spaceForm.capacity" type="number" required min="1" placeholder="10" />
</div>
<div class="form-group">
<label>Description</label>
<textarea v-model="spaceForm.description" rows="2" placeholder="Optional description"></textarea>
</div>
<div v-if="spaceFormError" class="error">{{ spaceFormError }}</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary" :disabled="creatingSpace">
{{ creatingSpace ? 'Creating...' : 'Create Space' }}
</button>
<button type="button" class="btn btn-secondary" @click="showCreateSpaceModal = 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, computed, onMounted, watch, nextTick } from 'vue'
import { useRoute } from 'vue-router'
import { propertiesApi, adminBookingsApi, spacesApi, usersApi, handleApiError } from '@/services/api'
import Breadcrumb from '@/components/Breadcrumb.vue'
import { Power, Trash2 } from 'lucide-vue-next'
import type { Property, Space, Booking, PropertyAccess, PropertySettings, User } from '@/types'
const route = useRoute()
const property = ref<Property | null>(null)
const loading = ref(true)
const error = ref('')
const activeTab = ref('spaces')
const spaces = ref<Space[]>([])
const bookings = ref<Booking[]>([])
const accessList = ref<PropertyAccess[]>([])
const settings = ref<PropertySettings | null>(null)
const savingSettings = ref(false)
const showEditModal = ref(false)
const editSubmitting = ref(false)
const editError = ref('')
const toast = ref('')
const editForm = ref({ name: '', description: '', address: '', is_public: false, list_on_landing: false })
// Public link card state
const copied = ref(false)
const editingSlug = ref(false)
const slugInput = ref('')
const savingSlug = ref(false)
const slugError = ref('')
const makingPublic = ref(false)
const qrCanvas = ref<HTMLCanvasElement | null>(null)
const qrReady = ref(false)
const publicBookingUrl = computed(() => {
if (!property.value?.slug) return ''
return `${window.location.origin}/book/${property.value.slug}`
})
const publicBookingIdUrl = computed(() => {
if (!property.value) return ''
return `${window.location.origin}/book/${property.value.id}`
})
// Create Space state
const showCreateSpaceModal = ref(false)
const creatingSpace = ref(false)
const spaceFormError = ref('')
const spaceForm = ref({ name: '', type: 'sala', capacity: 1, description: '' })
// Grant Access state
const allUsers = ref<User[]>([])
const grantUserId = ref<number | null>(null)
const grantingAccess = ref(false)
const grantError = ref('')
const availableUsers = computed(() => {
const grantedUserIds = new Set(accessList.value.filter(a => a.user_id).map(a => a.user_id))
return allUsers.value.filter(u => u.is_active && !grantedUserIds.has(u.id))
})
const tabs = [
{ key: 'spaces', label: 'Spaces' },
{ key: 'bookings', label: 'Bookings' },
{ key: 'access', label: 'Access' },
{ key: 'settings', label: 'Settings' }
]
const breadcrumbItems = computed(() => [
{ label: 'Dashboard', to: '/dashboard' },
{ label: 'Properties', to: '/properties' },
{ label: property.value?.name || 'Loading...' }
])
const propertyId = computed(() => Number(route.params.id))
const loadProperty = async () => {
loading.value = true
error.value = ''
try {
property.value = await propertiesApi.get(propertyId.value)
editForm.value = {
name: property.value.name,
description: property.value.description || '',
address: property.value.address || '',
is_public: property.value.is_public,
list_on_landing: property.value.list_on_landing || false
}
await loadTabData()
await renderQr()
} catch (err) {
error.value = handleApiError(err)
} finally {
loading.value = false
}
}
const loadTabData = async () => {
if (activeTab.value === 'spaces') {
try { spaces.value = await propertiesApi.getSpaces(propertyId.value, { include_inactive: true }) } catch {}
} else if (activeTab.value === 'bookings') {
try { bookings.value = await adminBookingsApi.getAll({ property_id: propertyId.value }) } catch {}
} else if (activeTab.value === 'access') {
try { accessList.value = await propertiesApi.getAccess(propertyId.value) } catch {}
if (allUsers.value.length === 0) loadUsers()
} else if (activeTab.value === 'settings') {
try { settings.value = await propertiesApi.getSettings(propertyId.value) } catch {}
}
}
watch(activeTab, () => loadTabData())
const toggleStatus = async () => {
if (!property.value) return
try {
property.value = await propertiesApi.updateStatus(property.value.id, !property.value.is_active)
showToast(`Property ${property.value.is_active ? 'activated' : 'deactivated'}`)
} catch (err) {
error.value = handleApiError(err)
}
}
const handleEdit = async () => {
editSubmitting.value = true
editError.value = ''
try {
property.value = await propertiesApi.update(propertyId.value, editForm.value)
showEditModal.value = false
showToast('Property updated!')
} catch (err) {
editError.value = handleApiError(err)
} finally {
editSubmitting.value = false
}
}
const revokeAccess = async (accessId: number) => {
if (!confirm('Revoke this access?')) return
try {
await propertiesApi.revokeAccess(propertyId.value, accessId)
accessList.value = accessList.value.filter(a => a.id !== accessId)
showToast('Access revoked')
} catch (err) {
error.value = handleApiError(err)
}
}
const saveSettings = async () => {
if (!settings.value) return
savingSettings.value = true
try {
settings.value = await propertiesApi.updateSettings(propertyId.value, settings.value)
showToast('Settings saved!')
} catch (err) {
error.value = handleApiError(err)
} finally {
savingSettings.value = false
}
}
// Create Space
const openCreateSpaceModal = () => {
spaceForm.value = { name: '', type: 'sala', capacity: 1, description: '' }
spaceFormError.value = ''
showCreateSpaceModal.value = true
}
const handleCreateSpace = async () => {
creatingSpace.value = true
spaceFormError.value = ''
try {
await spacesApi.create({
...spaceForm.value,
property_id: propertyId.value
} as any)
showCreateSpaceModal.value = false
showToast('Space created!')
spaces.value = await propertiesApi.getSpaces(propertyId.value, { include_inactive: true })
} catch (err) {
spaceFormError.value = handleApiError(err)
} finally {
creatingSpace.value = false
}
}
// Grant Access
const loadUsers = async () => {
try { allUsers.value = await usersApi.list() } catch {}
}
const grantAccess = async () => {
if (!grantUserId.value) return
grantingAccess.value = true
grantError.value = ''
try {
const newAccess = await propertiesApi.grantAccess(propertyId.value, { user_id: grantUserId.value })
accessList.value.push(newAccess)
grantUserId.value = null
showToast('Access granted!')
} catch (err) {
grantError.value = handleApiError(err)
} finally {
grantingAccess.value = false
}
}
// Space management
const toggleSpaceStatus = async (sp: Space) => {
try {
await spacesApi.updateStatus(sp.id, !sp.is_active)
showToast(`Space ${sp.is_active ? 'deactivated' : 'activated'}!`)
spaces.value = await propertiesApi.getSpaces(propertyId.value, { include_inactive: true })
} catch (err) {
error.value = handleApiError(err)
}
}
const confirmDeleteSpace = async (sp: Space) => {
if (!confirm(`Delete "${sp.name}"? The space will be deactivated and unlinked from this property. Existing bookings are preserved.`)) return
try {
// Deactivate and unlink from property
await spacesApi.updateStatus(sp.id, false)
showToast(`Space "${sp.name}" deactivated`)
spaces.value = await propertiesApi.getSpaces(propertyId.value, { include_inactive: true })
} catch (err) {
error.value = handleApiError(err)
}
}
// Public link card
const copyPublicLink = async () => {
const url = publicBookingUrl.value
if (!url) return
try {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(url)
} else {
const textarea = document.createElement('textarea')
textarea.value = url
textarea.style.position = 'fixed'
textarea.style.opacity = '0'
document.body.appendChild(textarea)
textarea.focus()
textarea.select()
document.execCommand('copy')
document.body.removeChild(textarea)
}
copied.value = true
setTimeout(() => { copied.value = false }, 2000)
} catch {
// ignore copy failures silently
}
}
const startEditSlug = () => {
slugInput.value = property.value?.slug || ''
slugError.value = ''
editingSlug.value = true
}
const cancelEditSlug = () => {
editingSlug.value = false
slugError.value = ''
}
const saveSlug = async () => {
if (!property.value) return
const newSlug = slugInput.value.trim()
if (newSlug === property.value.slug) {
editingSlug.value = false
return
}
if (!confirm('Linkurile și codurile QR cu slug-ul vechi nu vor mai funcționa. Continui?')) return
savingSlug.value = true
slugError.value = ''
try {
property.value = await propertiesApi.update(propertyId.value, { slug: newSlug })
editingSlug.value = false
showToast('Slug actualizat!')
await renderQr()
} catch (err: any) {
if (err?.response?.status === 409) {
slugError.value = 'Slug deja folosit'
} else if (err?.response?.status === 422) {
slugError.value = 'Format invalid: doar litere mici, cifre și cratime, 364 caractere'
} else {
slugError.value = handleApiError(err)
}
} finally {
savingSlug.value = false
}
}
const makePublic = async () => {
if (!property.value) return
makingPublic.value = true
try {
property.value = await propertiesApi.update(propertyId.value, { is_public: true })
showToast('Proprietate făcută publică!')
await renderQr()
} catch (err) {
error.value = handleApiError(err)
} finally {
makingPublic.value = false
}
}
const renderQr = async () => {
qrReady.value = false
if (!property.value?.is_public) return
await nextTick()
if (!qrCanvas.value) return
try {
const mod = await import('qrcode')
// `qrcode` is CommonJS; under Vite its methods land on `.default`.
const QRCode = (mod as any).default ?? mod
await QRCode.toCanvas(qrCanvas.value, publicBookingIdUrl.value, {
width: 512,
margin: 4
})
qrReady.value = true
} catch (err) {
console.error('QR render failed', err)
qrReady.value = false
}
}
// Render the QR whenever the canvas is mounted and the property is public.
// (renderQr called during load() can run before the canvas ref binds.)
watch(
[qrCanvas, () => property.value?.is_public, publicBookingIdUrl],
([canvas, isPublic]) => {
if (canvas && isPublic) renderQr()
}
)
const downloadQr = () => {
if (!qrCanvas.value || !qrReady.value) return
const link = document.createElement('a')
link.download = `qr-${property.value?.slug || property.value?.id}.png`
link.href = qrCanvas.value.toDataURL('image/png')
link.click()
}
// Date formatting
const formatDate = (dt: string) => {
if (!dt) return ''
const d = new Date(dt)
return d.toLocaleDateString('ro-RO', { day: '2-digit', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit' })
}
const formatTime = (dt: string) => {
if (!dt) return ''
const d = new Date(dt)
return d.toLocaleTimeString('ro-RO', { hour: '2-digit', minute: '2-digit' })
}
const showToast = (msg: string) => {
toast.value = msg
setTimeout(() => { toast.value = '' }, 3000)
}
onMounted(() => loadProperty())
</script>
<style scoped>
.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); } }
.error-card {
text-align: center;
padding: 40px;
background: var(--color-surface);
border-radius: var(--radius-md);
border: 1px solid var(--color-border);
max-width: 500px;
margin: 40px auto;
}
.error-card h3 { color: var(--color-danger); margin-bottom: 12px; }
.property-content {
display: flex;
flex-direction: column;
gap: 24px;
}
.property-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 20px;
flex-wrap: wrap;
}
.header-info h1 {
font-size: 28px;
font-weight: 700;
color: var(--color-text-primary);
margin-bottom: 8px;
}
.header-badges {
display: flex;
gap: 8px;
margin-bottom: 8px;
}
.property-desc {
font-size: 14px;
color: var(--color-text-secondary);
margin: 0;
}
.property-address {
font-size: 13px;
color: var(--color-text-muted);
margin: 4px 0 0;
}
.header-managers {
display: flex;
align-items: center;
gap: 8px;
margin-top: 10px;
flex-wrap: wrap;
}
.managers-label {
font-size: 12px;
font-weight: 600;
color: var(--color-text-muted);
}
.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;
}
.header-actions {
display: flex;
gap: 8px;
}
.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);
}
.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);
}
/* Public Link Card */
.public-link-card {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: 20px;
display: flex;
flex-direction: column;
gap: 12px;
}
.public-link-header h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
color: var(--color-text-primary);
}
.public-link-row {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.public-link-url {
flex: 1;
min-width: 200px;
padding: 8px 12px;
background: var(--color-bg-secondary);
border-radius: var(--radius-sm);
font-size: 13px;
color: var(--color-text-primary);
overflow-x: auto;
white-space: nowrap;
}
.slug-edit {
display: flex;
flex-direction: column;
gap: 6px;
}
.btn-link {
align-self: flex-start;
background: none;
border: none;
color: var(--color-accent);
font-size: 13px;
font-weight: 500;
cursor: pointer;
padding: 0;
}
.btn-link:hover {
text-decoration: underline;
}
.slug-edit-row {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.slug-input {
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);
min-width: 220px;
}
.slug-input:focus {
outline: none;
border-color: var(--color-accent);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-accent) 15%, transparent);
}
.slug-hint {
font-size: 12px;
color: var(--color-text-muted);
}
.qr-section {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 10px;
}
.qr-canvas {
width: 140px;
height: 140px;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: #fff;
}
/* Tabs */
.tabs {
display: flex;
border-bottom: 2px solid var(--color-border);
gap: 0;
}
.tab {
padding: 10px 20px;
border: none;
background: none;
font-size: 14px;
font-weight: 500;
color: var(--color-text-secondary);
cursor: pointer;
border-bottom: 2px solid transparent;
margin-bottom: -2px;
transition: all var(--transition-fast);
}
.tab:hover {
color: var(--color-text-primary);
}
.tab.active {
color: var(--color-accent);
border-bottom-color: var(--color-accent);
}
.tab-content {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: 20px;
}
.tab-content h3 {
margin: 0 0 16px;
font-size: 18px;
font-weight: 600;
color: var(--color-text-primary);
}
.tab-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.empty-tab {
text-align: center;
color: var(--color-text-muted);
padding: 24px;
}
.info-msg {
color: var(--color-text-secondary);
font-size: 14px;
margin-bottom: 16px;
}
/* Grant Access Form */
.grant-access-form {
margin-bottom: 16px;
}
.grant-access-row {
display: flex;
gap: 8px;
align-items: center;
}
.grant-select {
flex: 1;
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);
}
.grant-select:focus {
outline: none;
border-color: var(--color-accent);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-accent) 15%, transparent);
}
.error-inline {
margin-top: 6px;
font-size: 13px;
color: var(--color-danger);
}
/* Space cards in tab */
.space-cards {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 12px;
}
.space-card {
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: 14px;
}
.space-card-inactive {
opacity: 0.6;
border-style: dashed;
}
.space-card-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 6px;
}
.space-card-header h4 {
margin: 0;
font-size: 14px;
font-weight: 600;
color: var(--color-text-primary);
}
.space-card-actions {
display: flex;
gap: 4px;
flex-shrink: 0;
}
.btn-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
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);
}
.space-desc {
font-size: 12px;
color: var(--color-text-muted);
margin: 6px 0 0;
}
.space-meta {
display: flex;
gap: 12px;
font-size: 12px;
color: var(--color-text-secondary);
}
/* Bookings list */
.bookings-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.booking-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 14px;
background: var(--color-bg-secondary);
border-radius: var(--radius-sm);
}
.booking-info {
display: flex;
flex-direction: column;
gap: 2px;
}
.booking-info strong {
font-size: 14px;
color: var(--color-text-primary);
}
.booking-space {
font-size: 12px;
color: var(--color-accent);
}
.booking-meta-row {
display: flex;
gap: 12px;
align-items: center;
}
.booking-date {
font-size: 12px;
color: var(--color-text-muted);
}
.booking-guest, .booking-user {
font-size: 12px;
color: var(--color-text-secondary);
}
/* Access explainer */
.access-explainer {
margin-bottom: 16px;
padding: 12px 16px;
background: color-mix(in srgb, var(--color-accent) 5%, transparent);
border: 1px solid color-mix(in srgb, var(--color-accent) 15%, transparent);
border-radius: var(--radius-sm);
}
.access-explainer .info-msg {
margin: 0 0 4px;
}
.info-msg-detail {
font-size: 13px;
color: var(--color-text-muted);
margin: 0;
}
/* Settings */
.settings-help {
font-size: 13px;
color: var(--color-text-muted);
margin: 0 0 16px;
}
.settings-checkbox {
margin: 4px 0;
}
.settings-checkbox label {
display: inline-flex;
align-items: center;
gap: 8px;
cursor: pointer;
font-size: 14px;
font-weight: 500;
color: var(--color-text-primary);
}
.settings-actions {
margin-top: 8px;
}
/* Access list */
.access-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.access-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 14px;
background: var(--color-bg-secondary);
border-radius: var(--radius-sm);
}
.access-email {
font-size: 12px;
color: var(--color-text-muted);
margin-left: 8px;
}
/* Settings form */
.settings-form {
display: flex;
flex-direction: column;
gap: 14px;
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
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="number"],
.form-group select,
.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;
}
/* 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-success { background: var(--color-success); color: white; }
.btn-warning { background: var(--color-warning); color: white; }
.btn-danger { background: var(--color-danger); color: white; }
.btn-sm { padding: 6px 12px; font-size: 12px; }
.error {
padding: 12px;
background: color-mix(in srgb, var(--color-danger) 10%, transparent);
color: var(--color-danger);
border-radius: var(--radius-sm);
}
/* 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);
}
.property-form {
display: flex;
flex-direction: column;
gap: 14px;
}
.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-header { flex-direction: column; }
.form-row { grid-template-columns: 1fr; }
.tabs { overflow-x: auto; }
}
</style>