Files
clienthub/.planning/phases/14-crm-attio-style-fix/14-UI-SPEC.md
T
simone 89adf7a1d4 docs(14): UI design contract for CRM Attio-style redesign
UI-SPEC approved (5/6 PASS, 1 non-blocking flag on focal point).
Locks reuse of Phase 11 primitives (EditableCell, OptionSelect,
OptionMultiSelect) for the leads table, plus scope for the four
CRM bug fixes (i18n, type-safety, dead code).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 11:23:28 +02:00

21 KiB
Raw Blame History

phase, slug, status, shadcn_initialized, preset, created
phase slug status shadcn_initialized preset created
14 crm-attio-style-fix approved true default (neutral base color) 2026-06-13

Phase 14 — UI Design Contract

Visual and interaction contract for CRM table redesign (Attio-style) and bug fixes. Generated by gsd-ui-researcher, verified by gsd-ui-checker.


Design System

Property Value
Tool shadcn/ui
Preset default (neutral, baseColor: neutral)
Component library radix-ui (via shadcn)
Icon library lucide-react
Font Geist Sans (system fallback)

Source: components.json and src/app/globals.css (Phase 11, confirmed 2026-06-13)


Spacing Scale

Declared values (multiples of 4):

Token Value Usage
xs 4px Icon gaps, inline padding
sm 8px Compact element spacing
md 16px Default element spacing (py-2 px-3 ≈ 8px/12px)
lg 24px Section padding
xl 32px Layout gaps
2xl 48px Major section breaks
3xl 64px Page-level spacing

Exceptions: None — standard 4px-multiple scale

Implementation: Tailwind utility classes (py-2 px-3 for table cells, gap-1.5 for dropdown spacing, etc.). Reuse EditableCell, OptionSelect, OptionMultiSelect internal spacing without modification.


Typography

Role Size Weight Line Height
Body 16px (default) 400 (regular) 1.6
Label / Small 14px 400 (regular) 1.5
XSmall / Badge 12px 500 (medium, via font-medium) 1.4
Heading (page) 20px 600 (semibold, via font-semibold) 1.2

Source: Tailwind defaults + Phase 11 component internals (EditableCell: text-sm, OptionSelect/OptionMultiSelect: text-xs badge, text-sm button)

Details:

  • Body: 16px, weight 400, line-height 1.6 (per globals.css body)
  • Table cell labels (lead name, email, status): text-sm = 14px, weight 400
  • Badge labels (status stage, tags): text-xs = 12px, weight 500 (font-medium)
  • Placeholder text: text-[#71717a] (muted-foreground)
  • Error messages: text-xs text-red-600

Color

Role Value Usage
Dominant (60%) #ffffff (white) Page background, card backgrounds
Secondary (30%) #f4f4f5 (neutral-100) Hover states, subtle section backgrounds, table row hover
Accent (10%) #1A463C (primary, dark green) Button text, icon colors, ring/focus, checkbox accent
Destructive #ef4444 (red) Error messages, delete/dangerous action badges

Semantic colors (for lead status badges):

  • Contacted: blue (bg-blue-100 text-blue-800)
  • Qualified: purple (bg-purple-100 text-purple-800)
  • Proposal Sent: amber (bg-amber-100 text-amber-800)
  • Negotiating: orange (bg-orange-100 text-orange-800)
  • Won: green (bg-green-100 text-green-800)
  • Lost: red (bg-red-100 text-red-800)

Accent reserved for:

  • Form focus rings (input/textarea ring-primary)
  • Checkbox accent-[#1A463C]
  • Icon colors in OptionSelect/OptionMultiSelect (checkmarks, pencil, plus, X)
  • Primary button text
  • Selected state indicators (table row actions)

Notes:

  • Table row hover: hover:bg-[#f9f9f9] (Phase 11 ServiceTable pattern)
  • Border color: border-[#e5e7eb]
  • Input ring (unfocused): ring-1 ring-primary (#1A463C, 1px border)
  • Error ring: ring-2 ring-red-500 (2px on error state)
  • OptionSelect/OptionMultiSelect dropdown: white background, 1.5px shadow, 1px border

Copywriting Contract

Element Copy Context
Primary CTA "Salva" (implicit, on Enter key) Inline cell edit commit on Enter or blur
CTA — Add Lead "Nuovo Lead" CreateLeadModal button (unchanged from Phase 10)
CTA — Tag Create "Crea «{tag_name}»" OptionMultiSelect dropdown, create-on-type UX
CTA — Status Create N/A (fixed enum) Status uses OptionSelect but with fixed LEAD_STAGES, no create-on-the-fly
Empty state heading "Nessun lead trovato" LeadTable when leads.length === 0
Empty state body (no secondary message) Single-line empty state, matching ServiceTable pattern
Required field error "Campo richiesto" EditableCell when required field is empty on blur
Invalid status "Stato non valido" updateLeadField server action validation
Activity log "Attività registrata" (feedback on LogActivityModal submit) Unchanged from Phase 10
FollowUpWidget — (CRM-10, i18n) "Richiedi Follow-up" (heading), "lead/lead" (plural), "da contattare" (to contact), "Contatta" (button), "Vedi Tutto" (view all), "Tutti i lead sono aggiornati!" (no follow-ups) Dashboard widget, fully Italian

Destructive actions:

  • None explicit in Phase 14 scope (lead deletion already exists, not modified by CRM-08..12)
  • If a "delete lead" row action is added later, copy: "Elimina lead: Sei sicuro? Questa azione non può essere annullata."

Component Inventory

Reused from Phase 11 (no new imports needed)

Component Path Purpose in Phase 14
EditableCell src/components/ui/editable-cell.tsx Inline edit for lead name, email, phone, company, next_action
OptionSelect src/components/ui/option-select.tsx Lead status dropdown (fixed enum: LEAD_STAGES, 6 values, no create-on-the-fly)
OptionMultiSelect src/components/ui/option-multi-select.tsx Lead tags multi-select (CRM-09: create-on-the-fly enabled)
getOptionColor src/components/ui/option-colors.ts Badge color generation for tags (reused directly by OptionMultiSelect)
Badge src/components/ui/badge Display lead status & tags badges
Input src/components/ui/input Inline edit inputs, search box in new LeadsSearch component
Button src/components/ui/button Detail/action buttons (unchanged from Phase 10)

To Create / Modify

Component Path Change Scope
LeadTable src/components/admin/leads/LeadTable.tsx REWRITE — replace shadcn <Table> wrapper with raw <table>, add EditableCell + OptionSelect/OptionMultiSelect per row CRM-08, CRM-09
LeadsSearch src/app/admin/leads/LeadsSearch.tsx NEW — client-side search component (useMemo filter on name/email/company/status/tags), mirrors CatalogSearch.tsx CRM-08 UX (instant filter)
FollowUpWidget src/components/admin/dashboard/FollowUpWidget.tsx FIX (CRM-10) — translate 6 hardcoded English strings to Italian CRM-10
LeadForm src/components/admin/leads/LeadForm.tsx FIX (CRM-11) — remove useForm<any>, use Zod-inferred CreateLeadInput/UpdateLeadInput types, remove as any casts CRM-11
SendQuoteModal src/components/admin/leads/SendQuoteModal.tsx FIX (CRM-12) — remove unreachable if (tab === "new") branch from onSubmit CRM-12

Table Layout — LeadTable (CRM-08, CRM-09)

Visual Pattern (from Phase 11 ServiceTable)

┌─ Lead Table ─────────────────────────────────────────────────────────────────┐
│ Nome              │ Email           │ Stato     │ Prossima Azione   │ Tag    │
├───────────────────┼─────────────────┼───────────┼───────────────────┼────────┤
│ [EditableCell]    │ [EditableCell]  │ [Status]  │ [EditableCell]    │ [Tags] │
│ hover: bg-#f9f9f9 │                 │           │                   │        │
│ border-b #e5e7eb  │                 │           │                   │        │
├───────────────────┼─────────────────┼───────────┼───────────────────┼────────┤
│ ...               │ ...             │ ...       │ ...               │ ...    │
└───────────────────┴─────────────────┴───────────┴───────────────────┴────────┘

Column Structure

Column Type Editable Required Cell Component Min-Width Notes
Nome text Yes Yes EditableCell(type="text") 160px Lead name, bold, linked to detail page
Email text Yes No EditableCell(type="text") 160px Nullable, show "—" if empty
Telefono text Yes No EditableCell(type="text") 140px Nullable, show "—" if empty
Azienda text Yes No EditableCell(type="text") 140px Nullable, show "—" if empty
Stato enum Yes Yes OptionSelect(options=LEAD_STAGES, no create) 120px 6 fixed values: contacted, qualified, proposal_sent, negotiating, won, lost
Prossima Azione text Yes No EditableCell(type="text") 180px Nullable, show "—" if empty
Tag multi-select Yes No OptionMultiSelect(create-on-the-fly) 180px CRM-09: lead tags, polymorphic table (entity_type="leads")
Azioni link No N/A Link → /admin/leads/[id] 80px "Dettagli" button, unchanged from Phase 10

Row States

  • Normal: white background, border-b #e5e7eb, text #1a1a1a
  • Hover: bg-#f9f9f9, smooth 150ms transition
  • Cell edit (active): EditableCell in input mode — ring-1 ring-primary, h-8 for text inputs
  • Cell error: ring-2 ring-red-500, error message text-xs text-red-600 below cell
  • Row with error: shows error row spanning all columns, text-xs text-red-600

Search/Filter (CRM-08 UX — "Attio-style instant")

  • New component: LeadsSearch (client-side wrapper)
  • Input: search box at top of page (before table)
  • Behavior: useMemo filter on keystroke, no server round-trip
  • Filter fields: name, email, company, status label, tag names (all case-insensitive, includes-search)
  • Display: filtered leads in table below; if no results, empty state "Nessun lead trovato"
  • Placeholder: "Cerca lead..." (consistent with CatalogSearch style)

Bug Fixes

CRM-10: FollowUpWidget Internationalization

Current state: 6 hardcoded English strings in JSX.

Strings to translate:

  1. "Require Follow-up" → "Richiedi Follow-up"
  2. "lead" / "leads" (noun, "X lead to contact") → "lead" / "lead" (Italian stays same, but handle plural: "X lead da contattare" / "X lead da contattare")
  3. "to contact" → "da contattare"
  4. "Contact" (button label) → "Contatta"
  5. "View All" → "Vedi Tutto"
  6. "All leads are up to date!" → "Tutti i lead sono aggiornati!"

Style: Tone matches existing LeadDetail.tsx/LeadTable.tsx (semi-formal, second-person friendly).

Location: src/components/admin/dashboard/FollowUpWidget.tsx JSX only (no data-layer strings confirmed).

CRM-11: LeadForm Type Safety (react-hook-form)

Current pattern: useForm<any>(), field.value as any, data: any in onSubmit.

Fix pattern:

  • Import CreateLeadInput / UpdateLeadInput from src/lib/lead-validators.ts (Zod-inferred types)
  • Change useForm<any>()useForm<CreateLeadInput>() (or UpdateLeadInput for edit modal)
  • Replace as any casts with type-safe narrowing at schema boundary (e.g., lead.status as CreateLeadInput["status"])
  • Remove blanket data: any in onSubmit callback signature
  • Nullable fields: use field.value || "" pattern (already established elsewhere in the app)

No schema changes required — existing createLeadSchema/updateLeadSchema are correct; this is purely a type-safety fix in the component.

CRM-12: SendQuoteModal Unreachable Code

Current problem: if (tab === "new") branch inside onSubmit (form submit handler) is unreachable because the "new" tab has its own standalone button with onClick handler that does a redirect, never submitting the form.

Fix:

  • Remove the unreachable if (tab === "new") branch from onSubmit handler
  • Optionally simplify onSubmit to only handle the "existing quote" path (tab === "existing")
  • Confirm generate_new field in schema/defaultValues is also dead code (currently always false, unused in action)

Scope: Code cleanup only; no UX change to "new" tab button (still does redirect as-is).


Server Actions (Backend Contract)

New Actions Required (CRM-08, CRM-09)

File: src/app/admin/leads/actions.ts (extend existing)

// Pattern 1: Field update with per-field validation
export async function updateLeadField(
  leadId: string,
  fieldName: EditableField,
  value: string
): Promise<void> {
  // Validates fieldName against EDITABLE_FIELDS allowlist
  // Applies field-specific validation (name required, status in LEAD_STAGES, etc.)
  // Calls db.update() via Drizzle ORM
  // Calls revalidatePath("/admin/leads") and revalidatePath(`/admin/leads/${leadId}`)
}

// Pattern 2: Tag CRUD (polymorphic tags table)
export async function addLeadTag(leadId: string, value: string): Promise<void>
export async function removeLeadTag(leadId: string, value: string): Promise<void>
export async function renameLeadTag(oldValue: string, newValue: string): Promise<void>
// All three call requireAdmin() at the top
// Use tags table with entity_type="leads", entity_id=leadId
// Call revalidatePath("/admin/leads") after each mutation

Auth guard: All new actions must include await requireAdmin() check (per Phase 11 convention in catalog/actions.ts).

Query Functions (CRM-08 data fetch)

File: src/lib/admin-queries.ts (extend) or new src/lib/lead-service.ts

export type LeadWithTags = Lead & { tags: string[] };
export type LeadFieldOptions = { status: string[]; tags: string[] };

export async function getLeadsWithTags(): Promise<LeadWithTags[]>
  // Left-join leads + tags table (entity_type="leads")
  // Map-reduce to attach tags array per lead
  // Return sorted by updated_at DESC, tags ASC

export async function getLeadFieldOptions(): Promise<LeadFieldOptions>
  // Return status: LEAD_STAGES (fixed enum)
  // Return tags: distinct tag names for entity_type="leads", sorted by locale "it"

Interaction Flows

Flow 1: Inline Edit Lead Field (CRM-08)

User clicks cell
  ↓
EditableCell → input mode (focus + select text)
  ↓
User types + presses Enter (or loses focus)
  ↓
EditableCell.commit() → calls onSave callback
  ↓
Callback runs server action updateLeadField(leadId, fieldName, value)
  ↓
Server action validates, updates DB, revalidates paths
  ↓
Router.refresh() re-renders page with new data
  ↓
EditableCell exits edit mode, shows new value

Error handling: If server action throws, error message displays below cell (text-xs text-red-600). User can retry by clicking cell again.

Flow 2: Multi-Select Lead Tags (CRM-09)

User clicks tag cell
  ↓
OptionMultiSelect → dropdown opens, shows pill list + search input
  ↓
User types tag name (or selects existing)
  ↓
If new: shows "Crea «{name}»" button
  ↓
User presses Enter or clicks button
  ↓
Calls onAdd callback → server action addLeadTag(leadId, tagName)
  ↓
Server action inserts into tags table (entity_type="leads")
  ↓
Router.refresh() + revalidate paths
  ↓
OptionMultiSelect pill list updates with new tag (+ remove button)

Remove tag: User clicks × button on pill → calls onRemove callback → removeLeadTag action.

Rename tag: User clicks pencil icon on option → inline input → commitRename → renameLeadTag action (propagates to all leads with this tag).

Flow 3: Client-Side Search (CRM-08 UX)

User types in search box (LeadsSearch component)
  ↓
useState(query) triggers useMemo filter
  ↓
Filter includes: name, email, company, status, tags (all case-insensitive)
  ↓
Filtered leads array updates
  ↓
LeadTable re-renders with filtered rows
  ↓
No server call, instant response

Clear search: User clears input → all leads shown again.

Empty results: LeadTable displays "Nessun lead trovato" empty state.


Registry Safety

Registry Blocks Used Status
shadcn official Badge, Input, Button, Table (deprecated in new LeadTable), Textarea, Table import removal no vetting required
third-party none N/A

Notes:

  • Phase 14 removes the shadcn Table wrapper from LeadTable (switches to raw <table> for consistency with Phase 11 ServiceTable pattern)
  • No new third-party registries introduced
  • All components reused from Phase 11 or existing shadcn official library

Checker Sign-Off

  • Dimension 1 Copywriting: PASS
  • Dimension 2 Visuals: FLAG — no explicit focal point declared for the LeadsSearch + LeadTable screen (non-blocking; recommend "Primary focal point: search input bar at top; secondary: lead name column (bold, linked to detail)")
  • Dimension 3 Color: PASS
  • Dimension 4 Typography: PASS
  • Dimension 5 Spacing: PASS
  • Dimension 6 Registry Safety: PASS

Approval: APPROVED (2026-06-14) — 5/6 PASS, 1 non-blocking FLAG (Visuals/focal point)


Design Decisions & Rationale

Decision 1: Status Field — OptionSelect with Fixed LEAD_STAGES (not extensible)

What: Lead status uses OptionSelect component but with options={LEAD_STAGES} (6 fixed values: contacted, qualified, proposal_sent, negotiating, won, lost) and no create-on-the-fly UX.

Why: status is a closed enum, not an open-ended Notion-style property like tags. Allowing users to create arbitrary status values would break pipeline logic, STAGE_COLOR lookups, and LEAD_STAGES-based filters elsewhere (LeadDetail.tsx, FollowUpWidget, queries). OptionSelect is reused for interaction consistency with the tags column (click-to-open dropdown, keyboard nav, rename support), but the input validation (Pattern 2) enforces that only LEAD_STAGES values are accepted server-side, regardless of client-side UI.

Semantic colors preserved: Badge colors for status still use STAGE_COLOR map (blue/purple/amber/orange/green/red), not getOptionColor()'s hash-based pastels, to maintain pipeline-stage meaning.

Decision 2: LeadTable Layout — Raw <table> not shadcn <Table> Wrapper

What: Phase 14 LeadTable uses raw HTML <table>/<tr>/<td> elements with Tailwind classes, exactly like Phase 11 ServiceTable.

Why:

  1. Visual consistency: Phase 11 established the "database-view" pattern (ServiceTable) as the standard for inline-editable Attio-style tables; replicating it for leads ensures visual/interaction coherence.
  2. Component reuse: EditableCell/OptionSelect/OptionMultiSelect are already styled for raw-table cells (py-2 px-3, min-w-*, inline spacing); wrapping them in shadcn <Table> wrapper components adds unnecessary nesting.
  3. Simplicity: Raw table is easier to customize row-by-row (per-row error messages, inline edit state transitions, per-row hover) than the shadcn wrapper's structured slots.

Decision 3: No Lead Quick-Add Row (CRM-08 scope)

What: Phase 14 does NOT include a QuickAddRow equivalent for leads (Phase 11 added it for services).

Why: CRM-08 requirement says "inline editing dei campi principali" (existing leads) + CRM-09 says "assegna tag" (existing leads). Neither mentions lead creation UX. The existing CreateLeadModal is the lead-creation entry point and remains unchanged. Adding a quick-add row would be a nice-to-have (Attio/Pipedrive parity), but it is explicitly out of scope for Phase 14 per CRM-08..12.

If user asks for it: Flag as a follow-up enhancement, separate from Phase 14 planning.

Decision 4: LeadDetail.tsx Tags Display (Claude's Discretion)

What: Whether LeadDetail.tsx (the /admin/leads/[id] detail page) should surface the new lead tags in a read-only or editable section.

Current assumption: Tags are managed and visible in the table only (CRM-09 doesn't explicitly require detail-page display).

Recommendation: Low-effort addition — if planner agrees, add an OptionMultiSelect for tags in LeadDetail's "Profilo" card (same data layer as table). If planner says no, leave LeadDetail as-is.

Decision 5: FollowUpWidget Plural Handling (CRM-10)

What: The English phrase "X lead to contact" vs. "X leads to contact" — Italian equivalent: "X lead da contattare" (both singular and plural use "lead").

Implementation: Use template string: {count} lead da contattare (always "lead", never "leads" in Italian, since "lead" is a loanword with Italian plural also "lead").


Open Questions Resolved

Question Resolution Rationale
Should status use OptionSelect or closed-enum dropdown? OptionSelect + fixed LEAD_STAGES, no create-on-the-fly Consistency with table interaction pattern; server-side validation ensures only valid statuses enter DB
Include quick-add row for new leads? No — out of scope for CRM-08..12 CRM requirements focus on editing existing leads; CreateLeadModal unchanged
Surface tags in LeadDetail.tsx? Claude's discretion (recommend yes, low-effort) Add OptionMultiSelect in detail card if planner approves; keep table as primary interface if not
Retrofit pre-existing lead actions with requireAdmin()? No — out of scope for Phase 14 New actions (CRM-08/09) include requireAdmin(); pre-existing gap noted for user awareness, not in this phase's plan