Files
clienthub/.planning/phases/14-crm-attio-style-fix/14-03-PLAN.md
T
simone d115465240 docs(14): create phase plan
Plan Phase 14 (CRM Attio-style & Fix) into 3 waves: data-layer
foundation (query helpers + server actions), LeadTable raw-table
rewrite with inline edit/tags, and isolated bug fixes for CRM-10/11/12.
2026-06-14 12:09:45 +02:00

25 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
phase plan type wave depends_on files_modified autonomous requirements must_haves
14-crm-attio-style-fix 03 execute 1
src/components/admin/dashboard/FollowUpWidget.tsx
src/components/admin/leads/LeadForm.tsx
src/components/admin/leads/SendQuoteModal.tsx
true
CRM-10
CRM-11
CRM-12
truths artifacts key_links
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<CreateLeadInput>() (not useForm<any>()) — 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
path provides contains
src/components/admin/dashboard/FollowUpWidget.tsx Italian-only follow-up widget copy Richiedi Follow-up
path provides contains
src/components/admin/leads/LeadForm.tsx Typed react-hook-form for lead create/edit useForm<CreateLeadInput>
path provides contains
src/components/admin/leads/SendQuoteModal.tsx onSubmit without dead 'new' tab branch onSubmit
from to via pattern
src/components/admin/leads/LeadForm.tsx src/lib/lead-validators.ts import type { CreateLeadInput } 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<any>(), defeating react-hook-form's type safety — replace with useForm<CreateLeadInput>() 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.

<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_context>

@.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):

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<typeof createLeadSchema>;
export type UpdateLeadInput = z.infer<typeof updateLeadSchema>;

From src/components/admin/dashboard/FollowUpWidget.tsx (current, full 47 lines — to be translated):

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 (
    <Card>
      <CardHeader>
        <CardTitle>Require Follow-up</CardTitle>
      </CardHeader>
      <CardContent>
        {count > 0 ? (
          <>
            <p className="text-sm text-gray-600 mb-3">
              {count} lead{count !== 1 ? "s" : ""} to contact
            </p>
            <div className="space-y-2">
              {leads.slice(0, 5).map((lead) => (
                <div key={lead.id} className="flex items-center justify-between">
                  <span className="text-sm font-medium">{lead.name}</span>
                  <Button size="sm" variant="outline" asChild>
                    <Link href={`/admin/leads/${lead.id}`}>Contact</Link>
                  </Button>
                </div>
              ))}
            </div>
            {count > 5 && (
              <Link href="/admin/leads" className="text-sm text-blue-600 hover:underline mt-2 block">
                View All ({count})
              </Link>
            )}
          </>
        ) : (
          <p className="text-sm text-gray-500">All leads are up to date!</p>
        )}
      </CardContent>
    </Card>
  );
}

From src/components/admin/leads/LeadForm.tsx (current, lines 1-40 and 202-220 — relevant excerpts):

"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<typeof createLeadSchema>;  // <-- local redeclaration, line 36, to be removed

export function CreateLeadModal() {
  const form = useForm<any>({  // <-- 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<any>({  // <-- 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):

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<AssignQuoteInput>({
  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" && (
  <Button type="button" onClick={() => { window.location.href = `/admin/quotes/new?lead_id=${lead.id}`; }}>
    Crea Nuovo Preventivo
  </Button>
)}
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:

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 (
    <Card>
      <CardHeader>
        <CardTitle>Richiedi Follow-up</CardTitle>
      </CardHeader>
      <CardContent>
        {count > 0 ? (
          <>
            <p className="text-sm text-gray-600 mb-3">
              {count} lead da contattare
            </p>
            <div className="space-y-2">
              {leads.slice(0, 5).map((lead) => (
                <div key={lead.id} className="flex items-center justify-between">
                  <span className="text-sm font-medium">{lead.name}</span>
                  <Button size="sm" variant="outline" asChild>
                    <Link href={`/admin/leads/${lead.id}`}>Contatta</Link>
                  </Button>
                </div>
              ))}
            </div>
            {count > 5 && (
              <Link href="/admin/leads" className="text-sm text-blue-600 hover:underline mt-2 block">
                Vedi Tutto ({count})
              </Link>
            )}
          </>
        ) : (
          <p className="text-sm text-gray-500">Tutti i lead sono aggiornati!</p>
        )}
      </CardContent>
    </Card>
  );
}
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)
  1. Add CreateLeadInput to the import from @/lib/lead-validators:

    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)

  2. Remove the local redeclaration: delete the line type CreateLeadInput = z.infer<typeof createLeadSchema>; entirely.

  3. 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.

  4. In CreateLeadModal, change:

    const form = useForm<any>({
    

    to:

    const form = useForm<CreateLeadInput>({
    
  5. In EditLeadModal, change:

    const form = useForm<any>({
    

    to:

    const form = useForm<CreateLeadInput>({
    
  6. In EditLeadModal's defaultValues, change:

    status: lead.status as any,
    

    to:

    status: lead.status as CreateLeadInput["status"],
    
  7. 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 <acceptance_criteria>

    • grep -c "useForm<any>" src/components/admin/leads/LeadForm.tsx returns 0
    • grep -c "useForm<CreateLeadInput>" 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 </acceptance_criteria> 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.
  1. Locate the onSubmit function. It currently begins with:

    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
      });
    }
    
  2. Remove the dead if (data.tab === "new") { ... return; } block entirely, so onSubmit begins directly with setError(null);:

    async function onSubmit(data: AssignQuoteInput) {
      setError(null);
      startTransition(async () => {
        // ... existing-tab logic (unchanged)
      });
    }
    
  3. 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.

  4. 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.

  5. 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 <acceptance_criteria>

    • 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 </acceptance_criteria> 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.

<threat_model>

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<any>() to useForm<CreateLeadInput>() 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
</threat_model>
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

<success_criteria>

  • 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<CreateLeadInput>() 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 </success_criteria>
After completion, create `.planning/phases/14-crm-attio-style-fix/14-03-SUMMARY.md`