Files
bambuddy/frontend/src/components/ForecastPanel.tsx
T
maziggy 632334953c fix(inventory): support transparent / clear filament end-to-end (#1545)
Reporter wanted to select a transparent filament colour in the spool
  editor; CMW-ISS confirmed on v0.2.5b1 that AMS-detected transparent
  spools were silently labelled "Black" in the filament-mapping dropdown
  because the colour name resolver dropped the alpha byte and the underlying
  RGB 000000 HSL-bucketed to "Black". Spoolman already supported 8-digit
  hex; the built-in inventory didn't.

  Eight collapsing sites fixed together so transparent reaches the user
  intact:

  - frontend/src/utils/colors.ts: hexToColorName / getColorName /
    resolveSpoolColorName / isLightColor short-circuit to "Clear" for
    alpha=00 before HSL bucketing or catalog lookup
  - frontend/src/utils/amsHelpers.ts::normalizeColor preserves the alpha
    byte when alpha < FF (normalizeColorForCompare unchanged so type/colour
    matching is unaffected)
  - frontend/src/components/spool-form/constants.ts: new
    { name: 'Clear', hex: '00000000' } preset in QUICK_COLORS
  - frontend/src/components/spool-form/ColorSection.tsx: hex draft accepts
    0-8 chars, commits at 6 (+FF) or 8 verbatim; blur pads 7-char to 8;
    selectColor passes 6-char as +FF / 8-char verbatim; isSelected matches
    on full rgba; swatch buttons paint a checkerboard for alpha=00
  - backend/app/api/routes/printers.py::get_available_filaments preserves
    the full rgba on both AMS and vt_tray branches (6-char dedup key
    unchanged)
  - backend/app/services/spoolman.py::parse_ams_tray drops the silent
    00000000 -> F5E6D3FF cream rewrite — the swatch renderer paints a
    checkerboard underlay for alpha < FF already (added in #1154), so the
    rewrite was hidden technical debt that made every AMS-detected
    transparent spool land in inventory as cream
  - backend/app/services/spool_tag_matcher.py::create_spool_from_tray
    short-circuits the colour-catalog lookup for alpha=00 and stores
    color_name="Clear" directly — otherwise an RFID-tagged transparent
    Bambu spool would resolve against the #000000 catalog row (or "Black"
    via the HSL fallback) before the frontend's resolver ever saw it
  - Two shared helpers in utils/colors.ts — getSwatchStyle(rgba) (style
    object: checkerboard for alpha=00) and spoolColorString(rgba)
    (8-char hex string for SVG fill) — applied to every simple-swatch
    site that would otherwise have rendered Clear spools as solid black:
    LabelTemplatePickerModal, SpoolBuddyInventoryPage (SpoolCircle + dot),
    SpoolBuddyAmsPage (both branches), SpoolBuddyWriteTagPage (4 sites),
    ForecastPanel, AssignToAmsModal, AssignSpoolModal (both branches),
    InventorySpoolInfoCard, TagDetectedModal, SpoolInfoCard, LinkSpoolModal,
    and the FilamentSwatch tooltip title fallback

  Intentionally NOT changed: native <input type="color"> keeps 6-char hex
  (can't pick alpha; onChange still emits +FF, correct); Spoolman's
  _find_or_create_filament strips alpha (Spoolman catalog is 6-char only);
  print_scheduler colour matching strips alpha (auto-mapping treats Clear
  as Black for slot compatibility); label_renderer prints "#RRGGBB" on the
  physical label (printers can't print transparency, swatch fill via
  _color_from_hex still honours alpha).
2026-05-29 10:15:53 +02:00

1830 lines
84 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useMemo, useEffect } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import {
AlertTriangle, TrendingDown, ShoppingCart, Check, BellOff,
ChevronDown, ChevronUp, Info, Edit2, X, Lock,
ArrowUp, ArrowDown, ArrowUpDown, Package, Trash2, BarChart2,
CreditCard, PackageCheck, Download, RotateCcw,
} from 'lucide-react';
import {
AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip,
ResponsiveContainer, ReferenceLine, Legend,
} from 'recharts';
import { api } from '../api/client';
import type { InventorySpool, SpoolUsageRecord, FilamentSkuSettings, ShoppingListItem } from '../api/client';
import { getSwatchStyle } from '../utils/colors';
import { useToast } from '../contexts/ToastContext';
import { useAuth } from '../contexts/AuthContext';
// ── Types ─────────────────────────────────────────────────────────────────────
interface SkuGroup {
key: string;
material: string;
subtype: string | null;
brand: string | null;
spools: InventorySpool[];
}
interface SkuForecast {
group: SkuGroup;
settings: FilamentSkuSettings | null;
totalRemainingG: number;
totalLabelG: number;
totalSpools: number;
totalUsedG: number;
dailyRateG: number | null;
dailyRateStdDev: number | null;
rateTier: 'history' | 'delta' | 'none';
effectiveLeadTimeDays: number;
safetyStockG: number;
reorderPointG: number;
daysRemaining: number | null;
daysUntilROP: number | null;
projectedEmptyDate: Date | null;
reorderTriggerDate: Date | null;
reorderAlert: boolean;
stockBreakAlert: boolean;
}
type SortKey = 'material' | 'used' | 'days_left' | 'stock';
type SortDir = 'asc' | 'desc';
type ChartDays = 7 | 30 | 180;
// ── Constants ─────────────────────────────────────────────────────────────────
const Z_95 = 1.65;
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 addDays(date: Date, days: number): Date {
const d = new Date(date);
d.setDate(d.getDate() + Math.round(days));
return d;
}
function formatDate(date: Date): string {
return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
}
function formatDateShort(date: Date): string {
return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
}
/**
* 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).
* 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.
*/
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(),
);
const now = Date.now();
// λ for 30-day half-life: ln(2)/30
const lambda = Math.LN2 / 30;
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;
// g/day for this interval
const intervalRate = sorted[i].weight_used / elapsedDays;
// Exponential age-decay weight
const w = Math.exp(-lambda * ageDays);
observations.push({ rate: intervalRate, weight: w });
}
const totalW = observations.reduce((s, o) => s + o.weight, 0);
if (totalW === 0) return null;
const mean = observations.reduce((s, o) => s + o.rate * o.weight, 0) / totalW;
const variance = observations.reduce((s, o) => s + o.weight * (o.rate - mean) ** 2, 0) / totalW;
return { rate: mean, stdDev: Math.sqrt(variance) };
}
function computeDeltaRate(spools: InventorySpool[]): number | null {
// Use weight_used - baseline so "Reset usage to 0" on the Inventory page
// makes forecast restart from zero rather than carrying stale lifetime
// consumption across the reset (#1390).
const totalUsed = spools.reduce((s, sp) => s + Math.max(0, sp.weight_used - (sp.weight_used_baseline ?? 0)), 0);
if (totalUsed === 0) return null;
const now = Date.now();
const oldestMs = spools.reduce((min, sp) => {
const t = new Date(sp.created_at).getTime();
return t < min ? t : min;
}, now);
const daysSinceOldest = (now - oldestMs) / 86400000;
if (daysSinceOldest < 1) return null;
return totalUsed / daysSinceOldest;
}
function buildProjectionSeries(
forecast: SkuForecast,
days = 60,
): { day: number; label: string; stock: number; rop: number }[] {
if (forecast.dailyRateG === null) return [];
const rate = forecast.dailyRateG;
const result = [];
for (let d = 0; d <= days; d++) {
const stock = Math.max(0, forecast.totalRemainingG - rate * d);
result.push({
day: d,
label: formatDateShort(addDays(new Date(), d)),
stock: Math.round(stock),
rop: Math.round(forecast.reorderPointG),
});
if (stock === 0) break;
}
return result;
}
// ── Main component ────────────────────────────────────────────────────────────
export function ForecastPanel({ spools }: { spools: InventorySpool[] }) {
const queryClient = useQueryClient();
const { showToast } = useToast();
const { t } = useTranslation();
const { hasPermission, hasAnyPermission } = useAuth();
const canRead = hasPermission('inventory:forecast_read');
const canWrite = hasAnyPermission('inventory:forecast_write', 'inventory:update');
// All hooks must run unconditionally — guard render is deferred until after hooks
const [alertsOpen, setAlertsOpen] = useState(false);
const [sortKey, setSortKey] = useState<SortKey>('material');
const [sortDir, setSortDir] = useState<SortDir>('asc');
const [cartModal, setCartModal] = useState<SkuForecast | null>(null);
const [listOpen, setListOpen] = useState(false);
const [chartDays, setChartDays] = useState<ChartDays>(30);
const { data: settings } = useQuery({ queryKey: ['settings'], queryFn: api.getSettings, enabled: canRead });
const { data: skuSettingsList = [] } = useQuery({ queryKey: ['sku-settings'], queryFn: api.getSkuSettings, staleTime: 60_000, enabled: canRead });
const { data: usageHistory = [] } = useQuery({ queryKey: ['all-usage-history-forecast'], queryFn: () => api.getAllUsageHistory(5000), staleTime: 60_000, enabled: canRead });
const { data: shoppingList = [] } = useQuery({ queryKey: ['shopping-list'], queryFn: api.getShoppingList, staleTime: 30_000, enabled: canRead });
const globalLeadTime = settings?.forecast_global_lead_time_days ?? 0;
const settingsMap = useMemo(() => {
const m = new Map<string, FilamentSkuSettings>();
for (const s of skuSettingsList) m.set(skuKey(s.material, s.subtype, s.brand), s);
return m;
}, [skuSettingsList]);
const usageBySpoolId = useMemo(() => {
const m = new Map<number, SpoolUsageRecord[]>();
for (const r of usageHistory) {
const arr = m.get(r.spool_id) ?? [];
arr.push(r);
m.set(r.spool_id, arr);
}
return m;
}, [usageHistory]);
const groups = useMemo((): SkuGroup[] => {
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: [] };
g.spools.push(spool);
map.set(key, g);
}
return [...map.values()];
}, [spools]);
const forecasts = useMemo((): SkuForecast[] => {
const today = new Date(); today.setHours(0, 0, 0, 0);
return groups.map((group): SkuForecast => {
const skuSettings = settingsMap.get(group.key) ?? null;
const skuLeadTime = skuSettings?.lead_time_days ?? 0;
const effectiveLeadTimeDays = Math.max(globalLeadTime, skuLeadTime);
const marginValue = skuSettings?.safety_margin_value ?? 14;
const marginUnit = skuSettings?.safety_margin_unit ?? 'days';
const totalRemainingG = group.spools.reduce((s, sp) => s + Math.max(0, sp.label_weight - sp.weight_used), 0);
const totalLabelG = group.spools.reduce((s, sp) => s + sp.label_weight, 0);
// 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);
const groupHistory: SpoolUsageRecord[] = [];
for (const s of group.spools) groupHistory.push(...(usageBySpoolId.get(s.id) ?? []));
let dailyRateG: number | null = null;
let dailyRateStdDev: number | null = null;
let rateTier: SkuForecast['rateTier'] = 'none';
const histResult = computeHistoryRate(groupHistory);
if (histResult !== null) {
dailyRateG = histResult.rate;
dailyRateStdDev = histResult.stdDev;
rateTier = 'history';
} else {
const delta = computeDeltaRate(group.spools);
if (delta !== null) { dailyRateG = delta; rateTier = 'delta'; }
}
const σ = dailyRateStdDev ?? (dailyRateG !== null ? dailyRateG * 0.2 : 0);
const statisticalSafetyStockG = Z_95 * σ * Math.sqrt(effectiveLeadTimeDays);
// safety margin: user-defined buffer on top of statistical safety stock
const safetyMarginG = marginUnit === 'g'
? marginValue
: (dailyRateG !== null ? dailyRateG * marginValue : marginValue * 5);
const safetyStockG = statisticalSafetyStockG + safetyMarginG;
const reorderPointG = dailyRateG !== null
? dailyRateG * effectiveLeadTimeDays + safetyStockG
: 0;
const daysRemaining = dailyRateG && dailyRateG > 0 ? Math.floor(totalRemainingG / dailyRateG) : null;
const projectedEmptyDate = daysRemaining !== null ? addDays(today, daysRemaining) : null;
const daysUntilROP = dailyRateG && dailyRateG > 0
? Math.floor((totalRemainingG - reorderPointG) / dailyRateG)
: null;
const reorderTriggerDate = daysUntilROP !== null ? addDays(today, Math.max(0, daysUntilROP)) : null;
const stockBreakAlert = daysRemaining !== null && effectiveLeadTimeDays > 0 && daysRemaining <= effectiveLeadTimeDays;
const reorderAlert = !stockBreakAlert && daysUntilROP !== null && daysUntilROP <= 0;
return {
group, settings: skuSettings,
totalRemainingG, totalLabelG, totalSpools: group.spools.length, totalUsedG,
dailyRateG, dailyRateStdDev,
rateTier,
effectiveLeadTimeDays, safetyStockG, reorderPointG,
daysRemaining, daysUntilROP,
projectedEmptyDate, reorderTriggerDate,
reorderAlert, stockBreakAlert,
};
});
}, [groups, settingsMap, usageBySpoolId, globalLeadTime]);
const sortedForecasts = useMemo(() => {
const arr = [...forecasts];
arr.sort((a, b) => {
let va: number | string = 0;
let vb: number | string = 0;
switch (sortKey) {
case 'material':
va = [a.group.material, a.group.subtype ?? '', a.group.brand ?? ''].join(' ').toLowerCase();
vb = [b.group.material, b.group.subtype ?? '', b.group.brand ?? ''].join(' ').toLowerCase();
break;
case 'used':
va = a.totalUsedG; vb = b.totalUsedG;
break;
case 'days_left':
va = a.daysRemaining ?? 999999; vb = b.daysRemaining ?? 999999;
break;
case 'stock':
va = a.totalRemainingG; vb = b.totalRemainingG;
break;
}
const cmp = va < vb ? -1 : va > vb ? 1 : 0;
return sortDir === 'asc' ? cmp : -cmp;
});
return arr;
}, [forecasts, sortKey, sortDir]);
const alerts = useMemo(() => forecasts.filter((f) => !f.settings?.alerts_snoozed && (f.stockBreakAlert || f.reorderAlert)), [forecasts]);
const top5 = useMemo(() =>
[...forecasts]
.filter((f) => f.dailyRateG !== null)
.sort((a, b) => b.totalUsedG - a.totalUsedG)
.slice(0, 5),
[forecasts]
);
// ── Read permission guard — all hooks above this point ──────────────────────
if (!canRead) {
return (
<div className="flex flex-col items-center justify-center py-16 text-bambu-gray gap-3">
<Lock className="w-8 h-8 opacity-40" />
<p className="text-sm">{t('forecast.noReadAccess')}</p>
</div>
);
}
function handleSort(key: SortKey) {
if (sortKey === key) setSortDir((d) => d === 'asc' ? 'desc' : 'asc');
else { setSortKey(key); setSortDir(key === 'days_left' ? 'asc' : 'desc'); }
}
const shoppingListBadge = shoppingList.length > 0 ? shoppingList.length : null;
return (
<div className="space-y-5">
{/* ── Toolbar ── */}
<div className="flex flex-wrap items-center gap-3">
{/* Alert button */}
{alerts.length > 0 && (
<button
onClick={() => setAlertsOpen((o) => !o)}
className={`flex items-center gap-2 px-3 py-1.5 rounded-lg border text-sm font-medium transition-colors ${
alerts.some((f) => f.stockBreakAlert)
? 'bg-red-500/15 border-red-500/30 text-red-300 hover:bg-red-500/25'
: 'bg-yellow-500/15 border-yellow-500/30 text-yellow-300 hover:bg-yellow-500/25'
}`}
>
<AlertTriangle className="w-4 h-4" />
{t('forecast.alertCount', { count: alerts.length })}
{alertsOpen ? <ChevronUp className="w-3.5 h-3.5" /> : <ChevronDown className="w-3.5 h-3.5" />}
</button>
)}
{/* Global lead time */}
{canWrite && (
<GlobalLeadTimeSetting
value={globalLeadTime}
onSave={(v) => {
api.updateSettings({ forecast_global_lead_time_days: v }).then(() => {
queryClient.invalidateQueries({ queryKey: ['settings'] });
showToast(t('forecast.globalLeadTimeSaved'), 'success');
});
}}
/>
)}
{/* Shopping list toggle */}
<button
onClick={() => setListOpen((o) => !o)}
className="relative flex items-center gap-2 px-3 py-1.5 rounded-lg border border-bambu-dark-tertiary text-bambu-gray hover:bg-bambu-dark-tertiary text-sm transition-colors ml-auto"
>
<ShoppingCart className="w-4 h-4" />
<span className="hidden sm:inline">{t('forecast.shoppingList')}</span>
{shoppingListBadge && (
<span className="absolute -top-1.5 -right-1.5 w-4 h-4 rounded-full bg-bambu-green text-white text-[10px] font-bold flex items-center justify-center">
{shoppingListBadge}
</span>
)}
</button>
</div>
{/* ── Collapsed alerts panel ── */}
{alertsOpen && alerts.length > 0 && (
<div className="space-y-2">
{alerts.map((f) => (
<AlertBanner key={f.group.key} forecast={f} onCart={() => setCartModal(f)} />
))}
</div>
)}
{/* ── Shopping list panel ── */}
{listOpen && (
<ShoppingListPanel
items={shoppingList}
forecasts={forecasts}
globalLeadTime={globalLeadTime}
canWrite={canWrite}
onClose={() => setListOpen(false)}
onRemove={(id) => {
api.removeFromShoppingList(id)
.then(() => queryClient.invalidateQueries({ queryKey: ['shopping-list'] }))
.catch(() => showToast(t('forecast.failedSaveSettings'), 'error'));
}}
onClear={() => {
api.clearShoppingList()
.then(() => queryClient.invalidateQueries({ queryKey: ['shopping-list'] }))
.catch(() => showToast(t('forecast.failedSaveSettings'), 'error'));
}}
/>
)}
{/* ── Usage + projection chart ── */}
{top5.length > 0 && <UsageChart forecasts={top5} days={chartDays} onDaysChange={setChartDays} />}
{/* ── Table ── */}
{forecasts.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-bambu-gray">
<TrendingDown className="w-10 h-10 mb-3 opacity-40" />
<p className="text-sm">{t('forecast.noSpools')}</p>
</div>
) : (
<div className="bg-bambu-dark-secondary rounded-lg overflow-hidden border border-bambu-dark-tertiary">
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-bambu-dark-tertiary bg-bambu-dark-tertiary/30">
{/* Color dot */}
<th className="w-8 px-4 py-3" />
<SortableTh col="material" active={sortKey} dir={sortDir} onSort={handleSort}>
{t('forecast.sku')}
</SortableTh>
<SortableTh col="stock" active={sortKey} dir={sortDir} onSort={handleSort}>
{t('forecast.stock')}
</SortableTh>
<SortableTh col="used" active={sortKey} dir={sortDir} onSort={handleSort}>
{t('forecast.dailyRate')}
</SortableTh>
<SortableTh col="days_left" active={sortKey} dir={sortDir} onSort={handleSort}>
{t('forecast.daysLeft')}
</SortableTh>
<th className="px-4 py-3 text-left text-xs font-medium text-bambu-gray uppercase tracking-wide">
{t('forecast.emptyBy')}
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-bambu-gray uppercase tracking-wide">
{t('forecast.reorderBy')}
</th>
{/* Actions */}
<th className="w-24 px-4 py-3" />
</tr>
</thead>
<tbody className="divide-y divide-bambu-dark-tertiary">
{sortedForecasts.map((f) => (
<ForecastRow
key={f.group.key}
forecast={f}
globalLeadTime={globalLeadTime}
canWrite={canWrite}
onSaved={() => queryClient.invalidateQueries({ queryKey: ['sku-settings'] })}
onCart={() => setCartModal(f)}
showToast={showToast}
/>
))}
</tbody>
</table>
</div>
{/* Legend */}
<div className="flex flex-wrap items-center gap-4 px-4 py-3 text-xs text-bambu-gray border-t border-bambu-dark-tertiary bg-bambu-dark-tertiary/20">
<span className="flex items-center gap-1.5">
<span className="w-2 h-2 rounded-full bg-bambu-green inline-block" />
{t('forecast.trendLegend')}
</span>
<span className="flex items-center gap-1.5">
<span className="w-2 h-2 rounded-full bg-blue-400 inline-block" />
{t('forecast.estimatedLegend')}
</span>
<span className="flex items-center gap-1.5">
<span className="w-2 h-2 rounded-full bg-bambu-gray/40 inline-block" />
{t('forecast.noDataLegend')}
</span>
</div>
</div>
)}
{/* ── Add to cart modal ── */}
{cartModal && (
<AddToCartModal
forecast={cartModal}
onClose={() => setCartModal(null)}
onAdd={(item) => {
api.addToShoppingList(item).then(() => {
queryClient.invalidateQueries({ queryKey: ['shopping-list'] });
showToast(t('forecast.addedToCart'), 'success');
setCartModal(null);
setListOpen(true);
}).catch(() => showToast(t('forecast.failedAddItem'), 'error'));
}}
/>
)}
</div>
);
}
// ── Sortable th ───────────────────────────────────────────────────────────────
function SortableTh({
col, active, dir, onSort, children,
}: {
col: SortKey;
active: SortKey;
dir: SortDir;
onSort: (k: SortKey) => void;
children: React.ReactNode;
}) {
const isActive = active === col;
return (
<th
className="px-4 py-3 text-left text-xs font-medium text-bambu-gray uppercase tracking-wide cursor-pointer select-none hover:text-white transition-colors"
onClick={() => onSort(col)}
>
<span className="inline-flex items-center">
{children}
{isActive
? dir === 'asc'
? <ArrowUp className="w-3 h-3 ml-1 text-bambu-green" />
: <ArrowDown className="w-3 h-3 ml-1 text-bambu-green" />
: <ArrowUpDown className="w-3 h-3 ml-1 opacity-40" />
}
</span>
</th>
);
}
// ── Alert Banner ──────────────────────────────────────────────────────────────
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 isBreak = f.stockBreakAlert;
return (
<div className={`flex items-center gap-3 px-4 py-3 rounded-lg border text-sm ${
isBreak ? 'bg-red-500/10 border-red-500/30 text-red-300' : 'bg-yellow-500/10 border-yellow-500/30 text-yellow-300'
}`}>
<AlertTriangle className="w-4 h-4 flex-shrink-0" />
<div className="flex-1 min-w-0">
<span className="font-medium">{label}</span>
{isBreak ? (
<span className="ml-2 text-xs opacity-80">
{t('forecast.stockBreakRisk')} — {t('forecast.stockBreakDetail', { days: f.daysRemaining, lt: f.effectiveLeadTimeDays })}
</span>
) : (
<span className="ml-2 text-xs opacity-80">
{t('forecast.reorderNow')} — {t('forecast.reorderTriggerPassed', { date: f.reorderTriggerDate ? formatDate(f.reorderTriggerDate) : '—' })}
</span>
)}
</div>
<button
onClick={onCart}
className="flex items-center gap-1.5 px-2.5 py-1 rounded border border-current text-xs opacity-70 hover:opacity-100 transition-opacity"
>
<ShoppingCart className="w-3 h-3" /> {t('forecast.order')}
</button>
</div>
);
}
// ── Usage + Projection Chart ──────────────────────────────────────────────────
const CHART_TIMEFRAMES: { label: string; value: ChartDays }[] = [
{ label: '1W', value: 7 },
{ label: '1M', value: 30 },
{ label: '6M', value: 180 },
];
function UsageChart({ forecasts, days: maxDays, onDaysChange }: {
forecasts: SkuForecast[];
days: ChartDays;
onDaysChange: (d: ChartDays) => void;
}) {
const { t } = useTranslation();
const days = Array.from({ length: maxDays + 1 }, (_, i) => i);
const series = forecasts.map((f, idx) => ({
key: f.group.key,
label: [f.group.brand, f.group.material, f.group.subtype].filter(Boolean).join(' '),
color: CHART_COLORS[idx % CHART_COLORS.length],
rop: f.reorderPointG,
points: buildProjectionSeries(f, maxDays),
}));
const chartData = days.map((d) => {
const row: Record<string, number | string> = { day: d, label: formatDateShort(addDays(new Date(), d)) };
for (const s of series) {
const pt = s.points.find((p) => p.day === d);
row[s.key] = pt?.stock ?? 0;
}
return row;
});
const lastNonZeroDay = (() => {
for (let d = maxDays; d >= 0; d--) {
if (series.some((s) => (chartData[d]?.[s.key] as number) > 0)) return d;
}
return maxDays;
})();
const trimmedData = chartData.slice(0, lastNonZeroDay + 1);
const ropLines = series.filter((s) => s.rop > 0);
return (
<div className="bg-bambu-dark-secondary rounded-lg overflow-hidden border border-bambu-dark-tertiary p-4">
<div className="flex items-center gap-2 mb-4">
<TrendingDown className="w-4 h-4 text-bambu-green" />
<h3 className="text-sm font-semibold text-white">{t('forecast.chartTitle')}</h3>
<span className="text-xs text-bambu-gray ml-1 hidden sm:inline">{t('forecast.dashedLinesROP')}</span>
<div className="ml-auto flex items-center bg-bambu-dark-tertiary rounded-lg p-0.5">
{CHART_TIMEFRAMES.map((tf) => (
<button
key={tf.value}
onClick={() => onDaysChange(tf.value)}
className={`px-2.5 py-1 text-xs font-medium rounded-md transition-colors ${
maxDays === tf.value
? 'bg-bambu-dark-secondary text-white shadow'
: 'text-bambu-gray hover:text-white'
}`}
>
{tf.label}
</button>
))}
</div>
</div>
<ResponsiveContainer width="100%" height={220}>
<AreaChart data={trimmedData} margin={{ top: 4, right: 8, bottom: 0, left: 0 }}>
<defs>
{series.map((s) => (
<linearGradient key={s.key} id={`grad-${s.key}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor={s.color} stopOpacity={0.25} />
<stop offset="95%" stopColor={s.color} stopOpacity={0.02} />
</linearGradient>
))}
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="#374151" strokeOpacity={0.5} />
<XAxis
dataKey="label"
tick={{ fill: '#6B7280', fontSize: 10 }}
interval={Math.max(0, Math.ceil(lastNonZeroDay / 8) - 1)}
axisLine={false}
tickLine={false}
/>
<YAxis
tick={{ fill: '#6B7280', fontSize: 10 }}
axisLine={false}
tickLine={false}
tickFormatter={(v: number) => v >= 1000 ? `${(v / 1000).toFixed(1)}kg` : `${v}g`}
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}`;
}}
/>
<Legend
formatter={(value) => {
const s = series.find((x) => x.key === value);
return <span style={{ color: '#9CA3AF', fontSize: 11 }}>{s?.label ?? value}</span>;
}}
/>
{series.map((s) => (
<Area
key={s.key}
type="monotone"
dataKey={s.key}
stroke={s.color}
strokeWidth={2}
fill={`url(#grad-${s.key})`}
dot={false}
activeDot={{ r: 3 }}
/>
))}
{ropLines.map((s) => (
<ReferenceLine
key={`rop-${s.key}`}
y={s.rop}
stroke={s.color}
strokeDasharray="4 3"
strokeOpacity={0.6}
/>
))}
</AreaChart>
</ResponsiveContainer>
</div>
);
}
// ── Global lead time setting (compact inline) ─────────────────────────────────
function GlobalLeadTimeSetting({ value, onSave }: { value: number; onSave: (v: number) => void }) {
const { t } = useTranslation();
const [editing, setEditing] = useState(false);
const [input, setInput] = useState(String(value));
function save() {
const v = parseInt(input, 10);
if (isNaN(v) || v < 0) return;
onSave(v);
setEditing(false);
}
return (
<div className="flex items-center gap-2 px-3 py-1.5 bg-bambu-dark-tertiary/40 rounded-lg border border-bambu-dark-tertiary text-xs text-bambu-gray">
<Info className="w-3.5 h-3.5 flex-shrink-0" aria-label={t('forecast.globalLeadTimeHint')} />
<span className="hidden sm:inline">{t('forecast.globalLeadTime')}:</span>
{editing ? (
<form className="flex items-center gap-1.5" onSubmit={(e) => { e.preventDefault(); save(); }}>
<input
type="number" min={0} max={365}
value={input}
onChange={(e) => setInput(e.target.value)}
className="w-14 px-1.5 py-0.5 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-sm text-white focus:outline-none focus:border-bambu-green"
autoFocus
/>
<span className="text-bambu-gray">d</span>
<button type="submit" className="px-2 py-0.5 bg-bambu-green text-white text-xs rounded hover:bg-bambu-green/80">{t('forecast.save')}</button>
<button type="button" onClick={() => setEditing(false)} className="text-xs text-bambu-gray hover:text-white">✕</button>
</form>
) : (
<div className="flex items-center gap-1.5">
<span className="font-semibold text-white">{value}d</span>
<button onClick={() => { setInput(String(value)); setEditing(true); }} className="p-0.5 text-bambu-gray hover:text-white rounded transition-colors">
<Edit2 className="w-3 h-3" />
</button>
</div>
)}
</div>
);
}
// ── Forecast Row ──────────────────────────────────────────────────────────────
function ForecastRow({
forecast: f, globalLeadTime, canWrite, onSaved, onCart, showToast,
}: {
forecast: SkuForecast;
globalLeadTime: number;
canWrite: boolean;
onSaved: () => void;
onCart: () => void;
showToast: (msg: string, type: 'success' | 'error') => void;
}) {
const { t } = useTranslation();
const [expanded, setExpanded] = useState(false);
const [editingLead, setEditingLead] = useState(false);
const [editingMargin, setEditingMargin] = useState(false);
const [leadInput, setLeadInput] = useState(String(f.settings?.lead_time_days ?? 0));
const [marginInput, setMarginInput] = useState(String(f.settings?.safety_margin_value ?? 14));
const [marginUnit, setMarginUnit] = useState<'days' | 'g'>(f.settings?.safety_margin_unit ?? 'days');
// Sync inputs when remote settings change and the field is not actively being edited.
useEffect(() => {
if (!editingLead) setLeadInput(String(f.settings?.lead_time_days ?? 0));
}, [f.settings?.lead_time_days, editingLead]);
useEffect(() => {
if (!editingMargin) {
setMarginInput(String(f.settings?.safety_margin_value ?? 14));
setMarginUnit(f.settings?.safety_margin_unit ?? 'days');
}
}, [f.settings?.safety_margin_value, f.settings?.safety_margin_unit, editingMargin]);
const upsertMutation = useMutation({
mutationFn: api.upsertSkuSettings,
onSuccess: () => { onSaved(); showToast(t('forecast.settingsSaved'), 'success'); },
onError: () => showToast(t('forecast.failedSaveSettings'), 'error'),
});
const snoozed = f.settings?.alerts_snoozed ?? false;
const label = [f.group.brand, f.group.material, f.group.subtype].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' };
const remainPct = f.totalLabelG > 0 ? Math.round((f.totalRemainingG / f.totalLabelG) * 100) : 0;
const daysColor = snoozed ? 'text-bambu-gray'
: f.daysRemaining === null ? 'text-bambu-gray'
: f.stockBreakAlert ? 'text-red-400'
: f.reorderAlert ? 'text-yellow-400'
: f.daysRemaining < 30 ? 'text-yellow-400'
: '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 });
}
function toggleSnooze(e: React.MouseEvent) {
e.stopPropagation();
upsert(f.settings?.lead_time_days ?? 0, f.settings?.safety_margin_value ?? 14, f.settings?.safety_margin_unit ?? 'days', !snoozed);
}
const tierBadge = f.rateTier === 'history'
? <span className="inline-flex items-center gap-1 text-xs px-1.5 py-0.5 rounded bg-bambu-green/15 text-bambu-green"><span className="w-1.5 h-1.5 rounded-full bg-bambu-green" />{t('forecast.trend')}</span>
: f.rateTier === 'delta'
? <span className="inline-flex items-center gap-1 text-xs px-1.5 py-0.5 rounded bg-blue-400/15 text-blue-400"><span className="w-1.5 h-1.5 rounded-full bg-blue-400" />{t('forecast.estimated')}</span>
: <span className="inline-flex items-center gap-1 text-xs px-1.5 py-0.5 rounded bg-bambu-dark-tertiary text-bambu-gray/60"><span className="w-1.5 h-1.5 rounded-full bg-bambu-gray/40" />{t('forecast.noData')}</span>;
const rowAlertBorder = snoozed ? '' : f.stockBreakAlert ? 'bg-red-500/5' : f.reorderAlert ? 'bg-yellow-500/5' : '';
return (
<>
<tr
className={`cursor-pointer hover:bg-bambu-dark-tertiary/40 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"
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>
</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)}%` }}
/>
</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>
</td>
{/* Days left */}
<td className="px-4 py-3">
<span className={`text-sm font-semibold ${daysColor}`}>
{f.daysRemaining !== null ? `${f.daysRemaining}d` : <span className="text-bambu-gray font-normal">—</span>}
</span>
</td>
{/* Empty by */}
<td className="px-4 py-3">
<span className="text-sm text-bambu-gray">
{f.projectedEmptyDate ? formatDate(f.projectedEmptyDate) : '—'}
</span>
</td>
{/* Reorder by */}
<td className="px-4 py-3">
<span className={`text-sm font-medium ${!snoozed && f.reorderAlert ? 'text-yellow-400' : 'text-bambu-gray'}`}>
{f.reorderTriggerDate ? formatDate(f.reorderTriggerDate) : '—'}
</span>
</td>
{/* Actions */}
<td className="px-4 py-3" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-end gap-1">
{canWrite && (
<button
onClick={onCart}
className="p-1.5 text-bambu-gray hover:text-bambu-green rounded transition-colors"
title={t('forecast.addToCart')}
>
<ShoppingCart className="w-4 h-4" />
</button>
)}
{!snoozed && (f.stockBreakAlert ? (
<AlertTriangle className="w-4 h-4 text-red-400" aria-label={t('forecast.stockBreakRisk')} />
) : f.reorderAlert ? (
<AlertTriangle className="w-4 h-4 text-yellow-400" aria-label={t('forecast.reorderNow')} />
) : f.daysRemaining !== null ? (
<Check className="w-4 h-4 text-bambu-green/50" />
) : null)}
{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'}`}
title={t(snoozed ? 'forecast.alertsEnabled' : 'forecast.alertsSnoozed')}
>
<BellOff className="w-3.5 h-3.5" />
</button>
)}
<button
onClick={(e) => { e.stopPropagation(); setExpanded((v) => !v); }}
className="p-1.5 text-bambu-gray hover:text-white rounded transition-colors"
>
{expanded ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
</button>
</div>
</td>
</tr>
{/* ── Expanded detail row ── */}
{expanded && (
<tr className="bg-bambu-dark-tertiary/10">
<td colSpan={8} className="px-6 py-4">
<div className="space-y-4">
{/* Logistics summary */}
<div className="grid grid-cols-3 gap-3">
<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')}
/>
</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">
<p className="text-xs text-bambu-gray mb-2">{t('forecast.individualSpools')}</p>
<div className="bg-bambu-dark-secondary rounded-lg overflow-hidden border border-bambu-dark-tertiary">
<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>
</tr>
</thead>
<tbody className="divide-y divide-bambu-dark-tertiary">
{f.group.spools.map((s) => {
const remaining = Math.max(0, s.label_weight - s.weight_used);
const pct = s.label_weight > 0 ? (remaining / s.label_weight) * 100 : 0;
return (
<tr key={s.id} className="hover:bg-bambu-dark-tertiary/30 transition-colors">
<td className="px-4 py-2">
<span className="text-xs font-mono text-bambu-gray/70">#{s.id}</span>
</td>
<td className="px-4 py-2">
<div className="flex items-center gap-3">
<div className="w-24 h-1.5 bg-bambu-dark-tertiary rounded-full overflow-hidden flex-shrink-0">
<div
className={`h-full rounded-full ${pct > 50 ? 'bg-bambu-green' : pct > 20 ? 'bg-yellow-500' : 'bg-red-500'}`}
style={{ width: `${Math.min(pct, 100)}%` }}
/>
</div>
<span className="text-sm text-white">{Math.round(remaining)}g</span>
</div>
</td>
<td className="px-4 py-2">
<span className="text-sm text-bambu-gray">{Math.round(Math.max(0, s.weight_used - (s.weight_used_baseline ?? 0)))}g</span>
</td>
<td className="px-4 py-2">
<span className="text-sm text-bambu-gray">{s.label_weight}g</span>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
)}
</div>
</td>
</tr>
)}
</>
);
}
// ── Logistic stat chip ────────────────────────────────────────────────────────
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-lg font-semibold text-white">{value}</div>
</div>
);
}
// ── Setting field ─────────────────────────────────────────────────────────────
function SettingField({
label, hint, unit, editing, value, inputValue,
onInputChange, onEdit, onSave, onCancel, isPending,
saveLabel = 'Save', cancelLabel = 'Cancel',
}: {
label: string; hint: string; unit: string; editing: boolean;
value: number; inputValue: string;
onInputChange: (v: string) => void; onEdit: () => void;
onSave: () => void; onCancel: () => void; isPending: boolean;
saveLabel?: string; cancelLabel?: string;
}) {
return (
<div className="bg-bambu-dark-tertiary/40 rounded-lg p-3 space-y-1">
<div className="flex items-center gap-1.5">
<span className="text-xs font-medium text-white">{label}</span>
<span title={hint}><Info className="w-3 h-3 text-bambu-gray/50" /></span>
</div>
{editing ? (
<form className="flex items-center gap-2" onSubmit={(e) => { e.preventDefault(); onSave(); }}>
<input
type="number" min={0} max={365}
value={inputValue} onChange={(e) => onInputChange(e.target.value)}
className="w-20 px-2 py-1 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-sm text-white focus:outline-none focus:border-bambu-green"
autoFocus disabled={isPending}
/>
<span className="text-xs text-bambu-gray">{unit}</span>
<button type="submit" disabled={isPending} className="px-2 py-1 bg-bambu-green text-white text-xs rounded hover:bg-bambu-green/80 disabled:opacity-50">{saveLabel}</button>
<button type="button" onClick={onCancel} disabled={isPending} className="px-2 py-1 text-xs text-bambu-gray hover:text-white">{cancelLabel}</button>
</form>
) : (
<div className="flex items-center gap-2">
<span className="text-lg font-semibold text-white">{value}</span>
<span className="text-xs text-bambu-gray">{unit}</span>
<button onClick={onEdit} className="p-1 text-bambu-gray hover:text-white rounded transition-colors"><Edit2 className="w-3 h-3" /></button>
</div>
)}
</div>
);
}
// ── Safety margin field (dual unit: days | grams) ────────────────────────────
function SafetyMarginField({
value, unit, editing, inputValue, dailyRateG,
onInputChange, onUnitChange, onEdit, onSave, onCancel, isPending,
saveLabel = 'Save', cancelLabel = 'Cancel', safetyMarginLabel = 'Safety Margin',
}: {
value: number; unit: 'days' | 'g'; editing: boolean; inputValue: string;
dailyRateG: number | null;
onInputChange: (v: string) => void; onUnitChange: (u: 'days' | 'g') => void;
onEdit: () => void; onSave: () => void; onCancel: () => void; isPending: boolean;
saveLabel?: string; cancelLabel?: string; safetyMarginLabel?: string;
}) {
const { t } = useTranslation();
const displayG = unit === 'g' ? value : (dailyRateG !== null ? Math.round(dailyRateG * value) : null);
const hint = unit === 'days'
? t('forecast.safetyMarginHintDays', {
approx: displayG !== null ? t('forecast.safetyMarginHintDaysApprox', { g: displayG }) : '',
})
: t('forecast.safetyMarginHintG', {
approx: dailyRateG !== null ? t('forecast.safetyMarginHintGApprox', { days: Math.round(value / dailyRateG) }) : '',
});
return (
<div className="bg-bambu-dark-tertiary/40 rounded-lg p-3 space-y-1">
<div className="flex items-center gap-1.5">
<span className="text-xs font-medium text-white">{safetyMarginLabel}</span>
<span title={hint}><Info className="w-3 h-3 text-bambu-gray/50" /></span>
</div>
{editing ? (
<form className="flex items-center gap-2 flex-wrap" onSubmit={(e) => { e.preventDefault(); onSave(); }}>
<input
type="number" min={0} max={unit === 'g' ? 10000 : 365}
value={inputValue} onChange={(e) => onInputChange(e.target.value)}
className="w-20 px-2 py-1 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-sm text-white focus:outline-none focus:border-bambu-green"
autoFocus disabled={isPending}
/>
{/* Unit toggle */}
<div className="flex bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded overflow-hidden text-xs">
<button type="button" onClick={() => onUnitChange('days')} className={`px-2 py-1 transition-colors ${unit === 'days' ? 'bg-bambu-green text-white' : 'text-bambu-gray hover:text-white'}`}>days</button>
<button type="button" onClick={() => onUnitChange('g')} className={`px-2 py-1 transition-colors ${unit === 'g' ? 'bg-bambu-green text-white' : 'text-bambu-gray hover:text-white'}`}>g</button>
</div>
<button type="submit" disabled={isPending} className="px-2 py-1 bg-bambu-green text-white text-xs rounded hover:bg-bambu-green/80 disabled:opacity-50">{saveLabel}</button>
<button type="button" onClick={onCancel} disabled={isPending} className="px-2 py-1 text-xs text-bambu-gray hover:text-white">{cancelLabel}</button>
</form>
) : (
<div className="flex items-center gap-2">
<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>
)}
{unit === 'g' && dailyRateG !== null && (
<span className="text-xs text-bambu-gray/60">≈ {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>
)}
</div>
);
}
// ── Shopping list panel ───────────────────────────────────────────────────────
function ShoppingListPanel({
items, forecasts, globalLeadTime, canWrite, onClose, onRemove, onClear,
}: {
items: ShoppingListItem[];
forecasts: SkuForecast[];
globalLeadTime: number;
canWrite: boolean;
onClose: () => void;
onRemove: (id: number) => void;
onClear: () => void;
}) {
const { t } = useTranslation();
const queryClient = useQueryClient();
const [view, setView] = useState<'list' | 'logistics'>('list');
const statusMutation = useMutation({
mutationFn: async ({ id, status, item, avgSpoolG }: {
id: number;
status: 'pending' | 'purchased' | 'received';
item?: ShoppingListItem;
avgSpoolG?: number;
}) => {
await api.updateShoppingListStatus(id, status);
if (status === 'received' && item) {
// Add received spools to stock category
const spoolWeight = avgSpoolG ?? 1000;
const spoolBase: Parameters<typeof api.bulkCreateSpools>[0] = {
material: item.material,
subtype: item.subtype,
brand: item.brand,
label_weight: spoolWeight,
core_weight: 0,
core_weight_catalog_id: null,
color_name: null, 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,
data_origin: 'manual', tag_type: null,
cost_per_kg: null,
last_scale_weight: null, last_weighed_at: null,
weight_used: 0,
slicer_filament: null, slicer_filament_name: null,
added_full: null, last_used: null, encode_time: null,
category: 'Stock',
low_stock_threshold_pct: null,
};
await api.bulkCreateSpools(spoolBase, item.quantity_spools);
await api.removeFromShoppingList(id);
}
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['shopping-list'] });
queryClient.invalidateQueries({ queryKey: ['spools'] });
},
});
// Build a forecast lookup keyed by (material||subtype||brand)
const forecastMap = useMemo(() => {
const m = new Map<string, SkuForecast>();
for (const f of forecasts) m.set(f.group.key, f);
return m;
}, [forecasts]);
// Resolve a forecast for each cart item
const cartForecasts = useMemo(() =>
items.map((item) => ({
item,
forecast: forecastMap.get(skuKey(item.material, item.subtype, item.brand)) ?? null,
})),
[items, forecastMap]
);
// Items where stock break before replenishment is detected
const breakAlerts = useMemo(() =>
cartForecasts.filter(({ forecast: f }) => {
if (!f || f.dailyRateG === null) return false;
// Stock runs out before the lead time window ends
return f.stockBreakAlert || (f.daysRemaining !== null && f.daysRemaining <= f.effectiveLeadTimeDays);
}),
[cartForecasts]
);
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 rows = items.map((i) => {
const f = forecastMap.get(skuKey(i.material, i.subtype, i.brand)) ?? 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;
const restock = lt > 0 ? formatDate(addDays(new Date(), lt)) : '';
return [
i.quantity_spools,
i.material,
i.brand ?? '',
i.subtype ?? '',
totalWeightG,
lt || '',
restock,
i.status,
i.note ?? '',
].map((v) => `"${String(v).replace(/"/g, '""')}"`).join(',');
});
const csv = [headers.join(','), ...rows].join('\n');
const blob = new Blob([csv], { type: 'text/csv' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `shopping-list-${new Date().toISOString().slice(0, 10)}.csv`;
a.click();
setTimeout(() => URL.revokeObjectURL(url), 100);
}
return (
<div className="bg-bambu-dark-secondary rounded-lg overflow-hidden border border-bambu-dark-tertiary">
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-bambu-dark-tertiary bg-bambu-dark-tertiary/30">
<div className="flex items-center gap-3">
<ShoppingCart className="w-4 h-4 text-bambu-green" />
<h3 className="text-sm font-semibold text-white">{t('forecast.shoppingList')}</h3>
<span className="text-xs text-bambu-gray">{t('forecast.shoppingListItems', { count: items.length })}</span>
{/* View toggle */}
{items.length > 0 && (
<div className="flex bg-bambu-dark-tertiary rounded-md p-0.5 ml-1">
<button
onClick={() => setView('list')}
className={`flex items-center gap-1.5 px-2 py-0.5 text-xs font-medium rounded transition-colors ${view === 'list' ? 'bg-bambu-dark-secondary text-white shadow' : 'text-bambu-gray hover:text-white'}`}
>
<Package className="w-3 h-3" />
{t('forecast.listView')}
</button>
<button
onClick={() => setView('logistics')}
className={`flex items-center gap-1.5 px-2 py-0.5 text-xs font-medium rounded transition-colors ${view === 'logistics' ? 'bg-bambu-dark-secondary text-white shadow' : 'text-bambu-gray hover:text-white'}`}
>
<BarChart2 className="w-3 h-3" />
{t('forecast.logisticsView')}
{breakAlerts.length > 0 && (
<span className="w-3.5 h-3.5 rounded-full bg-red-500 text-white text-[9px] font-bold flex items-center justify-center">
{breakAlerts.length}
</span>
)}
</button>
</div>
)}
</div>
<div className="flex items-center gap-2">
{items.length > 0 && (
<>
<button onClick={downloadCsv} className="flex items-center gap-1.5 text-xs text-bambu-gray hover:text-white transition-colors px-2 py-1 rounded border border-bambu-dark-tertiary hover:bg-bambu-dark-tertiary">
<Download className="w-3 h-3" />
{t('forecast.downloadCsv')}
</button>
{canWrite && (
<button onClick={onClear} className="text-xs text-red-400 hover:text-red-300 transition-colors px-2 py-1 rounded border border-red-500/20 hover:bg-red-500/10">
{t('forecast.clearAll')}
</button>
)}
</>
)}
<button onClick={onClose} className="p-1 text-bambu-gray hover:text-white transition-colors"><X className="w-4 h-4" /></button>
</div>
</div>
{items.length === 0 ? (
<div className="flex flex-col items-center py-8 text-bambu-gray">
<Package className="w-8 h-8 mb-2 opacity-30" />
<p className="text-sm">{t('forecast.shoppingListEmpty')}</p>
</div>
) : view === 'list' ? (
<div className="overflow-x-auto">
<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-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 hasBreak = breakAlerts.some((a) => a.item.id === item.id);
const f = forecastMap.get(skuKey(item.material, item.subtype, item.brand)) ?? 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;
const restockDate = lt > 0 ? addDays(new Date(), lt) : null;
const isPurchased = item.status === 'purchased' || item.status === 'received';
const isReceived = item.status === 'received';
const isMutating = statusMutation.isPending;
return (
<tr key={item.id} className={`hover:bg-bambu-dark-tertiary/30 transition-colors ${hasBreak && !isPurchased ? 'bg-red-500/5' : ''}`}>
{/* Qty */}
<td className="px-4 py-2.5">
<span className="text-sm font-semibold text-bambu-green">{item.quantity_spools}×</span>
</td>
{/* Material */}
<td className="px-4 py-2.5">
<div className="flex items-center gap-2">
<span className="text-sm text-white">{lbl}</span>
{hasBreak && !isPurchased && (
<AlertTriangle className="w-3.5 h-3.5 text-red-400 flex-shrink-0" aria-label={t('forecast.stockBreakBefore')} />
)}
</div>
</td>
{/* Weight */}
<td className="px-4 py-2.5">
<span className="text-sm text-white">
{totalWeightG >= 1000 ? `${(totalWeightG / 1000).toFixed(1)}kg` : `${totalWeightG}g`}
</span>
</td>
{/* Lead time */}
<td className="px-4 py-2.5">
<span className="text-sm text-bambu-gray">{lt > 0 ? `${lt}d` : '—'}</span>
</td>
{/* Expected restock */}
<td className="px-4 py-2.5">
<span className="text-sm text-bambu-gray">
{restockDate ? formatDate(restockDate) : '—'}
</span>
</td>
{/* Status badge — read-only */}
<td className="px-4 py-2.5">
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${
isReceived ? 'bg-bambu-green/20 text-bambu-green' :
isPurchased ? 'bg-blue-400/20 text-blue-400' :
'bg-bambu-dark-tertiary text-bambu-gray'
}`}>
{isReceived ? t('forecast.received') : isPurchased ? t('forecast.purchased') : t('forecast.pending')}
</span>
</td>
{/* Note */}
<td className="px-4 py-2.5">
<span className="text-xs text-bambu-gray">{item.note || '—'}</span>
</td>
{/* Actions */}
<td className="px-4 py-2.5">
<div className="flex items-center justify-end gap-1">
{canWrite && (
<>
{/* Purchased icon — available when pending */}
<button
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 ${
isPurchased
? 'text-blue-400 hover:text-bambu-gray'
: 'text-bambu-gray hover:text-blue-400'
}`}
>
{isPurchased ? <RotateCcw className="w-3.5 h-3.5" /> : <CreditCard className="w-3.5 h-3.5" />}
</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"
>
<PackageCheck className="w-3.5 h-3.5" />
</button>
{/* Delete */}
<button
onClick={() => onRemove(item.id)}
className="p-1 text-bambu-gray hover:text-red-400 transition-colors"
title={t('forecast.remove')}
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</>
)}
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
) : (
/* Logistics view — exclude received items */
<div className="divide-y divide-bambu-dark-tertiary">
{cartForecasts.filter(({ item }) => item.status !== 'received').map(({ item, forecast }) => (
<CartLogisticsRow
key={item.id}
item={item}
forecast={forecast}
globalLeadTime={globalLeadTime}
canWrite={canWrite}
onRemove={() => onRemove(item.id)}
/>
))}
</div>
)}
</div>
);
}
// ── Cart logistics row ────────────────────────────────────────────────────────
function CartLogisticsRow({
item, forecast: f, globalLeadTime, canWrite, onRemove,
}: {
item: ShoppingListItem;
forecast: SkuForecast | null;
globalLeadTime: number;
canWrite: boolean;
onRemove: () => void;
}) {
const { t } = useTranslation();
const label = [item.brand, item.material, item.subtype].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
// chart shows a clean vertical step rather than a smooth interpolated slope.
const chartData = useMemo(() => {
if (!f || f.dailyRateG === null || f.dailyRateG <= 0) return null;
const rate = f.dailyRateG;
const lt = f.effectiveLeadTimeDays;
const avgSpoolG = f.totalSpools > 0 ? f.totalLabelG / f.totalSpools : 1000;
const arrivalG = item.quantity_spools * avgSpoolG;
const stockAtArrival = Math.max(0, f.totalRemainingG - rate * lt);
const peakG = stockAtArrival + arrivalG;
const daysPostArrival = Math.ceil(peakG / rate);
const clampedMax = Math.min(lt + daysPostArrival + 5, 365);
type Point = { day: number; label: string; stock: number; rop: number; safetyStock: number; arrival?: boolean };
const points: Point[] = [];
for (let d = 0; d <= clampedMax; d++) {
const dateLabel = formatDateShort(addDays(new Date(), d));
if (d === lt) {
// Just before arrival — pre-bump stock level
points.push({ day: d, label: dateLabel, stock: Math.round(stockAtArrival), rop: Math.round(f.reorderPointG), safetyStock: Math.round(f.safetyStockG) });
// Just after arrival — post-bump peak (same x label, creates the vertical step)
points.push({ day: d, label: dateLabel, stock: Math.round(peakG), rop: Math.round(f.reorderPointG), safetyStock: Math.round(f.safetyStockG), arrival: true });
} else {
const stock = d < lt
? Math.max(0, f.totalRemainingG - rate * d)
: Math.max(0, peakG - rate * (d - lt));
points.push({ day: d, label: dateLabel, stock: Math.round(stock), rop: Math.round(f.reorderPointG), safetyStock: Math.round(f.safetyStockG) });
}
}
return { points, lt, maxDays: clampedMax, arrivalG, peakG, stockAtArrival };
}, [f, item.quantity_spools]);
// Determine break scenario: stock hits zero before arrival
const stockBreaksAt = useMemo(() => {
if (!f || f.dailyRateG === null || f.dailyRateG <= 0) return null;
const zeroDay = Math.floor(f.totalRemainingG / f.dailyRateG);
if (zeroDay < f.effectiveLeadTimeDays) return zeroDay;
return null;
}, [f]);
const hasBreak = stockBreaksAt !== null;
return (
<div className={`px-4 py-4 ${hasBreak ? 'bg-red-500/5' : ''}`}>
{/* Row header */}
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2 min-w-0">
{hasBreak
? <AlertTriangle className="w-4 h-4 text-red-400 flex-shrink-0" />
: <Check className="w-4 h-4 text-bambu-green/60 flex-shrink-0" />
}
<span className="text-sm font-medium text-white truncate">{label}</span>
<span className="text-xs text-bambu-gray flex-shrink-0">{t('forecast.spoolCount', { count: item.quantity_spools })} ordered</span>
</div>
{canWrite && (
<button onClick={onRemove} className="p-1 text-bambu-gray hover:text-red-400 transition-colors flex-shrink-0">
<Trash2 className="w-3.5 h-3.5" />
</button>
)}
</div>
{/* Break alert */}
{hasBreak && (
<div className="mb-3 px-3 py-2 rounded-lg bg-red-500/10 border border-red-500/20 text-xs text-red-300">
<span className="font-medium">{t('forecast.stockBreakIn', { days: stockBreaksAt })}</span>
{' '}{t('forecast.stockRunsOutBefore', { lt: f!.effectiveLeadTimeDays })}
{f!.dailyRateG !== null && (
<span> {t('forecast.atRate', { rate: f!.dailyRateG.toFixed(1) })}{' '}
<span className="font-semibold">{t('forecast.moreSpools', { count: Math.ceil((f!.dailyRateG * f!.effectiveLeadTimeDays - f!.totalRemainingG) / ((f!.totalLabelG / (f!.totalSpools || 1)) || 1000)) })}</span>
{' '}{t('forecast.bridgeGap')}
</span>
)}
</div>
)}
{/* No forecast data */}
{(!f || f.dailyRateG === null) ? (
<div className="py-4 text-center text-xs text-bambu-gray">
{t('forecast.noUsageData')}
</div>
) : chartData ? (
<>
{/* Key stats row */}
<div className="grid grid-cols-5 gap-2 mb-3">
<div className="bg-bambu-dark-tertiary/40 rounded-lg px-2.5 py-2 text-center">
<div className="text-xs text-bambu-gray mb-0.5">{t('forecast.stock')}</div>
<div className="text-sm font-semibold text-white">{Math.round(f.totalRemainingG)}g</div>
</div>
<div className="bg-bambu-dark-tertiary/40 rounded-lg px-2.5 py-2 text-center">
<div className="text-xs text-bambu-gray mb-0.5">{t('forecast.leadTime')}</div>
<div className="text-sm font-semibold text-white">{f.effectiveLeadTimeDays}d</div>
<div className="text-[10px] text-bambu-gray/60">max(g:{globalLeadTime}, sku:{f.settings?.lead_time_days ?? 0})</div>
</div>
<div className="bg-bambu-dark-tertiary/40 rounded-lg px-2.5 py-2 text-center">
<div className="text-xs text-bambu-gray mb-0.5">{t('forecast.safetyMarginLabel')}</div>
<div className="text-sm font-semibold text-white">{Math.round(f.safetyStockG)}g</div>
</div>
<div className={`rounded-lg px-2.5 py-2 text-center ${hasBreak ? 'bg-red-500/15' : 'bg-bambu-dark-tertiary/40'}`}>
<div className="text-xs text-bambu-gray mb-0.5">{t('forecast.daysLeft')}</div>
<div className={`text-sm font-semibold ${hasBreak ? 'text-red-400' : 'text-green-400'}`}>
{f.daysRemaining ?? '—'}d
</div>
</div>
{chartData && (
<div className="bg-bambu-green/15 rounded-lg px-2.5 py-2 text-center">
<div className="text-xs text-bambu-gray mb-0.5">{t('forecast.onArrival')}</div>
<div className="text-sm font-semibold text-bambu-green">{Math.round(chartData.arrivalG)}g</div>
<div className="text-[10px] text-bambu-gray/60">+{t('forecast.spoolCount', { count: item.quantity_spools })}</div>
</div>
)}
</div>
{/* Chart */}
<ResponsiveContainer width="100%" height={180}>
<AreaChart data={chartData.points} margin={{ top: 8, right: 8, bottom: 0, left: 0 }}>
<defs>
{/* Pre-arrival fill: red if break, amber if tight, green if ok */}
<linearGradient id={`cart-pre-${item.id}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor={hasBreak ? '#EF4444' : '#1DB954'} stopOpacity={0.25} />
<stop offset="95%" stopColor={hasBreak ? '#EF4444' : '#1DB954'} stopOpacity={0.02} />
</linearGradient>
{/* Post-arrival fill: always green */}
<linearGradient id={`cart-post-${item.id}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#1DB954" stopOpacity={0.3} />
<stop offset="95%" stopColor="#1DB954" stopOpacity={0.03} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="#374151" strokeOpacity={0.4} />
<XAxis
dataKey="label"
tick={{ fill: '#6B7280', fontSize: 9 }}
interval={Math.max(0, Math.ceil(chartData.maxDays / 6) - 1)}
axisLine={false}
tickLine={false}
/>
<YAxis
tick={{ fill: '#6B7280', fontSize: 9 }}
axisLine={false}
tickLine={false}
tickFormatter={(v: number) => v >= 1000 ? `${(v / 1000).toFixed(1)}kg` : `${v}g`}
width={44}
/>
<Tooltip
contentStyle={{ background: '#1a1a2e', border: '1px solid #374151', borderRadius: 8, fontSize: 11 }}
labelStyle={{ color: '#9CA3AF' }}
formatter={(value, name) => {
if (typeof value !== 'number') return '';
if (name === 'stock') return `${value}g — ${t('forecast.stock')}`;
if (name === 'rop') return `${value}g — ${t('forecast.reorderPoint')}`;
if (name === 'safetyStock') return `${value}g — ${t('forecast.safetyMarginLabel')}`;
return `${value}`;
}}
/>
{/* Single stock area — linear interpolation renders the vertical step correctly
because the two duplicate-label points at arrival day create an instant jump */}
<Area
type="linear"
dataKey="stock"
stroke="#1DB954"
strokeWidth={2}
fill={`url(#cart-post-${item.id})`}
dot={false}
activeDot={{ r: 3 }}
/>
{/* Reorder point */}
{f.reorderPointG > 0 && (
<ReferenceLine
y={f.reorderPointG}
stroke="#F59E0B"
strokeDasharray="5 3"
strokeOpacity={0.8}
label={{ value: 'ROP', position: 'insideTopRight', fill: '#F59E0B', fontSize: 9 }}
/>
)}
{/* Safety stock floor */}
{f.safetyStockG > 0 && (
<ReferenceLine
y={f.safetyStockG}
stroke="#6B7280"
strokeDasharray="3 3"
strokeOpacity={0.6}
label={{ value: 'SS', position: 'insideTopRight', fill: '#6B7280', fontSize: 9 }}
/>
)}
{/* Arrival / lead-time-end vertical line */}
<ReferenceLine
x={formatDateShort(addDays(new Date(), chartData.lt))}
stroke="#3B82F6"
strokeWidth={1.5}
strokeDasharray="4 3"
strokeOpacity={0.9}
label={{ value: `+${chartData.arrivalG >= 1000 ? `${(chartData.arrivalG / 1000).toFixed(1)}kg` : `${Math.round(chartData.arrivalG)}g`} arrives (d${chartData.lt})`, position: 'insideTopLeft', fill: '#3B82F6', fontSize: 9 }}
/>
{/* Stock break — only shown when stock hits zero before arrival */}
{stockBreaksAt !== null && (
<ReferenceLine
x={formatDateShort(addDays(new Date(), stockBreaksAt))}
stroke="#EF4444"
strokeWidth={1.5}
strokeOpacity={0.9}
label={{ value: 'OUT', position: 'insideTopLeft', fill: '#EF4444', fontSize: 9 }}
/>
)}
</AreaChart>
</ResponsiveContainer>
{/* Legend */}
<div className="flex flex-wrap items-center gap-3 mt-2 text-[10px] text-bambu-gray">
<span className="flex items-center gap-1"><span className="inline-block w-4 border-t-2 border-yellow-400 border-dashed" /> {t('forecast.ropLabel')}</span>
<span className="flex items-center gap-1"><span className="inline-block w-4 border-t border-bambu-gray border-dashed" /> {t('forecast.safetyStockLegend')}</span>
<span className="flex items-center gap-1"><span className="inline-block w-4 border-t-2 border-blue-400 border-dashed" /> {t('forecast.stockArrivalLegend')}</span>
{hasBreak && <span className="flex items-center gap-1 text-red-400"><span className="inline-block w-4 border-t-2 border-red-400" /> {t('forecast.stockoutLegend')}</span>}
</div>
</>
) : null}
</div>
);
}
// ── Add to Cart Modal ─────────────────────────────────────────────────────────
function AddToCartModal({
forecast: f, onClose, onAdd,
}: {
forecast: SkuForecast;
onClose: () => void;
onAdd: (item: { material: string; subtype: string | null; brand: 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 [mode, setMode] = useState<'qty' | 'duration'>('qty');
const [qty, setQty] = useState('1');
const [durationDays, setDurationDays] = useState('30');
const [note, setNote] = useState('');
const spoolsForDuration = useMemo(() => {
if (!f.dailyRateG || f.dailyRateG <= 0) return null;
const neededG = f.dailyRateG * Number(durationDays);
const avgSpoolG = f.group.spools.length > 0
? f.group.spools.reduce((s, sp) => s + sp.label_weight, 0) / f.group.spools.length
: 1000;
return Math.ceil(neededG / avgSpoolG);
}, [f, durationDays]);
const finalQty = mode === 'qty' ? parseInt(qty, 10) || 1 : (spoolsForDuration ?? 1);
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 });
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4">
<div className="bg-bambu-dark-secondary rounded-2xl border border-bambu-dark-tertiary w-full max-w-sm shadow-2xl">
<div className="flex items-center justify-between px-5 pt-5 pb-4 border-b border-bambu-dark-tertiary">
<div className="flex items-center gap-2">
<ShoppingCart className="w-5 h-5 text-bambu-green" />
<h2 className="text-base font-semibold text-white">{t('forecast.addToCartTitle')}</h2>
</div>
<button onClick={onClose} className="p-1 text-bambu-gray hover:text-white transition-colors"><X className="w-5 h-5" /></button>
</div>
<form onSubmit={submit} className="p-5 space-y-4">
<div className="text-sm text-bambu-gray">{label}</div>
<div className="flex bg-bambu-dark-tertiary rounded-lg p-0.5">
<button
type="button"
onClick={() => setMode('qty')}
className={`flex-1 py-1.5 text-xs font-medium rounded-md transition-colors ${mode === 'qty' ? 'bg-bambu-dark-secondary text-white shadow' : 'text-bambu-gray hover:text-white'}`}
>
{t('forecast.byQuantity')}
</button>
<button
type="button"
onClick={() => setMode('duration')}
className={`flex-1 py-1.5 text-xs font-medium rounded-md transition-colors ${mode === 'duration' ? 'bg-bambu-dark-secondary text-white shadow' : 'text-bambu-gray hover:text-white'}`}
>
{t('forecast.byDuration')}
</button>
</div>
{mode === 'qty' ? (
<div className="space-y-1.5">
<label className="text-xs text-bambu-gray">{t('forecast.numberOfSpools')}</label>
<input
type="number" min={1} max={99}
value={qty} onChange={(e) => setQty(e.target.value)}
className="w-full px-3 py-2 bg-bambu-dark-tertiary border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:outline-none focus:border-bambu-green"
autoFocus
/>
</div>
) : (
<div className="space-y-2">
<div className="space-y-1.5">
<label className="text-xs text-bambu-gray">{t('forecast.lastHowManyDays')}</label>
<input
type="number" min={1} max={365}
value={durationDays} onChange={(e) => setDurationDays(e.target.value)}
className="w-full px-3 py-2 bg-bambu-dark-tertiary border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:outline-none focus:border-bambu-green"
autoFocus
/>
</div>
{f.dailyRateG !== null ? (
<div className="flex items-center gap-2 px-3 py-2 bg-bambu-dark-tertiary/50 rounded-lg">
<span className="text-xs text-bambu-gray">≈</span>
<span className="text-sm font-semibold text-bambu-green">{t('forecast.spoolCount', { count: spoolsForDuration ?? 0 })}</span>
<span className="text-xs text-bambu-gray">at {f.dailyRateG.toFixed(1)}g/day</span>
</div>
) : (
<div className="text-xs text-yellow-400 px-1">{t('forecast.noUsageQty')}</div>
)}
</div>
)}
<div className="space-y-1.5">
<label className="text-xs text-bambu-gray">{t('forecast.noteOptional')}</label>
<input
type="text" maxLength={200}
value={note} onChange={(e) => setNote(e.target.value)}
placeholder={t('forecast.notePlaceholder')}
className="w-full px-3 py-2 bg-bambu-dark-tertiary border border-bambu-dark-tertiary rounded-lg text-white text-sm placeholder:text-bambu-gray/40 focus:outline-none focus:border-bambu-green"
/>
</div>
<div className="flex items-center gap-3 pt-1">
<button
type="submit"
className="flex-1 py-2 bg-bambu-green text-white text-sm font-medium rounded-lg hover:bg-bambu-green/80 transition-colors"
>
{t('forecast.addNSpools', { count: finalQty })}
</button>
<button type="button" onClick={onClose} className="px-4 py-2 text-sm text-bambu-gray hover:text-white border border-bambu-dark-tertiary rounded-lg transition-colors">
{t('forecast.cancel')}
</button>
</div>
</form>
</div>
</div>
);
}
// ── Column headers (re-exported for InventoryPage) ────────────────────────────
export function ForecastColumnHeaders() {
return null;
}