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,373 @@
<template>
<div class="settings">
<h2>Global Booking Settings</h2>
<!-- Settings Form -->
<div class="card">
<h3>Booking Rules Configuration</h3>
<div v-if="loadingSettings" class="loading">Loading settings...</div>
<form v-else @submit.prevent="handleSubmit" class="settings-form">
<div class="form-row">
<div class="form-group">
<label for="minDuration">Minimum Duration (minutes)</label>
<input
id="minDuration"
v-model.number="formData.min_duration_minutes"
type="number"
required
min="15"
max="480"
placeholder="30"
/>
<small>Between 15 and 480 minutes</small>
</div>
<div class="form-group">
<label for="maxDuration">Maximum Duration (minutes)</label>
<input
id="maxDuration"
v-model.number="formData.max_duration_minutes"
type="number"
required
min="30"
max="1440"
placeholder="480"
/>
<small>Between 30 and 1440 minutes (24h)</small>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="workStart">Working Hours Start (hour)</label>
<input
id="workStart"
v-model.number="formData.working_hours_start"
type="number"
required
min="0"
max="23"
placeholder="8"
/>
<small>0-23 (e.g., 8 = 8:00 AM)</small>
</div>
<div class="form-group">
<label for="workEnd">Working Hours End (hour)</label>
<input
id="workEnd"
v-model.number="formData.working_hours_end"
type="number"
required
min="1"
max="24"
placeholder="20"
/>
<small>1-24 (e.g., 20 = 8:00 PM)</small>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="maxBookings">Max Bookings per Day per User</label>
<input
id="maxBookings"
v-model.number="formData.max_bookings_per_day_per_user"
type="number"
required
min="1"
max="20"
placeholder="3"
/>
<small>Between 1 and 20</small>
</div>
<div class="form-group">
<label for="minCancel">Min Hours Before Cancel</label>
<input
id="minCancel"
v-model.number="formData.min_hours_before_cancel"
type="number"
required
min="0"
max="72"
placeholder="2"
/>
<small>Between 0 and 72 hours</small>
</div>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary" :disabled="loading">
Save Settings
</button>
</div>
</form>
<div v-if="error" class="error">{{ error }}</div>
<div v-if="success" class="success">{{ success }}</div>
</div>
<!-- Info Card -->
<div class="card info-card">
<h4>About These Settings</h4>
<ul>
<li><strong>Duration:</strong> Controls minimum and maximum booking length</li>
<li><strong>Working Hours:</strong> Bookings outside these hours will be rejected</li>
<li><strong>Max Bookings:</strong> Limits how many bookings a user can make per day</li>
<li><strong>Cancel Policy:</strong> Users cannot cancel bookings too close to start time</li>
</ul>
<p class="note">These rules apply to all new booking requests.</p>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { settingsApi, handleApiError } from '@/services/api'
import type { Settings } from '@/types'
const loadingSettings = ref(true)
const loading = ref(false)
const error = ref('')
const success = ref('')
const formData = ref<Omit<Settings, 'id'>>({
min_duration_minutes: 30,
max_duration_minutes: 480,
working_hours_start: 8,
working_hours_end: 20,
max_bookings_per_day_per_user: 3,
min_hours_before_cancel: 2
})
const loadSettings = async () => {
try {
loadingSettings.value = true
const settings = await settingsApi.get()
formData.value = {
min_duration_minutes: settings.min_duration_minutes,
max_duration_minutes: settings.max_duration_minutes,
working_hours_start: settings.working_hours_start,
working_hours_end: settings.working_hours_end,
max_bookings_per_day_per_user: settings.max_bookings_per_day_per_user,
min_hours_before_cancel: settings.min_hours_before_cancel
}
} catch (err) {
error.value = handleApiError(err)
} finally {
loadingSettings.value = false
}
}
const validateForm = (): boolean => {
// Validate all fields are positive
if (
formData.value.min_duration_minutes <= 0 ||
formData.value.max_duration_minutes <= 0 ||
formData.value.working_hours_start < 0 ||
formData.value.working_hours_end < 0 ||
formData.value.max_bookings_per_day_per_user <= 0 ||
formData.value.min_hours_before_cancel < 0
) {
error.value = 'All fields must be positive values'
return false
}
// Validate min < max duration
if (formData.value.min_duration_minutes >= formData.value.max_duration_minutes) {
error.value = 'Minimum duration must be less than maximum duration'
return false
}
// Validate working hours start < end
if (formData.value.working_hours_start >= formData.value.working_hours_end) {
error.value = 'Working hours start must be less than working hours end'
return false
}
// Validate working hours are within 0-23 for start and 1-24 for end
if (formData.value.working_hours_start < 0 || formData.value.working_hours_start > 23) {
error.value = 'Working hours start must be between 0 and 23'
return false
}
if (formData.value.working_hours_end < 1 || formData.value.working_hours_end > 24) {
error.value = 'Working hours end must be between 1 and 24'
return false
}
return true
}
const handleSubmit = async () => {
error.value = ''
success.value = ''
// Client-side validation
if (!validateForm()) {
return
}
loading.value = true
try {
await settingsApi.update(formData.value)
success.value = 'Settings updated successfully!'
setTimeout(() => {
success.value = ''
}, 3000)
} catch (err) {
error.value = handleApiError(err)
} finally {
loading.value = false
}
}
onMounted(() => {
loadSettings()
})
</script>
<style scoped>
.settings {
max-width: 900px;
margin: 0 auto;
}
h2 {
margin-bottom: 1.5rem;
color: #333;
}
.card {
background: white;
border-radius: 8px;
padding: 1.5rem;
margin-bottom: 1.5rem;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
h3, h4 {
margin-top: 0;
margin-bottom: 1.5rem;
color: #444;
}
.settings-form {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1.5rem;
}
.form-group {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.form-group label {
font-weight: 500;
color: #555;
}
.form-group input {
padding: 0.5rem;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 1rem;
}
.form-group small {
color: #666;
font-size: 0.875rem;
}
.form-actions {
display: flex;
gap: 0.75rem;
margin-top: 0.5rem;
}
.btn {
padding: 0.6rem 1.2rem;
border: none;
border-radius: 4px;
font-size: 1rem;
cursor: pointer;
transition: background-color 0.2s;
}
.btn-primary {
background-color: #4caf50;
color: white;
}
.btn-primary:hover:not(:disabled) {
background-color: #45a049;
}
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.loading {
text-align: center;
padding: 2rem;
color: #666;
}
.error {
padding: 0.75rem;
background-color: #fee;
border: 1px solid #fcc;
border-radius: 4px;
color: #c33;
margin-top: 1rem;
}
.success {
padding: 0.75rem;
background-color: #efe;
border: 1px solid #cfc;
border-radius: 4px;
color: #3c3;
margin-top: 1rem;
}
.info-card {
background-color: #f8f9fa;
}
.info-card ul {
margin: 0;
padding-left: 1.5rem;
}
.info-card li {
margin-bottom: 0.5rem;
color: #555;
}
.note {
margin-top: 1rem;
margin-bottom: 0;
font-style: italic;
color: #666;
}
@media (max-width: 768px) {
.form-row {
grid-template-columns: 1fr;
}
}
</style>