Files
simone d115465240 docs(14): create phase plan
Plan Phase 14 (CRM Attio-style & Fix) into 3 waves: data-layer
foundation (query helpers + server actions), LeadTable raw-table
rewrite with inline edit/tags, and isolated bug fixes for CRM-10/11/12.
2026-06-14 12:09:45 +02:00

871 lines
27 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 116):
```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 35140):
```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 146)
**Role:** Client component (`"use client"`), renders search input + calls `LeadTable` with filtered results.
**Imports and structure** (lines 146):
```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 1821):
```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 71116):
```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 131199, 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 19):
```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 360448)
**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 360409):
```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 414448):
```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 147)
**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 1100+)
**Issue:** `useForm<any>()` defeats Zod type inference. Fix: use `CreateLeadInput` type exported from `lead-validators.ts`.
**Current pattern (lines 4151, 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 1110+)
**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 4871):**
```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 826):
- `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 1026):
- `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 1027):
- `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 121):
- `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 1821, 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 4356):
```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 131137):
```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 131199 (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 360409 (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 1829
**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 6263.