mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-18 21:47:39 +02:00
refactor(settings): Spool Catalog now identical in internal and Spoolman modes
Drop the Spoolman-mode hijack that replaced the local spool tare catalog
with an inline filament editor — two unrelated concepts that should never
have shared a card. Spool Catalog now renders the same way in both modes;
Spoolman users edit filament name and spool_weight in Spoolman's own UI.
Also eliminates the GET /spoolman/inventory/filaments 400 probe that fired
on the Filament settings page whenever Spoolman was disabled.
- frontend/src/components/SpoolCatalogSettings.tsx rewritten (752 -> 444 lines)
- frontend/src/components/SpoolWeightUpdateModal.tsx deleted (orphan)
- SpoolCatalogSettings test file rewritten to match the simplified component
- PATCH /spoolman/inventory/filaments/{id} backend route left in place
This commit is contained in:
@@ -1,34 +1,20 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Database, Plus, Trash2, RotateCcw, Loader2, Pencil, Check, X, Search, Download, Upload } from 'lucide-react';
|
||||
import { api, ApiError } from '../api/client';
|
||||
import type { SpoolCatalogEntry, SpoolmanFilamentEntry } from '../api/client';
|
||||
import { api } from '../api/client';
|
||||
import type { SpoolCatalogEntry } from '../api/client';
|
||||
import { useToast } from '../contexts/ToastContext';
|
||||
import { Card, CardHeader, CardContent } from './Card';
|
||||
import { ConfirmModal } from './ConfirmModal';
|
||||
import { SpoolWeightUpdateModal } from './SpoolWeightUpdateModal';
|
||||
|
||||
export function SpoolCatalogSettings() {
|
||||
const { t } = useTranslation();
|
||||
const { showToast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
const [catalog, setCatalog] = useState<SpoolCatalogEntry[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [search, setSearch] = useState('');
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Spoolman inline-edit state
|
||||
const [editingFilamentId, setEditingFilamentId] = useState<number | null>(null);
|
||||
const [editingFilamentName, setEditingFilamentName] = useState('');
|
||||
const [editingFilamentWeight, setEditingFilamentWeight] = useState('');
|
||||
const [pendingWeightEdit, setPendingWeightEdit] = useState<{
|
||||
filamentId: number;
|
||||
name: string;
|
||||
oldWeight: number | null;
|
||||
newWeight: number;
|
||||
} | null>(null);
|
||||
|
||||
// Add/Edit form state
|
||||
const [showAddForm, setShowAddForm] = useState(false);
|
||||
const [editingId, setEditingId] = useState<number | null>(null);
|
||||
@@ -44,86 +30,6 @@ export function SpoolCatalogSettings() {
|
||||
const [deleteEntry, setDeleteEntry] = useState<SpoolCatalogEntry | null>(null);
|
||||
const [showResetConfirm, setShowResetConfirm] = useState(false);
|
||||
|
||||
// Spoolman filament query — hoisted to determine display mode
|
||||
const {
|
||||
data: spoolmanFilaments,
|
||||
isLoading: spoolmanLoading,
|
||||
error: spoolmanError,
|
||||
} = useQuery<SpoolmanFilamentEntry[], Error>({
|
||||
queryKey: ['spoolman-inventory-filaments'],
|
||||
queryFn: () => api.getSpoolmanInventoryFilaments(),
|
||||
retry: false, // Spoolman may be intentionally disabled (400) — don't retry
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
// 400 = Spoolman explicitly disabled; all other states (data / 503 / …) mean Spoolman mode
|
||||
const isSpoolmanDisabled =
|
||||
!spoolmanLoading &&
|
||||
spoolmanError instanceof ApiError &&
|
||||
spoolmanError.status === 400;
|
||||
const isSpoolmanMode = !spoolmanLoading && !isSpoolmanDisabled;
|
||||
|
||||
const patchFilamentMutation = useMutation<
|
||||
SpoolmanFilamentEntry,
|
||||
Error,
|
||||
{ filamentId: number; data: { name?: string; spool_weight?: number | null; keep_existing_spools?: boolean } }
|
||||
>({
|
||||
mutationFn: ({ filamentId, data }) => api.patchSpoolmanFilament(filamentId, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['spoolman-inventory-filaments'] });
|
||||
showToast(t('settings.catalog.filamentUpdated'), 'success');
|
||||
setEditingFilamentId(null);
|
||||
setEditingFilamentName('');
|
||||
setEditingFilamentWeight('');
|
||||
setPendingWeightEdit(null);
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
if (error instanceof ApiError && error.status === 503) {
|
||||
showToast(t('inventory.spoolmanUnreachable'), 'error');
|
||||
} else if (error instanceof ApiError && error.status === 422) {
|
||||
showToast(t('settings.catalog.filamentUpdateInvalid'), 'error');
|
||||
} else {
|
||||
showToast(t('settings.catalog.filamentUpdateFailed'), 'error');
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const handleFilamentSave = (f: SpoolmanFilamentEntry) => {
|
||||
const nameChanged = editingFilamentName.trim() !== f.name;
|
||||
const weightChanged = editingFilamentWeight !== '' && editingFilamentWeight !== String(f.spool_weight ?? '');
|
||||
const parsedWeight = editingFilamentWeight !== '' ? parseFloat(editingFilamentWeight) : null;
|
||||
|
||||
if (weightChanged && parsedWeight !== null && !isNaN(parsedWeight) && parsedWeight > 0) {
|
||||
setPendingWeightEdit({
|
||||
filamentId: f.id,
|
||||
name: nameChanged ? editingFilamentName.trim() : f.name,
|
||||
oldWeight: f.spool_weight,
|
||||
newWeight: parsedWeight,
|
||||
});
|
||||
} else {
|
||||
const data: { name?: string } = {};
|
||||
if (nameChanged && editingFilamentName.trim()) data.name = editingFilamentName.trim();
|
||||
if (Object.keys(data).length > 0) {
|
||||
patchFilamentMutation.mutate({ filamentId: f.id, data });
|
||||
} else {
|
||||
setEditingFilamentId(null);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleWeightModalConfirm = (keepExisting: boolean) => {
|
||||
if (!pendingWeightEdit) return;
|
||||
const { filamentId, name, newWeight } = pendingWeightEdit;
|
||||
const currentFilament = spoolmanFilaments?.find(f => f.id === filamentId);
|
||||
const nameChanged = currentFilament && name !== currentFilament.name;
|
||||
const data: { name?: string; spool_weight: number; keep_existing_spools: boolean } = {
|
||||
spool_weight: newWeight,
|
||||
keep_existing_spools: keepExisting,
|
||||
};
|
||||
if (nameChanged) data.name = name;
|
||||
patchFilamentMutation.mutate({ filamentId, data });
|
||||
};
|
||||
|
||||
const loadCatalog = useCallback(async () => {
|
||||
try {
|
||||
const entries = await api.getSpoolCatalog();
|
||||
@@ -144,11 +50,6 @@ export function SpoolCatalogSettings() {
|
||||
entry.name.toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
|
||||
const filteredSpoolmanFilaments = (spoolmanFilaments ?? []).filter(f =>
|
||||
f.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||
(f.vendor?.name ?? '').toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
|
||||
const handleAdd = async () => {
|
||||
if (!formName.trim() || !formWeight) {
|
||||
showToast(t('settings.catalog.nameWeightRequired'), 'error');
|
||||
@@ -313,55 +214,47 @@ export function SpoolCatalogSettings() {
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Database className="w-5 h-5 text-bambu-gray" />
|
||||
<h2 className="text-lg font-semibold text-white">
|
||||
{isSpoolmanMode
|
||||
? t('settings.spoolmanFilamentCatalogTitle')
|
||||
: t('settings.catalog.spoolCatalog')}
|
||||
{t('settings.catalog.spoolCatalog')}
|
||||
</h2>
|
||||
<span className="text-sm text-bambu-gray">
|
||||
({spoolmanLoading ? '…' : isSpoolmanMode ? (spoolmanFilaments?.length ?? 0) : catalog.length})
|
||||
</span>
|
||||
<span className="text-sm text-bambu-gray">({catalog.length})</span>
|
||||
</div>
|
||||
|
||||
{/* CRUD buttons — local mode only */}
|
||||
{!isSpoolmanMode && !spoolmanLoading && (
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<button
|
||||
onClick={handleExport}
|
||||
className="px-3 py-1.5 text-sm bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-bambu-gray hover:text-white transition-colors flex items-center gap-1.5"
|
||||
title={t('settings.catalog.exportTooltip')}
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">{t('common.export')}</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="px-3 py-1.5 text-sm bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-bambu-gray hover:text-white transition-colors flex items-center gap-1.5"
|
||||
title={t('settings.catalog.importTooltip')}
|
||||
>
|
||||
<Upload className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">{t('common.import')}</span>
|
||||
</button>
|
||||
<input ref={fileInputRef} type="file" accept=".json" className="hidden" onChange={handleImport} />
|
||||
<button
|
||||
onClick={() => setShowResetConfirm(true)}
|
||||
className="px-3 py-1.5 text-sm bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-bambu-gray hover:text-white transition-colors flex items-center gap-1.5"
|
||||
title={t('settings.catalog.resetTooltip')}
|
||||
>
|
||||
<RotateCcw className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">{t('common.reset')}</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowAddForm(true)}
|
||||
className="px-3 py-1.5 text-sm bg-bambu-green text-white rounded-lg hover:bg-bambu-green/80 transition-colors flex items-center gap-1.5"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">{t('common.add')}</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<button
|
||||
onClick={handleExport}
|
||||
className="px-3 py-1.5 text-sm bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-bambu-gray hover:text-white transition-colors flex items-center gap-1.5"
|
||||
title={t('settings.catalog.exportTooltip')}
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">{t('common.export')}</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="px-3 py-1.5 text-sm bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-bambu-gray hover:text-white transition-colors flex items-center gap-1.5"
|
||||
title={t('settings.catalog.importTooltip')}
|
||||
>
|
||||
<Upload className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">{t('common.import')}</span>
|
||||
</button>
|
||||
<input ref={fileInputRef} type="file" accept=".json" className="hidden" onChange={handleImport} />
|
||||
<button
|
||||
onClick={() => setShowResetConfirm(true)}
|
||||
className="px-3 py-1.5 text-sm bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-bambu-gray hover:text-white transition-colors flex items-center gap-1.5"
|
||||
title={t('settings.catalog.resetTooltip')}
|
||||
>
|
||||
<RotateCcw className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">{t('common.reset')}</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowAddForm(true)}
|
||||
className="px-3 py-1.5 text-sm bg-bambu-green text-white rounded-lg hover:bg-bambu-green/80 transition-colors flex items-center gap-1.5"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">{t('common.add')}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Bulk-delete bar — local mode only */}
|
||||
{!isSpoolmanMode && selectedIds.size > 0 && (
|
||||
{selectedIds.size > 0 && (
|
||||
<div className="flex items-center gap-2 mt-2 px-3 py-2 bg-red-500/10 border border-red-500/30 rounded-lg">
|
||||
<span className="text-sm text-red-400">
|
||||
{t('settings.catalog.selectedCount', { count: selectedIds.size })}
|
||||
@@ -384,14 +277,10 @@ export function SpoolCatalogSettings() {
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-4">
|
||||
{/* Description */}
|
||||
<p className="text-sm text-bambu-gray">
|
||||
{isSpoolmanMode
|
||||
? t('settings.spoolmanFilamentCatalogDesc')
|
||||
: t('settings.catalog.spoolCatalogDescription')}
|
||||
{t('settings.catalog.spoolCatalogDescription')}
|
||||
</p>
|
||||
|
||||
{/* Search — always shown */}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray" />
|
||||
<input
|
||||
@@ -403,302 +292,174 @@ export function SpoolCatalogSettings() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Mode-determination loading spinner */}
|
||||
{spoolmanLoading && (
|
||||
{showAddForm && (
|
||||
<div className="p-4 bg-bambu-dark rounded-lg border border-bambu-dark-tertiary">
|
||||
<h3 className="text-sm font-medium text-white mb-3">{t('settings.catalog.addNewEntry')}</h3>
|
||||
<div className="flex gap-2 items-center">
|
||||
<div className="flex-1 min-w-0">
|
||||
<input
|
||||
type="text"
|
||||
className="w-full px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none"
|
||||
placeholder={t('settings.catalog.namePlaceholder')}
|
||||
value={formName}
|
||||
onChange={(e) => setFormName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
type="number"
|
||||
className="w-20 px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white text-center focus:border-bambu-green focus:outline-none"
|
||||
placeholder="g"
|
||||
value={formWeight}
|
||||
onChange={(e) => setFormWeight(e.target.value)}
|
||||
/>
|
||||
<span className="text-bambu-gray shrink-0">g</span>
|
||||
<button
|
||||
onClick={handleAdd}
|
||||
disabled={saving}
|
||||
className="px-3 py-2 bg-bambu-green text-white rounded-lg hover:bg-bambu-green/80 flex items-center gap-1 shrink-0"
|
||||
>
|
||||
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Check className="w-4 h-4" />}
|
||||
{t('common.add')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setShowAddForm(false); setFormName(''); setFormWeight(''); }}
|
||||
className="p-2 rounded-lg text-bambu-gray hover:text-white hover:bg-bambu-dark-tertiary"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-8 text-bambu-gray">
|
||||
<Loader2 className="w-5 h-5 animate-spin mr-2" />
|
||||
{t('common.loading')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── LOCAL MODE ── */}
|
||||
{!spoolmanLoading && !isSpoolmanMode && (
|
||||
<>
|
||||
{/* Add form */}
|
||||
{showAddForm && (
|
||||
<div className="p-4 bg-bambu-dark rounded-lg border border-bambu-dark-tertiary">
|
||||
<h3 className="text-sm font-medium text-white mb-3">{t('settings.catalog.addNewEntry')}</h3>
|
||||
<div className="flex gap-2 items-center">
|
||||
<div className="flex-1 min-w-0">
|
||||
<input
|
||||
type="text"
|
||||
className="w-full px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none"
|
||||
placeholder={t('settings.catalog.namePlaceholder')}
|
||||
value={formName}
|
||||
onChange={(e) => setFormName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
type="number"
|
||||
className="w-20 px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white text-center focus:border-bambu-green focus:outline-none"
|
||||
placeholder="g"
|
||||
value={formWeight}
|
||||
onChange={(e) => setFormWeight(e.target.value)}
|
||||
/>
|
||||
<span className="text-bambu-gray shrink-0">g</span>
|
||||
<button
|
||||
onClick={handleAdd}
|
||||
disabled={saving}
|
||||
className="px-3 py-2 bg-bambu-green text-white rounded-lg hover:bg-bambu-green/80 flex items-center gap-1 shrink-0"
|
||||
>
|
||||
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Check className="w-4 h-4" />}
|
||||
{t('common.add')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setShowAddForm(false); setFormName(''); setFormWeight(''); }}
|
||||
className="p-2 rounded-lg text-bambu-gray hover:text-white hover:bg-bambu-dark-tertiary"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Local catalog table */}
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-8 text-bambu-gray">
|
||||
<Loader2 className="w-5 h-5 animate-spin mr-2" />
|
||||
{t('common.loading')}
|
||||
</div>
|
||||
) : (
|
||||
<div className="max-h-[600px] overflow-y-auto border border-bambu-dark-tertiary rounded-lg">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-bambu-dark sticky top-0">
|
||||
<tr>
|
||||
<th className="px-2 py-2 w-10">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={filteredCatalog.length > 0 && selectedIds.size === filteredCatalog.length}
|
||||
onChange={toggleSelectAll}
|
||||
className="w-4 h-4 accent-bambu-green cursor-pointer"
|
||||
/>
|
||||
</th>
|
||||
<th className="px-4 py-2 text-left text-bambu-gray font-medium">{t('common.name')}</th>
|
||||
<th className="px-4 py-2 text-right text-bambu-gray font-medium w-24">{t('settings.catalog.weight')}</th>
|
||||
<th className="px-4 py-2 text-center text-bambu-gray font-medium w-20">{t('settings.catalog.type')}</th>
|
||||
<th className="px-4 py-2 w-24"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredCatalog.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={5} className="px-4 py-8 text-center text-bambu-gray">
|
||||
{search ? t('settings.catalog.noMatch') : t('settings.catalog.empty')}
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
filteredCatalog.map(entry => (
|
||||
<tr key={entry.id} className={`border-t border-bambu-dark-tertiary hover:bg-bambu-dark ${selectedIds.has(entry.id) ? 'bg-bambu-dark' : ''}`}>
|
||||
{editingId === entry.id ? (
|
||||
<>
|
||||
<td className="px-2 py-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedIds.has(entry.id)}
|
||||
onChange={() => toggleSelect(entry.id)}
|
||||
className="w-4 h-4 accent-bambu-green cursor-pointer"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-4 py-2">
|
||||
<input
|
||||
type="text"
|
||||
className="w-full px-2 py-1 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-white focus:border-bambu-green focus:outline-none"
|
||||
value={formName}
|
||||
onChange={(e) => setFormName(e.target.value)}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-4 py-2">
|
||||
<input
|
||||
type="number"
|
||||
className="w-full px-2 py-1 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-white text-right focus:border-bambu-green focus:outline-none"
|
||||
value={formWeight}
|
||||
onChange={(e) => setFormWeight(e.target.value)}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-4 py-2 text-center">
|
||||
<span className="text-xs text-bambu-gray">-</span>
|
||||
</td>
|
||||
<td className="px-4 py-2">
|
||||
<div className="flex justify-end gap-1">
|
||||
<button
|
||||
onClick={() => handleUpdate(entry.id)}
|
||||
disabled={saving}
|
||||
className="p-1.5 rounded hover:bg-green-500/20 text-green-500"
|
||||
>
|
||||
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Check className="w-4 h-4" />}
|
||||
</button>
|
||||
<button onClick={cancelEdit} className="p-1.5 rounded hover:bg-bambu-dark-tertiary text-bambu-gray">
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<td className="px-2 py-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedIds.has(entry.id)}
|
||||
onChange={() => toggleSelect(entry.id)}
|
||||
className="w-4 h-4 accent-bambu-green cursor-pointer"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-4 py-2 text-white">{entry.name}</td>
|
||||
<td className="px-4 py-2 text-right font-mono text-white">{entry.weight}g</td>
|
||||
<td className="px-4 py-2 text-center">
|
||||
{entry.is_default ? (
|
||||
<span className="text-xs px-2 py-0.5 rounded bg-bambu-dark-tertiary text-bambu-gray">
|
||||
{t('settings.catalog.default')}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs px-2 py-0.5 rounded bg-bambu-green/20 text-bambu-green">
|
||||
{t('settings.catalog.custom')}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-2">
|
||||
<div className="flex justify-end gap-1">
|
||||
<button
|
||||
onClick={() => startEdit(entry)}
|
||||
className="p-1.5 rounded hover:bg-bambu-dark-tertiary text-bambu-gray hover:text-white"
|
||||
>
|
||||
<Pencil className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDeleteEntry(entry)}
|
||||
className="p-1.5 rounded bg-red-500/10 hover:bg-red-500/20 text-red-500"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</>
|
||||
)}
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── SPOOLMAN MODE ── */}
|
||||
{!spoolmanLoading && isSpoolmanMode && (
|
||||
) : (
|
||||
<div className="max-h-[600px] overflow-y-auto border border-bambu-dark-tertiary rounded-lg">
|
||||
{spoolmanError ? (
|
||||
<p className="px-4 py-8 text-center text-sm text-red-400">
|
||||
{t('inventory.spoolmanCatalogLoadFailed')}
|
||||
</p>
|
||||
) : filteredSpoolmanFilaments.length === 0 ? (
|
||||
<p className="px-4 py-8 text-center text-sm text-bambu-gray">
|
||||
{t('inventory.noSpoolmanFilaments')}
|
||||
</p>
|
||||
) : (
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-bambu-dark sticky top-0">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-bambu-dark sticky top-0">
|
||||
<tr>
|
||||
<th className="px-2 py-2 w-10">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={filteredCatalog.length > 0 && selectedIds.size === filteredCatalog.length}
|
||||
onChange={toggleSelectAll}
|
||||
className="w-4 h-4 accent-bambu-green cursor-pointer"
|
||||
/>
|
||||
</th>
|
||||
<th className="px-4 py-2 text-left text-bambu-gray font-medium">{t('common.name')}</th>
|
||||
<th className="px-4 py-2 text-right text-bambu-gray font-medium w-24">{t('settings.catalog.weight')}</th>
|
||||
<th className="px-4 py-2 text-center text-bambu-gray font-medium w-20">{t('settings.catalog.type')}</th>
|
||||
<th className="px-4 py-2 w-24"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredCatalog.length === 0 ? (
|
||||
<tr>
|
||||
<th className="px-3 py-2 w-8"></th>
|
||||
<th className="px-4 py-2 text-left text-bambu-gray font-medium">{t('common.name')}</th>
|
||||
<th className="px-4 py-2 text-left text-bambu-gray font-medium w-28">{t('settings.catalog.material')}</th>
|
||||
<th className="px-4 py-2 text-right text-bambu-gray font-medium w-24">{t('settings.catalog.weight')}</th>
|
||||
<th className="px-4 py-2 text-right text-bambu-gray font-medium w-28">{t('settings.catalog.spoolWeight')}</th>
|
||||
<th className="px-3 py-2 w-20"></th>
|
||||
<td colSpan={5} className="px-4 py-8 text-center text-bambu-gray">
|
||||
{search ? t('settings.catalog.noMatch') : t('settings.catalog.empty')}
|
||||
</td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredSpoolmanFilaments.map(f => {
|
||||
const isEditing = editingFilamentId === f.id;
|
||||
const isSaving = patchFilamentMutation.isPending && editingFilamentId === f.id;
|
||||
return (
|
||||
<tr key={f.id} className="border-t border-bambu-dark-tertiary hover:bg-bambu-dark">
|
||||
<td className="px-3 py-2">
|
||||
<span
|
||||
className="w-4 h-4 rounded-full block shrink-0 border border-white/20"
|
||||
style={{ backgroundColor: f.color_hex ? `#${f.color_hex.replace('#', '')}` : '#808080' }}
|
||||
aria-label={t('inventory.spoolmanFilamentColorSwatch')}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-4 py-2 text-white truncate max-w-0">
|
||||
{isEditing ? (
|
||||
) : (
|
||||
filteredCatalog.map(entry => (
|
||||
<tr key={entry.id} className={`border-t border-bambu-dark-tertiary hover:bg-bambu-dark ${selectedIds.has(entry.id) ? 'bg-bambu-dark' : ''}`}>
|
||||
{editingId === entry.id ? (
|
||||
<>
|
||||
<td className="px-2 py-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedIds.has(entry.id)}
|
||||
onChange={() => toggleSelect(entry.id)}
|
||||
className="w-4 h-4 accent-bambu-green cursor-pointer"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-4 py-2">
|
||||
<input
|
||||
type="text"
|
||||
value={editingFilamentName}
|
||||
onChange={e => setEditingFilamentName(e.target.value)}
|
||||
className="w-full bg-bambu-dark-tertiary text-white rounded px-2 py-0.5 text-sm border border-bambu-dark-secondary focus:outline-none focus:border-bambu-green"
|
||||
aria-label={t('common.name')}
|
||||
className="w-full px-2 py-1 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-white focus:border-bambu-green focus:outline-none"
|
||||
value={formName}
|
||||
onChange={(e) => setFormName(e.target.value)}
|
||||
/>
|
||||
) : (
|
||||
<span className="block truncate">
|
||||
{f.vendor?.name ? `${f.vendor.name} — ` : ''}{f.name}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-bambu-gray">{f.material ?? '—'}</td>
|
||||
<td className="px-4 py-2 text-right font-mono text-white">
|
||||
{f.weight ? `${f.weight}g` : '—'}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-right font-mono text-bambu-gray">
|
||||
{isEditing ? (
|
||||
</td>
|
||||
<td className="px-4 py-2">
|
||||
<input
|
||||
type="number"
|
||||
value={editingFilamentWeight}
|
||||
onChange={e => setEditingFilamentWeight(e.target.value)}
|
||||
min="0"
|
||||
step="1"
|
||||
className="w-20 bg-bambu-dark-tertiary text-white rounded px-2 py-0.5 text-sm border border-bambu-dark-secondary focus:outline-none focus:border-bambu-green text-right"
|
||||
aria-label={t('settings.catalog.spoolWeight')}
|
||||
className="w-full px-2 py-1 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-white text-right focus:border-bambu-green focus:outline-none"
|
||||
value={formWeight}
|
||||
onChange={(e) => setFormWeight(e.target.value)}
|
||||
/>
|
||||
) : (
|
||||
f.spool_weight != null ? `${f.spool_weight}g` : '—'
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
{isEditing ? (
|
||||
<div className="flex items-center gap-1 justify-end">
|
||||
</td>
|
||||
<td className="px-4 py-2 text-center">
|
||||
<span className="text-xs text-bambu-gray">-</span>
|
||||
</td>
|
||||
<td className="px-4 py-2">
|
||||
<div className="flex justify-end gap-1">
|
||||
<button
|
||||
onClick={() => handleFilamentSave(f)}
|
||||
disabled={isSaving || !editingFilamentName.trim() || (editingFilamentWeight !== '' && (isNaN(parseFloat(editingFilamentWeight)) || parseFloat(editingFilamentWeight) <= 0))}
|
||||
className="p-1 text-bambu-green hover:text-white disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
aria-label={t('common.save')}
|
||||
onClick={() => handleUpdate(entry.id)}
|
||||
disabled={saving}
|
||||
className="p-1.5 rounded hover:bg-green-500/20 text-green-500"
|
||||
>
|
||||
{isSaving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Check className="w-4 h-4" />}
|
||||
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Check className="w-4 h-4" />}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setEditingFilamentId(null); setEditingFilamentName(''); setEditingFilamentWeight(''); }}
|
||||
className="p-1 text-bambu-gray hover:text-white"
|
||||
aria-label={t('common.cancel')}
|
||||
>
|
||||
<button onClick={cancelEdit} className="p-1.5 rounded hover:bg-bambu-dark-tertiary text-bambu-gray">
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditingFilamentId(f.id);
|
||||
setEditingFilamentName(f.name);
|
||||
setEditingFilamentWeight(f.spool_weight != null ? String(f.spool_weight) : '');
|
||||
}}
|
||||
className="p-1 text-bambu-gray hover:text-white"
|
||||
aria-label={t('common.edit')}
|
||||
>
|
||||
<Pencil className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</td>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<td className="px-2 py-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedIds.has(entry.id)}
|
||||
onChange={() => toggleSelect(entry.id)}
|
||||
className="w-4 h-4 accent-bambu-green cursor-pointer"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-4 py-2 text-white">{entry.name}</td>
|
||||
<td className="px-4 py-2 text-right font-mono text-white">{entry.weight}g</td>
|
||||
<td className="px-4 py-2 text-center">
|
||||
{entry.is_default ? (
|
||||
<span className="text-xs px-2 py-0.5 rounded bg-bambu-dark-tertiary text-bambu-gray">
|
||||
{t('settings.catalog.default')}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs px-2 py-0.5 rounded bg-bambu-green/20 text-bambu-green">
|
||||
{t('settings.catalog.custom')}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-2">
|
||||
<div className="flex justify-end gap-1">
|
||||
<button
|
||||
onClick={() => startEdit(entry)}
|
||||
className="p-1.5 rounded hover:bg-bambu-dark-tertiary text-bambu-gray hover:text-white"
|
||||
>
|
||||
<Pencil className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDeleteEntry(entry)}
|
||||
className="p-1.5 rounded bg-red-500/10 hover:bg-red-500/20 text-red-500"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</>
|
||||
)}
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
{/* Confirmation modals — local mode only */}
|
||||
{!isSpoolmanMode && deleteEntry && (
|
||||
{deleteEntry && (
|
||||
<ConfirmModal
|
||||
title={t('settings.catalog.deleteEntry')}
|
||||
message={t('settings.catalog.deleteConfirm', { name: deleteEntry.name })}
|
||||
@@ -709,7 +470,7 @@ export function SpoolCatalogSettings() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{!isSpoolmanMode && showBulkDeleteConfirm && (
|
||||
{showBulkDeleteConfirm && (
|
||||
<ConfirmModal
|
||||
title={t('settings.catalog.deleteSelected')}
|
||||
message={t('settings.catalog.bulkDeleteConfirm', { count: selectedIds.size })}
|
||||
@@ -720,7 +481,7 @@ export function SpoolCatalogSettings() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{!isSpoolmanMode && showResetConfirm && (
|
||||
{showResetConfirm && (
|
||||
<ConfirmModal
|
||||
title={t('settings.catalog.resetCatalog')}
|
||||
message={t('settings.catalog.resetConfirm')}
|
||||
@@ -730,15 +491,6 @@ export function SpoolCatalogSettings() {
|
||||
onCancel={() => setShowResetConfirm(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<SpoolWeightUpdateModal
|
||||
isOpen={pendingWeightEdit !== null}
|
||||
filamentName={pendingWeightEdit?.name ?? ''}
|
||||
oldWeight={pendingWeightEdit?.oldWeight ?? null}
|
||||
newWeight={pendingWeightEdit?.newWeight ?? 0}
|
||||
onConfirm={handleWeightModalConfirm}
|
||||
onClose={() => setPendingWeightEdit(null)}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Card, CardContent } from './Card';
|
||||
import { Button } from './Button';
|
||||
|
||||
interface SpoolWeightUpdateModalProps {
|
||||
isOpen: boolean;
|
||||
filamentName: string;
|
||||
oldWeight: number | null;
|
||||
newWeight: number;
|
||||
onConfirm: (keepExisting: boolean) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function SpoolWeightUpdateModal({
|
||||
isOpen,
|
||||
filamentName,
|
||||
oldWeight,
|
||||
newWeight,
|
||||
onConfirm,
|
||||
onClose,
|
||||
}: SpoolWeightUpdateModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const [keepExisting, setKeepExisting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) setKeepExisting(false);
|
||||
}, [isOpen]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const oldWeightLabel = oldWeight !== null ? `${oldWeight}g` : '—';
|
||||
const newWeightLabel = `${newWeight}g`;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4"
|
||||
onClick={onClose}
|
||||
>
|
||||
<Card className="w-full max-w-md" onClick={(e: React.MouseEvent) => e.stopPropagation()}>
|
||||
<CardContent className="p-6">
|
||||
<h3 className="text-lg font-semibold text-white mb-1">
|
||||
{t('settings.catalog.updateSpoolWeight')}
|
||||
</h3>
|
||||
<p className="text-bambu-gray text-sm mb-4">
|
||||
{filamentName}: {oldWeightLabel} → {newWeightLabel}
|
||||
</p>
|
||||
|
||||
<div className="space-y-3">
|
||||
<label className="flex items-start gap-3 cursor-pointer p-3 rounded-lg border border-bambu-dark-tertiary hover:bg-bambu-dark transition-colors">
|
||||
<input
|
||||
type="radio"
|
||||
name="weight-update-mode"
|
||||
checked={!keepExisting}
|
||||
onChange={() => setKeepExisting(false)}
|
||||
className="mt-1 accent-bambu-green"
|
||||
/>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-white">
|
||||
{t('settings.catalog.applyToAllSpools')}
|
||||
</div>
|
||||
<div className="text-xs text-bambu-gray mt-0.5">
|
||||
{t('settings.catalog.applyToAllSpoolsDesc')}
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label className="flex items-start gap-3 cursor-pointer p-3 rounded-lg border border-bambu-dark-tertiary hover:bg-bambu-dark transition-colors">
|
||||
<input
|
||||
type="radio"
|
||||
name="weight-update-mode"
|
||||
checked={keepExisting}
|
||||
onChange={() => setKeepExisting(true)}
|
||||
className="mt-1 accent-bambu-green"
|
||||
/>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-white">
|
||||
{t('settings.catalog.keepExistingSpoolWeight')}
|
||||
</div>
|
||||
<div className="text-xs text-bambu-gray mt-0.5">
|
||||
{t('settings.catalog.keepExistingSpoolWeightDesc')}
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 mt-6">
|
||||
<Button variant="secondary" onClick={onClose} className="flex-1">
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => onConfirm(keepExisting)}
|
||||
className="flex-1 bg-bambu-green hover:bg-bambu-green-dark"
|
||||
>
|
||||
{t('common.confirm')}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user