MaintenancePage.tsx 58 KB

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