소스 검색

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

Peer Drescher 3 일 전
부모
커밋
632930f02a

+ 12 - 0
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(<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 () => {

+ 209 - 7
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(
+      <>
+        <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(

+ 40 - 5
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(
+      <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);
   });
 });
 

+ 18 - 14
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 <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');

+ 23 - 0
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]);
+  });
 });

+ 12 - 6
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'
                     }`}
                   >
-                    <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
frontend/src/components/SpoolFormModal.tsx

@@ -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}
+      />
+    )}
+    </>
   );
 }

+ 29 - 7
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 (
     <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}
             />
           );

+ 361 - 133
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<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, printerId, spool.id]);
+
+  // 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) 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((e: KeyboardEvent) => {
-    if (e.key === 'Escape') onClose();
-  }, [onClose]);
+  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) document.addEventListener('keydown', handleKeyDown);
+    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>
+          )}
+
+          {!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>
-          </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}
-                  />
-                ))}
+          ) : 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>
-            )}
-
-            {/* 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>
+          ) : (
+            <>
+              {/* 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>
-                      {!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 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>
-                      )}
-                    </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 - 0
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',

+ 1 - 0
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',

+ 1 - 0
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',

+ 1 - 0
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',

+ 1 - 0
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',

+ 1 - 0
frontend/src/i18n/locales/ja.ts

@@ -342,6 +342,7 @@ export default {
     // Printer card
     readyToPrint: '印刷可能',
     external: '外部',
+    ext: '外部',
     extL: 'Ext-L',
     extR: 'Ext-R',
     deleteArchives: '印刷アーカイブを削除',

+ 1 - 0
frontend/src/i18n/locales/ko.ts

@@ -327,6 +327,7 @@ export default {
     },
     readyToPrint: '인쇄 준비 완료',
     external: '외부',
+    ext: '외부',
     extL: '외부-L',
     extR: '외부-R',
     deleteArchives: '인쇄 아카이브 삭제',

+ 1 - 0
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',

+ 1 - 0
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',

+ 1 - 0
frontend/src/i18n/locales/ru.ts

@@ -332,6 +332,7 @@ export default {
     },
     readyToPrint: "Готов к печати",
     external: "Внешняя подача",
+    ext: "Внешняя",
     extL: "Внешняя, левое",
     extR: "Внешняя, правое",
     deleteArchives: "Удалить архивы печати",

+ 1 - 0
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',

+ 1 - 0
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',

+ 1 - 0
frontend/src/i18n/locales/uk.ts

@@ -343,6 +343,7 @@ export default {
     // Printer card
     readyToPrint: "Готовий до друку",
     external: "Зовнішній",
+    ext: "Зовнішня",
     extL: "Ext-L",
     extR: "Ext-R",
     deleteArchives: "Видалити архіви друку",

+ 1 - 0
frontend/src/i18n/locales/zh-CN.ts

@@ -343,6 +343,7 @@ export default {
     // Printer card
     readyToPrint: '准备打印',
     external: '外部',
+    ext: '外置',
     extL: '外置左',
     extR: '外置右',
     deleteArchives: '删除打印归档',

+ 1 - 0
frontend/src/i18n/locales/zh-TW.ts

@@ -343,6 +343,7 @@ export default {
     // Printer card
     readyToPrint: '準備列印',
     external: '外部',
+    ext: '外接',
     extL: '外接左',
     extR: '外接右',
     deleteArchives: '刪除列印歸檔',

+ 1 - 0
frontend/src/pages/spoolbuddy/SpoolBuddyDashboard.tsx

@@ -694,6 +694,7 @@ export function SpoolBuddyDashboard() {
           onClose={() => setShowAssignAmsModal(false)}
           spool={effectiveModalSpool}
           printerId={selectedPrinterId}
+          variant="kiosk"
           spoolmanMode={spoolmanMode}
         />
       )}

+ 1 - 0
frontend/src/pages/spoolbuddy/SpoolBuddyInventoryPage.tsx

@@ -291,6 +291,7 @@ export function SpoolBuddyInventoryPage() {
               onClose={() => setShowAssignAmsModal(false)}
               spool={liveSpool}
               printerId={selectedPrinterId}
+              variant="kiosk"
               spoolmanMode={spoolmanMode}
             />
           </>

+ 11 - 2
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) ||