docs(07): phase 7 planning complete — 2 plans (schema migration + admin crud)

Wave 1: 07-01-PLAN.md — Unified services table (audit trail, backfill, validation)
Wave 2: 07-02-PLAN.md — Admin catalog CRUD rewire (preserve historical references)

Ready for execution: /gsd-execute-phase 7

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-06-11 06:17:29 +02:00
parent bdcdd26f17
commit 23cb057f0b
65 changed files with 22681 additions and 0 deletions
@@ -0,0 +1,216 @@
---
phase: "03"
plan: "01"
type: execute
wave: 1
depends_on: []
files_modified:
- src/db/schema.ts
autonomous: true
requirements:
- CAT-01
- CAT-02
- ADMIN-03
must_haves:
truths:
- "quote_items.service_id is nullable in the database (free-form items can be inserted without a catalog reference)"
- "quote_items.custom_label column exists in the database (free-form label storage)"
- "TypeScript QuoteItem type reflects both changes (no compile errors when service_id is null or custom_label is set)"
- "drizzle-kit push completes without errors against the live Neon database"
artifacts:
- path: "src/db/schema.ts"
provides: "Updated quote_items table definition with nullable service_id and custom_label column"
contains: "custom_label: text(\"custom_label\")"
key_links:
- from: "src/db/schema.ts quote_items.service_id"
to: "Neon Postgres quote_items table"
via: "drizzle-kit push"
pattern: "service_id.*references.*service_catalog"
---
<objective>
Make the two schema changes required for free-form quote items — make `service_id` nullable and add `custom_label text` — then push the changes to the live Neon database.
Purpose: All subsequent plans (Wave 2) reference `custom_label` and insert rows with `service_id = null`. Without this push, the DB will reject those inserts with a column-not-found or NOT NULL constraint error.
Output: Updated `src/db/schema.ts` and a successful `drizzle-kit push` confirmation.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@/Users/simonecavalli/IAMCAVALLI/.planning/PROJECT.md
@/Users/simonecavalli/IAMCAVALLI/.planning/ROADMAP.md
<interfaces>
<!-- Current quote_items definition in src/db/schema.ts 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() // <-- REMOVE .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(),
// custom_label missing — ADD after subtotal
});
```
<!-- After changes, the definition must be: -->
```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")
.references(() => service_catalog.id, { onDelete: "restrict" }), // nullable — no .notNull()
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(),
custom_label: text("custom_label"), // new field
});
```
<!-- quoteItemsRelations also references service_id — the relation stays but the field is now optional: -->
```typescript
export const quoteItemsRelations = relations(quote_items, ({ one }) => ({
client: one(clients, { fields: [quote_items.client_id], references: [clients.id] }),
service: one(service_catalog, {
fields: [quote_items.service_id],
references: [service_catalog.id],
}),
}));
```
<!-- The relation definition does NOT need to change — Drizzle handles nullable FK relations correctly. -->
<!-- Inferred TypeScript type after change (auto-generated by Drizzle): -->
```typescript
export type QuoteItem = typeof quote_items.$inferSelect;
// QuoteItem.service_id will be: string | null
// QuoteItem.custom_label will be: string | null
```
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Update quote_items schema — make service_id nullable and add custom_label</name>
<read_first>
- /Users/simonecavalli/IAMCAVALLI/src/db/schema.ts (full file — understand current definition before editing)
</read_first>
<files>src/db/schema.ts</files>
<action>
Edit `src/db/schema.ts` — two targeted changes to the `quote_items` table definition (lines 159-172):
**Change 1 — Remove `.notNull()` from service_id (per D-03 from CONTEXT.md):**
Before:
```typescript
service_id: text("service_id")
.notNull()
.references(() => service_catalog.id, { onDelete: "restrict" }),
```
After:
```typescript
service_id: text("service_id")
.references(() => service_catalog.id, { onDelete: "restrict" }),
```
**Change 2 — Add custom_label field after the `subtotal` line:**
```typescript
custom_label: text("custom_label"),
```
No other changes to the file. The `quoteItemsRelations` block does NOT need to change.
After the edit, run `npx tsc --noEmit` to confirm zero TypeScript errors before pushing.
</action>
<verify>
<automated>cd /Users/simonecavalli/IAMCAVALLI && grep -v '^//' src/db/schema.ts | grep -c 'custom_label: text("custom_label")'</automated>
Expected: 1
<automated>cd /Users/simonecavalli/IAMCAVALLI && grep -A3 'service_id: text("service_id")' src/db/schema.ts | grep -c 'notNull'</automated>
Expected: 0 (notNull must be gone from service_id)
<automated>cd /Users/simonecavalli/IAMCAVALLI && npx tsc --noEmit 2>&1 | head -20</automated>
Expected: no output (zero errors)
</verify>
<done>
`src/db/schema.ts` compiles with zero TypeScript errors. `service_id` has no `.notNull()`. `custom_label: text("custom_label")` is present in the quote_items table definition.
</done>
</task>
<task type="auto">
<name>Task 2: [BLOCKING] Push schema changes to Neon database</name>
<read_first>
- /Users/simonecavalli/IAMCAVALLI/.env.local (verify DATABASE_URL is set before running push)
- /Users/simonecavalli/IAMCAVALLI/drizzle.config.ts (verify push config points to correct schema)
</read_first>
<files>— (no source files modified; runs drizzle-kit against live DB)</files>
<action>
Run drizzle-kit push with the .env.local DATABASE_URL loaded:
```bash
cd /Users/simonecavalli/IAMCAVALLI
set -a && source .env.local && set +a && npx drizzle-kit push
```
When prompted to confirm schema changes, accept all changes. The push will:
1. DROP NOT NULL constraint from `quote_items.service_id`
2. ADD COLUMN `custom_label text` to `quote_items`
If the push fails with "column already exists" for `custom_label`, the column was already added in a prior run — this is safe to ignore. Verify the column exists by checking the push output or running a quick query.
Do NOT skip this task. Wave 2 plans cannot execute correctly without the DB columns existing.
</action>
<verify>
<automated>cd /Users/simonecavalli/IAMCAVALLI && set -a && source .env.local && set +a && npx drizzle-kit push 2>&1 | tail -5</automated>
Expected: Output contains "No changes" or "Changes applied" — either confirms the schema is in sync.
</verify>
<done>
`drizzle-kit push` exits without error. The live Neon DB has `quote_items.service_id` as nullable and `quote_items.custom_label text` column present.
</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Schema file → Neon DB | drizzle-kit push executes DDL against the live database; misconfigured DATABASE_URL would push to wrong environment |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-03-01-01 | Tampering | drizzle-kit push | mitigate | Always load DATABASE_URL from `.env.local` (not hardcoded); verify `.env.local` exists before running push |
| T-03-01-02 | Denial of Service | Neon DB DDL | accept | Schema changes are additive (ADD COLUMN, DROP NOT NULL) — no data loss risk; onDelete: "restrict" prevents orphaned quote_items |
</threat_model>
<verification>
After both tasks complete:
1. `grep 'custom_label' src/db/schema.ts` returns the field definition
2. `grep -A3 'service_id: text' src/db/schema.ts` shows no `.notNull()` on service_id
3. `npx tsc --noEmit` exits 0
4. `npx drizzle-kit push` reports "No changes" (schema is in sync with DB)
</verification>
<success_criteria>
- `src/db/schema.ts` has nullable `service_id` and `custom_label: text("custom_label")` in quote_items
- TypeScript compiles with zero errors
- drizzle-kit push confirms schema is synced to Neon DB
- Wave 2 plans can safely reference `custom_label` and insert rows with `service_id = null`
</success_criteria>
<output>
After completion, create `.planning/phases/03-service-catalog-quote-builder/03-01-SUMMARY.md`
</output>
@@ -0,0 +1,125 @@
---
phase: 03-service-catalog-quote-builder
plan: "01"
subsystem: database
tags: [drizzle, postgres, neon, schema, quote_items]
# Dependency graph
requires:
- phase: 02-admin-area-interactive-features
provides: quote_items table with service_catalog FK already in place
provides:
- quote_items.service_id nullable (allows free-form items without catalog reference)
- quote_items.custom_label text column (stores free-form item label)
- Live Neon DB schema synced — Wave 2 plans can safely insert rows with service_id = null
affects:
- 03-02 (service catalog CRUD — reads quote_items with custom_label)
- 03-03 (quote builder — inserts rows with service_id null and custom_label)
- 03-04 (quote display — renders custom_label for free-form items)
# Tech tracking
tech-stack:
added: []
patterns:
- "Drizzle nullable FK: omit .notNull() from FK column to allow null — relation definition unchanged"
key-files:
created: []
modified:
- src/db/schema.ts
key-decisions:
- "service_id remains as FK reference (onDelete: restrict) but is now nullable — Drizzle handles nullable FK relations correctly, no changes to quoteItemsRelations needed"
- "custom_label is plain text (no notNull) — free-form items set it, catalog-linked items leave it null"
patterns-established:
- "Nullable FK pattern: .references(...) without .notNull() — Drizzle infers string | null type automatically"
requirements-completed:
- CAT-01
- CAT-02
- ADMIN-03
# Metrics
duration: 3min
completed: 2026-05-17
---
# Phase 03 Plan 01: Schema — quote_items Nullable service_id + custom_label Summary
**Added `custom_label text` column and made `service_id` nullable in `quote_items` via Drizzle schema edit + Neon DDL push — unblocking Wave 2 free-form quote items**
## Performance
- **Duration:** ~3 min
- **Started:** 2026-05-17T09:35:00Z
- **Completed:** 2026-05-17T09:37:38Z
- **Tasks:** 2
- **Files modified:** 1 (src/db/schema.ts)
## Accomplishments
- Removed `.notNull()` from `quote_items.service_id` — field is now `string | null` in TypeScript
- Added `custom_label: text("custom_label")` column to `quote_items` table definition
- Executed `drizzle-kit push` against live Neon DB — changes applied, second run confirmed "No changes detected"
## Task Commits
Each task was committed atomically:
1. **Task 1: Update quote_items schema — make service_id nullable and add custom_label** - `9ddb699` (feat)
2. **Task 2: Push schema changes to Neon database** — no file commit (DB-only DDL operation; verified via `drizzle-kit push` output)
**Plan metadata:** committed with SUMMARY.md
## Files Created/Modified
- `src/db/schema.ts` — Removed `.notNull()` from `service_id`, added `custom_label: text("custom_label")` after `subtotal` in quote_items table
## Decisions Made
- `quoteItemsRelations` block was left unchanged — Drizzle handles nullable FK relations without requiring changes to the relation definition
- No migration file generated (using `drizzle-kit push` mode, not `drizzle-kit generate` + migrate)
## Deviations from Plan
### Auto-fixed Issues
**1. [Rule 3 - Blocking] Edited worktree file instead of main repo file**
- **Found during:** Task 1
- **Issue:** Initial edit went to `/Users/simonecavalli/IAMCAVALLI/src/db/schema.ts` (main repo) instead of the worktree copy at `src/db/schema.ts` relative to worktree root
- **Fix:** Applied same edits to the correct worktree file; reverted accidental main-repo edit via `git checkout -- src/db/schema.ts` in the main repo
- **Files modified:** worktree `src/db/schema.ts` (correct), main repo reverted to HEAD
- **Verification:** `git status` in worktree showed only `src/db/schema.ts` modified; main repo schema unchanged
- **Committed in:** `9ddb699` (Task 1 commit)
---
**Total deviations:** 1 auto-fixed (1 path confusion / blocking)
**Impact on plan:** Fix was immediate and required. No scope creep. End result is identical to plan spec.
## Issues Encountered
- Worktree path confusion: the `read_first` directive in the plan pointed to `/Users/simonecavalli/IAMCAVALLI/src/db/schema.ts` (absolute main-repo path), and the initial edit went there instead of the worktree copy. Caught immediately by checking `git status --short` in the worktree before committing.
## User Setup Required
None - no external service configuration required.
## Next Phase Readiness
- Wave 2 plans (03-02, 03-03, 03-04) can now safely reference `quote_items.custom_label` and insert rows with `service_id = null`
- No blockers or concerns
## Self-Check: PASSED
- FOUND: `src/db/schema.ts` — correct worktree file with both changes
- FOUND: `03-01-SUMMARY.md`
- FOUND: commit `9ddb699` (Task 1)
- FOUND: `custom_label: text("custom_label")` in schema
- OK: `service_id` has no `.notNull()` (grep -A2 shows `.references(...)` then `quantity.notNull()` — the notNull is on quantity, not service_id — confirmed correct)
---
*Phase: 03-service-catalog-quote-builder*
*Completed: 2026-05-17*
@@ -0,0 +1,647 @@
---
phase: "03"
plan: "02"
type: execute
wave: 2
depends_on:
- "03-01"
files_modified:
- src/app/admin/catalog/page.tsx
- src/app/admin/catalog/actions.ts
- src/components/admin/catalog/ServiceTable.tsx
- src/components/admin/catalog/ServiceForm.tsx
- src/components/admin/NavBar.tsx
autonomous: true
requirements:
- CAT-01
must_haves:
truths:
- "Admin can navigate to /admin/catalog from the NavBar ('Catalogo' link visible between Statistiche and Esci)"
- "Admin can see a table of all services with columns Nome | Descrizione | Prezzo | Stato | Azioni"
- "Admin can add a new service via an inline form (name, optional description, unit price) — it appears in the table after save"
- "Admin can click 'Modifica' on a row and edit name, description, price inline — changes persist after save"
- "Admin can click 'Disattiva' to soft-delete a service (active=false) — row shows 'Disattivato' badge at 50% opacity"
- "Admin can click 'Riattiva' on a disabled service to re-enable it"
- "Inactive services remain visible in the table (with badge) but are excluded from the quote builder dropdown"
artifacts:
- path: "src/app/admin/catalog/page.tsx"
provides: "Service catalog page — server component, fetches all services, renders table"
contains: "getAllServices"
- path: "src/app/admin/catalog/actions.ts"
provides: "Server Actions: createService, updateService, toggleServiceActive"
exports: ["createService", "updateService", "toggleServiceActive"]
- path: "src/components/admin/catalog/ServiceTable.tsx"
provides: "Table with per-row inline edit and active toggle"
contains: "ServiceTable"
- path: "src/components/admin/catalog/ServiceForm.tsx"
provides: "Add-new-service form rendered above table"
contains: "ServiceForm"
- path: "src/components/admin/NavBar.tsx"
provides: "NavBar with Catalogo link added"
contains: "/admin/catalog"
key_links:
- from: "src/components/admin/catalog/ServiceForm.tsx"
to: "src/app/admin/catalog/actions.ts createService"
via: "form action"
pattern: "createService"
- from: "src/components/admin/catalog/ServiceTable.tsx"
to: "src/app/admin/catalog/actions.ts updateService / toggleServiceActive"
via: "Server Action calls in useTransition"
pattern: "updateService|toggleServiceActive"
- from: "src/app/admin/catalog/page.tsx"
to: "src/lib/admin-queries.ts getAllServices (new function)"
via: "await getAllServices()"
pattern: "getAllServices"
---
<objective>
Deliver the complete `/admin/catalog` page: NavBar link, page layout, table with inline edit, add-service form, and soft-delete toggle. This is a self-contained vertical slice — after this plan executes, the admin can manage the service catalog end-to-end.
Purpose: Fulfills CAT-01 (service database with prices). Provides the catalog data that Wave 2's Quote Builder (plan 03-03) will query for its dropdown.
Output: 5 new/modified files — a fully functional service catalog page.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@/Users/simonecavalli/IAMCAVALLI/.planning/ROADMAP.md
@/Users/simonecavalli/IAMCAVALLI/.planning/phases/03-service-catalog-quote-builder/03-01-SUMMARY.md
<interfaces>
<!-- Analog: src/app/admin/page.tsx — follow this exact page structure -->
```typescript
// Server component, fetches data, renders table + header
export default async function AdminDashboard() {
const clients = await getAllClientsWithPayments();
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 */}
</div>
);
}
```
<!-- Analog: src/components/admin/DocumentRow.tsx — follow this inline edit pattern exactly -->
```typescript
"use client";
import { useState, useTransition } from "react";
import { useRouter } from "next/navigation";
export function DocumentRow({ doc, clientId }) {
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");
}
});
}
// ...
}
```
<!-- Analog: src/app/admin/clients/[id]/actions.ts — Zod validation pattern -->
```typescript
"use server";
import { z } from "zod";
import { db } from "@/db";
import { revalidatePath } from "next/cache";
const clientSchema = z.object({
name: z.string().min(1, "Nome richiesto"),
brand_name: z.string().min(1, "Brand name richiesto"),
brief: z.string(),
});
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");
}
```
<!-- NavBar current structure — add Catalogo link after Statistiche -->
```typescript
// src/components/admin/NavBar.tsx lines 7-29
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>
{/* ADD HERE: */}
{/* <Link href="/admin/catalog" className="text-sm text-white/70 hover:text-white transition-colors">Catalogo</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>
);
}
```
<!-- Table + card styling from existing admin UI -->
```typescript
// Table container
<div className="bg-white rounded-xl 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>
<tr className="border-b border-[#f4f4f5] hover:bg-[#f9f9f9]">
<td className="py-3 px-4">...</td>
</tr>
</tbody>
</table>
</div>
// Status badge — active
<span className="text-xs font-medium bg-[#1A463C]/10 text-[#1A463C] px-2 py-0.5 rounded-full">Attivo</span>
// Status badge — inactive
<span className="text-xs font-medium bg-[#f4f4f5] text-[#71717a] px-2 py-0.5 rounded-full">Disattivato</span>
// Currency display
{parseFloat(amount).toLocaleString("it-IT", { minimumFractionDigits: 2 })}
```
<!-- ServiceCatalog type (auto-generated from schema.ts) -->
```typescript
export type ServiceCatalog = typeof service_catalog.$inferSelect;
// Fields: id: string, name: string, description: string | null, unit_price: string, active: boolean
```
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Server Actions + getAllServices query</name>
<read_first>
- /Users/simonecavalli/IAMCAVALLI/src/app/admin/clients/[id]/actions.ts (Zod + Server Action pattern to replicate)
- /Users/simonecavalli/IAMCAVALLI/src/lib/admin-queries.ts (add getAllServices here, following existing function style)
- /Users/simonecavalli/IAMCAVALLI/src/db/schema.ts (confirm service_catalog fields after 03-01 changes)
</read_first>
<files>
src/app/admin/catalog/actions.ts
src/lib/admin-queries.ts
</files>
<action>
**Create `src/app/admin/catalog/actions.ts`** — three Server Actions following exact Zod+FormData pattern from `clients/[id]/actions.ts`:
```typescript
"use server";
import { db } from "@/db";
import { service_catalog } from "@/db/schema";
import { revalidatePath } from "next/cache";
import { eq } from "drizzle-orm";
import { z } from "zod";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
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"),
});
async function requireAdmin() {
const session = await getServerSession(authOptions);
if (!session) throw new Error("Non autorizzato");
}
export async function createService(formData: FormData) {
await requireAdmin();
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({
name: parsed.data.name,
description: parsed.data.description ?? null,
unit_price: parsed.data.unit_price.toFixed(2),
});
revalidatePath("/admin/catalog");
}
export async function updateService(serviceId: string, formData: FormData) {
await requireAdmin();
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({
name: parsed.data.name,
description: parsed.data.description ?? null,
unit_price: parsed.data.unit_price.toFixed(2),
})
.where(eq(service_catalog.id, serviceId));
revalidatePath("/admin/catalog");
}
export async function toggleServiceActive(serviceId: string, active: boolean) {
await requireAdmin();
await db
.update(service_catalog)
.set({ active })
.where(eq(service_catalog.id, serviceId));
revalidatePath("/admin/catalog");
}
```
**Add `getAllServices()` to `src/lib/admin-queries.ts`** — append at end of file before the closing exports:
```typescript
export async function getAllServices(): Promise<ServiceCatalog[]> {
return db
.select()
.from(service_catalog)
.orderBy(asc(service_catalog.name));
}
```
Also add `service_catalog` to the imports at top of admin-queries.ts, and `ServiceCatalog` to the type imports. Add `asc` if not already imported from `drizzle-orm`.
</action>
<verify>
<automated>cd /Users/simonecavalli/IAMCAVALLI && grep -c 'export async function createService' src/app/admin/catalog/actions.ts</automated>
Expected: 1
<automated>cd /Users/simonecavalli/IAMCAVALLI && grep -c 'export async function updateService' src/app/admin/catalog/actions.ts</automated>
Expected: 1
<automated>cd /Users/simonecavalli/IAMCAVALLI && grep -c 'export async function toggleServiceActive' src/app/admin/catalog/actions.ts</automated>
Expected: 1
<automated>cd /Users/simonecavalli/IAMCAVALLI && grep -c 'export async function getAllServices' src/lib/admin-queries.ts</automated>
Expected: 1
<automated>cd /Users/simonecavalli/IAMCAVALLI && npx tsc --noEmit 2>&1 | head -20</automated>
Expected: no output (zero errors)
</verify>
<done>
Three Server Actions exported from `catalog/actions.ts`. `getAllServices()` added to `admin-queries.ts`. TypeScript compiles clean.
</done>
</task>
<task type="auto">
<name>Task 2: Service Catalog page + components (ServiceTable, ServiceForm) + NavBar link</name>
<read_first>
- /Users/simonecavalli/IAMCAVALLI/src/app/admin/page.tsx (page structure to mirror)
- /Users/simonecavalli/IAMCAVALLI/src/components/admin/DocumentRow.tsx (inline edit pattern to replicate)
- /Users/simonecavalli/IAMCAVALLI/src/components/admin/NavBar.tsx (current NavBar to add Catalogo link)
- /Users/simonecavalli/IAMCAVALLI/src/app/admin/catalog/actions.ts (actions just created in Task 1)
</read_first>
<files>
src/app/admin/catalog/page.tsx
src/components/admin/catalog/ServiceTable.tsx
src/components/admin/catalog/ServiceForm.tsx
src/components/admin/NavBar.tsx
</files>
<action>
**Create `src/app/admin/catalog/page.tsx`** — Server Component mirroring `src/app/admin/page.tsx`:
```typescript
import { getAllServices } from "@/lib/admin-queries";
import { ServiceTable } from "@/components/admin/catalog/ServiceTable";
import { ServiceForm } from "@/components/admin/catalog/ServiceForm";
export const revalidate = 0;
export default async function CatalogPage() {
const services = await getAllServices();
return (
<div>
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold text-[#1a1a1a]">Catalogo Servizi</h1>
</div>
<div className="mb-6">
<ServiceForm />
</div>
{services.length === 0 ? (
<p className="text-sm text-[#71717a]">
Nessun servizio nel catalogo. Aggiungi il primo servizio qui sopra.
</p>
) : (
<ServiceTable services={services} />
)}
</div>
);
}
```
**Create `src/components/admin/catalog/ServiceForm.tsx`** — inline add-new-service form using Server Action:
```typescript
"use client";
import { useRef, useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { createService } from "@/app/admin/catalog/actions";
export function ServiceForm() {
const [open, setOpen] = useState(false);
const [error, setError] = useState<string | null>(null);
const [, startTransition] = useTransition();
const router = useRouter();
const formRef = useRef<HTMLFormElement>(null);
function handleSubmit(fd: FormData) {
setError(null);
startTransition(async () => {
try {
await createService(fd);
formRef.current?.reset();
setOpen(false);
router.refresh();
} catch (e) {
setError(e instanceof Error ? e.message : "Errore nel salvataggio");
}
});
}
if (!open) {
return (
<Button
onClick={() => setOpen(true)}
className="bg-[#1A463C] text-white hover:bg-[#1A463C]/90"
>
+ Aggiungi servizio
</Button>
);
}
return (
<div className="bg-white border border-[#e5e7eb] rounded-xl p-4 space-y-4">
<h3 className="font-medium text-[#1a1a1a]">Nuovo servizio</h3>
<form ref={formRef} action={handleSubmit} className="space-y-3">
<div className="space-y-1">
<Label htmlFor="name">Nome</Label>
<Input id="name" name="name" placeholder="es. Strategia di brand" required />
</div>
<div className="space-y-1">
<Label htmlFor="description">Descrizione (opzionale)</Label>
<Input id="description" name="description" placeholder="es. Incluso: analisi competitor, posizionamento" />
</div>
<div className="space-y-1">
<Label htmlFor="unit_price">Prezzo unitario ()</Label>
<Input
id="unit_price"
name="unit_price"
type="number"
step="0.01"
min="0.01"
placeholder="0.00"
required
/>
</div>
{error && <p className="text-xs text-red-600">{error}</p>}
<div className="flex gap-2">
<Button type="submit" size="sm" className="bg-[#1A463C] text-white hover:bg-[#1A463C]/90">
Aggiungi
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => { setOpen(false); setError(null); }}
>
Annulla
</Button>
</div>
</form>
</div>
);
}
```
**Create `src/components/admin/catalog/ServiceTable.tsx`** — table with per-row inline edit, following DocumentRow pattern:
```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 { Label } from "@/components/ui/label";
import { updateService, toggleServiceActive } from "@/app/admin/catalog/actions";
import type { ServiceCatalog } from "@/db/schema";
function ServiceRow({ service }: { service: ServiceCatalog }) {
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 updateService(service.id, fd);
setEditing(false);
router.refresh();
} catch (e) {
setError(e instanceof Error ? e.message : "Errore nel salvataggio");
}
});
}
function handleToggle() {
startTransition(async () => {
await toggleServiceActive(service.id, !service.active);
router.refresh();
});
}
if (editing) {
return (
<tr>
<td colSpan={5} className="px-4 py-3">
<form action={handleSave} className="space-y-2 bg-[#f9f9f9] rounded-lg p-3 border border-[#1A463C]/20">
<div className="flex gap-3 flex-wrap">
<div className="flex-1 min-w-[140px] space-y-1">
<Label htmlFor={`name-${service.id}`}>Nome</Label>
<Input id={`name-${service.id}`} name="name" defaultValue={service.name} required />
</div>
<div className="flex-[2] min-w-[180px] space-y-1">
<Label htmlFor={`desc-${service.id}`}>Descrizione</Label>
<Input id={`desc-${service.id}`} name="description" defaultValue={service.description ?? ""} />
</div>
<div className="w-28 space-y-1">
<Label htmlFor={`price-${service.id}`}>Prezzo ()</Label>
<Input
id={`price-${service.id}`}
name="unit_price"
type="number"
step="0.01"
min="0.01"
defaultValue={parseFloat(service.unit_price).toFixed(2)}
required
/>
</div>
</div>
{error && <p className="text-xs text-red-600">{error}</p>}
<div className="flex gap-2">
<Button type="submit" size="sm" className="bg-[#1A463C] text-white hover:bg-[#1A463C]/90">Salva</Button>
<Button type="button" variant="ghost" size="sm" onClick={() => { setEditing(false); setError(null); }}>Annulla</Button>
</div>
</form>
</td>
</tr>
);
}
return (
<tr className={`border-b border-[#f4f4f5] hover:bg-[#f9f9f9] transition-colors ${!service.active ? "opacity-50" : ""}`}>
<td className="py-3 px-4 font-medium text-[#1a1a1a]">{service.name}</td>
<td className="py-3 px-4 text-[#71717a] text-sm max-w-xs truncate">{service.description ?? "—"}</td>
<td className="py-3 px-4 tabular-nums font-mono">
{parseFloat(service.unit_price).toLocaleString("it-IT", { minimumFractionDigits: 2 })}
</td>
<td className="py-3 px-4">
{service.active ? (
<span className="text-xs font-medium bg-[#1A463C]/10 text-[#1A463C] px-2 py-0.5 rounded-full">Attivo</span>
) : (
<span className="text-xs font-medium bg-[#f4f4f5] text-[#71717a] px-2 py-0.5 rounded-full">Disattivato</span>
)}
</td>
<td className="py-3 px-4 text-right">
<div className="flex items-center justify-end gap-2">
<Button variant="ghost" size="sm" onClick={() => setEditing(true)}>Modifica</Button>
<Button variant="ghost" size="sm" onClick={handleToggle}>
{service.active ? "Disattiva" : "Riattiva"}
</Button>
</div>
</td>
</tr>
);
}
export function ServiceTable({ services }: { services: ServiceCatalog[] }) {
return (
<div className="bg-white rounded-xl 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]">Nome</th>
<th className="text-left py-3 px-4 font-medium text-[#71717a]">Descrizione</th>
<th className="text-left py-3 px-4 font-medium text-[#71717a]">Prezzo</th>
<th className="text-left py-3 px-4 font-medium text-[#71717a]">Stato</th>
<th className="py-3 px-4"></th>
</tr>
</thead>
<tbody>
{services.map((s) => (
<ServiceRow key={s.id} service={s} />
))}
</tbody>
</table>
</div>
);
}
```
**Modify `src/components/admin/NavBar.tsx`** — add Catalogo link after the Statistiche link:
```typescript
<Link href="/admin/catalog" className="text-sm text-white/70 hover:text-white transition-colors">
Catalogo
</Link>
```
Insert this line immediately after the existing `<Link href="/admin/analytics" ...>Statistiche</Link>` line.
</action>
<verify>
<automated>cd /Users/simonecavalli/IAMCAVALLI && grep -c '/admin/catalog' src/components/admin/NavBar.tsx</automated>
Expected: 1
<automated>cd /Users/simonecavalli/IAMCAVALLI && grep -c 'export function ServiceTable' src/components/admin/catalog/ServiceTable.tsx</automated>
Expected: 1
<automated>cd /Users/simonecavalli/IAMCAVALLI && grep -c 'export function ServiceForm' src/components/admin/catalog/ServiceForm.tsx</automated>
Expected: 1
<automated>cd /Users/simonecavalli/IAMCAVALLI && grep -c 'getAllServices' src/app/admin/catalog/page.tsx</automated>
Expected: 1
<automated>cd /Users/simonecavalli/IAMCAVALLI && npx tsc --noEmit 2>&1 | head -20</automated>
Expected: no output (zero errors)
<automated>cd /Users/simonecavalli/IAMCAVALLI && npm run build 2>&1 | tail -10</automated>
Expected: "Compiled successfully" or "Route (app)" output with no errors
</verify>
<done>
NavBar shows "Catalogo" link. `/admin/catalog` page renders. ServiceTable and ServiceForm compile. Full `npm run build` passes. Admin can navigate to `/admin/catalog` and see the table.
</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Admin browser → Server Actions (catalog/actions.ts) | FormData from admin form crosses to server; must be validated before DB write |
| /admin/catalog route → Auth.js session | All catalog routes inherit the `/admin/*` middleware session check from Phase 2; no additional guard needed at page level |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-03-02-01 | Spoofing | createService / updateService / toggleServiceActive | mitigate | `requireAdmin()` calls `getServerSession(authOptions)` at the top of every Server Action — rejects if no valid session |
| T-03-02-02 | Tampering | serviceSchema Zod validation | mitigate | `unit_price` validated as `z.coerce.number().min(0.01)` — prevents zero/negative prices; `name` requires min length 1 |
| T-03-02-03 | Tampering | updateService serviceId parameter | mitigate | serviceId is bound at call site in the Server Action closure — admin can only modify the row ID passed from the server-rendered page |
| T-03-02-04 | Information Disclosure | /admin/catalog page | accept | Page is behind Auth.js `/admin/*` middleware (enforced in Phase 2); service prices are admin-internal data, not client-facing |
| T-03-02-05 | Tampering | XSS in service name / description | accept | React JSX auto-escapes all string output; no `dangerouslySetInnerHTML` used; UI-SPEC forbids it |
</threat_model>
<verification>
After both tasks complete:
1. `grep '/admin/catalog' src/components/admin/NavBar.tsx` returns 1 match
2. `npx tsc --noEmit` exits clean
3. `npm run build` succeeds
4. Navigating to `/admin/catalog` (dev server) shows the catalog page with table headers and "Aggiungi servizio" button
5. Adding a service via the form makes it appear in the table
6. Clicking "Disattiva" changes badge to "Disattivato" and reduces row opacity
</verification>
<success_criteria>
- `/admin/catalog` route is accessible from NavBar and renders without error
- All three Server Actions (createService, updateService, toggleServiceActive) are exported from `catalog/actions.ts` with Zod validation and `requireAdmin()` guard
- ServiceTable renders per-row inline edit using the DocumentRow pattern
- Inactive services show "Disattivato" badge; active services show "Attivo" badge
- TypeScript and build both pass clean
</success_criteria>
<output>
After completion, create `.planning/phases/03-service-catalog-quote-builder/03-02-SUMMARY.md`
</output>
@@ -0,0 +1,143 @@
---
phase: 03-service-catalog-quote-builder
plan: "02"
subsystem: admin-ui
tags: [catalog, server-actions, drizzle, zod, nextjs, tailwind]
# Dependency graph
requires:
- phase: 03-service-catalog-quote-builder
plan: "01"
provides: service_catalog table + ServiceCatalog type in schema.ts
provides:
- /admin/catalog route: fully functional service catalog CRUD page
- createService server action (with requireAdmin + Zod validation)
- updateService server action (with requireAdmin + Zod validation)
- toggleServiceActive server action (with requireAdmin guard)
- getAllServices() query in admin-queries.ts
- NavBar Catalogo link
affects:
- 03-03 (quote builder — consumes getAllServices() for active services dropdown)
# Tech tracking
tech-stack:
added: []
patterns:
- "Server Action + requireAdmin() guard: getServerSession(authOptions) at top of every action"
- "Zod coerce.number for unit_price: z.coerce.number().min(0.01)"
- "Inline edit pattern: useTransition + form action + router.refresh() (mirrors DocumentRow.tsx)"
- "Soft-delete visibility: opacity-50 on inactive rows; badge Disattivato/Attivo"
key-files:
created:
- src/app/admin/catalog/actions.ts
- src/app/admin/catalog/page.tsx
- src/components/admin/catalog/ServiceTable.tsx
- src/components/admin/catalog/ServiceForm.tsx
modified:
- src/lib/admin-queries.ts
- src/components/admin/NavBar.tsx
key-decisions:
- "requireAdmin() added to all three Server Actions — enforces session check even though /admin/* middleware protects the route (defense in depth for T-03-02-01)"
- "unit_price stored as .toFixed(2) string in DB (numeric column) — consistent with existing payments/quote_items pattern"
- "Inactive services remain visible in table at opacity-50 — filtering for quote builder dropdown happens in 03-03 query"
# Metrics
duration: 15min
completed: 2026-05-17
---
# Phase 03 Plan 02: Service Catalog CRUD UI Summary
**Vertical slice completo `/admin/catalog`: NavBar link + pagina catalogo + tabella con edit inline + form aggiunta servizio + soft-delete toggle, con Server Actions protetti da Zod e requireAdmin()**
## Performance
- **Duration:** ~15 min
- **Started:** 2026-05-17T09:40:00Z
- **Completed:** 2026-05-17T09:55:00Z
- **Tasks:** 2
- **Files created:** 4 | **Files modified:** 2
## Accomplishments
- Creato `src/app/admin/catalog/actions.ts` con tre Server Actions (`createService`, `updateService`, `toggleServiceActive`) — ogni action chiama `requireAdmin()` e valida i dati via Zod
- Aggiunto `getAllServices()` a `src/lib/admin-queries.ts` con import di `service_catalog` e tipo `ServiceCatalog`
- Creato `src/app/admin/catalog/page.tsx` — Server Component che carica tutti i servizi e renderizza `ServiceForm` + `ServiceTable`
- Creato `src/components/admin/catalog/ServiceForm.tsx` — form add-new con toggle open/closed, useTransition, gestione errori
- Creato `src/components/admin/catalog/ServiceTable.tsx` — tabella con per-row inline edit (pattern DocumentRow), badge Attivo/Disattivato, opacity-50 per servizi inattivi
- Aggiunto link "Catalogo" in `src/components/admin/NavBar.tsx` tra Statistiche e Esci
- TypeScript clean (zero errori), `npm run build` compilato con successo
## Task Commits
| Task | Nome | Commit | File |
|------|------|--------|------|
| 1 | Server Actions + getAllServices query | `efbc235` | src/app/admin/catalog/actions.ts, src/lib/admin-queries.ts |
| 2 | Catalog page + components + NavBar link | `4aae2e0` | src/app/admin/catalog/page.tsx, src/components/admin/catalog/ServiceTable.tsx, src/components/admin/catalog/ServiceForm.tsx, src/components/admin/NavBar.tsx |
## Files Created/Modified
**Creati:**
- `src/app/admin/catalog/actions.ts` — tre Server Actions con requireAdmin() + Zod
- `src/app/admin/catalog/page.tsx` — Server Component per il catalogo
- `src/components/admin/catalog/ServiceTable.tsx` — tabella + inline edit per riga
- `src/components/admin/catalog/ServiceForm.tsx` — form aggiunta nuovo servizio
**Modificati:**
- `src/lib/admin-queries.ts` — aggiunto `getAllServices()`, import `service_catalog` e `ServiceCatalog`
- `src/components/admin/NavBar.tsx` — aggiunto link Catalogo dopo Statistiche
## Decisions Made
- `requireAdmin()` presente in ogni Server Action anche se `/admin/*` è già protetto da middleware — defense in depth per T-03-02-01
- `unit_price` salvato come `.toFixed(2)` string in campo `numeric` — coerente con pattern pagamenti e quote_items già presenti
- I servizi inattivi rimangono visibili in tabella con opacity-50 — il filtro `active = true` per il dropdown del Quote Builder sarà nella query di 03-03
## Deviations from Plan
### Auto-fixed Issues
**1. [Rule 3 - Blocking] Mancanza DATABASE_URL nel worktree per il build**
- **Found during:** Task 2 verifica `npm run build`
- **Issue:** Il worktree non aveva `.env.local` — il build falliva con "DATABASE_URL env var is required"
- **Fix:** Symlink di `/Users/simonecavalli/IAMCAVALLI/.env.local` nel worktree root
- **Files modified:** nessun file sorgente — solo symlink di configurazione
- **Commit:** nessun commit aggiuntivo (symlink non tracciato in git)
---
**Total deviations:** 1 auto-fixed (1 ambiente/blocking)
**Impact on plan:** Fix immediato, nessuno scope creep. Il build finale è compilato con successo.
## Known Stubs
Nessuno — tutti i componenti leggono dati reali dal DB via Server Actions e query Drizzle.
## Threat Surface Scan
Nessuna nuova superficie di sicurezza introdotta oltre a quanto già coperto dal threat model del piano:
- T-03-02-01: `requireAdmin()` implementato in tutti e tre i Server Actions (mitigato)
- T-03-02-02: Zod `unit_price: z.coerce.number().min(0.01)` implementato (mitigato)
- T-03-02-03: `serviceId` bound a livello di Server Action (mitigato)
- T-03-02-04: Rotta sotto middleware Auth.js `/admin/*` (accettato)
- T-03-02-05: Nessun `dangerouslySetInnerHTML`, JSX auto-escape (accettato)
## Self-Check: PASSED
- FOUND: `src/app/admin/catalog/actions.ts`
- FOUND: `src/app/admin/catalog/page.tsx`
- FOUND: `src/components/admin/catalog/ServiceTable.tsx`
- FOUND: `src/components/admin/catalog/ServiceForm.tsx`
- FOUND: `getAllServices` in `src/lib/admin-queries.ts`
- FOUND: `/admin/catalog` in `src/components/admin/NavBar.tsx`
- FOUND: commit `efbc235` (Task 1)
- FOUND: commit `4aae2e0` (Task 2)
- OK: TypeScript compila senza errori
- OK: `npm run build` — "Compiled successfully"
---
*Phase: 03-service-catalog-quote-builder*
*Completed: 2026-05-17*
@@ -0,0 +1,725 @@
---
phase: "03"
plan: "03"
type: execute
wave: 2
depends_on:
- "03-01"
files_modified:
- src/app/admin/clients/[id]/quote-actions.ts
- src/components/admin/tabs/QuoteTab.tsx
- src/app/admin/clients/[id]/page.tsx
- src/lib/admin-queries.ts
autonomous: true
requirements:
- CAT-02
- ADMIN-03
must_haves:
truths:
- "Admin can see a 'Preventivo' tab in /admin/clients/[id] — the 5th tab after Commenti"
- "Admin can select an active catalog service from a dropdown and add it (with qty) to the quote — the item appears in the table with snapshotted unit_price"
- "Admin can toggle to 'Voce libera' mode and add a custom label + price + qty item (service_id = null in DB)"
- "Admin can click 'Rimuovi' to delete a quote item — it disappears from the table"
- "The table footer shows 'Totale calcolato' as the sum of all subtotals"
- "Admin can set a separate 'Totale accettato dal cliente' via an editable input + Salva button — this writes to clients.accepted_total"
- "quote_items are NEVER returned by any client-facing route — only clients.accepted_total is visible to clients"
artifacts:
- path: "src/app/admin/clients/[id]/quote-actions.ts"
provides: "Server Actions: addQuoteItem, removeQuoteItem, updateAcceptedTotal"
exports: ["addQuoteItem", "removeQuoteItem", "updateAcceptedTotal"]
- path: "src/components/admin/tabs/QuoteTab.tsx"
provides: "Quote builder UI — add items (catalog + freeform), items table, accepted total editor"
contains: "QuoteTab"
- path: "src/app/admin/clients/[id]/page.tsx"
provides: "Client detail page with 5th Preventivo tab wired to QuoteTab"
contains: "Preventivo"
- path: "src/lib/admin-queries.ts"
provides: "getClientFullDetail extended to include quoteItems and activeServices"
contains: "quoteItems"
key_links:
- from: "src/components/admin/tabs/QuoteTab.tsx add-item form"
to: "src/app/admin/clients/[id]/quote-actions.ts addQuoteItem"
via: "form action (Server Action)"
pattern: "addQuoteItem"
- from: "src/components/admin/tabs/QuoteTab.tsx remove button"
to: "src/app/admin/clients/[id]/quote-actions.ts removeQuoteItem"
via: "form action"
pattern: "removeQuoteItem"
- from: "src/components/admin/tabs/QuoteTab.tsx accepted total form"
to: "src/app/admin/clients/[id]/quote-actions.ts updateAcceptedTotal"
via: "form action"
pattern: "updateAcceptedTotal"
- from: "src/app/admin/clients/[id]/page.tsx"
to: "src/lib/admin-queries.ts getClientFullDetail"
via: "await getClientFullDetail(id)"
pattern: "getClientFullDetail"
---
<objective>
Deliver the "Preventivo" tab in the admin client detail page. This is the quote builder vertical slice: Server Actions for quote item CRUD + accepted_total write, the QuoteTab component (catalog dropdown + freeform toggle + items table + accepted total editor), and the wiring of both into the existing client detail page.
Purpose: Fulfills CAT-02 (catalog as quote generation base) and ADMIN-03 (full quote detail visible to admin only). The client sees only `clients.accepted_total` — this constraint is enforced at the query layer.
Output: 4 new/modified files — a fully operational quote builder tab.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@/Users/simonecavalli/IAMCAVALLI/.planning/ROADMAP.md
@/Users/simonecavalli/IAMCAVALLI/.planning/phases/03-service-catalog-quote-builder/03-01-SUMMARY.md
<interfaces>
<!-- Existing client detail page tabs structure — add Preventivo as 5th tab -->
```typescript
// src/app/admin/clients/[id]/page.tsx (current)
<Tabs defaultValue="phases" className="w-full">
<TabsList className="mb-6">
<TabsTrigger value="phases">Fasi &amp; Task</TabsTrigger>
<TabsTrigger value="payments">Pagamenti</TabsTrigger>
<TabsTrigger value="documents">Documenti</TabsTrigger>
<TabsTrigger value="comments">Commenti</TabsTrigger>
{/* ADD: <TabsTrigger value="quote">Preventivo</TabsTrigger> */}
</TabsList>
{/* ADD: <TabsContent value="quote"><QuoteTab ... /></TabsContent> */}
</Tabs>
```
<!-- getClientFullDetail current return type — must be extended with quoteItems and activeServices -->
```typescript
export type ClientFullDetail = {
client: Client;
phases: Array<Phase & { tasks: Array<Task & { deliverables: Deliverable[] }> }>;
payments: Payment[];
documents: Document[];
notes: Note[];
comments: Comment[];
// ADD:
// quoteItems: QuoteItemWithLabel[];
// activeServices: ServiceCatalog[];
};
```
<!-- PaymentsTab — analog structure for QuoteTab (server component with inline Server Action calls) -->
```typescript
// src/components/admin/tabs/PaymentsTab.tsx pattern
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">...</h3>
<form action={async (fd) => { "use server"; await updateAcceptedTotal(clientId, fd); }}>
...
</form>
</div>
</div>
);
}
```
<!-- SECURITY: getClientFullDetail must NOT expose quote_items via client-facing API.
The quote data is added only to the admin query result — not to any client route.
Comment to add at the top of quote-actions.ts: -->
// quote_items NEVER exposed — security constraint from Phase 1 (CLAUDE.md)
// Only clients.accepted_total is visible to client-facing routes
<!-- Drizzle leftJoin + COALESCE pattern for quote items with service name -->
```typescript
import { sql, eq, asc } from "drizzle-orm";
import { quote_items, service_catalog, clients } from "@/db/schema";
// Get quote items for a client — service name from catalog OR custom_label
const items = await db
.select({
id: quote_items.id,
label: sql<string>`COALESCE(${service_catalog.name}, ${quote_items.custom_label})`,
custom_label: quote_items.custom_label,
service_id: quote_items.service_id,
quantity: quote_items.quantity,
unit_price: quote_items.unit_price, // snapshotted — NEVER use service_catalog.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))
.orderBy(asc(quote_items.id));
```
<!-- addQuoteItem — numeric precision and subtotal calculation -->
```typescript
const qty = parseFloat(formData.get("quantity") as string);
const price = parseFloat(formData.get("unit_price") as string);
const subtotal = (qty * price).toFixed(2);
// Insert: unit_price stored as string with 2dp (matches numeric(10,2) column)
await db.insert(quote_items).values({
client_id: clientId,
service_id: serviceId ?? null, // null for freeform items
custom_label: customLabel ?? null,
quantity: qty.toFixed(2),
unit_price: price.toFixed(2),
subtotal,
});
```
<!-- ServiceCatalog type for dropdown -->
```typescript
export type ServiceCatalog = typeof service_catalog.$inferSelect;
// Fields: id: string, name: string, unit_price: string, active: boolean, description: string | null
```
<!-- QuoteItem type (updated after 03-01) -->
```typescript
export type QuoteItem = typeof quote_items.$inferSelect;
// Fields: id, client_id, service_id: string | null, custom_label: string | null,
// quantity, unit_price, subtotal (all numeric as string)
```
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: quote-actions.ts Server Actions + extend getClientFullDetail</name>
<read_first>
- /Users/simonecavalli/IAMCAVALLI/src/app/admin/clients/[id]/actions.ts (pattern: Zod, requireAdmin, revalidatePath)
- /Users/simonecavalli/IAMCAVALLI/src/lib/admin-queries.ts (current getClientFullDetail to extend — add quoteItems and activeServices)
- /Users/simonecavalli/IAMCAVALLI/src/db/schema.ts (confirm custom_label and nullable service_id from 03-01)
- /Users/simonecavalli/IAMCAVALLI/src/lib/client-view.ts (VERIFY this file does NOT query quote_items — if it does, remove that query)
</read_first>
<files>
src/app/admin/clients/[id]/quote-actions.ts
src/lib/admin-queries.ts
</files>
<action>
**Create `src/app/admin/clients/[id]/quote-actions.ts`** — three Server Actions:
```typescript
"use server";
// quote_items NEVER exposed — security constraint from Phase 1 (CLAUDE.md)
// Only clients.accepted_total is visible to client-facing routes
import { db } from "@/db";
import { quote_items, clients, service_catalog } from "@/db/schema";
import { revalidatePath } from "next/cache";
import { eq } from "drizzle-orm";
import { z } from "zod";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
async function requireAdmin() {
const session = await getServerSession(authOptions);
if (!session) throw new Error("Non autorizzato");
}
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) {
await requireAdmin();
const rawServiceId = formData.get("service_id") as string | null;
const rawCustomLabel = formData.get("custom_label") as string | null;
const parsed = quoteItemSchema.safeParse({
service_id: rawServiceId && rawServiceId !== "" ? rawServiceId : null,
custom_label: rawCustomLabel && rawCustomLabel !== "" ? rawCustomLabel : null,
quantity: formData.get("quantity"),
unit_price: formData.get("unit_price"),
});
if (!parsed.success) throw new Error(parsed.error.issues[0].message);
// Validate: either service_id or custom_label must be present
if (!parsed.data.service_id && !parsed.data.custom_label) {
throw new Error("Seleziona un servizio dal catalogo o inserisci il nome di una voce libera");
}
const { service_id, custom_label, quantity, unit_price } = parsed.data;
const subtotal = (quantity * unit_price).toFixed(2);
await db.insert(quote_items).values({
client_id: clientId,
service_id: service_id ?? null,
custom_label: custom_label ?? null,
quantity: quantity.toFixed(2),
unit_price: unit_price.toFixed(2),
subtotal,
});
revalidatePath(`/admin/clients/${clientId}`);
}
export async function removeQuoteItem(quoteItemId: string, clientId: string) {
await requireAdmin();
await db.delete(quote_items).where(eq(quote_items.id, quoteItemId));
revalidatePath(`/admin/clients/${clientId}`);
}
export async function updateAcceptedTotal(clientId: string, formData: FormData) {
await requireAdmin();
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}`);
}
```
**Extend `src/lib/admin-queries.ts`** — add `QuoteItemWithLabel` type and extend `ClientFullDetail` + `getClientFullDetail`:
1. Add imports at top: `quote_items`, `service_catalog` from `@/db/schema`; `sql` from `drizzle-orm`; `ServiceCatalog` from `@/db/schema`.
2. Add new type before `ClientFullDetail`:
```typescript
export type QuoteItemWithLabel = {
id: string;
label: string; // COALESCE(service_catalog.name, quote_items.custom_label)
custom_label: string | null;
service_id: string | null;
quantity: string;
unit_price: string; // snapshotted — never joined back to service_catalog.unit_price
subtotal: string;
};
```
3. Add `quoteItems: QuoteItemWithLabel[]` and `activeServices: ServiceCatalog[]` to the `ClientFullDetail` type.
4. Add two queries inside `getClientFullDetail()` before the `return` statement:
```typescript
// quote_items NEVER exposed via client API — admin workspace query only
const quoteItemRows: QuoteItemWithLabel[] = await db
.select({
id: quote_items.id,
label: sql<string>`COALESCE(${service_catalog.name}, ${quote_items.custom_label})`,
custom_label: quote_items.custom_label,
service_id: quote_items.service_id,
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, id))
.orderBy(asc(quote_items.id));
const activeServiceRows = await db
.select()
.from(service_catalog)
.where(eq(service_catalog.active, true))
.orderBy(asc(service_catalog.name));
```
5. Add `quoteItems: quoteItemRows` and `activeServices: activeServiceRows` to the return object.
IMPORTANT: Also read `src/lib/client-view.ts` to verify it does NOT query `quote_items`. If it does, remove that query entirely — `accepted_total` is the only field the client sees.
</action>
<verify>
<automated>cd /Users/simonecavalli/IAMCAVALLI && grep -c 'export async function addQuoteItem' src/app/admin/clients/\[id\]/quote-actions.ts</automated>
Expected: 1
<automated>cd /Users/simonecavalli/IAMCAVALLI && grep -c 'export async function removeQuoteItem' src/app/admin/clients/\[id\]/quote-actions.ts</automated>
Expected: 1
<automated>cd /Users/simonecavalli/IAMCAVALLI && grep -c 'export async function updateAcceptedTotal' src/app/admin/clients/\[id\]/quote-actions.ts</automated>
Expected: 1
<automated>cd /Users/simonecavalli/IAMCAVALLI && grep -c 'quoteItems' src/lib/admin-queries.ts</automated>
Expected: 3 or more (type definition, query, return)
<automated>cd /Users/simonecavalli/IAMCAVALLI && grep -c 'quote_items' src/lib/client-view.ts 2>/dev/null || echo 0</automated>
Expected: 0 (quote_items must NOT appear in client-view.ts)
<automated>cd /Users/simonecavalli/IAMCAVALLI && npx tsc --noEmit 2>&1 | head -20</automated>
Expected: no output (zero errors)
</verify>
<done>
Three Server Actions exported with `requireAdmin()` guard and Zod validation. `getClientFullDetail` returns `quoteItems` and `activeServices`. `client-view.ts` contains zero references to `quote_items`. TypeScript compiles clean.
</done>
</task>
<task type="auto">
<name>Task 2: QuoteTab component + wire into client detail page</name>
<read_first>
- /Users/simonecavalli/IAMCAVALLI/src/components/admin/tabs/PaymentsTab.tsx (exact analog structure to follow)
- /Users/simonecavalli/IAMCAVALLI/src/app/admin/clients/[id]/page.tsx (current tab structure to extend)
- /Users/simonecavalli/IAMCAVALLI/src/app/admin/clients/[id]/quote-actions.ts (actions from Task 1)
- /Users/simonecavalli/IAMCAVALLI/src/lib/admin-queries.ts (updated ClientFullDetail type from Task 1)
</read_first>
<files>
src/components/admin/tabs/QuoteTab.tsx
src/app/admin/clients/[id]/page.tsx
</files>
<action>
**Create `src/components/admin/tabs/QuoteTab.tsx`** — "use client" component with three form sections:
```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 { Label } from "@/components/ui/label";
import { addQuoteItem, removeQuoteItem, updateAcceptedTotal } from "@/app/admin/clients/[id]/quote-actions";
import type { QuoteItemWithLabel } from "@/lib/admin-queries";
import type { ServiceCatalog } from "@/db/schema";
type Props = {
clientId: string;
items: QuoteItemWithLabel[];
activeServices: ServiceCatalog[];
acceptedTotal: string;
};
export function QuoteTab({ clientId, items, activeServices, acceptedTotal }: Props) {
const [showCustom, setShowCustom] = useState(false);
const [addError, setAddError] = useState<string | null>(null);
const [totalError, setTotalError] = useState<string | null>(null);
// For catalog mode: pre-fill unit_price when service is selected
const [selectedServicePrice, setSelectedServicePrice] = useState<string>("");
const [, startTransition] = useTransition();
const router = useRouter();
const calculatedTotal = items.reduce((sum, item) => sum + parseFloat(item.subtotal), 0);
function handleAddItem(fd: FormData) {
setAddError(null);
startTransition(async () => {
try {
await addQuoteItem(clientId, fd);
router.refresh();
} catch (e) {
setAddError(e instanceof Error ? e.message : "Errore nell'aggiunta");
}
});
}
function handleRemove(quoteItemId: string) {
startTransition(async () => {
await removeQuoteItem(quoteItemId, clientId);
router.refresh();
});
}
function handleSaveTotal(fd: FormData) {
setTotalError(null);
startTransition(async () => {
try {
await updateAcceptedTotal(clientId, fd);
router.refresh();
} catch (e) {
setTotalError(e instanceof Error ? e.message : "Errore nel salvataggio");
}
});
}
return (
<div className="space-y-6 max-w-2xl">
{/* Section 1: Add items */}
<div className="bg-white border border-[#e5e7eb] rounded-xl p-4 space-y-4">
<h3 className="text-xs font-bold text-[#71717a] uppercase tracking-wider">Aggiungi voci</h3>
{!showCustom ? (
/* Catalog mode */
<form action={handleAddItem} className="space-y-3">
<input type="hidden" name="custom_label" value="" />
<div className="flex items-end gap-3 flex-wrap">
<div className="flex-1 min-w-[180px] space-y-1">
<Label htmlFor="service_id">Seleziona dal catalogo</Label>
<select
name="service_id"
id="service_id"
className="w-full border border-[#e5e7eb] rounded-md px-3 py-2 text-sm bg-white focus:outline-none focus:ring-2 focus:ring-[#1A463C]/30"
onChange={(e) => {
const svc = activeServices.find((s) => s.id === e.target.value);
setSelectedServicePrice(svc ? parseFloat(svc.unit_price).toFixed(2) : "");
}}
required
>
<option value=""> Scegli servizio </option>
{activeServices.map((s) => (
<option key={s.id} value={s.id}>
{s.name} ({parseFloat(s.unit_price).toLocaleString("it-IT", { minimumFractionDigits: 2 })})
</option>
))}
</select>
</div>
<div className="w-28 space-y-1">
<Label htmlFor="unit_price_catalog">Prezzo unit.</Label>
<Input
id="unit_price_catalog"
name="unit_price"
type="number"
step="0.01"
min="0.01"
value={selectedServicePrice}
onChange={(e) => setSelectedServicePrice(e.target.value)}
placeholder="0.00"
required
/>
</div>
<div className="w-20 space-y-1">
<Label htmlFor="quantity_catalog">Qty</Label>
<Input
id="quantity_catalog"
name="quantity"
type="number"
step="0.01"
min="0.01"
defaultValue="1"
required
/>
</div>
<Button type="submit" size="sm" className="bg-[#1A463C] text-white hover:bg-[#1A463C]/90">
Aggiungi
</Button>
</div>
<button
type="button"
onClick={() => { setShowCustom(true); setAddError(null); }}
className="text-xs text-[#71717a] hover:text-[#1a1a1a] underline"
>
Oppure aggiungi voce libera
</button>
{addError && <p className="text-xs text-red-600">{addError}</p>}
</form>
) : (
/* Freeform mode */
<form action={handleAddItem} className="space-y-3">
<input type="hidden" name="service_id" value="" />
<div className="space-y-1">
<Label htmlFor="custom_label">Nome voce</Label>
<Input
id="custom_label"
name="custom_label"
placeholder="es. Consulenza extra, Spese viaggi"
required
/>
</div>
<div className="flex gap-3 flex-wrap">
<div className="flex-1 min-w-[120px] space-y-1">
<Label htmlFor="unit_price_custom">Prezzo unitario ()</Label>
<Input
id="unit_price_custom"
name="unit_price"
type="number"
step="0.01"
min="0.01"
placeholder="0.00"
required
/>
</div>
<div className="w-20 space-y-1">
<Label htmlFor="quantity_custom">Qty</Label>
<Input
id="quantity_custom"
name="quantity"
type="number"
step="0.01"
min="0.01"
defaultValue="1"
required
/>
</div>
</div>
{addError && <p className="text-xs text-red-600">{addError}</p>}
<div className="flex gap-2">
<Button type="submit" size="sm" className="bg-[#1A463C] text-white hover:bg-[#1A463C]/90">
Aggiungi voce libera
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => { setShowCustom(false); setAddError(null); }}
>
Torna al catalogo
</Button>
</div>
</form>
)}
</div>
{/* Section 2: Quote items table + calculated total */}
<div className="bg-white border border-[#e5e7eb] rounded-xl p-4">
<h3 className="text-xs font-bold text-[#71717a] uppercase tracking-wider mb-3">Voci preventivo</h3>
{items.length === 0 ? (
<p className="text-sm text-[#71717a] py-4 text-center">
Nessuna voce aggiunta. Seleziona dal catalogo per iniziare.
</p>
) : (
<>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="border-b border-[#e5e7eb]">
<tr>
<th className="text-left py-2 px-2 font-medium text-[#71717a]">Voce</th>
<th className="text-right py-2 px-2 font-medium text-[#71717a]">Qty</th>
<th className="text-right py-2 px-2 font-medium text-[#71717a]">Prezzo unit.</th>
<th className="text-right py-2 px-2 font-medium text-[#71717a]">Subtotale</th>
<th className="py-2 px-2"></th>
</tr>
</thead>
<tbody>
{items.map((item) => (
<tr key={item.id} className="border-b border-[#f4f4f5] hover:bg-[#f9f9f9]">
<td className="py-2 px-2 text-[#1a1a1a]">{item.label}</td>
<td className="py-2 px-2 text-right tabular-nums">{parseFloat(item.quantity).toLocaleString("it-IT", { minimumFractionDigits: 2 })}</td>
<td className="py-2 px-2 text-right tabular-nums font-mono">
{parseFloat(item.unit_price).toLocaleString("it-IT", { minimumFractionDigits: 2 })}
</td>
<td className="py-2 px-2 text-right tabular-nums font-mono font-medium">
{parseFloat(item.subtotal).toLocaleString("it-IT", { minimumFractionDigits: 2 })}
</td>
<td className="py-2 px-2 text-right">
<button
type="button"
onClick={() => handleRemove(item.id)}
className="text-xs text-[#71717a] hover:text-red-600 transition-colors"
>
Rimuovi
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="mt-3 pt-3 border-t border-[#e5e7eb] flex justify-end">
<p className="font-bold text-[#1a1a1a] tabular-nums">
Totale calcolato: {calculatedTotal.toLocaleString("it-IT", { minimumFractionDigits: 2 })}
</p>
</div>
</>
)}
</div>
{/* Section 3: Accepted total */}
<div className="bg-white border border-[#e5e7eb] rounded-xl p-4 space-y-3">
<h3 className="text-xs font-bold text-[#71717a] uppercase tracking-wider">Totale accettato dal cliente</h3>
<form action={handleSaveTotal} className="flex items-end gap-3">
<div className="flex-1 max-w-[200px] space-y-1">
<Label htmlFor="accepted_total">Importo ()</Label>
<Input
id="accepted_total"
name="accepted_total"
type="number"
step="0.01"
min="0"
defaultValue={parseFloat(acceptedTotal).toFixed(2)}
/>
</div>
<Button type="submit" size="sm" className="bg-[#1A463C] text-white hover:bg-[#1A463C]/90">
Salva
</Button>
</form>
{totalError && <p className="text-xs text-red-600">{totalError}</p>}
<p className="text-xs text-[#71717a]">
Il cliente vede solo questo importo, non le singole voci del preventivo.
</p>
</div>
</div>
);
}
```
**Modify `src/app/admin/clients/[id]/page.tsx`** — add QuoteTab as 5th tab:
1. Add import at top:
```typescript
import { QuoteTab } from "@/components/admin/tabs/QuoteTab";
```
2. Update destructure from `getClientFullDetail`:
```typescript
const { client, phases, payments, documents, comments, quoteItems, activeServices } = detail;
```
3. Add 5th TabsTrigger after "Commenti":
```typescript
<TabsTrigger value="quote">Preventivo</TabsTrigger>
```
4. Add 5th TabsContent after the comments TabsContent:
```typescript
<TabsContent value="quote">
<QuoteTab
clientId={client.id}
items={quoteItems}
activeServices={activeServices}
acceptedTotal={client.accepted_total ?? "0"}
/>
</TabsContent>
```
</action>
<verify>
<automated>cd /Users/simonecavalli/IAMCAVALLI && grep -c 'export function QuoteTab' src/components/admin/tabs/QuoteTab.tsx</automated>
Expected: 1
<automated>cd /Users/simonecavalli/IAMCAVALLI && grep -c 'Preventivo' src/app/admin/clients/\[id\]/page.tsx</automated>
Expected: 2 (TabsTrigger text + TabsContent value)
<automated>cd /Users/simonecavalli/IAMCAVALLI && grep -c 'quoteItems' src/app/admin/clients/\[id\]/page.tsx</automated>
Expected: 1 (destructured from detail)
<automated>cd /Users/simonecavalli/IAMCAVALLI && npx tsc --noEmit 2>&1 | head -20</automated>
Expected: no output (zero errors)
<automated>cd /Users/simonecavalli/IAMCAVALLI && npm run build 2>&1 | tail -10</automated>
Expected: build succeeds with no errors
</verify>
<done>
QuoteTab component renders with three sections. "Preventivo" tab appears in client detail page. TypeScript and build both pass clean.
</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Admin browser → quote-actions.ts Server Actions | FormData (clientId, service_id, unit_price, quantity) crosses to server — must be validated before DB write |
| getClientFullDetail → /admin/clients/[id]/page.tsx | quoteItems and activeServices returned ONLY to admin page — never to client-facing routes |
| client-view.ts / client API routes | Must NOT include quote_items in any query result — enforced at query layer |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-03-03-01 | Spoofing | addQuoteItem / removeQuoteItem / updateAcceptedTotal | mitigate | `requireAdmin()` calls `getServerSession(authOptions)` at top of every Server Action — rejects unauthenticated requests |
| T-03-03-02 | Tampering | addQuoteItem formData (unit_price, quantity) | mitigate | Zod `quoteItemSchema` validates both as `z.coerce.number().min(0.01)` — prevents zero/negative values or non-numeric injection |
| T-03-03-03 | Information Disclosure | quote_items exposed via client-facing route | mitigate | `getClientFullDetail` query adds quoteItems ONLY to admin return type; `client-view.ts` and all `/api/client/*` routes must never query `quote_items`; verified via grep gate in Task 1 verify |
| T-03-03-04 | Tampering | IDOR — removeQuoteItem with foreign clientId | mitigate | removeQuoteItem deletes by `quoteItemId` only — the admin must be authenticated (requireAdmin). Phase scope has single admin; if multi-admin added in future, add `AND client_id = clientId` to delete WHERE clause |
| T-03-03-05 | Tampering | XSS in custom_label field | accept | React JSX auto-escapes; custom_label rendered via `{item.label}` — no dangerouslySetInnerHTML; UI-SPEC prohibits it |
| T-03-03-06 | Tampering | Confusing calculated_total vs accepted_total | accept | Visual design enforces separation: calculated total is read-only bold text; accepted_total is distinct editable input with Save button and helper text "Il cliente vede solo questo importo" |
</threat_model>
<verification>
After both tasks complete:
1. `grep -c 'quote_items' src/lib/client-view.ts` returns 0
2. `npx tsc --noEmit` exits clean
3. `npm run build` succeeds
4. Client detail page at `/admin/clients/[id]` shows "Preventivo" as 5th tab
5. Adding a catalog item: item appears in table with snapshotted unit_price (not pulled from service_catalog)
6. Adding a freeform item: row appears with custom_label, service_id is null in DB
7. Clicking "Salva" on accepted_total updates `clients.accepted_total` — visible in PaymentsTab "Totale preventivo" field
</verification>
<success_criteria>
- `src/app/admin/clients/[id]/quote-actions.ts` exports three Server Actions with requireAdmin + Zod guards
- `getClientFullDetail` returns `quoteItems: QuoteItemWithLabel[]` and `activeServices: ServiceCatalog[]`
- QuoteTab renders all three sections: add items (catalog + freeform toggle), items table with calculated total, accepted total editor
- `client-view.ts` contains zero references to `quote_items`
- TypeScript and build both pass clean
</success_criteria>
<output>
After completion, create `.planning/phases/03-service-catalog-quote-builder/03-03-SUMMARY.md`
</output>
@@ -0,0 +1,111 @@
---
phase: "03"
plan: "03"
subsystem: "quote-builder"
tags: [quote, admin, server-actions, drizzle, security]
dependency_graph:
requires: ["03-01"]
provides: ["quote-tab-ui", "quote-actions", "admin-quote-queries"]
affects: ["src/lib/admin-queries.ts", "src/app/admin/clients/[id]/page.tsx"]
tech_stack:
added: []
patterns: ["Server Actions with requireAdmin guard", "useTransition for optimistic UI", "COALESCE SQL for label resolution", "leftJoin for optional catalog ref"]
key_files:
created:
- src/app/admin/clients/[id]/quote-actions.ts
- src/components/admin/tabs/QuoteTab.tsx
modified:
- src/lib/admin-queries.ts
- src/app/admin/clients/[id]/page.tsx
decisions:
- "QuoteTab is a Client Component (useTransition + useRouter) — actions called via startTransition, router.refresh() for revalidation"
- "updateAcceptedTotal in quote-actions.ts is separate from the one in actions.ts — scoped to quote tab, adds requireAdmin guard"
- "Service price pre-filled in catalog mode but editable — allows overriding price at quote time (snapshot semantics)"
metrics:
duration: "~15 min"
completed_date: "2026-05-17T09:45:11Z"
tasks_completed: 2
tasks_total: 2
files_created: 2
files_modified: 2
---
# Phase 03 Plan 03: Quote Builder Tab Summary
**One-liner:** Admin quote builder tab with catalog dropdown, freeform toggle, items table with calculated total, and accepted_total editor — all backed by Zod-validated Server Actions with requireAdmin guard.
## Tasks Completed
| Task | Name | Commit | Files |
|------|------|--------|-------|
| 1 | Server Actions + extend getClientFullDetail | db81829 | quote-actions.ts, admin-queries.ts |
| 2 | QuoteTab component + wire into client detail page | 48f81e7 | QuoteTab.tsx, page.tsx |
## What Was Built
**Task 1 — Server Actions + Query Layer**
- `src/app/admin/clients/[id]/quote-actions.ts`: Three Server Actions exported:
- `addQuoteItem(clientId, formData)` — Zod validates service_id/custom_label, quantity, unit_price; computes subtotal; inserts into `quote_items`
- `removeQuoteItem(quoteItemId, clientId)` — deletes by item ID
- `updateAcceptedTotal(clientId, formData)` — writes to `clients.accepted_total` only (no payment row splitting — that stays in `actions.ts`)
- All three call `requireAdmin()` (getServerSession check) before any DB operation
- `src/lib/admin-queries.ts`:
- Added `QuoteItemWithLabel` type (COALESCE resolved label, snapshotted unit_price)
- Extended `ClientFullDetail` with `quoteItems: QuoteItemWithLabel[]` and `activeServices: ServiceCatalog[]`
- Added two queries in `getClientFullDetail()`: leftJoin for quote items with COALESCE label; active services ordered by name
- Security comment enforces that `client-view.ts` must never query `quote_items` (verified: 0 functional references)
**Task 2 — UI Component + Page Wiring**
- `src/components/admin/tabs/QuoteTab.tsx` (`"use client"`) — three sections:
- **Add items**: catalog dropdown (pre-fills unit_price on selection, editable) + freeform toggle (custom_label + price + qty)
- **Items table**: label, qty, unit_price, subtotal columns; "Rimuovi" button per row; "Totale calcolato" in bold footer
- **Accepted total**: editable numeric input with "Salva" button + helper text clarifying client sees only this value
- `src/app/admin/clients/[id]/page.tsx`:
- Import `QuoteTab`
- Destructure `quoteItems`, `activeServices` from `getClientFullDetail` result
- Added 5th `TabsTrigger value="quote"` with label "Preventivo"
- Added 5th `TabsContent value="quote"` rendering `<QuoteTab>`
## Security Verification
| Constraint | Status |
|------------|--------|
| T-03-03-01: requireAdmin on all Server Actions | Done — all three actions call `await requireAdmin()` first |
| T-03-03-02: Zod validation on formData numbers | Done — `quoteItemSchema` validates quantity + unit_price as `z.coerce.number().min(0.01)` |
| T-03-03-03: quote_items not in client-facing routes | Done — client-view.ts has 0 functional references to quote_items (only comments) |
| T-03-03-04: IDOR on removeQuoteItem | Mitigated by requireAdmin; future multi-admin scenario noted for future hardening |
| T-03-03-05: XSS in custom_label | Accepted — React JSX auto-escapes, no dangerouslySetInnerHTML used |
| T-03-03-06: calculated_total vs accepted_total confusion | Accepted — visual design enforces separation |
## Deviations from Plan
**1. [Rule 3 - Blocking] Build required DATABASE_URL env var not present in worktree**
- **Found during:** Task 2 build verification
- **Issue:** Worktree has no `.env.local`; build fails with "DATABASE_URL env var is required" at runtime collection phase
- **Fix:** Ran build with `DATABASE_URL=$(grep DATABASE_URL /path/.env.local ...)` from main repo — build passed clean
- **Impact:** None on code quality; worktree environment limitation only
**2. [Rule 1 - Architecture] updateAcceptedTotal in quote-actions.ts does NOT update payment rows**
- **Found during:** Task 1 implementation
- **Rationale:** The existing `updateAcceptedTotal` in `actions.ts` splits the total 50/50 between payment rows. The quote tab version intentionally only writes to `clients.accepted_total` — this is the quote builder's domain. Payment row updates remain in the payments tab action. This preserves clean separation of concerns.
## Known Stubs
None — all three sections are fully wired to real Server Actions and real DB queries.
## Threat Flags
None — all new surface is admin-only, guarded by `requireAdmin()`, and consistent with the plan's threat model.
## Self-Check: PASSED
- `src/app/admin/clients/[id]/quote-actions.ts` exists and exports 3 Server Actions
- `src/components/admin/tabs/QuoteTab.tsx` exists and exports QuoteTab
- `src/lib/admin-queries.ts` modified with QuoteItemWithLabel type + quoteItems/activeServices in return
- `src/app/admin/clients/[id]/page.tsx` modified with Preventivo tab
- Commits db81829 and 48f81e7 verified in git log
- TypeScript: no errors
- Build: passes (with DATABASE_URL)
- client-view.ts: 0 functional references to quote_items
@@ -0,0 +1,216 @@
---
phase: "03"
plan: "04"
type: execute
wave: 3
depends_on:
- "03-02"
- "03-03"
files_modified: []
autonomous: false
requirements:
- CAT-01
- CAT-02
- ADMIN-03
must_haves:
truths:
- "Admin navigates to /admin/catalog — table shows all services with correct columns and status badges"
- "Admin adds a service, edits it inline, and disattiva/riattiva it — all changes persist on page refresh"
- "Admin opens a client's Preventivo tab — adds a catalog item and a freeform item — both appear in the table with correct subtotals and calculated total"
- "Admin saves an accepted_total — the client dashboard shows that exact amount, not the calculated sum"
- "A curl request to the client API returns NO quote_items field and NO service_id references"
artifacts:
- path: "src/app/admin/catalog/page.tsx"
provides: "Verified: catalog page loads and renders table"
- path: "src/components/admin/tabs/QuoteTab.tsx"
provides: "Verified: three sections render correctly, catalog and freeform items work"
- path: "src/lib/client-view.ts"
provides: "Verified: zero quote_items references"
key_links:
- from: "clients.accepted_total (DB)"
to: "client dashboard display"
via: "client-view.ts query → /c/[token] page"
pattern: "accepted_total"
---
<objective>
End-to-end verification of Phase 3. The admin runs the full workflow — create catalog service, add to quote, set accepted_total — and confirms the client dashboard shows the correct total. Also verifies the security constraint: `quote_items` are never returned by the client API.
Purpose: Confirms Phase 3 is shippable. Catches any integration issue between catalog, quote builder, and client dashboard before the phase is marked complete.
Output: Human verification sign-off + SUMMARY.md.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@/Users/simonecavalli/IAMCAVALLI/.planning/ROADMAP.md
@/Users/simonecavalli/IAMCAVALLI/.planning/phases/03-service-catalog-quote-builder/03-02-SUMMARY.md
@/Users/simonecavalli/IAMCAVALLI/.planning/phases/03-service-catalog-quote-builder/03-03-SUMMARY.md
</context>
<tasks>
<task type="auto">
<name>Task 1: Automated security and integration checks</name>
<read_first>
- /Users/simonecavalli/IAMCAVALLI/src/lib/client-view.ts (must contain zero quote_items references)
- /Users/simonecavalli/IAMCAVALLI/src/app/api (check all client-facing API route files for quote_items leaks)
</read_first>
<files></files>
<action>
Run the following automated checks in sequence. Report results for each.
**Check 1 — TypeScript compiles clean:**
```bash
cd /Users/simonecavalli/IAMCAVALLI && npx tsc --noEmit
```
Expected: zero output (no errors).
**Check 2 — Build succeeds:**
```bash
cd /Users/simonecavalli/IAMCAVALLI && npm run build
```
Expected: "Compiled successfully" with routes listed. No error lines.
**Check 3 — Security: quote_items not in client-facing code:**
```bash
cd /Users/simonecavalli/IAMCAVALLI && grep -rn 'quote_items' src/lib/client-view.ts src/app/api/ src/app/c/ 2>/dev/null || echo "CLEAN"
```
Expected: "CLEAN" or no output. If any match appears, that file must be fixed before the checkpoint.
**Check 4 — Service catalog page references getAllServices:**
```bash
cd /Users/simonecavalli/IAMCAVALLI && grep -c 'getAllServices' src/app/admin/catalog/page.tsx
```
Expected: 1
**Check 5 — NavBar contains Catalogo link:**
```bash
cd /Users/simonecavalli/IAMCAVALLI && grep -c '/admin/catalog' src/components/admin/NavBar.tsx
```
Expected: 1
**Check 6 — Client detail page has Preventivo tab:**
```bash
cd /Users/simonecavalli/IAMCAVALLI && grep -c 'Preventivo' src/app/admin/clients/\[id\]/page.tsx
```
Expected: 2
**Check 7 — quote-actions has requireAdmin in all three actions:**
```bash
cd /Users/simonecavalli/IAMCAVALLI && grep -c 'requireAdmin' src/app/admin/clients/\[id\]/quote-actions.ts
```
Expected: 3 (one per action)
**Check 8 — accepted_total security check (client view does NOT expose quote detail):**
```bash
cd /Users/simonecavalli/IAMCAVALLI && grep 'accepted_total\|quote_items\|service_id' src/lib/client-view.ts
```
Expected: `accepted_total` appears (it's the field clients see), `quote_items` does NOT appear, `service_id` does NOT appear.
If all 8 checks pass, proceed to the human verification checkpoint.
If any check fails, fix the issue before proceeding.
</action>
<verify>
<automated>cd /Users/simonecavalli/IAMCAVALLI && npx tsc --noEmit && npm run build 2>&1 | tail -5</automated>
Expected: build output ends with route list — no "Failed to compile" line.
<automated>cd /Users/simonecavalli/IAMCAVALLI && grep -rn 'quote_items' src/lib/client-view.ts src/app/c/ 2>/dev/null | wc -l | tr -d ' '</automated>
Expected: 0
</verify>
<done>
All 8 automated checks pass. TypeScript clean, build succeeds, quote_items absent from client-facing code.
</done>
</task>
<task type="checkpoint:human-verify" gate="blocking">
<name>Task 2: Human end-to-end verification of Phase 3</name>
<what-built>
Service Catalog CRUD at /admin/catalog, Quote Builder tab in client detail, accepted_total round-trip to client dashboard.
</what-built>
<how-to-verify>
Start the dev server: `npm run dev` (port 3000).
**Test A — Catalog page:**
1. Open http://localhost:3000/admin/catalog
2. Confirm the page loads with "Catalogo Servizi" heading and "Aggiungi servizio" button
3. Click "Aggiungi servizio" — fill in Nome: "Test Servizio", Prezzo: "500" — click Aggiungi
4. Confirm "Test Servizio" appears in the table with "Attivo" badge and €500,00 price
5. Click "Modifica" on the row — change price to "750" — click Salva
6. Confirm price updates to €750,00 without page reload
7. Click "Disattiva" — confirm badge changes to "Disattivato" and row becomes dimmed (50% opacity)
8. Click "Riattiva" — confirm badge returns to "Attivo"
**Test B — NavBar:**
1. Confirm "Catalogo" link appears in the admin NavBar between "Statistiche" and "Esci"
2. Click it — confirm it navigates to /admin/catalog
**Test C — Quote Builder tab:**
1. Open any existing client at http://localhost:3000/admin/clients/[id]
2. Confirm "Preventivo" tab appears as 5th tab (after Commenti)
3. Click the Preventivo tab
4. Select "Test Servizio" from the dropdown (if inactive, reactivate first) — set qty 1 — click Aggiungi
5. Confirm item appears in the table with correct unit price and subtotal
6. Click "Oppure aggiungi voce libera →" — enter Nome: "Extra consulenza", Prezzo: "200", Qty: 2 — click Aggiungi voce libera
7. Confirm second item appears with "Extra consulenza" label, subtotal €400,00
8. Confirm "Totale calcolato" shows the sum (e.g., €1.150,00 if service was €750)
9. Click "Rimuovi" on one item — confirm it disappears
**Test D — Accepted total round-trip (critical):**
1. In the Preventivo tab, set "Totale accettato dal cliente" to 1200 — click Salva
2. Open the client dashboard at http://localhost:3000/c/[client-token] in a new tab
3. Confirm the dashboard shows "€1.200,00" (or equivalent) as the accepted total
4. Back in admin, open the Pagamenti tab — confirm "Totale preventivo" input shows 1200
**Test E — Security check (quote_items never exposed):**
1. In the browser DevTools (Network tab), open the client dashboard /c/[token]
2. Find any API calls made by that page — inspect their response bodies
3. Confirm NO response contains "quote_items", "service_id" (from quote context), or individual line item prices
4. Alternative: run `curl http://localhost:3000/api/client/[client-id-or-token]` if a client API route exists — confirm response has only `accepted_total`, not quote item details
</how-to-verify>
<resume-signal>
Type "approved" if all 5 tests pass. Or describe any failures (e.g., "Test C step 5 fails — items not appearing") so they can be fixed.
</resume-signal>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Client browser → /c/[token] route | Client sees only what the route explicitly returns — verified here that quote_items are absent |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-03-04-01 | Information Disclosure | Client dashboard API response | mitigate | Check 3 + Test E verify that no quote_items appear in any client-facing response; if found, fix before approving |
| T-03-04-02 | Tampering | Phase 3 shipped without DB push | mitigate | 03-01 is a hard dependency of this wave; if drizzle-kit push was skipped, custom_label column absent causes runtime crash caught in Test C |
</threat_model>
<verification>
Phase 3 complete when:
1. All 8 automated checks in Task 1 pass
2. Human verifies Tests AE in Task 2
3. Client dashboard shows correct `accepted_total` after update (Test D)
4. Zero `quote_items` in any client-facing response (Test E)
</verification>
<success_criteria>
- Service catalog is fully operational: add, edit, disable, re-enable services
- Quote builder adds catalog items (with snapshotted price) and freeform items (service_id = null)
- accepted_total write in admin is reflected in client dashboard
- Phase 3 roadmap success criteria 13 are all TRUE:
1. Admin can add/edit/disable catalog services
2. Admin can compose a quote from catalog; system calculates total
3. After saving accepted_total, client dashboard shows correct total; quote_items never exposed
</success_criteria>
<output>
After completion, create `.planning/phases/03-service-catalog-quote-builder/03-04-SUMMARY.md`
</output>
@@ -0,0 +1,104 @@
---
phase: 03-service-catalog-quote-builder
plan: "04"
subsystem: testing
tags: [e2e, verification, security, catalog, quote-builder]
requires:
- phase: "03-01"
provides: schema con service_id nullable e custom_label pushato su Neon
- phase: "03-02"
provides: pagina /admin/catalog con CRUD servizi
- phase: "03-03"
provides: tab Preventivo con QuoteTab e quote-actions
provides:
- Verifica E2E confermata da utente: flusso catalogo → preventivo → accepted_total → dashboard cliente
- Conferma security constraint: quote_items mai esposti nell'API client
- Fix CSS: .planning/ escluso da Tailwind v4 source scan
affects: []
tech-stack:
added: []
patterns:
- "@source not pattern in globals.css per escludere directory non-source da Tailwind v4"
key-files:
created:
- .planning/phases/03-service-catalog-quote-builder/03-04-SUMMARY.md
modified:
- src/app/globals.css
key-decisions:
- "Aggiunto @source not '../../.planning/**' per evitare che SUMMARY.md con regex [-:|] generi CSS invalido in Turbopack"
requirements-completed:
- CAT-01
- CAT-02
- ADMIN-03
duration: 30min
completed: 2026-05-19
---
# Plan 03-04: E2E Verification Summary
**Verifica umana completa: catalogo servizi → preventivo → accepted_total → dashboard cliente, con conferma security constraint quote_items mai esposti**
## Performance
- **Duration:** ~30 min (incluso fix CSS)
- **Completed:** 2026-05-19
- **Tasks:** 2/2
- **Files modified:** 2
## Accomplishments
- 8 check automatici superati: TypeScript clean, build OK, security grep CLEAN, NavBar link, getAllServices, Preventivo tab, requireAdmin (3 azioni), accepted_total senza quote_items funzionali
- Verifica umana Tests AE tutti approvati: catalog CRUD, NavBar link, tab Preventivo con voci catalogo e libere, round-trip accepted_total sulla dashboard cliente, security check DevTools
- Fix CSS: Tailwind v4 scansionava `.planning/` e interpretava `[-:|]` (da un commento regex in un SUMMARY.md) come classe arbitraria invalida — aggiunto `@source not "../../.planning/**"` in globals.css
## Task Commits
1. **Task 1: Automated checks** — tutti 8 superati inline (nessun commit necessario)
2. **Task 2: Human E2E verification** — approvato dall'utente
3. **Fix CSS:** `511c7d1` fix(css): exclude .planning/ from Tailwind v4 source scan
## Files Created/Modified
- `src/app/globals.css` — aggiunto `@source not "../../.planning/**"` per escludere directory planning da Tailwind scanner
## Decisions Made
- `.planning/` escluso dalla scansione Tailwind v4 per prevenire che documentazione tecnica (con regex nei SUMMARY) generi classi CSS invalide in Turbopack
## Deviations from Plan
### Auto-fixed Issues
**1. CSS Build Error — Turbopack stricter than webpack su classi arbitrarie invalide**
- **Found during:** Avvio dev server per Test A
- **Issue:** `[-:|]` in `01-05-SUMMARY.md` (commento su una regex) era interpretato da Tailwind v4 come classe CSS arbitraria → genera `-: |;` che è CSS invalido → Turbopack fallisce con hard error (webpack lo trattava come warning)
- **Fix:** `@source not "../../.planning/**"` in `src/app/globals.css`
- **Verification:** Dev server riavviato, nessun errore CSS, `/admin/catalog` carica correttamente
- **Committed in:** `511c7d1`
---
**Total deviations:** 1 auto-fixed (CSS scanning scope)
**Impact on plan:** Fix necessario per l'esecuzione del dev server. Nessun scope creep.
## Issues Encountered
- Dev server avviato su porta 3001 invece di 3000 perché il vecchio processo era ancora attivo (process 94688) — kill manuale e riavvio hanno risolto
## Next Phase Readiness
- Fase 3 completa e verificata end-to-end
- Pronto per Fase 4 (AI Onboarding: CLAUDE-01, CLAUDE-02, CLAUDE-03)
- TODO pre-launch ancora aperti: Vercel deploy + DNS CNAME `welcomeclient.iamcavalli.net`
---
*Phase: 03-service-catalog-quote-builder*
*Completed: 2026-05-19*
@@ -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 14):
```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 832):
```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 2024):
```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 2636):
```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 138141):
```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 1080):
```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 4664):
```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 2254):
```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 192211):
```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 729):
```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 159172):
```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 166168):
```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 2024, 138141
**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 10114
**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 4664
**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 2039
**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 3645
**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)_