Merge pull request 'CUB-42: Show filament cost and usage in UI' (#31) from agent/rex/CUB-42-filament-cost-usage-ui into dev
All checks were successful
Dev Build / build-test (push) Successful in 2m10s
All checks were successful
Dev Build / build-test (push) Successful in 2m10s
Reviewed-on: #31 Reviewed-by: Joshua <joshua@cnjmail.com>
This commit was merged in pull request #31.
This commit is contained in:
@@ -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">—</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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 */
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user