import { useMemo, useState } from 'react' import { Link } from 'react-router' import { useQueries, useQuery } from '@tanstack/react-query' import { useTheme } from '@/hooks/useTheme' import { apiFetch } from '@/lib/api' import type { Client, Expense, Invoice, Staff, WorkOrder, WorkOrderDetail } from '@/lib/types' import { WORK_ORDER_STATUS_LABEL, onTimeDeliveryStats } from '@/lib/workOrderStatus' const STATUS_ORDER: WorkOrder['status'][] = ['quote', 'in_progress', 'completed', 'invoiced', 'cancelled'] type PeriodKey = 'month' | '30d' | '90d' | 'year' 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' }, ] 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()}` } function getPeriodStart(period: PeriodKey, now = new Date()) { 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) return start } if (period === '90d') { start.setDate(now.getDate() - 90) return start } start.setMonth(now.getMonth() - 12) return start } function DashboardCard({ title, value, subtitle, isLight, }: { title: string value: string subtitle: string isLight: boolean }) { return (

{title}

{value}

{subtitle}

) } export default function DashboardPage() { const { theme } = useTheme('ui_theme', 'dark') const isLight = theme === 'light' const [period, setPeriod] = useState('month') const workOrdersQ = useQuery({ queryKey: ['work-orders'], queryFn: () => apiFetch('/work-orders'), }) const invoicesQ = useQuery({ queryKey: ['invoices'], queryFn: () => apiFetch('/invoices'), }) const expensesQ = useQuery({ queryKey: ['expenses'], queryFn: () => apiFetch('/expenses'), }) const staffQ = useQuery({ queryKey: ['staff'], queryFn: () => apiFetch('/staff'), }) const clientsQ = useQuery({ queryKey: ['clients'], queryFn: () => apiFetch('/clients'), }) const workOrders = workOrdersQ.data ?? [] const invoices = invoicesQ.data ?? [] const expenses = expensesQ.data ?? [] const staff = staffQ.data ?? [] const clients = clientsQ.data ?? [] const invoiceDocs = invoices.filter((doc) => doc.type === 'invoice') const invoiceTotalsQ = useQueries({ queries: invoiceDocs.map((doc) => ({ queryKey: ['work-order-detail', doc.work_order_id, 'dashboard-total'], queryFn: () => apiFetch(`/work-orders/${doc.work_order_id}`), staleTime: 5 * 60 * 1000, })), }) const isLoading = [workOrdersQ, invoicesQ, expensesQ, staffQ, clientsQ].some((q) => q.isLoading) const hasError = [workOrdersQ, invoicesQ, expensesQ, staffQ, clientsQ].some((q) => q.isError) const entriesLoading = invoiceTotalsQ.some((q) => q.isLoading) const entriesError = invoiceTotalsQ.some((q) => q.isError) const workOrderTotals = useMemo(() => { const totals: Record = {} for (let i = 0; i < invoiceDocs.length; i++) { const q = invoiceTotalsQ[i] const doc = invoiceDocs[i] if (!q?.data || !doc) continue const items = q.data.items.reduce((sum, item) => sum + item.total, 0) const hours = q.data.staff_hours.reduce((sum, h) => sum + h.total, 0) totals[doc.work_order_id] = items + hours } return totals }, [invoiceDocs, invoiceTotalsQ]) const metrics = useMemo(() => { const now = new Date() const periodStart = getPeriodStart(period, now) // Pipeline state (byStatus, activeOrders, awaitingInvoice, recentOrders) reflects // ALL work orders regardless of period — an open OT started before the period // window must not disappear from the dashboard just because it's "old". const invoicesPeriod = invoices.filter((inv) => new Date(inv.issued_at) >= periodStart) const expensesPeriod = expenses.filter((exp) => new Date(exp.date) >= periodStart) const invoicesMonth = invoices.filter((inv) => monthKey(new Date(inv.issued_at)) === monthKey(now)) const expensesMonth = expenses.filter((exp) => monthKey(new Date(exp.date)) === monthKey(now)) const byStatus = Object.fromEntries(STATUS_ORDER.map((s) => [s, 0])) as Record for (const wo of workOrders) byStatus[wo.status] += 1 const activeOrders = byStatus.quote + byStatus.in_progress const awaitingInvoice = workOrders.filter((wo) => wo.status === 'completed').length const periodExpenses = expensesPeriod.reduce((sum, e) => sum + e.amount, 0) const monthExpenses = expensesMonth.reduce((sum, e) => sum + e.amount, 0) const monthDocs = invoicesMonth.length const activeStaff = staff.filter((s) => s.active).length const periodEntries = invoicesPeriod .filter((inv) => inv.type === 'invoice') .reduce((sum, inv) => sum + (workOrderTotals[inv.work_order_id] ?? 0), 0) const monthEntries = invoicesMonth .filter((inv) => inv.type === 'invoice') .reduce((sum, inv) => sum + (workOrderTotals[inv.work_order_id] ?? 0), 0) const recentOrders = [...workOrders] .sort((a, b) => +new Date(b.updated_at) - +new Date(a.updated_at)) .slice(0, 3) const recentExpenses = [...expensesPeriod] .sort((a, b) => +new Date(b.date) - +new Date(a.date)) .slice(0, 3) const delivery = onTimeDeliveryStats(workOrders) return { byStatus, activeOrders, awaitingInvoice, periodExpenses, periodEntries, monthEntries, monthExpenses, monthDocs, activeStaff, recentOrders, recentExpenses, delivery, totalOrdersCount: workOrders.length, periodDocsCount: invoicesPeriod.length, } }, [period, workOrders, invoices, expenses, staff, workOrderTotals]) const entriesVsExpensesMax = Math.max(metrics.periodEntries, metrics.periodExpenses, 1) const entriesBarWidth = (metrics.periodEntries / entriesVsExpensesMax) * 100 const expensesBarWidth = (metrics.periodExpenses / entriesVsExpensesMax) * 100 return (

Painel Operacional

Dashboard da Oficina

Visão rápida do trabalho em curso, faturação e despesas para apoiar as decisões do dia.

{PERIOD_OPTIONS.map((opt) => ( ))}
{isLoading && (

A carregar métricas do dashboard...

)} {hasError && !isLoading && (

Alguns dados não foram carregados. As métricas visíveis podem estar incompletas.

)}

Entradas Recebidas vs Despesas

{PERIOD_OPTIONS.find((p) => p.key === period)?.label}

Entradas (faturas)

{currency(metrics.periodEntries)}

Despesas

{currency(metrics.periodExpenses)}

Entradas recebidas {currency(metrics.periodEntries)}
Despesas registadas {currency(metrics.periodExpenses)}

Saldo estimado: = 0 ? (isLight ? 'text-emerald-700' : 'text-emerald-300') : (isLight ? 'text-rose-700' : 'text-rose-300')}> {currency(metrics.periodEntries - metrics.periodExpenses)}

{(entriesLoading || entriesError) && (

{entriesLoading ? 'A calcular totais de entradas...' : 'Algumas entradas não puderam ser calculadas.'}

)}

Fluxo das Ordens de Trabalho

Ver todas
{STATUS_ORDER.map((status) => { const count = metrics.byStatus[status] const total = Math.max(metrics.totalOrdersCount, 1) const width = Math.max((count / total) * 100, count > 0 ? 6 : 0) return (
{WORK_ORDER_STATUS_LABEL[status]} {count}
) })}

Faturação e Prioridades

Documentos emitidos no período

{metrics.periodDocsCount}

Ação recomendada

{metrics.awaitingInvoice > 0 ? `${metrics.awaitingInvoice} OT(s) em "Concluída" pronta(s) para faturar.` : 'Sem OTs finalizadas pendentes de emissão de fatura.'}

Em orçamento

{metrics.byStatus.quote > 0 ? `${metrics.byStatus.quote} OT(s) em "Orçamento" prontas para "Em Progresso".` : 'Não existem OTs pendentes em orçamento.'}

Contexto mensal

Entradas no mês: {currency(metrics.monthEntries)} | Despesas no mês: {currency(metrics.monthExpenses)} | Documentos: {metrics.monthDocs}

Atividade Recente de OTs

Mostrar tudo →
{metrics.recentOrders.length === 0 ? (

Ainda não existem ordens de trabalho.

) : (
    {metrics.recentOrders.map((wo) => (
  • OT #{wo.number}

    {WORK_ORDER_STATUS_LABEL[wo.status]}

    {new Intl.DateTimeFormat('pt-PT', { dateStyle: 'short', timeStyle: 'short' }).format(new Date(wo.updated_at))}
  • ))}
)}

Despesas Recentes

Mostrar tudo →
{metrics.recentExpenses.length === 0 ? (

Sem despesas no período selecionado.

) : (
    {metrics.recentExpenses.map((exp) => (
  • {currency(exp.amount)}

    {exp.type === 'fuel' ? 'Combustível' : exp.type === 'parts' ? 'Peças' : exp.type === 'tools' ? 'Ferramentas' : 'Outros'} {exp.description ? ` · ${exp.description}` : ''}

    {new Intl.DateTimeFormat('pt-PT', { dateStyle: 'short' }).format(new Date(exp.date))}
  • ))}
)}
) }