Files
clienthub/.planning/phases/19-pipeline-crm-kanban/19-01-PLAN.md
T

24 KiB
Raw Blame History

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
src/components/admin/leads/LeadsKanbanBoard.tsx
src/components/admin/leads/LeadsViewToggle.tsx
src/app/admin/leads/LeadsSearch.tsx
src/app/admin/leads/page.tsx
true
PIPE-01
PIPE-02
truths artifacts key_links
I lead sono visibili in una board Kanban con 6 colonne per stage (contacted, qualified, proposal_sent, negotiating, won, lost)
Trascinare un lead da una colonna a un'altra aggiorna leads.status in modo persistente
Spostare un lead nella colonna Vinto o Perso registra l'esito cambiando il campo status
La vista tabella inline-edit (LeadTable) resta disponibile e funzionante come vista alternativa
Il toggle Lista/Kanban è visibile sopra il contenuto e preserva lo stato della ricerca quando si cambia vista
path provides exports
src/components/admin/leads/LeadsKanbanBoard.tsx Board Kanban con DndContext, 6 DroppableColumn, DraggableLeadCard, DragOverlay, ottimistic update
LeadsKanbanBoard
path provides exports
src/components/admin/leads/LeadsViewToggle.tsx Client wrapper con useState<'list' | 'kanban'> e pill toggle
LeadsViewToggle
path provides
src/app/admin/leads/LeadsSearch.tsx Search + toggle integrati; passa filtered leads sia a LeadTable che a LeadsKanbanBoard
path provides
src/app/admin/leads/page.tsx Server component aggiornato che non renderizza più LeadsSearch direttamente ma LeadsViewToggle
from to via pattern
LeadsKanbanBoard.tsx — handleDragEnd src/app/admin/leads/actions.ts — updateLeadField startTransition async + router.refresh() updateLeadField(leadId.*status
from to via pattern
LeadsSearch.tsx — filtered LeadsKanbanBoard — leads prop filtered array passato come prop LeadsKanbanBoard.*leads={filtered}
from to via pattern
LeadsViewToggle / LeadsSearch LeadTable + LeadsKanbanBoard view state (list | kanban) useState<.list.*kanban
Aggiunge una vista Kanban stile Pipedrive alla pagina `/admin/leads`, affiancata alla tabella esistente via toggle Lista/Kanban.

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 stage
  • LeadsViewToggle.tsx — nuovo wrapper client con pill toggle Lista/Kanban
  • LeadsSearch.tsx — modificato per ospitare il toggle e passare filtered leads a entrambe le viste
  • page.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.md

Da 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
Task 1: LeadsKanbanBoard.tsx — board Kanban con drag-drop tra 6 stage src/components/admin/leads/LeadsKanbanBoard.tsx

<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:

  1. "use client" in cima.

  2. Definire type LeadStage e LEAD_COLUMNS come 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" },
];
  1. Componente DroppableColumn (analogo al DroppableColumn di 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>
  2. Componente DraggableLeadCard (analogo a DraggableCard di 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 secondaria lead.company se presente (text-xs text-[#71717a]), riga hint lead.next_action se presente (text-xs text-[#71717a] mt-1 line-clamp-1)
    • Stili drag: opacity-30 quando isDragging, hover:border-[#1A463C]/40 quando non in drag
  3. Componente esportato LeadsKanbanBoard:

    • Props: { leads: LeadWithTags[] }
    • useState<Record<string, LeadStage>> inizializzato da leads.map(l => [l.id, l.status as LeadStage])
    • useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 5 } }), useSensor(KeyboardSensor))
    • leadsByStage: LEAD_COLUMNS.reduce che raggruppa leads per stage corrente (legge leadStatuses[l.id] ?? l.status)
    • handleDragEnd: setActiveId(null) → guard if (!over) return → guard if (!(LEAD_COLUMNS.map(c => c.id) as string[]).includes(over.id as string)) return → guard stesso stage → ottimistic setLeadStatusesstartTransition(async () => { await updateLeadField(leadId, "status", newStage); router.refresh(); })
    • Layout: wrapper overflow-x-auto → griglia grid grid-cols-6 gap-3 min-w-[1080px] (6 colonne × 180px min)
    • DragOverlay dropAnimation={null}: ghost card con border-2 border-[#1A463C] shadow-xl rotate-1, mostra name e company del lead attivo
  4. 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";
grep -n "useDroppable\|useDraggable\|DragOverlay\|DndContext\|updateLeadField\|LeadsKanbanBoard" /Users/simonecavalli/Vault/IAMCAVALLI/src/components/admin/leads/LeadsKanbanBoard.tsx | head -20

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

LeadsKanbanBoard.tsx esiste, esporta LeadsKanbanBoard, implementa drag-drop tra 6 colonne lead stage via @dnd-kit primitives, persiste su updateLeadField con ottimistic update, mostra card con name/company/next_action. Task 2: LeadsViewToggle.tsx — wrapper client con pill toggle Lista/Kanban + search integrata src/components/admin/leads/LeadsViewToggle.tsx src/app/admin/leads/LeadsSearch.tsx

<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 filtered con useMemo (stessa logica di LeadsSearch attuale: filtra per name/email/company/status/tags)
  • Renderizza:
    1. Barra superiore: search input (a sinistra) + pill toggle Lista/Kanban (a destra), su una singola riga flex justify-between items-center mb-4
    2. Se view === "list": <LeadTable leads={filtered} options={options} />
    3. Se view === "kanban": <LeadsKanbanBoard leads={filtered} />
"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.

grep -n "useState\|LeadsKanbanBoard\|LeadTable\|LeadsViewToggle\|list.*kanban\|kanban.*list" /Users/simonecavalli/Vault/IAMCAVALLI/src/components/admin/leads/LeadsViewToggle.tsx | head -20

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

LeadsViewToggle.tsx esiste con search integrata + toggle Lista/Kanban. LeadsSearch.tsx ri-esporta LeadsViewToggle per retrocompatibilità. La ricerca filtra entrambe le viste simultaneamente. Task 3: page.tsx — cablaggio LeadsViewToggle + build check src/app/admin/leads/page.tsx

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

page.tsx aggiornato. Build Next.js verde. La pagina /admin/leads mostra il toggle Lista/Kanban con la board drag-drop funzionante. LeadsKanbanBoard con 6 colonne stage, drag-drop che persiste su updateLeadField, LeadsViewToggle con search integrata e pill toggle, page.tsx aggiornato. 1. Aprire http://localhost:3000/admin/leads (avviare il dev server con `npm run dev` se non già attivo) 2. Verificare che la pagina mostri: barra di ricerca a sinistra + pill toggle "Lista / Kanban" a destra 3. La vista Lista deve mostrare la tabella esistente (identica a prima della fase) 4. Cliccare "Kanban": deve apparire una board con 6 colonne (Contattato, Qualificato, Offerta inviata, Trattativa, Vinto, Perso) e i lead nelle colonne corrispondenti al loro stage 5. Trascinare un lead da una colonna a un'altra: la card deve spostarsi ottimisticamente; dopo pochi secondi la pagina si aggiorna e il lead rimane nella nuova colonna 6. Spostare un lead nella colonna "Vinto" o "Perso": verificare che il lead rimanga lì dopo il refresh 7. Digitare un nome nella barra di ricerca mentre si è in vista Kanban: la board deve filtrare le card in tempo reale 8. Tornare alla vista Lista: la ricerca deve ancora essere attiva con lo stesso testo Digita "approvato" se tutto funziona, oppure descrivi i problemi riscontrati

<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>
## Verifica fase completa
# 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`.