Files
techxcar/frontend/src/pages/app/ExpenseReportsPage.tsx
T

301 lines
13 KiB
TypeScript

import { useMemo, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { FilterX } from 'lucide-react'
import { apiFetch } from '@/lib/api'
import { useTheme } from '@/hooks/useTheme'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import type { Expense } from '@/lib/types'
type PeriodKey = 'month' | '30d' | '90d' | 'year' | 'all'
type ExpenseType = '' | 'fuel' | 'parts' | 'tools' | 'other'
const PERIOD_OPTIONS: { key: PeriodKey; label: string }[] = [
{ key: 'month', label: 'Mês atual' },
{ key: '30d', label: '30 dias' },
{ key: '90d', label: '90 dias' },
{ key: 'year', label: '12 meses' },
{ key: 'all', label: 'Todo o histórico' },
]
const TYPE_LABEL: Record<Exclude<ExpenseType, ''>, string> = {
fuel: 'Combustível',
parts: 'Peças',
tools: 'Ferramentas',
other: 'Outros',
}
function periodStart(period: PeriodKey) {
const now = new Date()
if (period === 'all') return null
const start = new Date(now)
if (period === 'month') {
start.setDate(1)
start.setHours(0, 0, 0, 0)
return start
}
if (period === '30d') start.setDate(now.getDate() - 30)
if (period === '90d') start.setDate(now.getDate() - 90)
if (period === 'year') start.setMonth(now.getMonth() - 12)
return start
}
function currency(v: number) {
return new Intl.NumberFormat('pt-PT', { style: 'currency', currency: 'EUR' }).format(v)
}
function monthKey(d: Date) {
return `${d.getFullYear()}-${d.getMonth()}`
}
export default function ExpenseReportsPage() {
const { theme } = useTheme('ui_theme', 'dark')
const isLight = theme === 'light'
const [period, setPeriod] = useState<PeriodKey>('90d')
const [typeFilter, setTypeFilter] = useState<ExpenseType>('')
const expensesQ = useQuery<Expense[]>({
queryKey: ['expenses', 'reports'],
queryFn: () => apiFetch<Expense[]>('/expenses'),
})
const expenses = expensesQ.data ?? []
const filtered = useMemo(() => {
const start = periodStart(period)
return expenses.filter((e) => {
if (typeFilter && e.type !== typeFilter) return false
if (start && new Date(e.date) < start) return false
return true
})
}, [expenses, period, typeFilter])
const hasFilters = period !== '90d' || !!typeFilter
const kpis = useMemo(() => {
const total = filtered.reduce((sum, e) => sum + e.amount, 0)
const count = filtered.length
const avg = count > 0 ? total / count : 0
const maxExpense = filtered.reduce((mx, e) => (e.amount > mx.amount ? e : mx), { amount: 0, type: 'other' as Expense['type'] })
const byType = {
fuel: 0,
parts: 0,
tools: 0,
other: 0,
}
for (const e of filtered) byType[e.type] += e.amount
const topType = (Object.entries(byType) as [keyof typeof byType, number][])
.sort((a, b) => b[1] - a[1])[0]
return {
total,
count,
avg,
maxValue: maxExpense.amount,
maxType: maxExpense.type,
topType: topType?.[0] ?? 'other',
topTypeAmount: topType?.[1] ?? 0,
}
}, [filtered])
const byTypeRows = useMemo(() => {
const totals = {
fuel: 0,
parts: 0,
tools: 0,
other: 0,
}
for (const e of filtered) totals[e.type] += e.amount
return (Object.entries(totals) as [keyof typeof totals, number][])
.map(([type, total]) => ({ type, total, pct: kpis.total > 0 ? (total / kpis.total) * 100 : 0 }))
.sort((a, b) => b.total - a.total)
}, [filtered, kpis.total])
const monthly = useMemo(() => {
const now = new Date()
const buckets = Array.from({ length: 6 }).map((_, i) => {
const d = new Date(now.getFullYear(), now.getMonth() - i, 1)
return {
key: monthKey(d),
label: new Intl.DateTimeFormat('pt-PT', { month: 'short' }).format(d),
total: 0,
count: 0,
}
}).reverse()
const index = new Map(buckets.map((b, i) => [b.key, i]))
for (const e of filtered) {
const idx = index.get(monthKey(new Date(e.date)))
if (idx === undefined) continue
buckets[idx].total += e.amount
buckets[idx].count += 1
}
return buckets
}, [filtered])
const maxMonthly = Math.max(1, ...monthly.map((m) => m.total))
const topExpenses = [...filtered]
.sort((a, b) => b.amount - a.amount)
.slice(0, 7)
return (
<section className="space-y-6">
<header className={`rounded-2xl border p-6 ${isLight ? 'border-slate-300 bg-white' : 'border-slate-700 bg-slate-900/70'}`}>
<p className={`text-xs uppercase tracking-[0.2em] ${isLight ? 'text-slate-500' : 'text-slate-400'}`}>
Relatórios Financeiros
</p>
<h1 className={`mt-1 text-3xl font-semibold ${isLight ? 'text-slate-900' : 'text-white'}`}>
Relatório de Despesas
</h1>
<p className={`mt-2 text-sm ${isLight ? 'text-slate-600' : 'text-slate-300'}`}>
Acompanhamento de custos por período e categoria para suportar decisões operacionais.
</p>
</header>
<div className={`rounded-2xl border p-4 ${isLight ? 'border-slate-300 bg-white' : 'border-slate-700 bg-slate-900/70'}`}>
<div className="grid gap-3 md:grid-cols-3">
<label className="text-sm">
<span className={`mb-1 block ${isLight ? 'text-slate-700' : 'text-slate-300'}`}>Período</span>
<select
className={`w-full rounded-md border px-3 py-2 text-sm ${isLight ? 'border-slate-300 bg-white text-slate-900' : 'border-slate-600 bg-slate-900 text-white'}`}
value={period}
onChange={(e) => setPeriod(e.target.value as PeriodKey)}
>
{PERIOD_OPTIONS.map((o) => (
<option key={o.key} value={o.key}>{o.label}</option>
))}
</select>
</label>
<label className="text-sm">
<span className={`mb-1 block ${isLight ? 'text-slate-700' : 'text-slate-300'}`}>Categoria</span>
<select
className={`w-full rounded-md border px-3 py-2 text-sm ${isLight ? 'border-slate-300 bg-white text-slate-900' : 'border-slate-600 bg-slate-900 text-white'}`}
value={typeFilter}
onChange={(e) => setTypeFilter(e.target.value as ExpenseType)}
>
<option value="">Todas</option>
<option value="fuel">Combustível</option>
<option value="parts">Peças</option>
<option value="tools">Ferramentas</option>
<option value="other">Outros</option>
</select>
</label>
<div className="space-y-1">
<span className={`mb-1 block text-sm ${isLight ? 'text-slate-700' : 'text-slate-300'}`}>Ações rápidas</span>
<Button
type="button"
variant="outline"
className="w-full"
onClick={() => {
setPeriod('90d')
setTypeFilter('')
}}
disabled={!hasFilters}
>
<FilterX className="h-4 w-4" />
Limpar todos os filtros
</Button>
</div>
</div>
</div>
<div className="flex items-center gap-3 text-sm">
<Badge variant="secondary">{expenses.length} total</Badge>
<Badge>{filtered.length} no filtro atual</Badge>
</div>
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
{[
['Total de despesas', currency(kpis.total)],
['Registos', String(kpis.count)],
['Média por registo', currency(kpis.avg)],
['Maior despesa', currency(kpis.maxValue)],
].map(([label, value]) => (
<article key={label} className={`rounded-xl border p-4 ${isLight ? 'border-slate-300 bg-white' : 'border-slate-700 bg-slate-900/70'}`}>
<p className={`text-xs uppercase tracking-wide ${isLight ? 'text-slate-500' : 'text-slate-400'}`}>{label}</p>
<p className={`mt-1 text-2xl font-semibold ${isLight ? 'text-slate-900' : 'text-white'}`}>{value}</p>
</article>
))}
</div>
{expensesQ.isLoading && (
<p className={isLight ? 'text-slate-600 text-sm' : 'text-slate-400 text-sm'}>A calcular relatório de despesas...</p>
)}
<div className="grid gap-4 xl:grid-cols-2">
<article className={`rounded-2xl border p-5 ${isLight ? 'border-slate-300 bg-white' : 'border-slate-700 bg-slate-900/70'}`}>
<h2 className={`text-lg font-semibold ${isLight ? 'text-slate-900' : 'text-white'}`}>Distribuição por Categoria</h2>
<p className={`mt-1 text-sm ${isLight ? 'text-slate-600' : 'text-slate-400'}`}>
Categoria com maior peso: {TYPE_LABEL[kpis.topType as keyof typeof TYPE_LABEL]} ({currency(kpis.topTypeAmount)})
</p>
<div className="mt-4 space-y-3">
{byTypeRows.map((row) => (
<div key={row.type}>
<div className="mb-1 flex items-center justify-between gap-2 text-sm">
<span className={isLight ? 'text-slate-800' : 'text-slate-200'}>{TYPE_LABEL[row.type]}</span>
<span className={isLight ? 'text-slate-600' : 'text-slate-400'}>
{currency(row.total)} ({row.pct.toFixed(1)}%)
</span>
</div>
<div className={`h-2 rounded-full ${isLight ? 'bg-slate-200' : 'bg-slate-800'}`}>
<div className="h-2 rounded-full bg-gradient-to-r from-cyan-500 to-blue-500" style={{ width: `${row.pct}%` }} />
</div>
</div>
))}
</div>
</article>
<article className={`rounded-2xl border p-5 ${isLight ? 'border-slate-300 bg-white' : 'border-slate-700 bg-slate-900/70'}`}>
<h2 className={`text-lg font-semibold ${isLight ? 'text-slate-900' : 'text-white'}`}>Evolução Mensal (Despesas)</h2>
<div className="mt-4 space-y-3">
{monthly.map((m) => (
<div key={m.key}>
<div className="mb-1 flex justify-between text-xs">
<span className={isLight ? 'text-slate-700 capitalize' : 'text-slate-300 capitalize'}>{m.label}</span>
<span className={isLight ? 'text-slate-600' : 'text-slate-400'}>
{m.count} reg. | {currency(m.total)}
</span>
</div>
<div className={`h-2 rounded-full ${isLight ? 'bg-slate-200' : 'bg-slate-800'}`}>
<div
className="h-2 rounded-full bg-gradient-to-r from-violet-500 to-fuchsia-500"
style={{ width: `${(m.total / maxMonthly) * 100}%` }}
/>
</div>
</div>
))}
</div>
</article>
<article className={`rounded-2xl border p-5 xl:col-span-2 ${isLight ? 'border-slate-300 bg-white' : 'border-slate-700 bg-slate-900/70'}`}>
<h2 className={`text-lg font-semibold ${isLight ? 'text-slate-900' : 'text-white'}`}>Top Despesas do Período</h2>
{topExpenses.length === 0 ? (
<p className={`mt-3 text-sm ${isLight ? 'text-slate-600' : 'text-slate-400'}`}>Sem despesas para o filtro atual.</p>
) : (
<div className="mt-4 overflow-hidden rounded-lg border border-slate-300/70 dark:border-slate-700">
<table className="w-full text-sm">
<thead className={isLight ? 'bg-slate-100' : 'bg-slate-800'}>
<tr>
<th className={`px-3 py-2 text-left ${isLight ? 'text-slate-600' : 'text-slate-300'}`}>Data</th>
<th className={`px-3 py-2 text-left ${isLight ? 'text-slate-600' : 'text-slate-300'}`}>Categoria</th>
<th className={`px-3 py-2 text-left ${isLight ? 'text-slate-600' : 'text-slate-300'}`}>Descrição</th>
<th className={`px-3 py-2 text-right ${isLight ? 'text-slate-600' : 'text-slate-300'}`}>Valor</th>
</tr>
</thead>
<tbody>
{topExpenses.map((e) => (
<tr key={e.id} className={`border-t ${isLight ? 'border-slate-200' : 'border-slate-700'}`}>
<td className={`px-3 py-2 ${isLight ? 'text-slate-800' : 'text-slate-200'}`}>
{new Intl.DateTimeFormat('pt-PT').format(new Date(e.date))}
</td>
<td className={`px-3 py-2 ${isLight ? 'text-slate-700' : 'text-slate-300'}`}>{TYPE_LABEL[e.type]}</td>
<td className={`px-3 py-2 ${isLight ? 'text-slate-700' : 'text-slate-300'}`}>{e.description || '—'}</td>
<td className={`px-3 py-2 text-right font-medium ${isLight ? 'text-slate-900' : 'text-white'}`}>{currency(e.amount)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</article>
</div>
</section>
)
}