SpoolInfoCard.tsx 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300
  1. import { useState } from 'react';
  2. import { useTranslation } from 'react-i18next';
  3. import { Check, AlertTriangle, RefreshCw, Unlink } from 'lucide-react';
  4. import type { MatchedSpool } from '../../hooks/useSpoolBuddyState';
  5. import { spoolbuddyApi } from '../../api/client';
  6. import { SpoolIcon } from './SpoolIcon';
  7. import { spoolColorString } from '../../utils/colors';
  8. // Storage key for default core weight
  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; // Default 250g (typical Bambu spool core)
  21. }
  22. interface SpoolInfoCardProps {
  23. spool: MatchedSpool;
  24. scaleWeight: number | null;
  25. onClose?: () => void;
  26. onSyncWeight?: () => void;
  27. onAssignToAms?: () => void;
  28. isAssigned?: boolean;
  29. onUnassignFromAms?: () => void;
  30. }
  31. export function SpoolInfoCard({ spool, scaleWeight, onClose, onSyncWeight, onAssignToAms, isAssigned, onUnassignFromAms }: SpoolInfoCardProps) {
  32. const { t } = useTranslation();
  33. const [syncing, setSyncing] = useState(false);
  34. const [synced, setSynced] = useState(false);
  35. const colorHex = spoolColorString(spool.rgba);
  36. // Use spool's core_weight if set, otherwise fall back to default
  37. const coreWeight = (spool.core_weight && spool.core_weight > 0)
  38. ? spool.core_weight
  39. : getDefaultCoreWeight();
  40. // Gross weight from scale (live) or fallback
  41. const grossWeight = scaleWeight !== null
  42. ? Math.round(Math.max(0, scaleWeight))
  43. : null;
  44. // Remaining filament = gross - core
  45. const remaining = grossWeight !== null
  46. ? Math.round(Math.max(0, grossWeight - coreWeight))
  47. : null;
  48. const labelWeight = Math.round(spool.label_weight || 1000);
  49. const fillPercent = remaining !== null ? Math.min(100, Math.round((remaining / labelWeight) * 100)) : null;
  50. const fillColor = fillPercent !== null
  51. ? fillPercent > 50 ? '#22c55e' : fillPercent > 20 ? '#eab308' : '#ef4444'
  52. : '#808080';
  53. // Weight comparison (scale vs calculated expected)
  54. const netWeight = Math.max(0,
  55. (spool.label_weight || 0) - (spool.weight_used || 0)
  56. );
  57. const calculatedWeight = netWeight + coreWeight;
  58. const difference = grossWeight !== null ? grossWeight - calculatedWeight : null;
  59. const isMatch = difference !== null ? Math.abs(difference) <= 50 : null;
  60. const handleSyncWeight = async () => {
  61. if (scaleWeight === null) return;
  62. setSyncing(true);
  63. try {
  64. await spoolbuddyApi.updateSpoolWeight(spool.id, Math.round(scaleWeight));
  65. setSynced(true);
  66. onSyncWeight?.();
  67. setTimeout(() => setSynced(false), 3000);
  68. } catch (e) {
  69. console.error('Failed to sync weight:', e);
  70. } finally {
  71. setSyncing(false);
  72. }
  73. };
  74. return (
  75. <div className="flex flex-col items-center space-y-4 max-w-md">
  76. {/* Top section: Spool icon + main info */}
  77. <div className="flex items-start gap-5">
  78. {/* Spool visualization */}
  79. <div className="relative shrink-0">
  80. <SpoolIcon color={colorHex} isEmpty={false} size={100} />
  81. {fillPercent !== null && (
  82. <div
  83. className="absolute -bottom-2 -right-2 px-2 py-0.5 rounded-full text-xs font-bold text-white shadow-lg"
  84. style={{ backgroundColor: fillColor }}
  85. >
  86. {fillPercent}%
  87. </div>
  88. )}
  89. </div>
  90. {/* Main info */}
  91. <div className="flex-1 min-w-0 pt-1">
  92. <div className="flex items-center gap-2">
  93. <h3 className="text-lg font-semibold text-zinc-100">
  94. {spool.color_name || 'Unknown color'}
  95. </h3>
  96. <span className="text-xs font-mono text-zinc-500 shrink-0">#{spool.id}</span>
  97. </div>
  98. <p className="text-sm text-zinc-400">
  99. {spool.brand} &bull; {spool.material}
  100. {spool.subtype && ` ${spool.subtype}`}
  101. </p>
  102. {/* Filament remaining - big number */}
  103. {remaining !== null && (
  104. <div className="mt-3">
  105. <div className="flex items-baseline gap-2">
  106. <span className="text-3xl font-bold font-mono text-zinc-100">{remaining}g</span>
  107. <span className="text-sm text-zinc-500">/ {labelWeight}g</span>
  108. </div>
  109. <p className="text-xs text-zinc-500 mt-0.5">{t('spoolbuddy.spool.remaining', 'Remaining')}</p>
  110. {/* Fill bar */}
  111. <div className="mt-2 max-w-xs">
  112. <div className="h-2 bg-zinc-700 rounded-full overflow-hidden">
  113. <div
  114. className="h-full rounded-full transition-all duration-500"
  115. style={{ width: `${fillPercent}%`, backgroundColor: fillColor }}
  116. />
  117. </div>
  118. </div>
  119. </div>
  120. )}
  121. </div>
  122. </div>
  123. {/* Details grid */}
  124. <div className="grid grid-cols-2 gap-x-6 gap-y-2 text-sm bg-zinc-800 rounded-lg p-4 w-full">
  125. <div className="flex justify-between">
  126. <span className="text-zinc-500">{t('spoolbuddy.dashboard.grossWeight', 'Gross weight')}</span>
  127. <span className="font-mono text-zinc-300">{grossWeight !== null ? `${grossWeight}g` : '\u2014'}</span>
  128. </div>
  129. <div className="flex justify-between">
  130. <span className="text-zinc-500">{t('spoolbuddy.spool.coreWeight', 'Core')}</span>
  131. <span className="font-mono text-zinc-300">{coreWeight}g</span>
  132. </div>
  133. <div className="flex justify-between">
  134. <span className="text-zinc-500">{t('spoolbuddy.dashboard.spoolSize', 'Spool size')}</span>
  135. <span className="font-mono text-zinc-300">{labelWeight}g</span>
  136. </div>
  137. <div className="flex justify-between items-center">
  138. <span className="text-zinc-500">{t('spoolbuddy.spool.scaleWeight', 'Scale')}</span>
  139. {grossWeight !== null ? (
  140. <span className={`flex items-center gap-1 font-mono ${isMatch ? 'text-green-500' : 'text-yellow-500'}`}>
  141. {grossWeight}g
  142. {isMatch ? (
  143. <Check className="w-3.5 h-3.5" />
  144. ) : (
  145. <>
  146. <AlertTriangle className="w-3.5 h-3.5" />
  147. <button
  148. onClick={handleSyncWeight}
  149. className="p-1 hover:bg-green-500/20 rounded transition-colors text-green-500"
  150. title={t('spoolbuddy.dashboard.syncWeight', 'Sync Weight')}
  151. >
  152. <RefreshCw className="w-4 h-4" />
  153. </button>
  154. </>
  155. )}
  156. </span>
  157. ) : (
  158. <span className="text-zinc-500">{'\u2014'}</span>
  159. )}
  160. </div>
  161. <div className="flex justify-between items-center">
  162. <span className="text-zinc-500">{t('spoolbuddy.dashboard.tagId', 'Tag')}</span>
  163. <span className="font-mono text-xs text-zinc-400 truncate max-w-[120px]" title={spool.tag_uid || ''}>
  164. {spool.tag_uid ? spool.tag_uid.slice(-8) : '\u2014'}
  165. </span>
  166. </div>
  167. </div>
  168. {/* Action buttons */}
  169. <div className="flex gap-2 justify-center">
  170. {onAssignToAms && (
  171. <button
  172. onClick={isAssigned ? undefined : onAssignToAms}
  173. disabled={!!isAssigned}
  174. 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"
  175. >
  176. {t('spoolbuddy.modal.assignToAms', 'Assign to AMS')}
  177. </button>
  178. )}
  179. {onUnassignFromAms && (
  180. <button
  181. onClick={onUnassignFromAms}
  182. 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]"
  183. >
  184. <Unlink className="w-4 h-4 inline mr-1" />
  185. {t('inventory.unassignSpool')}
  186. </button>
  187. )}
  188. <button
  189. onClick={handleSyncWeight}
  190. disabled={scaleWeight === null || syncing}
  191. className={`px-5 py-2.5 rounded-lg text-sm font-medium transition-colors min-h-[44px] ${
  192. synced
  193. ? 'bg-green-600/20 text-green-400'
  194. : onAssignToAms
  195. ? 'bg-zinc-700 text-zinc-300 hover:bg-zinc-600 disabled:opacity-40 disabled:cursor-not-allowed'
  196. : 'bg-green-600 text-white hover:bg-green-700 disabled:opacity-40 disabled:cursor-not-allowed'
  197. }`}
  198. >
  199. {syncing ? '...' : synced ? t('spoolbuddy.dashboard.weightSynced', 'Synced!') : t('spoolbuddy.dashboard.syncWeight', 'Sync Weight')}
  200. </button>
  201. {onClose && (
  202. <button
  203. onClick={onClose}
  204. 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]"
  205. >
  206. {t('spoolbuddy.dashboard.close', 'Close')}
  207. </button>
  208. )}
  209. </div>
  210. </div>
  211. );
  212. }
  213. interface UnknownTagCardProps {
  214. tagUid: string;
  215. scaleWeight: number | null;
  216. coreWeight?: number;
  217. onLinkSpool?: () => void;
  218. onAddToInventory?: () => void;
  219. onClose?: () => void;
  220. }
  221. export function UnknownTagCard({ tagUid, scaleWeight, coreWeight, onLinkSpool, onAddToInventory, onClose }: UnknownTagCardProps) {
  222. const { t } = useTranslation();
  223. const defaultCoreWeight = coreWeight ?? getDefaultCoreWeight();
  224. const grossWeight = scaleWeight !== null
  225. ? Math.round(Math.max(0, scaleWeight))
  226. : null;
  227. const estimatedRemaining = grossWeight !== null
  228. ? Math.round(Math.max(0, grossWeight - defaultCoreWeight))
  229. : null;
  230. return (
  231. <div className="flex flex-col items-center text-center space-y-5">
  232. <div className="w-20 h-20 rounded-2xl bg-green-500/15 flex items-center justify-center">
  233. <svg className="w-10 h-10 text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
  234. <path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A2 2 0 013 12V7a4 4 0 014-4z" />
  235. </svg>
  236. </div>
  237. <div>
  238. <h3 className="text-lg font-semibold text-zinc-100">{t('spoolbuddy.dashboard.newTag', 'New Tag Detected')}</h3>
  239. <p className="text-sm text-zinc-500 font-mono mt-1">{tagUid}</p>
  240. </div>
  241. {grossWeight !== null && (
  242. <div className="text-sm text-zinc-400">
  243. <span className="font-mono font-semibold">{grossWeight}g</span> {t('spoolbuddy.dashboard.onScale', 'on scale')}
  244. {estimatedRemaining !== null && estimatedRemaining > 0 && (
  245. <span className="text-zinc-500"> &bull; ~{estimatedRemaining}g filament</span>
  246. )}
  247. </div>
  248. )}
  249. <div className="flex flex-wrap gap-2 justify-center">
  250. {onAddToInventory && (
  251. <button
  252. onClick={onAddToInventory}
  253. 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]"
  254. >
  255. {t('spoolbuddy.modal.addToInventory', 'Add to Inventory')}
  256. </button>
  257. )}
  258. {onLinkSpool && (
  259. <button
  260. onClick={onLinkSpool}
  261. 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]"
  262. >
  263. <svg className="w-4 h-4 inline-block mr-1.5 -mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
  264. <path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
  265. </svg>
  266. {t('inventory.assignSpool', 'Assign Spool')}
  267. </button>
  268. )}
  269. {onClose && (
  270. <button
  271. onClick={onClose}
  272. 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]"
  273. >
  274. {t('spoolbuddy.dashboard.close', 'Close')}
  275. </button>
  276. )}
  277. </div>
  278. </div>
  279. );
  280. }