feat: refine OT flow, reports, theming and sidebar UX

This commit is contained in:
Luciano Milani
2026-07-02 22:19:03 +01:00
parent 8231bba3f9
commit 9bf176b385
48 changed files with 3469 additions and 599 deletions
+111 -68
View File
@@ -5,8 +5,11 @@ import (
"os"
"path/filepath"
"strings"
"time"
"github.com/gofiber/fiber/v2"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/techxcar/backend/internal/auth"
"github.com/techxcar/backend/internal/settings"
"github.com/techxcar/backend/internal/workorder"
@@ -83,21 +86,18 @@ func createInvoiceH() fiber.Handler {
return fiber.NewError(404, "ordem de trabalho não encontrada")
}
sett, err := settings.GetSettings(c.Context(), conn)
if err != nil {
return fiber.NewError(500, "erro ao obter definições")
}
var clientName, clientNIF, vehiclePlate string
if detail.ClientID != nil {
_ = conn.QueryRow(c.Context(),
`SELECT name, COALESCE(nif,'') FROM clients WHERE id = $1`,
*detail.ClientID).Scan(&clientName, &clientNIF)
}
if detail.VehicleID != nil {
_ = conn.QueryRow(c.Context(),
`SELECT plate FROM vehicles WHERE id = $1`,
*detail.VehicleID).Scan(&vehiclePlate)
if b.Type == "invoice" {
if detail.PaymentMethod == "" {
return fiber.NewError(400, "defina o meio de pagamento na OT antes de faturar")
}
var existingID string
if err := conn.QueryRow(c.Context(),
`SELECT id FROM invoices WHERE work_order_id=$1 AND type='invoice' ORDER BY issued_at DESC LIMIT 1`,
b.WorkOrderID).Scan(&existingID); err == nil && existingID != "" {
return fiber.NewError(409, "já existe uma fatura para esta OT")
} else if err != nil && err != pgx.ErrNoRows {
return fiber.NewError(500, "erro ao validar faturação existente")
}
}
inv, err := CreateInvoice(c.Context(), conn, b.WorkOrderID, b.Type)
@@ -105,61 +105,27 @@ func createInvoiceH() fiber.Handler {
return fiber.NewError(500, "erro ao criar fatura")
}
docType := "Orçamento"
prefix := "ORC"
if b.Type == "invoice" {
docType = "Fatura"
prefix = "FAT"
}
outPath := filepath.Join(storageRoot, tenantDir(c), fmt.Sprintf("inv_%s.pdf", inv.ID))
meta := pdf.DocMeta{
CompanyName: sett["company_name"],
CompanyNIF: sett["company_nif"],
CompanyAddress: sett["company_address"],
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"),
ClientName: clientName,
ClientNIF: clientNIF,
VehiclePlate: vehiclePlate,
}
lineItems := make([]pdf.LineItem, len(detail.Items))
for i, item := range detail.Items {
lineItems[i] = pdf.LineItem{
Description: item.Description,
Qty: item.Qty,
UnitPrice: item.UnitPrice,
DiscountPct: item.DiscountPct,
Total: item.Total,
paymentMethod := detail.PaymentMethod
paymentDate := ""
if detail.PaymentDate != nil {
paymentDate = detail.PaymentDate.Format("2006-01-02")
}
}
var staffTotal float64
for _, sh := range detail.StaffHours {
staffTotal += sh.Total
}
if err := pdf.Generate(meta, lineItems, staffTotal, outPath); err != nil {
return fiber.NewError(500, "erro ao gerar PDF")
}
if err := SetPDFPath(c.Context(), conn, inv.ID, outPath); err != nil {
return fiber.NewError(500, "erro ao registar caminho do PDF")
}
inv.PDFPath = outPath
if b.Type == "invoice" {
if _, err := workorder.TransitionStatus(c.Context(), conn, b.WorkOrderID, "invoiced", ""); err != nil {
if paymentDate == "" {
paymentDate = time.Now().Format("2006-01-02")
}
if _, err := workorder.TransitionStatus(c.Context(), conn, b.WorkOrderID, "invoiced", paymentMethod, paymentDate, ""); err != nil {
_, _ = conn.Exec(c.Context(), `DELETE FROM invoices WHERE id=$1`, inv.ID)
return fiber.NewError(500, "erro ao atualizar estado da ordem")
}
}
outPath, err := regeneratePDF(c, conn, inv)
if err != nil {
return fiber.NewError(500, "erro ao gerar PDF")
}
inv.PDFPath = outPath
return c.Status(201).JSON(fiber.Map{"data": inv, "error": nil})
}
}
@@ -175,13 +141,90 @@ func downloadPDFH() fiber.Handler {
if inv == nil {
return fiber.NewError(404, "fatura não encontrada")
}
if inv.PDFPath == "" {
return fiber.NewError(404, "PDF não disponível")
// Regenerate on download so layout/branding fixes are reflected on existing docs.
outPath, err := regeneratePDF(c, conn, inv)
if err != nil {
return fiber.NewError(500, "erro ao gerar PDF")
}
if _, err := os.Stat(inv.PDFPath); os.IsNotExist(err) {
if _, err := os.Stat(outPath); os.IsNotExist(err) {
return fiber.NewError(404, "ficheiro PDF não encontrado")
}
c.Set("Content-Disposition", fmt.Sprintf(`attachment; filename="invoice_%d.pdf"`, inv.Number))
return c.SendFile(inv.PDFPath)
return c.SendFile(outPath)
}
}
func regeneratePDF(c *fiber.Ctx, conn *pgxpool.Conn, inv *Invoice) (string, error) {
detail, err := workorder.GetWorkOrderDetail(c.Context(), conn, inv.WorkOrderID)
if err != nil {
return "", fmt.Errorf("erro ao obter ordem de trabalho: %w", err)
}
if detail == nil {
return "", fmt.Errorf("ordem de trabalho não encontrada")
}
sett, err := settings.GetSettings(c.Context(), conn)
if err != nil {
return "", fmt.Errorf("erro ao obter definições: %w", err)
}
var clientName, clientNIF, vehiclePlate string
if detail.ClientID != nil {
_ = conn.QueryRow(c.Context(),
`SELECT name, COALESCE(nif,'') FROM clients WHERE id = $1`,
*detail.ClientID).Scan(&clientName, &clientNIF)
}
if detail.VehicleID != nil {
_ = conn.QueryRow(c.Context(),
`SELECT plate FROM vehicles WHERE id = $1`,
*detail.VehicleID).Scan(&vehiclePlate)
}
docType := "Orçamento"
prefix := "ORC"
if inv.Type == "invoice" {
docType = "Fatura"
prefix = "FAT"
}
outPath := filepath.Join(storageRoot, tenantDir(c), fmt.Sprintf("inv_%s.pdf", inv.ID))
meta := pdf.DocMeta{
CompanyName: sett["company_name"],
CompanyNIF: sett["company_nif"],
CompanyAddress: sett["company_address"],
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"),
ClientName: clientName,
ClientNIF: clientNIF,
VehiclePlate: vehiclePlate,
}
lineItems := make([]pdf.LineItem, len(detail.Items))
for i, item := range detail.Items {
lineItems[i] = pdf.LineItem{
Description: item.Description,
Qty: item.Qty,
UnitPrice: item.UnitPrice,
DiscountPct: item.DiscountPct,
Total: item.Total,
}
}
var staffTotal float64
for _, sh := range detail.StaffHours {
staffTotal += sh.Total
}
if err := pdf.Generate(meta, lineItems, staffTotal, outPath); err != nil {
return "", err
}
if err := SetPDFPath(c.Context(), conn, inv.ID, outPath); err != nil {
return "", err
}
return outPath, nil
}