17 KiB
Phase 3: Service Catalog & Quote Builder — Pattern Map
Mapped: 2026-05-17 Files analyzed: 7 new/modified files Analogs found: 7/7 with exact or role-match
File Classification
| New/Modified File | Role | Data Flow | Closest Analog | Match Quality |
|---|---|---|---|---|
src/app/admin/catalog/page.tsx |
page | request-response | src/app/admin/page.tsx |
exact |
src/app/admin/catalog/actions.ts |
server-actions | CRUD | src/app/admin/clients/[id]/actions.ts |
exact |
src/components/admin/catalog/ServiceTable.tsx |
component | CRUD (display + inline edit) | src/components/admin/DocumentRow.tsx |
exact |
src/components/admin/tabs/QuoteTab.tsx |
component (client) | CRUD | src/components/admin/tabs/PaymentsTab.tsx |
exact |
src/app/admin/clients/[id]/quote-actions.ts |
server-actions | CRUD | src/app/admin/clients/[id]/actions.ts |
exact |
src/components/admin/NavBar.tsx |
component | request-response (MODIFIED) | src/components/admin/NavBar.tsx |
exact |
src/db/schema.ts |
config (MODIFIED) | schema | src/db/schema.ts |
exact |
Pattern Assignments
src/app/admin/catalog/page.tsx (page, request-response)
Analog: src/app/admin/page.tsx
Pattern: Server Component with header, table, and action buttons. Fetches data, renders read-only structure with empty state.
Imports pattern (lines 1–4):
import Link from "next/link";
import { getAllClientsWithPayments } from "@/lib/admin-queries";
import { ClientRow } from "@/components/admin/ClientRow";
import { Button } from "@/components/ui/button";
Page structure (lines 8–32):
export default async function AdminDashboard({
searchParams,
}: {
searchParams: Promise<{ archived?: string }>;
}) {
const { archived } = await searchParams;
const showArchived = archived === "1";
const clients = await getAllClientsWithPayments(showArchived);
return (
<div>
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold text-[#1a1a1a]">Clienti</h1>
<Button asChild>
<Link href="/admin/clients/new">+ Nuovo cliente</Link>
</Button>
</div>
{/* ... table rendering ... */}
</div>
);
}
For Catalog Page: Replace query with getAllServices(), render ServiceTable component, add "+ Aggiungi servizio" button.
src/app/admin/catalog/actions.ts (server-actions, CRUD)
Analog: src/app/admin/clients/[id]/actions.ts
Pattern: Server action exports with Zod schema validation, FormData parsing, DB operations, and revalidatePath.
Zod validation pattern (lines 20–24):
const clientSchema = z.object({
name: z.string().min(1, "Nome richiesto"),
brand_name: z.string().min(1, "Brand name richiesto"),
brief: z.string(),
});
Server action with validation (lines 26–36):
export async function updateClient(clientId: string, formData: FormData) {
const parsed = clientSchema.safeParse({
name: formData.get("name"),
brand_name: formData.get("brand_name"),
brief: formData.get("brief") ?? "",
});
if (!parsed.success) throw new Error(parsed.error.issues[0].message);
await db.update(clients).set(parsed.data).where(eq(clients.id, clientId));
revalidatePath(`/admin/clients/${clientId}`);
revalidatePath("/admin");
}
Document validation pattern (lines 138–141):
const docSchema = z.object({
label: z.string().min(1, "Etichetta richiesta"),
url: z.string().url("URL non valido"),
});
For Catalog Actions: Create serviceSchema with name, description, unit_price. Implement createService, updateService, toggleServiceActive. Path revalidation: /admin/catalog.
src/components/admin/catalog/ServiceTable.tsx (component, CRUD)
Analog: src/components/admin/DocumentRow.tsx
Pattern: Client component with local editing state, inline edit toggle, form submission via Server Action, error handling via useTransition.
DocumentRow structure (lines 10–80):
"use client";
import { useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { updateDocument, deleteDocument } from "@/app/admin/clients/[id]/actions";
import type { Document } from "@/db/schema";
export function DocumentRow({
doc,
clientId,
}: {
doc: Document;
clientId: string;
}) {
const [editing, setEditing] = useState(false);
const [error, setError] = useState<string | null>(null);
const [, startTransition] = useTransition();
const router = useRouter();
function handleSave(fd: FormData) {
setError(null);
startTransition(async () => {
try {
await updateDocument(doc.id, clientId, fd);
setEditing(false);
router.refresh();
} catch (e) {
setError(e instanceof Error ? e.message : "Errore nel salvataggio");
}
});
}
if (editing) {
return (
<form action={handleSave} className="bg-white border-2 border-[#1A463C]/30 rounded-lg px-4 py-3 space-y-2">
<Input name="label" defaultValue={doc.label} required />
<Input name="url" defaultValue={doc.url} type="url" required />
{error && <p className="text-xs text-red-600">{error}</p>}
<div className="flex gap-2 pt-1">
<Button type="submit" size="sm">Salva</Button>
<Button type="button" variant="ghost" size="sm" onClick={() => setEditing(false)}>
Annulla
</Button>
</div>
</form>
);
}
return (
<div className="flex items-center justify-between bg-white border border-[#e5e7eb] rounded-lg px-4 py-3 group">
<a href={doc.url} className="text-sm text-[#1A463C] hover:underline font-medium">
{doc.label}
</a>
<div className="flex items-center gap-1">
<Button variant="ghost" size="sm" onClick={() => setEditing(true)}>
Modifica
</Button>
<Button variant="ghost" size="sm" onClick={handleDelete}>
Rimuovi
</Button>
</div>
</div>
);
}
For ServiceTable: Render as table (not row), include service name, description, price, active status. Toggle row → editable inputs (name, description, price). Delete = soft toggle (active = false). Hover reveal "Disattiva"/"Riattiva" button.
Table styling (from admin/page.tsx lines 46–64):
<div className="bg-white rounded-lg border border-[#e5e7eb] overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-[#f9f9f9] border-b border-[#e5e7eb]">
<tr>
<th className="text-left py-3 px-4 font-medium text-[#71717a]">Column</th>
</tr>
</thead>
<tbody>
{/* rows */}
</tbody>
</table>
</div>
src/components/admin/tabs/QuoteTab.tsx (component client, CRUD)
Analog: src/components/admin/tabs/PaymentsTab.tsx
Pattern: Async server component (not client) that receives props (items, services, acceptedTotal, clientId), renders multiple form sections, each with its own Server Action call.
PaymentsTab structure (lines 22–54):
export async function PaymentsTab({ payments, acceptedTotal, clientId }: Props) {
return (
<div className="space-y-6 max-w-md">
<div className="bg-white border border-gray-200 rounded-lg p-4">
<h3 className="font-medium text-gray-900 mb-3">Totale preventivo</h3>
<form
action={async (fd: FormData) => {
"use server";
await updateAcceptedTotal(clientId, fd);
}}
className="flex items-end gap-3"
>
<div className="space-y-1 flex-1">
<Label htmlFor="accepted_total">Importo (€)</Label>
<Input
id="accepted_total"
name="accepted_total"
type="number"
step="0.01"
min="0"
defaultValue={acceptedTotal}
/>
</div>
<Button type="submit" size="sm">Salva</Button>
</form>
</div>
{payments.map((p) => (
<div key={p.id} className="bg-white border border-gray-200 rounded-lg p-4">
{/* ... */}
</div>
))}
</div>
);
}
For QuoteTab: Structure as three sections:
- Add items (dropdown catalog + qty OR toggle to custom label/price/qty)
- Quote items table (Voce | Qty | Unit Price | Subtotal | Delete)
- Accepted total (editable input + Save button)
Each section is its own form with inline Server Action call. Use same card styling (bg-white border border-[#e5e7eb] rounded-lg p-4).
src/app/admin/clients/[id]/quote-actions.ts (server-actions, CRUD)
Analog: src/app/admin/clients/[id]/actions.ts
Pattern: Identical to catalog actions — Zod validation, FormData parsing, numeric precision handling.
Numeric precision pattern (lines 192–211):
export async function updateAcceptedTotal(clientId: string, formData: FormData) {
const raw = (formData.get("accepted_total") as string)?.trim();
const val = parseFloat(raw);
if (isNaN(val) || val < 0) throw new Error("Importo non valido");
await db
.update(clients)
.set({ accepted_total: val.toFixed(2) })
.where(eq(clients.id, clientId));
revalidatePath(`/admin/clients/${clientId}`);
}
For Quote Actions: Implement:
addQuoteItem(clientId, formData)— parse service_id (nullable), custom_label (nullable), quantity, unit_price. Calculate subtotal. Insert into quote_items.removeQuoteItem(quoteItemId, clientId)— delete from quote_items.updateAcceptedTotal(clientId, formData)— identical to existing pattern in actions.ts.
All paths: revalidatePath(/admin/clients/${clientId}).
src/components/admin/NavBar.tsx (component, request-response — MODIFIED)
Analog: src/components/admin/NavBar.tsx
Current structure (lines 7–29):
export function NavBar() {
return (
<nav className="bg-[#1A463C] px-6 py-3 flex items-center justify-between">
<div className="flex items-center gap-6">
<span className="font-bold text-white tracking-tight">iamcavalli</span>
<Link href="/admin" className="text-sm text-white/70 hover:text-white transition-colors">
Clienti
</Link>
<Link href="/admin/analytics" className="text-sm text-white/70 hover:text-white transition-colors">
Statistiche
</Link>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => signOut({ callbackUrl: "/admin/login" })}
className="text-sm text-white/70 hover:text-white hover:bg-white/10"
>
Esci
</Button>
</nav>
);
}
Modification: Add new Link after "Statistiche":
<Link href="/admin/catalog" className="text-sm text-white/70 hover:text-white transition-colors">
Catalogo
</Link>
src/db/schema.ts (config — MODIFIED)
Analog: src/db/schema.ts
Current quote_items definition (lines 159–172):
export const quote_items = pgTable("quote_items", {
id: text("id")
.primaryKey()
.$defaultFn(() => nanoid()),
client_id: text("client_id")
.notNull()
.references(() => clients.id, { onDelete: "cascade" }),
service_id: text("service_id")
.notNull()
.references(() => service_catalog.id, { onDelete: "restrict" }),
quantity: numeric("quantity", { precision: 10, scale: 2 }).notNull(),
unit_price: numeric("unit_price", { precision: 10, scale: 2 }).notNull(),
subtotal: numeric("subtotal", { precision: 10, scale: 2 }).notNull(),
});
Required changes:
-
Make service_id nullable (line 166–168):
service_id: text("service_id") .references(() => service_catalog.id, { onDelete: "restrict" }), // removed .notNull() -
Add custom_label field (after subtotal):
custom_label: text("custom_label"),
After schema changes:
- Run
npx drizzle-kit pushto apply migrations to database - Verify no TypeScript errors in types (QuoteItem type will auto-update)
Shared Patterns
Form Validation (All CRUD Actions)
Source: src/app/admin/clients/[id]/actions.ts lines 20–24, 138–141
Pattern: Use Zod schema with .safeParse(), throw first error message.
Apply to: All catalog and quote actions
import { z } from "zod";
const serviceSchema = z.object({
name: z.string().min(1, "Nome richiesto"),
description: z.string().optional(),
unit_price: z.coerce.number().positive("Prezzo deve essere positivo"),
});
export async function createService(formData: FormData) {
const parsed = serviceSchema.safeParse({
name: formData.get("name"),
description: formData.get("description") ?? "",
unit_price: formData.get("unit_price"),
});
if (!parsed.success) throw new Error(parsed.error.issues[0].message);
await db.insert(service_catalog).values(parsed.data);
revalidatePath("/admin/catalog");
}
Inline Edit Component Pattern (ServiceTable, ServiceRow)
Source: src/components/admin/DocumentRow.tsx lines 10–114
Pattern:
- "use client" directive
- useState for
editing,error - useTransition for async form submission
- useRouter for refresh
- Toggle render: editing mode (form inputs) vs read mode (display + hover buttons)
- Server Action called inline in form action
Apply to: ServiceTable with per-row inline edit.
Currency Formatting
Source: src/components/admin/ClientRow.tsx line 33
Pattern:
€{parseFloat(amount).toLocaleString("it-IT", { minimumFractionDigits: 2 })}
Apply to: All price displays in ServiceTable and QuoteTab.
Table Styling
Source: src/app/admin/page.tsx lines 46–64
Pattern:
<div className="bg-white rounded-lg border border-[#e5e7eb] overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-[#f9f9f9] border-b border-[#e5e7eb]">
<tr>
<th className="text-left py-3 px-4 font-medium text-[#71717a]">Colonna</th>
</tr>
</thead>
<tbody>
{items.map(item => (
<tr key={item.id} className="border-b border-[#f4f4f5] hover:bg-[#f9f9f9]">
<td className="py-3 px-4">…</td>
</tr>
))}
</tbody>
</table>
</div>
Apply to: ServiceTable layout in catalog/page.tsx
Card Styling (Forms, Sections)
Source: src/components/admin/tabs/DocumentsTab.tsx line 18
Pattern:
<div className="bg-white border border-[#e5e7eb] rounded-lg p-4 space-y-3">
<h3 className="font-medium text-[#1a1a1a]">Titolo</h3>
{/* content */}
</div>
Apply to: All form sections in QuoteTab and ServiceTable.
Label + Input Grid
Source: src/components/admin/tabs/DocumentsTab.tsx lines 20–39
Pattern:
<div className="space-y-1">
<Label htmlFor="field-id">Label testo</Label>
<Input
id="field-id"
name="field-name"
type="text"
placeholder="placeholder"
required
/>
</div>
Apply to: All form inputs in catalog and quote builders.
Numeric Input Pattern
Source: src/components/admin/tabs/PaymentsTab.tsx lines 36–45
Pattern:
<Input
id="price"
name="unit_price"
type="number"
step="0.01"
min="0"
defaultValue={price}
/>
Apply to: All price/quantity inputs; use step="0.01" for EUR precision.
No Analog Found
No files require external patterns. All code patterns (Server Actions, inline edit, table layout, form validation) exist in the codebase.
Query Pattern (for page data fetching)
Not extracted as code — will be implemented in quote-actions.ts and documented in planning phase.
Example from RESEARCH.md:
// Get all active services for dropdown
const activeServices = await db
.select()
.from(service_catalog)
.where(eq(service_catalog.active, true))
.orderBy(asc(service_catalog.name));
// Get quote items with service names
const items = await db
.select({
id: quote_items.id,
label: sql`COALESCE(${service_catalog.name}, ${quote_items.custom_label})`,
quantity: quote_items.quantity,
unit_price: quote_items.unit_price,
subtotal: quote_items.subtotal,
})
.from(quote_items)
.leftJoin(service_catalog, eq(quote_items.service_id, service_catalog.id))
.where(eq(quote_items.client_id, clientId));
Metadata
Analog search scope: /src/app/admin/, /src/components/admin/, /src/app/admin/clients/[id]/
Files scanned: 13 analog files
Pattern extraction date: 2026-05-17
Coverage summary:
- Exact match (same role + data flow): 7/7
- Role-match (same role, similar flow): 0
- No analog: 0
Key insights:
- Phase 2 established Server Actions + Zod pattern — directly reusable for Phase 3 CRUD
- Inline edit pattern from DocumentRow is the gold standard for catalog service editing
- PaymentsTab structure fits QuoteTab exactly (multiple form sections, each with own Server Action)
- Table styling is consistent across admin interface — use directly
- No new dependencies or libraries needed — all patterns are vanilla React + Next.js built-ins