ForecastPanel.tsx 89 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909
  1. import { useState, useMemo, useEffect } from 'react';
  2. import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
  3. import { useTranslation } from 'react-i18next';
  4. import {
  5. AlertTriangle, TrendingDown, ShoppingCart, Check, BellOff,
  6. ChevronDown, ChevronUp, Info, Edit2, X, Lock,
  7. ArrowUp, ArrowDown, ArrowUpDown, Package, Trash2, BarChart2,
  8. CreditCard, PackageCheck, Download, RotateCcw,
  9. } from 'lucide-react';
  10. import {
  11. AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip,
  12. ResponsiveContainer, ReferenceLine, Legend,
  13. } from 'recharts';
  14. import { api } from '../api/client';
  15. import type { InventorySpool, SpoolUsageRecord, FilamentSkuSettings, ShoppingListItem } from '../api/client';
  16. import { getSwatchStyle } from '../utils/colors';
  17. import { useToast } from '../contexts/ToastContext';
  18. import { useAuth } from '../contexts/AuthContext';
  19. // ── Types ─────────────────────────────────────────────────────────────────────
  20. interface SkuGroup {
  21. key: string;
  22. material: string;
  23. subtype: string | null;
  24. brand: string | null;
  25. colorName: string | null;
  26. spools: InventorySpool[];
  27. }
  28. interface SkuForecast {
  29. group: SkuGroup;
  30. settings: FilamentSkuSettings | null;
  31. totalRemainingG: number;
  32. totalLabelG: number;
  33. totalSpools: number;
  34. totalUsedG: number;
  35. dailyRateG: number | null;
  36. dailyRateStdDev: number | null;
  37. rateTier: 'history' | 'delta' | 'none';
  38. effectiveLeadTimeDays: number;
  39. safetyStockG: number;
  40. reorderPointG: number;
  41. daysRemaining: number | null;
  42. daysUntilROP: number | null;
  43. projectedEmptyDate: Date | null;
  44. reorderTriggerDate: Date | null;
  45. reorderAlert: boolean;
  46. stockBreakAlert: boolean;
  47. }
  48. type SortKey = 'material' | 'used' | 'days_left' | 'stock';
  49. type SortDir = 'asc' | 'desc';
  50. type ChartDays = 7 | 30 | 180;
  51. // ── Constants ─────────────────────────────────────────────────────────────────
  52. const Z_95 = 1.65;
  53. const CHART_COLORS = ['#1DB954', '#3B82F6', '#F59E0B', '#EF4444', '#8B5CF6'];
  54. // ── Pure helpers ──────────────────────────────────────────────────────────────
  55. function skuKey(material: string, subtype: string | null, brand: string | null, colorName: string | null) {
  56. return `${material}||${subtype ?? ''}||${brand ?? ''}||${colorName ?? ''}`;
  57. }
  58. function addDays(date: Date, days: number): Date {
  59. const d = new Date(date);
  60. d.setDate(d.getDate() + Math.round(days));
  61. return d;
  62. }
  63. function formatDate(date: Date): string {
  64. return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
  65. }
  66. function formatDateShort(date: Date): string {
  67. return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
  68. }
  69. /**
  70. * Compute a time-weighted daily consumption rate and standard deviation.
  71. *
  72. * Algorithm:
  73. * 1. Aggregate all usage events by calendar day (UTC date string), summing
  74. * weight_used across all spools in the group that printed on that day.
  75. * Day-bucketing fixes two problems: (a) concurrent prints from multiple
  76. * spools in the same group no longer produce near-zero inter-event gaps
  77. * that inflate per-interval rates; (b) the oldest event's weight is no
  78. * longer silently dropped — it contributes to its day bucket.
  79. * 2. Sort day buckets oldest → newest and compute inter-day g/day rates.
  80. * The gap is in whole days (minimum 1) so same-day reprints don't
  81. * create a zero-duration interval.
  82. * 3. Apply exponential age-decay: each observation is weighted by
  83. * exp(-λ * age_days) so recent prints dominate. λ = ln(2)/30 gives a
  84. * 30-day half-life — prints from a month ago count half as much.
  85. * 4. Compute the weighted mean and weighted variance → std dev.
  86. *
  87. * Returns null when there are fewer than 2 distinct days (no gap to measure)
  88. * — the delta-rate fallback handles that case.
  89. */
  90. function computeHistoryRate(records: SpoolUsageRecord[]): { rate: number; stdDev: number } | null {
  91. if (records.length < 2) return null;
  92. // Aggregate by UTC calendar day so concurrent multi-spool prints on the
  93. // same day are summed before rate computation.
  94. const byDay = new Map<string, number>();
  95. for (const r of records) {
  96. const day = r.created_at.slice(0, 10); // "YYYY-MM-DD" UTC — consistent with server timestamps
  97. byDay.set(day, (byDay.get(day) ?? 0) + r.weight_used);
  98. }
  99. if (byDay.size < 2) return null;
  100. const days = [...byDay.entries()]
  101. .map(([day, totalG]) => ({ ms: new Date(day).getTime(), totalG }))
  102. .sort((a, b) => a.ms - b.ms);
  103. const now = Date.now();
  104. // λ for 30-day half-life: ln(2)/30
  105. const lambda = Math.LN2 / 30;
  106. const observations: { rate: number; weight: number }[] = [];
  107. for (let i = 1; i < days.length; i++) {
  108. const elapsedDays = Math.max((days[i].ms - days[i - 1].ms) / 86400000, 1);
  109. const ageDays = (now - days[i].ms) / 86400000;
  110. // g/day for this inter-day interval: weight printed on day[i] / gap to previous day
  111. const intervalRate = days[i].totalG / elapsedDays;
  112. const w = Math.exp(-lambda * ageDays);
  113. observations.push({ rate: intervalRate, weight: w });
  114. }
  115. const totalW = observations.reduce((s, o) => s + o.weight, 0);
  116. if (totalW === 0) return null;
  117. const mean = observations.reduce((s, o) => s + o.rate * o.weight, 0) / totalW;
  118. const variance = observations.reduce((s, o) => s + o.weight * (o.rate - mean) ** 2, 0) / totalW;
  119. return { rate: mean, stdDev: Math.sqrt(variance) };
  120. }
  121. function computeDeltaRate(spools: InventorySpool[]): number | null {
  122. // Use weight_used - baseline so "Reset usage to 0" on the Inventory page
  123. // makes forecast restart from zero rather than carrying stale lifetime
  124. // consumption across the reset (#1390).
  125. const totalUsed = spools.reduce((s, sp) => s + Math.max(0, sp.weight_used - (sp.weight_used_baseline ?? 0)), 0);
  126. if (totalUsed === 0) return null;
  127. const now = Date.now();
  128. const oldestMs = spools.reduce((min, sp) => {
  129. const t = new Date(sp.created_at).getTime();
  130. return t < min ? t : min;
  131. }, now);
  132. const daysSinceOldest = (now - oldestMs) / 86400000;
  133. if (daysSinceOldest < 1) return null;
  134. return totalUsed / daysSinceOldest;
  135. }
  136. function buildProjectionSeries(
  137. forecast: SkuForecast,
  138. days = 60,
  139. ): { day: number; label: string; stock: number; rop: number }[] {
  140. if (forecast.dailyRateG === null) return [];
  141. const rate = forecast.dailyRateG;
  142. const result = [];
  143. for (let d = 0; d <= days; d++) {
  144. const stock = Math.max(0, forecast.totalRemainingG - rate * d);
  145. result.push({
  146. day: d,
  147. label: formatDateShort(addDays(new Date(), d)),
  148. stock: Math.round(stock),
  149. rop: Math.round(forecast.reorderPointG),
  150. });
  151. if (stock === 0) break;
  152. }
  153. return result;
  154. }
  155. // ── Main component ────────────────────────────────────────────────────────────
  156. export function ForecastPanel({ spools }: { spools: InventorySpool[] }) {
  157. const queryClient = useQueryClient();
  158. const { showToast } = useToast();
  159. const { t } = useTranslation();
  160. const { hasPermission, hasAnyPermission } = useAuth();
  161. const canRead = hasPermission('inventory:forecast_read');
  162. const canWrite = hasAnyPermission('inventory:forecast_write', 'inventory:update');
  163. // All hooks must run unconditionally — guard render is deferred until after hooks
  164. const [alertsOpen, setAlertsOpen] = useState(false);
  165. const [sortKey, setSortKey] = useState<SortKey>('material');
  166. const [sortDir, setSortDir] = useState<SortDir>('asc');
  167. const [materialFilter, setMaterialFilter] = useState('');
  168. const [brandFilter, setBrandFilter] = useState('');
  169. const [cartModal, setCartModal] = useState<SkuForecast | null>(null);
  170. const [listOpen, setListOpen] = useState(false);
  171. const [chartDays, setChartDays] = useState<ChartDays>(30);
  172. const { data: settings } = useQuery({ queryKey: ['settings'], queryFn: api.getSettings, enabled: canRead });
  173. const { data: skuSettingsList = [] } = useQuery({ queryKey: ['sku-settings'], queryFn: api.getSkuSettings, staleTime: 60_000, enabled: canRead });
  174. const { data: usageHistory = [] } = useQuery({ queryKey: ['all-usage-history-forecast'], queryFn: () => api.getAllUsageHistory(5000), staleTime: 60_000, enabled: canRead });
  175. const { data: shoppingList = [] } = useQuery({ queryKey: ['shopping-list'], queryFn: api.getShoppingList, staleTime: 30_000, enabled: canRead });
  176. const globalLeadTime = settings?.forecast_global_lead_time_days ?? 0;
  177. const settingsMap = useMemo(() => {
  178. const m = new Map<string, FilamentSkuSettings>();
  179. for (const s of skuSettingsList) m.set(skuKey(s.material, s.subtype, s.brand, s.color_name), s);
  180. return m;
  181. }, [skuSettingsList]);
  182. const usageBySpoolId = useMemo(() => {
  183. const m = new Map<number, SpoolUsageRecord[]>();
  184. for (const r of usageHistory) {
  185. const arr = m.get(r.spool_id) ?? [];
  186. arr.push(r);
  187. m.set(r.spool_id, arr);
  188. }
  189. return m;
  190. }, [usageHistory]);
  191. const groups = useMemo((): SkuGroup[] => {
  192. const map = new Map<string, SkuGroup>();
  193. for (const spool of spools) {
  194. if (spool.archived_at) continue;
  195. const key = skuKey(spool.material, spool.subtype, spool.brand, spool.color_name);
  196. const g = map.get(key) ?? { key, material: spool.material, subtype: spool.subtype, brand: spool.brand, colorName: spool.color_name, spools: [] };
  197. g.spools.push(spool);
  198. map.set(key, g);
  199. }
  200. return [...map.values()];
  201. }, [spools]);
  202. const forecasts = useMemo((): SkuForecast[] => {
  203. const today = new Date(); today.setHours(0, 0, 0, 0);
  204. return groups.map((group): SkuForecast => {
  205. // Fall back to the NULL-colour row that pre-upgrade users have so their
  206. // lead-time / safety-margin overrides survive the first load after the
  207. // color_name column is added (#forecast-color-grouping migration).
  208. const skuSettings =
  209. settingsMap.get(group.key) ??
  210. (group.colorName !== null ? settingsMap.get(skuKey(group.material, group.subtype, group.brand, null)) ?? null : null);
  211. const skuLeadTime = skuSettings?.lead_time_days ?? 0;
  212. const effectiveLeadTimeDays = Math.max(globalLeadTime, skuLeadTime);
  213. const marginValue = skuSettings?.safety_margin_value ?? 14;
  214. const marginUnit = skuSettings?.safety_margin_unit ?? 'days';
  215. const totalRemainingG = group.spools.reduce((s, sp) => s + Math.max(0, sp.label_weight - sp.weight_used), 0);
  216. const totalLabelG = group.spools.reduce((s, sp) => s + sp.label_weight, 0);
  217. // Consumed since baseline (post-reset); see InventoryPage stats calc (#1390).
  218. const totalUsedG = group.spools.reduce((s, sp) => s + Math.max(0, sp.weight_used - (sp.weight_used_baseline ?? 0)), 0);
  219. // Only include history from spools that haven't been reset — pre-reset
  220. // events on a reset spool have no anchor timestamp so they'd inflate the
  221. // rate. Spools without a baseline are clean and keep their records.
  222. const groupHistory: SpoolUsageRecord[] = [];
  223. for (const s of group.spools) {
  224. if ((s.weight_used_baseline ?? 0) === 0) {
  225. groupHistory.push(...(usageBySpoolId.get(s.id) ?? []));
  226. }
  227. }
  228. let dailyRateG: number | null = null;
  229. let dailyRateStdDev: number | null = null;
  230. let rateTier: SkuForecast['rateTier'] = 'none';
  231. const histResult = computeHistoryRate(groupHistory);
  232. if (histResult !== null) {
  233. dailyRateG = histResult.rate;
  234. dailyRateStdDev = histResult.stdDev;
  235. rateTier = 'history';
  236. } else {
  237. const delta = computeDeltaRate(group.spools);
  238. if (delta !== null) { dailyRateG = delta; rateTier = 'delta'; }
  239. }
  240. const σ = dailyRateStdDev ?? (dailyRateG !== null ? dailyRateG * 0.2 : 0);
  241. const statisticalSafetyStockG = Z_95 * σ * Math.sqrt(effectiveLeadTimeDays);
  242. // safety margin: user-defined buffer on top of statistical safety stock
  243. const safetyMarginG = marginUnit === 'g'
  244. ? marginValue
  245. : (dailyRateG !== null ? dailyRateG * marginValue : marginValue * 5);
  246. const safetyStockG = statisticalSafetyStockG + safetyMarginG;
  247. const reorderPointG = dailyRateG !== null
  248. ? dailyRateG * effectiveLeadTimeDays + safetyStockG
  249. : 0;
  250. const daysRemaining = dailyRateG && dailyRateG > 0 ? Math.floor(totalRemainingG / dailyRateG) : null;
  251. const projectedEmptyDate = daysRemaining !== null ? addDays(today, daysRemaining) : null;
  252. const daysUntilROP = dailyRateG && dailyRateG > 0
  253. ? Math.floor((totalRemainingG - reorderPointG) / dailyRateG)
  254. : null;
  255. const reorderTriggerDate = daysUntilROP !== null ? addDays(today, Math.max(0, daysUntilROP)) : null;
  256. const stockBreakAlert = daysRemaining !== null && effectiveLeadTimeDays > 0 && daysRemaining <= effectiveLeadTimeDays;
  257. const reorderAlert = !stockBreakAlert && daysUntilROP !== null && daysUntilROP <= 0;
  258. return {
  259. group, settings: skuSettings,
  260. totalRemainingG, totalLabelG, totalSpools: group.spools.length, totalUsedG,
  261. dailyRateG, dailyRateStdDev,
  262. rateTier,
  263. effectiveLeadTimeDays, safetyStockG, reorderPointG,
  264. daysRemaining, daysUntilROP,
  265. projectedEmptyDate, reorderTriggerDate,
  266. reorderAlert, stockBreakAlert,
  267. };
  268. });
  269. }, [groups, settingsMap, usageBySpoolId, globalLeadTime]);
  270. const uniqueMaterials = useMemo(() =>
  271. [...new Set(groups.map((g) => g.material))].sort(),
  272. [groups]);
  273. const uniqueBrands = useMemo(() =>
  274. [...new Set(groups.map((g) => g.brand).filter(Boolean))].sort() as string[],
  275. [groups]);
  276. const sortedForecasts = useMemo(() => {
  277. let arr = [...forecasts];
  278. if (materialFilter) arr = arr.filter((f) => f.group.material === materialFilter);
  279. if (brandFilter) arr = arr.filter((f) => f.group.brand === brandFilter);
  280. arr.sort((a, b) => {
  281. let va: number | string = 0;
  282. let vb: number | string = 0;
  283. switch (sortKey) {
  284. case 'material':
  285. va = [a.group.material, a.group.subtype ?? '', a.group.brand ?? ''].join(' ').toLowerCase();
  286. vb = [b.group.material, b.group.subtype ?? '', b.group.brand ?? ''].join(' ').toLowerCase();
  287. break;
  288. case 'used':
  289. va = a.totalUsedG; vb = b.totalUsedG;
  290. break;
  291. case 'days_left':
  292. va = a.daysRemaining ?? 999999; vb = b.daysRemaining ?? 999999;
  293. break;
  294. case 'stock':
  295. va = a.totalRemainingG; vb = b.totalRemainingG;
  296. break;
  297. }
  298. const cmp = va < vb ? -1 : va > vb ? 1 : 0;
  299. return sortDir === 'asc' ? cmp : -cmp;
  300. });
  301. return arr;
  302. }, [forecasts, sortKey, sortDir, materialFilter, brandFilter]);
  303. const alerts = useMemo(() => forecasts.filter((f) => !f.settings?.alerts_snoozed && (f.stockBreakAlert || f.reorderAlert)), [forecasts]);
  304. const top5 = useMemo(() =>
  305. [...forecasts]
  306. .filter((f) => f.dailyRateG !== null)
  307. .sort((a, b) => b.totalUsedG - a.totalUsedG)
  308. .slice(0, 5),
  309. [forecasts]
  310. );
  311. // ── Read permission guard — all hooks above this point ──────────────────────
  312. if (!canRead) {
  313. return (
  314. <div className="flex flex-col items-center justify-center py-16 text-bambu-gray gap-3">
  315. <Lock className="w-8 h-8 opacity-40" />
  316. <p className="text-sm">{t('forecast.noReadAccess')}</p>
  317. </div>
  318. );
  319. }
  320. function handleSort(key: SortKey) {
  321. if (sortKey === key) setSortDir((d) => d === 'asc' ? 'desc' : 'asc');
  322. else { setSortKey(key); setSortDir(key === 'days_left' ? 'asc' : 'desc'); }
  323. }
  324. const shoppingListBadge = shoppingList.length > 0 ? shoppingList.length : null;
  325. return (
  326. <div className="space-y-5">
  327. {/* ── Toolbar ── */}
  328. <div className="flex flex-wrap items-center gap-3">
  329. {/* Alert button */}
  330. {alerts.length > 0 && (
  331. <button
  332. onClick={() => setAlertsOpen((o) => !o)}
  333. className={`flex items-center gap-2 px-3 py-1.5 rounded-lg border text-sm font-medium transition-colors ${
  334. alerts.some((f) => f.stockBreakAlert)
  335. ? 'bg-red-500/15 border-red-500/30 text-red-300 hover:bg-red-500/25'
  336. : 'bg-yellow-500/15 border-yellow-500/30 text-yellow-300 hover:bg-yellow-500/25'
  337. }`}
  338. >
  339. <AlertTriangle className="w-4 h-4" />
  340. {t('forecast.alertCount', { count: alerts.length })}
  341. {alertsOpen ? <ChevronUp className="w-3.5 h-3.5" /> : <ChevronDown className="w-3.5 h-3.5" />}
  342. </button>
  343. )}
  344. {/* Global lead time */}
  345. {canWrite && (
  346. <GlobalLeadTimeSetting
  347. value={globalLeadTime}
  348. onSave={(v) => {
  349. api.updateSettings({ forecast_global_lead_time_days: v }).then(() => {
  350. queryClient.invalidateQueries({ queryKey: ['settings'] });
  351. showToast(t('forecast.globalLeadTimeSaved'), 'success');
  352. });
  353. }}
  354. />
  355. )}
  356. {/* Material filter */}
  357. <select
  358. value={materialFilter}
  359. onChange={(e) => setMaterialFilter(e.target.value)}
  360. className={`px-3 py-1.5 rounded-lg border text-xs font-medium transition-colors cursor-pointer focus:outline-none ${
  361. materialFilter
  362. ? 'bg-bambu-green/20 text-bambu-green border-bambu-green/30'
  363. : 'bg-transparent text-bambu-gray border-bambu-dark-tertiary hover:bg-bambu-dark-tertiary'
  364. }`}
  365. >
  366. <option value="">{t('inventory.material')}</option>
  367. {uniqueMaterials.map((m) => <option key={m} value={m}>{m}</option>)}
  368. </select>
  369. {/* Brand filter */}
  370. <select
  371. value={brandFilter}
  372. onChange={(e) => setBrandFilter(e.target.value)}
  373. className={`px-3 py-1.5 rounded-lg border text-xs font-medium transition-colors cursor-pointer focus:outline-none ${
  374. brandFilter
  375. ? 'bg-bambu-green/20 text-bambu-green border-bambu-green/30'
  376. : 'bg-transparent text-bambu-gray border-bambu-dark-tertiary hover:bg-bambu-dark-tertiary'
  377. }`}
  378. >
  379. <option value="">{t('inventory.brand')}</option>
  380. {uniqueBrands.map((b) => <option key={b} value={b}>{b}</option>)}
  381. </select>
  382. {/* Shopping list toggle */}
  383. <button
  384. onClick={() => setListOpen((o) => !o)}
  385. 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"
  386. >
  387. <ShoppingCart className="w-4 h-4" />
  388. <span className="hidden sm:inline">{t('forecast.shoppingList')}</span>
  389. {shoppingListBadge && (
  390. <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">
  391. {shoppingListBadge}
  392. </span>
  393. )}
  394. </button>
  395. </div>
  396. {/* ── Collapsed alerts panel ── */}
  397. {alertsOpen && alerts.length > 0 && (
  398. <div className="space-y-2">
  399. {alerts.map((f) => (
  400. <AlertBanner key={f.group.key} forecast={f} onCart={() => setCartModal(f)} />
  401. ))}
  402. </div>
  403. )}
  404. {/* ── Shopping list panel ── */}
  405. {listOpen && (
  406. <ShoppingListPanel
  407. items={shoppingList}
  408. forecasts={forecasts}
  409. globalLeadTime={globalLeadTime}
  410. canWrite={canWrite}
  411. onClose={() => setListOpen(false)}
  412. onRemove={(id) => {
  413. api.removeFromShoppingList(id)
  414. .then(() => queryClient.invalidateQueries({ queryKey: ['shopping-list'] }))
  415. .catch(() => showToast(t('forecast.failedSaveSettings'), 'error'));
  416. }}
  417. onClear={() => {
  418. api.clearShoppingList()
  419. .then(() => queryClient.invalidateQueries({ queryKey: ['shopping-list'] }))
  420. .catch(() => showToast(t('forecast.failedSaveSettings'), 'error'));
  421. }}
  422. />
  423. )}
  424. {/* ── Usage + projection chart ── */}
  425. {top5.length > 0 && <UsageChart forecasts={top5} days={chartDays} onDaysChange={setChartDays} />}
  426. {/* ── Table ── */}
  427. {forecasts.length === 0 ? (
  428. <div className="flex flex-col items-center justify-center py-16 text-bambu-gray">
  429. <TrendingDown className="w-10 h-10 mb-3 opacity-40" />
  430. <p className="text-sm">{t('forecast.noSpools')}</p>
  431. </div>
  432. ) : (
  433. <div className="bg-bambu-dark-secondary rounded-lg overflow-hidden border border-bambu-dark-tertiary">
  434. <div className="overflow-x-auto">
  435. <table className="w-full">
  436. <thead>
  437. <tr className="border-b border-bambu-dark-tertiary bg-bambu-dark-tertiary/30">
  438. {/* Color dot */}
  439. <th className="w-8 px-4 py-3" />
  440. <SortableTh col="material" active={sortKey} dir={sortDir} onSort={handleSort}>
  441. {t('forecast.sku')}
  442. </SortableTh>
  443. <th className="px-4 py-3 text-left text-xs font-medium text-bambu-gray uppercase tracking-wide">
  444. {t('locations.spools')}
  445. </th>
  446. <SortableTh col="stock" active={sortKey} dir={sortDir} onSort={handleSort}>
  447. {t('forecast.stock')}
  448. </SortableTh>
  449. <SortableTh col="used" active={sortKey} dir={sortDir} onSort={handleSort}>
  450. {t('forecast.dailyRate')}
  451. </SortableTh>
  452. <SortableTh col="days_left" active={sortKey} dir={sortDir} onSort={handleSort}>
  453. {t('forecast.daysLeft')}
  454. </SortableTh>
  455. <th className="px-4 py-3 text-left text-xs font-medium text-bambu-gray uppercase tracking-wide">
  456. {t('forecast.emptyBy')}
  457. </th>
  458. <th className="px-4 py-3 text-left text-xs font-medium text-bambu-gray uppercase tracking-wide">
  459. {t('forecast.reorderBy')}
  460. </th>
  461. {/* Actions */}
  462. <th className="w-24 px-4 py-3" />
  463. </tr>
  464. </thead>
  465. <tbody className="divide-y divide-bambu-dark-tertiary">
  466. {sortedForecasts.map((f) => (
  467. <ForecastRow
  468. key={f.group.key}
  469. forecast={f}
  470. globalLeadTime={globalLeadTime}
  471. canWrite={canWrite}
  472. onSaved={() => queryClient.invalidateQueries({ queryKey: ['sku-settings'] })}
  473. onCart={() => setCartModal(f)}
  474. showToast={showToast}
  475. />
  476. ))}
  477. </tbody>
  478. </table>
  479. </div>
  480. {/* Legend */}
  481. <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">
  482. <span className="flex items-center gap-1.5">
  483. <span className="w-2 h-2 rounded-full bg-bambu-green inline-block" />
  484. {t('forecast.trendLegend')}
  485. </span>
  486. <span className="flex items-center gap-1.5">
  487. <span className="w-2 h-2 rounded-full bg-blue-400 inline-block" />
  488. {t('forecast.estimatedLegend')}
  489. </span>
  490. <span className="flex items-center gap-1.5">
  491. <span className="w-2 h-2 rounded-full bg-bambu-gray/40 inline-block" />
  492. {t('forecast.noDataLegend')}
  493. </span>
  494. </div>
  495. </div>
  496. )}
  497. {/* ── Add to cart modal ── */}
  498. {cartModal && (
  499. <AddToCartModal
  500. forecast={cartModal}
  501. onClose={() => setCartModal(null)}
  502. onAdd={(item) => {
  503. api.addToShoppingList(item).then(() => {
  504. queryClient.invalidateQueries({ queryKey: ['shopping-list'] });
  505. showToast(t('forecast.addedToCart'), 'success');
  506. setCartModal(null);
  507. setListOpen(true);
  508. }).catch(() => showToast(t('forecast.failedAddItem'), 'error'));
  509. }}
  510. />
  511. )}
  512. </div>
  513. );
  514. }
  515. // ── Sortable th ───────────────────────────────────────────────────────────────
  516. function SortableTh({
  517. col, active, dir, onSort, children,
  518. }: {
  519. col: SortKey;
  520. active: SortKey;
  521. dir: SortDir;
  522. onSort: (k: SortKey) => void;
  523. children: React.ReactNode;
  524. }) {
  525. const isActive = active === col;
  526. return (
  527. <th
  528. 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"
  529. onClick={() => onSort(col)}
  530. >
  531. <span className="inline-flex items-center">
  532. {children}
  533. {isActive
  534. ? dir === 'asc'
  535. ? <ArrowUp className="w-3 h-3 ml-1 text-bambu-green" />
  536. : <ArrowDown className="w-3 h-3 ml-1 text-bambu-green" />
  537. : <ArrowUpDown className="w-3 h-3 ml-1 opacity-40" />
  538. }
  539. </span>
  540. </th>
  541. );
  542. }
  543. // ── Alert Banner ──────────────────────────────────────────────────────────────
  544. function AlertBanner({ forecast: f, onCart }: { forecast: SkuForecast; onCart: () => void }) {
  545. const { t } = useTranslation();
  546. const label = [f.group.brand, f.group.material, f.group.subtype, f.group.colorName].filter(Boolean).join(' ');
  547. const isBreak = f.stockBreakAlert;
  548. return (
  549. <div className={`flex items-center gap-3 px-4 py-3 rounded-lg border text-sm ${
  550. isBreak ? 'bg-red-500/10 border-red-500/30 text-red-300' : 'bg-yellow-500/10 border-yellow-500/30 text-yellow-300'
  551. }`}>
  552. <AlertTriangle className="w-4 h-4 flex-shrink-0" />
  553. <div className="flex-1 min-w-0">
  554. <span className="font-medium">{label}</span>
  555. {isBreak ? (
  556. <span className="ml-2 text-xs opacity-80">
  557. {t('forecast.stockBreakRisk')} — {t('forecast.stockBreakDetail', { days: f.daysRemaining, lt: f.effectiveLeadTimeDays })}
  558. </span>
  559. ) : (
  560. <span className="ml-2 text-xs opacity-80">
  561. {t('forecast.reorderNow')} — {t('forecast.reorderTriggerPassed', { date: f.reorderTriggerDate ? formatDate(f.reorderTriggerDate) : '—' })}
  562. </span>
  563. )}
  564. </div>
  565. <button
  566. onClick={onCart}
  567. 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"
  568. >
  569. <ShoppingCart className="w-3 h-3" /> {t('forecast.order')}
  570. </button>
  571. </div>
  572. );
  573. }
  574. // ── Usage + Projection Chart ──────────────────────────────────────────────────
  575. const CHART_TIMEFRAMES: { label: string; value: ChartDays }[] = [
  576. { label: '1W', value: 7 },
  577. { label: '1M', value: 30 },
  578. { label: '6M', value: 180 },
  579. ];
  580. function UsageChart({ forecasts, days: maxDays, onDaysChange }: {
  581. forecasts: SkuForecast[];
  582. days: ChartDays;
  583. onDaysChange: (d: ChartDays) => void;
  584. }) {
  585. const { t } = useTranslation();
  586. const days = Array.from({ length: maxDays + 1 }, (_, i) => i);
  587. const series = forecasts.map((f, idx) => ({
  588. key: f.group.key,
  589. label: [f.group.brand, f.group.material, f.group.subtype, f.group.colorName].filter(Boolean).join(' '),
  590. color: CHART_COLORS[idx % CHART_COLORS.length],
  591. rop: f.reorderPointG,
  592. points: buildProjectionSeries(f, maxDays),
  593. }));
  594. const chartData = days.map((d) => {
  595. const row: Record<string, number | string> = { day: d, label: formatDateShort(addDays(new Date(), d)) };
  596. for (const s of series) {
  597. const pt = s.points.find((p) => p.day === d);
  598. row[s.key] = pt?.stock ?? 0;
  599. }
  600. return row;
  601. });
  602. const lastNonZeroDay = (() => {
  603. for (let d = maxDays; d >= 0; d--) {
  604. if (series.some((s) => (chartData[d]?.[s.key] as number) > 0)) return d;
  605. }
  606. return maxDays;
  607. })();
  608. const trimmedData = chartData.slice(0, lastNonZeroDay + 1);
  609. const ropLines = series.filter((s) => s.rop > 0);
  610. return (
  611. <div className="bg-bambu-dark-secondary rounded-lg overflow-hidden border border-bambu-dark-tertiary p-4">
  612. <div className="flex items-center gap-2 mb-4">
  613. <TrendingDown className="w-4 h-4 text-bambu-green" />
  614. <h3 className="text-sm font-semibold text-white">{t('forecast.chartTitle')}</h3>
  615. <span className="text-xs text-bambu-gray ml-1 hidden sm:inline">{t('forecast.dashedLinesROP')}</span>
  616. <div className="ml-auto flex items-center bg-bambu-dark-tertiary rounded-lg p-0.5">
  617. {CHART_TIMEFRAMES.map((tf) => (
  618. <button
  619. key={tf.value}
  620. onClick={() => onDaysChange(tf.value)}
  621. className={`px-2.5 py-1 text-xs font-medium rounded-md transition-colors ${
  622. maxDays === tf.value
  623. ? 'bg-bambu-dark-secondary text-white shadow'
  624. : 'text-bambu-gray hover:text-white'
  625. }`}
  626. >
  627. {tf.label}
  628. </button>
  629. ))}
  630. </div>
  631. </div>
  632. <ResponsiveContainer width="100%" height={220}>
  633. <AreaChart data={trimmedData} margin={{ top: 4, right: 8, bottom: 0, left: 0 }}>
  634. <defs>
  635. {series.map((s) => (
  636. <linearGradient key={s.key} id={`grad-${s.key}`} x1="0" y1="0" x2="0" y2="1">
  637. <stop offset="5%" stopColor={s.color} stopOpacity={0.25} />
  638. <stop offset="95%" stopColor={s.color} stopOpacity={0.02} />
  639. </linearGradient>
  640. ))}
  641. </defs>
  642. <CartesianGrid strokeDasharray="3 3" stroke="#374151" strokeOpacity={0.5} />
  643. <XAxis
  644. dataKey="label"
  645. tick={{ fill: '#6B7280', fontSize: 10 }}
  646. interval={Math.max(0, Math.ceil(lastNonZeroDay / 8) - 1)}
  647. axisLine={false}
  648. tickLine={false}
  649. />
  650. <YAxis
  651. tick={{ fill: '#6B7280', fontSize: 10 }}
  652. axisLine={false}
  653. tickLine={false}
  654. tickFormatter={(v: number) => v >= 1000 ? `${(v / 1000).toFixed(1)}kg` : `${v}g`}
  655. width={48}
  656. />
  657. <Tooltip
  658. content={({ label: dateLabel, payload }) => {
  659. if (!payload?.length) return null;
  660. return (
  661. <div style={{ background: '#1a1a2e', border: '1px solid #374151', borderRadius: 8, fontSize: 12, padding: '8px 12px' }}>
  662. <div style={{ color: '#9CA3AF', marginBottom: 6 }}>{dateLabel}</div>
  663. {payload.map((p) => {
  664. const s = series.find((x) => x.key === String(p.dataKey));
  665. if (typeof p.value !== 'number') return null;
  666. return (
  667. <div key={String(p.dataKey)} style={{ display: 'flex', alignItems: 'center', gap: 6, color: '#E5E7EB', marginBottom: 2 }}>
  668. <span style={{ color: s?.color ?? '#9CA3AF', fontSize: 10 }}>●</span>
  669. <span>{s?.label ?? String(p.dataKey)}</span>
  670. <span style={{ color: '#9CA3AF', marginLeft: 4 }}>{p.value}g</span>
  671. </div>
  672. );
  673. })}
  674. </div>
  675. );
  676. }}
  677. />
  678. <Legend
  679. formatter={(value) => {
  680. const s = series.find((x) => x.key === value);
  681. return <span style={{ color: '#9CA3AF', fontSize: 11 }}>{s?.label ?? value}</span>;
  682. }}
  683. />
  684. {series.map((s) => (
  685. <Area
  686. key={s.key}
  687. type="monotone"
  688. dataKey={s.key}
  689. stroke={s.color}
  690. strokeWidth={2}
  691. fill={`url(#grad-${s.key})`}
  692. dot={false}
  693. activeDot={{ r: 3 }}
  694. />
  695. ))}
  696. {ropLines.map((s) => (
  697. <ReferenceLine
  698. key={`rop-${s.key}`}
  699. y={s.rop}
  700. stroke={s.color}
  701. strokeDasharray="4 3"
  702. strokeOpacity={0.6}
  703. />
  704. ))}
  705. </AreaChart>
  706. </ResponsiveContainer>
  707. </div>
  708. );
  709. }
  710. // ── Global lead time setting (compact inline) ─────────────────────────────────
  711. function GlobalLeadTimeSetting({ value, onSave }: { value: number; onSave: (v: number) => void }) {
  712. const { t } = useTranslation();
  713. const [editing, setEditing] = useState(false);
  714. const [input, setInput] = useState(String(value));
  715. function save() {
  716. const v = parseInt(input, 10);
  717. if (isNaN(v) || v < 0) return;
  718. onSave(v);
  719. setEditing(false);
  720. }
  721. return (
  722. <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">
  723. <Info className="w-3.5 h-3.5 flex-shrink-0" aria-label={t('forecast.globalLeadTimeHint')} />
  724. <span className="hidden sm:inline">{t('forecast.globalLeadTime')}:</span>
  725. {editing ? (
  726. <form className="flex items-center gap-1.5" onSubmit={(e) => { e.preventDefault(); save(); }}>
  727. <input
  728. type="number" min={0} max={365}
  729. value={input}
  730. onChange={(e) => setInput(e.target.value)}
  731. 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"
  732. autoFocus
  733. />
  734. <span className="text-bambu-gray">d</span>
  735. <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>
  736. <button type="button" onClick={() => setEditing(false)} className="text-xs text-bambu-gray hover:text-white">✕</button>
  737. </form>
  738. ) : (
  739. <div className="flex items-center gap-1.5">
  740. <span className="font-semibold text-white">{value}d</span>
  741. <button onClick={() => { setInput(String(value)); setEditing(true); }} className="p-0.5 text-bambu-gray hover:text-white rounded transition-colors">
  742. <Edit2 className="w-3 h-3" />
  743. </button>
  744. </div>
  745. )}
  746. </div>
  747. );
  748. }
  749. // ── Forecast Row ──────────────────────────────────────────────────────────────
  750. function ForecastRow({
  751. forecast: f, globalLeadTime, canWrite, onSaved, onCart, showToast,
  752. }: {
  753. forecast: SkuForecast;
  754. globalLeadTime: number;
  755. canWrite: boolean;
  756. onSaved: () => void;
  757. onCart: () => void;
  758. showToast: (msg: string, type: 'success' | 'error') => void;
  759. }) {
  760. const { t } = useTranslation();
  761. const [expanded, setExpanded] = useState(false);
  762. const [editingLead, setEditingLead] = useState(false);
  763. const [editingMargin, setEditingMargin] = useState(false);
  764. const [leadInput, setLeadInput] = useState(String(f.settings?.lead_time_days ?? 0));
  765. const [marginInput, setMarginInput] = useState(String(f.settings?.safety_margin_value ?? 14));
  766. const [marginUnit, setMarginUnit] = useState<'days' | 'g'>(f.settings?.safety_margin_unit ?? 'days');
  767. // Sync inputs when remote settings change and the field is not actively being edited.
  768. useEffect(() => {
  769. if (!editingLead) setLeadInput(String(f.settings?.lead_time_days ?? 0));
  770. }, [f.settings?.lead_time_days, editingLead]);
  771. useEffect(() => {
  772. if (!editingMargin) {
  773. setMarginInput(String(f.settings?.safety_margin_value ?? 14));
  774. setMarginUnit(f.settings?.safety_margin_unit ?? 'days');
  775. }
  776. }, [f.settings?.safety_margin_value, f.settings?.safety_margin_unit, editingMargin]);
  777. const upsertMutation = useMutation({
  778. mutationFn: api.upsertSkuSettings,
  779. onSuccess: () => { onSaved(); showToast(t('forecast.settingsSaved'), 'success'); },
  780. onError: () => showToast(t('forecast.failedSaveSettings'), 'error'),
  781. });
  782. const snoozed = f.settings?.alerts_snoozed ?? false;
  783. const label = [f.group.brand, f.group.material, f.group.subtype, f.group.colorName].filter(Boolean).join(' ');
  784. // Use getSwatchStyle so a Clear (alpha=00) lead spool renders as a
  785. // checkerboard rather than collapsing to solid black (#1545).
  786. const colorStyle = f.group.spools[0]?.rgba ? getSwatchStyle(f.group.spools[0].rgba) : { backgroundColor: '#4B5563' };
  787. const remainPct = f.totalLabelG > 0 ? Math.round((f.totalRemainingG / f.totalLabelG) * 100) : 0;
  788. const daysColor = snoozed ? 'text-bambu-gray'
  789. : f.daysRemaining === null ? 'text-bambu-gray'
  790. : f.stockBreakAlert ? 'text-red-400'
  791. : f.reorderAlert ? 'text-yellow-400'
  792. : f.daysRemaining < 30 ? 'text-yellow-400'
  793. : 'text-green-400';
  794. function upsert(lead: number, marginVal: number, marginUnitArg: 'days' | 'g', alertsSnoozed = snoozed) {
  795. 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 });
  796. }
  797. function toggleSnooze(e: React.MouseEvent) {
  798. e.stopPropagation();
  799. upsert(f.settings?.lead_time_days ?? 0, f.settings?.safety_margin_value ?? 14, f.settings?.safety_margin_unit ?? 'days', !snoozed);
  800. }
  801. const tierBadge = f.rateTier === 'history'
  802. ? <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>
  803. : f.rateTier === 'delta'
  804. ? <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>
  805. : <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>;
  806. const rowAlertBorder = snoozed ? '' : f.stockBreakAlert ? 'bg-red-500/5' : f.reorderAlert ? 'bg-yellow-500/5' : '';
  807. return (
  808. <>
  809. <tr
  810. className={`border-b border-bambu-dark-tertiary/50 cursor-pointer hover:bg-bambu-dark-tertiary/30 transition-colors ${rowAlertBorder} ${snoozed ? 'opacity-50' : ''}`}
  811. onClick={() => setExpanded((e) => !e)}
  812. >
  813. {/* Color dot */}
  814. <td className="px-4 py-3">
  815. <span
  816. className="block w-5 h-5 rounded-full border border-black/20"
  817. style={colorStyle}
  818. />
  819. </td>
  820. {/* SKU */}
  821. <td className="px-4 py-3">
  822. <span className="text-sm text-white">{label}</span>
  823. </td>
  824. {/* Spools */}
  825. <td className="px-4 py-3">
  826. <span className="text-sm text-bambu-gray">{f.totalSpools}</span>
  827. </td>
  828. {/* Stock */}
  829. <td className="px-4 py-3 min-w-[140px]">
  830. <div className="flex items-center gap-2">
  831. <div className="flex-1 h-2 bg-bambu-dark-tertiary rounded-full overflow-hidden">
  832. <div
  833. className={`h-full rounded-full ${remainPct > 50 ? 'bg-bambu-green' : remainPct > 20 ? 'bg-yellow-500' : 'bg-red-500'}`}
  834. style={{ width: `${Math.min(remainPct, 100)}%` }}
  835. />
  836. </div>
  837. <span className="text-xs text-bambu-gray min-w-[40px] text-right">{Math.round(f.totalRemainingG)}g</span>
  838. </div>
  839. </td>
  840. {/* Rate */}
  841. <td className="px-4 py-3">
  842. <div className="flex items-center gap-1.5 flex-wrap">
  843. <span className="text-sm text-white">{f.dailyRateG !== null ? `${f.dailyRateG.toFixed(1)}g/d` : '—'}</span>
  844. {tierBadge}
  845. </div>
  846. </td>
  847. {/* Days left */}
  848. <td className="px-4 py-3">
  849. <span className={`text-sm font-semibold ${daysColor}`}>
  850. {f.daysRemaining !== null ? `${f.daysRemaining}d` : <span className="text-bambu-gray font-normal">—</span>}
  851. </span>
  852. </td>
  853. {/* Empty by */}
  854. <td className="px-4 py-3">
  855. <span className="text-sm text-bambu-gray">
  856. {f.projectedEmptyDate ? formatDate(f.projectedEmptyDate) : '—'}
  857. </span>
  858. </td>
  859. {/* Reorder by */}
  860. <td className="px-4 py-3">
  861. <span className={`text-sm ${!snoozed && f.reorderAlert ? 'text-yellow-400' : 'text-bambu-gray'}`}>
  862. {f.reorderTriggerDate ? formatDate(f.reorderTriggerDate) : '—'}
  863. </span>
  864. </td>
  865. {/* Actions */}
  866. <td className="px-4 py-3" onClick={(e) => e.stopPropagation()}>
  867. <div className="flex items-center justify-end gap-1">
  868. {canWrite && (
  869. <button
  870. onClick={onCart}
  871. className="p-1.5 text-bambu-gray hover:text-bambu-green rounded transition-colors"
  872. title={t('forecast.addToCart')}
  873. >
  874. <ShoppingCart className="w-4 h-4" />
  875. </button>
  876. )}
  877. {!snoozed && (f.stockBreakAlert ? (
  878. <AlertTriangle className="w-4 h-4 text-red-400" aria-label={t('forecast.stockBreakRisk')} />
  879. ) : f.reorderAlert ? (
  880. <AlertTriangle className="w-4 h-4 text-yellow-400" aria-label={t('forecast.reorderNow')} />
  881. ) : f.daysRemaining !== null ? (
  882. <Check className="w-4 h-4 text-bambu-green/50" />
  883. ) : null)}
  884. {canWrite && (
  885. <button
  886. onClick={toggleSnooze}
  887. className={`p-1 rounded transition-colors ${snoozed ? 'text-amber-400/80 hover:text-amber-300' : 'text-slate-400 hover:text-white'}`}
  888. title={t(snoozed ? 'forecast.alertsEnabled' : 'forecast.alertsSnoozed')}
  889. >
  890. <BellOff className="w-3.5 h-3.5" />
  891. </button>
  892. )}
  893. <button
  894. onClick={(e) => { e.stopPropagation(); setExpanded((v) => !v); }}
  895. className="p-1.5 text-bambu-gray hover:text-white rounded transition-colors"
  896. >
  897. {expanded ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
  898. </button>
  899. </div>
  900. </td>
  901. </tr>
  902. {/* ── Expanded detail row ── */}
  903. {expanded && (
  904. <tr className="bg-bambu-dark-tertiary/10">
  905. <td colSpan={9} className="px-6 py-4">
  906. <div className="space-y-3">
  907. {/* Single compact row: read-only stats + editable settings */}
  908. <div className={`grid gap-2 ${canWrite ? 'grid-cols-2 sm:grid-cols-4' : 'grid-cols-2'}`}>
  909. <LogisticStat
  910. label={t('forecast.effectiveLeadTime')}
  911. value={`${f.effectiveLeadTimeDays}d`}
  912. hint={t('forecast.effectiveLeadTimeHint', { global: globalLeadTime, sku: f.settings?.lead_time_days ?? 0 })}
  913. />
  914. <LogisticStat
  915. label={t('forecast.reorderPoint')}
  916. value={`${Math.round(f.reorderPointG)}g`}
  917. hint={t('forecast.reorderPointHint')}
  918. />
  919. {canWrite && (
  920. <>
  921. <SettingField
  922. label={t('forecast.skuLeadTimeOverride')}
  923. hint={t('forecast.skuLeadTimeHint')}
  924. unit={t('forecast.leadTime')}
  925. editing={editingLead}
  926. value={f.settings?.lead_time_days ?? 0}
  927. inputValue={leadInput}
  928. onInputChange={setLeadInput}
  929. onEdit={() => { setLeadInput(String(f.settings?.lead_time_days ?? 0)); setEditingLead(true); }}
  930. onSave={() => {
  931. const v = parseInt(leadInput, 10);
  932. if (!isNaN(v) && v >= 0) { upsert(v, f.settings?.safety_margin_value ?? 14, marginUnit); setEditingLead(false); }
  933. }}
  934. onCancel={() => setEditingLead(false)}
  935. isPending={upsertMutation.isPending}
  936. saveLabel={t('forecast.save')}
  937. cancelLabel={t('forecast.cancel')}
  938. />
  939. <SafetyMarginField
  940. value={f.settings?.safety_margin_value ?? 14}
  941. unit={marginUnit}
  942. editing={editingMargin}
  943. inputValue={marginInput}
  944. dailyRateG={f.dailyRateG}
  945. onInputChange={setMarginInput}
  946. onUnitChange={(u) => setMarginUnit(u)}
  947. onEdit={() => { setMarginInput(String(f.settings?.safety_margin_value ?? 14)); setMarginUnit(f.settings?.safety_margin_unit ?? 'days'); setEditingMargin(true); }}
  948. onSave={() => {
  949. const v = parseInt(marginInput, 10);
  950. if (!isNaN(v) && v >= 0) { upsert(f.settings?.lead_time_days ?? 0, v, marginUnit); setEditingMargin(false); }
  951. }}
  952. onCancel={() => setEditingMargin(false)}
  953. isPending={upsertMutation.isPending}
  954. saveLabel={t('forecast.save')}
  955. cancelLabel={t('forecast.cancel')}
  956. safetyMarginLabel={t('forecast.safetyMarginLabel')}
  957. />
  958. </>
  959. )}
  960. </div>
  961. {/* Individual spools — shown when group has >1 spool */}
  962. {f.group.spools.length > 1 && (
  963. <div className="border-t border-bambu-dark-tertiary pt-3">
  964. <p className="text-xs text-bambu-gray mb-2">{t('forecast.individualSpools')}</p>
  965. <div className="bg-bambu-dark-secondary rounded-lg overflow-hidden border border-bambu-dark-tertiary">
  966. <table className="w-full">
  967. <thead>
  968. <tr className="border-b border-bambu-dark-tertiary bg-bambu-dark-tertiary/30">
  969. <th className="px-4 py-3 text-left text-xs font-medium text-bambu-gray uppercase tracking-wide">#</th>
  970. <th className="px-4 py-3 text-left text-xs font-medium text-bambu-gray uppercase tracking-wide">{t('inventory.remaining')}</th>
  971. <th className="px-4 py-3 text-left text-xs font-medium text-bambu-gray uppercase tracking-wide">{t('inventory.used')}</th>
  972. <th className="px-4 py-3 text-left text-xs font-medium text-bambu-gray uppercase tracking-wide">{t('forecast.labelWeight')}</th>
  973. </tr>
  974. </thead>
  975. <tbody className="divide-y divide-bambu-dark-tertiary">
  976. {f.group.spools.map((s) => {
  977. const remaining = Math.max(0, s.label_weight - s.weight_used);
  978. const pct = s.label_weight > 0 ? (remaining / s.label_weight) * 100 : 0;
  979. return (
  980. <tr key={s.id} className="hover:bg-bambu-dark-tertiary/30 transition-colors">
  981. <td className="px-4 py-2">
  982. <span className="text-xs font-mono text-bambu-gray/70">#{s.id}</span>
  983. </td>
  984. <td className="px-4 py-2">
  985. <div className="flex items-center gap-3">
  986. <div className="w-24 h-1.5 bg-bambu-dark-tertiary rounded-full overflow-hidden flex-shrink-0">
  987. <div
  988. className={`h-full rounded-full ${pct > 50 ? 'bg-bambu-green' : pct > 20 ? 'bg-yellow-500' : 'bg-red-500'}`}
  989. style={{ width: `${Math.min(pct, 100)}%` }}
  990. />
  991. </div>
  992. <span className="text-sm text-white">{Math.round(remaining)}g</span>
  993. </div>
  994. </td>
  995. <td className="px-4 py-2">
  996. <span className="text-sm text-bambu-gray">{Math.round(Math.max(0, s.weight_used - (s.weight_used_baseline ?? 0)))}g</span>
  997. </td>
  998. <td className="px-4 py-2">
  999. <span className="text-sm text-bambu-gray">{s.label_weight}g</span>
  1000. </td>
  1001. </tr>
  1002. );
  1003. })}
  1004. </tbody>
  1005. </table>
  1006. </div>
  1007. </div>
  1008. )}
  1009. </div>
  1010. </td>
  1011. </tr>
  1012. )}
  1013. </>
  1014. );
  1015. }
  1016. // ── Logistic stat chip ────────────────────────────────────────────────────────
  1017. function LogisticStat({ label, value, hint }: { label: string; value: string; hint: string }) {
  1018. return (
  1019. <div className="bg-bambu-dark-tertiary/40 rounded-lg p-3" title={hint}>
  1020. <div className="text-xs font-medium text-white mb-1">{label}</div>
  1021. <div className="text-lg font-semibold text-white">{value}</div>
  1022. </div>
  1023. );
  1024. }
  1025. // ── Setting field ─────────────────────────────────────────────────────────────
  1026. function SettingField({
  1027. label, hint, unit, editing, value, inputValue,
  1028. onInputChange, onEdit, onSave, onCancel, isPending,
  1029. saveLabel = 'Save', cancelLabel = 'Cancel',
  1030. }: {
  1031. label: string; hint: string; unit: string; editing: boolean;
  1032. value: number; inputValue: string;
  1033. onInputChange: (v: string) => void; onEdit: () => void;
  1034. onSave: () => void; onCancel: () => void; isPending: boolean;
  1035. saveLabel?: string; cancelLabel?: string;
  1036. }) {
  1037. return (
  1038. <div className="bg-bambu-dark-tertiary/40 rounded-lg p-3 space-y-1">
  1039. <div className="flex items-center gap-1.5">
  1040. <span className="text-xs font-medium text-white">{label}</span>
  1041. <span title={hint}><Info className="w-3 h-3 text-bambu-gray/50" /></span>
  1042. </div>
  1043. {editing ? (
  1044. <form className="flex items-center gap-2" onSubmit={(e) => { e.preventDefault(); onSave(); }}>
  1045. <input
  1046. type="number" min={0} max={365}
  1047. value={inputValue} onChange={(e) => onInputChange(e.target.value)}
  1048. 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"
  1049. autoFocus disabled={isPending}
  1050. />
  1051. <span className="text-xs text-bambu-gray">{unit}</span>
  1052. <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>
  1053. <button type="button" onClick={onCancel} disabled={isPending} className="px-2 py-1 text-xs text-bambu-gray hover:text-white">{cancelLabel}</button>
  1054. </form>
  1055. ) : (
  1056. <div className="flex items-center gap-2">
  1057. <span className="text-lg font-semibold text-white">{value}</span>
  1058. <span className="text-xs text-bambu-gray">{unit}</span>
  1059. <button onClick={onEdit} className="p-1 text-bambu-gray hover:text-white rounded transition-colors"><Edit2 className="w-3 h-3" /></button>
  1060. </div>
  1061. )}
  1062. </div>
  1063. );
  1064. }
  1065. // ── Safety margin field (dual unit: days | grams) ────────────────────────────
  1066. function SafetyMarginField({
  1067. value, unit, editing, inputValue, dailyRateG,
  1068. onInputChange, onUnitChange, onEdit, onSave, onCancel, isPending,
  1069. saveLabel = 'Save', cancelLabel = 'Cancel', safetyMarginLabel = 'Safety Margin',
  1070. }: {
  1071. value: number; unit: 'days' | 'g'; editing: boolean; inputValue: string;
  1072. dailyRateG: number | null;
  1073. onInputChange: (v: string) => void; onUnitChange: (u: 'days' | 'g') => void;
  1074. onEdit: () => void; onSave: () => void; onCancel: () => void; isPending: boolean;
  1075. saveLabel?: string; cancelLabel?: string; safetyMarginLabel?: string;
  1076. }) {
  1077. const { t } = useTranslation();
  1078. const displayG = unit === 'g' ? value : (dailyRateG !== null ? Math.round(dailyRateG * value) : null);
  1079. const hint = unit === 'days'
  1080. ? t('forecast.safetyMarginHintDays', {
  1081. approx: displayG !== null ? t('forecast.safetyMarginHintDaysApprox', { g: displayG }) : '',
  1082. })
  1083. : t('forecast.safetyMarginHintG', {
  1084. approx: dailyRateG !== null ? t('forecast.safetyMarginHintGApprox', { days: Math.round(value / dailyRateG) }) : '',
  1085. });
  1086. return (
  1087. <div className="bg-bambu-dark-tertiary/40 rounded-lg p-3 space-y-1">
  1088. <div className="flex items-center gap-1.5">
  1089. <span className="text-xs font-medium text-white">{safetyMarginLabel}</span>
  1090. <span title={hint}><Info className="w-3 h-3 text-bambu-gray/50" /></span>
  1091. </div>
  1092. {editing ? (
  1093. <form className="flex items-center gap-2 flex-wrap" onSubmit={(e) => { e.preventDefault(); onSave(); }}>
  1094. <input
  1095. type="number" min={0} max={unit === 'g' ? 10000 : 365}
  1096. value={inputValue} onChange={(e) => onInputChange(e.target.value)}
  1097. 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"
  1098. autoFocus disabled={isPending}
  1099. />
  1100. {/* Unit toggle */}
  1101. <div className="flex bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded overflow-hidden text-xs">
  1102. <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>
  1103. <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>
  1104. </div>
  1105. <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>
  1106. <button type="button" onClick={onCancel} disabled={isPending} className="px-2 py-1 text-xs text-bambu-gray hover:text-white">{cancelLabel}</button>
  1107. </form>
  1108. ) : (
  1109. <div className="flex items-center gap-2">
  1110. <span className="text-lg font-semibold text-white">{value}</span>
  1111. <span className="text-xs text-bambu-gray">{unit}</span>
  1112. {displayG !== null && unit === 'days' && (
  1113. <span className="text-lg font-semibold text-white">≈ {displayG}g</span>
  1114. )}
  1115. {unit === 'g' && dailyRateG !== null && (
  1116. <span className="text-lg font-semibold text-white">≈ {Math.round(value / dailyRateG)}d</span>
  1117. )}
  1118. <button onClick={onEdit} className="p-1 text-bambu-gray hover:text-white rounded transition-colors"><Edit2 className="w-3 h-3" /></button>
  1119. </div>
  1120. )}
  1121. </div>
  1122. );
  1123. }
  1124. // ── Shopping list panel ───────────────────────────────────────────────────────
  1125. function ShoppingListPanel({
  1126. items, forecasts, globalLeadTime, canWrite, onClose, onRemove, onClear,
  1127. }: {
  1128. items: ShoppingListItem[];
  1129. forecasts: SkuForecast[];
  1130. globalLeadTime: number;
  1131. canWrite: boolean;
  1132. onClose: () => void;
  1133. onRemove: (id: number) => void;
  1134. onClear: () => void;
  1135. }) {
  1136. const { t } = useTranslation();
  1137. const queryClient = useQueryClient();
  1138. const [view, setView] = useState<'list' | 'logistics'>('list');
  1139. const statusMutation = useMutation({
  1140. mutationFn: async ({ id, status, item, avgSpoolG }: {
  1141. id: number;
  1142. status: 'pending' | 'purchased' | 'received';
  1143. item?: ShoppingListItem;
  1144. avgSpoolG?: number;
  1145. }) => {
  1146. await api.updateShoppingListStatus(id, status);
  1147. if (status === 'received' && item) {
  1148. // Add received spools to stock category
  1149. const spoolWeight = avgSpoolG ?? 1000;
  1150. const spoolBase: Parameters<typeof api.bulkCreateSpools>[0] = {
  1151. material: item.material,
  1152. subtype: item.subtype,
  1153. brand: item.brand,
  1154. label_weight: spoolWeight,
  1155. core_weight: 0,
  1156. core_weight_catalog_id: null,
  1157. color_name: item.color_name, rgba: null, extra_colors: null, effect_type: null,
  1158. nozzle_temp_min: null, nozzle_temp_max: null,
  1159. note: item.note ?? null,
  1160. tag_uid: null, tray_uuid: null,
  1161. data_origin: 'manual', tag_type: null,
  1162. cost_per_kg: null,
  1163. last_scale_weight: null, last_weighed_at: null,
  1164. weight_used: 0,
  1165. slicer_filament: null, slicer_filament_name: null,
  1166. added_full: null, last_used: null, encode_time: null,
  1167. category: 'Stock',
  1168. low_stock_threshold_pct: null,
  1169. };
  1170. await api.bulkCreateSpools(spoolBase, item.quantity_spools);
  1171. await api.removeFromShoppingList(id);
  1172. }
  1173. },
  1174. onSuccess: () => {
  1175. queryClient.invalidateQueries({ queryKey: ['shopping-list'] });
  1176. queryClient.invalidateQueries({ queryKey: ['spools'] });
  1177. },
  1178. });
  1179. // Build a forecast lookup keyed by (material||subtype||brand)
  1180. const forecastMap = useMemo(() => {
  1181. const m = new Map<string, SkuForecast>();
  1182. for (const f of forecasts) m.set(f.group.key, f);
  1183. return m;
  1184. }, [forecasts]);
  1185. // Resolve a forecast for each cart item
  1186. const cartForecasts = useMemo(() =>
  1187. items.map((item) => ({
  1188. item,
  1189. forecast: forecastMap.get(skuKey(item.material, item.subtype, item.brand, item.color_name)) ?? null,
  1190. })),
  1191. [items, forecastMap]
  1192. );
  1193. // Items where stock break before replenishment is detected
  1194. const breakAlerts = useMemo(() =>
  1195. cartForecasts.filter(({ forecast: f }) => {
  1196. if (!f || f.dailyRateG === null) return false;
  1197. // Stock runs out before the lead time window ends
  1198. return f.stockBreakAlert || (f.daysRemaining !== null && f.daysRemaining <= f.effectiveLeadTimeDays);
  1199. }),
  1200. [cartForecasts]
  1201. );
  1202. function downloadCsv() {
  1203. 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')];
  1204. const rows = items.map((i) => {
  1205. const f = forecastMap.get(skuKey(i.material, i.subtype, i.brand, i.color_name)) ?? null;
  1206. const avgSpoolG = f && f.totalSpools > 0 ? f.totalLabelG / f.totalSpools : 1000;
  1207. const totalWeightG = Math.round(i.quantity_spools * avgSpoolG);
  1208. const lt = f?.effectiveLeadTimeDays ?? globalLeadTime ?? 0;
  1209. const restock = lt > 0 ? formatDate(addDays(new Date(), lt)) : '';
  1210. return [
  1211. i.quantity_spools,
  1212. i.material,
  1213. i.brand ?? '',
  1214. i.subtype ?? '',
  1215. i.color_name ?? '',
  1216. totalWeightG,
  1217. lt || '',
  1218. restock,
  1219. i.status,
  1220. i.note ?? '',
  1221. ].map((v) => `"${String(v).replace(/"/g, '""')}"`).join(',');
  1222. });
  1223. const csv = [headers.join(','), ...rows].join('\n');
  1224. const blob = new Blob([csv], { type: 'text/csv' });
  1225. const url = URL.createObjectURL(blob);
  1226. const a = document.createElement('a');
  1227. a.href = url;
  1228. a.download = `shopping-list-${new Date().toISOString().slice(0, 10)}.csv`;
  1229. a.click();
  1230. setTimeout(() => URL.revokeObjectURL(url), 100);
  1231. }
  1232. return (
  1233. <div className="bg-bambu-dark-secondary rounded-lg overflow-hidden border border-bambu-dark-tertiary">
  1234. {/* Header */}
  1235. <div className="flex items-center justify-between px-4 py-3 border-b border-bambu-dark-tertiary bg-bambu-dark-tertiary/30">
  1236. <div className="flex items-center gap-3">
  1237. <ShoppingCart className="w-4 h-4 text-bambu-green" />
  1238. <h3 className="text-sm font-semibold text-white">{t('forecast.shoppingList')}</h3>
  1239. <span className="text-xs text-bambu-gray">{t('forecast.shoppingListItems', { count: items.length })}</span>
  1240. {/* View toggle */}
  1241. {items.length > 0 && (
  1242. <div className="flex bg-bambu-dark-tertiary rounded-md p-0.5 ml-1">
  1243. <button
  1244. onClick={() => setView('list')}
  1245. 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'}`}
  1246. >
  1247. <Package className="w-3 h-3" />
  1248. {t('forecast.listView')}
  1249. </button>
  1250. <button
  1251. onClick={() => setView('logistics')}
  1252. 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'}`}
  1253. >
  1254. <BarChart2 className="w-3 h-3" />
  1255. {t('forecast.logisticsView')}
  1256. {breakAlerts.length > 0 && (
  1257. <span className="w-3.5 h-3.5 rounded-full bg-red-500 text-white text-[9px] font-bold flex items-center justify-center">
  1258. {breakAlerts.length}
  1259. </span>
  1260. )}
  1261. </button>
  1262. </div>
  1263. )}
  1264. </div>
  1265. <div className="flex items-center gap-2">
  1266. {items.length > 0 && (
  1267. <>
  1268. <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">
  1269. <Download className="w-3 h-3" />
  1270. {t('forecast.downloadCsv')}
  1271. </button>
  1272. {canWrite && (
  1273. <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">
  1274. {t('forecast.clearAll')}
  1275. </button>
  1276. )}
  1277. </>
  1278. )}
  1279. <button onClick={onClose} className="p-1 text-bambu-gray hover:text-white transition-colors"><X className="w-4 h-4" /></button>
  1280. </div>
  1281. </div>
  1282. {items.length === 0 ? (
  1283. <div className="flex flex-col items-center py-8 text-bambu-gray">
  1284. <Package className="w-8 h-8 mb-2 opacity-30" />
  1285. <p className="text-sm">{t('forecast.shoppingListEmpty')}</p>
  1286. </div>
  1287. ) : view === 'list' ? (
  1288. <div className="overflow-x-auto">
  1289. <table className="w-full">
  1290. <thead>
  1291. <tr className="border-b border-bambu-dark-tertiary bg-bambu-dark-tertiary/20">
  1292. <th className="px-4 py-3 text-left text-xs font-medium text-bambu-gray uppercase tracking-wide">{t('forecast.qty')}</th>
  1293. <th className="px-4 py-3 text-left text-xs font-medium text-bambu-gray uppercase tracking-wide">{t('forecast.material')}</th>
  1294. <th className="px-4 py-3 text-left text-xs font-medium text-bambu-gray uppercase tracking-wide">{t('forecast.weight')}</th>
  1295. <th className="px-4 py-3 text-left text-xs font-medium text-bambu-gray uppercase tracking-wide">{t('forecast.leadTime')}</th>
  1296. <th className="px-4 py-3 text-left text-xs font-medium text-bambu-gray uppercase tracking-wide">{t('forecast.expectedRestock')}</th>
  1297. <th className="px-4 py-3 text-left text-xs font-medium text-bambu-gray uppercase tracking-wide">{t('forecast.status')}</th>
  1298. <th className="px-4 py-3 text-left text-xs font-medium text-bambu-gray uppercase tracking-wide">{t('forecast.note')}</th>
  1299. <th className="px-4 py-2 text-right text-xs font-medium text-bambu-gray uppercase tracking-wide">{t('forecast.actions')}</th>
  1300. </tr>
  1301. </thead>
  1302. <tbody className="divide-y divide-bambu-dark-tertiary">
  1303. {items.map((item) => {
  1304. const lbl = [item.brand, item.material, item.subtype, item.color_name].filter(Boolean).join(' ');
  1305. const hasBreak = breakAlerts.some((a) => a.item.id === item.id);
  1306. const f = forecastMap.get(skuKey(item.material, item.subtype, item.brand, item.color_name)) ?? null;
  1307. const avgSpoolG = f && f.totalSpools > 0 ? f.totalLabelG / f.totalSpools : 1000;
  1308. const totalWeightG = Math.round(item.quantity_spools * avgSpoolG);
  1309. const lt = f?.effectiveLeadTimeDays ?? globalLeadTime ?? 0;
  1310. const restockDate = lt > 0 ? addDays(new Date(), lt) : null;
  1311. const isPurchased = item.status === 'purchased' || item.status === 'received';
  1312. const isReceived = item.status === 'received';
  1313. const isMutating = statusMutation.isPending;
  1314. return (
  1315. <tr key={item.id} className={`hover:bg-bambu-dark-tertiary/30 transition-colors ${hasBreak && !isPurchased ? 'bg-red-500/5' : ''}`}>
  1316. {/* Qty */}
  1317. <td className="px-4 py-2.5">
  1318. <span className="text-sm font-semibold text-bambu-green">{item.quantity_spools}×</span>
  1319. </td>
  1320. {/* Material */}
  1321. <td className="px-4 py-2.5">
  1322. <div className="flex items-center gap-2">
  1323. <span className="text-sm text-white">{lbl}</span>
  1324. {hasBreak && !isPurchased && (
  1325. <AlertTriangle className="w-3.5 h-3.5 text-red-400 flex-shrink-0" aria-label={t('forecast.stockBreakBefore')} />
  1326. )}
  1327. </div>
  1328. </td>
  1329. {/* Weight */}
  1330. <td className="px-4 py-2.5">
  1331. <span className="text-sm text-white">
  1332. {totalWeightG >= 1000 ? `${(totalWeightG / 1000).toFixed(1)}kg` : `${totalWeightG}g`}
  1333. </span>
  1334. </td>
  1335. {/* Lead time */}
  1336. <td className="px-4 py-2.5">
  1337. <span className="text-sm text-bambu-gray">{lt > 0 ? `${lt}d` : '—'}</span>
  1338. </td>
  1339. {/* Expected restock */}
  1340. <td className="px-4 py-2.5">
  1341. <span className="text-sm text-bambu-gray">
  1342. {restockDate ? formatDate(restockDate) : '—'}
  1343. </span>
  1344. </td>
  1345. {/* Status badge — read-only */}
  1346. <td className="px-4 py-2.5">
  1347. <span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${
  1348. isReceived ? 'bg-bambu-green/20 text-bambu-green' :
  1349. isPurchased ? 'bg-blue-400/20 text-blue-400' :
  1350. 'bg-bambu-dark-tertiary text-bambu-gray'
  1351. }`}>
  1352. {isReceived ? t('forecast.received') : isPurchased ? t('forecast.purchased') : t('forecast.pending')}
  1353. </span>
  1354. </td>
  1355. {/* Note */}
  1356. <td className="px-4 py-2.5">
  1357. <span className="text-xs text-bambu-gray">{item.note || '—'}</span>
  1358. </td>
  1359. {/* Actions */}
  1360. <td className="px-4 py-2.5">
  1361. <div className="flex items-center justify-end gap-1">
  1362. {canWrite && (
  1363. <>
  1364. {/* Purchased icon — available when pending */}
  1365. <button
  1366. onClick={() => statusMutation.mutate({ id: item.id, status: isPurchased ? 'pending' : 'purchased' })}
  1367. disabled={isMutating || isReceived}
  1368. title={isPurchased ? t('forecast.resetToPending') : t('forecast.markPurchased')}
  1369. className={`p-1.5 rounded transition-colors disabled:opacity-30 ${
  1370. isPurchased
  1371. ? 'text-blue-400 hover:text-blue-300'
  1372. : 'text-blue-400/50 hover:text-blue-400'
  1373. }`}
  1374. >
  1375. {isPurchased ? <RotateCcw className="w-4 h-4" /> : <CreditCard className="w-4 h-4" />}
  1376. </button>
  1377. {/* Received icon — available only after purchasing */}
  1378. <button
  1379. onClick={() => statusMutation.mutate({ id: item.id, status: 'received', item, avgSpoolG })}
  1380. disabled={isMutating || !isPurchased || isReceived}
  1381. title={t('forecast.markReceived')}
  1382. className="p-1.5 rounded transition-colors text-bambu-green/50 hover:text-bambu-green disabled:opacity-30"
  1383. >
  1384. <PackageCheck className="w-4 h-4" />
  1385. </button>
  1386. {/* Delete */}
  1387. <button
  1388. onClick={() => onRemove(item.id)}
  1389. className="p-1 text-bambu-gray hover:text-red-400 transition-colors"
  1390. title={t('forecast.remove')}
  1391. >
  1392. <Trash2 className="w-3.5 h-3.5" />
  1393. </button>
  1394. </>
  1395. )}
  1396. </div>
  1397. </td>
  1398. </tr>
  1399. );
  1400. })}
  1401. </tbody>
  1402. </table>
  1403. </div>
  1404. ) : (
  1405. /* Logistics view — exclude received items */
  1406. <div className="divide-y divide-bambu-dark-tertiary">
  1407. {cartForecasts.filter(({ item }) => item.status !== 'received').map(({ item, forecast }) => (
  1408. <CartLogisticsRow
  1409. key={item.id}
  1410. item={item}
  1411. forecast={forecast}
  1412. globalLeadTime={globalLeadTime}
  1413. canWrite={canWrite}
  1414. onRemove={() => onRemove(item.id)}
  1415. />
  1416. ))}
  1417. </div>
  1418. )}
  1419. </div>
  1420. );
  1421. }
  1422. // ── Cart logistics row ────────────────────────────────────────────────────────
  1423. function CartLogisticsRow({
  1424. item, forecast: f, globalLeadTime, canWrite, onRemove,
  1425. }: {
  1426. item: ShoppingListItem;
  1427. forecast: SkuForecast | null;
  1428. globalLeadTime: number;
  1429. canWrite: boolean;
  1430. onRemove: () => void;
  1431. }) {
  1432. const { t } = useTranslation();
  1433. const label = [item.brand, item.material, item.subtype, item.color_name].filter(Boolean).join(' ');
  1434. // Build a timeline showing stock depletion, arrival bump, then post-arrival depletion.
  1435. // Two points are inserted at day `lt` (just-before and just-after arrival) so the
  1436. // chart shows a clean vertical step rather than a smooth interpolated slope.
  1437. const chartData = useMemo(() => {
  1438. if (!f || f.dailyRateG === null || f.dailyRateG <= 0) return null;
  1439. const rate = f.dailyRateG;
  1440. const lt = f.effectiveLeadTimeDays;
  1441. const avgSpoolG = f.totalSpools > 0 ? f.totalLabelG / f.totalSpools : 1000;
  1442. const arrivalG = item.quantity_spools * avgSpoolG;
  1443. const stockAtArrival = Math.max(0, f.totalRemainingG - rate * lt);
  1444. const peakG = stockAtArrival + arrivalG;
  1445. const daysPostArrival = Math.ceil(peakG / rate);
  1446. const clampedMax = Math.min(lt + daysPostArrival + 5, 365);
  1447. type Point = { day: number; label: string; stock: number; rop: number; safetyStock: number; arrival?: boolean };
  1448. const points: Point[] = [];
  1449. for (let d = 0; d <= clampedMax; d++) {
  1450. const dateLabel = formatDateShort(addDays(new Date(), d));
  1451. if (d === lt) {
  1452. // Just before arrival — pre-bump stock level
  1453. points.push({ day: d, label: dateLabel, stock: Math.round(stockAtArrival), rop: Math.round(f.reorderPointG), safetyStock: Math.round(f.safetyStockG) });
  1454. // Just after arrival — post-bump peak (same x label, creates the vertical step)
  1455. points.push({ day: d, label: dateLabel, stock: Math.round(peakG), rop: Math.round(f.reorderPointG), safetyStock: Math.round(f.safetyStockG), arrival: true });
  1456. } else {
  1457. const stock = d < lt
  1458. ? Math.max(0, f.totalRemainingG - rate * d)
  1459. : Math.max(0, peakG - rate * (d - lt));
  1460. points.push({ day: d, label: dateLabel, stock: Math.round(stock), rop: Math.round(f.reorderPointG), safetyStock: Math.round(f.safetyStockG) });
  1461. }
  1462. }
  1463. return { points, lt, maxDays: clampedMax, arrivalG, peakG, stockAtArrival };
  1464. }, [f, item.quantity_spools]);
  1465. // Determine break scenario: stock hits zero before arrival
  1466. const stockBreaksAt = useMemo(() => {
  1467. if (!f || f.dailyRateG === null || f.dailyRateG <= 0) return null;
  1468. const zeroDay = Math.floor(f.totalRemainingG / f.dailyRateG);
  1469. if (zeroDay < f.effectiveLeadTimeDays) return zeroDay;
  1470. return null;
  1471. }, [f]);
  1472. const hasBreak = stockBreaksAt !== null;
  1473. return (
  1474. <div className={`px-4 py-4 ${hasBreak ? 'bg-red-500/5' : ''}`}>
  1475. {/* Row header */}
  1476. <div className="flex items-center justify-between mb-3">
  1477. <div className="flex items-center gap-2 min-w-0">
  1478. {hasBreak
  1479. ? <AlertTriangle className="w-4 h-4 text-red-400 flex-shrink-0" />
  1480. : <Check className="w-4 h-4 text-bambu-green/60 flex-shrink-0" />
  1481. }
  1482. <span className="text-sm font-medium text-white truncate">{label}</span>
  1483. <span className="text-xs text-bambu-gray flex-shrink-0">{t('forecast.spoolCount', { count: item.quantity_spools })} ordered</span>
  1484. </div>
  1485. {canWrite && (
  1486. <button onClick={onRemove} className="p-1 text-bambu-gray hover:text-red-400 transition-colors flex-shrink-0">
  1487. <Trash2 className="w-3.5 h-3.5" />
  1488. </button>
  1489. )}
  1490. </div>
  1491. {/* Break alert */}
  1492. {hasBreak && (
  1493. <div className="mb-3 px-3 py-2 rounded-lg bg-red-500/10 border border-red-500/20 text-xs text-red-300">
  1494. <span className="font-medium">{t('forecast.stockBreakIn', { days: stockBreaksAt })}</span>
  1495. {' '}{t('forecast.stockRunsOutBefore', { lt: f!.effectiveLeadTimeDays })}
  1496. {f!.dailyRateG !== null && (
  1497. <span> {t('forecast.atRate', { rate: f!.dailyRateG.toFixed(1) })}{' '}
  1498. <span className="font-semibold">{t('forecast.moreSpools', { count: Math.ceil((f!.dailyRateG * f!.effectiveLeadTimeDays - f!.totalRemainingG) / ((f!.totalLabelG / (f!.totalSpools || 1)) || 1000)) })}</span>
  1499. {' '}{t('forecast.bridgeGap')}
  1500. </span>
  1501. )}
  1502. </div>
  1503. )}
  1504. {/* No forecast data */}
  1505. {(!f || f.dailyRateG === null) ? (
  1506. <div className="py-4 text-center text-xs text-bambu-gray">
  1507. {t('forecast.noUsageData')}
  1508. </div>
  1509. ) : chartData ? (
  1510. <>
  1511. {/* Key stats row */}
  1512. <div className="grid grid-cols-5 gap-2 mb-3">
  1513. <div className="bg-bambu-dark-tertiary/40 rounded-lg px-2.5 py-2 text-center">
  1514. <div className="text-xs text-bambu-gray mb-0.5">{t('forecast.stock')}</div>
  1515. <div className="text-sm font-semibold text-white">{Math.round(f.totalRemainingG)}g</div>
  1516. </div>
  1517. <div className="bg-bambu-dark-tertiary/40 rounded-lg px-2.5 py-2 text-center">
  1518. <div className="text-xs text-bambu-gray mb-0.5">{t('forecast.leadTime')}</div>
  1519. <div className="text-sm font-semibold text-white">{f.effectiveLeadTimeDays}d</div>
  1520. <div className="text-[10px] text-bambu-gray/60">max(g:{globalLeadTime}, sku:{f.settings?.lead_time_days ?? 0})</div>
  1521. </div>
  1522. <div className="bg-bambu-dark-tertiary/40 rounded-lg px-2.5 py-2 text-center">
  1523. <div className="text-xs text-bambu-gray mb-0.5">{t('forecast.safetyMarginLabel')}</div>
  1524. <div className="text-sm font-semibold text-white">{Math.round(f.safetyStockG)}g</div>
  1525. </div>
  1526. <div className={`rounded-lg px-2.5 py-2 text-center ${hasBreak ? 'bg-red-500/15' : 'bg-bambu-dark-tertiary/40'}`}>
  1527. <div className="text-xs text-bambu-gray mb-0.5">{t('forecast.daysLeft')}</div>
  1528. <div className={`text-sm font-semibold ${hasBreak ? 'text-red-400' : 'text-green-400'}`}>
  1529. {f.daysRemaining ?? '—'}d
  1530. </div>
  1531. </div>
  1532. {chartData && (
  1533. <div className="bg-bambu-green/15 rounded-lg px-2.5 py-2 text-center">
  1534. <div className="text-xs text-bambu-gray mb-0.5">{t('forecast.onArrival')}</div>
  1535. <div className="text-sm font-semibold text-bambu-green">{Math.round(chartData.arrivalG)}g</div>
  1536. <div className="text-[10px] text-bambu-gray/60">+{t('forecast.spoolCount', { count: item.quantity_spools })}</div>
  1537. </div>
  1538. )}
  1539. </div>
  1540. {/* Chart */}
  1541. <ResponsiveContainer width="100%" height={180}>
  1542. <AreaChart data={chartData.points} margin={{ top: 8, right: 8, bottom: 0, left: 0 }}>
  1543. <defs>
  1544. {/* Pre-arrival fill: red if break, amber if tight, green if ok */}
  1545. <linearGradient id={`cart-pre-${item.id}`} x1="0" y1="0" x2="0" y2="1">
  1546. <stop offset="5%" stopColor={hasBreak ? '#EF4444' : '#1DB954'} stopOpacity={0.25} />
  1547. <stop offset="95%" stopColor={hasBreak ? '#EF4444' : '#1DB954'} stopOpacity={0.02} />
  1548. </linearGradient>
  1549. {/* Post-arrival fill: always green */}
  1550. <linearGradient id={`cart-post-${item.id}`} x1="0" y1="0" x2="0" y2="1">
  1551. <stop offset="5%" stopColor="#1DB954" stopOpacity={0.3} />
  1552. <stop offset="95%" stopColor="#1DB954" stopOpacity={0.03} />
  1553. </linearGradient>
  1554. </defs>
  1555. <CartesianGrid strokeDasharray="3 3" stroke="#374151" strokeOpacity={0.4} />
  1556. <XAxis
  1557. dataKey="label"
  1558. tick={{ fill: '#6B7280', fontSize: 9 }}
  1559. interval={Math.max(0, Math.ceil(chartData.maxDays / 6) - 1)}
  1560. axisLine={false}
  1561. tickLine={false}
  1562. />
  1563. <YAxis
  1564. tick={{ fill: '#6B7280', fontSize: 9 }}
  1565. axisLine={false}
  1566. tickLine={false}
  1567. tickFormatter={(v: number) => v >= 1000 ? `${(v / 1000).toFixed(1)}kg` : `${v}g`}
  1568. width={44}
  1569. />
  1570. <Tooltip
  1571. contentStyle={{ background: '#1a1a2e', border: '1px solid #374151', borderRadius: 8, fontSize: 11 }}
  1572. labelStyle={{ color: '#9CA3AF' }}
  1573. formatter={(value, name) => {
  1574. if (typeof value !== 'number') return '';
  1575. if (name === 'stock') return `${value}g — ${t('forecast.stock')}`;
  1576. if (name === 'rop') return `${value}g — ${t('forecast.reorderPoint')}`;
  1577. if (name === 'safetyStock') return `${value}g — ${t('forecast.safetyMarginLabel')}`;
  1578. return `${value}`;
  1579. }}
  1580. />
  1581. {/* Single stock area — linear interpolation renders the vertical step correctly
  1582. because the two duplicate-label points at arrival day create an instant jump */}
  1583. <Area
  1584. type="linear"
  1585. dataKey="stock"
  1586. stroke="#1DB954"
  1587. strokeWidth={2}
  1588. fill={`url(#cart-post-${item.id})`}
  1589. dot={false}
  1590. activeDot={{ r: 3 }}
  1591. />
  1592. {/* Reorder point */}
  1593. {f.reorderPointG > 0 && (
  1594. <ReferenceLine
  1595. y={f.reorderPointG}
  1596. stroke="#F59E0B"
  1597. strokeDasharray="5 3"
  1598. strokeOpacity={0.8}
  1599. label={{ value: 'ROP', position: 'insideTopRight', fill: '#F59E0B', fontSize: 9 }}
  1600. />
  1601. )}
  1602. {/* Safety stock floor */}
  1603. {f.safetyStockG > 0 && (
  1604. <ReferenceLine
  1605. y={f.safetyStockG}
  1606. stroke="#6B7280"
  1607. strokeDasharray="3 3"
  1608. strokeOpacity={0.6}
  1609. label={{ value: 'SS', position: 'insideTopRight', fill: '#6B7280', fontSize: 9 }}
  1610. />
  1611. )}
  1612. {/* Arrival / lead-time-end vertical line */}
  1613. <ReferenceLine
  1614. x={formatDateShort(addDays(new Date(), chartData.lt))}
  1615. stroke="#3B82F6"
  1616. strokeWidth={1.5}
  1617. strokeDasharray="4 3"
  1618. strokeOpacity={0.9}
  1619. 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 }}
  1620. />
  1621. {/* Stock break — only shown when stock hits zero before arrival */}
  1622. {stockBreaksAt !== null && (
  1623. <ReferenceLine
  1624. x={formatDateShort(addDays(new Date(), stockBreaksAt))}
  1625. stroke="#EF4444"
  1626. strokeWidth={1.5}
  1627. strokeOpacity={0.9}
  1628. label={{ value: 'OUT', position: 'insideTopLeft', fill: '#EF4444', fontSize: 9 }}
  1629. />
  1630. )}
  1631. </AreaChart>
  1632. </ResponsiveContainer>
  1633. {/* Legend */}
  1634. <div className="flex flex-wrap items-center gap-3 mt-2 text-[10px] text-bambu-gray">
  1635. <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>
  1636. <span className="flex items-center gap-1"><span className="inline-block w-4 border-t border-bambu-gray border-dashed" /> {t('forecast.safetyStockLegend')}</span>
  1637. <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>
  1638. {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>}
  1639. </div>
  1640. </>
  1641. ) : null}
  1642. </div>
  1643. );
  1644. }
  1645. // ── Add to Cart Modal ─────────────────────────────────────────────────────────
  1646. function AddToCartModal({
  1647. forecast: f, onClose, onAdd,
  1648. }: {
  1649. forecast: SkuForecast;
  1650. onClose: () => void;
  1651. onAdd: (item: { material: string; subtype: string | null; brand: string | null; color_name: string | null; quantity_spools: number; note: string | null }) => void;
  1652. }) {
  1653. const { t } = useTranslation();
  1654. const label = [f.group.brand, f.group.material, f.group.subtype, f.group.colorName].filter(Boolean).join(' ');
  1655. const [mode, setMode] = useState<'qty' | 'duration'>('qty');
  1656. const [qty, setQty] = useState('1');
  1657. const [durationDays, setDurationDays] = useState('30');
  1658. const [note, setNote] = useState('');
  1659. const spoolsForDuration = useMemo(() => {
  1660. if (!f.dailyRateG || f.dailyRateG <= 0) return null;
  1661. const neededG = f.dailyRateG * Number(durationDays);
  1662. const avgSpoolG = f.group.spools.length > 0
  1663. ? f.group.spools.reduce((s, sp) => s + sp.label_weight, 0) / f.group.spools.length
  1664. : 1000;
  1665. return Math.ceil(neededG / avgSpoolG);
  1666. }, [f, durationDays]);
  1667. const finalQty = mode === 'qty' ? parseInt(qty, 10) || 1 : (spoolsForDuration ?? 1);
  1668. function submit(e: React.FormEvent) {
  1669. e.preventDefault();
  1670. onAdd({ material: f.group.material, subtype: f.group.subtype, brand: f.group.brand, color_name: f.group.colorName, quantity_spools: finalQty, note: note || null });
  1671. }
  1672. return (
  1673. <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4">
  1674. <div className="bg-bambu-dark-secondary rounded-2xl border border-bambu-dark-tertiary w-full max-w-sm shadow-2xl">
  1675. <div className="flex items-center justify-between px-5 pt-5 pb-4 border-b border-bambu-dark-tertiary">
  1676. <div className="flex items-center gap-2">
  1677. <ShoppingCart className="w-5 h-5 text-bambu-green" />
  1678. <h2 className="text-base font-semibold text-white">{t('forecast.addToCartTitle')}</h2>
  1679. </div>
  1680. <button onClick={onClose} className="p-1 text-bambu-gray hover:text-white transition-colors"><X className="w-5 h-5" /></button>
  1681. </div>
  1682. <form onSubmit={submit} className="p-5 space-y-4">
  1683. <div className="text-sm text-bambu-gray">{label}</div>
  1684. <div className="flex bg-bambu-dark-tertiary rounded-lg p-0.5">
  1685. <button
  1686. type="button"
  1687. onClick={() => setMode('qty')}
  1688. 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'}`}
  1689. >
  1690. {t('forecast.byQuantity')}
  1691. </button>
  1692. <button
  1693. type="button"
  1694. onClick={() => setMode('duration')}
  1695. 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'}`}
  1696. >
  1697. {t('forecast.byDuration')}
  1698. </button>
  1699. </div>
  1700. {mode === 'qty' ? (
  1701. <div className="space-y-1.5">
  1702. <label className="text-xs text-bambu-gray">{t('forecast.numberOfSpools')}</label>
  1703. <input
  1704. type="number" min={1} max={99}
  1705. value={qty} onChange={(e) => setQty(e.target.value)}
  1706. 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"
  1707. autoFocus
  1708. />
  1709. </div>
  1710. ) : (
  1711. <div className="space-y-2">
  1712. <div className="space-y-1.5">
  1713. <label className="text-xs text-bambu-gray">{t('forecast.lastHowManyDays')}</label>
  1714. <input
  1715. type="number" min={1} max={365}
  1716. value={durationDays} onChange={(e) => setDurationDays(e.target.value)}
  1717. 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"
  1718. autoFocus
  1719. />
  1720. </div>
  1721. {f.dailyRateG !== null ? (
  1722. <div className="flex items-center gap-2 px-3 py-2 bg-bambu-dark-tertiary/50 rounded-lg">
  1723. <span className="text-xs text-bambu-gray">≈</span>
  1724. <span className="text-sm font-semibold text-bambu-green">{t('forecast.spoolCount', { count: spoolsForDuration ?? 0 })}</span>
  1725. <span className="text-xs text-bambu-gray">at {f.dailyRateG.toFixed(1)}g/day</span>
  1726. </div>
  1727. ) : (
  1728. <div className="text-xs text-yellow-400 px-1">{t('forecast.noUsageQty')}</div>
  1729. )}
  1730. </div>
  1731. )}
  1732. <div className="space-y-1.5">
  1733. <label className="text-xs text-bambu-gray">{t('forecast.noteOptional')}</label>
  1734. <input
  1735. type="text" maxLength={200}
  1736. value={note} onChange={(e) => setNote(e.target.value)}
  1737. placeholder={t('forecast.notePlaceholder')}
  1738. 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"
  1739. />
  1740. </div>
  1741. <div className="flex items-center gap-3 pt-1">
  1742. <button
  1743. type="submit"
  1744. className="flex-1 py-2 bg-bambu-green text-white text-sm font-medium rounded-lg hover:bg-bambu-green/80 transition-colors"
  1745. >
  1746. {t('forecast.addNSpools', { count: finalQty })}
  1747. </button>
  1748. <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">
  1749. {t('forecast.cancel')}
  1750. </button>
  1751. </div>
  1752. </form>
  1753. </div>
  1754. </div>
  1755. );
  1756. }
  1757. // ── Column headers (re-exported for InventoryPage) ────────────────────────────
  1758. export function ForecastColumnHeaders() {
  1759. return null;
  1760. }