From 2edb9913522fab031896c292b3e9571c0b97d8ae Mon Sep 17 00:00:00 2001 From: Lem Wai Ping Date: Tue, 7 Apr 2026 09:33:19 +0800 Subject: [PATCH] feat(barcode): continuous multi-scan mode + build-order scan-to-allocate --- .../components/barcodes/BarcodeScanDialog.tsx | 325 +++++++++++++++--- src/frontend/src/pages/build/BuildDetail.tsx | 77 +++++ .../src/pages/stock/LocationDetail.tsx | 6 +- 3 files changed, 368 insertions(+), 40 deletions(-) diff --git a/src/frontend/src/components/barcodes/BarcodeScanDialog.tsx b/src/frontend/src/components/barcodes/BarcodeScanDialog.tsx index 6e2eb5064065..921b576dda90 100644 --- a/src/frontend/src/components/barcodes/BarcodeScanDialog.tsx +++ b/src/frontend/src/components/barcodes/BarcodeScanDialog.tsx @@ -4,9 +4,30 @@ import type { ModelType } from '@lib/enums/ModelType'; 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 { + ActionIcon, + Badge, + Box, + Button, + Checkbox, + Divider, + Group, + Modal, + ScrollArea, + Stack, + Text, + ThemeIcon, + Timeline, + Tooltip +} from '@mantine/core'; import { hideNotification, showNotification } from '@mantine/notifications'; -import { useCallback, useState } from 'react'; +import { + IconCheck, + IconCircleX, + IconTrash, + IconX +} from '@tabler/icons-react'; +import { useCallback, useRef, useState } from 'react'; import { type NavigateFunction, useNavigate } from 'react-router-dom'; import { api } from '../../App'; import { extractErrorMessage } from '../../functions/api'; @@ -24,20 +45,36 @@ export type BarcodeScanSuccessCallback = ( response: any ) => void; -// Callback function for handling a barcode scan -// This function should return true if the barcode was handled successfully +// Callback function for handling a barcode scan. +// Returns a BarcodeScanResult. In continuous mode the dialog stays open after +// a successful scan so users can scan the next item without touching the UI. export type BarcodeScanCallback = ( barcode: string, response: any ) => Promise; +// -------------------------------------------------------------------------- +// Represents one completed scan entry shown in the continuous scan log +// -------------------------------------------------------------------------- +interface ScanLogEntry { + id: string; // uniquely identifies entry for key prop + barcode: string; + label: string; // human-readable description of what was matched + success: boolean; + message: string; +} + +// -------------------------------------------------------------------------- +// BarcodeScanDialog +// -------------------------------------------------------------------------- export default function BarcodeScanDialog({ title, opened, callback, modelType, onClose, - onScanSuccess + onScanSuccess, + continuous = false }: Readonly<{ title?: string; opened: boolean; @@ -45,6 +82,9 @@ export default function BarcodeScanDialog({ callback?: BarcodeScanCallback; onClose: () => void; onScanSuccess?: BarcodeScanSuccessCallback; + /** When true, the dialog stays open for repeated scans and shows a running + * log of all completed scans. A "Done" button closes the dialog. */ + continuous?: boolean; }>) { const navigate = useNavigate(); @@ -63,36 +103,73 @@ export default function BarcodeScanDialog({ onScanSuccess={onScanSuccess} modelType={modelType} callback={callback} + continuous={continuous} /> ); } +// -------------------------------------------------------------------------- +// ScanInputHandler — owns all scan state +// -------------------------------------------------------------------------- export function ScanInputHandler({ callback, modelType, onClose, onScanSuccess, - navigate + navigate, + continuous: continuousProp = false }: Readonly<{ callback?: BarcodeScanCallback; onClose: () => void; onScanSuccess?: BarcodeScanSuccessCallback; modelType?: ModelType; navigate: NavigateFunction; + continuous?: boolean; }>) { const [error, setError] = useState(''); const [processing, setProcessing] = useState(false); + const [scanLog, setScanLog] = useState([]); + + // Continuous mode can be toggled per-session by the user (starts from the + // prop value, but the checkbox lets them turn it on/off mid-session). + const [continuous, setContinuous] = useState(continuousProp); + + // Ref so the scan counter survives re-renders without causing them + const scanCounter = useRef(0); + const user = useUserState(); + // ------------------------------------------------------------------------- + // Append a completed scan to the log + // ------------------------------------------------------------------------- + const appendLog = useCallback( + (entry: Omit) => { + scanCounter.current += 1; + setScanLog((prev) => [ + { ...entry, id: String(scanCounter.current) }, + ...prev // newest at top + ]); + }, + [] + ); + + // ------------------------------------------------------------------------- + // Remove a single entry from the log (allow undo of accidental scans) + // ------------------------------------------------------------------------- + const removeLogEntry = useCallback((id: string) => { + setScanLog((prev) => prev.filter((e) => e.id !== id)); + }, []); + + // ------------------------------------------------------------------------- + // Default scan handler — navigates to matched model detail page + // ------------------------------------------------------------------------- const defaultScan = useCallback( - (data: any) => { + (barcode: string, data: any) => { let match = false; - // 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; } @@ -103,12 +180,24 @@ export function ScanInputHandler({ model_type as ModelType, data[model_type]['pk'] ); - onClose(); if (onScanSuccess) { - onScanSuccess(data['barcode'], data); - } else { + onScanSuccess(barcode, data); + } + + if (!continuous) { + onClose(); navigate(url); + } else { + appendLog({ + barcode, + label: + data[model_type]?.['name'] ?? + data[model_type]?.['reference'] ?? + `${model_type} #${data[model_type]['pk']}`, + success: true, + message: t`Matched` + }); } match = true; @@ -118,12 +207,20 @@ export function ScanInputHandler({ } if (!match) { - setError(t`No matching item found`); + const message = t`No matching item found`; + setError(message); + + if (continuous) { + appendLog({ barcode, label: barcode, success: false, message }); + } } }, - [navigate, onClose, user, modelType] + [navigate, onClose, user, modelType, continuous, onScanSuccess, appendLog] ); + // ------------------------------------------------------------------------- + // Main scan handler + // ------------------------------------------------------------------------- const onScan = useCallback( (barcode: string) => { if (!barcode || barcode.length === 0) { @@ -134,20 +231,25 @@ export function ScanInputHandler({ setError(''); api - .post(apiUrl(ApiEndpoints.barcode), { - barcode: barcode - }) + .post(apiUrl(ApiEndpoints.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 + // Caller-provided callback if (modelType) { const pk: number = data[modelType]?.['pk']; if (!pk) { - setError(t`Barcode does not match the expected model type`); + const msg = t`Barcode does not match the expected model type`; + setError(msg); + if (continuous) { + appendLog({ + barcode, + label: barcode, + success: false, + message: msg + }); + } return; } } @@ -156,53 +258,200 @@ export function ScanInputHandler({ .then((result: BarcodeScanResult) => { if (result.success) { hideNotification('barcode-scan'); - showNotification({ - id: 'barcode-scan', - title: t`Success`, - message: result.success, - color: 'green' - }); - onClose(); + + if (!continuous) { + // Classic single-scan behaviour: show toast and close + showNotification({ + id: 'barcode-scan', + title: t`Success`, + message: result.success, + color: 'green' + }); + onClose(); + } else { + // Continuous mode: log the scan, stay open, ready for next + const label = + data[modelType ?? '']?.['name'] ?? + data[modelType ?? '']?.['reference'] ?? + barcode; + + appendLog({ + barcode, + label, + success: true, + message: result.success + }); + } } else { - setError(result.error ?? t`Failed to handle barcode`); + const msg = result.error ?? t`Failed to handle barcode`; + setError(msg); + if (continuous) { + appendLog({ + barcode, + label: barcode, + success: false, + message: msg + }); + } } }) .finally(() => { setProcessing(false); }); } else { - // If no callback is provided, use the default scan function - defaultScan(data); + defaultScan(barcode, data); setProcessing(false); } }) .catch((error) => { const _error = extractErrorMessage({ - error: error, + error, field: 'error', defaultMessage: t`Failed to scan barcode` }); setError(_error); + + if (continuous) { + appendLog({ + barcode, + label: barcode, + success: false, + message: _error + }); + } }) .finally(() => { setProcessing(false); }); }, - [callback, defaultScan, modelType, onClose] + [callback, defaultScan, modelType, onClose, continuous, appendLog] ); - return ; + // ------------------------------------------------------------------------- + // Render + // ------------------------------------------------------------------------- + return ( + + {/* Continuous mode toggle — always visible so user can switch on/off */} + + setContinuous(e.currentTarget.checked)} + size='sm' + aria-label='continuous-scan-toggle' + /> + {continuous && scanLog.length > 0 && ( + + {scanLog.filter((e) => e.success).length} /{' '} + {scanLog.length} {t`scanned`} + + )} + + + + + {/* Scan log — only shown in continuous mode */} + {continuous && scanLog.length > 0 && ( + <> + + + + {scanLog.map((entry) => ( + + {entry.success ? ( + + ) : ( + + )} + + } + title={ + + + {entry.label} + + + removeLogEntry(entry.id)} + aria-label={`remove-scan-${entry.id}`} + > + + + + + } + > + + {entry.message} + + + {entry.barcode} + + + ))} + + + + + + + + + )} + + {/* Show Done button even when log is empty so user can dismiss */} + {continuous && scanLog.length === 0 && ( + + + + )} + + ); } +// -------------------------------------------------------------------------- +// useBarcodeScanDialog — convenience hook +// -------------------------------------------------------------------------- export function useBarcodeScanDialog({ title, callback, - modelType + modelType, + continuous = false }: Readonly<{ title: string; modelType?: ModelType; callback: BarcodeScanCallback; + /** Set to true to enable multi-scan mode by default */ + continuous?: boolean; }>) { const [opened, setOpened] = useState(false); @@ -216,12 +465,10 @@ export function useBarcodeScanDialog({ opened={opened} callback={callback} modelType={modelType} + continuous={continuous} onClose={() => setOpened(false)} /> ); - return { - open, - dialog - }; + return { open, dialog }; } diff --git a/src/frontend/src/pages/build/BuildDetail.tsx b/src/frontend/src/pages/build/BuildDetail.tsx index 8f8bf0e60ead..2a7998cc7faa 100644 --- a/src/frontend/src/pages/build/BuildDetail.tsx +++ b/src/frontend/src/pages/build/BuildDetail.tsx @@ -9,6 +9,7 @@ import { IconList, IconListCheck, IconListNumbers, + IconScan, IconShoppingCart, IconSitemap } from '@tabler/icons-react'; @@ -24,6 +25,7 @@ import type { ApiFormFieldSet } from '@lib/types/Forms'; import AdminButton from '../../components/buttons/AdminButton'; import PrimaryActionButton from '../../components/buttons/PrimaryActionButton'; import { PrintingActions } from '../../components/buttons/PrintingActions'; +import { useBarcodeScanDialog } from '../../components/barcodes/BarcodeScanDialog'; import { type DetailsField, DetailsTable @@ -661,6 +663,72 @@ export default function BuildDetail() { fields: completeOrderFields }); + // --------------------------------------------------------------------------- + // Scan-to-allocate: scan a stock item barcode to directly allocate it to + // this build order. Resolves: https://github.com/inventree/InvenTree/issues/11278 + // + // Flow: + // 1. operator scans stock item barcode + // 2. we verify the item's part is a required build-line component + // 3. we POST to build/:id/allocate/ with the specific stock item + // 4. in continuous mode the dialog stays open for rapid multi-scan + // --------------------------------------------------------------------------- + const scanAllocateItem = useBarcodeScanDialog({ + title: t`Scan Component to Allocate`, + modelType: ModelType.stockitem, + // Continuous so operators can scan an entire batch in one session + continuous: true, + callback: async (barcode, response) => { + const stockItem = response?.stockitem?.instance; + + if (!stockItem?.pk) { + return { error: t`Could not identify scanned stock item` }; + } + + // Check if this part has an outstanding un-allocated build line + let buildLineMatches: any = null; + try { + const lineResp = await api.get(apiUrl(ApiEndpoints.build_line_list), { + params: { build: build.pk, part: stockItem.part, allocated: false } + }); + buildLineMatches = lineResp.data; + } catch { + return { error: t`Failed to look up build requirements` }; + } + + if (!buildLineMatches?.results?.length) { + return { + error: t`This component is not required (or is already fully allocated) in this build order` + }; + } + + const buildLine = buildLineMatches.results[0]; + const qtyNeeded = (buildLine.quantity ?? 0) - (buildLine.allocated ?? 0); + const qtyToAllocate = Math.min( + stockItem.quantity ?? 0, + qtyNeeded > 0 ? qtyNeeded : stockItem.quantity ?? 0 + ); + + try { + await api.post(apiUrl(ApiEndpoints.build_order_allocate, build.pk), { + items: [ + { + stock_item: stockItem.pk, + quantity: qtyToAllocate + } + ] + }); + + return { + success: t`Allocated ${qtyToAllocate} × ${stockItem.part_detail?.name ?? barcode}` + }; + } catch (err: any) { + const msg = err?.response?.data?.detail ?? err?.message ?? t`Allocation failed`; + return { error: msg }; + } + } + }); + const buildActions = useMemo(() => { const canEdit = user.hasChangeRole(UserRoles.build); @@ -697,6 +765,14 @@ export default function BuildDetail() { color='green' onClick={completeOrder.open} />, + } + hidden={!canEdit || build.status == buildStatus.COMPLETE || build.status == buildStatus.CANCELLED} + color='teal' + onClick={scanAllocateItem.open} + tooltip={t`Scan a component barcode to allocate stock to this build order`} + />, , { const item = response.stockitem.instance;