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")
|
||||
}
|
||||
|
||||
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"),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user