diff --git a/src/frontend/package.json b/src/frontend/package.json index 6abad83d32..d6df1680a8 100644 --- a/src/frontend/package.json +++ b/src/frontend/package.json @@ -38,7 +38,6 @@ "@mantine/spotlight": "^7.11.0", "@mantine/vanilla-extract": "^7.11.0", "@mdxeditor/editor": "^3.6.1", - "@naisutech/react-tree": "^3.1.0", "@sentry/react": "^8.13.0", "@tabler/icons-react": "^3.7.0", "@tanstack/react-query": "^5.49.2", @@ -53,6 +52,7 @@ "embla-carousel-react": "^8.1.6", "html5-qrcode": "^2.3.8", "mantine-datatable": "^7.11.1", + "qrcode": "^1.5.3", "react": "^18.3.1", "react-dom": "^18.3.1", "react-grid-layout": "^1.4.4", @@ -72,6 +72,7 @@ "@lingui/macro": "^4.11.1", "@playwright/test": "^1.45.0", "@types/node": "^20.14.9", + "@types/qrcode": "^1.5.5", "@types/react": "^18.3.3", "@types/react-dom": "^18.3.0", "@types/react-grid-layout": "^1.3.5", diff --git a/src/frontend/src/components/buttons/ButtonMenu.tsx b/src/frontend/src/components/buttons/ButtonMenu.tsx index 42feeb0718..0bf2a36df7 100644 --- a/src/frontend/src/components/buttons/ButtonMenu.tsx +++ b/src/frontend/src/components/buttons/ButtonMenu.tsx @@ -11,7 +11,7 @@ export function ButtonMenu({ label = '' }: { icon: any; - actions: any[]; + actions: React.ReactNode[]; label?: string; tooltip?: string; }) { diff --git a/src/frontend/src/components/buttons/CopyButton.tsx b/src/frontend/src/components/buttons/CopyButton.tsx index 3ac02e5da2..c63ec29630 100644 --- a/src/frontend/src/components/buttons/CopyButton.tsx +++ b/src/frontend/src/components/buttons/CopyButton.tsx @@ -1,6 +1,13 @@ import { t } from '@lingui/macro'; -import { Button, CopyButton as MantineCopyButton } from '@mantine/core'; -import { IconCopy } from '@tabler/icons-react'; +import { + ActionIcon, + Button, + CopyButton as MantineCopyButton, + Text, + Tooltip +} from '@mantine/core'; + +import { InvenTreeIcon } from '../../functions/icons'; export function CopyButton({ value, @@ -9,24 +16,27 @@ export function CopyButton({ value: any; label?: JSX.Element; }) { + const ButtonComponent = label ? Button : ActionIcon; + return ( <MantineCopyButton value={value}> {({ copied, copy }) => ( - <Button - color={copied ? 'teal' : 'gray'} - onClick={copy} - title={t`Copy to clipboard`} - variant="subtle" - size="compact-md" - > - <IconCopy size={10} /> - {label && ( - <> - <div> </div> - {label} - </> - )} - </Button> + <Tooltip label={copied ? t`Copied` : t`Copy`} withArrow> + <ButtonComponent + color={copied ? 'teal' : 'gray'} + onClick={copy} + variant="transparent" + size="sm" + > + {copied ? ( + <InvenTreeIcon icon="check" /> + ) : ( + <InvenTreeIcon icon="copy" /> + )} + + {label && <Text ml={10}>{label}</Text>} + </ButtonComponent> + </Tooltip> )} </MantineCopyButton> ); diff --git a/src/frontend/src/components/details/Details.tsx b/src/frontend/src/components/details/Details.tsx index d3675bceaa..7c2346eb38 100644 --- a/src/frontend/src/components/details/Details.tsx +++ b/src/frontend/src/components/details/Details.tsx @@ -1,15 +1,12 @@ import { t } from '@lingui/macro'; import { - ActionIcon, Anchor, Badge, - CopyButton, Paper, Skeleton, Stack, Table, - Text, - Tooltip + Text } from '@mantine/core'; import { useSuspenseQuery } from '@tanstack/react-query'; import { getValueAtPath } from 'mantine-datatable'; @@ -24,6 +21,7 @@ import { navigateToLink } from '../../functions/navigation'; import { getDetailUrl } from '../../functions/urls'; import { apiUrl } from '../../states/ApiState'; import { useGlobalSettingsState } from '../../states/SettingsState'; +import { CopyButton } from '../buttons/CopyButton'; import { YesNoButton } from '../buttons/YesNoButton'; import { ProgressBar } from '../items/ProgressBar'; import { StylishText } from '../items/StylishText'; @@ -325,26 +323,7 @@ function StatusValue(props: Readonly<FieldProps>) { } function CopyField({ value }: { value: string }) { - return ( - <CopyButton value={value}> - {({ copied, copy }) => ( - <Tooltip label={copied ? t`Copied` : t`Copy`} withArrow> - <ActionIcon - color={copied ? 'teal' : 'gray'} - onClick={copy} - variant="transparent" - size="sm" - > - {copied ? ( - <InvenTreeIcon icon="check" /> - ) : ( - <InvenTreeIcon icon="copy" /> - )} - </ActionIcon> - </Tooltip> - )} - </CopyButton> - ); + return <CopyButton value={value} />; } export function DetailsTableField({ diff --git a/src/frontend/src/components/items/ActionDropdown.tsx b/src/frontend/src/components/items/ActionDropdown.tsx index 7d04fb0d56..d29c21c93f 100644 --- a/src/frontend/src/components/items/ActionDropdown.tsx +++ b/src/frontend/src/components/items/ActionDropdown.tsx @@ -6,6 +6,7 @@ import { Menu, Tooltip } from '@mantine/core'; +import { modals } from '@mantine/modals'; import { IconCopy, IconEdit, @@ -16,9 +17,11 @@ import { } from '@tabler/icons-react'; import { ReactNode, useMemo } from 'react'; +import { ModelType } from '../../enums/ModelType'; import { identifierString } from '../../functions/conversion'; import { InvenTreeIcon } from '../../functions/icons'; import { notYetImplemented } from '../../functions/notifications'; +import { InvenTreeQRCode } from './QRCode'; export type ActionDropdownItem = { icon: ReactNode; @@ -128,11 +131,20 @@ export function BarcodeActionDropdown({ // Common action button for viewing a barcode export function ViewBarcodeAction({ hidden = false, - onClick + model, + pk }: { hidden?: boolean; - onClick?: () => void; + model: ModelType; + pk: number; }): ActionDropdownItem { + const onClick = () => { + modals.open({ + title: t`View Barcode`, + children: <InvenTreeQRCode model={model} pk={pk} /> + }); + }; + return { icon: <IconQrcode />, name: t`View`, diff --git a/src/frontend/src/components/items/QRCode.tsx b/src/frontend/src/components/items/QRCode.tsx new file mode 100644 index 0000000000..b0826bd1d3 --- /dev/null +++ b/src/frontend/src/components/items/QRCode.tsx @@ -0,0 +1,119 @@ +import { t } from '@lingui/macro'; +import { + Box, + Code, + Group, + Image, + Select, + Skeleton, + Stack +} from '@mantine/core'; +import { useQuery } from '@tanstack/react-query'; +import QR from 'qrcode'; +import { useEffect, useMemo, useState } from 'react'; + +import { api } from '../../App'; +import { ApiEndpoints } from '../../enums/ApiEndpoints'; +import { ModelType } from '../../enums/ModelType'; +import { apiUrl } from '../../states/ApiState'; +import { useGlobalSettingsState } from '../../states/SettingsState'; +import { CopyButton } from '../buttons/CopyButton'; + +type QRCodeProps = { + ecl?: 'L' | 'M' | 'Q' | 'H'; + margin?: number; + data?: string; +}; + +export const QRCode = ({ data, ecl = 'Q', margin = 1 }: QRCodeProps) => { + const [qrCode, setQRCode] = useState<string>(); + + useEffect(() => { + if (!data) return setQRCode(undefined); + + QR.toString(data, { errorCorrectionLevel: ecl, type: 'svg', margin }).then( + (svg) => { + setQRCode(`data:image/svg+xml;utf8,${encodeURIComponent(svg)}`); + } + ); + }, [data, ecl]); + + return ( + <Box> + {qrCode ? ( + <Image src={qrCode} alt="QR Code" /> + ) : ( + <Skeleton height={500} /> + )} + </Box> + ); +}; + +type InvenTreeQRCodeProps = { + model: ModelType; + pk: number; + showEclSelector?: boolean; +} & Omit<QRCodeProps, 'data'>; + +export const InvenTreeQRCode = ({ + showEclSelector = true, + model, + pk, + ecl: eclProp = 'Q', + ...props +}: InvenTreeQRCodeProps) => { + const settings = useGlobalSettingsState(); + const [ecl, setEcl] = useState(eclProp); + + useEffect(() => { + if (eclProp) setEcl(eclProp); + }, [eclProp]); + + const { data } = useQuery({ + queryKey: ['qr-code', model, pk], + queryFn: async () => { + const res = await api.post(apiUrl(ApiEndpoints.generate_barcode), { + model, + pk + }); + + return res.data?.barcode as string; + } + }); + + const eclOptions = useMemo( + () => [ + { value: 'L', label: t`Low (7%)` }, + { value: 'M', label: t`Medium (15%)` }, + { value: 'Q', label: t`Quartile (25%)` }, + { value: 'H', label: t`High (30%)` } + ], + [] + ); + + return ( + <Stack> + <QRCode data={data} ecl={ecl} {...props} /> + {data && settings.getSetting('BARCODE_SHOW_TEXT', 'false') && ( + <Group justify={showEclSelector ? 'space-between' : 'center'}> + {showEclSelector && ( + <Select + allowDeselect={false} + label={t`Select Error Correction Level`} + value={ecl} + onChange={(v) => + setEcl(v as Exclude<QRCodeProps['ecl'], undefined>) + } + data={eclOptions} + /> + )} + + <Group> + <Code>{data}</Code> + <CopyButton value={data} /> + </Group> + </Group> + )} + </Stack> + ); +}; diff --git a/src/frontend/src/enums/ApiEndpoints.tsx b/src/frontend/src/enums/ApiEndpoints.tsx index a3b49824e0..850c4506da 100644 --- a/src/frontend/src/enums/ApiEndpoints.tsx +++ b/src/frontend/src/enums/ApiEndpoints.tsx @@ -38,6 +38,7 @@ export enum ApiEndpoints { settings_global_list = 'settings/global/', settings_user_list = 'settings/user/', barcode = 'barcode/', + generate_barcode = 'barcode/generate/', news = 'news/', global_status = 'generic/status/', version = 'version/', diff --git a/src/frontend/src/pages/build/BuildDetail.tsx b/src/frontend/src/pages/build/BuildDetail.tsx index c5635d7278..87888c5655 100644 --- a/src/frontend/src/pages/build/BuildDetail.tsx +++ b/src/frontend/src/pages/build/BuildDetail.tsx @@ -370,7 +370,10 @@ export default function BuildDetail() { tooltip={t`Barcode Actions`} icon={<IconQrcode />} actions={[ - ViewBarcodeAction({}), + ViewBarcodeAction({ + model: ModelType.build, + pk: build.pk + }), LinkBarcodeAction({ hidden: build?.barcode_hash }), diff --git a/src/frontend/src/pages/part/PartDetail.tsx b/src/frontend/src/pages/part/PartDetail.tsx index 4c4dc156a9..f6587d06a4 100644 --- a/src/frontend/src/pages/part/PartDetail.tsx +++ b/src/frontend/src/pages/part/PartDetail.tsx @@ -894,7 +894,10 @@ export default function PartDetail() { <AdminButton model={ModelType.part} pk={part.pk} />, <BarcodeActionDropdown actions={[ - ViewBarcodeAction({}), + ViewBarcodeAction({ + model: ModelType.part, + pk: part.pk + }), LinkBarcodeAction({ hidden: part?.barcode_hash || !user.hasChangeRole(UserRoles.part) }), diff --git a/src/frontend/src/pages/purchasing/PurchaseOrderDetail.tsx b/src/frontend/src/pages/purchasing/PurchaseOrderDetail.tsx index 9fab1e1ccf..4328abd200 100644 --- a/src/frontend/src/pages/purchasing/PurchaseOrderDetail.tsx +++ b/src/frontend/src/pages/purchasing/PurchaseOrderDetail.tsx @@ -305,7 +305,10 @@ export default function PurchaseOrderDetail() { <AdminButton model={ModelType.purchaseorder} pk={order.pk} />, <BarcodeActionDropdown actions={[ - ViewBarcodeAction({}), + ViewBarcodeAction({ + model: ModelType.purchaseorder, + pk: order.pk + }), LinkBarcodeAction({ hidden: order?.barcode_hash }), diff --git a/src/frontend/src/pages/stock/LocationDetail.tsx b/src/frontend/src/pages/stock/LocationDetail.tsx index 4ad6330286..01ace9d2e7 100644 --- a/src/frontend/src/pages/stock/LocationDetail.tsx +++ b/src/frontend/src/pages/stock/LocationDetail.tsx @@ -276,23 +276,28 @@ export default function Stock() { variant="outline" size="lg" />, - <BarcodeActionDropdown - actions={[ - ViewBarcodeAction({}), - LinkBarcodeAction({}), - UnlinkBarcodeAction({}), - { - name: 'Scan in stock items', - icon: <InvenTreeIcon icon="stock" />, - tooltip: 'Scan items' - }, - { - name: 'Scan in container', - icon: <InvenTreeIcon icon="unallocated_stock" />, - tooltip: 'Scan container' - } - ]} - />, + location.pk ? ( + <BarcodeActionDropdown + actions={[ + ViewBarcodeAction({ + model: ModelType.stocklocation, + pk: location.pk + }), + LinkBarcodeAction({}), + UnlinkBarcodeAction({}), + { + name: 'Scan in stock items', + icon: <InvenTreeIcon icon="stock" />, + tooltip: 'Scan items' + }, + { + name: 'Scan in container', + icon: <InvenTreeIcon icon="unallocated_stock" />, + tooltip: 'Scan container' + } + ]} + /> + ) : null, <PrintingActions modelType={ModelType.stocklocation} items={[location.pk ?? 0]} diff --git a/src/frontend/src/pages/stock/StockDetail.tsx b/src/frontend/src/pages/stock/StockDetail.tsx index c07814af48..f1450317a4 100644 --- a/src/frontend/src/pages/stock/StockDetail.tsx +++ b/src/frontend/src/pages/stock/StockDetail.tsx @@ -423,7 +423,10 @@ export default function StockDetail() { <AdminButton model={ModelType.stockitem} pk={stockitem.pk} />, <BarcodeActionDropdown actions={[ - ViewBarcodeAction({}), + ViewBarcodeAction({ + model: ModelType.stockitem, + pk: stockitem.pk + }), LinkBarcodeAction({ hidden: stockitem?.barcode_hash || !user.hasChangeRole(UserRoles.stock) diff --git a/src/frontend/src/tables/InvenTreeTable.tsx b/src/frontend/src/tables/InvenTreeTable.tsx index ad60b65593..be32a739d5 100644 --- a/src/frontend/src/tables/InvenTreeTable.tsx +++ b/src/frontend/src/tables/InvenTreeTable.tsx @@ -104,7 +104,7 @@ export type InvenTreeTableProps<T = any> = { enableLabels?: boolean; enableReports?: boolean; pageSize?: number; - barcodeActions?: any[]; + barcodeActions?: React.ReactNode[]; tableFilters?: TableFilter[]; tableActions?: React.ReactNode[]; rowExpansion?: any; diff --git a/src/frontend/yarn.lock b/src/frontend/yarn.lock index 886fc793e7..a0ee7ef85f 100644 --- a/src/frontend/yarn.lock +++ b/src/frontend/yarn.lock @@ -796,7 +796,7 @@ resolved "https://registry.yarnpkg.com/@emotion/hash/-/hash-0.9.1.tgz#4ffb0055f7ef676ebc3a5a91fb621393294e2f43" integrity sha512-gJB6HLm5rYwSLI6PQa+X1t5CFGrv1J1TWG+sOyMCeKz2ojaj6Fnl/rZEspogG+cvqbt4AE/2eIyD2QfLKTBNlQ== -"@emotion/is-prop-valid@1.2.2", "@emotion/is-prop-valid@^1.2.0": +"@emotion/is-prop-valid@1.2.2": version "1.2.2" resolved "https://registry.yarnpkg.com/@emotion/is-prop-valid/-/is-prop-valid-1.2.2.tgz#d4175076679c6a26faa92b03bb786f9e52612337" integrity sha512-uNsoYd37AFmaCdXlg6EYD1KaPOaRWRByMCYzbKUX4+hhMfrxdVSelShywL4JVaAeM/eHUOSprYBQls+/neX3pw== @@ -1822,15 +1822,6 @@ dependencies: moo "^0.5.1" -"@naisutech/react-tree@^3.1.0": - version "3.1.0" - resolved "https://registry.yarnpkg.com/@naisutech/react-tree/-/react-tree-3.1.0.tgz#a83820425b53a1ec7a39804ff8bd9024f0a953f4" - integrity sha512-6p1l3ZIaTmbgiAf/mpFELvqwl51LDhr+09f7L+C27DBLWjtleezCMoUuiSLhrJgpixCPNL13PuI3q2yn+0AGvA== - dependencies: - "@emotion/is-prop-valid" "^1.2.0" - nanoid "^4.0.0" - react-draggable "^4.4.5" - "@open-draft/deferred-promise@^2.1.0": version "2.2.0" resolved "https://registry.yarnpkg.com/@open-draft/deferred-promise/-/deferred-promise-2.2.0.tgz#4a822d10f6f0e316be4d67b4d4f8c9a124b073bd" @@ -2598,6 +2589,13 @@ resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.12.tgz#12bb1e2be27293c1406acb6af1c3f3a1481d98c6" integrity sha512-5zvhXYtRNRluoE/jAp4GVsSduVUzNWKkOZrCDBWYtE7biZywwdC2AcEzg+cSMLFRfVgeAFqpfNabiPjxFddV1Q== +"@types/qrcode@^1.5.5": + version "1.5.5" + resolved "https://registry.yarnpkg.com/@types/qrcode/-/qrcode-1.5.5.tgz#993ff7c6b584277eee7aac0a20861eab682f9dac" + integrity sha512-CdfBi/e3Qk+3Z/fXYShipBT13OJ2fDO2Q2w5CIP5anLTLIndQG9z6P1cnm+8zCWSpm5dnxMFd/uREtb0EXuQzg== + dependencies: + "@types/node" "*" + "@types/react-dom@^18.3.0": version "18.3.0" resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-18.3.0.tgz#0cbc818755d87066ab6ca74fbedb2547d74a82b0" @@ -3455,6 +3453,11 @@ diff@^5.1.0: resolved "https://registry.yarnpkg.com/diff/-/diff-5.2.0.tgz#26ded047cd1179b78b9537d5ef725503ce1ae531" integrity sha512-uIFDxqpRZGZ6ThOk84hEfqWoHx2devRFvpTZcTHur85vImfaxUbTW9Ryh4CpCuDnToOP1CEtXKIgytHBPVff5A== +dijkstrajs@^1.0.1: + version "1.0.3" + resolved "https://registry.yarnpkg.com/dijkstrajs/-/dijkstrajs-1.0.3.tgz#4c8dbdea1f0f6478bff94d9c49c784d623e4fc23" + integrity sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA== + dom-helpers@^5.0.1: version "5.2.1" resolved "https://registry.yarnpkg.com/dom-helpers/-/dom-helpers-5.2.1.tgz#d9400536b2bf8225ad98fe052e029451ac40e902" @@ -3507,6 +3510,11 @@ emoji-regex@^8.0.0: resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-8.0.0.tgz#e818fd69ce5ccfcb404594f842963bf53164cc37" integrity sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A== +encode-utf8@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/encode-utf8/-/encode-utf8-1.0.3.tgz#f30fdd31da07fb596f281beb2f6b027851994cda" + integrity sha512-ucAnuBEhUK4boH2HjVYG5Q2mQyPorvv0u/ocS+zhdw0S8AlHYY+GOFhP1Gio5z4icpP2ivFSvhtFjQi8+T9ppw== + error-ex@^1.3.1: version "1.3.2" resolved "https://registry.yarnpkg.com/error-ex/-/error-ex-1.3.2.tgz#b4ac40648107fdcdcfae242f428bea8a14d4f1bf" @@ -4952,11 +4960,6 @@ nanoid@^3.3.7: resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.7.tgz#d0c301a691bc8d54efa0a2226ccf3fe2fd656bd8" integrity sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g== -nanoid@^4.0.0: - version "4.0.2" - resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-4.0.2.tgz#140b3c5003959adbebf521c170f282c5e7f9fb9e" - integrity sha512-7ZtY5KTCNheRGfEFxnedV5zFiORN1+Y1N6zvPTnHQd8ENUvfaDBeuJDZb2bN/oXwXxu3qkTXDzy57W5vAmDTBw== - next-tick@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/next-tick/-/next-tick-1.1.0.tgz#1836ee30ad56d67ef281b22bd199f709449b35eb" @@ -5236,6 +5239,11 @@ playwright@1.45.0: optionalDependencies: fsevents "2.3.2" +pngjs@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/pngjs/-/pngjs-5.0.0.tgz#e79dd2b215767fd9c04561c01236df960bce7fbb" + integrity sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw== + pofile@^1.1.4: version "1.1.4" resolved "https://registry.yarnpkg.com/pofile/-/pofile-1.1.4.tgz#eab7e29f5017589b2a61b2259dff608c0cad76a2" @@ -5302,6 +5310,16 @@ punycode@^2.1.0: resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.1.tgz#027422e2faec0b25e1549c3e1bd8309b9133b6e5" integrity sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg== +qrcode@^1.5.3: + version "1.5.3" + resolved "https://registry.yarnpkg.com/qrcode/-/qrcode-1.5.3.tgz#03afa80912c0dccf12bc93f615a535aad1066170" + integrity sha512-puyri6ApkEHYiVl4CFzo1tDkAZ+ATcnbJrJ6RiBM1Fhctdn/ix9MTE3hRph33omisEbC/2fcfemsseiKgBPKZg== + dependencies: + dijkstrajs "^1.0.1" + encode-utf8 "^1.0.3" + pngjs "^5.0.0" + yargs "^15.3.1" + ramda@^0.27.1: version "0.27.2" resolved "https://registry.yarnpkg.com/ramda/-/ramda-0.27.2.tgz#84463226f7f36dc33592f6f4ed6374c48306c3f1" @@ -6280,7 +6298,7 @@ yargs-parser@^18.1.2: camelcase "^5.0.0" decamelize "^1.2.0" -yargs@^15.0.2: +yargs@^15.0.2, yargs@^15.3.1: version "15.4.1" resolved "https://registry.yarnpkg.com/yargs/-/yargs-15.4.1.tgz#0d87a16de01aee9d8bec2bfbf74f67851730f4f8" integrity sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==