- Design system foundations: Plus Jakarta Sans font, shadow-card/radius tokens in @theme, design-reference/DESIGN-SYSTEM.md with component inventory - Collapsible admin shell (AdminShell): smooth w-64<->w-20 sidebar, new top header with "Admin" + avatar placeholder, localStorage-persisted state - Rename route /admin/leads -> /admin/pipeline (redirect stubs preserved), nav label Lead -> Pipeline; DB table unchanged - Reusable primitives: StatusBadge, SearchInput, SegmentedToggle (dual-theme) - Luxury restyle of lead table, kanban (6 stages + @dnd-kit intact), PageHeader - Tokenize editable-cell / option-multi-select for dark-mode legibility Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
23 KiB
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]
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
// 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)
// 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)
// 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.
// 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. UseuseDraggable+useDroppableto match the establishedKanbanBoard.tsxpattern. - Calling
router.refresh()beforeawait updateLeadField: Always await the server action first, then refresh. The existing KanbanBoard does this correctly insidestartTransition. - Dropping
react-hook-form/ Zod on drag-drop: No form validation needed for a status change —updateLeadFieldalready validates viaLEAD_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)
// 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
// 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)
// 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)
// 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
-
Search/filter scope in kanban view
- What we know:
LeadsSearchholds the searchuseStateand passesfilteredleads toLeadTable. 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
LeadsViewTogglethat receivesleads(unfiltered) andoptions, manages the view toggle, and passesfilteredleads to bothLeadTableandLeadsKanbanBoard. This is a clean pattern and handles it gracefully.
- What we know:
-
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 anoverflow-x-autowrapper. This makes it work on any viewport without content truncation.
- What we know: The existing kanban uses
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 dropdownsrc/app/admin/leads/actions.ts—updateLeadFieldsignature, EDITABLE_FIELDS,requireAdmin()guardsrc/lib/lead-validators.ts— canonical LEAD_STAGES array (6 values)src/lib/admin-queries.tslines 883–942 —LeadWithTagstype,getLeadsWithTags()query (all fields),LeadFieldOptionssrc/db/schema.tslines 441–462 —leadstable definition,statuscolumn with all 6 stage values documentedsrc/app/admin/leads/LeadsSearch.tsx— search filter pattern,LeadWithTags+LeadFieldOptionsprop interfacesrc/app/admin/leads/page.tsx— server component structure, data fetching pattern,revalidate = 0src/components/admin/AdminSidebar.tsx—/admin/leadsis already in NAV_ITEMS, no sidebar change neededpackage.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: falseconfirmed
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)