---
phase: 3
title: Service Catalog & Quote Builder
status: discussed
date: 2026-05-16
---

# Phase 3 — Decisions & Context

## Phase Goal
L'admin può costruire un catalogo servizi riutilizzabile e comporre preventivi da esso; il cliente vede solo il totale accettato (`accepted_total`).

## Key Decisions (LOCKED)

### 1. Service Catalog — Location: /admin/catalog
- Pagina dedicata `/admin/catalog` con link aggiunto in NavBar (Clienti | Statistiche | Catalogo).
- Tabella con colonne: Nome, Descrizione, Prezzo unitario, Stato (Attivo/Disattivato).
- CRUD completo: aggiungi, modifica inline, disattiva (soft delete via `active = false`).
- Items disattivati restano visibili in elenco (filtro toggle) ma non appaiono nel selettore quote.

### 2. Quote Builder — Location: Tab "Preventivo" in /admin/clients/[id]
- Nuovo tab nell'admin client detail page, accanto a Fasi, Pagamenti, Documenti.
- Mostra le voci preventivo del cliente con totale calcolato.
- L'admin può aggiungere voci dal catalogo (dropdown con `active = true`) o voci libere (nome + prezzo custom).
- Nessun blocco dopo la finalizzazione — voci sempre editabili.

### 3. Voci Preventivo — Catalogo + Free-form
- **Da catalogo**: seleziona voce, inserisce quantità; `unit_price` viene snapshotato al momento dell'aggiunta (non segue futuri cambi al catalogo).
- **Voce libera**: nome testo libero, prezzo unitario, quantità. `service_id` sarà NULL in `quote_items`.

  > **Schema change needed**: `service_id` in `quote_items` deve diventare nullable (attualmente `notNull()`).
  > Aggiungere campo `custom_label text` a `quote_items` per le voci libere.

### 4. Accepted Total — Admin-controlled, not auto-calculated
- Il builder mostra la somma calcolata delle voci come riferimento.
- Esiste un campo separato "Totale accettato dal cliente" (editable input) con pulsante "Salva".
- Il pulsante scrive il valore (che l'admin può modificare liberamente) su `clients.accepted_total`.
- **Rationale**: il cliente accetta una cifra commerciale (es. €1.500 tondo) che può differire dalla somma analitica interna. Il preventivo interno è solo uno strumento di stima.

### 5. Pagamenti — Nessun aggiornamento automatico
- Finalizzare il preventivo NON tocca i record `payments`.
- L'admin aggiorna manualmente gli importi di acconto e saldo nella tab Pagamenti.

### 6. Constraint già in vigore (IMMUTABLE)
- `quote_items` non vengono mai esposti dalle API client-facing.
- `clients.accepted_total` è l'unico valore economico che il cliente vede.

## Schema Changes Required

```sql
-- quote_items.service_id diventa nullable
ALTER TABLE quote_items ALTER COLUMN service_id DROP NOT NULL;

-- aggiunta colonna per voci libere
ALTER TABLE quote_items ADD COLUMN custom_label text;
```

In Drizzle schema.ts:
```ts
service_id: text("service_id").references(() => service_catalog.id, { onDelete: "restrict" }), // removed .notNull()
custom_label: text("custom_label"), // new field
```

## Reusable Assets

- `service_catalog` e `quote_items` tables già presenti in schema.ts con relazioni e TS types.
- Pattern Server Actions già stabilito (vedi `clients/[id]/actions.ts`).
- Pattern tab UI già stabilito (`tabs/PhasesTab.tsx`, `tabs/PaymentsTab.tsx`, etc.).
- Pattern inline edit già stabilito (`DocumentRow.tsx`).
- `fmtEur()` già definita in analytics/page.tsx — estrarre in lib/utils o duplicare.

## Pages & Routes to Create

| Route | Type | Purpose |
|-------|------|---------|
| `/admin/catalog` | Server Component page | Lista + CRUD catalogo servizi |
| `/admin/catalog/actions.ts` | Server Actions | createService, updateService, toggleActive |
| `src/components/admin/tabs/QuoteTab.tsx` | Client Component | Quote builder UI |
| `src/app/admin/clients/[id]/quote-actions.ts` | Server Actions | addQuoteItem, removeQuoteItem, updateAcceptedTotal |

## UI Notes

- Stile coerente con tab esistenti (border-b tabs navigation nell'admin client page).
- Catalogo: tabella simile a admin clients list (bg-white rounded-xl border border-[#e5e7eb]).
- Quote builder: due colonne su desktop (catalogo disponibile | voci selezionate) o lista unica con selettore.
- Totale calcolato mostrato in bold come sommario; campo `accepted_total` separato con label chiara.
- Colore brand: #1A463C per accent, #DEF168 per highlight.
