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 + + { + return { value: device.id, label: device.label }; + })} + size="sm" + /> + {ScanningEnabled ? ( + + + + ) : ( + + + + )} + + + {ScanningEnabled ? t`Scanning` : t`Not scanning`} + + + + {!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 ''; }