# Phase 19: Pipeline CRM Kanban — Research **Researched:** 2026-06-19 **Domain:** @dnd-kit drag-drop, Next.js App Router client components, CRM leads view toggle **Confidence:** HIGH — all findings verified directly from codebase --- ## Phase Requirements | ID | Description | Research Support | |----|-------------|------------------| | PIPE-01 | I lead sono visualizzabili in una board Kanban stile Pipedrive con colonne per stage e drag-drop per cambiare stage | `leads.status` enum verified (6 stages); `@dnd-kit/core` v6.3.1 already installed; exact analog in `KanbanBoard.tsx` | | PIPE-02 | Spostare un lead nelle colonne "Vinto"/"Perso" è il cambio-stato manuale dell'esito | `won`/`lost` are existing LEAD_STAGES values; `updateLeadField(id, "status", value)` handles this today via the table dropdown | --- ## Summary Phase 14 delivered a complete inline-edit table view of leads (`LeadTable.tsx`) backed by a solid data layer: `getLeadsWithTags()`, `updateLeadField()`, typed `LEAD_STAGES`, and a polymorphic tag system. Phase 19 adds a second view — Kanban — toggled from the same page, without replacing or changing any of that. The project already ships a working `KanbanBoard.tsx` (for project tasks) that uses exactly the `@dnd-kit` primitives needed here. The new `LeadsKanbanBoard` is a direct structural analog: swap task status columns (`todo/in_progress/done`) for lead stage columns (`contacted/qualified/proposal_sent/negotiating/won/lost`), swap task cards for lead cards, swap `updateTaskStatus` for `updateLeadField(id, "status", newStage)`. The view-toggle pattern is also ready in `PhasesViewToggle.tsx` — a client component that holds `useState<"list" | "kanban">` and renders either `listView` (a `ReactNode` passed as prop) or the kanban. The leads page only needs a `LeadsViewToggle` wrapper that receives the existing `LeadsSearch` as the `listView` slot and the new `LeadsKanbanBoard` as the kanban. No schema changes. No new server actions. No new dependencies. This is a pure UI addition. **Primary recommendation:** Copy the `KanbanBoard.tsx` structure exactly; adapt for 6 lead-stage columns; wire to `updateLeadField`; wrap with a `LeadsViewToggle` component in `LeadsSearch` or at the page level. --- ## Architectural Responsibility Map | Capability | Primary Tier | Secondary Tier | Rationale | |------------|-------------|----------------|-----------| | Kanban board rendering + drag state | Browser / Client | — | Drag-drop is inherently client-side; `"use client"` required | | Lead status persistence on drop | API / Backend (Server Action) | — | `updateLeadField` is already a `"use server"` action | | Lead data fetching | Frontend Server (SSR) | — | `LeadsPage` is a server component; passes data down as props | | View toggle state (table / kanban) | Browser / Client | — | `useState` in a client wrapper component | | Column definitions (stage labels, colors) | Browser / Client | — | Derived from `LEAD_STAGES` constant, purely presentational | --- ## Standard Stack ### Core (already installed — no new installs needed) | Library | Version | Purpose | Why Standard | |---------|---------|---------|--------------| | @dnd-kit/core | ^6.3.1 [VERIFIED: package.json] | DndContext, useDraggable, useDroppable, sensors | Already used in KanbanBoard.tsx | | @dnd-kit/sortable | ^10.0.0 [VERIFIED: package.json] | Available but NOT used by existing KanbanBoard | Not needed; existing pattern uses useDraggable + useDroppable directly | | @dnd-kit/utilities | ^3.2.2 [VERIFIED: package.json] | CSS.Transform helper | Imported if transform style needed | | React (useTransition, useState) | via Next.js 16 | Optimistic state + async server action bridging | Project pattern | **Installation:** None required. All dependencies already present. ### Existing Primitives Used by KanbanBoard.tsx [VERIFIED: src/components/admin/kanban/KanbanBoard.tsx] ```typescript import { DndContext, // Root context — wraps the entire board DragEndEvent, // Event type for onDragEnd handler DragOverlay, // Ghost card rendered at cursor during drag PointerSensor, // Mouse/touch activation KeyboardSensor, // Accessibility useSensor, useSensors, useDroppable, // Applied to column containers useDraggable, // Applied to individual cards } from "@dnd-kit/core"; ``` Note: `@dnd-kit/sortable` / `SortableContext` / `useSortable` are NOT used. The existing pattern uses the lower-level `useDraggable` + `useDroppable` primitives, which is appropriate for cross-column drag (not intra-column reordering). --- ## Architecture Patterns ### System Architecture Diagram ``` LeadsPage (server component) ├─ getLeadsWithTags() ──────────────────────────────► Postgres / leads + tags ├─ getLeadFieldOptions() ───────────────────────────► Postgres / tags └─ renders LeadsViewToggle (client component) ├─ [view="list"] → LeadsSearch → LeadTable (existing, unchanged) └─ [view="kanban"] → LeadsKanbanBoard (new) ├─ DndContext (onDragEnd → updateLeadField server action) ├─ DroppableColumn × 6 (one per LEAD_STAGES value) └─ DraggableLeadCard × N (one per lead) └─ useTransition + router.refresh() (after persist) ``` ### Recommended File Structure ``` src/ ├─ components/admin/leads/ │ ├─ LeadTable.tsx # EXISTING — unchanged │ └─ LeadsKanbanBoard.tsx # NEW — analogous to KanbanBoard.tsx ├─ app/admin/leads/ │ ├─ page.tsx # MODIFIED — wrap with LeadsViewToggle │ ├─ LeadsSearch.tsx # MODIFIED — receives view toggle or replaced by LeadsViewToggle │ └─ actions.ts # EXISTING — updateLeadField already handles status changes ``` The view toggle can live either at the page level (simpler) or inside `LeadsSearch` (keeps search state alive across views). Recommended: extract a `LeadsViewToggle` client wrapper at the page level (same pattern as `PhasesViewToggle`), passing `` as the `listView` ReactNode and `` as the kanban. ### Pattern 1: Column definition for 6 lead stages ```typescript // Source: VERIFIED from src/lib/lead-validators.ts + src/components/admin/leads/LeadTable.tsx 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-[#1A463C]", 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" }, ]; ``` ### Pattern 2: Lead Kanban Board (adapted from KanbanBoard.tsx) ```typescript // Source: VERIFIED structure from src/components/admin/kanban/KanbanBoard.tsx // Key adaptation: replace taskStatuses/updateTaskStatus with leadStatuses/updateLeadField "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"; export function LeadsKanbanBoard({ leads }: { leads: LeadWithTags[] }) { const router = useRouter(); const [, startTransition] = useTransition(); const [activeId, setActiveId] = useState(null); const [leadStatuses, setLeadStatuses] = useState>( () => Object.fromEntries(leads.map((l) => [l.id, l.status as LeadStage])) ); const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 5 } }), useSensor(KeyboardSensor) ); 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; if (newStage === leadStatuses[leadId]) return; // Optimistic update setLeadStatuses((prev) => ({ ...prev, [leadId]: newStage })); // Persist startTransition(async () => { await updateLeadField(leadId, "status", newStage); router.refresh(); }); } // ... render DndContext with LEAD_COLUMNS mapped to DroppableColumn } ``` ### Pattern 3: View toggle (adapted from PhasesViewToggle.tsx) ```typescript // Source: VERIFIED from src/components/admin/kanban/PhasesViewToggle.tsx "use client"; import { useState, type ReactNode } from "react"; import { LeadsKanbanBoard } from "@/components/admin/leads/LeadsKanbanBoard"; import type { LeadWithTags, LeadFieldOptions } from "@/lib/admin-queries"; export function LeadsViewToggle({ listView, leads, }: { listView: ReactNode; leads: LeadWithTags[]; }) { const [view, setView] = useState<"list" | "kanban">("list"); return (
{/* Toggle buttons — same pill pattern as PhasesViewToggle */} {view === "list" ? listView : }
); } ``` ### Pattern 4: Lead card content Each Kanban card should show: `name` (primary), `company` (secondary/optional), `next_action` (hint text, optional). Avoid showing `email`/`phone`/`tags` on the card to keep it compact — these are available in the table view. ```typescript // Fields available on LeadWithTags (VERIFIED: src/lib/admin-queries.ts line 890) // Lead & { tags: string[] } // Relevant for card: name, company, next_action, status ``` ### Anti-Patterns to Avoid - **Using `useSortable` / `SortableContext`:** The existing project pattern does NOT use these. They are for intra-column reordering. Use `useDraggable` + `useDroppable` to match the established `KanbanBoard.tsx` pattern. - **Calling `router.refresh()` before `await updateLeadField`:** Always await the server action first, then refresh. The existing KanbanBoard does this correctly inside `startTransition`. - **Dropping `react-hook-form` / Zod on drag-drop:** No form validation needed for a status change — `updateLeadField` already validates via `LEAD_STAGES.includes(value)` check. - **Removing `LeadsSearch` / `LeadTable`:** PIPE-01 requires the table to remain as an alternative view. Do not replace it. --- ## Don't Hand-Roll | Problem | Don't Build | Use Instead | Why | |---------|-------------|-------------|-----| | Drag detection (distance threshold) | Custom mouse event tracking | `PointerSensor` with `activationConstraint: { distance: 5 }` | Already proven in KanbanBoard.tsx; prevents accidental drag on click | | Keyboard accessibility for drag | Custom key handlers | `KeyboardSensor` from @dnd-kit/core | a11y for free | | Drag ghost/overlay | CSS clone positioning | `DragOverlay` from @dnd-kit/core | Correct portal rendering, no z-index fights | | Optimistic UI update | Complex local state with rollback | `useState` + `useTransition` (React pattern) | Already used in KanbanBoard.tsx and LeadTable.tsx | | Status validation | Re-implementing LEAD_STAGES check | `updateLeadField` server action already validates status | DRY — the action throws on invalid stage | **Key insight:** The entire drag-drop + persist pattern is already implemented and tested in `KanbanBoard.tsx`. This phase is a structural copy with domain adaptation, not a new implementation. --- ## Common Pitfalls ### Pitfall 1: Columns wider than viewport on 6-stage board **What goes wrong:** 6 columns in `grid-cols-6` become too narrow on typical laptop screens (1280–1440px). The 3-column project kanban uses `grid-cols-3` with comfortable card width. **Why it happens:** 6 × min-width ≈ 720px+ is tight. **How to avoid:** Use `grid-cols-3 lg:grid-cols-6` or a horizontally scrollable container (`overflow-x-auto` on the grid wrapper). Alternatively, `min-w-[200px]` per column inside a scroll container. **Warning signs:** Cards truncate before the lead name is visible. ### Pitfall 2: Won/Lost columns need visual distinction **What goes wrong:** Dropping to "won" or "lost" looks identical to other columns — user may not notice the semantic weight of these terminal states. **Why it happens:** Uniform column styling. **How to avoid:** Use visually distinct `headerClass` (green for won, red for lost) and consider a stronger `isOver` highlight for these columns. The STAGE_COLOR map in `LeadTable.tsx` already defines these colors — reuse them. ### Pitfall 3: Leads not sorted consistently between views **What goes wrong:** Table shows leads ordered by `updated_at DESC`; kanban derived from the same array shows different visual order depending on column grouping. **Why it happens:** No explicit sort on the kanban card order within a column. **How to avoid:** Sort leads within each column by `updated_at DESC` (same as the existing query order). The `getLeadsWithTags` query already returns `orderBy(desc(leads.updated_at))` so inheriting that order is sufficient. ### Pitfall 4: `router.refresh()` causes full re-mount of kanban **What goes wrong:** After a drag-drop, `router.refresh()` rehydrates the server component, re-running `getLeadsWithTags()`. If the drag animation hasn't completed, it can cause a visual flicker. **Why it happens:** Next.js App Router refresh re-renders the whole tree. **How to avoid:** The existing `KanbanBoard.tsx` uses the same pattern without issue. The `setActiveId(null)` call in `handleDragEnd` clears the overlay before the refresh arrives, so the flicker is acceptable. This is the project's established pattern — do not deviate. ### Pitfall 5: Search/filter not available in kanban view **What goes wrong:** The search bar lives in `LeadsSearch.tsx` and only filters `LeadTable`. If the user switches to kanban, they lose the ability to filter. **Why it happens:** The view toggle renders either `LeadsSearch` (with its internal state) or the bare `LeadsKanbanBoard`. **How to avoid:** Two acceptable approaches: (a) wrap both views together inside `LeadsSearch` and pass filtered leads to both (preferred — search state persists across view switches); or (b) accept that kanban shows all leads unfiltered (simpler, acceptable for now given the single-user context). Document the choice in the plan. --- ## Code Examples ### Existing updateLeadField signature (server action) ```typescript // Source: VERIFIED from src/app/admin/leads/actions.ts line 174 // EDITABLE_FIELDS includes "status" — drag-drop can call this directly export async function updateLeadField( leadId: string, fieldName: "name" | "email" | "phone" | "company" | "status" | "next_action", value: string ): Promise // Validates: status must be in LEAD_STAGES; throws on invalid value // Side effects: revalidatePath("/admin/leads") + revalidatePath(`/admin/leads/${leadId}`) ``` ### LEAD_STAGES canonical values ```typescript // Source: VERIFIED from src/lib/lead-validators.ts line 4 export const LEAD_STAGES = [ "contacted", "qualified", "proposal_sent", "negotiating", "won", "lost", ] as const; ``` ### Existing STAGE_COLOR map (reuse for kanban column headers) ```typescript // Source: VERIFIED from src/components/admin/leads/LeadTable.tsx line 20 const STAGE_COLOR: Record = { 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", }; // Move to a shared constant (e.g., src/lib/lead-constants.ts) if reused in both components ``` ### PhasesViewToggle pattern (exact analog) ```typescript // Source: VERIFIED from src/components/admin/kanban/PhasesViewToggle.tsx // State: useState<"list" | "kanban">("list") // Toggle: pill button group (bg-[#f4f4f5] rounded-lg p-1 w-fit) // Active: bg-white text-[#1A463C] shadow-sm // Inactive: text-[#71717a] hover:text-[#1a1a1a] ``` --- ## State of the Art | Old Approach | Current Approach | When Changed | Impact | |--------------|------------------|--------------|--------| | Lead status change via modal form | Inline dropdown in table cell (`StatusCell`) | Phase 14 | Drag-drop is the third mechanism; all write to same `updateLeadField` action | | Separate `/admin/analytics` route | Fused into `/admin` dashboard | Phase 18 | No impact on leads page | | `SendQuoteModal` with dead branch | Dead branch removed | Phase 18 | No impact | --- ## Project Constraints (from CLAUDE.md) | Directive | Impact on This Phase | |-----------|---------------------| | `clients.token` = rotatable, never PK | Not relevant (leads have no token) | | `quote_items` never exposed via client API | Not relevant (Kanban is admin-only) | | `deliverables.approved_at` immutable once set | Not relevant | | Auth: `/admin/*` → Auth.js session | Kanban lives at `/admin/leads` — already protected | | No file hosting v1 | Not relevant | | Migration safety: never drop/truncate rows | Phase 19 is UI-only — no schema changes, no migration needed | | Security: confirm before destructive commands | No destructive operations | | No package installs without showing name+version | No new packages needed | --- ## Environment Availability Step 2.6: SKIPPED — Phase 19 is a pure UI addition. All required libraries (`@dnd-kit/core`, `@dnd-kit/sortable`, `@dnd-kit/utilities`) are already installed. No external services, databases (beyond the existing Neon Postgres connection), or CLI tools are needed. --- ## Validation Architecture `nyquist_validation: false` in `.planning/config.json` — section omitted per config. --- ## Security Domain Phase 19 adds a new interaction path to an existing admin-only route (`/admin/leads`). No new auth surface is introduced. | ASVS Category | Applies | Standard Control | |---------------|---------|-----------------| | V2 Authentication | yes (existing) | Auth.js session via `requireAdmin()` in server action | | V4 Access Control | yes (existing) | `requireAdmin()` guard in `updateLeadField` — drag-drop calls same action | | V5 Input Validation | yes | `updateLeadField` validates status via `LEAD_STAGES.includes(value)` — no new validation needed | No new threat surface beyond what Phase 14 already addressed. The drag-drop `handleDragEnd` validates the `over.id` is a known stage before calling the server action — follow the same guard pattern as in `KanbanBoard.tsx` (line 190: `if (!(["todo", "in_progress", "done"] as string[]).includes(newStatus)) return;`). --- ## Assumptions Log | # | Claim | Section | Risk if Wrong | |---|-------|---------|---------------| | A1 | The `won` and `lost` columns are terminal states with no special side-effects beyond setting `leads.status` (no auto-creation of client/project, no email trigger) | Architecture Patterns | If the plan later requires auto-provisioning on "won", a new server action will be needed — but PIPE-01/02 say nothing about this, and PROP-04 (auto-provisioning) is deferred to backlog post-R5 | | A2 | Card content (name, company, next_action) is sufficient for the kanban view; no additional fields are needed per card | Code Examples | If the user wants tags or email visible on cards, the `LeadWithTags` type already provides them — no data-layer change, only card template change | | A3 | The search filter covering only the table view (not the kanban) is acceptable for v1 of this feature | Common Pitfalls | If the user wants search in kanban too, the fix is to lift filtered state into the toggle wrapper — straightforward but adds scope | --- ## Open Questions 1. **Search/filter scope in kanban view** - What we know: `LeadsSearch` holds the search `useState` and passes `filtered` leads to `LeadTable`. The kanban would receive all leads from the page. - What's unclear: Does the user want the search bar to filter the kanban board too, or is it acceptable that kanban shows all leads? - Recommendation: Default to wrapping both views inside a new `LeadsViewToggle` that receives `leads` (unfiltered) and `options`, manages the view toggle, and passes `filtered` leads to both `LeadTable` and `LeadsKanbanBoard`. This is a clean pattern and handles it gracefully. 2. **Column layout: scroll vs. wrap on 6 columns** - What we know: The existing kanban uses `grid-cols-3`. Six columns need more space. - What's unclear: Target viewport is unknown (likely 1440px+ since this is a single-admin tool). - Recommendation: Use `min-w-[180px]` per column inside an `overflow-x-auto` wrapper. This makes it work on any viewport without content truncation. --- ## Sources ### Primary (HIGH confidence) - `src/components/admin/kanban/KanbanBoard.tsx` — exact @dnd-kit usage pattern, drag primitives, sensors, DragOverlay, optimistic update + router.refresh() - `src/components/admin/kanban/PhasesViewToggle.tsx` — view toggle pattern (list/kanban state, pill button UI) - `src/components/admin/leads/LeadTable.tsx` — STAGE_COLOR map, LeadWithTags usage, StatusCell inline dropdown - `src/app/admin/leads/actions.ts` — `updateLeadField` signature, EDITABLE_FIELDS, `requireAdmin()` guard - `src/lib/lead-validators.ts` — canonical LEAD_STAGES array (6 values) - `src/lib/admin-queries.ts` lines 883–942 — `LeadWithTags` type, `getLeadsWithTags()` query (all fields), `LeadFieldOptions` - `src/db/schema.ts` lines 441–462 — `leads` table definition, `status` column with all 6 stage values documented - `src/app/admin/leads/LeadsSearch.tsx` — search filter pattern, `LeadWithTags` + `LeadFieldOptions` prop interface - `src/app/admin/leads/page.tsx` — server component structure, data fetching pattern, `revalidate = 0` - `src/components/admin/AdminSidebar.tsx` — `/admin/leads` is already in NAV_ITEMS, no sidebar change needed - `package.json` — @dnd-kit/core ^6.3.1, @dnd-kit/sortable ^10.0.0, @dnd-kit/utilities ^3.2.2 ### Secondary (MEDIUM confidence) - `.planning/config.json` — `nyquist_validation: false` confirmed --- ## Metadata **Confidence breakdown:** - Standard stack: HIGH — all packages verified in package.json; exact primitives verified in KanbanBoard.tsx - Architecture: HIGH — all patterns verified from existing codebase analogs - Pitfalls: HIGH — derived from direct code inspection and known Next.js App Router behaviors - Data layer: HIGH — schema, actions, and query functions all read directly **Research date:** 2026-06-19 **Valid until:** Stable indefinitely (no external dependencies; codebase-derived findings)