InventoryPage.tsx 51 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213
  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,
  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. type ArchiveFilter = 'active' | 'archived';
  19. type UsageFilter = 'all' | 'used' | 'new';
  20. type ViewMode = 'table' | 'cards';
  21. type SortDirection = 'asc' | 'desc';
  22. type SortState = { column: string; direction: SortDirection } | null;
  23. // Column definitions for the inventory table
  24. const COLUMN_CONFIG_KEY = 'bambuddy-inventory-columns';
  25. const DEFAULT_COLUMNS: ColumnConfig[] = [
  26. { id: 'id', label: '#', visible: true },
  27. { id: 'added_time', label: 'Added', visible: true },
  28. { id: 'encode_time', label: 'Encoded', visible: false },
  29. { id: 'last_used_time', label: 'Last Used', visible: false },
  30. { id: 'rgba', label: 'Color', visible: true },
  31. { id: 'material', label: 'Material', visible: true },
  32. { id: 'subtype', label: 'Subtype', visible: true },
  33. { id: 'color_name', label: 'Color Name', visible: false },
  34. { id: 'brand', label: 'Brand', visible: true },
  35. { id: 'slicer_filament', label: 'Slicer Filament', visible: false },
  36. { id: 'location', label: 'Location', visible: true },
  37. { id: 'label_weight', label: 'Label', visible: true },
  38. { id: 'net', label: 'Net', visible: true },
  39. { id: 'gross', label: 'Gross', visible: false },
  40. { id: 'added_full', label: 'Full', visible: false },
  41. { id: 'used', label: 'Used', visible: false },
  42. { id: 'printed_total', label: 'Printed Total', visible: false },
  43. { id: 'printed_since_weight', label: 'Printed Since Weight', visible: false },
  44. { id: 'note', label: 'Note', visible: false },
  45. { id: 'pa_k', label: 'PA(K)', visible: true },
  46. { id: 'tag_id', label: 'Tag ID', visible: false },
  47. { id: 'data_origin', label: 'Data Origin', visible: false },
  48. { id: 'tag_type', label: 'Linked Tag Type', visible: false },
  49. { id: 'remaining', label: 'Remaining', visible: true },
  50. ];
  51. function loadColumnConfig(): ColumnConfig[] {
  52. try {
  53. const stored = localStorage.getItem(COLUMN_CONFIG_KEY);
  54. if (stored) {
  55. const parsed = JSON.parse(stored) as ColumnConfig[];
  56. const defaultIds = new Set(DEFAULT_COLUMNS.map((c) => c.id));
  57. const storedIds = new Set(parsed.map((c) => c.id));
  58. // Keep stored columns that still exist in defaults
  59. const validStored = parsed.filter((c) => defaultIds.has(c.id));
  60. // Add any new default columns not in stored config
  61. const newColumns = DEFAULT_COLUMNS.filter((c) => !storedIds.has(c.id));
  62. return [...validStored, ...newColumns];
  63. }
  64. } catch {
  65. // Ignore errors
  66. }
  67. return DEFAULT_COLUMNS.map((c) => ({ ...c }));
  68. }
  69. function saveColumnConfig(config: ColumnConfig[]) {
  70. try {
  71. localStorage.setItem(COLUMN_CONFIG_KEY, JSON.stringify(config));
  72. } catch {
  73. // Ignore errors
  74. }
  75. }
  76. function formatWeight(g: number, useKg = false): string {
  77. if (useKg && g >= 1000) return `${(g / 1000).toFixed(1)}kg`;
  78. return `${Math.round(g)}g`;
  79. }
  80. // Material color mapping for pills
  81. const MATERIAL_COLORS: Record<string, string> = {
  82. PLA: 'bg-green-500/20 text-green-400',
  83. ABS: 'bg-red-500/20 text-red-400',
  84. PETG: 'bg-blue-500/20 text-blue-400',
  85. TPU: 'bg-purple-500/20 text-purple-400',
  86. ASA: 'bg-orange-500/20 text-orange-400',
  87. PA: 'bg-yellow-500/20 text-yellow-400',
  88. PC: 'bg-cyan-500/20 text-cyan-400',
  89. PET: 'bg-sky-500/20 text-sky-400',
  90. };
  91. type TFn = (key: string) => string;
  92. function formatDate(dateStr: string | null): string {
  93. if (!dateStr) return '-';
  94. const date = new Date(dateStr);
  95. return date.toLocaleDateString('en-GB', { day: '2-digit', month: '2-digit', year: '2-digit' });
  96. }
  97. type CellCtx = {
  98. spool: InventorySpool;
  99. remaining: number;
  100. pct: number;
  101. assignmentMap: Record<number, SpoolAssignment>;
  102. };
  103. // Column header labels (25 columns — matching SpoolBuddy exactly)
  104. const columnHeaders: Record<string, (t: TFn) => string> = {
  105. id: () => '#',
  106. added_time: () => 'Added',
  107. encode_time: () => 'Encoded',
  108. last_used_time: () => 'Last Used',
  109. rgba: (t) => t('inventory.color'),
  110. material: (t) => t('inventory.material'),
  111. subtype: (t) => t('inventory.subtype'),
  112. color_name: (t) => t('inventory.colorName'),
  113. brand: (t) => t('inventory.brand'),
  114. slicer_filament: (t) => t('inventory.slicerFilament'),
  115. location: () => 'Location',
  116. label_weight: (t) => t('inventory.labelWeight'),
  117. net: (t) => t('inventory.net'),
  118. gross: () => 'Gross',
  119. added_full: () => 'Full',
  120. used: (t) => t('inventory.weightUsed'),
  121. printed_total: () => 'Printed Total',
  122. printed_since_weight: () => 'Printed Since Weight',
  123. note: (t) => t('inventory.note'),
  124. pa_k: () => 'PA(K)',
  125. tag_id: () => 'Tag ID',
  126. data_origin: () => 'Data Origin',
  127. tag_type: () => 'Linked Tag Type',
  128. remaining: (t) => t('inventory.remaining'),
  129. };
  130. // Column cell renderers (25 columns — matching SpoolBuddy exactly)
  131. const columnCells: Record<string, (ctx: CellCtx) => ReactNode> = {
  132. id: ({ spool }) => (
  133. <span className="text-sm font-medium text-white">{spool.id}</span>
  134. ),
  135. added_time: ({ spool }) => (
  136. <span className="text-sm text-bambu-gray">{formatDate(spool.created_at)}</span>
  137. ),
  138. encode_time: ({ spool }) => (
  139. <span className="text-sm text-bambu-gray">{formatDate(spool.encode_time)}</span>
  140. ),
  141. last_used_time: ({ spool }) => (
  142. <span className="text-sm text-bambu-gray">{spool.last_used ? formatDate(spool.last_used) : 'Never'}</span>
  143. ),
  144. rgba: ({ spool }) => (
  145. <div className="flex items-center justify-center">
  146. <span
  147. className="w-5 h-5 rounded-full border border-white/20 flex-shrink-0"
  148. style={{ backgroundColor: spool.rgba ? `#${spool.rgba.substring(0, 6)}` : '#808080' }}
  149. title={spool.rgba ? `#${spool.rgba.substring(0, 6)}` : undefined}
  150. />
  151. </div>
  152. ),
  153. material: ({ spool }) => (
  154. <span className="text-sm text-white">{spool.material}</span>
  155. ),
  156. subtype: ({ spool }) => (
  157. <span className="text-sm text-bambu-gray">{spool.subtype || '-'}</span>
  158. ),
  159. color_name: ({ spool }) => (
  160. <span className="text-sm text-bambu-gray">{resolveSpoolColorName(spool.color_name, spool.rgba) || '-'}</span>
  161. ),
  162. brand: ({ spool }) => (
  163. <span className="text-sm text-bambu-gray">{spool.brand || '-'}</span>
  164. ),
  165. slicer_filament: ({ spool }) => (
  166. <span className="text-sm text-bambu-gray" title={spool.slicer_filament || undefined}>
  167. {spool.slicer_filament_name || spool.slicer_filament || '-'}
  168. </span>
  169. ),
  170. location: ({ spool, assignmentMap }) => {
  171. const assignment = assignmentMap[spool.id];
  172. if (!assignment) return <span className="text-sm text-bambu-gray">-</span>;
  173. const printerLabel = assignment.printer_name || `Printer ${assignment.printer_id}`;
  174. // Bambu slot notation: AMS 0=A, 1=B, 2=C, 3=D; tray 0-based → 1-based
  175. const slotLetter = String.fromCharCode(65 + assignment.ams_id);
  176. const slotNumber = assignment.tray_id + 1;
  177. return (
  178. <span className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-purple-500/20 text-purple-400">
  179. {printerLabel} {slotLetter}{slotNumber}
  180. </span>
  181. );
  182. },
  183. label_weight: ({ spool }) => (
  184. <span className="text-sm text-white">{formatWeight(spool.label_weight)}</span>
  185. ),
  186. net: ({ remaining }) => (
  187. <span className="text-sm text-white">{formatWeight(remaining)}</span>
  188. ),
  189. gross: ({ spool, remaining }) => (
  190. <span className="text-sm text-bambu-gray">{formatWeight(remaining + spool.core_weight)}</span>
  191. ),
  192. added_full: ({ spool }) => (
  193. <span className="text-sm text-bambu-gray">{spool.added_full == null ? '-' : spool.added_full ? 'Yes' : 'No'}</span>
  194. ),
  195. used: ({ spool }) => (
  196. <span className="text-sm text-bambu-gray">{spool.weight_used > 0 ? formatWeight(spool.weight_used) : '-'}</span>
  197. ),
  198. printed_total: () => (
  199. <span className="text-sm text-bambu-gray/50">-</span>
  200. ),
  201. printed_since_weight: () => (
  202. <span className="text-sm text-bambu-gray/50">-</span>
  203. ),
  204. note: ({ spool }) => (
  205. <span className="text-sm text-bambu-gray max-w-[150px] truncate block" title={spool.note || undefined}>{spool.note || '-'}</span>
  206. ),
  207. pa_k: ({ spool }) => {
  208. const count = spool.k_profiles?.length ?? 0;
  209. if (count === 0) return <span className="text-sm text-bambu-gray">-</span>;
  210. return (
  211. <span className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-bambu-green/20 text-bambu-green">
  212. K
  213. </span>
  214. );
  215. },
  216. tag_id: ({ spool }) => {
  217. const tag = spool.tag_uid || spool.tray_uuid;
  218. if (!tag) return <span className="text-sm text-bambu-gray/50">-</span>;
  219. return (
  220. <span className="text-sm text-bambu-gray font-mono" title={tag}>
  221. {tag.length > 12 ? `${tag.slice(0, 6)}...${tag.slice(-4)}` : tag}
  222. </span>
  223. );
  224. },
  225. data_origin: ({ spool }) => (
  226. <span className="text-sm text-bambu-gray">{spool.data_origin || '-'}</span>
  227. ),
  228. tag_type: ({ spool }) => (
  229. <span className="text-sm text-bambu-gray">{spool.tag_type || '-'}</span>
  230. ),
  231. remaining: ({ remaining, pct }) => (
  232. <div className="flex items-center gap-2">
  233. <div className="flex-1 h-2 bg-bambu-dark-tertiary rounded-full overflow-hidden">
  234. <div
  235. className={`h-full rounded-full ${pct > 50 ? 'bg-bambu-green' : pct > 20 ? 'bg-yellow-500' : 'bg-red-500'}`}
  236. style={{ width: `${Math.min(pct, 100)}%` }}
  237. />
  238. </div>
  239. <span className="text-xs text-bambu-gray min-w-[40px] text-right">{Math.round(remaining)}g</span>
  240. </div>
  241. ),
  242. };
  243. // Sort value extractors — return a comparable value for each sortable column
  244. const columnSortValues: Record<string, (spool: InventorySpool, assignmentMap: Record<number, SpoolAssignment>) => string | number> = {
  245. id: (s) => s.id,
  246. added_time: (s) => s.created_at || '',
  247. encode_time: (s) => s.encode_time || '',
  248. last_used_time: (s) => s.last_used || '',
  249. material: (s) => (s.material || '').toLowerCase(),
  250. subtype: (s) => (s.subtype || '').toLowerCase(),
  251. color_name: (s) => (s.color_name || '').toLowerCase(),
  252. brand: (s) => (s.brand || '').toLowerCase(),
  253. slicer_filament: (s) => (s.slicer_filament_name || s.slicer_filament || '').toLowerCase(),
  254. location: (s, am) => {
  255. const a = am[s.id];
  256. if (!a) return '';
  257. return `${a.printer_name || ''} ${String.fromCharCode(65 + a.ams_id)}${a.tray_id + 1}`;
  258. },
  259. label_weight: (s) => s.label_weight,
  260. net: (s) => Math.max(0, s.label_weight - s.weight_used),
  261. gross: (s) => Math.max(0, s.label_weight - s.weight_used) + s.core_weight,
  262. used: (s) => s.weight_used,
  263. remaining: (s) => s.label_weight > 0 ? Math.max(0, s.label_weight - s.weight_used) / s.label_weight : 0,
  264. note: (s) => (s.note || '').toLowerCase(),
  265. data_origin: (s) => (s.data_origin || '').toLowerCase(),
  266. tag_type: (s) => (s.tag_type || '').toLowerCase(),
  267. };
  268. const SORT_STATE_KEY = 'bambuddy-inventory-sort';
  269. function loadSortState(): SortState {
  270. try {
  271. const stored = localStorage.getItem(SORT_STATE_KEY);
  272. if (stored) return JSON.parse(stored);
  273. } catch { /* ignore */ }
  274. return null;
  275. }
  276. function saveSortState(state: SortState) {
  277. try {
  278. if (state) {
  279. localStorage.setItem(SORT_STATE_KEY, JSON.stringify(state));
  280. } else {
  281. localStorage.removeItem(SORT_STATE_KEY);
  282. }
  283. } catch { /* ignore */ }
  284. }
  285. export default function InventoryPage() {
  286. const { t } = useTranslation();
  287. const queryClient = useQueryClient();
  288. const { showToast } = useToast();
  289. const [formModal, setFormModal] = useState<{ spool?: InventorySpool | null } | null>(null);
  290. const [confirmAction, setConfirmAction] = useState<{ type: 'delete' | 'archive'; spoolId: number } | null>(null);
  291. // Filter state
  292. const [archiveFilter, setArchiveFilter] = useState<ArchiveFilter>('active');
  293. const [usageFilter, setUsageFilter] = useState<UsageFilter>('all');
  294. const [materialFilter, setMaterialFilter] = useState('');
  295. const [brandFilter, setBrandFilter] = useState('');
  296. const [search, setSearch] = useState('');
  297. const [viewMode, setViewMode] = useState<ViewMode>('table');
  298. const [sortState, setSortState] = useState<SortState>(loadSortState);
  299. const [columnConfig, setColumnConfig] = useState<ColumnConfig[]>(loadColumnConfig);
  300. const [showColumnModal, setShowColumnModal] = useState(false);
  301. // Pagination state (pageSize persisted to localStorage)
  302. const [pageIndex, setPageIndex] = useState(0);
  303. const [pageSize, setPageSize] = useState(() => {
  304. try {
  305. const stored = localStorage.getItem('bambuddy-inventory-pageSize');
  306. if (stored) {
  307. const n = Number(stored);
  308. if ([15, 30, 50, 100, -1].includes(n)) return n;
  309. }
  310. } catch { /* ignore */ }
  311. return 15;
  312. });
  313. const { data: spools, isLoading } = useQuery({
  314. queryKey: ['inventory-spools'],
  315. queryFn: () => api.getSpools(true), // Always fetch all, filter client-side
  316. refetchInterval: 30000,
  317. });
  318. const { data: assignments } = useQuery({
  319. queryKey: ['spool-assignments'],
  320. queryFn: () => api.getAssignments(),
  321. refetchInterval: 30000,
  322. });
  323. const deleteMutation = useMutation({
  324. mutationFn: (id: number) => api.deleteSpool(id),
  325. onSuccess: () => {
  326. queryClient.invalidateQueries({ queryKey: ['inventory-spools'] });
  327. showToast(t('inventory.spoolDeleted'), 'success');
  328. },
  329. });
  330. const archiveMutation = useMutation({
  331. mutationFn: (id: number) => api.archiveSpool(id),
  332. onSuccess: () => {
  333. queryClient.invalidateQueries({ queryKey: ['inventory-spools'] });
  334. showToast(t('inventory.spoolArchived'), 'success');
  335. },
  336. });
  337. const restoreMutation = useMutation({
  338. mutationFn: (id: number) => api.restoreSpool(id),
  339. onSuccess: () => {
  340. queryClient.invalidateQueries({ queryKey: ['inventory-spools'] });
  341. showToast(t('inventory.spoolRestored'), 'success');
  342. },
  343. });
  344. // Stats calculation (active spools only)
  345. const stats = useMemo(() => {
  346. if (!spools) return null;
  347. let totalWeight = 0;
  348. let totalConsumed = 0;
  349. let lowStock = 0;
  350. let activeCount = 0;
  351. const byMaterial: Record<string, { count: number; weight: number }> = {};
  352. for (const s of spools) {
  353. if (s.archived_at) continue;
  354. activeCount++;
  355. const remaining = Math.max(0, s.label_weight - s.weight_used);
  356. totalWeight += remaining;
  357. totalConsumed += s.weight_used;
  358. const pct = s.label_weight > 0 ? (remaining / s.label_weight) * 100 : 0;
  359. if (pct < 20) lowStock++;
  360. const mat = s.material || 'Unknown';
  361. if (!byMaterial[mat]) byMaterial[mat] = { count: 0, weight: 0 };
  362. byMaterial[mat].count++;
  363. byMaterial[mat].weight += remaining;
  364. }
  365. return { totalWeight, totalConsumed, lowStock, byMaterial, totalSpools: activeCount };
  366. }, [spools]);
  367. const inPrinterCount = assignments?.length ?? 0;
  368. // Map spool_id -> assignment for location column
  369. const assignmentMap = useMemo(() => {
  370. const map: Record<number, SpoolAssignment> = {};
  371. for (const a of assignments || []) {
  372. map[a.spool_id] = a;
  373. }
  374. return map;
  375. }, [assignments]);
  376. // Top materials by weight for stat card pills
  377. const topMaterials = useMemo(() => {
  378. if (!stats) return [];
  379. return Object.entries(stats.byMaterial)
  380. .sort((a, b) => b[1].weight - a[1].weight)
  381. .slice(0, 4);
  382. }, [stats]);
  383. // Filtering pipeline
  384. const filteredSpools = useMemo(() => {
  385. let filtered = spools || [];
  386. // Archive filter
  387. if (archiveFilter === 'active') {
  388. filtered = filtered.filter((s) => !s.archived_at);
  389. } else {
  390. filtered = filtered.filter((s) => !!s.archived_at);
  391. }
  392. // Usage filter
  393. if (usageFilter === 'used') {
  394. filtered = filtered.filter((s) => s.weight_used > 0);
  395. } else if (usageFilter === 'new') {
  396. filtered = filtered.filter((s) => s.weight_used === 0);
  397. }
  398. // Material dropdown
  399. if (materialFilter) {
  400. filtered = filtered.filter((s) => s.material === materialFilter);
  401. }
  402. // Brand dropdown
  403. if (brandFilter) {
  404. filtered = filtered.filter((s) => s.brand === brandFilter);
  405. }
  406. // Global search
  407. if (search) {
  408. const q = search.toLowerCase();
  409. filtered = filtered.filter((s) =>
  410. s.brand?.toLowerCase().includes(q) ||
  411. s.material.toLowerCase().includes(q) ||
  412. s.color_name?.toLowerCase().includes(q) ||
  413. s.subtype?.toLowerCase().includes(q) ||
  414. s.note?.toLowerCase().includes(q) ||
  415. s.slicer_filament_name?.toLowerCase().includes(q)
  416. );
  417. }
  418. return filtered;
  419. }, [spools, archiveFilter, usageFilter, materialFilter, brandFilter, search]);
  420. // Reset page on filter changes
  421. const resetPage = () => setPageIndex(0);
  422. // Unique values for filter dropdowns
  423. const uniqueMaterials = [...new Set(spools?.map((s) => s.material) || [])].sort();
  424. const uniqueBrands = [...new Set(spools?.map((s) => s.brand).filter(Boolean) || [])].sort() as string[];
  425. // Check if any filters are non-default
  426. const hasActiveFilters = archiveFilter !== 'active' || usageFilter !== 'all' || !!materialFilter || !!brandFilter || !!search;
  427. const handleColumnConfigSave = (config: ColumnConfig[]) => {
  428. setColumnConfig(config);
  429. saveColumnConfig(config);
  430. };
  431. // Visible column IDs in order
  432. const visibleColumns = useMemo(
  433. () => columnConfig.filter((c) => c.visible).map((c) => c.id),
  434. [columnConfig]
  435. );
  436. const handleSort = (colId: string) => {
  437. if (!columnSortValues[colId]) return; // Not sortable
  438. setSortState((prev) => {
  439. let next: SortState;
  440. if (prev?.column === colId) {
  441. // Toggle direction, or clear on third click
  442. next = prev.direction === 'asc' ? { column: colId, direction: 'desc' } : null;
  443. } else {
  444. next = { column: colId, direction: 'asc' };
  445. }
  446. saveSortState(next);
  447. return next;
  448. });
  449. resetPage();
  450. };
  451. // Sort filtered spools
  452. const sortedSpools = useMemo(() => {
  453. if (!sortState) return filteredSpools;
  454. const extractor = columnSortValues[sortState.column];
  455. if (!extractor) return filteredSpools;
  456. const sorted = [...filteredSpools].sort((a, b) => {
  457. const va = extractor(a, assignmentMap);
  458. const vb = extractor(b, assignmentMap);
  459. if (va < vb) return sortState.direction === 'asc' ? -1 : 1;
  460. if (va > vb) return sortState.direction === 'asc' ? 1 : -1;
  461. return 0;
  462. });
  463. return sorted;
  464. }, [filteredSpools, sortState, assignmentMap]);
  465. // Pagination (after sorting) — pageSize -1 means "All"
  466. const showAll = pageSize === -1;
  467. const effectivePageSize = showAll ? sortedSpools.length || 1 : pageSize;
  468. const totalPages = Math.max(1, Math.ceil(sortedSpools.length / effectivePageSize));
  469. const safePageIndex = showAll ? 0 : Math.min(pageIndex, totalPages - 1);
  470. const pagedSpools = showAll ? sortedSpools : sortedSpools.slice(safePageIndex * effectivePageSize, (safePageIndex + 1) * effectivePageSize);
  471. const handlePageSizeChange = (size: number) => {
  472. setPageSize(size);
  473. setPageIndex(0);
  474. try { localStorage.setItem('bambuddy-inventory-pageSize', String(size)); } catch { /* ignore */ }
  475. };
  476. const clearAllFilters = () => {
  477. setArchiveFilter('active');
  478. setUsageFilter('all');
  479. setMaterialFilter('');
  480. setBrandFilter('');
  481. setSearch('');
  482. resetPage();
  483. };
  484. return (
  485. <div className="p-4 md:p-6 space-y-6">
  486. {/* Header */}
  487. <div className="flex items-center justify-between">
  488. <div>
  489. <div className="flex items-center gap-3">
  490. <Package className="w-6 h-6 text-bambu-green" />
  491. <h1 className="text-2xl font-bold text-white">{t('inventory.title')}</h1>
  492. </div>
  493. <p className="text-sm text-bambu-gray mt-1 ml-9">{t('inventory.noSpools').split('.')[0] ? '' : ''}</p>
  494. </div>
  495. <Button onClick={() => setFormModal({ spool: null })}>
  496. <Plus className="w-4 h-4" />
  497. {t('inventory.addSpool')}
  498. </Button>
  499. </div>
  500. {/* Stats Bar */}
  501. {stats && !isLoading && (
  502. <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-3">
  503. {/* Total Inventory */}
  504. <div className="bg-bambu-dark-secondary rounded-lg p-4">
  505. <div className="flex items-center gap-2 mb-1">
  506. <Package className="w-4 h-4 text-bambu-green" />
  507. <span className="text-xs text-bambu-gray font-medium uppercase tracking-wide">{t('inventory.totalInventory')}</span>
  508. </div>
  509. <div className="text-xl font-bold text-white">{formatWeight(stats.totalWeight, true)}</div>
  510. <div className="text-xs text-bambu-gray mt-1">{stats.totalSpools} {stats.totalSpools !== 1 ? t('inventory.spools') : t('inventory.spool')}</div>
  511. </div>
  512. {/* Total Consumed */}
  513. <div className="bg-bambu-dark-secondary rounded-lg p-4">
  514. <div className="flex items-center gap-2 mb-1">
  515. <TrendingDown className="w-4 h-4 text-blue-400" />
  516. <span className="text-xs text-bambu-gray font-medium uppercase tracking-wide">{t('inventory.totalConsumed')}</span>
  517. </div>
  518. <div className="text-xl font-bold text-white">{formatWeight(stats.totalConsumed, true)}</div>
  519. <div className="text-xs text-bambu-gray mt-1">{t('inventory.sinceTracking')}</div>
  520. </div>
  521. {/* By Material */}
  522. <div className="bg-bambu-dark-secondary rounded-lg p-4">
  523. <div className="flex items-center gap-2 mb-1">
  524. <Layers className="w-4 h-4 text-green-400" />
  525. <span className="text-xs text-bambu-gray font-medium uppercase tracking-wide">{t('inventory.byMaterial')}</span>
  526. </div>
  527. <div className="flex flex-wrap gap-1.5 mt-1">
  528. {topMaterials.map(([mat, data]) => (
  529. <span
  530. key={mat}
  531. 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'}`}
  532. >
  533. {mat} <span className="opacity-70">{formatWeight(data.weight, true)}</span>
  534. </span>
  535. ))}
  536. </div>
  537. </div>
  538. {/* In Printer */}
  539. <div className="bg-bambu-dark-secondary rounded-lg p-4">
  540. <div className="flex items-center gap-2 mb-1">
  541. <Printer className="w-4 h-4 text-purple-400" />
  542. <span className="text-xs text-bambu-gray font-medium uppercase tracking-wide">{t('inventory.inPrinter')}</span>
  543. </div>
  544. <div className="text-xl font-bold text-white">{inPrinterCount}</div>
  545. <div className="text-xs text-bambu-gray mt-1">{t('inventory.loadedInAms')}</div>
  546. </div>
  547. {/* Low Stock */}
  548. <div className="bg-bambu-dark-secondary rounded-lg p-4">
  549. <div className="flex items-center gap-2 mb-1">
  550. <AlertTriangle className="w-4 h-4 text-yellow-400" />
  551. <span className="text-xs text-bambu-gray font-medium uppercase tracking-wide">{t('inventory.lowStock')}</span>
  552. </div>
  553. <div className={`text-xl font-bold ${stats.lowStock > 0 ? 'text-yellow-400' : 'text-white'}`}>{stats.lowStock}</div>
  554. <div className="text-xs text-bambu-gray mt-1">{t('inventory.lowStockThreshold')}</div>
  555. </div>
  556. </div>
  557. )}
  558. {/* Toolbar: Search + View toggle */}
  559. <div className="flex flex-col sm:flex-row gap-3 items-start sm:items-center justify-between">
  560. <div className="relative flex-1 max-w-md">
  561. <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray/50" />
  562. <input
  563. type="text"
  564. value={search}
  565. onChange={(e) => { setSearch(e.target.value); resetPage(); }}
  566. placeholder={t('inventory.search')}
  567. 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"
  568. />
  569. {search && (
  570. <button
  571. onClick={() => { setSearch(''); resetPage(); }}
  572. className="absolute right-3 top-1/2 -translate-y-1/2 text-bambu-gray hover:text-white"
  573. >
  574. <X className="w-4 h-4" />
  575. </button>
  576. )}
  577. </div>
  578. <div className="flex items-center gap-2">
  579. {/* Columns button (table view only) */}
  580. {viewMode === 'table' && (
  581. <button
  582. onClick={() => setShowColumnModal(true)}
  583. 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"
  584. title={t('inventory.configureColumns')}
  585. >
  586. <Columns className="w-4 h-4" />
  587. <span className="hidden sm:inline">{t('inventory.columns')}</span>
  588. </button>
  589. )}
  590. {/* Table / Cards toggle */}
  591. <div className="flex bg-bambu-dark-primary border border-bambu-dark-tertiary rounded-lg overflow-hidden">
  592. <button
  593. onClick={() => setViewMode('table')}
  594. className={`flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium transition-colors ${
  595. viewMode === 'table'
  596. ? 'bg-bambu-green text-white'
  597. : 'text-bambu-gray hover:bg-bambu-dark-tertiary'
  598. }`}
  599. >
  600. <TableProperties className="w-4 h-4" />
  601. <span className="hidden sm:inline">{t('inventory.table')}</span>
  602. </button>
  603. <button
  604. onClick={() => setViewMode('cards')}
  605. className={`flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium transition-colors ${
  606. viewMode === 'cards'
  607. ? 'bg-bambu-green text-white'
  608. : 'text-bambu-gray hover:bg-bambu-dark-tertiary'
  609. }`}
  610. >
  611. <LayoutGrid className="w-4 h-4" />
  612. <span className="hidden sm:inline">{t('inventory.cards')}</span>
  613. </button>
  614. </div>
  615. </div>
  616. </div>
  617. {/* Filter chips row */}
  618. <div className="flex flex-wrap items-center gap-2">
  619. {/* Active / Archived chips */}
  620. <div className="flex items-center rounded-lg border border-bambu-dark-tertiary overflow-hidden">
  621. <button
  622. onClick={() => { setArchiveFilter('active'); resetPage(); }}
  623. className={`flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium transition-colors ${
  624. archiveFilter === 'active'
  625. ? 'bg-bambu-green/20 text-bambu-green'
  626. : 'text-bambu-gray hover:bg-bambu-dark-tertiary'
  627. }`}
  628. >
  629. <Package className="w-3.5 h-3.5" />
  630. {t('inventory.active')}
  631. </button>
  632. <button
  633. onClick={() => { setArchiveFilter('archived'); resetPage(); }}
  634. className={`flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium transition-colors ${
  635. archiveFilter === 'archived'
  636. ? 'bg-bambu-green/20 text-bambu-green'
  637. : 'text-bambu-gray hover:bg-bambu-dark-tertiary'
  638. }`}
  639. >
  640. <Archive className="w-3.5 h-3.5" />
  641. {t('inventory.archived')}
  642. </button>
  643. </div>
  644. <div className="w-px h-5 bg-bambu-dark-tertiary" />
  645. {/* All / Used / New chips */}
  646. <div className="flex items-center rounded-lg border border-bambu-dark-tertiary overflow-hidden">
  647. <button
  648. onClick={() => { setUsageFilter('all'); resetPage(); }}
  649. className={`px-3 py-1.5 text-xs font-medium transition-colors ${
  650. usageFilter === 'all'
  651. ? 'bg-bambu-green/20 text-bambu-green'
  652. : 'text-bambu-gray hover:bg-bambu-dark-tertiary'
  653. }`}
  654. >
  655. {t('inventory.all')}
  656. </button>
  657. <button
  658. onClick={() => { setUsageFilter('used'); resetPage(); }}
  659. className={`flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium transition-colors ${
  660. usageFilter === 'used'
  661. ? 'bg-bambu-green/20 text-bambu-green'
  662. : 'text-bambu-gray hover:bg-bambu-dark-tertiary'
  663. }`}
  664. >
  665. <Clock className="w-3.5 h-3.5" />
  666. {t('inventory.used')}
  667. </button>
  668. <button
  669. onClick={() => { setUsageFilter('new'); resetPage(); }}
  670. className={`px-3 py-1.5 text-xs font-medium transition-colors ${
  671. usageFilter === 'new'
  672. ? 'bg-bambu-green/20 text-bambu-green'
  673. : 'text-bambu-gray hover:bg-bambu-dark-tertiary'
  674. }`}
  675. >
  676. {t('inventory.new')}
  677. </button>
  678. </div>
  679. <div className="w-px h-5 bg-bambu-dark-tertiary" />
  680. {/* Material dropdown chip */}
  681. <select
  682. value={materialFilter}
  683. onChange={(e) => { setMaterialFilter(e.target.value); resetPage(); }}
  684. className={`px-3 py-1.5 rounded-lg border text-xs font-medium transition-colors cursor-pointer focus:outline-none ${
  685. materialFilter
  686. ? 'bg-bambu-green/20 text-bambu-green border-bambu-green/30'
  687. : 'bg-transparent text-bambu-gray border-bambu-dark-tertiary hover:bg-bambu-dark-tertiary'
  688. }`}
  689. >
  690. <option value="">{t('inventory.material')}</option>
  691. {uniqueMaterials.map((m) => (
  692. <option key={m} value={m}>{m}</option>
  693. ))}
  694. </select>
  695. {/* Brand dropdown chip */}
  696. <select
  697. value={brandFilter}
  698. onChange={(e) => { setBrandFilter(e.target.value); resetPage(); }}
  699. className={`px-3 py-1.5 rounded-lg border text-xs font-medium transition-colors cursor-pointer focus:outline-none ${
  700. brandFilter
  701. ? 'bg-bambu-green/20 text-bambu-green border-bambu-green/30'
  702. : 'bg-transparent text-bambu-gray border-bambu-dark-tertiary hover:bg-bambu-dark-tertiary'
  703. }`}
  704. >
  705. <option value="">{t('inventory.brand')}</option>
  706. {uniqueBrands.map((b) => (
  707. <option key={b} value={b}>{b}</option>
  708. ))}
  709. </select>
  710. {/* Clear filters */}
  711. {hasActiveFilters && (
  712. <>
  713. <div className="w-px h-5 bg-bambu-dark-tertiary" />
  714. <button
  715. onClick={clearAllFilters}
  716. className="flex items-center gap-1 text-xs text-bambu-gray hover:text-bambu-green transition-colors"
  717. >
  718. <X className="w-3.5 h-3.5" />
  719. {t('inventory.clearFilters')}
  720. </button>
  721. </>
  722. )}
  723. {/* Results count */}
  724. <span className="ml-auto text-xs text-bambu-gray">
  725. {sortedSpools.length} {sortedSpools.length !== 1 ? t('inventory.spools') : t('inventory.spool')}
  726. </span>
  727. </div>
  728. {/* Content */}
  729. {isLoading ? (
  730. <div className="flex justify-center py-16">
  731. <Loader2 className="w-8 h-8 text-bambu-green animate-spin" />
  732. </div>
  733. ) : viewMode === 'cards' ? (
  734. /* Cards view */
  735. pagedSpools.length > 0 ? (
  736. <>
  737. <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
  738. {pagedSpools.map((spool) => {
  739. const remaining = Math.max(0, spool.label_weight - spool.weight_used);
  740. const pct = spool.label_weight > 0 ? (remaining / spool.label_weight) * 100 : 0;
  741. const colorStyle = spool.rgba ? `#${spool.rgba.substring(0, 6)}` : '#808080';
  742. return (
  743. <div
  744. key={spool.id}
  745. 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' : ''}`}
  746. onClick={() => setFormModal({ spool })}
  747. >
  748. {/* Color header */}
  749. <div className="h-14 flex items-center justify-center" style={{ backgroundColor: colorStyle }}>
  750. <span className="bg-white/90 text-gray-800 px-3 py-0.5 rounded-full text-sm font-medium">
  751. {resolveSpoolColorName(spool.color_name, spool.rgba) || '-'}
  752. </span>
  753. </div>
  754. {/* Content */}
  755. <div className="p-4 space-y-3">
  756. <div className="flex items-start justify-between gap-2">
  757. <div>
  758. <h3 className="font-semibold text-white">{spool.material}{spool.subtype ? ` ${spool.subtype}` : ''}</h3>
  759. <p className="text-sm text-bambu-gray">{spool.brand || '-'}</p>
  760. </div>
  761. <span className="text-xs font-mono text-bambu-gray bg-bambu-dark-tertiary px-2 py-1 rounded">#{spool.id}</span>
  762. </div>
  763. {/* Progress */}
  764. <div>
  765. <div className="flex justify-between text-xs text-bambu-gray mb-1">
  766. <span>{t('inventory.remaining')}</span>
  767. <span>{Math.round(pct)}%</span>
  768. </div>
  769. <div className="flex items-center gap-2">
  770. <div className="flex-1 h-2 bg-bambu-dark-tertiary rounded-full overflow-hidden">
  771. <div
  772. className={`h-full rounded-full ${pct > 50 ? 'bg-bambu-green' : pct > 20 ? 'bg-yellow-500' : 'bg-red-500'}`}
  773. style={{ width: `${Math.min(pct, 100)}%` }}
  774. />
  775. </div>
  776. <span className="text-xs text-bambu-gray min-w-[40px] text-right">{Math.round(remaining)}g</span>
  777. </div>
  778. </div>
  779. {/* Weight info */}
  780. <div className="grid grid-cols-2 gap-2 text-xs">
  781. <div>
  782. <span className="text-bambu-gray/60">{t('inventory.labelWeight')}: </span>
  783. <span className="text-bambu-gray">{formatWeight(spool.label_weight)}</span>
  784. </div>
  785. <div>
  786. <span className="text-bambu-gray/60">{t('inventory.weightUsed')}: </span>
  787. <span className="text-bambu-gray">{spool.weight_used > 0 ? formatWeight(spool.weight_used) : '-'}</span>
  788. </div>
  789. </div>
  790. {/* Note */}
  791. {spool.note && (
  792. <div className="text-xs text-bambu-gray/60 pt-2 border-t border-bambu-dark-tertiary truncate" title={spool.note}>
  793. {spool.note}
  794. </div>
  795. )}
  796. </div>
  797. </div>
  798. );
  799. })}
  800. </div>
  801. {/* Pagination for cards */}
  802. <PaginationBar
  803. pageIndex={safePageIndex}
  804. pageSize={pageSize}
  805. totalRows={sortedSpools.length}
  806. totalPages={totalPages}
  807. onPageChange={setPageIndex}
  808. onPageSizeChange={handlePageSizeChange}
  809. t={t}
  810. />
  811. </>
  812. ) : (
  813. <EmptyFilterState
  814. hasFilters={hasActiveFilters}
  815. onAddSpool={() => setFormModal({ spool: null })}
  816. t={t}
  817. />
  818. )
  819. ) : (
  820. /* Table view */
  821. pagedSpools.length > 0 ? (
  822. <div className="bg-bambu-dark-secondary rounded-lg overflow-hidden border border-bambu-dark-tertiary">
  823. <div className="overflow-x-auto">
  824. <table className="w-full">
  825. <thead>
  826. <tr className="border-b border-bambu-dark-tertiary bg-bambu-dark-tertiary/30">
  827. {visibleColumns.map((colId) => {
  828. const sortable = !!columnSortValues[colId];
  829. const isActive = sortState?.column === colId;
  830. return (
  831. <th
  832. key={colId}
  833. className={`text-left py-3 px-4 text-xs font-medium uppercase tracking-wide select-none ${colId === 'remaining' ? 'min-w-[150px]' : ''} ${
  834. sortable ? 'cursor-pointer hover:text-bambu-green transition-colors' : ''
  835. } ${isActive ? 'text-bambu-green' : 'text-bambu-gray'}`}
  836. onClick={sortable ? () => handleSort(colId) : undefined}
  837. >
  838. <span className="inline-flex items-center gap-1">
  839. {columnHeaders[colId]?.(t) ?? colId}
  840. {sortable && (
  841. isActive
  842. ? sortState.direction === 'asc'
  843. ? <ArrowUp className="w-3 h-3" />
  844. : <ArrowDown className="w-3 h-3" />
  845. : <ArrowUpDown className="w-3 h-3 opacity-30" />
  846. )}
  847. </span>
  848. </th>
  849. );
  850. })}
  851. <th className="text-right py-3 px-4 text-xs font-medium text-bambu-gray uppercase tracking-wide">{t('common.actions')}</th>
  852. </tr>
  853. </thead>
  854. <tbody>
  855. {pagedSpools.map((spool) => {
  856. const remaining = Math.max(0, spool.label_weight - spool.weight_used);
  857. const pct = spool.label_weight > 0 ? (remaining / spool.label_weight) * 100 : 0;
  858. return (
  859. <tr
  860. key={spool.id}
  861. className={`border-b border-bambu-dark-tertiary/50 hover:bg-bambu-dark-tertiary/30 transition-colors cursor-pointer ${
  862. spool.archived_at ? 'opacity-50' : ''
  863. }`}
  864. onClick={() => setFormModal({ spool })}
  865. >
  866. {visibleColumns.map((colId) => (
  867. <td key={colId} className="py-3 px-4">
  868. {columnCells[colId]?.({ spool, remaining, pct, assignmentMap })}
  869. </td>
  870. ))}
  871. <td className="py-3 px-4">
  872. <div className="flex items-center justify-end gap-1" onClick={(e) => e.stopPropagation()}>
  873. <button
  874. onClick={() => setFormModal({ spool })}
  875. className="p-1.5 text-bambu-gray hover:text-white rounded transition-colors"
  876. title={t('inventory.editSpool')}
  877. >
  878. <Edit2 className="w-4 h-4" />
  879. </button>
  880. {spool.archived_at ? (
  881. <button
  882. onClick={() => restoreMutation.mutate(spool.id)}
  883. className="p-1.5 text-bambu-gray hover:text-bambu-green rounded transition-colors"
  884. title={t('inventory.restore')}
  885. >
  886. <RotateCcw className="w-4 h-4" />
  887. </button>
  888. ) : (
  889. <button
  890. onClick={() => setConfirmAction({ type: 'archive', spoolId: spool.id })}
  891. className="p-1.5 text-bambu-gray hover:text-yellow-400 rounded transition-colors"
  892. title={t('inventory.archive')}
  893. >
  894. <Archive className="w-4 h-4" />
  895. </button>
  896. )}
  897. <button
  898. onClick={() => setConfirmAction({ type: 'delete', spoolId: spool.id })}
  899. className="p-1.5 text-bambu-gray hover:text-red-400 rounded transition-colors"
  900. title={t('common.delete')}
  901. >
  902. <Trash2 className="w-4 h-4" />
  903. </button>
  904. </div>
  905. </td>
  906. </tr>
  907. );
  908. })}
  909. </tbody>
  910. </table>
  911. </div>
  912. {/* Pagination inside card footer */}
  913. <div className="flex items-center justify-between px-4 py-3 bg-bambu-dark-tertiary/50 border-t border-bambu-dark-tertiary text-sm">
  914. <span className="text-bambu-gray">
  915. {showAll
  916. ? `${sortedSpools.length} ${sortedSpools.length !== 1 ? t('inventory.spools') : t('inventory.spool')}`
  917. : <>{t('inventory.showing')} {safePageIndex * effectivePageSize + 1} {t('inventory.to')}{' '}
  918. {Math.min((safePageIndex + 1) * effectivePageSize, sortedSpools.length)}{' '}
  919. {t('inventory.of')} {sortedSpools.length} {t('inventory.spools')}</>
  920. }
  921. </span>
  922. <div className="flex items-center gap-2">
  923. <span className="text-bambu-gray">{t('inventory.show')}</span>
  924. <select
  925. value={pageSize}
  926. onChange={(e) => handlePageSizeChange(Number(e.target.value))}
  927. 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"
  928. >
  929. {[15, 30, 50, 100].map((n) => (
  930. <option key={n} value={n}>{n}</option>
  931. ))}
  932. <option value={-1}>{t('inventory.all')}</option>
  933. </select>
  934. {!showAll && (
  935. <>
  936. <button
  937. onClick={() => setPageIndex(0)}
  938. disabled={safePageIndex === 0}
  939. className="p-1.5 rounded text-bambu-gray hover:text-white disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
  940. title="First page"
  941. >
  942. <ChevronsLeft className="w-4 h-4" />
  943. </button>
  944. <button
  945. onClick={() => setPageIndex((p) => Math.max(0, p - 1))}
  946. disabled={safePageIndex === 0}
  947. className="p-1.5 rounded text-bambu-gray hover:text-white disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
  948. >
  949. <ChevronLeft className="w-4 h-4" />
  950. </button>
  951. <span className="text-bambu-gray px-2 whitespace-nowrap">
  952. {t('inventory.page')} {safePageIndex + 1} {t('inventory.of')} {totalPages}
  953. </span>
  954. <button
  955. onClick={() => setPageIndex((p) => Math.min(totalPages - 1, p + 1))}
  956. disabled={safePageIndex >= totalPages - 1}
  957. className="p-1.5 rounded text-bambu-gray hover:text-white disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
  958. >
  959. <ChevronRight className="w-4 h-4" />
  960. </button>
  961. <button
  962. onClick={() => setPageIndex(totalPages - 1)}
  963. disabled={safePageIndex >= totalPages - 1}
  964. className="p-1.5 rounded text-bambu-gray hover:text-white disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
  965. title="Last page"
  966. >
  967. <ChevronsRight className="w-4 h-4" />
  968. </button>
  969. </>
  970. )}
  971. </div>
  972. </div>
  973. </div>
  974. ) : (
  975. <EmptyFilterState
  976. hasFilters={hasActiveFilters}
  977. onAddSpool={() => setFormModal({ spool: null })}
  978. t={t}
  979. />
  980. )
  981. )}
  982. {/* Spool Form Modal */}
  983. {formModal !== null && (
  984. <SpoolFormModal
  985. isOpen={true}
  986. onClose={() => setFormModal(null)}
  987. spool={formModal.spool}
  988. />
  989. )}
  990. {/* Confirm Modal (delete / archive) */}
  991. {confirmAction && (
  992. <ConfirmModal
  993. title={confirmAction.type === 'delete' ? t('common.delete') : t('inventory.archive')}
  994. message={confirmAction.type === 'delete' ? t('inventory.deleteConfirm') : t('inventory.archiveConfirm')}
  995. confirmText={confirmAction.type === 'delete' ? t('common.delete') : t('inventory.archive')}
  996. variant={confirmAction.type === 'delete' ? 'danger' : 'warning'}
  997. onConfirm={() => {
  998. if (confirmAction.type === 'delete') {
  999. deleteMutation.mutate(confirmAction.spoolId);
  1000. } else {
  1001. archiveMutation.mutate(confirmAction.spoolId);
  1002. }
  1003. setConfirmAction(null);
  1004. }}
  1005. onCancel={() => setConfirmAction(null)}
  1006. />
  1007. )}
  1008. {/* Column Config Modal */}
  1009. <ColumnConfigModal
  1010. isOpen={showColumnModal}
  1011. onClose={() => setShowColumnModal(false)}
  1012. columns={columnConfig}
  1013. defaultColumns={DEFAULT_COLUMNS}
  1014. onSave={handleColumnConfigSave}
  1015. />
  1016. </div>
  1017. );
  1018. }
  1019. /* Pagination bar (reused for cards view) */
  1020. function PaginationBar({
  1021. pageIndex, pageSize, totalRows, totalPages, onPageChange, onPageSizeChange, t,
  1022. }: {
  1023. pageIndex: number;
  1024. pageSize: number;
  1025. totalRows: number;
  1026. totalPages: number;
  1027. onPageChange: (page: number) => void;
  1028. onPageSizeChange: (size: number) => void;
  1029. t: (key: string) => string;
  1030. }) {
  1031. const isShowAll = pageSize === -1;
  1032. if (totalPages <= 1 && !isShowAll) return null;
  1033. const effectiveSize = isShowAll ? totalRows || 1 : pageSize;
  1034. return (
  1035. <div className="flex items-center justify-between pt-2 text-sm">
  1036. <span className="text-bambu-gray">
  1037. {isShowAll
  1038. ? `${totalRows} ${totalRows !== 1 ? t('inventory.spools') : t('inventory.spool')}`
  1039. : <>{t('inventory.showing')} {pageIndex * effectiveSize + 1} {t('inventory.to')}{' '}
  1040. {Math.min((pageIndex + 1) * effectiveSize, totalRows)}{' '}
  1041. {t('inventory.of')} {totalRows} {t('inventory.spools')}</>
  1042. }
  1043. </span>
  1044. <div className="flex items-center gap-2">
  1045. <span className="text-bambu-gray">{t('inventory.show')}</span>
  1046. <select
  1047. value={pageSize}
  1048. onChange={(e) => onPageSizeChange(Number(e.target.value))}
  1049. 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"
  1050. >
  1051. {[15, 30, 50, 100].map((n) => (
  1052. <option key={n} value={n}>{n}</option>
  1053. ))}
  1054. <option value={-1}>{t('inventory.all')}</option>
  1055. </select>
  1056. {!isShowAll && (
  1057. <>
  1058. <button
  1059. onClick={() => onPageChange(0)}
  1060. disabled={pageIndex === 0}
  1061. className="p-1.5 rounded text-bambu-gray hover:text-white disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
  1062. >
  1063. <ChevronsLeft className="w-4 h-4" />
  1064. </button>
  1065. <button
  1066. onClick={() => onPageChange(Math.max(0, pageIndex - 1))}
  1067. disabled={pageIndex === 0}
  1068. className="p-1.5 rounded text-bambu-gray hover:text-white disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
  1069. >
  1070. <ChevronLeft className="w-4 h-4" />
  1071. </button>
  1072. <span className="text-bambu-gray px-2 whitespace-nowrap">
  1073. {t('inventory.page')} {pageIndex + 1} {t('inventory.of')} {totalPages}
  1074. </span>
  1075. <button
  1076. onClick={() => onPageChange(Math.min(totalPages - 1, pageIndex + 1))}
  1077. disabled={pageIndex >= totalPages - 1}
  1078. className="p-1.5 rounded text-bambu-gray hover:text-white disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
  1079. >
  1080. <ChevronRight className="w-4 h-4" />
  1081. </button>
  1082. <button
  1083. onClick={() => onPageChange(totalPages - 1)}
  1084. disabled={pageIndex >= totalPages - 1}
  1085. className="p-1.5 rounded text-bambu-gray hover:text-white disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
  1086. >
  1087. <ChevronsRight className="w-4 h-4" />
  1088. </button>
  1089. </>
  1090. )}
  1091. </div>
  1092. </div>
  1093. );
  1094. }
  1095. /* Empty state matching SpoolBuddy's design */
  1096. function EmptyFilterState({
  1097. hasFilters,
  1098. onAddSpool,
  1099. t,
  1100. }: {
  1101. hasFilters: boolean;
  1102. onAddSpool: () => void;
  1103. t: (key: string) => string;
  1104. }) {
  1105. return (
  1106. <div className="flex flex-col items-center justify-center py-16 px-4">
  1107. <div className="relative mb-6">
  1108. <div className="absolute inset-0 -m-4 bg-bambu-green/5 rounded-full blur-2xl" />
  1109. <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">
  1110. <div className="absolute -top-1 -right-1 w-3 h-3 rounded-full bg-bambu-green/30" />
  1111. <div className="absolute -bottom-2 -left-2 w-2 h-2 rounded-full bg-bambu-green/20" />
  1112. {hasFilters ? (
  1113. <Search className="w-10 h-10 text-bambu-gray/40" strokeWidth={1.5} />
  1114. ) : (
  1115. <div className="relative">
  1116. <div className="w-14 h-14 rounded-full border-4 border-bambu-gray/20 flex items-center justify-center">
  1117. <div className="w-6 h-6 rounded-full bg-bambu-gray/10 border-2 border-bambu-gray/20" />
  1118. </div>
  1119. <div className="absolute -bottom-1 -right-1 w-6 h-6 rounded-full bg-bambu-green flex items-center justify-center shadow-md">
  1120. <span className="text-white text-lg font-bold leading-none">+</span>
  1121. </div>
  1122. </div>
  1123. )}
  1124. </div>
  1125. </div>
  1126. <h3 className="text-lg font-semibold text-white mb-2 text-center">
  1127. {hasFilters ? t('inventory.noSpoolsMatch') : t('inventory.noSpools').split('.')[0]}
  1128. </h3>
  1129. <p className="text-sm text-bambu-gray text-center max-w-sm mb-6">
  1130. {hasFilters
  1131. ? t('inventory.noSpoolsMatchDesc')
  1132. : t('inventory.noSpools')
  1133. }
  1134. </p>
  1135. {!hasFilters && (
  1136. <Button onClick={onAddSpool}>
  1137. <Package className="w-4 h-4" />
  1138. {t('inventory.addSpool')}
  1139. </Button>
  1140. )}
  1141. </div>
  1142. );
  1143. }