InventoryPage.tsx 65 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591
  1. import { useState, useMemo, type ReactNode } from 'react';
  2. import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
  3. import { useTranslation } from 'react-i18next';
  4. import {
  5. Plus, Loader2, Trash2, Archive, RotateCcw, Edit2, Package,
  6. Search, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight,
  7. TrendingDown, Layers, Printer, AlertTriangle, X, Clock, LayoutGrid, TableProperties, Columns,
  8. ArrowUp, ArrowDown, ArrowUpDown, Group, ChevronDown,
  9. } from 'lucide-react';
  10. import { api } from '../api/client';
  11. import type { InventorySpool, SpoolAssignment } from '../api/client';
  12. import { Button } from '../components/Button';
  13. import { SpoolFormModal } from '../components/SpoolFormModal';
  14. import { ConfirmModal } from '../components/ConfirmModal';
  15. import { ColumnConfigModal, type ColumnConfig } from '../components/ColumnConfigModal';
  16. import { useToast } from '../contexts/ToastContext';
  17. import { resolveSpoolColorName } from '../utils/colors';
  18. import { getCurrencySymbol } from '../utils/currency';
  19. import { formatDateInput, parseUTCDate, type DateFormat } from '../utils/date';
  20. import { formatSlotLabel } from '../utils/amsHelpers';
  21. type ArchiveFilter = 'active' | 'archived';
  22. type UsageFilter = 'all' | 'used' | 'new';
  23. type ViewMode = 'table' | 'cards';
  24. type SortDirection = 'asc' | 'desc';
  25. type SortState = { column: string; direction: SortDirection } | null;
  26. type DisplayItem =
  27. | { type: 'single'; spool: InventorySpool }
  28. | { type: 'group'; key: string; spools: InventorySpool[]; representative: InventorySpool };
  29. function spoolGroupKey(s: InventorySpool): string {
  30. return `${s.material}|${s.subtype || ''}|${s.brand || ''}|${s.color_name || ''}|${s.rgba || ''}|${s.label_weight}`;
  31. }
  32. // Column definitions for the inventory table
  33. const COLUMN_CONFIG_KEY = 'bambuddy-inventory-columns';
  34. const DEFAULT_COLUMNS: ColumnConfig[] = [
  35. { id: 'id', label: '#', visible: true },
  36. { id: 'added_time', label: 'Added', visible: true },
  37. { id: 'encode_time', label: 'Encoded', visible: false },
  38. { id: 'last_used_time', label: 'Last Used', visible: false },
  39. { id: 'rgba', label: 'Color', visible: true },
  40. { id: 'material', label: 'Material', visible: true },
  41. { id: 'subtype', label: 'Subtype', visible: true },
  42. { id: 'color_name', label: 'Color Name', visible: false },
  43. { id: 'brand', label: 'Brand', visible: true },
  44. { id: 'slicer_filament', label: 'Slicer Filament', visible: false },
  45. { id: 'location', label: 'Location', visible: true },
  46. { id: 'label_weight', label: 'Label', visible: true },
  47. { id: 'net', label: 'Net', visible: true },
  48. { id: 'gross', label: 'Gross', visible: false },
  49. { id: 'added_full', label: 'Full', visible: false },
  50. { id: 'used', label: 'Used', visible: false },
  51. { id: 'printed_total', label: 'Printed Total', visible: false },
  52. { id: 'printed_since_weight', label: 'Printed Since Weight', visible: false },
  53. { id: 'note', label: 'Note', visible: false },
  54. { id: 'pa_k', label: 'PA(K)', visible: true },
  55. { id: 'tag_id', label: 'Tag ID', visible: false },
  56. { id: 'data_origin', label: 'Data Origin', visible: false },
  57. { id: 'tag_type', label: 'Linked Tag Type', visible: false },
  58. { id: 'stock', label: 'Stock', visible: false },
  59. { id: 'remaining', label: 'Remaining', visible: true },
  60. { id: 'cost_per_kg', label: 'Cost/kg', visible: false },
  61. ];
  62. function loadColumnConfig(): ColumnConfig[] {
  63. try {
  64. const stored = localStorage.getItem(COLUMN_CONFIG_KEY);
  65. if (stored) {
  66. const parsed = JSON.parse(stored) as ColumnConfig[];
  67. const defaultIds = new Set(DEFAULT_COLUMNS.map((c) => c.id));
  68. const storedIds = new Set(parsed.map((c) => c.id));
  69. // Keep stored columns that still exist in defaults
  70. const validStored = parsed.filter((c) => defaultIds.has(c.id));
  71. // Add any new default columns not in stored config
  72. const newColumns = DEFAULT_COLUMNS.filter((c) => !storedIds.has(c.id));
  73. return [...validStored, ...newColumns];
  74. }
  75. } catch {
  76. // Ignore errors
  77. }
  78. return DEFAULT_COLUMNS.map((c) => ({ ...c }));
  79. }
  80. function saveColumnConfig(config: ColumnConfig[]) {
  81. try {
  82. localStorage.setItem(COLUMN_CONFIG_KEY, JSON.stringify(config));
  83. } catch {
  84. // Ignore errors
  85. }
  86. }
  87. function formatWeight(g: number, useKg = false): string {
  88. if (useKg && g >= 1000) return `${(g / 1000).toFixed(1)}kg`;
  89. return `${Math.round(g)}g`;
  90. }
  91. // Material color mapping for pills
  92. const MATERIAL_COLORS: Record<string, string> = {
  93. PLA: 'bg-green-500/20 text-green-400',
  94. ABS: 'bg-red-500/20 text-red-400',
  95. PETG: 'bg-blue-500/20 text-blue-400',
  96. TPU: 'bg-purple-500/20 text-purple-400',
  97. ASA: 'bg-orange-500/20 text-orange-400',
  98. PA: 'bg-yellow-500/20 text-yellow-400',
  99. PC: 'bg-cyan-500/20 text-cyan-400',
  100. PET: 'bg-sky-500/20 text-sky-400',
  101. };
  102. type TFn = (key: string, opts?: Record<string, unknown>) => string;
  103. function formatInventoryDate(dateStr: string | null, dateFormat: DateFormat = 'system'): string {
  104. if (!dateStr) return '-';
  105. const date = parseUTCDate(dateStr);
  106. if (!date) return '-';
  107. return formatDateInput(date, dateFormat);
  108. }
  109. type CellCtx = {
  110. spool: InventorySpool;
  111. remaining: number;
  112. pct: number;
  113. assignmentMap: Record<number, SpoolAssignment>;
  114. currencySymbol: string;
  115. dateFormat: DateFormat;
  116. t: TFn;
  117. };
  118. // Column header labels (25 columns — matching SpoolBuddy exactly)
  119. const columnHeaders: Record<string, (t: TFn) => string> = {
  120. id: () => '#',
  121. added_time: () => 'Added',
  122. encode_time: () => 'Encoded',
  123. last_used_time: () => 'Last Used',
  124. rgba: (t) => t('inventory.color'),
  125. material: (t) => t('inventory.material'),
  126. subtype: (t) => t('inventory.subtype'),
  127. color_name: (t) => t('inventory.colorName'),
  128. brand: (t) => t('inventory.brand'),
  129. slicer_filament: (t) => t('inventory.slicerFilament'),
  130. location: () => 'Location',
  131. label_weight: (t) => t('inventory.labelWeight'),
  132. net: (t) => t('inventory.net'),
  133. gross: () => 'Gross',
  134. added_full: () => 'Full',
  135. used: (t) => t('inventory.weightUsed'),
  136. printed_total: () => 'Printed Total',
  137. printed_since_weight: () => 'Printed Since Weight',
  138. note: (t) => t('inventory.note'),
  139. pa_k: () => 'PA(K)',
  140. tag_id: () => 'Tag ID',
  141. data_origin: () => 'Data Origin',
  142. tag_type: () => 'Linked Tag Type',
  143. stock: (t) => t('inventory.stock'),
  144. remaining: (t) => t('inventory.remaining'),
  145. cost_per_kg: (t) => t('inventory.costPerKg'),
  146. };
  147. // Column cell renderers (25 columns — matching SpoolBuddy exactly)
  148. const columnCells: Record<string, (ctx: CellCtx) => ReactNode> = {
  149. id: ({ spool }) => (
  150. <span className="text-sm font-medium text-white">{spool.id}</span>
  151. ),
  152. added_time: ({ spool, dateFormat }) => (
  153. <span className="text-sm text-bambu-gray">{formatInventoryDate(spool.created_at, dateFormat)}</span>
  154. ),
  155. encode_time: ({ spool, dateFormat }) => (
  156. <span className="text-sm text-bambu-gray">{formatInventoryDate(spool.encode_time, dateFormat)}</span>
  157. ),
  158. last_used_time: ({ spool, dateFormat }) => (
  159. <span className="text-sm text-bambu-gray">{spool.last_used ? formatInventoryDate(spool.last_used, dateFormat) : 'Never'}</span>
  160. ),
  161. rgba: ({ spool }) => (
  162. <div className="flex items-center justify-center">
  163. <span
  164. className="w-5 h-5 rounded-full border border-white/20 flex-shrink-0"
  165. style={{ backgroundColor: spool.rgba ? `#${spool.rgba.substring(0, 6)}` : '#808080' }}
  166. title={spool.rgba ? `#${spool.rgba.substring(0, 6)}` : undefined}
  167. />
  168. </div>
  169. ),
  170. material: ({ spool }) => (
  171. <span className="text-sm text-white">{spool.material}</span>
  172. ),
  173. subtype: ({ spool }) => (
  174. <span className="text-sm text-bambu-gray">{spool.subtype || '-'}</span>
  175. ),
  176. color_name: ({ spool }) => (
  177. <span className="text-sm text-bambu-gray">{resolveSpoolColorName(spool.color_name, spool.rgba) || '-'}</span>
  178. ),
  179. brand: ({ spool }) => (
  180. <span className="text-sm text-bambu-gray">{spool.brand || '-'}</span>
  181. ),
  182. slicer_filament: ({ spool }) => (
  183. <span className="text-sm text-bambu-gray" title={spool.slicer_filament || undefined}>
  184. {spool.slicer_filament_name || spool.slicer_filament || '-'}
  185. </span>
  186. ),
  187. location: ({ spool, assignmentMap }) => {
  188. const assignment = assignmentMap[spool.id];
  189. if (!assignment) return <span className="text-sm text-bambu-gray">-</span>;
  190. const printerLabel = assignment.printer_name || `Printer ${assignment.printer_id}`;
  191. const isExternal = assignment.ams_id === 254 || assignment.ams_id === 255;
  192. const isHt = !isExternal && assignment.ams_id >= 128;
  193. const slotLabel = formatSlotLabel(assignment.ams_id, assignment.tray_id, isHt, isExternal);
  194. return (
  195. <span className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-purple-500/20 text-purple-400">
  196. {printerLabel} {slotLabel}
  197. </span>
  198. );
  199. },
  200. label_weight: ({ spool }) => (
  201. <span className="text-sm text-white">{formatWeight(spool.label_weight)}</span>
  202. ),
  203. net: ({ remaining }) => (
  204. <span className="text-sm text-white">{formatWeight(remaining)}</span>
  205. ),
  206. gross: ({ spool, remaining }) => (
  207. <span className="text-sm text-bambu-gray">{formatWeight(remaining + spool.core_weight)}</span>
  208. ),
  209. added_full: ({ spool }) => (
  210. <span className="text-sm text-bambu-gray">{spool.added_full == null ? '-' : spool.added_full ? 'Yes' : 'No'}</span>
  211. ),
  212. used: ({ spool }) => (
  213. <span className="text-sm text-bambu-gray">{spool.weight_used > 0 ? formatWeight(spool.weight_used) : '-'}</span>
  214. ),
  215. printed_total: () => (
  216. <span className="text-sm text-bambu-gray/50">-</span>
  217. ),
  218. printed_since_weight: () => (
  219. <span className="text-sm text-bambu-gray/50">-</span>
  220. ),
  221. note: ({ spool }) => (
  222. <span className="text-sm text-bambu-gray max-w-[150px] truncate block" title={spool.note || undefined}>{spool.note || '-'}</span>
  223. ),
  224. pa_k: ({ spool }) => {
  225. const count = spool.k_profiles?.length ?? 0;
  226. if (count === 0) return <span className="text-sm text-bambu-gray">-</span>;
  227. return (
  228. <span className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-bambu-green/20 text-bambu-green">
  229. K
  230. </span>
  231. );
  232. },
  233. tag_id: ({ spool }) => {
  234. const tag = spool.tag_uid || spool.tray_uuid;
  235. if (!tag) return <span className="text-sm text-bambu-gray/50">-</span>;
  236. return (
  237. <span className="text-sm text-bambu-gray font-mono" title={tag}>
  238. {tag.length > 12 ? `${tag.slice(0, 6)}...${tag.slice(-4)}` : tag}
  239. </span>
  240. );
  241. },
  242. data_origin: ({ spool }) => (
  243. <span className="text-sm text-bambu-gray">{spool.data_origin || '-'}</span>
  244. ),
  245. tag_type: ({ spool }) => (
  246. <span className="text-sm text-bambu-gray">{spool.tag_type || '-'}</span>
  247. ),
  248. stock: ({ spool, t }) => {
  249. if (!spool.slicer_filament) {
  250. return (
  251. <span className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-amber-500/20 text-amber-400">
  252. {t('inventory.stock')}
  253. </span>
  254. );
  255. }
  256. return <span className="text-sm text-bambu-gray">-</span>;
  257. },
  258. remaining: ({ remaining, pct }) => (
  259. <div className="flex items-center gap-2">
  260. <div className="flex-1 h-2 bg-bambu-dark-tertiary rounded-full overflow-hidden">
  261. <div
  262. className={`h-full rounded-full ${pct > 50 ? 'bg-bambu-green' : pct > 20 ? 'bg-yellow-500' : 'bg-red-500'}`}
  263. style={{ width: `${Math.min(pct, 100)}%` }}
  264. />
  265. </div>
  266. <span className="text-xs text-bambu-gray min-w-[40px] text-right">{Math.round(remaining)}g</span>
  267. </div>
  268. ),
  269. cost_per_kg: ({ spool, currencySymbol }) => (
  270. <span className="text-sm text-bambu-gray">
  271. {spool.cost_per_kg != null ? `${currencySymbol}${spool.cost_per_kg.toFixed(2)}` : '-'}
  272. </span>
  273. ),
  274. };
  275. // Sort value extractors — return a comparable value for each sortable column
  276. const columnSortValues: Record<string, (spool: InventorySpool, assignmentMap: Record<number, SpoolAssignment>) => string | number> = {
  277. id: (s) => s.id,
  278. added_time: (s) => s.created_at || '',
  279. encode_time: (s) => s.encode_time || '',
  280. last_used_time: (s) => s.last_used || '',
  281. material: (s) => (s.material || '').toLowerCase(),
  282. subtype: (s) => (s.subtype || '').toLowerCase(),
  283. color_name: (s) => (s.color_name || '').toLowerCase(),
  284. brand: (s) => (s.brand || '').toLowerCase(),
  285. slicer_filament: (s) => (s.slicer_filament_name || s.slicer_filament || '').toLowerCase(),
  286. location: (s, am) => {
  287. const a = am[s.id];
  288. if (!a) return '';
  289. const isExt = a.ams_id === 254 || a.ams_id === 255;
  290. const isHt = !isExt && a.ams_id >= 128;
  291. return `${a.printer_name || ''} ${formatSlotLabel(a.ams_id, a.tray_id, isHt, isExt)}`;
  292. },
  293. label_weight: (s) => s.label_weight,
  294. net: (s) => Math.max(0, s.label_weight - s.weight_used),
  295. gross: (s) => Math.max(0, s.label_weight - s.weight_used) + s.core_weight,
  296. used: (s) => s.weight_used,
  297. remaining: (s) => s.label_weight > 0 ? Math.max(0, s.label_weight - s.weight_used) / s.label_weight : 0,
  298. note: (s) => (s.note || '').toLowerCase(),
  299. data_origin: (s) => (s.data_origin || '').toLowerCase(),
  300. tag_type: (s) => (s.tag_type || '').toLowerCase(),
  301. stock: (s) => s.slicer_filament ? 1 : 0,
  302. cost_per_kg: (s) => s.cost_per_kg ?? 0,
  303. };
  304. const SORT_STATE_KEY = 'bambuddy-inventory-sort';
  305. function loadSortState(): SortState {
  306. try {
  307. const stored = localStorage.getItem(SORT_STATE_KEY);
  308. if (stored) return JSON.parse(stored);
  309. } catch { /* ignore */ }
  310. return null;
  311. }
  312. function saveSortState(state: SortState) {
  313. try {
  314. if (state) {
  315. localStorage.setItem(SORT_STATE_KEY, JSON.stringify(state));
  316. } else {
  317. localStorage.removeItem(SORT_STATE_KEY);
  318. }
  319. } catch { /* ignore */ }
  320. }
  321. export default function InventoryPage() {
  322. const { t } = useTranslation();
  323. const queryClient = useQueryClient();
  324. const { showToast } = useToast();
  325. const [formModal, setFormModal] = useState<{ spool?: InventorySpool | null } | null>(null);
  326. const [confirmAction, setConfirmAction] = useState<{ type: 'delete' | 'archive'; spoolId: number } | null>(null);
  327. // Filter state
  328. const [archiveFilter, setArchiveFilter] = useState<ArchiveFilter>('active');
  329. const [usageFilter, setUsageFilter] = useState<UsageFilter>('all');
  330. const [materialFilter, setMaterialFilter] = useState('');
  331. const [brandFilter, setBrandFilter] = useState('');
  332. const [stockFilter, setStockFilter] = useState<'all' | 'stock' | 'configured'>('all');
  333. const [search, setSearch] = useState('');
  334. const [viewMode, setViewMode] = useState<ViewMode>('table');
  335. const [sortState, setSortState] = useState<SortState>(loadSortState);
  336. const [columnConfig, setColumnConfig] = useState<ColumnConfig[]>(loadColumnConfig);
  337. const [showColumnModal, setShowColumnModal] = useState(false);
  338. const [groupSimilar, setGroupSimilar] = useState(() => {
  339. try {
  340. return localStorage.getItem('bambuddy-inventory-group') === 'true';
  341. } catch { return false; }
  342. });
  343. const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set());
  344. // Pagination state (pageSize persisted to localStorage)
  345. const [pageIndex, setPageIndex] = useState(0);
  346. const [pageSize, setPageSize] = useState(() => {
  347. try {
  348. const stored = localStorage.getItem('bambuddy-inventory-pageSize');
  349. if (stored) {
  350. const n = Number(stored);
  351. if ([15, 30, 50, 100, -1].includes(n)) return n;
  352. }
  353. } catch { /* ignore */ }
  354. return 15;
  355. });
  356. const { data: settings } = useQuery({
  357. queryKey: ['settings'],
  358. queryFn: api.getSettings,
  359. });
  360. const dateFormat: DateFormat = settings?.date_format || 'system';
  361. const { data: spools, isLoading } = useQuery({
  362. queryKey: ['inventory-spools'],
  363. queryFn: () => api.getSpools(true), // Always fetch all, filter client-side
  364. refetchInterval: 30000,
  365. });
  366. const { data: assignments } = useQuery({
  367. queryKey: ['spool-assignments'],
  368. queryFn: () => api.getAssignments(),
  369. refetchInterval: 30000,
  370. });
  371. const deleteMutation = useMutation({
  372. mutationFn: (id: number) => api.deleteSpool(id),
  373. onSuccess: () => {
  374. queryClient.invalidateQueries({ queryKey: ['inventory-spools'] });
  375. showToast(t('inventory.spoolDeleted'), 'success');
  376. },
  377. });
  378. const archiveMutation = useMutation({
  379. mutationFn: (id: number) => api.archiveSpool(id),
  380. onSuccess: () => {
  381. queryClient.invalidateQueries({ queryKey: ['inventory-spools'] });
  382. showToast(t('inventory.spoolArchived'), 'success');
  383. },
  384. });
  385. const restoreMutation = useMutation({
  386. mutationFn: (id: number) => api.restoreSpool(id),
  387. onSuccess: () => {
  388. queryClient.invalidateQueries({ queryKey: ['inventory-spools'] });
  389. showToast(t('inventory.spoolRestored'), 'success');
  390. },
  391. });
  392. // Stats calculation (active spools only)
  393. const stats = useMemo(() => {
  394. if (!spools) return null;
  395. let totalWeight = 0;
  396. let totalConsumed = 0;
  397. let lowStock = 0;
  398. let activeCount = 0;
  399. const byMaterial: Record<string, { count: number; weight: number }> = {};
  400. for (const s of spools) {
  401. if (s.archived_at) continue;
  402. activeCount++;
  403. const remaining = Math.max(0, s.label_weight - s.weight_used);
  404. totalWeight += remaining;
  405. totalConsumed += s.weight_used;
  406. const pct = s.label_weight > 0 ? (remaining / s.label_weight) * 100 : 0;
  407. if (pct < 20) lowStock++;
  408. const mat = s.material || 'Unknown';
  409. if (!byMaterial[mat]) byMaterial[mat] = { count: 0, weight: 0 };
  410. byMaterial[mat].count++;
  411. byMaterial[mat].weight += remaining;
  412. }
  413. return { totalWeight, totalConsumed, lowStock, byMaterial, totalSpools: activeCount };
  414. }, [spools]);
  415. const inPrinterCount = assignments?.length ?? 0;
  416. const currencySymbol = getCurrencySymbol(settings?.currency || 'USD');
  417. // Map spool_id -> assignment for location column
  418. const assignmentMap = useMemo(() => {
  419. const map: Record<number, SpoolAssignment> = {};
  420. for (const a of assignments || []) {
  421. map[a.spool_id] = a;
  422. }
  423. return map;
  424. }, [assignments]);
  425. // Top materials by weight for stat card pills
  426. const topMaterials = useMemo(() => {
  427. if (!stats) return [];
  428. return Object.entries(stats.byMaterial)
  429. .sort((a, b) => b[1].weight - a[1].weight)
  430. .slice(0, 4);
  431. }, [stats]);
  432. // Filtering pipeline
  433. const filteredSpools = useMemo(() => {
  434. let filtered = spools || [];
  435. // Archive filter
  436. if (archiveFilter === 'active') {
  437. filtered = filtered.filter((s) => !s.archived_at);
  438. } else {
  439. filtered = filtered.filter((s) => !!s.archived_at);
  440. }
  441. // Usage filter
  442. if (usageFilter === 'used') {
  443. filtered = filtered.filter((s) => s.weight_used > 0);
  444. } else if (usageFilter === 'new') {
  445. filtered = filtered.filter((s) => s.weight_used === 0);
  446. }
  447. // Material dropdown
  448. if (materialFilter) {
  449. filtered = filtered.filter((s) => s.material === materialFilter);
  450. }
  451. // Brand dropdown
  452. if (brandFilter) {
  453. filtered = filtered.filter((s) => s.brand === brandFilter);
  454. }
  455. // Stock filter
  456. if (stockFilter === 'stock') {
  457. filtered = filtered.filter((s) => !s.slicer_filament);
  458. } else if (stockFilter === 'configured') {
  459. filtered = filtered.filter((s) => !!s.slicer_filament);
  460. }
  461. // Global search
  462. if (search) {
  463. const q = search.toLowerCase();
  464. filtered = filtered.filter((s) =>
  465. s.brand?.toLowerCase().includes(q) ||
  466. s.material.toLowerCase().includes(q) ||
  467. s.color_name?.toLowerCase().includes(q) ||
  468. s.subtype?.toLowerCase().includes(q) ||
  469. s.note?.toLowerCase().includes(q) ||
  470. s.slicer_filament_name?.toLowerCase().includes(q)
  471. );
  472. }
  473. return filtered;
  474. }, [spools, archiveFilter, usageFilter, materialFilter, brandFilter, stockFilter, search]);
  475. // Reset page on filter changes
  476. const resetPage = () => setPageIndex(0);
  477. // Unique values for filter dropdowns
  478. const uniqueMaterials = [...new Set(spools?.map((s) => s.material) || [])].sort();
  479. const uniqueBrands = [...new Set(spools?.map((s) => s.brand).filter(Boolean) || [])].sort() as string[];
  480. // Check if any filters are non-default
  481. const hasActiveFilters = archiveFilter !== 'active' || usageFilter !== 'all' || !!materialFilter || !!brandFilter || stockFilter !== 'all' || !!search;
  482. const handleColumnConfigSave = (config: ColumnConfig[]) => {
  483. setColumnConfig(config);
  484. saveColumnConfig(config);
  485. };
  486. // Visible column IDs in order
  487. const visibleColumns = useMemo(
  488. () => columnConfig.filter((c) => c.visible).map((c) => c.id),
  489. [columnConfig]
  490. );
  491. const handleSort = (colId: string) => {
  492. if (!columnSortValues[colId]) return; // Not sortable
  493. setSortState((prev) => {
  494. let next: SortState;
  495. if (prev?.column === colId) {
  496. // Toggle direction, or clear on third click
  497. next = prev.direction === 'asc' ? { column: colId, direction: 'desc' } : null;
  498. } else {
  499. next = { column: colId, direction: 'asc' };
  500. }
  501. saveSortState(next);
  502. return next;
  503. });
  504. resetPage();
  505. };
  506. // Sort filtered spools
  507. const sortedSpools = useMemo(() => {
  508. if (!sortState) return filteredSpools;
  509. const extractor = columnSortValues[sortState.column];
  510. if (!extractor) return filteredSpools;
  511. const sorted = [...filteredSpools].sort((a, b) => {
  512. const va = extractor(a, assignmentMap);
  513. const vb = extractor(b, assignmentMap);
  514. if (va < vb) return sortState.direction === 'asc' ? -1 : 1;
  515. if (va > vb) return sortState.direction === 'asc' ? 1 : -1;
  516. return 0;
  517. });
  518. return sorted;
  519. }, [filteredSpools, sortState, assignmentMap]);
  520. // Group similar spools when toggle is active
  521. const displayItems = useMemo((): DisplayItem[] => {
  522. if (!groupSimilar) return sortedSpools.map((s) => ({ type: 'single' as const, spool: s }));
  523. const groups = new Map<string, InventorySpool[]>();
  524. for (const spool of sortedSpools) {
  525. // Only group unused & unassigned spools
  526. if (spool.weight_used > 0 || assignmentMap[spool.id]) {
  527. // Will be added as singles in the walk below
  528. } else {
  529. const key = spoolGroupKey(spool);
  530. const arr = groups.get(key);
  531. if (arr) arr.push(spool);
  532. else groups.set(key, [spool]);
  533. }
  534. }
  535. const items: DisplayItem[] = [];
  536. const processedKeys = new Set<string>();
  537. // Walk sortedSpools order so groups appear at the position of their first member
  538. for (const spool of sortedSpools) {
  539. if (spool.weight_used > 0 || assignmentMap[spool.id]) {
  540. items.push({ type: 'single', spool });
  541. continue;
  542. }
  543. const key = spoolGroupKey(spool);
  544. if (processedKeys.has(key)) continue;
  545. processedKeys.add(key);
  546. const members = groups.get(key)!;
  547. if (members.length === 1) {
  548. items.push({ type: 'single', spool: members[0] });
  549. } else {
  550. items.push({ type: 'group', key, spools: members, representative: members[0] });
  551. }
  552. }
  553. return items;
  554. }, [sortedSpools, groupSimilar, assignmentMap]);
  555. // Pagination (after sorting) — pageSize -1 means "All"
  556. const showAll = pageSize === -1;
  557. const totalDisplayItems = displayItems.length;
  558. const effectivePageSize = showAll ? totalDisplayItems || 1 : pageSize;
  559. const totalPages = Math.max(1, Math.ceil(totalDisplayItems / effectivePageSize));
  560. const safePageIndex = showAll ? 0 : Math.min(pageIndex, totalPages - 1);
  561. const pagedItems = showAll
  562. ? displayItems
  563. : displayItems.slice(safePageIndex * effectivePageSize, (safePageIndex + 1) * effectivePageSize);
  564. const toggleGroupSimilar = () => {
  565. const next = !groupSimilar;
  566. setGroupSimilar(next);
  567. setExpandedGroups(new Set());
  568. resetPage();
  569. try { localStorage.setItem('bambuddy-inventory-group', String(next)); } catch { /* ignore */ }
  570. };
  571. const toggleGroupExpand = (key: string) => {
  572. setExpandedGroups((prev) => {
  573. const next = new Set(prev);
  574. if (next.has(key)) next.delete(key);
  575. else next.add(key);
  576. return next;
  577. });
  578. };
  579. const handlePageSizeChange = (size: number) => {
  580. setPageSize(size);
  581. setPageIndex(0);
  582. try { localStorage.setItem('bambuddy-inventory-pageSize', String(size)); } catch { /* ignore */ }
  583. };
  584. const clearAllFilters = () => {
  585. setArchiveFilter('active');
  586. setUsageFilter('all');
  587. setMaterialFilter('');
  588. setBrandFilter('');
  589. setStockFilter('all');
  590. setSearch('');
  591. resetPage();
  592. };
  593. return (
  594. <div className="p-4 md:p-6 space-y-6">
  595. {/* Header */}
  596. <div className="flex items-center justify-between">
  597. <div>
  598. <div className="flex items-center gap-3">
  599. <Package className="w-6 h-6 text-bambu-green" />
  600. <h1 className="text-2xl font-bold text-white">{t('inventory.title')}</h1>
  601. </div>
  602. <p className="text-sm text-bambu-gray mt-1 ml-9">{t('inventory.noSpools').split('.')[0] ? '' : ''}</p>
  603. </div>
  604. <Button onClick={() => setFormModal({ spool: null })}>
  605. <Plus className="w-4 h-4" />
  606. {t('inventory.addSpool')}
  607. </Button>
  608. </div>
  609. {/* Stats Bar */}
  610. {stats && !isLoading && (
  611. <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-3">
  612. {/* Total Inventory */}
  613. <div className="bg-bambu-dark-secondary rounded-lg p-4">
  614. <div className="flex items-center gap-2 mb-1">
  615. <Package className="w-4 h-4 text-bambu-green" />
  616. <span className="text-xs text-bambu-gray font-medium uppercase tracking-wide">{t('inventory.totalInventory')}</span>
  617. </div>
  618. <div className="text-xl font-bold text-white">{formatWeight(stats.totalWeight, true)}</div>
  619. <div className="text-xs text-bambu-gray mt-1">{stats.totalSpools} {stats.totalSpools !== 1 ? t('inventory.spools') : t('inventory.spool')}</div>
  620. </div>
  621. {/* Total Consumed */}
  622. <div className="bg-bambu-dark-secondary rounded-lg p-4">
  623. <div className="flex items-center gap-2 mb-1">
  624. <TrendingDown className="w-4 h-4 text-blue-400" />
  625. <span className="text-xs text-bambu-gray font-medium uppercase tracking-wide">{t('inventory.totalConsumed')}</span>
  626. </div>
  627. <div className="text-xl font-bold text-white">{formatWeight(stats.totalConsumed, true)}</div>
  628. <div className="text-xs text-bambu-gray mt-1">{t('inventory.sinceTracking')}</div>
  629. </div>
  630. {/* By Material */}
  631. <div className="bg-bambu-dark-secondary rounded-lg p-4">
  632. <div className="flex items-center gap-2 mb-1">
  633. <Layers className="w-4 h-4 text-green-400" />
  634. <span className="text-xs text-bambu-gray font-medium uppercase tracking-wide">{t('inventory.byMaterial')}</span>
  635. </div>
  636. <div className="flex flex-wrap gap-1.5 mt-1">
  637. {topMaterials.map(([mat, data]) => (
  638. <span
  639. key={mat}
  640. className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium ${MATERIAL_COLORS[mat] || 'bg-bambu-dark-tertiary text-bambu-gray'}`}
  641. >
  642. {mat} <span className="opacity-70">{formatWeight(data.weight, true)}</span>
  643. </span>
  644. ))}
  645. </div>
  646. </div>
  647. {/* In Printer */}
  648. <div className="bg-bambu-dark-secondary rounded-lg p-4">
  649. <div className="flex items-center gap-2 mb-1">
  650. <Printer className="w-4 h-4 text-purple-400" />
  651. <span className="text-xs text-bambu-gray font-medium uppercase tracking-wide">{t('inventory.inPrinter')}</span>
  652. </div>
  653. <div className="text-xl font-bold text-white">{inPrinterCount}</div>
  654. <div className="text-xs text-bambu-gray mt-1">{t('inventory.loadedInAms')}</div>
  655. </div>
  656. {/* Low Stock */}
  657. <div className="bg-bambu-dark-secondary rounded-lg p-4">
  658. <div className="flex items-center gap-2 mb-1">
  659. <AlertTriangle className="w-4 h-4 text-yellow-400" />
  660. <span className="text-xs text-bambu-gray font-medium uppercase tracking-wide">{t('inventory.lowStock')}</span>
  661. </div>
  662. <div className={`text-xl font-bold ${stats.lowStock > 0 ? 'text-yellow-400' : 'text-white'}`}>{stats.lowStock}</div>
  663. <div className="text-xs text-bambu-gray mt-1">{t('inventory.lowStockThreshold')}</div>
  664. </div>
  665. </div>
  666. )}
  667. {/* Toolbar: Search + View toggle */}
  668. <div className="flex flex-col sm:flex-row gap-3 items-start sm:items-center justify-between">
  669. <div className="relative flex-1 max-w-md">
  670. <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray/50" />
  671. <input
  672. type="text"
  673. value={search}
  674. onChange={(e) => { setSearch(e.target.value); resetPage(); }}
  675. placeholder={t('inventory.search')}
  676. className="w-full pl-10 pr-8 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white text-sm placeholder:text-bambu-gray/50 focus:outline-none focus:border-bambu-green"
  677. />
  678. {search && (
  679. <button
  680. onClick={() => { setSearch(''); resetPage(); }}
  681. className="absolute right-3 top-1/2 -translate-y-1/2 text-bambu-gray hover:text-white"
  682. >
  683. <X className="w-4 h-4" />
  684. </button>
  685. )}
  686. </div>
  687. <div className="flex items-center gap-2">
  688. {/* Columns button (table view only) */}
  689. {viewMode === 'table' && (
  690. <button
  691. onClick={() => setShowColumnModal(true)}
  692. className="flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium text-bambu-gray border border-bambu-dark-tertiary rounded-lg hover:bg-bambu-dark-tertiary transition-colors"
  693. title={t('inventory.configureColumns')}
  694. >
  695. <Columns className="w-4 h-4" />
  696. <span className="hidden sm:inline">{t('inventory.columns')}</span>
  697. </button>
  698. )}
  699. {/* Group similar toggle */}
  700. <button
  701. onClick={toggleGroupSimilar}
  702. className={`flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium border rounded-lg transition-colors ${
  703. groupSimilar
  704. ? 'bg-bambu-green/20 text-bambu-green border-bambu-green/30'
  705. : 'text-bambu-gray border-bambu-dark-tertiary hover:bg-bambu-dark-tertiary'
  706. }`}
  707. title={t('inventory.groupSimilar')}
  708. >
  709. <Group className="w-4 h-4" />
  710. <span className="hidden sm:inline">{t('inventory.groupSimilar')}</span>
  711. </button>
  712. {/* Table / Cards toggle */}
  713. <div className="flex bg-bambu-dark-primary border border-bambu-dark-tertiary rounded-lg overflow-hidden">
  714. <button
  715. onClick={() => setViewMode('table')}
  716. className={`flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium transition-colors ${
  717. viewMode === 'table'
  718. ? 'bg-bambu-green text-white'
  719. : 'text-bambu-gray hover:bg-bambu-dark-tertiary'
  720. }`}
  721. >
  722. <TableProperties className="w-4 h-4" />
  723. <span className="hidden sm:inline">{t('inventory.table')}</span>
  724. </button>
  725. <button
  726. onClick={() => setViewMode('cards')}
  727. className={`flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium transition-colors ${
  728. viewMode === 'cards'
  729. ? 'bg-bambu-green text-white'
  730. : 'text-bambu-gray hover:bg-bambu-dark-tertiary'
  731. }`}
  732. >
  733. <LayoutGrid className="w-4 h-4" />
  734. <span className="hidden sm:inline">{t('inventory.cards')}</span>
  735. </button>
  736. </div>
  737. </div>
  738. </div>
  739. {/* Filter chips row */}
  740. <div className="flex flex-wrap items-center gap-2">
  741. {/* Active / Archived chips */}
  742. <div className="flex items-center rounded-lg border border-bambu-dark-tertiary overflow-hidden">
  743. <button
  744. onClick={() => { setArchiveFilter('active'); resetPage(); }}
  745. className={`flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium transition-colors ${
  746. archiveFilter === 'active'
  747. ? 'bg-bambu-green/20 text-bambu-green'
  748. : 'text-bambu-gray hover:bg-bambu-dark-tertiary'
  749. }`}
  750. >
  751. <Package className="w-3.5 h-3.5" />
  752. {t('inventory.active')}
  753. </button>
  754. <button
  755. onClick={() => { setArchiveFilter('archived'); resetPage(); }}
  756. className={`flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium transition-colors ${
  757. archiveFilter === 'archived'
  758. ? 'bg-bambu-green/20 text-bambu-green'
  759. : 'text-bambu-gray hover:bg-bambu-dark-tertiary'
  760. }`}
  761. >
  762. <Archive className="w-3.5 h-3.5" />
  763. {t('inventory.archived')}
  764. </button>
  765. </div>
  766. <div className="w-px h-5 bg-bambu-dark-tertiary" />
  767. {/* All / Used / New chips */}
  768. <div className="flex items-center rounded-lg border border-bambu-dark-tertiary overflow-hidden">
  769. <button
  770. onClick={() => { setUsageFilter('all'); resetPage(); }}
  771. className={`px-3 py-1.5 text-xs font-medium transition-colors ${
  772. usageFilter === 'all'
  773. ? 'bg-bambu-green/20 text-bambu-green'
  774. : 'text-bambu-gray hover:bg-bambu-dark-tertiary'
  775. }`}
  776. >
  777. {t('inventory.all')}
  778. </button>
  779. <button
  780. onClick={() => { setUsageFilter('used'); resetPage(); }}
  781. className={`flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium transition-colors ${
  782. usageFilter === 'used'
  783. ? 'bg-bambu-green/20 text-bambu-green'
  784. : 'text-bambu-gray hover:bg-bambu-dark-tertiary'
  785. }`}
  786. >
  787. <Clock className="w-3.5 h-3.5" />
  788. {t('inventory.used')}
  789. </button>
  790. <button
  791. onClick={() => { setUsageFilter('new'); resetPage(); }}
  792. className={`px-3 py-1.5 text-xs font-medium transition-colors ${
  793. usageFilter === 'new'
  794. ? 'bg-bambu-green/20 text-bambu-green'
  795. : 'text-bambu-gray hover:bg-bambu-dark-tertiary'
  796. }`}
  797. >
  798. {t('inventory.new')}
  799. </button>
  800. </div>
  801. {/* Stock filter chips */}
  802. <div className="flex items-center rounded-lg border border-bambu-dark-tertiary overflow-hidden">
  803. <button
  804. onClick={() => { setStockFilter('all'); resetPage(); }}
  805. className={`px-3 py-1.5 text-xs font-medium transition-colors ${
  806. stockFilter === 'all'
  807. ? 'bg-bambu-green/20 text-bambu-green'
  808. : 'text-bambu-gray hover:bg-bambu-dark-tertiary'
  809. }`}
  810. >
  811. {t('inventory.all')}
  812. </button>
  813. <button
  814. onClick={() => { setStockFilter('stock'); resetPage(); }}
  815. className={`px-3 py-1.5 text-xs font-medium transition-colors ${
  816. stockFilter === 'stock'
  817. ? 'bg-amber-500/20 text-amber-400'
  818. : 'text-bambu-gray hover:bg-bambu-dark-tertiary'
  819. }`}
  820. >
  821. {t('inventory.stock')}
  822. </button>
  823. <button
  824. onClick={() => { setStockFilter('configured'); resetPage(); }}
  825. className={`px-3 py-1.5 text-xs font-medium transition-colors ${
  826. stockFilter === 'configured'
  827. ? 'bg-bambu-green/20 text-bambu-green'
  828. : 'text-bambu-gray hover:bg-bambu-dark-tertiary'
  829. }`}
  830. >
  831. {t('inventory.configured')}
  832. </button>
  833. </div>
  834. <div className="w-px h-5 bg-bambu-dark-tertiary" />
  835. {/* Material dropdown chip */}
  836. <select
  837. value={materialFilter}
  838. onChange={(e) => { setMaterialFilter(e.target.value); resetPage(); }}
  839. className={`px-3 py-1.5 rounded-lg border text-xs font-medium transition-colors cursor-pointer focus:outline-none ${
  840. materialFilter
  841. ? 'bg-bambu-green/20 text-bambu-green border-bambu-green/30'
  842. : 'bg-transparent text-bambu-gray border-bambu-dark-tertiary hover:bg-bambu-dark-tertiary'
  843. }`}
  844. >
  845. <option value="">{t('inventory.material')}</option>
  846. {uniqueMaterials.map((m) => (
  847. <option key={m} value={m}>{m}</option>
  848. ))}
  849. </select>
  850. {/* Brand dropdown chip */}
  851. <select
  852. value={brandFilter}
  853. onChange={(e) => { setBrandFilter(e.target.value); resetPage(); }}
  854. className={`px-3 py-1.5 rounded-lg border text-xs font-medium transition-colors cursor-pointer focus:outline-none ${
  855. brandFilter
  856. ? 'bg-bambu-green/20 text-bambu-green border-bambu-green/30'
  857. : 'bg-transparent text-bambu-gray border-bambu-dark-tertiary hover:bg-bambu-dark-tertiary'
  858. }`}
  859. >
  860. <option value="">{t('inventory.brand')}</option>
  861. {uniqueBrands.map((b) => (
  862. <option key={b} value={b}>{b}</option>
  863. ))}
  864. </select>
  865. {/* Clear filters */}
  866. {hasActiveFilters && (
  867. <>
  868. <div className="w-px h-5 bg-bambu-dark-tertiary" />
  869. <button
  870. onClick={clearAllFilters}
  871. className="flex items-center gap-1 text-xs text-bambu-gray hover:text-bambu-green transition-colors"
  872. >
  873. <X className="w-3.5 h-3.5" />
  874. {t('inventory.clearFilters')}
  875. </button>
  876. </>
  877. )}
  878. {/* Results count */}
  879. <span className="ml-auto text-xs text-bambu-gray">
  880. {sortedSpools.length} {sortedSpools.length !== 1 ? t('inventory.spools') : t('inventory.spool')}
  881. {groupSimilar && totalDisplayItems < sortedSpools.length && ` (${totalDisplayItems} ${t('inventory.groupedRows')})`}
  882. </span>
  883. </div>
  884. {/* Content */}
  885. {isLoading ? (
  886. <div className="flex justify-center py-16">
  887. <Loader2 className="w-8 h-8 text-bambu-green animate-spin" />
  888. </div>
  889. ) : viewMode === 'cards' ? (
  890. /* Cards view */
  891. pagedItems.length > 0 ? (
  892. <>
  893. <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
  894. {pagedItems.map((item) => {
  895. if (item.type === 'group') {
  896. const { key, spools: groupSpools, representative: rep } = item;
  897. const colorStyle = rep.rgba ? `#${rep.rgba.substring(0, 6)}` : '#808080';
  898. const isExpanded = expandedGroups.has(key);
  899. return (
  900. <div key={`group-${key}`} className="col-span-full">
  901. {/* Group header card */}
  902. <div
  903. className="bg-bambu-dark-secondary rounded-lg overflow-hidden border border-bambu-green/30 hover:border-bambu-green transition-colors cursor-pointer"
  904. onClick={() => toggleGroupExpand(key)}
  905. >
  906. <div className="h-10 flex items-center px-4 gap-3" style={{ backgroundColor: colorStyle }}>
  907. <span className="bg-white/90 text-gray-800 px-3 py-0.5 rounded-full text-sm font-medium">
  908. {resolveSpoolColorName(rep.color_name, rep.rgba) || '-'}
  909. </span>
  910. </div>
  911. <div className="px-4 py-3 flex items-center justify-between">
  912. <div className="flex items-center gap-3">
  913. <ChevronDown className={`w-4 h-4 text-bambu-gray transition-transform ${isExpanded ? '' : '-rotate-90'}`} />
  914. <div>
  915. <h3 className="font-semibold text-white">{rep.material}{rep.subtype ? ` ${rep.subtype}` : ''}</h3>
  916. <p className="text-sm text-bambu-gray">{rep.brand || '-'}</p>
  917. </div>
  918. </div>
  919. <div className="flex items-center gap-2">
  920. <span className="text-sm text-bambu-gray">{formatWeight(rep.label_weight)}</span>
  921. <span className="text-xs font-medium bg-bambu-green/20 text-bambu-green px-2 py-0.5 rounded-full">
  922. {t('inventory.groupedSpools', { count: groupSpools.length })}
  923. </span>
  924. </div>
  925. </div>
  926. </div>
  927. {/* Expanded individual spools */}
  928. {isExpanded && (
  929. <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 mt-2 ml-4">
  930. {groupSpools.map((spool) => {
  931. const remaining = Math.max(0, spool.label_weight - spool.weight_used);
  932. const pct = spool.label_weight > 0 ? (remaining / spool.label_weight) * 100 : 0;
  933. const spoolColor = spool.rgba ? `#${spool.rgba.substring(0, 6)}` : '#808080';
  934. return (
  935. <SpoolCard
  936. key={spool.id}
  937. spool={spool}
  938. remaining={remaining}
  939. pct={pct}
  940. colorStyle={spoolColor}
  941. onClick={() => setFormModal({ spool })}
  942. t={t}
  943. />
  944. );
  945. })}
  946. </div>
  947. )}
  948. </div>
  949. );
  950. }
  951. const spool = item.spool;
  952. const remaining = Math.max(0, spool.label_weight - spool.weight_used);
  953. const pct = spool.label_weight > 0 ? (remaining / spool.label_weight) * 100 : 0;
  954. const colorStyle = spool.rgba ? `#${spool.rgba.substring(0, 6)}` : '#808080';
  955. return (
  956. <SpoolCard
  957. key={spool.id}
  958. spool={spool}
  959. remaining={remaining}
  960. pct={pct}
  961. colorStyle={colorStyle}
  962. onClick={() => setFormModal({ spool })}
  963. t={t}
  964. />
  965. );
  966. })}
  967. </div>
  968. {/* Pagination for cards */}
  969. <PaginationBar
  970. pageIndex={safePageIndex}
  971. pageSize={pageSize}
  972. totalRows={totalDisplayItems}
  973. totalPages={totalPages}
  974. onPageChange={setPageIndex}
  975. onPageSizeChange={handlePageSizeChange}
  976. t={t}
  977. />
  978. </>
  979. ) : (
  980. <EmptyFilterState
  981. hasFilters={hasActiveFilters}
  982. onAddSpool={() => setFormModal({ spool: null })}
  983. t={t}
  984. />
  985. )
  986. ) : (
  987. /* Table view */
  988. pagedItems.length > 0 ? (
  989. <div className="bg-bambu-dark-secondary rounded-lg overflow-hidden border border-bambu-dark-tertiary">
  990. <div className="overflow-x-auto">
  991. <table className="w-full">
  992. <thead>
  993. <tr className="border-b border-bambu-dark-tertiary bg-bambu-dark-tertiary/30">
  994. {visibleColumns.map((colId) => {
  995. const sortable = !!columnSortValues[colId];
  996. const isActive = sortState?.column === colId;
  997. return (
  998. <th
  999. key={colId}
  1000. className={`text-left py-3 px-4 text-xs font-medium uppercase tracking-wide select-none ${colId === 'remaining' ? 'min-w-[150px]' : ''} ${
  1001. sortable ? 'cursor-pointer hover:text-bambu-green transition-colors' : ''
  1002. } ${isActive ? 'text-bambu-green' : 'text-bambu-gray'}`}
  1003. onClick={sortable ? () => handleSort(colId) : undefined}
  1004. >
  1005. <span className="inline-flex items-center gap-1">
  1006. {columnHeaders[colId]?.(t) ?? colId}
  1007. {sortable && (
  1008. isActive
  1009. ? sortState.direction === 'asc'
  1010. ? <ArrowUp className="w-3 h-3" />
  1011. : <ArrowDown className="w-3 h-3" />
  1012. : <ArrowUpDown className="w-3 h-3 opacity-30" />
  1013. )}
  1014. </span>
  1015. </th>
  1016. );
  1017. })}
  1018. <th className="text-right py-3 px-4 text-xs font-medium text-bambu-gray uppercase tracking-wide">{t('common.actions')}</th>
  1019. </tr>
  1020. </thead>
  1021. <tbody>
  1022. {pagedItems.map((item) => {
  1023. if (item.type === 'group') {
  1024. const { key, spools: groupSpools, representative: rep } = item;
  1025. const isExpanded = expandedGroups.has(key);
  1026. const remaining = Math.max(0, rep.label_weight - rep.weight_used);
  1027. const pct = rep.label_weight > 0 ? (remaining / rep.label_weight) * 100 : 0;
  1028. return (
  1029. <SpoolTableGroup
  1030. key={`group-${key}`}
  1031. spools={groupSpools}
  1032. representative={rep}
  1033. remaining={remaining}
  1034. pct={pct}
  1035. isExpanded={isExpanded}
  1036. onToggle={() => toggleGroupExpand(key)}
  1037. onEdit={(s) => setFormModal({ spool: s })}
  1038. onArchive={(id) => setConfirmAction({ type: 'archive', spoolId: id })}
  1039. onDelete={(id) => setConfirmAction({ type: 'delete', spoolId: id })}
  1040. visibleColumns={visibleColumns}
  1041. assignmentMap={assignmentMap}
  1042. currencySymbol={currencySymbol}
  1043. dateFormat={dateFormat}
  1044. t={t}
  1045. />
  1046. );
  1047. }
  1048. const spool = item.spool;
  1049. const remaining = Math.max(0, spool.label_weight - spool.weight_used);
  1050. const pct = spool.label_weight > 0 ? (remaining / spool.label_weight) * 100 : 0;
  1051. return (
  1052. <SpoolTableRow
  1053. key={spool.id}
  1054. spool={spool}
  1055. remaining={remaining}
  1056. pct={pct}
  1057. onEdit={() => setFormModal({ spool })}
  1058. onRestore={() => restoreMutation.mutate(spool.id)}
  1059. onArchive={() => setConfirmAction({ type: 'archive', spoolId: spool.id })}
  1060. onDelete={() => setConfirmAction({ type: 'delete', spoolId: spool.id })}
  1061. visibleColumns={visibleColumns}
  1062. assignmentMap={assignmentMap}
  1063. currencySymbol={currencySymbol}
  1064. dateFormat={dateFormat}
  1065. t={t}
  1066. />
  1067. );
  1068. })}
  1069. </tbody>
  1070. </table>
  1071. </div>
  1072. {/* Pagination inside card footer */}
  1073. <div className="flex items-center justify-between px-4 py-3 bg-bambu-dark-tertiary/50 border-t border-bambu-dark-tertiary text-sm">
  1074. <span className="text-bambu-gray">
  1075. {showAll
  1076. ? `${totalDisplayItems} ${totalDisplayItems !== 1 ? t('inventory.spools') : t('inventory.spool')}`
  1077. : <>{t('inventory.showing')} {safePageIndex * effectivePageSize + 1} {t('inventory.to')}{' '}
  1078. {Math.min((safePageIndex + 1) * effectivePageSize, totalDisplayItems)}{' '}
  1079. {t('inventory.of')} {totalDisplayItems} {t('inventory.spools')}</>
  1080. }
  1081. </span>
  1082. <div className="flex items-center gap-2">
  1083. <span className="text-bambu-gray">{t('inventory.show')}</span>
  1084. <select
  1085. value={pageSize}
  1086. onChange={(e) => handlePageSizeChange(Number(e.target.value))}
  1087. className="px-2 py-1 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-white text-sm focus:outline-none focus:border-bambu-green"
  1088. >
  1089. {[15, 30, 50, 100].map((n) => (
  1090. <option key={n} value={n}>{n}</option>
  1091. ))}
  1092. <option value={-1}>{t('inventory.all')}</option>
  1093. </select>
  1094. {!showAll && (
  1095. <>
  1096. <button
  1097. onClick={() => setPageIndex(0)}
  1098. disabled={safePageIndex === 0}
  1099. className="p-1.5 rounded text-bambu-gray hover:text-white disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
  1100. title="First page"
  1101. >
  1102. <ChevronsLeft className="w-4 h-4" />
  1103. </button>
  1104. <button
  1105. onClick={() => setPageIndex((p) => Math.max(0, p - 1))}
  1106. disabled={safePageIndex === 0}
  1107. className="p-1.5 rounded text-bambu-gray hover:text-white disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
  1108. >
  1109. <ChevronLeft className="w-4 h-4" />
  1110. </button>
  1111. <span className="text-bambu-gray px-2 whitespace-nowrap">
  1112. {t('inventory.page')} {safePageIndex + 1} {t('inventory.of')} {totalPages}
  1113. </span>
  1114. <button
  1115. onClick={() => setPageIndex((p) => Math.min(totalPages - 1, p + 1))}
  1116. disabled={safePageIndex >= totalPages - 1}
  1117. className="p-1.5 rounded text-bambu-gray hover:text-white disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
  1118. >
  1119. <ChevronRight className="w-4 h-4" />
  1120. </button>
  1121. <button
  1122. onClick={() => setPageIndex(totalPages - 1)}
  1123. disabled={safePageIndex >= totalPages - 1}
  1124. className="p-1.5 rounded text-bambu-gray hover:text-white disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
  1125. title="Last page"
  1126. >
  1127. <ChevronsRight className="w-4 h-4" />
  1128. </button>
  1129. </>
  1130. )}
  1131. </div>
  1132. </div>
  1133. </div>
  1134. ) : (
  1135. <EmptyFilterState
  1136. hasFilters={hasActiveFilters}
  1137. onAddSpool={() => setFormModal({ spool: null })}
  1138. t={t}
  1139. />
  1140. )
  1141. )}
  1142. {/* Spool Form Modal */}
  1143. {formModal !== null && (
  1144. <SpoolFormModal
  1145. isOpen={true}
  1146. onClose={() => setFormModal(null)}
  1147. spool={formModal.spool}
  1148. currencySymbol={currencySymbol}
  1149. />
  1150. )}
  1151. {/* Confirm Modal (delete / archive) */}
  1152. {confirmAction && (
  1153. <ConfirmModal
  1154. title={confirmAction.type === 'delete' ? t('common.delete') : t('inventory.archive')}
  1155. message={confirmAction.type === 'delete' ? t('inventory.deleteConfirm') : t('inventory.archiveConfirm')}
  1156. confirmText={confirmAction.type === 'delete' ? t('common.delete') : t('inventory.archive')}
  1157. variant={confirmAction.type === 'delete' ? 'danger' : 'warning'}
  1158. onConfirm={() => {
  1159. if (confirmAction.type === 'delete') {
  1160. deleteMutation.mutate(confirmAction.spoolId);
  1161. } else {
  1162. archiveMutation.mutate(confirmAction.spoolId);
  1163. }
  1164. setConfirmAction(null);
  1165. }}
  1166. onCancel={() => setConfirmAction(null)}
  1167. />
  1168. )}
  1169. {/* Column Config Modal */}
  1170. <ColumnConfigModal
  1171. isOpen={showColumnModal}
  1172. onClose={() => setShowColumnModal(false)}
  1173. columns={columnConfig}
  1174. defaultColumns={DEFAULT_COLUMNS}
  1175. onSave={handleColumnConfigSave}
  1176. />
  1177. </div>
  1178. );
  1179. }
  1180. /* Pagination bar (reused for cards view) */
  1181. function PaginationBar({
  1182. pageIndex, pageSize, totalRows, totalPages, onPageChange, onPageSizeChange, t,
  1183. }: {
  1184. pageIndex: number;
  1185. pageSize: number;
  1186. totalRows: number;
  1187. totalPages: number;
  1188. onPageChange: (page: number) => void;
  1189. onPageSizeChange: (size: number) => void;
  1190. t: (key: string) => string;
  1191. }) {
  1192. const isShowAll = pageSize === -1;
  1193. if (totalPages <= 1 && !isShowAll) return null;
  1194. const effectiveSize = isShowAll ? totalRows || 1 : pageSize;
  1195. return (
  1196. <div className="flex items-center justify-between pt-2 text-sm">
  1197. <span className="text-bambu-gray">
  1198. {isShowAll
  1199. ? `${totalRows} ${totalRows !== 1 ? t('inventory.spools') : t('inventory.spool')}`
  1200. : <>{t('inventory.showing')} {pageIndex * effectiveSize + 1} {t('inventory.to')}{' '}
  1201. {Math.min((pageIndex + 1) * effectiveSize, totalRows)}{' '}
  1202. {t('inventory.of')} {totalRows} {t('inventory.spools')}</>
  1203. }
  1204. </span>
  1205. <div className="flex items-center gap-2">
  1206. <span className="text-bambu-gray">{t('inventory.show')}</span>
  1207. <select
  1208. value={pageSize}
  1209. onChange={(e) => onPageSizeChange(Number(e.target.value))}
  1210. className="px-2 py-1 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-white text-sm focus:outline-none focus:border-bambu-green"
  1211. >
  1212. {[15, 30, 50, 100].map((n) => (
  1213. <option key={n} value={n}>{n}</option>
  1214. ))}
  1215. <option value={-1}>{t('inventory.all')}</option>
  1216. </select>
  1217. {!isShowAll && (
  1218. <>
  1219. <button
  1220. onClick={() => onPageChange(0)}
  1221. disabled={pageIndex === 0}
  1222. className="p-1.5 rounded text-bambu-gray hover:text-white disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
  1223. >
  1224. <ChevronsLeft className="w-4 h-4" />
  1225. </button>
  1226. <button
  1227. onClick={() => onPageChange(Math.max(0, pageIndex - 1))}
  1228. disabled={pageIndex === 0}
  1229. className="p-1.5 rounded text-bambu-gray hover:text-white disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
  1230. >
  1231. <ChevronLeft className="w-4 h-4" />
  1232. </button>
  1233. <span className="text-bambu-gray px-2 whitespace-nowrap">
  1234. {t('inventory.page')} {pageIndex + 1} {t('inventory.of')} {totalPages}
  1235. </span>
  1236. <button
  1237. onClick={() => onPageChange(Math.min(totalPages - 1, pageIndex + 1))}
  1238. disabled={pageIndex >= totalPages - 1}
  1239. className="p-1.5 rounded text-bambu-gray hover:text-white disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
  1240. >
  1241. <ChevronRight className="w-4 h-4" />
  1242. </button>
  1243. <button
  1244. onClick={() => onPageChange(totalPages - 1)}
  1245. disabled={pageIndex >= totalPages - 1}
  1246. className="p-1.5 rounded text-bambu-gray hover:text-white disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
  1247. >
  1248. <ChevronsRight className="w-4 h-4" />
  1249. </button>
  1250. </>
  1251. )}
  1252. </div>
  1253. </div>
  1254. );
  1255. }
  1256. /* Spool card for cards view */
  1257. function SpoolCard({
  1258. spool, remaining, pct, colorStyle, onClick, t,
  1259. }: {
  1260. spool: InventorySpool;
  1261. remaining: number;
  1262. pct: number;
  1263. colorStyle: string;
  1264. onClick: () => void;
  1265. t: (key: string, opts?: Record<string, unknown>) => string;
  1266. }) {
  1267. return (
  1268. <div
  1269. className={`bg-bambu-dark-secondary rounded-lg overflow-hidden border border-bambu-dark-tertiary hover:border-bambu-green transition-colors cursor-pointer ${spool.archived_at ? 'opacity-50' : ''}`}
  1270. onClick={onClick}
  1271. >
  1272. <div className="h-14 flex items-center justify-center" style={{ backgroundColor: colorStyle }}>
  1273. <span className="bg-white/90 text-gray-800 px-3 py-0.5 rounded-full text-sm font-medium">
  1274. {resolveSpoolColorName(spool.color_name, spool.rgba) || '-'}
  1275. </span>
  1276. </div>
  1277. <div className="p-4 space-y-3">
  1278. <div className="flex items-start justify-between gap-2">
  1279. <div>
  1280. <h3 className="font-semibold text-white">
  1281. {spool.material}{spool.subtype ? ` ${spool.subtype}` : ''}
  1282. </h3>
  1283. <p className="text-sm text-bambu-gray">{spool.brand || '-'}</p>
  1284. </div>
  1285. <span className="text-xs font-mono text-bambu-gray bg-bambu-dark-tertiary px-2 py-1 rounded">
  1286. #{spool.id}
  1287. </span>
  1288. </div>
  1289. <div>
  1290. <div className="flex justify-between text-xs text-bambu-gray mb-1">
  1291. <span>{t('inventory.remaining')}</span>
  1292. <span>{Math.round(pct)}%</span>
  1293. </div>
  1294. <div className="flex items-center gap-2">
  1295. <div className="flex-1 h-2 bg-bambu-dark-tertiary rounded-full overflow-hidden">
  1296. <div
  1297. className={`h-full rounded-full ${pct > 50 ? 'bg-bambu-green' : pct > 20 ? 'bg-yellow-500' : 'bg-red-500'}`}
  1298. style={{ width: `${Math.min(pct, 100)}%` }}
  1299. />
  1300. </div>
  1301. <span className="text-xs text-bambu-gray min-w-[40px] text-right">
  1302. {Math.round(remaining)}g
  1303. </span>
  1304. </div>
  1305. </div>
  1306. <div className="grid grid-cols-2 gap-2 text-xs">
  1307. <div>
  1308. <span className="text-bambu-gray/60">{t('inventory.labelWeight')}: </span>
  1309. <span className="text-bambu-gray">{formatWeight(spool.label_weight)}</span>
  1310. </div>
  1311. <div>
  1312. <span className="text-bambu-gray/60">{t('inventory.weightUsed')}: </span>
  1313. <span className="text-bambu-gray">
  1314. {spool.weight_used > 0 ? formatWeight(spool.weight_used) : '-'}
  1315. </span>
  1316. </div>
  1317. </div>
  1318. {spool.note && (
  1319. <div
  1320. className="text-xs text-bambu-gray/60 pt-2 border-t border-bambu-dark-tertiary truncate"
  1321. title={spool.note}
  1322. >
  1323. {spool.note}
  1324. </div>
  1325. )}
  1326. </div>
  1327. </div>
  1328. );
  1329. }
  1330. /* Single spool row for table view */
  1331. function SpoolTableRow({
  1332. spool, remaining, pct, onEdit, onRestore, onArchive, onDelete,
  1333. visibleColumns, assignmentMap, currencySymbol, dateFormat, t,
  1334. }: {
  1335. spool: InventorySpool;
  1336. remaining: number;
  1337. pct: number;
  1338. onEdit: () => void;
  1339. onRestore: () => void;
  1340. onArchive: () => void;
  1341. onDelete: () => void;
  1342. visibleColumns: string[];
  1343. assignmentMap: Record<number, SpoolAssignment>;
  1344. currencySymbol: string;
  1345. dateFormat: DateFormat;
  1346. t: TFn;
  1347. }) {
  1348. return (
  1349. <tr
  1350. className={`border-b border-bambu-dark-tertiary/50 hover:bg-bambu-dark-tertiary/30 transition-colors cursor-pointer ${
  1351. spool.archived_at ? 'opacity-50' : ''
  1352. }`}
  1353. onClick={onEdit}
  1354. >
  1355. {visibleColumns.map((colId) => (
  1356. <td key={colId} className="py-3 px-4">
  1357. {columnCells[colId]?.({ spool, remaining, pct, assignmentMap, currencySymbol, dateFormat, t })}
  1358. </td>
  1359. ))}
  1360. <td className="py-3 px-4">
  1361. <div className="flex items-center justify-end gap-1" onClick={(e) => e.stopPropagation()}>
  1362. <button onClick={onEdit} className="p-1.5 text-bambu-gray hover:text-white rounded transition-colors" title={t('inventory.editSpool')}>
  1363. <Edit2 className="w-4 h-4" />
  1364. </button>
  1365. {spool.archived_at ? (
  1366. <button onClick={onRestore} className="p-1.5 text-bambu-gray hover:text-bambu-green rounded transition-colors" title={t('inventory.restore')}>
  1367. <RotateCcw className="w-4 h-4" />
  1368. </button>
  1369. ) : (
  1370. <button onClick={onArchive} className="p-1.5 text-bambu-gray hover:text-yellow-400 rounded transition-colors" title={t('inventory.archive')}>
  1371. <Archive className="w-4 h-4" />
  1372. </button>
  1373. )}
  1374. <button onClick={onDelete} className="p-1.5 text-bambu-gray hover:text-red-400 rounded transition-colors" title={t('common.delete')}>
  1375. <Trash2 className="w-4 h-4" />
  1376. </button>
  1377. </div>
  1378. </td>
  1379. </tr>
  1380. );
  1381. }
  1382. /* Grouped spool rows for table view */
  1383. function SpoolTableGroup({
  1384. spools, representative, remaining, pct, isExpanded, onToggle,
  1385. onEdit, onArchive, onDelete,
  1386. visibleColumns, assignmentMap, currencySymbol, dateFormat, t,
  1387. }: {
  1388. spools: InventorySpool[];
  1389. representative: InventorySpool;
  1390. remaining: number;
  1391. pct: number;
  1392. isExpanded: boolean;
  1393. onToggle: () => void;
  1394. onEdit: (spool: InventorySpool) => void;
  1395. onArchive: (id: number) => void;
  1396. onDelete: (id: number) => void;
  1397. visibleColumns: string[];
  1398. assignmentMap: Record<number, SpoolAssignment>;
  1399. currencySymbol: string;
  1400. dateFormat: DateFormat;
  1401. t: TFn;
  1402. }) {
  1403. return (
  1404. <>
  1405. {/* Group header row */}
  1406. <tr
  1407. className="border-b border-bambu-dark-tertiary/50 hover:bg-bambu-dark-tertiary/30 transition-colors cursor-pointer bg-bambu-green/5"
  1408. onClick={onToggle}
  1409. >
  1410. {visibleColumns.map((colId, idx) => (
  1411. <td key={colId} className="py-3 px-4">
  1412. {idx === 0 ? (
  1413. <div className="flex items-center gap-2">
  1414. <ChevronDown className={`w-4 h-4 text-bambu-gray transition-transform ${isExpanded ? '' : '-rotate-90'}`} />
  1415. {columnCells[colId]?.({ spool: representative, remaining, pct, assignmentMap, currencySymbol, dateFormat, t })}
  1416. </div>
  1417. ) : colId === 'id' ? (
  1418. <span className="text-xs font-medium bg-bambu-green/20 text-bambu-green px-2 py-0.5 rounded-full">
  1419. {t('inventory.groupedSpools', { count: spools.length })}
  1420. </span>
  1421. ) : (
  1422. columnCells[colId]?.({ spool: representative, remaining, pct, assignmentMap, currencySymbol, dateFormat, t })
  1423. )}
  1424. </td>
  1425. ))}
  1426. <td className="py-3 px-4">
  1427. <span className="text-xs text-bambu-gray">
  1428. {spools.map((s) => `#${s.id}`).join(', ')}
  1429. </span>
  1430. </td>
  1431. </tr>
  1432. {/* Expanded individual rows */}
  1433. {isExpanded && spools.map((spool) => {
  1434. const r = Math.max(0, spool.label_weight - spool.weight_used);
  1435. const p = spool.label_weight > 0 ? (r / spool.label_weight) * 100 : 0;
  1436. return (
  1437. <SpoolTableRow
  1438. key={spool.id}
  1439. spool={spool}
  1440. remaining={r}
  1441. pct={p}
  1442. onEdit={() => onEdit(spool)}
  1443. onRestore={() => {}}
  1444. onArchive={() => onArchive(spool.id)}
  1445. onDelete={() => onDelete(spool.id)}
  1446. visibleColumns={visibleColumns}
  1447. assignmentMap={assignmentMap}
  1448. currencySymbol={currencySymbol}
  1449. dateFormat={dateFormat}
  1450. t={t}
  1451. />
  1452. );
  1453. })}
  1454. </>
  1455. );
  1456. }
  1457. /* Empty state matching SpoolBuddy's design */
  1458. function EmptyFilterState({
  1459. hasFilters,
  1460. onAddSpool,
  1461. t,
  1462. }: {
  1463. hasFilters: boolean;
  1464. onAddSpool: () => void;
  1465. t: (key: string) => string;
  1466. }) {
  1467. return (
  1468. <div className="flex flex-col items-center justify-center py-16 px-4">
  1469. <div className="relative mb-6">
  1470. <div className="absolute inset-0 -m-4 bg-bambu-green/5 rounded-full blur-2xl" />
  1471. <div className="relative flex items-center justify-center w-24 h-24 rounded-2xl bg-gradient-to-br from-bambu-dark-secondary to-bambu-dark-tertiary border border-bambu-dark-tertiary shadow-lg">
  1472. <div className="absolute -top-1 -right-1 w-3 h-3 rounded-full bg-bambu-green/30" />
  1473. <div className="absolute -bottom-2 -left-2 w-2 h-2 rounded-full bg-bambu-green/20" />
  1474. {hasFilters ? (
  1475. <Search className="w-10 h-10 text-bambu-gray/40" strokeWidth={1.5} />
  1476. ) : (
  1477. <div className="relative">
  1478. <div className="w-14 h-14 rounded-full border-4 border-bambu-gray/20 flex items-center justify-center">
  1479. <div className="w-6 h-6 rounded-full bg-bambu-gray/10 border-2 border-bambu-gray/20" />
  1480. </div>
  1481. <div className="absolute -bottom-1 -right-1 w-6 h-6 rounded-full bg-bambu-green flex items-center justify-center shadow-md">
  1482. <span className="text-white text-lg font-bold leading-none">+</span>
  1483. </div>
  1484. </div>
  1485. )}
  1486. </div>
  1487. </div>
  1488. <h3 className="text-lg font-semibold text-white mb-2 text-center">
  1489. {hasFilters ? t('inventory.noSpoolsMatch') : t('inventory.noSpools').split('.')[0]}
  1490. </h3>
  1491. <p className="text-sm text-bambu-gray text-center max-w-sm mb-6">
  1492. {hasFilters
  1493. ? t('inventory.noSpoolsMatchDesc')
  1494. : t('inventory.noSpools')
  1495. }
  1496. </p>
  1497. {!hasFilters && (
  1498. <Button onClick={onAddSpool}>
  1499. <Package className="w-4 h-4" />
  1500. {t('inventory.addSpool')}
  1501. </Button>
  1502. )}
  1503. </div>
  1504. );
  1505. }