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,managercan write;technicianread-only apiFetch<T>returnsresponse.datadirectly (seefrontend/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 queriesbackend/internal/staff/handler.go— HTTP handlers + route registrationbackend/internal/expense/repository.go— Expense CRUD queriesbackend/internal/expense/handler.go— HTTP handlers + route registrationbackend/internal/settings/repository.go— tenant_settings key-value CRUDbackend/internal/settings/handler.go— HTTP handlers + route registrationbackend/pkg/pdf/pdf.go—Generate(items []LineItem, meta DocMeta, outPath string) errorbackend/internal/invoice/repository.go— Invoice CRUD queriesbackend/internal/invoice/handler.go— HTTP handlers (generate, list, download PDF)
Backend — modified:
backend/internal/server/server.go— wire 4 new route groupsbackend/go.mod+backend/go.sum— addgithub.com/go-pdf/fpdf/v2backend/Dockerfile— add/app/storagevolume dir + chown
Frontend — new files:
frontend/src/pages/app/StaffPage.tsxfrontend/src/pages/app/ExpensesPage.tsxfrontend/src/pages/app/SettingsPage.tsxfrontend/src/pages/app/InvoicesPage.tsx
Frontend — modified:
frontend/src/components/layout/AppLayout.tsx— add 4 nav itemsfrontend/src/App.tsx— add 4 routesfrontend/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 foundCreateStaff(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 foundDeleteStaff(ctx, conn, id) errorstaff.RegisterRoutes(app *fiber.App, db *database.DB, secret string)GET /api/v1/staff→[]*Staff(all roles)POST /api/v1/staff→*Staff201 (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 = allCreateExpense(ctx, conn, vehicleID *string, expType, description string, amount float64, date time.Time) (*Expense, error)DeleteExpense(ctx, conn, id string) errorexpense.RegisterRoutes(app, db, secret)GET /api/v1/expenses→[]*Expense(query:?type=fuel)POST /api/v1/expenses→*Expense201DELETE /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 keysGetSettings(ctx, conn) (map[string]string, error)— returns all settingsSetSetting(ctx, conn, key, value string) errorsettings.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:
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(addgithub.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/workordertypes -
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 foundCreateInvoice(ctx, conn, woID, docType string) (*Invoice, error)— inserts row, returns with generated numberSetPDFPath(ctx, conn, id, path string) errorinvoice.RegisterRoutes(app, db, secret)GET /api/v1/invoices→[]*InvoicePOST /api/v1/invoices→*Invoice201 — body:{work_order_id, type: "quote"|"invoice"}; generates PDF; if type=invoice transitions WO to invoicedGET /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">Nº</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/storagevolume 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