PUI: Scan Page (#5500)

* Added basic structure for scan

* Added function to scan page

* switched to enum and added source info

* Add QR code scanner

* added more actions and made qr more dynamic

* refactored Title and Doc combo

* cleand up structure and calls

* adjusted spacing

* cleaned up button section for QR codes

* switched to localStorage for Inputs

* fixed spacing

* added links to thumbnails

* added first renderers for objects

* removed reference col

* made open link action more selective

* added object renderers incl. APIs and matchers

* added handler for parts that do not match

* refactored object matcher

* fixed type
This commit is contained in:
Matthias Mair 2023-09-15 18:20:36 -04:00 committed by GitHub
parent 2be2ea4f8f
commit ee64d12504
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 1111 additions and 16 deletions

View File

@ -0,0 +1,15 @@
import { IconInfoCircle } from '@tabler/icons-react';
import { BaseDocProps, DocTooltip } from './DocTooltip';
interface DocInfoProps extends BaseDocProps {
size?: number;
}
export function DocInfo({ size = 18, text, detail, link }: DocInfoProps) {
return (
<DocTooltip text={text} detail={detail} link={link}>
<IconInfoCircle size={size} />
</DocTooltip>
);
}

View File

@ -4,19 +4,24 @@ import { useEffect, useRef, useState } from 'react';
import { InvenTreeStyle } from '../../globalStyle';
export interface BaseDocProps {
text: string | JSX.Element;
detail?: string | JSX.Element;
link?: string;
docchildren?: React.ReactNode;
}
export interface DocTooltipProps extends BaseDocProps {
children: React.ReactNode;
}
export function DocTooltip({
children,
text,
detail,
link,
docchildren
}: {
children: React.ReactNode;
text: string | JSX.Element;
detail?: string | JSX.Element;
link?: string;
docchildren?: React.ReactNode;
}) {
}: DocTooltipProps) {
const { classes } = InvenTreeStyle();
return (

View File

@ -1,5 +1,5 @@
import { t } from '@lingui/macro';
import { Image } from '@mantine/core';
import { Anchor, Image } from '@mantine/core';
import { Group } from '@mantine/core';
import { Text } from '@mantine/core';
@ -49,11 +49,20 @@ export function ThumbnailHoverCard({
alt?: string;
size?: number;
}) {
// TODO: Handle link
return (
<Group position="left" spacing={10}>
<Thumbnail src={src} alt={alt} size={size} />
<Text>{text}</Text>
</Group>
);
function MainGroup() {
return (
<Group position="left" spacing={10}>
<Thumbnail src={src} alt={alt} size={size} />
<Text>{text}</Text>
</Group>
);
}
if (link)
return (
<Anchor href={link} style={{ textDecoration: 'none' }}>
<MainGroup />
</Anchor>
);
return <MainGroup />;
}

View File

@ -0,0 +1,26 @@
import { Group, Title, TitleProps } from '@mantine/core';
import { DocInfo } from './DocInfo';
interface DocTitleProps extends TitleProps {
text?: string;
detail?: string;
}
export function TitleWithDoc({
children,
variant,
order,
size,
text,
detail
}: DocTitleProps) {
return (
<Group>
<Title variant={variant} order={order} size={size}>
{children}
</Title>
{text && <DocInfo text={text} detail={detail} />}
</Group>
);
}

View File

@ -0,0 +1,31 @@
import { Group } from '@mantine/core';
import { ApiPaths } from '../../states/ApiState';
import { GeneralRenderer } from './GeneralRenderer';
import { PartRenderer } from './PartRenderer';
export const BuildOrderRenderer = ({ pk }: { pk: string }) => {
const DetailRenderer = (data: any) => {
return (
<Group position="apart">
{data?.reference}
<small>
<PartRenderer
pk={data?.part_detail?.pk}
data={data?.part_detail}
link={true}
/>
</small>
</Group>
);
};
return (
<GeneralRenderer
api_key={ApiPaths.build_order_detail}
api_ref="build_order"
link={`/build/${pk}`}
pk={pk}
renderer={DetailRenderer}
/>
);
};

View File

@ -0,0 +1,83 @@
import { Anchor, Loader } from '@mantine/core';
import { useQuery } from '@tanstack/react-query';
import { api } from '../../App';
import { ApiPaths, url } from '../../states/ApiState';
import { ThumbnailHoverCard } from '../items/Thumbnail';
export function GeneralRenderer({
api_key,
api_ref: ref,
link,
pk,
image = true,
data = undefined,
renderer
}: {
api_key: ApiPaths;
api_ref: string;
link: string;
pk: string;
image?: boolean;
data?: any;
renderer?: (data: any) => JSX.Element;
}) {
// check if data was passed - or fetch it
if (!data) {
const {
data: fetched_data,
isError,
isFetching,
isLoading
} = useQuery({
queryKey: [ref, pk],
queryFn: () => {
return api
.get(url(api_key, pk))
.then((res) => res.data)
.catch(() => {
{
}
});
}
});
// Loading section
if (isError) {
return <div>Something went wrong...</div>;
}
if (isFetching || isLoading) {
return <Loader />;
}
data = fetched_data;
}
// Renderers
let content = undefined;
// Specific renderer was passed
if (renderer) content = renderer(data);
// No image and no content no default renderer
if (image === false && !content) content = data.name;
// Wrap in link if link was passed
if (content && link) {
content = (
<Anchor href={link} style={{ textDecoration: 'none' }}>
{content}
</Anchor>
);
}
// Return content if it exists, else default
if (content !== undefined) {
return content;
}
return (
<ThumbnailHoverCard
src={data.thumbnail || data.image}
text={data.name}
link={link}
/>
);
}

View File

@ -0,0 +1,22 @@
import { ApiPaths } from '../../states/ApiState';
import { GeneralRenderer } from './GeneralRenderer';
export const PartRenderer = ({
pk,
data = undefined,
link = true
}: {
pk: string;
data?: any;
link?: boolean;
}) => {
return (
<GeneralRenderer
api_key={ApiPaths.part_detail}
api_ref="part"
link={link ? `/part/${pk}` : ''}
pk={pk}
data={data}
/>
);
};

View File

@ -0,0 +1,26 @@
import { Group } from '@mantine/core';
import { ApiPaths } from '../../states/ApiState';
import { GeneralRenderer } from './GeneralRenderer';
export const PurchaseOrderRenderer = ({ pk }: { pk: string }) => {
const DetailRenderer = (data: any) => {
const code = data?.project_code_detail?.code;
return (
<Group position="apart">
<div>{data?.reference}</div>
{code && <div>({code})</div>}
{data?.supplier_reference && <div>{data?.supplier_reference}</div>}
</Group>
);
};
return (
<GeneralRenderer
api_key={ApiPaths.purchase_order_detail}
api_ref="pruchaseorder"
link={`/order/purchase-order/${pk}`}
pk={pk}
renderer={DetailRenderer}
/>
);
};

View File

@ -0,0 +1,16 @@
import { ApiPaths } from '../../states/ApiState';
import { GeneralRenderer } from './GeneralRenderer';
export const SalesOrderRenderer = ({ pk }: { pk: string }) => {
return (
<GeneralRenderer
api_key={ApiPaths.sales_order_detail}
api_ref="sales_order"
link={`/order/so/${pk}`}
pk={pk}
renderer={(data: any) => {
return data.reference;
}}
/>
);
};

View File

@ -0,0 +1,27 @@
import { Group } from '@mantine/core';
import { ApiPaths } from '../../states/ApiState';
import { GeneralRenderer } from './GeneralRenderer';
import { PartRenderer } from './PartRenderer';
export const StockItemRenderer = ({ pk }: { pk: string }) => {
const DetailRenderer = (data: any) => {
return (
<Group position="apart">
{data?.quantity}
<small>
<PartRenderer pk={data?.part_detail.pk} data={data?.part_detail} />
</small>
</Group>
);
};
return (
<GeneralRenderer
api_key={ApiPaths.stock_item_detail}
api_ref="stockitem"
link={`/stock/item/${pk}`}
pk={pk}
renderer={DetailRenderer}
/>
);
};

View File

@ -0,0 +1,14 @@
import { ApiPaths } from '../../states/ApiState';
import { GeneralRenderer } from './GeneralRenderer';
export const StockLocationRenderer = ({ pk }: { pk: string }) => {
return (
<GeneralRenderer
api_key={ApiPaths.stock_location_detail}
api_ref="stock_location"
link={`/stock/location/${pk}`}
pk={pk}
image={false}
/>
);
};

View File

@ -0,0 +1,33 @@
import { Group } from '@mantine/core';
import { ApiPaths } from '../../states/ApiState';
import { GeneralRenderer } from './GeneralRenderer';
import { PartRenderer } from './PartRenderer';
export const SupplierPartRenderer = ({ pk }: { pk: string }) => {
const DetailRenderer = (data: any) => {
return (
<Group position="apart">
{data?.SKU}
<small>
<span style={{ color: 'white' }}>
<PartRenderer
pk={data?.part_detail?.pk}
data={data?.part_detail}
link={false}
/>
</span>
</small>
</Group>
);
};
return (
<GeneralRenderer
api_key={ApiPaths.supplier_part_detail}
api_ref="supplier_part"
link={`/supplier-part/${pk}`}
pk={pk}
renderer={DetailRenderer}
/>
);
};

View File

@ -0,0 +1,39 @@
import { BuildOrderRenderer } from './BuildOrderRenderer';
import { PartRenderer } from './PartRenderer';
import { PurchaseOrderRenderer } from './PurchaseOrderRenderer';
import { SalesOrderRenderer } from './SalesOrderRenderer';
import { StockItemRenderer } from './StockItemRenderer';
import { StockLocationRenderer } from './StockLocationRenderer';
import { SupplierPartRenderer } from './SupplierPartRenderer';
export enum RenderTypes {
part = 'part',
stock_item = 'stockitem',
stock_location = 'stocklocation',
supplier_part = 'supplierpart',
purchase_order = 'purchase_order',
sales_order = 'sales_order',
build_order = 'build_order'
}
// dict of renderers
const renderers = {
[RenderTypes.part]: PartRenderer,
[RenderTypes.stock_item]: StockItemRenderer,
[RenderTypes.stock_location]: StockLocationRenderer,
[RenderTypes.supplier_part]: SupplierPartRenderer,
[RenderTypes.purchase_order]: PurchaseOrderRenderer,
[RenderTypes.sales_order]: SalesOrderRenderer,
[RenderTypes.build_order]: BuildOrderRenderer
};
export interface RenderProps {
type: RenderTypes;
pk: string;
}
export function Render(props: RenderProps) {
const { type, ...rest } = props;
const RendererComponent = renderers[type];
return <RendererComponent {...rest} />;
}

View File

@ -16,6 +16,13 @@ export const menuItems: MenuLinkItem[] = [
text: <Trans>Profile page</Trans>,
link: '/profile/user',
doctext: <Trans>User attributes and design settings.</Trans>
},
{
id: 'scan',
text: <Trans>Scanning</Trans>,
link: '/scan',
doctext: <Trans>View for interactive scanning and multiple actions.</Trans>,
highlight: true
}
];

View File

@ -0,0 +1,711 @@
import { Trans, t } from '@lingui/macro';
import {
ActionIcon,
Button,
Checkbox,
Col,
Grid,
Group,
ScrollArea,
Select,
Space,
Stack,
Table,
Text,
TextInput,
rem
} from '@mantine/core';
import { Badge, Container } from '@mantine/core';
import {
getHotkeyHandler,
randomId,
useFullscreen,
useListState,
useLocalStorage
} from '@mantine/hooks';
import { useDocumentVisibility } from '@mantine/hooks';
import { showNotification } from '@mantine/notifications';
import {
IconAlertCircle,
IconArrowsMaximize,
IconArrowsMinimize,
IconLink,
IconNumber,
IconPlayerPlayFilled,
IconPlayerStopFilled,
IconPlus,
IconQuestionMark,
IconSearch,
IconTrash
} from '@tabler/icons-react';
import { IconX } from '@tabler/icons-react';
import { Html5Qrcode } from 'html5-qrcode';
import { CameraDevice } from 'html5-qrcode/camera/core';
import { useEffect, useState } from 'react';
import { api } from '../../App';
import { DocInfo } from '../../components/items/DocInfo';
import { StylishText } from '../../components/items/StylishText';
import { TitleWithDoc } from '../../components/items/TitleWithDoc';
import { Render, RenderTypes } from '../../components/renderers';
import { notYetImplemented } from '../../functions/notifications';
import { IS_DEV_OR_DEMO } from '../../main';
import { ApiPaths, url } from '../../states/ApiState';
interface ScanItem {
id: string;
ref: string;
data: any;
timestamp: Date;
source: string;
link?: string;
objectType?: RenderTypes;
objectPk?: string;
}
function matchObject(rd: any): [RenderTypes | undefined, string | undefined] {
if (rd?.part) {
return [RenderTypes.part, rd?.part.pk];
} else if (rd?.stockitem) {
return [RenderTypes.stock_item, rd?.stockitem.pk];
} else if (rd?.stocklocation) {
return [RenderTypes.stock_location, rd?.stocklocation.pk];
} else if (rd?.supplierpart) {
return [RenderTypes.supplier_part, rd?.supplierpart.pk];
} else if (rd?.purchaseorder) {
return [RenderTypes.purchase_order, rd?.purchaseorder.pk];
} else if (rd?.salesorder) {
return [RenderTypes.sales_order, rd?.salesorder.pk];
} else if (rd?.build) {
return [RenderTypes.build_order, rd?.build.pk];
} else {
return [undefined, undefined];
}
}
export default function Scan() {
const { toggle: toggleFullscreen, fullscreen } = useFullscreen();
const [history, historyHandlers] = useListState<ScanItem>([]);
const [historyStorage, setHistoryStorage] = useLocalStorage<ScanItem[]>({
key: 'scan-history',
defaultValue: []
});
const [selection, setSelection] = useState<string[]>([]);
const [inputValue, setInputValue] = useLocalStorage<string | null>({
key: 'input-selection',
defaultValue: null
});
// button handlers
function btnRunSelectedBarcode() {
const item = getSelectedItem(selection[0]);
if (!item) return;
runBarcode(item?.ref, item?.id);
}
const selectionLinked =
selection.length === 1 && getSelectedItem(selection[0])?.link != undefined;
function btnOpenSelectedLink() {
const item = getSelectedItem(selection[0]);
if (!item) return;
if (!selectionLinked) return;
window.open(item.link, '_blank');
}
function btnDeleteFullHistory() {
historyHandlers.setState([]);
setHistoryStorage([]);
setSelection([]);
}
function btnDeleteHistory() {
historyHandlers.setState(
history.filter((item) => !selection.includes(item.id))
);
setSelection([]);
}
// general functions
function getSelectedItem(ref: string): ScanItem | undefined {
if (selection.length === 0) return;
const item = history.find((item) => item.id === ref);
if (item?.ref === undefined) return;
return item;
}
function runBarcode(value: string, id?: string) {
api
.post(url(ApiPaths.barcode), { barcode: value })
.then((response) => {
// update item in history
if (!id) return;
const item = getSelectedItem(selection[0]);
if (!item) return;
// set link data
item.link = response.data?.url;
const rsp = matchObject(response.data);
item.objectType = rsp[0];
item.objectPk = rsp[1];
historyHandlers.setState(history);
})
.catch((err) => {
// 400 and no plugin means no match
if (
err.response?.status === 400 &&
err.response?.data?.plugin === 'None'
)
return;
// otherwise log error
console.log('error while running barcode', err);
});
}
function addItems(items: ScanItem[]) {
for (const item of items) {
historyHandlers.append(item);
runBarcode(item.ref, item.id);
}
setSelection(items.map((item) => item.id));
}
// save history data to session storage
useEffect(() => {
if (history.length === 0) return;
setHistoryStorage(history);
}, [history]);
// load data from session storage on mount
if (history.length === 0 && historyStorage.length != 0) {
historyHandlers.setState(historyStorage);
}
// input stuff
const inputOptions = [
{ value: InputMethod.Manual, label: t`Manual input` },
{ value: InputMethod.ImageBarcode, label: t`Image Barcode` }
];
const inp = (function () {
switch (inputValue) {
case InputMethod.Manual:
return <InputManual action={addItems} />;
case InputMethod.ImageBarcode:
return <InputImageBarcode action={addItems} />;
default:
return <Text>No input selected</Text>;
}
})();
// selected actions component
const SelectedActions = () => {
const uniqueObjectTypes = [
...new Set(
selection
.map((id) => {
return history.find((item) => item.id === id)?.objectType;
})
.filter((item) => item != undefined)
)
];
if (uniqueObjectTypes.length === 0) {
return (
<Group spacing={0}>
<IconQuestionMark color="orange" />
<Trans>Selected elements are not known</Trans>
</Group>
);
} else if (uniqueObjectTypes.length > 1) {
return (
<Group spacing={0}>
<IconAlertCircle color="orange" />
<Trans>Multiple object types selected</Trans>
</Group>
);
}
return (
<>
<Text fz="sm" c="dimmed">
<Trans>Actions for {uniqueObjectTypes[0]} </Trans>
</Text>
<Group>
<ActionIcon onClick={notYetImplemented} title={t`Count`}>
<IconNumber />
</ActionIcon>
</Group>
</>
);
};
// rendering
return (
<>
<Group position="apart">
<Group position="left">
<StylishText>
<Trans>Scan Page</Trans>
</StylishText>
<DocInfo
text={t`This page can be used for continuously scanning items and taking actions on them.`}
/>
</Group>
<Button onClick={toggleFullscreen} size="sm" variant="subtle">
{fullscreen ? <IconArrowsMaximize /> : <IconArrowsMinimize />}
</Button>
</Group>
<Space h={'md'} />
<Grid maw={'100%'}>
<Col span={4}>
<Stack>
<Stack spacing="xs">
<Group position="apart">
<TitleWithDoc
order={3}
text={t`Select the input method you want to use to scan items.`}
>
<Trans>Input</Trans>
</TitleWithDoc>
<Select
value={inputValue}
onChange={setInputValue}
data={inputOptions}
searchable
placeholder={t`Select input method`}
nothingFound={t`Nothing found`}
/>
</Group>
{inp}
</Stack>
<Stack spacing={0}>
<TitleWithDoc
order={3}
text={t`Depending on the selected parts actions will be shown here. Not all barcode types are supported currently.`}
>
<Trans>Action</Trans>
</TitleWithDoc>
{selection.length === 0 ? (
<Text>
<Trans>No selection</Trans>
</Text>
) : (
<>
<Text>
<Trans>{selection.length} items selected</Trans>
</Text>
<Text fz="sm" c="dimmed">
<Trans>General Actions</Trans>
</Text>
<Group>
<ActionIcon
color="red"
onClick={btnDeleteHistory}
title={t`Delete`}
>
<IconTrash />
</ActionIcon>
<ActionIcon
onClick={btnRunSelectedBarcode}
disabled={selection.length > 1}
title={t`Lookup part`}
>
<IconSearch />
</ActionIcon>
<ActionIcon
onClick={btnOpenSelectedLink}
disabled={!selectionLinked}
title={t`Open Link`}
>
<IconLink />
</ActionIcon>
</Group>
<SelectedActions />
</>
)}
</Stack>
</Stack>
</Col>
<Col span={8}>
<Group position="apart">
<TitleWithDoc
order={3}
text={t`History is locally kept in this browser.`}
detail={t`The history is kept in this browser's local storage. So it won't be shared with other users or other devices but is persistent through reloads. You can select items in the history to perform actions on them. To add items, scan/enter them in the Input area.`}
>
<Trans>History</Trans>
</TitleWithDoc>
<ActionIcon color="red" onClick={btnDeleteFullHistory}>
<IconTrash />
</ActionIcon>
</Group>
<HistoryTable
data={history}
selection={selection}
setSelection={setSelection}
/>
</Col>
</Grid>
</>
);
}
function HistoryTable({
data,
selection,
setSelection
}: {
data: ScanItem[];
selection: string[];
setSelection: React.Dispatch<React.SetStateAction<string[]>>;
}) {
const toggleRow = (id: string) =>
setSelection((current) =>
current.includes(id)
? current.filter((item) => item !== id)
: [...current, id]
);
const toggleAll = () =>
setSelection((current) =>
current.length === data.length ? [] : data.map((item) => item.id)
);
const rows = data.map((item) => {
const selected = selection.includes(item.id);
return (
<tr key={item.id}>
<td>
<Checkbox
checked={selection.includes(item.id)}
onChange={() => toggleRow(item.id)}
transitionDuration={0}
/>
</td>
<td>
{item.objectPk && item.objectType ? (
<Render type={item.objectType} pk={item.objectPk} />
) : (
item.ref
)}
</td>
<td>{item.objectType}</td>
<td>{item.source}</td>
<td>{item.timestamp?.toString()}</td>
</tr>
);
});
// rendering
if (data.length === 0)
return (
<Text>
<Trans>No history</Trans>
</Text>
);
return (
<ScrollArea>
<Table miw={800} verticalSpacing="sm">
<thead>
<tr>
<th style={{ width: rem(40) }}>
<Checkbox
onChange={toggleAll}
checked={selection.length === data.length}
indeterminate={
selection.length > 0 && selection.length !== data.length
}
transitionDuration={0}
/>
</th>
<th>
<Trans>Item</Trans>
</th>
<th>
<Trans>Type</Trans>
</th>
<th>
<Trans>Source</Trans>
</th>
<th>
<Trans>Scanned at</Trans>
</th>
</tr>
</thead>
<tbody>{rows}</tbody>
</Table>
</ScrollArea>
);
}
// region input stuff
enum InputMethod {
Manual = 'manually',
ImageBarcode = 'imageBarcode'
}
interface inputProps {
action: (items: ScanItem[]) => void;
}
function InputManual({ action }: inputProps) {
const [value, setValue] = useState<string>('');
function btnAddItem() {
if (value === '') return;
const new_item: ScanItem = {
id: randomId(),
ref: value,
data: { item: value },
timestamp: new Date(),
source: InputMethod.Manual
};
action([new_item]);
setValue('');
}
function btnAddDummyItem() {
const new_item: ScanItem = {
id: randomId(),
ref: 'Test item',
data: {},
timestamp: new Date(),
source: InputMethod.Manual
};
action([new_item]);
}
return (
<>
<Group>
<TextInput
placeholder={t`Enter item serial or data`}
value={value}
onChange={(event) => setValue(event.currentTarget.value)}
onKeyDown={getHotkeyHandler([['Enter', btnAddItem]])}
/>
<ActionIcon onClick={btnAddItem} w={16}>
<IconPlus />
</ActionIcon>
</Group>
{IS_DEV_OR_DEMO && (
<Button onClick={btnAddDummyItem} variant="outline">
<Trans>Add dummy item</Trans>
</Button>
)}
</>
);
}
/* Input that uses QR code detection from images */
function InputImageBarcode({ action }: inputProps) {
const [qrCodeScanner, setQrCodeScanner] = useState<Html5Qrcode | null>(null);
const [camId, setCamId] = useLocalStorage<CameraDevice | null>({
key: 'camId',
defaultValue: null
});
const [cameras, setCameras] = useState<any[]>([]);
const [cameraValue, setCameraValue] = useState<string | null>(null);
const [ScanningEnabled, setIsScanning] = useState<boolean>(false);
const [wasAutoPaused, setWasAutoPaused] = useState<boolean>(false);
const documentState = useDocumentVisibility();
let lastValue: string = '';
// Mount QR code once we are loaded
useEffect(() => {
setQrCodeScanner(new Html5Qrcode('reader'));
// load cameras
Html5Qrcode.getCameras().then((devices) => {
if (devices?.length) {
setCameras(devices);
}
});
}, []);
// set camera value from id
useEffect(() => {
if (camId) {
setCameraValue(camId.id);
}
}, [camId]);
// Stop/start when leaving or reentering page
useEffect(() => {
if (ScanningEnabled && documentState === 'hidden') {
btnStopScanning();
setWasAutoPaused(true);
} else if (wasAutoPaused && documentState === 'visible') {
btnStartScanning();
setWasAutoPaused(false);
}
}, [documentState]);
// Scanner functions
function onScanSuccess(decodedText: string) {
qrCodeScanner?.pause();
// dedouplication
if (decodedText === lastValue) {
qrCodeScanner?.resume();
return;
}
lastValue = decodedText;
// submit value upstream
action([
{
id: randomId(),
ref: decodedText,
data: decodedText,
timestamp: new Date(),
source: InputMethod.ImageBarcode
}
]);
qrCodeScanner?.resume();
}
function onScanFailure(error: string) {
if (
error !=
'QR code parse error, error = NotFoundException: No MultiFormat Readers were able to detect the code.'
) {
console.warn(`Code scan error = ${error}`);
}
}
// button handlers
function btnSelectCamera() {
Html5Qrcode.getCameras()
.then((devices) => {
if (devices?.length) {
setCamId(devices[0]);
}
})
.catch((err) => {
showNotification({
title: t`Error while getting camera`,
message: err,
color: 'red',
icon: <IconX />
});
});
}
function btnStartScanning() {
if (camId && qrCodeScanner && !ScanningEnabled) {
qrCodeScanner
.start(
camId.id,
{ fps: 10, qrbox: { width: 250, height: 250 } },
(decodedText) => {
onScanSuccess(decodedText);
},
(errorMessage) => {
onScanFailure(errorMessage);
}
)
.catch((err: string) => {
showNotification({
title: t`Error while scanning`,
message: err,
color: 'red',
icon: <IconX />
});
});
setIsScanning(true);
}
}
function btnStopScanning() {
if (qrCodeScanner && ScanningEnabled) {
qrCodeScanner.stop().catch((err: string) => {
showNotification({
title: t`Error while stopping`,
message: err,
color: 'red',
icon: <IconX />
});
});
setIsScanning(false);
}
}
// on value change
useEffect(() => {
if (cameraValue === null) return;
if (cameraValue === camId?.id) {
console.log('matching value and id');
return;
}
const cam = cameras.find((cam) => cam.id === cameraValue);
// stop scanning if cam changed while scanning
if (qrCodeScanner && ScanningEnabled) {
// stop scanning
qrCodeScanner.stop().then(() => {
// change ID
setCamId(cam);
// start scanning
qrCodeScanner.start(
cam.id,
{ fps: 10, qrbox: { width: 250, height: 250 } },
(decodedText) => {
onScanSuccess(decodedText);
},
(errorMessage) => {
onScanFailure(errorMessage);
}
);
});
} else {
setCamId(cam);
}
}, [cameraValue]);
return (
<Stack spacing="xs">
<Group spacing="xs">
<Select
value={cameraValue}
onChange={setCameraValue}
data={cameras.map((device) => {
return { value: device.id, label: device.label };
})}
size="sm"
/>
{ScanningEnabled ? (
<ActionIcon onClick={btnStopScanning} title={t`Stop scanning`}>
<IconPlayerStopFilled />
</ActionIcon>
) : (
<ActionIcon
onClick={btnStartScanning}
title={t`Start scanning`}
disabled={!camId}
>
<IconPlayerPlayFilled />
</ActionIcon>
)}
<Space sx={{ flex: 1 }} />
<Badge color={ScanningEnabled ? 'green' : 'orange'}>
{ScanningEnabled ? t`Scanning` : t`Not scanning`}
</Badge>
</Group>
<Container px={0} id="reader" w={'100%'} mih="300px" />
{!camId && (
<Button onClick={btnSelectCamera}>
<Trans>Select Camera</Trans>
</Button>
)}
</Stack>
);
}
// endregion

View File

@ -14,6 +14,7 @@ export const Playground = Loadable(
export const PartIndex = Loadable(lazy(() => import('./pages/part/PartIndex')));
export const Stock = Loadable(lazy(() => import('./pages/Index/Stock')));
export const Build = Loadable(lazy(() => import('./pages/Index/Build')));
export const Scan = Loadable(lazy(() => import('./pages/Index/Scan')));
export const Dashboard = Loadable(
lazy(() => import('./pages/Index/Dashboard'))
@ -73,6 +74,10 @@ export const router = createBrowserRouter(
path: 'playground/',
element: <Playground />
},
{
path: 'scan',
element: <Scan />
},
{
path: 'part/',
element: <PartIndex />

View File

@ -48,7 +48,16 @@ export enum ApiPaths {
user_token = 'api-user-token',
user_simple_login = 'api-user-simple-login',
user_reset = 'api-user-reset',
user_reset_set = 'api-user-reset-set'
user_reset_set = 'api-user-reset-set',
barcode = 'api-barcode',
part_detail = 'api-part-detail',
supplier_part_detail = 'api-supplier-part-detail',
stock_item_detail = 'api-stock-item-detail',
stock_location_detail = 'api-stock-location-detail',
purchase_order_detail = 'api-purchase-order-detail',
sales_order_detail = 'api-sales-order-detail',
build_order_detail = 'api-build-order-detail'
}
export function url(path: ApiPaths, pk?: any): string {
@ -64,6 +73,23 @@ export function url(path: ApiPaths, pk?: any): string {
case ApiPaths.user_reset_set:
return '/auth/password/reset/confirm/';
case ApiPaths.barcode:
return 'barcode/';
case ApiPaths.part_detail:
return `part/${pk}/`;
case ApiPaths.supplier_part_detail:
return `company/part/${pk}/`;
case ApiPaths.stock_item_detail:
return `stock/${pk}/`;
case ApiPaths.stock_location_detail:
return `stock/location/${pk}/`;
case ApiPaths.purchase_order_detail:
return `order/po/${pk}/`;
case ApiPaths.sales_order_detail:
return `order/so/${pk}/`;
case ApiPaths.build_order_detail:
return `build/${pk}/`;
default:
return '';
}