MaintenancePage.tsx 57 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333
  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-400';
  153. if (item.is_warning) return '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-500/20 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-500/20 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-400' : 'text-amber-400'}`} />;
  394. })()}
  395. </div>
  396. <div>
  397. <div className={`text-sm font-medium ${nextTask.is_due ? 'text-red-400' : 'text-amber-400'}`}>
  398. {nextTask.maintenance_type_name}
  399. </div>
  400. <div className={`text-xs ${nextTask.is_due ? 'text-red-400/70' : '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-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-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-400/50 bg-orange-400/10 text-orange-400 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-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-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-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. mutationFn: ({ id, data }: { id: number; data: Partial<{ name: string; default_interval_hours: number; interval_type: 'hours' | 'days'; icon: string }> }) =>
  1072. api.updateMaintenanceType(id, data),
  1073. onSuccess: () => {
  1074. queryClient.invalidateQueries({ queryKey: ['maintenanceTypes'] });
  1075. queryClient.invalidateQueries({ queryKey: ['maintenanceOverview'] });
  1076. showToast(t('maintenance.typeUpdated'));
  1077. },
  1078. onError: (error: Error) => {
  1079. showToast(error.message, 'error');
  1080. },
  1081. });
  1082. const deleteTypeMutation = useMutation({
  1083. mutationFn: api.deleteMaintenanceType,
  1084. onSuccess: () => {
  1085. queryClient.invalidateQueries({ queryKey: ['maintenanceTypes'] });
  1086. queryClient.invalidateQueries({ queryKey: ['maintenanceOverview'] });
  1087. showToast(t('maintenance.typeDeleted'));
  1088. },
  1089. onError: (error: Error) => {
  1090. showToast(error.message, 'error');
  1091. },
  1092. });
  1093. const restoreDefaultsMutation = useMutation({
  1094. mutationFn: api.restoreDefaultMaintenanceTypes,
  1095. onSuccess: (data: { restored: number }) => {
  1096. queryClient.invalidateQueries({ queryKey: ['maintenanceTypes'] });
  1097. queryClient.invalidateQueries({ queryKey: ['maintenanceOverview'] });
  1098. showToast(t('maintenance.defaultsRestored', { count: data.restored }));
  1099. },
  1100. onError: (error: Error) => {
  1101. showToast(error.message, 'error');
  1102. },
  1103. });
  1104. const setHoursMutation = useMutation({
  1105. mutationFn: ({ printerId, hours }: { printerId: number; hours: number }) =>
  1106. api.setPrinterHours(printerId, hours),
  1107. onSuccess: () => {
  1108. queryClient.invalidateQueries({ queryKey: ['maintenanceOverview'] });
  1109. queryClient.invalidateQueries({ queryKey: ['maintenanceSummary'] });
  1110. showToast(t('maintenance.printHoursUpdated'));
  1111. },
  1112. onError: (error: Error) => {
  1113. showToast(error.message, 'error');
  1114. },
  1115. });
  1116. const assignTypeMutation = useMutation({
  1117. mutationFn: ({ printerId, typeId }: { printerId: number; typeId: number }) =>
  1118. api.assignMaintenanceType(printerId, typeId),
  1119. onSuccess: () => {
  1120. queryClient.invalidateQueries({ queryKey: ['maintenanceOverview'] });
  1121. showToast(t('maintenance.printerAssigned'));
  1122. },
  1123. onError: (error: Error) => {
  1124. showToast(error.message, 'error');
  1125. },
  1126. });
  1127. const removeItemMutation = useMutation({
  1128. mutationFn: api.removeMaintenanceItem,
  1129. onSuccess: () => {
  1130. queryClient.invalidateQueries({ queryKey: ['maintenanceOverview'] });
  1131. showToast(t('maintenance.printerRemoved'));
  1132. },
  1133. onError: (error: Error) => {
  1134. showToast(error.message, 'error');
  1135. },
  1136. });
  1137. const handlePerform = (id: number) => {
  1138. performMutation.mutate({ id });
  1139. };
  1140. const handleToggle = (id: number, enabled: boolean) => {
  1141. updateMutation.mutate({ id, data: { enabled } });
  1142. };
  1143. const handleSetHours = (printerId: number, hours: number) => {
  1144. setHoursMutation.mutate({ printerId, hours });
  1145. };
  1146. if (isLoading) {
  1147. return (
  1148. <div className="p-4 md:p-8 flex justify-center">
  1149. <Loader2 className="w-8 h-8 text-bambu-green animate-spin" />
  1150. </div>
  1151. );
  1152. }
  1153. const totalDue = overview?.reduce((sum, p) => sum + p.due_count, 0) || 0;
  1154. const totalWarning = overview?.reduce((sum, p) => sum + p.warning_count, 0) || 0;
  1155. return (
  1156. <div className="p-4 md:p-8">
  1157. {/* Header */}
  1158. <div className="mb-6">
  1159. <h1 className="text-2xl font-bold text-white flex items-center gap-3">
  1160. <Wrench className="w-7 h-7 text-bambu-green" />
  1161. {t('maintenance.title')}
  1162. </h1>
  1163. <p className="text-bambu-gray mt-1">
  1164. {activeTab === 'status' ? (
  1165. <>
  1166. {totalDue > 0 && <span className="text-red-400">{t('maintenance.dueCount', { count: totalDue })}</span>}
  1167. {totalDue > 0 && totalWarning > 0 && ' · '}
  1168. {totalWarning > 0 && <span className="text-amber-400">{t('maintenance.warningCount', { count: totalWarning })}</span>}
  1169. {totalDue === 0 && totalWarning === 0 && <span className="text-bambu-green">{t('maintenance.allOk')}</span>}
  1170. </>
  1171. ) : (
  1172. t('maintenance.configureSettings')
  1173. )}
  1174. </p>
  1175. </div>
  1176. {/* Tabs */}
  1177. <div className="flex gap-1 mb-6 border-b border-bambu-dark-tertiary">
  1178. <button
  1179. onClick={() => setActiveTab('status')}
  1180. className={`px-4 py-2 text-sm font-medium border-b-2 -mb-px transition-colors ${
  1181. activeTab === 'status'
  1182. ? 'text-bambu-green border-bambu-green'
  1183. : 'text-bambu-gray border-transparent hover:text-white'
  1184. }`}
  1185. >
  1186. {t('maintenance.statusTab')}
  1187. </button>
  1188. <button
  1189. onClick={() => setActiveTab('settings')}
  1190. className={`px-4 py-2 text-sm font-medium border-b-2 -mb-px transition-colors ${
  1191. activeTab === 'settings'
  1192. ? 'text-bambu-green border-bambu-green'
  1193. : 'text-bambu-gray border-transparent hover:text-white'
  1194. }`}
  1195. >
  1196. {t('maintenance.settingsTab')}
  1197. </button>
  1198. </div>
  1199. {/* Tab content */}
  1200. {activeTab === 'status' ? (
  1201. <div className="space-y-6">
  1202. {overview && overview.length > 0 ? (
  1203. [...overview].sort((a, b) => {
  1204. // Sort printers with issues first
  1205. const aScore = a.due_count * 10 + a.warning_count;
  1206. const bScore = b.due_count * 10 + b.warning_count;
  1207. if (aScore !== bScore) return bScore - aScore;
  1208. return a.printer_name.localeCompare(b.printer_name);
  1209. }).map((printerOverview) => (
  1210. <PrinterSection
  1211. key={printerOverview.printer_id}
  1212. overview={printerOverview}
  1213. onPerform={handlePerform}
  1214. onToggle={handleToggle}
  1215. onSetHours={handleSetHours}
  1216. hasPermission={hasPermission}
  1217. t={t}
  1218. />
  1219. ))
  1220. ) : (
  1221. <Card>
  1222. <CardContent className="text-center py-16">
  1223. <Wrench className="w-16 h-16 mx-auto mb-4 text-bambu-gray/30" />
  1224. <p className="text-lg font-medium text-white mb-2">{t('common.noPrinters')}</p>
  1225. <p className="text-bambu-gray">{t('maintenance.configureSettings')}</p>
  1226. </CardContent>
  1227. </Card>
  1228. )}
  1229. </div>
  1230. ) : (
  1231. <SettingsSection
  1232. overview={overview}
  1233. types={types || []}
  1234. onUpdateInterval={(id, data) =>
  1235. updateMutation.mutate({ id, data })
  1236. }
  1237. onAddType={async (data, printerIds) => {
  1238. // Create the type first, then assign to selected printers
  1239. const newType = await api.createMaintenanceType(data);
  1240. // Assign to each selected printer
  1241. for (const printerId of printerIds) {
  1242. await api.assignMaintenanceType(printerId, newType.id);
  1243. }
  1244. queryClient.invalidateQueries({ queryKey: ['maintenanceTypes'] });
  1245. queryClient.invalidateQueries({ queryKey: ['maintenanceOverview'] });
  1246. showToast(t('maintenance.typeUpdated'));
  1247. }}
  1248. onUpdateType={(id, data) => updateTypeMutation.mutate({ id, data })}
  1249. onDeleteType={(id) => deleteTypeMutation.mutate(id)}
  1250. onRestoreDefaults={() => restoreDefaultsMutation.mutate()}
  1251. isRestoringDefaults={restoreDefaultsMutation.isPending}
  1252. onAssignType={(printerId, typeId) => assignTypeMutation.mutate({ printerId, typeId })}
  1253. onRemoveItem={(itemId) => removeItemMutation.mutate(itemId)}
  1254. hasPermission={hasPermission}
  1255. t={t}
  1256. />
  1257. )}
  1258. </div>
  1259. );
  1260. }