+ );
+}
+```
+
+**Modify `src/components/admin/NavBar.tsx`** — add Catalogo link after the Statistiche link:
+
+```typescript
+
+ Catalogo
+
+```
+
+Insert this line immediately after the existing `Statistiche` line.
+
+
+ cd /Users/simonecavalli/IAMCAVALLI && grep -c '/admin/catalog' src/components/admin/NavBar.tsx
+ Expected: 1
+ cd /Users/simonecavalli/IAMCAVALLI && grep -c 'export function ServiceTable' src/components/admin/catalog/ServiceTable.tsx
+ Expected: 1
+ cd /Users/simonecavalli/IAMCAVALLI && grep -c 'export function ServiceForm' src/components/admin/catalog/ServiceForm.tsx
+ Expected: 1
+ cd /Users/simonecavalli/IAMCAVALLI && grep -c 'getAllServices' src/app/admin/catalog/page.tsx
+ Expected: 1
+ cd /Users/simonecavalli/IAMCAVALLI && npx tsc --noEmit 2>&1 | head -20
+ Expected: no output (zero errors)
+ cd /Users/simonecavalli/IAMCAVALLI && npm run build 2>&1 | tail -10
+ Expected: "Compiled successfully" or "Route (app)" output with no errors
+
+
+ NavBar shows "Catalogo" link. `/admin/catalog` page renders. ServiceTable and ServiceForm compile. Full `npm run build` passes. Admin can navigate to `/admin/catalog` and see the table.
+
+
+
+
+
+
+## Trust Boundaries
+
+| Boundary | Description |
+|----------|-------------|
+| Admin browser → Server Actions (catalog/actions.ts) | FormData from admin form crosses to server; must be validated before DB write |
+| /admin/catalog route → Auth.js session | All catalog routes inherit the `/admin/*` middleware session check from Phase 2; no additional guard needed at page level |
+
+## STRIDE Threat Register
+
+| Threat ID | Category | Component | Disposition | Mitigation Plan |
+|-----------|----------|-----------|-------------|-----------------|
+| T-03-02-01 | Spoofing | createService / updateService / toggleServiceActive | mitigate | `requireAdmin()` calls `getServerSession(authOptions)` at the top of every Server Action — rejects if no valid session |
+| T-03-02-02 | Tampering | serviceSchema Zod validation | mitigate | `unit_price` validated as `z.coerce.number().min(0.01)` — prevents zero/negative prices; `name` requires min length 1 |
+| T-03-02-03 | Tampering | updateService serviceId parameter | mitigate | serviceId is bound at call site in the Server Action closure — admin can only modify the row ID passed from the server-rendered page |
+| T-03-02-04 | Information Disclosure | /admin/catalog page | accept | Page is behind Auth.js `/admin/*` middleware (enforced in Phase 2); service prices are admin-internal data, not client-facing |
+| T-03-02-05 | Tampering | XSS in service name / description | accept | React JSX auto-escapes all string output; no `dangerouslySetInnerHTML` used; UI-SPEC forbids it |
+
+
+
+After both tasks complete:
+1. `grep '/admin/catalog' src/components/admin/NavBar.tsx` returns 1 match
+2. `npx tsc --noEmit` exits clean
+3. `npm run build` succeeds
+4. Navigating to `/admin/catalog` (dev server) shows the catalog page with table headers and "Aggiungi servizio" button
+5. Adding a service via the form makes it appear in the table
+6. Clicking "Disattiva" changes badge to "Disattivato" and reduces row opacity
+
+
+
+- `/admin/catalog` route is accessible from NavBar and renders without error
+- All three Server Actions (createService, updateService, toggleServiceActive) are exported from `catalog/actions.ts` with Zod validation and `requireAdmin()` guard
+- ServiceTable renders per-row inline edit using the DocumentRow pattern
+- Inactive services show "Disattivato" badge; active services show "Attivo" badge
+- TypeScript and build both pass clean
+
+
+
\ No newline at end of file
diff --git a/.planning/phases/03-service-catalog-quote-builder/03-03-PLAN.md b/.planning/phases/03-service-catalog-quote-builder/03-03-PLAN.md
new file mode 100644
index 0000000..53ab9ff
--- /dev/null
+++ b/.planning/phases/03-service-catalog-quote-builder/03-03-PLAN.md
@@ -0,0 +1,725 @@
+---
+phase: "03"
+plan: "03"
+type: execute
+wave: 2
+depends_on:
+ - "03-01"
+files_modified:
+ - src/app/admin/clients/[id]/quote-actions.ts
+ - src/components/admin/tabs/QuoteTab.tsx
+ - src/app/admin/clients/[id]/page.tsx
+ - src/lib/admin-queries.ts
+autonomous: true
+requirements:
+ - CAT-02
+ - ADMIN-03
+
+must_haves:
+ truths:
+ - "Admin can see a 'Preventivo' tab in /admin/clients/[id] — the 5th tab after Commenti"
+ - "Admin can select an active catalog service from a dropdown and add it (with qty) to the quote — the item appears in the table with snapshotted unit_price"
+ - "Admin can toggle to 'Voce libera' mode and add a custom label + price + qty item (service_id = null in DB)"
+ - "Admin can click 'Rimuovi' to delete a quote item — it disappears from the table"
+ - "The table footer shows 'Totale calcolato' as the sum of all subtotals"
+ - "Admin can set a separate 'Totale accettato dal cliente' via an editable input + Salva button — this writes to clients.accepted_total"
+ - "quote_items are NEVER returned by any client-facing route — only clients.accepted_total is visible to clients"
+ artifacts:
+ - path: "src/app/admin/clients/[id]/quote-actions.ts"
+ provides: "Server Actions: addQuoteItem, removeQuoteItem, updateAcceptedTotal"
+ exports: ["addQuoteItem", "removeQuoteItem", "updateAcceptedTotal"]
+ - path: "src/components/admin/tabs/QuoteTab.tsx"
+ provides: "Quote builder UI — add items (catalog + freeform), items table, accepted total editor"
+ contains: "QuoteTab"
+ - path: "src/app/admin/clients/[id]/page.tsx"
+ provides: "Client detail page with 5th Preventivo tab wired to QuoteTab"
+ contains: "Preventivo"
+ - path: "src/lib/admin-queries.ts"
+ provides: "getClientFullDetail extended to include quoteItems and activeServices"
+ contains: "quoteItems"
+ key_links:
+ - from: "src/components/admin/tabs/QuoteTab.tsx add-item form"
+ to: "src/app/admin/clients/[id]/quote-actions.ts addQuoteItem"
+ via: "form action (Server Action)"
+ pattern: "addQuoteItem"
+ - from: "src/components/admin/tabs/QuoteTab.tsx remove button"
+ to: "src/app/admin/clients/[id]/quote-actions.ts removeQuoteItem"
+ via: "form action"
+ pattern: "removeQuoteItem"
+ - from: "src/components/admin/tabs/QuoteTab.tsx accepted total form"
+ to: "src/app/admin/clients/[id]/quote-actions.ts updateAcceptedTotal"
+ via: "form action"
+ pattern: "updateAcceptedTotal"
+ - from: "src/app/admin/clients/[id]/page.tsx"
+ to: "src/lib/admin-queries.ts getClientFullDetail"
+ via: "await getClientFullDetail(id)"
+ pattern: "getClientFullDetail"
+---
+
+
+Deliver the "Preventivo" tab in the admin client detail page. This is the quote builder vertical slice: Server Actions for quote item CRUD + accepted_total write, the QuoteTab component (catalog dropdown + freeform toggle + items table + accepted total editor), and the wiring of both into the existing client detail page.
+
+Purpose: Fulfills CAT-02 (catalog as quote generation base) and ADMIN-03 (full quote detail visible to admin only). The client sees only `clients.accepted_total` — this constraint is enforced at the query layer.
+Output: 4 new/modified files — a fully operational quote builder tab.
+
+
+
+@$HOME/.claude/get-shit-done/workflows/execute-plan.md
+@$HOME/.claude/get-shit-done/templates/summary.md
+
+
+
+@/Users/simonecavalli/IAMCAVALLI/.planning/ROADMAP.md
+@/Users/simonecavalli/IAMCAVALLI/.planning/phases/03-service-catalog-quote-builder/03-01-SUMMARY.md
+
+
+
+```typescript
+// src/app/admin/clients/[id]/page.tsx (current)
+
+
+ Fasi & Task
+ Pagamenti
+ Documenti
+ Commenti
+ {/* ADD: Preventivo */}
+
+ {/* ADD: */}
+
+```
+
+
+```typescript
+export type ClientFullDetail = {
+ client: Client;
+ phases: Array }>;
+ payments: Payment[];
+ documents: Document[];
+ notes: Note[];
+ comments: Comment[];
+ // ADD:
+ // quoteItems: QuoteItemWithLabel[];
+ // activeServices: ServiceCatalog[];
+};
+```
+
+
+```typescript
+// src/components/admin/tabs/PaymentsTab.tsx pattern
+export async function PaymentsTab({ payments, acceptedTotal, clientId }: Props) {
+ return (
+
+
+
...
+
+
+
+ );
+}
+```
+
+
+// quote_items NEVER exposed — security constraint from Phase 1 (CLAUDE.md)
+// Only clients.accepted_total is visible to client-facing routes
+
+
+```typescript
+import { sql, eq, asc } from "drizzle-orm";
+import { quote_items, service_catalog, clients } from "@/db/schema";
+
+// Get quote items for a client — service name from catalog OR custom_label
+const items = await db
+ .select({
+ id: quote_items.id,
+ label: sql`COALESCE(${service_catalog.name}, ${quote_items.custom_label})`,
+ custom_label: quote_items.custom_label,
+ service_id: quote_items.service_id,
+ quantity: quote_items.quantity,
+ unit_price: quote_items.unit_price, // snapshotted — NEVER use service_catalog.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))
+ .orderBy(asc(quote_items.id));
+```
+
+
+```typescript
+const qty = parseFloat(formData.get("quantity") as string);
+const price = parseFloat(formData.get("unit_price") as string);
+const subtotal = (qty * price).toFixed(2);
+// Insert: unit_price stored as string with 2dp (matches numeric(10,2) column)
+await db.insert(quote_items).values({
+ client_id: clientId,
+ service_id: serviceId ?? null, // null for freeform items
+ custom_label: customLabel ?? null,
+ quantity: qty.toFixed(2),
+ unit_price: price.toFixed(2),
+ subtotal,
+});
+```
+
+
+```typescript
+export type ServiceCatalog = typeof service_catalog.$inferSelect;
+// Fields: id: string, name: string, unit_price: string, active: boolean, description: string | null
+```
+
+
+```typescript
+export type QuoteItem = typeof quote_items.$inferSelect;
+// Fields: id, client_id, service_id: string | null, custom_label: string | null,
+// quantity, unit_price, subtotal (all numeric as string)
+```
+
+
+
+
+
+
+ Task 1: quote-actions.ts Server Actions + extend getClientFullDetail
+
+ - /Users/simonecavalli/IAMCAVALLI/src/app/admin/clients/[id]/actions.ts (pattern: Zod, requireAdmin, revalidatePath)
+ - /Users/simonecavalli/IAMCAVALLI/src/lib/admin-queries.ts (current getClientFullDetail to extend — add quoteItems and activeServices)
+ - /Users/simonecavalli/IAMCAVALLI/src/db/schema.ts (confirm custom_label and nullable service_id from 03-01)
+ - /Users/simonecavalli/IAMCAVALLI/src/lib/client-view.ts (VERIFY this file does NOT query quote_items — if it does, remove that query)
+
+
+ src/app/admin/clients/[id]/quote-actions.ts
+ src/lib/admin-queries.ts
+
+
+**Create `src/app/admin/clients/[id]/quote-actions.ts`** — three Server Actions:
+
+```typescript
+"use server";
+
+// quote_items NEVER exposed — security constraint from Phase 1 (CLAUDE.md)
+// Only clients.accepted_total is visible to client-facing routes
+
+import { db } from "@/db";
+import { quote_items, clients, service_catalog } from "@/db/schema";
+import { revalidatePath } from "next/cache";
+import { eq } from "drizzle-orm";
+import { z } from "zod";
+import { getServerSession } from "next-auth";
+import { authOptions } from "@/lib/auth";
+
+async function requireAdmin() {
+ const session = await getServerSession(authOptions);
+ if (!session) throw new Error("Non autorizzato");
+}
+
+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) {
+ await requireAdmin();
+
+ const rawServiceId = formData.get("service_id") as string | null;
+ const rawCustomLabel = formData.get("custom_label") as string | null;
+
+ const parsed = quoteItemSchema.safeParse({
+ service_id: rawServiceId && rawServiceId !== "" ? rawServiceId : null,
+ custom_label: rawCustomLabel && rawCustomLabel !== "" ? rawCustomLabel : null,
+ quantity: formData.get("quantity"),
+ unit_price: formData.get("unit_price"),
+ });
+
+ if (!parsed.success) throw new Error(parsed.error.issues[0].message);
+
+ // Validate: either service_id or custom_label must be present
+ if (!parsed.data.service_id && !parsed.data.custom_label) {
+ throw new Error("Seleziona un servizio dal catalogo o inserisci il nome di una voce libera");
+ }
+
+ const { service_id, custom_label, quantity, unit_price } = parsed.data;
+ const subtotal = (quantity * unit_price).toFixed(2);
+
+ await db.insert(quote_items).values({
+ client_id: clientId,
+ service_id: service_id ?? null,
+ custom_label: custom_label ?? null,
+ quantity: quantity.toFixed(2),
+ unit_price: unit_price.toFixed(2),
+ subtotal,
+ });
+
+ revalidatePath(`/admin/clients/${clientId}`);
+}
+
+export async function removeQuoteItem(quoteItemId: string, clientId: string) {
+ await requireAdmin();
+ await db.delete(quote_items).where(eq(quote_items.id, quoteItemId));
+ revalidatePath(`/admin/clients/${clientId}`);
+}
+
+export async function updateAcceptedTotal(clientId: string, formData: FormData) {
+ await requireAdmin();
+ 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}`);
+}
+```
+
+**Extend `src/lib/admin-queries.ts`** — add `QuoteItemWithLabel` type and extend `ClientFullDetail` + `getClientFullDetail`:
+
+1. Add imports at top: `quote_items`, `service_catalog` from `@/db/schema`; `sql` from `drizzle-orm`; `ServiceCatalog` from `@/db/schema`.
+
+2. Add new type before `ClientFullDetail`:
+```typescript
+export type QuoteItemWithLabel = {
+ id: string;
+ label: string; // COALESCE(service_catalog.name, quote_items.custom_label)
+ custom_label: string | null;
+ service_id: string | null;
+ quantity: string;
+ unit_price: string; // snapshotted — never joined back to service_catalog.unit_price
+ subtotal: string;
+};
+```
+
+3. Add `quoteItems: QuoteItemWithLabel[]` and `activeServices: ServiceCatalog[]` to the `ClientFullDetail` type.
+
+4. Add two queries inside `getClientFullDetail()` before the `return` statement:
+
+```typescript
+ // quote_items NEVER exposed via client API — admin workspace query only
+ const quoteItemRows: QuoteItemWithLabel[] = await db
+ .select({
+ id: quote_items.id,
+ label: sql`COALESCE(${service_catalog.name}, ${quote_items.custom_label})`,
+ custom_label: quote_items.custom_label,
+ service_id: quote_items.service_id,
+ 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, id))
+ .orderBy(asc(quote_items.id));
+
+ const activeServiceRows = await db
+ .select()
+ .from(service_catalog)
+ .where(eq(service_catalog.active, true))
+ .orderBy(asc(service_catalog.name));
+```
+
+5. Add `quoteItems: quoteItemRows` and `activeServices: activeServiceRows` to the return object.
+
+IMPORTANT: Also read `src/lib/client-view.ts` to verify it does NOT query `quote_items`. If it does, remove that query entirely — `accepted_total` is the only field the client sees.
+
+
+ cd /Users/simonecavalli/IAMCAVALLI && grep -c 'export async function addQuoteItem' src/app/admin/clients/\[id\]/quote-actions.ts
+ Expected: 1
+ cd /Users/simonecavalli/IAMCAVALLI && grep -c 'export async function removeQuoteItem' src/app/admin/clients/\[id\]/quote-actions.ts
+ Expected: 1
+ cd /Users/simonecavalli/IAMCAVALLI && grep -c 'export async function updateAcceptedTotal' src/app/admin/clients/\[id\]/quote-actions.ts
+ Expected: 1
+ cd /Users/simonecavalli/IAMCAVALLI && grep -c 'quoteItems' src/lib/admin-queries.ts
+ Expected: 3 or more (type definition, query, return)
+ cd /Users/simonecavalli/IAMCAVALLI && grep -c 'quote_items' src/lib/client-view.ts 2>/dev/null || echo 0
+ Expected: 0 (quote_items must NOT appear in client-view.ts)
+ cd /Users/simonecavalli/IAMCAVALLI && npx tsc --noEmit 2>&1 | head -20
+ Expected: no output (zero errors)
+
+
+ Three Server Actions exported with `requireAdmin()` guard and Zod validation. `getClientFullDetail` returns `quoteItems` and `activeServices`. `client-view.ts` contains zero references to `quote_items`. TypeScript compiles clean.
+
+
+
+
+ Task 2: QuoteTab component + wire into client detail page
+
+ - /Users/simonecavalli/IAMCAVALLI/src/components/admin/tabs/PaymentsTab.tsx (exact analog structure to follow)
+ - /Users/simonecavalli/IAMCAVALLI/src/app/admin/clients/[id]/page.tsx (current tab structure to extend)
+ - /Users/simonecavalli/IAMCAVALLI/src/app/admin/clients/[id]/quote-actions.ts (actions from Task 1)
+ - /Users/simonecavalli/IAMCAVALLI/src/lib/admin-queries.ts (updated ClientFullDetail type from Task 1)
+
+
+ src/components/admin/tabs/QuoteTab.tsx
+ src/app/admin/clients/[id]/page.tsx
+
+
+**Create `src/components/admin/tabs/QuoteTab.tsx`** — "use client" component with three form sections:
+
+```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 { Label } from "@/components/ui/label";
+import { addQuoteItem, removeQuoteItem, updateAcceptedTotal } from "@/app/admin/clients/[id]/quote-actions";
+import type { QuoteItemWithLabel } from "@/lib/admin-queries";
+import type { ServiceCatalog } from "@/db/schema";
+
+type Props = {
+ clientId: string;
+ items: QuoteItemWithLabel[];
+ activeServices: ServiceCatalog[];
+ acceptedTotal: string;
+};
+
+export function QuoteTab({ clientId, items, activeServices, acceptedTotal }: Props) {
+ const [showCustom, setShowCustom] = useState(false);
+ const [addError, setAddError] = useState(null);
+ const [totalError, setTotalError] = useState(null);
+ // For catalog mode: pre-fill unit_price when service is selected
+ const [selectedServicePrice, setSelectedServicePrice] = useState("");
+ const [, startTransition] = useTransition();
+ const router = useRouter();
+
+ const calculatedTotal = items.reduce((sum, item) => sum + parseFloat(item.subtotal), 0);
+
+ function handleAddItem(fd: FormData) {
+ setAddError(null);
+ startTransition(async () => {
+ try {
+ await addQuoteItem(clientId, fd);
+ router.refresh();
+ } catch (e) {
+ setAddError(e instanceof Error ? e.message : "Errore nell'aggiunta");
+ }
+ });
+ }
+
+ function handleRemove(quoteItemId: string) {
+ startTransition(async () => {
+ await removeQuoteItem(quoteItemId, clientId);
+ router.refresh();
+ });
+ }
+
+ function handleSaveTotal(fd: FormData) {
+ setTotalError(null);
+ startTransition(async () => {
+ try {
+ await updateAcceptedTotal(clientId, fd);
+ router.refresh();
+ } catch (e) {
+ setTotalError(e instanceof Error ? e.message : "Errore nel salvataggio");
+ }
+ });
+ }
+
+ return (
+
+ Totale calcolato: €{calculatedTotal.toLocaleString("it-IT", { minimumFractionDigits: 2 })}
+
+
+ >
+ )}
+
+
+ {/* Section 3: Accepted total */}
+
+
Totale accettato dal cliente
+
+ {totalError &&
{totalError}
}
+
+ Il cliente vede solo questo importo, non le singole voci del preventivo.
+
+
+
+
+ );
+}
+```
+
+**Modify `src/app/admin/clients/[id]/page.tsx`** — add QuoteTab as 5th tab:
+
+1. Add import at top:
+```typescript
+import { QuoteTab } from "@/components/admin/tabs/QuoteTab";
+```
+
+2. Update destructure from `getClientFullDetail`:
+```typescript
+const { client, phases, payments, documents, comments, quoteItems, activeServices } = detail;
+```
+
+3. Add 5th TabsTrigger after "Commenti":
+```typescript
+Preventivo
+```
+
+4. Add 5th TabsContent after the comments TabsContent:
+```typescript
+
+
+
+```
+
+
+ cd /Users/simonecavalli/IAMCAVALLI && grep -c 'export function QuoteTab' src/components/admin/tabs/QuoteTab.tsx
+ Expected: 1
+ cd /Users/simonecavalli/IAMCAVALLI && grep -c 'Preventivo' src/app/admin/clients/\[id\]/page.tsx
+ Expected: 2 (TabsTrigger text + TabsContent value)
+ cd /Users/simonecavalli/IAMCAVALLI && grep -c 'quoteItems' src/app/admin/clients/\[id\]/page.tsx
+ Expected: 1 (destructured from detail)
+ cd /Users/simonecavalli/IAMCAVALLI && npx tsc --noEmit 2>&1 | head -20
+ Expected: no output (zero errors)
+ cd /Users/simonecavalli/IAMCAVALLI && npm run build 2>&1 | tail -10
+ Expected: build succeeds with no errors
+
+
+ QuoteTab component renders with three sections. "Preventivo" tab appears in client detail page. TypeScript and build both pass clean.
+
+
+
+
+
+
+## Trust Boundaries
+
+| Boundary | Description |
+|----------|-------------|
+| Admin browser → quote-actions.ts Server Actions | FormData (clientId, service_id, unit_price, quantity) crosses to server — must be validated before DB write |
+| getClientFullDetail → /admin/clients/[id]/page.tsx | quoteItems and activeServices returned ONLY to admin page — never to client-facing routes |
+| client-view.ts / client API routes | Must NOT include quote_items in any query result — enforced at query layer |
+
+## STRIDE Threat Register
+
+| Threat ID | Category | Component | Disposition | Mitigation Plan |
+|-----------|----------|-----------|-------------|-----------------|
+| T-03-03-01 | Spoofing | addQuoteItem / removeQuoteItem / updateAcceptedTotal | mitigate | `requireAdmin()` calls `getServerSession(authOptions)` at top of every Server Action — rejects unauthenticated requests |
+| T-03-03-02 | Tampering | addQuoteItem formData (unit_price, quantity) | mitigate | Zod `quoteItemSchema` validates both as `z.coerce.number().min(0.01)` — prevents zero/negative values or non-numeric injection |
+| T-03-03-03 | Information Disclosure | quote_items exposed via client-facing route | mitigate | `getClientFullDetail` query adds quoteItems ONLY to admin return type; `client-view.ts` and all `/api/client/*` routes must never query `quote_items`; verified via grep gate in Task 1 verify |
+| T-03-03-04 | Tampering | IDOR — removeQuoteItem with foreign clientId | mitigate | removeQuoteItem deletes by `quoteItemId` only — the admin must be authenticated (requireAdmin). Phase scope has single admin; if multi-admin added in future, add `AND client_id = clientId` to delete WHERE clause |
+| T-03-03-05 | Tampering | XSS in custom_label field | accept | React JSX auto-escapes; custom_label rendered via `{item.label}` — no dangerouslySetInnerHTML; UI-SPEC prohibits it |
+| T-03-03-06 | Tampering | Confusing calculated_total vs accepted_total | accept | Visual design enforces separation: calculated total is read-only bold text; accepted_total is distinct editable input with Save button and helper text "Il cliente vede solo questo importo" |
+
+
+
+After both tasks complete:
+1. `grep -c 'quote_items' src/lib/client-view.ts` returns 0
+2. `npx tsc --noEmit` exits clean
+3. `npm run build` succeeds
+4. Client detail page at `/admin/clients/[id]` shows "Preventivo" as 5th tab
+5. Adding a catalog item: item appears in table with snapshotted unit_price (not pulled from service_catalog)
+6. Adding a freeform item: row appears with custom_label, service_id is null in DB
+7. Clicking "Salva" on accepted_total updates `clients.accepted_total` — visible in PaymentsTab "Totale preventivo" field
+
+
+
+- `src/app/admin/clients/[id]/quote-actions.ts` exports three Server Actions with requireAdmin + Zod guards
+- `getClientFullDetail` returns `quoteItems: QuoteItemWithLabel[]` and `activeServices: ServiceCatalog[]`
+- QuoteTab renders all three sections: add items (catalog + freeform toggle), items table with calculated total, accepted total editor
+- `client-view.ts` contains zero references to `quote_items`
+- TypeScript and build both pass clean
+
+
+
\ No newline at end of file
diff --git a/.planning/phases/03-service-catalog-quote-builder/03-04-PLAN.md b/.planning/phases/03-service-catalog-quote-builder/03-04-PLAN.md
new file mode 100644
index 0000000..eebf70d
--- /dev/null
+++ b/.planning/phases/03-service-catalog-quote-builder/03-04-PLAN.md
@@ -0,0 +1,216 @@
+---
+phase: "03"
+plan: "04"
+type: execute
+wave: 3
+depends_on:
+ - "03-02"
+ - "03-03"
+files_modified: []
+autonomous: false
+requirements:
+ - CAT-01
+ - CAT-02
+ - ADMIN-03
+
+must_haves:
+ truths:
+ - "Admin navigates to /admin/catalog — table shows all services with correct columns and status badges"
+ - "Admin adds a service, edits it inline, and disattiva/riattiva it — all changes persist on page refresh"
+ - "Admin opens a client's Preventivo tab — adds a catalog item and a freeform item — both appear in the table with correct subtotals and calculated total"
+ - "Admin saves an accepted_total — the client dashboard shows that exact amount, not the calculated sum"
+ - "A curl request to the client API returns NO quote_items field and NO service_id references"
+ artifacts:
+ - path: "src/app/admin/catalog/page.tsx"
+ provides: "Verified: catalog page loads and renders table"
+ - path: "src/components/admin/tabs/QuoteTab.tsx"
+ provides: "Verified: three sections render correctly, catalog and freeform items work"
+ - path: "src/lib/client-view.ts"
+ provides: "Verified: zero quote_items references"
+ key_links:
+ - from: "clients.accepted_total (DB)"
+ to: "client dashboard display"
+ via: "client-view.ts query → /c/[token] page"
+ pattern: "accepted_total"
+---
+
+
+End-to-end verification of Phase 3. The admin runs the full workflow — create catalog service, add to quote, set accepted_total — and confirms the client dashboard shows the correct total. Also verifies the security constraint: `quote_items` are never returned by the client API.
+
+Purpose: Confirms Phase 3 is shippable. Catches any integration issue between catalog, quote builder, and client dashboard before the phase is marked complete.
+Output: Human verification sign-off + SUMMARY.md.
+
+
+
+@$HOME/.claude/get-shit-done/workflows/execute-plan.md
+@$HOME/.claude/get-shit-done/templates/summary.md
+
+
+
+@/Users/simonecavalli/IAMCAVALLI/.planning/ROADMAP.md
+@/Users/simonecavalli/IAMCAVALLI/.planning/phases/03-service-catalog-quote-builder/03-02-SUMMARY.md
+@/Users/simonecavalli/IAMCAVALLI/.planning/phases/03-service-catalog-quote-builder/03-03-SUMMARY.md
+
+
+
+
+
+ Task 1: Automated security and integration checks
+
+ - /Users/simonecavalli/IAMCAVALLI/src/lib/client-view.ts (must contain zero quote_items references)
+ - /Users/simonecavalli/IAMCAVALLI/src/app/api (check all client-facing API route files for quote_items leaks)
+
+
+
+Run the following automated checks in sequence. Report results for each.
+
+**Check 1 — TypeScript compiles clean:**
+```bash
+cd /Users/simonecavalli/IAMCAVALLI && npx tsc --noEmit
+```
+Expected: zero output (no errors).
+
+**Check 2 — Build succeeds:**
+```bash
+cd /Users/simonecavalli/IAMCAVALLI && npm run build
+```
+Expected: "Compiled successfully" with routes listed. No error lines.
+
+**Check 3 — Security: quote_items not in client-facing code:**
+```bash
+cd /Users/simonecavalli/IAMCAVALLI && grep -rn 'quote_items' src/lib/client-view.ts src/app/api/ src/app/c/ 2>/dev/null || echo "CLEAN"
+```
+Expected: "CLEAN" or no output. If any match appears, that file must be fixed before the checkpoint.
+
+**Check 4 — Service catalog page references getAllServices:**
+```bash
+cd /Users/simonecavalli/IAMCAVALLI && grep -c 'getAllServices' src/app/admin/catalog/page.tsx
+```
+Expected: 1
+
+**Check 5 — NavBar contains Catalogo link:**
+```bash
+cd /Users/simonecavalli/IAMCAVALLI && grep -c '/admin/catalog' src/components/admin/NavBar.tsx
+```
+Expected: 1
+
+**Check 6 — Client detail page has Preventivo tab:**
+```bash
+cd /Users/simonecavalli/IAMCAVALLI && grep -c 'Preventivo' src/app/admin/clients/\[id\]/page.tsx
+```
+Expected: 2
+
+**Check 7 — quote-actions has requireAdmin in all three actions:**
+```bash
+cd /Users/simonecavalli/IAMCAVALLI && grep -c 'requireAdmin' src/app/admin/clients/\[id\]/quote-actions.ts
+```
+Expected: 3 (one per action)
+
+**Check 8 — accepted_total security check (client view does NOT expose quote detail):**
+```bash
+cd /Users/simonecavalli/IAMCAVALLI && grep 'accepted_total\|quote_items\|service_id' src/lib/client-view.ts
+```
+Expected: `accepted_total` appears (it's the field clients see), `quote_items` does NOT appear, `service_id` does NOT appear.
+
+If all 8 checks pass, proceed to the human verification checkpoint.
+If any check fails, fix the issue before proceeding.
+
+
+ cd /Users/simonecavalli/IAMCAVALLI && npx tsc --noEmit && npm run build 2>&1 | tail -5
+ Expected: build output ends with route list — no "Failed to compile" line.
+ cd /Users/simonecavalli/IAMCAVALLI && grep -rn 'quote_items' src/lib/client-view.ts src/app/c/ 2>/dev/null | wc -l | tr -d ' '
+ Expected: 0
+
+
+ All 8 automated checks pass. TypeScript clean, build succeeds, quote_items absent from client-facing code.
+
+
+
+
+ Task 2: Human end-to-end verification of Phase 3
+
+ Service Catalog CRUD at /admin/catalog, Quote Builder tab in client detail, accepted_total round-trip to client dashboard.
+
+
+ Start the dev server: `npm run dev` (port 3000).
+
+ **Test A — Catalog page:**
+ 1. Open http://localhost:3000/admin/catalog
+ 2. Confirm the page loads with "Catalogo Servizi" heading and "Aggiungi servizio" button
+ 3. Click "Aggiungi servizio" — fill in Nome: "Test Servizio", Prezzo: "500" — click Aggiungi
+ 4. Confirm "Test Servizio" appears in the table with "Attivo" badge and €500,00 price
+ 5. Click "Modifica" on the row — change price to "750" — click Salva
+ 6. Confirm price updates to €750,00 without page reload
+ 7. Click "Disattiva" — confirm badge changes to "Disattivato" and row becomes dimmed (50% opacity)
+ 8. Click "Riattiva" — confirm badge returns to "Attivo"
+
+ **Test B — NavBar:**
+ 1. Confirm "Catalogo" link appears in the admin NavBar between "Statistiche" and "Esci"
+ 2. Click it — confirm it navigates to /admin/catalog
+
+ **Test C — Quote Builder tab:**
+ 1. Open any existing client at http://localhost:3000/admin/clients/[id]
+ 2. Confirm "Preventivo" tab appears as 5th tab (after Commenti)
+ 3. Click the Preventivo tab
+ 4. Select "Test Servizio" from the dropdown (if inactive, reactivate first) — set qty 1 — click Aggiungi
+ 5. Confirm item appears in the table with correct unit price and subtotal
+ 6. Click "Oppure aggiungi voce libera →" — enter Nome: "Extra consulenza", Prezzo: "200", Qty: 2 — click Aggiungi voce libera
+ 7. Confirm second item appears with "Extra consulenza" label, subtotal €400,00
+ 8. Confirm "Totale calcolato" shows the sum (e.g., €1.150,00 if service was €750)
+ 9. Click "Rimuovi" on one item — confirm it disappears
+
+ **Test D — Accepted total round-trip (critical):**
+ 1. In the Preventivo tab, set "Totale accettato dal cliente" to 1200 — click Salva
+ 2. Open the client dashboard at http://localhost:3000/c/[client-token] in a new tab
+ 3. Confirm the dashboard shows "€1.200,00" (or equivalent) as the accepted total
+ 4. Back in admin, open the Pagamenti tab — confirm "Totale preventivo" input shows 1200
+
+ **Test E — Security check (quote_items never exposed):**
+ 1. In the browser DevTools (Network tab), open the client dashboard /c/[token]
+ 2. Find any API calls made by that page — inspect their response bodies
+ 3. Confirm NO response contains "quote_items", "service_id" (from quote context), or individual line item prices
+ 4. Alternative: run `curl http://localhost:3000/api/client/[client-id-or-token]` if a client API route exists — confirm response has only `accepted_total`, not quote item details
+
+
+ Type "approved" if all 5 tests pass. Or describe any failures (e.g., "Test C step 5 fails — items not appearing") so they can be fixed.
+
+
+
+
+
+
+## Trust Boundaries
+
+| Boundary | Description |
+|----------|-------------|
+| Client browser → /c/[token] route | Client sees only what the route explicitly returns — verified here that quote_items are absent |
+
+## STRIDE Threat Register
+
+| Threat ID | Category | Component | Disposition | Mitigation Plan |
+|-----------|----------|-----------|-------------|-----------------|
+| T-03-04-01 | Information Disclosure | Client dashboard API response | mitigate | Check 3 + Test E verify that no quote_items appear in any client-facing response; if found, fix before approving |
+| T-03-04-02 | Tampering | Phase 3 shipped without DB push | mitigate | 03-01 is a hard dependency of this wave; if drizzle-kit push was skipped, custom_label column absent causes runtime crash caught in Test C |
+
+
+
+Phase 3 complete when:
+1. All 8 automated checks in Task 1 pass
+2. Human verifies Tests A–E in Task 2
+3. Client dashboard shows correct `accepted_total` after update (Test D)
+4. Zero `quote_items` in any client-facing response (Test E)
+
+
+
+- Service catalog is fully operational: add, edit, disable, re-enable services
+- Quote builder adds catalog items (with snapshotted price) and freeform items (service_id = null)
+- accepted_total write in admin is reflected in client dashboard
+- Phase 3 roadmap success criteria 1–3 are all TRUE:
+ 1. Admin can add/edit/disable catalog services
+ 2. Admin can compose a quote from catalog; system calculates total
+ 3. After saving accepted_total, client dashboard shows correct total; quote_items never exposed
+
+
+
\ No newline at end of file