Files
Simone Cavalli a4942d7684 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>
2026-05-17 11:23:15 +02:00

25 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
phase plan type wave depends_on files_modified autonomous requirements must_haves
03 02 execute 2
03-01
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
true
CAT-01
truths artifacts key_links
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
path provides contains
src/app/admin/catalog/page.tsx Service catalog page — server component, fetches all services, renders table getAllServices
path provides exports
src/app/admin/catalog/actions.ts Server Actions: createService, updateService, toggleServiceActive
createService
updateService
toggleServiceActive
path provides contains
src/components/admin/catalog/ServiceTable.tsx Table with per-row inline edit and active toggle ServiceTable
path provides contains
src/components/admin/catalog/ServiceForm.tsx Add-new-service form rendered above table ServiceForm
path provides contains
src/components/admin/NavBar.tsx NavBar with Catalogo link added /admin/catalog
from to via pattern
src/components/admin/catalog/ServiceForm.tsx src/app/admin/catalog/actions.ts createService form action createService
from to via pattern
src/components/admin/catalog/ServiceTable.tsx src/app/admin/catalog/actions.ts updateService / toggleServiceActive Server Action calls in useTransition updateService|toggleServiceActive
from to via pattern
src/app/admin/catalog/page.tsx src/lib/admin-queries.ts getAllServices (new function) await getAllServices() getAllServices
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.

<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_context>

@/Users/simonecavalli/IAMCAVALLI/.planning/ROADMAP.md @/Users/simonecavalli/IAMCAVALLI/.planning/phases/03-service-catalog-quote-builder/03-01-SUMMARY.md ```typescript // Server component, fetches data, renders table + header export default async function AdminDashboard() { const clients = await getAllClientsWithPayments(); return (

Clienti

+ Nuovo cliente
{/* table */}
); } ```
"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");
      }
    });
  }
  // ...
}
"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");
}
// 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 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 })}
export type ServiceCatalog = typeof service_catalog.$inferSelect;
// Fields: id: string, name: string, description: string | null, unit_price: string, active: boolean
Task 1: Server Actions + getAllServices query - /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) src/app/admin/catalog/actions.ts src/lib/admin-queries.ts **Create `src/app/admin/catalog/actions.ts`** — three Server Actions following exact Zod+FormData pattern from `clients/[id]/actions.ts`:
"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:

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. cd /Users/simonecavalli/IAMCAVALLI && grep -c 'export async function createService' src/app/admin/catalog/actions.ts Expected: 1 cd /Users/simonecavalli/IAMCAVALLI && grep -c 'export async function updateService' src/app/admin/catalog/actions.ts Expected: 1 cd /Users/simonecavalli/IAMCAVALLI && grep -c 'export async function toggleServiceActive' src/app/admin/catalog/actions.ts Expected: 1 cd /Users/simonecavalli/IAMCAVALLI && grep -c 'export async function getAllServices' src/lib/admin-queries.ts Expected: 1 cd /Users/simonecavalli/IAMCAVALLI && npx tsc --noEmit 2>&1 | head -20 Expected: no output (zero errors) Three Server Actions exported from catalog/actions.ts. getAllServices() added to admin-queries.ts. TypeScript compiles clean.

Task 2: Service Catalog page + components (ServiceTable, ServiceForm) + NavBar link - /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) src/app/admin/catalog/page.tsx src/components/admin/catalog/ServiceTable.tsx src/components/admin/catalog/ServiceForm.tsx src/components/admin/NavBar.tsx **Create `src/app/admin/catalog/page.tsx`** — Server Component mirroring `src/app/admin/page.tsx`:
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:

"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:

"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:

<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. cd /Users/simonecavalli/IAMCAVALLI && grep -c '/admin/catalog' src/components/admin/NavBar.tsx Expected: 1 cd /Users/simonecavalli/IAMCAVALLI && grep -c 'export function ServiceTable' src/components/admin/catalog/ServiceTable.tsx Expected: 1 cd /Users/simonecavalli/IAMCAVALLI && grep -c 'export function ServiceForm' src/components/admin/catalog/ServiceForm.tsx Expected: 1 cd /Users/simonecavalli/IAMCAVALLI && grep -c 'getAllServices' src/app/admin/catalog/page.tsx Expected: 1 cd /Users/simonecavalli/IAMCAVALLI && npx tsc --noEmit 2>&1 | head -20 Expected: no output (zero errors) cd /Users/simonecavalli/IAMCAVALLI && npm run build 2>&1 | tail -10 Expected: "Compiled successfully" or "Route (app)" output with no errors 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.

<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>
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

<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>
After completion, create `.planning/phases/03-service-catalog-quote-builder/03-02-SUMMARY.md`