docs(03): plan Phase 3 — Service Catalog & Quote Builder (4 plans, 2 waves)
Wave 1: schema push (service_id nullable + custom_label). Wave 2 (parallel): catalog CRUD page + quote builder tab. Wave 3: E2E human verification checkpoint. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -68,9 +68,14 @@ Decimal phases appear between their surrounding integers in numeric order.
|
|||||||
1. L'admin può aggiungere, modificare e disattivare voci nel catalogo servizi (nome, descrizione, prezzo unitario)
|
1. L'admin può aggiungere, modificare e disattivare voci nel catalogo servizi (nome, descrizione, prezzo unitario)
|
||||||
2. L'admin può comporre un preventivo per un cliente selezionando voci dal catalogo; il sistema calcola il totale
|
2. L'admin può comporre un preventivo per un cliente selezionando voci dal catalogo; il sistema calcola il totale
|
||||||
3. Dopo la finalizzazione del preventivo, `accepted_total` è scritto sulla riga cliente e la dashboard del cliente mostra il totale corretto; i `quote_items` non sono mai esposti al cliente
|
3. Dopo la finalizzazione del preventivo, `accepted_total` è scritto sulla riga cliente e la dashboard del cliente mostra il totale corretto; i `quote_items` non sono mai esposti al cliente
|
||||||
**Plans**: TBD
|
**Plans**: 4 plans
|
||||||
|
**Plan list**:
|
||||||
|
- [ ] 03-01-PLAN.md — Schema changes (service_id nullable, custom_label) + drizzle-kit push [BLOCKING]
|
||||||
|
- [ ] 03-02-PLAN.md — Service Catalog: /admin/catalog page + CRUD actions + ServiceTable + NavBar link
|
||||||
|
- [ ] 03-03-PLAN.md — Quote Builder: QuoteTab + quote-actions + client detail page wiring
|
||||||
|
- [ ] 03-04-PLAN.md — E2E verification: catalog CRUD, quote round-trip, accepted_total, security check
|
||||||
**UI hint**: yes
|
**UI hint**: yes
|
||||||
**Status**: Pending planning
|
**Status**: Planned — ready for execution
|
||||||
|
|
||||||
### Phase 4: Claude AI Onboarding (v2)
|
### Phase 4: Claude AI Onboarding (v2)
|
||||||
**Goal**: L'admin può usare un flusso chat guidato per onboardare un nuovo cliente e generare un piano a fasi e un preventivo assistito da Claude
|
**Goal**: L'admin può usare un flusso chat guidato per onboardare un nuovo cliente e generare un piano a fasi e un preventivo assistito da Claude
|
||||||
@@ -94,5 +99,5 @@ Phases execute in numeric order: 1 → 2 → 3 → 4
|
|||||||
|-------|-------|--------|-----------|
|
|-------|-------|--------|-----------|
|
||||||
| 1. Foundation & Client Dashboard | 5/5 | ✅ Done | 2026-05-14 |
|
| 1. Foundation & Client Dashboard | 5/5 | ✅ Done | 2026-05-14 |
|
||||||
| 2. Admin Area & Interactive Features | 4/4 | Planned | - |
|
| 2. Admin Area & Interactive Features | 4/4 | Planned | - |
|
||||||
| 3. Service Catalog & Quote Builder | 0/TBD | Pending | - |
|
| 3. Service Catalog & Quote Builder | 4/4 | Planned | - |
|
||||||
| 4. Claude AI Onboarding (v2) | 0/TBD | Pending | - |
|
| 4. Claude AI Onboarding (v2) | 0/TBD | Pending | - |
|
||||||
@@ -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,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,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 & 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,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 A–E 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 1–3 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>
|
||||||
Reference in New Issue
Block a user