Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
24 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 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 19-pipeline-crm-kanban | 01 | execute | 1 |
|
true |
|
|
Purpose: Completare PIPE-01 (board drag-drop per stage) e PIPE-02 (vinto/perso come cambio-colonna manuale) senza toccare il data layer e senza rimuovere la LeadTable esistente.
Output:
LeadsKanbanBoard.tsx— nuovo componente client con @dnd-kit drag-drop tra 6 colonne stageLeadsViewToggle.tsx— nuovo wrapper client con pill toggle Lista/KanbanLeadsSearch.tsx— modificato per ospitare il toggle e passarefilteredleads a entrambe le vistepage.tsx— modificato per rimuovere il render diretto di<LeadsSearch>e invece renderizzare<LeadsViewToggle>
<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_context>
@.planning/PROJECT.md @.planning/ROADMAP.md @.planning/phases/19-pipeline-crm-kanban/19-RESEARCH.mdDa src/lib/admin-queries.ts (VERIFIED):
// LeadWithTags = leads row + tags array
export type LeadWithTags = {
id: string;
name: string;
email: string | null;
phone: string | null;
company: string | null;
status: string; // one of LEAD_STAGES values
next_action: string | null;
created_at: Date;
updated_at: Date;
tags: string[];
};
export type LeadFieldOptions = {
tags: string[];
};
Da src/lib/lead-validators.ts (VERIFIED):
export const LEAD_STAGES = [
"contacted",
"qualified",
"proposal_sent",
"negotiating",
"won",
"lost",
] as const;
export type LeadStage = typeof LEAD_STAGES[number];
Da src/app/admin/leads/actions.ts (VERIFIED, line 174):
export async function updateLeadField(
leadId: string,
fieldName: "name" | "email" | "phone" | "company" | "status" | "next_action",
value: string
): Promise<void>
// Valida: status deve essere in LEAD_STAGES; throws su valore invalido
// Side effects: revalidatePath("/admin/leads") + revalidatePath(`/admin/leads/${leadId}`)
Da src/components/admin/kanban/KanbanBoard.tsx (analog esatto, VERIFIED):
// Pattern da replicare per LeadsKanbanBoard:
// - useState<Record<string, Status>>() per ottimistic update
// - DndContext con sensors (PointerSensor distance:5 + KeyboardSensor)
// - onDragStart: setActiveId(e.active.id as string)
// - onDragEnd: setActiveId(null); if (!over) return; guard su valore valido;
// setTaskStatuses(...); startTransition(async () => { await action; router.refresh(); })
// - DroppableColumn: useDroppable({ id }) → setNodeRef, isOver
// - DraggableCard: useDraggable({ id }) → setNodeRef, transform, isDragging, listeners, attributes
// - DragOverlay dropAnimation={null} con ghost card
Da src/components/admin/kanban/PhasesViewToggle.tsx (analog esatto, VERIFIED):
// Toggle pill pattern:
// className pill: "flex items-center gap-1 mb-5 bg-[#f4f4f5] rounded-lg p-1 w-fit"
// Active button: "bg-white text-[#1A463C] shadow-sm"
// Inactive button: "text-[#71717a] hover:text-[#1a1a1a]"
// Testo: "Lista" / "Kanban"
Da src/components/admin/leads/LeadTable.tsx (VERIFIED, line 20):
// STAGE_COLOR — reusare per headerClass/dotClass nella board
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",
};
Da src/app/admin/leads/LeadsSearch.tsx (VERIFIED — STATO ATTUALE):
// Attualmente: search input + LeadTable(filtered, options)
// Dopo questa fase: search input + LeadsViewToggle (che gestisce il rendering di LeadTable o LeadsKanbanBoard)
// Alternativa più pulita: LeadsSearch mantiene la search, ma il toggle vive in LeadsViewToggle
// Approccio scelto (vedi task 1): LeadsSearch riceve `view` + `onViewChange` da LeadsViewToggle
<read_first> - src/components/admin/kanban/KanbanBoard.tsx — analog esatto da replicare strutturalmente - src/lib/lead-validators.ts — LEAD_STAGES canonical values - src/app/admin/leads/actions.ts — firma updateLeadField (già letta, riportata in interfaces) </read_first>
Creare `src/components/admin/leads/LeadsKanbanBoard.tsx` come componente client. Seguire la struttura esatta di `KanbanBoard.tsx`, adattata per i lead.Struttura richiesta:
-
"use client"in cima. -
Definire
type LeadStageeLEAD_COLUMNScome costante di modulo (NON importare LEAD_STAGES da lead-validators — definire il tipo locale per evitare dipendenze circolari con il barrel del server):
type LeadStage = "contacted" | "qualified" | "proposal_sent" | "negotiating" | "won" | "lost";
const LEAD_COLUMNS: {
id: LeadStage;
label: string;
headerClass: string;
dotClass: string;
}[] = [
{ id: "contacted", label: "Contattato", headerClass: "text-[#71717a]", dotClass: "bg-[#d4d4d8]" },
{ id: "qualified", label: "Qualificato", headerClass: "text-purple-700", dotClass: "bg-purple-400" },
{ id: "proposal_sent", label: "Offerta inviata", headerClass: "text-amber-700", dotClass: "bg-amber-400" },
{ id: "negotiating", label: "Trattativa", headerClass: "text-orange-700", dotClass: "bg-orange-400" },
{ id: "won", label: "Vinto", headerClass: "text-green-700", dotClass: "bg-green-500" },
{ id: "lost", label: "Perso", headerClass: "text-red-700", dotClass: "bg-red-400" },
];
-
Componente
DroppableColumn(analogo alDroppableColumndi KanbanBoard.tsx):- Props:
{ id: LeadStage; label: string; headerClass: string; dotClass: string; leads: LeadWithTags[]; activeId: string | null } const { setNodeRef, isOver } = useDroppable({ id })- Border color isOver:
border-[#1A463C] bg-[#1A463C]/5, default:border-[#e5e7eb] bg-[#f9f9f9] - Header pill count badge identico al modello
- Empty state:
<p className="text-xs text-[#d4d4d8] italic text-center py-10 select-none">Nessun lead</p>
- Props:
-
Componente
DraggableLeadCard(analogo aDraggableCarddi KanbanBoard.tsx):- Props:
{ lead: LeadWithTags; isActive: boolean } const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({ id: lead.id })- Contenuto card: riga primaria
lead.name(text-sm font-medium text-[#1a1a1a]), riga secondarialead.companyse presente (text-xs text-[#71717a]), riga hintlead.next_actionse presente (text-xs text-[#71717a] mt-1 line-clamp-1) - Stili drag: opacity-30 quando isDragging, hover:border-[#1A463C]/40 quando non in drag
- Props:
-
Componente esportato
LeadsKanbanBoard:- Props:
{ leads: LeadWithTags[] } useState<Record<string, LeadStage>>inizializzato daleads.map(l => [l.id, l.status as LeadStage])useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 5 } }), useSensor(KeyboardSensor))leadsByStage:LEAD_COLUMNS.reduceche raggruppa leads per stage corrente (leggeleadStatuses[l.id] ?? l.status)handleDragEnd:setActiveId(null)→ guardif (!over) return→ guardif (!(LEAD_COLUMNS.map(c => c.id) as string[]).includes(over.id as string)) return→ guard stesso stage → ottimisticsetLeadStatuses→startTransition(async () => { await updateLeadField(leadId, "status", newStage); router.refresh(); })- Layout: wrapper
overflow-x-auto→ grigliagrid grid-cols-6 gap-3 min-w-[1080px](6 colonne × 180px min) DragOverlay dropAnimation={null}: ghost card conborder-2 border-[#1A463C] shadow-xl rotate-1, mostranameecompanydel lead attivo
- Props:
-
Imports richiesti:
import { useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import {
DndContext, DragEndEvent, DragOverlay,
PointerSensor, KeyboardSensor, useSensor, useSensors,
useDroppable, useDraggable,
} from "@dnd-kit/core";
import { updateLeadField } from "@/app/admin/leads/actions";
import type { LeadWithTags } from "@/lib/admin-queries";
<acceptance_criteria>
- LeadsKanbanBoard.tsx esiste in src/components/admin/leads/
- Contiene "use client" alla prima riga
- Contiene export function LeadsKanbanBoard(
- Contiene useDroppable( e useDraggable(
- Contiene DragOverlay
- Contiene updateLeadField(leadId, "status", newStage)
- Contiene LEAD_COLUMNS con tutti e 6 gli stage: contacted, qualified, proposal_sent, negotiating, won, lost
- Contiene overflow-x-auto nel wrapper della griglia
- Contiene grid-cols-6
- Contiene startTransition
- npx tsc --noEmit non emette errori su questo file (verificabile dopo task 3)
</acceptance_criteria>
<read_first> - src/components/admin/kanban/PhasesViewToggle.tsx — analog esatto del pill toggle (già letto) - src/app/admin/leads/LeadsSearch.tsx — stato attuale (già letto) </read_first>
**Approccio scelto (Pitfall 5 dalla research):** Il toggle e la search vivono insieme in modo che la ricerca filtra entrambe le viste. Si implementa così:File 1 — src/components/admin/leads/LeadsViewToggle.tsx (NUOVO):
LeadsViewToggle è il nuovo client wrapper che:
- Contiene
useState<"list" | "kanban">("list") - Contiene
useState("")per la query di ricerca - Calcola
filteredconuseMemo(stessa logica di LeadsSearch attuale: filtra per name/email/company/status/tags) - Renderizza:
- Barra superiore: search input (a sinistra) + pill toggle Lista/Kanban (a destra), su una singola riga
flex justify-between items-center mb-4 - Se
view === "list":<LeadTable leads={filtered} options={options} /> - Se
view === "kanban":<LeadsKanbanBoard leads={filtered} />
- Barra superiore: search input (a sinistra) + pill toggle Lista/Kanban (a destra), su una singola riga
"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 { LeadsKanbanBoard } from "@/components/admin/leads/LeadsKanbanBoard";
import type { LeadWithTags, LeadFieldOptions } from "@/lib/admin-queries";
export function LeadsViewToggle({
leads,
options,
}: {
leads: LeadWithTags[];
options: LeadFieldOptions;
}) {
const [view, setView] = useState<"list" | "kanban">("list");
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="flex items-center justify-between gap-4">
{/* Search */}
<div className="relative max-w-sm flex-1">
<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>
{/* Toggle pill */}
<div className="flex items-center gap-1 bg-[#f4f4f5] rounded-lg p-1 w-fit flex-shrink-0">
<button
onClick={() => setView("list")}
className={`px-3 py-1.5 rounded-md text-xs font-semibold transition-all ${
view === "list"
? "bg-white text-[#1A463C] shadow-sm"
: "text-[#71717a] hover:text-[#1a1a1a]"
}`}
>
Lista
</button>
<button
onClick={() => setView("kanban")}
className={`px-3 py-1.5 rounded-md text-xs font-semibold transition-all ${
view === "kanban"
? "bg-white text-[#1A463C] shadow-sm"
: "text-[#71717a] hover:text-[#1a1a1a]"
}`}
>
Kanban
</button>
</div>
</div>
{view === "list" ? (
<LeadTable leads={filtered} options={options} />
) : (
<LeadsKanbanBoard leads={filtered} />
)}
</div>
);
}
File 2 — src/app/admin/leads/LeadsSearch.tsx (MODIFICATO):
LeadsSearch non è più necessario: la sua logica (search + filtered + LeadTable) è stata trasferita in LeadsViewToggle. Svuotare il file sostituendo il contenuto con un re-export di LeadsViewToggle per non rompere eventuali import esistenti:
// LeadsSearch is superseded by LeadsViewToggle (Phase 19).
// Re-exported here to avoid breaking any existing import.
export { LeadsViewToggle as LeadsSearch } from "@/components/admin/leads/LeadsViewToggle";
Nota: se page.tsx verrà aggiornato nel task 3 ad importare direttamente LeadsViewToggle, questo re-export è solo una rete di sicurezza e non causa conflitti.
<acceptance_criteria>
- src/components/admin/leads/LeadsViewToggle.tsx esiste
- Contiene "use client"
- Contiene useState<"list" | "kanban">("list")
- Contiene useState("") per la query
- Contiene useMemo( per filtered
- Contiene LeadsKanbanBoard importato da @/components/admin/leads/LeadsKanbanBoard
- Contiene LeadTable importato da @/components/admin/leads/LeadTable
- Contiene export function LeadsViewToggle(
- Contiene il pill toggle con classi bg-[#f4f4f5] rounded-lg p-1
- src/app/admin/leads/LeadsSearch.tsx contiene LeadsViewToggle as LeadsSearch
</acceptance_criteria>
<read_first> - src/app/admin/leads/page.tsx — stato attuale (già letto: 23 righe, renderizza LeadsSearch) </read_first>
Aggiornare `src/app/admin/leads/page.tsx` per usare `LeadsViewToggle` al posto di `LeadsSearch`.Stato attuale:
import { LeadsSearch } from "./LeadsSearch";
// ...
<LeadsSearch leads={leads} options={options} />
Nuovo stato — sostituire l'import e il render:
import { LeadsViewToggle } from "@/components/admin/leads/LeadsViewToggle";
// ...
<LeadsViewToggle leads={leads} options={options} />
Il file completo aggiornato:
import { getLeadsWithTags, getLeadFieldOptions } from "@/lib/admin-queries";
import { LeadsViewToggle } from "@/components/admin/leads/LeadsViewToggle";
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>
<LeadsViewToggle leads={leads} options={options} />
</div>
);
}
Dopo aver scritto il file, eseguire il build per verificare zero errori TypeScript:
cd /Users/simonecavalli/Vault/IAMCAVALLI && npx next build 2>&1 | tail -30
Se il build fallisce per errori TypeScript, correggerli prima di considerare il task completo.
grep -n "LeadsViewToggle\|LeadsSearch" /Users/simonecavalli/Vault/IAMCAVALLI/src/app/admin/leads/page.tsx<acceptance_criteria>
- page.tsx importa LeadsViewToggle da @/components/admin/leads/LeadsViewToggle
- page.tsx NON importa più LeadsSearch direttamente (o se lo importa, è solo tramite il re-export che risolve in LeadsViewToggle)
- page.tsx renderizza <LeadsViewToggle leads={leads} options={options} />
- npx next build completa senza errori TypeScript (warning accettabili, errori no)
</acceptance_criteria>
<threat_model>
Trust Boundaries
| Boundary | Description |
|---|---|
| Browser → Server Action | handleDragEnd invia (leadId, "status", newStage) via updateLeadField; newStage arriva dal DOM (over.id) |
STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|---|---|---|---|---|
| T-19-01 | Tampering | handleDragEnd — over.id come colonna di destinazione |
mitigate | Guard client-side: if (!(LEAD_COLUMNS.map(c => c.id) as string[]).includes(over.id as string)) return prima di chiamare updateLeadField. Guard server-side: updateLeadField valida già LEAD_STAGES.includes(value) e lancia errore su valore invalido (Phase 14 pattern invariato). |
| T-19-02 | Elevation of Privilege | updateLeadField server action |
accept | requireAdmin() già presente nell'azione (Phase 14); il kanban chiama la stessa azione della tabella inline-edit, stessa protezione. |
| </threat_model> |
# 1. Build pulito
cd /Users/simonecavalli/Vault/IAMCAVALLI && npx next build 2>&1 | grep -E "error|Error|✓ Compiled"
# 2. File creati
ls /Users/simonecavalli/Vault/IAMCAVALLI/src/components/admin/leads/LeadsKanbanBoard.tsx
ls /Users/simonecavalli/Vault/IAMCAVALLI/src/components/admin/leads/LeadsViewToggle.tsx
# 3. Struttura corretta LeadsKanbanBoard
grep -c "useDroppable\|useDraggable\|DragOverlay\|updateLeadField" /Users/simonecavalli/Vault/IAMCAVALLI/src/components/admin/leads/LeadsKanbanBoard.tsx
# 4. Tutti e 6 gli stage nella board
grep -v '^//' /Users/simonecavalli/Vault/IAMCAVALLI/src/components/admin/leads/LeadsKanbanBoard.tsx | grep -c '"contacted"\|"qualified"\|"proposal_sent"\|"negotiating"\|"won"\|"lost"'
# 5. Toggle presente in LeadsViewToggle
grep -c 'useState<"list" | "kanban">' /Users/simonecavalli/Vault/IAMCAVALLI/src/components/admin/leads/LeadsViewToggle.tsx
# 6. page.tsx non importa più LeadsSearch direttamente
grep "LeadsSearch" /Users/simonecavalli/Vault/IAMCAVALLI/src/app/admin/leads/page.tsx | wc -l
# deve essere 0
<success_criteria>
PIPE-01: La board Kanban con 6 colonne (contacted → lost) è visibile a /admin/leads dopo aver cliccato "Kanban". Drag-drop aggiorna leads.status in modo persistente (il lead rimane nella nuova colonna dopo refresh).
PIPE-02: Spostare un lead nella colonna "Vinto" o "Perso" è il gesto manuale che registra l'esito — nessun modale, nessuna conferma, solo il drag-drop su quella colonna.
Retrocompatibilità: La vista Lista (LeadTable con inline edit) resta invariata e accessibile tramite il toggle "Lista". </success_criteria>
Dopo il completamento, creare `.planning/phases/19-pipeline-crm-kanban/19-01-SUMMARY.md` seguendo il template in `@$HOME/.claude/get-shit-done/templates/summary.md`.