This commit is contained in:
sleepy_lemonade 2026-07-05 21:42:24 +10:00 committed by GitHub
commit e2162b004a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 368 additions and 40 deletions

View File

@ -5,9 +5,30 @@ import type { ModelType } from '@lib/enums/ModelType';
import { apiUrl } from '@lib/functions/Api'; import { apiUrl } from '@lib/functions/Api';
import { getDetailUrl } from '@lib/functions/Navigation'; import { getDetailUrl } from '@lib/functions/Navigation';
import { t } from '@lingui/core/macro'; 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 { 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 { type NavigateFunction, useNavigate } from 'react-router-dom';
import { api } from '../../App'; import { api } from '../../App';
import { extractErrorMessage } from '../../functions/api'; import { extractErrorMessage } from '../../functions/api';
@ -24,20 +45,36 @@ export type BarcodeScanSuccessCallback = (
response: any response: any
) => void; ) => void;
// Callback function for handling a barcode scan // Callback function for handling a barcode scan.
// This function should return true if the barcode was handled successfully // 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 = ( export type BarcodeScanCallback = (
barcode: string, barcode: string,
response: any response: any
) => Promise<BarcodeScanResult>; ) => Promise<BarcodeScanResult>;
// --------------------------------------------------------------------------
// 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({ export default function BarcodeScanDialog({
title, title,
opened, opened,
callback, callback,
modelType, modelType,
onClose, onClose,
onScanSuccess onScanSuccess,
continuous = false
}: Readonly<{ }: Readonly<{
title?: string; title?: string;
opened: boolean; opened: boolean;
@ -45,6 +82,9 @@ export default function BarcodeScanDialog({
callback?: BarcodeScanCallback; callback?: BarcodeScanCallback;
onClose: () => void; onClose: () => void;
onScanSuccess?: BarcodeScanSuccessCallback; 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(); const navigate = useNavigate();
@ -63,36 +103,73 @@ export default function BarcodeScanDialog({
onScanSuccess={onScanSuccess} onScanSuccess={onScanSuccess}
modelType={modelType} modelType={modelType}
callback={callback} callback={callback}
continuous={continuous}
/> />
</Box> </Box>
</Modal> </Modal>
); );
} }
// --------------------------------------------------------------------------
// ScanInputHandler — owns all scan state
// --------------------------------------------------------------------------
export function ScanInputHandler({ export function ScanInputHandler({
callback, callback,
modelType, modelType,
onClose, onClose,
onScanSuccess, onScanSuccess,
navigate navigate,
continuous: continuousProp = false
}: Readonly<{ }: Readonly<{
callback?: BarcodeScanCallback; callback?: BarcodeScanCallback;
onClose: () => void; onClose: () => void;
onScanSuccess?: BarcodeScanSuccessCallback; onScanSuccess?: BarcodeScanSuccessCallback;
modelType?: ModelType; modelType?: ModelType;
navigate: NavigateFunction; navigate: NavigateFunction;
continuous?: boolean;
}>) { }>) {
const [error, setError] = useState<string>(''); const [error, setError] = useState<string>('');
const [processing, setProcessing] = useState<boolean>(false); const [processing, setProcessing] = useState<boolean>(false);
const [scanLog, setScanLog] = useState<ScanLogEntry[]>([]);
// 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<boolean>(continuousProp);
// Ref so the scan counter survives re-renders without causing them
const scanCounter = useRef<number>(0);
const user = useUserState(); const user = useUserState();
// -------------------------------------------------------------------------
// Append a completed scan to the log
// -------------------------------------------------------------------------
const appendLog = useCallback(
(entry: Omit<ScanLogEntry, 'id'>) => {
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( const defaultScan = useCallback(
(data: any) => { (barcode: string, data: any) => {
let match = false; let match = false;
// Find the matching model type
for (const model_type of Object.keys(ModelInformationDict)) { for (const model_type of Object.keys(ModelInformationDict)) {
// If a specific model type is provided, check if it matches
if (modelType && model_type !== modelType) { if (modelType && model_type !== modelType) {
continue; continue;
} }
@ -103,12 +180,24 @@ export function ScanInputHandler({
model_type as ModelType, model_type as ModelType,
data[model_type]['pk'] data[model_type]['pk']
); );
onClose();
if (onScanSuccess) { if (onScanSuccess) {
onScanSuccess(data['barcode'], data); onScanSuccess(barcode, data);
} else { }
if (!continuous) {
onClose();
navigate(url); 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; match = true;
@ -118,12 +207,20 @@ export function ScanInputHandler({
} }
if (!match) { 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( const onScan = useCallback(
(barcode: string) => { (barcode: string) => {
if (!barcode || barcode.length === 0) { if (!barcode || barcode.length === 0) {
@ -134,20 +231,25 @@ export function ScanInputHandler({
setError(''); setError('');
api api
.post(apiUrl(ApiEndpoints.barcode), { .post(apiUrl(ApiEndpoints.barcode), { barcode })
barcode: barcode
})
.then((response: any) => { .then((response: any) => {
const data = response.data ?? {}; const data = response.data ?? {};
if (callback && data.success && response.status === 200) { if (callback && data.success && response.status === 200) {
const instance = null; // Caller-provided callback
// If the caller is expecting a specific model type, check if it matches
if (modelType) { if (modelType) {
const pk: number = data[modelType]?.['pk']; const pk: number = data[modelType]?.['pk'];
if (!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; return;
} }
} }
@ -156,53 +258,200 @@ export function ScanInputHandler({
.then((result: BarcodeScanResult) => { .then((result: BarcodeScanResult) => {
if (result.success) { if (result.success) {
hideNotification('barcode-scan'); hideNotification('barcode-scan');
showNotification({
id: 'barcode-scan', if (!continuous) {
title: t`Success`, // Classic single-scan behaviour: show toast and close
message: result.success, showNotification({
color: 'green' id: 'barcode-scan',
}); title: t`Success`,
onClose(); 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 { } 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(() => { .finally(() => {
setProcessing(false); setProcessing(false);
}); });
} else { } else {
// If no callback is provided, use the default scan function defaultScan(barcode, data);
defaultScan(data);
setProcessing(false); setProcessing(false);
} }
}) })
.catch((error) => { .catch((error) => {
const _error = extractErrorMessage({ const _error = extractErrorMessage({
error: error, error,
field: 'error', field: 'error',
defaultMessage: t`Failed to scan barcode` defaultMessage: t`Failed to scan barcode`
}); });
setError(_error); setError(_error);
if (continuous) {
appendLog({
barcode,
label: barcode,
success: false,
message: _error
});
}
}) })
.finally(() => { .finally(() => {
setProcessing(false); setProcessing(false);
}); });
}, },
[callback, defaultScan, modelType, onClose] [callback, defaultScan, modelType, onClose, continuous, appendLog]
); );
return <BarcodeInput onScan={onScan} error={error} processing={processing} />; // -------------------------------------------------------------------------
// Render
// -------------------------------------------------------------------------
return (
<Stack gap='sm'>
{/* Continuous mode toggle — always visible so user can switch on/off */}
<Group justify='space-between' align='center'>
<Checkbox
label={t`Stay open for multiple scans`}
checked={continuous}
onChange={(e) => setContinuous(e.currentTarget.checked)}
size='sm'
aria-label='continuous-scan-toggle'
/>
{continuous && scanLog.length > 0 && (
<Badge color='blue' variant='light'>
{scanLog.filter((e) => e.success).length} /{' '}
{scanLog.length} {t`scanned`}
</Badge>
)}
</Group>
<BarcodeInput onScan={onScan} error={error} processing={processing} />
{/* Scan log — only shown in continuous mode */}
{continuous && scanLog.length > 0 && (
<>
<Divider
label={t`Scan log (newest first)`}
labelPosition='center'
/>
<ScrollArea h={200} offsetScrollbars>
<Timeline active={-1} bulletSize={22} lineWidth={2}>
{scanLog.map((entry) => (
<Timeline.Item
key={entry.id}
bullet={
<ThemeIcon
size={22}
variant='filled'
color={entry.success ? 'green' : 'red'}
radius='xl'
>
{entry.success ? (
<IconCheck size={14} />
) : (
<IconX size={14} />
)}
</ThemeIcon>
}
title={
<Group gap={6} align='center'>
<Text size='sm' fw={500}>
{entry.label}
</Text>
<Tooltip label={t`Remove from log`}>
<ActionIcon
size='xs'
variant='subtle'
color='gray'
onClick={() => removeLogEntry(entry.id)}
aria-label={`remove-scan-${entry.id}`}
>
<IconCircleX size={12} />
</ActionIcon>
</Tooltip>
</Group>
}
>
<Text size='xs' c='dimmed'>
{entry.message}
</Text>
<Text size='xs' c='dimmed' ff='monospace'>
{entry.barcode}
</Text>
</Timeline.Item>
))}
</Timeline>
</ScrollArea>
<Group justify='space-between'>
<Button
variant='subtle'
size='xs'
color='red'
leftSection={<IconTrash size={14} />}
onClick={() => setScanLog([])}
>
{t`Clear log`}
</Button>
<Button variant='filled' size='sm' onClick={onClose}>
{t`Done`} ({scanLog.filter((e) => e.success).length}{' '}
{t`items`})
</Button>
</Group>
</>
)}
{/* Show Done button even when log is empty so user can dismiss */}
{continuous && scanLog.length === 0 && (
<Group justify='flex-end'>
<Button variant='filled' size='sm' onClick={onClose}>
{t`Done`}
</Button>
</Group>
)}
</Stack>
);
} }
// --------------------------------------------------------------------------
// useBarcodeScanDialog — convenience hook
// --------------------------------------------------------------------------
export function useBarcodeScanDialog({ export function useBarcodeScanDialog({
title, title,
callback, callback,
modelType modelType,
continuous = false
}: Readonly<{ }: Readonly<{
title: string; title: string;
modelType?: ModelType; modelType?: ModelType;
callback: BarcodeScanCallback; callback: BarcodeScanCallback;
/** Set to true to enable multi-scan mode by default */
continuous?: boolean;
}>) { }>) {
const [opened, setOpened] = useState(false); const [opened, setOpened] = useState(false);
@ -216,12 +465,10 @@ export function useBarcodeScanDialog({
opened={opened} opened={opened}
callback={callback} callback={callback}
modelType={modelType} modelType={modelType}
continuous={continuous}
onClose={() => setOpened(false)} onClose={() => setOpened(false)}
/> />
); );
return { return { open, dialog };
open,
dialog
};
} }

View File

@ -10,6 +10,7 @@ import {
IconList, IconList,
IconListCheck, IconListCheck,
IconListNumbers, IconListNumbers,
IconScan,
IconShoppingCart, IconShoppingCart,
IconSitemap IconSitemap
} from '@tabler/icons-react'; } from '@tabler/icons-react';
@ -27,6 +28,7 @@ import type { PanelType } from '@lib/types/Panel';
import AdminButton from '../../components/buttons/AdminButton'; import AdminButton from '../../components/buttons/AdminButton';
import PrimaryActionButton from '../../components/buttons/PrimaryActionButton'; import PrimaryActionButton from '../../components/buttons/PrimaryActionButton';
import { PrintingActions } from '../../components/buttons/PrintingActions'; import { PrintingActions } from '../../components/buttons/PrintingActions';
import { useBarcodeScanDialog } from '../../components/barcodes/BarcodeScanDialog';
import { import {
type DetailsField, type DetailsField,
DetailsTable DetailsTable
@ -703,6 +705,72 @@ export default function BuildDetail() {
fields: completeOrderFields 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 buildActions = useMemo(() => {
const canEdit = user.hasChangeRole(UserRoles.build); const canEdit = user.hasChangeRole(UserRoles.build);
@ -739,6 +807,14 @@ export default function BuildDetail() {
color='green' color='green'
onClick={completeOrder.open} onClick={completeOrder.open}
/>, />,
<PrimaryActionButton
title={t`Scan to Allocate`}
icon={<IconScan size={16} />}
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`}
/>,
<AdminButton model={ModelType.build} id={build.pk} />, <AdminButton model={ModelType.build} id={build.pk} />,
<BarcodeActionDropdown <BarcodeActionDropdown
model={ModelType.build} model={ModelType.build}
@ -805,6 +881,7 @@ export default function BuildDetail() {
{holdOrder.modal} {holdOrder.modal}
{issueOrder.modal} {issueOrder.modal}
{completeOrder.modal} {completeOrder.modal}
{scanAllocateItem.dialog}
<InstanceDetail query={instanceQuery} requiredRole={UserRoles.build}> <InstanceDetail query={instanceQuery} requiredRole={UserRoles.build}>
<Stack gap='xs'> <Stack gap='xs'>
<PageDetail <PageDetail

View File

@ -367,8 +367,12 @@ export default function Stock() {
}); });
const scanInStockItem = useBarcodeScanDialog({ const scanInStockItem = useBarcodeScanDialog({
title: t`Scan Stock Item`, title: t`Scan Stock Items into Location`,
modelType: ModelType.stockitem, modelType: ModelType.stockitem,
// continuous=true keeps the dialog open after each successful scan so
// operators can rapidly scan an entire shelf of items in one session.
// Resolves: https://github.com/inventree/InvenTree/issues/11138
continuous: true,
callback: async (barcode, response) => { callback: async (barcode, response) => {
const item = response.stockitem.instance; const item = response.stockitem.instance;