BulkPrinterToolbar.tsx 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272
  1. import { useState } from 'react';
  2. import { useTranslation } from 'react-i18next';
  3. import { useQueryClient } from '@tanstack/react-query';
  4. import { useAuth } from '../contexts/AuthContext';
  5. import {
  6. X,
  7. Square,
  8. Pause,
  9. Play,
  10. ChevronDown,
  11. BellOff,
  12. Eraser,
  13. } from 'lucide-react';
  14. import { Button } from './Button';
  15. import { filterKnownHMSErrors } from './HMSErrorModal';
  16. import type { Printer, HMSError } from '../api/client';
  17. export type BulkAction = 'stop' | 'pause' | 'resume' | 'clearPlate' | 'clearHMS';
  18. export type PrinterState = 'printing' | 'paused' | 'finished' | 'idle' | 'error' | 'offline';
  19. interface PrinterStatus {
  20. connected: boolean;
  21. state: string | null;
  22. hms_errors?: HMSError[];
  23. awaiting_plate_clear?: boolean;
  24. }
  25. interface BulkPrinterToolbarProps {
  26. selectedIds: Set<number>;
  27. printers: Printer[];
  28. onClose: () => void;
  29. onSelectAll: () => void;
  30. onSelectByLocation: (location: string) => void;
  31. onSelectByState: (state: PrinterState) => void;
  32. onAction: (action: BulkAction) => void;
  33. actionPending: boolean;
  34. }
  35. const STATE_OPTIONS: { key: PrinterState; dot: string }[] = [
  36. { key: 'printing', dot: 'bg-bambu-green' },
  37. { key: 'paused', dot: 'bg-status-warning' },
  38. { key: 'finished', dot: 'bg-blue-400' },
  39. { key: 'idle', dot: 'bg-bambu-green' },
  40. { key: 'error', dot: 'bg-status-error' },
  41. { key: 'offline', dot: 'bg-gray-400' },
  42. ];
  43. export function BulkPrinterToolbar({
  44. selectedIds,
  45. printers,
  46. onClose,
  47. onSelectAll,
  48. onSelectByLocation,
  49. onSelectByState,
  50. onAction,
  51. actionPending,
  52. }: BulkPrinterToolbarProps) {
  53. const { t } = useTranslation();
  54. const { hasPermission } = useAuth();
  55. const queryClient = useQueryClient();
  56. const [showLocationDropdown, setShowLocationDropdown] = useState(false);
  57. const [showStateDropdown, setShowStateDropdown] = useState(false);
  58. // Read cached statuses for selected printers
  59. const selectedStatuses = Array.from(selectedIds).map(id => ({
  60. id,
  61. status: queryClient.getQueryData<PrinterStatus>(['printerStatus', id]),
  62. }));
  63. // Smart enablement: check if any selected printer is in the right state
  64. const anyRunning = selectedStatuses.some(
  65. ({ status }) => status?.connected && status.state === 'RUNNING',
  66. );
  67. const anyPaused = selectedStatuses.some(
  68. ({ status }) => status?.connected && status.state === 'PAUSE',
  69. );
  70. const anyStoppable = anyRunning || anyPaused;
  71. const anyNeedsClearPlate = selectedStatuses.some(
  72. ({ status }) => !!(status?.connected && status.awaiting_plate_clear),
  73. );
  74. const anyWithHMS = selectedStatuses.some(({ status }) => {
  75. if (!status?.connected || !status.hms_errors) return false;
  76. return filterKnownHMSErrors(status.hms_errors).length > 0;
  77. });
  78. const canControl = hasPermission('printers:control');
  79. const canClearPlate = hasPermission('printers:clear_plate');
  80. // Unique locations from all printers (not just selected)
  81. const locations = [...new Set(printers.map(p => p.location).filter((l): l is string => !!l))].sort();
  82. // Count printers per state for the state dropdown
  83. const stateCounts: Record<PrinterState, number> = { printing: 0, paused: 0, finished: 0, idle: 0, error: 0, offline: 0 };
  84. printers.forEach(p => {
  85. const status = queryClient.getQueryData<PrinterStatus>(['printerStatus', p.id]);
  86. if (!status || !status.connected) { stateCounts.offline++; return; }
  87. const hasKnownHms = status.hms_errors ? filterKnownHMSErrors(status.hms_errors).length > 0 : false;
  88. if (hasKnownHms) stateCounts.error++;
  89. switch (status.state) {
  90. case 'RUNNING': stateCounts.printing++; break;
  91. case 'PAUSE': stateCounts.paused++; break;
  92. case 'FINISH': stateCounts.finished++; break;
  93. // FAILED without an active HMS error is the post-cancel terminal state —
  94. // group with FINISH. When HMS is active the error bucket is already
  95. // incremented above; don't double-count.
  96. case 'FAILED': if (!hasKnownHms) stateCounts.finished++; break;
  97. default: stateCounts.idle++; break;
  98. }
  99. });
  100. const stateLabels: Record<PrinterState, string> = {
  101. printing: t('printers.status.printing'),
  102. paused: t('printers.status.paused', 'Paused'),
  103. finished: t('printers.status.finished', 'Finished'),
  104. idle: t('printers.status.idle'),
  105. error: t('printers.status.problem'),
  106. offline: t('printers.status.offline'),
  107. };
  108. return (
  109. <div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-40 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-xl px-4 py-3 flex items-center gap-3 flex-wrap">
  110. {/* Close */}
  111. <Button variant="secondary" size="sm" onClick={onClose}>
  112. <X className="w-4 h-4" />
  113. </Button>
  114. <div className="w-px h-6 bg-bambu-dark-tertiary" />
  115. {/* Selection count */}
  116. <span className="text-white font-medium text-sm">
  117. {t('printers.bulk.selected', { count: selectedIds.size })}
  118. </span>
  119. <div className="w-px h-6 bg-bambu-dark-tertiary" />
  120. {/* Select All */}
  121. <Button variant="secondary" size="sm" onClick={onSelectAll}>
  122. {t('printers.bulk.selectAll')}
  123. </Button>
  124. {/* Select by State */}
  125. <div className="relative">
  126. <Button
  127. variant="secondary"
  128. size="sm"
  129. onClick={() => { setShowStateDropdown(!showStateDropdown); setShowLocationDropdown(false); }}
  130. >
  131. {t('printers.bulk.selectByState')}
  132. <ChevronDown className={`w-3 h-3 transition-transform ${showStateDropdown ? 'rotate-180' : ''}`} />
  133. </Button>
  134. {showStateDropdown && (
  135. <>
  136. <div
  137. className="fixed inset-0 z-10"
  138. onClick={() => setShowStateDropdown(false)}
  139. />
  140. <div className="absolute bottom-full mb-2 left-0 w-48 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-lg z-20 py-1">
  141. {STATE_OPTIONS.filter(({ key }) => stateCounts[key] > 0).map(({ key, dot }) => (
  142. <button
  143. key={key}
  144. onClick={() => {
  145. onSelectByState(key);
  146. setShowStateDropdown(false);
  147. }}
  148. className="w-full text-left px-3 py-2 text-sm text-bambu-gray-light hover:bg-bambu-dark-tertiary hover:text-white transition-colors flex items-center gap-2"
  149. >
  150. <div className={`w-2 h-2 rounded-full ${dot}`} />
  151. {stateLabels[key]}
  152. <span className="ml-auto text-bambu-gray text-xs">{stateCounts[key]}</span>
  153. </button>
  154. ))}
  155. </div>
  156. </>
  157. )}
  158. </div>
  159. {/* Select by Location */}
  160. {locations.length > 0 && (
  161. <div className="relative">
  162. <Button
  163. variant="secondary"
  164. size="sm"
  165. onClick={() => { setShowLocationDropdown(!showLocationDropdown); setShowStateDropdown(false); }}
  166. >
  167. {t('printers.bulk.selectByLocation')}
  168. <ChevronDown className={`w-3 h-3 transition-transform ${showLocationDropdown ? 'rotate-180' : ''}`} />
  169. </Button>
  170. {showLocationDropdown && (
  171. <>
  172. <div
  173. className="fixed inset-0 z-10"
  174. onClick={() => setShowLocationDropdown(false)}
  175. />
  176. <div className="absolute bottom-full mb-2 left-0 w-48 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-lg z-20 py-1">
  177. {locations.map(location => (
  178. <button
  179. key={location}
  180. onClick={() => {
  181. onSelectByLocation(location);
  182. setShowLocationDropdown(false);
  183. }}
  184. className="w-full text-left px-3 py-2 text-sm text-bambu-gray-light hover:bg-bambu-dark-tertiary hover:text-white transition-colors"
  185. >
  186. {location}
  187. </button>
  188. ))}
  189. </div>
  190. </>
  191. )}
  192. </div>
  193. )}
  194. <div className="w-px h-6 bg-bambu-dark-tertiary" />
  195. {/* Action buttons */}
  196. <Button
  197. size="sm"
  198. className="bg-red-500 hover:bg-red-600"
  199. onClick={() => onAction('stop')}
  200. disabled={actionPending || !canControl || !anyStoppable}
  201. title={!canControl ? t('printers.permission.noControl') : !anyStoppable ? t('printers.bulk.noneApplicable') : undefined}
  202. >
  203. <Square className="w-3.5 h-3.5" />
  204. {t('printers.bulk.actions.stop')}
  205. </Button>
  206. <Button
  207. variant="secondary"
  208. size="sm"
  209. onClick={() => onAction('pause')}
  210. disabled={actionPending || !canControl || !anyRunning}
  211. title={!canControl ? t('printers.permission.noControl') : !anyRunning ? t('printers.bulk.noneApplicable') : undefined}
  212. >
  213. <Pause className="w-3.5 h-3.5" />
  214. {t('printers.bulk.actions.pause')}
  215. </Button>
  216. <Button
  217. variant="secondary"
  218. size="sm"
  219. onClick={() => onAction('resume')}
  220. disabled={actionPending || !canControl || !anyPaused}
  221. title={!canControl ? t('printers.permission.noControl') : !anyPaused ? t('printers.bulk.noneApplicable') : undefined}
  222. >
  223. <Play className="w-3.5 h-3.5" />
  224. {t('printers.bulk.actions.resume')}
  225. </Button>
  226. <Button
  227. variant="secondary"
  228. size="sm"
  229. onClick={() => onAction('clearHMS')}
  230. disabled={actionPending || !canControl || !anyWithHMS}
  231. title={!canControl ? t('printers.permission.noControl') : !anyWithHMS ? t('printers.bulk.noneApplicable') : undefined}
  232. >
  233. <BellOff className="w-3.5 h-3.5" />
  234. {t('printers.bulk.actions.clearHMS')}
  235. </Button>
  236. <Button
  237. variant="secondary"
  238. size="sm"
  239. onClick={() => onAction('clearPlate')}
  240. disabled={actionPending || !canClearPlate || !anyNeedsClearPlate}
  241. title={!canClearPlate ? t('printers.permission.noControl') : !anyNeedsClearPlate ? t('printers.bulk.noneApplicable') : undefined}
  242. >
  243. <Eraser className="w-3.5 h-3.5" />
  244. {t('printers.bulk.actions.clearPlate')}
  245. </Button>
  246. </div>
  247. );
  248. }