docs(phase-03): complete phase execution — service catalog + quote builder verified
This commit is contained in:
@@ -0,0 +1,873 @@
|
||||
# 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)
|
||||
Reference in New Issue
Block a user