redesign the homepage
Build & Deploy / Build & Push Docker Image (push) Failing after 47s
Build & Deploy / Deploy to VPS (push) Has been skipped
Test / API Unit Tests (push) Failing after 5m4s
Test / Marketplace Unit Tests (push) Failing after 4m55s
Test / Admin Unit Tests (push) Successful in 9m37s
Test / Dashboard Unit Tests (push) Successful in 9m37s
Test / API Integration Tests (push) Successful in 9m54s

This commit is contained in:
root
2026-06-26 16:27:21 -04:00
parent 256ff0814e
commit d7fb7b7a7b
1030 changed files with 107374 additions and 2657 deletions
@@ -0,0 +1,206 @@
'use client'
import { useEffect, useState } from 'react'
import { formatCurrency } from '@rentaldrivego/types'
import { API_BASE, apiFetch } from '@/lib/api'
import { useDashboardI18n } from '@/components/I18nProvider'
interface ReportRow {
reservationId: string
customerName: string
vehicle: string
startDate: string
endDate: string
totalAmount: number
source: string
paymentStatus: string
}
interface ReportData {
summary: {
totalReservations: number
totalRentalRevenue: number
totalDiscounts: number
totalInsurance: number
totalAdditionalDrivers: number
totalCollected: number
totalPricingRulesAdj?: number
}
rows: ReportRow[]
}
type ReportPeriod = 'WEEKLY' | 'MONTHLY' | 'QUARTERLY' | 'ANNUAL'
export default function ReportsPage() {
const { language } = useDashboardI18n()
const [period, setPeriod] = useState<ReportPeriod>('MONTHLY')
const [report, setReport] = useState<ReportData | null>(null)
const [error, setError] = useState<string | null>(null)
const [exporting, setExporting] = useState(false)
const copy = {
en: {
title: 'Reports',
subtitle: 'Accountant-ready exports with totals for insurance, additional drivers, and pricing rules.',
exportCsv: 'Export CSV',
exporting: 'Exporting…',
bookings: 'Bookings',
rental: 'Rental',
insurance: 'Insurance',
drivers: 'Drivers',
discounts: 'Discounts',
collected: 'Collected',
reservation: 'Reservation',
vehicle: 'Vehicle',
period: 'Period',
source: 'Source',
payment: 'Payment',
total: 'Total',
emptyRows: 'No report rows available for this period.',
periodLabels: { WEEKLY: 'Weekly', MONTHLY: 'Monthly', QUARTERLY: 'Quarterly', ANNUAL: 'Annual' } as Record<ReportPeriod, string>,
},
fr: {
title: 'Rapports',
subtitle: 'Exports prêts pour la comptabilité avec les totaux dassurance, de conducteurs supplémentaires et de règles tarifaires.',
exportCsv: 'Exporter en CSV',
exporting: 'Export en cours…',
bookings: 'Réservations',
rental: 'Location',
insurance: 'Assurance',
drivers: 'Conducteurs',
discounts: 'Remises',
collected: 'Encaissé',
reservation: 'Réservation',
vehicle: 'Véhicule',
period: 'Période',
source: 'Source',
payment: 'Paiement',
total: 'Total',
emptyRows: 'Aucune ligne de rapport pour cette période.',
periodLabels: { WEEKLY: 'Hebdomadaire', MONTHLY: 'Mensuel', QUARTERLY: 'Trimestriel', ANNUAL: 'Annuel' } as Record<ReportPeriod, string>,
},
ar: {
title: 'التقارير',
subtitle: 'ملفات تصدير جاهزة للمحاسبة تشمل إجماليات التأمين والسائقين الإضافيين وقواعد التسعير.',
exportCsv: 'تصدير CSV',
exporting: 'جارٍ التصدير…',
bookings: 'الحجوزات',
rental: 'الإيجار',
insurance: 'التأمين',
drivers: 'السائقون',
discounts: 'الخصومات',
collected: 'المحصّل',
reservation: 'الحجز',
vehicle: 'المركبة',
period: 'الفترة',
source: 'المصدر',
payment: 'الدفع',
total: 'الإجمالي',
emptyRows: 'لا توجد بيانات تقرير لهذه الفترة.',
periodLabels: { WEEKLY: 'أسبوعي', MONTHLY: 'شهري', QUARTERLY: 'ربع سنوي', ANNUAL: 'سنوي' } as Record<ReportPeriod, string>,
},
}[language]
useEffect(() => {
apiFetch<ReportData>(`/analytics/report?period=${period}`)
.then(setReport)
.catch((err) => setError(err.message))
}, [period])
async function exportCsv() {
setExporting(true)
setError(null)
try {
const res = await fetch(`${API_BASE}/analytics/report?period=${period}&format=CSV`, {
credentials: 'include',
})
if (!res.ok) {
const json = await res.json().catch(() => null)
throw new Error(json?.message ?? copy.exportCsv)
}
const csv = await res.text()
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' })
const url = window.URL.createObjectURL(blob)
const anchor = document.createElement('a')
anchor.href = url
anchor.download = `rentaldrivego-${period.toLowerCase()}-report.csv`
anchor.click()
window.URL.revokeObjectURL(url)
} catch (err: any) {
setError(err.message ?? copy.exportCsv)
} finally {
setExporting(false)
}
}
return (
<div className="space-y-6">
<div className="rdg-page-heading flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
<div>
<h2 className="text-xl font-semibold text-slate-900">{copy.title}</h2>
<p className="mt-1 text-sm text-slate-500">{copy.subtitle}</p>
</div>
<div className="flex flex-wrap gap-2">
{(Object.keys(copy.periodLabels) as ReportPeriod[]).map((option) => (
<button
key={option}
onClick={() => setPeriod(option)}
className={`rounded-full px-4 py-2 text-sm font-semibold ${period === option ? 'bg-blue-900 text-white' : 'border border-slate-300 text-slate-700'}`}
>
{copy.periodLabels[option]}
</button>
))}
<button onClick={exportCsv} disabled={exporting} className="rounded-full border border-slate-900 px-4 py-2 text-sm font-semibold text-slate-900">
{exporting ? copy.exporting : copy.exportCsv}
</button>
</div>
</div>
{error && <div className="card p-6 text-sm text-red-600">{error}</div>}
{report && (
<div className="grid grid-cols-2 gap-4 lg:grid-cols-6">
<div className="card p-5"><p className="text-sm text-slate-500">{copy.bookings}</p><p className="mt-1 text-2xl font-bold text-slate-900">{report.summary.totalReservations}</p></div>
<div className="card p-5"><p className="text-sm text-slate-500">{copy.rental}</p><p className="mt-1 text-2xl font-bold text-slate-900">{formatCurrency(report.summary.totalRentalRevenue, 'MAD')}</p></div>
<div className="card p-5"><p className="text-sm text-slate-500">{copy.insurance}</p><p className="mt-1 text-2xl font-bold text-slate-900">{formatCurrency(report.summary.totalInsurance, 'MAD')}</p></div>
<div className="card p-5"><p className="text-sm text-slate-500">{copy.drivers}</p><p className="mt-1 text-2xl font-bold text-slate-900">{formatCurrency(report.summary.totalAdditionalDrivers, 'MAD')}</p></div>
<div className="card p-5"><p className="text-sm text-slate-500">{copy.discounts}</p><p className="mt-1 text-2xl font-bold text-slate-900">{formatCurrency(report.summary.totalDiscounts, 'MAD')}</p></div>
<div className="card p-5"><p className="text-sm text-slate-500">{copy.collected}</p><p className="mt-1 text-2xl font-bold text-slate-900">{formatCurrency(report.summary.totalCollected, 'MAD')}</p></div>
</div>
)}
<div className="card overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-slate-200 bg-slate-50">
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wide text-slate-500">{copy.reservation}</th>
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wide text-slate-500">{copy.vehicle}</th>
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wide text-slate-500">{copy.period}</th>
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wide text-slate-500">{copy.source}</th>
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wide text-slate-500">{copy.payment}</th>
<th className="px-6 py-3 text-right text-xs font-medium uppercase tracking-wide text-slate-500">{copy.total}</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{(report?.rows ?? []).map((row) => (
<tr key={row.reservationId}>
<td className="px-6 py-4 text-sm text-slate-700">{row.customerName}</td>
<td className="px-6 py-4 text-sm text-slate-700">{row.vehicle}</td>
<td className="px-6 py-4 text-sm text-slate-500">{row.startDate} - {row.endDate}</td>
<td className="px-6 py-4 text-sm text-slate-700">{row.source}</td>
<td className="px-6 py-4"><span className="badge-gray">{row.paymentStatus}</span></td>
<td className="px-6 py-4 text-right text-sm font-semibold text-slate-900">{formatCurrency(row.totalAmount, 'MAD')}</td>
</tr>
))}
{(report?.rows.length ?? 0) === 0 && (
<tr>
<td colSpan={6} className="px-6 py-10 text-center text-sm text-slate-400">{copy.emptyRows}</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</div>
)
}