feat: atualizar fluxo OT, dashboard, transacoes e PDFs
This commit is contained in:
@@ -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"),
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
@@ -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()
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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="grid gap-6 xl:grid-cols-2">
|
||||||
<div className="rounded-lg border border-slate-700 overflow-hidden">
|
<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">
|
<table className="w-full text-sm">
|
||||||
<thead className="bg-slate-800">
|
<thead className="bg-slate-900/80">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="text-left px-4 py-3 text-slate-400 font-medium">Nº</th>
|
<th className="text-left px-4 py-2 text-slate-400 font-medium">Nº</th>
|
||||||
<th className="text-left px-4 py-3 text-slate-400 font-medium">Tipo</th>
|
<th className="text-left px-4 py-2 text-slate-400 font-medium">OT</th>
|
||||||
<th className="text-left px-4 py-3 text-slate-400 font-medium">OT</th>
|
<th className="text-left px-4 py-2 text-slate-400 font-medium">Emitida</th>
|
||||||
<th className="text-left px-4 py-3 text-slate-400 font-medium">Emitida</th>
|
<th className="px-4 py-2"></th>
|
||||||
<th className="px-4 py-3"></th>
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{invoices.map((inv) => (
|
{quoteDocs.map((inv) => (
|
||||||
<tr key={inv.id} className="border-t border-slate-700 hover:bg-slate-800/50">
|
<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-2 text-white font-mono">#{inv.number}</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-2 text-slate-400 font-mono text-xs">{inv.work_order_id.slice(0, 8)}…</td>
|
||||||
<Badge variant={inv.type === 'invoice' ? 'default' : 'secondary'}>
|
<td className="px-4 py-2 text-slate-400">
|
||||||
{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))}
|
{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"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => window.open(`/api/v1/invoices/${inv.id}/pdf`, '_blank')}
|
|
||||||
>
|
|
||||||
Descarregar PDF
|
|
||||||
</Button>
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</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">Nº</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>
|
||||||
|
|||||||
@@ -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'],
|
||||||
|
|||||||
@@ -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)}
|
||||||
|
|||||||
Reference in New Issue
Block a user