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)
|
- Barcodes can be embedded in [labels or reports](../report/barcodes.md)
|
||||||
- Barcode functionality can be [extended via plugins](../extend/plugins/barcode.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:
|
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 Item](../stock/stock.md#stock-item)
|
||||||
- [Stock Location](../stock/stock.md#stock-location)
|
- [Stock Location](../stock/stock.md#stock-location)
|
||||||
- [Supplier Part](../order/company.md#supplier-parts)
|
- [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
|
## 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)
|
### Input Modes
|
||||||
- Webcam (image processing)
|
|
||||||
|
|
||||||
### 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" %}
|
### Quick Scan
|
||||||
{% include 'img.html' %}
|
|
||||||
{% endwith %}
|
|
||||||
|
|
||||||
### Scanning
|
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:
|
||||||
|
|
||||||
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:
|
|
||||||
|
|
||||||
{% with id="barcode_scan", url="barcode/barcode_scan.png", description="Barcode scan" %}
|
{% with id="barcode_scan", url="barcode/barcode_scan.png", description="Barcode scan" %}
|
||||||
{% include 'img.html' %}
|
{% include 'img.html' %}
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
|
|
||||||
#### No Match Found
|
|
||||||
|
|
||||||
If no match is found for the scanned barcode, the following error message is displayed:
|
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" %}
|
{% with id="barcode_no_match", url="barcode/barcode_no_match.png", description="No match for barcode" %}
|
||||||
{% include 'img.html' %}
|
{% include 'img.html' %}
|
||||||
{% endwith %}
|
{% 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
|
## 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
|
## 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:
|
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' %}
|
{% include 'img.html' %}
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
|
|
||||||
|
@ -91,6 +91,8 @@ Configuration of barcode functionality:
|
|||||||
{{ globalsetting("BARCODE_STORE_RESULTS") }}
|
{{ globalsetting("BARCODE_STORE_RESULTS") }}
|
||||||
{{ globalsetting("BARCODE_RESULTS_MAX_NUM") }}
|
{{ globalsetting("BARCODE_RESULTS_MAX_NUM") }}
|
||||||
|
|
||||||
|
Read more about [barcode scanning](../barcodes/barcodes.md).
|
||||||
|
|
||||||
### Pricing and Currency
|
### Pricing and Currency
|
||||||
|
|
||||||
Configuration of pricing data and currency support:
|
Configuration of pricing data and currency support:
|
||||||
|
@ -1,13 +1,16 @@
|
|||||||
"""InvenTree API version information."""
|
"""InvenTree API version information."""
|
||||||
|
|
||||||
# InvenTree API version
|
# 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."""
|
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
|
||||||
|
|
||||||
|
|
||||||
INVENTREE_API_TEXT = """
|
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
|
v295 - 2024-12-23 : https://github.com/inventree/InvenTree/pull/8746
|
||||||
- Improve API documentation for build APIs
|
- Improve API documentation for build APIs
|
||||||
|
|
||||||
|
@ -135,15 +135,15 @@ class InvenTreeInternalBarcodePlugin(SettingsMixin, BarcodeMixin, InvenTreePlugi
|
|||||||
|
|
||||||
def generate(self, model_instance: InvenTreeBarcodeMixin):
|
def generate(self, model_instance: InvenTreeBarcodeMixin):
|
||||||
"""Generate a barcode for a given model instance."""
|
"""Generate a barcode for a given model instance."""
|
||||||
barcode_format = self.get_setting('INTERNAL_BARCODE_FORMAT')
|
barcode_format = self.get_setting(
|
||||||
|
'INTERNAL_BARCODE_FORMAT', backup_value='json'
|
||||||
if barcode_format == 'json':
|
)
|
||||||
return json.dumps({model_instance.barcode_model_type(): model_instance.pk})
|
|
||||||
|
|
||||||
if barcode_format == 'short':
|
if barcode_format == 'short':
|
||||||
prefix = self.get_setting('SHORT_BARCODE_PREFIX')
|
prefix = self.get_setting('SHORT_BARCODE_PREFIX')
|
||||||
model_type_code = model_instance.barcode_model_type_code()
|
model_type_code = model_instance.barcode_model_type_code()
|
||||||
|
|
||||||
return f'{prefix}{model_type_code}{model_instance.pk}'
|
return f'{prefix}{model_type_code}{model_instance.pk}'
|
||||||
|
else:
|
||||||
return None
|
# Default = JSON format
|
||||||
|
return json.dumps({model_instance.barcode_model_type(): model_instance.pk})
|
||||||
|
@ -902,8 +902,9 @@ class StockApiMixin:
|
|||||||
try:
|
try:
|
||||||
params = self.request.query_params
|
params = self.request.query_params
|
||||||
|
|
||||||
|
kwargs['part_detail'] = str2bool(params.get('part_detail', True))
|
||||||
|
|
||||||
for key in [
|
for key in [
|
||||||
'part_detail',
|
|
||||||
'path_detail',
|
'path_detail',
|
||||||
'location_detail',
|
'location_detail',
|
||||||
'supplier_part_detail',
|
'supplier_part_detail',
|
||||||
|
@ -11,8 +11,8 @@ export const api = axios.create({});
|
|||||||
* Setup default settings for the Axios API instance.
|
* Setup default settings for the Axios API instance.
|
||||||
*/
|
*/
|
||||||
export function setApiDefaults() {
|
export function setApiDefaults() {
|
||||||
const host = useLocalState.getState().host;
|
const { host } = useLocalState.getState();
|
||||||
const token = useUserState.getState().token;
|
const { token } = useUserState.getState();
|
||||||
|
|
||||||
api.defaults.baseURL = host;
|
api.defaults.baseURL = host;
|
||||||
api.defaults.timeout = 2500;
|
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 { modals } from '@mantine/modals';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import QR from 'qrcode';
|
import QR from 'qrcode';
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
import { api } from '../../App';
|
import { api } from '../../App';
|
||||||
import { ApiEndpoints } from '../../enums/ApiEndpoints';
|
import { ApiEndpoints } from '../../enums/ApiEndpoints';
|
||||||
import { apiUrl } from '../../states/ApiState';
|
import { apiUrl } from '../../states/ApiState';
|
||||||
import { useGlobalSettingsState } from '../../states/SettingsState';
|
import { useGlobalSettingsState } from '../../states/SettingsState';
|
||||||
import { CopyButton } from '../buttons/CopyButton';
|
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';
|
import { BarcodeInput } from './BarcodeInput';
|
||||||
|
|
||||||
type QRCodeProps = {
|
type QRCodeProps = {
|
||||||
@ -76,12 +78,13 @@ export const InvenTreeQRCode = ({
|
|||||||
const { data } = useQuery({
|
const { data } = useQuery({
|
||||||
queryKey: ['qr-code', mdl_prop.model, mdl_prop.pk],
|
queryKey: ['qr-code', mdl_prop.model, mdl_prop.pk],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const res = await api.post(apiUrl(ApiEndpoints.barcode_generate), {
|
return api
|
||||||
|
.post(apiUrl(ApiEndpoints.barcode_generate), {
|
||||||
model: mdl_prop.model,
|
model: mdl_prop.model,
|
||||||
pk: mdl_prop.pk
|
pk: mdl_prop.pk
|
||||||
});
|
})
|
||||||
|
.then((res) => res.data?.barcode ?? ('' as string))
|
||||||
return res.data?.barcode as string;
|
.catch((error) => '');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -146,37 +149,33 @@ export const InvenTreeQRCode = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const QRCodeLink = ({ mdl_prop }: { mdl_prop: QrCodeType }) => {
|
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
|
api
|
||||||
.post(apiUrl(ApiEndpoints.barcode_link), {
|
.post(apiUrl(ApiEndpoints.barcode_link), {
|
||||||
[mdl_prop.model]: mdl_prop.pk,
|
[mdl_prop.model]: mdl_prop.pk,
|
||||||
barcode: value || barcode
|
barcode: barcode
|
||||||
})
|
})
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
|
setError('');
|
||||||
modals.closeAll();
|
modals.closeAll();
|
||||||
location.reload();
|
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 (
|
return (
|
||||||
<Stack gap='xs'>
|
<Stack gap='xs'>
|
||||||
<Divider />
|
<Divider />
|
||||||
<BarcodeInput
|
<BarcodeInput onScan={linkBarcode} actionText={t`Link`} error={error} />
|
||||||
value={barcode}
|
|
||||||
onChange={(event) => setBarcode(event.currentTarget.value)}
|
|
||||||
onScan={actionSubmit}
|
|
||||||
onAction={handleLinkBarcode}
|
|
||||||
actionText={t`Link`}
|
|
||||||
/>
|
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@ -194,11 +193,12 @@ export const QRCodeUnlink = ({ mdl_prop }: { mdl_prop: QrCodeType }) => {
|
|||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<Box>
|
<Box>
|
||||||
<Stack gap='xs'>
|
<Stack gap='sm'>
|
||||||
<Divider />
|
<Divider />
|
||||||
<Text>
|
<Text>
|
||||||
<Trans>This will remove the link to the associated barcode</Trans>
|
<Trans>This will remove the link to the associated barcode</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
|
<Divider />
|
||||||
<Group grow>
|
<Group grow>
|
||||||
<Button color='red' onClick={unlinkBarcode}>
|
<Button color='red' onClick={unlinkBarcode}>
|
||||||
<Trans>Unlink Barcode</Trans>
|
<Trans>Unlink Barcode</Trans>
|
@ -1,25 +1,25 @@
|
|||||||
import { t } from '@lingui/macro';
|
import { t } from '@lingui/macro';
|
||||||
import { ActionIcon } from '@mantine/core';
|
import { ActionIcon } from '@mantine/core';
|
||||||
import { openContextModal } from '@mantine/modals';
|
import { useDisclosure } from '@mantine/hooks';
|
||||||
import { IconQrcode } from '@tabler/icons-react';
|
import { IconQrcode } from '@tabler/icons-react';
|
||||||
|
import BarcodeScanDialog from '../barcodes/BarcodeScanDialog';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A button which opens the QR code scanner modal
|
* A button which opens the QR code scanner modal
|
||||||
*/
|
*/
|
||||||
export function ScanButton() {
|
export function ScanButton() {
|
||||||
|
const [opened, { open, close }] = useDisclosure(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
onClick={() =>
|
onClick={open}
|
||||||
openContextModal({
|
|
||||||
modal: 'qr',
|
|
||||||
title: t`Scan Barcode`,
|
|
||||||
innerProps: {}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
variant='transparent'
|
variant='transparent'
|
||||||
title={t`Open Barcode Scanner`}
|
title={t`Open Barcode Scanner`}
|
||||||
>
|
>
|
||||||
<IconQrcode />
|
<IconQrcode />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
|
<BarcodeScanDialog opened={opened} onClose={close} />
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -22,7 +22,8 @@ import { type ReactNode, useMemo } from 'react';
|
|||||||
import type { ModelType } from '../../enums/ModelType';
|
import type { ModelType } from '../../enums/ModelType';
|
||||||
import { identifierString } from '../../functions/conversion';
|
import { identifierString } from '../../functions/conversion';
|
||||||
import { InvenTreeIcon } from '../../functions/icons';
|
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 = {
|
export type ActionDropdownItem = {
|
||||||
icon?: ReactNode;
|
icon?: ReactNode;
|
||||||
@ -213,7 +214,7 @@ function GeneralBarcodeAction({
|
|||||||
}): ActionDropdownItem {
|
}): ActionDropdownItem {
|
||||||
const onClick = () => {
|
const onClick = () => {
|
||||||
modals.open({
|
modals.open({
|
||||||
title: title,
|
title: <StylishText size='xl'>{title}</StylishText>,
|
||||||
children: <ChildItem mdl_prop={mdl_prop} />
|
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 { api } from '../../App';
|
||||||
import { ModelType } from '../../enums/ModelType';
|
import { ModelType } from '../../enums/ModelType';
|
||||||
import { navigateToLink } from '../../functions/navigation';
|
import { navigateToLink } from '../../functions/navigation';
|
||||||
|
import { shortenString } from '../../functions/tables';
|
||||||
import { apiUrl } from '../../states/ApiState';
|
import { apiUrl } from '../../states/ApiState';
|
||||||
import { Thumbnail } from '../images/Thumbnail';
|
import { Thumbnail } from '../images/Thumbnail';
|
||||||
import { RenderBuildItem, RenderBuildLine, RenderBuildOrder } from './Build';
|
import { RenderBuildItem, RenderBuildLine, RenderBuildOrder } from './Build';
|
||||||
@ -193,6 +194,16 @@ export function RenderInlineModel({
|
|||||||
[url, navigate]
|
[url, navigate]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const primaryText = shortenString({
|
||||||
|
str: primary,
|
||||||
|
len: 50
|
||||||
|
});
|
||||||
|
|
||||||
|
const secondaryText = shortenString({
|
||||||
|
str: secondary,
|
||||||
|
len: 75
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Group gap='xs' justify='space-between' wrap='nowrap' title={tooltip}>
|
<Group gap='xs' justify='space-between' wrap='nowrap' title={tooltip}>
|
||||||
<Group gap='xs' justify='left' wrap='nowrap'>
|
<Group gap='xs' justify='left' wrap='nowrap'>
|
||||||
@ -200,12 +211,12 @@ export function RenderInlineModel({
|
|||||||
{image && <Thumbnail src={image} size={18} />}
|
{image && <Thumbnail src={image} size={18} />}
|
||||||
{url ? (
|
{url ? (
|
||||||
<Anchor href='' onClick={(event: any) => onClick(event)}>
|
<Anchor href='' onClick={(event: any) => onClick(event)}>
|
||||||
<Text size='sm'>{primary}</Text>
|
<Text size='sm'>{primaryText}</Text>
|
||||||
</Anchor>
|
</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>
|
</Group>
|
||||||
{suffix && (
|
{suffix && (
|
||||||
<>
|
<>
|
||||||
|
@ -67,7 +67,7 @@ export function RenderStockItem(
|
|||||||
<RenderInlineModel
|
<RenderInlineModel
|
||||||
{...props}
|
{...props}
|
||||||
primary={instance.part_detail?.full_name}
|
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}
|
image={instance.part_detail?.thumbnail || instance.part_detail?.image}
|
||||||
url={
|
url={
|
||||||
props.link ? getDetailUrl(ModelType.stockitem, instance.pk) : undefined
|
props.link ? getDetailUrl(ModelType.stockitem, instance.pk) : undefined
|
||||||
|
@ -6,7 +6,6 @@ import { ContextMenuProvider } from 'mantine-contextmenu';
|
|||||||
|
|
||||||
import { AboutInvenTreeModal } from '../components/modals/AboutInvenTreeModal';
|
import { AboutInvenTreeModal } from '../components/modals/AboutInvenTreeModal';
|
||||||
import { LicenseModal } from '../components/modals/LicenseModal';
|
import { LicenseModal } from '../components/modals/LicenseModal';
|
||||||
import { QrCodeModal } from '../components/modals/QrCodeModal';
|
|
||||||
import { ServerInfoModal } from '../components/modals/ServerInfoModal';
|
import { ServerInfoModal } from '../components/modals/ServerInfoModal';
|
||||||
import { useLocalState } from '../states/LocalState';
|
import { useLocalState } from '../states/LocalState';
|
||||||
import { LanguageContext } from './LanguageContext';
|
import { LanguageContext } from './LanguageContext';
|
||||||
@ -46,7 +45,6 @@ export function ThemeContext({
|
|||||||
<ModalsProvider
|
<ModalsProvider
|
||||||
labels={{ confirm: t`Submit`, cancel: t`Cancel` }}
|
labels={{ confirm: t`Submit`, cancel: t`Cancel` }}
|
||||||
modals={{
|
modals={{
|
||||||
qr: QrCodeModal,
|
|
||||||
info: ServerInfoModal,
|
info: ServerInfoModal,
|
||||||
about: AboutInvenTreeModal,
|
about: AboutInvenTreeModal,
|
||||||
license: LicenseModal
|
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 { t } from '@lingui/macro';
|
||||||
import { notifications } from '@mantine/notifications';
|
import { notifications } from '@mantine/notifications';
|
||||||
import { IconCircleCheck, IconExclamationCircle } from '@tabler/icons-react';
|
import { IconCircleCheck, IconExclamationCircle } from '@tabler/icons-react';
|
||||||
|
import { extractErrorMessage } from './api';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Show a notification that the feature is not yet implemented
|
* Show a notification that the feature is not yet implemented
|
||||||
@ -86,19 +87,15 @@ export function showApiErrorMessage({
|
|||||||
message?: string;
|
message?: string;
|
||||||
field?: string;
|
field?: string;
|
||||||
}) {
|
}) {
|
||||||
// Extract error description from response
|
const errorMessage = extractErrorMessage({
|
||||||
const error_data: any = error.response?.data ?? {};
|
error: error,
|
||||||
|
field: field,
|
||||||
let error_msg: any =
|
defaultMessage: message
|
||||||
message ?? error_data[field ?? 'error'] ?? error_data['non_field_errors'];
|
});
|
||||||
|
|
||||||
if (!error_msg) {
|
|
||||||
error_msg = t`An error occurred`;
|
|
||||||
}
|
|
||||||
|
|
||||||
notifications.show({
|
notifications.show({
|
||||||
title: title,
|
title: title,
|
||||||
message: error_msg,
|
message: errorMessage,
|
||||||
color: 'red'
|
color: 'red'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -38,6 +38,7 @@ import type { TableFilter } from '../tables/Filter';
|
|||||||
* records: An array of records (rows) in the table
|
* records: An array of records (rows) in the table
|
||||||
* setRecords: A function to set the records
|
* setRecords: A function to set the records
|
||||||
* updateRecord: A function to update a single record in the table
|
* 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 = {
|
export type TableState = {
|
||||||
tableKey: string;
|
tableKey: string;
|
||||||
@ -54,7 +55,7 @@ export type TableState = {
|
|||||||
setExpandedRecords: (records: any[]) => void;
|
setExpandedRecords: (records: any[]) => void;
|
||||||
isRowExpanded: (pk: number) => boolean;
|
isRowExpanded: (pk: number) => boolean;
|
||||||
selectedRecords: any[];
|
selectedRecords: any[];
|
||||||
selectedIds: number[];
|
selectedIds: any[];
|
||||||
hasSelectedRecords: boolean;
|
hasSelectedRecords: boolean;
|
||||||
setSelectedRecords: (records: any[]) => void;
|
setSelectedRecords: (records: any[]) => void;
|
||||||
clearSelectedRecords: () => void;
|
clearSelectedRecords: () => void;
|
||||||
@ -71,6 +72,7 @@ export type TableState = {
|
|||||||
records: any[];
|
records: any[];
|
||||||
setRecords: (records: any[]) => void;
|
setRecords: (records: any[]) => void;
|
||||||
updateRecord: (record: any) => void;
|
updateRecord: (record: any) => void;
|
||||||
|
idAccessor?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -79,7 +81,7 @@ export type TableState = {
|
|||||||
* Refer to the TableState type definition for more information.
|
* 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 to generate a new ID (to refresh the table)
|
||||||
function generateTableName() {
|
function generateTableName() {
|
||||||
return `${tableName.replaceAll('-', '')}-${randomId()}`;
|
return `${tableName.replaceAll('-', '')}-${randomId()}`;
|
||||||
@ -127,7 +129,7 @@ export function useTable(tableName: string): TableState {
|
|||||||
|
|
||||||
// Array of selected primary key values
|
// Array of selected primary key values
|
||||||
const selectedIds = useMemo(
|
const selectedIds = useMemo(
|
||||||
() => selectedRecords.map((r) => r.pk ?? r.id),
|
() => selectedRecords.map((r) => r[idAccessor || 'pk']),
|
||||||
[selectedRecords]
|
[selectedRecords]
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -164,7 +166,9 @@ export function useTable(tableName: string): TableState {
|
|||||||
const _records = [...records];
|
const _records = [...records];
|
||||||
|
|
||||||
// Find the matching record in the table
|
// 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) {
|
if (index >= 0) {
|
||||||
_records[index] = {
|
_records[index] = {
|
||||||
@ -213,6 +217,7 @@ export function useTable(tableName: string): TableState {
|
|||||||
setPageSize,
|
setPageSize,
|
||||||
records,
|
records,
|
||||||
setRecords,
|
setRecords,
|
||||||
updateRecord
|
updateRecord,
|
||||||
|
idAccessor
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -1,205 +1,134 @@
|
|||||||
import { Trans, t } from '@lingui/macro';
|
import { Trans, t } from '@lingui/macro';
|
||||||
import {
|
import {
|
||||||
ActionIcon,
|
ActionIcon,
|
||||||
Badge,
|
Alert,
|
||||||
Button,
|
Divider,
|
||||||
Checkbox,
|
|
||||||
Container,
|
|
||||||
Grid,
|
Grid,
|
||||||
Group,
|
Group,
|
||||||
ScrollArea,
|
Paper,
|
||||||
Select,
|
|
||||||
Space,
|
Space,
|
||||||
Stack,
|
Stack,
|
||||||
Table,
|
Text
|
||||||
Text,
|
|
||||||
TextInput,
|
|
||||||
rem
|
|
||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
import {
|
import { randomId, useListState, useLocalStorage } from '@mantine/hooks';
|
||||||
getHotkeyHandler,
|
|
||||||
randomId,
|
|
||||||
useDocumentVisibility,
|
|
||||||
useFullscreen,
|
|
||||||
useListState,
|
|
||||||
useLocalStorage
|
|
||||||
} from '@mantine/hooks';
|
|
||||||
import { showNotification } from '@mantine/notifications';
|
|
||||||
import {
|
import {
|
||||||
IconAlertCircle,
|
IconAlertCircle,
|
||||||
IconArrowsMaximize,
|
|
||||||
IconArrowsMinimize,
|
|
||||||
IconLink,
|
|
||||||
IconNumber,
|
IconNumber,
|
||||||
IconPlayerPlayFilled,
|
IconQuestionMark
|
||||||
IconPlayerStopFilled,
|
|
||||||
IconPlus,
|
|
||||||
IconQuestionMark,
|
|
||||||
IconSearch,
|
|
||||||
IconTrash,
|
|
||||||
IconX
|
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import { Html5Qrcode } from 'html5-qrcode';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import type { CameraDevice } from 'html5-qrcode/camera/core';
|
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
|
||||||
|
|
||||||
|
import { showNotification } from '@mantine/notifications';
|
||||||
import { api } from '../../App';
|
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 { StylishText } from '../../components/items/StylishText';
|
||||||
import { TitleWithDoc } from '../../components/items/TitleWithDoc';
|
|
||||||
import PageTitle from '../../components/nav/PageTitle';
|
import PageTitle from '../../components/nav/PageTitle';
|
||||||
import { RenderInstance } from '../../components/render/Instance';
|
|
||||||
import { ModelInformationDict } from '../../components/render/ModelType';
|
import { ModelInformationDict } from '../../components/render/ModelType';
|
||||||
import { ApiEndpoints } from '../../enums/ApiEndpoints';
|
import { ApiEndpoints } from '../../enums/ApiEndpoints';
|
||||||
import { ModelType } from '../../enums/ModelType';
|
import type { ModelType } from '../../enums/ModelType';
|
||||||
import { notYetImplemented } from '../../functions/notifications';
|
import {
|
||||||
import { IS_DEV_OR_DEMO } from '../../main';
|
notYetImplemented,
|
||||||
|
showApiErrorMessage
|
||||||
|
} from '../../functions/notifications';
|
||||||
import { apiUrl } from '../../states/ApiState';
|
import { apiUrl } from '../../states/ApiState';
|
||||||
|
import BarcodeScanTable from '../../tables/general/BarcodeScanTable';
|
||||||
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];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Scan() {
|
export default function Scan() {
|
||||||
const { toggle: toggleFullscreen, fullscreen } = useFullscreen();
|
const [history, historyHandlers] = useListState<BarcodeScanItem>([]);
|
||||||
const [history, historyHandlers] = useListState<ScanItem>([]);
|
|
||||||
const [historyStorage, setHistoryStorage] = useLocalStorage<ScanItem[]>({
|
const [historyStorage, setHistoryStorage] = useLocalStorage<
|
||||||
|
BarcodeScanItem[]
|
||||||
|
>({
|
||||||
key: 'scan-history',
|
key: 'scan-history',
|
||||||
defaultValue: []
|
defaultValue: []
|
||||||
});
|
});
|
||||||
|
|
||||||
const [selection, setSelection] = useState<string[]>([]);
|
const [selection, setSelection] = useState<string[]>([]);
|
||||||
const [inputValue, setInputValue] = useLocalStorage<string | null>({
|
|
||||||
key: 'input-selection',
|
// Fetch model instance based on scan item
|
||||||
defaultValue: null
|
const fetchInstance = useCallback(
|
||||||
|
(item: BarcodeScanItem) => {
|
||||||
|
if (!item.model || !item.pk) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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;
|
||||||
// button handlers
|
|
||||||
function btnRunSelectedBarcode() {
|
|
||||||
const item = getSelectedItem(selection[0]);
|
|
||||||
if (!item) return;
|
|
||||||
runBarcode(item?.ref, item?.id);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectionLinked =
|
|
||||||
selection.length === 1 && getSelectedItem(selection[0])?.link != undefined;
|
|
||||||
|
|
||||||
function btnOpenSelectedLink() {
|
|
||||||
const item = getSelectedItem(selection[0]);
|
|
||||||
if (!item) return;
|
|
||||||
if (!selectionLinked) return;
|
|
||||||
window.open(item.link, '_blank');
|
|
||||||
}
|
|
||||||
|
|
||||||
function btnDeleteFullHistory() {
|
|
||||||
historyHandlers.setState([]);
|
|
||||||
setHistoryStorage([]);
|
|
||||||
setSelection([]);
|
|
||||||
}
|
|
||||||
|
|
||||||
function btnDeleteHistory() {
|
|
||||||
historyHandlers.setState(
|
|
||||||
history.filter((item) => !selection.includes(item.id))
|
|
||||||
);
|
|
||||||
setSelection([]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
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];
|
const model_info = ModelInformationDict[item.model];
|
||||||
|
|
||||||
if (model_info?.api_endpoint) {
|
|
||||||
const url = apiUrl(model_info.api_endpoint, item.pk);
|
|
||||||
|
|
||||||
api
|
api
|
||||||
.get(url)
|
.get(apiUrl(model_info.api_endpoint, item.pk))
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
item.instance = response.data;
|
item.instance = response.data;
|
||||||
const list_idx = history.findIndex((i) => i.id === id);
|
historyHandlers.append(item);
|
||||||
historyHandlers.setItem(list_idx, item);
|
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((error) => {
|
||||||
console.error('error while fetching instance data at', url);
|
showApiErrorMessage({
|
||||||
console.info(err);
|
error: error,
|
||||||
|
title: t`API Error`,
|
||||||
|
message: t`Failed to fetch instance data`
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[api, history]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Barcode scanning callback function
|
||||||
|
const scanBarcode = useCallback(
|
||||||
|
(barcode: string) => {
|
||||||
|
api
|
||||||
|
.post(apiUrl(ApiEndpoints.barcode), { barcode: barcode })
|
||||||
|
.then((response) => {
|
||||||
|
const data = response?.data ?? {};
|
||||||
|
|
||||||
|
let match = false;
|
||||||
|
|
||||||
|
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[]) {
|
// If no match is found, add an empty result
|
||||||
for (const item of items) {
|
if (!match) {
|
||||||
historyHandlers.append(item);
|
historyHandlers.append({
|
||||||
runBarcode(item.ref, item.id);
|
id: randomId(),
|
||||||
}
|
barcode: barcode,
|
||||||
setSelection(items.map((item) => item.id));
|
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
|
// save history data to session storage
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -208,61 +137,27 @@ export default function Scan() {
|
|||||||
}, [history]);
|
}, [history]);
|
||||||
|
|
||||||
// load data from session storage on mount
|
// load data from session storage on mount
|
||||||
if (history.length === 0 && historyStorage.length != 0) {
|
useEffect(() => {
|
||||||
historyHandlers.setState(historyStorage);
|
historyHandlers.setState(historyStorage);
|
||||||
}
|
}, [historyStorage]);
|
||||||
|
|
||||||
// input stuff
|
// Items selected for action
|
||||||
const inputOptions = [
|
const selectedItems: BarcodeScanItem[] = useMemo(() => {
|
||||||
{ value: InputMethod.Manual, label: t`Manual input` },
|
return history.filter((item) => selection.includes(item.id));
|
||||||
{ value: InputMethod.ImageBarcode, label: t`Image Barcode` }
|
}, [selection, history]);
|
||||||
];
|
|
||||||
|
|
||||||
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>;
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|
||||||
// selected actions component
|
// selected actions component
|
||||||
const SelectedActions = () => {
|
const SelectedActions = useMemo(() => {
|
||||||
const uniqueObjectTypes = [
|
const uniqueObjectTypes = new Set(selectedItems.map((item) => item.model));
|
||||||
...new Set(
|
|
||||||
selection
|
|
||||||
.map((id) => {
|
|
||||||
return history.find((item) => item.id === id)?.model;
|
|
||||||
})
|
|
||||||
.filter((item) => item != undefined)
|
|
||||||
)
|
|
||||||
];
|
|
||||||
|
|
||||||
if (uniqueObjectTypes.length === 0) {
|
if (uniqueObjectTypes.size === 0) {
|
||||||
return (
|
return (
|
||||||
<Group gap={0}>
|
<Group gap={0}>
|
||||||
<IconQuestionMark color='orange' />
|
<IconQuestionMark color='orange' />
|
||||||
<Trans>Selected elements are not known</Trans>
|
<Trans>Selected elements are not known</Trans>
|
||||||
</Group>
|
</Group>
|
||||||
);
|
);
|
||||||
} else if (uniqueObjectTypes.length > 1) {
|
} else if (uniqueObjectTypes.size > 1) {
|
||||||
return (
|
return (
|
||||||
<Group gap={0}>
|
<Group gap={0}>
|
||||||
<IconAlertCircle color='orange' />
|
<IconAlertCircle color='orange' />
|
||||||
@ -270,10 +165,11 @@ export default function Scan() {
|
|||||||
</Group>
|
</Group>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Text fz='sm' c='dimmed'>
|
<Text fz='sm' c='dimmed'>
|
||||||
<Trans>Actions for {uniqueObjectTypes[0]} </Trans>
|
<Trans>Actions ... </Trans>
|
||||||
</Text>
|
</Text>
|
||||||
<Group>
|
<Group>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
@ -286,499 +182,72 @@ export default function Scan() {
|
|||||||
</Group>
|
</Group>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
}, [selectedItems]);
|
||||||
|
|
||||||
// rendering
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageTitle title={t`Barcode Scanning`} />
|
<PageTitle title={t`Barcode Scanning`} />
|
||||||
<Group justify='space-between'>
|
<Group justify='space-between'>
|
||||||
<Group justify='left'>
|
<Group justify='left'>
|
||||||
<StylishText>
|
<StylishText size='xl'>
|
||||||
<Trans>Scan Page</Trans>
|
<Trans>Barcode Scanning</Trans>
|
||||||
</StylishText>
|
</StylishText>
|
||||||
<DocInfo
|
|
||||||
text={t`This page can be used for continuously scanning items and taking actions on them.`}
|
|
||||||
/>
|
|
||||||
</Group>
|
</Group>
|
||||||
<Button
|
|
||||||
onClick={toggleFullscreen}
|
|
||||||
size='sm'
|
|
||||||
variant='subtle'
|
|
||||||
title={t`Toggle Fullscreen`}
|
|
||||||
>
|
|
||||||
{fullscreen ? <IconArrowsMaximize /> : <IconArrowsMinimize />}
|
|
||||||
</Button>
|
|
||||||
</Group>
|
</Group>
|
||||||
<Space h={'md'} />
|
<Space h={'md'} />
|
||||||
<Grid maw={'100%'}>
|
<Grid maw={'100%'}>
|
||||||
<Grid.Col span={4}>
|
<Grid.Col span={4}>
|
||||||
<Stack>
|
<Paper p='sm' shadow='xs'>
|
||||||
<Stack gap='xs'>
|
<Stack gap='xs'>
|
||||||
<Group justify='space-between'>
|
<StylishText size='lg'>{t`Barcode Input`}</StylishText>
|
||||||
<TitleWithDoc
|
<Divider />
|
||||||
order={3}
|
<BarcodeInput onScan={scanBarcode} />
|
||||||
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}
|
|
||||||
</Stack>
|
</Stack>
|
||||||
<Stack gap={0}>
|
</Paper>
|
||||||
<TitleWithDoc
|
<Paper p='sm' shadow='xs'>
|
||||||
order={3}
|
<Stack gap='xs'>
|
||||||
text={t`Depending on the selected parts actions will be shown here. Not all barcode types are supported currently.`}
|
<StylishText size='lg'>{t`Action`}</StylishText>
|
||||||
>
|
<Divider />
|
||||||
<Trans>Action</Trans>
|
|
||||||
</TitleWithDoc>
|
|
||||||
{selection.length === 0 ? (
|
{selection.length === 0 ? (
|
||||||
<Text>
|
<Alert title={t`No Items Selected`} color='blue'>
|
||||||
<Trans>No selection</Trans>
|
<Trans>Scan and select items to perform actions</Trans>
|
||||||
</Text>
|
</Alert>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Text>
|
<Text>
|
||||||
<Trans>{selection.length} items selected</Trans>
|
<Trans>{selection.length} items selected</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
<Text fz='sm' c='dimmed'>
|
{SelectedActions}
|
||||||
<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 />
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
</Stack>
|
</Paper>
|
||||||
</Grid.Col>
|
</Grid.Col>
|
||||||
<Grid.Col span={8}>
|
<Grid.Col span={8}>
|
||||||
|
<Paper p='sm' shadow='xs'>
|
||||||
|
<Stack gap='xs'>
|
||||||
<Group justify='space-between'>
|
<Group justify='space-between'>
|
||||||
<TitleWithDoc
|
<StylishText size='lg'>{t`Scanned Items`}</StylishText>
|
||||||
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>
|
</Group>
|
||||||
<HistoryTable
|
<Divider />
|
||||||
data={history}
|
<BarcodeScanTable
|
||||||
selection={selection}
|
records={history}
|
||||||
setSelection={setSelection}
|
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.Col>
|
||||||
</Grid>
|
</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({
|
export function CurrencyTable({
|
||||||
setInfo
|
setInfo
|
||||||
}: Readonly<{ setInfo: (info: any) => void }>) {
|
}: Readonly<{ setInfo: (info: any) => void }>) {
|
||||||
const table = useTable('currency');
|
const table = useTable('currency', 'currency');
|
||||||
const columns = useMemo(() => {
|
const columns = useMemo(() => {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
@ -71,7 +71,6 @@ export function CurrencyTable({
|
|||||||
tableState={table}
|
tableState={table}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
props={{
|
props={{
|
||||||
idAccessor: 'currency',
|
|
||||||
tableActions: tableActions,
|
tableActions: tableActions,
|
||||||
dataFormatter: (data: any) => {
|
dataFormatter: (data: any) => {
|
||||||
setInfo(data);
|
setInfo(data);
|
||||||
|
@ -11,7 +11,7 @@ import { InvenTreeTable } from '../../../../tables/InvenTreeTable';
|
|||||||
import CustomUnitsTable from '../../../../tables/settings/CustomUnitsTable';
|
import CustomUnitsTable from '../../../../tables/settings/CustomUnitsTable';
|
||||||
|
|
||||||
function AllUnitTable() {
|
function AllUnitTable() {
|
||||||
const table = useTable('all-units');
|
const table = useTable('all-units', 'name');
|
||||||
const columns = useMemo(() => {
|
const columns = useMemo(() => {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
@ -29,7 +29,6 @@ function AllUnitTable() {
|
|||||||
tableState={table}
|
tableState={table}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
props={{
|
props={{
|
||||||
idAccessor: 'name',
|
|
||||||
enableSearch: false,
|
enableSearch: false,
|
||||||
enablePagination: false,
|
enablePagination: false,
|
||||||
enableColumnSwitching: false,
|
enableColumnSwitching: false,
|
||||||
|
@ -81,7 +81,6 @@ export type InvenTreeTableProps<T = any> = {
|
|||||||
tableFilters?: TableFilter[];
|
tableFilters?: TableFilter[];
|
||||||
tableActions?: React.ReactNode[];
|
tableActions?: React.ReactNode[];
|
||||||
rowExpansion?: DataTableRowExpansionProps<T>;
|
rowExpansion?: DataTableRowExpansionProps<T>;
|
||||||
idAccessor?: string;
|
|
||||||
dataFormatter?: (data: any) => any;
|
dataFormatter?: (data: any) => any;
|
||||||
rowActions?: (record: T) => RowAction[];
|
rowActions?: (record: T) => RowAction[];
|
||||||
onRowClick?: (record: T, index: number, event: any) => void;
|
onRowClick?: (record: T, index: number, event: any) => void;
|
||||||
@ -111,8 +110,7 @@ const defaultInvenTreeTableProps: InvenTreeTableProps = {
|
|||||||
defaultSortColumn: '',
|
defaultSortColumn: '',
|
||||||
barcodeActions: [],
|
barcodeActions: [],
|
||||||
tableFilters: [],
|
tableFilters: [],
|
||||||
tableActions: [],
|
tableActions: []
|
||||||
idAccessor: 'pk'
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -121,11 +119,13 @@ const defaultInvenTreeTableProps: InvenTreeTableProps = {
|
|||||||
export function InvenTreeTable<T extends Record<string, any>>({
|
export function InvenTreeTable<T extends Record<string, any>>({
|
||||||
url,
|
url,
|
||||||
tableState,
|
tableState,
|
||||||
|
tableData,
|
||||||
columns,
|
columns,
|
||||||
props
|
props
|
||||||
}: Readonly<{
|
}: Readonly<{
|
||||||
url: string;
|
url?: string;
|
||||||
tableState: TableState;
|
tableState: TableState;
|
||||||
|
tableData?: any[];
|
||||||
columns: TableColumn<T>[];
|
columns: TableColumn<T>[];
|
||||||
props: InvenTreeTableProps<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
|
// Request OPTIONS data from the API, before we load the table
|
||||||
const tableOptionQuery = useQuery({
|
const tableOptionQuery = useQuery({
|
||||||
enabled: true,
|
enabled: !!url && !tableData,
|
||||||
queryKey: ['options', url, tableState.tableKey, props.enableColumnCaching],
|
queryKey: ['options', url, tableState.tableKey, props.enableColumnCaching],
|
||||||
retry: 3,
|
retry: 3,
|
||||||
refetchOnMount: true,
|
refetchOnMount: true,
|
||||||
gcTime: 5000,
|
gcTime: 5000,
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
|
if (!url) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
if (props.enableColumnCaching == false) {
|
if (props.enableColumnCaching == false) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@ -445,6 +449,10 @@ export function InvenTreeTable<T extends Record<string, any>>({
|
|||||||
const fetchTableData = async () => {
|
const fetchTableData = async () => {
|
||||||
const queryParams = getTableFilters(true);
|
const queryParams = getTableFilters(true);
|
||||||
|
|
||||||
|
if (!url) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
return api
|
return api
|
||||||
.get(url, {
|
.get(url, {
|
||||||
params: queryParams,
|
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: [
|
queryKey: [
|
||||||
'tabledata',
|
'tabledata',
|
||||||
url,
|
url,
|
||||||
@ -511,6 +524,7 @@ export function InvenTreeTable<T extends Record<string, any>>({
|
|||||||
tableState.activeFilters,
|
tableState.activeFilters,
|
||||||
tableState.searchTerm
|
tableState.searchTerm
|
||||||
],
|
],
|
||||||
|
enabled: !!url && !tableData,
|
||||||
queryFn: fetchTableData,
|
queryFn: fetchTableData,
|
||||||
refetchOnMount: true
|
refetchOnMount: true
|
||||||
});
|
});
|
||||||
@ -531,13 +545,15 @@ export function InvenTreeTable<T extends Record<string, any>>({
|
|||||||
|
|
||||||
// Update tableState.records when new data received
|
// Update tableState.records when new data received
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
tableState.setRecords(data ?? []);
|
const data = tableData ?? apiData ?? [];
|
||||||
|
|
||||||
|
tableState.setRecords(data);
|
||||||
|
|
||||||
// set pagesize to length if pagination is disabled
|
// set pagesize to length if pagination is disabled
|
||||||
if (!tableProps.enablePagination) {
|
if (!tableProps.enablePagination) {
|
||||||
tableState.setPageSize(data?.length ?? defaultPageSize);
|
tableState.setPageSize(data?.length ?? defaultPageSize);
|
||||||
}
|
}
|
||||||
}, [data]);
|
}, [tableData, apiData]);
|
||||||
|
|
||||||
// Callback when a cell is clicked
|
// Callback when a cell is clicked
|
||||||
const handleCellClick = useCallback(
|
const handleCellClick = useCallback(
|
||||||
@ -645,13 +661,29 @@ export function InvenTreeTable<T extends Record<string, any>>({
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const optionalParams = useMemo(() => {
|
const optionalParams = useMemo(() => {
|
||||||
const optionalParamsa: Record<string, any> = {};
|
let _params: Record<string, any> = {};
|
||||||
|
|
||||||
if (tableProps.enablePagination) {
|
if (tableProps.enablePagination) {
|
||||||
optionalParamsa['recordsPerPageOptions'] = PAGE_SIZES;
|
_params = {
|
||||||
optionalParamsa['onRecordsPerPageChange'] = updatePageSize;
|
..._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 (
|
return (
|
||||||
<>
|
<>
|
||||||
@ -678,12 +710,8 @@ export function InvenTreeTable<T extends Record<string, any>>({
|
|||||||
highlightOnHover
|
highlightOnHover
|
||||||
loaderType={loader}
|
loaderType={loader}
|
||||||
pinLastColumn={tableProps.rowActions != undefined}
|
pinLastColumn={tableProps.rowActions != undefined}
|
||||||
idAccessor={tableProps.idAccessor}
|
idAccessor={tableState.idAccessor ?? 'pk'}
|
||||||
minHeight={tableProps.minHeight ?? 300}
|
minHeight={tableProps.minHeight ?? 300}
|
||||||
totalRecords={tableState.recordCount}
|
|
||||||
recordsPerPage={tableState.pageSize}
|
|
||||||
page={tableState.page}
|
|
||||||
onPageChange={tableState.setPage}
|
|
||||||
sortStatus={sortStatus}
|
sortStatus={sortStatus}
|
||||||
onSortStatusChange={handleSortStatusChange}
|
onSortStatusChange={handleSortStatusChange}
|
||||||
selectedRecords={
|
selectedRecords={
|
||||||
|
@ -42,7 +42,7 @@ export default function InvenTreeTableHeader({
|
|||||||
filters,
|
filters,
|
||||||
toggleColumn
|
toggleColumn
|
||||||
}: Readonly<{
|
}: Readonly<{
|
||||||
tableUrl: string;
|
tableUrl?: string;
|
||||||
tableState: TableState;
|
tableState: TableState;
|
||||||
tableProps: InvenTreeTableProps<any>;
|
tableProps: InvenTreeTableProps<any>;
|
||||||
hasSwitchableColumns: boolean;
|
hasSwitchableColumns: boolean;
|
||||||
@ -92,7 +92,7 @@ export default function InvenTreeTableHeader({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const deleteRecords = useDeleteApiFormModal({
|
const deleteRecords = useDeleteApiFormModal({
|
||||||
url: tableUrl,
|
url: tableUrl ?? '',
|
||||||
title: t`Delete Selected Items`,
|
title: t`Delete Selected Items`,
|
||||||
preFormContent: (
|
preFormContent: (
|
||||||
<Alert
|
<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
|
* Table displaying list of plugin registry errors
|
||||||
*/
|
*/
|
||||||
export default function PluginErrorTable() {
|
export default function PluginErrorTable() {
|
||||||
const table = useTable('registryErrors');
|
const table = useTable('registryErrors', 'id');
|
||||||
|
|
||||||
const registryErrorTableColumns: TableColumn<PluginRegistryErrorI>[] =
|
const registryErrorTableColumns: TableColumn<PluginRegistryErrorI>[] =
|
||||||
useMemo(
|
useMemo(
|
||||||
@ -49,7 +49,6 @@ export default function PluginErrorTable() {
|
|||||||
props={{
|
props={{
|
||||||
dataFormatter: (data: any) =>
|
dataFormatter: (data: any) =>
|
||||||
data.registry_errors.map((e: any, i: number) => ({ id: i, ...e })),
|
data.registry_errors.map((e: any, i: number) => ({ id: i, ...e })),
|
||||||
idAccessor: 'id',
|
|
||||||
enableDownload: false,
|
enableDownload: false,
|
||||||
enableFilters: false,
|
enableFilters: false,
|
||||||
enableSearch: false
|
enableSearch: false
|
||||||
|
@ -446,6 +446,7 @@ export default function StockItemTestResultTable({
|
|||||||
return (
|
return (
|
||||||
<DataTable
|
<DataTable
|
||||||
key={record.pk}
|
key={record.pk}
|
||||||
|
idAccessor={'test'}
|
||||||
noHeader
|
noHeader
|
||||||
columns={cols}
|
columns={cols}
|
||||||
records={results.slice(0, -1)}
|
records={results.slice(0, -1)}
|
||||||
|
@ -74,6 +74,7 @@ export const test = baseTest.extend({
|
|||||||
url != 'http://localhost:8000/api/barcode/' &&
|
url != 'http://localhost:8000/api/barcode/' &&
|
||||||
url != 'https://docs.inventree.org/en/versions.json' &&
|
url != 'https://docs.inventree.org/en/versions.json' &&
|
||||||
url != 'http://localhost:5173/favicon.ico' &&
|
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/news/') &&
|
||||||
!url.startsWith('http://localhost:8000/api/notifications/') &&
|
!url.startsWith('http://localhost:8000/api/notifications/') &&
|
||||||
!url.startsWith('chrome://') &&
|
!url.startsWith('chrome://') &&
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import { test } from './baseFixtures.js';
|
import { test } from './baseFixtures.js';
|
||||||
import { doQuickLogin } from './login.js';
|
import { doQuickLogin } from './login.js';
|
||||||
|
|
||||||
test('Modals as admin', async ({ page }) => {
|
test('Modals - Admin', async ({ page }) => {
|
||||||
await doQuickLogin(page, 'admin', 'inventree');
|
await doQuickLogin(page, 'admin', 'inventree');
|
||||||
|
|
||||||
// use server info
|
// use server info
|
||||||
@ -49,15 +49,4 @@ test('Modals as admin', async ({ page }) => {
|
|||||||
.getByRole('button', { name: 'About InvenTree About the InvenTree org' })
|
.getByRole('button', { name: 'About InvenTree About the InvenTree org' })
|
||||||
.click();
|
.click();
|
||||||
await page.getByRole('cell', { name: 'InvenTree Version' }).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 openFilterDrawer(page);
|
||||||
await clickButtonIfVisible(page, 'Clear Filters');
|
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 { clickButtonIfVisible, openFilterDrawer } from '../helpers.ts';
|
||||||
import { doQuickLogin } from '../login.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 }) => {
|
test('Purchase Orders - General', async ({ page }) => {
|
||||||
await doQuickLogin(page);
|
await doQuickLogin(page);
|
||||||
|
|
||||||
|
@ -3,6 +3,57 @@ import { baseUrl } from '../defaults.ts';
|
|||||||
import { clearTableFilters, setTableChoiceFilter } from '../helpers.ts';
|
import { clearTableFilters, setTableChoiceFilter } from '../helpers.ts';
|
||||||
import { doQuickLogin } from '../login.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 }) => {
|
test('Sales Orders - Basic Tests', async ({ page }) => {
|
||||||
await doQuickLogin(page);
|
await doQuickLogin(page);
|
||||||
|
|
||||||
@ -129,55 +180,3 @@ test('Sales Orders - Shipments', async ({ page }) => {
|
|||||||
await page.getByText('Quantity: 42').click();
|
await page.getByText('Quantity: 42').click();
|
||||||
await page.getByRole('button', { name: 'Cancel' }).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 { baseUrl } from '../defaults';
|
||||||
import { doQuickLogin } from '../login';
|
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 doQuickLogin(page);
|
||||||
|
|
||||||
await page.goto(`${baseUrl}/scan`);
|
await page.getByRole('button', { name: 'Open Barcode Scanner' }).click();
|
||||||
await page.getByPlaceholder('Select input method').click();
|
await scan(page, '{"part": 15}');
|
||||||
await page.getByRole('option', { name: 'Manual input' }).click();
|
|
||||||
await page.getByPlaceholder('Enter item serial or data').click();
|
|
||||||
|
|
||||||
// nonsense data
|
await page.getByText('Part: R_550R_0805_1%', { exact: true }).waitFor();
|
||||||
await page.getByPlaceholder('Enter item serial or data').fill('123');
|
await page.getByText('Available:').waitFor();
|
||||||
await page.getByPlaceholder('Enter item serial or data').press('Enter');
|
await page.getByText('Required:').waitFor();
|
||||||
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.getByPlaceholder('Enter item serial or data').fill(search_text);
|
test('Scanning - Basic', async ({ page }) => {
|
||||||
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 }) => {
|
|
||||||
await doQuickLogin(page);
|
await doQuickLogin(page);
|
||||||
|
|
||||||
await page.getByLabel('navigation-menu').click();
|
// Navigate to the 'scan' page
|
||||||
await page.getByRole('button', { name: 'System Information' }).click();
|
|
||||||
await page.locator('button').filter({ hasText: 'Close' }).click();
|
|
||||||
|
|
||||||
await page.getByLabel('navigation-menu').click();
|
await page.getByLabel('navigation-menu').click();
|
||||||
await page.getByRole('button', { name: 'Scan Barcode' }).click();
|
await page.getByRole('button', { name: 'Scan Barcode' }).click();
|
||||||
|
|
||||||
await page.getByPlaceholder('Select input method').click();
|
await page.getByText('Scan or enter barcode data').waitFor();
|
||||||
await page.getByRole('option', { name: 'Manual input' }).click();
|
|
||||||
await page.getByPlaceholder('Enter item serial or data').click();
|
// Select the scanner input
|
||||||
await page.getByPlaceholder('Enter item serial or data').fill('123');
|
await page.getByLabel('barcode-input-scanner').click();
|
||||||
await page.getByPlaceholder('Enter item serial or data').press('Enter');
|
await page.getByPlaceholder('Enter barcode data').fill('123-abc');
|
||||||
await page.getByRole('cell', { name: 'manually' }).click();
|
await page.getByRole('button', { name: 'Scan', exact: true }).click();
|
||||||
await page.getByRole('button', { name: 'Lookup part' }).click();
|
|
||||||
await page.getByPlaceholder('Select input method').click();
|
// Select the camera input
|
||||||
await page.getByRole('option', { name: 'Manual input' }).click();
|
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 }) => {
|
test('Scanning - Part', async ({ page }) => {
|
||||||
await defaultScanTest(page, '{"part": 1}');
|
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('R_10R_0402_1%').waitFor();
|
||||||
await page.getByText('Stock:').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 }) => {
|
test('Scanning - Stockitem', async ({ page }) => {
|
||||||
// TODO: Come back to here and re-enable this test
|
await doQuickLogin(page);
|
||||||
// TODO: Something is wrong with the test, it's not working as expected
|
await page.goto(`${baseUrl}/scan/`);
|
||||||
// TODO: The barcode scanning page needs some attention in general
|
await scan(page, '{"stockitem": 408}');
|
||||||
/*
|
|
||||||
* TODO: 2024-11-08 : https://github.com/inventree/InvenTree/pull/8445
|
|
||||||
await defaultScanTest(page, '{"stockitem": 408}');
|
|
||||||
|
|
||||||
// stockitem: 408
|
|
||||||
await page.getByText('1551ABK').waitFor();
|
await page.getByText('1551ABK').waitFor();
|
||||||
await page.getByText('Quantity: 100').waitFor();
|
await page.getByText('Quantity: 100').waitFor();
|
||||||
await page.getByRole('cell', { name: 'Quantity: 100' }).waitFor();
|
await page.getByRole('cell', { name: 'Quantity: 100' }).waitFor();
|
||||||
*/
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Scanning (StockLocation)', async ({ page }) => {
|
test('Scanning - StockLocation', async ({ page }) => {
|
||||||
await defaultScanTest(page, '{"stocklocation": 3}');
|
await doQuickLogin(page);
|
||||||
|
await page.goto(`${baseUrl}/scan/`);
|
||||||
|
await scan(page, '{"stocklocation": 3}');
|
||||||
|
|
||||||
// stocklocation: 3
|
// stocklocation: 3
|
||||||
await page.getByText('Factory/Storage Room B', { exact: true }).waitFor();
|
await page.getByText('Factory/Storage Room B', { exact: true }).waitFor();
|
||||||
await page.getByText('Storage Room B (green door)').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 }) => {
|
test('Scanning - SupplierPart', async ({ page }) => {
|
||||||
await defaultScanTest(page, '{"supplierpart": 204}');
|
await doQuickLogin(page);
|
||||||
|
await page.goto(`${baseUrl}/scan/`);
|
||||||
|
await scan(page, '{"supplierpart": 204}');
|
||||||
|
|
||||||
// supplierpart: 204
|
// supplierpart: 204
|
||||||
await page.waitForTimeout(1000);
|
await page.waitForTimeout(1000);
|
||||||
await page.getByText('1551ABK').first().waitFor();
|
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 }) => {
|
test('Scanning - PurchaseOrder', async ({ page }) => {
|
||||||
await defaultScanTest(page, '{"purchaseorder": 12}');
|
await doQuickLogin(page);
|
||||||
|
await page.goto(`${baseUrl}/scan/`);
|
||||||
|
await scan(page, '{"purchaseorder": 12}');
|
||||||
|
|
||||||
// purchaseorder: 12
|
// purchaseorder: 12
|
||||||
await page.getByText('PO0012').waitFor();
|
await page.getByText('PO0012').waitFor();
|
||||||
await page.getByText('Wire from Wirey').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 }) => {
|
test('Scanning - SalesOrder', async ({ page }) => {
|
||||||
await defaultScanTest(page, '{"salesorder": 6}');
|
await doQuickLogin(page);
|
||||||
|
await page.goto(`${baseUrl}/scan/`);
|
||||||
|
await scan(page, '{"salesorder": 6}');
|
||||||
|
|
||||||
// salesorder: 6
|
// salesorder: 6
|
||||||
await page.getByText('SO0006').waitFor();
|
await page.getByText('SO0006').waitFor();
|
||||||
await page.getByText('Selling more stuff to this').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 }) => {
|
test('Scanning - Build', async ({ page }) => {
|
||||||
await defaultScanTest(page, '{"build": 8}');
|
await doQuickLogin(page);
|
||||||
|
await page.goto(`${baseUrl}/scan/`);
|
||||||
|
await scan(page, '{"build": 8}');
|
||||||
|
|
||||||
// build: 8
|
// build: 8
|
||||||
await page.getByText('BO0008').waitFor();
|
await page.getByText('BO0008').waitFor();
|
||||||
await page.getByText('PCBA build').waitFor();
|
await page.getByText('PCBA build').waitFor();
|
||||||
await page.getByRole('cell', { name: 'build', exact: true }).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.getByText('Unavailable').waitFor();
|
||||||
await page.getByLabel('action-menu-stock-operations').click();
|
await page.getByLabel('action-menu-stock-operations').click();
|
||||||
await page.getByLabel('action-menu-stock-operations-return').click();
|
await page.getByLabel('action-menu-stock-operations-return').click();
|
||||||
|
|
||||||
await page.waitForTimeout(2500);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Stock - Tracking', async ({ page }) => {
|
test('Stock - Tracking', async ({ page }) => {
|
||||||
|
@ -2,57 +2,6 @@ import { test } from './baseFixtures.js';
|
|||||||
import { baseUrl } from './defaults.js';
|
import { baseUrl } from './defaults.js';
|
||||||
import { doQuickLogin } from './login.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 }) => {
|
test('Company', async ({ page }) => {
|
||||||
await doQuickLogin(page);
|
await doQuickLogin(page);
|
||||||
|
|
||||||
|
@ -92,8 +92,6 @@ test('Login - Failures', async ({ page }) => {
|
|||||||
await page.getByLabel('login-password').fill('');
|
await page.getByLabel('login-password').fill('');
|
||||||
|
|
||||||
await loginWithError();
|
await loginWithError();
|
||||||
|
|
||||||
await page.waitForTimeout(2500);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Login - Change Password', async ({ page }) => {
|
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.getByLabel('action-button-locate-item').click();
|
||||||
await page.getByRole('button', { name: 'Submit' }).click();
|
await page.getByRole('button', { name: 'Submit' }).click();
|
||||||
await page.getByText('Item location requested').waitFor();
|
await page.getByText('Item location requested').waitFor();
|
||||||
|
|
||||||
await page.waitForTimeout(2500);
|
|
||||||
return;
|
|
||||||
});
|
});
|
||||||
|