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.
27 KiB
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):
"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):
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
statusfield uses fixed enum (no create-on-the-fly); CatalogSearch'scategoria/faseare 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):
"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):
"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):
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):
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):
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):
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):
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 1–47)
Issue: 6 hardcoded English strings need Italian translation.
Strings to fix:
- Line 15:
"Require Follow-up"→"Richiedi Follow-up" - Line 22:
"lead"/"leads"→ Always"lead"in Italian (loanword, plural also "lead") - Line 23:
"to contact"→"da contattare" - Line 30:
"Contact"→"Contatta" - Line 37:
"View All"→"Vedi Tutto" - 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 1–100+)
Issue: useForm<any>() defeats Zod type inference. Fix: use CreateLeadInput type exported from lead-validators.ts.
Current pattern (lines 41–51, 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 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):
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 8–26):
type?: "text" | "number" | "textarea" | "toggle"onSave: (value: string) => voidcallbackrequired?: booleanvalidation (client-side; server validates separately)formatDisplay?: (value: string) => stringfor 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) => voidonRename?: (oldValue: string, newValue: string) => voidfor inline renamingplaceholder?: stringdefault"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) => voidonRename?: (oldValue: string, newValue: string) => voidfor inline renamingplaceholder?: stringdefault"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
OptionSelectandOptionMultiSelectfor 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)
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 43–56):
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):
{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:
- Define entity-type constant:
const LEADS_TAG_ENTITY = "leads" - Insert:
.onConflictDoNothing()for idempotency (unique index on(entity_type, entity_id, name)) - Delete:
where(and(eq(tags.entity_type, LEADS_TAG_ENTITY), eq(tags.entity_id, leadId), eq(tags.name, value))) - Rename: Update all rows with
entity_type="leads"and matching name (propagates across all leads) - 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:
- Select from entity table, left-join tags table scoped by
entity_type - Iterate rows, group by entity ID, append tag names to array
- Return
Map.values()asLeadWithTags[]
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:
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
-
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.
-
Status field is a closed enum. Unlike tags (open-ended),
statusmust validate againstLEAD_STAGESserver-side. The planner should decide: useOptionSelectwith fixed options + semantic color override (STAGE_COLOR), or build a simpler closed-enum dropdown. Both are low-effort; just pick one explicitly. -
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.
-
New server actions should include
requireAdmin(). Currentsrc/app/admin/leads/actions.tspre-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. -
Two paths for revalidation. Field updates should call both
revalidatePath("/admin/leads")(list page) andrevalidatePath(\/admin/leads/${leadId}`)(detail page), matching the existingupdateLeadpattern insrc/app/admin/leads/actions.ts` lines 62–63.