Files
techxcar/docs/superpowers/plans/2026-06-30-plan4-staff-expenses-invoices-settings.md
Luciano Milani 5de37bb512 Inicial
2026-07-02 12:47:55 +01:00

70 KiB

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<T> returns response.data directly (see frontend/src/lib/api.ts)
  • PDFs stored at /app/storage/<tenantSchema>/inv_<invoiceID>.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.goGenerate(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:

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:

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
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
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:

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:

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
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
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/settingsmap[string]string (admin/manager)
    • PUT /api/v1/settingsmap[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:

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:

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
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

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:

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
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
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:

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:

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:

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
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
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:

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:

        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:

RUN mkdir -p /app/storage && chown -R app:app /app

The full updated 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
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
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:

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<string, string>

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:

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
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:

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<typeof schema>

const emptyStaff: FormData = { name: '', email: '', phone: '', type: 'internal', hourly_rate: 0, active: true }

export default function StaffPage() {
  const qc = useQueryClient()
  const [editing, setEditing] = useState<Staff | null>(null)
  const [showForm, setShowForm] = useState(false)

  const { data: staff = [], isLoading } = useQuery<Staff[]>({
    queryKey: ['staff'],
    queryFn: () => apiFetch<Staff[]>('/staff'),
  })

  const { register, handleSubmit, reset, formState: { errors } } = useForm<FormData>({
    resolver: zodResolver(schema) as import('react-hook-form').Resolver<FormData>,
    defaultValues: emptyStaff,
  })

  const save = useMutation({
    mutationFn: (data: FormData) =>
      editing
        ? apiFetch<Staff>(`/staff/${editing.id}`, { method: 'PUT', body: JSON.stringify(data) })
        : apiFetch<Staff>('/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 (
    <div>
      <div className="flex items-center justify-between mb-6">
        <div>
          <h1 className="text-2xl font-bold text-white">Técnicos</h1>
          <p className="text-slate-400 text-sm mt-0.5">{staff.length} técnicos</p>
        </div>
        <Button onClick={openNew}>Novo Técnico</Button>
      </div>

      {showForm && (
        <div className="mb-6 bg-slate-800 rounded-lg border border-slate-700 p-6">
          <h2 className="text-lg font-semibold text-white mb-4">
            {editing ? 'Editar Técnico' : 'Novo Técnico'}
          </h2>
          <form onSubmit={handleSubmit((d) => save.mutate(d))} className="grid grid-cols-2 gap-4">
            <div className="col-span-2 space-y-1">
              <Label htmlFor="name">Nome *</Label>
              <Input id="name" {...register('name')} className="bg-slate-900 border-slate-600 text-white" />
              {errors.name && <p className="text-red-400 text-xs">{errors.name.message}</p>}
            </div>
            <div className="space-y-1">
              <Label htmlFor="email">Email</Label>
              <Input id="email" type="email" {...register('email')} className="bg-slate-900 border-slate-600 text-white" />
            </div>
            <div className="space-y-1">
              <Label htmlFor="phone">Telefone</Label>
              <Input id="phone" {...register('phone')} className="bg-slate-900 border-slate-600 text-white" />
            </div>
            <div className="space-y-1">
              <Label htmlFor="type">Tipo</Label>
              <select
                id="type"
                {...register('type')}
                className="w-full rounded-md border border-slate-600 bg-slate-900 text-white px-3 py-2 text-sm"
              >
                <option value="internal">Interno</option>
                <option value="external">Externo</option>
              </select>
            </div>
            <div className="space-y-1">
              <Label htmlFor="hourly_rate">Custo/hora ()</Label>
              <Input id="hourly_rate" type="number" step="0.01" {...register('hourly_rate')}
                className="bg-slate-900 border-slate-600 text-white" />
            </div>
            <div className="flex items-center gap-2 pt-6">
              <input type="checkbox" id="active" {...register('active')} className="h-4 w-4" />
              <Label htmlFor="active">Activo</Label>
            </div>
            {save.error && <p className="col-span-2 text-red-400 text-sm">{save.error.message}</p>}
            <div className="col-span-2 flex gap-2 justify-end">
              <Button type="button" variant="outline" onClick={() => { setShowForm(false); setEditing(null) }}>
                Cancelar
              </Button>
              <Button type="submit" disabled={save.isPending}>
                {save.isPending ? 'A guardar...' : 'Guardar'}
              </Button>
            </div>
          </form>
        </div>
      )}

      {isLoading ? (
        <p className="text-slate-400">A carregar...</p>
      ) : staff.length === 0 ? (
        <p className="text-slate-500 text-sm">Nenhum técnico registado.</p>
      ) : (
        <div className="rounded-lg border border-slate-700 overflow-hidden">
          <table className="w-full text-sm">
            <thead className="bg-slate-800">
              <tr>
                <th className="text-left px-4 py-3 text-slate-400 font-medium">Nome</th>
                <th className="text-left px-4 py-3 text-slate-400 font-medium">Tipo</th>
                <th className="text-left px-4 py-3 text-slate-400 font-medium">Email</th>
                <th className="text-right px-4 py-3 text-slate-400 font-medium">/hora</th>
                <th className="text-left px-4 py-3 text-slate-400 font-medium">Estado</th>
                <th className="px-4 py-3"></th>
              </tr>
            </thead>
            <tbody>
              {staff.map((s) => (
                <tr key={s.id} className="border-t border-slate-700 hover:bg-slate-800/50">
                  <td className="px-4 py-3 text-white font-medium">{s.name}</td>
                  <td className="px-4 py-3 text-slate-400">{s.type === 'internal' ? 'Interno' : 'Externo'}</td>
                  <td className="px-4 py-3 text-slate-400">{s.email || '—'}</td>
                  <td className="px-4 py-3 text-white text-right">{s.hourly_rate.toFixed(2)} </td>
                  <td className="px-4 py-3">
                    <Badge variant={s.active ? 'default' : 'secondary'}>
                      {s.active ? 'Activo' : 'Inactivo'}
                    </Badge>
                  </td>
                  <td className="px-4 py-3 flex gap-2 justify-end">
                    <Button size="sm" variant="outline" onClick={() => openEdit(s)}>Editar</Button>
                    <Button
                      size="sm"
                      variant="destructive"
                      onClick={() => { if (confirm('Eliminar técnico?')) remove.mutate(s.id) }}
                    >
                      Eliminar
                    </Button>
                  </td>
                </tr>
              ))}
            </tbody>
          </table>
        </div>
      )}
    </div>
  )
}
  • Step 2: Add route to App.tsx

In frontend/src/App.tsx, add import:

import StaffPage from '@/pages/app/StaffPage'

Inside the /app route group, add:

<Route path="staff" element={<StaffPage />} />
  • Step 3: Build check
cd /var/home/lmilani/Documentos/IDE/techxcar/frontend
npm run build 2>&1 | tail -10

Expected: build success, zero TypeScript errors.

  • Step 4: Commit
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:

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<string, string> = {
  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<typeof schema>

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<Expense[]>({
    queryKey: ['expenses', typeFilter],
    queryFn: () => apiFetch<Expense[]>(`/expenses${typeFilter ? `?type=${typeFilter}` : ''}`),
  })

  const { register, handleSubmit, reset, formState: { errors } } = useForm<FormData>({
    resolver: zodResolver(schema) as Resolver<FormData>,
    defaultValues: emptyExpense,
  })

  const create = useMutation({
    mutationFn: (data: FormData) =>
      apiFetch<Expense>('/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 (
    <div>
      <div className="flex items-center justify-between mb-6">
        <div>
          <h1 className="text-2xl font-bold text-white">Despesas</h1>
          <p className="text-slate-400 text-sm mt-0.5">
            {expenses.length} registos  total: {total.toFixed(2)} 
          </p>
        </div>
        <Button onClick={() => { reset(emptyExpense); setShowForm(true) }}>Nova Despesa</Button>
      </div>

      <div className="flex gap-2 mb-4">
        {['', ...EXPENSE_TYPES].map((t) => (
          <button
            key={t}
            onClick={() => setTypeFilter(t)}
            className={`px-3 py-1 rounded text-xs font-medium transition-colors ${
              typeFilter === t
                ? 'bg-slate-600 text-white'
                : 'text-slate-400 hover:text-white hover:bg-slate-800'
            }`}
          >
            {t === '' ? 'Todas' : TYPE_LABELS[t]}
          </button>
        ))}
      </div>

      {showForm && (
        <div className="mb-6 bg-slate-800 rounded-lg border border-slate-700 p-6">
          <h2 className="text-lg font-semibold text-white mb-4">Nova Despesa</h2>
          <form onSubmit={handleSubmit((d) => create.mutate(d))} className="grid grid-cols-2 gap-4">
            <div className="space-y-1">
              <Label htmlFor="type">Tipo</Label>
              <select
                id="type"
                {...register('type')}
                className="w-full rounded-md border border-slate-600 bg-slate-900 text-white px-3 py-2 text-sm"
              >
                {EXPENSE_TYPES.map((t) => (
                  <option key={t} value={t}>{TYPE_LABELS[t]}</option>
                ))}
              </select>
            </div>
            <div className="space-y-1">
              <Label htmlFor="amount">Valor () *</Label>
              <Input id="amount" type="number" step="0.01" {...register('amount')}
                className="bg-slate-900 border-slate-600 text-white" />
              {errors.amount && <p className="text-red-400 text-xs">{errors.amount.message}</p>}
            </div>
            <div className="space-y-1">
              <Label htmlFor="date">Data *</Label>
              <Input id="date" type="date" {...register('date')}
                className="bg-slate-900 border-slate-600 text-white" />
              {errors.date && <p className="text-red-400 text-xs">{errors.date.message}</p>}
            </div>
            <div className="space-y-1">
              <Label htmlFor="description">Descrição</Label>
              <Input id="description" {...register('description')}
                className="bg-slate-900 border-slate-600 text-white" />
            </div>
            {create.error && <p className="col-span-2 text-red-400 text-sm">{create.error.message}</p>}
            <div className="col-span-2 flex gap-2 justify-end">
              <Button type="button" variant="outline" onClick={() => setShowForm(false)}>Cancelar</Button>
              <Button type="submit" disabled={create.isPending}>
                {create.isPending ? 'A guardar...' : 'Guardar'}
              </Button>
            </div>
          </form>
        </div>
      )}

      {isLoading ? (
        <p className="text-slate-400">A carregar...</p>
      ) : expenses.length === 0 ? (
        <p className="text-slate-500 text-sm">Nenhuma despesa registada.</p>
      ) : (
        <div className="rounded-lg border border-slate-700 overflow-hidden">
          <table className="w-full text-sm">
            <thead className="bg-slate-800">
              <tr>
                <th className="text-left px-4 py-3 text-slate-400 font-medium">Data</th>
                <th className="text-left px-4 py-3 text-slate-400 font-medium">Tipo</th>
                <th className="text-left px-4 py-3 text-slate-400 font-medium">Descrição</th>
                <th className="text-right px-4 py-3 text-slate-400 font-medium">Valor</th>
                <th className="px-4 py-3"></th>
              </tr>
            </thead>
            <tbody>
              {expenses.map((e) => (
                <tr key={e.id} className="border-t border-slate-700 hover:bg-slate-800/50">
                  <td className="px-4 py-3 text-slate-400">
                    {new Intl.DateTimeFormat('pt-PT').format(new Date(e.date))}
                  </td>
                  <td className="px-4 py-3">
                    <Badge variant="secondary">{TYPE_LABELS[e.type]}</Badge>
                  </td>
                  <td className="px-4 py-3 text-slate-300">{e.description || '—'}</td>
                  <td className="px-4 py-3 text-white font-medium text-right">{e.amount.toFixed(2)} </td>
                  <td className="px-4 py-3 text-right">
                    <Button
                      size="sm"
                      variant="destructive"
                      onClick={() => { if (confirm('Eliminar despesa?')) remove.mutate(e.id) }}
                    >
                      Eliminar
                    </Button>
                  </td>
                </tr>
              ))}
            </tbody>
          </table>
        </div>
      )}
    </div>
  )
}
  • Step 2: Add route to App.tsx
import ExpensesPage from '@/pages/app/ExpensesPage'
// inside /app group:
<Route path="expenses" element={<ExpensesPage />} />
  • Step 3: Build check + commit
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:

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<TenantSettings>({
    queryKey: ['settings'],
    queryFn: () => apiFetch<TenantSettings>('/settings'),
  })

  const { register, handleSubmit, reset } = useForm<FormData>({
    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<TenantSettings>('/settings', { method: 'PUT', body: JSON.stringify(data) }),
    onSuccess: () => qc.invalidateQueries({ queryKey: ['settings'] }),
  })

  if (isLoading) return <p className="text-slate-400">A carregar...</p>

  return (
    <div className="max-w-2xl">
      <div className="mb-6">
        <h1 className="text-2xl font-bold text-white">Definições</h1>
        <p className="text-slate-400 text-sm mt-0.5">Dados da oficina utilizados nos documentos PDF</p>
      </div>

      <form
        onSubmit={handleSubmit((d) => save.mutate(d))}
        className="bg-slate-800 rounded-lg border border-slate-700 p-6 space-y-4"
      >
        <div className="space-y-1">
          <Label htmlFor="company_name">Nome da oficina</Label>
          <Input id="company_name" {...register('company_name')}
            className="bg-slate-900 border-slate-600 text-white" />
        </div>
        <div className="grid grid-cols-2 gap-4">
          <div className="space-y-1">
            <Label htmlFor="company_nif">NIF</Label>
            <Input id="company_nif" {...register('company_nif')}
              className="bg-slate-900 border-slate-600 text-white" />
          </div>
          <div className="space-y-1">
            <Label htmlFor="company_phone">Telefone</Label>
            <Input id="company_phone" {...register('company_phone')}
              className="bg-slate-900 border-slate-600 text-white" />
          </div>
        </div>
        <div className="space-y-1">
          <Label htmlFor="company_address">Morada</Label>
          <Input id="company_address" {...register('company_address')}
            className="bg-slate-900 border-slate-600 text-white" />
        </div>
        <div className="grid grid-cols-2 gap-4">
          <div className="space-y-1">
            <Label htmlFor="company_email">Email</Label>
            <Input id="company_email" type="email" {...register('company_email')}
              className="bg-slate-900 border-slate-600 text-white" />
          </div>
          <div className="space-y-1">
            <Label htmlFor="company_iban">IBAN</Label>
            <Input id="company_iban" {...register('company_iban')}
              placeholder="PT50..."
              className="bg-slate-900 border-slate-600 text-white" />
          </div>
        </div>

        {save.error && (
          <p className="text-red-400 text-sm">{save.error.message}</p>
        )}
        {save.isSuccess && (
          <p className="text-green-400 text-sm">Definições guardadas.</p>
        )}

        <div className="flex justify-end pt-2">
          <Button type="submit" disabled={save.isPending}>
            {save.isPending ? 'A guardar...' : 'Guardar Definições'}
          </Button>
        </div>
      </form>
    </div>
  )
}
  • Step 2: Add route to App.tsx
import SettingsPage from '@/pages/app/SettingsPage'
// inside /app group:
<Route path="settings" element={<SettingsPage />} />
  • Step 3: Build check + commit
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:

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<string, string> = { 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<Invoice[]>({
    queryKey: ['invoices'],
    queryFn: () => apiFetch<Invoice[]>('/invoices'),
  })

  const { data: workOrders = [] } = useQuery<WorkOrder[]>({
    queryKey: ['work-orders-for-invoice'],
    queryFn: () => apiFetch<WorkOrder[]>('/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<Invoice>('/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 (
    <div>
      <div className="flex items-center justify-between mb-6">
        <div>
          <h1 className="text-2xl font-bold text-white">Faturação</h1>
          <p className="text-slate-400 text-sm mt-0.5">{invoices.length} documentos</p>
        </div>
        <Button onClick={() => setShowGenerate(true)}>Gerar Documento</Button>
      </div>

      {showGenerate && (
        <div className="mb-6 bg-slate-800 rounded-lg border border-slate-700 p-6">
          <h2 className="text-lg font-semibold text-white mb-4">Gerar Documento</h2>
          <div className="grid grid-cols-2 gap-4">
            <div className="space-y-1">
              <label className="text-sm text-slate-300">Tipo</label>
              <select
                value={docType}
                onChange={(e) => setDocType(e.target.value as 'quote' | 'invoice')}
                className="w-full rounded-md border border-slate-600 bg-slate-900 text-white px-3 py-2 text-sm"
              >
                <option value="quote">Orçamento</option>
                <option value="invoice">Fatura</option>
              </select>
            </div>
            <div className="space-y-1">
              <label className="text-sm text-slate-300">Ordem de Trabalho</label>
              <select
                value={selectedWO}
                onChange={(e) => setSelectedWO(e.target.value)}
                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>
                {eligibleWOs.map((wo) => (
                  <option key={wo.id} value={wo.id}>
                    #{wo.number} ({wo.status})
                  </option>
                ))}
              </select>
            </div>
          </div>
          {generate.error && (
            <p className="text-red-400 text-sm mt-3">{generate.error.message}</p>
          )}
          <div className="flex gap-2 justify-end mt-4">
            <Button variant="outline" onClick={() => { setShowGenerate(false); setSelectedWO('') }}>
              Cancelar
            </Button>
            <Button
              onClick={() => generate.mutate()}
              disabled={!selectedWO || generate.isPending}
            >
              {generate.isPending ? 'A gerar...' : 'Gerar PDF'}
            </Button>
          </div>
        </div>
      )}

      {isLoading ? (
        <p className="text-slate-400">A carregar...</p>
      ) : invoices.length === 0 ? (
        <p className="text-slate-500 text-sm">Nenhum documento gerado.</p>
      ) : (
        <div className="rounded-lg border border-slate-700 overflow-hidden">
          <table className="w-full text-sm">
            <thead className="bg-slate-800">
              <tr>
                <th className="text-left px-4 py-3 text-slate-400 font-medium"></th>
                <th className="text-left px-4 py-3 text-slate-400 font-medium">Tipo</th>
                <th className="text-left px-4 py-3 text-slate-400 font-medium">OT</th>
                <th className="text-left px-4 py-3 text-slate-400 font-medium">Emitida</th>
                <th className="px-4 py-3"></th>
              </tr>
            </thead>
            <tbody>
              {invoices.map((inv) => (
                <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-3">
                    <Badge variant={inv.type === 'invoice' ? 'default' : 'secondary'}>
                      {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))}
                  </td>
                  <td className="px-4 py-3 text-right">
                    <Button size="sm" variant="outline" onClick={() => handleDownload(inv)}>
                      Descarregar PDF
                    </Button>
                  </td>
                </tr>
              ))}
            </tbody>
          </table>
        </div>
      )}
    </div>
  )
}
  • Step 2: Add route to App.tsx
import InvoicesPage from '@/pages/app/InvoicesPage'
// inside /app group:
<Route path="invoices" element={<InvoicesPage />} />
  • Step 3: Full build + tests
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
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