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/public/locales/en/common.json b/client/public/locales/en/common.json index 88ff2ae85..60d6e802f 100644 --- a/client/public/locales/en/common.json +++ b/client/public/locales/en/common.json @@ -104,9 +104,13 @@ }, "qrcode": { "button": "Print Labels", + "exportButton": "Export Labels", + "printFilamentTitle": "Print Filament Labels", + "printSpoolTitle": "Print Spool Labels", "title": "Label Printing", "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.", + "templateHelpFilament": "Use {} to insert values of the filament object as text. For example, {id} will be replaced with the filament id, or {vendor.name} will be replaced with the vendor name. If a value is missing it will be replaced with \"?\". A second set of {} can be used to remove this. In addition, any text between the sets of {} will be removed if the value is missing. For example, {Article: {article_number}} will only show the label if a filament has an article number. Enclose text with double asterix ** to make it bold. Click the button to view a list of all available tags.", "textSize": "Label Text Size", "showContent": "Print Label", "useHTTPUrl": { @@ -133,6 +137,14 @@ "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.", + "noFilamentsSelected": "You have not selected any filaments.", + "selectAll": "Select/Unselect All", + "selectedTotal_one": "{{count}} filament selected", + "selectedTotal_other": "{{count}} filaments selected" } }, "scanner": { diff --git a/client/src/App.tsx b/client/src/App.tsx index d907b8ee1..696437e8d 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -208,6 +208,7 @@ function App() { /> } /> } /> + } /> } /> diff --git a/client/src/components/qrCodeScanner.tsx b/client/src/components/qrCodeScanner.tsx index ecf97eafb..e683a7812 100644 --- a/client/src/components/qrCodeScanner.tsx +++ b/client/src/components/qrCodeScanner.tsx @@ -18,15 +18,28 @@ const QRCodeScannerModal = () => { const result = detectedCodes[0].rawValue; // Check for the spoolman ID format - const match = result.match(/^web\+spoolman:s-(?[0-9]+)$/i); - if (match && match.groups) { + const spoolMatch = result.match(/^web\+spoolman:s-(?[0-9]+)$/i); + if (spoolMatch && spoolMatch.groups) { setVisible(false); - navigate(`/spool/show/${match.groups.id}`); + navigate(`/spool/show/${spoolMatch.groups.id}`); + return; + } + const filamentMatch = result.match(/^web\+spoolman:f-(?[0-9]+)$/i); + if (filamentMatch && filamentMatch.groups) { + setVisible(false); + navigate(`/filament/show/${filamentMatch.groups.id}`); + return; + } + const spoolURLmatch = result.match(/^https?:\/\/[^/]+(?:\/[^/]+)*\/spool\/show\/(?[0-9]+)$/i); + if (spoolURLmatch && spoolURLmatch.groups) { + setVisible(false); + navigate(`/spool/show/${spoolURLmatch.groups.id}`); + return; } - const fullURLmatch = result.match(/^https?:\/\/[^/]+\/spool\/show\/(?[0-9]+)$/i); - if (fullURLmatch && fullURLmatch.groups) { + const filamentURLmatch = result.match(/^https?:\/\/[^/]+(?:\/[^/]+)*\/filament\/show\/(?[0-9]+)$/i); + if (filamentURLmatch && filamentURLmatch.groups) { setVisible(false); - navigate(`/spool/show/${fullURLmatch.groups.id}`); + navigate(`/filament/show/${filamentURLmatch.groups.id}`); } }; diff --git a/client/src/pages/filamentPrinting/index.tsx b/client/src/pages/filamentPrinting/index.tsx new file mode 100644 index 000000000..5cea1ad38 --- /dev/null +++ b/client/src/pages/filamentPrinting/index.tsx @@ -0,0 +1,71 @@ +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 { useNavigate, useSearchParams } from "react-router"; +import FilamentQRCodePrintingDialog from "../printing/filamentQrCodePrintingDialog"; +import FilamentSelectModal from "../printing/filamentSelectModal"; + +dayjs.extend(utc); + +const { useToken } = theme; + +export const FilamentPrinting = () => { + const { token } = useToken(); + const t = useTranslate(); + const [searchParams, setSearchParams] = useSearchParams(); + const navigate = useNavigate(); + + const filamentIds = searchParams.getAll("filaments").map(Number); + const step = filamentIds.length > 0 ? 1 : 0; + + return ( + <> + { + const returnUrl = searchParams.get("return"); + if (returnUrl) { + navigate(returnUrl, { relative: "path" }); + } else { + navigate("/filament"); + } + }} + > + + {step === 0 && ( + { + setSearchParams((prev) => { + const newParams = new URLSearchParams(prev); + newParams.delete("filaments"); + selectedFilamentIds.forEach((id) => newParams.append("filaments", id.toString())); + newParams.set("return", "/filament/print"); + return newParams; + }); + }} + /> + )} + {step === 1 && } + + + > + ); +}; + +export default FilamentPrinting; diff --git a/client/src/pages/filaments/functions.ts b/client/src/pages/filaments/functions.ts index e19d5b3db..2187e0c7b 100644 --- a/client/src/pages/filaments/functions.ts +++ b/client/src/pages/filaments/functions.ts @@ -1,3 +1,4 @@ +import { useQueries } from "@tanstack/react-query"; import { ExternalFilament } from "../../utils/queryExternalDB"; import { getAPIURL } from "../../utils/url"; import { getOrCreateVendorFromExternal } from "../vendors/functions"; @@ -48,3 +49,24 @@ export async function createFilamentFromExternal(externalFilament: ExternalFilam } return response.json(); } + +/** + * Returns an array of queries using the useQueries hook from @tanstack/react-query. + * Each query fetches a filament by its ID from the server. + * + * @param {number[]} ids - An array of filament IDs to fetch. + * @return An array of query results, each containing the fetched filament data. + */ +export function useGetFilamentsByIds(ids: number[]) { + return useQueries({ + queries: ids.map((id) => { + return { + queryKey: ["filament", id], + queryFn: async () => { + const res = await fetch(getAPIURL() + "/filament/" + id); + return (await res.json()) as IFilament; + }, + }; + }), + }); +} diff --git a/client/src/pages/filaments/list.tsx b/client/src/pages/filaments/list.tsx index 2d42198ce..e49c531fe 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 = () => { ( <> + } + onClick={() => { + navigate("print"); + }} + > + {t("printing.qrcode.button")} + } diff --git a/client/src/pages/filaments/show.tsx b/client/src/pages/filaments/show.tsx index 4bc6c66be..613d99a61 100644 --- a/client/src/pages/filaments/show.tsx +++ b/client/src/pages/filaments/show.tsx @@ -1,5 +1,6 @@ import { DateField, NumberField, Show, TextField } from "@refinedev/antd"; import { useShow, useTranslate } from "@refinedev/core"; +import { PrinterOutlined } from "@ant-design/icons"; import { Button, Typography } from "antd"; import dayjs from "dayjs"; import utc from "dayjs/plugin/utc"; @@ -10,6 +11,7 @@ import SpoolIcon from "../../components/spoolIcon"; import { enrichText } from "../../utils/parsing"; import { EntityType, useGetFields } from "../../utils/queryFields"; import { useCurrencyFormatter } from "../../utils/settings"; +import { getBasePath } from "../../utils/url"; import { IFilament } from "./model"; dayjs.extend(utc); @@ -65,6 +67,19 @@ export const FilamentShow = () => { {t("filament.fields.spools")} + } + href={ + getBasePath() + + "/filament/print?filaments=" + + record?.id + + "&return=" + + encodeURIComponent(window.location.pathname) + } + > + {t("printing.qrcode.button")} + {defaultButtons} > )} diff --git a/client/src/pages/printing/filamentQrCodePrintingDialog.tsx b/client/src/pages/printing/filamentQrCodePrintingDialog.tsx new file mode 100644 index 000000000..d470b8ca6 --- /dev/null +++ b/client/src/pages/printing/filamentQrCodePrintingDialog.tsx @@ -0,0 +1,342 @@ +import { CopyOutlined, DeleteOutlined, PlusOutlined, SaveOutlined } from "@ant-design/icons"; +import { useTranslate } from "@refinedev/core"; +import { Button, Flex, Form, Input, Modal, Popconfirm, Select, Table, Typography, message } from "antd"; +import TextArea from "antd/es/input/TextArea"; +import { useState } from "react"; +import { v4 as uuidv4 } from "uuid"; +import { EntityType, useGetFields } from "../../utils/queryFields"; +import { parseStringSettingValue, useGetSetting } from "../../utils/querySettings"; +import { useSavedState } from "../../utils/saveload"; +import { useGetFilamentsByIds } from "../filaments/functions"; +import { IFilament } from "../filaments/model"; +import { + SpoolQRCodePrintSettings, + renderLabelContents, + useGetPrintSettings as useGetPrintPresets, + useSetPrintSettings as useSetPrintPresets, +} from "./printing"; +import QRCodePrintingDialog from "./qrCodePrintingDialog"; + +const { Text } = Typography; + +interface FilamentQRCodePrintingDialogProps { + filamentIds: number[]; +} + +const FilamentQRCodePrintingDialog = ({ filamentIds }: FilamentQRCodePrintingDialogProps) => { + const t = useTranslate(); + const baseUrlSetting = useGetSetting("base_url"); + const baseUrl = parseStringSettingValue(baseUrlSetting.data?.value); + const baseUrlRoot = baseUrl !== "" ? baseUrl : window.location.origin; + const [messageApi, contextHolder] = message.useMessage(); + const [useHTTPUrl, setUseHTTPUrl] = useSavedState("print-useHTTPUrl-filament", false); + + const itemQueries = useGetFilamentsByIds(filamentIds); + const items = itemQueries + .map((itemQuery) => { + return itemQuery.data ?? null; + }) + .filter((item) => item !== null) as IFilament[]; + + const [selectedPresetState, setSelectedPresetState] = useSavedState( + "selectedPresetFilament", + undefined, + ); + + const [localPresets, setLocalPresets] = useState(); + const remotePresets = useGetPrintPresets("print_presets_filament"); + const setRemotePresets = useSetPrintPresets("print_presets_filament"); + + const localOrRemotePresets = localPresets ?? remotePresets; + + const savePresetsRemote = () => { + if (!localPresets) return; + setRemotePresets(localPresets); + }; + + const addNewPreset = () => { + if (!localOrRemotePresets) return; + const newId = uuidv4(); + const newPreset = { + labelSettings: { + printSettings: { + id: newId, + name: t("printing.generic.newSetting"), + }, + }, + }; + setLocalPresets([...localOrRemotePresets, newPreset]); + setSelectedPresetState(newId); + return newPreset; + }; + const duplicateCurrentPreset = () => { + if (!localOrRemotePresets) return; + const newPreset = { + ...curPreset, + labelSettings: { ...curPreset.labelSettings, printSettings: { ...curPreset.labelSettings.printSettings } }, + }; + newPreset.labelSettings.printSettings.id = uuidv4(); + setLocalPresets([...localOrRemotePresets, newPreset]); + setSelectedPresetState(newPreset.labelSettings.printSettings.id); + }; + const updateCurrentPreset = (newSettings: SpoolQRCodePrintSettings) => { + if (!localOrRemotePresets) return; + setLocalPresets( + localOrRemotePresets.map((presets) => + presets.labelSettings.printSettings.id === newSettings.labelSettings.printSettings.id ? newSettings : presets, + ), + ); + }; + const deleteCurrentPreset = () => { + if (!localOrRemotePresets) return; + setLocalPresets( + localOrRemotePresets.filter((qPreset) => qPreset.labelSettings.printSettings.id !== selectedPresetState), + ); + setSelectedPresetState(undefined); + }; + + let curPreset: SpoolQRCodePrintSettings; + if (localOrRemotePresets === undefined) { + curPreset = { + labelSettings: { + printSettings: { + id: "TEMP", + name: t("printing.generic.newSetting"), + }, + }, + }; + } else { + if (localOrRemotePresets.length === 0) { + const newSetting = addNewPreset(); + if (!newSetting) { + console.error("Error adding new setting, this should never happen"); + return; + } + localOrRemotePresets.push(newSetting); + curPreset = newSetting; + } else { + if (!selectedPresetState) { + curPreset = localOrRemotePresets[0]; + setSelectedPresetState(localOrRemotePresets[0].labelSettings.printSettings.id); + } else { + const foundSetting = localOrRemotePresets.find( + (settings) => settings.labelSettings.printSettings.id === selectedPresetState, + ); + if (foundSetting) { + curPreset = foundSetting; + } else { + curPreset = { + labelSettings: { + printSettings: { + id: "TEMP", + name: t("printing.generic.newSetting"), + }, + }, + }; + } + } + } + } + + const [templateHelpOpen, setTemplateHelpOpen] = useState(false); + const template = + curPreset.template ?? + `**{vendor.name} - {name} +#{id} - {material}** +{Diameter: {diameter} mm} +{Weight: {weight} g} +{Spool Weight: {spool_weight} g} +{ET: {settings_extruder_temp} °C} +{BT: {settings_bed_temp} °C} +{Article: {article_number}} +{{comment}} +{comment} +{vendor.comment}`; + + const filamentTags = [ + { tag: "id" }, + { tag: "registered" }, + { tag: "name" }, + { tag: "material" }, + { tag: "price" }, + { tag: "density" }, + { tag: "diameter" }, + { tag: "weight" }, + { tag: "spool_weight" }, + { tag: "article_number" }, + { tag: "comment" }, + { tag: "settings_extruder_temp" }, + { tag: "settings_bed_temp" }, + { tag: "color_hex" }, + { tag: "multi_color_hexes" }, + { tag: "multi_color_direction" }, + { tag: "external_id" }, + ]; + const filamentFields = useGetFields(EntityType.filament); + if (filamentFields.data !== undefined) { + filamentFields.data.forEach((field) => { + filamentTags.push({ tag: `extra.${field.key}` }); + }); + } + const vendorTags = [ + { tag: "vendor.id" }, + { tag: "vendor.registered" }, + { tag: "vendor.name" }, + { tag: "vendor.comment" }, + { tag: "vendor.empty_spool_weight" }, + { tag: "vendor.external_id" }, + ]; + const vendorFields = useGetFields(EntityType.vendor); + if (vendorFields.data !== undefined) { + vendorFields.data.forEach((field) => { + vendorTags.push({ tag: `vendor.extra.${field.key}` }); + }); + } + + const templateTags = [...filamentTags, ...vendorTags]; + + return ( + <> + {contextHolder} + { + updateCurrentPreset({ ...curPreset, labelSettings: newSettings }); + }} + baseUrlRoot={baseUrlRoot} + useHTTPUrl={useHTTPUrl} + setUseHTTPUrl={setUseHTTPUrl} + previewValues={{ + default: "WEB+SPOOLMAN:F-{id}", + url: `${baseUrlRoot}/filament/show/{id}`, + }} + extraSettingsStart={ + <> + + + { + setSelectedPresetState(value); + }} + options={ + localOrRemotePresets && + localOrRemotePresets.map((settings) => ({ + label: settings.labelSettings.printSettings?.name || t("printing.generic.defaultSettings"), + value: settings.labelSettings.printSettings.id, + })) + } + > + } + title={t("printing.generic.addSettings")} + onClick={addNewPreset} + /> + } + title={t("printing.generic.duplicateSettings")} + onClick={duplicateCurrentPreset} + /> + {localOrRemotePresets && localOrRemotePresets.length > 1 && ( + + } + title={t("printing.generic.deleteSettings")} + /> + + )} + + + + { + // Triple-spread: name is 3 levels deep; each level must be copied to + // avoid mutating the preset array element referenced by curPreset. + updateCurrentPreset({ + ...curPreset, + labelSettings: { + ...curPreset.labelSettings, + printSettings: { ...curPreset.labelSettings.printSettings, name: e.target.value }, + }, + }); + }} + /> + + > + } + items={items.map((filament) => ({ + value: useHTTPUrl ? `${baseUrlRoot}/filament/show/${filament.id}` : `WEB+SPOOLMAN:F-${filament.id}`, + label: ( + + {renderLabelContents(template, filament)} + + ), + errorLevel: "H", + }))} + extraSettings={ + <> + + { + updateCurrentPreset({ ...curPreset, template: newValue.target.value }); + }} + /> + + setTemplateHelpOpen(false)}> + + + + {t("printing.qrcode.templateHelpFilament")}{" "} + setTemplateHelpOpen(true)}> + {t("actions.show")} + + + > + } + extraButtons={ + <> + } + onClick={() => { + savePresetsRemote(); + messageApi.success(t("notifications.saveSuccessful")); + }} + > + {t("printing.generic.saveSetting")} + + > + } + /> + > + ); +}; + +export default FilamentQRCodePrintingDialog; diff --git a/client/src/pages/printing/filamentSelectModal.tsx b/client/src/pages/printing/filamentSelectModal.tsx new file mode 100644 index 000000000..59bfdce3e --- /dev/null +++ b/client/src/pages/printing/filamentSelectModal.tsx @@ -0,0 +1,360 @@ +import { useTable } from "@refinedev/antd"; +import { Button, Checkbox, Col, Input, message, Pagination, Row, Space, 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; + onPrint?: (selectedFilamentIds: number[]) => void; + onExport?: (selectedFilamentIds: number[]) => void; + initialSelectedIds?: number[]; + 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, onPrint, onExport, initialSelectedIds, searchPlaceholder }: Props) => { + const [selectedItems, setSelectedItems] = useState(initialSelectedIds ?? []); + const [messageApi, contextHolder] = message.useMessage(); + const navigate = useNavigate(); + const [tableScrollY, setTableScrollY] = useState(300); + const [searchTerm, setSearchTerm] = useState(""); + const [debouncedSearch, setDebouncedSearch] = useState(""); + const rootRef = useRef(null); + const tableContainerRef = useRef(null); + + const { tableProps, sorters, filters, setFilters, currentPage, pageSize, setCurrentPage, setPageSize } = + useTable({ + 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} + + {(resolvedDescription || tableProps.pagination) && ( + + {resolvedDescription && {resolvedDescription}} + {tableProps.pagination && ( + + + + )} + + )} + + + { + setSearchTerm(event.target.value); + }} + onSearch={(value) => { + setSearchTerm(value); + }} + /> + + + + + { + setSearchTerm(""); + setFilters([], "replace"); + setCurrentPage(1); + }} + > + {t("buttons.clearFilters")} + + + + + { + selectUnselectFiltered(e.target.checked); + }} + > + {t("printing.filamentSelect.selectAll")} + + + {t("printing.filamentSelect.selectedTotal", { + count: selectedItems.length, + })} + + + {onPrint && ( + { + if (selectedItems.length === 0) { + messageApi.open({ + type: "error", + content: t("printing.filamentSelect.noFilamentsSelected"), + }); + return; + } + onPrint(selectedItems); + }} + > + {t("printing.qrcode.button")} + + )} + {onExport && ( + { + if (selectedItems.length === 0) { + messageApi.open({ + type: "error", + content: t("printing.filamentSelect.noFilamentsSelected"), + }); + return; + } + onExport(selectedItems); + }} + > + {t("printing.qrcode.exportButton")} + + )} + + + + + + ( + handleSelectItem(item.id)} /> + ), + }, + SortedColumn({ + ...commonProps, + id: "id", + i18ncat: "filament", + width: 70, + }), + FilteredQueryColumn({ + ...commonProps, + id: "vendor.name", + i18nkey: "filament.fields.vendor_name", + 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, + }), + ])} + /> + + + > + ); +}; + +export default FilamentSelectModal; diff --git a/client/src/pages/printing/index.tsx b/client/src/pages/printing/index.tsx index f6ddc4246..bfdfa29b4 100644 --- a/client/src/pages/printing/index.tsx +++ b/client/src/pages/printing/index.tsx @@ -24,7 +24,7 @@ export const Printing = () => { return ( <> { const returnUrl = searchParams.get("return"); if (returnUrl) { diff --git a/client/src/pages/printing/printing.tsx b/client/src/pages/printing/printing.tsx index 59b47895f..19e4dc693 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; @@ -30,8 +29,8 @@ export interface SpoolQRCodePrintSettings { labelSettings: QRCodePrintSettings; } -export function useGetPrintSettings(): SpoolQRCodePrintSettings[] | undefined { - const { data } = useGetSetting("print_presets"); +export function useGetPrintSettings(settingKey = "print_presets"): SpoolQRCodePrintSettings[] | undefined { + const { data } = useGetSetting(settingKey); if (!data) return; const parsed: SpoolQRCodePrintSettings[] = data && data.value ? JSON.parse(data.value) : ([] as SpoolQRCodePrintSettings[]); @@ -44,8 +43,10 @@ export function useGetPrintSettings(): SpoolQRCodePrintSettings[] | undefined { }); } -export function useSetPrintSettings(): (spoolQRCodePrintSettings: SpoolQRCodePrintSettings[]) => void { - const mut = useSetSetting("print_presets"); +export function useSetPrintSettings( + settingKey = "print_presets", +): (spoolQRCodePrintSettings: SpoolQRCodePrintSettings[]) => void { + const mut = useSetSetting(settingKey); return (spoolQRCodePrintSettings: SpoolQRCodePrintSettings[]) => { mut.mutate(spoolQRCodePrintSettings); @@ -99,20 +100,20 @@ function applyTextFormatting(text: string): ReactElement[] { return elements; } -export function renderLabelContents(template: string, spool: ISpool): ReactElement { +export function renderLabelContents(template: string, obj: GenericObject): ReactElement { // Find all {tags} in the template string and loop over them const matches = [...template.matchAll(/{(?:[^}{]|{[^}{]*})*}/gs)]; let label_text = template; matches.forEach((match) => { if ((match[0].match(/{/g) || []).length == 1) { const tag = match[0].replace(/[{}]/g, ""); - const tagValue = getTagValue(tag, spool); + const tagValue = getTagValue(tag, obj); label_text = label_text.replace(match[0], 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); + const tagValue = getTagValue(tag, obj); if (tagValue == "?") { label_text = label_text.replace(match[0], ""); } else { 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/qrCodePrintingDialog.tsx b/client/src/pages/printing/qrCodePrintingDialog.tsx index 46967c08c..1d8fbd47f 100644 --- a/client/src/pages/printing/qrCodePrintingDialog.tsx +++ b/client/src/pages/printing/qrCodePrintingDialog.tsx @@ -23,8 +23,10 @@ interface QRCodePrintingDialogProps { baseUrlRoot: string; useHTTPUrl: boolean; setUseHTTPUrl: (value: boolean) => void; + previewValues?: { default: string; url: string }; } +// Layer QR-specific controls on top of the shared sheet-printing dialog used by spool and filament labels. const QRCodePrintingDialog = ({ items, printSettings, @@ -35,13 +37,16 @@ const QRCodePrintingDialog = ({ baseUrlRoot, useHTTPUrl, setUseHTTPUrl, + previewValues, }: QRCodePrintingDialogProps) => { const t = useTranslate(); const showContent = printSettings?.showContent === undefined ? true : printSettings?.showContent; const showQRCodeMode = printSettings?.showQRCodeMode || "withIcon"; const textSize = printSettings?.textSize || 3; + const preview = previewValues ?? ({ default: `WEB+SPOOLMAN:S-{id}`, url: `${baseUrlRoot}/spool/show/{id}` } as const); + // Build the printable QR blocks here; the underlying dialog handles page layout and export mechanics. const elements = items.map((item, idx) => { return ( @@ -71,8 +76,8 @@ const QRCodePrintingDialog = ({ items={elements} printSettings={printSettings.printSettings} setPrintSettings={(newSettings) => { - printSettings.printSettings = newSettings; - setPrintSettings(printSettings); + // Spread to preserve immutability — printSettings.printSettings is a nested object + setPrintSettings({ ...printSettings, printSettings: newSettings }); }} extraButtons={extraButtons} extraSettingsStart={extraSettingsStart} @@ -89,8 +94,7 @@ const QRCodePrintingDialog = ({ { label: t("printing.qrcode.showQRCodeMode.withIcon"), value: "withIcon" }, ]} onChange={(e: RadioChangeEvent) => { - printSettings.showQRCodeMode = e.target.value; - setPrintSettings(printSettings); + setPrintSettings({ ...printSettings, showQRCodeMode: e.target.value }); }} value={showQRCodeMode} optionType="button" @@ -110,7 +114,8 @@ const QRCodePrintingDialog = ({ - {useHTTPUrl ? `${baseUrlRoot}/spool/show/{id}` : `WEB+SPOOLMAN:S-{id}`} + {/* Mirror the encoded payload so users can confirm which QR format the preset will emit. */} + {useHTTPUrl ? preview.url : preview.default} > )} @@ -118,8 +123,7 @@ const QRCodePrintingDialog = ({ { - printSettings.showContent = checked; - setPrintSettings(printSettings); + setPrintSettings({ ...printSettings, showContent: checked }); }} /> @@ -134,8 +138,7 @@ const QRCodePrintingDialog = ({ value={textSize} step={0.1} onChange={(value) => { - printSettings.textSize = value; - setPrintSettings(printSettings); + setPrintSettings({ ...printSettings, textSize: value }); }} /> @@ -148,8 +151,7 @@ const QRCodePrintingDialog = ({ value={textSize} addonAfter="mm" onChange={(value) => { - printSettings.textSize = value ?? 5; - setPrintSettings(printSettings); + setPrintSettings({ ...printSettings, textSize: value ?? 5 }); }} /> diff --git a/client/src/pages/printing/spoolQrCodePrintingDialog.tsx b/client/src/pages/printing/spoolQrCodePrintingDialog.tsx index 865c597cf..3392b60be 100644 --- a/client/src/pages/printing/spoolQrCodePrintingDialog.tsx +++ b/client/src/pages/printing/spoolQrCodePrintingDialog.tsx @@ -5,7 +5,7 @@ import TextArea from "antd/es/input/TextArea"; import { useState } from "react"; import { v4 as uuidv4 } from "uuid"; import { EntityType, useGetFields } from "../../utils/queryFields"; -import { useGetSetting } from "../../utils/querySettings"; +import { parseStringSettingValue, useGetSetting } from "../../utils/querySettings"; import { useSavedState } from "../../utils/saveload"; import { useGetSpoolsByIds } from "../spools/functions"; import { ISpool } from "../spools/model"; @@ -26,10 +26,8 @@ interface SpoolQRCodePrintingDialog { const SpoolQRCodePrintingDialog = ({ spoolIds }: SpoolQRCodePrintingDialog) => { const t = useTranslate(); const baseUrlSetting = useGetSetting("base_url"); - const baseUrlRoot = - baseUrlSetting.data?.value !== undefined && JSON.parse(baseUrlSetting.data?.value) !== "" - ? JSON.parse(baseUrlSetting.data?.value) - : window.location.origin; + const baseUrl = parseStringSettingValue(baseUrlSetting.data?.value); + const baseUrlRoot = baseUrl !== "" ? baseUrl : window.location.origin; const [messageApi, contextHolder] = message.useMessage(); const [useHTTPUrl, setUseHTTPUrl] = useSavedState("print-useHTTPUrl", false); @@ -235,12 +233,15 @@ Spool Weight: {filament.spool_weight} g { - curPreset.labelSettings = newSettings; - updateCurrentPreset(curPreset); + updateCurrentPreset({ ...curPreset, labelSettings: newSettings }); }} baseUrlRoot={baseUrlRoot} useHTTPUrl={useHTTPUrl} setUseHTTPUrl={setUseHTTPUrl} + previewValues={{ + default: "WEB+SPOOLMAN:S-{id}", + url: `${baseUrlRoot}/spool/show/{id}`, + }} extraSettingsStart={ <> @@ -292,8 +293,15 @@ Spool Weight: {filament.spool_weight} g { - curPreset.labelSettings.printSettings.name = e.target.value; - updateCurrentPreset(curPreset); + // Triple-spread: name is 3 levels deep; each level must be copied to + // avoid mutating the preset array element referenced by curPreset. + updateCurrentPreset({ + ...curPreset, + labelSettings: { + ...curPreset.labelSettings, + printSettings: { ...curPreset.labelSettings.printSettings, name: e.target.value }, + }, + }); }} /> @@ -321,8 +329,7 @@ Spool Weight: {filament.spool_weight} g value={template} rows={8} onChange={(newValue) => { - curPreset.template = newValue.target.value; - updateCurrentPreset(curPreset); + updateCurrentPreset({ ...curPreset, template: newValue.target.value }); }} /> diff --git a/client/src/pages/printing/spoolSelectModal.tsx b/client/src/pages/printing/spoolSelectModal.tsx index 91d8fdb36..5ba0792ac 100644 --- a/client/src/pages/printing/spoolSelectModal.tsx +++ b/client/src/pages/printing/spoolSelectModal.tsx @@ -100,9 +100,12 @@ const SpoolSelectModal = ({ description, onContinue }: Props) => { }; // State for the select/unselect all checkbox - const isAllFilteredSelected = dataSource.every((spool) => selectedItems.includes(spool.id)); + // Memoised Set for O(1) membership checks — avoids O(n²) when dataSource and + // selectedItems are both large (many loaded spools, many already selected). + const selectedSet = useMemo(() => new Set(selectedItems), [selectedItems]); + const isAllFilteredSelected = dataSource.length > 0 && dataSource.every((spool) => selectedSet.has(spool.id)); const isSomeButNotAllFilteredSelected = - dataSource.some((spool) => selectedItems.includes(spool.id)) && !isAllFilteredSelected; + dataSource.some((spool) => selectedSet.has(spool.id)) && !isAllFilteredSelected; const commonProps = { t, @@ -131,7 +134,7 @@ const SpoolSelectModal = ({ description, onContinue }: Props) => { { width: 50, render: (_, item: ISpool) => ( - handleSelectItem(item.id)} /> + handleSelectItem(item.id)} /> ), }, SortedColumn({ diff --git a/client/src/pages/settings/generalSettings.tsx b/client/src/pages/settings/generalSettings.tsx index 9c1a2190b..9e38ca90d 100644 --- a/client/src/pages/settings/generalSettings.tsx +++ b/client/src/pages/settings/generalSettings.tsx @@ -1,7 +1,7 @@ import { useTranslate } from "@refinedev/core"; import { Button, Checkbox, Form, Input, message } from "antd"; import { useEffect } from "react"; -import { useGetSettings, useSetSetting } from "../../utils/querySettings"; +import { parseStringSettingValue, useGetSettings, useSetSetting } from "../../utils/querySettings"; export function GeneralSettings() { const settings = useGetSettings(); @@ -17,7 +17,7 @@ export function GeneralSettings() { if (settings.data) { form.setFieldsValue({ currency: JSON.parse(settings.data.currency.value), - base_url: JSON.parse(settings.data.base_url.value), + base_url: parseStringSettingValue(settings.data.base_url.value), round_prices: JSON.parse(settings.data.round_prices.value), }); } diff --git a/client/src/utils/querySettings.ts b/client/src/utils/querySettings.ts index 9de02fde4..3980cd65e 100644 --- a/client/src/utils/querySettings.ts +++ b/client/src/utils/querySettings.ts @@ -11,6 +11,17 @@ interface SettingsResponse { [key: string]: SettingResponseValue; } +export function parseStringSettingValue(value: string | undefined, fallback = ""): string { + if (value === undefined) return fallback; + + try { + const parsed = JSON.parse(value); + return typeof parsed === "string" ? parsed : fallback; + } catch { + return value; + } +} + export function useGetSettings() { return useQuery({ queryKey: ["settings"], diff --git a/spoolman/api/v1/filament.py b/spoolman/api/v1/filament.py index 3e3f859af..08764e6ff 100644 --- a/spoolman/api/v1/filament.py +++ b/spoolman/api/v1/filament.py @@ -202,6 +202,17 @@ def prevent_none(cls: type["FilamentUpdateParameters"], v: float | None) -> floa async def find( *, db: Annotated[AsyncSession, Depends(get_db_session)], + search: Annotated[ + str | None, + Query( + title="Search", + description=( + "Partial case-insensitive search term applied across filament ID, vendor name, name, material, and " + "article number. Separate multiple terms with a comma. Surround a term with quotes to search for " + "the exact term." + ), + ), + ] = None, vendor_name_old: Annotated[ str | None, Query(alias="vendor_name", title="Vendor Name", description="See vendor.name.", deprecated=True), @@ -345,6 +356,7 @@ async def find( db_items, total_count = await filament.find( db=db, ids=filter_by_ids, + search=search, vendor_name=vendor_name if vendor_name is not None else vendor_name_old, vendor_id=vendor_ids, name=name, @@ -433,8 +445,9 @@ async def create( # noqa: ANN201 db: Annotated[AsyncSession, Depends(get_db_session)], body: FilamentParameters, ): - if body.extra: - all_fields = await get_extra_fields(db, EntityType.filament) + # Fetch extra field definitions once at endpoint entry + all_fields = await get_extra_fields(db, EntityType.filament) if body.extra else None + if body.extra and all_fields: try: validate_extra_field_dict(all_fields, body.extra) except ValueError as e: @@ -485,8 +498,9 @@ async def update( # noqa: ANN201 ): patch_data = body.model_dump(exclude_unset=True) - if body.extra: - all_fields = await get_extra_fields(db, EntityType.filament) + # Fetch extra field definitions once at endpoint entry + all_fields = await get_extra_fields(db, EntityType.filament) if body.extra else None + if body.extra and all_fields: try: validate_extra_field_dict(all_fields, body.extra) except ValueError as e: diff --git a/spoolman/api/v1/spool.py b/spoolman/api/v1/spool.py index 8f667e3da..c517a5812 100644 --- a/spoolman/api/v1/spool.py +++ b/spoolman/api/v1/spool.py @@ -389,8 +389,9 @@ async def create( # noqa: ANN201 content={"message": "Only specify either remaining_weight or used_weight."}, ) - if body.extra: - all_fields = await get_extra_fields(db, EntityType.spool) + # Fetch extra field definitions once at endpoint entry + all_fields = await get_extra_fields(db, EntityType.spool) if body.extra else None + if body.extra and all_fields: try: validate_extra_field_dict(all_fields, body.extra) except ValueError as e: @@ -451,8 +452,9 @@ async def update( # noqa: ANN201 content={"message": "Only specify either remaining_weight or used_weight."}, ) - if body.extra: - all_fields = await get_extra_fields(db, EntityType.spool) + # Fetch extra field definitions once at endpoint entry + all_fields = await get_extra_fields(db, EntityType.spool) if body.extra else None + if body.extra and all_fields: try: validate_extra_field_dict(all_fields, body.extra) except ValueError as e: diff --git a/spoolman/api/v1/vendor.py b/spoolman/api/v1/vendor.py index 9216fba30..5ed4b02a3 100644 --- a/spoolman/api/v1/vendor.py +++ b/spoolman/api/v1/vendor.py @@ -209,8 +209,9 @@ async def create( # noqa: ANN201 db: Annotated[AsyncSession, Depends(get_db_session)], body: VendorParameters, ): - if body.extra: - all_fields = await get_extra_fields(db, EntityType.vendor) + # Fetch extra field definitions once at endpoint entry + all_fields = await get_extra_fields(db, EntityType.vendor) if body.extra else None + if body.extra and all_fields: try: validate_extra_field_dict(all_fields, body.extra) except ValueError as e: @@ -249,8 +250,9 @@ async def update( # noqa: ANN201 ): patch_data = body.model_dump(exclude_unset=True) - if body.extra: - all_fields = await get_extra_fields(db, EntityType.vendor) + # Fetch extra field definitions once at endpoint entry + all_fields = await get_extra_fields(db, EntityType.vendor) if body.extra else None + if body.extra and all_fields: try: validate_extra_field_dict(all_fields, body.extra) except ValueError as e: diff --git a/spoolman/database/filament.py b/spoolman/database/filament.py index e2d742758..d1faad1ba 100644 --- a/spoolman/database/filament.py +++ b/spoolman/database/filament.py @@ -92,10 +92,56 @@ async def get_by_id(db: AsyncSession, filament_id: int) -> models.Filament: return filament +def _build_search_filters(search: str) -> list: + """Build search filter conditions for filament search. + + Supports comma-separated terms, exact matching (quoted strings), fuzzy matching, + and numeric ID matching. + + Args: + search: Comma-separated search query with optional quoted exact-match terms. + + Returns: + List of SQLAlchemy filter conditions to be combined with OR. + + """ + search_conditions = [] + for value_part in search.split(","): + if len(value_part) == 0: + continue + + if value_part[0] == '"' and value_part[-1] == '"': + exact_value = value_part[1:-1] + search_conditions.extend( + [ + models.Vendor.name == exact_value, + models.Filament.name == exact_value, + models.Filament.material == exact_value, + models.Filament.article_number == exact_value, + ], + ) + if exact_value.lstrip("-").isdigit(): + search_conditions.append(models.Filament.id == int(exact_value)) + else: + fuzzy_value = f"%{value_part}%" + search_conditions.extend( + [ + models.Vendor.name.ilike(fuzzy_value), + models.Filament.name.ilike(fuzzy_value), + models.Filament.material.ilike(fuzzy_value), + models.Filament.article_number.ilike(fuzzy_value), + sqlalchemy.cast(models.Filament.id, sqlalchemy.String).ilike(fuzzy_value), + ], + ) + + return search_conditions + + async def find( *, db: AsyncSession, ids: list[int] | None = None, + search: str | None = None, vendor_name: str | None = None, vendor_id: int | Sequence[int] | None = None, name: str | None = None, @@ -126,6 +172,10 @@ async def find( stmt = add_where_clause_str_opt(stmt, models.Filament.material, material) stmt = add_where_clause_str_opt(stmt, models.Filament.article_number, article_number) stmt = add_where_clause_str_opt(stmt, models.Filament.external_id, external_id) + if search is not None: + search_conditions = _build_search_filters(search) + if search_conditions: + stmt = stmt.where(sqlalchemy.or_(*search_conditions)) total_count = None diff --git a/spoolman/settings.py b/spoolman/settings.py index 0c6ce3193..f63d1e77b 100644 --- a/spoolman/settings.py +++ b/spoolman/settings.py @@ -64,6 +64,7 @@ def parse_setting(key: str) -> SettingDefinition: register_setting("currency", SettingType.STRING, json.dumps("EUR")) register_setting("round_prices", SettingType.BOOLEAN, json.dumps(obj=False)) register_setting("print_presets", SettingType.ARRAY, json.dumps([])) +register_setting("print_presets_filament", SettingType.ARRAY, json.dumps([])) register_setting("extra_fields_vendor", SettingType.ARRAY, json.dumps([])) register_setting("extra_fields_filament", SettingType.ARRAY, json.dumps([]))
+ {renderLabelContents(template, filament)} +