Files
clienthub/.planning/phases/03-service-catalog-quote-builder/03-RESEARCH.md
T

39 KiB

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.

# 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
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:

// 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:

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:

// 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)

// 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)

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

// 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)