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.
43 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 | 02 | execute | 2 |
|
|
true |
|
|
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.
<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/admin-queries.ts (created in Plan 14-01):
export type LeadWithTags = Lead & { tags: string[] };
export type LeadFieldOptions = { status: string[]; tags: string[] };
export async function getLeadsWithTags(): Promise<LeadWithTags[]>;
export async function getLeadFieldOptions(): Promise<LeadFieldOptions>;
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):
export async function updateLeadField(leadId: string, fieldName: EditableField, value: string): Promise<void>;
// EditableField = "name" | "email" | "phone" | "company" | "status" | "next_action"
export async function addLeadTag(leadId: string, value: string): Promise<void>;
export async function removeLeadTag(leadId: string, value: string): Promise<void>;
export async function renameLeadTag(oldValue: string, newValue: string): Promise<void>;
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):
"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<string | null>(null);
function run(fn: () => Promise<unknown>) {
setError(null);
startTransition(async () => {
try {
await fn();
router.refresh();
} catch (e) {
setError(e instanceof Error ? e.message : "Errore nel salvataggio");
}
});
}
return (
<>
<tr className="border-b border-[#e5e7eb] hover:bg-[#f9f9f9] transition-colors duration-150">
<td className="py-2 px-3 font-medium text-[#1a1a1a] min-w-[160px]">
<EditableCell value={service.name} type="text" required onSave={(v) => run(() => updateServiceField(service.id, "name", v))} />
</td>
{/* ... more <td> cells ... */}
</tr>
{error && (
<tr>
<td colSpan={COL_COUNT} className="py-1 px-3 text-xs text-red-600">{error}</td>
</tr>
)}
</>
);
}
export function ServiceTable({ services, options }: { services: ServiceWithTags[]; options: CatalogFieldOptions }) {
return (
<div className="bg-white rounded-xl border border-[#e5e7eb] overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-[#f9f9f9] border-b border-[#e5e7eb] sticky top-0 z-[1]">
<tr>
{COLUMN_HEADERS.map((header) => (
<th key={header} className="text-left py-2 px-3 font-semibold text-[#71717a]">{header}</th>
))}
</tr>
</thead>
<tbody>
{services.map((service) => <ServiceRow key={service.id} service={service} options={options} />)}
</tbody>
</table>
</div>
{services.length === 0 && <div className="text-center py-8 text-gray-500">Nessun servizio trovato</div>}
</div>
);
}
From src/app/admin/catalog/CatalogSearch.tsx (Phase 11, shipped — exact structural analog for LeadsSearch.tsx, full file 46 lines):
"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 (
<div className="space-y-4">
<div className="relative max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-[#71717a]" />
<Input type="text" placeholder="Cerca per nome, categoria, fase, tag o pacchetto..." value={query} onChange={(e) => setQuery(e.target.value)} className="pl-9 h-9" />
</div>
<ServiceTable services={filtered} options={options} />
</div>
);
}
From src/app/admin/catalog/page.tsx (Phase 11, shipped — exact structural analog for leads/page.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 (
<div>
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold text-[#1a1a1a]">Catalogo Servizi</h1>
</div>
<CatalogSearch services={services} options={options} />
</div>
);
}
From src/app/admin/leads/page.tsx (CURRENT — to be replaced):
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 (
<div className="space-y-6">
<div className="flex justify-between items-center">
<h1 className="text-3xl font-bold">Lead Pipeline</h1>
<CreateLeadModal />
</div>
<Suspense fallback={<div className="animate-pulse">Loading...</div>}>
<LeadTable leads={leads} />
</Suspense>
</div>
);
}
export default function LeadsPage() {
return <LeadsList />;
}
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):
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):
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):
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):
"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<string, string> = {
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):
<Card>
<CardHeader>
<CardTitle>Profilo</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div>
<label className="text-sm text-gray-600">Stato</label>
<Badge className={STAGE_COLOR[lead.status as keyof typeof STAGE_COLOR] || ""}>
{lead.status.replace(/_/g, " ")}
</Badge>
</div>
{/* email, telefono, ultimo contatto, prossima azione fields ... */}
</CardContent>
</Card>
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).
"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<string, string> = {
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<string | null>(null);
function run(fn: () => Promise<unknown>) {
setError(null);
startTransition(async () => {
try {
await fn();
router.refresh();
} catch (e) {
setError(e instanceof Error ? e.message : "Errore nel salvataggio");
}
});
}
return (
<>
<tr className="border-b border-[#e5e7eb] hover:bg-[#f9f9f9] transition-colors duration-150">
<td className="py-2 px-3 font-medium text-[#1a1a1a] min-w-[160px]">
<EditableCell
value={lead.name}
type="text"
required
onSave={(v) => run(() => updateLeadField(lead.id, "name", v))}
/>
</td>
<td className="py-2 px-3 min-w-[160px]">
<EditableCell
value={lead.email ?? ""}
type="text"
placeholder="email@esempio.com"
onSave={(v) => run(() => updateLeadField(lead.id, "email", v))}
/>
</td>
<td className="py-2 px-3 min-w-[140px]">
<EditableCell
value={lead.phone ?? ""}
type="text"
placeholder="+39 3XX XXXXXXX"
onSave={(v) => run(() => updateLeadField(lead.id, "phone", v))}
/>
</td>
<td className="py-2 px-3 min-w-[140px]">
<EditableCell
value={lead.company ?? ""}
type="text"
placeholder="Nome azienda"
onSave={(v) => run(() => updateLeadField(lead.id, "company", v))}
/>
</td>
<td className="py-2 px-3 min-w-[120px]">
<OptionSelect
value={lead.status}
options={options.status}
onChange={(v) => run(() => updateLeadField(lead.id, "status", v ?? "contacted"))}
renderBadge={(opt) => (
<Badge
variant="outline"
className={cn(
STAGE_COLOR[opt] ?? getOptionColor(opt),
"border-transparent text-xs px-2 py-0.5 font-medium truncate"
)}
>
{opt.replace(/_/g, " ")}
</Badge>
)}
/>
</td>
<td className="py-2 px-3 min-w-[180px]">
<EditableCell
value={lead.next_action ?? ""}
type="text"
placeholder="Prossima azione..."
onSave={(v) => run(() => updateLeadField(lead.id, "next_action", v))}
/>
</td>
<td className="py-2 px-3 min-w-[180px]">
<OptionMultiSelect
values={lead.tags}
options={options.tags}
onAdd={(v) => run(() => addLeadTag(lead.id, v))}
onRemove={(v) => run(() => removeLeadTag(lead.id, v))}
onRename={(o, n) => run(() => renameLeadTag(o, n))}
/>
</td>
<td className="py-2 px-3 min-w-[80px]">
<Button variant="ghost" size="sm" asChild>
<Link href={`/admin/leads/${lead.id}`}>Dettagli</Link>
</Button>
</td>
</tr>
{error && (
<tr>
<td colSpan={COL_COUNT} className="py-1 px-3 text-xs text-red-600">
{error}
</td>
</tr>
)}
</>
);
}
export function LeadTable({
leads,
options,
}: {
leads: LeadWithTags[];
options: LeadFieldOptions;
}) {
return (
<div className="bg-white rounded-xl border border-[#e5e7eb] overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-[#f9f9f9] border-b border-[#e5e7eb] sticky top-0 z-[1]">
<tr>
{COLUMN_HEADERS.map((header) => (
<th
key={header}
className="text-left py-2 px-3 font-semibold text-[#71717a]"
>
{header}
</th>
))}
</tr>
</thead>
<tbody>
{leads.map((lead) => (
<LeadRow key={lead.id} lead={lead} options={options} />
))}
</tbody>
</table>
</div>
{leads.length === 0 && (
<div className="text-center py-8 text-gray-500">Nessun lead trovato</div>
)}
</div>
);
}
CRITICAL CORRECTION — OptionSelect does NOT have a renderBadge prop (per the props contract in <interfaces> 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 <tr>). 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 <scope_reduction_prohibition> — 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):
function StatusCell({
lead,
options,
run,
}: {
lead: LeadWithTags;
options: LeadFieldOptions;
run: (fn: () => Promise<unknown>) => void;
}) {
const [isOpen, setIsOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(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 (
<div ref={containerRef} className="relative w-full min-w-[120px]">
<div
onClick={() => 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]"
>
<Badge
variant="outline"
className={cn(
STAGE_COLOR[lead.status] ?? "",
"border-transparent text-xs px-2 py-0.5 font-medium truncate"
)}
>
{lead.status.replace(/_/g, " ")}
</Badge>
</div>
{isOpen && (
<div className="absolute top-full left-0 mt-1 bg-white border border-[#e5e7eb] rounded-md shadow-md p-1.5 z-20 min-w-[180px]">
<div className="flex flex-col gap-0.5">
{options.status.map((stage) => (
<button
key={stage}
type="button"
onClick={() => {
setIsOpen(false);
run(() => updateLeadField(lead.id, "status", stage));
}}
className="flex items-center gap-2 rounded px-1.5 py-1 hover:bg-[#f5f5f5] text-left"
>
<span className="w-3.5 flex-shrink-0">
{lead.status === stage && <Check className="h-3.5 w-3.5 text-[#1A463C]" />}
</span>
<Badge
variant="outline"
className={cn(STAGE_COLOR[stage] ?? "", "border-transparent text-xs px-2 py-0.5 font-medium truncate")}
>
{stage.replace(/_/g, " ")}
</Badge>
</button>
))}
</div>
</div>
)}
</div>
);
}
Use <StatusCell lead={lead} options={options} run={run} /> 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.statusvalues 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:
"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
<acceptance_criteria>
- 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
</acceptance_criteria>
LeadTable.tsx is fully rewritten as a raw <table> 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.
"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 (
<div className="space-y-4">
<div className="relative max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-[#71717a]" />
<Input
type="text"
placeholder="Cerca lead..."
value={query}
onChange={(e) => setQuery(e.target.value)}
className="pl-9 h-9"
/>
</div>
<LeadTable leads={filtered} options={options} />
</div>
);
}
Part B — Rewrite src/app/admin/leads/page.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 (
<div className="space-y-6">
<div className="flex justify-between items-center">
<h1 className="text-2xl font-bold text-[#1a1a1a]">Lead Pipeline</h1>
<CreateLeadModal />
</div>
<LeadsSearch leads={leads} options={options} />
</div>
);
}
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):
-
First, read
src/app/admin/leads/[id]/page.tsxto find whereLeadDetailis rendered and what data is currently fetched/passed. -
Update the detail page (
src/app/admin/leads/[id]/page.tsx) to also fetch tags + tag options for this lead. SincegetLeadsWithTags()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
tagstable scoped byentity_id = lead.id, entity_type = "leads".
Use approach (a) for consistency and zero new query functions: import
getLeadsWithTagsandgetLeadFieldOptionsfrom@/lib/admin-queries, findleads.find(l => l.id === params.id)for the tags array, and passtagOptions={options.tags}fromgetLeadFieldOptions(). - (a) Call
-
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
OptionMultiSelectfrom@/components/ui/option-multi-select, andaddLeadTag/removeLeadTag/renameLeadTagfrom@/app/admin/leads/actions. - Add
useRouter,useTransition,useState(for error) imports from"react"/"next/navigation"— replicate the samerun()helper pattern asLeadTable.tsx'sLeadRow. - In the "Profilo" Card's
CardContent, after the "Stato" field block, add a new field block:<div> <label className="text-sm text-gray-600">Tag</label> <OptionMultiSelect values={tags} options={tagOptions} onAdd={(v) => run(() => addLeadTag(lead.id, v))} onRemove={(v) => run(() => removeLeadTag(lead.id, v))} onRename={(o, n) => run(() => renameLeadTag(o, n))} /> </div> - Add the
run()helper function at the top of the component body:const router = useRouter(); const [, startTransition] = useTransition(); const [tagError, setTagError] = useState<string | null>(null); function run(fn: () => Promise<unknown>) { setTagError(null); startTransition(async () => { try { await fn(); router.refresh(); } catch (e) { setTagError(e instanceof Error ? e.message : "Errore nel salvataggio"); } }); } - Display
tagErrorbelow the OptionMultiSelect if set:{tagError && <p className="text-xs text-red-600 mt-1">{tagError}</p>}.
- Add
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
<acceptance_criteria>
- 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
</acceptance_criteria>
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.
<threat_model>
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 |
| </threat_model> |
<success_criteria>
/admin/leadsrenders a raw<table>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 viaupdateLeadField statusis editable via a closed dropdown (StatusCell) showing only the 6LEAD_STAGESvalues with semanticSTAGE_COLORbadges (won=green, lost=red, etc.)- Lead tags are editable via
OptionMultiSelect, backed byaddLeadTag/removeLeadTag/renameLeadTag(CRM-09) /admin/leadshas 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 sameOptionMultiSelect+ actionsnpx tsc --noEmitandnpx eslintboth exit 0 </success_criteria>