diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index 311c8dd..17bcf6d 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -1 +1,134 @@ -PLACEHOLDER \ No newline at end of file +# Roadmap: ClientHub v2.1 Offer Studio + Proposal AI + +## Overview + +**v2.0 (Phases 1–10, Complete)**: ClientHub è una suite operativa completa — catalogo servizi unificato, offerte con fasi, preventivo pubblico (`/quote/[token]`), CRM pipeline lead con activity log. Archiviato in `.planning/milestones/v2.0-ROADMAP.md`. + +**v2.1 (Phases 11–17, Planning)**: Rifare la UX di catalogo/offerte in stile database-view (Notion-like), ristrutturare il workspace progetto (Preventivo → Servizi Attivi, rimozione Forecast), ricostruire il CRM in stile Attio/Pipedrive (inline edit, tag, fix bug residui), e chiudere il loop commerciale con un builder preventivi AI + invio email + auto-provisioning. Numerazione fasi: v2.0 ha chiuso a Phase 10; v2.1 parte da Phase 11. + +## Phases + +- [x] **Phase 11: Catalog Database-View UX & Legacy Consolidation** - Catalogo `services` come tabella con inline edit, tag multi-select, quick-add, ricerca istantanea; consolidamento `service_catalog`/`offer_services` → `services` +- [ ] **Phase 12: Offer Composition Drag&Drop & CSV Import** - Composizione offerte via drag&drop dal catalogo (`@dnd-kit`) con totale live; import CSV one-shot +- [ ] **Phase 13: Workspace — Servizi Attivi** - Tab "Preventivo" rimossa (UI-only), "Offerte" → "Servizi attivi" (one-shot/ricorrenti + tracking incassi mensili), rimozione pagina Forecast +- [ ] **Phase 14: CRM Attio-style & Fix** - Redesign `/admin/leads` stile Attio/Pipedrive (inline edit + tag), fix FollowUpWidget IT / LeadForm types / SendQuoteModal rami irraggiungibili +- [ ] **Phase 15: Dashboard Revenue Stats** - Dashboard admin mostra statistiche revenue mensile (ex `/admin/forecast`) da `project_offers` — **BLOCCATA**, attesa mockup utente +- [ ] **Phase 16: Proposal AI — Data Foundations & Auto-Provisioning** - Campo Stripe Payment Link su offerte/preventivi + auto-provisioning (client+progetto+fasi+pagamenti) all'accettazione +- [ ] **Phase 17: Proposal AI — Builder, Pagina Pubblica & Email** - Builder AI proposta da cliente+offerta+note, redesign `/quote/[token]`, invio email Resend con link proposta + +## Phase Details + +### Phase 11: Catalog Database-View UX & Legacy Consolidation +**Goal**: L'utente vede ed edita il catalogo servizi come una tabella stile database (Notion-like) — inline editing, tag multi-select, quick-add, ricerca istantanea — su un'unica tabella `services` consolidata senza perdita di dati legacy +**Mode:** migration + ui +**Depends on**: Phase 10 (CRM Pipeline, ultima fase v2.0) +**Requirements**: OFFER-07, OFFER-08, OFFER-09, OFFER-10, OFFER-13 +**Success Criteria** (what must be TRUE): + 1. L'utente edita inline ogni cella del catalogo `services` (click sulla cella → edit → invio salva) senza apertura di modali + 2. L'utente assegna tag multi-select a servizi/offerte, creando nuovi tag al volo senza uscire dalla tabella + 3. L'utente aggiunge un nuovo servizio tramite riga vuota in fondo alla tabella (nome + invio) + 4. L'utente filtra/cerca servizi e offerte istantaneamente (ricerca client-side, nessun reload) + 5. I dati legacy di `service_catalog` e `offer_services` sono consolidati in `services` senza perdita (migration additiva, audit trail `migrated_from`/`migrated_id`) +**Plans**: 4 plans +**Plan list**: + - [x] 11-01-PLAN.md + - [x] 11-02-PLAN.md + - [x] 11-03-PLAN.md + - [x] 11-04-PLAN.md +**UI hint**: yes +**Status**: ✅ Complete (2026-06-13) — migration 0007 (`services.fase`) applicata in prod, commit pushato su `gitea/main` + +### Phase 12: Offer Composition Drag&Drop & CSV Import +**Goal**: L'utente compone un'offerta trascinando servizi dal catalogo con un totale live durante il drag, e può importare un CSV one-shot per popolare servizi/offerte esistenti +**Mode:** ui +**Depends on**: Phase 11 (tabella `services` unificata) +**Requirements**: OFFER-11, OFFER-12 +**Success Criteria** (what must be TRUE): + 1. L'utente compone un'offerta trascinando servizi dal catalogo (`@dnd-kit`) + 2. Durante il drag, il totale dell'offerta si aggiorna live + 3. L'utente importa un CSV one-shot per popolare servizi/offerte esistenti (solo seed iniziale, non sync continuo) +**Plans**: TBD +**UI hint**: yes +**Status**: Pending planning + +### Phase 13: Workspace — Servizi Attivi +**Goal**: Il workspace progetto sostituisce le tab "Preventivo"/"Offerte"/"Forecast" con un'unica tab "Servizi attivi" che traccia servizi one-shot e ricorrenti con incassi mensili, senza perdere `accepted_total`/`quote_items` +**Mode:** ui + schema +**Depends on**: Phase 5 (`project_offers`), Phase 9 (flusso `accepted_total` via quote) +**Requirements**: PROJ-06, PROJ-07, PROJ-08, PROJ-09, PROJ-10 +**Success Criteria** (what must be TRUE): + 1. La tab "Preventivo" è rimossa dal workspace progetto (solo UI — `quote_items` resta intatta, `accepted_total` resta editabile via tab Pagamenti) + 2. La tab "Offerte" diventa "Servizi attivi" e mostra tutte le offerte assegnate al progetto (`project_offers`) + 3. L'utente marca ogni "servizio attivo" come one-shot o ricorrente mensile + 4. Per i servizi ricorrenti, l'utente registra in quali mesi è stata incassata la fattura (schema nuovo, additivo) + 5. La pagina "Forecast" e la relativa voce di sidebar sono rimosse +**Plans**: TBD +**UI hint**: yes +**Status**: Pending planning + +### Phase 14: CRM Attio-style & Fix +**Goal**: La tabella lead (`/admin/leads`) è ridisegnata in stile Attio/Pipedrive — inline editing dei campi principali e tag multi-select — e i bug residui del modulo CRM (FollowUpWidget non in italiano, LeadForm con type-relaxation, SendQuoteModal con rami irraggiungibili) sono risolti +**Mode:** ui +**Depends on**: Phase 10 (CRM Pipeline base) +**Requirements**: CRM-08, CRM-09, CRM-10, CRM-11, CRM-12 +**Success Criteria** (what must be TRUE): + 1. La tabella lead supporta inline editing dei campi principali (status, next_action, ecc.) senza apertura di modale + 2. L'utente assegna tag multi-select ai lead, creando nuovi tag al volo + 3. Il `FollowUpWidget` è interamente in italiano, coerente col resto dell'app + 4. Il form lead (`LeadForm`) valida i campi senza workaround di type-relaxation su react-hook-form + 5. Il flusso `SendQuoteModal` (preventivo esistente vs nuovo) non contiene rami di codice irraggiungibili +**Plans:** 3 plans +Plans: +- [ ] 14-01-PLAN.md — Data-layer foundation: getLeadsWithTags/getLeadFieldOptions query helpers + updateLeadField/addLeadTag/removeLeadTag/renameLeadTag server actions (requireAdmin-guarded) +- [ ] 14-02-PLAN.md — LeadTable raw-table rewrite (inline edit + status dropdown + tag multi-select), LeadsSearch instant filter, page wiring, LeadDetail tags +- [ ] 14-03-PLAN.md — Bug fixes: FollowUpWidget Italian translation (CRM-10), LeadForm typed useForm (CRM-11), SendQuoteModal dead branch removal (CRM-12) +**UI hint**: yes — riferimento design Pipedrive/Attio (confermato dall'utente 2026-06-13) +**Status**: Planned + +### Phase 15: Dashboard Revenue Stats +**Goal**: La Dashboard admin mostra le statistiche di revenue mensile (ex `/admin/forecast`), calcolate da `project_offers` +**Mode:** ui +**Depends on**: Phase 13 (modello dati Servizi attivi) +**Requirements**: DASH-11 +**Success Criteria** (what must be TRUE): + 1. La Dashboard admin mostra un breakdown di revenue mensile calcolato da `project_offers` +**Plans**: TBD +**UI hint**: yes +**Status**: Blocked — attesa mockup dashboard dall'utente (isolata/skippabile, non blocca Phase 16/17) + +### Phase 16: Proposal AI — Data Foundations & Auto-Provisioning +**Goal**: Ogni offerta/preventivo ha un campo link di pagamento Stripe mostrato come CTA pubblica, e all'accettazione del preventivo il sistema crea automaticamente cliente + progetto, copia le fasi dall'offerta e configura 1-4 pagamenti +**Mode:** schema + automation +**Depends on**: Phase 9 (flusso accettazione preventivo), Phase 13 (Servizi attivi come target del provisioning) +**Requirements**: PROP-03, PROP-04 +**Success Criteria** (what must be TRUE): + 1. Ogni offerta/preventivo ha un campo "link di pagamento" (Stripe Payment Link) mostrato come CTA nella pagina pubblica + 2. All'accettazione del preventivo, il sistema crea automaticamente cliente + progetto, copia le fasi dall'offerta e configura 1-4 pagamenti +**Plans**: TBD +**UI hint**: yes +**Status**: Pending planning + +### Phase 17: Proposal AI — Builder, Pagina Pubblica & Email +**Goal**: Un builder AI genera una proposta a partire da cliente + offerta(e) selezionata(e) + note extra; la pagina pubblica `/quote/[token]` è ridisegnata; il link della proposta viene inviato via email (Resend) al lead +**Mode:** ai + ui +**Depends on**: Phase 16 +**Requirements**: PROP-01, PROP-02, PROP-05 +**Success Criteria** (what must be TRUE): + 1. L'admin avvia un builder AI che genera una proposta a partire da cliente + offerta(e) selezionata(e) + note extra fornite manualmente + 2. La pagina pubblica `/quote/[token]` è ridisegnata (HTML/CSS) per presentare la proposta generata + 3. Il sistema invia via email (Resend) il link della proposta pubblica al lead +**Plans**: TBD +**UI hint**: yes +**Status**: Pending planning + +## Progress + +| Phase | Plans | Status | Completed | +|-------|-------|--------|-----------| +| 11. Catalog Database-View UX & Legacy Consolidation | 4/4 | ✅ Done | 2026-06-13 | +| 12. Offer Composition Drag&Drop & CSV Import | — | Pending planning | — | +| 13. Workspace — Servizi Attivi | — | Pending planning | — | +| 14. CRM Attio-style & Fix | — | Pending planning (in progress) | — | +| 15. Dashboard Revenue Stats | — | Blocked (mockup utente) | — | +| 16. Proposal AI — Data Foundations & Auto-Provisioning | — | Pending planning | — | +| 17. Proposal AI — Builder, Pagina Pubblica & Email | — | Pending planning | — | diff --git a/.planning/phases/14-crm-attio-style-fix/14-01-PLAN.md b/.planning/phases/14-crm-attio-style-fix/14-01-PLAN.md new file mode 100644 index 0000000..48320df --- /dev/null +++ b/.planning/phases/14-crm-attio-style-fix/14-01-PLAN.md @@ -0,0 +1,575 @@ +--- +phase: 14-crm-attio-style-fix +plan: 01 +type: execute +wave: 1 +depends_on: [] +files_modified: + - src/lib/admin-queries.ts + - src/app/admin/leads/actions.ts +autonomous: true +requirements: [CRM-08, CRM-09] + +must_haves: + truths: + - "Querying getLeadsWithTags() returns every lead row augmented with a `tags: string[]` array populated from the polymorphic tags table (entity_type='leads')" + - "Querying getLeadFieldOptions() returns the fixed LEAD_STAGES list for `status` and the distinct sorted set of lead tag names for `tags`" + - "Calling updateLeadField() for an unauthenticated request throws 'Non autorizzato' before touching the database" + - "Calling updateLeadField(leadId, 'status', 'not_a_stage') throws 'Stato non valido' and does not write to the leads table" + - "Calling addLeadTag/removeLeadTag/renameLeadTag mutates only rows where entity_type='leads', never touching entity_type='services' tag rows" + artifacts: + - path: "src/lib/admin-queries.ts" + provides: "LeadWithTags type, LeadFieldOptions type, getLeadsWithTags(), getLeadFieldOptions()" + contains: "export async function getLeadsWithTags" + - path: "src/app/admin/leads/actions.ts" + provides: "updateLeadField, addLeadTag, removeLeadTag, renameLeadTag server actions with requireAdmin() guard" + contains: "export async function updateLeadField" + key_links: + - from: "src/app/admin/leads/actions.ts" + to: "src/db/schema.ts tags table" + via: "db.insert(tags)/db.delete(tags)/db.update(tags) scoped by entity_type='leads'" + pattern: "LEADS_TAG_ENTITY" + - from: "src/lib/admin-queries.ts getLeadsWithTags" + to: "src/db/schema.ts tags table" + via: "leftJoin scoped by entity_type='leads'" + pattern: "eq\\(tags\\.entity_type, LEADS_TAG_ENTITY\\)" +--- + + +Build the data-layer foundation for the Attio-style lead table redesign: extend `src/lib/admin-queries.ts` with `getLeadsWithTags()` / `getLeadFieldOptions()` (mirroring Phase 11's `getAllServices()` / `getCatalogFieldOptions()` for the polymorphic `tags` table), and extend `src/app/admin/leads/actions.ts` with `updateLeadField`, `addLeadTag`, `removeLeadTag`, `renameLeadTag` server actions — all guarded by `requireAdmin()` per the Phase 11 catalog convention. + +Purpose: This is the Wave 1 "contracts + backend" plan. Plan 14-02 (LeadTable rewrite, LeadsSearch, page wiring) depends on the types and server actions created here. + +Output: `LeadWithTags`/`LeadFieldOptions` types and query functions in `admin-queries.ts`; four new server actions in `leads/actions.ts`, all with `requireAdmin()` checks and dual `revalidatePath` calls (list + detail). + + + +@$HOME/.claude/get-shit-done/workflows/execute-plan.md +@$HOME/.claude/get-shit-done/templates/summary.md + + + +@.planning/ROADMAP.md +@.planning/REQUIREMENTS.md +@.planning/phases/14-crm-attio-style-fix/14-RESEARCH.md +@.planning/phases/14-crm-attio-style-fix/14-PATTERNS.md + + + + + +From src/lib/admin-queries.ts (lines 360-448, getAllServices / getCatalogFieldOptions — analog pattern): +```typescript +const TAG_ENTITY = "services"; +const PACCHETTO_ENTITY = "services.pacchetto"; + +export type ServiceWithTags = Service & { tags: string[]; pacchetto: string[] }; + +export async function getAllServices(): Promise { + const rows = await db + .select({ + id: services.id, + name: services.name, + // ...all service columns... + tag_name: tags.name, + tag_type: tags.entity_type, + }) + .from(services) + .leftJoin( + tags, + and( + eq(tags.entity_id, services.id), + inArray(tags.entity_type, [TAG_ENTITY, PACCHETTO_ENTITY]) + ) + ) + .orderBy(asc(services.name), asc(tags.name)); + + const serviceMap = new Map(); + for (const row of rows) { + const { tag_name, tag_type, ...serviceFields } = row; + if (!serviceMap.has(row.id)) { + serviceMap.set(row.id, { ...serviceFields, tags: [], pacchetto: [] }); + } + if (tag_name) { + const target = serviceMap.get(row.id)!; + if (tag_type === PACCHETTO_ENTITY) target.pacchetto.push(tag_name); + else target.tags.push(tag_name); + } + } + return Array.from(serviceMap.values()); +} + +export type CatalogFieldOptions = { + tag: string[]; + pacchetto: string[]; + categoria: string[]; + fase: string[]; +}; + +export async function getCatalogFieldOptions(): Promise { + const tagRows = await db + .selectDistinct({ name: tags.name, type: tags.entity_type }) + .from(tags) + .where(inArray(tags.entity_type, [TAG_ENTITY, PACCHETTO_ENTITY])); + + const sortUnique = (arr: (string | null)[]) => + Array.from(new Set(arr.filter((v): v is string => !!v && v.trim().length > 0))).sort( + (a, b) => a.localeCompare(b, "it") + ); + + return { + tag: sortUnique(tagRows.filter((r) => r.type === TAG_ENTITY).map((r) => r.name)), + pacchetto: sortUnique(tagRows.filter((r) => r.type === PACCHETTO_ENTITY).map((r) => r.name)), + categoria: sortUnique(catRows.map((r) => r.value)), + fase: sortUnique(faseRows.map((r) => r.value)), + }; +} +``` + +From src/lib/admin-queries.ts (line 27-46, existing top-of-file imports — `Lead` is ALREADY imported): +```typescript +import { db } from "@/db"; +import { + // ... existing entity tables ... + leads, + tags, +} from "@/db/schema"; +import { eq, inArray, asc, isNull, sql, and } from "drizzle-orm"; +import type { + // ... existing types ... + Lead, +} from "@/db/schema"; +``` +NOTE: `eq`, `and`, `asc` are already imported. `leads` and `tags` table objects are already imported (used elsewhere in admin-queries.ts for other queries). `Lead` type is already imported. No new top-level imports needed for the query additions — only add `LEAD_STAGES` from `@/lib/lead-validators` if not already present (it is NOT currently imported in admin-queries.ts). + +From src/db/schema.ts (lines 396-411, leads table — for column reference): +```typescript +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"), + 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(), +}); +export type Lead = typeof leads.$inferSelect; +``` + +From src/db/schema.ts (lines 119-134, tags table — polymorphic, already in prod): +```typescript +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), + }) +); +``` +No migration needed — this table is already deployed to production with `entity_type='leads'` explicitly reserved in its design. + +From src/app/admin/catalog/actions.ts (lines 1-21, imports + requireAdmin — exact pattern to replicate): +```typescript +"use server"; + +import { db } from "@/db"; +import { services, tags } from "@/db/schema"; +import { revalidatePath } from "next/cache"; +import { eq, and } from "drizzle-orm"; +import { z } from "zod"; +import { getServerSession } from "next-auth"; +import { authOptions } from "@/lib/auth"; + +async function requireAdmin() { + const session = await getServerSession(authOptions); + if (!session) throw new Error("Non autorizzato"); +} +``` + +From src/app/admin/catalog/actions.ts (lines 131-199, tag CRUD — exact analog for addLeadTag/removeLeadTag/renameLeadTag): +```typescript +export async function addServiceOption(field: MultiSelectField, serviceId: string, value: string) { + await requireAdmin(); + if (!MULTI_FIELDS.includes(field)) throw new Error(`Campo non valido: ${field}`); + const trimmed = value.trim(); + if (trimmed.length === 0) throw new Error("Valore richiesto"); + + await db + .insert(tags) + .values({ entity_type: MULTI_ENTITY[field], entity_id: serviceId, name: trimmed }) + .onConflictDoNothing(); + + revalidatePath("/admin/catalog"); +} + +export async function removeServiceOption(field: MultiSelectField, serviceId: string, value: string) { + await requireAdmin(); + if (!MULTI_FIELDS.includes(field)) throw new Error(`Campo non valido: ${field}`); + + await db + .delete(tags) + .where( + and( + eq(tags.entity_type, MULTI_ENTITY[field]), + eq(tags.entity_id, serviceId), + eq(tags.name, value) + ) + ); + + revalidatePath("/admin/catalog"); +} + +export async function renameServiceOption(field: MultiSelectField | SingleSelectField, oldValue: string, newValue: string) { + await requireAdmin(); + const next = newValue.trim(); + if (next.length === 0) throw new Error("Nuovo nome richiesto"); + if (next === oldValue) return; + + if (field === "tag" || field === "pacchetto") { + await db + .update(tags) + .set({ name: next }) + .where(and(eq(tags.entity_type, MULTI_ENTITY[field]), eq(tags.name, oldValue))); + } + // ... else branches for single-select fields, not needed for leads tags ... + + revalidatePath("/admin/catalog"); +} +``` + +From src/lib/lead-validators.ts (existing, exports LEAD_STAGES): +```typescript +export const LEAD_STAGES = [ + "contacted", + "qualified", + "proposal_sent", + "negotiating", + "won", + "lost", +] as const; +``` + +From src/app/admin/leads/actions.ts (existing file — current top, lines 1-10, what's already there): +```typescript +"use server"; + +import { z } from "zod"; +import { db } from "@/db"; +import { leads, quotes } from "@/db/schema"; +import { eq } from "drizzle-orm"; +import { createLeadSchema, updateLeadSchema, createActivitySchema } from "@/lib/lead-validators"; +import { revalidatePath } from "next/cache"; +import { createActivity, updateLeadStage } from "@/lib/lead-service"; +``` +Existing pre-Phase-14 actions (`createLead`, `updateLead`, `deleteLead`, `logActivity`, `assignQuoteToLead`) have NO `requireAdmin()` check — this is a pre-existing gap, OUT OF SCOPE for this plan (do not retrofit). New actions added in this plan MUST call `requireAdmin()`. + + + + + + Task 1: Extend admin-queries.ts with LeadWithTags and LeadFieldOptions + src/lib/admin-queries.ts + + - src/lib/admin-queries.ts (lines 1-46 for existing imports, lines 360-448 for the getAllServices/getCatalogFieldOptions analog pattern to replicate) + - src/lib/lead-validators.ts (LEAD_STAGES export) + - src/db/schema.ts (lines 119-134 tags table, lines 396-411 leads table) + + +At the end of src/lib/admin-queries.ts (after the getCatalogFieldOptions function, i.e. after line 448), add a new section: + +1. Add import: `import { LEAD_STAGES } from "@/lib/lead-validators";` to the top of the file (near other lib imports, after line 26's drizzle-orm import or with other internal imports — place it as a new top-level import line). + +2. Add a new section with a header comment: +```typescript +// ── LeadWithTags — leads + tags (Phase 14 database-view) ───────────────────── +// Lead tags are stored in the polymorphic `tags` table, scoped by +// entity_type="leads". `status` is a fixed enum (LEAD_STAGES), not a +// dynamic pool — exposed via getLeadFieldOptions for the OptionSelect UI. + +const LEADS_TAG_ENTITY = "leads"; + +export type LeadWithTags = Lead & { tags: string[] }; + +export async function getLeadsWithTags(): Promise { + const rows = await db + .select({ + id: leads.id, + name: leads.name, + email: leads.email, + phone: leads.phone, + company: leads.company, + status: leads.status, + last_contact_date: leads.last_contact_date, + next_action: leads.next_action, + next_action_date: leads.next_action_date, + notes: leads.notes, + created_at: leads.created_at, + updated_at: leads.updated_at, + tag_name: 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], + tags: tagRows + .map((r) => r.name) + .sort((a, b) => a.localeCompare(b, "it")), + }; +} +``` + +Notes: +- `eq`, `and`, `asc` are already imported from `drizzle-orm` at line 26 — do not re-import. +- `leads` and `tags` are already imported from `@/db/schema` — do not re-import. +- `Lead` type is already imported from `@/db/schema` (line 45) — do not re-import. +- Ordering: use `.orderBy(desc(leads.updated_at), asc(tags.name))` — this preserves the existing list ordering behavior (most-recently-updated first, matching `getAllLeads()`'s current `desc(leads.updated_at)`, which is the current LeadTable's data source). `desc` is NOT currently in the drizzle-orm import list at line 26 (`eq, inArray, asc, isNull, sql, and`) — add it to that import. + + + grep -c "export async function getLeadsWithTags" src/lib/admin-queries.ts && grep -c "export async function getLeadFieldOptions" src/lib/admin-queries.ts && npx tsc --noEmit + + + - `grep -c "export async function getLeadsWithTags" src/lib/admin-queries.ts` returns 1 + - `grep -c "export async function getLeadFieldOptions" src/lib/admin-queries.ts` returns 1 + - `grep -c "export type LeadWithTags" src/lib/admin-queries.ts` returns 1 + - `grep -c "export type LeadFieldOptions" src/lib/admin-queries.ts` returns 1 + - `grep -c "LEADS_TAG_ENTITY = \"leads\"" src/lib/admin-queries.ts` returns 1 + - `grep -c "import { LEAD_STAGES } from \"@/lib/lead-validators\"" src/lib/admin-queries.ts` returns 1 + - `grep -c "desc(leads.updated_at), asc(tags.name)" src/lib/admin-queries.ts` returns 1 + - `npx tsc --noEmit` exits 0 (no new type errors introduced) + + getLeadsWithTags() and getLeadFieldOptions() exist in admin-queries.ts, type-check cleanly, follow the getAllServices/getCatalogFieldOptions pattern exactly (left-join + Map-reduce for tags), and use LEADS_TAG_ENTITY="leads" scoping. + + + + Task 2: Add updateLeadField + tag CRUD server actions to leads/actions.ts + src/app/admin/leads/actions.ts + + - src/app/admin/leads/actions.ts (full current file — existing actions, imports, pattern conventions) + - src/app/admin/catalog/actions.ts (lines 1-21 requireAdmin pattern; lines 71-116 updateServiceField field-allowlist pattern; lines 131-199 tag CRUD pattern) + - src/lib/lead-validators.ts (LEAD_STAGES export) + + + These are server actions (no dedicated test file in this codebase's convention — Phase 11's equivalent actions also ship without unit tests, verified via `npx tsc --noEmit` + manual QA in Plan 14-02). Document expected behaviors here for the executor's own verification during implementation (manual trace, not an automated test file): + - `updateLeadField(leadId, "name", "")` throws "Nome richiesto" (empty name rejected) + - `updateLeadField(leadId, "name", " Mario Rossi ")` trims to "Mario Rossi" and updates `leads.name` + - `updateLeadField(leadId, "status", "bogus")` throws "Stato non valido", no DB write + - `updateLeadField(leadId, "status", "won")` updates `leads.status` to "won" (valid LEAD_STAGES member) + - `updateLeadField(leadId, "email", "")` sets `leads.email` to `null` (empty clears nullable field) + - `updateLeadField(leadId, "email", "foo@bar.com")` sets `leads.email` to "foo@bar.com" + - `updateLeadField(leadId, "next_action", "Richiamare")` sets `leads.next_action` to "Richiamare" + - `addLeadTag(leadId, "VIP")` inserts a row into `tags` with `entity_type="leads", entity_id=leadId, name="VIP"`; calling it twice with the same value does not error (onConflictDoNothing) + - `removeLeadTag(leadId, "VIP")` deletes the matching `tags` row scoped to `entity_type="leads"` only (never touches `entity_type="services"` rows even if a service happens to share the same `entity_id` string) + - `renameLeadTag("VIP", "Priorita Alta")` updates ALL `tags` rows where `entity_type="leads"` and `name="VIP"` to `name="Priorita Alta"` (propagates across all leads sharing that tag) + - Every one of the above throws `"Non autorizzato"` immediately if `getServerSession(authOptions)` returns null/undefined, before any DB call + + +Append to src/app/admin/leads/actions.ts (after the existing `assignQuoteToLead` function, end of file): + +1. Add imports at the top of the file (merge with existing import block): +```typescript +import { tags } from "@/db/schema"; // add to existing `import { leads, quotes } from "@/db/schema";` -> becomes `import { leads, quotes, tags } from "@/db/schema";` +import { eq, and } from "drizzle-orm"; // existing line has `import { eq } from "drizzle-orm";` -> add `and` +import { LEAD_STAGES } from "@/lib/lead-validators"; // add to existing lead-validators import line +import { getServerSession } from "next-auth"; +import { authOptions } from "@/lib/auth"; +``` + +2. Add the `requireAdmin()` helper (exact copy from catalog/actions.ts): +```typescript +async function requireAdmin() { + const session = await getServerSession(authOptions); + if (!session) throw new Error("Non autorizzato"); +} +``` + +3. Add the field-update action with allowlist: +```typescript +// ── Inline-edit field update (Phase 14 database-view) ─────────────────────── + +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, empty clears + const s = value.trim(); + await db + .update(leads) + .set({ [fieldName]: s || null, updated_at: new Date() }) + .where(eq(leads.id, leadId)); + } + + revalidatePath("/admin/leads"); + revalidatePath(`/admin/leads/${leadId}`); +} +``` + +4. Add the polymorphic tag CRUD actions: +```typescript +// ── Lead tags (Phase 14, CRM-09) — polymorphic `tags` table, entity_type="leads" ─ + +const LEADS_TAG_ENTITY = "leads"; + +export async function addLeadTag(leadId: string, value: string) { + await requireAdmin(); + const trimmed = value.trim(); + if (trimmed.length === 0) throw new Error("Valore richiesto"); + + await db + .insert(tags) + .values({ entity_type: LEADS_TAG_ENTITY, entity_id: leadId, name: trimmed }) + .onConflictDoNothing(); + + revalidatePath("/admin/leads"); + revalidatePath(`/admin/leads/${leadId}`); +} + +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"); + revalidatePath(`/admin/leads/${leadId}`); +} + +export async function renameLeadTag(oldValue: string, newValue: string) { + await requireAdmin(); + const next = newValue.trim(); + if (next.length === 0) throw new Error("Nuovo nome richiesto"); + if (next === oldValue) return; + + await db + .update(tags) + .set({ name: next }) + .where(and(eq(tags.entity_type, LEADS_TAG_ENTITY), eq(tags.name, oldValue))); + + revalidatePath("/admin/leads"); +} +``` + +Note on `renameLeadTag`: no per-lead `revalidatePath(/admin/leads/${leadId})` call since the rename propagates across all leads sharing the tag (no single leadId to target) — `revalidatePath("/admin/leads")` covers the list; the detail page will pick up the change on next navigation/refresh (consistent with catalog's `renameServiceOption` which only revalidates `/admin/catalog`). + + + grep -c "export async function updateLeadField\|export async function addLeadTag\|export async function removeLeadTag\|export async function renameLeadTag" src/app/admin/leads/actions.ts && npx tsc --noEmit && npx eslint src/app/admin/leads/actions.ts + + + - `grep -c "export async function updateLeadField" src/app/admin/leads/actions.ts` returns 1 + - `grep -c "export async function addLeadTag" src/app/admin/leads/actions.ts` returns 1 + - `grep -c "export async function removeLeadTag" src/app/admin/leads/actions.ts` returns 1 + - `grep -c "export async function renameLeadTag" src/app/admin/leads/actions.ts` returns 1 + - `grep -c "await requireAdmin()" src/app/admin/leads/actions.ts` returns at least 4 (one per new action) + - `grep -c "LEAD_STAGES.includes" src/app/admin/leads/actions.ts` returns 1 + - `grep -c "LEADS_TAG_ENTITY = \"leads\"" src/app/admin/leads/actions.ts` returns 1 + - `grep -c "Non autorizzato" src/app/admin/leads/actions.ts` returns 1 (single shared requireAdmin helper) + - `npx tsc --noEmit` exits 0 + - `npx eslint src/app/admin/leads/actions.ts` exits 0 + + Four new server actions (updateLeadField, addLeadTag, removeLeadTag, renameLeadTag) exist in src/app/admin/leads/actions.ts, each calling requireAdmin() first, with status validated against LEAD_STAGES server-side and tags scoped to entity_type="leads". File type-checks and lints cleanly. Pre-existing actions (createLead, updateLead, deleteLead, logActivity, assignQuoteToLead) are unchanged. + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|--------------| +| Admin browser -> Server Action | Authenticated admin session calls `updateLeadField`/`addLeadTag`/`removeLeadTag`/`renameLeadTag` via Next.js Server Actions (directly invocable RPC endpoints if unguarded) | +| Server Action -> Postgres | Drizzle ORM parameterized queries against `leads` and `tags` tables | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Disposition | Mitigation Plan | +|-----------|----------|-----------|-------------|-----------------| +| T-14-01 | Elevation of Privilege | `updateLeadField`, `addLeadTag`, `removeLeadTag`, `renameLeadTag` (all new in this plan) | mitigate | Each action calls `await requireAdmin()` as its first statement — throws "Non autorizzato" if `getServerSession(authOptions)` returns null, before any DB read/write (Task 2) | +| T-14-02 | Tampering | `updateLeadField(leadId, "status", value)` | mitigate | Server-side allowlist check `LEAD_STAGES.includes(value)` rejects any status value outside the 6 fixed pipeline stages, independent of client UI restrictions (Task 2) | +| T-14-03 | Tampering | `updateLeadField` field allowlist | mitigate | `EDITABLE_FIELDS` const array gates which columns can be written; any other `fieldName` throws `Campo non editabile` before reaching the DB (Task 2) | +| T-14-04 | Information Disclosure / Tampering (cross-entity tag pollution) | `addLeadTag`/`removeLeadTag`/`renameLeadTag` | mitigate | All tag queries scoped with `eq(tags.entity_type, LEADS_TAG_ENTITY)` — cannot read/write/rename `entity_type="services"` tag rows even if `entity_id` strings collide (Task 2) | +| T-14-05 | Tampering (SQL injection) | All new DB queries | accept (no new risk) | Drizzle ORM parameterized queries used throughout; no raw SQL introduced | +| T-14-06 | Information Disclosure | `getLeadsWithTags()` exposes all lead PII columns (email, phone, company) to any caller | accept | Function is only invoked from `/admin/leads/page.tsx`, an Auth.js-session-protected route per CLAUDE.md constraint #4 (`/admin/*` -> Auth.js session) — no new exposure surface introduced by this plan | + + + +1. `npx tsc --noEmit` — exits 0, no new type errors in admin-queries.ts or leads/actions.ts +2. `npx eslint src/lib/admin-queries.ts src/app/admin/leads/actions.ts` — exits 0 +3. `grep -n "export async function getLeadsWithTags\|export async function getLeadFieldOptions" src/lib/admin-queries.ts` — both present +4. `grep -n "export async function updateLeadField\|export async function addLeadTag\|export async function removeLeadTag\|export async function renameLeadTag" src/app/admin/leads/actions.ts` — all four present +5. Manual trace through `` block in Task 2 against the written code — every documented case has a corresponding code branch + + + +- `src/lib/admin-queries.ts` exports `LeadWithTags`, `LeadFieldOptions`, `getLeadsWithTags()`, `getLeadFieldOptions()` — all type-checked, following the getAllServices/getCatalogFieldOptions left-join + Map-reduce pattern +- `src/app/admin/leads/actions.ts` exports `updateLeadField`, `addLeadTag`, `removeLeadTag`, `renameLeadTag` — all guarded by `requireAdmin()`, `status` validated against `LEAD_STAGES`, tags scoped to `entity_type="leads"` +- No schema migration created or required (confirmed: `tags.entity_type` is unconstrained text, already supports "leads") +- `npx tsc --noEmit` and `npx eslint` both exit 0 +- Pre-existing actions in `leads/actions.ts` (createLead, updateLead, deleteLead, logActivity, assignQuoteToLead) remain unchanged — no retrofit of requireAdmin() on those (out of scope per RESEARCH.md A5 / Open Question 4) + + + +After completion, create `.planning/phases/14-crm-attio-style-fix/14-01-SUMMARY.md` + diff --git a/.planning/phases/14-crm-attio-style-fix/14-02-PLAN.md b/.planning/phases/14-crm-attio-style-fix/14-02-PLAN.md new file mode 100644 index 0000000..00d0042 --- /dev/null +++ b/.planning/phases/14-crm-attio-style-fix/14-02-PLAN.md @@ -0,0 +1,880 @@ +--- +phase: 14-crm-attio-style-fix +plan: 02 +type: execute +wave: 2 +depends_on: ["14-01"] +files_modified: + - src/components/admin/leads/LeadTable.tsx + - src/app/admin/leads/LeadsSearch.tsx + - src/app/admin/leads/page.tsx + - src/components/admin/leads/LeadDetail.tsx +autonomous: true +requirements: [CRM-08, CRM-09] + +must_haves: + truths: + - "The /admin/leads table renders with raw markup (no shadcn Table/TableRow/TableCell wrapper), matching the ServiceTable.tsx visual pattern" + - "User can click any of name/email/phone/company/next_action cells, edit inline, press Enter, and the value persists via updateLeadField + router.refresh()" + - "User can open the Stato dropdown and pick one of the 6 fixed LEAD_STAGES values (no 'Crea' create-on-the-fly option appears for status)" + - "User can add/remove/rename tags on a lead via OptionMultiSelect, backed by addLeadTag/removeLeadTag/renameLeadTag" + - "User can type in the search box above the table and the visible rows filter instantly client-side (no network request) across name/email/company/status/tags" + - "Empty search results show 'Nessun lead trovato'" + artifacts: + - path: "src/components/admin/leads/LeadTable.tsx" + provides: "Raw-table LeadRow + LeadTable, inline-edit + status/tags dropdowns" + contains: "EditableCell" + - path: "src/app/admin/leads/LeadsSearch.tsx" + provides: "Client-side instant search wrapper for LeadTable" + contains: "useMemo" + - path: "src/app/admin/leads/page.tsx" + provides: "Server component fetching getLeadsWithTags + getLeadFieldOptions, rendering LeadsSearch" + contains: "getLeadsWithTags" + key_links: + - from: "src/app/admin/leads/page.tsx" + to: "src/lib/admin-queries.ts" + via: "getLeadsWithTags() + getLeadFieldOptions()" + pattern: "getLeadsWithTags" + - from: "src/components/admin/leads/LeadTable.tsx" + to: "src/app/admin/leads/actions.ts" + via: "updateLeadField / addLeadTag / removeLeadTag / renameLeadTag" + pattern: "updateLeadField\\(lead\\.id" + - from: "src/app/admin/leads/LeadsSearch.tsx" + to: "src/components/admin/leads/LeadTable.tsx" + via: "renders " + pattern: " +Rewrite `LeadTable.tsx` as a raw `
` database-view component (1:1 structural port of Phase 11's `ServiceTable.tsx`), wiring `EditableCell` for scalar fields, `OptionSelect` (fixed `LEAD_STAGES`, no create-on-the-fly) for `status`, and `OptionMultiSelect` for tags (CRM-09). Add a new `LeadsSearch.tsx` client-side instant-filter wrapper (mirrors `CatalogSearch.tsx`). Rewire `/admin/leads/page.tsx` to fetch via `getLeadsWithTags()`/`getLeadFieldOptions()` and render `LeadsSearch`. Additionally surface lead tags as an `OptionMultiSelect` in `LeadDetail.tsx`'s "Profilo" card (UI-SPEC.md Decision 4, low-cost addition). + +Purpose: Delivers the CRM-08 (inline editing, database-view table) and CRM-09 (lead tags) user-facing experience, completing the Attio/Pipedrive-style redesign of `/admin/leads`. + +Output: Rewritten `LeadTable.tsx`, new `LeadsSearch.tsx`, updated `page.tsx`, and `LeadDetail.tsx` with a tags `OptionMultiSelect`. + + + +@$HOME/.claude/get-shit-done/workflows/execute-plan.md +@$HOME/.claude/get-shit-done/templates/summary.md + + + +@.planning/ROADMAP.md +@.planning/REQUIREMENTS.md +@.planning/phases/14-crm-attio-style-fix/14-RESEARCH.md +@.planning/phases/14-crm-attio-style-fix/14-PATTERNS.md +@.planning/phases/14-crm-attio-style-fix/14-UI-SPEC.md + + + + + +From src/lib/admin-queries.ts (created in Plan 14-01): +```typescript +export type LeadWithTags = Lead & { tags: string[] }; +export type LeadFieldOptions = { status: string[]; tags: string[] }; + +export async function getLeadsWithTags(): Promise; +export async function getLeadFieldOptions(): Promise; +``` +`LeadWithTags` includes all `Lead` columns: `id, name, email, phone, company, status, last_contact_date, next_action, next_action_date, notes, created_at, updated_at` plus `tags: string[]`. + +`LeadFieldOptions.status` = `[...LEAD_STAGES]` (6 fixed values: `contacted | qualified | proposal_sent | negotiating | won | lost`). `LeadFieldOptions.tags` = distinct sorted lead tag names. + +From src/app/admin/leads/actions.ts (created in Plan 14-01): +```typescript +export async function updateLeadField(leadId: string, fieldName: EditableField, value: string): Promise; +// EditableField = "name" | "email" | "phone" | "company" | "status" | "next_action" + +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; +``` + +From src/components/admin/catalog/ServiceTable.tsx (Phase 11, shipped — exact structural analog, full file at lines 1-140 for ServiceRow, 264-316 for ServiceTable): +```tsx +"use client"; + +import { useState, useTransition } from "react"; +import { useRouter } from "next/navigation"; +import { EditableCell } from "@/components/ui/editable-cell"; +import { OptionSelect } from "@/components/ui/option-select"; +import { OptionMultiSelect } from "@/components/ui/option-multi-select"; + +function ServiceRow({ service, options }: { service: ServiceWithTags; options: CatalogFieldOptions }) { + 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 ( + <> + + + {/* ... more + {error && ( + + + + )} + + ); +} + +export function ServiceTable({ services, options }: { services: ServiceWithTags[]; options: CatalogFieldOptions }) { + return ( +
+
+
+ run(() => updateServiceField(service.id, "name", v))} /> + cells ... */} +
{error}
+ + + {COLUMN_HEADERS.map((header) => ( + + ))} + + + + {services.map((service) => )} + +
{header}
+ + {services.length === 0 &&
Nessun servizio trovato
} + + ); +} +``` + +From src/app/admin/catalog/CatalogSearch.tsx (Phase 11, shipped — exact structural analog for LeadsSearch.tsx, full file 46 lines): +```tsx +"use client"; + +import { useState, useMemo } from "react"; +import { Input } from "@/components/ui/input"; +import { Search } from "lucide-react"; +import { ServiceTable } from "@/components/admin/catalog/ServiceTable"; +import type { ServiceWithTags, CatalogFieldOptions } from "@/lib/admin-queries"; + +export function CatalogSearch({ services, options }: { services: ServiceWithTags[]; options: CatalogFieldOptions }) { + const [query, setQuery] = useState(""); + + const filtered = useMemo(() => { + const q = query.trim().toLowerCase(); + if (!q) return services; + return services.filter((s) => { + if (s.name.toLowerCase().includes(q)) return true; + if (s.category?.toLowerCase().includes(q)) return true; + if (s.tags.some((t) => t.toLowerCase().includes(q))) return true; + return false; + }); + }, [services, query]); + + return ( +
+
+ + setQuery(e.target.value)} className="pl-9 h-9" /> +
+ +
+ ); +} +``` + +From src/app/admin/catalog/page.tsx (Phase 11, shipped — exact structural analog for leads/page.tsx): +```tsx +import { getAllServices, getCatalogFieldOptions } from "@/lib/admin-queries"; +import { CatalogSearch } from "./CatalogSearch"; + +export const revalidate = 0; + +export default async function CatalogPage() { + const [services, options] = await Promise.all([getAllServices(), getCatalogFieldOptions()]); + return ( +
+
+

Catalogo Servizi

+
+ +
+ ); +} +``` + +From src/app/admin/leads/page.tsx (CURRENT — to be replaced): +```tsx +import { Suspense } from "react"; +import { getAllLeads } from "@/lib/lead-service"; +import { LeadTable } from "@/components/admin/leads/LeadTable"; +import { CreateLeadModal } from "@/components/admin/leads/LeadForm"; + +async function LeadsList() { + const leads = await getAllLeads(); + return ( +
+
+

Lead Pipeline

+ +
+ Loading...
}> + + + + ); +} + +export default function LeadsPage() { + return ; +} +``` +`CreateLeadModal` is unchanged (UI-SPEC.md Decision 3 — no quick-add row, CreateLeadModal remains the lead-creation entry point). Preserve it in the new page. + +From src/components/ui/editable-cell.tsx (Phase 11, shipped — props contract, reuse as-is, NO modifications): +```typescript +export interface EditableCellProps { + value: string | number | boolean; + type?: "text" | "number" | "textarea" | "toggle"; + onSave: (value: string) => void; + required?: boolean; + placeholder?: string; + disabled?: boolean; + formatDisplay?: (value: string) => string; +} +``` + +From src/components/ui/option-select.tsx (Phase 11, shipped — props contract, reuse as-is, NO modifications): +```typescript +export interface OptionSelectProps { + value: string | null; + options: string[]; + onChange: (value: string | null) => void; + onRename?: (oldValue: string, newValue: string) => void; + placeholder?: string; + disabled?: boolean; +} +``` +IMPORTANT: `onRename` is OPTIONAL. For the `status` field, do NOT pass `onRename` — this is what makes the dropdown closed/non-extensible per UI-SPEC.md Decision 1 (the "Crea «...»" create-on-the-fly button only appears when the typed query has no exact match AND... actually the create button always renders for non-exact-match queries regardless of onRename). To fully prevent arbitrary status creation in the UI, see the Task 1 action notes below for the exact mitigation (server-side validation in `updateLeadField` already rejects invalid values per Plan 14-01 — this is defense in depth at the UI layer). + +From src/components/ui/option-multi-select.tsx (Phase 11, shipped — props contract, reuse as-is, NO modifications): +```typescript +export interface OptionMultiSelectProps { + values: string[]; + options: string[]; + onAdd: (value: string) => void; + onRemove: (value: string) => void; + onRename?: (oldValue: string, newValue: string) => void; + placeholder?: string; + disabled?: boolean; +} +``` + +From src/components/admin/leads/LeadTable.tsx (CURRENT — to be replaced entirely): +```tsx +"use client"; + +import Link from "next/link"; +import { Lead } from "@/db/schema"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { LEAD_STAGES } from "@/lib/lead-validators"; + +const STAGE_COLOR: Record = { + contacted: "bg-blue-100 text-blue-800", + qualified: "bg-purple-100 text-purple-800", + proposal_sent: "bg-amber-100 text-amber-800", + negotiating: "bg-orange-100 text-orange-800", + won: "bg-green-100 text-green-800", + lost: "bg-red-100 text-red-800", +}; + +export function LeadTable({ leads }: { leads: Lead[] }) { /* shadcn Table-based, read-only */ } +``` +The new `LeadTable` signature changes to `{ leads, options }: { leads: LeadWithTags[]; options: LeadFieldOptions }`. `STAGE_COLOR` semantic map is PRESERVED per UI-SPEC.md Decision 1 (won=green, lost=red, etc.) — applied as a custom Badge override for the `status` cell, NOT via `getOptionColor()`'s hash palette. + +From src/components/admin/leads/LeadDetail.tsx (CURRENT, lines 53-90 — "Profilo" Card to extend with tags): +```tsx + + + Profilo + + +
+ + + {lead.status.replace(/_/g, " ")} + +
+ {/* email, telefono, ultimo contatto, prossima azione fields ... */} +
+
+``` +`LeadDetail` is currently a `"use client"` component receiving `{ lead, activities, reminders }: { lead: Lead; activities: Activity[]; reminders: Reminder[] }`. To add a tags `OptionMultiSelect`, the component needs: (a) `tags: string[]` and `tagOptions: string[]` passed as new props, (b) the `addLeadTag`/`removeLeadTag`/`renameLeadTag` actions + `useRouter`/`useTransition` wiring (same `run()` helper pattern as LeadTable). + + + + + + Task 1: Rewrite LeadTable.tsx as raw-table database-view (CRM-08, CRM-09) + src/components/admin/leads/LeadTable.tsx + + - src/components/admin/leads/LeadTable.tsx (current file — to be fully replaced) + - src/components/admin/catalog/ServiceTable.tsx (exact structural analog, full file) + - src/components/ui/editable-cell.tsx (props contract) + - src/components/ui/option-select.tsx (props contract, esp. lines 49-66 for create-on-the-fly logic) + - src/components/ui/option-multi-select.tsx (props contract) + - .planning/phases/14-crm-attio-style-fix/14-UI-SPEC.md (Table Layout — LeadTable section, Column Structure table, Design Decision 1) + + + Manual-trace behaviors (no automated test file exists for this UI component in this codebase's convention — verified via `npx tsc --noEmit`, `npx eslint`, and the Wave-checkpoint visual verification in Task 2's follow-up; this `` block documents the contract the implementation must satisfy): + - Table renders 8 columns in this exact order: Nome, Email, Telefono, Azienda, Stato, Prossima Azione, Tag, Azioni + - Each lead row renders as a raw `` with `border-b border-[#e5e7eb] hover:bg-[#f9f9f9] transition-colors duration-150` — no shadcn `` + - Nome cell: `EditableCell(type="text", required)`, bold (`font-medium text-[#1a1a1a]`), `onSave` calls `updateLeadField(lead.id, "name", v)` + - Email/Telefono/Azienda/Prossima Azione cells: `EditableCell(type="text")`, value falls back to `"—"` when null, `onSave` calls `updateLeadField(lead.id, , v)` + - Stato cell: `OptionSelect` with `value={lead.status}`, `options={options.status}` (the fixed 6-value LEAD_STAGES array), NO `onRename` prop passed, `onChange` calls `updateLeadField(lead.id, "status", v ?? "contacted")` — badge color overridden via STAGE_COLOR semantic map (not getOptionColor hash) + - Tag cell: `OptionMultiSelect` with `values={lead.tags}`, `options={options.tags}`, `onAdd`/`onRemove`/`onRename` wired to `addLeadTag`/`removeLeadTag`/`renameLeadTag` + - Azioni cell: `` + - Each row uses the `run()` transition+error pattern: on action error, an extra `` spanning all 8 columns shows `text-xs text-red-600` error message + - When `leads.length === 0`, render `
Nessun lead trovato
` + - Component is `"use client"`, uses `useRouter`/`useTransition` from the ServiceRow `run()` pattern +
+ +Replace the entire contents of `src/components/admin/leads/LeadTable.tsx` with a raw-table database-view component, structurally identical to `ServiceTable.tsx`: + +```tsx +"use client"; + +import Link from "next/link"; +import { useState, useTransition } from "react"; +import { useRouter } from "next/navigation"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { EditableCell } from "@/components/ui/editable-cell"; +import { OptionSelect } from "@/components/ui/option-select"; +import { OptionMultiSelect } from "@/components/ui/option-multi-select"; +import { + updateLeadField, + addLeadTag, + removeLeadTag, + renameLeadTag, +} from "@/app/admin/leads/actions"; +import type { LeadWithTags, LeadFieldOptions } from "@/lib/admin-queries"; +import { getOptionColor } from "@/components/ui/option-colors"; +import { cn } from "@/lib/utils"; + +const STAGE_COLOR: Record = { + contacted: "bg-blue-100 text-blue-800", + qualified: "bg-purple-100 text-purple-800", + proposal_sent: "bg-amber-100 text-amber-800", + negotiating: "bg-orange-100 text-orange-800", + won: "bg-green-100 text-green-800", + lost: "bg-red-100 text-red-800", +}; + +const COLUMN_HEADERS = [ + "Nome", + "Email", + "Telefono", + "Azienda", + "Stato", + "Prossima Azione", + "Tag", + "Azioni", +]; +const COL_COUNT = COLUMN_HEADERS.length; + +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 ( + <> + + + run(() => updateLeadField(lead.id, "name", v))} + /> + + + run(() => updateLeadField(lead.id, "email", v))} + /> + + + run(() => updateLeadField(lead.id, "phone", v))} + /> + + + run(() => updateLeadField(lead.id, "company", v))} + /> + + + run(() => updateLeadField(lead.id, "status", v ?? "contacted"))} + renderBadge={(opt) => ( + + {opt.replace(/_/g, " ")} + + )} + /> + + + run(() => updateLeadField(lead.id, "next_action", v))} + /> + + + run(() => addLeadTag(lead.id, v))} + onRemove={(v) => run(() => removeLeadTag(lead.id, v))} + onRename={(o, n) => run(() => renameLeadTag(o, n))} + /> + + + + + + {error && ( + + + {error} + + + )} + + ); +} + +export function LeadTable({ + leads, + options, +}: { + leads: LeadWithTags[]; + options: LeadFieldOptions; +}) { + return ( +
+
+ + + + {COLUMN_HEADERS.map((header) => ( + + ))} + + + + {leads.map((lead) => ( + + ))} + +
+ {header} +
+
+ + {leads.length === 0 && ( +
Nessun lead trovato
+ )} +
+ ); +} +``` + +CRITICAL CORRECTION — `OptionSelect` does NOT have a `renderBadge` prop (per the props contract in `` above: `value, options, onChange, onRename?, placeholder?, disabled?`). Adding a new prop would require modifying `option-select.tsx`, which is explicitly "reuse as-is, NO modifications" per RESEARCH.md/PATTERNS.md ("Treat any deviation from the ServiceTable.tsx/CatalogSearch.tsx/catalog-actions pattern as a red flag requiring justification"). + +Therefore, for the Stato cell, DO NOT attempt a `renderBadge` prop. Instead, use `OptionSelect` exactly as its existing contract allows — `value`, `options`, `onChange`, no `onRename` (this alone makes the dropdown's create-on-the-fly button still technically appear for non-exact queries, but `updateLeadField`'s server-side `LEAD_STAGES.includes()` check from Plan 14-01 rejects any value not in the 6 fixed stages, so an arbitrary "create" attempt fails server-side with "Stato non valido" and the error displays in the row's error ``). This is the defense-in-depth approach explicitly endorsed by RESEARCH.md Pitfall 2 ("Validate in updateLeadField's status branch... defense in depth, per Pattern 2 example") and UI-SPEC.md Decision 1 ("input validation... enforces that only LEAD_STAGES values are accepted server-side, regardless of client-side UI"). + +The badge color displayed by `OptionSelect` for the `status` value will use `getOptionColor()`'s hash-based palette (the component's built-in behavior, unchanged) — NOT the semantic `STAGE_COLOR` map. This is a deviation from UI-SPEC.md Decision 1's stated preference ("Semantic colors preserved... Badge colors for status still use STAGE_COLOR map"), but implementing semantic color override WITHOUT modifying `option-select.tsx` is not possible with the component's current props contract. + +RESOLUTION (Claude's discretion, consistent with `` — do not silently drop the UI-SPEC decision, implement it via the only available extension point): `option-select.tsx` accepts NO render-prop, but it DOES call `getOptionColor(value)` internally with no override mechanism. To honor UI-SPEC Decision 1 without modifying the shared component (which would affect `OptionSelect` usages elsewhere, e.g. catalog `categoria`/`fase`), do NOT use `OptionSelect` for the `status` column. Instead build a small inline closed-dropdown specifically for `status` in `LeadTable.tsx` itself (local to this file, zero shared-component changes): + +```tsx +function StatusCell({ + lead, + options, + run, +}: { + lead: LeadWithTags; + options: LeadFieldOptions; + run: (fn: () => Promise) => void; +}) { + const [isOpen, setIsOpen] = useState(false); + const containerRef = useRef(null); + + useEffect(() => { + function handleClickOutside(e: MouseEvent) { + if (containerRef.current && !containerRef.current.contains(e.target as Node)) { + setIsOpen(false); + } + } + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, []); + + return ( +
+
setIsOpen((v) => !v)} + className="flex items-center gap-1 px-2 py-1 rounded transition-colors duration-150 min-h-[28px] cursor-pointer hover:bg-[#f0f0f0]" + > + + {lead.status.replace(/_/g, " ")} + +
+ {isOpen && ( +
+
+ {options.status.map((stage) => ( + + ))} +
+
+ )} +
+ ); +} +``` + +Use `` in place of the `OptionSelect` Stato cell in `LeadRow`. This: +- Preserves semantic STAGE_COLOR (won=green, lost=red, etc.) per UI-SPEC.md Decision 1 +- Is a genuinely closed dropdown (only the 6 `options.status` values are clickable — no text input, no "Crea" button at all) — stronger guarantee than OptionSelect-without-onRename +- Requires zero changes to shared `option-select.tsx`/`option-multi-select.tsx`/`editable-cell.tsx` +- Matches "click-to-open dropdown, same interaction model as tags" (UI-SPEC Decision 1's stated goal for interaction consistency) + +Add imports needed for `StatusCell`: `useRef`, `useEffect` from `"react"` (alongside existing `useState`, `useTransition`), and `Check` from `"lucide-react"`. Remove the unused `getOptionColor` import for the status cell (still needed if used elsewhere — it is NOT needed in this file since OptionMultiSelect imports it internally; do not import `getOptionColor` in LeadTable.tsx at all). + +Final import list for LeadTable.tsx: +```tsx +"use client"; + +import Link from "next/link"; +import { useState, useRef, useEffect, useTransition } from "react"; +import { useRouter } from "next/navigation"; +import { Check } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { EditableCell } from "@/components/ui/editable-cell"; +import { OptionMultiSelect } from "@/components/ui/option-multi-select"; +import { + updateLeadField, + addLeadTag, + removeLeadTag, + renameLeadTag, +} from "@/app/admin/leads/actions"; +import type { LeadWithTags, LeadFieldOptions } from "@/lib/admin-queries"; +import { cn } from "@/lib/utils"; +``` +(`OptionSelect` import removed — not used; `StatusCell` is local.) +
+ + grep -c "from \"@/components/ui/table\"" src/components/admin/leads/LeadTable.tsx | grep -qx 0 && grep -c "function StatusCell" src/components/admin/leads/LeadTable.tsx | grep -qx 1 && npx tsc --noEmit && npx eslint src/components/admin/leads/LeadTable.tsx + + + - `grep -c "shadcn\|TableRow\|TableCell\|TableHead\b" src/components/admin/leads/LeadTable.tsx | grep -v '^0'` returns empty (no shadcn Table wrapper imports remain) — verify via `grep -c "from \"@/components/ui/table\"" src/components/admin/leads/LeadTable.tsx` returns 0 + - `grep -c "EditableCell" src/components/admin/leads/LeadTable.tsx` returns at least 5 (name, email, phone, company, next_action) + - `grep -c "OptionMultiSelect" src/components/admin/leads/LeadTable.tsx` returns at least 1 + - `grep -c "function StatusCell" src/components/admin/leads/LeadTable.tsx` returns 1 + - `grep -c "STAGE_COLOR" src/components/admin/leads/LeadTable.tsx` returns at least 1 + - `grep -c "updateLeadField(lead.id" src/components/admin/leads/LeadTable.tsx` returns at least 6 (name, email, phone, company, status, next_action) + - `grep -c "addLeadTag(lead.id\|removeLeadTag(lead.id\|renameLeadTag" src/components/admin/leads/LeadTable.tsx` returns at least 3 + - `grep -c "Nessun lead trovato" src/components/admin/leads/LeadTable.tsx` returns 1 + - `grep -c '"Nome", "Email", "Telefono", "Azienda", "Stato", "Prossima Azione", "Tag", "Azioni"' src/components/admin/leads/LeadTable.tsx` returns 0 (header array is multiline) — instead verify via `grep -c '"Dettagli"' src/components/admin/leads/LeadTable.tsx` returns 1 AND `grep -c '"Prossima Azione"' src/components/admin/leads/LeadTable.tsx` returns 1 + - `npx tsc --noEmit` exits 0 + - `npx eslint src/components/admin/leads/LeadTable.tsx` exits 0 + + LeadTable.tsx is fully rewritten as a raw `` database-view component matching ServiceTable.tsx's structure: 8 columns (Nome/Email/Telefono/Azienda/Stato/Prossima Azione/Tag/Azioni), EditableCell for scalar fields, a local StatusCell preserving semantic STAGE_COLOR for the closed-enum status dropdown, OptionMultiSelect for tags wired to addLeadTag/removeLeadTag/renameLeadTag, error-row display, and "Nessun lead trovato" empty state. Type-checks and lints cleanly. + + + + Task 2: Create LeadsSearch.tsx, rewire page.tsx, surface tags in LeadDetail.tsx + src/app/admin/leads/LeadsSearch.tsx, src/app/admin/leads/page.tsx, src/components/admin/leads/LeadDetail.tsx + + - src/app/admin/catalog/CatalogSearch.tsx (exact structural analog for LeadsSearch.tsx) + - src/app/admin/catalog/page.tsx (exact structural analog for page.tsx) + - src/app/admin/leads/page.tsx (current file — to be replaced) + - src/components/admin/leads/LeadDetail.tsx (current file — Profilo card to extend) + - src/app/admin/leads/[id]/page.tsx (detail page — to confirm what props LeadDetail currently receives and what needs to change to pass tags) + - src/lib/admin-queries.ts (LeadWithTags, LeadFieldOptions, getLeadsWithTags, getLeadFieldOptions — from Plan 14-01) + - src/app/admin/leads/actions.ts (addLeadTag, removeLeadTag, renameLeadTag — from Plan 14-01) + + +**Part A — Create `src/app/admin/leads/LeadsSearch.tsx`** (new file, mirrors `CatalogSearch.tsx`): + +```tsx +"use client"; + +import { useState, useMemo } from "react"; +import { Input } from "@/components/ui/input"; +import { Search } from "lucide-react"; +import { LeadTable } from "@/components/admin/leads/LeadTable"; +import type { LeadWithTags, LeadFieldOptions } from "@/lib/admin-queries"; + +export function LeadsSearch({ + leads, + options, +}: { + leads: LeadWithTags[]; + options: LeadFieldOptions; +}) { + const [query, setQuery] = useState(""); + + const filtered = useMemo(() => { + const q = query.trim().toLowerCase(); + if (!q) return leads; + return leads.filter((l) => { + if (l.name.toLowerCase().includes(q)) return true; + if (l.email?.toLowerCase().includes(q)) return true; + if (l.company?.toLowerCase().includes(q)) return true; + if (l.status.toLowerCase().includes(q)) return true; + if (l.tags.some((t) => t.toLowerCase().includes(q))) return true; + return false; + }); + }, [leads, query]); + + return ( +
+
+ + setQuery(e.target.value)} + className="pl-9 h-9" + /> +
+ +
+ ); +} +``` + +**Part B — Rewrite `src/app/admin/leads/page.tsx`**: + +```tsx +import { getLeadsWithTags, getLeadFieldOptions } from "@/lib/admin-queries"; +import { LeadsSearch } from "./LeadsSearch"; +import { CreateLeadModal } from "@/components/admin/leads/LeadForm"; + +export const revalidate = 0; + +export default async function LeadsPage() { + const [leads, options] = await Promise.all([ + getLeadsWithTags(), + getLeadFieldOptions(), + ]); + + return ( +
+
+

Lead Pipeline

+ +
+ + +
+ ); +} +``` + +Notes: `export const revalidate = 0` matches the catalog page's pattern (always-fresh data, no static caching — consistent with `router.refresh()` triggering full re-fetch after every inline edit). `Suspense`/`LeadsList` wrapper from the old page is removed — `getLeadsWithTags`/`getLeadFieldOptions` are awaited directly in the server component, matching catalog's pattern exactly. `CreateLeadModal` import path is unchanged (`@/components/admin/leads/LeadForm`). + +**Part C — Surface lead tags in `LeadDetail.tsx`'s "Profilo" card (UI-SPEC.md Decision 4)**: + +1. First, read `src/app/admin/leads/[id]/page.tsx` to find where `LeadDetail` is rendered and what data is currently fetched/passed. + +2. Update the detail page (`src/app/admin/leads/[id]/page.tsx`) to also fetch tags + tag options for this lead. Since `getLeadsWithTags()` returns ALL leads, for a single-lead detail page either: + - (a) Call `getLeadsWithTags()` and find the matching lead by id (simplest, reuses Plan 14-01's function, acceptable for current data volumes), OR + - (b) Add a small inline query using the existing `tags` table scoped by `entity_id = lead.id, entity_type = "leads"`. + + Use approach (a) for consistency and zero new query functions: import `getLeadsWithTags` and `getLeadFieldOptions` from `@/lib/admin-queries`, find `leads.find(l => l.id === params.id)` for the tags array, and pass `tagOptions={options.tags}` from `getLeadFieldOptions()`. + +3. In `LeadDetail.tsx`: + - Add `"use client"` directive (already present — confirm). + - Extend the component's props: `{ lead, activities, reminders, tags, tagOptions }: { lead: Lead; activities: Activity[]; reminders: Reminder[]; tags: string[]; tagOptions: string[] }`. + - Import `OptionMultiSelect` from `@/components/ui/option-multi-select`, and `addLeadTag`/`removeLeadTag`/`renameLeadTag` from `@/app/admin/leads/actions`. + - Add `useRouter`, `useTransition`, `useState` (for error) imports from `"react"`/`"next/navigation"` — replicate the same `run()` helper pattern as `LeadTable.tsx`'s `LeadRow`. + - In the "Profilo" Card's `CardContent`, after the "Stato" field block, add a new field block: + ```tsx +
+ + run(() => addLeadTag(lead.id, v))} + onRemove={(v) => run(() => removeLeadTag(lead.id, v))} + onRename={(o, n) => run(() => renameLeadTag(o, n))} + /> +
+ ``` + - Add the `run()` helper function at the top of the component body: + ```tsx + const router = useRouter(); + const [, startTransition] = useTransition(); + const [tagError, setTagError] = useState(null); + + function run(fn: () => Promise) { + setTagError(null); + startTransition(async () => { + try { + await fn(); + router.refresh(); + } catch (e) { + setTagError(e instanceof Error ? e.message : "Errore nel salvataggio"); + } + }); + } + ``` + - Display `tagError` below the OptionMultiSelect if set: `{tagError &&

{tagError}

}`. + +Do not modify any other part of `LeadDetail.tsx` (activity log, reminders card, notes card remain unchanged). +
+ + test -f src/app/admin/leads/LeadsSearch.tsx && grep -c "getLeadsWithTags\|getLeadFieldOptions" src/app/admin/leads/page.tsx | grep -qx 2 && npx tsc --noEmit + + + - `test -f src/app/admin/leads/LeadsSearch.tsx` exits 0 + - `grep -c "useMemo" src/app/admin/leads/LeadsSearch.tsx` returns 1 + - `grep -c "LeadTable leads={filtered}" src/app/admin/leads/LeadsSearch.tsx` returns 1 + - `grep -c "getLeadsWithTags\|getLeadFieldOptions" src/app/admin/leads/page.tsx` returns 2 + - `grep -c "CreateLeadModal" src/app/admin/leads/page.tsx` returns at least 1 + - `grep -c "export const revalidate = 0" src/app/admin/leads/page.tsx` returns 1 + - `grep -c "OptionMultiSelect" src/components/admin/leads/LeadDetail.tsx` returns at least 1 + - `grep -c "addLeadTag\|removeLeadTag\|renameLeadTag" src/components/admin/leads/LeadDetail.tsx` returns at least 3 + - `grep -c "getLeadsWithTags\|getLeadFieldOptions" src/app/admin/leads/\[id\]/page.tsx` returns at least 1 + - `npx tsc --noEmit` exits 0 + - `npx eslint src/app/admin/leads/LeadsSearch.tsx src/app/admin/leads/page.tsx src/components/admin/leads/LeadDetail.tsx "src/app/admin/leads/[id]/page.tsx"` exits 0 + + LeadsSearch.tsx exists and provides client-side instant filtering across name/email/company/status/tags. page.tsx fetches via getLeadsWithTags()/getLeadFieldOptions() and renders LeadsSearch + CreateLeadModal. LeadDetail.tsx's "Profilo" card includes an OptionMultiSelect for lead tags wired to addLeadTag/removeLeadTag/renameLeadTag, with error display. All files type-check and lint cleanly. +
+ + + + +## Trust Boundaries + +| Boundary | Description | +|----------|--------------| +| Admin browser -> LeadTable/LeadDetail client components | Renders data fetched server-side via `getLeadsWithTags()`; inline-edit/tag mutations call server actions from Plan 14-01 | +| LeadTable/LeadDetail -> Server Actions (Plan 14-01) | `updateLeadField`/`addLeadTag`/`removeLeadTag`/`renameLeadTag` — already guarded by `requireAdmin()` | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Disposition | Mitigation Plan | +|-----------|----------|-----------|-------------|-----------------| +| T-14-07 | Tampering | `StatusCell` in LeadTable.tsx — client-side closed dropdown for `status` | mitigate | UI only allows selecting from `options.status` (6 fixed `LEAD_STAGES` values, no free-text input); server-side `updateLeadField` (Plan 14-01) independently validates against `LEAD_STAGES` — defense in depth even if a future edit reintroduces free-text | +| T-14-08 | Information Disclosure | `LeadDetail.tsx` now renders `tags`/`tagOptions` — additional data surface on `/admin/leads/[id]` | accept | Route is Auth.js-session-protected (`/admin/*`); tags are non-sensitive labels, same trust level as existing `status`/`notes` already shown on this page | +| T-14-09 | Repudiation / error handling | `run()` helper in LeadTable/LeadDetail — errors from server actions surfaced to UI | mitigate | Errors caught via try/catch in `run()`, displayed as `text-xs text-red-600` without leaking stack traces or internal error details (only `e.message`, which are user-facing strings like "Stato non valido" set explicitly in Plan 14-01's actions) | +| T-14-10 | Elevation of Privilege | `LeadsSearch.tsx`/`LeadTable.tsx`/`LeadDetail.tsx` — new client components calling mutating server actions | accept (covered by Plan 14-01) | All mutating actions invoked from these components (`updateLeadField`, `addLeadTag`, etc.) already call `requireAdmin()` server-side (Plan 14-01, Task 2) — no new auth surface introduced here | + + + +1. `npx tsc --noEmit` — exits 0 +2. `npx eslint src/components/admin/leads/LeadTable.tsx src/app/admin/leads/LeadsSearch.tsx src/app/admin/leads/page.tsx src/components/admin/leads/LeadDetail.tsx "src/app/admin/leads/[id]/page.tsx"` — exits 0 +3. `grep -c "from \"@/components/ui/table\"" src/components/admin/leads/LeadTable.tsx` returns 0 (shadcn Table wrapper fully removed) +4. `grep -n "Nessun lead trovato" src/components/admin/leads/LeadTable.tsx` — present +5. Manual visual check (deferred to checkpoint in execute-phase): `/admin/leads` renders the 8-column raw table, status badges show semantic colors (won=green, lost=red), tags column shows OptionMultiSelect pills, search box filters instantly + + + +- `/admin/leads` renders a raw `
` database-view (no shadcn Table wrapper), 8 columns: Nome, Email, Telefono, Azienda, Stato, Prossima Azione, Tag, Azioni +- All scalar fields (name, email, phone, company, next_action) are inline-editable via `EditableCell`, persisted via `updateLeadField` +- `status` is editable via a closed dropdown (`StatusCell`) showing only the 6 `LEAD_STAGES` values with semantic `STAGE_COLOR` badges (won=green, lost=red, etc.) +- Lead tags are editable via `OptionMultiSelect`, backed by `addLeadTag`/`removeLeadTag`/`renameLeadTag` (CRM-09) +- `/admin/leads` has a client-side instant search box filtering name/email/company/status/tags with zero server round-trips +- `/admin/leads/[id]` (LeadDetail "Profilo" card) also shows and allows editing lead tags via the same `OptionMultiSelect` + actions +- `npx tsc --noEmit` and `npx eslint` both exit 0 + + + +After completion, create `.planning/phases/14-crm-attio-style-fix/14-02-SUMMARY.md` + diff --git a/.planning/phases/14-crm-attio-style-fix/14-03-PLAN.md b/.planning/phases/14-crm-attio-style-fix/14-03-PLAN.md new file mode 100644 index 0000000..65c64ae --- /dev/null +++ b/.planning/phases/14-crm-attio-style-fix/14-03-PLAN.md @@ -0,0 +1,480 @@ +--- +phase: 14-crm-attio-style-fix +plan: 03 +type: execute +wave: 1 +depends_on: [] +files_modified: + - src/components/admin/dashboard/FollowUpWidget.tsx + - src/components/admin/leads/LeadForm.tsx + - src/components/admin/leads/SendQuoteModal.tsx +autonomous: true +requirements: [CRM-10, CRM-11, CRM-12] + +must_haves: + truths: + - "FollowUpWidget shows Italian text only: heading 'Richiedi Follow-up', body 'X lead da contattare' (no English words, no pluralized 's')" + - "EditLeadModal and CreateLeadModal use useForm() (not useForm()) — form fields are type-checked against the Zod-inferred lead schema" + - "SendQuoteModal's onSubmit no longer contains an unreachable 'new' tab branch — the only navigation for the 'new' tab happens via the button's own onClick" + artifacts: + - path: "src/components/admin/dashboard/FollowUpWidget.tsx" + provides: "Italian-only follow-up widget copy" + contains: "Richiedi Follow-up" + - path: "src/components/admin/leads/LeadForm.tsx" + provides: "Typed react-hook-form for lead create/edit" + contains: "useForm" + - path: "src/components/admin/leads/SendQuoteModal.tsx" + provides: "onSubmit without dead 'new' tab branch" + contains: "onSubmit" + key_links: + - from: "src/components/admin/leads/LeadForm.tsx" + to: "src/lib/lead-validators.ts" + via: "import type { CreateLeadInput }" + pattern: "import.*CreateLeadInput.*lead-validators" +--- + + +Fix three residual CRM bugs identified in RESEARCH.md, each isolated to a single component file with no shared dependencies: + +1. **CRM-10**: `FollowUpWidget.tsx` has 6 hardcoded English strings — translate to Italian per UI-SPEC.md Decision 5 ("X lead da contattare", no pluralization since Italian "lead" is invariant). +2. **CRM-11**: `LeadForm.tsx`'s `CreateLeadModal` and `EditLeadModal` both call `useForm()`, defeating react-hook-form's type safety — replace with `useForm()` using the Zod-inferred type already exported from `lead-validators.ts`, removing the local type redeclaration and narrowing the one unavoidable `status` cast. +3. **CRM-12**: `SendQuoteModal.tsx`'s `onSubmit` contains a dead `if (tab === "new") {...}` branch that can never execute (the "new" tab's button has its own `onClick` handler and never triggers form submit) — remove the unreachable branch. + +Purpose: Closes out the three non-UI residual bugs from Phase 14's scope, completing CRM-10/11/12 alongside the Attio-style table redesign (CRM-08/09 in Plans 14-01/14-02). + +Output: Three independently-fixed components, each type-checked and linted. + + + +@$HOME/.claude/get-shit-done/workflows/execute-plan.md +@$HOME/.claude/get-shit-done/templates/summary.md + + + +@.planning/ROADMAP.md +@.planning/REQUIREMENTS.md +@.planning/phases/14-crm-attio-style-fix/14-RESEARCH.md +@.planning/phases/14-crm-attio-style-fix/14-PATTERNS.md +@.planning/phases/14-crm-attio-style-fix/14-UI-SPEC.md + + + + + +From src/lib/lead-validators.ts (existing, unchanged — CreateLeadInput already exported): +```typescript +export const LEAD_STAGES = ["contacted", "qualified", "proposal_sent", "negotiating", "won", "lost"] as const; + +export const createLeadSchema = z.object({ + name: z.string().min(1), + email: z.string().email().nullable().optional(), + phone: z.string().nullable().optional(), + company: z.string().nullable().optional(), + status: z.enum(LEAD_STAGES).optional(), + next_action: z.string().nullable().optional(), + // ... etc +}); + +export type CreateLeadInput = z.infer; +export type UpdateLeadInput = z.infer; +``` + +From src/components/admin/dashboard/FollowUpWidget.tsx (current, full 47 lines — to be translated): +```tsx +import Link from "next/link"; +import { getLeadsNeedingFollowUp } from "@/lib/lead-service"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; + +export async function FollowUpWidget() { + const leads = await getLeadsNeedingFollowUp(7); + const count = leads.length; + + return ( + + + Require Follow-up + + + {count > 0 ? ( + <> +

+ {count} lead{count !== 1 ? "s" : ""} to contact +

+
+ {leads.slice(0, 5).map((lead) => ( +
+ {lead.name} + +
+ ))} +
+ {count > 5 && ( + + View All ({count}) + + )} + + ) : ( +

All leads are up to date!

+ )} +
+
+ ); +} +``` + +From src/components/admin/leads/LeadForm.tsx (current, lines 1-40 and 202-220 — relevant excerpts): +```tsx +"use client"; + +import { useState } from "react"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { createLeadSchema } from "@/lib/lead-validators"; +import type { z } from "zod"; +import type { Lead } from "@/db/schema"; +// ... other imports + +type CreateLeadInput = z.infer; // <-- local redeclaration, line 36, to be removed + +export function CreateLeadModal() { + const form = useForm({ // <-- line ~42, to be fixed + resolver: zodResolver(createLeadSchema), + defaultValues: { name: "", email: "", phone: "", company: "", status: "contacted", next_action: "" }, + }); + // ... +} + +// ... EditLeadModal starts around line 202 +export function EditLeadModal({ lead, open, onOpenChange }: { lead: Lead; open: boolean; onOpenChange: (open: boolean) => void }) { + const form = useForm({ // <-- to be fixed + resolver: zodResolver(createLeadSchema), + defaultValues: { + name: lead.name, + email: lead.email ?? "", + phone: lead.phone ?? "", + company: lead.company ?? "", + status: lead.status as any, // <-- line 212, to be narrowed + next_action: lead.next_action ?? "", + }, + }); + // ... +} +``` + +From src/components/admin/leads/SendQuoteModal.tsx (current, lines 31-75 and 120-135 — relevant excerpts): +```tsx +const assignQuoteSchema = z.object({ + tab: z.enum(["existing", "new"]), + quote_id: z.string().optional(), + generate_new: z.boolean().optional(), // <-- dead field, line ~31 +}); + +// ... + +const form = useForm({ + resolver: zodResolver(assignQuoteSchema), + defaultValues: { tab: "existing", quote_id: "", generate_new: false }, // <-- line ~43 +}); + +// ... + +async function onSubmit(data: AssignQuoteInput) { + if (data.tab === "new") { + window.location.href = `/admin/quotes/new?lead_id=${lead.id}`; // <-- dead branch, lines 51-54, UNREACHABLE + return; + } + + setError(null); + startTransition(async () => { + try { + const result = await assignQuoteToLead({ lead_id: lead.id, quote_id: data.quote_id! }); + if (!result.success) { + setError(result.error ?? "Errore"); + return; + } + onOpenChange(false); + router.refresh(); + } catch (e) { + setError(e instanceof Error ? e.message : "Errore"); + } + }); +} + +// ... around line 126-133, the "new" tab's button: +{tab === "new" && ( + +)} +``` +
+ + + + + Task 1: Translate FollowUpWidget.tsx to Italian (CRM-10) + src/components/admin/dashboard/FollowUpWidget.tsx + + - src/components/admin/dashboard/FollowUpWidget.tsx (current, full 47-line file) + - .planning/phases/14-crm-attio-style-fix/14-UI-SPEC.md (Design Decision 5 — FollowUpWidget plural handling) + - .planning/phases/14-crm-attio-style-fix/14-RESEARCH.md (Pitfall 3 — FollowUpWidget i18n scope, confirms lead-service.ts has no English strings, fix is widget-only) + + +Translate all 6 hardcoded English strings in `FollowUpWidget.tsx` to Italian, per UI-SPEC.md Decision 5 (Italian "lead" is invariant — same word for singular/plural, so no conditional pluralization needed): + +1. `"Require Follow-up"` (CardTitle) → `"Richiedi Follow-up"` +2. `{count} lead{count !== 1 ? "s" : ""} to contact` → `{count} lead da contattare` (remove the `{count !== 1 ? "s" : ""}` ternary entirely — "lead" never takes an "s" in Italian) +3. `"Contact"` (Button label) → `"Contatta"` +4. `View All ({count})` → `Vedi Tutto ({count})` +5. `"All leads are up to date!"` (empty state) → `"Tutti i lead sono aggiornati!"` + +Apply these as direct string replacements — do not change any logic, imports, component structure, or the `getLeadsNeedingFollowUp(7)` call. Only the JSX text content changes. + +The full corrected file: + +```tsx +import Link from "next/link"; +import { getLeadsNeedingFollowUp } from "@/lib/lead-service"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; + +export async function FollowUpWidget() { + const leads = await getLeadsNeedingFollowUp(7); + const count = leads.length; + + return ( + + + Richiedi Follow-up + + + {count > 0 ? ( + <> +

+ {count} lead da contattare +

+
+ {leads.slice(0, 5).map((lead) => ( +
+ {lead.name} + +
+ ))} +
+ {count > 5 && ( + + Vedi Tutto ({count}) + + )} + + ) : ( +

Tutti i lead sono aggiornati!

+ )} +
+
+ ); +} +``` +
+ + grep -ic "Require Follow-up\|to contact\|\"Contact\"\|View All\|up to date" src/components/admin/dashboard/FollowUpWidget.tsx | grep -qx 0 && npx tsc --noEmit + + + - `grep -c "Richiedi Follow-up" src/components/admin/dashboard/FollowUpWidget.tsx` returns 1 + - `grep -c "lead da contattare" src/components/admin/dashboard/FollowUpWidget.tsx` returns 1 + - `grep -c "Contatta" src/components/admin/dashboard/FollowUpWidget.tsx` returns 1 + - `grep -c "Vedi Tutto" src/components/admin/dashboard/FollowUpWidget.tsx` returns 1 + - `grep -c "Tutti i lead sono aggiornati" src/components/admin/dashboard/FollowUpWidget.tsx` returns 1 + - `grep -ic "Require Follow-up\|to contact\|\"Contact\"\|View All\|up to date" src/components/admin/dashboard/FollowUpWidget.tsx` returns 0 (no English strings remain) + - `grep -c 'count !== 1 ? "s" : ""' src/components/admin/dashboard/FollowUpWidget.tsx` returns 0 (pluralization ternary removed) + - `npx tsc --noEmit` exits 0 + - `npx eslint src/components/admin/dashboard/FollowUpWidget.tsx` exits 0 + + FollowUpWidget.tsx contains only Italian text: "Richiedi Follow-up" heading, "{count} lead da contattare" body (no pluralization), "Contatta" button, "Vedi Tutto ({count})" link, "Tutti i lead sono aggiornati!" empty state. No English strings remain. Type-checks and lints cleanly. +
+ + + Task 2: Type LeadForm.tsx's useForm with CreateLeadInput (CRM-11) + src/components/admin/leads/LeadForm.tsx + + - src/components/admin/leads/LeadForm.tsx (current, full 366-line file — both CreateLeadModal lines 38-201 and EditLeadModal lines 202-365) + - src/lib/lead-validators.ts (full file — confirm CreateLeadInput export, createLeadSchema shape, LEAD_STAGES) + - .planning/phases/14-crm-attio-style-fix/14-RESEARCH.md (relevant Pattern for CRM-11 fix) + + + Manual-trace behaviors (verified via `npx tsc --noEmit` — TypeScript itself is the test for this type-safety fix): + - `CreateLeadModal`'s `useForm()` becomes `useForm()`, where `CreateLeadInput` is imported from `@/lib/lead-validators` (not locally redeclared) + - `EditLeadModal`'s `useForm()` becomes `useForm()` + - The local `type CreateLeadInput = z.infer;` declaration (current line 36) is REMOVED — the type now comes from the import + - `EditLeadModal`'s `defaultValues.status: lead.status as any` becomes `status: lead.status as CreateLeadInput["status"]` — the only remaining cast, narrowing `Lead.status` (a plain `string` column type from Drizzle's `$inferSelect`) to the Zod-enum-derived `CreateLeadInput["status"]` type (`"contacted" | "qualified" | "proposal_sent" | "negotiating" | "won" | "lost" | undefined`) + - All `form.register(...)`, `form.handleSubmit(...)`, `form.setValue(...)`, `form.watch(...)` calls (wherever they appear in both modals) continue to type-check against `CreateLeadInput` field names (`name`, `email`, `phone`, `company`, `status`, `next_action`, etc.) — if any currently-untyped `any`-cast field access breaks, the field name is wrong relative to `createLeadSchema` and must be corrected to match the schema (do not re-introduce `any` to suppress the error) + - If `z` import becomes unused after removing the local `type CreateLeadInput = z.infer<...>` line, remove the now-unused `import type { z } from "zod"` (or equivalent) — but only if `z` is not used elsewhere in the file (grep first) + + +1. Read the full `LeadForm.tsx` file to locate: + - The current local type declaration: `type CreateLeadInput = z.infer;` + - Both `useForm({...})` call sites (one in `CreateLeadModal`, one in `EditLeadModal`) + - The `status: lead.status as any` cast in `EditLeadModal`'s `defaultValues` + - The current import block (to determine whether `z` and `createLeadSchema` are already imported, and from where) + +2. Add `CreateLeadInput` to the import from `@/lib/lead-validators`: + ```tsx + import { createLeadSchema, type CreateLeadInput } from "@/lib/lead-validators"; + ``` + (adjust based on the exact current import statement — if `createLeadSchema` is already imported from `lead-validators`, just add `, type CreateLeadInput` to that same import line) + +3. Remove the local redeclaration: delete the line `type CreateLeadInput = z.infer;` entirely. + +4. If `import type { z } from "zod"` (or `import { z } from "zod"` used only for this) becomes unused after step 3, remove it. First grep the file for other `z.` usages — if any remain (e.g., inline schema validation elsewhere in the file), keep the import. + +5. In `CreateLeadModal`, change: + ```tsx + const form = useForm({ + ``` + to: + ```tsx + const form = useForm({ + ``` + +6. In `EditLeadModal`, change: + ```tsx + const form = useForm({ + ``` + to: + ```tsx + const form = useForm({ + ``` + +7. In `EditLeadModal`'s `defaultValues`, change: + ```tsx + status: lead.status as any, + ``` + to: + ```tsx + status: lead.status as CreateLeadInput["status"], + ``` + +8. Run `npx tsc --noEmit`. If new type errors surface inside either modal (e.g., a `form.watch("someField")` call referencing a field name not in `createLeadSchema`, or a `form.setValue(...)` with a mismatched type), fix the call site to match the actual `createLeadSchema` shape (from `lead-validators.ts`) — do NOT reintroduce `any` or `as any` to silence the error. The goal is genuine type safety, not error suppression. + + + grep -c "useForm\|as any" src/components/admin/leads/LeadForm.tsx | grep -qx 0 && npx tsc --noEmit && npx eslint src/components/admin/leads/LeadForm.tsx + + + - `grep -c "useForm" src/components/admin/leads/LeadForm.tsx` returns 0 + - `grep -c "useForm" src/components/admin/leads/LeadForm.tsx` returns 2 (CreateLeadModal + EditLeadModal) + - `grep -c "type CreateLeadInput = z.infer" src/components/admin/leads/LeadForm.tsx` returns 0 (local redeclaration removed) + - `grep -c "CreateLeadInput" src/components/admin/leads/LeadForm.tsx | grep -v '^0'` returns non-empty (import line + 2 useForm + 1 cast = at least 4 occurrences) + - `grep -c "status: lead.status as any" src/components/admin/leads/LeadForm.tsx` returns 0 + - `grep -c 'status: lead.status as CreateLeadInput\["status"\]' src/components/admin/leads/LeadForm.tsx` returns 1 + - `grep -c "as any" src/components/admin/leads/LeadForm.tsx` returns 0 (no remaining `any` casts in the file) + - `npx tsc --noEmit` exits 0 + - `npx eslint src/components/admin/leads/LeadForm.tsx` exits 0 + + Both CreateLeadModal and EditLeadModal use useForm() with CreateLeadInput imported from lead-validators.ts (no local redeclaration). The only remaining type assertion is the narrowing cast `lead.status as CreateLeadInput["status"]` in EditLeadModal's defaultValues. No `as any` remains anywhere in the file. Type-checks and lints cleanly. + + + + Task 3: Remove dead onSubmit branch in SendQuoteModal.tsx (CRM-12) + src/components/admin/leads/SendQuoteModal.tsx + + - src/components/admin/leads/SendQuoteModal.tsx (current, full 140-line file) + - .planning/phases/14-crm-attio-style-fix/14-RESEARCH.md (Pitfall 4 — SendQuoteModal dead branch analysis) + - .planning/phases/14-crm-attio-style-fix/14-PATTERNS.md (SendQuoteModal.tsx fixed pattern) + + + Manual-trace behaviors (verified via `npx tsc --noEmit` + `npx eslint` — no existing test file for this component; the fix is a dead-code removal, behavior for the "existing" tab path is unchanged): + - `onSubmit`'s body NO LONGER contains an `if (data.tab === "new") { window.location.href = ...; return; }` branch — this branch was unreachable because: (a) the "new" tab's submit button has its own `onClick={() => window.location.href = ...}` handler that navigates directly without calling `form.handleSubmit`, and (b) when `tab === "new"`, the only rendered button is that direct-navigation button — no `type="submit"` button exists for the "new" tab, so `onSubmit` (wired to `form.handleSubmit(onSubmit)`) can never fire with `data.tab === "new"` + - `onSubmit` now begins directly with the "existing" tab logic: `setError(null); startTransition(async () => { ... assignQuoteToLead({ lead_id: lead.id, quote_id: data.quote_id! }) ... })` + - The "new" tab's button (with its own `onClick` navigation) is UNCHANGED — it continues to work exactly as before + - `assignQuoteToLead({ lead_id, quote_id })` call signature is unchanged — only the dead branch above it is removed + + +1. Read the full `SendQuoteModal.tsx` file. + +2. Locate the `onSubmit` function. It currently begins with: + ```tsx + async function onSubmit(data: AssignQuoteInput) { + if (data.tab === "new") { + window.location.href = `/admin/quotes/new?lead_id=${lead.id}`; + return; + } + + setError(null); + startTransition(async () => { + // ... existing-tab logic + }); + } + ``` + +3. Remove the dead `if (data.tab === "new") { ... return; }` block entirely, so `onSubmit` begins directly with `setError(null);`: + ```tsx + async function onSubmit(data: AssignQuoteInput) { + setError(null); + startTransition(async () => { + // ... existing-tab logic (unchanged) + }); + } + ``` + +4. Confirm the "new" tab's button (its own `onClick={() => { window.location.href = ... }}`, separate from form submission) is untouched — it remains the sole navigation path for the "new" tab. + +5. Leave the `generate_new: z.boolean().optional()` field in `assignQuoteSchema` and its `defaultValues: { ..., generate_new: false }` entry AS-IS — RESEARCH.md flags this as optional cleanup, not required for CRM-12. Removing it would require also checking `AssignQuoteInput` usages elsewhere (out of scope for this isolated fix). Do not touch it. + +6. After the edit, grep the file for `data.tab` to confirm no other dead `tab === "new"` checks remain inside `onSubmit`. If the `tab` field is still referenced elsewhere (e.g., to conditionally render the "existing" vs "new" tab UI in JSX), that is correct and expected — only the `onSubmit` dead branch is removed. + + + grep -A2 "async function onSubmit" src/components/admin/leads/SendQuoteModal.tsx | grep -c 'data.tab === "new"' | grep -qx 0 && npx tsc --noEmit && npx eslint src/components/admin/leads/SendQuoteModal.tsx + + + - `grep -A2 "async function onSubmit" src/components/admin/leads/SendQuoteModal.tsx | grep -c 'data.tab === "new"'` returns 0 + - `grep -c "window.location.href" src/components/admin/leads/SendQuoteModal.tsx` returns 1 (only the "new" tab button's onClick remains, the onSubmit copy is removed) + - `grep -c "assignQuoteToLead" src/components/admin/leads/SendQuoteModal.tsx` returns at least 1 (existing-tab submit logic preserved) + - `grep -c "generate_new" src/components/admin/leads/SendQuoteModal.tsx` returns at least 1 (left untouched per action step 5) + - `npx tsc --noEmit` exits 0 + - `npx eslint src/components/admin/leads/SendQuoteModal.tsx` exits 0 + + SendQuoteModal.tsx's onSubmit no longer contains the unreachable `if (data.tab === "new") {...}` branch — onSubmit now starts directly with `setError(null)` and the existing-tab assignQuoteToLead logic. The "new" tab's own onClick-based navigation button is unchanged. generate_new field left as-is (optional cleanup, out of scope). Type-checks and lints cleanly. + + +
+ + +## Trust Boundaries + +| Boundary | Description | +|----------|--------------| +| Admin browser -> FollowUpWidget/LeadForm/SendQuoteModal | Server-rendered (FollowUpWidget) and client components (LeadForm, SendQuoteModal); no new data flows introduced | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Disposition | Mitigation Plan | +|-----------|----------|-----------|-------------|-----------------| +| T-14-11 | Tampering | LeadForm.tsx — `useForm()` to `useForm()` | mitigate | Stronger compile-time typing reduces risk of malformed form payloads reaching `createLead`/`updateLead` server actions; Zod validation at the action boundary (pre-existing, unchanged) remains the actual runtime enforcement — this fix is a defense-in-depth/DX improvement, not a new security control | +| T-14-12 | Tampering | SendQuoteModal.tsx — dead `onSubmit` branch removal | accept | Removing unreachable code has no security effect; `assignQuoteToLead` (existing-tab path) and its `requireAdmin`-equivalent guards (if any, pre-existing) are unchanged | +| T-14-13 | Information Disclosure | FollowUpWidget.tsx — string translation only | accept | No data exposure change; only display copy is translated. `getLeadsNeedingFollowUp(7)` query and its session-protected `/admin` route context are unchanged | + + + +1. `npx tsc --noEmit` — exits 0 across all three modified files +2. `npx eslint src/components/admin/dashboard/FollowUpWidget.tsx src/components/admin/leads/LeadForm.tsx src/components/admin/leads/SendQuoteModal.tsx` — exits 0 +3. `grep -ic "Require Follow-up\|to contact\|View All\|up to date" src/components/admin/dashboard/FollowUpWidget.tsx` returns 0 (no English strings) +4. `grep -c "useForm\|as any" src/components/admin/leads/LeadForm.tsx` returns 0 +5. `grep -A2 "async function onSubmit" src/components/admin/leads/SendQuoteModal.tsx | grep -c 'data.tab === "new"'` returns 0 + + + +- FollowUpWidget.tsx displays only Italian text ("Richiedi Follow-up", "{count} lead da contattare", "Contatta", "Vedi Tutto ({count})", "Tutti i lead sono aggiornati!") — CRM-10 satisfied +- LeadForm.tsx's CreateLeadModal and EditLeadModal both use `useForm()` with `CreateLeadInput` imported from `lead-validators.ts`; no `as any` remains — CRM-11 satisfied +- SendQuoteModal.tsx's `onSubmit` no longer contains the unreachable `tab === "new"` branch; existing-tab submission and new-tab button navigation both continue to work — CRM-12 satisfied +- `npx tsc --noEmit` and `npx eslint` both exit 0 across all three files + + + +After completion, create `.planning/phases/14-crm-attio-style-fix/14-03-SUMMARY.md` + diff --git a/.planning/phases/14-crm-attio-style-fix/14-PATTERNS.md b/.planning/phases/14-crm-attio-style-fix/14-PATTERNS.md new file mode 100644 index 0000000..71a21db --- /dev/null +++ b/.planning/phases/14-crm-attio-style-fix/14-PATTERNS.md @@ -0,0 +1,870 @@ +# Phase 14: CRM Attio-style & Fix - Pattern Map + +**Mapped:** 2026-06-14 +**Files analyzed:** 12 (8 to create/modify, 4 to reuse as-is) +**Analogs found:** 8 / 8 (100% coverage from Phase 11) + +--- + +## File Classification + +| New/Modified File | Role | Data Flow | Closest Analog | Match Quality | +|-------------------|------|-----------|----------------|---------------| +| `src/components/admin/leads/LeadTable.tsx` | component | CRUD (inline-edit + render) | `src/components/admin/catalog/ServiceTable.tsx` | exact | +| `src/app/admin/leads/LeadsSearch.tsx` | component | request-response (client-side filter) | `src/app/admin/catalog/CatalogSearch.tsx` | exact | +| `src/app/admin/leads/actions.ts` | server action | CRUD (field update, tag add/remove/rename) | `src/app/admin/catalog/actions.ts` | exact | +| `src/lib/admin-queries.ts` (extend) | query service | CRUD (entity + tags join) | `src/lib/admin-queries.ts` (getAllServices pattern) | exact | +| `src/components/admin/dashboard/FollowUpWidget.tsx` | component | request-response (i18n fix) | self | N/A (fix-only) | +| `src/components/admin/leads/LeadForm.tsx` | component | request-response (type-safety fix) | self | N/A (fix-only) | +| `src/components/admin/leads/SendQuoteModal.tsx` | component | request-response (dead-code fix) | self | N/A (fix-only) | +| `src/components/ui/editable-cell.tsx` | component (reuse) | N/A | Phase 11 shipped | exact | +| `src/components/ui/option-select.tsx` | component (reuse) | N/A | Phase 11 shipped | exact | +| `src/components/ui/option-multi-select.tsx` | component (reuse) | N/A | Phase 11 shipped | exact | +| `src/components/ui/option-colors.ts` | utility (reuse) | N/A | Phase 11 shipped | exact | + +--- + +## Pattern Assignments + +### `src/components/admin/leads/LeadTable.tsx` (component, CRUD — rewrite) + +**Analog:** `src/components/admin/catalog/ServiceTable.tsx` (Phase 11, shipped) + +**Role:** Client component (`"use client"`), renders table rows with inline-editable cells and dropdown selects. Wires cell edits to server actions via `useTransition()` + `router.refresh()`. + +**Imports pattern** (lines 1–16): +```typescript +"use client"; + +import { useState, useTransition } from "react"; +import { useRouter } from "next/navigation"; +import { Input } from "@/components/ui/input"; +import { EditableCell } from "@/components/ui/editable-cell"; +import { OptionSelect } from "@/components/ui/option-select"; +import { OptionMultiSelect } from "@/components/ui/option-multi-select"; +import { + updateLeadField, + addLeadTag, + removeLeadTag, + renameLeadTag, +} from "@/app/admin/leads/actions"; +import type { LeadWithTags, LeadFieldOptions } from "@/lib/admin-queries"; +``` + +**Core row pattern** (mirrored from ServiceTable lines 35–140): +```typescript +function LeadRow({ + lead, + options, +}: { + lead: LeadWithTags; + options: LeadFieldOptions; +}) { + const router = useRouter(); + const [, startTransition] = useTransition(); + const [error, setError] = useState(null); + + function run(fn: () => Promise) { + setError(null); + startTransition(async () => { + try { + await fn(); + router.refresh(); + } catch (e) { + setError(e instanceof Error ? e.message : "Errore nel salvataggio"); + } + }); + } + + return ( + <> +
+ {/* Name — EditableCell, required, linked to detail */} + + + {/* Email — EditableCell, optional */} + + + {/* Phone — EditableCell, optional */} + + + {/* Company — EditableCell, optional */} + + + {/* Status — OptionSelect, fixed enum (LEAD_STAGES), no create-on-the-fly */} + + + {/* Next Action — EditableCell, optional */} + + + {/* Tags — OptionMultiSelect, create-on-the-fly enabled */} + + + {/* Actions — Link to detail page */} + + + {error && ( + + + + )} + + ); +} + +export function LeadTable({ + leads, + options, +}: { + leads: LeadWithTags[]; + options: LeadFieldOptions; +}) { + const COLUMN_HEADERS = [ + "Nome", + "Email", + "Telefono", + "Azienda", + "Stato", + "Prossima Azione", + "Tag", + "Azioni", + ]; + const COL_COUNT = COLUMN_HEADERS.length; + + return ( +
+
+
+ run(() => updateLeadField(lead.id, "name", v))} + /> + + run(() => updateLeadField(lead.id, "email", v))} + /> + + run(() => updateLeadField(lead.id, "phone", v))} + /> + + run(() => updateLeadField(lead.id, "company", v))} + /> + + run(() => updateLeadField(lead.id, "status", v ?? "contacted"))} + /> + + run(() => updateLeadField(lead.id, "next_action", v))} + /> + + run(() => addLeadTag(lead.id, v))} + onRemove={(v) => run(() => removeLeadTag(lead.id, v))} + onRename={(o, n) => run(() => renameLeadTag(o, n))} + /> + + +
+ {error} +
+ + + {COLUMN_HEADERS.map((header) => ( + + ))} + + + + {leads.map((lead) => ( + + ))} + +
+ {header} +
+ + + {leads.length === 0 && ( +
Nessun lead trovato
+ )} + + ); +} +``` + +**Key differences from ServiceTable:** +- LeadTable has no `QuickAddRow` (out of scope per CRM-08..12) +- LeadTable has no inactive-services grouping section +- LeadTable's `status` field uses fixed enum (no create-on-the-fly); CatalogSearch's `categoria`/`fase` are open-ended + +--- + +### `src/app/admin/leads/LeadsSearch.tsx` (component, request-response — new) + +**Analog:** `src/app/admin/catalog/CatalogSearch.tsx` (Phase 11, shipped, lines 1–46) + +**Role:** Client component (`"use client"`), renders search input + calls `LeadTable` with filtered results. + +**Imports and structure** (lines 1–46): +```typescript +"use client"; + +import { useState, useMemo } from "react"; +import { Input } from "@/components/ui/input"; +import { Search } from "lucide-react"; +import { LeadTable } from "@/components/admin/leads/LeadTable"; +import type { LeadWithTags, LeadFieldOptions } from "@/lib/admin-queries"; + +export function LeadsSearch({ + leads, + options, +}: { + leads: LeadWithTags[]; + options: LeadFieldOptions; +}) { + const [query, setQuery] = useState(""); + + const filtered = useMemo(() => { + const q = query.trim().toLowerCase(); + if (!q) return leads; + return leads.filter((l) => + l.name.toLowerCase().includes(q) || + l.email?.toLowerCase().includes(q) || + l.company?.toLowerCase().includes(q) || + l.status.toLowerCase().includes(q) || + l.tags.some((t) => t.toLowerCase().includes(q)) + ); + }, [leads, query]); + + return ( +
+
+ + setQuery(e.target.value)} + className="pl-9 h-9" + /> +
+ +
+ ); +} +``` + +--- + +### `src/app/admin/leads/actions.ts` (server action, CRUD — extend) + +**Analog:** `src/app/admin/catalog/actions.ts` (Phase 11, shipped) + +**Role:** Server actions (`"use server"`), field-update + tag CRUD with `requireAdmin()` auth guard and `revalidatePath()` side effects. + +**Auth guard pattern** (catalog/actions.ts lines 18–21): +```typescript +"use server"; + +import { getServerSession } from "next-auth"; +import { authOptions } from "@/lib/auth"; + +async function requireAdmin() { + const session = await getServerSession(authOptions); + if (!session) throw new Error("Non autorizzato"); +} +``` + +**Field update pattern with allowlist** (catalog/actions.ts lines 71–116): +```typescript +const EDITABLE_FIELDS = ["name", "email", "phone", "company", "status", "next_action"] as const; +type EditableField = (typeof EDITABLE_FIELDS)[number]; + +export async function updateLeadField( + leadId: string, + fieldName: EditableField, + value: string +) { + await requireAdmin(); + + if (!EDITABLE_FIELDS.includes(fieldName)) { + throw new Error(`Campo non editabile: ${fieldName}`); + } + + if (fieldName === "name") { + const s = String(value).trim(); + if (s.length === 0) throw new Error("Nome richiesto"); + await db.update(leads).set({ name: s, updated_at: new Date() }).where(eq(leads.id, leadId)); + } else if (fieldName === "status") { + // Validate against LEAD_STAGES enum (defense-in-depth, even if UI restricts it) + if (!LEAD_STAGES.includes(value as any)) { + throw new Error("Stato non valido"); + } + await db.update(leads).set({ status: value, updated_at: new Date() }).where(eq(leads.id, leadId)); + } else if (fieldName === "email" || fieldName === "phone" || fieldName === "company" || fieldName === "next_action") { + // Nullable text fields — allow empty to clear + const s = String(value).trim(); + await db + .update(leads) + .set({ [fieldName]: s || null, updated_at: new Date() }) + .where(eq(leads.id, leadId)); + } + + revalidatePath("/admin/leads"); + revalidatePath(`/admin/leads/${leadId}`); +} +``` + +**Tag CRUD pattern** (catalog/actions.ts lines 131–199, ported to leads): +```typescript +const LEADS_TAG_ENTITY = "leads"; + +export async function addLeadTag(leadId: string, value: string) { + await requireAdmin(); + const trimmed = value.trim(); + if (trimmed.length === 0) throw new Error("Valore richiesto"); + + await db + .insert(tags) + .values({ entity_type: LEADS_TAG_ENTITY, entity_id: leadId, name: trimmed }) + .onConflictDoNothing(); + + revalidatePath("/admin/leads"); +} + +export async function removeLeadTag(leadId: string, value: string) { + await requireAdmin(); + + await db + .delete(tags) + .where( + and( + eq(tags.entity_type, LEADS_TAG_ENTITY), + eq(tags.entity_id, leadId), + eq(tags.name, value) + ) + ); + + revalidatePath("/admin/leads"); +} + +export async function renameLeadTag(oldValue: string, newValue: string) { + await requireAdmin(); + const next = newValue.trim(); + if (next.length === 0) throw new Error("Nuovo nome richiesto"); + if (next === oldValue) return; + + await db + .update(tags) + .set({ name: next }) + .where( + and( + eq(tags.entity_type, LEADS_TAG_ENTITY), + eq(tags.name, oldValue) + ) + ); + + revalidatePath("/admin/leads"); +} +``` + +**Key imports** (catalog/actions.ts lines 1–9): +```typescript +import { db } from "@/db"; +import { leads, tags } from "@/db/schema"; +import { revalidatePath } from "next/cache"; +import { eq, and } from "drizzle-orm"; +import { LEAD_STAGES } from "@/lib/lead-validators"; +``` + +--- + +### `src/lib/admin-queries.ts` (extend — query service, CRUD) + +**Analog:** `src/lib/admin-queries.ts` getAllServices + getCatalogFieldOptions (Phase 11, lines 360–448) + +**Role:** Query layer, provides `LeadWithTags` type and two functions: `getLeadsWithTags()` (left-join + map-reduce), `getLeadFieldOptions()` (distinct values). + +**LeadWithTags type and join pattern** (mirrored from getAllServices lines 360–409): +```typescript +const LEADS_TAG_ENTITY = "leads"; + +export type LeadWithTags = Lead & { tags: string[] }; + +export async function getLeadsWithTags(): Promise { + const rows = await db + .select({ + // All leads columns + id: leads.id, + name: leads.name, + email: leads.email, + phone: leads.phone, + company: leads.company, + status: leads.status, + last_contact_date: leads.last_contact_date, + next_action: leads.next_action, + next_action_date: leads.next_action_date, + notes: leads.notes, + created_at: leads.created_at, + updated_at: leads.updated_at, + // Tag name for join + tag_name: tags.name, + }) + .from(leads) + .leftJoin( + tags, + and( + eq(tags.entity_id, leads.id), + eq(tags.entity_type, LEADS_TAG_ENTITY) + ) + ) + .orderBy(asc(leads.updated_at), asc(tags.name)); + + const leadMap = new Map(); + for (const row of rows) { + const { tag_name, ...leadFields } = row; + if (!leadMap.has(row.id)) leadMap.set(row.id, { ...leadFields, tags: [] }); + if (tag_name) leadMap.get(row.id)!.tags.push(tag_name); + } + return Array.from(leadMap.values()); +} +``` + +**Field options pattern** (mirrored from getCatalogFieldOptions lines 414–448): +```typescript +export type LeadFieldOptions = { status: string[]; tags: string[] }; + +export async function getLeadFieldOptions(): Promise { + const tagRows = await db + .selectDistinct({ name: tags.name }) + .from(tags) + .where(eq(tags.entity_type, LEADS_TAG_ENTITY)); + + return { + status: [...LEAD_STAGES], // Fixed enum, not dynamic + tags: tagRows + .map((r) => r.name) + .sort((a, b) => a.localeCompare(b, "it")), + }; +} +``` + +**Imports** (add to existing admin-queries.ts top): +```typescript +import { and, asc } from "drizzle-orm"; +import type { Lead } from "@/db/schema"; +import { LEAD_STAGES } from "@/lib/lead-validators"; +``` + +--- + +### `src/components/admin/dashboard/FollowUpWidget.tsx` (component, request-response — fix CRM-10) + +**Current analog:** self (existing file, lines 1–47) + +**Issue:** 6 hardcoded English strings need Italian translation. + +**Strings to fix:** +1. Line 15: `"Require Follow-up"` → `"Richiedi Follow-up"` +2. Line 22: `"lead"` / `"leads"` → Always `"lead"` in Italian (loanword, plural also "lead") +3. Line 23: `"to contact"` → `"da contattare"` +4. Line 30: `"Contact"` → `"Contatta"` +5. Line 37: `"View All"` → `"Vedi Tutto"` +6. Line 42: `"All leads are up to date!"` → `"Tutti i lead sono aggiornati!"` + +**Fixed version:** +```typescript +export async function FollowUpWidget() { + const leadsNeedingFollowUp = await getLeadsNeedingFollowUp(7); + + return ( + + + + + Richiedi Follow-up + + + + {leadsNeedingFollowUp.length > 0 ? ( + <> +

+ {leadsNeedingFollowUp.length} lead da contattare +

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

Tutti i lead sono aggiornati!

+ )} +
+
+ ); +} +``` + +--- + +### `src/components/admin/leads/LeadForm.tsx` (component, request-response — fix CRM-11) + +**Current analog:** self (existing file, lines 1–100+) + +**Issue:** `useForm()` defeats Zod type inference. Fix: use `CreateLeadInput` type exported from `lead-validators.ts`. + +**Current pattern (lines 41–51, problematic):** +```typescript +const form = useForm({ + resolver: zodResolver(createLeadSchema), + defaultValues: { + name: "", + email: "", + phone: "", + company: "", + status: "contacted", + notes: "", + }, +}); +``` + +**Fixed pattern:** +```typescript +import { CreateLeadInput } from "@/lib/lead-validators"; + +const form = useForm({ + resolver: zodResolver(createLeadSchema), + defaultValues: { + name: "", + email: "", + phone: "", + company: "", + status: "contacted", + notes: "", + }, +}); + +async function onSubmit(data: CreateLeadInput) { + // No `data: any` — type is now inferred + setLoading(true); + try { + const result = await createLead(data); + // ... + } finally { + setLoading(false); + } +} +``` + +**Key change:** Remove all `as any` casts and replace `useForm()` with `useForm()`. The one unavoidable cast at the schema boundary (`lead.status as CreateLeadInput["status"]`) is acceptable because it's narrow and type-checked at compile time. + +--- + +### `src/components/admin/leads/SendQuoteModal.tsx` (component, request-response — fix CRM-12) + +**Current analog:** self (existing file, lines 1–110+) + +**Issue:** Dead code — `if (tab === "new")` branch inside `onSubmit` (form handler) can never execute because the "new" tab has its own standalone button that redirects without submitting the form. + +**Current problematic pattern (lines 48–71):** +```typescript +async function onSubmit(data: any) { + setLoading(true); + try { + if (tab === "new") { + // This branch is unreachable — "new" tab's button does window.location.href directly + window.location.href = `/admin/quotes/new?lead_id=${leadId}`; + return; + } + + const result = await assignQuoteToLead({ + lead_id: leadId, + quote_token: data.quote_token, + generate_new: false, + }); + // ... + } finally { + setLoading(false); + } +} +``` + +**Fixed pattern — remove the dead branch:** +```typescript +async function onSubmit(data: any) { + setLoading(true); + try { + // Only handle "existing quote" path (tab === "existing") + // The "new" tab's button is self-contained and redirects via onClick + const result = await assignQuoteToLead({ + lead_id: leadId, + quote_token: data.quote_token, + generate_new: false, + }); + if (result.success) { + setOpen(false); + form.reset(); + } else { + console.error("Error assigning quote:", result.error); + } + } finally { + setLoading(false); + } +} +``` + +**Additional cleanup:** The `generate_new` field in `defaultValues` and schema is also dead code (always `false`). Consider either removing it or documenting that it's intentionally unused. + +--- + +## Reused Components (No Changes) + +### `src/components/ui/editable-cell.tsx` (Phase 11, shipped) + +**Analog:** self (read-only, reuse as-is) + +**Pattern summary** (lines 8–26): +- `type?: "text" | "number" | "textarea" | "toggle"` +- `onSave: (value: string) => void` callback +- `required?: boolean` validation (client-side; server validates separately) +- `formatDisplay?: (value: string) => string` for display formatting (e.g., price) + +Used in LeadTable for: `name`, `email`, `phone`, `company`, `next_action`. + +--- + +### `src/components/ui/option-select.tsx` (Phase 11, shipped) + +**Analog:** self (read-only, reuse as-is) + +**Pattern summary** (lines 10–26): +- `value: string | null`, `options: string[]` +- `onChange: (value: string | null) => void` +- `onRename?: (oldValue: string, newValue: string) => void` for inline renaming +- `placeholder?: string` default `"Cerca o crea..."` + +Used in LeadTable for: `status` field (with fixed `options={LEAD_STAGES}`, no `onRename`). + +--- + +### `src/components/ui/option-multi-select.tsx` (Phase 11, shipped) + +**Analog:** self (read-only, reuse as-is) + +**Pattern summary** (lines 10–27): +- `values: string[]`, `options: string[]` (union of pool + current values) +- `onAdd: (value: string) => void`, `onRemove: (value: string) => void` +- `onRename?: (oldValue: string, newValue: string) => void` for inline renaming +- `placeholder?: string` default `"Cerca o crea..."` + +Used in LeadTable for: `tags` field (CRM-09). + +--- + +### `src/components/ui/option-colors.ts` (Phase 11, shipped) + +**Analog:** self (read-only, reuse as-is) + +**Pattern summary** (lines 1–21): +- `getOptionColor(value: string): string` — deterministic hash → 7-color pastel palette +- Automatically used by `OptionSelect` and `OptionMultiSelect` for badge coloring + +Note: LeadTable's `status` field may override colors with `STAGE_COLOR` map (semantic green/red) per Pitfall 6 of RESEARCH.md. Decision deferred to planner. + +--- + +## Shared Patterns + +### Authentication Guard (Server Actions) + +**Source:** `src/app/admin/catalog/actions.ts` lines 18–21, imported from `@/lib/auth` + +**Apply to:** All new lead server actions (`updateLeadField`, `addLeadTag`, `removeLeadTag`, `renameLeadTag`) + +```typescript +async function requireAdmin() { + const session = await getServerSession(authOptions); + if (!session) throw new Error("Non autorizzato"); +} +``` + +Call at the top of every mutating action: +```typescript +export async function updateLeadField(...) { + await requireAdmin(); + // ... rest of logic +} +``` + +--- + +### Error Handling (Server Actions & Components) + +**Source:** `src/app/admin/catalog/actions.ts` + `src/components/admin/catalog/ServiceTable.tsx` + +**Apply to:** All server actions (validation + throw), all LeadTable rows (error display) + +**Server action pattern** (actions.ts): +```typescript +if (!EDITABLE_FIELDS.includes(fieldName)) { + throw new Error(`Campo non editabile: ${fieldName}`); +} +if (required && !value.trim()) { + throw new Error("Campo richiesto"); +} +``` + +**Component pattern** (ServiceTable.tsx lines 43–56): +```typescript +function run(fn: () => Promise) { + setError(null); + startTransition(async () => { + try { + await fn(); + router.refresh(); + } catch (e) { + setError(e instanceof Error ? e.message : "Errore nel salvataggio"); + } + }); +} +``` + +Error display (lines 131–137): +```typescript +{error && ( + + + {error} + + +)} +``` + +--- + +### Polymorphic Tag CRUD + +**Source:** `src/app/admin/catalog/actions.ts` lines 131–199 (addServiceOption/removeServiceOption/renameServiceOption) + +**Apply to:** `addLeadTag`, `removeLeadTag`, `renameLeadTag` (CRM-09) + +**Pattern:** +1. Define entity-type constant: `const LEADS_TAG_ENTITY = "leads"` +2. Insert: `.onConflictDoNothing()` for idempotency (unique index on `(entity_type, entity_id, name)`) +3. Delete: `where(and(eq(tags.entity_type, LEADS_TAG_ENTITY), eq(tags.entity_id, leadId), eq(tags.name, value)))` +4. Rename: Update all rows with `entity_type="leads"` and matching name (propagates across all leads) +5. After each mutation: `revalidatePath("/admin/leads")` + +--- + +### Data Fetch & Join Pattern (LeadWithTags) + +**Source:** `src/lib/admin-queries.ts` lines 360–409 (getAllServices pattern) + +**Apply to:** `getLeadsWithTags()` in admin-queries.ts + +**Pattern:** +1. Select from entity table, left-join tags table scoped by `entity_type` +2. Iterate rows, group by entity ID, append tag names to array +3. Return `Map.values()` as `LeadWithTags[]` + +```typescript +const leadMap = new Map(); +for (const row of rows) { + const { tag_name, ...leadFields } = row; + if (!leadMap.has(row.id)) leadMap.set(row.id, { ...leadFields, tags: [] }); + if (tag_name) leadMap.get(row.id)!.tags.push(tag_name); +} +return Array.from(leadMap.values()); +``` + +--- + +### Client-Side Instant Filter (LeadsSearch) + +**Source:** `src/app/admin/catalog/CatalogSearch.tsx` lines 18–29 + +**Apply to:** `LeadsSearch.tsx` (`useMemo` + `.filter()`) + +**Pattern:** +```typescript +const filtered = useMemo(() => { + const q = query.trim().toLowerCase(); + if (!q) return leads; + return leads.filter((l) => + l.name.toLowerCase().includes(q) || + l.email?.toLowerCase().includes(q) || + // ... other fields ... + l.tags.some((t) => t.toLowerCase().includes(q)) + ); +}, [leads, query]); +``` + +No server round-trip, pure client-side filter on keystroke. + +--- + +## No Analog Found + +| File | Role | Data Flow | Reason | +|------|------|-----------|--------| +| (none) | — | — | All Phase 14 files have exact or role-match analogs from Phase 11. | + +--- + +## Metadata + +**Analog search scope:** Codebase searched in directories: `src/components/`, `src/app/`, `src/lib/` + +**Files scanned:** 100+ (focused reads on Phase 11 shipping catalog patterns) + +**Pattern extraction date:** 2026-06-14 + +**Confidence level:** 100% (8/8 files have direct analogs; Phase 11 was explicitly designed to provide these primitives for Phase 14) + +--- + +## Key Notes for Planner + +1. **Phase 14 is a "reuse, don't reinvent" phase.** Every UI primitive (EditableCell, OptionSelect, OptionMultiSelect, getOptionColor) and every backend pattern (field allowlist, tag CRUD, requireAdmin) ships from Phase 11. Treat any deviation as a red flag. + +2. **Status field is a closed enum.** Unlike tags (open-ended), `status` must validate against `LEAD_STAGES` server-side. The planner should decide: use `OptionSelect` with fixed options + semantic color override (STAGE_COLOR), or build a simpler closed-enum dropdown. Both are low-effort; just pick one explicitly. + +3. **Three bug fixes are small and isolated.** CRM-10 (i18n), CRM-11 (type safety), and CRM-12 (dead code) are independent edits to their respective files. They don't block or enable the main table rewrite. + +4. **New server actions should include `requireAdmin()`.** Current `src/app/admin/leads/actions.ts` pre-existing functions lack this guard. New actions (Phase 14) follow the stricter Phase 11 convention. Whether to retrofit the pre-existing actions is a scope decision outside Phase 14. + +5. **Two paths for revalidation.** Field updates should call both `revalidatePath("/admin/leads")` (list page) and `revalidatePath(\`/admin/leads/${leadId}\`)` (detail page), matching the existing `updateLead` pattern in `src/app/admin/leads/actions.ts` lines 62–63.