CUB-42: Show filament cost and usage in UI
All checks were successful
Dev Build / build-test (pull_request) Successful in 2m9s

This commit is contained in:
2026-04-27 21:34:47 -04:00
parent 808d5f909d
commit f2d9b7f455
5 changed files with 181 additions and 3 deletions

View File

@@ -83,6 +83,35 @@
</td> </td>
</ng-container> </ng-container>
<!-- Cost Column -->
<ng-container matColumnDef="cost">
<th mat-header-cell *matHeaderCellDef mat-sort-header="cost">Cost</th>
<td mat-cell *matCellDef="let filament">
<div class="cost-cell">
@if (filament.purchasePrice !== null) {
<span class="cost-price">{{ formatCurrency(filament.purchasePrice) }}</span>
@let cpg = getCostPerGram(filament);
@if (cpg !== null) {
<span class="cost-per-gram">${{ cpg.toFixed(2) }}/g</span>
}
} @else {
<span class="cost-unknown">&mdash;</span>
}
</div>
</td>
</ng-container>
<!-- Usage Column -->
<ng-container matColumnDef="usage">
<th mat-header-cell *matHeaderCellDef mat-sort-header="usage">Usage</th>
<td mat-cell *matCellDef="let filament">
<div class="usage-cell">
<span class="usage-grams">{{ formatWeight(getGramsUsed(filament)) }} used</span>
<span class="usage-remaining">{{ formatWeight(filament.weightRemainingGrams) }} left</span>
</div>
</td>
</ng-container>
<!-- Stock Level Indicator Column --> <!-- Stock Level Indicator Column -->
<ng-container matColumnDef="stockLevel"> <ng-container matColumnDef="stockLevel">
<th mat-header-cell *matHeaderCellDef mat-sort-header="stockLevel">Stock</th> <th mat-header-cell *matHeaderCellDef mat-sort-header="stockLevel">Stock</th>

View File

@@ -55,7 +55,7 @@ $color-inactive: #94a3b8; // Gray — inactive spool
// Table styling // Table styling
.filament-table { .filament-table {
width: 100%; width: 100%;
min-width: 700px; min-width: 900px;
th { th {
font-weight: 600; font-weight: 600;
@@ -132,6 +132,48 @@ $color-inactive: #94a3b8; // Gray — inactive spool
letter-spacing: 0.02em; 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 weight cell
.remaining-cell { .remaining-cell {
display: flex; display: flex;

View File

@@ -30,6 +30,8 @@ export type FilamentColumn =
| 'brand' | 'brand'
| 'serial' | 'serial'
| 'remaining' | 'remaining'
| 'cost'
| 'usage'
| 'stockLevel' | 'stockLevel'
| 'status'; | 'status';
@@ -70,6 +72,8 @@ export class FilamentTableComponent implements OnInit {
'brand', 'brand',
'serial', 'serial',
'remaining', 'remaining',
'cost',
'usage',
'stockLevel', 'stockLevel',
'status', 'status',
]); ]);
@@ -143,6 +147,18 @@ export class FilamentTableComponent implements OnInit {
getRemainingPercent(b), getRemainingPercent(b),
isAsc 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': case 'stockLevel':
return compare( return compare(
stockLevelOrder(classifyStockLevel(a)), stockLevelOrder(classifyStockLevel(a)),
@@ -243,6 +259,29 @@ export class FilamentTableComponent implements OnInit {
} }
return `${Math.round(grams)}g`; 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 */ /** Compare helper for sorting */

View File

@@ -87,6 +87,43 @@
</div> </div>
</div> </div>
<!-- Average Cost per Gram -->
@if (avgCostPerGram() !== null) {
<div class="summary-item metric-card"
matTooltip="Average cost per gram across priced, active spools"
matTooltipPosition="below">
<mat-icon aria-hidden="true" class="metric-icon">scale</mat-icon>
<div class="metric-content">
<span class="metric-value">${{ avgCostPerGram()!.toFixed(2) }}/g</span>
<span class="metric-label">Avg Cost/g</span>
</div>
</div>
}
<!-- Total Usage -->
<div class="summary-item metric-card"
matTooltip="Total filament used across all spools"
matTooltipPosition="below">
<mat-icon aria-hidden="true" class="metric-icon">trending_down</mat-icon>
<div class="metric-content">
<span class="metric-value">{{ formatWeight(totalGramsUsed()) }}</span>
<span class="metric-label">Total Used</span>
</div>
</div>
<!-- Estimated Used Value -->
@if (estimatedUsedValue() !== null) {
<div class="summary-item metric-card"
matTooltip="Estimated value of filament consumed"
matTooltipPosition="below">
<mat-icon aria-hidden="true" class="metric-icon">receipt_long</mat-icon>
<div class="metric-content">
<span class="metric-value">{{ formatCurrency(estimatedUsedValue()!) }}</span>
<span class="metric-label">Used Value</span>
</div>
</div>
}
<!-- Overall Remaining Stock Bar --> <!-- Overall Remaining Stock Bar -->
<div class="summary-item metric-card stock-bar-card" <div class="summary-item metric-card stock-bar-card"
matTooltip="{{ formatWeight(totalRemainingGrams()) }} of {{ formatWeight(totalCapacityGrams()) }} remaining" matTooltip="{{ formatWeight(totalRemainingGrams()) }} of {{ formatWeight(totalCapacityGrams()) }} remaining"

View File

@@ -88,6 +88,37 @@ export class InventorySummaryComponent implements OnInit, OnDestroy {
.reduce((sum, f) => sum + (f.purchasePrice ?? 0), 0) .reduce((sum, f) => 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 */ /** Computed: total remaining weight across all spools in grams */
readonly totalRemainingGrams = computed(() => readonly totalRemainingGrams = computed(() =>
this.filaments().reduce((sum, f) => sum + f.weightRemainingGrams, 0) 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', { return new Intl.NumberFormat('en-US', {
style: 'currency', style: 'currency',
currency: 'USD', currency: 'USD',
minimumFractionDigits: 0, minimumFractionDigits: 2,
maximumFractionDigits: 0, maximumFractionDigits: 2,
}).format(value); }).format(value);
} }
} }