ForecastPanel.tsx 84 KB

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