Compare commits
1 Commits
agent/rex/
...
feca1e3ee9
| Author | SHA1 | Date | |
|---|---|---|---|
| feca1e3ee9 |
@@ -1,7 +1,6 @@
|
|||||||
using Extrudex.API.DTOs;
|
using Extrudex.API.DTOs;
|
||||||
using Extrudex.API.DTOs.Filaments;
|
using Extrudex.API.DTOs.Filaments;
|
||||||
using Extrudex.Domain.Entities;
|
using Extrudex.Domain.Entities;
|
||||||
using Extrudex.Domain.Interfaces;
|
|
||||||
using Extrudex.Infrastructure.Data;
|
using Extrudex.Infrastructure.Data;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
@@ -18,22 +17,16 @@ namespace Extrudex.API.Controllers;
|
|||||||
public class FilamentsController : ControllerBase
|
public class FilamentsController : ControllerBase
|
||||||
{
|
{
|
||||||
private readonly ExtrudexDbContext _dbContext;
|
private readonly ExtrudexDbContext _dbContext;
|
||||||
private readonly ILowStockDetector _lowStockDetector;
|
|
||||||
private readonly ILogger<FilamentsController> _logger;
|
private readonly ILogger<FilamentsController> _logger;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Initializes a new instance of the <see cref="FilamentsController"/> class.
|
/// Initializes a new instance of the <see cref="FilamentsController"/> class.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="dbContext">The database context for data access.</param>
|
/// <param name="dbContext">The database context for data access.</param>
|
||||||
/// <param name="lowStockDetector">The low-stock detection service for filament alerts.</param>
|
|
||||||
/// <param name="logger">The logger for diagnostic output.</param>
|
/// <param name="logger">The logger for diagnostic output.</param>
|
||||||
public FilamentsController(
|
public FilamentsController(ExtrudexDbContext dbContext, ILogger<FilamentsController> logger)
|
||||||
ExtrudexDbContext dbContext,
|
|
||||||
ILowStockDetector lowStockDetector,
|
|
||||||
ILogger<FilamentsController> logger)
|
|
||||||
{
|
{
|
||||||
_dbContext = dbContext;
|
_dbContext = dbContext;
|
||||||
_lowStockDetector = lowStockDetector;
|
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,7 +95,7 @@ public class FilamentsController : ControllerBase
|
|||||||
.OrderByDescending(s => s.CreatedAt)
|
.OrderByDescending(s => s.CreatedAt)
|
||||||
.Skip((pageNumber - 1) * pageSize)
|
.Skip((pageNumber - 1) * pageSize)
|
||||||
.Take(pageSize)
|
.Take(pageSize)
|
||||||
.Select(s => MapToFilamentResponse(s, _lowStockDetector))
|
.Select(s => MapToFilamentResponse(s))
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
var response = new PagedResponse<FilamentResponse>
|
var response = new PagedResponse<FilamentResponse>
|
||||||
@@ -143,7 +136,7 @@ public class FilamentsController : ControllerBase
|
|||||||
return NotFound(new { error = $"Filament with ID '{id}' not found." });
|
return NotFound(new { error = $"Filament with ID '{id}' not found." });
|
||||||
}
|
}
|
||||||
|
|
||||||
return Ok(MapToFilamentResponse(spool, _lowStockDetector));
|
return Ok(MapToFilamentResponse(spool));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -218,7 +211,7 @@ public class FilamentsController : ControllerBase
|
|||||||
if (entity.MaterialModifierId.HasValue)
|
if (entity.MaterialModifierId.HasValue)
|
||||||
await _dbContext.Entry(entity).Reference(s => s.MaterialModifier).LoadAsync();
|
await _dbContext.Entry(entity).Reference(s => s.MaterialModifier).LoadAsync();
|
||||||
|
|
||||||
var response = MapToFilamentResponse(entity, _lowStockDetector);
|
var response = MapToFilamentResponse(entity);
|
||||||
return CreatedAtAction(nameof(GetFilament), new { id = entity.Id }, response);
|
return CreatedAtAction(nameof(GetFilament), new { id = entity.Id }, response);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -299,37 +292,7 @@ public class FilamentsController : ControllerBase
|
|||||||
if (entity.MaterialModifierId.HasValue)
|
if (entity.MaterialModifierId.HasValue)
|
||||||
await _dbContext.Entry(entity).Reference(s => s.MaterialModifier).LoadAsync();
|
await _dbContext.Entry(entity).Reference(s => s.MaterialModifier).LoadAsync();
|
||||||
|
|
||||||
return Ok(MapToFilamentResponse(entity, _lowStockDetector));
|
return Ok(MapToFilamentResponse(entity));
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets only the filament spools that are flagged as low stock.
|
|
||||||
/// A spool is considered low stock when its remaining weight percentage
|
|
||||||
/// is at or below the configured threshold.
|
|
||||||
/// </summary>
|
|
||||||
/// <returns>A list of low-stock filament spools with alert metadata.</returns>
|
|
||||||
/// <response code="200">Returns the list of low-stock filament spools.</response>
|
|
||||||
[HttpGet("low-stock")]
|
|
||||||
[ProducesResponseType(typeof(List<FilamentResponse>), StatusCodes.Status200OK)]
|
|
||||||
public async Task<ActionResult<List<FilamentResponse>>> GetLowStockFilaments()
|
|
||||||
{
|
|
||||||
_logger.LogDebug("Getting low-stock filaments (threshold: {Threshold}%)",
|
|
||||||
_lowStockDetector.LowStockThresholdPercent);
|
|
||||||
|
|
||||||
var spools = await _dbContext.Spools
|
|
||||||
.Include(s => s.MaterialBase)
|
|
||||||
.Include(s => s.MaterialFinish)
|
|
||||||
.Include(s => s.MaterialModifier)
|
|
||||||
.Where(s => s.IsActive)
|
|
||||||
.OrderByDescending(s => s.CreatedAt)
|
|
||||||
.ToListAsync();
|
|
||||||
|
|
||||||
var lowStockItems = spools
|
|
||||||
.Where(s => _lowStockDetector.IsLowStock(s.WeightRemainingGrams, s.WeightTotalGrams))
|
|
||||||
.Select(s => MapToFilamentResponse(s, _lowStockDetector))
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
return Ok(lowStockItems);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -398,12 +361,10 @@ public class FilamentsController : ControllerBase
|
|||||||
/// Maps a Spool domain entity to a FilamentResponse DTO.
|
/// Maps a Spool domain entity to a FilamentResponse DTO.
|
||||||
/// Denormalizes material names for display convenience.
|
/// Denormalizes material names for display convenience.
|
||||||
/// Populates the QrCodeUrl for easy frontend access to the spool's QR code.
|
/// Populates the QrCodeUrl for easy frontend access to the spool's QR code.
|
||||||
/// Calculates low-stock status and remaining weight percentage.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="s">The spool entity to map.</param>
|
/// <param name="s">The spool entity to map.</param>
|
||||||
/// <param name="lowStockDetector">The low-stock detection service for computing alert flags.</param>
|
/// <returns>A FilamentResponse DTO with denormalized material names and QR code URL.</returns>
|
||||||
/// <returns>A FilamentResponse DTO with denormalized material names, QR code URL, and low-stock metadata.</returns>
|
private static FilamentResponse MapToFilamentResponse(Spool s) => new()
|
||||||
private static FilamentResponse MapToFilamentResponse(Spool s, ILowStockDetector lowStockDetector) => new()
|
|
||||||
{
|
{
|
||||||
Id = s.Id,
|
Id = s.Id,
|
||||||
MaterialBaseId = s.MaterialBaseId,
|
MaterialBaseId = s.MaterialBaseId,
|
||||||
@@ -426,8 +387,6 @@ public class FilamentsController : ControllerBase
|
|||||||
StorageLocation = s.StorageLocation,
|
StorageLocation = s.StorageLocation,
|
||||||
CreatedAt = s.CreatedAt,
|
CreatedAt = s.CreatedAt,
|
||||||
UpdatedAt = s.UpdatedAt,
|
UpdatedAt = s.UpdatedAt,
|
||||||
QrCodeUrl = $"/api/qr/spool/{s.Id}",
|
QrCodeUrl = $"/api/qr/spool/{s.Id}"
|
||||||
IsLowStock = lowStockDetector.IsLowStock(s.WeightRemainingGrams, s.WeightTotalGrams),
|
|
||||||
RemainingWeightPercent = lowStockDetector.GetRemainingWeightPercent(s.WeightRemainingGrams, s.WeightTotalGrams)
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -76,19 +76,6 @@ public class FilamentResponse
|
|||||||
/// Encodes a deep link to the spool's detail page.
|
/// Encodes a deep link to the spool's detail page.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string QrCodeUrl { get; set; } = string.Empty;
|
public string QrCodeUrl { get; set; } = string.Empty;
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Whether this spool is flagged as low stock — remaining weight is at or
|
|
||||||
/// below the configured low-stock threshold percentage.
|
|
||||||
/// Useful for UI alerts and inventory dashboards.
|
|
||||||
/// </summary>
|
|
||||||
public bool IsLowStock { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Remaining filament weight as a percentage of total weight (0–100).
|
|
||||||
/// Rounded to one decimal place. Returns 0 if total weight is zero.
|
|
||||||
/// </summary>
|
|
||||||
public decimal RemainingWeightPercent { get; set; }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -1,39 +0,0 @@
|
|||||||
namespace Extrudex.Domain.Interfaces;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Detects low-stock filament spools based on configurable weight thresholds.
|
|
||||||
/// Determines whether a spool's remaining filament falls below a critical level
|
|
||||||
/// so that alerts and API flags can be surfaced to the user.
|
|
||||||
/// </summary>
|
|
||||||
public interface ILowStockDetector
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Determines whether a spool is considered low stock based on its remaining
|
|
||||||
/// weight relative to its total weight and the configured threshold percentage.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="weightRemainingGrams">The current remaining weight in grams.</param>
|
|
||||||
/// <param name="weightTotalGrams">The total spool weight in grams when full.</param>
|
|
||||||
/// <returns>
|
|
||||||
/// <c>true</c> if the remaining weight percentage is at or below the configured
|
|
||||||
/// low-stock threshold; <c>false</c> otherwise. Returns <c>false</c> for spools
|
|
||||||
/// with zero total weight to avoid division-by-zero.
|
|
||||||
/// </returns>
|
|
||||||
bool IsLowStock(decimal weightRemainingGrams, decimal weightTotalGrams);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Calculates the remaining weight as a percentage of total weight.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="weightRemainingGrams">The current remaining weight in grams.</param>
|
|
||||||
/// <param name="weightTotalGrams">The total spool weight in grams when full.</param>
|
|
||||||
/// <returns>
|
|
||||||
/// A value between 0 and 100 representing the percentage of filament remaining.
|
|
||||||
/// Returns 0 if total weight is zero to avoid division-by-zero.
|
|
||||||
/// </returns>
|
|
||||||
decimal GetRemainingWeightPercent(decimal weightRemainingGrams, decimal weightTotalGrams);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the currently configured low-stock threshold percentage.
|
|
||||||
/// Useful for API responses so clients know what threshold is in effect.
|
|
||||||
/// </summary>
|
|
||||||
decimal LowStockThresholdPercent { get; }
|
|
||||||
}
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
using Extrudex.Domain.Interfaces;
|
|
||||||
using Microsoft.Extensions.Configuration;
|
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
|
|
||||||
namespace Extrudex.Infrastructure.Services;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Detects low-stock filament spools by comparing the remaining weight percentage
|
|
||||||
/// against a configurable threshold. The threshold can be set via:
|
|
||||||
/// 1. EXTRUDEX_LOW_STOCK_THRESHOLD env var (highest priority, e.g. "25")
|
|
||||||
/// 2. FilamentAlerts:LowStockThresholdPercent in appsettings.json
|
|
||||||
/// 3. Default: 20% (a standard spool is "low" when ≤20% remains)
|
|
||||||
/// </summary>
|
|
||||||
public class LowStockDetector : ILowStockDetector
|
|
||||||
{
|
|
||||||
private readonly ILogger<LowStockDetector> _logger;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The percentage threshold below which a spool is considered low stock.
|
|
||||||
/// For example, 20 means a spool is "low" when ≤20% of its filament remains.
|
|
||||||
/// </summary>
|
|
||||||
public decimal LowStockThresholdPercent { get; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Initializes a new instance of the <see cref="LowStockDetector"/> class.
|
|
||||||
/// Reads the low-stock threshold from configuration with env var override support.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="configuration">Application configuration for threshold settings.</param>
|
|
||||||
/// <param name="logger">Logger for diagnostic output.</param>
|
|
||||||
public LowStockDetector(IConfiguration configuration, ILogger<LowStockDetector> logger)
|
|
||||||
{
|
|
||||||
_logger = logger;
|
|
||||||
|
|
||||||
// Priority: env var > appsettings > default (20%)
|
|
||||||
var envThreshold = Environment.GetEnvironmentVariable("EXTRUDEX_LOW_STOCK_THRESHOLD");
|
|
||||||
var configThreshold = configuration.GetValue<decimal?>("FilamentAlerts:LowStockThresholdPercent");
|
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(envThreshold) && decimal.TryParse(envThreshold, out var parsedEnv))
|
|
||||||
{
|
|
||||||
LowStockThresholdPercent = Math.Clamp(parsedEnv, 0m, 100m);
|
|
||||||
_logger.LogInformation(
|
|
||||||
"Low-stock threshold set from env var EXTRUDEX_LOW_STOCK_THRESHOLD: {Threshold}%",
|
|
||||||
LowStockThresholdPercent);
|
|
||||||
}
|
|
||||||
else if (configThreshold.HasValue)
|
|
||||||
{
|
|
||||||
LowStockThresholdPercent = Math.Clamp(configThreshold.Value, 0m, 100m);
|
|
||||||
_logger.LogInformation(
|
|
||||||
"Low-stock threshold set from config FilamentAlerts:LowStockThresholdPercent: {Threshold}%",
|
|
||||||
LowStockThresholdPercent);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
LowStockThresholdPercent = 20m;
|
|
||||||
_logger.LogInformation(
|
|
||||||
"Low-stock threshold using default: {Threshold}%", LowStockThresholdPercent);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public bool IsLowStock(decimal weightRemainingGrams, decimal weightTotalGrams)
|
|
||||||
{
|
|
||||||
if (weightTotalGrams <= 0m)
|
|
||||||
{
|
|
||||||
_logger.LogDebug(
|
|
||||||
"Spool with total weight {Total}g cannot be evaluated for low stock — treating as not low",
|
|
||||||
weightTotalGrams);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
var remainingPercent = GetRemainingWeightPercent(weightRemainingGrams, weightTotalGrams);
|
|
||||||
var isLow = remainingPercent <= LowStockThresholdPercent;
|
|
||||||
|
|
||||||
if (isLow)
|
|
||||||
{
|
|
||||||
_logger.LogDebug(
|
|
||||||
"Spool is LOW STOCK: {Remaining}g / {Total}g = {Percent:F1}% (threshold: {Threshold}%)",
|
|
||||||
weightRemainingGrams, weightTotalGrams, remainingPercent, LowStockThresholdPercent);
|
|
||||||
}
|
|
||||||
|
|
||||||
return isLow;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public decimal GetRemainingWeightPercent(decimal weightRemainingGrams, decimal weightTotalGrams)
|
|
||||||
{
|
|
||||||
if (weightTotalGrams <= 0m)
|
|
||||||
return 0m;
|
|
||||||
|
|
||||||
return Math.Round(
|
|
||||||
(weightRemainingGrams / weightTotalGrams) * 100m,
|
|
||||||
1,
|
|
||||||
MidpointRounding.AwayFromZero);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -55,9 +55,6 @@ builder.Services.AddSingleton<IQrCodeService, QrCodeService>();
|
|||||||
// ── Cost Per Print Calculation ─────────────────────────────
|
// ── Cost Per Print Calculation ─────────────────────────────
|
||||||
builder.Services.AddScoped<ICostPerPrintService, CostPerPrintService>();
|
builder.Services.AddScoped<ICostPerPrintService, CostPerPrintService>();
|
||||||
|
|
||||||
// ── Low Stock Detection ────────────────────────────────────
|
|
||||||
builder.Services.AddSingleton<ILowStockDetector, LowStockDetector>();
|
|
||||||
|
|
||||||
// ── Usage Logging ───────────────────────────────────────────
|
// ── Usage Logging ───────────────────────────────────────────
|
||||||
builder.Services.AddScoped<IUsageLogService, UsageLogService>();
|
builder.Services.AddScoped<IUsageLogService, UsageLogService>();
|
||||||
|
|
||||||
|
|||||||
@@ -20,8 +20,5 @@
|
|||||||
"RequestTimeout": "00:00:15",
|
"RequestTimeout": "00:00:15",
|
||||||
"Enabled": true,
|
"Enabled": true,
|
||||||
"HistoryBatchSize": 25
|
"HistoryBatchSize": 25
|
||||||
},
|
|
||||||
"FilamentAlerts": {
|
|
||||||
"LowStockThresholdPercent": 20
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -83,35 +83,6 @@
|
|||||||
</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: 900px;
|
min-width: 700px;
|
||||||
|
|
||||||
th {
|
th {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -132,48 +132,6 @@ $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,8 +30,6 @@ export type FilamentColumn =
|
|||||||
| 'brand'
|
| 'brand'
|
||||||
| 'serial'
|
| 'serial'
|
||||||
| 'remaining'
|
| 'remaining'
|
||||||
| 'cost'
|
|
||||||
| 'usage'
|
|
||||||
| 'stockLevel'
|
| 'stockLevel'
|
||||||
| 'status';
|
| 'status';
|
||||||
|
|
||||||
@@ -72,8 +70,6 @@ export class FilamentTableComponent implements OnInit {
|
|||||||
'brand',
|
'brand',
|
||||||
'serial',
|
'serial',
|
||||||
'remaining',
|
'remaining',
|
||||||
'cost',
|
|
||||||
'usage',
|
|
||||||
'stockLevel',
|
'stockLevel',
|
||||||
'status',
|
'status',
|
||||||
]);
|
]);
|
||||||
@@ -147,18 +143,6 @@ 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)),
|
||||||
@@ -259,29 +243,6 @@ 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,43 +87,6 @@
|
|||||||
</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,37 +88,6 @@ 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)
|
||||||
@@ -200,8 +169,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: 2,
|
minimumFractionDigits: 0,
|
||||||
maximumFractionDigits: 2,
|
maximumFractionDigits: 0,
|
||||||
}).format(value);
|
}).format(value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user