2165 lines
70 KiB
Markdown
2165 lines
70 KiB
Markdown
# 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.go` — `Generate(items []LineItem, meta DocMeta, outPath string) error`
|
|
- `backend/internal/invoice/repository.go` — Invoice CRUD queries
|
|
- `backend/internal/invoice/handler.go` — HTTP handlers (generate, list, download PDF)
|
|
|
|
**Backend — modified:**
|
|
- `backend/internal/server/server.go` — wire 4 new route groups
|
|
- `backend/go.mod` + `backend/go.sum` — add `github.com/go-pdf/fpdf/v2`
|
|
- `backend/Dockerfile` — add `/app/storage` volume dir + chown
|
|
|
|
**Frontend — new files:**
|
|
- `frontend/src/pages/app/StaffPage.tsx`
|
|
- `frontend/src/pages/app/ExpensesPage.tsx`
|
|
- `frontend/src/pages/app/SettingsPage.tsx`
|
|
- `frontend/src/pages/app/InvoicesPage.tsx`
|
|
|
|
**Frontend — modified:**
|
|
- `frontend/src/components/layout/AppLayout.tsx` — add 4 nav items
|
|
- `frontend/src/App.tsx` — add 4 routes
|
|
- `frontend/src/lib/types.ts` — add Staff, Expense, TenantSettings, Invoice types
|
|
|
|
---
|
|
|
|
### Task 1: Staff Repository + Handlers
|
|
|
|
**Files:**
|
|
- Create: `backend/internal/staff/repository.go`
|
|
- Create: `backend/internal/staff/handler.go`
|
|
|
|
**Interfaces:**
|
|
- Produces:
|
|
- `Staff{ID, UserID *string, Name, Email, Phone, Type, Active bool, HourlyRate float64, CreatedAt time.Time}`
|
|
- `ListStaff(ctx, conn) ([]*Staff, error)`
|
|
- `GetStaffByID(ctx, conn, id) (*Staff, error)` — nil if not found
|
|
- `CreateStaff(ctx, conn, name, email, phone, staffType string, hourlyRate float64) (*Staff, error)`
|
|
- `UpdateStaff(ctx, conn, id, name, email, phone, staffType string, hourlyRate float64, active bool) (*Staff, error)` — nil if not found
|
|
- `DeleteStaff(ctx, conn, id) error`
|
|
- `staff.RegisterRoutes(app *fiber.App, db *database.DB, secret string)`
|
|
- `GET /api/v1/staff` → `[]*Staff` (all roles)
|
|
- `POST /api/v1/staff` → `*Staff` 201 (admin/manager)
|
|
- `PUT /api/v1/staff/:id` → `*Staff` (admin/manager)
|
|
- `DELETE /api/v1/staff/:id` → 204 (admin/manager)
|
|
|
|
- [ ] **Step 1: Create repository.go**
|
|
|
|
Create `backend/internal/staff/repository.go`:
|
|
|
|
```go
|
|
package staff
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
)
|
|
|
|
type Staff struct {
|
|
ID string `json:"id"`
|
|
UserID *string `json:"user_id"`
|
|
Name string `json:"name"`
|
|
Email string `json:"email"`
|
|
Phone string `json:"phone"`
|
|
Type string `json:"type"`
|
|
HourlyRate float64 `json:"hourly_rate"`
|
|
Active bool `json:"active"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
}
|
|
|
|
func ListStaff(ctx context.Context, conn *pgxpool.Conn) ([]*Staff, error) {
|
|
rows, err := conn.Query(ctx,
|
|
`SELECT id, user_id, name, COALESCE(email,''), COALESCE(phone,''), type,
|
|
COALESCE(hourly_rate,0), active, created_at
|
|
FROM staff ORDER BY name`)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("staff: list: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
var list []*Staff
|
|
for rows.Next() {
|
|
var s Staff
|
|
if err := rows.Scan(&s.ID, &s.UserID, &s.Name, &s.Email, &s.Phone,
|
|
&s.Type, &s.HourlyRate, &s.Active, &s.CreatedAt); err != nil {
|
|
return nil, fmt.Errorf("staff: scan: %w", err)
|
|
}
|
|
list = append(list, &s)
|
|
}
|
|
return list, nil
|
|
}
|
|
|
|
func GetStaffByID(ctx context.Context, conn *pgxpool.Conn, id string) (*Staff, error) {
|
|
row := conn.QueryRow(ctx,
|
|
`SELECT id, user_id, name, COALESCE(email,''), COALESCE(phone,''), type,
|
|
COALESCE(hourly_rate,0), active, created_at
|
|
FROM staff WHERE id = $1`, id)
|
|
var s Staff
|
|
err := row.Scan(&s.ID, &s.UserID, &s.Name, &s.Email, &s.Phone,
|
|
&s.Type, &s.HourlyRate, &s.Active, &s.CreatedAt)
|
|
if err == pgx.ErrNoRows {
|
|
return nil, nil
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("staff: get: %w", err)
|
|
}
|
|
return &s, nil
|
|
}
|
|
|
|
func CreateStaff(ctx context.Context, conn *pgxpool.Conn, name, email, phone, staffType string, hourlyRate float64) (*Staff, error) {
|
|
row := conn.QueryRow(ctx,
|
|
`INSERT INTO staff (name, email, phone, type, hourly_rate)
|
|
VALUES ($1, $2, $3, $4, $5)
|
|
RETURNING id, user_id, name, COALESCE(email,''), COALESCE(phone,''), type,
|
|
COALESCE(hourly_rate,0), active, created_at`,
|
|
name, email, phone, staffType, hourlyRate)
|
|
var s Staff
|
|
if err := row.Scan(&s.ID, &s.UserID, &s.Name, &s.Email, &s.Phone,
|
|
&s.Type, &s.HourlyRate, &s.Active, &s.CreatedAt); err != nil {
|
|
return nil, fmt.Errorf("staff: create: %w", err)
|
|
}
|
|
return &s, nil
|
|
}
|
|
|
|
func UpdateStaff(ctx context.Context, conn *pgxpool.Conn, id, name, email, phone, staffType string, hourlyRate float64, active bool) (*Staff, error) {
|
|
row := conn.QueryRow(ctx,
|
|
`UPDATE staff SET name=$2, email=$3, phone=$4, type=$5, hourly_rate=$6, active=$7
|
|
WHERE id=$1
|
|
RETURNING id, user_id, name, COALESCE(email,''), COALESCE(phone,''), type,
|
|
COALESCE(hourly_rate,0), active, created_at`,
|
|
id, name, email, phone, staffType, hourlyRate, active)
|
|
var s Staff
|
|
err := row.Scan(&s.ID, &s.UserID, &s.Name, &s.Email, &s.Phone,
|
|
&s.Type, &s.HourlyRate, &s.Active, &s.CreatedAt)
|
|
if err == pgx.ErrNoRows {
|
|
return nil, nil
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("staff: update: %w", err)
|
|
}
|
|
return &s, nil
|
|
}
|
|
|
|
func DeleteStaff(ctx context.Context, conn *pgxpool.Conn, id string) error {
|
|
tag, err := conn.Exec(ctx, `DELETE FROM staff WHERE id = $1`, id)
|
|
if err != nil {
|
|
return fmt.Errorf("staff: delete: %w", err)
|
|
}
|
|
if tag.RowsAffected() == 0 {
|
|
return pgx.ErrNoRows
|
|
}
|
|
return nil
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Create handler.go**
|
|
|
|
Create `backend/internal/staff/handler.go`:
|
|
|
|
```go
|
|
package staff
|
|
|
|
import (
|
|
"errors"
|
|
|
|
"github.com/gofiber/fiber/v2"
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/techxcar/backend/internal/auth"
|
|
"github.com/techxcar/backend/pkg/database"
|
|
)
|
|
|
|
func RegisterRoutes(app *fiber.App, db *database.DB, secret string) {
|
|
ro := []fiber.Handler{
|
|
auth.RequireAuth(secret),
|
|
auth.RequireRole("tenant_admin", "manager", "technician"),
|
|
auth.TenantMiddleware(db),
|
|
}
|
|
write := []fiber.Handler{
|
|
auth.RequireAuth(secret),
|
|
auth.RequireRole("tenant_admin", "manager"),
|
|
auth.TenantMiddleware(db),
|
|
}
|
|
|
|
app.Get("/api/v1/staff", append(ro, listStaffH())...)
|
|
app.Post("/api/v1/staff", append(write, createStaffH())...)
|
|
app.Put("/api/v1/staff/:id", append(write, updateStaffH())...)
|
|
app.Delete("/api/v1/staff/:id", append(write, deleteStaffH())...)
|
|
}
|
|
|
|
func listStaffH() fiber.Handler {
|
|
return func(c *fiber.Ctx) error {
|
|
conn := auth.GetConn(c)
|
|
list, err := ListStaff(c.Context(), conn)
|
|
if err != nil {
|
|
return fiber.NewError(500, "erro ao listar técnicos")
|
|
}
|
|
if list == nil {
|
|
list = []*Staff{}
|
|
}
|
|
return c.JSON(fiber.Map{"data": list, "error": nil})
|
|
}
|
|
}
|
|
|
|
type staffBody struct {
|
|
Name string `json:"name"`
|
|
Email string `json:"email"`
|
|
Phone string `json:"phone"`
|
|
Type string `json:"type"`
|
|
HourlyRate float64 `json:"hourly_rate"`
|
|
Active bool `json:"active"`
|
|
}
|
|
|
|
func createStaffH() fiber.Handler {
|
|
return func(c *fiber.Ctx) error {
|
|
var b staffBody
|
|
if err := c.BodyParser(&b); err != nil {
|
|
return fiber.NewError(400, "corpo do pedido inválido")
|
|
}
|
|
if b.Name == "" {
|
|
return fiber.NewError(400, "nome obrigatório")
|
|
}
|
|
if b.Type != "internal" && b.Type != "external" {
|
|
return fiber.NewError(400, "tipo deve ser 'internal' ou 'external'")
|
|
}
|
|
conn := auth.GetConn(c)
|
|
s, err := CreateStaff(c.Context(), conn, b.Name, b.Email, b.Phone, b.Type, b.HourlyRate)
|
|
if err != nil {
|
|
return fiber.NewError(500, "erro ao criar técnico")
|
|
}
|
|
return c.Status(201).JSON(fiber.Map{"data": s, "error": nil})
|
|
}
|
|
}
|
|
|
|
func updateStaffH() fiber.Handler {
|
|
return func(c *fiber.Ctx) error {
|
|
id := c.Params("id")
|
|
var b staffBody
|
|
if err := c.BodyParser(&b); err != nil {
|
|
return fiber.NewError(400, "corpo do pedido inválido")
|
|
}
|
|
if b.Name == "" {
|
|
return fiber.NewError(400, "nome obrigatório")
|
|
}
|
|
conn := auth.GetConn(c)
|
|
s, err := UpdateStaff(c.Context(), conn, id, b.Name, b.Email, b.Phone, b.Type, b.HourlyRate, b.Active)
|
|
if err != nil {
|
|
return fiber.NewError(500, "erro ao actualizar técnico")
|
|
}
|
|
if s == nil {
|
|
return fiber.NewError(404, "técnico não encontrado")
|
|
}
|
|
return c.JSON(fiber.Map{"data": s, "error": nil})
|
|
}
|
|
}
|
|
|
|
func deleteStaffH() fiber.Handler {
|
|
return func(c *fiber.Ctx) error {
|
|
id := c.Params("id")
|
|
conn := auth.GetConn(c)
|
|
err := DeleteStaff(c.Context(), conn, id)
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return fiber.NewError(404, "técnico não encontrado")
|
|
}
|
|
if err != nil {
|
|
return fiber.NewError(500, "erro ao eliminar técnico")
|
|
}
|
|
return c.SendStatus(204)
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Compile check**
|
|
|
|
```bash
|
|
cd /var/home/lmilani/Documentos/IDE/techxcar/backend
|
|
export PATH=$PATH:/var/home/lmilani/.local/go/bin
|
|
go build ./...
|
|
```
|
|
|
|
Expected: no output (success).
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
cd /var/home/lmilani/Documentos/IDE/techxcar
|
|
git add backend/internal/staff/
|
|
git commit -m "feat: staff repository and HTTP handlers (list, create, update, delete)"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 2: Expense Repository + Handlers
|
|
|
|
**Files:**
|
|
- Create: `backend/internal/expense/repository.go`
|
|
- Create: `backend/internal/expense/handler.go`
|
|
|
|
**Interfaces:**
|
|
- Produces:
|
|
- `Expense{ID, VehicleID *string, Type, Description string, Amount float64, Date time.Time, CreatedAt time.Time}`
|
|
- `ListExpenses(ctx, conn, typeFilter string) ([]*Expense, error)` — empty typeFilter = all
|
|
- `CreateExpense(ctx, conn, vehicleID *string, expType, description string, amount float64, date time.Time) (*Expense, error)`
|
|
- `DeleteExpense(ctx, conn, id string) error`
|
|
- `expense.RegisterRoutes(app, db, secret)`
|
|
- `GET /api/v1/expenses` → `[]*Expense` (query: `?type=fuel`)
|
|
- `POST /api/v1/expenses` → `*Expense` 201
|
|
- `DELETE /api/v1/expenses/:id` → 204
|
|
|
|
- [ ] **Step 1: Create repository.go**
|
|
|
|
Create `backend/internal/expense/repository.go`:
|
|
|
|
```go
|
|
package expense
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
)
|
|
|
|
type Expense struct {
|
|
ID string `json:"id"`
|
|
VehicleID *string `json:"vehicle_id"`
|
|
Type string `json:"type"`
|
|
Amount float64 `json:"amount"`
|
|
Description string `json:"description"`
|
|
Date time.Time `json:"date"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
}
|
|
|
|
func ListExpenses(ctx context.Context, conn *pgxpool.Conn, typeFilter string) ([]*Expense, error) {
|
|
q := `SELECT id, vehicle_id, type, amount, COALESCE(description,''), date, created_at
|
|
FROM expenses`
|
|
args := []any{}
|
|
if typeFilter != "" {
|
|
q += " WHERE type = $1"
|
|
args = append(args, typeFilter)
|
|
}
|
|
q += " ORDER BY date DESC, created_at DESC"
|
|
rows, err := conn.Query(ctx, q, args...)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("expense: list: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
var list []*Expense
|
|
for rows.Next() {
|
|
var e Expense
|
|
if err := rows.Scan(&e.ID, &e.VehicleID, &e.Type, &e.Amount, &e.Description, &e.Date, &e.CreatedAt); err != nil {
|
|
return nil, fmt.Errorf("expense: scan: %w", err)
|
|
}
|
|
list = append(list, &e)
|
|
}
|
|
return list, nil
|
|
}
|
|
|
|
func CreateExpense(ctx context.Context, conn *pgxpool.Conn, vehicleID *string, expType, description string, amount float64, date time.Time) (*Expense, error) {
|
|
row := conn.QueryRow(ctx,
|
|
`INSERT INTO expenses (vehicle_id, type, amount, description, date)
|
|
VALUES ($1, $2, $3, $4, $5)
|
|
RETURNING id, vehicle_id, type, amount, COALESCE(description,''), date, created_at`,
|
|
vehicleID, expType, amount, description, date)
|
|
var e Expense
|
|
if err := row.Scan(&e.ID, &e.VehicleID, &e.Type, &e.Amount, &e.Description, &e.Date, &e.CreatedAt); err != nil {
|
|
return nil, fmt.Errorf("expense: create: %w", err)
|
|
}
|
|
return &e, nil
|
|
}
|
|
|
|
func DeleteExpense(ctx context.Context, conn *pgxpool.Conn, id string) error {
|
|
tag, err := conn.Exec(ctx, `DELETE FROM expenses WHERE id = $1`, id)
|
|
if err != nil {
|
|
return fmt.Errorf("expense: delete: %w", err)
|
|
}
|
|
if tag.RowsAffected() == 0 {
|
|
return pgx.ErrNoRows
|
|
}
|
|
return nil
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Create handler.go**
|
|
|
|
Create `backend/internal/expense/handler.go`:
|
|
|
|
```go
|
|
package expense
|
|
|
|
import (
|
|
"errors"
|
|
"time"
|
|
|
|
"github.com/gofiber/fiber/v2"
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/techxcar/backend/internal/auth"
|
|
"github.com/techxcar/backend/pkg/database"
|
|
)
|
|
|
|
var allowedTypes = map[string]bool{"fuel": true, "parts": true, "tools": true, "other": true}
|
|
|
|
func RegisterRoutes(app *fiber.App, db *database.DB, secret string) {
|
|
ro := []fiber.Handler{
|
|
auth.RequireAuth(secret),
|
|
auth.RequireRole("tenant_admin", "manager", "technician"),
|
|
auth.TenantMiddleware(db),
|
|
}
|
|
write := []fiber.Handler{
|
|
auth.RequireAuth(secret),
|
|
auth.RequireRole("tenant_admin", "manager"),
|
|
auth.TenantMiddleware(db),
|
|
}
|
|
|
|
app.Get("/api/v1/expenses", append(ro, listExpensesH())...)
|
|
app.Post("/api/v1/expenses", append(write, createExpenseH())...)
|
|
app.Delete("/api/v1/expenses/:id", append(write, deleteExpenseH())...)
|
|
}
|
|
|
|
func listExpensesH() fiber.Handler {
|
|
return func(c *fiber.Ctx) error {
|
|
conn := auth.GetConn(c)
|
|
list, err := ListExpenses(c.Context(), conn, c.Query("type"))
|
|
if err != nil {
|
|
return fiber.NewError(500, "erro ao listar despesas")
|
|
}
|
|
if list == nil {
|
|
list = []*Expense{}
|
|
}
|
|
return c.JSON(fiber.Map{"data": list, "error": nil})
|
|
}
|
|
}
|
|
|
|
type expenseBody struct {
|
|
VehicleID string `json:"vehicle_id"`
|
|
Type string `json:"type"`
|
|
Amount float64 `json:"amount"`
|
|
Description string `json:"description"`
|
|
Date string `json:"date"` // YYYY-MM-DD
|
|
}
|
|
|
|
func createExpenseH() fiber.Handler {
|
|
return func(c *fiber.Ctx) error {
|
|
var b expenseBody
|
|
if err := c.BodyParser(&b); err != nil {
|
|
return fiber.NewError(400, "corpo do pedido inválido")
|
|
}
|
|
if !allowedTypes[b.Type] {
|
|
return fiber.NewError(400, "tipo inválido: fuel, parts, tools, other")
|
|
}
|
|
if b.Amount <= 0 {
|
|
return fiber.NewError(400, "valor deve ser positivo")
|
|
}
|
|
if b.Date == "" {
|
|
return fiber.NewError(400, "data obrigatória")
|
|
}
|
|
date, err := time.Parse("2006-01-02", b.Date)
|
|
if err != nil {
|
|
return fiber.NewError(400, "data inválida (formato: YYYY-MM-DD)")
|
|
}
|
|
var vehicleID *string
|
|
if b.VehicleID != "" {
|
|
vehicleID = &b.VehicleID
|
|
}
|
|
conn := auth.GetConn(c)
|
|
e, err := CreateExpense(c.Context(), conn, vehicleID, b.Type, b.Description, b.Amount, date)
|
|
if err != nil {
|
|
return fiber.NewError(500, "erro ao registar despesa")
|
|
}
|
|
return c.Status(201).JSON(fiber.Map{"data": e, "error": nil})
|
|
}
|
|
}
|
|
|
|
func deleteExpenseH() fiber.Handler {
|
|
return func(c *fiber.Ctx) error {
|
|
id := c.Params("id")
|
|
conn := auth.GetConn(c)
|
|
err := DeleteExpense(c.Context(), conn, id)
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return fiber.NewError(404, "despesa não encontrada")
|
|
}
|
|
if err != nil {
|
|
return fiber.NewError(500, "erro ao eliminar despesa")
|
|
}
|
|
return c.SendStatus(204)
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Compile check**
|
|
|
|
```bash
|
|
cd /var/home/lmilani/Documentos/IDE/techxcar/backend
|
|
export PATH=$PATH:/var/home/lmilani/.local/go/bin
|
|
go build ./...
|
|
```
|
|
|
|
Expected: no output.
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
cd /var/home/lmilani/Documentos/IDE/techxcar
|
|
git add backend/internal/expense/
|
|
git commit -m "feat: expense repository and HTTP handlers (list, create, delete)"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 3: Settings Repository + Handlers
|
|
|
|
**Files:**
|
|
- Create: `backend/internal/settings/repository.go`
|
|
- Create: `backend/internal/settings/handler.go`
|
|
|
|
**Interfaces:**
|
|
- Produces:
|
|
- `AllowedKeys` — set of valid setting keys
|
|
- `GetSettings(ctx, conn) (map[string]string, error)` — returns all settings
|
|
- `SetSetting(ctx, conn, key, value string) error`
|
|
- `settings.RegisterRoutes(app, db, secret)`
|
|
- `GET /api/v1/settings` → `map[string]string` (admin/manager)
|
|
- `PUT /api/v1/settings` → `map[string]string` (admin only) — body: `{"key":"value",...}`
|
|
|
|
Valid keys: `company_name`, `company_nif`, `company_address`, `company_iban`, `company_phone`, `company_email`
|
|
|
|
- [ ] **Step 1: Create repository.go**
|
|
|
|
Create `backend/internal/settings/repository.go`:
|
|
|
|
```go
|
|
package settings
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
)
|
|
|
|
var AllowedKeys = map[string]bool{
|
|
"company_name": true,
|
|
"company_nif": true,
|
|
"company_address": true,
|
|
"company_iban": true,
|
|
"company_phone": true,
|
|
"company_email": true,
|
|
}
|
|
|
|
func GetSettings(ctx context.Context, conn *pgxpool.Conn) (map[string]string, error) {
|
|
rows, err := conn.Query(ctx, `SELECT key, value FROM tenant_settings`)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("settings: get: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
result := map[string]string{}
|
|
for rows.Next() {
|
|
var k, v string
|
|
if err := rows.Scan(&k, &v); err != nil {
|
|
return nil, fmt.Errorf("settings: scan: %w", err)
|
|
}
|
|
result[k] = v
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func SetSetting(ctx context.Context, conn *pgxpool.Conn, key, value string) error {
|
|
_, err := conn.Exec(ctx,
|
|
`INSERT INTO tenant_settings (key, value) VALUES ($1, $2)
|
|
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()`,
|
|
key, value)
|
|
if err != nil {
|
|
return fmt.Errorf("settings: set %s: %w", key, err)
|
|
}
|
|
return nil
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Create handler.go**
|
|
|
|
Create `backend/internal/settings/handler.go`:
|
|
|
|
```go
|
|
package settings
|
|
|
|
import (
|
|
"github.com/gofiber/fiber/v2"
|
|
"github.com/techxcar/backend/internal/auth"
|
|
"github.com/techxcar/backend/pkg/database"
|
|
)
|
|
|
|
func RegisterRoutes(app *fiber.App, db *database.DB, secret string) {
|
|
read := []fiber.Handler{
|
|
auth.RequireAuth(secret),
|
|
auth.RequireRole("tenant_admin", "manager"),
|
|
auth.TenantMiddleware(db),
|
|
}
|
|
admin := []fiber.Handler{
|
|
auth.RequireAuth(secret),
|
|
auth.RequireRole("tenant_admin"),
|
|
auth.TenantMiddleware(db),
|
|
}
|
|
|
|
app.Get("/api/v1/settings", append(read, getSettingsH())...)
|
|
app.Put("/api/v1/settings", append(admin, updateSettingsH())...)
|
|
}
|
|
|
|
func getSettingsH() fiber.Handler {
|
|
return func(c *fiber.Ctx) error {
|
|
conn := auth.GetConn(c)
|
|
s, err := GetSettings(c.Context(), conn)
|
|
if err != nil {
|
|
return fiber.NewError(500, "erro ao obter definições")
|
|
}
|
|
return c.JSON(fiber.Map{"data": s, "error": nil})
|
|
}
|
|
}
|
|
|
|
func updateSettingsH() fiber.Handler {
|
|
return func(c *fiber.Ctx) error {
|
|
var body map[string]string
|
|
if err := c.BodyParser(&body); err != nil {
|
|
return fiber.NewError(400, "corpo do pedido inválido")
|
|
}
|
|
conn := auth.GetConn(c)
|
|
for k, v := range body {
|
|
if !AllowedKeys[k] {
|
|
return fiber.NewError(400, "chave inválida: "+k)
|
|
}
|
|
if err := SetSetting(c.Context(), conn, k, v); err != nil {
|
|
return fiber.NewError(500, "erro ao guardar definição: "+k)
|
|
}
|
|
}
|
|
s, err := GetSettings(c.Context(), conn)
|
|
if err != nil {
|
|
return fiber.NewError(500, "erro ao obter definições")
|
|
}
|
|
return c.JSON(fiber.Map{"data": s, "error": nil})
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Compile check + commit**
|
|
|
|
```bash
|
|
cd /var/home/lmilani/Documentos/IDE/techxcar/backend
|
|
export PATH=$PATH:/var/home/lmilani/.local/go/bin
|
|
go build ./...
|
|
git add backend/internal/settings/
|
|
git commit -m "feat: settings repository and HTTP handlers (get, update tenant settings)"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 4: PDF Package + go.mod
|
|
|
|
**Files:**
|
|
- Create: `backend/pkg/pdf/pdf.go`
|
|
- Modify: `backend/go.mod` (add `github.com/go-pdf/fpdf/v2`)
|
|
|
|
**Interfaces:**
|
|
- Produces:
|
|
- `pdf.DocMeta{CompanyName, CompanyNIF, CompanyAddress, CompanyIBAN, CompanyPhone, CompanyEmail, DocType, DocNumber, IssuedAt, ClientName, ClientNIF, VehiclePlate string}`
|
|
- `pdf.LineItem{Description string, Qty, UnitPrice, DiscountPct, Total float64}`
|
|
- `pdf.Generate(meta DocMeta, items []LineItem, staffTotal float64, outPath string) error`
|
|
|
|
- [ ] **Step 1: Add dependency**
|
|
|
|
```bash
|
|
cd /var/home/lmilani/Documentos/IDE/techxcar/backend
|
|
export PATH=$PATH:/var/home/lmilani/.local/go/bin
|
|
go get github.com/go-pdf/fpdf/v2
|
|
```
|
|
|
|
Expected: `go.mod` and `go.sum` updated.
|
|
|
|
- [ ] **Step 2: Create pdf.go**
|
|
|
|
Create `backend/pkg/pdf/pdf.go`:
|
|
|
|
```go
|
|
package pdf
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
|
|
"github.com/go-pdf/fpdf/v2"
|
|
)
|
|
|
|
type DocMeta struct {
|
|
CompanyName string
|
|
CompanyNIF string
|
|
CompanyAddress string
|
|
CompanyIBAN string
|
|
CompanyPhone string
|
|
CompanyEmail string
|
|
DocType string // "Fatura" or "Orçamento"
|
|
DocNumber string // e.g. "F2024/001"
|
|
IssuedAt string // e.g. "30/06/2026"
|
|
ClientName string
|
|
ClientNIF string
|
|
VehiclePlate string
|
|
}
|
|
|
|
type LineItem struct {
|
|
Description string
|
|
Qty float64
|
|
UnitPrice float64
|
|
DiscountPct float64
|
|
Total float64
|
|
}
|
|
|
|
func Generate(meta DocMeta, items []LineItem, staffTotal float64, outPath string) error {
|
|
if err := os.MkdirAll(filepath.Dir(outPath), 0755); err != nil {
|
|
return fmt.Errorf("pdf: mkdir: %w", err)
|
|
}
|
|
|
|
f := fpdf.New("P", "mm", "A4", "")
|
|
f.AddPage()
|
|
f.SetMargins(15, 15, 15)
|
|
|
|
// Header: company info
|
|
f.SetFont("Helvetica", "B", 18)
|
|
f.CellFormat(120, 10, meta.CompanyName, "", 0, "L", false, 0, "")
|
|
f.SetFont("Helvetica", "B", 14)
|
|
f.CellFormat(60, 10, meta.DocType, "", 1, "R", false, 0, "")
|
|
|
|
f.SetFont("Helvetica", "", 9)
|
|
if meta.CompanyNIF != "" {
|
|
f.CellFormat(120, 5, "NIF: "+meta.CompanyNIF, "", 0, "L", false, 0, "")
|
|
} else {
|
|
f.CellFormat(120, 5, "", "", 0, "L", false, 0, "")
|
|
}
|
|
f.SetFont("Helvetica", "", 11)
|
|
f.CellFormat(60, 5, meta.DocNumber, "", 1, "R", false, 0, "")
|
|
|
|
f.SetFont("Helvetica", "", 9)
|
|
if meta.CompanyAddress != "" {
|
|
f.MultiCell(120, 5, meta.CompanyAddress, "", "L", false)
|
|
}
|
|
f.SetXY(f.GetX()+120, f.GetY())
|
|
f.SetFont("Helvetica", "", 9)
|
|
f.CellFormat(60, 5, "Data: "+meta.IssuedAt, "", 1, "R", false, 0, "")
|
|
f.Ln(5)
|
|
|
|
// Client info
|
|
if meta.ClientName != "" {
|
|
f.SetFont("Helvetica", "B", 9)
|
|
f.CellFormat(180, 5, "Cliente", "", 1, "L", false, 0, "")
|
|
f.SetFont("Helvetica", "", 9)
|
|
f.CellFormat(180, 5, meta.ClientName, "", 1, "L", false, 0, "")
|
|
if meta.ClientNIF != "" {
|
|
f.CellFormat(180, 5, "NIF: "+meta.ClientNIF, "", 1, "L", false, 0, "")
|
|
}
|
|
if meta.VehiclePlate != "" {
|
|
f.CellFormat(180, 5, "Matrícula: "+meta.VehiclePlate, "", 1, "L", false, 0, "")
|
|
}
|
|
f.Ln(5)
|
|
}
|
|
|
|
// Table header
|
|
f.SetFillColor(50, 50, 50)
|
|
f.SetTextColor(255, 255, 255)
|
|
f.SetFont("Helvetica", "B", 9)
|
|
f.CellFormat(90, 7, "Descrição", "1", 0, "L", true, 0, "")
|
|
f.CellFormat(20, 7, "Qtd.", "1", 0, "C", true, 0, "")
|
|
f.CellFormat(25, 7, "Preço Unit.", "1", 0, "R", true, 0, "")
|
|
f.CellFormat(20, 7, "Desc.%", "1", 0, "C", true, 0, "")
|
|
f.CellFormat(25, 7, "Total", "1", 1, "R", true, 0, "")
|
|
|
|
// Table rows
|
|
f.SetFillColor(245, 245, 245)
|
|
f.SetTextColor(0, 0, 0)
|
|
f.SetFont("Helvetica", "", 9)
|
|
fill := false
|
|
for _, item := range items {
|
|
f.CellFormat(90, 6, item.Description, "1", 0, "L", fill, 0, "")
|
|
f.CellFormat(20, 6, fmt.Sprintf("%.2f", item.Qty), "1", 0, "C", fill, 0, "")
|
|
f.CellFormat(25, 6, fmt.Sprintf("%.2f €", item.UnitPrice), "1", 0, "R", fill, 0, "")
|
|
f.CellFormat(20, 6, fmt.Sprintf("%.0f%%", item.DiscountPct), "1", 0, "C", fill, 0, "")
|
|
f.CellFormat(25, 6, fmt.Sprintf("%.2f €", item.Total), "1", 1, "R", fill, 0, "")
|
|
fill = !fill
|
|
}
|
|
|
|
// Totals
|
|
var subtotal float64
|
|
for _, item := range items {
|
|
subtotal += item.Total
|
|
}
|
|
grandTotal := subtotal + staffTotal
|
|
|
|
f.Ln(3)
|
|
f.SetFont("Helvetica", "", 9)
|
|
if staffTotal > 0 {
|
|
f.CellFormat(155, 6, "Subtotal peças/serviços", "", 0, "R", false, 0, "")
|
|
f.CellFormat(25, 6, fmt.Sprintf("%.2f €", subtotal), "1", 1, "R", false, 0, "")
|
|
f.CellFormat(155, 6, "Mão de obra", "", 0, "R", false, 0, "")
|
|
f.CellFormat(25, 6, fmt.Sprintf("%.2f €", staffTotal), "1", 1, "R", false, 0, "")
|
|
}
|
|
f.SetFont("Helvetica", "B", 10)
|
|
f.CellFormat(155, 7, "TOTAL", "", 0, "R", false, 0, "")
|
|
f.CellFormat(25, 7, fmt.Sprintf("%.2f €", grandTotal), "1", 1, "R", false, 0, "")
|
|
|
|
// Footer: IBAN
|
|
if meta.CompanyIBAN != "" {
|
|
f.Ln(8)
|
|
f.SetFont("Helvetica", "", 8)
|
|
f.CellFormat(180, 5, "IBAN: "+meta.CompanyIBAN, "", 1, "C", false, 0, "")
|
|
}
|
|
|
|
return f.OutputFileAndClose(outPath)
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Compile check**
|
|
|
|
```bash
|
|
cd /var/home/lmilani/Documentos/IDE/techxcar/backend
|
|
export PATH=$PATH:/var/home/lmilani/.local/go/bin
|
|
go build ./...
|
|
```
|
|
|
|
Expected: no output.
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
cd /var/home/lmilani/Documentos/IDE/techxcar
|
|
git add backend/pkg/pdf/ backend/go.mod backend/go.sum
|
|
git commit -m "feat: PDF generation package using go-pdf/fpdf (pure Go, no Chrome dependency)"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 5: Invoice Repository + Handlers
|
|
|
|
**Files:**
|
|
- Create: `backend/internal/invoice/repository.go`
|
|
- Create: `backend/internal/invoice/handler.go`
|
|
|
|
**Interfaces:**
|
|
- Consumes: `pkg/pdf.Generate`, `internal/settings.GetSettings`, `internal/workorder` types
|
|
- Produces:
|
|
- `Invoice{ID, WorkOrderID, Type, Number int, PDFPath string, IssuedAt time.Time, CreatedAt time.Time}`
|
|
- `ListInvoices(ctx, conn) ([]*Invoice, error)`
|
|
- `GetInvoice(ctx, conn, id) (*Invoice, error)` — nil if not found
|
|
- `CreateInvoice(ctx, conn, woID, docType string) (*Invoice, error)` — inserts row, returns with generated number
|
|
- `SetPDFPath(ctx, conn, id, path string) error`
|
|
- `invoice.RegisterRoutes(app, db, secret)`
|
|
- `GET /api/v1/invoices` → `[]*Invoice`
|
|
- `POST /api/v1/invoices` → `*Invoice` 201 — body: `{work_order_id, type: "quote"|"invoice"}`; generates PDF; if type=invoice transitions WO to invoiced
|
|
- `GET /api/v1/invoices/:id/pdf` → binary PDF stream (authenticated)
|
|
|
|
- [ ] **Step 1: Create repository.go**
|
|
|
|
Create `backend/internal/invoice/repository.go`:
|
|
|
|
```go
|
|
package invoice
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
)
|
|
|
|
type Invoice struct {
|
|
ID string `json:"id"`
|
|
WorkOrderID string `json:"work_order_id"`
|
|
Type string `json:"type"`
|
|
Number int `json:"number"`
|
|
PDFPath string `json:"pdf_path"`
|
|
IssuedAt time.Time `json:"issued_at"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
}
|
|
|
|
func ListInvoices(ctx context.Context, conn *pgxpool.Conn) ([]*Invoice, error) {
|
|
rows, err := conn.Query(ctx,
|
|
`SELECT id, work_order_id, type, number, COALESCE(pdf_path,''), issued_at, created_at
|
|
FROM invoices ORDER BY issued_at DESC`)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invoice: list: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
var list []*Invoice
|
|
for rows.Next() {
|
|
var inv Invoice
|
|
if err := rows.Scan(&inv.ID, &inv.WorkOrderID, &inv.Type, &inv.Number,
|
|
&inv.PDFPath, &inv.IssuedAt, &inv.CreatedAt); err != nil {
|
|
return nil, fmt.Errorf("invoice: scan: %w", err)
|
|
}
|
|
list = append(list, &inv)
|
|
}
|
|
return list, nil
|
|
}
|
|
|
|
func GetInvoice(ctx context.Context, conn *pgxpool.Conn, id string) (*Invoice, error) {
|
|
row := conn.QueryRow(ctx,
|
|
`SELECT id, work_order_id, type, number, COALESCE(pdf_path,''), issued_at, created_at
|
|
FROM invoices WHERE id = $1`, id)
|
|
var inv Invoice
|
|
err := row.Scan(&inv.ID, &inv.WorkOrderID, &inv.Type, &inv.Number,
|
|
&inv.PDFPath, &inv.IssuedAt, &inv.CreatedAt)
|
|
if err == pgx.ErrNoRows {
|
|
return nil, nil
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invoice: get: %w", err)
|
|
}
|
|
return &inv, nil
|
|
}
|
|
|
|
func CreateInvoice(ctx context.Context, conn *pgxpool.Conn, woID, docType string) (*Invoice, error) {
|
|
row := conn.QueryRow(ctx,
|
|
`INSERT INTO invoices (work_order_id, type, issued_at)
|
|
VALUES ($1, $2, NOW())
|
|
RETURNING id, work_order_id, type, number, COALESCE(pdf_path,''), issued_at, created_at`,
|
|
woID, docType)
|
|
var inv Invoice
|
|
if err := row.Scan(&inv.ID, &inv.WorkOrderID, &inv.Type, &inv.Number,
|
|
&inv.PDFPath, &inv.IssuedAt, &inv.CreatedAt); err != nil {
|
|
return nil, fmt.Errorf("invoice: create: %w", err)
|
|
}
|
|
return &inv, nil
|
|
}
|
|
|
|
func SetPDFPath(ctx context.Context, conn *pgxpool.Conn, id, path string) error {
|
|
_, err := conn.Exec(ctx, `UPDATE invoices SET pdf_path = $2 WHERE id = $1`, id, path)
|
|
if err != nil {
|
|
return fmt.Errorf("invoice: set pdf path: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Create handler.go**
|
|
|
|
Create `backend/internal/invoice/handler.go`:
|
|
|
|
```go
|
|
package invoice
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"github.com/gofiber/fiber/v2"
|
|
"github.com/techxcar/backend/internal/auth"
|
|
"github.com/techxcar/backend/internal/settings"
|
|
"github.com/techxcar/backend/internal/workorder"
|
|
"github.com/techxcar/backend/pkg/database"
|
|
"github.com/techxcar/backend/pkg/pdf"
|
|
)
|
|
|
|
const storageRoot = "/app/storage"
|
|
|
|
func RegisterRoutes(app *fiber.App, db *database.DB, secret string) {
|
|
ro := []fiber.Handler{
|
|
auth.RequireAuth(secret),
|
|
auth.RequireRole("tenant_admin", "manager", "technician"),
|
|
auth.TenantMiddleware(db),
|
|
}
|
|
write := []fiber.Handler{
|
|
auth.RequireAuth(secret),
|
|
auth.RequireRole("tenant_admin", "manager"),
|
|
auth.TenantMiddleware(db),
|
|
}
|
|
|
|
app.Get("/api/v1/invoices", append(ro, listInvoicesH())...)
|
|
app.Post("/api/v1/invoices", append(write, createInvoiceH())...)
|
|
app.Get("/api/v1/invoices/:id/pdf", append(ro, downloadPDFH())...)
|
|
}
|
|
|
|
func listInvoicesH() fiber.Handler {
|
|
return func(c *fiber.Ctx) error {
|
|
conn := auth.GetConn(c)
|
|
list, err := ListInvoices(c.Context(), conn)
|
|
if err != nil {
|
|
return fiber.NewError(500, "erro ao listar faturas")
|
|
}
|
|
if list == nil {
|
|
list = []*Invoice{}
|
|
}
|
|
return c.JSON(fiber.Map{"data": list, "error": nil})
|
|
}
|
|
}
|
|
|
|
type createBody struct {
|
|
WorkOrderID string `json:"work_order_id"`
|
|
Type string `json:"type"` // "quote" or "invoice"
|
|
}
|
|
|
|
func createInvoiceH() fiber.Handler {
|
|
return func(c *fiber.Ctx) error {
|
|
var b createBody
|
|
if err := c.BodyParser(&b); err != nil {
|
|
return fiber.NewError(400, "corpo do pedido inválido")
|
|
}
|
|
if b.WorkOrderID == "" {
|
|
return fiber.NewError(400, "work_order_id obrigatório")
|
|
}
|
|
if b.Type != "quote" && b.Type != "invoice" {
|
|
return fiber.NewError(400, "tipo deve ser 'quote' ou 'invoice'")
|
|
}
|
|
|
|
conn := auth.GetConn(c)
|
|
|
|
// Fetch WO detail
|
|
detail, err := workorder.GetWorkOrderDetail(c.Context(), conn, b.WorkOrderID)
|
|
if err != nil {
|
|
return fiber.NewError(500, "erro ao obter ordem de trabalho")
|
|
}
|
|
if detail == nil {
|
|
return fiber.NewError(404, "ordem de trabalho não encontrada")
|
|
}
|
|
|
|
// Fetch tenant settings
|
|
sett, err := settings.GetSettings(c.Context(), conn)
|
|
if err != nil {
|
|
return fiber.NewError(500, "erro ao obter definições")
|
|
}
|
|
|
|
// Fetch client info if present
|
|
var clientName, clientNIF, vehiclePlate string
|
|
if detail.ClientID != nil {
|
|
row := conn.QueryRow(c.Context(),
|
|
`SELECT name, COALESCE(nif,'') FROM clients WHERE id = $1`, *detail.ClientID)
|
|
_ = row.Scan(&clientName, &clientNIF)
|
|
}
|
|
if detail.VehicleID != nil {
|
|
row := conn.QueryRow(c.Context(),
|
|
`SELECT plate FROM vehicles WHERE id = $1`, *detail.VehicleID)
|
|
_ = row.Scan(&vehiclePlate)
|
|
}
|
|
|
|
// Create invoice record
|
|
inv, err := CreateInvoice(c.Context(), conn, b.WorkOrderID, b.Type)
|
|
if err != nil {
|
|
return fiber.NewError(500, "erro ao criar fatura")
|
|
}
|
|
|
|
// Build PDF meta
|
|
docType := "Orçamento"
|
|
if b.Type == "invoice" {
|
|
docType = "Fatura"
|
|
}
|
|
prefix := "ORC"
|
|
if b.Type == "invoice" {
|
|
prefix = "FAT"
|
|
}
|
|
|
|
// Derive tenant schema from search_path for storage directory
|
|
var searchPath string
|
|
row := conn.QueryRow(c.Context(), `SHOW search_path`)
|
|
_ = row.Scan(&searchPath)
|
|
// extract first segment (e.g. "tenant_abc123_def456_...")
|
|
tenantDir := strings.Split(strings.TrimSpace(searchPath), ",")[0]
|
|
tenantDir = strings.TrimSpace(tenantDir)
|
|
|
|
outPath := filepath.Join(storageRoot, tenantDir, fmt.Sprintf("inv_%s.pdf", inv.ID))
|
|
|
|
meta := pdf.DocMeta{
|
|
CompanyName: sett["company_name"],
|
|
CompanyNIF: sett["company_nif"],
|
|
CompanyAddress: sett["company_address"],
|
|
CompanyIBAN: sett["company_iban"],
|
|
CompanyPhone: sett["company_phone"],
|
|
CompanyEmail: sett["company_email"],
|
|
DocType: docType,
|
|
DocNumber: fmt.Sprintf("%s/%d/%04d", prefix, inv.IssuedAt.Year(), inv.Number),
|
|
IssuedAt: inv.IssuedAt.Format("02/01/2006"),
|
|
ClientName: clientName,
|
|
ClientNIF: clientNIF,
|
|
VehiclePlate: vehiclePlate,
|
|
}
|
|
|
|
// Build line items
|
|
lineItems := make([]pdf.LineItem, len(detail.Items))
|
|
for i, item := range detail.Items {
|
|
lineItems[i] = pdf.LineItem{
|
|
Description: item.Description,
|
|
Qty: item.Qty,
|
|
UnitPrice: item.UnitPrice,
|
|
DiscountPct: item.DiscountPct,
|
|
Total: item.Total,
|
|
}
|
|
}
|
|
var staffTotal float64
|
|
for _, sh := range detail.StaffHours {
|
|
staffTotal += sh.Total
|
|
}
|
|
|
|
// Generate PDF
|
|
if err := pdf.Generate(meta, lineItems, staffTotal, outPath); err != nil {
|
|
return fiber.NewError(500, "erro ao gerar PDF")
|
|
}
|
|
|
|
// Store path
|
|
if err := SetPDFPath(c.Context(), conn, inv.ID, outPath); err != nil {
|
|
return fiber.NewError(500, "erro ao registar caminho do PDF")
|
|
}
|
|
inv.PDFPath = outPath
|
|
|
|
// If invoice (not quote), transition WO to invoiced
|
|
if b.Type == "invoice" {
|
|
if err := workorder.TransitionStatus(c.Context(), conn, b.WorkOrderID, detail.Status, "invoiced", ""); err != nil {
|
|
return fiber.NewError(500, "erro ao atualizar estado da ordem")
|
|
}
|
|
}
|
|
|
|
return c.Status(201).JSON(fiber.Map{"data": inv, "error": nil})
|
|
}
|
|
}
|
|
|
|
func downloadPDFH() fiber.Handler {
|
|
return func(c *fiber.Ctx) error {
|
|
id := c.Params("id")
|
|
conn := auth.GetConn(c)
|
|
inv, err := GetInvoice(c.Context(), conn, id)
|
|
if err != nil {
|
|
return fiber.NewError(500, "erro interno")
|
|
}
|
|
if inv == nil {
|
|
return fiber.NewError(404, "fatura não encontrada")
|
|
}
|
|
if inv.PDFPath == "" {
|
|
return fiber.NewError(404, "PDF não disponível")
|
|
}
|
|
if _, err := os.Stat(inv.PDFPath); os.IsNotExist(err) {
|
|
return fiber.NewError(404, "ficheiro PDF não encontrado")
|
|
}
|
|
c.Set("Content-Disposition", fmt.Sprintf(`attachment; filename="invoice_%d.pdf"`, inv.Number))
|
|
return c.SendFile(inv.PDFPath)
|
|
}
|
|
}
|
|
```
|
|
|
|
Note: `handler.go` calls `workorder.GetWorkOrderDetail` and `workorder.TransitionStatus` — these functions must be exported from the `workorder` package. Check if they are already exported. If `GetWorkOrderDetail` is unexported (lowercase), rename it.
|
|
|
|
- [ ] **Step 3: Verify workorder exports**
|
|
|
|
Check `backend/internal/workorder/repository.go` for `GetWorkOrderDetail` and `TransitionStatus`. If unexported, rename them:
|
|
|
|
```bash
|
|
grep -n "func getWorkOrderDetail\|func transitionStatus\|func GetWorkOrderDetail\|func TransitionStatus" \
|
|
/var/home/lmilani/Documentos/IDE/techxcar/backend/internal/workorder/repository.go \
|
|
/var/home/lmilani/Documentos/IDE/techxcar/backend/internal/workorder/handler.go
|
|
```
|
|
|
|
If the functions are in `handler.go` as unexported closures (not top-level named functions), extract them to `repository.go` as exported functions. The typical pattern in this codebase puts DB logic in `repository.go`. Adjust as needed to ensure `GetWorkOrderDetail(ctx, conn, id) (*WorkOrderDetail, error)` and `TransitionStatus(ctx, conn, id, from, to, changedBy string) error` are exported and callable from `invoice/handler.go`.
|
|
|
|
- [ ] **Step 4: Compile check**
|
|
|
|
```bash
|
|
cd /var/home/lmilani/Documentos/IDE/techxcar/backend
|
|
export PATH=$PATH:/var/home/lmilani/.local/go/bin
|
|
go build ./...
|
|
```
|
|
|
|
Fix any compilation errors (missing exports, import cycles). There should be no import cycle since `invoice` imports `workorder` and `settings`, not the other way around.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
cd /var/home/lmilani/Documentos/IDE/techxcar
|
|
git add backend/internal/invoice/ backend/internal/workorder/
|
|
git commit -m "feat: invoice repository and HTTP handlers (generate PDF, list, download)"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 6: Wire Routes + Dockerfile Storage Dir
|
|
|
|
**Files:**
|
|
- Modify: `backend/internal/server/server.go`
|
|
- Modify: `backend/Dockerfile`
|
|
|
|
- [ ] **Step 1: Update server.go**
|
|
|
|
In `backend/internal/server/server.go`, add imports and wire 4 new packages:
|
|
|
|
```go
|
|
import (
|
|
// existing imports...
|
|
"github.com/techxcar/backend/internal/expense"
|
|
"github.com/techxcar/backend/internal/invoice"
|
|
"github.com/techxcar/backend/internal/settings"
|
|
"github.com/techxcar/backend/internal/staff"
|
|
)
|
|
```
|
|
|
|
Inside `if deps.DB != nil && deps.Redis != nil && deps.Config != nil {` block, after the existing route registrations:
|
|
|
|
```go
|
|
staff.RegisterRoutes(app, deps.DB, deps.Config.JWTSecret)
|
|
expense.RegisterRoutes(app, deps.DB, deps.Config.JWTSecret)
|
|
settings.RegisterRoutes(app, deps.DB, deps.Config.JWTSecret)
|
|
invoice.RegisterRoutes(app, deps.DB, deps.Config.JWTSecret)
|
|
```
|
|
|
|
- [ ] **Step 2: Update Dockerfile to create storage volume dir**
|
|
|
|
In `backend/Dockerfile`, add the storage directory creation before the `USER app` line:
|
|
|
|
```dockerfile
|
|
RUN mkdir -p /app/storage && chown -R app:app /app
|
|
```
|
|
|
|
The full updated Dockerfile:
|
|
|
|
```dockerfile
|
|
FROM golang:1.25-alpine AS builder
|
|
WORKDIR /app
|
|
COPY go.mod go.sum ./
|
|
RUN go mod download
|
|
COPY . .
|
|
RUN CGO_ENABLED=0 GOOS=linux go build -o server ./cmd/server
|
|
|
|
FROM alpine:3.19
|
|
RUN apk --no-cache add ca-certificates tzdata
|
|
RUN addgroup -S app && adduser -S app -G app
|
|
WORKDIR /app
|
|
RUN mkdir -p /app/storage && chown -R app:app /app
|
|
COPY --from=builder /app/server .
|
|
COPY --from=builder /app/migrations ./migrations
|
|
USER app
|
|
EXPOSE 8080
|
|
CMD ["./server"]
|
|
```
|
|
|
|
- [ ] **Step 3: Full backend build + compile check**
|
|
|
|
```bash
|
|
cd /var/home/lmilani/Documentos/IDE/techxcar/backend
|
|
export PATH=$PATH:/var/home/lmilani/.local/go/bin
|
|
go build ./...
|
|
go test ./... 2>&1 | grep -v "^---" | grep -v "^==="
|
|
```
|
|
|
|
Expected: build success; tests pass or skip (no TEST_DATABASE_URL).
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
cd /var/home/lmilani/Documentos/IDE/techxcar
|
|
git add backend/internal/server/server.go backend/Dockerfile
|
|
git commit -m "feat: wire staff, expense, settings, invoice routes; add /app/storage dir"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 7: Frontend — Types + AppLayout Nav
|
|
|
|
**Files:**
|
|
- Modify: `frontend/src/lib/types.ts`
|
|
- Modify: `frontend/src/components/layout/AppLayout.tsx`
|
|
|
|
- [ ] **Step 1: Add types to types.ts**
|
|
|
|
Append to `frontend/src/lib/types.ts`:
|
|
|
|
```ts
|
|
export interface Staff {
|
|
id: string
|
|
user_id: string | null
|
|
name: string
|
|
email: string
|
|
phone: string
|
|
type: 'internal' | 'external'
|
|
hourly_rate: number
|
|
active: boolean
|
|
created_at: string
|
|
}
|
|
|
|
export interface Expense {
|
|
id: string
|
|
vehicle_id: string | null
|
|
type: 'fuel' | 'parts' | 'tools' | 'other'
|
|
amount: number
|
|
description: string
|
|
date: string
|
|
created_at: string
|
|
}
|
|
|
|
export type TenantSettings = Record<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:
|
|
|
|
```tsx
|
|
const nav = [
|
|
{ to: '/app', label: 'Dashboard', end: true },
|
|
{ to: '/app/work-orders', label: 'Ordens de Trabalho' },
|
|
{ to: '/app/clients', label: 'Clientes' },
|
|
{ to: '/app/catalog', label: 'Catálogo' },
|
|
{ to: '/app/staff', label: 'Técnicos' },
|
|
{ to: '/app/expenses', label: 'Despesas' },
|
|
{ to: '/app/invoices', label: 'Faturação' },
|
|
{ to: '/app/settings', label: 'Definições' },
|
|
]
|
|
```
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
cd /var/home/lmilani/Documentos/IDE/techxcar
|
|
git add frontend/src/lib/types.ts frontend/src/components/layout/AppLayout.tsx
|
|
git commit -m "feat: add Staff, Expense, TenantSettings, Invoice types and nav items"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 8: Frontend — StaffPage
|
|
|
|
**Files:**
|
|
- Create: `frontend/src/pages/app/StaffPage.tsx`
|
|
- Modify: `frontend/src/App.tsx`
|
|
|
|
- [ ] **Step 1: Create StaffPage.tsx**
|
|
|
|
Create `frontend/src/pages/app/StaffPage.tsx`:
|
|
|
|
```tsx
|
|
import { useState } from 'react'
|
|
import { useQuery, useMutation, useQueryClient, type Resolver } from '@tanstack/react-query'
|
|
import { useForm } from 'react-hook-form'
|
|
import { zodResolver } from '@hookform/resolvers/zod'
|
|
import { z } from 'zod'
|
|
import { apiFetch } from '@/lib/api'
|
|
import { Badge } from '@/components/ui/badge'
|
|
import { Button } from '@/components/ui/button'
|
|
import { Input } from '@/components/ui/input'
|
|
import { Label } from '@/components/ui/label'
|
|
import type { Staff } from '@/lib/types'
|
|
|
|
const STAFF_TYPES = ['internal', 'external'] as const
|
|
|
|
const schema = z.object({
|
|
name: z.string().min(1, 'Nome obrigatório'),
|
|
email: z.string(),
|
|
phone: z.string(),
|
|
type: z.enum(STAFF_TYPES),
|
|
hourly_rate: z.coerce.number().min(0),
|
|
active: z.boolean(),
|
|
})
|
|
type FormData = z.infer<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:
|
|
```tsx
|
|
import StaffPage from '@/pages/app/StaffPage'
|
|
```
|
|
|
|
Inside the `/app` route group, add:
|
|
```tsx
|
|
<Route path="staff" element={<StaffPage />} />
|
|
```
|
|
|
|
- [ ] **Step 3: Build check**
|
|
|
|
```bash
|
|
cd /var/home/lmilani/Documentos/IDE/techxcar/frontend
|
|
npm run build 2>&1 | tail -10
|
|
```
|
|
|
|
Expected: build success, zero TypeScript errors.
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
cd /var/home/lmilani/Documentos/IDE/techxcar
|
|
git add frontend/src/pages/app/StaffPage.tsx frontend/src/App.tsx
|
|
git commit -m "feat: staff page — list, create/edit/delete technicians"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 9: Frontend — ExpensesPage
|
|
|
|
**Files:**
|
|
- Create: `frontend/src/pages/app/ExpensesPage.tsx`
|
|
- Modify: `frontend/src/App.tsx`
|
|
|
|
- [ ] **Step 1: Create ExpensesPage.tsx**
|
|
|
|
Create `frontend/src/pages/app/ExpensesPage.tsx`:
|
|
|
|
```tsx
|
|
import { useState } from 'react'
|
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
|
import { useForm, type Resolver } from 'react-hook-form'
|
|
import { zodResolver } from '@hookform/resolvers/zod'
|
|
import { z } from 'zod'
|
|
import { apiFetch } from '@/lib/api'
|
|
import { Badge } from '@/components/ui/badge'
|
|
import { Button } from '@/components/ui/button'
|
|
import { Input } from '@/components/ui/input'
|
|
import { Label } from '@/components/ui/label'
|
|
import type { Expense } from '@/lib/types'
|
|
|
|
const EXPENSE_TYPES = ['fuel', 'parts', 'tools', 'other'] as const
|
|
const TYPE_LABELS: Record<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**
|
|
|
|
```tsx
|
|
import ExpensesPage from '@/pages/app/ExpensesPage'
|
|
// inside /app group:
|
|
<Route path="expenses" element={<ExpensesPage />} />
|
|
```
|
|
|
|
- [ ] **Step 3: Build check + commit**
|
|
|
|
```bash
|
|
cd /var/home/lmilani/Documentos/IDE/techxcar/frontend && npm run build 2>&1 | tail -5
|
|
cd /var/home/lmilani/Documentos/IDE/techxcar
|
|
git add frontend/src/pages/app/ExpensesPage.tsx frontend/src/App.tsx
|
|
git commit -m "feat: expenses page — list with type filter, create, delete"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 10: Frontend — SettingsPage
|
|
|
|
**Files:**
|
|
- Create: `frontend/src/pages/app/SettingsPage.tsx`
|
|
- Modify: `frontend/src/App.tsx`
|
|
|
|
- [ ] **Step 1: Create SettingsPage.tsx**
|
|
|
|
Create `frontend/src/pages/app/SettingsPage.tsx`:
|
|
|
|
```tsx
|
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
|
import { useForm } from 'react-hook-form'
|
|
import { useEffect } from 'react'
|
|
import { apiFetch } from '@/lib/api'
|
|
import { Button } from '@/components/ui/button'
|
|
import { Input } from '@/components/ui/input'
|
|
import { Label } from '@/components/ui/label'
|
|
import type { TenantSettings } from '@/lib/types'
|
|
|
|
type FormData = {
|
|
company_name: string
|
|
company_nif: string
|
|
company_address: string
|
|
company_iban: string
|
|
company_phone: string
|
|
company_email: string
|
|
}
|
|
|
|
const defaultSettings: FormData = {
|
|
company_name: '',
|
|
company_nif: '',
|
|
company_address: '',
|
|
company_iban: '',
|
|
company_phone: '',
|
|
company_email: '',
|
|
}
|
|
|
|
export default function SettingsPage() {
|
|
const qc = useQueryClient()
|
|
|
|
const { data: settings, isLoading } = useQuery<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**
|
|
|
|
```tsx
|
|
import SettingsPage from '@/pages/app/SettingsPage'
|
|
// inside /app group:
|
|
<Route path="settings" element={<SettingsPage />} />
|
|
```
|
|
|
|
- [ ] **Step 3: Build check + commit**
|
|
|
|
```bash
|
|
cd /var/home/lmilani/Documentos/IDE/techxcar/frontend && npm run build 2>&1 | tail -5
|
|
cd /var/home/lmilani/Documentos/IDE/techxcar
|
|
git add frontend/src/pages/app/SettingsPage.tsx frontend/src/App.tsx
|
|
git commit -m "feat: settings page — tenant company data for PDF generation"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 11: Frontend — InvoicesPage
|
|
|
|
**Files:**
|
|
- Create: `frontend/src/pages/app/InvoicesPage.tsx`
|
|
- Modify: `frontend/src/App.tsx`
|
|
|
|
- [ ] **Step 1: Create InvoicesPage.tsx**
|
|
|
|
Create `frontend/src/pages/app/InvoicesPage.tsx`:
|
|
|
|
```tsx
|
|
import { useState } from 'react'
|
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
|
import { apiFetch } from '@/lib/api'
|
|
import { Badge } from '@/components/ui/badge'
|
|
import { Button } from '@/components/ui/button'
|
|
import type { Invoice, WorkOrder } from '@/lib/types'
|
|
|
|
const TYPE_LABELS: Record<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**
|
|
|
|
```tsx
|
|
import InvoicesPage from '@/pages/app/InvoicesPage'
|
|
// inside /app group:
|
|
<Route path="invoices" element={<InvoicesPage />} />
|
|
```
|
|
|
|
- [ ] **Step 3: Full build + tests**
|
|
|
|
```bash
|
|
cd /var/home/lmilani/Documentos/IDE/techxcar/frontend
|
|
npm run build 2>&1 | tail -10
|
|
npm run test:run
|
|
```
|
|
|
|
Expected: build success, 10 tests pass.
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
cd /var/home/lmilani/Documentos/IDE/techxcar
|
|
git add frontend/src/pages/app/InvoicesPage.tsx frontend/src/App.tsx
|
|
git commit -m "feat: invoices page — generate quote/invoice PDF from work order, list, download"
|
|
```
|
|
|
|
---
|
|
|
|
## Self-Review
|
|
|
|
**Spec coverage:**
|
|
- ✅ Técnicos: internal/external, hourly_rate, active/inactive, CRUD — Tasks 1, 8
|
|
- ✅ Despesas: tipo (fuel/parts/tools/other), valor, data, filtro — Tasks 2, 9
|
|
- ✅ tenant_settings: company_name, NIF, address, IBAN, phone, email — Tasks 3, 10
|
|
- ✅ PDF gerado em Go com go-pdf/fpdf (pure Go, pragmatic MVP — chromedp deferred) — Task 4
|
|
- ✅ Fatura + Orçamento: gerados a partir de OT, numeração sequencial, PDF guardado — Tasks 5, 11
|
|
- ✅ Fatura transiciona OT para "invoiced" — Task 5
|
|
- ✅ PDF endpoint autenticado `/api/v1/invoices/:id/pdf` — Task 5
|
|
- ✅ `/app/storage` volume dir criado no Dockerfile — Task 6
|
|
|
|
**Not in Plan 4 (deferred to Plan 5):**
|
|
- Dashboard KPIs (receita mês, OTs por estado)
|
|
- Relatórios com filtro de período
|
|
- Exportação CSV
|
|
- Notificações Telegram/Email
|
|
- Platform settings (SMTP, Telegram) — only tenant settings in this plan
|