MaintenancePage.tsx 54 KB

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