# 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(null); function run(fn: () => Promise) { setError(null); startTransition(async () => { try { await fn(); router.refresh(); } catch (e) { setError(e instanceof Error ? e.message : "Errore nel salvataggio"); } }); } return ( <> {/* Name — EditableCell, required, linked to detail */} run(() => updateLeadField(lead.id, "name", v))} /> {/* Email — EditableCell, optional */} run(() => updateLeadField(lead.id, "email", v))} /> {/* Phone — EditableCell, optional */} run(() => updateLeadField(lead.id, "phone", v))} /> {/* Company — EditableCell, optional */} run(() => updateLeadField(lead.id, "company", v))} /> {/* Status — OptionSelect, fixed enum (LEAD_STAGES), no create-on-the-fly */} run(() => updateLeadField(lead.id, "status", v ?? "contacted"))} /> {/* Next Action — EditableCell, optional */} run(() => updateLeadField(lead.id, "next_action", v))} /> {/* Tags — OptionMultiSelect, create-on-the-fly enabled */} run(() => addLeadTag(lead.id, v))} onRemove={(v) => run(() => removeLeadTag(lead.id, v))} onRename={(o, n) => run(() => renameLeadTag(o, n))} /> {/* Actions — Link to detail page */} {error && ( {error} )} ); } 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 (
{COLUMN_HEADERS.map((header) => ( ))} {leads.map((lead) => ( ))}
{header}
{leads.length === 0 && (
Nessun lead trovato
)}
); } ``` **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 (
setQuery(e.target.value)} className="pl-9 h-9" />
); } ``` --- ### `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 { 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(); 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 { 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 ( Richiedi Follow-up {leadsNeedingFollowUp.length > 0 ? ( <>

{leadsNeedingFollowUp.length} lead da contattare

    {leadsNeedingFollowUp.slice(0, 3).map((lead) => (
  • {lead.name}
  • ))}
{leadsNeedingFollowUp.length > 3 && ( )} ) : (

Tutti i lead sono aggiornati!

)}
); } ``` --- ### `src/components/admin/leads/LeadForm.tsx` (component, request-response — fix CRM-11) **Current analog:** self (existing file, lines 1–100+) **Issue:** `useForm()` defeats Zod type inference. Fix: use `CreateLeadInput` type exported from `lead-validators.ts`. **Current pattern (lines 41–51, problematic):** ```typescript const form = useForm({ resolver: zodResolver(createLeadSchema), defaultValues: { name: "", email: "", phone: "", company: "", status: "contacted", notes: "", }, }); ``` **Fixed pattern:** ```typescript import { CreateLeadInput } from "@/lib/lead-validators"; const form = useForm({ 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()` with `useForm()`. 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) { 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 && ( {error} )} ``` --- ### 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(); 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.