From 89adf7a1d4c1b508cd7e32928c6ba657f987318c Mon Sep 17 00:00:00 2001 From: Simone Cavalli Date: Sun, 14 Jun 2026 11:23:28 +0200 Subject: [PATCH] docs(14): UI design contract for CRM Attio-style redesign 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 --- .planning/STATE.md | 12 +- .../14-crm-attio-style-fix/14-RESEARCH.md | 527 ++++++++++++++++++ .../14-crm-attio-style-fix/14-UI-SPEC.md | 442 +++++++++++++++ 3 files changed, 975 insertions(+), 6 deletions(-) create mode 100644 .planning/phases/14-crm-attio-style-fix/14-RESEARCH.md create mode 100644 .planning/phases/14-crm-attio-style-fix/14-UI-SPEC.md diff --git a/.planning/STATE.md b/.planning/STATE.md index c07a0bd..11b32a5 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -3,9 +3,9 @@ gsd_state_version: 1.0 milestone: v2.1 milestone_name: milestone status: verifying -stopped_at: "11-04: ServiceTable rewritten as database-view (inline edit, tags, quick-add, active/inactive split) + CatalogSearch added — Phase 11 plans 1-4 all executed, code-complete" -last_updated: "2026-06-13T18:31:02.274Z" -last_activity: 2026-06-13 +stopped_at: Phase 14 UI-SPEC approved +last_updated: "2026-06-14T08:54:38.369Z" +last_activity: 2026-06-14 progress: total_phases: 11 completed_phases: 9 @@ -99,6 +99,6 @@ Items acknowledged and carried forward from previous milestone close: ## Session Continuity -Last session: 2026-06-13T14:02:06Z -Stopped at: 11-04: ServiceTable rewritten as database-view (inline edit, tags, quick-add, active/inactive split) + CatalogSearch added — Phase 11 plans 1-4 all executed, code-complete -Resume file: .planning/phases/11-catalog-database-view-ux-legacy-consolidation/11-04-SUMMARY.md +Last session: 2026-06-14T08:54:38.362Z +Stopped at: Phase 14 UI-SPEC approved +Resume file: .planning/phases/14-crm-attio-style-fix/14-UI-SPEC.md diff --git a/.planning/phases/14-crm-attio-style-fix/14-RESEARCH.md b/.planning/phases/14-crm-attio-style-fix/14-RESEARCH.md new file mode 100644 index 0000000..e6eb005 --- /dev/null +++ b/.planning/phases/14-crm-attio-style-fix/14-RESEARCH.md @@ -0,0 +1,527 @@ +# 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 `` (shadcn wrapper components) with a raw `
` 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.tsx` has ~6 hardcoded English strings that need Italian translations consistent with the rest of the app's tone (already established in `LeadDetail.tsx`/`LeadTable.tsx`). +- **CRM-11**: `LeadForm.tsx` uses `useForm` and `field.value as any` workarounds — needs to use the actual Zod-inferred type (`CreateLeadInput`/`UpdateLeadInput`, already exported from `lead-validators.ts`) with `field.value || ""` patterns already correctly applied elsewhere. +- **CRM-12**: `SendQuoteModal.tsx`'s "new quote" tab has a dead `if (tab === "new")` branch inside `onSubmit` that can never fire because the "new" tab's button has its own `onClick` and 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):** +```tsx +// 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(null); + + function run(fn: () => Promise) { + setError(null); + startTransition(async () => { + try { + await fn(); + router.refresh(); + } catch (e) { + setError(e instanceof Error ? e.message : "Errore nel salvataggio"); + } + }); + } + + return ( + + + + + {/* ... next_action, email, phone, company via EditableCell ... */} + + ); +} +``` + +### 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:** +```ts +// 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:** +```ts +// 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:** +```ts +// 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 { + 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(); + 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 { + 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:** +```tsx +// 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()` with `useForm()` (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`:** +```tsx +// CURRENT (CRM-11 violation) +const form = useForm({ + resolver: zodResolver(createLeadSchema), + defaultValues: { ... }, +}); +// ... +status: lead.status as any, +``` +**Fixed pattern:** +```tsx +// Source: lead-validators.ts already exports CreateLeadInput = z.infer +const form = useForm({ + 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` 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()`:** Defeats the entire purpose of `zodResolver` — no compile-time field-name or type checking. CRM-11 requires removing this. +- **Reintroducing a `lead_tags` table:** The polymorphic `tags` table 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.tsx` is 100% client-side). +- **Keeping `STAGE_COLOR` as a separate hardcoded map AND introducing `getOptionColor`:** Pick one color strategy for `status` badges — 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 `
+ run(() => updateLeadField(lead.id, "name", v))} + /> + + run(() => updateLeadField(lead.id, "status", v ?? "contacted"))} + /> + + run(() => addLeadTag(lead.id, v))} + onRemove={(v) => run(() => removeLeadTag(lead.id, v))} + onRename={(o, n) => run(() => renameLeadTag(o, n))} + /> +
`/``/`` wrapper components, read-only, modal-based editing via `EditLeadModal` | `ServiceTable.tsx` uses raw `
`/``/`
` 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 `` | 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 + +1. **Should `status` use `OptionSelect` (Notion-style, hash colors, user-extensible) or a closed-enum dropdown preserving `STAGE_COLOR`'s semantic colors?** + - What we know: `LEAD_STAGES` is a fixed 6-value enum (`contacted | qualified | proposal_sent | negotiating | won | lost`) with meaningful semantic colors already defined in `STAGE_COLOR` (green=won, red=lost, etc.). `OptionSelect` is 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 `OptionSelect` widget, hash colors) or semantic pipeline-stage coloring (Pipedrive's actual stage pills ARE color-coded by stage meaning, often user-configurable per stage — closer to `STAGE_COLOR`). + - Recommendation: Use `OptionSelect` for interaction consistency (click-to-open dropdown, same as tags) but pass a fixed `options={LEAD_STAGES}` list (no create-on-the-fly) and either (a) extend `OptionSelect` with an optional color-override map, or (b) render the closed/non-extensible dropdown with `STAGE_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. + +2. **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*. `CreateLeadModal` already 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-phase` or the planner surfaces it — CRM-08..12 requirements don't list it, and `CreateLeadModal` is functional. If the planner wants full UX parity with Phase 11's catalog, a `QuickAddRow`-equivalent for leads (name + email + Enter) is a natural, low-effort addition using the same pattern. + +3. **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.tsx` needs an `OptionMultiSelect` for 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 same `OptionMultiSelect` in `LeadDetail.tsx`'s "Profilo" card costs little extra and improves consistency. Mark as Claude's discretion / nice-to-have within CRM-09's scope. + +4. **Pre-existing `requireAdmin()` gap in `src/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 no `requireAdmin()` check, unlike `src/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. + +## 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` — confirms `tags` table 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) diff --git a/.planning/phases/14-crm-attio-style-fix/14-UI-SPEC.md b/.planning/phases/14-crm-attio-style-fix/14-UI-SPEC.md new file mode 100644 index 0000000..df5b6f6 --- /dev/null +++ b/.planning/phases/14-crm-attio-style-fix/14-UI-SPEC.md @@ -0,0 +1,442 @@ +--- +phase: 14 +slug: crm-attio-style-fix +status: approved +shadcn_initialized: true +preset: "default (neutral base color)" +created: "2026-06-13" +--- + +# Phase 14 — UI Design Contract + +> Visual and interaction contract for CRM table redesign (Attio-style) and bug fixes. Generated by gsd-ui-researcher, verified by gsd-ui-checker. + +--- + +## Design System + +| Property | Value | +|----------|-------| +| Tool | shadcn/ui | +| Preset | default (neutral, baseColor: neutral) | +| Component library | radix-ui (via shadcn) | +| Icon library | lucide-react | +| Font | Geist Sans (system fallback) | + +**Source:** `components.json` and `src/app/globals.css` (Phase 11, confirmed 2026-06-13) + +--- + +## Spacing Scale + +Declared values (multiples of 4): + +| Token | Value | Usage | +|-------|-------|-------| +| xs | 4px | Icon gaps, inline padding | +| sm | 8px | Compact element spacing | +| md | 16px | Default element spacing (py-2 px-3 ≈ 8px/12px) | +| lg | 24px | Section padding | +| xl | 32px | Layout gaps | +| 2xl | 48px | Major section breaks | +| 3xl | 64px | Page-level spacing | + +**Exceptions:** None — standard 4px-multiple scale + +**Implementation:** Tailwind utility classes (py-2 px-3 for table cells, gap-1.5 for dropdown spacing, etc.). Reuse `EditableCell`, `OptionSelect`, `OptionMultiSelect` internal spacing without modification. + +--- + +## Typography + +| Role | Size | Weight | Line Height | +|------|------|--------|-------------| +| Body | 16px (default) | 400 (regular) | 1.6 | +| Label / Small | 14px | 400 (regular) | 1.5 | +| XSmall / Badge | 12px | 500 (medium, via `font-medium`) | 1.4 | +| Heading (page) | 20px | 600 (semibold, via `font-semibold`) | 1.2 | + +**Source:** Tailwind defaults + Phase 11 component internals (EditableCell: `text-sm`, OptionSelect/OptionMultiSelect: `text-xs badge, text-sm button`) + +**Details:** +- Body: 16px, weight 400, line-height 1.6 (per `globals.css` body) +- Table cell labels (lead name, email, status): `text-sm` = 14px, weight 400 +- Badge labels (status stage, tags): `text-xs` = 12px, weight 500 (`font-medium`) +- Placeholder text: `text-[#71717a]` (muted-foreground) +- Error messages: `text-xs text-red-600` + +--- + +## Color + +| Role | Value | Usage | +|------|-------|-------| +| Dominant (60%) | #ffffff (white) | Page background, card backgrounds | +| Secondary (30%) | #f4f4f5 (neutral-100) | Hover states, subtle section backgrounds, table row hover | +| Accent (10%) | #1A463C (primary, dark green) | Button text, icon colors, ring/focus, checkbox accent | +| Destructive | #ef4444 (red) | Error messages, delete/dangerous action badges | + +**Semantic colors (for lead status badges):** +- Contacted: blue (bg-blue-100 text-blue-800) +- Qualified: purple (bg-purple-100 text-purple-800) +- Proposal Sent: amber (bg-amber-100 text-amber-800) +- Negotiating: orange (bg-orange-100 text-orange-800) +- Won: green (bg-green-100 text-green-800) +- Lost: red (bg-red-100 text-red-800) + +**Accent reserved for:** +- Form focus rings (input/textarea `ring-primary`) +- Checkbox `accent-[#1A463C]` +- Icon colors in OptionSelect/OptionMultiSelect (checkmarks, pencil, plus, X) +- Primary button text +- Selected state indicators (table row actions) + +**Notes:** +- Table row hover: `hover:bg-[#f9f9f9]` (Phase 11 ServiceTable pattern) +- Border color: `border-[#e5e7eb]` +- Input ring (unfocused): `ring-1 ring-primary` (#1A463C, 1px border) +- Error ring: `ring-2 ring-red-500` (2px on error state) +- OptionSelect/OptionMultiSelect dropdown: white background, 1.5px shadow, 1px border + +--- + +## Copywriting Contract + +| Element | Copy | Context | +|---------|------|---------| +| Primary CTA | "Salva" (implicit, on Enter key) | Inline cell edit commit on Enter or blur | +| CTA — Add Lead | "Nuovo Lead" | CreateLeadModal button (unchanged from Phase 10) | +| CTA — Tag Create | "Crea «{tag_name}»" | OptionMultiSelect dropdown, create-on-type UX | +| CTA — Status Create | N/A (fixed enum) | Status uses OptionSelect but with fixed LEAD_STAGES, no create-on-the-fly | +| Empty state heading | "Nessun lead trovato" | LeadTable when leads.length === 0 | +| Empty state body | (no secondary message) | Single-line empty state, matching ServiceTable pattern | +| Required field error | "Campo richiesto" | EditableCell when required field is empty on blur | +| Invalid status | "Stato non valido" | updateLeadField server action validation | +| Activity log | "Attività registrata" (feedback on LogActivityModal submit) | Unchanged from Phase 10 | +| FollowUpWidget — (CRM-10, i18n) | "Richiedi Follow-up" (heading), "lead/lead" (plural), "da contattare" (to contact), "Contatta" (button), "Vedi Tutto" (view all), "Tutti i lead sono aggiornati!" (no follow-ups) | Dashboard widget, fully Italian | + +**Destructive actions:** +- None explicit in Phase 14 scope (lead deletion already exists, not modified by CRM-08..12) +- If a "delete lead" row action is added later, copy: "Elimina lead: Sei sicuro? Questa azione non può essere annullata." + +--- + +## Component Inventory + +### Reused from Phase 11 (no new imports needed) + +| Component | Path | Purpose in Phase 14 | +|-----------|------|-------------------| +| `EditableCell` | `src/components/ui/editable-cell.tsx` | Inline edit for lead name, email, phone, company, next_action | +| `OptionSelect` | `src/components/ui/option-select.tsx` | Lead status dropdown (fixed enum: LEAD_STAGES, 6 values, no create-on-the-fly) | +| `OptionMultiSelect` | `src/components/ui/option-multi-select.tsx` | Lead tags multi-select (CRM-09: create-on-the-fly enabled) | +| `getOptionColor` | `src/components/ui/option-colors.ts` | Badge color generation for tags (reused directly by OptionMultiSelect) | +| `Badge` | `src/components/ui/badge` | Display lead status & tags badges | +| `Input` | `src/components/ui/input` | Inline edit inputs, search box in new LeadsSearch component | +| `Button` | `src/components/ui/button` | Detail/action buttons (unchanged from Phase 10) | + +### To Create / Modify + +| Component | Path | Change | Scope | +|-----------|------|--------|-------| +| `LeadTable` | `src/components/admin/leads/LeadTable.tsx` | REWRITE — replace shadcn `` wrapper with raw `
`, add EditableCell + OptionSelect/OptionMultiSelect per row | CRM-08, CRM-09 | +| `LeadsSearch` | `src/app/admin/leads/LeadsSearch.tsx` | NEW — client-side search component (useMemo filter on name/email/company/status/tags), mirrors CatalogSearch.tsx | CRM-08 UX (instant filter) | +| `FollowUpWidget` | `src/components/admin/dashboard/FollowUpWidget.tsx` | FIX (CRM-10) — translate 6 hardcoded English strings to Italian | CRM-10 | +| `LeadForm` | `src/components/admin/leads/LeadForm.tsx` | FIX (CRM-11) — remove `useForm`, use Zod-inferred CreateLeadInput/UpdateLeadInput types, remove `as any` casts | CRM-11 | +| `SendQuoteModal` | `src/components/admin/leads/SendQuoteModal.tsx` | FIX (CRM-12) — remove unreachable `if (tab === "new")` branch from onSubmit | CRM-12 | + +--- + +## Table Layout — LeadTable (CRM-08, CRM-09) + +### Visual Pattern (from Phase 11 ServiceTable) + +``` +┌─ Lead Table ─────────────────────────────────────────────────────────────────┐ +│ Nome │ Email │ Stato │ Prossima Azione │ Tag │ +├───────────────────┼─────────────────┼───────────┼───────────────────┼────────┤ +│ [EditableCell] │ [EditableCell] │ [Status] │ [EditableCell] │ [Tags] │ +│ hover: bg-#f9f9f9 │ │ │ │ │ +│ border-b #e5e7eb │ │ │ │ │ +├───────────────────┼─────────────────┼───────────┼───────────────────┼────────┤ +│ ... │ ... │ ... │ ... │ ... │ +└───────────────────┴─────────────────┴───────────┴───────────────────┴────────┘ +``` + +### Column Structure + +| Column | Type | Editable | Required | Cell Component | Min-Width | Notes | +|--------|------|----------|----------|----------------|-----------|-------| +| Nome | text | Yes | Yes | EditableCell(type="text") | 160px | Lead name, bold, linked to detail page | +| Email | text | Yes | No | EditableCell(type="text") | 160px | Nullable, show "—" if empty | +| Telefono | text | Yes | No | EditableCell(type="text") | 140px | Nullable, show "—" if empty | +| Azienda | text | Yes | No | EditableCell(type="text") | 140px | Nullable, show "—" if empty | +| Stato | enum | Yes | Yes | OptionSelect(options=LEAD_STAGES, no create) | 120px | 6 fixed values: contacted, qualified, proposal_sent, negotiating, won, lost | +| Prossima Azione | text | Yes | No | EditableCell(type="text") | 180px | Nullable, show "—" if empty | +| Tag | multi-select | Yes | No | OptionMultiSelect(create-on-the-fly) | 180px | CRM-09: lead tags, polymorphic table (entity_type="leads") | +| Azioni | link | No | N/A | Link → `/admin/leads/[id]` | 80px | "Dettagli" button, unchanged from Phase 10 | + +### Row States + +- **Normal:** white background, border-b #e5e7eb, text #1a1a1a +- **Hover:** bg-#f9f9f9, smooth 150ms transition +- **Cell edit (active):** EditableCell in input mode — ring-1 ring-primary, h-8 for text inputs +- **Cell error:** ring-2 ring-red-500, error message text-xs text-red-600 below cell +- **Row with error:** shows error row spanning all columns, text-xs text-red-600 + +### Search/Filter (CRM-08 UX — "Attio-style instant") + +- New component: `LeadsSearch` (client-side wrapper) +- Input: search box at top of page (before table) +- Behavior: `useMemo` filter on keystroke, no server round-trip +- Filter fields: name, email, company, status label, tag names (all case-insensitive, includes-search) +- Display: filtered leads in table below; if no results, empty state "Nessun lead trovato" +- Placeholder: "Cerca lead..." (consistent with CatalogSearch style) + +--- + +## Bug Fixes + +### CRM-10: FollowUpWidget Internationalization + +**Current state:** 6 hardcoded English strings in JSX. + +**Strings to translate:** +1. "Require Follow-up" → "Richiedi Follow-up" +2. "lead" / "leads" (noun, "X lead to contact") → "lead" / "lead" (Italian stays same, but handle plural: "X lead da contattare" / "X lead da contattare") +3. "to contact" → "da contattare" +4. "Contact" (button label) → "Contatta" +5. "View All" → "Vedi Tutto" +6. "All leads are up to date!" → "Tutti i lead sono aggiornati!" + +**Style:** Tone matches existing `LeadDetail.tsx`/`LeadTable.tsx` (semi-formal, second-person friendly). + +**Location:** `src/components/admin/dashboard/FollowUpWidget.tsx` JSX only (no data-layer strings confirmed). + +### CRM-11: LeadForm Type Safety (react-hook-form) + +**Current pattern:** `useForm()`, `field.value as any`, `data: any` in onSubmit. + +**Fix pattern:** +- Import `CreateLeadInput` / `UpdateLeadInput` from `src/lib/lead-validators.ts` (Zod-inferred types) +- Change `useForm()` → `useForm()` (or UpdateLeadInput for edit modal) +- Replace `as any` casts with type-safe narrowing at schema boundary (e.g., `lead.status as CreateLeadInput["status"]`) +- Remove blanket `data: any` in `onSubmit` callback signature +- Nullable fields: use `field.value || ""` pattern (already established elsewhere in the app) + +**No schema changes required** — existing `createLeadSchema`/`updateLeadSchema` are correct; this is purely a type-safety fix in the component. + +### CRM-12: SendQuoteModal Unreachable Code + +**Current problem:** `if (tab === "new")` branch inside `onSubmit` (form submit handler) is unreachable because the "new" tab has its own standalone button with `onClick` handler that does a redirect, never submitting the form. + +**Fix:** +- Remove the unreachable `if (tab === "new")` branch from `onSubmit` handler +- Optionally simplify `onSubmit` to only handle the "existing quote" path (tab === "existing") +- Confirm `generate_new` field in schema/defaultValues is also dead code (currently always false, unused in action) + +**Scope:** Code cleanup only; no UX change to "new" tab button (still does redirect as-is). + +--- + +## Server Actions (Backend Contract) + +### New Actions Required (CRM-08, CRM-09) + +**File:** `src/app/admin/leads/actions.ts` (extend existing) + +```typescript +// Pattern 1: Field update with per-field validation +export async function updateLeadField( + leadId: string, + fieldName: EditableField, + value: string +): Promise { + // Validates fieldName against EDITABLE_FIELDS allowlist + // Applies field-specific validation (name required, status in LEAD_STAGES, etc.) + // Calls db.update() via Drizzle ORM + // Calls revalidatePath("/admin/leads") and revalidatePath(`/admin/leads/${leadId}`) +} + +// Pattern 2: Tag CRUD (polymorphic tags table) +export async function addLeadTag(leadId: string, value: string): Promise +export async function removeLeadTag(leadId: string, value: string): Promise +export async function renameLeadTag(oldValue: string, newValue: string): Promise +// All three call requireAdmin() at the top +// Use tags table with entity_type="leads", entity_id=leadId +// Call revalidatePath("/admin/leads") after each mutation +``` + +**Auth guard:** All new actions must include `await requireAdmin()` check (per Phase 11 convention in `catalog/actions.ts`). + +### Query Functions (CRM-08 data fetch) + +**File:** `src/lib/admin-queries.ts` (extend) or new `src/lib/lead-service.ts` + +```typescript +export type LeadWithTags = Lead & { tags: string[] }; +export type LeadFieldOptions = { status: string[]; tags: string[] }; + +export async function getLeadsWithTags(): Promise + // Left-join leads + tags table (entity_type="leads") + // Map-reduce to attach tags array per lead + // Return sorted by updated_at DESC, tags ASC + +export async function getLeadFieldOptions(): Promise + // Return status: LEAD_STAGES (fixed enum) + // Return tags: distinct tag names for entity_type="leads", sorted by locale "it" +``` + +--- + +## Interaction Flows + +### Flow 1: Inline Edit Lead Field (CRM-08) + +``` +User clicks cell + ↓ +EditableCell → input mode (focus + select text) + ↓ +User types + presses Enter (or loses focus) + ↓ +EditableCell.commit() → calls onSave callback + ↓ +Callback runs server action updateLeadField(leadId, fieldName, value) + ↓ +Server action validates, updates DB, revalidates paths + ↓ +Router.refresh() re-renders page with new data + ↓ +EditableCell exits edit mode, shows new value +``` + +**Error handling:** If server action throws, error message displays below cell (text-xs text-red-600). User can retry by clicking cell again. + +### Flow 2: Multi-Select Lead Tags (CRM-09) + +``` +User clicks tag cell + ↓ +OptionMultiSelect → dropdown opens, shows pill list + search input + ↓ +User types tag name (or selects existing) + ↓ +If new: shows "Crea «{name}»" button + ↓ +User presses Enter or clicks button + ↓ +Calls onAdd callback → server action addLeadTag(leadId, tagName) + ↓ +Server action inserts into tags table (entity_type="leads") + ↓ +Router.refresh() + revalidate paths + ↓ +OptionMultiSelect pill list updates with new tag (+ remove button) +``` + +**Remove tag:** User clicks × button on pill → calls onRemove callback → removeLeadTag action. + +**Rename tag:** User clicks pencil icon on option → inline input → commitRename → renameLeadTag action (propagates to all leads with this tag). + +### Flow 3: Client-Side Search (CRM-08 UX) + +``` +User types in search box (LeadsSearch component) + ↓ +useState(query) triggers useMemo filter + ↓ +Filter includes: name, email, company, status, tags (all case-insensitive) + ↓ +Filtered leads array updates + ↓ +LeadTable re-renders with filtered rows + ↓ +No server call, instant response +``` + +**Clear search:** User clears input → all leads shown again. + +**Empty results:** LeadTable displays "Nessun lead trovato" empty state. + +--- + +## Registry Safety + +| Registry | Blocks Used | Status | +|----------|-------------|--------| +| shadcn official | Badge, Input, Button, Table (deprecated in new LeadTable), Textarea, Table import removal | no vetting required | +| third-party | none | N/A | + +**Notes:** +- Phase 14 removes the shadcn `Table` wrapper from LeadTable (switches to raw `
` for consistency with Phase 11 ServiceTable pattern) +- No new third-party registries introduced +- All components reused from Phase 11 or existing shadcn official library + +--- + +## Checker Sign-Off + +- [x] Dimension 1 Copywriting: PASS +- [x] Dimension 2 Visuals: FLAG — no explicit focal point declared for the LeadsSearch + LeadTable screen (non-blocking; recommend "Primary focal point: search input bar at top; secondary: lead name column (bold, linked to detail)") +- [x] Dimension 3 Color: PASS +- [x] Dimension 4 Typography: PASS +- [x] Dimension 5 Spacing: PASS +- [x] Dimension 6 Registry Safety: PASS + +**Approval:** APPROVED (2026-06-14) — 5/6 PASS, 1 non-blocking FLAG (Visuals/focal point) + +--- + +## Design Decisions & Rationale + +### Decision 1: Status Field — OptionSelect with Fixed LEAD_STAGES (not extensible) + +**What:** Lead `status` uses `OptionSelect` component but with `options={LEAD_STAGES}` (6 fixed values: contacted, qualified, proposal_sent, negotiating, won, lost) and **no create-on-the-fly** UX. + +**Why:** `status` is a closed enum, not an open-ended Notion-style property like tags. Allowing users to create arbitrary status values would break pipeline logic, STAGE_COLOR lookups, and LEAD_STAGES-based filters elsewhere (LeadDetail.tsx, FollowUpWidget, queries). OptionSelect is reused for interaction consistency with the tags column (click-to-open dropdown, keyboard nav, rename support), but the input validation (Pattern 2) enforces that only LEAD_STAGES values are accepted server-side, regardless of client-side UI. + +**Semantic colors preserved:** Badge colors for status still use STAGE_COLOR map (blue/purple/amber/orange/green/red), not `getOptionColor()`'s hash-based pastels, to maintain pipeline-stage meaning. + +### Decision 2: LeadTable Layout — Raw `
` not shadcn `
` Wrapper + +**What:** Phase 14 LeadTable uses raw HTML `
//
` elements with Tailwind classes, exactly like Phase 11 ServiceTable. + +**Why:** +1. **Visual consistency:** Phase 11 established the "database-view" pattern (ServiceTable) as the standard for inline-editable Attio-style tables; replicating it for leads ensures visual/interaction coherence. +2. **Component reuse:** EditableCell/OptionSelect/OptionMultiSelect are already styled for raw-table cells (py-2 px-3, min-w-*, inline spacing); wrapping them in shadcn `` wrapper components adds unnecessary nesting. +3. **Simplicity:** Raw table is easier to customize row-by-row (per-row error messages, inline edit state transitions, per-row hover) than the shadcn wrapper's structured slots. + +### Decision 3: No Lead Quick-Add Row (CRM-08 scope) + +**What:** Phase 14 does NOT include a `QuickAddRow` equivalent for leads (Phase 11 added it for services). + +**Why:** CRM-08 requirement says "inline editing dei campi principali" (existing leads) + CRM-09 says "assegna tag" (existing leads). Neither mentions lead *creation* UX. The existing `CreateLeadModal` is the lead-creation entry point and remains unchanged. Adding a quick-add row would be a nice-to-have (Attio/Pipedrive parity), but it is explicitly **out of scope** for Phase 14 per CRM-08..12. + +**If user asks for it:** Flag as a follow-up enhancement, separate from Phase 14 planning. + +### Decision 4: LeadDetail.tsx Tags Display (Claude's Discretion) + +**What:** Whether `LeadDetail.tsx` (the `/admin/leads/[id]` detail page) should surface the new lead tags in a read-only or editable section. + +**Current assumption:** Tags are managed and visible in the table only (CRM-09 doesn't explicitly require detail-page display). + +**Recommendation:** Low-effort addition — if planner agrees, add an `OptionMultiSelect` for tags in LeadDetail's "Profilo" card (same data layer as table). If planner says no, leave LeadDetail as-is. + +### Decision 5: FollowUpWidget Plural Handling (CRM-10) + +**What:** The English phrase "X lead to contact" vs. "X leads to contact" — Italian equivalent: "X lead da contattare" (both singular and plural use "lead"). + +**Implementation:** Use template string: `{count} lead da contattare` (always "lead", never "leads" in Italian, since "lead" is a loanword with Italian plural also "lead"). + +--- + +## Open Questions Resolved + +| Question | Resolution | Rationale | +|----------|-----------|-----------| +| Should status use OptionSelect or closed-enum dropdown? | OptionSelect + fixed LEAD_STAGES, no create-on-the-fly | Consistency with table interaction pattern; server-side validation ensures only valid statuses enter DB | +| Include quick-add row for new leads? | No — out of scope for CRM-08..12 | CRM requirements focus on editing existing leads; CreateLeadModal unchanged | +| Surface tags in LeadDetail.tsx? | Claude's discretion (recommend yes, low-effort) | Add OptionMultiSelect in detail card if planner approves; keep table as primary interface if not | +| Retrofit pre-existing lead actions with requireAdmin()? | No — out of scope for Phase 14 | New actions (CRM-08/09) include requireAdmin(); pre-existing gap noted for user awareness, not in this phase's plan | +