SpoolBuddyInventoryPage.tsx 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387
  1. import { useState, useMemo } from 'react';
  2. import { useQuery } from '@tanstack/react-query';
  3. import { useTranslation } from 'react-i18next';
  4. import { useOutletContext } from 'react-router-dom';
  5. import { Search, X, Package } from 'lucide-react';
  6. import { api } from '../../api/client';
  7. import type { InventorySpool, SpoolAssignment } from '../../api/client';
  8. import { resolveSpoolColorName } from '../../utils/colors';
  9. import { formatSlotLabel } from '../../utils/amsHelpers';
  10. import { InventorySpoolInfoCard } from '../../components/spoolbuddy/InventorySpoolInfoCard';
  11. import { AssignToAmsModal } from '../../components/spoolbuddy/AssignToAmsModal';
  12. import type { SpoolBuddyOutletContext } from '../../components/spoolbuddy/SpoolBuddyLayout';
  13. type FilterMode = 'all' | 'in_ams' | string; // string = material name
  14. function spoolColor(spool: InventorySpool): string {
  15. if (spool.rgba) return `#${spool.rgba.substring(0, 6)}`;
  16. return '#808080';
  17. }
  18. function spoolRemaining(spool: InventorySpool): number {
  19. return Math.max(0, spool.label_weight - spool.weight_used);
  20. }
  21. function spoolPct(spool: InventorySpool): number {
  22. if (spool.label_weight <= 0) return 0;
  23. return Math.max(0, Math.min(100, ((spool.label_weight - spool.weight_used) / spool.label_weight) * 100));
  24. }
  25. function spoolDisplayName(spool: InventorySpool): string {
  26. const parts = [spool.material];
  27. if (spool.subtype) parts.push(spool.subtype);
  28. return parts.join(' ');
  29. }
  30. function assignmentLabel(a: SpoolAssignment): string {
  31. const isExternal = a.ams_id === 254 || a.ams_id === 255;
  32. const isHt = !isExternal && a.ams_id >= 128;
  33. return formatSlotLabel(a.ams_id, a.tray_id, isHt, isExternal);
  34. }
  35. /* Spool circle — same style as AMS page tray slots */
  36. function SpoolCircle({ color, size = 56 }: { color: string; size?: number }) {
  37. return (
  38. <svg width={size} height={size} viewBox="0 0 56 56">
  39. <circle cx="28" cy="28" r="26" fill={color} />
  40. <circle cx="28" cy="28" r="20" fill={color} style={{ filter: 'brightness(0.85)' }} />
  41. <ellipse cx="20" cy="20" rx="6" ry="4" fill="white" opacity="0.3" />
  42. <circle cx="28" cy="28" r="8" fill="#2d2d2d" />
  43. <circle cx="28" cy="28" r="5" fill="#1a1a1a" />
  44. </svg>
  45. );
  46. }
  47. export function SpoolBuddyInventoryPage() {
  48. const { sbState, selectedPrinterId } = useOutletContext<SpoolBuddyOutletContext>();
  49. const { t } = useTranslation();
  50. const [searchQuery, setSearchQuery] = useState('');
  51. const [filterMode, setFilterMode] = useState<FilterMode>('all');
  52. const [selectedSpoolId, setSelectedSpoolId] = useState<number | null>(null);
  53. const [showAssignAmsModal, setShowAssignAmsModal] = useState(false);
  54. const { data: spoolmanSettings } = useQuery({
  55. queryKey: ['spoolman-settings'],
  56. queryFn: api.getSpoolmanSettings,
  57. staleTime: 5 * 60 * 1000,
  58. });
  59. const { data: spools = [], isLoading, refetch: refetchSpools } = useQuery({
  60. queryKey: ['inventory-spools'],
  61. queryFn: () => api.getSpools(false),
  62. refetchInterval: 30000,
  63. });
  64. const { data: assignments = [] } = useQuery({
  65. queryKey: ['spool-assignments'],
  66. queryFn: () => api.getAssignments(),
  67. refetchInterval: 30000,
  68. });
  69. // Build assignment lookup: spool_id → assignment
  70. const assignmentMap = useMemo(() => {
  71. const map: Record<number, SpoolAssignment> = {};
  72. assignments.forEach(a => { map[a.spool_id] = a; });
  73. return map;
  74. }, [assignments]);
  75. const activeSpools = useMemo(() => spools.filter(s => !s.archived_at), [spools]);
  76. // Spools that have an AMS assignment
  77. const assignedSpoolIds = useMemo(() => new Set(assignments.map(a => a.spool_id)), [assignments]);
  78. const inAmsCount = useMemo(() => activeSpools.filter(s => assignedSpoolIds.has(s.id)).length, [activeSpools, assignedSpoolIds]);
  79. // Unique materials for filter pills
  80. const materials = useMemo(() => {
  81. const set = new Set<string>();
  82. activeSpools.forEach(s => set.add(s.material));
  83. return Array.from(set).sort();
  84. }, [activeSpools]);
  85. // Filter and sort
  86. const filteredSpools = useMemo(() => {
  87. let list = activeSpools;
  88. if (filterMode === 'in_ams') {
  89. list = list.filter(s => assignedSpoolIds.has(s.id));
  90. } else if (filterMode !== 'all') {
  91. list = list.filter(s => s.material === filterMode);
  92. }
  93. if (searchQuery.trim()) {
  94. const q = searchQuery.toLowerCase().trim();
  95. list = list.filter(s =>
  96. s.material.toLowerCase().includes(q) ||
  97. (s.subtype && s.subtype.toLowerCase().includes(q)) ||
  98. (s.brand && s.brand.toLowerCase().includes(q)) ||
  99. (s.color_name && s.color_name.toLowerCase().includes(q)) ||
  100. (s.note && s.note.toLowerCase().includes(q))
  101. );
  102. }
  103. // Sort: assigned spools first (by slot label), then by most recently updated
  104. return [...list].sort((a, b) => {
  105. const aAssigned = assignedSpoolIds.has(a.id) ? 0 : 1;
  106. const bAssigned = assignedSpoolIds.has(b.id) ? 0 : 1;
  107. if (aAssigned !== bAssigned) return aAssigned - bAssigned;
  108. return new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime();
  109. });
  110. }, [activeSpools, filterMode, searchQuery, assignedSpoolIds]);
  111. // Spoolman iframe mode
  112. const spoolmanEnabled = spoolmanSettings?.spoolman_enabled === 'true' && spoolmanSettings?.spoolman_url;
  113. if (spoolmanEnabled) {
  114. return (
  115. <div className="h-full flex flex-col">
  116. <iframe
  117. src={`${spoolmanSettings.spoolman_url.replace(/\/+$/, '')}/spool`}
  118. className="flex-1 w-full border-0"
  119. title="Spoolman"
  120. sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox"
  121. />
  122. </div>
  123. );
  124. }
  125. return (
  126. <div className="h-full flex flex-col">
  127. {/* Search + filter pills */}
  128. <div className="px-3 pt-3 pb-2 space-y-2.5">
  129. {/* Search */}
  130. <div className="relative">
  131. <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-white/40" />
  132. <input
  133. type="text"
  134. value={searchQuery}
  135. onChange={e => setSearchQuery(e.target.value)}
  136. placeholder={t('spoolbuddy.inventory.searchPlaceholder', 'Search spools...')}
  137. 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"
  138. />
  139. {searchQuery && (
  140. <button
  141. onClick={() => setSearchQuery('')}
  142. className="absolute right-2 top-1/2 -translate-y-1/2 text-white/40 hover:text-white/60"
  143. >
  144. <X className="w-4 h-4" />
  145. </button>
  146. )}
  147. </div>
  148. {/* Filter pills — inline scrollable row */}
  149. <div className="flex gap-1.5 overflow-x-auto no-scrollbar">
  150. <FilterPill
  151. active={filterMode === 'all'}
  152. onClick={() => setFilterMode('all')}
  153. label={`${t('spoolbuddy.inventory.all', 'All')} (${activeSpools.length})`}
  154. green
  155. />
  156. {inAmsCount > 0 && (
  157. <FilterPill
  158. active={filterMode === 'in_ams'}
  159. onClick={() => setFilterMode('in_ams')}
  160. label={`${t('spoolbuddy.inventory.inAms', 'In AMS')} (${inAmsCount})`}
  161. />
  162. )}
  163. {materials.map(mat => (
  164. <FilterPill
  165. key={mat}
  166. active={filterMode === mat}
  167. onClick={() => setFilterMode(filterMode === mat ? 'all' : mat)}
  168. label={mat}
  169. />
  170. ))}
  171. </div>
  172. </div>
  173. {/* Spool grid */}
  174. <div className="flex-1 overflow-y-auto px-3 pb-3">
  175. {isLoading ? (
  176. <div className="flex items-center justify-center py-16">
  177. <div className="w-8 h-8 border-2 border-bambu-green border-t-transparent rounded-full animate-spin" />
  178. </div>
  179. ) : filteredSpools.length === 0 ? (
  180. <div className="flex flex-col items-center justify-center py-16 text-white/30">
  181. <Package className="w-12 h-12 mb-3" />
  182. <p className="text-sm">
  183. {searchQuery || filterMode !== 'all'
  184. ? t('spoolbuddy.inventory.noResults', 'No spools match your filters')
  185. : t('spoolbuddy.inventory.empty', 'No spools in inventory')}
  186. </p>
  187. </div>
  188. ) : (
  189. <div className="grid grid-cols-[repeat(auto-fill,minmax(130px,1fr))] gap-2">
  190. {filteredSpools.map(spool => (
  191. <CatalogCard
  192. key={spool.id}
  193. spool={spool}
  194. assignment={assignmentMap[spool.id]}
  195. onClick={() => setSelectedSpoolId(spool.id)}
  196. />
  197. ))}
  198. </div>
  199. )}
  200. </div>
  201. {/* Detail modal — look up spool from live query data so it stays current */}
  202. {selectedSpoolId != null && (() => {
  203. const liveSpool = spools.find(s => s.id === selectedSpoolId);
  204. if (!liveSpool) return null;
  205. const handleCloseDetail = () => {
  206. setSelectedSpoolId(null);
  207. setShowAssignAmsModal(false);
  208. };
  209. return (
  210. <>
  211. <SpoolDetailModal
  212. spool={liveSpool}
  213. assignment={assignmentMap[liveSpool.id]}
  214. sbState={sbState}
  215. onSyncWeight={() => {
  216. void refetchSpools();
  217. }}
  218. onAssignToAms={() => setShowAssignAmsModal(true)}
  219. onClose={handleCloseDetail}
  220. />
  221. <AssignToAmsModal
  222. isOpen={showAssignAmsModal}
  223. onClose={() => setShowAssignAmsModal(false)}
  224. spool={liveSpool}
  225. printerId={selectedPrinterId}
  226. />
  227. </>
  228. );
  229. })()}
  230. </div>
  231. );
  232. }
  233. /* Filter pill button */
  234. function FilterPill({ active, onClick, label, green }: {
  235. active: boolean;
  236. onClick: () => void;
  237. label: string;
  238. green?: boolean;
  239. }) {
  240. return (
  241. <button
  242. onClick={onClick}
  243. className={`px-4 py-1.5 rounded-full text-sm font-medium border whitespace-nowrap shrink-0 transition-colors ${
  244. active
  245. ? green
  246. ? 'bg-bambu-green/20 text-bambu-green border-bambu-green/50'
  247. : 'bg-white/10 text-white border-white/20'
  248. : 'bg-transparent text-white/40 border-bambu-dark-tertiary hover:text-white/60'
  249. }`}
  250. >
  251. {label}
  252. </button>
  253. );
  254. }
  255. /* Catalog-style spool card matching the mockup */
  256. function CatalogCard({ spool, assignment, onClick }: {
  257. spool: InventorySpool;
  258. assignment?: SpoolAssignment;
  259. onClick: () => void;
  260. }) {
  261. const color = spoolColor(spool);
  262. const pct = spoolPct(spool);
  263. const remaining = spoolRemaining(spool);
  264. const colorName = resolveSpoolColorName(spool.color_name, spool.rgba);
  265. return (
  266. <button
  267. onClick={onClick}
  268. 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"
  269. >
  270. {/* Spool icon */}
  271. <SpoolCircle color={color} size={56} />
  272. {/* Material + Subtype */}
  273. <p className="text-xs font-semibold text-white leading-tight truncate w-full">
  274. {spoolDisplayName(spool)}
  275. </p>
  276. {/* Color dot + name */}
  277. <div className="flex items-center gap-1 min-w-0 max-w-full">
  278. <span
  279. className="w-2.5 h-2.5 rounded-full shrink-0 border border-white/10"
  280. style={{ backgroundColor: color }}
  281. />
  282. <span className="text-[11px] text-white/50 truncate">
  283. {colorName || '-'}
  284. </span>
  285. </div>
  286. {/* Fill bar + weight */}
  287. <div className="w-full space-y-0.5">
  288. <div className="h-1.5 bg-bambu-dark-tertiary rounded-full overflow-hidden">
  289. <div
  290. className={`h-full rounded-full ${pct > 50 ? 'bg-bambu-green' : pct > 20 ? 'bg-yellow-500' : 'bg-red-500'}`}
  291. style={{ width: `${Math.min(pct, 100)}%` }}
  292. />
  293. </div>
  294. <p className="text-[11px] text-white/40">
  295. {Math.round(remaining)}g ({Math.round(pct)}%)
  296. </p>
  297. </div>
  298. {/* AMS location badge */}
  299. {assignment && (
  300. <span className="px-2 py-0.5 rounded text-[10px] font-bold bg-bambu-green/20 text-bambu-green">
  301. {assignmentLabel(assignment)}
  302. </span>
  303. )}
  304. </button>
  305. );
  306. }
  307. /* Detail bottom sheet */
  308. function SpoolDetailModal({ spool, assignment, sbState, onSyncWeight, onAssignToAms, onClose }: {
  309. spool: InventorySpool;
  310. assignment?: SpoolAssignment;
  311. sbState: SpoolBuddyOutletContext['sbState'];
  312. onSyncWeight: () => void;
  313. onAssignToAms: () => void;
  314. onClose: () => void;
  315. }) {
  316. const useLiveScaleWeight = sbState.deviceOnline && sbState.weight !== null;
  317. const modalScaleWeight = useLiveScaleWeight
  318. ? Math.round(sbState.weight as number)
  319. : null;
  320. const persistedGrossWeight = spool.last_scale_weight != null ? Math.round(spool.last_scale_weight) : null;
  321. return (
  322. <div className="fixed inset-0 z-50 bg-black/70 flex items-center justify-center p-4" onClick={onClose}>
  323. <div
  324. className="w-full max-w-md bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-2xl p-4 overflow-y-auto max-h-[90vh]"
  325. onClick={e => e.stopPropagation()}
  326. >
  327. <div className="space-y-3">
  328. {assignment && (
  329. <div className="flex items-center justify-center gap-2">
  330. <span className="px-2.5 py-1 rounded-md text-xs font-bold bg-bambu-green/20 text-bambu-green">
  331. {assignmentLabel(assignment)}
  332. </span>
  333. {assignment.printer_name && (
  334. <span className="text-xs text-zinc-400">{assignment.printer_name}</span>
  335. )}
  336. </div>
  337. )}
  338. <div className="flex justify-center">
  339. <InventorySpoolInfoCard
  340. spool={spool}
  341. liveScaleWeight={modalScaleWeight}
  342. persistedGrossWeight={persistedGrossWeight}
  343. onSyncWeight={onSyncWeight}
  344. onAssignToAms={onAssignToAms}
  345. onClose={onClose}
  346. className="max-w-md"
  347. />
  348. </div>
  349. </div>
  350. </div>
  351. </div>
  352. );
  353. }