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
+2 -1
View File
@@ -105,7 +105,7 @@ func createInvoiceH() fiber.Handler {
return fiber.NewError(500, "erro ao criar fatura") return fiber.NewError(500, "erro ao criar fatura")
} }
docType := "Orcamento" docType := "Orçamento"
prefix := "ORC" prefix := "ORC"
if b.Type == "invoice" { if b.Type == "invoice" {
docType = "Fatura" docType = "Fatura"
@@ -121,6 +121,7 @@ func createInvoiceH() fiber.Handler {
CompanyIBAN: sett["company_iban"], CompanyIBAN: sett["company_iban"],
CompanyPhone: sett["company_phone"], CompanyPhone: sett["company_phone"],
CompanyEmail: sett["company_email"], CompanyEmail: sett["company_email"],
CompanyLogo: sett["company_logo"],
DocType: docType, DocType: docType,
DocNumber: fmt.Sprintf("%s/%d/%04d", prefix, inv.IssuedAt.Year(), inv.Number), DocNumber: fmt.Sprintf("%s/%d/%04d", prefix, inv.IssuedAt.Year(), inv.Number),
IssuedAt: inv.IssuedAt.Format("02/01/2006"), IssuedAt: inv.IssuedAt.Format("02/01/2006"),
+3 -3
View File
@@ -21,7 +21,7 @@ type Invoice struct {
func ListInvoices(ctx context.Context, conn *pgxpool.Conn) ([]*Invoice, error) { func ListInvoices(ctx context.Context, conn *pgxpool.Conn) ([]*Invoice, error) {
rows, err := conn.Query(ctx, 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`) FROM invoices ORDER BY issued_at DESC`)
if err != nil { if err != nil {
return nil, fmt.Errorf("invoice: list: %w", err) 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) { func GetInvoice(ctx context.Context, conn *pgxpool.Conn, id string) (*Invoice, error) {
row := conn.QueryRow(ctx, 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) FROM invoices WHERE id = $1`, id)
var inv Invoice var inv Invoice
err := row.Scan(&inv.ID, &inv.WorkOrderID, &inv.Type, &inv.Number, 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, row := conn.QueryRow(ctx,
`INSERT INTO invoices (work_order_id, type, issued_at) `INSERT INTO invoices (work_order_id, type, issued_at)
VALUES ($1, $2, NOW()) 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) woID, docType)
var inv Invoice var inv Invoice
if err := row.Scan(&inv.ID, &inv.WorkOrderID, &inv.Type, &inv.Number, if err := row.Scan(&inv.ID, &inv.WorkOrderID, &inv.Type, &inv.Number,
+1
View File
@@ -14,6 +14,7 @@ var AllowedKeys = map[string]bool{
"company_iban": true, "company_iban": true,
"company_phone": true, "company_phone": true,
"company_email": true, "company_email": true,
"company_logo": true,
} }
func GetSettings(ctx context.Context, conn *pgxpool.Conn) (map[string]string, error) { func GetSettings(ctx context.Context, conn *pgxpool.Conn) (map[string]string, error) {
+1
View File
@@ -48,6 +48,7 @@ type WorkOrderDetail struct {
} }
var allowedTransitions = map[string][]string{ var allowedTransitions = map[string][]string{
"quote": {"open", "cancelled"},
"open": {"in_progress", "cancelled"}, "open": {"in_progress", "cancelled"},
"in_progress": {"completed", "cancelled"}, "in_progress": {"completed", "cancelled"},
"completed": {"invoiced", "cancelled"}, "completed": {"invoiced", "cancelled"},
@@ -35,7 +35,7 @@ func TestWorkOrderCRUD(t *testing.T) {
wo, err := workorder.CreateWorkOrder(ctx, conn, "", "", "", "") wo, err := workorder.CreateWorkOrder(ctx, conn, "", "", "", "")
require.NoError(t, err) require.NoError(t, err)
assert.NotEmpty(t, wo.ID) assert.NotEmpty(t, wo.ID)
assert.Equal(t, "open", wo.Status) assert.Equal(t, "quote", wo.Status)
list, err := workorder.ListWorkOrders(ctx, conn, "") list, err := workorder.ListWorkOrders(ctx, conn, "")
require.NoError(t, err) require.NoError(t, err)
@@ -55,9 +55,9 @@ func TestWorkOrderTransition(t *testing.T) {
wo, err := workorder.CreateWorkOrder(ctx, conn, "", "", "", "") wo, err := workorder.CreateWorkOrder(ctx, conn, "", "", "", "")
require.NoError(t, err) 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) 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", "") _, err = workorder.TransitionStatus(ctx, conn, wo.ID, "cancelled_invalid", "")
assert.Error(t, err, "invalid transition should error") assert.Error(t, err, "invalid transition should error")
@@ -69,6 +69,9 @@ func TestAllowedTransitions(t *testing.T) {
to string to string
valid bool valid bool
}{ }{
{"quote", "open", true},
{"quote", "cancelled", true},
{"quote", "in_progress", false},
{"open", "in_progress", true}, {"open", "in_progress", true},
{"open", "cancelled", true}, {"open", "cancelled", true},
{"open", "completed", false}, {"open", "completed", false},
@@ -63,7 +63,7 @@ CREATE TABLE IF NOT EXISTS work_orders (
number SERIAL UNIQUE, number SERIAL UNIQUE,
client_id UUID REFERENCES clients(id) ON DELETE SET NULL, client_id UUID REFERENCES clients(id) ON DELETE SET NULL,
vehicle_id UUID REFERENCES vehicles(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, internal_notes TEXT,
client_notes TEXT, client_notes TEXT,
created_by UUID REFERENCES users(id) ON DELETE SET NULL, created_by UUID REFERENCES users(id) ON DELETE SET NULL,
@@ -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';
@@ -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
);
+102 -21
View File
@@ -1,9 +1,14 @@
package pdf package pdf
import ( import (
"bytes"
"encoding/base64"
"fmt" "fmt"
"net/http"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"time"
"github.com/go-pdf/fpdf" "github.com/go-pdf/fpdf"
) )
@@ -15,6 +20,7 @@ type DocMeta struct {
CompanyIBAN string CompanyIBAN string
CompanyPhone string CompanyPhone string
CompanyEmail string CompanyEmail string
CompanyLogo string
DocType string // "Fatura" or "Orçamento" DocType string // "Fatura" or "Orçamento"
DocNumber string // e.g. "FAT/2026/001" DocNumber string // e.g. "FAT/2026/001"
IssuedAt string // e.g. "30/06/2026" 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 := fpdf.New("P", "mm", "A4", "")
f.AddPage() f.AddPage()
f.SetMargins(15, 15, 15) 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 // Header
f.SetFont("Helvetica", "B", 18) 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.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) f.SetFont("Helvetica", "", 9)
if meta.CompanyNIF != "" { 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 { } else {
f.CellFormat(120, 5, "", "", 0, "L", false, 0, "") f.CellFormat(leftW, 5, "", "", 0, "L", false, 0, "")
} }
f.SetFont("Helvetica", "", 11) 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) f.SetFont("Helvetica", "", 9)
if meta.CompanyAddress != "" { if meta.CompanyAddress != "" {
f.MultiCell(120, 5, meta.CompanyAddress, "", "L", false) f.MultiCell(leftW, 5, t(meta.CompanyAddress), "", "L", false)
} }
f.Ln(3) f.Ln(3)
curY := f.GetY() curY := f.GetY()
f.SetXY(135, curY-3) 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.SetY(curY + 3)
f.Ln(3) f.Ln(3)
// Client block // Client block
if meta.ClientName != "" { if meta.ClientName != "" {
f.SetFont("Helvetica", "B", 9) 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.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 != "" { 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 != "" { 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) f.Ln(4)
} }
@@ -85,11 +99,11 @@ func Generate(meta DocMeta, items []LineItem, staffTotal float64, outPath string
f.SetFillColor(50, 50, 50) f.SetFillColor(50, 50, 50)
f.SetTextColor(255, 255, 255) f.SetTextColor(255, 255, 255)
f.SetFont("Helvetica", "B", 9) f.SetFont("Helvetica", "B", 9)
f.CellFormat(90, 7, "Descricao", "1", 0, "L", true, 0, "") f.CellFormat(90, 7, t("Descrição"), "1", 0, "L", true, 0, "")
f.CellFormat(20, 7, "Qtd.", "1", 0, "C", true, 0, "") f.CellFormat(20, 7, t("Qtd."), "1", 0, "C", true, 0, "")
f.CellFormat(25, 7, "Preco Unit.", "1", 0, "R", true, 0, "") f.CellFormat(25, 7, t("Preço Unit."), "1", 0, "R", true, 0, "")
f.CellFormat(20, 7, "Desc.%", "1", 0, "C", true, 0, "") f.CellFormat(20, 7, t("Desc.%"), "1", 0, "C", true, 0, "")
f.CellFormat(25, 7, "Total", "1", 1, "R", true, 0, "") f.CellFormat(25, 7, t("Total"), "1", 1, "R", true, 0, "")
// Table rows // Table rows
f.SetFillColor(245, 245, 245) f.SetFillColor(245, 245, 245)
@@ -97,7 +111,7 @@ func Generate(meta DocMeta, items []LineItem, staffTotal float64, outPath string
f.SetFont("Helvetica", "", 9) f.SetFont("Helvetica", "", 9)
fill := false fill := false
for _, item := range items { 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(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(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, "") 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.Ln(3)
f.SetFont("Helvetica", "", 9) f.SetFont("Helvetica", "", 9)
if staffTotal > 0 { 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(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.CellFormat(25, 6, fmt.Sprintf("%.2f EUR", staffTotal), "1", 1, "R", false, 0, "")
} }
f.SetFont("Helvetica", "B", 10) 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, "") f.CellFormat(25, 7, fmt.Sprintf("%.2f EUR", grandTotal), "1", 1, "R", false, 0, "")
// IBAN footer // IBAN footer
if meta.CompanyIBAN != "" { if meta.CompanyIBAN != "" {
f.Ln(8) f.Ln(8)
f.SetFont("Helvetica", "", 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) 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
}
+1 -1
View File
@@ -11,7 +11,7 @@ const nav = [
{ to: '/app/catalog', label: 'Catálogo' }, { to: '/app/catalog', label: 'Catálogo' },
{ to: '/app/staff', label: 'Técnicos' }, { to: '/app/staff', label: 'Técnicos' },
{ to: '/app/expenses', label: 'Despesas' }, { to: '/app/expenses', label: 'Despesas' },
{ to: '/app/invoices', label: 'Faturação' }, { to: '/app/invoices', label: 'Transações' },
{ to: '/app/settings', label: 'Definiçõ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>( async function authorizedFetch(path: string, options: RequestInit = {}): Promise<Response> {
path: string,
options: RequestInit = {}
): Promise<T> {
const { accessToken, clearAuth } = useAuthStore.getState() const { accessToken, clearAuth } = useAuthStore.getState()
const headers: Record<string, string> = { const headers: Record<string, string> = {
'Content-Type': 'application/json',
...(options.headers as Record<string, string>), ...(options.headers as Record<string, string>),
} }
if (accessToken) { if (accessToken) {
headers['Authorization'] = `Bearer ${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) { if (res.status === 204) {
return undefined as T return undefined as T
} }
@@ -75,3 +85,18 @@ export async function apiFetch<T>(
return json.data as 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 number: number
client_id: string | null client_id: string | null
vehicle_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 internal_notes: string
client_notes: string client_notes: string
created_by: string | null 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 ( return (
<div> <article className="rounded-2xl border border-slate-700/70 bg-slate-900/65 p-4 transition-colors hover:border-slate-500/80">
<h1 className="text-2xl font-bold text-gray-900">Dashboard</h1> <p className="text-[11px] uppercase tracking-[0.14em] text-slate-400">{title}</p>
<p className="text-gray-500 mt-1 text-sm">Bem-vindo ao TechXCar implementado no Plano 5</p> <p className="mt-2 text-2xl font-semibold text-white [font-family:'Space_Grotesk',ui-sans-serif,sans-serif]">
</div> {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 { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' 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 { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import type { Invoice, WorkOrder } from '@/lib/types' import type { Invoice, WorkOrder } from '@/lib/types'
const STATUS_LABELS: Record<string, string> = {
const TYPE_LABELS: Record<string, string> = { quote: 'Orçamento', invoice: 'Fatura' } quote: 'Orçamento',
open: 'Aberta',
in_progress: 'Em Curso',
completed: 'Concluída',
invoiced: 'Faturada',
cancelled: 'Cancelada',
}
export default function InvoicesPage() { export default function InvoicesPage() {
const qc = useQueryClient() const qc = useQueryClient()
const [showGenerate, setShowGenerate] = useState(false) const [showGenerate, setShowGenerate] = useState(false)
const [selectedWO, setSelectedWO] = useState('') const [selectedWO, setSelectedWO] = useState('')
const [docType, setDocType] = useState<'quote' | 'invoice'>('quote') const [docType, setDocType] = useState<'quote' | 'invoice'>('quote')
const [viewError, setViewError] = useState('')
const { data: invoices = [], isLoading } = useQuery<Invoice[]>({ const { data: invoices = [], isLoading } = useQuery<Invoice[]>({
queryKey: ['invoices'], queryKey: ['invoices'],
queryFn: () => apiFetch<Invoice[]>('/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[]>({ const { data: workOrders = [] } = useQuery<WorkOrder[]>({
queryKey: ['work-orders-for-invoice'], queryKey: ['work-orders-for-invoice'],
@@ -25,8 +34,9 @@ export default function InvoicesPage() {
}) })
const eligibleWOs = workOrders.filter((wo) => 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({ const generate = useMutation({
mutationFn: () => 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 ( return (
<div> <div>
<div className="flex items-center justify-between mb-6"> <div className="flex items-center justify-between mb-6">
<div> <div>
<h1 className="text-2xl font-bold text-white">Faturação</h1> <h1 className="text-2xl font-bold text-white">Transações</h1>
<p className="text-slate-400 text-sm mt-0.5">{invoices.length} documentos</p> <p className="text-slate-400 text-sm mt-0.5">
{invoices.length} documentos emitidos (orçamentos e faturas)
</p>
</div> </div>
<Button onClick={() => setShowGenerate(true)}>Gerar Documento</Button> <Button onClick={() => setShowGenerate(true)}>Nova Transação</Button>
</div> </div>
{showGenerate && ( {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" 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> <option value=""> Seleccionar OT </option>
{eligibleWOs.map((wo) => ( {eligibleSorted.map((wo) => (
<option key={wo.id} value={wo.id}> <option key={wo.id} value={wo.id}>
#{wo.number} ({wo.status}) #{wo.number} ({STATUS_LABELS[wo.status] ?? wo.status})
</option> </option>
))} ))}
</select> </select>
@@ -100,50 +124,85 @@ export default function InvoicesPage() {
</div> </div>
)} )}
{viewError && (
<p className="mb-4 text-red-400 text-sm">{viewError}</p>
)}
{isLoading ? ( {isLoading ? (
<p className="text-slate-400">A carregar...</p> <p className="text-slate-400">A carregar...</p>
) : invoices.length === 0 ? ( ) : invoices.length === 0 ? (
<p className="text-slate-500 text-sm">Nenhum documento gerado.</p> <p className="text-slate-500 text-sm">Nenhum documento gerado.</p>
) : ( ) : (
<div className="rounded-lg border border-slate-700 overflow-hidden"> <div className="grid gap-6 xl:grid-cols-2">
<table className="w-full text-sm"> <div className="rounded-lg border border-slate-700 overflow-hidden">
<thead className="bg-slate-800"> <div className="bg-slate-800 px-4 py-3 flex items-center justify-between">
<tr> <h2 className="text-sm font-semibold text-white">Orçamentos</h2>
<th className="text-left px-4 py-3 text-slate-400 font-medium"></th> <Badge variant="secondary">{quoteDocs.length}</Badge>
<th className="text-left px-4 py-3 text-slate-400 font-medium">Tipo</th> </div>
<th className="text-left px-4 py-3 text-slate-400 font-medium">OT</th> {quoteDocs.length === 0 ? (
<th className="text-left px-4 py-3 text-slate-400 font-medium">Emitida</th> <p className="px-4 py-5 text-slate-500 text-sm">Sem orçamentos gerados.</p>
<th className="px-4 py-3"></th> ) : (
</tr> <table className="w-full text-sm">
</thead> <thead className="bg-slate-900/80">
<tbody> <tr>
{invoices.map((inv) => ( <th className="text-left px-4 py-2 text-slate-400 font-medium"></th>
<tr key={inv.id} className="border-t border-slate-700 hover:bg-slate-800/50"> <th className="text-left px-4 py-2 text-slate-400 font-medium">OT</th>
<td className="px-4 py-3 text-white font-mono">#{inv.number}</td> <th className="text-left px-4 py-2 text-slate-400 font-medium">Emitida</th>
<td className="px-4 py-3"> <th className="px-4 py-2"></th>
<Badge variant={inv.type === 'invoice' ? 'default' : 'secondary'}> </tr>
{TYPE_LABELS[inv.type]} </thead>
</Badge> <tbody>
</td> {quoteDocs.map((inv) => (
<td className="px-4 py-3 text-slate-400 font-mono text-xs"> <tr key={inv.id} className="border-t border-slate-700 hover:bg-slate-800/50">
{inv.work_order_id.slice(0, 8)} <td className="px-4 py-2 text-white font-mono">#{inv.number}</td>
</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-3 text-slate-400"> <td className="px-4 py-2 text-slate-400">
{new Intl.DateTimeFormat('pt-PT').format(new Date(inv.issued_at))} {new Intl.DateTimeFormat('pt-PT').format(new Date(inv.issued_at))}
</td> </td>
<td className="px-4 py-3 text-right"> <td className="px-4 py-2 text-right">
<Button <Button size="sm" variant="outline" onClick={() => openPDF(inv)}>Ver PDF</Button>
size="sm" </td>
variant="outline" </tr>
onClick={() => window.open(`/api/v1/invoices/${inv.id}/pdf`, '_blank')} ))}
> </tbody>
Descarregar PDF </table>
</Button> )}
</td> </div>
</tr>
))} <div className="rounded-lg border border-slate-700 overflow-hidden">
</tbody> <div className="bg-slate-800 px-4 py-3 flex items-center justify-between">
</table> <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>
)} )}
</div> </div>
+22
View File
@@ -14,6 +14,7 @@ type FormData = {
company_iban: string company_iban: string
company_phone: string company_phone: string
company_email: string company_email: string
company_logo: string
} }
const defaultValues: FormData = { const defaultValues: FormData = {
@@ -23,6 +24,7 @@ const defaultValues: FormData = {
company_iban: '', company_iban: '',
company_phone: '', company_phone: '',
company_email: '', company_email: '',
company_logo: '',
} }
export default function SettingsPage() { export default function SettingsPage() {
@@ -44,6 +46,7 @@ export default function SettingsPage() {
company_iban: settings['company_iban'] ?? '', company_iban: settings['company_iban'] ?? '',
company_phone: settings['company_phone'] ?? '', company_phone: settings['company_phone'] ?? '',
company_email: settings['company_email'] ?? '', company_email: settings['company_email'] ?? '',
company_logo: settings['company_logo'] ?? '',
}) })
} }
}, [settings, reset]) }, [settings, reset])
@@ -101,6 +104,25 @@ export default function SettingsPage() {
className="bg-slate-900 border-slate-600 text-white" /> className="bg-slate-900 border-slate-600 text-white" />
</div> </div>
</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 && ( {save.error && (
<p className="text-red-400 text-sm">{(save.error as Error).message}</p> <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> = { const STATUS_LABELS: Record<string, string> = {
quote: 'Orçamento',
open: 'Aberta', open: 'Aberta',
in_progress: 'Em Curso', in_progress: 'Em Curso',
completed: 'Concluída', completed: 'Concluída',
@@ -35,6 +36,7 @@ const STATUS_LABELS: Record<string, string> = {
} }
const TRANSITIONS: Record<string, string[]> = { const TRANSITIONS: Record<string, string[]> = {
quote: ['open', 'cancelled'],
open: ['in_progress', 'cancelled'], open: ['in_progress', 'cancelled'],
in_progress: ['completed', 'cancelled'], in_progress: ['completed', 'cancelled'],
completed: ['invoiced', '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' import type { WorkOrder, Client, Vehicle } from '@/lib/types'
const STATUS_LABELS: Record<string, string> = { const STATUS_LABELS: Record<string, string> = {
quote: 'Orçamento',
open: 'Aberta', open: 'Aberta',
in_progress: 'Em Curso', in_progress: 'Em Curso',
completed: 'Concluída', completed: 'Concluída',
@@ -20,6 +21,7 @@ const STATUS_LABELS: Record<string, string> = {
} }
const STATUS_VARIANT: Record<string, 'default' | 'secondary' | 'destructive'> = { const STATUS_VARIANT: Record<string, 'default' | 'secondary' | 'destructive'> = {
quote: 'secondary',
open: 'secondary', open: 'secondary',
in_progress: 'default', in_progress: 'default',
completed: 'default', completed: 'default',
@@ -85,7 +87,7 @@ export default function WorkOrdersPage() {
</div> </div>
<div className="flex gap-2 mb-4"> <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 <button
key={s} key={s}
onClick={() => setStatusFilter(s)} onClick={() => setStatusFilter(s)}