d7fb7b7a7b
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
207 lines
9.1 KiB
TypeScript
207 lines
9.1 KiB
TypeScript
'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 d’assurance, 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>
|
||
)
|
||
}
|