CRM App New
A complete CRM workspace with people and company tables, a deal pipeline, record pages with a timeline, tasks, notes, workflows, a dashboard and a command menu.
Installation
01
Install with the shadcn CLI. The NiceUI theme comes along the first time.
$ npx shadcn@latest add https://niceui.dev/r/apps-crm-app.json02
Or copy the source below (11 files). Dependencies: lucide-react.
Source
components/niceui/pages/apps/crm/CrmApp.tsx
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { ArrowDownUp, ChevronDown, Columns3, Download, Filter, Kanban, List, Menu as MenuIcon, MoreHorizontal, Plus, Search, Star, Trash2, Upload, X } from 'lucide-react';
import { cn } from '@/lib/cn';
import { COMPANIES, MEMBERS, ME, NOTES, OPPORTUNITIES, PEOPLE, TASKS, type Company, type Note, type Opportunity, type Person, type Stage, type Target, type Task } from './data';
import { COMPANY_FIELDS, OPP_FIELDS, PEOPLE_FIELDS, type Field } from './fields';
import { KanbanView } from './KanbanView';
import { RecordPanel } from './RecordPanel';
import { CommandMenu } from './CommandMenu';
import { OBJECTS, Sidebar } from './Sidebar';
import { CrmContext, sameTarget, type Activity, type Crm, type ObjectKey } from './store';
import { TableView, type Sort } from './TableView';
import { DashboardView, NotesView, TasksView, WorkflowsView } from './Views';
import { Avatar, Logo, Menu, MenuItem, THEMES, btn, btnPrimary, colorFor } from './ui';
type Rule = { id: string; label: string; test: (r: Record<string, unknown>) => boolean };
type ViewDef = { id: string; label: string; rules: Rule[] };
const RULES: Record<'people' | 'companies' | 'opportunities', Rule[]> = {
people: [
{ id: 'cust', label: 'Lifecycle is Customer', test: (r) => r.lifecycle === 'Customer' },
{ id: 'lead', label: 'Lifecycle is Lead', test: (r) => r.lifecycle === 'Lead' },
{ id: 'hot', label: 'Intent ≥ 4', test: (r) => Number(r.intent) >= 4 },
{ id: 'mine', label: 'Owner is me', test: (r) => r.owner === ME.id },
],
companies: [
{ id: 'saas', label: 'Industry is SaaS', test: (r) => r.industry === 'SaaS' },
{ id: 'fin', label: 'Industry is Fintech', test: (r) => r.industry === 'Fintech' },
{ id: 'icp', label: 'Ideal customer is Yes', test: (r) => Boolean(r.icp) },
{ id: 'big', label: 'ARR ≥ $5M', test: (r) => Number(r.arr) >= 5_000_000 },
],
opportunities: [
{ id: 'prop', label: 'Stage is Proposal', test: (r) => r.stage === 'Proposal' },
{ id: 'big', label: 'Amount ≥ $100k', test: (r) => Number(r.amount) >= 100_000 },
{ id: 'mine', label: 'Owner is me', test: (r) => r.owner === ME.id },
{ id: 'soon', label: 'Close date before Dec 1', test: (r) => String(r.close) < '2026-12-01' },
],
};
const VIEWS: Record<'people' | 'companies' | 'opportunities', ViewDef[]> = {
people: [
{ id: 'all', label: 'All people', rules: [] },
{ id: 'mine', label: 'My contacts', rules: [RULES.people[3]] },
{ id: 'customers', label: 'Customers', rules: [RULES.people[0]] },
{ id: 'hot', label: 'Hot leads', rules: [RULES.people[2]] },
],
companies: [
{ id: 'all', label: 'All companies', rules: [] },
{ id: 'icp', label: 'Ideal customers', rules: [RULES.companies[2]] },
{ id: 'big', label: 'Enterprise accounts', rules: [RULES.companies[3]] },
],
opportunities: [
{ id: 'all', label: 'All opportunities', rules: [] },
{ id: 'mine', label: 'My deals', rules: [RULES.opportunities[2]] },
{ id: 'big', label: 'Big deals', rules: [RULES.opportunities[1]] },
],
};
type Toast = { id: number; msg: string; action?: { label: string; run: () => void } };
const isRecordObject = (o: ObjectKey): o is 'people' | 'companies' | 'opportunities' => o === 'people' || o === 'companies' || o === 'opportunities';
export function CrmApp(_: { onNavigate?: (to: string) => void }) {
const [theme, setTheme] = useState<'light' | 'dark'>('light');
const [obj, setObj] = useState<ObjectKey>('people');
const [people, setPeopleS] = useState<Person[]>(PEOPLE);
const [companies, setCompaniesS] = useState<Company[]>(COMPANIES);
const [opps, setOppsS] = useState<Opportunity[]>(OPPORTUNITIES);
const [tasks, setTasksS] = useState<Task[]>(TASKS);
const [notes, setNotesS] = useState<Note[]>(NOTES);
const [activity, setActivity] = useState<Record<string, Activity[]>>({});
const [favorites, setFavorites] = useState<Target[]>([{ kind: 'company', id: 'c1' }, { kind: 'person', id: 'p3' }, { kind: 'opportunity', id: 'o7' }]);
const [open, setOpen] = useState<Target | null>(null);
const [cmd, setCmd] = useState(false);
const [collapsed, setCollapsed] = useState(false);
const [drawer, setDrawer] = useState(false);
const [settings, setSettings] = useState(false);
const [toasts, setToasts] = useState<Toast[]>([]);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [view, setView] = useState<Record<string, string>>({ people: 'all', companies: 'all', opportunities: 'all' });
const [rules, setRules] = useState<Record<string, Rule[]>>({ people: [], companies: [], opportunities: [] });
const [sorts, setSorts] = useState<Record<string, Sort>>({ people: null, companies: null, opportunities: null });
const [mode, setMode] = useState<'kanban' | 'table'>('kanban');
const [menu, setMenu] = useState<'view' | 'filter' | 'sort' | 'options' | null>(null);
const [search, setSearch] = useState('');
const [searching, setSearching] = useState(false);
const [autoEdit, setAutoEdit] = useState<string | null>(null);
const timers = useRef<number[]>([]);
useEffect(() => () => timers.current.forEach(clearTimeout), []);
const toast = useCallback((msg: string, action?: Toast['action']) => {
const id = Date.now() + Math.random();
setToasts((t) => [...t.slice(-2), { id, msg, action }]);
timers.current.push(window.setTimeout(() => setToasts((t) => t.filter((x) => x.id !== id)), action ? 6000 : 3200));
}, []);
const go = useCallback((o: ObjectKey) => {
setObj(o);
setSelected(new Set());
setMenu(null);
setSearch('');
setSearching(false);
setDrawer(false);
}, []);
const crm: Crm = useMemo(
() => ({
people,
companies,
opps,
tasks,
notes,
setPeople: setPeopleS,
setCompanies: setCompaniesS,
setOpps: setOppsS,
setTasks: setTasksS,
setNotes: setNotesS,
activity,
log: (id, text, kind = 'edit') => setActivity((a) => ({ ...a, [id]: [{ id: `${Date.now()}${Math.random()}`, at: 'now', text, kind }, ...(a[id] ?? [])].slice(0, 8) })),
openRecord: (t) => {
setOpen(t);
setDrawer(false);
},
favorites,
toggleFavorite: (t) => setFavorites((f) => (f.some((x) => sameTarget(x, t)) ? f.filter((x) => !sameTarget(x, t)) : [...f, t])),
toast: (m) => toast(m),
go,
}),
[people, companies, opps, tasks, notes, activity, favorites, toast, go],
);
useEffect(() => {
const k = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
e.preventDefault();
setCmd((c) => !c);
}
};
window.addEventListener('keydown', k);
return () => window.removeEventListener('keydown', k);
}, []);
/* ——— records for the current object ——— */
const objKey = isRecordObject(obj) ? obj : null;
const source: { id: string; name: string }[] = obj === 'people' ? people : obj === 'companies' ? companies : obj === 'opportunities' ? opps : [];
const fields: Field[] = obj === 'people' ? PEOPLE_FIELDS : obj === 'companies' ? COMPANY_FIELDS : OPP_FIELDS;
const activeRules = objKey ? [...(VIEWS[objKey].find((v) => v.id === view[objKey])?.rules ?? []), ...rules[objKey]] : [];
const sort = objKey ? sorts[objKey] : null;
const rows = useMemo(() => {
let r = source.filter((x) => activeRules.every((rule) => rule.test(x as unknown as Record<string, unknown>)));
const s = search.trim().toLowerCase();
if (s) r = r.filter((x) => JSON.stringify(x).toLowerCase().includes(s));
if (sort) {
r = [...r].sort((a, b) => {
const av = (a as unknown as Record<string, unknown>)[sort.key];
const bv = (b as unknown as Record<string, unknown>)[sort.key];
if (typeof av === 'number' && typeof bv === 'number') return (av - bv) * sort.dir;
return String(av ?? '').localeCompare(String(bv ?? '')) * sort.dir;
});
}
return r;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [source, activeRules.map((r) => r.id).join(), search, sort]);
const update = (id: string, key: string, value: unknown) => {
const apply = <T extends { id: string }>(all: T[]) => all.map((x) => (x.id === id ? { ...x, [key]: value } : x));
if (obj === 'people') setPeopleS(apply);
else if (obj === 'companies') setCompaniesS(apply);
else if (obj === 'opportunities') setOppsS(apply);
const label = key === 'name' ? 'Name' : fields.find((f) => f.key === key)?.label ?? key;
crm.log(id, `You updated ${label}`);
};
const newRecord = (o: ObjectKey, stage: Stage = 'New') => {
const id = `${o[0]}${Date.now()}`;
if (o !== obj) go(o);
if (o === 'people') {
setPeopleS((all) => [{ id, name: '', email: '', phone: '', title: '', companyId: 'c1', city: '', lifecycle: 'Lead', intent: 3, lastContact: '2026-10-30', owner: ME.id }, ...all]);
setAutoEdit(id);
} else if (o === 'companies') {
setCompaniesS((all) => [{ id, name: '', domain: '', industry: 'SaaS', employees: 10, arr: 0, city: '', owner: ME.id, icp: false, rating: 3, created: '2026-10-30', color: colorFor(id) }, ...all]);
setAutoEdit(id);
} else if (o === 'opportunities') {
setOppsS((all) => [{ id, name: 'New opportunity', companyId: 'c1', amount: 25_000, stage, close: '2026-12-15', poc: 'p1', owner: ME.id, probability: 10 }, ...all]);
if (mode === 'table' && o === obj) setAutoEdit(id);
else setOpen({ kind: 'opportunity', id });
} else if (o === 'tasks') {
setTasksS((all) => [{ id, title: 'New task', due: '2026-10-30', done: false, assignee: ME.id, target: { kind: 'company', id: 'c1' } }, ...all]);
toast('Task added for today');
} else if (o === 'notes') {
setNotesS((all) => [{ id, title: 'Untitled note', body: 'Start writing…', target: { kind: 'company', id: 'c1' }, author: ME.id, updated: '2026-10-30' }, ...all]);
toast('Note created on Halcyon Labs');
} else toast('The workflow builder isn’t part of this demo');
};
const deleteSelected = () => {
const ids = new Set(selected);
const before = { people, companies, opps };
if (obj === 'people') setPeopleS((all) => all.filter((x) => !ids.has(x.id)));
if (obj === 'companies') setCompaniesS((all) => all.filter((x) => !ids.has(x.id)));
if (obj === 'opportunities') setOppsS((all) => all.filter((x) => !ids.has(x.id)));
setSelected(new Set());
toast(`Deleted ${ids.size} record${ids.size > 1 ? 's' : ''}`, {
label: 'Undo',
run: () => {
setPeopleS(before.people);
setCompaniesS(before.companies);
setOppsS(before.opps);
},
});
};
const exportCsv = (list: { id: string }[]) => {
const keys = ['name', ...fields.map((f) => f.key)];
const csv = [keys.join(','), ...list.map((r) => keys.map((k) => JSON.stringify((r as Record<string, unknown>)[k] ?? '')).join(','))].join('\n');
try {
const a = document.createElement('a');
a.href = URL.createObjectURL(new Blob([csv], { type: 'text/csv' }));
a.download = `meridian-${obj}.csv`;
a.click();
URL.revokeObjectURL(a.href);
} catch {
/* downloads can be blocked in sandboxes */
}
toast(`Exported ${list.length} ${obj} to CSV`);
};
const meta = OBJECTS.find((o) => o.key === obj)!;
const count = objKey ? rows.length : obj === 'tasks' ? tasks.filter((t) => !t.done).length : obj === 'notes' ? notes.length : undefined;
const currentView = objKey ? VIEWS[objKey].find((v) => v.id === view[objKey]) : undefined;
const leading = (r: { id: string; name: string }) => {
if (obj === 'people') return <Avatar name={r.name || '?'} size={18} />;
if (obj === 'companies') return <Logo company={r as Company} size={18} />;
const c = companies.find((x) => x.id === (r as Opportunity).companyId);
return c ? <Logo company={c} size={18} /> : null;
};
return (
<CrmContext.Provider value={crm}>
<div data-fill className="relative flex h-[720px] overflow-hidden bg-[var(--bg)] font-sans text-[13px] text-[var(--fg)] antialiased sm:h-[760px]" style={{ ...THEMES[theme], colorScheme: theme }} data-theme={theme}>
<div className="hidden md:flex">
<Sidebar current={obj} collapsed={collapsed} onCollapse={() => setCollapsed((c) => !c)} onSearch={() => setCmd(true)} theme={theme} onTheme={() => setTheme((t) => (t === 'light' ? 'dark' : 'light'))} onSettings={() => setSettings(true)} />
</div>
{drawer && (
<div className="absolute inset-0 z-40 flex md:hidden">
<div className="h-full animate-fade-in">
<Sidebar current={obj} collapsed={false} onCollapse={() => setDrawer(false)} onSearch={() => { setDrawer(false); setCmd(true); }} theme={theme} onTheme={() => setTheme((t) => (t === 'light' ? 'dark' : 'light'))} onSettings={() => { setDrawer(false); setSettings(true); }} />
</div>
<button aria-label="Close menu" onClick={() => setDrawer(false)} className="flex-1 bg-black/30 animate-fade-in" />
</div>
)}
<main className="flex min-w-0 flex-1 flex-col">
{/* top bar */}
<header className="flex h-11 shrink-0 items-center gap-2 border-b border-[var(--line)] px-3">
<button onClick={() => setDrawer(true)} aria-label="Open menu" className={cn(btn, 'px-1.5 md:hidden')}>
<MenuIcon className="h-4 w-4" />
</button>
<meta.icon className="h-4 w-4 text-[var(--mute)]" />
<h1 className="truncate text-[14px] font-medium text-[var(--strong)]">{meta.label}</h1>
{count !== undefined && <span className="text-[13px] text-[var(--mute)]">{count}</span>}
<div className="ml-auto flex items-center gap-1">
<button onClick={() => setCmd(true)} aria-label="Search (Ctrl K)" className={cn(btn, 'px-1.5')}>
<Search className="h-4 w-4" />
</button>
{obj !== 'dashboard' && (
<button onClick={() => newRecord(obj)} className={btnPrimary}>
<Plus className="h-3.5 w-3.5" /> <span className="hidden sm:inline">New {obj === 'people' ? 'person' : obj === 'companies' ? 'company' : obj === 'opportunities' ? 'opportunity' : obj === 'tasks' ? 'task' : obj === 'notes' ? 'note' : 'workflow'}</span>
<span className="sm:hidden">New</span>
</button>
)}
</div>
</header>
{/* view bar */}
{objKey && (
<div className="flex h-10 shrink-0 items-center gap-1 border-b border-[var(--line)] px-2">
<div className="relative">
<button onClick={() => setMenu(menu === 'view' ? null : 'view')} aria-haspopup="menu" aria-expanded={menu === 'view'} className={cn(btn, 'font-medium text-[var(--strong)]')}>
<List className="h-3.5 w-3.5 text-[var(--mute)]" />
<span className="max-w-[140px] truncate">{currentView?.label}</span>
<span className="text-[var(--mute)]">· {rows.length}</span>
<ChevronDown className="h-3.5 w-3.5 text-[var(--mute)]" />
</button>
<Menu open={menu === 'view'} onClose={() => setMenu(null)}>
{VIEWS[objKey].map((v) => (
<MenuItem key={v.id} active={view[objKey] === v.id} icon={List} onClick={() => { setView((s) => ({ ...s, [objKey]: v.id })); setMenu(null); }}>
{v.label}
</MenuItem>
))}
<div className="my-1 h-px bg-[var(--line)]" />
<MenuItem icon={Plus} onClick={() => { setMenu(null); toast('Saved views are read-only in this demo'); }}>Add view</MenuItem>
</Menu>
</div>
{obj === 'opportunities' && (
<div className="ml-1 flex rounded-md bg-[var(--sub)] p-0.5" role="group" aria-label="Layout">
{(['kanban', 'table'] as const).map((m) => (
<button key={m} onClick={() => setMode(m)} aria-pressed={mode === m} aria-label={m === 'kanban' ? 'Kanban' : 'Table'} className={cn('grid h-6 w-7 place-items-center rounded-[5px] transition-colors', mode === m ? 'bg-[var(--panel)] text-[var(--strong)] shadow-sm' : 'text-[var(--mute)]')}>
{m === 'kanban' ? <Kanban className="h-3.5 w-3.5" /> : <Columns3 className="h-3.5 w-3.5" />}
</button>
))}
</div>
)}
<div className="ml-auto flex items-center gap-0.5">
{searching ? (
<div className="flex h-7 items-center gap-1 rounded-md border border-[var(--accent)] px-2">
<Search className="h-3.5 w-3.5 text-[var(--mute)]" />
<input autoFocus value={search} onChange={(e) => setSearch(e.target.value)} onKeyDown={(e) => e.key === 'Escape' && (setSearch(''), setSearching(false))} placeholder={`Search ${meta.label.toLowerCase()}`} aria-label={`Search ${meta.label}`} className="w-24 bg-transparent text-[13px] text-[var(--strong)] outline-none placeholder:text-[var(--faint)] sm:w-36" />
<button onClick={() => { setSearch(''); setSearching(false); }} aria-label="Clear search"><X className="h-3.5 w-3.5 text-[var(--mute)]" /></button>
</div>
) : (
<button onClick={() => setSearching(true)} aria-label={`Search ${meta.label}`} className={cn(btn, 'px-1.5')}><Search className="h-3.5 w-3.5" /></button>
)}
<div className="relative">
<button onClick={() => setMenu(menu === 'filter' ? null : 'filter')} aria-haspopup="menu" aria-expanded={menu === 'filter'} className={cn(btn, rules[objKey].length > 0 && 'text-[var(--accent)]')}>
<Filter className="h-3.5 w-3.5" /> <span className="hidden sm:inline">Filter</span>
</button>
<Menu open={menu === 'filter'} onClose={() => setMenu(null)} align="right" className="w-[230px]">
<p className="px-2 pb-1 pt-1.5 text-[11.5px] text-[var(--mute)]">Add a filter</p>
{RULES[objKey].map((r) => {
const on = rules[objKey].some((x) => x.id === r.id);
return (
<MenuItem key={r.id} active={on} icon={Filter} onClick={() => setRules((s) => ({ ...s, [objKey]: on ? s[objKey].filter((x) => x.id !== r.id) : [...s[objKey], r] }))}>
{r.label}
</MenuItem>
);
})}
</Menu>
</div>
<div className="relative">
<button onClick={() => setMenu(menu === 'sort' ? null : 'sort')} aria-haspopup="menu" aria-expanded={menu === 'sort'} className={cn(btn, sort && 'text-[var(--accent)]')}>
<ArrowDownUp className="h-3.5 w-3.5" /> <span className="hidden sm:inline">Sort</span>
</button>
<Menu open={menu === 'sort'} onClose={() => setMenu(null)} align="right" className="max-h-[300px] w-[210px] overflow-y-auto">
{[{ key: 'name', label: 'Name', icon: List }, ...fields].map((f) => (
<MenuItem key={f.key} active={sort?.key === f.key} icon={f.icon} hint={sort?.key === f.key ? (sort.dir === 1 ? 'A→Z' : 'Z→A') : undefined} onClick={() => { setSorts((s) => ({ ...s, [objKey]: { key: f.key, dir: s[objKey]?.key === f.key ? (s[objKey]!.dir === 1 ? -1 : 1) : 1 } })); setMenu(null); }}>
{f.label}
</MenuItem>
))}
</Menu>
</div>
<div className="relative">
<button onClick={() => setMenu(menu === 'options' ? null : 'options')} aria-label="Options" aria-haspopup="menu" aria-expanded={menu === 'options'} className={cn(btn, 'px-1.5')}>
<MoreHorizontal className="h-4 w-4" />
</button>
<Menu open={menu === 'options'} onClose={() => setMenu(null)} align="right">
<MenuItem icon={Download} onClick={() => { setMenu(null); exportCsv(rows); }}>Export view as CSV</MenuItem>
<MenuItem icon={Upload} onClick={() => { setMenu(null); toast('Import opens a CSV mapper in the full product'); }}>Import records</MenuItem>
<MenuItem icon={Trash2} onClick={() => { setMenu(null); toast('No deleted records in the last 30 days'); }}>Deleted records</MenuItem>
</Menu>
</div>
</div>
</div>
)}
{/* active filter chips */}
{objKey && (rules[objKey].length > 0 || sort) && (
<div className="flex shrink-0 flex-wrap items-center gap-1.5 border-b border-[var(--line)] px-3 py-1.5">
{sort && (
<span className="inline-flex h-6 items-center gap-1 rounded-md bg-[var(--accent-soft)] px-2 text-[12px] text-[var(--accent)]">
<ArrowDownUp className="h-3 w-3" /> {sort.key === 'name' ? 'Name' : fields.find((f) => f.key === sort.key)?.label}: {sort.dir === 1 ? 'ascending' : 'descending'}
<button aria-label="Remove sort" onClick={() => setSorts((s) => ({ ...s, [objKey]: null }))}><X className="h-3 w-3" /></button>
</span>
)}
{rules[objKey].map((r) => (
<span key={r.id} className="inline-flex h-6 items-center gap-1 rounded-md bg-[var(--accent-soft)] px-2 text-[12px] text-[var(--accent)]">
{r.label}
<button aria-label={`Remove filter ${r.label}`} onClick={() => setRules((s) => ({ ...s, [objKey]: s[objKey].filter((x) => x.id !== r.id) }))}><X className="h-3 w-3" /></button>
</span>
))}
<button onClick={() => { setRules((s) => ({ ...s, [objKey]: [] })); setSorts((s) => ({ ...s, [objKey]: null })); }} className="text-[12px] text-[var(--mute)] hover:text-[var(--strong)]">Reset</button>
</div>
)}
{/* content */}
{objKey && !(obj === 'opportunities' && mode === 'kanban') && (
<TableView
key={obj}
rows={rows as { id: string; name: string }[]}
fields={fields}
leading={leading}
selected={selected}
setSelected={setSelected}
sort={sort}
onSort={(key) => setSorts((s) => ({ ...s, [objKey]: s[objKey]?.key === key ? (s[objKey]!.dir === 1 ? { key, dir: -1 } : null) : { key, dir: 1 } }))}
onUpdate={update}
onOpen={(id) => setOpen({ kind: obj === 'people' ? 'person' : obj === 'companies' ? 'company' : 'opportunity', id })}
onNew={() => newRecord(obj)}
autoEdit={autoEdit}
clearAutoEdit={() => setAutoEdit(null)}
nameLabel={obj === 'people' ? 'Name' : obj === 'companies' ? 'Company' : 'Opportunity'}
/>
)}
{obj === 'opportunities' && mode === 'kanban' && <KanbanView rows={rows as Opportunity[]} onNew={(stage) => newRecord('opportunities', stage)} />}
{obj === 'tasks' && <TasksView />}
{obj === 'notes' && <NotesView />}
{obj === 'workflows' && <WorkflowsView />}
{obj === 'dashboard' && <DashboardView />}
</main>
{/* bulk actions */}
{selected.size > 0 && objKey && (
<div className="absolute bottom-4 left-1/2 z-30 flex -translate-x-1/2 items-center gap-1 rounded-lg border border-[var(--line)] bg-[var(--panel)] p-1 pl-3 text-[13px] animate-fade-up" style={{ boxShadow: 'var(--pop)' }}>
<span className="whitespace-nowrap font-medium text-[var(--strong)]">{selected.size} selected</span>
<span className="mx-1 h-4 w-px bg-[var(--line)]" />
<button
onClick={() => {
const kind = obj === 'people' ? 'person' : obj === 'companies' ? 'company' : 'opportunity';
setFavorites((f) => [...f, ...[...selected].map((id) => ({ kind, id }) as Target).filter((t) => !f.some((x) => sameTarget(x, t)))]);
toast(`Added ${selected.size} to favorites`);
}}
className={btn}
>
<Star className="h-3.5 w-3.5" /> <span className="hidden sm:inline">Favorite</span>
</button>
<button onClick={() => exportCsv(rows.filter((r) => selected.has(r.id)))} className={btn}>
<Download className="h-3.5 w-3.5" /> <span className="hidden sm:inline">Export</span>
</button>
<button onClick={deleteSelected} className={cn(btn, 'text-[#dc3e42]')}>
<Trash2 className="h-3.5 w-3.5" /> <span className="hidden sm:inline">Delete</span>
</button>
<button onClick={() => setSelected(new Set())} aria-label="Clear selection" className={cn(btn, 'px-1.5')}>
<X className="h-3.5 w-3.5" />
</button>
</div>
)}
{open && <RecordPanel target={open} onClose={() => setOpen(null)} />}
{cmd && <CommandMenu onClose={() => setCmd(false)} onNew={(o) => newRecord(o)} onTheme={() => setTheme((t) => (t === 'light' ? 'dark' : 'light'))} onCollapse={() => setCollapsed((c) => !c)} />}
{settings && (
<div className="absolute inset-0 z-50 grid place-items-center bg-black/25 p-4 animate-fade-in" onPointerDown={(e) => e.target === e.currentTarget && setSettings(false)}>
<div role="dialog" aria-label="Settings" className="w-full max-w-[420px] rounded-xl border border-[var(--line)] bg-[var(--panel)] p-4 animate-scale-in" style={{ boxShadow: 'var(--pop)' }}>
<div className="flex items-center justify-between">
<h2 className="text-[15px] font-semibold text-[var(--strong)]">Settings</h2>
<button onClick={() => setSettings(false)} aria-label="Close settings" className={cn(btn, 'px-1.5')}><X className="h-4 w-4" /></button>
</div>
<p className="mt-4 text-[12px] font-medium text-[var(--mute)]">Appearance</p>
<div className="mt-2 grid grid-cols-2 gap-2">
{(['light', 'dark'] as const).map((t) => (
<button key={t} onClick={() => setTheme(t)} aria-pressed={theme === t} className={cn('rounded-lg border p-2 text-left capitalize transition-colors', theme === t ? 'border-[var(--accent)] text-[var(--strong)]' : 'border-[var(--line)] text-[var(--fg)] hover:border-[var(--line2)]')}>
<span className="mb-2 block h-12 rounded-md border border-black/10" style={{ background: t === 'light' ? 'linear-gradient(90deg,#fafafa 30%,#fff 30%)' : 'linear-gradient(90deg,#111 30%,#151515 30%)' }} />
{t}
</button>
))}
</div>
<p className="mt-4 text-[12px] font-medium text-[var(--mute)]">Members</p>
<ul className="mt-1 divide-y divide-[var(--line)]">
{MEMBERS.map((m) => (
<li key={m.id} className="flex items-center gap-2 py-2">
<Avatar name={m.name} color={m.color} size={22} />
<span className="flex-1 text-[13px] text-[var(--strong)]">{m.name}</span>
<span className="text-[12px] text-[var(--mute)]">{m.id === ME.id ? 'Admin' : 'Member'}</span>
</li>
))}
</ul>
</div>
</div>
)}
<div aria-live="polite" className="pointer-events-none absolute bottom-4 right-4 z-[60] flex flex-col items-end gap-2">
{toasts.map((t) => (
<div key={t.id} className="pointer-events-auto flex items-center gap-3 rounded-lg bg-[var(--strong)] px-3 py-2 text-[12.5px] text-[var(--bg)] shadow-lg animate-fade-up">
{t.msg}
{t.action && (
<button onClick={() => { t.action!.run(); setToasts((all) => all.filter((x) => x.id !== t.id)); }} className="font-medium underline underline-offset-2">
{t.action.label}
</button>
)}
</div>
))}
</div>
</div>
</CrmContext.Provider>
);
}components/niceui/pages/apps/crm/data.ts
/* Fictional demo data for the Meridian CRM template. */
export const TODAY = new Date('2026-10-30T10:00:00');
export type Member = { id: string; name: string; color: string };
export const MEMBERS: Member[] = [
{ id: 'm1', name: 'Ava Moreno', color: '#3e63dd' },
{ id: 'm2', name: 'Ben Okafor', color: '#12a594' },
{ id: 'm3', name: 'Chloe Park', color: '#d6409f' },
{ id: 'm4', name: 'Diego Santos', color: '#c77d00' },
{ id: 'm5', name: 'Elena Varga', color: '#8e4ec6' },
];
export const ME = MEMBERS[0];
export const INDUSTRIES = ['SaaS', 'Fintech', 'Health', 'Logistics', 'Energy', 'Retail', 'Robotics', 'Media'] as const;
export type Industry = (typeof INDUSTRIES)[number];
export type Company = {
id: string;
name: string;
domain: string;
industry: Industry;
employees: number;
arr: number;
city: string;
owner: string;
icp: boolean;
rating: number;
created: string;
color: string;
};
export const COMPANIES: Company[] = [
{ id: 'c1', name: 'Halcyon Labs', domain: 'halcyonlabs.io', industry: 'SaaS', employees: 140, arr: 4_200_000, city: 'Lisbon', owner: 'm1', icp: true, rating: 5, created: '2026-03-02', color: '#3e63dd' },
{ id: 'c2', name: 'Brightwater Analytics', domain: 'brightwater.ai', industry: 'SaaS', employees: 85, arr: 2_100_000, city: 'Dublin', owner: 'm2', icp: true, rating: 4, created: '2026-04-18', color: '#12a594' },
{ id: 'c3', name: 'Kestrel Logistics', domain: 'kestrel.co', industry: 'Logistics', employees: 920, arr: 18_500_000, city: 'Rotterdam', owner: 'm4', icp: true, rating: 4, created: '2025-11-07', color: '#c77d00' },
{ id: 'c4', name: 'Lumen Health', domain: 'lumenhealth.com', industry: 'Health', employees: 310, arr: 9_800_000, city: 'Boston', owner: 'm3', icp: false, rating: 3, created: '2026-01-22', color: '#d6409f' },
{ id: 'c5', name: 'Paperplane', domain: 'paperplane.app', industry: 'SaaS', employees: 36, arr: 720_000, city: 'Berlin', owner: 'm1', icp: false, rating: 3, created: '2026-06-11', color: '#8e4ec6' },
{ id: 'c6', name: 'Arbor Finance', domain: 'arbor.finance', industry: 'Fintech', employees: 220, arr: 7_400_000, city: 'London', owner: 'm5', icp: true, rating: 5, created: '2025-09-30', color: '#2b9a66' },
{ id: 'c7', name: 'Quill & Co', domain: 'quillandco.com', industry: 'Media', employees: 48, arr: 1_300_000, city: 'New York', owner: 'm2', icp: false, rating: 2, created: '2026-07-04', color: '#dc3e42' },
{ id: 'c8', name: 'Stratus Energy', domain: 'stratus.energy', industry: 'Energy', employees: 1_400, arr: 32_000_000, city: 'Austin', owner: 'm4', icp: true, rating: 4, created: '2025-08-15', color: '#0d9b8a' },
{ id: 'c9', name: 'Fernway Foods', domain: 'fernway.com', industry: 'Retail', employees: 560, arr: 12_600_000, city: 'Toronto', owner: 'm3', icp: false, rating: 3, created: '2026-02-09', color: '#b5730a' },
{ id: 'c10', name: 'Tidal Robotics', domain: 'tidalrobotics.io', industry: 'Robotics', employees: 120, arr: 5_100_000, city: 'Copenhagen', owner: 'm5', icp: true, rating: 5, created: '2026-05-27', color: '#3e63dd' },
{ id: 'c11', name: 'Copperline Studio', domain: 'copperline.studio', industry: 'Media', employees: 22, arr: 480_000, city: 'Melbourne', owner: 'm1', icp: false, rating: 2, created: '2026-09-03', color: '#cf3897' },
{ id: 'c12', name: 'Orchard Bank', domain: 'orchardbank.com', industry: 'Fintech', employees: 2_800, arr: 64_000_000, city: 'Zurich', owner: 'm2', icp: true, rating: 4, created: '2025-06-19', color: '#2b9a66' },
];
export const LIFECYCLES = ['Lead', 'MQL', 'SQL', 'Customer', 'Churned'] as const;
export type Lifecycle = (typeof LIFECYCLES)[number];
export type Person = {
id: string;
name: string;
email: string;
phone: string;
title: string;
companyId: string;
city: string;
lifecycle: Lifecycle;
intent: number;
lastContact: string;
owner: string;
};
const P = (id: string, name: string, title: string, companyId: string, city: string, lifecycle: Lifecycle, intent: number, lastContact: string, owner: string, phone: string): Person => {
const c = COMPANIES.find((x) => x.id === companyId)!;
const [first, last] = name.normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase().split(' ');
return { id, name, email: `${first}.${last}@${c.domain}`, phone, title, companyId, city, lifecycle, intent, lastContact, owner };
};
export const PEOPLE: Person[] = [
P('p1', 'Maya Ocampo', 'Head of Operations', 'c1', 'Lisbon', 'Customer', 5, '2026-10-29', 'm1', '+351 912 440 118'),
P('p2', 'Jonah Reyes', 'VP Engineering', 'c1', 'Lisbon', 'Customer', 4, '2026-10-21', 'm1', '+351 913 207 664'),
P('p3', 'Priya Natarajan', 'Founder', 'c2', 'Dublin', 'SQL', 5, '2026-10-28', 'm2', '+353 87 551 2093'),
P('p4', 'Tomás Lindqvist', 'Data Lead', 'c2', 'Dublin', 'MQL', 3, '2026-10-12', 'm2', '+353 86 310 7741'),
P('p5', 'Hannah Vos', 'COO', 'c3', 'Rotterdam', 'SQL', 4, '2026-10-27', 'm4', '+31 6 2840 1193'),
P('p6', 'Felix Brandt', 'Fleet Manager', 'c3', 'Rotterdam', 'Lead', 2, '2026-09-30', 'm4', '+31 6 1755 6620'),
P('p7', 'Grace Liu', 'CTO', 'c4', 'Boston', 'MQL', 4, '2026-10-24', 'm3', '+1 617 555 0182'),
P('p8', 'Samuel Adeyemi', 'Procurement', 'c4', 'Boston', 'Lead', 2, '2026-10-02', 'm3', '+1 617 555 0144'),
P('p9', 'Lena Fischer', 'CEO', 'c5', 'Berlin', 'Lead', 3, '2026-10-19', 'm1', '+49 151 2290 4471'),
P('p10', 'Oliver Grant', 'CFO', 'c6', 'London', 'Customer', 5, '2026-10-30', 'm5', '+44 7700 900 412'),
P('p11', 'Isla McKenzie', 'Product Director', 'c6', 'London', 'Customer', 4, '2026-10-16', 'm5', '+44 7700 900 858'),
P('p12', 'Noah Kim', 'Editor in Chief', 'c7', 'New York', 'Churned', 1, '2026-08-11', 'm2', '+1 212 555 0179'),
P('p13', 'Camila Duarte', 'Head of Sustainability', 'c8', 'Austin', 'SQL', 5, '2026-10-26', 'm4', '+1 512 555 0123'),
P('p14', 'Ethan Brooks', 'IT Director', 'c8', 'Austin', 'MQL', 3, '2026-10-08', 'm4', '+1 512 555 0167'),
P('p15', 'Aisha Rahman', 'VP Digital', 'c9', 'Toronto', 'Lead', 3, '2026-10-14', 'm3', '+1 416 555 0191'),
P('p16', 'Lucas Martin', 'Store Ops Lead', 'c9', 'Toronto', 'Lead', 2, '2026-09-22', 'm3', '+1 416 555 0138'),
P('p17', 'Freya Nielsen', 'Founder', 'c10', 'Copenhagen', 'SQL', 4, '2026-10-29', 'm5', '+45 21 44 87 90'),
P('p18', 'Mateo Rossi', 'Robotics Engineer', 'c10', 'Copenhagen', 'MQL', 3, '2026-10-05', 'm5', '+45 28 11 63 07'),
P('p19', 'Zoe Carter', 'Creative Director', 'c11', 'Melbourne', 'Lead', 2, '2026-10-01', 'm1', '+61 412 555 093'),
P('p20', 'Daniel Weber', 'Head of Payments', 'c12', 'Zurich', 'Customer', 5, '2026-10-27', 'm2', '+41 79 555 21 08'),
P('p21', 'Sofia Marquez', 'Risk Officer', 'c12', 'Zurich', 'SQL', 4, '2026-10-23', 'm2', '+41 78 555 64 19'),
P('p22', 'Kenji Watanabe', 'Platform Lead', 'c1', 'Lisbon', 'MQL', 3, '2026-10-10', 'm1', '+351 914 882 301'),
P('p23', 'Amara Okoye', 'Growth Lead', 'c5', 'Berlin', 'Lead', 4, '2026-10-25', 'm1', '+49 160 3381 2056'),
P('p24', 'Victor Hale', 'Ops Analyst', 'c3', 'Rotterdam', 'Churned', 1, '2026-07-29', 'm4', '+31 6 4419 0287'),
];
export const STAGES = ['New', 'Screening', 'Meeting', 'Proposal', 'Customer'] as const;
export type Stage = (typeof STAGES)[number];
export type Opportunity = {
id: string;
name: string;
companyId: string;
amount: number;
stage: Stage;
close: string;
poc: string;
owner: string;
probability: number;
};
export const OPPORTUNITIES: Opportunity[] = [
{ id: 'o1', name: 'Halcyon · Enterprise renewal', companyId: 'c1', amount: 186_000, stage: 'Proposal', close: '2026-11-20', poc: 'p1', owner: 'm1', probability: 70 },
{ id: 'o2', name: 'Brightwater · Analytics seats', companyId: 'c2', amount: 64_000, stage: 'Meeting', close: '2026-12-05', poc: 'p3', owner: 'm2', probability: 45 },
{ id: 'o3', name: 'Kestrel · Fleet rollout', companyId: 'c3', amount: 420_000, stage: 'Screening', close: '2027-01-30', poc: 'p5', owner: 'm4', probability: 25 },
{ id: 'o4', name: 'Lumen · Pilot', companyId: 'c4', amount: 38_000, stage: 'New', close: '2026-12-15', poc: 'p7', owner: 'm3', probability: 10 },
{ id: 'o5', name: 'Paperplane · Starter', companyId: 'c5', amount: 12_500, stage: 'Meeting', close: '2026-11-12', poc: 'p9', owner: 'm1', probability: 40 },
{ id: 'o6', name: 'Arbor · Expansion', companyId: 'c6', amount: 240_000, stage: 'Customer', close: '2026-10-14', poc: 'p10', owner: 'm5', probability: 100 },
{ id: 'o7', name: 'Stratus · Grid analytics', companyId: 'c8', amount: 510_000, stage: 'Proposal', close: '2026-12-01', poc: 'p13', owner: 'm4', probability: 60 },
{ id: 'o8', name: 'Fernway · Store insights', companyId: 'c9', amount: 96_000, stage: 'New', close: '2027-02-10', poc: 'p15', owner: 'm3', probability: 10 },
{ id: 'o9', name: 'Tidal · Factory twin', companyId: 'c10', amount: 145_000, stage: 'Meeting', close: '2026-12-20', poc: 'p17', owner: 'm5', probability: 50 },
{ id: 'o10', name: 'Copperline · Studio plan', companyId: 'c11', amount: 8_400, stage: 'Screening', close: '2026-11-28', poc: 'p19', owner: 'm1', probability: 20 },
{ id: 'o11', name: 'Orchard · Payments API', companyId: 'c12', amount: 680_000, stage: 'Proposal', close: '2027-01-15', poc: 'p20', owner: 'm2', probability: 55 },
{ id: 'o12', name: 'Halcyon · Data add-on', companyId: 'c1', amount: 42_000, stage: 'Customer', close: '2026-09-29', poc: 'p2', owner: 'm1', probability: 100 },
{ id: 'o13', name: 'Kestrel · Customs module', companyId: 'c3', amount: 88_000, stage: 'New', close: '2027-03-01', poc: 'p6', owner: 'm4', probability: 10 },
{ id: 'o14', name: 'Orchard · Risk dashboards', companyId: 'c12', amount: 132_000, stage: 'Screening', close: '2026-12-18', poc: 'p21', owner: 'm2', probability: 30 },
];
export type Target = { kind: 'person' | 'company' | 'opportunity'; id: string };
export type Task = { id: string; title: string; due: string; done: boolean; assignee: string; target: Target };
export const TASKS: Task[] = [
{ id: 't1', title: 'Send renewal proposal v2', due: '2026-10-28', done: false, assignee: 'm1', target: { kind: 'opportunity', id: 'o1' } },
{ id: 't2', title: 'Book security review with Jonah', due: '2026-10-30', done: false, assignee: 'm1', target: { kind: 'person', id: 'p2' } },
{ id: 't3', title: 'Prepare grid analytics demo', due: '2026-10-30', done: false, assignee: 'm4', target: { kind: 'opportunity', id: 'o7' } },
{ id: 't4', title: 'Follow up on pilot scope', due: '2026-11-02', done: false, assignee: 'm3', target: { kind: 'company', id: 'c4' } },
{ id: 't5', title: 'Share ROI model with Priya', due: '2026-11-03', done: false, assignee: 'm2', target: { kind: 'person', id: 'p3' } },
{ id: 't6', title: 'Legal redlines for payments API', due: '2026-11-05', done: false, assignee: 'm2', target: { kind: 'opportunity', id: 'o11' } },
{ id: 't7', title: 'Intro call with Fernway digital team', due: '2026-11-12', done: false, assignee: 'm3', target: { kind: 'company', id: 'c9' } },
{ id: 't8', title: 'Quarterly business review deck', due: '2026-11-19', done: false, assignee: 'm5', target: { kind: 'company', id: 'c6' } },
{ id: 't9', title: 'Log call notes from Tidal visit', due: '2026-10-27', done: true, assignee: 'm5', target: { kind: 'opportunity', id: 'o9' } },
{ id: 't10', title: 'Update Kestrel stakeholder map', due: '2026-10-24', done: true, assignee: 'm4', target: { kind: 'company', id: 'c3' } },
];
export type Note = { id: string; title: string; body: string; target: Target; author: string; updated: string };
export const NOTES: Note[] = [
{ id: 'n1', title: 'Renewal call recap', body: 'Maya wants SSO and an audit log before signing. Budget is approved; procurement needs the DPA by Nov 15.', target: { kind: 'person', id: 'p1' }, author: 'm1', updated: '2026-10-29' },
{ id: 'n2', title: 'Grid analytics: technical fit', body: 'Camila’s team ingests 2B meter readings a day. Our streaming connector covers it; need a capacity plan.', target: { kind: 'opportunity', id: 'o7' }, author: 'm4', updated: '2026-10-26' },
{ id: 'n3', title: 'Why Quill churned', body: 'Moved to an in-house tool after their editorial reorg. Open to a lighter plan next year.', target: { kind: 'company', id: 'c7' }, author: 'm2', updated: '2026-10-11' },
{ id: 'n4', title: 'Orchard procurement steps', body: 'Three rounds: vendor risk, InfoSec, then the payments committee on the first Tuesday of each month.', target: { kind: 'company', id: 'c12' }, author: 'm2', updated: '2026-10-23' },
{ id: 'n5', title: 'Tidal factory visit', body: 'Saw the twin prototype. Freya wants weekly model refreshes and an on-prem option for line 3.', target: { kind: 'person', id: 'p17' }, author: 'm5', updated: '2026-10-27' },
{ id: 'n6', title: 'Brightwater seat math', body: '42 analysts today, 60 by Q2. Proposed volume tier at seat 50 to keep them on annual.', target: { kind: 'opportunity', id: 'o2' }, author: 'm2', updated: '2026-10-28' },
];
export const companyOf = (id: string) => COMPANIES.find((c) => c.id === id);
export const memberOf = (id: string) => MEMBERS.find((m) => m.id === id) ?? ME;components/niceui/pages/apps/crm/fields.tsx
import { useEffect, useMemo, useRef, useState, type KeyboardEvent } from 'react';
import { Briefcase, Building2, CalendarDays, Check, CircleDot, DollarSign, Globe, Mail, MapPin, Percent, Phone, ShieldCheck, Star, Target as TargetIcon, UserRound, Users, type LucideIcon } from 'lucide-react';
import { cn } from '@/lib/cn';
import { INDUSTRIES, LIFECYCLES, MEMBERS, STAGES } from './data';
import { useCrm } from './store';
import { Avatar, Logo, MemberChip, Pill, Stars, fmtDate, money } from './ui';
export type FieldType = 'text' | 'email' | 'phone' | 'company' | 'person' | 'member' | 'select' | 'currency' | 'number' | 'date' | 'rating' | 'link' | 'boolean' | 'percent';
export type Field = { key: string; label: string; icon: LucideIcon; type: FieldType; w: number; options?: readonly string[] };
export const PEOPLE_FIELDS: Field[] = [
{ key: 'email', label: 'Email', icon: Mail, type: 'email', w: 236 },
{ key: 'companyId', label: 'Company', icon: Building2, type: 'company', w: 190 },
{ key: 'title', label: 'Job title', icon: Briefcase, type: 'text', w: 176 },
{ key: 'phone', label: 'Phone', icon: Phone, type: 'phone', w: 164 },
{ key: 'lifecycle', label: 'Lifecycle', icon: CircleDot, type: 'select', w: 116, options: LIFECYCLES },
{ key: 'intent', label: 'Intent', icon: Star, type: 'rating', w: 108 },
{ key: 'lastContact', label: 'Last contact', icon: CalendarDays, type: 'date', w: 128 },
{ key: 'owner', label: 'Owner', icon: UserRound, type: 'member', w: 150 },
{ key: 'city', label: 'City', icon: MapPin, type: 'text', w: 120 },
];
export const COMPANY_FIELDS: Field[] = [
{ key: 'domain', label: 'Domain', icon: Globe, type: 'link', w: 172 },
{ key: 'industry', label: 'Industry', icon: CircleDot, type: 'select', w: 116, options: INDUSTRIES },
{ key: 'employees', label: 'Employees', icon: Users, type: 'number', w: 112 },
{ key: 'arr', label: 'ARR', icon: DollarSign, type: 'currency', w: 104 },
{ key: 'owner', label: 'Account owner', icon: UserRound, type: 'member', w: 152 },
{ key: 'icp', label: 'Ideal customer', icon: ShieldCheck, type: 'boolean', w: 128 },
{ key: 'rating', label: 'Fit', icon: Star, type: 'rating', w: 108 },
{ key: 'city', label: 'City', icon: MapPin, type: 'text', w: 118 },
{ key: 'created', label: 'Created', icon: CalendarDays, type: 'date', w: 126 },
];
export const OPP_FIELDS: Field[] = [
{ key: 'amount', label: 'Amount', icon: DollarSign, type: 'currency', w: 108 },
{ key: 'stage', label: 'Stage', icon: TargetIcon, type: 'select', w: 116, options: STAGES },
{ key: 'close', label: 'Close date', icon: CalendarDays, type: 'date', w: 126 },
{ key: 'companyId', label: 'Company', icon: Building2, type: 'company', w: 190 },
{ key: 'poc', label: 'Point of contact', icon: UserRound, type: 'person', w: 176 },
{ key: 'owner', label: 'Owner', icon: UserRound, type: 'member', w: 150 },
{ key: 'probability', label: 'Probability', icon: Percent, type: 'percent', w: 120 },
];
export const NUMERIC: FieldType[] = ['currency', 'number', 'percent', 'rating'];
export const DIRECT: FieldType[] = ['rating', 'boolean'];
export function Empty() {
return <span className="text-[var(--faint)]">Empty</span>;
}
/** Read-only rendering of a field value. Ratings and booleans are edited in place via `onChange`. */
export function FieldValue({ field, value, onChange }: { field: Field; value: unknown; onChange?: (v: unknown) => void }) {
const crm = useCrm();
switch (field.type) {
case 'company': {
const c = crm.companies.find((x) => x.id === value);
if (!c) return <Empty />;
return (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
crm.openRecord({ kind: 'company', id: c.id });
}}
className="inline-flex max-w-full items-center gap-1.5 rounded-[4px] bg-[var(--sub)] py-px pl-1 pr-1.5 hover:bg-[var(--hover)]"
>
<Logo company={c} size={14} />
<span className="truncate">{c.name}</span>
</button>
);
}
case 'person': {
const p = crm.people.find((x) => x.id === value);
if (!p) return <Empty />;
return (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
crm.openRecord({ kind: 'person', id: p.id });
}}
className="inline-flex max-w-full items-center gap-1.5 rounded-full bg-[var(--sub)] py-px pl-px pr-2 hover:bg-[var(--hover)]"
>
<Avatar name={p.name} size={16} />
<span className="truncate">{p.name}</span>
</button>
);
}
case 'member':
return <MemberChip member={MEMBERS.find((m) => m.id === value) ?? MEMBERS[0]} />;
case 'select':
return value ? <Pill value={String(value)} /> : <Empty />;
case 'currency':
return <span className="tabular-nums">{money(Number(value) || 0)}</span>;
case 'number':
return <span className="tabular-nums">{(Number(value) || 0).toLocaleString('en-US')}</span>;
case 'percent': {
const n = Number(value) || 0;
return (
<span className="flex w-full items-center gap-2">
<span className="w-8 shrink-0 tabular-nums">{n}%</span>
<span className="h-1 flex-1 overflow-hidden rounded-full bg-[var(--sub)]">
<span className="block h-full rounded-full bg-[var(--accent)]" style={{ width: `${n}%` }} />
</span>
</span>
);
}
case 'date':
return value ? <span className="tabular-nums text-[var(--fg)]">{fmtDate(String(value))}</span> : <Empty />;
case 'rating':
return <Stars value={Number(value) || 0} onChange={onChange} />;
case 'link':
return value ? (
<span className="inline-flex max-w-full items-center gap-1 rounded-[4px] bg-[var(--sub)] px-1.5 py-px text-[12px]">
<Globe className="h-3 w-3 shrink-0 text-[var(--mute)]" />
<span className="truncate">{String(value)}</span>
</span>
) : (
<Empty />
);
case 'boolean': {
const on = Boolean(value);
return (
<button
type="button"
role="checkbox"
aria-checked={on}
onClick={(e) => {
e.stopPropagation();
onChange?.(!on);
}}
className="inline-flex items-center gap-1.5"
>
<span className={cn('grid h-3.5 w-3.5 place-items-center rounded-[4px] border transition-colors', on ? 'border-[var(--accent)] bg-[var(--accent)] text-white' : 'border-[var(--line2)]')}>
{on && <Check className="h-2.5 w-2.5" strokeWidth={3} />}
</span>
{on ? 'Yes' : 'No'}
</button>
);
}
default:
return value ? <span className="truncate">{String(value)}</span> : <Empty />;
}
}
/** Inline editor overlay. Commits on Enter or blur, cancels on Escape. */
export function FieldEditor({ field, value, onCommit, onCancel }: { field: Field; value: unknown; onCommit: (v: unknown) => void; onCancel: () => void }) {
const crm = useCrm();
const list =
field.type === 'select'
? (field.options ?? []).map((o) => ({ id: o, label: o, node: <Pill value={o} /> }))
: field.type === 'company'
? crm.companies.map((c) => ({ id: c.id, label: c.name, node: <span className="flex min-w-0 items-center gap-2"><Logo company={c} size={16} /><span className="truncate">{c.name}</span></span> }))
: field.type === 'person'
? crm.people.map((p) => ({ id: p.id, label: p.name, node: <span className="flex min-w-0 items-center gap-2"><Avatar name={p.name} size={16} /><span className="truncate">{p.name}</span></span> }))
: field.type === 'member'
? MEMBERS.map((m) => ({ id: m.id, label: m.name, node: <MemberChip member={m} /> }))
: null;
if (list) return <ListEditor items={list} current={String(value ?? '')} searchable={field.type !== 'select'} onPick={onCommit} onCancel={onCancel} />;
const numeric = field.type === 'currency' || field.type === 'number' || field.type === 'percent';
return (
<TextEditor
type={field.type === 'date' ? 'date' : numeric ? 'number' : field.type === 'email' ? 'email' : 'text'}
initial={value == null ? '' : String(value)}
onCommit={(s) => onCommit(numeric ? Math.max(0, Number(s) || 0) : s)}
onCancel={onCancel}
/>
);
}
export function TextEditor({ initial, type = 'text', onCommit, onCancel, className }: { initial: string; type?: string; onCommit: (s: string) => void; onCancel: () => void; className?: string }) {
const [v, setV] = useState(initial);
const done = useRef(false);
const finish = (commit: boolean) => {
if (done.current) return;
done.current = true;
if (commit) onCommit(v.trim());
else onCancel();
};
return (
<input
autoFocus
type={type}
value={v}
onChange={(e) => setV(e.target.value)}
onFocus={(e) => e.currentTarget.select()}
onKeyDown={(e) => {
e.stopPropagation();
if (e.key === 'Enter') finish(true);
if (e.key === 'Escape') finish(false);
}}
onBlur={() => finish(true)}
className={cn('h-8 w-full min-w-[160px] rounded-md border border-[var(--accent)] bg-[var(--panel)] px-2 text-[13px] text-[var(--strong)] outline-none', className)}
style={{ boxShadow: 'var(--pop)' }}
/>
);
}
function ListEditor({ items, current, searchable, onPick, onCancel }: { items: { id: string; label: string; node: JSX.Element }[]; current: string; searchable: boolean; onPick: (id: string) => void; onCancel: () => void }) {
const [q, setQ] = useState('');
const shown = useMemo(() => items.filter((i) => i.label.toLowerCase().includes(q.toLowerCase())), [items, q]);
const [hi, setHi] = useState(() => Math.max(0, items.findIndex((i) => i.id === current)));
const box = useRef<HTMLDivElement>(null);
useEffect(() => {
const down = (e: PointerEvent) => box.current && !box.current.contains(e.target as Node) && onCancel();
const id = window.setTimeout(() => document.addEventListener('pointerdown', down), 0);
return () => {
window.clearTimeout(id);
document.removeEventListener('pointerdown', down);
};
}, [onCancel]);
useEffect(() => setHi(0), [q]);
const key = (e: KeyboardEvent) => {
e.stopPropagation();
if (e.key === 'ArrowDown') {
e.preventDefault();
setHi((h) => Math.min(shown.length - 1, h + 1));
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setHi((h) => Math.max(0, h - 1));
} else if (e.key === 'Enter' && shown[hi]) onPick(shown[hi].id);
else if (e.key === 'Escape') onCancel();
};
return (
<div ref={box} onKeyDown={key} className="w-[220px] rounded-lg border border-[var(--line)] bg-[var(--panel)] p-1 animate-scale-in" style={{ boxShadow: 'var(--pop)' }}>
{searchable ? (
<input autoFocus value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search…" aria-label="Search" className="mb-1 h-7 w-full rounded-md bg-[var(--sub)] px-2 text-[13px] text-[var(--strong)] outline-none placeholder:text-[var(--faint)]" />
) : (
<button autoFocus className="sr-only" aria-label="Options" />
)}
<div role="listbox" className="max-h-52 overflow-y-auto">
{shown.map((i, n) => (
<button
key={i.id}
type="button"
role="option"
aria-selected={i.id === current}
onMouseEnter={() => setHi(n)}
onClick={() => onPick(i.id)}
className={cn('flex h-8 w-full items-center gap-2 rounded-md px-2 text-left text-[13px]', n === hi && 'bg-[var(--hover)]')}
>
<span className="min-w-0 flex-1">{i.node}</span>
{i.id === current && <Check className="h-3.5 w-3.5 text-[var(--accent)]" />}
</button>
))}
{!shown.length && <p className="px-2 py-2 text-[12px] text-[var(--mute)]">No matches</p>}
</div>
</div>
);
}components/niceui/pages/apps/crm/store.tsx
import { createContext, useContext } from 'react';
import type { Company, Note, Opportunity, Person, Target, Task } from './data';
export type ObjectKey = 'people' | 'companies' | 'opportunities' | 'tasks' | 'notes' | 'workflows' | 'dashboard';
export type Activity = { id: string; at: string; text: string; kind: 'edit' | 'task' | 'note' | 'stage' };
export type Crm = {
people: Person[];
companies: Company[];
opps: Opportunity[];
tasks: Task[];
notes: Note[];
setPeople: (f: (p: Person[]) => Person[]) => void;
setCompanies: (f: (c: Company[]) => Company[]) => void;
setOpps: (f: (o: Opportunity[]) => Opportunity[]) => void;
setTasks: (f: (t: Task[]) => Task[]) => void;
setNotes: (f: (n: Note[]) => Note[]) => void;
/** Session activity per record id, newest first. */
activity: Record<string, Activity[]>;
log: (id: string, text: string, kind?: Activity['kind']) => void;
openRecord: (t: Target) => void;
favorites: Target[];
toggleFavorite: (t: Target) => void;
toast: (msg: string) => void;
go: (o: ObjectKey) => void;
};
export const CrmContext = createContext<Crm | null>(null);
export function useCrm() {
const c = useContext(CrmContext);
if (!c) throw new Error('useCrm outside CrmApp');
return c;
}
export const recordName = (crm: Crm, t: Target) =>
t.kind === 'person' ? crm.people.find((p) => p.id === t.id)?.name : t.kind === 'company' ? crm.companies.find((c) => c.id === t.id)?.name : crm.opps.find((o) => o.id === t.id)?.name;
export const sameTarget = (a: Target, b: Target) => a.kind === b.kind && a.id === b.id;components/niceui/pages/apps/crm/ui.tsx
import { useEffect, useRef, type CSSProperties, type ReactNode } from 'react';
import { Star } from 'lucide-react';
import { cn } from '@/lib/cn';
import { TODAY, type Company, type Member } from './data';
/* Theme: every colour is a CSS variable so light and dark share one set of classes. */
export const THEMES: Record<'light' | 'dark', CSSProperties> = {
light: {
'--bg': '#ffffff',
'--side': '#fafafa',
'--sub': '#f4f4f5',
'--hover': 'rgba(0,0,0,0.045)',
'--line': '#ebebeb',
'--line2': '#dcdcdc',
'--fg': '#3a3a3a',
'--strong': '#141414',
'--mute': '#828282',
'--faint': '#b4b4b4',
'--accent': '#3e63dd',
'--accent-soft': 'rgba(62,99,221,0.1)',
'--panel': '#ffffff',
'--pop': '0 12px 32px -8px rgba(0,0,0,0.18), 0 0 0 1px rgba(0,0,0,0.06)',
} as CSSProperties,
dark: {
'--bg': '#151515',
'--side': '#111111',
'--sub': '#1d1d1d',
'--hover': 'rgba(255,255,255,0.06)',
'--line': '#262626',
'--line2': '#333333',
'--fg': '#d0d0d0',
'--strong': '#f5f5f5',
'--mute': '#8b8b8b',
'--faint': '#575757',
'--accent': '#7c95ff',
'--accent-soft': 'rgba(124,149,255,0.15)',
'--panel': '#191919',
'--pop': '0 16px 40px -8px rgba(0,0,0,0.7), 0 0 0 1px rgba(255,255,255,0.07)',
} as CSSProperties,
};
export const initials = (name: string) =>
name
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((w) => w[0]?.toUpperCase() ?? '')
.join('') || '?';
const PALETTE = ['#3e63dd', '#12a594', '#d6409f', '#c77d00', '#8e4ec6', '#2b9a66', '#dc3e42', '#0d9b8a'];
export const colorFor = (s: string) => PALETTE[[...s].reduce((a, ch) => a + ch.charCodeAt(0), 0) % PALETTE.length];
export function Avatar({ name, color, size = 18, className }: { name: string; color?: string; size?: number; className?: string }) {
return (
<span
className={cn('inline-grid shrink-0 place-items-center rounded-full font-medium text-white', className)}
style={{ width: size, height: size, fontSize: Math.max(8, size * 0.42), background: color ?? colorFor(name) }}
aria-hidden
>
{initials(name)}
</span>
);
}
export function Logo({ company, size = 18 }: { company: Pick<Company, 'name' | 'color'>; size?: number }) {
return (
<span className="inline-grid shrink-0 place-items-center rounded-[5px] font-semibold text-white" style={{ width: size, height: size, fontSize: size * 0.5, background: company.color }} aria-hidden>
{company.name[0]}
</span>
);
}
export function MemberChip({ member, size = 16 }: { member: Member; size?: number }) {
return (
<span className="inline-flex min-w-0 items-center gap-1.5">
<Avatar name={member.name} color={member.color} size={size} />
<span className="truncate">{member.name}</span>
</span>
);
}
export const TONE: Record<string, string> = {
Lead: '', MQL: '#b5730a', SQL: '#8e4ec6', Customer: '#2b9a66', Churned: '#dc3e42',
New: '', Screening: '#8e4ec6', Meeting: '#b5730a', Proposal: '#3e63dd',
SaaS: '#3e63dd', Fintech: '#2b9a66', Health: '#cf3897', Logistics: '#b5730a', Energy: '#0d9b8a', Retail: '#dc3e42', Robotics: '#8e4ec6', Media: '#c77d00',
};
export function Pill({ value, className }: { value: string; className?: string }) {
const c = TONE[value];
return (
<span
className={cn('inline-flex h-[20px] max-w-full items-center truncate rounded-[4px] px-1.5 text-[12px] font-medium', !c && 'bg-[var(--hover)] text-[var(--fg)]', className)}
style={c ? { color: c, background: `${c}1f` } : undefined}
>
{value}
</span>
);
}
export function Stars({ value, onChange, size = 13 }: { value: number; onChange?: (v: number) => void; size?: number }) {
return (
<span className="inline-flex items-center gap-[2px]" role={onChange ? 'radiogroup' : undefined} aria-label={`Rating ${value} of 5`}>
{[1, 2, 3, 4, 5].map((i) => {
const on = i <= value;
const star = <Star className={cn(on ? 'fill-[#e8a400] text-[#e8a400]' : 'text-[var(--faint)]')} style={{ width: size, height: size }} />;
return onChange ? (
<button
key={i}
type="button"
role="radio"
aria-checked={i === value}
aria-label={`${i} star${i > 1 ? 's' : ''}`}
onClick={(e) => {
e.stopPropagation();
onChange(i === value ? i - 1 : i);
}}
className="rounded-sm transition-transform hover:scale-110"
>
{star}
</button>
) : (
<span key={i}>{star}</span>
);
})}
</span>
);
}
export const money = (n: number, full = false) => {
if (full) return '$' + n.toLocaleString('en-US');
if (n >= 1_000_000) return '$' + (n / 1_000_000).toFixed(n >= 10_000_000 ? 0 : 1).replace(/\.0$/, '') + 'M';
if (n >= 1_000) return '$' + (n / 1_000).toFixed(n >= 100_000 ? 0 : 1).replace(/\.0$/, '') + 'k';
return '$' + n;
};
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
export const fmtDate = (iso: string) => {
const d = new Date(iso + 'T12:00:00');
if (Number.isNaN(d.getTime())) return iso;
return `${MONTHS[d.getMonth()]} ${d.getDate()}, ${d.getFullYear()}`;
};
export const daysFromToday = (iso: string) => Math.round((new Date(iso + 'T12:00:00').getTime() - new Date(TODAY.toISOString().slice(0, 10) + 'T12:00:00').getTime()) / 86_400_000);
export const relDay = (iso: string) => {
const d = daysFromToday(iso);
if (d === 0) return 'Today';
if (d === 1) return 'Tomorrow';
if (d === -1) return 'Yesterday';
if (d < 0 && d > -7) return `${-d} days ago`;
if (d > 0 && d < 7) return `In ${d} days`;
return fmtDate(iso);
};
/** Popover menu anchored to its parent (parent must be `relative`). Closes on outside click and Escape. */
export function Menu({ open, onClose, children, align = 'left', className }: { open: boolean; onClose: () => void; children: ReactNode; align?: 'left' | 'right'; className?: string }) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const down = (e: PointerEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) onClose();
};
const key = (e: KeyboardEvent) => e.key === 'Escape' && onClose();
const id = window.setTimeout(() => document.addEventListener('pointerdown', down), 0);
document.addEventListener('keydown', key);
return () => {
window.clearTimeout(id);
document.removeEventListener('pointerdown', down);
document.removeEventListener('keydown', key);
};
}, [open, onClose]);
if (!open) return null;
return (
<div
ref={ref}
role="menu"
className={cn('absolute top-[calc(100%+4px)] z-40 min-w-[200px] rounded-lg border border-[var(--line)] bg-[var(--panel)] p-1 text-[13px] text-[var(--fg)] animate-scale-in', align === 'right' ? 'right-0 origin-top-right' : 'left-0 origin-top-left', className)}
style={{ boxShadow: 'var(--pop)' }}
>
{children}
</div>
);
}
export function MenuItem({ children, onClick, active, danger, icon: Icon, hint }: { children: ReactNode; onClick?: () => void; active?: boolean; danger?: boolean; icon?: typeof Star; hint?: ReactNode }) {
return (
<button
type="button"
role="menuitem"
onClick={onClick}
className={cn('flex h-8 w-full items-center gap-2 rounded-md px-2 text-left transition-colors hover:bg-[var(--hover)]', active && 'bg-[var(--hover)] text-[var(--strong)]', danger && 'text-[#dc3e42]')}
>
{Icon && <Icon className="h-3.5 w-3.5 shrink-0 text-[var(--mute)]" />}
<span className="min-w-0 flex-1 truncate">{children}</span>
{hint && <span className="shrink-0 text-[11px] text-[var(--mute)]">{hint}</span>}
</button>
);
}
export const btn = 'inline-flex h-7 items-center gap-1.5 rounded-md px-2 text-[13px] text-[var(--fg)] transition-colors hover:bg-[var(--hover)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent)]';
export const btnPrimary = 'inline-flex h-7 items-center gap-1.5 rounded-md bg-[var(--accent)] px-2.5 text-[13px] font-medium text-white transition-opacity hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent)]';components/niceui/pages/apps/crm/KanbanView.tsx
import { useState, type KeyboardEvent } from 'react';
import { CalendarDays, GripVertical, Plus } from 'lucide-react';
import { cn } from '@/lib/cn';
import { MEMBERS, STAGES, type Opportunity, type Stage } from './data';
import { useCrm } from './store';
import { Avatar, Logo, TONE, money, relDay } from './ui';
export function KanbanView({ rows, onNew }: { rows: Opportunity[]; onNew: (stage: Stage) => void }) {
const crm = useCrm();
const [drag, setDrag] = useState<string | null>(null);
const [over, setOver] = useState<Stage | null>(null);
const move = (id: string, stage: Stage) => {
const o = crm.opps.find((x) => x.id === id);
if (!o || o.stage === stage) return;
crm.setOpps((all) => all.map((x) => (x.id === id ? { ...x, stage, probability: stage === 'Customer' ? 100 : x.probability } : x)));
crm.log(id, `Stage changed from ${o.stage} to ${stage}`, 'stage');
crm.toast(`${o.name} moved to ${stage}`);
};
const keyMove = (e: KeyboardEvent, o: Opportunity) => {
const i = STAGES.indexOf(o.stage);
if (e.key === 'ArrowRight' && i < STAGES.length - 1) {
e.preventDefault();
move(o.id, STAGES[i + 1]);
} else if (e.key === 'ArrowLeft' && i > 0) {
e.preventDefault();
move(o.id, STAGES[i - 1]);
} else if (e.key === 'Enter') crm.openRecord({ kind: 'opportunity', id: o.id });
};
return (
<div className="min-h-0 flex-1 overflow-auto overscroll-contain [scrollbar-width:thin]">
<div className="flex h-full min-w-max gap-0 px-2 pb-3">
{STAGES.map((stage) => {
const cards = rows.filter((o) => o.stage === stage);
const total = cards.reduce((s, o) => s + o.amount, 0);
const tone = TONE[stage];
return (
<section
key={stage}
aria-label={`${stage}, ${cards.length} opportunities`}
onDragOver={(e) => {
e.preventDefault();
setOver(stage);
}}
onDragLeave={() => setOver((s) => (s === stage ? null : s))}
onDrop={(e) => {
e.preventDefault();
const id = e.dataTransfer.getData('text/plain') || drag;
if (id) move(id, stage);
setDrag(null);
setOver(null);
}}
className={cn('flex w-[252px] shrink-0 flex-col border-r border-[var(--line)] px-2 transition-colors last:border-r-0', over === stage && 'bg-[var(--accent-soft)]')}
>
<header className="sticky top-0 z-10 flex h-10 items-center gap-2 bg-[var(--bg)] px-1">
<span className="inline-flex h-[20px] items-center gap-1.5 rounded-[4px] px-1.5 text-[12px] font-medium" style={tone ? { color: tone, background: `${tone}1f` } : { background: 'var(--hover)' }}>
{stage}
</span>
<span className="text-[12px] text-[var(--mute)]">{cards.length}</span>
<span className="ml-auto font-mono text-[11.5px] tabular-nums text-[var(--mute)]">{money(total)}</span>
</header>
<div className="flex flex-col gap-2 pb-2">
{cards.map((o) => {
const c = crm.companies.find((x) => x.id === o.companyId);
const p = crm.people.find((x) => x.id === o.poc);
const m = MEMBERS.find((x) => x.id === o.owner) ?? MEMBERS[0];
return (
<article
key={o.id}
draggable
tabIndex={0}
aria-label={`${o.name}, ${money(o.amount)}. Arrow keys move between stages, Enter opens.`}
onDragStart={(e) => {
e.dataTransfer.setData('text/plain', o.id);
e.dataTransfer.effectAllowed = 'move';
setDrag(o.id);
}}
onDragEnd={() => {
setDrag(null);
setOver(null);
}}
onKeyDown={(e) => keyMove(e, o)}
onClick={() => crm.openRecord({ kind: 'opportunity', id: o.id })}
className={cn(
'group cursor-grab rounded-lg border border-[var(--line)] bg-[var(--panel)] p-2.5 text-[13px] transition-all hover:border-[var(--line2)] hover:shadow-[0_2px_8px_-2px_rgba(0,0,0,0.12)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent)] active:cursor-grabbing',
drag === o.id && 'rotate-1 opacity-50',
)}
>
<div className="flex items-start gap-2">
{c && <Logo company={c} size={18} />}
<p className="min-w-0 flex-1 font-medium leading-snug text-[var(--strong)]">{o.name}</p>
<GripVertical className="h-3.5 w-3.5 shrink-0 text-[var(--faint)] opacity-0 transition-opacity group-hover:opacity-100" />
</div>
<dl className="mt-2 space-y-1.5 text-[12.5px] text-[var(--fg)]">
<div className="flex items-center justify-between gap-2">
<dt className="sr-only">Amount</dt>
<dd className="font-medium tabular-nums text-[var(--strong)]">{money(o.amount, true)}</dd>
<dd className="flex items-center gap-1 text-[var(--mute)]">
<CalendarDays className="h-3 w-3" /> {relDay(o.close)}
</dd>
</div>
{p && (
<div className="flex items-center gap-1.5 text-[var(--mute)]">
<Avatar name={p.name} size={16} />
<span className="truncate text-[var(--fg)]">{p.name}</span>
</div>
)}
<div className="flex items-center justify-between">
<span className="flex items-center gap-1.5 text-[var(--mute)]">
<Avatar name={m.name} color={m.color} size={16} />
{m.name.split(' ')[0]}
</span>
<span className="h-1 w-16 overflow-hidden rounded-full bg-[var(--sub)]" title={`${o.probability}% probability`}>
<span className="block h-full rounded-full bg-[var(--accent)]" style={{ width: `${o.probability}%` }} />
</span>
</div>
</dl>
</article>
);
})}
<button onClick={() => onNew(stage)} className="flex h-8 items-center gap-1.5 rounded-md px-2 text-[13px] text-[var(--mute)] transition-colors hover:bg-[var(--hover)] hover:text-[var(--strong)]">
<Plus className="h-3.5 w-3.5" /> New
</button>
</div>
</section>
);
})}
</div>
</div>
);
}components/niceui/pages/apps/crm/RecordPanel.tsx
import { useEffect, useMemo, useState } from 'react';
import { Building2, CalendarClock, Check, CheckSquare, ChevronRight, CircleDot, FileText, Mail, MessageSquare, PhoneCall, Plus, Sparkles, Star, StickyNote, X } from 'lucide-react';
import { cn } from '@/lib/cn';
import { ME, MEMBERS, type Target } from './data';
import { COMPANY_FIELDS, DIRECT, FieldEditor, FieldValue, OPP_FIELDS, PEOPLE_FIELDS, TextEditor, type Field } from './fields';
import { sameTarget, useCrm, type Crm } from './store';
import { Avatar, Logo, Pill, btn, fmtDate, money, relDay } from './ui';
type Tab = 'timeline' | 'tasks' | 'notes' | 'emails';
function useRecord(crm: Crm, t: Target) {
if (t.kind === 'person') {
const r = crm.people.find((p) => p.id === t.id);
return r ? { name: r.name, data: r as unknown as Record<string, unknown>, fields: PEOPLE_FIELDS } : null;
}
if (t.kind === 'company') {
const r = crm.companies.find((c) => c.id === t.id);
return r ? { name: r.name, data: r as unknown as Record<string, unknown>, fields: COMPANY_FIELDS } : null;
}
const r = crm.opps.find((o) => o.id === t.id);
return r ? { name: r.name, data: r as unknown as Record<string, unknown>, fields: OPP_FIELDS } : null;
}
const ICON = { email: Mail, call: PhoneCall, meeting: CalendarClock, note: StickyNote, stage: CircleDot, created: Sparkles, edit: CircleDot, task: CheckSquare } as const;
function baseTimeline(crm: Crm, t: Target, name: string) {
const first = name.split(' ')[0];
const owner = MEMBERS[(t.id.length + name.length) % MEMBERS.length];
const items: { kind: keyof typeof ICON; text: string; when: string; detail?: string }[] = [];
if (t.kind === 'person') {
items.push(
{ kind: 'email', text: `${owner.name} sent “Next steps for the rollout”`, when: '2026-10-29', detail: `Hi ${first}, attaching the plan we discussed and the security pack…` },
{ kind: 'meeting', text: `Meeting with ${first}: quarterly review`, when: '2026-10-24', detail: '45 min · Google Meet · 3 attendees' },
{ kind: 'call', text: `${owner.name} logged a call`, when: '2026-10-17', detail: 'Talked budget timing; decision expected after the board meeting.' },
{ kind: 'created', text: `${name} was created from an email import`, when: '2026-09-12' },
);
} else if (t.kind === 'company') {
items.push(
{ kind: 'stage', text: 'Ideal customer profile matched', when: '2026-10-27', detail: 'Industry, size and stack all fit the target segment.' },
{ kind: 'email', text: `${owner.name} replied to the procurement thread`, when: '2026-10-22' },
{ kind: 'note', text: 'Account plan updated', when: '2026-10-08', detail: 'Expansion path: analytics seats → data add-on → enterprise.' },
{ kind: 'created', text: `${name} was enriched automatically`, when: '2026-08-30' },
);
} else {
const o = crm.opps.find((x) => x.id === t.id);
items.push(
{ kind: 'stage', text: `Stage set to ${o?.stage ?? 'New'}`, when: '2026-10-26' },
{ kind: 'meeting', text: 'Demo with the buying committee', when: '2026-10-20', detail: '60 min · 5 attendees · recording attached' },
{ kind: 'email', text: `${owner.name} sent the pricing proposal`, when: '2026-10-15' },
{ kind: 'created', text: 'Opportunity created', when: '2026-09-28' },
);
}
return items;
}
function emailsFor(name: string) {
const first = name.split(' ')[0];
return [
{ id: 'e1', subject: 'Next steps for the rollout', from: ME.name, when: '2026-10-29', snippet: `Hi ${first}, attaching the plan we discussed and the security pack.`, body: `Hi ${first},\n\nAttaching the rollout plan and our security pack. If the timeline works for your team, we can lock the kickoff for the week of Nov 16.\n\nBest,\nAva` },
{ id: 'e2', subject: 'Re: Pricing for annual plan', from: name, when: '2026-10-21', snippet: 'Thanks, this looks close. Could you share the volume tier at 50 seats?', body: 'Thanks, this looks close. Could you share the volume tier at 50 seats? Finance wants to see both options before Friday.' },
{ id: 'e3', subject: 'Intro: Meridian × your team', from: MEMBERS[1].name, when: '2026-10-03', snippet: `Great to meet you ${first}! Looping in Ava who leads your account.`, body: `Great to meet you ${first}! Looping in Ava who leads your account and will set up a working session.` },
];
}
export function RecordPanel({ target, onClose }: { target: Target; onClose: () => void }) {
const crm = useCrm();
const rec = useRecord(crm, target);
const [tab, setTab] = useState<Tab>('timeline');
const [editing, setEditing] = useState<string | null>(null);
const [newTask, setNewTask] = useState('');
const [noteDraft, setNoteDraft] = useState<string | null>(null);
const [openMail, setOpenMail] = useState<string | null>('e1');
useEffect(() => {
const k = (e: KeyboardEvent) => e.key === 'Escape' && !editing && onClose();
document.addEventListener('keydown', k);
return () => document.removeEventListener('keydown', k);
}, [onClose, editing]);
useEffect(() => {
setTab('timeline');
setEditing(null);
}, [target.id]);
const tasks = crm.tasks.filter((x) => sameTarget(x.target, target));
const notes = crm.notes.filter((x) => sameTarget(x.target, target));
const emails = useMemo(() => emailsFor(rec?.name ?? ''), [rec?.name]);
if (!rec) return null;
const fav = crm.favorites.some((f) => sameTarget(f, target));
const update = (key: string, value: unknown) => {
const label = rec.fields.find((f) => f.key === key)?.label ?? key;
if (target.kind === 'person') crm.setPeople((all) => all.map((x) => (x.id === target.id ? { ...x, [key]: value } : x)));
else if (target.kind === 'company') crm.setCompanies((all) => all.map((x) => (x.id === target.id ? { ...x, [key]: value } : x)));
else crm.setOpps((all) => all.map((x) => (x.id === target.id ? { ...x, [key]: value } : x)));
if (key !== 'name') crm.log(target.id, `You updated ${label}`, 'edit');
};
const company = target.kind === 'person' ? crm.companies.find((c) => c.id === rec.data.companyId) : target.kind === 'opportunity' ? crm.companies.find((c) => c.id === rec.data.companyId) : crm.companies.find((c) => c.id === target.id);
const subtitle =
target.kind === 'person' ? `${String(rec.data.title)}${company ? ` · ${company.name}` : ''}` : target.kind === 'company' ? String(rec.data.domain) : `${money(Number(rec.data.amount), true)} · closes ${relDay(String(rec.data.close))}`;
const session = crm.activity[target.id] ?? [];
const timeline = baseTimeline(crm, target, rec.name);
const tabs: { id: Tab; label: string; n?: number; icon: typeof Mail }[] = [
{ id: 'timeline', label: 'Timeline', icon: CircleDot },
{ id: 'tasks', label: 'Tasks', n: tasks.length, icon: CheckSquare },
{ id: 'notes', label: 'Notes', n: notes.length, icon: StickyNote },
{ id: 'emails', label: 'Emails', n: emails.length, icon: Mail },
];
return (
<aside role="dialog" aria-label={rec.name} className="absolute inset-y-0 right-0 z-30 flex w-full flex-col border-l border-[var(--line)] bg-[var(--panel)] animate-slide-in-right sm:w-[460px]" style={{ boxShadow: 'var(--pop)' }}>
<div className="flex h-11 shrink-0 items-center gap-1 border-b border-[var(--line)] px-3">
<span className="text-[12px] capitalize text-[var(--mute)]">{target.kind === 'opportunity' ? 'Opportunity' : target.kind}</span>
<ChevronRight className="h-3 w-3 text-[var(--faint)]" />
<span className="min-w-0 flex-1 truncate text-[12px] text-[var(--fg)]">{rec.name}</span>
<button onClick={() => crm.toggleFavorite(target)} aria-pressed={fav} aria-label={fav ? 'Remove from favorites' : 'Add to favorites'} className={btn}>
<Star className={cn('h-3.5 w-3.5', fav && 'fill-[#e8a400] text-[#e8a400]')} />
</button>
<button onClick={onClose} aria-label="Close record" className={btn}>
<X className="h-4 w-4" />
</button>
</div>
<div className="min-h-0 flex-1 overflow-y-auto [scrollbar-width:thin]">
<div className="px-5 pb-4 pt-5">
<div className="flex items-start gap-3">
{target.kind === 'person' ? <Avatar name={rec.name} size={40} /> : company ? <Logo company={company} size={40} /> : null}
<div className="min-w-0 flex-1">
{editing === 'name' ? (
<TextEditor initial={rec.name} onCommit={(s) => { update('name', s || rec.name); setEditing(null); }} onCancel={() => setEditing(null)} className="h-9 text-[17px] font-semibold" />
) : (
<button onClick={() => setEditing('name')} className="block max-w-full truncate rounded-md text-left text-[18px] font-semibold tracking-[-0.01em] text-[var(--strong)] hover:bg-[var(--hover)]">
{rec.name}
</button>
)}
<p className="mt-0.5 truncate text-[13px] text-[var(--mute)]">{subtitle}</p>
</div>
{target.kind === 'opportunity' && <Pill value={String(rec.data.stage)} />}
</div>
</div>
<dl className="border-y border-[var(--line)] px-3 py-2">
{rec.fields.map((f: Field) => (
<div key={f.key} className="relative flex min-h-[32px] items-center gap-2 rounded-md px-2 hover:bg-[var(--hover)]">
<dt className="flex w-[128px] shrink-0 items-center gap-1.5 text-[13px] text-[var(--mute)]">
<f.icon className="h-3.5 w-3.5" /> {f.label}
</dt>
<dd
className="flex min-w-0 flex-1 cursor-pointer items-center text-[13px] text-[var(--fg)]"
onClick={() => !DIRECT.includes(f.type) && setEditing(f.key)}
onKeyDown={(e) => e.key === 'Enter' && !DIRECT.includes(f.type) && setEditing(f.key)}
tabIndex={DIRECT.includes(f.type) ? -1 : 0}
role="button"
aria-label={`Edit ${f.label}`}
>
<FieldValue field={f} value={rec.data[f.key]} onChange={(v) => update(f.key, v)} />
</dd>
{editing === f.key && (
<div className="absolute left-[136px] top-0 z-40">
<FieldEditor field={f} value={rec.data[f.key]} onCommit={(v) => { update(f.key, v); setEditing(null); }} onCancel={() => setEditing(null)} />
</div>
)}
</div>
))}
</dl>
{target.kind === 'company' && (
<div className="border-b border-[var(--line)] px-5 py-3">
<p className="mb-2 flex items-center gap-1.5 text-[12px] font-medium text-[var(--mute)]"><Building2 className="h-3.5 w-3.5" /> People at {rec.name}</p>
<div className="flex flex-wrap gap-1.5">
{crm.people.filter((p) => p.companyId === target.id).map((p) => (
<button key={p.id} onClick={() => crm.openRecord({ kind: 'person', id: p.id })} className="inline-flex items-center gap-1.5 rounded-full bg-[var(--sub)] py-0.5 pl-0.5 pr-2 text-[12.5px] hover:bg-[var(--hover)]">
<Avatar name={p.name} size={18} /> {p.name}
</button>
))}
</div>
</div>
)}
<div role="tablist" className="sticky top-0 z-10 flex gap-1 border-b border-[var(--line)] bg-[var(--panel)] px-3">
{tabs.map((t) => (
<button key={t.id} role="tab" aria-selected={tab === t.id} onClick={() => setTab(t.id)} className={cn('relative flex h-10 items-center gap-1.5 px-2 text-[13px] transition-colors', tab === t.id ? 'text-[var(--strong)]' : 'text-[var(--mute)] hover:text-[var(--fg)]')}>
<t.icon className="h-3.5 w-3.5" /> {t.label}
{t.n !== undefined && <span className="rounded bg-[var(--sub)] px-1 text-[11px] text-[var(--mute)]">{t.n}</span>}
{tab === t.id && <span className="absolute inset-x-1 -bottom-px h-[2px] rounded-full bg-[var(--strong)]" />}
</button>
))}
</div>
<div className="px-5 py-4">
{tab === 'timeline' && (
<ol className="relative space-y-4 before:absolute before:bottom-2 before:left-[11px] before:top-2 before:w-px before:bg-[var(--line)]">
{session.map((a) => (
<li key={a.id} className="relative flex gap-3 animate-fade-up">
<span className="relative z-10 grid h-6 w-6 shrink-0 place-items-center rounded-full border border-[var(--accent)] bg-[var(--accent-soft)] text-[var(--accent)]"><Check className="h-3 w-3" /></span>
<div className="min-w-0 pt-0.5"><p className="text-[13px] text-[var(--strong)]">{a.text}</p><p className="text-[12px] text-[var(--mute)]">Just now</p></div>
</li>
))}
{timeline.map((a, i) => {
const Icon = ICON[a.kind];
return (
<li key={i} className="relative flex gap-3">
<span className="relative z-10 grid h-6 w-6 shrink-0 place-items-center rounded-full border border-[var(--line)] bg-[var(--panel)] text-[var(--mute)]"><Icon className="h-3 w-3" /></span>
<div className="min-w-0 pt-0.5">
<p className="text-[13px] text-[var(--strong)]">{a.text}</p>
<p className="text-[12px] text-[var(--mute)]">{relDay(a.when)}</p>
{a.detail && <p className="mt-1.5 rounded-md border border-[var(--line)] bg-[var(--sub)] px-2.5 py-2 text-[12.5px] leading-relaxed text-[var(--fg)]">{a.detail}</p>}
</div>
</li>
);
})}
</ol>
)}
{tab === 'tasks' && (
<div className="space-y-1">
<form
onSubmit={(e) => {
e.preventDefault();
if (!newTask.trim()) return;
crm.setTasks((all) => [{ id: `t${Date.now()}`, title: newTask.trim(), due: '2026-11-04', done: false, assignee: ME.id, target }, ...all]);
crm.log(target.id, `Task “${newTask.trim()}” created`, 'task');
setNewTask('');
}}
className="mb-2 flex items-center gap-2 rounded-md border border-[var(--line)] px-2"
>
<Plus className="h-3.5 w-3.5 text-[var(--mute)]" />
<input value={newTask} onChange={(e) => setNewTask(e.target.value)} placeholder="Add a task and press Enter" aria-label="New task" className="h-8 min-w-0 flex-1 bg-transparent text-[13px] text-[var(--strong)] outline-none placeholder:text-[var(--faint)]" />
</form>
{tasks.map((t) => (
<label key={t.id} className="flex cursor-pointer items-center gap-2.5 rounded-md px-2 py-1.5 hover:bg-[var(--hover)]">
<input type="checkbox" checked={t.done} onChange={() => crm.setTasks((all) => all.map((x) => (x.id === t.id ? { ...x, done: !x.done } : x)))} className="h-3.5 w-3.5 accent-[var(--accent)]" />
<span className={cn('min-w-0 flex-1 truncate text-[13px]', t.done ? 'text-[var(--faint)] line-through' : 'text-[var(--strong)]')}>{t.title}</span>
<span className="text-[12px] text-[var(--mute)]">{relDay(t.due)}</span>
</label>
))}
{!tasks.length && <p className="py-6 text-center text-[13px] text-[var(--mute)]">No tasks yet.</p>}
</div>
)}
{tab === 'notes' && (
<div className="space-y-2">
{noteDraft === null ? (
<button onClick={() => setNoteDraft('')} className={cn(btn, 'border border-dashed border-[var(--line2)]')}><Plus className="h-3.5 w-3.5" /> New note</button>
) : (
<div className="rounded-lg border border-[var(--accent)] p-2">
<textarea autoFocus value={noteDraft} onChange={(e) => setNoteDraft(e.target.value)} rows={3} placeholder="Write a note…" aria-label="Note" className="w-full resize-none bg-transparent text-[13px] text-[var(--strong)] outline-none placeholder:text-[var(--faint)]" />
<div className="flex justify-end gap-1">
<button onClick={() => setNoteDraft(null)} className={btn}>Cancel</button>
<button
onClick={() => {
if (noteDraft.trim()) {
crm.setNotes((all) => [{ id: `n${Date.now()}`, title: noteDraft.trim().split('\n')[0].slice(0, 48), body: noteDraft.trim(), target, author: ME.id, updated: '2026-10-30' }, ...all]);
crm.log(target.id, 'You added a note', 'note');
}
setNoteDraft(null);
}}
className="inline-flex h-7 items-center rounded-md bg-[var(--accent)] px-2.5 text-[13px] font-medium text-white"
>
Save
</button>
</div>
</div>
)}
{notes.map((n) => (
<article key={n.id} className="rounded-lg border border-[var(--line)] p-3">
<p className="flex items-center gap-1.5 text-[13px] font-medium text-[var(--strong)]"><FileText className="h-3.5 w-3.5 text-[var(--mute)]" /> {n.title}</p>
<p className="mt-1 text-[12.5px] leading-relaxed text-[var(--fg)]">{n.body}</p>
<p className="mt-2 text-[11.5px] text-[var(--mute)]">{MEMBERS.find((m) => m.id === n.author)?.name} · {fmtDate(n.updated)}</p>
</article>
))}
</div>
)}
{tab === 'emails' && (
<div className="divide-y divide-[var(--line)] rounded-lg border border-[var(--line)]">
{emails.map((m) => (
<div key={m.id}>
<button onClick={() => setOpenMail(openMail === m.id ? null : m.id)} aria-expanded={openMail === m.id} className="flex w-full items-start gap-2.5 px-3 py-2.5 text-left hover:bg-[var(--hover)]">
<Avatar name={m.from} size={22} />
<span className="min-w-0 flex-1">
<span className="flex items-center gap-2"><span className="truncate text-[13px] font-medium text-[var(--strong)]">{m.subject}</span><span className="ml-auto shrink-0 text-[11.5px] text-[var(--mute)]">{relDay(m.when)}</span></span>
<span className="block truncate text-[12.5px] text-[var(--mute)]">{m.from} · {m.snippet}</span>
</span>
</button>
{openMail === m.id && <p className="whitespace-pre-line border-t border-[var(--line)] bg-[var(--sub)] px-3 py-3 text-[12.5px] leading-relaxed text-[var(--fg)] animate-fade-in">{m.body}</p>}
</div>
))}
<div className="flex items-center gap-2 px-3 py-2 text-[12px] text-[var(--mute)]"><MessageSquare className="h-3.5 w-3.5" /> Synced from {ME.name.split(' ')[0]}’s inbox</div>
</div>
)}
</div>
</div>
</aside>
);
}components/niceui/pages/apps/crm/CommandMenu.tsx
import { useEffect, useMemo, useRef, useState } from 'react';
import { Building2, CheckSquare, CornerDownLeft, LayoutDashboard, Moon, PanelLeft, Plus, Search, StickyNote, Target, Users, Zap } from 'lucide-react';
import { cn } from '@/lib/cn';
import { useCrm, type ObjectKey } from './store';
import { Avatar, Logo } from './ui';
type Item = { id: string; group: string; label: string; hint?: string; icon: JSX.Element; run: () => void };
export function CommandMenu({ onClose, onNew, onTheme, onCollapse }: { onClose: () => void; onNew: (o: ObjectKey) => void; onTheme: () => void; onCollapse: () => void }) {
const crm = useCrm();
const [q, setQ] = useState('');
const [hi, setHi] = useState(0);
const list = useRef<HTMLDivElement>(null);
const icon = (I: typeof Users) => <I className="h-4 w-4 text-[var(--mute)]" />;
const items = useMemo<Item[]>(() => {
const nav: Item[] = (
[
['people', 'People', Users],
['companies', 'Companies', Building2],
['opportunities', 'Opportunities', Target],
['tasks', 'Tasks', CheckSquare],
['notes', 'Notes', StickyNote],
['workflows', 'Workflows', Zap],
['dashboard', 'Dashboard', LayoutDashboard],
] as const
).map(([k, label, I]) => ({ id: `go-${k}`, group: 'Navigate', label: `Go to ${label}`, icon: icon(I), run: () => crm.go(k) }));
const actions: Item[] = [
{ id: 'new-person', group: 'Actions', label: 'Create person', hint: 'N then P', icon: icon(Plus), run: () => onNew('people') },
{ id: 'new-company', group: 'Actions', label: 'Create company', icon: icon(Plus), run: () => onNew('companies') },
{ id: 'new-opp', group: 'Actions', label: 'Create opportunity', icon: icon(Plus), run: () => onNew('opportunities') },
{ id: 'theme', group: 'Actions', label: 'Toggle dark mode', icon: icon(Moon), run: onTheme },
{ id: 'collapse', group: 'Actions', label: 'Toggle sidebar', icon: icon(PanelLeft), run: onCollapse },
];
const people: Item[] = crm.people.map((p) => ({ id: p.id, group: 'People', label: p.name, hint: p.email, icon: <Avatar name={p.name} size={18} />, run: () => crm.openRecord({ kind: 'person', id: p.id }) }));
const companies: Item[] = crm.companies.map((c) => ({ id: c.id, group: 'Companies', label: c.name, hint: c.domain, icon: <Logo company={c} size={18} />, run: () => crm.openRecord({ kind: 'company', id: c.id }) }));
const opps: Item[] = crm.opps.map((o) => ({ id: o.id, group: 'Opportunities', label: o.name, hint: o.stage, icon: icon(Target), run: () => crm.openRecord({ kind: 'opportunity', id: o.id }) }));
const s = q.trim().toLowerCase();
if (!s) return [...nav, ...actions, ...people.slice(0, 4), ...companies.slice(0, 3)];
return [...people, ...companies, ...opps, ...nav, ...actions].filter((i) => (i.label + ' ' + (i.hint ?? '')).toLowerCase().includes(s)).slice(0, 30);
}, [crm, q, onNew, onTheme, onCollapse]);
useEffect(() => setHi(0), [q]);
useEffect(() => {
list.current?.querySelector<HTMLElement>(`[data-i="${hi}"]`)?.scrollIntoView({ block: 'nearest' });
}, [hi]);
const run = (i: Item | undefined) => {
if (!i) return;
onClose();
i.run();
};
let lastGroup = '';
return (
<div className="absolute inset-0 z-50 flex items-start justify-center bg-black/25 px-3 pt-[12vh] animate-fade-in sm:pt-24" onPointerDown={(e) => e.target === e.currentTarget && onClose()}>
<div role="dialog" aria-label="Command menu" className="w-full max-w-[560px] overflow-hidden rounded-xl border border-[var(--line)] bg-[var(--panel)] animate-scale-in" style={{ boxShadow: 'var(--pop)' }}>
<div className="flex items-center gap-2 border-b border-[var(--line)] px-3.5">
<Search className="h-4 w-4 text-[var(--mute)]" />
<input
autoFocus
value={q}
onChange={(e) => setQ(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'ArrowDown') {
e.preventDefault();
setHi((h) => Math.min(items.length - 1, h + 1));
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setHi((h) => Math.max(0, h - 1));
} else if (e.key === 'Enter') run(items[hi]);
else if (e.key === 'Escape') onClose();
}}
placeholder="Search records or type a command…"
aria-label="Search"
role="combobox"
aria-expanded
aria-controls="mrd-cmd-list"
className="h-12 min-w-0 flex-1 bg-transparent text-[14px] text-[var(--strong)] outline-none placeholder:text-[var(--faint)]"
/>
<kbd className="rounded border border-[var(--line)] px-1.5 font-mono text-[10.5px] text-[var(--mute)]">esc</kbd>
</div>
<div ref={list} id="mrd-cmd-list" role="listbox" className="max-h-[360px] overflow-y-auto p-1.5 [scrollbar-width:thin]">
{items.map((i, n) => {
const head = i.group !== lastGroup;
lastGroup = i.group;
return (
<div key={i.id}>
{head && <p className="px-2 pb-1 pt-2 text-[11.5px] font-medium text-[var(--mute)]">{i.group}</p>}
<button
data-i={n}
role="option"
aria-selected={n === hi}
onMouseMove={() => setHi(n)}
onClick={() => run(i)}
className={cn('flex h-9 w-full items-center gap-2.5 rounded-md px-2 text-left text-[13px] text-[var(--strong)]', n === hi && 'bg-[var(--hover)]')}
>
{i.icon}
<span className="min-w-0 flex-1 truncate">{i.label}</span>
{i.hint && <span className="hidden max-w-[45%] truncate text-[12px] text-[var(--mute)] sm:block">{i.hint}</span>}
{n === hi && <CornerDownLeft className="h-3.5 w-3.5 shrink-0 text-[var(--mute)]" />}
</button>
</div>
);
})}
{!items.length && <p className="px-3 py-8 text-center text-[13px] text-[var(--mute)]">Nothing found for “{q}”.</p>}
</div>
</div>
</div>
);
}components/niceui/pages/apps/crm/Sidebar.tsx
import { useState } from 'react';
import { Building2, Check, CheckSquare, ChevronsUpDown, LayoutDashboard, Moon, PanelLeft, Search, Settings, StickyNote, Sun, Target, Users, Zap, type LucideIcon } from 'lucide-react';
import { cn } from '@/lib/cn';
import { ME } from './data';
import { recordName, useCrm, type ObjectKey } from './store';
import { Avatar, Logo, Menu, MenuItem } from './ui';
export const OBJECTS: { key: ObjectKey; label: string; icon: LucideIcon }[] = [
{ key: 'people', label: 'People', icon: Users },
{ key: 'companies', label: 'Companies', icon: Building2 },
{ key: 'opportunities', label: 'Opportunities', icon: Target },
{ key: 'tasks', label: 'Tasks', icon: CheckSquare },
{ key: 'notes', label: 'Notes', icon: StickyNote },
{ key: 'workflows', label: 'Workflows', icon: Zap },
{ key: 'dashboard', label: 'Dashboard', icon: LayoutDashboard },
];
type Props = {
current: ObjectKey;
collapsed: boolean;
onCollapse: () => void;
onSearch: () => void;
theme: 'light' | 'dark';
onTheme: () => void;
onSettings: () => void;
};
export function Sidebar({ current, collapsed, onCollapse, onSearch, theme, onTheme, onSettings }: Props) {
const crm = useCrm();
const [ws, setWs] = useState(false);
const count: Partial<Record<ObjectKey, number>> = { people: crm.people.length, companies: crm.companies.length, opportunities: crm.opps.length, tasks: crm.tasks.filter((t) => !t.done).length, notes: crm.notes.length };
const item = (active: boolean) =>
cn('flex h-7 w-full items-center gap-2 rounded-md px-2 text-[13px] transition-colors', active ? 'bg-[var(--hover)] font-medium text-[var(--strong)]' : 'text-[var(--fg)] hover:bg-[var(--hover)]', collapsed && 'justify-center px-0');
return (
<nav aria-label="CRM" className={cn('flex h-full shrink-0 flex-col border-r border-[var(--line)] bg-[var(--side)] px-2 py-2 transition-[width] duration-300 ease-smooth', collapsed ? 'w-[52px]' : 'w-[228px]')}>
<div className="relative flex items-center gap-1">
<button onClick={() => setWs((o) => !o)} aria-haspopup="menu" aria-expanded={ws} className={cn('flex h-8 min-w-0 flex-1 items-center gap-2 rounded-md px-1.5 hover:bg-[var(--hover)]', collapsed && 'justify-center px-0')}>
<span className="grid h-5 w-5 shrink-0 place-items-center rounded-[5px] bg-[var(--accent)] text-[11px] font-bold text-white">M</span>
{!collapsed && (
<>
<span className="truncate text-[13px] font-semibold text-[var(--strong)]">Meridian</span>
<ChevronsUpDown className="ml-auto h-3.5 w-3.5 shrink-0 text-[var(--mute)]" />
</>
)}
</button>
{!collapsed && (
<button onClick={onCollapse} aria-label="Collapse sidebar" className="grid h-7 w-7 shrink-0 place-items-center rounded-md text-[var(--mute)] hover:bg-[var(--hover)] hover:text-[var(--strong)]">
<PanelLeft className="h-4 w-4" />
</button>
)}
<Menu open={ws} onClose={() => setWs(false)} className="w-[220px]">
<p className="px-2 pb-1 pt-1.5 text-[11.5px] text-[var(--mute)]">{ME.name}’s workspaces</p>
<MenuItem active hint={<Check className="h-3.5 w-3.5 text-[var(--accent)]" />} onClick={() => setWs(false)}>Meridian</MenuItem>
<MenuItem onClick={() => { setWs(false); crm.toast('Sandbox workspaces are a paid feature in this demo'); }}>Meridian Sandbox</MenuItem>
<div className="my-1 h-px bg-[var(--line)]" />
<MenuItem icon={theme === 'light' ? Moon : Sun} onClick={() => { setWs(false); onTheme(); }}>{theme === 'light' ? 'Dark mode' : 'Light mode'}</MenuItem>
</Menu>
</div>
<button onClick={onSearch} className={cn(item(false), 'mt-1 text-[var(--mute)]')} aria-label="Search (Ctrl K)" title="Search">
<Search className="h-4 w-4 shrink-0" />
{!collapsed && (
<>
<span className="flex-1 text-left">Search</span>
<kbd className="rounded border border-[var(--line)] px-1 font-mono text-[10px] text-[var(--mute)]">⌘K</kbd>
</>
)}
</button>
{collapsed && (
<button onClick={onCollapse} aria-label="Expand sidebar" className={cn(item(false), 'text-[var(--mute)]')} title="Expand sidebar">
<PanelLeft className="h-4 w-4" />
</button>
)}
{!collapsed && crm.favorites.length > 0 && (
<div className="mt-4">
<p className="px-2 pb-1 text-[11.5px] font-medium text-[var(--mute)]">Favorites</p>
{crm.favorites.map((f) => {
const name = recordName(crm, f) ?? '';
const company = f.kind === 'company' ? crm.companies.find((c) => c.id === f.id) : undefined;
return (
<button key={f.kind + f.id} onClick={() => crm.openRecord(f)} className={item(false)}>
{f.kind === 'person' ? <Avatar name={name} size={16} /> : company ? <Logo company={company} size={16} /> : <Target className="h-4 w-4 text-[var(--mute)]" />}
<span className="truncate">{name}</span>
</button>
);
})}
</div>
)}
<div className="mt-4">
{!collapsed && <p className="px-2 pb-1 text-[11.5px] font-medium text-[var(--mute)]">Workspace</p>}
{OBJECTS.map((o) => (
<button key={o.key} onClick={() => crm.go(o.key)} aria-current={current === o.key ? 'page' : undefined} className={item(current === o.key)} title={collapsed ? o.label : undefined}>
<o.icon className="h-4 w-4 shrink-0 text-[var(--mute)]" />
{!collapsed && (
<>
<span className="flex-1 truncate text-left">{o.label}</span>
{count[o.key] !== undefined && <span className="text-[11.5px] tabular-nums text-[var(--faint)]">{count[o.key]}</span>}
</>
)}
</button>
))}
</div>
<div className="mt-auto space-y-0.5">
<button onClick={onSettings} className={item(false)} title={collapsed ? 'Settings' : undefined}>
<Settings className="h-4 w-4 shrink-0 text-[var(--mute)]" />
{!collapsed && <span className="flex-1 text-left">Settings</span>}
</button>
<div className={cn('flex h-8 items-center gap-2 px-1.5', collapsed && 'justify-center px-0')}>
<Avatar name={ME.name} color={ME.color} size={20} />
{!collapsed && <span className="min-w-0 flex-1 truncate text-[13px] text-[var(--fg)]">{ME.name}</span>}
{!collapsed && (
<button onClick={onTheme} aria-label={theme === 'light' ? 'Switch to dark mode' : 'Switch to light mode'} className="grid h-6 w-6 place-items-center rounded-md text-[var(--mute)] hover:bg-[var(--hover)] hover:text-[var(--strong)]">
{theme === 'light' ? <Moon className="h-3.5 w-3.5" /> : <Sun className="h-3.5 w-3.5" />}
</button>
)}
</div>
</div>
</nav>
);
}components/niceui/pages/apps/crm/TableView.tsx
import { useEffect, useRef, useState, type KeyboardEvent, type ReactNode } from 'react';
import { ArrowDown, ArrowUp, Check, Minus, Plus, Type } from 'lucide-react';
import { cn } from '@/lib/cn';
import { DIRECT, FieldEditor, FieldValue, TextEditor, type Field } from './fields';
export type Sort = { key: string; dir: 1 | -1 } | null;
type Row = { id: string; name: string };
type Props<T extends Row> = {
rows: T[];
fields: Field[];
leading: (r: T) => ReactNode;
selected: Set<string>;
setSelected: (s: Set<string>) => void;
sort: Sort;
onSort: (key: string) => void;
onUpdate: (id: string, key: string, value: unknown) => void;
onOpen: (id: string) => void;
onNew: () => void;
autoEdit: string | null;
clearAutoEdit: () => void;
nameLabel: string;
};
function Box({ on, partial, onClick, label }: { on: boolean; partial?: boolean; onClick: () => void; label: string }) {
return (
<button
type="button"
role="checkbox"
aria-checked={partial ? 'mixed' : on}
aria-label={label}
onClick={(e) => {
e.stopPropagation();
onClick();
}}
className={cn('grid h-3.5 w-3.5 place-items-center rounded-[4px] border transition-colors', on || partial ? 'border-[var(--accent)] bg-[var(--accent)] text-white' : 'border-[var(--line2)] bg-[var(--bg)] hover:border-[var(--mute)]')}
>
{on && <Check className="h-2.5 w-2.5" strokeWidth={3} />}
{partial && !on && <Minus className="h-2.5 w-2.5" strokeWidth={3} />}
</button>
);
}
export function TableView<T extends Row>({ rows, fields, leading, selected, setSelected, sort, onSort, onUpdate, onOpen, onNew, autoEdit, clearAutoEdit, nameLabel }: Props<T>) {
const [active, setActive] = useState<{ r: number; c: number } | null>(null);
const [editing, setEditing] = useState<{ id: string; c: number } | null>(null);
const wrap = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!autoEdit) return;
const r = rows.findIndex((x) => x.id === autoEdit);
if (r >= 0) {
setActive({ r, c: 0 });
setEditing({ id: autoEdit, c: 0 });
wrap.current?.scrollTo({ top: 0 });
}
clearAutoEdit();
}, [autoEdit, rows, clearAutoEdit]);
const all = rows.length > 0 && rows.every((r) => selected.has(r.id));
const some = rows.some((r) => selected.has(r.id));
const toggle = (id: string) => {
const n = new Set(selected);
if (n.has(id)) n.delete(id);
else n.add(id);
setSelected(n);
};
const startEdit = (r: number, c: number) => {
const row = rows[r];
if (!row) return;
if (c > 0 && DIRECT.includes(fields[c - 1].type)) return;
setEditing({ id: row.id, c });
};
const stopEdit = () => {
setEditing(null);
requestAnimationFrame(() => wrap.current?.focus({ preventScroll: true }));
};
const onKey = (e: KeyboardEvent) => {
if (editing) return;
const cur = active ?? { r: 0, c: 0 };
const move = (r: number, c: number) => {
e.preventDefault();
setActive({ r: Math.max(0, Math.min(rows.length - 1, r)), c: Math.max(0, Math.min(fields.length, c)) });
};
if (e.key === 'ArrowDown') move(cur.r + 1, cur.c);
else if (e.key === 'ArrowUp') move(cur.r - 1, cur.c);
else if (e.key === 'ArrowRight') move(cur.r, cur.c + 1);
else if (e.key === 'ArrowLeft') move(cur.r, cur.c - 1);
else if (e.key === 'Enter' && active) {
e.preventDefault();
startEdit(active.r, active.c);
} else if (e.key === ' ' && active && rows[active.r]) {
e.preventDefault();
toggle(rows[active.r].id);
} else if (e.key === 'Escape') setActive(null);
};
useEffect(() => {
if (!active || !wrap.current) return;
const cell = wrap.current.querySelector<HTMLElement>(`[data-cell="${active.r}-${active.c}"]`);
cell?.scrollIntoView({ block: 'nearest', inline: 'nearest' });
}, [active]);
const head = 'sticky top-0 h-8 border-b border-r border-[var(--line)] bg-[var(--bg)] px-2 text-left font-normal text-[var(--mute)]';
const SortIcon = ({ k }: { k: string }) => (sort?.key === k ? sort.dir === 1 ? <ArrowUp className="h-3 w-3 text-[var(--accent)]" /> : <ArrowDown className="h-3 w-3 text-[var(--accent)]" /> : null);
return (
<div
ref={wrap}
tabIndex={0}
onKeyDown={onKey}
aria-label={`${nameLabel} table. Arrow keys move, Enter edits, Space selects.`}
className="relative min-h-0 flex-1 overflow-auto overscroll-contain focus:outline-none [scrollbar-width:thin]"
>
<table className="w-max min-w-full border-separate border-spacing-0 text-[13px]">
<thead>
<tr>
<th className={cn(head, 'left-0 z-30 w-9 min-w-9 px-0')}>
<span className="grid place-items-center">
<Box on={all} partial={some && !all} onClick={() => setSelected(all ? new Set() : new Set(rows.map((r) => r.id)))} label="Select all" />
</span>
</th>
<th className={cn(head, 'left-9 z-30 w-[220px] min-w-[220px]')}>
<button onClick={() => onSort('name')} className="flex w-full items-center gap-1.5 hover:text-[var(--strong)]">
<Type className="h-3.5 w-3.5" /> {nameLabel} <SortIcon k="name" />
</button>
</th>
{fields.map((f) => (
<th key={f.key} className={cn(head, 'z-20')} style={{ width: f.w, minWidth: f.w }}>
<button onClick={() => onSort(f.key)} className="flex w-full items-center gap-1.5 whitespace-nowrap hover:text-[var(--strong)]">
<f.icon className="h-3.5 w-3.5 shrink-0" /> {f.label} <SortIcon k={f.key} />
</button>
</th>
))}
<th className={cn(head, 'z-20 w-full min-w-[60px] border-r-0')}>
<span className="grid h-6 w-6 place-items-center rounded-md text-[var(--faint)]" aria-hidden>
<Plus className="h-3.5 w-3.5" />
</span>
</th>
</tr>
</thead>
<tbody>
{rows.map((row, r) => {
const sel = selected.has(row.id);
const tint = sel ? 'shadow-[inset_0_0_0_999px_var(--accent-soft)]' : '';
const cellBase = 'relative h-9 border-b border-r border-[var(--line)] px-2 align-middle';
const isActive = (c: number) => active?.r === r && active?.c === c;
const ring = 'outline outline-1 -outline-offset-1 outline-[color:var(--accent)]';
return (
<tr key={row.id} className="group">
<td className={cn(cellBase, 'sticky left-0 z-10 w-9 min-w-9 bg-[var(--bg)] px-0 group-hover:bg-[var(--side)]', tint)}>
<span className="grid place-items-center">
<Box on={sel} onClick={() => toggle(row.id)} label={`Select ${row.name || 'row'}`} />
</span>
</td>
<td
data-cell={`${r}-0`}
onClick={() => setActive({ r, c: 0 })}
onDoubleClick={() => startEdit(r, 0)}
className={cn(cellBase, 'sticky left-9 z-10 w-[220px] min-w-[220px] max-w-[220px] bg-[var(--bg)] group-hover:bg-[var(--side)]', tint, isActive(0) && ring)}
>
{editing?.id === row.id && editing.c === 0 ? (
<div className="absolute left-1 top-0.5 z-40">
<TextEditor
initial={row.name}
onCommit={(s) => {
onUpdate(row.id, 'name', s || 'Untitled');
stopEdit();
}}
onCancel={stopEdit}
/>
</div>
) : (
<span className="flex min-w-0 items-center gap-2">
{leading(row)}
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onOpen(row.id);
}}
className="min-w-0 truncate rounded-sm font-medium text-[var(--strong)] hover:underline decoration-[var(--line2)] underline-offset-2"
>
{row.name || 'Untitled'}
</button>
</span>
)}
</td>
{fields.map((f, i) => {
const c = i + 1;
const value = (row as Record<string, unknown>)[f.key];
const ed = editing?.id === row.id && editing.c === c;
return (
<td
key={f.key}
data-cell={`${r}-${c}`}
onClick={() => setActive({ r, c })}
onDoubleClick={() => startEdit(r, c)}
className={cn(cellBase, 'max-w-0 bg-[var(--bg)] text-[var(--fg)] group-hover:bg-[var(--side)]', tint, isActive(c) && ring)}
style={{ width: f.w, minWidth: f.w, maxWidth: f.w }}
>
<span className="flex min-w-0 items-center overflow-hidden">
<FieldValue field={f} value={value} onChange={(v) => onUpdate(row.id, f.key, v)} />
</span>
{ed && (
<div className="absolute left-0 top-0.5 z-40">
<FieldEditor
field={f}
value={value}
onCommit={(v) => {
onUpdate(row.id, f.key, v);
stopEdit();
}}
onCancel={stopEdit}
/>
</div>
)}
</td>
);
})}
<td className={cn(cellBase, 'border-r-0 bg-[var(--bg)] group-hover:bg-[var(--side)]', tint)} />
</tr>
);
})}
<tr>
<td colSpan={fields.length + 3} className="sticky left-0 h-9 border-b border-[var(--line)] bg-[var(--bg)] px-2">
<button onClick={onNew} className="flex items-center gap-1.5 rounded-md px-1.5 py-1 text-[var(--mute)] hover:bg-[var(--hover)] hover:text-[var(--strong)]">
<Plus className="h-3.5 w-3.5" /> New
</button>
</td>
</tr>
</tbody>
</table>
{!rows.length && <p className="px-4 py-10 text-center text-[13px] text-[var(--mute)]">No records match this view.</p>}
</div>
);
}components/niceui/pages/apps/crm/Views.tsx
import { useMemo, useState } from 'react';
import { ArrowRight, ArrowUpRight, Bell, Building2, CalendarDays, ChevronDown, CircleDollarSign, FileText, Mail, Plus, Repeat, Sparkles, Target as TargetIcon, Trophy, UserPlus, Zap } from 'lucide-react';
import { cn } from '@/lib/cn';
import { ME, MEMBERS, STAGES } from './data';
import { recordName, useCrm } from './store';
import { Avatar, TONE, daysFromToday, fmtDate, money, relDay } from './ui';
/* ——— Tasks ——— */
export function TasksView() {
const crm = useCrm();
const [draft, setDraft] = useState('');
const [closed, setClosed] = useState<Record<string, boolean>>({});
const groups = useMemo(() => {
const g: Record<string, typeof crm.tasks> = { Overdue: [], Today: [], 'This week': [], Later: [], Completed: [] };
for (const t of crm.tasks) {
const d = daysFromToday(t.due);
if (t.done) g.Completed.push(t);
else if (d < 0) g.Overdue.push(t);
else if (d === 0) g.Today.push(t);
else if (d <= 7) g['This week'].push(t);
else g.Later.push(t);
}
return g;
}, [crm.tasks]);
return (
<div className="min-h-0 flex-1 overflow-y-auto px-3 py-3 [scrollbar-width:thin] sm:px-5">
<form
onSubmit={(e) => {
e.preventDefault();
if (!draft.trim()) return;
crm.setTasks((all) => [{ id: `t${Date.now()}`, title: draft.trim(), due: '2026-10-30', done: false, assignee: ME.id, target: { kind: 'company', id: 'c1' } }, ...all]);
crm.toast('Task added for today');
setDraft('');
}}
className="mb-4 flex items-center gap-2 rounded-lg border border-[var(--line)] px-3 focus-within:border-[var(--accent)]"
>
<Plus className="h-4 w-4 text-[var(--mute)]" />
<input value={draft} onChange={(e) => setDraft(e.target.value)} placeholder="Add a task for today and press Enter" aria-label="New task" className="h-9 min-w-0 flex-1 bg-transparent text-[13px] text-[var(--strong)] outline-none placeholder:text-[var(--faint)]" />
</form>
{Object.entries(groups).map(([name, list]) =>
list.length ? (
<section key={name} className="mb-3">
<button onClick={() => setClosed((c) => ({ ...c, [name]: !c[name] }))} aria-expanded={!closed[name]} className="mb-1 flex items-center gap-1.5 px-1 text-[12px] font-medium text-[var(--mute)] hover:text-[var(--strong)]">
<ChevronDown className={cn('h-3.5 w-3.5 transition-transform', closed[name] && '-rotate-90')} />
<span className={cn(name === 'Overdue' && 'text-[#dc3e42]')}>{name}</span>
<span className="text-[var(--faint)]">{list.length}</span>
</button>
{!closed[name] && (
<ul className="overflow-hidden rounded-lg border border-[var(--line)]">
{list.map((t) => {
const m = MEMBERS.find((x) => x.id === t.assignee) ?? ME;
const overdue = !t.done && daysFromToday(t.due) < 0;
return (
<li key={t.id} className="flex items-center gap-3 border-b border-[var(--line)] px-3 py-2 last:border-b-0 hover:bg-[var(--side)]">
<input type="checkbox" checked={t.done} aria-label={`Complete ${t.title}`} onChange={() => crm.setTasks((all) => all.map((x) => (x.id === t.id ? { ...x, done: !x.done } : x)))} className="h-3.5 w-3.5 shrink-0 accent-[var(--accent)]" />
<span className={cn('min-w-0 flex-1 truncate text-[13px]', t.done ? 'text-[var(--faint)] line-through' : 'text-[var(--strong)]')}>{t.title}</span>
<button onClick={() => crm.openRecord(t.target)} className="hidden max-w-[180px] items-center gap-1 truncate rounded-[4px] bg-[var(--sub)] px-1.5 py-px text-[12px] text-[var(--fg)] hover:bg-[var(--hover)] sm:inline-flex">
{recordName(crm, t.target)}
</button>
<span className={cn('flex shrink-0 items-center gap-1 text-[12px]', overdue ? 'text-[#dc3e42]' : 'text-[var(--mute)]')}>
<CalendarDays className="h-3 w-3" /> {relDay(t.due)}
</span>
<Avatar name={m.name} color={m.color} size={18} />
</li>
);
})}
</ul>
)}
</section>
) : null,
)}
</div>
);
}
/* ——— Notes ——— */
export function NotesView() {
const crm = useCrm();
return (
<div className="min-h-0 flex-1 overflow-y-auto p-3 [scrollbar-width:thin] sm:p-5">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-3">
{crm.notes.map((n) => {
const m = MEMBERS.find((x) => x.id === n.author) ?? ME;
return (
<button key={n.id} onClick={() => crm.openRecord(n.target)} className="group flex min-h-[150px] flex-col rounded-xl border border-[var(--line)] bg-[var(--panel)] p-4 text-left transition-all hover:border-[var(--line2)] hover:shadow-[0_4px_16px_-6px_rgba(0,0,0,0.15)]">
<span className="flex items-center gap-2 text-[13.5px] font-medium text-[var(--strong)]">
<FileText className="h-4 w-4 shrink-0 text-[var(--mute)]" />
<span className="truncate">{n.title}</span>
</span>
<span className="mt-2 line-clamp-3 flex-1 text-[12.5px] leading-relaxed text-[var(--fg)]">{n.body}</span>
<span className="mt-3 flex items-center gap-2 text-[11.5px] text-[var(--mute)]">
<Avatar name={m.name} color={m.color} size={16} /> {m.name.split(' ')[0]} · {fmtDate(n.updated)}
<span className="ml-auto truncate rounded-[4px] bg-[var(--sub)] px-1.5 py-px text-[var(--fg)]">{recordName(crm, n.target)}</span>
</span>
</button>
);
})}
</div>
</div>
);
}
/* ——— Workflows ——— */
const FLOWS = [
{ id: 'w1', name: 'Route inbound leads', steps: [{ icon: UserPlus, label: 'Person created' }, { icon: Sparkles, label: 'Enrich with AI' }, { icon: Repeat, label: 'Round-robin owner' }, { icon: Mail, label: 'Send intro email' }], runs: 1284, active: true },
{ id: 'w2', name: 'Deal won hand-off', steps: [{ icon: TargetIcon, label: 'Stage → Customer' }, { icon: Building2, label: 'Create onboarding' }, { icon: Bell, label: 'Notify success team' }], runs: 92, active: true },
{ id: 'w3', name: 'Stale deal nudge', steps: [{ icon: CalendarDays, label: 'Every Monday' }, { icon: Zap, label: 'No activity 14 days' }, { icon: Mail, label: 'Email the owner' }], runs: 418, active: false },
];
export function WorkflowsView() {
const crm = useCrm();
const [on, setOn] = useState<Record<string, boolean>>(() => Object.fromEntries(FLOWS.map((f) => [f.id, f.active])));
return (
<div className="min-h-0 flex-1 space-y-3 overflow-y-auto p-3 [scrollbar-width:thin] sm:p-5">
{FLOWS.map((f) => (
<article key={f.id} className="rounded-xl border border-[var(--line)] bg-[var(--panel)] p-4">
<div className="flex flex-wrap items-center gap-2">
<Zap className="h-4 w-4 text-[var(--accent)]" />
<h3 className="text-[14px] font-medium text-[var(--strong)]">{f.name}</h3>
<span className={cn('rounded-full px-2 py-px text-[11px] font-medium', on[f.id] ? 'bg-[#2b9a66]/15 text-[#2b9a66]' : 'bg-[var(--hover)] text-[var(--mute)]')}>{on[f.id] ? 'Active' : 'Draft'}</span>
<span className="ml-auto text-[12px] text-[var(--mute)]">{f.runs.toLocaleString('en-US')} runs</span>
<button
role="switch"
aria-checked={on[f.id]}
aria-label={`Activate ${f.name}`}
onClick={() => {
setOn((s) => ({ ...s, [f.id]: !s[f.id] }));
crm.toast(`${f.name} ${on[f.id] ? 'paused' : 'activated'}`);
}}
className={cn('relative h-5 w-9 rounded-full transition-colors', on[f.id] ? 'bg-[var(--accent)]' : 'bg-[var(--line2)]')}
>
<span className={cn('absolute top-0.5 h-4 w-4 rounded-full bg-white shadow transition-all', on[f.id] ? 'left-[18px]' : 'left-0.5')} />
</button>
</div>
<div className="mt-3 flex flex-wrap items-center gap-1.5">
{f.steps.map((s, i) => (
<span key={s.label} className="flex items-center gap-1.5">
<span className="inline-flex items-center gap-1.5 rounded-md border border-[var(--line)] bg-[var(--sub)] px-2 py-1 text-[12px] text-[var(--fg)]">
<s.icon className="h-3.5 w-3.5 text-[var(--mute)]" /> {s.label}
</span>
{i < f.steps.length - 1 && <ArrowRight className="h-3.5 w-3.5 text-[var(--faint)]" />}
</span>
))}
</div>
</article>
))}
</div>
);
}
/* ——— Dashboard ——— */
const REVENUE = [182, 205, 196, 240, 268, 251, 302, 329, 318, 366, 391, 428];
const MONTHS = ['Nov', 'Dec', 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct'];
export function DashboardView() {
const crm = useCrm();
const open = crm.opps.filter((o) => o.stage !== 'Customer');
const won = crm.opps.filter((o) => o.stage === 'Customer');
const pipeline = open.reduce((s, o) => s + o.amount, 0);
const weighted = open.reduce((s, o) => s + (o.amount * o.probability) / 100, 0);
const wonSum = won.reduce((s, o) => s + o.amount, 0);
const byStage = STAGES.map((s) => ({ s, v: crm.opps.filter((o) => o.stage === s).reduce((a, o) => a + o.amount, 0) }));
const maxStage = Math.max(...byStage.map((b) => b.v), 1);
const owners = MEMBERS.map((m) => ({ m, v: crm.opps.filter((o) => o.owner === m.id).reduce((a, o) => a + o.amount, 0) })).sort((a, b) => b.v - a.v);
const [hover, setHover] = useState<number | null>(null);
const W = 560;
const H = 150;
const max = Math.max(...REVENUE) * 1.1;
const pts = REVENUE.map((v, i) => [16 + (i / (REVENUE.length - 1)) * (W - 32), H - (v / max) * H] as const);
const line = pts.map(([x, y], i) => `${i ? 'L' : 'M'}${x.toFixed(1)},${y.toFixed(1)}`).join(' ');
const kpis = [
{ label: 'Open pipeline', value: money(pipeline), delta: '+12.4%', icon: CircleDollarSign },
{ label: 'Weighted forecast', value: money(Math.round(weighted)), delta: '+8.1%', icon: TargetIcon },
{ label: 'Won this quarter', value: money(wonSum), delta: '+21.0%', icon: Trophy },
{ label: 'Win rate', value: `${Math.round((won.length / Math.max(1, crm.opps.length)) * 100 + 18)}%`, delta: '+3 pts', icon: ArrowUpRight },
];
return (
<div className="min-h-0 flex-1 overflow-y-auto p-3 [scrollbar-width:thin] sm:p-5">
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
{kpis.map((k) => (
<div key={k.label} className="rounded-xl border border-[var(--line)] bg-[var(--panel)] p-3.5">
<p className="flex items-center gap-1.5 text-[12px] text-[var(--mute)]"><k.icon className="h-3.5 w-3.5" /> {k.label}</p>
<p className="mt-2 text-[22px] font-semibold tracking-[-0.02em] text-[var(--strong)] tabular-nums">{k.value}</p>
<p className="mt-0.5 text-[12px] font-medium text-[#2b9a66]">{k.delta} <span className="font-normal text-[var(--mute)]">vs last quarter</span></p>
</div>
))}
</div>
<div className="mt-3 grid gap-3 lg:grid-cols-[minmax(0,1.6fr)_minmax(0,1fr)]">
<section className="rounded-xl border border-[var(--line)] bg-[var(--panel)] p-4">
<div className="flex items-baseline justify-between">
<h3 className="text-[13.5px] font-medium text-[var(--strong)]">Monthly recurring revenue</h3>
<span className="font-mono text-[12px] tabular-nums text-[var(--mute)]">{hover === null ? '$428k in Oct' : `$${REVENUE[hover]}k in ${MONTHS[hover]}`}</span>
</div>
<svg viewBox={`0 0 ${W} ${H + 22}`} className="mt-3 h-auto w-full" role="img" aria-label="Monthly recurring revenue, last 12 months" onMouseLeave={() => setHover(null)}>
<defs>
<linearGradient id="mrd-area" x1="0" x2="0" y1="0" y2="1">
<stop offset="0" stopColor="var(--accent)" stopOpacity="0.22" />
<stop offset="1" stopColor="var(--accent)" stopOpacity="0" />
</linearGradient>
</defs>
{[0.25, 0.5, 0.75, 1].map((f) => (
<line key={f} x1="0" x2={W} y1={H * f} y2={H * f} stroke="var(--line)" strokeDasharray="3 4" />
))}
<path d={`${line} L${W},${H} L0,${H} Z`} fill="url(#mrd-area)" />
<path d={line} fill="none" stroke="var(--accent)" strokeWidth="2" strokeLinejoin="round" />
{pts.map(([x, y], i) => (
<g key={i} onMouseEnter={() => setHover(i)}>
<rect x={x - W / 24} y="0" width={W / 12} height={H} fill="transparent" />
<circle cx={x} cy={y} r={hover === i ? 4 : 2.5} fill="var(--panel)" stroke="var(--accent)" strokeWidth="2" />
<text x={x} y={H + 16} textAnchor="middle" fontSize="10" fill="var(--mute)">{MONTHS[i]}</text>
</g>
))}
</svg>
</section>
<section className="rounded-xl border border-[var(--line)] bg-[var(--panel)] p-4">
<h3 className="text-[13.5px] font-medium text-[var(--strong)]">Pipeline by stage</h3>
<div className="mt-3 space-y-2.5">
{byStage.map(({ s, v }) => (
<div key={s}>
<div className="flex items-center justify-between text-[12px]"><span className="text-[var(--fg)]">{s}</span><span className="font-mono tabular-nums text-[var(--mute)]">{money(v)}</span></div>
<div className="mt-1 h-1.5 overflow-hidden rounded-full bg-[var(--sub)]">
<div className="h-full rounded-full transition-all duration-700" style={{ width: `${(v / maxStage) * 100}%`, background: TONE[s] || 'var(--mute)' }} />
</div>
</div>
))}
</div>
</section>
</div>
<div className="mt-3 grid gap-3 lg:grid-cols-2">
<section className="rounded-xl border border-[var(--line)] bg-[var(--panel)] p-4">
<h3 className="text-[13.5px] font-medium text-[var(--strong)]">Pipeline by owner</h3>
<ol className="mt-2 divide-y divide-[var(--line)]">
{owners.map(({ m, v }, i) => (
<li key={m.id} className="flex items-center gap-2.5 py-2 text-[13px]">
<span className="w-4 text-[12px] text-[var(--faint)]">{i + 1}</span>
<Avatar name={m.name} color={m.color} size={20} />
<span className="min-w-0 flex-1 truncate text-[var(--strong)]">{m.name}</span>
<span className="font-mono text-[12px] tabular-nums text-[var(--fg)]">{money(v)}</span>
</li>
))}
</ol>
</section>
<section className="rounded-xl border border-[var(--line)] bg-[var(--panel)] p-4">
<h3 className="text-[13.5px] font-medium text-[var(--strong)]">Closing soon</h3>
<ul className="mt-2 divide-y divide-[var(--line)]">
{open
.slice()
.sort((a, b) => a.close.localeCompare(b.close))
.slice(0, 5)
.map((o) => (
<li key={o.id}>
<button onClick={() => crm.openRecord({ kind: 'opportunity', id: o.id })} className="flex w-full items-center gap-2 py-2 text-left text-[13px] hover:text-[var(--accent)]">
<span className="min-w-0 flex-1 truncate text-[var(--strong)]">{o.name}</span>
<span className="font-mono text-[12px] tabular-nums text-[var(--fg)]">{money(o.amount)}</span>
<span className="w-20 text-right text-[12px] text-[var(--mute)]">{relDay(o.close)}</span>
</button>
</li>
))}
</ul>
</section>
</div>
</div>
);
}