d115465240
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.
881 lines
43 KiB
Markdown
881 lines
43 KiB
Markdown
---
|
|
phase: 14-crm-attio-style-fix
|
|
plan: 02
|
|
type: execute
|
|
wave: 2
|
|
depends_on: ["14-01"]
|
|
files_modified:
|
|
- src/components/admin/leads/LeadTable.tsx
|
|
- src/app/admin/leads/LeadsSearch.tsx
|
|
- src/app/admin/leads/page.tsx
|
|
- src/components/admin/leads/LeadDetail.tsx
|
|
autonomous: true
|
|
requirements: [CRM-08, CRM-09]
|
|
|
|
must_haves:
|
|
truths:
|
|
- "The /admin/leads table renders with raw <table> markup (no shadcn Table/TableRow/TableCell wrapper), matching the ServiceTable.tsx visual pattern"
|
|
- "User can click any of name/email/phone/company/next_action cells, edit inline, press Enter, and the value persists via updateLeadField + router.refresh()"
|
|
- "User can open the Stato dropdown and pick one of the 6 fixed LEAD_STAGES values (no 'Crea' create-on-the-fly option appears for status)"
|
|
- "User can add/remove/rename tags on a lead via OptionMultiSelect, backed by addLeadTag/removeLeadTag/renameLeadTag"
|
|
- "User can type in the search box above the table and the visible rows filter instantly client-side (no network request) across name/email/company/status/tags"
|
|
- "Empty search results show 'Nessun lead trovato'"
|
|
artifacts:
|
|
- path: "src/components/admin/leads/LeadTable.tsx"
|
|
provides: "Raw-table LeadRow + LeadTable, inline-edit + status/tags dropdowns"
|
|
contains: "EditableCell"
|
|
- path: "src/app/admin/leads/LeadsSearch.tsx"
|
|
provides: "Client-side instant search wrapper for LeadTable"
|
|
contains: "useMemo"
|
|
- path: "src/app/admin/leads/page.tsx"
|
|
provides: "Server component fetching getLeadsWithTags + getLeadFieldOptions, rendering LeadsSearch"
|
|
contains: "getLeadsWithTags"
|
|
key_links:
|
|
- from: "src/app/admin/leads/page.tsx"
|
|
to: "src/lib/admin-queries.ts"
|
|
via: "getLeadsWithTags() + getLeadFieldOptions()"
|
|
pattern: "getLeadsWithTags"
|
|
- from: "src/components/admin/leads/LeadTable.tsx"
|
|
to: "src/app/admin/leads/actions.ts"
|
|
via: "updateLeadField / addLeadTag / removeLeadTag / renameLeadTag"
|
|
pattern: "updateLeadField\\(lead\\.id"
|
|
- from: "src/app/admin/leads/LeadsSearch.tsx"
|
|
to: "src/components/admin/leads/LeadTable.tsx"
|
|
via: "renders <LeadTable leads={filtered} options={options} />"
|
|
pattern: "<LeadTable"
|
|
---
|
|
|
|
<objective>
|
|
Rewrite `LeadTable.tsx` as a raw `<table>` database-view component (1:1 structural port of Phase 11's `ServiceTable.tsx`), wiring `EditableCell` for scalar fields, `OptionSelect` (fixed `LEAD_STAGES`, no create-on-the-fly) for `status`, and `OptionMultiSelect` for tags (CRM-09). Add a new `LeadsSearch.tsx` client-side instant-filter wrapper (mirrors `CatalogSearch.tsx`). Rewire `/admin/leads/page.tsx` to fetch via `getLeadsWithTags()`/`getLeadFieldOptions()` and render `LeadsSearch`. Additionally surface lead tags as an `OptionMultiSelect` in `LeadDetail.tsx`'s "Profilo" card (UI-SPEC.md Decision 4, low-cost addition).
|
|
|
|
Purpose: Delivers the CRM-08 (inline editing, database-view table) and CRM-09 (lead tags) user-facing experience, completing the Attio/Pipedrive-style redesign of `/admin/leads`.
|
|
|
|
Output: Rewritten `LeadTable.tsx`, new `LeadsSearch.tsx`, updated `page.tsx`, and `LeadDetail.tsx` with a tags `OptionMultiSelect`.
|
|
</objective>
|
|
|
|
<execution_context>
|
|
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
|
@$HOME/.claude/get-shit-done/templates/summary.md
|
|
</execution_context>
|
|
|
|
<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
|
|
</context>
|
|
|
|
<interfaces>
|
|
<!-- Types and server actions created by Plan 14-01 — use these directly, no exploration needed -->
|
|
|
|
From src/lib/admin-queries.ts (created in Plan 14-01):
|
|
```typescript
|
|
export type LeadWithTags = Lead & { tags: string[] };
|
|
export type LeadFieldOptions = { status: string[]; tags: string[] };
|
|
|
|
export async function getLeadsWithTags(): Promise<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):
|
|
```typescript
|
|
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):
|
|
```tsx
|
|
"use client";
|
|
|
|
import { useState, useTransition } from "react";
|
|
import { useRouter } from "next/navigation";
|
|
import { EditableCell } from "@/components/ui/editable-cell";
|
|
import { OptionSelect } from "@/components/ui/option-select";
|
|
import { OptionMultiSelect } from "@/components/ui/option-multi-select";
|
|
|
|
function ServiceRow({ service, options }: { service: ServiceWithTags; options: CatalogFieldOptions }) {
|
|
const router = useRouter();
|
|
const [, startTransition] = useTransition();
|
|
const [error, setError] = useState<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):
|
|
```tsx
|
|
"use client";
|
|
|
|
import { useState, useMemo } from "react";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Search } from "lucide-react";
|
|
import { ServiceTable } from "@/components/admin/catalog/ServiceTable";
|
|
import type { ServiceWithTags, CatalogFieldOptions } from "@/lib/admin-queries";
|
|
|
|
export function CatalogSearch({ services, options }: { services: ServiceWithTags[]; options: CatalogFieldOptions }) {
|
|
const [query, setQuery] = useState("");
|
|
|
|
const filtered = useMemo(() => {
|
|
const q = query.trim().toLowerCase();
|
|
if (!q) return services;
|
|
return services.filter((s) => {
|
|
if (s.name.toLowerCase().includes(q)) return true;
|
|
if (s.category?.toLowerCase().includes(q)) return true;
|
|
if (s.tags.some((t) => t.toLowerCase().includes(q))) return true;
|
|
return false;
|
|
});
|
|
}, [services, query]);
|
|
|
|
return (
|
|
<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):
|
|
```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):
|
|
```tsx
|
|
import { Suspense } from "react";
|
|
import { getAllLeads } from "@/lib/lead-service";
|
|
import { LeadTable } from "@/components/admin/leads/LeadTable";
|
|
import { CreateLeadModal } from "@/components/admin/leads/LeadForm";
|
|
|
|
async function LeadsList() {
|
|
const leads = await getAllLeads();
|
|
return (
|
|
<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):
|
|
```typescript
|
|
export interface EditableCellProps {
|
|
value: string | number | boolean;
|
|
type?: "text" | "number" | "textarea" | "toggle";
|
|
onSave: (value: string) => void;
|
|
required?: boolean;
|
|
placeholder?: string;
|
|
disabled?: boolean;
|
|
formatDisplay?: (value: string) => string;
|
|
}
|
|
```
|
|
|
|
From src/components/ui/option-select.tsx (Phase 11, shipped — props contract, reuse as-is, NO modifications):
|
|
```typescript
|
|
export interface OptionSelectProps {
|
|
value: string | null;
|
|
options: string[];
|
|
onChange: (value: string | null) => void;
|
|
onRename?: (oldValue: string, newValue: string) => void;
|
|
placeholder?: string;
|
|
disabled?: boolean;
|
|
}
|
|
```
|
|
IMPORTANT: `onRename` is OPTIONAL. For the `status` field, do NOT pass `onRename` — this is what makes the dropdown closed/non-extensible per UI-SPEC.md Decision 1 (the "Crea «...»" create-on-the-fly button only appears when the typed query has no exact match AND... actually the create button always renders for non-exact-match queries regardless of onRename). To fully prevent arbitrary status creation in the UI, see the Task 1 action notes below for the exact mitigation (server-side validation in `updateLeadField` already rejects invalid values per Plan 14-01 — this is defense in depth at the UI layer).
|
|
|
|
From src/components/ui/option-multi-select.tsx (Phase 11, shipped — props contract, reuse as-is, NO modifications):
|
|
```typescript
|
|
export interface OptionMultiSelectProps {
|
|
values: string[];
|
|
options: string[];
|
|
onAdd: (value: string) => void;
|
|
onRemove: (value: string) => void;
|
|
onRename?: (oldValue: string, newValue: string) => void;
|
|
placeholder?: string;
|
|
disabled?: boolean;
|
|
}
|
|
```
|
|
|
|
From src/components/admin/leads/LeadTable.tsx (CURRENT — to be replaced entirely):
|
|
```tsx
|
|
"use client";
|
|
|
|
import Link from "next/link";
|
|
import { Lead } from "@/db/schema";
|
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Button } from "@/components/ui/button";
|
|
import { LEAD_STAGES } from "@/lib/lead-validators";
|
|
|
|
const STAGE_COLOR: Record<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):
|
|
```tsx
|
|
<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).
|
|
</interfaces>
|
|
|
|
<tasks>
|
|
|
|
<task type="auto" tdd="true">
|
|
<name>Task 1: Rewrite LeadTable.tsx as raw-table database-view (CRM-08, CRM-09)</name>
|
|
<files>src/components/admin/leads/LeadTable.tsx</files>
|
|
<read_first>
|
|
- src/components/admin/leads/LeadTable.tsx (current file — to be fully replaced)
|
|
- src/components/admin/catalog/ServiceTable.tsx (exact structural analog, full file)
|
|
- src/components/ui/editable-cell.tsx (props contract)
|
|
- src/components/ui/option-select.tsx (props contract, esp. lines 49-66 for create-on-the-fly logic)
|
|
- src/components/ui/option-multi-select.tsx (props contract)
|
|
- .planning/phases/14-crm-attio-style-fix/14-UI-SPEC.md (Table Layout — LeadTable section, Column Structure table, Design Decision 1)
|
|
</read_first>
|
|
<behavior>
|
|
Manual-trace behaviors (no automated test file exists for this UI component in this codebase's convention — verified via `npx tsc --noEmit`, `npx eslint`, and the Wave-checkpoint visual verification in Task 2's follow-up; this `<behavior>` block documents the contract the implementation must satisfy):
|
|
- Table renders 8 columns in this exact order: Nome, Email, Telefono, Azienda, Stato, Prossima Azione, Tag, Azioni
|
|
- Each lead row renders as a raw `<tr>` with `border-b border-[#e5e7eb] hover:bg-[#f9f9f9] transition-colors duration-150` — no shadcn `<TableRow>`
|
|
- Nome cell: `EditableCell(type="text", required)`, bold (`font-medium text-[#1a1a1a]`), `onSave` calls `updateLeadField(lead.id, "name", v)`
|
|
- Email/Telefono/Azienda/Prossima Azione cells: `EditableCell(type="text")`, value falls back to `"—"` when null, `onSave` calls `updateLeadField(lead.id, <field>, v)`
|
|
- Stato cell: `OptionSelect` with `value={lead.status}`, `options={options.status}` (the fixed 6-value LEAD_STAGES array), NO `onRename` prop passed, `onChange` calls `updateLeadField(lead.id, "status", v ?? "contacted")` — badge color overridden via STAGE_COLOR semantic map (not getOptionColor hash)
|
|
- Tag cell: `OptionMultiSelect` with `values={lead.tags}`, `options={options.tags}`, `onAdd`/`onRemove`/`onRename` wired to `addLeadTag`/`removeLeadTag`/`renameLeadTag`
|
|
- Azioni cell: `<Button variant="ghost" size="sm" asChild><Link href={`/admin/leads/${lead.id}`}>Dettagli</Link></Button>`
|
|
- Each row uses the `run()` transition+error pattern: on action error, an extra `<tr>` spanning all 8 columns shows `text-xs text-red-600` error message
|
|
- When `leads.length === 0`, render `<div className="text-center py-8 text-gray-500">Nessun lead trovato</div>`
|
|
- Component is `"use client"`, uses `useRouter`/`useTransition` from the ServiceRow `run()` pattern
|
|
</behavior>
|
|
<action>
|
|
Replace the entire contents of `src/components/admin/leads/LeadTable.tsx` with a raw-table database-view component, structurally identical to `ServiceTable.tsx`:
|
|
|
|
```tsx
|
|
"use client";
|
|
|
|
import Link from "next/link";
|
|
import { useState, useTransition } from "react";
|
|
import { useRouter } from "next/navigation";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Button } from "@/components/ui/button";
|
|
import { EditableCell } from "@/components/ui/editable-cell";
|
|
import { OptionSelect } from "@/components/ui/option-select";
|
|
import { OptionMultiSelect } from "@/components/ui/option-multi-select";
|
|
import {
|
|
updateLeadField,
|
|
addLeadTag,
|
|
removeLeadTag,
|
|
renameLeadTag,
|
|
} from "@/app/admin/leads/actions";
|
|
import type { LeadWithTags, LeadFieldOptions } from "@/lib/admin-queries";
|
|
import { getOptionColor } from "@/components/ui/option-colors";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
const STAGE_COLOR: Record<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):
|
|
|
|
```tsx
|
|
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.status` values are clickable — no text input, no "Crea" button at all) — stronger guarantee than OptionSelect-without-onRename
|
|
- Requires zero changes to shared `option-select.tsx`/`option-multi-select.tsx`/`editable-cell.tsx`
|
|
- Matches "click-to-open dropdown, same interaction model as tags" (UI-SPEC Decision 1's stated goal for interaction consistency)
|
|
|
|
Add imports needed for `StatusCell`: `useRef`, `useEffect` from `"react"` (alongside existing `useState`, `useTransition`), and `Check` from `"lucide-react"`. Remove the unused `getOptionColor` import for the status cell (still needed if used elsewhere — it is NOT needed in this file since OptionMultiSelect imports it internally; do not import `getOptionColor` in LeadTable.tsx at all).
|
|
|
|
Final import list for LeadTable.tsx:
|
|
```tsx
|
|
"use client";
|
|
|
|
import Link from "next/link";
|
|
import { useState, useRef, useEffect, useTransition } from "react";
|
|
import { useRouter } from "next/navigation";
|
|
import { Check } from "lucide-react";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Button } from "@/components/ui/button";
|
|
import { EditableCell } from "@/components/ui/editable-cell";
|
|
import { OptionMultiSelect } from "@/components/ui/option-multi-select";
|
|
import {
|
|
updateLeadField,
|
|
addLeadTag,
|
|
removeLeadTag,
|
|
renameLeadTag,
|
|
} from "@/app/admin/leads/actions";
|
|
import type { LeadWithTags, LeadFieldOptions } from "@/lib/admin-queries";
|
|
import { cn } from "@/lib/utils";
|
|
```
|
|
(`OptionSelect` import removed — not used; `StatusCell` is local.)
|
|
</action>
|
|
<verify>
|
|
<automated>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</automated>
|
|
</verify>
|
|
<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>
|
|
<done>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.</done>
|
|
</task>
|
|
|
|
<task type="auto">
|
|
<name>Task 2: Create LeadsSearch.tsx, rewire page.tsx, surface tags in LeadDetail.tsx</name>
|
|
<files>src/app/admin/leads/LeadsSearch.tsx, src/app/admin/leads/page.tsx, src/components/admin/leads/LeadDetail.tsx</files>
|
|
<read_first>
|
|
- src/app/admin/catalog/CatalogSearch.tsx (exact structural analog for LeadsSearch.tsx)
|
|
- src/app/admin/catalog/page.tsx (exact structural analog for page.tsx)
|
|
- src/app/admin/leads/page.tsx (current file — to be replaced)
|
|
- src/components/admin/leads/LeadDetail.tsx (current file — Profilo card to extend)
|
|
- src/app/admin/leads/[id]/page.tsx (detail page — to confirm what props LeadDetail currently receives and what needs to change to pass tags)
|
|
- src/lib/admin-queries.ts (LeadWithTags, LeadFieldOptions, getLeadsWithTags, getLeadFieldOptions — from Plan 14-01)
|
|
- src/app/admin/leads/actions.ts (addLeadTag, removeLeadTag, renameLeadTag — from Plan 14-01)
|
|
</read_first>
|
|
<action>
|
|
**Part A — Create `src/app/admin/leads/LeadsSearch.tsx`** (new file, mirrors `CatalogSearch.tsx`):
|
|
|
|
```tsx
|
|
"use client";
|
|
|
|
import { useState, useMemo } from "react";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Search } from "lucide-react";
|
|
import { LeadTable } from "@/components/admin/leads/LeadTable";
|
|
import type { LeadWithTags, LeadFieldOptions } from "@/lib/admin-queries";
|
|
|
|
export function LeadsSearch({
|
|
leads,
|
|
options,
|
|
}: {
|
|
leads: LeadWithTags[];
|
|
options: LeadFieldOptions;
|
|
}) {
|
|
const [query, setQuery] = useState("");
|
|
|
|
const filtered = useMemo(() => {
|
|
const q = query.trim().toLowerCase();
|
|
if (!q) return leads;
|
|
return leads.filter((l) => {
|
|
if (l.name.toLowerCase().includes(q)) return true;
|
|
if (l.email?.toLowerCase().includes(q)) return true;
|
|
if (l.company?.toLowerCase().includes(q)) return true;
|
|
if (l.status.toLowerCase().includes(q)) return true;
|
|
if (l.tags.some((t) => t.toLowerCase().includes(q))) return true;
|
|
return false;
|
|
});
|
|
}, [leads, query]);
|
|
|
|
return (
|
|
<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`**:
|
|
|
|
```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)**:
|
|
|
|
1. First, read `src/app/admin/leads/[id]/page.tsx` to find where `LeadDetail` is rendered and what data is currently fetched/passed.
|
|
|
|
2. Update the detail page (`src/app/admin/leads/[id]/page.tsx`) to also fetch tags + tag options for this lead. Since `getLeadsWithTags()` returns ALL leads, for a single-lead detail page either:
|
|
- (a) Call `getLeadsWithTags()` and find the matching lead by id (simplest, reuses Plan 14-01's function, acceptable for current data volumes), OR
|
|
- (b) Add a small inline query using the existing `tags` table scoped by `entity_id = lead.id, entity_type = "leads"`.
|
|
|
|
Use approach (a) for consistency and zero new query functions: import `getLeadsWithTags` and `getLeadFieldOptions` from `@/lib/admin-queries`, find `leads.find(l => l.id === params.id)` for the tags array, and pass `tagOptions={options.tags}` from `getLeadFieldOptions()`.
|
|
|
|
3. In `LeadDetail.tsx`:
|
|
- Add `"use client"` directive (already present — confirm).
|
|
- Extend the component's props: `{ lead, activities, reminders, tags, tagOptions }: { lead: Lead; activities: Activity[]; reminders: Reminder[]; tags: string[]; tagOptions: string[] }`.
|
|
- Import `OptionMultiSelect` from `@/components/ui/option-multi-select`, and `addLeadTag`/`removeLeadTag`/`renameLeadTag` from `@/app/admin/leads/actions`.
|
|
- Add `useRouter`, `useTransition`, `useState` (for error) imports from `"react"`/`"next/navigation"` — replicate the same `run()` helper pattern as `LeadTable.tsx`'s `LeadRow`.
|
|
- In the "Profilo" Card's `CardContent`, after the "Stato" field block, add a new field block:
|
|
```tsx
|
|
<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:
|
|
```tsx
|
|
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 `tagError` below the OptionMultiSelect if set: `{tagError && <p className="text-xs text-red-600 mt-1">{tagError}</p>}`.
|
|
|
|
Do not modify any other part of `LeadDetail.tsx` (activity log, reminders card, notes card remain unchanged).
|
|
</action>
|
|
<verify>
|
|
<automated>test -f src/app/admin/leads/LeadsSearch.tsx && grep -c "getLeadsWithTags\|getLeadFieldOptions" src/app/admin/leads/page.tsx | grep -qx 2 && npx tsc --noEmit</automated>
|
|
</verify>
|
|
<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>
|
|
<done>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.</done>
|
|
</task>
|
|
|
|
</tasks>
|
|
|
|
<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>
|
|
|
|
<verification>
|
|
1. `npx tsc --noEmit` — exits 0
|
|
2. `npx eslint src/components/admin/leads/LeadTable.tsx src/app/admin/leads/LeadsSearch.tsx src/app/admin/leads/page.tsx src/components/admin/leads/LeadDetail.tsx "src/app/admin/leads/[id]/page.tsx"` — exits 0
|
|
3. `grep -c "from \"@/components/ui/table\"" src/components/admin/leads/LeadTable.tsx` returns 0 (shadcn Table wrapper fully removed)
|
|
4. `grep -n "Nessun lead trovato" src/components/admin/leads/LeadTable.tsx` — present
|
|
5. Manual visual check (deferred to checkpoint in execute-phase): `/admin/leads` renders the 8-column raw table, status badges show semantic colors (won=green, lost=red), tags column shows OptionMultiSelect pills, search box filters instantly
|
|
</verification>
|
|
|
|
<success_criteria>
|
|
- `/admin/leads` renders 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 via `updateLeadField`
|
|
- `status` is editable via a closed dropdown (`StatusCell`) showing only the 6 `LEAD_STAGES` values with semantic `STAGE_COLOR` badges (won=green, lost=red, etc.)
|
|
- Lead tags are editable via `OptionMultiSelect`, backed by `addLeadTag`/`removeLeadTag`/`renameLeadTag` (CRM-09)
|
|
- `/admin/leads` has a client-side instant search box filtering name/email/company/status/tags with zero server round-trips
|
|
- `/admin/leads/[id]` (LeadDetail "Profilo" card) also shows and allows editing lead tags via the same `OptionMultiSelect` + actions
|
|
- `npx tsc --noEmit` and `npx eslint` both exit 0
|
|
</success_criteria>
|
|
|
|
<output>
|
|
After completion, create `.planning/phases/14-crm-attio-style-fix/14-02-SUMMARY.md`
|
|
</output>
|