InventoryPage.tsx 50 KB

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