Files
Simone Cavalli a4942d7684 docs(03): plan Phase 3 — Service Catalog & Quote Builder (4 plans, 2 waves)
Wave 1: schema push (service_id nullable + custom_label).
Wave 2 (parallel): catalog CRUD page + quote builder tab.
Wave 3: E2E human verification checkpoint.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 11:23:15 +02:00

31 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
phase plan type wave depends_on files_modified autonomous requirements must_haves
03 03 execute 2
03-01
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
true
CAT-02
ADMIN-03
truths artifacts key_links
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
path provides exports
src/app/admin/clients/[id]/quote-actions.ts Server Actions: addQuoteItem, removeQuoteItem, updateAcceptedTotal
addQuoteItem
removeQuoteItem
updateAcceptedTotal
path provides contains
src/components/admin/tabs/QuoteTab.tsx Quote builder UI — add items (catalog + freeform), items table, accepted total editor QuoteTab
path provides contains
src/app/admin/clients/[id]/page.tsx Client detail page with 5th Preventivo tab wired to QuoteTab Preventivo
path provides contains
src/lib/admin-queries.ts getClientFullDetail extended to include quoteItems and activeServices quoteItems
from to via pattern
src/components/admin/tabs/QuoteTab.tsx add-item form src/app/admin/clients/[id]/quote-actions.ts addQuoteItem form action (Server Action) addQuoteItem
from to via pattern
src/components/admin/tabs/QuoteTab.tsx remove button src/app/admin/clients/[id]/quote-actions.ts removeQuoteItem form action removeQuoteItem
from to via pattern
src/components/admin/tabs/QuoteTab.tsx accepted total form src/app/admin/clients/[id]/quote-actions.ts updateAcceptedTotal form action updateAcceptedTotal
from to via pattern
src/app/admin/clients/[id]/page.tsx src/lib/admin-queries.ts getClientFullDetail await getClientFullDetail(id) 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.

<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_context>

@/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: */} ```
export type ClientFullDetail = {
  client: Client;
  phases: Array<Phase & { tasks: Array<Task & { deliverables: Deliverable[] }> }>;
  payments: Payment[];
  documents: Document[];
  notes: Note[];
  comments: Comment[];
  // ADD:
  // quoteItems: QuoteItemWithLabel[];
  // activeServices: ServiceCatalog[];
};
// src/components/admin/tabs/PaymentsTab.tsx pattern
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">...</h3>
        <form action={async (fd) => { "use server"; await updateAcceptedTotal(clientId, fd); }}>
          ...
        </form>
      </div>
    </div>
  );
}

// quote_items NEVER exposed — security constraint from Phase 1 (CLAUDE.md) // Only clients.accepted_total is visible to client-facing routes

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<string>`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));
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,
});
export type ServiceCatalog = typeof service_catalog.$inferSelect;
// Fields: id: string, name: string, unit_price: string, active: boolean, description: string | null
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:
"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:

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;
};
  1. Add quoteItems: QuoteItemWithLabel[] and activeServices: ServiceCatalog[] to the ClientFullDetail type.

  2. Add two queries inside getClientFullDetail() before the return statement:

  // quote_items NEVER exposed via client API — admin workspace query only
  const quoteItemRows: QuoteItemWithLabel[] = await db
    .select({
      id: quote_items.id,
      label: sql<string>`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));
  1. 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:
"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<string | null>(null);
  const [totalError, setTotalError] = useState<string | null>(null);
  // For catalog mode: pre-fill unit_price when service is selected
  const [selectedServicePrice, setSelectedServicePrice] = useState<string>("");
  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 (
    <div className="space-y-6 max-w-2xl">

      {/* Section 1: Add items */}
      <div className="bg-white border border-[#e5e7eb] rounded-xl p-4 space-y-4">
        <h3 className="text-xs font-bold text-[#71717a] uppercase tracking-wider">Aggiungi voci</h3>

        {!showCustom ? (
          /* Catalog mode */
          <form action={handleAddItem} className="space-y-3">
            <input type="hidden" name="custom_label" value="" />
            <div className="flex items-end gap-3 flex-wrap">
              <div className="flex-1 min-w-[180px] space-y-1">
                <Label htmlFor="service_id">Seleziona dal catalogo</Label>
                <select
                  name="service_id"
                  id="service_id"
                  className="w-full border border-[#e5e7eb] rounded-md px-3 py-2 text-sm bg-white focus:outline-none focus:ring-2 focus:ring-[#1A463C]/30"
                  onChange={(e) => {
                    const svc = activeServices.find((s) => s.id === e.target.value);
                    setSelectedServicePrice(svc ? parseFloat(svc.unit_price).toFixed(2) : "");
                  }}
                  required
                >
                  <option value=""> Scegli servizio </option>
                  {activeServices.map((s) => (
                    <option key={s.id} value={s.id}>
                      {s.name} ({parseFloat(s.unit_price).toLocaleString("it-IT", { minimumFractionDigits: 2 })})
                    </option>
                  ))}
                </select>
              </div>
              <div className="w-28 space-y-1">
                <Label htmlFor="unit_price_catalog">Prezzo unit.</Label>
                <Input
                  id="unit_price_catalog"
                  name="unit_price"
                  type="number"
                  step="0.01"
                  min="0.01"
                  value={selectedServicePrice}
                  onChange={(e) => setSelectedServicePrice(e.target.value)}
                  placeholder="0.00"
                  required
                />
              </div>
              <div className="w-20 space-y-1">
                <Label htmlFor="quantity_catalog">Qty</Label>
                <Input
                  id="quantity_catalog"
                  name="quantity"
                  type="number"
                  step="0.01"
                  min="0.01"
                  defaultValue="1"
                  required
                />
              </div>
              <Button type="submit" size="sm" className="bg-[#1A463C] text-white hover:bg-[#1A463C]/90">
                Aggiungi
              </Button>
            </div>
            <button
              type="button"
              onClick={() => { setShowCustom(true); setAddError(null); }}
              className="text-xs text-[#71717a] hover:text-[#1a1a1a] underline"
            >
              Oppure aggiungi voce libera 
            </button>
            {addError && <p className="text-xs text-red-600">{addError}</p>}
          </form>
        ) : (
          /* Freeform mode */
          <form action={handleAddItem} className="space-y-3">
            <input type="hidden" name="service_id" value="" />
            <div className="space-y-1">
              <Label htmlFor="custom_label">Nome voce</Label>
              <Input
                id="custom_label"
                name="custom_label"
                placeholder="es. Consulenza extra, Spese viaggi"
                required
              />
            </div>
            <div className="flex gap-3 flex-wrap">
              <div className="flex-1 min-w-[120px] space-y-1">
                <Label htmlFor="unit_price_custom">Prezzo unitario ()</Label>
                <Input
                  id="unit_price_custom"
                  name="unit_price"
                  type="number"
                  step="0.01"
                  min="0.01"
                  placeholder="0.00"
                  required
                />
              </div>
              <div className="w-20 space-y-1">
                <Label htmlFor="quantity_custom">Qty</Label>
                <Input
                  id="quantity_custom"
                  name="quantity"
                  type="number"
                  step="0.01"
                  min="0.01"
                  defaultValue="1"
                  required
                />
              </div>
            </div>
            {addError && <p className="text-xs text-red-600">{addError}</p>}
            <div className="flex gap-2">
              <Button type="submit" size="sm" className="bg-[#1A463C] text-white hover:bg-[#1A463C]/90">
                Aggiungi voce libera
              </Button>
              <Button
                type="button"
                variant="ghost"
                size="sm"
                onClick={() => { setShowCustom(false); setAddError(null); }}
              >
                Torna al catalogo
              </Button>
            </div>
          </form>
        )}
      </div>

      {/* Section 2: Quote items table + calculated total */}
      <div className="bg-white border border-[#e5e7eb] rounded-xl p-4">
        <h3 className="text-xs font-bold text-[#71717a] uppercase tracking-wider mb-3">Voci preventivo</h3>
        {items.length === 0 ? (
          <p className="text-sm text-[#71717a] py-4 text-center">
            Nessuna voce aggiunta. Seleziona dal catalogo per iniziare.
          </p>
        ) : (
          <>
            <div className="overflow-x-auto">
              <table className="w-full text-sm">
                <thead className="border-b border-[#e5e7eb]">
                  <tr>
                    <th className="text-left py-2 px-2 font-medium text-[#71717a]">Voce</th>
                    <th className="text-right py-2 px-2 font-medium text-[#71717a]">Qty</th>
                    <th className="text-right py-2 px-2 font-medium text-[#71717a]">Prezzo unit.</th>
                    <th className="text-right py-2 px-2 font-medium text-[#71717a]">Subtotale</th>
                    <th className="py-2 px-2"></th>
                  </tr>
                </thead>
                <tbody>
                  {items.map((item) => (
                    <tr key={item.id} className="border-b border-[#f4f4f5] hover:bg-[#f9f9f9]">
                      <td className="py-2 px-2 text-[#1a1a1a]">{item.label}</td>
                      <td className="py-2 px-2 text-right tabular-nums">{parseFloat(item.quantity).toLocaleString("it-IT", { minimumFractionDigits: 2 })}</td>
                      <td className="py-2 px-2 text-right tabular-nums font-mono">
                        {parseFloat(item.unit_price).toLocaleString("it-IT", { minimumFractionDigits: 2 })}
                      </td>
                      <td className="py-2 px-2 text-right tabular-nums font-mono font-medium">
                        {parseFloat(item.subtotal).toLocaleString("it-IT", { minimumFractionDigits: 2 })}
                      </td>
                      <td className="py-2 px-2 text-right">
                        <button
                          type="button"
                          onClick={() => handleRemove(item.id)}
                          className="text-xs text-[#71717a] hover:text-red-600 transition-colors"
                        >
                          Rimuovi
                        </button>
                      </td>
                    </tr>
                  ))}
                </tbody>
              </table>
            </div>
            <div className="mt-3 pt-3 border-t border-[#e5e7eb] flex justify-end">
              <p className="font-bold text-[#1a1a1a] tabular-nums">
                Totale calcolato: {calculatedTotal.toLocaleString("it-IT", { minimumFractionDigits: 2 })}
              </p>
            </div>
          </>
        )}
      </div>

      {/* Section 3: Accepted total */}
      <div className="bg-white border border-[#e5e7eb] rounded-xl p-4 space-y-3">
        <h3 className="text-xs font-bold text-[#71717a] uppercase tracking-wider">Totale accettato dal cliente</h3>
        <form action={handleSaveTotal} className="flex items-end gap-3">
          <div className="flex-1 max-w-[200px] space-y-1">
            <Label htmlFor="accepted_total">Importo ()</Label>
            <Input
              id="accepted_total"
              name="accepted_total"
              type="number"
              step="0.01"
              min="0"
              defaultValue={parseFloat(acceptedTotal).toFixed(2)}
            />
          </div>
          <Button type="submit" size="sm" className="bg-[#1A463C] text-white hover:bg-[#1A463C]/90">
            Salva
          </Button>
        </form>
        {totalError && <p className="text-xs text-red-600">{totalError}</p>}
        <p className="text-xs text-[#71717a]">
          Il cliente vede solo questo importo, non le singole voci del preventivo.
        </p>
      </div>

    </div>
  );
}

Modify src/app/admin/clients/[id]/page.tsx — add QuoteTab as 5th tab:

  1. Add import at top:
import { QuoteTab } from "@/components/admin/tabs/QuoteTab";
  1. Update destructure from getClientFullDetail:
const { client, phases, payments, documents, comments, quoteItems, activeServices } = detail;
  1. Add 5th TabsTrigger after "Commenti":
<TabsTrigger value="quote">Preventivo</TabsTrigger>
  1. Add 5th TabsContent after the comments TabsContent:
<TabsContent value="quote">
  <QuoteTab
    clientId={client.id}
    items={quoteItems}
    activeServices={activeServices}
    acceptedTotal={client.accepted_total ?? "0"}
  />
</TabsContent>
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.

<threat_model>

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"
</threat_model>
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

<success_criteria>

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