From 8231bba3f939cfc607975375b2da22d9cb9a699e Mon Sep 17 00:00:00 2001 From: Luciano Milani Date: Thu, 2 Jul 2026 13:42:47 +0100 Subject: [PATCH] feat: atualizar fluxo OT, dashboard, transacoes e PDFs --- backend/internal/invoice/handler.go | 3 +- backend/internal/invoice/repository.go | 6 +- backend/internal/settings/repository.go | 1 + backend/internal/workorder/repository.go | 1 + backend/internal/workorder/repository_test.go | 9 +- .../tenant/000001_create_tenant_schema.up.sql | 2 +- ...003_work_order_status_quote_first.down.sql | 13 + ...00003_work_order_status_quote_first.up.sql | 18 + backend/pkg/pdf/pdf.go | 123 ++++- frontend/src/components/layout/AppLayout.tsx | 2 +- frontend/src/lib/api.ts | 37 +- frontend/src/lib/types.ts | 2 +- frontend/src/pages/app/DashboardPage.tsx | 420 +++++++++++++++++- frontend/src/pages/app/InvoicesPage.tsx | 155 +++++-- frontend/src/pages/app/SettingsPage.tsx | 22 + .../src/pages/app/WorkOrderDetailPage.tsx | 2 + frontend/src/pages/app/WorkOrdersPage.tsx | 4 +- 17 files changed, 729 insertions(+), 91 deletions(-) create mode 100644 backend/migrations/tenant/000003_work_order_status_quote_first.down.sql create mode 100644 backend/migrations/tenant/000003_work_order_status_quote_first.up.sql diff --git a/backend/internal/invoice/handler.go b/backend/internal/invoice/handler.go index 7ce99e9..66fe4f0 100644 --- a/backend/internal/invoice/handler.go +++ b/backend/internal/invoice/handler.go @@ -105,7 +105,7 @@ func createInvoiceH() fiber.Handler { return fiber.NewError(500, "erro ao criar fatura") } - docType := "Orcamento" + docType := "Orçamento" prefix := "ORC" if b.Type == "invoice" { docType = "Fatura" @@ -121,6 +121,7 @@ func createInvoiceH() fiber.Handler { CompanyIBAN: sett["company_iban"], CompanyPhone: sett["company_phone"], CompanyEmail: sett["company_email"], + CompanyLogo: sett["company_logo"], DocType: docType, DocNumber: fmt.Sprintf("%s/%d/%04d", prefix, inv.IssuedAt.Year(), inv.Number), IssuedAt: inv.IssuedAt.Format("02/01/2006"), diff --git a/backend/internal/invoice/repository.go b/backend/internal/invoice/repository.go index 629e4b3..dda1425 100644 --- a/backend/internal/invoice/repository.go +++ b/backend/internal/invoice/repository.go @@ -21,7 +21,7 @@ type Invoice struct { func ListInvoices(ctx context.Context, conn *pgxpool.Conn) ([]*Invoice, error) { rows, err := conn.Query(ctx, - `SELECT id, work_order_id, type, number, COALESCE(pdf_path,''), issued_at, created_at + `SELECT id, work_order_id, type, number, COALESCE(pdf_path,''), issued_at, issued_at AS created_at FROM invoices ORDER BY issued_at DESC`) if err != nil { return nil, fmt.Errorf("invoice: list: %w", err) @@ -41,7 +41,7 @@ func ListInvoices(ctx context.Context, conn *pgxpool.Conn) ([]*Invoice, error) { func GetInvoice(ctx context.Context, conn *pgxpool.Conn, id string) (*Invoice, error) { row := conn.QueryRow(ctx, - `SELECT id, work_order_id, type, number, COALESCE(pdf_path,''), issued_at, created_at + `SELECT id, work_order_id, type, number, COALESCE(pdf_path,''), issued_at, issued_at AS created_at FROM invoices WHERE id = $1`, id) var inv Invoice err := row.Scan(&inv.ID, &inv.WorkOrderID, &inv.Type, &inv.Number, @@ -59,7 +59,7 @@ func CreateInvoice(ctx context.Context, conn *pgxpool.Conn, woID, docType string row := conn.QueryRow(ctx, `INSERT INTO invoices (work_order_id, type, issued_at) VALUES ($1, $2, NOW()) - RETURNING id, work_order_id, type, number, COALESCE(pdf_path,''), issued_at, created_at`, + RETURNING id, work_order_id, type, number, COALESCE(pdf_path,''), issued_at, issued_at AS created_at`, woID, docType) var inv Invoice if err := row.Scan(&inv.ID, &inv.WorkOrderID, &inv.Type, &inv.Number, diff --git a/backend/internal/settings/repository.go b/backend/internal/settings/repository.go index 6e0bc21..2b75459 100644 --- a/backend/internal/settings/repository.go +++ b/backend/internal/settings/repository.go @@ -14,6 +14,7 @@ var AllowedKeys = map[string]bool{ "company_iban": true, "company_phone": true, "company_email": true, + "company_logo": true, } func GetSettings(ctx context.Context, conn *pgxpool.Conn) (map[string]string, error) { diff --git a/backend/internal/workorder/repository.go b/backend/internal/workorder/repository.go index 40ef88c..61a1c95 100644 --- a/backend/internal/workorder/repository.go +++ b/backend/internal/workorder/repository.go @@ -48,6 +48,7 @@ type WorkOrderDetail struct { } var allowedTransitions = map[string][]string{ + "quote": {"open", "cancelled"}, "open": {"in_progress", "cancelled"}, "in_progress": {"completed", "cancelled"}, "completed": {"invoiced", "cancelled"}, diff --git a/backend/internal/workorder/repository_test.go b/backend/internal/workorder/repository_test.go index 68d5235..1a8d267 100644 --- a/backend/internal/workorder/repository_test.go +++ b/backend/internal/workorder/repository_test.go @@ -35,7 +35,7 @@ func TestWorkOrderCRUD(t *testing.T) { wo, err := workorder.CreateWorkOrder(ctx, conn, "", "", "", "") require.NoError(t, err) assert.NotEmpty(t, wo.ID) - assert.Equal(t, "open", wo.Status) + assert.Equal(t, "quote", wo.Status) list, err := workorder.ListWorkOrders(ctx, conn, "") require.NoError(t, err) @@ -55,9 +55,9 @@ func TestWorkOrderTransition(t *testing.T) { wo, err := workorder.CreateWorkOrder(ctx, conn, "", "", "", "") require.NoError(t, err) - wo2, err := workorder.TransitionStatus(ctx, conn, wo.ID, "in_progress", "") + wo2, err := workorder.TransitionStatus(ctx, conn, wo.ID, "open", "") require.NoError(t, err) - assert.Equal(t, "in_progress", wo2.Status) + assert.Equal(t, "open", wo2.Status) _, err = workorder.TransitionStatus(ctx, conn, wo.ID, "cancelled_invalid", "") assert.Error(t, err, "invalid transition should error") @@ -69,6 +69,9 @@ func TestAllowedTransitions(t *testing.T) { to string valid bool }{ + {"quote", "open", true}, + {"quote", "cancelled", true}, + {"quote", "in_progress", false}, {"open", "in_progress", true}, {"open", "cancelled", true}, {"open", "completed", false}, diff --git a/backend/migrations/tenant/000001_create_tenant_schema.up.sql b/backend/migrations/tenant/000001_create_tenant_schema.up.sql index fb5917e..88c24ba 100644 --- a/backend/migrations/tenant/000001_create_tenant_schema.up.sql +++ b/backend/migrations/tenant/000001_create_tenant_schema.up.sql @@ -63,7 +63,7 @@ CREATE TABLE IF NOT EXISTS work_orders ( number SERIAL UNIQUE, client_id UUID REFERENCES clients(id) ON DELETE SET NULL, vehicle_id UUID REFERENCES vehicles(id) ON DELETE SET NULL, - status TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open', 'in_progress', 'completed', 'invoiced', 'cancelled')), + status TEXT NOT NULL DEFAULT 'quote' CHECK (status IN ('quote', 'open', 'in_progress', 'completed', 'invoiced', 'cancelled')), internal_notes TEXT, client_notes TEXT, created_by UUID REFERENCES users(id) ON DELETE SET NULL, diff --git a/backend/migrations/tenant/000003_work_order_status_quote_first.down.sql b/backend/migrations/tenant/000003_work_order_status_quote_first.down.sql new file mode 100644 index 0000000..af05e6c --- /dev/null +++ b/backend/migrations/tenant/000003_work_order_status_quote_first.down.sql @@ -0,0 +1,13 @@ +ALTER TABLE work_orders + ALTER COLUMN status SET DEFAULT 'open'; + +ALTER TABLE work_orders + DROP CONSTRAINT IF EXISTS work_orders_status_check; + +ALTER TABLE work_orders + ADD CONSTRAINT work_orders_status_check + CHECK (status IN ('open', 'in_progress', 'completed', 'invoiced', 'cancelled')); + +UPDATE work_orders +SET status = 'open' +WHERE status = 'quote'; diff --git a/backend/migrations/tenant/000003_work_order_status_quote_first.up.sql b/backend/migrations/tenant/000003_work_order_status_quote_first.up.sql new file mode 100644 index 0000000..6253b3f --- /dev/null +++ b/backend/migrations/tenant/000003_work_order_status_quote_first.up.sql @@ -0,0 +1,18 @@ +ALTER TABLE work_orders + ALTER COLUMN status SET DEFAULT 'quote'; + +ALTER TABLE work_orders + DROP CONSTRAINT IF EXISTS work_orders_status_check; + +ALTER TABLE work_orders + ADD CONSTRAINT work_orders_status_check + CHECK (status IN ('quote', 'open', 'in_progress', 'completed', 'invoiced', 'cancelled')); + +UPDATE work_orders +SET status = 'quote' +WHERE status = 'open' + AND NOT EXISTS ( + SELECT 1 + FROM wo_status_log l + WHERE l.work_order_id = work_orders.id + ); diff --git a/backend/pkg/pdf/pdf.go b/backend/pkg/pdf/pdf.go index 4753065..74e4612 100644 --- a/backend/pkg/pdf/pdf.go +++ b/backend/pkg/pdf/pdf.go @@ -1,9 +1,14 @@ package pdf import ( + "bytes" + "encoding/base64" "fmt" + "net/http" "os" "path/filepath" + "strings" + "time" "github.com/go-pdf/fpdf" ) @@ -15,6 +20,7 @@ type DocMeta struct { CompanyIBAN string CompanyPhone string CompanyEmail string + CompanyLogo string DocType string // "Fatura" or "Orçamento" DocNumber string // e.g. "FAT/2026/001" IssuedAt string // e.g. "30/06/2026" @@ -39,44 +45,52 @@ func Generate(meta DocMeta, items []LineItem, staffTotal float64, outPath string f := fpdf.New("P", "mm", "A4", "") f.AddPage() f.SetMargins(15, 15, 15) + tr := f.UnicodeTranslatorFromDescriptor("") + t := func(s string) string { return tr(s) } + + hasLogo := addLogo(f, meta.CompanyLogo) + leftW := 120.0 + if hasLogo { + leftW = 100.0 + } // Header f.SetFont("Helvetica", "B", 18) - f.CellFormat(120, 10, meta.CompanyName, "", 0, "L", false, 0, "") + f.CellFormat(leftW, 10, t(meta.CompanyName), "", 0, "L", false, 0, "") f.SetFont("Helvetica", "B", 14) - f.CellFormat(60, 10, meta.DocType, "", 1, "R", false, 0, "") + f.CellFormat(60, 10, t(meta.DocType), "", 1, "R", false, 0, "") f.SetFont("Helvetica", "", 9) if meta.CompanyNIF != "" { - f.CellFormat(120, 5, "NIF: "+meta.CompanyNIF, "", 0, "L", false, 0, "") + f.CellFormat(leftW, 5, t("NIF: ")+meta.CompanyNIF, "", 0, "L", false, 0, "") } else { - f.CellFormat(120, 5, "", "", 0, "L", false, 0, "") + f.CellFormat(leftW, 5, "", "", 0, "L", false, 0, "") } f.SetFont("Helvetica", "", 11) - f.CellFormat(60, 5, meta.DocNumber, "", 1, "R", false, 0, "") + f.CellFormat(60, 5, t(meta.DocNumber), "", 1, "R", false, 0, "") f.SetFont("Helvetica", "", 9) if meta.CompanyAddress != "" { - f.MultiCell(120, 5, meta.CompanyAddress, "", "L", false) + f.MultiCell(leftW, 5, t(meta.CompanyAddress), "", "L", false) } f.Ln(3) curY := f.GetY() f.SetXY(135, curY-3) - f.CellFormat(60, 5, "Data: "+meta.IssuedAt, "", 1, "R", false, 0, "") + f.CellFormat(60, 5, t("Data: ")+meta.IssuedAt, "", 1, "R", false, 0, "") f.SetY(curY + 3) f.Ln(3) // Client block if meta.ClientName != "" { f.SetFont("Helvetica", "B", 9) - f.CellFormat(180, 5, "Cliente", "", 1, "L", false, 0, "") + f.CellFormat(180, 5, t("Cliente"), "", 1, "L", false, 0, "") f.SetFont("Helvetica", "", 9) - f.CellFormat(180, 5, meta.ClientName, "", 1, "L", false, 0, "") + f.CellFormat(180, 5, t(meta.ClientName), "", 1, "L", false, 0, "") if meta.ClientNIF != "" { - f.CellFormat(180, 5, "NIF: "+meta.ClientNIF, "", 1, "L", false, 0, "") + f.CellFormat(180, 5, t("NIF: ")+meta.ClientNIF, "", 1, "L", false, 0, "") } if meta.VehiclePlate != "" { - f.CellFormat(180, 5, "Matrícula: "+meta.VehiclePlate, "", 1, "L", false, 0, "") + f.CellFormat(180, 5, t("Matrícula: ")+meta.VehiclePlate, "", 1, "L", false, 0, "") } f.Ln(4) } @@ -85,11 +99,11 @@ func Generate(meta DocMeta, items []LineItem, staffTotal float64, outPath string f.SetFillColor(50, 50, 50) f.SetTextColor(255, 255, 255) f.SetFont("Helvetica", "B", 9) - f.CellFormat(90, 7, "Descricao", "1", 0, "L", true, 0, "") - f.CellFormat(20, 7, "Qtd.", "1", 0, "C", true, 0, "") - f.CellFormat(25, 7, "Preco Unit.", "1", 0, "R", true, 0, "") - f.CellFormat(20, 7, "Desc.%", "1", 0, "C", true, 0, "") - f.CellFormat(25, 7, "Total", "1", 1, "R", true, 0, "") + f.CellFormat(90, 7, t("Descrição"), "1", 0, "L", true, 0, "") + f.CellFormat(20, 7, t("Qtd."), "1", 0, "C", true, 0, "") + f.CellFormat(25, 7, t("Preço Unit."), "1", 0, "R", true, 0, "") + f.CellFormat(20, 7, t("Desc.%"), "1", 0, "C", true, 0, "") + f.CellFormat(25, 7, t("Total"), "1", 1, "R", true, 0, "") // Table rows f.SetFillColor(245, 245, 245) @@ -97,7 +111,7 @@ func Generate(meta DocMeta, items []LineItem, staffTotal float64, outPath string f.SetFont("Helvetica", "", 9) fill := false for _, item := range items { - f.CellFormat(90, 6, item.Description, "1", 0, "L", fill, 0, "") + f.CellFormat(90, 6, t(item.Description), "1", 0, "L", fill, 0, "") f.CellFormat(20, 6, fmt.Sprintf("%.2f", item.Qty), "1", 0, "C", fill, 0, "") f.CellFormat(25, 6, fmt.Sprintf("%.2f EUR", item.UnitPrice), "1", 0, "R", fill, 0, "") f.CellFormat(20, 6, fmt.Sprintf("%.0f%%", item.DiscountPct), "1", 0, "C", fill, 0, "") @@ -115,21 +129,88 @@ func Generate(meta DocMeta, items []LineItem, staffTotal float64, outPath string f.Ln(3) f.SetFont("Helvetica", "", 9) if staffTotal > 0 { - f.CellFormat(155, 6, "Subtotal pecas/servicos", "", 0, "R", false, 0, "") + f.CellFormat(155, 6, t("Subtotal peças/serviços"), "", 0, "R", false, 0, "") f.CellFormat(25, 6, fmt.Sprintf("%.2f EUR", subtotal), "1", 1, "R", false, 0, "") - f.CellFormat(155, 6, "Mao de obra", "", 0, "R", false, 0, "") + f.CellFormat(155, 6, t("Mão de obra"), "", 0, "R", false, 0, "") f.CellFormat(25, 6, fmt.Sprintf("%.2f EUR", staffTotal), "1", 1, "R", false, 0, "") } f.SetFont("Helvetica", "B", 10) - f.CellFormat(155, 7, "TOTAL", "", 0, "R", false, 0, "") + f.CellFormat(155, 7, t("TOTAL"), "", 0, "R", false, 0, "") f.CellFormat(25, 7, fmt.Sprintf("%.2f EUR", grandTotal), "1", 1, "R", false, 0, "") // IBAN footer if meta.CompanyIBAN != "" { f.Ln(8) f.SetFont("Helvetica", "", 8) - f.CellFormat(180, 5, "IBAN: "+meta.CompanyIBAN, "", 1, "C", false, 0, "") + f.CellFormat(180, 5, t("IBAN: ")+meta.CompanyIBAN, "", 1, "C", false, 0, "") } return f.OutputFileAndClose(outPath) } + +func addLogo(f *fpdf.Fpdf, logo string) bool { + logo = strings.TrimSpace(logo) + if logo == "" { + return false + } + + if strings.HasPrefix(logo, "data:image/") { + comma := strings.Index(logo, ",") + if comma <= 0 { + return false + } + meta := logo[:comma] + payload := logo[comma+1:] + if !strings.Contains(meta, ";base64") { + return false + } + raw, err := base64.StdEncoding.DecodeString(payload) + if err != nil { + return false + } + imgType := "PNG" + if strings.Contains(meta, "image/jpeg") || strings.Contains(meta, "image/jpg") { + imgType = "JPG" + } + opts := fpdf.ImageOptions{ImageType: imgType, ReadDpi: true} + f.RegisterImageOptionsReader("company_logo", opts, bytes.NewReader(raw)) + f.ImageOptions("company_logo", 15, 14, 26, 0, false, opts, 0, "") + f.SetX(45) + return true + } + + if strings.HasPrefix(logo, "http://") || strings.HasPrefix(logo, "https://") { + client := http.Client{Timeout: 5 * time.Second} + resp, err := client.Get(logo) + if err != nil { + return false + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return false + } + buf := new(bytes.Buffer) + if _, err := buf.ReadFrom(resp.Body); err != nil { + return false + } + imgType := "PNG" + ct := strings.ToLower(resp.Header.Get("Content-Type")) + if strings.Contains(ct, "jpeg") || strings.Contains(ct, "jpg") { + imgType = "JPG" + } + opts := fpdf.ImageOptions{ImageType: imgType, ReadDpi: true} + f.RegisterImageOptionsReader("company_logo", opts, bytes.NewReader(buf.Bytes())) + f.ImageOptions("company_logo", 15, 14, 26, 0, false, opts, 0, "") + f.SetX(45) + return true + } + + if _, err := os.Stat(logo); err == nil { + opts := fpdf.ImageOptions{ReadDpi: true} + f.ImageOptions(logo, 15, 14, 26, 0, false, opts, 0, "") + f.SetX(45) + return true + } + + return false +} diff --git a/frontend/src/components/layout/AppLayout.tsx b/frontend/src/components/layout/AppLayout.tsx index f363611..a21f7fa 100644 --- a/frontend/src/components/layout/AppLayout.tsx +++ b/frontend/src/components/layout/AppLayout.tsx @@ -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' }, ] diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index bbc8900..0ec5357 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -27,17 +27,12 @@ async function refreshAccessToken(): Promise { } } -export async function apiFetch( - path: string, - options: RequestInit = {} -): Promise { +async function authorizedFetch(path: string, options: RequestInit = {}): Promise { const { accessToken, clearAuth } = useAuthStore.getState() const headers: Record = { - 'Content-Type': 'application/json', ...(options.headers as Record), } - if (accessToken) { headers['Authorization'] = `Bearer ${accessToken}` } @@ -63,6 +58,21 @@ export async function apiFetch( } } + return res +} + +export async function apiFetch( + path: string, + options: RequestInit = {} +): Promise { + const res = await authorizedFetch(path, { + ...options, + headers: { + 'Content-Type': 'application/json', + ...(options.headers as Record), + }, + }) + if (res.status === 204) { return undefined as T } @@ -75,3 +85,18 @@ export async function apiFetch( return json.data as T } + +export async function apiFetchBlob(path: string, options: RequestInit = {}): Promise { + 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() +} diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index f2b4737..85c0d9e 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -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 diff --git a/frontend/src/pages/app/DashboardPage.tsx b/frontend/src/pages/app/DashboardPage.tsx index f4b9990..3363a32 100644 --- a/frontend/src/pages/app/DashboardPage.tsx +++ b/frontend/src/pages/app/DashboardPage.tsx @@ -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 = { + 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 ( -
-

Dashboard

-

Bem-vindo ao TechXCar — implementado no Plano 5

-
+
+

{title}

+

+ {value} +

+

{subtitle}

+
+ ) +} + +export default function DashboardPage() { + 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) + + 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 + 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 ( +
+
+
+
+

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 ? 'text-emerald-300' : '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.periodOrdersCount, 1) + const width = Math.max((count / total) * 100, count > 0 ? 6 : 0) + return ( +
+
+ {STATUS_LABEL[status]} + {count} +
+
+
+
+
+ ) + })} +
+
+ +
+

Faturação e Prioridades

+
+
+

Documentos emitidos no período

+

{metrics.periodDocsCount}

+
+
+

Ação recomendada

+

+ {metrics.byStatus.completed > 0 + ? `${metrics.byStatus.completed} OT(s) concluída(s) pronta(s) para faturar.` + : 'Sem OTs concluídas pendentes de faturação.'} +

+
+
+

Em orçamento

+

+ {metrics.byStatus.quote > 0 + ? `${metrics.byStatus.quote} OT(s) em orçamento aguardam aprovação.` + : '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

+ + Abrir OTs + +
+ {metrics.recentOrders.length === 0 ? ( +

Ainda não existem ordens de trabalho.

+ ) : ( +
    + {metrics.recentOrders.map((wo) => ( +
  • +
    +

    OT #{wo.number}

    +

    {STATUS_LABEL[wo.status]}

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

Documentos Recentes

+ + Abrir Faturação + +
+ {metrics.recentDocs.length === 0 ? ( +

Sem documentos emitidos.

+ ) : ( +
    + {metrics.recentDocs.map((doc) => ( +
  • +
    +

    + {doc.type === 'invoice' ? 'Fatura' : 'Orçamento'} #{doc.number} +

    +

    OT {doc.work_order_id.slice(0, 8)}...

    +
    + + {new Intl.DateTimeFormat('pt-PT', { dateStyle: 'short' }).format(new Date(doc.issued_at))} + +
  • + ))} +
+ )} +
+
+
) } diff --git a/frontend/src/pages/app/InvoicesPage.tsx b/frontend/src/pages/app/InvoicesPage.tsx index 6afb867..ef2ac52 100644 --- a/frontend/src/pages/app/InvoicesPage.tsx +++ b/frontend/src/pages/app/InvoicesPage.tsx @@ -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 = { quote: 'Orçamento', invoice: 'Fatura' } +const STATUS_LABELS: Record = { + 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({ queryKey: ['invoices'], queryFn: () => apiFetch('/invoices'), }) + const quoteDocs = invoices.filter((i) => i.type === 'quote') + const invoiceDocs = invoices.filter((i) => i.type === 'invoice') const { data: workOrders = [] } = useQuery({ 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 (
-

Faturação

-

{invoices.length} documentos

+

Transações

+

+ {invoices.length} documentos emitidos (orçamentos e faturas) +

- +
{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" > - {eligibleWOs.map((wo) => ( + {eligibleSorted.map((wo) => ( ))} @@ -100,50 +124,85 @@ export default function InvoicesPage() {
)} + {viewError && ( +

{viewError}

+ )} + {isLoading ? (

A carregar...

) : invoices.length === 0 ? (

Nenhum documento gerado.

) : ( -
- - - - - - - - - - - - {invoices.map((inv) => ( - - - - - - - - ))} - -
TipoOTEmitida
#{inv.number} - - {TYPE_LABELS[inv.type]} - - - {inv.work_order_id.slice(0, 8)}… - - {new Intl.DateTimeFormat('pt-PT').format(new Date(inv.issued_at))} - - -
+
+
+
+

Orçamentos

+ {quoteDocs.length} +
+ {quoteDocs.length === 0 ? ( +

Sem orçamentos gerados.

+ ) : ( + + + + + + + + + + + {quoteDocs.map((inv) => ( + + + + + + + ))} + +
OTEmitida
#{inv.number}{inv.work_order_id.slice(0, 8)}… + {new Intl.DateTimeFormat('pt-PT').format(new Date(inv.issued_at))} + + +
+ )} +
+ +
+
+

Faturas

+ {invoiceDocs.length} +
+ {invoiceDocs.length === 0 ? ( +

Sem faturas geradas.

+ ) : ( + + + + + + + + + + + {invoiceDocs.map((inv) => ( + + + + + + + ))} + +
OTEmitida
#{inv.number}{inv.work_order_id.slice(0, 8)}… + {new Intl.DateTimeFormat('pt-PT').format(new Date(inv.issued_at))} + + +
+ )} +
)}
diff --git a/frontend/src/pages/app/SettingsPage.tsx b/frontend/src/pages/app/SettingsPage.tsx index 54903de..0af24ef 100644 --- a/frontend/src/pages/app/SettingsPage.tsx +++ b/frontend/src/pages/app/SettingsPage.tsx @@ -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" /> +
+ + +

+ Este logotipo será impresso em Orçamentos e Faturas. +

+ {settings?.company_logo && ( +
+

Pré-visualização:

+ Logotipo da oficina +
+ )} +
{save.error && (

{(save.error as Error).message}

diff --git a/frontend/src/pages/app/WorkOrderDetailPage.tsx b/frontend/src/pages/app/WorkOrderDetailPage.tsx index 8e40e20..f30128d 100644 --- a/frontend/src/pages/app/WorkOrderDetailPage.tsx +++ b/frontend/src/pages/app/WorkOrderDetailPage.tsx @@ -27,6 +27,7 @@ const CATEGORY_LABEL: Record = { } const STATUS_LABELS: Record = { + quote: 'Orçamento', open: 'Aberta', in_progress: 'Em Curso', completed: 'Concluída', @@ -35,6 +36,7 @@ const STATUS_LABELS: Record = { } const TRANSITIONS: Record = { + quote: ['open', 'cancelled'], open: ['in_progress', 'cancelled'], in_progress: ['completed', 'cancelled'], completed: ['invoiced', 'cancelled'], diff --git a/frontend/src/pages/app/WorkOrdersPage.tsx b/frontend/src/pages/app/WorkOrdersPage.tsx index 9613e09..c4b6719 100644 --- a/frontend/src/pages/app/WorkOrdersPage.tsx +++ b/frontend/src/pages/app/WorkOrdersPage.tsx @@ -12,6 +12,7 @@ import { Label } from '@/components/ui/label' import type { WorkOrder, Client, Vehicle } from '@/lib/types' const STATUS_LABELS: Record = { + quote: 'Orçamento', open: 'Aberta', in_progress: 'Em Curso', completed: 'Concluída', @@ -20,6 +21,7 @@ const STATUS_LABELS: Record = { } const STATUS_VARIANT: Record = { + quote: 'secondary', open: 'secondary', in_progress: 'default', completed: 'default', @@ -85,7 +87,7 @@ export default function WorkOrdersPage() {
- {['', 'open', 'in_progress', 'completed', 'invoiced', 'cancelled'].map((s) => ( + {['', 'quote', 'open', 'in_progress', 'completed', 'invoiced', 'cancelled'].map((s) => (