Compare commits
4 Commits
84c426e66c
...
34be9341bb
| Author | SHA1 | Date | |
|---|---|---|---|
| 34be9341bb | |||
| 607c2578f0 | |||
| 2c67e6fd72 | |||
| 5e4b2239cb |
@@ -176,7 +176,10 @@ Plans:
|
|||||||
1. I lead sono mostrati in colonne per stage (contacted → qualified → proposal_sent → negotiating → won/lost) con drag-drop che aggiorna `leads.status`
|
1. I lead sono mostrati in colonne per stage (contacted → qualified → proposal_sent → negotiating → won/lost) con drag-drop che aggiorna `leads.status`
|
||||||
2. La tabella inline-edit esistente resta disponibile come vista alternativa
|
2. La tabella inline-edit esistente resta disponibile come vista alternativa
|
||||||
3. Spostare un lead su "Vinto"/"Perso" è l'azione manuale che ne registra l'esito
|
3. Spostare un lead su "Vinto"/"Perso" è l'azione manuale che ne registra l'esito
|
||||||
**Status**: Pending planning — `/gsd-plan-phase 19`
|
**Plans**: 1 plan
|
||||||
|
**Plan list**:
|
||||||
|
- [ ] 19-01-PLAN.md — LeadsKanbanBoard + LeadsViewToggle + page wiring (PIPE-01, PIPE-02)
|
||||||
|
**Status**: Planned — ready for `/gsd-execute-phase 19`
|
||||||
|
|
||||||
### Phase 20: Knowledge Base Cliente
|
### Phase 20: Knowledge Base Cliente
|
||||||
**Goal**: Memorizzare i transcript datati delle call (multipli per lead/cliente) e mostrarli nel profilo, pronti per essere dati in pasto all'AI.
|
**Goal**: Memorizzare i transcript datati delle call (multipli per lead/cliente) e mostrarli nel profilo, pronti per essere dati in pasto all'AI.
|
||||||
|
|||||||
@@ -0,0 +1,553 @@
|
|||||||
|
---
|
||||||
|
phase: 19-pipeline-crm-kanban
|
||||||
|
plan: 01
|
||||||
|
type: execute
|
||||||
|
wave: 1
|
||||||
|
depends_on: []
|
||||||
|
files_modified:
|
||||||
|
- src/components/admin/leads/LeadsKanbanBoard.tsx
|
||||||
|
- src/components/admin/leads/LeadsViewToggle.tsx
|
||||||
|
- src/app/admin/leads/LeadsSearch.tsx
|
||||||
|
- src/app/admin/leads/page.tsx
|
||||||
|
autonomous: true
|
||||||
|
requirements:
|
||||||
|
- PIPE-01
|
||||||
|
- PIPE-02
|
||||||
|
|
||||||
|
must_haves:
|
||||||
|
truths:
|
||||||
|
- "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"
|
||||||
|
artifacts:
|
||||||
|
- path: "src/components/admin/leads/LeadsKanbanBoard.tsx"
|
||||||
|
provides: "Board Kanban con DndContext, 6 DroppableColumn, DraggableLeadCard, DragOverlay, ottimistic update"
|
||||||
|
exports: ["LeadsKanbanBoard"]
|
||||||
|
- path: "src/components/admin/leads/LeadsViewToggle.tsx"
|
||||||
|
provides: "Client wrapper con useState<'list' | 'kanban'> e pill toggle"
|
||||||
|
exports: ["LeadsViewToggle"]
|
||||||
|
- path: "src/app/admin/leads/LeadsSearch.tsx"
|
||||||
|
provides: "Search + toggle integrati; passa filtered leads sia a LeadTable che a LeadsKanbanBoard"
|
||||||
|
- path: "src/app/admin/leads/page.tsx"
|
||||||
|
provides: "Server component aggiornato che non renderizza più LeadsSearch direttamente ma LeadsViewToggle"
|
||||||
|
key_links:
|
||||||
|
- from: "LeadsKanbanBoard.tsx — handleDragEnd"
|
||||||
|
to: "src/app/admin/leads/actions.ts — updateLeadField"
|
||||||
|
via: "startTransition async + router.refresh()"
|
||||||
|
pattern: "updateLeadField\\(leadId.*status"
|
||||||
|
- from: "LeadsSearch.tsx — filtered"
|
||||||
|
to: "LeadsKanbanBoard — leads prop"
|
||||||
|
via: "filtered array passato come prop"
|
||||||
|
pattern: "LeadsKanbanBoard.*leads=\\{filtered\\}"
|
||||||
|
- from: "LeadsViewToggle / LeadsSearch"
|
||||||
|
to: "LeadTable + LeadsKanbanBoard"
|
||||||
|
via: "view state (list | kanban)"
|
||||||
|
pattern: "useState<.list.*kanban"
|
||||||
|
---
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
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>`
|
||||||
|
</objective>
|
||||||
|
|
||||||
|
<execution_context>
|
||||||
|
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
||||||
|
@$HOME/.claude/get-shit-done/templates/summary.md
|
||||||
|
</execution_context>
|
||||||
|
|
||||||
|
<context>
|
||||||
|
@.planning/PROJECT.md
|
||||||
|
@.planning/ROADMAP.md
|
||||||
|
@.planning/phases/19-pipeline-crm-kanban/19-RESEARCH.md
|
||||||
|
|
||||||
|
<interfaces>
|
||||||
|
<!-- Tipi e contratti estratti dal codebase. L'executor li usa direttamente — nessuna esplorazione necessaria. -->
|
||||||
|
|
||||||
|
Da src/lib/admin-queries.ts (VERIFIED):
|
||||||
|
```typescript
|
||||||
|
// 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):
|
||||||
|
```typescript
|
||||||
|
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):
|
||||||
|
```typescript
|
||||||
|
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):
|
||||||
|
```typescript
|
||||||
|
// 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):
|
||||||
|
```typescript
|
||||||
|
// 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):
|
||||||
|
```typescript
|
||||||
|
// 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):
|
||||||
|
```typescript
|
||||||
|
// 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
|
||||||
|
```
|
||||||
|
</interfaces>
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 1: LeadsKanbanBoard.tsx — board Kanban con drag-drop tra 6 stage</name>
|
||||||
|
<files>src/components/admin/leads/LeadsKanbanBoard.tsx</files>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<action>
|
||||||
|
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):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
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" },
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
3. 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>`
|
||||||
|
|
||||||
|
4. 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
|
||||||
|
|
||||||
|
5. 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 `setLeadStatuses` → `startTransition(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
|
||||||
|
|
||||||
|
6. Imports richiesti:
|
||||||
|
```typescript
|
||||||
|
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";
|
||||||
|
```
|
||||||
|
</action>
|
||||||
|
|
||||||
|
<verify>
|
||||||
|
<automated>grep -n "useDroppable\|useDraggable\|DragOverlay\|DndContext\|updateLeadField\|LeadsKanbanBoard" /Users/simonecavalli/Vault/IAMCAVALLI/src/components/admin/leads/LeadsKanbanBoard.tsx | head -20</automated>
|
||||||
|
</verify>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<done>
|
||||||
|
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.
|
||||||
|
</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 2: LeadsViewToggle.tsx — wrapper client con pill toggle Lista/Kanban + search integrata</name>
|
||||||
|
<files>
|
||||||
|
src/components/admin/leads/LeadsViewToggle.tsx
|
||||||
|
src/app/admin/leads/LeadsSearch.tsx
|
||||||
|
</files>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<action>
|
||||||
|
**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} />`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
"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:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 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.
|
||||||
|
</action>
|
||||||
|
|
||||||
|
<verify>
|
||||||
|
<automated>grep -n "useState\|LeadsKanbanBoard\|LeadTable\|LeadsViewToggle\|list.*kanban\|kanban.*list" /Users/simonecavalli/Vault/IAMCAVALLI/src/components/admin/leads/LeadsViewToggle.tsx | head -20</automated>
|
||||||
|
</verify>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<done>
|
||||||
|
LeadsViewToggle.tsx esiste con search integrata + toggle Lista/Kanban. LeadsSearch.tsx ri-esporta LeadsViewToggle per retrocompatibilità. La ricerca filtra entrambe le viste simultaneamente.
|
||||||
|
</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 3: page.tsx — cablaggio LeadsViewToggle + build check</name>
|
||||||
|
<files>src/app/admin/leads/page.tsx</files>
|
||||||
|
|
||||||
|
<read_first>
|
||||||
|
- src/app/admin/leads/page.tsx — stato attuale (già letto: 23 righe, renderizza LeadsSearch)
|
||||||
|
</read_first>
|
||||||
|
|
||||||
|
<action>
|
||||||
|
Aggiornare `src/app/admin/leads/page.tsx` per usare `LeadsViewToggle` al posto di `LeadsSearch`.
|
||||||
|
|
||||||
|
Stato attuale:
|
||||||
|
```typescript
|
||||||
|
import { LeadsSearch } from "./LeadsSearch";
|
||||||
|
// ...
|
||||||
|
<LeadsSearch leads={leads} options={options} />
|
||||||
|
```
|
||||||
|
|
||||||
|
Nuovo stato — sostituire l'import e il render:
|
||||||
|
```typescript
|
||||||
|
import { LeadsViewToggle } from "@/components/admin/leads/LeadsViewToggle";
|
||||||
|
// ...
|
||||||
|
<LeadsViewToggle leads={leads} options={options} />
|
||||||
|
```
|
||||||
|
|
||||||
|
Il file completo aggiornato:
|
||||||
|
```typescript
|
||||||
|
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:
|
||||||
|
```bash
|
||||||
|
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.
|
||||||
|
</action>
|
||||||
|
|
||||||
|
<verify>
|
||||||
|
<automated>grep -n "LeadsViewToggle\|LeadsSearch" /Users/simonecavalli/Vault/IAMCAVALLI/src/app/admin/leads/page.tsx</automated>
|
||||||
|
</verify>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<done>
|
||||||
|
page.tsx aggiornato. Build Next.js verde. La pagina /admin/leads mostra il toggle Lista/Kanban con la board drag-drop funzionante.
|
||||||
|
</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="checkpoint:human-verify" gate="blocking">
|
||||||
|
<what-built>
|
||||||
|
LeadsKanbanBoard con 6 colonne stage, drag-drop che persiste su updateLeadField, LeadsViewToggle con search integrata e pill toggle, page.tsx aggiornato.
|
||||||
|
</what-built>
|
||||||
|
<how-to-verify>
|
||||||
|
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
|
||||||
|
</how-to-verify>
|
||||||
|
<resume-signal>Digita "approvato" se tutto funziona, oppure descrivi i problemi riscontrati</resume-signal>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
## Verifica fase completa
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 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
|
||||||
|
```
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
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`.
|
||||||
|
</output>
|
||||||
@@ -1,46 +1,3 @@
|
|||||||
"use client";
|
// LeadsSearch is superseded by LeadsViewToggle (Phase 19).
|
||||||
|
// Re-exported here to avoid breaking any existing import.
|
||||||
import { useState, useMemo } from "react";
|
export { LeadsViewToggle as LeadsSearch } from "@/components/admin/leads/LeadsViewToggle";
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { getLeadsWithTags, getLeadFieldOptions } from "@/lib/admin-queries";
|
import { getLeadsWithTags, getLeadFieldOptions } from "@/lib/admin-queries";
|
||||||
import { LeadsSearch } from "./LeadsSearch";
|
import { LeadsViewToggle } from "@/components/admin/leads/LeadsViewToggle";
|
||||||
import { CreateLeadModal } from "@/components/admin/leads/LeadForm";
|
import { CreateLeadModal } from "@/components/admin/leads/LeadForm";
|
||||||
|
|
||||||
export const revalidate = 0;
|
export const revalidate = 0;
|
||||||
@@ -17,7 +17,7 @@ export default async function LeadsPage() {
|
|||||||
<CreateLeadModal />
|
<CreateLeadModal />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<LeadsSearch leads={leads} options={options} />
|
<LeadsViewToggle leads={leads} options={options} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,183 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
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";
|
||||||
|
|
||||||
|
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" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const VALID_STAGES = LEAD_COLUMNS.map((c) => c.id) as string[];
|
||||||
|
|
||||||
|
function DroppableColumn({
|
||||||
|
id, label, headerClass, dotClass, leads, activeId,
|
||||||
|
}: {
|
||||||
|
id: LeadStage;
|
||||||
|
label: string;
|
||||||
|
headerClass: string;
|
||||||
|
dotClass: string;
|
||||||
|
leads: LeadWithTags[];
|
||||||
|
activeId: string | null;
|
||||||
|
}) {
|
||||||
|
const { setNodeRef, isOver } = useDroppable({ id });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={setNodeRef}
|
||||||
|
className={`flex flex-col rounded-xl border-2 transition-colors ${
|
||||||
|
isOver ? "border-[#1A463C] bg-[#1A463C]/5" : "border-[#e5e7eb] bg-[#f9f9f9]"
|
||||||
|
} min-h-[240px]`}
|
||||||
|
>
|
||||||
|
<div className={`px-4 py-3 flex items-center justify-between ${headerClass}`}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className={`w-2 h-2 rounded-full ${dotClass}`} />
|
||||||
|
<span className="text-xs font-bold uppercase tracking-wider">{label}</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs font-semibold tabular-nums bg-white rounded-full px-2 py-0.5 border border-[#e5e7eb]">
|
||||||
|
{leads.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 p-3 space-y-2">
|
||||||
|
{leads.map((lead) => (
|
||||||
|
<DraggableLeadCard key={lead.id} lead={lead} isActive={activeId === lead.id} />
|
||||||
|
))}
|
||||||
|
{leads.length === 0 && (
|
||||||
|
<p className="text-xs text-[#d4d4d8] italic text-center py-10 select-none">
|
||||||
|
Nessun lead
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DraggableLeadCard({ lead, isActive }: { lead: LeadWithTags; isActive: boolean }) {
|
||||||
|
const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({ id: lead.id });
|
||||||
|
|
||||||
|
const style = transform
|
||||||
|
? { transform: `translate3d(${transform.x}px, ${transform.y}px, 0)` }
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={setNodeRef}
|
||||||
|
style={style}
|
||||||
|
{...listeners}
|
||||||
|
{...attributes}
|
||||||
|
className={`bg-white rounded-lg border border-[#e5e7eb] px-3 py-2.5 cursor-grab active:cursor-grabbing shadow-sm select-none transition-opacity ${
|
||||||
|
isDragging ? "opacity-30" : "hover:border-[#1A463C]/40 hover:shadow"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<p className="text-sm font-medium text-[#1a1a1a]">{lead.name}</p>
|
||||||
|
{lead.company && (
|
||||||
|
<p className="text-xs text-[#71717a]">{lead.company}</p>
|
||||||
|
)}
|
||||||
|
{lead.next_action && (
|
||||||
|
<p className="text-xs text-[#71717a] mt-1 line-clamp-1">{lead.next_action}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LeadsKanbanBoard({ leads }: { leads: LeadWithTags[] }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [, startTransition] = useTransition();
|
||||||
|
const [activeId, setActiveId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const [leadStatuses, setLeadStatuses] = useState<Record<string, LeadStage>>(() =>
|
||||||
|
Object.fromEntries(leads.map((l) => [l.id, l.status as LeadStage]))
|
||||||
|
);
|
||||||
|
|
||||||
|
const sensors = useSensors(
|
||||||
|
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
|
||||||
|
useSensor(KeyboardSensor)
|
||||||
|
);
|
||||||
|
|
||||||
|
const leadsByStage = LEAD_COLUMNS.reduce(
|
||||||
|
(acc, col) => {
|
||||||
|
acc[col.id] = leads.filter((l) => (leadStatuses[l.id] ?? l.status) === col.id);
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{} as Record<LeadStage, LeadWithTags[]>
|
||||||
|
);
|
||||||
|
|
||||||
|
const activeLead = activeId ? leads.find((l) => l.id === activeId) : null;
|
||||||
|
|
||||||
|
function handleDragEnd(event: DragEndEvent) {
|
||||||
|
const { active, over } = event;
|
||||||
|
setActiveId(null);
|
||||||
|
if (!over) return;
|
||||||
|
|
||||||
|
const leadId = active.id as string;
|
||||||
|
const newStage = over.id as LeadStage;
|
||||||
|
const currentStage = leadStatuses[leadId];
|
||||||
|
|
||||||
|
if (!VALID_STAGES.includes(newStage)) return;
|
||||||
|
if (newStage === currentStage) return;
|
||||||
|
|
||||||
|
setLeadStatuses((prev) => ({ ...prev, [leadId]: newStage }));
|
||||||
|
|
||||||
|
startTransition(async () => {
|
||||||
|
await updateLeadField(leadId, "status", newStage);
|
||||||
|
router.refresh();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DndContext
|
||||||
|
sensors={sensors}
|
||||||
|
onDragStart={(e) => setActiveId(e.active.id as string)}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<div className="grid grid-cols-6 gap-3 min-w-[1080px]">
|
||||||
|
{LEAD_COLUMNS.map((col) => (
|
||||||
|
<DroppableColumn
|
||||||
|
key={col.id}
|
||||||
|
{...col}
|
||||||
|
leads={leadsByStage[col.id]}
|
||||||
|
activeId={activeId}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DragOverlay dropAnimation={null}>
|
||||||
|
{activeLead && (
|
||||||
|
<div className="bg-white rounded-lg border-2 border-[#1A463C] px-3 py-2.5 shadow-xl rotate-1 pointer-events-none">
|
||||||
|
<p className="text-sm font-medium text-[#1a1a1a]">{activeLead.name}</p>
|
||||||
|
{activeLead.company && (
|
||||||
|
<p className="text-xs text-[#71717a]">{activeLead.company}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</DragOverlay>
|
||||||
|
</DndContext>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
"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">
|
||||||
|
<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>
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user