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

23 KiB
Raw Blame History

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)
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. 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)

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

  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.tsupdateLeadField 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.jsonnyquist_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)