InventorySpoolInfoCard.tsx 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305
  1. import { useState } from 'react';
  2. import { useQuery } from '@tanstack/react-query';
  3. import { useTranslation } from 'react-i18next';
  4. import { Check, AlertTriangle, RefreshCw, Unlink } from 'lucide-react';
  5. import type { InventorySpool } from '../../api/client';
  6. import { spoolbuddyApi, api } from '../../api/client';
  7. import { SpoolIcon } from './SpoolIcon';
  8. import { resolveSpoolColorName, spoolColorString } from '../../utils/colors';
  9. const DEFAULT_CORE_WEIGHT_KEY = 'spoolbuddy-default-core-weight';
  10. function getDefaultCoreWeight(): number {
  11. try {
  12. const stored = localStorage.getItem(DEFAULT_CORE_WEIGHT_KEY);
  13. if (stored) {
  14. const weight = parseInt(stored, 10);
  15. if (weight >= 0 && weight <= 500) return weight;
  16. }
  17. } catch {
  18. // Ignore errors
  19. }
  20. return 250;
  21. }
  22. interface InventorySpoolInfoCardProps {
  23. spool: InventorySpool;
  24. liveScaleWeight: number | null;
  25. persistedGrossWeight?: number | null;
  26. onClose?: () => void;
  27. onSyncWeight?: () => void;
  28. onAssignToAms?: () => void;
  29. isAssigned?: boolean;
  30. onUnassignFromAms?: () => void;
  31. className?: string;
  32. }
  33. export function InventorySpoolInfoCard({
  34. spool,
  35. liveScaleWeight,
  36. persistedGrossWeight,
  37. onClose,
  38. onSyncWeight,
  39. onAssignToAms,
  40. isAssigned,
  41. onUnassignFromAms,
  42. className,
  43. }: InventorySpoolInfoCardProps) {
  44. const { t } = useTranslation();
  45. const [syncing, setSyncing] = useState(false);
  46. const [synced, setSynced] = useState(false);
  47. const [syncedGrossWeight, setSyncedGrossWeight] = useState<number | null>(null);
  48. // Fetch k_profiles if not already present in the spool object
  49. const { data: fetchedKProfiles } = useQuery({
  50. queryKey: ['spool-k-profiles', spool.id],
  51. queryFn: () => api.getSpoolKProfiles(spool.id),
  52. // Inventory list payloads may omit k_profiles, so lazily fetch when missing.
  53. enabled: !spool.k_profiles || spool.k_profiles.length === 0,
  54. staleTime: 5 * 60 * 1000,
  55. });
  56. // Use fetched k_profiles if available, otherwise use the ones from the spool object
  57. const kProfiles = (spool.k_profiles && spool.k_profiles.length > 0) ? spool.k_profiles : fetchedKProfiles;
  58. const colorHex = spoolColorString(spool.rgba);
  59. // The stored name is not the displayed one: Bambu tags often carry no
  60. // colour name at all, or an internal code, and Spoolman has no field for
  61. // one — the catalog resolves the swatch's hex instead (#3090, #857).
  62. const colorName = resolveSpoolColorName(spool.color_name, spool.rgba, spool.color_name_is_synthesized);
  63. const coreWeight = (spool.core_weight && spool.core_weight > 0)
  64. ? spool.core_weight
  65. : getDefaultCoreWeight();
  66. const grossWeightFromScale = liveScaleWeight !== null
  67. ? Math.round(Math.max(0, liveScaleWeight))
  68. : null;
  69. // Inventory scenario: prefer the most recently synced value in this modal session.
  70. const displayedGrossWeight = syncedGrossWeight ?? (
  71. persistedGrossWeight !== undefined
  72. ? (persistedGrossWeight !== null ? Math.round(Math.max(0, persistedGrossWeight)) : null)
  73. : grossWeightFromScale
  74. );
  75. const inventoryRemaining = Math.round(Math.max(0,
  76. (spool.label_weight || 0) - (spool.weight_used || 0)
  77. ));
  78. // Use live scale for remaining/fill only when scale has a meaningful reading.
  79. const minDynamicScaleReading = 10;
  80. const useDynamicRemaining = grossWeightFromScale !== null
  81. && grossWeightFromScale >= minDynamicScaleReading;
  82. const remaining = useDynamicRemaining
  83. ? Math.round(Math.max(0, grossWeightFromScale - coreWeight))
  84. : inventoryRemaining;
  85. const labelWeight = Math.round(spool.label_weight || 1000);
  86. const fillPercent = labelWeight > 0 ? Math.min(100, Math.round((remaining / labelWeight) * 100)) : null;
  87. const fillColor = fillPercent !== null
  88. ? (fillPercent > 50 ? '#22c55e' : fillPercent > 20 ? '#eab308' : '#ef4444')
  89. : '#808080';
  90. const netWeight = Math.max(0,
  91. (spool.label_weight || 0) - (spool.weight_used || 0)
  92. );
  93. const calculatedWeight = netWeight + coreWeight;
  94. const difference = grossWeightFromScale !== null ? grossWeightFromScale - calculatedWeight : null;
  95. const isMatch = difference !== null ? Math.abs(difference) <= 50 : null;
  96. // Inventory fallback so gross is always populated across spools.
  97. const inventoryDerivedGrossWeight = Math.round(calculatedWeight);
  98. const resolvedGrossWeight = displayedGrossWeight ?? inventoryDerivedGrossWeight;
  99. const nozzleTempRange = (spool.nozzle_temp_min != null && spool.nozzle_temp_max != null)
  100. ? `${spool.nozzle_temp_min}-${spool.nozzle_temp_max}\u00B0C`
  101. : null;
  102. const slicerPreset = spool.slicer_filament_name || spool.slicer_filament || null;
  103. const note = spool.note?.trim() || null;
  104. const kFactorSummary = (kProfiles && kProfiles.length > 0)
  105. ? Array.from(new Set(kProfiles.map(kp => kp.k_value.toFixed(3)))).join(', ')
  106. : null;
  107. const handleSyncWeight = async () => {
  108. if (liveScaleWeight === null) return;
  109. const roundedLiveWeight = Math.round(Math.max(0, liveScaleWeight));
  110. setSyncing(true);
  111. try {
  112. await spoolbuddyApi.updateSpoolWeight(spool.id, roundedLiveWeight);
  113. setSyncedGrossWeight(roundedLiveWeight);
  114. setSynced(true);
  115. onSyncWeight?.();
  116. setTimeout(() => setSynced(false), 3000);
  117. } catch (e) {
  118. console.error('Failed to sync weight:', e);
  119. } finally {
  120. setSyncing(false);
  121. }
  122. };
  123. return (
  124. <div className={`flex flex-col items-center space-y-4 max-w-md ${className ?? ''}`}>
  125. <div className="flex items-start gap-5">
  126. <div className="relative shrink-0">
  127. <SpoolIcon color={colorHex} isEmpty={false} size={100} spool={spool} />
  128. {fillPercent !== null && (
  129. <div
  130. className="absolute -bottom-2 -right-2 px-2 py-0.5 rounded-full text-xs font-bold text-white shadow-lg"
  131. style={{ backgroundColor: fillColor }}
  132. >
  133. {fillPercent}%
  134. </div>
  135. )}
  136. </div>
  137. <div className="flex-1 min-w-0 pt-1">
  138. <div className="flex items-center gap-2">
  139. <h3 className="text-lg font-semibold text-zinc-100">
  140. {colorName || t('spoolbuddy.spool.unknownColor')}
  141. </h3>
  142. <span className="text-xs font-mono text-zinc-500 shrink-0">#{spool.id}</span>
  143. </div>
  144. <p className="text-sm text-zinc-400">
  145. {spool.brand} &bull; {spool.material}
  146. {spool.subtype && ` ${spool.subtype}`}
  147. </p>
  148. <div className="mt-3">
  149. <div className="flex items-baseline gap-2">
  150. <span className="text-3xl font-bold font-mono text-zinc-100">{remaining}g</span>
  151. <span className="text-sm text-zinc-500">/ {labelWeight}g</span>
  152. </div>
  153. <p className="text-xs text-zinc-500 mt-0.5">{t('spoolbuddy.spool.remaining', 'Remaining')}</p>
  154. <div className="mt-2 max-w-xs">
  155. <div className="h-2 bg-zinc-700 rounded-full overflow-hidden">
  156. <div
  157. className="h-full rounded-full transition-all duration-500"
  158. style={{ width: `${fillPercent ?? 0}%`, backgroundColor: fillColor }}
  159. />
  160. </div>
  161. </div>
  162. </div>
  163. </div>
  164. </div>
  165. <div className="grid grid-cols-2 gap-x-6 gap-y-2 text-sm bg-zinc-800 rounded-lg p-4 w-full">
  166. <div className="flex justify-between">
  167. <span className="text-zinc-500">{t('spoolbuddy.dashboard.grossWeight', 'Gross weight')}</span>
  168. <span className="font-mono text-zinc-300">{resolvedGrossWeight}g</span>
  169. </div>
  170. <div className="flex justify-between">
  171. <span className="text-zinc-500">{t('spoolbuddy.spool.coreWeight', 'Core')}</span>
  172. <span className="font-mono text-zinc-300">{coreWeight}g</span>
  173. </div>
  174. <div className="flex justify-between">
  175. <span className="text-zinc-500">{t('spoolbuddy.dashboard.spoolSize', 'Spool size')}</span>
  176. <span className="font-mono text-zinc-300">{labelWeight}g</span>
  177. </div>
  178. <div className="flex justify-between items-center">
  179. <span className="text-zinc-500">{t('spoolbuddy.spool.scaleWeight', 'Scale')}</span>
  180. {grossWeightFromScale !== null ? (
  181. <span className={`flex items-center gap-1 font-mono ${isMatch ? 'text-green-500' : 'text-yellow-500'}`}>
  182. {grossWeightFromScale}g
  183. {isMatch ? (
  184. <Check className="w-3.5 h-3.5" />
  185. ) : (
  186. <>
  187. <AlertTriangle className="w-3.5 h-3.5" />
  188. <button
  189. onClick={handleSyncWeight}
  190. className="p-1 hover:bg-green-500/20 rounded transition-colors text-green-500"
  191. title={t('spoolbuddy.dashboard.syncWeight', 'Sync Weight')}
  192. >
  193. <RefreshCw className="w-4 h-4" />
  194. </button>
  195. </>
  196. )}
  197. </span>
  198. ) : (
  199. <span className="text-zinc-500">{'\u2014'}</span>
  200. )}
  201. </div>
  202. <div className="flex justify-between items-center">
  203. <span className="text-zinc-500">{t('spoolbuddy.dashboard.tagId', 'Tag')}</span>
  204. <span className="font-mono text-xs text-zinc-400 truncate max-w-[120px]" title={spool.tag_uid || ''}>
  205. {spool.tag_uid ? spool.tag_uid.slice(-8) : '\u2014'}
  206. </span>
  207. </div>
  208. {nozzleTempRange && (
  209. <div className="flex justify-between items-center">
  210. <span className="text-zinc-500">{t('spoolbuddy.inventory.nozzleTemp', 'Nozzle')}</span>
  211. <span className="font-mono text-zinc-300">{nozzleTempRange}</span>
  212. </div>
  213. )}
  214. {spool.cost_per_kg != null && spool.cost_per_kg > 0 && (
  215. <div className="flex justify-between items-center">
  216. <span className="text-zinc-500">{t('spoolbuddy.inventory.costPerKg', 'Cost/kg')}</span>
  217. <span className="font-mono text-zinc-300">{spool.cost_per_kg.toFixed(2)}/kg</span>
  218. </div>
  219. )}
  220. {kFactorSummary && (
  221. <div className="flex justify-between items-center">
  222. <span className="text-zinc-500">{t('spoolbuddy.inventory.kProfiles', 'K-Profile')}</span>
  223. <span className="font-mono text-zinc-300 truncate max-w-[220px] text-right" title={kFactorSummary}>{kFactorSummary}</span>
  224. </div>
  225. )}
  226. {slicerPreset && (
  227. <div className="min-w-0">
  228. <p className="text-xs text-zinc-500 mb-1">{t('spoolbuddy.inventory.slicerFilament', 'Slicer Filament')}</p>
  229. <p className="text-sm text-zinc-300 whitespace-pre-wrap break-words">{slicerPreset}</p>
  230. </div>
  231. )}
  232. {note && (
  233. <div className="col-span-2">
  234. <p className="text-xs text-zinc-500 mb-1">{t('spoolbuddy.inventory.note', 'Note')}</p>
  235. <p className="text-sm leading-5 text-zinc-300 whitespace-pre-wrap break-words max-h-[3.75rem] overflow-y-auto pr-1">{note}</p>
  236. </div>
  237. )}
  238. </div>
  239. <div className="flex gap-2 justify-center">
  240. {onAssignToAms && (
  241. <button
  242. onClick={isAssigned ? undefined : onAssignToAms}
  243. disabled={!!isAssigned}
  244. className="px-5 py-2.5 rounded-lg text-sm font-medium bg-green-600 text-white hover:bg-green-700 transition-colors min-h-[44px] disabled:opacity-50 disabled:cursor-not-allowed"
  245. >
  246. {t('spoolbuddy.modal.assignToAms', 'Assign to AMS')}
  247. </button>
  248. )}
  249. {onUnassignFromAms && (
  250. <button
  251. onClick={onUnassignFromAms}
  252. className="px-5 py-2.5 rounded-lg text-sm font-medium bg-red-600/20 text-red-400 hover:bg-red-600/30 transition-colors min-h-[44px]"
  253. >
  254. <Unlink className="w-4 h-4 inline mr-1" />
  255. {t('inventory.unassignSpool')}
  256. </button>
  257. )}
  258. <button
  259. onClick={handleSyncWeight}
  260. disabled={liveScaleWeight === null || syncing}
  261. className={`px-5 py-2.5 rounded-lg text-sm font-medium transition-colors min-h-[44px] ${
  262. synced
  263. ? 'bg-green-600/20 text-green-400'
  264. : onAssignToAms
  265. ? 'bg-zinc-700 text-zinc-300 hover:bg-zinc-600 disabled:opacity-40 disabled:cursor-not-allowed'
  266. : 'bg-green-600 text-white hover:bg-green-700 disabled:opacity-40 disabled:cursor-not-allowed'
  267. }`}
  268. >
  269. {syncing ? '...' : synced ? t('spoolbuddy.dashboard.weightSynced', 'Synced!') : t('spoolbuddy.dashboard.syncWeight', 'Sync Weight')}
  270. </button>
  271. {onClose && (
  272. <button
  273. onClick={onClose}
  274. className="px-5 py-2.5 rounded-lg text-sm font-medium bg-zinc-700 text-zinc-300 hover:bg-zinc-600 transition-colors min-h-[44px]"
  275. >
  276. {t('spoolbuddy.dashboard.close', 'Close')}
  277. </button>
  278. )}
  279. </div>
  280. </div>
  281. );
  282. }