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

...

{ "use server"; await updateAcceptedTotal(clientId, fd); }}> ...
); } ``` // 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 (
{/* Section 1: Add items */}

Aggiungi voci

{!showCustom ? ( /* Catalog mode */
setSelectedServicePrice(e.target.value)} placeholder="0.00" required />
{addError &&

{addError}

}
) : ( /* Freeform mode */
{addError &&

{addError}

}
)}
{/* Section 2: Quote items table + calculated total */}

Voci preventivo

{items.length === 0 ? (

Nessuna voce aggiunta. Seleziona dal catalogo per iniziare.

) : ( <>
{items.map((item) => ( ))}
Voce Qty Prezzo unit. Subtotale
{item.label} {parseFloat(item.quantity).toLocaleString("it-IT", { minimumFractionDigits: 2 })} €{parseFloat(item.unit_price).toLocaleString("it-IT", { minimumFractionDigits: 2 })} €{parseFloat(item.subtotal).toLocaleString("it-IT", { minimumFractionDigits: 2 })}

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 After completion, create `.planning/phases/03-service-catalog-quote-builder/03-03-SUMMARY.md`