feat: Space Booking System - MVP complet

Sistem web pentru rezervarea de birouri și săli de ședință
cu flux de aprobare administrativă.

Stack: FastAPI + Vue.js 3 + SQLite + TypeScript

Features implementate:
- Autentificare JWT + Self-registration cu email verification
- CRUD Spații, Utilizatori, Settings (Admin)
- Calendar interactiv (FullCalendar) cu drag-and-drop
- Creare rezervări cu validare (durată, program, overlap, max/zi)
- Rezervări recurente (săptămânal)
- Admin: aprobare/respingere/anulare cereri
- Admin: creare directă rezervări (bypass approval)
- Admin: editare orice rezervare
- User: editare/anulare rezervări proprii
- Notificări in-app (bell icon + dropdown)
- Notificări email (async SMTP cu BackgroundTasks)
- Jurnal acțiuni administrative (audit log)
- Rapoarte avansate (utilizare, top users, approval rate)
- Șabloane rezervări (booking templates)
- Atașamente fișiere (upload/download)
- Conflict warnings (verificare disponibilitate real-time)
- Integrare Google Calendar (OAuth2)
- Suport timezone (UTC storage + user preference)
- 225+ teste backend

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Claude Agent
2026-02-09 17:51:29 +00:00
commit df4031d99c
113 changed files with 24491 additions and 0 deletions

View File

@@ -0,0 +1,388 @@
<template>
<div class="space-detail">
<!-- Breadcrumbs -->
<nav class="breadcrumbs">
<router-link to="/">Home</router-link>
<span class="separator">/</span>
<router-link to="/spaces">Spaces</router-link>
<span class="separator">/</span>
<span class="current">{{ space?.name || 'Loading...' }}</span>
</nav>
<!-- Loading State -->
<div v-if="loading" class="loading">
<div class="spinner"></div>
<p>Loading space details...</p>
</div>
<!-- Error State -->
<div v-else-if="error" class="error-container">
<div class="error-card">
<h3>Error Loading Space</h3>
<p>{{ error }}</p>
<router-link to="/spaces" class="btn btn-primary">Back to Spaces</router-link>
</div>
</div>
<!-- Space Details -->
<div v-else-if="space" class="space-content">
<!-- Header Section -->
<div class="space-header">
<div class="header-info">
<h1>{{ space.name }}</h1>
<div class="space-meta">
<span class="badge badge-type">{{ formatType(space.type) }}</span>
<span class="badge badge-capacity">
<svg class="icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"
/>
</svg>
Capacity: {{ space.capacity }}
</span>
<span :class="['badge', space.is_active ? 'badge-active' : 'badge-inactive']">
{{ space.is_active ? 'Active' : 'Inactive' }}
</span>
</div>
</div>
<button
class="btn btn-primary btn-reserve"
:disabled="!space.is_active"
@click="handleReserve"
>
<svg class="icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 6v6m0 0v6m0-6h6m-6 0H6"
/>
</svg>
Reserve Space
</button>
</div>
<!-- Description -->
<div v-if="space.description" class="card description-card">
<h3>Description</h3>
<p>{{ space.description }}</p>
</div>
<!-- Calendar Section -->
<div class="card calendar-card">
<h3>Availability Calendar</h3>
<p class="calendar-subtitle">View existing bookings and available time slots</p>
<SpaceCalendar :space-id="space.id" />
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { spacesApi, handleApiError } from '@/services/api'
import SpaceCalendar from '@/components/SpaceCalendar.vue'
import type { Space } from '@/types'
const route = useRoute()
const router = useRouter()
const space = ref<Space | null>(null)
const loading = ref(true)
const error = ref('')
// Format space type for display
const formatType = (type: string): string => {
const typeMap: Record<string, string> = {
sala: 'Sala',
birou: 'Birou'
}
return typeMap[type] || type
}
// Load space details
const loadSpace = async () => {
loading.value = true
error.value = ''
try {
const spaceId = Number(route.params.id)
if (isNaN(spaceId)) {
error.value = 'Invalid space ID'
return
}
// Fetch all spaces and filter by ID
// (Could be optimized with a dedicated GET /api/spaces/:id endpoint in the future)
const spaces = await spacesApi.list()
const foundSpace = spaces.find((s) => s.id === spaceId)
if (!foundSpace) {
error.value = 'Space not found (404). The space may not exist or has been removed.'
} else {
space.value = foundSpace
}
} catch (err) {
error.value = handleApiError(err)
} finally {
loading.value = false
}
}
// Handle reserve button click
const handleReserve = () => {
// Placeholder for US-004d: Redirect to booking creation page
// For now, navigate to a placeholder route
router.push({
path: '/booking/new',
query: { space: space.value?.id }
})
}
onMounted(() => {
loadSpace()
})
</script>
<style scoped>
.space-detail {
max-width: 1200px;
margin: 0 auto;
}
/* Breadcrumbs */
.breadcrumbs {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 24px;
font-size: 14px;
color: #6b7280;
}
.breadcrumbs a {
color: #3b82f6;
text-decoration: none;
transition: color 0.2s;
}
.breadcrumbs a:hover {
color: #2563eb;
text-decoration: underline;
}
.breadcrumbs .separator {
color: #9ca3af;
}
.breadcrumbs .current {
color: #374151;
font-weight: 500;
}
/* Loading State */
.loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 80px 20px;
color: #6b7280;
}
.spinner {
width: 48px;
height: 48px;
border: 4px solid #e5e7eb;
border-top-color: #3b82f6;
border-radius: 50%;
animation: spin 0.8s linear infinite;
margin-bottom: 16px;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* Error State */
.error-container {
display: flex;
justify-content: center;
padding: 40px 20px;
}
.error-card {
background: white;
border-radius: 8px;
padding: 40px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
text-align: center;
max-width: 500px;
}
.error-card h3 {
color: #991b1b;
margin-bottom: 12px;
}
.error-card p {
color: #6b7280;
margin-bottom: 24px;
}
/* Space Content */
.space-content {
display: flex;
flex-direction: column;
gap: 24px;
}
/* Header Section */
.space-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 24px;
flex-wrap: wrap;
}
.header-info {
flex: 1;
min-width: 300px;
}
.header-info h1 {
font-size: 32px;
font-weight: 700;
color: #111827;
margin-bottom: 16px;
}
.space-meta {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 14px;
border-radius: 16px;
font-size: 14px;
font-weight: 500;
}
.badge-type {
background: #dbeafe;
color: #1e40af;
}
.badge-capacity {
background: #f3f4f6;
color: #374151;
}
.badge-active {
background: #d1fae5;
color: #065f46;
}
.badge-inactive {
background: #fee2e2;
color: #991b1b;
}
.icon {
width: 18px;
height: 18px;
}
/* Reserve Button */
.btn {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 12px 24px;
border: none;
border-radius: 8px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
white-space: nowrap;
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-primary {
background: #3b82f6;
color: white;
}
.btn-primary:hover:not(:disabled) {
background: #2563eb;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3);
}
.btn-reserve {
min-width: 180px;
justify-content: center;
}
/* Cards */
.card {
background: white;
border-radius: 8px;
padding: 24px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.card h3 {
font-size: 20px;
font-weight: 600;
color: #111827;
margin-bottom: 12px;
}
/* Description Card */
.description-card p {
color: #4b5563;
line-height: 1.6;
}
/* Calendar Card */
.calendar-subtitle {
color: #6b7280;
font-size: 14px;
margin-bottom: 20px;
}
/* Responsive */
@media (max-width: 768px) {
.space-header {
flex-direction: column;
}
.btn-reserve {
width: 100%;
}
.header-info h1 {
font-size: 24px;
}
}
</style>