feat: atualizar fluxo OT, dashboard, transacoes e PDFs

This commit is contained in:
Luciano Milani
2026-07-02 13:42:47 +01:00
parent 1def51e65b
commit 8231bba3f9
17 changed files with 729 additions and 91 deletions
+1 -1
View File
@@ -11,7 +11,7 @@ const nav = [
{ to: '/app/catalog', label: 'Catálogo' },
{ to: '/app/staff', label: 'Técnicos' },
{ to: '/app/expenses', label: 'Despesas' },
{ to: '/app/invoices', label: 'Faturação' },
{ to: '/app/invoices', label: 'Transações' },
{ to: '/app/settings', label: 'Definições' },
]
+31 -6
View File
@@ -27,17 +27,12 @@ async function refreshAccessToken(): Promise<string | null> {
}
}
export async function apiFetch<T>(
path: string,
options: RequestInit = {}
): Promise<T> {
async function authorizedFetch(path: string, options: RequestInit = {}): Promise<Response> {
const { accessToken, clearAuth } = useAuthStore.getState()
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...(options.headers as Record<string, string>),
}
if (accessToken) {
headers['Authorization'] = `Bearer ${accessToken}`
}
@@ -63,6 +58,21 @@ export async function apiFetch<T>(
}
}
return res
}
export async function apiFetch<T>(
path: string,
options: RequestInit = {}
): Promise<T> {
const res = await authorizedFetch(path, {
...options,
headers: {
'Content-Type': 'application/json',
...(options.headers as Record<string, string>),
},
})
if (res.status === 204) {
return undefined as T
}
@@ -75,3 +85,18 @@ export async function apiFetch<T>(
return json.data as T
}
export async function apiFetchBlob(path: string, options: RequestInit = {}): Promise<Blob> {
const res = await authorizedFetch(path, options)
if (!res.ok) {
let message = 'Erro ao obter ficheiro'
try {
const json = await res.json()
message = json.error ?? message
} catch {
// ignore non-json responses
}
throw new ApiError(res.status, message)
}
return res.blob()
}
+1 -1
View File
@@ -59,7 +59,7 @@ export interface WorkOrder {
number: number
client_id: string | null
vehicle_id: string | null
status: 'open' | 'in_progress' | 'completed' | 'invoiced' | 'cancelled'
status: 'quote' | 'open' | 'in_progress' | 'completed' | 'invoiced' | 'cancelled'
internal_notes: string
client_notes: string
created_by: string | null
+415 -5
View File
@@ -1,8 +1,418 @@
export default function DashboardPage() {
import { useMemo, useState } from 'react'
import { Link } from 'react-router'
import { useQueries, useQuery } from '@tanstack/react-query'
import { apiFetch } from '@/lib/api'
import type { Client, Expense, Invoice, Staff, WorkOrder, WorkOrderDetail } from '@/lib/types'
const STATUS_LABEL: Record<WorkOrder['status'], string> = {
quote: 'Orçamento',
open: 'Aberta',
in_progress: 'Em Curso',
completed: 'Concluída',
invoiced: 'Faturada',
cancelled: 'Cancelada',
}
const STATUS_ORDER: WorkOrder['status'][] = ['quote', 'open', '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,
}: {
title: string
value: string
subtitle: string
}) {
return (
<div>
<h1 className="text-2xl font-bold text-gray-900">Dashboard</h1>
<p className="text-gray-500 mt-1 text-sm">Bem-vindo ao TechXCar implementado no Plano 5</p>
</div>
<article className="rounded-2xl border border-slate-700/70 bg-slate-900/65 p-4 transition-colors hover:border-slate-500/80">
<p className="text-[11px] uppercase tracking-[0.14em] text-slate-400">{title}</p>
<p className="mt-2 text-2xl font-semibold text-white [font-family:'Space_Grotesk',ui-sans-serif,sans-serif]">
{value}
</p>
<p className="mt-1 text-xs text-slate-400">{subtitle}</p>
</article>
)
}
export default function DashboardPage() {
const [period, setPeriod] = useState<PeriodKey>('month')
const workOrdersQ = useQuery<WorkOrder[]>({
queryKey: ['work-orders'],
queryFn: () => apiFetch<WorkOrder[]>('/work-orders'),
})
const invoicesQ = useQuery<Invoice[]>({
queryKey: ['invoices'],
queryFn: () => apiFetch<Invoice[]>('/invoices'),
})
const expensesQ = useQuery<Expense[]>({
queryKey: ['expenses'],
queryFn: () => apiFetch<Expense[]>('/expenses'),
})
const staffQ = useQuery<Staff[]>({
queryKey: ['staff'],
queryFn: () => apiFetch<Staff[]>('/staff'),
})
const clientsQ = useQuery<Client[]>({
queryKey: ['clients'],
queryFn: () => apiFetch<Client[]>('/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<WorkOrderDetail>(`/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<string, number> = {}
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)
const workOrdersPeriod = workOrders.filter((wo) => new Date(wo.created_at) >= periodStart)
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<WorkOrder['status'], number>
for (const wo of workOrdersPeriod) byStatus[wo.status] += 1
const activeOrders = byStatus.quote + byStatus.open + byStatus.in_progress
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 = [...workOrdersPeriod]
.sort((a, b) => +new Date(b.updated_at) - +new Date(a.updated_at))
.slice(0, 6)
const recentDocs = [...invoicesPeriod]
.sort((a, b) => +new Date(b.issued_at) - +new Date(a.issued_at))
.slice(0, 6)
return {
byStatus,
activeOrders,
periodExpenses,
periodEntries,
monthEntries,
monthExpenses,
monthDocs,
activeStaff,
recentOrders,
recentDocs,
periodOrdersCount: workOrdersPeriod.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 (
<section className="space-y-6 [font-family:'Sora',ui-sans-serif,sans-serif]">
<header className="relative overflow-hidden rounded-2xl border border-slate-700/60 bg-gradient-to-br from-slate-900 via-slate-900 to-sky-950/35 p-6">
<div className="absolute -right-16 -top-16 h-44 w-44 rounded-full bg-cyan-600/10 blur-2xl" />
<div className="absolute -left-12 bottom-0 h-32 w-32 rounded-full bg-amber-500/10 blur-2xl" />
<p className="text-xs uppercase tracking-[0.2em] text-slate-400">Painel Operacional</p>
<h1 className="mt-1 text-3xl font-semibold text-white [font-family:'Space_Grotesk',ui-sans-serif,sans-serif]">
Dashboard da Oficina
</h1>
<p className="mt-2 max-w-2xl text-sm text-slate-300">
Visão rápida do trabalho em curso, faturação e despesas para apoiar as decisões do dia.
</p>
<div className="mt-4 flex flex-wrap gap-2">
{PERIOD_OPTIONS.map((opt) => (
<button
key={opt.key}
onClick={() => setPeriod(opt.key)}
className={`rounded-md border px-3 py-1.5 text-xs transition-colors ${
period === opt.key
? 'border-cyan-400/80 bg-cyan-500/20 text-cyan-100'
: 'border-slate-700 bg-slate-900/80 text-slate-300 hover:border-slate-500 hover:text-white'
}`}
>
{opt.label}
</button>
))}
</div>
</header>
{isLoading && (
<p className="rounded-xl border border-slate-700 bg-slate-900/60 px-4 py-3 text-sm text-slate-300">
A carregar métricas do dashboard...
</p>
)}
{hasError && !isLoading && (
<p className="rounded-xl border border-red-900/80 bg-red-950/40 px-4 py-3 text-sm text-red-200">
Alguns dados não foram carregados. As métricas visíveis podem estar incompletas.
</p>
)}
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
<DashboardCard
title="OTs Ativas"
value={String(metrics.activeOrders)}
subtitle={`No período (${metrics.periodOrdersCount} OTs)`}
/>
<DashboardCard
title="Clientes"
value={String(clients.length)}
subtitle="Base total de clientes"
/>
<DashboardCard
title="Técnicos Ativos"
value={`${metrics.activeStaff}/${staff.length}`}
subtitle="Recursos disponíveis"
/>
<DashboardCard
title="Despesas do Período"
value={currency(metrics.periodExpenses)}
subtitle="Somatório no filtro selecionado"
/>
</div>
<article className="rounded-2xl border border-slate-700/70 bg-slate-900/65 p-5">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-base font-semibold text-white">Entradas Recebidas vs Despesas</h2>
<span className="text-xs text-slate-400">
{PERIOD_OPTIONS.find((p) => p.key === period)?.label}
</span>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="rounded-lg border border-emerald-800/60 bg-emerald-950/20 p-3">
<p className="text-xs uppercase tracking-wide text-emerald-300">Entradas (faturas)</p>
<p className="mt-1 text-2xl font-semibold text-white">{currency(metrics.periodEntries)}</p>
</div>
<div className="rounded-lg border border-rose-800/60 bg-rose-950/20 p-3">
<p className="text-xs uppercase tracking-wide text-rose-300">Despesas</p>
<p className="mt-1 text-2xl font-semibold text-white">{currency(metrics.periodExpenses)}</p>
</div>
</div>
<div className="mt-4 space-y-3">
<div>
<div className="mb-1 flex justify-between text-xs">
<span className="text-slate-300">Entradas recebidas</span>
<span className="font-mono text-emerald-300">{currency(metrics.periodEntries)}</span>
</div>
<div className="h-3 rounded-full bg-slate-800">
<div className="h-3 rounded-full bg-gradient-to-r from-emerald-500 to-teal-400" style={{ width: `${entriesBarWidth}%` }} />
</div>
</div>
<div>
<div className="mb-1 flex justify-between text-xs">
<span className="text-slate-300">Despesas registadas</span>
<span className="font-mono text-rose-300">{currency(metrics.periodExpenses)}</span>
</div>
<div className="h-3 rounded-full bg-slate-800">
<div className="h-3 rounded-full bg-gradient-to-r from-rose-500 to-orange-400" style={{ width: `${expensesBarWidth}%` }} />
</div>
</div>
</div>
<p className="mt-4 text-sm text-slate-300">
Saldo estimado: <span className={metrics.periodEntries - metrics.periodExpenses >= 0 ? 'text-emerald-300' : 'text-rose-300'}>
{currency(metrics.periodEntries - metrics.periodExpenses)}
</span>
</p>
{(entriesLoading || entriesError) && (
<p className="mt-2 text-xs text-slate-400">
{entriesLoading ? 'A calcular totais de entradas...' : 'Algumas entradas não puderam ser calculadas.'}
</p>
)}
</article>
<div className="grid items-stretch gap-4 xl:grid-cols-2">
<article className="h-full rounded-2xl border border-slate-700/70 bg-slate-900/65 p-5">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-base font-semibold text-white">Fluxo das Ordens de Trabalho</h2>
<Link to="/app/work-orders" className="text-xs text-cyan-300 hover:text-cyan-200">
Ver todas
</Link>
</div>
<div className="space-y-3">
{STATUS_ORDER.map((status) => {
const count = metrics.byStatus[status]
const total = Math.max(metrics.periodOrdersCount, 1)
const width = Math.max((count / total) * 100, count > 0 ? 6 : 0)
return (
<div key={status}>
<div className="mb-1 flex justify-between text-xs">
<span className="text-slate-300">{STATUS_LABEL[status]}</span>
<span className="font-mono text-slate-400">{count}</span>
</div>
<div className="h-2 rounded-full bg-slate-800">
<div className="h-2 rounded-full bg-gradient-to-r from-cyan-500 to-emerald-500" style={{ width: `${width}%` }} />
</div>
</div>
)
})}
</div>
</article>
<article className="h-full rounded-2xl border border-slate-700/70 bg-slate-900/65 p-5">
<h2 className="text-base font-semibold text-white">Faturação e Prioridades</h2>
<div className="mt-4 space-y-3 text-sm">
<div className="rounded-lg border border-slate-700 bg-slate-900/70 px-3 py-2">
<p className="text-slate-400">Documentos emitidos no período</p>
<p className="text-xl font-semibold text-white">{metrics.periodDocsCount}</p>
</div>
<div className="rounded-lg border border-amber-800/70 bg-amber-950/30 px-3 py-2 text-amber-100">
<p className="text-xs uppercase tracking-wide">Ação recomendada</p>
<p className="mt-1">
{metrics.byStatus.completed > 0
? `${metrics.byStatus.completed} OT(s) concluída(s) pronta(s) para faturar.`
: 'Sem OTs concluídas pendentes de faturação.'}
</p>
</div>
<div className="rounded-lg border border-cyan-900/70 bg-cyan-950/25 px-3 py-2 text-cyan-100">
<p className="text-xs uppercase tracking-wide">Em orçamento</p>
<p className="mt-1">
{metrics.byStatus.quote > 0
? `${metrics.byStatus.quote} OT(s) em orçamento aguardam aprovação.`
: 'Não existem OTs pendentes em orçamento.'}
</p>
</div>
<div className="rounded-lg border border-slate-700 bg-slate-900/70 px-3 py-2">
<p className="text-slate-400">Contexto mensal</p>
<p className="text-slate-200 mt-1 text-xs">
Entradas no mês: {currency(metrics.monthEntries)} | Despesas no mês: {currency(metrics.monthExpenses)} | Documentos: {metrics.monthDocs}
</p>
</div>
</div>
</article>
</div>
<div className="grid gap-4 xl:grid-cols-2">
<article className="rounded-2xl border border-slate-700/70 bg-slate-900/65 p-5">
<div className="mb-3 flex items-center justify-between">
<h2 className="text-base font-semibold text-white">Atividade Recente de OTs</h2>
<Link to="/app/work-orders" className="text-xs text-cyan-300 hover:text-cyan-200">
Abrir OTs
</Link>
</div>
{metrics.recentOrders.length === 0 ? (
<p className="text-sm text-slate-400">Ainda não existem ordens de trabalho.</p>
) : (
<ul className="divide-y divide-slate-800">
{metrics.recentOrders.map((wo) => (
<li key={wo.id} className="flex items-center justify-between py-2">
<div>
<p className="text-sm font-medium text-white">OT #{wo.number}</p>
<p className="text-xs text-slate-400">{STATUS_LABEL[wo.status]}</p>
</div>
<span className="text-xs text-slate-500">
{new Intl.DateTimeFormat('pt-PT', { dateStyle: 'short', timeStyle: 'short' }).format(new Date(wo.updated_at))}
</span>
</li>
))}
</ul>
)}
</article>
<article className="rounded-2xl border border-slate-700/70 bg-slate-900/65 p-5">
<div className="mb-3 flex items-center justify-between">
<h2 className="text-base font-semibold text-white">Documentos Recentes</h2>
<Link to="/app/invoices" className="text-xs text-cyan-300 hover:text-cyan-200">
Abrir Faturação
</Link>
</div>
{metrics.recentDocs.length === 0 ? (
<p className="text-sm text-slate-400">Sem documentos emitidos.</p>
) : (
<ul className="divide-y divide-slate-800">
{metrics.recentDocs.map((doc) => (
<li key={doc.id} className="flex items-center justify-between py-2">
<div>
<p className="text-sm font-medium text-white">
{doc.type === 'invoice' ? 'Fatura' : 'Orçamento'} #{doc.number}
</p>
<p className="text-xs text-slate-400 font-mono">OT {doc.work_order_id.slice(0, 8)}...</p>
</div>
<span className="text-xs text-slate-500">
{new Intl.DateTimeFormat('pt-PT', { dateStyle: 'short' }).format(new Date(doc.issued_at))}
</span>
</li>
))}
</ul>
)}
</article>
</div>
</section>
)
}
+107 -48
View File
@@ -1,22 +1,31 @@
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { apiFetch } from '@/lib/api'
import { apiFetch, apiFetchBlob } from '@/lib/api'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import type { Invoice, WorkOrder } from '@/lib/types'
const TYPE_LABELS: Record<string, string> = { quote: 'Orçamento', invoice: 'Fatura' }
const STATUS_LABELS: Record<string, string> = {
quote: 'Orçamento',
open: 'Aberta',
in_progress: 'Em Curso',
completed: 'Concluída',
invoiced: 'Faturada',
cancelled: 'Cancelada',
}
export default function InvoicesPage() {
const qc = useQueryClient()
const [showGenerate, setShowGenerate] = useState(false)
const [selectedWO, setSelectedWO] = useState('')
const [docType, setDocType] = useState<'quote' | 'invoice'>('quote')
const [viewError, setViewError] = useState('')
const { data: invoices = [], isLoading } = useQuery<Invoice[]>({
queryKey: ['invoices'],
queryFn: () => apiFetch<Invoice[]>('/invoices'),
})
const quoteDocs = invoices.filter((i) => i.type === 'quote')
const invoiceDocs = invoices.filter((i) => i.type === 'invoice')
const { data: workOrders = [] } = useQuery<WorkOrder[]>({
queryKey: ['work-orders-for-invoice'],
@@ -25,8 +34,9 @@ export default function InvoicesPage() {
})
const eligibleWOs = workOrders.filter((wo) =>
wo.status === 'open' || wo.status === 'in_progress' || wo.status === 'completed'
wo.status === 'quote' || wo.status === 'open' || wo.status === 'in_progress' || wo.status === 'completed'
)
const eligibleSorted = [...eligibleWOs].sort((a, b) => b.number - a.number)
const generate = useMutation({
mutationFn: () =>
@@ -42,14 +52,28 @@ export default function InvoicesPage() {
},
})
async function openPDF(inv: Invoice) {
setViewError('')
try {
const blob = await apiFetchBlob(`/invoices/${inv.id}/pdf`)
const url = URL.createObjectURL(blob)
window.open(url, '_blank', 'noopener,noreferrer')
setTimeout(() => URL.revokeObjectURL(url), 60_000)
} catch (err) {
setViewError((err as Error).message)
}
}
return (
<div>
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold text-white">Faturação</h1>
<p className="text-slate-400 text-sm mt-0.5">{invoices.length} documentos</p>
<h1 className="text-2xl font-bold text-white">Transações</h1>
<p className="text-slate-400 text-sm mt-0.5">
{invoices.length} documentos emitidos (orçamentos e faturas)
</p>
</div>
<Button onClick={() => setShowGenerate(true)}>Gerar Documento</Button>
<Button onClick={() => setShowGenerate(true)}>Nova Transação</Button>
</div>
{showGenerate && (
@@ -75,9 +99,9 @@ export default function InvoicesPage() {
className="w-full rounded-md border border-slate-600 bg-slate-900 text-white px-3 py-2 text-sm"
>
<option value=""> Seleccionar OT </option>
{eligibleWOs.map((wo) => (
{eligibleSorted.map((wo) => (
<option key={wo.id} value={wo.id}>
#{wo.number} ({wo.status})
#{wo.number} ({STATUS_LABELS[wo.status] ?? wo.status})
</option>
))}
</select>
@@ -100,50 +124,85 @@ export default function InvoicesPage() {
</div>
)}
{viewError && (
<p className="mb-4 text-red-400 text-sm">{viewError}</p>
)}
{isLoading ? (
<p className="text-slate-400">A carregar...</p>
) : invoices.length === 0 ? (
<p className="text-slate-500 text-sm">Nenhum documento gerado.</p>
) : (
<div className="rounded-lg border border-slate-700 overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-slate-800">
<tr>
<th className="text-left px-4 py-3 text-slate-400 font-medium"></th>
<th className="text-left px-4 py-3 text-slate-400 font-medium">Tipo</th>
<th className="text-left px-4 py-3 text-slate-400 font-medium">OT</th>
<th className="text-left px-4 py-3 text-slate-400 font-medium">Emitida</th>
<th className="px-4 py-3"></th>
</tr>
</thead>
<tbody>
{invoices.map((inv) => (
<tr key={inv.id} className="border-t border-slate-700 hover:bg-slate-800/50">
<td className="px-4 py-3 text-white font-mono">#{inv.number}</td>
<td className="px-4 py-3">
<Badge variant={inv.type === 'invoice' ? 'default' : 'secondary'}>
{TYPE_LABELS[inv.type]}
</Badge>
</td>
<td className="px-4 py-3 text-slate-400 font-mono text-xs">
{inv.work_order_id.slice(0, 8)}
</td>
<td className="px-4 py-3 text-slate-400">
{new Intl.DateTimeFormat('pt-PT').format(new Date(inv.issued_at))}
</td>
<td className="px-4 py-3 text-right">
<Button
size="sm"
variant="outline"
onClick={() => window.open(`/api/v1/invoices/${inv.id}/pdf`, '_blank')}
>
Descarregar PDF
</Button>
</td>
</tr>
))}
</tbody>
</table>
<div className="grid gap-6 xl:grid-cols-2">
<div className="rounded-lg border border-slate-700 overflow-hidden">
<div className="bg-slate-800 px-4 py-3 flex items-center justify-between">
<h2 className="text-sm font-semibold text-white">Orçamentos</h2>
<Badge variant="secondary">{quoteDocs.length}</Badge>
</div>
{quoteDocs.length === 0 ? (
<p className="px-4 py-5 text-slate-500 text-sm">Sem orçamentos gerados.</p>
) : (
<table className="w-full text-sm">
<thead className="bg-slate-900/80">
<tr>
<th className="text-left px-4 py-2 text-slate-400 font-medium"></th>
<th className="text-left px-4 py-2 text-slate-400 font-medium">OT</th>
<th className="text-left px-4 py-2 text-slate-400 font-medium">Emitida</th>
<th className="px-4 py-2"></th>
</tr>
</thead>
<tbody>
{quoteDocs.map((inv) => (
<tr key={inv.id} className="border-t border-slate-700 hover:bg-slate-800/50">
<td className="px-4 py-2 text-white font-mono">#{inv.number}</td>
<td className="px-4 py-2 text-slate-400 font-mono text-xs">{inv.work_order_id.slice(0, 8)}</td>
<td className="px-4 py-2 text-slate-400">
{new Intl.DateTimeFormat('pt-PT').format(new Date(inv.issued_at))}
</td>
<td className="px-4 py-2 text-right">
<Button size="sm" variant="outline" onClick={() => openPDF(inv)}>Ver PDF</Button>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
<div className="rounded-lg border border-slate-700 overflow-hidden">
<div className="bg-slate-800 px-4 py-3 flex items-center justify-between">
<h2 className="text-sm font-semibold text-white">Faturas</h2>
<Badge>{invoiceDocs.length}</Badge>
</div>
{invoiceDocs.length === 0 ? (
<p className="px-4 py-5 text-slate-500 text-sm">Sem faturas geradas.</p>
) : (
<table className="w-full text-sm">
<thead className="bg-slate-900/80">
<tr>
<th className="text-left px-4 py-2 text-slate-400 font-medium"></th>
<th className="text-left px-4 py-2 text-slate-400 font-medium">OT</th>
<th className="text-left px-4 py-2 text-slate-400 font-medium">Emitida</th>
<th className="px-4 py-2"></th>
</tr>
</thead>
<tbody>
{invoiceDocs.map((inv) => (
<tr key={inv.id} className="border-t border-slate-700 hover:bg-slate-800/50">
<td className="px-4 py-2 text-white font-mono">#{inv.number}</td>
<td className="px-4 py-2 text-slate-400 font-mono text-xs">{inv.work_order_id.slice(0, 8)}</td>
<td className="px-4 py-2 text-slate-400">
{new Intl.DateTimeFormat('pt-PT').format(new Date(inv.issued_at))}
</td>
<td className="px-4 py-2 text-right">
<Button size="sm" variant="outline" onClick={() => openPDF(inv)}>Ver PDF</Button>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
)}
</div>
+22
View File
@@ -14,6 +14,7 @@ type FormData = {
company_iban: string
company_phone: string
company_email: string
company_logo: string
}
const defaultValues: FormData = {
@@ -23,6 +24,7 @@ const defaultValues: FormData = {
company_iban: '',
company_phone: '',
company_email: '',
company_logo: '',
}
export default function SettingsPage() {
@@ -44,6 +46,7 @@ export default function SettingsPage() {
company_iban: settings['company_iban'] ?? '',
company_phone: settings['company_phone'] ?? '',
company_email: settings['company_email'] ?? '',
company_logo: settings['company_logo'] ?? '',
})
}
}, [settings, reset])
@@ -101,6 +104,25 @@ export default function SettingsPage() {
className="bg-slate-900 border-slate-600 text-white" />
</div>
</div>
<div className="space-y-1">
<Label htmlFor="company_logo">Logotipo</Label>
<Input id="company_logo" {...register('company_logo')}
placeholder="https://.../logo.png ou data:image/png;base64,..."
className="bg-slate-900 border-slate-600 text-white" />
<p className="text-xs text-slate-500">
Este logotipo será impresso em Orçamentos e Faturas.
</p>
{settings?.company_logo && (
<div className="mt-2 rounded-md border border-slate-700 p-3 bg-slate-900/50">
<p className="text-xs text-slate-400 mb-2">Pré-visualização:</p>
<img
src={settings.company_logo}
alt="Logotipo da oficina"
className="h-12 object-contain bg-white/90 p-1 rounded"
/>
</div>
)}
</div>
{save.error && (
<p className="text-red-400 text-sm">{(save.error as Error).message}</p>
@@ -27,6 +27,7 @@ const CATEGORY_LABEL: Record<string, string> = {
}
const STATUS_LABELS: Record<string, string> = {
quote: 'Orçamento',
open: 'Aberta',
in_progress: 'Em Curso',
completed: 'Concluída',
@@ -35,6 +36,7 @@ const STATUS_LABELS: Record<string, string> = {
}
const TRANSITIONS: Record<string, string[]> = {
quote: ['open', 'cancelled'],
open: ['in_progress', 'cancelled'],
in_progress: ['completed', 'cancelled'],
completed: ['invoiced', 'cancelled'],
+3 -1
View File
@@ -12,6 +12,7 @@ import { Label } from '@/components/ui/label'
import type { WorkOrder, Client, Vehicle } from '@/lib/types'
const STATUS_LABELS: Record<string, string> = {
quote: 'Orçamento',
open: 'Aberta',
in_progress: 'Em Curso',
completed: 'Concluída',
@@ -20,6 +21,7 @@ const STATUS_LABELS: Record<string, string> = {
}
const STATUS_VARIANT: Record<string, 'default' | 'secondary' | 'destructive'> = {
quote: 'secondary',
open: 'secondary',
in_progress: 'default',
completed: 'default',
@@ -85,7 +87,7 @@ export default function WorkOrdersPage() {
</div>
<div className="flex gap-2 mb-4">
{['', 'open', 'in_progress', 'completed', 'invoiced', 'cancelled'].map((s) => (
{['', 'quote', 'open', 'in_progress', 'completed', 'invoiced', 'cancelled'].map((s) => (
<button
key={s}
onClick={() => setStatusFilter(s)}