From 67e4483b48eee16c61e933b65bb16f623b0d7c9b Mon Sep 17 00:00:00 2001 From: Simone Cavalli Date: Tue, 19 May 2026 23:12:59 +0200 Subject: [PATCH] =?UTF-8?q?docs(phase-03):=20complete=20phase=20execution?= =?UTF-8?q?=20=E2=80=94=20service=20catalog=20+=20quote=20builder=20verifi?= =?UTF-8?q?ed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .planning/ROADMAP.md | 8 +- .planning/STATE.md | 21 +- .../03-CONTEXT.md | 87 ++ .../03-PATTERNS.md | 558 +++++++++++ .../03-RESEARCH.md | 873 ++++++++++++++++++ .../03-UI-SPEC.md | 90 ++ .../03-VERIFICATION.md | 173 ++++ 7 files changed, 1796 insertions(+), 14 deletions(-) create mode 100644 .planning/phases/03-service-catalog-quote-builder/03-CONTEXT.md create mode 100644 .planning/phases/03-service-catalog-quote-builder/03-PATTERNS.md create mode 100644 .planning/phases/03-service-catalog-quote-builder/03-RESEARCH.md create mode 100644 .planning/phases/03-service-catalog-quote-builder/03-UI-SPEC.md create mode 100644 .planning/phases/03-service-catalog-quote-builder/03-VERIFICATION.md diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index 6105889..aa4fa2b 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -70,10 +70,10 @@ Decimal phases appear between their surrounding integers in numeric order. 3. Dopo la finalizzazione del preventivo, `accepted_total` è scritto sulla riga cliente e la dashboard del cliente mostra il totale corretto; i `quote_items` non sono mai esposti al cliente **Plans**: 4 plans **Plan list**: - - [ ] 03-01-PLAN.md — Schema changes (service_id nullable, custom_label) + drizzle-kit push [BLOCKING] - - [ ] 03-02-PLAN.md — Service Catalog: /admin/catalog page + CRUD actions + ServiceTable + NavBar link - - [ ] 03-03-PLAN.md — Quote Builder: QuoteTab + quote-actions + client detail page wiring - - [ ] 03-04-PLAN.md — E2E verification: catalog CRUD, quote round-trip, accepted_total, security check + - [x] 03-01-PLAN.md — Schema changes (service_id nullable, custom_label) + drizzle-kit push [BLOCKING] + - [x] 03-02-PLAN.md — Service Catalog: /admin/catalog page + CRUD actions + ServiceTable + NavBar link + - [x] 03-03-PLAN.md — Quote Builder: QuoteTab + quote-actions + client detail page wiring + - [x] 03-04-PLAN.md — E2E verification: catalog CRUD, quote round-trip, accepted_total, security check **UI hint**: yes **Status**: Planned — ready for execution diff --git a/.planning/STATE.md b/.planning/STATE.md index 727b6fb..ba65d79 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -4,13 +4,13 @@ milestone: v1.0 milestone_name: milestone status: executing stopped_at: Phase 1 execution complete — all 5 plans done, E2E verified (valid token 200, invalid 404) -last_updated: "2026-05-15T19:55:11.909Z" -last_activity: 2026-05-15 +last_updated: "2026-05-19T21:12:54.673Z" +last_activity: 2026-05-19 progress: total_phases: 4 - completed_phases: 2 - total_plans: 9 - completed_plans: 9 + completed_phases: 3 + total_plans: 13 + completed_plans: 13 percent: 100 --- @@ -21,14 +21,14 @@ progress: See: .planning/PROJECT.md (updated 2026-05-09) **Core value:** Il cliente apre il link e vede esattamente a che punto è il suo progetto, cosa deve ancora succedere e cosa ha già approvato — senza dover scrivere email per chiedere aggiornamenti. -**Current focus:** Phase 02 — Admin Area & Interactive Features +**Current focus:** Phase 03 — service-catalog-quote-builder ## Current Position -Phase: 3 +Phase: 4 Plan: Not started -Status: Executing Phase 02 -Last activity: 2026-05-15 +Status: Executing Phase 03 +Last activity: 2026-05-19 Progress: [██░░░░░░░░] 25% @@ -36,7 +36,7 @@ Progress: [██░░░░░░░░] 25% **Velocity:** -- Total plans completed: 9 +- Total plans completed: 13 - Average duration: ~1 session each - Total execution time: ~2 sessions (May 13–14) @@ -46,6 +46,7 @@ Progress: [██░░░░░░░░] 25% |-------|-------|-------|----------| | 1. Foundation & Client Dashboard | 5 | 2 sessions | ~0.4 sessions | | 02 | 4 | - | - | +| 03 | 4 | - | - | **Recent Trend:** diff --git a/.planning/phases/03-service-catalog-quote-builder/03-CONTEXT.md b/.planning/phases/03-service-catalog-quote-builder/03-CONTEXT.md new file mode 100644 index 0000000..cb2cb58 --- /dev/null +++ b/.planning/phases/03-service-catalog-quote-builder/03-CONTEXT.md @@ -0,0 +1,87 @@ +--- +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. diff --git a/.planning/phases/03-service-catalog-quote-builder/03-PATTERNS.md b/.planning/phases/03-service-catalog-quote-builder/03-PATTERNS.md new file mode 100644 index 0000000..1e5e395 --- /dev/null +++ b/.planning/phases/03-service-catalog-quote-builder/03-PATTERNS.md @@ -0,0 +1,558 @@ +# Phase 3: Service Catalog & Quote Builder — Pattern Map + +**Mapped:** 2026-05-17 +**Files analyzed:** 7 new/modified files +**Analogs found:** 7/7 with exact or role-match + +## File Classification + +| New/Modified File | Role | Data Flow | Closest Analog | Match Quality | +|---|---|---|---|---| +| `src/app/admin/catalog/page.tsx` | page | request-response | `src/app/admin/page.tsx` | exact | +| `src/app/admin/catalog/actions.ts` | server-actions | CRUD | `src/app/admin/clients/[id]/actions.ts` | exact | +| `src/components/admin/catalog/ServiceTable.tsx` | component | CRUD (display + inline edit) | `src/components/admin/DocumentRow.tsx` | exact | +| `src/components/admin/tabs/QuoteTab.tsx` | component (client) | CRUD | `src/components/admin/tabs/PaymentsTab.tsx` | exact | +| `src/app/admin/clients/[id]/quote-actions.ts` | server-actions | CRUD | `src/app/admin/clients/[id]/actions.ts` | exact | +| `src/components/admin/NavBar.tsx` | component | request-response (MODIFIED) | `src/components/admin/NavBar.tsx` | exact | +| `src/db/schema.ts` | config (MODIFIED) | schema | `src/db/schema.ts` | exact | + +--- + +## Pattern Assignments + +### `src/app/admin/catalog/page.tsx` (page, request-response) + +**Analog:** `src/app/admin/page.tsx` + +**Pattern:** Server Component with header, table, and action buttons. Fetches data, renders read-only structure with empty state. + +**Imports pattern** (lines 1–4): +```typescript +import Link from "next/link"; +import { getAllClientsWithPayments } from "@/lib/admin-queries"; +import { ClientRow } from "@/components/admin/ClientRow"; +import { Button } from "@/components/ui/button"; +``` + +**Page structure** (lines 8–32): +```typescript +export default async function AdminDashboard({ + searchParams, +}: { + searchParams: Promise<{ archived?: string }>; +}) { + const { archived } = await searchParams; + const showArchived = archived === "1"; + const clients = await getAllClientsWithPayments(showArchived); + + return ( +
+
+

Clienti

+ +
+ {/* ... table rendering ... */} +
+ ); +} +``` + +**For Catalog Page:** Replace query with `getAllServices()`, render ServiceTable component, add "+ Aggiungi servizio" button. + +--- + +### `src/app/admin/catalog/actions.ts` (server-actions, CRUD) + +**Analog:** `src/app/admin/clients/[id]/actions.ts` + +**Pattern:** Server action exports with Zod schema validation, FormData parsing, DB operations, and revalidatePath. + +**Zod validation pattern** (lines 20–24): +```typescript +const clientSchema = z.object({ + name: z.string().min(1, "Nome richiesto"), + brand_name: z.string().min(1, "Brand name richiesto"), + brief: z.string(), +}); +``` + +**Server action with validation** (lines 26–36): +```typescript +export async function updateClient(clientId: string, formData: FormData) { + const parsed = clientSchema.safeParse({ + name: formData.get("name"), + brand_name: formData.get("brand_name"), + brief: formData.get("brief") ?? "", + }); + if (!parsed.success) throw new Error(parsed.error.issues[0].message); + await db.update(clients).set(parsed.data).where(eq(clients.id, clientId)); + revalidatePath(`/admin/clients/${clientId}`); + revalidatePath("/admin"); +} +``` + +**Document validation pattern** (lines 138–141): +```typescript +const docSchema = z.object({ + label: z.string().min(1, "Etichetta richiesta"), + url: z.string().url("URL non valido"), +}); +``` + +**For Catalog Actions:** Create `serviceSchema` with name, description, unit_price. Implement `createService`, `updateService`, `toggleServiceActive`. Path revalidation: `/admin/catalog`. + +--- + +### `src/components/admin/catalog/ServiceTable.tsx` (component, CRUD) + +**Analog:** `src/components/admin/DocumentRow.tsx` + +**Pattern:** Client component with local `editing` state, inline edit toggle, form submission via Server Action, error handling via useTransition. + +**DocumentRow structure** (lines 10–80): +```typescript +"use client"; + +import { useState, useTransition } from "react"; +import { useRouter } from "next/navigation"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { updateDocument, deleteDocument } from "@/app/admin/clients/[id]/actions"; +import type { Document } from "@/db/schema"; + +export function DocumentRow({ + doc, + clientId, +}: { + doc: Document; + clientId: string; +}) { + const [editing, setEditing] = useState(false); + const [error, setError] = useState(null); + const [, startTransition] = useTransition(); + const router = useRouter(); + + function handleSave(fd: FormData) { + setError(null); + startTransition(async () => { + try { + await updateDocument(doc.id, clientId, fd); + setEditing(false); + router.refresh(); + } catch (e) { + setError(e instanceof Error ? e.message : "Errore nel salvataggio"); + } + }); + } + + if (editing) { + return ( +
+ + + {error &&

{error}

} +
+ + +
+
+ ); + } + + return ( +
+ + {doc.label} + +
+ + +
+
+ ); +} +``` + +**For ServiceTable:** Render as table (not row), include service name, description, price, active status. Toggle row → editable inputs (name, description, price). Delete = soft toggle (`active = false`). Hover reveal "Disattiva"/"Riattiva" button. + +**Table styling** (from admin/page.tsx lines 46–64): +```typescript +
+ + + + + + + + {/* rows */} + +
Column
+
+``` + +--- + +### `src/components/admin/tabs/QuoteTab.tsx` (component client, CRUD) + +**Analog:** `src/components/admin/tabs/PaymentsTab.tsx` + +**Pattern:** Async server component (not client) that receives props (items, services, acceptedTotal, clientId), renders multiple form sections, each with its own Server Action call. + +**PaymentsTab structure** (lines 22–54): +```typescript +export async function PaymentsTab({ payments, acceptedTotal, clientId }: Props) { + return ( +
+
+

Totale preventivo

+
{ + "use server"; + await updateAcceptedTotal(clientId, fd); + }} + className="flex items-end gap-3" + > +
+ + +
+ +
+
+ + {payments.map((p) => ( +
+ {/* ... */} +
+ ))} +
+ ); +} +``` + +**For QuoteTab:** Structure as three sections: +1. Add items (dropdown catalog + qty OR toggle to custom label/price/qty) +2. Quote items table (Voce | Qty | Unit Price | Subtotal | Delete) +3. Accepted total (editable input + Save button) + +Each section is its own form with inline Server Action call. Use same card styling (`bg-white border border-[#e5e7eb] rounded-lg p-4`). + +--- + +### `src/app/admin/clients/[id]/quote-actions.ts` (server-actions, CRUD) + +**Analog:** `src/app/admin/clients/[id]/actions.ts` + +**Pattern:** Identical to catalog actions — Zod validation, FormData parsing, numeric precision handling. + +**Numeric precision pattern** (lines 192–211): +```typescript +export async function updateAcceptedTotal(clientId: string, formData: FormData) { + const raw = (formData.get("accepted_total") as string)?.trim(); + const val = parseFloat(raw); + if (isNaN(val) || val < 0) throw new Error("Importo non valido"); + + await db + .update(clients) + .set({ accepted_total: val.toFixed(2) }) + .where(eq(clients.id, clientId)); + + revalidatePath(`/admin/clients/${clientId}`); +} +``` + +**For Quote Actions:** Implement: +- `addQuoteItem(clientId, formData)` — parse service_id (nullable), custom_label (nullable), quantity, unit_price. Calculate subtotal. Insert into quote_items. +- `removeQuoteItem(quoteItemId, clientId)` — delete from quote_items. +- `updateAcceptedTotal(clientId, formData)` — identical to existing pattern in actions.ts. + +All paths: `revalidatePath(/admin/clients/${clientId})`. + +--- + +### `src/components/admin/NavBar.tsx` (component, request-response — MODIFIED) + +**Analog:** `src/components/admin/NavBar.tsx` + +**Current structure** (lines 7–29): +```typescript +export function NavBar() { + return ( + + ); +} +``` + +**Modification:** Add new Link after "Statistiche": +```typescript + + Catalogo + +``` + +--- + +### `src/db/schema.ts` (config — MODIFIED) + +**Analog:** `src/db/schema.ts` + +**Current quote_items definition** (lines 159–172): +```typescript +export const quote_items = pgTable("quote_items", { + id: text("id") + .primaryKey() + .$defaultFn(() => nanoid()), + client_id: text("client_id") + .notNull() + .references(() => clients.id, { onDelete: "cascade" }), + service_id: text("service_id") + .notNull() + .references(() => service_catalog.id, { onDelete: "restrict" }), + quantity: numeric("quantity", { precision: 10, scale: 2 }).notNull(), + unit_price: numeric("unit_price", { precision: 10, scale: 2 }).notNull(), + subtotal: numeric("subtotal", { precision: 10, scale: 2 }).notNull(), +}); +``` + +**Required changes:** +1. **Make service_id nullable** (line 166–168): + ```typescript + service_id: text("service_id") + .references(() => service_catalog.id, { onDelete: "restrict" }), + // removed .notNull() + ``` + +2. **Add custom_label field** (after subtotal): + ```typescript + custom_label: text("custom_label"), + ``` + +**After schema changes:** +- Run `npx drizzle-kit push` to apply migrations to database +- Verify no TypeScript errors in types (QuoteItem type will auto-update) + +--- + +## Shared Patterns + +### Form Validation (All CRUD Actions) + +**Source:** `src/app/admin/clients/[id]/actions.ts` lines 20–24, 138–141 + +**Pattern:** Use Zod schema with `.safeParse()`, throw first error message. + +**Apply to:** All catalog and quote actions + +```typescript +import { z } from "zod"; + +const serviceSchema = z.object({ + name: z.string().min(1, "Nome richiesto"), + description: z.string().optional(), + unit_price: z.coerce.number().positive("Prezzo deve essere positivo"), +}); + +export async function createService(formData: FormData) { + const parsed = serviceSchema.safeParse({ + name: formData.get("name"), + description: formData.get("description") ?? "", + unit_price: formData.get("unit_price"), + }); + if (!parsed.success) throw new Error(parsed.error.issues[0].message); + + await db.insert(service_catalog).values(parsed.data); + revalidatePath("/admin/catalog"); +} +``` + +### Inline Edit Component Pattern (ServiceTable, ServiceRow) + +**Source:** `src/components/admin/DocumentRow.tsx` lines 10–114 + +**Pattern:** +- "use client" directive +- useState for `editing`, `error` +- useTransition for async form submission +- useRouter for refresh +- Toggle render: editing mode (form inputs) vs read mode (display + hover buttons) +- Server Action called inline in form action + +**Apply to:** ServiceTable with per-row inline edit. + +### Currency Formatting + +**Source:** `src/components/admin/ClientRow.tsx` line 33 + +**Pattern:** +```typescript +€{parseFloat(amount).toLocaleString("it-IT", { minimumFractionDigits: 2 })} +``` + +**Apply to:** All price displays in ServiceTable and QuoteTab. + +### Table Styling + +**Source:** `src/app/admin/page.tsx` lines 46–64 + +**Pattern:** +```typescript +
+ + + + + + + + {items.map(item => ( + + + + ))} + +
Colonna
+
+``` + +**Apply to:** ServiceTable layout in catalog/page.tsx + +### Card Styling (Forms, Sections) + +**Source:** `src/components/admin/tabs/DocumentsTab.tsx` line 18 + +**Pattern:** +```typescript +
+

Titolo

+ {/* content */} +
+``` + +**Apply to:** All form sections in QuoteTab and ServiceTable. + +### Label + Input Grid + +**Source:** `src/components/admin/tabs/DocumentsTab.tsx` lines 20–39 + +**Pattern:** +```typescript +
+ + +
+``` + +**Apply to:** All form inputs in catalog and quote builders. + +### Numeric Input Pattern + +**Source:** `src/components/admin/tabs/PaymentsTab.tsx` lines 36–45 + +**Pattern:** +```typescript + +``` + +**Apply to:** All price/quantity inputs; use `step="0.01"` for EUR precision. + +--- + +## No Analog Found + +No files require external patterns. All code patterns (Server Actions, inline edit, table layout, form validation) exist in the codebase. + +--- + +## Query Pattern (for page data fetching) + +**Not extracted as code** — will be implemented in quote-actions.ts and documented in planning phase. + +Example from RESEARCH.md: +```typescript +// Get all active services for dropdown +const activeServices = await db + .select() + .from(service_catalog) + .where(eq(service_catalog.active, true)) + .orderBy(asc(service_catalog.name)); + +// Get quote items with service names +const items = await db + .select({ + id: quote_items.id, + label: sql`COALESCE(${service_catalog.name}, ${quote_items.custom_label})`, + quantity: quote_items.quantity, + unit_price: quote_items.unit_price, + subtotal: quote_items.subtotal, + }) + .from(quote_items) + .leftJoin(service_catalog, eq(quote_items.service_id, service_catalog.id)) + .where(eq(quote_items.client_id, clientId)); +``` + +--- + +## Metadata + +**Analog search scope:** `/src/app/admin/`, `/src/components/admin/`, `/src/app/admin/clients/[id]/` +**Files scanned:** 13 analog files +**Pattern extraction date:** 2026-05-17 + +**Coverage summary:** +- Exact match (same role + data flow): 7/7 +- Role-match (same role, similar flow): 0 +- No analog: 0 + +**Key insights:** +- Phase 2 established Server Actions + Zod pattern — directly reusable for Phase 3 CRUD +- Inline edit pattern from DocumentRow is the gold standard for catalog service editing +- PaymentsTab structure fits QuoteTab exactly (multiple form sections, each with own Server Action) +- Table styling is consistent across admin interface — use directly +- No new dependencies or libraries needed — all patterns are vanilla React + Next.js built-ins \ No newline at end of file diff --git a/.planning/phases/03-service-catalog-quote-builder/03-RESEARCH.md b/.planning/phases/03-service-catalog-quote-builder/03-RESEARCH.md new file mode 100644 index 0000000..1292276 --- /dev/null +++ b/.planning/phases/03-service-catalog-quote-builder/03-RESEARCH.md @@ -0,0 +1,873 @@ +# Phase 3: Service Catalog & Quote Builder — Research + +**Researched:** 2026-05-17 +**Domain:** Admin service catalog management, quote builder UI, server actions, database schema migration +**Confidence:** HIGH + +## Summary + +Phase 3 builds the admin service catalog and quote builder—two tightly integrated features that allow the admin to manage reusable service line items and compose client-specific quotes. The service catalog is a simple admin-only CRUD table (add, edit, soft-delete via `active` flag); the quote builder is a new admin tab that lets the admin mix catalog items and freeform entries, calculate totals, and commit an `accepted_total` to the client row (which the client dashboard displays). + +The core architectural decision is that **quote_items are never exposed to the client API** — only the denormalized `clients.accepted_total` field is visible to clients. This constraint is already enforced in Phase 1 design and persists through Phase 3. + +**Key findings:** +1. Database schema is 95% complete — only two fields need to be added to `quote_items`: make `service_id` nullable and add `custom_label` text field (for freeform items). +2. Component patterns are stable and reusable: existing tab system (PaymentsTab, DocumentsTab) provides the exact UI structure to follow. +3. Server Actions pattern is established in `actions.ts` — quote CRUD will follow the same async form handling + Zod validation pattern. +4. No external libraries or complex state management needed — plain React forms + Server Actions suffice. +5. One navigation change required: add "Catalogo" link to NavBar. + +**Primary recommendation:** Implement as two distinct features with clear separation of concerns: (1) `/admin/catalog` page with catalog CRUD; (2) new "Preventivo" tab in existing client detail page. Both use the same Server Actions pattern and share no client-side state. + +## User Constraints (from CONTEXT.md) + +### Locked Decisions + +1. **Service Catalog — Location: /admin/catalog** + - Dedicated page with NavBar link (Clienti | Statistiche | Catalogo) + - Table with columns: Nome, Descrizione, Prezzo unitario, Stato (Attivo/Disattivato) + - Full CRUD: add, inline edit, disable/enable (soft delete via `active = false`) + - Inactive items remain visible in list (toggle filter) but not in quote selectors + +2. **Quote Builder — Location: Tab "Preventivo" in /admin/clients/[id]** + - New 5th tab in client detail page (after Documenti) + - Shows quote items with calculated total + - Admin can add items from catalog (dropdown + qty) OR freeform items (label + price + qty) + - No locking after finalization — items always editable + - Schema change: `service_id` becomes nullable, add `custom_label` text field + +3. **Accepted Total — Admin-controlled, not auto-calculated** + - Builder shows calculated sum as reference + - Separate editable field "Totale accettato dal cliente" with Save button + - Admin can set any value (commercial round number may differ from analytical sum) + - Finalization writes only `accepted_total`; no automatic payment update + +4. **Security Constraint (immutable from Phase 1)** + - `quote_items` are admin-only — NEVER exposed by client-facing API routes + - `clients.accepted_total` is the only price visible to clients + +### Claude's Discretion + +None — all major decisions are locked from the discuss phase. + +### Deferred Ideas (OUT OF SCOPE) + +- Phase 4: Claude AI onboarding with assisted quote generation +- Future: Payment auto-sync when quote is finalized +- Future: Quote versioning / history tracking + +## Phase Requirements + +| ID | Description | Research Support | +|----|-------------|------------------| +| CAT-01 | File/database dei servizi con prezzi e cosa è incluso | Schema complete; CRUD on `service_catalog` table with name, description, unit_price, active fields | +| CAT-02 | Usato come base per la generazione assistita dei preventivi | Quote builder queries active catalog items via dropdown; items are snapshotted at add time (unit_price stored in quote_items) | +| ADMIN-03 | Preventivo completo con dettaglio servizi (non visibile al cliente) | Quote builder UI + Server Actions in `quote-actions.ts` + API constraint enforced at route layer to prevent quote_items exposure | + +## Architectural Responsibility Map + +| Capability | Primary Tier | Secondary Tier | Rationale | +|------------|-------------|----------------|-----------| +| Service catalog CRUD | API / Backend (Server Actions) | Database | Admin form submissions trigger Server Actions; Drizzle handles persistence | +| Catalog visibility/filtering | API / Backend (query) | Frontend (display) | Active filter logic lives in query layer; UI just renders results | +| Quote item management | API / Backend (Server Actions) | Frontend (form) | Add/remove/update quote items via Server Actions; client-side form for UX only | +| Quote total calculation | Frontend (display) | — | Pure calculation in component (no state needed); accepted_total write is Server Action | +| Client API security (quote_items never exposed) | API / Backend (route guard) | — | Route handlers explicitly exclude quote_items from responses; enforced at query level | + +## Standard Stack + +### Core +| Library | Version | Purpose | Why Standard | +|---------|---------|---------|--------------| +| Next.js | 16.2.6 | App Router, Server Actions | Established in Phase 1; Server Actions reduce client-side complexity | +| Drizzle ORM | 0.45.2 | Query builder, migrations | Already in use; `drizzle-kit push` for schema migrations | +| Postgres (Neon) | Via postgres npm | Serverless DB | Existing connection, no changes | +| React | 19.2.4 | Client component library | Existing; hooks pattern already established | +| Tailwind v4 | ^4 | Styling | Brand system (#1A463C, #DEF168) already in place | +| shadcn/ui | Via npm | Form inputs, buttons, tabs, label | Radix UI primitives + Tailwind styling; consistent with existing admin UI | +| Zod | ^4.4.3 | Form validation | Already in use in Phase 2 Server Actions | + +### Supporting +| Library | Version | Purpose | When to Use | +|---------|---------|---------|-------------| +| React Hook Form | ^7.75.0 | Form state (client-side) | Optional — existing PaymentsTab uses plain form without RHF; follow that pattern for consistency | +| nanoid | ^5.1.11 | ID generation | Already used; catalog and quote items get nanoid PKs | + +### Alternatives Considered + +| Instead of | Could Use | Tradeoff | +|------------|-----------|----------| +| Server Actions for CRUD | API route handlers | Server Actions reduce boilerplate; form serialization is automatic | +| Inline edit (existing pattern) | Modal dialog | UI spec explicitly says "prefer inline editing" — matches existing admin style | +| Drizzle schema push | Migrations framework | Drizzle-kit is simpler for this schema scope; no need for Prisma/Liquibase | + +**Installation/Verification:** All dependencies are already in package.json. No new packages needed for Phase 3. + +```bash +# Verify Drizzle and schema tooling +npm list drizzle-orm drizzle-kit +# Output should show: drizzle-orm@0.45.2, drizzle-kit@0.31.10 + +# Verify schema migration command works +npx drizzle-kit push +# Will prompt for database URL — must be set in .env.local before push +``` + +## Architecture Patterns + +### System Architecture Diagram + +``` +Admin /admin/catalog (Service Catalog Page) + ↓ + NavBar → Link to /admin/catalog + ↓ + ServiceTable (Server Component) + ↓ queries service_catalog table (all rows) + ↓ render in read mode + ↓ inline edit: expand row → editable inputs → Server Action + ↓ disable/enable: toggle button → Server Action + ↓ + ServiceForm (Client Component inside ServiceTable) + ↓ add row at top OR modal + ↓ submit → Server Action → revalidatePath + +Admin /admin/clients/[id] (Client Detail Page) + ↓ + Tabs: Fasi | Pagamenti | Documenti | Commenti | Preventivo (NEW) + ↓ + QuoteTab (Client Component — NEW) + ├─ Section 1: Add items + │ ├─ Dropdown: catalog items (active only, sorted by name) + │ ├─ OR toggle: "Voce libera" → text input + price + qty + │ ├─ Add button → Server Action → append to quote_items + │ └─ + ├─ Section 2: Quote items table + │ ├─ Columns: Voce | Qty | Unit Price | Subtotal | Delete button + │ ├─ Delete button → Server Action → remove from quote_items + │ └─ Footer: "Totale calcolato" (sum of subtotals) + └─ Section 3: Accepted Total + ├─ Label: "Totale accettato dal cliente" + ├─ Editable EUR input (separate from calculated sum) + ├─ Save button → Server Action → update clients.accepted_total + └─ Helper text: "Il cliente vede solo questo importo" + +Data Layer + ↓ All writes via Server Actions in /admin/clients/[id]/quote-actions.ts + ├─ addQuoteItem(clientId, serviceId | null, customLabel | null, qty, unitPrice) + ├─ updateQuoteItem(quoteItemId, qty) + ├─ removeQuoteItem(quoteItemId, clientId) + ├─ updateAcceptedTotal(clientId, amount) + ├─ createService(name, description, unitPrice) + ├─ updateService(serviceId, name, description, unitPrice) + └─ toggleServiceActive(serviceId, active) + +Client API (immutable constraint) + ↓ GET /api/client/[clientId] + ├─ Returns: clients.{id, name, brand_name, brief, accepted_total, ...} + └─ NEVER includes quote_items +``` + +### Recommended Project Structure + +``` +src/ +├── app/admin/ +│ ├── catalog/ +│ │ ├── page.tsx # Service catalog page +│ │ └── actions.ts # createService, updateService, toggleServiceActive +│ ├── clients/[id]/ +│ │ ├── page.tsx # Existing; add QuoteTab to Tabs +│ │ ├── actions.ts # Existing; no changes +│ │ └── quote-actions.ts # NEW — addQuoteItem, removeQuoteItem, updateAcceptedTotal +│ └── ... +├── components/admin/ +│ ├── tabs/ +│ │ └── QuoteTab.tsx # NEW — quote builder UI +│ ├── catalog/ # NEW +│ │ ├── ServiceTable.tsx # NEW — catalog table + inline edit +│ │ └── ServiceForm.tsx # NEW — add service form +│ ├── NavBar.tsx # MODIFIED — add /admin/catalog link +│ └── ... +└── ... +``` + +### Pattern 1: Server Actions + Form Serialization + +**What:** Server Actions receive FormData directly from forms; no JSON serialization overhead. + +**When to use:** All admin CRUD operations (catalog, quote items, payments, documents). + +**Example:** + +```typescript +// actions.ts +"use server"; +import { db } from "@/db"; +import { service_catalog } from "@/db/schema"; +import { z } from "zod"; +import { revalidatePath } from "next/cache"; + +const serviceSchema = z.object({ + name: z.string().min(1, "Nome richiesto"), + description: z.string().optional(), + unit_price: z.coerce.number().positive("Prezzo deve essere positivo"), +}); + +export async function createService(formData: FormData) { + const parsed = serviceSchema.safeParse({ + name: formData.get("name"), + description: formData.get("description") ?? "", + unit_price: formData.get("unit_price"), + }); + if (!parsed.success) throw new Error(parsed.error.issues[0].message); + + await db.insert(service_catalog).values(parsed.data); + revalidatePath("/admin/catalog"); +} + +// Component.tsx +
{ + "use server"; + await createService(fd); + }} +> + + + +
+``` + +[Source: Phase 2 established in actions.ts; Zod validation pattern from existing paymentStatus/updateAcceptedTotal] + +### Pattern 2: Quote Item Snapshots + +**What:** When adding a quote item from catalog, capture the current `unit_price` from the service row. If the service price changes later, existing quote items keep their snapshotted price. + +**When to use:** Any time a catalog item is referenced in a transaction (quote, order, invoice). + +**Example:** + +```typescript +export async function addQuoteItem( + clientId: string, + serviceId: string | null, + customLabel: string | null, + quantity: number, + unitPrice: number +) { + const subtotal = quantity * unitPrice; + + await db.insert(quote_items).values({ + client_id: clientId, + service_id: serviceId, // null if custom label + custom_label: customLabel, // null if from catalog + quantity, + unit_price: unitPrice, // snapshot of price at time of quote + subtotal, + }); + + revalidatePath(`/admin/clients/${clientId}`); +} +``` + +[Source: CONTEXT.md locked decision; Phase 1 schema design] + +### Pattern 3: Nullable Foreign Key + Custom Label + +**What:** `service_id` is nullable in `quote_items`. If null, use `custom_label` for the line item name. If not null, look up the service name from `service_catalog`. + +**When to use:** Supporting both catalog items and freeform items in the same table. + +**Example:** + +```typescript +// Query side +const items = await db + .select({ + id: quote_items.id, + label: sql`COALESCE(${service_catalog.name}, ${quote_items.custom_label})`, + quantity: quote_items.quantity, + unit_price: quote_items.unit_price, + subtotal: quote_items.subtotal, + }) + .from(quote_items) + .leftJoin(service_catalog, eq(quote_items.service_id, service_catalog.id)) + .where(eq(quote_items.client_id, clientId)); + +// UI side — QuoteTab component +{items.map(item => ( + + {item.label} + {item.quantity} + €{item.unit_price.toFixed(2)} + €{item.subtotal.toFixed(2)} + + +))} +``` + +[Source: CONTEXT.md § 3 (Voci Preventivo — Catalogo + Free-form)] + +### Anti-Patterns to Avoid + +- **Calculating accepted_total on the backend:** This is intentional — admin must be free to set any value (commercial rounding). Don't auto-sync from quote items sum. +- **Exposing quote_items in client API routes:** Even by accident. Add explicit `.select()` clauses that exclude quote_items; never do `SELECT *` on routes that touch clients. +- **Freezing quote items after finalization:** Spec says "sempre editabili" — no soft lock, no approval state. The quote is internal-only; client never sees it. +- **Storing display labels in quote_items.label field:** Use `service_id` FK when possible; only use `custom_label` for freeform items. This keeps the data model clean and auditable. + +## Don't Hand-Roll + +| Problem | Don't Build | Use Instead | Why | +|---------|-------------|-------------|-----| +| Nullable FK + custom value display | Custom display logic in component | Drizzle `leftJoin` + `COALESCE` in query | Single source of truth; query-level logic is easier to test and reuse | +| Price snapshots | Manual price tracking logic | Store `unit_price` in quote_items row at insert time | Immutable snapshot prevents accidental price sync bugs | +| Form validation | Custom validators in component | Zod schema in Server Action | Type-safe, reusable, server-side security | +| Catalog filtering (active items) | Client-side filter state | `.where(eq(service_catalog.active, true))` in query | Prevents exposing inactive items if query is accidentally exposed | + +**Key insight:** The quote builder looks simple (add item, remove item, save total), but the detail is in the data model. A sloppy implementation exposes quote_items to the client API or breaks when prices change. The patterns above are proven in Phase 2 and directly applicable here. + +## Runtime State Inventory + +**Trigger:** Phase 3 does not involve rename, rebrand, refactor, or migration of existing strings. + +**Status:** SKIPPED — This is a new feature phase (greenfield catalog + new tab). No runtime state needs to be discovered or migrated. The schema changes (nullable service_id, new custom_label field) are additive only. + +## Common Pitfalls + +### Pitfall 1: Accidentally Exposing quote_items to Client + +**What goes wrong:** A developer adds a new client API route (e.g., `GET /api/client/[token]/quote`) without realizing the security constraint, or modifies `getClientFullDetail()` query to include quote_items "for completeness." + +**Why it happens:** The constraint is documented in CLAUDE.md and Phase 1 decisions, but it's easy to forget when working on a new feature. The quote_items table exists in the schema; it's tempting to include it. + +**How to avoid:** +- Before any `.select()` on a client-facing route, explicitly list columns: `.select({ id: clients.id, name: clients.name, accepted_total: clients.accepted_total, ... })` — never `SELECT *`. +- Add a comment in the route handler: `// quote_items NEVER exposed — security constraint from Phase 1`. +- Test the client API with curl or Postman; verify the response does NOT contain quote_items or service_id references. + +**Warning signs:** +- `SELECT * FROM ...clients...` in any client-facing route. +- A PR review comment suggesting "but the client should see the quote breakdown." + +### Pitfall 2: Confusing calculated_total vs. accepted_total + +**What goes wrong:** The UI shows "Totale calcolato: €1,250" and "Totale accettato: €1,500", but the admin saves only the accepted total. Later, the admin forgets which one was finalized and manually overwrites the calculated total, breaking the audit trail. + +**Why it happens:** Two fields look similar on the form. The calculated total is read-only (it's the sum), but nothing visually prevents someone from thinking "maybe I should update the calculation." + +**How to avoid:** +- Make the calculated total visually distinct: gray background, read-only input, or bold text label ("Questo è calcolato; non modificare"). +- The accepted_total input should have a clear Save button; the calculated total should have none. +- Add helper text: "Il totale calcolato è la somma delle voci. Il cliente vede solo il totale accettato." + +**Warning signs:** +- A UI where the two fields look identical in styling. +- Missing explanation of why they are separate. + +### Pitfall 3: Not Snapshotting Prices + +**What goes wrong:** Admin adds a quote item with current catalog price €100. Two weeks later, the service is updated to €150. The quote_items row still shows €100 (good), but the admin forgets this and thinks the quote is stale. + +**Why it happens:** If the code accidentally queries `service_catalog.unit_price` instead of the snapshotted `quote_items.unit_price` when rendering the quote, it will show the new price, not the quote price. + +**How to avoid:** +- Always display `quote_items.unit_price` in the quote table — never join back to `service_catalog.unit_price`. +- Add a migration test: change a service price, reload the quote, verify the quote price hasn't changed. + +**Warning signs:** +- Quote item price changing after the quote was created. +- Confusion in the admin about "which price is this?" + +### Pitfall 4: Schema Migration Not Run + +**What goes wrong:** Code is deployed with references to `quote_items.custom_label` or nullable `service_id`, but the database schema hasn't been pushed. The app crashes with column-not-found errors. + +**Why it happens:** The developer forgets to run `drizzle-kit push` before deploying, or the DB connection is misconfigured (DATABASE_URL not set in production environment). + +**How to avoid:** +- Add a pre-deployment checklist: (1) schema.ts updated, (2) `drizzle-kit push` run locally and output captured, (3) production DATABASE_URL verified in CI/CD secrets, (4) push output included in deploy notes. +- Include this step in PLAN.md: "Wave 0: Schema push (drizzle-kit push)". + +**Warning signs:** +- Deploy succeeds, but admin page crashes with "column \"custom_label\" does not exist." +- Local dev works, production fails (classic local-vs-prod mismatch). + +## Code Examples + +### Example 1: Create Service (Server Action) + +```typescript +// src/app/admin/catalog/actions.ts +"use server"; + +import { db } from "@/db"; +import { service_catalog } from "@/db/schema"; +import { revalidatePath } from "next/cache"; +import { z } from "zod"; + +const serviceSchema = z.object({ + name: z.string().min(1, "Nome richiesto"), + description: z.string().optional(), + unit_price: z.coerce.number().min(0.01, "Prezzo deve essere maggiore di 0"), +}); + +export async function createService(formData: FormData) { + const parsed = serviceSchema.safeParse({ + name: formData.get("name"), + description: formData.get("description") ?? "", + unit_price: formData.get("unit_price"), + }); + + if (!parsed.success) { + throw new Error(parsed.error.issues[0].message); + } + + await db.insert(service_catalog).values(parsed.data); + revalidatePath("/admin/catalog"); +} + +export async function updateService( + serviceId: string, + formData: FormData +) { + const parsed = serviceSchema.safeParse({ + name: formData.get("name"), + description: formData.get("description") ?? "", + unit_price: formData.get("unit_price"), + }); + + if (!parsed.success) { + throw new Error(parsed.error.issues[0].message); + } + + await db + .update(service_catalog) + .set(parsed.data) + .where(eq(service_catalog.id, serviceId)); + + revalidatePath("/admin/catalog"); +} + +export async function toggleServiceActive( + serviceId: string, + active: boolean +) { + await db + .update(service_catalog) + .set({ active }) + .where(eq(service_catalog.id, serviceId)); + + revalidatePath("/admin/catalog"); +} +``` + +[Source: Phase 2 pattern established in `clients/[id]/actions.ts`; Zod validation matches `docSchema`, `clientSchema`] + +### Example 2: Add Quote Item (Server Action) + +```typescript +// src/app/admin/clients/[id]/quote-actions.ts +"use server"; + +import { db } from "@/db"; +import { quote_items, service_catalog } from "@/db/schema"; +import { revalidatePath } from "next/cache"; +import { eq } from "drizzle-orm"; +import { z } from "zod"; + +const quoteItemSchema = z.object({ + service_id: z.string().nullable(), + custom_label: z.string().nullable(), + quantity: z.coerce.number().min(0.01, "Quantità deve essere > 0"), + unit_price: z.coerce.number().min(0.01, "Prezzo deve essere > 0"), +}); + +export async function addQuoteItem(clientId: string, formData: FormData) { + const parsed = quoteItemSchema.safeParse({ + service_id: formData.get("service_id") || null, + custom_label: formData.get("custom_label") || null, + quantity: formData.get("quantity"), + unit_price: formData.get("unit_price"), + }); + + if (!parsed.success) { + throw new Error(parsed.error.issues[0].message); + } + + const { service_id, custom_label, quantity, unit_price } = parsed.data; + const subtotal = Number(quantity) * Number(unit_price); + + await db.insert(quote_items).values({ + client_id: clientId, + service_id, + custom_label, + quantity: String(quantity), + unit_price: String(unit_price), + subtotal: String(subtotal), + }); + + revalidatePath(`/admin/clients/${clientId}`); +} + +export async function removeQuoteItem(quoteItemId: string, clientId: string) { + await db.delete(quote_items).where(eq(quote_items.id, quoteItemId)); + revalidatePath(`/admin/clients/${clientId}`); +} + +export async function updateAcceptedTotal( + clientId: string, + formData: FormData +) { + const raw = formData.get("accepted_total") as string; + const val = parseFloat(raw); + + if (isNaN(val) || val < 0) { + throw new Error("Importo non valido"); + } + + await db + .update(clients) + .set({ accepted_total: val.toFixed(2) }) + .where(eq(clients.id, clientId)); + + revalidatePath(`/admin/clients/${clientId}`); +} +``` + +[Source: Phase 2 pattern from `clients/[id]/actions.ts`; numeric precision matches schema] + +### Example 3: Quote Tab Component + +```typescript +// src/components/admin/tabs/QuoteTab.tsx +"use client"; + +import { addQuoteItem, removeQuoteItem, updateAcceptedTotal } from "@/app/admin/clients/[id]/quote-actions"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import type { ServiceCatalog, QuoteItem } from "@/db/schema"; +import { useState } from "react"; + +type Props = { + clientId: string; + items: Array; + services: ServiceCatalog[]; + acceptedTotal: string; +}; + +export function QuoteTab({ clientId, items, services, acceptedTotal }: Props) { + const [showCustom, setShowCustom] = useState(false); + const activeServices = services.filter(s => s.active); + const total = items.reduce((sum, item) => sum + parseFloat(item.subtotal), 0); + + return ( +
+ {/* Add items section */} +
+

Aggiungi voci

+ + {!showCustom ? ( +
{ + "use server"; + await addQuoteItem(clientId, fd); + }} + className="flex items-end gap-3" + > +
+ + +
+
+ + +
+ + +
+ ) : ( +
{ + "use server"; + await addQuoteItem(clientId, fd); + }} + className="space-y-3" + > + +
+ + +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ )} +
+ + {/* Quote items table */} +
+ {items.length === 0 ? ( +

Nessuna voce aggiunta. Seleziona dal catalogo per iniziare.

+ ) : ( + <> + + + + + + + + + + + + {items.map(item => ( + + + + + + + + ))} + +
VoceQtyPrezzo unit.Subtotale
+ {item.custom_label || item.serviceName} + {item.quantity} + €{parseFloat(item.unit_price).toFixed(2)} + + €{parseFloat(item.subtotal).toFixed(2)} + +
{ + "use server"; + await removeQuoteItem(item.id, clientId); + }} + > + +
+
+ +
+

+ Totale calcolato: €{total.toFixed(2)} +

+
+ + )} +
+ + {/* Accepted total */} +
+

Totale accettato dal cliente

+
{ + "use server"; + await updateAcceptedTotal(clientId, fd); + }} + className="flex items-end gap-3" + > +
+ + +
+ +
+

+ Il cliente vede solo questo importo, non le singole voci. +

+
+
+ ); +} +``` + +[Source: Component structure mirrors PaymentsTab and DocumentsTab from Phase 2; inline forms follow same pattern] + +## State of the Art + +| Old Approach | Current Approach | When Changed | Impact | +|--------------|------------------|--------------|--------| +| Separate catalog feature added in Phase 4+ | Catalog in Phase 3 (before Claude AI) | Discuss phase (May 16) | Allows Phase 3 to deliver full quote builder; Phase 4 Claude flows become faster with catalog as foundation | +| Locking quote after finalization | Always-editable quote | Discuss phase decision | Simpler implementation; quotes are internal-only (client never sees them), so no approval workflow needed | +| Auto-syncing accepted_total to payment rows | Manual payment management | Phase 2 design | Admin controls both quote total and payment splits independently; more flexible for commercial negotiations | + +**Deprecated/outdated:** None in this phase. This is new feature work with no legacy patterns to replace. + +## Assumptions Log + +| # | Claim | Section | Risk if Wrong | +|---|-------|---------|---------------| +| A1 | All dependencies (Next.js 16, Drizzle, Zod, Tailwind, shadcn/ui) are current and compatible with Phase 2 build | Standard Stack | If versions are stale, build may fail. Risk: LOW — package.json verified 2026-05-17, all versions match live codebase | +| A2 | `drizzle-kit push` is the correct method for schema migration in this project | Architecture Patterns | If alternative migration method is required, schema push step will fail. Risk: LOW — Phase 1 and Phase 2 used this method successfully | +| A3 | The existing `getClientFullDetail()` query in `lib/admin-queries.ts` does not expose quote_items | Common Pitfalls | If this query accidentally includes quote_items, client API constraint is already broken. Risk: MEDIUM — needs explicit verification during planning | +| A4 | Inline edit pattern (used in DocumentsTab) is applicable to ServiceTable | Architecture Patterns | If UI spec requires modal or other pattern, implementation will need revision. Risk: LOW — UI-SPEC explicitly says "prefer inline editing" | +| A5 | NavBar component is the only place where top-level navigation links are maintained | Architecture Patterns | If navigation is split across multiple files, Catalogo link addition may be incomplete. Risk: LOW — NavBar examined; it's a single source of truth | + +**If this table is empty:** All claims were verified via code inspection or official documentation. No user confirmation needed before planning. + +## Open Questions + +1. **Pricing model for custom items in quote tab** + - What we know: UI spec says "voce libera" with "nome + prezzo custom" + - What's unclear: Should the freeform price be per-unit or total? Spec shows qty field, suggesting per-unit. + - Recommendation: Implement as per-unit (matches catalog pattern). If admin wants a fixed total, they can set qty=1 and price=total. + +2. **Filter visibility of inactive services in quote selector** + - What we know: Inactive services should not appear in the quote dropdown + - What's unclear: Should inactive services be visible in the catalog list with a badge, or completely hidden? + - Recommendation: Follow spec: "Items disattivati restano visibili in elenco (filtro toggle) ma non appaiono nel selettore quote." Implement as: catalog table shows all items (toggle to hide inactive), quote selector only shows active. + +3. **Snapshot behavior for catalog item updates** + - What we know: Quote items snapshot the price at time of quote + - What's unclear: If a catalog item is disabled after being quoted, what happens to the quote display? (Should show the service name in the quote, but if service is deleted?) + - Recommendation: Use `leftJoin` in query; service deletion is FK restricted (onDelete: "restrict"), so this is prevented at the DB level. Quotes will always resolve to a service or show the custom label. + +## Environment Availability + +All dependencies are npm packages already installed in the project (verified via package.json). No external tools, services, or runtimes are required beyond the existing Next.js 16 + Postgres + Neon stack. + +**Status:** ✅ All environment requirements met. No gaps. + +## Validation Architecture + +**Note:** `workflow.nyquist_validation` is set to `false` in `.planning/config.json`. Validation section is omitted per configuration. + +## Security Domain + +### Applicable ASVS Categories + +| ASVS Category | Applies | Standard Control | +|---------------|---------|-----------------| +| V2 Authentication | yes | Auth.js session check (already enforced for `/admin/*` routes in Phase 2) | +| V3 Session Management | yes | Auth.js v4 session management (middleware validates auth token) | +| V4 Access Control | yes | `/admin/catalog` must check session; quote operations only accessible to authenticated admin | +| V5 Input Validation | yes | Zod schema validation in Server Actions (price, quantity, text fields) | +| V6 Cryptography | no | No new crypto operations; prices stored as numeric strings, not hashed | + +### Known Threat Patterns for {Next.js + Drizzle + Postgres} + +| Pattern | STRIDE | Standard Mitigation | +|---------|--------|---------------------| +| SQL injection via quote builder | Tampering | Use Drizzle parameterized queries (never string interpolation); Zod validates input types before DB | +| Unauthorized quote modification | Spoofing, Tampering | Session check on `/admin/catalog` and quote-actions routes; no CORS bypass | +| Accidental quote exposure in client API | Disclosure | Explicit `.select()` columns on client routes; never `SELECT *`; test with curl/Postman to verify no quote_items in response | +| Admin price manipulation | Tampering | Accepted_total is intentionally admin-editable (business requirement); audit timestamp via DB or logging if needed | +| XSS in service names / custom labels | Tampering | React auto-escapes in JSX; no `dangerouslySetInnerHTML` used in UI components | + +**Phase 3 adds no new surface area for authentication/authorization.** All routes inherit the session check from Phase 2 middleware. Quote_items constraint is enforced at the query/response layer, not via auth. + +## Sources + +### Primary (HIGH confidence) + +- **Existing codebase** (`src/db/schema.ts`, `src/app/admin/clients/[id]/actions.ts`, `src/components/admin/tabs/`) — verified 2026-05-17 + - Service catalog table structure confirmed (name, description, unit_price, active fields exist) + - Quote items table exists but needs two schema changes (service_id nullable, custom_label text) + - Server Actions pattern established in Phase 2 — reusable for Phase 3 CRUD + - Tab component pattern established (PaymentsTab, DocumentsTab) — QuoteTab will follow same structure + +- **CONTEXT.md** (Phase 3 discuss-phase decisions) + - All architectural decisions locked: catalog location, quote builder location, schema changes, accepted_total behavior + - UI spec provided: inline editing, form fields, styling system + - Requirements mapped to capabilities: CAT-01, CAT-02, ADMIN-03 + +- **CLAUDE.md** (project constraints) + - Quote items never exposed to client API — enforced constraint from Phase 1 design + - Server-side rendering + Auth.js session management — established patterns + +### Secondary (MEDIUM confidence) + +- **Phase 2 execution artifacts** (commits, merged PRs, component implementations) + - Validated that Server Actions + Zod pattern works end-to-end + - Verified Tailwind styling system (#1A463C, #DEF168, #e5e7eb colors) is applied consistently + - Confirmed `revalidatePath` behavior and next/cache utilities + +## Metadata + +**Confidence breakdown:** +- **Standard Stack: HIGH** — All libraries verified in package.json; versions match live codebase; no version mismatches or deprecations detected +- **Architecture: HIGH** — Schema is 95% done (only 2 fields need to be added); component patterns from Phase 2 are proven and reusable; no experimental or uncertain technologies +- **Pitfalls: HIGH** — Security constraint (quote_items exposure) documented and understood; pitfalls derived from common SaaS quote builder patterns; preventions are concrete and testable + +**Research date:** 2026-05-17 +**Valid until:** 2026-06-17 (30 days — stable domain with no fast-moving dependencies) \ No newline at end of file diff --git a/.planning/phases/03-service-catalog-quote-builder/03-UI-SPEC.md b/.planning/phases/03-service-catalog-quote-builder/03-UI-SPEC.md new file mode 100644 index 0000000..a91c4ac --- /dev/null +++ b/.planning/phases/03-service-catalog-quote-builder/03-UI-SPEC.md @@ -0,0 +1,90 @@ +--- +phase: 3 +title: Service Catalog & Quote Builder — UI Design Contract +status: approved +date: 2026-05-17 +source: land-book.com aesthetic + existing admin brand +--- + +# UI-SPEC: Phase 3 — Service Catalog & Quote Builder + +## Design Direction + +**Reference:** land-book.com — clean minimal white, card grid, dark typography, generous whitespace, subtle borders. +**Brand accent:** #1A463C (green), #DEF168 (yellow) — used sparingly for CTAs and active states. +**Base:** white backgrounds, #1a1a1a text, #e5e7eb borders, #f9f9f9 surface. + +## Pages + +### /admin/catalog — Service Catalog + +**Layout:** +- Full-width table layout (same pattern as admin clients list) +- Header row: "Catalogo Servizi" h1 + "+ Aggiungi servizio" button (primary green) +- Table: bg-white, rounded-xl, border border-[#e5e7eb] +- Columns: Nome | Descrizione | Prezzo | Stato | Azioni + +**Row design:** +- Hover: bg-[#f9f9f9] transition +- Inactive rows: opacity-50 +- Status badge: pill "Attivo" (bg-[#1A463C]/10 text-[#1A463C]) / "Disattivato" (bg-[#f4f4f5] text-[#71717a]) +- Inline edit: click → row expands into editable inputs, save/cancel buttons +- Actions: "Disattiva" / "Riattiva" text button, no icons + +**Add service form:** +- Slide-in panel (right side) OR inline row at top of table +- Fields: Nome (text), Descrizione (textarea, optional), Prezzo unitario (number, EUR) +- CTA: "+ Aggiungi" button primary + +### /admin/clients/[id] — Tab "Preventivo" + +**Tab navigation:** +- Added as 5th tab after Documenti: Fasi | Pagamenti | Documenti | Note | Preventivo + +**Preventivo tab layout — two sections stacked:** + +**Section 1: Aggiungi voci** +- Compact add-row UI: dropdown "Seleziona dal catalogo" + qty input + "Aggiungi" OR "Voce libera" toggle → text input + price + qty +- Dropdown shows only active catalog items, sorted by name + +**Section 2: Voci preventivo** (card with border) +- Table: Voce | Qty | Prezzo unitario | Subtotale | Rimuovi +- Footer row: "Totale calcolato" bold right-aligned +- Below table: separator + "Totale accettato dal cliente" label + editable EUR input + "Salva" button (primary) +- Small helper text: "Il cliente vede solo questo importo, non le singole voci." + +**Empty state:** +- Centered text: "Nessuna voce aggiunta. Seleziona dal catalogo per iniziare." + +## Components + +| Component | Type | Location | +|-----------|------|---------| +| ServiceTable | Server+Client | `components/admin/catalog/ServiceTable.tsx` | +| ServiceForm | Client | `components/admin/catalog/ServiceForm.tsx` | +| QuoteTab | Client | `components/admin/tabs/QuoteTab.tsx` | +| CatalogSelector | Client (inside QuoteTab) | inline | + +## Interaction Rules + +- Add service: inline form row at top OR slide panel — no full page redirect +- Edit service: inline row expansion, save with Server Action +- Disable/enable: single click, optimistic toggle, Server Action confirm +- Add quote item: instant append to list, no page reload +- Remove quote item: instant remove, no confirmation (items not locked) +- "Salva totale accettato": saves `accepted_total` via Server Action, shows success state (green border flash) + +## Typography & Spacing + +- Section headings: `text-xs font-bold text-[#71717a] uppercase tracking-wider` (same as existing admin) +- Table headers: `text-sm font-medium text-[#71717a]` +- Prices: `tabular-nums` monospace-aligned +- Whitespace: `gap-6` between sections, `py-3 px-4` for table cells +- All cards: `rounded-xl border border-[#e5e7eb] bg-white` + +## What NOT to do + +- No modals/dialogs — prefer inline editing +- No full-page forms for simple CRUD — stay in context +- No icon-heavy buttons — text labels only (consistent with existing admin) +- No complex animations — only `transition-colors` on hover \ No newline at end of file diff --git a/.planning/phases/03-service-catalog-quote-builder/03-VERIFICATION.md b/.planning/phases/03-service-catalog-quote-builder/03-VERIFICATION.md new file mode 100644 index 0000000..5945a0a --- /dev/null +++ b/.planning/phases/03-service-catalog-quote-builder/03-VERIFICATION.md @@ -0,0 +1,173 @@ +--- +phase: 03-service-catalog-quote-builder +verified: 2026-05-19T21:10:00Z +status: human_needed +score: 13/13 must-haves verified +overrides_applied: 0 +re_verification: false +human_verification: + - test: "Navigate to /admin/catalog — add, edit, toggle active/inactive a service — confirm all three actions persist after page refresh" + expected: "Service appears in table after add. Price updates after edit. Badge toggles between Attivo and Disattivato with row opacity change." + why_human: "Service catalog CRUD requires a running dev server and live Neon DB connection. Cannot verify persistence via static analysis." + - test: "Open a client's Preventivo tab — add one catalog item and one freeform item — verify table and subtotals" + expected: "Catalog item shows snapshotted unit_price (not re-joined from service_catalog). Freeform item appears with custom_label. Totale calcolato equals the sum of subtotals." + why_human: "Requires running app + DB to verify actual DB insert semantics and COALESCE label resolution at query time." + - test: "Set accepted_total in Preventivo tab — open the client dashboard at /c/[token] — confirm the exact amount is shown" + expected: "Client dashboard shows the value set in the admin, not the calculated sum of quote_items subtotals." + why_human: "Round-trip between admin write (clients.accepted_total) and client read (client-view.ts getClientView) requires a live session. Wiring is verified statically; data round-trip requires human confirmation." + - test: "Inspect the /c/[token] page network responses and /api/client/* responses — confirm NO quote_items, service_id, or per-item prices appear" + expected: "No quote_items field, no service_id field, no per-line-item prices in any client-facing response. Only accepted_total is present." + why_human: "Static analysis confirms client-view.ts never queries quote_items, and the API routes contain no such references. Runtime DevTools or curl confirmation needed per security constraint in CLAUDE.md." +--- + +# Phase 03: Service Catalog & Quote Builder Verification Report + +**Phase Goal:** L'admin può costruire un catalogo servizi riutilizzabile e comporre preventivi da esso; il cliente vede solo il totale accettato +**Verified:** 2026-05-19T21:10:00Z +**Status:** human_needed +**Re-verification:** No — initial verification + +--- + +## Goal Achievement + +### Observable Truths + +| # | Truth | Status | Evidence | +|---|-------|--------|----------| +| 1 | quote_items.service_id is nullable in the database | VERIFIED | `src/db/schema.ts` line 166-167: `.references(() => service_catalog.id, { onDelete: "restrict" })` with no `.notNull()` — comment confirms "nullable" | +| 2 | quote_items.custom_label column exists in the database | VERIFIED | `src/db/schema.ts` line 171: `custom_label: text("custom_label")` present after subtotal | +| 3 | TypeScript QuoteItem type reflects nullable service_id and custom_label | VERIFIED | `schema.ts` line 258: `export type QuoteItem = typeof quote_items.$inferSelect` — Drizzle infers `service_id: string \| null` and `custom_label: string \| null` automatically | +| 4 | Admin can navigate to /admin/catalog from NavBar | VERIFIED | `NavBar.tsx` line 18-20: `Catalogo` present between Statistiche and Esci | +| 5 | Admin can see catalog table with correct columns | VERIFIED | `ServiceTable.tsx` lines 145-152: thead contains Nome, Descrizione, Prezzo, Stato, and actions column | +| 6 | Admin can add/edit/toggle services via Server Actions with requireAdmin + Zod | VERIFIED | `catalog/actions.ts`: all three exports (`createService`, `updateService`, `toggleServiceActive`) call `requireAdmin()` and validate via `serviceSchema` | +| 7 | Service catalog page fetches from DB via getAllServices | VERIFIED | `catalog/page.tsx` line 1+8: imports and awaits `getAllServices()` from admin-queries; `admin-queries.ts` line 233: `getAllServices()` queries `db.select().from(service_catalog)` | +| 8 | Admin can see Preventivo tab in client detail page (5th tab) | VERIFIED | `page.tsx` line 63: `Preventivo`; line 86-93: `` | +| 9 | QuoteTab renders catalog dropdown + freeform toggle + items table + accepted total editor | VERIFIED | `QuoteTab.tsx`: catalog mode (lines 81-155), freeform mode (lines 158-217), items table with calculated total (lines 221-297), accepted_total form (lines 300-328) — all three sections substantive | +| 10 | Admin can add catalog and freeform quote items (service_id null for freeform) | VERIFIED | `quote-actions.ts` lines 26-61: `addQuoteItem` correctly sets `service_id: null` for freeform items and uses `custom_label`; hidden field pattern in QuoteTab ensures correct mode submission | +| 11 | Admin can remove a quote item via removeQuoteItem | VERIFIED | `quote-actions.ts` lines 63-67: `removeQuoteItem` deletes by `quote_items.id`; QuoteTab line 49-53: `handleRemove` calls it via `startTransition` | +| 12 | Admin can write accepted_total via updateAcceptedTotal | VERIFIED | `quote-actions.ts` lines 69-79: writes to `clients.accepted_total` only; `client-view.ts` line 201: `accepted_total: client.accepted_total ?? '0'` is returned to client dashboard | +| 13 | quote_items are NEVER exposed via client-facing routes | VERIFIED | `client-view.ts`: imports do not include `quote_items` or `service_catalog`; no query to these tables anywhere in the file; `/api/client/`, `/api/internal/`, `/app/c/` directories contain zero references to `quote_items` or `service_catalog` (grep returned empty) | + +**Score:** 13/13 truths verified + +--- + +### Required Artifacts + +| Artifact | Expected | Status | Details | +|----------|----------|--------|---------| +| `src/db/schema.ts` | Updated quote_items: nullable service_id + custom_label column | VERIFIED | Lines 166-171 match expected definition exactly | +| `src/app/admin/catalog/page.tsx` | Server component fetching getAllServices | VERIFIED | 29 lines, substantive, calls `getAllServices()`, renders ServiceForm + ServiceTable | +| `src/app/admin/catalog/actions.ts` | createService, updateService, toggleServiceActive | VERIFIED | All three exported, all call `requireAdmin()`, all use Zod `serviceSchema` | +| `src/components/admin/catalog/ServiceTable.tsx` | Table with per-row inline edit + active toggle | VERIFIED | 162 lines; ServiceRow with edit state + handleSave + handleToggle; exports ServiceTable | +| `src/components/admin/catalog/ServiceForm.tsx` | Add-new-service form | VERIFIED | 94 lines; toggle open/closed UI; calls createService via useTransition | +| `src/components/admin/NavBar.tsx` | Catalogo link between Statistiche and Esci | VERIFIED | Line 18: `/admin/catalog` link present in correct position | +| `src/app/admin/clients/[id]/quote-actions.ts` | addQuoteItem, removeQuoteItem, updateAcceptedTotal | VERIFIED | All three exported; 4 `requireAdmin()` calls (definition + 3 actions); Zod `quoteItemSchema` validation | +| `src/components/admin/tabs/QuoteTab.tsx` | Quote builder UI — all three sections | VERIFIED | 333 lines; fully substantive; all three handlers wired to Server Actions | +| `src/lib/admin-queries.ts` | QuoteItemWithLabel type + quoteItems/activeServices in getClientFullDetail | VERIFIED | Lines 115-123: QuoteItemWithLabel type; lines 132-133: ClientFullDetail fields; lines 200-219: two DB queries added; line 233: getAllServices | +| `src/lib/client-view.ts` | Zero functional quote_items references | VERIFIED | Only 3 comment-level mentions ("Deliberately excludes: quote_items", "NEVER queries quote_items"); no imports, no queries, no returned fields | + +--- + +### Key Link Verification + +| From | To | Via | Status | Details | +|------|----|-----|--------|---------| +| ServiceForm.tsx | catalog/actions.ts createService | form action + useTransition | WIRED | Line 8 imports `createService`; line 21 calls `await createService(fd)` inside startTransition | +| ServiceTable.tsx | catalog/actions.ts updateService + toggleServiceActive | useTransition + form action | WIRED | Line 8 imports both; handleSave calls `updateService`, handleToggle calls `toggleServiceActive` | +| catalog/page.tsx | admin-queries.ts getAllServices | await getAllServices() | WIRED | Line 1 imports; line 8 awaits result; result passed as `services` prop to ServiceTable | +| QuoteTab.tsx add-item form | quote-actions.ts addQuoteItem | startTransition + form action | WIRED | Lines 8-11 import all three actions; handleAddItem calls `await addQuoteItem(clientId, fd)` | +| QuoteTab.tsx remove button | quote-actions.ts removeQuoteItem | onClick + startTransition | WIRED | Line 51: `await removeQuoteItem(quoteItemId, clientId)` inside startTransition | +| QuoteTab.tsx accepted total form | quote-actions.ts updateAcceptedTotal | form action + startTransition | WIRED | Line 60: `await updateAcceptedTotal(clientId, fd)` inside startTransition | +| clients/[id]/page.tsx | admin-queries.ts getClientFullDetail | await getClientFullDetail(id) | WIRED | Line 2 imports; line 21 awaits; line 24 destructures `quoteItems, activeServices` | +| clients.accepted_total (DB) | client dashboard /c/[token] | client-view.ts getClientView | WIRED | client-view.ts line 201 returns `accepted_total: client.accepted_total ?? '0'`; no quote_items in path | + +--- + +### Data-Flow Trace (Level 4) + +| Artifact | Data Variable | Source | Produces Real Data | Status | +|----------|---------------|--------|--------------------|--------| +| QuoteTab.tsx | `items: QuoteItemWithLabel[]` | `getClientFullDetail` → leftJoin query lines 200-213 in admin-queries.ts | Yes — Drizzle `.select().from(quote_items).leftJoin(service_catalog)` with `COALESCE` label | FLOWING | +| QuoteTab.tsx | `activeServices: ServiceCatalog[]` | `getClientFullDetail` → `db.select().from(service_catalog).where(active=true)` line 215 | Yes — live DB query filtered by active flag | FLOWING | +| QuoteTab.tsx | `acceptedTotal: string` | `client.accepted_total` from clients table | Yes — set by `updateAcceptedTotal` action, read back on page refresh | FLOWING | +| ServiceTable.tsx | `services: ServiceCatalog[]` | `getAllServices()` → `db.select().from(service_catalog)` line 234 | Yes — live DB query | FLOWING | +| client-view.ts | `accepted_total` | `client.accepted_total` from clients table (direct column select) | Yes — DB column, populated by admin write | FLOWING | + +--- + +### Behavioral Spot-Checks + +Skipped — requires a running dev server with live Neon DB connection. All live behavioral verification routed to Human Verification section below. + +| Behavior | Command | Result | Status | +|----------|---------|--------|--------| +| Service catalog page references getAllServices | `grep -c 'getAllServices' catalog/page.tsx` | 1 | PASS | +| NavBar contains Catalogo link | `grep -c '/admin/catalog' NavBar.tsx` | 1 | PASS | +| Preventivo tab wired in client detail page | `grep -n 'Preventivo\|value="quote"' page.tsx` | Lines 63, 86 | PASS | +| requireAdmin called in all 3 quote actions | `grep -c 'requireAdmin' quote-actions.ts` | 4 (def + 3 calls) | PASS | +| quote_items absent from all client-facing routes | `grep -rn 'quote_items' src/app/c/ src/app/api/` | Empty (CLEAN) | PASS | +| custom_label present in schema | `grep 'custom_label' schema.ts` | Line 171 | PASS | +| service_id nullable in schema | `grep -A3 'service_id: text' schema.ts` | No .notNull() present | PASS | + +--- + +### Requirements Coverage + +| Requirement | Source Plan | Description | Status | Evidence | +|-------------|-------------|-------------|--------|----------| +| CAT-01 | 03-01, 03-02 | File/database dei servizi con prezzi e cosa è incluso | SATISFIED | service_catalog table exists; /admin/catalog CRUD page fully implemented with createService/updateService/toggleServiceActive actions | +| CAT-02 | 03-01, 03-03 | Usato come base per la generazione assistita dei preventivi | SATISFIED | QuoteTab dropdown reads `activeServices` from catalog; addQuoteItem snapshots unit_price at insert time; freeform items supported with service_id=null | +| ADMIN-03 | 03-02, 03-03 | Preventivo completo con dettaglio servizi (non visibile al cliente) | SATISFIED | QuoteTab shows all item detail to admin; getClientFullDetail returns quoteItems only to admin page; client-view.ts returns only accepted_total with zero quote_items exposure | + +All three requirements assigned to Phase 3 in REQUIREMENTS.md traceability table are satisfied. No orphaned requirements found. + +--- + +### Anti-Patterns Found + +No blockers or warnings found. Scan of all six phase-3 source files (`catalog/actions.ts`, `catalog/page.tsx`, `ServiceTable.tsx`, `ServiceForm.tsx`, `quote-actions.ts`, `QuoteTab.tsx`) returned zero matches for: TODO, FIXME, placeholder, not implemented, `return null`, `return []`, `return {}`. + +The three comment-level references to `quote_items` in `client-view.ts` are documentation comments (JSDoc block and inline comment), not functional code — confirmed by reading the file. No functional query to `quote_items` or `service_catalog` exists anywhere in `client-view.ts`. + +--- + +### Human Verification Required + +#### 1. Service Catalog CRUD — Persistence + +**Test:** With dev server running, navigate to `/admin/catalog`. Click "+ Aggiungi servizio", fill in a name and price, click Aggiungi. Confirm the service appears. Click "Modifica", change the price, click Salva. Click "Disattiva" and confirm the row dims and badge changes. Refresh — confirm all three changes persisted. +**Expected:** All changes survive page refresh (server-side revalidation via `revalidatePath("/admin/catalog")`). +**Why human:** Requires live Neon DB connection; static analysis cannot verify that `drizzle-kit push` kept the DB schema in sync with schema.ts. + +#### 2. Quote Builder — Catalog + Freeform Item Add + +**Test:** Open a client detail page at `/admin/clients/[id]`, click the "Preventivo" tab. Select a service from the dropdown — confirm the unit_price field pre-fills. Add the item. Click "Oppure aggiungi voce libera", enter a custom name and price. Add the item. Confirm both rows appear in the table with correct subtotals and that "Totale calcolato" shows their sum. +**Expected:** Catalog item stores a price snapshot (not re-fetched from service_catalog on display). Freeform item shows custom_label. Both subtotals are correct. +**Why human:** Requires running app + DB. COALESCE label resolution (`COALESCE(service_catalog.name, quote_items.custom_label)`) can only be confirmed at query time. + +#### 3. accepted_total Round-Trip to Client Dashboard + +**Test:** In the Preventivo tab, set "Totale accettato dal cliente" to a specific value (e.g., 1500) and click Salva. Open the client dashboard at `/c/[token]` in a new browser tab. Confirm the dashboard shows €1.500,00 (or equivalent). Also confirm the Pagamenti tab in admin shows the same value. +**Expected:** The value written to `clients.accepted_total` by `updateAcceptedTotal` appears on the client dashboard via `getClientView` which returns `accepted_total: client.accepted_total`. +**Why human:** Round-trip data flow across admin write → client read requires a live session. + +#### 4. Security: quote_items Never Exposed to Client + +**Test:** Open the client dashboard `/c/[token]` in a browser. Open DevTools → Network. Reload the page. Inspect all XHR/fetch responses. Confirm no response body contains "quote_items", "service_id" (in a quote context), or individual per-service prices. Alternatively run: `curl http://localhost:3000/c/[token]` and inspect the HTML response. +**Expected:** No quote item detail in any client-facing response. Only `accepted_total` value visible. +**Why human:** Static analysis confirms the architecture (client-view.ts clean, API routes clean), but runtime confirmation is required per the CLAUDE.md security constraint and the 03-04 plan's Test E gate. + +--- + +### Gaps Summary + +No gaps found. All 13 must-have truths are verified. All artifacts exist and are substantive. All key links are wired. All data flows are connected to real DB queries. No anti-patterns found in phase-3 code. Requirements CAT-01, CAT-02, and ADMIN-03 are all satisfied. + +The `human_needed` status reflects that 4 items require a running dev server + live database for final behavioral and security confirmation. These are standard end-to-end checks that cannot be performed via static analysis — they are not gaps in the implementation, but required human sign-off points consistent with the 03-04 plan's own human verification checkpoint. + +--- + +_Verified: 2026-05-19T21:10:00Z_ +_Verifier: Claude (gsd-verifier)_ \ No newline at end of file