| 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) {
+
+
scale
+
+ ${{ avgCostPerGram()!.toFixed(2) }}/g
+ Avg Cost/g
+
+
+ }
+
+
+
+
trending_down
+
+ {{ formatWeight(totalGramsUsed()) }}
+ Total Used
+
+
+
+
+ @if (estimatedUsedValue() !== null) {
+
+
receipt_long
+
+ {{ 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