Improve spool ID search and AMS assignment (issue #2978) (#2998)

This commit is contained in:
Peer Drescher
2026-09-28 16:42:38 +02:00
committed by GitHub
parent a06bb05624
commit 632930f02a
27 changed files with 781 additions and 190 deletions
@@ -109,6 +109,17 @@ describe('AssignSpoolModal', () => {
expect(screen.getByText(/Jade White/)).toBeInTheDocument();
});
it('shows each spool ID on the assignment card (#2978)', async () => {
render(<AssignSpoolModal {...defaultProps} />);
await waitFor(() => {
expect(screen.getByText('#1')).toBeInTheDocument();
});
expect(screen.getByText('#2')).toBeInTheDocument();
expect(screen.getByText('#3')).toBeInTheDocument();
});
it('renders a clear tray as the transparency checkerboard, not an invisible circle (#2912)', async () => {
render(
<AssignSpoolModal
@@ -467,6 +478,7 @@ describe('AssignSpoolModal — Spoolman enabled (T-Gap 7)', () => {
expect(screen.getByText(/Bambu Lab/)).toBeInTheDocument();
});
expect(api.getSpoolmanInventorySpools).toHaveBeenCalledWith(false);
expect(screen.getByText('#200')).toBeInTheDocument();
});
it('does not fetch Spoolman spools when spoolmanEnabled=false', async () => {
@@ -4,7 +4,7 @@
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { screen, waitFor } from '@testing-library/react';
import { fireEvent, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { render } from '../utils';
@@ -12,9 +12,11 @@ vi.mock('../../api/client', () => ({
api: {
getPrinterStatus: vi.fn(),
getPrinter: vi.fn(),
getPrinters: vi.fn(),
getSettings: vi.fn().mockResolvedValue({}),
assignSpool: vi.fn(),
assignSpoolmanSlot: vi.fn(),
updateSpool: vi.fn(),
getAuthStatus: vi.fn().mockResolvedValue({ auth_enabled: false }),
getAssignments: vi.fn().mockResolvedValue([]),
getSpoolmanSlotAssignments: vi.fn().mockResolvedValue([]),
@@ -23,6 +25,7 @@ vi.mock('../../api/client', () => ({
import { AssignToAmsModal } from '../../components/spoolbuddy/AssignToAmsModal';
import { api } from '../../api/client';
import { __resetColorCatalogForTests, setColorCatalog } from '../../utils/colors';
const SPOOL = {
id: 42,
@@ -88,10 +91,15 @@ const PRINTER_STATUS_ONLINE = {
describe('AssignToAmsModal', () => {
beforeEach(() => {
vi.clearAllMocks();
__resetColorCatalogForTests();
vi.mocked(api.getPrinterStatus).mockResolvedValue(PRINTER_STATUS_ONLINE as never);
vi.mocked(api.getPrinter).mockResolvedValue({ id: 1, name: 'Test Printer' } as never);
vi.mocked(api.getPrinters).mockResolvedValue([
{ id: 7, name: 'Farm Printer 7', is_active: true },
] as never);
vi.mocked(api.assignSpool).mockResolvedValue({} as never);
vi.mocked(api.assignSpoolmanSlot).mockResolvedValue({} as never);
vi.mocked(api.updateSpool).mockResolvedValue({} as never);
});
it('renders modal when open', async () => {
@@ -110,6 +118,45 @@ describe('AssignToAmsModal', () => {
});
});
it('moves focus into the dialog and traps Tab navigation', async () => {
render(
<>
<button data-testid="outside-control">Outside</button>
<AssignToAmsModal
isOpen={true}
onClose={vi.fn()}
spool={SPOOL as never}
printerId={1}
variant="dialog"
spoolmanMode={false}
/>
</>
);
const dialog = await screen.findByRole('dialog');
await waitFor(() => expect(dialog).toContainElement(document.activeElement as HTMLElement));
const focusable = Array.from(dialog.querySelectorAll<HTMLElement>(
'button:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])',
));
const first = focusable[0];
const last = focusable[focusable.length - 1];
expect(first).toBeTruthy();
expect(last).toBeTruthy();
last.focus();
fireEvent.keyDown(document, { key: 'Tab' });
expect(document.activeElement).toBe(first);
first.focus();
fireEvent.keyDown(document, { key: 'Tab', shiftKey: true });
expect(document.activeElement).toBe(last);
screen.getByTestId('outside-control').focus();
fireEvent.keyDown(document, { key: 'Tab' });
expect(document.activeElement).toBe(first);
});
it('renders nothing when closed', () => {
render(
<AssignToAmsModal
@@ -124,6 +171,161 @@ describe('AssignToAmsModal', () => {
expect(screen.queryByText(/Assign.*to AMS/i)).not.toBeInTheDocument();
});
it('selects a printer and requires explicit slot confirmation in the inventory flow (#2978)', async () => {
const user = userEvent.setup();
render(
<AssignToAmsModal
isOpen={true}
onClose={vi.fn()}
spool={SPOOL as never}
printerId={null}
spoolmanMode={false}
/>
);
const printerSelect = await screen.findByLabelText(/select printer/i);
await screen.findByRole('option', { name: 'Farm Printer 7' });
await user.selectOptions(printerSelect, '7');
await waitFor(() => {
expect(screen.queryAllByTestId('ams-slot').length).toBeGreaterThan(0);
});
const slotButtons = screen.queryAllByTestId('ams-slot');
expect(slotButtons[0]).toHaveAccessibleName('AMS A Slot 1');
await user.click(slotButtons[0]);
expect(api.assignSpool).not.toHaveBeenCalled();
await user.click(screen.getByRole('button', { name: /assign spool/i }));
await waitFor(() => {
expect(api.assignSpool).toHaveBeenCalledWith(
expect.objectContaining({ spool_id: 42, printer_id: 7, ams_id: 0, tray_id: 0 })
);
});
});
it.each([
{
caseName: 'missing colour name resolved from the catalog',
spool: { color_name: null, rgba: '00AE42FF' },
catalog: { '00ae42': 'Bambu Green' },
},
{
caseName: 'stored Bambu colour code',
spool: { color_name: 'A1-B2', rgba: 'FF00AAFF' },
catalog: {},
},
])('does not rewrite $caseName when assigning without a colour edit', async ({ spool, catalog }) => {
const user = userEvent.setup();
setColorCatalog(catalog);
render(
<AssignToAmsModal
isOpen={true}
onClose={vi.fn()}
spool={{ ...SPOOL, ...spool } as never}
printerId={null}
spoolmanMode={false}
/>
);
const printerSelect = await screen.findByLabelText(/select printer/i);
await screen.findByRole('option', { name: 'Farm Printer 7' });
await user.selectOptions(printerSelect, '7');
await waitFor(() => {
expect(screen.queryAllByTestId('ams-slot').length).toBeGreaterThan(0);
});
await user.click(screen.queryAllByTestId('ams-slot')[0]);
await user.click(screen.getByRole('button', { name: /assign spool/i }));
await waitFor(() => expect(api.assignSpool).toHaveBeenCalled());
expect(api.updateSpool).not.toHaveBeenCalled();
});
it('keeps the kiosk layout full-screen while selecting a printer', async () => {
render(
<AssignToAmsModal
isOpen={true}
onClose={vi.fn()}
spool={SPOOL as never}
printerId={null}
variant="kiosk"
spoolmanMode={false}
/>
);
const dialog = await screen.findByRole('dialog');
expect(dialog).toHaveClass('w-full', 'h-full');
expect(dialog).not.toHaveClass('max-w-3xl', 'max-w-5xl');
expect(await screen.findByLabelText(/select printer/i)).toBeInTheDocument();
expect(screen.queryByLabelText(/color name/i)).not.toBeInTheDocument();
});
it('names the spool by Bambuddy\'s catalog-resolved colour in the header (#3090)', async () => {
setColorCatalog({ d02727: 'Candy Red' });
render(
<AssignToAmsModal
isOpen={true}
onClose={vi.fn()}
spool={{
...SPOOL,
color_name: 'Silk+',
color_name_is_synthesized: true,
rgba: 'D02727FF',
} as never}
printerId={null}
spoolmanMode={false}
/>
);
expect((await screen.findAllByText(/Candy Red/)).length).toBeGreaterThan(0);
expect(screen.queryByText(/Silk\+/)).toBeNull();
});
it('labels and assigns the left external slot correctly on dual-nozzle printers', async () => {
const user = userEvent.setup();
vi.mocked(api.getPrinter).mockResolvedValue({
id: 7,
name: 'Farm Printer 7',
nozzle_count: 2,
} as never);
vi.mocked(api.getPrinterStatus).mockResolvedValue({
...PRINTER_STATUS_ONLINE,
ams: [],
vt_tray: [
{ id: 254, ...BLANK_TRAY },
{ id: 255, ...BLANK_TRAY },
],
} as never);
render(
<AssignToAmsModal
isOpen={true}
onClose={vi.fn()}
spool={SPOOL as never}
printerId={null}
spoolmanMode={false}
/>
);
const printerSelect = await screen.findByLabelText(/select printer/i);
await screen.findByRole('option', { name: 'Farm Printer 7' });
await user.selectOptions(printerSelect, '7');
const leftExternalSlot = await screen.findByTitle('Ext-L');
await user.click(leftExternalSlot);
expect(screen.getAllByText('Ext-L').length).toBeGreaterThan(0);
await user.click(screen.getByRole('button', { name: /assign spool/i }));
await waitFor(() => {
expect(api.assignSpool).toHaveBeenCalledWith(
expect.objectContaining({ printer_id: 7, ams_id: 255, tray_id: 0 })
);
});
});
describe('API routing based on spoolmanMode', () => {
it('calls assignSpool when spoolmanMode is false', async () => {
const user = userEvent.setup();
@@ -138,11 +340,11 @@ describe('AssignToAmsModal', () => {
);
await waitFor(() => {
expect(screen.queryAllByTitle(/AMS Slot/i).length).toBeGreaterThan(0);
expect(screen.queryAllByTestId('ams-slot').length).toBeGreaterThan(0);
});
// Click first available slot button
const slotButtons = screen.queryAllByTitle(/AMS Slot/i);
const slotButtons = screen.queryAllByTestId('ams-slot');
await user.click(slotButtons[0]);
await waitFor(() => {
expect(api.assignSpool).toHaveBeenCalledWith(
@@ -165,10 +367,10 @@ describe('AssignToAmsModal', () => {
);
await waitFor(() => {
expect(screen.queryAllByTitle(/AMS Slot/i).length).toBeGreaterThan(0);
expect(screen.queryAllByTestId('ams-slot').length).toBeGreaterThan(0);
});
const slotButtons = screen.queryAllByTitle(/AMS Slot/i);
const slotButtons = screen.queryAllByTestId('ams-slot');
await user.click(slotButtons[0]);
await waitFor(() => {
expect(api.assignSpoolmanSlot).toHaveBeenCalledWith(
@@ -195,10 +397,10 @@ describe('AssignToAmsModal', () => {
);
await waitFor(() => {
expect(screen.queryAllByTitle(/AMS Slot/i).length).toBeGreaterThan(0);
expect(screen.queryAllByTestId('ams-slot').length).toBeGreaterThan(0);
});
const slotButtons = screen.queryAllByTitle(/AMS Slot/i);
const slotButtons = screen.queryAllByTestId('ams-slot');
await user.click(slotButtons[0]);
await waitFor(() => {
expect(api.assignSpoolmanSlot).toHaveBeenCalledWith(
@@ -968,7 +968,7 @@ describe('SpoolFormModal — SpoolmanFilamentPicker integration (T2)', () => {
});
});
describe('SpoolFormModal — Unassign button (#1336)', () => {
describe('SpoolFormModal — assignment actions (#1336, #2978)', () => {
const spoolmanSpool: InventorySpool = {
id: 42,
material: 'PLA',
@@ -1045,7 +1045,7 @@ describe('SpoolFormModal — Unassign button (#1336)', () => {
expect(api.unassignSpool).not.toHaveBeenCalled();
});
it('keeps Unassign disabled in Spoolman mode when no slot assignment exists', async () => {
it('offers direct assignment when no Spoolman slot assignment exists (#2978)', async () => {
vi.mocked(api.getSpoolmanSlotAssignments).mockResolvedValueOnce([]);
render(
@@ -1059,12 +1059,47 @@ describe('SpoolFormModal — Unassign button (#1336)', () => {
/>
);
const unassignBtn = await screen.findByRole('button', { name: /unassign/i });
// Wait one tick for the (empty) query result to settle so the disabled state is final.
await waitFor(() => {
expect(api.getSpoolmanSlotAssignments).toHaveBeenCalled();
});
expect(unassignBtn).toBeDisabled();
expect(screen.queryByRole('button', { name: /unassign/i })).not.toBeInTheDocument();
const assignBtn = screen.getByRole('button', { name: /assign spool/i });
expect(assignBtn).not.toBeDisabled();
fireEvent.click(assignBtn);
expect(await screen.findByRole('heading', { name: /assign to AMS/i })).toBeInTheDocument();
expect(screen.queryByLabelText(/color name/i)).not.toBeInTheDocument();
});
it('closes only the nested assignment dialog on the first Escape press', async () => {
const onClose = vi.fn();
vi.mocked(api.getSpoolmanSlotAssignments).mockResolvedValueOnce([]);
render(
<SpoolFormModal
isOpen={true}
onClose={onClose}
spool={spoolmanSpool}
mode="edit"
currencySymbol="$"
spoolmanMode={true}
/>
);
await waitFor(() => expect(api.getSpoolmanSlotAssignments).toHaveBeenCalled());
fireEvent.click(screen.getByRole('button', { name: /assign spool/i }));
expect(await screen.findByRole('heading', { name: /assign to AMS/i })).toBeInTheDocument();
fireEvent.keyDown(document, { key: 'Escape' });
await waitFor(() => {
expect(screen.queryByRole('heading', { name: /assign to AMS/i })).not.toBeInTheDocument();
});
expect(onClose).not.toHaveBeenCalled();
expect(screen.getByText('Edit Spool')).toBeInTheDocument();
fireEvent.keyDown(document, { key: 'Escape' });
expect(onClose).toHaveBeenCalledTimes(1);
});
});
@@ -10,10 +10,9 @@
* - P13-6b: Slot-assigned-only Spoolman spool produces a fill bar
* - P13-6c: SlotActionPicker hides Link button when slot has SpoolmanSlotAssignment
*
* SpoolSlot tiles render as <div onClick=... title="AMS Slot N">; tests target
* them via getByTitle which is a stable, semantic selector. Buttons inside the
* SlotActionPicker are addressed by their visible text (translated via the
* mocked react-i18next; t-fallback returns the second arg).
* SpoolSlot tiles are selected through a language-independent test id. Buttons
* inside the SlotActionPicker are addressed by their visible text (translated
* via the mocked react-i18next; t-fallback returns the second arg).
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
@@ -187,6 +186,11 @@ function setupDefaultApiResponses() {
};
}
async function findAmsSlot(slotNumber: number): Promise<HTMLElement> {
const slots = await screen.findAllByTestId('ams-slot');
return slots[slotNumber - 1];
}
describe('SpoolBuddyAmsPage Phase 13', () => {
beforeEach(() => {
assignSpoolModalCalls.length = 0;
@@ -206,7 +210,7 @@ describe('SpoolBuddyAmsPage Phase 13', () => {
// gate doesn't suppress the Assign-Spool action. Slot 1 carries
// blDefault with a non-zero tray_uuid in buildPrinterStatus, which
// is correctly recognized as BL-RFID and offers Configure only.
const slot2 = await screen.findByTitle('AMS Slot 2');
const slot2 = await findAmsSlot(2);
fireEvent.click(slot2);
// SlotActionPicker opens; click the Assign-Spool action ("Track a spool from your inventory")
@@ -235,7 +239,7 @@ describe('SpoolBuddyAmsPage Phase 13', () => {
// to the modal showing both inventories. With the fix, in spoolman
// mode the prop is true (we'd see no local-list rendered if any
// assign-modal opened — which it can't from this picker in this mode).
const slot1 = await screen.findByTitle('AMS Slot 1');
const slot1 = await findAmsSlot(1);
fireEvent.click(slot1);
// The picker opens with the Configure button (always visible)
await screen.findByText('Set filament preset, K-profile, and color');
@@ -268,7 +272,7 @@ describe('SpoolBuddyAmsPage Phase 13', () => {
const qc = lastQueryClient!;
const invalidateSpy = vi.spyOn(qc, 'invalidateQueries');
const slot1 = await screen.findByTitle('AMS Slot 1');
const slot1 = await findAmsSlot(1);
fireEvent.click(slot1);
const unlinkBtn = await screen.findByText('Remove Spoolman link from this slot');
@@ -297,7 +301,7 @@ describe('SpoolBuddyAmsPage Phase 13', () => {
renderPage();
// Wait for the page to settle and queries to fire
await screen.findByTitle('AMS Slot 1');
await findAmsSlot(1);
await waitFor(() => {
expect(apiCallCounts.getSpoolmanSlotAssignments ?? 0).toBeGreaterThan(0);
expect(apiCallCounts.getSpoolmanInventorySpools ?? 0).toBeGreaterThan(0);
@@ -308,7 +312,7 @@ describe('SpoolBuddyAmsPage Phase 13', () => {
spoolmanStatusValue = { enabled: false, connected: false };
renderPage();
await screen.findByTitle('AMS Slot 1');
await findAmsSlot(1);
// Wait an extra tick for any pending queries that might fire
await new Promise(r => setTimeout(r, 100));
expect(apiCallCounts.getSpoolmanSlotAssignments ?? 0).toBe(0);
@@ -332,7 +336,7 @@ describe('SpoolBuddyAmsPage Phase 13', () => {
renderPage();
// Click slot 2 (tray_id=1, second slot) — has SpoolmanSlotAssignment but no tag link.
const slot2 = await screen.findByTitle('AMS Slot 2');
const slot2 = await findAmsSlot(2);
fireEvent.click(slot2);
// Configure button is always visible
@@ -351,7 +355,7 @@ describe('SpoolBuddyAmsPage Phase 13', () => {
renderPage();
// Click slot 2 (empty)
const slot2 = await screen.findByTitle('AMS Slot 2');
const slot2 = await findAmsSlot(2);
fireEvent.click(slot2);
// Link button SHOULD appear in this case
@@ -383,7 +387,7 @@ describe('SpoolBuddyAmsPage P13-1d — Empty slot Local-Assign in local mode', (
renderPage();
// Slot 2 (tray_id=1) is empty by default in buildPrinterStatus()
const emptySlot = await screen.findByTitle('AMS Slot 2');
const emptySlot = await findAmsSlot(2);
fireEvent.click(emptySlot);
// The Assign-Spool action must be visible in the picker
@@ -421,7 +425,7 @@ describe('SpoolBuddyAmsPage Phase 14 — SlotActionPicker BL-detection in local
// Slot 0 is BL-RFID by default (buildPrinterStatus blDefault has 32-hex tray_uuid)
renderPage();
const blSlot = await screen.findByTitle('AMS Slot 1');
const blSlot = await findAmsSlot(1);
fireEvent.click(blSlot);
// Configure must remain — it's a legitimate operation on BL-RFID slots.
@@ -439,7 +443,7 @@ describe('SpoolBuddyAmsPage Phase 14 — SlotActionPicker BL-detection in local
// Slot 2 (tray_id=1) is empty (tray_type=''), which means the SlotActionPicker
// sees tray=null per handleAmsSlotClick. isBambuLabSpool(null) returns false,
// so the Assign action must still appear.
const emptySlot = await screen.findByTitle('AMS Slot 2');
const emptySlot = await findAmsSlot(2);
fireEvent.click(emptySlot);
await screen.findByText('Track a spool from your inventory');
@@ -80,6 +80,24 @@ describe('spoolMatchesQuery', () => {
expect(spoolMatchesQuery(spool, '4')).toBe(true);
expect(spoolMatchesQuery(spool, '99')).toBe(false);
});
it('treats a hash-prefixed number as an exact spool ID (#2978)', () => {
expect(spoolMatchesQuery(makeSpool({ id: 3 }), '#3')).toBe(true);
expect(spoolMatchesQuery(makeSpool({ id: 13 }), '#3')).toBe(false);
expect(spoolMatchesQuery(makeSpool({ id: 30 }), '#3')).toBe(false);
expect(spoolMatchesQuery(makeSpool({ id: 3 }), ' #3 ')).toBe(true);
});
it('does not treat #ID text in other fields as an exact ID match (#2978)', () => {
expect(spoolMatchesQuery(makeSpool({ id: 13, color_name: 'Batch #3' }), '#3')).toBe(false);
expect(spoolMatchesQuery(makeSpool({ id: 13, note: 'Use after spool #3' }), '#3')).toBe(false);
});
it('keeps plain numeric ID searches as partial matches (#2978)', () => {
expect(spoolMatchesQuery(makeSpool({ id: 3 }), '3')).toBe(true);
expect(spoolMatchesQuery(makeSpool({ id: 13 }), '3')).toBe(true);
expect(spoolMatchesQuery(makeSpool({ id: 30 }), '3')).toBe(true);
});
});
describe('filterSpoolsByQuery', () => {
@@ -114,4 +132,9 @@ describe('filterSpoolsByQuery', () => {
it('returns empty array when no match', () => {
expect(filterSpoolsByQuery(spools, 'nylon')).toHaveLength(0);
});
it('returns only the exact spool for a #ID query (#2978)', () => {
const result = filterSpoolsByQuery(spools, '#3');
expect(result.map((spool) => spool.id)).toEqual([3]);
});
});
+12 -6
View File
@@ -421,9 +421,12 @@ export function AssignSpoolModal({ isOpen, onClose, printerId, amsId, trayId, tr
: 'bg-bambu-dark border-bambu-dark-tertiary hover:border-bambu-gray'
}`}
>
<p className="text-white text-sm font-medium truncate">
{spool.brand ? `${spool.brand} ` : ''}{spool.material}{spool.subtype ? ` ${spool.subtype}` : ''}
</p>
<div className="flex items-start justify-between gap-2">
<p className="text-white text-sm font-medium truncate">
{spool.brand ? `${spool.brand} ` : ''}{spool.material}{spool.subtype ? ` ${spool.subtype}` : ''}
</p>
<span className="text-[10px] font-mono text-bambu-gray shrink-0">#{spool.id}</span>
</div>
<div className="flex items-center gap-1.5 mt-1">
{spool.rgba && (
<span
@@ -503,9 +506,12 @@ export function AssignSpoolModal({ isOpen, onClose, printerId, amsId, trayId, tr
: 'bg-bambu-dark border-bambu-dark-tertiary hover:border-bambu-gray'
}`}
>
<p className="text-white text-sm font-medium truncate">
{spool.brand ? `${spool.brand} ` : ''}{spool.material}{spool.subtype ? ` ${spool.subtype}` : ''}
</p>
<div className="flex items-start justify-between gap-2">
<p className="text-white text-sm font-medium truncate">
{spool.brand ? `${spool.brand} ` : ''}{spool.material}{spool.subtype ? ` ${spool.subtype}` : ''}
</p>
<span className="text-[10px] font-mono text-bambu-gray shrink-0">#{spool.id}</span>
</div>
<div className="flex items-center gap-1.5 mt-1">
{spool.rgba && (
<span
+46 -13
View File
@@ -1,7 +1,7 @@
import { useState, useEffect, useMemo } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { X, Loader2, Save, Beaker, Palette, Zap, Tag, Unlink } from 'lucide-react';
import { X, Loader2, Save, Beaker, Palette, Zap, Tag, Link2, Unlink } from 'lucide-react';
import { api, ApiError } from '../api/client';
import type { InventorySpool, SlicerSetting, SpoolCatalogEntry, LocalPreset, BuiltinFilament, SpoolmanBulkCreateResult, SpoolFilamentPresetInput, SpoolKProfileInput, SpoolmanFilamentEntry } from '../api/client';
import { Button } from './Button';
@@ -25,6 +25,7 @@ import { SpoolmanFilamentPicker } from './spool-form/SpoolmanFilamentPicker';
import { PrinterProfilesSection } from './spool-form/PrinterProfilesSection';
import { normaliseFlow } from '../utils/nozzleFlow';
import { SpoolUsageHistory } from './SpoolUsageHistory';
import { AssignToAmsModal } from './spoolbuddy/AssignToAmsModal';
import {
invalidateInventoryLocations,
invalidateSpoolAndLocationQueries,
@@ -91,6 +92,11 @@ export function SpoolFormModal({
const [supplierLinksTouched, setSupplierLinksTouched] = useState(false);
const [quickAdd, setQuickAdd] = useState(false);
const [quantity, setQuantity] = useState(1);
const [showAssignModal, setShowAssignModal] = useState(false);
useEffect(() => {
setShowAssignModal(false);
}, [isOpen, spool?.id]);
// Cloud presets
const [cloudAuthenticated, setCloudAuthenticated] = useState(false);
@@ -729,12 +735,12 @@ export function SpoolFormModal({
// the slot assignment lives in the spoolman_slot_assignments table keyed by
// spoolman_spool_id, not in the legacy spool_assignments table — #1336 was the
// resulting "Unassign button is always disabled" report.
const { data: assignments } = useQuery({
const { data: assignments, isLoading: assignmentsLoading } = useQuery({
queryKey: ['spool-assignments'],
queryFn: () => api.getAssignments(),
enabled: isOpen && isEditing && !spoolmanMode,
});
const { data: spoolmanSlotAssignments } = useQuery({
const { data: spoolmanSlotAssignments, isLoading: spoolmanAssignmentsLoading } = useQuery({
queryKey: ['spoolman-slot-assignments-all'],
queryFn: () => api.getSpoolmanSlotAssignments(),
enabled: isOpen && isEditing && spoolmanMode,
@@ -746,6 +752,7 @@ export function SpoolFormModal({
}
return assignments?.find(a => a.spool_id === spool.id);
})();
const assignmentLoading = spoolmanMode ? spoolmanAssignmentsLoading : assignmentsLoading;
// Read inventory + settings caches (already populated by InventoryPage) to
// drive the category autocomplete and low-stock-threshold placeholder. #729
@@ -910,11 +917,14 @@ export function SpoolFormModal({
useEffect(() => {
if (!isOpen) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
// The nested AMS assignment dialog owns Escape while it is open. Both
// listeners live on document, so propagation control in the child would
// not prevent this editor handler from running for the same key press.
if (e.key === 'Escape' && !showAssignModal) onClose();
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [isOpen, onClose]);
}, [isOpen, onClose, showAssignModal]);
if (!isOpen) return null;
@@ -985,6 +995,7 @@ export function SpoolFormModal({
const isPending = createMutation.isPending || bulkCreateMutation.isPending || updateMutation.isPending || deleteTagMutation.isPending || unassignMutation.isPending;
return (
<>
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
@@ -1242,14 +1253,25 @@ export function SpoolFormModal({
<Tag className="w-4 h-4" />
{t('inventory.clearRfid', 'Clear RFID Tag')}
</Button>
<Button
variant="secondary"
onClick={() => unassignMutation.mutate()}
disabled={isPending || !spoolAssignment}
>
<Unlink className="w-4 h-4" />
{t('inventory.unassignSpool', 'Unassign')}
</Button>
{spoolAssignment ? (
<Button
variant="secondary"
onClick={() => unassignMutation.mutate()}
disabled={isPending || assignmentLoading}
>
<Unlink className="w-4 h-4" />
{t('inventory.unassignSpool', 'Unassign')}
</Button>
) : (
<Button
variant="secondary"
onClick={() => setShowAssignModal(true)}
disabled={isPending || assignmentLoading}
>
<Link2 className="w-4 h-4" />
{t('inventory.assignSpool', 'Assign Spool')}
</Button>
)}
</div>
)}
<div className="flex gap-2 ml-auto">
@@ -1276,5 +1298,16 @@ export function SpoolFormModal({
</div>
</div>
</div>
{isEditing && spool && (
<AssignToAmsModal
isOpen={showAssignModal}
onClose={() => setShowAssignModal(false)}
spool={spool}
printerId={null}
variant="dialog"
spoolmanMode={spoolmanMode}
/>
)}
</>
);
}
@@ -1,3 +1,4 @@
import { useTranslation } from 'react-i18next';
import type { AMSUnit, AMSTray } from '../../api/client';
import { getFillBarColor } from '../../utils/amsHelpers';
@@ -155,10 +156,11 @@ interface SpoolSlotProps {
isActive: boolean;
fillOverride?: number | null;
spoolmanFill?: number | null;
accessibleLabel?: string;
onClick?: () => void;
}
function SpoolSlot({ tray, slotIndex, isActive, fillOverride, spoolmanFill, onClick }: SpoolSlotProps) {
function SpoolSlot({ tray, slotIndex, isActive, fillOverride, spoolmanFill, accessibleLabel, onClick }: SpoolSlotProps) {
const isEmpty = isTrayEmpty(tray);
const emptyKind = getEmptySlotKind(tray);
const color = trayColorToCSS(tray.tray_color);
@@ -170,9 +172,24 @@ function SpoolSlot({ tray, slotIndex, isActive, fillOverride, spoolmanFill, onCl
return (
<div
className={`relative flex flex-col items-center p-2.5 rounded-lg transition-all ${isActive ? 'ring-2 ring-bambu-green' : ''} ${onClick ? 'cursor-pointer hover:bg-white/5' : ''}`}
className={`relative flex flex-col items-center p-2.5 rounded-lg border transition-all ${
isActive
? 'ring-2 ring-bambu-green border-bambu-green bg-bambu-green/10'
: 'border-transparent'
} ${onClick ? 'cursor-pointer hover:border-bambu-gray hover:bg-bambu-dark-tertiary' : ''}`}
onClick={onClick}
title={onClick ? `AMS Slot ${slotIndex + 1}` : undefined}
onKeyDown={onClick ? (event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
onClick();
}
} : undefined}
role={onClick ? 'button' : undefined}
tabIndex={onClick ? 0 : undefined}
aria-pressed={onClick ? isActive : undefined}
aria-label={onClick ? accessibleLabel : undefined}
title={onClick ? accessibleLabel : undefined}
data-testid={onClick ? 'ams-slot' : undefined}
>
{/* Spool visualization */}
<div className="relative w-16 h-16 mb-1">
@@ -196,7 +213,7 @@ function SpoolSlot({ tray, slotIndex, isActive, fillOverride, spoolmanFill, onCl
{/* Material type */}
<span
className="text-sm text-white/70 truncate max-w-full"
className="text-sm text-bambu-gray-light truncate max-w-full"
title={emptyKind === 'reset' ? 'Spool loaded — slot not configured' : undefined}
>
{isEmpty ? (emptyKind === 'reset' ? '?' : 'Empty') : tray.tray_type || 'Unknown'}
@@ -216,7 +233,9 @@ function SpoolSlot({ tray, slotIndex, isActive, fillOverride, spoolmanFill, onCl
)}
{/* Slot number */}
<span className="absolute top-1 right-1 text-xs text-white/30">{slotIndex + 1}</span>
<span className="absolute top-1 right-1 rounded bg-bambu-dark-tertiary px-1.5 py-0.5 text-[10px] font-semibold text-bambu-gray-light">
{slotIndex + 1}
</span>
</div>
);
}
@@ -240,16 +259,18 @@ interface AmsUnitCardProps {
}
export function AmsUnitCard({ unit, activeSlot, onConfigureSlot, isDualNozzle, nozzleSide, thresholds, fillOverrides, spoolmanFillOverrides }: AmsUnitCardProps) {
const { t } = useTranslation();
const trays = unit.tray || [];
const isHt = unit.is_ams_ht;
const slotCount = isHt ? 1 : 4;
const amsName = getAmsName(unit.id);
return (
<div className="bg-bambu-dark-secondary rounded-lg p-3">
<div className="bg-bambu-dark-secondary rounded-xl border border-[var(--border-color)] p-3 shadow-sm">
{/* Header */}
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-1.5">
<span className="text-white font-medium text-base">{getAmsName(unit.id)}</span>
<span className="text-white font-medium text-base">{amsName}</span>
{isDualNozzle && nozzleSide && (
<NozzleBadge side={nozzleSide} />
)}
@@ -298,6 +319,7 @@ export function AmsUnitCard({ unit, activeSlot, onConfigureSlot, isDualNozzle, n
isActive={activeSlot === i}
fillOverride={fillOverrides?.[`${unit.id}-${i}`] ?? null}
spoolmanFill={spoolmanFillOverrides?.[`${unit.id}-${i}`] ?? null}
accessibleLabel={t('configureAmsSlot.slotLabel', { ams: amsName, slot: i + 1 })}
onClick={onConfigureSlot ? () => onConfigureSlot(unit.id, i, isTrayEmpty(tray) ? null : tray) : undefined}
/>
);
@@ -1,7 +1,7 @@
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { X, Loader2, CheckCircle, XCircle, Layers } from 'lucide-react';
import { X, Loader2, Check, CheckCircle, XCircle, Layers } from 'lucide-react';
import { api, type InventorySpool, type PrinterStatus, type AMSTray } from '../../api/client';
import { ConfirmModal } from '../ConfirmModal';
import { AmsUnitCard, NozzleBadge } from './AmsUnitCard';
@@ -25,6 +25,20 @@ function trayColorToCSS(color: string | null): string {
return `#${color.slice(0, 6)}`;
}
const FOCUSABLE_SELECTOR = [
'a[href]',
'button:not([disabled])',
'input:not([disabled]):not([type="hidden"])',
'select:not([disabled])',
'textarea:not([disabled])',
'[tabindex]:not([tabindex="-1"])',
].join(',');
function getFocusableElements(container: HTMLElement): HTMLElement[] {
return Array.from(container.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR))
.filter((element) => !element.hasAttribute('hidden') && element.getAttribute('aria-hidden') !== 'true');
}
// --- Material/profile mismatch helpers (pure functions, no component state) ---
const normalizeValue = (value: string | undefined | null) =>
(value ?? '').trim().toUpperCase();
@@ -58,12 +72,23 @@ interface AssignToAmsModalProps {
onClose: () => void;
spool: InventorySpool;
printerId: number | null;
variant?: 'dialog' | 'kiosk';
spoolmanMode?: boolean;
}
export function AssignToAmsModal({ isOpen, onClose, spool, printerId, spoolmanMode = false }: AssignToAmsModalProps) {
export function AssignToAmsModal({
isOpen,
onClose,
spool,
printerId,
variant = 'dialog',
spoolmanMode = false,
}: AssignToAmsModalProps) {
const { t } = useTranslation();
const queryClient = useQueryClient();
const dialogRef = useRef<HTMLDivElement>(null);
const [selectedPrinterId, setSelectedPrinterId] = useState<number | null>(printerId);
const [selectedSlot, setSelectedSlot] = useState<{ amsId: number; trayId: number } | null>(null);
const [statusMessage, setStatusMessage] = useState<string | null>(null);
const [statusType, setStatusType] = useState<'info' | 'success' | 'error' | null>(null);
const [showMismatchConfirm, setShowMismatchConfirm] = useState(false);
@@ -84,34 +109,100 @@ export function AssignToAmsModal({ isOpen, onClose, spool, printerId, spoolmanMo
useEffect(() => {
if (isOpen) {
setSelectedPrinterId(printerId);
setSelectedSlot(null);
setStatusMessage(null);
setStatusType(null);
setShowMismatchConfirm(false);
setMismatchDetails(null);
setPendingSlot(null);
}
}, [isOpen]);
}, [isOpen, printerId, spool.id]);
const handleKeyDown = useCallback((e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
}, [onClose]);
// Printer selection and visual presentation are separate concerns: the
// SpoolBuddy kiosk must stay full-screen even when no printer is online and
// the picker is shown.
const showPrinterPicker = printerId === null;
const requiresSlotConfirmation = showPrinterPicker;
const targetPrinterId = printerId ?? selectedPrinterId;
const { data: selectablePrinters = [], isLoading: printersLoading } = useQuery({
queryKey: ['printers'],
queryFn: () => api.getPrinters(),
enabled: isOpen && showPrinterPicker,
});
const activePrinters = useMemo(
() => selectablePrinters.filter((candidate) => candidate.is_active !== false),
[selectablePrinters],
);
useEffect(() => {
if (isOpen) document.addEventListener('keydown', handleKeyDown);
if (!isOpen) return;
const previousOverflow = document.body.style.overflow;
const previouslyFocused = document.activeElement instanceof HTMLElement
? document.activeElement
: null;
document.body.style.overflow = 'hidden';
const dialog = dialogRef.current;
const firstFocusable = dialog ? getFocusableElements(dialog)[0] : null;
(firstFocusable ?? dialog)?.focus();
return () => {
document.body.style.overflow = previousOverflow;
if (previouslyFocused?.isConnected) previouslyFocused.focus();
};
}, [isOpen]);
const handleKeyDown = useCallback((event: KeyboardEvent) => {
// ConfirmModal is the active modal while a material warning is visible.
// Let it own Escape and keyboard focus until it closes.
if (showMismatchConfirm) return;
if (event.key === 'Escape') {
event.preventDefault();
onClose();
return;
}
if (event.key !== 'Tab') return;
const dialog = dialogRef.current;
if (!dialog) return;
const focusable = getFocusableElements(dialog);
if (focusable.length === 0) {
event.preventDefault();
dialog.focus();
return;
}
const first = focusable[0];
const last = focusable[focusable.length - 1];
const active = document.activeElement;
if (event.shiftKey && (active === first || !dialog.contains(active))) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && (active === last || !dialog.contains(active))) {
event.preventDefault();
first.focus();
}
}, [onClose, showMismatchConfirm]);
useEffect(() => {
if (!isOpen) return;
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [isOpen, handleKeyDown]);
const { data: status } = useQuery<PrinterStatus>({
queryKey: ['printerStatus', printerId],
queryFn: () => api.getPrinterStatus(printerId!),
enabled: isOpen && printerId !== null,
const { data: status, isLoading: statusLoading } = useQuery<PrinterStatus>({
queryKey: ['printerStatus', targetPrinterId],
queryFn: () => api.getPrinterStatus(targetPrinterId!),
enabled: isOpen && targetPrinterId !== null,
refetchInterval: 5000,
});
const { data: printer } = useQuery({
queryKey: ['printer', printerId],
queryFn: () => api.getPrinter(printerId!),
enabled: isOpen && printerId !== null,
queryKey: ['printer', targetPrinterId],
queryFn: () => api.getPrinter(targetPrinterId!),
enabled: isOpen && targetPrinterId !== null,
});
const { data: settings } = useQuery({
@@ -122,16 +213,16 @@ export function AssignToAmsModal({ isOpen, onClose, spool, printerId, spoolmanMo
});
const { data: assignments } = useQuery({
queryKey: ['spool-assignments', printerId],
queryFn: () => api.getAssignments(printerId!),
enabled: isOpen && printerId !== null,
queryKey: ['spool-assignments', targetPrinterId],
queryFn: () => api.getAssignments(targetPrinterId!),
enabled: isOpen && targetPrinterId !== null,
staleTime: 30 * 1000,
});
const { data: spoolmanAssignments = [] } = useQuery({
queryKey: ['spoolman-slot-assignments', printerId],
queryFn: () => api.getSpoolmanSlotAssignments(printerId ?? undefined),
enabled: isOpen && !!spoolmanMode && printerId !== null,
queryKey: ['spoolman-slot-assignments', targetPrinterId],
queryFn: () => api.getSpoolmanSlotAssignments(targetPrinterId ?? undefined),
enabled: isOpen && !!spoolmanMode && targetPrinterId !== null,
staleTime: 30 * 1000,
});
@@ -168,6 +259,9 @@ export function AssignToAmsModal({ isOpen, onClose, spool, printerId, spoolmanMo
const isDualNozzle = printer?.nozzle_count === 2 || status?.temperatures?.nozzle_2 !== undefined;
const cachedAmsExtruderMap = useRef<Record<string, number>>({});
useEffect(() => {
cachedAmsExtruderMap.current = {};
}, [targetPrinterId]);
useEffect(() => {
if (status?.ams_extruder_map && Object.keys(status.ams_extruder_map).length > 0) {
cachedAmsExtruderMap.current = status.ams_extruder_map;
@@ -201,19 +295,19 @@ export function AssignToAmsModal({ isOpen, onClose, spool, printerId, spoolmanMo
// from the immediate-apply path so we can adjust the success toast.
const configureMutation = useMutation({
mutationFn: async ({ amsId, trayId }: { amsId: number; trayId: number }) => {
if (!printerId) throw new Error('No printer selected');
if (!targetPrinterId) throw new Error('No printer selected');
if (spoolmanMode) {
return await api.assignSpoolmanSlot({
spoolman_spool_id: spool.id,
printer_id: printerId,
printer_id: targetPrinterId,
ams_id: amsId,
tray_id: trayId,
});
}
return await api.assignSpool({
spool_id: spool.id,
printer_id: printerId,
printer_id: targetPrinterId,
ams_id: amsId,
tray_id: trayId,
});
@@ -234,8 +328,11 @@ export function AssignToAmsModal({ isOpen, onClose, spool, printerId, spoolmanMo
setStatusMessage(t('spoolbuddy.modal.assignSuccess', 'Assigned!'));
}
queryClient.invalidateQueries({ queryKey: ['slotPresets'] });
queryClient.invalidateQueries({ queryKey: ['spool-assignments'] });
queryClient.invalidateQueries({ queryKey: ['spoolman-slot-assignments'] });
queryClient.invalidateQueries({ queryKey: ['spoolman-slot-assignments-all'] });
queryClient.invalidateQueries({ queryKey: ['inventory-spools'] });
queryClient.invalidateQueries({ queryKey: ['spoolman-inventory-spools'] });
setTimeout(() => onClose(), pendingConfig ? 2500 : 1500);
},
onError: (err) => {
@@ -258,8 +355,11 @@ export function AssignToAmsModal({ isOpen, onClose, spool, printerId, spoolmanMo
const getSlotLocationLabel = useCallback((amsId: number, trayId: number): string => {
if (amsId <= 3) return `${getAmsName(amsId)} ${t('ams.slot', 'Slot')} ${trayId + 1}`;
if (amsId >= 128 && amsId <= 135) return getAmsName(amsId);
if (amsId === 254) return t('printers.extL', 'Ext-L');
return isDualNozzle ? t('printers.extR', 'Ext-R') : t('printers.ext', 'Ext');
if (!isDualNozzle) return t('printers.ext', 'Ext');
// Current assignment records use AMS 255 with tray 0/1 for Ext-L/Ext-R;
// AMS 254 is retained for compatibility with older assignment records.
if (amsId === 254 || (amsId === 255 && trayId === 0)) return t('printers.extL', 'Ext-L');
return t('printers.extR', 'Ext-R');
}, [t, isDualNozzle]);
const doAssign = useCallback((amsId: number, trayId: number) => {
@@ -268,9 +368,8 @@ export function AssignToAmsModal({ isOpen, onClose, spool, printerId, spoolmanMo
configureMutation.mutate({ amsId, trayId });
}, [configureMutation, t]);
const handleSlotClick = useCallback((amsId: number, trayId: number) => {
const prepareAssignment = useCallback((amsId: number, trayId: number) => {
if (isWaiting) return;
if (!settings?.disable_filament_warnings) {
const tray = getTrayForSlot(amsId, trayId);
if (tray && !isTrayEmpty(tray)) {
@@ -311,6 +410,24 @@ export function AssignToAmsModal({ isOpen, onClose, spool, printerId, spoolmanMo
doAssign(amsId, trayId);
}, [isWaiting, settings?.disable_filament_warnings, spool, getTrayForSlot, getSlotLocationLabel, doAssign]);
const handleSlotClick = useCallback((amsId: number, trayId: number) => {
if (isWaiting) return;
if (requiresSlotConfirmation) {
setSelectedSlot({ amsId, trayId });
setStatusMessage(null);
setStatusType(null);
return;
}
prepareAssignment(amsId, trayId);
}, [requiresSlotConfirmation, isWaiting, prepareAssignment]);
const handleAssignSelectedSlot = useCallback(() => {
if (!selectedSlot) return;
prepareAssignment(selectedSlot.amsId, selectedSlot.trayId);
}, [prepareAssignment, selectedSlot]);
const handleConfirmMismatch = useCallback(() => {
if (!pendingSlot) return;
setShowMismatchConfirm(false);
@@ -368,160 +485,270 @@ export function AssignToAmsModal({ isOpen, onClose, spool, printerId, spoolmanMo
if (!isOpen) return null;
const colorStyle = spoolSwatchStyle(spool) ?? getSwatchStyle(spool.rgba);
const selectedSlotLabel = selectedSlot
? getSlotLocationLabel(selectedSlot.amsId, selectedSlot.trayId)
: null;
const isDialogVariant = variant === 'dialog';
const overlayClasses = isDialogVariant
? 'fixed inset-0 z-[60] flex items-center justify-center bg-black/45 dark:bg-black/70 p-2 sm:p-4 backdrop-blur-sm animate-fade-in'
: 'fixed inset-0 z-[60] bg-bambu-dark';
const dialogClasses = isDialogVariant
? 'bg-bambu-dark-secondary text-white font-sans w-full max-w-3xl h-[calc(100vh-1rem)] sm:h-[calc(100vh-2rem)] max-h-[820px] border border-[var(--border-color)] rounded-xl sm:rounded-2xl shadow-2xl flex flex-col overflow-hidden animate-slide-up'
: 'w-full h-full bg-bambu-dark flex flex-col';
return (
<>
<div className="fixed inset-0 z-[60] bg-bambu-dark flex flex-col">
<div
className={overlayClasses}
onClick={isDialogVariant && !isWaiting ? onClose : undefined}
>
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-labelledby="assign-to-ams-title"
tabIndex={-1}
className={dialogClasses}
onClick={(event) => event.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between px-5 py-3 border-b border-zinc-800 shrink-0">
<div className="flex items-center justify-between bg-bambu-dark-secondary px-5 py-3 border-b border-[var(--border-color)] shrink-0">
<div className="flex items-center gap-3 min-w-0">
<div className="w-7 h-7 rounded-full shrink-0" style={colorStyle} />
<div className="min-w-0">
<h2 className="text-sm font-semibold text-zinc-100 truncate">
<h2 id="assign-to-ams-title" className="text-sm font-semibold text-white truncate">
{t('spoolbuddy.modal.assignToAmsTitle', 'Assign to AMS')}
<span className="font-normal text-zinc-500 ml-2">
<span className="font-normal text-bambu-gray-light ml-2">
{resolveSpoolColorName(spool.color_name, spool.rgba, spool.color_name_is_synthesized) ||
t('spoolbuddy.spool.unknownColor')}{' '}
&bull; {spool.brand} {spool.material}{spool.subtype && ` ${spool.subtype}`}
t('spoolbuddy.spool.unknownColor')} &bull; {spool.brand} {spool.material}
{spool.subtype && ` ${spool.subtype}`}
</span>
<span className="text-[10px] font-mono text-zinc-500 ml-2 shrink-0">#{spool.id}</span>
<span className="text-[10px] font-mono text-bambu-gray ml-2 shrink-0">#{spool.id}</span>
</h2>
</div>
</div>
<button
onClick={onClose}
disabled={isWaiting}
className="p-2 rounded-lg text-zinc-500 hover:text-zinc-300 hover:bg-zinc-800 transition-colors shrink-0 disabled:opacity-50"
aria-label={t('common.close', 'Close')}
className="p-2 rounded-lg text-bambu-gray hover:text-white hover:bg-bambu-dark-tertiary transition-colors shrink-0 disabled:opacity-50"
>
<X className="w-5 h-5" />
</button>
</div>
{showPrinterPicker && (
<div className="bg-bambu-dark-secondary px-5 py-3 border-b border-[var(--border-color)] shrink-0">
<label htmlFor="assign-spool-printer" className="flex items-center gap-2 text-sm font-medium text-white mb-2">
<span className="inline-flex h-5 w-5 items-center justify-center rounded-full bg-bambu-green text-[11px] font-bold text-[#ffffff]">1</span>
{t('printModal.selectPrinter')}
</label>
<select
id="assign-spool-printer"
aria-label={t('printModal.selectPrinter')}
value={selectedPrinterId ?? ''}
onChange={(event) => {
setSelectedPrinterId(event.target.value ? Number(event.target.value) : null);
setSelectedSlot(null);
setStatusMessage(null);
setStatusType(null);
}}
disabled={isWaiting || printersLoading}
className="w-full bg-bambu-dark border border-[var(--border-color)] rounded-lg px-3 py-2.5 text-sm text-white focus:outline-none focus:border-bambu-green disabled:opacity-50"
>
<option value="">
{printersLoading
? t('common.loading')
: activePrinters.length === 0
? t('common.noPrinters')
: t('printModal.selectPrinter')}
</option>
{activePrinters.map((candidate) => (
<option key={candidate.id} value={candidate.id}>
{candidate.name}
</option>
))}
</select>
</div>
)}
{/* Status message */}
{statusMessage && (
<div className={`mx-5 mt-3 p-3 rounded-lg flex items-center gap-3 border shrink-0 ${
statusType === 'info'
? 'bg-blue-500/10 border-blue-500/40'
? 'bg-blue-50 border-blue-300 dark:bg-blue-500/10 dark:border-blue-500/40'
: statusType === 'success'
? 'bg-green-500/10 border-green-500/40'
: 'bg-red-500/10 border-red-500/40'
? 'bg-green-50 border-green-300 dark:bg-green-500/10 dark:border-green-500/40'
: 'bg-red-50 border-red-300 dark:bg-red-500/10 dark:border-red-500/40'
}`}>
{statusType === 'info' && <Loader2 className="w-4 h-4 text-blue-400 animate-spin shrink-0" />}
{statusType === 'success' && <CheckCircle className="w-4 h-4 text-green-400 shrink-0" />}
{statusType === 'error' && <XCircle className="w-4 h-4 text-red-400 shrink-0" />}
{statusType === 'info' && <Loader2 className="w-4 h-4 text-blue-600 dark:text-blue-400 animate-spin shrink-0" />}
{statusType === 'success' && <CheckCircle className="w-4 h-4 text-green-600 dark:text-green-400 shrink-0" />}
{statusType === 'error' && <XCircle className="w-4 h-4 text-red-600 dark:text-red-400 shrink-0" />}
<span className={`text-sm ${
statusType === 'info' ? 'text-blue-300' : statusType === 'success' ? 'text-green-300' : 'text-red-300'
statusType === 'info'
? 'text-blue-700 dark:text-blue-300'
: statusType === 'success'
? 'text-green-700 dark:text-green-300'
: 'text-red-700 dark:text-red-300'
}`}>{statusMessage}</span>
</div>
)}
{/* AMS slots */}
<div className="flex-1 flex flex-col gap-3 p-4 min-h-0 overflow-y-auto">
{!isConnected && printerId ? (
<div className="flex-1 flex items-center justify-center">
<div className="text-center text-white/50">
<p className="text-lg mb-2">{t('spoolbuddy.ams.printerDisconnected', 'Printer disconnected')}</p>
<div className="flex-1 min-h-0 flex flex-col">
{/* AMS slots */}
<section className="flex min-h-[18rem] flex-col gap-3 bg-bambu-dark p-4 lg:min-h-0 lg:overflow-y-auto">
{showPrinterPicker && (
<div className="flex items-center gap-2 text-sm font-medium text-white shrink-0">
<span className="inline-flex h-5 w-5 items-center justify-center rounded-full bg-bambu-green text-[11px] font-bold text-[#ffffff]">2</span>
{t('printModal.selectSlot')}
</div>
</div>
) : amsUnits.length === 0 && vtTrays.length === 0 ? (
<div className="flex-1 flex items-center justify-center">
<div className="text-center text-white/50">
<Layers className="w-12 h-12 mx-auto mb-3 opacity-50" />
<p className="text-lg mb-2">{t('spoolbuddy.ams.noData', 'No AMS detected')}</p>
<p className="text-sm">{t('spoolbuddy.ams.connectAms', 'Connect an AMS to see filament slots')}</p>
</div>
</div>
) : (
<>
{/* Regular AMS — 2-col grid */}
{regularAms.length > 0 && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 flex-1 min-h-0">
{regularAms.map((unit) => (
<AmsUnitCard
key={unit.id}
unit={unit}
activeSlot={currentAssignment?.ams_id === unit.id ? (currentAssignment.tray_id ?? null) : null}
onConfigureSlot={(_amsId, trayId) => handleSlotClick(unit.id, trayId)}
isDualNozzle={isDualNozzle}
nozzleSide={getNozzleSide(unit.id)}
thresholds={amsThresholds}
fillOverrides={fillOverrides}
/>
))}
</div>
)}
)}
{/* Single-slot items (HT + External) */}
{singleSlots.length > 0 && (
<div className="flex gap-2 shrink-0">
{singleSlots.map(({ key, label, amsId, trayId, tray, isEmpty, nozzleSide, effectiveFill }) => {
const color = trayColorToCSS(tray.tray_color);
const isActive = !!currentAssignment &&
currentAssignment.ams_id === amsId &&
currentAssignment.tray_id === trayId;
return (
<div
key={key}
onClick={() => handleSlotClick(amsId, trayId)}
className={`bg-bambu-dark-secondary rounded-lg px-3 py-2 cursor-pointer hover:bg-bambu-dark-secondary/80 transition-all flex items-center gap-2 ${
isActive ? 'ring-2 ring-bambu-green' : ''
} ${isWaiting ? 'opacity-50 pointer-events-none' : ''}`}
>
<div className="relative w-10 h-10 shrink-0">
{isEmpty ? (
<div className="w-full h-full rounded-full border-2 border-dashed border-gray-500 flex items-center justify-center">
<div className="w-1.5 h-1.5 rounded-full bg-gray-600" />
</div>
) : (
<svg viewBox="0 0 56 56" className="w-full h-full">
<circle cx="28" cy="28" r="26" fill={color} />
<circle cx="28" cy="28" r="20" fill={color} style={{ filter: 'brightness(0.85)' }} />
<ellipse cx="20" cy="20" rx="6" ry="4" fill="white" opacity="0.3" />
<circle cx="28" cy="28" r="8" fill="#2d2d2d" />
<circle cx="28" cy="28" r="5" fill="#1a1a1a" />
</svg>
)}
</div>
<div className="min-w-0">
<div className="flex items-center gap-1">
<span className="text-xs text-white/50 font-medium">{label}</span>
{nozzleSide && <NozzleBadge side={nozzleSide} />}
</div>
<div className="text-sm text-white/80 truncate">
{isEmpty ? 'Empty' : tray.tray_type || '?'}
</div>
</div>
{!isEmpty && effectiveFill != null && effectiveFill >= 0 && (
<div className="w-1.5 h-8 bg-bambu-dark-tertiary rounded-full overflow-hidden shrink-0 flex flex-col-reverse">
<div
className="w-full rounded-full"
style={{
height: `${effectiveFill}%`,
backgroundColor: getFillBarColor(effectiveFill),
}}
/>
</div>
)}
</div>
);
})}
{!targetPrinterId ? (
<div className="flex flex-1 items-center justify-center rounded-xl border border-dashed border-[var(--border-color)] bg-bambu-dark-secondary p-8">
<div className="text-center text-bambu-gray">
<Layers className="w-12 h-12 mx-auto mb-3 opacity-60" />
<p className="text-base text-bambu-gray-light">{t('printModal.selectPrinter')}</p>
</div>
)}
</>
)}
</div>
) : statusLoading ? (
<div className="flex flex-1 items-center justify-center text-bambu-gray-light">
<Loader2 className="mr-2 h-5 w-5 animate-spin text-bambu-green" />
{t('common.loading')}
</div>
) : !isConnected ? (
<div className="flex flex-1 items-center justify-center rounded-xl border border-red-300 bg-red-50 p-8 dark:border-red-500/30 dark:bg-red-500/5">
<p className="text-base text-red-700 dark:text-red-300">{t('spoolbuddy.ams.printerDisconnected', 'Printer disconnected')}</p>
</div>
) : amsUnits.length === 0 && vtTrays.length === 0 ? (
<div className="flex flex-1 items-center justify-center rounded-xl border border-dashed border-[var(--border-color)] bg-bambu-dark-secondary p-8">
<div className="text-center text-bambu-gray">
<Layers className="w-12 h-12 mx-auto mb-3 opacity-60" />
<p className="text-base text-bambu-gray-light mb-1">{t('spoolbuddy.ams.noData', 'No AMS detected')}</p>
<p className="text-sm">{t('spoolbuddy.ams.connectAms', 'Connect an AMS to see filament slots')}</p>
</div>
</div>
) : (
<>
{/* Regular AMS — 2-col grid */}
{regularAms.length > 0 && (
<div className="grid grid-cols-1 xl:grid-cols-2 gap-3">
{regularAms.map((unit) => (
<AmsUnitCard
key={unit.id}
unit={unit}
activeSlot={
selectedSlot?.amsId === unit.id
? selectedSlot.trayId
: currentAssignment?.ams_id === unit.id
? (currentAssignment.tray_id ?? null)
: null
}
onConfigureSlot={(_amsId, trayId) => handleSlotClick(unit.id, trayId)}
isDualNozzle={isDualNozzle}
nozzleSide={getNozzleSide(unit.id)}
thresholds={amsThresholds}
fillOverrides={fillOverrides}
/>
))}
</div>
)}
{/* Single-slot items (HT + External) */}
{singleSlots.length > 0 && (
<div className="flex flex-wrap gap-2 shrink-0">
{singleSlots.map(({ key, label, amsId, trayId, tray, isEmpty, nozzleSide, effectiveFill }) => {
const color = trayColorToCSS(tray.tray_color);
const isActive = selectedSlot
? selectedSlot.amsId === amsId && selectedSlot.trayId === trayId
: !!currentAssignment && currentAssignment.ams_id === amsId && currentAssignment.tray_id === trayId;
return (
<button
key={key}
type="button"
aria-pressed={isActive}
title={getSlotLocationLabel(amsId, trayId)}
onClick={() => handleSlotClick(amsId, trayId)}
className={`bg-bambu-dark-secondary border rounded-lg px-3 py-2 hover:bg-bambu-dark-tertiary transition-all flex items-center gap-2 ${
isActive ? 'ring-2 ring-bambu-green border-bambu-green bg-bambu-green/10' : 'border-[var(--border-color)]'
} ${isWaiting ? 'opacity-50 pointer-events-none' : ''}`}
>
<div className="relative w-10 h-10 shrink-0">
{isEmpty ? (
<div className="w-full h-full rounded-full border-2 border-dashed border-gray-500 flex items-center justify-center">
<div className="w-1.5 h-1.5 rounded-full bg-gray-600" />
</div>
) : (
<svg viewBox="0 0 56 56" className="w-full h-full">
<circle cx="28" cy="28" r="26" fill={color} />
<circle cx="28" cy="28" r="20" fill={color} style={{ filter: 'brightness(0.85)' }} />
<ellipse cx="20" cy="20" rx="6" ry="4" fill="white" opacity="0.3" />
<circle cx="28" cy="28" r="8" fill="#2d2d2d" />
<circle cx="28" cy="28" r="5" fill="#1a1a1a" />
</svg>
)}
</div>
<div className="min-w-0 text-left">
<div className="flex items-center gap-1">
<span className="text-xs text-bambu-gray font-medium">{label}</span>
{nozzleSide && <NozzleBadge side={nozzleSide} />}
</div>
<div className="text-sm text-white truncate">
{isEmpty ? t('ams.empty') : tray.tray_type || '?'}
</div>
</div>
{!isEmpty && effectiveFill != null && effectiveFill >= 0 && (
<div className="w-1.5 h-8 bg-bambu-dark-tertiary rounded-full overflow-hidden shrink-0 flex flex-col-reverse">
<div
className="w-full rounded-full"
style={{
height: `${effectiveFill}%`,
backgroundColor: getFillBarColor(effectiveFill),
}}
/>
</div>
)}
</button>
);
})}
</div>
)}
</>
)}
</section>
</div>
{/* Footer */}
<div className="flex justify-end gap-3 px-5 py-3 border-t border-zinc-800 shrink-0">
<div className="flex items-center gap-3 bg-bambu-dark-secondary px-5 py-3 border-t border-[var(--border-color)] shrink-0">
{requiresSlotConfirmation && (
<div className="min-w-0 flex-1 text-sm">
<span className="text-bambu-gray">{t('printModal.selectSlot')}: </span>
<span className={selectedSlotLabel ? 'font-medium text-white' : 'text-bambu-gray'}>
{selectedSlotLabel || '—'}
</span>
</div>
)}
<button
onClick={onClose}
disabled={isWaiting}
className="px-5 py-2.5 rounded-lg text-sm font-medium bg-zinc-800 text-zinc-300 hover:bg-zinc-700 transition-colors min-h-[44px] disabled:opacity-50"
className="px-5 py-2.5 rounded-lg border border-[var(--border-color)] text-sm font-medium bg-bambu-dark-tertiary text-white hover:bg-bambu-dark transition-colors min-h-[44px] disabled:opacity-50"
>
{statusType === 'success' ? t('spoolbuddy.dashboard.close', 'Close') : t('spoolbuddy.modal.cancel', 'Cancel')}
{statusType === 'success' ? t('common.close') : t('common.cancel')}
</button>
{requiresSlotConfirmation && (
<button
type="button"
onClick={handleAssignSelectedSlot}
disabled={!targetPrinterId || !selectedSlot || isWaiting || statusType === 'success'}
className="inline-flex min-h-[44px] items-center gap-2 rounded-lg bg-bambu-green px-5 py-2.5 text-sm font-semibold text-[#ffffff] transition-colors hover:bg-bambu-green-dark disabled:cursor-not-allowed disabled:opacity-40"
>
{isWaiting ? <Loader2 className="h-4 w-4 animate-spin" /> : <Check className="h-4 w-4" />}
{t('inventory.assignSpool')}
</button>
)}
</div>
</div>
</div>
{showMismatchConfirm && mismatchDetails && (() => {
let message = '';
@@ -571,6 +798,7 @@ export function AssignToAmsModal({ isOpen, onClose, spool, printerId, spoolmanMo
message={message}
confirmText={t('inventory.assignMismatchConfirm')}
variant="warning"
overlayZIndex="z-[70]"
isLoading={configureMutation.isPending}
onConfirm={handleConfirmMismatch}
onCancel={() => {
+1
View File
@@ -343,6 +343,7 @@ export default {
// Printer card
readyToPrint: 'Druckbereit',
external: 'Extern',
ext: 'Extern',
extL: 'Ext-L',
extR: 'Ext-R',
deleteArchives: 'Druckarchive löschen',
+1
View File
@@ -343,6 +343,7 @@ export default {
// Printer card
readyToPrint: 'Ready to print',
external: 'External',
ext: 'Ext',
extL: 'Ext-L',
extR: 'Ext-R',
deleteArchives: 'Delete print archives',
+1
View File
@@ -343,6 +343,7 @@ export default {
// Printer card
readyToPrint: 'Lista para imprimir',
external: 'Externo',
ext: 'Externo',
extL: 'Ext-L',
extR: 'Ext-R',
deleteArchives: 'Eliminar archivos de impresión',
+1
View File
@@ -343,6 +343,7 @@ export default {
// Printer card
readyToPrint: 'Prête à imprimer',
external: 'Externe',
ext: 'Externe',
extL: 'Ext-L',
extR: 'Ext-R',
deleteArchives: 'Supprimer les archives d\'impression',
+1
View File
@@ -343,6 +343,7 @@ export default {
// Printer card
readyToPrint: 'Pronta a stampare',
external: 'Esterna',
ext: 'Esterno',
extL: 'Ext-L',
extR: 'Ext-R',
deleteArchives: 'Elimina archivi stampa',
+1
View File
@@ -342,6 +342,7 @@ export default {
// Printer card
readyToPrint: '印刷可能',
external: '外部',
ext: '外部',
extL: 'Ext-L',
extR: 'Ext-R',
deleteArchives: '印刷アーカイブを削除',
+1
View File
@@ -327,6 +327,7 @@ export default {
},
readyToPrint: '인쇄 준비 완료',
external: '외부',
ext: '외부',
extL: '외부-L',
extR: '외부-R',
deleteArchives: '인쇄 아카이브 삭제',
+1
View File
@@ -343,6 +343,7 @@ export default {
// Printer card
readyToPrint: 'Klaar om af te drukken',
external: 'Extern',
ext: 'Extern',
extL: 'Ext-L',
extR: 'Ext-R',
deleteArchives: 'Afdrukarchieven verwijderen',
+1
View File
@@ -343,6 +343,7 @@ export default {
// Printer card
readyToPrint: 'Pronto para imprimir',
external: 'Externo',
ext: 'Externo',
extL: 'Ext-L',
extR: 'Ext-R',
deleteArchives: 'Excluir arquivos de impressão',
+1
View File
@@ -332,6 +332,7 @@ export default {
},
readyToPrint: "Готов к печати",
external: "Внешняя подача",
ext: "Внешняя",
extL: "Внешняя, левое",
extR: "Внешняя, правое",
deleteArchives: "Удалить архивы печати",
+1
View File
@@ -343,6 +343,7 @@ export default {
// Printer card
readyToPrint: 'Redo att skriva ut',
external: 'Extern',
ext: 'Extern',
extL: 'Ext-V',
extR: 'Ext-H',
deleteArchives: 'Ta bort utskriftsarkiv',
+1
View File
@@ -343,6 +343,7 @@ export default {
// Yazıcı kartı
readyToPrint: 'Baskıya hazır',
external: 'Harici',
ext: 'Harici',
extL: 'Ext-L',
extR: 'Ext-R',
deleteArchives: 'Baskı arşivlerini sil',
+1
View File
@@ -343,6 +343,7 @@ export default {
// Printer card
readyToPrint: "Готовий до друку",
external: "Зовнішній",
ext: "Зовнішня",
extL: "Ext-L",
extR: "Ext-R",
deleteArchives: "Видалити архіви друку",
+1
View File
@@ -343,6 +343,7 @@ export default {
// Printer card
readyToPrint: '准备打印',
external: '外部',
ext: '外置',
extL: '外置左',
extR: '外置右',
deleteArchives: '删除打印归档',
+1
View File
@@ -343,6 +343,7 @@ export default {
// Printer card
readyToPrint: '準備列印',
external: '外部',
ext: '外接',
extL: '外接左',
extR: '外接右',
deleteArchives: '刪除列印歸檔',
@@ -694,6 +694,7 @@ export function SpoolBuddyDashboard() {
onClose={() => setShowAssignAmsModal(false)}
spool={effectiveModalSpool}
printerId={selectedPrinterId}
variant="kiosk"
spoolmanMode={spoolmanMode}
/>
)}
@@ -291,6 +291,7 @@ export function SpoolBuddyInventoryPage() {
onClose={() => setShowAssignAmsModal(false)}
spool={liveSpool}
printerId={selectedPrinterId}
variant="kiosk"
spoolmanMode={spoolmanMode}
/>
</>
+11 -2
View File
@@ -6,8 +6,17 @@ import { resolveSpoolColorName } from './colors';
* Case-insensitive. Empty query always returns true.
*/
export function spoolMatchesQuery(spool: InventorySpool, query: string): boolean {
if (!query) return true;
const q = query.toLowerCase();
const q = query.trim().toLowerCase();
if (!q) return true;
// A hash-prefixed number is an explicit spool ID lookup. Keep plain numeric
// searches backwards-compatible ("3" can still match #3, #13, #30, ...),
// while "#3" selects only the physical spool whose label says #3.
const exactIdQuery = q.match(/^#(\d+)$/);
if (exactIdQuery) {
return spool.id === Number(exactIdQuery[1]);
}
return (
String(spool.id).includes(q) ||
spool.material.toLowerCase().includes(q) ||