PAProfileSection.tsx 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273
  1. import { ChevronDown, ChevronRight, Sparkles } from 'lucide-react';
  2. import { useTranslation } from 'react-i18next';
  3. import type { CalibrationProfile, PAProfileSectionProps } from './types';
  4. import { isMatchingCalibration } from './utils';
  5. export function PAProfileSection({
  6. formData,
  7. printersWithCalibrations,
  8. selectedProfiles,
  9. setSelectedProfiles,
  10. expandedPrinters,
  11. setExpandedPrinters,
  12. }: PAProfileSectionProps) {
  13. const { t } = useTranslation();
  14. const togglePrinterExpanded = (printerId: string) => {
  15. setExpandedPrinters((prev) => {
  16. const next = new Set(prev);
  17. if (next.has(printerId)) next.delete(printerId);
  18. else next.add(printerId);
  19. return next;
  20. });
  21. };
  22. const toggleProfileSelected = (printerId: string, caliIdx: number, extruderId?: number | null) => {
  23. const key = `${printerId}:${caliIdx}:${extruderId ?? 'null'}`;
  24. const printerNozzleKey = `${printerId}:${extruderId ?? 'null'}`;
  25. setSelectedProfiles((prev) => {
  26. const next = new Set(prev);
  27. if (next.has(key)) {
  28. next.delete(key);
  29. } else {
  30. // Remove existing profile for same printer/nozzle
  31. for (const existingKey of Array.from(next)) {
  32. const parts = existingKey.split(':');
  33. const existingPrinterNozzle = `${parts[0]}:${parts[2]}`;
  34. if (existingPrinterNozzle === printerNozzleKey) {
  35. next.delete(existingKey);
  36. }
  37. }
  38. next.add(key);
  39. }
  40. return next;
  41. });
  42. };
  43. // Auto-select best matching profiles
  44. const autoSelectProfiles = () => {
  45. const newSelection = new Set<string>();
  46. for (const { printer, calibrations } of printersWithCalibrations) {
  47. if (!printer.connected) continue;
  48. const matchingCals = calibrations.filter(cal =>
  49. isMatchingCalibration(cal, formData),
  50. );
  51. // Group by extruder
  52. const byExtruder = new Map<string, CalibrationProfile[]>();
  53. for (const cal of matchingCals) {
  54. const extKey = `${cal.extruder_id ?? 'null'}`;
  55. if (!byExtruder.has(extKey)) byExtruder.set(extKey, []);
  56. byExtruder.get(extKey)!.push(cal);
  57. }
  58. // Select best (highest K) for each extruder
  59. for (const [extKey, cals] of byExtruder) {
  60. if (cals.length > 0) {
  61. const sorted = [...cals].sort((a, b) => b.k_value - a.k_value);
  62. const best = sorted[0];
  63. newSelection.add(`${printer.id}:${best.cali_idx}:${extKey}`);
  64. }
  65. }
  66. }
  67. setSelectedProfiles(newSelection);
  68. };
  69. if (!formData.material) {
  70. return (
  71. <div className="p-6 bg-bambu-dark rounded-lg text-center">
  72. <p className="text-bambu-gray">
  73. {t('inventory.selectMaterialFirst')}
  74. </p>
  75. </div>
  76. );
  77. }
  78. if (printersWithCalibrations.length === 0) {
  79. return (
  80. <div className="p-6 bg-bambu-dark rounded-lg text-center">
  81. <p className="text-bambu-gray">
  82. {t('inventory.noPrintersConfigured')}
  83. </p>
  84. </div>
  85. );
  86. }
  87. // Count total matching profiles
  88. const totalMatching = printersWithCalibrations.reduce((sum, { printer, calibrations }) => {
  89. if (!printer.connected) return sum;
  90. return sum + calibrations.filter(cal => isMatchingCalibration(cal, formData)).length;
  91. }, 0);
  92. const renderProfile = (printer: { id: number }, cal: CalibrationProfile) => {
  93. const key = `${printer.id}:${cal.cali_idx}:${cal.extruder_id ?? 'null'}`;
  94. const isSelected = selectedProfiles.has(key);
  95. return (
  96. <label
  97. key={`${cal.cali_idx}-${cal.extruder_id}`}
  98. className={`flex items-center gap-3 p-3 rounded-lg cursor-pointer transition-all border ${
  99. isSelected
  100. ? 'bg-bambu-green/10 border-bambu-green/30'
  101. : 'bg-bambu-dark border-transparent hover:bg-bambu-dark/80'
  102. }`}
  103. >
  104. <input
  105. type="checkbox"
  106. checked={isSelected}
  107. onChange={() => toggleProfileSelected(String(printer.id), cal.cali_idx, cal.extruder_id)}
  108. className="w-4 h-4 rounded border-bambu-dark-tertiary text-bambu-green focus:ring-bambu-green"
  109. />
  110. <div className="flex-1 min-w-0">
  111. <span className={`text-sm font-medium ${isSelected ? 'text-bambu-green' : 'text-white'}`}>
  112. {cal.name || cal.filament_id}
  113. </span>
  114. </div>
  115. <div className="flex items-center gap-2 shrink-0">
  116. {cal.nozzle_diameter && (
  117. <span className="text-xs font-mono px-2 py-0.5 rounded bg-bambu-dark text-bambu-gray">
  118. {cal.nozzle_diameter}mm
  119. </span>
  120. )}
  121. <span className="text-xs font-mono px-2 py-0.5 rounded bg-bambu-dark text-bambu-gray">
  122. K={cal.k_value.toFixed(3)}
  123. </span>
  124. </div>
  125. </label>
  126. );
  127. };
  128. return (
  129. <div className="space-y-4">
  130. {/* Header with auto-select */}
  131. <div className="flex items-center justify-between">
  132. <p className="text-xs text-bambu-gray">
  133. {t('inventory.matchingFilter')}: {formData.brand || t('inventory.anyBrand')} / {formData.material} / {formData.subtype || t('inventory.anyVariant')}
  134. </p>
  135. {totalMatching > 0 && (
  136. <button
  137. type="button"
  138. onClick={autoSelectProfiles}
  139. className="flex items-center gap-1.5 px-2 py-1 text-xs bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-bambu-gray hover:text-white hover:border-bambu-green transition-colors"
  140. >
  141. <Sparkles className="w-3.5 h-3.5" />
  142. {t('inventory.autoSelect')} ({totalMatching})
  143. </button>
  144. )}
  145. </div>
  146. {/* Printer sections */}
  147. <div className="space-y-3">
  148. {printersWithCalibrations.map(({ printer, calibrations }) => {
  149. const isExpanded = expandedPrinters.has(String(printer.id));
  150. const matchingCals = calibrations.filter(cal => isMatchingCalibration(cal, formData));
  151. const matchingCount = matchingCals.length;
  152. // Multi-nozzle grouping
  153. const isMultiNozzle = matchingCals.some(cal =>
  154. cal.extruder_id !== undefined && cal.extruder_id !== null && cal.extruder_id > 0,
  155. );
  156. const leftNozzleCals = matchingCals.filter(cal => cal.extruder_id === 1);
  157. const rightNozzleCals = matchingCals.filter(cal =>
  158. cal.extruder_id === 0 || cal.extruder_id === undefined || cal.extruder_id === null,
  159. );
  160. return (
  161. <div
  162. key={printer.id}
  163. className="border border-bambu-dark-tertiary rounded-lg overflow-hidden"
  164. >
  165. {/* Printer Header */}
  166. <button
  167. type="button"
  168. onClick={() => togglePrinterExpanded(String(printer.id))}
  169. className="w-full px-4 py-3 flex items-center justify-between bg-bambu-dark-secondary hover:bg-bambu-dark-tertiary transition-colors"
  170. >
  171. <div className="flex items-center gap-3">
  172. {isExpanded ? (
  173. <ChevronDown className="w-4 h-4 text-bambu-gray" />
  174. ) : (
  175. <ChevronRight className="w-4 h-4 text-bambu-gray" />
  176. )}
  177. <span className="font-medium text-white">
  178. {printer.name}
  179. </span>
  180. {matchingCount > 0 ? (
  181. <span className="text-xs px-2 py-0.5 rounded-full bg-bambu-green/20 text-bambu-green">
  182. {matchingCount} {matchingCount !== 1 ? t('inventory.matches') : t('inventory.match')}
  183. </span>
  184. ) : (
  185. <span className="text-xs px-2 py-0.5 rounded-full bg-bambu-dark-tertiary text-bambu-gray">
  186. {t('inventory.noMatches')}
  187. </span>
  188. )}
  189. </div>
  190. <span className={`text-xs px-2 py-1 rounded-full ${
  191. printer.connected
  192. ? 'bg-green-500/20 text-green-500'
  193. : 'bg-bambu-gray/20 text-bambu-gray'
  194. }`}>
  195. {printer.connected ? t('inventory.connected') : t('inventory.offline')}
  196. </span>
  197. </button>
  198. {/* Calibration Profiles */}
  199. {isExpanded && (
  200. <div className="px-4 py-3 space-y-3 bg-bambu-dark border-t border-bambu-dark-tertiary">
  201. {!printer.connected ? (
  202. <p className="text-sm text-bambu-gray italic py-2">
  203. {t('inventory.printerOffline')}
  204. </p>
  205. ) : matchingCount === 0 ? (
  206. <p className="text-sm text-bambu-gray italic py-2">
  207. {t('inventory.noKProfilesMatch')}
  208. </p>
  209. ) : isMultiNozzle ? (
  210. <>
  211. {leftNozzleCals.length > 0 && (
  212. <div className="space-y-2">
  213. <p className="text-xs font-medium text-bambu-gray uppercase tracking-wide">
  214. {t('inventory.leftNozzle')}
  215. </p>
  216. <div className="space-y-2">
  217. {leftNozzleCals.map(cal => renderProfile(printer, cal))}
  218. </div>
  219. </div>
  220. )}
  221. {rightNozzleCals.length > 0 && (
  222. <div className="space-y-2">
  223. <p className="text-xs font-medium text-bambu-gray uppercase tracking-wide">
  224. {t('inventory.rightNozzle')}
  225. </p>
  226. <div className="space-y-2">
  227. {rightNozzleCals.map(cal => renderProfile(printer, cal))}
  228. </div>
  229. </div>
  230. )}
  231. </>
  232. ) : (
  233. <div className="space-y-2">
  234. {matchingCals.map(cal => renderProfile(printer, cal))}
  235. </div>
  236. )}
  237. </div>
  238. )}
  239. </div>
  240. );
  241. })}
  242. </div>
  243. {/* Summary */}
  244. {selectedProfiles.size > 0 && (
  245. <div className="p-3 bg-bambu-green/10 border border-bambu-green/30 rounded-lg">
  246. <p className="text-sm text-white">
  247. <span className="font-semibold">{selectedProfiles.size}</span> {t('inventory.profilesSelected')}
  248. </p>
  249. </div>
  250. )}
  251. </div>
  252. );
  253. }