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:
@@ -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>
|
||||
Reference in New Issue
Block a user