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:
- Database schema is 95% complete — only two fields need to be added to
quote_items: makeservice_idnullable and addcustom_labeltext field (for freeform items). - Component patterns are stable and reusable: existing tab system (PaymentsTab, DocumentsTab) provides the exact UI structure to follow.
- Server Actions pattern is established in
actions.ts— quote CRUD will follow the same async form handling + Zod validation pattern. - No external libraries or complex state management needed — plain React forms + Server Actions suffice.
- 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
-
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
-
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_idbecomes nullable, addcustom_labeltext field
-
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
-
Security Constraint (immutable from Phase 1)
quote_itemsare admin-only — NEVER exposed by client-facing API routesclients.accepted_totalis 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
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:
// 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 doSELECT *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_idFK when possible; only usecustom_labelfor 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, ... })— neverSELECT *. - 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_pricein the quote table — never join back toservice_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 pushrun 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
-
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.
-
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.
-
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
leftJoinin 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
revalidatePathbehavior 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)