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