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:
515
frontend/src/views/AdminReports.vue
Normal file
515
frontend/src/views/AdminReports.vue
Normal 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>
|
||||
Reference in New Issue
Block a user