From db81829b85e9073f288cd0a8355f60de2ecdd803 Mon Sep 17 00:00:00 2001 From: Simone Cavalli Date: Sun, 17 May 2026 11:42:44 +0200 Subject: [PATCH 1/3] feat(03-03): Server Actions quote CRUD + extend getClientFullDetail - Create quote-actions.ts: addQuoteItem, removeQuoteItem, updateAcceptedTotal - All three actions guarded by requireAdmin() + Zod validation - Extend admin-queries.ts: QuoteItemWithLabel type, quoteItems + activeServices queries - quote_items NEVER exposed via client-facing routes (security constraint enforced) --- src/app/admin/clients/[id]/quote-actions.ts | 79 +++++++++++++++++++++ src/lib/admin-queries.ts | 40 +++++++++++ 2 files changed, 119 insertions(+) create mode 100644 src/app/admin/clients/[id]/quote-actions.ts diff --git a/src/app/admin/clients/[id]/quote-actions.ts b/src/app/admin/clients/[id]/quote-actions.ts new file mode 100644 index 0000000..f76391d --- /dev/null +++ b/src/app/admin/clients/[id]/quote-actions.ts @@ -0,0 +1,79 @@ +"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}`); +} diff --git a/src/lib/admin-queries.ts b/src/lib/admin-queries.ts index ab50a5c..085962f 100644 --- a/src/lib/admin-queries.ts +++ b/src/lib/admin-queries.ts @@ -9,6 +9,8 @@ import { documents, notes, time_entries, + quote_items, + service_catalog, } from "@/db/schema"; import { eq, inArray, asc, isNull, sql } from "drizzle-orm"; import type { @@ -20,6 +22,7 @@ import type { Document, Note, Comment, + ServiceCatalog, } from "@/db/schema"; export type ClientWithPayments = { @@ -108,6 +111,17 @@ export async function getClientById(id: string) { // ── ClientFullDetail — used by /admin/clients/[id] workspace ───────────────── +// quote_items NEVER exposed via client API — admin workspace query only +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; +}; + export type ClientFullDetail = { client: Client; phases: Array }>; @@ -115,6 +129,8 @@ export type ClientFullDetail = { documents: Document[]; notes: Note[]; comments: Comment[]; + quoteItems: QuoteItemWithLabel[]; + activeServices: ServiceCatalog[]; }; export async function getClientFullDetail(id: string): Promise { @@ -180,6 +196,28 @@ export async function getClientFullDetail(id: string): Promise`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)); + return { client, phases: phasesWithTasks, @@ -187,5 +225,7 @@ export async function getClientFullDetail(id: string): Promise Date: Sun, 17 May 2026 11:44:57 +0200 Subject: [PATCH 2/3] feat(03-03): QuoteTab component + Preventivo tab in client detail page - Create QuoteTab.tsx: catalog dropdown + freeform toggle + items table + accepted total editor - Wire QuoteTab as 5th tab (Preventivo) in /admin/clients/[id]/page.tsx - Destructure quoteItems + activeServices from getClientFullDetail result - TypeScript clean, build passes --- src/app/admin/clients/[id]/page.tsx | 12 +- src/components/admin/tabs/QuoteTab.tsx | 333 +++++++++++++++++++++++++ 2 files changed, 344 insertions(+), 1 deletion(-) create mode 100644 src/components/admin/tabs/QuoteTab.tsx diff --git a/src/app/admin/clients/[id]/page.tsx b/src/app/admin/clients/[id]/page.tsx index cc0d23e..32447ac 100644 --- a/src/app/admin/clients/[id]/page.tsx +++ b/src/app/admin/clients/[id]/page.tsx @@ -5,6 +5,7 @@ import { PhasesTab } from "@/components/admin/tabs/PhasesTab"; import { PaymentsTab } from "@/components/admin/tabs/PaymentsTab"; import { DocumentsTab } from "@/components/admin/tabs/DocumentsTab"; import { CommentsTab } from "@/components/admin/tabs/CommentsTab"; +import { QuoteTab } from "@/components/admin/tabs/QuoteTab"; import { PhasesViewToggle } from "@/components/admin/kanban/PhasesViewToggle"; import { ClientActions } from "@/components/admin/ClientActions"; import Link from "next/link"; @@ -20,7 +21,7 @@ export default async function ClientDetailPage({ const detail = await getClientFullDetail(id); if (!detail) notFound(); - const { client, phases, payments, documents, comments } = detail; + const { client, phases, payments, documents, comments, quoteItems, activeServices } = detail; return (
@@ -59,6 +60,7 @@ export default async function ClientDetailPage({ Pagamenti Documenti Commenti + Preventivo @@ -81,6 +83,14 @@ export default async function ClientDetailPage({ + + +
); diff --git a/src/components/admin/tabs/QuoteTab.tsx b/src/components/admin/tabs/QuoteTab.tsx new file mode 100644 index 0000000..2674e1a --- /dev/null +++ b/src/components/admin/tabs/QuoteTab.tsx @@ -0,0 +1,333 @@ +"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. +

+
+ +
+ ); +} From 8641253e85b54c192cab888cee192aa0ba04d0aa Mon Sep 17 00:00:00 2001 From: Simone Cavalli Date: Sun, 17 May 2026 11:46:11 +0200 Subject: [PATCH 3/3] docs(03-03): complete quote builder plan summary - SUMMARY.md for plan 03-03 (2/2 tasks complete) - Covers quote-actions.ts, QuoteTab.tsx, admin-queries extension, page.tsx wiring - Security verification: requireAdmin on all actions, 0 quote_items in client-view --- .../03-03-SUMMARY.md | 111 ++++++++++++++++++ 1 file changed, 111 insertions(+) create mode 100644 .planning/phases/03-service-catalog-quote-builder/03-03-SUMMARY.md diff --git a/.planning/phases/03-service-catalog-quote-builder/03-03-SUMMARY.md b/.planning/phases/03-service-catalog-quote-builder/03-03-SUMMARY.md new file mode 100644 index 0000000..c28af33 --- /dev/null +++ b/.planning/phases/03-service-catalog-quote-builder/03-03-SUMMARY.md @@ -0,0 +1,111 @@ +--- +phase: "03" +plan: "03" +subsystem: "quote-builder" +tags: [quote, admin, server-actions, drizzle, security] +dependency_graph: + requires: ["03-01"] + provides: ["quote-tab-ui", "quote-actions", "admin-quote-queries"] + affects: ["src/lib/admin-queries.ts", "src/app/admin/clients/[id]/page.tsx"] +tech_stack: + added: [] + patterns: ["Server Actions with requireAdmin guard", "useTransition for optimistic UI", "COALESCE SQL for label resolution", "leftJoin for optional catalog ref"] +key_files: + created: + - src/app/admin/clients/[id]/quote-actions.ts + - src/components/admin/tabs/QuoteTab.tsx + modified: + - src/lib/admin-queries.ts + - src/app/admin/clients/[id]/page.tsx +decisions: + - "QuoteTab is a Client Component (useTransition + useRouter) — actions called via startTransition, router.refresh() for revalidation" + - "updateAcceptedTotal in quote-actions.ts is separate from the one in actions.ts — scoped to quote tab, adds requireAdmin guard" + - "Service price pre-filled in catalog mode but editable — allows overriding price at quote time (snapshot semantics)" +metrics: + duration: "~15 min" + completed_date: "2026-05-17T09:45:11Z" + tasks_completed: 2 + tasks_total: 2 + files_created: 2 + files_modified: 2 +--- + +# Phase 03 Plan 03: Quote Builder Tab Summary + +**One-liner:** Admin quote builder tab with catalog dropdown, freeform toggle, items table with calculated total, and accepted_total editor — all backed by Zod-validated Server Actions with requireAdmin guard. + +## Tasks Completed + +| Task | Name | Commit | Files | +|------|------|--------|-------| +| 1 | Server Actions + extend getClientFullDetail | db81829 | quote-actions.ts, admin-queries.ts | +| 2 | QuoteTab component + wire into client detail page | 48f81e7 | QuoteTab.tsx, page.tsx | + +## What Was Built + +**Task 1 — Server Actions + Query Layer** + +- `src/app/admin/clients/[id]/quote-actions.ts`: Three Server Actions exported: + - `addQuoteItem(clientId, formData)` — Zod validates service_id/custom_label, quantity, unit_price; computes subtotal; inserts into `quote_items` + - `removeQuoteItem(quoteItemId, clientId)` — deletes by item ID + - `updateAcceptedTotal(clientId, formData)` — writes to `clients.accepted_total` only (no payment row splitting — that stays in `actions.ts`) + - All three call `requireAdmin()` (getServerSession check) before any DB operation +- `src/lib/admin-queries.ts`: + - Added `QuoteItemWithLabel` type (COALESCE resolved label, snapshotted unit_price) + - Extended `ClientFullDetail` with `quoteItems: QuoteItemWithLabel[]` and `activeServices: ServiceCatalog[]` + - Added two queries in `getClientFullDetail()`: leftJoin for quote items with COALESCE label; active services ordered by name + - Security comment enforces that `client-view.ts` must never query `quote_items` (verified: 0 functional references) + +**Task 2 — UI Component + Page Wiring** + +- `src/components/admin/tabs/QuoteTab.tsx` (`"use client"`) — three sections: + - **Add items**: catalog dropdown (pre-fills unit_price on selection, editable) + freeform toggle (custom_label + price + qty) + - **Items table**: label, qty, unit_price, subtotal columns; "Rimuovi" button per row; "Totale calcolato" in bold footer + - **Accepted total**: editable numeric input with "Salva" button + helper text clarifying client sees only this value +- `src/app/admin/clients/[id]/page.tsx`: + - Import `QuoteTab` + - Destructure `quoteItems`, `activeServices` from `getClientFullDetail` result + - Added 5th `TabsTrigger value="quote"` with label "Preventivo" + - Added 5th `TabsContent value="quote"` rendering `` + +## Security Verification + +| Constraint | Status | +|------------|--------| +| T-03-03-01: requireAdmin on all Server Actions | Done — all three actions call `await requireAdmin()` first | +| T-03-03-02: Zod validation on formData numbers | Done — `quoteItemSchema` validates quantity + unit_price as `z.coerce.number().min(0.01)` | +| T-03-03-03: quote_items not in client-facing routes | Done — client-view.ts has 0 functional references to quote_items (only comments) | +| T-03-03-04: IDOR on removeQuoteItem | Mitigated by requireAdmin; future multi-admin scenario noted for future hardening | +| T-03-03-05: XSS in custom_label | Accepted — React JSX auto-escapes, no dangerouslySetInnerHTML used | +| T-03-03-06: calculated_total vs accepted_total confusion | Accepted — visual design enforces separation | + +## Deviations from Plan + +**1. [Rule 3 - Blocking] Build required DATABASE_URL env var not present in worktree** +- **Found during:** Task 2 build verification +- **Issue:** Worktree has no `.env.local`; build fails with "DATABASE_URL env var is required" at runtime collection phase +- **Fix:** Ran build with `DATABASE_URL=$(grep DATABASE_URL /path/.env.local ...)` from main repo — build passed clean +- **Impact:** None on code quality; worktree environment limitation only + +**2. [Rule 1 - Architecture] updateAcceptedTotal in quote-actions.ts does NOT update payment rows** +- **Found during:** Task 1 implementation +- **Rationale:** The existing `updateAcceptedTotal` in `actions.ts` splits the total 50/50 between payment rows. The quote tab version intentionally only writes to `clients.accepted_total` — this is the quote builder's domain. Payment row updates remain in the payments tab action. This preserves clean separation of concerns. + +## Known Stubs + +None — all three sections are fully wired to real Server Actions and real DB queries. + +## Threat Flags + +None — all new surface is admin-only, guarded by `requireAdmin()`, and consistent with the plan's threat model. + +## Self-Check: PASSED + +- `src/app/admin/clients/[id]/quote-actions.ts` exists and exports 3 Server Actions +- `src/components/admin/tabs/QuoteTab.tsx` exists and exports QuoteTab +- `src/lib/admin-queries.ts` modified with QuoteItemWithLabel type + quoteItems/activeServices in return +- `src/app/admin/clients/[id]/page.tsx` modified with Preventivo tab +- Commits db81829 and 48f81e7 verified in git log +- TypeScript: no errors +- Build: passes (with DATABASE_URL) +- client-view.ts: 0 functional references to quote_items