# 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): ```typescript import Link from "next/link"; import { getAllClientsWithPayments } from "@/lib/admin-queries"; import { ClientRow } from "@/components/admin/ClientRow"; import { Button } from "@/components/ui/button"; ``` **Page structure** (lines 8–32): ```typescript export default async function AdminDashboard({ searchParams, }: { searchParams: Promise<{ archived?: string }>; }) { const { archived } = await searchParams; const showArchived = archived === "1"; const clients = await getAllClientsWithPayments(showArchived); return (

Clienti

{/* ... table rendering ... */}
); } ``` **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): ```typescript const clientSchema = z.object({ name: z.string().min(1, "Nome richiesto"), brand_name: z.string().min(1, "Brand name richiesto"), brief: z.string(), }); ``` **Server action with validation** (lines 26–36): ```typescript export async function updateClient(clientId: string, formData: FormData) { const parsed = clientSchema.safeParse({ name: formData.get("name"), brand_name: formData.get("brand_name"), brief: formData.get("brief") ?? "", }); if (!parsed.success) throw new Error(parsed.error.issues[0].message); await db.update(clients).set(parsed.data).where(eq(clients.id, clientId)); revalidatePath(`/admin/clients/${clientId}`); revalidatePath("/admin"); } ``` **Document validation pattern** (lines 138–141): ```typescript const docSchema = z.object({ label: z.string().min(1, "Etichetta richiesta"), url: z.string().url("URL non valido"), }); ``` **For Catalog Actions:** Create `serviceSchema` with name, description, unit_price. Implement `createService`, `updateService`, `toggleServiceActive`. Path revalidation: `/admin/catalog`. --- ### `src/components/admin/catalog/ServiceTable.tsx` (component, CRUD) **Analog:** `src/components/admin/DocumentRow.tsx` **Pattern:** Client component with local `editing` state, inline edit toggle, form submission via Server Action, error handling via useTransition. **DocumentRow structure** (lines 10–80): ```typescript "use client"; import { useState, useTransition } from "react"; import { useRouter } from "next/navigation"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { updateDocument, deleteDocument } from "@/app/admin/clients/[id]/actions"; import type { Document } from "@/db/schema"; export function DocumentRow({ doc, clientId, }: { doc: Document; clientId: string; }) { const [editing, setEditing] = useState(false); const [error, setError] = useState(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 (
{error &&

{error}

}
); } return (
{doc.label}
); } ``` **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): ```typescript
{/* rows */}
Column
``` --- ### `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): ```typescript export async function PaymentsTab({ payments, acceptedTotal, clientId }: Props) { return (

Totale preventivo

{ "use server"; await updateAcceptedTotal(clientId, fd); }} className="flex items-end gap-3" >
{payments.map((p) => (
{/* ... */}
))}
); } ``` **For QuoteTab:** Structure as three sections: 1. Add items (dropdown catalog + qty OR toggle to custom label/price/qty) 2. Quote items table (Voce | Qty | Unit Price | Subtotal | Delete) 3. Accepted total (editable input + Save button) Each section is its own form with inline Server Action call. Use same card styling (`bg-white border border-[#e5e7eb] rounded-lg p-4`). --- ### `src/app/admin/clients/[id]/quote-actions.ts` (server-actions, CRUD) **Analog:** `src/app/admin/clients/[id]/actions.ts` **Pattern:** Identical to catalog actions — Zod validation, FormData parsing, numeric precision handling. **Numeric precision pattern** (lines 192–211): ```typescript export async function updateAcceptedTotal(clientId: string, formData: FormData) { const raw = (formData.get("accepted_total") as string)?.trim(); const val = parseFloat(raw); if (isNaN(val) || val < 0) throw new Error("Importo non valido"); await db .update(clients) .set({ accepted_total: val.toFixed(2) }) .where(eq(clients.id, clientId)); revalidatePath(`/admin/clients/${clientId}`); } ``` **For Quote Actions:** Implement: - `addQuoteItem(clientId, formData)` — parse service_id (nullable), custom_label (nullable), quantity, unit_price. Calculate subtotal. Insert into quote_items. - `removeQuoteItem(quoteItemId, clientId)` — delete from quote_items. - `updateAcceptedTotal(clientId, formData)` — identical to existing pattern in actions.ts. All paths: `revalidatePath(/admin/clients/${clientId})`. --- ### `src/components/admin/NavBar.tsx` (component, request-response — MODIFIED) **Analog:** `src/components/admin/NavBar.tsx` **Current structure** (lines 7–29): ```typescript export function NavBar() { return ( ); } ``` **Modification:** Add new Link after "Statistiche": ```typescript Catalogo ``` --- ### `src/db/schema.ts` (config — MODIFIED) **Analog:** `src/db/schema.ts` **Current quote_items definition** (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() .references(() => service_catalog.id, { onDelete: "restrict" }), quantity: numeric("quantity", { precision: 10, scale: 2 }).notNull(), unit_price: numeric("unit_price", { precision: 10, scale: 2 }).notNull(), subtotal: numeric("subtotal", { precision: 10, scale: 2 }).notNull(), }); ``` **Required changes:** 1. **Make service_id nullable** (line 166–168): ```typescript service_id: text("service_id") .references(() => service_catalog.id, { onDelete: "restrict" }), // removed .notNull() ``` 2. **Add custom_label field** (after subtotal): ```typescript custom_label: text("custom_label"), ``` **After schema changes:** - Run `npx drizzle-kit push` to apply migrations to database - Verify no TypeScript errors in types (QuoteItem type will auto-update) --- ## Shared Patterns ### Form Validation (All CRUD Actions) **Source:** `src/app/admin/clients/[id]/actions.ts` lines 20–24, 138–141 **Pattern:** Use Zod schema with `.safeParse()`, throw first error message. **Apply to:** All catalog and quote actions ```typescript import { z } from "zod"; const serviceSchema = z.object({ name: z.string().min(1, "Nome richiesto"), description: z.string().optional(), unit_price: z.coerce.number().positive("Prezzo deve essere positivo"), }); export async function createService(formData: FormData) { const parsed = serviceSchema.safeParse({ name: formData.get("name"), description: formData.get("description") ?? "", unit_price: formData.get("unit_price"), }); if (!parsed.success) throw new Error(parsed.error.issues[0].message); await db.insert(service_catalog).values(parsed.data); revalidatePath("/admin/catalog"); } ``` ### Inline Edit Component Pattern (ServiceTable, ServiceRow) **Source:** `src/components/admin/DocumentRow.tsx` lines 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:** ```typescript €{parseFloat(amount).toLocaleString("it-IT", { minimumFractionDigits: 2 })} ``` **Apply to:** All price displays in ServiceTable and QuoteTab. ### Table Styling **Source:** `src/app/admin/page.tsx` lines 46–64 **Pattern:** ```typescript
{items.map(item => ( ))}
Colonna
``` **Apply to:** ServiceTable layout in catalog/page.tsx ### Card Styling (Forms, Sections) **Source:** `src/components/admin/tabs/DocumentsTab.tsx` line 18 **Pattern:** ```typescript

Titolo

{/* content */}
``` **Apply to:** All form sections in QuoteTab and ServiceTable. ### Label + Input Grid **Source:** `src/components/admin/tabs/DocumentsTab.tsx` lines 20–39 **Pattern:** ```typescript
``` **Apply to:** All form inputs in catalog and quote builders. ### Numeric Input Pattern **Source:** `src/components/admin/tabs/PaymentsTab.tsx` lines 36–45 **Pattern:** ```typescript ``` **Apply to:** All price/quantity inputs; use `step="0.01"` for EUR precision. --- ## No Analog Found No files require external patterns. All code patterns (Server Actions, inline edit, table layout, form validation) exist in the codebase. --- ## Query Pattern (for page data fetching) **Not extracted as code** — will be implemented in quote-actions.ts and documented in planning phase. Example from RESEARCH.md: ```typescript // Get all active services for dropdown const activeServices = await db .select() .from(service_catalog) .where(eq(service_catalog.active, true)) .orderBy(asc(service_catalog.name)); // Get quote items with service names const items = await db .select({ id: quote_items.id, label: sql`COALESCE(${service_catalog.name}, ${quote_items.custom_label})`, quantity: quote_items.quantity, unit_price: quote_items.unit_price, subtotal: quote_items.subtotal, }) .from(quote_items) .leftJoin(service_catalog, eq(quote_items.service_id, service_catalog.id)) .where(eq(quote_items.client_id, clientId)); ``` --- ## Metadata **Analog search scope:** `/src/app/admin/`, `/src/components/admin/`, `/src/app/admin/clients/[id]/` **Files scanned:** 13 analog files **Pattern extraction date:** 2026-05-17 **Coverage summary:** - Exact match (same role + data flow): 7/7 - Role-match (same role, similar flow): 0 - No analog: 0 **Key insights:** - Phase 2 established Server Actions + Zod pattern — directly reusable for Phase 3 CRUD - Inline edit pattern from DocumentRow is the gold standard for catalog service editing - PaymentsTab structure fits QuoteTab exactly (multiple form sections, each with own Server Action) - Table styling is consistent across admin interface — use directly - No new dependencies or libraries needed — all patterns are vanilla React + Next.js built-ins