"use client";
import * as React from "react";
import { AnimatePresence, motion, MotionConfig } from "motion/react";
import { Building2, CalendarCheck2, Columns3, LayoutDashboard, Menu, Search, Sparkles, UsersRound, X, type LucideIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import { ActivitiesView } from "./activities-view";
import { CompaniesView } from "./companies-view";
import { ContactForm } from "./contact-form";
import { ContactsView } from "./contacts-view";
import { Avatar, Button, Field, IconButton, inputCls, Overlay, PanelHeader, Toasts, useToasts } from "./crm-ui";
import { DashboardView } from "./dashboard-view";
import { SEED_CRM, SEED_TODAY, uid } from "./data";
import { DealDrawer } from "./deal-drawer";
import { PipelineView } from "./pipeline-view";
import type { Contact, CrmData, CrmView, Deal } from "./types";
export type { Activity, Company, Contact, CrmData, Deal, Stage } from "./types";
export interface CrmAppProps {
/** Stages, owners, companies, contacts, deals and activities. Defaults to a seeded demo workspace. */
initialData?: CrmData;
/** Signed-in user (must exist in `owners`). Defaults to the first owner. */
currentUserId?: string;
/** Workspace name shown in the sidebar. */
workspace?: string;
/** "Today" as YYYY-MM-DD, used for due dates. Defaults to the seed date. */
today?: string;
/** View shown on mount. */
defaultView?: CrmView;
/** Called with the full dataset after every change — persist it here. */
onChange?: (data: CrmData) => void;
/** Called when a deal changes stage (drag, keyboard or drawer). */
onDealStageChange?: (deal: Deal, fromStageId: string, toStageId: string) => void;
/** Called when a contact is created or edited. */
onContactSave?: (contact: Contact, isNew: boolean) => void;
className?: string;
}
const NAV: { id: CrmView; label: string; icon: LucideIcon }[] = [
{ id: "dashboard", label: "Dashboard", icon: LayoutDashboard },
{ id: "deals", label: "Deals", icon: Columns3 },
{ id: "contacts", label: "Contacts", icon: UsersRound },
{ id: "companies", label: "Companies", icon: Building2 },
{ id: "activities", label: "Activities", icon: CalendarCheck2 },
];
type Dialog = { kind: "deal"; id: string } | { kind: "contact"; id: string | null } | { kind: "newDeal"; stageId: string } | null;
export function CrmApp({
initialData,
currentUserId,
workspace = "Northwind Sales",
today = SEED_TODAY,
defaultView = "deals",
onChange,
onDealStageChange,
onContactSave,
className,
}: CrmAppProps) {
const rootRef = React.useRef<HTMLDivElement>(null);
const [data, setData] = React.useState<CrmData>(initialData ?? SEED_CRM);
const [view, setView] = React.useState<CrmView>(defaultView);
const [dialog, setDialog] = React.useState<Dialog>(null);
const [navOpen, setNavOpen] = React.useState(false);
const [announce, setAnnounce] = React.useState("");
const { toasts, push, dismiss } = useToasts();
const me = data.owners.find((o) => o.id === currentUserId) ?? data.owners[0];
const onChangeRef = React.useRef(onChange);
React.useLayoutEffect(() => {
onChangeRef.current = onChange;
});
const first = React.useRef(true);
React.useEffect(() => {
if (first.current) {
first.current = false;
return;
}
onChangeRef.current?.(data);
}, [data]);
/* ------------------------------- mutations ------------------------------ */
const patchDeal = React.useCallback((deal: Deal) => setData((d) => ({ ...d, deals: d.deals.map((x) => (x.id === deal.id ? deal : x)) })), []);
const moveDeal = React.useCallback(
(dealId: string, stageId: string, index: number, via: "drag" | "keyboard" | "drawer") => {
const snapshot = data;
const deal = data.deals.find((d) => d.id === dealId);
const to = data.stages.find((s) => s.id === stageId);
const from = data.stages.find((s) => s.id === deal?.stageId);
if (!deal || !to || !from) return;
const changed = from.id !== to.id;
const stamp = `${today}T${new Date().toTimeString().slice(0, 8)}`;
const moved: Deal = changed
? {
...deal,
stageId,
probability: to.probability,
nextTask: to.closed ? null : deal.nextTask,
closeDate: to.closed ? today : deal.closeDate,
notes: [...deal.notes, { id: uid("n"), authorId: me.id, at: stamp, kind: "stage", text: `${from.name} → ${to.name}` }],
}
: deal;
setData((d) => {
const rest = d.deals.filter((x) => x.id !== dealId);
const inStage = rest.filter((x) => x.stageId === stageId);
const before = inStage[index];
const at = before ? rest.indexOf(before) : inStage.length ? rest.indexOf(inStage[inStage.length - 1]) + 1 : rest.length;
rest.splice(at, 0, moved);
return { ...d, deals: rest };
});
if (changed) {
onDealStageChange?.(moved, from.id, to.id);
const msg = `${deal.title} moved to ${to.name}`;
setAnnounce(msg);
push(to.closed === "won" ? `🎉 ${deal.title} won` : msg, () => setData(snapshot));
}
if (via === "keyboard") requestAnimationFrame(() => rootRef.current?.querySelector<HTMLElement>(`[data-deal-button="${CSS.escape(dealId)}"]`)?.focus());
},
[data, me.id, today, onDealStageChange, push],
);
const toggleActivity = React.useCallback((id: string, done: boolean) => setData((d) => ({ ...d, activities: d.activities.map((a) => (a.id === id ? { ...a, done } : a)) })), []);
const saveContact = (draft: Omit<Contact, "id" | "lastContacted">, existing: Contact | null) => {
const contact: Contact = existing ? { ...existing, ...draft } : { ...draft, id: uid("c"), lastContacted: today };
setData((d) => ({ ...d, contacts: existing ? d.contacts.map((c) => (c.id === existing.id ? contact : c)) : [contact, ...d.contacts] }));
onContactSave?.(contact, !existing);
setDialog(null);
push(existing ? `Saved ${contact.name}` : `Added ${contact.name}`);
};
const deleteContacts = (ids: string[]) => {
const snapshot = data;
setData((d) => ({ ...d, contacts: d.contacts.filter((c) => !ids.includes(c.id)), deals: d.deals.map((x) => ({ ...x, contactIds: x.contactIds.filter((id) => !ids.includes(id)) })) }));
push(`Deleted ${ids.length} ${ids.length === 1 ? "contact" : "contacts"}`, () => setData(snapshot));
};
/* ------------------------------- shortcuts ------------------------------ */
const keyRef = React.useRef<(e: KeyboardEvent) => void>(() => {});
React.useLayoutEffect(() => {
keyRef.current = (e) => {
if (dialog || e.metaKey || e.ctrlKey || e.altKey) return;
const t = e.target as HTMLElement;
if (t.closest("input,textarea,select,[contenteditable]")) return;
if (!rootRef.current?.contains(t) && t !== document.body) return;
const n = Number(e.key);
if (n >= 1 && n <= NAV.length) {
e.preventDefault();
setView(NAV[n - 1].id);
} else if (e.key === "c" && view === "contacts") {
e.preventDefault();
setDialog({ kind: "contact", id: null });
} else if (e.key === "n" && view === "deals") {
e.preventDefault();
setDialog({ kind: "newDeal", stageId: data.stages[0].id });
} else if (e.key === "/") {
const s = rootRef.current?.querySelector<HTMLInputElement>('main input[type="search"]');
if (s) {
e.preventDefault();
s.focus();
}
}
};
});
React.useEffect(() => {
const h = (e: KeyboardEvent) => keyRef.current(e);
window.addEventListener("keydown", h);
return () => window.removeEventListener("keydown", h);
}, []);
const openDeal = data.deals.find((d) => dialog?.kind === "deal" && d.id === dialog.id) ?? null;
const editContact = dialog?.kind === "contact" && dialog.id ? data.contacts.find((c) => c.id === dialog.id) ?? null : null;
const current = NAV.find((n) => n.id === view)!;
const counts: Partial<Record<CrmView, number>> = {
deals: data.deals.filter((d) => !data.stages.find((s) => s.id === d.stageId)?.closed).length,
contacts: data.contacts.length,
activities: data.activities.filter((a) => !a.done).length,
};
const nav = (
<nav aria-label="CRM" className="flex h-full flex-col">
<div className="flex h-14 items-center gap-2.5 px-4">
<span className="grid size-7 place-items-center rounded-lg bg-gradient-to-br from-primary to-primary/60 text-primary-foreground shadow-sm shadow-primary/30">
<Sparkles className="size-3.5" aria-hidden />
</span>
<span className="min-w-0 truncate text-[13px] font-semibold">{workspace}</span>
</div>
<button
type="button"
onClick={() => {
setView("contacts");
setNavOpen(false);
requestAnimationFrame(() => rootRef.current?.querySelector<HTMLInputElement>('main input[type="search"]')?.focus());
}}
className="mx-3 mb-3 flex h-8 items-center gap-2 rounded-lg border bg-background px-2.5 text-xs text-muted-foreground outline-none transition hover:border-foreground/20 focus-visible:ring-2 focus-visible:ring-ring dark:bg-input/20"
>
<Search className="size-3.5" aria-hidden /> Search
<kbd className="ml-auto rounded border px-1 font-mono text-[10px]">/</kbd>
</button>
<ul className="space-y-0.5 px-2">
{NAV.map((n, i) => {
const active = view === n.id;
return (
<li key={n.id}>
<button
type="button"
aria-current={active ? "page" : undefined}
title={`${n.label} (${i + 1})`}
onClick={() => {
setView(n.id);
setNavOpen(false);
}}
className={cn(
"relative flex h-8 w-full items-center gap-2.5 rounded-lg px-2.5 text-[13px] font-medium outline-none transition focus-visible:ring-2 focus-visible:ring-ring",
active ? "text-foreground" : "text-muted-foreground hover:bg-accent/60 hover:text-foreground",
)}
>
{active && <motion.span layoutId="crm-nav-pill" className="absolute inset-0 rounded-lg bg-background shadow-xs ring-1 ring-border dark:bg-accent" transition={{ type: "spring", stiffness: 500, damping: 38 }} />}
<n.icon className="relative size-4" aria-hidden />
<span className="relative flex-1 text-left">{n.label}</span>
{counts[n.id] !== undefined && <span className="relative text-[11px] tabular-nums text-muted-foreground">{counts[n.id]}</span>}
</button>
</li>
);
})}
</ul>
<div className="mt-auto flex items-center gap-2.5 border-t p-3">
<Avatar name={me.name} size="sm" />
<div className="min-w-0 text-xs">
<div className="truncate font-medium">{me.name}</div>
<div className="truncate text-muted-foreground">Account Executive</div>
</div>
</div>
</nav>
);
return (
<MotionConfig reducedMotion="user">
<div ref={rootRef} className={cn("relative isolate flex h-[760px] w-full overflow-hidden bg-background text-foreground antialiased", className)}>
<div inert={dialog || navOpen ? true : undefined} className="flex min-w-0 flex-1">
<aside className="hidden w-56 shrink-0 border-r bg-muted/40 md:block dark:bg-muted/20">{nav}</aside>
<main className="flex min-w-0 flex-1 flex-col">
<header className="flex h-14 shrink-0 items-center gap-2 border-b px-3 sm:px-5">
<IconButton label="Open navigation" className="md:hidden" onClick={() => setNavOpen(true)}>
<Menu className="size-4" />
</IconButton>
<current.icon className="hidden size-4 text-muted-foreground sm:block" aria-hidden />
<h2 className="text-sm font-semibold">{current.label}</h2>
<span className="hidden text-xs text-muted-foreground sm:inline">
· {view === "dashboard" ? "Q3 overview" : view === "deals" ? "Drag cards between stages" : view === "contacts" ? `${data.contacts.length} people` : view === "companies" ? "Accounts" : "Your follow-ups"}
</span>
</header>
<div className="min-h-0 flex-1">
<AnimatePresence mode="wait" initial={false}>
<motion.div key={view} className="h-full" initial={{ opacity: 0, y: 4 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, transition: { duration: 0.08 } }} transition={{ duration: 0.16 }}>
{view === "dashboard" && <DashboardView data={data} today={today} onToggleActivity={toggleActivity} onOpenDeal={(id) => setDialog({ kind: "deal", id })} onGo={setView} />}
{view === "deals" && (
<PipelineView data={data} today={today} rootRef={rootRef} onOpen={(id) => setDialog({ kind: "deal", id })} onMove={moveDeal} onNew={(stageId) => setDialog({ kind: "newDeal", stageId })} />
)}
{view === "contacts" && (
<ContactsView
data={data}
today={today}
onEdit={(c) => setDialog({ kind: "contact", id: c.id })}
onNew={() => setDialog({ kind: "contact", id: null })}
onDelete={deleteContacts}
onBulk={(ids, patch) => {
setData((d) => ({ ...d, contacts: d.contacts.map((c) => (ids.includes(c.id) ? { ...c, ...patch } : c)) }));
push(`Updated ${ids.length} ${ids.length === 1 ? "contact" : "contacts"}`);
}}
/>
)}
{view === "companies" && <CompaniesView data={data} onOpenDeal={(id) => setDialog({ kind: "deal", id })} />}
{view === "activities" && (
<ActivitiesView
data={data}
today={today}
meId={me.id}
onToggle={toggleActivity}
onAdd={(a) => {
setData((d) => ({ ...d, activities: [...d.activities, { ...a, id: uid("a") }] }));
push("Activity added");
}}
/>
)}
</motion.div>
</AnimatePresence>
</div>
</main>
</div>
{/* Mobile nav drawer */}
<AnimatePresence>
{navOpen && (
<>
<motion.div aria-hidden initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} onClick={() => setNavOpen(false)} className="absolute inset-0 z-40 bg-foreground/10 md:hidden dark:bg-black/50" />
<motion.aside
role="dialog"
aria-label="Navigation"
initial={{ x: "-100%" }}
animate={{ x: 0 }}
exit={{ x: "-100%" }}
transition={{ type: "spring", stiffness: 420, damping: 40 }}
onKeyDown={(e) => e.key === "Escape" && setNavOpen(false)}
className="absolute inset-y-0 left-0 z-50 w-64 border-r bg-background shadow-2xl md:hidden"
>
<IconButton label="Close navigation" className="absolute right-2 top-3 z-10" onClick={() => setNavOpen(false)}>
<X className="size-4" />
</IconButton>
{nav}
</motion.aside>
</>
)}
</AnimatePresence>
<Overlay open={dialog?.kind === "deal" && !!openDeal} onClose={() => setDialog(null)} label={openDeal ? `Deal: ${openDeal.title}` : "Deal"}>
{openDeal && <DealDrawer deal={openDeal} data={data} today={today} meId={me.id} onChange={patchDeal} onStage={(s) => moveDeal(openDeal.id, s, 0, "drawer")} onClose={() => setDialog(null)} />}
</Overlay>
<Overlay open={dialog?.kind === "contact"} onClose={() => setDialog(null)} label={editContact ? "Edit contact" : "New contact"} variant="dialog" className="max-sm:h-full max-sm:max-h-none max-sm:rounded-none">
{dialog?.kind === "contact" && <ContactForm key={dialog.id ?? "new"} contact={editContact} data={data} meId={me.id} onSave={(d) => saveContact(d, editContact)} onCancel={() => setDialog(null)} />}
</Overlay>
<Overlay open={dialog?.kind === "newDeal"} onClose={() => setDialog(null)} label="New deal" variant="dialog">
{dialog?.kind === "newDeal" && (
<NewDealForm
data={data}
stageId={dialog.stageId}
today={today}
onCancel={() => setDialog(null)}
onCreate={(deal) => {
const full: Deal = { ...deal, id: uid("d"), ownerId: me.id, createdAt: today, notes: [], nextTask: null, contactIds: data.contacts.filter((c) => c.companyId === deal.companyId).slice(0, 1).map((c) => c.id) };
setData((d) => ({ ...d, deals: [full, ...d.deals] }));
setDialog({ kind: "deal", id: full.id });
push(`Created ${full.title}`);
}}
/>
)}
</Overlay>
<div aria-live="assertive" className="sr-only">
{announce}
</div>
<Toasts toasts={toasts} dismiss={dismiss} />
</div>
</MotionConfig>
);
}
type NewDeal = Pick<Deal, "title" | "companyId" | "stageId" | "value" | "probability" | "closeDate">;
function NewDealForm({ data, stageId, today, onCreate, onCancel }: { data: CrmData; stageId: string; today: string; onCreate: (d: NewDeal) => void; onCancel: () => void }) {
const [title, setTitle] = React.useState("");
const [companyId, setCompanyId] = React.useState(data.companies[0]?.id ?? "");
const [value, setValue] = React.useState("");
const [stage, setStage] = React.useState(stageId);
const [close, setClose] = React.useState(() => {
const d = new Date(`${today}T12:00:00Z`);
d.setUTCDate(d.getUTCDate() + 30);
return d.toISOString().slice(0, 10);
});
const [tried, setTried] = React.useState(false);
const amount = Number(value.replace(/[^\d.]/g, ""));
const errTitle = !title.trim() ? "Give the deal a name." : undefined;
const errValue = !value.trim() || !(amount > 0) ? "Enter a value greater than 0." : undefined;
return (
<form
noValidate
onSubmit={(e) => {
e.preventDefault();
setTried(true);
if (errTitle || errValue) return;
const s = data.stages.find((x) => x.id === stage) ?? data.stages[0];
onCreate({ title: title.trim(), companyId, stageId: s.id, value: amount, probability: s.probability, closeDate: close });
}}
className="flex min-h-0 flex-col"
>
<PanelHeader title="New deal" onClose={onCancel} />
<div className="grid gap-4 p-4 sm:grid-cols-2 sm:p-5">
<div className="sm:col-span-2">
<Field label="Deal name" htmlFor="nd-title" error={tried ? errTitle : undefined}>
<input id="nd-title" data-autofocus value={title} onChange={(e) => setTitle(e.target.value)} aria-invalid={tried && !!errTitle} aria-describedby={tried && errTitle ? "nd-title-err" : undefined} placeholder="e.g. Annual platform licence" className={inputCls} />
</Field>
</div>
<Field label="Company" htmlFor="nd-company">
<select id="nd-company" value={companyId} onChange={(e) => setCompanyId(e.target.value)} className={inputCls}>
{data.companies.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
</Field>
<Field label="Value (USD)" htmlFor="nd-value" error={tried ? errValue : undefined}>
<input id="nd-value" inputMode="decimal" value={value} onChange={(e) => setValue(e.target.value)} aria-invalid={tried && !!errValue} aria-describedby={tried && errValue ? "nd-value-err" : undefined} placeholder="25,000" className={inputCls} />
</Field>
<Field label="Stage" htmlFor="nd-stage">
<select id="nd-stage" value={stage} onChange={(e) => setStage(e.target.value)} className={inputCls}>
{data.stages
.filter((s) => !s.closed)
.map((s) => (
<option key={s.id} value={s.id}>
{s.name}
</option>
))}
</select>
</Field>
<Field label="Expected close" htmlFor="nd-close">
<input id="nd-close" type="date" value={close} onChange={(e) => setClose(e.target.value || close)} className={inputCls} />
</Field>
</div>
<div className="flex justify-end gap-2 border-t p-3">
<Button onClick={onCancel}>Cancel</Button>
<Button type="submit" variant="primary">
Create deal
</Button>
</div>
</form>
);
}
export default CrmApp;