Barcode scanning (#8732)
* Implement new "general purpose" barcode scan dialog - Separated widgets for camera / keyboard / wedge scanner - UI / UX improvements * Handle scan results * Fix missing imports * Handle successful global scan * Handle error when linking barcode * Backend fix for InvenTreeInternalBarcodePlugin * Error handling * Working on scanner input * Refactor scan page * Callback from scanner input * Refactoring <Scan> page * Allow InvenTreeTable to be used with supplied data * Refactor optionalparams * Refactoring table of scan results * Implement callbacks * Navigate from barcode table * Fix delete callback * Refactor callbacks * Refactor idAccessor - Access as part of useTable hook - No longer hard-coded to 'pk' * prevent duplicate scans * Fix for deleting items from table * Cleanup * Bump API version * Adjust playwright tests * Update playwright tests * Update barcode screenshots * Fix links * Add quick links to barcode formats * Updated screenshots * Fix for BuildLineSubTable * Specify idAccessor values * Clear barcode input after timeout period * Move items * Fix for playwright test * Remove debug print * Additional error ignores * Cleanup scanner input - Simplify - Prevent errant keycodes from closing the scanner dialog * Playwright test adjustments
Before Width: | Height: | Size: 223 KiB After Width: | Height: | Size: 10 KiB |
Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 18 KiB |
BIN
docs/docs/assets/images/barcode/barcode_nav_menu.png
Normal file
After Width: | Height: | Size: 31 KiB |
Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 16 KiB |
Before Width: | Height: | Size: 53 KiB After Width: | Height: | Size: 30 KiB |
BIN
docs/docs/assets/images/barcode/barcode_scan_page.png
Normal file
After Width: | Height: | Size: 46 KiB |
Before Width: | Height: | Size: 194 KiB |
BIN
docs/docs/assets/images/barcode/barcode_unlink_1.png
Normal file
After Width: | Height: | Size: 7.8 KiB |
BIN
docs/docs/assets/images/barcode/barcode_unlink_2.png
Normal file
After Width: | Height: | Size: 12 KiB |
@ -13,7 +13,15 @@ InvenTree has native support for barcodes, which provides powerful functionality
|
||||
- Barcodes can be embedded in [labels or reports](../report/barcodes.md)
|
||||
- Barcode functionality can be [extended via plugins](../extend/plugins/barcode.md)
|
||||
|
||||
### Barcode Data Types
|
||||
### Barcode Formats
|
||||
|
||||
InvenTree supports the following barcode formats:
|
||||
|
||||
- [Internal Barcodes](./internal.md): Native InvenTree barcodes, which are automatically generated for each item
|
||||
- [External Barcodes](./external.md): External (third party) barcodes which can be assigned to items
|
||||
- [Custom Barcodes](./custom.md): Fully customizable barcodes can be generated using the plugin system.
|
||||
|
||||
### Barcode Model Linking
|
||||
|
||||
Barcodes can be linked with the following data model types:
|
||||
|
||||
@ -21,41 +29,58 @@ Barcodes can be linked with the following data model types:
|
||||
- [Stock Item](../stock/stock.md#stock-item)
|
||||
- [Stock Location](../stock/stock.md#stock-location)
|
||||
- [Supplier Part](../order/company.md#supplier-parts)
|
||||
- [Purchase Order](../order/purchase_order.md#purchase-orders)
|
||||
- [Sales Order](../order/sales_order.md#sales-orders)
|
||||
- [Return Order](../order/return_order.md#return-orders)
|
||||
- [Build Order](../build/build.md#build-orders)
|
||||
|
||||
### Configuration Options
|
||||
|
||||
The barcode system can be configured via the [global settings](../settings/global.md#barcodes).
|
||||
|
||||
## Web Integration
|
||||
|
||||
Barcode scanning can be enabled within the web interface. Barcode scanning in the web interface supports scanning via:
|
||||
Barcode scanning can be enabled within the web interface. This allows users to scan barcodes directly from the web browser.
|
||||
|
||||
- Keyboard style scanners (e.g. USB connected)
|
||||
- Webcam (image processing)
|
||||
### Input Modes
|
||||
|
||||
### Configuration
|
||||
The following barcode input modes are supported by the web interface:
|
||||
|
||||
Barcode scanning may need to be enabled for the web interface:
|
||||
- **Webcam**: Use a connected webcam to scan barcodes
|
||||
- **Scanner**: Use a connected barcode scanner to scan barcodes
|
||||
- **Keyboard**: Manually enter a barcode via the keyboard
|
||||
|
||||
{% with id="barcode_config", url="barcode/barcode_settings.png", description="Barcode settings" %}
|
||||
{% include 'img.html' %}
|
||||
{% endwith %}
|
||||
### Quick Scan
|
||||
|
||||
### Scanning
|
||||
|
||||
When enabled, select the barcode icon in the top-right of the menu bar to scan a barcode. If the barcode is recognized by the system, the web browser will automatically navigate to the correct item:
|
||||
If barcode scanning is enabled in the web interface, select the barcode icon in the top-right of the menu bar to perform a quick-scan of a barcode. If the barcode is recognized by the system, the web browser will automatically navigate to the correct item:
|
||||
|
||||
{% with id="barcode_scan", url="barcode/barcode_scan.png", description="Barcode scan" %}
|
||||
{% include 'img.html' %}
|
||||
{% endwith %}
|
||||
|
||||
#### No Match Found
|
||||
|
||||
If no match is found for the scanned barcode, the following error message is displayed:
|
||||
|
||||
{% with id="barcode_no_match", url="barcode/barcode_no_match.png", description="No match for barcode" %}
|
||||
{% include 'img.html' %}
|
||||
{% endwith %}
|
||||
|
||||
### Scanning Action Page
|
||||
|
||||
A more comprehensive barcode scanning interface is available via the "Scan" page in the web interface. This page allows the user to scan multiple barcodes, and perform certain actions on the scanned items.
|
||||
|
||||
To access this page, select *Scan Barcode* from the main navigation menu:
|
||||
|
||||
{% with id="barcode_nav_menu", url="barcode/barcode_nav_menu.png", description="Barcode menu item" %}
|
||||
{% include 'img.html' %}
|
||||
{% endwith %}
|
||||
|
||||
{% with id="barcode_scan_page", url="barcode/barcode_scan_page.png", description="Barcode scan page" %}
|
||||
{% include 'img.html' %}
|
||||
{% endwith %}
|
||||
|
||||
## App Integration
|
||||
|
||||
Barcode scanning is a key feature of the [companion mobile app](../app/barcode.md).
|
||||
Barcode scanning is a key feature of the [companion mobile app](../app/barcode.md). When running on a device with an integrated camera, the app can scan barcodes directly from the camera feed.
|
||||
|
||||
## Barcode History
|
||||
|
||||
|
@ -36,7 +36,11 @@ To link an arbitrary barcode, select the *Link Barcode* action as shown below:
|
||||
|
||||
If an item already has a linked barcode, it can be un-linked by selecting the *Unlink Barcode* action:
|
||||
|
||||
{% with id="barcode_unlink", url="barcode/barcode_unlink.png", description="Unlink barcode" %}
|
||||
{% with id="barcode_unlink_1", url="barcode/barcode_unlink_1.png", description="Unlink barcode" %}
|
||||
{% include 'img.html' %}
|
||||
{% endwith %}
|
||||
|
||||
{% with id="barcode_unlink_2", url="barcode/barcode_unlink_2.png", description="Unlink barcode" %}
|
||||
{% include 'img.html' %}
|
||||
{% endwith %}
|
||||
|
||||
|
@ -91,6 +91,8 @@ Configuration of barcode functionality:
|
||||
{{ globalsetting("BARCODE_STORE_RESULTS") }}
|
||||
{{ globalsetting("BARCODE_RESULTS_MAX_NUM") }}
|
||||
|
||||
Read more about [barcode scanning](../barcodes/barcodes.md).
|
||||
|
||||
### Pricing and Currency
|
||||
|
||||
Configuration of pricing data and currency support:
|
||||
|
@ -1,13 +1,16 @@
|
||||
"""InvenTree API version information."""
|
||||
|
||||
# InvenTree API version
|
||||
INVENTREE_API_VERSION = 295
|
||||
INVENTREE_API_VERSION = 296
|
||||
|
||||
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
|
||||
|
||||
|
||||
INVENTREE_API_TEXT = """
|
||||
|
||||
v296 - 2024-12-25 : https://github.com/inventree/InvenTree/pull/8732
|
||||
- Adjust default "part_detail" behaviour for StockItem API endpoints
|
||||
|
||||
v295 - 2024-12-23 : https://github.com/inventree/InvenTree/pull/8746
|
||||
- Improve API documentation for build APIs
|
||||
|
||||
|
@ -135,15 +135,15 @@ class InvenTreeInternalBarcodePlugin(SettingsMixin, BarcodeMixin, InvenTreePlugi
|
||||
|
||||
def generate(self, model_instance: InvenTreeBarcodeMixin):
|
||||
"""Generate a barcode for a given model instance."""
|
||||
barcode_format = self.get_setting('INTERNAL_BARCODE_FORMAT')
|
||||
|
||||
if barcode_format == 'json':
|
||||
return json.dumps({model_instance.barcode_model_type(): model_instance.pk})
|
||||
barcode_format = self.get_setting(
|
||||
'INTERNAL_BARCODE_FORMAT', backup_value='json'
|
||||
)
|
||||
|
||||
if barcode_format == 'short':
|
||||
prefix = self.get_setting('SHORT_BARCODE_PREFIX')
|
||||
model_type_code = model_instance.barcode_model_type_code()
|
||||
|
||||
return f'{prefix}{model_type_code}{model_instance.pk}'
|
||||
|
||||
return None
|
||||
else:
|
||||
# Default = JSON format
|
||||
return json.dumps({model_instance.barcode_model_type(): model_instance.pk})
|
||||
|
@ -902,8 +902,9 @@ class StockApiMixin:
|
||||
try:
|
||||
params = self.request.query_params
|
||||
|
||||
kwargs['part_detail'] = str2bool(params.get('part_detail', True))
|
||||
|
||||
for key in [
|
||||
'part_detail',
|
||||
'path_detail',
|
||||
'location_detail',
|
||||
'supplier_part_detail',
|
||||
|
@ -11,8 +11,8 @@ export const api = axios.create({});
|
||||
* Setup default settings for the Axios API instance.
|
||||
*/
|
||||
export function setApiDefaults() {
|
||||
const host = useLocalState.getState().host;
|
||||
const token = useUserState.getState().token;
|
||||
const { host } = useLocalState.getState();
|
||||
const { token } = useUserState.getState();
|
||||
|
||||
api.defaults.baseURL = host;
|
||||
api.defaults.timeout = 2500;
|
||||
|
207
src/frontend/src/components/barcodes/BarcodeCameraInput.tsx
Normal file
@ -0,0 +1,207 @@
|
||||
import { t } from '@lingui/macro';
|
||||
import { ActionIcon, Container, Group, Select, Stack } from '@mantine/core';
|
||||
import { useDocumentVisibility, useLocalStorage } from '@mantine/hooks';
|
||||
import { showNotification } from '@mantine/notifications';
|
||||
import {
|
||||
IconCamera,
|
||||
IconPlayerPlayFilled,
|
||||
IconPlayerStopFilled,
|
||||
IconX
|
||||
} from '@tabler/icons-react';
|
||||
import { type CameraDevice, Html5Qrcode } from 'html5-qrcode';
|
||||
import { useEffect, useState } from 'react';
|
||||
import Expand from '../items/Expand';
|
||||
import type { BarcodeInputProps } from './BarcodeInput';
|
||||
|
||||
export default function BarcodeCameraInput({
|
||||
onScan
|
||||
}: Readonly<BarcodeInputProps>) {
|
||||
const [qrCodeScanner, setQrCodeScanner] = useState<Html5Qrcode | null>(null);
|
||||
const [camId, setCamId] = useLocalStorage<CameraDevice | null>({
|
||||
key: 'camId',
|
||||
defaultValue: null
|
||||
});
|
||||
const [cameras, setCameras] = useState<any[]>([]);
|
||||
const [cameraValue, setCameraValue] = useState<string | null>(null);
|
||||
const [scanningEnabled, setScanningEnabled] = useState<boolean>(false);
|
||||
const [wasAutoPaused, setWasAutoPaused] = useState<boolean>(false);
|
||||
const documentState = useDocumentVisibility();
|
||||
|
||||
let lastValue = '';
|
||||
|
||||
// Mount QR code once we are loaded
|
||||
useEffect(() => {
|
||||
setQrCodeScanner(new Html5Qrcode('reader'));
|
||||
|
||||
// load cameras
|
||||
Html5Qrcode.getCameras().then((devices) => {
|
||||
if (devices?.length) {
|
||||
setCameras(devices);
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
// set camera value from id
|
||||
useEffect(() => {
|
||||
if (camId) {
|
||||
setCameraValue(camId.id);
|
||||
}
|
||||
}, [camId]);
|
||||
|
||||
// Stop/start when leaving or reentering page
|
||||
useEffect(() => {
|
||||
if (scanningEnabled && documentState === 'hidden') {
|
||||
btnStopScanning();
|
||||
setWasAutoPaused(true);
|
||||
} else if (wasAutoPaused && documentState === 'visible') {
|
||||
btnStartScanning();
|
||||
setWasAutoPaused(false);
|
||||
}
|
||||
}, [documentState]);
|
||||
|
||||
// Scanner functions
|
||||
function onScanSuccess(decodedText: string) {
|
||||
qrCodeScanner?.pause();
|
||||
|
||||
// dedouplication
|
||||
if (decodedText === lastValue) {
|
||||
qrCodeScanner?.resume();
|
||||
return;
|
||||
}
|
||||
lastValue = decodedText;
|
||||
|
||||
// submit value upstream
|
||||
onScan?.(decodedText);
|
||||
|
||||
qrCodeScanner?.resume();
|
||||
}
|
||||
|
||||
function onScanFailure(error: string) {
|
||||
if (
|
||||
error !=
|
||||
'QR code parse error, error = NotFoundException: No MultiFormat Readers were able to detect the code.'
|
||||
) {
|
||||
console.warn(`Code scan error = ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
function btnStartScanning() {
|
||||
if (camId && qrCodeScanner && !scanningEnabled) {
|
||||
qrCodeScanner
|
||||
.start(
|
||||
camId.id,
|
||||
{ fps: 10, qrbox: { width: 250, height: 250 } },
|
||||
(decodedText) => {
|
||||
onScanSuccess(decodedText);
|
||||
},
|
||||
(errorMessage) => {
|
||||
onScanFailure(errorMessage);
|
||||
}
|
||||
)
|
||||
.catch((err: string) => {
|
||||
showNotification({
|
||||
title: t`Error while scanning`,
|
||||
message: err,
|
||||
color: 'red',
|
||||
icon: <IconX />
|
||||
});
|
||||
});
|
||||
setScanningEnabled(true);
|
||||
}
|
||||
}
|
||||
|
||||
function btnStopScanning() {
|
||||
if (qrCodeScanner && scanningEnabled) {
|
||||
qrCodeScanner.stop().catch((err: string) => {
|
||||
showNotification({
|
||||
title: t`Error while stopping`,
|
||||
message: err,
|
||||
color: 'red',
|
||||
icon: <IconX />
|
||||
});
|
||||
});
|
||||
setScanningEnabled(false);
|
||||
}
|
||||
}
|
||||
|
||||
// on value change
|
||||
useEffect(() => {
|
||||
if (cameraValue === null) return;
|
||||
if (cameraValue === camId?.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cam = cameras.find((cam) => cam.id === cameraValue);
|
||||
|
||||
// stop scanning if cam changed while scanning
|
||||
if (qrCodeScanner && scanningEnabled) {
|
||||
// stop scanning
|
||||
qrCodeScanner.stop().then(() => {
|
||||
// change ID
|
||||
setCamId(cam);
|
||||
// start scanning
|
||||
qrCodeScanner.start(
|
||||
cam.id,
|
||||
{ fps: 10, qrbox: { width: 250, height: 250 } },
|
||||
(decodedText) => {
|
||||
onScanSuccess(decodedText);
|
||||
},
|
||||
(errorMessage) => {
|
||||
onScanFailure(errorMessage);
|
||||
}
|
||||
);
|
||||
});
|
||||
} else {
|
||||
setCamId(cam);
|
||||
}
|
||||
}, [cameraValue]);
|
||||
|
||||
const placeholder = t`Start scanning by selecting a camera and pressing the play button.`;
|
||||
|
||||
return (
|
||||
<Stack gap='xs'>
|
||||
<Group gap='xs' preventGrowOverflow>
|
||||
<Expand>
|
||||
<Select
|
||||
leftSection={<IconCamera />}
|
||||
value={cameraValue}
|
||||
onChange={setCameraValue}
|
||||
data={cameras.map((device) => {
|
||||
return { value: device.id, label: device.label };
|
||||
})}
|
||||
/>
|
||||
</Expand>
|
||||
|
||||
{scanningEnabled ? (
|
||||
<ActionIcon
|
||||
size='lg'
|
||||
color='red'
|
||||
onClick={btnStopScanning}
|
||||
title={t`Stop scanning`}
|
||||
variant='transparent'
|
||||
>
|
||||
<IconPlayerStopFilled />
|
||||
</ActionIcon>
|
||||
) : (
|
||||
<ActionIcon
|
||||
size='lg'
|
||||
color='green'
|
||||
onClick={btnStartScanning}
|
||||
title={t`Start scanning`}
|
||||
disabled={!camId}
|
||||
variant='transparent'
|
||||
>
|
||||
<IconPlayerPlayFilled />
|
||||
</ActionIcon>
|
||||
)}
|
||||
</Group>
|
||||
{scanningEnabled ? (
|
||||
<Container px={0} id='reader' w={'100%'} mih='300px' />
|
||||
) : (
|
||||
<Container px={0} id='reader' w={'100%'}>
|
||||
{placeholder}
|
||||
</Container>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
123
src/frontend/src/components/barcodes/BarcodeInput.tsx
Normal file
@ -0,0 +1,123 @@
|
||||
import { t } from '@lingui/macro';
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Card,
|
||||
Divider,
|
||||
LoadingOverlay,
|
||||
SegmentedControl,
|
||||
type SegmentedControlItem,
|
||||
Stack,
|
||||
Tooltip
|
||||
} from '@mantine/core';
|
||||
import { IconCamera, IconScan } from '@tabler/icons-react';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
|
||||
import { useLocalStorage } from '@mantine/hooks';
|
||||
import { Boundary } from '../Boundary';
|
||||
import BarcodeCameraInput from './BarcodeCameraInput';
|
||||
import BarcodeKeyboardInput from './BarcodeKeyboardInput';
|
||||
|
||||
export type BarcodeInputProps = {
|
||||
onScan: (barcode: string) => void;
|
||||
processing?: boolean;
|
||||
error?: string;
|
||||
label?: string;
|
||||
actionText?: string;
|
||||
};
|
||||
|
||||
export function BarcodeInput({
|
||||
onScan,
|
||||
error,
|
||||
processing,
|
||||
label = t`Barcode`,
|
||||
actionText = t`Scan`
|
||||
}: Readonly<BarcodeInputProps>) {
|
||||
const [barcode, setBarcode] = useState<string>('');
|
||||
|
||||
const [inputType, setInputType] = useLocalStorage<string | null>({
|
||||
key: 'barcodeInputType',
|
||||
defaultValue: 'scanner'
|
||||
});
|
||||
|
||||
const scanningOptions: SegmentedControlItem[] = useMemo(() => {
|
||||
const options: SegmentedControlItem[] = [];
|
||||
|
||||
// TODO : Hide camera input optionally
|
||||
options.push({
|
||||
value: 'camera',
|
||||
label: (
|
||||
<Tooltip label={t`Camera Input`}>
|
||||
<IconCamera size={20} aria-label='barcode-input-camera' />
|
||||
</Tooltip>
|
||||
)
|
||||
});
|
||||
|
||||
options.push({
|
||||
value: 'scanner',
|
||||
label: (
|
||||
<Tooltip label={t`Scanner Input`}>
|
||||
<IconScan size={20} aria-label='barcode-input-scanner' />
|
||||
</Tooltip>
|
||||
)
|
||||
});
|
||||
|
||||
return options;
|
||||
}, []);
|
||||
|
||||
const onScanBarcode = useCallback(
|
||||
(barcode: string) => {
|
||||
setBarcode(barcode);
|
||||
onScan(barcode);
|
||||
},
|
||||
[onScan]
|
||||
);
|
||||
|
||||
const scannerInput = useMemo(() => {
|
||||
switch (inputType) {
|
||||
case 'camera':
|
||||
return <BarcodeCameraInput onScan={onScanBarcode} />;
|
||||
case 'scanner':
|
||||
default:
|
||||
return <BarcodeKeyboardInput onScan={onScanBarcode} />;
|
||||
}
|
||||
}, [inputType, onScan]);
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Boundary label='BarcodeInput'>
|
||||
<LoadingOverlay visible={processing} />
|
||||
<Stack gap='xs'>
|
||||
<SegmentedControl
|
||||
aria-label='barcode-input-type'
|
||||
size='xs'
|
||||
data={scanningOptions}
|
||||
value={inputType || 'scanner'}
|
||||
onChange={setInputType}
|
||||
/>
|
||||
<Divider />
|
||||
<Card p='sm' withBorder>
|
||||
{barcode ? (
|
||||
<Alert color='blue' title={t`Barcode Data`} p='xs'>
|
||||
{barcode}
|
||||
</Alert>
|
||||
) : (
|
||||
<Alert color='yellow' title={t`No barcode data`} p='xs'>
|
||||
{t`Scan or enter barcode data`}
|
||||
</Alert>
|
||||
)}
|
||||
{error && (
|
||||
<Alert color='red' title={t`Error`} p='xs'>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card p='sm' withBorder>
|
||||
{scannerInput}
|
||||
</Card>
|
||||
</Stack>
|
||||
</Boundary>
|
||||
</Box>
|
||||
);
|
||||
}
|
@ -0,0 +1,50 @@
|
||||
import { t } from '@lingui/macro';
|
||||
import { Button, FocusTrap, Stack, TextInput } from '@mantine/core';
|
||||
import { IconQrcode } from '@tabler/icons-react';
|
||||
import { useCallback, useState } from 'react';
|
||||
import type { BarcodeInputProps } from './BarcodeInput';
|
||||
|
||||
export default function BarcodeKeyboardInput({
|
||||
onScan,
|
||||
actionText = t`Scan`
|
||||
}: Readonly<BarcodeInputProps>) {
|
||||
const [text, setText] = useState<string>('');
|
||||
|
||||
const onTextScan = useCallback(
|
||||
(barcode: string) => {
|
||||
if (!!barcode) {
|
||||
onScan(barcode);
|
||||
}
|
||||
setText('');
|
||||
},
|
||||
[onScan]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stack gap='sm'>
|
||||
<FocusTrap active>
|
||||
<TextInput
|
||||
data-autofocus
|
||||
aria-label='barcode-scan-keyboard-input'
|
||||
value={text}
|
||||
onChange={(event) => {
|
||||
setText(event.currentTarget?.value);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.code === 'Enter') {
|
||||
onTextScan(text);
|
||||
}
|
||||
}}
|
||||
placeholder={t`Enter barcode data`}
|
||||
leftSection={<IconQrcode />}
|
||||
w='100%'
|
||||
/>
|
||||
</FocusTrap>
|
||||
<Button fullWidth disabled={!text} onClick={() => onTextScan(text)}>
|
||||
{actionText}
|
||||
</Button>
|
||||
</Stack>
|
||||
</>
|
||||
);
|
||||
}
|
98
src/frontend/src/components/barcodes/BarcodeScanDialog.tsx
Normal file
@ -0,0 +1,98 @@
|
||||
import { t } from '@lingui/macro';
|
||||
import { Box, Divider, Modal } from '@mantine/core';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { api } from '../../App';
|
||||
import { ApiEndpoints } from '../../enums/ApiEndpoints';
|
||||
import type { ModelType } from '../../enums/ModelType';
|
||||
import { extractErrorMessage } from '../../functions/api';
|
||||
import { getDetailUrl } from '../../functions/urls';
|
||||
import { apiUrl } from '../../states/ApiState';
|
||||
import { useUserState } from '../../states/UserState';
|
||||
import { StylishText } from '../items/StylishText';
|
||||
import { ModelInformationDict } from '../render/ModelType';
|
||||
import { BarcodeInput } from './BarcodeInput';
|
||||
|
||||
export default function BarcodeScanDialog({
|
||||
title,
|
||||
opened,
|
||||
onClose
|
||||
}: {
|
||||
title?: string;
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
const user = useUserState();
|
||||
|
||||
const [error, setError] = useState<string>('');
|
||||
|
||||
const [processing, setProcessing] = useState<boolean>(false);
|
||||
|
||||
const onScan = useCallback((barcode: string) => {
|
||||
if (!barcode || barcode.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
setProcessing(true);
|
||||
|
||||
api
|
||||
.post(apiUrl(ApiEndpoints.barcode), {
|
||||
barcode: barcode
|
||||
})
|
||||
.then((response) => {
|
||||
setError('');
|
||||
|
||||
const data = response.data ?? {};
|
||||
let match = false;
|
||||
|
||||
// Find the matching model type
|
||||
for (const model_type of Object.keys(ModelInformationDict)) {
|
||||
if (data[model_type]?.['pk']) {
|
||||
if (user.hasViewPermission(model_type as ModelType)) {
|
||||
const url = getDetailUrl(
|
||||
model_type as ModelType,
|
||||
data[model_type]['pk']
|
||||
);
|
||||
onClose();
|
||||
navigate(url);
|
||||
match = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!match) {
|
||||
setError(t`No matching item found`);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
const _error = extractErrorMessage({
|
||||
error: error,
|
||||
field: 'error',
|
||||
defaultMessage: t`Failed to scan barcode`
|
||||
});
|
||||
|
||||
setError(_error);
|
||||
})
|
||||
.finally(() => {
|
||||
setProcessing(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
size='lg'
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
title={<StylishText size='xl'>{title ?? t`Scan Barcode`}</StylishText>}
|
||||
>
|
||||
<Divider />
|
||||
<Box>
|
||||
<BarcodeInput onScan={onScan} error={error} processing={processing} />
|
||||
</Box>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
23
src/frontend/src/components/barcodes/BarcodeScanItem.tsx
Normal file
@ -0,0 +1,23 @@
|
||||
import type { ModelType } from '../../enums/ModelType';
|
||||
|
||||
/**
|
||||
* Interface defining a single barcode scan item
|
||||
* @param id: Unique identifier for the scan
|
||||
* @param barcode: Scanned barcode data
|
||||
* @param data: Data returned from the server
|
||||
* @param instance: Instance of the scanned item (if discovered)
|
||||
* @param timestamp: Date and time of the scan
|
||||
* @param source: Source of the scan (e.g. 'barcode', 'QR code')
|
||||
* @param model: Model type of the scanned item
|
||||
* @param pk: Primary key of the scanned item
|
||||
*/
|
||||
export interface BarcodeScanItem {
|
||||
id: string;
|
||||
barcode: string;
|
||||
data?: any;
|
||||
instance?: any;
|
||||
timestamp: Date;
|
||||
source: string;
|
||||
model?: ModelType;
|
||||
pk?: string;
|
||||
}
|
@ -15,14 +15,16 @@ import {
|
||||
import { modals } from '@mantine/modals';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import QR from 'qrcode';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { api } from '../../App';
|
||||
import { ApiEndpoints } from '../../enums/ApiEndpoints';
|
||||
import { apiUrl } from '../../states/ApiState';
|
||||
import { useGlobalSettingsState } from '../../states/SettingsState';
|
||||
import { CopyButton } from '../buttons/CopyButton';
|
||||
import type { QrCodeType } from './ActionDropdown';
|
||||
import type { QrCodeType } from '../items/ActionDropdown';
|
||||
|
||||
import { extractErrorMessage } from '../../functions/api';
|
||||
import { BarcodeInput } from './BarcodeInput';
|
||||
|
||||
type QRCodeProps = {
|
||||
@ -76,12 +78,13 @@ export const InvenTreeQRCode = ({
|
||||
const { data } = useQuery({
|
||||
queryKey: ['qr-code', mdl_prop.model, mdl_prop.pk],
|
||||
queryFn: async () => {
|
||||
const res = await api.post(apiUrl(ApiEndpoints.barcode_generate), {
|
||||
model: mdl_prop.model,
|
||||
pk: mdl_prop.pk
|
||||
});
|
||||
|
||||
return res.data?.barcode as string;
|
||||
return api
|
||||
.post(apiUrl(ApiEndpoints.barcode_generate), {
|
||||
model: mdl_prop.model,
|
||||
pk: mdl_prop.pk
|
||||
})
|
||||
.then((res) => res.data?.barcode ?? ('' as string))
|
||||
.catch((error) => '');
|
||||
}
|
||||
});
|
||||
|
||||
@ -146,37 +149,33 @@ export const InvenTreeQRCode = ({
|
||||
};
|
||||
|
||||
export const QRCodeLink = ({ mdl_prop }: { mdl_prop: QrCodeType }) => {
|
||||
const [barcode, setBarcode] = useState('');
|
||||
const [error, setError] = useState<string>('');
|
||||
|
||||
function linkBarcode(value?: string) {
|
||||
const linkBarcode = useCallback((barcode: string) => {
|
||||
api
|
||||
.post(apiUrl(ApiEndpoints.barcode_link), {
|
||||
[mdl_prop.model]: mdl_prop.pk,
|
||||
barcode: value || barcode
|
||||
barcode: barcode
|
||||
})
|
||||
.then((response) => {
|
||||
setError('');
|
||||
modals.closeAll();
|
||||
location.reload();
|
||||
})
|
||||
.catch((error) => {
|
||||
const msg = extractErrorMessage({
|
||||
error: error,
|
||||
field: 'error',
|
||||
defaultMessage: t`Failed to link barcode`
|
||||
});
|
||||
setError(msg);
|
||||
});
|
||||
}
|
||||
const actionSubmit = (decodedText: string) => {
|
||||
linkBarcode(decodedText);
|
||||
};
|
||||
|
||||
const handleLinkBarcode = () => {
|
||||
linkBarcode(barcode);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Stack gap='xs'>
|
||||
<Divider />
|
||||
<BarcodeInput
|
||||
value={barcode}
|
||||
onChange={(event) => setBarcode(event.currentTarget.value)}
|
||||
onScan={actionSubmit}
|
||||
onAction={handleLinkBarcode}
|
||||
actionText={t`Link`}
|
||||
/>
|
||||
<BarcodeInput onScan={linkBarcode} actionText={t`Link`} error={error} />
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@ -194,11 +193,12 @@ export const QRCodeUnlink = ({ mdl_prop }: { mdl_prop: QrCodeType }) => {
|
||||
}
|
||||
return (
|
||||
<Box>
|
||||
<Stack gap='xs'>
|
||||
<Stack gap='sm'>
|
||||
<Divider />
|
||||
<Text>
|
||||
<Trans>This will remove the link to the associated barcode</Trans>
|
||||
</Text>
|
||||
<Divider />
|
||||
<Group grow>
|
||||
<Button color='red' onClick={unlinkBarcode}>
|
||||
<Trans>Unlink Barcode</Trans>
|
@ -1,25 +1,25 @@
|
||||
import { t } from '@lingui/macro';
|
||||
import { ActionIcon } from '@mantine/core';
|
||||
import { openContextModal } from '@mantine/modals';
|
||||
import { useDisclosure } from '@mantine/hooks';
|
||||
import { IconQrcode } from '@tabler/icons-react';
|
||||
import BarcodeScanDialog from '../barcodes/BarcodeScanDialog';
|
||||
|
||||
/**
|
||||
* A button which opens the QR code scanner modal
|
||||
*/
|
||||
export function ScanButton() {
|
||||
const [opened, { open, close }] = useDisclosure(false);
|
||||
|
||||
return (
|
||||
<ActionIcon
|
||||
onClick={() =>
|
||||
openContextModal({
|
||||
modal: 'qr',
|
||||
title: t`Scan Barcode`,
|
||||
innerProps: {}
|
||||
})
|
||||
}
|
||||
variant='transparent'
|
||||
title={t`Open Barcode Scanner`}
|
||||
>
|
||||
<IconQrcode />
|
||||
</ActionIcon>
|
||||
<>
|
||||
<ActionIcon
|
||||
onClick={open}
|
||||
variant='transparent'
|
||||
title={t`Open Barcode Scanner`}
|
||||
>
|
||||
<IconQrcode />
|
||||
</ActionIcon>
|
||||
<BarcodeScanDialog opened={opened} onClose={close} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
@ -22,7 +22,8 @@ import { type ReactNode, useMemo } from 'react';
|
||||
import type { ModelType } from '../../enums/ModelType';
|
||||
import { identifierString } from '../../functions/conversion';
|
||||
import { InvenTreeIcon } from '../../functions/icons';
|
||||
import { InvenTreeQRCode, QRCodeLink, QRCodeUnlink } from './QRCode';
|
||||
import { InvenTreeQRCode, QRCodeLink, QRCodeUnlink } from '../barcodes/QRCode';
|
||||
import { StylishText } from './StylishText';
|
||||
|
||||
export type ActionDropdownItem = {
|
||||
icon?: ReactNode;
|
||||
@ -213,7 +214,7 @@ function GeneralBarcodeAction({
|
||||
}): ActionDropdownItem {
|
||||
const onClick = () => {
|
||||
modals.open({
|
||||
title: title,
|
||||
title: <StylishText size='xl'>{title}</StylishText>,
|
||||
children: <ChildItem mdl_prop={mdl_prop} />
|
||||
});
|
||||
};
|
||||
|
@ -1,60 +0,0 @@
|
||||
import { t } from '@lingui/macro';
|
||||
import { ActionIcon, Box, Button, Divider, TextInput } from '@mantine/core';
|
||||
import { IconQrcode } from '@tabler/icons-react';
|
||||
import type React from 'react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { InputImageBarcode } from '../../pages/Index/Scan';
|
||||
|
||||
type BarcodeInputProps = {
|
||||
onScan: (decodedText: string) => void;
|
||||
value?: string;
|
||||
onChange?: (event: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
onAction?: () => void;
|
||||
placeholder?: string;
|
||||
label?: string;
|
||||
actionText?: string;
|
||||
};
|
||||
|
||||
export function BarcodeInput({
|
||||
onScan,
|
||||
value,
|
||||
onChange,
|
||||
onAction,
|
||||
placeholder = t`Scan barcode data here using barcode scanner`,
|
||||
label = t`Barcode`,
|
||||
actionText = t`Scan`
|
||||
}: Readonly<BarcodeInputProps>) {
|
||||
const [isScanning, setIsScanning] = useState(false);
|
||||
|
||||
return (
|
||||
<Box>
|
||||
{isScanning && (
|
||||
<>
|
||||
<InputImageBarcode action={onScan} />
|
||||
<Divider mt={'sm'} />
|
||||
</>
|
||||
)}
|
||||
<TextInput
|
||||
label={label}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
placeholder={placeholder}
|
||||
leftSection={
|
||||
<ActionIcon
|
||||
variant={isScanning ? 'filled' : 'subtle'}
|
||||
onClick={() => setIsScanning(!isScanning)}
|
||||
>
|
||||
<IconQrcode />
|
||||
</ActionIcon>
|
||||
}
|
||||
w='100%'
|
||||
/>
|
||||
{onAction ? (
|
||||
<Button color='green' onClick={onAction} mt='lg' fullWidth>
|
||||
{actionText}
|
||||
</Button>
|
||||
) : null}
|
||||
</Box>
|
||||
);
|
||||
}
|
@ -1,61 +0,0 @@
|
||||
import { Trans, t } from '@lingui/macro';
|
||||
import { Button, ScrollArea, Stack, Text } from '@mantine/core';
|
||||
import { useListState } from '@mantine/hooks';
|
||||
import type { ContextModalProps } from '@mantine/modals';
|
||||
import { showNotification } from '@mantine/notifications';
|
||||
|
||||
import { api } from '../../App';
|
||||
import { ApiEndpoints } from '../../enums/ApiEndpoints';
|
||||
import { apiUrl } from '../../states/ApiState';
|
||||
import { BarcodeInput } from '../items/BarcodeInput';
|
||||
|
||||
export function QrCodeModal({
|
||||
context,
|
||||
id
|
||||
}: Readonly<ContextModalProps<{ modalBody: string }>>) {
|
||||
const [values, handlers] = useListState<string>([]);
|
||||
|
||||
function onScanAction(decodedText: string) {
|
||||
handlers.append(decodedText);
|
||||
api
|
||||
.post(apiUrl(ApiEndpoints.barcode), { barcode: decodedText })
|
||||
.then((response) => {
|
||||
showNotification({
|
||||
title: response.data?.success || t`Unknown response`,
|
||||
message: JSON.stringify(response.data),
|
||||
color: response.data?.success ? 'teal' : 'red'
|
||||
});
|
||||
if (response.data?.url) {
|
||||
window.location.href = response.data.url;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap='xs'>
|
||||
<BarcodeInput onScan={onScanAction} />
|
||||
{values.length == 0 ? (
|
||||
<Text c={'grey'}>
|
||||
<Trans>No scans yet!</Trans>
|
||||
</Text>
|
||||
) : (
|
||||
<ScrollArea style={{ height: 200 }} type='auto' offsetScrollbars>
|
||||
{values.map((value, index) => (
|
||||
<div key={`${index}-${value}`}>{value}</div>
|
||||
))}
|
||||
</ScrollArea>
|
||||
)}
|
||||
<Button
|
||||
fullWidth
|
||||
mt='md'
|
||||
color='red'
|
||||
onClick={() => {
|
||||
// stopScanning();
|
||||
context.closeModal(id);
|
||||
}}
|
||||
>
|
||||
<Trans>Close modal</Trans>
|
||||
</Button>
|
||||
</Stack>
|
||||
);
|
||||
}
|
@ -6,6 +6,7 @@ import { type ReactNode, useCallback } from 'react';
|
||||
import { api } from '../../App';
|
||||
import { ModelType } from '../../enums/ModelType';
|
||||
import { navigateToLink } from '../../functions/navigation';
|
||||
import { shortenString } from '../../functions/tables';
|
||||
import { apiUrl } from '../../states/ApiState';
|
||||
import { Thumbnail } from '../images/Thumbnail';
|
||||
import { RenderBuildItem, RenderBuildLine, RenderBuildOrder } from './Build';
|
||||
@ -193,6 +194,16 @@ export function RenderInlineModel({
|
||||
[url, navigate]
|
||||
);
|
||||
|
||||
const primaryText = shortenString({
|
||||
str: primary,
|
||||
len: 50
|
||||
});
|
||||
|
||||
const secondaryText = shortenString({
|
||||
str: secondary,
|
||||
len: 75
|
||||
});
|
||||
|
||||
return (
|
||||
<Group gap='xs' justify='space-between' wrap='nowrap' title={tooltip}>
|
||||
<Group gap='xs' justify='left' wrap='nowrap'>
|
||||
@ -200,12 +211,12 @@ export function RenderInlineModel({
|
||||
{image && <Thumbnail src={image} size={18} />}
|
||||
{url ? (
|
||||
<Anchor href='' onClick={(event: any) => onClick(event)}>
|
||||
<Text size='sm'>{primary}</Text>
|
||||
<Text size='sm'>{primaryText}</Text>
|
||||
</Anchor>
|
||||
) : (
|
||||
<Text size='sm'>{primary}</Text>
|
||||
<Text size='sm'>{primaryText}</Text>
|
||||
)}
|
||||
{showSecondary && secondary && <Text size='xs'>{secondary}</Text>}
|
||||
{showSecondary && secondary && <Text size='xs'>{secondaryText}</Text>}
|
||||
</Group>
|
||||
{suffix && (
|
||||
<>
|
||||
|
@ -67,7 +67,7 @@ export function RenderStockItem(
|
||||
<RenderInlineModel
|
||||
{...props}
|
||||
primary={instance.part_detail?.full_name}
|
||||
suffix={<Text size='sm'>{quantity_string}</Text>}
|
||||
suffix={<Text size='xs'>{quantity_string}</Text>}
|
||||
image={instance.part_detail?.thumbnail || instance.part_detail?.image}
|
||||
url={
|
||||
props.link ? getDetailUrl(ModelType.stockitem, instance.pk) : undefined
|
||||
|
@ -6,7 +6,6 @@ import { ContextMenuProvider } from 'mantine-contextmenu';
|
||||
|
||||
import { AboutInvenTreeModal } from '../components/modals/AboutInvenTreeModal';
|
||||
import { LicenseModal } from '../components/modals/LicenseModal';
|
||||
import { QrCodeModal } from '../components/modals/QrCodeModal';
|
||||
import { ServerInfoModal } from '../components/modals/ServerInfoModal';
|
||||
import { useLocalState } from '../states/LocalState';
|
||||
import { LanguageContext } from './LanguageContext';
|
||||
@ -46,7 +45,6 @@ export function ThemeContext({
|
||||
<ModalsProvider
|
||||
labels={{ confirm: t`Submit`, cancel: t`Cancel` }}
|
||||
modals={{
|
||||
qr: QrCodeModal,
|
||||
info: ServerInfoModal,
|
||||
about: AboutInvenTreeModal,
|
||||
license: LicenseModal
|
||||
|
61
src/frontend/src/functions/api.tsx
Normal file
@ -0,0 +1,61 @@
|
||||
import { t } from '@lingui/macro';
|
||||
|
||||
/**
|
||||
* Extract a sensible error message from an API error response
|
||||
* @param error The error response from the API
|
||||
* @param field The field to extract the error message from (optional)
|
||||
* @param defaultMessage A default message to use if no error message is found (optional)
|
||||
*/
|
||||
export function extractErrorMessage({
|
||||
error,
|
||||
field,
|
||||
defaultMessage
|
||||
}: {
|
||||
error: any;
|
||||
field?: string;
|
||||
defaultMessage?: string;
|
||||
}): string {
|
||||
const error_data = error.response?.data ?? null;
|
||||
|
||||
let message = '';
|
||||
|
||||
if (error_data) {
|
||||
message = error_data[field ?? 'error'] ?? error_data['non_field_errors'];
|
||||
}
|
||||
|
||||
// No message? Look at the response status codes
|
||||
if (!message) {
|
||||
const status = error.response?.status ?? null;
|
||||
|
||||
if (status) {
|
||||
switch (status) {
|
||||
case 400:
|
||||
message = t`Bad request`;
|
||||
break;
|
||||
case 401:
|
||||
message = t`Unauthorized`;
|
||||
break;
|
||||
case 403:
|
||||
message = t`Forbidden`;
|
||||
break;
|
||||
case 404:
|
||||
message = t`Not found`;
|
||||
break;
|
||||
case 405:
|
||||
message = t`Method not allowed`;
|
||||
break;
|
||||
case 500:
|
||||
message = t`Internal server error`;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!message) {
|
||||
message = defaultMessage ?? t`An error occurred`;
|
||||
}
|
||||
|
||||
return message;
|
||||
}
|
@ -1,6 +1,7 @@
|
||||
import { t } from '@lingui/macro';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import { IconCircleCheck, IconExclamationCircle } from '@tabler/icons-react';
|
||||
import { extractErrorMessage } from './api';
|
||||
|
||||
/**
|
||||
* Show a notification that the feature is not yet implemented
|
||||
@ -86,19 +87,15 @@ export function showApiErrorMessage({
|
||||
message?: string;
|
||||
field?: string;
|
||||
}) {
|
||||
// Extract error description from response
|
||||
const error_data: any = error.response?.data ?? {};
|
||||
|
||||
let error_msg: any =
|
||||
message ?? error_data[field ?? 'error'] ?? error_data['non_field_errors'];
|
||||
|
||||
if (!error_msg) {
|
||||
error_msg = t`An error occurred`;
|
||||
}
|
||||
const errorMessage = extractErrorMessage({
|
||||
error: error,
|
||||
field: field,
|
||||
defaultMessage: message
|
||||
});
|
||||
|
||||
notifications.show({
|
||||
title: title,
|
||||
message: error_msg,
|
||||
message: errorMessage,
|
||||
color: 'red'
|
||||
});
|
||||
}
|
||||
|
@ -38,6 +38,7 @@ import type { TableFilter } from '../tables/Filter';
|
||||
* records: An array of records (rows) in the table
|
||||
* setRecords: A function to set the records
|
||||
* updateRecord: A function to update a single record in the table
|
||||
* idAccessor: The name of the primary key field in the records (default = 'pk')
|
||||
*/
|
||||
export type TableState = {
|
||||
tableKey: string;
|
||||
@ -54,7 +55,7 @@ export type TableState = {
|
||||
setExpandedRecords: (records: any[]) => void;
|
||||
isRowExpanded: (pk: number) => boolean;
|
||||
selectedRecords: any[];
|
||||
selectedIds: number[];
|
||||
selectedIds: any[];
|
||||
hasSelectedRecords: boolean;
|
||||
setSelectedRecords: (records: any[]) => void;
|
||||
clearSelectedRecords: () => void;
|
||||
@ -71,6 +72,7 @@ export type TableState = {
|
||||
records: any[];
|
||||
setRecords: (records: any[]) => void;
|
||||
updateRecord: (record: any) => void;
|
||||
idAccessor?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
@ -79,7 +81,7 @@ export type TableState = {
|
||||
* Refer to the TableState type definition for more information.
|
||||
*/
|
||||
|
||||
export function useTable(tableName: string): TableState {
|
||||
export function useTable(tableName: string, idAccessor = 'pk'): TableState {
|
||||
// Function to generate a new ID (to refresh the table)
|
||||
function generateTableName() {
|
||||
return `${tableName.replaceAll('-', '')}-${randomId()}`;
|
||||
@ -127,7 +129,7 @@ export function useTable(tableName: string): TableState {
|
||||
|
||||
// Array of selected primary key values
|
||||
const selectedIds = useMemo(
|
||||
() => selectedRecords.map((r) => r.pk ?? r.id),
|
||||
() => selectedRecords.map((r) => r[idAccessor || 'pk']),
|
||||
[selectedRecords]
|
||||
);
|
||||
|
||||
@ -164,7 +166,9 @@ export function useTable(tableName: string): TableState {
|
||||
const _records = [...records];
|
||||
|
||||
// Find the matching record in the table
|
||||
const index = _records.findIndex((r) => r.pk === record.pk);
|
||||
const index = _records.findIndex(
|
||||
(r) => r[idAccessor || 'pk'] === record.pk
|
||||
);
|
||||
|
||||
if (index >= 0) {
|
||||
_records[index] = {
|
||||
@ -213,6 +217,7 @@ export function useTable(tableName: string): TableState {
|
||||
setPageSize,
|
||||
records,
|
||||
setRecords,
|
||||
updateRecord
|
||||
updateRecord,
|
||||
idAccessor
|
||||
};
|
||||
}
|
||||
|
@ -1,205 +1,134 @@
|
||||
import { Trans, t } from '@lingui/macro';
|
||||
import {
|
||||
ActionIcon,
|
||||
Badge,
|
||||
Button,
|
||||
Checkbox,
|
||||
Container,
|
||||
Alert,
|
||||
Divider,
|
||||
Grid,
|
||||
Group,
|
||||
ScrollArea,
|
||||
Select,
|
||||
Paper,
|
||||
Space,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
TextInput,
|
||||
rem
|
||||
Text
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
getHotkeyHandler,
|
||||
randomId,
|
||||
useDocumentVisibility,
|
||||
useFullscreen,
|
||||
useListState,
|
||||
useLocalStorage
|
||||
} from '@mantine/hooks';
|
||||
import { showNotification } from '@mantine/notifications';
|
||||
import { randomId, useListState, useLocalStorage } from '@mantine/hooks';
|
||||
import {
|
||||
IconAlertCircle,
|
||||
IconArrowsMaximize,
|
||||
IconArrowsMinimize,
|
||||
IconLink,
|
||||
IconNumber,
|
||||
IconPlayerPlayFilled,
|
||||
IconPlayerStopFilled,
|
||||
IconPlus,
|
||||
IconQuestionMark,
|
||||
IconSearch,
|
||||
IconTrash,
|
||||
IconX
|
||||
IconQuestionMark
|
||||
} from '@tabler/icons-react';
|
||||
import { Html5Qrcode } from 'html5-qrcode';
|
||||
import type { CameraDevice } from 'html5-qrcode/camera/core';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { showNotification } from '@mantine/notifications';
|
||||
import { api } from '../../App';
|
||||
import { DocInfo } from '../../components/items/DocInfo';
|
||||
import { BarcodeInput } from '../../components/barcodes/BarcodeInput';
|
||||
import type { BarcodeScanItem } from '../../components/barcodes/BarcodeScanItem';
|
||||
import { StylishText } from '../../components/items/StylishText';
|
||||
import { TitleWithDoc } from '../../components/items/TitleWithDoc';
|
||||
import PageTitle from '../../components/nav/PageTitle';
|
||||
import { RenderInstance } from '../../components/render/Instance';
|
||||
import { ModelInformationDict } from '../../components/render/ModelType';
|
||||
import { ApiEndpoints } from '../../enums/ApiEndpoints';
|
||||
import { ModelType } from '../../enums/ModelType';
|
||||
import { notYetImplemented } from '../../functions/notifications';
|
||||
import { IS_DEV_OR_DEMO } from '../../main';
|
||||
import type { ModelType } from '../../enums/ModelType';
|
||||
import {
|
||||
notYetImplemented,
|
||||
showApiErrorMessage
|
||||
} from '../../functions/notifications';
|
||||
import { apiUrl } from '../../states/ApiState';
|
||||
|
||||
export interface ScanItem {
|
||||
id: string;
|
||||
ref: string;
|
||||
data: any;
|
||||
instance?: any;
|
||||
timestamp: Date;
|
||||
source: string;
|
||||
link?: string;
|
||||
model?: ModelType;
|
||||
pk?: string;
|
||||
}
|
||||
|
||||
/*
|
||||
* Match the scanned object to a known internal model type
|
||||
*/
|
||||
function matchObject(rd: any): [ModelType | undefined, string | undefined] {
|
||||
if (rd?.part) {
|
||||
return [ModelType.part, rd?.part.pk];
|
||||
} else if (rd?.stockitem) {
|
||||
return [ModelType.stockitem, rd?.stockitem.pk];
|
||||
} else if (rd?.stocklocation) {
|
||||
return [ModelType.stocklocation, rd?.stocklocation.pk];
|
||||
} else if (rd?.supplierpart) {
|
||||
return [ModelType.supplierpart, rd?.supplierpart.pk];
|
||||
} else if (rd?.purchaseorder) {
|
||||
return [ModelType.purchaseorder, rd?.purchaseorder.pk];
|
||||
} else if (rd?.salesorder) {
|
||||
return [ModelType.salesorder, rd?.salesorder.pk];
|
||||
} else if (rd?.build) {
|
||||
return [ModelType.build, rd?.build.pk];
|
||||
} else {
|
||||
return [undefined, undefined];
|
||||
}
|
||||
}
|
||||
import BarcodeScanTable from '../../tables/general/BarcodeScanTable';
|
||||
|
||||
export default function Scan() {
|
||||
const { toggle: toggleFullscreen, fullscreen } = useFullscreen();
|
||||
const [history, historyHandlers] = useListState<ScanItem>([]);
|
||||
const [historyStorage, setHistoryStorage] = useLocalStorage<ScanItem[]>({
|
||||
const [history, historyHandlers] = useListState<BarcodeScanItem>([]);
|
||||
|
||||
const [historyStorage, setHistoryStorage] = useLocalStorage<
|
||||
BarcodeScanItem[]
|
||||
>({
|
||||
key: 'scan-history',
|
||||
defaultValue: []
|
||||
});
|
||||
|
||||
const [selection, setSelection] = useState<string[]>([]);
|
||||
const [inputValue, setInputValue] = useLocalStorage<string | null>({
|
||||
key: 'input-selection',
|
||||
defaultValue: null
|
||||
});
|
||||
|
||||
// button handlers
|
||||
function btnRunSelectedBarcode() {
|
||||
const item = getSelectedItem(selection[0]);
|
||||
if (!item) return;
|
||||
runBarcode(item?.ref, item?.id);
|
||||
}
|
||||
// Fetch model instance based on scan item
|
||||
const fetchInstance = useCallback(
|
||||
(item: BarcodeScanItem) => {
|
||||
if (!item.model || !item.pk) {
|
||||
return;
|
||||
}
|
||||
|
||||
const selectionLinked =
|
||||
selection.length === 1 && getSelectedItem(selection[0])?.link != undefined;
|
||||
// Prevent duplicates
|
||||
if (history.find((i) => i.model == item.model && i.pk == item.pk)) {
|
||||
showNotification({
|
||||
label: t`Duplicate`,
|
||||
message: t`Item already scanned`,
|
||||
color: 'orange'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
function btnOpenSelectedLink() {
|
||||
const item = getSelectedItem(selection[0]);
|
||||
if (!item) return;
|
||||
if (!selectionLinked) return;
|
||||
window.open(item.link, '_blank');
|
||||
}
|
||||
const model_info = ModelInformationDict[item.model];
|
||||
|
||||
function btnDeleteFullHistory() {
|
||||
historyHandlers.setState([]);
|
||||
setHistoryStorage([]);
|
||||
setSelection([]);
|
||||
}
|
||||
api
|
||||
.get(apiUrl(model_info.api_endpoint, item.pk))
|
||||
.then((response) => {
|
||||
item.instance = response.data;
|
||||
historyHandlers.append(item);
|
||||
})
|
||||
.catch((error) => {
|
||||
showApiErrorMessage({
|
||||
error: error,
|
||||
title: t`API Error`,
|
||||
message: t`Failed to fetch instance data`
|
||||
});
|
||||
});
|
||||
},
|
||||
[api, history]
|
||||
);
|
||||
|
||||
function btnDeleteHistory() {
|
||||
historyHandlers.setState(
|
||||
history.filter((item) => !selection.includes(item.id))
|
||||
);
|
||||
setSelection([]);
|
||||
}
|
||||
// Barcode scanning callback function
|
||||
const scanBarcode = useCallback(
|
||||
(barcode: string) => {
|
||||
api
|
||||
.post(apiUrl(ApiEndpoints.barcode), { barcode: barcode })
|
||||
.then((response) => {
|
||||
const data = response?.data ?? {};
|
||||
|
||||
// general functions
|
||||
function getSelectedItem(ref: string): ScanItem | undefined {
|
||||
if (selection.length === 0) return;
|
||||
const item = history.find((item) => item.id === ref);
|
||||
if (item?.ref === undefined) return;
|
||||
return item;
|
||||
}
|
||||
let match = false;
|
||||
|
||||
function runBarcode(value: string, id?: string) {
|
||||
api
|
||||
.post(apiUrl(ApiEndpoints.barcode), { barcode: value })
|
||||
.then((response) => {
|
||||
// update item in history
|
||||
if (!id) return;
|
||||
const item = getSelectedItem(selection[0]);
|
||||
if (!item) return;
|
||||
|
||||
// set link data
|
||||
item.link = response.data?.url;
|
||||
|
||||
const rsp = matchObject(response.data);
|
||||
item.model = rsp[0];
|
||||
item.pk = rsp[1];
|
||||
|
||||
// Fetch instance data
|
||||
if (item.model && item.pk) {
|
||||
const model_info = ModelInformationDict[item.model];
|
||||
|
||||
if (model_info?.api_endpoint) {
|
||||
const url = apiUrl(model_info.api_endpoint, item.pk);
|
||||
|
||||
api
|
||||
.get(url)
|
||||
.then((response) => {
|
||||
item.instance = response.data;
|
||||
const list_idx = history.findIndex((i) => i.id === id);
|
||||
historyHandlers.setItem(list_idx, item);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('error while fetching instance data at', url);
|
||||
console.info(err);
|
||||
for (const model_type of Object.keys(ModelInformationDict)) {
|
||||
if (data[model_type]?.pk) {
|
||||
match = true;
|
||||
fetchInstance({
|
||||
id: randomId(),
|
||||
barcode: barcode,
|
||||
data: data,
|
||||
timestamp: new Date(),
|
||||
source: 'scan',
|
||||
model: model_type as ModelType,
|
||||
pk: data[model_type]?.pk
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
historyHandlers.setState(history);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
// 400 and no plugin means no match
|
||||
if (
|
||||
err.response?.status === 400 &&
|
||||
err.response?.data?.plugin === 'None'
|
||||
)
|
||||
return;
|
||||
// otherwise log error
|
||||
console.log('error while running barcode', err);
|
||||
});
|
||||
}
|
||||
|
||||
function addItems(items: ScanItem[]) {
|
||||
for (const item of items) {
|
||||
historyHandlers.append(item);
|
||||
runBarcode(item.ref, item.id);
|
||||
}
|
||||
setSelection(items.map((item) => item.id));
|
||||
}
|
||||
// If no match is found, add an empty result
|
||||
if (!match) {
|
||||
historyHandlers.append({
|
||||
id: randomId(),
|
||||
barcode: barcode,
|
||||
data: data,
|
||||
timestamp: new Date(),
|
||||
source: 'scan'
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
showApiErrorMessage({
|
||||
error: error,
|
||||
message: t`Failed to scan barcode`,
|
||||
title: t`Scan Error`,
|
||||
field: 'error'
|
||||
});
|
||||
});
|
||||
},
|
||||
[fetchInstance]
|
||||
);
|
||||
|
||||
// save history data to session storage
|
||||
useEffect(() => {
|
||||
@ -208,61 +137,27 @@ export default function Scan() {
|
||||
}, [history]);
|
||||
|
||||
// load data from session storage on mount
|
||||
if (history.length === 0 && historyStorage.length != 0) {
|
||||
useEffect(() => {
|
||||
historyHandlers.setState(historyStorage);
|
||||
}
|
||||
}, [historyStorage]);
|
||||
|
||||
// input stuff
|
||||
const inputOptions = [
|
||||
{ value: InputMethod.Manual, label: t`Manual input` },
|
||||
{ value: InputMethod.ImageBarcode, label: t`Image Barcode` }
|
||||
];
|
||||
|
||||
const inp = (() => {
|
||||
switch (inputValue) {
|
||||
case InputMethod.Manual:
|
||||
return <InputManual action={addItems} />;
|
||||
case InputMethod.ImageBarcode:
|
||||
return (
|
||||
<InputImageBarcode
|
||||
action={(decodedText: string) => {
|
||||
addItems([
|
||||
{
|
||||
id: randomId(),
|
||||
ref: decodedText,
|
||||
data: decodedText,
|
||||
timestamp: new Date(),
|
||||
source: InputMethod.ImageBarcode
|
||||
}
|
||||
]);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return <Text>No input selected</Text>;
|
||||
}
|
||||
})();
|
||||
// Items selected for action
|
||||
const selectedItems: BarcodeScanItem[] = useMemo(() => {
|
||||
return history.filter((item) => selection.includes(item.id));
|
||||
}, [selection, history]);
|
||||
|
||||
// selected actions component
|
||||
const SelectedActions = () => {
|
||||
const uniqueObjectTypes = [
|
||||
...new Set(
|
||||
selection
|
||||
.map((id) => {
|
||||
return history.find((item) => item.id === id)?.model;
|
||||
})
|
||||
.filter((item) => item != undefined)
|
||||
)
|
||||
];
|
||||
const SelectedActions = useMemo(() => {
|
||||
const uniqueObjectTypes = new Set(selectedItems.map((item) => item.model));
|
||||
|
||||
if (uniqueObjectTypes.length === 0) {
|
||||
if (uniqueObjectTypes.size === 0) {
|
||||
return (
|
||||
<Group gap={0}>
|
||||
<IconQuestionMark color='orange' />
|
||||
<Trans>Selected elements are not known</Trans>
|
||||
</Group>
|
||||
);
|
||||
} else if (uniqueObjectTypes.length > 1) {
|
||||
} else if (uniqueObjectTypes.size > 1) {
|
||||
return (
|
||||
<Group gap={0}>
|
||||
<IconAlertCircle color='orange' />
|
||||
@ -270,10 +165,11 @@ export default function Scan() {
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Text fz='sm' c='dimmed'>
|
||||
<Trans>Actions for {uniqueObjectTypes[0]} </Trans>
|
||||
<Trans>Actions ... </Trans>
|
||||
</Text>
|
||||
<Group>
|
||||
<ActionIcon
|
||||
@ -286,499 +182,72 @@ export default function Scan() {
|
||||
</Group>
|
||||
</>
|
||||
);
|
||||
};
|
||||
}, [selectedItems]);
|
||||
|
||||
// rendering
|
||||
return (
|
||||
<>
|
||||
<PageTitle title={t`Barcode Scanning`} />
|
||||
<Group justify='space-between'>
|
||||
<Group justify='left'>
|
||||
<StylishText>
|
||||
<Trans>Scan Page</Trans>
|
||||
<StylishText size='xl'>
|
||||
<Trans>Barcode Scanning</Trans>
|
||||
</StylishText>
|
||||
<DocInfo
|
||||
text={t`This page can be used for continuously scanning items and taking actions on them.`}
|
||||
/>
|
||||
</Group>
|
||||
<Button
|
||||
onClick={toggleFullscreen}
|
||||
size='sm'
|
||||
variant='subtle'
|
||||
title={t`Toggle Fullscreen`}
|
||||
>
|
||||
{fullscreen ? <IconArrowsMaximize /> : <IconArrowsMinimize />}
|
||||
</Button>
|
||||
</Group>
|
||||
<Space h={'md'} />
|
||||
<Grid maw={'100%'}>
|
||||
<Grid.Col span={4}>
|
||||
<Stack>
|
||||
<Paper p='sm' shadow='xs'>
|
||||
<Stack gap='xs'>
|
||||
<Group justify='space-between'>
|
||||
<TitleWithDoc
|
||||
order={3}
|
||||
text={t`Select the input method you want to use to scan items.`}
|
||||
>
|
||||
<Trans>Input</Trans>
|
||||
</TitleWithDoc>
|
||||
<Select
|
||||
value={inputValue}
|
||||
onChange={setInputValue}
|
||||
data={inputOptions}
|
||||
searchable
|
||||
placeholder={t`Select input method`}
|
||||
nothingFoundMessage={t`Nothing found`}
|
||||
/>
|
||||
</Group>
|
||||
{inp}
|
||||
<StylishText size='lg'>{t`Barcode Input`}</StylishText>
|
||||
<Divider />
|
||||
<BarcodeInput onScan={scanBarcode} />
|
||||
</Stack>
|
||||
<Stack gap={0}>
|
||||
<TitleWithDoc
|
||||
order={3}
|
||||
text={t`Depending on the selected parts actions will be shown here. Not all barcode types are supported currently.`}
|
||||
>
|
||||
<Trans>Action</Trans>
|
||||
</TitleWithDoc>
|
||||
</Paper>
|
||||
<Paper p='sm' shadow='xs'>
|
||||
<Stack gap='xs'>
|
||||
<StylishText size='lg'>{t`Action`}</StylishText>
|
||||
<Divider />
|
||||
{selection.length === 0 ? (
|
||||
<Text>
|
||||
<Trans>No selection</Trans>
|
||||
</Text>
|
||||
<Alert title={t`No Items Selected`} color='blue'>
|
||||
<Trans>Scan and select items to perform actions</Trans>
|
||||
</Alert>
|
||||
) : (
|
||||
<>
|
||||
<Text>
|
||||
<Trans>{selection.length} items selected</Trans>
|
||||
</Text>
|
||||
<Text fz='sm' c='dimmed'>
|
||||
<Trans>General Actions</Trans>
|
||||
</Text>
|
||||
<Group>
|
||||
<ActionIcon
|
||||
color='red'
|
||||
onClick={btnDeleteHistory}
|
||||
title={t`Delete`}
|
||||
variant='default'
|
||||
>
|
||||
<IconTrash />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
onClick={btnRunSelectedBarcode}
|
||||
disabled={selection.length > 1}
|
||||
title={t`Lookup part`}
|
||||
variant='default'
|
||||
>
|
||||
<IconSearch />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
onClick={btnOpenSelectedLink}
|
||||
disabled={!selectionLinked}
|
||||
title={t`Open Link`}
|
||||
variant='default'
|
||||
>
|
||||
<IconLink />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
<SelectedActions />
|
||||
{SelectedActions}
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={8}>
|
||||
<Group justify='space-between'>
|
||||
<TitleWithDoc
|
||||
order={3}
|
||||
text={t`History is locally kept in this browser.`}
|
||||
detail={t`The history is kept in this browser's local storage. So it won't be shared with other users or other devices but is persistent through reloads. You can select items in the history to perform actions on them. To add items, scan/enter them in the Input area.`}
|
||||
>
|
||||
<Trans>History</Trans>
|
||||
</TitleWithDoc>
|
||||
<ActionIcon
|
||||
color='red'
|
||||
onClick={btnDeleteFullHistory}
|
||||
variant='default'
|
||||
title={t`Delete History`}
|
||||
>
|
||||
<IconTrash />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
<HistoryTable
|
||||
data={history}
|
||||
selection={selection}
|
||||
setSelection={setSelection}
|
||||
/>
|
||||
<Paper p='sm' shadow='xs'>
|
||||
<Stack gap='xs'>
|
||||
<Group justify='space-between'>
|
||||
<StylishText size='lg'>{t`Scanned Items`}</StylishText>
|
||||
</Group>
|
||||
<Divider />
|
||||
<BarcodeScanTable
|
||||
records={history}
|
||||
onItemsSelected={(ids: string[]) => {
|
||||
setSelection(ids);
|
||||
}}
|
||||
onItemsDeleted={(ids: string[]) => {
|
||||
const newHistory = history.filter(
|
||||
(item) => !ids.includes(item.id)
|
||||
);
|
||||
|
||||
historyHandlers.setState(newHistory);
|
||||
setHistoryStorage(newHistory);
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function HistoryTable({
|
||||
data,
|
||||
selection,
|
||||
setSelection
|
||||
}: Readonly<{
|
||||
data: ScanItem[];
|
||||
selection: string[];
|
||||
setSelection: React.Dispatch<React.SetStateAction<string[]>>;
|
||||
}>) {
|
||||
const toggleRow = (id: string) =>
|
||||
setSelection((current) =>
|
||||
current.includes(id)
|
||||
? current.filter((item) => item !== id)
|
||||
: [...current, id]
|
||||
);
|
||||
const toggleAll = () =>
|
||||
setSelection((current) =>
|
||||
current.length === data.length ? [] : data.map((item) => item.id)
|
||||
);
|
||||
|
||||
const rows = useMemo(() => {
|
||||
return data.map((item) => {
|
||||
return (
|
||||
<tr key={item.id}>
|
||||
<td>
|
||||
<Checkbox
|
||||
checked={selection.includes(item.id)}
|
||||
onChange={() => toggleRow(item.id)}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
{item.pk && item.model && item.instance ? (
|
||||
<RenderInstance model={item.model} instance={item.instance} />
|
||||
) : (
|
||||
item.ref
|
||||
)}
|
||||
</td>
|
||||
<td>{item.model}</td>
|
||||
<td>{item.source}</td>
|
||||
<td>{item.timestamp?.toString()}</td>
|
||||
</tr>
|
||||
);
|
||||
});
|
||||
}, [data, selection]);
|
||||
|
||||
// rendering
|
||||
if (data.length === 0)
|
||||
return (
|
||||
<Text>
|
||||
<Trans>No history</Trans>
|
||||
</Text>
|
||||
);
|
||||
return (
|
||||
<ScrollArea>
|
||||
<Table miw={800} verticalSpacing='sm'>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: rem(40) }}>
|
||||
<Checkbox
|
||||
onChange={toggleAll}
|
||||
checked={selection.length === data.length}
|
||||
indeterminate={
|
||||
selection.length > 0 && selection.length !== data.length
|
||||
}
|
||||
/>
|
||||
</th>
|
||||
<th>
|
||||
<Trans>Item</Trans>
|
||||
</th>
|
||||
<th>
|
||||
<Trans>Type</Trans>
|
||||
</th>
|
||||
<th>
|
||||
<Trans>Source</Trans>
|
||||
</th>
|
||||
<th>
|
||||
<Trans>Scanned at</Trans>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>{rows}</tbody>
|
||||
</Table>
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
|
||||
// region input stuff
|
||||
enum InputMethod {
|
||||
Manual = 'manually',
|
||||
ImageBarcode = 'imageBarcode'
|
||||
}
|
||||
|
||||
export interface ScanInputInterface {
|
||||
action: (items: ScanItem[]) => void;
|
||||
}
|
||||
|
||||
interface BarcodeInputProps {
|
||||
action: (decodedText: string) => void;
|
||||
notScanningPlaceholder?: string;
|
||||
}
|
||||
|
||||
function InputManual({ action }: Readonly<ScanInputInterface>) {
|
||||
const [value, setValue] = useState<string>('');
|
||||
|
||||
function btnAddItem() {
|
||||
if (value === '') return;
|
||||
|
||||
const new_item: ScanItem = {
|
||||
id: randomId(),
|
||||
ref: value,
|
||||
data: { item: value },
|
||||
timestamp: new Date(),
|
||||
source: InputMethod.Manual
|
||||
};
|
||||
action([new_item]);
|
||||
setValue('');
|
||||
}
|
||||
|
||||
function btnAddDummyItem() {
|
||||
const new_item: ScanItem = {
|
||||
id: randomId(),
|
||||
ref: 'Test item',
|
||||
data: {},
|
||||
timestamp: new Date(),
|
||||
source: InputMethod.Manual
|
||||
};
|
||||
action([new_item]);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Group>
|
||||
<TextInput
|
||||
placeholder={t`Enter item serial or data`}
|
||||
value={value}
|
||||
onChange={(event) => setValue(event.currentTarget.value)}
|
||||
onKeyDown={getHotkeyHandler([['Enter', btnAddItem]])}
|
||||
/>
|
||||
<ActionIcon onClick={btnAddItem} w={16} variant='default'>
|
||||
<IconPlus />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
|
||||
{IS_DEV_OR_DEMO && (
|
||||
<Button onClick={btnAddDummyItem} variant='outline'>
|
||||
<Trans>Add dummy item</Trans>
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/* Input that uses QR code detection from images */
|
||||
export function InputImageBarcode({
|
||||
action,
|
||||
notScanningPlaceholder = t`Start scanning by selecting a camera and pressing the play button.`
|
||||
}: Readonly<BarcodeInputProps>) {
|
||||
const [qrCodeScanner, setQrCodeScanner] = useState<Html5Qrcode | null>(null);
|
||||
const [camId, setCamId] = useLocalStorage<CameraDevice | null>({
|
||||
key: 'camId',
|
||||
defaultValue: null
|
||||
});
|
||||
const [cameras, setCameras] = useState<any[]>([]);
|
||||
const [cameraValue, setCameraValue] = useState<string | null>(null);
|
||||
const [scanningEnabled, setScanningEnabled] = useState<boolean>(false);
|
||||
const [wasAutoPaused, setWasAutoPaused] = useState<boolean>(false);
|
||||
const documentState = useDocumentVisibility();
|
||||
|
||||
let lastValue = '';
|
||||
|
||||
// Mount QR code once we are loaded
|
||||
useEffect(() => {
|
||||
setQrCodeScanner(new Html5Qrcode('reader'));
|
||||
|
||||
// load cameras
|
||||
Html5Qrcode.getCameras().then((devices) => {
|
||||
if (devices?.length) {
|
||||
setCameras(devices);
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
// set camera value from id
|
||||
useEffect(() => {
|
||||
if (camId) {
|
||||
setCameraValue(camId.id);
|
||||
}
|
||||
}, [camId]);
|
||||
|
||||
// Stop/start when leaving or reentering page
|
||||
useEffect(() => {
|
||||
if (scanningEnabled && documentState === 'hidden') {
|
||||
btnStopScanning();
|
||||
setWasAutoPaused(true);
|
||||
} else if (wasAutoPaused && documentState === 'visible') {
|
||||
btnStartScanning();
|
||||
setWasAutoPaused(false);
|
||||
}
|
||||
}, [documentState]);
|
||||
|
||||
// Scanner functions
|
||||
function onScanSuccess(decodedText: string) {
|
||||
qrCodeScanner?.pause();
|
||||
|
||||
// dedouplication
|
||||
if (decodedText === lastValue) {
|
||||
qrCodeScanner?.resume();
|
||||
return;
|
||||
}
|
||||
lastValue = decodedText;
|
||||
|
||||
// submit value upstream
|
||||
action(decodedText);
|
||||
|
||||
qrCodeScanner?.resume();
|
||||
}
|
||||
|
||||
function onScanFailure(error: string) {
|
||||
if (
|
||||
error !=
|
||||
'QR code parse error, error = NotFoundException: No MultiFormat Readers were able to detect the code.'
|
||||
) {
|
||||
console.warn(`Code scan error = ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
// button handlers
|
||||
function btnSelectCamera() {
|
||||
Html5Qrcode.getCameras()
|
||||
.then((devices) => {
|
||||
if (devices?.length) {
|
||||
setCamId(devices[0]);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
showNotification({
|
||||
title: t`Error while getting camera`,
|
||||
message: err,
|
||||
color: 'red',
|
||||
icon: <IconX />
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function btnStartScanning() {
|
||||
if (camId && qrCodeScanner && !scanningEnabled) {
|
||||
qrCodeScanner
|
||||
.start(
|
||||
camId.id,
|
||||
{ fps: 10, qrbox: { width: 250, height: 250 } },
|
||||
(decodedText) => {
|
||||
onScanSuccess(decodedText);
|
||||
},
|
||||
(errorMessage) => {
|
||||
onScanFailure(errorMessage);
|
||||
}
|
||||
)
|
||||
.catch((err: string) => {
|
||||
showNotification({
|
||||
title: t`Error while scanning`,
|
||||
message: err,
|
||||
color: 'red',
|
||||
icon: <IconX />
|
||||
});
|
||||
});
|
||||
setScanningEnabled(true);
|
||||
}
|
||||
}
|
||||
|
||||
function btnStopScanning() {
|
||||
if (qrCodeScanner && scanningEnabled) {
|
||||
qrCodeScanner.stop().catch((err: string) => {
|
||||
showNotification({
|
||||
title: t`Error while stopping`,
|
||||
message: err,
|
||||
color: 'red',
|
||||
icon: <IconX />
|
||||
});
|
||||
});
|
||||
setScanningEnabled(false);
|
||||
}
|
||||
}
|
||||
|
||||
// on value change
|
||||
useEffect(() => {
|
||||
if (cameraValue === null) return;
|
||||
if (cameraValue === camId?.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cam = cameras.find((cam) => cam.id === cameraValue);
|
||||
|
||||
// stop scanning if cam changed while scanning
|
||||
if (qrCodeScanner && scanningEnabled) {
|
||||
// stop scanning
|
||||
qrCodeScanner.stop().then(() => {
|
||||
// change ID
|
||||
setCamId(cam);
|
||||
// start scanning
|
||||
qrCodeScanner.start(
|
||||
cam.id,
|
||||
{ fps: 10, qrbox: { width: 250, height: 250 } },
|
||||
(decodedText) => {
|
||||
onScanSuccess(decodedText);
|
||||
},
|
||||
(errorMessage) => {
|
||||
onScanFailure(errorMessage);
|
||||
}
|
||||
);
|
||||
});
|
||||
} else {
|
||||
setCamId(cam);
|
||||
}
|
||||
}, [cameraValue]);
|
||||
|
||||
return (
|
||||
<Stack gap='xs'>
|
||||
<Group gap='xs' preventGrowOverflow>
|
||||
<Select
|
||||
value={cameraValue}
|
||||
onChange={setCameraValue}
|
||||
data={cameras.map((device) => {
|
||||
return { value: device.id, label: device.label };
|
||||
})}
|
||||
maw={200}
|
||||
size='sm'
|
||||
/>
|
||||
{scanningEnabled ? (
|
||||
<ActionIcon
|
||||
size='input-sm'
|
||||
onClick={btnStopScanning}
|
||||
title={t`Stop scanning`}
|
||||
variant='default'
|
||||
>
|
||||
<IconPlayerStopFilled />
|
||||
</ActionIcon>
|
||||
) : (
|
||||
<ActionIcon
|
||||
size='input-sm'
|
||||
onClick={btnStartScanning}
|
||||
title={t`Start scanning`}
|
||||
disabled={!camId}
|
||||
variant='default'
|
||||
>
|
||||
<IconPlayerPlayFilled />
|
||||
</ActionIcon>
|
||||
)}
|
||||
<Space style={{ flex: 1 }} />
|
||||
<Badge color={scanningEnabled ? 'green' : 'orange'}>
|
||||
{scanningEnabled ? t`Scanning` : t`Not scanning`}
|
||||
</Badge>
|
||||
</Group>
|
||||
{scanningEnabled ? (
|
||||
<Container px={0} id='reader' w={'100%'} mih='300px' />
|
||||
) : (
|
||||
<Container px={0} id='reader' w={'100%'} mih='300px'>
|
||||
{notScanningPlaceholder}
|
||||
</Container>
|
||||
)}
|
||||
{!camId && (
|
||||
<Button onClick={btnSelectCamera}>
|
||||
<Trans>Select Camera</Trans>
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
@ -20,7 +20,7 @@ import { InvenTreeTable } from '../../../../tables/InvenTreeTable';
|
||||
export function CurrencyTable({
|
||||
setInfo
|
||||
}: Readonly<{ setInfo: (info: any) => void }>) {
|
||||
const table = useTable('currency');
|
||||
const table = useTable('currency', 'currency');
|
||||
const columns = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
@ -71,7 +71,6 @@ export function CurrencyTable({
|
||||
tableState={table}
|
||||
columns={columns}
|
||||
props={{
|
||||
idAccessor: 'currency',
|
||||
tableActions: tableActions,
|
||||
dataFormatter: (data: any) => {
|
||||
setInfo(data);
|
||||
|
@ -11,7 +11,7 @@ import { InvenTreeTable } from '../../../../tables/InvenTreeTable';
|
||||
import CustomUnitsTable from '../../../../tables/settings/CustomUnitsTable';
|
||||
|
||||
function AllUnitTable() {
|
||||
const table = useTable('all-units');
|
||||
const table = useTable('all-units', 'name');
|
||||
const columns = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
@ -29,7 +29,6 @@ function AllUnitTable() {
|
||||
tableState={table}
|
||||
columns={columns}
|
||||
props={{
|
||||
idAccessor: 'name',
|
||||
enableSearch: false,
|
||||
enablePagination: false,
|
||||
enableColumnSwitching: false,
|
||||
|
@ -81,7 +81,6 @@ export type InvenTreeTableProps<T = any> = {
|
||||
tableFilters?: TableFilter[];
|
||||
tableActions?: React.ReactNode[];
|
||||
rowExpansion?: DataTableRowExpansionProps<T>;
|
||||
idAccessor?: string;
|
||||
dataFormatter?: (data: any) => any;
|
||||
rowActions?: (record: T) => RowAction[];
|
||||
onRowClick?: (record: T, index: number, event: any) => void;
|
||||
@ -111,8 +110,7 @@ const defaultInvenTreeTableProps: InvenTreeTableProps = {
|
||||
defaultSortColumn: '',
|
||||
barcodeActions: [],
|
||||
tableFilters: [],
|
||||
tableActions: [],
|
||||
idAccessor: 'pk'
|
||||
tableActions: []
|
||||
};
|
||||
|
||||
/**
|
||||
@ -121,11 +119,13 @@ const defaultInvenTreeTableProps: InvenTreeTableProps = {
|
||||
export function InvenTreeTable<T extends Record<string, any>>({
|
||||
url,
|
||||
tableState,
|
||||
tableData,
|
||||
columns,
|
||||
props
|
||||
}: Readonly<{
|
||||
url: string;
|
||||
url?: string;
|
||||
tableState: TableState;
|
||||
tableData?: any[];
|
||||
columns: TableColumn<T>[];
|
||||
props: InvenTreeTableProps<T>;
|
||||
}>) {
|
||||
@ -158,12 +158,16 @@ export function InvenTreeTable<T extends Record<string, any>>({
|
||||
|
||||
// Request OPTIONS data from the API, before we load the table
|
||||
const tableOptionQuery = useQuery({
|
||||
enabled: true,
|
||||
enabled: !!url && !tableData,
|
||||
queryKey: ['options', url, tableState.tableKey, props.enableColumnCaching],
|
||||
retry: 3,
|
||||
refetchOnMount: true,
|
||||
gcTime: 5000,
|
||||
queryFn: async () => {
|
||||
if (!url) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (props.enableColumnCaching == false) {
|
||||
return null;
|
||||
}
|
||||
@ -445,6 +449,10 @@ export function InvenTreeTable<T extends Record<string, any>>({
|
||||
const fetchTableData = async () => {
|
||||
const queryParams = getTableFilters(true);
|
||||
|
||||
if (!url) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return api
|
||||
.get(url, {
|
||||
params: queryParams,
|
||||
@ -499,7 +507,12 @@ export function InvenTreeTable<T extends Record<string, any>>({
|
||||
});
|
||||
};
|
||||
|
||||
const { data, isFetching, isLoading, refetch } = useQuery({
|
||||
const {
|
||||
data: apiData,
|
||||
isFetching,
|
||||
isLoading,
|
||||
refetch
|
||||
} = useQuery({
|
||||
queryKey: [
|
||||
'tabledata',
|
||||
url,
|
||||
@ -511,6 +524,7 @@ export function InvenTreeTable<T extends Record<string, any>>({
|
||||
tableState.activeFilters,
|
||||
tableState.searchTerm
|
||||
],
|
||||
enabled: !!url && !tableData,
|
||||
queryFn: fetchTableData,
|
||||
refetchOnMount: true
|
||||
});
|
||||
@ -531,13 +545,15 @@ export function InvenTreeTable<T extends Record<string, any>>({
|
||||
|
||||
// Update tableState.records when new data received
|
||||
useEffect(() => {
|
||||
tableState.setRecords(data ?? []);
|
||||
const data = tableData ?? apiData ?? [];
|
||||
|
||||
tableState.setRecords(data);
|
||||
|
||||
// set pagesize to length if pagination is disabled
|
||||
if (!tableProps.enablePagination) {
|
||||
tableState.setPageSize(data?.length ?? defaultPageSize);
|
||||
}
|
||||
}, [data]);
|
||||
}, [tableData, apiData]);
|
||||
|
||||
// Callback when a cell is clicked
|
||||
const handleCellClick = useCallback(
|
||||
@ -645,13 +661,29 @@ export function InvenTreeTable<T extends Record<string, any>>({
|
||||
]);
|
||||
|
||||
const optionalParams = useMemo(() => {
|
||||
const optionalParamsa: Record<string, any> = {};
|
||||
let _params: Record<string, any> = {};
|
||||
|
||||
if (tableProps.enablePagination) {
|
||||
optionalParamsa['recordsPerPageOptions'] = PAGE_SIZES;
|
||||
optionalParamsa['onRecordsPerPageChange'] = updatePageSize;
|
||||
_params = {
|
||||
..._params,
|
||||
totalRecords: tableState.recordCount,
|
||||
recordsPerPage: tableState.pageSize,
|
||||
page: tableState.page,
|
||||
onPageChange: tableState.setPage,
|
||||
recordsPerPageOptions: PAGE_SIZES,
|
||||
onRecordsPerPageChange: updatePageSize
|
||||
};
|
||||
}
|
||||
return optionalParamsa;
|
||||
}, [tableProps.enablePagination]);
|
||||
|
||||
return _params;
|
||||
}, [
|
||||
tableProps.enablePagination,
|
||||
tableState.recordCount,
|
||||
tableState.pageSize,
|
||||
tableState.page,
|
||||
tableState.setPage,
|
||||
updatePageSize
|
||||
]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -678,12 +710,8 @@ export function InvenTreeTable<T extends Record<string, any>>({
|
||||
highlightOnHover
|
||||
loaderType={loader}
|
||||
pinLastColumn={tableProps.rowActions != undefined}
|
||||
idAccessor={tableProps.idAccessor}
|
||||
idAccessor={tableState.idAccessor ?? 'pk'}
|
||||
minHeight={tableProps.minHeight ?? 300}
|
||||
totalRecords={tableState.recordCount}
|
||||
recordsPerPage={tableState.pageSize}
|
||||
page={tableState.page}
|
||||
onPageChange={tableState.setPage}
|
||||
sortStatus={sortStatus}
|
||||
onSortStatusChange={handleSortStatusChange}
|
||||
selectedRecords={
|
||||
|
@ -42,7 +42,7 @@ export default function InvenTreeTableHeader({
|
||||
filters,
|
||||
toggleColumn
|
||||
}: Readonly<{
|
||||
tableUrl: string;
|
||||
tableUrl?: string;
|
||||
tableState: TableState;
|
||||
tableProps: InvenTreeTableProps<any>;
|
||||
hasSwitchableColumns: boolean;
|
||||
@ -92,7 +92,7 @@ export default function InvenTreeTableHeader({
|
||||
};
|
||||
|
||||
const deleteRecords = useDeleteApiFormModal({
|
||||
url: tableUrl,
|
||||
url: tableUrl ?? '',
|
||||
title: t`Delete Selected Items`,
|
||||
preFormContent: (
|
||||
<Alert
|
||||
|
124
src/frontend/src/tables/general/BarcodeScanTable.tsx
Normal file
@ -0,0 +1,124 @@
|
||||
import { t } from '@lingui/macro';
|
||||
import { IconTrash } from '@tabler/icons-react';
|
||||
import { useCallback, useEffect, useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import type { BarcodeScanItem } from '../../components/barcodes/BarcodeScanItem';
|
||||
import { ActionButton } from '../../components/buttons/ActionButton';
|
||||
import { RenderInstance } from '../../components/render/Instance';
|
||||
import { useTable } from '../../hooks/UseTable';
|
||||
import { useUserState } from '../../states/UserState';
|
||||
import type { TableColumn } from '../Column';
|
||||
import { InvenTreeTable } from '../InvenTreeTable';
|
||||
import { type RowAction, RowViewAction } from '../RowActions';
|
||||
|
||||
/**
|
||||
* A table for showing barcode scan history data on the scan index page
|
||||
*/
|
||||
export default function BarcodeScanTable({
|
||||
records,
|
||||
onItemsSelected,
|
||||
onItemsDeleted
|
||||
}: {
|
||||
records: BarcodeScanItem[];
|
||||
onItemsSelected: (items: string[]) => void;
|
||||
onItemsDeleted: (items: string[]) => void;
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
const user = useUserState();
|
||||
|
||||
const table = useTable('barcode-scan-results', 'id');
|
||||
|
||||
const tableColumns: TableColumn[] = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
accessor: 'instance',
|
||||
title: t`Item`,
|
||||
sortable: false,
|
||||
switchable: false,
|
||||
render: (record) => {
|
||||
if (record.instance) {
|
||||
return (
|
||||
<RenderInstance model={record.model} instance={record.instance} />
|
||||
);
|
||||
} else {
|
||||
return '-';
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
accessor: 'model',
|
||||
title: t`Model`,
|
||||
sortable: false
|
||||
},
|
||||
{
|
||||
accessor: 'barcode',
|
||||
title: t`Barcode`,
|
||||
sortable: false
|
||||
},
|
||||
{
|
||||
accessor: 'timestamp',
|
||||
title: t`Timestamp`,
|
||||
sortable: false,
|
||||
render: (record) => {
|
||||
return record.timestamp?.toLocaleString();
|
||||
}
|
||||
}
|
||||
];
|
||||
}, []);
|
||||
|
||||
const rowActions = useCallback((record: BarcodeScanItem) => {
|
||||
const actions: RowAction[] = [];
|
||||
|
||||
if (record.model && record.pk && record.instance) {
|
||||
actions.push(
|
||||
RowViewAction({
|
||||
title: t`View Item`,
|
||||
modelId: record.instance?.pk,
|
||||
modelType: record.model,
|
||||
navigate: navigate,
|
||||
hidden: !user.hasViewPermission(record.model)
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return actions;
|
||||
}, []);
|
||||
|
||||
const tableActions = useMemo(() => {
|
||||
return [
|
||||
<ActionButton
|
||||
disabled={!table.hasSelectedRecords}
|
||||
icon={<IconTrash />}
|
||||
color='red'
|
||||
tooltip={t`Delete selected records`}
|
||||
onClick={() => {
|
||||
onItemsDeleted(table.selectedIds);
|
||||
table.clearSelectedRecords();
|
||||
}}
|
||||
/>
|
||||
];
|
||||
}, [table.hasSelectedRecords, table.selectedIds]);
|
||||
|
||||
useEffect(() => {
|
||||
onItemsSelected(table.selectedIds);
|
||||
}, [table.selectedIds]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<InvenTreeTable
|
||||
tableState={table}
|
||||
tableData={records}
|
||||
columns={tableColumns}
|
||||
props={{
|
||||
enableFilters: false,
|
||||
enableSelection: true,
|
||||
enablePagination: false,
|
||||
enableSearch: false,
|
||||
enableRefresh: false,
|
||||
rowActions: rowActions,
|
||||
tableActions: tableActions
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
@ -19,7 +19,7 @@ export interface PluginRegistryErrorI {
|
||||
* Table displaying list of plugin registry errors
|
||||
*/
|
||||
export default function PluginErrorTable() {
|
||||
const table = useTable('registryErrors');
|
||||
const table = useTable('registryErrors', 'id');
|
||||
|
||||
const registryErrorTableColumns: TableColumn<PluginRegistryErrorI>[] =
|
||||
useMemo(
|
||||
@ -49,7 +49,6 @@ export default function PluginErrorTable() {
|
||||
props={{
|
||||
dataFormatter: (data: any) =>
|
||||
data.registry_errors.map((e: any, i: number) => ({ id: i, ...e })),
|
||||
idAccessor: 'id',
|
||||
enableDownload: false,
|
||||
enableFilters: false,
|
||||
enableSearch: false
|
||||
|
@ -446,6 +446,7 @@ export default function StockItemTestResultTable({
|
||||
return (
|
||||
<DataTable
|
||||
key={record.pk}
|
||||
idAccessor={'test'}
|
||||
noHeader
|
||||
columns={cols}
|
||||
records={results.slice(0, -1)}
|
||||
|
@ -74,6 +74,7 @@ export const test = baseTest.extend({
|
||||
url != 'http://localhost:8000/api/barcode/' &&
|
||||
url != 'https://docs.inventree.org/en/versions.json' &&
|
||||
url != 'http://localhost:5173/favicon.ico' &&
|
||||
!url.startsWith('https://api.github.com/repos/inventree') &&
|
||||
!url.startsWith('http://localhost:8000/api/news/') &&
|
||||
!url.startsWith('http://localhost:8000/api/notifications/') &&
|
||||
!url.startsWith('chrome://') &&
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { test } from './baseFixtures.js';
|
||||
import { doQuickLogin } from './login.js';
|
||||
|
||||
test('Modals as admin', async ({ page }) => {
|
||||
test('Modals - Admin', async ({ page }) => {
|
||||
await doQuickLogin(page, 'admin', 'inventree');
|
||||
|
||||
// use server info
|
||||
@ -49,15 +49,4 @@ test('Modals as admin', async ({ page }) => {
|
||||
.getByRole('button', { name: 'About InvenTree About the InvenTree org' })
|
||||
.click();
|
||||
await page.getByRole('cell', { name: 'InvenTree Version' }).click();
|
||||
|
||||
await page.goto('./platform/');
|
||||
|
||||
// Barcode scanning window
|
||||
await page.getByRole('button', { name: 'Open Barcode Scanner' }).click();
|
||||
await page.getByRole('banner').getByRole('button').click();
|
||||
await page.getByRole('button', { name: 'Open Barcode Scanner' }).click();
|
||||
await page.getByRole('button', { name: 'Close modal' }).click();
|
||||
await page.getByRole('button', { name: 'Open Barcode Scanner' }).click();
|
||||
await page.waitForTimeout(500);
|
||||
await page.getByRole('banner').getByRole('button').click();
|
||||
});
|
||||
|
@ -268,6 +268,4 @@ test('Build Order - Filters', async ({ page }) => {
|
||||
|
||||
await openFilterDrawer(page);
|
||||
await clickButtonIfVisible(page, 'Clear Filters');
|
||||
|
||||
await page.waitForTimeout(2500);
|
||||
});
|
||||
|
@ -3,6 +3,70 @@ import { baseUrl } from '../defaults.ts';
|
||||
import { clickButtonIfVisible, openFilterDrawer } from '../helpers.ts';
|
||||
import { doQuickLogin } from '../login.ts';
|
||||
|
||||
test('Purchase Orders', async ({ page }) => {
|
||||
await doQuickLogin(page);
|
||||
|
||||
await page.goto(`${baseUrl}/home`);
|
||||
await page.getByRole('tab', { name: 'Purchasing' }).click();
|
||||
await page.getByRole('tab', { name: 'Purchase Orders' }).click();
|
||||
|
||||
// Check for expected values
|
||||
await page.getByRole('cell', { name: 'PO0014' }).waitFor();
|
||||
await page.getByText('Wire-E-Coyote').waitFor();
|
||||
await page.getByText('Cancelled').first().waitFor();
|
||||
await page.getByText('Pending').first().waitFor();
|
||||
await page.getByText('On Hold').first().waitFor();
|
||||
|
||||
// Click through to a particular purchase order
|
||||
await page.getByRole('cell', { name: 'PO0013' }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Issue Order' }).waitFor();
|
||||
});
|
||||
|
||||
test('Purchase Orders - Barcodes', async ({ page }) => {
|
||||
await doQuickLogin(page);
|
||||
|
||||
await page.goto(`${baseUrl}/purchasing/purchase-order/13/detail`);
|
||||
await page.getByRole('button', { name: 'Issue Order' }).waitFor();
|
||||
|
||||
// Display QR code
|
||||
await page.getByLabel('action-menu-barcode-actions').click();
|
||||
await page.getByLabel('action-menu-barcode-actions-view').click();
|
||||
await page.getByRole('img', { name: 'QR Code' }).waitFor();
|
||||
await page.getByRole('banner').getByRole('button').click();
|
||||
|
||||
// Link to barcode
|
||||
await page.getByLabel('action-menu-barcode-actions').click();
|
||||
await page.getByLabel('action-menu-barcode-actions-link-barcode').click();
|
||||
|
||||
await page.getByLabel('barcode-input-scanner').click();
|
||||
|
||||
// Simulate barcode scan
|
||||
await page.getByPlaceholder('Enter barcode data').fill('1234567890');
|
||||
await page.getByRole('button', { name: 'Scan', exact: true }).click();
|
||||
await page.waitForTimeout(250);
|
||||
|
||||
await page.getByRole('button', { name: 'Issue Order' }).waitFor();
|
||||
|
||||
// Ensure we can scan back to this page, with the associated barcode
|
||||
await page.goto(`${baseUrl}/home`);
|
||||
await page.waitForTimeout(250);
|
||||
await page.getByRole('button', { name: 'Open Barcode Scanner' }).click();
|
||||
await page.getByPlaceholder('Enter barcode data').fill('1234567890');
|
||||
await page.getByRole('button', { name: 'Scan', exact: true }).click();
|
||||
|
||||
await page.getByText('Purchase Order: PO0013', { exact: true }).waitFor();
|
||||
|
||||
// Unlink barcode
|
||||
await page.getByLabel('action-menu-barcode-actions').click();
|
||||
await page.getByLabel('action-menu-barcode-actions-unlink-barcode').click();
|
||||
await page.getByRole('heading', { name: 'Unlink Barcode' }).waitFor();
|
||||
await page.getByText('This will remove the link to').waitFor();
|
||||
await page.getByRole('button', { name: 'Unlink Barcode' }).click();
|
||||
await page.waitForTimeout(250);
|
||||
await page.getByRole('button', { name: 'Issue Order' }).waitFor();
|
||||
});
|
||||
|
||||
test('Purchase Orders - General', async ({ page }) => {
|
||||
await doQuickLogin(page);
|
||||
|
||||
|
@ -3,6 +3,57 @@ import { baseUrl } from '../defaults.ts';
|
||||
import { clearTableFilters, setTableChoiceFilter } from '../helpers.ts';
|
||||
import { doQuickLogin } from '../login.ts';
|
||||
|
||||
test('Sales Orders - Tabs', async ({ page }) => {
|
||||
await doQuickLogin(page);
|
||||
|
||||
await page.goto(`${baseUrl}/sales/index/`);
|
||||
await page.waitForURL('**/platform/sales/**');
|
||||
|
||||
await page.getByRole('tab', { name: 'Sales Orders' }).click();
|
||||
await page.waitForURL('**/platform/sales/index/salesorders');
|
||||
await page.getByRole('tab', { name: 'Return Orders' }).click();
|
||||
|
||||
// Customers
|
||||
await page.getByRole('tab', { name: 'Customers' }).click();
|
||||
await page.getByText('Customer A').click();
|
||||
await page.getByRole('tab', { name: 'Notes' }).click();
|
||||
await page.getByRole('tab', { name: 'Attachments' }).click();
|
||||
await page.getByRole('tab', { name: 'Contacts' }).click();
|
||||
await page.getByRole('tab', { name: 'Assigned Stock' }).click();
|
||||
await page.getByRole('tab', { name: 'Return Orders' }).click();
|
||||
await page.getByRole('tab', { name: 'Sales Orders' }).click();
|
||||
await page.getByRole('tab', { name: 'Contacts' }).click();
|
||||
await page.getByRole('cell', { name: 'Dorathy Gross' }).waitFor();
|
||||
await page
|
||||
.getByRole('row', { name: 'Dorathy Gross dorathy.gross@customer.com' })
|
||||
.waitFor();
|
||||
|
||||
// Sales Order Details
|
||||
await page.getByRole('tab', { name: 'Sales Orders' }).click();
|
||||
await page.getByRole('cell', { name: 'SO0001' }).click();
|
||||
await page
|
||||
.getByLabel('Order Details')
|
||||
.getByText('Selling some stuff')
|
||||
.waitFor();
|
||||
await page.getByRole('tab', { name: 'Line Items' }).click();
|
||||
await page.getByRole('tab', { name: 'Shipments' }).click();
|
||||
await page.getByRole('tab', { name: 'Build Orders' }).click();
|
||||
await page.getByText('No records found').first().waitFor();
|
||||
await page.getByRole('tab', { name: 'Attachments' }).click();
|
||||
await page.getByText('No attachments found').first().waitFor();
|
||||
await page.getByRole('tab', { name: 'Notes' }).click();
|
||||
await page.getByRole('tab', { name: 'Order Details' }).click();
|
||||
|
||||
// Return Order Details
|
||||
await page.getByRole('link', { name: 'Customer A' }).click();
|
||||
await page.getByRole('tab', { name: 'Return Orders' }).click();
|
||||
await page.getByRole('cell', { name: 'RMA-' }).click();
|
||||
await page.getByText('RMA-0001', { exact: true }).waitFor();
|
||||
await page.getByRole('tab', { name: 'Line Items' }).click();
|
||||
await page.getByRole('tab', { name: 'Attachments' }).click();
|
||||
await page.getByRole('tab', { name: 'Notes' }).click();
|
||||
});
|
||||
|
||||
test('Sales Orders - Basic Tests', async ({ page }) => {
|
||||
await doQuickLogin(page);
|
||||
|
||||
@ -129,55 +180,3 @@ test('Sales Orders - Shipments', async ({ page }) => {
|
||||
await page.getByText('Quantity: 42').click();
|
||||
await page.getByRole('button', { name: 'Cancel' }).click();
|
||||
});
|
||||
|
||||
test('Purchase Orders', async ({ page }) => {
|
||||
await doQuickLogin(page);
|
||||
|
||||
await page.goto(`${baseUrl}/home`);
|
||||
await page.getByRole('tab', { name: 'Purchasing' }).click();
|
||||
await page.getByRole('tab', { name: 'Purchase Orders' }).click();
|
||||
|
||||
// Check for expected values
|
||||
await page.getByRole('cell', { name: 'PO0014' }).waitFor();
|
||||
await page.getByText('Wire-E-Coyote').waitFor();
|
||||
await page.getByText('Cancelled').first().waitFor();
|
||||
await page.getByText('Pending').first().waitFor();
|
||||
await page.getByText('On Hold').first().waitFor();
|
||||
|
||||
// Click through to a particular purchase order
|
||||
await page.getByRole('cell', { name: 'PO0013' }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Issue Order' }).waitFor();
|
||||
});
|
||||
|
||||
test('Purchase Orders - Barcodes', async ({ page }) => {
|
||||
await doQuickLogin(page);
|
||||
|
||||
await page.goto(`${baseUrl}/purchasing/purchase-order/13/detail`);
|
||||
await page.getByRole('button', { name: 'Issue Order' }).waitFor();
|
||||
|
||||
// Display QR code
|
||||
await page.getByLabel('action-menu-barcode-actions').click();
|
||||
await page.getByLabel('action-menu-barcode-actions-view').click();
|
||||
await page.getByRole('img', { name: 'QR Code' }).waitFor();
|
||||
await page.getByRole('banner').getByRole('button').click();
|
||||
|
||||
// Link to barcode
|
||||
await page.getByLabel('action-menu-barcode-actions').click();
|
||||
await page.getByLabel('action-menu-barcode-actions-link-barcode').click();
|
||||
await page.getByRole('heading', { name: 'Link Barcode' }).waitFor();
|
||||
await page
|
||||
.getByPlaceholder('Scan barcode data here using')
|
||||
.fill('1234567890');
|
||||
await page.getByRole('button', { name: 'Link' }).click();
|
||||
await page.getByRole('button', { name: 'Issue Order' }).waitFor();
|
||||
|
||||
// Unlink barcode
|
||||
await page.getByLabel('action-menu-barcode-actions').click();
|
||||
await page.getByLabel('action-menu-barcode-actions-unlink-barcode').click();
|
||||
await page.getByRole('heading', { name: 'Unlink Barcode' }).waitFor();
|
||||
await page.getByText('This will remove the link to').waitFor();
|
||||
await page.getByRole('button', { name: 'Unlink Barcode' }).click();
|
||||
await page.waitForTimeout(500);
|
||||
await page.getByRole('button', { name: 'Issue Order' }).waitFor();
|
||||
});
|
||||
|
@ -2,157 +2,120 @@ import { test } from '../baseFixtures';
|
||||
import { baseUrl } from '../defaults';
|
||||
import { doQuickLogin } from '../login';
|
||||
|
||||
async function defaultScanTest(page, search_text) {
|
||||
const scan = async (page, barcode) => {
|
||||
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 ({ page }) => {
|
||||
await doQuickLogin(page);
|
||||
|
||||
await page.goto(`${baseUrl}/scan`);
|
||||
await page.getByPlaceholder('Select input method').click();
|
||||
await page.getByRole('option', { name: 'Manual input' }).click();
|
||||
await page.getByPlaceholder('Enter item serial or data').click();
|
||||
await page.getByRole('button', { name: 'Open Barcode Scanner' }).click();
|
||||
await scan(page, '{"part": 15}');
|
||||
|
||||
// nonsense data
|
||||
await page.getByPlaceholder('Enter item serial or data').fill('123');
|
||||
await page.getByPlaceholder('Enter item serial or data').press('Enter');
|
||||
await page.getByRole('cell', { name: '123' }).click();
|
||||
await page.getByRole('cell', { name: 'manually' }).click();
|
||||
await page.getByRole('button', { name: 'Lookup part' }).click();
|
||||
await page.getByRole('button', { name: 'Delete', exact: true }).click();
|
||||
await page.getByText('Part: R_550R_0805_1%', { exact: true }).waitFor();
|
||||
await page.getByText('Available:').waitFor();
|
||||
await page.getByText('Required:').waitFor();
|
||||
});
|
||||
|
||||
await page.getByPlaceholder('Enter item serial or data').fill(search_text);
|
||||
await page.getByPlaceholder('Enter item serial or data').press('Enter');
|
||||
await page.getByRole('checkbox').nth(2).check();
|
||||
await page.getByRole('button', { name: 'Lookup part' }).click();
|
||||
}
|
||||
|
||||
test('Scanning', async ({ page }) => {
|
||||
test('Scanning - Basic', async ({ page }) => {
|
||||
await doQuickLogin(page);
|
||||
|
||||
await page.getByLabel('navigation-menu').click();
|
||||
await page.getByRole('button', { name: 'System Information' }).click();
|
||||
await page.locator('button').filter({ hasText: 'Close' }).click();
|
||||
|
||||
// Navigate to the 'scan' page
|
||||
await page.getByLabel('navigation-menu').click();
|
||||
await page.getByRole('button', { name: 'Scan Barcode' }).click();
|
||||
|
||||
await page.getByPlaceholder('Select input method').click();
|
||||
await page.getByRole('option', { name: 'Manual input' }).click();
|
||||
await page.getByPlaceholder('Enter item serial or data').click();
|
||||
await page.getByPlaceholder('Enter item serial or data').fill('123');
|
||||
await page.getByPlaceholder('Enter item serial or data').press('Enter');
|
||||
await page.getByRole('cell', { name: 'manually' }).click();
|
||||
await page.getByRole('button', { name: 'Lookup part' }).click();
|
||||
await page.getByPlaceholder('Select input method').click();
|
||||
await page.getByRole('option', { name: 'Manual input' }).click();
|
||||
await page.getByText('Scan or enter barcode data').waitFor();
|
||||
|
||||
// Select the scanner input
|
||||
await page.getByLabel('barcode-input-scanner').click();
|
||||
await page.getByPlaceholder('Enter barcode data').fill('123-abc');
|
||||
await page.getByRole('button', { name: 'Scan', exact: true }).click();
|
||||
|
||||
// Select the camera input
|
||||
await page.getByLabel('barcode-input-camera').click();
|
||||
await page.getByText('Start scanning by selecting a camera').waitFor();
|
||||
|
||||
await page.getByText('No match found for barcode').waitFor();
|
||||
});
|
||||
|
||||
test('Scanning (Part)', async ({ page }) => {
|
||||
await defaultScanTest(page, '{"part": 1}');
|
||||
test('Scanning - Part', async ({ page }) => {
|
||||
await doQuickLogin(page);
|
||||
await page.goto(`${baseUrl}/scan/`);
|
||||
|
||||
await scan(page, '{"part": 1}');
|
||||
|
||||
// part: 1
|
||||
await page.getByText('R_10R_0402_1%').waitFor();
|
||||
await page.getByText('Stock:').waitFor();
|
||||
await page.getByRole('cell', { name: 'part' }).waitFor();
|
||||
await page.getByRole('cell', { name: 'part', exact: true }).waitFor();
|
||||
});
|
||||
|
||||
test('Scanning (Stockitem)', async ({ page }) => {
|
||||
// TODO: Come back to here and re-enable this test
|
||||
// TODO: Something is wrong with the test, it's not working as expected
|
||||
// TODO: The barcode scanning page needs some attention in general
|
||||
/*
|
||||
* TODO: 2024-11-08 : https://github.com/inventree/InvenTree/pull/8445
|
||||
await defaultScanTest(page, '{"stockitem": 408}');
|
||||
test('Scanning - Stockitem', async ({ page }) => {
|
||||
await doQuickLogin(page);
|
||||
await page.goto(`${baseUrl}/scan/`);
|
||||
await scan(page, '{"stockitem": 408}');
|
||||
|
||||
// stockitem: 408
|
||||
await page.getByText('1551ABK').waitFor();
|
||||
await page.getByText('Quantity: 100').waitFor();
|
||||
await page.getByRole('cell', { name: 'Quantity: 100' }).waitFor();
|
||||
*/
|
||||
});
|
||||
|
||||
test('Scanning (StockLocation)', async ({ page }) => {
|
||||
await defaultScanTest(page, '{"stocklocation": 3}');
|
||||
test('Scanning - StockLocation', async ({ page }) => {
|
||||
await doQuickLogin(page);
|
||||
await page.goto(`${baseUrl}/scan/`);
|
||||
await scan(page, '{"stocklocation": 3}');
|
||||
|
||||
// stocklocation: 3
|
||||
await page.getByText('Factory/Storage Room B', { exact: true }).waitFor();
|
||||
await page.getByText('Storage Room B (green door)').waitFor();
|
||||
await page.getByRole('cell', { name: 'stocklocation' }).waitFor();
|
||||
await page
|
||||
.getByRole('cell', { name: 'stocklocation', exact: true })
|
||||
.waitFor();
|
||||
});
|
||||
|
||||
test('Scanning (SupplierPart)', async ({ page }) => {
|
||||
await defaultScanTest(page, '{"supplierpart": 204}');
|
||||
test('Scanning - SupplierPart', async ({ page }) => {
|
||||
await doQuickLogin(page);
|
||||
await page.goto(`${baseUrl}/scan/`);
|
||||
await scan(page, '{"supplierpart": 204}');
|
||||
|
||||
// supplierpart: 204
|
||||
await page.waitForTimeout(1000);
|
||||
await page.getByText('1551ABK').first().waitFor();
|
||||
await page.getByRole('cell', { name: 'supplierpart' }).waitFor();
|
||||
await page.getByRole('cell', { name: 'supplierpart', exact: true }).waitFor();
|
||||
});
|
||||
|
||||
test('Scanning (PurchaseOrder)', async ({ page }) => {
|
||||
await defaultScanTest(page, '{"purchaseorder": 12}');
|
||||
test('Scanning - PurchaseOrder', async ({ page }) => {
|
||||
await doQuickLogin(page);
|
||||
await page.goto(`${baseUrl}/scan/`);
|
||||
await scan(page, '{"purchaseorder": 12}');
|
||||
|
||||
// purchaseorder: 12
|
||||
await page.getByText('PO0012').waitFor();
|
||||
await page.getByText('Wire from Wirey').waitFor();
|
||||
await page.getByRole('cell', { name: 'purchaseorder' }).waitFor();
|
||||
await page
|
||||
.getByRole('cell', { name: 'purchaseorder', exact: true })
|
||||
.waitFor();
|
||||
});
|
||||
|
||||
test('Scanning (SalesOrder)', async ({ page }) => {
|
||||
await defaultScanTest(page, '{"salesorder": 6}');
|
||||
test('Scanning - SalesOrder', async ({ page }) => {
|
||||
await doQuickLogin(page);
|
||||
await page.goto(`${baseUrl}/scan/`);
|
||||
await scan(page, '{"salesorder": 6}');
|
||||
|
||||
// salesorder: 6
|
||||
await page.getByText('SO0006').waitFor();
|
||||
await page.getByText('Selling more stuff to this').waitFor();
|
||||
await page.getByRole('cell', { name: 'salesorder' }).waitFor();
|
||||
await page.getByRole('cell', { name: 'salesorder', exact: true }).waitFor();
|
||||
});
|
||||
|
||||
test('Scanning (Build)', async ({ page }) => {
|
||||
await defaultScanTest(page, '{"build": 8}');
|
||||
test('Scanning - Build', async ({ page }) => {
|
||||
await doQuickLogin(page);
|
||||
await page.goto(`${baseUrl}/scan/`);
|
||||
await scan(page, '{"build": 8}');
|
||||
|
||||
// build: 8
|
||||
await page.getByText('BO0008').waitFor();
|
||||
await page.getByText('PCBA build').waitFor();
|
||||
await page.getByRole('cell', { name: 'build', exact: true }).waitFor();
|
||||
});
|
||||
|
||||
test('Scanning (General)', async ({ page }) => {
|
||||
await defaultScanTest(page, '{"unknown": 312}');
|
||||
await page.getByText('"unknown": 312').waitFor();
|
||||
|
||||
// checkAll
|
||||
await page.getByRole('checkbox').nth(0).check();
|
||||
|
||||
// Delete
|
||||
await page.getByRole('button', { name: 'Delete', exact: true }).click();
|
||||
|
||||
// Reload to check history is working
|
||||
await page.goto(`${baseUrl}/scan`);
|
||||
await page.getByText('"unknown": 312').waitFor();
|
||||
|
||||
// Clear history
|
||||
await page.getByRole('button', { name: 'Delete History' }).click();
|
||||
await page.getByText('No history').waitFor();
|
||||
|
||||
// reload again
|
||||
await page.goto(`${baseUrl}/scan`);
|
||||
await page.getByText('No history').waitFor();
|
||||
|
||||
// Empty dummy input
|
||||
await page.getByPlaceholder('Enter item serial or data').fill('');
|
||||
await page.getByPlaceholder('Enter item serial or data').press('Enter');
|
||||
|
||||
// Empty add dummy item
|
||||
await page.getByRole('button', { name: 'Add dummy item' }).click();
|
||||
|
||||
// Empty plus sign
|
||||
await page
|
||||
.locator('div')
|
||||
.filter({ hasText: /^InputAdd dummy item$/ })
|
||||
.getByRole('button')
|
||||
.first()
|
||||
.click();
|
||||
|
||||
// Toggle fullscreen
|
||||
await page.getByRole('button', { name: 'Toggle Fullscreen' }).click();
|
||||
await page.waitForTimeout(1000);
|
||||
await page.getByRole('button', { name: 'Toggle Fullscreen' }).click();
|
||||
});
|
||||
|
@ -206,8 +206,6 @@ test('Stock - Stock Actions', async ({ page }) => {
|
||||
await page.getByText('Unavailable').waitFor();
|
||||
await page.getByLabel('action-menu-stock-operations').click();
|
||||
await page.getByLabel('action-menu-stock-operations-return').click();
|
||||
|
||||
await page.waitForTimeout(2500);
|
||||
});
|
||||
|
||||
test('Stock - Tracking', async ({ page }) => {
|
||||
|
@ -2,57 +2,6 @@ import { test } from './baseFixtures.js';
|
||||
import { baseUrl } from './defaults.js';
|
||||
import { doQuickLogin } from './login.js';
|
||||
|
||||
test('Sales', async ({ page }) => {
|
||||
await doQuickLogin(page);
|
||||
|
||||
await page.goto(`${baseUrl}/sales/index/`);
|
||||
await page.waitForURL('**/platform/sales/**');
|
||||
|
||||
await page.getByRole('tab', { name: 'Sales Orders' }).click();
|
||||
await page.waitForURL('**/platform/sales/index/salesorders');
|
||||
await page.getByRole('tab', { name: 'Return Orders' }).click();
|
||||
|
||||
// Customers
|
||||
await page.getByRole('tab', { name: 'Customers' }).click();
|
||||
await page.getByText('Customer A').click();
|
||||
await page.getByRole('tab', { name: 'Notes' }).click();
|
||||
await page.getByRole('tab', { name: 'Attachments' }).click();
|
||||
await page.getByRole('tab', { name: 'Contacts' }).click();
|
||||
await page.getByRole('tab', { name: 'Assigned Stock' }).click();
|
||||
await page.getByRole('tab', { name: 'Return Orders' }).click();
|
||||
await page.getByRole('tab', { name: 'Sales Orders' }).click();
|
||||
await page.getByRole('tab', { name: 'Contacts' }).click();
|
||||
await page.getByRole('cell', { name: 'Dorathy Gross' }).waitFor();
|
||||
await page
|
||||
.getByRole('row', { name: 'Dorathy Gross dorathy.gross@customer.com' })
|
||||
.waitFor();
|
||||
|
||||
// Sales Order Details
|
||||
await page.getByRole('tab', { name: 'Sales Orders' }).click();
|
||||
await page.getByRole('cell', { name: 'SO0001' }).click();
|
||||
await page
|
||||
.getByLabel('Order Details')
|
||||
.getByText('Selling some stuff')
|
||||
.waitFor();
|
||||
await page.getByRole('tab', { name: 'Line Items' }).click();
|
||||
await page.getByRole('tab', { name: 'Shipments' }).click();
|
||||
await page.getByRole('tab', { name: 'Build Orders' }).click();
|
||||
await page.getByText('No records found').first().waitFor();
|
||||
await page.getByRole('tab', { name: 'Attachments' }).click();
|
||||
await page.getByText('No attachments found').first().waitFor();
|
||||
await page.getByRole('tab', { name: 'Notes' }).click();
|
||||
await page.getByRole('tab', { name: 'Order Details' }).click();
|
||||
|
||||
// Return Order Details
|
||||
await page.getByRole('link', { name: 'Customer A' }).click();
|
||||
await page.getByRole('tab', { name: 'Return Orders' }).click();
|
||||
await page.getByRole('cell', { name: 'RMA-' }).click();
|
||||
await page.getByText('RMA-0001', { exact: true }).waitFor();
|
||||
await page.getByRole('tab', { name: 'Line Items' }).click();
|
||||
await page.getByRole('tab', { name: 'Attachments' }).click();
|
||||
await page.getByRole('tab', { name: 'Notes' }).click();
|
||||
});
|
||||
|
||||
test('Company', async ({ page }) => {
|
||||
await doQuickLogin(page);
|
||||
|
||||
|
@ -92,8 +92,6 @@ test('Login - Failures', async ({ page }) => {
|
||||
await page.getByLabel('login-password').fill('');
|
||||
|
||||
await loginWithError();
|
||||
|
||||
await page.waitForTimeout(2500);
|
||||
});
|
||||
|
||||
test('Login - Change Password', async ({ page }) => {
|
||||
|
@ -122,7 +122,4 @@ test('Plugins - Locate Item', async ({ page, request }) => {
|
||||
await page.getByLabel('action-button-locate-item').click();
|
||||
await page.getByRole('button', { name: 'Submit' }).click();
|
||||
await page.getByText('Item location requested').waitFor();
|
||||
|
||||
await page.waitForTimeout(2500);
|
||||
return;
|
||||
});
|
||||
|