PAProfileSection.tsx 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268
  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. <span className="text-xs font-mono px-2 py-0.5 rounded bg-bambu-dark text-bambu-gray">
  117. K={cal.k_value.toFixed(3)}
  118. </span>
  119. </div>
  120. </label>
  121. );
  122. };
  123. return (
  124. <div className="space-y-4">
  125. {/* Header with auto-select */}
  126. <div className="flex items-center justify-between">
  127. <p className="text-xs text-bambu-gray">
  128. {t('inventory.matchingFilter')}: {formData.brand || t('inventory.anyBrand')} / {formData.material} / {formData.subtype || t('inventory.anyVariant')}
  129. </p>
  130. {totalMatching > 0 && (
  131. <button
  132. type="button"
  133. onClick={autoSelectProfiles}
  134. 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"
  135. >
  136. <Sparkles className="w-3.5 h-3.5" />
  137. {t('inventory.autoSelect')} ({totalMatching})
  138. </button>
  139. )}
  140. </div>
  141. {/* Printer sections */}
  142. <div className="space-y-3">
  143. {printersWithCalibrations.map(({ printer, calibrations }) => {
  144. const isExpanded = expandedPrinters.has(String(printer.id));
  145. const matchingCals = calibrations.filter(cal => isMatchingCalibration(cal, formData));
  146. const matchingCount = matchingCals.length;
  147. // Multi-nozzle grouping
  148. const isMultiNozzle = matchingCals.some(cal =>
  149. cal.extruder_id !== undefined && cal.extruder_id !== null && cal.extruder_id > 0,
  150. );
  151. const leftNozzleCals = matchingCals.filter(cal => cal.extruder_id === 1);
  152. const rightNozzleCals = matchingCals.filter(cal =>
  153. cal.extruder_id === 0 || cal.extruder_id === undefined || cal.extruder_id === null,
  154. );
  155. return (
  156. <div
  157. key={printer.id}
  158. className="border border-bambu-dark-tertiary rounded-lg overflow-hidden"
  159. >
  160. {/* Printer Header */}
  161. <button
  162. type="button"
  163. onClick={() => togglePrinterExpanded(String(printer.id))}
  164. className="w-full px-4 py-3 flex items-center justify-between bg-bambu-dark-secondary hover:bg-bambu-dark-tertiary transition-colors"
  165. >
  166. <div className="flex items-center gap-3">
  167. {isExpanded ? (
  168. <ChevronDown className="w-4 h-4 text-bambu-gray" />
  169. ) : (
  170. <ChevronRight className="w-4 h-4 text-bambu-gray" />
  171. )}
  172. <span className="font-medium text-white">
  173. {printer.name}
  174. </span>
  175. {matchingCount > 0 ? (
  176. <span className="text-xs px-2 py-0.5 rounded-full bg-bambu-green/20 text-bambu-green">
  177. {matchingCount} {matchingCount !== 1 ? t('inventory.matches') : t('inventory.match')}
  178. </span>
  179. ) : (
  180. <span className="text-xs px-2 py-0.5 rounded-full bg-bambu-dark-tertiary text-bambu-gray">
  181. {t('inventory.noMatches')}
  182. </span>
  183. )}
  184. </div>
  185. <span className={`text-xs px-2 py-1 rounded-full ${
  186. printer.connected
  187. ? 'bg-green-500/20 text-green-500'
  188. : 'bg-bambu-gray/20 text-bambu-gray'
  189. }`}>
  190. {printer.connected ? t('inventory.connected') : t('inventory.offline')}
  191. </span>
  192. </button>
  193. {/* Calibration Profiles */}
  194. {isExpanded && (
  195. <div className="px-4 py-3 space-y-3 bg-bambu-dark border-t border-bambu-dark-tertiary">
  196. {!printer.connected ? (
  197. <p className="text-sm text-bambu-gray italic py-2">
  198. {t('inventory.printerOffline')}
  199. </p>
  200. ) : matchingCount === 0 ? (
  201. <p className="text-sm text-bambu-gray italic py-2">
  202. {t('inventory.noKProfilesMatch')}
  203. </p>
  204. ) : isMultiNozzle ? (
  205. <>
  206. {leftNozzleCals.length > 0 && (
  207. <div className="space-y-2">
  208. <p className="text-xs font-medium text-bambu-gray uppercase tracking-wide">
  209. {t('inventory.leftNozzle')}
  210. </p>
  211. <div className="space-y-2">
  212. {leftNozzleCals.map(cal => renderProfile(printer, cal))}
  213. </div>
  214. </div>
  215. )}
  216. {rightNozzleCals.length > 0 && (
  217. <div className="space-y-2">
  218. <p className="text-xs font-medium text-bambu-gray uppercase tracking-wide">
  219. {t('inventory.rightNozzle')}
  220. </p>
  221. <div className="space-y-2">
  222. {rightNozzleCals.map(cal => renderProfile(printer, cal))}
  223. </div>
  224. </div>
  225. )}
  226. </>
  227. ) : (
  228. <div className="space-y-2">
  229. {matchingCals.map(cal => renderProfile(printer, cal))}
  230. </div>
  231. )}
  232. </div>
  233. )}
  234. </div>
  235. );
  236. })}
  237. </div>
  238. {/* Summary */}
  239. {selectedProfiles.size > 0 && (
  240. <div className="p-3 bg-bambu-green/10 border border-bambu-green/30 rounded-lg">
  241. <p className="text-sm text-white">
  242. <span className="font-semibold">{selectedProfiles.size}</span> {t('inventory.profilesSelected')}
  243. </p>
  244. </div>
  245. )}
  246. </div>
  247. );
  248. }