diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 000000000..e7a70afa5 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,4 @@ +# Changelog + +## Unreleased +- Add filament label printing with separate presets, QR codes, and AML export (labels and pages), plus AML size control and filament QR scanning support. diff --git a/client/package-lock.json b/client/package-lock.json index b19647a12..4efe04a31 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -24,6 +24,7 @@ "i18next": "^25.7.3", "i18next-browser-languagedetector": "^8.2.0", "i18next-http-backend": "^3.0.2", + "jszip": "3.10.1", "react": "^19.2.3", "react-dnd": "^16.0.1", "react-dnd-html5-backend": "^16.0.1", @@ -6206,6 +6207,12 @@ "url": "https://opencollective.com/core-js" } }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, "node_modules/cosmiconfig": { "version": "8.3.6", "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-8.3.6.tgz", @@ -8656,6 +8663,12 @@ "node": ">= 4" } }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "license": "MIT" + }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -8698,7 +8711,6 @@ "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true, "license": "ISC" }, "node_modules/ini": { @@ -9626,6 +9638,54 @@ "node": ">=4.0" } }, + "node_modules/jszip": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", + "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", + "license": "(MIT OR GPL-3.0-or-later)", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, + "node_modules/jszip/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/jszip/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/jszip/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/jszip/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/kbar": { "version": "0.1.0-beta.40", "resolved": "https://registry.npmjs.org/kbar/-/kbar-0.1.0-beta.40.tgz", @@ -9687,6 +9747,15 @@ "node": ">= 0.8.0" } }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, "node_modules/lines-and-columns": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", @@ -10857,6 +10926,12 @@ "dev": true, "license": "MIT" }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, "node_modules/papaparse": { "version": "5.5.3", "resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.5.3.tgz", @@ -11316,6 +11391,12 @@ "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, "node_modules/promise-inflight": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/promise-inflight/-/promise-inflight-1.0.1.tgz", @@ -13036,6 +13117,12 @@ "node": ">= 0.4" } }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "license": "MIT" + }, "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", @@ -14352,7 +14439,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "dev": true, "license": "MIT" }, "node_modules/utils-merge": { diff --git a/client/package.json b/client/package.json index 1603ff246..4088c8b9e 100644 --- a/client/package.json +++ b/client/package.json @@ -23,6 +23,7 @@ "i18next": "^25.7.3", "i18next-browser-languagedetector": "^8.2.0", "i18next-http-backend": "^3.0.2", + "jszip": "3.10.1", "react": "^19.2.3", "react-dnd": "^16.0.1", "react-dnd-html5-backend": "^16.0.1", @@ -39,19 +40,19 @@ "@refinedev/cli": "^2.16.50", "@types/loadable__component": "^5.13.10", "@types/node": "^25.0.3", - "@types/react-dom": "^19.2.3", "@types/react": "^19.2.7", + "@types/react-dom": "^19.2.3", "@types/uuid": "^10.0.0", "@vitejs/plugin-react": "^5.1.2", + "eslint": "^9.39.2", "eslint-config-prettier": "^10.1.8", + "eslint-plugin-react": "^7.37.5", "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.4.26", - "eslint-plugin-react": "^7.37.5", - "eslint": "^9.39.2", "globals": "^17.0.0", "prettier": "3.7.4", - "typescript-eslint": "^8.52.0", "typescript": "^5.9.3", + "typescript-eslint": "^8.52.0", "vite": "^7.3.0", "vite-plugin-mkcert": "^1.17.9", "vite-plugin-pwa": "^1.2.0" diff --git a/client/public/locales/en/common.json b/client/public/locales/en/common.json index 88ff2ae85..461b1db7d 100644 --- a/client/public/locales/en/common.json +++ b/client/public/locales/en/common.json @@ -65,11 +65,21 @@ "helpMargin": "Margins should be configured to match your label paper and printer, changing these will affect the size of the entire grid.", "helpPrinterMargin": "Safe-Zone should be set to how close to the paper edge your printer can print, changing these will not affect the entire grid.", "print": "Print", + "exportLabels": "Export Labels", "columns": "Columns", "rows": "Rows", "paperSize": "Paper Size", "customSize": "Custom", "dimensions": "Dimensions", + "amlLabelSize": "AML Label Size", + "exportFormat": "Export Format", + "exportFormatOptions": { + "png": "PNG", + "aml": "AML" + }, + "exportAsZip": "Export as .zip", + "exportDpi": "Export DPI", + "exportDpiHelp": "Higher DPI makes sharper PNG/AML exports but increases file size and export time.", "showBorder": "Show Border", "previewScale": "Preview Scale", "skipItems": "Skip Items", @@ -92,6 +102,10 @@ "grid": "Grid" }, "settings": "Presets", + "spoolPrintPresets": "Spool Print Presets", + "filamentPrintPresets": "Filament Print Presets", + "spoolImagePresets": "Spool Image Presets", + "filamentImagePresets": "Filament Image Presets", "defaultSettings": "Default", "addSettings": "Add New Preset", "newSetting": "New", @@ -100,13 +114,21 @@ "deleteSettingsConfirm": "Are you sure you want to delete this preset?", "settingsName": "Preset Name", "saveSetting": "Save Presets", - "saveAsImage": "Save as Image" + "saveAsImage": "Save as Image", + "saveAsAmlLabels": "Save as AML (Labels)" }, "qrcode": { "button": "Print Labels", + "exportButton": "Export Labels", + "selectButton": "Export/Print", + "selectTitle": "Export / Print Labels", "title": "Label Printing", "template": "Label Template", + "filenameTemplate": "Filename Template", + "filenameTemplateTooltipSpool": "Use {} to insert values of the spool object as text. Refer to the label template rules and available tags for more details.", + "filenameTemplateTooltipFilament": "Use {} to insert values of the filament object as text. Refer to the label template rules and available tags for more details.", "templateHelp": "Use {} to insert values of the spool object as text. For example, {id} will be replaced with the spool id, or {filament.material} will be replaced with the material of the spool. if a value is missing it will be replaced with \"?\". A second set of {} can be used to remove this. In addition, any text between the sets of {} will be removed if the value is missing. For example, {Lot Nr: {lot_nr}} will only show the label if the spool has a lot number. Enclose text with double asterix ** to make it bold. Click the button to view a list of all available tags.", + "templateHelpFilament": "Use {} to insert values of the filament object as text. For example, {id} will be replaced with the filament id, or {vendor.name} will be replaced with the vendor name. If a value is missing it will be replaced with \"?\". A second set of {} can be used to remove this. In addition, any text between the sets of {} will be removed if the value is missing. For example, {Article: {article_number}} will only show the label if a filament has an article number. Enclose text with double asterix ** to make it bold. Click the button to view a list of all available tags.", "textSize": "Label Text Size", "showContent": "Print Label", "useHTTPUrl": { @@ -127,12 +149,22 @@ }, "spoolSelect": { "title": "Select Spools", - "description": "Select spools to print labels for.", + "description": "Select spools to export or print labels for.", + "searchPlaceholder": "Search vendor, filament, material, location, lot #", "showArchived": "Show Archived", "noSpoolsSelected": "You have not selected any spools.", "selectAll": "Select/Unselect All", "selectedTotal_one": "{{count}} spool selected", "selectedTotal_other": "{{count}} spools selected" + }, + "filamentSelect": { + "title": "Select Filaments", + "description": "Select filaments to export or print labels for.", + "searchPlaceholder": "Search vendor, name, material, article #", + "noFilamentsSelected": "You have not selected any filaments.", + "selectAll": "Select/Unselect All", + "selectedTotal_one": "{{count}} filament selected", + "selectedTotal_other": "{{count}} filaments selected" } }, "scanner": { diff --git a/client/src/App.tsx b/client/src/App.tsx index d907b8ee1..24d3ef298 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -194,7 +194,9 @@ function App() { /> } /> } /> + } /> } /> + } /> } /> @@ -208,6 +210,9 @@ function App() { /> } /> } /> + } /> + } /> + } /> } /> diff --git a/client/src/components/column.tsx b/client/src/components/column.tsx index 059b607f0..0a985b1c7 100644 --- a/client/src/components/column.tsx +++ b/client/src/components/column.tsx @@ -46,6 +46,7 @@ interface BaseColumnProps { title?: string; align?: AlignType; sorter?: boolean; + ellipsis?: boolean; t: (key: string) => string; navigate: (link: string) => void; dataSource: Obj[]; @@ -62,6 +63,7 @@ interface FilteredColumnProps { allowMultipleFilters?: boolean; onFilterDropdownOpen?: () => void; loadingFilters?: boolean; + filterSearch?: boolean | ((input: string, record: ColumnFilterItem) => boolean); } interface CustomColumnProps { @@ -90,6 +92,7 @@ function Column( dataIndex: props.id, align: props.align, title: props.title ?? t(props.i18nkey ?? `${props.i18ncat}.fields.${props.id}`), + ellipsis: props.ellipsis, filterMultiple: props.allowMultipleFilters ?? true, width: props.width ?? undefined, onCell: props.onCell ?? undefined, @@ -108,6 +111,7 @@ function Column( if (props.filters && props.filteredValue) { columnProps.filters = props.filters; columnProps.filteredValue = props.filteredValue; + columnProps.filterSearch = props.filterSearch ?? true; if (props.loadingFilters) { columnProps.filterDropdown = ; } @@ -356,7 +360,9 @@ export function SpoolIconColumn(props: SpoolIconColumnProps< )} - {value} + + {value} + ); }, diff --git a/client/src/components/otherModels.tsx b/client/src/components/otherModels.tsx index 0adabd80c..69fae0e55 100644 --- a/client/src/components/otherModels.tsx +++ b/client/src/components/otherModels.tsx @@ -86,29 +86,18 @@ export function useSpoolmanFilamentFilter(enabled: boolean = false) { } export function useSpoolmanFilamentNames(enabled: boolean = false) { - return useQuery({ + return useQuery({ enabled: enabled, - queryKey: ["filaments"], + queryKey: ["filamentNames"], queryFn: async () => { - const response = await fetch(getAPIURL() + "/filament"); + const response = await fetch(getAPIURL() + "/filament-name"); if (!response.ok) { throw new Error("Network response was not ok"); } return response.json(); }, select: (data) => { - // Concatenate vendor name and filament name - let names = data - .filter((filament) => { - return filament.name !== null && filament.name !== undefined && filament.name !== ""; - }) - .map((filament) => { - return filament.name ?? ""; - }) - .sort(); - // Remove duplicates - names = [...new Set(names)]; - return names; + return data.sort(); }, }); } diff --git a/client/src/components/qrCodeScanner.tsx b/client/src/components/qrCodeScanner.tsx index ecf97eafb..e683a7812 100644 --- a/client/src/components/qrCodeScanner.tsx +++ b/client/src/components/qrCodeScanner.tsx @@ -18,15 +18,28 @@ const QRCodeScannerModal = () => { const result = detectedCodes[0].rawValue; // Check for the spoolman ID format - const match = result.match(/^web\+spoolman:s-(?[0-9]+)$/i); - if (match && match.groups) { + const spoolMatch = result.match(/^web\+spoolman:s-(?[0-9]+)$/i); + if (spoolMatch && spoolMatch.groups) { setVisible(false); - navigate(`/spool/show/${match.groups.id}`); + navigate(`/spool/show/${spoolMatch.groups.id}`); + return; + } + const filamentMatch = result.match(/^web\+spoolman:f-(?[0-9]+)$/i); + if (filamentMatch && filamentMatch.groups) { + setVisible(false); + navigate(`/filament/show/${filamentMatch.groups.id}`); + return; + } + const spoolURLmatch = result.match(/^https?:\/\/[^/]+(?:\/[^/]+)*\/spool\/show\/(?[0-9]+)$/i); + if (spoolURLmatch && spoolURLmatch.groups) { + setVisible(false); + navigate(`/spool/show/${spoolURLmatch.groups.id}`); + return; } - const fullURLmatch = result.match(/^https?:\/\/[^/]+\/spool\/show\/(?[0-9]+)$/i); - if (fullURLmatch && fullURLmatch.groups) { + const filamentURLmatch = result.match(/^https?:\/\/[^/]+(?:\/[^/]+)*\/filament\/show\/(?[0-9]+)$/i); + if (filamentURLmatch && filamentURLmatch.groups) { setVisible(false); - navigate(`/spool/show/${fullURLmatch.groups.id}`); + navigate(`/filament/show/${filamentURLmatch.groups.id}`); } }; diff --git a/client/src/pages/filamentExport/index.tsx b/client/src/pages/filamentExport/index.tsx new file mode 100644 index 000000000..a6046dbf6 --- /dev/null +++ b/client/src/pages/filamentExport/index.tsx @@ -0,0 +1,69 @@ +import { PageHeader } from "@refinedev/antd"; +import { useTranslate } from "@refinedev/core"; +import { theme } from "antd"; +import { Content } from "antd/es/layout/layout"; +import { useEffect, useMemo } from "react"; +import { useNavigate, useSearchParams } from "react-router"; +import FilamentQRCodeExportDialog from "../printing/filamentQrCodeExportDialog"; + +const { useToken } = theme; + +export const FilamentExport = () => { + const { token } = useToken(); + const t = useTranslate(); + const [searchParams] = useSearchParams(); + const navigate = useNavigate(); + + const filamentIds = searchParams.getAll("filaments").map(Number); + const returnUrl = searchParams.get("return"); + const selectionPath = useMemo(() => { + const params = new URLSearchParams(); + if (returnUrl) { + params.set("return", returnUrl); + } + const query = params.toString(); + return `/filament/labels${query ? `?${query}` : ""}`; + }, [returnUrl]); + + useEffect(() => { + if (filamentIds.length === 0) { + navigate(selectionPath, { replace: true }); + } + }, [filamentIds.length, navigate, selectionPath]); + + return ( + <> + { + const returnUrl = searchParams.get("return"); + if (returnUrl) { + navigate(returnUrl, { relative: "path" }); + } else { + navigate("/filament"); + } + }} + > + + {filamentIds.length > 0 && } + + + + ); +}; + +export default FilamentExport; diff --git a/client/src/pages/filamentLabels/index.tsx b/client/src/pages/filamentLabels/index.tsx new file mode 100644 index 000000000..de3e6aa36 --- /dev/null +++ b/client/src/pages/filamentLabels/index.tsx @@ -0,0 +1,77 @@ +import { PageHeader } from "@refinedev/antd"; +import { useTranslate } from "@refinedev/core"; +import { theme } from "antd"; +import { Content } from "antd/es/layout/layout"; +import { useMemo } from "react"; +import { useNavigate, useSearchParams } from "react-router"; +import FilamentSelectModal from "../printing/filamentSelectModal"; + +const { useToken } = theme; + +export const FilamentLabels = () => { + const { token } = useToken(); + const t = useTranslate(); + const [searchParams] = useSearchParams(); + const navigate = useNavigate(); + + const returnUrl = searchParams.get("return"); + const initialSelectedIds = searchParams.getAll("filaments").map(Number).filter((id) => !Number.isNaN(id)); + + const selectionPath = useMemo(() => { + const params = new URLSearchParams(); + if (returnUrl) { + params.set("return", returnUrl); + } + const query = params.toString(); + return `/filament/labels${query ? `?${query}` : ""}`; + }, [returnUrl]); + + const handleNavigate = (mode: "print" | "export", ids: number[]) => { + const params = new URLSearchParams(); + ids.forEach((id) => params.append("filaments", id.toString())); + params.set("return", selectionPath); + navigate(`/filament/${mode}?${params.toString()}`); + }; + + return ( + <> + { + if (returnUrl) { + navigate(returnUrl, { relative: "path" }); + } else { + navigate("/filament"); + } + }} + > + + handleNavigate("print", ids)} + onExport={(ids) => handleNavigate("export", ids)} + /> + + + + ); +}; + +export default FilamentLabels; diff --git a/client/src/pages/filamentPrinting/index.tsx b/client/src/pages/filamentPrinting/index.tsx new file mode 100644 index 000000000..31a3c3ab2 --- /dev/null +++ b/client/src/pages/filamentPrinting/index.tsx @@ -0,0 +1,67 @@ +import { PageHeader } from "@refinedev/antd"; +import { useTranslate } from "@refinedev/core"; +import { theme } from "antd"; +import { Content } from "antd/es/layout/layout"; +import { useEffect, useMemo } from "react"; +import { useNavigate, useSearchParams } from "react-router"; +import FilamentQRCodePrintingDialog from "../printing/filamentQrCodePrintingDialog"; + +const { useToken } = theme; + +export const FilamentPrinting = () => { + const { token } = useToken(); + const t = useTranslate(); + const [searchParams] = useSearchParams(); + const navigate = useNavigate(); + + const filamentIds = searchParams.getAll("filaments").map(Number); + const returnUrl = searchParams.get("return"); + const selectionPath = useMemo(() => { + const params = new URLSearchParams(); + if (returnUrl) { + params.set("return", returnUrl); + } + const query = params.toString(); + return `/filament/labels${query ? `?${query}` : ""}`; + }, [returnUrl]); + + useEffect(() => { + if (filamentIds.length === 0) { + navigate(selectionPath, { replace: true }); + } + }, [filamentIds.length, navigate, selectionPath]); + + return ( + <> + { + const returnUrl = searchParams.get("return"); + if (returnUrl) { + navigate(returnUrl, { relative: "path" }); + } else { + navigate("/filament"); + } + }} + > + + {filamentIds.length > 0 && } + + + + ); +}; + +export default FilamentPrinting; diff --git a/client/src/pages/filaments/functions.ts b/client/src/pages/filaments/functions.ts index e19d5b3db..2187e0c7b 100644 --- a/client/src/pages/filaments/functions.ts +++ b/client/src/pages/filaments/functions.ts @@ -1,3 +1,4 @@ +import { useQueries } from "@tanstack/react-query"; import { ExternalFilament } from "../../utils/queryExternalDB"; import { getAPIURL } from "../../utils/url"; import { getOrCreateVendorFromExternal } from "../vendors/functions"; @@ -48,3 +49,24 @@ export async function createFilamentFromExternal(externalFilament: ExternalFilam } return response.json(); } + +/** + * Returns an array of queries using the useQueries hook from @tanstack/react-query. + * Each query fetches a filament by its ID from the server. + * + * @param {number[]} ids - An array of filament IDs to fetch. + * @return An array of query results, each containing the fetched filament data. + */ +export function useGetFilamentsByIds(ids: number[]) { + return useQueries({ + queries: ids.map((id) => { + return { + queryKey: ["filament", id], + queryFn: async () => { + const res = await fetch(getAPIURL() + "/filament/" + id); + return (await res.json()) as IFilament; + }, + }; + }), + }); +} diff --git a/client/src/pages/filaments/list.tsx b/client/src/pages/filaments/list.tsx index 2d42198ce..e1af5e2c1 100644 --- a/client/src/pages/filaments/list.tsx +++ b/client/src/pages/filaments/list.tsx @@ -1,4 +1,4 @@ -import { EditOutlined, EyeOutlined, FileOutlined, FilterOutlined, PlusSquareOutlined } from "@ant-design/icons"; +import { EditOutlined, EyeOutlined, FileOutlined, FilterOutlined, PlusSquareOutlined, PrinterOutlined } from "@ant-design/icons"; import { List, useTable } from "@refinedev/antd"; import { useInvalidate, useNavigation, useTranslate } from "@refinedev/core"; import { Button, Dropdown, Table } from "antd"; @@ -169,6 +169,15 @@ export const FilamentList = () => { ( <> + + {defaultButtons} )} diff --git a/client/src/pages/printing/exportDialog.tsx b/client/src/pages/printing/exportDialog.tsx new file mode 100644 index 000000000..18f6b32a2 --- /dev/null +++ b/client/src/pages/printing/exportDialog.tsx @@ -0,0 +1,785 @@ +import { DownloadOutlined } from "@ant-design/icons"; +import { useTranslate } from "@refinedev/core"; +import { Button, Checkbox, Col, Collapse, Divider, Form, InputNumber, Radio, Row, Slider, Space } from "antd"; +import * as htmlToImage from "html-to-image"; +import JSZip from "jszip"; +import { ReactElement, useRef } from "react"; +import { useSavedState } from "../../utils/saveload"; +import { PrintSettings } from "./printing"; + +interface ExportDialogProps { + items: ReactElement[]; + printSettings: PrintSettings; + setPrintSettings: (setPrintSettings: PrintSettings) => void; + style?: string; + extraSettings?: ReactElement; + extraSettingsStart?: ReactElement; + extraButtons?: ReactElement; + zipFileTypeName: string; +} + +const ExportDialog = ({ + items, + printSettings, + setPrintSettings, + style, + extraSettings, + extraSettingsStart, + extraButtons, + zipFileTypeName, +}: ExportDialogProps) => { + const t = useTranslate(); + + const [collapseState, setCollapseState] = useSavedState("export-collapseState", []); + const [previewScale, setPreviewScale] = useSavedState("export-previewScale", 0.7); + + const margin = printSettings?.margin || { top: 0, bottom: 0, left: 0, right: 0 }; + const printerMargin = printSettings?.printerMargin || { top: 0, bottom: 0, left: 0, right: 0 }; + const customPaperSize = printSettings?.customPaperSize || { width: 40, height: 30 }; + const exportDpi = printSettings?.exportDpi || 300; + const exportFormat = printSettings?.exportFormat || "aml"; + const exportAsZip = printSettings?.exportAsZip ?? false; + + const paperWidth = customPaperSize.width; + const paperHeight = customPaperSize.height; + const itemWidth = Math.max(paperWidth - margin.left - margin.right, 0); + const itemHeight = Math.max(paperHeight - margin.top - margin.bottom, 0); + + const contentRef = useRef(null); + + const pageBlocks: ReactElement[][] = []; + for (const item of items) { + pageBlocks.push([item]); + } + + const pages = pageBlocks.map(function (pageItems, pageIdx) { + const itemDivs = pageItems.map((item, itemIdx) => { + return ( +
+ {item} +
+ ); + }); + + return ( +
+
+
+ {itemDivs} +
+
+
+ ); + }); + + const getPrintItems = () => { + const root = contentRef.current ?? document; + return Array.from(root.getElementsByClassName("print-qrcode-item")); + }; + + const downloadTextFile = (filename: string, content: string, mimeType: string) => { + const blob = new Blob([content], { type: mimeType }); + const link = document.createElement("a"); + link.href = URL.createObjectURL(blob); + link.download = filename; + link.click(); + URL.revokeObjectURL(link.href); + }; + + const downloadBlobFile = (filename: string, blob: Blob) => { + const link = document.createElement("a"); + link.href = URL.createObjectURL(blob); + link.download = filename; + link.click(); + URL.revokeObjectURL(link.href); + }; + + const escapeXml = (value: string) => { + return value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); + }; + + const buildAmlPageXml = (width: number, height: number, base64Png: string) => { + const id = Math.floor(Math.random() * 2 ** 31); + const objectId = Math.floor(Math.random() * 2 ** 31); + return ` + 0 + 0 + 0 + 0 + 1 + #000000 + 0 + + 0 + ${base64Png} + ${height.toFixed(3)} + ${width.toFixed(3)} + 0.000 + 0.000 + 0.000000 + 0 + 0 + 0.7055555449591742 + #000000 + ${id} + ${objectId} + 0 + 0 + 1 + 0 + 0 + 0 + + 0 + 0 + `; + }; + + const buildAmlXml = (name: string, widthMm: number, heightMm: number, base64Pages: string[]) => { + const width = Number.isFinite(widthMm) ? widthMm : 0; + const height = Number.isFinite(heightMm) ? heightMm : 0; + const validBoundsWidth = Math.max(width - 2, 0); + const validBoundsHeight = Math.max(height - 2, 0); + const widthIn = width / 25.4; + const heightIn = height / 25.4; + + return ` + + ${escapeXml(name)} + Custom Label + 0 + ${height.toFixed(3)} + ${width.toFixed(3)} + 1 + 1 + ${validBoundsWidth.toFixed(0)} + ${validBoundsHeight.toFixed(0)} + 0 + #ffffff + #000000 + ${width.toFixed(2)}mm * ${height.toFixed(2)}mm + ${widthIn.toFixed(3)}inch * ${heightIn.toFixed(3)}inch + 0 + 0 + 0 + 0 + 0 + 0 + 0 + Custom + ${width.toFixed(1)} * ${height.toFixed(1)} mm + ${widthIn.toFixed(2)} * ${heightIn.toFixed(2)} in + + ${base64Pages.map((base64Png) => buildAmlPageXml(width, height, base64Png)).join("\n")} + + +`; + }; + + const getExportImageOptions = () => { + const exportPixelRatio = Math.max(1, Math.min(exportDpi / 96, 10)); + return { + backgroundColor: "#FFF", + cacheBust: true, + pixelRatio: exportPixelRatio, + }; + }; + + const sanitizeFilename = (value: string) => { + const trimmed = value.trim(); + if (trimmed === "") { + return ""; + } + return trimmed + .replace(/[<>:"/\\|?*\x00-\x1F]/g, "-") + .replace(/\s+/g, " ") + .replace(/\.+$/g, ""); + }; + + const getUniqueExportItems = () => { + const hasPrinted: Element[] = []; + const itemsToPrint = getPrintItems(); + const usedNames = new Set(); + const uniqueItems: { item: Element; safeName: string }[] = []; + let idx = 1; + + for (const item of itemsToPrint) { + // Prevent printing copies + let isDuplicate = false; + for (let i = 0; i < hasPrinted.length; i += 1) { + if (item.isEqualNode(hasPrinted[i])) { + isDuplicate = true; + break; + } + } + if (isDuplicate) { + continue; + } + hasPrinted.push(item); + + const rawName = (item as HTMLElement).dataset.amlName || `label-${idx}`; + const baseName = sanitizeFilename(rawName) || `label-${idx}`; + let safeName = baseName; + let nameSuffix = 1; + while (usedNames.has(safeName)) { + safeName = `${baseName}${String(nameSuffix).padStart(2, "0")}`; + nameSuffix += 1; + } + usedNames.add(safeName); + uniqueItems.push({ item, safeName }); + idx += 1; + } + return uniqueItems; + }; + + const saveAsImage = async () => { + const uniqueItems = getUniqueExportItems(); + for (const { item, safeName } of uniqueItems) { + const url = await htmlToImage.toPng(item as HTMLElement, getExportImageOptions()); + const link = document.createElement("a"); + link.href = url; + link.download = `${safeName}.png`; + link.click(); + } + }; + + const saveAsAmlLabels = async () => { + const uniqueItems = getUniqueExportItems(); + for (const { item, safeName } of uniqueItems) { + const url = await htmlToImage.toPng(item as HTMLElement, getExportImageOptions()); + const base64 = url.split(",")[1] ?? ""; + const aml = buildAmlXml(safeName, paperWidth, paperHeight, [base64]); + downloadTextFile(`${safeName}.aml`, aml, "application/xml"); + } + }; + + const saveAsZip = async () => { + const uniqueItems = getUniqueExportItems(); + if (uniqueItems.length === 0) { + return; + } + + const zip = new JSZip(); + for (const { item, safeName } of uniqueItems) { + const url = await htmlToImage.toPng(item as HTMLElement, getExportImageOptions()); + if (exportFormat === "png") { + const response = await fetch(url); + const blob = await response.blob(); + zip.file(`${safeName}.png`, blob); + } else { + const base64 = url.split(",")[1] ?? ""; + const aml = buildAmlXml(safeName, paperWidth, paperHeight, [base64]); + zip.file(`${safeName}.aml`, aml); + } + } + + const blob = await zip.generateAsync({ type: "blob" }); + downloadBlobFile(`${exportFormat.toUpperCase()} ${zipFileTypeName} labels.zip`, blob); + }; + + const handleExport = async () => { + if (exportAsZip) { + await saveAsZip(); + return; + } + + if (exportFormat === "png") { + await saveAsImage(); + return; + } + + await saveAsAmlLabels(); + }; + + return ( + <> + + +
+
+ + {pages} +
+
+ + +
+
+ {extraSettingsStart} + + +
+ { + printSettings.exportFormat = e.target.value; + setPrintSettings(printSettings); + }} + value={exportFormat} + optionType="button" + buttonStyle="solid" + /> + { + printSettings.exportAsZip = event.target.checked; + setPrintSettings(printSettings); + }} + > + {t("printing.generic.exportAsZip")} + +
+
+ + + + `${value} dpi` }} + value={exportDpi} + onChange={(value) => { + printSettings.exportDpi = value; + setPrintSettings(printSettings); + }} + /> + + + { + printSettings.exportDpi = value ?? 300; + setPrintSettings(printSettings); + }} + /> + + + + + + + { + setPreviewScale(value); + }} + /> + + + { + setPreviewScale(value ?? 0.1); + }} + /> + + + + + { + if (Array.isArray(key)) { + setCollapseState(key); + } + }} + > + + {extraSettings} + + + + + + { + customPaperSize.width = value ?? 0; + printSettings.customPaperSize = customPaperSize; + setPrintSettings(printSettings); + }} + /> + + + x + + + { + customPaperSize.height = value ?? 0; + printSettings.customPaperSize = customPaperSize; + setPrintSettings(printSettings); + }} + /> + + + + +

{t("printing.generic.helpMargin")}

+ + + + `${value} mm` }} + value={margin.left} + onChange={(value) => { + margin.left = value; + printSettings.margin = margin; + setPrintSettings(printSettings); + }} + /> + + + { + margin.left = value ?? 0; + printSettings.margin = margin; + setPrintSettings(printSettings); + }} + /> + + + + + + + `${value} mm` }} + value={margin.top} + onChange={(value) => { + margin.top = value; + printSettings.margin = margin; + setPrintSettings(printSettings); + }} + /> + + + { + margin.top = value ?? 0; + printSettings.margin = margin; + setPrintSettings(printSettings); + }} + /> + + + + + + + `${value} mm` }} + value={margin.right} + onChange={(value) => { + margin.right = value; + printSettings.margin = margin; + setPrintSettings(printSettings); + }} + /> + + + { + margin.right = value ?? 0; + printSettings.margin = margin; + setPrintSettings(printSettings); + }} + /> + + + + + + + `${value} mm` }} + value={margin.bottom} + onChange={(value) => { + margin.bottom = value; + printSettings.margin = margin; + setPrintSettings(printSettings); + }} + /> + + + { + margin.bottom = value ?? 0; + printSettings.margin = margin; + setPrintSettings(printSettings); + }} + /> + + + + +

{t("printing.generic.helpPrinterMargin")}

+ + + + `${value} mm` }} + value={printerMargin.left} + onChange={(value) => { + printerMargin.left = value; + printSettings.printerMargin = printerMargin; + setPrintSettings(printSettings); + }} + /> + + + { + printerMargin.left = value ?? 0; + printSettings.printerMargin = printerMargin; + setPrintSettings(printSettings); + }} + /> + + + + + + + `${value} mm` }} + value={printerMargin.top} + onChange={(value) => { + printerMargin.top = value; + printSettings.printerMargin = printerMargin; + setPrintSettings(printSettings); + }} + /> + + + { + printerMargin.top = value ?? 0; + printSettings.printerMargin = printerMargin; + setPrintSettings(printSettings); + }} + /> + + + + + + + `${value} mm` }} + value={printerMargin.right} + onChange={(value) => { + printerMargin.right = value; + printSettings.printerMargin = printerMargin; + setPrintSettings(printSettings); + }} + /> + + + { + printerMargin.right = value ?? 0; + printSettings.printerMargin = printerMargin; + setPrintSettings(printSettings); + }} + /> + + + + + + + `${value} mm` }} + value={printerMargin.bottom} + onChange={(value) => { + printerMargin.bottom = value; + printSettings.printerMargin = printerMargin; + setPrintSettings(printSettings); + }} + /> + + + { + printerMargin.bottom = value ?? 0; + printSettings.printerMargin = printerMargin; + setPrintSettings(printSettings); + }} + /> + + + +
+
+ +
+ +
+ + + + {extraButtons} + + + + + + ); +}; + +export default ExportDialog; diff --git a/client/src/pages/printing/filamentQrCodeExportDialog.tsx b/client/src/pages/printing/filamentQrCodeExportDialog.tsx new file mode 100644 index 000000000..21d6fb4ab --- /dev/null +++ b/client/src/pages/printing/filamentQrCodeExportDialog.tsx @@ -0,0 +1,356 @@ +import { CopyOutlined, DeleteOutlined, PlusOutlined, SaveOutlined } from "@ant-design/icons"; +import { useTranslate } from "@refinedev/core"; +import { Button, Flex, Form, Input, Modal, Popconfirm, Select, Table, Typography, message } from "antd"; +import TextArea from "antd/es/input/TextArea"; +import { useState } from "react"; +import { v4 as uuidv4 } from "uuid"; +import { EntityType, useGetFields } from "../../utils/queryFields"; +import { useGetSetting } from "../../utils/querySettings"; +import { useSavedState } from "../../utils/saveload"; +import { useGetFilamentsByIds } from "../filaments/functions"; +import { IFilament } from "../filaments/model"; +import { + SpoolQRCodePrintSettings, + renderLabelContents, + renderTemplateText, + useGetPrintSettings as useGetPrintPresets, + useSetPrintSettings as useSetPrintPresets, +} from "./printing"; +import QRCodeExportDialog from "./qrCodeExportDialog"; + +const { Text } = Typography; + +interface FilamentQRCodeExportDialogProps { + filamentIds: number[]; +} + +const FilamentQRCodeExportDialog = ({ filamentIds }: FilamentQRCodeExportDialogProps) => { + const t = useTranslate(); + const baseUrlSetting = useGetSetting("base_url"); + const baseUrlRoot = + baseUrlSetting.data?.value !== undefined && JSON.parse(baseUrlSetting.data?.value) !== "" + ? JSON.parse(baseUrlSetting.data?.value) + : window.location.origin; + const [messageApi, contextHolder] = message.useMessage(); + const [useHTTPUrl, setUseHTTPUrl] = useSavedState("export-useHTTPUrl-filament", false); + + const itemQueries = useGetFilamentsByIds(filamentIds); + const items = itemQueries + .map((itemQuery) => { + return itemQuery.data ?? null; + }) + .filter((item) => item !== null) as IFilament[]; + + const [selectedPresetState, setSelectedPresetState] = useSavedState( + "selectedImagePresetFilament", + undefined, + ); + + const [localPresets, setLocalPresets] = useState(); + const remotePresets = useGetPrintPresets("image_presets_filament"); + const setRemotePresets = useSetPrintPresets("image_presets_filament"); + + const localOrRemotePresets = localPresets ?? remotePresets; + + const savePresetsRemote = () => { + if (!localPresets) return; + setRemotePresets(localPresets); + }; + + const addNewPreset = () => { + if (!localOrRemotePresets) return; + const newId = uuidv4(); + const newPreset = { + labelSettings: { + printSettings: { + id: newId, + name: t("printing.generic.newSetting"), + }, + }, + }; + setLocalPresets([...localOrRemotePresets, newPreset]); + setSelectedPresetState(newId); + return newPreset; + }; + const duplicateCurrentPreset = () => { + if (!localOrRemotePresets) return; + const newPreset = { + ...curPreset, + labelSettings: { ...curPreset.labelSettings, printSettings: { ...curPreset.labelSettings.printSettings } }, + }; + newPreset.labelSettings.printSettings.id = uuidv4(); + setLocalPresets([...localOrRemotePresets, newPreset]); + setSelectedPresetState(newPreset.labelSettings.printSettings.id); + }; + const updateCurrentPreset = (newSettings: SpoolQRCodePrintSettings) => { + if (!localOrRemotePresets) return; + setLocalPresets( + localOrRemotePresets.map((presets) => + presets.labelSettings.printSettings.id === newSettings.labelSettings.printSettings.id ? newSettings : presets, + ), + ); + }; + const deleteCurrentPreset = () => { + if (!localOrRemotePresets) return; + setLocalPresets( + localOrRemotePresets.filter((qPreset) => qPreset.labelSettings.printSettings.id !== selectedPresetState), + ); + setSelectedPresetState(undefined); + }; + + let curPreset: SpoolQRCodePrintSettings; + if (localOrRemotePresets === undefined) { + curPreset = { + labelSettings: { + printSettings: { + id: "TEMP", + name: t("printing.generic.newSetting"), + }, + }, + }; + } else { + if (localOrRemotePresets.length === 0) { + const newSetting = addNewPreset(); + if (!newSetting) { + console.error("Error adding new setting, this should never happen"); + return; + } + localOrRemotePresets.push(newSetting); + curPreset = newSetting; + } else { + if (!selectedPresetState) { + curPreset = localOrRemotePresets[0]; + setSelectedPresetState(localOrRemotePresets[0].labelSettings.printSettings.id); + } else { + const foundSetting = localOrRemotePresets.find( + (settings) => settings.labelSettings.printSettings.id === selectedPresetState, + ); + if (foundSetting) { + curPreset = foundSetting; + } else { + curPreset = { + labelSettings: { + printSettings: { + id: "TEMP", + name: t("printing.generic.newSetting"), + }, + }, + }; + } + } + } + } + + const [templateHelpOpen, setTemplateHelpOpen] = useState(false); + const template = + curPreset.template ?? + `**{vendor.name} - {name} +#{id} - {material}** +{Diameter: {diameter} mm} +{Weight: {weight} g} +{Spool Weight: {spool_weight} g} +{ET: {settings_extruder_temp} °C} +{BT: {settings_bed_temp} °C} +{Article: {article_number}} +{{comment}} +{comment} +{vendor.comment}`; + const filenameTemplate = curPreset.filenameTemplate ?? `{vendor.name}-{material}-{name}`; + + const filamentTags = [ + { tag: "id" }, + { tag: "registered" }, + { tag: "name" }, + { tag: "material" }, + { tag: "price" }, + { tag: "density" }, + { tag: "diameter" }, + { tag: "weight" }, + { tag: "spool_weight" }, + { tag: "article_number" }, + { tag: "comment" }, + { tag: "settings_extruder_temp" }, + { tag: "settings_bed_temp" }, + { tag: "color_hex" }, + { tag: "multi_color_hexes" }, + { tag: "multi_color_direction" }, + { tag: "external_id" }, + ]; + const filamentFields = useGetFields(EntityType.filament); + if (filamentFields.data !== undefined) { + filamentFields.data.forEach((field) => { + filamentTags.push({ tag: `extra.${field.key}` }); + }); + } + const vendorTags = [ + { tag: "vendor.id" }, + { tag: "vendor.registered" }, + { tag: "vendor.name" }, + { tag: "vendor.comment" }, + { tag: "vendor.empty_spool_weight" }, + { tag: "vendor.external_id" }, + ]; + const vendorFields = useGetFields(EntityType.vendor); + if (vendorFields.data !== undefined) { + vendorFields.data.forEach((field) => { + vendorTags.push({ tag: `vendor.extra.${field.key}` }); + }); + } + + const templateTags = [...filamentTags, ...vendorTags]; + + return ( + <> + {contextHolder} + { + curPreset.labelSettings = newSettings; + updateCurrentPreset(curPreset); + }} + baseUrlRoot={baseUrlRoot} + useHTTPUrl={useHTTPUrl} + setUseHTTPUrl={setUseHTTPUrl} + previewValues={{ + default: "WEB+SPOOLMAN:F-{id}", + url: `${baseUrlRoot}/filament/show/{id}`, + }} + zipFileTypeName="filament" + extraSettingsStart={ + <> + + + +