MaintenancePage.tsx 57 KB

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