Files
space-booking/frontend/src/components/BookingForm.vue
Claude Agent 0bf3e6a7e2 feat: complete UI redesign with dark mode, sidebar navigation, and modern design system
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>
2026-02-11 21:27:05 +00:00

611 lines
15 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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