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>
1400 lines
38 KiB
Vue
1400 lines
38 KiB
Vue
<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, 3–64 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ă. Fă-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ă...' : 'Fă-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, 3–64 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>
|