mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-05 13:41:36 +02:00
The app was built dark-first, so hundreds of hardcoded Tailwind semantic text/icon utilities at light shades (text-amber-400, text-blue-300, ...) had no dark: variant. With darkMode:'class' they applied in light theme too, producing washed-out text on pale tints and white cards — including the three reported spots (AMS Drying banner, Archives no-3MF warning, debug-logging banner). Give each a theme-aware pair: a darker readable shade in light theme with the original pinned to dark:, so dark theme is unchanged. ~100 files. The bambu-* CSS-variable palette (self-correcting) and the dark-only SpoolBuddy kiosk are left untouched. Plain text-white is already theme-aware via the existing index.css .text-white override, so it needed no changes.
1910 lines
90 KiB
TypeScript
1910 lines
90 KiB
TypeScript
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;
|
||
colorName: 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, colorName: string | null) {
|
||
return `${material}||${subtype ?? ''}||${brand ?? ''}||${colorName ?? ''}`;
|
||
}
|
||
|
||
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. 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 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;
|
||
|
||
// 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
|
||
const lambda = Math.LN2 / 30;
|
||
|
||
const observations: { rate: number; weight: number }[] = [];
|
||
|
||
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 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 });
|
||
}
|
||
|
||
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 [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);
|
||
|
||
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.color_name), 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, 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);
|
||
}
|
||
return [...map.values()];
|
||
}, [spools]);
|
||
|
||
const forecasts = useMemo((): SkuForecast[] => {
|
||
const today = new Date(); today.setHours(0, 0, 0, 0);
|
||
|
||
return groups.map((group): SkuForecast => {
|
||
// 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;
|
||
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);
|
||
|
||
// 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) {
|
||
if ((s.weight_used_baseline ?? 0) === 0) {
|
||
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 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(() => {
|
||
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;
|
||
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, materialFilter, brandFilter]);
|
||
|
||
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-100 dark:bg-red-500/15 border-red-300 dark:border-red-500/30 text-red-700 dark:text-red-300 hover:bg-red-200 dark:hover:bg-red-500/25'
|
||
: 'bg-yellow-100 dark:bg-yellow-500/15 border-yellow-300 dark:border-yellow-500/30 text-yellow-700 dark:text-yellow-300 hover:bg-yellow-200 dark: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');
|
||
});
|
||
}}
|
||
/>
|
||
)}
|
||
|
||
{/* 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)}
|
||
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>
|
||
<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>
|
||
<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, f.group.colorName].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-50 dark:bg-red-500/10 border-red-300 dark:border-red-500/30 text-red-700 dark:text-red-300' : 'bg-yellow-50 dark:bg-yellow-500/10 border-yellow-300 dark:border-yellow-500/30 text-yellow-700 dark: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, f.group.colorName].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
|
||
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
|
||
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, 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' };
|
||
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-700 dark:text-red-400'
|
||
: f.reorderAlert ? 'text-yellow-700 dark:text-yellow-400'
|
||
: f.daysRemaining < 30 ? 'text-yellow-700 dark:text-yellow-400'
|
||
: 'text-green-700 dark: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, 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) {
|
||
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-100 dark:bg-blue-400/15 text-blue-700 dark: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={`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-5 h-5 rounded-full border border-black/20"
|
||
style={colorStyle}
|
||
/>
|
||
</td>
|
||
|
||
{/* SKU */}
|
||
<td className="px-4 py-3">
|
||
<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-[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>
|
||
</td>
|
||
|
||
{/* Rate */}
|
||
<td className="px-4 py-3">
|
||
<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 */}
|
||
<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 ${!snoozed && f.reorderAlert ? 'text-yellow-700 dark: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-600 dark:text-red-400" aria-label={t('forecast.stockBreakRisk')} />
|
||
) : f.reorderAlert ? (
|
||
<AlertTriangle className="w-4 h-4 text-yellow-600 dark: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-amber-600/80 dark:text-amber-400/80 hover:text-amber-700 dark: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" />
|
||
</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={9} className="px-6 py-4">
|
||
<div className="space-y-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.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>
|
||
|
||
{/* 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-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">
|
||
{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 font-medium text-white 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-lg font-semibold text-white">≈ {displayG}g</span>
|
||
)}
|
||
{unit === 'g' && dailyRateG !== null && (
|
||
<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>
|
||
)}
|
||
</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: 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,
|
||
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, item.color_name)) ?? 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'), 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, 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;
|
||
const restock = lt > 0 ? formatDate(addDays(new Date(), lt)) : '';
|
||
return [
|
||
i.quantity_spools,
|
||
i.material,
|
||
i.brand ?? '',
|
||
i.subtype ?? '',
|
||
i.color_name ?? '',
|
||
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-700 dark:text-red-400 hover:text-red-800 dark:hover:text-red-300 transition-colors px-2 py-1 rounded border border-red-200 dark:border-red-500/20 hover:bg-red-50 dark: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-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, 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, 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;
|
||
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-600 dark: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-100 dark:bg-blue-400/20 text-blue-700 dark: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.5 rounded transition-colors disabled:opacity-30 ${
|
||
isPurchased
|
||
? 'text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300'
|
||
: 'text-blue-600/50 dark:text-blue-400/50 hover:text-blue-600 dark:hover:text-blue-400'
|
||
}`}
|
||
>
|
||
{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.5 rounded transition-colors text-bambu-green/50 hover:text-bambu-green disabled:opacity-30"
|
||
>
|
||
<PackageCheck className="w-4 h-4" />
|
||
</button>
|
||
{/* Delete */}
|
||
<button
|
||
onClick={() => onRemove(item.id)}
|
||
className="p-1 text-bambu-gray hover:text-red-600 dark: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, 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
|
||
// 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-600 dark: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-600 dark: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-50 dark:bg-red-500/10 border border-red-200 dark:border-red-500/20 text-xs text-red-700 dark: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-100 dark: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-700 dark:text-red-400' : 'text-green-700 dark: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-700 dark: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; 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, f.group.colorName].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, color_name: f.group.colorName, 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-700 dark: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;
|
||
}
|