# 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 (
    <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):
```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<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):
```typescript
<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):
```typescript
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 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 (
    <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":
```typescript
<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):
```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
<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:**
```typescript
<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:**
```typescript
<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:**
```typescript
<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:
```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