Files
space-booking/frontend/src/views/AdminReports.vue
Claude Agent d245c72757 feat: complete UI/UX overhaul - dashboard unification, calendar UX, mobile optimization
- Dashboard redesign as command center with filters, quick actions, inline approve/reject
- Reusable components: BookingRow, BookingFilters, ActionMenu, BookingPreviewModal, BookingEditModal
- Calendar: drag & drop reschedule, eventClick preview modal, grid/list toggle
- Mobile: segmented control bookings/calendar toggle, compact pills, responsive layout
- Collapsible filters with active count badge
- Smart menu positioning with Teleport
- Calendar/list bidirectional data sync
- Navigation: unified History page, removed AdminPending
- Google Calendar OAuth integration
- Dark mode contrast improvements, breadcrumb navigation
- useLocalStorage composable for state persistence

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 15:34:47 +00:00

549 lines
13 KiB
Vue

<template>
<div class="admin-reports">
<Breadcrumb :items="breadcrumbItems" />
<h2>Booking Reports</h2>
<!-- Date Range Filter -->
<CollapsibleSection title="Date Range Filter" :icon="CalendarDays">
<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 btn-primary">Refresh</button>
<button @click="clearFilters" class="btn btn-secondary">Clear Filters</button>
</div>
</CollapsibleSection>
<!-- 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>
<div class="table-responsive">
<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>
</div>
<!-- Top Users Report -->
<div v-if="activeTab === 'users' && !loading" class="report-content">
<h3>Top Users Report</h3>
<canvas ref="usersChart"></canvas>
<div class="table-responsive">
<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>
</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 Breadcrumb from '@/components/Breadcrumb.vue'
import CollapsibleSection from '@/components/CollapsibleSection.vue'
import { CalendarDays } from 'lucide-vue-next'
import type { SpaceUsageReport, TopUsersReport, ApprovalRateReport } from '@/types'
const breadcrumbItems = [
{ label: 'Dashboard', to: '/dashboard' },
{ label: 'Admin', to: '/admin' },
{ label: 'Reports' }
]
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>
h2 {
margin-bottom: 20px;
color: var(--color-text-primary);
}
.filters {
display: flex;
gap: 15px;
align-items: center;
flex-wrap: wrap;
}
.filters label {
display: flex;
flex-direction: column;
gap: 5px;
font-weight: 500;
color: var(--color-text-primary);
font-size: 14px;
}
.filters input[type='date'] {
padding: 8px;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-surface);
color: var(--color-text-primary);
}
.btn {
padding: 8px 16px;
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
font-weight: 500;
font-size: 14px;
transition: all var(--transition-fast);
}
.btn-primary {
background: var(--color-accent);
color: white;
}
.btn-primary:hover {
background: var(--color-accent-hover);
}
.btn-secondary {
background: var(--color-bg-tertiary);
color: var(--color-text-primary);
}
.btn-secondary:hover {
background: var(--color-border);
}
.loading,
.error {
padding: 20px;
text-align: center;
border-radius: var(--radius-sm);
}
.loading {
color: var(--color-text-secondary);
}
.error {
background: color-mix(in srgb, var(--color-danger) 10%, transparent);
color: var(--color-danger);
}
.tabs {
display: flex;
gap: 10px;
margin: 20px 0;
border-bottom: 2px solid var(--color-border);
}
.tab-button {
padding: 10px 20px;
background: none;
border: none;
border-bottom: 3px solid transparent;
cursor: pointer;
font-weight: 500;
color: var(--color-text-secondary);
transition: all var(--transition-fast);
}
.tab-button:hover {
color: var(--color-text-primary);
}
.tab-button.active {
color: var(--color-accent);
border-bottom-color: var(--color-accent);
}
.report-content {
background: var(--color-surface);
padding: 20px;
border-radius: var(--radius-md);
box-shadow: var(--shadow-sm);
border: 1px solid var(--color-border);
}
.report-content h3 {
margin-bottom: 20px;
color: var(--color-text-primary);
}
canvas {
max-height: 400px;
margin-bottom: 30px;
}
.table-responsive {
overflow-x: auto;
}
.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 var(--color-border);
color: var(--color-text-primary);
}
.report-table th {
background: var(--color-bg-secondary);
font-weight: 600;
}
.report-table tbody tr:hover {
background: var(--color-surface-hover);
}
.report-table tfoot {
font-weight: bold;
background: var(--color-bg-secondary);
}
.status-approved {
color: var(--color-success);
}
.status-pending {
color: var(--color-warning);
}
.status-rejected {
color: var(--color-danger);
}
.status-canceled {
color: var(--color-text-muted);
}
.stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 20px;
margin-bottom: 30px;
}
.stat-card {
padding: 20px;
background: var(--color-bg-secondary);
border-radius: var(--radius-md);
text-align: center;
border: 2px solid transparent;
}
.stat-card h3 {
font-size: 2em;
margin: 0 0 10px 0;
color: var(--color-text-primary);
}
.stat-card p {
margin: 0;
color: var(--color-text-secondary);
font-weight: 500;
}
.stat-card.approved {
background: color-mix(in srgb, var(--color-success) 10%, transparent);
border-color: var(--color-success);
}
.stat-card.approved h3 {
color: var(--color-success);
}
.stat-card.rejected {
background: color-mix(in srgb, var(--color-danger) 10%, transparent);
border-color: var(--color-danger);
}
.stat-card.rejected h3 {
color: var(--color-danger);
}
.breakdown {
margin-top: 20px;
padding: 15px;
background: var(--color-bg-secondary);
border-radius: var(--radius-md);
}
.breakdown p {
margin: 8px 0;
font-size: 1.1em;
color: var(--color-text-primary);
}
.breakdown strong {
color: var(--color-text-primary);
}
@media (max-width: 768px) {
.filters {
flex-direction: column;
align-items: stretch;
}
}
</style>