From f2d9b7f4558615a847a3e0cf25c41c16951a18f7 Mon Sep 17 00:00:00 2001 From: rex-bot Date: Mon, 27 Apr 2026 21:34:47 -0400 Subject: [PATCH] CUB-42: Show filament cost and usage in UI --- .../filament-table.component.html | 29 ++++++++++++ .../filament-table.component.scss | 44 ++++++++++++++++++- .../filament-table.component.ts | 39 ++++++++++++++++ .../inventory-summary.component.html | 37 ++++++++++++++++ .../inventory-summary.component.ts | 35 ++++++++++++++- 5 files changed, 181 insertions(+), 3 deletions(-) diff --git a/frontend/src/app/components/filament-table/filament-table.component.html b/frontend/src/app/components/filament-table/filament-table.component.html index 5beccd4..e8b331f 100644 --- a/frontend/src/app/components/filament-table/filament-table.component.html +++ b/frontend/src/app/components/filament-table/filament-table.component.html @@ -83,6 +83,35 @@ + + + Cost + +
+ @if (filament.purchasePrice !== null) { + {{ formatCurrency(filament.purchasePrice) }} + @let cpg = getCostPerGram(filament); + @if (cpg !== null) { + ${{ cpg.toFixed(2) }}/g + } + } @else { + + } +
+ +
+ + + + Usage + +
+ {{ formatWeight(getGramsUsed(filament)) }} used + {{ formatWeight(filament.weightRemainingGrams) }} left +
+ +
+ Stock diff --git a/frontend/src/app/components/filament-table/filament-table.component.scss b/frontend/src/app/components/filament-table/filament-table.component.scss index d85bb4a..95dff37 100644 --- a/frontend/src/app/components/filament-table/filament-table.component.scss +++ b/frontend/src/app/components/filament-table/filament-table.component.scss @@ -55,7 +55,7 @@ $color-inactive: #94a3b8; // Gray — inactive spool // Table styling .filament-table { width: 100%; - min-width: 700px; + min-width: 900px; th { font-weight: 600; @@ -132,6 +132,48 @@ $color-inactive: #94a3b8; // Gray — inactive spool letter-spacing: 0.02em; } +// Cost cell +.cost-cell { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 80px; + + .cost-price { + font-weight: 600; + color: var(--mat-sys-on-surface); + } + + .cost-per-gram { + font-size: 11px; + color: var(--mat-sys-on-surface-variant); + letter-spacing: 0.02em; + } + + .cost-unknown { + color: var(--mat-sys-on-surface-variant); + font-style: italic; + } +} + +// Usage cell +.usage-cell { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 100px; + + .usage-grams { + font-weight: 500; + color: var(--mat-sys-on-surface); + } + + .usage-remaining { + font-size: 12px; + color: var(--mat-sys-on-surface-variant); + } +} + // Remaining weight cell .remaining-cell { display: flex; diff --git a/frontend/src/app/components/filament-table/filament-table.component.ts b/frontend/src/app/components/filament-table/filament-table.component.ts index 685c06d..93273f1 100644 --- a/frontend/src/app/components/filament-table/filament-table.component.ts +++ b/frontend/src/app/components/filament-table/filament-table.component.ts @@ -30,6 +30,8 @@ export type FilamentColumn = | 'brand' | 'serial' | 'remaining' + | 'cost' + | 'usage' | 'stockLevel' | 'status'; @@ -70,6 +72,8 @@ export class FilamentTableComponent implements OnInit { 'brand', 'serial', 'remaining', + 'cost', + 'usage', 'stockLevel', 'status', ]); @@ -143,6 +147,18 @@ export class FilamentTableComponent implements OnInit { getRemainingPercent(b), isAsc ); + case 'cost': + return compare( + a.purchasePrice ?? 0, + b.purchasePrice ?? 0, + isAsc + ); + case 'usage': + return compare( + a.weightTotalGrams - a.weightRemainingGrams, + b.weightTotalGrams - b.weightRemainingGrams, + isAsc + ); case 'stockLevel': return compare( stockLevelOrder(classifyStockLevel(a)), @@ -243,6 +259,29 @@ export class FilamentTableComponent implements OnInit { } return `${Math.round(grams)}g`; } + + /** Template helper: format currency */ + formatCurrency(value: number): string { + return new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }).format(value); + } + + /** Template helper: compute cost per gram for a filament */ + getCostPerGram(filament: Filament): number | null { + if (filament.purchasePrice === null || filament.purchasePrice === 0 || filament.weightTotalGrams <= 0) { + return null; + } + return filament.purchasePrice / filament.weightTotalGrams; + } + + /** Template helper: compute grams used for a filament */ + getGramsUsed(filament: Filament): number { + return filament.weightTotalGrams - filament.weightRemainingGrams; + } } /** Compare helper for sorting */ diff --git a/frontend/src/app/components/inventory-summary/inventory-summary.component.html b/frontend/src/app/components/inventory-summary/inventory-summary.component.html index fc49650..bb8fe32 100644 --- a/frontend/src/app/components/inventory-summary/inventory-summary.component.html +++ b/frontend/src/app/components/inventory-summary/inventory-summary.component.html @@ -87,6 +87,43 @@ + + @if (avgCostPerGram() !== null) { +
+ +
+ ${{ avgCostPerGram()!.toFixed(2) }}/g + Avg Cost/g +
+
+ } + + +
+ +
+ {{ formatWeight(totalGramsUsed()) }} + Total Used +
+
+ + + @if (estimatedUsedValue() !== null) { +
+ +
+ {{ formatCurrency(estimatedUsedValue()!) }} + Used Value +
+
+ } +
sum + (f.purchasePrice ?? 0), 0) ); + /** Computed: average cost per gram across active spools with a price */ + readonly avgCostPerGram = computed(() => { + const priced = this.filaments().filter( + (f) => f.isActive && f.purchasePrice !== null && f.purchasePrice! > 0 && f.weightTotalGrams > 0 + ); + if (priced.length === 0) return null; + const totalCost = priced.reduce((sum, f) => sum + f.purchasePrice!, 0); + const totalWeight = priced.reduce((sum, f) => sum + f.weightTotalGrams, 0); + return totalWeight > 0 ? totalCost / totalWeight : null; + }); + + /** Computed: total grams used across all spools */ + readonly totalGramsUsed = computed(() => + this.filaments().reduce( + (sum, f) => sum + (f.weightTotalGrams - f.weightRemainingGrams), + 0 + ) + ); + + /** Computed: total estimated value of used filament */ + readonly estimatedUsedValue = computed(() => { + const priced = this.filaments().filter( + (f) => f.isActive && f.purchasePrice !== null && f.purchasePrice! > 0 && f.weightTotalGrams > 0 + ); + if (priced.length === 0) return null; + return priced.reduce((sum, f) => { + const usedFraction = (f.weightTotalGrams - f.weightRemainingGrams) / f.weightTotalGrams; + return sum + f.purchasePrice! * usedFraction; + }, 0); + }); + /** Computed: total remaining weight across all spools in grams */ readonly totalRemainingGrams = computed(() => this.filaments().reduce((sum, f) => sum + f.weightRemainingGrams, 0) @@ -169,8 +200,8 @@ export class InventorySummaryComponent implements OnInit, OnDestroy { return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', - minimumFractionDigits: 0, - maximumFractionDigits: 0, + minimumFractionDigits: 2, + maximumFractionDigits: 2, }).format(value); } } \ No newline at end of file