# TechXCar — Plan 4: Staff, Despesas, Faturação & Settings > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Implement Staff (técnicos), Expenses (despesas), Tenant Settings, PDF generation, and Invoices/Quotes (faturação) — the remaining operational data layer and UI needed before reports. **Architecture:** Backend gains `internal/staff/`, `internal/expense/`, `internal/settings/`, `internal/invoice/` packages and `pkg/pdf/` — all following the established pattern (package-level functions receiving deps, routes registered in `server.New()`). PDF generation uses `github.com/go-pdf/fpdf` (pure Go, no Chrome dependency — pragmatic deviation from the spec's chromedp to avoid a ~150MB Chromium dependency in the Docker image; HTML-templated chromedp can be swapped in later). All `/app/*` routes use `RequireAuth + RequireRole + TenantMiddleware`; handlers call `auth.GetConn(c)` for the tenant-scoped connection. **Tech Stack:** Go 1.25 + Fiber v2 + pgx/v5 + `github.com/go-pdf/fpdf` v2; React 19 + TypeScript + TanStack Query v5 + React Hook Form + Zod + shadcn/ui ## Global Constraints - All tables already exist in `migrations/tenant/000001_create_tenant_schema.up.sql` — no new migrations - Response envelope: `{"data": ..., "error": null}` or `{"data": null, "error": "message"}` - API base: `/api/v1/` - Tenant-scoped queries always use `auth.GetConn(c)` — never the pool directly - PT-PT strings for all user-facing error messages - Roles: `tenant_admin`, `manager` can write; `technician` read-only - `apiFetch` returns `response.data` directly (see `frontend/src/lib/api.ts`) - PDFs stored at `/app/storage//inv_.pdf`; `/app/storage/` is a Docker volume --- ## File Map **Backend — new files:** - `backend/internal/staff/repository.go` — Staff CRUD queries - `backend/internal/staff/handler.go` — HTTP handlers + route registration - `backend/internal/expense/repository.go` — Expense CRUD queries - `backend/internal/expense/handler.go` — HTTP handlers + route registration - `backend/internal/settings/repository.go` — tenant_settings key-value CRUD - `backend/internal/settings/handler.go` — HTTP handlers + route registration - `backend/pkg/pdf/pdf.go` — `Generate(items []LineItem, meta DocMeta, outPath string) error` - `backend/internal/invoice/repository.go` — Invoice CRUD queries - `backend/internal/invoice/handler.go` — HTTP handlers (generate, list, download PDF) **Backend — modified:** - `backend/internal/server/server.go` — wire 4 new route groups - `backend/go.mod` + `backend/go.sum` — add `github.com/go-pdf/fpdf/v2` - `backend/Dockerfile` — add `/app/storage` volume dir + chown **Frontend — new files:** - `frontend/src/pages/app/StaffPage.tsx` - `frontend/src/pages/app/ExpensesPage.tsx` - `frontend/src/pages/app/SettingsPage.tsx` - `frontend/src/pages/app/InvoicesPage.tsx` **Frontend — modified:** - `frontend/src/components/layout/AppLayout.tsx` — add 4 nav items - `frontend/src/App.tsx` — add 4 routes - `frontend/src/lib/types.ts` — add Staff, Expense, TenantSettings, Invoice types --- ### Task 1: Staff Repository + Handlers **Files:** - Create: `backend/internal/staff/repository.go` - Create: `backend/internal/staff/handler.go` **Interfaces:** - Produces: - `Staff{ID, UserID *string, Name, Email, Phone, Type, Active bool, HourlyRate float64, CreatedAt time.Time}` - `ListStaff(ctx, conn) ([]*Staff, error)` - `GetStaffByID(ctx, conn, id) (*Staff, error)` — nil if not found - `CreateStaff(ctx, conn, name, email, phone, staffType string, hourlyRate float64) (*Staff, error)` - `UpdateStaff(ctx, conn, id, name, email, phone, staffType string, hourlyRate float64, active bool) (*Staff, error)` — nil if not found - `DeleteStaff(ctx, conn, id) error` - `staff.RegisterRoutes(app *fiber.App, db *database.DB, secret string)` - `GET /api/v1/staff` → `[]*Staff` (all roles) - `POST /api/v1/staff` → `*Staff` 201 (admin/manager) - `PUT /api/v1/staff/:id` → `*Staff` (admin/manager) - `DELETE /api/v1/staff/:id` → 204 (admin/manager) - [ ] **Step 1: Create repository.go** Create `backend/internal/staff/repository.go`: ```go package staff import ( "context" "fmt" "time" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" ) type Staff struct { ID string `json:"id"` UserID *string `json:"user_id"` Name string `json:"name"` Email string `json:"email"` Phone string `json:"phone"` Type string `json:"type"` HourlyRate float64 `json:"hourly_rate"` Active bool `json:"active"` CreatedAt time.Time `json:"created_at"` } func ListStaff(ctx context.Context, conn *pgxpool.Conn) ([]*Staff, error) { rows, err := conn.Query(ctx, `SELECT id, user_id, name, COALESCE(email,''), COALESCE(phone,''), type, COALESCE(hourly_rate,0), active, created_at FROM staff ORDER BY name`) if err != nil { return nil, fmt.Errorf("staff: list: %w", err) } defer rows.Close() var list []*Staff for rows.Next() { var s Staff if err := rows.Scan(&s.ID, &s.UserID, &s.Name, &s.Email, &s.Phone, &s.Type, &s.HourlyRate, &s.Active, &s.CreatedAt); err != nil { return nil, fmt.Errorf("staff: scan: %w", err) } list = append(list, &s) } return list, nil } func GetStaffByID(ctx context.Context, conn *pgxpool.Conn, id string) (*Staff, error) { row := conn.QueryRow(ctx, `SELECT id, user_id, name, COALESCE(email,''), COALESCE(phone,''), type, COALESCE(hourly_rate,0), active, created_at FROM staff WHERE id = $1`, id) var s Staff err := row.Scan(&s.ID, &s.UserID, &s.Name, &s.Email, &s.Phone, &s.Type, &s.HourlyRate, &s.Active, &s.CreatedAt) if err == pgx.ErrNoRows { return nil, nil } if err != nil { return nil, fmt.Errorf("staff: get: %w", err) } return &s, nil } func CreateStaff(ctx context.Context, conn *pgxpool.Conn, name, email, phone, staffType string, hourlyRate float64) (*Staff, error) { row := conn.QueryRow(ctx, `INSERT INTO staff (name, email, phone, type, hourly_rate) VALUES ($1, $2, $3, $4, $5) RETURNING id, user_id, name, COALESCE(email,''), COALESCE(phone,''), type, COALESCE(hourly_rate,0), active, created_at`, name, email, phone, staffType, hourlyRate) var s Staff if err := row.Scan(&s.ID, &s.UserID, &s.Name, &s.Email, &s.Phone, &s.Type, &s.HourlyRate, &s.Active, &s.CreatedAt); err != nil { return nil, fmt.Errorf("staff: create: %w", err) } return &s, nil } func UpdateStaff(ctx context.Context, conn *pgxpool.Conn, id, name, email, phone, staffType string, hourlyRate float64, active bool) (*Staff, error) { row := conn.QueryRow(ctx, `UPDATE staff SET name=$2, email=$3, phone=$4, type=$5, hourly_rate=$6, active=$7 WHERE id=$1 RETURNING id, user_id, name, COALESCE(email,''), COALESCE(phone,''), type, COALESCE(hourly_rate,0), active, created_at`, id, name, email, phone, staffType, hourlyRate, active) var s Staff err := row.Scan(&s.ID, &s.UserID, &s.Name, &s.Email, &s.Phone, &s.Type, &s.HourlyRate, &s.Active, &s.CreatedAt) if err == pgx.ErrNoRows { return nil, nil } if err != nil { return nil, fmt.Errorf("staff: update: %w", err) } return &s, nil } func DeleteStaff(ctx context.Context, conn *pgxpool.Conn, id string) error { tag, err := conn.Exec(ctx, `DELETE FROM staff WHERE id = $1`, id) if err != nil { return fmt.Errorf("staff: delete: %w", err) } if tag.RowsAffected() == 0 { return pgx.ErrNoRows } return nil } ``` - [ ] **Step 2: Create handler.go** Create `backend/internal/staff/handler.go`: ```go package staff import ( "errors" "github.com/gofiber/fiber/v2" "github.com/jackc/pgx/v5" "github.com/techxcar/backend/internal/auth" "github.com/techxcar/backend/pkg/database" ) func RegisterRoutes(app *fiber.App, db *database.DB, secret string) { ro := []fiber.Handler{ auth.RequireAuth(secret), auth.RequireRole("tenant_admin", "manager", "technician"), auth.TenantMiddleware(db), } write := []fiber.Handler{ auth.RequireAuth(secret), auth.RequireRole("tenant_admin", "manager"), auth.TenantMiddleware(db), } app.Get("/api/v1/staff", append(ro, listStaffH())...) app.Post("/api/v1/staff", append(write, createStaffH())...) app.Put("/api/v1/staff/:id", append(write, updateStaffH())...) app.Delete("/api/v1/staff/:id", append(write, deleteStaffH())...) } func listStaffH() fiber.Handler { return func(c *fiber.Ctx) error { conn := auth.GetConn(c) list, err := ListStaff(c.Context(), conn) if err != nil { return fiber.NewError(500, "erro ao listar técnicos") } if list == nil { list = []*Staff{} } return c.JSON(fiber.Map{"data": list, "error": nil}) } } type staffBody struct { Name string `json:"name"` Email string `json:"email"` Phone string `json:"phone"` Type string `json:"type"` HourlyRate float64 `json:"hourly_rate"` Active bool `json:"active"` } func createStaffH() fiber.Handler { return func(c *fiber.Ctx) error { var b staffBody if err := c.BodyParser(&b); err != nil { return fiber.NewError(400, "corpo do pedido inválido") } if b.Name == "" { return fiber.NewError(400, "nome obrigatório") } if b.Type != "internal" && b.Type != "external" { return fiber.NewError(400, "tipo deve ser 'internal' ou 'external'") } conn := auth.GetConn(c) s, err := CreateStaff(c.Context(), conn, b.Name, b.Email, b.Phone, b.Type, b.HourlyRate) if err != nil { return fiber.NewError(500, "erro ao criar técnico") } return c.Status(201).JSON(fiber.Map{"data": s, "error": nil}) } } func updateStaffH() fiber.Handler { return func(c *fiber.Ctx) error { id := c.Params("id") var b staffBody if err := c.BodyParser(&b); err != nil { return fiber.NewError(400, "corpo do pedido inválido") } if b.Name == "" { return fiber.NewError(400, "nome obrigatório") } conn := auth.GetConn(c) s, err := UpdateStaff(c.Context(), conn, id, b.Name, b.Email, b.Phone, b.Type, b.HourlyRate, b.Active) if err != nil { return fiber.NewError(500, "erro ao actualizar técnico") } if s == nil { return fiber.NewError(404, "técnico não encontrado") } return c.JSON(fiber.Map{"data": s, "error": nil}) } } func deleteStaffH() fiber.Handler { return func(c *fiber.Ctx) error { id := c.Params("id") conn := auth.GetConn(c) err := DeleteStaff(c.Context(), conn, id) if errors.Is(err, pgx.ErrNoRows) { return fiber.NewError(404, "técnico não encontrado") } if err != nil { return fiber.NewError(500, "erro ao eliminar técnico") } return c.SendStatus(204) } } ``` - [ ] **Step 3: Compile check** ```bash cd /var/home/lmilani/Documentos/IDE/techxcar/backend export PATH=$PATH:/var/home/lmilani/.local/go/bin go build ./... ``` Expected: no output (success). - [ ] **Step 4: Commit** ```bash cd /var/home/lmilani/Documentos/IDE/techxcar git add backend/internal/staff/ git commit -m "feat: staff repository and HTTP handlers (list, create, update, delete)" ``` --- ### Task 2: Expense Repository + Handlers **Files:** - Create: `backend/internal/expense/repository.go` - Create: `backend/internal/expense/handler.go` **Interfaces:** - Produces: - `Expense{ID, VehicleID *string, Type, Description string, Amount float64, Date time.Time, CreatedAt time.Time}` - `ListExpenses(ctx, conn, typeFilter string) ([]*Expense, error)` — empty typeFilter = all - `CreateExpense(ctx, conn, vehicleID *string, expType, description string, amount float64, date time.Time) (*Expense, error)` - `DeleteExpense(ctx, conn, id string) error` - `expense.RegisterRoutes(app, db, secret)` - `GET /api/v1/expenses` → `[]*Expense` (query: `?type=fuel`) - `POST /api/v1/expenses` → `*Expense` 201 - `DELETE /api/v1/expenses/:id` → 204 - [ ] **Step 1: Create repository.go** Create `backend/internal/expense/repository.go`: ```go package expense import ( "context" "fmt" "time" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" ) type Expense struct { ID string `json:"id"` VehicleID *string `json:"vehicle_id"` Type string `json:"type"` Amount float64 `json:"amount"` Description string `json:"description"` Date time.Time `json:"date"` CreatedAt time.Time `json:"created_at"` } func ListExpenses(ctx context.Context, conn *pgxpool.Conn, typeFilter string) ([]*Expense, error) { q := `SELECT id, vehicle_id, type, amount, COALESCE(description,''), date, created_at FROM expenses` args := []any{} if typeFilter != "" { q += " WHERE type = $1" args = append(args, typeFilter) } q += " ORDER BY date DESC, created_at DESC" rows, err := conn.Query(ctx, q, args...) if err != nil { return nil, fmt.Errorf("expense: list: %w", err) } defer rows.Close() var list []*Expense for rows.Next() { var e Expense if err := rows.Scan(&e.ID, &e.VehicleID, &e.Type, &e.Amount, &e.Description, &e.Date, &e.CreatedAt); err != nil { return nil, fmt.Errorf("expense: scan: %w", err) } list = append(list, &e) } return list, nil } func CreateExpense(ctx context.Context, conn *pgxpool.Conn, vehicleID *string, expType, description string, amount float64, date time.Time) (*Expense, error) { row := conn.QueryRow(ctx, `INSERT INTO expenses (vehicle_id, type, amount, description, date) VALUES ($1, $2, $3, $4, $5) RETURNING id, vehicle_id, type, amount, COALESCE(description,''), date, created_at`, vehicleID, expType, amount, description, date) var e Expense if err := row.Scan(&e.ID, &e.VehicleID, &e.Type, &e.Amount, &e.Description, &e.Date, &e.CreatedAt); err != nil { return nil, fmt.Errorf("expense: create: %w", err) } return &e, nil } func DeleteExpense(ctx context.Context, conn *pgxpool.Conn, id string) error { tag, err := conn.Exec(ctx, `DELETE FROM expenses WHERE id = $1`, id) if err != nil { return fmt.Errorf("expense: delete: %w", err) } if tag.RowsAffected() == 0 { return pgx.ErrNoRows } return nil } ``` - [ ] **Step 2: Create handler.go** Create `backend/internal/expense/handler.go`: ```go package expense import ( "errors" "time" "github.com/gofiber/fiber/v2" "github.com/jackc/pgx/v5" "github.com/techxcar/backend/internal/auth" "github.com/techxcar/backend/pkg/database" ) var allowedTypes = map[string]bool{"fuel": true, "parts": true, "tools": true, "other": true} func RegisterRoutes(app *fiber.App, db *database.DB, secret string) { ro := []fiber.Handler{ auth.RequireAuth(secret), auth.RequireRole("tenant_admin", "manager", "technician"), auth.TenantMiddleware(db), } write := []fiber.Handler{ auth.RequireAuth(secret), auth.RequireRole("tenant_admin", "manager"), auth.TenantMiddleware(db), } app.Get("/api/v1/expenses", append(ro, listExpensesH())...) app.Post("/api/v1/expenses", append(write, createExpenseH())...) app.Delete("/api/v1/expenses/:id", append(write, deleteExpenseH())...) } func listExpensesH() fiber.Handler { return func(c *fiber.Ctx) error { conn := auth.GetConn(c) list, err := ListExpenses(c.Context(), conn, c.Query("type")) if err != nil { return fiber.NewError(500, "erro ao listar despesas") } if list == nil { list = []*Expense{} } return c.JSON(fiber.Map{"data": list, "error": nil}) } } type expenseBody struct { VehicleID string `json:"vehicle_id"` Type string `json:"type"` Amount float64 `json:"amount"` Description string `json:"description"` Date string `json:"date"` // YYYY-MM-DD } func createExpenseH() fiber.Handler { return func(c *fiber.Ctx) error { var b expenseBody if err := c.BodyParser(&b); err != nil { return fiber.NewError(400, "corpo do pedido inválido") } if !allowedTypes[b.Type] { return fiber.NewError(400, "tipo inválido: fuel, parts, tools, other") } if b.Amount <= 0 { return fiber.NewError(400, "valor deve ser positivo") } if b.Date == "" { return fiber.NewError(400, "data obrigatória") } date, err := time.Parse("2006-01-02", b.Date) if err != nil { return fiber.NewError(400, "data inválida (formato: YYYY-MM-DD)") } var vehicleID *string if b.VehicleID != "" { vehicleID = &b.VehicleID } conn := auth.GetConn(c) e, err := CreateExpense(c.Context(), conn, vehicleID, b.Type, b.Description, b.Amount, date) if err != nil { return fiber.NewError(500, "erro ao registar despesa") } return c.Status(201).JSON(fiber.Map{"data": e, "error": nil}) } } func deleteExpenseH() fiber.Handler { return func(c *fiber.Ctx) error { id := c.Params("id") conn := auth.GetConn(c) err := DeleteExpense(c.Context(), conn, id) if errors.Is(err, pgx.ErrNoRows) { return fiber.NewError(404, "despesa não encontrada") } if err != nil { return fiber.NewError(500, "erro ao eliminar despesa") } return c.SendStatus(204) } } ``` - [ ] **Step 3: Compile check** ```bash cd /var/home/lmilani/Documentos/IDE/techxcar/backend export PATH=$PATH:/var/home/lmilani/.local/go/bin go build ./... ``` Expected: no output. - [ ] **Step 4: Commit** ```bash cd /var/home/lmilani/Documentos/IDE/techxcar git add backend/internal/expense/ git commit -m "feat: expense repository and HTTP handlers (list, create, delete)" ``` --- ### Task 3: Settings Repository + Handlers **Files:** - Create: `backend/internal/settings/repository.go` - Create: `backend/internal/settings/handler.go` **Interfaces:** - Produces: - `AllowedKeys` — set of valid setting keys - `GetSettings(ctx, conn) (map[string]string, error)` — returns all settings - `SetSetting(ctx, conn, key, value string) error` - `settings.RegisterRoutes(app, db, secret)` - `GET /api/v1/settings` → `map[string]string` (admin/manager) - `PUT /api/v1/settings` → `map[string]string` (admin only) — body: `{"key":"value",...}` Valid keys: `company_name`, `company_nif`, `company_address`, `company_iban`, `company_phone`, `company_email` - [ ] **Step 1: Create repository.go** Create `backend/internal/settings/repository.go`: ```go package settings import ( "context" "fmt" "github.com/jackc/pgx/v5/pgxpool" ) var AllowedKeys = map[string]bool{ "company_name": true, "company_nif": true, "company_address": true, "company_iban": true, "company_phone": true, "company_email": true, } func GetSettings(ctx context.Context, conn *pgxpool.Conn) (map[string]string, error) { rows, err := conn.Query(ctx, `SELECT key, value FROM tenant_settings`) if err != nil { return nil, fmt.Errorf("settings: get: %w", err) } defer rows.Close() result := map[string]string{} for rows.Next() { var k, v string if err := rows.Scan(&k, &v); err != nil { return nil, fmt.Errorf("settings: scan: %w", err) } result[k] = v } return result, nil } func SetSetting(ctx context.Context, conn *pgxpool.Conn, key, value string) error { _, err := conn.Exec(ctx, `INSERT INTO tenant_settings (key, value) VALUES ($1, $2) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()`, key, value) if err != nil { return fmt.Errorf("settings: set %s: %w", key, err) } return nil } ``` - [ ] **Step 2: Create handler.go** Create `backend/internal/settings/handler.go`: ```go package settings import ( "github.com/gofiber/fiber/v2" "github.com/techxcar/backend/internal/auth" "github.com/techxcar/backend/pkg/database" ) func RegisterRoutes(app *fiber.App, db *database.DB, secret string) { read := []fiber.Handler{ auth.RequireAuth(secret), auth.RequireRole("tenant_admin", "manager"), auth.TenantMiddleware(db), } admin := []fiber.Handler{ auth.RequireAuth(secret), auth.RequireRole("tenant_admin"), auth.TenantMiddleware(db), } app.Get("/api/v1/settings", append(read, getSettingsH())...) app.Put("/api/v1/settings", append(admin, updateSettingsH())...) } func getSettingsH() fiber.Handler { return func(c *fiber.Ctx) error { conn := auth.GetConn(c) s, err := GetSettings(c.Context(), conn) if err != nil { return fiber.NewError(500, "erro ao obter definições") } return c.JSON(fiber.Map{"data": s, "error": nil}) } } func updateSettingsH() fiber.Handler { return func(c *fiber.Ctx) error { var body map[string]string if err := c.BodyParser(&body); err != nil { return fiber.NewError(400, "corpo do pedido inválido") } conn := auth.GetConn(c) for k, v := range body { if !AllowedKeys[k] { return fiber.NewError(400, "chave inválida: "+k) } if err := SetSetting(c.Context(), conn, k, v); err != nil { return fiber.NewError(500, "erro ao guardar definição: "+k) } } s, err := GetSettings(c.Context(), conn) if err != nil { return fiber.NewError(500, "erro ao obter definições") } return c.JSON(fiber.Map{"data": s, "error": nil}) } } ``` - [ ] **Step 3: Compile check + commit** ```bash cd /var/home/lmilani/Documentos/IDE/techxcar/backend export PATH=$PATH:/var/home/lmilani/.local/go/bin go build ./... git add backend/internal/settings/ git commit -m "feat: settings repository and HTTP handlers (get, update tenant settings)" ``` --- ### Task 4: PDF Package + go.mod **Files:** - Create: `backend/pkg/pdf/pdf.go` - Modify: `backend/go.mod` (add `github.com/go-pdf/fpdf/v2`) **Interfaces:** - Produces: - `pdf.DocMeta{CompanyName, CompanyNIF, CompanyAddress, CompanyIBAN, CompanyPhone, CompanyEmail, DocType, DocNumber, IssuedAt, ClientName, ClientNIF, VehiclePlate string}` - `pdf.LineItem{Description string, Qty, UnitPrice, DiscountPct, Total float64}` - `pdf.Generate(meta DocMeta, items []LineItem, staffTotal float64, outPath string) error` - [ ] **Step 1: Add dependency** ```bash cd /var/home/lmilani/Documentos/IDE/techxcar/backend export PATH=$PATH:/var/home/lmilani/.local/go/bin go get github.com/go-pdf/fpdf/v2 ``` Expected: `go.mod` and `go.sum` updated. - [ ] **Step 2: Create pdf.go** Create `backend/pkg/pdf/pdf.go`: ```go package pdf import ( "fmt" "os" "path/filepath" "github.com/go-pdf/fpdf/v2" ) type DocMeta struct { CompanyName string CompanyNIF string CompanyAddress string CompanyIBAN string CompanyPhone string CompanyEmail string DocType string // "Fatura" or "Orçamento" DocNumber string // e.g. "F2024/001" IssuedAt string // e.g. "30/06/2026" ClientName string ClientNIF string VehiclePlate string } type LineItem struct { Description string Qty float64 UnitPrice float64 DiscountPct float64 Total float64 } func Generate(meta DocMeta, items []LineItem, staffTotal float64, outPath string) error { if err := os.MkdirAll(filepath.Dir(outPath), 0755); err != nil { return fmt.Errorf("pdf: mkdir: %w", err) } f := fpdf.New("P", "mm", "A4", "") f.AddPage() f.SetMargins(15, 15, 15) // Header: company info f.SetFont("Helvetica", "B", 18) f.CellFormat(120, 10, meta.CompanyName, "", 0, "L", false, 0, "") f.SetFont("Helvetica", "B", 14) f.CellFormat(60, 10, meta.DocType, "", 1, "R", false, 0, "") f.SetFont("Helvetica", "", 9) if meta.CompanyNIF != "" { f.CellFormat(120, 5, "NIF: "+meta.CompanyNIF, "", 0, "L", false, 0, "") } else { f.CellFormat(120, 5, "", "", 0, "L", false, 0, "") } f.SetFont("Helvetica", "", 11) f.CellFormat(60, 5, meta.DocNumber, "", 1, "R", false, 0, "") f.SetFont("Helvetica", "", 9) if meta.CompanyAddress != "" { f.MultiCell(120, 5, meta.CompanyAddress, "", "L", false) } f.SetXY(f.GetX()+120, f.GetY()) f.SetFont("Helvetica", "", 9) f.CellFormat(60, 5, "Data: "+meta.IssuedAt, "", 1, "R", false, 0, "") f.Ln(5) // Client info if meta.ClientName != "" { f.SetFont("Helvetica", "B", 9) f.CellFormat(180, 5, "Cliente", "", 1, "L", false, 0, "") f.SetFont("Helvetica", "", 9) f.CellFormat(180, 5, meta.ClientName, "", 1, "L", false, 0, "") if meta.ClientNIF != "" { f.CellFormat(180, 5, "NIF: "+meta.ClientNIF, "", 1, "L", false, 0, "") } if meta.VehiclePlate != "" { f.CellFormat(180, 5, "Matrícula: "+meta.VehiclePlate, "", 1, "L", false, 0, "") } f.Ln(5) } // Table header f.SetFillColor(50, 50, 50) f.SetTextColor(255, 255, 255) f.SetFont("Helvetica", "B", 9) f.CellFormat(90, 7, "Descrição", "1", 0, "L", true, 0, "") f.CellFormat(20, 7, "Qtd.", "1", 0, "C", true, 0, "") f.CellFormat(25, 7, "Preço 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, "") // Table rows f.SetFillColor(245, 245, 245) f.SetTextColor(0, 0, 0) f.SetFont("Helvetica", "", 9) fill := false for _, item := range items { f.CellFormat(90, 6, 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 €", item.UnitPrice), "1", 0, "R", fill, 0, "") f.CellFormat(20, 6, fmt.Sprintf("%.0f%%", item.DiscountPct), "1", 0, "C", fill, 0, "") f.CellFormat(25, 6, fmt.Sprintf("%.2f €", item.Total), "1", 1, "R", fill, 0, "") fill = !fill } // Totals var subtotal float64 for _, item := range items { subtotal += item.Total } grandTotal := subtotal + staffTotal f.Ln(3) f.SetFont("Helvetica", "", 9) if staffTotal > 0 { f.CellFormat(155, 6, "Subtotal peças/serviços", "", 0, "R", false, 0, "") f.CellFormat(25, 6, fmt.Sprintf("%.2f €", subtotal), "1", 1, "R", false, 0, "") f.CellFormat(155, 6, "Mão de obra", "", 0, "R", false, 0, "") f.CellFormat(25, 6, fmt.Sprintf("%.2f €", staffTotal), "1", 1, "R", false, 0, "") } f.SetFont("Helvetica", "B", 10) f.CellFormat(155, 7, "TOTAL", "", 0, "R", false, 0, "") f.CellFormat(25, 7, fmt.Sprintf("%.2f €", grandTotal), "1", 1, "R", false, 0, "") // Footer: IBAN if meta.CompanyIBAN != "" { f.Ln(8) f.SetFont("Helvetica", "", 8) f.CellFormat(180, 5, "IBAN: "+meta.CompanyIBAN, "", 1, "C", false, 0, "") } return f.OutputFileAndClose(outPath) } ``` - [ ] **Step 3: Compile check** ```bash cd /var/home/lmilani/Documentos/IDE/techxcar/backend export PATH=$PATH:/var/home/lmilani/.local/go/bin go build ./... ``` Expected: no output. - [ ] **Step 4: Commit** ```bash cd /var/home/lmilani/Documentos/IDE/techxcar git add backend/pkg/pdf/ backend/go.mod backend/go.sum git commit -m "feat: PDF generation package using go-pdf/fpdf (pure Go, no Chrome dependency)" ``` --- ### Task 5: Invoice Repository + Handlers **Files:** - Create: `backend/internal/invoice/repository.go` - Create: `backend/internal/invoice/handler.go` **Interfaces:** - Consumes: `pkg/pdf.Generate`, `internal/settings.GetSettings`, `internal/workorder` types - Produces: - `Invoice{ID, WorkOrderID, Type, Number int, PDFPath string, IssuedAt time.Time, CreatedAt time.Time}` - `ListInvoices(ctx, conn) ([]*Invoice, error)` - `GetInvoice(ctx, conn, id) (*Invoice, error)` — nil if not found - `CreateInvoice(ctx, conn, woID, docType string) (*Invoice, error)` — inserts row, returns with generated number - `SetPDFPath(ctx, conn, id, path string) error` - `invoice.RegisterRoutes(app, db, secret)` - `GET /api/v1/invoices` → `[]*Invoice` - `POST /api/v1/invoices` → `*Invoice` 201 — body: `{work_order_id, type: "quote"|"invoice"}`; generates PDF; if type=invoice transitions WO to invoiced - `GET /api/v1/invoices/:id/pdf` → binary PDF stream (authenticated) - [ ] **Step 1: Create repository.go** Create `backend/internal/invoice/repository.go`: ```go package invoice import ( "context" "fmt" "time" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" ) type Invoice struct { ID string `json:"id"` WorkOrderID string `json:"work_order_id"` Type string `json:"type"` Number int `json:"number"` PDFPath string `json:"pdf_path"` IssuedAt time.Time `json:"issued_at"` CreatedAt time.Time `json:"created_at"` } 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 FROM invoices ORDER BY issued_at DESC`) if err != nil { return nil, fmt.Errorf("invoice: list: %w", err) } defer rows.Close() var list []*Invoice for rows.Next() { var inv Invoice if err := rows.Scan(&inv.ID, &inv.WorkOrderID, &inv.Type, &inv.Number, &inv.PDFPath, &inv.IssuedAt, &inv.CreatedAt); err != nil { return nil, fmt.Errorf("invoice: scan: %w", err) } list = append(list, &inv) } return list, nil } 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 FROM invoices WHERE id = $1`, id) var inv Invoice err := row.Scan(&inv.ID, &inv.WorkOrderID, &inv.Type, &inv.Number, &inv.PDFPath, &inv.IssuedAt, &inv.CreatedAt) if err == pgx.ErrNoRows { return nil, nil } if err != nil { return nil, fmt.Errorf("invoice: get: %w", err) } return &inv, nil } func CreateInvoice(ctx context.Context, conn *pgxpool.Conn, woID, docType string) (*Invoice, error) { 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`, woID, docType) var inv Invoice if err := row.Scan(&inv.ID, &inv.WorkOrderID, &inv.Type, &inv.Number, &inv.PDFPath, &inv.IssuedAt, &inv.CreatedAt); err != nil { return nil, fmt.Errorf("invoice: create: %w", err) } return &inv, nil } func SetPDFPath(ctx context.Context, conn *pgxpool.Conn, id, path string) error { _, err := conn.Exec(ctx, `UPDATE invoices SET pdf_path = $2 WHERE id = $1`, id, path) if err != nil { return fmt.Errorf("invoice: set pdf path: %w", err) } return nil } ``` - [ ] **Step 2: Create handler.go** Create `backend/internal/invoice/handler.go`: ```go package invoice import ( "fmt" "os" "path/filepath" "strings" "github.com/gofiber/fiber/v2" "github.com/techxcar/backend/internal/auth" "github.com/techxcar/backend/internal/settings" "github.com/techxcar/backend/internal/workorder" "github.com/techxcar/backend/pkg/database" "github.com/techxcar/backend/pkg/pdf" ) const storageRoot = "/app/storage" func RegisterRoutes(app *fiber.App, db *database.DB, secret string) { ro := []fiber.Handler{ auth.RequireAuth(secret), auth.RequireRole("tenant_admin", "manager", "technician"), auth.TenantMiddleware(db), } write := []fiber.Handler{ auth.RequireAuth(secret), auth.RequireRole("tenant_admin", "manager"), auth.TenantMiddleware(db), } app.Get("/api/v1/invoices", append(ro, listInvoicesH())...) app.Post("/api/v1/invoices", append(write, createInvoiceH())...) app.Get("/api/v1/invoices/:id/pdf", append(ro, downloadPDFH())...) } func listInvoicesH() fiber.Handler { return func(c *fiber.Ctx) error { conn := auth.GetConn(c) list, err := ListInvoices(c.Context(), conn) if err != nil { return fiber.NewError(500, "erro ao listar faturas") } if list == nil { list = []*Invoice{} } return c.JSON(fiber.Map{"data": list, "error": nil}) } } type createBody struct { WorkOrderID string `json:"work_order_id"` Type string `json:"type"` // "quote" or "invoice" } func createInvoiceH() fiber.Handler { return func(c *fiber.Ctx) error { var b createBody if err := c.BodyParser(&b); err != nil { return fiber.NewError(400, "corpo do pedido inválido") } if b.WorkOrderID == "" { return fiber.NewError(400, "work_order_id obrigatório") } if b.Type != "quote" && b.Type != "invoice" { return fiber.NewError(400, "tipo deve ser 'quote' ou 'invoice'") } conn := auth.GetConn(c) // Fetch WO detail detail, err := workorder.GetWorkOrderDetail(c.Context(), conn, b.WorkOrderID) if err != nil { return fiber.NewError(500, "erro ao obter ordem de trabalho") } if detail == nil { return fiber.NewError(404, "ordem de trabalho não encontrada") } // Fetch tenant settings sett, err := settings.GetSettings(c.Context(), conn) if err != nil { return fiber.NewError(500, "erro ao obter definições") } // Fetch client info if present var clientName, clientNIF, vehiclePlate string if detail.ClientID != nil { row := conn.QueryRow(c.Context(), `SELECT name, COALESCE(nif,'') FROM clients WHERE id = $1`, *detail.ClientID) _ = row.Scan(&clientName, &clientNIF) } if detail.VehicleID != nil { row := conn.QueryRow(c.Context(), `SELECT plate FROM vehicles WHERE id = $1`, *detail.VehicleID) _ = row.Scan(&vehiclePlate) } // Create invoice record inv, err := CreateInvoice(c.Context(), conn, b.WorkOrderID, b.Type) if err != nil { return fiber.NewError(500, "erro ao criar fatura") } // Build PDF meta docType := "Orçamento" if b.Type == "invoice" { docType = "Fatura" } prefix := "ORC" if b.Type == "invoice" { prefix = "FAT" } // Derive tenant schema from search_path for storage directory var searchPath string row := conn.QueryRow(c.Context(), `SHOW search_path`) _ = row.Scan(&searchPath) // extract first segment (e.g. "tenant_abc123_def456_...") tenantDir := strings.Split(strings.TrimSpace(searchPath), ",")[0] tenantDir = strings.TrimSpace(tenantDir) outPath := filepath.Join(storageRoot, tenantDir, 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"], 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, } // Build line items 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 } // Generate PDF if err := pdf.Generate(meta, lineItems, staffTotal, outPath); err != nil { return fiber.NewError(500, "erro ao gerar PDF") } // Store path 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 invoice (not quote), transition WO to invoiced if b.Type == "invoice" { if err := workorder.TransitionStatus(c.Context(), conn, b.WorkOrderID, detail.Status, "invoiced", ""); err != nil { return fiber.NewError(500, "erro ao atualizar estado da ordem") } } return c.Status(201).JSON(fiber.Map{"data": inv, "error": nil}) } } func downloadPDFH() fiber.Handler { return func(c *fiber.Ctx) error { id := c.Params("id") conn := auth.GetConn(c) inv, err := GetInvoice(c.Context(), conn, id) if err != nil { return fiber.NewError(500, "erro interno") } if inv == nil { return fiber.NewError(404, "fatura não encontrada") } if inv.PDFPath == "" { return fiber.NewError(404, "PDF não disponível") } if _, err := os.Stat(inv.PDFPath); 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) } } ``` Note: `handler.go` calls `workorder.GetWorkOrderDetail` and `workorder.TransitionStatus` — these functions must be exported from the `workorder` package. Check if they are already exported. If `GetWorkOrderDetail` is unexported (lowercase), rename it. - [ ] **Step 3: Verify workorder exports** Check `backend/internal/workorder/repository.go` for `GetWorkOrderDetail` and `TransitionStatus`. If unexported, rename them: ```bash grep -n "func getWorkOrderDetail\|func transitionStatus\|func GetWorkOrderDetail\|func TransitionStatus" \ /var/home/lmilani/Documentos/IDE/techxcar/backend/internal/workorder/repository.go \ /var/home/lmilani/Documentos/IDE/techxcar/backend/internal/workorder/handler.go ``` If the functions are in `handler.go` as unexported closures (not top-level named functions), extract them to `repository.go` as exported functions. The typical pattern in this codebase puts DB logic in `repository.go`. Adjust as needed to ensure `GetWorkOrderDetail(ctx, conn, id) (*WorkOrderDetail, error)` and `TransitionStatus(ctx, conn, id, from, to, changedBy string) error` are exported and callable from `invoice/handler.go`. - [ ] **Step 4: Compile check** ```bash cd /var/home/lmilani/Documentos/IDE/techxcar/backend export PATH=$PATH:/var/home/lmilani/.local/go/bin go build ./... ``` Fix any compilation errors (missing exports, import cycles). There should be no import cycle since `invoice` imports `workorder` and `settings`, not the other way around. - [ ] **Step 5: Commit** ```bash cd /var/home/lmilani/Documentos/IDE/techxcar git add backend/internal/invoice/ backend/internal/workorder/ git commit -m "feat: invoice repository and HTTP handlers (generate PDF, list, download)" ``` --- ### Task 6: Wire Routes + Dockerfile Storage Dir **Files:** - Modify: `backend/internal/server/server.go` - Modify: `backend/Dockerfile` - [ ] **Step 1: Update server.go** In `backend/internal/server/server.go`, add imports and wire 4 new packages: ```go import ( // existing imports... "github.com/techxcar/backend/internal/expense" "github.com/techxcar/backend/internal/invoice" "github.com/techxcar/backend/internal/settings" "github.com/techxcar/backend/internal/staff" ) ``` Inside `if deps.DB != nil && deps.Redis != nil && deps.Config != nil {` block, after the existing route registrations: ```go staff.RegisterRoutes(app, deps.DB, deps.Config.JWTSecret) expense.RegisterRoutes(app, deps.DB, deps.Config.JWTSecret) settings.RegisterRoutes(app, deps.DB, deps.Config.JWTSecret) invoice.RegisterRoutes(app, deps.DB, deps.Config.JWTSecret) ``` - [ ] **Step 2: Update Dockerfile to create storage volume dir** In `backend/Dockerfile`, add the storage directory creation before the `USER app` line: ```dockerfile RUN mkdir -p /app/storage && chown -R app:app /app ``` The full updated Dockerfile: ```dockerfile FROM golang:1.25-alpine AS builder WORKDIR /app COPY go.mod go.sum ./ RUN go mod download COPY . . RUN CGO_ENABLED=0 GOOS=linux go build -o server ./cmd/server FROM alpine:3.19 RUN apk --no-cache add ca-certificates tzdata RUN addgroup -S app && adduser -S app -G app WORKDIR /app RUN mkdir -p /app/storage && chown -R app:app /app COPY --from=builder /app/server . COPY --from=builder /app/migrations ./migrations USER app EXPOSE 8080 CMD ["./server"] ``` - [ ] **Step 3: Full backend build + compile check** ```bash cd /var/home/lmilani/Documentos/IDE/techxcar/backend export PATH=$PATH:/var/home/lmilani/.local/go/bin go build ./... go test ./... 2>&1 | grep -v "^---" | grep -v "^===" ``` Expected: build success; tests pass or skip (no TEST_DATABASE_URL). - [ ] **Step 4: Commit** ```bash cd /var/home/lmilani/Documentos/IDE/techxcar git add backend/internal/server/server.go backend/Dockerfile git commit -m "feat: wire staff, expense, settings, invoice routes; add /app/storage dir" ``` --- ### Task 7: Frontend — Types + AppLayout Nav **Files:** - Modify: `frontend/src/lib/types.ts` - Modify: `frontend/src/components/layout/AppLayout.tsx` - [ ] **Step 1: Add types to types.ts** Append to `frontend/src/lib/types.ts`: ```ts export interface Staff { id: string user_id: string | null name: string email: string phone: string type: 'internal' | 'external' hourly_rate: number active: boolean created_at: string } export interface Expense { id: string vehicle_id: string | null type: 'fuel' | 'parts' | 'tools' | 'other' amount: number description: string date: string created_at: string } export type TenantSettings = Record export interface Invoice { id: string work_order_id: string type: 'quote' | 'invoice' number: number pdf_path: string issued_at: string created_at: string } ``` - [ ] **Step 2: Update AppLayout nav** In `frontend/src/components/layout/AppLayout.tsx`, update the `nav` array: ```tsx const nav = [ { to: '/app', label: 'Dashboard', end: true }, { to: '/app/work-orders', label: 'Ordens de Trabalho' }, { to: '/app/clients', label: 'Clientes' }, { to: '/app/catalog', label: 'Catálogo' }, { to: '/app/staff', label: 'Técnicos' }, { to: '/app/expenses', label: 'Despesas' }, { to: '/app/invoices', label: 'Faturação' }, { to: '/app/settings', label: 'Definições' }, ] ``` - [ ] **Step 3: Commit** ```bash cd /var/home/lmilani/Documentos/IDE/techxcar git add frontend/src/lib/types.ts frontend/src/components/layout/AppLayout.tsx git commit -m "feat: add Staff, Expense, TenantSettings, Invoice types and nav items" ``` --- ### Task 8: Frontend — StaffPage **Files:** - Create: `frontend/src/pages/app/StaffPage.tsx` - Modify: `frontend/src/App.tsx` - [ ] **Step 1: Create StaffPage.tsx** Create `frontend/src/pages/app/StaffPage.tsx`: ```tsx import { useState } from 'react' import { useQuery, useMutation, useQueryClient, type Resolver } from '@tanstack/react-query' import { useForm } from 'react-hook-form' import { zodResolver } from '@hookform/resolvers/zod' import { z } from 'zod' import { apiFetch } from '@/lib/api' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import type { Staff } from '@/lib/types' const STAFF_TYPES = ['internal', 'external'] as const const schema = z.object({ name: z.string().min(1, 'Nome obrigatório'), email: z.string(), phone: z.string(), type: z.enum(STAFF_TYPES), hourly_rate: z.coerce.number().min(0), active: z.boolean(), }) type FormData = z.infer const emptyStaff: FormData = { name: '', email: '', phone: '', type: 'internal', hourly_rate: 0, active: true } export default function StaffPage() { const qc = useQueryClient() const [editing, setEditing] = useState(null) const [showForm, setShowForm] = useState(false) const { data: staff = [], isLoading } = useQuery({ queryKey: ['staff'], queryFn: () => apiFetch('/staff'), }) const { register, handleSubmit, reset, formState: { errors } } = useForm({ resolver: zodResolver(schema) as import('react-hook-form').Resolver, defaultValues: emptyStaff, }) const save = useMutation({ mutationFn: (data: FormData) => editing ? apiFetch(`/staff/${editing.id}`, { method: 'PUT', body: JSON.stringify(data) }) : apiFetch('/staff', { method: 'POST', body: JSON.stringify(data) }), onSuccess: () => { qc.invalidateQueries({ queryKey: ['staff'] }) setShowForm(false) setEditing(null) reset(emptyStaff) }, }) const remove = useMutation({ mutationFn: (id: string) => apiFetch(`/staff/${id}`, { method: 'DELETE' }), onSuccess: () => qc.invalidateQueries({ queryKey: ['staff'] }), }) function openNew() { setEditing(null) reset(emptyStaff) setShowForm(true) } function openEdit(s: Staff) { setEditing(s) reset({ name: s.name, email: s.email, phone: s.phone, type: s.type, hourly_rate: s.hourly_rate, active: s.active }) setShowForm(true) } return (

Técnicos

{staff.length} técnicos

{showForm && (

{editing ? 'Editar Técnico' : 'Novo Técnico'}

save.mutate(d))} className="grid grid-cols-2 gap-4">
{errors.name &&

{errors.name.message}

}
{save.error &&

{save.error.message}

}
)} {isLoading ? (

A carregar...

) : staff.length === 0 ? (

Nenhum técnico registado.

) : (
{staff.map((s) => ( ))}
Nome Tipo Email €/hora Estado
{s.name} {s.type === 'internal' ? 'Interno' : 'Externo'} {s.email || '—'} {s.hourly_rate.toFixed(2)} € {s.active ? 'Activo' : 'Inactivo'}
)}
) } ``` - [ ] **Step 2: Add route to App.tsx** In `frontend/src/App.tsx`, add import: ```tsx import StaffPage from '@/pages/app/StaffPage' ``` Inside the `/app` route group, add: ```tsx } /> ``` - [ ] **Step 3: Build check** ```bash cd /var/home/lmilani/Documentos/IDE/techxcar/frontend npm run build 2>&1 | tail -10 ``` Expected: build success, zero TypeScript errors. - [ ] **Step 4: Commit** ```bash cd /var/home/lmilani/Documentos/IDE/techxcar git add frontend/src/pages/app/StaffPage.tsx frontend/src/App.tsx git commit -m "feat: staff page — list, create/edit/delete technicians" ``` --- ### Task 9: Frontend — ExpensesPage **Files:** - Create: `frontend/src/pages/app/ExpensesPage.tsx` - Modify: `frontend/src/App.tsx` - [ ] **Step 1: Create ExpensesPage.tsx** Create `frontend/src/pages/app/ExpensesPage.tsx`: ```tsx import { useState } from 'react' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { useForm, type Resolver } from 'react-hook-form' import { zodResolver } from '@hookform/resolvers/zod' import { z } from 'zod' import { apiFetch } from '@/lib/api' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import type { Expense } from '@/lib/types' const EXPENSE_TYPES = ['fuel', 'parts', 'tools', 'other'] as const const TYPE_LABELS: Record = { fuel: 'Combustível', parts: 'Peças', tools: 'Ferramentas', other: 'Outros', } const schema = z.object({ type: z.enum(EXPENSE_TYPES), amount: z.coerce.number().positive('Valor deve ser positivo'), description: z.string(), date: z.string().min(1, 'Data obrigatória'), vehicle_id: z.string(), }) type FormData = z.infer const today = new Date().toISOString().split('T')[0] const emptyExpense: FormData = { type: 'fuel', amount: 0, description: '', date: today, vehicle_id: '' } export default function ExpensesPage() { const qc = useQueryClient() const [typeFilter, setTypeFilter] = useState('') const [showForm, setShowForm] = useState(false) const { data: expenses = [], isLoading } = useQuery({ queryKey: ['expenses', typeFilter], queryFn: () => apiFetch(`/expenses${typeFilter ? `?type=${typeFilter}` : ''}`), }) const { register, handleSubmit, reset, formState: { errors } } = useForm({ resolver: zodResolver(schema) as Resolver, defaultValues: emptyExpense, }) const create = useMutation({ mutationFn: (data: FormData) => apiFetch('/expenses', { method: 'POST', body: JSON.stringify(data) }), onSuccess: () => { qc.invalidateQueries({ queryKey: ['expenses'] }) setShowForm(false) reset(emptyExpense) }, }) const remove = useMutation({ mutationFn: (id: string) => apiFetch(`/expenses/${id}`, { method: 'DELETE' }), onSuccess: () => qc.invalidateQueries({ queryKey: ['expenses'] }), }) const total = expenses.reduce((s, e) => s + e.amount, 0) return (

Despesas

{expenses.length} registos — total: {total.toFixed(2)} €

{['', ...EXPENSE_TYPES].map((t) => ( ))}
{showForm && (

Nova Despesa

create.mutate(d))} className="grid grid-cols-2 gap-4">
{errors.amount &&

{errors.amount.message}

}
{errors.date &&

{errors.date.message}

}
{create.error &&

{create.error.message}

}
)} {isLoading ? (

A carregar...

) : expenses.length === 0 ? (

Nenhuma despesa registada.

) : (
{expenses.map((e) => ( ))}
Data Tipo Descrição Valor
{new Intl.DateTimeFormat('pt-PT').format(new Date(e.date))} {TYPE_LABELS[e.type]} {e.description || '—'} {e.amount.toFixed(2)} €
)}
) } ``` - [ ] **Step 2: Add route to App.tsx** ```tsx import ExpensesPage from '@/pages/app/ExpensesPage' // inside /app group: } /> ``` - [ ] **Step 3: Build check + commit** ```bash cd /var/home/lmilani/Documentos/IDE/techxcar/frontend && npm run build 2>&1 | tail -5 cd /var/home/lmilani/Documentos/IDE/techxcar git add frontend/src/pages/app/ExpensesPage.tsx frontend/src/App.tsx git commit -m "feat: expenses page — list with type filter, create, delete" ``` --- ### Task 10: Frontend — SettingsPage **Files:** - Create: `frontend/src/pages/app/SettingsPage.tsx` - Modify: `frontend/src/App.tsx` - [ ] **Step 1: Create SettingsPage.tsx** Create `frontend/src/pages/app/SettingsPage.tsx`: ```tsx import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { useForm } from 'react-hook-form' import { useEffect } from 'react' import { apiFetch } from '@/lib/api' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import type { TenantSettings } from '@/lib/types' type FormData = { company_name: string company_nif: string company_address: string company_iban: string company_phone: string company_email: string } const defaultSettings: FormData = { company_name: '', company_nif: '', company_address: '', company_iban: '', company_phone: '', company_email: '', } export default function SettingsPage() { const qc = useQueryClient() const { data: settings, isLoading } = useQuery({ queryKey: ['settings'], queryFn: () => apiFetch('/settings'), }) const { register, handleSubmit, reset } = useForm({ defaultValues: defaultSettings, }) useEffect(() => { if (settings) { reset({ company_name: settings['company_name'] ?? '', company_nif: settings['company_nif'] ?? '', company_address: settings['company_address'] ?? '', company_iban: settings['company_iban'] ?? '', company_phone: settings['company_phone'] ?? '', company_email: settings['company_email'] ?? '', }) } }, [settings, reset]) const save = useMutation({ mutationFn: (data: FormData) => apiFetch('/settings', { method: 'PUT', body: JSON.stringify(data) }), onSuccess: () => qc.invalidateQueries({ queryKey: ['settings'] }), }) if (isLoading) return

A carregar...

return (

Definições

Dados da oficina utilizados nos documentos PDF

save.mutate(d))} className="bg-slate-800 rounded-lg border border-slate-700 p-6 space-y-4" >
{save.error && (

{save.error.message}

)} {save.isSuccess && (

Definições guardadas.

)}
) } ``` - [ ] **Step 2: Add route to App.tsx** ```tsx import SettingsPage from '@/pages/app/SettingsPage' // inside /app group: } /> ``` - [ ] **Step 3: Build check + commit** ```bash cd /var/home/lmilani/Documentos/IDE/techxcar/frontend && npm run build 2>&1 | tail -5 cd /var/home/lmilani/Documentos/IDE/techxcar git add frontend/src/pages/app/SettingsPage.tsx frontend/src/App.tsx git commit -m "feat: settings page — tenant company data for PDF generation" ``` --- ### Task 11: Frontend — InvoicesPage **Files:** - Create: `frontend/src/pages/app/InvoicesPage.tsx` - Modify: `frontend/src/App.tsx` - [ ] **Step 1: Create InvoicesPage.tsx** Create `frontend/src/pages/app/InvoicesPage.tsx`: ```tsx import { useState } from 'react' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { apiFetch } from '@/lib/api' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import type { Invoice, WorkOrder } from '@/lib/types' const TYPE_LABELS: Record = { quote: 'Orçamento', invoice: 'Fatura' } export default function InvoicesPage() { const qc = useQueryClient() const [showGenerate, setShowGenerate] = useState(false) const [selectedWO, setSelectedWO] = useState('') const [docType, setDocType] = useState<'quote' | 'invoice'>('quote') const { data: invoices = [], isLoading } = useQuery({ queryKey: ['invoices'], queryFn: () => apiFetch('/invoices'), }) const { data: workOrders = [] } = useQuery({ queryKey: ['work-orders-for-invoice'], queryFn: () => apiFetch('/work-orders'), enabled: showGenerate, }) // Filter: only OTs that haven't been invoiced yet (or completed for invoice) const eligibleWOs = workOrders.filter((wo) => wo.status === 'open' || wo.status === 'in_progress' || wo.status === 'completed' ) const generate = useMutation({ mutationFn: () => apiFetch('/invoices', { method: 'POST', body: JSON.stringify({ work_order_id: selectedWO, type: docType }), }), onSuccess: () => { qc.invalidateQueries({ queryKey: ['invoices'] }) qc.invalidateQueries({ queryKey: ['work-orders'] }) setShowGenerate(false) setSelectedWO('') }, }) function handleDownload(inv: Invoice) { window.open(`/api/v1/invoices/${inv.id}/pdf`, '_blank') } return (

Faturação

{invoices.length} documentos

{showGenerate && (

Gerar Documento

{generate.error && (

{generate.error.message}

)}
)} {isLoading ? (

A carregar...

) : invoices.length === 0 ? (

Nenhum documento gerado.

) : (
{invoices.map((inv) => ( ))}
Tipo OT Emitida
#{inv.number} {TYPE_LABELS[inv.type]} {inv.work_order_id.slice(0, 8)}… {new Intl.DateTimeFormat('pt-PT').format(new Date(inv.issued_at))}
)}
) } ``` - [ ] **Step 2: Add route to App.tsx** ```tsx import InvoicesPage from '@/pages/app/InvoicesPage' // inside /app group: } /> ``` - [ ] **Step 3: Full build + tests** ```bash cd /var/home/lmilani/Documentos/IDE/techxcar/frontend npm run build 2>&1 | tail -10 npm run test:run ``` Expected: build success, 10 tests pass. - [ ] **Step 4: Commit** ```bash cd /var/home/lmilani/Documentos/IDE/techxcar git add frontend/src/pages/app/InvoicesPage.tsx frontend/src/App.tsx git commit -m "feat: invoices page — generate quote/invoice PDF from work order, list, download" ``` --- ## Self-Review **Spec coverage:** - ✅ Técnicos: internal/external, hourly_rate, active/inactive, CRUD — Tasks 1, 8 - ✅ Despesas: tipo (fuel/parts/tools/other), valor, data, filtro — Tasks 2, 9 - ✅ tenant_settings: company_name, NIF, address, IBAN, phone, email — Tasks 3, 10 - ✅ PDF gerado em Go com go-pdf/fpdf (pure Go, pragmatic MVP — chromedp deferred) — Task 4 - ✅ Fatura + Orçamento: gerados a partir de OT, numeração sequencial, PDF guardado — Tasks 5, 11 - ✅ Fatura transiciona OT para "invoiced" — Task 5 - ✅ PDF endpoint autenticado `/api/v1/invoices/:id/pdf` — Task 5 - ✅ `/app/storage` volume dir criado no Dockerfile — Task 6 **Not in Plan 4 (deferred to Plan 5):** - Dashboard KPIs (receita mês, OTs por estado) - Relatórios com filtro de período - Exportação CSV - Notificações Telegram/Email - Platform settings (SMTP, Telegram) — only tenant settings in this plan