Refactored maintenance settings

This commit is contained in:
Martin Ziegler
2025-12-01 15:41:41 +01:00
parent 6de875582a
commit 8df273b821
5 changed files with 393 additions and 236 deletions
+302 -145
View File
@@ -8,10 +8,8 @@ import {
Clock,
Plus,
Trash2,
Settings2,
ChevronDown,
ChevronUp,
RotateCcw,
Droplet,
Flame,
Ruler,
@@ -19,6 +17,7 @@ import {
Square,
Cable,
Edit3,
RotateCcw,
} from 'lucide-react';
import { api } from '../api/client';
import type { MaintenanceStatus, PrinterMaintenanceOverview } from '../api/client';
@@ -334,134 +333,266 @@ function PrinterSection({
);
}
// Settings modal for managing custom types
function SettingsModal({
onClose,
// Settings section component - maintenance types and per-printer interval overrides
function SettingsSection({
overview,
types,
onUpdateInterval,
onAddType,
onDeleteType,
}: {
onClose: () => void;
overview: PrinterMaintenanceOverview[] | undefined;
types: Array<{ id: number; name: string; default_interval_hours: number; icon: string | null; is_system: boolean }>;
onUpdateInterval: (id: number, customInterval: number | null) => void;
onAddType: (data: { name: string; description?: string; default_interval_hours: number; icon?: string }) => void;
onDeleteType: (id: number) => void;
}) {
const [name, setName] = useState('');
const [interval, setInterval] = useState('100');
const [icon, setIcon] = useState('Wrench');
const [editingInterval, setEditingInterval] = useState<number | null>(null);
const [intervalInput, setIntervalInput] = useState('');
const [showAddType, setShowAddType] = useState(false);
const [newTypeName, setNewTypeName] = useState('');
const [newTypeInterval, setNewTypeInterval] = useState('100');
const [newTypeIcon, setNewTypeIcon] = useState('Wrench');
const handleSubmit = (e: React.FormEvent) => {
const handleSaveInterval = (itemId: number, defaultInterval: number) => {
const newInterval = parseFloat(intervalInput);
if (!isNaN(newInterval) && newInterval > 0) {
const customInterval = Math.abs(newInterval - defaultInterval) < 0.01 ? null : newInterval;
onUpdateInterval(itemId, customInterval);
}
setEditingInterval(null);
};
const handleAddType = (e: React.FormEvent) => {
e.preventDefault();
if (name.trim() && parseFloat(interval) > 0) {
if (newTypeName.trim() && parseFloat(newTypeInterval) > 0) {
onAddType({
name: name.trim(),
default_interval_hours: parseFloat(interval),
icon,
name: newTypeName.trim(),
default_interval_hours: parseFloat(newTypeInterval),
icon: newTypeIcon,
});
setName('');
setInterval('100');
setNewTypeName('');
setNewTypeInterval('100');
setShowAddType(false);
}
};
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="fixed inset-0 bg-black/50 flex items-center justify-center z-50" onClick={onClose}>
<div className="bg-bambu-dark-secondary rounded-lg p-6 w-full max-w-lg" onClick={e => e.stopPropagation()}>
<h3 className="text-lg font-semibold text-white mb-4">Maintenance Settings</h3>
{/* Existing custom types */}
{customTypes.length > 0 && (
<div className="mb-6">
<h4 className="text-sm text-bambu-gray mb-2">Custom Maintenance Types</h4>
<div className="space-y-2">
{customTypes.map((type) => {
const Icon = getIcon(type.icon);
return (
<div key={type.id} className="flex items-center justify-between p-2 bg-bambu-dark rounded">
<div className="flex items-center gap-2">
<Icon className="w-4 h-4 text-bambu-gray" />
<span className="text-white text-sm">{type.name}</span>
<span className="text-bambu-gray text-xs">({type.default_interval_hours}h)</span>
</div>
<button
onClick={() => {
if (confirm(`Delete "${type.name}"?`)) {
onDeleteType(type.id);
}
}}
className="p-1 rounded hover:bg-bambu-dark-tertiary text-bambu-gray hover:text-red-400"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
);
})}
</div>
</div>
)}
{/* Add new type */}
<form onSubmit={handleSubmit}>
<h4 className="text-sm text-bambu-gray mb-2">Add Custom Type</h4>
<div className="flex gap-2 mb-3">
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
className="flex-1 px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white text-sm"
placeholder="Name (e.g., Replace HEPA Filter)"
/>
<input
type="number"
value={interval}
onChange={(e) => setInterval(e.target.value)}
className="w-20 px-2 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white text-sm"
placeholder="Hours"
min="1"
/>
</div>
<div className="flex items-center justify-between">
<div className="flex gap-1">
{Object.keys(iconMap).map((iconName) => {
const IconComp = iconMap[iconName];
return (
<button
key={iconName}
type="button"
onClick={() => setIcon(iconName)}
className={`p-1.5 rounded ${
icon === iconName
? 'bg-bambu-green text-white'
: 'bg-bambu-dark text-bambu-gray hover:text-white'
}`}
>
<IconComp className="w-4 h-4" />
</button>
);
})}
</div>
<Button type="submit" size="sm" disabled={!name.trim()}>
<Plus className="w-4 h-4" />
Add
</Button>
</div>
</form>
<div className="mt-6 pt-4 border-t border-bambu-dark-tertiary flex justify-end">
<Button variant="secondary" onClick={onClose}>
Close
<div className="space-y-8">
{/* Maintenance Types */}
<div>
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-white">Maintenance Types</h2>
<Button size="sm" variant="secondary" onClick={() => setShowAddType(!showAddType)}>
<Plus className="w-4 h-4" />
Add Custom Type
</Button>
</div>
{/* Add custom type form */}
{showAddType && (
<Card className="mb-4">
<div className="p-4">
<form onSubmit={handleAddType}>
<div className="flex gap-3 items-end">
<div className="flex-1">
<label className="block text-xs text-bambu-gray mb-1">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 text-white text-sm"
placeholder="e.g., Replace HEPA Filter"
autoFocus
/>
</div>
<div className="w-28">
<label className="block text-xs text-bambu-gray mb-1">Interval (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 text-white text-sm"
min="1"
/>
</div>
<div>
<label className="block text-xs text-bambu-gray mb-1">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 ${
newTypeIcon === iconName
? 'bg-bambu-green text-white'
: 'bg-bambu-dark-tertiary text-bambu-gray hover:text-white'
}`}
>
<IconComp className="w-4 h-4" />
</button>
);
})}
</div>
</div>
<div className="flex gap-2">
<Button type="submit" size="sm" disabled={!newTypeName.trim()}>Add</Button>
<Button type="button" size="sm" variant="secondary" onClick={() => setShowAddType(false)}>Cancel</Button>
</div>
</div>
</form>
</div>
</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);
return (
<div key={type.id} className="bg-bambu-dark-secondary rounded-lg p-4 border border-bambu-dark-tertiary">
<div className="flex items-center gap-3">
<div className="p-2 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">{type.default_interval_hours}h interval</div>
</div>
</div>
</div>
);
})}
{/* Custom types */}
{customTypes.map((type) => {
const Icon = getIcon(type.icon);
return (
<div key={type.id} className="bg-bambu-dark-secondary rounded-lg p-4 border border-bambu-green/30">
<div className="flex items-center gap-3">
<div className="p-2 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">{type.default_interval_hours}h interval</div>
</div>
<button
onClick={() => {
if (confirm(`Delete "${type.name}"?`)) {
onDeleteType(type.id);
}
}}
className="p-1.5 rounded hover:bg-bambu-dark text-bambu-gray hover:text-red-400 transition-colors"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
);
})}
</div>
</div>
{/* Per-printer interval overrides */}
{printerItems.length > 0 && (
<div>
<h2 className="text-lg font-semibold text-white mb-2">Interval Overrides</h2>
<p className="text-sm text-bambu-gray mb-4">
Set custom intervals per printer.
</p>
<div className="space-y-3">
{printerItems.map((printer) => (
<Card key={printer.printerId}>
<div className="p-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 isEditing = editingInterval === item.id;
return (
<div key={item.id} className="flex items-center gap-2 p-2 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">
<input
type="number"
value={intervalInput}
onChange={(e) => setIntervalInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleSaveInterval(item.id, defaultInterval);
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"
autoFocus
/>
<Button size="sm" onClick={() => handleSaveInterval(item.id, defaultInterval)}>OK</Button>
</div>
) : (
<button
onClick={() => {
setEditingInterval(item.id);
setIntervalInput(item.interval_hours.toString());
}}
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"
>
{item.interval_hours}h
<Edit3 className="w-3 h-3 inline ml-1.5 text-bambu-gray" />
</button>
)}
</div>
);
})}
</div>
</div>
</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 [showSettings, setShowSettings] = useState(false);
const [activeTab, setActiveTab] = useState<TabType>('status');
const { data: overview, isLoading } = useQuery({
queryKey: ['maintenanceOverview'],
@@ -561,52 +692,78 @@ export function MaintenancePage() {
return (
<div className="p-8">
{/* Header */}
<div className="mb-6 flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-white">Maintenance</h1>
<p className="text-bambu-gray text-sm">
{totalDue > 0 && <span className="text-red-400">{totalDue} tasks overdue</span>}
{totalDue > 0 && totalWarning > 0 && ' · '}
{totalWarning > 0 && <span className="text-yellow-400">{totalWarning} due soon</span>}
{totalDue === 0 && totalWarning === 0 && 'All maintenance up to date'}
</p>
</div>
<Button variant="secondary" onClick={() => setShowSettings(true)}>
<Settings2 className="w-4 h-4" />
<div className="mb-6">
<h1 className="text-2xl font-bold text-white">Maintenance</h1>
<p className="text-bambu-gray text-sm">
{activeTab === 'status' ? (
<>
{totalDue > 0 && <span className="text-red-400">{totalDue} tasks overdue</span>}
{totalDue > 0 && totalWarning > 0 && ' · '}
{totalWarning > 0 && <span className="text-yellow-400">{totalWarning} due soon</span>}
{totalDue === 0 && totalWarning === 0 && 'All maintenance up to date'}
</>
) : (
'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>
</button>
</div>
{/* Printers - sorted alphabetically */}
<div className="space-y-4">
{overview && overview.length > 0 ? (
[...overview].sort((a, b) => 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-12">
<Wrench 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 start tracking maintenance
</p>
</CardContent>
</Card>
)}
</div>
{/* Settings modal */}
{showSettings && types && (
<SettingsModal
onClose={() => setShowSettings(false)}
types={types}
{/* Tab content */}
{activeTab === 'status' ? (
<div className="space-y-4">
{overview && overview.length > 0 ? (
[...overview].sort((a, b) => 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-12">
<Wrench 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 start tracking maintenance
</p>
</CardContent>
</Card>
)}
</div>
) : (
<SettingsSection
overview={overview}
types={types || []}
onUpdateInterval={(id, customInterval) =>
updateMutation.mutate({ id, data: { custom_interval_hours: customInterval } })
}
onAddType={(data) => addTypeMutation.mutate(data)}
onDeleteType={(id) => deleteTypeMutation.mutate(id)}
/>
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -7,8 +7,8 @@
<link rel="icon" type="image/png" sizes="32x32" href="/img/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/img/favicon-16x16.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/img/apple-touch-icon.png" />
<script type="module" crossorigin src="/assets/index-Bi9VwPck.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CJM6Rz8E.css">
<script type="module" crossorigin src="/assets/index-eHK2TMOH.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BF_tKqob.css">
</head>
<body>
<div id="root"></div>