2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-12-14 08:19:54 +00:00

[UI] Barcode form inputs (#10973)

* Add barcode buttons to related fields

- Only field types which support barcodes

* Add per-user settings for barcode support

* Fill form field with scanned data

* Updated docs

* Fix duplicate setting

* Add playwright tests

* Fix duplicate setting in docs

* Fix broken link

* Fix memo deps

* Fix typo

* Remove setting

* Updated playwright tests

* Improved typing
This commit is contained in:
Oliver
2025-12-07 18:31:32 +11:00
committed by GitHub
parent f4186e73ff
commit ae70c22485
16 changed files with 309 additions and 108 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View File

@@ -69,6 +69,19 @@ To access this page, select *Scan Barcode* from the main navigation menu:
{{ image("barcode/barcode_nav_menu.png", "Barcode menu item") }}
{{ image("barcode/barcode_scan_page.png", "Barcode scan page") }}
### Barcodes in Forms
The InvenTree user interface supports direct scanning of barcodes within certain forms in the web UI. This means that any form field which points to a model which supports barcodes can accept barcode input. If barcode scanning is supported for a particular field, a barcode icon will be displayed next to the input field:
{{ image("barcode/barcode_field.png", "Barcode form field") }}
To scan a barcode into a form field, click this barcode icon. A barcode scanning dialog will be displayed, allowing the user to scan a barcode using their preferred input method:
{{ image("barcode/barcode_field_dialog.png", "Barcode field scan dialog") }}
Once scanned, the form field will be automatically populated with the correct item.
{{ image("barcode/barcode_field_filled.png", "Barcode field populated") }}
## App Integration
@@ -81,3 +94,10 @@ If enabled, InvenTree can retain logs of the most recent barcode scans. This can
Refer to the [barcode settings](../settings/global.md#barcodes) to enable barcode history logging.
The barcode history can be viewed via the admin panel in the web interface.
## Barcode Settings
There are a number of settings which control the behavior of barcodes within InvenTree. For more information, refer to the links below:
- [Global Barcode Settings](../settings/global.md#barcodes)
- [User Preferences for Barcode Scanning](../settings/user.md#display-settings)

View File

@@ -78,7 +78,7 @@ If this setting is enabled, users can reset their password via email. This requi
If this setting is enabled, users must have multi-factor authentication enabled to log in.
#### Auto Fil SSO Users
#### Auto Fill SSO Users
Automatically fill out user-details from SSO account-data. If this feature is enabled the user is only asked for their username, first- and surname if those values can not be gathered from their SSO profile. This might lead to unwanted usernames bleeding over.

View File

@@ -22,6 +22,7 @@ The *Display Settings* screen shows general display configuration options:
{{ usersetting("STICKY_HEADER") }}
{{ usersetting("STICKY_TABLE_HEADER") }}
{{ usersetting("SHOW_SPOTLIGHT") }}
{{ usersetting("BARCODE_IN_FORM_FIELDS") }}
{{ usersetting("DATE_DISPLAY_FORMAT") }}
{{ usersetting("FORMS_CLOSE_USING_ESCAPE") }}
{{ usersetting("DISPLAY_STOCKTAKE_TAB") }}

View File

@@ -41,6 +41,12 @@ USER_SETTINGS: dict[str, InvenTreeSettingsKeyType] = {
'default': False,
'validator': bool,
},
'BARCODE_IN_FORM_FIELDS': {
'name': _('Barcode Scanner in Form Fields'),
'description': _('Allow barcode scanner input in form fields'),
'default': True,
'validator': bool,
},
'SEARCH_PREVIEW_SHOW_PARTS': {
'name': _('Search Parts'),
'description': _('Display parts in search preview window'),

View File

@@ -10,6 +10,7 @@ export interface ModelInformationInterface {
url_detail?: string;
api_endpoint: ApiEndpoints;
admin_url?: string;
supports_barcode?: boolean;
icon: keyof InvenTreeIconType;
}
@@ -31,6 +32,7 @@ export const ModelInformationDict: ModelDict = {
url_detail: '/part/:pk/',
api_endpoint: ApiEndpoints.part_list,
admin_url: '/part/part/',
supports_barcode: true,
icon: 'part'
},
parameter: {
@@ -60,6 +62,7 @@ export const ModelInformationDict: ModelDict = {
url_detail: '/purchasing/supplier-part/:pk/',
api_endpoint: ApiEndpoints.supplier_part_list,
admin_url: '/company/supplierpart/',
supports_barcode: true,
icon: 'supplier_part'
},
manufacturerpart: {
@@ -69,6 +72,7 @@ export const ModelInformationDict: ModelDict = {
url_detail: '/purchasing/manufacturer-part/:pk/',
api_endpoint: ApiEndpoints.manufacturer_part_list,
admin_url: '/company/manufacturerpart/',
supports_barcode: true,
icon: 'manufacturers'
},
partcategory: {
@@ -87,6 +91,7 @@ export const ModelInformationDict: ModelDict = {
url_detail: '/stock/item/:pk/',
api_endpoint: ApiEndpoints.stock_item_list,
admin_url: '/stock/stockitem/',
supports_barcode: true,
icon: 'stock'
},
stocklocation: {
@@ -96,6 +101,7 @@ export const ModelInformationDict: ModelDict = {
url_detail: '/stock/location/:pk/',
api_endpoint: ApiEndpoints.stock_location_list,
admin_url: '/stock/stocklocation/',
supports_barcode: true,
icon: 'location'
},
stocklocationtype: {
@@ -117,6 +123,7 @@ export const ModelInformationDict: ModelDict = {
url_detail: '/manufacturing/build-order/:pk/',
api_endpoint: ApiEndpoints.build_order_list,
admin_url: '/build/build/',
supports_barcode: true,
icon: 'build_order'
},
buildline: {
@@ -155,6 +162,7 @@ export const ModelInformationDict: ModelDict = {
url_detail: '/purchasing/purchase-order/:pk/',
api_endpoint: ApiEndpoints.purchase_order_list,
admin_url: '/order/purchaseorder/',
supports_barcode: true,
icon: 'purchase_orders'
},
purchaseorderlineitem: {
@@ -170,6 +178,7 @@ export const ModelInformationDict: ModelDict = {
url_detail: '/sales/sales-order/:pk/',
api_endpoint: ApiEndpoints.sales_order_list,
admin_url: '/order/salesorder/',
supports_barcode: true,
icon: 'sales_orders'
},
salesordershipment: {
@@ -178,6 +187,7 @@ export const ModelInformationDict: ModelDict = {
url_overview: '/sales/index/shipments',
url_detail: '/sales/shipment/:pk/',
api_endpoint: ApiEndpoints.sales_order_shipment_list,
supports_barcode: true,
icon: 'shipment'
},
returnorder: {
@@ -187,6 +197,7 @@ export const ModelInformationDict: ModelDict = {
url_detail: '/sales/return-order/:pk/',
api_endpoint: ApiEndpoints.return_order_list,
admin_url: '/order/returnorder/',
supports_barcode: true,
icon: 'return_orders'
},
returnorderlineitem: {

View File

@@ -19,6 +19,11 @@ export type BarcodeScanResult = {
error?: string;
};
export type BarcodeScanSuccessCallback = (
barcode: string,
response: any
) => void;
// Callback function for handling a barcode scan
// This function should return true if the barcode was handled successfully
export type BarcodeScanCallback = (
@@ -31,13 +36,15 @@ export default function BarcodeScanDialog({
opened,
callback,
modelType,
onClose
onClose,
onScanSuccess
}: Readonly<{
title?: string;
opened: boolean;
modelType?: ModelType;
callback?: BarcodeScanCallback;
onClose: () => void;
onScanSuccess?: BarcodeScanSuccessCallback;
}>) {
const navigate = useNavigate();
@@ -53,6 +60,7 @@ export default function BarcodeScanDialog({
<ScanInputHandler
navigate={navigate}
onClose={onClose}
onScanSuccess={onScanSuccess}
modelType={modelType}
callback={callback}
/>
@@ -65,10 +73,12 @@ export function ScanInputHandler({
callback,
modelType,
onClose,
onScanSuccess,
navigate
}: Readonly<{
callback?: BarcodeScanCallback;
onClose: () => void;
onScanSuccess?: BarcodeScanSuccessCallback;
modelType?: ModelType;
navigate: NavigateFunction;
}>) {
@@ -94,7 +104,13 @@ export function ScanInputHandler({
data[model_type]['pk']
);
onClose();
navigate(url);
if (onScanSuccess) {
onScanSuccess(data['barcode'], data);
} else {
navigate(url);
}
match = true;
break;
}

View File

@@ -1,19 +1,32 @@
import type { ModelType } from '@lib/index';
import { t } from '@lingui/core/macro';
import { ActionIcon, Tooltip } from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
import { IconQrcode } from '@tabler/icons-react';
import BarcodeScanDialog from '../barcodes/BarcodeScanDialog';
import BarcodeScanDialog, {
type BarcodeScanCallback,
type BarcodeScanSuccessCallback
} from '../barcodes/BarcodeScanDialog';
/**
* A button which opens the QR code scanner modal
*/
export function ScanButton() {
export function ScanButton({
modelType,
callback,
onScanSuccess
}: {
modelType?: ModelType;
callback?: BarcodeScanCallback;
onScanSuccess?: BarcodeScanSuccessCallback;
}) {
const [opened, { open, close }] = useDisclosure(false);
return (
<>
<Tooltip position='bottom-end' label={t`Scan Barcode`}>
<ActionIcon
aria-label={`barcode-scan-button-${modelType ?? 'any'}`}
onClick={open}
variant='transparent'
title={t`Open Barcode Scanner`}
@@ -21,7 +34,13 @@ export function ScanButton() {
<IconQrcode />
</ActionIcon>
</Tooltip>
<BarcodeScanDialog opened={opened} onClose={close} />
<BarcodeScanDialog
opened={opened}
modelType={modelType}
callback={callback}
onClose={close}
onScanSuccess={onScanSuccess}
/>
</>
);
}

View File

@@ -1,5 +1,6 @@
import { t } from '@lingui/core/macro';
import {
Group,
Input,
darken,
useMantineColorScheme,
@@ -15,9 +16,16 @@ import {
} from 'react-hook-form';
import Select from 'react-select';
import { ModelInformationDict } from '@lib/enums/ModelInformation';
import type { ApiFormFieldType } from '@lib/types/Forms';
import { useApi } from '../../../contexts/ApiContext';
import {
useGlobalSettingsState,
useUserSettingsState
} from '../../../states/SettingsStates';
import { vars } from '../../../theme';
import { ScanButton } from '../../buttons/ScanButton';
import Expand from '../../items/Expand';
import { RenderInstance } from '../../render/Instance';
/**
@@ -60,6 +68,101 @@ export function RelatedModelField({
const [data, setData] = useState<any[]>([]);
const dataRef = useRef<any[]>([]);
const globalSettings = useGlobalSettingsState();
const userSettings = useUserSettingsState();
// Search input query
const [value, setValue] = useState<string>('');
const [searchText] = useDebouncedValue(value, 250);
// Fetch a single field by primary key, using the provided API filters
const fetchSingleField = useCallback(
(pk: number) => {
if (!definition?.api_url) {
return;
}
const params = definition?.filters ?? {};
const url = `${definition.api_url}${pk}/`;
api
.get(url, {
params: params
})
.then((response) => {
const pk_field = definition.pk_field ?? 'pk';
if (response.data?.[pk_field]) {
const value = {
value: response.data[pk_field],
data: response.data
};
// Run custom callback for this field (if provided)
if (definition.onValueChange) {
definition.onValueChange(response.data[pk_field], response.data);
}
setInitialData(value);
dataRef.current = [value];
setPk(response.data[pk_field]);
}
});
},
[
definition.api_url,
definition.filters,
definition.onValueChange,
definition.pk_field,
setValue,
setPk
]
);
// Memoize the model type information for this field
const modelInfo = useMemo(() => {
if (!definition.model) {
return null;
}
return ModelInformationDict[definition.model];
}, [definition.model]);
// Determine whether a barcode field should be added
const addBarcodeField: boolean = useMemo(() => {
if (!modelInfo || !modelInfo.supports_barcode) {
return false;
}
if (!globalSettings.isSet('BARCODE_ENABLE')) {
return false;
}
if (!userSettings.isSet('BARCODE_IN_FORM_FIELDS')) {
return false;
}
return true;
}, [globalSettings, userSettings, modelInfo]);
// Callback function to handle barcode scan results
const onBarcodeScan = useCallback(
(barcode: string, response: any) => {
// Fetch model information from the response
const modelData = response?.[definition.model ?? ''] ?? null;
if (modelData) {
const pk_field = definition.pk_field ?? 'pk';
const pk = modelData[pk_field];
if (pk) {
// Perform a full re-fetch of the field data
// This is necessary as the barcode scan does not provide full data necessarily
fetchSingleField(pk);
}
}
},
[definition.model, definition.pk_field, fetchSingleField]
);
const [isOpen, setIsOpen] = useState<boolean>(false);
const [autoFilled, setAutoFilled] = useState<boolean>(false);
@@ -144,37 +247,7 @@ export function RelatedModelField({
const id = pk || field.value;
if (id !== null && id !== undefined && id !== '') {
const url = `${definition.api_url}${id}/`;
if (!url) {
setPk(null);
return;
}
const params = definition?.filters ?? {};
api
.get(url, {
params: params
})
.then((response) => {
const pk_field = definition.pk_field ?? 'pk';
if (response.data?.[pk_field]) {
const value = {
value: response.data[pk_field],
data: response.data
};
// Run custom callback for this field (if provided)
if (definition.onValueChange) {
definition.onValueChange(response.data[pk_field], response.data);
}
setInitialData(value);
dataRef.current = [value];
setPk(response.data[pk_field]);
}
});
fetchSingleField(id);
} else {
setPk(null);
}
@@ -185,10 +258,6 @@ export function RelatedModelField({
field.value
]);
// Search input query
const [value, setValue] = useState<string>('');
const [searchText] = useDebouncedValue(value, 250);
const [filters, setFilters] = useState<any>({});
const resetSearch = useCallback(() => {
@@ -377,58 +446,68 @@ export function RelatedModelField({
error={definition.error ?? error?.message}
styles={{ description: { paddingBottom: '5px' } }}
>
<Select
id={fieldId}
aria-label={`related-field-${field.name}`}
value={currentValue}
ref={field.ref}
options={data}
filterOption={null}
onInputChange={(value: any) => {
setValue(value);
}}
onChange={onChange}
onMenuScrollToBottom={() => setOffset(offset + limit)}
onMenuOpen={() => {
setIsOpen(true);
resetSearch();
selectQuery.refetch();
}}
onMenuClose={() => {
setIsOpen(false);
}}
isLoading={
selectQuery.isFetching ||
selectQuery.isLoading ||
selectQuery.isRefetching
}
isClearable={!definition.required}
isDisabled={definition.disabled}
isSearchable={true}
placeholder={definition.placeholder || `${t`Search`}...`}
loadingMessage={() => `${t`Loading`}...`}
menuPortalTarget={document.body}
noOptionsMessage={() => t`No results found`}
menuPosition='fixed'
styles={{
menuPortal: (base: any) => ({ ...base, zIndex: 9999 }),
clearIndicator: (base: any) => ({
...base,
color: 'red',
':hover': { color: 'red' }
})
}}
formatOptionLabel={(option: any) => formatOption(option)}
theme={(theme) => {
return {
...theme,
colors: {
...theme.colors,
...colors
<Group justify='space-between' wrap='nowrap' gap={3}>
<Expand>
<Select
id={fieldId}
aria-label={`related-field-${field.name}`}
value={currentValue}
ref={field.ref}
options={data}
filterOption={null}
onInputChange={(value: any) => {
setValue(value);
}}
onChange={onChange}
onMenuScrollToBottom={() => setOffset(offset + limit)}
onMenuOpen={() => {
setIsOpen(true);
resetSearch();
selectQuery.refetch();
}}
onMenuClose={() => {
setIsOpen(false);
}}
isLoading={
selectQuery.isFetching ||
selectQuery.isLoading ||
selectQuery.isRefetching
}
};
}}
/>
isClearable={!definition.required}
isDisabled={definition.disabled}
isSearchable={true}
placeholder={definition.placeholder || `${t`Search`}...`}
loadingMessage={() => `${t`Loading`}...`}
menuPortalTarget={document.body}
noOptionsMessage={() => t`No results found`}
menuPosition='fixed'
styles={{
menuPortal: (base: any) => ({ ...base, zIndex: 9999 }),
clearIndicator: (base: any) => ({
...base,
color: 'red',
':hover': { color: 'red' }
})
}}
formatOptionLabel={(option: any) => formatOption(option)}
theme={(theme) => {
return {
...theme,
colors: {
...theme.colors,
...colors
}
};
}}
/>
</Expand>
{addBarcodeField && (
<ScanButton
modelType={definition.model}
onScanSuccess={onBarcodeScan}
/>
)}
</Group>
</Input.Wrapper>
);
}

View File

@@ -59,6 +59,7 @@ export default function UserSettings() {
'STICKY_HEADER',
'STICKY_TABLE_HEADER',
'SHOW_SPOTLIGHT',
'BARCODE_IN_FORM_FIELDS',
'DATE_DISPLAY_FORMAT',
'FORMS_CLOSE_USING_ESCAPE',
'DISPLAY_STOCKTAKE_TAB',

View File

@@ -54,7 +54,11 @@ export const clearTableFilters = async (page: Page) => {
await page.waitForLoadState('networkidle');
};
export const setTableChoiceFilter = async (page: Page, filter, value) => {
export const setTableChoiceFilter = async (
page: Page,
filter: string,
value: string
) => {
await openFilterDrawer(page);
await page.getByRole('button', { name: 'Add Filter' }).click();
@@ -116,7 +120,7 @@ export const navigate = async (
/**
* CLick on the 'tab' element with the provided name
*/
export const loadTab = async (page: Page, tabName, exact?) => {
export const loadTab = async (page: Page, tabName: string, exact?: boolean) => {
await page
.getByLabel(/panel-tabs-/)
.getByRole('tab', { name: tabName, exact: exact ?? false })
@@ -140,7 +144,7 @@ export const activateCalendarView = async (page: Page) => {
/**
* Perform a 'global search' on the provided page, for the provided query text
*/
export const globalSearch = async (page: Page, query) => {
export const globalSearch = async (page: Page, query: string) => {
await page.getByLabel('open-search').click();
await page.getByLabel('global-search-input').clear();
await page.getByPlaceholder('Enter search text').fill(query);

View File

@@ -14,7 +14,7 @@ interface LoginOptions {
/*
* Perform form based login operation from the "login" URL
*/
export const doLogin = async (page, options?: LoginOptions) => {
export const doLogin = async (page: Page, options?: LoginOptions) => {
const username: string = options?.username ?? user.username;
const password: string = options?.password ?? user.password;

View File

@@ -225,7 +225,9 @@ test('Purchase Orders - Barcodes', async ({ browser }) => {
// Ensure we can scan back to this page, with the associated barcode
await page.getByRole('tab', { name: 'Sales' }).click();
await page.waitForTimeout(250);
await page.getByRole('button', { name: 'Open Barcode Scanner' }).click();
await page.getByRole('button', { name: 'barcode-scan-button-any' }).click();
await page.getByPlaceholder('Enter barcode data').fill('1234567890');
await page.getByRole('button', { name: 'Scan', exact: true }).click();

View File

@@ -1,24 +1,37 @@
import type { Page } from '@playwright/test';
import { test } from '../baseFixtures';
import { doCachedLogin } from '../login';
const scan = async (page, barcode) => {
const scan = async (page: Page, barcode: string) => {
await page.getByLabel('barcode-input-scanner').click();
await page.getByLabel('barcode-scan-keyboard-input').fill(barcode);
await page.getByRole('button', { name: 'Scan', exact: true }).click();
};
test('Scanning - Dialog', async ({ browser }) => {
test('Barcode Scanning - Dialog', async ({ browser }) => {
const page = await doCachedLogin(browser);
await page.getByRole('button', { name: 'Open Barcode Scanner' }).click();
// Attempt scan with invalid data
await page.getByRole('button', { name: 'barcode-scan-button-any' }).click();
await scan(page, 'invalid-barcode-123');
await page.getByText('No match found for barcode').waitFor();
// Attempt scan with "legacy" barcode format
await scan(page, '{"part": 15}');
await page.getByText('Part: R_550R_0805_1%', { exact: true }).waitFor();
await page.getByText('Available:').waitFor();
await page.getByText('Required:').waitFor();
// Attempt scan with "modern" barcode format
await page.getByRole('button', { name: 'barcode-scan-button-any' }).click();
await scan(page, 'INV-BO0010');
await page.getByText('Build Order: BO0010').waitFor();
await page.getByText('Making a high level assembly part').waitFor();
});
test('Scanning - Basic', async ({ browser }) => {
test('Barcode Scanning - Basic', async ({ browser }) => {
const page = await doCachedLogin(browser);
// Navigate to the 'scan' page
@@ -39,7 +52,7 @@ test('Scanning - Basic', async ({ browser }) => {
await page.getByText('No match found for barcode').waitFor();
});
test('Scanning - Part', async ({ browser }) => {
test('Barcode Scanning - Part', async ({ browser }) => {
const page = await doCachedLogin(browser, { url: 'scan/' });
await scan(page, '{"part": 1}');
@@ -49,7 +62,7 @@ test('Scanning - Part', async ({ browser }) => {
await page.getByRole('cell', { name: 'part', exact: true }).waitFor();
});
test('Scanning - Stockitem', async ({ browser }) => {
test('Barcode Scanning - Stockitem', async ({ browser }) => {
const page = await doCachedLogin(browser, { url: 'scan/' });
await scan(page, '{"stockitem": 408}');
@@ -58,7 +71,7 @@ test('Scanning - Stockitem', async ({ browser }) => {
await page.getByRole('cell', { name: 'Quantity: 100' }).waitFor();
});
test('Scanning - StockLocation', async ({ browser }) => {
test('Barcode Scanning - StockLocation', async ({ browser }) => {
const page = await doCachedLogin(browser, { url: 'scan/' });
await scan(page, '{"stocklocation": 3}');
@@ -71,7 +84,7 @@ test('Scanning - StockLocation', async ({ browser }) => {
.waitFor();
});
test('Scanning - SupplierPart', async ({ browser }) => {
test('Barcode Scanning - SupplierPart', async ({ browser }) => {
const page = await doCachedLogin(browser, { url: 'scan/' });
await scan(page, '{"supplierpart": 204}');
@@ -80,7 +93,7 @@ test('Scanning - SupplierPart', async ({ browser }) => {
await page.getByRole('cell', { name: 'supplierpart', exact: true }).waitFor();
});
test('Scanning - PurchaseOrder', async ({ browser }) => {
test('Barcode Scanning - PurchaseOrder', async ({ browser }) => {
const page = await doCachedLogin(browser, { url: 'scan/' });
await scan(page, '{"purchaseorder": 12}');
@@ -92,7 +105,7 @@ test('Scanning - PurchaseOrder', async ({ browser }) => {
.waitFor();
});
test('Scanning - SalesOrder', async ({ browser }) => {
test('Barcode Scanning - SalesOrder', async ({ browser }) => {
const page = await doCachedLogin(browser, { url: 'scan/' });
await scan(page, '{"salesorder": 6}');
@@ -103,7 +116,7 @@ test('Scanning - SalesOrder', async ({ browser }) => {
await page.getByRole('cell', { name: 'salesorder', exact: true }).waitFor();
});
test('Scanning - Build', async ({ browser }) => {
test('Barcode Scanning - Build', async ({ browser }) => {
const page = await doCachedLogin(browser, { url: 'scan/' });
await scan(page, '{"build": 8}');
@@ -112,3 +125,32 @@ test('Scanning - Build', async ({ browser }) => {
await page.getByText('PCBA build').waitFor();
await page.getByRole('cell', { name: 'build', exact: true }).waitFor();
});
test('Barcode Scanning - Forms', async ({ browser }) => {
const page = await doCachedLogin(browser, {
url: '/stock/location/index/stock-items'
});
// Open the "Add Stock Item" form
await page
.getByRole('button', { name: 'action-button-add-stock-item' })
.click();
// Fill out the "part" data
await page.getByRole('button', { name: 'barcode-scan-button-part' }).click();
await page
.getByRole('textbox', { name: 'barcode-scan-keyboard-input' })
.fill('INV-PA99');
await page.getByRole('button', { name: 'Scan', exact: true }).click();
await page.getByText('Red Round Table').waitFor();
// Fill out the "location" data
await page
.getByRole('button', { name: 'barcode-scan-button-stocklocation' })
.click();
await page
.getByRole('textbox', { name: 'barcode-scan-keyboard-input' })
.fill('INV-SL37');
await page.getByRole('button', { name: 'Scan', exact: true }).click();
await page.getByText('Offsite Storage').waitFor();
});