Mikkel Manniche
Dansk English Deutsch

Skabelon · React · HTML

Spend control

Firmaets forbrug i Tiles: en ikonbjælke, en mørk opgraderingsflise, virtuelle kort med grænser, forbrugssøjler og en boblegraf over butikker.

Forhåndsvisning

React

Installér skabelonen med shadcn. Den lægges i dit projekt som almindelige komponenter, som du selv retter i.

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

Hele siden som én fil med al CSS indbygget og intet JavaScript. Knapper, faner og filtre virker med HTML og CSS alene.

Åbn i ny fane

Farven i din egen app

Har du allerede shadcn sat op, giver disse variabler samme farve. Lys i :root, mørk i .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);
}

Installér

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

Afhængigheder

Filer

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

Manniche Lab · open source

Gratis værktøjer til AI-agenter og webudvikling.