Files
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

443 lines
21 KiB
Markdown
Raw Permalink 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: 14
slug: crm-attio-style-fix
status: approved
shadcn_initialized: true
preset: "default (neutral base color)"
created: "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)
```typescript
// 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`
```typescript
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
- [x] Dimension 1 Copywriting: PASS
- [x] 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)")
- [x] Dimension 3 Color: PASS
- [x] Dimension 4 Typography: PASS
- [x] Dimension 5 Spacing: PASS
- [x] 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 |