[Fix] Forecasting: Group spools by color and rework UI (#1814)

This commit is contained in:
maziggy
2026-06-28 12:46:04 +02:00
7 changed files with 322 additions and 142 deletions
+10
View File
@@ -2164,6 +2164,7 @@ class FilamentSkuSettingsResponse(BaseModel):
material: str
subtype: str | None
brand: str | None
color_name: str | None
lead_time_days: int
safety_margin_value: int
safety_margin_unit: str
@@ -2177,6 +2178,7 @@ class FilamentSkuSettingsUpsert(BaseModel):
material: str
subtype: str | None = None
brand: str | None = None
color_name: str | None = None
lead_time_days: int = 0
safety_margin_value: int = 14
safety_margin_unit: str = "days"
@@ -2213,6 +2215,7 @@ async def upsert_sku_settings(
FilamentSkuSettings.material == data.material,
FilamentSkuSettings.subtype == data.subtype,
FilamentSkuSettings.brand == data.brand,
FilamentSkuSettings.color_name == data.color_name,
)
)
row = result.scalar_one_or_none()
@@ -2226,6 +2229,7 @@ async def upsert_sku_settings(
material=data.material,
subtype=data.subtype,
brand=data.brand,
color_name=data.color_name,
lead_time_days=data.lead_time_days,
safety_margin_value=data.safety_margin_value,
safety_margin_unit=data.safety_margin_unit,
@@ -2245,6 +2249,7 @@ class ShoppingListItemResponse(BaseModel):
material: str
subtype: str | None
brand: str | None
color_name: str | None
quantity_spools: int
note: str | None
status: str
@@ -2259,6 +2264,7 @@ class ShoppingListItemCreate(BaseModel):
material: str
subtype: str | None = None
brand: str | None = None
color_name: str | None = None
quantity_spools: int = 1
note: str | None = None
@@ -2283,6 +2289,7 @@ async def get_shopping_list(
material=i.material,
subtype=i.subtype,
brand=i.brand,
color_name=i.color_name,
quantity_spools=i.quantity_spools,
note=i.note,
status=i.status or "pending",
@@ -2308,6 +2315,7 @@ async def add_to_shopping_list(
material=data.material,
subtype=data.subtype,
brand=data.brand,
color_name=data.color_name,
quantity_spools=data.quantity_spools,
note=data.note,
)
@@ -2319,6 +2327,7 @@ async def add_to_shopping_list(
material=item.material,
subtype=item.subtype,
brand=item.brand,
color_name=item.color_name,
quantity_spools=item.quantity_spools,
note=item.note,
status=item.status or "pending",
@@ -2362,6 +2371,7 @@ async def update_shopping_list_status(
material=item.material,
subtype=item.subtype,
brand=item.brand,
color_name=item.color_name,
quantity_spools=item.quantity_spools,
note=item.note,
status=item.status or "pending",
+88 -5
View File
@@ -2638,9 +2638,10 @@ async def run_migrations(conn):
lead_time_days INTEGER NOT NULL DEFAULT 0,
safety_margin_value INTEGER NOT NULL DEFAULT 14,
safety_margin_unit VARCHAR(10) NOT NULL DEFAULT 'days',
color_name VARCHAR(100),
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE (material, subtype, brand)
UNIQUE (material, subtype, brand, color_name)
)""",
)
async with conn.begin_nested():
@@ -2654,6 +2655,9 @@ async def run_migrations(conn):
await _safe_execute(
conn, "ALTER TABLE filament_sku_settings ADD COLUMN alerts_snoozed BOOLEAN NOT NULL DEFAULT 0"
)
# Migration: add color_name so forecasts distinguish colours within a SKU.
await _safe_execute(conn, "ALTER TABLE filament_sku_settings ADD COLUMN color_name VARCHAR(100)")
await _safe_execute(conn, "ALTER TABLE filament_shopping_list ADD COLUMN color_name VARCHAR(100)")
# Backfill and drop legacy safety_margin_days column — SQLite requires a table rebuild.
# Only run if the stale column still exists.
cols_result = await conn.execute(text("PRAGMA table_info(filament_sku_settings)"))
@@ -2676,28 +2680,77 @@ async def run_migrations(conn):
material VARCHAR(50) NOT NULL,
subtype VARCHAR(50),
brand VARCHAR(100),
color_name VARCHAR(100),
lead_time_days INTEGER NOT NULL DEFAULT 0,
safety_margin_value INTEGER NOT NULL DEFAULT 14,
safety_margin_unit VARCHAR(10) NOT NULL DEFAULT 'days',
alerts_snoozed BOOLEAN NOT NULL DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE (material, subtype, brand)
UNIQUE (material, subtype, brand, color_name)
)"""
)
)
await conn.execute(
text(
"""INSERT INTO filament_sku_settings_new
(id, material, subtype, brand, lead_time_days, safety_margin_value,
(id, material, subtype, brand, color_name, lead_time_days, safety_margin_value,
safety_margin_unit, alerts_snoozed, created_at, updated_at)
SELECT id, material, subtype, brand, lead_time_days, safety_margin_value,
SELECT id, material, subtype, brand, color_name, lead_time_days, safety_margin_value,
safety_margin_unit, COALESCE(alerts_snoozed, 0), created_at, updated_at
FROM filament_sku_settings"""
)
)
await conn.execute(text("DROP TABLE filament_sku_settings"))
await conn.execute(text("ALTER TABLE filament_sku_settings_new RENAME TO filament_sku_settings"))
# Widen the unique key to include color_name on pre-existing tables. The
# auto-created UNIQUE index still covers only (material, subtype, brand)
# after the ADD COLUMN above, so rebuild the table to refresh it (#forecast
# -color-grouping). Detected by inspecting the index columns; skipped once
# color_name is already part of the key.
idx_rows = await conn.execute(text("PRAGMA index_list(filament_sku_settings)"))
needs_uq_rebuild = False
for idx in idx_rows.fetchall():
if idx[3] != "u": # origin col: 'u' = UNIQUE constraint, 'c' = CREATE INDEX, 'pk' = primary key
continue
info = await conn.execute(text(f"PRAGMA index_info({idx[1]})"))
cols = {row[2] for row in info.fetchall()}
if "material" in cols and "color_name" not in cols:
needs_uq_rebuild = True
break
if needs_uq_rebuild:
async with conn.begin_nested():
await conn.execute(text("DROP TABLE IF EXISTS filament_sku_settings_uqfix"))
await conn.execute(
text(
"""CREATE TABLE filament_sku_settings_uqfix (
id INTEGER PRIMARY KEY AUTOINCREMENT,
material VARCHAR(50) NOT NULL,
subtype VARCHAR(50),
brand VARCHAR(100),
color_name VARCHAR(100),
lead_time_days INTEGER NOT NULL DEFAULT 0,
safety_margin_value INTEGER NOT NULL DEFAULT 14,
safety_margin_unit VARCHAR(10) NOT NULL DEFAULT 'days',
alerts_snoozed BOOLEAN NOT NULL DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE (material, subtype, brand, color_name)
)"""
)
)
await conn.execute(
text(
"""INSERT INTO filament_sku_settings_uqfix
(id, material, subtype, brand, color_name, lead_time_days, safety_margin_value,
safety_margin_unit, alerts_snoozed, created_at, updated_at)
SELECT id, material, subtype, brand, color_name, lead_time_days, safety_margin_value,
safety_margin_unit, COALESCE(alerts_snoozed, 0), created_at, updated_at
FROM filament_sku_settings"""
)
)
await conn.execute(text("DROP TABLE filament_sku_settings"))
await conn.execute(text("ALTER TABLE filament_sku_settings_uqfix RENAME TO filament_sku_settings"))
await _safe_execute(
conn,
"""CREATE TABLE IF NOT EXISTS filament_shopping_list (
@@ -2705,6 +2758,7 @@ async def run_migrations(conn):
material VARCHAR(50) NOT NULL,
subtype VARCHAR(50),
brand VARCHAR(100),
color_name VARCHAR(100),
quantity_spools INTEGER NOT NULL DEFAULT 1,
note VARCHAR(500),
status VARCHAR(20) NOT NULL DEFAULT 'pending',
@@ -2732,9 +2786,10 @@ async def run_migrations(conn):
lead_time_days INTEGER NOT NULL DEFAULT 0,
safety_margin_value INTEGER NOT NULL DEFAULT 14,
safety_margin_unit VARCHAR(10) NOT NULL DEFAULT 'days',
color_name VARCHAR(100),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE (material, subtype, brand)
UNIQUE (material, subtype, brand, color_name)
)""",
)
async with conn.begin_nested():
@@ -2751,6 +2806,33 @@ async def run_migrations(conn):
conn,
"ALTER TABLE filament_sku_settings ADD COLUMN IF NOT EXISTS alerts_snoozed BOOLEAN NOT NULL DEFAULT FALSE",
)
# Migration: add color_name and widen the unique key to include it so
# forecasts distinguish colours within a SKU (#forecast-color-grouping).
await _safe_execute(conn, "ALTER TABLE filament_sku_settings ADD COLUMN IF NOT EXISTS color_name VARCHAR(100)")
await _safe_execute(conn, "ALTER TABLE filament_shopping_list ADD COLUMN IF NOT EXISTS color_name VARCHAR(100)")
# Widen UNIQUE (material, subtype, brand) → (material, subtype, brand, color_name).
# The original constraint was declared with name="uq_filament_sku" in the
# model, so we drop/re-add by that name. Gated on a pg_constraint lookup so
# the rebuild only runs when color_name is missing from the key — without
# the gate, every startup would take an ACCESS EXCLUSIVE lock on the table
# and churn the constraint.
uq_check = await conn.execute(
text(
"SELECT 1 FROM pg_constraint c "
"JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = ANY(c.conkey) "
"WHERE c.conname = 'uq_filament_sku' AND a.attname = 'color_name' LIMIT 1"
)
)
if uq_check.scalar_one_or_none() is None:
await _safe_execute(
conn,
"ALTER TABLE filament_sku_settings DROP CONSTRAINT IF EXISTS uq_filament_sku",
)
await _safe_execute(
conn,
"ALTER TABLE filament_sku_settings ADD CONSTRAINT uq_filament_sku "
"UNIQUE (material, subtype, brand, color_name)",
)
# Only backfill from safety_margin_days if that column still exists (PostgreSQL).
col_check = await conn.execute(
text(
@@ -2773,6 +2855,7 @@ async def run_migrations(conn):
material VARCHAR(50) NOT NULL,
subtype VARCHAR(50),
brand VARCHAR(100),
color_name VARCHAR(100),
quantity_spools INTEGER NOT NULL DEFAULT 1,
note VARCHAR(500),
status VARCHAR(20) NOT NULL DEFAULT 'pending',
+4 -1
View File
@@ -13,13 +13,16 @@ class FilamentSkuSettings(Base):
__table_args__ = (
# sqlite_where ensures NULL columns participate in uniqueness (NULLS NOT DISTINCT).
# On PostgreSQL the partial index is not needed — standard UNIQUE handles it.
UniqueConstraint("material", "subtype", "brand", name="uq_filament_sku"),
# color_name is part of the key so forecasts distinguish e.g. White vs Black
# PLA Matte (#forecast-color-grouping).
UniqueConstraint("material", "subtype", "brand", "color_name", name="uq_filament_sku"),
)
id: Mapped[int] = mapped_column(primary_key=True)
material: Mapped[str] = mapped_column(String(50))
subtype: Mapped[str | None] = mapped_column(String(50))
brand: Mapped[str | None] = mapped_column(String(100))
color_name: Mapped[str | None] = mapped_column(String(100))
lead_time_days: Mapped[int] = mapped_column(Integer, default=0)
safety_margin_value: Mapped[int] = mapped_column(Integer, default=14)
safety_margin_unit: Mapped[str] = mapped_column(String(10), default="days")
+1
View File
@@ -15,6 +15,7 @@ class ShoppingListItem(Base):
material: Mapped[str] = mapped_column(String(50))
subtype: Mapped[str | None] = mapped_column(String(50))
brand: Mapped[str | None] = mapped_column(String(100))
color_name: Mapped[str | None] = mapped_column(String(100))
quantity_spools: Mapped[int] = mapped_column(Integer, default=1)
note: Mapped[str | None] = mapped_column(String(500))
status: Mapped[str] = mapped_column(String(20), default="pending") # pending | purchased | received
+3
View File
@@ -2792,6 +2792,7 @@ export interface FilamentSkuSettings {
material: string;
subtype: string | null;
brand: string | null;
color_name: string | null;
lead_time_days: number;
safety_margin_value: number;
safety_margin_unit: 'days' | 'g';
@@ -2803,6 +2804,7 @@ export interface ShoppingListItem {
material: string;
subtype: string | null;
brand: string | null;
color_name: string | null;
quantity_spools: number;
note: string | null;
status: 'pending' | 'purchased' | 'received';
@@ -2814,6 +2816,7 @@ export interface ShoppingListItemCreate {
material: string;
subtype: string | null;
brand: string | null;
color_name: string | null;
quantity_spools: number;
note?: string | null;
}
+215 -135
View File
@@ -24,6 +24,7 @@ interface SkuGroup {
material: string;
subtype: string | null;
brand: string | null;
colorName: string | null;
spools: InventorySpool[];
}
@@ -59,8 +60,8 @@ const CHART_COLORS = ['#1DB954', '#3B82F6', '#F59E0B', '#EF4444', '#8B5CF6'];
// ── Pure helpers ──────────────────────────────────────────────────────────────
function skuKey(material: string, subtype: string | null, brand: string | null) {
return `${material}||${subtype ?? ''}||${brand ?? ''}`;
function skuKey(material: string, subtype: string | null, brand: string | null, colorName: string | null) {
return `${material}||${subtype ?? ''}||${brand ?? ''}||${colorName ?? ''}`;
}
function addDays(date: Date, days: number): Date {
@@ -81,25 +82,39 @@ function formatDateShort(date: Date): string {
* Compute a time-weighted daily consumption rate and standard deviation.
*
* Algorithm:
* 1. Sort all usage events by timestamp (oldest → newest).
* 2. Convert each event into a g/day intensity = weight_used / elapsed_days,
* where elapsed_days is the gap to the previous event (floor: 0.5d to
* avoid inflated rates from same-day prints).
* 1. Aggregate all usage events by calendar day (UTC date string), summing
* weight_used across all spools in the group that printed on that day.
* Day-bucketing fixes two problems: (a) concurrent prints from multiple
* spools in the same group no longer produce near-zero inter-event gaps
* that inflate per-interval rates; (b) the oldest event's weight is no
* longer silently dropped — it contributes to its day bucket.
* 2. Sort day buckets oldest → newest and compute inter-day g/day rates.
* The gap is in whole days (minimum 1) so same-day reprints don't
* create a zero-duration interval.
* 3. Apply exponential age-decay: each observation is weighted by
* exp(-λ * age_days) so recent prints dominate. λ = ln(2)/30 gives a
* 30-day half-life — prints from a month ago count half as much.
* 4. Compute the weighted mean and weighted variance → std dev.
*
* Returns null when there is only one event (no gap to measure) — the
* delta-rate fallback handles that case.
* Returns null when there are fewer than 2 distinct days (no gap to measure)
* — the delta-rate fallback handles that case.
*/
function computeHistoryRate(records: SpoolUsageRecord[]): { rate: number; stdDev: number } | null {
if (records.length < 2) return null;
// Sort ascending by time
const sorted = [...records].sort(
(a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime(),
);
// Aggregate by UTC calendar day so concurrent multi-spool prints on the
// same day are summed before rate computation.
const byDay = new Map<string, number>();
for (const r of records) {
const day = r.created_at.slice(0, 10); // "YYYY-MM-DD" UTC — consistent with server timestamps
byDay.set(day, (byDay.get(day) ?? 0) + r.weight_used);
}
if (byDay.size < 2) return null;
const days = [...byDay.entries()]
.map(([day, totalG]) => ({ ms: new Date(day).getTime(), totalG }))
.sort((a, b) => a.ms - b.ms);
const now = Date.now();
// λ for 30-day half-life: ln(2)/30
@@ -107,15 +122,12 @@ function computeHistoryRate(records: SpoolUsageRecord[]): { rate: number; stdDev
const observations: { rate: number; weight: number }[] = [];
for (let i = 1; i < sorted.length; i++) {
const prev = new Date(sorted[i - 1].created_at).getTime();
const curr = new Date(sorted[i].created_at).getTime();
const elapsedDays = Math.max((curr - prev) / 86400000, 0.5); // floor at 0.5d
const ageDays = (now - curr) / 86400000;
for (let i = 1; i < days.length; i++) {
const elapsedDays = Math.max((days[i].ms - days[i - 1].ms) / 86400000, 1);
const ageDays = (now - days[i].ms) / 86400000;
// g/day for this interval
const intervalRate = sorted[i].weight_used / elapsedDays;
// Exponential age-decay weight
// g/day for this inter-day interval: weight printed on day[i] / gap to previous day
const intervalRate = days[i].totalG / elapsedDays;
const w = Math.exp(-lambda * ageDays);
observations.push({ rate: intervalRate, weight: w });
@@ -181,6 +193,8 @@ export function ForecastPanel({ spools }: { spools: InventorySpool[] }) {
const [alertsOpen, setAlertsOpen] = useState(false);
const [sortKey, setSortKey] = useState<SortKey>('material');
const [sortDir, setSortDir] = useState<SortDir>('asc');
const [materialFilter, setMaterialFilter] = useState('');
const [brandFilter, setBrandFilter] = useState('');
const [cartModal, setCartModal] = useState<SkuForecast | null>(null);
const [listOpen, setListOpen] = useState(false);
const [chartDays, setChartDays] = useState<ChartDays>(30);
@@ -194,7 +208,7 @@ export function ForecastPanel({ spools }: { spools: InventorySpool[] }) {
const settingsMap = useMemo(() => {
const m = new Map<string, FilamentSkuSettings>();
for (const s of skuSettingsList) m.set(skuKey(s.material, s.subtype, s.brand), s);
for (const s of skuSettingsList) m.set(skuKey(s.material, s.subtype, s.brand, s.color_name), s);
return m;
}, [skuSettingsList]);
@@ -212,8 +226,8 @@ export function ForecastPanel({ spools }: { spools: InventorySpool[] }) {
const map = new Map<string, SkuGroup>();
for (const spool of spools) {
if (spool.archived_at) continue;
const key = skuKey(spool.material, spool.subtype, spool.brand);
const g = map.get(key) ?? { key, material: spool.material, subtype: spool.subtype, brand: spool.brand, spools: [] };
const key = skuKey(spool.material, spool.subtype, spool.brand, spool.color_name);
const g = map.get(key) ?? { key, material: spool.material, subtype: spool.subtype, brand: spool.brand, colorName: spool.color_name, spools: [] };
g.spools.push(spool);
map.set(key, g);
}
@@ -224,7 +238,12 @@ export function ForecastPanel({ spools }: { spools: InventorySpool[] }) {
const today = new Date(); today.setHours(0, 0, 0, 0);
return groups.map((group): SkuForecast => {
const skuSettings = settingsMap.get(group.key) ?? null;
// Fall back to the NULL-colour row that pre-upgrade users have so their
// lead-time / safety-margin overrides survive the first load after the
// color_name column is added (#forecast-color-grouping migration).
const skuSettings =
settingsMap.get(group.key) ??
(group.colorName !== null ? settingsMap.get(skuKey(group.material, group.subtype, group.brand, null)) ?? null : null);
const skuLeadTime = skuSettings?.lead_time_days ?? 0;
const effectiveLeadTimeDays = Math.max(globalLeadTime, skuLeadTime);
const marginValue = skuSettings?.safety_margin_value ?? 14;
@@ -235,8 +254,15 @@ export function ForecastPanel({ spools }: { spools: InventorySpool[] }) {
// Consumed since baseline (post-reset); see InventoryPage stats calc (#1390).
const totalUsedG = group.spools.reduce((s, sp) => s + Math.max(0, sp.weight_used - (sp.weight_used_baseline ?? 0)), 0);
// Only include history from spools that haven't been reset — pre-reset
// events on a reset spool have no anchor timestamp so they'd inflate the
// rate. Spools without a baseline are clean and keep their records.
const groupHistory: SpoolUsageRecord[] = [];
for (const s of group.spools) groupHistory.push(...(usageBySpoolId.get(s.id) ?? []));
for (const s of group.spools) {
if ((s.weight_used_baseline ?? 0) === 0) {
groupHistory.push(...(usageBySpoolId.get(s.id) ?? []));
}
}
let dailyRateG: number | null = null;
let dailyRateStdDev: number | null = null;
@@ -287,8 +313,18 @@ export function ForecastPanel({ spools }: { spools: InventorySpool[] }) {
});
}, [groups, settingsMap, usageBySpoolId, globalLeadTime]);
const uniqueMaterials = useMemo(() =>
[...new Set(groups.map((g) => g.material))].sort(),
[groups]);
const uniqueBrands = useMemo(() =>
[...new Set(groups.map((g) => g.brand).filter(Boolean))].sort() as string[],
[groups]);
const sortedForecasts = useMemo(() => {
const arr = [...forecasts];
let arr = [...forecasts];
if (materialFilter) arr = arr.filter((f) => f.group.material === materialFilter);
if (brandFilter) arr = arr.filter((f) => f.group.brand === brandFilter);
arr.sort((a, b) => {
let va: number | string = 0;
let vb: number | string = 0;
@@ -311,7 +347,7 @@ export function ForecastPanel({ spools }: { spools: InventorySpool[] }) {
return sortDir === 'asc' ? cmp : -cmp;
});
return arr;
}, [forecasts, sortKey, sortDir]);
}, [forecasts, sortKey, sortDir, materialFilter, brandFilter]);
const alerts = useMemo(() => forecasts.filter((f) => !f.settings?.alerts_snoozed && (f.stockBreakAlert || f.reorderAlert)), [forecasts]);
@@ -374,6 +410,34 @@ export function ForecastPanel({ spools }: { spools: InventorySpool[] }) {
/>
)}
{/* Material filter */}
<select
value={materialFilter}
onChange={(e) => setMaterialFilter(e.target.value)}
className={`px-3 py-1.5 rounded-lg border text-xs font-medium transition-colors cursor-pointer focus:outline-none ${
materialFilter
? 'bg-bambu-green/20 text-bambu-green border-bambu-green/30'
: 'bg-transparent text-bambu-gray border-bambu-dark-tertiary hover:bg-bambu-dark-tertiary'
}`}
>
<option value="">{t('inventory.material')}</option>
{uniqueMaterials.map((m) => <option key={m} value={m}>{m}</option>)}
</select>
{/* Brand filter */}
<select
value={brandFilter}
onChange={(e) => setBrandFilter(e.target.value)}
className={`px-3 py-1.5 rounded-lg border text-xs font-medium transition-colors cursor-pointer focus:outline-none ${
brandFilter
? 'bg-bambu-green/20 text-bambu-green border-bambu-green/30'
: 'bg-transparent text-bambu-gray border-bambu-dark-tertiary hover:bg-bambu-dark-tertiary'
}`}
>
<option value="">{t('inventory.brand')}</option>
{uniqueBrands.map((b) => <option key={b} value={b}>{b}</option>)}
</select>
{/* Shopping list toggle */}
<button
onClick={() => setListOpen((o) => !o)}
@@ -439,6 +503,9 @@ export function ForecastPanel({ spools }: { spools: InventorySpool[] }) {
<SortableTh col="material" active={sortKey} dir={sortDir} onSort={handleSort}>
{t('forecast.sku')}
</SortableTh>
<th className="px-4 py-3 text-left text-xs font-medium text-bambu-gray uppercase tracking-wide">
{t('locations.spools')}
</th>
<SortableTh col="stock" active={sortKey} dir={sortDir} onSort={handleSort}>
{t('forecast.stock')}
</SortableTh>
@@ -545,7 +612,7 @@ function SortableTh({
function AlertBanner({ forecast: f, onCart }: { forecast: SkuForecast; onCart: () => void }) {
const { t } = useTranslation();
const label = [f.group.brand, f.group.material, f.group.subtype].filter(Boolean).join(' ');
const label = [f.group.brand, f.group.material, f.group.subtype, f.group.colorName].filter(Boolean).join(' ');
const isBreak = f.stockBreakAlert;
return (
@@ -593,7 +660,7 @@ function UsageChart({ forecasts, days: maxDays, onDaysChange }: {
const series = forecasts.map((f, idx) => ({
key: f.group.key,
label: [f.group.brand, f.group.material, f.group.subtype].filter(Boolean).join(' '),
label: [f.group.brand, f.group.material, f.group.subtype, f.group.colorName].filter(Boolean).join(' '),
color: CHART_COLORS[idx % CHART_COLORS.length],
rop: f.reorderPointG,
points: buildProjectionSeries(f, maxDays),
@@ -666,13 +733,24 @@ function UsageChart({ forecasts, days: maxDays, onDaysChange }: {
width={48}
/>
<Tooltip
contentStyle={{ background: '#1a1a2e', border: '1px solid #374151', borderRadius: 8, fontSize: 12 }}
labelStyle={{ color: '#9CA3AF' }}
itemStyle={{ color: '#E5E7EB' }}
formatter={(value, name) => {
if (typeof value !== 'number') return '';
const s = series.find((x) => x.key === String(name));
return `${value}g — ${s?.label ?? name}`;
content={({ label: dateLabel, payload }) => {
if (!payload?.length) return null;
return (
<div style={{ background: '#1a1a2e', border: '1px solid #374151', borderRadius: 8, fontSize: 12, padding: '8px 12px' }}>
<div style={{ color: '#9CA3AF', marginBottom: 6 }}>{dateLabel}</div>
{payload.map((p) => {
const s = series.find((x) => x.key === String(p.dataKey));
if (typeof p.value !== 'number') return null;
return (
<div key={String(p.dataKey)} style={{ display: 'flex', alignItems: 'center', gap: 6, color: '#E5E7EB', marginBottom: 2 }}>
<span style={{ color: s?.color ?? '#9CA3AF', fontSize: 10 }}>●</span>
<span>{s?.label ?? String(p.dataKey)}</span>
<span style={{ color: '#9CA3AF', marginLeft: 4 }}>{p.value}g</span>
</div>
);
})}
</div>
);
}}
/>
<Legend
@@ -790,7 +868,7 @@ function ForecastRow({
const snoozed = f.settings?.alerts_snoozed ?? false;
const label = [f.group.brand, f.group.material, f.group.subtype].filter(Boolean).join(' ');
const label = [f.group.brand, f.group.material, f.group.subtype, f.group.colorName].filter(Boolean).join(' ');
// Use getSwatchStyle so a Clear (alpha=00) lead spool renders as a
// checkerboard rather than collapsing to solid black (#1545).
const colorStyle = f.group.spools[0]?.rgba ? getSwatchStyle(f.group.spools[0].rgba) : { backgroundColor: '#4B5563' };
@@ -804,7 +882,7 @@ function ForecastRow({
: 'text-green-400';
function upsert(lead: number, marginVal: number, marginUnitArg: 'days' | 'g', alertsSnoozed = snoozed) {
upsertMutation.mutate({ material: f.group.material, subtype: f.group.subtype, brand: f.group.brand, lead_time_days: lead, safety_margin_value: marginVal, safety_margin_unit: marginUnitArg, alerts_snoozed: alertsSnoozed });
upsertMutation.mutate({ material: f.group.material, subtype: f.group.subtype, brand: f.group.brand, color_name: f.group.colorName, lead_time_days: lead, safety_margin_value: marginVal, safety_margin_unit: marginUnitArg, alerts_snoozed: alertsSnoozed });
}
function toggleSnooze(e: React.MouseEvent) {
@@ -823,38 +901,46 @@ function ForecastRow({
return (
<>
<tr
className={`cursor-pointer hover:bg-bambu-dark-tertiary/40 transition-colors ${rowAlertBorder} ${snoozed ? 'opacity-50' : ''}`}
className={`border-b border-bambu-dark-tertiary/50 cursor-pointer hover:bg-bambu-dark-tertiary/30 transition-colors ${rowAlertBorder} ${snoozed ? 'opacity-50' : ''}`}
onClick={() => setExpanded((e) => !e)}
>
{/* Color dot */}
<td className="px-4 py-3">
<span
className="block w-3 h-3 rounded-full border border-black/20"
className="block w-5 h-5 rounded-full border border-black/20"
style={colorStyle}
/>
</td>
{/* SKU */}
<td className="px-4 py-3">
<div className="text-sm font-medium text-white">{label}</div>
<div className="text-xs text-bambu-gray">{t('forecast.spoolCount', { count: f.totalSpools })}</div>
<span className="text-sm text-white">{label}</span>
</td>
{/* Spools */}
<td className="px-4 py-3">
<span className="text-sm text-bambu-gray">{f.totalSpools}</span>
</td>
{/* Stock */}
<td className="px-4 py-3 min-w-[120px]">
<div className="h-1.5 bg-bambu-dark-tertiary rounded-full overflow-hidden mb-1 w-24">
<div
className={`h-full rounded-full ${remainPct > 50 ? 'bg-bambu-green' : remainPct > 20 ? 'bg-yellow-500' : 'bg-red-500'}`}
style={{ width: `${Math.min(remainPct, 100)}%` }}
/>
<td className="px-4 py-3 min-w-[140px]">
<div className="flex items-center gap-2">
<div className="flex-1 h-2 bg-bambu-dark-tertiary rounded-full overflow-hidden">
<div
className={`h-full rounded-full ${remainPct > 50 ? 'bg-bambu-green' : remainPct > 20 ? 'bg-yellow-500' : 'bg-red-500'}`}
style={{ width: `${Math.min(remainPct, 100)}%` }}
/>
</div>
<span className="text-xs text-bambu-gray min-w-[40px] text-right">{Math.round(f.totalRemainingG)}g</span>
</div>
<span className="text-xs text-bambu-gray">{Math.round(f.totalRemainingG)}g</span>
</td>
{/* Rate */}
<td className="px-4 py-3">
<div className="text-sm text-white">{f.dailyRateG !== null ? `${f.dailyRateG.toFixed(1)}g/d` : '—'}</div>
<div className="mt-0.5">{tierBadge}</div>
<div className="flex items-center gap-1.5 flex-wrap">
<span className="text-sm text-white">{f.dailyRateG !== null ? `${f.dailyRateG.toFixed(1)}g/d` : '—'}</span>
{tierBadge}
</div>
</td>
{/* Days left */}
@@ -873,7 +959,7 @@ function ForecastRow({
{/* Reorder by */}
<td className="px-4 py-3">
<span className={`text-sm font-medium ${!snoozed && f.reorderAlert ? 'text-yellow-400' : 'text-bambu-gray'}`}>
<span className={`text-sm ${!snoozed && f.reorderAlert ? 'text-yellow-400' : 'text-bambu-gray'}`}>
{f.reorderTriggerDate ? formatDate(f.reorderTriggerDate) : '—'}
</span>
</td>
@@ -900,7 +986,7 @@ function ForecastRow({
{canWrite && (
<button
onClick={toggleSnooze}
className={`p-1 rounded transition-colors ${snoozed ? 'text-bambu-gray/70 hover:text-white' : 'text-bambu-dark-tertiary hover:text-bambu-gray'}`}
className={`p-1 rounded transition-colors ${snoozed ? 'text-amber-400/80 hover:text-amber-300' : 'text-slate-400 hover:text-white'}`}
title={t(snoozed ? 'forecast.alertsEnabled' : 'forecast.alertsSnoozed')}
>
<BellOff className="w-3.5 h-3.5" />
@@ -919,71 +1005,64 @@ function ForecastRow({
{/* ── Expanded detail row ── */}
{expanded && (
<tr className="bg-bambu-dark-tertiary/10">
<td colSpan={8} className="px-6 py-4">
<div className="space-y-4">
<td colSpan={9} className="px-6 py-4">
<div className="space-y-3">
{/* Logistics summary */}
<div className="grid grid-cols-3 gap-3">
{/* Single compact row: read-only stats + editable settings */}
<div className={`grid gap-2 ${canWrite ? 'grid-cols-2 sm:grid-cols-4' : 'grid-cols-2'}`}>
<LogisticStat
label={t('forecast.effectiveLeadTime')}
value={`${f.effectiveLeadTimeDays}d`}
hint={t('forecast.effectiveLeadTimeHint', { global: globalLeadTime, sku: f.settings?.lead_time_days ?? 0 })}
/>
<LogisticStat
label={t('forecast.safetyMarginLabel')}
value={`${Math.round(f.safetyStockG)}g`}
hint={t('forecast.safetyMarginHint')}
/>
<LogisticStat
label={t('forecast.reorderPoint')}
value={`${Math.round(f.reorderPointG)}g`}
hint={t('forecast.reorderPointHint')}
/>
{canWrite && (
<>
<SettingField
label={t('forecast.skuLeadTimeOverride')}
hint={t('forecast.skuLeadTimeHint')}
unit={t('forecast.leadTime')}
editing={editingLead}
value={f.settings?.lead_time_days ?? 0}
inputValue={leadInput}
onInputChange={setLeadInput}
onEdit={() => { setLeadInput(String(f.settings?.lead_time_days ?? 0)); setEditingLead(true); }}
onSave={() => {
const v = parseInt(leadInput, 10);
if (!isNaN(v) && v >= 0) { upsert(v, f.settings?.safety_margin_value ?? 14, marginUnit); setEditingLead(false); }
}}
onCancel={() => setEditingLead(false)}
isPending={upsertMutation.isPending}
saveLabel={t('forecast.save')}
cancelLabel={t('forecast.cancel')}
/>
<SafetyMarginField
value={f.settings?.safety_margin_value ?? 14}
unit={marginUnit}
editing={editingMargin}
inputValue={marginInput}
dailyRateG={f.dailyRateG}
onInputChange={setMarginInput}
onUnitChange={(u) => setMarginUnit(u)}
onEdit={() => { setMarginInput(String(f.settings?.safety_margin_value ?? 14)); setMarginUnit(f.settings?.safety_margin_unit ?? 'days'); setEditingMargin(true); }}
onSave={() => {
const v = parseInt(marginInput, 10);
if (!isNaN(v) && v >= 0) { upsert(f.settings?.lead_time_days ?? 0, v, marginUnit); setEditingMargin(false); }
}}
onCancel={() => setEditingMargin(false)}
isPending={upsertMutation.isPending}
saveLabel={t('forecast.save')}
cancelLabel={t('forecast.cancel')}
safetyMarginLabel={t('forecast.safetyMarginLabel')}
/>
</>
)}
</div>
{/* Per-SKU settings — write-gated */}
{canWrite && (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<SettingField
label={t('forecast.skuLeadTimeOverride')}
hint={t('forecast.skuLeadTimeHint')}
unit={t('forecast.leadTime')}
editing={editingLead}
value={f.settings?.lead_time_days ?? 0}
inputValue={leadInput}
onInputChange={setLeadInput}
onEdit={() => { setLeadInput(String(f.settings?.lead_time_days ?? 0)); setEditingLead(true); }}
onSave={() => {
const v = parseInt(leadInput, 10);
if (!isNaN(v) && v >= 0) { upsert(v, f.settings?.safety_margin_value ?? 14, marginUnit); setEditingLead(false); }
}}
onCancel={() => setEditingLead(false)}
isPending={upsertMutation.isPending}
saveLabel={t('forecast.save')}
cancelLabel={t('forecast.cancel')}
/>
<SafetyMarginField
value={f.settings?.safety_margin_value ?? 14}
unit={marginUnit}
editing={editingMargin}
inputValue={marginInput}
dailyRateG={f.dailyRateG}
onInputChange={setMarginInput}
onUnitChange={(u) => setMarginUnit(u)}
onEdit={() => { setMarginInput(String(f.settings?.safety_margin_value ?? 14)); setMarginUnit(f.settings?.safety_margin_unit ?? 'days'); setEditingMargin(true); }}
onSave={() => {
const v = parseInt(marginInput, 10);
if (!isNaN(v) && v >= 0) { upsert(f.settings?.lead_time_days ?? 0, v, marginUnit); setEditingMargin(false); }
}}
onCancel={() => setEditingMargin(false)}
isPending={upsertMutation.isPending}
saveLabel={t('forecast.save')}
cancelLabel={t('forecast.cancel')}
safetyMarginLabel={t('forecast.safetyMarginLabel')}
/>
</div>
)}
{/* Individual spools — shown when group has >1 spool */}
{f.group.spools.length > 1 && (
<div className="border-t border-bambu-dark-tertiary pt-3">
@@ -992,10 +1071,10 @@ function ForecastRow({
<table className="w-full">
<thead>
<tr className="border-b border-bambu-dark-tertiary bg-bambu-dark-tertiary/30">
<th className="px-4 py-2 text-left text-xs font-medium text-bambu-gray uppercase tracking-wide">#</th>
<th className="px-4 py-2 text-left text-xs font-medium text-bambu-gray uppercase tracking-wide">{t('inventory.remaining')}</th>
<th className="px-4 py-2 text-left text-xs font-medium text-bambu-gray uppercase tracking-wide">{t('inventory.used')}</th>
<th className="px-4 py-2 text-left text-xs font-medium text-bambu-gray uppercase tracking-wide">{t('forecast.labelWeight')}</th>
<th className="px-4 py-3 text-left text-xs font-medium text-bambu-gray uppercase tracking-wide">#</th>
<th className="px-4 py-3 text-left text-xs font-medium text-bambu-gray uppercase tracking-wide">{t('inventory.remaining')}</th>
<th className="px-4 py-3 text-left text-xs font-medium text-bambu-gray uppercase tracking-wide">{t('inventory.used')}</th>
<th className="px-4 py-3 text-left text-xs font-medium text-bambu-gray uppercase tracking-wide">{t('forecast.labelWeight')}</th>
</tr>
</thead>
<tbody className="divide-y divide-bambu-dark-tertiary">
@@ -1045,7 +1124,7 @@ function ForecastRow({
function LogisticStat({ label, value, hint }: { label: string; value: string; hint: string }) {
return (
<div className="bg-bambu-dark-tertiary/40 rounded-lg p-3" title={hint}>
<div className="text-xs text-bambu-gray mb-1">{label}</div>
<div className="text-xs font-medium text-white mb-1">{label}</div>
<div className="text-lg font-semibold text-white">{value}</div>
</div>
);
@@ -1143,10 +1222,10 @@ function SafetyMarginField({
<span className="text-lg font-semibold text-white">{value}</span>
<span className="text-xs text-bambu-gray">{unit}</span>
{displayG !== null && unit === 'days' && (
<span className="text-xs text-bambu-gray/60">≈ {displayG}g</span>
<span className="text-lg font-semibold text-white">≈ {displayG}g</span>
)}
{unit === 'g' && dailyRateG !== null && (
<span className="text-xs text-bambu-gray/60">≈ {Math.round(value / dailyRateG)}d</span>
<span className="text-lg font-semibold text-white">≈ {Math.round(value / dailyRateG)}d</span>
)}
<button onClick={onEdit} className="p-1 text-bambu-gray hover:text-white rounded transition-colors"><Edit2 className="w-3 h-3" /></button>
</div>
@@ -1190,7 +1269,7 @@ function ShoppingListPanel({
label_weight: spoolWeight,
core_weight: 0,
core_weight_catalog_id: null,
color_name: null, rgba: null, extra_colors: null, effect_type: null,
color_name: item.color_name, rgba: null, extra_colors: null, effect_type: null,
nozzle_temp_min: null, nozzle_temp_max: null,
note: item.note ?? null,
tag_uid: null, tray_uuid: null,
@@ -1224,7 +1303,7 @@ function ShoppingListPanel({
const cartForecasts = useMemo(() =>
items.map((item) => ({
item,
forecast: forecastMap.get(skuKey(item.material, item.subtype, item.brand)) ?? null,
forecast: forecastMap.get(skuKey(item.material, item.subtype, item.brand, item.color_name)) ?? null,
})),
[items, forecastMap]
);
@@ -1240,9 +1319,9 @@ function ShoppingListPanel({
);
function downloadCsv() {
const headers = [t('forecast.qty'), t('forecast.material'), 'Brand', 'Subtype', `${t('forecast.weight')} (g)`, `${t('forecast.leadTime')} (d)`, t('forecast.expectedRestock'), t('forecast.status'), t('forecast.note')];
const headers = [t('forecast.qty'), t('forecast.material'), t('inventory.brand'), t('inventory.subtype'), t('inventory.color'), `${t('forecast.weight')} (g)`, `${t('forecast.leadTime')} (d)`, t('forecast.expectedRestock'), t('forecast.status'), t('forecast.note')];
const rows = items.map((i) => {
const f = forecastMap.get(skuKey(i.material, i.subtype, i.brand)) ?? null;
const f = forecastMap.get(skuKey(i.material, i.subtype, i.brand, i.color_name)) ?? null;
const avgSpoolG = f && f.totalSpools > 0 ? f.totalLabelG / f.totalSpools : 1000;
const totalWeightG = Math.round(i.quantity_spools * avgSpoolG);
const lt = f?.effectiveLeadTimeDays ?? globalLeadTime ?? 0;
@@ -1252,6 +1331,7 @@ function ShoppingListPanel({
i.material,
i.brand ?? '',
i.subtype ?? '',
i.color_name ?? '',
totalWeightG,
lt || '',
restock,
@@ -1330,21 +1410,21 @@ function ShoppingListPanel({
<table className="w-full">
<thead>
<tr className="border-b border-bambu-dark-tertiary bg-bambu-dark-tertiary/20">
<th className="px-4 py-2 text-left text-xs font-medium text-bambu-gray uppercase tracking-wide">{t('forecast.qty')}</th>
<th className="px-4 py-2 text-left text-xs font-medium text-bambu-gray uppercase tracking-wide">{t('forecast.material')}</th>
<th className="px-4 py-2 text-left text-xs font-medium text-bambu-gray uppercase tracking-wide">{t('forecast.weight')}</th>
<th className="px-4 py-2 text-left text-xs font-medium text-bambu-gray uppercase tracking-wide">{t('forecast.leadTime')}</th>
<th className="px-4 py-2 text-left text-xs font-medium text-bambu-gray uppercase tracking-wide">{t('forecast.expectedRestock')}</th>
<th className="px-4 py-2 text-left text-xs font-medium text-bambu-gray uppercase tracking-wide">{t('forecast.status')}</th>
<th className="px-4 py-2 text-left text-xs font-medium text-bambu-gray uppercase tracking-wide">{t('forecast.note')}</th>
<th className="px-4 py-3 text-left text-xs font-medium text-bambu-gray uppercase tracking-wide">{t('forecast.qty')}</th>
<th className="px-4 py-3 text-left text-xs font-medium text-bambu-gray uppercase tracking-wide">{t('forecast.material')}</th>
<th className="px-4 py-3 text-left text-xs font-medium text-bambu-gray uppercase tracking-wide">{t('forecast.weight')}</th>
<th className="px-4 py-3 text-left text-xs font-medium text-bambu-gray uppercase tracking-wide">{t('forecast.leadTime')}</th>
<th className="px-4 py-3 text-left text-xs font-medium text-bambu-gray uppercase tracking-wide">{t('forecast.expectedRestock')}</th>
<th className="px-4 py-3 text-left text-xs font-medium text-bambu-gray uppercase tracking-wide">{t('forecast.status')}</th>
<th className="px-4 py-3 text-left text-xs font-medium text-bambu-gray uppercase tracking-wide">{t('forecast.note')}</th>
<th className="px-4 py-2 text-right text-xs font-medium text-bambu-gray uppercase tracking-wide">{t('forecast.actions')}</th>
</tr>
</thead>
<tbody className="divide-y divide-bambu-dark-tertiary">
{items.map((item) => {
const lbl = [item.brand, item.material, item.subtype].filter(Boolean).join(' ');
const lbl = [item.brand, item.material, item.subtype, item.color_name].filter(Boolean).join(' ');
const hasBreak = breakAlerts.some((a) => a.item.id === item.id);
const f = forecastMap.get(skuKey(item.material, item.subtype, item.brand)) ?? null;
const f = forecastMap.get(skuKey(item.material, item.subtype, item.brand, item.color_name)) ?? null;
const avgSpoolG = f && f.totalSpools > 0 ? f.totalLabelG / f.totalSpools : 1000;
const totalWeightG = Math.round(item.quantity_spools * avgSpoolG);
const lt = f?.effectiveLeadTimeDays ?? globalLeadTime ?? 0;
@@ -1408,22 +1488,22 @@ function ShoppingListPanel({
onClick={() => statusMutation.mutate({ id: item.id, status: isPurchased ? 'pending' : 'purchased' })}
disabled={isMutating || isReceived}
title={isPurchased ? t('forecast.resetToPending') : t('forecast.markPurchased')}
className={`p-1 rounded transition-colors disabled:opacity-30 ${
className={`p-1.5 rounded transition-colors disabled:opacity-30 ${
isPurchased
? 'text-blue-400 hover:text-bambu-gray'
: 'text-bambu-gray hover:text-blue-400'
? 'text-blue-400 hover:text-blue-300'
: 'text-blue-400/50 hover:text-blue-400'
}`}
>
{isPurchased ? <RotateCcw className="w-3.5 h-3.5" /> : <CreditCard className="w-3.5 h-3.5" />}
{isPurchased ? <RotateCcw className="w-4 h-4" /> : <CreditCard className="w-4 h-4" />}
</button>
{/* Received icon — available only after purchasing */}
<button
onClick={() => statusMutation.mutate({ id: item.id, status: 'received', item, avgSpoolG })}
disabled={isMutating || !isPurchased || isReceived}
title={t('forecast.markReceived')}
className="p-1 rounded transition-colors text-bambu-gray hover:text-bambu-green disabled:opacity-30"
className="p-1.5 rounded transition-colors text-bambu-green/50 hover:text-bambu-green disabled:opacity-30"
>
<PackageCheck className="w-3.5 h-3.5" />
<PackageCheck className="w-4 h-4" />
</button>
{/* Delete */}
<button
@@ -1474,7 +1554,7 @@ function CartLogisticsRow({
onRemove: () => void;
}) {
const { t } = useTranslation();
const label = [item.brand, item.material, item.subtype].filter(Boolean).join(' ');
const label = [item.brand, item.material, item.subtype, item.color_name].filter(Boolean).join(' ');
// Build a timeline showing stock depletion, arrival bump, then post-arrival depletion.
// Two points are inserted at day `lt` (just-before and just-after arrival) so the
@@ -1706,10 +1786,10 @@ function AddToCartModal({
}: {
forecast: SkuForecast;
onClose: () => void;
onAdd: (item: { material: string; subtype: string | null; brand: string | null; quantity_spools: number; note: string | null }) => void;
onAdd: (item: { material: string; subtype: string | null; brand: string | null; color_name: string | null; quantity_spools: number; note: string | null }) => void;
}) {
const { t } = useTranslation();
const label = [f.group.brand, f.group.material, f.group.subtype].filter(Boolean).join(' ');
const label = [f.group.brand, f.group.material, f.group.subtype, f.group.colorName].filter(Boolean).join(' ');
const [mode, setMode] = useState<'qty' | 'duration'>('qty');
const [qty, setQty] = useState('1');
const [durationDays, setDurationDays] = useState('30');
@@ -1728,7 +1808,7 @@ function AddToCartModal({
function submit(e: React.FormEvent) {
e.preventDefault();
onAdd({ material: f.group.material, subtype: f.group.subtype, brand: f.group.brand, quantity_spools: finalQty, note: note || null });
onAdd({ material: f.group.material, subtype: f.group.subtype, brand: f.group.brand, color_name: f.group.colorName, quantity_spools: finalQty, note: note || null });
}
return (
+1 -1
View File
@@ -6463,7 +6463,7 @@ export default {
globalLeadTimeSaved: 'Global lead time saved',
globalLeadTime: 'Global lead time',
globalLeadTimeHint: 'Global lead time floor — used in reorder point calculation for all SKUs',
skuLeadTimeOverride: 'SKU Lead Time Override',
skuLeadTimeOverride: 'Lead Time Override',
skuLeadTimeHint: '0 = use global lead time. Set >0 to override for this SKU.',
safetyMarginLabel: 'Safety Margin',
effectiveLeadTime: 'Effective Lead Time',