Add bulk delete for spool and color catalog entries (#646)

Checkbox selection + "Delete Selected" button in Settings > Filament
  for both Spool Catalog and Color Catalog. Adds POST bulk-delete
  endpoints and translations for all 7 locales.
This commit is contained in:
maziggy
2026-03-07 10:13:40 +01:00
parent d55585a73f
commit a7ddc483b3
15 changed files with 389 additions and 74 deletions
@@ -25,6 +25,10 @@ export function ColorCatalogSettings() {
const [formMaterial, setFormMaterial] = useState('');
const [saving, setSaving] = useState(false);
// Selection state
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
const [showBulkDeleteConfirm, setShowBulkDeleteConfirm] = useState(false);
// Sync state
const [syncing, setSyncing] = useState(false);
const [syncProgress, setSyncProgress] = useState<{ fetched: number; total: number } | null>(null);
@@ -219,6 +223,36 @@ export function ColorCatalogSettings() {
}
};
const toggleSelect = (id: number) => {
setSelectedIds(prev => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
const toggleSelectAll = () => {
if (selectedIds.size === filteredCatalog.length) {
setSelectedIds(new Set());
} else {
setSelectedIds(new Set(filteredCatalog.map(e => e.id)));
}
};
const handleBulkDelete = async () => {
setShowBulkDeleteConfirm(false);
if (selectedIds.size === 0) return;
try {
const result = await api.bulkDeleteColorEntries([...selectedIds]);
setCatalog(prev => prev.filter(e => !selectedIds.has(e.id)));
setSelectedIds(new Set());
showToast(t('settings.colorCatalog.bulkDeleted', { count: result.deleted }), 'success');
} catch {
showToast(t('settings.colorCatalog.bulkDeleteFailed'), 'error');
}
};
const handleExport = () => {
const exportData = catalog.map(({ manufacturer, color_name, hex_color, material }) => ({
manufacturer, color_name, hex_color, material,
@@ -331,6 +365,26 @@ export function ColorCatalogSettings() {
<span className="hidden sm:inline">{t('common.add')}</span>
</button>
</div>
{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.colorCatalog.selectedCount', { count: selectedIds.size })}
</span>
<button
onClick={() => setShowBulkDeleteConfirm(true)}
className="ml-auto px-3 py-1.5 text-sm bg-red-500 text-white rounded-lg hover:bg-red-600 transition-colors flex items-center gap-1.5"
>
<Trash2 className="w-4 h-4" />
{t('settings.colorCatalog.deleteSelected')}
</button>
<button
onClick={() => setSelectedIds(new Set())}
className="px-3 py-1.5 text-sm text-bambu-gray hover:text-white transition-colors"
>
{t('common.cancel')}
</button>
</div>
)}
</CardHeader>
<CardContent className="space-y-4">
<p className="text-sm text-bambu-gray">
@@ -440,6 +494,14 @@ export function ColorCatalogSettings() {
<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-3 py-2 text-left text-bambu-gray font-medium w-12"></th>
<th className="px-3 py-2 text-left text-bambu-gray font-medium">{t('settings.colorCatalog.manufacturer')}</th>
<th className="px-3 py-2 text-left text-bambu-gray font-medium">{t('inventory.material')}</th>
@@ -451,15 +513,23 @@ export function ColorCatalogSettings() {
<tbody>
{filteredCatalog.length === 0 ? (
<tr>
<td colSpan={6} className="px-3 py-8 text-center text-bambu-gray">
<td colSpan={7} className="px-3 py-8 text-center text-bambu-gray">
{search || filterManufacturer ? t('settings.colorCatalog.noMatch') : t('settings.colorCatalog.empty')}
</td>
</tr>
) : (
filteredCatalog.map(entry => (
<tr key={entry.id} className="border-t border-bambu-dark-tertiary hover:bg-bambu-dark">
<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-3 py-2">
<input
type="color"
@@ -517,6 +587,14 @@ export function ColorCatalogSettings() {
</>
) : (
<>
<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-3 py-2">
<div
className="w-8 h-8 rounded border border-bambu-dark-tertiary"
@@ -567,6 +645,18 @@ export function ColorCatalogSettings() {
/>
)}
{/* Bulk delete confirmation */}
{showBulkDeleteConfirm && (
<ConfirmModal
title={t('settings.colorCatalog.deleteSelected')}
message={t('settings.colorCatalog.bulkDeleteConfirm', { count: selectedIds.size })}
confirmText={t('common.delete')}
variant="danger"
onConfirm={handleBulkDelete}
onCancel={() => setShowBulkDeleteConfirm(false)}
/>
)}
{/* Reset confirmation */}
{showResetConfirm && (
<ConfirmModal
@@ -22,6 +22,10 @@ export function SpoolCatalogSettings() {
const [formWeight, setFormWeight] = useState('');
const [saving, setSaving] = useState(false);
// Selection state
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
const [showBulkDeleteConfirm, setShowBulkDeleteConfirm] = useState(false);
// Confirmation modals
const [deleteEntry, setDeleteEntry] = useState<SpoolCatalogEntry | null>(null);
const [showResetConfirm, setShowResetConfirm] = useState(false);
@@ -123,6 +127,36 @@ export function SpoolCatalogSettings() {
}
};
const toggleSelect = (id: number) => {
setSelectedIds(prev => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
const toggleSelectAll = () => {
if (selectedIds.size === filteredCatalog.length) {
setSelectedIds(new Set());
} else {
setSelectedIds(new Set(filteredCatalog.map(e => e.id)));
}
};
const handleBulkDelete = async () => {
setShowBulkDeleteConfirm(false);
if (selectedIds.size === 0) return;
try {
const result = await api.bulkDeleteCatalogEntries([...selectedIds]);
setCatalog(prev => prev.filter(e => !selectedIds.has(e.id)));
setSelectedIds(new Set());
showToast(t('settings.catalog.bulkDeleted', { count: result.deleted }), 'success');
} catch {
showToast(t('settings.catalog.bulkDeleteFailed'), 'error');
}
};
const handleExport = () => {
const exportData = catalog.map(({ name, weight }) => ({ name, weight }));
const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' });
@@ -206,6 +240,26 @@ export function SpoolCatalogSettings() {
<span className="hidden sm:inline">{t('common.add')}</span>
</button>
</div>
{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 })}
</span>
<button
onClick={() => setShowBulkDeleteConfirm(true)}
className="ml-auto px-3 py-1.5 text-sm bg-red-500 text-white rounded-lg hover:bg-red-600 transition-colors flex items-center gap-1.5"
>
<Trash2 className="w-4 h-4" />
{t('settings.catalog.deleteSelected')}
</button>
<button
onClick={() => setSelectedIds(new Set())}
className="px-3 py-1.5 text-sm text-bambu-gray hover:text-white transition-colors"
>
{t('common.cancel')}
</button>
</div>
)}
</CardHeader>
<CardContent className="space-y-4">
<p className="text-sm text-bambu-gray">
@@ -275,6 +329,14 @@ export function SpoolCatalogSettings() {
<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>
@@ -284,15 +346,23 @@ export function SpoolCatalogSettings() {
<tbody>
{filteredCatalog.length === 0 ? (
<tr>
<td colSpan={4} className="px-4 py-8 text-center text-bambu-gray">
<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">
<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"
@@ -329,6 +399,14 @@ export function SpoolCatalogSettings() {
</>
) : (
<>
<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">
@@ -381,6 +459,18 @@ export function SpoolCatalogSettings() {
/>
)}
{/* Bulk delete confirmation */}
{showBulkDeleteConfirm && (
<ConfirmModal
title={t('settings.catalog.deleteSelected')}
message={t('settings.catalog.bulkDeleteConfirm', { count: selectedIds.size })}
confirmText={t('common.delete')}
variant="danger"
onConfirm={handleBulkDelete}
onCancel={() => setShowBulkDeleteConfirm(false)}
/>
)}
{/* Reset confirmation */}
{showResetConfirm && (
<ConfirmModal