Files
clienthub/.planning/phases/19-pipeline-crm-kanban/19-RESEARCH.md
T
simone 86e1499e8f feat: Quiet Luxury design system + Pipeline (ex-Lead) redesign
- 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>
2026-07-10 16:16:44 +02:00

434 lines
23 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 (12801440px). 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 883942 — `LeadWithTags` type, `getLeadsWithTags()` query (all fields), `LeadFieldOptions`
- `src/db/schema.ts` lines 441462 — `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)