Implemented comprehensive UI overhaul with three-layer architecture: Layer 1 - Theme System: - CSS variables for light/dark themes (theme.css) - Theme composable with light/dark/auto mode (useTheme.ts) - Sidebar state management composable (useSidebar.ts) - Refactored main.css to use CSS variables throughout Layer 2 - Core Components: - AppSidebar with collapsible navigation (desktop) and overlay (mobile) - CollapsibleSection reusable component for expandable cards - Restructured App.vue with new sidebar layout - Integrated Lucide icons library (lucide-vue-next) Layer 3 - Views & Components: - Updated all 14 views with CSS variables and responsive design - Replaced inline SVG with Lucide icon components - Added collapsible sections to Dashboard, Admin pages, UserProfile - Updated 3 shared components (BookingForm, SpaceCalendar, AttachmentsList) Features: - Dark/light/auto theme with persistent preference - Collapsible sidebar (icons-only on desktop, overlay on mobile) - Consistent color palette using CSS variables - Full responsive design across all pages - Modern minimalist aesthetic with Indigo accent color Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
611 lines
15 KiB
Vue
611 lines
15 KiB
Vue
<template>
|
||
<div class="booking-form">
|
||
<form @submit.prevent="handleSubmit">
|
||
<!-- Space Selection -->
|
||
<div class="form-group">
|
||
<label for="space" class="form-label">Space *</label>
|
||
<select
|
||
v-if="!spaceId"
|
||
id="space"
|
||
v-model="formData.space_id"
|
||
class="form-input"
|
||
:class="{ 'form-input-error': errors.space_id }"
|
||
:disabled="loadingSpaces"
|
||
>
|
||
<option :value="null">Select a space</option>
|
||
<option v-for="space in activeSpaces" :key="space.id" :value="space.id">
|
||
{{ space.name }} ({{ space.type }}, Capacity: {{ space.capacity }})
|
||
</option>
|
||
</select>
|
||
<input
|
||
v-else
|
||
type="text"
|
||
class="form-input"
|
||
:value="selectedSpaceName"
|
||
readonly
|
||
disabled
|
||
/>
|
||
<span v-if="errors.space_id" class="form-error">{{ errors.space_id }}</span>
|
||
</div>
|
||
|
||
<!-- Title -->
|
||
<div class="form-group">
|
||
<label for="title" class="form-label">Titlu *</label>
|
||
<input
|
||
id="title"
|
||
v-model="formData.title"
|
||
type="text"
|
||
class="form-input"
|
||
:class="{ 'form-input-error': errors.title }"
|
||
placeholder="Titlu rezervare"
|
||
/>
|
||
<span v-if="errors.title" class="form-error">{{ errors.title }}</span>
|
||
</div>
|
||
|
||
<!-- Description -->
|
||
<div class="form-group">
|
||
<label for="description" class="form-label">Descriere (opțional)</label>
|
||
<textarea
|
||
id="description"
|
||
v-model="formData.description"
|
||
class="form-textarea"
|
||
rows="3"
|
||
placeholder="Detalii suplimentare..."
|
||
></textarea>
|
||
</div>
|
||
|
||
<!-- Start Date & Time -->
|
||
<div class="form-group">
|
||
<label class="form-label">Început *</label>
|
||
<div class="datetime-row">
|
||
<div class="datetime-field">
|
||
<label for="start-date" class="form-sublabel">Data</label>
|
||
<input
|
||
id="start-date"
|
||
v-model="formData.start_date"
|
||
type="date"
|
||
class="form-input"
|
||
:class="{ 'form-input-error': errors.start_date }"
|
||
:min="minDate"
|
||
/>
|
||
</div>
|
||
<div class="datetime-field">
|
||
<label for="start-time" class="form-sublabel">Ora</label>
|
||
<input
|
||
id="start-time"
|
||
v-model="formData.start_time"
|
||
type="time"
|
||
class="form-input"
|
||
:class="{ 'form-input-error': errors.start_time }"
|
||
/>
|
||
</div>
|
||
</div>
|
||
<span v-if="errors.start_date || errors.start_time" class="form-error">
|
||
{{ errors.start_date || errors.start_time }}
|
||
</span>
|
||
</div>
|
||
|
||
<!-- End Date & Time -->
|
||
<div class="form-group">
|
||
<label class="form-label">Sfârșit *</label>
|
||
<div class="datetime-row">
|
||
<div class="datetime-field">
|
||
<label for="end-date" class="form-sublabel">Data</label>
|
||
<input
|
||
id="end-date"
|
||
v-model="formData.end_date"
|
||
type="date"
|
||
class="form-input"
|
||
:class="{ 'form-input-error': errors.end_date }"
|
||
:min="formData.start_date || minDate"
|
||
/>
|
||
</div>
|
||
<div class="datetime-field">
|
||
<label for="end-time" class="form-sublabel">Ora</label>
|
||
<input
|
||
id="end-time"
|
||
v-model="formData.end_time"
|
||
type="time"
|
||
class="form-input"
|
||
:class="{ 'form-input-error': errors.end_time }"
|
||
/>
|
||
</div>
|
||
</div>
|
||
<span v-if="errors.end_date || errors.end_time" class="form-error">
|
||
{{ errors.end_date || errors.end_time }}
|
||
</span>
|
||
</div>
|
||
|
||
<!-- Warning Banner -->
|
||
<div v-if="availabilityWarning" class="warning-banner">
|
||
<span class="warning-icon">⚠️</span>
|
||
<div>
|
||
<strong>{{ availabilityWarning.message }}</strong>
|
||
<div v-if="availabilityWarning.conflicts.length > 0" class="conflicts-list">
|
||
<p>Conflicting bookings:</p>
|
||
<ul>
|
||
<li v-for="conflict in availabilityWarning.conflicts" :key="conflict.id">
|
||
{{ conflict.user_name }} - "{{ conflict.title }}" ({{ conflict.status }})
|
||
</li>
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- API Error -->
|
||
<div v-if="apiError" class="api-error">
|
||
{{ apiError }}
|
||
</div>
|
||
|
||
<!-- Success Message -->
|
||
<div v-if="successMessage" class="success-message">
|
||
{{ successMessage }}
|
||
</div>
|
||
|
||
<!-- Actions -->
|
||
<div class="form-actions">
|
||
<button
|
||
type="button"
|
||
class="btn btn-secondary"
|
||
:disabled="submitting"
|
||
@click="handleCancel"
|
||
>
|
||
Anulează
|
||
</button>
|
||
<button type="submit" class="btn btn-primary" :disabled="submitting">
|
||
{{ submitting ? 'Se creează...' : 'Creează rezervare' }}
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { ref, computed, onMounted, watch } from 'vue'
|
||
import { bookingsApi, spacesApi, handleApiError } from '@/services/api'
|
||
import type {
|
||
Space,
|
||
BookingCreate,
|
||
AvailabilityCheck
|
||
} from '@/types'
|
||
|
||
interface Props {
|
||
spaceId?: number
|
||
}
|
||
|
||
interface Emits {
|
||
(e: 'submit', bookingId: number): void
|
||
(e: 'cancel'): void
|
||
}
|
||
|
||
const props = defineProps<Props>()
|
||
const emit = defineEmits<Emits>()
|
||
|
||
// Form data
|
||
interface FormData {
|
||
space_id: number | null
|
||
start_date: string
|
||
start_time: string
|
||
end_date: string
|
||
end_time: string
|
||
title: string
|
||
description: string
|
||
}
|
||
|
||
// Initialize with default values (today, next hour, 1 hour duration)
|
||
const getDefaults = () => {
|
||
const now = new Date()
|
||
const startHour = now.getHours() + 1
|
||
const endHour = startHour + 1
|
||
|
||
const year = now.getFullYear()
|
||
const month = String(now.getMonth() + 1).padStart(2, '0')
|
||
const day = String(now.getDate()).padStart(2, '0')
|
||
|
||
return {
|
||
date: `${year}-${month}-${day}`,
|
||
start_time: `${String(startHour).padStart(2, '0')}:00`,
|
||
end_time: `${String(endHour).padStart(2, '0')}:00`
|
||
}
|
||
}
|
||
|
||
const defaults = getDefaults()
|
||
|
||
const formData = ref<FormData>({
|
||
space_id: props.spaceId || null,
|
||
start_date: defaults.date,
|
||
start_time: defaults.start_time,
|
||
end_date: defaults.date,
|
||
end_time: defaults.end_time,
|
||
title: '',
|
||
description: ''
|
||
})
|
||
|
||
// Form state
|
||
const errors = ref<Record<string, string>>({})
|
||
const apiError = ref('')
|
||
const successMessage = ref('')
|
||
const submitting = ref(false)
|
||
const loadingSpaces = ref(false)
|
||
const spaces = ref<Space[]>([])
|
||
const availabilityWarning = ref<AvailabilityCheck | null>(null)
|
||
|
||
// Computed values
|
||
const activeSpaces = computed(() => spaces.value.filter((s) => s.is_active))
|
||
|
||
const selectedSpaceName = computed(() => {
|
||
if (!props.spaceId) return ''
|
||
const space = spaces.value.find((s) => s.id === props.spaceId)
|
||
return space ? `${space.name} (${space.type})` : ''
|
||
})
|
||
|
||
const minDate = computed(() => {
|
||
const today = new Date()
|
||
return today.toISOString().split('T')[0]
|
||
})
|
||
|
||
// Helper to construct datetime from separate date and time fields
|
||
const constructDateTime = (date: string, time: string): string => {
|
||
return `${date}T${time}:00`
|
||
}
|
||
|
||
// Load spaces
|
||
const loadSpaces = async () => {
|
||
loadingSpaces.value = true
|
||
try {
|
||
spaces.value = await spacesApi.list()
|
||
} catch (err) {
|
||
apiError.value = `Failed to load spaces: ${handleApiError(err)}`
|
||
} finally {
|
||
loadingSpaces.value = false
|
||
}
|
||
}
|
||
|
||
// Watch date/time fields and check availability (debounced)
|
||
let checkTimeout: number | undefined
|
||
watch(
|
||
[
|
||
() => formData.value.space_id,
|
||
() => formData.value.start_date,
|
||
() => formData.value.start_time,
|
||
() => formData.value.end_date,
|
||
() => formData.value.end_time
|
||
],
|
||
() => {
|
||
if (checkTimeout) {
|
||
clearTimeout(checkTimeout)
|
||
}
|
||
checkTimeout = window.setTimeout(async () => {
|
||
if (
|
||
formData.value.space_id &&
|
||
formData.value.start_date &&
|
||
formData.value.start_time &&
|
||
formData.value.end_date &&
|
||
formData.value.end_time
|
||
) {
|
||
await checkAvailability()
|
||
} else {
|
||
availabilityWarning.value = null
|
||
}
|
||
}, 500) // Debounce 500ms
|
||
}
|
||
)
|
||
|
||
// Check availability
|
||
const checkAvailability = async () => {
|
||
try {
|
||
const start_datetime = constructDateTime(formData.value.start_date, formData.value.start_time)
|
||
const end_datetime = constructDateTime(formData.value.end_date, formData.value.end_time)
|
||
|
||
const response = await bookingsApi.checkAvailability({
|
||
space_id: formData.value.space_id!,
|
||
start_datetime,
|
||
end_datetime
|
||
})
|
||
|
||
if (!response.data.available || response.data.conflicts.length > 0) {
|
||
availabilityWarning.value = response.data
|
||
} else {
|
||
availabilityWarning.value = null
|
||
}
|
||
} catch (error) {
|
||
console.error('Failed to check availability:', error)
|
||
// Don't show error to user - this is a non-critical feature
|
||
availabilityWarning.value = null
|
||
}
|
||
}
|
||
|
||
// Validation
|
||
const validateForm = (): boolean => {
|
||
errors.value = {}
|
||
let isValid = true
|
||
|
||
// Space ID
|
||
if (!formData.value.space_id) {
|
||
errors.value.space_id = 'Please select a space'
|
||
isValid = false
|
||
}
|
||
|
||
// Date and time validation
|
||
if (!formData.value.start_date) {
|
||
errors.value.start_date = 'Data de început este obligatorie'
|
||
isValid = false
|
||
}
|
||
|
||
if (!formData.value.start_time) {
|
||
errors.value.start_time = 'Ora de început este obligatorie'
|
||
isValid = false
|
||
}
|
||
|
||
if (!formData.value.end_date) {
|
||
errors.value.end_date = 'Data de sfârșit este obligatorie'
|
||
isValid = false
|
||
}
|
||
|
||
if (!formData.value.end_time) {
|
||
errors.value.end_time = 'Ora de sfârșit este obligatorie'
|
||
isValid = false
|
||
}
|
||
|
||
// Validate start is not in the past
|
||
if (formData.value.start_date && formData.value.start_time) {
|
||
const startDateTime = new Date(constructDateTime(formData.value.start_date, formData.value.start_time))
|
||
const now = new Date()
|
||
if (startDateTime < now) {
|
||
errors.value.start_date = 'Data și ora de început nu pot fi în trecut'
|
||
isValid = false
|
||
}
|
||
}
|
||
|
||
// Validate end is after start
|
||
if (formData.value.start_date && formData.value.start_time && formData.value.end_date && formData.value.end_time) {
|
||
const startDateTime = new Date(constructDateTime(formData.value.start_date, formData.value.start_time))
|
||
const endDateTime = new Date(constructDateTime(formData.value.end_date, formData.value.end_time))
|
||
|
||
if (endDateTime <= startDateTime) {
|
||
errors.value.end_time = 'Sfârșitul trebuie să fie după început'
|
||
isValid = false
|
||
}
|
||
}
|
||
|
||
// Title
|
||
if (!formData.value.title.trim()) {
|
||
errors.value.title = 'Title is required'
|
||
isValid = false
|
||
}
|
||
|
||
return isValid
|
||
}
|
||
|
||
// Submit handler
|
||
const handleSubmit = async () => {
|
||
apiError.value = ''
|
||
successMessage.value = ''
|
||
|
||
if (!validateForm()) {
|
||
return
|
||
}
|
||
|
||
submitting.value = true
|
||
|
||
try {
|
||
// Create booking
|
||
const payload: BookingCreate = {
|
||
space_id: formData.value.space_id!,
|
||
start_datetime: constructDateTime(formData.value.start_date, formData.value.start_time),
|
||
end_datetime: constructDateTime(formData.value.end_date, formData.value.end_time),
|
||
title: formData.value.title.trim(),
|
||
description: formData.value.description.trim() || undefined
|
||
}
|
||
|
||
const booking = await bookingsApi.create(payload)
|
||
|
||
successMessage.value = 'Rezervare creată cu succes!'
|
||
|
||
// Emit success event
|
||
setTimeout(() => {
|
||
emit('submit', booking.id)
|
||
}, 1000)
|
||
} catch (err) {
|
||
apiError.value = handleApiError(err)
|
||
} finally {
|
||
submitting.value = false
|
||
}
|
||
}
|
||
|
||
// Cancel handler
|
||
const handleCancel = () => {
|
||
emit('cancel')
|
||
}
|
||
|
||
// Load spaces on mount
|
||
onMounted(() => {
|
||
loadSpaces()
|
||
})
|
||
</script>
|
||
|
||
<style scoped>
|
||
.booking-form {
|
||
/* No extra styling - will inherit from parent modal */
|
||
}
|
||
|
||
.form-group {
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.form-label {
|
||
display: block;
|
||
margin-bottom: 6px;
|
||
font-weight: 500;
|
||
color: var(--color-text-primary);
|
||
font-size: 14px;
|
||
}
|
||
|
||
.form-sublabel {
|
||
display: block;
|
||
margin-bottom: 4px;
|
||
font-weight: 400;
|
||
color: var(--color-text-secondary);
|
||
font-size: 12px;
|
||
}
|
||
|
||
.datetime-row {
|
||
display: grid;
|
||
grid-template-columns: 1fr 1fr;
|
||
gap: 12px;
|
||
}
|
||
|
||
.datetime-field {
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
|
||
.form-input,
|
||
.form-textarea {
|
||
width: 100%;
|
||
padding: 8px 12px;
|
||
border: 1px solid var(--color-border);
|
||
border-radius: var(--radius-sm);
|
||
font-size: 14px;
|
||
font-family: inherit;
|
||
transition: border-color var(--transition-fast);
|
||
background: var(--color-surface);
|
||
color: var(--color-text-primary);
|
||
}
|
||
|
||
.form-input:focus,
|
||
.form-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-input-error {
|
||
border-color: var(--color-danger);
|
||
}
|
||
|
||
.form-input:disabled {
|
||
background-color: var(--color-bg-tertiary);
|
||
cursor: not-allowed;
|
||
}
|
||
|
||
.form-textarea {
|
||
resize: vertical;
|
||
}
|
||
|
||
.form-error {
|
||
display: block;
|
||
margin-top: 4px;
|
||
color: var(--color-danger);
|
||
font-size: 13px;
|
||
}
|
||
|
||
.api-error {
|
||
padding: 12px;
|
||
background: color-mix(in srgb, var(--color-danger) 10%, transparent);
|
||
color: var(--color-danger);
|
||
border-radius: var(--radius-sm);
|
||
margin-bottom: 16px;
|
||
font-size: 14px;
|
||
}
|
||
|
||
.success-message {
|
||
padding: 12px;
|
||
background: color-mix(in srgb, var(--color-success) 10%, transparent);
|
||
color: var(--color-success);
|
||
border-radius: var(--radius-sm);
|
||
margin-bottom: 16px;
|
||
font-size: 14px;
|
||
}
|
||
|
||
.form-actions {
|
||
display: flex;
|
||
gap: 12px;
|
||
justify-content: flex-end;
|
||
}
|
||
|
||
.btn {
|
||
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-text-secondary);
|
||
color: white;
|
||
}
|
||
|
||
.btn-secondary:hover:not(:disabled) {
|
||
background: var(--color-text-primary);
|
||
}
|
||
|
||
.warning-banner {
|
||
background: color-mix(in srgb, var(--color-warning) 15%, transparent);
|
||
border: 1px solid var(--color-warning);
|
||
border-radius: var(--radius-sm);
|
||
padding: 12px;
|
||
margin-bottom: 16px;
|
||
display: flex;
|
||
gap: 12px;
|
||
align-items: flex-start;
|
||
font-size: 14px;
|
||
color: var(--color-text-primary);
|
||
}
|
||
|
||
.warning-icon {
|
||
font-size: 20px;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.conflicts-list {
|
||
margin-top: 8px;
|
||
font-size: 13px;
|
||
}
|
||
|
||
.conflicts-list p {
|
||
margin: 4px 0;
|
||
font-weight: 500;
|
||
}
|
||
|
||
.conflicts-list ul {
|
||
margin: 4px 0;
|
||
padding-left: 20px;
|
||
}
|
||
|
||
.conflicts-list li {
|
||
margin: 2px 0;
|
||
}
|
||
|
||
@media (max-width: 640px) {
|
||
.datetime-row {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
|
||
.form-actions {
|
||
flex-direction: column-reverse;
|
||
}
|
||
|
||
.btn {
|
||
width: 100%;
|
||
}
|
||
}
|
||
</style>
|