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.
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 |
|
true |
|
|
- CRM-10:
FollowUpWidget.tsxhas 6 hardcoded English strings — translate to Italian per UI-SPEC.md Decision 5 ("X lead da contattare", no pluralization since Italian "lead" is invariant). - CRM-11:
LeadForm.tsx'sCreateLeadModalandEditLeadModalboth calluseForm<any>(), defeating react-hook-form's type safety — replace withuseForm<CreateLeadInput>()using the Zod-inferred type already exported fromlead-validators.ts, removing the local type redeclaration and narrowing the one unavoidablestatuscast. - CRM-12:
SendQuoteModal.tsx'sonSubmitcontains a deadif (tab === "new") {...}branch that can never execute (the "new" tab's button has its ownonClickhandler 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.mdFrom 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>
)}
"Require Follow-up"(CardTitle) →"Richiedi Follow-up"{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)"Contact"(Button label) →"Contatta"View All ({count})→Vedi Tutto ({count})"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>
);
}
-
Add
CreateLeadInputto the import from@/lib/lead-validators:import { createLeadSchema, type CreateLeadInput } from "@/lib/lead-validators";(adjust based on the exact current import statement — if
createLeadSchemais already imported fromlead-validators, just add, type CreateLeadInputto that same import line) -
Remove the local redeclaration: delete the line
type CreateLeadInput = z.infer<typeof createLeadSchema>;entirely. -
If
import type { z } from "zod"(orimport { z } from "zod"used only for this) becomes unused after step 3, remove it. First grep the file for otherz.usages — if any remain (e.g., inline schema validation elsewhere in the file), keep the import. -
In
CreateLeadModal, change:const form = useForm<any>({to:
const form = useForm<CreateLeadInput>({ -
In
EditLeadModal, change:const form = useForm<any>({to:
const form = useForm<CreateLeadInput>({ -
In
EditLeadModal'sdefaultValues, change:status: lead.status as any,to:
status: lead.status as CreateLeadInput["status"], -
Run
npx tsc --noEmit. If new type errors surface inside either modal (e.g., aform.watch("someField")call referencing a field name not increateLeadSchema, or aform.setValue(...)with a mismatched type), fix the call site to match the actualcreateLeadSchemashape (fromlead-validators.ts) — do NOT reintroduceanyoras anyto 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.tsxreturns 0grep -c "useForm<CreateLeadInput>" src/components/admin/leads/LeadForm.tsxreturns 2 (CreateLeadModal + EditLeadModal)grep -c "type CreateLeadInput = z.infer" src/components/admin/leads/LeadForm.tsxreturns 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.tsxreturns 0grep -c 'status: lead.status as CreateLeadInput\["status"\]' src/components/admin/leads/LeadForm.tsxreturns 1grep -c "as any" src/components/admin/leads/LeadForm.tsxreturns 0 (no remaininganycasts in the file)npx tsc --noEmitexits 0npx eslint src/components/admin/leads/LeadForm.tsxexits 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 castlead.status as CreateLeadInput["status"]in EditLeadModal's defaultValues. Noas anyremains anywhere in the file. Type-checks and lints cleanly.
-
Locate the
onSubmitfunction. 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 }); } -
Remove the dead
if (data.tab === "new") { ... return; }block entirely, soonSubmitbegins directly withsetError(null);:async function onSubmit(data: AssignQuoteInput) { setError(null); startTransition(async () => { // ... existing-tab logic (unchanged) }); } -
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. -
Leave the
generate_new: z.boolean().optional()field inassignQuoteSchemaand itsdefaultValues: { ..., generate_new: false }entry AS-IS — RESEARCH.md flags this as optional cleanup, not required for CRM-12. Removing it would require also checkingAssignQuoteInputusages elsewhere (out of scope for this isolated fix). Do not touch it. -
After the edit, grep the file for
data.tabto confirm no other deadtab === "new"checks remain insideonSubmit. If thetabfield is still referenced elsewhere (e.g., to conditionally render the "existing" vs "new" tab UI in JSX), that is correct and expected — only theonSubmitdead 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 0grep -c "window.location.href" src/components/admin/leads/SendQuoteModal.tsxreturns 1 (only the "new" tab button's onClick remains, the onSubmit copy is removed)grep -c "assignQuoteToLead" src/components/admin/leads/SendQuoteModal.tsxreturns at least 1 (existing-tab submit logic preserved)grep -c "generate_new" src/components/admin/leads/SendQuoteModal.tsxreturns at least 1 (left untouched per action step 5)npx tsc --noEmitexits 0npx eslint src/components/admin/leads/SendQuoteModal.tsxexits 0 </acceptance_criteria> SendQuoteModal.tsx's onSubmit no longer contains the unreachableif (data.tab === "new") {...}branch — onSubmit now starts directly withsetError(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> |
<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>()withCreateLeadInputimported fromlead-validators.ts; noas anyremains — CRM-11 satisfied - SendQuoteModal.tsx's
onSubmitno longer contains the unreachabletab === "new"branch; existing-tab submission and new-tab button navigation both continue to work — CRM-12 satisfied npx tsc --noEmitandnpx eslintboth exit 0 across all three files </success_criteria>