ForecastPanel.tsx 95 KB

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