| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165 |
- import { useState, useMemo } from 'react';
- import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
- import {
- Wrench,
- Loader2,
- Check,
- AlertTriangle,
- Clock,
- Plus,
- Trash2,
- ChevronDown,
- ChevronUp,
- Droplet,
- Flame,
- Ruler,
- Sparkles,
- Square,
- Cable,
- Edit3,
- RotateCcw,
- Calendar,
- Timer,
- Cog,
- Fan,
- Zap,
- Wind,
- Thermometer,
- Layers,
- Box,
- Target,
- RefreshCw,
- Settings,
- Filter,
- CircleDot,
- Printer,
- } from 'lucide-react';
- import { api } from '../api/client';
- import type { MaintenanceStatus, PrinterMaintenanceOverview, MaintenanceType } from '../api/client';
- import { Card, CardContent } from '../components/Card';
- import { Button } from '../components/Button';
- import { Toggle } from '../components/Toggle';
- import { useToast } from '../contexts/ToastContext';
- // Icon mapping for maintenance types
- const iconMap: Record<string, React.ComponentType<{ className?: string }>> = {
- Droplet,
- Flame,
- Ruler,
- Sparkles,
- Square,
- Cable,
- Wrench,
- Calendar,
- Timer,
- Cog,
- Fan,
- Zap,
- Wind,
- Thermometer,
- Layers,
- Box,
- Target,
- RefreshCw,
- Settings,
- Filter,
- CircleDot,
- };
- function getIcon(iconName: string | null) {
- if (!iconName) return Wrench;
- return iconMap[iconName] || Wrench;
- }
- function formatDuration(value: number, type: 'hours' | 'days'): string {
- if (type === 'days') {
- if (value < 1) return 'Today';
- if (value === 1) return '1 day';
- if (value < 7) return `${Math.round(value)} days`;
- if (value < 30) return `${Math.round(value / 7)} weeks`;
- return `${Math.round(value / 30)} months`;
- } else {
- if (value < 1) return `${Math.round(value * 60)}m`;
- if (value < 10) return `${value.toFixed(1)}h`;
- return `${Math.round(value)}h`;
- }
- }
- function formatIntervalLabel(value: number, type: 'hours' | 'days'): string {
- if (type === 'days') {
- if (value === 1) return '1 day';
- if (value === 7) return '1 week';
- if (value === 14) return '2 weeks';
- if (value === 30) return '1 month';
- if (value === 60) return '2 months';
- if (value === 90) return '3 months';
- if (value === 180) return '6 months';
- if (value === 365) return '1 year';
- return `${value} days`;
- }
- return `${value}h`;
- }
- // Maintenance item card - cleaner, more visual design
- function MaintenanceCard({
- item,
- onPerform,
- onToggle,
- }: {
- item: MaintenanceStatus;
- onPerform: (id: number) => void;
- onToggle: (id: number, enabled: boolean) => void;
- }) {
- const Icon = getIcon(item.maintenance_type_icon);
- const intervalType = item.interval_type || 'hours';
- // Calculate progress based on interval type
- const getProgress = () => {
- if (intervalType === 'days') {
- const daysSince = item.days_since_maintenance ?? 0;
- return Math.max(0, Math.min(100, (daysSince / item.interval_hours) * 100));
- }
- return Math.max(0, Math.min(100,
- ((item.interval_hours - item.hours_until_due) / item.interval_hours) * 100
- ));
- };
- const progressPercent = getProgress();
- const getStatusColor = () => {
- if (!item.enabled) return 'text-bambu-gray';
- if (item.is_due) return 'text-red-400';
- if (item.is_warning) return 'text-amber-400';
- return 'text-bambu-green';
- };
- const getProgressColor = () => {
- if (!item.enabled) return 'bg-bambu-gray/30';
- if (item.is_due) return 'bg-red-500';
- if (item.is_warning) return 'bg-amber-500';
- return 'bg-bambu-green';
- };
- const getBgColor = () => {
- if (!item.enabled) return 'bg-bambu-dark-secondary/50';
- if (item.is_due) return 'bg-red-500/5 border-red-500/20';
- if (item.is_warning) return 'bg-amber-500/5 border-amber-500/20';
- return 'bg-bambu-dark-secondary border-bambu-dark-tertiary';
- };
- const getStatusText = () => {
- if (!item.enabled) return 'Disabled';
- if (intervalType === 'days') {
- const daysUntil = item.days_until_due ?? 0;
- if (item.is_due) return `Overdue by ${formatDuration(Math.abs(daysUntil), 'days')}`;
- if (item.is_warning) return `Due in ${formatDuration(daysUntil, 'days')}`;
- return `${formatDuration(daysUntil, 'days')} left`;
- } else {
- if (item.is_due) return `Overdue by ${formatDuration(Math.abs(item.hours_until_due), 'hours')}`;
- if (item.is_warning) return `Due in ${formatDuration(item.hours_until_due, 'hours')}`;
- return `${formatDuration(item.hours_until_due, 'hours')} left`;
- }
- };
- return (
- <div className={`rounded-xl border p-4 transition-all ${getBgColor()}`}>
- <div className="flex items-start gap-3">
- {/* Icon with status indicator */}
- <div className={`relative p-2.5 rounded-lg ${
- item.is_due ? 'bg-red-500/20' :
- item.is_warning ? 'bg-amber-500/20' :
- item.enabled ? 'bg-bambu-dark' : 'bg-bambu-dark/50'
- }`}>
- <Icon className={`w-5 h-5 ${getStatusColor()}`} />
- {item.enabled && (item.is_due || item.is_warning) && (
- <span className={`absolute -top-1 -right-1 w-2.5 h-2.5 rounded-full ${
- item.is_due ? 'bg-red-500' : 'bg-amber-500'
- } animate-pulse`} />
- )}
- </div>
- {/* Content */}
- <div className="flex-1 min-w-0">
- <div className="flex items-center gap-2">
- <h3 className={`font-medium truncate ${item.enabled ? 'text-white' : 'text-bambu-gray'}`}>
- {item.maintenance_type_name}
- </h3>
- {intervalType === 'days' && (
- <span title="Time-based interval">
- <Calendar className="w-3.5 h-3.5 text-bambu-gray shrink-0" />
- </span>
- )}
- </div>
- {/* Progress bar */}
- <div className="mt-2 mb-1.5">
- <div className="w-full h-1.5 bg-bambu-dark rounded-full overflow-hidden">
- <div
- className={`h-full rounded-full transition-all duration-500 ${getProgressColor()}`}
- style={{ width: `${progressPercent}%` }}
- />
- </div>
- </div>
- {/* Status text */}
- <div className={`text-xs flex items-center gap-1 ${getStatusColor()}`}>
- {item.is_due && <AlertTriangle className="w-3 h-3" />}
- {item.is_warning && !item.is_due && <Clock className="w-3 h-3" />}
- {!item.is_due && !item.is_warning && item.enabled && <Check className="w-3 h-3" />}
- {getStatusText()}
- </div>
- </div>
- {/* Actions */}
- <div className="flex items-center gap-2 shrink-0">
- <Toggle
- checked={item.enabled}
- onChange={(checked) => onToggle(item.id, checked)}
- />
- <Button
- size="sm"
- variant={item.is_due ? 'primary' : 'secondary'}
- onClick={() => onPerform(item.id)}
- disabled={!item.enabled}
- className="!px-3"
- >
- <RotateCcw className="w-3.5 h-3.5" />
- Reset
- </Button>
- </div>
- </div>
- </div>
- );
- }
- // Printer section with improved visual hierarchy
- function PrinterSection({
- overview,
- onPerform,
- onToggle,
- onSetHours,
- }: {
- overview: PrinterMaintenanceOverview;
- onPerform: (id: number) => void;
- onToggle: (id: number, enabled: boolean) => void;
- onSetHours: (printerId: number, hours: number) => void;
- }) {
- const [expanded, setExpanded] = useState(true);
- const [editingHours, setEditingHours] = useState(false);
- const [hoursInput, setHoursInput] = useState(overview.total_print_hours.toFixed(1));
- const sortedItems = [...overview.maintenance_items].sort((a, b) => {
- // Sort by urgency first, then by type
- if (a.is_due && !b.is_due) return -1;
- if (!a.is_due && b.is_due) return 1;
- if (a.is_warning && !b.is_warning) return -1;
- if (!a.is_warning && b.is_warning) return 1;
- return a.maintenance_type_id - b.maintenance_type_id;
- });
- const nextTask = sortedItems.find(item => item.enabled && (item.is_due || item.is_warning));
- const handleSaveHours = () => {
- const hours = parseFloat(hoursInput);
- if (!isNaN(hours) && hours >= 0) {
- onSetHours(overview.printer_id, hours);
- setEditingHours(false);
- }
- };
- return (
- <Card className="overflow-hidden">
- {/* Header */}
- <div className="p-5">
- <div className="flex items-center justify-between">
- <div className="flex items-center gap-4">
- <h2 className="text-xl font-semibold text-white">{overview.printer_name}</h2>
- <div className="flex items-center gap-2">
- {overview.due_count > 0 && (
- <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">
- <AlertTriangle className="w-3 h-3" />
- {overview.due_count} overdue
- </span>
- )}
- {overview.warning_count > 0 && (
- <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">
- <Clock className="w-3 h-3" />
- {overview.warning_count} due soon
- </span>
- )}
- {overview.due_count === 0 && overview.warning_count === 0 && (
- <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">
- <Check className="w-3 h-3" />
- All good
- </span>
- )}
- </div>
- </div>
- <button
- onClick={() => setExpanded(!expanded)}
- 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"
- >
- {expanded ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
- {expanded ? 'Collapse' : 'Expand'}
- </button>
- </div>
- {/* Quick stats row */}
- <div className="flex items-center gap-6 mt-4">
- {/* Print Hours */}
- <div className="flex items-center gap-3">
- <div className="p-2 bg-bambu-dark/50 rounded-lg">
- <Timer className="w-4 h-4 text-bambu-gray" />
- </div>
- {editingHours ? (
- <div className="flex items-center gap-2">
- <input
- type="number"
- value={hoursInput}
- onChange={(e) => setHoursInput(e.target.value)}
- onKeyDown={(e) => {
- if (e.key === 'Enter') handleSaveHours();
- if (e.key === 'Escape') setEditingHours(false);
- }}
- className="w-24 px-2 py-1 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white text-sm"
- min="0"
- step="1"
- autoFocus
- />
- <span className="text-xs text-bambu-gray">hours</span>
- <Button size="sm" onClick={handleSaveHours}>Save</Button>
- <Button size="sm" variant="secondary" onClick={() => setEditingHours(false)}>Cancel</Button>
- </div>
- ) : (
- <button
- onClick={() => {
- setHoursInput(Math.round(overview.total_print_hours).toString());
- setEditingHours(true);
- }}
- className="group"
- >
- <div className="text-sm font-medium text-white group-hover:text-bambu-green transition-colors flex items-center gap-1">
- {Math.round(overview.total_print_hours)} hours
- <Edit3 className="w-3 h-3 text-bambu-gray group-hover:text-bambu-green" />
- </div>
- <div className="text-xs text-bambu-gray">Total print time</div>
- </button>
- )}
- </div>
- {/* Divider */}
- <div className="w-px h-10 bg-bambu-dark-tertiary" />
- {/* Next Maintenance */}
- {nextTask && (
- <div className="flex items-center gap-3">
- <div className={`p-2 rounded-lg ${
- nextTask.is_due ? 'bg-red-500/20' : 'bg-amber-500/20'
- }`}>
- {(() => {
- const Icon = getIcon(nextTask.maintenance_type_icon);
- return <Icon className={`w-4 h-4 ${nextTask.is_due ? 'text-red-400' : 'text-amber-400'}`} />;
- })()}
- </div>
- <div>
- <div className={`text-sm font-medium ${nextTask.is_due ? 'text-red-400' : 'text-amber-400'}`}>
- {nextTask.maintenance_type_name}
- </div>
- <div className={`text-xs ${nextTask.is_due ? 'text-red-400/70' : 'text-amber-400/70'}`}>
- {nextTask.is_due ? 'Overdue' : 'Due soon'}
- </div>
- </div>
- </div>
- )}
- </div>
- </div>
- {/* Maintenance items */}
- {expanded && (
- <CardContent className="pt-0 border-t border-bambu-dark-tertiary">
- <div className="grid grid-cols-1 lg:grid-cols-2 gap-3 pt-4">
- {sortedItems.map((item) => (
- <MaintenanceCard
- key={item.id}
- item={item}
- onPerform={onPerform}
- onToggle={onToggle}
- />
- ))}
- </div>
- </CardContent>
- )}
- </Card>
- );
- }
- // Settings section - maintenance types configuration
- function SettingsSection({
- overview,
- types,
- onUpdateInterval,
- onAddType,
- onUpdateType,
- onDeleteType,
- onAssignType,
- onRemoveItem,
- }: {
- overview: PrinterMaintenanceOverview[] | undefined;
- types: MaintenanceType[];
- onUpdateInterval: (id: number, data: { custom_interval_hours?: number | null; custom_interval_type?: 'hours' | 'days' | null }) => void;
- onAddType: (data: { name: string; description?: string; default_interval_hours: number; interval_type: 'hours' | 'days'; icon?: string }, printerIds: number[]) => void;
- onUpdateType: (id: number, data: { name?: string; default_interval_hours?: number; interval_type?: 'hours' | 'days'; icon?: string }) => void;
- onDeleteType: (id: number) => void;
- onAssignType: (printerId: number, typeId: number) => void;
- onRemoveItem: (itemId: number) => void;
- }) {
- const [editingInterval, setEditingInterval] = useState<number | null>(null);
- const [intervalInput, setIntervalInput] = useState('');
- const [intervalTypeInput, setIntervalTypeInput] = useState<'hours' | 'days'>('hours');
- const [showAddType, setShowAddType] = useState(false);
- const [newTypeName, setNewTypeName] = useState('');
- const [newTypeInterval, setNewTypeInterval] = useState('100');
- const [newTypeIntervalType, setNewTypeIntervalType] = useState<'hours' | 'days'>('hours');
- const [newTypeIcon, setNewTypeIcon] = useState('Wrench');
- const [selectedPrinters, setSelectedPrinters] = useState<Set<number>>(new Set());
- const [expandedType, setExpandedType] = useState<number | null>(null);
- // Get unique printers from overview
- const printers = useMemo(() => {
- if (!overview) return [];
- return overview.map(o => ({ id: o.printer_id, name: o.printer_name }));
- }, [overview]);
- // Get which printers have a specific maintenance type assigned
- const getAssignedPrinters = (typeId: number) => {
- if (!overview) return [];
- return overview
- .filter(p => p.maintenance_items.some(item => item.maintenance_type_id === typeId))
- .map(p => ({
- printerId: p.printer_id,
- printerName: p.printer_name,
- itemId: p.maintenance_items.find(item => item.maintenance_type_id === typeId)?.id,
- }));
- };
- // Get printers that DON'T have a specific type assigned
- const getUnassignedPrinters = (typeId: number) => {
- if (!overview) return [];
- const assignedIds = new Set(getAssignedPrinters(typeId).map(p => p.printerId));
- return printers.filter(p => !assignedIds.has(p.id));
- };
- // Edit type state
- const [editingType, setEditingType] = useState<MaintenanceType | null>(null);
- const [editTypeName, setEditTypeName] = useState('');
- const [editTypeInterval, setEditTypeInterval] = useState('');
- const [editTypeIntervalType, setEditTypeIntervalType] = useState<'hours' | 'days'>('hours');
- const [editTypeIcon, setEditTypeIcon] = useState('Wrench');
- const startEditType = (type: MaintenanceType) => {
- setEditingType(type);
- setEditTypeName(type.name);
- setEditTypeInterval(type.default_interval_hours.toString());
- setEditTypeIntervalType(type.interval_type || 'hours');
- setEditTypeIcon(type.icon || 'Wrench');
- };
- const handleSaveEditType = () => {
- if (editingType && editTypeName.trim() && parseFloat(editTypeInterval) > 0) {
- onUpdateType(editingType.id, {
- name: editTypeName.trim(),
- default_interval_hours: parseFloat(editTypeInterval),
- interval_type: editTypeIntervalType,
- icon: editTypeIcon,
- });
- setEditingType(null);
- }
- };
- const handleSaveInterval = (itemId: number, defaultInterval: number, defaultIntervalType: 'hours' | 'days') => {
- const newInterval = parseFloat(intervalInput);
- if (!isNaN(newInterval) && newInterval > 0) {
- const customInterval = Math.abs(newInterval - defaultInterval) < 0.01 ? null : newInterval;
- const customIntervalType = intervalTypeInput !== defaultIntervalType ? intervalTypeInput : null;
- onUpdateInterval(itemId, {
- custom_interval_hours: customInterval,
- custom_interval_type: customIntervalType
- });
- }
- setEditingInterval(null);
- };
- const handleAddType = (e: React.FormEvent) => {
- e.preventDefault();
- if (newTypeName.trim() && parseFloat(newTypeInterval) > 0 && selectedPrinters.size > 0) {
- onAddType({
- name: newTypeName.trim(),
- default_interval_hours: parseFloat(newTypeInterval),
- interval_type: newTypeIntervalType,
- icon: newTypeIcon,
- }, Array.from(selectedPrinters));
- setNewTypeName('');
- setNewTypeInterval('100');
- setNewTypeIntervalType('hours');
- setSelectedPrinters(new Set());
- setShowAddType(false);
- }
- };
- const togglePrinterSelection = (printerId: number) => {
- setSelectedPrinters(prev => {
- const next = new Set(prev);
- if (next.has(printerId)) {
- next.delete(printerId);
- } else {
- next.add(printerId);
- }
- return next;
- });
- };
- const printerItems = overview?.map(p => ({
- printerId: p.printer_id,
- printerName: p.printer_name,
- items: p.maintenance_items.sort((a, b) => a.maintenance_type_id - b.maintenance_type_id),
- })).sort((a, b) => a.printerName.localeCompare(b.printerName)) || [];
- const systemTypes = types.filter(t => t.is_system);
- const customTypes = types.filter(t => !t.is_system);
- return (
- <div className="space-y-8">
- {/* Maintenance Types */}
- <div>
- <div className="flex items-center justify-between mb-4">
- <div>
- <h2 className="text-lg font-semibold text-white">Maintenance Types</h2>
- <p className="text-sm text-bambu-gray mt-1">System types and your custom maintenance tasks</p>
- </div>
- <Button onClick={() => setShowAddType(!showAddType)}>
- <Plus className="w-4 h-4" />
- Add Custom Type
- </Button>
- </div>
- {/* Add custom type form */}
- {showAddType && (
- <Card className="mb-6">
- <CardContent className="py-4">
- <form onSubmit={handleAddType}>
- <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
- <div className="lg:col-span-2">
- <label className="block text-xs text-bambu-gray mb-1.5">Name</label>
- <input
- type="text"
- value={newTypeName}
- onChange={(e) => setNewTypeName(e.target.value)}
- 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"
- placeholder="e.g., Replace HEPA Filter"
- autoFocus
- />
- </div>
- <div>
- <label className="block text-xs text-bambu-gray mb-1.5">Interval Type</label>
- <select
- value={newTypeIntervalType}
- onChange={(e) => {
- setNewTypeIntervalType(e.target.value as 'hours' | 'days');
- // Set sensible default based on type
- if (e.target.value === 'days') {
- setNewTypeInterval('30');
- } else {
- setNewTypeInterval('100');
- }
- }}
- 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"
- >
- <option value="hours">Print Hours</option>
- <option value="days">Calendar Days</option>
- </select>
- </div>
- <div>
- <label className="block text-xs text-bambu-gray mb-1.5">
- Interval ({newTypeIntervalType === 'days' ? 'days' : 'hours'})
- </label>
- <input
- type="number"
- value={newTypeInterval}
- onChange={(e) => setNewTypeInterval(e.target.value)}
- 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"
- min="1"
- />
- </div>
- </div>
- <div className="mt-4 flex items-end justify-between">
- <div>
- <label className="block text-xs text-bambu-gray mb-1.5">Icon</label>
- <div className="flex gap-1">
- {Object.keys(iconMap).map((iconName) => {
- const IconComp = iconMap[iconName];
- return (
- <button
- key={iconName}
- type="button"
- onClick={() => setNewTypeIcon(iconName)}
- className={`p-2 rounded-lg transition-colors ${
- newTypeIcon === iconName
- ? 'bg-bambu-green text-white'
- : 'bg-bambu-dark text-bambu-gray hover:text-white hover:bg-bambu-dark-tertiary'
- }`}
- >
- <IconComp className="w-4 h-4" />
- </button>
- );
- })}
- </div>
- </div>
- </div>
- {/* Printer selection */}
- <div className="mt-4">
- <label className="block text-xs text-bambu-gray mb-1.5">Assign to Printers</label>
- <div className="flex flex-wrap gap-2">
- {printers.map(p => (
- <button
- key={p.id}
- type="button"
- onClick={() => togglePrinterSelection(p.id)}
- className={`px-3 py-1.5 rounded-lg text-sm transition-colors ${
- selectedPrinters.has(p.id)
- ? 'bg-bambu-green text-white'
- : 'bg-bambu-dark text-bambu-gray hover:text-white hover:bg-bambu-dark-tertiary'
- }`}
- >
- {p.name}
- </button>
- ))}
- </div>
- {selectedPrinters.size === 0 && (
- <p className="text-xs text-orange-400 mt-1">Select at least one printer</p>
- )}
- </div>
- <div className="mt-4 flex justify-end gap-2">
- <Button type="button" variant="secondary" onClick={() => { setShowAddType(false); setSelectedPrinters(new Set()); }}>
- Cancel
- </Button>
- <Button type="submit" disabled={!newTypeName.trim() || selectedPrinters.size === 0}>
- Add Type
- </Button>
- </div>
- </form>
- </CardContent>
- </Card>
- )}
- {/* Types grid */}
- <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
- {/* System types */}
- {systemTypes.map((type) => {
- const Icon = getIcon(type.icon);
- const intervalType = type.interval_type || 'hours';
- return (
- <div key={type.id} className="bg-bambu-dark-secondary rounded-xl p-4 border border-bambu-dark-tertiary">
- <div className="flex items-center gap-3">
- <div className="p-2.5 bg-bambu-dark rounded-lg">
- <Icon className="w-5 h-5 text-bambu-gray" />
- </div>
- <div className="flex-1 min-w-0">
- <div className="text-sm font-medium text-white truncate">{type.name}</div>
- <div className="text-xs text-bambu-gray mt-0.5 flex items-center gap-1">
- {intervalType === 'days' ? <Calendar className="w-3 h-3" /> : <Timer className="w-3 h-3" />}
- {formatIntervalLabel(type.default_interval_hours, intervalType)}
- </div>
- </div>
- </div>
- </div>
- );
- })}
- {/* Custom types */}
- {customTypes.map((type) => {
- const Icon = getIcon(type.icon);
- const intervalType = type.interval_type || 'hours';
- const isEditing = editingType?.id === type.id;
- if (isEditing) {
- return (
- <div key={type.id} className="bg-bambu-dark-secondary rounded-xl p-4 border border-bambu-green">
- <div className="space-y-3">
- <input
- type="text"
- value={editTypeName}
- onChange={(e) => setEditTypeName(e.target.value)}
- 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"
- placeholder="Name"
- autoFocus
- />
- <div className="flex gap-2">
- <select
- value={editTypeIntervalType}
- onChange={(e) => setEditTypeIntervalType(e.target.value as 'hours' | 'days')}
- 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"
- >
- <option value="hours">Print Hours</option>
- <option value="days">Calendar Days</option>
- </select>
- <input
- type="number"
- value={editTypeInterval}
- onChange={(e) => setEditTypeInterval(e.target.value)}
- 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"
- min="1"
- />
- </div>
- <div className="flex flex-wrap gap-1">
- {Object.keys(iconMap).map((iconName) => {
- const IconComp = iconMap[iconName];
- return (
- <button
- key={iconName}
- type="button"
- onClick={() => setEditTypeIcon(iconName)}
- className={`p-1.5 rounded transition-colors ${
- editTypeIcon === iconName
- ? 'bg-bambu-green text-white'
- : 'bg-bambu-dark text-bambu-gray hover:text-white'
- }`}
- >
- <IconComp className="w-3.5 h-3.5" />
- </button>
- );
- })}
- </div>
- <div className="flex gap-2">
- <Button size="sm" onClick={handleSaveEditType} disabled={!editTypeName.trim()}>
- Save
- </Button>
- <Button size="sm" variant="secondary" onClick={() => setEditingType(null)}>
- Cancel
- </Button>
- </div>
- </div>
- </div>
- );
- }
- const assignedPrinters = getAssignedPrinters(type.id);
- const unassignedPrinters = getUnassignedPrinters(type.id);
- const isExpanded = expandedType === type.id;
- return (
- <div key={type.id} className="bg-bambu-dark-secondary rounded-xl p-4 border border-bambu-green/30">
- <div className="flex items-center gap-3">
- <div className="p-2.5 bg-bambu-green/20 rounded-lg">
- <Icon className="w-5 h-5 text-bambu-green" />
- </div>
- <div className="flex-1 min-w-0">
- <div className="flex items-center gap-2">
- <span className="text-sm font-medium text-white truncate">{type.name}</span>
- <span className="px-1.5 py-0.5 bg-bambu-green/20 text-bambu-green text-[10px] font-medium rounded">
- Custom
- </span>
- </div>
- <div className="text-xs text-bambu-gray mt-0.5 flex items-center gap-1">
- {intervalType === 'days' ? <Calendar className="w-3 h-3" /> : <Timer className="w-3 h-3" />}
- {formatIntervalLabel(type.default_interval_hours, intervalType)}
- </div>
- </div>
- <button
- onClick={() => setExpandedType(isExpanded ? null : type.id)}
- className={`px-2 py-1 rounded-lg border transition-colors flex items-center gap-1 ${
- assignedPrinters.length > 0
- ? 'border-bambu-green/50 bg-bambu-green/10 text-bambu-green hover:bg-bambu-green/20'
- : 'border-orange-400/50 bg-orange-400/10 text-orange-400 hover:bg-orange-400/20'
- }`}
- title={`${assignedPrinters.length} printer(s) assigned - click to manage`}
- >
- <Printer className="w-3 h-3" />
- <span className="text-xs font-medium">{assignedPrinters.length}</span>
- <ChevronDown className={`w-3 h-3 transition-transform ${isExpanded ? 'rotate-180' : ''}`} />
- </button>
- <button
- onClick={() => startEditType(type)}
- className="p-2 rounded-lg hover:bg-bambu-dark text-bambu-gray hover:text-white transition-colors"
- >
- <Edit3 className="w-4 h-4" />
- </button>
- <button
- onClick={() => {
- if (confirm(`Delete "${type.name}"?`)) {
- onDeleteType(type.id);
- }
- }}
- className="p-2 rounded-lg hover:bg-bambu-dark text-bambu-gray hover:text-red-400 transition-colors"
- >
- <Trash2 className="w-4 h-4" />
- </button>
- </div>
- {/* Printer assignment management */}
- {isExpanded && (
- <div className="mt-3 pt-3 border-t border-bambu-dark-tertiary">
- <p className="text-xs text-bambu-gray mb-2">Assigned to printers:</p>
- {assignedPrinters.length === 0 ? (
- <p className="text-xs text-orange-400">No printers assigned</p>
- ) : (
- <div className="flex flex-wrap gap-1 mb-2">
- {assignedPrinters.map(p => (
- <span
- key={p.printerId}
- className="inline-flex items-center gap-1 px-2 py-1 bg-bambu-dark rounded text-xs text-white"
- >
- {p.printerName}
- <button
- onClick={() => p.itemId && onRemoveItem(p.itemId)}
- className="hover:text-red-400 ml-1"
- title="Remove from this printer"
- >
- ×
- </button>
- </span>
- ))}
- </div>
- )}
- {unassignedPrinters.length > 0 && (
- <div className="flex flex-wrap gap-1">
- <span className="text-xs text-bambu-gray mr-1">Add:</span>
- {unassignedPrinters.map(p => (
- <button
- key={p.id}
- onClick={() => onAssignType(p.id, type.id)}
- className="px-2 py-1 bg-bambu-dark hover:bg-bambu-green/20 rounded text-xs text-bambu-gray hover:text-bambu-green transition-colors"
- >
- + {p.name}
- </button>
- ))}
- </div>
- )}
- </div>
- )}
- </div>
- );
- })}
- </div>
- </div>
- {/* Per-printer interval overrides */}
- {printerItems.length > 0 && (
- <div>
- <div className="mb-4">
- <h2 className="text-lg font-semibold text-white">Interval Overrides</h2>
- <p className="text-sm text-bambu-gray mt-1">Customize intervals for specific printers</p>
- </div>
- <div className="space-y-4">
- {printerItems.map((printer) => (
- <Card key={printer.printerId}>
- <CardContent className="py-4">
- <h3 className="text-sm font-medium text-white mb-3">{printer.printerName}</h3>
- <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-2">
- {printer.items.map((item) => {
- const Icon = getIcon(item.maintenance_type_icon);
- const typeInfo = types.find(t => t.id === item.maintenance_type_id);
- const defaultInterval = typeInfo?.default_interval_hours || item.interval_hours;
- const defaultIntervalType = typeInfo?.interval_type || 'hours';
- const intervalType = item.interval_type || 'hours';
- const isEditing = editingInterval === item.id;
- return (
- <div key={item.id} className="flex items-center gap-2 p-2.5 bg-bambu-dark rounded-lg">
- <Icon className="w-4 h-4 text-bambu-gray shrink-0" />
- <span className="text-xs text-bambu-gray flex-1 truncate">{item.maintenance_type_name}</span>
- {isEditing ? (
- <div className="flex items-center gap-1">
- {intervalTypeInput === 'days' ? (
- <Calendar className="w-3.5 h-3.5 text-bambu-gray shrink-0" />
- ) : (
- <Timer className="w-3.5 h-3.5 text-bambu-gray shrink-0" />
- )}
- <select
- value={intervalTypeInput}
- onChange={(e) => setIntervalTypeInput(e.target.value as 'hours' | 'days')}
- className="px-1.5 py-1 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-white text-xs"
- >
- <option value="hours">Print Hours</option>
- <option value="days">Calendar Days</option>
- </select>
- <input
- type="number"
- value={intervalInput}
- onChange={(e) => setIntervalInput(e.target.value)}
- onKeyDown={(e) => {
- if (e.key === 'Enter') handleSaveInterval(item.id, defaultInterval, defaultIntervalType);
- if (e.key === 'Escape') setEditingInterval(null);
- }}
- className="w-16 px-2 py-1 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-white text-xs"
- min="1"
- />
- <Button size="sm" onClick={() => handleSaveInterval(item.id, defaultInterval, defaultIntervalType)}>OK</Button>
- </div>
- ) : (
- <button
- onClick={() => {
- setEditingInterval(item.id);
- setIntervalInput(item.interval_hours.toString());
- setIntervalTypeInput(intervalType);
- }}
- className="px-2 py-1 bg-bambu-dark-tertiary hover:bg-bambu-dark-secondary border border-bambu-dark-tertiary hover:border-bambu-green rounded text-xs font-medium text-white transition-colors flex items-center gap-1"
- >
- {intervalType === 'days' ? <Calendar className="w-3 h-3" /> : <Timer className="w-3 h-3" />}
- {formatIntervalLabel(item.interval_hours, intervalType)}
- <Edit3 className="w-3 h-3 text-bambu-gray" />
- </button>
- )}
- </div>
- );
- })}
- </div>
- </CardContent>
- </Card>
- ))}
- </div>
- </div>
- )}
- {printerItems.length === 0 && (
- <Card>
- <CardContent className="text-center py-12">
- <Clock className="w-12 h-12 mx-auto mb-4 text-bambu-gray/30" />
- <p className="text-bambu-gray">No printers configured</p>
- <p className="text-sm text-bambu-gray/70 mt-1">
- Add printers to configure maintenance intervals
- </p>
- </CardContent>
- </Card>
- )}
- </div>
- );
- }
- type TabType = 'status' | 'settings';
- export function MaintenancePage() {
- const queryClient = useQueryClient();
- const { showToast } = useToast();
- const [activeTab, setActiveTab] = useState<TabType>('status');
- const { data: overview, isLoading } = useQuery({
- queryKey: ['maintenanceOverview'],
- queryFn: api.getMaintenanceOverview,
- });
- const { data: types } = useQuery({
- queryKey: ['maintenanceTypes'],
- queryFn: api.getMaintenanceTypes,
- });
- const performMutation = useMutation({
- mutationFn: ({ id, notes }: { id: number; notes?: string }) =>
- api.performMaintenance(id, notes),
- onSuccess: () => {
- queryClient.invalidateQueries({ queryKey: ['maintenanceOverview'] });
- queryClient.invalidateQueries({ queryKey: ['maintenanceSummary'] });
- showToast('Maintenance marked as complete');
- },
- onError: (error: Error) => {
- showToast(error.message, 'error');
- },
- });
- const updateMutation = useMutation({
- mutationFn: ({ id, data }: { id: number; data: { custom_interval_hours?: number | null; custom_interval_type?: 'hours' | 'days' | null; enabled?: boolean } }) =>
- api.updateMaintenanceItem(id, data),
- onSuccess: () => {
- queryClient.invalidateQueries({ queryKey: ['maintenanceOverview'] });
- },
- onError: (error: Error) => {
- showToast(error.message, 'error');
- },
- });
- // addTypeMutation removed - we now handle type creation with printer assignment
- // directly in onAddType callback
- const updateTypeMutation = useMutation({
- mutationFn: ({ id, data }: { id: number; data: Partial<{ name: string; default_interval_hours: number; interval_type: 'hours' | 'days'; icon: string }> }) =>
- api.updateMaintenanceType(id, data),
- onSuccess: () => {
- queryClient.invalidateQueries({ queryKey: ['maintenanceTypes'] });
- queryClient.invalidateQueries({ queryKey: ['maintenanceOverview'] });
- showToast('Maintenance type updated');
- },
- onError: (error: Error) => {
- showToast(error.message, 'error');
- },
- });
- const deleteTypeMutation = useMutation({
- mutationFn: api.deleteMaintenanceType,
- onSuccess: () => {
- queryClient.invalidateQueries({ queryKey: ['maintenanceTypes'] });
- queryClient.invalidateQueries({ queryKey: ['maintenanceOverview'] });
- showToast('Maintenance type deleted');
- },
- onError: (error: Error) => {
- showToast(error.message, 'error');
- },
- });
- const setHoursMutation = useMutation({
- mutationFn: ({ printerId, hours }: { printerId: number; hours: number }) =>
- api.setPrinterHours(printerId, hours),
- onSuccess: () => {
- queryClient.invalidateQueries({ queryKey: ['maintenanceOverview'] });
- queryClient.invalidateQueries({ queryKey: ['maintenanceSummary'] });
- showToast('Print hours updated');
- },
- onError: (error: Error) => {
- showToast(error.message, 'error');
- },
- });
- const assignTypeMutation = useMutation({
- mutationFn: ({ printerId, typeId }: { printerId: number; typeId: number }) =>
- api.assignMaintenanceType(printerId, typeId),
- onSuccess: () => {
- queryClient.invalidateQueries({ queryKey: ['maintenanceOverview'] });
- showToast('Printer assigned');
- },
- onError: (error: Error) => {
- showToast(error.message, 'error');
- },
- });
- const removeItemMutation = useMutation({
- mutationFn: api.removeMaintenanceItem,
- onSuccess: () => {
- queryClient.invalidateQueries({ queryKey: ['maintenanceOverview'] });
- showToast('Printer removed');
- },
- onError: (error: Error) => {
- showToast(error.message, 'error');
- },
- });
- const handlePerform = (id: number) => {
- performMutation.mutate({ id });
- };
- const handleToggle = (id: number, enabled: boolean) => {
- updateMutation.mutate({ id, data: { enabled } });
- };
- const handleSetHours = (printerId: number, hours: number) => {
- setHoursMutation.mutate({ printerId, hours });
- };
- if (isLoading) {
- return (
- <div className="p-4 md:p-8 flex justify-center">
- <Loader2 className="w-8 h-8 text-bambu-green animate-spin" />
- </div>
- );
- }
- const totalDue = overview?.reduce((sum, p) => sum + p.due_count, 0) || 0;
- const totalWarning = overview?.reduce((sum, p) => sum + p.warning_count, 0) || 0;
- return (
- <div className="p-4 md:p-8">
- {/* Header */}
- <div className="mb-6">
- <h1 className="text-2xl font-bold text-white">Maintenance</h1>
- <p className="text-bambu-gray text-sm mt-1">
- {activeTab === 'status' ? (
- <>
- {totalDue > 0 && <span className="text-red-400">{totalDue} task{totalDue !== 1 ? 's' : ''} overdue</span>}
- {totalDue > 0 && totalWarning > 0 && ' · '}
- {totalWarning > 0 && <span className="text-amber-400">{totalWarning} due soon</span>}
- {totalDue === 0 && totalWarning === 0 && <span className="text-bambu-green">All maintenance up to date</span>}
- </>
- ) : (
- 'Configure maintenance types and intervals'
- )}
- </p>
- </div>
- {/* Tabs */}
- <div className="flex gap-1 mb-6 border-b border-bambu-dark-tertiary">
- <button
- onClick={() => setActiveTab('status')}
- className={`px-4 py-2 text-sm font-medium border-b-2 -mb-px transition-colors ${
- activeTab === 'status'
- ? 'text-bambu-green border-bambu-green'
- : 'text-bambu-gray border-transparent hover:text-white'
- }`}
- >
- Status
- </button>
- <button
- onClick={() => setActiveTab('settings')}
- className={`px-4 py-2 text-sm font-medium border-b-2 -mb-px transition-colors ${
- activeTab === 'settings'
- ? 'text-bambu-green border-bambu-green'
- : 'text-bambu-gray border-transparent hover:text-white'
- }`}
- >
- Settings
- </button>
- </div>
- {/* Tab content */}
- {activeTab === 'status' ? (
- <div className="space-y-6">
- {overview && overview.length > 0 ? (
- [...overview].sort((a, b) => {
- // Sort printers with issues first
- const aScore = a.due_count * 10 + a.warning_count;
- const bScore = b.due_count * 10 + b.warning_count;
- if (aScore !== bScore) return bScore - aScore;
- return a.printer_name.localeCompare(b.printer_name);
- }).map((printerOverview) => (
- <PrinterSection
- key={printerOverview.printer_id}
- overview={printerOverview}
- onPerform={handlePerform}
- onToggle={handleToggle}
- onSetHours={handleSetHours}
- />
- ))
- ) : (
- <Card>
- <CardContent className="text-center py-16">
- <Wrench className="w-16 h-16 mx-auto mb-4 text-bambu-gray/30" />
- <p className="text-lg font-medium text-white mb-2">No printers configured</p>
- <p className="text-bambu-gray">Add printers to start tracking maintenance</p>
- </CardContent>
- </Card>
- )}
- </div>
- ) : (
- <SettingsSection
- overview={overview}
- types={types || []}
- onUpdateInterval={(id, data) =>
- updateMutation.mutate({ id, data })
- }
- onAddType={async (data, printerIds) => {
- // Create the type first, then assign to selected printers
- const newType = await api.createMaintenanceType(data);
- // Assign to each selected printer
- for (const printerId of printerIds) {
- await api.assignMaintenanceType(printerId, newType.id);
- }
- queryClient.invalidateQueries({ queryKey: ['maintenanceTypes'] });
- queryClient.invalidateQueries({ queryKey: ['maintenanceOverview'] });
- showToast('Maintenance type added');
- }}
- onUpdateType={(id, data) => updateTypeMutation.mutate({ id, data })}
- onDeleteType={(id) => deleteTypeMutation.mutate(id)}
- onAssignType={(printerId, typeId) => assignTypeMutation.mutate({ printerId, typeId })}
- onRemoveItem={(itemId) => removeItemMutation.mutate(itemId)}
- />
- )}
- </div>
- );
- }
|