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 = () => { ( <> + } + onClick={() => { + navigate("labels"); + }} + > + {t("printing.qrcode.selectButton")} + } diff --git a/client/src/pages/filaments/show.tsx b/client/src/pages/filaments/show.tsx index 4bc6c66be..1db2ce073 100644 --- a/client/src/pages/filaments/show.tsx +++ b/client/src/pages/filaments/show.tsx @@ -1,5 +1,6 @@ import { DateField, NumberField, Show, TextField } from "@refinedev/antd"; import { useShow, useTranslate } from "@refinedev/core"; +import { PrinterOutlined } from "@ant-design/icons"; import { Button, Typography } from "antd"; import dayjs from "dayjs"; import utc from "dayjs/plugin/utc"; @@ -10,6 +11,7 @@ import SpoolIcon from "../../components/spoolIcon"; import { enrichText } from "../../utils/parsing"; import { EntityType, useGetFields } from "../../utils/queryFields"; import { useCurrencyFormatter } from "../../utils/settings"; +import { getBasePath, stripBasePath } from "../../utils/url"; import { IFilament } from "./model"; dayjs.extend(utc); @@ -65,6 +67,19 @@ export const FilamentShow = () => { {t("filament.fields.spools")} + } + href={ + getBasePath() + + "/filament/labels?filaments=" + + record?.id + + "&return=" + + encodeURIComponent(stripBasePath(window.location.pathname)) + } + > + {t("printing.qrcode.selectButton")} + {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} + } size="large" onClick={handleExport}> + {t("printing.generic.exportLabels")} + + + + + > + ); +}; + +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={ + <> + + + { + setSelectedPresetState(value); + }} + options={ + localOrRemotePresets && + localOrRemotePresets.map((settings) => ({ + label: settings.labelSettings.printSettings?.name || t("printing.generic.defaultSettings"), + value: settings.labelSettings.printSettings.id, + })) + } + > + } + title={t("printing.generic.addSettings")} + onClick={addNewPreset} + /> + } + title={t("printing.generic.duplicateSettings")} + onClick={duplicateCurrentPreset} + /> + {localOrRemotePresets && localOrRemotePresets.length > 1 && ( + + } + title={t("printing.generic.deleteSettings")} + /> + + )} + + + + { + curPreset.labelSettings.printSettings.name = e.target.value; + updateCurrentPreset(curPreset); + }} + /> + + > + } + items={items.map((filament) => ({ + value: useHTTPUrl ? `${baseUrlRoot}/filament/show/${filament.id}` : `WEB+SPOOLMAN:F-${filament.id}`, + amlName: renderTemplateText(filenameTemplate, filament), + label: ( + + {renderLabelContents(template, filament)} + + ), + errorLevel: "H", + }))} + extraSettings={ + <> + + { + curPreset.template = newValue.target.value; + updateCurrentPreset(curPreset); + }} + /> + + setTemplateHelpOpen(false)}> + + + + {t("printing.qrcode.templateHelpFilament")}{" "} + setTemplateHelpOpen(true)}> + {t("actions.show")} + + + + { + curPreset.filenameTemplate = newValue.target.value; + updateCurrentPreset(curPreset); + }} + /> + + > + } + extraButtons={ + <> + } + onClick={() => { + savePresetsRemote(); + messageApi.success(t("notifications.saveSuccessful")); + }} + > + {t("printing.generic.saveSetting")} + + > + } + /> + > + ); +}; + +export default FilamentQRCodeExportDialog; diff --git a/client/src/pages/printing/filamentQrCodePrintingDialog.tsx b/client/src/pages/printing/filamentQrCodePrintingDialog.tsx new file mode 100644 index 000000000..531e585cc --- /dev/null +++ b/client/src/pages/printing/filamentQrCodePrintingDialog.tsx @@ -0,0 +1,340 @@ +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, + useGetPrintSettings as useGetPrintPresets, + useSetPrintSettings as useSetPrintPresets, +} from "./printing"; +import QRCodePrintingDialog from "./qrCodePrintingDialog"; + +const { Text } = Typography; + +interface FilamentQRCodePrintingDialogProps { + filamentIds: number[]; +} + +const FilamentQRCodePrintingDialog = ({ filamentIds }: FilamentQRCodePrintingDialogProps) => { + 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("print-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( + "selectedPresetFilament", + undefined, + ); + + const [localPresets, setLocalPresets] = useState(); + const remotePresets = useGetPrintPresets("print_presets_filament"); + const setRemotePresets = useSetPrintPresets("print_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 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}`, + }} + extraSettingsStart={ + <> + + + { + setSelectedPresetState(value); + }} + options={ + localOrRemotePresets && + localOrRemotePresets.map((settings) => ({ + label: settings.labelSettings.printSettings?.name || t("printing.generic.defaultSettings"), + value: settings.labelSettings.printSettings.id, + })) + } + > + } + title={t("printing.generic.addSettings")} + onClick={addNewPreset} + /> + } + title={t("printing.generic.duplicateSettings")} + onClick={duplicateCurrentPreset} + /> + {localOrRemotePresets && localOrRemotePresets.length > 1 && ( + + } + title={t("printing.generic.deleteSettings")} + /> + + )} + + + + { + curPreset.labelSettings.printSettings.name = e.target.value; + updateCurrentPreset(curPreset); + }} + /> + + > + } + items={items.map((filament) => ({ + value: useHTTPUrl ? `${baseUrlRoot}/filament/show/${filament.id}` : `WEB+SPOOLMAN:F-${filament.id}`, + amlName: `filament-${filament.id}`, + label: ( + + {renderLabelContents(template, filament)} + + ), + errorLevel: "H", + }))} + extraSettings={ + <> + + { + curPreset.template = newValue.target.value; + updateCurrentPreset(curPreset); + }} + /> + + setTemplateHelpOpen(false)}> + + + + {t("printing.qrcode.templateHelpFilament")}{" "} + setTemplateHelpOpen(true)}> + {t("actions.show")} + + + > + } + extraButtons={ + <> + } + onClick={() => { + savePresetsRemote(); + messageApi.success(t("notifications.saveSuccessful")); + }} + > + {t("printing.generic.saveSetting")} + + > + } + /> + > + ); +}; + +export default FilamentQRCodePrintingDialog; diff --git a/client/src/pages/printing/filamentSelectModal.tsx b/client/src/pages/printing/filamentSelectModal.tsx new file mode 100644 index 000000000..b3203d335 --- /dev/null +++ b/client/src/pages/printing/filamentSelectModal.tsx @@ -0,0 +1,314 @@ +import { useTable } from "@refinedev/antd"; +import { CrudFilter } from "@refinedev/core"; +import { Button, Checkbox, Col, Input, message, Pagination, Row, Space, Table } from "antd"; +import { t } from "i18next"; +import { useMemo, useState } from "react"; +import { useNavigate } from "react-router"; +import { FilteredQueryColumn, SortedColumn, SpoolIconColumn } from "../../components/column"; +import { useSpoolmanFilamentNames, useSpoolmanMaterials, useSpoolmanVendors } from "../../components/otherModels"; +import { removeUndefined } from "../../utils/filtering"; +import { TableState } from "../../utils/saveload"; +import { IFilament } from "../filaments/model"; + +interface Props { + description?: string; + initialSelectedIds?: number[]; + onExport?: (selectedIds: number[]) => void; + onPrint?: (selectedIds: number[]) => void; +} + +interface IFilamentCollapsed extends IFilament { + "vendor.name": string | null; +} + +function collapseFilament(element: IFilament): IFilamentCollapsed { + return { ...element, "vendor.name": element.vendor?.name ?? null }; +} + +const FilamentSelectModal = ({ description, initialSelectedIds, onExport, onPrint }: Props) => { + const [selectedItems, setSelectedItems] = useState(initialSelectedIds ?? []); + const [messageApi, contextHolder] = message.useMessage(); + const navigate = useNavigate(); + const [searchValue, setSearchValue] = useState(""); + + const { tableProps, sorters, filters, setFilters, currentPage, pageSize, setCurrentPage, setPageSize } = + useTable({ + resource: "filament", + syncWithLocation: false, + pagination: { + mode: "server", + currentPage: 1, + pageSize: 50, + }, + sorters: { + mode: "server", + }, + filters: { + mode: "server", + }, + queryOptions: { + select(data) { + return { + total: data.total, + data: data.data.map(collapseFilament), + }; + }, + }, + }); + + const tableState: TableState = { + sorters, + filters, + pagination: { currentPage: currentPage, pageSize }, + }; + + const dataSource: IFilamentCollapsed[] = useMemo( + () => (tableProps.dataSource || []).map((record) => ({ ...record })), + [tableProps.dataSource], + ); + const selectedSet = useMemo(() => new Set(selectedItems), [selectedItems]); + + const paginationTotal = tableProps.pagination ? tableProps.pagination.total ?? 0 : 0; + const handlePageChange = (page: number, nextPageSize?: number) => { + if (typeof nextPageSize === "number" && nextPageSize !== pageSize) { + setPageSize(nextPageSize); + } + setCurrentPage(page); + }; + const handlePageSizeChange = (_current: number, size: number) => { + setPageSize(size); + setCurrentPage(1); + }; + + const applySearchFilter = (nextSearch: string) => { + const trimmedSearch = nextSearch.trim(); + const nextFilters: CrudFilter[] = []; + (filters ?? []).forEach((filter) => { + if ("field" in filter && filter.field !== "search") { + nextFilters.push(filter); + } + }); + if (trimmedSearch.length > 0) { + nextFilters.push({ + field: "search", + operator: "contains", + value: [trimmedSearch], + }); + } + setFilters(nextFilters, "replace"); + setCurrentPage(1); + }; + + const selectUnselectFiltered = (select: boolean) => { + setSelectedItems((prevSelected) => { + const nextSelected = new Set(prevSelected); + dataSource.forEach((filament) => { + if (select) { + nextSelected.add(filament.id); + } else { + nextSelected.delete(filament.id); + } + }); + return Array.from(nextSelected); + }); + }; + + const handleSelectItem = (item: number) => { + setSelectedItems((prevSelected) => + prevSelected.includes(item) ? prevSelected.filter((selected) => selected !== item) : [...prevSelected, item], + ); + }; + + const isAllFilteredSelected = dataSource.every((filament) => selectedSet.has(filament.id)); + const isSomeButNotAllFilteredSelected = + dataSource.some((filament) => selectedSet.has(filament.id)) && !isAllFilteredSelected; + + const commonProps = { + t, + navigate, + dataSource, + tableState, + sorter: true, + }; + + return ( + <> + {contextHolder} + + {(description || tableProps.pagination) && ( + + {description && {description}} + {tableProps.pagination && ( + + + + )} + + )} + + + { + const value = event.target.value; + setSearchValue(value); + if (value === "") { + applySearchFilter(""); + } + }} + onSearch={(value) => { + setSearchValue(value); + applySearchFilter(value); + }} + /> + + + + + { + setSearchValue(""); + setFilters([], "replace"); + setCurrentPage(1); + }} + > + {t("buttons.clearFilters")} + + + + + { + selectUnselectFiltered(e.target.checked); + }} + > + {t("printing.filamentSelect.selectAll")} + + + {t("printing.filamentSelect.selectedTotal", { + count: selectedItems.length, + })} + + + {onPrint && ( + { + if (selectedItems.length === 0) { + messageApi.open({ + type: "error", + content: t("printing.filamentSelect.noFilamentsSelected"), + }); + return; + } + onPrint(selectedItems); + }} + > + {t("printing.qrcode.button")} + + )} + {onExport && ( + { + if (selectedItems.length === 0) { + messageApi.open({ + type: "error", + content: t("printing.filamentSelect.noFilamentsSelected"), + }); + return; + } + onExport(selectedItems); + }} + > + {t("printing.qrcode.exportButton")} + + )} + + + + + + ( + handleSelectItem(item.id)} /> + ), + }, + SortedColumn({ + ...commonProps, + id: "id", + i18ncat: "filament", + width: 70, + }), + FilteredQueryColumn({ + ...commonProps, + id: "vendor.name", + i18nkey: "filament.fields.vendor_name", + width: 200, + ellipsis: true, + filterValueQuery: useSpoolmanVendors(), + }), + SpoolIconColumn({ + ...commonProps, + id: "name", + i18ncat: "filament", + width: 360, + ellipsis: true, + color: (record: IFilamentCollapsed) => + record.multi_color_hexes + ? { + colors: record.multi_color_hexes.split(","), + vertical: record.multi_color_direction === "longitudinal", + } + : record.color_hex, + filterValueQuery: useSpoolmanFilamentNames(), + }), + FilteredQueryColumn({ + ...commonProps, + id: "material", + i18ncat: "filament", + width: 140, + ellipsis: true, + filterValueQuery: useSpoolmanMaterials(), + }), + ])} + /> + + + > + ); +}; + +export default FilamentSelectModal; diff --git a/client/src/pages/printing/index.tsx b/client/src/pages/printing/index.tsx index f6ddc4246..51349f777 100644 --- a/client/src/pages/printing/index.tsx +++ b/client/src/pages/printing/index.tsx @@ -2,24 +2,34 @@ import { PageHeader } from "@refinedev/antd"; import { useTranslate } from "@refinedev/core"; import { theme } from "antd"; import { Content } from "antd/es/layout/layout"; -import dayjs from "dayjs"; -import utc from "dayjs/plugin/utc"; +import { useEffect, useMemo } from "react"; import { useNavigate, useSearchParams } from "react-router"; import SpoolQRCodePrintingDialog from "./spoolQrCodePrintingDialog"; -import SpoolSelectModal from "./spoolSelectModal"; - -dayjs.extend(utc); const { useToken } = theme; export const Printing = () => { const { token } = useToken(); const t = useTranslate(); - const [searchParams, setSearchParams] = useSearchParams(); + const [searchParams] = useSearchParams(); const navigate = useNavigate(); const spoolIds = searchParams.getAll("spools").map(Number); - const step = spoolIds.length > 0 ? 1 : 0; + const returnUrl = searchParams.get("return"); + const selectionPath = useMemo(() => { + const params = new URLSearchParams(); + if (returnUrl) { + params.set("return", returnUrl); + } + const query = params.toString(); + return `/spool/labels${query ? `?${query}` : ""}`; + }, [returnUrl]); + + useEffect(() => { + if (spoolIds.length === 0) { + navigate(selectionPath, { replace: true }); + } + }, [navigate, selectionPath, spoolIds.length]); return ( <> @@ -47,21 +57,7 @@ export const Printing = () => { lineHeight: 1.5, }} > - {step === 0 && ( - { - setSearchParams((prev) => { - const newParams = new URLSearchParams(prev); - newParams.delete("spools"); - spools.forEach((spool) => newParams.append("spools", spool.id.toString())); - newParams.set("return", "/spool/print"); - return newParams; - }); - }} - /> - )} - {step === 1 && } + {spoolIds.length > 0 && } > diff --git a/client/src/pages/printing/printing.tsx b/client/src/pages/printing/printing.tsx index 59b47895f..8f8c21324 100644 --- a/client/src/pages/printing/printing.tsx +++ b/client/src/pages/printing/printing.tsx @@ -1,7 +1,6 @@ import { ReactElement } from "react"; import { v4 as uuidv4 } from "uuid"; import { useGetSetting, useSetSetting } from "../../utils/querySettings"; -import { ISpool } from "../spools/model"; export interface PrintSettings { id: string; @@ -16,6 +15,10 @@ export interface PrintSettings { paperSize?: string; customPaperSize?: { width: number; height: number }; borderShowMode?: "none" | "border" | "grid"; + amlLabelSize?: { width: number; height: number }; + exportDpi?: number; + exportFormat?: "png" | "aml"; + exportAsZip?: boolean; } export interface QRCodePrintSettings { @@ -27,11 +30,12 @@ export interface QRCodePrintSettings { export interface SpoolQRCodePrintSettings { template?: string; + filenameTemplate?: string; labelSettings: QRCodePrintSettings; } -export function useGetPrintSettings(): SpoolQRCodePrintSettings[] | undefined { - const { data } = useGetSetting("print_presets"); +export function useGetPrintSettings(settingKey = "print_presets"): SpoolQRCodePrintSettings[] | undefined { + const { data } = useGetSetting(settingKey); if (!data) return; const parsed: SpoolQRCodePrintSettings[] = data && data.value ? JSON.parse(data.value) : ([] as SpoolQRCodePrintSettings[]); @@ -44,8 +48,10 @@ export function useGetPrintSettings(): SpoolQRCodePrintSettings[] | undefined { }); } -export function useSetPrintSettings(): (spoolQRCodePrintSettings: SpoolQRCodePrintSettings[]) => void { - const mut = useSetSetting("print_presets"); +export function useSetPrintSettings( + settingKey = "print_presets", +): (spoolQRCodePrintSettings: SpoolQRCodePrintSettings[]) => void { + const mut = useSetSetting(settingKey); return (spoolQRCodePrintSettings: SpoolQRCodePrintSettings[]) => { mut.mutate(spoolQRCodePrintSettings); @@ -99,29 +105,33 @@ function applyTextFormatting(text: string): ReactElement[] { return elements; } -export function renderLabelContents(template: string, spool: ISpool): ReactElement { +export function renderTemplateText(template: string, obj: GenericObject): string { // Find all {tags} in the template string and loop over them const matches = [...template.matchAll(/{(?:[^}{]|{[^}{]*})*}/gs)]; - let label_text = template; + let renderedText = template; matches.forEach((match) => { if ((match[0].match(/{/g) || []).length == 1) { const tag = match[0].replace(/[{}]/g, ""); - const tagValue = getTagValue(tag, spool); - label_text = label_text.replace(match[0], tagValue); + const tagValue = getTagValue(tag, obj); + renderedText = renderedText.replace(match[0], String(tagValue)); } else if ((match[0].match(/{/g) || []).length == 2) { const structure = match[0].match(/{(.*?){(.*?)}(.*?)}/); if (structure != null) { const tag = structure[2]; - const tagValue = getTagValue(tag, spool); - if (tagValue == "?") { - label_text = label_text.replace(match[0], ""); + const tagValue = getTagValue(tag, obj); + if (tagValue === "?") { + renderedText = renderedText.replace(match[0], ""); } else { - label_text = label_text.replace(match[0], structure[1] + tagValue + structure[3]); + renderedText = renderedText.replace(match[0], structure[1] + tagValue + structure[3]); } } } }); + return renderedText; +} +export function renderLabelContents(template: string, obj: GenericObject): ReactElement { + const renderedText = renderTemplateText(template, obj); // Split string on \n into individual lines - return <>{applyTextFormatting(label_text)}>; + return <>{applyTextFormatting(renderedText)}>; } diff --git a/client/src/pages/printing/printingDialog.tsx b/client/src/pages/printing/printingDialog.tsx index 02b8019a7..b6975fbad 100644 --- a/client/src/pages/printing/printingDialog.tsx +++ b/client/src/pages/printing/printingDialog.tsx @@ -1,4 +1,4 @@ -import { FileImageOutlined, PrinterOutlined } from "@ant-design/icons"; +import { PrinterOutlined } from "@ant-design/icons"; import { useTranslate } from "@refinedev/core"; import { Button, @@ -14,7 +14,6 @@ import { Slider, Space, } from "antd"; -import * as htmlToImage from "html-to-image"; import { ReactElement, useRef } from "react"; import { useReactToPrint } from "react-to-print"; import { useSavedState } from "../../utils/saveload"; @@ -178,30 +177,6 @@ const PrintingDialog = ({ ); }); - const saveAsImage = () => { - const hasPrinted: Element[] = []; - - Array.from(document.getElementsByClassName("print-qrcode-item")).forEach(async (item) => { - // Prevent printing copies - for (let i = 0; i < hasPrinted.length; i += 1) { - if (item.isEqualNode(hasPrinted[i])) return; - } - hasPrinted.push(item); - - // Generate png image - const url = await htmlToImage.toPng(item as HTMLElement, { - backgroundColor: "#FFF", - cacheBust: true, - }); - - // Download image - const link = document.createElement("a"); - link.href = url; - link.download = "spoolmanlabel.png"; - link.click(); - }); - }; - return ( <> @@ -811,13 +786,10 @@ const PrintingDialog = ({ - + {extraButtons} - } size="large" onClick={saveAsImage}> - {t("printing.generic.saveAsImage")} - } size="large" onClick={() => reactToPrintFn()}> {t("printing.generic.print")} diff --git a/client/src/pages/printing/qrCodeExportDialog.tsx b/client/src/pages/printing/qrCodeExportDialog.tsx new file mode 100644 index 000000000..71277cd17 --- /dev/null +++ b/client/src/pages/printing/qrCodeExportDialog.tsx @@ -0,0 +1,208 @@ +import { useTranslate } from "@refinedev/core"; +import { Col, Form, InputNumber, QRCode, Radio, RadioChangeEvent, Row, Slider, Switch, Typography } from "antd"; +import { ReactElement } from "react"; +import { getBasePath } from "../../utils/url"; +import { QRCodePrintSettings } from "./printing"; +import ExportDialog from "./exportDialog"; + +const { Text } = Typography; + +interface QRCodeData { + value: string; + label?: ReactElement; + errorLevel?: "L" | "M" | "Q" | "H"; + amlName?: string; +} + +interface QRCodeExportDialogProps { + items: QRCodeData[]; + printSettings: QRCodePrintSettings; + setPrintSettings: (setPrintSettings: QRCodePrintSettings) => void; + extraSettings?: ReactElement; + extraSettingsStart?: ReactElement; + extraButtons?: ReactElement; + baseUrlRoot: string; + useHTTPUrl: boolean; + setUseHTTPUrl: (value: boolean) => void; + previewValues?: { default: string; url: string }; + zipFileTypeName: string; +} + +const QRCodeExportDialog = ({ + items, + printSettings, + setPrintSettings, + extraSettings, + extraSettingsStart, + extraButtons, + baseUrlRoot, + useHTTPUrl, + setUseHTTPUrl, + previewValues, + zipFileTypeName, +}: QRCodeExportDialogProps) => { + const t = useTranslate(); + + const showContent = printSettings?.showContent === undefined ? true : printSettings?.showContent; + const showQRCodeMode = printSettings?.showQRCodeMode || "withIcon"; + const textSize = printSettings?.textSize || 3; + const preview = + previewValues ?? ({ default: `WEB+SPOOLMAN:S-{id}`, url: `${baseUrlRoot}/spool/show/{id}` } as const); + + const elements = items.map((item, idx) => { + return ( + + {showQRCodeMode !== "no" && ( + + + + )} + {showContent && ( + + {item.label ?? item.value} + + )} + + ); + }); + + return ( + { + printSettings.printSettings = newSettings; + setPrintSettings(printSettings); + }} + extraButtons={extraButtons} + zipFileTypeName={zipFileTypeName} + extraSettingsStart={extraSettingsStart} + extraSettings={ + <> + + { + printSettings.showQRCodeMode = e.target.value; + setPrintSettings(printSettings); + }} + value={showQRCodeMode} + optionType="button" + buttonStyle="solid" + /> + + {showQRCodeMode !== "no" && ( + <> + + setUseHTTPUrl(e.target.value)} value={useHTTPUrl}> + {t("printing.qrcode.useHTTPUrl.options.default")} + {t("printing.qrcode.useHTTPUrl.options.url")} + + + + {useHTTPUrl ? preview.url : preview.default} + + > + )} + + { + printSettings.showContent = checked; + setPrintSettings(printSettings); + }} + /> + + + + + `${value} mm` }} + min={2} + max={7} + value={textSize} + step={0.1} + onChange={(value) => { + printSettings.textSize = value; + setPrintSettings(printSettings); + }} + /> + + + { + printSettings.textSize = value ?? 5; + setPrintSettings(printSettings); + }} + /> + + + + + {extraSettings} + > + } + style={` + .print-page .print-qrcode-item { + display: flex; + width: 100%; + height: 100%; + justify-content: center; + } + + .print-page .print-qrcode-container { + max-width: ${showContent ? "50%" : "100%"}; + display: flex; + } + + .print-page .print-qrcode { + width: auto !important; + height: auto !important; + padding: 2mm; + } + + .print-page .print-qrcode-title { + flex: 1 1 auto; + font-size: ${textSize}mm; + color: #000; + overflow: hidden; + } + + .print-page canvas, .print-page svg { + object-fit: contain; + height: 100% !important; + width: 100% !important; + max-height: 100%; + max-width: 100%; + } + `} + /> + ); +}; + +export default QRCodeExportDialog; diff --git a/client/src/pages/printing/qrCodePrintingDialog.tsx b/client/src/pages/printing/qrCodePrintingDialog.tsx index 46967c08c..c25eb0d8e 100644 --- a/client/src/pages/printing/qrCodePrintingDialog.tsx +++ b/client/src/pages/printing/qrCodePrintingDialog.tsx @@ -11,6 +11,7 @@ interface QRCodeData { value: string; label?: ReactElement; errorLevel?: "L" | "M" | "Q" | "H"; + amlName?: string; } interface QRCodePrintingDialogProps { @@ -23,6 +24,7 @@ interface QRCodePrintingDialogProps { baseUrlRoot: string; useHTTPUrl: boolean; setUseHTTPUrl: (value: boolean) => void; + previewValues?: { default: string; url: string }; } const QRCodePrintingDialog = ({ @@ -35,16 +37,19 @@ const QRCodePrintingDialog = ({ baseUrlRoot, useHTTPUrl, setUseHTTPUrl, + previewValues, }: QRCodePrintingDialogProps) => { const t = useTranslate(); const showContent = printSettings?.showContent === undefined ? true : printSettings?.showContent; const showQRCodeMode = printSettings?.showQRCodeMode || "withIcon"; const textSize = printSettings?.textSize || 3; + const preview = + previewValues ?? ({ default: `WEB+SPOOLMAN:S-{id}`, url: `${baseUrlRoot}/spool/show/{id}` } as const); const elements = items.map((item, idx) => { return ( - + {showQRCodeMode !== "no" && ( - {useHTTPUrl ? `${baseUrlRoot}/spool/show/{id}` : `WEB+SPOOLMAN:S-{id}`} + {useHTTPUrl ? preview.url : preview.default} > )} diff --git a/client/src/pages/printing/spoolQrCodeExportDialog.tsx b/client/src/pages/printing/spoolQrCodeExportDialog.tsx new file mode 100644 index 000000000..905c6284a --- /dev/null +++ b/client/src/pages/printing/spoolQrCodeExportDialog.tsx @@ -0,0 +1,378 @@ +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 { useGetSpoolsByIds } from "../spools/functions"; +import { ISpool } from "../spools/model"; +import { + SpoolQRCodePrintSettings, + renderLabelContents, + renderTemplateText, + useGetPrintSettings as useGetPrintPresets, + useSetPrintSettings as useSetPrintPresets, +} from "./printing"; +import QRCodeExportDialog from "./qrCodeExportDialog"; + +const { Text } = Typography; + +interface SpoolQRCodeExportDialog { + spoolIds: number[]; +} + +const SpoolQRCodeExportDialog = ({ spoolIds }: SpoolQRCodeExportDialog) => { + 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", false); + + const itemQueries = useGetSpoolsByIds(spoolIds); + const items = itemQueries + .map((itemQuery) => { + return itemQuery.data ?? null; + }) + .filter((item) => item !== null) as ISpool[]; + + const [selectedPresetState, setSelectedPresetState] = useSavedState( + "selectedImagePresetSpool", + undefined, + ); + + const [localPresets, setLocalPresets] = useState(); + const remotePresets = useGetPrintPresets("image_presets"); + const setRemotePresets = useSetPrintPresets("image_presets"); + + 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 ?? + `**{filament.vendor.name} - {filament.name} +#{id} - {filament.material}** +Spool Weight: {filament.spool_weight} g +{ET: {filament.settings_extruder_temp} °C} +{BT: {filament.settings_bed_temp} °C} +{Lot Nr: {lot_nr}} +{{comment}} +{filament.comment} +{filament.vendor.comment}`; + const filenameTemplate = + curPreset.filenameTemplate ?? `{filament.vendor.name}-{filament.material}-{filament.name}-{id}`; + + const spoolTags = [ + { tag: "id" }, + { tag: "registered" }, + { tag: "first_used" }, + { tag: "last_used" }, + { tag: "price" }, + { tag: "initial_weight" }, + { tag: "spool_weight" }, + { tag: "remaining_weight" }, + { tag: "used_weight" }, + { tag: "remaining_length" }, + { tag: "used_length" }, + { tag: "location" }, + { tag: "lot_nr" }, + { tag: "comment" }, + { tag: "archived" }, + ]; + const spoolFields = useGetFields(EntityType.spool); + if (spoolFields.data !== undefined) { + spoolFields.data.forEach((field) => { + spoolTags.push({ tag: `extra.${field.key}` }); + }); + } + const filamentTags = [ + { tag: "filament.id" }, + { tag: "filament.registered" }, + { tag: "filament.name" }, + { tag: "filament.material" }, + { tag: "filament.price" }, + { tag: "filament.density" }, + { tag: "filament.diameter" }, + { tag: "filament.weight" }, + { tag: "filament.spool_weight" }, + { tag: "filament.article_number" }, + { tag: "filament.comment" }, + { tag: "filament.settings_extruder_temp" }, + { tag: "filament.settings_bed_temp" }, + { tag: "filament.color_hex" }, + { tag: "filament.multi_color_hexes" }, + { tag: "filament.multi_color_direction" }, + { tag: "filament.external_id" }, + ]; + const filamentFields = useGetFields(EntityType.filament); + if (filamentFields.data !== undefined) { + filamentFields.data.forEach((field) => { + filamentTags.push({ tag: `filament.extra.${field.key}` }); + }); + } + const vendorTags = [ + { tag: "filament.vendor.id" }, + { tag: "filament.vendor.registered" }, + { tag: "filament.vendor.name" }, + { tag: "filament.vendor.comment" }, + { tag: "filament.vendor.empty_spool_weight" }, + { tag: "filament.vendor.external_id" }, + ]; + const vendorFields = useGetFields(EntityType.vendor); + if (vendorFields.data !== undefined) { + vendorFields.data.forEach((field) => { + vendorTags.push({ tag: `filament.vendor.extra.${field.key}` }); + }); + } + + const templateTags = [...spoolTags, ...filamentTags, ...vendorTags]; + + return ( + <> + {contextHolder} + { + curPreset.labelSettings = newSettings; + updateCurrentPreset(curPreset); + }} + baseUrlRoot={baseUrlRoot} + useHTTPUrl={useHTTPUrl} + setUseHTTPUrl={setUseHTTPUrl} + previewValues={{ + default: "WEB+SPOOLMAN:S-{id}", + url: `${baseUrlRoot}/spool/show/{id}`, + }} + zipFileTypeName="spool" + extraSettingsStart={ + <> + + + { + setSelectedPresetState(value); + }} + options={ + localOrRemotePresets && + localOrRemotePresets.map((settings) => ({ + label: settings.labelSettings.printSettings?.name || t("printing.generic.defaultSettings"), + value: settings.labelSettings.printSettings.id, + })) + } + > + } + title={t("printing.generic.addSettings")} + onClick={addNewPreset} + /> + } + title={t("printing.generic.duplicateSettings")} + onClick={duplicateCurrentPreset} + /> + {localOrRemotePresets && localOrRemotePresets.length > 1 && ( + + } + title={t("printing.generic.deleteSettings")} + /> + + )} + + + + { + curPreset.labelSettings.printSettings.name = e.target.value; + updateCurrentPreset(curPreset); + }} + /> + + > + } + items={items.map((spool) => ({ + value: useHTTPUrl ? `${baseUrlRoot}/spool/show/${spool.id}` : `WEB+SPOOLMAN:S-${spool.id}`, + amlName: renderTemplateText(filenameTemplate, spool), + label: ( + + {renderLabelContents(template, spool)} + + ), + errorLevel: "H", + }))} + extraSettings={ + <> + + { + curPreset.template = newValue.target.value; + updateCurrentPreset(curPreset); + }} + /> + + setTemplateHelpOpen(false)}> + + + + {t("printing.qrcode.templateHelp")}{" "} + setTemplateHelpOpen(true)}> + {t("actions.show")} + + + + { + curPreset.filenameTemplate = newValue.target.value; + updateCurrentPreset(curPreset); + }} + /> + + > + } + extraButtons={ + <> + } + onClick={() => { + savePresetsRemote(); + messageApi.success(t("notifications.saveSuccessful")); + }} + > + {t("printing.generic.saveSetting")} + + > + } + /> + > + ); +}; + +export default SpoolQRCodeExportDialog; diff --git a/client/src/pages/printing/spoolQrCodePrintingDialog.tsx b/client/src/pages/printing/spoolQrCodePrintingDialog.tsx index 865c597cf..6d16e8cd5 100644 --- a/client/src/pages/printing/spoolQrCodePrintingDialog.tsx +++ b/client/src/pages/printing/spoolQrCodePrintingDialog.tsx @@ -241,9 +241,13 @@ Spool Weight: {filament.spool_weight} g baseUrlRoot={baseUrlRoot} useHTTPUrl={useHTTPUrl} setUseHTTPUrl={setUseHTTPUrl} + previewValues={{ + default: "WEB+SPOOLMAN:S-{id}", + url: `${baseUrlRoot}/spool/show/{id}`, + }} extraSettingsStart={ <> - + ({ value: useHTTPUrl ? `${baseUrlRoot}/spool/show/${spool.id}` : `WEB+SPOOLMAN:S-${spool.id}`, + amlName: `spool-${spool.id}`, label: ( void; + initialSelectedIds?: number[]; + onExport?: (selectedIds: number[]) => void; + onPrint?: (selectedIds: number[]) => void; } interface ISpoolCollapsed extends ISpool { @@ -36,13 +43,16 @@ function collapseSpool(element: ISpool): ISpoolCollapsed { }; } -const SpoolSelectModal = ({ description, onContinue }: Props) => { - const [selectedItems, setSelectedItems] = useState([]); +const SpoolSelectModal = ({ description, initialSelectedIds, onExport, onPrint }: Props) => { + const [selectedItems, setSelectedItems] = useState(initialSelectedIds ?? []); const [showArchived, setShowArchived] = useState(false); const [messageApi, contextHolder] = message.useMessage(); const navigate = useNavigate(); + const [searchValue, setSearchValue] = useState(""); + const [selectedArchivedMap, setSelectedArchivedMap] = useState>({}); - const { tableProps, sorters, filters, currentPage, pageSize } = useTable({ + const { tableProps, sorters, filters, setFilters, currentPage, pageSize, setCurrentPage, setPageSize } = + useTable({ resource: "spool", meta: { queryParams: { @@ -51,9 +61,9 @@ const SpoolSelectModal = ({ description, onContinue }: Props) => { }, syncWithLocation: false, pagination: { - mode: "off", + mode: "server", currentPage: 1, - pageSize: 10, + pageSize: 50, }, sorters: { mode: "server", @@ -83,12 +93,64 @@ const SpoolSelectModal = ({ description, onContinue }: Props) => { () => (tableProps.dataSource || []).map((record) => ({ ...record })), [tableProps.dataSource], ); + const selectedSet = useMemo(() => new Set(selectedItems), [selectedItems]); + + useEffect(() => { + if (dataSource.length === 0) { + return; + } + setSelectedArchivedMap((prev) => { + const next = { ...prev }; + dataSource.forEach((spool) => { + next[spool.id] = spool.archived === true; + }); + return next; + }); + }, [dataSource]); + + const paginationTotal = tableProps.pagination ? tableProps.pagination.total ?? 0 : 0; + const handlePageChange = (page: number, nextPageSize?: number) => { + if (typeof nextPageSize === "number" && nextPageSize !== pageSize) { + setPageSize(nextPageSize); + } + setCurrentPage(page); + }; + const handlePageSizeChange = (_current: number, size: number) => { + setPageSize(size); + setCurrentPage(1); + }; + + const applySearchFilter = (nextSearch: string) => { + const trimmedSearch = nextSearch.trim(); + const nextFilters: CrudFilter[] = []; + (filters ?? []).forEach((filter) => { + if ("field" in filter && filter.field !== "search") { + nextFilters.push(filter); + } + }); + if (trimmedSearch.length > 0) { + nextFilters.push({ + field: "search", + operator: "contains", + value: [trimmedSearch], + }); + } + setFilters(nextFilters, "replace"); + setCurrentPage(1); + }; // Function to add/remove all filtered items from selected items const selectUnselectFiltered = (select: boolean) => { setSelectedItems((prevSelected) => { - const filtered = dataSource.map((spool) => spool.id).filter((spool) => !prevSelected.includes(spool)); - return select ? [...prevSelected, ...filtered] : filtered; + const nextSelected = new Set(prevSelected); + dataSource.forEach((spool) => { + if (select) { + nextSelected.add(spool.id); + } else { + nextSelected.delete(spool.id); + } + }); + return Array.from(nextSelected); }); }; @@ -100,9 +162,9 @@ const SpoolSelectModal = ({ description, onContinue }: Props) => { }; // State for the select/unselect all checkbox - const isAllFilteredSelected = dataSource.every((spool) => selectedItems.includes(spool.id)); + const isAllFilteredSelected = dataSource.every((spool) => selectedSet.has(spool.id)); const isSomeButNotAllFilteredSelected = - dataSource.some((spool) => selectedItems.includes(spool.id)) && !isAllFilteredSelected; + dataSource.some((spool) => selectedSet.has(spool.id)) && !isAllFilteredSelected; const commonProps = { t, @@ -118,102 +180,202 @@ const SpoolSelectModal = ({ description, onContinue }: Props) => { return ( <> {contextHolder} - - {description && {description}} - ( - handleSelectItem(item.id)} /> - ), - }, - SortedColumn({ - ...commonProps, - id: "id", - i18ncat: "spool", - width: 80, - }), - SpoolIconColumn({ - ...commonProps, - id: "filament.combined_name", - dataId: "filament.combined_name", - i18nkey: "spool.fields.filament_name", - color: (record: ISpoolCollapsed) => record.filament.color_hex, - filterValueQuery: useSpoolmanFilamentFilter(), - }), - FilteredQueryColumn({ - ...commonProps, - id: "filament.material", - i18nkey: "spool.fields.material", - filterValueQuery: useSpoolmanMaterials(), - }), - ])} - /> - - - { - selectUnselectFiltered(e.target.checked); - }} - > - {t("printing.spoolSelect.selectAll")} - - - - - {t("printing.spoolSelect.selectedTotal", { - count: selectedItems.length, - })} - - - - { - setShowArchived(e.target.checked); - if (!e.target.checked) { - // Remove archived spools from selected items - setSelectedItems((prevSelected) => - prevSelected.filter( - (selected) => dataSource.find((spool) => spool.id === selected)?.archived !== true, - ), - ); + + {(description || tableProps.pagination) && ( + + {description && {description}} + {tableProps.pagination && ( + + + + )} + + )} + + + { + const value = event.target.value; + setSearchValue(value); + if (value === "") { + applySearchFilter(""); } }} - > - {t("printing.spoolSelect.showArchived")} - + onSearch={(value) => { + setSearchValue(value); + applySearchFilter(value); + }} + /> - + + + } - iconPosition="end" onClick={() => { - if (selectedItems.length === 0) { - messageApi.open({ - type: "error", - content: t("printing.spoolSelect.noSpoolsSelected"), - }); - return; - } - onContinue(dataSource.filter((spool) => selectedItems.includes(spool.id))); + setSearchValue(""); + setFilters([], "replace"); + setCurrentPage(1); }} > - {t("buttons.continue")} + {t("buttons.clearFilters")} + + + { + selectUnselectFiltered(e.target.checked); + }} + > + {t("printing.spoolSelect.selectAll")} + + { + setShowArchived(e.target.checked); + if (!e.target.checked) { + // Remove archived spools from selected items + setSelectedItems((prevSelected) => + prevSelected.filter((selected) => selectedArchivedMap[selected] !== true), + ); + } + }} + > + {t("printing.spoolSelect.showArchived")} + + + {t("printing.spoolSelect.selectedTotal", { + count: selectedItems.length, + })} + + + {onPrint && ( + { + if (selectedItems.length === 0) { + messageApi.open({ + type: "error", + content: t("printing.spoolSelect.noSpoolsSelected"), + }); + return; + } + onPrint(selectedItems); + }} + > + {t("printing.qrcode.button")} + + )} + {onExport && ( + { + if (selectedItems.length === 0) { + messageApi.open({ + type: "error", + content: t("printing.spoolSelect.noSpoolsSelected"), + }); + return; + } + onExport(selectedItems); + }} + > + {t("printing.qrcode.exportButton")} + + )} + + + - + + ( + handleSelectItem(item.id)} /> + ), + }, + SortedColumn({ + ...commonProps, + id: "id", + i18ncat: "spool", + width: 70, + }), + SpoolIconColumn({ + ...commonProps, + id: "filament.combined_name", + dataId: "filament.combined_name", + i18nkey: "spool.fields.filament_name", + width: 360, + ellipsis: true, + color: (record: ISpoolCollapsed) => + record.filament.multi_color_hexes + ? { + colors: record.filament.multi_color_hexes.split(","), + vertical: record.filament.multi_color_direction === "longitudinal", + } + : record.filament.color_hex, + filterValueQuery: useSpoolmanFilamentFilter(), + }), + FilteredQueryColumn({ + ...commonProps, + id: "filament.material", + i18nkey: "spool.fields.material", + filterValueQuery: useSpoolmanMaterials(), + width: 140, + ellipsis: true, + }), + FilteredQueryColumn({ + ...commonProps, + id: "location", + i18ncat: "spool", + filterValueQuery: useSpoolmanLocations(), + width: 160, + ellipsis: true, + }), + FilteredQueryColumn({ + ...commonProps, + id: "lot_nr", + i18ncat: "spool", + filterValueQuery: useSpoolmanLotNumbers(), + width: 160, + ellipsis: true, + }), + ])} + /> + + > ); }; diff --git a/client/src/pages/printingExport/index.tsx b/client/src/pages/printingExport/index.tsx new file mode 100644 index 000000000..144208eb8 --- /dev/null +++ b/client/src/pages/printingExport/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 SpoolQRCodeExportDialog from "../printing/spoolQrCodeExportDialog"; + +const { useToken } = theme; + +export const PrintingExport = () => { + const { token } = useToken(); + const t = useTranslate(); + const [searchParams] = useSearchParams(); + const navigate = useNavigate(); + + const spoolIds = searchParams.getAll("spools").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 `/spool/labels${query ? `?${query}` : ""}`; + }, [returnUrl]); + + useEffect(() => { + if (spoolIds.length === 0) { + navigate(selectionPath, { replace: true }); + } + }, [navigate, selectionPath, spoolIds.length]); + + return ( + <> + { + const returnUrl = searchParams.get("return"); + if (returnUrl) { + navigate(returnUrl, { relative: "path" }); + } else { + navigate("/spool"); + } + }} + > + + {spoolIds.length > 0 && } + + + > + ); +}; + +export default PrintingExport; diff --git a/client/src/pages/spoolLabels/index.tsx b/client/src/pages/spoolLabels/index.tsx new file mode 100644 index 000000000..18e51cfa1 --- /dev/null +++ b/client/src/pages/spoolLabels/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 SpoolSelectModal from "../printing/spoolSelectModal"; + +const { useToken } = theme; + +export const SpoolLabels = () => { + const { token } = useToken(); + const t = useTranslate(); + const [searchParams] = useSearchParams(); + const navigate = useNavigate(); + + const returnUrl = searchParams.get("return"); + const initialSelectedIds = searchParams.getAll("spools").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 `/spool/labels${query ? `?${query}` : ""}`; + }, [returnUrl]); + + const handleNavigate = (mode: "print" | "export", ids: number[]) => { + const params = new URLSearchParams(); + ids.forEach((id) => params.append("spools", id.toString())); + params.set("return", selectionPath); + navigate(`/spool/${mode}?${params.toString()}`); + }; + + return ( + <> + { + if (returnUrl) { + navigate(returnUrl, { relative: "path" }); + } else { + navigate("/spool"); + } + }} + > + + handleNavigate("print", ids)} + onExport={(ids) => handleNavigate("export", ids)} + /> + + + > + ); +}; + +export default SpoolLabels; diff --git a/client/src/pages/spools/list.tsx b/client/src/pages/spools/list.tsx index 9ee11a3ed..23260145e 100644 --- a/client/src/pages/spools/list.tsx +++ b/client/src/pages/spools/list.tsx @@ -264,10 +264,10 @@ export const SpoolList = () => { type="primary" icon={} onClick={() => { - navigate("print"); + navigate("labels"); }} > - {t("printing.qrcode.button")} + {t("printing.qrcode.selectButton")} { icon={} href={ getBasePath() + - "/spool/print?spools=" + + "/spool/labels?spools=" + record?.id + "&return=" + - encodeURIComponent(window.location.pathname) + encodeURIComponent(stripBasePath(window.location.pathname)) } > - {t("printing.qrcode.button")} + {t("printing.qrcode.selectButton")} {record?.archived ? ( } onClick={() => archiveSpool(record, false)}> diff --git a/client/src/utils/url.ts b/client/src/utils/url.ts index ba638dd0a..e2b7c9682 100644 --- a/client/src/utils/url.ts +++ b/client/src/utils/url.ts @@ -33,3 +33,19 @@ export function getAPIURL(): string { } return getBasePath() + import.meta.env.VITE_APIURL; } + +/** + * Removes the configured base path from a URL pathname. + * Ensures the returned pathname starts with "/" and doesn't double-apply the base path. + */ +export function stripBasePath(pathname: string): string { + const basePath = getBasePath(); + if (!basePath) { + return pathname; + } + if (pathname.startsWith(basePath)) { + const stripped = pathname.slice(basePath.length); + return stripped.length > 0 ? stripped : "/"; + } + return pathname; +} diff --git a/migrations/versions/2026_02_11_1700-b76f1b4c3f5a_filament_search_indexes.py b/migrations/versions/2026_02_11_1700-b76f1b4c3f5a_filament_search_indexes.py new file mode 100644 index 000000000..0acb13c85 --- /dev/null +++ b/migrations/versions/2026_02_11_1700-b76f1b4c3f5a_filament_search_indexes.py @@ -0,0 +1,35 @@ +"""filament_search_indexes. + +Revision ID: b76f1b4c3f5a +Revises: 415a8f855e14 +Create Date: 2026-02-11 17:00:00.000000 +""" + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "b76f1b4c3f5a" +down_revision = "415a8f855e14" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + """Perform the upgrade.""" + op.create_index("ix_vendor_name", "vendor", ["name"], unique=False) + op.create_index("ix_filament_name", "filament", ["name"], unique=False) + op.create_index("ix_filament_material", "filament", ["material"], unique=False) + op.create_index("ix_filament_article_number", "filament", ["article_number"], unique=False) + op.create_index("ix_filament_external_id", "filament", ["external_id"], unique=False) + op.create_index("ix_filament_vendor_id", "filament", ["vendor_id"], unique=False) + + +def downgrade() -> None: + """Perform the downgrade.""" + op.drop_index("ix_filament_vendor_id", table_name="filament") + op.drop_index("ix_filament_external_id", table_name="filament") + op.drop_index("ix_filament_article_number", table_name="filament") + op.drop_index("ix_filament_material", table_name="filament") + op.drop_index("ix_filament_name", table_name="filament") + op.drop_index("ix_vendor_name", table_name="vendor") diff --git a/migrations/versions/2026_02_11_1710-f1a3d9c2c4e1_spool_search_indexes.py b/migrations/versions/2026_02_11_1710-f1a3d9c2c4e1_spool_search_indexes.py new file mode 100644 index 000000000..0fe2d096b --- /dev/null +++ b/migrations/versions/2026_02_11_1710-f1a3d9c2c4e1_spool_search_indexes.py @@ -0,0 +1,27 @@ +"""spool_search_indexes. + +Revision ID: f1a3d9c2c4e1 +Revises: b76f1b4c3f5a +Create Date: 2026-02-11 17:10:00.000000 +""" + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "f1a3d9c2c4e1" +down_revision = "b76f1b4c3f5a" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + """Perform the upgrade.""" + op.create_index("ix_spool_location", "spool", ["location"], unique=False) + op.create_index("ix_spool_lot_nr", "spool", ["lot_nr"], unique=False) + + +def downgrade() -> None: + """Perform the downgrade.""" + op.drop_index("ix_spool_lot_nr", table_name="spool") + op.drop_index("ix_spool_location", table_name="spool") diff --git a/spoolman/api/v1/filament.py b/spoolman/api/v1/filament.py index 3e3f859af..1ef682cc4 100644 --- a/spoolman/api/v1/filament.py +++ b/spoolman/api/v1/filament.py @@ -241,6 +241,16 @@ async def find( examples=["1", "1,2"], ), ] = None, + search: Annotated[ + str | None, + Query( + title="Search", + description=( + "General search across vendor name, filament name, material, article number, and external ID. " + "Separate multiple terms with a comma. Surround a term with quotes for an exact match." + ), + ), + ] = None, name: Annotated[ str | None, Query( @@ -347,6 +357,7 @@ async def find( ids=filter_by_ids, vendor_name=vendor_name if vendor_name is not None else vendor_name_old, vendor_id=vendor_ids, + search=search, name=name, material=material, article_number=article_number, diff --git a/spoolman/api/v1/other.py b/spoolman/api/v1/other.py index 866aced93..a5a6f3e6c 100644 --- a/spoolman/api/v1/other.py +++ b/spoolman/api/v1/other.py @@ -73,6 +73,32 @@ async def find_article_numbers( return await filament.find_article_numbers(db=db) +@router.get( + "/filament-name", + name="Find filament names", + description="Get a list of all filament names.", + response_model_exclude_none=True, + responses={ + 200: { + "description": "A list of all filament names.", + "content": { + "application/json": { + "example": [ + "PLA Basic Black", + "PETG Orange", + ], + }, + }, + }, + }, +) +async def find_filament_names( + *, + db: Annotated[AsyncSession, Depends(get_db_session)], +) -> list[str]: + return await filament.find_names(db=db) + + @router.get( "/lot-number", name="Find lot numbers", diff --git a/spoolman/api/v1/spool.py b/spoolman/api/v1/spool.py index 8f667e3da..7244eef25 100644 --- a/spoolman/api/v1/spool.py +++ b/spoolman/api/v1/spool.py @@ -225,6 +225,16 @@ async def find( pattern=r"^-?\d+(,-?\d+)*$", ), ] = None, + search: Annotated[ + str | None, + Query( + title="Search", + description=( + "General search across vendor name, filament name, material, location, and lot number. " + "Separate multiple terms with a comma. Surround a term with quotes for an exact match." + ), + ), + ] = None, location: Annotated[ str | None, Query( @@ -292,6 +302,7 @@ async def find( filament_material=filament_material if filament_material is not None else filament_material_old, vendor_name=filament_vendor_name if filament_vendor_name is not None else vendor_name_old, vendor_id=filament_vendor_ids, + search=search, location=location, lot_nr=lot_nr, allow_archived=allow_archived, diff --git a/spoolman/database/filament.py b/spoolman/database/filament.py index e2d742758..95410410a 100644 --- a/spoolman/database/filament.py +++ b/spoolman/database/filament.py @@ -16,6 +16,7 @@ SortOrder, add_where_clause_int_in, add_where_clause_int_opt, + add_where_clause_search, add_where_clause_str, add_where_clause_str_opt, parse_nested_field, @@ -98,6 +99,7 @@ async def find( ids: list[int] | None = None, vendor_name: str | None = None, vendor_id: int | Sequence[int] | None = None, + search: str | None = None, name: str | None = None, material: str | None = None, article_number: str | None = None, @@ -122,6 +124,17 @@ async def find( stmt = add_where_clause_int_in(stmt, models.Filament.id, ids) stmt = add_where_clause_int_opt(stmt, models.Filament.vendor_id, vendor_id) stmt = add_where_clause_str(stmt, models.Vendor.name, vendor_name) + stmt = add_where_clause_search( + stmt, + [ + models.Vendor.name, + models.Filament.name, + models.Filament.material, + models.Filament.article_number, + models.Filament.external_id, + ], + search, + ) stmt = add_where_clause_str_opt(stmt, models.Filament.name, name) stmt = add_where_clause_str_opt(stmt, models.Filament.material, material) stmt = add_where_clause_str_opt(stmt, models.Filament.article_number, article_number) @@ -211,6 +224,16 @@ async def find_materials( return [row[0] for row in rows.all() if row[0] is not None] +async def find_names( + *, + db: AsyncSession, +) -> list[str]: + """Find a list of filament names by searching for distinct values in the filament table.""" + stmt = select(models.Filament.name).distinct() + rows = await db.execute(stmt) + return sorted([row[0] for row in rows.all() if row[0] is not None and row[0] != ""]) + + async def find_article_numbers( *, db: AsyncSession, diff --git a/spoolman/database/spool.py b/spoolman/database/spool.py index 5c190ce65..8b4ca6709 100644 --- a/spoolman/database/spool.py +++ b/spoolman/database/spool.py @@ -17,6 +17,7 @@ SortOrder, add_where_clause_int, add_where_clause_int_opt, + add_where_clause_search, add_where_clause_str, add_where_clause_str_opt, parse_nested_field, @@ -119,6 +120,7 @@ async def find( # noqa: C901, PLR0912 filament_material: str | None = None, vendor_name: str | None = None, vendor_id: int | Sequence[int] | None = None, + search: str | None = None, location: str | None = None, lot_nr: str | None = None, allow_archived: bool = False, @@ -143,6 +145,17 @@ async def find( # noqa: C901, PLR0912 stmt = add_where_clause_int(stmt, models.Spool.filament_id, filament_id) stmt = add_where_clause_int_opt(stmt, models.Filament.vendor_id, vendor_id) stmt = add_where_clause_str(stmt, models.Vendor.name, vendor_name) + stmt = add_where_clause_search( + stmt, + [ + models.Vendor.name, + models.Filament.name, + models.Filament.material, + models.Spool.location, + models.Spool.lot_nr, + ], + search, + ) stmt = add_where_clause_str_opt(stmt, models.Filament.name, filament_name) stmt = add_where_clause_str_opt(stmt, models.Filament.material, filament_material) stmt = add_where_clause_str_opt(stmt, models.Spool.location, location) diff --git a/spoolman/database/utils.py b/spoolman/database/utils.py index 2d8776c00..c19f50aac 100644 --- a/spoolman/database/utils.py +++ b/spoolman/database/utils.py @@ -85,6 +85,32 @@ def add_where_clause_str( return stmt +def add_where_clause_search( + stmt: Select, + fields: Sequence[attributes.InstrumentedAttribute[str | None]], + value: str | None, +) -> Select: + """Add a where clause for a general search across multiple string fields.""" + if value is not None: + conditions = [] + for value_part in value.split(","): + value_part = value_part.strip() + if len(value_part) == 0: + continue + # Do exact match if value_part is surrounded by quotes + if value_part[0] == '"' and value_part[-1] == '"': + term = value_part[1:-1] + conditions.append(sqlalchemy.or_(*[field == term for field in fields])) + # Do prefix match for better index usage + else: + pattern = f"{value_part}%" + conditions.append(sqlalchemy.or_(*[field.ilike(pattern) for field in fields])) + + if conditions: + stmt = stmt.where(sqlalchemy.or_(*conditions)) + return stmt + + def add_where_clause_int( stmt: Select, field: attributes.InstrumentedAttribute[int], diff --git a/spoolman/env.py b/spoolman/env.py index f73dcc976..e74108f86 100644 --- a/spoolman/env.py +++ b/spoolman/env.py @@ -210,6 +210,8 @@ def is_debug_mode() -> bool: raise ValueError(f"Failed to parse SPOOLMAN_DEBUG_MODE variable: Unknown debug mode '{debug_mode}'.") + + def is_cors_defined() -> bool: """Get whether CORS is enabled from environment variables. diff --git a/spoolman/import_externaldb.py b/spoolman/import_externaldb.py new file mode 100644 index 000000000..77eab6106 --- /dev/null +++ b/spoolman/import_externaldb.py @@ -0,0 +1,113 @@ +"""Import filaments from the external database into the local DB.""" + +from __future__ import annotations + +import logging +from datetime import datetime + +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from spoolman import externaldb +from spoolman.api.v1.models import MultiColorDirection +from spoolman.database import models + +logger = logging.getLogger(__name__) + + +def _normalize_hex(value: str | None) -> str | None: + if not value: + return None + return value[1:] if value.startswith("#") else value + + +async def import_external_filaments( + db: AsyncSession, + *, + only_if_empty: bool = True, +) -> int: + """Import external filaments into the local database. + + Returns the number of imported filaments. + """ + if only_if_empty: + existing_count = await db.scalar(select(func.count(models.Filament.id))) + if existing_count and existing_count > 0: + logger.info("Skipping external DB import because filaments already exist (%d).", existing_count) + return 0 + + logger.info("Fetching external filaments for import.") + filaments = await externaldb.fetch_external_filaments() + + existing_vendor_rows = await db.execute(select(models.Vendor)) + vendors_by_external_id = { + vendor.external_id: vendor for vendor in existing_vendor_rows.scalars().all() if vendor.external_id + } + + existing_filament_rows = await db.execute( + select(models.Filament.external_id).where(models.Filament.external_id.is_not(None)), + ) + existing_filament_ids = {row[0] for row in existing_filament_rows.all() if row[0]} + + now = datetime.utcnow().replace(microsecond=0) + imported = 0 + + for filament in filaments: + if filament.id in existing_filament_ids: + continue + + manufacturer = filament.manufacturer.strip() + vendor_item = vendors_by_external_id.get(manufacturer) + if vendor_item is None: + vendor_item = models.Vendor( + name=manufacturer, + registered=now, + comment=None, + empty_spool_weight=None, + external_id=manufacturer, + extra=[], + ) + db.add(vendor_item) + await db.flush() + vendors_by_external_id[manufacturer] = vendor_item + + color_hex = _normalize_hex(filament.color_hex) + multi_color_hexes = None + if filament.color_hexes: + normalized = [_normalize_hex(value) for value in filament.color_hexes] + multi_color_hexes = ",".join([value for value in normalized if value]) + + multi_color_direction = None + if filament.multi_color_direction is not None: + multi_color_direction = MultiColorDirection(filament.multi_color_direction.value) + + spool_weight = filament.spool_weight + if spool_weight is None and vendor_item.empty_spool_weight is not None: + spool_weight = vendor_item.empty_spool_weight + + db_item = models.Filament( + registered=now, + name=filament.name, + vendor=vendor_item, + material=filament.material, + price=None, + density=filament.density, + diameter=filament.diameter, + weight=filament.weight, + spool_weight=spool_weight, + article_number=None, + comment=None, + settings_extruder_temp=filament.extruder_temp, + settings_bed_temp=filament.bed_temp, + color_hex=color_hex if filament.color_hex else None, + multi_color_hexes=None if filament.color_hex else multi_color_hexes, + multi_color_direction=multi_color_direction.value if multi_color_direction else None, + external_id=filament.id, + extra=[], + ) + db.add(db_item) + imported += 1 + + await db.commit() + logger.info("Imported %d external filaments.", imported) + return imported diff --git a/spoolman/settings.py b/spoolman/settings.py index 0c6ce3193..63deafa6f 100644 --- a/spoolman/settings.py +++ b/spoolman/settings.py @@ -64,6 +64,9 @@ def parse_setting(key: str) -> SettingDefinition: register_setting("currency", SettingType.STRING, json.dumps("EUR")) register_setting("round_prices", SettingType.BOOLEAN, json.dumps(obj=False)) register_setting("print_presets", SettingType.ARRAY, json.dumps([])) +register_setting("print_presets_filament", SettingType.ARRAY, json.dumps([])) +register_setting("image_presets", SettingType.ARRAY, json.dumps([])) +register_setting("image_presets_filament", SettingType.ARRAY, json.dumps([])) register_setting("extra_fields_vendor", SettingType.ARRAY, json.dumps([])) register_setting("extra_fields_filament", SettingType.ARRAY, json.dumps([]))
{t("printing.generic.helpMargin")}
{t("printing.generic.helpPrinterMargin")}
+ {renderLabelContents(template, filament)} +
+ {renderLabelContents(template, spool)} +
void; + initialSelectedIds?: number[]; + onExport?: (selectedIds: number[]) => void; + onPrint?: (selectedIds: number[]) => void; } interface ISpoolCollapsed extends ISpool { @@ -36,13 +43,16 @@ function collapseSpool(element: ISpool): ISpoolCollapsed { }; } -const SpoolSelectModal = ({ description, onContinue }: Props) => { - const [selectedItems, setSelectedItems] = useState([]); +const SpoolSelectModal = ({ description, initialSelectedIds, onExport, onPrint }: Props) => { + const [selectedItems, setSelectedItems] = useState(initialSelectedIds ?? []); const [showArchived, setShowArchived] = useState(false); const [messageApi, contextHolder] = message.useMessage(); const navigate = useNavigate(); + const [searchValue, setSearchValue] = useState(""); + const [selectedArchivedMap, setSelectedArchivedMap] = useState>({}); - const { tableProps, sorters, filters, currentPage, pageSize } = useTable({ + const { tableProps, sorters, filters, setFilters, currentPage, pageSize, setCurrentPage, setPageSize } = + useTable({ resource: "spool", meta: { queryParams: { @@ -51,9 +61,9 @@ const SpoolSelectModal = ({ description, onContinue }: Props) => { }, syncWithLocation: false, pagination: { - mode: "off", + mode: "server", currentPage: 1, - pageSize: 10, + pageSize: 50, }, sorters: { mode: "server", @@ -83,12 +93,64 @@ const SpoolSelectModal = ({ description, onContinue }: Props) => { () => (tableProps.dataSource || []).map((record) => ({ ...record })), [tableProps.dataSource], ); + const selectedSet = useMemo(() => new Set(selectedItems), [selectedItems]); + + useEffect(() => { + if (dataSource.length === 0) { + return; + } + setSelectedArchivedMap((prev) => { + const next = { ...prev }; + dataSource.forEach((spool) => { + next[spool.id] = spool.archived === true; + }); + return next; + }); + }, [dataSource]); + + const paginationTotal = tableProps.pagination ? tableProps.pagination.total ?? 0 : 0; + const handlePageChange = (page: number, nextPageSize?: number) => { + if (typeof nextPageSize === "number" && nextPageSize !== pageSize) { + setPageSize(nextPageSize); + } + setCurrentPage(page); + }; + const handlePageSizeChange = (_current: number, size: number) => { + setPageSize(size); + setCurrentPage(1); + }; + + const applySearchFilter = (nextSearch: string) => { + const trimmedSearch = nextSearch.trim(); + const nextFilters: CrudFilter[] = []; + (filters ?? []).forEach((filter) => { + if ("field" in filter && filter.field !== "search") { + nextFilters.push(filter); + } + }); + if (trimmedSearch.length > 0) { + nextFilters.push({ + field: "search", + operator: "contains", + value: [trimmedSearch], + }); + } + setFilters(nextFilters, "replace"); + setCurrentPage(1); + }; // Function to add/remove all filtered items from selected items const selectUnselectFiltered = (select: boolean) => { setSelectedItems((prevSelected) => { - const filtered = dataSource.map((spool) => spool.id).filter((spool) => !prevSelected.includes(spool)); - return select ? [...prevSelected, ...filtered] : filtered; + const nextSelected = new Set(prevSelected); + dataSource.forEach((spool) => { + if (select) { + nextSelected.add(spool.id); + } else { + nextSelected.delete(spool.id); + } + }); + return Array.from(nextSelected); }); }; @@ -100,9 +162,9 @@ const SpoolSelectModal = ({ description, onContinue }: Props) => { }; // State for the select/unselect all checkbox - const isAllFilteredSelected = dataSource.every((spool) => selectedItems.includes(spool.id)); + const isAllFilteredSelected = dataSource.every((spool) => selectedSet.has(spool.id)); const isSomeButNotAllFilteredSelected = - dataSource.some((spool) => selectedItems.includes(spool.id)) && !isAllFilteredSelected; + dataSource.some((spool) => selectedSet.has(spool.id)) && !isAllFilteredSelected; const commonProps = { t, @@ -118,102 +180,202 @@ const SpoolSelectModal = ({ description, onContinue }: Props) => { return ( <> {contextHolder} - - {description && {description}} - ( - handleSelectItem(item.id)} /> - ), - }, - SortedColumn({ - ...commonProps, - id: "id", - i18ncat: "spool", - width: 80, - }), - SpoolIconColumn({ - ...commonProps, - id: "filament.combined_name", - dataId: "filament.combined_name", - i18nkey: "spool.fields.filament_name", - color: (record: ISpoolCollapsed) => record.filament.color_hex, - filterValueQuery: useSpoolmanFilamentFilter(), - }), - FilteredQueryColumn({ - ...commonProps, - id: "filament.material", - i18nkey: "spool.fields.material", - filterValueQuery: useSpoolmanMaterials(), - }), - ])} - /> - - - { - selectUnselectFiltered(e.target.checked); - }} - > - {t("printing.spoolSelect.selectAll")} - - - - - {t("printing.spoolSelect.selectedTotal", { - count: selectedItems.length, - })} - - - - { - setShowArchived(e.target.checked); - if (!e.target.checked) { - // Remove archived spools from selected items - setSelectedItems((prevSelected) => - prevSelected.filter( - (selected) => dataSource.find((spool) => spool.id === selected)?.archived !== true, - ), - ); + + {(description || tableProps.pagination) && ( + + {description && {description}} + {tableProps.pagination && ( + + + + )} + + )} + + + { + const value = event.target.value; + setSearchValue(value); + if (value === "") { + applySearchFilter(""); } }} - > - {t("printing.spoolSelect.showArchived")} - + onSearch={(value) => { + setSearchValue(value); + applySearchFilter(value); + }} + /> - + + + } - iconPosition="end" onClick={() => { - if (selectedItems.length === 0) { - messageApi.open({ - type: "error", - content: t("printing.spoolSelect.noSpoolsSelected"), - }); - return; - } - onContinue(dataSource.filter((spool) => selectedItems.includes(spool.id))); + setSearchValue(""); + setFilters([], "replace"); + setCurrentPage(1); }} > - {t("buttons.continue")} + {t("buttons.clearFilters")} + + + { + selectUnselectFiltered(e.target.checked); + }} + > + {t("printing.spoolSelect.selectAll")} + + { + setShowArchived(e.target.checked); + if (!e.target.checked) { + // Remove archived spools from selected items + setSelectedItems((prevSelected) => + prevSelected.filter((selected) => selectedArchivedMap[selected] !== true), + ); + } + }} + > + {t("printing.spoolSelect.showArchived")} + + + {t("printing.spoolSelect.selectedTotal", { + count: selectedItems.length, + })} + + + {onPrint && ( + { + if (selectedItems.length === 0) { + messageApi.open({ + type: "error", + content: t("printing.spoolSelect.noSpoolsSelected"), + }); + return; + } + onPrint(selectedItems); + }} + > + {t("printing.qrcode.button")} + + )} + {onExport && ( + { + if (selectedItems.length === 0) { + messageApi.open({ + type: "error", + content: t("printing.spoolSelect.noSpoolsSelected"), + }); + return; + } + onExport(selectedItems); + }} + > + {t("printing.qrcode.exportButton")} + + )} + + + - + + ( + handleSelectItem(item.id)} /> + ), + }, + SortedColumn({ + ...commonProps, + id: "id", + i18ncat: "spool", + width: 70, + }), + SpoolIconColumn({ + ...commonProps, + id: "filament.combined_name", + dataId: "filament.combined_name", + i18nkey: "spool.fields.filament_name", + width: 360, + ellipsis: true, + color: (record: ISpoolCollapsed) => + record.filament.multi_color_hexes + ? { + colors: record.filament.multi_color_hexes.split(","), + vertical: record.filament.multi_color_direction === "longitudinal", + } + : record.filament.color_hex, + filterValueQuery: useSpoolmanFilamentFilter(), + }), + FilteredQueryColumn({ + ...commonProps, + id: "filament.material", + i18nkey: "spool.fields.material", + filterValueQuery: useSpoolmanMaterials(), + width: 140, + ellipsis: true, + }), + FilteredQueryColumn({ + ...commonProps, + id: "location", + i18ncat: "spool", + filterValueQuery: useSpoolmanLocations(), + width: 160, + ellipsis: true, + }), + FilteredQueryColumn({ + ...commonProps, + id: "lot_nr", + i18ncat: "spool", + filterValueQuery: useSpoolmanLotNumbers(), + width: 160, + ellipsis: true, + }), + ])} + /> + + > ); }; diff --git a/client/src/pages/printingExport/index.tsx b/client/src/pages/printingExport/index.tsx new file mode 100644 index 000000000..144208eb8 --- /dev/null +++ b/client/src/pages/printingExport/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 SpoolQRCodeExportDialog from "../printing/spoolQrCodeExportDialog"; + +const { useToken } = theme; + +export const PrintingExport = () => { + const { token } = useToken(); + const t = useTranslate(); + const [searchParams] = useSearchParams(); + const navigate = useNavigate(); + + const spoolIds = searchParams.getAll("spools").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 `/spool/labels${query ? `?${query}` : ""}`; + }, [returnUrl]); + + useEffect(() => { + if (spoolIds.length === 0) { + navigate(selectionPath, { replace: true }); + } + }, [navigate, selectionPath, spoolIds.length]); + + return ( + <> + { + const returnUrl = searchParams.get("return"); + if (returnUrl) { + navigate(returnUrl, { relative: "path" }); + } else { + navigate("/spool"); + } + }} + > + + {spoolIds.length > 0 && } + + + > + ); +}; + +export default PrintingExport; diff --git a/client/src/pages/spoolLabels/index.tsx b/client/src/pages/spoolLabels/index.tsx new file mode 100644 index 000000000..18e51cfa1 --- /dev/null +++ b/client/src/pages/spoolLabels/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 SpoolSelectModal from "../printing/spoolSelectModal"; + +const { useToken } = theme; + +export const SpoolLabels = () => { + const { token } = useToken(); + const t = useTranslate(); + const [searchParams] = useSearchParams(); + const navigate = useNavigate(); + + const returnUrl = searchParams.get("return"); + const initialSelectedIds = searchParams.getAll("spools").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 `/spool/labels${query ? `?${query}` : ""}`; + }, [returnUrl]); + + const handleNavigate = (mode: "print" | "export", ids: number[]) => { + const params = new URLSearchParams(); + ids.forEach((id) => params.append("spools", id.toString())); + params.set("return", selectionPath); + navigate(`/spool/${mode}?${params.toString()}`); + }; + + return ( + <> + { + if (returnUrl) { + navigate(returnUrl, { relative: "path" }); + } else { + navigate("/spool"); + } + }} + > + + handleNavigate("print", ids)} + onExport={(ids) => handleNavigate("export", ids)} + /> + + + > + ); +}; + +export default SpoolLabels; diff --git a/client/src/pages/spools/list.tsx b/client/src/pages/spools/list.tsx index 9ee11a3ed..23260145e 100644 --- a/client/src/pages/spools/list.tsx +++ b/client/src/pages/spools/list.tsx @@ -264,10 +264,10 @@ export const SpoolList = () => { type="primary" icon={} onClick={() => { - navigate("print"); + navigate("labels"); }} > - {t("printing.qrcode.button")} + {t("printing.qrcode.selectButton")} { icon={} href={ getBasePath() + - "/spool/print?spools=" + + "/spool/labels?spools=" + record?.id + "&return=" + - encodeURIComponent(window.location.pathname) + encodeURIComponent(stripBasePath(window.location.pathname)) } > - {t("printing.qrcode.button")} + {t("printing.qrcode.selectButton")} {record?.archived ? ( } onClick={() => archiveSpool(record, false)}> diff --git a/client/src/utils/url.ts b/client/src/utils/url.ts index ba638dd0a..e2b7c9682 100644 --- a/client/src/utils/url.ts +++ b/client/src/utils/url.ts @@ -33,3 +33,19 @@ export function getAPIURL(): string { } return getBasePath() + import.meta.env.VITE_APIURL; } + +/** + * Removes the configured base path from a URL pathname. + * Ensures the returned pathname starts with "/" and doesn't double-apply the base path. + */ +export function stripBasePath(pathname: string): string { + const basePath = getBasePath(); + if (!basePath) { + return pathname; + } + if (pathname.startsWith(basePath)) { + const stripped = pathname.slice(basePath.length); + return stripped.length > 0 ? stripped : "/"; + } + return pathname; +} diff --git a/migrations/versions/2026_02_11_1700-b76f1b4c3f5a_filament_search_indexes.py b/migrations/versions/2026_02_11_1700-b76f1b4c3f5a_filament_search_indexes.py new file mode 100644 index 000000000..0acb13c85 --- /dev/null +++ b/migrations/versions/2026_02_11_1700-b76f1b4c3f5a_filament_search_indexes.py @@ -0,0 +1,35 @@ +"""filament_search_indexes. + +Revision ID: b76f1b4c3f5a +Revises: 415a8f855e14 +Create Date: 2026-02-11 17:00:00.000000 +""" + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "b76f1b4c3f5a" +down_revision = "415a8f855e14" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + """Perform the upgrade.""" + op.create_index("ix_vendor_name", "vendor", ["name"], unique=False) + op.create_index("ix_filament_name", "filament", ["name"], unique=False) + op.create_index("ix_filament_material", "filament", ["material"], unique=False) + op.create_index("ix_filament_article_number", "filament", ["article_number"], unique=False) + op.create_index("ix_filament_external_id", "filament", ["external_id"], unique=False) + op.create_index("ix_filament_vendor_id", "filament", ["vendor_id"], unique=False) + + +def downgrade() -> None: + """Perform the downgrade.""" + op.drop_index("ix_filament_vendor_id", table_name="filament") + op.drop_index("ix_filament_external_id", table_name="filament") + op.drop_index("ix_filament_article_number", table_name="filament") + op.drop_index("ix_filament_material", table_name="filament") + op.drop_index("ix_filament_name", table_name="filament") + op.drop_index("ix_vendor_name", table_name="vendor") diff --git a/migrations/versions/2026_02_11_1710-f1a3d9c2c4e1_spool_search_indexes.py b/migrations/versions/2026_02_11_1710-f1a3d9c2c4e1_spool_search_indexes.py new file mode 100644 index 000000000..0fe2d096b --- /dev/null +++ b/migrations/versions/2026_02_11_1710-f1a3d9c2c4e1_spool_search_indexes.py @@ -0,0 +1,27 @@ +"""spool_search_indexes. + +Revision ID: f1a3d9c2c4e1 +Revises: b76f1b4c3f5a +Create Date: 2026-02-11 17:10:00.000000 +""" + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "f1a3d9c2c4e1" +down_revision = "b76f1b4c3f5a" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + """Perform the upgrade.""" + op.create_index("ix_spool_location", "spool", ["location"], unique=False) + op.create_index("ix_spool_lot_nr", "spool", ["lot_nr"], unique=False) + + +def downgrade() -> None: + """Perform the downgrade.""" + op.drop_index("ix_spool_lot_nr", table_name="spool") + op.drop_index("ix_spool_location", table_name="spool") diff --git a/spoolman/api/v1/filament.py b/spoolman/api/v1/filament.py index 3e3f859af..1ef682cc4 100644 --- a/spoolman/api/v1/filament.py +++ b/spoolman/api/v1/filament.py @@ -241,6 +241,16 @@ async def find( examples=["1", "1,2"], ), ] = None, + search: Annotated[ + str | None, + Query( + title="Search", + description=( + "General search across vendor name, filament name, material, article number, and external ID. " + "Separate multiple terms with a comma. Surround a term with quotes for an exact match." + ), + ), + ] = None, name: Annotated[ str | None, Query( @@ -347,6 +357,7 @@ async def find( ids=filter_by_ids, vendor_name=vendor_name if vendor_name is not None else vendor_name_old, vendor_id=vendor_ids, + search=search, name=name, material=material, article_number=article_number, diff --git a/spoolman/api/v1/other.py b/spoolman/api/v1/other.py index 866aced93..a5a6f3e6c 100644 --- a/spoolman/api/v1/other.py +++ b/spoolman/api/v1/other.py @@ -73,6 +73,32 @@ async def find_article_numbers( return await filament.find_article_numbers(db=db) +@router.get( + "/filament-name", + name="Find filament names", + description="Get a list of all filament names.", + response_model_exclude_none=True, + responses={ + 200: { + "description": "A list of all filament names.", + "content": { + "application/json": { + "example": [ + "PLA Basic Black", + "PETG Orange", + ], + }, + }, + }, + }, +) +async def find_filament_names( + *, + db: Annotated[AsyncSession, Depends(get_db_session)], +) -> list[str]: + return await filament.find_names(db=db) + + @router.get( "/lot-number", name="Find lot numbers", diff --git a/spoolman/api/v1/spool.py b/spoolman/api/v1/spool.py index 8f667e3da..7244eef25 100644 --- a/spoolman/api/v1/spool.py +++ b/spoolman/api/v1/spool.py @@ -225,6 +225,16 @@ async def find( pattern=r"^-?\d+(,-?\d+)*$", ), ] = None, + search: Annotated[ + str | None, + Query( + title="Search", + description=( + "General search across vendor name, filament name, material, location, and lot number. " + "Separate multiple terms with a comma. Surround a term with quotes for an exact match." + ), + ), + ] = None, location: Annotated[ str | None, Query( @@ -292,6 +302,7 @@ async def find( filament_material=filament_material if filament_material is not None else filament_material_old, vendor_name=filament_vendor_name if filament_vendor_name is not None else vendor_name_old, vendor_id=filament_vendor_ids, + search=search, location=location, lot_nr=lot_nr, allow_archived=allow_archived, diff --git a/spoolman/database/filament.py b/spoolman/database/filament.py index e2d742758..95410410a 100644 --- a/spoolman/database/filament.py +++ b/spoolman/database/filament.py @@ -16,6 +16,7 @@ SortOrder, add_where_clause_int_in, add_where_clause_int_opt, + add_where_clause_search, add_where_clause_str, add_where_clause_str_opt, parse_nested_field, @@ -98,6 +99,7 @@ async def find( ids: list[int] | None = None, vendor_name: str | None = None, vendor_id: int | Sequence[int] | None = None, + search: str | None = None, name: str | None = None, material: str | None = None, article_number: str | None = None, @@ -122,6 +124,17 @@ async def find( stmt = add_where_clause_int_in(stmt, models.Filament.id, ids) stmt = add_where_clause_int_opt(stmt, models.Filament.vendor_id, vendor_id) stmt = add_where_clause_str(stmt, models.Vendor.name, vendor_name) + stmt = add_where_clause_search( + stmt, + [ + models.Vendor.name, + models.Filament.name, + models.Filament.material, + models.Filament.article_number, + models.Filament.external_id, + ], + search, + ) stmt = add_where_clause_str_opt(stmt, models.Filament.name, name) stmt = add_where_clause_str_opt(stmt, models.Filament.material, material) stmt = add_where_clause_str_opt(stmt, models.Filament.article_number, article_number) @@ -211,6 +224,16 @@ async def find_materials( return [row[0] for row in rows.all() if row[0] is not None] +async def find_names( + *, + db: AsyncSession, +) -> list[str]: + """Find a list of filament names by searching for distinct values in the filament table.""" + stmt = select(models.Filament.name).distinct() + rows = await db.execute(stmt) + return sorted([row[0] for row in rows.all() if row[0] is not None and row[0] != ""]) + + async def find_article_numbers( *, db: AsyncSession, diff --git a/spoolman/database/spool.py b/spoolman/database/spool.py index 5c190ce65..8b4ca6709 100644 --- a/spoolman/database/spool.py +++ b/spoolman/database/spool.py @@ -17,6 +17,7 @@ SortOrder, add_where_clause_int, add_where_clause_int_opt, + add_where_clause_search, add_where_clause_str, add_where_clause_str_opt, parse_nested_field, @@ -119,6 +120,7 @@ async def find( # noqa: C901, PLR0912 filament_material: str | None = None, vendor_name: str | None = None, vendor_id: int | Sequence[int] | None = None, + search: str | None = None, location: str | None = None, lot_nr: str | None = None, allow_archived: bool = False, @@ -143,6 +145,17 @@ async def find( # noqa: C901, PLR0912 stmt = add_where_clause_int(stmt, models.Spool.filament_id, filament_id) stmt = add_where_clause_int_opt(stmt, models.Filament.vendor_id, vendor_id) stmt = add_where_clause_str(stmt, models.Vendor.name, vendor_name) + stmt = add_where_clause_search( + stmt, + [ + models.Vendor.name, + models.Filament.name, + models.Filament.material, + models.Spool.location, + models.Spool.lot_nr, + ], + search, + ) stmt = add_where_clause_str_opt(stmt, models.Filament.name, filament_name) stmt = add_where_clause_str_opt(stmt, models.Filament.material, filament_material) stmt = add_where_clause_str_opt(stmt, models.Spool.location, location) diff --git a/spoolman/database/utils.py b/spoolman/database/utils.py index 2d8776c00..c19f50aac 100644 --- a/spoolman/database/utils.py +++ b/spoolman/database/utils.py @@ -85,6 +85,32 @@ def add_where_clause_str( return stmt +def add_where_clause_search( + stmt: Select, + fields: Sequence[attributes.InstrumentedAttribute[str | None]], + value: str | None, +) -> Select: + """Add a where clause for a general search across multiple string fields.""" + if value is not None: + conditions = [] + for value_part in value.split(","): + value_part = value_part.strip() + if len(value_part) == 0: + continue + # Do exact match if value_part is surrounded by quotes + if value_part[0] == '"' and value_part[-1] == '"': + term = value_part[1:-1] + conditions.append(sqlalchemy.or_(*[field == term for field in fields])) + # Do prefix match for better index usage + else: + pattern = f"{value_part}%" + conditions.append(sqlalchemy.or_(*[field.ilike(pattern) for field in fields])) + + if conditions: + stmt = stmt.where(sqlalchemy.or_(*conditions)) + return stmt + + def add_where_clause_int( stmt: Select, field: attributes.InstrumentedAttribute[int], diff --git a/spoolman/env.py b/spoolman/env.py index f73dcc976..e74108f86 100644 --- a/spoolman/env.py +++ b/spoolman/env.py @@ -210,6 +210,8 @@ def is_debug_mode() -> bool: raise ValueError(f"Failed to parse SPOOLMAN_DEBUG_MODE variable: Unknown debug mode '{debug_mode}'.") + + def is_cors_defined() -> bool: """Get whether CORS is enabled from environment variables. diff --git a/spoolman/import_externaldb.py b/spoolman/import_externaldb.py new file mode 100644 index 000000000..77eab6106 --- /dev/null +++ b/spoolman/import_externaldb.py @@ -0,0 +1,113 @@ +"""Import filaments from the external database into the local DB.""" + +from __future__ import annotations + +import logging +from datetime import datetime + +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from spoolman import externaldb +from spoolman.api.v1.models import MultiColorDirection +from spoolman.database import models + +logger = logging.getLogger(__name__) + + +def _normalize_hex(value: str | None) -> str | None: + if not value: + return None + return value[1:] if value.startswith("#") else value + + +async def import_external_filaments( + db: AsyncSession, + *, + only_if_empty: bool = True, +) -> int: + """Import external filaments into the local database. + + Returns the number of imported filaments. + """ + if only_if_empty: + existing_count = await db.scalar(select(func.count(models.Filament.id))) + if existing_count and existing_count > 0: + logger.info("Skipping external DB import because filaments already exist (%d).", existing_count) + return 0 + + logger.info("Fetching external filaments for import.") + filaments = await externaldb.fetch_external_filaments() + + existing_vendor_rows = await db.execute(select(models.Vendor)) + vendors_by_external_id = { + vendor.external_id: vendor for vendor in existing_vendor_rows.scalars().all() if vendor.external_id + } + + existing_filament_rows = await db.execute( + select(models.Filament.external_id).where(models.Filament.external_id.is_not(None)), + ) + existing_filament_ids = {row[0] for row in existing_filament_rows.all() if row[0]} + + now = datetime.utcnow().replace(microsecond=0) + imported = 0 + + for filament in filaments: + if filament.id in existing_filament_ids: + continue + + manufacturer = filament.manufacturer.strip() + vendor_item = vendors_by_external_id.get(manufacturer) + if vendor_item is None: + vendor_item = models.Vendor( + name=manufacturer, + registered=now, + comment=None, + empty_spool_weight=None, + external_id=manufacturer, + extra=[], + ) + db.add(vendor_item) + await db.flush() + vendors_by_external_id[manufacturer] = vendor_item + + color_hex = _normalize_hex(filament.color_hex) + multi_color_hexes = None + if filament.color_hexes: + normalized = [_normalize_hex(value) for value in filament.color_hexes] + multi_color_hexes = ",".join([value for value in normalized if value]) + + multi_color_direction = None + if filament.multi_color_direction is not None: + multi_color_direction = MultiColorDirection(filament.multi_color_direction.value) + + spool_weight = filament.spool_weight + if spool_weight is None and vendor_item.empty_spool_weight is not None: + spool_weight = vendor_item.empty_spool_weight + + db_item = models.Filament( + registered=now, + name=filament.name, + vendor=vendor_item, + material=filament.material, + price=None, + density=filament.density, + diameter=filament.diameter, + weight=filament.weight, + spool_weight=spool_weight, + article_number=None, + comment=None, + settings_extruder_temp=filament.extruder_temp, + settings_bed_temp=filament.bed_temp, + color_hex=color_hex if filament.color_hex else None, + multi_color_hexes=None if filament.color_hex else multi_color_hexes, + multi_color_direction=multi_color_direction.value if multi_color_direction else None, + external_id=filament.id, + extra=[], + ) + db.add(db_item) + imported += 1 + + await db.commit() + logger.info("Imported %d external filaments.", imported) + return imported diff --git a/spoolman/settings.py b/spoolman/settings.py index 0c6ce3193..63deafa6f 100644 --- a/spoolman/settings.py +++ b/spoolman/settings.py @@ -64,6 +64,9 @@ def parse_setting(key: str) -> SettingDefinition: register_setting("currency", SettingType.STRING, json.dumps("EUR")) register_setting("round_prices", SettingType.BOOLEAN, json.dumps(obj=False)) register_setting("print_presets", SettingType.ARRAY, json.dumps([])) +register_setting("print_presets_filament", SettingType.ARRAY, json.dumps([])) +register_setting("image_presets", SettingType.ARRAY, json.dumps([])) +register_setting("image_presets_filament", SettingType.ARRAY, json.dumps([])) register_setting("extra_fields_vendor", SettingType.ARRAY, json.dumps([])) register_setting("extra_fields_filament", SettingType.ARRAY, json.dumps([]))