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")
}
docType := "Orcamento"
docType := "Orçamento"
prefix := "ORC"
if b.Type == "invoice" {
docType = "Fatura"
@@ -121,6 +121,7 @@ func createInvoiceH() fiber.Handler {
CompanyIBAN: sett["company_iban"],
CompanyPhone: sett["company_phone"],
CompanyEmail: sett["company_email"],
CompanyLogo: sett["company_logo"],
DocType: docType,
DocNumber: fmt.Sprintf("%s/%d/%04d", prefix, inv.IssuedAt.Year(), inv.Number),
IssuedAt: inv.IssuedAt.Format("02/01/2006"),
+3 -3
View File
@@ -21,7 +21,7 @@ type Invoice struct {
func ListInvoices(ctx context.Context, conn *pgxpool.Conn) ([]*Invoice, error) {
rows, err := conn.Query(ctx,
`SELECT id, work_order_id, type, number, COALESCE(pdf_path,''), issued_at, created_at
`SELECT id, work_order_id, type, number, COALESCE(pdf_path,''), issued_at, issued_at AS created_at
FROM invoices ORDER BY issued_at DESC`)
if err != nil {
return nil, fmt.Errorf("invoice: list: %w", err)
@@ -41,7 +41,7 @@ func ListInvoices(ctx context.Context, conn *pgxpool.Conn) ([]*Invoice, error) {
func GetInvoice(ctx context.Context, conn *pgxpool.Conn, id string) (*Invoice, error) {
row := conn.QueryRow(ctx,
`SELECT id, work_order_id, type, number, COALESCE(pdf_path,''), issued_at, created_at
`SELECT id, work_order_id, type, number, COALESCE(pdf_path,''), issued_at, issued_at AS created_at
FROM invoices WHERE id = $1`, id)
var inv Invoice
err := row.Scan(&inv.ID, &inv.WorkOrderID, &inv.Type, &inv.Number,
@@ -59,7 +59,7 @@ func CreateInvoice(ctx context.Context, conn *pgxpool.Conn, woID, docType string
row := conn.QueryRow(ctx,
`INSERT INTO invoices (work_order_id, type, issued_at)
VALUES ($1, $2, NOW())
RETURNING id, work_order_id, type, number, COALESCE(pdf_path,''), issued_at, created_at`,
RETURNING id, work_order_id, type, number, COALESCE(pdf_path,''), issued_at, issued_at AS created_at`,
woID, docType)
var inv Invoice
if err := row.Scan(&inv.ID, &inv.WorkOrderID, &inv.Type, &inv.Number,
+1
View File
@@ -14,6 +14,7 @@ var AllowedKeys = map[string]bool{
"company_iban": true,
"company_phone": true,
"company_email": true,
"company_logo": true,
}
func GetSettings(ctx context.Context, conn *pgxpool.Conn) (map[string]string, error) {
+1
View File
@@ -48,6 +48,7 @@ type WorkOrderDetail struct {
}
var allowedTransitions = map[string][]string{
"quote": {"open", "cancelled"},
"open": {"in_progress", "cancelled"},
"in_progress": {"completed", "cancelled"},
"completed": {"invoiced", "cancelled"},
@@ -35,7 +35,7 @@ func TestWorkOrderCRUD(t *testing.T) {
wo, err := workorder.CreateWorkOrder(ctx, conn, "", "", "", "")
require.NoError(t, err)
assert.NotEmpty(t, wo.ID)
assert.Equal(t, "open", wo.Status)
assert.Equal(t, "quote", wo.Status)
list, err := workorder.ListWorkOrders(ctx, conn, "")
require.NoError(t, err)
@@ -55,9 +55,9 @@ func TestWorkOrderTransition(t *testing.T) {
wo, err := workorder.CreateWorkOrder(ctx, conn, "", "", "", "")
require.NoError(t, err)
wo2, err := workorder.TransitionStatus(ctx, conn, wo.ID, "in_progress", "")
wo2, err := workorder.TransitionStatus(ctx, conn, wo.ID, "open", "")
require.NoError(t, err)
assert.Equal(t, "in_progress", wo2.Status)
assert.Equal(t, "open", wo2.Status)
_, err = workorder.TransitionStatus(ctx, conn, wo.ID, "cancelled_invalid", "")
assert.Error(t, err, "invalid transition should error")
@@ -69,6 +69,9 @@ func TestAllowedTransitions(t *testing.T) {
to string
valid bool
}{
{"quote", "open", true},
{"quote", "cancelled", true},
{"quote", "in_progress", false},
{"open", "in_progress", true},
{"open", "cancelled", true},
{"open", "completed", false},
@@ -63,7 +63,7 @@ CREATE TABLE IF NOT EXISTS work_orders (
number SERIAL UNIQUE,
client_id UUID REFERENCES clients(id) ON DELETE SET NULL,
vehicle_id UUID REFERENCES vehicles(id) ON DELETE SET NULL,
status TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open', 'in_progress', 'completed', 'invoiced', 'cancelled')),
status TEXT NOT NULL DEFAULT 'quote' CHECK (status IN ('quote', 'open', 'in_progress', 'completed', 'invoiced', 'cancelled')),
internal_notes TEXT,
client_notes TEXT,
created_by UUID REFERENCES users(id) ON DELETE SET NULL,
@@ -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
import (
"bytes"
"encoding/base64"
"fmt"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"github.com/go-pdf/fpdf"
)
@@ -15,6 +20,7 @@ type DocMeta struct {
CompanyIBAN string
CompanyPhone string
CompanyEmail string
CompanyLogo string
DocType string // "Fatura" or "Orçamento"
DocNumber string // e.g. "FAT/2026/001"
IssuedAt string // e.g. "30/06/2026"
@@ -39,44 +45,52 @@ func Generate(meta DocMeta, items []LineItem, staffTotal float64, outPath string
f := fpdf.New("P", "mm", "A4", "")
f.AddPage()
f.SetMargins(15, 15, 15)
tr := f.UnicodeTranslatorFromDescriptor("")
t := func(s string) string { return tr(s) }
hasLogo := addLogo(f, meta.CompanyLogo)
leftW := 120.0
if hasLogo {
leftW = 100.0
}
// Header
f.SetFont("Helvetica", "B", 18)
f.CellFormat(120, 10, meta.CompanyName, "", 0, "L", false, 0, "")
f.CellFormat(leftW, 10, t(meta.CompanyName), "", 0, "L", false, 0, "")
f.SetFont("Helvetica", "B", 14)
f.CellFormat(60, 10, meta.DocType, "", 1, "R", false, 0, "")
f.CellFormat(60, 10, t(meta.DocType), "", 1, "R", false, 0, "")
f.SetFont("Helvetica", "", 9)
if meta.CompanyNIF != "" {
f.CellFormat(120, 5, "NIF: "+meta.CompanyNIF, "", 0, "L", false, 0, "")
f.CellFormat(leftW, 5, t("NIF: ")+meta.CompanyNIF, "", 0, "L", false, 0, "")
} else {
f.CellFormat(120, 5, "", "", 0, "L", false, 0, "")
f.CellFormat(leftW, 5, "", "", 0, "L", false, 0, "")
}
f.SetFont("Helvetica", "", 11)
f.CellFormat(60, 5, meta.DocNumber, "", 1, "R", false, 0, "")
f.CellFormat(60, 5, t(meta.DocNumber), "", 1, "R", false, 0, "")
f.SetFont("Helvetica", "", 9)
if meta.CompanyAddress != "" {
f.MultiCell(120, 5, meta.CompanyAddress, "", "L", false)
f.MultiCell(leftW, 5, t(meta.CompanyAddress), "", "L", false)
}
f.Ln(3)
curY := f.GetY()
f.SetXY(135, curY-3)
f.CellFormat(60, 5, "Data: "+meta.IssuedAt, "", 1, "R", false, 0, "")
f.CellFormat(60, 5, t("Data: ")+meta.IssuedAt, "", 1, "R", false, 0, "")
f.SetY(curY + 3)
f.Ln(3)
// Client block
if meta.ClientName != "" {
f.SetFont("Helvetica", "B", 9)
f.CellFormat(180, 5, "Cliente", "", 1, "L", false, 0, "")
f.CellFormat(180, 5, t("Cliente"), "", 1, "L", false, 0, "")
f.SetFont("Helvetica", "", 9)
f.CellFormat(180, 5, meta.ClientName, "", 1, "L", false, 0, "")
f.CellFormat(180, 5, t(meta.ClientName), "", 1, "L", false, 0, "")
if meta.ClientNIF != "" {
f.CellFormat(180, 5, "NIF: "+meta.ClientNIF, "", 1, "L", false, 0, "")
f.CellFormat(180, 5, t("NIF: ")+meta.ClientNIF, "", 1, "L", false, 0, "")
}
if meta.VehiclePlate != "" {
f.CellFormat(180, 5, "Matrícula: "+meta.VehiclePlate, "", 1, "L", false, 0, "")
f.CellFormat(180, 5, t("Matrícula: ")+meta.VehiclePlate, "", 1, "L", false, 0, "")
}
f.Ln(4)
}
@@ -85,11 +99,11 @@ func Generate(meta DocMeta, items []LineItem, staffTotal float64, outPath string
f.SetFillColor(50, 50, 50)
f.SetTextColor(255, 255, 255)
f.SetFont("Helvetica", "B", 9)
f.CellFormat(90, 7, "Descricao", "1", 0, "L", true, 0, "")
f.CellFormat(20, 7, "Qtd.", "1", 0, "C", true, 0, "")
f.CellFormat(25, 7, "Preco Unit.", "1", 0, "R", true, 0, "")
f.CellFormat(20, 7, "Desc.%", "1", 0, "C", true, 0, "")
f.CellFormat(25, 7, "Total", "1", 1, "R", true, 0, "")
f.CellFormat(90, 7, t("Descrição"), "1", 0, "L", true, 0, "")
f.CellFormat(20, 7, t("Qtd."), "1", 0, "C", true, 0, "")
f.CellFormat(25, 7, t("Preço Unit."), "1", 0, "R", true, 0, "")
f.CellFormat(20, 7, t("Desc.%"), "1", 0, "C", true, 0, "")
f.CellFormat(25, 7, t("Total"), "1", 1, "R", true, 0, "")
// Table rows
f.SetFillColor(245, 245, 245)
@@ -97,7 +111,7 @@ func Generate(meta DocMeta, items []LineItem, staffTotal float64, outPath string
f.SetFont("Helvetica", "", 9)
fill := false
for _, item := range items {
f.CellFormat(90, 6, item.Description, "1", 0, "L", fill, 0, "")
f.CellFormat(90, 6, t(item.Description), "1", 0, "L", fill, 0, "")
f.CellFormat(20, 6, fmt.Sprintf("%.2f", item.Qty), "1", 0, "C", fill, 0, "")
f.CellFormat(25, 6, fmt.Sprintf("%.2f EUR", item.UnitPrice), "1", 0, "R", fill, 0, "")
f.CellFormat(20, 6, fmt.Sprintf("%.0f%%", item.DiscountPct), "1", 0, "C", fill, 0, "")
@@ -115,21 +129,88 @@ func Generate(meta DocMeta, items []LineItem, staffTotal float64, outPath string
f.Ln(3)
f.SetFont("Helvetica", "", 9)
if staffTotal > 0 {
f.CellFormat(155, 6, "Subtotal pecas/servicos", "", 0, "R", false, 0, "")
f.CellFormat(155, 6, t("Subtotal peças/serviços"), "", 0, "R", false, 0, "")
f.CellFormat(25, 6, fmt.Sprintf("%.2f EUR", subtotal), "1", 1, "R", false, 0, "")
f.CellFormat(155, 6, "Mao de obra", "", 0, "R", false, 0, "")
f.CellFormat(155, 6, t("Mão de obra"), "", 0, "R", false, 0, "")
f.CellFormat(25, 6, fmt.Sprintf("%.2f EUR", staffTotal), "1", 1, "R", false, 0, "")
}
f.SetFont("Helvetica", "B", 10)
f.CellFormat(155, 7, "TOTAL", "", 0, "R", false, 0, "")
f.CellFormat(155, 7, t("TOTAL"), "", 0, "R", false, 0, "")
f.CellFormat(25, 7, fmt.Sprintf("%.2f EUR", grandTotal), "1", 1, "R", false, 0, "")
// IBAN footer
if meta.CompanyIBAN != "" {
f.Ln(8)
f.SetFont("Helvetica", "", 8)
f.CellFormat(180, 5, "IBAN: "+meta.CompanyIBAN, "", 1, "C", false, 0, "")
f.CellFormat(180, 5, t("IBAN: ")+meta.CompanyIBAN, "", 1, "C", false, 0, "")
}
return f.OutputFileAndClose(outPath)
}
func addLogo(f *fpdf.Fpdf, logo string) bool {
logo = strings.TrimSpace(logo)
if logo == "" {
return false
}
if strings.HasPrefix(logo, "data:image/") {
comma := strings.Index(logo, ",")
if comma <= 0 {
return false
}
meta := logo[:comma]
payload := logo[comma+1:]
if !strings.Contains(meta, ";base64") {
return false
}
raw, err := base64.StdEncoding.DecodeString(payload)
if err != nil {
return false
}
imgType := "PNG"
if strings.Contains(meta, "image/jpeg") || strings.Contains(meta, "image/jpg") {
imgType = "JPG"
}
opts := fpdf.ImageOptions{ImageType: imgType, ReadDpi: true}
f.RegisterImageOptionsReader("company_logo", opts, bytes.NewReader(raw))
f.ImageOptions("company_logo", 15, 14, 26, 0, false, opts, 0, "")
f.SetX(45)
return true
}
if strings.HasPrefix(logo, "http://") || strings.HasPrefix(logo, "https://") {
client := http.Client{Timeout: 5 * time.Second}
resp, err := client.Get(logo)
if err != nil {
return false
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return false
}
buf := new(bytes.Buffer)
if _, err := buf.ReadFrom(resp.Body); err != nil {
return false
}
imgType := "PNG"
ct := strings.ToLower(resp.Header.Get("Content-Type"))
if strings.Contains(ct, "jpeg") || strings.Contains(ct, "jpg") {
imgType = "JPG"
}
opts := fpdf.ImageOptions{ImageType: imgType, ReadDpi: true}
f.RegisterImageOptionsReader("company_logo", opts, bytes.NewReader(buf.Bytes()))
f.ImageOptions("company_logo", 15, 14, 26, 0, false, opts, 0, "")
f.SetX(45)
return true
}
if _, err := os.Stat(logo); err == nil {
opts := fpdf.ImageOptions{ReadDpi: true}
f.ImageOptions(logo, 15, 14, 26, 0, false, opts, 0, "")
f.SetX(45)
return true
}
return false
}