Aller au contenu
Whoa UI

TemplatesDashboard

Dashboard 2

Un dashboard CRM sombre avec un graphique de prévisions animé par des ressorts.

BetaWhoa UI est en bêta : certains éléments peuvent encore changer. Un bug ? Écrivez-moi à hello@ugolin-olle.com.

Home

Thursday, 16 OctoberSynced just now

6 deals close this week and 3 follow-ups are overdue.

Open pipeline

$0.00M

+12.4%vs last month

Won this month

$0K

+8.1%24 deals closed

Win rate

0%

−2.3%vs last month

Revenue forecast

On track

Weighted by stage probability · last 6 months

$0.0Mof $2.80M target
Revenue forecast by month, in thousands of dollars
MonthForecastClosed won
May$360K$322K
June$395K$371K
July$420K$398K
August$440K$405K
September$455K$441K
October$390K$312K

Today

5 tasks · 2 overdue

  • Call Priya at Lumen

    Discovery follow-up · Overdue

  • Send proposal to Northwind

    Proposal · 13:00

  • Review Acme renewal terms

    Contract · 15:30

  • Update Q3 forecast notes

    Forecast · yesterday · Overdue

  • Intro call with Helix

    Prospecting · 17:00

Pipeline by stage

$1.84M · 99 deals

Deals by source

0%All sources

Deals closing soon

Next 14 days
DealStageAmount
Orbit LabsPlatform rollout
Closing$120K
Acme CorpAnnual renewal
Negotiation$84K
NorthwindTeam expansion
Proposal$62K
LumenPilot program
Qualified$48K
HelixStarter plan
Prospecting$36K

Installation#

Via shadcn — copie le code source

pnpm dlx shadcn@latest add https://ugolin-olle.com/r/dashboard-2.json

Requierthttps://ugolin-olle.com/r/command-search.json

Installation manuelle
Files
components/blocks/dashboard-2/index.tsx
'use client';import { AnimatePresence, LazyMotion, MotionConfig, domMax, m } from 'motion/react';import * as React from 'react';import type { SearchItem } from '@/components/blocks/command-search';import { cn } from '@/lib/utils';import { ActivityFeed } from './activity-feed';import { CommandPalette } from './command-palette';import {  activity as defaultActivity,  deals as defaultDeals,  kpis as defaultKpis,  notices as defaultNotices,  sources as defaultSources,  stages as defaultStages,  tasks as defaultTasks,} from './data';import type { ActivityEntry, Deal, Kpi, Notice, Source, Stage, Task } from './data';import { DealsTable } from './deals-table';import { PageIntro, TopBar } from './header';import { KpiCard } from './kpi-card';import { EASE_OUT_STRONG, enter, rise, slideIn, stagger, stage } from './motion';import { RevenueCard } from './revenue-card';import { Sidebar } from './sidebar';import { SourceDonut } from './source-donut';import { StageChart } from './stage-chart';import { TaskRail } from './task-rail';import { Toast } from './toast';import type { ToastMessage } from './toast';export type { Deal, Kpi, MonthPoint, Source, Stage, Task } from './data';export { DealsTable } from './deals-table';export { Sparkline } from './sparkline';export { SourceDonut } from './source-donut';export { StageChart } from './stage-chart';export { AnimatedNumber } from './animated-number';export { EvilChart } from './evil-chart';export { KpiCard } from './kpi-card';export { RevenueCard } from './revenue-card';export { ActivityFeed } from './activity-feed';export { CommandPalette } from './command-palette';export { Sidebar } from './sidebar';export { TaskRail } from './task-rail';/** Dark theme tokens, scoped to the template so it does not depend on the host's theme. */const tokens = {  '--d2-bg': '#0a0a0a',  '--d2-sidebar': '#131313',  '--d2-card': '#0f0f0f',  '--d2-line': 'rgba(255,255,255,0.08)',  '--d2-fg': '#f5f5f5',  '--d2-muted': '#8d8d8d',  '--d2-dim': '#5c5c5c',  '--d2-orange': '#ff6b2c',} as React.CSSProperties;const appear = (delay: number) => ({  initial: 'hidden',  animate: 'visible',  variants: rise,  transition: { ...enter, delay },});export interface Dashboard2Props {  kpis?: Kpi[];  tasks?: Task[];  stages?: Stage[];  sources?: Source[];  deals?: Deal[];  activity?: ActivityEntry[];  notices?: Notice[];  /** Items for the ⌘K search. Defaults to demo deals, people, companies and tasks. */  searchItems?: SearchItem[];  onSearchSelect?: (item: SearchItem) => void;  /** Line under the page title, e.g. `Thursday, 16 October`. */  date?: string;  summary?: string;  /** Fill the parent (which must have a height) instead of the viewport, e.g. in docs previews. */  contained?: boolean;  className?: string;}export function Dashboard2({  kpis = defaultKpis,  tasks = defaultTasks,  stages = defaultStages,  sources = defaultSources,  deals = defaultDeals,  activity = defaultActivity,  notices = defaultNotices,  searchItems,  onSearchSelect,  date = 'Thursday, 16 October',  summary = '6 deals close this week and 3 follow-ups are overdue.',  contained = false,  className,}: Dashboard2Props) {  const [nav, setNav] = React.useState('home');  const rootRef = React.useRef<HTMLDivElement>(null);  const [narrow, setNarrow] = React.useState(false);  // `null` follows the container width; a click on the toggle pins the user's choice.  const [pinned, setPinned] = React.useState<boolean | null>(null);  const collapsed = pinned ?? narrow;  const [paletteOpen, setPaletteOpen] = React.useState(false);  const searchBtn = React.useRef<HTMLButtonElement>(null);  const [toast, setToast] = React.useState<ToastMessage | null>(null);  const notify = React.useCallback((text: string) => {    setToast({ id: Date.now(), text });  }, []);  React.useEffect(() => {    if (!toast) return;    const t = window.setTimeout(() => setToast(null), 2400);    return () => window.clearTimeout(t);  }, [toast]);  const closePalette = React.useCallback(() => {    setPaletteOpen(false);    searchBtn.current?.focus();  }, []);  // ⌘K / Ctrl+K only while focus is inside this dashboard, so it never hijacks the host page.  const onKeyDown = (e: React.KeyboardEvent) => {    if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {      e.preventDefault();      setPaletteOpen((o) => !o);    }  };  React.useLayoutEffect(() => {    const el = rootRef.current;    if (!el) return;    const read = () => setNarrow(el.clientWidth < 900);    read();    const ro = new ResizeObserver(read);    ro.observe(el);    return () => ro.disconnect();  }, []);  const [tab, setTab] = React.useState('Overview');  return (    // Not `strict`: the embedded CommandSearch is built with the full `motion` component.    <LazyMotion features={domMax}>      <MotionConfig reducedMotion="user" transition={enter}>        <div          className={cn(            '@container/dash relative flex w-full bg-(--d2-bg) text-(--d2-fg) antialiased',            contained ? 'h-full min-h-0' : 'h-screen',            className,          )}          onKeyDown={onKeyDown}          ref={rootRef}          style={tokens}        >          <m.div            animate="visible"            className="relative z-30 flex shrink-0"            initial="hidden"            transition={{ ...enter, delay: stage.sidebar }}            variants={slideIn}          >            <Sidebar              active={nav}              collapsed={collapsed}              onNavigate={setNav}              onToggle={() => setPinned(!collapsed)}            />          </m.div>          <div className="@container/main flex min-w-0 flex-1 flex-col">            {/* Own stacking level: the bar's popover must paint above the scrolling content. */}            <m.div className="relative z-20" {...appear(stage.header)}>              <TopBar                notices={notices}                onSearch={() => setPaletteOpen(true)}                searchRef={searchBtn}              />            </m.div>            <main className="min-h-0 flex-1 overflow-y-auto">              <div className="mx-auto w-full max-w-[1100px] px-5 py-6 @4xl/dash:px-6 @4xl/dash:py-7">                <m.div {...appear(stage.header)}>                  <PageIntro date={date} onTabChange={setTab} summary={summary} tab={tab} />                </m.div>                <m.div                  animate="visible"                  className="mt-5 grid grid-cols-1 gap-4 @xl/main:grid-cols-3"                  initial="hidden"                  variants={stagger(stage.kpis, 0.07)}                >                  {kpis.map((kpi, i) => (                    <KpiCard index={i} key={kpi.id} kpi={kpi} />                  ))}                </m.div>                <AnimatePresence initial={false} mode="wait">                  <m.div                    animate={{ opacity: 1, transform: 'translateY(0px)' }}                    className="mt-4 flex flex-col gap-4"                    exit={{ opacity: 0, transition: { duration: 0.1 } }}                    initial={{ opacity: 0, transform: 'translateY(6px)' }}                    key={tab}                    role="tabpanel"                    transition={{ duration: 0.25, ease: EASE_OUT_STRONG }}                  >                    {tab === 'Overview' && (                      <>                        <div className="grid grid-cols-1 items-start gap-4 @4xl/main:grid-cols-[minmax(0,1fr)_280px]">                          <div className="min-w-0">                            <RevenueCard />                          </div>                          <TaskRail initial={tasks} />                        </div>                        <div className="grid grid-cols-1 gap-4 @4xl/main:grid-cols-[minmax(0,1.5fr)_minmax(0,1fr)]">                          <StageChart stages={stages} />                          <SourceDonut sources={sources} />                        </div>                        <DealsTable deals={deals} />                      </>                    )}                    {tab === 'Pipeline' && (                      <>                        <div className="grid grid-cols-1 gap-4 @4xl/main:grid-cols-[minmax(0,1.5fr)_minmax(0,1fr)]">                          <StageChart stages={stages} />                          <SourceDonut sources={sources} />                        </div>                        <DealsTable deals={deals} />                      </>                    )}                    {tab === 'Activity' && (                      <div className="grid grid-cols-1 items-start gap-4 @4xl/main:grid-cols-[minmax(0,1fr)_280px]">                        <ActivityFeed entries={activity} />                        <TaskRail initial={tasks} />                      </div>                    )}                    {tab === 'Forecast' && <RevenueCard />}                  </m.div>                </AnimatePresence>              </div>            </main>          </div>          <CommandPalette            items={searchItems}            onClose={closePalette}            onSelect={(item) => {              closePalette();              notify(`Opened “${item.title}”`);              onSearchSelect?.(item);            }}            open={paletteOpen}          />          <Toast message={toast} />        </div>      </MotionConfig>    </LazyMotion>  );}

Le template Dashboard 2 est un tableau de bord CRM en mode sombre : barre latérale qui se replie en rail d'icônes sur tablette, cartes KPI avec compteurs animés, graphique de prévisions de revenus et rail de tâches du jour.

Le graphique est volontairement poussé : barres animées par ressorts (prévision et signé indépendants), axe et grilles qui se déplacent avec les valeurs au changement de période (3M, 6M, 12M), infobulle qui suit le curseur, survol avec lueur et atténuation des autres barres, ligne d'objectif et progression animées. Il reste accessible : colonnes focalisables, flèches du clavier, tableau de données pour les lecteurs d'écran et prefers-reduced-motion respecté.

Il embarque aussi la recherche command-search (⌘K / Ctrl+K), des notifications, un fil d'activité, des graphiques de pipeline par étape et de répartition des sources, un tableau de deals et des onglets Overview, Pipeline, Activity et Forecast qui changent réellement de contenu.

Installation en CLI

npx @whoa-studio/templates add dashboard-2

Utilisation

Passez vos kpis, tasks, stages, sources, deals, activity, notices et searchItems (avec onSearchSelect). Sans props, le composant affiche des données de démonstration. Les mois du graphique vivent dans data.ts.

Inspirations