UI-SPEC approved (5/6 PASS, 1 non-blocking flag on focal point). Locks reuse of Phase 11 primitives (EditableCell, OptionSelect, OptionMultiSelect) for the leads table, plus scope for the four CRM bug fixes (i18n, type-safety, dead code). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
47 KiB
Phase 14: CRM Attio-style & Fix - Research
Researched: 2026-06-13 Domain: Next.js 16 App Router admin CRM table redesign (database-view UX) + bug fixes (i18n, react-hook-form typing, dead code branches) Confidence: HIGH
Summary
Phase 14 is almost entirely a "reuse, don't reinvent" phase. Phase 11 already built and shipped the exact primitives this phase needs — EditableCell, OptionSelect, OptionMultiSelect, getOptionColor, and a Notion/Attio-style database-view table pattern (ServiceTable.tsx + CatalogSearch.tsx + the catalog server actions in src/app/admin/catalog/actions.ts). The polymorphic tags table (migration 0006_add_tags_table.sql) was explicitly designed in its comments to support entity_type: "leads" in Phase 14 — no new migration is needed for CRM-09.
The work for CRM-08/CRM-09 is a structural port: replace LeadTable.tsx's static <Table> (shadcn wrapper components) with a raw <table> following ServiceTable.tsx's exact layout, wire EditableCell to a new updateLeadField server action (mirroring updateServiceField), wire OptionSelect for status/next_action and OptionMultiSelect for lead tags to new addLeadTag/removeLeadTag/renameLeadTag actions (mirroring addServiceOption/removeServiceOption/renameServiceOption), and add a getLeadsWithTags/getLeadFieldOptions query pair (mirroring getAllServices/getCatalogFieldOptions) in src/lib/admin-queries.ts or src/lib/lead-service.ts.
The three bug fixes (CRM-10, CRM-11, CRM-12) are independent, small, and isolated:
- CRM-10:
FollowUpWidget.tsxhas ~6 hardcoded English strings that need Italian translations consistent with the rest of the app's tone (already established inLeadDetail.tsx/LeadTable.tsx). - CRM-11:
LeadForm.tsxusesuseForm<any>andfield.value as anyworkarounds — needs to use the actual Zod-inferred type (CreateLeadInput/UpdateLeadInput, already exported fromlead-validators.ts) withfield.value || ""patterns already correctly applied elsewhere. - CRM-12:
SendQuoteModal.tsx's "new quote" tab has a deadif (tab === "new")branch insideonSubmitthat can never fire because the "new" tab's button has its ownonClickand never submits the form — needs removing the unreachable branch and/or restructuring so the form only handles the "existing quote" path.
Primary recommendation: Build a LeadTable.tsx rewrite that is a near 1:1 structural port of ServiceTable.tsx, reusing EditableCell/OptionSelect/OptionMultiSelect/getOptionColor as-is, adding lead-scoped server actions in src/app/admin/leads/actions.ts that mirror the catalog actions' shape, and fixing the three bugs as small isolated edits to their respective files.
Architectural Responsibility Map
| Capability | Primary Tier | Secondary Tier | Rationale |
|---|---|---|---|
| Lead table inline editing (CRM-08) | Frontend Server (RSC) + API/Backend (Server Actions) | Browser (client component for interactivity) | LeadTable becomes a client component ("use client") like ServiceTable; persistence via Next.js Server Actions in src/app/admin/leads/actions.ts, same pattern as src/app/admin/catalog/actions.ts |
| Lead tag multi-select (CRM-09) | Database (tags table, polymorphic) |
API/Backend (server actions) | Reuses existing polymorphic tags table with entity_type: "leads" — no schema change; CRUD via new addLeadTag/removeLeadTag/renameLeadTag server actions |
| FollowUpWidget i18n (CRM-10) | Frontend Server (RSC) | — | Pure server component (async function FollowUpWidget()), string-literal translation only, no data layer change |
| LeadForm type safety (CRM-11) | Browser/Client component | — | react-hook-form + zodResolver, client-side form; type fix only, no backend change |
| SendQuoteModal dead branches (CRM-12) | Browser/Client component | API/Backend (existing assignQuoteToLead action) |
Client-side control-flow fix; existing server action assignQuoteToLead in src/app/admin/leads/actions.ts is reused unchanged |
| Lead search/filter (Attio-style UX, implied by CRM-08 design ref) | Browser/Client component | — | Client-side useMemo filter, same pattern as CatalogSearch.tsx — no reload, no backend query change |
Standard Stack
Core (already installed — no new dependencies)
| Library | Version (installed) | Latest (npm) | Purpose | Why Standard |
|---|---|---|---|---|
| next | 16.2.6 | 16.2.9 [VERIFIED: npm registry] | App Router, Server Actions, RSC | Already the project framework; patch bump only, not in scope |
| react-hook-form | ^7.75.0 | 7.79.0 [VERIFIED: npm registry] | LeadForm validation (CRM-11) | Already used across the app; fixing type-relaxation, not swapping libraries |
| @hookform/resolvers | ^5.2.2 | 5.4.0 [VERIFIED: npm registry] | zodResolver bridge | Already used; no upgrade needed for this phase's scope |
| zod | ^4.4.3 | 4.4.3 [VERIFIED: npm registry] | Schema validation (lead-validators.ts) |
Already current; createLeadSchema/updateLeadSchema exist and are correctly typed — CRM-11 fix is about using the inferred types in the form, not the schema itself |
| drizzle-orm | ^0.45.2 | — | DB access for leads, tags, activities, reminders |
Existing ORM; polymorphic tags table already supports the new entity_type |
| date-fns + date-fns/locale/it | ^4.4.0 | — | formatDistanceToNow/format with Italian locale |
Already used in LeadDetail.tsx/LeadTable.tsx; reuse for any new date displays |
| lucide-react | ^1.14.0 | — | Icons (Search, Plus, Check, X, Pencil, AlertCircle) |
Already used by OptionSelect/OptionMultiSelect/CatalogSearch/FollowUpWidget |
No installation needed — this phase uses zero new packages. All required primitives ship from Phase 11.
Supporting (existing internal modules to reuse)
| Module | Path | Purpose | When to Use |
|---|---|---|---|
EditableCell |
src/components/ui/editable-cell.tsx |
Click-to-edit text/number/textarea/toggle cell | Lead table: name, email, phone, company, next_action, notes |
OptionSelect |
src/components/ui/option-select.tsx |
Single-select dropdown with create/rename, badge-colored | Lead table: status (pipeline stage badge) |
OptionMultiSelect |
src/components/ui/option-multi-select.tsx |
Multi-select pill list with create/rename | Lead table: tags (CRM-09) |
getOptionColor |
src/components/ui/option-colors.ts |
Deterministic hash-based pastel color for badges | Reused automatically by OptionSelect/OptionMultiSelect — also usable for the STAGE_COLOR replacement if desired |
Alternatives Considered
| Instead of | Could Use | Tradeoff |
|---|---|---|
Reusing tags polymorphic table with entity_type: "leads" |
New lead_tags dedicated table |
Rejected — migration 0006's own code comment explicitly reserves this for Phase 14; adding a new table would duplicate the pattern and contradict the design already shipped |
| Hand-rolled inline-edit cell | TanStack Table + a cell-editing plugin | Rejected — Phase 11 already solved this with zero new dependencies; introducing TanStack Table now would be inconsistent with the catalog table and add a new dependency for no functional gain |
Manual STAGE_COLOR record (current LeadTable.tsx) |
getOptionColor() hash-based palette |
Recommended to replace STAGE_COLOR with OptionSelect + getOptionColor for status, for visual consistency with the tags column and Attio/Pipedrive's uniform "status pill" look — but the 6 lead stages already have meaningful semantic colors (green=won, red=lost) that a hash function won't reproduce. Discretion: keep a small STAGE_COLOR map for the status OptionSelect's badge color override (see Open Questions), or accept hash colors for simplicity |
Installation: None required.
Architecture Patterns
System Architecture Diagram
┌─────────────────────────────────────────────────────────────────────┐
│ Browser (/admin/leads) │
│ │
│ LeadsSearch (client, "use client") │
│ │ useState(query) ──useMemo──> filtered leads (client-side filter) │
│ ▼ │
│ LeadTable (client, "use client") │
│ ├─ LeadRow per lead │
│ │ ├─ EditableCell (name, email, phone, company, next_action) ────┐│
│ │ ├─ OptionSelect (status: pipeline stage) ──────────────────┐ ││
│ │ └─ OptionMultiSelect (tags) ──────────────────────────────┐ │ ││
│ └─ Link → /admin/leads/[id] (detail page, unchanged) │ │ ││
└───────────────────────────────────────────────────────────────────┼──┼┼┘
│ ││
Server Actions ("use server", src/app/admin/leads/actions.ts)│ ││
┌──────────────────────────────────────────────────────────┐│ ││
│ updateLeadField(leadId, field, value) <───────────────────┘ ││
│ addLeadTag / removeLeadTag / renameLeadTag <───────────────────┘│
│ (writes to `tags` table, entity_type = "leads") <────────────┘
│ → revalidatePath("/admin/leads") │
└──────────────────────────────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────┐
│ Neon Postgres (Drizzle ORM) │
│ leads (status, next_action, name, email, phone, company)│
│ tags (entity_type="leads", entity_id=lead.id, name) │
│ activities / reminders (unchanged — used by detail page) │
└──────────────────────────────────────────────────────────┘
Page load: getLeadsWithTags() + getLeadFieldOptions()
(new query fns in src/lib/admin-queries.ts or lead-service.ts)
mirror getAllServices() / getCatalogFieldOptions()
Recommended Project Structure
src/
├── app/admin/leads/
│ ├── page.tsx # server component — fetch leads+tags+options, render LeadsSearch
│ ├── actions.ts # extend: updateLeadField, addLeadTag, removeLeadTag, renameLeadTag
│ ├── LeadsSearch.tsx # NEW — client-side filter wrapper, mirrors CatalogSearch.tsx
│ └── [id]/page.tsx # unchanged
├── components/admin/leads/
│ ├── LeadTable.tsx # REWRITE — database-view table, mirrors ServiceTable.tsx
│ ├── LeadForm.tsx # FIX (CRM-11) — remove `any` / type-relaxation
│ ├── SendQuoteModal.tsx # FIX (CRM-12) — remove unreachable branch
│ ├── LeadDetail.tsx # unchanged (or minor: surface tags if desired — discretion)
│ └── LogActivityModal.tsx # unchanged
├── components/admin/dashboard/
│ └── FollowUpWidget.tsx # FIX (CRM-10) — translate to Italian
└── lib/
├── lead-validators.ts # possibly extend: editable field allowlist / tag entity constant
├── lead-service.ts # OR admin-queries.ts — add getLeadsWithTags / getLeadFieldOptions
└── admin-queries.ts # has TAG_ENTITY="services" precedent — add LEADS_TAG_ENTITY="leads"
Pattern 1: Database-View Table Row with Inline Edit (CRM-08)
What: Each table row renders read-only by default; clicking a cell turns it into an input (text/textarea/number/toggle) that commits on Enter/blur, or opens a dropdown for select/multi-select fields.
When to use: /admin/leads table — name, email, phone, company, status, next_action, tags.
Example (from ServiceTable.tsx, directly portable):
// Source: src/components/admin/catalog/ServiceTable.tsx (Phase 11, shipped)
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">
<td className="py-2 px-3 font-medium min-w-[160px]">
<EditableCell
value={lead.name}
type="text"
required
onSave={(v) => run(() => updateLeadField(lead.id, "name", v))}
/>
</td>
<td className="py-2 px-3 min-w-[140px]">
<OptionSelect
value={lead.status}
options={options.status}
onChange={(v) => run(() => updateLeadField(lead.id, "status", v ?? "contacted"))}
/>
</td>
<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>
{/* ... next_action, email, phone, company via EditableCell ... */}
</tr>
);
}
Pattern 2: Server Action — Field Allowlist + Validation per Field (CRM-08)
What: A single updateLeadField(leadId, fieldName, value) server action with an EDITABLE_FIELDS const-array allowlist, switching on fieldName to apply field-specific normalization/validation.
When to use: Any inline-editable scalar column.
Example:
// Source: src/app/admin/catalog/actions.ts updateServiceField (Phase 11, shipped)
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 = 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") {
if (!LEAD_STAGES.includes(value as typeof LEAD_STAGES[number])) {
throw new Error("Stato non valido");
}
await db.update(leads).set({ status: value, updated_at: new Date() }).where(eq(leads.id, leadId));
} else {
// email/phone/company/next_action — nullable text fields
await db.update(leads).set({ [fieldName]: value.trim() || null, updated_at: new Date() }).where(eq(leads.id, leadId));
}
revalidatePath("/admin/leads");
revalidatePath(`/admin/leads/${leadId}`);
}
Note (requireAdmin check): src/app/admin/catalog/actions.ts calls await requireAdmin() (session check via getServerSession(authOptions)) at the top of every mutating action. src/app/admin/leads/actions.ts currently does NOT have this check — createLead/updateLead/deleteLead/logActivity/assignQuoteToLead have no auth guard. This is a pre-existing gap, not introduced by this phase, but new actions added in Phase 14 (updateLeadField, addLeadTag, etc.) should follow the catalog convention and include requireAdmin() for consistency — flagged in Pitfalls below.
Pattern 3: Polymorphic Tag CRUD (CRM-09)
What: Multi-select tag pool stored in the shared tags table, scoped by entity_type. Adding/removing a tag is an insert/delete keyed by (entity_type, entity_id, name); renaming propagates to all rows sharing that tag name within the entity_type scope.
When to use: Lead tags (CRM-09) — entity_type = "leads".
Example:
// Source: src/app/admin/catalog/actions.ts addServiceOption/removeServiceOption/renameServiceOption
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(); // unique index: tags_entity_name_unique (entity_type, entity_id, name)
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 || 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");
}
Pattern 4: Query Layer — Entity + Tags Join (CRM-09 data fetch)
What: getAllServices()'s left-join + Map-reduce pattern, ported to leads.
Example:
// Source: src/lib/admin-queries.ts getAllServices (Phase 11, shipped) — port to leads
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...
tag_name: tags.name,
})
.from(leads)
.leftJoin(tags, and(eq(tags.entity_id, leads.id), eq(tags.entity_type, LEADS_TAG_ENTITY)))
.orderBy(desc(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());
}
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 a dynamic pool
tags: tagRows.map((r) => r.name).sort((a, b) => a.localeCompare(b, "it")),
};
}
Pattern 5: Client-Side Instant Search (CRM-08 UX, "Attio-style")
What: useState + useMemo filter over the full dataset, no server round-trip.
Example:
// Source: src/app/admin/catalog/CatalogSearch.tsx (Phase 11, shipped) — port to leads
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]);
Pattern 6: react-hook-form with Zod-Inferred Types (CRM-11 fix)
What: Replace useForm<any>() with useForm<CreateLeadInput>() (or the existing exported type from lead-validators.ts), and remove as any / defaultValue={field.value} workarounds where field.value is string | undefined vs the Input's expected string.
Current problem in LeadForm.tsx:
// CURRENT (CRM-11 violation)
const form = useForm<any>({
resolver: zodResolver(createLeadSchema),
defaultValues: { ... },
});
// ...
status: lead.status as any,
Fixed pattern:
// Source: lead-validators.ts already exports CreateLeadInput = z.infer<typeof createLeadSchema>
const form = useForm<CreateLeadInput>({
resolver: zodResolver(createLeadSchema),
defaultValues: {
name: lead.name,
email: lead.email ?? "",
phone: lead.phone ?? "",
company: lead.company ?? "",
status: lead.status as CreateLeadInput["status"], // narrows from `string` (DB column) to the literal union — still a cast, but type-safe at the schema boundary, not an `any` escape hatch
notes: lead.notes ?? "",
},
});
Note: lead.status from Drizzle's Lead type is string (the status column is text(), not a pg enum), while createLeadSchema.status is z.enum(LEAD_STAGES). A cast from string to the literal union is unavoidable here UNLESS the Drizzle schema itself uses a typed enum — that's a schema-level change out of scope for this phase. The CRM-11 fix should focus on removing useForm<any> and data: any in onSubmit, and ensure the one remaining narrowing cast (lead.status as CreateLeadInput["status"]) is the only type assertion, replacing the current blanket as any on the whole form generic.
Anti-Patterns to Avoid
useForm<any>(): Defeats the entire purpose ofzodResolver— no compile-time field-name or type checking. CRM-11 requires removing this.- Reintroducing a
lead_tagstable: The polymorphictagstable was explicitly designed (see migration 0006 comments) to cover this. A separate table would fragment the tag UX between catalog and CRM. - Server-side search/filter for the leads table: Contradicts the "Attio-style, instant" requirement and Phase 11 precedent (
CatalogSearch.tsxis 100% client-side). - Keeping
STAGE_COLORas a separate hardcoded map AND introducinggetOptionColor: Pick one color strategy forstatusbadges — don't run two parallel color systems for the same field (see Open Questions).
Don't Hand-Roll
| Problem | Don't Build | Use Instead | Why |
|---|---|---|---|
| Inline-editable table cell (click → edit → save) | New cell component | EditableCell (src/components/ui/editable-cell.tsx) |
Already handles focus management, Enter/Escape/blur commit, required-field validation, React Compiler / hooks-lint compliant (per Phase 11 fix history) |
| Single-select dropdown with create-on-the-fly + rename | New dropdown | OptionSelect (src/components/ui/option-select.tsx) |
Handles search/filter, badge coloring, click-outside, keyboard nav |
| Multi-select tag pills with create/remove/rename | New tag picker | OptionMultiSelect (src/components/ui/option-multi-select.tsx) |
Same as above, plus pill rendering with remove (×) buttons |
| Badge color assignment for tags/status values | Hardcoded color map per value | getOptionColor (src/components/ui/option-colors.ts) |
Deterministic hash → 7-color pastel palette, AA-contrast; used automatically by OptionSelect/OptionMultiSelect |
| Polymorphic tag storage/CRUD | New lead_tags table + migration |
Existing tags table with entity_type = "leads" |
Table and unique index already exist (migration 0006); zero migration needed |
| Client-side instant search/filter | Server action + reload, or a search library | useMemo filter pattern from CatalogSearch.tsx |
Proven pattern, zero new deps, matches "Attio-style instant" requirement |
Key insight: Every UI primitive this phase needs was built in Phase 11 specifically so Phase 14 wouldn't have to rebuild them — the migration 0006 code comment ("services now, leads in Phase 14") is essentially a forward-reference left for this research to find. Treat any deviation from the ServiceTable.tsx/CatalogSearch.tsx/catalog-actions pattern as a red flag requiring justification.
Common Pitfalls
Pitfall 1: Missing requireAdmin() on new lead server actions
What goes wrong: New actions (updateLeadField, addLeadTag, etc.) ship without an auth check, while the catalog actions they're modeled on (updateServiceField, addServiceOption) all call await requireAdmin() first.
Why it happens: The existing src/app/admin/leads/actions.ts (createLead, updateLead, deleteLead, assignQuoteToLead) has no requireAdmin() calls — copying that file's existing style instead of the catalog file's style propagates the gap.
How to avoid: New actions in src/app/admin/leads/actions.ts should import getServerSession/authOptions and call requireAdmin() (copy from src/app/admin/catalog/actions.ts lines 8, 18-21), matching the more rigorous Phase 11 convention. Whether to also retrofit the pre-existing actions is a scope decision — flagged in Open Questions, not required by CRM-08..12 directly but worth a one-line task if low-risk.
Pitfall 2: status field type mismatch between Drizzle Lead.status: string and Zod z.enum(LEAD_STAGES)
What goes wrong: OptionSelect's value: string | null and onChange: (value: string | null) => void accept any string, including values not in LEAD_STAGES (e.g., a typo'd custom status). If a user "creates" a new status value via OptionSelect's create-on-the-fly UX (same as tags), it would write an arbitrary string into leads.status, breaking STAGE_COLOR/LEAD_STAGES-based logic elsewhere (LeadDetail.tsx, FollowUpWidget, lead-service queries that filter eq(leads.status, stage)).
Why it happens: OptionSelect was designed for category/fase — genuinely open-ended Notion-style properties. status is a closed enum (LEAD_STAGES, 6 fixed pipeline stages) — a different semantic.
How to avoid: For the status column, either (a) use OptionSelect but pass options={LEAD_STAGES} as a fixed, non-extensible list and don't wire an onRename/create handler (i.e., the dropdown only lets you pick from the 6 stages, no "Crea «...»" option) — or (b) build a small dedicated <select>/OptionSelect-lite specifically for closed enums. Validate in updateLeadField's status branch that the incoming value is one of LEAD_STAGES regardless of UI (defense in depth, per Pattern 2 example).
Warning signs: A lead row shows a status badge with an unexpected color/label not in STAGE_COLOR, or pipeline-stage-based dashboards/queries silently exclude leads with a "custom" status string.
Pitfall 3: FollowUpWidget i18n — string source location may include both the component AND lead-service.ts
What goes wrong: Translating only the JSX strings in FollowUpWidget.tsx ("Require Follow-up", "to contact", "Contact", "View All", "All leads are up to date!") misses any English strings returned from getLeadsNeedingFollowUp or related helper functions, if any exist.
Why it happens: i18n bugs are often scattered across the component AND its data-fetching helper.
How to avoid: Grep for English words across FollowUpWidget.tsx AND lead-service.ts's getLeadsNeedingFollowUp. From research, getLeadsNeedingFollowUp (lines 34-48 of lead-service.ts) returns raw Lead[] with no English strings — confirmed the fix is scoped to FollowUpWidget.tsx only. Six strings identified: "Require Follow-up", "lead"/"leads" pluralization + "to contact", "Contact" (button), "View All", "All leads are up to date!".
Warning signs: Visual QA on the dashboard widget — any remaining English text.
Pitfall 4: SendQuoteModal's dead branch — fixing it might change UX, not just code structure
What goes wrong: The if (tab === "new") branch inside onSubmit (line 51-54 of SendQuoteModal.tsx) is unreachable because the "new" tab's UI (TabsContent value="new") has its own standalone <Button onClick={...}> that does window.location.href = ... directly — it never calls form.handleSubmit(onSubmit). Simply deleting the dead if block is correct, but a naive "make it reachable instead" fix (e.g., wrapping the "new" tab's button in the form) would change behavior (introduce a submit-triggered validation pass on a form whose schema doesn't apply to the "new" flow) without being asked.
Why it happens: The form's assignQuoteSchema validates lead_id/quote_token/generate_new — none of which are meaningful for the "open quote builder" redirect flow.
How to avoid: CRM-12 says "non contiene rami di codice irraggiungibili" (doesn't contain unreachable branches) — the minimal, correct fix is removing the dead if (tab === "new") block from onSubmit (since that code path can never execute) and potentially simplifying onSubmit to only handle the "existing quote" case, since the "new" tab is already self-contained via its own button. Also consider: the generate_new field in assignQuoteSchema/defaultValues is never meaningfully used (always false, and the one parsed.data.generate_new check in actions.ts's assignQuoteToLead is itself a no-op early-return) — this is a second unreachable/dead-value chain worth flagging together.
Warning signs: TypeScript/ESLint no-unreachable won't catch this (it's not syntactically unreachable, just logically — tab state can theoretically be "new" when onSubmit fires, but the UI never lets that happen since the "new" tab doesn't render a submit button inside the <Form>). Manual review of tab state transitions vs. form submission triggers is required.
Pitfall 5: revalidatePath granularity — both list and detail pages
What goes wrong: New updateLeadField/addLeadTag/etc. actions only call revalidatePath("/admin/leads"), but the lead detail page (/admin/leads/[id]) shows the same status/tags data and goes stale.
Why it happens: Easy to copy only the list-page revalidation from updateServiceField (catalog has no per-item detail page to worry about).
How to avoid: Follow updateLead's existing pattern in src/app/admin/leads/actions.ts (lines 62-63), which calls both revalidatePath("/admin/leads") AND revalidatePath(/admin/leads/${id}). New inline-edit/tag actions should do the same.
Pitfall 6: Lead.status typed as plain string makes STAGE_COLOR lookups silently fall through
What goes wrong: STAGE_COLOR[lead.status as keyof typeof STAGE_COLOR] || "" already has a fallback (|| "") for unknown statuses — so this isn't a crash risk, but if OptionSelect is used for status with getOptionColor() instead of STAGE_COLOR, the "won"/"lost" semantic colors (green/red) are lost in favor of hash-derived pastels, which may look less meaningful in an Attio-style pipeline view where color-coding the funnel stage is a core UX signal.
How to avoid: This is a design decision, not a bug — see Open Questions. Recommend preserving STAGE_COLOR's semantic green/red/etc. for status, either via a custom render (not OptionSelect's default getOptionColor) or by extending OptionSelect with an optional colorMap prop.
Code Examples
Existing Lead / Activity / Reminder types (from src/db/schema.ts)
// Source: src/db/schema.ts lines 396-417 (leads table)
export const leads = pgTable("leads", {
id: text("id").primaryKey().$defaultFn(() => nanoid()),
name: text("name").notNull(),
email: text("email"),
phone: text("phone"),
company: text("company"),
status: text("status").notNull().default("contacted"), // contacted | qualified | proposal_sent | negotiating | won | lost
last_contact_date: timestamp("last_contact_date", { withTimezone: true }),
next_action: text("next_action"),
next_action_date: timestamp("next_action_date", { withTimezone: true }),
notes: text("notes"),
created_at: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updated_at: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
});
Existing polymorphic tags table (from src/db/schema.ts, migration 0006 — already in prod per memory)
// Source: src/db/schema.ts lines 119-140
export const tags = pgTable("tags", {
id: text("id").primaryKey().$defaultFn(() => nanoid()),
entity_type: text("entity_type").notNull(), // "services" | "leads" (Phase 14)
entity_id: text("entity_id").notNull(),
name: text("name").notNull(),
created_at: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
}, (t) => ({
entityTagUnique: uniqueIndex("tags_entity_name_unique").on(t.entity_type, t.entity_id, t.name),
entityIdx: index("tags_entity_idx").on(t.entity_type, t.entity_id),
}));
This table is already deployed to production (per MEMORY.md: "Tags Table and Legacy Data Consolidation Deployed to Production", 2026-06-13). CRM-09 needs zero migration — just new rows with entity_type = "leads".
State of the Art
| Old Approach | Current Approach | When Changed | Impact |
|---|---|---|---|
LeadTable.tsx uses shadcn <Table>/<TableRow>/<TableCell> wrapper components, read-only, modal-based editing via EditLeadModal |
ServiceTable.tsx uses raw <table>/<tr>/<td> with Tailwind classes, inline-editable via EditableCell/OptionSelect/OptionMultiSelect, no modals for field edits |
Phase 11 (2026-06-13) | Phase 14 ports this same shift to leads — LeadTable.tsx should drop the shadcn Table import entirely in favor of the raw-table pattern, for visual + interaction consistency with /admin/catalog |
Lead creation/edit via CreateLeadModal/EditLeadModal dialogs (LeadForm.tsx) |
Catalog has both: quick-add inline row (QuickAddRow in ServiceTable.tsx) for new items + inline edit for existing |
Phase 11 | Not explicitly required by CRM-08..12 — CreateLeadModal can stay as the "new lead" entry point (CRM-08 only requires inline edit of existing lead fields, not a new quick-add row). Adding a QuickAddRow-equivalent for leads is a "nice to have" / Claude's-discretion item, not a stated requirement — flagged in Open Questions |
Lead status shown via hardcoded STAGE_COLOR record + shadcn <Badge> |
Catalog category/fase/tags shown via OptionSelect/OptionMultiSelect + getOptionColor |
Phase 11 | See Pitfall 6 — recommend hybrid: keep semantic stage colors, adopt OptionSelect's interaction model |
Deprecated/outdated: None — no library deprecations relevant to this phase. The "old approach" column above refers to pre-Phase-11 UI patterns within this codebase, not external library deprecations.
Assumptions Log
| # | Claim | Section | Risk if Wrong |
|---|---|---|---|
| A1 | The 6 fields needing inline edit per CRM-08 ("status, next_action, ecc.") should include name, email, phone, company in addition to status/next_action — i.e., "tutti i campi principali" maps to the full set of scalar columns shown in the current LeadTable.tsx header row (Nome, Email, Azienda, Stato, Ultimo Contatto, Prossima Azione) |
Architecture Patterns Pattern 1/2, Recommended Project Structure | If the user only wants status+next_action inline-editable and the rest left read-only/modal-edited, the planner would over-scope updateLeadField's EDITABLE_FIELDS allowlist — low risk since extra editable fields are additive and harmless, but worth confirming during planning/discuss-phase |
| A2 | Ultimo Contatto (last_contact_date) stays read-only/computed (auto-set by createActivity, per lead-service.ts lines 102-106) and is NOT inline-editable |
Recommended Project Structure | Low risk — last_contact_date is a derived/audit field; making it editable would conflict with the auto-update-on-activity logic. If wrong, planner adds one more EditableCell for a date field (more complex type="date" handling not yet in EditableCell's type union) |
| A3 | For status (CRM-08), the existing STAGE_COLOR semantic color map should be preserved rather than switched to getOptionColor()'s hash-based palette (Pitfall 6) |
Pitfalls Pitfall 6, Anti-Patterns | Low/cosmetic risk — if wrong, won/lost lose their green/red semantic colors in favor of hash-derived pastels; purely visual, easy to adjust in a follow-up |
| A4 | No new lead "quick-add row" (mirroring catalog's QuickAddRow) is required — CreateLeadModal remains the lead-creation entry point |
State of the Art row 2, Open Questions | Low risk — CRM-08..12 requirements don't mention lead creation UX; if the user actually wants Attio-style quick-add-row creation too, it's an additive enhancement, not a blocker |
| A5 | New lead-scoped server actions (updateLeadField, addLeadTag, etc.) should include requireAdmin() checks per the catalog-actions convention, even though the pre-existing leads/actions.ts functions lack this check |
Pitfalls Pitfall 1 | Medium — if the planner skips this, new mutating endpoints would be unauthenticated (matching the pre-existing gap, but inconsistent with Phase 11's stricter convention); retrofitting the existing actions is explicitly out of scope unless the user requests it |
Validation needed: A1, A3, A4 are UX/scope judgment calls best confirmed during /gsd-plan-phase or a quick /gsd-discuss-phase pass, since the user has strong opinions about the CRM's look-and-feel ("tutti i miei amici fanno robe fighe e complesse"). A5 is a security-adjacent decision (Pitfall 1 / Security Domain V4) that should be called out explicitly to the user given CLAUDE.md's emphasis on confirming destructive/security-relevant changes — though adding an auth check is additive/protective, not destructive, so it likely doesn't need explicit pre-approval, just documentation in the plan.
Open Questions
-
Should
statususeOptionSelect(Notion-style, hash colors, user-extensible) or a closed-enum dropdown preservingSTAGE_COLOR's semantic colors?- What we know:
LEAD_STAGESis a fixed 6-value enum (contacted | qualified | proposal_sent | negotiating | won | lost) with meaningful semantic colors already defined inSTAGE_COLOR(green=won, red=lost, etc.).OptionSelectis designed for open-ended, user-extensible pools (category/fase/tags) with hash-based colors. - What's unclear: Whether "Attio/Pipedrive style" for the planner means visual consistency with the tags column (same
OptionSelectwidget, hash colors) or semantic pipeline-stage coloring (Pipedrive's actual stage pills ARE color-coded by stage meaning, often user-configurable per stage — closer toSTAGE_COLOR). - Recommendation: Use
OptionSelectfor interaction consistency (click-to-open dropdown, same as tags) but pass a fixedoptions={LEAD_STAGES}list (no create-on-the-fly) and either (a) extendOptionSelectwith an optional color-override map, or (b) render the closed/non-extensible dropdown withSTAGE_COLOR-based badges directly (simpler, less reuse). Either is a small, low-risk implementation choice — flag for the planner to pick one explicitly rather than leaving ambiguous.
- What we know:
-
Does CRM-08's "campi principali" include adding a quick-add row for new leads (Attio/Pipedrive "+ Add record" inline row)?
- What we know: CRM-08 requirement text says "La tabella lead supporta inline editing dei campi principali ... senza apertura modale" — this is about editing, not creation.
CreateLeadModalalready exists for creation. - What's unclear: Whether the Pipedrive/Attio reference implies replacing modal-based creation with an inline quick-add row too (Phase 11 did both for catalog).
- Recommendation: Treat as out-of-scope/additive (A4) unless
/gsd-discuss-phaseor the planner surfaces it — CRM-08..12 requirements don't list it, andCreateLeadModalis functional. If the planner wants full UX parity with Phase 11's catalog, aQuickAddRow-equivalent for leads (name + email + Enter) is a natural, low-effort addition using the same pattern.
- What we know: CRM-08 requirement text says "La tabella lead supporta inline editing dei campi principali ... senza apertura modale" — this is about editing, not creation.
-
Should
LeadDetail.tsx(the/admin/leads/[id]detail page) also show the new lead tags?- What we know: CRM-09 says "L'utente assegna tag multi-select ai lead" — doesn't specify whether tags are visible/editable only in the table or also on the detail page. The phase description's "compact detail panels" UI hint suggests Attio-style detail views also show tags/properties.
- What's unclear: Whether
LeadDetail.tsxneeds anOptionMultiSelectfor tags too, or if the table is the sole tag-management surface. - Recommendation: Low-cost addition — if
getLeadsWithTags/addLeadTag/etc. are built for the table, surfacing the sameOptionMultiSelectinLeadDetail.tsx's "Profilo" card costs little extra and improves consistency. Mark as Claude's discretion / nice-to-have within CRM-09's scope.
-
Pre-existing
requireAdmin()gap insrc/app/admin/leads/actions.ts— retrofit existing actions too, or only guard new ones?- What we know:
createLead,updateLead,deleteLead,logActivity,assignQuoteToLead(all pre-existing) have norequireAdmin()check, unlikesrc/app/admin/catalog/actions.ts's Phase-11-era actions. - What's unclear: Whether this is in scope for Phase 14 at all — none of CRM-08..12 mention auth.
- Recommendation: Out of scope for CRM-08..12 directly. New actions (CRM-08/09) should include
requireAdmin()per A5. Retrofitting the 5 pre-existing actions is a separate, small security-hardening task that could be mentioned to the user as a "spotted while researching" item, but shouldn't block or expand this phase's plan unless the user asks.
- What we know:
Environment Availability
Skipped — this phase is purely code/UI changes against the existing Next.js app and already-provisioned Postgres database (no new external tools, services, or runtimes required). All necessary npm packages are already installed (verified above).
Security Domain
Applicable ASVS Categories
| ASVS Category | Applies | Standard Control |
|---|---|---|
| V2 Authentication | Indirect | /admin/* routes protected by Auth.js v4 session (per CLAUDE.md architecture constraint #4) — new server actions should call requireAdmin() (see Pitfall 1 / A5) |
| V3 Session Management | No | No session-handling changes in this phase |
| V4 Access Control | Yes | All new mutating server actions (updateLeadField, addLeadTag, removeLeadTag, renameLeadTag) must be reachable only from authenticated admin context — follow requireAdmin() pattern from src/app/admin/catalog/actions.ts |
| V5 Input Validation | Yes | updateLeadField's per-field validation (Pattern 2) — especially status must be constrained to LEAD_STAGES enum values regardless of client-side OptionSelect configuration (defense in depth, Pitfall 2). Tag names should be trimmed/non-empty (mirrors addServiceOption) |
| V6 Cryptography | No | No crypto/secrets involved in this phase |
Known Threat Patterns for this stack
| Pattern | STRIDE | Standard Mitigation |
|---|---|---|
| Unauthenticated server action invocation (Next.js Server Actions are callable directly if not guarded) | Elevation of Privilege | requireAdmin() session check at the top of every new mutating action (Pattern 2/3 examples include this) |
Arbitrary status value injection via OptionSelect's create-on-the-fly UX if misapplied to the closed status enum |
Tampering | Server-side allowlist check against LEAD_STAGES in updateLeadField (Pattern 2), independent of client UI configuration (Pitfall 2) |
| SQL injection via raw string interpolation | Tampering | N/A — Drizzle ORM parameterized queries used throughout; no raw SQL introduced by this phase |
Sources
Primary (HIGH confidence)
/Users/simonecavalli/Vault/IAMCAVALLI/src/components/admin/catalog/ServiceTable.tsx— Phase 11 database-view table pattern (shipped, code-complete)/Users/simonecavalli/Vault/IAMCAVALLI/src/components/ui/editable-cell.tsx— inline-edit primitive/Users/simonecavalli/Vault/IAMCAVALLI/src/components/ui/option-select.tsx— single-select primitive/Users/simonecavalli/Vault/IAMCAVALLI/src/components/ui/option-multi-select.tsx— multi-select primitive/Users/simonecavalli/Vault/IAMCAVALLI/src/components/ui/option-colors.ts— color derivation/Users/simonecavalli/Vault/IAMCAVALLI/src/app/admin/catalog/actions.ts— server action patterns (updateServiceField, addServiceOption/removeServiceOption/renameServiceOption, requireAdmin)/Users/simonecavalli/Vault/IAMCAVALLI/src/app/admin/catalog/CatalogSearch.tsx— client-side instant filter pattern/Users/simonecavalli/Vault/IAMCAVALLI/src/lib/admin-queries.ts(lines 355-430) — getAllServices/getCatalogFieldOptions tags-join pattern/Users/simonecavalli/Vault/IAMCAVALLI/src/db/schema.ts— leads, activities, reminders, tags, quotes table definitions/Users/simonecavalli/Vault/IAMCAVALLI/src/db/migrations/0006_add_tags_table.sql— confirmstagstable already deployed, polymorphic, no migration needed for CRM-09/Users/simonecavalli/Vault/IAMCAVALLI/src/components/admin/leads/LeadTable.tsx,LeadForm.tsx,SendQuoteModal.tsx,LeadDetail.tsx,LogActivityModal.tsx— current CRM code under fix/redesign/Users/simonecavalli/Vault/IAMCAVALLI/src/components/admin/dashboard/FollowUpWidget.tsx— CRM-10 target/Users/simonecavalli/Vault/IAMCAVALLI/src/lib/lead-validators.ts,lead-service.ts— Zod schemas and query/mutation helpers- npm registry —
npm view next/react-hook-form/zod/@hookform/resolvers version[VERIFIED: npm registry, 2026-06-13]
Secondary (MEDIUM confidence)
- None used — all findings verified directly against the codebase (HIGH confidence, codebase is ground truth for an internal refactor/redesign phase)
Tertiary (LOW confidence)
- None — no external/ecosystem research was needed since this phase reuses 100% internal, already-shipped primitives
Metadata
Confidence breakdown:
- Standard Stack: HIGH — zero new dependencies; all versions verified against npm registry and the project's own
package.json - Architecture: HIGH — directly ported from Phase 11's shipped, verified, lint-passing code (
ServiceTable.tsx, catalog actions, admin-queries.ts) - Pitfalls: HIGH — all six pitfalls derived from direct code reading of the actual files this phase will modify, not speculation
Research date: 2026-06-13 Valid until: 30 days (stable internal codebase pattern; revisit if Phase 11 patterns change before Phase 14 executes, or if Phase 12/13 modify shared primitives)