diff --git a/.playwright-cli/page-2026-03-14T13-03-08-306Z.yml b/.playwright-cli/page-2026-03-14T13-03-08-306Z.yml new file mode 100644 index 000000000..e69de29bb diff --git a/.playwright-cli/page-2026-03-14T13-04-28-164Z.yml b/.playwright-cli/page-2026-03-14T13-04-28-164Z.yml new file mode 100644 index 000000000..d944002d6 --- /dev/null +++ b/.playwright-cli/page-2026-03-14T13-04-28-164Z.yml @@ -0,0 +1 @@ +- generic [ref=e2]: loading \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 000000000..48b889858 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,4 @@ +# Changelog + +## Unreleased +- Add filament label printing with separate presets, QR codes, 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..290461290 100644 --- a/client/public/locales/en/common.json +++ b/client/public/locales/en/common.json @@ -70,6 +70,7 @@ "paperSize": "Paper Size", "customSize": "Custom", "dimensions": "Dimensions", + "amlLabelSize": "AML Label Size", "showBorder": "Show Border", "previewScale": "Preview Scale", "skipItems": "Skip Items", @@ -100,15 +101,76 @@ "deleteSettingsConfirm": "Are you sure you want to delete this preset?", "settingsName": "Preset Name", "saveSetting": "Save Presets", - "saveAsImage": "Save as Image" + "savePreset": "Save Preset", + "saveAsImage": "Save as Image", + "saveAsAmlLabels": "Save as AML (Labels)", + "saveAsAmlPages": "Save as AML (Pages)", + "spoolImagePresets": "Spool Image Presets", + "filamentImagePresets": "Filament Image Presets", + "exportFormat": "Export Format", + "exportFormatOptions": { + "png": "PNG", + "aml": "AML" + }, + "exportAsZip": "Export as .zip", + "exportDpi": "Export DPI", + "exportDpiHelp": "Higher DPI improves image quality for exports but increases file size.", + "exportLabels": "Export Labels", + "filenamePreview": "Filename Preview", + "filenamePreviewAdditional": "Additional Example", + "zipFilenamePreview": "ZIP Filename" }, "qrcode": { "button": "Print Labels", + "selectButton": "Print / Export Labels", + "exportButton": "Export Labels", + "selectTitle": "Export / Print Labels", + "exportFilamentTitle": "Export Filament Labels", + "exportSpoolTitle": "Export Spool Labels", + "printFilamentTitle": "Print Filament Labels", + "printSpoolTitle": "Print Spool Labels", "title": "Label Printing", + "sectionLogo": "Logo", + "sectionTitle": "Title", + "sectionQRCode": "QR Code", + "sectionInformation": "Information", "template": "Label Template", - "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.", + "titleTemplate": "Title Template", + "infoTemplate": "Information Template", + "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, and use ==text== to invert text. 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, and use ==text== to invert text. Click the button to view a list of all available tags.", "textSize": "Label Text Size", - "showContent": "Print Label", + "infoTextSize": "Information Text Size", + "titleTextSize": "Title Text Size", + "titleMaxTextSize": "Title Max Text Size", + "titleAreaHeight": "Title Area Height", + "showContent": "Show Information on label", + "showManufacturerLogo": "Show Manufacturer Logo", + "logoSource": "Logo Source", + "logoSourceOptions": { + "print": "Print Logo", + "color": "Color Logo" + }, + "showTitle": "Show Title", + "titleFitToWidth": "Fit Title to Width", + "titleFitToWidthOptions": { + "on": "On", + "off": "Off" + }, + "appliedTextSize": "Applied Size", + "logoSize": "Logo Size", + "logoAlign": "Logo Horizontal Align", + "titleAlign": "Title Horizontal Align", + "qrCodePosition": "QR Code Position", + "qrCodePositionLeft": "Left", + "qrCodePositionRight": "Right", + "qrCodeAlign": "QR Code Vertical Align", + "qrCodeAlignTop": "Top", + "qrCodeAlignCenter": "Center", + "qrCodeAlignBottom": "Bottom", + "qrCodeSize": "QR Code Size", + "infoAlign": "Information Horizontal Align", + "infoVerticalAlign": "Information Vertical Align", "useHTTPUrl": { "label": "QR code link", "tooltip": "Will use proprietary link that will work only if scanned from Spoolman's scanning feature (default). URL uses either the base URL specified in settings, or the current page URL if not set.", @@ -123,16 +185,31 @@ "no": "No", "simple": "Simple", "withIcon": "With Icon" - } + }, + "filenameTemplate": "Filename Template", + "filenameTemplateTooltipSpool": "Use {} to insert values of the spool object as text. Refer to the label template rules and available tags for details.", + "filenameTemplateTooltipFilament": "Use {} to insert values of the filament object as text. Refer to the label template rules and available tags for details.", + "titleTemplateTooltipSpool": "Use {} to insert values of the spool object as text. Refer to the label template rules and available tags for details.", + "titleTemplateTooltipFilament": "Use {} to insert values of the filament object as text. Refer to the label template rules and available tags for details." }, "spoolSelect": { "title": "Select Spools", "description": "Select spools to print labels for.", + "searchPlaceholder": "Search vendor, name, material, 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 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": { @@ -269,14 +346,27 @@ "vendor": "Manufacturers", "fields": { "id": "ID", + "logo": "Logo", + "logo_url": "Logo URL", + "print_logo_url": "Print Logo URL", + "logo_preview": "Logo Preview", + "print_logo_preview": "Print Logo Preview", "name": "Name", "empty_spool_weight": "Empty Spool Weight", "external_id": "External ID", "registered": "Registered", - "comment": "Comment" + "comment": "Comment", + "logo_suggestions": "Logo Suggestions", + "print_logo_suggestions": "Print Logo Suggestions", + "logo_suggestions_placeholder": "Select a suggested logo path", + "logo_suggestions_none": "None" }, "fields_help": { - "empty_spool_weight": "The weight of an empty spool from this manufacturer." + "empty_spool_weight": "The weight of an empty spool from this manufacturer.", + "logo_url": "Optional custom logo used in the UI. Supports absolute URLs or local paths like /vendor-logos/web/bambu-lab-web.png. Use any image your browser can display from that path or URL.", + "print_logo_url": "Optional custom logo used for label rendering. Supports absolute URLs or local paths like /vendor-logos/print/bambu-lab.png. Use any image your browser can display from that path or URL.", + "logo_suggestions": "Checks available logo files in /vendor-logos/web for names similar to this manufacturer.", + "print_logo_suggestions": "Checks available logo files in /vendor-logos/print for names similar to this manufacturer." }, "titles": { "create": "Create Manufacturer", @@ -286,8 +376,36 @@ "show": "Show Manufacturer", "show_title": "[Manufacturer #{{id}}] {{name}}" }, + "buttons": { + "sync_logos": "Sync Logos", + "clear_logo_url": "Clear URL", + "upload_web_logo": "Upload Web Logo", + "upload_print_logo": "Upload Print Logo", + "sync_this_vendor_from_github": "Refresh Pack + Sync This Manufacturer", + "sync_this_vendor_from_github_help": "Refreshes the shared logo pack from GitHub, then applies matched logo paths to this manufacturer only when its logo fields are blank and a local filename match is found (case/punctuation-insensitive, with singular/plural tolerance). The database is not updated until you click Save.", + "convert_logo_to_print": "Convert Logo to Print", + "convert_logo_to_print_help": "Creates a black-and-white print logo from the current Logo URL and stores it as a separate local print logo file.", + "convert_logo_to_print_help_locked": "A Print Logo URL is already set. Clear Print Logo URL to enable creating a new print logo from the current Logo URL." + }, + "logo_filter": { + "has_logo": "Has Logo", + "no_logo": "No Logo" + }, "form": { - "vendor_updated": "This manufacturer has been updated by someone/something else since you opened this page. Saving will overwrite those changes!" + "vendor_updated": "This manufacturer has been updated by someone/something else since you opened this page. Saving will overwrite those changes!", + "logo_sync_requires_name": "Set a manufacturer name first.", + "logo_sync_no_match": "No matching logos found for this manufacturer name.", + "logo_sync_applied": "Suggested logo paths applied.", + "logo_sync_applied_pending_save": "Applied {{count}} suggested logo field(s) in this form. Click Save to keep these changes, or leave the page to discard them.", + "logo_sync_skipped_existing": "Matching logos were found, but existing logo URL fields are already set and were not overwritten.", + "logo_preview_auto_notice": "using auto-matched logo from available logo files", + "logo_preview_default_notice": "no logo defined, using default generated text logo", + "logo_convert_requires_web_logo": "Set a Logo URL first.", + "logo_convert_requires_empty_print_logo": "Clear Print Logo URL first to generate a new print logo.", + "logo_convert_success": "Generated print logo from web logo.", + "logo_convert_error": "Could not generate a print logo from this Logo URL.", + "logo_upload_success": "Uploaded logo file and set {{field}}.", + "logo_upload_error": "Could not upload this logo file." } }, "home": { @@ -322,6 +440,16 @@ "round_prices": { "label": "Round prices", "tooltip": "Round prices to the nearest whole number." + }, + "logo_sync": { + "title": "Global Manufacturer Logo Sync", + "description": "Syncs missing logo URLs across the whole manufacturer database using the bundled logo manifest. Existing logo URLs are not overwritten.", + "where": "Source: /vendor-logos/manifest.json (bundled static web/print logo files). Matching ignores minor punctuation/case differences in names.", + "button": "Sync Logos Now", + "not_ready": "Logo manifest is not available yet.", + "load_error": "Could not load manufacturers for logo sync.", + "done": "Logo sync complete. Matched {{matched}}, updated {{updated}}.", + "scope_note": "This action syncs the whole manufacturer database. You can sync a single manufacturer from its Edit page." } }, "extra_fields": { diff --git a/client/public/vendor-logos/manifest.json b/client/public/vendor-logos/manifest.json new file mode 100644 index 000000000..e69de29bb 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/otherModels.tsx b/client/src/components/otherModels.tsx index 0adabd80c..ffb43bd8e 100644 --- a/client/src/components/otherModels.tsx +++ b/client/src/components/otherModels.tsx @@ -3,8 +3,9 @@ import { Tooltip } from "antd"; import { ColumnFilterItem } from "antd/es/table/interface"; import { IFilament } from "../pages/filaments/model"; import { IVendor } from "../pages/vendors/model"; -import { getAPIURL } from "../utils/url"; +import { getAPIURL, getBasePath } from "../utils/url"; +// Build shared table-filter options here so selection dialogs can reuse labels and tooltip metadata. export function useSpoolmanFilamentFilter(enabled: boolean = false) { return useQuery({ enabled: enabled, @@ -17,13 +18,13 @@ export function useSpoolmanFilamentFilter(enabled: boolean = false) { return response.json(); }, select: (data) => { - // Concatenate vendor name and filament name + // Concatenate vendor name and filament name. const names = data - // Remove empty names + // Remove empty names before building filter entries. .filter((filament) => { return filament.name !== null && filament.name !== undefined && filament.name !== ""; }) - // Transform to ColumnFilterItem + // Transform each filament into the table filter shape. .map((filament) => { let name = ""; if (filament.vendor?.name) { @@ -73,12 +74,13 @@ export function useSpoolmanFilamentFilter(enabled: boolean = false) { ), value: filament.id, + // Keep a plain string sort key because the rendered label itself is wrapped in JSX. sortId: name, }; }) - // Remove duplicates + // Remove duplicates so each filament only appears once in the filter dropdown. .filter((item, index, self) => self.findIndex((t) => t.value === item.value) === index) - // Sort by name + // Sort by the plain string key instead of the JSX label. .sort((a, b) => a.sortId.localeCompare(b.sortId)); return names; }, @@ -134,6 +136,29 @@ export function useSpoolmanVendors(enabled: boolean = false) { }); } +export function useSpoolmanVendorExternalIds(enabled: boolean = false) { + return useQuery({ + enabled: enabled, + queryKey: ["vendorExternalIds"], + queryFn: async () => { + const response = await fetch(getAPIURL() + "/vendor"); + if (!response.ok) { + throw new Error("Network response was not ok"); + } + return response.json(); + }, + select: (data) => { + const externalIds = data + .map((vendor) => vendor.external_id) + .filter( + (externalId): externalId is string => externalId !== null && externalId !== undefined && externalId !== "", + ) + .sort(); + return [...new Set(externalIds)]; + }, + }); +} + export function useSpoolmanMaterials(enabled: boolean = false) { return useQuery({ enabled: enabled, @@ -201,3 +226,23 @@ export function useSpoolmanLocations(enabled: boolean = false) { }, }); } + +interface VendorLogoManifest { + web_files?: string[]; + print_files?: string[]; +} + +// Missing bundled/runtime logo packs should degrade to empty suggestions instead of breaking vendor forms. +export function useVendorLogoManifest(enabled: boolean = true) { + return useQuery({ + enabled, + queryKey: ["vendor-logo-manifest"], + queryFn: async () => { + const response = await fetch(`${getBasePath()}/vendor-logos/manifest.json`, { cache: "no-store" }); + if (!response.ok) { + return { web_files: [], print_files: [] }; + } + return response.json(); + }, + }); +} 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/components/vendorLogo.tsx b/client/src/components/vendorLogo.tsx new file mode 100644 index 000000000..dfeab916c --- /dev/null +++ b/client/src/components/vendorLogo.tsx @@ -0,0 +1,53 @@ +import { CSSProperties, useEffect, useMemo, useState } from "react"; +import { IVendor } from "../pages/vendors/model"; +import { getVendorLogoCandidates } from "../utils/vendorLogo"; + +interface VendorLogoProps { + vendor?: IVendor; + usePrintLogo?: boolean; + showFallbackText?: boolean; + imgStyle?: CSSProperties; + fallbackStyle?: CSSProperties; +} + +// Walk the vendor's candidate logo URLs in order, then optionally fall back to plain vendor text. +export function VendorLogo({ + vendor, + usePrintLogo = false, + showFallbackText = false, + imgStyle, + fallbackStyle, +}: VendorLogoProps) { + const candidates = useMemo(() => getVendorLogoCandidates(vendor, usePrintLogo), [vendor, usePrintLogo]); + const [currentCandidateIndex, setCurrentCandidateIndex] = useState(0); + useEffect(() => { + // Reset to the highest-priority candidate whenever the vendor or logo mode changes. + setCurrentCandidateIndex(0); + }, [vendor, usePrintLogo]); + + const currentSrc = candidates[currentCandidateIndex]; + const fallbackText = vendor?.name ?? ""; + + if (currentSrc) { + return ( + {vendor?.name { + // Try the next candidate before giving up so a stale saved path can still fall + // back to the local runtime logo filenames inferred from the vendor name. + setCurrentCandidateIndex((idx) => idx + 1); + }} + /> + ); + } + + if (showFallbackText && fallbackText) { + return
{fallbackText}
; + } + + return null; +} + +export default VendorLogo; diff --git a/client/src/pages/filamentExport/index.tsx b/client/src/pages/filamentExport/index.tsx new file mode 100644 index 000000000..c12fbfd59 --- /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..e86f1daf3 --- /dev/null +++ b/client/src/pages/filamentLabels/index.tsx @@ -0,0 +1,80 @@ +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..f9a88e6f9 --- /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..323fac3a5 100644 --- a/client/src/pages/filaments/functions.ts +++ b/client/src/pages/filaments/functions.ts @@ -1,18 +1,17 @@ +import { useQueries } from "@tanstack/react-query"; import { ExternalFilament } from "../../utils/queryExternalDB"; import { getAPIURL } from "../../utils/url"; import { getOrCreateVendorFromExternal } from "../vendors/functions"; import { IFilament } from "./model"; -/** - * Create a new internal filament given an external filament object. - * Returns the created internal filament. - */ +// Mirror an external catalog filament into the local API while preserving the source color representation. export async function createFilamentFromExternal(externalFilament: ExternalFilament): Promise { const vendor = await getOrCreateVendorFromExternal(externalFilament.manufacturer); let color_hex: string | undefined = undefined; let multi_color_hexes: string | undefined = undefined; let multi_color_direction: string | undefined = undefined; + // External catalogs send either a single swatch or a multi-color list; keep the API payload mutually exclusive. if (externalFilament.color_hex) { color_hex = externalFilament.color_hex; } else if (externalFilament.color_hexes && externalFilament.color_hexes.length > 0) { @@ -48,3 +47,18 @@ export async function createFilamentFromExternal(externalFilament: ExternalFilam } return response.json(); } + +// Fetch selected filaments in parallel so print/export dialogs can render labels as soon as each item resolves. +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..a44f8ecb6 100644 --- a/client/src/pages/filaments/list.tsx +++ b/client/src/pages/filaments/list.tsx @@ -1,4 +1,11 @@ -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 +176,15 @@ export const FilamentList = () => { ( <> + + {defaultButtons} )} > - {t("filament.fields.id")} - - {t("filament.fields.vendor")} - - {t("filament.fields.registered")} - - {t("filament.fields.name")} - - {t("filament.fields.color_hex")} - {colorObj && } - {record?.color_hex && } - {t("filament.fields.material")} - - {t("filament.fields.price")} - - {t("filament.fields.density")} - - {t("filament.fields.diameter")} - - {t("filament.fields.weight")} - - {t("filament.fields.spool_weight")} - - {t("filament.fields.settings_extruder_temp")} - {!record?.settings_extruder_temp ? ( - - ) : ( - - )} - {t("filament.fields.settings_bed_temp")} - {!record?.settings_bed_temp ? ( - - ) : ( - - )} - {t("filament.fields.article_number")} - - {t("filament.fields.external_id")} - - {t("filament.fields.comment")} - - {t("settings.extra_fields.tab")} - {extraFields?.data?.map((field, index) => ( - - ))} + + + {t("filament.fields.id")} + + {t("filament.fields.registered")} + + {t("filament.fields.name")} + + {t("filament.fields.color_hex")} + {colorObj && } + {record?.color_hex && } + {t("filament.fields.material")} + + {t("filament.fields.price")} + + {t("filament.fields.density")} + + {t("filament.fields.diameter")} + + {t("filament.fields.weight")} + + {t("filament.fields.spool_weight")} + + {t("filament.fields.settings_extruder_temp")} + {!record?.settings_extruder_temp ? ( + + ) : ( + + )} + {t("filament.fields.settings_bed_temp")} + {!record?.settings_bed_temp ? ( + + ) : ( + + )} + {t("filament.fields.article_number")} + + {t("filament.fields.external_id")} + + {t("filament.fields.comment")} + + {t("settings.extra_fields.tab")} + {extraFields?.data?.map((field, index) => ( + + ))} + + + {t("filament.fields.vendor")} + + {record?.vendor && ( +
+ +
+ )} + +
); }; diff --git a/client/src/pages/help/index.tsx b/client/src/pages/help/index.tsx index bf493e6fd..a1358836f 100644 --- a/client/src/pages/help/index.tsx +++ b/client/src/pages/help/index.tsx @@ -35,11 +35,32 @@ export const Help = () => { i18nKey={"help.description"} components={{ p:

, + h2: , + h3: <Title level={4} />, + h4: <Title level={5} />, + hr: <hr style={{ border: 0, borderTop: `1px solid ${token.colorSplit}`, margin: `${token.marginLG}px 0` }} />, title: <Title />, filamentCreateLink: <Link to="/filament/create" />, spoolCreateLink: <Link to="/spool/create" />, vendorCreateLink: <Link to="/vendor/create" />, + logosSettingsLink: <Link to="/settings/logos" />, readmeLink: <Link to="https://github.com/Donkie/Spoolman#integration-status" target="_blank" />, + strong: <strong />, + code: ( + <code + style={{ + backgroundColor: token.colorFillSecondary, + borderRadius: token.borderRadiusSM, + border: `1px solid ${token.colorBorderSecondary}`, + fontSize: token.fontSizeSM, + lineHeight: 1.35, + padding: "0 6px", + }} + /> + ), + ol: <ol />, + ul: <ul />, + li: <li />, itemsHelp: ( <List itemLayout="horizontal" diff --git a/client/src/pages/printing/exportDialog.tsx b/client/src/pages/printing/exportDialog.tsx new file mode 100644 index 000000000..918d926a5 --- /dev/null +++ b/client/src/pages/printing/exportDialog.tsx @@ -0,0 +1,819 @@ +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; + extraFormatSettings?: ReactElement; + extraButtons?: ReactElement; + zipFileTypeName: string; +} + +// Render one preview page per exported label and reuse that DOM for PNG/AML generation so preview stays authoritative. +const ExportDialog = ({ + items, + printSettings, + setPrintSettings, + style, + extraSettings, + extraSettingsStart, + extraFormatSettings, + extraButtons, + zipFileTypeName, +}: ExportDialogProps) => { + const t = useTranslate(); + + const [collapseState, setCollapseState] = useSavedState<string[]>("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<HTMLDivElement>(null); + + const sanitizeFilename = (value: string) => { + const trimmed = value.trim(); + if (trimmed === "") { + return ""; + } + return ( + trimmed + // eslint-disable-next-line no-control-regex + .replace(/[<>:"/\\|?*\x00-\x1F]/g, "-") + .replace(/\s+/g, " ") + .replace(/\.+$/g, "") + ); + }; + + // Exports deliberately stay one-label-per-page so preview names and downloaded files map 1:1 to a logical label. + const pageBlocks: ReactElement[][] = []; + for (const item of items) { + pageBlocks.push([item]); + } + + const pages = pageBlocks.map(function (pageItems, pageIdx) { + const itemDivs = pageItems.map((item, itemIdx) => { + const rawPreviewName = (item.props as { "data-aml-name"?: string })["data-aml-name"] ?? `label-${itemIdx + 1}`; + const previewName = sanitizeFilename(rawPreviewName) || `label-${itemIdx + 1}`; + return ( + <div + key={itemIdx} + className="print-page-item" + style={{ + position: "relative", + width: `${itemWidth}mm`, + height: `${itemHeight}mm`, + paddingLeft: `${Math.max(printerMargin.left - margin.left, 0)}mm`, + paddingRight: `${Math.max(printerMargin.right - margin.right, 0)}mm`, + paddingTop: `${Math.max(printerMargin.top - margin.top, 0)}mm`, + paddingBottom: `${Math.max(printerMargin.bottom - margin.bottom, 0)}mm`, + }} + > + <div className="print-page-filename"> + {previewName}.{exportFormat} + </div> + {item} + </div> + ); + }); + + return ( + <div + className="print-page" + key={pageIdx} + style={{ + width: `${paperWidth}mm`, + height: `${paperHeight}mm`, + backgroundColor: "#FFF", + overflow: "hidden", + }} + > + <div + className="print-page-area" + style={{ + height: `${paperHeight - margin.top - margin.bottom}mm`, + width: `${paperWidth - margin.left - margin.right}mm`, + marginTop: `${margin.top}mm`, + marginLeft: `${margin.left}mm`, + marginRight: `${margin.right}mm`, + marginBottom: `${margin.bottom}mm`, + }} + > + <div + style={{ + display: "flex", + flexWrap: "wrap", + }} + > + {itemDivs} + </div> + </div> + </div> + ); + }); + + 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, """) + .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 `<WdPage> + <masksToBoundsType>0</masksToBoundsType> + <borderDisplay>0</borderDisplay> + <isAutoHeight>0</isAutoHeight> + <lineType>0</lineType> + <borderWidth>1</borderWidth> + <borderColor>#000000</borderColor> + <lockMovement>0</lockMovement> + <contents><Image> + <lineType>0</lineType> + <content>${base64Png}</content> + <height>${height.toFixed(3)}</height> + <width>${width.toFixed(3)}</width> + <y>0.000</y> + <x>0.000</x> + <orientation>0.000000</orientation> + <lockMovement>0</lockMovement> + <borderDisplay>0</borderDisplay> + <borderHeight>0.7055555449591742</borderHeight> + <borderColor>#000000</borderColor> + <id>${id}</id> + <objectId>${objectId}</objectId> + <imageEffect>0</imageEffect> + <antiColor>0</antiColor> + <isRatioScale>1</isRatioScale> + <imageType>0</imageType> + <isMirror>0</isMirror> + <isRedBlack>0</isRedBlack> + </Image></contents> + <columnCount>0</columnCount> + <isRibbonLabel>0</isRibbonLabel> + </WdPage>`; + }; + + 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 `<?xml version="1.0" encoding="UTF-8" standalone="yes"?> +<LPAPI version="1.6"> + <labelName>${escapeXml(name)}</labelName> + <paperName>Custom Label</paperName> + <isPrintHorizontal>0</isPrintHorizontal> + <labelHeight>${height.toFixed(3)}</labelHeight> + <labelWidth>${width.toFixed(3)}</labelWidth> + <validBoundsX>1</validBoundsX> + <validBoundsY>1</validBoundsY> + <validBoundsWidth>${validBoundsWidth.toFixed(0)}</validBoundsWidth> + <validBoundsHeight>${validBoundsHeight.toFixed(0)}</validBoundsHeight> + <paperType>0</paperType> + <paperBackground>#ffffff</paperBackground> + <paperForeground>#000000</paperForeground> + <DisplaySize_mm>${width.toFixed(2)}mm * ${height.toFixed(2)}mm</DisplaySize_mm> + <DisplaySize_in>${widthIn.toFixed(3)}inch * ${heightIn.toFixed(3)}inch</DisplaySize_in> + <isRotate180>0</isRotate180> + <isBannerMode>0</isBannerMode> + <isCustomSize>0</isCustomSize> + <leftBlank>0</leftBlank> + <rightBlank>0</rightBlank> + <upBlank>0</upBlank> + <downBlank>0</downBlank> + <typeName>Custom</typeName> + <showDisplayMm>${width.toFixed(1)} * ${height.toFixed(1)} mm</showDisplayMm> + <showDisplayIn>${widthIn.toFixed(2)} * ${heightIn.toFixed(2)} in</showDisplayIn> + <contents> + ${base64Pages.map((base64Png) => buildAmlPageXml(width, height, base64Png)).join("\n")} + </contents> + </LPAPI> +`; + }; + + const getExportImageOptions = () => { + const exportPixelRatio = Math.max(1, Math.min(exportDpi / 96, 10)); + return { + backgroundColor: "#FFF", + cacheBust: true, + pixelRatio: exportPixelRatio, + }; + }; + + const getUniqueExportItems = () => { + const hasPrinted: Element[] = []; + const itemsToPrint = getPrintItems(); + const usedNames = new Set<string>(); + const uniqueItems: { item: Element; safeName: string }[] = []; + let idx = 1; + + // Repeated copies share the same DOM shape, but export should still emit one file per unique label design. + for (const item of itemsToPrint) { + // Prevent duplicate exports when the preview contains repeated 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; + } + + // ZIP exports reuse the same per-label rendering path so single-file and batch downloads stay consistent. + 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 ( + <> + <Row gutter={16}> + <Col + span={14} + style={{ + display: "flex", + flexDirection: "column", + }} + > + <div + style={{ + transform: "translateZ(0)", + overflow: "auto", + flexBasis: "0px", + flexGrow: "1", + }} + > + <div + className="print-container" + ref={contentRef} + style={{ + transform: `scale(${previewScale})`, + transformOrigin: "top left", + }} + > + <style> + {` + .print-page { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'; + line-height: 1.2; + } + + .print-page * { + box-sizing: border-box; + } + + .print-page .print-page-filename { + position: absolute; + top: 0.5mm; + left: 0.5mm; + z-index: 2; + pointer-events: none; + font-size: 2mm; + line-height: 1.2; + color: #333; + background: rgba(255, 255, 255, 0.9); + border-radius: 1mm; + padding: 0 0.8mm; + max-width: calc(100% - 1mm); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + ${style ?? ""} + `} + </style> + {pages} + </div> + </div> + </Col> + <Col span={10}> + <div style={{ maxHeight: "calc(100vh - 260px)", overflowY: "auto", paddingRight: 8 }}> + <Form labelAlign="left" colon={false} labelWrap={true} labelCol={{ span: 8 }} wrapperCol={{ span: 16 }}> + {extraSettingsStart} + <Divider /> + <Form.Item label={t("printing.generic.exportFormat")}> + <div style={{ display: "flex", alignItems: "center", gap: 12, flexWrap: "wrap" }}> + <Radio.Group + options={[ + { label: t("printing.generic.exportFormatOptions.png"), value: "png" }, + { label: t("printing.generic.exportFormatOptions.aml"), value: "aml" }, + ]} + onChange={(e) => { + printSettings.exportFormat = e.target.value; + setPrintSettings(printSettings); + }} + value={exportFormat} + optionType="button" + buttonStyle="solid" + /> + <Checkbox + checked={exportAsZip} + onChange={(event) => { + printSettings.exportAsZip = event.target.checked; + setPrintSettings(printSettings); + }} + > + {t("printing.generic.exportAsZip")} + </Checkbox> + </div> + </Form.Item> + {extraFormatSettings} + <Form.Item label={t("printing.generic.exportDpi")} help={t("printing.generic.exportDpiHelp")}> + <Row> + <Col span={12}> + <Slider + min={72} + max={600} + step={1} + tooltip={{ formatter: (value) => `${value} dpi` }} + value={exportDpi} + onChange={(value) => { + printSettings.exportDpi = value; + setPrintSettings(printSettings); + }} + /> + </Col> + <Col span={12}> + <InputNumber + min={72} + max={1200} + step={1} + style={{ margin: "0 16px" }} + value={exportDpi} + addonAfter="dpi" + onChange={(value) => { + printSettings.exportDpi = value ?? 300; + setPrintSettings(printSettings); + }} + /> + </Col> + </Row> + </Form.Item> + <Form.Item label={t("printing.generic.previewScale")}> + <Row> + <Col span={12}> + <Slider + min={0.1} + max={3} + step={0.01} + value={previewScale} + onChange={(value) => { + setPreviewScale(value); + }} + /> + </Col> + <Col span={12}> + <InputNumber + min={0.1} + max={3} + step={0.01} + style={{ margin: "0 16px" }} + value={previewScale} + onChange={(value) => { + setPreviewScale(value ?? 0.1); + }} + /> + </Col> + </Row> + </Form.Item> + <Divider /> + <Collapse + defaultActiveKey={collapseState} + bordered={false} + ghost + onChange={(key) => { + if (Array.isArray(key)) { + setCollapseState(key); + } + }} + > + <Collapse.Panel header={t("printing.generic.contentSettings")} key="1"> + {extraSettings} + </Collapse.Panel> + <Collapse.Panel header={t("printing.generic.layoutSettings")} key="2"> + <Form.Item label={t("printing.generic.dimensions")}> + <Row align="middle"> + <Col span={11}> + <InputNumber + value={customPaperSize.width} + min={1} + addonAfter="mm" + onChange={(value) => { + customPaperSize.width = value ?? 0; + printSettings.customPaperSize = customPaperSize; + setPrintSettings(printSettings); + }} + /> + </Col> + <Col span={2} style={{ textAlign: "center" }}> + x + </Col> + <Col span={11}> + <InputNumber + value={customPaperSize.height} + min={1} + addonAfter="mm" + onChange={(value) => { + customPaperSize.height = value ?? 0; + printSettings.customPaperSize = customPaperSize; + setPrintSettings(printSettings); + }} + /> + </Col> + </Row> + </Form.Item> + <Divider /> + <p>{t("printing.generic.helpMargin")}</p> + <Form.Item label={t("printing.generic.marginLeft")}> + <Row> + <Col span={12}> + <Slider + min={-20} + max={50} + step={0.1} + tooltip={{ formatter: (value) => `${value} mm` }} + value={margin.left} + onChange={(value) => { + margin.left = value; + printSettings.margin = margin; + setPrintSettings(printSettings); + }} + /> + </Col> + <Col span={12}> + <InputNumber + step={0.1} + style={{ margin: "0 16px" }} + value={margin.left} + addonAfter="mm" + onChange={(value) => { + margin.left = value ?? 0; + printSettings.margin = margin; + setPrintSettings(printSettings); + }} + /> + </Col> + </Row> + </Form.Item> + <Form.Item label={t("printing.generic.marginTop")}> + <Row> + <Col span={12}> + <Slider + min={-20} + max={50} + step={0.1} + tooltip={{ formatter: (value) => `${value} mm` }} + value={margin.top} + onChange={(value) => { + margin.top = value; + printSettings.margin = margin; + setPrintSettings(printSettings); + }} + /> + </Col> + <Col span={12}> + <InputNumber + step={0.1} + style={{ margin: "0 16px" }} + value={margin.top} + addonAfter="mm" + onChange={(value) => { + margin.top = value ?? 0; + printSettings.margin = margin; + setPrintSettings(printSettings); + }} + /> + </Col> + </Row> + </Form.Item> + <Form.Item label={t("printing.generic.marginRight")}> + <Row> + <Col span={12}> + <Slider + min={-20} + max={50} + step={0.1} + tooltip={{ formatter: (value) => `${value} mm` }} + value={margin.right} + onChange={(value) => { + margin.right = value; + printSettings.margin = margin; + setPrintSettings(printSettings); + }} + /> + </Col> + <Col span={12}> + <InputNumber + step={0.1} + style={{ margin: "0 16px" }} + value={margin.right} + addonAfter="mm" + onChange={(value) => { + margin.right = value ?? 0; + printSettings.margin = margin; + setPrintSettings(printSettings); + }} + /> + </Col> + </Row> + </Form.Item> + <Form.Item label={t("printing.generic.marginBottom")}> + <Row> + <Col span={12}> + <Slider + min={-20} + max={50} + step={0.1} + tooltip={{ formatter: (value) => `${value} mm` }} + value={margin.bottom} + onChange={(value) => { + margin.bottom = value; + printSettings.margin = margin; + setPrintSettings(printSettings); + }} + /> + </Col> + <Col span={12}> + <InputNumber + step={0.1} + style={{ margin: "0 16px" }} + value={margin.bottom} + addonAfter="mm" + onChange={(value) => { + margin.bottom = value ?? 0; + printSettings.margin = margin; + setPrintSettings(printSettings); + }} + /> + </Col> + </Row> + </Form.Item> + <Divider /> + <p>{t("printing.generic.helpPrinterMargin")}</p> + <Form.Item label={t("printing.generic.printerMarginLeft")}> + <Row> + <Col span={12}> + <Slider + min={-20} + max={50} + step={0.1} + tooltip={{ formatter: (value) => `${value} mm` }} + value={printerMargin.left} + onChange={(value) => { + printerMargin.left = value; + printSettings.printerMargin = printerMargin; + setPrintSettings(printSettings); + }} + /> + </Col> + <Col span={12}> + <InputNumber + step={0.1} + style={{ margin: "0 16px" }} + value={printerMargin.left} + addonAfter="mm" + onChange={(value) => { + printerMargin.left = value ?? 0; + printSettings.printerMargin = printerMargin; + setPrintSettings(printSettings); + }} + /> + </Col> + </Row> + </Form.Item> + <Form.Item label={t("printing.generic.printerMarginTop")}> + <Row> + <Col span={12}> + <Slider + min={-20} + max={50} + step={0.1} + tooltip={{ formatter: (value) => `${value} mm` }} + value={printerMargin.top} + onChange={(value) => { + printerMargin.top = value; + printSettings.printerMargin = printerMargin; + setPrintSettings(printSettings); + }} + /> + </Col> + <Col span={12}> + <InputNumber + step={0.1} + style={{ margin: "0 16px" }} + value={printerMargin.top} + addonAfter="mm" + onChange={(value) => { + printerMargin.top = value ?? 0; + printSettings.printerMargin = printerMargin; + setPrintSettings(printSettings); + }} + /> + </Col> + </Row> + </Form.Item> + <Form.Item label={t("printing.generic.printerMarginRight")}> + <Row> + <Col span={12}> + <Slider + min={-20} + max={50} + step={0.1} + tooltip={{ formatter: (value) => `${value} mm` }} + value={printerMargin.right} + onChange={(value) => { + printerMargin.right = value; + printSettings.printerMargin = printerMargin; + setPrintSettings(printSettings); + }} + /> + </Col> + <Col span={12}> + <InputNumber + step={0.1} + style={{ margin: "0 16px" }} + value={printerMargin.right} + addonAfter="mm" + onChange={(value) => { + printerMargin.right = value ?? 0; + printSettings.printerMargin = printerMargin; + setPrintSettings(printSettings); + }} + /> + </Col> + </Row> + </Form.Item> + <Form.Item label={t("printing.generic.printerMarginBottom")}> + <Row> + <Col span={12}> + <Slider + min={-20} + max={50} + step={0.1} + tooltip={{ formatter: (value) => `${value} mm` }} + value={printerMargin.bottom} + onChange={(value) => { + printerMargin.bottom = value; + printSettings.printerMargin = printerMargin; + setPrintSettings(printSettings); + }} + /> + </Col> + <Col span={12}> + <InputNumber + step={0.1} + style={{ margin: "0 16px" }} + value={printerMargin.bottom} + addonAfter="mm" + onChange={(value) => { + printerMargin.bottom = value ?? 0; + printSettings.printerMargin = printerMargin; + setPrintSettings(printSettings); + }} + /> + </Col> + </Row> + </Form.Item> + </Collapse.Panel> + </Collapse> + </Form> + </div> + </Col> + </Row> + <Row justify={"end"} style={{ paddingRight: 72 }}> + <Col> + <Space> + {extraButtons} + <Button type="primary" icon={<DownloadOutlined />} size="large" onClick={handleExport}> + {t("printing.generic.exportLabels")} + </Button> + </Space> + </Col> + </Row> + </> + ); +}; + +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..fff35449f --- /dev/null +++ b/client/src/pages/printing/filamentQrCodeExportDialog.tsx @@ -0,0 +1,533 @@ +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 { useEffect, 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 { + getConfiguredBaseUrl, + SpoolQRCodePrintSettings, + renderLabelContents, + renderTemplateText, + useGetPrintSettings as useGetPrintPresets, + useSetPrintSettings as useSetPrintPresets, +} from "./printing"; +import QRCodeExportDialog from "./qrCodeExportDialog"; + +const { Text } = Typography; + +interface FilamentQRCodeExportDialogProps { + filamentIds: number[]; +} + +// Adapt filament records into the generic QR export dialog and keep export presets isolated from spool presets. +const FilamentQRCodeExportDialog = ({ filamentIds }: FilamentQRCodeExportDialogProps) => { + const t = useTranslate(); + const currentPresetType = "filament"; + const otherPresetType = "spool"; + const defaultPresetName = t("printing.generic.defaultSettings"); + const importedPresetSuffix = `(${otherPresetType} preset basis)`; + const isDefaultPresetName = (name?: string) => { + const normalizedName = (name ?? "").trim().toLowerCase(); + const normalizedDefault = defaultPresetName.trim().toLowerCase(); + return normalizedName === normalizedDefault || normalizedName === "default"; + }; + const escapeRegExp = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const getNextPresetName = (baseName: string, presets: SpoolQRCodePrintSettings[]) => { + const trimmedBaseName = baseName.trim() || defaultPresetName; + const normalizedBaseName = trimmedBaseName.replace(/-\d{2}$/u, ""); + const suffixPattern = new RegExp(`^${escapeRegExp(normalizedBaseName)}-(\\d{2})$`, "u"); + let maxSuffix = 0; + for (const preset of presets) { + const presetName = (preset.labelSettings.printSettings?.name ?? "").trim(); + const match = presetName.match(suffixPattern); + if (!match) continue; + maxSuffix = Math.max(maxSuffix, Number.parseInt(match[1], 10)); + } + return `${normalizedBaseName}-${String(maxSuffix + 1).padStart(2, "0")}`; + }; + const buildNewPreset = ( + id: string, + name: string, + sourcePreset?: SpoolQRCodePrintSettings, + ): SpoolQRCodePrintSettings => { + const copiedSourcePrintSettings = sourcePreset?.labelSettings?.printSettings ?? {}; + return { + ...sourcePreset, + labelSettings: { + ...sourcePreset?.labelSettings, + printSettings: { + ...copiedSourcePrintSettings, + id, + name, + }, + }, + }; + }; + + const toPresetValue = (type: "spool" | "filament", id: string) => `${type}:${id}`; + const parsePresetValue = (value?: string): { type: "spool" | "filament"; id: string } | undefined => { + if (!value) return undefined; + const separatorIndex = value.indexOf(":"); + if (separatorIndex < 0) return { type: currentPresetType, id: value }; + const type = value.slice(0, separatorIndex); + const id = value.slice(separatorIndex + 1); + if ((type === currentPresetType || type === otherPresetType) && id) { + return { type, id }; + } + return undefined; + }; + + const baseUrlSetting = useGetSetting("base_url"); + // Accept both JSON-backed settings and legacy plain strings so old `base_url` values do not crash the dialog. + const baseUrlRoot = getConfiguredBaseUrl(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<string | undefined>( + "selectedImagePresetFilament", + undefined, + ); + + const [localCurrentPresets, setLocalCurrentPresets] = useState<SpoolQRCodePrintSettings[] | undefined>(); + const remoteSpoolPresets = useGetPrintPresets("image_presets"); + const remoteFilamentPresets = useGetPrintPresets("image_presets_filament"); + const setRemoteFilamentPresets = useSetPrintPresets("image_presets_filament"); + + const currentPresets = localCurrentPresets ?? remoteFilamentPresets; + const otherPresets = remoteSpoolPresets ?? []; + + // Keep edits local until the user explicitly saves so imported spool presets can be tried without immediate persistence. + const savePresetsRemote = async () => { + if (!localCurrentPresets) return; + await setRemoteFilamentPresets(localCurrentPresets); + }; + + useEffect(() => { + // Keep the saved local list active until the refetched settings catch up, otherwise the + // selector can briefly fall back to the default preset immediately after save. + if (!localCurrentPresets || !remoteFilamentPresets) return; + if (JSON.stringify(localCurrentPresets) === JSON.stringify(remoteFilamentPresets)) { + setLocalCurrentPresets(undefined); + } + }, [localCurrentPresets, remoteFilamentPresets]); + + const getSelectedPreset = () => { + const parsed = parsePresetValue(selectedPresetState); + if (!parsed) return undefined; + if (parsed.type === currentPresetType) { + return currentPresets?.find((settings) => settings.labelSettings.printSettings.id === parsed.id); + } + return otherPresets.find((settings) => settings.labelSettings.printSettings.id === parsed.id); + }; + + const promotePresetToCurrentType = (preset: SpoolQRCodePrintSettings): SpoolQRCodePrintSettings | undefined => { + if (!currentPresets) return; + // Imported spool presets become filament-owned copies immediately so later edits never touch the source preset. + const baseName = (preset.labelSettings.printSettings?.name ?? defaultPresetName).trim() || defaultPresetName; + const promotedName = getNextPresetName(`${baseName} ${importedPresetSuffix}`, currentPresets); + const promotedPreset: SpoolQRCodePrintSettings = { + ...buildNewPreset(uuidv4(), promotedName, preset), + }; + const nextPresets = [...currentPresets, promotedPreset]; + setLocalCurrentPresets(nextPresets); + setSelectedPresetState(toPresetValue(currentPresetType, promotedPreset.labelSettings.printSettings.id)); + return promotedPreset; + }; + + // New presets derive from the currently selected settings object so export variants start from what the user sees. + const addNewPreset = () => { + if (!currentPresets) return; + const newId = uuidv4(); + const selectedPreset = getSelectedPreset(); + const basePresetName = selectedPreset?.labelSettings.printSettings?.name ?? defaultPresetName; + const nextPresetName = getNextPresetName(basePresetName, currentPresets); + const newPreset = buildNewPreset(newId, nextPresetName, selectedPreset); + setLocalCurrentPresets([...currentPresets, newPreset]); + setSelectedPresetState(toPresetValue(currentPresetType, newId)); + return newPreset; + }; + // Duplicates get a fresh id so the cloned export preset can diverge from its source immediately. + const duplicateCurrentPreset = () => { + if (!currentPresets) return; + const newPreset = { + ...curPreset, + labelSettings: { ...curPreset.labelSettings, printSettings: { ...curPreset.labelSettings.printSettings } }, + }; + newPreset.labelSettings.printSettings.id = uuidv4(); + setLocalCurrentPresets([...currentPresets, newPreset]); + setSelectedPresetState(toPresetValue(currentPresetType, newPreset.labelSettings.printSettings.id)); + }; + // Edits to a spool-derived preset first promote it into the filament bucket before any persistence is possible. + const updateCurrentPreset = (newSettings: SpoolQRCodePrintSettings) => { + if (!currentPresets) return; + const parsed = parsePresetValue(selectedPresetState); + if (!parsed || parsed.type !== currentPresetType) { + promotePresetToCurrentType(newSettings); + return; + } + + let foundPreset = false; + const nextPresets = currentPresets.map((presets) => { + if (presets.labelSettings.printSettings.id === parsed.id) { + foundPreset = true; + return newSettings; + } + return presets; + }); + setLocalCurrentPresets(nextPresets); + if (!foundPreset) { + promotePresetToCurrentType(newSettings); + } + }; + const deleteCurrentPreset = () => { + if (!currentPresets) return; + const parsed = parsePresetValue(selectedPresetState); + if (!parsed || parsed.type !== currentPresetType) return; + setLocalCurrentPresets(currentPresets.filter((qPreset) => qPreset.labelSettings.printSettings.id !== parsed.id)); + setSelectedPresetState(undefined); + }; + + let curPreset: SpoolQRCodePrintSettings; + if (currentPresets === undefined) { + // Use a temporary preset while settings load so the export dialog can render immediately. + curPreset = { + labelSettings: { + printSettings: { + id: "TEMP", + name: defaultPresetName, + }, + }, + }; + } else { + if (currentPresets.length === 0) { + // Seed the filament bucket with one editable preset the first time export settings are opened. + const defaultId = uuidv4(); + const defaultPreset = buildNewPreset(defaultId, defaultPresetName); + setLocalCurrentPresets([defaultPreset]); + setSelectedPresetState(toPresetValue(currentPresetType, defaultId)); + curPreset = defaultPreset; + } else { + const parsedSelectedPreset = parsePresetValue(selectedPresetState); + if (parsedSelectedPreset && parsedSelectedPreset.type === otherPresetType) { + const preferredPreset = + currentPresets.find((settings) => isDefaultPresetName(settings.labelSettings.printSettings?.name)) ?? + currentPresets[0]; + curPreset = preferredPreset; + setSelectedPresetState(toPresetValue(currentPresetType, preferredPreset.labelSettings.printSettings.id)); + } else if (parsedSelectedPreset) { + const foundSetting = currentPresets.find( + (settings) => settings.labelSettings.printSettings.id === parsedSelectedPreset.id, + ); + if (foundSetting) { + curPreset = foundSetting; + } else { + const preferredPreset = + currentPresets.find((settings) => isDefaultPresetName(settings.labelSettings.printSettings?.name)) ?? + currentPresets[0]; + curPreset = preferredPreset; + setSelectedPresetState(toPresetValue(currentPresetType, preferredPreset.labelSettings.printSettings.id)); + } + } else { + const preferredPreset = + currentPresets.find((settings) => isDefaultPresetName(settings.labelSettings.printSettings?.name)) ?? + currentPresets[0]; + curPreset = preferredPreset; + setSelectedPresetState(toPresetValue(currentPresetType, preferredPreset.labelSettings.printSettings.id)); + } + } + } + + const hasUnsavedChanges = + localCurrentPresets !== undefined && + JSON.stringify(localCurrentPresets) !== JSON.stringify(remoteFilamentPresets ?? []); + + const [templateHelpOpen, setTemplateHelpOpen] = useState(false); + const titleTemplate = curPreset.titleTemplate ?? `==**{name}**== {color_hex}`; + const infoTemplate = + curPreset.template ?? + `{material} ({article_number}) +{Diameter: {diameter} mm} +{Weight: {weight} g} +{Spool Weight: {spool_weight} g} +{ET: {settings_extruder_temp} °C} +{BT: {settings_bed_temp} °C} +{{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}` }); + }); + } + + // Expose both filament and vendor placeholders because the same tag picker drives label text and export filenames. + const templateTags = [...filamentTags, ...vendorTags]; + + return ( + <> + {contextHolder} + <QRCodeExportDialog + printSettings={curPreset.labelSettings} + setPrintSettings={(newSettings) => { + updateCurrentPreset({ + ...curPreset, + labelSettings: newSettings, + }); + }} + baseUrlRoot={baseUrlRoot} + useHTTPUrl={useHTTPUrl} + setUseHTTPUrl={setUseHTTPUrl} + previewValues={{ + default: "WEB+SPOOLMAN:F-{id}", + url: `${baseUrlRoot}/filament/show/{id}`, + }} + zipFileTypeName="filament" + extraSettingsStart={ + <> + <Form.Item label={t("printing.generic.filamentImagePresets")}> + <Flex gap={8}> + <Select + value={ + selectedPresetState + ? selectedPresetState.includes(":") + ? selectedPresetState + : toPresetValue(currentPresetType, selectedPresetState) + : undefined + } + onChange={(value) => { + const parsed = parsePresetValue(value); + if (!parsed) return; + if (parsed.type === otherPresetType) { + const sourcePreset = otherPresets.find( + (settings) => settings.labelSettings.printSettings.id === parsed.id, + ); + if (sourcePreset) { + promotePresetToCurrentType(sourcePreset); + } + return; + } + setSelectedPresetState(value); + }} + options={ + currentPresets + ? [ + { + label: t("printing.generic.filamentImagePresets"), + options: currentPresets.map((settings) => ({ + label: + settings.labelSettings.printSettings?.name || t("printing.generic.defaultSettings"), + value: toPresetValue(currentPresetType, settings.labelSettings.printSettings.id), + })), + }, + { + label: t("printing.generic.spoolImagePresets"), + options: otherPresets.map((settings) => ({ + label: + settings.labelSettings.printSettings?.name || t("printing.generic.defaultSettings"), + value: toPresetValue(otherPresetType, settings.labelSettings.printSettings.id), + })), + }, + ] + : [] + } + ></Select> + <Button + style={{ width: "3em" }} + icon={<PlusOutlined />} + title={t("printing.generic.addSettings")} + onClick={addNewPreset} + /> + <Button + style={{ width: "3em" }} + icon={<CopyOutlined />} + title={t("printing.generic.duplicateSettings")} + onClick={duplicateCurrentPreset} + /> + {currentPresets && currentPresets.length > 1 && ( + <Popconfirm + title={t("printing.generic.deleteSettings")} + description={t("printing.generic.deleteSettingsConfirm")} + onConfirm={deleteCurrentPreset} + okText={t("buttons.delete")} + cancelText={t("buttons.cancel")} + > + <Button + style={{ width: "3em" }} + danger + icon={<DeleteOutlined />} + title={t("printing.generic.deleteSettings")} + /> + </Popconfirm> + )} + </Flex> + </Form.Item> + <Form.Item label={t("printing.generic.settingsName")}> + <Input + value={curPreset.labelSettings.printSettings?.name} + onChange={(e) => { + updateCurrentPreset({ + ...curPreset, + labelSettings: { + ...curPreset.labelSettings, + printSettings: { + ...curPreset.labelSettings.printSettings, + name: e.target.value, + }, + }, + }); + }} + /> + <div style={{ minHeight: 22, paddingTop: 4 }}> + {hasUnsavedChanges && <Text type="danger">Unsaved Preset Changes</Text>} + </div> + </Form.Item> + </> + } + items={items.map((filament) => ({ + value: useHTTPUrl ? `${baseUrlRoot}/filament/show/${filament.id}` : `WEB+SPOOLMAN:F-${filament.id}`, + amlName: renderTemplateText(filenameTemplate, filament), + vendor: filament.vendor, + title: <>{renderLabelContents(titleTemplate, filament)}</>, + label: <>{renderLabelContents(infoTemplate, filament)}</>, + errorLevel: "H", + }))} + extraExportSettings={ + <Form.Item + label={t("printing.qrcode.filenameTemplate")} + tooltip={t("printing.qrcode.filenameTemplateTooltipFilament")} + > + <Input + value={filenameTemplate} + onChange={(newValue) => { + updateCurrentPreset({ + ...curPreset, + filenameTemplate: newValue.target.value, + }); + }} + /> + </Form.Item> + } + extraTitleSettings={ + <Form.Item + label={t("printing.qrcode.titleTemplate")} + tooltip={t("printing.qrcode.titleTemplateTooltipFilament")} + > + <TextArea + value={titleTemplate} + rows={4} + onChange={(newValue) => { + updateCurrentPreset({ + ...curPreset, + titleTemplate: newValue.target.value, + }); + }} + /> + </Form.Item> + } + extraInfoSettings={ + <> + <Form.Item label={t("printing.qrcode.infoTemplate")}> + <TextArea + value={infoTemplate} + rows={8} + onChange={(newValue) => { + updateCurrentPreset({ + ...curPreset, + template: newValue.target.value, + }); + }} + /> + </Form.Item> + <Modal open={templateHelpOpen} footer={null} onCancel={() => setTemplateHelpOpen(false)}> + <Table + size="small" + showHeader={false} + pagination={false} + scroll={{ y: 400 }} + columns={[{ dataIndex: "tag" }]} + dataSource={templateTags} + /> + </Modal> + <Text type="secondary"> + {t("printing.qrcode.templateHelpFilament")}{" "} + <Button size="small" onClick={() => setTemplateHelpOpen(true)}> + {t("actions.show")} + </Button> + </Text> + </> + } + extraButtons={ + <> + <Button + type={hasUnsavedChanges ? "primary" : "default"} + size="large" + icon={<SaveOutlined />} + disabled={!hasUnsavedChanges} + onClick={async () => { + try { + await savePresetsRemote(); + messageApi.success(t("notifications.saveSuccessful")); + } catch (error) { + messageApi.error(error instanceof Error ? error.message : "Save failed"); + } + }} + > + {t("printing.generic.savePreset")} + </Button> + </> + } + /> + </> + ); +}; + +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..280d31b5b --- /dev/null +++ b/client/src/pages/printing/filamentQrCodePrintingDialog.tsx @@ -0,0 +1,450 @@ +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 { useEffect, 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 { + getConfiguredBaseUrl, + 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 currentPresetType = "filament"; + const otherPresetType = "spool"; + const defaultPresetName = t("printing.generic.defaultSettings"); + const importedPresetSuffix = `(${otherPresetType} preset basis)`; + const escapeRegExp = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const getNextPresetName = (baseName: string, presets: SpoolQRCodePrintSettings[]) => { + const trimmedBaseName = baseName.trim() || defaultPresetName; + const normalizedBaseName = trimmedBaseName.replace(/-\d{2}$/u, ""); + const suffixPattern = new RegExp(`^${escapeRegExp(normalizedBaseName)}-(\\d{2})$`, "u"); + let maxSuffix = 0; + for (const preset of presets) { + const presetName = (preset.labelSettings.printSettings?.name ?? "").trim(); + const match = presetName.match(suffixPattern); + if (!match) continue; + maxSuffix = Math.max(maxSuffix, Number.parseInt(match[1], 10)); + } + return `${normalizedBaseName}-${String(maxSuffix + 1).padStart(2, "0")}`; + }; + const buildNewPreset = ( + id: string, + name: string, + sourcePreset?: SpoolQRCodePrintSettings, + ): SpoolQRCodePrintSettings => { + const copiedSourcePrintSettings = sourcePreset?.labelSettings?.printSettings ?? {}; + return { + ...sourcePreset, + labelSettings: { + ...sourcePreset?.labelSettings, + printSettings: { + ...copiedSourcePrintSettings, + id, + name, + }, + }, + }; + }; + const toPresetValue = (type: "spool" | "filament", id: string) => `${type}:${id}`; + const parsePresetValue = (value?: string): { type: "spool" | "filament"; id: string } | undefined => { + if (!value) return undefined; + const separatorIndex = value.indexOf(":"); + if (separatorIndex < 0) return { type: currentPresetType, id: value }; + const type = value.slice(0, separatorIndex); + const id = value.slice(separatorIndex + 1); + if ((type === currentPresetType || type === otherPresetType) && id) { + return { type, id }; + } + return undefined; + }; + const baseUrlSetting = useGetSetting("base_url"); + // Accept both JSON-backed settings and legacy plain strings so old `base_url` values do not crash the dialog. + const baseUrlRoot = getConfiguredBaseUrl(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<string | undefined>( + "selectedPresetFilament", + undefined, + ); + + const [localCurrentPresets, setLocalCurrentPresets] = useState<SpoolQRCodePrintSettings[] | undefined>(); + const remoteCurrentPresets = useGetPrintPresets("print_presets_filament"); + const remoteOtherPresets = useGetPrintPresets("print_presets"); + const setRemotePresets = useSetPrintPresets("print_presets_filament"); + + const currentPresets = localCurrentPresets ?? remoteCurrentPresets; + const otherPresets = remoteOtherPresets ?? []; + + const savePresetsRemote = async () => { + if (!localCurrentPresets) return; + await setRemotePresets(localCurrentPresets); + }; + + useEffect(() => { + // Keep the saved local list active until the refetched settings catch up, otherwise the + // selector can briefly fall back to the default preset immediately after save. + if (!localCurrentPresets || !remoteCurrentPresets) return; + if (JSON.stringify(localCurrentPresets) === JSON.stringify(remoteCurrentPresets)) { + setLocalCurrentPresets(undefined); + } + }, [localCurrentPresets, remoteCurrentPresets]); + + const addNewPreset = () => { + if (!currentPresets) return; + const newId = uuidv4(); + const newPreset = buildNewPreset(newId, t("printing.generic.newSetting")); + setLocalCurrentPresets([...currentPresets, newPreset]); + setSelectedPresetState(toPresetValue(currentPresetType, newId)); + return newPreset; + }; + const promotePresetToCurrentType = (preset: SpoolQRCodePrintSettings): SpoolQRCodePrintSettings | undefined => { + if (!currentPresets) return; + const baseName = (preset.labelSettings.printSettings?.name ?? defaultPresetName).trim() || defaultPresetName; + const promotedName = getNextPresetName(`${baseName} ${importedPresetSuffix}`, currentPresets); + const promotedPreset = buildNewPreset(uuidv4(), promotedName, preset); + setLocalCurrentPresets([...currentPresets, promotedPreset]); + setSelectedPresetState(toPresetValue(currentPresetType, promotedPreset.labelSettings.printSettings.id)); + return promotedPreset; + }; + const duplicateCurrentPreset = () => { + if (!currentPresets) return; + const newPreset = { + ...curPreset, + labelSettings: { ...curPreset.labelSettings, printSettings: { ...curPreset.labelSettings.printSettings } }, + }; + newPreset.labelSettings.printSettings.id = uuidv4(); + setLocalCurrentPresets([...currentPresets, newPreset]); + setSelectedPresetState(toPresetValue(currentPresetType, newPreset.labelSettings.printSettings.id)); + }; + const updateCurrentPreset = (newSettings: SpoolQRCodePrintSettings) => { + if (!currentPresets) return; + const parsed = parsePresetValue(selectedPresetState); + if (!parsed || parsed.type !== currentPresetType) { + promotePresetToCurrentType(newSettings); + return; + } + setLocalCurrentPresets( + currentPresets.map((presets) => (presets.labelSettings.printSettings.id === parsed.id ? newSettings : presets)), + ); + }; + const deleteCurrentPreset = () => { + if (!currentPresets) return; + const parsed = parsePresetValue(selectedPresetState); + if (!parsed || parsed.type !== currentPresetType) return; + setLocalCurrentPresets(currentPresets.filter((qPreset) => qPreset.labelSettings.printSettings.id !== parsed.id)); + setSelectedPresetState(undefined); + }; + + let curPreset: SpoolQRCodePrintSettings; + if (currentPresets === undefined) { + curPreset = { + labelSettings: { + printSettings: { + id: "TEMP", + name: t("printing.generic.newSetting"), + }, + }, + }; + } else { + if (currentPresets.length === 0) { + const newSetting = addNewPreset(); + if (!newSetting) { + console.error("Error adding new setting, this should never happen"); + return; + } + currentPresets.push(newSetting); + curPreset = newSetting; + } else { + const parsedSelectedPreset = parsePresetValue(selectedPresetState); + if (!parsedSelectedPreset) { + curPreset = currentPresets[0]; + setSelectedPresetState(toPresetValue(currentPresetType, currentPresets[0].labelSettings.printSettings.id)); + } else if (parsedSelectedPreset.type === otherPresetType) { + curPreset = currentPresets[0]; + setSelectedPresetState(toPresetValue(currentPresetType, currentPresets[0].labelSettings.printSettings.id)); + } else { + const foundSetting = currentPresets.find( + (settings) => settings.labelSettings.printSettings.id === parsedSelectedPreset.id, + ); + if (foundSetting) { + curPreset = foundSetting; + } else { + curPreset = currentPresets[0]; + setSelectedPresetState(toPresetValue(currentPresetType, currentPresets[0].labelSettings.printSettings.id)); + } + } + } + } + + const hasUnsavedChanges = + localCurrentPresets !== undefined && + JSON.stringify(localCurrentPresets) !== JSON.stringify(remoteCurrentPresets ?? []); + + const [templateHelpOpen, setTemplateHelpOpen] = useState(false); + const titleTemplate = curPreset.titleTemplate ?? `==**{name}**== {color_hex}`; + const infoTemplate = + curPreset.template ?? + `{material} ({article_number}) +{Diameter: {diameter} mm} +{Weight: {weight} g} +{Spool Weight: {spool_weight} g} +{ET: {settings_extruder_temp} °C} +{BT: {settings_bed_temp} °C} +{{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} + <QRCodePrintingDialog + printSettings={curPreset.labelSettings} + setPrintSettings={(newSettings) => { + curPreset.labelSettings = newSettings; + updateCurrentPreset(curPreset); + }} + baseUrlRoot={baseUrlRoot} + useHTTPUrl={useHTTPUrl} + setUseHTTPUrl={setUseHTTPUrl} + previewValues={{ + default: "WEB+SPOOLMAN:F-{id}", + url: `${baseUrlRoot}/filament/show/{id}`, + }} + extraSettingsStart={ + <> + <Form.Item label={t("printing.generic.filamentPrintPresets")}> + <Flex gap={8}> + <Select + value={ + selectedPresetState + ? selectedPresetState.includes(":") + ? selectedPresetState + : toPresetValue(currentPresetType, selectedPresetState) + : undefined + } + onChange={(value) => { + const parsed = parsePresetValue(value); + if (!parsed) return; + if (parsed.type === otherPresetType) { + const sourcePreset = otherPresets.find( + (settings) => settings.labelSettings.printSettings.id === parsed.id, + ); + if (sourcePreset) { + promotePresetToCurrentType(sourcePreset); + } + return; + } + setSelectedPresetState(value); + }} + options={ + currentPresets + ? [ + { + label: t("printing.generic.filamentPrintPresets"), + options: currentPresets.map((settings) => ({ + label: + settings.labelSettings.printSettings?.name || t("printing.generic.defaultSettings"), + value: toPresetValue(currentPresetType, settings.labelSettings.printSettings.id), + })), + }, + { + label: t("printing.generic.spoolPrintPresets"), + options: otherPresets.map((settings) => ({ + label: + settings.labelSettings.printSettings?.name || t("printing.generic.defaultSettings"), + value: toPresetValue(otherPresetType, settings.labelSettings.printSettings.id), + })), + }, + ] + : [] + } + ></Select> + <Button + style={{ width: "3em" }} + icon={<PlusOutlined />} + title={t("printing.generic.addSettings")} + onClick={addNewPreset} + /> + <Button + style={{ width: "3em" }} + icon={<CopyOutlined />} + title={t("printing.generic.duplicateSettings")} + onClick={duplicateCurrentPreset} + /> + {currentPresets && currentPresets.length > 1 && ( + <Popconfirm + title={t("printing.generic.deleteSettings")} + description={t("printing.generic.deleteSettingsConfirm")} + onConfirm={deleteCurrentPreset} + okText={t("buttons.delete")} + cancelText={t("buttons.cancel")} + > + <Button + style={{ width: "3em" }} + danger + icon={<DeleteOutlined />} + title={t("printing.generic.deleteSettings")} + /> + </Popconfirm> + )} + </Flex> + </Form.Item> + <Form.Item label={t("printing.generic.settingsName")}> + <Input + value={curPreset.labelSettings.printSettings?.name} + onChange={(e) => { + curPreset.labelSettings.printSettings.name = e.target.value; + updateCurrentPreset(curPreset); + }} + /> + <div style={{ minHeight: 22, paddingTop: 4 }}> + {hasUnsavedChanges && <Text type="danger">Unsaved Preset Changes</Text>} + </div> + </Form.Item> + </> + } + items={items.map((filament) => ({ + value: useHTTPUrl ? `${baseUrlRoot}/filament/show/${filament.id}` : `WEB+SPOOLMAN:F-${filament.id}`, + amlName: `filament-${filament.id}`, + vendor: filament.vendor, + title: <>{renderLabelContents(titleTemplate, filament)}</>, + label: <>{renderLabelContents(infoTemplate, filament)}</>, + errorLevel: "H", + }))} + extraTitleSettings={ + <Form.Item + label={t("printing.qrcode.titleTemplate")} + tooltip={t("printing.qrcode.titleTemplateTooltipFilament")} + > + <TextArea + value={titleTemplate} + rows={4} + onChange={(newValue) => { + curPreset.titleTemplate = newValue.target.value; + updateCurrentPreset(curPreset); + }} + /> + </Form.Item> + } + extraInfoSettings={ + <> + <Form.Item label={t("printing.qrcode.infoTemplate")}> + <TextArea + value={infoTemplate} + rows={8} + onChange={(newValue) => { + curPreset.template = newValue.target.value; + updateCurrentPreset(curPreset); + }} + /> + </Form.Item> + <Modal open={templateHelpOpen} footer={null} onCancel={() => setTemplateHelpOpen(false)}> + <Table + size="small" + showHeader={false} + pagination={false} + scroll={{ y: 400 }} + columns={[{ dataIndex: "tag" }]} + dataSource={templateTags} + /> + </Modal> + <Text type="secondary"> + {t("printing.qrcode.templateHelpFilament")}{" "} + <Button size="small" onClick={() => setTemplateHelpOpen(true)}> + {t("actions.show")} + </Button> + </Text> + </> + } + extraButtons={ + <> + <Button + type="primary" + size="large" + icon={<SaveOutlined />} + onClick={async () => { + try { + await savePresetsRemote(); + messageApi.success(t("notifications.saveSuccessful")); + } catch (error) { + messageApi.error(error instanceof Error ? error.message : "Save failed"); + } + }} + > + {t("printing.generic.saveSetting")} + </Button> + </> + } + /> + </> + ); +}; + +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..a2f8baf6a --- /dev/null +++ b/client/src/pages/printing/filamentSelectModal.tsx @@ -0,0 +1,356 @@ +import { useTable } from "@refinedev/antd"; +import { Button, Checkbox, Col, Input, message, Pagination, Row, Table } from "antd"; +import { t } from "i18next"; +import { useCallback, useEffect, useMemo, useRef, 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: (selectedFilamentIds: number[]) => void; + searchPlaceholder?: string; +} + +interface IFilamentCollapsed extends IFilament { + "vendor.name": string | null; +} + +// Flatten vendor name into each row so shared table helpers can sort and filter it like a top-level field. +function collapseFilament(element: IFilament): IFilamentCollapsed { + return { ...element, "vendor.name": element.vendor?.name ?? null }; +} + +const MIN_TABLE_SCROLL_Y = 180; +const TABLE_BOTTOM_GAP = 16; + +// Combine server-side paging with lightweight local selection so the print flow can stay inside one dialog. +const FilamentSelectModal = ({ description, initialSelectedIds, onExport, onPrint, searchPlaceholder }: Props) => { + const [selectedItems, setSelectedItems] = useState<number[]>(initialSelectedIds ?? []); + const [messageApi, contextHolder] = message.useMessage(); + const navigate = useNavigate(); + const [tableScrollY, setTableScrollY] = useState<number>(300); + const [searchTerm, setSearchTerm] = useState(""); + const [debouncedSearch, setDebouncedSearch] = useState(""); + const rootRef = useRef<HTMLDivElement | null>(null); + const tableContainerRef = useRef<HTMLDivElement | null>(null); + + const { tableProps, sorters, filters, setFilters, currentPage, pageSize, setCurrentPage, setPageSize } = + useTable<IFilamentCollapsed>({ + resource: "filament", + meta: { + queryParams: { + ...(debouncedSearch.length > 0 ? { search: debouncedSearch } : {}), + }, + }, + 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, pageSize }, + }; + + const dataSource = [...(tableProps.dataSource ?? [])]; + const selectedSet = useMemo(() => new Set(selectedItems), [selectedItems]); + const paginationTotal = tableProps.pagination ? (tableProps.pagination.total ?? 0) : 0; + + useEffect(() => { + const computeScrollHeight = () => { + if (!tableContainerRef.current) { + return; + } + // Recompute against the current viewport so the table can fill the dialog without introducing a second pager row. + const viewportHeight = window.visualViewport?.height ?? window.innerHeight; + const tableTop = tableContainerRef.current.getBoundingClientRect().top; + const availableHeight = Math.floor(viewportHeight - tableTop - TABLE_BOTTOM_GAP); + setTableScrollY(Math.max(MIN_TABLE_SCROLL_Y, availableHeight)); + }; + + computeScrollHeight(); + + const onViewportResize = () => computeScrollHeight(); + window.addEventListener("resize", onViewportResize); + window.addEventListener("orientationchange", onViewportResize); + window.visualViewport?.addEventListener("resize", onViewportResize); + window.visualViewport?.addEventListener("scroll", onViewportResize); + + const resizeObserver = + typeof ResizeObserver !== "undefined" ? new ResizeObserver(() => computeScrollHeight()) : undefined; + if (resizeObserver && rootRef.current) { + resizeObserver.observe(rootRef.current); + } + + return () => { + window.removeEventListener("resize", onViewportResize); + window.removeEventListener("orientationchange", onViewportResize); + window.visualViewport?.removeEventListener("resize", onViewportResize); + window.visualViewport?.removeEventListener("scroll", onViewportResize); + resizeObserver?.disconnect(); + }; + }, []); + const handlePageChange = useCallback( + (page: number, nextPageSize?: number) => { + if (typeof nextPageSize === "number" && nextPageSize !== pageSize) { + setPageSize(nextPageSize); + } + setCurrentPage(page); + }, + [pageSize], + ); + const handlePageSizeChange = useCallback((_current: number, size: number) => { + setPageSize(size); + setCurrentPage(1); + }, []); + + // Debounce search input to avoid excessive API calls while typing + useEffect(() => { + const timer = setTimeout(() => { + setDebouncedSearch(searchTerm.trim()); + setCurrentPage(1); + }, 300); + return () => clearTimeout(timer); + }, [searchTerm, setCurrentPage]); + + // Bulk toggles only touch the rows currently visible after paging and server-side filtering. + const selectUnselectFiltered = useCallback( + (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); + }); + }, + [dataSource], + ); + + const handleSelectItem = useCallback((item: number) => { + setSelectedItems((prevSelected) => + prevSelected.includes(item) ? prevSelected.filter((selected) => selected !== item) : [...prevSelected, item], + ); + }, []); + + const isAllFilteredSelected = dataSource.length > 0 && dataSource.every((filament) => selectedSet.has(filament.id)); + const isSomeButNotAllFilteredSelected = + dataSource.some((filament) => selectedSet.has(filament.id)) && !isAllFilteredSelected; + + const commonProps = { + t, + navigate, + actions: () => [], + dataSource, + tableState, + sorter: true, + }; + + const resolvedDescription = + description ?? + t("printing.filamentSelect.description", { + defaultValue: "Search for and select filament labels to print:", + }); + const resolvedSearchPlaceholder = + searchPlaceholder ?? + t("printing.filamentSelect.searchPlaceholder", { + defaultValue: "Search by filament ID, vendor, name, or material", + }); + + return ( + <> + {contextHolder} + <div ref={rootRef} style={{ width: "100%", display: "flex", flexDirection: "column", height: "100%" }}> + {(resolvedDescription || tableProps.pagination) && ( + <Row gutter={[12, 8]} align="middle" style={{ marginBottom: 8 }}> + <Col flex="auto">{resolvedDescription && <div style={{ margin: 0 }}>{resolvedDescription}</div>}</Col> + {tableProps.pagination && ( + <Col flex="none"> + <Pagination + size="small" + current={currentPage} + pageSize={pageSize} + total={paginationTotal} + showSizeChanger + pageSizeOptions={["10", "20", "50", "100"]} + showQuickJumper + onChange={handlePageChange} + onShowSizeChange={handlePageSizeChange} + /> + </Col> + )} + </Row> + )} + <Row gutter={[12, 8]} style={{ marginBottom: 8 }}> + <Col xs={24} md={12}> + <Input.Search + placeholder={resolvedSearchPlaceholder} + value={searchTerm} + allowClear + enterButton + onChange={(event) => { + setSearchTerm(event.target.value); + }} + onSearch={(value) => { + setSearchTerm(value); + }} + /> + </Col> + </Row> + <Row gutter={[12, 12]} align="middle" style={{ marginBottom: 8 }}> + <Col flex="none"> + <Button + onClick={() => { + setSearchTerm(""); + setFilters([], "replace"); + setCurrentPage(1); + }} + > + {t("buttons.clearFilters")} + </Button> + </Col> + <Col flex="auto"> + <div + style={{ + display: "flex", + justifyContent: "flex-end", + alignItems: "center", + gap: 12, + flexWrap: "wrap", + }} + > + <Checkbox + checked={isAllFilteredSelected} + indeterminate={isSomeButNotAllFilteredSelected} + onChange={(e) => { + selectUnselectFiltered(e.target.checked); + }} + > + {t("printing.filamentSelect.selectAll")} + </Checkbox> + <div style={{ minWidth: 140, textAlign: "right" }}> + {t("printing.filamentSelect.selectedTotal", { + count: selectedItems.length, + })} + </div> + <Button + type="primary" + onClick={() => { + if (selectedItems.length === 0) { + messageApi.open({ + type: "error", + content: t("printing.filamentSelect.noFilamentsSelected"), + }); + return; + } + onPrint(selectedItems); + }} + > + {t("printing.qrcode.button")} + </Button> + {onExport && ( + <Button + type="primary" + onClick={() => { + if (selectedItems.length === 0) { + messageApi.open({ + type: "error", + content: t("printing.filamentSelect.noFilamentsSelected"), + }); + return; + } + onExport(selectedItems); + }} + > + {t("printing.qrcode.exportButton")} + </Button> + )} + </div> + </Col> + </Row> + <div ref={tableContainerRef} style={{ flex: 1, minHeight: 0 }}> + <Table + {...tableProps} + rowKey="id" + tableLayout="fixed" + pagination={false} + dataSource={dataSource} + scroll={{ y: tableScrollY, x: "max-content" }} + columns={removeUndefined([ + { + width: 48, + render: (_, item: IFilament) => ( + <Checkbox checked={selectedSet.has(item.id)} onChange={() => handleSelectItem(item.id)} /> + ), + }, + SortedColumn({ + ...commonProps, + id: "id", + i18ncat: "filament", + width: 70, + }), + FilteredQueryColumn({ + ...commonProps, + id: "vendor.name", + i18nkey: "filament.fields.vendor_name", + filterValueQuery: useSpoolmanVendors(), + width: 180, + }), + SpoolIconColumn({ + ...commonProps, + id: "name", + i18ncat: "filament", + width: 320, + 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", + filterValueQuery: useSpoolmanMaterials(), + width: 140, + }), + ])} + /> + </div> + </div> + </> + ); +}; + +export default FilamentSelectModal; diff --git a/client/src/pages/printing/index.tsx b/client/src/pages/printing/index.tsx index f6ddc4246..4760c3735 100644 --- a/client/src/pages/printing/index.tsx +++ b/client/src/pages/printing/index.tsx @@ -2,29 +2,39 @@ 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 ( <> <PageHeader - title={t("printing.qrcode.button")} + title={t("printing.qrcode.printSpoolTitle")} onBack={() => { const returnUrl = searchParams.get("return"); if (returnUrl) { @@ -47,21 +57,7 @@ export const Printing = () => { lineHeight: 1.5, }} > - {step === 0 && ( - <SpoolSelectModal - description={t("printing.spoolSelect.description")} - onContinue={(spools) => { - 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 && <SpoolQRCodePrintingDialog spoolIds={spoolIds} />} + {spoolIds.length > 0 && <SpoolQRCodePrintingDialog spoolIds={spoolIds} />} </Content> </PageHeader> </> diff --git a/client/src/pages/printing/logoLabelBlock.tsx b/client/src/pages/printing/logoLabelBlock.tsx new file mode 100644 index 000000000..13a77cfd1 --- /dev/null +++ b/client/src/pages/printing/logoLabelBlock.tsx @@ -0,0 +1,62 @@ +import { ReactElement } from "react"; +import VendorLogo from "../../components/vendorLogo"; +import { IVendor } from "../vendors/model"; + +interface LogoLabelBlockProps { + vendor?: IVendor; + label: ReactElement; +} + +const LogoLabelBlock = ({ vendor, label }: LogoLabelBlockProps) => { + return ( + <div + style={{ + display: "flex", + flexDirection: "column", + width: "100%", + height: "100%", + padding: "1mm 1mm 1mm 0", + boxSizing: "border-box", + }} + > + <div + style={{ + minHeight: "6mm", + maxHeight: "6mm", + display: "flex", + alignItems: "center", + overflow: "hidden", + marginBottom: "0.5mm", + }} + > + <VendorLogo + vendor={vendor} + usePrintLogo + showFallbackText + imgStyle={{ + display: "block", + maxWidth: "100%", + maxHeight: "100%", + objectFit: "contain", + objectPosition: "left center", + }} + fallbackStyle={{ + // Label previews render on a white canvas, so fallback text must stay dark + // even when the surrounding application theme is dark. + color: "#000", + fontWeight: 700, + fontSize: "3.2mm", + lineHeight: 1, + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "nowrap", + width: "100%", + }} + /> + </div> + <div style={{ flex: 1, minHeight: 0, overflow: "hidden" }}>{label}</div> + </div> + ); +}; + +export default LogoLabelBlock; diff --git a/client/src/pages/printing/printing.tsx b/client/src/pages/printing/printing.tsx index 59b47895f..f7941fafd 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,26 +15,87 @@ 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 { showContent?: boolean; showQRCodeMode?: "no" | "simple" | "withIcon"; textSize?: number; + showManufacturerLogo?: boolean; + logoSource?: "print" | "color"; + logoHeightMm?: number; + logoAlign?: "left" | "center" | "right"; + showTitle?: boolean; + titleAreaHeightMm?: number; // Legacy field; no longer used. + titleTextSize?: number; // Legacy field; migrated to titleMaxTextSize. + titleMaxTextSize?: number; + titleFitToWidth?: boolean; + titleAlign?: "left" | "center" | "right"; + qrCodeSizeMm?: number; + qrCodePosition?: "left" | "right"; + qrCodeAlign?: "top" | "center" | "bottom"; + infoAlign?: "left" | "center" | "right"; + infoVerticalAlign?: "top" | "center" | "bottom"; printSettings: PrintSettings; } export interface SpoolQRCodePrintSettings { template?: string; + titleTemplate?: string; + filenameTemplate?: string; labelSettings: QRCodePrintSettings; } -export function useGetPrintSettings(): SpoolQRCodePrintSettings[] | undefined { - const { data } = useGetSetting("print_presets"); +// Merge shared defaults and saved presets without duplicating ids when multiple setting buckets are loaded together. +export function mergePrintPresets( + ...presetLists: Array<SpoolQRCodePrintSettings[] | undefined> +): SpoolQRCodePrintSettings[] | undefined { + const merged: SpoolQRCodePrintSettings[] = []; + const seenIds = new Set<string>(); + const hasUnloadedList = presetLists.some((list) => list === undefined); + + for (const list of presetLists) { + if (!list) continue; + for (const preset of list) { + const id = preset.labelSettings?.printSettings?.id; + if (!id || seenIds.has(id)) continue; + seenIds.add(id); + merged.push(preset); + } + } + + if (merged.length === 0 && hasUnloadedList) { + return undefined; + } + + return merged; +} + +export function getConfiguredBaseUrl(rawValue: string | undefined, fallback: string): string { + if (rawValue === undefined) { + return fallback; + } + + try { + const parsed = JSON.parse(rawValue); + return typeof parsed === "string" && parsed.trim() !== "" ? parsed : fallback; + } catch { + const trimmed = rawValue.trim(); + return trimmed !== "" ? trimmed : fallback; + } +} + +// Load saved print presets and backfill missing ids so older settings remain selectable in the current UI. +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[]); - // Loop through all parsed and generate a new ID field if it's not set + // Older presets did not store ids; generate them lazily so the editor can still target each entry. return parsed.map((settings) => { if (!settings.labelSettings.printSettings.id) { settings.labelSettings.printSettings.id = uuidv4(); @@ -44,35 +104,31 @@ 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); }; } -interface GenericObject { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - [key: string]: any; - extra: { [key: string]: string }; -} - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -function getTagValue(tag: string, obj: GenericObject): any { - // Split tag by . +// Resolve dot-path placeholders, including JSON-backed extra fields, for title/label/filename templates. +function getTagValue(tag: string, obj: object): unknown { + const record = obj as { [key: string]: unknown; extra?: { [key: string]: string } }; const tagParts = tag.split("."); if (tagParts[0] === "extra") { - const extraValue = obj.extra[tagParts[1]]; + const extraValue = record.extra?.[tagParts[1]]; if (extraValue === undefined) { return "?"; } return JSON.parse(extraValue); } - const value = obj[tagParts[0]] ?? "?"; - // check if value is itself an object. If so, recursively call this and remove the first part of the tag - if (typeof value === "object") { + const value = record[tagParts[0]] ?? "?"; + // Nested relations reuse the same lookup rules so templates can walk into vendor and filament fields. + if (typeof value === "object" && value !== null) { return getTagValue(tagParts.slice(1).join("."), value); } return value; @@ -88,40 +144,66 @@ function applyNewline(text: string): ReactElement[] { } function applyTextFormatting(text: string): ReactElement[] { - const regex = /\*\*([\w\W]*?)\*\*/g; + // Supports **bold** and ==inverted== blocks (can be mixed in one template). + const regex = /(\*\*[\w\W]*?\*\*|==[\w\W]*?==)/g; const parts = text.split(regex); - // Map over the parts and wrap matched text with <b> tags const elements = parts.map((part, index) => { - // Even index: outside asterisks, odd index: inside asterisks (to be bolded) + if (part.startsWith("**") && part.endsWith("**")) { + const content = part.slice(2, -2); + const node = applyTextFormatting(content); + return <b key={index}>{node}</b>; + } + + if (part.startsWith("==") && part.endsWith("==")) { + const content = part.slice(2, -2); + const node = applyTextFormatting(content); + return ( + <span + key={index} + style={{ + backgroundColor: "#000", + color: "#fff", + padding: "0 0.6mm", + display: "inline-block", + }} + > + {node} + </span> + ); + } + const node = applyNewline(part); - return index % 2 === 0 ? <span key={index}>{node}</span> : <b key={index}>{node}</b>; + return <span key={index}>{node}</span>; }); return elements; } -export function renderLabelContents(template: string, spool: ISpool): ReactElement { - // Find all {tags} in the template string and loop over them +// Expand optional sections and scalar tags into plain text before the print/export renderers apply styling. +export function renderTemplateText(template: string, obj: object): string { 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] + String(tagValue) + structure[3]); } } } }); + return renderedText; +} - // Split string on \n into individual lines - return <>{applyTextFormatting(label_text)}</>; +export function renderLabelContents(template: string, obj: object): ReactElement { + const renderedText = renderTemplateText(template, obj); + return <>{applyTextFormatting(renderedText)}</>; } diff --git a/client/src/pages/printing/printingDialog.tsx b/client/src/pages/printing/printingDialog.tsx index 02b8019a7..cb6cbb696 100644 --- a/client/src/pages/printing/printingDialog.tsx +++ b/client/src/pages/printing/printingDialog.tsx @@ -178,13 +178,26 @@ const PrintingDialog = ({ ); }); - const saveAsImage = () => { + const getPrintItems = () => { + const root = contentRef.current ?? document; + return Array.from(root.getElementsByClassName("print-qrcode-item")); + }; + + const saveAsImage = async () => { const hasPrinted: Element[] = []; + const items = getPrintItems(); - Array.from(document.getElementsByClassName("print-qrcode-item")).forEach(async (item) => { + for (const item of items) { // Prevent printing copies + let isDuplicate = false; for (let i = 0; i < hasPrinted.length; i += 1) { - if (item.isEqualNode(hasPrinted[i])) return; + if (item.isEqualNode(hasPrinted[i])) { + isDuplicate = true; + break; + } + } + if (isDuplicate) { + continue; } hasPrinted.push(item); @@ -199,7 +212,7 @@ const PrintingDialog = ({ link.href = url; link.download = "spoolmanlabel.png"; link.click(); - }); + } }; return ( diff --git a/client/src/pages/printing/qrCodeExportDialog.tsx b/client/src/pages/printing/qrCodeExportDialog.tsx new file mode 100644 index 000000000..91666a4f2 --- /dev/null +++ b/client/src/pages/printing/qrCodeExportDialog.tsx @@ -0,0 +1,221 @@ +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; + extraTitleSettings?: ReactElement; + extraInfoSettings?: ReactElement; + extraExportSettings?: ReactElement; + extraSettingsStart?: ReactElement; + extraButtons?: ReactElement; + baseUrlRoot: string; + useHTTPUrl: boolean; + setUseHTTPUrl: (value: boolean) => void; + previewValues?: { default: string; url: string }; + zipFileTypeName: string; +} + +// Wrap the generic export layout with QR-specific controls so spool and filament +// export flows can share one renderer without forking the export pipeline. +const QRCodeExportDialog = ({ + items, + printSettings, + setPrintSettings, + extraSettings, + extraTitleSettings, + extraInfoSettings, + extraExportSettings, + extraSettingsStart, + extraButtons, + baseUrlRoot, + useHTTPUrl, + setUseHTTPUrl, + previewValues, + zipFileTypeName, +}: QRCodeExportDialogProps) => { + const t = useTranslate(); + const updateQRCodeSettings = (updates: Partial<QRCodePrintSettings>) => { + setPrintSettings({ + ...printSettings, + ...updates, + }); + }; + + 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); + + // ExportDialog captures each `.print-qrcode-item` into its own file, so attach the + // rendered label body and export filename metadata at this level. + const elements = items.map((item, idx) => { + return ( + <div className="print-qrcode-item" key={idx} data-aml-name={item.amlName ?? ""}> + {showQRCodeMode !== "no" && ( + <div className="print-qrcode-container"> + <QRCode + className="print-qrcode" + icon={showQRCodeMode === "withIcon" ? getBasePath() + "/favicon.svg" : undefined} + value={item.value} + errorLevel={item.errorLevel} + type="svg" + color="#000" + /> + </div> + )} + {showContent && ( + <div className="print-qrcode-title" style={showQRCodeMode === "no" ? { paddingLeft: "1mm" } : {}}> + {item.label ?? item.value} + </div> + )} + </div> + ); + }); + + return ( + <ExportDialog + items={elements} + printSettings={printSettings.printSettings} + setPrintSettings={(newSettings) => { + updateQRCodeSettings({ printSettings: newSettings }); + }} + extraButtons={extraButtons} + extraFormatSettings={extraExportSettings} + zipFileTypeName={zipFileTypeName} + extraSettingsStart={extraSettingsStart} + extraSettings={ + <> + <Form.Item label={t("printing.qrcode.showQRCode")}> + <Radio.Group + options={[ + { label: t("printing.qrcode.showQRCodeMode.no"), value: "no" }, + { + label: t("printing.qrcode.showQRCodeMode.simple"), + value: "simple", + }, + { label: t("printing.qrcode.showQRCodeMode.withIcon"), value: "withIcon" }, + ]} + onChange={(e: RadioChangeEvent) => { + updateQRCodeSettings({ showQRCodeMode: e.target.value }); + }} + value={showQRCodeMode} + optionType="button" + buttonStyle="solid" + /> + </Form.Item> + {showQRCodeMode !== "no" && ( + <> + <Form.Item + label={t("printing.qrcode.useHTTPUrl.label")} + tooltip={t("printing.qrcode.useHTTPUrl.tooltip")} + style={{ marginBottom: 0 }} + > + <Radio.Group onChange={(e) => setUseHTTPUrl(e.target.value)} value={useHTTPUrl}> + <Radio value={false}>{t("printing.qrcode.useHTTPUrl.options.default")}</Radio> + <Radio value={true}>{t("printing.qrcode.useHTTPUrl.options.url")}</Radio> + </Radio.Group> + </Form.Item> + <Form.Item label={t("printing.qrcode.useHTTPUrl.preview")}> + <Text> {useHTTPUrl ? preview.url : preview.default}</Text> + </Form.Item> + </> + )} + <Form.Item label={t("printing.qrcode.showContent")}> + <Switch + checked={showContent} + onChange={(checked) => { + updateQRCodeSettings({ showContent: checked }); + }} + /> + </Form.Item> + <Form.Item label={t("printing.qrcode.textSize")}> + <Row> + <Col span={12}> + <Slider + disabled={!showContent} + tooltip={{ formatter: (value) => `${value} mm` }} + min={2} + max={7} + value={textSize} + step={0.1} + onChange={(value) => { + updateQRCodeSettings({ textSize: value }); + }} + /> + </Col> + <Col span={12}> + <InputNumber + disabled={!showContent} + min={0.01} + step={0.1} + style={{ margin: "0 16px" }} + value={textSize} + addonAfter="mm" + onChange={(value) => { + updateQRCodeSettings({ textSize: value ?? 5 }); + }} + /> + </Col> + </Row> + </Form.Item> + + {extraTitleSettings} + {extraInfoSettings} + {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..608730abf 100644 --- a/client/src/pages/printing/qrCodePrintingDialog.tsx +++ b/client/src/pages/printing/qrCodePrintingDialog.tsx @@ -18,30 +18,41 @@ interface QRCodePrintingDialogProps { printSettings: QRCodePrintSettings; setPrintSettings: (setPrintSettings: QRCodePrintSettings) => void; extraSettings?: ReactElement; + extraTitleSettings?: ReactElement; + extraInfoSettings?: ReactElement; extraSettingsStart?: ReactElement; extraButtons?: ReactElement; baseUrlRoot: string; useHTTPUrl: boolean; setUseHTTPUrl: (value: boolean) => void; + previewValues?: { default: string; url: string }; } +// Wrap the generic print-sheet layout with QR-specific controls so spool and filament +// print flows can share one renderer without forking the label layout logic. const QRCodePrintingDialog = ({ items, printSettings, setPrintSettings, extraSettings, + extraTitleSettings, + extraInfoSettings, extraSettingsStart, extraButtons, 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); + // Build the same per-label structure used by the export flow so print previews and + // exported files stay visually aligned. const elements = items.map((item, idx) => { return ( <div className="print-qrcode-item" key={idx}> @@ -110,7 +121,7 @@ const QRCodePrintingDialog = ({ </Radio.Group> </Form.Item> <Form.Item label={t("printing.qrcode.useHTTPUrl.preview")}> - <Text> {useHTTPUrl ? `${baseUrlRoot}/spool/show/{id}` : `WEB+SPOOLMAN:S-{id}`}</Text> + <Text> {useHTTPUrl ? preview.url : preview.default}</Text> </Form.Item> </> )} @@ -156,6 +167,8 @@ const QRCodePrintingDialog = ({ </Row> </Form.Item> + {extraTitleSettings} + {extraInfoSettings} {extraSettings} </> } diff --git a/client/src/pages/printing/spoolQrCodeExportDialog.tsx b/client/src/pages/printing/spoolQrCodeExportDialog.tsx new file mode 100644 index 000000000..c38b74b86 --- /dev/null +++ b/client/src/pages/printing/spoolQrCodeExportDialog.tsx @@ -0,0 +1,557 @@ +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 { useEffect, 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 { + getConfiguredBaseUrl, + SpoolQRCodePrintSettings, + renderLabelContents, + renderTemplateText, + useGetPrintSettings as useGetPrintPresets, + useSetPrintSettings as useSetPrintPresets, +} from "./printing"; +import QRCodeExportDialog from "./qrCodeExportDialog"; + +const { Text } = Typography; + +interface SpoolQRCodeExportDialog { + spoolIds: number[]; +} + +// Adapt spool records into the generic QR export dialog and keep export presets isolated from filament presets. +const SpoolQRCodeExportDialog = ({ spoolIds }: SpoolQRCodeExportDialog) => { + const t = useTranslate(); + const currentPresetType = "spool"; + const otherPresetType = "filament"; + const defaultPresetName = t("printing.generic.defaultSettings"); + const importedPresetSuffix = `(${otherPresetType} preset basis)`; + const isDefaultPresetName = (name?: string) => { + const normalizedName = (name ?? "").trim().toLowerCase(); + const normalizedDefault = defaultPresetName.trim().toLowerCase(); + return normalizedName === normalizedDefault || normalizedName === "default"; + }; + const escapeRegExp = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const getNextPresetName = (baseName: string, presets: SpoolQRCodePrintSettings[]) => { + const trimmedBaseName = baseName.trim() || defaultPresetName; + const normalizedBaseName = trimmedBaseName.replace(/-\d{2}$/u, ""); + const suffixPattern = new RegExp(`^${escapeRegExp(normalizedBaseName)}-(\\d{2})$`, "u"); + let maxSuffix = 0; + for (const preset of presets) { + const presetName = (preset.labelSettings.printSettings?.name ?? "").trim(); + const match = presetName.match(suffixPattern); + if (!match) continue; + maxSuffix = Math.max(maxSuffix, Number.parseInt(match[1], 10)); + } + return `${normalizedBaseName}-${String(maxSuffix + 1).padStart(2, "0")}`; + }; + const buildNewPreset = ( + id: string, + name: string, + sourcePreset?: SpoolQRCodePrintSettings, + ): SpoolQRCodePrintSettings => { + const copiedSourcePrintSettings = sourcePreset?.labelSettings?.printSettings ?? {}; + return { + ...sourcePreset, + labelSettings: { + ...sourcePreset?.labelSettings, + printSettings: { + ...copiedSourcePrintSettings, + id, + name, + }, + }, + }; + }; + + const toPresetValue = (type: "spool" | "filament", id: string) => `${type}:${id}`; + const parsePresetValue = (value?: string): { type: "spool" | "filament"; id: string } | undefined => { + if (!value) return undefined; + const separatorIndex = value.indexOf(":"); + if (separatorIndex < 0) return { type: currentPresetType, id: value }; + const type = value.slice(0, separatorIndex); + const id = value.slice(separatorIndex + 1); + if ((type === currentPresetType || type === otherPresetType) && id) { + return { type, id }; + } + return undefined; + }; + + const baseUrlSetting = useGetSetting("base_url"); + // Accept both JSON-backed settings and legacy plain strings so old `base_url` values do not crash the dialog. + const baseUrlRoot = getConfiguredBaseUrl(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<string | undefined>( + "selectedImagePresetSpool", + undefined, + ); + + const [localCurrentPresets, setLocalCurrentPresets] = useState<SpoolQRCodePrintSettings[] | undefined>(); + const remoteSpoolPresets = useGetPrintPresets("image_presets"); + const remoteFilamentPresets = useGetPrintPresets("image_presets_filament"); + const setRemoteSpoolPresets = useSetPrintPresets("image_presets"); + + const currentPresets = localCurrentPresets ?? remoteSpoolPresets; + const otherPresets = remoteFilamentPresets ?? []; + + // Keep edits local until the user explicitly saves so imported filament presets can be tried without persistence. + const savePresetsRemote = async () => { + if (!localCurrentPresets) return; + await setRemoteSpoolPresets(localCurrentPresets); + }; + + useEffect(() => { + // Keep the saved local list active until the refetched settings catch up, otherwise the + // selector can briefly fall back to the default preset immediately after save. + if (!localCurrentPresets || !remoteSpoolPresets) return; + if (JSON.stringify(localCurrentPresets) === JSON.stringify(remoteSpoolPresets)) { + setLocalCurrentPresets(undefined); + } + }, [localCurrentPresets, remoteSpoolPresets]); + + const getSelectedPreset = () => { + const parsed = parsePresetValue(selectedPresetState); + if (!parsed) return undefined; + if (parsed.type === currentPresetType) { + return currentPresets?.find((settings) => settings.labelSettings.printSettings.id === parsed.id); + } + return otherPresets.find((settings) => settings.labelSettings.printSettings.id === parsed.id); + }; + + const promotePresetToCurrentType = (preset: SpoolQRCodePrintSettings): SpoolQRCodePrintSettings | undefined => { + if (!currentPresets) return; + // Imported filament presets become spool-owned copies immediately so later edits never touch the source preset. + const baseName = (preset.labelSettings.printSettings?.name ?? defaultPresetName).trim() || defaultPresetName; + const promotedName = getNextPresetName(`${baseName} ${importedPresetSuffix}`, currentPresets); + const promotedPreset: SpoolQRCodePrintSettings = { + ...buildNewPreset(uuidv4(), promotedName, preset), + }; + const nextPresets = [...currentPresets, promotedPreset]; + setLocalCurrentPresets(nextPresets); + setSelectedPresetState(toPresetValue(currentPresetType, promotedPreset.labelSettings.printSettings.id)); + return promotedPreset; + }; + + // New presets derive from the currently selected settings object so export variants start from what the user sees. + const addNewPreset = () => { + if (!currentPresets) return; + const newId = uuidv4(); + const selectedPreset = getSelectedPreset(); + const basePresetName = selectedPreset?.labelSettings.printSettings?.name ?? defaultPresetName; + const nextPresetName = getNextPresetName(basePresetName, currentPresets); + const newPreset = buildNewPreset(newId, nextPresetName, selectedPreset); + setLocalCurrentPresets([...currentPresets, newPreset]); + setSelectedPresetState(toPresetValue(currentPresetType, newId)); + return newPreset; + }; + // Duplicates get a fresh id so the cloned export preset can diverge from its source immediately. + const duplicateCurrentPreset = () => { + if (!currentPresets) return; + const newPreset = { + ...curPreset, + labelSettings: { ...curPreset.labelSettings, printSettings: { ...curPreset.labelSettings.printSettings } }, + }; + newPreset.labelSettings.printSettings.id = uuidv4(); + setLocalCurrentPresets([...currentPresets, newPreset]); + setSelectedPresetState(toPresetValue(currentPresetType, newPreset.labelSettings.printSettings.id)); + }; + // Edits to a filament-derived preset first promote it into the spool bucket before any persistence is possible. + const updateCurrentPreset = (newSettings: SpoolQRCodePrintSettings) => { + if (!currentPresets) return; + const parsed = parsePresetValue(selectedPresetState); + if (!parsed || parsed.type !== currentPresetType) { + promotePresetToCurrentType(newSettings); + return; + } + + let foundPreset = false; + const nextPresets = currentPresets.map((presets) => { + if (presets.labelSettings.printSettings.id === parsed.id) { + foundPreset = true; + return newSettings; + } + return presets; + }); + setLocalCurrentPresets(nextPresets); + if (!foundPreset) { + promotePresetToCurrentType(newSettings); + } + }; + const deleteCurrentPreset = () => { + if (!currentPresets) return; + const parsed = parsePresetValue(selectedPresetState); + if (!parsed || parsed.type !== currentPresetType) return; + setLocalCurrentPresets(currentPresets.filter((qPreset) => qPreset.labelSettings.printSettings.id !== parsed.id)); + setSelectedPresetState(undefined); + }; + + let curPreset: SpoolQRCodePrintSettings; + if (currentPresets === undefined) { + // Use a temporary preset while settings load so the export dialog can render immediately. + curPreset = { + labelSettings: { + printSettings: { + id: "TEMP", + name: defaultPresetName, + }, + }, + }; + } else { + if (currentPresets.length === 0) { + // Seed the spool bucket with one editable preset the first time export settings are opened. + const defaultId = uuidv4(); + const defaultPreset = buildNewPreset(defaultId, defaultPresetName); + setLocalCurrentPresets([defaultPreset]); + setSelectedPresetState(toPresetValue(currentPresetType, defaultId)); + curPreset = defaultPreset; + } else { + const parsedSelectedPreset = parsePresetValue(selectedPresetState); + if (parsedSelectedPreset && parsedSelectedPreset.type === otherPresetType) { + const preferredPreset = + currentPresets.find((settings) => isDefaultPresetName(settings.labelSettings.printSettings?.name)) ?? + currentPresets[0]; + curPreset = preferredPreset; + setSelectedPresetState(toPresetValue(currentPresetType, preferredPreset.labelSettings.printSettings.id)); + } else if (parsedSelectedPreset) { + const foundSetting = currentPresets.find( + (settings) => settings.labelSettings.printSettings.id === parsedSelectedPreset.id, + ); + if (foundSetting) { + curPreset = foundSetting; + } else { + const preferredPreset = + currentPresets.find((settings) => isDefaultPresetName(settings.labelSettings.printSettings?.name)) ?? + currentPresets[0]; + curPreset = preferredPreset; + setSelectedPresetState(toPresetValue(currentPresetType, preferredPreset.labelSettings.printSettings.id)); + } + } else { + const preferredPreset = + currentPresets.find((settings) => isDefaultPresetName(settings.labelSettings.printSettings?.name)) ?? + currentPresets[0]; + curPreset = preferredPreset; + setSelectedPresetState(toPresetValue(currentPresetType, preferredPreset.labelSettings.printSettings.id)); + } + } + } + + const hasUnsavedChanges = + localCurrentPresets !== undefined && + JSON.stringify(localCurrentPresets) !== JSON.stringify(remoteSpoolPresets ?? []); + + const [templateHelpOpen, setTemplateHelpOpen] = useState(false); + const titleTemplate = curPreset.titleTemplate ?? `==**{filament.name}**== {filament.color_hex}`; + const infoTemplate = + curPreset.template ?? + `{filament.material} ({filament.article_number}) +Spool ID: #{id} +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}` }); + }); + } + + // Expose spool, filament, and vendor placeholders because the same tag picker drives label text and export filenames. + const templateTags = [...spoolTags, ...filamentTags, ...vendorTags]; + + return ( + <> + {contextHolder} + <QRCodeExportDialog + printSettings={curPreset.labelSettings} + setPrintSettings={(newSettings) => { + updateCurrentPreset({ + ...curPreset, + labelSettings: newSettings, + }); + }} + baseUrlRoot={baseUrlRoot} + useHTTPUrl={useHTTPUrl} + setUseHTTPUrl={setUseHTTPUrl} + previewValues={{ + default: "WEB+SPOOLMAN:S-{id}", + url: `${baseUrlRoot}/spool/show/{id}`, + }} + zipFileTypeName="spool" + extraSettingsStart={ + <> + <Form.Item label={t("printing.generic.spoolImagePresets")}> + <Flex gap={8}> + <Select + value={ + selectedPresetState + ? selectedPresetState.includes(":") + ? selectedPresetState + : toPresetValue(currentPresetType, selectedPresetState) + : undefined + } + onChange={(value) => { + const parsed = parsePresetValue(value); + if (!parsed) return; + if (parsed.type === otherPresetType) { + const sourcePreset = otherPresets.find( + (settings) => settings.labelSettings.printSettings.id === parsed.id, + ); + if (sourcePreset) { + promotePresetToCurrentType(sourcePreset); + } + return; + } + setSelectedPresetState(value); + }} + options={ + currentPresets + ? [ + { + label: t("printing.generic.spoolImagePresets"), + options: currentPresets.map((settings) => ({ + label: + settings.labelSettings.printSettings?.name || t("printing.generic.defaultSettings"), + value: toPresetValue(currentPresetType, settings.labelSettings.printSettings.id), + })), + }, + { + label: t("printing.generic.filamentImagePresets"), + options: otherPresets.map((settings) => ({ + label: + settings.labelSettings.printSettings?.name || t("printing.generic.defaultSettings"), + value: toPresetValue(otherPresetType, settings.labelSettings.printSettings.id), + })), + }, + ] + : [] + } + ></Select> + <Button + style={{ width: "3em" }} + icon={<PlusOutlined />} + title={t("printing.generic.addSettings")} + onClick={addNewPreset} + /> + <Button + style={{ width: "3em" }} + icon={<CopyOutlined />} + title={t("printing.generic.duplicateSettings")} + onClick={duplicateCurrentPreset} + /> + {currentPresets && currentPresets.length > 1 && ( + <Popconfirm + title={t("printing.generic.deleteSettings")} + description={t("printing.generic.deleteSettingsConfirm")} + onConfirm={deleteCurrentPreset} + okText={t("buttons.delete")} + cancelText={t("buttons.cancel")} + > + <Button + style={{ width: "3em" }} + danger + icon={<DeleteOutlined />} + title={t("printing.generic.deleteSettings")} + /> + </Popconfirm> + )} + </Flex> + </Form.Item> + <Form.Item label={t("printing.generic.settingsName")}> + <Input + value={curPreset.labelSettings.printSettings?.name} + onChange={(e) => { + updateCurrentPreset({ + ...curPreset, + labelSettings: { + ...curPreset.labelSettings, + printSettings: { + ...curPreset.labelSettings.printSettings, + name: e.target.value, + }, + }, + }); + }} + /> + <div style={{ minHeight: 22, paddingTop: 4 }}> + {hasUnsavedChanges && <Text type="danger">Unsaved Preset Changes</Text>} + </div> + </Form.Item> + </> + } + items={items.map((spool) => ({ + value: useHTTPUrl ? `${baseUrlRoot}/spool/show/${spool.id}` : `WEB+SPOOLMAN:S-${spool.id}`, + amlName: renderTemplateText(filenameTemplate, spool), + vendor: spool.filament.vendor, + title: <>{renderLabelContents(titleTemplate, spool)}</>, + label: <>{renderLabelContents(infoTemplate, spool)}</>, + errorLevel: "H", + }))} + extraExportSettings={ + <Form.Item + label={t("printing.qrcode.filenameTemplate")} + tooltip={t("printing.qrcode.filenameTemplateTooltipSpool")} + > + <Input + value={filenameTemplate} + onChange={(newValue) => { + updateCurrentPreset({ + ...curPreset, + filenameTemplate: newValue.target.value, + }); + }} + /> + </Form.Item> + } + extraTitleSettings={ + <Form.Item + label={t("printing.qrcode.titleTemplate")} + tooltip={t("printing.qrcode.titleTemplateTooltipSpool")} + > + <TextArea + value={titleTemplate} + rows={4} + onChange={(newValue) => { + updateCurrentPreset({ + ...curPreset, + titleTemplate: newValue.target.value, + }); + }} + /> + </Form.Item> + } + extraInfoSettings={ + <> + <Form.Item label={t("printing.qrcode.infoTemplate")}> + <TextArea + value={infoTemplate} + rows={8} + onChange={(newValue) => { + updateCurrentPreset({ + ...curPreset, + template: newValue.target.value, + }); + }} + /> + </Form.Item> + <Modal open={templateHelpOpen} footer={null} onCancel={() => setTemplateHelpOpen(false)}> + <Table + size="small" + showHeader={false} + pagination={false} + scroll={{ y: 400 }} + columns={[{ dataIndex: "tag" }]} + dataSource={templateTags} + /> + </Modal> + <Text type="secondary"> + {t("printing.qrcode.templateHelp")}{" "} + <Button size="small" onClick={() => setTemplateHelpOpen(true)}> + {t("actions.show")} + </Button> + </Text> + </> + } + extraButtons={ + <> + <Button + type={hasUnsavedChanges ? "primary" : "default"} + size="large" + icon={<SaveOutlined />} + disabled={!hasUnsavedChanges} + onClick={async () => { + try { + await savePresetsRemote(); + messageApi.success(t("notifications.saveSuccessful")); + } catch (error) { + messageApi.error(error instanceof Error ? error.message : "Save failed"); + } + }} + > + {t("printing.generic.savePreset")} + </Button> + </> + } + /> + </> + ); +}; + +export default SpoolQRCodeExportDialog; diff --git a/client/src/pages/printing/spoolQrCodePrintingDialog.tsx b/client/src/pages/printing/spoolQrCodePrintingDialog.tsx index 865c597cf..747f3e67a 100644 --- a/client/src/pages/printing/spoolQrCodePrintingDialog.tsx +++ b/client/src/pages/printing/spoolQrCodePrintingDialog.tsx @@ -2,7 +2,7 @@ import { CopyOutlined, DeleteOutlined, PlusOutlined, SaveOutlined } from "@ant-d 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 { useEffect, useState } from "react"; import { v4 as uuidv4 } from "uuid"; import { EntityType, useGetFields } from "../../utils/queryFields"; import { useGetSetting } from "../../utils/querySettings"; @@ -10,6 +10,7 @@ import { useSavedState } from "../../utils/saveload"; import { useGetSpoolsByIds } from "../spools/functions"; import { ISpool } from "../spools/model"; import { + getConfiguredBaseUrl, SpoolQRCodePrintSettings, renderLabelContents, useGetPrintSettings as useGetPrintPresets, @@ -25,11 +26,57 @@ interface SpoolQRCodePrintingDialog { const SpoolQRCodePrintingDialog = ({ spoolIds }: SpoolQRCodePrintingDialog) => { const t = useTranslate(); + const currentPresetType = "spool"; + const otherPresetType = "filament"; + const defaultPresetName = t("printing.generic.defaultSettings"); + const importedPresetSuffix = `(${otherPresetType} preset basis)`; + const escapeRegExp = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const getNextPresetName = (baseName: string, presets: SpoolQRCodePrintSettings[]) => { + const trimmedBaseName = baseName.trim() || defaultPresetName; + const normalizedBaseName = trimmedBaseName.replace(/-\d{2}$/u, ""); + const suffixPattern = new RegExp(`^${escapeRegExp(normalizedBaseName)}-(\\d{2})$`, "u"); + let maxSuffix = 0; + for (const preset of presets) { + const presetName = (preset.labelSettings.printSettings?.name ?? "").trim(); + const match = presetName.match(suffixPattern); + if (!match) continue; + maxSuffix = Math.max(maxSuffix, Number.parseInt(match[1], 10)); + } + return `${normalizedBaseName}-${String(maxSuffix + 1).padStart(2, "0")}`; + }; + const buildNewPreset = ( + id: string, + name: string, + sourcePreset?: SpoolQRCodePrintSettings, + ): SpoolQRCodePrintSettings => { + const copiedSourcePrintSettings = sourcePreset?.labelSettings?.printSettings ?? {}; + return { + ...sourcePreset, + labelSettings: { + ...sourcePreset?.labelSettings, + printSettings: { + ...copiedSourcePrintSettings, + id, + name, + }, + }, + }; + }; + const toPresetValue = (type: "spool" | "filament", id: string) => `${type}:${id}`; + const parsePresetValue = (value?: string): { type: "spool" | "filament"; id: string } | undefined => { + if (!value) return undefined; + const separatorIndex = value.indexOf(":"); + if (separatorIndex < 0) return { type: currentPresetType, id: value }; + const type = value.slice(0, separatorIndex); + const id = value.slice(separatorIndex + 1); + if ((type === currentPresetType || type === otherPresetType) && id) { + return { type, id }; + } + return undefined; + }; const baseUrlSetting = useGetSetting("base_url"); - const baseUrlRoot = - baseUrlSetting.data?.value !== undefined && JSON.parse(baseUrlSetting.data?.value) !== "" - ? JSON.parse(baseUrlSetting.data?.value) - : window.location.origin; + // Accept both JSON-backed settings and legacy plain strings so old `base_url` values do not crash the dialog. + const baseUrlRoot = getConfiguredBaseUrl(baseUrlSetting.data?.value, window.location.origin); const [messageApi, contextHolder] = message.useMessage(); const [useHTTPUrl, setUseHTTPUrl] = useSavedState("print-useHTTPUrl", false); @@ -45,62 +92,78 @@ const SpoolQRCodePrintingDialog = ({ spoolIds }: SpoolQRCodePrintingDialog) => { // Keep a local copy of the settings which is what's actually displayed. Use the remote state only for saving. // This decouples the debounce stuff from the UI - const [localPresets, setLocalPresets] = useState<SpoolQRCodePrintSettings[] | undefined>(); - const remotePresets = useGetPrintPresets(); + const [localCurrentPresets, setLocalCurrentPresets] = useState<SpoolQRCodePrintSettings[] | undefined>(); + const remoteCurrentPresets = useGetPrintPresets(); + const remoteOtherPresets = useGetPrintPresets("print_presets_filament"); const setRemotePresets = useSetPrintPresets(); - const localOrRemotePresets = localPresets ?? remotePresets; + const currentPresets = localCurrentPresets ?? remoteCurrentPresets; + const otherPresets = remoteOtherPresets ?? []; const savePresetsRemote = () => { - if (!localPresets) return; - setRemotePresets(localPresets); + if (!localCurrentPresets) return; + setRemotePresets(localCurrentPresets); }; + useEffect(() => { + // Keep the saved local list active until the refetched settings catch up, otherwise the + // selector can briefly fall back to the default preset immediately after save. + if (!localCurrentPresets || !remoteCurrentPresets) return; + if (JSON.stringify(localCurrentPresets) === JSON.stringify(remoteCurrentPresets)) { + setLocalCurrentPresets(undefined); + } + }, [localCurrentPresets, remoteCurrentPresets]); + // Functions to update settings const addNewPreset = () => { - if (!localOrRemotePresets) return; + if (!currentPresets) return; const newId = uuidv4(); - const newPreset = { - labelSettings: { - printSettings: { - id: newId, - name: t("printing.generic.newSetting"), - }, - }, - }; - setLocalPresets([...localOrRemotePresets, newPreset]); - setSelectedPresetState(newId); + const newPreset = buildNewPreset(newId, t("printing.generic.newSetting")); + setLocalCurrentPresets([...currentPresets, newPreset]); + setSelectedPresetState(toPresetValue(currentPresetType, newId)); return newPreset; }; + const promotePresetToCurrentType = (preset: SpoolQRCodePrintSettings): SpoolQRCodePrintSettings | undefined => { + if (!currentPresets) return; + const baseName = (preset.labelSettings.printSettings?.name ?? defaultPresetName).trim() || defaultPresetName; + const promotedName = getNextPresetName(`${baseName} ${importedPresetSuffix}`, currentPresets); + const promotedPreset = buildNewPreset(uuidv4(), promotedName, preset); + setLocalCurrentPresets([...currentPresets, promotedPreset]); + setSelectedPresetState(toPresetValue(currentPresetType, promotedPreset.labelSettings.printSettings.id)); + return promotedPreset; + }; const duplicateCurrentPreset = () => { - if (!localOrRemotePresets) return; + if (!currentPresets) return; const newPreset = { ...curPreset, labelSettings: { ...curPreset.labelSettings, printSettings: { ...curPreset.labelSettings.printSettings } }, }; newPreset.labelSettings.printSettings.id = uuidv4(); - setLocalPresets([...localOrRemotePresets, newPreset]); - setSelectedPresetState(newPreset.labelSettings.printSettings.id); + setLocalCurrentPresets([...currentPresets, newPreset]); + setSelectedPresetState(toPresetValue(currentPresetType, 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, - ), + if (!currentPresets) return; + const parsed = parsePresetValue(selectedPresetState); + if (!parsed || parsed.type !== currentPresetType) { + promotePresetToCurrentType(newSettings); + return; + } + setLocalCurrentPresets( + currentPresets.map((presets) => (presets.labelSettings.printSettings.id === parsed.id ? newSettings : presets)), ); }; const deleteCurrentPreset = () => { - if (!localOrRemotePresets) return; - setLocalPresets( - localOrRemotePresets.filter((qPreset) => qPreset.labelSettings.printSettings.id !== selectedPresetState), - ); + if (!currentPresets) return; + const parsed = parsePresetValue(selectedPresetState); + if (!parsed || parsed.type !== currentPresetType) return; + setLocalCurrentPresets(currentPresets.filter((qPreset) => qPreset.labelSettings.printSettings.id !== parsed.id)); setSelectedPresetState(undefined); }; // Initialize presets let curPreset: SpoolQRCodePrintSettings; - if (localOrRemotePresets === undefined) { + if (currentPresets === undefined) { // DB not loaded yet, use a temporary one curPreset = { labelSettings: { @@ -112,7 +175,7 @@ const SpoolQRCodePrintingDialog = ({ spoolIds }: SpoolQRCodePrintingDialog) => { }; } else { // DB is loaded, find the selected setting - if (localOrRemotePresets.length === 0) { + if (currentPresets.length === 0) { // DB loaded, but no settings found, add a new one and select it const newSetting = addNewPreset(); if (!newSetting) { @@ -121,41 +184,43 @@ const SpoolQRCodePrintingDialog = ({ spoolIds }: SpoolQRCodePrintingDialog) => { } // Mutate the allPrintSettings list so that the rest of the UI will work fine - localOrRemotePresets.push(newSetting); + currentPresets.push(newSetting); curPreset = newSetting; } else { - // DB loaded and at least 1 setting exists - if (!selectedPresetState) { + const parsedSelectedPreset = parsePresetValue(selectedPresetState); + if (!parsedSelectedPreset) { // No setting has been selected, select the first one - curPreset = localOrRemotePresets[0]; - setSelectedPresetState(localOrRemotePresets[0].labelSettings.printSettings.id); + curPreset = currentPresets[0]; + setSelectedPresetState(toPresetValue(currentPresetType, currentPresets[0].labelSettings.printSettings.id)); + } else if (parsedSelectedPreset.type === otherPresetType) { + curPreset = currentPresets[0]; + setSelectedPresetState(toPresetValue(currentPresetType, currentPresets[0].labelSettings.printSettings.id)); } else { // A setting has been selected, find it - const foundSetting = localOrRemotePresets.find( - (settings) => settings.labelSettings.printSettings.id === selectedPresetState, + const foundSetting = currentPresets.find( + (settings) => settings.labelSettings.printSettings.id === parsedSelectedPreset.id, ); if (foundSetting) { curPreset = foundSetting; } else { - // Selected setting not found, select a temp one - curPreset = { - labelSettings: { - printSettings: { - id: "TEMP", - name: t("printing.generic.newSetting"), - }, - }, - }; + // Selected setting not found, reset to first available preset. + curPreset = currentPresets[0]; + setSelectedPresetState(toPresetValue(currentPresetType, currentPresets[0].labelSettings.printSettings.id)); } } } } + const hasUnsavedChanges = + localCurrentPresets !== undefined && + JSON.stringify(localCurrentPresets) !== JSON.stringify(remoteCurrentPresets ?? []); + const [templateHelpOpen, setTemplateHelpOpen] = useState(false); - const template = + const titleTemplate = curPreset.titleTemplate ?? `==**{filament.name}**== {filament.color_hex}`; + const infoTemplate = curPreset.template ?? - `**{filament.vendor.name} - {filament.name} -#{id} - {filament.material}** + `{filament.material} ({filament.article_number}) +Spool ID: #{id} Spool Weight: {filament.spool_weight} g {ET: {filament.settings_extruder_temp} °C} {BT: {filament.settings_bed_temp} °C} @@ -241,21 +306,57 @@ Spool Weight: {filament.spool_weight} g baseUrlRoot={baseUrlRoot} useHTTPUrl={useHTTPUrl} setUseHTTPUrl={setUseHTTPUrl} + previewValues={{ + default: "WEB+SPOOLMAN:S-{id}", + url: `${baseUrlRoot}/spool/show/{id}`, + }} extraSettingsStart={ <> - <Form.Item label={t("printing.generic.settings")}> + <Form.Item label={t("printing.generic.spoolPrintPresets")}> <Flex gap={8}> <Select - value={selectedPresetState} + value={ + selectedPresetState + ? selectedPresetState.includes(":") + ? selectedPresetState + : toPresetValue(currentPresetType, selectedPresetState) + : undefined + } onChange={(value) => { + const parsed = parsePresetValue(value); + if (!parsed) return; + if (parsed.type === otherPresetType) { + const sourcePreset = otherPresets.find( + (settings) => settings.labelSettings.printSettings.id === parsed.id, + ); + if (sourcePreset) { + promotePresetToCurrentType(sourcePreset); + } + return; + } setSelectedPresetState(value); }} options={ - localOrRemotePresets && - localOrRemotePresets.map((settings) => ({ - label: settings.labelSettings.printSettings?.name || t("printing.generic.defaultSettings"), - value: settings.labelSettings.printSettings.id, - })) + currentPresets + ? [ + { + label: t("printing.generic.spoolPrintPresets"), + options: currentPresets.map((settings) => ({ + label: + settings.labelSettings.printSettings?.name || t("printing.generic.defaultSettings"), + value: toPresetValue(currentPresetType, settings.labelSettings.printSettings.id), + })), + }, + { + label: t("printing.generic.filamentPrintPresets"), + options: otherPresets.map((settings) => ({ + label: + settings.labelSettings.printSettings?.name || t("printing.generic.defaultSettings"), + value: toPresetValue(otherPresetType, settings.labelSettings.printSettings.id), + })), + }, + ] + : [] } ></Select> <Button @@ -270,7 +371,7 @@ Spool Weight: {filament.spool_weight} g title={t("printing.generic.duplicateSettings")} onClick={duplicateCurrentPreset} /> - {localOrRemotePresets && localOrRemotePresets.length > 1 && ( + {currentPresets && currentPresets.length > 1 && ( <Popconfirm title={t("printing.generic.deleteSettings")} description={t("printing.generic.deleteSettingsConfirm")} @@ -296,29 +397,40 @@ Spool Weight: {filament.spool_weight} g updateCurrentPreset(curPreset); }} /> + <div style={{ minHeight: 22, paddingTop: 4 }}> + {hasUnsavedChanges && <Text type="danger">Unsaved Preset Changes</Text>} + </div> </Form.Item> </> } items={items.map((spool) => ({ value: useHTTPUrl ? `${baseUrlRoot}/spool/show/${spool.id}` : `WEB+SPOOLMAN:S-${spool.id}`, - label: ( - <p - style={{ - padding: "1mm 1mm 1mm 0", - margin: 0, - whiteSpace: "pre-wrap", - }} - > - {renderLabelContents(template, spool)} - </p> - ), + amlName: `spool-${spool.id}`, + vendor: spool.filament.vendor, + title: <>{renderLabelContents(titleTemplate, spool)}</>, + label: <>{renderLabelContents(infoTemplate, spool)}</>, errorLevel: "H", }))} - extraSettings={ + extraTitleSettings={ + <Form.Item + label={t("printing.qrcode.titleTemplate")} + tooltip={t("printing.qrcode.titleTemplateTooltipSpool")} + > + <TextArea + value={titleTemplate} + rows={4} + onChange={(newValue) => { + curPreset.titleTemplate = newValue.target.value; + updateCurrentPreset(curPreset); + }} + /> + </Form.Item> + } + extraInfoSettings={ <> - <Form.Item label={t("printing.qrcode.template")}> + <Form.Item label={t("printing.qrcode.infoTemplate")}> <TextArea - value={template} + value={infoTemplate} rows={8} onChange={(newValue) => { curPreset.template = newValue.target.value; diff --git a/client/src/pages/printing/spoolSelectModal.tsx b/client/src/pages/printing/spoolSelectModal.tsx index 91d8fdb36..89a17db52 100644 --- a/client/src/pages/printing/spoolSelectModal.tsx +++ b/client/src/pages/printing/spoolSelectModal.tsx @@ -1,8 +1,7 @@ -import { RightOutlined } from "@ant-design/icons"; import { useTable } from "@refinedev/antd"; import { Button, Checkbox, Col, message, Row, Space, Table } from "antd"; import { t } from "i18next"; -import { useMemo, useState } from "react"; +import { useCallback, useMemo, useState } from "react"; import { useNavigate } from "react-router"; import { FilteredQueryColumn, SortedColumn, SpoolIconColumn } from "../../components/column"; import { useSpoolmanFilamentFilter, useSpoolmanMaterials } from "../../components/otherModels"; @@ -12,7 +11,9 @@ import { ISpool } from "../spools/model"; interface Props { description?: string; - onContinue: (selectedSpools: ISpool[]) => void; + initialSelectedIds?: number[]; + onExport?: (selectedIds: number[]) => void; + onPrint?: (selectedIds: number[]) => void; } interface ISpoolCollapsed extends ISpool { @@ -21,6 +22,8 @@ interface ISpoolCollapsed extends ISpool { "filament.material"?: string; } +// Flatten related filament fields onto the row so shared table columns can sort +// and filter without reaching through nested objects. function collapseSpool(element: ISpool): ISpoolCollapsed { let filament_name: string; if (element.filament.vendor && "name" in element.filament.vendor) { @@ -36,8 +39,8 @@ function collapseSpool(element: ISpool): ISpoolCollapsed { }; } -const SpoolSelectModal = ({ description, onContinue }: Props) => { - const [selectedItems, setSelectedItems] = useState<number[]>([]); +const SpoolSelectModal = ({ description, initialSelectedIds, onExport, onPrint }: Props) => { + const [selectedItems, setSelectedItems] = useState<number[]>(initialSelectedIds ?? []); const [showArchived, setShowArchived] = useState(false); const [messageApi, contextHolder] = message.useMessage(); const navigate = useNavigate(); @@ -71,35 +74,44 @@ const SpoolSelectModal = ({ description, onContinue }: Props) => { }, }); - // Store state in local storage + // Shared column helpers expect table sort/filter state in this shape. const tableState: TableState = { sorters, filters, pagination: { currentPage: currentPage, pageSize }, }; - // Collapse the dataSource to a mutable list and add a filament_name field + // Work on shallow copies so selection helpers can inspect row state without mutating + // Refine's cached query data. const dataSource: ISpoolCollapsed[] = useMemo( () => (tableProps.dataSource || []).map((record) => ({ ...record })), [tableProps.dataSource], ); - // 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; - }); - }; + // Bulk selection applies only to the rows currently loaded in the modal. + const selectUnselectFiltered = useCallback( + (select: boolean) => { + setSelectedItems((prevSelected) => { + const nextSelected = new Set(prevSelected); + dataSource.forEach((spool) => { + if (select) { + nextSelected.add(spool.id); + } else { + nextSelected.delete(spool.id); + } + }); + return Array.from(nextSelected); + }); + }, + [dataSource], + ); - // Handler for selecting/unselecting individual items - const handleSelectItem = (item: number) => { + const handleSelectItem = useCallback((item: number) => { setSelectedItems((prevSelected) => prevSelected.includes(item) ? prevSelected.filter((selected) => selected !== item) : [...prevSelected, item], ); - }; + }, []); - // State for the select/unselect all checkbox const isAllFilteredSelected = dataSource.every((spool) => selectedItems.includes(spool.id)); const isSomeButNotAllFilteredSelected = dataSource.some((spool) => selectedItems.includes(spool.id)) && !isAllFilteredSelected; @@ -181,7 +193,8 @@ const SpoolSelectModal = ({ description, onContinue }: Props) => { onChange={(e) => { setShowArchived(e.target.checked); if (!e.target.checked) { - // Remove archived spools from selected items + // Drop archived selections when that filter is hidden so the badge count + // matches the set of choices the modal is showing. setSelectedItems((prevSelected) => prevSelected.filter( (selected) => dataSource.find((spool) => spool.id === selected)?.archived !== true, @@ -194,23 +207,42 @@ const SpoolSelectModal = ({ description, onContinue }: Props) => { </Checkbox> </Col> <Col span={24}> - <Button - type="primary" - icon={<RightOutlined />} - 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))); - }} - > - {t("buttons.continue")} - </Button> + <Space> + {onPrint && ( + <Button + type="primary" + onClick={() => { + if (selectedItems.length === 0) { + messageApi.open({ + type: "error", + content: t("printing.spoolSelect.noSpoolsSelected"), + }); + return; + } + onPrint(selectedItems); + }} + > + {t("printing.qrcode.button")} + </Button> + )} + {onExport && ( + <Button + type="primary" + onClick={() => { + if (selectedItems.length === 0) { + messageApi.open({ + type: "error", + content: t("printing.spoolSelect.noSpoolsSelected"), + }); + return; + } + onExport(selectedItems); + }} + > + {t("printing.qrcode.exportButton")} + </Button> + )} + </Space> </Col> </Row> </Space> diff --git a/client/src/pages/printing/titleTextBlock.tsx b/client/src/pages/printing/titleTextBlock.tsx new file mode 100644 index 000000000..d8da3464b --- /dev/null +++ b/client/src/pages/printing/titleTextBlock.tsx @@ -0,0 +1,107 @@ +import { ReactNode, useEffect, useLayoutEffect, useRef, useState } from "react"; + +interface TitleTextBlockProps { + children: ReactNode; + fitToWidth: boolean; + align: "left" | "center" | "right"; + maxTextSizeMm: number; + onEffectiveTextSizeChange?: (sizeMm: number) => void; +} + +const alignToFlex = (value: "left" | "center" | "right"): "flex-start" | "center" | "flex-end" => { + if (value === "center") return "center"; + if (value === "right") return "flex-end"; + return "flex-start"; +}; + +const TitleTextBlock = ({ + children, + fitToWidth, + align, + maxTextSizeMm, + onEffectiveTextSizeChange, +}: TitleTextBlockProps) => { + const containerRef = useRef<HTMLDivElement | null>(null); + const contentRef = useRef<HTMLDivElement | null>(null); + const [scale, setScale] = useState(1); + const [resizeTick, setResizeTick] = useState(0); + + useEffect(() => { + if (!fitToWidth) return; + if (!containerRef.current) return; + + const observer = new ResizeObserver(() => { + setResizeTick((value) => value + 1); + }); + observer.observe(containerRef.current); + return () => observer.disconnect(); + }, [fitToWidth]); + + useLayoutEffect(() => { + if (!fitToWidth) { + setScale(1); + return; + } + + const container = containerRef.current; + const content = contentRef.current; + if (!container || !content) return; + + const availableWidth = container.clientWidth; + if (availableWidth <= 0) { + setScale(1); + return; + } + + const neededWidth = content.scrollWidth; + if (neededWidth <= 0) { + setScale(1); + return; + } + + const currentScale = Math.max(scale, 0.0001); + const unscaledNeededWidth = neededWidth / currentScale; + const nextScale = Math.min(1, availableWidth / Math.max(unscaledNeededWidth, 1)); + const normalizedScale = Number((nextScale > 0 ? nextScale : 1).toFixed(4)); + setScale(normalizedScale); + }, [children, fitToWidth, maxTextSizeMm, resizeTick]); + + const effectiveSizeMm = fitToWidth ? Number((maxTextSizeMm * scale).toFixed(1)) : Number(maxTextSizeMm.toFixed(1)); + + useEffect(() => { + onEffectiveTextSizeChange?.(effectiveSizeMm); + }, [effectiveSizeMm, onEffectiveTextSizeChange]); + + return ( + <div + ref={containerRef} + className="print-qrcode-title-text-wrapper" + style={{ + width: "100%", + display: "flex", + justifyContent: alignToFlex(align), + }} + > + <div + ref={contentRef} + className="print-qrcode-title-text" + style={{ + fontSize: `${effectiveSizeMm}mm`, + textAlign: align, + lineHeight: 1.2, + color: "#000", + whiteSpace: fitToWidth ? "pre" : "pre-wrap", + overflowWrap: fitToWidth ? "normal" : "anywhere", + wordBreak: fitToWidth ? "normal" : "break-word", + width: fitToWidth ? "auto" : "100%", + maxWidth: "100%", + display: "inline-block", + }} + > + {children} + </div> + </div> + ); +}; + +export default TitleTextBlock; diff --git a/client/src/pages/printingExport/index.tsx b/client/src/pages/printingExport/index.tsx new file mode 100644 index 000000000..26e0880d0 --- /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 ( + <> + <PageHeader + title={t("printing.qrcode.exportSpoolTitle")} + onBack={() => { + const returnUrl = searchParams.get("return"); + if (returnUrl) { + navigate(returnUrl, { relative: "path" }); + } else { + navigate("/spool"); + } + }} + > + <Content + style={{ + padding: 20, + minHeight: 280, + margin: "0 auto", + backgroundColor: token.colorBgContainer, + borderRadius: token.borderRadiusLG, + color: token.colorText, + fontFamily: token.fontFamily, + fontSize: token.fontSizeLG, + lineHeight: 1.5, + }} + > + {spoolIds.length > 0 && <SpoolQRCodeExportDialog spoolIds={spoolIds} />} + </Content> + </PageHeader> + </> + ); +}; + +export default PrintingExport; diff --git a/client/src/pages/settings/index.tsx b/client/src/pages/settings/index.tsx index 4393addfa..81d181f4d 100644 --- a/client/src/pages/settings/index.tsx +++ b/client/src/pages/settings/index.tsx @@ -1,4 +1,11 @@ -import { FileOutlined, HighlightOutlined, SolutionOutlined, ToolOutlined, UserOutlined } from "@ant-design/icons"; +import { + FileOutlined, + HighlightOutlined, + PictureOutlined, + SolutionOutlined, + ToolOutlined, + UserOutlined, +} from "@ant-design/icons"; import { useTranslate } from "@refinedev/core"; import { Menu, theme } from "antd"; import { Content } from "antd/es/layout/layout"; @@ -7,6 +14,7 @@ import utc from "dayjs/plugin/utc"; import { Route, Routes, useNavigate } from "react-router"; import { ExtraFieldsSettings } from "./extraFieldsSettings"; import { GeneralSettings } from "./generalSettings"; +import { ManufacturerLogosSettings } from "./manufacturerLogosSettings"; dayjs.extend(utc); @@ -57,6 +65,7 @@ export const Settings = () => { }} items={[ { key: "", label: t("settings.general.tab"), icon: <ToolOutlined /> }, + { key: "logos", label: t("settings.logo_manager.tab"), icon: <PictureOutlined /> }, { key: "extra", label: t("settings.extra_fields.tab"), @@ -87,6 +96,7 @@ export const Settings = () => { <main> <Routes> <Route index element={<GeneralSettings />} /> + <Route path="/logos" element={<ManufacturerLogosSettings />} /> <Route path="/extra/:entityType" element={<ExtraFieldsSettings />} /> </Routes> </main> diff --git a/client/src/pages/settings/manufacturerLogosSettings.tsx b/client/src/pages/settings/manufacturerLogosSettings.tsx new file mode 100644 index 000000000..871aacded --- /dev/null +++ b/client/src/pages/settings/manufacturerLogosSettings.tsx @@ -0,0 +1,720 @@ +import { QuestionCircleOutlined } from "@ant-design/icons"; +import { useInvalidate, useTranslate, useUpdate } from "@refinedev/core"; +import { + Button, + Checkbox, + Divider, + Flex, + Grid, + Modal, + Pagination, + Select, + Space, + Tag, + Table, + Tooltip, + Typography, + message, + theme, +} from "antd"; +import type { CSSProperties } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { useVendorLogoManifest } from "../../components/otherModels"; +import { getAPIURL, getBasePath } from "../../utils/url"; +import { parseExtraString, suggestVendorLogoPaths } from "../../utils/vendorLogo"; +import { IVendor } from "../vendors/model"; + +const { Text, Paragraph, Link } = Typography; +const { useBreakpoint } = Grid; + +const ROWS_PER_PAGE_OPTIONS = ["auto", 10, 20, 50, 100] as const; +type RowsPerPageOption = (typeof ROWS_PER_PAGE_OPTIONS)[number]; +type VendorSelection = number[]; + +interface LogoPackSyncResponse { + updated: boolean; + source_url: string; + web_logo_count: number; + print_logo_count: number; +} + +interface LogoPackImportResponse { + message: string; + web_logo_count: number; + print_logo_count: number; + generated_print_logo_count: number; +} + +interface LogoSyncProposal { + vendor: IVendor; + webPath?: string; + printPath?: string; +} + +const ActionHeader = ({ title, tooltip }: { title: string; tooltip: string }) => ( + <Space size={6}> + <Text strong>{title}</Text> + <Tooltip title={tooltip}> + <QuestionCircleOutlined /> + </Tooltip> + </Space> +); + +// Keep the global logo-pack refresh, review, and per-vendor field updates in one place so the workflow stays explicit. +export function ManufacturerLogosSettings() { + const t = useTranslate(); + const { token } = theme.useToken(); + const screens = useBreakpoint(); + const isMobile = !screens.md; + const [messageApi, contextHolder] = message.useMessage(); + const logoManifestQuery = useVendorLogoManifest(); + const invalidate = useInvalidate(); + const { mutateAsync: updateVendor } = useUpdate(); + + const [isBuildingReview, setIsBuildingReview] = useState(false); + const [isSyncApplying, setIsSyncApplying] = useState(false); + const [isSyncingLogoPack, setIsSyncingLogoPack] = useState(false); + const [isImportingLogoPack, setIsImportingLogoPack] = useState(false); + const [hasPendingReviewNotice, setHasPendingReviewNotice] = useState(false); + const [generatePrintLogos, setGeneratePrintLogos] = useState(false); + const [logoSourceUrl, setLogoSourceUrl] = useState<string>("https://github.com/MarksMakerSpace/filament-profiles"); + + const [reviewOpen, setReviewOpen] = useState(false); + const [proposals, setProposals] = useState<LogoSyncProposal[]>([]); + const [selectedVendorIds, setSelectedVendorIds] = useState<VendorSelection>([]); + const [currentPage, setCurrentPage] = useState(1); + const [rowsPerPageOption, setRowsPerPageOption] = useState<RowsPerPageOption>("auto"); + const [autoRowsPerPage, setAutoRowsPerPage] = useState(10); + const [maxTableBodyHeight, setMaxTableBodyHeight] = useState(340); + + const reviewTopRef = useRef<HTMLDivElement | null>(null); + const reviewFooterRef = useRef<HTMLDivElement | null>(null); + const logoPackInputRef = useRef<HTMLInputElement | null>(null); + + const logoPreviewHeight = isMobile ? 28 : 40; + const rowHeight = isMobile ? 56 : 64; + const resolvedRowsPerPage = rowsPerPageOption === "auto" ? autoRowsPerPage : rowsPerPageOption; + + // Keep review paging derived from the current viewport fit so mobile and desktop share one table model. + const pagedProposals = useMemo(() => { + const safePageSize = Math.max(1, resolvedRowsPerPage); + const totalPages = Math.max(1, Math.ceil(proposals.length / safePageSize)); + const normalizedPage = Math.min(currentPage, totalPages); + const start = (normalizedPage - 1) * safePageSize; + return { + totalPages, + normalizedPage, + safePageSize, + rows: proposals.slice(start, start + safePageSize), + }; + }, [currentPage, proposals, resolvedRowsPerPage]); + + useEffect(() => { + if (currentPage !== pagedProposals.normalizedPage) { + setCurrentPage(pagedProposals.normalizedPage); + } + }, [currentPage, pagedProposals.normalizedPage]); + + const recalculateAutoPaging = () => { + if (!reviewOpen) { + return; + } + + const viewportHeight = window.innerHeight; + const modalVerticalPadding = isMobile ? 28 : 84; + const modalMaxHeight = Math.max(360, viewportHeight - modalVerticalPadding); + const topHeight = reviewTopRef.current?.offsetHeight ?? 120; + const footerHeight = reviewFooterRef.current?.offsetHeight ?? 70; + const tableChromeHeight = isMobile ? 154 : 138; + const nextMaxBodyHeight = Math.max(140, modalMaxHeight - topHeight - footerHeight - tableChromeHeight); + setMaxTableBodyHeight(nextMaxBodyHeight); + + if (rowsPerPageOption === "auto") { + // "Auto" should fill the available modal body without forcing the review table into nested pagination. + const rowsThatFit = Math.max(1, Math.floor(nextMaxBodyHeight / rowHeight)); + setAutoRowsPerPage((previous) => (previous === rowsThatFit ? previous : rowsThatFit)); + } + }; + + useEffect(() => { + if (!reviewOpen) { + return; + } + + const deferredRecalculate = () => { + window.requestAnimationFrame(() => recalculateAutoPaging()); + }; + + deferredRecalculate(); + window.addEventListener("resize", deferredRecalculate); + + const resizeObserver = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(deferredRecalculate); + if (resizeObserver) { + if (reviewTopRef.current) { + resizeObserver.observe(reviewTopRef.current); + } + if (reviewFooterRef.current) { + resizeObserver.observe(reviewFooterRef.current); + } + } + + return () => { + window.removeEventListener("resize", deferredRecalculate); + resizeObserver?.disconnect(); + }; + }, [reviewOpen, rowsPerPageOption, proposals.length, isMobile]); + + const tableBodyHeight = useMemo(() => { + const visibleRowCount = pagedProposals.rows.length; + const contentHeight = Math.max(rowHeight, visibleRowCount * rowHeight); + + if (rowsPerPageOption === "auto") { + const autoHasOverflow = proposals.length > pagedProposals.safePageSize; + return autoHasOverflow ? maxTableBodyHeight : Math.min(maxTableBodyHeight, contentHeight); + } + + return Math.min(maxTableBodyHeight, contentHeight); + }, [ + maxTableBodyHeight, + pagedProposals.rows.length, + pagedProposals.safePageSize, + proposals.length, + rowHeight, + rowsPerPageOption, + ]); + + const toRuntimeLogoUrl = (path: string | undefined) => { + if (!path) { + return undefined; + } + if (path.startsWith("http://") || path.startsWith("https://") || path.startsWith("data:")) { + return path; + } + if (path.startsWith("/")) { + return `${getBasePath()}${path}`; + } + return `${getBasePath()}/${path}`; + }; + + const buildLogoSyncProposals = async (): Promise<LogoSyncProposal[]> => { + const manifest = logoManifestQuery.data; + if (!manifest) { + throw new Error(t("settings.logo_manager.not_ready")); + } + + const response = await fetch(getAPIURL() + "/vendor"); + if (!response.ok) { + throw new Error(t("settings.logo_manager.sync_load_error")); + } + + const vendors = (await response.json()) as IVendor[]; + return vendors + .map((vendor) => { + const existingLogo = parseExtraString(vendor.extra?.logo_url); + const existingPrintLogo = parseExtraString(vendor.extra?.print_logo_url); + if (existingLogo && existingPrintLogo) { + return undefined; + } + + // Suggestions only fill missing fields; explicit vendor logo assignments always win over manifest matches. + const { webPath, printPath } = suggestVendorLogoPaths(vendor.name, manifest); + const proposal: LogoSyncProposal = { + vendor, + webPath: existingLogo ? undefined : webPath, + printPath: existingPrintLogo ? undefined : printPath, + }; + + if (!proposal.webPath && !proposal.printPath) { + return undefined; + } + return proposal; + }) + .filter((proposal): proposal is LogoSyncProposal => Boolean(proposal)); + }; + + const openSyncReview = async () => { + setIsBuildingReview(true); + try { + const nextProposals = await buildLogoSyncProposals(); + setProposals(nextProposals); + setSelectedVendorIds([]); + setCurrentPage(1); + setRowsPerPageOption("auto"); + setReviewOpen(true); + // Once the review modal is opened, the "new files ready" reminder has served its purpose. + setHasPendingReviewNotice(false); + + if (nextProposals.length === 0) { + messageApi.info("No manufacturer logo matches were found for blank logo URLs."); + } + } catch (error) { + messageApi.error(error instanceof Error ? error.message : t("settings.logo_manager.sync_load_error")); + } finally { + setIsBuildingReview(false); + } + }; + + const applySyncForSelection = async (vendorIds: VendorSelection) => { + if (vendorIds.length === 0) { + return; + } + + setIsSyncApplying(true); + try { + let updatedCount = 0; + const proposalByVendorId = new Map<number, LogoSyncProposal>( + proposals.map((proposal) => [proposal.vendor.id, proposal]), + ); + + for (const vendorId of vendorIds) { + const proposal = proposalByVendorId.get(vendorId); + if (!proposal) { + continue; + } + + const existingLogo = parseExtraString(proposal.vendor.extra?.logo_url); + const existingPrintLogo = parseExtraString(proposal.vendor.extra?.print_logo_url); + const mergedExtra = { ...(proposal.vendor.extra ?? {}) }; + + // Mirror the bulk-sync contract: populate blank logo fields only, never overwrite saved vendor choices. + if (!existingLogo && proposal.webPath) { + mergedExtra.logo_url = JSON.stringify(proposal.webPath); + } + if (!existingPrintLogo && proposal.printPath) { + mergedExtra.print_logo_url = JSON.stringify(proposal.printPath); + } + + if ( + mergedExtra.logo_url === proposal.vendor.extra?.logo_url && + mergedExtra.print_logo_url === proposal.vendor.extra?.print_logo_url + ) { + continue; + } + + await updateVendor({ + resource: "vendor", + id: proposal.vendor.id, + values: { ...proposal.vendor, extra: mergedExtra }, + }); + updatedCount += 1; + } + + if (updatedCount > 0) { + invalidate({ resource: "vendor", invalidates: ["list"] }); + } + + const appliedSet = new Set(vendorIds); + const remaining = proposals.filter((proposal) => !appliedSet.has(proposal.vendor.id)); + setProposals(remaining); + setSelectedVendorIds((existing) => existing.filter((id) => !appliedSet.has(id))); + setCurrentPage(1); + + messageApi.success(`Logo sync complete. Updated ${updatedCount} manufacturers.`); + if (remaining.length === 0) { + setReviewOpen(false); + } + } catch (error) { + messageApi.error(error instanceof Error ? error.message : t("settings.logo_manager.sync_load_error")); + } finally { + setIsSyncApplying(false); + } + }; + + const selectAllMatches = () => { + setSelectedVendorIds(proposals.map((proposal) => proposal.vendor.id)); + }; + + const clearChecked = () => { + setSelectedVendorIds([]); + }; + + const renderLogoCell = (path: string | undefined) => { + if (!path) { + return <Text type="secondary">No match</Text>; + } + + const filename = path.split("/").pop() ?? path; + const url = toRuntimeLogoUrl(path); + if (!url) { + return <Text type="secondary">No match</Text>; + } + + return ( + <Tooltip + title={ + <span> + {filename} + <br /> + {path} + </span> + } + > + <div + style={{ + minHeight: logoPreviewHeight, + display: "flex", + alignItems: "center", + }} + > + <img + src={url} + alt={filename} + style={{ + maxHeight: logoPreviewHeight, + maxWidth: "100%", + objectFit: "contain", + display: "block", + }} + /> + </div> + </Tooltip> + ); + }; + + const tableColumns = [ + { + title: "Manufacturer", + dataIndex: ["vendor", "name"], + key: "manufacturer", + width: isMobile ? 180 : 260, + render: (_: unknown, proposal: LogoSyncProposal) => ( + <Text style={{ fontSize: isMobile ? token.fontSize : token.fontSizeLG }}>{proposal.vendor.name}</Text> + ), + }, + { + title: "Web Logo File", + dataIndex: "webPath", + key: "webPath", + width: isMobile ? 210 : 320, + render: (_: unknown, proposal: LogoSyncProposal) => renderLogoCell(proposal.webPath), + }, + { + title: "Print Logo File", + dataIndex: "printPath", + key: "printPath", + width: isMobile ? 210 : 320, + render: (_: unknown, proposal: LogoSyncProposal) => renderLogoCell(proposal.printPath), + }, + ]; + + const reviewScrollVars: CSSProperties = { + ["--logo-scroll-thumb" as string]: token.colorPrimary, + ["--logo-scroll-track" as string]: token.colorBgContainerDisabled, + }; + const sectionHeaderStyle = { fontSize: isMobile ? token.fontSizeLG : token.fontSizeHeading4 }; + + const importLogoPackFromZip = async (file: File) => { + setIsImportingLogoPack(true); + try { + const formData = new FormData(); + formData.append("file", file); + formData.append("generate_print_logos", String(generatePrintLogos)); + + const response = await fetch(getAPIURL() + "/vendor/logo-pack/import-zip", { + method: "POST", + body: formData, + }); + const body = (await response.json()) as LogoPackImportResponse & { message?: string }; + + if (!response.ok) { + throw new Error(body.message ?? t("settings.logo_manager.import_load_error")); + } + + // Refresh the manifest before showing review notices so previews and suggestions point at the imported files. + await logoManifestQuery.refetch(); + messageApi.success( + t("settings.logo_manager.import_done", { + web: body.web_logo_count, + print: body.print_logo_count, + generated: body.generated_print_logo_count, + }), + ); + setHasPendingReviewNotice(true); + } catch (error) { + messageApi.error(error instanceof Error ? error.message : t("settings.logo_manager.import_load_error")); + } finally { + setIsImportingLogoPack(false); + if (logoPackInputRef.current) { + logoPackInputRef.current.value = ""; + } + } + }; + + const syncLogoPackFromGithub = async () => { + setIsSyncingLogoPack(true); + try { + // Refresh only the shared local logo pack here. Per-vendor field population remains a separate review/apply step. + const response = await fetch(getAPIURL() + "/vendor/logo-pack/sync-from-github", { + method: "POST", + }); + const body = (await response.json()) as LogoPackSyncResponse & { message?: string }; + + if (!response.ok) { + throw new Error(body.message ?? t("settings.logo_manager.github_load_error")); + } + + if (body.source_url) { + setLogoSourceUrl(body.source_url); + } + + await logoManifestQuery.refetch(); + if (body.updated) { + setHasPendingReviewNotice(true); + } + messageApi.success( + body.updated + ? t("settings.logo_manager.github_done_updated", { + web: body.web_logo_count, + print: body.print_logo_count, + }) + : t("settings.logo_manager.github_done_no_changes", { + web: body.web_logo_count, + print: body.print_logo_count, + }), + ); + } catch (error) { + messageApi.error(error instanceof Error ? error.message : t("settings.logo_manager.github_load_error")); + } finally { + setIsSyncingLogoPack(false); + } + }; + + return ( + <> + <style> + {` + .logo-review-table .ant-table-body { + overflow-y: scroll !important; + scrollbar-gutter: stable both-edges; + scrollbar-width: auto; + scrollbar-color: var(--logo-scroll-thumb) var(--logo-scroll-track); + } + .logo-review-table .ant-table-body::-webkit-scrollbar { + width: 14px; + height: 14px; + } + .logo-review-table .ant-table-body::-webkit-scrollbar-thumb { + background-color: var(--logo-scroll-thumb); + border-radius: 12px; + border: 2px solid var(--logo-scroll-track); + } + .logo-review-table .ant-table-body::-webkit-scrollbar-track { + background-color: var(--logo-scroll-track); + border-radius: 12px; + } + `} + </style> + <div + style={{ + maxWidth: "1080px", + margin: "0 auto", + }} + > + <Space direction="vertical" size={4} style={{ width: "100%", marginBottom: 16 }}> + <Text strong style={{ fontSize: isMobile ? token.fontSizeXL : token.fontSizeHeading3 }}> + Manufacturer Logo Management + </Text> + <Paragraph type="secondary" style={{ marginBottom: 0 }}> + Manage automatic synchronization of manufacturer logos for this Spoolman installation.{" "} + <Link href={`${getBasePath()}/help`} style={{ fontSize: token.fontSizeSM }}> + Help? + </Link> + </Paragraph> + </Space> + + <Space direction="vertical" size={12} style={{ width: "100%" }}> + <Text strong style={sectionHeaderStyle}> + Import Logo Files + </Text> + <ActionHeader title="Sync Logo Pack" tooltip={t("settings.logo_manager.github_tooltip")} /> + <Paragraph type="secondary" style={{ marginBottom: 0, paddingInlineStart: 16 }}> + Downloads missing logo files from the{" "} + <Tooltip title="Open the configured logo source repository on GitHub."> + <Link href={logoSourceUrl} target="_blank" rel="noreferrer"> + logo source repository + </Link> + </Tooltip>{" "} + into the local logo library. This action updates local logo files only; manufacturer records are not + changed. + </Paragraph> + <Button type="primary" onClick={() => void syncLogoPackFromGithub()} loading={isSyncingLogoPack}> + Sync Logo Pack from Repository + </Button> + + <ActionHeader + title={t("settings.logo_manager.import_title")} + tooltip={t("settings.logo_manager.import_tooltip")} + /> + <Paragraph type="secondary" style={{ marginBottom: 0, paddingInlineStart: 16 }}> + Imports logo files from a local ZIP archive into the same local logo library. This action updates local logo + files only; use <Text strong>Manufacturer Logo Assignment</Text> below to apply logo paths to manufacturer + records. + </Paragraph> + <Checkbox checked={generatePrintLogos} onChange={(event) => setGeneratePrintLogos(event.target.checked)}> + {t("settings.logo_manager.generate_print_toggle")} + </Checkbox> + <input + ref={logoPackInputRef} + type="file" + accept=".zip,application/zip" + style={{ display: "none" }} + onChange={(event) => { + const file = event.target.files?.[0]; + if (file) { + void importLogoPackFromZip(file); + } + }} + /> + <Button type="primary" onClick={() => logoPackInputRef.current?.click()} loading={isImportingLogoPack}> + {t("settings.logo_manager.import_button")} + </Button> + + <Divider style={{ margin: "8px 0" }} /> + + <Space size={6}> + <Text strong style={sectionHeaderStyle}> + Manufacturer Logo Assignment + </Text> + <Tooltip title="Builds a review list of logo matches for manufacturers with blank logo URLs. No manufacturer record is updated until you choose Sync All or Sync Checked in the review dialog."> + <QuestionCircleOutlined /> + </Tooltip> + </Space> + <Paragraph type="secondary" style={{ marginBottom: 0, paddingInlineStart: 16 }}> + Builds review proposals for manufacturers with blank logo fields from currently available local logos. + Matching ignores minor punctuation and case differences, existing logo URLs are never overwritten, and no + changes are written until you choose a sync action in the review dialog. + </Paragraph> + <Paragraph type="secondary" style={{ marginBottom: 0, paddingInlineStart: 16 }}> + Use this workflow to review and apply assignments across multiple manufacturers. For one-manufacturer + updates, use that manufacturer's Edit page. + </Paragraph> + {hasPendingReviewNotice && ( + <Space size={8} style={{ paddingInlineStart: 16 }}> + <Tag color="gold">{t("settings.logo_manager.review_pending_badge")}</Tag> + <Text type="secondary">{t("settings.logo_manager.review_pending_text")}</Text> + </Space> + )} + <Button type="primary" onClick={() => void openSyncReview()} loading={isBuildingReview}> + Review and Sync Manufacturer Logos + </Button> + </Space> + </div> + + <Modal + title="Review Manufacturer Logo Matches" + open={reviewOpen} + onCancel={() => { + if (isSyncApplying) { + return; + } + setReviewOpen(false); + }} + footer={null} + width={isMobile ? "96vw" : 1220} + style={{ top: isMobile ? 8 : 24 }} + destroyOnHidden + > + <Flex vertical gap={12}> + <div ref={reviewTopRef}> + <Paragraph type="secondary" style={{ marginBottom: 10 }}> + Proposed assignments: {proposals.length}. Selected: {selectedVendorIds.length}. + </Paragraph> + <Space wrap> + <Tooltip title="Selects every proposed manufacturer across all pages."> + <Button onClick={selectAllMatches} disabled={proposals.length === 0 || isSyncApplying}> + Select All Matches + </Button> + </Tooltip> + <Button onClick={clearChecked} disabled={selectedVendorIds.length === 0 || isSyncApplying}> + Clear Checked + </Button> + </Space> + </div> + + <div style={reviewScrollVars}> + <Table<LogoSyncProposal> + className="logo-review-table" + rowKey={(record) => record.vendor.id} + size={isMobile ? "small" : "middle"} + columns={tableColumns} + dataSource={pagedProposals.rows} + pagination={false} + tableLayout="fixed" + scroll={{ x: isMobile ? 760 : 980, y: tableBodyHeight }} + rowSelection={{ + hideSelectAll: false, + preserveSelectedRowKeys: true, + columnTitle: (originalNode) => ( + <Tooltip title="Selects or clears visible rows on this page only."> + <span>{originalNode}</span> + </Tooltip> + ), + selectedRowKeys: selectedVendorIds, + onChange: (keys) => { + setSelectedVendorIds(keys as VendorSelection); + }, + }} + /> + </div> + + <div ref={reviewFooterRef}> + <Flex + gap={12} + justify="space-between" + align={isMobile ? "flex-start" : "center"} + wrap={isMobile ? "wrap" : "nowrap"} + > + <Space wrap> + <Button onClick={() => setReviewOpen(false)} disabled={isSyncApplying}> + Cancel + </Button> + <Button + onClick={() => void applySyncForSelection(proposals.map((proposal) => proposal.vendor.id))} + loading={isSyncApplying} + disabled={proposals.length === 0} + > + Sync All + </Button> + <Button + type="primary" + onClick={() => void applySyncForSelection(selectedVendorIds)} + loading={isSyncApplying} + disabled={selectedVendorIds.length === 0} + > + Sync Checked ({selectedVendorIds.length}) + </Button> + </Space> + + <Space wrap style={{ marginInlineStart: isMobile ? 0 : "auto" }}> + <Select<RowsPerPageOption> + value={rowsPerPageOption} + onChange={(value) => { + setRowsPerPageOption(value); + setCurrentPage(1); + }} + dropdownMatchSelectWidth={false} + style={{ minWidth: rowsPerPageOption === "auto" ? 86 : 112 }} + options={ROWS_PER_PAGE_OPTIONS.map((option) => ({ + value: option, + label: option === "auto" ? "Auto" : `${option} / page`, + }))} + /> + <Pagination + size={isMobile ? "small" : "default"} + current={pagedProposals.normalizedPage} + pageSize={pagedProposals.safePageSize} + total={proposals.length} + onChange={(page) => setCurrentPage(page)} + showSizeChanger={false} + /> + </Space> + </Flex> + </div> + </Flex> + </Modal> + {contextHolder} + </> + ); +} + +export default ManufacturerLogosSettings; diff --git a/client/src/pages/spoolLabels/index.tsx b/client/src/pages/spoolLabels/index.tsx new file mode 100644 index 000000000..a99c1238b --- /dev/null +++ b/client/src/pages/spoolLabels/index.tsx @@ -0,0 +1,80 @@ +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 ( + <> + <PageHeader + title={t("printing.qrcode.selectTitle")} + onBack={() => { + if (returnUrl) { + navigate(returnUrl, { relative: "path" }); + } else { + navigate("/spool"); + } + }} + > + <Content + style={{ + padding: 20, + minHeight: "70vh", + height: "calc(100vh - 200px)", + margin: "0 auto", + backgroundColor: token.colorBgContainer, + borderRadius: token.borderRadiusLG, + color: token.colorText, + fontFamily: token.fontFamily, + fontSize: token.fontSizeLG, + lineHeight: 1.5, + display: "flex", + flexDirection: "column", + overflow: "hidden", + }} + > + <SpoolSelectModal + description={t("printing.spoolSelect.description")} + initialSelectedIds={initialSelectedIds} + onPrint={(ids) => handleNavigate("print", ids)} + onExport={(ids) => handleNavigate("export", ids)} + /> + </Content> + </PageHeader> + </> + ); +}; + +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={<PrinterOutlined />} onClick={() => { - navigate("print"); + navigate("labels"); }} > - {t("printing.qrcode.button")} + {t("printing.qrcode.selectButton")} </Button> <Button type="primary" diff --git a/client/src/pages/spools/show.tsx b/client/src/pages/spools/show.tsx index 9f6ba59d6..f8dc168db 100644 --- a/client/src/pages/spools/show.tsx +++ b/client/src/pages/spools/show.tsx @@ -1,16 +1,17 @@ import { InboxOutlined, PrinterOutlined, ToTopOutlined, ToolOutlined } from "@ant-design/icons"; import { DateField, NumberField, Show, TextField } from "@refinedev/antd"; import { useInvalidate, useShow, useTranslate } from "@refinedev/core"; -import { Button, Modal, Typography } from "antd"; +import { Button, Col, Modal, Row, Typography } from "antd"; import dayjs from "dayjs"; import utc from "dayjs/plugin/utc"; import { ExtraFieldDisplay } from "../../components/extraFields"; import { NumberFieldUnit } from "../../components/numberField"; import SpoolIcon from "../../components/spoolIcon"; +import VendorLogo from "../../components/vendorLogo"; import { enrichText } from "../../utils/parsing"; import { EntityType, useGetFields } from "../../utils/queryFields"; import { useCurrencyFormatter } from "../../utils/settings"; -import { getBasePath } from "../../utils/url"; +import { getBasePath, stripBasePath } from "../../utils/url"; import { IFilament } from "../filaments/model"; import { setSpoolArchived, useSpoolAdjustModal } from "./functions"; import { ISpool } from "./model"; @@ -96,6 +97,14 @@ export const SpoolShow = () => { return <a href={URL}>{formatFilament(item)}</a>; }; + const vendorURL = (item: IFilament) => { + if (!item.vendor) { + return null; + } + const url = `/vendor/show/${item.vendor.id}`; + return <a href={url}>{item.vendor.name}</a>; + }; + const formatTitle = (item: ISpool) => { return t("spool.titles.show_title", { id: item.id, @@ -125,13 +134,13 @@ export const SpoolShow = () => { icon={<PrinterOutlined />} 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")} </Button> {record?.archived ? ( <Button icon={<ToTopOutlined />} onClick={() => archiveSpool(record, false)}> @@ -148,81 +157,113 @@ export const SpoolShow = () => { </> )} > - <Title level={5}>{t("spool.fields.id")} - - {t("spool.fields.filament")} - {colorObj && } - - {t("spool.fields.price")} - - {t("spool.fields.registered")} - - {t("spool.fields.first_used")} -

+ +
+ )} + + ); }; diff --git a/client/src/pages/vendors/create.tsx b/client/src/pages/vendors/create.tsx index 38a3e1926..4fc3a82ad 100644 --- a/client/src/pages/vendors/create.tsx +++ b/client/src/pages/vendors/create.tsx @@ -1,11 +1,13 @@ import { Create, useForm } from "@refinedev/antd"; import { HttpError, IResourceComponentsProps, useTranslate } from "@refinedev/core"; -import { Button, Form, Input, InputNumber, Typography } from "antd"; +import { AutoComplete, Button, Form, Input, InputNumber, Typography } from "antd"; import TextArea from "antd/es/input/TextArea"; import dayjs from "dayjs"; import utc from "dayjs/plugin/utc"; import { useEffect } from "react"; import { ExtraFieldFormItem, ParsedExtras, StringifiedExtras } from "../../components/extraFields"; +import { useVendorLogoManifest } from "../../components/otherModels"; +import VendorLogo from "../../components/vendorLogo"; import { EntityType, useGetFields } from "../../utils/queryFields"; import { IVendor, IVendorParsedExtras } from "./model"; @@ -18,6 +20,7 @@ interface CreateOrCloneProps { export const VendorCreate = (props: IResourceComponentsProps & CreateOrCloneProps) => { const t = useTranslate(); const extraFields = useGetFields(EntityType.vendor); + const logoManifest = useVendorLogoManifest(); const { form, formProps, formLoading, onFinish, redirect } = useForm< IVendor, @@ -35,6 +38,20 @@ export const VendorCreate = (props: IResourceComponentsProps & CreateOrCloneProp formProps.initialValues = ParsedExtras(formProps.initialValues); } + const watchedName = Form.useWatch(["name"], form); + const watchedExtra = Form.useWatch(["extra"], form) as { [key: string]: unknown } | undefined; + const logoPreviewVendor: IVendor = { + id: 0, + registered: "", + name: watchedName ?? "", + extra: { + logo_url: typeof watchedExtra?.logo_url === "string" ? watchedExtra.logo_url : "", + print_logo_url: typeof watchedExtra?.print_logo_url === "string" ? watchedExtra.print_logo_url : "", + }, + }; + const webLogoOptions = (logoManifest.data?.web_files ?? []).map((value) => ({ value })); + const printLogoOptions = (logoManifest.data?.print_files ?? []).map((value) => ({ value })); + const handleSubmit = async (redirectTo: "list" | "edit" | "create") => { const values = StringifiedExtras(await form.validateFields()); await onFinish(values); @@ -90,6 +107,101 @@ export const VendorCreate = (props: IResourceComponentsProps & CreateOrCloneProp >