SpoolBuddyInventoryPage.tsx 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481
  1. import { useState, useMemo } from 'react';
  2. import { useQuery } from '@tanstack/react-query';
  3. import { useTranslation } from 'react-i18next';
  4. import { Search, X, Package } from 'lucide-react';
  5. import { api } from '../../api/client';
  6. import type { InventorySpool, SpoolAssignment } from '../../api/client';
  7. import { resolveSpoolColorName } from '../../utils/colors';
  8. import { formatSlotLabel } from '../../utils/amsHelpers';
  9. type FilterMode = 'all' | 'in_ams' | string; // string = material name
  10. function spoolColor(spool: InventorySpool): string {
  11. if (spool.rgba) return `#${spool.rgba.substring(0, 6)}`;
  12. return '#808080';
  13. }
  14. function spoolRemaining(spool: InventorySpool): number {
  15. return Math.max(0, spool.label_weight - spool.weight_used);
  16. }
  17. function spoolPct(spool: InventorySpool): number {
  18. if (spool.label_weight <= 0) return 0;
  19. return Math.max(0, Math.min(100, ((spool.label_weight - spool.weight_used) / spool.label_weight) * 100));
  20. }
  21. function spoolDisplayName(spool: InventorySpool): string {
  22. const parts = [spool.material];
  23. if (spool.subtype) parts.push(spool.subtype);
  24. return parts.join(' ');
  25. }
  26. function assignmentLabel(a: SpoolAssignment): string {
  27. const isExternal = a.ams_id === 254 || a.ams_id === 255;
  28. const isHt = !isExternal && a.ams_id >= 128;
  29. return formatSlotLabel(a.ams_id, a.tray_id, isHt, isExternal);
  30. }
  31. /* Spool circle — same style as AMS page tray slots */
  32. function SpoolCircle({ color, size = 56 }: { color: string; size?: number }) {
  33. return (
  34. <svg width={size} height={size} viewBox="0 0 56 56">
  35. <circle cx="28" cy="28" r="26" fill={color} />
  36. <circle cx="28" cy="28" r="20" fill={color} style={{ filter: 'brightness(0.85)' }} />
  37. <ellipse cx="20" cy="20" rx="6" ry="4" fill="white" opacity="0.3" />
  38. <circle cx="28" cy="28" r="8" fill="#2d2d2d" />
  39. <circle cx="28" cy="28" r="5" fill="#1a1a1a" />
  40. </svg>
  41. );
  42. }
  43. export function SpoolBuddyInventoryPage() {
  44. const { t } = useTranslation();
  45. const [searchQuery, setSearchQuery] = useState('');
  46. const [filterMode, setFilterMode] = useState<FilterMode>('all');
  47. const [selectedSpoolId, setSelectedSpoolId] = useState<number | null>(null);
  48. const { data: spoolmanSettings } = useQuery({
  49. queryKey: ['spoolman-settings'],
  50. queryFn: api.getSpoolmanSettings,
  51. staleTime: 5 * 60 * 1000,
  52. });
  53. const { data: spools = [], isLoading } = useQuery({
  54. queryKey: ['inventory-spools'],
  55. queryFn: () => api.getSpools(false),
  56. refetchInterval: 30000,
  57. });
  58. const { data: assignments = [] } = useQuery({
  59. queryKey: ['spool-assignments'],
  60. queryFn: () => api.getAssignments(),
  61. refetchInterval: 30000,
  62. });
  63. // Build assignment lookup: spool_id → assignment
  64. const assignmentMap = useMemo(() => {
  65. const map: Record<number, SpoolAssignment> = {};
  66. assignments.forEach(a => { map[a.spool_id] = a; });
  67. return map;
  68. }, [assignments]);
  69. const activeSpools = useMemo(() => spools.filter(s => !s.archived_at), [spools]);
  70. // Spools that have an AMS assignment
  71. const assignedSpoolIds = useMemo(() => new Set(assignments.map(a => a.spool_id)), [assignments]);
  72. const inAmsCount = useMemo(() => activeSpools.filter(s => assignedSpoolIds.has(s.id)).length, [activeSpools, assignedSpoolIds]);
  73. // Unique materials for filter pills
  74. const materials = useMemo(() => {
  75. const set = new Set<string>();
  76. activeSpools.forEach(s => set.add(s.material));
  77. return Array.from(set).sort();
  78. }, [activeSpools]);
  79. // Filter and sort
  80. const filteredSpools = useMemo(() => {
  81. let list = activeSpools;
  82. if (filterMode === 'in_ams') {
  83. list = list.filter(s => assignedSpoolIds.has(s.id));
  84. } else if (filterMode !== 'all') {
  85. list = list.filter(s => s.material === filterMode);
  86. }
  87. if (searchQuery.trim()) {
  88. const q = searchQuery.toLowerCase().trim();
  89. list = list.filter(s =>
  90. s.material.toLowerCase().includes(q) ||
  91. (s.subtype && s.subtype.toLowerCase().includes(q)) ||
  92. (s.brand && s.brand.toLowerCase().includes(q)) ||
  93. (s.color_name && s.color_name.toLowerCase().includes(q)) ||
  94. (s.note && s.note.toLowerCase().includes(q))
  95. );
  96. }
  97. // Sort: assigned spools first (by slot label), then by most recently updated
  98. return [...list].sort((a, b) => {
  99. const aAssigned = assignedSpoolIds.has(a.id) ? 0 : 1;
  100. const bAssigned = assignedSpoolIds.has(b.id) ? 0 : 1;
  101. if (aAssigned !== bAssigned) return aAssigned - bAssigned;
  102. return new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime();
  103. });
  104. }, [activeSpools, filterMode, searchQuery, assignedSpoolIds]);
  105. // Spoolman iframe mode
  106. const spoolmanEnabled = spoolmanSettings?.spoolman_enabled === 'true' && spoolmanSettings?.spoolman_url;
  107. if (spoolmanEnabled) {
  108. return (
  109. <div className="h-full flex flex-col">
  110. <iframe
  111. src={`${spoolmanSettings.spoolman_url.replace(/\/+$/, '')}/spool`}
  112. className="flex-1 w-full border-0"
  113. title="Spoolman"
  114. sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox"
  115. />
  116. </div>
  117. );
  118. }
  119. return (
  120. <div className="h-full flex flex-col">
  121. {/* Search + filter pills */}
  122. <div className="px-3 pt-3 pb-2 space-y-2.5">
  123. {/* Search */}
  124. <div className="relative">
  125. <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-white/40" />
  126. <input
  127. type="text"
  128. value={searchQuery}
  129. onChange={e => setSearchQuery(e.target.value)}
  130. placeholder={t('spoolbuddy.inventory.searchPlaceholder', 'Search spools...')}
  131. className="w-full pl-9 pr-8 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-sm text-white placeholder-white/30 focus:outline-none focus:border-bambu-green"
  132. />
  133. {searchQuery && (
  134. <button
  135. onClick={() => setSearchQuery('')}
  136. className="absolute right-2 top-1/2 -translate-y-1/2 text-white/40 hover:text-white/60"
  137. >
  138. <X className="w-4 h-4" />
  139. </button>
  140. )}
  141. </div>
  142. {/* Filter pills — inline scrollable row */}
  143. <div className="flex gap-1.5 overflow-x-auto no-scrollbar">
  144. <FilterPill
  145. active={filterMode === 'all'}
  146. onClick={() => setFilterMode('all')}
  147. label={`${t('spoolbuddy.inventory.all', 'All')} (${activeSpools.length})`}
  148. green
  149. />
  150. {inAmsCount > 0 && (
  151. <FilterPill
  152. active={filterMode === 'in_ams'}
  153. onClick={() => setFilterMode('in_ams')}
  154. label={`${t('spoolbuddy.inventory.inAms', 'In AMS')} (${inAmsCount})`}
  155. />
  156. )}
  157. {materials.map(mat => (
  158. <FilterPill
  159. key={mat}
  160. active={filterMode === mat}
  161. onClick={() => setFilterMode(filterMode === mat ? 'all' : mat)}
  162. label={mat}
  163. />
  164. ))}
  165. </div>
  166. </div>
  167. {/* Spool grid */}
  168. <div className="flex-1 overflow-y-auto px-3 pb-3">
  169. {isLoading ? (
  170. <div className="flex items-center justify-center py-16">
  171. <div className="w-8 h-8 border-2 border-bambu-green border-t-transparent rounded-full animate-spin" />
  172. </div>
  173. ) : filteredSpools.length === 0 ? (
  174. <div className="flex flex-col items-center justify-center py-16 text-white/30">
  175. <Package className="w-12 h-12 mb-3" />
  176. <p className="text-sm">
  177. {searchQuery || filterMode !== 'all'
  178. ? t('spoolbuddy.inventory.noResults', 'No spools match your filters')
  179. : t('spoolbuddy.inventory.empty', 'No spools in inventory')}
  180. </p>
  181. </div>
  182. ) : (
  183. <div className="grid grid-cols-[repeat(auto-fill,minmax(130px,1fr))] gap-2">
  184. {filteredSpools.map(spool => (
  185. <CatalogCard
  186. key={spool.id}
  187. spool={spool}
  188. assignment={assignmentMap[spool.id]}
  189. onClick={() => setSelectedSpoolId(spool.id)}
  190. />
  191. ))}
  192. </div>
  193. )}
  194. </div>
  195. {/* Detail modal — look up spool from live query data so it stays current */}
  196. {selectedSpoolId != null && (() => {
  197. const liveSpool = spools.find(s => s.id === selectedSpoolId);
  198. if (!liveSpool) return null;
  199. return (
  200. <SpoolDetailModal
  201. spool={liveSpool}
  202. assignment={assignmentMap[liveSpool.id]}
  203. onClose={() => setSelectedSpoolId(null)}
  204. />
  205. );
  206. })()}
  207. </div>
  208. );
  209. }
  210. /* Filter pill button */
  211. function FilterPill({ active, onClick, label, green }: {
  212. active: boolean;
  213. onClick: () => void;
  214. label: string;
  215. green?: boolean;
  216. }) {
  217. return (
  218. <button
  219. onClick={onClick}
  220. className={`px-4 py-1.5 rounded-full text-sm font-medium border whitespace-nowrap shrink-0 transition-colors ${
  221. active
  222. ? green
  223. ? 'bg-bambu-green/20 text-bambu-green border-bambu-green/50'
  224. : 'bg-white/10 text-white border-white/20'
  225. : 'bg-transparent text-white/40 border-bambu-dark-tertiary hover:text-white/60'
  226. }`}
  227. >
  228. {label}
  229. </button>
  230. );
  231. }
  232. /* Catalog-style spool card matching the mockup */
  233. function CatalogCard({ spool, assignment, onClick }: {
  234. spool: InventorySpool;
  235. assignment?: SpoolAssignment;
  236. onClick: () => void;
  237. }) {
  238. const color = spoolColor(spool);
  239. const pct = spoolPct(spool);
  240. const remaining = spoolRemaining(spool);
  241. const colorName = resolveSpoolColorName(spool.color_name, spool.rgba);
  242. return (
  243. <button
  244. onClick={onClick}
  245. className="bg-bambu-dark-secondary rounded-xl p-3 flex flex-col items-center text-center gap-1.5 border border-transparent hover:border-bambu-green/50 transition-colors"
  246. >
  247. {/* Spool icon */}
  248. <SpoolCircle color={color} size={56} />
  249. {/* Material + Subtype */}
  250. <p className="text-xs font-semibold text-white leading-tight truncate w-full">
  251. {spoolDisplayName(spool)}
  252. </p>
  253. {/* Color dot + name */}
  254. <div className="flex items-center gap-1 min-w-0 max-w-full">
  255. <span
  256. className="w-2.5 h-2.5 rounded-full shrink-0 border border-white/10"
  257. style={{ backgroundColor: color }}
  258. />
  259. <span className="text-[11px] text-white/50 truncate">
  260. {colorName || '-'}
  261. </span>
  262. </div>
  263. {/* Fill bar + weight */}
  264. <div className="w-full space-y-0.5">
  265. <div className="h-1.5 bg-bambu-dark-tertiary rounded-full overflow-hidden">
  266. <div
  267. className={`h-full rounded-full ${pct > 50 ? 'bg-bambu-green' : pct > 20 ? 'bg-yellow-500' : 'bg-red-500'}`}
  268. style={{ width: `${Math.min(pct, 100)}%` }}
  269. />
  270. </div>
  271. <p className="text-[11px] text-white/40">
  272. {Math.round(remaining)}g ({Math.round(pct)}%)
  273. </p>
  274. </div>
  275. {/* AMS location badge */}
  276. {assignment && (
  277. <span className="px-2 py-0.5 rounded text-[10px] font-bold bg-bambu-green/20 text-bambu-green">
  278. {assignmentLabel(assignment)}
  279. </span>
  280. )}
  281. </button>
  282. );
  283. }
  284. /* Detail bottom sheet */
  285. function SpoolDetailModal({ spool, assignment, onClose }: {
  286. spool: InventorySpool;
  287. assignment?: SpoolAssignment;
  288. onClose: () => void;
  289. }) {
  290. const { t } = useTranslation();
  291. const color = spoolColor(spool);
  292. const pct = spoolPct(spool);
  293. const remaining = spoolRemaining(spool);
  294. const colorName = resolveSpoolColorName(spool.color_name, spool.rgba);
  295. return (
  296. <div className="fixed inset-0 z-50" onClick={onClose}>
  297. <div
  298. className="h-full w-full bg-bambu-dark overflow-y-auto"
  299. onClick={e => e.stopPropagation()}
  300. >
  301. {/* Header with spool icon */}
  302. <div className="flex items-center gap-4 p-4 pb-3">
  303. <SpoolCircle color={color} size={72} />
  304. <div className="flex-1 min-w-0">
  305. <h2 className="text-lg font-semibold text-white">
  306. {spoolDisplayName(spool)}
  307. </h2>
  308. {spool.brand && (
  309. <p className="text-sm text-white/50">{spool.brand}</p>
  310. )}
  311. <div className="flex items-center gap-1.5 mt-1">
  312. <span
  313. className="w-3 h-3 rounded-full border border-white/10"
  314. style={{ backgroundColor: color }}
  315. />
  316. <span className="text-sm text-white/60">
  317. {colorName || '-'}
  318. </span>
  319. </div>
  320. </div>
  321. </div>
  322. <div className="px-4 pb-4 space-y-4">
  323. {/* Remaining bar */}
  324. <div>
  325. <div className="flex justify-between text-xs text-white/50 mb-1.5">
  326. <span>{t('spoolbuddy.inventory.remaining', 'Remaining')}</span>
  327. <span>{Math.round(remaining)}g ({Math.round(pct)}%)</span>
  328. </div>
  329. <div className="h-3 bg-bambu-dark-secondary rounded-full overflow-hidden">
  330. <div
  331. className={`h-full rounded-full transition-all ${pct > 50 ? 'bg-bambu-green' : pct > 20 ? 'bg-yellow-500' : 'bg-red-500'}`}
  332. style={{ width: `${Math.min(pct, 100)}%` }}
  333. />
  334. </div>
  335. </div>
  336. {/* AMS location */}
  337. {assignment && (
  338. <div className="flex items-center gap-2">
  339. <span className="px-2.5 py-1 rounded-md text-xs font-bold bg-bambu-green/20 text-bambu-green">
  340. {assignmentLabel(assignment)}
  341. </span>
  342. {assignment.printer_name && (
  343. <span className="text-xs text-white/40">{assignment.printer_name}</span>
  344. )}
  345. </div>
  346. )}
  347. {/* Detail grid */}
  348. <div className="grid grid-cols-2 gap-2.5">
  349. <DetailItem
  350. label={t('spoolbuddy.inventory.labelWeight', 'Label Weight')}
  351. value={`${spool.label_weight}g`}
  352. />
  353. <DetailItem
  354. label={t('spoolbuddy.inventory.weightUsed', 'Used')}
  355. value={spool.weight_used > 0 ? `${Math.round(spool.weight_used)}g` : '-'}
  356. />
  357. <DetailItem
  358. label={t('spoolbuddy.inventory.coreWeight', 'Core Weight')}
  359. value={spool.core_weight > 0 ? `${spool.core_weight}g` : '-'}
  360. />
  361. <DetailItem
  362. label={t('spoolbuddy.inventory.grossWeight', 'Gross Weight')}
  363. value={`${spool.label_weight + spool.core_weight}g`}
  364. />
  365. {spool.nozzle_temp_min != null && spool.nozzle_temp_max != null && (
  366. <DetailItem
  367. label={t('spoolbuddy.inventory.nozzleTemp', 'Nozzle Temp')}
  368. value={`${spool.nozzle_temp_min}-${spool.nozzle_temp_max}°C`}
  369. />
  370. )}
  371. {spool.cost_per_kg != null && spool.cost_per_kg > 0 && (
  372. <DetailItem
  373. label={t('spoolbuddy.inventory.costPerKg', 'Cost/kg')}
  374. value={`${spool.cost_per_kg.toFixed(2)}/kg`}
  375. />
  376. )}
  377. {spool.last_scale_weight != null && (
  378. <DetailItem
  379. label={t('spoolbuddy.inventory.lastScaleWeight', 'Scale Weight')}
  380. value={`${Math.round(spool.last_scale_weight)}g`}
  381. />
  382. )}
  383. {spool.tag_uid && (
  384. <DetailItem
  385. label={t('spoolbuddy.inventory.tagId', 'Tag')}
  386. value={spool.tag_uid}
  387. mono
  388. />
  389. )}
  390. {(spool.slicer_filament_name || spool.slicer_filament) && (
  391. <DetailItem
  392. label={t('spoolbuddy.inventory.slicerFilament', 'Slicer Filament')}
  393. value={spool.slicer_filament_name || spool.slicer_filament || ''}
  394. />
  395. )}
  396. </div>
  397. {/* K-Profiles */}
  398. {spool.k_profiles && spool.k_profiles.length > 0 && (
  399. <div>
  400. <p className="text-xs text-white/40 mb-1.5">{t('spoolbuddy.inventory.kProfiles', 'PA K-Profiles')}</p>
  401. <div className="space-y-1">
  402. {spool.k_profiles.map(kp => (
  403. <div key={kp.id} className="flex items-center justify-between bg-bambu-dark-secondary rounded-lg px-3 py-2">
  404. <span className="text-sm text-white/70 truncate">
  405. {kp.name || `${kp.nozzle_diameter}mm ${kp.nozzle_type || ''}`}
  406. </span>
  407. <span className="text-sm font-mono text-bambu-green shrink-0 ml-2">
  408. {kp.k_value.toFixed(3)}
  409. </span>
  410. </div>
  411. ))}
  412. </div>
  413. </div>
  414. )}
  415. {/* Note */}
  416. {spool.note && (
  417. <div className="bg-bambu-dark-secondary rounded-lg p-3">
  418. <p className="text-xs text-white/40 mb-1">{t('spoolbuddy.inventory.note', 'Note')}</p>
  419. <p className="text-sm text-white/70">{spool.note}</p>
  420. </div>
  421. )}
  422. {/* Close button */}
  423. <button
  424. onClick={onClose}
  425. className="w-full py-3 rounded-xl bg-bambu-dark-secondary hover:bg-bambu-dark-tertiary text-white/60 hover:text-white text-sm font-medium transition-colors"
  426. >
  427. {t('spoolbuddy.inventory.close', 'Close')}
  428. </button>
  429. </div>
  430. </div>
  431. </div>
  432. );
  433. }
  434. function DetailItem({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
  435. return (
  436. <div className="bg-bambu-dark-secondary rounded-lg px-3 py-2">
  437. <p className="text-[10px] text-white/40 uppercase tracking-wide">{label}</p>
  438. <p className={`text-sm text-white mt-0.5 truncate ${mono ? 'font-mono text-xs' : ''}`}>{value}</p>
  439. </div>
  440. );
  441. }