|
@@ -21,6 +21,7 @@ import { ColumnConfigModal, type ColumnConfig } from '../components/ColumnConfig
|
|
|
import { LabelTemplatePickerModal } from '../components/LabelTemplatePickerModal';
|
|
import { LabelTemplatePickerModal } from '../components/LabelTemplatePickerModal';
|
|
|
import { SpoolCsvImportModal } from '../components/SpoolCsvImportModal';
|
|
import { SpoolCsvImportModal } from '../components/SpoolCsvImportModal';
|
|
|
import { LocationsModal } from '../components/LocationsModal';
|
|
import { LocationsModal } from '../components/LocationsModal';
|
|
|
|
|
+import { BulkEditSpoolsModal } from '../components/BulkEditSpoolsModal';
|
|
|
import { useToast } from '../contexts/ToastContext';
|
|
import { useToast } from '../contexts/ToastContext';
|
|
|
import { useAuth } from '../contexts/AuthContext';
|
|
import { useAuth } from '../contexts/AuthContext';
|
|
|
import { resolveSpoolColorName } from '../utils/colors';
|
|
import { resolveSpoolColorName } from '../utils/colors';
|
|
@@ -44,6 +45,17 @@ type DisplayItem =
|
|
|
| { type: 'single'; spool: InventorySpool }
|
|
| { type: 'single'; spool: InventorySpool }
|
|
|
| { type: 'group'; key: string; spools: InventorySpool[]; representative: InventorySpool };
|
|
| { type: 'group'; key: string; spools: InventorySpool[]; representative: InventorySpool };
|
|
|
|
|
|
|
|
|
|
+function dedupeAndSort(values: Array<string | null | undefined>): string[] {
|
|
|
|
|
+ const set = new Set<string>();
|
|
|
|
|
+ for (const v of values) {
|
|
|
|
|
+ if (typeof v === 'string') {
|
|
|
|
|
+ const trimmed = v.trim();
|
|
|
|
|
+ if (trimmed) set.add(trimmed);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return Array.from(set).sort((a, b) => a.localeCompare(b));
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
function spoolGroupKey(s: InventorySpool): string {
|
|
function spoolGroupKey(s: InventorySpool): string {
|
|
|
// Include extra_colors + effect_type so the "Group similar" toggle does
|
|
// Include extra_colors + effect_type so the "Group similar" toggle does
|
|
|
// not collapse two spools that share the base colour but differ on
|
|
// not collapse two spools that share the base colour but differ on
|
|
@@ -505,6 +517,28 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo
|
|
|
});
|
|
});
|
|
|
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set());
|
|
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set());
|
|
|
|
|
|
|
|
|
|
+ // Bulk-selection state for batch actions on the spool list (#1795).
|
|
|
|
|
+ // Cleared when the user switches filter/tab/page because cross-page selection
|
|
|
|
|
+ // produces a confusing toolbar count vs. visible-row count delta.
|
|
|
|
|
+ const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
|
|
|
|
|
+ const [bulkEditOpen, setBulkEditOpen] = useState(false);
|
|
|
|
|
+ const [bulkConfirmAction, setBulkConfirmAction] = useState<'delete' | 'archive' | 'restore' | 'reset-consumed-counter' | null>(null);
|
|
|
|
|
+ const toggleSelected = useCallback((id: number) => {
|
|
|
|
|
+ setSelectedIds((prev) => {
|
|
|
|
|
+ const next = new Set(prev);
|
|
|
|
|
+ if (next.has(id)) next.delete(id);
|
|
|
|
|
+ else next.add(id);
|
|
|
|
|
+ return next;
|
|
|
|
|
+ });
|
|
|
|
|
+ }, []);
|
|
|
|
|
+ const clearSelection = useCallback(() => setSelectedIds(new Set()), []);
|
|
|
|
|
+
|
|
|
|
|
+ // Clear selection on any filter/tab change so the toolbar count stays
|
|
|
|
|
+ // honest vs. what the user is actually looking at.
|
|
|
|
|
+ useEffect(() => {
|
|
|
|
|
+ setSelectedIds(new Set());
|
|
|
|
|
+ }, [archiveFilter, usageFilter, materialFilter, brandFilter, categoryFilter, spoolFilter, stockFilter, search]);
|
|
|
|
|
+
|
|
|
// Pagination state (pageSize persisted to localStorage)
|
|
// Pagination state (pageSize persisted to localStorage)
|
|
|
const [pageIndex, setPageIndex] = useState(0);
|
|
const [pageIndex, setPageIndex] = useState(0);
|
|
|
const [pageSize, setPageSize] = useState(() => {
|
|
const [pageSize, setPageSize] = useState(() => {
|
|
@@ -751,12 +785,127 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo
|
|
|
onSuccess: (data) => {
|
|
onSuccess: (data) => {
|
|
|
queryClient.invalidateQueries({ queryKey: spoolsQueryKey });
|
|
queryClient.invalidateQueries({ queryKey: spoolsQueryKey });
|
|
|
showToast(t('inventory.allConsumedCountersReset', { count: data.reset }), 'success');
|
|
showToast(t('inventory.allConsumedCountersReset', { count: data.reset }), 'success');
|
|
|
|
|
+ // Close any open bulk-confirm modal + clear selection so the toolbar
|
|
|
|
|
+ // collapses after the action — matches the other three bulk mutations
|
|
|
|
|
+ // and stops the confirm dialog from lingering after Reset.
|
|
|
|
|
+ setBulkConfirmAction(null);
|
|
|
|
|
+ clearSelection();
|
|
|
},
|
|
},
|
|
|
onError: () => {
|
|
onError: () => {
|
|
|
showToast(t('inventory.resetConsumedCounterFailed'), 'error');
|
|
showToast(t('inventory.resetConsumedCounterFailed'), 'error');
|
|
|
},
|
|
},
|
|
|
});
|
|
});
|
|
|
|
|
|
|
|
|
|
+ // Bulk action mutations (#1795). Each invalidates the same query keys as
|
|
|
|
|
+ // the per-spool equivalents so the table refreshes with the new state.
|
|
|
|
|
+ // Helper: count items that didn't succeed across the two response shapes
|
|
|
|
|
+ // (internal mode returns not_found, Spoolman returns errors[]). When the
|
|
|
|
|
+ // success count is 0 OR any failures occurred, surface that to the user
|
|
|
|
|
+ // instead of the silent green-toast-and-clear flow the first cut shipped.
|
|
|
|
|
+ const failedCount = (data: { not_found?: number[]; errors?: Array<{ id: number }> }): number =>
|
|
|
|
|
+ (data.not_found?.length ?? 0) + (data.errors?.length ?? 0);
|
|
|
|
|
+
|
|
|
|
|
+ const bulkUpdateMutation = useMutation({
|
|
|
|
|
+ mutationFn: async ({ ids, update }: { ids: number[]; update: Partial<Omit<InventorySpool, 'id' | 'archived_at' | 'created_at' | 'updated_at' | 'k_profiles'>> }): Promise<{ updated: number; not_found?: number[]; errors?: Array<{ id: number; status: number; detail: string }> }> => {
|
|
|
|
|
+ if (spoolmanMode) return api.bulkUpdateSpoolmanInventorySpools(ids, update);
|
|
|
|
|
+ return api.bulkUpdateSpools(ids, update);
|
|
|
|
|
+ },
|
|
|
|
|
+ onSuccess: (data) => {
|
|
|
|
|
+ refreshSpoolQueries();
|
|
|
|
|
+ const failed = failedCount(data);
|
|
|
|
|
+ if (data.updated === 0) {
|
|
|
|
|
+ showToast(t('inventory.bulk.updateAllFailed', { count: failed }), 'error');
|
|
|
|
|
+ return; // keep modal open + selection intact so user can retry
|
|
|
|
|
+ }
|
|
|
|
|
+ if (failed > 0) {
|
|
|
|
|
+ showToast(t('inventory.bulk.updatePartial', { ok: data.updated, failed }), 'warning');
|
|
|
|
|
+ } else {
|
|
|
|
|
+ showToast(t('inventory.bulk.updateSuccess', { count: data.updated }), 'success');
|
|
|
|
|
+ }
|
|
|
|
|
+ setBulkEditOpen(false);
|
|
|
|
|
+ clearSelection();
|
|
|
|
|
+ },
|
|
|
|
|
+ onError: (err: Error) => {
|
|
|
|
|
+ showToast(err.message || t('inventory.bulk.updateFailed'), 'error');
|
|
|
|
|
+ },
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ const bulkDeleteMutation = useMutation({
|
|
|
|
|
+ mutationFn: async (ids: number[]): Promise<{ deleted: number; not_found?: number[]; errors?: Array<{ id: number; status: number; detail: string }> }> => {
|
|
|
|
|
+ if (spoolmanMode) return api.bulkDeleteSpoolmanInventorySpools(ids);
|
|
|
|
|
+ return api.bulkDeleteSpools(ids);
|
|
|
|
|
+ },
|
|
|
|
|
+ onSuccess: (data) => {
|
|
|
|
|
+ refreshSpoolQueries();
|
|
|
|
|
+ const failed = failedCount(data);
|
|
|
|
|
+ if (data.deleted === 0) {
|
|
|
|
|
+ showToast(t('inventory.bulk.deleteAllFailed', { count: failed }), 'error');
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (failed > 0) {
|
|
|
|
|
+ showToast(t('inventory.bulk.deletePartial', { ok: data.deleted, failed }), 'warning');
|
|
|
|
|
+ } else {
|
|
|
|
|
+ showToast(t('inventory.bulk.deleteSuccess', { count: data.deleted }), 'success');
|
|
|
|
|
+ }
|
|
|
|
|
+ setBulkConfirmAction(null);
|
|
|
|
|
+ clearSelection();
|
|
|
|
|
+ },
|
|
|
|
|
+ onError: (err: Error) => {
|
|
|
|
|
+ showToast(err.message || t('inventory.bulk.deleteFailed'), 'error');
|
|
|
|
|
+ },
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ const bulkArchiveMutation = useMutation({
|
|
|
|
|
+ mutationFn: async (ids: number[]): Promise<{ archived: number; already_archived?: number[]; not_found?: number[]; errors?: Array<{ id: number; status: number; detail: string }> }> => {
|
|
|
|
|
+ if (spoolmanMode) return api.bulkArchiveSpoolmanInventorySpools(ids);
|
|
|
|
|
+ return api.bulkArchiveSpools(ids);
|
|
|
|
|
+ },
|
|
|
|
|
+ onSuccess: (data) => {
|
|
|
|
|
+ refreshSpoolQueries();
|
|
|
|
|
+ // already-archived rows are NOT failures — they're correctly idempotent.
|
|
|
|
|
+ const failed = failedCount(data);
|
|
|
|
|
+ if (data.archived === 0 && failed > 0) {
|
|
|
|
|
+ showToast(t('inventory.bulk.archiveAllFailed', { count: failed }), 'error');
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (failed > 0) {
|
|
|
|
|
+ showToast(t('inventory.bulk.archivePartial', { ok: data.archived, failed }), 'warning');
|
|
|
|
|
+ } else {
|
|
|
|
|
+ showToast(t('inventory.bulk.archiveSuccess', { count: data.archived }), 'success');
|
|
|
|
|
+ }
|
|
|
|
|
+ setBulkConfirmAction(null);
|
|
|
|
|
+ clearSelection();
|
|
|
|
|
+ },
|
|
|
|
|
+ onError: (err: Error) => {
|
|
|
|
|
+ showToast(err.message || t('inventory.bulk.archiveFailed'), 'error');
|
|
|
|
|
+ },
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ const bulkRestoreMutation = useMutation({
|
|
|
|
|
+ mutationFn: async (ids: number[]): Promise<{ restored: number; already_active?: number[]; not_found?: number[]; errors?: Array<{ id: number; status: number; detail: string }> }> => {
|
|
|
|
|
+ if (spoolmanMode) return api.bulkRestoreSpoolmanInventorySpools(ids);
|
|
|
|
|
+ return api.bulkRestoreSpools(ids);
|
|
|
|
|
+ },
|
|
|
|
|
+ onSuccess: (data) => {
|
|
|
|
|
+ refreshSpoolQueries();
|
|
|
|
|
+ const failed = failedCount(data);
|
|
|
|
|
+ if (data.restored === 0 && failed > 0) {
|
|
|
|
|
+ showToast(t('inventory.bulk.restoreAllFailed', { count: failed }), 'error');
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (failed > 0) {
|
|
|
|
|
+ showToast(t('inventory.bulk.restorePartial', { ok: data.restored, failed }), 'warning');
|
|
|
|
|
+ } else {
|
|
|
|
|
+ showToast(t('inventory.bulk.restoreSuccess', { count: data.restored }), 'success');
|
|
|
|
|
+ }
|
|
|
|
|
+ setBulkConfirmAction(null);
|
|
|
|
|
+ clearSelection();
|
|
|
|
|
+ },
|
|
|
|
|
+ onError: (err: Error) => {
|
|
|
|
|
+ showToast(err.message || t('inventory.bulk.restoreFailed'), 'error');
|
|
|
|
|
+ },
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
// Spool IDs the "Reset all usage" button bulk-targets. Includes archived
|
|
// Spool IDs the "Reset all usage" button bulk-targets. Includes archived
|
|
|
// spools too — without them, the broadened "Total Consumed" stat (which
|
|
// spools too — without them, the broadened "Total Consumed" stat (which
|
|
|
// sums archived consumption per the #1390 follow-up) would stay non-zero
|
|
// sums archived consumption per the #1390 follow-up) would stay non-zero
|
|
@@ -1660,6 +1809,54 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo
|
|
|
)}
|
|
)}
|
|
|
</div>
|
|
</div>
|
|
|
|
|
|
|
|
|
|
+ {/* Bulk action toolbar (#1795). Appears as soon as at least one
|
|
|
|
|
+ spool is selected; sticky so it stays visible while the user
|
|
|
|
|
+ scrolls a long list. */}
|
|
|
|
|
+ {selectedIds.size > 0 && viewMode !== 'forecast' && (
|
|
|
|
|
+ <div className="sticky top-2 z-10 mb-4 flex items-center gap-2 px-3 py-2 bg-bambu-green/10 border border-bambu-green/30 rounded-lg backdrop-blur-sm">
|
|
|
|
|
+ <span className="text-sm text-bambu-green font-medium">
|
|
|
|
|
+ {t('inventory.bulk.selectionCount', { count: selectedIds.size })}
|
|
|
|
|
+ </span>
|
|
|
|
|
+ <div className="ml-auto flex flex-wrap items-center gap-2">
|
|
|
|
|
+ <Button size="sm" variant="secondary" onClick={() => setBulkEditOpen(true)}>
|
|
|
|
|
+ <Edit2 className="w-3.5 h-3.5 mr-1.5" />
|
|
|
|
|
+ {t('inventory.bulk.edit')}
|
|
|
|
|
+ </Button>
|
|
|
|
|
+ <Button size="sm" variant="secondary" onClick={() => setLabelPickerSpoolIds([...selectedIds])}>
|
|
|
|
|
+ <Printer className="w-3.5 h-3.5 mr-1.5" />
|
|
|
|
|
+ {t('inventory.bulk.printLabels')}
|
|
|
|
|
+ </Button>
|
|
|
|
|
+ <Button size="sm" variant="secondary" onClick={() => setBulkConfirmAction('reset-consumed-counter')}>
|
|
|
|
|
+ <Eraser className="w-3.5 h-3.5 mr-1.5" />
|
|
|
|
|
+ {t('inventory.bulk.resetUsage')}
|
|
|
|
|
+ </Button>
|
|
|
|
|
+ {archiveFilter === 'archived' ? (
|
|
|
|
|
+ <Button size="sm" variant="secondary" onClick={() => setBulkConfirmAction('restore')}>
|
|
|
|
|
+ <RotateCcw className="w-3.5 h-3.5 mr-1.5" />
|
|
|
|
|
+ {t('inventory.bulk.restore')}
|
|
|
|
|
+ </Button>
|
|
|
|
|
+ ) : (
|
|
|
|
|
+ <Button size="sm" variant="secondary" onClick={() => setBulkConfirmAction('archive')}>
|
|
|
|
|
+ <Archive className="w-3.5 h-3.5 mr-1.5" />
|
|
|
|
|
+ {t('inventory.bulk.archive')}
|
|
|
|
|
+ </Button>
|
|
|
|
|
+ )}
|
|
|
|
|
+ <Button size="sm" variant="danger" onClick={() => setBulkConfirmAction('delete')}>
|
|
|
|
|
+ <Trash2 className="w-3.5 h-3.5 mr-1.5" />
|
|
|
|
|
+ {t('inventory.bulk.delete')}
|
|
|
|
|
+ </Button>
|
|
|
|
|
+ <button
|
|
|
|
|
+ className="p-1.5 text-bambu-gray hover:text-white rounded transition-colors"
|
|
|
|
|
+ onClick={clearSelection}
|
|
|
|
|
+ title={t('inventory.bulk.clearSelection')}
|
|
|
|
|
+ aria-label={t('inventory.bulk.clearSelection')}
|
|
|
|
|
+ >
|
|
|
|
|
+ <X className="w-4 h-4" />
|
|
|
|
|
+ </button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
+
|
|
|
{/* Content */}
|
|
{/* Content */}
|
|
|
{isLoading ? (
|
|
{isLoading ? (
|
|
|
<div className="flex justify-center py-16">
|
|
<div className="flex justify-center py-16">
|
|
@@ -1787,6 +1984,34 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo
|
|
|
<table className="w-full">
|
|
<table className="w-full">
|
|
|
<thead>
|
|
<thead>
|
|
|
<tr className="border-b border-bambu-dark-tertiary bg-bambu-dark-tertiary/30">
|
|
<tr className="border-b border-bambu-dark-tertiary bg-bambu-dark-tertiary/30">
|
|
|
|
|
+ <th className="w-10 px-3 py-3">
|
|
|
|
|
+ <input
|
|
|
|
|
+ type="checkbox"
|
|
|
|
|
+ className="h-4 w-4 cursor-pointer"
|
|
|
|
|
+ aria-label={t('inventory.bulk.selectAllVisible')}
|
|
|
|
|
+ checked={pagedItems.length > 0 && pagedItems.every((item) => {
|
|
|
|
|
+ const ids = item.type === 'group' ? item.spools.map((s) => s.id) : [item.spool.id];
|
|
|
|
|
+ return ids.every((id) => selectedIds.has(id));
|
|
|
|
|
+ })}
|
|
|
|
|
+ onChange={(e) => {
|
|
|
|
|
+ if (e.target.checked) {
|
|
|
|
|
+ const next = new Set(selectedIds);
|
|
|
|
|
+ for (const item of pagedItems) {
|
|
|
|
|
+ const ids = item.type === 'group' ? item.spools.map((s) => s.id) : [item.spool.id];
|
|
|
|
|
+ for (const id of ids) next.add(id);
|
|
|
|
|
+ }
|
|
|
|
|
+ setSelectedIds(next);
|
|
|
|
|
+ } else {
|
|
|
|
|
+ const next = new Set(selectedIds);
|
|
|
|
|
+ for (const item of pagedItems) {
|
|
|
|
|
+ const ids = item.type === 'group' ? item.spools.map((s) => s.id) : [item.spool.id];
|
|
|
|
|
+ for (const id of ids) next.delete(id);
|
|
|
|
|
+ }
|
|
|
|
|
+ setSelectedIds(next);
|
|
|
|
|
+ }
|
|
|
|
|
+ }}
|
|
|
|
|
+ />
|
|
|
|
|
+ </th>
|
|
|
{visibleColumns.map((colId) => {
|
|
{visibleColumns.map((colId) => {
|
|
|
const sortable = !!columnSortValues[colId];
|
|
const sortable = !!columnSortValues[colId];
|
|
|
const isActive = sortState?.column === colId;
|
|
const isActive = sortState?.column === colId;
|
|
@@ -1833,6 +2058,18 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo
|
|
|
pct={pct}
|
|
pct={pct}
|
|
|
isExpanded={isExpanded}
|
|
isExpanded={isExpanded}
|
|
|
onToggle={() => toggleGroupExpand(key)}
|
|
onToggle={() => toggleGroupExpand(key)}
|
|
|
|
|
+ selectedIds={selectedIds}
|
|
|
|
|
+ onToggleSelected={toggleSelected}
|
|
|
|
|
+ onToggleGroupSelected={(ids, select) => {
|
|
|
|
|
+ setSelectedIds((prev) => {
|
|
|
|
|
+ const next = new Set(prev);
|
|
|
|
|
+ for (const id of ids) {
|
|
|
|
|
+ if (select) next.add(id);
|
|
|
|
|
+ else next.delete(id);
|
|
|
|
|
+ }
|
|
|
|
|
+ return next;
|
|
|
|
|
+ });
|
|
|
|
|
+ }}
|
|
|
onEdit={(s) => setFormModal({ spool: s, mode: 'edit' })}
|
|
onEdit={(s) => setFormModal({ spool: s, mode: 'edit' })}
|
|
|
onCopy={(s) => setFormModal({ spool: s, mode: 'copy' })}
|
|
onCopy={(s) => setFormModal({ spool: s, mode: 'copy' })}
|
|
|
onArchive={(id) => setConfirmAction({ type: 'archive', spoolId: id })}
|
|
onArchive={(id) => setConfirmAction({ type: 'archive', spoolId: id })}
|
|
@@ -1858,6 +2095,8 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo
|
|
|
spool={spool}
|
|
spool={spool}
|
|
|
remaining={remaining}
|
|
remaining={remaining}
|
|
|
pct={pct}
|
|
pct={pct}
|
|
|
|
|
+ isSelected={selectedIds.has(spool.id)}
|
|
|
|
|
+ onToggleSelected={() => toggleSelected(spool.id)}
|
|
|
onEdit={() => setFormModal({ spool, mode: 'edit' })}
|
|
onEdit={() => setFormModal({ spool, mode: 'edit' })}
|
|
|
onCopy={() => setFormModal({ spool: spool, mode: 'copy' })}
|
|
onCopy={() => setFormModal({ spool: spool, mode: 'copy' })}
|
|
|
onRestore={() => restoreMutation.mutate(spool.id)}
|
|
onRestore={() => restoreMutation.mutate(spool.id)}
|
|
@@ -2019,6 +2258,59 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo
|
|
|
spoolmanMode={spoolmanMode}
|
|
spoolmanMode={spoolmanMode}
|
|
|
/>
|
|
/>
|
|
|
|
|
|
|
|
|
|
+ <BulkEditSpoolsModal
|
|
|
|
|
+ isOpen={bulkEditOpen}
|
|
|
|
|
+ selectedCount={selectedIds.size}
|
|
|
|
|
+ isPending={bulkUpdateMutation.isPending}
|
|
|
|
|
+ availableLocations={storageLocations.map((l) => ({ id: l.id, name: l.name }))}
|
|
|
|
|
+ availableMaterials={dedupeAndSort((spools ?? []).map((s) => s.material))}
|
|
|
|
|
+ availableSubtypes={dedupeAndSort((spools ?? []).map((s) => s.subtype))}
|
|
|
|
|
+ availableBrands={dedupeAndSort((spools ?? []).map((s) => s.brand))}
|
|
|
|
|
+ availableCategories={dedupeAndSort((spools ?? []).map((s) => s.category))}
|
|
|
|
|
+ availableSlicerFilaments={dedupeAndSort((spools ?? []).map((s) => s.slicer_filament))}
|
|
|
|
|
+ availableSlicerFilamentNames={dedupeAndSort((spools ?? []).map((s) => s.slicer_filament_name))}
|
|
|
|
|
+ onClose={() => setBulkEditOpen(false)}
|
|
|
|
|
+ onApply={(patch) => bulkUpdateMutation.mutate({ ids: [...selectedIds], update: patch })}
|
|
|
|
|
+ />
|
|
|
|
|
+
|
|
|
|
|
+ {bulkConfirmAction && (
|
|
|
|
|
+ <ConfirmModal
|
|
|
|
|
+ title={
|
|
|
|
|
+ bulkConfirmAction === 'delete' ? t('inventory.bulk.deleteTitle') :
|
|
|
|
|
+ bulkConfirmAction === 'archive' ? t('inventory.bulk.archiveTitle') :
|
|
|
|
|
+ bulkConfirmAction === 'restore' ? t('inventory.bulk.restoreTitle') :
|
|
|
|
|
+ t('inventory.bulk.resetUsageTitle')
|
|
|
|
|
+ }
|
|
|
|
|
+ message={
|
|
|
|
|
+ bulkConfirmAction === 'delete' ? t('inventory.bulk.deleteMessage', { count: selectedIds.size }) :
|
|
|
|
|
+ bulkConfirmAction === 'archive' ? t('inventory.bulk.archiveMessage', { count: selectedIds.size }) :
|
|
|
|
|
+ bulkConfirmAction === 'restore' ? t('inventory.bulk.restoreMessage', { count: selectedIds.size }) :
|
|
|
|
|
+ t('inventory.bulk.resetUsageMessage', { count: selectedIds.size })
|
|
|
|
|
+ }
|
|
|
|
|
+ confirmText={
|
|
|
|
|
+ bulkConfirmAction === 'delete' ? t('common.delete') :
|
|
|
|
|
+ bulkConfirmAction === 'archive' ? t('inventory.archive') :
|
|
|
|
|
+ bulkConfirmAction === 'restore' ? t('inventory.restore') :
|
|
|
|
|
+ t('inventory.resetConsumedCounter')
|
|
|
|
|
+ }
|
|
|
|
|
+ variant={bulkConfirmAction === 'delete' ? 'danger' : 'warning'}
|
|
|
|
|
+ isLoading={
|
|
|
|
|
+ bulkDeleteMutation.isPending ||
|
|
|
|
|
+ bulkArchiveMutation.isPending ||
|
|
|
|
|
+ bulkRestoreMutation.isPending ||
|
|
|
|
|
+ bulkResetConsumedCounterMutation.isPending
|
|
|
|
|
+ }
|
|
|
|
|
+ onConfirm={() => {
|
|
|
|
|
+ const ids = [...selectedIds];
|
|
|
|
|
+ if (bulkConfirmAction === 'delete') bulkDeleteMutation.mutate(ids);
|
|
|
|
|
+ else if (bulkConfirmAction === 'archive') bulkArchiveMutation.mutate(ids);
|
|
|
|
|
+ else if (bulkConfirmAction === 'restore') bulkRestoreMutation.mutate(ids);
|
|
|
|
|
+ else bulkResetConsumedCounterMutation.mutate(ids);
|
|
|
|
|
+ }}
|
|
|
|
|
+ onCancel={() => setBulkConfirmAction(null)}
|
|
|
|
|
+ />
|
|
|
|
|
+ )}
|
|
|
|
|
+
|
|
|
{csvImportOpen && (
|
|
{csvImportOpen && (
|
|
|
<SpoolCsvImportModal
|
|
<SpoolCsvImportModal
|
|
|
onClose={() => setCsvImportOpen(false)}
|
|
onClose={() => setCsvImportOpen(false)}
|
|
@@ -2223,12 +2515,15 @@ function SpoolCard({
|
|
|
|
|
|
|
|
/* Single spool row for table view */
|
|
/* Single spool row for table view */
|
|
|
function SpoolTableRow({
|
|
function SpoolTableRow({
|
|
|
- spool, remaining, pct, onEdit, onCopy, onRestore, onArchive, onDelete, onPrintLabel, onResetConsumedCounter,
|
|
|
|
|
|
|
+ spool, remaining, pct, isSelected, onToggleSelected,
|
|
|
|
|
+ onEdit, onCopy, onRestore, onArchive, onDelete, onPrintLabel, onResetConsumedCounter,
|
|
|
visibleColumns, assignmentMap, catalogMap, currencySymbol, dateFormat, t, onSyncWeight,
|
|
visibleColumns, assignmentMap, catalogMap, currencySymbol, dateFormat, t, onSyncWeight,
|
|
|
}: {
|
|
}: {
|
|
|
spool: InventorySpool;
|
|
spool: InventorySpool;
|
|
|
remaining: number;
|
|
remaining: number;
|
|
|
pct: number;
|
|
pct: number;
|
|
|
|
|
+ isSelected?: boolean;
|
|
|
|
|
+ onToggleSelected?: () => void;
|
|
|
onEdit: () => void;
|
|
onEdit: () => void;
|
|
|
onCopy?: () => void;
|
|
onCopy?: () => void;
|
|
|
onRestore: () => void;
|
|
onRestore: () => void;
|
|
@@ -2248,9 +2543,20 @@ function SpoolTableRow({
|
|
|
<tr
|
|
<tr
|
|
|
className={`border-b border-bambu-dark-tertiary/50 hover:bg-bambu-dark-tertiary/30 transition-colors cursor-pointer ${
|
|
className={`border-b border-bambu-dark-tertiary/50 hover:bg-bambu-dark-tertiary/30 transition-colors cursor-pointer ${
|
|
|
spool.archived_at ? 'opacity-50' : ''
|
|
spool.archived_at ? 'opacity-50' : ''
|
|
|
- }`}
|
|
|
|
|
|
|
+ } ${isSelected ? 'bg-bambu-green/10' : ''}`}
|
|
|
onClick={onEdit}
|
|
onClick={onEdit}
|
|
|
>
|
|
>
|
|
|
|
|
+ <td className="w-10 px-3 py-3" onClick={(e) => e.stopPropagation()}>
|
|
|
|
|
+ {onToggleSelected && (
|
|
|
|
|
+ <input
|
|
|
|
|
+ type="checkbox"
|
|
|
|
|
+ className="h-4 w-4 cursor-pointer"
|
|
|
|
|
+ aria-label={t('inventory.bulk.selectRow')}
|
|
|
|
|
+ checked={!!isSelected}
|
|
|
|
|
+ onChange={onToggleSelected}
|
|
|
|
|
+ />
|
|
|
|
|
+ )}
|
|
|
|
|
+ </td>
|
|
|
{visibleColumns.map((colId) => (
|
|
{visibleColumns.map((colId) => (
|
|
|
<td key={colId} className="py-3 px-4">
|
|
<td key={colId} className="py-3 px-4">
|
|
|
{columnCells[colId]?.({ spool, remaining, pct, assignmentMap, catalogMap, currencySymbol, dateFormat, t, onSyncWeight })}
|
|
{columnCells[colId]?.({ spool, remaining, pct, assignmentMap, catalogMap, currencySymbol, dateFormat, t, onSyncWeight })}
|
|
@@ -2303,6 +2609,7 @@ function SpoolTableGroup({
|
|
|
spools, headerSpool, remaining, pct, isExpanded, onToggle,
|
|
spools, headerSpool, remaining, pct, isExpanded, onToggle,
|
|
|
onEdit, onCopy, onArchive, onDelete, onPrintLabel, onResetConsumedCounter,
|
|
onEdit, onCopy, onArchive, onDelete, onPrintLabel, onResetConsumedCounter,
|
|
|
visibleColumns, assignmentMap, catalogMap, currencySymbol, dateFormat, t, onSyncWeight,
|
|
visibleColumns, assignmentMap, catalogMap, currencySymbol, dateFormat, t, onSyncWeight,
|
|
|
|
|
+ selectedIds, onToggleSelected, onToggleGroupSelected,
|
|
|
}: {
|
|
}: {
|
|
|
spools: InventorySpool[];
|
|
spools: InventorySpool[];
|
|
|
// Aggregate of all members (summed quantities, shared identity) — rendered
|
|
// Aggregate of all members (summed quantities, shared identity) — rendered
|
|
@@ -2325,7 +2632,11 @@ function SpoolTableGroup({
|
|
|
dateFormat: DateFormat;
|
|
dateFormat: DateFormat;
|
|
|
t: TFn;
|
|
t: TFn;
|
|
|
onSyncWeight?: (spool: InventorySpool) => void;
|
|
onSyncWeight?: (spool: InventorySpool) => void;
|
|
|
|
|
+ selectedIds?: Set<number>;
|
|
|
|
|
+ onToggleSelected?: (id: number) => void;
|
|
|
|
|
+ onToggleGroupSelected?: (ids: number[], select: boolean) => void;
|
|
|
}) {
|
|
}) {
|
|
|
|
|
+ const allMembersSelected = !!selectedIds && spools.every((s) => selectedIds.has(s.id));
|
|
|
return (
|
|
return (
|
|
|
<>
|
|
<>
|
|
|
{/* Group header row */}
|
|
{/* Group header row */}
|
|
@@ -2333,6 +2644,17 @@ function SpoolTableGroup({
|
|
|
className="border-b border-bambu-dark-tertiary/50 hover:bg-bambu-dark-tertiary/30 transition-colors cursor-pointer bg-bambu-green/5"
|
|
className="border-b border-bambu-dark-tertiary/50 hover:bg-bambu-dark-tertiary/30 transition-colors cursor-pointer bg-bambu-green/5"
|
|
|
onClick={onToggle}
|
|
onClick={onToggle}
|
|
|
>
|
|
>
|
|
|
|
|
+ <td className="w-10 px-3 py-3" onClick={(e) => e.stopPropagation()}>
|
|
|
|
|
+ {onToggleGroupSelected && (
|
|
|
|
|
+ <input
|
|
|
|
|
+ type="checkbox"
|
|
|
|
|
+ className="h-4 w-4 cursor-pointer"
|
|
|
|
|
+ aria-label={t('inventory.bulk.selectGroup')}
|
|
|
|
|
+ checked={allMembersSelected}
|
|
|
|
|
+ onChange={(e) => onToggleGroupSelected(spools.map((s) => s.id), e.target.checked)}
|
|
|
|
|
+ />
|
|
|
|
|
+ )}
|
|
|
|
|
+ </td>
|
|
|
{visibleColumns.map((colId, idx) => (
|
|
{visibleColumns.map((colId, idx) => (
|
|
|
<td key={colId} className="py-3 px-4">
|
|
<td key={colId} className="py-3 px-4">
|
|
|
{idx === 0 ? (
|
|
{idx === 0 ? (
|
|
@@ -2365,6 +2687,8 @@ function SpoolTableGroup({
|
|
|
spool={spool}
|
|
spool={spool}
|
|
|
remaining={r}
|
|
remaining={r}
|
|
|
pct={p}
|
|
pct={p}
|
|
|
|
|
+ isSelected={selectedIds?.has(spool.id)}
|
|
|
|
|
+ onToggleSelected={onToggleSelected ? () => onToggleSelected(spool.id) : undefined}
|
|
|
onEdit={() => onEdit(spool)}
|
|
onEdit={() => onEdit(spool)}
|
|
|
onCopy={onCopy ? () => onCopy(spool) : undefined}
|
|
onCopy={onCopy ? () => onCopy(spool) : undefined}
|
|
|
onRestore={() => {}}
|
|
onRestore={() => {}}
|