From 86e1499e8fabe9644db19a37421a5c4ed36ae592 Mon Sep 17 00:00:00 2001 From: Simone Cavalli Date: Fri, 10 Jul 2026 16:16:44 +0200 Subject: [PATCH] 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 --- .../19-pipeline-crm-kanban/19-RESEARCH.md | 433 ++++++++++++++++ design-reference/DESIGN-SYSTEM.md | 148 ++++++ design-reference/pagina-catalogo | 361 +++++++++++++ design-reference/pagina-clienti | 270 ++++++++++ design-reference/pagina-clientportal | 484 ++++++++++++++++++ design-reference/pagina-lead-pipeline | 405 +++++++++++++++ design-reference/pagina-offerte | 366 +++++++++++++ design-reference/pagina-preventivi | 238 +++++++++ src/app/admin/layout.tsx | 14 +- src/app/admin/leads/[id]/page.tsx | 46 +- src/app/admin/leads/page.tsx | 23 +- .../admin/{leads => pipeline}/LeadsSearch.tsx | 0 src/app/admin/pipeline/[id]/page.tsx | 40 ++ src/app/admin/{leads => pipeline}/actions.ts | 32 +- src/app/admin/pipeline/page.tsx | 24 + src/app/globals.css | 12 +- src/app/layout.tsx | 9 +- src/components/admin/AdminShell.tsx | 65 +++ src/components/admin/AdminSidebar.tsx | 41 +- src/components/admin/PageHeader.tsx | 6 +- .../admin/dashboard/FollowUpWidget.tsx | 6 +- src/components/admin/leads/LeadDetail.tsx | 2 +- src/components/admin/leads/LeadForm.tsx | 2 +- src/components/admin/leads/LeadTable.tsx | 91 ++-- .../admin/leads/LeadsKanbanBoard.tsx | 42 +- .../admin/leads/LeadsViewToggle.tsx | 53 +- .../admin/leads/LogActivityModal.tsx | 2 +- src/components/admin/leads/SendQuoteModal.tsx | 2 +- .../admin/leads/TranscriptModal.tsx | 2 +- src/components/ui/SearchInput.tsx | 26 + src/components/ui/SegmentedToggle.tsx | 45 ++ src/components/ui/StatusBadge.tsx | 53 ++ src/components/ui/editable-cell.tsx | 2 +- src/components/ui/option-multi-select.tsx | 20 +- 34 files changed, 3134 insertions(+), 231 deletions(-) create mode 100644 .planning/phases/19-pipeline-crm-kanban/19-RESEARCH.md create mode 100644 design-reference/DESIGN-SYSTEM.md create mode 100644 design-reference/pagina-catalogo create mode 100644 design-reference/pagina-clienti create mode 100644 design-reference/pagina-clientportal create mode 100644 design-reference/pagina-lead-pipeline create mode 100644 design-reference/pagina-offerte create mode 100644 design-reference/pagina-preventivi rename src/app/admin/{leads => pipeline}/LeadsSearch.tsx (100%) create mode 100644 src/app/admin/pipeline/[id]/page.tsx rename src/app/admin/{leads => pipeline}/actions.ts (93%) create mode 100644 src/app/admin/pipeline/page.tsx create mode 100644 src/components/admin/AdminShell.tsx create mode 100644 src/components/ui/SearchInput.tsx create mode 100644 src/components/ui/SegmentedToggle.tsx create mode 100644 src/components/ui/StatusBadge.tsx diff --git a/.planning/phases/19-pipeline-crm-kanban/19-RESEARCH.md b/.planning/phases/19-pipeline-crm-kanban/19-RESEARCH.md new file mode 100644 index 0000000..dfd796a --- /dev/null +++ b/.planning/phases/19-pipeline-crm-kanban/19-RESEARCH.md @@ -0,0 +1,433 @@ +# 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) diff --git a/design-reference/DESIGN-SYSTEM.md b/design-reference/DESIGN-SYSTEM.md new file mode 100644 index 0000000..af4633c --- /dev/null +++ b/design-reference/DESIGN-SYSTEM.md @@ -0,0 +1,148 @@ +# Design System: iamcavalli Admin & Client Portal (v1.0) + +> "Minimalist Premium / Quiet Luxury" — the aesthetic that replaces the old +> mixed token/hardcoded styling across ClientHub's admin area. First applied +> to the Lead Pipeline page (Phase: Pipeline redesign); the tokens and +> primitives documented here are the base for restyling every other section. + +## Philosophy + +- **Quiet, not loud.** Elevation is nearly invisible (shadow opacity ≤ 0.02), + borders are hairline, color is used sparingly and only to carry meaning + (status, semantics) — never for decoration. +- **Density with air.** Generous padding (`py-4 px-6` in tables, `p-4` in + kanban columns) paired with small type (`text-xs`, `text-[11px]`) reads as + premium rather than cramped. +- **Numbers are monospace.** Prices, phone numbers, dates, counts — anything + tabular/numeric — use `font-mono` and right-alignment so columns of digits + line up. +- **Dual-theme by construction.** Every surface is built from semantic + tokens (`bg-card`, `text-muted-foreground`, `border-border`, …), never raw + Tailwind palette classes (`bg-white`, `text-slate-900`, …) or hex literals. + This is what makes dark mode "just work" without a parallel dark stylesheet. +- **Motion is functional.** Transitions exist to explain state change (sidebar + collapse, hover, drag) — `duration-200`–`duration-350`, + `ease-[cubic-bezier(0.4,0,0.2,1)]` for the sidebar specifically. No + decorative animation. + +## Typography + +- **Sans**: Plus Jakarta Sans (weights 300–700), loaded via `next/font/google` + as `--font-plus-jakarta-sans`, mapped to Tailwind's `--font-sans` in + `@theme`. Used for all UI text. +- **Mono**: Geist Mono, `--font-geist-mono` → `--font-mono`. Used for numeric + data cells only (prices, phone numbers, counts, dates in tables). +- **Scale conventions**: + - Page title: `text-2xl font-semibold tracking-tight` + - Page subtitle: `text-xs text-muted-foreground` + - Table header cells: `text-[11px] font-semibold uppercase tracking-wider text-muted-foreground` + - Table body: `text-sm` + - Badge/pill label: `text-[10px]`–`text-xs font-semibold uppercase tracking-wide` + +## Color Tokens + +Raw values live in `:root` (light) / `.dark` (dark) in `src/app/globals.css` +and are mapped to Tailwind utilities via `@theme inline`. Components must +consume the mapped utility, never the raw hex or a Tailwind palette shade. + +| Utility | Light | Dark | Usage | +|---|---|---|---| +| `bg-background` / `text-foreground` | `#ffffff` / `#1a1a1a` | `#0e1512` / `#f2f4f3` | Page canvas | +| `bg-card` / `text-card-foreground` | `#ffffff` | `#131a16` | Cards, table, kanban cards, header bar | +| `bg-muted` / `text-muted-foreground` | `#f9f9f9` / `#71717a` | `#171f1b` / `#9aa39e` | Subtle backgrounds, secondary text, kanban columns | +| `bg-primary` / `text-primary-foreground` | `#1A463C` | `#3FA88C` | Primary actions, sidebar, active nav state | +| `bg-accent` | `#DEF168` | `#DEF168` | Rare highlight accent (lime) | +| `border-border` / `border-input` | `#e5e7eb` | `#26302b` | All hairline borders | +| `ring-ring` | `#1A463C` | `#3FA88C` | Focus rings | +| `bg-destructive` | `#ef4444` | `#f87171` | Destructive actions | + +**Sidebar exception**: the sidebar stays brand green `bg-[#1A463C]` in both +themes (it is not a themed surface — it's the constant brand anchor). + +**Status/semantic colors** (lead stages, badges) use Tailwind's default +palette (blue/purple/amber/orange/emerald/red) directly, each paired with an +explicit `dark:` variant for legibility on dark surfaces — see `StatusBadge`. + +## Elevation & Radius + +- `--radius: 0.5rem` (base) is wired into `@theme` as `--radius-lg` (`= + --radius`) and `--radius-xl` (`= --radius + 0.25rem`), so `rounded-lg` / + `rounded-xl` utilities resolve consistently off one token. +- `--shadow-card: 0 4px 20px rgba(0,0,0,0.01)` → `shadow-card` utility. + Default resting elevation for cards, table containers, kanban cards. +- `--shadow-card-hover: 0 8px 30px rgba(0,0,0,0.02)` → `shadow-card-hover` + utility. Applied on hover for interactive cards. + +## Layout Conventions + +- **Table container**: `bg-card rounded-xl border border-border shadow-card + overflow-hidden`. +- **Table header row**: `bg-muted/50 border-b border-border text-[11px] + uppercase tracking-wider text-muted-foreground`. +- **Table body rows**: `py-4 px-6`, `divide-y divide-border`, + `hover:bg-muted/40 transition-colors`. +- **Kanban column**: `bg-muted/60 rounded-xl border border-border p-4`. +- **Kanban card**: `bg-card rounded-lg border border-border shadow-sm + hover:border-primary/30 hover:shadow-card-hover transition-all`. +- **Page header**: title `text-2xl font-semibold tracking-tight + text-foreground`, subtitle `text-xs text-muted-foreground mt-1`, primary + action button `bg-primary text-primary-foreground hover:bg-primary/90`. + +## Color/Class Migration Map (dual-theme principle) + +When restyling any surface, replace hardcoded classes with token utilities: + +| Old (hardcoded) | New (token) | +|---|---| +| `bg-white` | `bg-card` | +| `bg-slate-50` / `bg-gray-50` | `bg-muted` | +| `border-slate-100` / `border-slate-200` | `border-border` | +| `text-slate-900` / `text-[#1a1a1a]` | `text-foreground` | +| `text-slate-500` / `text-slate-400` / `text-[#71717a]` | `text-muted-foreground` | +| `bg-brand-dark` / `text-[#1A463C]` (as action color) | `bg-primary` / `text-primary` | + +## Motion + +- **Sidebar collapse**: `transition-[width] duration-350 + ease-[cubic-bezier(0.4,0,0.2,1)]` on the `