docs(phase-03): complete phase execution — service catalog + quote builder verified
This commit is contained in:
@@ -70,10 +70,10 @@ Decimal phases appear between their surrounding integers in numeric order.
|
||||
3. Dopo la finalizzazione del preventivo, `accepted_total` è scritto sulla riga cliente e la dashboard del cliente mostra il totale corretto; i `quote_items` non sono mai esposti al cliente
|
||||
**Plans**: 4 plans
|
||||
**Plan list**:
|
||||
- [ ] 03-01-PLAN.md — Schema changes (service_id nullable, custom_label) + drizzle-kit push [BLOCKING]
|
||||
- [ ] 03-02-PLAN.md — Service Catalog: /admin/catalog page + CRUD actions + ServiceTable + NavBar link
|
||||
- [ ] 03-03-PLAN.md — Quote Builder: QuoteTab + quote-actions + client detail page wiring
|
||||
- [ ] 03-04-PLAN.md — E2E verification: catalog CRUD, quote round-trip, accepted_total, security check
|
||||
- [x] 03-01-PLAN.md — Schema changes (service_id nullable, custom_label) + drizzle-kit push [BLOCKING]
|
||||
- [x] 03-02-PLAN.md — Service Catalog: /admin/catalog page + CRUD actions + ServiceTable + NavBar link
|
||||
- [x] 03-03-PLAN.md — Quote Builder: QuoteTab + quote-actions + client detail page wiring
|
||||
- [x] 03-04-PLAN.md — E2E verification: catalog CRUD, quote round-trip, accepted_total, security check
|
||||
**UI hint**: yes
|
||||
**Status**: Planned — ready for execution
|
||||
|
||||
|
||||
+11
-10
@@ -4,13 +4,13 @@ milestone: v1.0
|
||||
milestone_name: milestone
|
||||
status: executing
|
||||
stopped_at: Phase 1 execution complete — all 5 plans done, E2E verified (valid token 200, invalid 404)
|
||||
last_updated: "2026-05-15T19:55:11.909Z"
|
||||
last_activity: 2026-05-15
|
||||
last_updated: "2026-05-19T21:12:54.673Z"
|
||||
last_activity: 2026-05-19
|
||||
progress:
|
||||
total_phases: 4
|
||||
completed_phases: 2
|
||||
total_plans: 9
|
||||
completed_plans: 9
|
||||
completed_phases: 3
|
||||
total_plans: 13
|
||||
completed_plans: 13
|
||||
percent: 100
|
||||
---
|
||||
|
||||
@@ -21,14 +21,14 @@ progress:
|
||||
See: .planning/PROJECT.md (updated 2026-05-09)
|
||||
|
||||
**Core value:** Il cliente apre il link e vede esattamente a che punto è il suo progetto, cosa deve ancora succedere e cosa ha già approvato — senza dover scrivere email per chiedere aggiornamenti.
|
||||
**Current focus:** Phase 02 — Admin Area & Interactive Features
|
||||
**Current focus:** Phase 03 — service-catalog-quote-builder
|
||||
|
||||
## Current Position
|
||||
|
||||
Phase: 3
|
||||
Phase: 4
|
||||
Plan: Not started
|
||||
Status: Executing Phase 02
|
||||
Last activity: 2026-05-15
|
||||
Status: Executing Phase 03
|
||||
Last activity: 2026-05-19
|
||||
|
||||
Progress: [██░░░░░░░░] 25%
|
||||
|
||||
@@ -36,7 +36,7 @@ Progress: [██░░░░░░░░] 25%
|
||||
|
||||
**Velocity:**
|
||||
|
||||
- Total plans completed: 9
|
||||
- Total plans completed: 13
|
||||
- Average duration: ~1 session each
|
||||
- Total execution time: ~2 sessions (May 13–14)
|
||||
|
||||
@@ -46,6 +46,7 @@ Progress: [██░░░░░░░░] 25%
|
||||
|-------|-------|-------|----------|
|
||||
| 1. Foundation & Client Dashboard | 5 | 2 sessions | ~0.4 sessions |
|
||||
| 02 | 4 | - | - |
|
||||
| 03 | 4 | - | - |
|
||||
|
||||
**Recent Trend:**
|
||||
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
---
|
||||
phase: 3
|
||||
title: Service Catalog & Quote Builder
|
||||
status: discussed
|
||||
date: 2026-05-16
|
||||
---
|
||||
|
||||
# Phase 3 — Decisions & Context
|
||||
|
||||
## Phase Goal
|
||||
L'admin può costruire un catalogo servizi riutilizzabile e comporre preventivi da esso; il cliente vede solo il totale accettato (`accepted_total`).
|
||||
|
||||
## Key Decisions (LOCKED)
|
||||
|
||||
### 1. Service Catalog — Location: /admin/catalog
|
||||
- Pagina dedicata `/admin/catalog` con link aggiunto in NavBar (Clienti | Statistiche | Catalogo).
|
||||
- Tabella con colonne: Nome, Descrizione, Prezzo unitario, Stato (Attivo/Disattivato).
|
||||
- CRUD completo: aggiungi, modifica inline, disattiva (soft delete via `active = false`).
|
||||
- Items disattivati restano visibili in elenco (filtro toggle) ma non appaiono nel selettore quote.
|
||||
|
||||
### 2. Quote Builder — Location: Tab "Preventivo" in /admin/clients/[id]
|
||||
- Nuovo tab nell'admin client detail page, accanto a Fasi, Pagamenti, Documenti.
|
||||
- Mostra le voci preventivo del cliente con totale calcolato.
|
||||
- L'admin può aggiungere voci dal catalogo (dropdown con `active = true`) o voci libere (nome + prezzo custom).
|
||||
- Nessun blocco dopo la finalizzazione — voci sempre editabili.
|
||||
|
||||
### 3. Voci Preventivo — Catalogo + Free-form
|
||||
- **Da catalogo**: seleziona voce, inserisce quantità; `unit_price` viene snapshotato al momento dell'aggiunta (non segue futuri cambi al catalogo).
|
||||
- **Voce libera**: nome testo libero, prezzo unitario, quantità. `service_id` sarà NULL in `quote_items`.
|
||||
|
||||
> **Schema change needed**: `service_id` in `quote_items` deve diventare nullable (attualmente `notNull()`).
|
||||
> Aggiungere campo `custom_label text` a `quote_items` per le voci libere.
|
||||
|
||||
### 4. Accepted Total — Admin-controlled, not auto-calculated
|
||||
- Il builder mostra la somma calcolata delle voci come riferimento.
|
||||
- Esiste un campo separato "Totale accettato dal cliente" (editable input) con pulsante "Salva".
|
||||
- Il pulsante scrive il valore (che l'admin può modificare liberamente) su `clients.accepted_total`.
|
||||
- **Rationale**: il cliente accetta una cifra commerciale (es. €1.500 tondo) che può differire dalla somma analitica interna. Il preventivo interno è solo uno strumento di stima.
|
||||
|
||||
### 5. Pagamenti — Nessun aggiornamento automatico
|
||||
- Finalizzare il preventivo NON tocca i record `payments`.
|
||||
- L'admin aggiorna manualmente gli importi di acconto e saldo nella tab Pagamenti.
|
||||
|
||||
### 6. Constraint già in vigore (IMMUTABLE)
|
||||
- `quote_items` non vengono mai esposti dalle API client-facing.
|
||||
- `clients.accepted_total` è l'unico valore economico che il cliente vede.
|
||||
|
||||
## Schema Changes Required
|
||||
|
||||
```sql
|
||||
-- quote_items.service_id diventa nullable
|
||||
ALTER TABLE quote_items ALTER COLUMN service_id DROP NOT NULL;
|
||||
|
||||
-- aggiunta colonna per voci libere
|
||||
ALTER TABLE quote_items ADD COLUMN custom_label text;
|
||||
```
|
||||
|
||||
In Drizzle schema.ts:
|
||||
```ts
|
||||
service_id: text("service_id").references(() => service_catalog.id, { onDelete: "restrict" }), // removed .notNull()
|
||||
custom_label: text("custom_label"), // new field
|
||||
```
|
||||
|
||||
## Reusable Assets
|
||||
|
||||
- `service_catalog` e `quote_items` tables già presenti in schema.ts con relazioni e TS types.
|
||||
- Pattern Server Actions già stabilito (vedi `clients/[id]/actions.ts`).
|
||||
- Pattern tab UI già stabilito (`tabs/PhasesTab.tsx`, `tabs/PaymentsTab.tsx`, etc.).
|
||||
- Pattern inline edit già stabilito (`DocumentRow.tsx`).
|
||||
- `fmtEur()` già definita in analytics/page.tsx — estrarre in lib/utils o duplicare.
|
||||
|
||||
## Pages & Routes to Create
|
||||
|
||||
| Route | Type | Purpose |
|
||||
|-------|------|---------|
|
||||
| `/admin/catalog` | Server Component page | Lista + CRUD catalogo servizi |
|
||||
| `/admin/catalog/actions.ts` | Server Actions | createService, updateService, toggleActive |
|
||||
| `src/components/admin/tabs/QuoteTab.tsx` | Client Component | Quote builder UI |
|
||||
| `src/app/admin/clients/[id]/quote-actions.ts` | Server Actions | addQuoteItem, removeQuoteItem, updateAcceptedTotal |
|
||||
|
||||
## UI Notes
|
||||
|
||||
- Stile coerente con tab esistenti (border-b tabs navigation nell'admin client page).
|
||||
- Catalogo: tabella simile a admin clients list (bg-white rounded-xl border border-[#e5e7eb]).
|
||||
- Quote builder: due colonne su desktop (catalogo disponibile | voci selezionate) o lista unica con selettore.
|
||||
- Totale calcolato mostrato in bold come sommario; campo `accepted_total` separato con label chiara.
|
||||
- Colore brand: #1A463C per accent, #DEF168 per highlight.
|
||||
@@ -0,0 +1,558 @@
|
||||
# Phase 3: Service Catalog & Quote Builder — Pattern Map
|
||||
|
||||
**Mapped:** 2026-05-17
|
||||
**Files analyzed:** 7 new/modified files
|
||||
**Analogs found:** 7/7 with exact or role-match
|
||||
|
||||
## File Classification
|
||||
|
||||
| New/Modified File | Role | Data Flow | Closest Analog | Match Quality |
|
||||
|---|---|---|---|---|
|
||||
| `src/app/admin/catalog/page.tsx` | page | request-response | `src/app/admin/page.tsx` | exact |
|
||||
| `src/app/admin/catalog/actions.ts` | server-actions | CRUD | `src/app/admin/clients/[id]/actions.ts` | exact |
|
||||
| `src/components/admin/catalog/ServiceTable.tsx` | component | CRUD (display + inline edit) | `src/components/admin/DocumentRow.tsx` | exact |
|
||||
| `src/components/admin/tabs/QuoteTab.tsx` | component (client) | CRUD | `src/components/admin/tabs/PaymentsTab.tsx` | exact |
|
||||
| `src/app/admin/clients/[id]/quote-actions.ts` | server-actions | CRUD | `src/app/admin/clients/[id]/actions.ts` | exact |
|
||||
| `src/components/admin/NavBar.tsx` | component | request-response (MODIFIED) | `src/components/admin/NavBar.tsx` | exact |
|
||||
| `src/db/schema.ts` | config (MODIFIED) | schema | `src/db/schema.ts` | exact |
|
||||
|
||||
---
|
||||
|
||||
## Pattern Assignments
|
||||
|
||||
### `src/app/admin/catalog/page.tsx` (page, request-response)
|
||||
|
||||
**Analog:** `src/app/admin/page.tsx`
|
||||
|
||||
**Pattern:** Server Component with header, table, and action buttons. Fetches data, renders read-only structure with empty state.
|
||||
|
||||
**Imports pattern** (lines 1–4):
|
||||
```typescript
|
||||
import Link from "next/link";
|
||||
import { getAllClientsWithPayments } from "@/lib/admin-queries";
|
||||
import { ClientRow } from "@/components/admin/ClientRow";
|
||||
import { Button } from "@/components/ui/button";
|
||||
```
|
||||
|
||||
**Page structure** (lines 8–32):
|
||||
```typescript
|
||||
export default async function AdminDashboard({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ archived?: string }>;
|
||||
}) {
|
||||
const { archived } = await searchParams;
|
||||
const showArchived = archived === "1";
|
||||
const clients = await getAllClientsWithPayments(showArchived);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="text-2xl font-bold text-[#1a1a1a]">Clienti</h1>
|
||||
<Button asChild>
|
||||
<Link href="/admin/clients/new">+ Nuovo cliente</Link>
|
||||
</Button>
|
||||
</div>
|
||||
{/* ... table rendering ... */}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**For Catalog Page:** Replace query with `getAllServices()`, render ServiceTable component, add "+ Aggiungi servizio" button.
|
||||
|
||||
---
|
||||
|
||||
### `src/app/admin/catalog/actions.ts` (server-actions, CRUD)
|
||||
|
||||
**Analog:** `src/app/admin/clients/[id]/actions.ts`
|
||||
|
||||
**Pattern:** Server action exports with Zod schema validation, FormData parsing, DB operations, and revalidatePath.
|
||||
|
||||
**Zod validation pattern** (lines 20–24):
|
||||
```typescript
|
||||
const clientSchema = z.object({
|
||||
name: z.string().min(1, "Nome richiesto"),
|
||||
brand_name: z.string().min(1, "Brand name richiesto"),
|
||||
brief: z.string(),
|
||||
});
|
||||
```
|
||||
|
||||
**Server action with validation** (lines 26–36):
|
||||
```typescript
|
||||
export async function updateClient(clientId: string, formData: FormData) {
|
||||
const parsed = clientSchema.safeParse({
|
||||
name: formData.get("name"),
|
||||
brand_name: formData.get("brand_name"),
|
||||
brief: formData.get("brief") ?? "",
|
||||
});
|
||||
if (!parsed.success) throw new Error(parsed.error.issues[0].message);
|
||||
await db.update(clients).set(parsed.data).where(eq(clients.id, clientId));
|
||||
revalidatePath(`/admin/clients/${clientId}`);
|
||||
revalidatePath("/admin");
|
||||
}
|
||||
```
|
||||
|
||||
**Document validation pattern** (lines 138–141):
|
||||
```typescript
|
||||
const docSchema = z.object({
|
||||
label: z.string().min(1, "Etichetta richiesta"),
|
||||
url: z.string().url("URL non valido"),
|
||||
});
|
||||
```
|
||||
|
||||
**For Catalog Actions:** Create `serviceSchema` with name, description, unit_price. Implement `createService`, `updateService`, `toggleServiceActive`. Path revalidation: `/admin/catalog`.
|
||||
|
||||
---
|
||||
|
||||
### `src/components/admin/catalog/ServiceTable.tsx` (component, CRUD)
|
||||
|
||||
**Analog:** `src/components/admin/DocumentRow.tsx`
|
||||
|
||||
**Pattern:** Client component with local `editing` state, inline edit toggle, form submission via Server Action, error handling via useTransition.
|
||||
|
||||
**DocumentRow structure** (lines 10–80):
|
||||
```typescript
|
||||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { updateDocument, deleteDocument } from "@/app/admin/clients/[id]/actions";
|
||||
import type { Document } from "@/db/schema";
|
||||
|
||||
export function DocumentRow({
|
||||
doc,
|
||||
clientId,
|
||||
}: {
|
||||
doc: Document;
|
||||
clientId: string;
|
||||
}) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [, startTransition] = useTransition();
|
||||
const router = useRouter();
|
||||
|
||||
function handleSave(fd: FormData) {
|
||||
setError(null);
|
||||
startTransition(async () => {
|
||||
try {
|
||||
await updateDocument(doc.id, clientId, fd);
|
||||
setEditing(false);
|
||||
router.refresh();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Errore nel salvataggio");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (editing) {
|
||||
return (
|
||||
<form action={handleSave} className="bg-white border-2 border-[#1A463C]/30 rounded-lg px-4 py-3 space-y-2">
|
||||
<Input name="label" defaultValue={doc.label} required />
|
||||
<Input name="url" defaultValue={doc.url} type="url" required />
|
||||
{error && <p className="text-xs text-red-600">{error}</p>}
|
||||
<div className="flex gap-2 pt-1">
|
||||
<Button type="submit" size="sm">Salva</Button>
|
||||
<Button type="button" variant="ghost" size="sm" onClick={() => setEditing(false)}>
|
||||
Annulla
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between bg-white border border-[#e5e7eb] rounded-lg px-4 py-3 group">
|
||||
<a href={doc.url} className="text-sm text-[#1A463C] hover:underline font-medium">
|
||||
{doc.label}
|
||||
</a>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button variant="ghost" size="sm" onClick={() => setEditing(true)}>
|
||||
Modifica
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={handleDelete}>
|
||||
Rimuovi
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**For ServiceTable:** Render as table (not row), include service name, description, price, active status. Toggle row → editable inputs (name, description, price). Delete = soft toggle (`active = false`). Hover reveal "Disattiva"/"Riattiva" button.
|
||||
|
||||
**Table styling** (from admin/page.tsx lines 46–64):
|
||||
```typescript
|
||||
<div className="bg-white rounded-lg border border-[#e5e7eb] overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-[#f9f9f9] border-b border-[#e5e7eb]">
|
||||
<tr>
|
||||
<th className="text-left py-3 px-4 font-medium text-[#71717a]">Column</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{/* rows */}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `src/components/admin/tabs/QuoteTab.tsx` (component client, CRUD)
|
||||
|
||||
**Analog:** `src/components/admin/tabs/PaymentsTab.tsx`
|
||||
|
||||
**Pattern:** Async server component (not client) that receives props (items, services, acceptedTotal, clientId), renders multiple form sections, each with its own Server Action call.
|
||||
|
||||
**PaymentsTab structure** (lines 22–54):
|
||||
```typescript
|
||||
export async function PaymentsTab({ payments, acceptedTotal, clientId }: Props) {
|
||||
return (
|
||||
<div className="space-y-6 max-w-md">
|
||||
<div className="bg-white border border-gray-200 rounded-lg p-4">
|
||||
<h3 className="font-medium text-gray-900 mb-3">Totale preventivo</h3>
|
||||
<form
|
||||
action={async (fd: FormData) => {
|
||||
"use server";
|
||||
await updateAcceptedTotal(clientId, fd);
|
||||
}}
|
||||
className="flex items-end gap-3"
|
||||
>
|
||||
<div className="space-y-1 flex-1">
|
||||
<Label htmlFor="accepted_total">Importo (€)</Label>
|
||||
<Input
|
||||
id="accepted_total"
|
||||
name="accepted_total"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
defaultValue={acceptedTotal}
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" size="sm">Salva</Button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{payments.map((p) => (
|
||||
<div key={p.id} className="bg-white border border-gray-200 rounded-lg p-4">
|
||||
{/* ... */}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**For QuoteTab:** Structure as three sections:
|
||||
1. Add items (dropdown catalog + qty OR toggle to custom label/price/qty)
|
||||
2. Quote items table (Voce | Qty | Unit Price | Subtotal | Delete)
|
||||
3. Accepted total (editable input + Save button)
|
||||
|
||||
Each section is its own form with inline Server Action call. Use same card styling (`bg-white border border-[#e5e7eb] rounded-lg p-4`).
|
||||
|
||||
---
|
||||
|
||||
### `src/app/admin/clients/[id]/quote-actions.ts` (server-actions, CRUD)
|
||||
|
||||
**Analog:** `src/app/admin/clients/[id]/actions.ts`
|
||||
|
||||
**Pattern:** Identical to catalog actions — Zod validation, FormData parsing, numeric precision handling.
|
||||
|
||||
**Numeric precision pattern** (lines 192–211):
|
||||
```typescript
|
||||
export async function updateAcceptedTotal(clientId: string, formData: FormData) {
|
||||
const raw = (formData.get("accepted_total") as string)?.trim();
|
||||
const val = parseFloat(raw);
|
||||
if (isNaN(val) || val < 0) throw new Error("Importo non valido");
|
||||
|
||||
await db
|
||||
.update(clients)
|
||||
.set({ accepted_total: val.toFixed(2) })
|
||||
.where(eq(clients.id, clientId));
|
||||
|
||||
revalidatePath(`/admin/clients/${clientId}`);
|
||||
}
|
||||
```
|
||||
|
||||
**For Quote Actions:** Implement:
|
||||
- `addQuoteItem(clientId, formData)` — parse service_id (nullable), custom_label (nullable), quantity, unit_price. Calculate subtotal. Insert into quote_items.
|
||||
- `removeQuoteItem(quoteItemId, clientId)` — delete from quote_items.
|
||||
- `updateAcceptedTotal(clientId, formData)` — identical to existing pattern in actions.ts.
|
||||
|
||||
All paths: `revalidatePath(/admin/clients/${clientId})`.
|
||||
|
||||
---
|
||||
|
||||
### `src/components/admin/NavBar.tsx` (component, request-response — MODIFIED)
|
||||
|
||||
**Analog:** `src/components/admin/NavBar.tsx`
|
||||
|
||||
**Current structure** (lines 7–29):
|
||||
```typescript
|
||||
export function NavBar() {
|
||||
return (
|
||||
<nav className="bg-[#1A463C] px-6 py-3 flex items-center justify-between">
|
||||
<div className="flex items-center gap-6">
|
||||
<span className="font-bold text-white tracking-tight">iamcavalli</span>
|
||||
<Link href="/admin" className="text-sm text-white/70 hover:text-white transition-colors">
|
||||
Clienti
|
||||
</Link>
|
||||
<Link href="/admin/analytics" className="text-sm text-white/70 hover:text-white transition-colors">
|
||||
Statistiche
|
||||
</Link>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => signOut({ callbackUrl: "/admin/login" })}
|
||||
className="text-sm text-white/70 hover:text-white hover:bg-white/10"
|
||||
>
|
||||
Esci
|
||||
</Button>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Modification:** Add new Link after "Statistiche":
|
||||
```typescript
|
||||
<Link href="/admin/catalog" className="text-sm text-white/70 hover:text-white transition-colors">
|
||||
Catalogo
|
||||
</Link>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `src/db/schema.ts` (config — MODIFIED)
|
||||
|
||||
**Analog:** `src/db/schema.ts`
|
||||
|
||||
**Current quote_items definition** (lines 159–172):
|
||||
```typescript
|
||||
export const quote_items = pgTable("quote_items", {
|
||||
id: text("id")
|
||||
.primaryKey()
|
||||
.$defaultFn(() => nanoid()),
|
||||
client_id: text("client_id")
|
||||
.notNull()
|
||||
.references(() => clients.id, { onDelete: "cascade" }),
|
||||
service_id: text("service_id")
|
||||
.notNull()
|
||||
.references(() => service_catalog.id, { onDelete: "restrict" }),
|
||||
quantity: numeric("quantity", { precision: 10, scale: 2 }).notNull(),
|
||||
unit_price: numeric("unit_price", { precision: 10, scale: 2 }).notNull(),
|
||||
subtotal: numeric("subtotal", { precision: 10, scale: 2 }).notNull(),
|
||||
});
|
||||
```
|
||||
|
||||
**Required changes:**
|
||||
1. **Make service_id nullable** (line 166–168):
|
||||
```typescript
|
||||
service_id: text("service_id")
|
||||
.references(() => service_catalog.id, { onDelete: "restrict" }),
|
||||
// removed .notNull()
|
||||
```
|
||||
|
||||
2. **Add custom_label field** (after subtotal):
|
||||
```typescript
|
||||
custom_label: text("custom_label"),
|
||||
```
|
||||
|
||||
**After schema changes:**
|
||||
- Run `npx drizzle-kit push` to apply migrations to database
|
||||
- Verify no TypeScript errors in types (QuoteItem type will auto-update)
|
||||
|
||||
---
|
||||
|
||||
## Shared Patterns
|
||||
|
||||
### Form Validation (All CRUD Actions)
|
||||
|
||||
**Source:** `src/app/admin/clients/[id]/actions.ts` lines 20–24, 138–141
|
||||
|
||||
**Pattern:** Use Zod schema with `.safeParse()`, throw first error message.
|
||||
|
||||
**Apply to:** All catalog and quote actions
|
||||
|
||||
```typescript
|
||||
import { z } from "zod";
|
||||
|
||||
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");
|
||||
}
|
||||
```
|
||||
|
||||
### Inline Edit Component Pattern (ServiceTable, ServiceRow)
|
||||
|
||||
**Source:** `src/components/admin/DocumentRow.tsx` lines 10–114
|
||||
|
||||
**Pattern:**
|
||||
- "use client" directive
|
||||
- useState for `editing`, `error`
|
||||
- useTransition for async form submission
|
||||
- useRouter for refresh
|
||||
- Toggle render: editing mode (form inputs) vs read mode (display + hover buttons)
|
||||
- Server Action called inline in form action
|
||||
|
||||
**Apply to:** ServiceTable with per-row inline edit.
|
||||
|
||||
### Currency Formatting
|
||||
|
||||
**Source:** `src/components/admin/ClientRow.tsx` line 33
|
||||
|
||||
**Pattern:**
|
||||
```typescript
|
||||
€{parseFloat(amount).toLocaleString("it-IT", { minimumFractionDigits: 2 })}
|
||||
```
|
||||
|
||||
**Apply to:** All price displays in ServiceTable and QuoteTab.
|
||||
|
||||
### Table Styling
|
||||
|
||||
**Source:** `src/app/admin/page.tsx` lines 46–64
|
||||
|
||||
**Pattern:**
|
||||
```typescript
|
||||
<div className="bg-white rounded-lg border border-[#e5e7eb] overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-[#f9f9f9] border-b border-[#e5e7eb]">
|
||||
<tr>
|
||||
<th className="text-left py-3 px-4 font-medium text-[#71717a]">Colonna</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map(item => (
|
||||
<tr key={item.id} className="border-b border-[#f4f4f5] hover:bg-[#f9f9f9]">
|
||||
<td className="py-3 px-4">…</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
```
|
||||
|
||||
**Apply to:** ServiceTable layout in catalog/page.tsx
|
||||
|
||||
### Card Styling (Forms, Sections)
|
||||
|
||||
**Source:** `src/components/admin/tabs/DocumentsTab.tsx` line 18
|
||||
|
||||
**Pattern:**
|
||||
```typescript
|
||||
<div className="bg-white border border-[#e5e7eb] rounded-lg p-4 space-y-3">
|
||||
<h3 className="font-medium text-[#1a1a1a]">Titolo</h3>
|
||||
{/* content */}
|
||||
</div>
|
||||
```
|
||||
|
||||
**Apply to:** All form sections in QuoteTab and ServiceTable.
|
||||
|
||||
### Label + Input Grid
|
||||
|
||||
**Source:** `src/components/admin/tabs/DocumentsTab.tsx` lines 20–39
|
||||
|
||||
**Pattern:**
|
||||
```typescript
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="field-id">Label testo</Label>
|
||||
<Input
|
||||
id="field-id"
|
||||
name="field-name"
|
||||
type="text"
|
||||
placeholder="placeholder"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
```
|
||||
|
||||
**Apply to:** All form inputs in catalog and quote builders.
|
||||
|
||||
### Numeric Input Pattern
|
||||
|
||||
**Source:** `src/components/admin/tabs/PaymentsTab.tsx` lines 36–45
|
||||
|
||||
**Pattern:**
|
||||
```typescript
|
||||
<Input
|
||||
id="price"
|
||||
name="unit_price"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
defaultValue={price}
|
||||
/>
|
||||
```
|
||||
|
||||
**Apply to:** All price/quantity inputs; use `step="0.01"` for EUR precision.
|
||||
|
||||
---
|
||||
|
||||
## No Analog Found
|
||||
|
||||
No files require external patterns. All code patterns (Server Actions, inline edit, table layout, form validation) exist in the codebase.
|
||||
|
||||
---
|
||||
|
||||
## Query Pattern (for page data fetching)
|
||||
|
||||
**Not extracted as code** — will be implemented in quote-actions.ts and documented in planning phase.
|
||||
|
||||
Example from RESEARCH.md:
|
||||
```typescript
|
||||
// Get all active services for dropdown
|
||||
const activeServices = await db
|
||||
.select()
|
||||
.from(service_catalog)
|
||||
.where(eq(service_catalog.active, true))
|
||||
.orderBy(asc(service_catalog.name));
|
||||
|
||||
// Get quote items with service names
|
||||
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));
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Metadata
|
||||
|
||||
**Analog search scope:** `/src/app/admin/`, `/src/components/admin/`, `/src/app/admin/clients/[id]/`
|
||||
**Files scanned:** 13 analog files
|
||||
**Pattern extraction date:** 2026-05-17
|
||||
|
||||
**Coverage summary:**
|
||||
- Exact match (same role + data flow): 7/7
|
||||
- Role-match (same role, similar flow): 0
|
||||
- No analog: 0
|
||||
|
||||
**Key insights:**
|
||||
- Phase 2 established Server Actions + Zod pattern — directly reusable for Phase 3 CRUD
|
||||
- Inline edit pattern from DocumentRow is the gold standard for catalog service editing
|
||||
- PaymentsTab structure fits QuoteTab exactly (multiple form sections, each with own Server Action)
|
||||
- Table styling is consistent across admin interface — use directly
|
||||
- No new dependencies or libraries needed — all patterns are vanilla React + Next.js built-ins
|
||||
@@ -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)
|
||||
@@ -0,0 +1,90 @@
|
||||
---
|
||||
phase: 3
|
||||
title: Service Catalog & Quote Builder — UI Design Contract
|
||||
status: approved
|
||||
date: 2026-05-17
|
||||
source: land-book.com aesthetic + existing admin brand
|
||||
---
|
||||
|
||||
# UI-SPEC: Phase 3 — Service Catalog & Quote Builder
|
||||
|
||||
## Design Direction
|
||||
|
||||
**Reference:** land-book.com — clean minimal white, card grid, dark typography, generous whitespace, subtle borders.
|
||||
**Brand accent:** #1A463C (green), #DEF168 (yellow) — used sparingly for CTAs and active states.
|
||||
**Base:** white backgrounds, #1a1a1a text, #e5e7eb borders, #f9f9f9 surface.
|
||||
|
||||
## Pages
|
||||
|
||||
### /admin/catalog — Service Catalog
|
||||
|
||||
**Layout:**
|
||||
- Full-width table layout (same pattern as admin clients list)
|
||||
- Header row: "Catalogo Servizi" h1 + "+ Aggiungi servizio" button (primary green)
|
||||
- Table: bg-white, rounded-xl, border border-[#e5e7eb]
|
||||
- Columns: Nome | Descrizione | Prezzo | Stato | Azioni
|
||||
|
||||
**Row design:**
|
||||
- Hover: bg-[#f9f9f9] transition
|
||||
- Inactive rows: opacity-50
|
||||
- Status badge: pill "Attivo" (bg-[#1A463C]/10 text-[#1A463C]) / "Disattivato" (bg-[#f4f4f5] text-[#71717a])
|
||||
- Inline edit: click → row expands into editable inputs, save/cancel buttons
|
||||
- Actions: "Disattiva" / "Riattiva" text button, no icons
|
||||
|
||||
**Add service form:**
|
||||
- Slide-in panel (right side) OR inline row at top of table
|
||||
- Fields: Nome (text), Descrizione (textarea, optional), Prezzo unitario (number, EUR)
|
||||
- CTA: "+ Aggiungi" button primary
|
||||
|
||||
### /admin/clients/[id] — Tab "Preventivo"
|
||||
|
||||
**Tab navigation:**
|
||||
- Added as 5th tab after Documenti: Fasi | Pagamenti | Documenti | Note | Preventivo
|
||||
|
||||
**Preventivo tab layout — two sections stacked:**
|
||||
|
||||
**Section 1: Aggiungi voci**
|
||||
- Compact add-row UI: dropdown "Seleziona dal catalogo" + qty input + "Aggiungi" OR "Voce libera" toggle → text input + price + qty
|
||||
- Dropdown shows only active catalog items, sorted by name
|
||||
|
||||
**Section 2: Voci preventivo** (card with border)
|
||||
- Table: Voce | Qty | Prezzo unitario | Subtotale | Rimuovi
|
||||
- Footer row: "Totale calcolato" bold right-aligned
|
||||
- Below table: separator + "Totale accettato dal cliente" label + editable EUR input + "Salva" button (primary)
|
||||
- Small helper text: "Il cliente vede solo questo importo, non le singole voci."
|
||||
|
||||
**Empty state:**
|
||||
- Centered text: "Nessuna voce aggiunta. Seleziona dal catalogo per iniziare."
|
||||
|
||||
## Components
|
||||
|
||||
| Component | Type | Location |
|
||||
|-----------|------|---------|
|
||||
| ServiceTable | Server+Client | `components/admin/catalog/ServiceTable.tsx` |
|
||||
| ServiceForm | Client | `components/admin/catalog/ServiceForm.tsx` |
|
||||
| QuoteTab | Client | `components/admin/tabs/QuoteTab.tsx` |
|
||||
| CatalogSelector | Client (inside QuoteTab) | inline |
|
||||
|
||||
## Interaction Rules
|
||||
|
||||
- Add service: inline form row at top OR slide panel — no full page redirect
|
||||
- Edit service: inline row expansion, save with Server Action
|
||||
- Disable/enable: single click, optimistic toggle, Server Action confirm
|
||||
- Add quote item: instant append to list, no page reload
|
||||
- Remove quote item: instant remove, no confirmation (items not locked)
|
||||
- "Salva totale accettato": saves `accepted_total` via Server Action, shows success state (green border flash)
|
||||
|
||||
## Typography & Spacing
|
||||
|
||||
- Section headings: `text-xs font-bold text-[#71717a] uppercase tracking-wider` (same as existing admin)
|
||||
- Table headers: `text-sm font-medium text-[#71717a]`
|
||||
- Prices: `tabular-nums` monospace-aligned
|
||||
- Whitespace: `gap-6` between sections, `py-3 px-4` for table cells
|
||||
- All cards: `rounded-xl border border-[#e5e7eb] bg-white`
|
||||
|
||||
## What NOT to do
|
||||
|
||||
- No modals/dialogs — prefer inline editing
|
||||
- No full-page forms for simple CRUD — stay in context
|
||||
- No icon-heavy buttons — text labels only (consistent with existing admin)
|
||||
- No complex animations — only `transition-colors` on hover
|
||||
@@ -0,0 +1,173 @@
|
||||
---
|
||||
phase: 03-service-catalog-quote-builder
|
||||
verified: 2026-05-19T21:10:00Z
|
||||
status: human_needed
|
||||
score: 13/13 must-haves verified
|
||||
overrides_applied: 0
|
||||
re_verification: false
|
||||
human_verification:
|
||||
- test: "Navigate to /admin/catalog — add, edit, toggle active/inactive a service — confirm all three actions persist after page refresh"
|
||||
expected: "Service appears in table after add. Price updates after edit. Badge toggles between Attivo and Disattivato with row opacity change."
|
||||
why_human: "Service catalog CRUD requires a running dev server and live Neon DB connection. Cannot verify persistence via static analysis."
|
||||
- test: "Open a client's Preventivo tab — add one catalog item and one freeform item — verify table and subtotals"
|
||||
expected: "Catalog item shows snapshotted unit_price (not re-joined from service_catalog). Freeform item appears with custom_label. Totale calcolato equals the sum of subtotals."
|
||||
why_human: "Requires running app + DB to verify actual DB insert semantics and COALESCE label resolution at query time."
|
||||
- test: "Set accepted_total in Preventivo tab — open the client dashboard at /c/[token] — confirm the exact amount is shown"
|
||||
expected: "Client dashboard shows the value set in the admin, not the calculated sum of quote_items subtotals."
|
||||
why_human: "Round-trip between admin write (clients.accepted_total) and client read (client-view.ts getClientView) requires a live session. Wiring is verified statically; data round-trip requires human confirmation."
|
||||
- test: "Inspect the /c/[token] page network responses and /api/client/* responses — confirm NO quote_items, service_id, or per-item prices appear"
|
||||
expected: "No quote_items field, no service_id field, no per-line-item prices in any client-facing response. Only accepted_total is present."
|
||||
why_human: "Static analysis confirms client-view.ts never queries quote_items, and the API routes contain no such references. Runtime DevTools or curl confirmation needed per security constraint in CLAUDE.md."
|
||||
---
|
||||
|
||||
# Phase 03: Service Catalog & Quote Builder Verification Report
|
||||
|
||||
**Phase Goal:** L'admin può costruire un catalogo servizi riutilizzabile e comporre preventivi da esso; il cliente vede solo il totale accettato
|
||||
**Verified:** 2026-05-19T21:10:00Z
|
||||
**Status:** human_needed
|
||||
**Re-verification:** No — initial verification
|
||||
|
||||
---
|
||||
|
||||
## Goal Achievement
|
||||
|
||||
### Observable Truths
|
||||
|
||||
| # | Truth | Status | Evidence |
|
||||
|---|-------|--------|----------|
|
||||
| 1 | quote_items.service_id is nullable in the database | VERIFIED | `src/db/schema.ts` line 166-167: `.references(() => service_catalog.id, { onDelete: "restrict" })` with no `.notNull()` — comment confirms "nullable" |
|
||||
| 2 | quote_items.custom_label column exists in the database | VERIFIED | `src/db/schema.ts` line 171: `custom_label: text("custom_label")` present after subtotal |
|
||||
| 3 | TypeScript QuoteItem type reflects nullable service_id and custom_label | VERIFIED | `schema.ts` line 258: `export type QuoteItem = typeof quote_items.$inferSelect` — Drizzle infers `service_id: string \| null` and `custom_label: string \| null` automatically |
|
||||
| 4 | Admin can navigate to /admin/catalog from NavBar | VERIFIED | `NavBar.tsx` line 18-20: `<Link href="/admin/catalog" ...>Catalogo</Link>` present between Statistiche and Esci |
|
||||
| 5 | Admin can see catalog table with correct columns | VERIFIED | `ServiceTable.tsx` lines 145-152: thead contains Nome, Descrizione, Prezzo, Stato, and actions column |
|
||||
| 6 | Admin can add/edit/toggle services via Server Actions with requireAdmin + Zod | VERIFIED | `catalog/actions.ts`: all three exports (`createService`, `updateService`, `toggleServiceActive`) call `requireAdmin()` and validate via `serviceSchema` |
|
||||
| 7 | Service catalog page fetches from DB via getAllServices | VERIFIED | `catalog/page.tsx` line 1+8: imports and awaits `getAllServices()` from admin-queries; `admin-queries.ts` line 233: `getAllServices()` queries `db.select().from(service_catalog)` |
|
||||
| 8 | Admin can see Preventivo tab in client detail page (5th tab) | VERIFIED | `page.tsx` line 63: `<TabsTrigger value="quote">Preventivo</TabsTrigger>`; line 86-93: `<TabsContent value="quote"><QuoteTab .../></TabsContent>` |
|
||||
| 9 | QuoteTab renders catalog dropdown + freeform toggle + items table + accepted total editor | VERIFIED | `QuoteTab.tsx`: catalog mode (lines 81-155), freeform mode (lines 158-217), items table with calculated total (lines 221-297), accepted_total form (lines 300-328) — all three sections substantive |
|
||||
| 10 | Admin can add catalog and freeform quote items (service_id null for freeform) | VERIFIED | `quote-actions.ts` lines 26-61: `addQuoteItem` correctly sets `service_id: null` for freeform items and uses `custom_label`; hidden field pattern in QuoteTab ensures correct mode submission |
|
||||
| 11 | Admin can remove a quote item via removeQuoteItem | VERIFIED | `quote-actions.ts` lines 63-67: `removeQuoteItem` deletes by `quote_items.id`; QuoteTab line 49-53: `handleRemove` calls it via `startTransition` |
|
||||
| 12 | Admin can write accepted_total via updateAcceptedTotal | VERIFIED | `quote-actions.ts` lines 69-79: writes to `clients.accepted_total` only; `client-view.ts` line 201: `accepted_total: client.accepted_total ?? '0'` is returned to client dashboard |
|
||||
| 13 | quote_items are NEVER exposed via client-facing routes | VERIFIED | `client-view.ts`: imports do not include `quote_items` or `service_catalog`; no query to these tables anywhere in the file; `/api/client/`, `/api/internal/`, `/app/c/` directories contain zero references to `quote_items` or `service_catalog` (grep returned empty) |
|
||||
|
||||
**Score:** 13/13 truths verified
|
||||
|
||||
---
|
||||
|
||||
### Required Artifacts
|
||||
|
||||
| Artifact | Expected | Status | Details |
|
||||
|----------|----------|--------|---------|
|
||||
| `src/db/schema.ts` | Updated quote_items: nullable service_id + custom_label column | VERIFIED | Lines 166-171 match expected definition exactly |
|
||||
| `src/app/admin/catalog/page.tsx` | Server component fetching getAllServices | VERIFIED | 29 lines, substantive, calls `getAllServices()`, renders ServiceForm + ServiceTable |
|
||||
| `src/app/admin/catalog/actions.ts` | createService, updateService, toggleServiceActive | VERIFIED | All three exported, all call `requireAdmin()`, all use Zod `serviceSchema` |
|
||||
| `src/components/admin/catalog/ServiceTable.tsx` | Table with per-row inline edit + active toggle | VERIFIED | 162 lines; ServiceRow with edit state + handleSave + handleToggle; exports ServiceTable |
|
||||
| `src/components/admin/catalog/ServiceForm.tsx` | Add-new-service form | VERIFIED | 94 lines; toggle open/closed UI; calls createService via useTransition |
|
||||
| `src/components/admin/NavBar.tsx` | Catalogo link between Statistiche and Esci | VERIFIED | Line 18: `/admin/catalog` link present in correct position |
|
||||
| `src/app/admin/clients/[id]/quote-actions.ts` | addQuoteItem, removeQuoteItem, updateAcceptedTotal | VERIFIED | All three exported; 4 `requireAdmin()` calls (definition + 3 actions); Zod `quoteItemSchema` validation |
|
||||
| `src/components/admin/tabs/QuoteTab.tsx` | Quote builder UI — all three sections | VERIFIED | 333 lines; fully substantive; all three handlers wired to Server Actions |
|
||||
| `src/lib/admin-queries.ts` | QuoteItemWithLabel type + quoteItems/activeServices in getClientFullDetail | VERIFIED | Lines 115-123: QuoteItemWithLabel type; lines 132-133: ClientFullDetail fields; lines 200-219: two DB queries added; line 233: getAllServices |
|
||||
| `src/lib/client-view.ts` | Zero functional quote_items references | VERIFIED | Only 3 comment-level mentions ("Deliberately excludes: quote_items", "NEVER queries quote_items"); no imports, no queries, no returned fields |
|
||||
|
||||
---
|
||||
|
||||
### Key Link Verification
|
||||
|
||||
| From | To | Via | Status | Details |
|
||||
|------|----|-----|--------|---------|
|
||||
| ServiceForm.tsx | catalog/actions.ts createService | form action + useTransition | WIRED | Line 8 imports `createService`; line 21 calls `await createService(fd)` inside startTransition |
|
||||
| ServiceTable.tsx | catalog/actions.ts updateService + toggleServiceActive | useTransition + form action | WIRED | Line 8 imports both; handleSave calls `updateService`, handleToggle calls `toggleServiceActive` |
|
||||
| catalog/page.tsx | admin-queries.ts getAllServices | await getAllServices() | WIRED | Line 1 imports; line 8 awaits result; result passed as `services` prop to ServiceTable |
|
||||
| QuoteTab.tsx add-item form | quote-actions.ts addQuoteItem | startTransition + form action | WIRED | Lines 8-11 import all three actions; handleAddItem calls `await addQuoteItem(clientId, fd)` |
|
||||
| QuoteTab.tsx remove button | quote-actions.ts removeQuoteItem | onClick + startTransition | WIRED | Line 51: `await removeQuoteItem(quoteItemId, clientId)` inside startTransition |
|
||||
| QuoteTab.tsx accepted total form | quote-actions.ts updateAcceptedTotal | form action + startTransition | WIRED | Line 60: `await updateAcceptedTotal(clientId, fd)` inside startTransition |
|
||||
| clients/[id]/page.tsx | admin-queries.ts getClientFullDetail | await getClientFullDetail(id) | WIRED | Line 2 imports; line 21 awaits; line 24 destructures `quoteItems, activeServices` |
|
||||
| clients.accepted_total (DB) | client dashboard /c/[token] | client-view.ts getClientView | WIRED | client-view.ts line 201 returns `accepted_total: client.accepted_total ?? '0'`; no quote_items in path |
|
||||
|
||||
---
|
||||
|
||||
### Data-Flow Trace (Level 4)
|
||||
|
||||
| Artifact | Data Variable | Source | Produces Real Data | Status |
|
||||
|----------|---------------|--------|--------------------|--------|
|
||||
| QuoteTab.tsx | `items: QuoteItemWithLabel[]` | `getClientFullDetail` → leftJoin query lines 200-213 in admin-queries.ts | Yes — Drizzle `.select().from(quote_items).leftJoin(service_catalog)` with `COALESCE` label | FLOWING |
|
||||
| QuoteTab.tsx | `activeServices: ServiceCatalog[]` | `getClientFullDetail` → `db.select().from(service_catalog).where(active=true)` line 215 | Yes — live DB query filtered by active flag | FLOWING |
|
||||
| QuoteTab.tsx | `acceptedTotal: string` | `client.accepted_total` from clients table | Yes — set by `updateAcceptedTotal` action, read back on page refresh | FLOWING |
|
||||
| ServiceTable.tsx | `services: ServiceCatalog[]` | `getAllServices()` → `db.select().from(service_catalog)` line 234 | Yes — live DB query | FLOWING |
|
||||
| client-view.ts | `accepted_total` | `client.accepted_total` from clients table (direct column select) | Yes — DB column, populated by admin write | FLOWING |
|
||||
|
||||
---
|
||||
|
||||
### Behavioral Spot-Checks
|
||||
|
||||
Skipped — requires a running dev server with live Neon DB connection. All live behavioral verification routed to Human Verification section below.
|
||||
|
||||
| Behavior | Command | Result | Status |
|
||||
|----------|---------|--------|--------|
|
||||
| Service catalog page references getAllServices | `grep -c 'getAllServices' catalog/page.tsx` | 1 | PASS |
|
||||
| NavBar contains Catalogo link | `grep -c '/admin/catalog' NavBar.tsx` | 1 | PASS |
|
||||
| Preventivo tab wired in client detail page | `grep -n 'Preventivo\|value="quote"' page.tsx` | Lines 63, 86 | PASS |
|
||||
| requireAdmin called in all 3 quote actions | `grep -c 'requireAdmin' quote-actions.ts` | 4 (def + 3 calls) | PASS |
|
||||
| quote_items absent from all client-facing routes | `grep -rn 'quote_items' src/app/c/ src/app/api/` | Empty (CLEAN) | PASS |
|
||||
| custom_label present in schema | `grep 'custom_label' schema.ts` | Line 171 | PASS |
|
||||
| service_id nullable in schema | `grep -A3 'service_id: text' schema.ts` | No .notNull() present | PASS |
|
||||
|
||||
---
|
||||
|
||||
### Requirements Coverage
|
||||
|
||||
| Requirement | Source Plan | Description | Status | Evidence |
|
||||
|-------------|-------------|-------------|--------|----------|
|
||||
| CAT-01 | 03-01, 03-02 | File/database dei servizi con prezzi e cosa è incluso | SATISFIED | service_catalog table exists; /admin/catalog CRUD page fully implemented with createService/updateService/toggleServiceActive actions |
|
||||
| CAT-02 | 03-01, 03-03 | Usato come base per la generazione assistita dei preventivi | SATISFIED | QuoteTab dropdown reads `activeServices` from catalog; addQuoteItem snapshots unit_price at insert time; freeform items supported with service_id=null |
|
||||
| ADMIN-03 | 03-02, 03-03 | Preventivo completo con dettaglio servizi (non visibile al cliente) | SATISFIED | QuoteTab shows all item detail to admin; getClientFullDetail returns quoteItems only to admin page; client-view.ts returns only accepted_total with zero quote_items exposure |
|
||||
|
||||
All three requirements assigned to Phase 3 in REQUIREMENTS.md traceability table are satisfied. No orphaned requirements found.
|
||||
|
||||
---
|
||||
|
||||
### Anti-Patterns Found
|
||||
|
||||
No blockers or warnings found. Scan of all six phase-3 source files (`catalog/actions.ts`, `catalog/page.tsx`, `ServiceTable.tsx`, `ServiceForm.tsx`, `quote-actions.ts`, `QuoteTab.tsx`) returned zero matches for: TODO, FIXME, placeholder, not implemented, `return null`, `return []`, `return {}`.
|
||||
|
||||
The three comment-level references to `quote_items` in `client-view.ts` are documentation comments (JSDoc block and inline comment), not functional code — confirmed by reading the file. No functional query to `quote_items` or `service_catalog` exists anywhere in `client-view.ts`.
|
||||
|
||||
---
|
||||
|
||||
### Human Verification Required
|
||||
|
||||
#### 1. Service Catalog CRUD — Persistence
|
||||
|
||||
**Test:** With dev server running, navigate to `/admin/catalog`. Click "+ Aggiungi servizio", fill in a name and price, click Aggiungi. Confirm the service appears. Click "Modifica", change the price, click Salva. Click "Disattiva" and confirm the row dims and badge changes. Refresh — confirm all three changes persisted.
|
||||
**Expected:** All changes survive page refresh (server-side revalidation via `revalidatePath("/admin/catalog")`).
|
||||
**Why human:** Requires live Neon DB connection; static analysis cannot verify that `drizzle-kit push` kept the DB schema in sync with schema.ts.
|
||||
|
||||
#### 2. Quote Builder — Catalog + Freeform Item Add
|
||||
|
||||
**Test:** Open a client detail page at `/admin/clients/[id]`, click the "Preventivo" tab. Select a service from the dropdown — confirm the unit_price field pre-fills. Add the item. Click "Oppure aggiungi voce libera", enter a custom name and price. Add the item. Confirm both rows appear in the table with correct subtotals and that "Totale calcolato" shows their sum.
|
||||
**Expected:** Catalog item stores a price snapshot (not re-fetched from service_catalog on display). Freeform item shows custom_label. Both subtotals are correct.
|
||||
**Why human:** Requires running app + DB. COALESCE label resolution (`COALESCE(service_catalog.name, quote_items.custom_label)`) can only be confirmed at query time.
|
||||
|
||||
#### 3. accepted_total Round-Trip to Client Dashboard
|
||||
|
||||
**Test:** In the Preventivo tab, set "Totale accettato dal cliente" to a specific value (e.g., 1500) and click Salva. Open the client dashboard at `/c/[token]` in a new browser tab. Confirm the dashboard shows €1.500,00 (or equivalent). Also confirm the Pagamenti tab in admin shows the same value.
|
||||
**Expected:** The value written to `clients.accepted_total` by `updateAcceptedTotal` appears on the client dashboard via `getClientView` which returns `accepted_total: client.accepted_total`.
|
||||
**Why human:** Round-trip data flow across admin write → client read requires a live session.
|
||||
|
||||
#### 4. Security: quote_items Never Exposed to Client
|
||||
|
||||
**Test:** Open the client dashboard `/c/[token]` in a browser. Open DevTools → Network. Reload the page. Inspect all XHR/fetch responses. Confirm no response body contains "quote_items", "service_id" (in a quote context), or individual per-service prices. Alternatively run: `curl http://localhost:3000/c/[token]` and inspect the HTML response.
|
||||
**Expected:** No quote item detail in any client-facing response. Only `accepted_total` value visible.
|
||||
**Why human:** Static analysis confirms the architecture (client-view.ts clean, API routes clean), but runtime confirmation is required per the CLAUDE.md security constraint and the 03-04 plan's Test E gate.
|
||||
|
||||
---
|
||||
|
||||
### Gaps Summary
|
||||
|
||||
No gaps found. All 13 must-have truths are verified. All artifacts exist and are substantive. All key links are wired. All data flows are connected to real DB queries. No anti-patterns found in phase-3 code. Requirements CAT-01, CAT-02, and ADMIN-03 are all satisfied.
|
||||
|
||||
The `human_needed` status reflects that 4 items require a running dev server + live database for final behavioral and security confirmation. These are standard end-to-end checks that cannot be performed via static analysis — they are not gaps in the implementation, but required human sign-off points consistent with the 03-04 plan's own human verification checkpoint.
|
||||
|
||||
---
|
||||
|
||||
_Verified: 2026-05-19T21:10:00Z_
|
||||
_Verifier: Claude (gsd-verifier)_
|
||||
Reference in New Issue
Block a user