ForecastPanel.tsx 90 KB

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