feat: refine OT flow, reports, theming and sidebar UX
This commit is contained in:
@@ -0,0 +1,275 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { apiFetch } from '@/lib/api'
|
||||
import { useTheme } from '@/hooks/useTheme'
|
||||
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 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-2">
|
||||
<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>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user