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,515 @@
<template>
<div class="admin-reports">
<h2>Booking Reports</h2>
<!-- Date Range Filter -->
<div class="filters">
<label>
Start Date:
<input type="date" v-model="startDate" />
</label>
<label>
End Date:
<input type="date" v-model="endDate" />
</label>
<button @click="loadReports" class="btn-primary">Refresh</button>
<button @click="clearFilters" class="btn-secondary">Clear Filters</button>
</div>
<!-- Loading State -->
<div v-if="loading" class="loading">Loading reports...</div>
<!-- Error State -->
<div v-if="error" class="error">{{ error }}</div>
<!-- Tabs -->
<div v-if="!loading && !error" class="tabs">
<button
@click="activeTab = 'usage'"
:class="{ active: activeTab === 'usage' }"
class="tab-button"
>
Space Usage
</button>
<button
@click="activeTab = 'users'"
:class="{ active: activeTab === 'users' }"
class="tab-button"
>
Top Users
</button>
<button
@click="activeTab = 'approval'"
:class="{ active: activeTab === 'approval' }"
class="tab-button"
>
Approval Rate
</button>
</div>
<!-- Usage Report -->
<div v-if="activeTab === 'usage' && !loading" class="report-content">
<h3>Space Usage Report</h3>
<canvas ref="usageChart"></canvas>
<table class="report-table">
<thead>
<tr>
<th>Space</th>
<th>Total</th>
<th>Approved</th>
<th>Pending</th>
<th>Rejected</th>
<th>Canceled</th>
<th>Hours</th>
</tr>
</thead>
<tbody>
<tr v-for="item in usageReport?.items" :key="item.space_id">
<td>{{ item.space_name }}</td>
<td>{{ item.total_bookings }}</td>
<td class="status-approved">{{ item.approved_bookings }}</td>
<td class="status-pending">{{ item.pending_bookings }}</td>
<td class="status-rejected">{{ item.rejected_bookings }}</td>
<td class="status-canceled">{{ item.canceled_bookings }}</td>
<td>{{ item.total_hours.toFixed(1) }}h</td>
</tr>
</tbody>
<tfoot>
<tr>
<td><strong>Total</strong></td>
<td><strong>{{ usageReport?.total_bookings }}</strong></td>
<td colspan="5"></td>
</tr>
</tfoot>
</table>
</div>
<!-- Top Users Report -->
<div v-if="activeTab === 'users' && !loading" class="report-content">
<h3>Top Users Report</h3>
<canvas ref="usersChart"></canvas>
<table class="report-table">
<thead>
<tr>
<th>User</th>
<th>Email</th>
<th>Total Bookings</th>
<th>Approved</th>
<th>Total Hours</th>
</tr>
</thead>
<tbody>
<tr v-for="item in topUsersReport?.items" :key="item.user_id">
<td>{{ item.user_name }}</td>
<td>{{ item.user_email }}</td>
<td>{{ item.total_bookings }}</td>
<td class="status-approved">{{ item.approved_bookings }}</td>
<td>{{ item.total_hours.toFixed(1) }}h</td>
</tr>
</tbody>
</table>
</div>
<!-- Approval Rate Report -->
<div v-if="activeTab === 'approval' && !loading" class="report-content">
<h3>Approval Rate Report</h3>
<div class="stats">
<div class="stat-card">
<h3>{{ approvalReport?.total_requests }}</h3>
<p>Total Requests</p>
</div>
<div class="stat-card approved">
<h3>{{ approvalReport?.approval_rate }}%</h3>
<p>Approval Rate</p>
</div>
<div class="stat-card rejected">
<h3>{{ approvalReport?.rejection_rate }}%</h3>
<p>Rejection Rate</p>
</div>
</div>
<canvas ref="approvalChart"></canvas>
<div class="breakdown">
<p><strong>Approved:</strong> {{ approvalReport?.approved }}</p>
<p><strong>Rejected:</strong> {{ approvalReport?.rejected }}</p>
<p><strong>Pending:</strong> {{ approvalReport?.pending }}</p>
<p><strong>Canceled:</strong> {{ approvalReport?.canceled }}</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, watch, nextTick } from 'vue'
import { reportsApi } from '@/services/api'
import Chart from 'chart.js/auto'
import type { SpaceUsageReport, TopUsersReport, ApprovalRateReport } from '@/types'
const activeTab = ref('usage')
const startDate = ref('')
const endDate = ref('')
const loading = ref(false)
const error = ref('')
const usageReport = ref<SpaceUsageReport | null>(null)
const topUsersReport = ref<TopUsersReport | null>(null)
const approvalReport = ref<ApprovalRateReport | null>(null)
const usageChart = ref<HTMLCanvasElement | null>(null)
const usersChart = ref<HTMLCanvasElement | null>(null)
const approvalChart = ref<HTMLCanvasElement | null>(null)
let usageChartInstance: Chart | null = null
let usersChartInstance: Chart | null = null
let approvalChartInstance: Chart | null = null
const loadReports = async () => {
loading.value = true
error.value = ''
try {
const params = {
start_date: startDate.value || undefined,
end_date: endDate.value || undefined
}
usageReport.value = await reportsApi.getUsage(params)
topUsersReport.value = await reportsApi.getTopUsers(params)
approvalReport.value = await reportsApi.getApprovalRate(params)
await nextTick()
renderCharts()
} catch (e: any) {
error.value = e.response?.data?.detail || 'Failed to load reports'
} finally {
loading.value = false
}
}
const clearFilters = () => {
startDate.value = ''
endDate.value = ''
loadReports()
}
const renderCharts = () => {
// Render usage chart (bar chart)
if (usageChart.value && usageReport.value) {
if (usageChartInstance) {
usageChartInstance.destroy()
}
usageChartInstance = new Chart(usageChart.value, {
type: 'bar',
data: {
labels: usageReport.value.items.map((i) => i.space_name),
datasets: [
{
label: 'Total Bookings',
data: usageReport.value.items.map((i) => i.total_bookings),
backgroundColor: '#4CAF50'
}
]
},
options: {
responsive: true,
maintainAspectRatio: true,
plugins: {
legend: {
display: false
}
}
}
})
}
// Render users chart (horizontal bar)
if (usersChart.value && topUsersReport.value) {
if (usersChartInstance) {
usersChartInstance.destroy()
}
usersChartInstance = new Chart(usersChart.value, {
type: 'bar',
data: {
labels: topUsersReport.value.items.map((i) => i.user_name),
datasets: [
{
label: 'Total Bookings',
data: topUsersReport.value.items.map((i) => i.total_bookings),
backgroundColor: '#2196F3'
}
]
},
options: {
responsive: true,
maintainAspectRatio: true,
indexAxis: 'y',
plugins: {
legend: {
display: false
}
}
}
})
}
// Render approval chart (pie chart)
if (approvalChart.value && approvalReport.value) {
if (approvalChartInstance) {
approvalChartInstance.destroy()
}
approvalChartInstance = new Chart(approvalChart.value, {
type: 'pie',
data: {
labels: ['Approved', 'Rejected', 'Pending', 'Canceled'],
datasets: [
{
data: [
approvalReport.value.approved,
approvalReport.value.rejected,
approvalReport.value.pending,
approvalReport.value.canceled
],
backgroundColor: ['#4CAF50', '#F44336', '#FFA500', '#9E9E9E']
}
]
},
options: {
responsive: true,
maintainAspectRatio: true
}
})
}
}
watch(activeTab, () => {
nextTick(() => renderCharts())
})
onMounted(() => {
loadReports()
})
</script>
<style scoped>
.admin-reports {
padding: 20px;
max-width: 1200px;
margin: 0 auto;
}
h2 {
margin-bottom: 20px;
color: #333;
}
.filters {
display: flex;
gap: 15px;
margin-bottom: 30px;
align-items: center;
padding: 15px;
background: #f5f5f5;
border-radius: 8px;
}
.filters label {
display: flex;
flex-direction: column;
gap: 5px;
font-weight: 500;
}
.filters input[type='date'] {
padding: 8px;
border: 1px solid #ddd;
border-radius: 4px;
}
.btn-primary,
.btn-secondary {
padding: 8px 16px;
border: none;
border-radius: 4px;
cursor: pointer;
font-weight: 500;
}
.btn-primary {
background: #4caf50;
color: white;
}
.btn-primary:hover {
background: #45a049;
}
.btn-secondary {
background: #9e9e9e;
color: white;
}
.btn-secondary:hover {
background: #757575;
}
.loading,
.error {
padding: 20px;
text-align: center;
border-radius: 4px;
}
.error {
background: #ffebee;
color: #c62828;
}
.tabs {
display: flex;
gap: 10px;
margin-bottom: 20px;
border-bottom: 2px solid #ddd;
}
.tab-button {
padding: 10px 20px;
background: none;
border: none;
border-bottom: 3px solid transparent;
cursor: pointer;
font-weight: 500;
color: #666;
transition: all 0.3s;
}
.tab-button:hover {
color: #333;
}
.tab-button.active {
color: #4caf50;
border-bottom-color: #4caf50;
}
.report-content {
background: white;
padding: 20px;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.report-content h3 {
margin-bottom: 20px;
color: #333;
}
canvas {
max-height: 400px;
margin-bottom: 30px;
}
.report-table {
width: 100%;
border-collapse: collapse;
margin-top: 20px;
}
.report-table th,
.report-table td {
padding: 12px;
text-align: left;
border-bottom: 1px solid #ddd;
}
.report-table th {
background: #f5f5f5;
font-weight: 600;
color: #333;
}
.report-table tbody tr:hover {
background: #f9f9f9;
}
.report-table tfoot {
font-weight: bold;
background: #f5f5f5;
}
.status-approved {
color: #4caf50;
}
.status-pending {
color: #ffa500;
}
.status-rejected {
color: #f44336;
}
.status-canceled {
color: #9e9e9e;
}
.stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 20px;
margin-bottom: 30px;
}
.stat-card {
padding: 20px;
background: #f5f5f5;
border-radius: 8px;
text-align: center;
border: 2px solid transparent;
}
.stat-card h3 {
font-size: 2em;
margin: 0 0 10px 0;
color: #333;
}
.stat-card p {
margin: 0;
color: #666;
font-weight: 500;
}
.stat-card.approved {
background: #e8f5e9;
border-color: #4caf50;
}
.stat-card.approved h3 {
color: #4caf50;
}
.stat-card.rejected {
background: #ffebee;
border-color: #f44336;
}
.stat-card.rejected h3 {
color: #f44336;
}
.breakdown {
margin-top: 20px;
padding: 15px;
background: #f5f5f5;
border-radius: 8px;
}
.breakdown p {
margin: 8px 0;
font-size: 1.1em;
}
</style>