# Phase 3: Service Catalog & Quote Builder — Research

**Researched:** 2026-05-17
**Domain:** Admin service catalog management, quote builder UI, server actions, database schema migration
**Confidence:** HIGH

## Summary

Phase 3 builds the admin service catalog and quote builder—two tightly integrated features that allow the admin to manage reusable service line items and compose client-specific quotes. The service catalog is a simple admin-only CRUD table (add, edit, soft-delete via `active` flag); the quote builder is a new admin tab that lets the admin mix catalog items and freeform entries, calculate totals, and commit an `accepted_total` to the client row (which the client dashboard displays).

The core architectural decision is that **quote_items are never exposed to the client API** — only the denormalized `clients.accepted_total` field is visible to clients. This constraint is already enforced in Phase 1 design and persists through Phase 3.

**Key findings:**
1. Database schema is 95% complete — only two fields need to be added to `quote_items`: make `service_id` nullable and add `custom_label` text field (for freeform items).
2. Component patterns are stable and reusable: existing tab system (PaymentsTab, DocumentsTab) provides the exact UI structure to follow.
3. Server Actions pattern is established in `actions.ts` — quote CRUD will follow the same async form handling + Zod validation pattern.
4. No external libraries or complex state management needed — plain React forms + Server Actions suffice.
5. One navigation change required: add "Catalogo" link to NavBar.

**Primary recommendation:** Implement as two distinct features with clear separation of concerns: (1) `/admin/catalog` page with catalog CRUD; (2) new "Preventivo" tab in existing client detail page. Both use the same Server Actions pattern and share no client-side state.

## User Constraints (from CONTEXT.md)

### Locked Decisions

1. **Service Catalog — Location: /admin/catalog**
   - Dedicated page with NavBar link (Clienti | Statistiche | Catalogo)
   - Table with columns: Nome, Descrizione, Prezzo unitario, Stato (Attivo/Disattivato)
   - Full CRUD: add, inline edit, disable/enable (soft delete via `active = false`)
   - Inactive items remain visible in list (toggle filter) but not in quote selectors

2. **Quote Builder — Location: Tab "Preventivo" in /admin/clients/[id]**
   - New 5th tab in client detail page (after Documenti)
   - Shows quote items with calculated total
   - Admin can add items from catalog (dropdown + qty) OR freeform items (label + price + qty)
   - No locking after finalization — items always editable
   - Schema change: `service_id` becomes nullable, add `custom_label` text field

3. **Accepted Total — Admin-controlled, not auto-calculated**
   - Builder shows calculated sum as reference
   - Separate editable field "Totale accettato dal cliente" with Save button
   - Admin can set any value (commercial round number may differ from analytical sum)
   - Finalization writes only `accepted_total`; no automatic payment update

4. **Security Constraint (immutable from Phase 1)**
   - `quote_items` are admin-only — NEVER exposed by client-facing API routes
   - `clients.accepted_total` is the only price visible to clients

### Claude's Discretion

None — all major decisions are locked from the discuss phase.

### Deferred Ideas (OUT OF SCOPE)

- Phase 4: Claude AI onboarding with assisted quote generation
- Future: Payment auto-sync when quote is finalized
- Future: Quote versioning / history tracking

## Phase Requirements

| ID | Description | Research Support |
|----|-------------|------------------|
| CAT-01 | File/database dei servizi con prezzi e cosa è incluso | Schema complete; CRUD on `service_catalog` table with name, description, unit_price, active fields |
| CAT-02 | Usato come base per la generazione assistita dei preventivi | Quote builder queries active catalog items via dropdown; items are snapshotted at add time (unit_price stored in quote_items) |
| ADMIN-03 | Preventivo completo con dettaglio servizi (non visibile al cliente) | Quote builder UI + Server Actions in `quote-actions.ts` + API constraint enforced at route layer to prevent quote_items exposure |

## Architectural Responsibility Map

| Capability | Primary Tier | Secondary Tier | Rationale |
|------------|-------------|----------------|-----------|
| Service catalog CRUD | API / Backend (Server Actions) | Database | Admin form submissions trigger Server Actions; Drizzle handles persistence |
| Catalog visibility/filtering | API / Backend (query) | Frontend (display) | Active filter logic lives in query layer; UI just renders results |
| Quote item management | API / Backend (Server Actions) | Frontend (form) | Add/remove/update quote items via Server Actions; client-side form for UX only |
| Quote total calculation | Frontend (display) | — | Pure calculation in component (no state needed); accepted_total write is Server Action |
| Client API security (quote_items never exposed) | API / Backend (route guard) | — | Route handlers explicitly exclude quote_items from responses; enforced at query level |

## Standard Stack

### Core
| Library | Version | Purpose | Why Standard |
|---------|---------|---------|--------------|
| Next.js | 16.2.6 | App Router, Server Actions | Established in Phase 1; Server Actions reduce client-side complexity |
| Drizzle ORM | 0.45.2 | Query builder, migrations | Already in use; `drizzle-kit push` for schema migrations |
| Postgres (Neon) | Via postgres npm | Serverless DB | Existing connection, no changes |
| React | 19.2.4 | Client component library | Existing; hooks pattern already established |
| Tailwind v4 | ^4 | Styling | Brand system (#1A463C, #DEF168) already in place |
| shadcn/ui | Via npm | Form inputs, buttons, tabs, label | Radix UI primitives + Tailwind styling; consistent with existing admin UI |
| Zod | ^4.4.3 | Form validation | Already in use in Phase 2 Server Actions |

### Supporting
| Library | Version | Purpose | When to Use |
|---------|---------|---------|-------------|
| React Hook Form | ^7.75.0 | Form state (client-side) | Optional — existing PaymentsTab uses plain form without RHF; follow that pattern for consistency |
| nanoid | ^5.1.11 | ID generation | Already used; catalog and quote items get nanoid PKs |

### Alternatives Considered

| Instead of | Could Use | Tradeoff |
|------------|-----------|----------|
| Server Actions for CRUD | API route handlers | Server Actions reduce boilerplate; form serialization is automatic |
| Inline edit (existing pattern) | Modal dialog | UI spec explicitly says "prefer inline editing" — matches existing admin style |
| Drizzle schema push | Migrations framework | Drizzle-kit is simpler for this schema scope; no need for Prisma/Liquibase |

**Installation/Verification:** All dependencies are already in package.json. No new packages needed for Phase 3.

```bash
# Verify Drizzle and schema tooling
npm list drizzle-orm drizzle-kit
# Output should show: drizzle-orm@0.45.2, drizzle-kit@0.31.10

# Verify schema migration command works
npx drizzle-kit push
# Will prompt for database URL — must be set in .env.local before push
```

## Architecture Patterns

### System Architecture Diagram

```
Admin /admin/catalog (Service Catalog Page)
  ↓
  NavBar → Link to /admin/catalog
  ↓
  ServiceTable (Server Component)
    ↓ queries service_catalog table (all rows)
    ↓ render in read mode
    ↓ inline edit: expand row → editable inputs → Server Action
    ↓ disable/enable: toggle button → Server Action
    ↓
  ServiceForm (Client Component inside ServiceTable)
    ↓ add row at top OR modal
    ↓ submit → Server Action → revalidatePath

Admin /admin/clients/[id] (Client Detail Page)
  ↓
  Tabs: Fasi | Pagamenti | Documenti | Commenti | Preventivo (NEW)
  ↓
  QuoteTab (Client Component — NEW)
    ├─ Section 1: Add items
    │   ├─ Dropdown: catalog items (active only, sorted by name)
    │   ├─ OR toggle: "Voce libera" → text input + price + qty
    │   ├─ Add button → Server Action → append to quote_items
    │   └─
    ├─ Section 2: Quote items table
    │   ├─ Columns: Voce | Qty | Unit Price | Subtotal | Delete button
    │   ├─ Delete button → Server Action → remove from quote_items
    │   └─ Footer: "Totale calcolato" (sum of subtotals)
    └─ Section 3: Accepted Total
        ├─ Label: "Totale accettato dal cliente"
        ├─ Editable EUR input (separate from calculated sum)
        ├─ Save button → Server Action → update clients.accepted_total
        └─ Helper text: "Il cliente vede solo questo importo"

Data Layer
  ↓ All writes via Server Actions in /admin/clients/[id]/quote-actions.ts
  ├─ addQuoteItem(clientId, serviceId | null, customLabel | null, qty, unitPrice)
  ├─ updateQuoteItem(quoteItemId, qty)
  ├─ removeQuoteItem(quoteItemId, clientId)
  ├─ updateAcceptedTotal(clientId, amount)
  ├─ createService(name, description, unitPrice)
  ├─ updateService(serviceId, name, description, unitPrice)
  └─ toggleServiceActive(serviceId, active)

Client API (immutable constraint)
  ↓ GET /api/client/[clientId]
  ├─ Returns: clients.{id, name, brand_name, brief, accepted_total, ...}
  └─ NEVER includes quote_items
```

### Recommended Project Structure

```
src/
├── app/admin/
│   ├── catalog/
│   │   ├── page.tsx                    # Service catalog page
│   │   └── actions.ts                  # createService, updateService, toggleServiceActive
│   ├── clients/[id]/
│   │   ├── page.tsx                    # Existing; add QuoteTab to Tabs
│   │   ├── actions.ts                  # Existing; no changes
│   │   └── quote-actions.ts            # NEW — addQuoteItem, removeQuoteItem, updateAcceptedTotal
│   └── ...
├── components/admin/
│   ├── tabs/
│   │   └── QuoteTab.tsx                # NEW — quote builder UI
│   ├── catalog/                        # NEW
│   │   ├── ServiceTable.tsx            # NEW — catalog table + inline edit
│   │   └── ServiceForm.tsx             # NEW — add service form
│   ├── NavBar.tsx                      # MODIFIED — add /admin/catalog link
│   └── ...
└── ...
```

### Pattern 1: Server Actions + Form Serialization

**What:** Server Actions receive FormData directly from forms; no JSON serialization overhead.

**When to use:** All admin CRUD operations (catalog, quote items, payments, documents).

**Example:**

```typescript
// actions.ts
"use server";
import { db } from "@/db";
import { service_catalog } from "@/db/schema";
import { z } from "zod";
import { revalidatePath } from "next/cache";

const serviceSchema = z.object({
  name: z.string().min(1, "Nome richiesto"),
  description: z.string().optional(),
  unit_price: z.coerce.number().positive("Prezzo deve essere positivo"),
});

export async function createService(formData: FormData) {
  const parsed = serviceSchema.safeParse({
    name: formData.get("name"),
    description: formData.get("description") ?? "",
    unit_price: formData.get("unit_price"),
  });
  if (!parsed.success) throw new Error(parsed.error.issues[0].message);
  
  await db.insert(service_catalog).values(parsed.data);
  revalidatePath("/admin/catalog");
}

// Component.tsx
<form
  action={async (fd: FormData) => {
    "use server";
    await createService(fd);
  }}
>
  <input name="name" required />
  <input name="unit_price" type="number" step="0.01" required />
  <button type="submit">Aggiungi</button>
</form>
```

[Source: Phase 2 established in actions.ts; Zod validation pattern from existing paymentStatus/updateAcceptedTotal]

### Pattern 2: Quote Item Snapshots

**What:** When adding a quote item from catalog, capture the current `unit_price` from the service row. If the service price changes later, existing quote items keep their snapshotted price.

**When to use:** Any time a catalog item is referenced in a transaction (quote, order, invoice).

**Example:**

```typescript
export async function addQuoteItem(
  clientId: string,
  serviceId: string | null,
  customLabel: string | null,
  quantity: number,
  unitPrice: number
) {
  const subtotal = quantity * unitPrice;
  
  await db.insert(quote_items).values({
    client_id: clientId,
    service_id: serviceId, // null if custom label
    custom_label: customLabel, // null if from catalog
    quantity,
    unit_price: unitPrice, // snapshot of price at time of quote
    subtotal,
  });
  
  revalidatePath(`/admin/clients/${clientId}`);
}
```

[Source: CONTEXT.md locked decision; Phase 1 schema design]

### Pattern 3: Nullable Foreign Key + Custom Label

**What:** `service_id` is nullable in `quote_items`. If null, use `custom_label` for the line item name. If not null, look up the service name from `service_catalog`.

**When to use:** Supporting both catalog items and freeform items in the same table.

**Example:**

```typescript
// Query side
const items = await db
  .select({
    id: quote_items.id,
    label: sql`COALESCE(${service_catalog.name}, ${quote_items.custom_label})`,
    quantity: quote_items.quantity,
    unit_price: quote_items.unit_price,
    subtotal: quote_items.subtotal,
  })
  .from(quote_items)
  .leftJoin(service_catalog, eq(quote_items.service_id, service_catalog.id))
  .where(eq(quote_items.client_id, clientId));

// UI side — QuoteTab component
{items.map(item => (
  <tr key={item.id}>
    <td>{item.label}</td>
    <td>{item.quantity}</td>
    <td>€{item.unit_price.toFixed(2)}</td>
    <td>€{item.subtotal.toFixed(2)}</td>
    <td><button onClick={() => removeQuoteItem(item.id)}>Rimuovi</button></td>
  </tr>
))}
```

[Source: CONTEXT.md § 3 (Voci Preventivo — Catalogo + Free-form)]

### Anti-Patterns to Avoid

- **Calculating accepted_total on the backend:** This is intentional — admin must be free to set any value (commercial rounding). Don't auto-sync from quote items sum.
- **Exposing quote_items in client API routes:** Even by accident. Add explicit `.select()` clauses that exclude quote_items; never do `SELECT *` on routes that touch clients.
- **Freezing quote items after finalization:** Spec says "sempre editabili" — no soft lock, no approval state. The quote is internal-only; client never sees it.
- **Storing display labels in quote_items.label field:** Use `service_id` FK when possible; only use `custom_label` for freeform items. This keeps the data model clean and auditable.

## Don't Hand-Roll

| Problem | Don't Build | Use Instead | Why |
|---------|-------------|-------------|-----|
| Nullable FK + custom value display | Custom display logic in component | Drizzle `leftJoin` + `COALESCE` in query | Single source of truth; query-level logic is easier to test and reuse |
| Price snapshots | Manual price tracking logic | Store `unit_price` in quote_items row at insert time | Immutable snapshot prevents accidental price sync bugs |
| Form validation | Custom validators in component | Zod schema in Server Action | Type-safe, reusable, server-side security |
| Catalog filtering (active items) | Client-side filter state | `.where(eq(service_catalog.active, true))` in query | Prevents exposing inactive items if query is accidentally exposed |

**Key insight:** The quote builder looks simple (add item, remove item, save total), but the detail is in the data model. A sloppy implementation exposes quote_items to the client API or breaks when prices change. The patterns above are proven in Phase 2 and directly applicable here.

## Runtime State Inventory

**Trigger:** Phase 3 does not involve rename, rebrand, refactor, or migration of existing strings.

**Status:** SKIPPED — This is a new feature phase (greenfield catalog + new tab). No runtime state needs to be discovered or migrated. The schema changes (nullable service_id, new custom_label field) are additive only.

## Common Pitfalls

### Pitfall 1: Accidentally Exposing quote_items to Client

**What goes wrong:** A developer adds a new client API route (e.g., `GET /api/client/[token]/quote`) without realizing the security constraint, or modifies `getClientFullDetail()` query to include quote_items "for completeness."

**Why it happens:** The constraint is documented in CLAUDE.md and Phase 1 decisions, but it's easy to forget when working on a new feature. The quote_items table exists in the schema; it's tempting to include it.

**How to avoid:** 
- Before any `.select()` on a client-facing route, explicitly list columns: `.select({ id: clients.id, name: clients.name, accepted_total: clients.accepted_total, ... })` — never `SELECT *`.
- Add a comment in the route handler: `// quote_items NEVER exposed — security constraint from Phase 1`.
- Test the client API with curl or Postman; verify the response does NOT contain quote_items or service_id references.

**Warning signs:** 
- `SELECT * FROM ...clients...` in any client-facing route.
- A PR review comment suggesting "but the client should see the quote breakdown."

### Pitfall 2: Confusing calculated_total vs. accepted_total

**What goes wrong:** The UI shows "Totale calcolato: €1,250" and "Totale accettato: €1,500", but the admin saves only the accepted total. Later, the admin forgets which one was finalized and manually overwrites the calculated total, breaking the audit trail.

**Why it happens:** Two fields look similar on the form. The calculated total is read-only (it's the sum), but nothing visually prevents someone from thinking "maybe I should update the calculation."

**How to avoid:**
- Make the calculated total visually distinct: gray background, read-only input, or bold text label ("Questo è calcolato; non modificare").
- The accepted_total input should have a clear Save button; the calculated total should have none.
- Add helper text: "Il totale calcolato è la somma delle voci. Il cliente vede solo il totale accettato."

**Warning signs:**
- A UI where the two fields look identical in styling.
- Missing explanation of why they are separate.

### Pitfall 3: Not Snapshotting Prices

**What goes wrong:** Admin adds a quote item with current catalog price €100. Two weeks later, the service is updated to €150. The quote_items row still shows €100 (good), but the admin forgets this and thinks the quote is stale.

**Why it happens:** If the code accidentally queries `service_catalog.unit_price` instead of the snapshotted `quote_items.unit_price` when rendering the quote, it will show the new price, not the quote price.

**How to avoid:**
- Always display `quote_items.unit_price` in the quote table — never join back to `service_catalog.unit_price`.
- Add a migration test: change a service price, reload the quote, verify the quote price hasn't changed.

**Warning signs:**
- Quote item price changing after the quote was created.
- Confusion in the admin about "which price is this?"

### Pitfall 4: Schema Migration Not Run

**What goes wrong:** Code is deployed with references to `quote_items.custom_label` or nullable `service_id`, but the database schema hasn't been pushed. The app crashes with column-not-found errors.

**Why it happens:** The developer forgets to run `drizzle-kit push` before deploying, or the DB connection is misconfigured (DATABASE_URL not set in production environment).

**How to avoid:**
- Add a pre-deployment checklist: (1) schema.ts updated, (2) `drizzle-kit push` run locally and output captured, (3) production DATABASE_URL verified in CI/CD secrets, (4) push output included in deploy notes.
- Include this step in PLAN.md: "Wave 0: Schema push (drizzle-kit push)".

**Warning signs:**
- Deploy succeeds, but admin page crashes with "column \"custom_label\" does not exist."
- Local dev works, production fails (classic local-vs-prod mismatch).

## Code Examples

### Example 1: Create Service (Server Action)

```typescript
// src/app/admin/catalog/actions.ts
"use server";

import { db } from "@/db";
import { service_catalog } from "@/db/schema";
import { revalidatePath } from "next/cache";
import { z } from "zod";

const serviceSchema = z.object({
  name: z.string().min(1, "Nome richiesto"),
  description: z.string().optional(),
  unit_price: z.coerce.number().min(0.01, "Prezzo deve essere maggiore di 0"),
});

export async function createService(formData: FormData) {
  const parsed = serviceSchema.safeParse({
    name: formData.get("name"),
    description: formData.get("description") ?? "",
    unit_price: formData.get("unit_price"),
  });
  
  if (!parsed.success) {
    throw new Error(parsed.error.issues[0].message);
  }

  await db.insert(service_catalog).values(parsed.data);
  revalidatePath("/admin/catalog");
}

export async function updateService(
  serviceId: string,
  formData: FormData
) {
  const parsed = serviceSchema.safeParse({
    name: formData.get("name"),
    description: formData.get("description") ?? "",
    unit_price: formData.get("unit_price"),
  });
  
  if (!parsed.success) {
    throw new Error(parsed.error.issues[0].message);
  }

  await db
    .update(service_catalog)
    .set(parsed.data)
    .where(eq(service_catalog.id, serviceId));
  
  revalidatePath("/admin/catalog");
}

export async function toggleServiceActive(
  serviceId: string,
  active: boolean
) {
  await db
    .update(service_catalog)
    .set({ active })
    .where(eq(service_catalog.id, serviceId));
  
  revalidatePath("/admin/catalog");
}
```

[Source: Phase 2 pattern established in `clients/[id]/actions.ts`; Zod validation matches `docSchema`, `clientSchema`]

### Example 2: Add Quote Item (Server Action)

```typescript
// src/app/admin/clients/[id]/quote-actions.ts
"use server";

import { db } from "@/db";
import { quote_items, service_catalog } from "@/db/schema";
import { revalidatePath } from "next/cache";
import { eq } from "drizzle-orm";
import { z } from "zod";

const quoteItemSchema = z.object({
  service_id: z.string().nullable(),
  custom_label: z.string().nullable(),
  quantity: z.coerce.number().min(0.01, "Quantità deve essere > 0"),
  unit_price: z.coerce.number().min(0.01, "Prezzo deve essere > 0"),
});

export async function addQuoteItem(clientId: string, formData: FormData) {
  const parsed = quoteItemSchema.safeParse({
    service_id: formData.get("service_id") || null,
    custom_label: formData.get("custom_label") || null,
    quantity: formData.get("quantity"),
    unit_price: formData.get("unit_price"),
  });

  if (!parsed.success) {
    throw new Error(parsed.error.issues[0].message);
  }

  const { service_id, custom_label, quantity, unit_price } = parsed.data;
  const subtotal = Number(quantity) * Number(unit_price);

  await db.insert(quote_items).values({
    client_id: clientId,
    service_id,
    custom_label,
    quantity: String(quantity),
    unit_price: String(unit_price),
    subtotal: String(subtotal),
  });

  revalidatePath(`/admin/clients/${clientId}`);
}

export async function removeQuoteItem(quoteItemId: string, clientId: string) {
  await db.delete(quote_items).where(eq(quote_items.id, quoteItemId));
  revalidatePath(`/admin/clients/${clientId}`);
}

export async function updateAcceptedTotal(
  clientId: string,
  formData: FormData
) {
  const raw = formData.get("accepted_total") as string;
  const val = parseFloat(raw);
  
  if (isNaN(val) || val < 0) {
    throw new Error("Importo non valido");
  }

  await db
    .update(clients)
    .set({ accepted_total: val.toFixed(2) })
    .where(eq(clients.id, clientId));

  revalidatePath(`/admin/clients/${clientId}`);
}
```

[Source: Phase 2 pattern from `clients/[id]/actions.ts`; numeric precision matches schema]

### Example 3: Quote Tab Component

```typescript
// src/components/admin/tabs/QuoteTab.tsx
"use client";

import { addQuoteItem, removeQuoteItem, updateAcceptedTotal } from "@/app/admin/clients/[id]/quote-actions";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import type { ServiceCatalog, QuoteItem } from "@/db/schema";
import { useState } from "react";

type Props = {
  clientId: string;
  items: Array<QuoteItem & { serviceName?: string }>;
  services: ServiceCatalog[];
  acceptedTotal: string;
};

export function QuoteTab({ clientId, items, services, acceptedTotal }: Props) {
  const [showCustom, setShowCustom] = useState(false);
  const activeServices = services.filter(s => s.active);
  const total = items.reduce((sum, item) => sum + parseFloat(item.subtotal), 0);

  return (
    <div className="space-y-6 max-w-2xl">
      {/* Add items section */}
      <div className="bg-white border border-[#e5e7eb] rounded-xl p-4 space-y-4">
        <h3 className="font-medium text-[#1a1a1a]">Aggiungi voci</h3>
        
        {!showCustom ? (
          <form
            action={async (fd: FormData) => {
              "use server";
              await addQuoteItem(clientId, fd);
            }}
            className="flex items-end gap-3"
          >
            <div className="flex-1 space-y-1">
              <Label htmlFor="service">Seleziona dal catalogo</Label>
              <select
                name="service_id"
                id="service"
                className="w-full border border-[#e5e7eb] rounded px-3 py-2 text-sm bg-white"
              >
                <option value="">— Scegli servizio —</option>
                {activeServices.map(s => (
                  <option key={s.id} value={s.id}>
                    {s.name}
                  </option>
                ))}
              </select>
            </div>
            <div className="space-y-1">
              <Label htmlFor="qty">Qty</Label>
              <Input
                id="qty"
                name="quantity"
                type="number"
                step="0.01"
                min="0.01"
                defaultValue="1"
                className="w-20"
              />
            </div>
            <Button type="submit" size="sm">Aggiungi</Button>
            <button
              type="button"
              onClick={() => setShowCustom(true)}
              className="text-xs text-[#71717a] hover:text-[#1a1a1a]"
            >
              Voce libera →
            </button>
          </form>
        ) : (
          <form
            action={async (fd: FormData) => {
              "use server";
              await addQuoteItem(clientId, fd);
            }}
            className="space-y-3"
          >
            <input type="hidden" name="service_id" value="" />
            <div className="space-y-1">
              <Label htmlFor="label">Nome voce</Label>
              <Input
                id="label"
                name="custom_label"
                placeholder="es. Consulenza premium"
                required
              />
            </div>
            <div className="flex gap-3">
              <div className="flex-1 space-y-1">
                <Label htmlFor="price">Prezzo unitario</Label>
                <Input
                  id="price"
                  name="unit_price"
                  type="number"
                  step="0.01"
                  min="0.01"
                  required
                />
              </div>
              <div className="space-y-1">
                <Label htmlFor="qty2">Qty</Label>
                <Input
                  id="qty2"
                  name="quantity"
                  type="number"
                  step="0.01"
                  min="0.01"
                  defaultValue="1"
                  className="w-20"
                />
              </div>
            </div>
            <div className="flex gap-2">
              <Button type="submit" size="sm">Aggiungi</Button>
              <Button
                type="button"
                variant="outline"
                size="sm"
                onClick={() => setShowCustom(false)}
              >
                Torna al catalogo
              </Button>
            </div>
          </form>
        )}
      </div>

      {/* Quote items table */}
      <div className="bg-white border border-[#e5e7eb] rounded-xl p-4">
        {items.length === 0 ? (
          <p className="text-sm text-[#71717a]">Nessuna voce aggiunta. Seleziona dal catalogo per iniziare.</p>
        ) : (
          <>
            <table className="w-full text-sm">
              <thead>
                <tr className="border-b border-[#e5e7eb]">
                  <th className="text-left py-2 px-2">Voce</th>
                  <th className="text-right py-2 px-2">Qty</th>
                  <th className="text-right py-2 px-2">Prezzo unit.</th>
                  <th className="text-right py-2 px-2">Subtotale</th>
                  <th className="py-2 px-2"></th>
                </tr>
              </thead>
              <tbody>
                {items.map(item => (
                  <tr key={item.id} className="border-b border-[#e5e7eb] hover:bg-[#f9f9f9]">
                    <td className="py-2 px-2 text-[#1a1a1a]">
                      {item.custom_label || item.serviceName}
                    </td>
                    <td className="py-2 px-2 text-right">{item.quantity}</td>
                    <td className="py-2 px-2 text-right font-mono">
                      €{parseFloat(item.unit_price).toFixed(2)}
                    </td>
                    <td className="py-2 px-2 text-right font-mono font-medium">
                      €{parseFloat(item.subtotal).toFixed(2)}
                    </td>
                    <td className="py-2 px-2 text-right">
                      <form
                        action={async (fd: FormData) => {
                          "use server";
                          await removeQuoteItem(item.id, clientId);
                        }}
                      >
                        <button
                          type="submit"
                          className="text-xs text-[#71717a] hover:text-red-600"
                        >
                          Rimuovi
                        </button>
                      </form>
                    </td>
                  </tr>
                ))}
              </tbody>
            </table>
            
            <div className="mt-4 pt-4 border-t border-[#e5e7eb] flex justify-end">
              <p className="font-bold text-[#1a1a1a]">
                Totale calcolato: €{total.toFixed(2)}
              </p>
            </div>
          </>
        )}
      </div>

      {/* Accepted total */}
      <div className="bg-white border border-[#e5e7eb] rounded-xl p-4 space-y-3">
        <h3 className="font-medium text-[#1a1a1a]">Totale accettato dal cliente</h3>
        <form
          action={async (fd: FormData) => {
            "use server";
            await updateAcceptedTotal(clientId, fd);
          }}
          className="flex items-end gap-3"
        >
          <div className="flex-1 space-y-1">
            <Label htmlFor="accepted">Importo (€)</Label>
            <Input
              id="accepted"
              name="accepted_total"
              type="number"
              step="0.01"
              min="0"
              defaultValue={acceptedTotal}
            />
          </div>
          <Button type="submit" size="sm">Salva</Button>
        </form>
        <p className="text-xs text-[#71717a]">
          Il cliente vede solo questo importo, non le singole voci.
        </p>
      </div>
    </div>
  );
}
```

[Source: Component structure mirrors PaymentsTab and DocumentsTab from Phase 2; inline forms follow same pattern]

## State of the Art

| Old Approach | Current Approach | When Changed | Impact |
|--------------|------------------|--------------|--------|
| Separate catalog feature added in Phase 4+ | Catalog in Phase 3 (before Claude AI) | Discuss phase (May 16) | Allows Phase 3 to deliver full quote builder; Phase 4 Claude flows become faster with catalog as foundation |
| Locking quote after finalization | Always-editable quote | Discuss phase decision | Simpler implementation; quotes are internal-only (client never sees them), so no approval workflow needed |
| Auto-syncing accepted_total to payment rows | Manual payment management | Phase 2 design | Admin controls both quote total and payment splits independently; more flexible for commercial negotiations |

**Deprecated/outdated:** None in this phase. This is new feature work with no legacy patterns to replace.

## Assumptions Log

| # | Claim | Section | Risk if Wrong |
|---|-------|---------|---------------|
| A1 | All dependencies (Next.js 16, Drizzle, Zod, Tailwind, shadcn/ui) are current and compatible with Phase 2 build | Standard Stack | If versions are stale, build may fail. Risk: LOW — package.json verified 2026-05-17, all versions match live codebase |
| A2 | `drizzle-kit push` is the correct method for schema migration in this project | Architecture Patterns | If alternative migration method is required, schema push step will fail. Risk: LOW — Phase 1 and Phase 2 used this method successfully |
| A3 | The existing `getClientFullDetail()` query in `lib/admin-queries.ts` does not expose quote_items | Common Pitfalls | If this query accidentally includes quote_items, client API constraint is already broken. Risk: MEDIUM — needs explicit verification during planning |
| A4 | Inline edit pattern (used in DocumentsTab) is applicable to ServiceTable | Architecture Patterns | If UI spec requires modal or other pattern, implementation will need revision. Risk: LOW — UI-SPEC explicitly says "prefer inline editing" |
| A5 | NavBar component is the only place where top-level navigation links are maintained | Architecture Patterns | If navigation is split across multiple files, Catalogo link addition may be incomplete. Risk: LOW — NavBar examined; it's a single source of truth |

**If this table is empty:** All claims were verified via code inspection or official documentation. No user confirmation needed before planning.

## Open Questions

1. **Pricing model for custom items in quote tab**
   - What we know: UI spec says "voce libera" with "nome + prezzo custom"
   - What's unclear: Should the freeform price be per-unit or total? Spec shows qty field, suggesting per-unit.
   - Recommendation: Implement as per-unit (matches catalog pattern). If admin wants a fixed total, they can set qty=1 and price=total.

2. **Filter visibility of inactive services in quote selector**
   - What we know: Inactive services should not appear in the quote dropdown
   - What's unclear: Should inactive services be visible in the catalog list with a badge, or completely hidden?
   - Recommendation: Follow spec: "Items disattivati restano visibili in elenco (filtro toggle) ma non appaiono nel selettore quote." Implement as: catalog table shows all items (toggle to hide inactive), quote selector only shows active.

3. **Snapshot behavior for catalog item updates**
   - What we know: Quote items snapshot the price at time of quote
   - What's unclear: If a catalog item is disabled after being quoted, what happens to the quote display? (Should show the service name in the quote, but if service is deleted?)
   - Recommendation: Use `leftJoin` in query; service deletion is FK restricted (onDelete: "restrict"), so this is prevented at the DB level. Quotes will always resolve to a service or show the custom label.

## Environment Availability

All dependencies are npm packages already installed in the project (verified via package.json). No external tools, services, or runtimes are required beyond the existing Next.js 16 + Postgres + Neon stack.

**Status:** ✅ All environment requirements met. No gaps.

## Validation Architecture

**Note:** `workflow.nyquist_validation` is set to `false` in `.planning/config.json`. Validation section is omitted per configuration.

## Security Domain

### Applicable ASVS Categories

| ASVS Category | Applies | Standard Control |
|---------------|---------|-----------------|
| V2 Authentication | yes | Auth.js session check (already enforced for `/admin/*` routes in Phase 2) |
| V3 Session Management | yes | Auth.js v4 session management (middleware validates auth token) |
| V4 Access Control | yes | `/admin/catalog` must check session; quote operations only accessible to authenticated admin |
| V5 Input Validation | yes | Zod schema validation in Server Actions (price, quantity, text fields) |
| V6 Cryptography | no | No new crypto operations; prices stored as numeric strings, not hashed |

### Known Threat Patterns for {Next.js + Drizzle + Postgres}

| Pattern | STRIDE | Standard Mitigation |
|---------|--------|---------------------|
| SQL injection via quote builder | Tampering | Use Drizzle parameterized queries (never string interpolation); Zod validates input types before DB |
| Unauthorized quote modification | Spoofing, Tampering | Session check on `/admin/catalog` and quote-actions routes; no CORS bypass |
| Accidental quote exposure in client API | Disclosure | Explicit `.select()` columns on client routes; never `SELECT *`; test with curl/Postman to verify no quote_items in response |
| Admin price manipulation | Tampering | Accepted_total is intentionally admin-editable (business requirement); audit timestamp via DB or logging if needed |
| XSS in service names / custom labels | Tampering | React auto-escapes in JSX; no `dangerouslySetInnerHTML` used in UI components |

**Phase 3 adds no new surface area for authentication/authorization.** All routes inherit the session check from Phase 2 middleware. Quote_items constraint is enforced at the query/response layer, not via auth.

## Sources

### Primary (HIGH confidence)

- **Existing codebase** (`src/db/schema.ts`, `src/app/admin/clients/[id]/actions.ts`, `src/components/admin/tabs/`) — verified 2026-05-17
  - Service catalog table structure confirmed (name, description, unit_price, active fields exist)
  - Quote items table exists but needs two schema changes (service_id nullable, custom_label text)
  - Server Actions pattern established in Phase 2 — reusable for Phase 3 CRUD
  - Tab component pattern established (PaymentsTab, DocumentsTab) — QuoteTab will follow same structure

- **CONTEXT.md** (Phase 3 discuss-phase decisions)
  - All architectural decisions locked: catalog location, quote builder location, schema changes, accepted_total behavior
  - UI spec provided: inline editing, form fields, styling system
  - Requirements mapped to capabilities: CAT-01, CAT-02, ADMIN-03

- **CLAUDE.md** (project constraints)
  - Quote items never exposed to client API — enforced constraint from Phase 1 design
  - Server-side rendering + Auth.js session management — established patterns

### Secondary (MEDIUM confidence)

- **Phase 2 execution artifacts** (commits, merged PRs, component implementations)
  - Validated that Server Actions + Zod pattern works end-to-end
  - Verified Tailwind styling system (#1A463C, #DEF168, #e5e7eb colors) is applied consistently
  - Confirmed `revalidatePath` behavior and next/cache utilities

## Metadata

**Confidence breakdown:**
- **Standard Stack: HIGH** — All libraries verified in package.json; versions match live codebase; no version mismatches or deprecations detected
- **Architecture: HIGH** — Schema is 95% done (only 2 fields need to be added); component patterns from Phase 2 are proven and reusable; no experimental or uncertain technologies
- **Pitfalls: HIGH** — Security constraint (quote_items exposure) documented and understood; pitfalls derived from common SaaS quote builder patterns; preventions are concrete and testable

**Research date:** 2026-05-17
**Valid until:** 2026-06-17 (30 days — stable domain with no fast-moving dependencies)