Menu

Change language

GitHub repo

Cost Breakdown

blockfinance

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.

Installation

$npx uipkge-ng@latest add cost-breakdown
Manifest:https://uipkge.dev/r/angular/cost-breakdown.jsonInstalls to:src/app/components/blocks/cost-breakdown/

Variants

Loading interactive previews…

Schema

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

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

Files installed (2)

  • src/app/components/blocks/cost-breakdown/cost-breakdown.component.ts8.7 kB
    // 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).
    // Port of the React block 1:1 — swap `weeklySeries`, `byLane`, and `byCarrier`
    // to repoint. Pies surface inline value labels so the panels read at a glance.
    import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
    import { cn } from '@/lib/utils'
    import { UiBarChartComponent } from '@/ui/charts/bar-chart/bar-chart.component'
    import { UiPieChartComponent } from '@/ui/charts/pie-chart/pie-chart.component'
    
    export interface CostWeekRow {
      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.
    export const weeklySeries: CostWeekRow[] = [
      {
        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,
      },
    ]
    
    export const costStackKeys = [
      { 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
    
    export 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 },
    ]
    
    export 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 },
    ]
    
    export function totalCostAll(): number {
      return weeklySeries.reduce(
        (acc, w) =>
          acc +
          w.detentionOrigin +
          w.combinedDndOrigin +
          w.demurrageOrigin +
          w.detentionDest +
          w.demurrageDest +
          w.combinedDndDest,
        0,
      )
    }
    
    export function fmtUsd(n: number): string {
      return `$${(n / 1000).toFixed(1)}k`
    }
    
    @Component({
      changeDetection: ChangeDetectionStrategy.Eager,
      selector: 'ui-cost-breakdown, [ui-cost-breakdown]',
      standalone: true,
      // React renders the root <div> itself: the host stays out of layout and `class` goes to the root.
      host: { '[attr.class]': '"contents"' },
      imports: [UiBarChartComponent, UiPieChartComponent],
      template: `
        <div data-slot="cost-breakdown" [class]="rootClass">
          <!-- 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-sm 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>
            <ui-bar-chart
              [data]="weeklySeries"
              xField="week"
              [yField]="stackFields"
              [option]="stackedOption"
              height="320"
            />
          </section>
    
          <!-- Dual pie breakdown (bottom) -->
          <div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
            <section class="bg-card border-border rounded-lg border p-4 sm:p-5">
              <h3 class="text-sm 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>
              <ui-pie-chart [data]="byLane" [option]="pieOption" height="320" />
            </section>
    
            <section class="bg-card border-border rounded-lg border p-4 sm:p-5">
              <h3 class="text-sm 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>
              <ui-pie-chart [data]="byCarrier" [option]="pieOption" height="320" />
            </section>
          </div>
        </div>
      `,
    })
    export class UiCostBreakdownComponent {
      protected readonly weeklySeries = weeklySeries
      protected readonly byLane = byLane
      protected readonly byCarrier = byCarrier
      protected readonly totalAll = totalCostAll()
    
      protected readonly stackFields = costStackKeys.map((s) => s.key)
    
      protected readonly stackedOption = {
        series: costStackKeys.map(() => ({ stack: 'total' as const })),
        yAxis: {
          type: 'value' as const,
          axisLabel: { formatter: (v: number) => (v >= 1000 ? `$${Math.round(v / 1000)}K` : `$${v}`) },
        },
      }
    
      // Inline slice labels mirror the React `pieOption`. The label colour is left to
      // the pie wrapper default (registry chart text token) so the block does not take
      // a direct `@/ui/charts/use-chart-theme` dependency.
      protected readonly pieOption = {
        legend: { show: false },
        series: [
          {
            radius: ['0%', '70%'],
            label: {
              show: true,
              formatter: (p: { value: number; percent: number }) => `$${(p.value / 1000).toFixed(1)}k\n(${p.percent}%)`,
              fontSize: 10,
            },
            labelLine: { show: false },
          },
        ],
      }
    
      @Input('class') className?: string
    
      get rootClass(): string {
        return cn('space-y-6', this.className)
      }
    
      fmtUsd(n: number): string {
        return fmtUsd(n)
      }
    }
    
  • src/app/components/blocks/cost-breakdown/index.ts0.2 kB

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