mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-18 11:56:01 +02:00
Add visual spool grouping and optional brand/subtype in quick-add (#480)
Group toggle in inventory toolbar collapses identical unused/unassigned spools into expandable rows/cards with count badges. Grouping key uses material, subtype, brand, color, and label weight. Brand and subtype fields now appear in quick-add mode as optional (no asterisk, no validation). Quantity field restricted to quick-add mode only.
This commit is contained in:
@@ -3,9 +3,11 @@
|
||||
*
|
||||
* Verifies:
|
||||
* - Quick-add toggle appears only in create mode
|
||||
* - Quick-add mode hides slicer preset, brand, subtype fields
|
||||
* - Quick-add mode shows brand and subtype as optional (no asterisk)
|
||||
* - Quick-add mode hides slicer preset field
|
||||
* - Quick-add mode hides PA Profile tab
|
||||
* - Quantity field is rendered in filament section
|
||||
* - Quantity field is only rendered in quick-add mode
|
||||
* - Quantity field is hidden in edit mode
|
||||
* - Bulk create calls bulkCreateSpools when quantity > 1
|
||||
* - Single quantity calls createSpool as before
|
||||
* - validateForm with quickAdd=true only requires material
|
||||
@@ -199,7 +201,7 @@ describe('SpoolFormModal quick-add toggle', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('renders quantity field', async () => {
|
||||
it('hides quantity field by default (non-quick-add)', async () => {
|
||||
render(
|
||||
<SpoolFormModal
|
||||
isOpen={true}
|
||||
@@ -212,8 +214,88 @@ describe('SpoolFormModal quick-add toggle', () => {
|
||||
expect(screen.getByRole('heading', { name: 'Add Spool' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Quantity field should be visible
|
||||
expect(screen.getByText('Quantity')).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('1')).toBeInTheDocument();
|
||||
// Quantity field should NOT be visible in normal create mode
|
||||
expect(screen.queryByText('Quantity')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows quantity field only in quick-add mode', async () => {
|
||||
render(
|
||||
<SpoolFormModal
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
currencySymbol="$"
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('heading', { name: 'Add Spool' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Toggle quick-add on
|
||||
const toggleButtons = screen.getAllByRole('button');
|
||||
const quickAddToggle = toggleButtons.find(btn =>
|
||||
btn.getAttribute('type') === 'button' &&
|
||||
btn.className.includes('rounded-full') &&
|
||||
btn.closest('div')?.textContent?.includes('Quick Add')
|
||||
);
|
||||
expect(quickAddToggle).toBeTruthy();
|
||||
fireEvent.click(quickAddToggle!);
|
||||
|
||||
// Quantity field should now be visible
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Quantity')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('hides quantity field in edit mode', async () => {
|
||||
render(
|
||||
<SpoolFormModal
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
spool={existingSpool}
|
||||
currencySymbol="$"
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Edit Spool')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Quantity field should NOT be visible in edit mode
|
||||
expect(screen.queryByText('Quantity')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows brand and subtype in quick-add mode without asterisk', async () => {
|
||||
render(
|
||||
<SpoolFormModal
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
currencySymbol="$"
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('heading', { name: 'Add Spool' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Toggle quick-add on
|
||||
const toggleButtons = screen.getAllByRole('button');
|
||||
const quickAddToggle = toggleButtons.find(btn =>
|
||||
btn.getAttribute('type') === 'button' &&
|
||||
btn.className.includes('rounded-full') &&
|
||||
btn.closest('div')?.textContent?.includes('Quick Add')
|
||||
);
|
||||
fireEvent.click(quickAddToggle!);
|
||||
|
||||
// Brand and Subtype should be visible (without asterisk = optional)
|
||||
await waitFor(() => {
|
||||
const brandLabel = screen.getByText('Brand');
|
||||
expect(brandLabel).toBeInTheDocument();
|
||||
expect(brandLabel.textContent).not.toContain('*');
|
||||
|
||||
const subtypeLabel = screen.getByText('Subtype');
|
||||
expect(subtypeLabel).toBeInTheDocument();
|
||||
expect(subtypeLabel.textContent).not.toContain('*');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
/**
|
||||
* Tests for the spool grouping logic used in InventoryPage.
|
||||
*
|
||||
* The grouping is a pure client-side computation:
|
||||
* - Spools with identical material+subtype+brand+color_name+rgba+label_weight are grouped
|
||||
* - Only unused (weight_used === 0) and unassigned spools are eligible for grouping
|
||||
* - Used or assigned spools always appear individually
|
||||
* - Groups with only 1 member remain as singles
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import type { InventorySpool, SpoolAssignment } from '../../api/client';
|
||||
|
||||
// Replicate the grouping key function from InventoryPage (not exported)
|
||||
function spoolGroupKey(s: InventorySpool): string {
|
||||
return `${s.material}|${s.subtype || ''}|${s.brand || ''}|${s.color_name || ''}|${s.rgba || ''}|${s.label_weight}`;
|
||||
}
|
||||
|
||||
type DisplayItem =
|
||||
| { type: 'single'; spool: InventorySpool }
|
||||
| { type: 'group'; key: string; spools: InventorySpool[]; representative: InventorySpool };
|
||||
|
||||
// Replicate the grouping logic from InventoryPage
|
||||
function computeDisplayItems(
|
||||
sortedSpools: InventorySpool[],
|
||||
assignmentMap: Record<number, SpoolAssignment>,
|
||||
): DisplayItem[] {
|
||||
const groups = new Map<string, InventorySpool[]>();
|
||||
|
||||
for (const spool of sortedSpools) {
|
||||
if (spool.weight_used > 0 || assignmentMap[spool.id]) {
|
||||
// Will be added as singles in the walk below
|
||||
} else {
|
||||
const key = spoolGroupKey(spool);
|
||||
const arr = groups.get(key);
|
||||
if (arr) arr.push(spool);
|
||||
else groups.set(key, [spool]);
|
||||
}
|
||||
}
|
||||
|
||||
const items: DisplayItem[] = [];
|
||||
const processedKeys = new Set<string>();
|
||||
|
||||
for (const spool of sortedSpools) {
|
||||
if (spool.weight_used > 0 || assignmentMap[spool.id]) {
|
||||
items.push({ type: 'single', spool });
|
||||
continue;
|
||||
}
|
||||
const key = spoolGroupKey(spool);
|
||||
if (processedKeys.has(key)) continue;
|
||||
processedKeys.add(key);
|
||||
const members = groups.get(key)!;
|
||||
if (members.length === 1) {
|
||||
items.push({ type: 'single', spool: members[0] });
|
||||
} else {
|
||||
items.push({ type: 'group', key, spools: members, representative: members[0] });
|
||||
}
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
function makeSpool(overrides: Partial<InventorySpool> & { id: number }): InventorySpool {
|
||||
return {
|
||||
material: 'PLA',
|
||||
subtype: 'Basic',
|
||||
brand: 'Polymaker',
|
||||
color_name: 'Red',
|
||||
rgba: 'FF0000FF',
|
||||
label_weight: 1000,
|
||||
core_weight: 250,
|
||||
core_weight_catalog_id: null,
|
||||
weight_used: 0,
|
||||
slicer_filament: null,
|
||||
slicer_filament_name: null,
|
||||
nozzle_temp_min: null,
|
||||
nozzle_temp_max: null,
|
||||
note: null,
|
||||
added_full: null,
|
||||
last_used: null,
|
||||
encode_time: null,
|
||||
tag_uid: null,
|
||||
tray_uuid: null,
|
||||
data_origin: null,
|
||||
tag_type: null,
|
||||
archived_at: null,
|
||||
created_at: '2025-01-01T00:00:00Z',
|
||||
updated_at: '2025-01-01T00:00:00Z',
|
||||
k_profiles: [],
|
||||
cost_per_kg: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('spoolGroupKey', () => {
|
||||
it('generates same key for identical spools', () => {
|
||||
const a = makeSpool({ id: 1 });
|
||||
const b = makeSpool({ id: 2 });
|
||||
expect(spoolGroupKey(a)).toBe(spoolGroupKey(b));
|
||||
});
|
||||
|
||||
it('generates different key when material differs', () => {
|
||||
const a = makeSpool({ id: 1, material: 'PLA' });
|
||||
const b = makeSpool({ id: 2, material: 'PETG' });
|
||||
expect(spoolGroupKey(a)).not.toBe(spoolGroupKey(b));
|
||||
});
|
||||
|
||||
it('generates different key when subtype differs', () => {
|
||||
const a = makeSpool({ id: 1, subtype: 'Basic' });
|
||||
const b = makeSpool({ id: 2, subtype: 'Matte' });
|
||||
expect(spoolGroupKey(a)).not.toBe(spoolGroupKey(b));
|
||||
});
|
||||
|
||||
it('generates different key when brand differs', () => {
|
||||
const a = makeSpool({ id: 1, brand: 'Polymaker' });
|
||||
const b = makeSpool({ id: 2, brand: 'Bambu Lab' });
|
||||
expect(spoolGroupKey(a)).not.toBe(spoolGroupKey(b));
|
||||
});
|
||||
|
||||
it('generates different key when color_name differs', () => {
|
||||
const a = makeSpool({ id: 1, color_name: 'Red' });
|
||||
const b = makeSpool({ id: 2, color_name: 'Blue' });
|
||||
expect(spoolGroupKey(a)).not.toBe(spoolGroupKey(b));
|
||||
});
|
||||
|
||||
it('generates different key when label_weight differs', () => {
|
||||
const a = makeSpool({ id: 1, label_weight: 1000 });
|
||||
const b = makeSpool({ id: 2, label_weight: 500 });
|
||||
expect(spoolGroupKey(a)).not.toBe(spoolGroupKey(b));
|
||||
});
|
||||
|
||||
it('treats null and empty string subtype the same', () => {
|
||||
const a = makeSpool({ id: 1, subtype: null as unknown as string });
|
||||
const b = makeSpool({ id: 2, subtype: '' });
|
||||
expect(spoolGroupKey(a)).toBe(spoolGroupKey(b));
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeDisplayItems', () => {
|
||||
it('groups identical unused unassigned spools', () => {
|
||||
const spools = [
|
||||
makeSpool({ id: 1 }),
|
||||
makeSpool({ id: 2 }),
|
||||
makeSpool({ id: 3 }),
|
||||
];
|
||||
const items = computeDisplayItems(spools, {});
|
||||
expect(items).toHaveLength(1);
|
||||
expect(items[0].type).toBe('group');
|
||||
if (items[0].type === 'group') {
|
||||
expect(items[0].spools).toHaveLength(3);
|
||||
expect(items[0].representative.id).toBe(1);
|
||||
}
|
||||
});
|
||||
|
||||
it('does not group spools with different properties', () => {
|
||||
const spools = [
|
||||
makeSpool({ id: 1, material: 'PLA' }),
|
||||
makeSpool({ id: 2, material: 'PETG' }),
|
||||
makeSpool({ id: 3, material: 'ABS' }),
|
||||
];
|
||||
const items = computeDisplayItems(spools, {});
|
||||
expect(items).toHaveLength(3);
|
||||
expect(items.every((i) => i.type === 'single')).toBe(true);
|
||||
});
|
||||
|
||||
it('excludes used spools from groups', () => {
|
||||
const spools = [
|
||||
makeSpool({ id: 1, weight_used: 0 }),
|
||||
makeSpool({ id: 2, weight_used: 100 }), // used
|
||||
makeSpool({ id: 3, weight_used: 0 }),
|
||||
];
|
||||
const items = computeDisplayItems(spools, {});
|
||||
// 1 group (id:1, id:3) + 1 single (id:2)
|
||||
expect(items).toHaveLength(2);
|
||||
const group = items.find((i) => i.type === 'group');
|
||||
const single = items.find((i) => i.type === 'single');
|
||||
expect(group).toBeDefined();
|
||||
expect(single).toBeDefined();
|
||||
if (group?.type === 'group') {
|
||||
expect(group.spools).toHaveLength(2);
|
||||
expect(group.spools.map((s) => s.id).sort()).toEqual([1, 3]);
|
||||
}
|
||||
if (single?.type === 'single') {
|
||||
expect(single.spool.id).toBe(2);
|
||||
}
|
||||
});
|
||||
|
||||
it('excludes assigned spools from groups', () => {
|
||||
const spools = [
|
||||
makeSpool({ id: 1 }),
|
||||
makeSpool({ id: 2 }), // assigned
|
||||
makeSpool({ id: 3 }),
|
||||
];
|
||||
const assignmentMap: Record<number, SpoolAssignment> = {
|
||||
2: {
|
||||
spool_id: 2,
|
||||
printer_id: 1,
|
||||
printer_name: 'P1S',
|
||||
ams_id: 0,
|
||||
tray_id: 0,
|
||||
configured: true,
|
||||
fingerprint_color: null,
|
||||
fingerprint_type: null,
|
||||
},
|
||||
};
|
||||
const items = computeDisplayItems(spools, assignmentMap);
|
||||
// 1 group (id:1, id:3) + 1 single (id:2)
|
||||
expect(items).toHaveLength(2);
|
||||
const group = items.find((i) => i.type === 'group');
|
||||
expect(group?.type).toBe('group');
|
||||
if (group?.type === 'group') {
|
||||
expect(group.spools.map((s) => s.id).sort()).toEqual([1, 3]);
|
||||
}
|
||||
});
|
||||
|
||||
it('does not group a single spool', () => {
|
||||
const spools = [makeSpool({ id: 1 })];
|
||||
const items = computeDisplayItems(spools, {});
|
||||
expect(items).toHaveLength(1);
|
||||
expect(items[0].type).toBe('single');
|
||||
});
|
||||
|
||||
it('preserves order — group appears at first member position', () => {
|
||||
const spools = [
|
||||
makeSpool({ id: 1, material: 'PETG' }), // unique
|
||||
makeSpool({ id: 2, material: 'PLA' }), // group member
|
||||
makeSpool({ id: 3, material: 'PLA' }), // group member
|
||||
makeSpool({ id: 4, material: 'ABS' }), // unique
|
||||
];
|
||||
const items = computeDisplayItems(spools, {});
|
||||
expect(items).toHaveLength(3);
|
||||
expect(items[0].type).toBe('single'); // PETG
|
||||
expect(items[1].type).toBe('group'); // PLA group at position of id:2
|
||||
expect(items[2].type).toBe('single'); // ABS
|
||||
if (items[1].type === 'group') {
|
||||
expect(items[1].spools.map((s) => s.id)).toEqual([2, 3]);
|
||||
}
|
||||
});
|
||||
|
||||
it('handles mix of groupable and non-groupable spools', () => {
|
||||
const spools = [
|
||||
makeSpool({ id: 1, material: 'PLA' }), // groupable
|
||||
makeSpool({ id: 2, material: 'PLA', weight_used: 50 }), // used → single
|
||||
makeSpool({ id: 3, material: 'PLA' }), // groupable
|
||||
makeSpool({ id: 4, material: 'PETG' }), // different → single
|
||||
];
|
||||
const items = computeDisplayItems(spools, {});
|
||||
// PLA group (id:1,3) + PLA used single (id:2) + PETG single (id:4)
|
||||
expect(items).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('returns all singles when no spools can be grouped', () => {
|
||||
const spools = [
|
||||
makeSpool({ id: 1, material: 'PLA', weight_used: 100 }),
|
||||
makeSpool({ id: 2, material: 'PETG', weight_used: 200 }),
|
||||
];
|
||||
const items = computeDisplayItems(spools, {});
|
||||
expect(items).toHaveLength(2);
|
||||
expect(items.every((i) => i.type === 'single')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns empty array for empty input', () => {
|
||||
const items = computeDisplayItems([], {});
|
||||
expect(items).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -255,10 +255,11 @@ export function FilamentSection({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Brand (dropdown with search) — hidden in quick-add mode */}
|
||||
{!quickAdd && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-bambu-gray mb-1">{t('inventory.brand')} *</label>
|
||||
{/* Brand (dropdown with search) */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-bambu-gray mb-1">
|
||||
{t('inventory.brand')}{!quickAdd && ' *'}
|
||||
</label>
|
||||
<div className="relative" ref={brandRef}>
|
||||
<input
|
||||
type="text"
|
||||
@@ -317,13 +318,13 @@ export function FilamentSection({
|
||||
{errors?.brand && (
|
||||
<p className="mt-1 text-xs text-red-400">{errors.brand}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Variant / Subtype — hidden in quick-add mode */}
|
||||
{!quickAdd && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-bambu-gray mb-1">{t('inventory.subtype')} *</label>
|
||||
{/* Variant / Subtype */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-bambu-gray mb-1">
|
||||
{t('inventory.subtype')}{!quickAdd && ' *'}
|
||||
</label>
|
||||
<div className="relative" ref={subtypeRef}>
|
||||
<input
|
||||
type="text"
|
||||
@@ -381,8 +382,7 @@ export function FilamentSection({
|
||||
{errors?.subtype && (
|
||||
<p className="mt-1 text-xs text-red-400">{errors.subtype}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Label Weight */}
|
||||
<div>
|
||||
@@ -412,21 +412,23 @@ export function FilamentSection({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quantity */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-bambu-gray mb-1">{t('inventory.quantity')}</label>
|
||||
<input
|
||||
type="number"
|
||||
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:outline-none focus:border-bambu-green"
|
||||
value={quantity}
|
||||
min={1}
|
||||
max={100}
|
||||
onChange={(e) => {
|
||||
const val = Math.max(1, Math.min(100, parseInt(e.target.value) || 1));
|
||||
onQuantityChange(val);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{/* Quantity — only in quick-add mode */}
|
||||
{quickAdd && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-bambu-gray mb-1">{t('inventory.quantity')}</label>
|
||||
<input
|
||||
type="number"
|
||||
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:outline-none focus:border-bambu-green"
|
||||
value={quantity}
|
||||
min={1}
|
||||
max={100}
|
||||
onChange={(e) => {
|
||||
const val = Math.max(1, Math.min(100, parseInt(e.target.value) || 1));
|
||||
onQuantityChange(val);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -2668,6 +2668,10 @@ export default {
|
||||
table: 'Tabelle',
|
||||
cards: 'Karten',
|
||||
net: 'Netto',
|
||||
// Grouping
|
||||
groupSimilar: 'Gruppieren',
|
||||
groupedSpools: '{{count}} identische Spulen',
|
||||
groupedRows: 'Zeilen',
|
||||
// Column config
|
||||
columns: 'Spalten',
|
||||
configureColumns: 'Spalten konfigurieren',
|
||||
|
||||
@@ -2672,6 +2672,10 @@ export default {
|
||||
table: 'Table',
|
||||
cards: 'Cards',
|
||||
net: 'Net',
|
||||
// Grouping
|
||||
groupSimilar: 'Group',
|
||||
groupedSpools: '{{count}} identical spools',
|
||||
groupedRows: 'rows',
|
||||
// Column config
|
||||
columns: 'Columns',
|
||||
configureColumns: 'Configure Columns',
|
||||
|
||||
@@ -2594,6 +2594,9 @@ export default {
|
||||
table: 'テーブル',
|
||||
cards: 'カード',
|
||||
net: '正味',
|
||||
groupSimilar: 'グループ化',
|
||||
groupedSpools: '{{count}}本の同一スプール',
|
||||
groupedRows: '行',
|
||||
columns: '列',
|
||||
configureColumns: '列の設定',
|
||||
configureColumnsDesc: 'ドラッグして並べ替えるか、矢印を使用してください。目のアイコンで表示/非表示を切り替えます。',
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
Plus, Loader2, Trash2, Archive, RotateCcw, Edit2, Package,
|
||||
Search, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight,
|
||||
TrendingDown, Layers, Printer, AlertTriangle, X, Clock, LayoutGrid, TableProperties, Columns,
|
||||
ArrowUp, ArrowDown, ArrowUpDown,
|
||||
ArrowUp, ArrowDown, ArrowUpDown, Group, ChevronDown,
|
||||
} from 'lucide-react';
|
||||
import { api } from '../api/client';
|
||||
import type { InventorySpool, SpoolAssignment } from '../api/client';
|
||||
@@ -25,6 +25,14 @@ type ViewMode = 'table' | 'cards';
|
||||
type SortDirection = 'asc' | 'desc';
|
||||
type SortState = { column: string; direction: SortDirection } | null;
|
||||
|
||||
type DisplayItem =
|
||||
| { type: 'single'; spool: InventorySpool }
|
||||
| { type: 'group'; key: string; spools: InventorySpool[]; representative: InventorySpool };
|
||||
|
||||
function spoolGroupKey(s: InventorySpool): string {
|
||||
return `${s.material}|${s.subtype || ''}|${s.brand || ''}|${s.color_name || ''}|${s.rgba || ''}|${s.label_weight}`;
|
||||
}
|
||||
|
||||
// Column definitions for the inventory table
|
||||
const COLUMN_CONFIG_KEY = 'bambuddy-inventory-columns';
|
||||
|
||||
@@ -101,7 +109,7 @@ const MATERIAL_COLORS: Record<string, string> = {
|
||||
PET: 'bg-sky-500/20 text-sky-400',
|
||||
};
|
||||
|
||||
type TFn = (key: string) => string;
|
||||
type TFn = (key: string, opts?: Record<string, unknown>) => string;
|
||||
|
||||
function formatInventoryDate(dateStr: string | null, dateFormat: DateFormat = 'system'): string {
|
||||
if (!dateStr) return '-';
|
||||
@@ -347,6 +355,12 @@ export default function InventoryPage() {
|
||||
const [sortState, setSortState] = useState<SortState>(loadSortState);
|
||||
const [columnConfig, setColumnConfig] = useState<ColumnConfig[]>(loadColumnConfig);
|
||||
const [showColumnModal, setShowColumnModal] = useState(false);
|
||||
const [groupSimilar, setGroupSimilar] = useState(() => {
|
||||
try {
|
||||
return localStorage.getItem('bambuddy-inventory-group') === 'true';
|
||||
} catch { return false; }
|
||||
});
|
||||
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set());
|
||||
|
||||
// Pagination state (pageSize persisted to localStorage)
|
||||
const [pageIndex, setPageIndex] = useState(0);
|
||||
@@ -552,12 +566,71 @@ export default function InventoryPage() {
|
||||
return sorted;
|
||||
}, [filteredSpools, sortState, assignmentMap]);
|
||||
|
||||
// Group similar spools when toggle is active
|
||||
const displayItems = useMemo((): DisplayItem[] => {
|
||||
if (!groupSimilar) return sortedSpools.map((s) => ({ type: 'single' as const, spool: s }));
|
||||
|
||||
const groups = new Map<string, InventorySpool[]>();
|
||||
|
||||
for (const spool of sortedSpools) {
|
||||
// Only group unused & unassigned spools
|
||||
if (spool.weight_used > 0 || assignmentMap[spool.id]) {
|
||||
// Will be added as singles in the walk below
|
||||
} else {
|
||||
const key = spoolGroupKey(spool);
|
||||
const arr = groups.get(key);
|
||||
if (arr) arr.push(spool);
|
||||
else groups.set(key, [spool]);
|
||||
}
|
||||
}
|
||||
|
||||
const items: DisplayItem[] = [];
|
||||
const processedKeys = new Set<string>();
|
||||
|
||||
// Walk sortedSpools order so groups appear at the position of their first member
|
||||
for (const spool of sortedSpools) {
|
||||
if (spool.weight_used > 0 || assignmentMap[spool.id]) {
|
||||
items.push({ type: 'single', spool });
|
||||
continue;
|
||||
}
|
||||
const key = spoolGroupKey(spool);
|
||||
if (processedKeys.has(key)) continue;
|
||||
processedKeys.add(key);
|
||||
const members = groups.get(key)!;
|
||||
if (members.length === 1) {
|
||||
items.push({ type: 'single', spool: members[0] });
|
||||
} else {
|
||||
items.push({ type: 'group', key, spools: members, representative: members[0] });
|
||||
}
|
||||
}
|
||||
return items;
|
||||
}, [sortedSpools, groupSimilar, assignmentMap]);
|
||||
|
||||
// Pagination (after sorting) — pageSize -1 means "All"
|
||||
const showAll = pageSize === -1;
|
||||
const effectivePageSize = showAll ? sortedSpools.length || 1 : pageSize;
|
||||
const totalPages = Math.max(1, Math.ceil(sortedSpools.length / effectivePageSize));
|
||||
const totalDisplayItems = displayItems.length;
|
||||
const effectivePageSize = showAll ? totalDisplayItems || 1 : pageSize;
|
||||
const totalPages = Math.max(1, Math.ceil(totalDisplayItems / effectivePageSize));
|
||||
const safePageIndex = showAll ? 0 : Math.min(pageIndex, totalPages - 1);
|
||||
const pagedSpools = showAll ? sortedSpools : sortedSpools.slice(safePageIndex * effectivePageSize, (safePageIndex + 1) * effectivePageSize);
|
||||
const pagedItems = showAll
|
||||
? displayItems
|
||||
: displayItems.slice(safePageIndex * effectivePageSize, (safePageIndex + 1) * effectivePageSize);
|
||||
const toggleGroupSimilar = () => {
|
||||
const next = !groupSimilar;
|
||||
setGroupSimilar(next);
|
||||
setExpandedGroups(new Set());
|
||||
resetPage();
|
||||
try { localStorage.setItem('bambuddy-inventory-group', String(next)); } catch { /* ignore */ }
|
||||
};
|
||||
|
||||
const toggleGroupExpand = (key: string) => {
|
||||
setExpandedGroups((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(key)) next.delete(key);
|
||||
else next.add(key);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handlePageSizeChange = (size: number) => {
|
||||
setPageSize(size);
|
||||
@@ -688,6 +761,19 @@ export default function InventoryPage() {
|
||||
<span className="hidden sm:inline">{t('inventory.columns')}</span>
|
||||
</button>
|
||||
)}
|
||||
{/* Group similar toggle */}
|
||||
<button
|
||||
onClick={toggleGroupSimilar}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium border rounded-lg transition-colors ${
|
||||
groupSimilar
|
||||
? 'bg-bambu-green/20 text-bambu-green border-bambu-green/30'
|
||||
: 'text-bambu-gray border-bambu-dark-tertiary hover:bg-bambu-dark-tertiary'
|
||||
}`}
|
||||
title={t('inventory.groupSimilar')}
|
||||
>
|
||||
<Group className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">{t('inventory.groupSimilar')}</span>
|
||||
</button>
|
||||
{/* Table / Cards toggle */}
|
||||
<div className="flex bg-bambu-dark-primary border border-bambu-dark-tertiary rounded-lg overflow-hidden">
|
||||
<button
|
||||
@@ -866,6 +952,7 @@ export default function InventoryPage() {
|
||||
{/* Results count */}
|
||||
<span className="ml-auto text-xs text-bambu-gray">
|
||||
{sortedSpools.length} {sortedSpools.length !== 1 ? t('inventory.spools') : t('inventory.spool')}
|
||||
{groupSimilar && totalDisplayItems < sortedSpools.length && ` (${totalDisplayItems} ${t('inventory.groupedRows')})`}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -876,69 +963,80 @@ export default function InventoryPage() {
|
||||
</div>
|
||||
) : viewMode === 'cards' ? (
|
||||
/* Cards view */
|
||||
pagedSpools.length > 0 ? (
|
||||
pagedItems.length > 0 ? (
|
||||
<>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
{pagedSpools.map((spool) => {
|
||||
{pagedItems.map((item) => {
|
||||
if (item.type === 'group') {
|
||||
const { key, spools: groupSpools, representative: rep } = item;
|
||||
const colorStyle = rep.rgba ? `#${rep.rgba.substring(0, 6)}` : '#808080';
|
||||
const isExpanded = expandedGroups.has(key);
|
||||
return (
|
||||
<div key={`group-${key}`} className="col-span-full">
|
||||
{/* Group header card */}
|
||||
<div
|
||||
className="bg-bambu-dark-secondary rounded-lg overflow-hidden border border-bambu-green/30 hover:border-bambu-green transition-colors cursor-pointer"
|
||||
onClick={() => toggleGroupExpand(key)}
|
||||
>
|
||||
<div className="h-10 flex items-center px-4 gap-3" style={{ backgroundColor: colorStyle }}>
|
||||
<span className="bg-white/90 text-gray-800 px-3 py-0.5 rounded-full text-sm font-medium">
|
||||
{resolveSpoolColorName(rep.color_name, rep.rgba) || '-'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="px-4 py-3 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<ChevronDown className={`w-4 h-4 text-bambu-gray transition-transform ${isExpanded ? '' : '-rotate-90'}`} />
|
||||
<div>
|
||||
<h3 className="font-semibold text-white">{rep.material}{rep.subtype ? ` ${rep.subtype}` : ''}</h3>
|
||||
<p className="text-sm text-bambu-gray">{rep.brand || '-'}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-bambu-gray">{formatWeight(rep.label_weight)}</span>
|
||||
<span className="text-xs font-medium bg-bambu-green/20 text-bambu-green px-2 py-0.5 rounded-full">
|
||||
{t('inventory.groupedSpools', { count: groupSpools.length })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Expanded individual spools */}
|
||||
{isExpanded && (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 mt-2 ml-4">
|
||||
{groupSpools.map((spool) => {
|
||||
const remaining = Math.max(0, spool.label_weight - spool.weight_used);
|
||||
const pct = spool.label_weight > 0 ? (remaining / spool.label_weight) * 100 : 0;
|
||||
const spoolColor = spool.rgba ? `#${spool.rgba.substring(0, 6)}` : '#808080';
|
||||
return (
|
||||
<SpoolCard
|
||||
key={spool.id}
|
||||
spool={spool}
|
||||
remaining={remaining}
|
||||
pct={pct}
|
||||
colorStyle={spoolColor}
|
||||
onClick={() => setFormModal({ spool })}
|
||||
t={t}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const spool = item.spool;
|
||||
const remaining = Math.max(0, spool.label_weight - spool.weight_used);
|
||||
const pct = spool.label_weight > 0 ? (remaining / spool.label_weight) * 100 : 0;
|
||||
const colorStyle = spool.rgba ? `#${spool.rgba.substring(0, 6)}` : '#808080';
|
||||
return (
|
||||
<div
|
||||
<SpoolCard
|
||||
key={spool.id}
|
||||
className={`bg-bambu-dark-secondary rounded-lg overflow-hidden border border-bambu-dark-tertiary hover:border-bambu-green transition-colors cursor-pointer ${spool.archived_at ? 'opacity-50' : ''}`}
|
||||
spool={spool}
|
||||
remaining={remaining}
|
||||
pct={pct}
|
||||
colorStyle={colorStyle}
|
||||
onClick={() => setFormModal({ spool })}
|
||||
>
|
||||
{/* Color header */}
|
||||
<div className="h-14 flex items-center justify-center" style={{ backgroundColor: colorStyle }}>
|
||||
<span className="bg-white/90 text-gray-800 px-3 py-0.5 rounded-full text-sm font-medium">
|
||||
{resolveSpoolColorName(spool.color_name, spool.rgba) || '-'}
|
||||
</span>
|
||||
</div>
|
||||
{/* Content */}
|
||||
<div className="p-4 space-y-3">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div>
|
||||
<h3 className="font-semibold text-white">{spool.material}{spool.subtype ? ` ${spool.subtype}` : ''}</h3>
|
||||
<p className="text-sm text-bambu-gray">{spool.brand || '-'}</p>
|
||||
</div>
|
||||
<span className="text-xs font-mono text-bambu-gray bg-bambu-dark-tertiary px-2 py-1 rounded">#{spool.id}</span>
|
||||
</div>
|
||||
{/* Progress */}
|
||||
<div>
|
||||
<div className="flex justify-between text-xs text-bambu-gray mb-1">
|
||||
<span>{t('inventory.remaining')}</span>
|
||||
<span>{Math.round(pct)}%</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 h-2 bg-bambu-dark-tertiary rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full ${pct > 50 ? 'bg-bambu-green' : pct > 20 ? 'bg-yellow-500' : 'bg-red-500'}`}
|
||||
style={{ width: `${Math.min(pct, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-bambu-gray min-w-[40px] text-right">{Math.round(remaining)}g</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* Weight info */}
|
||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||
<div>
|
||||
<span className="text-bambu-gray/60">{t('inventory.labelWeight')}: </span>
|
||||
<span className="text-bambu-gray">{formatWeight(spool.label_weight)}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-bambu-gray/60">{t('inventory.weightUsed')}: </span>
|
||||
<span className="text-bambu-gray">{spool.weight_used > 0 ? formatWeight(spool.weight_used) : '-'}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* Note */}
|
||||
{spool.note && (
|
||||
<div className="text-xs text-bambu-gray/60 pt-2 border-t border-bambu-dark-tertiary truncate" title={spool.note}>
|
||||
{spool.note}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
t={t}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
@@ -946,7 +1044,7 @@ export default function InventoryPage() {
|
||||
<PaginationBar
|
||||
pageIndex={safePageIndex}
|
||||
pageSize={pageSize}
|
||||
totalRows={sortedSpools.length}
|
||||
totalRows={totalDisplayItems}
|
||||
totalPages={totalPages}
|
||||
onPageChange={setPageIndex}
|
||||
onPageSizeChange={handlePageSizeChange}
|
||||
@@ -962,7 +1060,7 @@ export default function InventoryPage() {
|
||||
)
|
||||
) : (
|
||||
/* Table view */
|
||||
pagedSpools.length > 0 ? (
|
||||
pagedItems.length > 0 ? (
|
||||
<div className="bg-bambu-dark-secondary rounded-lg overflow-hidden border border-bambu-dark-tertiary">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
@@ -996,58 +1094,51 @@ export default function InventoryPage() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{pagedSpools.map((spool) => {
|
||||
{pagedItems.map((item) => {
|
||||
if (item.type === 'group') {
|
||||
const { key, spools: groupSpools, representative: rep } = item;
|
||||
const isExpanded = expandedGroups.has(key);
|
||||
const remaining = Math.max(0, rep.label_weight - rep.weight_used);
|
||||
const pct = rep.label_weight > 0 ? (remaining / rep.label_weight) * 100 : 0;
|
||||
return (
|
||||
<SpoolTableGroup
|
||||
key={`group-${key}`}
|
||||
spools={groupSpools}
|
||||
representative={rep}
|
||||
remaining={remaining}
|
||||
pct={pct}
|
||||
isExpanded={isExpanded}
|
||||
onToggle={() => toggleGroupExpand(key)}
|
||||
onEdit={(s) => setFormModal({ spool: s })}
|
||||
onArchive={(id) => setConfirmAction({ type: 'archive', spoolId: id })}
|
||||
onDelete={(id) => setConfirmAction({ type: 'delete', spoolId: id })}
|
||||
visibleColumns={visibleColumns}
|
||||
assignmentMap={assignmentMap}
|
||||
currencySymbol={currencySymbol}
|
||||
dateFormat={dateFormat}
|
||||
t={t}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const spool = item.spool;
|
||||
const remaining = Math.max(0, spool.label_weight - spool.weight_used);
|
||||
const pct = spool.label_weight > 0 ? (remaining / spool.label_weight) * 100 : 0;
|
||||
return (
|
||||
<tr
|
||||
<SpoolTableRow
|
||||
key={spool.id}
|
||||
className={`border-b border-bambu-dark-tertiary/50 hover:bg-bambu-dark-tertiary/30 transition-colors cursor-pointer ${
|
||||
spool.archived_at ? 'opacity-50' : ''
|
||||
}`}
|
||||
onClick={() => setFormModal({ spool })}
|
||||
>
|
||||
{visibleColumns.map((colId) => (
|
||||
<td key={colId} className="py-3 px-4">
|
||||
{columnCells[colId]?.({ spool, remaining, pct, assignmentMap, currencySymbol, dateFormat, t })}
|
||||
</td>
|
||||
))}
|
||||
<td className="py-3 px-4">
|
||||
<div className="flex items-center justify-end gap-1" onClick={(e) => e.stopPropagation()}>
|
||||
<button
|
||||
onClick={() => setFormModal({ spool })}
|
||||
className="p-1.5 text-bambu-gray hover:text-white rounded transition-colors"
|
||||
title={t('inventory.editSpool')}
|
||||
>
|
||||
<Edit2 className="w-4 h-4" />
|
||||
</button>
|
||||
{spool.archived_at ? (
|
||||
<button
|
||||
onClick={() => restoreMutation.mutate(spool.id)}
|
||||
className="p-1.5 text-bambu-gray hover:text-bambu-green rounded transition-colors"
|
||||
title={t('inventory.restore')}
|
||||
>
|
||||
<RotateCcw className="w-4 h-4" />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setConfirmAction({ type: 'archive', spoolId: spool.id })}
|
||||
className="p-1.5 text-bambu-gray hover:text-yellow-400 rounded transition-colors"
|
||||
title={t('inventory.archive')}
|
||||
>
|
||||
<Archive className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setConfirmAction({ type: 'delete', spoolId: spool.id })}
|
||||
className="p-1.5 text-bambu-gray hover:text-red-400 rounded transition-colors"
|
||||
title={t('common.delete')}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
spool={spool}
|
||||
remaining={remaining}
|
||||
pct={pct}
|
||||
onEdit={() => setFormModal({ spool })}
|
||||
onRestore={() => restoreMutation.mutate(spool.id)}
|
||||
onArchive={() => setConfirmAction({ type: 'archive', spoolId: spool.id })}
|
||||
onDelete={() => setConfirmAction({ type: 'delete', spoolId: spool.id })}
|
||||
visibleColumns={visibleColumns}
|
||||
assignmentMap={assignmentMap}
|
||||
currencySymbol={currencySymbol}
|
||||
dateFormat={dateFormat}
|
||||
t={t}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
@@ -1058,10 +1149,10 @@ export default function InventoryPage() {
|
||||
<div className="flex items-center justify-between px-4 py-3 bg-bambu-dark-tertiary/50 border-t border-bambu-dark-tertiary text-sm">
|
||||
<span className="text-bambu-gray">
|
||||
{showAll
|
||||
? `${sortedSpools.length} ${sortedSpools.length !== 1 ? t('inventory.spools') : t('inventory.spool')}`
|
||||
? `${totalDisplayItems} ${totalDisplayItems !== 1 ? t('inventory.spools') : t('inventory.spool')}`
|
||||
: <>{t('inventory.showing')} {safePageIndex * effectivePageSize + 1} {t('inventory.to')}{' '}
|
||||
{Math.min((safePageIndex + 1) * effectivePageSize, sortedSpools.length)}{' '}
|
||||
{t('inventory.of')} {sortedSpools.length} {t('inventory.spools')}</>
|
||||
{Math.min((safePageIndex + 1) * effectivePageSize, totalDisplayItems)}{' '}
|
||||
{t('inventory.of')} {totalDisplayItems} {t('inventory.spools')}</>
|
||||
}
|
||||
</span>
|
||||
|
||||
@@ -1245,6 +1336,210 @@ function PaginationBar({
|
||||
);
|
||||
}
|
||||
|
||||
/* Spool card for cards view */
|
||||
function SpoolCard({
|
||||
spool, remaining, pct, colorStyle, onClick, t,
|
||||
}: {
|
||||
spool: InventorySpool;
|
||||
remaining: number;
|
||||
pct: number;
|
||||
colorStyle: string;
|
||||
onClick: () => void;
|
||||
t: (key: string, opts?: Record<string, unknown>) => string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={`bg-bambu-dark-secondary rounded-lg overflow-hidden border border-bambu-dark-tertiary hover:border-bambu-green transition-colors cursor-pointer ${spool.archived_at ? 'opacity-50' : ''}`}
|
||||
onClick={onClick}
|
||||
>
|
||||
<div className="h-14 flex items-center justify-center" style={{ backgroundColor: colorStyle }}>
|
||||
<span className="bg-white/90 text-gray-800 px-3 py-0.5 rounded-full text-sm font-medium">
|
||||
{resolveSpoolColorName(spool.color_name, spool.rgba) || '-'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="p-4 space-y-3">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div>
|
||||
<h3 className="font-semibold text-white">
|
||||
{spool.material}{spool.subtype ? ` ${spool.subtype}` : ''}
|
||||
</h3>
|
||||
<p className="text-sm text-bambu-gray">{spool.brand || '-'}</p>
|
||||
</div>
|
||||
<span className="text-xs font-mono text-bambu-gray bg-bambu-dark-tertiary px-2 py-1 rounded">
|
||||
#{spool.id}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex justify-between text-xs text-bambu-gray mb-1">
|
||||
<span>{t('inventory.remaining')}</span>
|
||||
<span>{Math.round(pct)}%</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 h-2 bg-bambu-dark-tertiary rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full ${pct > 50 ? 'bg-bambu-green' : pct > 20 ? 'bg-yellow-500' : 'bg-red-500'}`}
|
||||
style={{ width: `${Math.min(pct, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-bambu-gray min-w-[40px] text-right">
|
||||
{Math.round(remaining)}g
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||
<div>
|
||||
<span className="text-bambu-gray/60">{t('inventory.labelWeight')}: </span>
|
||||
<span className="text-bambu-gray">{formatWeight(spool.label_weight)}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-bambu-gray/60">{t('inventory.weightUsed')}: </span>
|
||||
<span className="text-bambu-gray">
|
||||
{spool.weight_used > 0 ? formatWeight(spool.weight_used) : '-'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{spool.note && (
|
||||
<div
|
||||
className="text-xs text-bambu-gray/60 pt-2 border-t border-bambu-dark-tertiary truncate"
|
||||
title={spool.note}
|
||||
>
|
||||
{spool.note}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* Single spool row for table view */
|
||||
function SpoolTableRow({
|
||||
spool, remaining, pct, onEdit, onRestore, onArchive, onDelete,
|
||||
visibleColumns, assignmentMap, currencySymbol, dateFormat, t,
|
||||
}: {
|
||||
spool: InventorySpool;
|
||||
remaining: number;
|
||||
pct: number;
|
||||
onEdit: () => void;
|
||||
onRestore: () => void;
|
||||
onArchive: () => void;
|
||||
onDelete: () => void;
|
||||
visibleColumns: string[];
|
||||
assignmentMap: Record<number, SpoolAssignment>;
|
||||
currencySymbol: string;
|
||||
dateFormat: DateFormat;
|
||||
t: TFn;
|
||||
}) {
|
||||
return (
|
||||
<tr
|
||||
className={`border-b border-bambu-dark-tertiary/50 hover:bg-bambu-dark-tertiary/30 transition-colors cursor-pointer ${
|
||||
spool.archived_at ? 'opacity-50' : ''
|
||||
}`}
|
||||
onClick={onEdit}
|
||||
>
|
||||
{visibleColumns.map((colId) => (
|
||||
<td key={colId} className="py-3 px-4">
|
||||
{columnCells[colId]?.({ spool, remaining, pct, assignmentMap, currencySymbol, dateFormat, t })}
|
||||
</td>
|
||||
))}
|
||||
<td className="py-3 px-4">
|
||||
<div className="flex items-center justify-end gap-1" onClick={(e) => e.stopPropagation()}>
|
||||
<button onClick={onEdit} className="p-1.5 text-bambu-gray hover:text-white rounded transition-colors" title={t('inventory.editSpool')}>
|
||||
<Edit2 className="w-4 h-4" />
|
||||
</button>
|
||||
{spool.archived_at ? (
|
||||
<button onClick={onRestore} className="p-1.5 text-bambu-gray hover:text-bambu-green rounded transition-colors" title={t('inventory.restore')}>
|
||||
<RotateCcw className="w-4 h-4" />
|
||||
</button>
|
||||
) : (
|
||||
<button onClick={onArchive} className="p-1.5 text-bambu-gray hover:text-yellow-400 rounded transition-colors" title={t('inventory.archive')}>
|
||||
<Archive className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
<button onClick={onDelete} className="p-1.5 text-bambu-gray hover:text-red-400 rounded transition-colors" title={t('common.delete')}>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
/* Grouped spool rows for table view */
|
||||
function SpoolTableGroup({
|
||||
spools, representative, remaining, pct, isExpanded, onToggle,
|
||||
onEdit, onArchive, onDelete,
|
||||
visibleColumns, assignmentMap, currencySymbol, dateFormat, t,
|
||||
}: {
|
||||
spools: InventorySpool[];
|
||||
representative: InventorySpool;
|
||||
remaining: number;
|
||||
pct: number;
|
||||
isExpanded: boolean;
|
||||
onToggle: () => void;
|
||||
onEdit: (spool: InventorySpool) => void;
|
||||
onArchive: (id: number) => void;
|
||||
onDelete: (id: number) => void;
|
||||
visibleColumns: string[];
|
||||
assignmentMap: Record<number, SpoolAssignment>;
|
||||
currencySymbol: string;
|
||||
dateFormat: DateFormat;
|
||||
t: TFn;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{/* Group header row */}
|
||||
<tr
|
||||
className="border-b border-bambu-dark-tertiary/50 hover:bg-bambu-dark-tertiary/30 transition-colors cursor-pointer bg-bambu-green/5"
|
||||
onClick={onToggle}
|
||||
>
|
||||
{visibleColumns.map((colId, idx) => (
|
||||
<td key={colId} className="py-3 px-4">
|
||||
{idx === 0 ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<ChevronDown className={`w-4 h-4 text-bambu-gray transition-transform ${isExpanded ? '' : '-rotate-90'}`} />
|
||||
{columnCells[colId]?.({ spool: representative, remaining, pct, assignmentMap, currencySymbol, dateFormat, t })}
|
||||
</div>
|
||||
) : colId === 'id' ? (
|
||||
<span className="text-xs font-medium bg-bambu-green/20 text-bambu-green px-2 py-0.5 rounded-full">
|
||||
{t('inventory.groupedSpools', { count: spools.length })}
|
||||
</span>
|
||||
) : (
|
||||
columnCells[colId]?.({ spool: representative, remaining, pct, assignmentMap, currencySymbol, dateFormat, t })
|
||||
)}
|
||||
</td>
|
||||
))}
|
||||
<td className="py-3 px-4">
|
||||
<span className="text-xs text-bambu-gray">
|
||||
{spools.map((s) => `#${s.id}`).join(', ')}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
{/* Expanded individual rows */}
|
||||
{isExpanded && spools.map((spool) => {
|
||||
const r = Math.max(0, spool.label_weight - spool.weight_used);
|
||||
const p = spool.label_weight > 0 ? (r / spool.label_weight) * 100 : 0;
|
||||
return (
|
||||
<SpoolTableRow
|
||||
key={spool.id}
|
||||
spool={spool}
|
||||
remaining={r}
|
||||
pct={p}
|
||||
onEdit={() => onEdit(spool)}
|
||||
onRestore={() => {}}
|
||||
onArchive={() => onArchive(spool.id)}
|
||||
onDelete={() => onDelete(spool.id)}
|
||||
visibleColumns={visibleColumns}
|
||||
assignmentMap={assignmentMap}
|
||||
currencySymbol={currencySymbol}
|
||||
dateFormat={dateFormat}
|
||||
t={t}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/* Empty state matching SpoolBuddy's design */
|
||||
function EmptyFilterState({
|
||||
hasFilters,
|
||||
|
||||
Reference in New Issue
Block a user