MaintenancePage.tsx 47 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165
  1. import { useState, useMemo } from 'react';
  2. import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
  3. import {
  4. Wrench,
  5. Loader2,
  6. Check,
  7. AlertTriangle,
  8. Clock,
  9. Plus,
  10. Trash2,
  11. ChevronDown,
  12. ChevronUp,
  13. Droplet,
  14. Flame,
  15. Ruler,
  16. Sparkles,
  17. Square,
  18. Cable,
  19. Edit3,
  20. RotateCcw,
  21. Calendar,
  22. Timer,
  23. Cog,
  24. Fan,
  25. Zap,
  26. Wind,
  27. Thermometer,
  28. Layers,
  29. Box,
  30. Target,
  31. RefreshCw,
  32. Settings,
  33. Filter,
  34. CircleDot,
  35. Printer,
  36. } from 'lucide-react';
  37. import { api } from '../api/client';
  38. import type { MaintenanceStatus, PrinterMaintenanceOverview, MaintenanceType } from '../api/client';
  39. import { Card, CardContent } from '../components/Card';
  40. import { Button } from '../components/Button';
  41. import { Toggle } from '../components/Toggle';
  42. import { useToast } from '../contexts/ToastContext';
  43. // Icon mapping for maintenance types
  44. const iconMap: Record<string, React.ComponentType<{ className?: string }>> = {
  45. Droplet,
  46. Flame,
  47. Ruler,
  48. Sparkles,
  49. Square,
  50. Cable,
  51. Wrench,
  52. Calendar,
  53. Timer,
  54. Cog,
  55. Fan,
  56. Zap,
  57. Wind,
  58. Thermometer,
  59. Layers,
  60. Box,
  61. Target,
  62. RefreshCw,
  63. Settings,
  64. Filter,
  65. CircleDot,
  66. };
  67. function getIcon(iconName: string | null) {
  68. if (!iconName) return Wrench;
  69. return iconMap[iconName] || Wrench;
  70. }
  71. function formatDuration(value: number, type: 'hours' | 'days'): string {
  72. if (type === 'days') {
  73. if (value < 1) return 'Today';
  74. if (value === 1) return '1 day';
  75. if (value < 7) return `${Math.round(value)} days`;
  76. if (value < 30) return `${Math.round(value / 7)} weeks`;
  77. return `${Math.round(value / 30)} months`;
  78. } else {
  79. if (value < 1) return `${Math.round(value * 60)}m`;
  80. if (value < 10) return `${value.toFixed(1)}h`;
  81. return `${Math.round(value)}h`;
  82. }
  83. }
  84. function formatIntervalLabel(value: number, type: 'hours' | 'days'): string {
  85. if (type === 'days') {
  86. if (value === 1) return '1 day';
  87. if (value === 7) return '1 week';
  88. if (value === 14) return '2 weeks';
  89. if (value === 30) return '1 month';
  90. if (value === 60) return '2 months';
  91. if (value === 90) return '3 months';
  92. if (value === 180) return '6 months';
  93. if (value === 365) return '1 year';
  94. return `${value} days`;
  95. }
  96. return `${value}h`;
  97. }
  98. // Maintenance item card - cleaner, more visual design
  99. function MaintenanceCard({
  100. item,
  101. onPerform,
  102. onToggle,
  103. }: {
  104. item: MaintenanceStatus;
  105. onPerform: (id: number) => void;
  106. onToggle: (id: number, enabled: boolean) => void;
  107. }) {
  108. const Icon = getIcon(item.maintenance_type_icon);
  109. const intervalType = item.interval_type || 'hours';
  110. // Calculate progress based on interval type
  111. const getProgress = () => {
  112. if (intervalType === 'days') {
  113. const daysSince = item.days_since_maintenance ?? 0;
  114. return Math.max(0, Math.min(100, (daysSince / item.interval_hours) * 100));
  115. }
  116. return Math.max(0, Math.min(100,
  117. ((item.interval_hours - item.hours_until_due) / item.interval_hours) * 100
  118. ));
  119. };
  120. const progressPercent = getProgress();
  121. const getStatusColor = () => {
  122. if (!item.enabled) return 'text-bambu-gray';
  123. if (item.is_due) return 'text-red-400';
  124. if (item.is_warning) return 'text-amber-400';
  125. return 'text-bambu-green';
  126. };
  127. const getProgressColor = () => {
  128. if (!item.enabled) return 'bg-bambu-gray/30';
  129. if (item.is_due) return 'bg-red-500';
  130. if (item.is_warning) return 'bg-amber-500';
  131. return 'bg-bambu-green';
  132. };
  133. const getBgColor = () => {
  134. if (!item.enabled) return 'bg-bambu-dark-secondary/50';
  135. if (item.is_due) return 'bg-red-500/5 border-red-500/20';
  136. if (item.is_warning) return 'bg-amber-500/5 border-amber-500/20';
  137. return 'bg-bambu-dark-secondary border-bambu-dark-tertiary';
  138. };
  139. const getStatusText = () => {
  140. if (!item.enabled) return 'Disabled';
  141. if (intervalType === 'days') {
  142. const daysUntil = item.days_until_due ?? 0;
  143. if (item.is_due) return `Overdue by ${formatDuration(Math.abs(daysUntil), 'days')}`;
  144. if (item.is_warning) return `Due in ${formatDuration(daysUntil, 'days')}`;
  145. return `${formatDuration(daysUntil, 'days')} left`;
  146. } else {
  147. if (item.is_due) return `Overdue by ${formatDuration(Math.abs(item.hours_until_due), 'hours')}`;
  148. if (item.is_warning) return `Due in ${formatDuration(item.hours_until_due, 'hours')}`;
  149. return `${formatDuration(item.hours_until_due, 'hours')} left`;
  150. }
  151. };
  152. return (
  153. <div className={`rounded-xl border p-4 transition-all ${getBgColor()}`}>
  154. <div className="flex items-start gap-3">
  155. {/* Icon with status indicator */}
  156. <div className={`relative p-2.5 rounded-lg ${
  157. item.is_due ? 'bg-red-500/20' :
  158. item.is_warning ? 'bg-amber-500/20' :
  159. item.enabled ? 'bg-bambu-dark' : 'bg-bambu-dark/50'
  160. }`}>
  161. <Icon className={`w-5 h-5 ${getStatusColor()}`} />
  162. {item.enabled && (item.is_due || item.is_warning) && (
  163. <span className={`absolute -top-1 -right-1 w-2.5 h-2.5 rounded-full ${
  164. item.is_due ? 'bg-red-500' : 'bg-amber-500'
  165. } animate-pulse`} />
  166. )}
  167. </div>
  168. {/* Content */}
  169. <div className="flex-1 min-w-0">
  170. <div className="flex items-center gap-2">
  171. <h3 className={`font-medium truncate ${item.enabled ? 'text-white' : 'text-bambu-gray'}`}>
  172. {item.maintenance_type_name}
  173. </h3>
  174. {intervalType === 'days' && (
  175. <span title="Time-based interval">
  176. <Calendar className="w-3.5 h-3.5 text-bambu-gray shrink-0" />
  177. </span>
  178. )}
  179. </div>
  180. {/* Progress bar */}
  181. <div className="mt-2 mb-1.5">
  182. <div className="w-full h-1.5 bg-bambu-dark rounded-full overflow-hidden">
  183. <div
  184. className={`h-full rounded-full transition-all duration-500 ${getProgressColor()}`}
  185. style={{ width: `${progressPercent}%` }}
  186. />
  187. </div>
  188. </div>
  189. {/* Status text */}
  190. <div className={`text-xs flex items-center gap-1 ${getStatusColor()}`}>
  191. {item.is_due && <AlertTriangle className="w-3 h-3" />}
  192. {item.is_warning && !item.is_due && <Clock className="w-3 h-3" />}
  193. {!item.is_due && !item.is_warning && item.enabled && <Check className="w-3 h-3" />}
  194. {getStatusText()}
  195. </div>
  196. </div>
  197. {/* Actions */}
  198. <div className="flex items-center gap-2 shrink-0">
  199. <Toggle
  200. checked={item.enabled}
  201. onChange={(checked) => onToggle(item.id, checked)}
  202. />
  203. <Button
  204. size="sm"
  205. variant={item.is_due ? 'primary' : 'secondary'}
  206. onClick={() => onPerform(item.id)}
  207. disabled={!item.enabled}
  208. className="!px-3"
  209. >
  210. <RotateCcw className="w-3.5 h-3.5" />
  211. Reset
  212. </Button>
  213. </div>
  214. </div>
  215. </div>
  216. );
  217. }
  218. // Printer section with improved visual hierarchy
  219. function PrinterSection({
  220. overview,
  221. onPerform,
  222. onToggle,
  223. onSetHours,
  224. }: {
  225. overview: PrinterMaintenanceOverview;
  226. onPerform: (id: number) => void;
  227. onToggle: (id: number, enabled: boolean) => void;
  228. onSetHours: (printerId: number, hours: number) => void;
  229. }) {
  230. const [expanded, setExpanded] = useState(true);
  231. const [editingHours, setEditingHours] = useState(false);
  232. const [hoursInput, setHoursInput] = useState(overview.total_print_hours.toFixed(1));
  233. const sortedItems = [...overview.maintenance_items].sort((a, b) => {
  234. // Sort by urgency first, then by type
  235. if (a.is_due && !b.is_due) return -1;
  236. if (!a.is_due && b.is_due) return 1;
  237. if (a.is_warning && !b.is_warning) return -1;
  238. if (!a.is_warning && b.is_warning) return 1;
  239. return a.maintenance_type_id - b.maintenance_type_id;
  240. });
  241. const nextTask = sortedItems.find(item => item.enabled && (item.is_due || item.is_warning));
  242. const handleSaveHours = () => {
  243. const hours = parseFloat(hoursInput);
  244. if (!isNaN(hours) && hours >= 0) {
  245. onSetHours(overview.printer_id, hours);
  246. setEditingHours(false);
  247. }
  248. };
  249. return (
  250. <Card className="overflow-hidden">
  251. {/* Header */}
  252. <div className="p-5">
  253. <div className="flex items-center justify-between">
  254. <div className="flex items-center gap-4">
  255. <h2 className="text-xl font-semibold text-white">{overview.printer_name}</h2>
  256. <div className="flex items-center gap-2">
  257. {overview.due_count > 0 && (
  258. <span className="px-2.5 py-1 bg-red-500/20 text-red-400 text-xs font-medium rounded-full flex items-center gap-1.5">
  259. <AlertTriangle className="w-3 h-3" />
  260. {overview.due_count} overdue
  261. </span>
  262. )}
  263. {overview.warning_count > 0 && (
  264. <span className="px-2.5 py-1 bg-amber-500/20 text-amber-400 text-xs font-medium rounded-full flex items-center gap-1.5">
  265. <Clock className="w-3 h-3" />
  266. {overview.warning_count} due soon
  267. </span>
  268. )}
  269. {overview.due_count === 0 && overview.warning_count === 0 && (
  270. <span className="px-2.5 py-1 bg-bambu-green/20 text-bambu-green text-xs font-medium rounded-full flex items-center gap-1.5">
  271. <Check className="w-3 h-3" />
  272. All good
  273. </span>
  274. )}
  275. </div>
  276. </div>
  277. <button
  278. onClick={() => setExpanded(!expanded)}
  279. className="flex items-center gap-1.5 px-3 py-1.5 text-sm text-bambu-gray hover:text-white hover:bg-bambu-dark rounded-lg transition-colors"
  280. >
  281. {expanded ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
  282. {expanded ? 'Collapse' : 'Expand'}
  283. </button>
  284. </div>
  285. {/* Quick stats row */}
  286. <div className="flex items-center gap-6 mt-4">
  287. {/* Print Hours */}
  288. <div className="flex items-center gap-3">
  289. <div className="p-2 bg-bambu-dark/50 rounded-lg">
  290. <Timer className="w-4 h-4 text-bambu-gray" />
  291. </div>
  292. {editingHours ? (
  293. <div className="flex items-center gap-2">
  294. <input
  295. type="number"
  296. value={hoursInput}
  297. onChange={(e) => setHoursInput(e.target.value)}
  298. onKeyDown={(e) => {
  299. if (e.key === 'Enter') handleSaveHours();
  300. if (e.key === 'Escape') setEditingHours(false);
  301. }}
  302. className="w-24 px-2 py-1 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white text-sm"
  303. min="0"
  304. step="1"
  305. autoFocus
  306. />
  307. <span className="text-xs text-bambu-gray">hours</span>
  308. <Button size="sm" onClick={handleSaveHours}>Save</Button>
  309. <Button size="sm" variant="secondary" onClick={() => setEditingHours(false)}>Cancel</Button>
  310. </div>
  311. ) : (
  312. <button
  313. onClick={() => {
  314. setHoursInput(Math.round(overview.total_print_hours).toString());
  315. setEditingHours(true);
  316. }}
  317. className="group"
  318. >
  319. <div className="text-sm font-medium text-white group-hover:text-bambu-green transition-colors flex items-center gap-1">
  320. {Math.round(overview.total_print_hours)} hours
  321. <Edit3 className="w-3 h-3 text-bambu-gray group-hover:text-bambu-green" />
  322. </div>
  323. <div className="text-xs text-bambu-gray">Total print time</div>
  324. </button>
  325. )}
  326. </div>
  327. {/* Divider */}
  328. <div className="w-px h-10 bg-bambu-dark-tertiary" />
  329. {/* Next Maintenance */}
  330. {nextTask && (
  331. <div className="flex items-center gap-3">
  332. <div className={`p-2 rounded-lg ${
  333. nextTask.is_due ? 'bg-red-500/20' : 'bg-amber-500/20'
  334. }`}>
  335. {(() => {
  336. const Icon = getIcon(nextTask.maintenance_type_icon);
  337. return <Icon className={`w-4 h-4 ${nextTask.is_due ? 'text-red-400' : 'text-amber-400'}`} />;
  338. })()}
  339. </div>
  340. <div>
  341. <div className={`text-sm font-medium ${nextTask.is_due ? 'text-red-400' : 'text-amber-400'}`}>
  342. {nextTask.maintenance_type_name}
  343. </div>
  344. <div className={`text-xs ${nextTask.is_due ? 'text-red-400/70' : 'text-amber-400/70'}`}>
  345. {nextTask.is_due ? 'Overdue' : 'Due soon'}
  346. </div>
  347. </div>
  348. </div>
  349. )}
  350. </div>
  351. </div>
  352. {/* Maintenance items */}
  353. {expanded && (
  354. <CardContent className="pt-0 border-t border-bambu-dark-tertiary">
  355. <div className="grid grid-cols-1 lg:grid-cols-2 gap-3 pt-4">
  356. {sortedItems.map((item) => (
  357. <MaintenanceCard
  358. key={item.id}
  359. item={item}
  360. onPerform={onPerform}
  361. onToggle={onToggle}
  362. />
  363. ))}
  364. </div>
  365. </CardContent>
  366. )}
  367. </Card>
  368. );
  369. }
  370. // Settings section - maintenance types configuration
  371. function SettingsSection({
  372. overview,
  373. types,
  374. onUpdateInterval,
  375. onAddType,
  376. onUpdateType,
  377. onDeleteType,
  378. onAssignType,
  379. onRemoveItem,
  380. }: {
  381. overview: PrinterMaintenanceOverview[] | undefined;
  382. types: MaintenanceType[];
  383. onUpdateInterval: (id: number, data: { custom_interval_hours?: number | null; custom_interval_type?: 'hours' | 'days' | null }) => void;
  384. onAddType: (data: { name: string; description?: string; default_interval_hours: number; interval_type: 'hours' | 'days'; icon?: string }, printerIds: number[]) => void;
  385. onUpdateType: (id: number, data: { name?: string; default_interval_hours?: number; interval_type?: 'hours' | 'days'; icon?: string }) => void;
  386. onDeleteType: (id: number) => void;
  387. onAssignType: (printerId: number, typeId: number) => void;
  388. onRemoveItem: (itemId: number) => void;
  389. }) {
  390. const [editingInterval, setEditingInterval] = useState<number | null>(null);
  391. const [intervalInput, setIntervalInput] = useState('');
  392. const [intervalTypeInput, setIntervalTypeInput] = useState<'hours' | 'days'>('hours');
  393. const [showAddType, setShowAddType] = useState(false);
  394. const [newTypeName, setNewTypeName] = useState('');
  395. const [newTypeInterval, setNewTypeInterval] = useState('100');
  396. const [newTypeIntervalType, setNewTypeIntervalType] = useState<'hours' | 'days'>('hours');
  397. const [newTypeIcon, setNewTypeIcon] = useState('Wrench');
  398. const [selectedPrinters, setSelectedPrinters] = useState<Set<number>>(new Set());
  399. const [expandedType, setExpandedType] = useState<number | null>(null);
  400. // Get unique printers from overview
  401. const printers = useMemo(() => {
  402. if (!overview) return [];
  403. return overview.map(o => ({ id: o.printer_id, name: o.printer_name }));
  404. }, [overview]);
  405. // Get which printers have a specific maintenance type assigned
  406. const getAssignedPrinters = (typeId: number) => {
  407. if (!overview) return [];
  408. return overview
  409. .filter(p => p.maintenance_items.some(item => item.maintenance_type_id === typeId))
  410. .map(p => ({
  411. printerId: p.printer_id,
  412. printerName: p.printer_name,
  413. itemId: p.maintenance_items.find(item => item.maintenance_type_id === typeId)?.id,
  414. }));
  415. };
  416. // Get printers that DON'T have a specific type assigned
  417. const getUnassignedPrinters = (typeId: number) => {
  418. if (!overview) return [];
  419. const assignedIds = new Set(getAssignedPrinters(typeId).map(p => p.printerId));
  420. return printers.filter(p => !assignedIds.has(p.id));
  421. };
  422. // Edit type state
  423. const [editingType, setEditingType] = useState<MaintenanceType | null>(null);
  424. const [editTypeName, setEditTypeName] = useState('');
  425. const [editTypeInterval, setEditTypeInterval] = useState('');
  426. const [editTypeIntervalType, setEditTypeIntervalType] = useState<'hours' | 'days'>('hours');
  427. const [editTypeIcon, setEditTypeIcon] = useState('Wrench');
  428. const startEditType = (type: MaintenanceType) => {
  429. setEditingType(type);
  430. setEditTypeName(type.name);
  431. setEditTypeInterval(type.default_interval_hours.toString());
  432. setEditTypeIntervalType(type.interval_type || 'hours');
  433. setEditTypeIcon(type.icon || 'Wrench');
  434. };
  435. const handleSaveEditType = () => {
  436. if (editingType && editTypeName.trim() && parseFloat(editTypeInterval) > 0) {
  437. onUpdateType(editingType.id, {
  438. name: editTypeName.trim(),
  439. default_interval_hours: parseFloat(editTypeInterval),
  440. interval_type: editTypeIntervalType,
  441. icon: editTypeIcon,
  442. });
  443. setEditingType(null);
  444. }
  445. };
  446. const handleSaveInterval = (itemId: number, defaultInterval: number, defaultIntervalType: 'hours' | 'days') => {
  447. const newInterval = parseFloat(intervalInput);
  448. if (!isNaN(newInterval) && newInterval > 0) {
  449. const customInterval = Math.abs(newInterval - defaultInterval) < 0.01 ? null : newInterval;
  450. const customIntervalType = intervalTypeInput !== defaultIntervalType ? intervalTypeInput : null;
  451. onUpdateInterval(itemId, {
  452. custom_interval_hours: customInterval,
  453. custom_interval_type: customIntervalType
  454. });
  455. }
  456. setEditingInterval(null);
  457. };
  458. const handleAddType = (e: React.FormEvent) => {
  459. e.preventDefault();
  460. if (newTypeName.trim() && parseFloat(newTypeInterval) > 0 && selectedPrinters.size > 0) {
  461. onAddType({
  462. name: newTypeName.trim(),
  463. default_interval_hours: parseFloat(newTypeInterval),
  464. interval_type: newTypeIntervalType,
  465. icon: newTypeIcon,
  466. }, Array.from(selectedPrinters));
  467. setNewTypeName('');
  468. setNewTypeInterval('100');
  469. setNewTypeIntervalType('hours');
  470. setSelectedPrinters(new Set());
  471. setShowAddType(false);
  472. }
  473. };
  474. const togglePrinterSelection = (printerId: number) => {
  475. setSelectedPrinters(prev => {
  476. const next = new Set(prev);
  477. if (next.has(printerId)) {
  478. next.delete(printerId);
  479. } else {
  480. next.add(printerId);
  481. }
  482. return next;
  483. });
  484. };
  485. const printerItems = overview?.map(p => ({
  486. printerId: p.printer_id,
  487. printerName: p.printer_name,
  488. items: p.maintenance_items.sort((a, b) => a.maintenance_type_id - b.maintenance_type_id),
  489. })).sort((a, b) => a.printerName.localeCompare(b.printerName)) || [];
  490. const systemTypes = types.filter(t => t.is_system);
  491. const customTypes = types.filter(t => !t.is_system);
  492. return (
  493. <div className="space-y-8">
  494. {/* Maintenance Types */}
  495. <div>
  496. <div className="flex items-center justify-between mb-4">
  497. <div>
  498. <h2 className="text-lg font-semibold text-white">Maintenance Types</h2>
  499. <p className="text-sm text-bambu-gray mt-1">System types and your custom maintenance tasks</p>
  500. </div>
  501. <Button onClick={() => setShowAddType(!showAddType)}>
  502. <Plus className="w-4 h-4" />
  503. Add Custom Type
  504. </Button>
  505. </div>
  506. {/* Add custom type form */}
  507. {showAddType && (
  508. <Card className="mb-6">
  509. <CardContent className="py-4">
  510. <form onSubmit={handleAddType}>
  511. <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
  512. <div className="lg:col-span-2">
  513. <label className="block text-xs text-bambu-gray mb-1.5">Name</label>
  514. <input
  515. type="text"
  516. value={newTypeName}
  517. onChange={(e) => setNewTypeName(e.target.value)}
  518. className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:border-bambu-green focus:outline-none"
  519. placeholder="e.g., Replace HEPA Filter"
  520. autoFocus
  521. />
  522. </div>
  523. <div>
  524. <label className="block text-xs text-bambu-gray mb-1.5">Interval Type</label>
  525. <select
  526. value={newTypeIntervalType}
  527. onChange={(e) => {
  528. setNewTypeIntervalType(e.target.value as 'hours' | 'days');
  529. // Set sensible default based on type
  530. if (e.target.value === 'days') {
  531. setNewTypeInterval('30');
  532. } else {
  533. setNewTypeInterval('100');
  534. }
  535. }}
  536. className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:border-bambu-green focus:outline-none"
  537. >
  538. <option value="hours">Print Hours</option>
  539. <option value="days">Calendar Days</option>
  540. </select>
  541. </div>
  542. <div>
  543. <label className="block text-xs text-bambu-gray mb-1.5">
  544. Interval ({newTypeIntervalType === 'days' ? 'days' : 'hours'})
  545. </label>
  546. <input
  547. type="number"
  548. value={newTypeInterval}
  549. onChange={(e) => setNewTypeInterval(e.target.value)}
  550. className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:border-bambu-green focus:outline-none"
  551. min="1"
  552. />
  553. </div>
  554. </div>
  555. <div className="mt-4 flex items-end justify-between">
  556. <div>
  557. <label className="block text-xs text-bambu-gray mb-1.5">Icon</label>
  558. <div className="flex gap-1">
  559. {Object.keys(iconMap).map((iconName) => {
  560. const IconComp = iconMap[iconName];
  561. return (
  562. <button
  563. key={iconName}
  564. type="button"
  565. onClick={() => setNewTypeIcon(iconName)}
  566. className={`p-2 rounded-lg transition-colors ${
  567. newTypeIcon === iconName
  568. ? 'bg-bambu-green text-white'
  569. : 'bg-bambu-dark text-bambu-gray hover:text-white hover:bg-bambu-dark-tertiary'
  570. }`}
  571. >
  572. <IconComp className="w-4 h-4" />
  573. </button>
  574. );
  575. })}
  576. </div>
  577. </div>
  578. </div>
  579. {/* Printer selection */}
  580. <div className="mt-4">
  581. <label className="block text-xs text-bambu-gray mb-1.5">Assign to Printers</label>
  582. <div className="flex flex-wrap gap-2">
  583. {printers.map(p => (
  584. <button
  585. key={p.id}
  586. type="button"
  587. onClick={() => togglePrinterSelection(p.id)}
  588. className={`px-3 py-1.5 rounded-lg text-sm transition-colors ${
  589. selectedPrinters.has(p.id)
  590. ? 'bg-bambu-green text-white'
  591. : 'bg-bambu-dark text-bambu-gray hover:text-white hover:bg-bambu-dark-tertiary'
  592. }`}
  593. >
  594. {p.name}
  595. </button>
  596. ))}
  597. </div>
  598. {selectedPrinters.size === 0 && (
  599. <p className="text-xs text-orange-400 mt-1">Select at least one printer</p>
  600. )}
  601. </div>
  602. <div className="mt-4 flex justify-end gap-2">
  603. <Button type="button" variant="secondary" onClick={() => { setShowAddType(false); setSelectedPrinters(new Set()); }}>
  604. Cancel
  605. </Button>
  606. <Button type="submit" disabled={!newTypeName.trim() || selectedPrinters.size === 0}>
  607. Add Type
  608. </Button>
  609. </div>
  610. </form>
  611. </CardContent>
  612. </Card>
  613. )}
  614. {/* Types grid */}
  615. <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
  616. {/* System types */}
  617. {systemTypes.map((type) => {
  618. const Icon = getIcon(type.icon);
  619. const intervalType = type.interval_type || 'hours';
  620. return (
  621. <div key={type.id} className="bg-bambu-dark-secondary rounded-xl p-4 border border-bambu-dark-tertiary">
  622. <div className="flex items-center gap-3">
  623. <div className="p-2.5 bg-bambu-dark rounded-lg">
  624. <Icon className="w-5 h-5 text-bambu-gray" />
  625. </div>
  626. <div className="flex-1 min-w-0">
  627. <div className="text-sm font-medium text-white truncate">{type.name}</div>
  628. <div className="text-xs text-bambu-gray mt-0.5 flex items-center gap-1">
  629. {intervalType === 'days' ? <Calendar className="w-3 h-3" /> : <Timer className="w-3 h-3" />}
  630. {formatIntervalLabel(type.default_interval_hours, intervalType)}
  631. </div>
  632. </div>
  633. </div>
  634. </div>
  635. );
  636. })}
  637. {/* Custom types */}
  638. {customTypes.map((type) => {
  639. const Icon = getIcon(type.icon);
  640. const intervalType = type.interval_type || 'hours';
  641. const isEditing = editingType?.id === type.id;
  642. if (isEditing) {
  643. return (
  644. <div key={type.id} className="bg-bambu-dark-secondary rounded-xl p-4 border border-bambu-green">
  645. <div className="space-y-3">
  646. <input
  647. type="text"
  648. value={editTypeName}
  649. onChange={(e) => setEditTypeName(e.target.value)}
  650. className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:border-bambu-green focus:outline-none"
  651. placeholder="Name"
  652. autoFocus
  653. />
  654. <div className="flex gap-2">
  655. <select
  656. value={editTypeIntervalType}
  657. onChange={(e) => setEditTypeIntervalType(e.target.value as 'hours' | 'days')}
  658. className="flex-1 px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:border-bambu-green focus:outline-none"
  659. >
  660. <option value="hours">Print Hours</option>
  661. <option value="days">Calendar Days</option>
  662. </select>
  663. <input
  664. type="number"
  665. value={editTypeInterval}
  666. onChange={(e) => setEditTypeInterval(e.target.value)}
  667. className="w-24 px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:border-bambu-green focus:outline-none"
  668. min="1"
  669. />
  670. </div>
  671. <div className="flex flex-wrap gap-1">
  672. {Object.keys(iconMap).map((iconName) => {
  673. const IconComp = iconMap[iconName];
  674. return (
  675. <button
  676. key={iconName}
  677. type="button"
  678. onClick={() => setEditTypeIcon(iconName)}
  679. className={`p-1.5 rounded transition-colors ${
  680. editTypeIcon === iconName
  681. ? 'bg-bambu-green text-white'
  682. : 'bg-bambu-dark text-bambu-gray hover:text-white'
  683. }`}
  684. >
  685. <IconComp className="w-3.5 h-3.5" />
  686. </button>
  687. );
  688. })}
  689. </div>
  690. <div className="flex gap-2">
  691. <Button size="sm" onClick={handleSaveEditType} disabled={!editTypeName.trim()}>
  692. Save
  693. </Button>
  694. <Button size="sm" variant="secondary" onClick={() => setEditingType(null)}>
  695. Cancel
  696. </Button>
  697. </div>
  698. </div>
  699. </div>
  700. );
  701. }
  702. const assignedPrinters = getAssignedPrinters(type.id);
  703. const unassignedPrinters = getUnassignedPrinters(type.id);
  704. const isExpanded = expandedType === type.id;
  705. return (
  706. <div key={type.id} className="bg-bambu-dark-secondary rounded-xl p-4 border border-bambu-green/30">
  707. <div className="flex items-center gap-3">
  708. <div className="p-2.5 bg-bambu-green/20 rounded-lg">
  709. <Icon className="w-5 h-5 text-bambu-green" />
  710. </div>
  711. <div className="flex-1 min-w-0">
  712. <div className="flex items-center gap-2">
  713. <span className="text-sm font-medium text-white truncate">{type.name}</span>
  714. <span className="px-1.5 py-0.5 bg-bambu-green/20 text-bambu-green text-[10px] font-medium rounded">
  715. Custom
  716. </span>
  717. </div>
  718. <div className="text-xs text-bambu-gray mt-0.5 flex items-center gap-1">
  719. {intervalType === 'days' ? <Calendar className="w-3 h-3" /> : <Timer className="w-3 h-3" />}
  720. {formatIntervalLabel(type.default_interval_hours, intervalType)}
  721. </div>
  722. </div>
  723. <button
  724. onClick={() => setExpandedType(isExpanded ? null : type.id)}
  725. className={`px-2 py-1 rounded-lg border transition-colors flex items-center gap-1 ${
  726. assignedPrinters.length > 0
  727. ? 'border-bambu-green/50 bg-bambu-green/10 text-bambu-green hover:bg-bambu-green/20'
  728. : 'border-orange-400/50 bg-orange-400/10 text-orange-400 hover:bg-orange-400/20'
  729. }`}
  730. title={`${assignedPrinters.length} printer(s) assigned - click to manage`}
  731. >
  732. <Printer className="w-3 h-3" />
  733. <span className="text-xs font-medium">{assignedPrinters.length}</span>
  734. <ChevronDown className={`w-3 h-3 transition-transform ${isExpanded ? 'rotate-180' : ''}`} />
  735. </button>
  736. <button
  737. onClick={() => startEditType(type)}
  738. className="p-2 rounded-lg hover:bg-bambu-dark text-bambu-gray hover:text-white transition-colors"
  739. >
  740. <Edit3 className="w-4 h-4" />
  741. </button>
  742. <button
  743. onClick={() => {
  744. if (confirm(`Delete "${type.name}"?`)) {
  745. onDeleteType(type.id);
  746. }
  747. }}
  748. className="p-2 rounded-lg hover:bg-bambu-dark text-bambu-gray hover:text-red-400 transition-colors"
  749. >
  750. <Trash2 className="w-4 h-4" />
  751. </button>
  752. </div>
  753. {/* Printer assignment management */}
  754. {isExpanded && (
  755. <div className="mt-3 pt-3 border-t border-bambu-dark-tertiary">
  756. <p className="text-xs text-bambu-gray mb-2">Assigned to printers:</p>
  757. {assignedPrinters.length === 0 ? (
  758. <p className="text-xs text-orange-400">No printers assigned</p>
  759. ) : (
  760. <div className="flex flex-wrap gap-1 mb-2">
  761. {assignedPrinters.map(p => (
  762. <span
  763. key={p.printerId}
  764. className="inline-flex items-center gap-1 px-2 py-1 bg-bambu-dark rounded text-xs text-white"
  765. >
  766. {p.printerName}
  767. <button
  768. onClick={() => p.itemId && onRemoveItem(p.itemId)}
  769. className="hover:text-red-400 ml-1"
  770. title="Remove from this printer"
  771. >
  772. ×
  773. </button>
  774. </span>
  775. ))}
  776. </div>
  777. )}
  778. {unassignedPrinters.length > 0 && (
  779. <div className="flex flex-wrap gap-1">
  780. <span className="text-xs text-bambu-gray mr-1">Add:</span>
  781. {unassignedPrinters.map(p => (
  782. <button
  783. key={p.id}
  784. onClick={() => onAssignType(p.id, type.id)}
  785. className="px-2 py-1 bg-bambu-dark hover:bg-bambu-green/20 rounded text-xs text-bambu-gray hover:text-bambu-green transition-colors"
  786. >
  787. + {p.name}
  788. </button>
  789. ))}
  790. </div>
  791. )}
  792. </div>
  793. )}
  794. </div>
  795. );
  796. })}
  797. </div>
  798. </div>
  799. {/* Per-printer interval overrides */}
  800. {printerItems.length > 0 && (
  801. <div>
  802. <div className="mb-4">
  803. <h2 className="text-lg font-semibold text-white">Interval Overrides</h2>
  804. <p className="text-sm text-bambu-gray mt-1">Customize intervals for specific printers</p>
  805. </div>
  806. <div className="space-y-4">
  807. {printerItems.map((printer) => (
  808. <Card key={printer.printerId}>
  809. <CardContent className="py-4">
  810. <h3 className="text-sm font-medium text-white mb-3">{printer.printerName}</h3>
  811. <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-2">
  812. {printer.items.map((item) => {
  813. const Icon = getIcon(item.maintenance_type_icon);
  814. const typeInfo = types.find(t => t.id === item.maintenance_type_id);
  815. const defaultInterval = typeInfo?.default_interval_hours || item.interval_hours;
  816. const defaultIntervalType = typeInfo?.interval_type || 'hours';
  817. const intervalType = item.interval_type || 'hours';
  818. const isEditing = editingInterval === item.id;
  819. return (
  820. <div key={item.id} className="flex items-center gap-2 p-2.5 bg-bambu-dark rounded-lg">
  821. <Icon className="w-4 h-4 text-bambu-gray shrink-0" />
  822. <span className="text-xs text-bambu-gray flex-1 truncate">{item.maintenance_type_name}</span>
  823. {isEditing ? (
  824. <div className="flex items-center gap-1">
  825. {intervalTypeInput === 'days' ? (
  826. <Calendar className="w-3.5 h-3.5 text-bambu-gray shrink-0" />
  827. ) : (
  828. <Timer className="w-3.5 h-3.5 text-bambu-gray shrink-0" />
  829. )}
  830. <select
  831. value={intervalTypeInput}
  832. onChange={(e) => setIntervalTypeInput(e.target.value as 'hours' | 'days')}
  833. className="px-1.5 py-1 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-white text-xs"
  834. >
  835. <option value="hours">Print Hours</option>
  836. <option value="days">Calendar Days</option>
  837. </select>
  838. <input
  839. type="number"
  840. value={intervalInput}
  841. onChange={(e) => setIntervalInput(e.target.value)}
  842. onKeyDown={(e) => {
  843. if (e.key === 'Enter') handleSaveInterval(item.id, defaultInterval, defaultIntervalType);
  844. if (e.key === 'Escape') setEditingInterval(null);
  845. }}
  846. className="w-16 px-2 py-1 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-white text-xs"
  847. min="1"
  848. />
  849. <Button size="sm" onClick={() => handleSaveInterval(item.id, defaultInterval, defaultIntervalType)}>OK</Button>
  850. </div>
  851. ) : (
  852. <button
  853. onClick={() => {
  854. setEditingInterval(item.id);
  855. setIntervalInput(item.interval_hours.toString());
  856. setIntervalTypeInput(intervalType);
  857. }}
  858. className="px-2 py-1 bg-bambu-dark-tertiary hover:bg-bambu-dark-secondary border border-bambu-dark-tertiary hover:border-bambu-green rounded text-xs font-medium text-white transition-colors flex items-center gap-1"
  859. >
  860. {intervalType === 'days' ? <Calendar className="w-3 h-3" /> : <Timer className="w-3 h-3" />}
  861. {formatIntervalLabel(item.interval_hours, intervalType)}
  862. <Edit3 className="w-3 h-3 text-bambu-gray" />
  863. </button>
  864. )}
  865. </div>
  866. );
  867. })}
  868. </div>
  869. </CardContent>
  870. </Card>
  871. ))}
  872. </div>
  873. </div>
  874. )}
  875. {printerItems.length === 0 && (
  876. <Card>
  877. <CardContent className="text-center py-12">
  878. <Clock className="w-12 h-12 mx-auto mb-4 text-bambu-gray/30" />
  879. <p className="text-bambu-gray">No printers configured</p>
  880. <p className="text-sm text-bambu-gray/70 mt-1">
  881. Add printers to configure maintenance intervals
  882. </p>
  883. </CardContent>
  884. </Card>
  885. )}
  886. </div>
  887. );
  888. }
  889. type TabType = 'status' | 'settings';
  890. export function MaintenancePage() {
  891. const queryClient = useQueryClient();
  892. const { showToast } = useToast();
  893. const [activeTab, setActiveTab] = useState<TabType>('status');
  894. const { data: overview, isLoading } = useQuery({
  895. queryKey: ['maintenanceOverview'],
  896. queryFn: api.getMaintenanceOverview,
  897. });
  898. const { data: types } = useQuery({
  899. queryKey: ['maintenanceTypes'],
  900. queryFn: api.getMaintenanceTypes,
  901. });
  902. const performMutation = useMutation({
  903. mutationFn: ({ id, notes }: { id: number; notes?: string }) =>
  904. api.performMaintenance(id, notes),
  905. onSuccess: () => {
  906. queryClient.invalidateQueries({ queryKey: ['maintenanceOverview'] });
  907. queryClient.invalidateQueries({ queryKey: ['maintenanceSummary'] });
  908. showToast('Maintenance marked as complete');
  909. },
  910. onError: (error: Error) => {
  911. showToast(error.message, 'error');
  912. },
  913. });
  914. const updateMutation = useMutation({
  915. mutationFn: ({ id, data }: { id: number; data: { custom_interval_hours?: number | null; custom_interval_type?: 'hours' | 'days' | null; enabled?: boolean } }) =>
  916. api.updateMaintenanceItem(id, data),
  917. onSuccess: () => {
  918. queryClient.invalidateQueries({ queryKey: ['maintenanceOverview'] });
  919. },
  920. onError: (error: Error) => {
  921. showToast(error.message, 'error');
  922. },
  923. });
  924. // addTypeMutation removed - we now handle type creation with printer assignment
  925. // directly in onAddType callback
  926. const updateTypeMutation = useMutation({
  927. mutationFn: ({ id, data }: { id: number; data: Partial<{ name: string; default_interval_hours: number; interval_type: 'hours' | 'days'; icon: string }> }) =>
  928. api.updateMaintenanceType(id, data),
  929. onSuccess: () => {
  930. queryClient.invalidateQueries({ queryKey: ['maintenanceTypes'] });
  931. queryClient.invalidateQueries({ queryKey: ['maintenanceOverview'] });
  932. showToast('Maintenance type updated');
  933. },
  934. onError: (error: Error) => {
  935. showToast(error.message, 'error');
  936. },
  937. });
  938. const deleteTypeMutation = useMutation({
  939. mutationFn: api.deleteMaintenanceType,
  940. onSuccess: () => {
  941. queryClient.invalidateQueries({ queryKey: ['maintenanceTypes'] });
  942. queryClient.invalidateQueries({ queryKey: ['maintenanceOverview'] });
  943. showToast('Maintenance type deleted');
  944. },
  945. onError: (error: Error) => {
  946. showToast(error.message, 'error');
  947. },
  948. });
  949. const setHoursMutation = useMutation({
  950. mutationFn: ({ printerId, hours }: { printerId: number; hours: number }) =>
  951. api.setPrinterHours(printerId, hours),
  952. onSuccess: () => {
  953. queryClient.invalidateQueries({ queryKey: ['maintenanceOverview'] });
  954. queryClient.invalidateQueries({ queryKey: ['maintenanceSummary'] });
  955. showToast('Print hours updated');
  956. },
  957. onError: (error: Error) => {
  958. showToast(error.message, 'error');
  959. },
  960. });
  961. const assignTypeMutation = useMutation({
  962. mutationFn: ({ printerId, typeId }: { printerId: number; typeId: number }) =>
  963. api.assignMaintenanceType(printerId, typeId),
  964. onSuccess: () => {
  965. queryClient.invalidateQueries({ queryKey: ['maintenanceOverview'] });
  966. showToast('Printer assigned');
  967. },
  968. onError: (error: Error) => {
  969. showToast(error.message, 'error');
  970. },
  971. });
  972. const removeItemMutation = useMutation({
  973. mutationFn: api.removeMaintenanceItem,
  974. onSuccess: () => {
  975. queryClient.invalidateQueries({ queryKey: ['maintenanceOverview'] });
  976. showToast('Printer removed');
  977. },
  978. onError: (error: Error) => {
  979. showToast(error.message, 'error');
  980. },
  981. });
  982. const handlePerform = (id: number) => {
  983. performMutation.mutate({ id });
  984. };
  985. const handleToggle = (id: number, enabled: boolean) => {
  986. updateMutation.mutate({ id, data: { enabled } });
  987. };
  988. const handleSetHours = (printerId: number, hours: number) => {
  989. setHoursMutation.mutate({ printerId, hours });
  990. };
  991. if (isLoading) {
  992. return (
  993. <div className="p-4 md:p-8 flex justify-center">
  994. <Loader2 className="w-8 h-8 text-bambu-green animate-spin" />
  995. </div>
  996. );
  997. }
  998. const totalDue = overview?.reduce((sum, p) => sum + p.due_count, 0) || 0;
  999. const totalWarning = overview?.reduce((sum, p) => sum + p.warning_count, 0) || 0;
  1000. return (
  1001. <div className="p-4 md:p-8">
  1002. {/* Header */}
  1003. <div className="mb-6">
  1004. <h1 className="text-2xl font-bold text-white">Maintenance</h1>
  1005. <p className="text-bambu-gray text-sm mt-1">
  1006. {activeTab === 'status' ? (
  1007. <>
  1008. {totalDue > 0 && <span className="text-red-400">{totalDue} task{totalDue !== 1 ? 's' : ''} overdue</span>}
  1009. {totalDue > 0 && totalWarning > 0 && ' · '}
  1010. {totalWarning > 0 && <span className="text-amber-400">{totalWarning} due soon</span>}
  1011. {totalDue === 0 && totalWarning === 0 && <span className="text-bambu-green">All maintenance up to date</span>}
  1012. </>
  1013. ) : (
  1014. 'Configure maintenance types and intervals'
  1015. )}
  1016. </p>
  1017. </div>
  1018. {/* Tabs */}
  1019. <div className="flex gap-1 mb-6 border-b border-bambu-dark-tertiary">
  1020. <button
  1021. onClick={() => setActiveTab('status')}
  1022. className={`px-4 py-2 text-sm font-medium border-b-2 -mb-px transition-colors ${
  1023. activeTab === 'status'
  1024. ? 'text-bambu-green border-bambu-green'
  1025. : 'text-bambu-gray border-transparent hover:text-white'
  1026. }`}
  1027. >
  1028. Status
  1029. </button>
  1030. <button
  1031. onClick={() => setActiveTab('settings')}
  1032. className={`px-4 py-2 text-sm font-medium border-b-2 -mb-px transition-colors ${
  1033. activeTab === 'settings'
  1034. ? 'text-bambu-green border-bambu-green'
  1035. : 'text-bambu-gray border-transparent hover:text-white'
  1036. }`}
  1037. >
  1038. Settings
  1039. </button>
  1040. </div>
  1041. {/* Tab content */}
  1042. {activeTab === 'status' ? (
  1043. <div className="space-y-6">
  1044. {overview && overview.length > 0 ? (
  1045. [...overview].sort((a, b) => {
  1046. // Sort printers with issues first
  1047. const aScore = a.due_count * 10 + a.warning_count;
  1048. const bScore = b.due_count * 10 + b.warning_count;
  1049. if (aScore !== bScore) return bScore - aScore;
  1050. return a.printer_name.localeCompare(b.printer_name);
  1051. }).map((printerOverview) => (
  1052. <PrinterSection
  1053. key={printerOverview.printer_id}
  1054. overview={printerOverview}
  1055. onPerform={handlePerform}
  1056. onToggle={handleToggle}
  1057. onSetHours={handleSetHours}
  1058. />
  1059. ))
  1060. ) : (
  1061. <Card>
  1062. <CardContent className="text-center py-16">
  1063. <Wrench className="w-16 h-16 mx-auto mb-4 text-bambu-gray/30" />
  1064. <p className="text-lg font-medium text-white mb-2">No printers configured</p>
  1065. <p className="text-bambu-gray">Add printers to start tracking maintenance</p>
  1066. </CardContent>
  1067. </Card>
  1068. )}
  1069. </div>
  1070. ) : (
  1071. <SettingsSection
  1072. overview={overview}
  1073. types={types || []}
  1074. onUpdateInterval={(id, data) =>
  1075. updateMutation.mutate({ id, data })
  1076. }
  1077. onAddType={async (data, printerIds) => {
  1078. // Create the type first, then assign to selected printers
  1079. const newType = await api.createMaintenanceType(data);
  1080. // Assign to each selected printer
  1081. for (const printerId of printerIds) {
  1082. await api.assignMaintenanceType(printerId, newType.id);
  1083. }
  1084. queryClient.invalidateQueries({ queryKey: ['maintenanceTypes'] });
  1085. queryClient.invalidateQueries({ queryKey: ['maintenanceOverview'] });
  1086. showToast('Maintenance type added');
  1087. }}
  1088. onUpdateType={(id, data) => updateTypeMutation.mutate({ id, data })}
  1089. onDeleteType={(id) => deleteTypeMutation.mutate(id)}
  1090. onAssignType={(printerId, typeId) => assignTypeMutation.mutate({ printerId, typeId })}
  1091. onRemoveItem={(itemId) => removeItemMutation.mutate(itemId)}
  1092. />
  1093. )}
  1094. </div>
  1095. );
  1096. }