UIPackage

Cost Breakdown

block dashboard
Edit on GitHub

Three-panel spend dashboard. Stacked weekly bar chart over time on top, plus two side-by-side categorical pies below (share by lane / share by carrier). Theme-aware via registry tokens.

Also available for React ->

Installation

$ npx shadcn-vue@latest add https://uipkge.dev/r/vue/cost-breakdown.json

Or with the named registry: npx shadcn-vue@latest add @uipkge/cost-breakdown

Examples

Schema

Type aliases exported from this item's source. Use these to shape the data you pass in.

WeekRow
interface WeekRow {
  week: string
  detentionOrigin: number
  combinedDndOrigin: number
  demurrageOrigin: number
  detentionDest: number
  demurrageDest: number
  combinedDndDest: number
}

npm dependencies

Theming

CSS custom properties referenced in this item. Override any of them in your :root or per-element to retheme.

--background

Files (1)

  • app/components/blocks/CostBreakdown.vue 7.6 kB
    <script setup lang="ts">
    // Three-panel cost breakdown: a stacked-by-week bar over time on top,
    // and two side-by-side categorical pie charts below (cost share by
    // lane + by carrier). The patterns repeat across spend dashboards;
    // swap `weeklySeries`, `byLane`, and `byCarrier` to repoint.
    //
    // All three panels are theme-aware: bar colors come from
    // chartColors via the wrappers; pies surface inline value labels so
    // the panels read at a glance without leaning on the legend alone.
    import { computed } from 'vue'
    import { BarChart, PieChart } from '@/components/ui/charts'
    
    interface WeekRow {
      week: string
      detentionOrigin: number
      combinedDndOrigin: number
      demurrageOrigin: number
      detentionDest: number
      demurrageDest: number
      combinedDndDest: number
    }
    
    // 17 weeks of cost data. Heavy spike in mid-Feb mirrors a closing
    // quarter cleanup or a port-disruption incident.
    const weeklySeries: WeekRow[] = [
      {
        week: 'Jan 26 - Feb 1',
        detentionOrigin: 2800,
        combinedDndOrigin: 0,
        demurrageOrigin: 0,
        detentionDest: 0,
        demurrageDest: 0,
        combinedDndDest: 0,
      },
      {
        week: 'Feb 2 - Feb 8',
        detentionOrigin: 1100,
        combinedDndOrigin: 0,
        demurrageOrigin: 0,
        detentionDest: 0,
        demurrageDest: 0,
        combinedDndDest: 0,
      },
      {
        week: 'Feb 9 - Feb 15',
        detentionOrigin: 1800,
        combinedDndOrigin: 3100,
        demurrageOrigin: 800,
        detentionDest: 1800,
        demurrageDest: 4300,
        combinedDndDest: 1100,
      },
      {
        week: 'Feb 16 - Feb 22',
        detentionOrigin: 0,
        combinedDndOrigin: 22000,
        demurrageOrigin: 12500,
        detentionDest: 0,
        demurrageDest: 1900,
        combinedDndDest: 35500,
      },
      {
        week: 'Feb 23 - Mar 1',
        detentionOrigin: 0,
        combinedDndOrigin: 0,
        demurrageOrigin: 3300,
        detentionDest: 800,
        demurrageDest: 6800,
        combinedDndDest: 4400,
      },
      {
        week: 'Mar 2 - Mar 8',
        detentionOrigin: 2400,
        combinedDndOrigin: 0,
        demurrageOrigin: 6400,
        detentionDest: 1200,
        demurrageDest: 2800,
        combinedDndDest: 6600,
      },
      {
        week: 'Mar 9 - Mar 15',
        detentionOrigin: 0,
        combinedDndOrigin: 0,
        demurrageOrigin: 600,
        detentionDest: 1300,
        demurrageDest: 5300,
        combinedDndDest: 5400,
      },
      {
        week: 'Mar 16 - Mar 22',
        detentionOrigin: 5400,
        combinedDndOrigin: 0,
        demurrageOrigin: 6000,
        detentionDest: 600,
        demurrageDest: 4200,
        combinedDndDest: 1400,
      },
      {
        week: 'Mar 23 - Mar 29',
        detentionOrigin: 0,
        combinedDndOrigin: 11000,
        demurrageOrigin: 4900,
        detentionDest: 0,
        demurrageDest: 4300,
        combinedDndDest: 2200,
      },
      {
        week: 'Mar 30 - Apr 5',
        detentionOrigin: 7700,
        combinedDndOrigin: 14000,
        demurrageOrigin: 5400,
        detentionDest: 1700,
        demurrageDest: 6300,
        combinedDndDest: 7200,
      },
      {
        week: 'Apr 6 - Apr 12',
        detentionOrigin: 1200,
        combinedDndOrigin: 3100,
        demurrageOrigin: 1500,
        detentionDest: 0,
        demurrageDest: 4800,
        combinedDndDest: 6300,
      },
      {
        week: 'Apr 13 - Apr 19',
        detentionOrigin: 0,
        combinedDndOrigin: 3300,
        demurrageOrigin: 3200,
        detentionDest: 3500,
        demurrageDest: 5000,
        combinedDndDest: 5700,
      },
      {
        week: 'Apr 20 - Apr 26',
        detentionOrigin: 1700,
        combinedDndOrigin: 5300,
        demurrageOrigin: 2500,
        detentionDest: 600,
        demurrageDest: 3800,
        combinedDndDest: 4800,
      },
      {
        week: 'Apr 27 - May 3',
        detentionOrigin: 6000,
        combinedDndOrigin: 15000,
        demurrageOrigin: 5500,
        detentionDest: 600,
        demurrageDest: 800,
        combinedDndDest: 2400,
      },
      {
        week: 'May 4 - May 10',
        detentionOrigin: 1900,
        combinedDndOrigin: 2200,
        demurrageOrigin: 3300,
        detentionDest: 3500,
        demurrageDest: 3700,
        combinedDndDest: 1100,
      },
      {
        week: 'May 11 - May 17',
        detentionOrigin: 1900,
        combinedDndOrigin: 1200,
        demurrageOrigin: 800,
        detentionDest: 700,
        demurrageDest: 600,
        combinedDndDest: 1200,
      },
    ]
    
    const stackKeys = [
      { key: 'detentionOrigin', label: 'Detention at origin' },
      { key: 'combinedDndOrigin', label: 'Combined at origin' },
      { key: 'demurrageOrigin', label: 'Demurrage at origin' },
      { key: 'detentionDest', label: 'Detention at destination' },
      { key: 'demurrageDest', label: 'Demurrage at destination' },
      { key: 'combinedDndDest', label: 'Combined at destination' },
    ] as const
    
    const stackedOption = computed(() => ({
      series: stackKeys.map(() => ({ stack: 'total' as const })),
      yAxis: {
        type: 'value' as const,
        axisLabel: { formatter: (v: number) => (v >= 1000 ? `$${Math.round(v / 1000)}K` : `$${v}`) },
      },
    }))
    
    const byLane = [
      { name: 'LANE-01', value: 119800 },
      { name: 'LANE-02', value: 39000 },
      { name: 'LANE-03', value: 53100 },
      { name: 'LANE-04', value: 32000 },
      { name: 'LANE-05', value: 24700 },
      { name: 'LANE-06', value: 23500 },
      { name: 'LANE-07', value: 20300 },
      { name: 'LANE-08', value: 18300 },
      { name: 'LANE-09', value: 14700 },
      { name: 'OTHER', value: 8100 },
    ]
    
    const byCarrier = [
      { name: 'C-ALPHA', value: 144200 },
      { name: 'C-BETA', value: 69800 },
      { name: 'C-GAMMA', value: 32700 },
      { name: 'C-DELTA', value: 31800 },
      { name: 'C-EPSILON', value: 27800 },
      { name: 'C-ZETA', value: 22600 },
      { name: 'OTHER', value: 10100 },
    ]
    
    const totalAll = computed(() =>
      weeklySeries.reduce(
        (acc, w) =>
          acc +
          w.detentionOrigin +
          w.combinedDndOrigin +
          w.demurrageOrigin +
          w.detentionDest +
          w.demurrageDest +
          w.combinedDndDest,
        0,
      ),
    )
    
    const pieOption = {
      legend: { show: false },
      series: [
        {
          radius: ['0%', '70%'],
          label: {
            show: true,
            formatter: (p: any) => `$${(p.value / 1000).toFixed(1)}k\n(${p.percent}%)`,
            fontSize: 10,
            color: 'var(--background)',
          },
          labelLine: { show: false },
        },
      ],
    }
    
    const fmtUsd = (n: number) => `$${(n / 1000).toFixed(1)}k`
    </script>
    
    <template>
      <div class="space-y-6">
        <!-- Weekly trend (top) -->
        <section class="bg-card border-border rounded-lg border p-5">
          <div class="mb-3 flex items-baseline justify-between gap-4">
            <div>
              <h3 class="text-[15px] font-semibold tracking-tight">Cost per day</h3>
              <p class="text-muted-foreground mt-0.5 text-xs">17-week rollup. Hover a stack to inspect the breakdown.</p>
            </div>
            <div class="text-right">
              <div class="text-muted-foreground font-mono text-xs tracking-wider uppercase">Total</div>
              <div class="text-lg font-semibold tabular-nums">{{ fmtUsd(totalAll) }}</div>
            </div>
          </div>
          <BarChart
            :data="weeklySeries"
            x-field="week"
            :y-field="[
              'detentionOrigin',
              'combinedDndOrigin',
              'demurrageOrigin',
              'detentionDest',
              'demurrageDest',
              'combinedDndDest',
            ]"
            :option="stackedOption"
            height="320"
          />
        </section>
    
        <!-- Dual pie breakdown (bottom) -->
        <div class="grid gap-6 lg:grid-cols-2">
          <section class="bg-card border-border rounded-lg border p-5">
            <h3 class="text-[15px] font-semibold tracking-tight">Cost share by lane</h3>
            <p class="text-muted-foreground mt-0.5 mb-4 text-xs">Top 9 lanes + a grouped 'Other' bucket.</p>
            <PieChart :data="byLane" :option="pieOption" height="320" />
          </section>
    
          <section class="bg-card border-border rounded-lg border p-5">
            <h3 class="text-[15px] font-semibold tracking-tight">Cost share by carrier</h3>
            <p class="text-muted-foreground mt-0.5 mb-4 text-xs">Top 6 carriers + a grouped 'Other' bucket.</p>
            <PieChart :data="byCarrier" :option="pieOption" height="320" />
          </section>
        </div>
      </div>
    </template>

Raw manifest: https://uipkge.dev/r/vue/cost-breakdown.json