# Phase 14: CRM Attio-style & Fix - Pattern Map

**Mapped:** 2026-06-14
**Files analyzed:** 12 (8 to create/modify, 4 to reuse as-is)
**Analogs found:** 8 / 8 (100% coverage from Phase 11)

---

## File Classification

| New/Modified File | Role | Data Flow | Closest Analog | Match Quality |
|-------------------|------|-----------|----------------|---------------|
| `src/components/admin/leads/LeadTable.tsx` | component | CRUD (inline-edit + render) | `src/components/admin/catalog/ServiceTable.tsx` | exact |
| `src/app/admin/leads/LeadsSearch.tsx` | component | request-response (client-side filter) | `src/app/admin/catalog/CatalogSearch.tsx` | exact |
| `src/app/admin/leads/actions.ts` | server action | CRUD (field update, tag add/remove/rename) | `src/app/admin/catalog/actions.ts` | exact |
| `src/lib/admin-queries.ts` (extend) | query service | CRUD (entity + tags join) | `src/lib/admin-queries.ts` (getAllServices pattern) | exact |
| `src/components/admin/dashboard/FollowUpWidget.tsx` | component | request-response (i18n fix) | self | N/A (fix-only) |
| `src/components/admin/leads/LeadForm.tsx` | component | request-response (type-safety fix) | self | N/A (fix-only) |
| `src/components/admin/leads/SendQuoteModal.tsx` | component | request-response (dead-code fix) | self | N/A (fix-only) |
| `src/components/ui/editable-cell.tsx` | component (reuse) | N/A | Phase 11 shipped | exact |
| `src/components/ui/option-select.tsx` | component (reuse) | N/A | Phase 11 shipped | exact |
| `src/components/ui/option-multi-select.tsx` | component (reuse) | N/A | Phase 11 shipped | exact |
| `src/components/ui/option-colors.ts` | utility (reuse) | N/A | Phase 11 shipped | exact |

---

## Pattern Assignments

### `src/components/admin/leads/LeadTable.tsx` (component, CRUD — rewrite)

**Analog:** `src/components/admin/catalog/ServiceTable.tsx` (Phase 11, shipped)

**Role:** Client component (`"use client"`), renders table rows with inline-editable cells and dropdown selects. Wires cell edits to server actions via `useTransition()` + `router.refresh()`.

**Imports pattern** (lines 1–16):
```typescript
"use client";

import { useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { Input } from "@/components/ui/input";
import { EditableCell } from "@/components/ui/editable-cell";
import { OptionSelect } from "@/components/ui/option-select";
import { OptionMultiSelect } from "@/components/ui/option-multi-select";
import {
  updateLeadField,
  addLeadTag,
  removeLeadTag,
  renameLeadTag,
} from "@/app/admin/leads/actions";
import type { LeadWithTags, LeadFieldOptions } from "@/lib/admin-queries";
```

**Core row pattern** (mirrored from ServiceTable lines 35–140):
```typescript
function LeadRow({
  lead,
  options,
}: {
  lead: LeadWithTags;
  options: LeadFieldOptions;
}) {
  const router = useRouter();
  const [, startTransition] = useTransition();
  const [error, setError] = useState<string | null>(null);

  function run(fn: () => Promise<unknown>) {
    setError(null);
    startTransition(async () => {
      try {
        await fn();
        router.refresh();
      } catch (e) {
        setError(e instanceof Error ? e.message : "Errore nel salvataggio");
      }
    });
  }

  return (
    <>
    <tr className="border-b border-[#e5e7eb] hover:bg-[#f9f9f9] transition-colors duration-150">
      {/* Name — EditableCell, required, linked to detail */}
      <td className="py-2 px-3 font-medium text-[#1a1a1a] min-w-[160px]">
        <EditableCell
          value={lead.name}
          type="text"
          required
          onSave={(v) => run(() => updateLeadField(lead.id, "name", v))}
        />
      </td>
      
      {/* Email — EditableCell, optional */}
      <td className="py-2 px-3 min-w-[160px]">
        <EditableCell
          value={lead.email ?? "—"}
          type="text"
          onSave={(v) => run(() => updateLeadField(lead.id, "email", v))}
        />
      </td>
      
      {/* Phone — EditableCell, optional */}
      <td className="py-2 px-3 min-w-[140px]">
        <EditableCell
          value={lead.phone ?? "—"}
          type="text"
          onSave={(v) => run(() => updateLeadField(lead.id, "phone", v))}
        />
      </td>
      
      {/* Company — EditableCell, optional */}
      <td className="py-2 px-3 min-w-[140px]">
        <EditableCell
          value={lead.company ?? "—"}
          type="text"
          onSave={(v) => run(() => updateLeadField(lead.id, "company", v))}
        />
      </td>

      {/* Status — OptionSelect, fixed enum (LEAD_STAGES), no create-on-the-fly */}
      <td className="py-2 px-3 min-w-[120px]">
        <OptionSelect
          value={lead.status}
          options={options.status}
          onChange={(v) => run(() => updateLeadField(lead.id, "status", v ?? "contacted"))}
        />
      </td>

      {/* Next Action — EditableCell, optional */}
      <td className="py-2 px-3 min-w-[180px]">
        <EditableCell
          value={lead.next_action ?? "—"}
          type="text"
          onSave={(v) => run(() => updateLeadField(lead.id, "next_action", v))}
        />
      </td>

      {/* Tags — OptionMultiSelect, create-on-the-fly enabled */}
      <td className="py-2 px-3 min-w-[180px]">
        <OptionMultiSelect
          values={lead.tags}
          options={options.tags}
          onAdd={(v) => run(() => addLeadTag(lead.id, v))}
          onRemove={(v) => run(() => removeLeadTag(lead.id, v))}
          onRename={(o, n) => run(() => renameLeadTag(o, n))}
        />
      </td>

      {/* Actions — Link to detail page */}
      <td className="py-2 px-3 min-w-[80px]">
        <Button variant="ghost" size="sm" asChild>
          <Link href={`/admin/leads/${lead.id}`}>Dettagli</Link>
        </Button>
      </td>
    </tr>
    {error && (
      <tr>
        <td colSpan={8} className="py-1 px-3 text-xs text-red-600">
          {error}
        </td>
      </tr>
    )}
    </>
  );
}

export function LeadTable({
  leads,
  options,
}: {
  leads: LeadWithTags[];
  options: LeadFieldOptions;
}) {
  const COLUMN_HEADERS = [
    "Nome",
    "Email",
    "Telefono",
    "Azienda",
    "Stato",
    "Prossima Azione",
    "Tag",
    "Azioni",
  ];
  const COL_COUNT = COLUMN_HEADERS.length;

  return (
    <div className="bg-white rounded-xl border border-[#e5e7eb] overflow-hidden">
      <div className="overflow-x-auto">
        <table className="w-full text-sm">
          <thead className="bg-[#f9f9f9] border-b border-[#e5e7eb] sticky top-0 z-[1]">
            <tr>
              {COLUMN_HEADERS.map((header) => (
                <th
                  key={header}
                  className="text-left py-2 px-3 font-semibold text-[#71717a]"
                >
                  {header}
                </th>
              ))}
            </tr>
          </thead>
          <tbody>
            {leads.map((lead) => (
              <LeadRow key={lead.id} lead={lead} options={options} />
            ))}
          </tbody>
        </table>
      </div>

      {leads.length === 0 && (
        <div className="text-center py-8 text-gray-500">Nessun lead trovato</div>
      )}
    </div>
  );
}
```

**Key differences from ServiceTable:**
- LeadTable has no `QuickAddRow` (out of scope per CRM-08..12)
- LeadTable has no inactive-services grouping section
- LeadTable's `status` field uses fixed enum (no create-on-the-fly); CatalogSearch's `categoria`/`fase` are open-ended

---

### `src/app/admin/leads/LeadsSearch.tsx` (component, request-response — new)

**Analog:** `src/app/admin/catalog/CatalogSearch.tsx` (Phase 11, shipped, lines 1–46)

**Role:** Client component (`"use client"`), renders search input + calls `LeadTable` with filtered results.

**Imports and structure** (lines 1–46):
```typescript
"use client";

import { useState, useMemo } from "react";
import { Input } from "@/components/ui/input";
import { Search } from "lucide-react";
import { LeadTable } from "@/components/admin/leads/LeadTable";
import type { LeadWithTags, LeadFieldOptions } from "@/lib/admin-queries";

export function LeadsSearch({
  leads,
  options,
}: {
  leads: LeadWithTags[];
  options: LeadFieldOptions;
}) {
  const [query, setQuery] = useState("");

  const filtered = useMemo(() => {
    const q = query.trim().toLowerCase();
    if (!q) return leads;
    return leads.filter((l) =>
      l.name.toLowerCase().includes(q) ||
      l.email?.toLowerCase().includes(q) ||
      l.company?.toLowerCase().includes(q) ||
      l.status.toLowerCase().includes(q) ||
      l.tags.some((t) => t.toLowerCase().includes(q))
    );
  }, [leads, query]);

  return (
    <div className="space-y-4">
      <div className="relative max-w-sm">
        <Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-[#71717a]" />
        <Input
          type="text"
          placeholder="Cerca lead..."
          value={query}
          onChange={(e) => setQuery(e.target.value)}
          className="pl-9 h-9"
        />
      </div>
      <LeadTable leads={filtered} options={options} />
    </div>
  );
}
```

---

### `src/app/admin/leads/actions.ts` (server action, CRUD — extend)

**Analog:** `src/app/admin/catalog/actions.ts` (Phase 11, shipped)

**Role:** Server actions (`"use server"`), field-update + tag CRUD with `requireAdmin()` auth guard and `revalidatePath()` side effects.

**Auth guard pattern** (catalog/actions.ts lines 18–21):
```typescript
"use server";

import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";

async function requireAdmin() {
  const session = await getServerSession(authOptions);
  if (!session) throw new Error("Non autorizzato");
}
```

**Field update pattern with allowlist** (catalog/actions.ts lines 71–116):
```typescript
const EDITABLE_FIELDS = ["name", "email", "phone", "company", "status", "next_action"] as const;
type EditableField = (typeof EDITABLE_FIELDS)[number];

export async function updateLeadField(
  leadId: string,
  fieldName: EditableField,
  value: string
) {
  await requireAdmin();

  if (!EDITABLE_FIELDS.includes(fieldName)) {
    throw new Error(`Campo non editabile: ${fieldName}`);
  }

  if (fieldName === "name") {
    const s = String(value).trim();
    if (s.length === 0) throw new Error("Nome richiesto");
    await db.update(leads).set({ name: s, updated_at: new Date() }).where(eq(leads.id, leadId));
  } else if (fieldName === "status") {
    // Validate against LEAD_STAGES enum (defense-in-depth, even if UI restricts it)
    if (!LEAD_STAGES.includes(value as any)) {
      throw new Error("Stato non valido");
    }
    await db.update(leads).set({ status: value, updated_at: new Date() }).where(eq(leads.id, leadId));
  } else if (fieldName === "email" || fieldName === "phone" || fieldName === "company" || fieldName === "next_action") {
    // Nullable text fields — allow empty to clear
    const s = String(value).trim();
    await db
      .update(leads)
      .set({ [fieldName]: s || null, updated_at: new Date() })
      .where(eq(leads.id, leadId));
  }

  revalidatePath("/admin/leads");
  revalidatePath(`/admin/leads/${leadId}`);
}
```

**Tag CRUD pattern** (catalog/actions.ts lines 131–199, ported to leads):
```typescript
const LEADS_TAG_ENTITY = "leads";

export async function addLeadTag(leadId: string, value: string) {
  await requireAdmin();
  const trimmed = value.trim();
  if (trimmed.length === 0) throw new Error("Valore richiesto");

  await db
    .insert(tags)
    .values({ entity_type: LEADS_TAG_ENTITY, entity_id: leadId, name: trimmed })
    .onConflictDoNothing();

  revalidatePath("/admin/leads");
}

export async function removeLeadTag(leadId: string, value: string) {
  await requireAdmin();

  await db
    .delete(tags)
    .where(
      and(
        eq(tags.entity_type, LEADS_TAG_ENTITY),
        eq(tags.entity_id, leadId),
        eq(tags.name, value)
      )
    );

  revalidatePath("/admin/leads");
}

export async function renameLeadTag(oldValue: string, newValue: string) {
  await requireAdmin();
  const next = newValue.trim();
  if (next.length === 0) throw new Error("Nuovo nome richiesto");
  if (next === oldValue) return;

  await db
    .update(tags)
    .set({ name: next })
    .where(
      and(
        eq(tags.entity_type, LEADS_TAG_ENTITY),
        eq(tags.name, oldValue)
      )
    );

  revalidatePath("/admin/leads");
}
```

**Key imports** (catalog/actions.ts lines 1–9):
```typescript
import { db } from "@/db";
import { leads, tags } from "@/db/schema";
import { revalidatePath } from "next/cache";
import { eq, and } from "drizzle-orm";
import { LEAD_STAGES } from "@/lib/lead-validators";
```

---

### `src/lib/admin-queries.ts` (extend — query service, CRUD)

**Analog:** `src/lib/admin-queries.ts` getAllServices + getCatalogFieldOptions (Phase 11, lines 360–448)

**Role:** Query layer, provides `LeadWithTags` type and two functions: `getLeadsWithTags()` (left-join + map-reduce), `getLeadFieldOptions()` (distinct values).

**LeadWithTags type and join pattern** (mirrored from getAllServices lines 360–409):
```typescript
const LEADS_TAG_ENTITY = "leads";

export type LeadWithTags = Lead & { tags: string[] };

export async function getLeadsWithTags(): Promise<LeadWithTags[]> {
  const rows = await db
    .select({
      // All leads columns
      id: leads.id,
      name: leads.name,
      email: leads.email,
      phone: leads.phone,
      company: leads.company,
      status: leads.status,
      last_contact_date: leads.last_contact_date,
      next_action: leads.next_action,
      next_action_date: leads.next_action_date,
      notes: leads.notes,
      created_at: leads.created_at,
      updated_at: leads.updated_at,
      // Tag name for join
      tag_name: tags.name,
    })
    .from(leads)
    .leftJoin(
      tags,
      and(
        eq(tags.entity_id, leads.id),
        eq(tags.entity_type, LEADS_TAG_ENTITY)
      )
    )
    .orderBy(asc(leads.updated_at), asc(tags.name));

  const leadMap = new Map<string, LeadWithTags>();
  for (const row of rows) {
    const { tag_name, ...leadFields } = row;
    if (!leadMap.has(row.id)) leadMap.set(row.id, { ...leadFields, tags: [] });
    if (tag_name) leadMap.get(row.id)!.tags.push(tag_name);
  }
  return Array.from(leadMap.values());
}
```

**Field options pattern** (mirrored from getCatalogFieldOptions lines 414–448):
```typescript
export type LeadFieldOptions = { status: string[]; tags: string[] };

export async function getLeadFieldOptions(): Promise<LeadFieldOptions> {
  const tagRows = await db
    .selectDistinct({ name: tags.name })
    .from(tags)
    .where(eq(tags.entity_type, LEADS_TAG_ENTITY));

  return {
    status: [...LEAD_STAGES], // Fixed enum, not dynamic
    tags: tagRows
      .map((r) => r.name)
      .sort((a, b) => a.localeCompare(b, "it")),
  };
}
```

**Imports** (add to existing admin-queries.ts top):
```typescript
import { and, asc } from "drizzle-orm";
import type { Lead } from "@/db/schema";
import { LEAD_STAGES } from "@/lib/lead-validators";
```

---

### `src/components/admin/dashboard/FollowUpWidget.tsx` (component, request-response — fix CRM-10)

**Current analog:** self (existing file, lines 1–47)

**Issue:** 6 hardcoded English strings need Italian translation.

**Strings to fix:**
1. Line 15: `"Require Follow-up"` → `"Richiedi Follow-up"`
2. Line 22: `"lead"` / `"leads"` → Always `"lead"` in Italian (loanword, plural also "lead")
3. Line 23: `"to contact"` → `"da contattare"`
4. Line 30: `"Contact"` → `"Contatta"`
5. Line 37: `"View All"` → `"Vedi Tutto"`
6. Line 42: `"All leads are up to date!"` → `"Tutti i lead sono aggiornati!"`

**Fixed version:**
```typescript
export async function FollowUpWidget() {
  const leadsNeedingFollowUp = await getLeadsNeedingFollowUp(7);

  return (
    <Card className="border-orange-200 bg-orange-50">
      <CardHeader>
        <CardTitle className="flex items-center gap-2">
          <AlertCircle className="h-5 w-5 text-orange-600" />
          Richiedi Follow-up
        </CardTitle>
      </CardHeader>
      <CardContent className="space-y-4">
        {leadsNeedingFollowUp.length > 0 ? (
          <>
            <p className="text-lg font-bold text-orange-900">
              {leadsNeedingFollowUp.length} lead da contattare
            </p>
            <ul className="space-y-2 text-sm">
              {leadsNeedingFollowUp.slice(0, 3).map((lead) => (
                <li key={lead.id} className="flex justify-between items-center">
                  <span>{lead.name}</span>
                  <Button variant="ghost" size="sm" asChild>
                    <Link href={`/admin/leads/${lead.id}`}>Contatta</Link>
                  </Button>
                </li>
              ))}
            </ul>
            {leadsNeedingFollowUp.length > 3 && (
              <Button variant="outline" className="w-full" asChild>
                <Link href="/admin/leads">Vedi Tutto ({leadsNeedingFollowUp.length})</Link>
              </Button>
            )}
          </>
        ) : (
          <p className="text-gray-600 text-sm">Tutti i lead sono aggiornati!</p>
        )}
      </CardContent>
    </Card>
  );
}
```

---

### `src/components/admin/leads/LeadForm.tsx` (component, request-response — fix CRM-11)

**Current analog:** self (existing file, lines 1–100+)

**Issue:** `useForm<any>()` defeats Zod type inference. Fix: use `CreateLeadInput` type exported from `lead-validators.ts`.

**Current pattern (lines 41–51, problematic):**
```typescript
const form = useForm<any>({
  resolver: zodResolver(createLeadSchema),
  defaultValues: {
    name: "",
    email: "",
    phone: "",
    company: "",
    status: "contacted",
    notes: "",
  },
});
```

**Fixed pattern:**
```typescript
import { CreateLeadInput } from "@/lib/lead-validators";

const form = useForm<CreateLeadInput>({
  resolver: zodResolver(createLeadSchema),
  defaultValues: {
    name: "",
    email: "",
    phone: "",
    company: "",
    status: "contacted",
    notes: "",
  },
});

async function onSubmit(data: CreateLeadInput) {
  // No `data: any` — type is now inferred
  setLoading(true);
  try {
    const result = await createLead(data);
    // ...
  } finally {
    setLoading(false);
  }
}
```

**Key change:** Remove all `as any` casts and replace `useForm<any>()` with `useForm<CreateLeadInput>()`. The one unavoidable cast at the schema boundary (`lead.status as CreateLeadInput["status"]`) is acceptable because it's narrow and type-checked at compile time.

---

### `src/components/admin/leads/SendQuoteModal.tsx` (component, request-response — fix CRM-12)

**Current analog:** self (existing file, lines 1–110+)

**Issue:** Dead code — `if (tab === "new")` branch inside `onSubmit` (form handler) can never execute because the "new" tab has its own standalone button that redirects without submitting the form.

**Current problematic pattern (lines 48–71):**
```typescript
async function onSubmit(data: any) {
  setLoading(true);
  try {
    if (tab === "new") {
      // This branch is unreachable — "new" tab's button does window.location.href directly
      window.location.href = `/admin/quotes/new?lead_id=${leadId}`;
      return;
    }

    const result = await assignQuoteToLead({
      lead_id: leadId,
      quote_token: data.quote_token,
      generate_new: false,
    });
    // ...
  } finally {
    setLoading(false);
  }
}
```

**Fixed pattern — remove the dead branch:**
```typescript
async function onSubmit(data: any) {
  setLoading(true);
  try {
    // Only handle "existing quote" path (tab === "existing")
    // The "new" tab's button is self-contained and redirects via onClick
    const result = await assignQuoteToLead({
      lead_id: leadId,
      quote_token: data.quote_token,
      generate_new: false,
    });
    if (result.success) {
      setOpen(false);
      form.reset();
    } else {
      console.error("Error assigning quote:", result.error);
    }
  } finally {
    setLoading(false);
  }
}
```

**Additional cleanup:** The `generate_new` field in `defaultValues` and schema is also dead code (always `false`). Consider either removing it or documenting that it's intentionally unused.

---

## Reused Components (No Changes)

### `src/components/ui/editable-cell.tsx` (Phase 11, shipped)

**Analog:** self (read-only, reuse as-is)

**Pattern summary** (lines 8–26):
- `type?: "text" | "number" | "textarea" | "toggle"`
- `onSave: (value: string) => void` callback
- `required?: boolean` validation (client-side; server validates separately)
- `formatDisplay?: (value: string) => string` for display formatting (e.g., price)

Used in LeadTable for: `name`, `email`, `phone`, `company`, `next_action`.

---

### `src/components/ui/option-select.tsx` (Phase 11, shipped)

**Analog:** self (read-only, reuse as-is)

**Pattern summary** (lines 10–26):
- `value: string | null`, `options: string[]`
- `onChange: (value: string | null) => void`
- `onRename?: (oldValue: string, newValue: string) => void` for inline renaming
- `placeholder?: string` default `"Cerca o crea..."`

Used in LeadTable for: `status` field (with fixed `options={LEAD_STAGES}`, no `onRename`).

---

### `src/components/ui/option-multi-select.tsx` (Phase 11, shipped)

**Analog:** self (read-only, reuse as-is)

**Pattern summary** (lines 10–27):
- `values: string[]`, `options: string[]` (union of pool + current values)
- `onAdd: (value: string) => void`, `onRemove: (value: string) => void`
- `onRename?: (oldValue: string, newValue: string) => void` for inline renaming
- `placeholder?: string` default `"Cerca o crea..."`

Used in LeadTable for: `tags` field (CRM-09).

---

### `src/components/ui/option-colors.ts` (Phase 11, shipped)

**Analog:** self (read-only, reuse as-is)

**Pattern summary** (lines 1–21):
- `getOptionColor(value: string): string` — deterministic hash → 7-color pastel palette
- Automatically used by `OptionSelect` and `OptionMultiSelect` for badge coloring

Note: LeadTable's `status` field may override colors with `STAGE_COLOR` map (semantic green/red) per Pitfall 6 of RESEARCH.md. Decision deferred to planner.

---

## Shared Patterns

### Authentication Guard (Server Actions)

**Source:** `src/app/admin/catalog/actions.ts` lines 18–21, imported from `@/lib/auth`

**Apply to:** All new lead server actions (`updateLeadField`, `addLeadTag`, `removeLeadTag`, `renameLeadTag`)

```typescript
async function requireAdmin() {
  const session = await getServerSession(authOptions);
  if (!session) throw new Error("Non autorizzato");
}
```

Call at the top of every mutating action:
```typescript
export async function updateLeadField(...) {
  await requireAdmin();
  // ... rest of logic
}
```

---

### Error Handling (Server Actions & Components)

**Source:** `src/app/admin/catalog/actions.ts` + `src/components/admin/catalog/ServiceTable.tsx`

**Apply to:** All server actions (validation + throw), all LeadTable rows (error display)

**Server action pattern** (actions.ts):
```typescript
if (!EDITABLE_FIELDS.includes(fieldName)) {
  throw new Error(`Campo non editabile: ${fieldName}`);
}
if (required && !value.trim()) {
  throw new Error("Campo richiesto");
}
```

**Component pattern** (ServiceTable.tsx lines 43–56):
```typescript
function run(fn: () => Promise<unknown>) {
  setError(null);
  startTransition(async () => {
    try {
      await fn();
      router.refresh();
    } catch (e) {
      setError(e instanceof Error ? e.message : "Errore nel salvataggio");
    }
  });
}
```

Error display (lines 131–137):
```typescript
{error && (
  <tr>
    <td colSpan={COL_COUNT} className="py-1 px-3 text-xs text-red-600">
      {error}
    </td>
  </tr>
)}
```

---

### Polymorphic Tag CRUD

**Source:** `src/app/admin/catalog/actions.ts` lines 131–199 (addServiceOption/removeServiceOption/renameServiceOption)

**Apply to:** `addLeadTag`, `removeLeadTag`, `renameLeadTag` (CRM-09)

**Pattern:**
1. Define entity-type constant: `const LEADS_TAG_ENTITY = "leads"`
2. Insert: `.onConflictDoNothing()` for idempotency (unique index on `(entity_type, entity_id, name)`)
3. Delete: `where(and(eq(tags.entity_type, LEADS_TAG_ENTITY), eq(tags.entity_id, leadId), eq(tags.name, value)))`
4. Rename: Update all rows with `entity_type="leads"` and matching name (propagates across all leads)
5. After each mutation: `revalidatePath("/admin/leads")`

---

### Data Fetch & Join Pattern (LeadWithTags)

**Source:** `src/lib/admin-queries.ts` lines 360–409 (getAllServices pattern)

**Apply to:** `getLeadsWithTags()` in admin-queries.ts

**Pattern:**
1. Select from entity table, left-join tags table scoped by `entity_type`
2. Iterate rows, group by entity ID, append tag names to array
3. Return `Map.values()` as `LeadWithTags[]`

```typescript
const leadMap = new Map<string, LeadWithTags>();
for (const row of rows) {
  const { tag_name, ...leadFields } = row;
  if (!leadMap.has(row.id)) leadMap.set(row.id, { ...leadFields, tags: [] });
  if (tag_name) leadMap.get(row.id)!.tags.push(tag_name);
}
return Array.from(leadMap.values());
```

---

### Client-Side Instant Filter (LeadsSearch)

**Source:** `src/app/admin/catalog/CatalogSearch.tsx` lines 18–29

**Apply to:** `LeadsSearch.tsx` (`useMemo` + `.filter()`)

**Pattern:**
```typescript
const filtered = useMemo(() => {
  const q = query.trim().toLowerCase();
  if (!q) return leads;
  return leads.filter((l) =>
    l.name.toLowerCase().includes(q) ||
    l.email?.toLowerCase().includes(q) ||
    // ... other fields ...
    l.tags.some((t) => t.toLowerCase().includes(q))
  );
}, [leads, query]);
```

No server round-trip, pure client-side filter on keystroke.

---

## No Analog Found

| File | Role | Data Flow | Reason |
|------|------|-----------|--------|
| (none) | — | — | All Phase 14 files have exact or role-match analogs from Phase 11. |

---

## Metadata

**Analog search scope:** Codebase searched in directories: `src/components/`, `src/app/`, `src/lib/`

**Files scanned:** 100+ (focused reads on Phase 11 shipping catalog patterns)

**Pattern extraction date:** 2026-06-14

**Confidence level:** 100% (8/8 files have direct analogs; Phase 11 was explicitly designed to provide these primitives for Phase 14)

---

## Key Notes for Planner

1. **Phase 14 is a "reuse, don't reinvent" phase.** Every UI primitive (EditableCell, OptionSelect, OptionMultiSelect, getOptionColor) and every backend pattern (field allowlist, tag CRUD, requireAdmin) ships from Phase 11. Treat any deviation as a red flag.

2. **Status field is a closed enum.** Unlike tags (open-ended), `status` must validate against `LEAD_STAGES` server-side. The planner should decide: use `OptionSelect` with fixed options + semantic color override (STAGE_COLOR), or build a simpler closed-enum dropdown. Both are low-effort; just pick one explicitly.

3. **Three bug fixes are small and isolated.** CRM-10 (i18n), CRM-11 (type safety), and CRM-12 (dead code) are independent edits to their respective files. They don't block or enable the main table rewrite.

4. **New server actions should include `requireAdmin()`.** Current `src/app/admin/leads/actions.ts` pre-existing functions lack this guard. New actions (Phase 14) follow the stricter Phase 11 convention. Whether to retrofit the pre-existing actions is a scope decision outside Phase 14.

5. **Two paths for revalidation.** Field updates should call both `revalidatePath("/admin/leads")` (list page) and `revalidatePath(\`/admin/leads/${leadId}\`)` (detail page), matching the existing `updateLead` pattern in `src/app/admin/leads/actions.ts` lines 62–63.
