mirror of
https://github.com/inventree/InvenTree.git
synced 2025-04-28 03:26:45 +00:00
[UI] Barcode actions (#9538)
* Provide callback function for barcode scan dialog * Adds hook for barcode scan dialog * Fix callback processing * Add function to extract API URL for a model instance * Tweak page titles * Extract instance data when scanning a barcode * Scan item into location * Scan in stock location * Remove notYetImplemented func * Revert "Remove notYetImplemented func" This reverts commit a35408380e749c1d9054fd4fe7bcabec71863982. * Add stock item action * Add playwright tests
This commit is contained in:
parent
7d87b8b896
commit
79dad13328
@ -10,7 +10,7 @@ from django.db import models
|
||||
from django.db.models import QuerySet
|
||||
from django.db.models.signals import post_save
|
||||
from django.dispatch import receiver
|
||||
from django.urls import reverse
|
||||
from django.urls import resolve, reverse
|
||||
from django.urls.exceptions import NoReverseMatch
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
@ -958,7 +958,17 @@ class InvenTreeBarcodeMixin(models.Model):
|
||||
|
||||
if hasattr(self, 'get_api_url'):
|
||||
api_url = self.get_api_url()
|
||||
data['api_url'] = f'{api_url}{self.pk}/'
|
||||
data['api_url'] = api_url = f'{api_url}{self.pk}/'
|
||||
|
||||
# Attempt to serialize the object too
|
||||
try:
|
||||
match = resolve(api_url)
|
||||
view_class = match.func.view_class
|
||||
serializer_class = view_class.serializer_class
|
||||
serializer = serializer_class(self)
|
||||
data['instance'] = serializer.data
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if hasattr(self, 'get_absolute_url'):
|
||||
data['web_url'] = self.get_absolute_url()
|
||||
|
@ -1,13 +1,15 @@
|
||||
import type { NavigateFunction } from 'react-router-dom';
|
||||
import { ModelInformationDict } from '../enums/ModelInformation';
|
||||
import type { ModelType } from '../enums/ModelType';
|
||||
import { apiUrl } from './Api';
|
||||
import { cancelEvent } from './Events';
|
||||
|
||||
export const getBaseUrl = (): string =>
|
||||
(window as any).INVENTREE_SETTINGS?.base_url || 'web';
|
||||
|
||||
/**
|
||||
* Returns the detail view URL for a given model type
|
||||
* Returns the detail view URL for a given model type.
|
||||
* This is the UI URL, not the API URL.
|
||||
*/
|
||||
export function getDetailUrl(
|
||||
model: ModelType,
|
||||
@ -35,6 +37,27 @@ export function getDetailUrl(
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the API detail URL for a given model type.
|
||||
*/
|
||||
export function getApiUrl(
|
||||
model: ModelType,
|
||||
pk: number | string
|
||||
): string | undefined {
|
||||
const modelInfo = ModelInformationDict[model];
|
||||
|
||||
if (pk === undefined || pk === null) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (!!pk && modelInfo && modelInfo.api_endpoint) {
|
||||
return apiUrl(modelInfo.api_endpoint, pk);
|
||||
}
|
||||
|
||||
console.error(`No API detail URL found for model ${model} <${pk}>`);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/*
|
||||
* Navigate to a provided link.
|
||||
* - If the link is to be opened externally, open it in a new tab.
|
||||
|
@ -5,6 +5,7 @@ import { apiUrl } from '@lib/functions/Api';
|
||||
import { getDetailUrl } from '@lib/functions/Navigation';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { Box, Divider, Modal } from '@mantine/core';
|
||||
import { hideNotification, showNotification } from '@mantine/notifications';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { type NavigateFunction, useNavigate } from 'react-router-dom';
|
||||
import { api } from '../../App';
|
||||
@ -13,13 +14,29 @@ import { useUserState } from '../../states/UserState';
|
||||
import { StylishText } from '../items/StylishText';
|
||||
import { BarcodeInput } from './BarcodeInput';
|
||||
|
||||
export type BarcodeScanResult = {
|
||||
success?: string;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
// Callback function for handling a barcode scan
|
||||
// This function should return true if the barcode was handled successfully
|
||||
export type BarcodeScanCallback = (
|
||||
barcode: string,
|
||||
response: any
|
||||
) => Promise<BarcodeScanResult>;
|
||||
|
||||
export default function BarcodeScanDialog({
|
||||
title,
|
||||
opened,
|
||||
callback,
|
||||
modelType,
|
||||
onClose
|
||||
}: Readonly<{
|
||||
title?: string;
|
||||
opened: boolean;
|
||||
modelType?: ModelType;
|
||||
callback?: BarcodeScanCallback;
|
||||
onClose: () => void;
|
||||
}>) {
|
||||
const navigate = useNavigate();
|
||||
@ -33,69 +50,162 @@ export default function BarcodeScanDialog({
|
||||
>
|
||||
<Divider />
|
||||
<Box>
|
||||
<ScanInputHandler navigate={navigate} onClose={onClose} />
|
||||
<ScanInputHandler
|
||||
navigate={navigate}
|
||||
onClose={onClose}
|
||||
modelType={modelType}
|
||||
callback={callback}
|
||||
/>
|
||||
</Box>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export function ScanInputHandler({
|
||||
callback,
|
||||
modelType,
|
||||
onClose,
|
||||
navigate
|
||||
}: Readonly<{ onClose: () => void; navigate: NavigateFunction }>) {
|
||||
}: Readonly<{
|
||||
callback?: BarcodeScanCallback;
|
||||
onClose: () => void;
|
||||
modelType?: ModelType;
|
||||
navigate: NavigateFunction;
|
||||
}>) {
|
||||
const [error, setError] = useState<string>('');
|
||||
const [processing, setProcessing] = useState<boolean>(false);
|
||||
const user = useUserState();
|
||||
|
||||
const onScan = useCallback((barcode: string) => {
|
||||
if (!barcode || barcode.length === 0) {
|
||||
return;
|
||||
}
|
||||
const defaultScan = useCallback(
|
||||
(data: any) => {
|
||||
let match = false;
|
||||
|
||||
setProcessing(true);
|
||||
// Find the matching model type
|
||||
for (const model_type of Object.keys(ModelInformationDict)) {
|
||||
// If a specific model type is provided, check if it matches
|
||||
if (modelType && model_type !== modelType) {
|
||||
continue;
|
||||
}
|
||||
|
||||
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 (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`
|
||||
if (!match) {
|
||||
setError(t`No matching item found`);
|
||||
}
|
||||
},
|
||||
[navigate, onClose, user, modelType]
|
||||
);
|
||||
|
||||
const onScan = useCallback(
|
||||
(barcode: string) => {
|
||||
if (!barcode || barcode.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
setProcessing(true);
|
||||
setError('');
|
||||
|
||||
api
|
||||
.post(apiUrl(ApiEndpoints.barcode), {
|
||||
barcode: barcode
|
||||
})
|
||||
.then((response: any) => {
|
||||
const data = response.data ?? {};
|
||||
|
||||
if (callback && data.success && response.status === 200) {
|
||||
const instance = null;
|
||||
|
||||
// If the caller is expecting a specific model type, check if it matches
|
||||
if (modelType) {
|
||||
const pk: number = data[modelType]?.['pk'];
|
||||
if (!pk) {
|
||||
setError(t`Barcode does not match the expected model type`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
callback(barcode, data)
|
||||
.then((result: BarcodeScanResult) => {
|
||||
if (result.success) {
|
||||
hideNotification('barcode-scan');
|
||||
showNotification({
|
||||
id: 'barcode-scan',
|
||||
title: t`Success`,
|
||||
message: result.success,
|
||||
color: 'green'
|
||||
});
|
||||
onClose();
|
||||
} else {
|
||||
setError(result.error ?? t`Failed to handle barcode`);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
setProcessing(false);
|
||||
});
|
||||
} else {
|
||||
// If no callback is provided, use the default scan function
|
||||
defaultScan(data);
|
||||
setProcessing(false);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
const _error = extractErrorMessage({
|
||||
error: error,
|
||||
field: 'error',
|
||||
defaultMessage: t`Failed to scan barcode`
|
||||
});
|
||||
|
||||
setError(_error);
|
||||
})
|
||||
.finally(() => {
|
||||
setProcessing(false);
|
||||
});
|
||||
|
||||
setError(_error);
|
||||
})
|
||||
.finally(() => {
|
||||
setProcessing(false);
|
||||
});
|
||||
}, []);
|
||||
},
|
||||
[callback, defaultScan, modelType, onClose]
|
||||
);
|
||||
|
||||
return <BarcodeInput onScan={onScan} error={error} processing={processing} />;
|
||||
}
|
||||
|
||||
export function useBarcodeScanDialog({
|
||||
title,
|
||||
callback,
|
||||
modelType
|
||||
}: Readonly<{
|
||||
title: string;
|
||||
modelType?: ModelType;
|
||||
callback: BarcodeScanCallback;
|
||||
}>) {
|
||||
const [opened, setOpened] = useState(false);
|
||||
|
||||
const open = useCallback(() => {
|
||||
setOpened(true);
|
||||
}, []);
|
||||
|
||||
const dialog = (
|
||||
<BarcodeScanDialog
|
||||
title={title}
|
||||
opened={opened}
|
||||
callback={callback}
|
||||
modelType={modelType}
|
||||
onClose={() => setOpened(false)}
|
||||
/>
|
||||
);
|
||||
|
||||
return {
|
||||
open,
|
||||
dialog
|
||||
};
|
||||
}
|
||||
|
@ -342,8 +342,8 @@ export default function CategoryDetail() {
|
||||
selectedId={category?.pk}
|
||||
/>
|
||||
<PageDetail
|
||||
title={t`Part Category`}
|
||||
subtitle={category?.name}
|
||||
title={category?.name ?? t`Part Category`}
|
||||
subtitle={category?.description}
|
||||
icon={category?.icon && <ApiIcon name={category?.icon} />}
|
||||
breadcrumbs={breadcrumbs}
|
||||
breadcrumbAction={() => {
|
||||
|
@ -2,11 +2,14 @@ import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
|
||||
import { ModelType } from '@lib/enums/ModelType';
|
||||
import { UserRoles } from '@lib/enums/Roles';
|
||||
import { getDetailUrl } from '@lib/functions/Navigation';
|
||||
import { apiUrl } from '@lib/index';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { Group, Skeleton, Stack, Text } from '@mantine/core';
|
||||
import { IconInfoCircle, IconPackages, IconSitemap } from '@tabler/icons-react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { api } from '../../App';
|
||||
import { useBarcodeScanDialog } from '../../components/barcodes/BarcodeScanDialog';
|
||||
import AdminButton from '../../components/buttons/AdminButton';
|
||||
import { PrintingActions } from '../../components/buttons/PrintingActions';
|
||||
import {
|
||||
@ -35,7 +38,6 @@ import {
|
||||
useTransferStockItem
|
||||
} from '../../forms/StockForms';
|
||||
import { InvenTreeIcon } from '../../functions/icons';
|
||||
import { notYetImplemented } from '../../functions/notifications';
|
||||
import {
|
||||
useDeleteApiFormModal,
|
||||
useEditApiFormModal
|
||||
@ -272,6 +274,62 @@ export default function Stock() {
|
||||
const transferStockItems = useTransferStockItem(stockItemActionProps);
|
||||
const countStockItems = useCountStockItem(stockItemActionProps);
|
||||
|
||||
const scanInStockItem = useBarcodeScanDialog({
|
||||
title: t`Scan Stock Item`,
|
||||
modelType: ModelType.stockitem,
|
||||
callback: async (barcode, response) => {
|
||||
const item = response.stockitem.instance;
|
||||
|
||||
// Scan the stock item into the current location
|
||||
return api
|
||||
.post(apiUrl(ApiEndpoints.stock_transfer), {
|
||||
location: location.pk,
|
||||
items: [
|
||||
{
|
||||
pk: item.pk,
|
||||
quantity: item.quantity
|
||||
}
|
||||
]
|
||||
})
|
||||
.then(() => {
|
||||
return {
|
||||
success: t`Scanned stock item into location`
|
||||
};
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Error scanning stock item:', error);
|
||||
return {
|
||||
error: t`Error scanning stock item`
|
||||
};
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const scanInStockLocation = useBarcodeScanDialog({
|
||||
title: t`Scan Stock Location`,
|
||||
modelType: ModelType.stocklocation,
|
||||
callback: async (barcode, response) => {
|
||||
const pk = response.stocklocation.pk;
|
||||
|
||||
// Set the parent location
|
||||
return api
|
||||
.patch(apiUrl(ApiEndpoints.stock_location_list, pk), {
|
||||
parent: location.pk
|
||||
})
|
||||
.then(() => {
|
||||
return {
|
||||
success: t`Scanned stock location into location`
|
||||
};
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Error scanning stock location:', error);
|
||||
return {
|
||||
error: t`Error scanning stock location`
|
||||
};
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const locationActions = useMemo(
|
||||
() => [
|
||||
<AdminButton model={ModelType.stocklocation} id={location.pk} />,
|
||||
@ -286,14 +344,14 @@ export default function Stock() {
|
||||
{
|
||||
name: 'Scan in stock items',
|
||||
icon: <InvenTreeIcon icon='stock' />,
|
||||
tooltip: 'Scan items',
|
||||
onClick: notYetImplemented
|
||||
tooltip: 'Scan item into this location',
|
||||
onClick: scanInStockItem.open
|
||||
},
|
||||
{
|
||||
name: 'Scan in container',
|
||||
icon: <InvenTreeIcon icon='unallocated_stock' />,
|
||||
tooltip: 'Scan container',
|
||||
onClick: notYetImplemented
|
||||
tooltip: 'Scan container into this location',
|
||||
onClick: scanInStockLocation.open
|
||||
}
|
||||
]}
|
||||
/>
|
||||
@ -362,6 +420,8 @@ export default function Stock() {
|
||||
<>
|
||||
{editLocation.modal}
|
||||
{deleteLocation.modal}
|
||||
{scanInStockItem.dialog}
|
||||
{scanInStockLocation.dialog}
|
||||
<InstanceDetail
|
||||
status={requestStatus}
|
||||
loading={id ? instanceQuery.isFetching : false}
|
||||
@ -377,8 +437,8 @@ export default function Stock() {
|
||||
selectedId={location?.pk}
|
||||
/>
|
||||
<PageDetail
|
||||
title={t`Stock Items`}
|
||||
subtitle={location?.name}
|
||||
title={location?.name ?? t`Stock Location`}
|
||||
subtitle={location?.description}
|
||||
icon={location?.icon && <ApiIcon name={location?.icon} />}
|
||||
actions={locationActions}
|
||||
editAction={editLocation.open}
|
||||
|
@ -33,6 +33,7 @@ import { ModelType } from '@lib/enums/ModelType';
|
||||
import { UserRoles } from '@lib/enums/Roles';
|
||||
import { apiUrl } from '@lib/functions/Api';
|
||||
import { getDetailUrl } from '@lib/functions/Navigation';
|
||||
import { useBarcodeScanDialog } from '../../components/barcodes/BarcodeScanDialog';
|
||||
import { ActionButton } from '../../components/buttons/ActionButton';
|
||||
import AdminButton from '../../components/buttons/AdminButton';
|
||||
import { PrintingActions } from '../../components/buttons/PrintingActions';
|
||||
@ -749,6 +750,37 @@ export default function StockDetail() {
|
||||
parts: stockitem.part_detail ? [stockitem.part_detail] : []
|
||||
});
|
||||
|
||||
const scanIntoLocation = useBarcodeScanDialog({
|
||||
title: t`Scan Into Location`,
|
||||
modelType: ModelType.stocklocation,
|
||||
callback: async (barcode, response) => {
|
||||
const pk = response.stocklocation.pk;
|
||||
|
||||
return api
|
||||
.post(apiUrl(ApiEndpoints.stock_transfer), {
|
||||
location: pk,
|
||||
items: [
|
||||
{
|
||||
pk: stockitem.pk,
|
||||
quantity: stockitem.quantity
|
||||
}
|
||||
]
|
||||
})
|
||||
.then(() => {
|
||||
refreshInstance();
|
||||
return {
|
||||
success: t`Scanned stock item into location`
|
||||
};
|
||||
})
|
||||
.catch((error) => {
|
||||
console.log('Error scanning stock item:', error);
|
||||
return {
|
||||
error: t`Error scanning stock item`
|
||||
};
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const stockActions = useMemo(() => {
|
||||
// Can this stock item be transferred to a different location?
|
||||
const canTransfer =
|
||||
@ -775,6 +807,14 @@ export default function StockDetail() {
|
||||
pk={stockitem.pk}
|
||||
hash={stockitem?.barcode_hash}
|
||||
perm={user.hasChangeRole(UserRoles.stock)}
|
||||
actions={[
|
||||
{
|
||||
name: t`Scan into location`,
|
||||
icon: <InvenTreeIcon icon='location' />,
|
||||
tooltip: t`Scan this item into a location`,
|
||||
onClick: scanIntoLocation.open
|
||||
}
|
||||
]}
|
||||
/>,
|
||||
<PrintingActions
|
||||
modelType={ModelType.stockitem}
|
||||
@ -974,6 +1014,7 @@ export default function StockDetail() {
|
||||
return (
|
||||
<>
|
||||
{findBySerialNumber.modal}
|
||||
{scanIntoLocation.dialog}
|
||||
<InstanceDetail
|
||||
requiredRole={UserRoles.stock}
|
||||
status={requestStatus}
|
||||
|
@ -236,6 +236,17 @@ test('Stock - Stock Actions', async ({ browser }) => {
|
||||
.waitFor();
|
||||
await page.getByText('123').first().waitFor();
|
||||
|
||||
// Check barcode actions
|
||||
await page.getByLabel('action-menu-barcode-actions').click();
|
||||
await page
|
||||
.getByLabel('action-menu-barcode-actions-scan-into-location')
|
||||
.click();
|
||||
await page
|
||||
.getByPlaceholder('Enter barcode data')
|
||||
.fill('{"stocklocation": 12}');
|
||||
await page.getByRole('button', { name: 'Scan', exact: true }).click();
|
||||
await page.getByText('Scanned stock item into location').waitFor();
|
||||
|
||||
// Add stock, and change status
|
||||
await launchStockAction('add');
|
||||
await page.getByLabel('number-field-quantity').fill('12');
|
||||
@ -284,3 +295,34 @@ test('Stock - Tracking', async ({ browser }) => {
|
||||
await page.getByRole('link', { name: 'Widget Assembly' }).waitFor();
|
||||
await page.getByRole('cell', { name: 'Installed into assembly' }).waitFor();
|
||||
});
|
||||
|
||||
test('Stock - Location', async ({ browser }) => {
|
||||
const page = await doCachedLogin(browser, { url: 'stock/location/12/' });
|
||||
|
||||
await loadTab(page, 'Default Parts');
|
||||
await loadTab(page, 'Stock Items');
|
||||
await loadTab(page, 'Stock Locations');
|
||||
await loadTab(page, 'Location Details');
|
||||
|
||||
await page.getByLabel('action-menu-barcode-actions').click();
|
||||
await page
|
||||
.getByLabel('action-menu-barcode-actions-scan-in-stock-items')
|
||||
.waitFor();
|
||||
await page
|
||||
.getByLabel('action-menu-barcode-actions-scan-in-container')
|
||||
.click();
|
||||
|
||||
// Attempt to scan in the same location (should fail)
|
||||
await page
|
||||
.getByPlaceholder('Enter barcode data')
|
||||
.fill('{"stocklocation": 12}');
|
||||
await page.getByRole('button', { name: 'Scan', exact: true }).click();
|
||||
await page.getByText('Error scanning stock location').waitFor();
|
||||
|
||||
// Attempt to scan bad data (no match)
|
||||
await page
|
||||
.getByPlaceholder('Enter barcode data')
|
||||
.fill('{"stocklocation": 1234}');
|
||||
await page.getByRole('button', { name: 'Scan', exact: true }).click();
|
||||
await page.getByText('No match found for barcode data').waitFor();
|
||||
});
|
||||
|
Loading…
x
Reference in New Issue
Block a user