diff --git a/src/frontend/src/components/items/DocInfo.tsx b/src/frontend/src/components/items/DocInfo.tsx
new file mode 100644
index 0000000000..d9012d0716
--- /dev/null
+++ b/src/frontend/src/components/items/DocInfo.tsx
@@ -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 (
+
+
+
+ );
+}
diff --git a/src/frontend/src/components/items/DocTooltip.tsx b/src/frontend/src/components/items/DocTooltip.tsx
index c09e370dc9..80b0d5660d 100644
--- a/src/frontend/src/components/items/DocTooltip.tsx
+++ b/src/frontend/src/components/items/DocTooltip.tsx
@@ -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 (
diff --git a/src/frontend/src/components/items/Thumbnail.tsx b/src/frontend/src/components/items/Thumbnail.tsx
index 0f9c1d2a1f..cbef00ecb7 100644
--- a/src/frontend/src/components/items/Thumbnail.tsx
+++ b/src/frontend/src/components/items/Thumbnail.tsx
@@ -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 (
-
-
- {text}
-
- );
+ function MainGroup() {
+ return (
+
+
+ {text}
+
+ );
+ }
+
+ if (link)
+ return (
+
+
+
+ );
+ return ;
}
diff --git a/src/frontend/src/components/items/TitleWithDoc.tsx b/src/frontend/src/components/items/TitleWithDoc.tsx
new file mode 100644
index 0000000000..d91c7fac97
--- /dev/null
+++ b/src/frontend/src/components/items/TitleWithDoc.tsx
@@ -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 (
+
+
+ {children}
+
+ {text && }
+
+ );
+}
diff --git a/src/frontend/src/components/renderers/BuildOrderRenderer.tsx b/src/frontend/src/components/renderers/BuildOrderRenderer.tsx
new file mode 100644
index 0000000000..4b7283b3fc
--- /dev/null
+++ b/src/frontend/src/components/renderers/BuildOrderRenderer.tsx
@@ -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 (
+
+ {data?.reference}
+
+
+
+
+ );
+ };
+ return (
+
+ );
+};
diff --git a/src/frontend/src/components/renderers/GeneralRenderer.tsx b/src/frontend/src/components/renderers/GeneralRenderer.tsx
new file mode 100644
index 0000000000..bff41223ff
--- /dev/null
+++ b/src/frontend/src/components/renderers/GeneralRenderer.tsx
@@ -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
Something went wrong...
;
+ }
+ if (isFetching || isLoading) {
+ return ;
+ }
+ 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 = (
+
+ {content}
+
+ );
+ }
+
+ // Return content if it exists, else default
+ if (content !== undefined) {
+ return content;
+ }
+ return (
+
+ );
+}
diff --git a/src/frontend/src/components/renderers/PartRenderer.tsx b/src/frontend/src/components/renderers/PartRenderer.tsx
new file mode 100644
index 0000000000..ddd51f9adf
--- /dev/null
+++ b/src/frontend/src/components/renderers/PartRenderer.tsx
@@ -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 (
+
+ );
+};
diff --git a/src/frontend/src/components/renderers/PurchaseOrderRenderer.tsx b/src/frontend/src/components/renderers/PurchaseOrderRenderer.tsx
new file mode 100644
index 0000000000..d1c2213b5d
--- /dev/null
+++ b/src/frontend/src/components/renderers/PurchaseOrderRenderer.tsx
@@ -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 (
+
+ {data?.reference}
+ {code && ({code})
}
+ {data?.supplier_reference && {data?.supplier_reference}
}
+
+ );
+ };
+ return (
+
+ );
+};
diff --git a/src/frontend/src/components/renderers/SalesOrderRenderer.tsx b/src/frontend/src/components/renderers/SalesOrderRenderer.tsx
new file mode 100644
index 0000000000..9a3bfc9f09
--- /dev/null
+++ b/src/frontend/src/components/renderers/SalesOrderRenderer.tsx
@@ -0,0 +1,16 @@
+import { ApiPaths } from '../../states/ApiState';
+import { GeneralRenderer } from './GeneralRenderer';
+
+export const SalesOrderRenderer = ({ pk }: { pk: string }) => {
+ return (
+ {
+ return data.reference;
+ }}
+ />
+ );
+};
diff --git a/src/frontend/src/components/renderers/StockItemRenderer.tsx b/src/frontend/src/components/renderers/StockItemRenderer.tsx
new file mode 100644
index 0000000000..97594491d9
--- /dev/null
+++ b/src/frontend/src/components/renderers/StockItemRenderer.tsx
@@ -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 (
+
+ {data?.quantity}
+
+
+
+
+ );
+ };
+ return (
+
+ );
+};
diff --git a/src/frontend/src/components/renderers/StockLocationRenderer.tsx b/src/frontend/src/components/renderers/StockLocationRenderer.tsx
new file mode 100644
index 0000000000..f416561653
--- /dev/null
+++ b/src/frontend/src/components/renderers/StockLocationRenderer.tsx
@@ -0,0 +1,14 @@
+import { ApiPaths } from '../../states/ApiState';
+import { GeneralRenderer } from './GeneralRenderer';
+
+export const StockLocationRenderer = ({ pk }: { pk: string }) => {
+ return (
+
+ );
+};
diff --git a/src/frontend/src/components/renderers/SupplierPartRenderer.tsx b/src/frontend/src/components/renderers/SupplierPartRenderer.tsx
new file mode 100644
index 0000000000..755b92d6dc
--- /dev/null
+++ b/src/frontend/src/components/renderers/SupplierPartRenderer.tsx
@@ -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 (
+
+ {data?.SKU}
+
+
+
+
+
+
+ );
+ };
+ return (
+
+ );
+};
diff --git a/src/frontend/src/components/renderers/index.tsx b/src/frontend/src/components/renderers/index.tsx
new file mode 100644
index 0000000000..af42b84c66
--- /dev/null
+++ b/src/frontend/src/components/renderers/index.tsx
@@ -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 ;
+}
diff --git a/src/frontend/src/defaults/menuItems.tsx b/src/frontend/src/defaults/menuItems.tsx
index 39a27da79e..efca9f52dc 100644
--- a/src/frontend/src/defaults/menuItems.tsx
+++ b/src/frontend/src/defaults/menuItems.tsx
@@ -16,6 +16,13 @@ export const menuItems: MenuLinkItem[] = [
text: Profile page,
link: '/profile/user',
doctext: User attributes and design settings.
+ },
+ {
+ id: 'scan',
+ text: Scanning,
+ link: '/scan',
+ doctext: View for interactive scanning and multiple actions.,
+ highlight: true
}
];
diff --git a/src/frontend/src/pages/Index/Scan.tsx b/src/frontend/src/pages/Index/Scan.tsx
new file mode 100644
index 0000000000..5e6f0f7f8e
--- /dev/null
+++ b/src/frontend/src/pages/Index/Scan.tsx
@@ -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([]);
+ const [historyStorage, setHistoryStorage] = useLocalStorage({
+ key: 'scan-history',
+ defaultValue: []
+ });
+ const [selection, setSelection] = useState([]);
+ const [inputValue, setInputValue] = useLocalStorage({
+ 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 ;
+ case InputMethod.ImageBarcode:
+ return ;
+ default:
+ return No input selected;
+ }
+ })();
+
+ // 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 (
+
+
+ Selected elements are not known
+
+ );
+ } else if (uniqueObjectTypes.length > 1) {
+ return (
+
+
+ Multiple object types selected
+
+ );
+ }
+ return (
+ <>
+
+ Actions for {uniqueObjectTypes[0]}
+
+
+
+
+
+
+ >
+ );
+ };
+
+ // rendering
+ return (
+ <>
+
+
+
+ Scan Page
+
+
+
+
+
+
+
+
+
+
+
+
+ Input
+
+
+
+ {inp}
+
+
+
+ Action
+
+ {selection.length === 0 ? (
+
+ No selection
+
+ ) : (
+ <>
+
+ {selection.length} items selected
+
+
+ General Actions
+
+
+
+
+
+ 1}
+ title={t`Lookup part`}
+ >
+
+
+
+
+
+
+
+ >
+ )}
+
+
+
+
+
+
+ History
+
+
+
+
+
+
+
+
+ >
+ );
+}
+
+function HistoryTable({
+ data,
+ selection,
+ setSelection
+}: {
+ data: ScanItem[];
+ selection: string[];
+ setSelection: React.Dispatch>;
+}) {
+ 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 (
+
+
+ toggleRow(item.id)}
+ transitionDuration={0}
+ />
+ |
+
+ {item.objectPk && item.objectType ? (
+
+ ) : (
+ item.ref
+ )}
+ |
+ {item.objectType} |
+ {item.source} |
+ {item.timestamp?.toString()} |
+
+ );
+ });
+
+ // rendering
+ if (data.length === 0)
+ return (
+
+ No history
+
+ );
+ return (
+
+
+
+
+
+ 0 && selection.length !== data.length
+ }
+ transitionDuration={0}
+ />
+ |
+
+ Item
+ |
+
+ Type
+ |
+
+ Source
+ |
+
+ Scanned at
+ |
+
+
+ {rows}
+
+
+ );
+}
+
+// region input stuff
+enum InputMethod {
+ Manual = 'manually',
+ ImageBarcode = 'imageBarcode'
+}
+
+interface inputProps {
+ action: (items: ScanItem[]) => void;
+}
+
+function InputManual({ action }: inputProps) {
+ const [value, setValue] = useState('');
+
+ 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 (
+ <>
+
+ setValue(event.currentTarget.value)}
+ onKeyDown={getHotkeyHandler([['Enter', btnAddItem]])}
+ />
+
+
+
+
+
+ {IS_DEV_OR_DEMO && (
+
+ )}
+ >
+ );
+}
+
+/* Input that uses QR code detection from images */
+function InputImageBarcode({ action }: inputProps) {
+ const [qrCodeScanner, setQrCodeScanner] = useState(null);
+ const [camId, setCamId] = useLocalStorage({
+ key: 'camId',
+ defaultValue: null
+ });
+ const [cameras, setCameras] = useState([]);
+ const [cameraValue, setCameraValue] = useState(null);
+ const [ScanningEnabled, setIsScanning] = useState(false);
+ const [wasAutoPaused, setWasAutoPaused] = useState(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:
+ });
+ });
+ }
+
+ 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:
+ });
+ });
+ setIsScanning(true);
+ }
+ }
+
+ function btnStopScanning() {
+ if (qrCodeScanner && ScanningEnabled) {
+ qrCodeScanner.stop().catch((err: string) => {
+ showNotification({
+ title: t`Error while stopping`,
+ message: err,
+ color: 'red',
+ icon:
+ });
+ });
+ 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 (
+
+
+
+
+ {!camId && (
+
+ )}
+
+ );
+}
+
+// endregion
diff --git a/src/frontend/src/router.tsx b/src/frontend/src/router.tsx
index 10c8fbc5d7..171be315d1 100644
--- a/src/frontend/src/router.tsx
+++ b/src/frontend/src/router.tsx
@@ -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:
},
+ {
+ path: 'scan',
+ element:
+ },
{
path: 'part/',
element:
diff --git a/src/frontend/src/states/ApiState.tsx b/src/frontend/src/states/ApiState.tsx
index e0fe131a4a..89b04914ad 100644
--- a/src/frontend/src/states/ApiState.tsx
+++ b/src/frontend/src/states/ApiState.tsx
@@ -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 '';
}