--- 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`