Files
clienthub/.planning/phases/14-crm-attio-style-fix/14-PATTERNS.md
T
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

27 KiB
Raw Blame History

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

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

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

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

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

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

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

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

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

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

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:

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

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

Fixed pattern:

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

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:

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)

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

Call at the top of every mutating action:

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

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

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

{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[]
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:

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 existingupdateLeadpattern insrc/app/admin/leads/actions.ts` lines 6263.