Files
clienthub/.planning/phases/03-service-catalog-quote-builder/03-PATTERNS.md
T

17 KiB
Raw Blame History

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 14):

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 832):

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 2024):

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 2636):

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 138141):

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 1080):

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

<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 2254):

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:

  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 192211):

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 729):

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 159172):

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 166168):

    service_id: text("service_id")
      .references(() => service_catalog.id, { onDelete: "restrict" }),
      // removed .notNull()
    
  2. Add custom_label field (after subtotal):

    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 2024, 138141

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 10114

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 4664

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 2039

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 3645

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