+```
+
+**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
+
+```
+
+[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 ? (
+
+ ) : (
+
+ )}
+
+
+ {/* Quote items table */}
+
+ {items.length === 0 ? (
+
Nessuna voce aggiunta. Seleziona dal catalogo per iniziare.
+ ) : (
+ <>
+
+
+
+
Voce
+
Qty
+
Prezzo unit.
+
Subtotale
+
+
+
+
+ {items.map(item => (
+
+
+ {item.custom_label || item.serviceName}
+
+
{item.quantity}
+
+ €{parseFloat(item.unit_price).toFixed(2)}
+
+
+ €{parseFloat(item.subtotal).toFixed(2)}
+
+
+
+
+
+ ))}
+
+
+
+
+
+ Totale calcolato: €{total.toFixed(2)}
+
+
+ >
+ )}
+
+
+ {/* Accepted total */}
+
+
Totale accettato dal cliente
+
+
+ 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