diff --git a/src/backend/InvenTree/InvenTree/models.py b/src/backend/InvenTree/InvenTree/models.py index d2fedce8dc..24d6ea649f 100644 --- a/src/backend/InvenTree/InvenTree/models.py +++ b/src/backend/InvenTree/InvenTree/models.py @@ -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() diff --git a/src/frontend/lib/functions/Navigation.tsx b/src/frontend/lib/functions/Navigation.tsx index 90430eda74..4e879f4959 100644 --- a/src/frontend/lib/functions/Navigation.tsx +++ b/src/frontend/lib/functions/Navigation.tsx @@ -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. diff --git a/src/frontend/src/components/barcodes/BarcodeScanDialog.tsx b/src/frontend/src/components/barcodes/BarcodeScanDialog.tsx index ea4911f336..3457d55534 100644 --- a/src/frontend/src/components/barcodes/BarcodeScanDialog.tsx +++ b/src/frontend/src/components/barcodes/BarcodeScanDialog.tsx @@ -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; + 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({ > - + ); } + 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(''); const [processing, setProcessing] = useState(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 ; } + +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 = ( + setOpened(false)} + /> + ); + + return { + open, + dialog + }; +} diff --git a/src/frontend/src/pages/part/CategoryDetail.tsx b/src/frontend/src/pages/part/CategoryDetail.tsx index dba51739a8..fe4e00c360 100644 --- a/src/frontend/src/pages/part/CategoryDetail.tsx +++ b/src/frontend/src/pages/part/CategoryDetail.tsx @@ -342,8 +342,8 @@ export default function CategoryDetail() { selectedId={category?.pk} /> } breadcrumbs={breadcrumbs} breadcrumbAction={() => { diff --git a/src/frontend/src/pages/stock/LocationDetail.tsx b/src/frontend/src/pages/stock/LocationDetail.tsx index 45e063f280..72f036cc3e 100644 --- a/src/frontend/src/pages/stock/LocationDetail.tsx +++ b/src/frontend/src/pages/stock/LocationDetail.tsx @@ -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( () => [ , @@ -286,14 +344,14 @@ export default function Stock() { { name: 'Scan in stock items', icon: , - tooltip: 'Scan items', - onClick: notYetImplemented + tooltip: 'Scan item into this location', + onClick: scanInStockItem.open }, { name: 'Scan in container', icon: , - 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} } actions={locationActions} editAction={editLocation.open} diff --git a/src/frontend/src/pages/stock/StockDetail.tsx b/src/frontend/src/pages/stock/StockDetail.tsx index 5739acb867..0c4de8082a 100644 --- a/src/frontend/src/pages/stock/StockDetail.tsx +++ b/src/frontend/src/pages/stock/StockDetail.tsx @@ -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: , + tooltip: t`Scan this item into a location`, + onClick: scanIntoLocation.open + } + ]} />, {findBySerialNumber.modal} + {scanIntoLocation.dialog} { .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(); +});