Mikkel Manniche
Dansk English Deutsch

Vorlage · React · HTML

Spend control

Firmenausgaben in Tiles: eine Symbolleiste, eine dunkle Upgrade-Kachel, virtuelle Karten mit Limits, Ausgabenbalken und ein Blasendiagramm der Händler.

Vorschau

React

Installiere die Vorlage mit shadcn. Sie landet als normale Komponenten in deinem Projekt, die du selbst anpasst.

npx shadcn@latest add https://mikkelmanniche.dk/lab/r/spend-control.json
npx shadcn@latest add @manniche/spend-control
spend-control.tsx
// A spend-control screen: icon rail, an upsell panel, virtual cards, the spending bars and a bubble chart of merchants.
// Built from the Tiles primitives. All figures are example data passed in as props.
import { type LucideIcon } from 'lucide-react'
import { cn } from '@/lib/utils'
import { BarChart, type BarPoint } from '@/registry/manniche/bar-chart/bar-chart'
import { BubbleChart, type BubbleDatum } from '@/registry/manniche/bubble-chart/bubble-chart'
import { BigNumber, DeltaPill } from '@/registry/manniche/chart-kit/chart-kit'
import { DataTile, TileFact } from '@/registry/manniche/data-tile/data-tile'

export type SpendCard = { id: string; name: string; last4: string; spent: number; limit: number; frozen?: boolean }
export type SpendRailItem = { id: string; label: string; icon: LucideIcon; current?: boolean }

export type SpendControlData = {
  rail: SpendRailItem[]
  cards: SpendCard[]
  spending: BarPoint[]
  spendingTotal: number
  spendingChange: number
  merchants: BubbleDatum[]
  upsell: { title: string; text: string; action: string }
}

export type SpendControlLabels = {
  title?: string
  cards?: string
  spending?: string
  merchants?: string
  limit?: string
  frozen?: string
  note?: string
  navLabel?: string
}

export type SpendControlProps = {
  data: SpendControlData
  labels?: SpendControlLabels
  now?: Date
  currency?: string
  onUpsell?: () => void
  className?: string
}

const L: Required<SpendControlLabels> = {
  title: 'Spend control',
  cards: 'Virtual cards',
  spending: 'Spending, last 7 days',
  merchants: 'Merchants by visits and average spend',
  limit: 'of',
  frozen: 'Frozen',
  note: 'Example data.',
  navLabel: 'Sections',
}

export function SpendControl({ data, labels, now, currency = 'EUR', onUpsell, className }: SpendControlProps) {
  const t = { ...L, ...labels }
  const money = { currency, decimals: 0 }
  const date = now?.toLocaleDateString('en-GB', { weekday: 'long', day: 'numeric', month: 'long' })
  return (
    <div className={cn('@container w-full rounded-2xl bg-background p-3 text-foreground', className)}>
      {/* Container queries match an ancestor, so the row/column switch sits one level in. */}
      <div className="flex flex-col gap-3 @sm:flex-row">
        <nav aria-label={t.navLabel} className="flex shrink-0 items-center gap-1 self-start overflow-x-auto rounded-xl border border-border bg-card p-1.5 @sm:flex-col @sm:self-stretch">
          {data.rail.map((item) => (
            <a
              key={item.id}
              href={`#${item.id}`}
              aria-label={item.label}
              title={item.label}
              aria-current={item.current ? 'page' : undefined}
              className={cn(
                'grid size-10 place-items-center rounded-lg text-muted-foreground hover:bg-muted hover:text-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring',
                item.current && 'bg-foreground text-background hover:bg-foreground hover:text-background',
              )}
            >
              <item.icon className="size-4" aria-hidden />
            </a>
          ))}
        </nav>
        <div className="grid min-w-0 flex-1 grid-cols-1 gap-3 @2xl:grid-cols-6">
          <header className="@2xl:col-span-6">
            {date && <p className="text-xs text-muted-foreground">{date}</p>}
            <h2 className="text-lg font-semibold tracking-tight">{t.title}</h2>
          </header>
          <div className="@2xl:col-span-4">
            <DataTile className="h-full" title={t.spending} footer={t.note}>
              <TileFact aside={<DeltaPill value={data.spendingChange} goodWhen="down" />}>
                <BigNumber value={data.spendingTotal} format={money} size="xl" />
              </TileFact>
              <BarChart data={data.spending} label={t.spending} format={money} />
            </DataTile>
          </div>
          <div className="@2xl:col-span-2">
            <DataTile className="h-full" title={data.upsell.title} inverted>
              <p className="text-sm opacity-80">{data.upsell.text}</p>
              <button
                type="button"
                onClick={onUpsell}
                className="mt-4 rounded-full bg-foreground px-4 py-2 text-sm font-medium text-background transition-transform active:scale-[0.97] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring"
              >
                {data.upsell.action}
              </button>
            </DataTile>
          </div>
          <div className="@2xl:col-span-2">
            <DataTile className="h-full" title={t.cards}>
              <ul className="flex flex-col gap-3">
                {data.cards.map((c) => {
                  const pct = c.limit > 0 ? Math.min(100, Math.round((c.spent / c.limit) * 100)) : 0
                  return (
                    <li key={c.id} className={cn('flex flex-col gap-1.5', c.frozen && 'opacity-60')}>
                      <div className="flex items-baseline justify-between gap-2 text-sm">
                        <span className="truncate font-medium">{c.name}</span>
                        <span className="tabular-nums text-xs text-muted-foreground">
                          {c.frozen ? t.frozen : `•••• ${c.last4}`}
                        </span>
                      </div>
                      <div
                        role="progressbar"
                        aria-label={c.name}
                        aria-valuenow={pct}
                        aria-valuemin={0}
                        aria-valuemax={100}
                        className="h-1.5 overflow-hidden rounded-full bg-muted"
                      >
                        <div className="h-full rounded-full bg-foreground" style={{ width: `${pct}%` }} />
                      </div>
                      <p className="text-xs tabular-nums text-muted-foreground">
                        {c.spent.toLocaleString('en-GB', { style: 'currency', currency, maximumFractionDigits: 0 })} {t.limit}{' '}
                        {c.limit.toLocaleString('en-GB', { style: 'currency', currency, maximumFractionDigits: 0 })}
                      </p>
                    </li>
                  )
                })}
              </ul>
            </DataTile>
          </div>
          <div className="@2xl:col-span-4">
            <DataTile className="h-full" title={t.merchants}>
              <BubbleChart data={data.merchants} label={t.merchants} format={money} yFormat={money} />
            </DataTile>
          </div>
        </div>
      </div>
    </div>
  )
}
spend-control-demo.tsx
import { CreditCard, Home, PieChart, Settings, Users, Wallet } from 'lucide-react'
import { SpendControl, type SpendControlData } from '@/components/spend-control'

const sampleRail: SpendControlData['rail'] = [
  { id: 'home', label: 'Home', icon: Home, current: true },
  { id: 'cards', label: 'Cards', icon: CreditCard },
  { id: 'wallet', label: 'Wallet', icon: Wallet },
  { id: 'reports', label: 'Reports', icon: PieChart },
  { id: 'team', label: 'Team', icon: Users },
  { id: 'settings', label: 'Settings', icon: Settings },
]

// Example data only. Names and figures are invented.
const data: SpendControlData = {
  rail: sampleRail,
  cards: [
    { id: 'c1', name: 'Team lunches', last4: '4401', spent: 312, limit: 500 },
    { id: 'c2', name: 'Software', last4: '8120', spent: 689, limit: 800 },
    { id: 'c3', name: 'Travel', last4: '2257', spent: 40, limit: 1500, frozen: true },
  ],
  spending: [
    { label: 'Wed', title: 'Wednesday 30 Sep', value: 95 },
    { label: 'Thu', title: 'Thursday 1 Oct', value: 340 },
    { label: 'Fri', title: 'Friday 2 Oct', value: 180 },
    { label: 'Sat', title: 'Saturday 3 Oct', value: 60 },
    { label: 'Sun', title: 'Sunday 4 Oct', value: 36 },
    { label: 'Mon', title: 'Monday 5 Oct', value: 120 },
    { label: 'Tue', title: 'Tuesday 6 Oct', value: 210 },
  ],
  spendingTotal: 1041,
  spendingChange: -4.2,
  merchants: [
    { label: 'Example Cafe', value: 420, x: 12, y: 18, group: 'Food' },
    { label: 'Sample Cloud', value: 689, x: 2, y: 120, group: 'Software' },
    { label: 'Demo Rail', value: 240, x: 3, y: 64, group: 'Travel' },
    { label: 'Test Supplies', value: 150, x: 6, y: 35, group: 'Office' },
    { label: 'Mock Market', value: 310, x: 9, y: 28, group: 'Food' },
  ],
  upsell: { title: 'More cards', text: 'Example panel: add cards for every team.', action: 'Add a card' },
}

export default function SpendControlDemo() {
  return <SpendControl data={data} now={new Date('2026-10-06T10:00:00')} />
}

HTML

Die ganze Seite als eine Datei mit eingebautem CSS und ohne JavaScript. Umschalter, Tabs und Filter funktionieren allein mit HTML und CSS.

In neuem Tab öffnen

Die Farbe in deiner eigenen App

Wenn shadcn bereits eingerichtet ist, ergeben diese Variablen dieselbe Farbe. Hell in :root, dunkel in .dark.

:root {
  --primary: oklch(0.52 0.2 262);
  --primary-foreground: oklch(0.99 0 0);
  --accent: oklch(0.95 0.034 262);
  --accent-foreground: oklch(0.36 0.140 262);
  --ring: oklch(0.52 0.2 262);
}

.dark {
  --primary: oklch(0.74 0.150 262);
  --primary-foreground: oklch(0.18 0.024 262);
  --accent: oklch(0.29 0.050 262);
  --accent-foreground: oklch(0.9 0.050 262);
  --ring: oklch(0.74 0.150 262);
}

Installieren

npx shadcn@latest add https://mikkelmanniche.dk/lab/r/spend-control.json

Abhängigkeiten

Dateien

  • spend-control.tsx
  • spend-control.html

Manniche Lab · Open Source

Kostenlose Werkzeuge für KI-Agenten und Webentwicklung.