mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +00:00
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:
parent
2be2ea4f8f
commit
ee64d12504
15
src/frontend/src/components/items/DocInfo.tsx
Normal file
15
src/frontend/src/components/items/DocInfo.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -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 (
|
||||
|
@ -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 />;
|
||||
}
|
||||
|
26
src/frontend/src/components/items/TitleWithDoc.tsx
Normal file
26
src/frontend/src/components/items/TitleWithDoc.tsx
Normal 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>
|
||||
);
|
||||
}
|
31
src/frontend/src/components/renderers/BuildOrderRenderer.tsx
Normal file
31
src/frontend/src/components/renderers/BuildOrderRenderer.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
};
|
83
src/frontend/src/components/renderers/GeneralRenderer.tsx
Normal file
83
src/frontend/src/components/renderers/GeneralRenderer.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
22
src/frontend/src/components/renderers/PartRenderer.tsx
Normal file
22
src/frontend/src/components/renderers/PartRenderer.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
};
|
@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
16
src/frontend/src/components/renderers/SalesOrderRenderer.tsx
Normal file
16
src/frontend/src/components/renderers/SalesOrderRenderer.tsx
Normal 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;
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
27
src/frontend/src/components/renderers/StockItemRenderer.tsx
Normal file
27
src/frontend/src/components/renderers/StockItemRenderer.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
};
|
@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
39
src/frontend/src/components/renderers/index.tsx
Normal file
39
src/frontend/src/components/renderers/index.tsx
Normal 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} />;
|
||||
}
|
@ -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
|
||||
}
|
||||
];
|
||||
|
||||
|
711
src/frontend/src/pages/Index/Scan.tsx
Normal file
711
src/frontend/src/pages/Index/Scan.tsx
Normal 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
|
@ -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 />
|
||||
|
@ -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 '';
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user