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>
516 lines
11 KiB
Vue
516 lines
11 KiB
Vue
<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>
|