From 632930f02a7ed4dd2184f974639a2a67e916220e Mon Sep 17 00:00:00 2001 From: Peer Drescher Date: Mon, 28 Sep 2026 16:42:38 +0200 Subject: [PATCH] Improve spool ID search and AMS assignment (issue #2978) (#2998) --- .../components/AssignSpoolModal.test.tsx | 12 + .../components/AssignToAmsModal.test.tsx | 216 +++++++- .../components/SpoolFormModal.test.tsx | 45 +- .../pages/SpoolBuddyAmsPage.test.tsx | 32 +- .../__tests__/utils/inventorySearch.test.ts | 23 + frontend/src/components/AssignSpoolModal.tsx | 18 +- frontend/src/components/SpoolFormModal.tsx | 59 ++- .../src/components/spoolbuddy/AmsUnitCard.tsx | 36 +- .../spoolbuddy/AssignToAmsModal.tsx | 500 +++++++++++++----- frontend/src/i18n/locales/de.ts | 1 + frontend/src/i18n/locales/en.ts | 1 + frontend/src/i18n/locales/es.ts | 1 + frontend/src/i18n/locales/fr.ts | 1 + frontend/src/i18n/locales/it.ts | 1 + frontend/src/i18n/locales/ja.ts | 1 + frontend/src/i18n/locales/ko.ts | 1 + frontend/src/i18n/locales/nl.ts | 1 + frontend/src/i18n/locales/pt-BR.ts | 1 + frontend/src/i18n/locales/ru.ts | 1 + frontend/src/i18n/locales/sv.ts | 1 + frontend/src/i18n/locales/tr.ts | 1 + frontend/src/i18n/locales/uk.ts | 1 + frontend/src/i18n/locales/zh-CN.ts | 1 + frontend/src/i18n/locales/zh-TW.ts | 1 + .../pages/spoolbuddy/SpoolBuddyDashboard.tsx | 1 + .../spoolbuddy/SpoolBuddyInventoryPage.tsx | 1 + frontend/src/utils/inventorySearch.ts | 13 +- 27 files changed, 781 insertions(+), 190 deletions(-) diff --git a/frontend/src/__tests__/components/AssignSpoolModal.test.tsx b/frontend/src/__tests__/components/AssignSpoolModal.test.tsx index 750ce4e5d..e27a29c8b 100644 --- a/frontend/src/__tests__/components/AssignSpoolModal.test.tsx +++ b/frontend/src/__tests__/components/AssignSpoolModal.test.tsx @@ -109,6 +109,17 @@ describe('AssignSpoolModal', () => { expect(screen.getByText(/Jade White/)).toBeInTheDocument(); }); + it('shows each spool ID on the assignment card (#2978)', async () => { + render(); + + 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( { 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 () => { diff --git a/frontend/src/__tests__/components/AssignToAmsModal.test.tsx b/frontend/src/__tests__/components/AssignToAmsModal.test.tsx index 6a8e98d35..4762531f1 100644 --- a/frontend/src/__tests__/components/AssignToAmsModal.test.tsx +++ b/frontend/src/__tests__/components/AssignToAmsModal.test.tsx @@ -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( + <> + + + + ); + + const dialog = await screen.findByRole('dialog'); + await waitFor(() => expect(dialog).toContainElement(document.activeElement as HTMLElement)); + + const focusable = Array.from(dialog.querySelectorAll( + '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( { 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( + + ); + + 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( + + ); + + 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( + + ); + + 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( + + ); + + 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( + + ); + + 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( diff --git a/frontend/src/__tests__/components/SpoolFormModal.test.tsx b/frontend/src/__tests__/components/SpoolFormModal.test.tsx index 3c098474b..614f9b224 100644 --- a/frontend/src/__tests__/components/SpoolFormModal.test.tsx +++ b/frontend/src/__tests__/components/SpoolFormModal.test.tsx @@ -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( + + ); + + 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); }); }); diff --git a/frontend/src/__tests__/pages/SpoolBuddyAmsPage.test.tsx b/frontend/src/__tests__/pages/SpoolBuddyAmsPage.test.tsx index 62def1995..066f3078d 100644 --- a/frontend/src/__tests__/pages/SpoolBuddyAmsPage.test.tsx +++ b/frontend/src/__tests__/pages/SpoolBuddyAmsPage.test.tsx @@ -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
; 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 { + 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'); diff --git a/frontend/src/__tests__/utils/inventorySearch.test.ts b/frontend/src/__tests__/utils/inventorySearch.test.ts index 9d8a850dc..91d2dc1e0 100644 --- a/frontend/src/__tests__/utils/inventorySearch.test.ts +++ b/frontend/src/__tests__/utils/inventorySearch.test.ts @@ -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]); + }); }); diff --git a/frontend/src/components/AssignSpoolModal.tsx b/frontend/src/components/AssignSpoolModal.tsx index a876c46bd..bd33222c2 100644 --- a/frontend/src/components/AssignSpoolModal.tsx +++ b/frontend/src/components/AssignSpoolModal.tsx @@ -421,9 +421,12 @@ export function AssignSpoolModal({ isOpen, onClose, printerId, amsId, trayId, tr : 'bg-bambu-dark border-bambu-dark-tertiary hover:border-bambu-gray' }`} > -

- {spool.brand ? `${spool.brand} ` : ''}{spool.material}{spool.subtype ? ` ${spool.subtype}` : ''} -

+
+

+ {spool.brand ? `${spool.brand} ` : ''}{spool.material}{spool.subtype ? ` ${spool.subtype}` : ''} +

+ #{spool.id} +
{spool.rgba && ( -

- {spool.brand ? `${spool.brand} ` : ''}{spool.material}{spool.subtype ? ` ${spool.subtype}` : ''} -

+
+

+ {spool.brand ? `${spool.brand} ` : ''}{spool.material}{spool.subtype ? ` ${spool.subtype}` : ''} +

+ #{spool.id} +
{spool.rgba && ( { + 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 ( + <>
{t('inventory.clearRfid', 'Clear RFID Tag')} - + {spoolAssignment ? ( + + ) : ( + + )}
)}
@@ -1276,5 +1298,16 @@ export function SpoolFormModal({
+ {isEditing && spool && ( + setShowAssignModal(false)} + spool={spool} + printerId={null} + variant="dialog" + spoolmanMode={spoolmanMode} + /> + )} + ); } diff --git a/frontend/src/components/spoolbuddy/AmsUnitCard.tsx b/frontend/src/components/spoolbuddy/AmsUnitCard.tsx index fc1431945..3d5c939fd 100644 --- a/frontend/src/components/spoolbuddy/AmsUnitCard.tsx +++ b/frontend/src/components/spoolbuddy/AmsUnitCard.tsx @@ -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 (
{ + 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 */}
@@ -196,7 +213,7 @@ function SpoolSlot({ tray, slotIndex, isActive, fillOverride, spoolmanFill, onCl {/* Material type */} {isEmpty ? (emptyKind === 'reset' ? '?' : 'Empty') : tray.tray_type || 'Unknown'} @@ -216,7 +233,9 @@ function SpoolSlot({ tray, slotIndex, isActive, fillOverride, spoolmanFill, onCl )} {/* Slot number */} - {slotIndex + 1} + + {slotIndex + 1} +
); } @@ -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 ( -
+
{/* Header */}
- {getAmsName(unit.id)} + {amsName} {isDualNozzle && 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} /> ); diff --git a/frontend/src/components/spoolbuddy/AssignToAmsModal.tsx b/frontend/src/components/spoolbuddy/AssignToAmsModal.tsx index db7ea1081..55191efd6 100644 --- a/frontend/src/components/spoolbuddy/AssignToAmsModal.tsx +++ b/frontend/src/components/spoolbuddy/AssignToAmsModal.tsx @@ -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(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(null); + const [selectedPrinterId, setSelectedPrinterId] = useState(printerId); + const [selectedSlot, setSelectedSlot] = useState<{ amsId: number; trayId: number } | null>(null); const [statusMessage, setStatusMessage] = useState(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({ - queryKey: ['printerStatus', printerId], - queryFn: () => api.getPrinterStatus(printerId!), - enabled: isOpen && printerId !== null, + const { data: status, isLoading: statusLoading } = useQuery({ + 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>({}); + 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 ( <> -
+
+
event.stopPropagation()} + > {/* Header */} -
+
-

+

{t('spoolbuddy.modal.assignToAmsTitle', 'Assign to AMS')} - + {resolveSpoolColorName(spool.color_name, spool.rgba, spool.color_name_is_synthesized) || - t('spoolbuddy.spool.unknownColor')}{' '} - • {spool.brand} {spool.material}{spool.subtype && ` ${spool.subtype}`} + t('spoolbuddy.spool.unknownColor')} • {spool.brand} {spool.material} + {spool.subtype && ` ${spool.subtype}`} - #{spool.id} + #{spool.id}

+ {showPrinterPicker && ( +
+ + +
+ )} + {/* Status message */} {statusMessage && (
- {statusType === 'info' && } - {statusType === 'success' && } - {statusType === 'error' && } + {statusType === 'info' && } + {statusType === 'success' && } + {statusType === 'error' && } {statusMessage}
)} - {/* AMS slots */} -
- {!isConnected && printerId ? ( -
-
-

{t('spoolbuddy.ams.printerDisconnected', 'Printer disconnected')}

+
+ {/* AMS slots */} +
+ {showPrinterPicker && ( +
+ 2 + {t('printModal.selectSlot')}
-
- ) : amsUnits.length === 0 && vtTrays.length === 0 ? ( -
-
- -

{t('spoolbuddy.ams.noData', 'No AMS detected')}

-

{t('spoolbuddy.ams.connectAms', 'Connect an AMS to see filament slots')}

-
-
- ) : ( - <> - {/* Regular AMS — 2-col grid */} - {regularAms.length > 0 && ( -
- {regularAms.map((unit) => ( - handleSlotClick(unit.id, trayId)} - isDualNozzle={isDualNozzle} - nozzleSide={getNozzleSide(unit.id)} - thresholds={amsThresholds} - fillOverrides={fillOverrides} - /> - ))} -
- )} + )} - {/* Single-slot items (HT + External) */} - {singleSlots.length > 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 ( -
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' : ''}`} - > -
- {isEmpty ? ( -
-
-
- ) : ( - - - - - - - - )} -
-
-
- {label} - {nozzleSide && } -
-
- {isEmpty ? 'Empty' : tray.tray_type || '?'} -
-
- {!isEmpty && effectiveFill != null && effectiveFill >= 0 && ( -
-
-
- )} -
- ); - })} + {!targetPrinterId ? ( +
+
+ +

{t('printModal.selectPrinter')}

- )} - - )} +
+ ) : statusLoading ? ( +
+ + {t('common.loading')} +
+ ) : !isConnected ? ( +
+

{t('spoolbuddy.ams.printerDisconnected', 'Printer disconnected')}

+
+ ) : amsUnits.length === 0 && vtTrays.length === 0 ? ( +
+
+ +

{t('spoolbuddy.ams.noData', 'No AMS detected')}

+

{t('spoolbuddy.ams.connectAms', 'Connect an AMS to see filament slots')}

+
+
+ ) : ( + <> + {/* Regular AMS — 2-col grid */} + {regularAms.length > 0 && ( +
+ {regularAms.map((unit) => ( + handleSlotClick(unit.id, trayId)} + isDualNozzle={isDualNozzle} + nozzleSide={getNozzleSide(unit.id)} + thresholds={amsThresholds} + fillOverrides={fillOverrides} + /> + ))} +
+ )} + + {/* Single-slot items (HT + External) */} + {singleSlots.length > 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 ( + + ); + })} +
+ )} + + )} +
{/* Footer */} -
+
+ {requiresSlotConfirmation && ( +
+ {t('printModal.selectSlot')}: + + {selectedSlotLabel || '—'} + +
+ )} + {requiresSlotConfirmation && ( + + )}
+
{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={() => { diff --git a/frontend/src/i18n/locales/de.ts b/frontend/src/i18n/locales/de.ts index 1a0ccf2b0..566fdde3c 100644 --- a/frontend/src/i18n/locales/de.ts +++ b/frontend/src/i18n/locales/de.ts @@ -343,6 +343,7 @@ export default { // Printer card readyToPrint: 'Druckbereit', external: 'Extern', + ext: 'Extern', extL: 'Ext-L', extR: 'Ext-R', deleteArchives: 'Druckarchive löschen', diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 3d6e4d4ef..850fd1d32 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -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', diff --git a/frontend/src/i18n/locales/es.ts b/frontend/src/i18n/locales/es.ts index 6e1cfbace..f33794d30 100644 --- a/frontend/src/i18n/locales/es.ts +++ b/frontend/src/i18n/locales/es.ts @@ -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', diff --git a/frontend/src/i18n/locales/fr.ts b/frontend/src/i18n/locales/fr.ts index e0b4a3e6f..09c9bd370 100644 --- a/frontend/src/i18n/locales/fr.ts +++ b/frontend/src/i18n/locales/fr.ts @@ -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', diff --git a/frontend/src/i18n/locales/it.ts b/frontend/src/i18n/locales/it.ts index 9267b2e5b..ad3d7d91c 100644 --- a/frontend/src/i18n/locales/it.ts +++ b/frontend/src/i18n/locales/it.ts @@ -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', diff --git a/frontend/src/i18n/locales/ja.ts b/frontend/src/i18n/locales/ja.ts index 70cc6579c..c187ca84e 100644 --- a/frontend/src/i18n/locales/ja.ts +++ b/frontend/src/i18n/locales/ja.ts @@ -342,6 +342,7 @@ export default { // Printer card readyToPrint: '印刷可能', external: '外部', + ext: '外部', extL: 'Ext-L', extR: 'Ext-R', deleteArchives: '印刷アーカイブを削除', diff --git a/frontend/src/i18n/locales/ko.ts b/frontend/src/i18n/locales/ko.ts index 499607d37..362df7b77 100644 --- a/frontend/src/i18n/locales/ko.ts +++ b/frontend/src/i18n/locales/ko.ts @@ -327,6 +327,7 @@ export default { }, readyToPrint: '인쇄 준비 완료', external: '외부', + ext: '외부', extL: '외부-L', extR: '외부-R', deleteArchives: '인쇄 아카이브 삭제', diff --git a/frontend/src/i18n/locales/nl.ts b/frontend/src/i18n/locales/nl.ts index 823c8105f..d4ebc8c23 100644 --- a/frontend/src/i18n/locales/nl.ts +++ b/frontend/src/i18n/locales/nl.ts @@ -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', diff --git a/frontend/src/i18n/locales/pt-BR.ts b/frontend/src/i18n/locales/pt-BR.ts index efa78c259..f0739f726 100644 --- a/frontend/src/i18n/locales/pt-BR.ts +++ b/frontend/src/i18n/locales/pt-BR.ts @@ -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', diff --git a/frontend/src/i18n/locales/ru.ts b/frontend/src/i18n/locales/ru.ts index df1e827f1..584dc5a56 100644 --- a/frontend/src/i18n/locales/ru.ts +++ b/frontend/src/i18n/locales/ru.ts @@ -332,6 +332,7 @@ export default { }, readyToPrint: "Готов к печати", external: "Внешняя подача", + ext: "Внешняя", extL: "Внешняя, левое", extR: "Внешняя, правое", deleteArchives: "Удалить архивы печати", diff --git a/frontend/src/i18n/locales/sv.ts b/frontend/src/i18n/locales/sv.ts index a413d56ed..30e135557 100644 --- a/frontend/src/i18n/locales/sv.ts +++ b/frontend/src/i18n/locales/sv.ts @@ -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', diff --git a/frontend/src/i18n/locales/tr.ts b/frontend/src/i18n/locales/tr.ts index 22bda1874..dc51c0b0b 100644 --- a/frontend/src/i18n/locales/tr.ts +++ b/frontend/src/i18n/locales/tr.ts @@ -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', diff --git a/frontend/src/i18n/locales/uk.ts b/frontend/src/i18n/locales/uk.ts index 92a2f27e9..48c92883d 100644 --- a/frontend/src/i18n/locales/uk.ts +++ b/frontend/src/i18n/locales/uk.ts @@ -343,6 +343,7 @@ export default { // Printer card readyToPrint: "Готовий до друку", external: "Зовнішній", + ext: "Зовнішня", extL: "Ext-L", extR: "Ext-R", deleteArchives: "Видалити архіви друку", diff --git a/frontend/src/i18n/locales/zh-CN.ts b/frontend/src/i18n/locales/zh-CN.ts index 193f4c5ad..db1ed2038 100644 --- a/frontend/src/i18n/locales/zh-CN.ts +++ b/frontend/src/i18n/locales/zh-CN.ts @@ -343,6 +343,7 @@ export default { // Printer card readyToPrint: '准备打印', external: '外部', + ext: '外置', extL: '外置左', extR: '外置右', deleteArchives: '删除打印归档', diff --git a/frontend/src/i18n/locales/zh-TW.ts b/frontend/src/i18n/locales/zh-TW.ts index 13e61890d..97133d382 100644 --- a/frontend/src/i18n/locales/zh-TW.ts +++ b/frontend/src/i18n/locales/zh-TW.ts @@ -343,6 +343,7 @@ export default { // Printer card readyToPrint: '準備列印', external: '外部', + ext: '外接', extL: '外接左', extR: '外接右', deleteArchives: '刪除列印歸檔', diff --git a/frontend/src/pages/spoolbuddy/SpoolBuddyDashboard.tsx b/frontend/src/pages/spoolbuddy/SpoolBuddyDashboard.tsx index 1f9bf53e5..37bf1fe5a 100644 --- a/frontend/src/pages/spoolbuddy/SpoolBuddyDashboard.tsx +++ b/frontend/src/pages/spoolbuddy/SpoolBuddyDashboard.tsx @@ -694,6 +694,7 @@ export function SpoolBuddyDashboard() { onClose={() => setShowAssignAmsModal(false)} spool={effectiveModalSpool} printerId={selectedPrinterId} + variant="kiosk" spoolmanMode={spoolmanMode} /> )} diff --git a/frontend/src/pages/spoolbuddy/SpoolBuddyInventoryPage.tsx b/frontend/src/pages/spoolbuddy/SpoolBuddyInventoryPage.tsx index 3fbd228d5..4b65fd2a4 100644 --- a/frontend/src/pages/spoolbuddy/SpoolBuddyInventoryPage.tsx +++ b/frontend/src/pages/spoolbuddy/SpoolBuddyInventoryPage.tsx @@ -291,6 +291,7 @@ export function SpoolBuddyInventoryPage() { onClose={() => setShowAssignAmsModal(false)} spool={liveSpool} printerId={selectedPrinterId} + variant="kiosk" spoolmanMode={spoolmanMode} /> diff --git a/frontend/src/utils/inventorySearch.ts b/frontend/src/utils/inventorySearch.ts index fa7165e52..a797bac1c 100644 --- a/frontend/src/utils/inventorySearch.ts +++ b/frontend/src/utils/inventorySearch.ts @@ -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) ||