Fazekit

Code

"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;

More in Business

View all →