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
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:
@@ -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 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user