Merge 5361d9f757 into 5df2adae6d
This commit is contained in:
commit
e2162b004a
|
|
@ -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
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue