# 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>
## 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 |
</phase_requirements>

---

## 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 `<LeadsSearch leads={leads} options={options} />` as the `listView` ReactNode and `<LeadsKanbanBoard leads={leads} />` 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<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)
  );

  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 (
    <div>
      {/* Toggle buttons — same pill pattern as PhasesViewToggle */}
      {view === "list" ? listView : <LeadsKanbanBoard leads={leads} />}
    </div>
  );
}
```

### 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<void>
// 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<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",
};
// 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)
