mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-21 01:57:41 +02:00
Add spool inventory: AMS slot assignment, usage tracking, and remaining weight editing
Built-in spool inventory with AMS slot assignment for non-BL spools, automatic filament usage tracking via 3MF estimates, and remaining weight editing in the spool form. Inventory features: - AMS slot assignment: assign/unassign manual spools to AMS tray slots - Filter out Bambu Lab spools (RFID-managed) from assignment modal - Filter out already-assigned spools (one spool per slot) - Auto-unlink manual assignments when a BL spool is inserted - Hide assign/unassign UI on BL-occupied slots - Case-insensitive fingerprint matching for auto-unlink logic Usage tracking: - 3MF-based filament consumption tracking for non-BL spools - Extract per-filament used_g from archived 3MF slice_info - Map 3MF slot_id to AMS (ams_id, tray_id) positions - Scale estimates by print progress for failed/aborted prints - Skip BL spools (tracked via AMS remain% delta, Path 1) - Prevent double-counting with handled_trays set Other: - Add remaining weight field to spool edit form - Fix hardcoded "Empty" string in AssignSpoolModal (now i18n) - Add backend unit tests for usage_tracker (11 tests) - Add frontend tests for AssignSpoolModal filters (5 tests) - Update CHANGELOG, README, wiki docs, and website
This commit is contained in:
@@ -0,0 +1,134 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { screen, waitFor } from '@testing-library/react';
|
||||
import { render } from '../utils';
|
||||
import { AssignSpoolModal } from '../../components/AssignSpoolModal';
|
||||
import { api } from '../../api/client';
|
||||
|
||||
vi.mock('../../api/client', () => ({
|
||||
api: {
|
||||
getSpools: vi.fn(),
|
||||
getAssignments: vi.fn(),
|
||||
assignSpool: vi.fn(),
|
||||
getSettings: vi.fn().mockResolvedValue({}),
|
||||
getAuthStatus: vi.fn().mockResolvedValue({ auth_enabled: false }),
|
||||
},
|
||||
}));
|
||||
|
||||
const defaultProps = {
|
||||
isOpen: true,
|
||||
onClose: vi.fn(),
|
||||
printerId: 1,
|
||||
amsId: 0,
|
||||
trayId: 0,
|
||||
trayInfo: { type: 'PLA', color: 'FF0000', location: 'AMS 1 - Slot 1' },
|
||||
};
|
||||
|
||||
const manualSpool = {
|
||||
id: 1,
|
||||
material: 'PLA',
|
||||
subtype: 'Basic',
|
||||
brand: 'Polymaker',
|
||||
color_name: 'Red',
|
||||
rgba: 'FF0000FF',
|
||||
label_weight: 1000,
|
||||
weight_used: 0,
|
||||
tag_uid: null,
|
||||
tray_uuid: null,
|
||||
};
|
||||
|
||||
const blSpool = {
|
||||
id: 2,
|
||||
material: 'PLA',
|
||||
subtype: 'Basic',
|
||||
brand: 'Bambu',
|
||||
color_name: 'Jade White',
|
||||
rgba: 'FFFFFFFE',
|
||||
label_weight: 1000,
|
||||
weight_used: 50,
|
||||
tag_uid: '05CC1E0F00000100',
|
||||
tray_uuid: 'A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4',
|
||||
};
|
||||
|
||||
const anotherManualSpool = {
|
||||
id: 3,
|
||||
material: 'PETG',
|
||||
subtype: 'HF',
|
||||
brand: 'Overture',
|
||||
color_name: 'Black',
|
||||
rgba: '000000FF',
|
||||
label_weight: 1000,
|
||||
weight_used: 200,
|
||||
tag_uid: null,
|
||||
tray_uuid: null,
|
||||
};
|
||||
|
||||
describe('AssignSpoolModal', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
(api.getSpools as ReturnType<typeof vi.fn>).mockResolvedValue([manualSpool, blSpool, anotherManualSpool]);
|
||||
(api.getAssignments as ReturnType<typeof vi.fn>).mockResolvedValue([]);
|
||||
});
|
||||
|
||||
it('renders nothing when closed', () => {
|
||||
render(<AssignSpoolModal {...defaultProps} isOpen={false} />);
|
||||
expect(screen.queryByText('Assign Spool')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('filters out Bambu Lab spools (with tag_uid/tray_uuid)', async () => {
|
||||
render(<AssignSpoolModal {...defaultProps} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Polymaker/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Manual spools should be visible
|
||||
expect(screen.getByText(/Polymaker/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Overture/)).toBeInTheDocument();
|
||||
|
||||
// BL spool should NOT be visible
|
||||
expect(screen.queryByText(/Jade White/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('filters out spools already assigned to other slots', async () => {
|
||||
(api.getAssignments as ReturnType<typeof vi.fn>).mockResolvedValue([
|
||||
{ id: 1, spool_id: 3, printer_id: 1, ams_id: 0, tray_id: 1 }, // spool 3 assigned to different slot
|
||||
]);
|
||||
|
||||
render(<AssignSpoolModal {...defaultProps} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Polymaker/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Spool 1 (not assigned) should be visible
|
||||
expect(screen.getByText(/Polymaker/)).toBeInTheDocument();
|
||||
|
||||
// Spool 3 (assigned to another slot) should NOT be visible
|
||||
expect(screen.queryByText(/Overture/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('keeps spool visible if assigned to the current slot', async () => {
|
||||
(api.getAssignments as ReturnType<typeof vi.fn>).mockResolvedValue([
|
||||
{ id: 1, spool_id: 1, printer_id: 1, ams_id: 0, tray_id: 0 }, // spool 1 assigned to THIS slot
|
||||
]);
|
||||
|
||||
render(<AssignSpoolModal {...defaultProps} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Polymaker/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Spool 1 (assigned to current slot) should still be visible for re-assignment
|
||||
expect(screen.getByText(/Polymaker/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows noManualSpools message when all spools are BL or assigned', async () => {
|
||||
(api.getSpools as ReturnType<typeof vi.fn>).mockResolvedValue([blSpool]);
|
||||
|
||||
render(<AssignSpoolModal {...defaultProps} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/No manually added spools/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,11 @@
|
||||
/**
|
||||
* Tests for the LinkSpoolModal component.
|
||||
*
|
||||
* Tests the Spoolman link spool modal including:
|
||||
* - Displaying unlinked spools
|
||||
* - Selecting a spool to link
|
||||
* - Link success with toast notification
|
||||
* - Link error with toast notification
|
||||
* Tests the inventory link-to-spool modal including:
|
||||
* - Rendering modal with tag/tray info
|
||||
* - Displaying untagged spools
|
||||
* - Linking a spool via click
|
||||
* - Search filtering
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
@@ -16,10 +16,10 @@ import { LinkSpoolModal } from '../../components/LinkSpoolModal';
|
||||
// Mock the API client
|
||||
vi.mock('../../api/client', () => ({
|
||||
api: {
|
||||
getUnlinkedSpools: vi.fn(),
|
||||
linkSpool: vi.fn(),
|
||||
getSpools: vi.fn(),
|
||||
linkTagToSpool: vi.fn(),
|
||||
getSettings: vi.fn().mockResolvedValue({}),
|
||||
getAuthStatus: vi.fn().mockResolvedValue({ enabled: false, configured: false }),
|
||||
getAuthStatus: vi.fn().mockResolvedValue({ auth_enabled: false }),
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -40,37 +40,56 @@ describe('LinkSpoolModal', () => {
|
||||
const defaultProps = {
|
||||
isOpen: true,
|
||||
onClose: vi.fn(),
|
||||
tagUid: 'ABCD1234',
|
||||
trayUuid: 'A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4',
|
||||
trayInfo: {
|
||||
type: 'PLA Basic',
|
||||
color: 'FF0000',
|
||||
location: 'AMS A1',
|
||||
},
|
||||
printerId: 1,
|
||||
amsId: 0,
|
||||
trayId: 0,
|
||||
};
|
||||
|
||||
const mockUnlinkedSpools = [
|
||||
const mockSpools = [
|
||||
{
|
||||
id: 1,
|
||||
filament_name: 'PLA Red',
|
||||
filament_material: 'PLA',
|
||||
filament_color_hex: 'FF0000',
|
||||
remaining_weight: 800,
|
||||
location: 'Shelf A',
|
||||
material: 'PLA',
|
||||
brand: 'Generic',
|
||||
subtype: '',
|
||||
color_name: 'Red',
|
||||
rgba: 'FF0000FF',
|
||||
label_weight: 1000,
|
||||
weight_used: 200,
|
||||
tag_uid: null,
|
||||
tray_uuid: null,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
filament_name: 'PETG Blue',
|
||||
filament_material: 'PETG',
|
||||
filament_color_hex: '0000FF',
|
||||
remaining_weight: 500,
|
||||
location: null,
|
||||
material: 'PETG',
|
||||
brand: 'Bambu',
|
||||
subtype: 'Basic',
|
||||
color_name: 'Blue',
|
||||
rgba: '0000FFFF',
|
||||
label_weight: 1000,
|
||||
weight_used: 500,
|
||||
tag_uid: null,
|
||||
tray_uuid: null,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
material: 'ABS',
|
||||
brand: 'Brand',
|
||||
subtype: '',
|
||||
color_name: 'White',
|
||||
rgba: 'FFFFFFFF',
|
||||
label_weight: 1000,
|
||||
weight_used: 0,
|
||||
tag_uid: 'EXISTING_TAG',
|
||||
tray_uuid: 'EXISTING_UUID',
|
||||
},
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(api.getUnlinkedSpools).mockResolvedValue(mockUnlinkedSpools);
|
||||
vi.mocked(api.linkSpool).mockResolvedValue({ success: true, message: 'Linked' });
|
||||
vi.mocked(api.getSpools).mockResolvedValue(mockSpools);
|
||||
vi.mocked(api.linkTagToSpool).mockResolvedValue({});
|
||||
});
|
||||
|
||||
describe('rendering', () => {
|
||||
@@ -78,33 +97,21 @@ describe('LinkSpoolModal', () => {
|
||||
render(<LinkSpoolModal {...defaultProps} />);
|
||||
|
||||
await waitFor(() => {
|
||||
// Look for the title in h2 element
|
||||
expect(screen.getByRole('heading', { name: /link to spoolman/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('heading', { name: /link to spool/i })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('displays tray info', async () => {
|
||||
it('displays printer and tray info', async () => {
|
||||
render(<LinkSpoolModal {...defaultProps} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('PLA Basic')).toBeInTheDocument();
|
||||
expect(screen.getByText('(AMS A1)')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('displays tray UUID', async () => {
|
||||
render(<LinkSpoolModal {...defaultProps} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(defaultProps.trayUuid)).toBeInTheDocument();
|
||||
expect(screen.getByText(/AMS 0 T0/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Printer #1/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows loading state while fetching spools', async () => {
|
||||
// Delay the response
|
||||
vi.mocked(api.getUnlinkedSpools).mockImplementation(
|
||||
() => new Promise(() => {})
|
||||
);
|
||||
vi.mocked(api.getSpools).mockImplementation(() => new Promise(() => {}));
|
||||
|
||||
render(<LinkSpoolModal {...defaultProps} />);
|
||||
|
||||
@@ -113,132 +120,74 @@ describe('LinkSpoolModal', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('displays unlinked spools list', async () => {
|
||||
it('displays untagged spools only', async () => {
|
||||
render(<LinkSpoolModal {...defaultProps} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('PLA Red')).toBeInTheDocument();
|
||||
expect(screen.getByText('PETG Blue')).toBeInTheDocument();
|
||||
// Spools 1 and 2 have no tag_uid/tray_uuid — should be shown
|
||||
expect(screen.getByText(/Generic PLA/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Bambu PETG/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows message when no unlinked spools', async () => {
|
||||
vi.mocked(api.getUnlinkedSpools).mockResolvedValue([]);
|
||||
|
||||
render(<LinkSpoolModal {...defaultProps} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('No unlinked spools available')).toBeInTheDocument();
|
||||
});
|
||||
// Spool 3 has tag_uid — should be filtered out
|
||||
expect(screen.queryByText(/Brand ABS/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render when isOpen is false', () => {
|
||||
render(<LinkSpoolModal {...defaultProps} isOpen={false} />);
|
||||
expect(screen.queryByRole('heading', { name: /link to spoolman/i })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('spool selection', () => {
|
||||
it('allows selecting a spool', async () => {
|
||||
render(<LinkSpoolModal {...defaultProps} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('PLA Red')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Click to select spool
|
||||
fireEvent.click(screen.getByText('PLA Red'));
|
||||
|
||||
// Should show check mark (via visual styling)
|
||||
const selectedButton = screen.getByText('PLA Red').closest('button');
|
||||
expect(selectedButton).toHaveClass('border-bambu-green');
|
||||
});
|
||||
|
||||
it('link button is disabled until spool is selected', async () => {
|
||||
render(<LinkSpoolModal {...defaultProps} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('PLA Red')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const linkButton = screen.getByRole('button', { name: /link to spoolman/i });
|
||||
expect(linkButton).toBeDisabled();
|
||||
|
||||
// Select a spool
|
||||
fireEvent.click(screen.getByText('PLA Red'));
|
||||
|
||||
expect(linkButton).not.toBeDisabled();
|
||||
expect(screen.queryByRole('heading', { name: /link to spool/i })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('linking', () => {
|
||||
it('calls linkSpool API on submit', async () => {
|
||||
it('calls linkTagToSpool on spool click', async () => {
|
||||
render(<LinkSpoolModal {...defaultProps} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('PLA Red')).toBeInTheDocument();
|
||||
expect(screen.getByText(/Generic PLA/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Select a spool
|
||||
fireEvent.click(screen.getByText('PLA Red'));
|
||||
|
||||
// Click link button
|
||||
fireEvent.click(screen.getByRole('button', { name: /link to spoolman/i }));
|
||||
fireEvent.click(screen.getByText(/Generic PLA/).closest('button')!);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(api.linkSpool).toHaveBeenCalledWith(1, defaultProps.trayUuid);
|
||||
expect(api.linkTagToSpool).toHaveBeenCalledWith(1, {
|
||||
tag_uid: 'ABCD1234',
|
||||
tray_uuid: 'A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4',
|
||||
tag_type: 'bambulab',
|
||||
data_origin: 'nfc_link',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('shows success toast on successful link', async () => {
|
||||
it('shows success toast and calls onClose', async () => {
|
||||
render(<LinkSpoolModal {...defaultProps} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('PLA Red')).toBeInTheDocument();
|
||||
expect(screen.getByText(/Generic PLA/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByText('PLA Red'));
|
||||
fireEvent.click(screen.getByRole('button', { name: /link to spoolman/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockShowToast).toHaveBeenCalledWith(
|
||||
'Spool linked to Spoolman successfully',
|
||||
'success'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('calls onClose after successful link', async () => {
|
||||
render(<LinkSpoolModal {...defaultProps} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('PLA Red')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByText('PLA Red'));
|
||||
fireEvent.click(screen.getByRole('button', { name: /link to spoolman/i }));
|
||||
fireEvent.click(screen.getByText(/Generic PLA/).closest('button')!);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockShowToast).toHaveBeenCalled();
|
||||
expect(defaultProps.onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows error toast on link failure', async () => {
|
||||
const errorMessage = 'Failed to update spool';
|
||||
vi.mocked(api.linkSpool).mockRejectedValue(new Error(errorMessage));
|
||||
it('shows error toast on failure', async () => {
|
||||
vi.mocked(api.linkTagToSpool).mockRejectedValue(new Error('Link failed'));
|
||||
|
||||
render(<LinkSpoolModal {...defaultProps} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('PLA Red')).toBeInTheDocument();
|
||||
expect(screen.getByText(/Generic PLA/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByText('PLA Red'));
|
||||
fireEvent.click(screen.getByRole('button', { name: /link to spoolman/i }));
|
||||
fireEvent.click(screen.getByText(/Generic PLA/).closest('button')!);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockShowToast).toHaveBeenCalledWith(
|
||||
`Failed to link spool: ${errorMessage}`,
|
||||
expect.stringContaining('Link failed'),
|
||||
'error'
|
||||
);
|
||||
});
|
||||
@@ -246,25 +195,13 @@ describe('LinkSpoolModal', () => {
|
||||
});
|
||||
|
||||
describe('modal actions', () => {
|
||||
it('calls onClose when cancel button is clicked', async () => {
|
||||
render(<LinkSpoolModal {...defaultProps} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Cancel')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByText('Cancel'));
|
||||
expect(defaultProps.onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls onClose when backdrop is clicked', async () => {
|
||||
render(<LinkSpoolModal {...defaultProps} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('heading', { name: /link to spoolman/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('heading', { name: /link to spool/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Click the backdrop (the element with bg-black/60)
|
||||
const backdrop = document.querySelector('.bg-black\\/60');
|
||||
if (backdrop) {
|
||||
fireEvent.click(backdrop);
|
||||
@@ -276,10 +213,9 @@ describe('LinkSpoolModal', () => {
|
||||
render(<LinkSpoolModal {...defaultProps} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('heading', { name: /link to spoolman/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('heading', { name: /link to spool/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Find and click the X button in the header
|
||||
const closeButtons = screen.getAllByRole('button');
|
||||
const xButton = closeButtons.find(btn => btn.querySelector('svg.lucide-x'));
|
||||
if (xButton) {
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
/**
|
||||
* Tests for the SpoolmanSettings component.
|
||||
*
|
||||
* Tests the Spoolman integration UI including:
|
||||
* - Enable/disable toggle
|
||||
* - URL configuration
|
||||
* - Connection status
|
||||
* - Sync functionality
|
||||
* Tests the filament tracking mode selector and Spoolman integration UI:
|
||||
* - Mode selector (Built-in Inventory vs Spoolman)
|
||||
* - Built-in Inventory info panel
|
||||
* - Spoolman URL, sync mode, connection status
|
||||
* - Weight sync and partial usage toggles
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { render } from '../utils';
|
||||
import { SpoolmanSettings } from '../../components/SpoolmanSettings';
|
||||
|
||||
@@ -26,6 +27,7 @@ vi.mock('../../api/client', () => ({
|
||||
syncAllPrintersAms: vi.fn(),
|
||||
syncPrinterAms: vi.fn(),
|
||||
getPrinters: vi.fn(),
|
||||
getAuthStatus: vi.fn().mockResolvedValue({ auth_enabled: false }),
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -36,7 +38,7 @@ describe('SpoolmanSettings', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Default API mocks
|
||||
// Default API mocks — Spoolman disabled (Built-in Inventory mode)
|
||||
vi.mocked(api.getSpoolmanSettings).mockResolvedValue({
|
||||
spoolman_enabled: 'false',
|
||||
spoolman_url: '',
|
||||
@@ -70,90 +72,61 @@ describe('SpoolmanSettings', () => {
|
||||
|
||||
describe('rendering', () => {
|
||||
it('renders loading state initially', () => {
|
||||
// Delay the API response to catch loading state
|
||||
vi.mocked(api.getSpoolmanSettings).mockImplementation(() => new Promise(() => {}));
|
||||
render(<SpoolmanSettings />);
|
||||
|
||||
// Should show loading spinner
|
||||
expect(document.querySelector('.animate-spin')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders component title', async () => {
|
||||
it('renders filament tracking title', async () => {
|
||||
render(<SpoolmanSettings />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Spoolman Integration')).toBeInTheDocument();
|
||||
expect(screen.getByText('Filament Tracking')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders enable toggle', async () => {
|
||||
it('renders mode selector cards', async () => {
|
||||
render(<SpoolmanSettings />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Enable Spoolman')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders URL input', async () => {
|
||||
render(<SpoolmanSettings />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Spoolman URL')).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText('http://192.168.1.100:7912')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders sync mode selector', async () => {
|
||||
render(<SpoolmanSettings />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Sync Mode')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders info banner about sync', async () => {
|
||||
render(<SpoolmanSettings />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('How Sync Works')).toBeInTheDocument();
|
||||
expect(screen.getByText(/Only official Bambu Lab spools/)).toBeInTheDocument();
|
||||
expect(screen.getByText('Built-in Inventory')).toBeInTheDocument();
|
||||
expect(screen.getByText('Spoolman')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('disabled state', () => {
|
||||
it('URL input is disabled when Spoolman is disabled', async () => {
|
||||
describe('built-in inventory mode (default)', () => {
|
||||
it('shows built-in inventory as selected by default', async () => {
|
||||
render(<SpoolmanSettings />);
|
||||
|
||||
await waitFor(() => {
|
||||
const urlInput = screen.getByPlaceholderText('http://192.168.1.100:7912');
|
||||
expect(urlInput).toBeDisabled();
|
||||
// Built-in Inventory card should have the active border
|
||||
const builtInBtn = screen.getByText('Built-in Inventory').closest('button');
|
||||
expect(builtInBtn).toHaveClass('border-bambu-green');
|
||||
});
|
||||
});
|
||||
|
||||
it('sync mode selector is disabled when Spoolman is disabled', async () => {
|
||||
it('shows built-in info panel when selected', async () => {
|
||||
render(<SpoolmanSettings />);
|
||||
|
||||
await waitFor(() => {
|
||||
// Find the select by its display value
|
||||
const selectElement = screen.getByDisplayValue('Automatic');
|
||||
expect(selectElement).toBeDisabled();
|
||||
expect(screen.getByText(/Automatically detects Bambu Lab RFID spools/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('does not show connection status when disabled', async () => {
|
||||
it('does not show Spoolman URL input', async () => {
|
||||
render(<SpoolmanSettings />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Spoolman Integration')).toBeInTheDocument();
|
||||
expect(screen.getByText('Filament Tracking')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Status section should not be visible when disabled
|
||||
expect(screen.queryByText('Status:')).not.toBeInTheDocument();
|
||||
expect(screen.queryByPlaceholderText('http://192.168.1.100:7912')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('enabled state', () => {
|
||||
describe('spoolman mode', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(api.getSpoolmanSettings).mockResolvedValue({
|
||||
spoolman_enabled: 'true',
|
||||
@@ -171,16 +144,40 @@ describe('SpoolmanSettings', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('URL input is enabled when Spoolman is enabled', async () => {
|
||||
it('shows Spoolman card as selected', async () => {
|
||||
render(<SpoolmanSettings />);
|
||||
|
||||
await waitFor(() => {
|
||||
const urlInput = screen.getByPlaceholderText('http://192.168.1.100:7912');
|
||||
expect(urlInput).not.toBeDisabled();
|
||||
const spoolmanBtn = screen.getByText('Spoolman').closest('button');
|
||||
expect(spoolmanBtn).toHaveClass('border-bambu-green');
|
||||
});
|
||||
});
|
||||
|
||||
it('shows connection status section when enabled', async () => {
|
||||
it('shows URL input when Spoolman is selected', async () => {
|
||||
render(<SpoolmanSettings />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByPlaceholderText('http://192.168.1.100:7912')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows sync mode selector', async () => {
|
||||
render(<SpoolmanSettings />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Sync Mode')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows how sync works info', async () => {
|
||||
render(<SpoolmanSettings />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('How Sync Works')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows connection status section', async () => {
|
||||
render(<SpoolmanSettings />);
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -202,20 +199,6 @@ describe('SpoolmanSettings', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('shows Connect button when disconnected', async () => {
|
||||
vi.mocked(api.getSpoolmanStatus).mockResolvedValue({
|
||||
enabled: true,
|
||||
connected: false,
|
||||
url: 'http://localhost:7912',
|
||||
});
|
||||
|
||||
render(<SpoolmanSettings />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Connect')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows Connected and Disconnect button when connected', async () => {
|
||||
vi.mocked(api.getSpoolmanStatus).mockResolvedValue({
|
||||
enabled: true,
|
||||
@@ -242,27 +225,12 @@ describe('SpoolmanSettings', () => {
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Sync AMS Data')).toBeInTheDocument();
|
||||
expect(screen.getByText('Sync')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows All Printers option in sync dropdown', async () => {
|
||||
vi.mocked(api.getSpoolmanStatus).mockResolvedValue({
|
||||
enabled: true,
|
||||
connected: true,
|
||||
url: 'http://localhost:7912',
|
||||
});
|
||||
|
||||
render(<SpoolmanSettings />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('option', { name: 'All Printers' })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('weight sync toggle', () => {
|
||||
it('shows weight sync toggle when sync mode is auto and enabled', async () => {
|
||||
it('shows weight sync toggle when Spoolman enabled and sync mode is auto', async () => {
|
||||
vi.mocked(api.getSpoolmanSettings).mockResolvedValue({
|
||||
spoolman_enabled: 'true',
|
||||
spoolman_url: 'http://localhost:7912',
|
||||
@@ -290,20 +258,11 @@ describe('SpoolmanSettings', () => {
|
||||
render(<SpoolmanSettings />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Spoolman Integration')).toBeInTheDocument();
|
||||
expect(screen.getByText('Filament Tracking')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.queryByText('Disable AMS Estimated Weight Sync')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows weight sync toggle in disabled state when sync mode is auto', async () => {
|
||||
render(<SpoolmanSettings />);
|
||||
|
||||
await waitFor(() => {
|
||||
// Toggle label is visible since sync mode defaults to auto
|
||||
expect(screen.getByText('Disable AMS Estimated Weight Sync')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('partial usage toggle', () => {
|
||||
@@ -335,49 +294,28 @@ describe('SpoolmanSettings', () => {
|
||||
render(<SpoolmanSettings />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Spoolman Integration')).toBeInTheDocument();
|
||||
expect(screen.getByText('Filament Tracking')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.queryByText('Report Partial Usage for Failed Prints')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('sync mode options', () => {
|
||||
it('shows Automatic option', async () => {
|
||||
describe('mode switching', () => {
|
||||
it('can switch to Spoolman mode', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<SpoolmanSettings />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('option', { name: 'Automatic' })).toBeInTheDocument();
|
||||
expect(screen.getByText('Built-in Inventory')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows Manual Only option', async () => {
|
||||
render(<SpoolmanSettings />);
|
||||
// Click Spoolman card
|
||||
await user.click(screen.getByText('Spoolman').closest('button')!);
|
||||
|
||||
// Spoolman settings should now be visible
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('option', { name: 'Manual Only' })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('info text', () => {
|
||||
it('shows URL help text', async () => {
|
||||
render(<SpoolmanSettings />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText('URL of your Spoolman server (e.g., http://localhost:7912)')
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows sync mode description for auto mode', async () => {
|
||||
render(<SpoolmanSettings />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText('AMS data syncs automatically when changes are detected')
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText('http://192.168.1.100:7912')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -86,7 +86,7 @@ describe('SettingsPage', () => {
|
||||
expect(screen.getAllByText('General').length).toBeGreaterThan(0);
|
||||
expect(screen.getByText('Smart Plugs')).toBeInTheDocument();
|
||||
expect(screen.getByText('Notifications')).toBeInTheDocument();
|
||||
expect(screen.getByText('Filament')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('Filament').length).toBeGreaterThan(0);
|
||||
expect(screen.getByText('Network')).toBeInTheDocument();
|
||||
expect(screen.getByText('API Keys')).toBeInTheDocument();
|
||||
});
|
||||
@@ -207,10 +207,10 @@ describe('SettingsPage', () => {
|
||||
render(<SettingsPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Filament')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('Filament').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
await user.click(screen.getByText('Filament'));
|
||||
await user.click(screen.getAllByText('Filament')[0]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('AMS Display Thresholds')).toBeInTheDocument();
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { X, Loader2, Package, Check, Search } from 'lucide-react';
|
||||
import { api } from '../api/client';
|
||||
import type { InventorySpool } from '../api/client';
|
||||
import type { InventorySpool, SpoolAssignment } from '../api/client';
|
||||
import { Button } from './Button';
|
||||
import { useToast } from '../contexts/ToastContext';
|
||||
|
||||
@@ -33,12 +33,25 @@ export function AssignSpoolModal({ isOpen, onClose, printerId, amsId, trayId, tr
|
||||
enabled: isOpen,
|
||||
});
|
||||
|
||||
const { data: assignments } = useQuery({
|
||||
queryKey: ['spool-assignments'],
|
||||
queryFn: () => api.getAssignments(),
|
||||
enabled: isOpen,
|
||||
});
|
||||
|
||||
const assignMutation = useMutation({
|
||||
mutationFn: (spoolId: number) =>
|
||||
api.assignSpool({ spool_id: spoolId, printer_id: printerId, ams_id: amsId, tray_id: trayId }),
|
||||
onSuccess: () => {
|
||||
onSuccess: (newAssignment) => {
|
||||
// Immediately update cache so UI reflects the new assignment without waiting for refetch
|
||||
queryClient.setQueryData<SpoolAssignment[]>(['spool-assignments'], (old) => {
|
||||
const filtered = (old || []).filter(a =>
|
||||
!(a.printer_id === printerId && a.ams_id === amsId && a.tray_id === trayId)
|
||||
);
|
||||
filtered.push(newAssignment);
|
||||
return filtered;
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ['spool-assignments'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['printer-status'] });
|
||||
showToast(t('inventory.assignSuccess'), 'success');
|
||||
onClose();
|
||||
},
|
||||
@@ -49,7 +62,18 @@ export function AssignSpoolModal({ isOpen, onClose, printerId, amsId, trayId, tr
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const filteredSpools = spools?.filter((spool: InventorySpool) => {
|
||||
// Filter out Bambu Lab spools (identified by RFID tag_uid or tray_uuid)
|
||||
// and spools already assigned to other slots
|
||||
const assignedSpoolIds = new Set(
|
||||
(assignments || [])
|
||||
.filter(a => !(a.printer_id === printerId && a.ams_id === amsId && a.tray_id === trayId))
|
||||
.map(a => a.spool_id)
|
||||
);
|
||||
const manualSpools = spools?.filter((spool: InventorySpool) =>
|
||||
!spool.tag_uid && !spool.tray_uuid && !assignedSpoolIds.has(spool.id)
|
||||
);
|
||||
|
||||
const filteredSpools = manualSpools?.filter((spool: InventorySpool) => {
|
||||
if (!searchFilter) return true;
|
||||
const q = searchFilter.toLowerCase();
|
||||
return (
|
||||
@@ -101,7 +125,7 @@ export function AssignSpoolModal({ isOpen, onClose, printerId, amsId, trayId, tr
|
||||
style={{ backgroundColor: `#${trayInfo.color}` }}
|
||||
/>
|
||||
)}
|
||||
<span className="text-white font-medium">{trayInfo.type || 'Empty'}</span>
|
||||
<span className="text-white font-medium">{trayInfo.type || t('ams.emptySlot')}</span>
|
||||
<span className="text-bambu-gray">({trayInfo.location})</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -161,9 +185,13 @@ export function AssignSpoolModal({ isOpen, onClose, printerId, amsId, trayId, tr
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : manualSpools && manualSpools.length === 0 ? (
|
||||
<div className="text-center py-8 text-bambu-gray">
|
||||
<p>{t('inventory.noManualSpools')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8 text-bambu-gray">
|
||||
<p>{t('inventory.noSpools')}</p>
|
||||
<p>{t('inventory.noSpoolsMatch')}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -442,9 +442,9 @@ export function ColorCatalogSettings() {
|
||||
<tr>
|
||||
<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>
|
||||
<th className="px-3 py-2 text-left text-bambu-gray font-medium">{t('settings.colorCatalog.colorName')}</th>
|
||||
<th className="px-3 py-2 text-left text-bambu-gray font-medium w-24">{t('settings.colorCatalog.hex')}</th>
|
||||
<th className="px-3 py-2 text-left text-bambu-gray font-medium">{t('inventory.material')}</th>
|
||||
<th className="px-3 py-2 w-16"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -476,6 +476,14 @@ export function ColorCatalogSettings() {
|
||||
onChange={(e) => setFormManufacturer(e.target.value)}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<input
|
||||
type="text"
|
||||
className="w-full px-2 py-1 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-white text-sm focus:border-bambu-green focus:outline-none"
|
||||
value={formMaterial}
|
||||
onChange={(e) => setFormMaterial(e.target.value)}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<input
|
||||
type="text"
|
||||
@@ -492,14 +500,6 @@ export function ColorCatalogSettings() {
|
||||
onChange={(e) => setFormHexColor(e.target.value)}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<input
|
||||
type="text"
|
||||
className="w-full px-2 py-1 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-white text-sm focus:border-bambu-green focus:outline-none"
|
||||
value={formMaterial}
|
||||
onChange={(e) => setFormMaterial(e.target.value)}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<div className="flex justify-end gap-1">
|
||||
<button
|
||||
@@ -525,9 +525,9 @@ export function ColorCatalogSettings() {
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-white">{entry.manufacturer}</td>
|
||||
<td className="px-3 py-2 text-bambu-gray">{entry.material || '-'}</td>
|
||||
<td className="px-3 py-2 text-white">{entry.color_name}</td>
|
||||
<td className="px-3 py-2 font-mono text-xs text-bambu-gray">{entry.hex_color}</td>
|
||||
<td className="px-3 py-2 text-bambu-gray">{entry.material || '-'}</td>
|
||||
<td className="px-3 py-2">
|
||||
<div className="flex justify-end gap-1">
|
||||
<button
|
||||
|
||||
@@ -329,8 +329,8 @@ export function FilamentHoverCard({ data, children, disabled, className = '', sp
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Inventory section - assign/unassign for non-Bambu spools */}
|
||||
{inventory && (data.vendor !== 'Bambu Lab' || !data.trayUuid) && (
|
||||
{/* Inventory section - only for non-Bambu spools */}
|
||||
{inventory && data.vendor !== 'Bambu Lab' && (
|
||||
<div className="pt-2 mt-2 border-t border-bambu-dark-tertiary space-y-2">
|
||||
{inventory.assignedSpool ? (
|
||||
<>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { X, Loader2, Save, Beaker, Palette } from 'lucide-react';
|
||||
import { api } from '../api/client';
|
||||
import type { InventorySpool, SlicerSetting, SpoolCatalogEntry } from '../api/client';
|
||||
import type { InventorySpool, SlicerSetting, SpoolCatalogEntry, LocalPreset } from '../api/client';
|
||||
import { Button } from './Button';
|
||||
import { useToast } from '../contexts/ToastContext';
|
||||
import type { SpoolFormData, PrinterWithCalibrations, ColorPreset } from './spool-form/types';
|
||||
@@ -45,6 +45,12 @@ export function SpoolFormModal({ isOpen, onClose, spool, printersWithCalibration
|
||||
// Spool catalog
|
||||
const [spoolCatalog, setSpoolCatalog] = useState<SpoolCatalogEntry[]>([]);
|
||||
|
||||
// Local presets (OrcaSlicer imports)
|
||||
const [localPresets, setLocalPresets] = useState<LocalPreset[]>([]);
|
||||
|
||||
// Color catalog
|
||||
const [colorCatalog, setColorCatalog] = useState<{ manufacturer: string; color_name: string; hex_color: string; material: string | null }[]>([]);
|
||||
|
||||
// Color state
|
||||
const [recentColors, setRecentColors] = useState<ColorPreset[]>([]);
|
||||
|
||||
@@ -89,6 +95,8 @@ export function SpoolFormModal({ isOpen, onClose, spool, printersWithCalibration
|
||||
};
|
||||
fetchData();
|
||||
api.getSpoolCatalog().then(setSpoolCatalog).catch(console.error);
|
||||
api.getColorCatalog().then(setColorCatalog).catch(console.error);
|
||||
api.getLocalPresets().then(r => setLocalPresets(r.filament)).catch(console.error);
|
||||
|
||||
// Fetch printer calibrations if not provided via props
|
||||
if (printersWithCalibrations.length === 0) {
|
||||
@@ -130,18 +138,18 @@ export function SpoolFormModal({ isOpen, onClose, spool, printersWithCalibration
|
||||
})();
|
||||
}
|
||||
}
|
||||
}, [isOpen]);
|
||||
}, [isOpen, printersWithCalibrations.length]);
|
||||
|
||||
// Build filament options from cloud presets
|
||||
// Build filament options: cloud → local → fallback
|
||||
const filamentOptions = useMemo(
|
||||
() => buildFilamentOptions(cloudPresets, new Set()),
|
||||
[cloudPresets],
|
||||
() => buildFilamentOptions(cloudPresets, new Set(), localPresets),
|
||||
[cloudPresets, localPresets],
|
||||
);
|
||||
|
||||
// Extract brands from presets
|
||||
const availableBrands = useMemo(
|
||||
() => extractBrandsFromPresets(cloudPresets),
|
||||
[cloudPresets],
|
||||
() => extractBrandsFromPresets(cloudPresets, localPresets),
|
||||
[cloudPresets, localPresets],
|
||||
);
|
||||
|
||||
// Find selected preset option
|
||||
@@ -162,6 +170,7 @@ export function SpoolFormModal({ isOpen, onClose, spool, printersWithCalibration
|
||||
rgba: spool.rgba || '808080FF',
|
||||
label_weight: spool.label_weight || 1000,
|
||||
core_weight: spool.core_weight || 250,
|
||||
weight_used: spool.weight_used || 0,
|
||||
slicer_filament: spool.slicer_filament || '',
|
||||
note: spool.note || '',
|
||||
});
|
||||
@@ -214,11 +223,11 @@ export function SpoolFormModal({ isOpen, onClose, spool, printersWithCalibration
|
||||
mutationFn: (data: Record<string, unknown>) =>
|
||||
api.createSpool(data as Parameters<typeof api.createSpool>[0]),
|
||||
onSuccess: async (newSpool) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['inventory-spools'] });
|
||||
// Save K-profiles if any selected
|
||||
if (selectedProfiles.size > 0 && newSpool?.id) {
|
||||
await saveKProfiles(newSpool.id);
|
||||
}
|
||||
await queryClient.invalidateQueries({ queryKey: ['inventory-spools'] });
|
||||
showToast(t('inventory.spoolCreated'), 'success');
|
||||
onClose();
|
||||
},
|
||||
@@ -231,11 +240,11 @@ export function SpoolFormModal({ isOpen, onClose, spool, printersWithCalibration
|
||||
mutationFn: (data: Record<string, unknown>) =>
|
||||
api.updateSpool(spool!.id, data as Parameters<typeof api.updateSpool>[1]),
|
||||
onSuccess: async () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['inventory-spools'] });
|
||||
// Save K-profiles
|
||||
if (spool?.id) {
|
||||
await saveKProfiles(spool.id);
|
||||
}
|
||||
await queryClient.invalidateQueries({ queryKey: ['inventory-spools'] });
|
||||
showToast(t('inventory.spoolUpdated'), 'success');
|
||||
onClose();
|
||||
},
|
||||
@@ -290,6 +299,16 @@ export function SpoolFormModal({ isOpen, onClose, spool, printersWithCalibration
|
||||
}
|
||||
};
|
||||
|
||||
// Close on Escape key
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleSubmit = () => {
|
||||
@@ -314,7 +333,7 @@ export function SpoolFormModal({ isOpen, onClose, spool, printersWithCalibration
|
||||
rgba: formData.rgba || null,
|
||||
label_weight: formData.label_weight,
|
||||
core_weight: formData.core_weight,
|
||||
weight_used: spool?.weight_used ?? 0,
|
||||
weight_used: formData.weight_used,
|
||||
slicer_filament: formData.slicer_filament || null,
|
||||
slicer_filament_name: presetName,
|
||||
nozzle_temp_min: null,
|
||||
@@ -421,6 +440,7 @@ export function SpoolFormModal({ isOpen, onClose, spool, printersWithCalibration
|
||||
updateField={updateField}
|
||||
recentColors={recentColors}
|
||||
onColorUsed={handleColorUsed}
|
||||
catalogColors={colorCatalog}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -139,6 +139,28 @@ export function AdditionalSection({
|
||||
onChange={(weight) => updateField('core_weight', weight)}
|
||||
/>
|
||||
|
||||
{/* Current Weight (remaining filament) */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-bambu-gray mb-1">{t('inventory.currentWeight')}</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative flex-1">
|
||||
<input
|
||||
type="number"
|
||||
value={Math.max(0, formData.label_weight - formData.weight_used)}
|
||||
min={0}
|
||||
max={formData.label_weight}
|
||||
onChange={(e) => {
|
||||
const remaining = parseInt(e.target.value) || 0;
|
||||
updateField('weight_used', Math.max(0, formData.label_weight - remaining));
|
||||
}}
|
||||
className="w-full px-3 py-2 pr-7 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:outline-none focus:border-bambu-green"
|
||||
/>
|
||||
<span className="absolute right-2 top-1/2 -translate-y-1/2 text-xs text-bambu-gray">g</span>
|
||||
</div>
|
||||
<span className="text-xs text-bambu-gray shrink-0">/ {formData.label_weight}g</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Note */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-bambu-gray mb-1">{t('inventory.note')}</label>
|
||||
|
||||
@@ -9,6 +9,7 @@ export function ColorSection({
|
||||
updateField,
|
||||
recentColors,
|
||||
onColorUsed,
|
||||
catalogColors,
|
||||
}: ColorSectionProps) {
|
||||
const { t } = useTranslation();
|
||||
const [showAllColors, setShowAllColors] = useState(false);
|
||||
@@ -28,8 +29,87 @@ export function ColorSection({
|
||||
onColorUsed({ name, hex });
|
||||
};
|
||||
|
||||
// Colors to show based on search/expand state
|
||||
const filteredColors = useMemo(() => {
|
||||
// Filter catalog colors by the selected brand + material + subtype
|
||||
// Brand matching is word-based: "mz - Bambu" matches "Bambu Lab" because both contain "Bambu"
|
||||
// Material matching: try exact "PETG Basic" first, fall back to base material "PETG" prefix
|
||||
const matchedCatalogColors = useMemo(() => {
|
||||
if (catalogColors.length === 0) return [];
|
||||
const brand = formData.brand?.trim();
|
||||
const material = formData.material?.toLowerCase().trim();
|
||||
const subtype = formData.subtype?.toLowerCase().trim();
|
||||
if (!brand && !material) return [];
|
||||
|
||||
// Split brand into words (>= 2 chars) for word-based matching
|
||||
const brandWords = brand
|
||||
? brand.toLowerCase().split(/[\s\-_]+/).filter(w => w.length >= 2)
|
||||
: [];
|
||||
|
||||
const brandMatches = (manufacturer: string) => {
|
||||
if (brandWords.length === 0) return true; // no brand filter
|
||||
const mfrLower = manufacturer.toLowerCase();
|
||||
// Any significant brand word found in manufacturer name
|
||||
return brandWords.some(w => mfrLower.includes(w));
|
||||
};
|
||||
|
||||
// Build the combined material+subtype string to match catalog entries
|
||||
const fullMaterial = material && subtype ? `${material} ${subtype}` : '';
|
||||
|
||||
// First pass: try exact fullMaterial match (e.g. "PETG Basic")
|
||||
if (fullMaterial) {
|
||||
const exact = catalogColors.filter(c =>
|
||||
brandMatches(c.manufacturer) &&
|
||||
c.material?.toLowerCase() === fullMaterial,
|
||||
);
|
||||
if (exact.length > 0) {
|
||||
return exact.map(c => ({
|
||||
name: c.color_name,
|
||||
hex: c.hex_color.replace('#', '').substring(0, 6),
|
||||
}));
|
||||
}
|
||||
// Try without trailing "+" (e.g. "PLA Silk+" -> "PLA Silk")
|
||||
const normalized = fullMaterial.replace(/\+$/, '');
|
||||
if (normalized !== fullMaterial) {
|
||||
const normMatch = catalogColors.filter(c =>
|
||||
brandMatches(c.manufacturer) &&
|
||||
c.material?.toLowerCase() === normalized,
|
||||
);
|
||||
if (normMatch.length > 0) {
|
||||
return normMatch.map(c => ({
|
||||
name: c.color_name,
|
||||
hex: c.hex_color.replace('#', '').substring(0, 6),
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Second pass: match base material prefix (e.g. "PETG" matches "PETG Basic", "PETG-HF")
|
||||
if (material) {
|
||||
const byMaterial = catalogColors.filter(c =>
|
||||
brandMatches(c.manufacturer) &&
|
||||
(!c.material || c.material.toLowerCase().startsWith(material)),
|
||||
);
|
||||
if (byMaterial.length > 0) {
|
||||
return byMaterial.map(c => ({
|
||||
name: c.color_name,
|
||||
hex: c.hex_color.replace('#', '').substring(0, 6),
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
}, [catalogColors, formData.brand, formData.material, formData.subtype]);
|
||||
|
||||
const hasCatalogMatch = matchedCatalogColors.length > 0;
|
||||
|
||||
// Search within matched catalog colors
|
||||
const filteredCatalogColors = useMemo(() => {
|
||||
if (!colorSearch) return matchedCatalogColors;
|
||||
const q = colorSearch.toLowerCase();
|
||||
return matchedCatalogColors.filter(c => c.name.toLowerCase().includes(q));
|
||||
}, [matchedCatalogColors, colorSearch]);
|
||||
|
||||
// Fallback hardcoded colors for search/expand
|
||||
const filteredFallbackColors = useMemo(() => {
|
||||
if (colorSearch) {
|
||||
return ALL_COLORS.filter(c =>
|
||||
c.name.toLowerCase().includes(colorSearch.toLowerCase()),
|
||||
@@ -84,48 +164,81 @@ export function ColorSection({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Color Swatches Grid */}
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between text-xs text-bambu-gray">
|
||||
<span>{colorSearch ? t('inventory.searchResults') : (showAllColors ? t('inventory.allColors') : t('inventory.commonColors'))}</span>
|
||||
{!colorSearch && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAllColors(!showAllColors)}
|
||||
className="flex items-center gap-1 hover:text-white transition-colors"
|
||||
>
|
||||
{showAllColors ? (
|
||||
<>{t('inventory.showLess')} <ChevronUp className="w-3 h-3" /></>
|
||||
) : (
|
||||
<>{t('inventory.showAll')} <ChevronDown className="w-3 h-3" /></>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
{/* Color Swatches */}
|
||||
{hasCatalogMatch ? (
|
||||
/* Catalog colors matching selected brand/material */
|
||||
<div className="space-y-1.5">
|
||||
<span className="text-xs text-bambu-gray">
|
||||
{colorSearch ? t('inventory.searchResults') : `${formData.brand}${formData.material ? ` ${formData.material}` : ''}`}
|
||||
</span>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{filteredCatalogColors.map(color => (
|
||||
<button
|
||||
key={`${color.hex}-${color.name}`}
|
||||
type="button"
|
||||
onClick={() => selectColor(color.hex, color.name)}
|
||||
className={`w-6 h-6 rounded border-2 transition-all hover:scale-110 relative group ${
|
||||
isSelected(color.hex)
|
||||
? 'border-bambu-green ring-1 ring-bambu-green/30 scale-110'
|
||||
: 'border-bambu-dark-tertiary'
|
||||
}`}
|
||||
style={{ backgroundColor: `#${color.hex}` }}
|
||||
title={color.name}
|
||||
>
|
||||
<span className="absolute -bottom-7 left-1/2 -translate-x-1/2 px-2 py-0.5 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-xs whitespace-nowrap opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none z-10 shadow-lg text-white">
|
||||
{color.name}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
{filteredCatalogColors.length === 0 && (
|
||||
<p className="text-sm text-bambu-gray py-1">{t('inventory.noColorsFound')}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{filteredColors.map(color => (
|
||||
<button
|
||||
key={color.hex}
|
||||
type="button"
|
||||
onClick={() => selectColor(color.hex, color.name)}
|
||||
className={`w-6 h-6 rounded border-2 transition-all hover:scale-110 relative group ${
|
||||
isSelected(color.hex)
|
||||
? 'border-bambu-green ring-1 ring-bambu-green/30 scale-110'
|
||||
: 'border-bambu-dark-tertiary'
|
||||
}`}
|
||||
style={{ backgroundColor: `#${color.hex}` }}
|
||||
title={color.name}
|
||||
>
|
||||
<span className="absolute -bottom-7 left-1/2 -translate-x-1/2 px-2 py-0.5 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-xs whitespace-nowrap opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none z-10 shadow-lg text-white">
|
||||
{color.name}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
{filteredColors.length === 0 && (
|
||||
<p className="text-sm text-bambu-gray py-1">{t('inventory.noColorsFound')}</p>
|
||||
)}
|
||||
) : (
|
||||
/* Fallback: hardcoded color palette (no brand/material selected or no catalog matches) */
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between text-xs text-bambu-gray">
|
||||
<span>{colorSearch ? t('inventory.searchResults') : (showAllColors ? t('inventory.allColors') : t('inventory.commonColors'))}</span>
|
||||
{!colorSearch && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAllColors(!showAllColors)}
|
||||
className="flex items-center gap-1 hover:text-white transition-colors"
|
||||
>
|
||||
{showAllColors ? (
|
||||
<>{t('inventory.showLess')} <ChevronUp className="w-3 h-3" /></>
|
||||
) : (
|
||||
<>{t('inventory.showAll')} <ChevronDown className="w-3 h-3" /></>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{filteredFallbackColors.map(color => (
|
||||
<button
|
||||
key={color.hex}
|
||||
type="button"
|
||||
onClick={() => selectColor(color.hex, color.name)}
|
||||
className={`w-6 h-6 rounded border-2 transition-all hover:scale-110 relative group ${
|
||||
isSelected(color.hex)
|
||||
? 'border-bambu-green ring-1 ring-bambu-green/30 scale-110'
|
||||
: 'border-bambu-dark-tertiary'
|
||||
}`}
|
||||
style={{ backgroundColor: `#${color.hex}` }}
|
||||
title={color.name}
|
||||
>
|
||||
<span className="absolute -bottom-7 left-1/2 -translate-x-1/2 px-2 py-0.5 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-xs whitespace-nowrap opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none z-10 shadow-lg text-white">
|
||||
{color.name}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
{filteredFallbackColors.length === 0 && (
|
||||
<p className="text-sm text-bambu-gray py-1">{t('inventory.noColorsFound')}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Manual Color Input */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
|
||||
@@ -9,6 +9,7 @@ export interface SpoolFormData {
|
||||
rgba: string;
|
||||
label_weight: number;
|
||||
core_weight: number;
|
||||
weight_used: number;
|
||||
slicer_filament: string;
|
||||
note: string;
|
||||
}
|
||||
@@ -21,6 +22,7 @@ export const defaultFormData: SpoolFormData = {
|
||||
rgba: '808080FF',
|
||||
label_weight: 1000,
|
||||
core_weight: 250,
|
||||
weight_used: 0,
|
||||
slicer_filament: '',
|
||||
note: '',
|
||||
};
|
||||
@@ -79,6 +81,7 @@ export interface FilamentSectionProps extends SectionProps {
|
||||
export interface ColorSectionProps extends SectionProps {
|
||||
recentColors: ColorPreset[];
|
||||
onColorUsed: (color: ColorPreset) => void;
|
||||
catalogColors: { manufacturer: string; color_name: string; hex_color: string; material: string | null }[];
|
||||
}
|
||||
|
||||
// Additional section props
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { SlicerSetting } from '../../api/client';
|
||||
import type { SlicerSetting, LocalPreset } from '../../api/client';
|
||||
import type { ColorPreset, FilamentOption } from './types';
|
||||
import { KNOWN_VARIANTS, DEFAULT_BRANDS, RECENT_COLORS_KEY, MAX_RECENT_COLORS } from './constants';
|
||||
|
||||
@@ -91,8 +91,8 @@ export function parsePresetName(name: string): { brand: string; material: string
|
||||
return { brand, material, variant };
|
||||
}
|
||||
|
||||
// Extract unique brands from cloud presets
|
||||
export function extractBrandsFromPresets(presets: SlicerSetting[]): string[] {
|
||||
// Extract unique brands from cloud presets and local presets
|
||||
export function extractBrandsFromPresets(presets: SlicerSetting[], localPresets?: LocalPreset[]): string[] {
|
||||
const brandSet = new Set<string>(DEFAULT_BRANDS);
|
||||
|
||||
for (const preset of presets) {
|
||||
@@ -102,14 +102,56 @@ export function extractBrandsFromPresets(presets: SlicerSetting[]): string[] {
|
||||
}
|
||||
}
|
||||
|
||||
// Also extract brands from local presets
|
||||
if (localPresets) {
|
||||
for (const preset of localPresets) {
|
||||
if (preset.filament_vendor && preset.filament_vendor.length > 1) {
|
||||
brandSet.add(preset.filament_vendor);
|
||||
} else {
|
||||
const { brand } = parsePresetName(preset.name);
|
||||
if (brand && brand.length > 1) {
|
||||
brandSet.add(brand);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(brandSet).sort((a, b) => a.localeCompare(b));
|
||||
}
|
||||
|
||||
// Build filament options from cloud presets
|
||||
// Build filament options from local presets (OrcaSlicer imports)
|
||||
function buildLocalFilamentOptions(localPresets: LocalPreset[]): FilamentOption[] {
|
||||
const filamentPresets = localPresets.filter(p => p.preset_type === 'filament');
|
||||
if (filamentPresets.length === 0) return [];
|
||||
|
||||
const presetsMap = new Map<string, FilamentOption>();
|
||||
for (const preset of filamentPresets) {
|
||||
const baseName = preset.name.replace(/@.*$/, '').trim();
|
||||
const existing = presetsMap.get(baseName);
|
||||
if (existing) {
|
||||
existing.allCodes.push(String(preset.id));
|
||||
} else {
|
||||
// Use filament_type as the code if available (e.g. "GFL00"), otherwise use the id
|
||||
const code = preset.filament_type || String(preset.id);
|
||||
presetsMap.set(baseName, {
|
||||
code,
|
||||
name: baseName,
|
||||
displayName: baseName,
|
||||
isCustom: false,
|
||||
allCodes: [code],
|
||||
});
|
||||
}
|
||||
}
|
||||
return Array.from(presetsMap.values()).sort((a, b) => a.displayName.localeCompare(b.displayName));
|
||||
}
|
||||
|
||||
// Build filament options: cloud presets → local presets → hardcoded fallback
|
||||
export function buildFilamentOptions(
|
||||
cloudPresets: SlicerSetting[],
|
||||
configuredPrinterModels: Set<string>,
|
||||
localPresets?: LocalPreset[],
|
||||
): FilamentOption[] {
|
||||
// 1. Cloud presets (highest priority)
|
||||
if (cloudPresets.length > 0) {
|
||||
const customPresets: FilamentOption[] = [];
|
||||
const defaultPresetsMap = new Map<string, FilamentOption>();
|
||||
@@ -155,7 +197,13 @@ export function buildFilamentOptions(
|
||||
].sort((a, b) => a.displayName.localeCompare(b.displayName));
|
||||
}
|
||||
|
||||
// Fallback to hardcoded defaults
|
||||
// 2. Local presets (OrcaSlicer imports)
|
||||
if (localPresets && localPresets.length > 0) {
|
||||
const localOptions = buildLocalFilamentOptions(localPresets);
|
||||
if (localOptions.length > 0) return localOptions;
|
||||
}
|
||||
|
||||
// 3. Hardcoded fallback
|
||||
return FALLBACK_PRESETS;
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ export default {
|
||||
profiles: 'Profile',
|
||||
maintenance: 'Wartung',
|
||||
projects: 'Projekte',
|
||||
inventory: 'Inventar',
|
||||
inventory: 'Filament',
|
||||
files: 'Dateimanager',
|
||||
settings: 'Einstellungen',
|
||||
system: 'System',
|
||||
@@ -2447,6 +2447,7 @@ export default {
|
||||
coreWeight: 'Leergewicht der Spule',
|
||||
searchSpoolWeight: 'Spulengewicht suchen...',
|
||||
weightUsed: 'Verbraucht',
|
||||
currentWeight: 'Restgewicht',
|
||||
slicerFilament: 'Slicer-Filament',
|
||||
slicerFilamentName: 'Slicer-Preset-Name',
|
||||
slicerPreset: 'Slicer-Preset',
|
||||
@@ -2459,6 +2460,7 @@ export default {
|
||||
archive: 'Archivieren',
|
||||
restore: 'Wiederherstellen',
|
||||
noSpools: 'Noch keine Spulen. Fügen Sie Ihre erste Spule hinzu.',
|
||||
noManualSpools: 'Keine manuell hinzugefügten Spulen verfügbar. Fügen Sie zuerst eine Spule zum Inventar hinzu.',
|
||||
kProfiles: 'K-Profile',
|
||||
addKProfile: 'K-Profil hinzufügen',
|
||||
assignSpool: 'Spule zuweisen',
|
||||
|
||||
@@ -8,7 +8,7 @@ export default {
|
||||
profiles: 'Profiles',
|
||||
maintenance: 'Maintenance',
|
||||
projects: 'Projects',
|
||||
inventory: 'Inventory',
|
||||
inventory: 'Filament',
|
||||
files: 'File Manager',
|
||||
settings: 'Settings',
|
||||
system: 'System',
|
||||
@@ -2447,6 +2447,7 @@ export default {
|
||||
coreWeight: 'Empty Spool Weight',
|
||||
searchSpoolWeight: 'Search spool weight...',
|
||||
weightUsed: 'Used',
|
||||
currentWeight: 'Remaining Weight',
|
||||
slicerFilament: 'Slicer Filament',
|
||||
slicerFilamentName: 'Slicer Preset Name',
|
||||
slicerPreset: 'Slicer Preset',
|
||||
@@ -2459,6 +2460,7 @@ export default {
|
||||
archive: 'Archive',
|
||||
restore: 'Restore',
|
||||
noSpools: 'No spools yet. Add your first spool to get started.',
|
||||
noManualSpools: 'No manually added spools available. Add a spool to your inventory first.',
|
||||
kProfiles: 'K-Profiles',
|
||||
addKProfile: 'Add K-Profile',
|
||||
assignSpool: 'Assign Spool',
|
||||
|
||||
@@ -7,7 +7,7 @@ export default {
|
||||
profiles: 'プロファイル',
|
||||
maintenance: 'メンテナンス',
|
||||
projects: 'プロジェクト',
|
||||
inventory: 'インベントリ',
|
||||
inventory: 'フィラメント',
|
||||
files: 'ファイル管理',
|
||||
settings: '設定',
|
||||
system: 'システム',
|
||||
@@ -2378,6 +2378,7 @@ export default {
|
||||
coreWeight: '空スプール重量',
|
||||
searchSpoolWeight: 'スプール重量を検索...',
|
||||
weightUsed: '使用量',
|
||||
currentWeight: '残量',
|
||||
slicerFilament: 'スライサーフィラメント',
|
||||
slicerFilamentName: 'スライサープリセット名',
|
||||
slicerPreset: 'スライサープリセット',
|
||||
@@ -2390,6 +2391,7 @@ export default {
|
||||
archive: 'アーカイブ',
|
||||
restore: '復元',
|
||||
noSpools: 'スプールがありません。最初のスプールを追加してください。',
|
||||
noManualSpools: '手動で追加されたスプールがありません。先にインベントリにスプールを追加してください。',
|
||||
kProfiles: 'Kプロファイル',
|
||||
addKProfile: 'Kプロファイルを追加',
|
||||
assignSpool: 'スプールを割り当て',
|
||||
|
||||
@@ -5,6 +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,
|
||||
} from 'lucide-react';
|
||||
import { api } from '../api/client';
|
||||
import type { InventorySpool, SpoolAssignment } from '../api/client';
|
||||
@@ -17,6 +18,8 @@ import { resolveSpoolColorName } from '../utils/colors';
|
||||
type ArchiveFilter = 'active' | 'archived';
|
||||
type UsageFilter = 'all' | 'used' | 'new';
|
||||
type ViewMode = 'table' | 'cards';
|
||||
type SortDirection = 'asc' | 'desc';
|
||||
type SortState = { column: string; direction: SortDirection } | null;
|
||||
|
||||
// Column definitions for the inventory table
|
||||
const COLUMN_CONFIG_KEY = 'bambuddy-inventory-columns';
|
||||
@@ -249,6 +252,52 @@ const columnCells: Record<string, (ctx: CellCtx) => ReactNode> = {
|
||||
),
|
||||
};
|
||||
|
||||
// Sort value extractors — return a comparable value for each sortable column
|
||||
const columnSortValues: Record<string, (spool: InventorySpool, assignmentMap: Record<number, SpoolAssignment>) => string | number> = {
|
||||
id: (s) => s.id,
|
||||
added_time: (s) => s.created_at || '',
|
||||
encode_time: (s) => s.encode_time || '',
|
||||
last_used_time: (s) => s.last_used || '',
|
||||
material: (s) => (s.material || '').toLowerCase(),
|
||||
subtype: (s) => (s.subtype || '').toLowerCase(),
|
||||
color_name: (s) => (s.color_name || '').toLowerCase(),
|
||||
brand: (s) => (s.brand || '').toLowerCase(),
|
||||
slicer_filament: (s) => (s.slicer_filament_name || s.slicer_filament || '').toLowerCase(),
|
||||
location: (s, am) => {
|
||||
const a = am[s.id];
|
||||
if (!a) return '';
|
||||
return `${a.printer_name || ''} ${String.fromCharCode(65 + a.ams_id)}${a.tray_id + 1}`;
|
||||
},
|
||||
label_weight: (s) => s.label_weight,
|
||||
net: (s) => Math.max(0, s.label_weight - s.weight_used),
|
||||
gross: (s) => Math.max(0, s.label_weight - s.weight_used) + s.core_weight,
|
||||
used: (s) => s.weight_used,
|
||||
remaining: (s) => s.label_weight > 0 ? Math.max(0, s.label_weight - s.weight_used) / s.label_weight : 0,
|
||||
note: (s) => (s.note || '').toLowerCase(),
|
||||
data_origin: (s) => (s.data_origin || '').toLowerCase(),
|
||||
tag_type: (s) => (s.tag_type || '').toLowerCase(),
|
||||
};
|
||||
|
||||
const SORT_STATE_KEY = 'bambuddy-inventory-sort';
|
||||
|
||||
function loadSortState(): SortState {
|
||||
try {
|
||||
const stored = localStorage.getItem(SORT_STATE_KEY);
|
||||
if (stored) return JSON.parse(stored);
|
||||
} catch { /* ignore */ }
|
||||
return null;
|
||||
}
|
||||
|
||||
function saveSortState(state: SortState) {
|
||||
try {
|
||||
if (state) {
|
||||
localStorage.setItem(SORT_STATE_KEY, JSON.stringify(state));
|
||||
} else {
|
||||
localStorage.removeItem(SORT_STATE_KEY);
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
export default function InventoryPage() {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
@@ -262,21 +311,33 @@ export default function InventoryPage() {
|
||||
const [brandFilter, setBrandFilter] = useState('');
|
||||
const [search, setSearch] = useState('');
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('table');
|
||||
const [sortState, setSortState] = useState<SortState>(loadSortState);
|
||||
const [columnConfig, setColumnConfig] = useState<ColumnConfig[]>(loadColumnConfig);
|
||||
const [showColumnModal, setShowColumnModal] = useState(false);
|
||||
|
||||
// Pagination state
|
||||
// Pagination state (pageSize persisted to localStorage)
|
||||
const [pageIndex, setPageIndex] = useState(0);
|
||||
const [pageSize, setPageSize] = useState(15);
|
||||
const [pageSize, setPageSize] = useState(() => {
|
||||
try {
|
||||
const stored = localStorage.getItem('bambuddy-inventory-pageSize');
|
||||
if (stored) {
|
||||
const n = Number(stored);
|
||||
if ([15, 30, 50, 100, -1].includes(n)) return n;
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
return 15;
|
||||
});
|
||||
|
||||
const { data: spools, isLoading } = useQuery({
|
||||
queryKey: ['inventory-spools'],
|
||||
queryFn: () => api.getSpools(true), // Always fetch all, filter client-side
|
||||
refetchInterval: 30000,
|
||||
});
|
||||
|
||||
const { data: assignments } = useQuery({
|
||||
queryKey: ['spool-assignments'],
|
||||
queryFn: () => api.getAssignments(),
|
||||
refetchInterval: 30000,
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
@@ -390,11 +451,6 @@ export default function InventoryPage() {
|
||||
return filtered;
|
||||
}, [spools, archiveFilter, usageFilter, materialFilter, brandFilter, search]);
|
||||
|
||||
// Pagination
|
||||
const totalPages = Math.max(1, Math.ceil(filteredSpools.length / pageSize));
|
||||
const safePageIndex = Math.min(pageIndex, totalPages - 1);
|
||||
const pagedSpools = filteredSpools.slice(safePageIndex * pageSize, (safePageIndex + 1) * pageSize);
|
||||
|
||||
// Reset page on filter changes
|
||||
const resetPage = () => setPageIndex(0);
|
||||
|
||||
@@ -416,6 +472,50 @@ export default function InventoryPage() {
|
||||
[columnConfig]
|
||||
);
|
||||
|
||||
const handleSort = (colId: string) => {
|
||||
if (!columnSortValues[colId]) return; // Not sortable
|
||||
setSortState((prev) => {
|
||||
let next: SortState;
|
||||
if (prev?.column === colId) {
|
||||
// Toggle direction, or clear on third click
|
||||
next = prev.direction === 'asc' ? { column: colId, direction: 'desc' } : null;
|
||||
} else {
|
||||
next = { column: colId, direction: 'asc' };
|
||||
}
|
||||
saveSortState(next);
|
||||
return next;
|
||||
});
|
||||
resetPage();
|
||||
};
|
||||
|
||||
// Sort filtered spools
|
||||
const sortedSpools = useMemo(() => {
|
||||
if (!sortState) return filteredSpools;
|
||||
const extractor = columnSortValues[sortState.column];
|
||||
if (!extractor) return filteredSpools;
|
||||
const sorted = [...filteredSpools].sort((a, b) => {
|
||||
const va = extractor(a, assignmentMap);
|
||||
const vb = extractor(b, assignmentMap);
|
||||
if (va < vb) return sortState.direction === 'asc' ? -1 : 1;
|
||||
if (va > vb) return sortState.direction === 'asc' ? 1 : -1;
|
||||
return 0;
|
||||
});
|
||||
return sorted;
|
||||
}, [filteredSpools, sortState, 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 safePageIndex = showAll ? 0 : Math.min(pageIndex, totalPages - 1);
|
||||
const pagedSpools = showAll ? sortedSpools : sortedSpools.slice(safePageIndex * effectivePageSize, (safePageIndex + 1) * effectivePageSize);
|
||||
|
||||
const handlePageSizeChange = (size: number) => {
|
||||
setPageSize(size);
|
||||
setPageIndex(0);
|
||||
try { localStorage.setItem('bambuddy-inventory-pageSize', String(size)); } catch { /* ignore */ }
|
||||
};
|
||||
|
||||
const clearAllFilters = () => {
|
||||
setArchiveFilter('active');
|
||||
setUsageFilter('all');
|
||||
@@ -527,15 +627,17 @@ export default function InventoryPage() {
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Columns button */}
|
||||
<button
|
||||
onClick={() => setShowColumnModal(true)}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium text-bambu-gray border border-bambu-dark-tertiary rounded-lg hover:bg-bambu-dark-tertiary transition-colors"
|
||||
title={t('inventory.configureColumns')}
|
||||
>
|
||||
<Columns className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">{t('inventory.columns')}</span>
|
||||
</button>
|
||||
{/* Columns button (table view only) */}
|
||||
{viewMode === 'table' && (
|
||||
<button
|
||||
onClick={() => setShowColumnModal(true)}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium text-bambu-gray border border-bambu-dark-tertiary rounded-lg hover:bg-bambu-dark-tertiary transition-colors"
|
||||
title={t('inventory.configureColumns')}
|
||||
>
|
||||
<Columns className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">{t('inventory.columns')}</span>
|
||||
</button>
|
||||
)}
|
||||
{/* Table / Cards toggle */}
|
||||
<div className="flex bg-bambu-dark-primary border border-bambu-dark-tertiary rounded-lg overflow-hidden">
|
||||
<button
|
||||
@@ -679,7 +781,7 @@ export default function InventoryPage() {
|
||||
|
||||
{/* Results count */}
|
||||
<span className="ml-auto text-xs text-bambu-gray">
|
||||
{filteredSpools.length} {filteredSpools.length !== 1 ? t('inventory.spools') : t('inventory.spool')}
|
||||
{sortedSpools.length} {sortedSpools.length !== 1 ? t('inventory.spools') : t('inventory.spool')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -760,10 +862,10 @@ export default function InventoryPage() {
|
||||
<PaginationBar
|
||||
pageIndex={safePageIndex}
|
||||
pageSize={pageSize}
|
||||
totalRows={filteredSpools.length}
|
||||
totalRows={sortedSpools.length}
|
||||
totalPages={totalPages}
|
||||
onPageChange={setPageIndex}
|
||||
onPageSizeChange={(size) => { setPageSize(size); resetPage(); }}
|
||||
onPageSizeChange={handlePageSizeChange}
|
||||
t={t}
|
||||
/>
|
||||
</>
|
||||
@@ -782,14 +884,30 @@ export default function InventoryPage() {
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-bambu-dark-tertiary bg-bambu-dark-tertiary/30">
|
||||
{visibleColumns.map((colId) => (
|
||||
<th
|
||||
key={colId}
|
||||
className={`text-left py-3 px-4 text-xs font-medium text-bambu-gray uppercase tracking-wide ${colId === 'remaining' ? 'min-w-[150px]' : ''}`}
|
||||
>
|
||||
{columnHeaders[colId]?.(t) ?? colId}
|
||||
</th>
|
||||
))}
|
||||
{visibleColumns.map((colId) => {
|
||||
const sortable = !!columnSortValues[colId];
|
||||
const isActive = sortState?.column === colId;
|
||||
return (
|
||||
<th
|
||||
key={colId}
|
||||
className={`text-left py-3 px-4 text-xs font-medium uppercase tracking-wide select-none ${colId === 'remaining' ? 'min-w-[150px]' : ''} ${
|
||||
sortable ? 'cursor-pointer hover:text-bambu-green transition-colors' : ''
|
||||
} ${isActive ? 'text-bambu-green' : 'text-bambu-gray'}`}
|
||||
onClick={sortable ? () => handleSort(colId) : undefined}
|
||||
>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
{columnHeaders[colId]?.(t) ?? colId}
|
||||
{sortable && (
|
||||
isActive
|
||||
? sortState.direction === 'asc'
|
||||
? <ArrowUp className="w-3 h-3" />
|
||||
: <ArrowDown className="w-3 h-3" />
|
||||
: <ArrowUpDown className="w-3 h-3 opacity-30" />
|
||||
)}
|
||||
</span>
|
||||
</th>
|
||||
);
|
||||
})}
|
||||
<th className="text-right py-3 px-4 text-xs font-medium text-bambu-gray uppercase tracking-wide">{t('common.actions')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -859,56 +977,64 @@ export default function InventoryPage() {
|
||||
{/* Pagination inside card footer */}
|
||||
<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">
|
||||
{t('inventory.showing')} {safePageIndex * pageSize + 1} {t('inventory.to')}{' '}
|
||||
{Math.min((safePageIndex + 1) * pageSize, filteredSpools.length)}{' '}
|
||||
{t('inventory.of')} {filteredSpools.length} {t('inventory.spools')}
|
||||
{showAll
|
||||
? `${sortedSpools.length} ${sortedSpools.length !== 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')}</>
|
||||
}
|
||||
</span>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-bambu-gray">{t('inventory.show')}</span>
|
||||
<select
|
||||
value={pageSize}
|
||||
onChange={(e) => { setPageSize(Number(e.target.value)); resetPage(); }}
|
||||
onChange={(e) => handlePageSizeChange(Number(e.target.value))}
|
||||
className="px-2 py-1 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-white text-sm focus:outline-none focus:border-bambu-green"
|
||||
>
|
||||
{[15, 30, 50, 100].map((n) => (
|
||||
<option key={n} value={n}>{n}</option>
|
||||
))}
|
||||
<option value={-1}>{t('inventory.all')}</option>
|
||||
</select>
|
||||
|
||||
<button
|
||||
onClick={() => setPageIndex(0)}
|
||||
disabled={safePageIndex === 0}
|
||||
className="p-1.5 rounded text-bambu-gray hover:text-white disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||
title="First page"
|
||||
>
|
||||
<ChevronsLeft className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setPageIndex((p) => Math.max(0, p - 1))}
|
||||
disabled={safePageIndex === 0}
|
||||
className="p-1.5 rounded text-bambu-gray hover:text-white disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</button>
|
||||
<span className="text-bambu-gray px-2 whitespace-nowrap">
|
||||
{t('inventory.page')} {safePageIndex + 1} {t('inventory.of')} {totalPages}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setPageIndex((p) => Math.min(totalPages - 1, p + 1))}
|
||||
disabled={safePageIndex >= totalPages - 1}
|
||||
className="p-1.5 rounded text-bambu-gray hover:text-white disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setPageIndex(totalPages - 1)}
|
||||
disabled={safePageIndex >= totalPages - 1}
|
||||
className="p-1.5 rounded text-bambu-gray hover:text-white disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||
title="Last page"
|
||||
>
|
||||
<ChevronsRight className="w-4 h-4" />
|
||||
</button>
|
||||
{!showAll && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setPageIndex(0)}
|
||||
disabled={safePageIndex === 0}
|
||||
className="p-1.5 rounded text-bambu-gray hover:text-white disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||
title="First page"
|
||||
>
|
||||
<ChevronsLeft className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setPageIndex((p) => Math.max(0, p - 1))}
|
||||
disabled={safePageIndex === 0}
|
||||
className="p-1.5 rounded text-bambu-gray hover:text-white disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</button>
|
||||
<span className="text-bambu-gray px-2 whitespace-nowrap">
|
||||
{t('inventory.page')} {safePageIndex + 1} {t('inventory.of')} {totalPages}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setPageIndex((p) => Math.min(totalPages - 1, p + 1))}
|
||||
disabled={safePageIndex >= totalPages - 1}
|
||||
className="p-1.5 rounded text-bambu-gray hover:text-white disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setPageIndex(totalPages - 1)}
|
||||
disabled={safePageIndex >= totalPages - 1}
|
||||
className="p-1.5 rounded text-bambu-gray hover:text-white disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||
title="Last page"
|
||||
>
|
||||
<ChevronsRight className="w-4 h-4" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -954,13 +1080,18 @@ function PaginationBar({
|
||||
onPageSizeChange: (size: number) => void;
|
||||
t: (key: string) => string;
|
||||
}) {
|
||||
if (totalPages <= 1) return null;
|
||||
const isShowAll = pageSize === -1;
|
||||
if (totalPages <= 1 && !isShowAll) return null;
|
||||
const effectiveSize = isShowAll ? totalRows || 1 : pageSize;
|
||||
return (
|
||||
<div className="flex items-center justify-between pt-2 text-sm">
|
||||
<span className="text-bambu-gray">
|
||||
{t('inventory.showing')} {pageIndex * pageSize + 1} {t('inventory.to')}{' '}
|
||||
{Math.min((pageIndex + 1) * pageSize, totalRows)}{' '}
|
||||
{t('inventory.of')} {totalRows} {t('inventory.spools')}
|
||||
{isShowAll
|
||||
? `${totalRows} ${totalRows !== 1 ? t('inventory.spools') : t('inventory.spool')}`
|
||||
: <>{t('inventory.showing')} {pageIndex * effectiveSize + 1} {t('inventory.to')}{' '}
|
||||
{Math.min((pageIndex + 1) * effectiveSize, totalRows)}{' '}
|
||||
{t('inventory.of')} {totalRows} {t('inventory.spools')}</>
|
||||
}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-bambu-gray">{t('inventory.show')}</span>
|
||||
@@ -972,38 +1103,43 @@ function PaginationBar({
|
||||
{[15, 30, 50, 100].map((n) => (
|
||||
<option key={n} value={n}>{n}</option>
|
||||
))}
|
||||
<option value={-1}>{t('inventory.all')}</option>
|
||||
</select>
|
||||
<button
|
||||
onClick={() => onPageChange(0)}
|
||||
disabled={pageIndex === 0}
|
||||
className="p-1.5 rounded text-bambu-gray hover:text-white disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<ChevronsLeft className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onPageChange(Math.max(0, pageIndex - 1))}
|
||||
disabled={pageIndex === 0}
|
||||
className="p-1.5 rounded text-bambu-gray hover:text-white disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</button>
|
||||
<span className="text-bambu-gray px-2 whitespace-nowrap">
|
||||
{t('inventory.page')} {pageIndex + 1} {t('inventory.of')} {totalPages}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => onPageChange(Math.min(totalPages - 1, pageIndex + 1))}
|
||||
disabled={pageIndex >= totalPages - 1}
|
||||
className="p-1.5 rounded text-bambu-gray hover:text-white disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onPageChange(totalPages - 1)}
|
||||
disabled={pageIndex >= totalPages - 1}
|
||||
className="p-1.5 rounded text-bambu-gray hover:text-white disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<ChevronsRight className="w-4 h-4" />
|
||||
</button>
|
||||
{!isShowAll && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => onPageChange(0)}
|
||||
disabled={pageIndex === 0}
|
||||
className="p-1.5 rounded text-bambu-gray hover:text-white disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<ChevronsLeft className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onPageChange(Math.max(0, pageIndex - 1))}
|
||||
disabled={pageIndex === 0}
|
||||
className="p-1.5 rounded text-bambu-gray hover:text-white disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</button>
|
||||
<span className="text-bambu-gray px-2 whitespace-nowrap">
|
||||
{t('inventory.page')} {pageIndex + 1} {t('inventory.of')} {totalPages}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => onPageChange(Math.min(totalPages - 1, pageIndex + 1))}
|
||||
disabled={pageIndex >= totalPages - 1}
|
||||
className="p-1.5 rounded text-bambu-gray hover:text-white disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onPageChange(totalPages - 1)}
|
||||
disabled={pageIndex >= totalPages - 1}
|
||||
className="p-1.5 rounded text-bambu-gray hover:text-white disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<ChevronsRight className="w-4 h-4" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -2834,7 +2834,7 @@ function PrinterCard({
|
||||
brand: assignment.spool.brand,
|
||||
color_name: assignment.spool.color_name,
|
||||
} : null,
|
||||
onAssignSpool: () => setAssignSpoolModal({
|
||||
onAssignSpool: filamentData.vendor !== 'Bambu Lab' ? () => setAssignSpoolModal({
|
||||
printerId: printer.id,
|
||||
amsId: ams.id,
|
||||
trayId: slotIdx,
|
||||
@@ -2843,8 +2843,8 @@ function PrinterCard({
|
||||
color: filamentData.colorHex || '',
|
||||
location: `${getAmsLabel(ams.id, ams.tray.length)} Slot ${slotIdx + 1}`,
|
||||
},
|
||||
}),
|
||||
onUnassignSpool: assignment ? () => onUnassignSpool?.(printer.id, ams.id, slotIdx) : undefined,
|
||||
}) : undefined,
|
||||
onUnassignSpool: assignment && filamentData.vendor !== 'Bambu Lab' ? () => onUnassignSpool?.(printer.id, ams.id, slotIdx) : undefined,
|
||||
};
|
||||
})()}
|
||||
configureSlot={{
|
||||
@@ -3068,7 +3068,7 @@ function PrinterCard({
|
||||
brand: assignment.spool.brand,
|
||||
color_name: assignment.spool.color_name,
|
||||
} : null,
|
||||
onAssignSpool: () => setAssignSpoolModal({
|
||||
onAssignSpool: filamentData.vendor !== 'Bambu Lab' ? () => setAssignSpoolModal({
|
||||
printerId: printer.id,
|
||||
amsId: ams.id,
|
||||
trayId: htSlotId,
|
||||
@@ -3077,8 +3077,8 @@ function PrinterCard({
|
||||
color: filamentData.colorHex || '',
|
||||
location: getAmsLabel(ams.id, ams.tray.length),
|
||||
},
|
||||
}),
|
||||
onUnassignSpool: assignment ? () => onUnassignSpool?.(printer.id, ams.id, htSlotId) : undefined,
|
||||
}) : undefined,
|
||||
onUnassignSpool: assignment && filamentData.vendor !== 'Bambu Lab' ? () => onUnassignSpool?.(printer.id, ams.id, htSlotId) : undefined,
|
||||
};
|
||||
})()}
|
||||
configureSlot={{
|
||||
@@ -3247,7 +3247,7 @@ function PrinterCard({
|
||||
brand: assignment.spool.brand,
|
||||
color_name: assignment.spool.color_name,
|
||||
} : null,
|
||||
onAssignSpool: () => setAssignSpoolModal({
|
||||
onAssignSpool: extFilamentData.vendor !== 'Bambu Lab' ? () => setAssignSpoolModal({
|
||||
printerId: printer.id,
|
||||
amsId: 255,
|
||||
trayId: 0,
|
||||
@@ -3256,8 +3256,8 @@ function PrinterCard({
|
||||
color: extFilamentData.colorHex || '',
|
||||
location: 'External Spool',
|
||||
},
|
||||
}),
|
||||
onUnassignSpool: assignment ? () => onUnassignSpool?.(printer.id, 255, 0) : undefined,
|
||||
}) : undefined,
|
||||
onUnassignSpool: assignment && extFilamentData.vendor !== 'Bambu Lab' ? () => onUnassignSpool?.(printer.id, 255, 0) : undefined,
|
||||
};
|
||||
})()}
|
||||
configureSlot={{
|
||||
@@ -4948,9 +4948,9 @@ export function PrintersPage() {
|
||||
});
|
||||
|
||||
// Helper to find assignment for a specific slot
|
||||
const getAssignment = (printerId: number, amsId: number, trayId: number): SpoolAssignment | undefined => {
|
||||
const getAssignment = (printerId: number, amsId: number | string, trayId: number | string): SpoolAssignment | undefined => {
|
||||
return spoolAssignments?.find(
|
||||
(a) => a.printer_id === printerId && a.ams_id === amsId && a.tray_id === trayId
|
||||
(a) => a.printer_id === printerId && a.ams_id === Number(amsId) && a.tray_id === Number(trayId)
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user