docs(phase-03): complete phase execution — service catalog + quote builder verified

This commit is contained in:
Simone Cavalli
2026-05-19 23:12:59 +02:00
parent fe0a65ebeb
commit 67e4483b48
7 changed files with 1796 additions and 14 deletions
@@ -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.
@@ -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 14):
```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 832):
```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 (
<div>
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold text-[#1a1a1a]">Clienti</h1>
<Button asChild>
<Link href="/admin/clients/new">+ Nuovo cliente</Link>
</Button>
</div>
{/* ... table rendering ... */}
</div>
);
}
```
**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 2024):
```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 2636):
```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 138141):
```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 1080):
```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<string | null>(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 (
<form action={handleSave} className="bg-white border-2 border-[#1A463C]/30 rounded-lg px-4 py-3 space-y-2">
<Input name="label" defaultValue={doc.label} required />
<Input name="url" defaultValue={doc.url} type="url" required />
{error && <p className="text-xs text-red-600">{error}</p>}
<div className="flex gap-2 pt-1">
<Button type="submit" size="sm">Salva</Button>
<Button type="button" variant="ghost" size="sm" onClick={() => setEditing(false)}>
Annulla
</Button>
</div>
</form>
);
}
return (
<div className="flex items-center justify-between bg-white border border-[#e5e7eb] rounded-lg px-4 py-3 group">
<a href={doc.url} className="text-sm text-[#1A463C] hover:underline font-medium">
{doc.label}
</a>
<div className="flex items-center gap-1">
<Button variant="ghost" size="sm" onClick={() => setEditing(true)}>
Modifica
</Button>
<Button variant="ghost" size="sm" onClick={handleDelete}>
Rimuovi
</Button>
</div>
</div>
);
}
```
**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 4664):
```typescript
<div className="bg-white rounded-lg border border-[#e5e7eb] overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-[#f9f9f9] border-b border-[#e5e7eb]">
<tr>
<th className="text-left py-3 px-4 font-medium text-[#71717a]">Column</th>
</tr>
</thead>
<tbody>
{/* rows */}
</tbody>
</table>
</div>
```
---
### `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 2254):
```typescript
export async function PaymentsTab({ payments, acceptedTotal, clientId }: Props) {
return (
<div className="space-y-6 max-w-md">
<div className="bg-white border border-gray-200 rounded-lg p-4">
<h3 className="font-medium text-gray-900 mb-3">Totale preventivo</h3>
<form
action={async (fd: FormData) => {
"use server";
await updateAcceptedTotal(clientId, fd);
}}
className="flex items-end gap-3"
>
<div className="space-y-1 flex-1">
<Label htmlFor="accepted_total">Importo ()</Label>
<Input
id="accepted_total"
name="accepted_total"
type="number"
step="0.01"
min="0"
defaultValue={acceptedTotal}
/>
</div>
<Button type="submit" size="sm">Salva</Button>
</form>
</div>
{payments.map((p) => (
<div key={p.id} className="bg-white border border-gray-200 rounded-lg p-4">
{/* ... */}
</div>
))}
</div>
);
}
```
**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 192211):
```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 729):
```typescript
export function NavBar() {
return (
<nav className="bg-[#1A463C] px-6 py-3 flex items-center justify-between">
<div className="flex items-center gap-6">
<span className="font-bold text-white tracking-tight">iamcavalli</span>
<Link href="/admin" className="text-sm text-white/70 hover:text-white transition-colors">
Clienti
</Link>
<Link href="/admin/analytics" className="text-sm text-white/70 hover:text-white transition-colors">
Statistiche
</Link>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => signOut({ callbackUrl: "/admin/login" })}
className="text-sm text-white/70 hover:text-white hover:bg-white/10"
>
Esci
</Button>
</nav>
);
}
```
**Modification:** Add new Link after "Statistiche":
```typescript
<Link href="/admin/catalog" className="text-sm text-white/70 hover:text-white transition-colors">
Catalogo
</Link>
```
---
### `src/db/schema.ts` (config — MODIFIED)
**Analog:** `src/db/schema.ts`
**Current quote_items definition** (lines 159172):
```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 166168):
```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 2024, 138141
**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 10114
**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 4664
**Pattern:**
```typescript
<div className="bg-white rounded-lg border border-[#e5e7eb] overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-[#f9f9f9] border-b border-[#e5e7eb]">
<tr>
<th className="text-left py-3 px-4 font-medium text-[#71717a]">Colonna</th>
</tr>
</thead>
<tbody>
{items.map(item => (
<tr key={item.id} className="border-b border-[#f4f4f5] hover:bg-[#f9f9f9]">
<td className="py-3 px-4">…</td>
</tr>
))}
</tbody>
</table>
</div>
```
**Apply to:** ServiceTable layout in catalog/page.tsx
### Card Styling (Forms, Sections)
**Source:** `src/components/admin/tabs/DocumentsTab.tsx` line 18
**Pattern:**
```typescript
<div className="bg-white border border-[#e5e7eb] rounded-lg p-4 space-y-3">
<h3 className="font-medium text-[#1a1a1a]">Titolo</h3>
{/* content */}
</div>
```
**Apply to:** All form sections in QuoteTab and ServiceTable.
### Label + Input Grid
**Source:** `src/components/admin/tabs/DocumentsTab.tsx` lines 2039
**Pattern:**
```typescript
<div className="space-y-1">
<Label htmlFor="field-id">Label testo</Label>
<Input
id="field-id"
name="field-name"
type="text"
placeholder="placeholder"
required
/>
</div>
```
**Apply to:** All form inputs in catalog and quote builders.
### Numeric Input Pattern
**Source:** `src/components/admin/tabs/PaymentsTab.tsx` lines 3645
**Pattern:**
```typescript
<Input
id="price"
name="unit_price"
type="number"
step="0.01"
min="0"
defaultValue={price}
/>
```
**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
@@ -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
<form
action={async (fd: FormData) => {
"use server";
await createService(fd);
}}
>
<input name="name" required />
<input name="unit_price" type="number" step="0.01" required />
<button type="submit">Aggiungi</button>
</form>
```
[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 => (
<tr key={item.id}>
<td>{item.label}</td>
<td>{item.quantity}</td>
<td>{item.unit_price.toFixed(2)}</td>
<td>{item.subtotal.toFixed(2)}</td>
<td><button onClick={() => removeQuoteItem(item.id)}>Rimuovi</button></td>
</tr>
))}
```
[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<QuoteItem & { serviceName?: string }>;
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 (
<div className="space-y-6 max-w-2xl">
{/* Add items section */}
<div className="bg-white border border-[#e5e7eb] rounded-xl p-4 space-y-4">
<h3 className="font-medium text-[#1a1a1a]">Aggiungi voci</h3>
{!showCustom ? (
<form
action={async (fd: FormData) => {
"use server";
await addQuoteItem(clientId, fd);
}}
className="flex items-end gap-3"
>
<div className="flex-1 space-y-1">
<Label htmlFor="service">Seleziona dal catalogo</Label>
<select
name="service_id"
id="service"
className="w-full border border-[#e5e7eb] rounded px-3 py-2 text-sm bg-white"
>
<option value=""> Scegli servizio </option>
{activeServices.map(s => (
<option key={s.id} value={s.id}>
{s.name}
</option>
))}
</select>
</div>
<div className="space-y-1">
<Label htmlFor="qty">Qty</Label>
<Input
id="qty"
name="quantity"
type="number"
step="0.01"
min="0.01"
defaultValue="1"
className="w-20"
/>
</div>
<Button type="submit" size="sm">Aggiungi</Button>
<button
type="button"
onClick={() => setShowCustom(true)}
className="text-xs text-[#71717a] hover:text-[#1a1a1a]"
>
Voce libera
</button>
</form>
) : (
<form
action={async (fd: FormData) => {
"use server";
await addQuoteItem(clientId, fd);
}}
className="space-y-3"
>
<input type="hidden" name="service_id" value="" />
<div className="space-y-1">
<Label htmlFor="label">Nome voce</Label>
<Input
id="label"
name="custom_label"
placeholder="es. Consulenza premium"
required
/>
</div>
<div className="flex gap-3">
<div className="flex-1 space-y-1">
<Label htmlFor="price">Prezzo unitario</Label>
<Input
id="price"
name="unit_price"
type="number"
step="0.01"
min="0.01"
required
/>
</div>
<div className="space-y-1">
<Label htmlFor="qty2">Qty</Label>
<Input
id="qty2"
name="quantity"
type="number"
step="0.01"
min="0.01"
defaultValue="1"
className="w-20"
/>
</div>
</div>
<div className="flex gap-2">
<Button type="submit" size="sm">Aggiungi</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setShowCustom(false)}
>
Torna al catalogo
</Button>
</div>
</form>
)}
</div>
{/* Quote items table */}
<div className="bg-white border border-[#e5e7eb] rounded-xl p-4">
{items.length === 0 ? (
<p className="text-sm text-[#71717a]">Nessuna voce aggiunta. Seleziona dal catalogo per iniziare.</p>
) : (
<>
<table className="w-full text-sm">
<thead>
<tr className="border-b border-[#e5e7eb]">
<th className="text-left py-2 px-2">Voce</th>
<th className="text-right py-2 px-2">Qty</th>
<th className="text-right py-2 px-2">Prezzo unit.</th>
<th className="text-right py-2 px-2">Subtotale</th>
<th className="py-2 px-2"></th>
</tr>
</thead>
<tbody>
{items.map(item => (
<tr key={item.id} className="border-b border-[#e5e7eb] hover:bg-[#f9f9f9]">
<td className="py-2 px-2 text-[#1a1a1a]">
{item.custom_label || item.serviceName}
</td>
<td className="py-2 px-2 text-right">{item.quantity}</td>
<td className="py-2 px-2 text-right font-mono">
{parseFloat(item.unit_price).toFixed(2)}
</td>
<td className="py-2 px-2 text-right font-mono font-medium">
{parseFloat(item.subtotal).toFixed(2)}
</td>
<td className="py-2 px-2 text-right">
<form
action={async (fd: FormData) => {
"use server";
await removeQuoteItem(item.id, clientId);
}}
>
<button
type="submit"
className="text-xs text-[#71717a] hover:text-red-600"
>
Rimuovi
</button>
</form>
</td>
</tr>
))}
</tbody>
</table>
<div className="mt-4 pt-4 border-t border-[#e5e7eb] flex justify-end">
<p className="font-bold text-[#1a1a1a]">
Totale calcolato: {total.toFixed(2)}
</p>
</div>
</>
)}
</div>
{/* Accepted total */}
<div className="bg-white border border-[#e5e7eb] rounded-xl p-4 space-y-3">
<h3 className="font-medium text-[#1a1a1a]">Totale accettato dal cliente</h3>
<form
action={async (fd: FormData) => {
"use server";
await updateAcceptedTotal(clientId, fd);
}}
className="flex items-end gap-3"
>
<div className="flex-1 space-y-1">
<Label htmlFor="accepted">Importo ()</Label>
<Input
id="accepted"
name="accepted_total"
type="number"
step="0.01"
min="0"
defaultValue={acceptedTotal}
/>
</div>
<Button type="submit" size="sm">Salva</Button>
</form>
<p className="text-xs text-[#71717a]">
Il cliente vede solo questo importo, non le singole voci.
</p>
</div>
</div>
);
}
```
[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)
@@ -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
@@ -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: `<Link href="/admin/catalog" ...>Catalogo</Link>` 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: `<TabsTrigger value="quote">Preventivo</TabsTrigger>`; line 86-93: `<TabsContent value="quote"><QuoteTab .../></TabsContent>` |
| 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)_