+
-
- {itemDivs}
-
+ {itemDivs}
-
-
- {`${previewFilenameBases[pageIdx] ?? `label-${pageIdx + 1}`}${previewExtension}`}
-
-
);
});
@@ -275,17 +249,6 @@ const ExportDialog = ({
};
};
- const sanitizeFilename = (value: string) => {
- const trimmed = value.trim();
- if (trimmed === "") {
- return "";
- }
- return trimmed
- .replace(/[<>:"/\\|?*\x00-\x1F]/g, "-")
- .replace(/\s+/g, " ")
- .replace(/\.+$/g, "");
- };
-
const getUniqueExportItems = () => {
const hasPrinted: Element[] = [];
const itemsToPrint = getPrintItems();
@@ -293,8 +256,9 @@ const ExportDialog = ({
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 printing copies
+ // 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])) {
@@ -349,6 +313,7 @@ const ExportDialog = ({
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());
@@ -391,12 +356,6 @@ const ExportDialog = ({
flexDirection: "column",
}}
>
- {exportAsZip && (
-
- {t("printing.generic.zipFilenamePreview")}:{" "}
- {zipFilename}
-
- )}
@@ -462,7 +439,7 @@ const ExportDialog = ({
- {extraExportSettings}
+ {extraFormatSettings}
diff --git a/client/src/pages/printing/filamentQrCodeExportDialog.tsx b/client/src/pages/printing/filamentQrCodeExportDialog.tsx
index 88863943a..f905f605d 100644
--- a/client/src/pages/printing/filamentQrCodeExportDialog.tsx
+++ b/client/src/pages/printing/filamentQrCodeExportDialog.tsx
@@ -1,9 +1,8 @@
import { CopyOutlined, DeleteOutlined, PlusOutlined, SaveOutlined } from "@ant-design/icons";
import { useTranslate } from "@refinedev/core";
-import { Button, Flex, Form, Input, Modal, Popconfirm, Select, Typography, message } from "antd";
+import { Button, Flex, Form, Input, Modal, Popconfirm, Select, Table, Typography, message } from "antd";
import TextArea from "antd/es/input/TextArea";
-import ResizableTable from "../../components/resizableTable";
-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";
@@ -11,6 +10,7 @@ import { useSavedState } from "../../utils/saveload";
import { useGetFilamentsByIds } from "../filaments/functions";
import { IFilament } from "../filaments/model";
import {
+ getConfiguredBaseUrl,
SpoolQRCodePrintSettings,
renderLabelContents,
renderTemplateText,
@@ -25,11 +25,13 @@ 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();
@@ -49,7 +51,11 @@ const FilamentQRCodeExportDialog = ({ filamentIds }: FilamentQRCodeExportDialogP
}
return `${normalizedBaseName}-${String(maxSuffix + 1).padStart(2, "0")}`;
};
- const buildNewPreset = (id: string, name: string, sourcePreset?: SpoolQRCodePrintSettings): SpoolQRCodePrintSettings => {
+ const buildNewPreset = (
+ id: string,
+ name: string,
+ sourcePreset?: SpoolQRCodePrintSettings,
+ ): SpoolQRCodePrintSettings => {
const copiedSourcePrintSettings = sourcePreset?.labelSettings?.printSettings ?? {};
return {
...sourcePreset,
@@ -78,10 +84,8 @@ const FilamentQRCodeExportDialog = ({ filamentIds }: FilamentQRCodeExportDialogP
};
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("export-useHTTPUrl-filament", false);
@@ -105,12 +109,21 @@ const FilamentQRCodeExportDialog = ({ filamentIds }: FilamentQRCodeExportDialogP
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);
- setLocalCurrentPresets(undefined);
};
+ 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;
@@ -122,15 +135,11 @@ const FilamentQRCodeExportDialog = ({ filamentIds }: FilamentQRCodeExportDialogP
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 = {
- ...preset,
- labelSettings: {
- ...preset.labelSettings,
- printSettings: {
- ...preset.labelSettings.printSettings,
- id: uuidv4(),
- },
- },
+ ...buildNewPreset(uuidv4(), promotedName, preset),
};
const nextPresets = [...currentPresets, promotedPreset];
setLocalCurrentPresets(nextPresets);
@@ -138,6 +147,7 @@ const FilamentQRCodeExportDialog = ({ filamentIds }: FilamentQRCodeExportDialogP
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();
@@ -149,6 +159,7 @@ const FilamentQRCodeExportDialog = ({ filamentIds }: FilamentQRCodeExportDialogP
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 = {
@@ -159,6 +170,7 @@ const FilamentQRCodeExportDialog = ({ filamentIds }: FilamentQRCodeExportDialogP
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);
@@ -184,14 +196,13 @@ const FilamentQRCodeExportDialog = ({ filamentIds }: FilamentQRCodeExportDialogP
if (!currentPresets) return;
const parsed = parsePresetValue(selectedPresetState);
if (!parsed || parsed.type !== currentPresetType) return;
- setLocalCurrentPresets(
- currentPresets.filter((qPreset) => qPreset.labelSettings.printSettings.id !== parsed.id),
- );
+ 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: {
@@ -202,6 +213,7 @@ const FilamentQRCodeExportDialog = ({ filamentIds }: FilamentQRCodeExportDialogP
};
} 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]);
@@ -210,24 +222,11 @@ const FilamentQRCodeExportDialog = ({ filamentIds }: FilamentQRCodeExportDialogP
} else {
const parsedSelectedPreset = parsePresetValue(selectedPresetState);
if (parsedSelectedPreset && parsedSelectedPreset.type === otherPresetType) {
- const importedPreset = otherPresets.find(
- (settings) => settings.labelSettings.printSettings.id === parsedSelectedPreset.id,
- );
- if (importedPreset) {
- curPreset = {
- ...importedPreset,
- labelSettings: {
- ...importedPreset.labelSettings,
- printSettings: { ...importedPreset.labelSettings.printSettings },
- },
- };
- } else {
- const preferredPreset =
- currentPresets.find((settings) => isDefaultPresetName(settings.labelSettings.printSettings?.name)) ??
- currentPresets[0];
- curPreset = preferredPreset;
- setSelectedPresetState(toPresetValue(currentPresetType, preferredPreset.labelSettings.printSettings.id));
- }
+ 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,
@@ -310,6 +309,7 @@ const FilamentQRCodeExportDialog = ({ filamentIds }: FilamentQRCodeExportDialogP
});
}
+ // Expose both filament and vendor placeholders because the same tag picker drives label text and export filenames.
const templateTags = [...filamentTags, ...vendorTags];
return (
@@ -344,6 +344,15 @@ const FilamentQRCodeExportDialog = ({ filamentIds }: FilamentQRCodeExportDialogP
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={
@@ -352,14 +361,16 @@ const FilamentQRCodeExportDialog = ({ filamentIds }: FilamentQRCodeExportDialogP
{
label: t("printing.generic.filamentImagePresets"),
options: currentPresets.map((settings) => ({
- label: settings.labelSettings.printSettings?.name || t("printing.generic.defaultSettings"),
+ 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"),
+ label:
+ settings.labelSettings.printSettings?.name || t("printing.generic.defaultSettings"),
value: toPresetValue(otherPresetType, settings.labelSettings.printSettings.id),
})),
},
@@ -405,6 +416,9 @@ const FilamentQRCodeExportDialog = ({ filamentIds }: FilamentQRCodeExportDialogP
updateCurrentPreset(curPreset);
}}
/>
+
+ {hasUnsavedChanges && Unsaved Preset Changes}
+
>
}
@@ -417,7 +431,10 @@ const FilamentQRCodeExportDialog = ({ filamentIds }: FilamentQRCodeExportDialogP
errorLevel: "H",
}))}
extraExportSettings={
-
+
{
@@ -428,7 +445,10 @@ const FilamentQRCodeExportDialog = ({ filamentIds }: FilamentQRCodeExportDialogP
}
extraTitleSettings={
-
+
setTemplateHelpOpen(false)}>
- {
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");
- 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-filament", false);
@@ -46,60 +92,76 @@ const FilamentQRCodePrintingDialog = ({ filamentIds }: FilamentQRCodePrintingDia
undefined,
);
- const [localPresets, setLocalPresets] = useState();
- const remotePresets = useGetPrintPresets("print_presets_filament");
+ const [localCurrentPresets, setLocalCurrentPresets] = useState();
+ const remoteCurrentPresets = useGetPrintPresets("print_presets_filament");
+ const remoteOtherPresets = useGetPrintPresets("print_presets");
const setRemotePresets = useSetPrintPresets("print_presets_filament");
- const localOrRemotePresets = localPresets ?? remotePresets;
+ const currentPresets = localCurrentPresets ?? remoteCurrentPresets;
+ const otherPresets = remoteOtherPresets ?? [];
const savePresetsRemote = async () => {
- if (!localPresets) return;
- await setRemotePresets(localPresets);
+ 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 (!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);
};
let curPreset: SpoolQRCodePrintSettings;
- if (localOrRemotePresets === undefined) {
+ if (currentPresets === undefined) {
curPreset = {
labelSettings: {
printSettings: {
@@ -109,32 +171,40 @@ const FilamentQRCodePrintingDialog = ({ filamentIds }: FilamentQRCodePrintingDia
},
};
} else {
- if (localOrRemotePresets.length === 0) {
+ if (currentPresets.length === 0) {
const newSetting = addNewPreset();
if (!newSetting) {
console.error("Error adding new setting, this should never happen");
return;
}
- localOrRemotePresets.push(newSetting);
+ currentPresets.push(newSetting);
curPreset = newSetting;
} else {
- if (!selectedPresetState) {
- curPreset = localOrRemotePresets[0];
- setSelectedPresetState(localOrRemotePresets[0].labelSettings.printSettings.id);
+ 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 = localOrRemotePresets.find(
- (settings) => settings.labelSettings.printSettings.id === selectedPresetState,
+ const foundSetting = currentPresets.find(
+ (settings) => settings.labelSettings.printSettings.id === parsedSelectedPreset.id,
);
if (foundSetting) {
curPreset = foundSetting;
} else {
- curPreset = localOrRemotePresets[0];
- setSelectedPresetState(localOrRemotePresets[0].labelSettings.printSettings.id);
+ 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 =
@@ -209,19 +279,51 @@ const FilamentQRCodePrintingDialog = ({ filamentIds }: FilamentQRCodePrintingDia
}}
extraSettingsStart={
<>
-
+
- {localOrRemotePresets && localOrRemotePresets.length > 1 && (
+ {currentPresets && currentPresets.length > 1 && (
+
+ {hasUnsavedChanges && Unsaved Preset Changes}
+
>
}
@@ -274,7 +379,10 @@ const FilamentQRCodePrintingDialog = ({ filamentIds }: FilamentQRCodePrintingDia
errorLevel: "H",
}))}
extraTitleSettings={
-
+
setTemplateHelpOpen(false)}>
- void;
- onPrint?: (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 namespace = "filamentSelectModal-v1";
-const allColumns: string[] = ["id", "spool_count", "vendor.name", "name", "material"];
+const MIN_TABLE_SCROLL_Y = 180;
+const TABLE_BOTTOM_GAP = 16;
-function getColumnLabel(columnId: string): string {
- if (columnId === "vendor.name") {
- return t("filament.fields.vendor_name");
- }
- return t(`filament.fields.${columnId.replace(".", "_")}`);
-}
-
-const FilamentSelectModal = ({ description, initialSelectedIds, onExport, onPrint }: Props) => {
+// 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(initialSelectedIds ?? []);
const [messageApi, contextHolder] = message.useMessage();
const navigate = useNavigate();
- const [searchValue, setSearchValue] = useState("");
- const [showColumns, setShowColumns] = useSavedState(`${namespace}-showColumns`, allColumns);
+ 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",
@@ -77,87 +73,125 @@ const FilamentSelectModal = ({ description, initialSelectedIds, onExport, onPrin
const tableState: TableState = {
sorters,
filters,
- pagination: { currentPage: currentPage, pageSize },
- showColumns,
+ pagination: { currentPage, pageSize },
};
- const dataSource: IFilamentCollapsed[] = useMemo(
- () => (tableProps.dataSource || []).map((record) => ({ ...record })),
- [tableProps.dataSource],
- );
+ const dataSource = [...(tableProps.dataSource ?? [])];
const selectedSet = useMemo(() => new Set(selectedItems), [selectedItems]);
+ const paginationTotal = tableProps.pagination ? (tableProps.pagination.total ?? 0) : 0;
- const paginationTotal = tableProps.pagination ? tableProps.pagination.total ?? 0 : 0;
- const handlePageChange = (page: number, nextPageSize?: number) => {
- if (typeof nextPageSize === "number" && nextPageSize !== pageSize) {
- setPageSize(nextPageSize);
+ 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);
}
- setCurrentPage(page);
- };
- const handlePageSizeChange = (_current: number, size: number) => {
- setPageSize(size);
- setCurrentPage(1);
- };
- const applySearchFilter = (nextSearch: string) => {
- const trimmedSearch = nextSearch.trim();
- const nextFilters: CrudFilter[] = [];
- (filters ?? []).forEach((filter) => {
- if ("field" in filter && filter.field !== "search") {
- nextFilters.push(filter);
+ 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);
}
- });
- if (trimmedSearch.length > 0) {
- nextFilters.push({
- field: "search",
- operator: "contains",
- value: [trimmedSearch],
- });
- }
- setFilters(nextFilters, "replace");
+ setCurrentPage(page);
+ },
+ [pageSize],
+ );
+ const handlePageSizeChange = useCallback((_current: number, size: number) => {
+ setPageSize(size);
setCurrentPage(1);
- };
+ }, []);
- const selectUnselectFiltered = (select: boolean) => {
- setSelectedItems((prevSelected) => {
- const nextSelected = new Set(prevSelected);
- dataSource.forEach((filament) => {
- if (select) {
- nextSelected.add(filament.id);
- } else {
- nextSelected.delete(filament.id);
- }
+ // 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);
});
- return Array.from(nextSelected);
- });
- };
+ },
+ [dataSource],
+ );
- const handleSelectItem = (item: number) => {
+ const handleSelectItem = useCallback((item: number) => {
setSelectedItems((prevSelected) =>
prevSelected.includes(item) ? prevSelected.filter((selected) => selected !== item) : [...prevSelected, item],
);
- };
+ }, []);
- const isAllFilteredSelected = dataSource.every((filament) => selectedSet.has(filament.id));
+ const isAllFilteredSelected = dataSource.length > 0 && dataSource.every((filament) => selectedSet.has(filament.id));
const isSomeButNotAllFilteredSelected =
dataSource.some((filament) => selectedSet.has(filament.id)) && !isAllFilteredSelected;
- const hasActiveFilters = searchValue.trim().length > 0 || (filters?.length ?? 0) > 0;
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}
-
- {(description || tableProps.pagination) && (
+
+ {(resolvedDescription || tableProps.pagination) && (
- {description && {description}
}
+ {resolvedDescription && {resolvedDescription}
}
{tableProps.pagination && (
)}
-
+
{
- const value = event.target.value;
- setSearchValue(value);
- if (value === "") {
- applySearchFilter("");
- }
+ setSearchTerm(event.target.value);
}}
onSearch={(value) => {
- setSearchValue(value);
- applySearchFilter(value);
+ setSearchTerm(value);
}}
/>
@@ -199,10 +228,8 @@ const FilamentSelectModal = ({ description, initialSelectedIds, onExport, onPrin
}
onClick={() => {
- setSearchValue("");
+ setSearchTerm("");
setFilters([], "replace");
setCurrentPage(1);
}}
@@ -210,28 +237,6 @@ const FilamentSelectModal = ({ description, initialSelectedIds, onExport, onPrin
{t("buttons.clearFilters")}
-
- ({
- key: columnId,
- label: getColumnLabel(columnId),
- })),
- selectedKeys: showColumns,
- selectable: true,
- multiple: true,
- onDeselect: (info) => {
- setShowColumns(info.selectedKeys as string[]);
- },
- onSelect: (info) => {
- setShowColumns(info.selectedKeys as string[]);
- },
- }}
- >
- }>{t("buttons.hideColumns")}
-
-
-
- {onPrint && (
-
- )}
- {onExport && (
-
- )}
-
+
+ {onExport && (
+
+ )}
-
-
+ value ?? 0,
- }),
FilteredQueryColumn({
...commonProps,
id: "vendor.name",
i18nkey: "filament.fields.vendor_name",
- width: 200,
filterValueQuery: useSpoolmanVendors(),
+ width: 180,
}),
SpoolIconColumn({
...commonProps,
id: "name",
i18ncat: "filament",
- width: 360,
+ width: 320,
color: (record: IFilamentCollapsed) =>
record.multi_color_hexes
? {
@@ -352,8 +342,8 @@ const FilamentSelectModal = ({ description, initialSelectedIds, onExport, onPrin
...commonProps,
id: "material",
i18ncat: "filament",
- width: 140,
filterValueQuery: useSpoolmanMaterials(),
+ width: 140,
}),
])}
/>
diff --git a/client/src/pages/printing/logoLabelBlock.tsx b/client/src/pages/printing/logoLabelBlock.tsx
index 4cf921934..13a77cfd1 100644
--- a/client/src/pages/printing/logoLabelBlock.tsx
+++ b/client/src/pages/printing/logoLabelBlock.tsx
@@ -41,6 +41,9 @@ const LogoLabelBlock = ({ vendor, label }: LogoLabelBlockProps) => {
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,
diff --git a/client/src/pages/printing/printing.tsx b/client/src/pages/printing/printing.tsx
index 249f866dc..f7941fafd 100644
--- a/client/src/pages/printing/printing.tsx
+++ b/client/src/pages/printing/printing.tsx
@@ -26,6 +26,7 @@ export interface QRCodePrintSettings {
showQRCodeMode?: "no" | "simple" | "withIcon";
textSize?: number;
showManufacturerLogo?: boolean;
+ logoSource?: "print" | "color";
logoHeightMm?: number;
logoAlign?: "left" | "center" | "right";
showTitle?: boolean;
@@ -49,6 +50,7 @@ export interface SpoolQRCodePrintSettings {
labelSettings: QRCodePrintSettings;
}
+// Merge shared defaults and saved presets without duplicating ids when multiple setting buckets are loaded together.
export function mergePrintPresets(
...presetLists: Array
): SpoolQRCodePrintSettings[] | undefined {
@@ -73,12 +75,27 @@ export function mergePrintPresets(
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();
@@ -89,35 +106,29 @@ export function useGetPrintSettings(settingKey = "print_presets"): SpoolQRCodePr
export function useSetPrintSettings(
settingKey = "print_presets",
-): (spoolQRCodePrintSettings: SpoolQRCodePrintSettings[]) => Promise {
+): (spoolQRCodePrintSettings: SpoolQRCodePrintSettings[]) => void {
const mut = useSetSetting(settingKey);
return (spoolQRCodePrintSettings: SpoolQRCodePrintSettings[]) => {
- return mut.mutateAsync(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;
@@ -167,8 +178,8 @@ function applyTextFormatting(text: string): ReactElement[] {
return elements;
}
-export function renderTemplateText(template: string, obj: GenericObject): string {
- // 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 renderedText = template;
matches.forEach((match) => {
@@ -184,7 +195,7 @@ export function renderTemplateText(template: string, obj: GenericObject): string
if (tagValue === "?") {
renderedText = renderedText.replace(match[0], "");
} else {
- renderedText = renderedText.replace(match[0], structure[1] + tagValue + structure[3]);
+ renderedText = renderedText.replace(match[0], structure[1] + String(tagValue) + structure[3]);
}
}
}
@@ -192,8 +203,7 @@ export function renderTemplateText(template: string, obj: GenericObject): string
return renderedText;
}
-export function renderLabelContents(template: string, obj: GenericObject): ReactElement {
+export function renderLabelContents(template: string, obj: object): ReactElement {
const renderedText = renderTemplateText(template, obj);
- // Split string on \n into individual lines
return <>{applyTextFormatting(renderedText)}>;
}
diff --git a/client/src/pages/printing/printingDialog.tsx b/client/src/pages/printing/printingDialog.tsx
index 141ebf2d4..cb6cbb696 100644
--- a/client/src/pages/printing/printingDialog.tsx
+++ b/client/src/pages/printing/printingDialog.tsx
@@ -1,4 +1,4 @@
-import { FileImageOutlined, FileTextOutlined, PrinterOutlined } from "@ant-design/icons";
+import { FileImageOutlined, PrinterOutlined } from "@ant-design/icons";
import { useTranslate } from "@refinedev/core";
import {
Button,
@@ -86,7 +86,6 @@ const PrintingDialog = ({
const paperSize = printSettings?.paperSize || "A4";
const customPaperSize = printSettings?.customPaperSize || { width: 210, height: 297 };
const borderShowMode = printSettings?.borderShowMode || "grid";
- const amlLabelSize = printSettings?.amlLabelSize || { width: 40, height: 30 };
const paperWidth = paperSize === "custom" ? customPaperSize.width : paperDimensions[paperSize].width;
const paperHeight = paperSize === "custom" ? customPaperSize.height : paperDimensions[paperSize].height;
@@ -184,94 +183,6 @@ const PrintingDialog = ({
return Array.from(root.getElementsByClassName("print-qrcode-item"));
};
- const getPrintPages = () => {
- const root = contentRef.current ?? document;
- return Array.from(root.getElementsByClassName("print-page"));
- };
-
- 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 buildAmlXml = (name: string, widthMm: number, heightMm: number, base64Png: 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;
- const id = Math.floor(Math.random() * 2 ** 31);
- const objectId = Math.floor(Math.random() * 2 ** 31);
-
- return `
-
- ${name}
- Custom Label
- 0
- ${height.toFixed(3)}
- ${width.toFixed(3)}
- 1
- 1
- ${validBoundsWidth.toFixed(0)}
- ${validBoundsHeight.toFixed(0)}
- 0
- #ffffff
- #000000
- ${width.toFixed(2)}mm * ${height.toFixed(2)}mm
- ${widthIn.toFixed(3)}inch * ${heightIn.toFixed(3)}inch
- 0
- 0
- 0
- 0
- 0
- 0
- 0
- Custom
- ${width.toFixed(1)} * ${height.toFixed(1)} mm
- ${widthIn.toFixed(2)} * ${heightIn.toFixed(2)} in
-
-
- 0
- 0
- 0
- 0
- 1
- #000000
- 0
-
- 0
- ${base64Png}
- ${height.toFixed(3)}
- ${width.toFixed(3)}
- 0.000
- 0.000
- 0.000000
- 0
- 0
- 0.7055555449591742
- #000000
- ${id}
- ${objectId}
- 0
- 0
- 1
- 0
- 0
- 0
-
- 0
- 0
-
-
-
-`;
- };
-
const saveAsImage = async () => {
const hasPrinted: Element[] = [];
const items = getPrintItems();
@@ -304,61 +215,6 @@ const PrintingDialog = ({
}
};
- const saveAsAmlLabels = async () => {
- const hasPrinted: Element[] = [];
- const items = getPrintItems();
- const usedNames = new Set();
- let idx = 1;
-
- 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])) {
- isDuplicate = true;
- break;
- }
- }
- if (isDuplicate) {
- continue;
- }
- hasPrinted.push(item);
-
- const rawName = (item as HTMLElement).dataset.amlName || `label-${idx}`;
- const safeName = rawName.replace(/[^a-zA-Z0-9-_]+/g, "-");
- if (usedNames.has(safeName)) {
- continue;
- }
- usedNames.add(safeName);
-
- const url = await htmlToImage.toPng(item as HTMLElement, {
- backgroundColor: "#FFF",
- cacheBust: true,
- });
- const base64 = url.split(",")[1] ?? "";
- const aml = buildAmlXml(safeName, amlLabelSize.width, amlLabelSize.height, base64);
- downloadTextFile(`${safeName}.aml`, aml, "application/xml");
- idx += 1;
- }
- };
-
- const saveAsAmlPages = async () => {
- const pages = getPrintPages();
- let pageIdx = 1;
-
- for (const page of pages) {
- const url = await htmlToImage.toPng(page as HTMLElement, {
- backgroundColor: "#FFF",
- cacheBust: true,
- });
- const base64 = url.split(",")[1] ?? "";
- const name = `labels-page-${pageIdx}`;
- const aml = buildAmlXml(name, paperWidth, paperHeight, base64);
- downloadTextFile(`${name}.aml`, aml, "application/xml");
- pageIdx += 1;
- }
- };
-
return (
<>
@@ -596,37 +452,6 @@ const PrintingDialog = ({
-
-
-
- {
- amlLabelSize.width = value ?? 0;
- printSettings.amlLabelSize = amlLabelSize;
- setPrintSettings(printSettings);
- }}
- />
-
-
- x
-
-
- {
- amlLabelSize.height = value ?? 0;
- printSettings.amlLabelSize = amlLabelSize;
- setPrintSettings(printSettings);
- }}
- />
-
-
-
@@ -1003,12 +828,6 @@ const PrintingDialog = ({
{extraButtons}
- } size="large" onClick={saveAsAmlLabels}>
- {t("printing.generic.saveAsAmlLabels")}
-
- } size="large" onClick={saveAsAmlPages}>
- {t("printing.generic.saveAsAmlPages")}
-
} size="large" onClick={saveAsImage}>
{t("printing.generic.saveAsImage")}
diff --git a/client/src/pages/printing/qrCodeExportDialog.tsx b/client/src/pages/printing/qrCodeExportDialog.tsx
index 7574e5bc0..89f200262 100644
--- a/client/src/pages/printing/qrCodeExportDialog.tsx
+++ b/client/src/pages/printing/qrCodeExportDialog.tsx
@@ -1,41 +1,14 @@
-import {
- AlignCenterOutlined,
- AlignLeftOutlined,
- AlignRightOutlined,
- VerticalAlignBottomOutlined,
- VerticalAlignMiddleOutlined,
- VerticalAlignTopOutlined,
-} from "@ant-design/icons";
import { useTranslate } from "@refinedev/core";
-import {
- Col,
- Divider,
- Form,
- InputNumber,
- QRCode,
- Radio,
- RadioChangeEvent,
- Row,
- Segmented,
- Slider,
- Switch,
- Typography,
-} from "antd";
+import { Col, Form, InputNumber, QRCode, Radio, RadioChangeEvent, Row, Slider, Switch, Typography } from "antd";
import { ReactElement } from "react";
-import { useMemo, useState } from "react";
-import VendorLogo from "../../components/vendorLogo";
import { getBasePath } from "../../utils/url";
-import { IVendor } from "../vendors/model";
import { QRCodePrintSettings } from "./printing";
import ExportDialog from "./exportDialog";
-import TitleTextBlock from "./titleTextBlock";
const { Text } = Typography;
interface QRCodeData {
value: string;
- vendor?: IVendor;
- title?: ReactElement;
label?: ReactElement;
errorLevel?: "L" | "M" | "Q" | "H";
amlName?: string;
@@ -45,9 +18,10 @@ interface QRCodeExportDialogProps {
items: QRCodeData[];
printSettings: QRCodePrintSettings;
setPrintSettings: (setPrintSettings: QRCodePrintSettings) => void;
- extraExportSettings?: ReactElement;
+ extraSettings?: ReactElement;
extraTitleSettings?: ReactElement;
extraInfoSettings?: ReactElement;
+ extraExportSettings?: ReactElement;
extraSettingsStart?: ReactElement;
extraButtons?: ReactElement;
baseUrlRoot: string;
@@ -57,13 +31,16 @@ interface QRCodeExportDialogProps {
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,
- extraExportSettings,
+ extraSettings,
extraTitleSettings,
extraInfoSettings,
+ extraExportSettings,
extraSettingsStart,
extraButtons,
baseUrlRoot,
@@ -74,135 +51,31 @@ const QRCodeExportDialog = ({
}: QRCodeExportDialogProps) => {
const t = useTranslate();
- const toOneDecimal = (value: number, min: number, max: number): number =>
- Math.min(max, Math.max(min, Math.round(value * 10) / 10));
- const horizontalToFlex = (value: "left" | "center" | "right"): "flex-start" | "center" | "flex-end" => {
- if (value === "center") return "center";
- if (value === "right") return "flex-end";
- return "flex-start";
- };
- const verticalToFlex = (value: "top" | "center" | "bottom"): "flex-start" | "center" | "flex-end" => {
- if (value === "center") return "center";
- if (value === "bottom") return "flex-end";
- return "flex-start";
- };
-
const showContent = printSettings?.showContent === undefined ? true : printSettings?.showContent;
const showQRCodeMode = printSettings?.showQRCodeMode || "withIcon";
- const infoTextSize = printSettings?.textSize || 3;
- const showManufacturerLogo = printSettings?.showManufacturerLogo ?? true;
- const logoHeightMm = printSettings?.logoHeightMm ?? 6;
- const logoAlign = printSettings?.logoAlign || "left";
- const showTitle = printSettings?.showTitle ?? true;
- const titleMaxTextSize = printSettings?.titleMaxTextSize ?? printSettings?.titleTextSize ?? 4;
- const titleFitToWidth = printSettings?.titleFitToWidth ?? true;
- const titleAlign = printSettings?.titleAlign || "left";
- const qrCodePosition = printSettings?.qrCodePosition || "right";
- const qrCodeAlign = printSettings?.qrCodeAlign || "center";
- const infoAlign = printSettings?.infoAlign || "left";
- const infoVerticalAlign = printSettings?.infoVerticalAlign || "top";
- const qrCodeSizeMax = 30;
- const qrCodeSizeMm = toOneDecimal(printSettings?.qrCodeSizeMm ?? 16, 8, qrCodeSizeMax);
- const [titleEffectiveTextSizesByItem, setTitleEffectiveTextSizesByItem] = useState>({});
-
- const paperHeight = printSettings.printSettings?.customPaperSize?.height ?? 30;
- const topMargin = printSettings.printSettings?.margin?.top ?? 0;
- const bottomMargin = printSettings.printSettings?.margin?.bottom ?? 0;
- const containerPaddingMm = 1.2; // .print-qrcode-item vertical padding
- const minMainHeightMm = showQRCodeMode === "no" ? 0 : 8;
- const availableContentHeightMm = Math.max(0, paperHeight - topMargin - bottomMargin - containerPaddingMm);
- const maxHeaderHeightMm = Math.max(0, availableContentHeightMm - minMainHeightMm);
- const preview =
- previewValues ?? ({ default: `WEB+SPOOLMAN:S-{id}`, url: `${baseUrlRoot}/spool/show/{id}` } as const);
- const appliedTitleSizeDisplay = useMemo(() => {
- const values = Object.values(titleEffectiveTextSizesByItem).filter((value) => Number.isFinite(value));
- if (values.length === 0) {
- return `${titleMaxTextSize.toFixed(1)} mm`;
- }
- const minSize = Math.min(...values);
- const maxSize = Math.max(...values);
- if (values.length > 1 && maxSize - minSize >= 0.1) {
- return `${minSize.toFixed(1)}-${maxSize.toFixed(1)} mm`;
- }
- return `${maxSize.toFixed(1)} mm`;
- }, [titleEffectiveTextSizesByItem, titleMaxTextSize]);
+ 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) => {
- const hasHeader = (showManufacturerLogo && !!item.vendor) || (showTitle && !!item.title);
return (
- {hasHeader && (
-
- {showManufacturerLogo && !!item.vendor && (
-
- )}
- {showTitle && item.title && (
-
- {
- setTitleEffectiveTextSizesByItem((current) => {
- if (current[idx] === sizeMm) {
- return current;
- }
- return { ...current, [idx]: sizeMm };
- });
- }}
- >
- {item.title}
-
-
- )}
+ {showQRCodeMode !== "no" && (
+
+
)}
- {(showQRCodeMode !== "no" || showContent) && (
-
- {showContent && (
-
-
{item.label ?? item.value}
-
- )}
- {showQRCodeMode !== "no" && (
-
- )}
+ {showContent && (
+
+ {item.label ?? item.value}
)}
@@ -217,159 +90,13 @@ const QRCodeExportDialog = ({
printSettings.printSettings = newSettings;
setPrintSettings(printSettings);
}}
- extraExportSettings={extraExportSettings}
extraButtons={extraButtons}
+ extraFormatSettings={extraExportSettings}
zipFileTypeName={zipFileTypeName}
extraSettingsStart={extraSettingsStart}
extraSettings={
<>
-
{t("printing.qrcode.sectionLogo")}
-
- {
- printSettings.showManufacturerLogo = checked;
- setPrintSettings(printSettings);
- }}
- />
-
-
-
-
- `${value} mm` }}
- value={logoHeightMm}
- onChange={(value) => {
- printSettings.logoHeightMm = value;
- setPrintSettings(printSettings);
- }}
- />
-
-
- {
- printSettings.logoHeightMm = value ?? 6;
- setPrintSettings(printSettings);
- }}
- />
-
-
-
-
- },
- { value: "center", icon: },
- { value: "right", icon: },
- ]}
- value={logoAlign}
- onChange={(value) => {
- printSettings.logoAlign = value as "left" | "center" | "right";
- setPrintSettings(printSettings);
- }}
- />
-
-
-
-
{t("printing.qrcode.sectionTitle")}
-
- {
- printSettings.showTitle = checked;
- setPrintSettings(printSettings);
- }}
- />
-
-
-
-
- `${value} mm` }}
- value={titleMaxTextSize}
- onChange={(value) => {
- printSettings.titleMaxTextSize = value;
- setPrintSettings(printSettings);
- }}
- />
-
-
- {
- printSettings.titleMaxTextSize = value ?? 4;
- setPrintSettings(printSettings);
- }}
- />
-
-
-
-
-
- {
- printSettings.titleFitToWidth = e.target.value;
- setPrintSettings(printSettings);
- }}
- value={titleFitToWidth}
- optionType="button"
- buttonStyle="solid"
- />
- {showTitle && (
-
- {t("printing.qrcode.appliedTextSize")}: {appliedTitleSizeDisplay}
-
- )}
-
-
-
- },
- { value: "center", icon: },
- { value: "right", icon: },
- ]}
- value={titleAlign}
- onChange={(value) => {
- printSettings.titleAlign = value as "left" | "center" | "right";
- setPrintSettings(printSettings);
- }}
- />
-
- {extraTitleSettings}
-
-
-
{t("printing.qrcode.sectionQRCode")}
-
+
-
- {
- printSettings.qrCodePosition = e.target.value;
- setPrintSettings(printSettings);
- }}
- value={qrCodePosition}
- optionType="button"
- buttonStyle="solid"
- />
-
-
- },
- {
- value: "center",
- icon: ,
- },
- {
- value: "bottom",
- icon: ,
- },
- ]}
- onChange={(value) => {
- printSettings.qrCodeAlign = value as "top" | "center" | "bottom";
- setPrintSettings(printSettings);
- }}
- value={qrCodeAlign}
- />
-
-
-
-
- `${value} mm` }}
- value={qrCodeSizeMm}
- onChange={(value) => {
- printSettings.qrCodeSizeMm = toOneDecimal(value, 8, qrCodeSizeMax);
- setPrintSettings(printSettings);
- }}
- />
-
-
- {
- printSettings.qrCodeSizeMm = toOneDecimal(value ?? 16, 8, qrCodeSizeMax);
- setPrintSettings(printSettings);
- }}
- />
-
-
-
{showQRCodeMode !== "no" && (
<>
>
)}
-
-
- {t("printing.qrcode.sectionInformation")}
-
+
{
@@ -485,15 +141,15 @@ const QRCodeExportDialog = ({
}}
/>
-
+
`${value} mm` }}
- min={1}
+ min={2}
max={7}
- value={infoTextSize}
+ value={textSize}
step={0.1}
onChange={(value) => {
printSettings.textSize = value;
@@ -504,12 +160,10 @@ const QRCodeExportDialog = ({
{
printSettings.textSize = value ?? 5;
@@ -519,165 +173,38 @@ const QRCodeExportDialog = ({
-
- },
- { value: "center", icon: },
- { value: "right", icon: },
- ]}
- value={infoAlign}
- onChange={(value) => {
- printSettings.infoAlign = value as "left" | "center" | "right";
- setPrintSettings(printSettings);
- }}
- />
-
-
- },
- {
- value: "center",
- icon: ,
- },
- {
- value: "bottom",
- icon: ,
- },
- ]}
- value={infoVerticalAlign}
- onChange={(value) => {
- printSettings.infoVerticalAlign = value as "top" | "center" | "bottom";
- setPrintSettings(printSettings);
- }}
- />
-
+
+ {extraTitleSettings}
{extraInfoSettings}
+ {extraSettings}
>
}
style={`
.print-page .print-qrcode-item {
display: flex;
- flex-direction: column;
width: 100%;
height: 100%;
- padding: 0.6mm;
- overflow: hidden;
- }
-
- .print-page .print-qrcode-header {
- display: flex;
- flex-direction: column;
- width: 100%;
- overflow: visible;
- margin-bottom: 0.3mm;
- gap: 0.4mm;
- max-height: ${maxHeaderHeightMm}mm;
- }
-
- .print-page .print-qrcode-logo-row {
- width: 100%;
- min-height: ${showManufacturerLogo ? logoHeightMm : 0}mm;
- max-height: ${showManufacturerLogo ? logoHeightMm : 0}mm;
- overflow: hidden;
- }
-
- .print-page .print-qrcode-logo {
- width: 100%;
- height: 100%;
- display: flex;
- justify-content: ${horizontalToFlex(logoAlign)};
- align-items: center;
- overflow: hidden;
- }
-
- .print-page .print-qrcode-title-area {
- width: 100%;
- display: block;
- overflow: visible;
- flex: 0 0 auto;
+ justify-content: center;
}
- .print-page .print-qrcode-title-text {
- overflow: visible;
- }
-
- .print-page .print-qrcode-main {
- flex: 1 1 auto;
- min-height: 0;
- height: 100%;
+ .print-page .print-qrcode-container {
+ max-width: ${showContent ? "50%" : "100%"};
display: flex;
- gap: 1mm;
- align-items: stretch;
- }
-
- .print-page .print-qrcode-main-left {
- flex-direction: row-reverse;
}
- .print-page .print-qrcode-main-right {
- flex-direction: row;
+ .print-page .print-qrcode {
+ width: auto !important;
+ height: auto !important;
+ padding: 2mm;
}
- .print-page .print-qrcode-info {
+ .print-page .print-qrcode-title {
flex: 1 1 auto;
- min-width: 0;
- min-height: 0;
- height: 100%;
- display: flex;
- flex-direction: column;
- justify-content: ${verticalToFlex(infoVerticalAlign)};
- align-items: ${horizontalToFlex(infoAlign)};
- overflow: hidden;
- }
-
- .print-page .print-qrcode-info-text {
- width: 100%;
- font-size: ${infoTextSize}mm;
- text-align: ${infoAlign};
+ font-size: ${textSize}mm;
color: #000;
- line-height: 1.15;
overflow: hidden;
}
- .print-page .print-qrcode-code-column {
- flex: 0 0 auto;
- width: ${qrCodeSizeMm}mm;
- max-width: 80%;
- display: flex;
- align-items: center;
- }
-
- .print-page .print-qrcode-code-column-top {
- align-items: flex-start;
- }
-
- .print-page .print-qrcode-code-column-center {
- align-items: center;
- }
-
- .print-page .print-qrcode-code-column-bottom {
- align-items: flex-end;
- }
-
- .print-page .print-qrcode-code-box {
- width: min(${qrCodeSizeMm}mm, 100%);
- height: min(${qrCodeSizeMm}mm, 100%);
- aspect-ratio: 1 / 1;
- display: flex;
- }
-
- .print-page .print-qrcode {
- width: 100% !important;
- height: 100% !important;
- padding: 0.6mm;
- }
-
.print-page canvas, .print-page svg {
object-fit: contain;
height: 100% !important;
diff --git a/client/src/pages/printing/qrCodePrintingDialog.tsx b/client/src/pages/printing/qrCodePrintingDialog.tsx
index df59e97c9..608730abf 100644
--- a/client/src/pages/printing/qrCodePrintingDialog.tsx
+++ b/client/src/pages/printing/qrCodePrintingDialog.tsx
@@ -1,50 +1,23 @@
-import {
- AlignCenterOutlined,
- AlignLeftOutlined,
- AlignRightOutlined,
- VerticalAlignBottomOutlined,
- VerticalAlignMiddleOutlined,
- VerticalAlignTopOutlined,
-} from "@ant-design/icons";
import { useTranslate } from "@refinedev/core";
-import {
- Col,
- Divider,
- Form,
- InputNumber,
- QRCode,
- Radio,
- RadioChangeEvent,
- Row,
- Segmented,
- Slider,
- Switch,
- Typography,
-} from "antd";
+import { Col, Form, InputNumber, QRCode, Radio, RadioChangeEvent, Row, Slider, Switch, Typography } from "antd";
import { ReactElement } from "react";
-import { useMemo, useState } from "react";
-import VendorLogo from "../../components/vendorLogo";
import { getBasePath } from "../../utils/url";
-import { IVendor } from "../vendors/model";
import { QRCodePrintSettings } from "./printing";
import PrintingDialog from "./printingDialog";
-import TitleTextBlock from "./titleTextBlock";
const { Text } = Typography;
interface QRCodeData {
value: string;
- vendor?: IVendor;
- title?: ReactElement;
label?: ReactElement;
errorLevel?: "L" | "M" | "Q" | "H";
- amlName?: string;
}
interface QRCodePrintingDialogProps {
items: QRCodeData[];
printSettings: QRCodePrintSettings;
setPrintSettings: (setPrintSettings: QRCodePrintSettings) => void;
+ extraSettings?: ReactElement;
extraTitleSettings?: ReactElement;
extraInfoSettings?: ReactElement;
extraSettingsStart?: ReactElement;
@@ -55,10 +28,13 @@ interface QRCodePrintingDialogProps {
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,
@@ -70,136 +46,31 @@ const QRCodePrintingDialog = ({
}: QRCodePrintingDialogProps) => {
const t = useTranslate();
- const toOneDecimal = (value: number, min: number, max: number): number =>
- Math.min(max, Math.max(min, Math.round(value * 10) / 10));
- const horizontalToFlex = (value: "left" | "center" | "right"): "flex-start" | "center" | "flex-end" => {
- if (value === "center") return "center";
- if (value === "right") return "flex-end";
- return "flex-start";
- };
- const verticalToFlex = (value: "top" | "center" | "bottom"): "flex-start" | "center" | "flex-end" => {
- if (value === "center") return "center";
- if (value === "bottom") return "flex-end";
- return "flex-start";
- };
-
const showContent = printSettings?.showContent === undefined ? true : printSettings?.showContent;
const showQRCodeMode = printSettings?.showQRCodeMode || "withIcon";
- const infoTextSize = printSettings?.textSize || 3;
- const showManufacturerLogo = printSettings?.showManufacturerLogo ?? true;
- const logoHeightMm = printSettings?.logoHeightMm ?? 6;
- const logoAlign = printSettings?.logoAlign || "left";
- const showTitle = printSettings?.showTitle ?? true;
- const titleMaxTextSize = printSettings?.titleMaxTextSize ?? printSettings?.titleTextSize ?? 4;
- const titleFitToWidth = printSettings?.titleFitToWidth ?? true;
- const titleAlign = printSettings?.titleAlign || "left";
- const qrCodePosition = printSettings?.qrCodePosition || "right";
- const qrCodeAlign = printSettings?.qrCodeAlign || "center";
- const infoAlign = printSettings?.infoAlign || "left";
- const infoVerticalAlign = printSettings?.infoVerticalAlign || "top";
- const qrCodeSizeMax = 30;
- const qrCodeSizeMm = toOneDecimal(printSettings?.qrCodeSizeMm ?? 16, 8, qrCodeSizeMax);
- const [titleEffectiveTextSizesByItem, setTitleEffectiveTextSizesByItem] = useState>({});
-
- const paperHeight = printSettings.printSettings?.customPaperSize?.height ?? 30;
- const topMargin = printSettings.printSettings?.margin?.top ?? 0;
- const bottomMargin = printSettings.printSettings?.margin?.bottom ?? 0;
- const containerPaddingMm = 1.2; // .print-qrcode-item vertical padding
- const minMainHeightMm = showQRCodeMode === "no" ? 0 : 8;
- const availableContentHeightMm = Math.max(0, paperHeight - topMargin - bottomMargin - containerPaddingMm);
- const maxHeaderHeightMm = Math.max(0, availableContentHeightMm - minMainHeightMm);
- const preview =
- previewValues ?? ({ default: `WEB+SPOOLMAN:S-{id}`, url: `${baseUrlRoot}/spool/show/{id}` } as const);
- const appliedTitleSizeDisplay = useMemo(() => {
- const values = Object.values(titleEffectiveTextSizesByItem).filter((value) => Number.isFinite(value));
- if (values.length === 0) {
- return `${titleMaxTextSize.toFixed(1)} mm`;
- }
- const minSize = Math.min(...values);
- const maxSize = Math.max(...values);
- if (values.length > 1 && maxSize - minSize >= 0.1) {
- return `${minSize.toFixed(1)}-${maxSize.toFixed(1)} mm`;
- }
- return `${maxSize.toFixed(1)} mm`;
- }, [titleEffectiveTextSizesByItem, titleMaxTextSize]);
+ 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) => {
- const hasHeader = (showManufacturerLogo && !!item.vendor) || (showTitle && !!item.title);
return (
-
- {hasHeader && (
-
- {showManufacturerLogo && !!item.vendor && (
-
- )}
- {showTitle && item.title && (
-
- {
- setTitleEffectiveTextSizesByItem((current) => {
- if (current[idx] === sizeMm) {
- return current;
- }
- return { ...current, [idx]: sizeMm };
- });
- }}
- >
- {item.title}
-
-
- )}
+
+ {showQRCodeMode !== "no" && (
+
+
)}
-
- {(showQRCodeMode !== "no" || showContent) && (
-
- {showContent && (
-
-
{item.label ?? item.value}
-
- )}
- {showQRCodeMode !== "no" && (
-
- )}
+ {showContent && (
+
+ {item.label ?? item.value}
)}
@@ -218,153 +89,7 @@ const QRCodePrintingDialog = ({
extraSettingsStart={extraSettingsStart}
extraSettings={
<>
-
{t("printing.qrcode.sectionLogo")}
-
- {
- printSettings.showManufacturerLogo = checked;
- setPrintSettings(printSettings);
- }}
- />
-
-
-
-
- `${value} mm` }}
- value={logoHeightMm}
- onChange={(value) => {
- printSettings.logoHeightMm = value;
- setPrintSettings(printSettings);
- }}
- />
-
-
- {
- printSettings.logoHeightMm = value ?? 6;
- setPrintSettings(printSettings);
- }}
- />
-
-
-
-
- },
- { value: "center", icon: },
- { value: "right", icon: },
- ]}
- value={logoAlign}
- onChange={(value) => {
- printSettings.logoAlign = value as "left" | "center" | "right";
- setPrintSettings(printSettings);
- }}
- />
-
-
-
-
{t("printing.qrcode.sectionTitle")}
-
- {
- printSettings.showTitle = checked;
- setPrintSettings(printSettings);
- }}
- />
-
-
-
-
- `${value} mm` }}
- value={titleMaxTextSize}
- onChange={(value) => {
- printSettings.titleMaxTextSize = value;
- setPrintSettings(printSettings);
- }}
- />
-
-
- {
- printSettings.titleMaxTextSize = value ?? 4;
- setPrintSettings(printSettings);
- }}
- />
-
-
-
-
-
- {
- printSettings.titleFitToWidth = e.target.value;
- setPrintSettings(printSettings);
- }}
- value={titleFitToWidth}
- optionType="button"
- buttonStyle="solid"
- />
- {showTitle && (
-
- {t("printing.qrcode.appliedTextSize")}: {appliedTitleSizeDisplay}
-
- )}
-
-
-
- },
- { value: "center", icon: },
- { value: "right", icon: },
- ]}
- value={titleAlign}
- onChange={(value) => {
- printSettings.titleAlign = value as "left" | "center" | "right";
- setPrintSettings(printSettings);
- }}
- />
-
- {extraTitleSettings}
-
-
-
{t("printing.qrcode.sectionQRCode")}
-
+
-
- {
- printSettings.qrCodePosition = e.target.value;
- setPrintSettings(printSettings);
- }}
- value={qrCodePosition}
- optionType="button"
- buttonStyle="solid"
- />
-
-
- },
- {
- value: "center",
- icon: ,
- },
- {
- value: "bottom",
- icon: ,
- },
- ]}
- onChange={(value) => {
- printSettings.qrCodeAlign = value as "top" | "center" | "bottom";
- setPrintSettings(printSettings);
- }}
- value={qrCodeAlign}
- />
-
-
-
-
- `${value} mm` }}
- value={qrCodeSizeMm}
- onChange={(value) => {
- printSettings.qrCodeSizeMm = toOneDecimal(value, 8, qrCodeSizeMax);
- setPrintSettings(printSettings);
- }}
- />
-
-
- {
- printSettings.qrCodeSizeMm = toOneDecimal(value ?? 16, 8, qrCodeSizeMax);
- setPrintSettings(printSettings);
- }}
- />
-
-
-
{showQRCodeMode !== "no" && (
<>
>
)}
-
-
- {t("printing.qrcode.sectionInformation")}
-
+
{
@@ -480,15 +134,15 @@ const QRCodePrintingDialog = ({
}}
/>
-
+
`${value} mm` }}
- min={1}
+ min={2}
max={7}
- value={infoTextSize}
+ value={textSize}
step={0.1}
onChange={(value) => {
printSettings.textSize = value;
@@ -499,12 +153,10 @@ const QRCodePrintingDialog = ({
{
printSettings.textSize = value ?? 5;
@@ -514,166 +166,40 @@ const QRCodePrintingDialog = ({
-
- },
- { value: "center", icon: },
- { value: "right", icon: },
- ]}
- value={infoAlign}
- onChange={(value) => {
- printSettings.infoAlign = value as "left" | "center" | "right";
- setPrintSettings(printSettings);
- }}
- />
-
-
- },
- {
- value: "center",
- icon: ,
- },
- {
- value: "bottom",
- icon: ,
- },
- ]}
- value={infoVerticalAlign}
- onChange={(value) => {
- printSettings.infoVerticalAlign = value as "top" | "center" | "bottom";
- setPrintSettings(printSettings);
- }}
- />
-
+
+ {extraTitleSettings}
{extraInfoSettings}
+ {extraSettings}
>
}
style={`
.print-page .print-qrcode-item {
display: flex;
- flex-direction: column;
width: 100%;
height: 100%;
- padding: 0.6mm;
- overflow: hidden;
+ justify-content: center;
}
- .print-page .print-qrcode-header {
+ .print-page .print-qrcode-container {
+ max-width: ${showContent ? "50%" : "100%"};
display: flex;
- flex-direction: column;
- width: 100%;
- overflow: visible;
- margin-bottom: 0.3mm;
- gap: 0.4mm;
- max-height: ${maxHeaderHeightMm}mm;
- }
-
- .print-page .print-qrcode-logo-row {
- width: 100%;
- min-height: ${showManufacturerLogo ? logoHeightMm : 0}mm;
- max-height: ${showManufacturerLogo ? logoHeightMm : 0}mm;
- overflow: hidden;
- }
-
- .print-page .print-qrcode-logo {
- width: 100%;
- height: 100%;
- display: flex;
- justify-content: ${horizontalToFlex(logoAlign)};
- align-items: center;
- overflow: hidden;
- }
-
- .print-page .print-qrcode-title-area {
- width: 100%;
- display: block;
- overflow: visible;
- flex: 0 0 auto;
- }
-
- .print-page .print-qrcode-title-text {
- overflow: visible;
- }
-
- .print-page .print-qrcode-main {
- flex: 1 1 auto;
- min-height: 0;
- height: 100%;
- display: flex;
- gap: 1mm;
- align-items: stretch;
}
- .print-page .print-qrcode-main-left {
- flex-direction: row-reverse;
- }
-
- .print-page .print-qrcode-main-right {
- flex-direction: row;
+ .print-page .print-qrcode {
+ width: auto !important;
+ height: auto !important;
+ padding: 2mm;
}
- .print-page .print-qrcode-info {
+ .print-page .print-qrcode-title {
flex: 1 1 auto;
- min-width: 0;
- min-height: 0;
- height: 100%;
- display: flex;
- flex-direction: column;
- justify-content: ${verticalToFlex(infoVerticalAlign)};
- align-items: ${horizontalToFlex(infoAlign)};
- overflow: hidden;
- }
-
- .print-page .print-qrcode-info-text {
- width: 100%;
- font-size: ${infoTextSize}mm;
- text-align: ${infoAlign};
+ font-size: ${textSize}mm;
color: #000;
- line-height: 1.15;
overflow: hidden;
}
- .print-page .print-qrcode-code-column {
- flex: 0 0 auto;
- width: ${qrCodeSizeMm}mm;
- max-width: 80%;
- display: flex;
- align-items: center;
- }
-
- .print-page .print-qrcode-code-column-top {
- align-items: flex-start;
- }
-
- .print-page .print-qrcode-code-column-center {
- align-items: center;
- }
-
- .print-page .print-qrcode-code-column-bottom {
- align-items: flex-end;
- }
-
- .print-page .print-qrcode-code-box {
- width: min(${qrCodeSizeMm}mm, 100%);
- height: min(${qrCodeSizeMm}mm, 100%);
- aspect-ratio: 1 / 1;
- display: flex;
- }
-
- .print-page .print-qrcode {
- width: 100% !important;
- height: 100% !important;
- padding: 0.6mm;
- }
-
.print-page canvas, .print-page svg {
+ /* display: block; */
object-fit: contain;
height: 100% !important;
width: 100% !important;
diff --git a/client/src/pages/printing/spoolQrCodeExportDialog.tsx b/client/src/pages/printing/spoolQrCodeExportDialog.tsx
index f805e74d7..371f79b42 100644
--- a/client/src/pages/printing/spoolQrCodeExportDialog.tsx
+++ b/client/src/pages/printing/spoolQrCodeExportDialog.tsx
@@ -1,9 +1,8 @@
import { CopyOutlined, DeleteOutlined, PlusOutlined, SaveOutlined } from "@ant-design/icons";
import { useTranslate } from "@refinedev/core";
-import { Button, Flex, Form, Input, Modal, Popconfirm, Select, Typography, message } from "antd";
+import { Button, Flex, Form, Input, Modal, Popconfirm, Select, Table, Typography, message } from "antd";
import TextArea from "antd/es/input/TextArea";
-import ResizableTable from "../../components/resizableTable";
-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";
@@ -11,6 +10,7 @@ import { useSavedState } from "../../utils/saveload";
import { useGetSpoolsByIds } from "../spools/functions";
import { ISpool } from "../spools/model";
import {
+ getConfiguredBaseUrl,
SpoolQRCodePrintSettings,
renderLabelContents,
renderTemplateText,
@@ -25,11 +25,13 @@ 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();
@@ -49,7 +51,11 @@ const SpoolQRCodeExportDialog = ({ spoolIds }: SpoolQRCodeExportDialog) => {
}
return `${normalizedBaseName}-${String(maxSuffix + 1).padStart(2, "0")}`;
};
- const buildNewPreset = (id: string, name: string, sourcePreset?: SpoolQRCodePrintSettings): SpoolQRCodePrintSettings => {
+ const buildNewPreset = (
+ id: string,
+ name: string,
+ sourcePreset?: SpoolQRCodePrintSettings,
+ ): SpoolQRCodePrintSettings => {
const copiedSourcePrintSettings = sourcePreset?.labelSettings?.printSettings ?? {};
return {
...sourcePreset,
@@ -78,10 +84,8 @@ const SpoolQRCodeExportDialog = ({ spoolIds }: SpoolQRCodeExportDialog) => {
};
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("export-useHTTPUrl", false);
@@ -105,12 +109,21 @@ const SpoolQRCodeExportDialog = ({ spoolIds }: SpoolQRCodeExportDialog) => {
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);
- setLocalCurrentPresets(undefined);
};
+ 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;
@@ -122,15 +135,11 @@ const SpoolQRCodeExportDialog = ({ spoolIds }: SpoolQRCodeExportDialog) => {
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 = {
- ...preset,
- labelSettings: {
- ...preset.labelSettings,
- printSettings: {
- ...preset.labelSettings.printSettings,
- id: uuidv4(),
- },
- },
+ ...buildNewPreset(uuidv4(), promotedName, preset),
};
const nextPresets = [...currentPresets, promotedPreset];
setLocalCurrentPresets(nextPresets);
@@ -138,6 +147,7 @@ const SpoolQRCodeExportDialog = ({ spoolIds }: SpoolQRCodeExportDialog) => {
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();
@@ -149,6 +159,7 @@ const SpoolQRCodeExportDialog = ({ spoolIds }: SpoolQRCodeExportDialog) => {
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 = {
@@ -159,6 +170,7 @@ const SpoolQRCodeExportDialog = ({ spoolIds }: SpoolQRCodeExportDialog) => {
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);
@@ -184,14 +196,13 @@ const SpoolQRCodeExportDialog = ({ spoolIds }: SpoolQRCodeExportDialog) => {
if (!currentPresets) return;
const parsed = parsePresetValue(selectedPresetState);
if (!parsed || parsed.type !== currentPresetType) return;
- setLocalCurrentPresets(
- currentPresets.filter((qPreset) => qPreset.labelSettings.printSettings.id !== parsed.id),
- );
+ 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: {
@@ -202,6 +213,7 @@ const SpoolQRCodeExportDialog = ({ spoolIds }: SpoolQRCodeExportDialog) => {
};
} 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]);
@@ -210,24 +222,11 @@ const SpoolQRCodeExportDialog = ({ spoolIds }: SpoolQRCodeExportDialog) => {
} else {
const parsedSelectedPreset = parsePresetValue(selectedPresetState);
if (parsedSelectedPreset && parsedSelectedPreset.type === otherPresetType) {
- const importedPreset = otherPresets.find(
- (settings) => settings.labelSettings.printSettings.id === parsedSelectedPreset.id,
- );
- if (importedPreset) {
- curPreset = {
- ...importedPreset,
- labelSettings: {
- ...importedPreset.labelSettings,
- printSettings: { ...importedPreset.labelSettings.printSettings },
- },
- };
- } else {
- const preferredPreset =
- currentPresets.find((settings) => isDefaultPresetName(settings.labelSettings.printSettings?.name)) ??
- currentPresets[0];
- curPreset = preferredPreset;
- setSelectedPresetState(toPresetValue(currentPresetType, preferredPreset.labelSettings.printSettings.id));
- }
+ 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,
@@ -252,7 +251,8 @@ const SpoolQRCodeExportDialog = ({ spoolIds }: SpoolQRCodeExportDialog) => {
}
const hasUnsavedChanges =
- localCurrentPresets !== undefined && JSON.stringify(localCurrentPresets) !== JSON.stringify(remoteSpoolPresets ?? []);
+ localCurrentPresets !== undefined &&
+ JSON.stringify(localCurrentPresets) !== JSON.stringify(remoteSpoolPresets ?? []);
const [templateHelpOpen, setTemplateHelpOpen] = useState(false);
const titleTemplate = curPreset.titleTemplate ?? `==**{filament.name}**== {filament.color_hex}`;
@@ -333,6 +333,7 @@ Spool Weight: {filament.spool_weight} g
});
}
+ // Expose spool, filament, and vendor placeholders because the same tag picker drives label text and export filenames.
const templateTags = [...spoolTags, ...filamentTags, ...vendorTags];
return (
@@ -367,6 +368,15 @@ Spool Weight: {filament.spool_weight} g
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={
@@ -375,14 +385,16 @@ Spool Weight: {filament.spool_weight} g
{
label: t("printing.generic.spoolImagePresets"),
options: currentPresets.map((settings) => ({
- label: settings.labelSettings.printSettings?.name || t("printing.generic.defaultSettings"),
+ 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"),
+ label:
+ settings.labelSettings.printSettings?.name || t("printing.generic.defaultSettings"),
value: toPresetValue(otherPresetType, settings.labelSettings.printSettings.id),
})),
},
@@ -428,6 +440,9 @@ Spool Weight: {filament.spool_weight} g
updateCurrentPreset(curPreset);
}}
/>
+
+ {hasUnsavedChanges && Unsaved Preset Changes}
+
>
}
@@ -440,7 +455,10 @@ Spool Weight: {filament.spool_weight} g
errorLevel: "H",
}))}
extraExportSettings={
-
+
{
@@ -451,7 +469,10 @@ Spool Weight: {filament.spool_weight} g
}
extraTitleSettings={
-
+
setTemplateHelpOpen(false)}>
- {
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);
@@ -46,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();
- const remotePresets = useGetPrintPresets();
+ const [localCurrentPresets, setLocalCurrentPresets] = useState();
+ 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 = async () => {
- if (!localPresets) return;
- await setRemotePresets(localPresets);
+ const savePresetsRemote = () => {
+ 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: {
@@ -113,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) {
@@ -122,30 +184,37 @@ 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, reset to first available preset.
- curPreset = localOrRemotePresets[0];
- setSelectedPresetState(localOrRemotePresets[0].labelSettings.printSettings.id);
+ 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 ?? `==**{filament.name}**== {filament.color_hex}`;
const infoTemplate =
@@ -243,19 +312,51 @@ Spool Weight: {filament.spool_weight} g
}}
extraSettingsStart={
<>
-
+
- {localOrRemotePresets && localOrRemotePresets.length > 1 && (
+ {currentPresets && currentPresets.length > 1 && (
+
+ {hasUnsavedChanges && Unsaved Preset Changes}
+
>
}
@@ -308,7 +412,10 @@ Spool Weight: {filament.spool_weight} g
errorLevel: "H",
}))}
extraTitleSettings={
-
+
setTemplateHelpOpen(false)}>
- }
- onClick={async () => {
- try {
- await savePresetsRemote();
- messageApi.success(t("notifications.saveSuccessful"));
- } catch (error) {
- messageApi.error(error instanceof Error ? error.message : "Save failed");
- }
+ onClick={() => {
+ savePresetsRemote();
+ messageApi.success(t("notifications.saveSuccessful"));
}}
>
{t("printing.generic.saveSetting")}
diff --git a/client/src/pages/printing/spoolSelectModal.tsx b/client/src/pages/printing/spoolSelectModal.tsx
index fdb873177..89a17db52 100644
--- a/client/src/pages/printing/spoolSelectModal.tsx
+++ b/client/src/pages/printing/spoolSelectModal.tsx
@@ -1,20 +1,12 @@
-import { EditOutlined, FilterOutlined } from "@ant-design/icons";
import { useTable } from "@refinedev/antd";
-import { CrudFilter } from "@refinedev/core";
-import { Button, Checkbox, Col, Dropdown, Input, message, Pagination, Row, Space } from "antd";
+import { Button, Checkbox, Col, message, Row, Space, Table } from "antd";
import { t } from "i18next";
-import { useEffect, useMemo, useState } from "react";
+import { useCallback, useMemo, useState } from "react";
import { useNavigate } from "react-router";
import { FilteredQueryColumn, SortedColumn, SpoolIconColumn } from "../../components/column";
-import ResizableTable from "../../components/resizableTable";
-import {
- useSpoolmanFilamentFilter,
- useSpoolmanLocations,
- useSpoolmanLotNumbers,
- useSpoolmanMaterials,
-} from "../../components/otherModels";
+import { useSpoolmanFilamentFilter, useSpoolmanMaterials } from "../../components/otherModels";
import { removeUndefined } from "../../utils/filtering";
-import { TableState, useSavedState } from "../../utils/saveload";
+import { TableState } from "../../utils/saveload";
import { ISpool } from "../spools/model";
interface Props {
@@ -30,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) {
@@ -45,139 +39,82 @@ function collapseSpool(element: ISpool): ISpoolCollapsed {
};
}
-const namespace = "spoolSelectModal-v1";
-const allColumns: string[] = ["id", "filament.combined_name", "filament.material", "location", "lot_nr"];
-
-function getColumnLabel(columnId: string): string {
- if (columnId === "filament.combined_name") {
- return t("spool.fields.filament_name");
- }
- if (columnId === "filament.material") {
- return t("spool.fields.material");
- }
- return t(`spool.fields.${columnId.replace(".", "_")}`);
-}
-
const SpoolSelectModal = ({ description, initialSelectedIds, onExport, onPrint }: Props) => {
const [selectedItems, setSelectedItems] = useState(initialSelectedIds ?? []);
const [showArchived, setShowArchived] = useState(false);
const [messageApi, contextHolder] = message.useMessage();
const navigate = useNavigate();
- const [searchValue, setSearchValue] = useState("");
- const [selectedArchivedMap, setSelectedArchivedMap] = useState>({});
- const [showColumns, setShowColumns] = useSavedState(`${namespace}-showColumns`, allColumns);
- const { tableProps, sorters, filters, setFilters, currentPage, pageSize, setCurrentPage, setPageSize } =
- useTable({
- resource: "spool",
- meta: {
- queryParams: {
- ["allow_archived"]: showArchived,
- },
+ const { tableProps, sorters, filters, currentPage, pageSize } = useTable({
+ resource: "spool",
+ meta: {
+ queryParams: {
+ ["allow_archived"]: showArchived,
},
- 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(collapseSpool),
- };
- },
+ },
+ syncWithLocation: false,
+ pagination: {
+ mode: "off",
+ currentPage: 1,
+ pageSize: 10,
+ },
+ sorters: {
+ mode: "server",
+ },
+ filters: {
+ mode: "server",
+ },
+ queryOptions: {
+ select(data) {
+ return {
+ total: data.total,
+ data: data.data.map(collapseSpool),
+ };
},
- });
+ },
+ });
+ // Shared column helpers expect table sort/filter state in this shape.
const tableState: TableState = {
sorters,
filters,
pagination: { currentPage: currentPage, pageSize },
- showColumns,
};
+ // 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],
);
- const selectedSet = useMemo(() => new Set(selectedItems), [selectedItems]);
-
- useEffect(() => {
- if (dataSource.length === 0) {
- return;
- }
- setSelectedArchivedMap((prev) => {
- const next = { ...prev };
- dataSource.forEach((spool) => {
- next[spool.id] = spool.archived === true;
- });
- return next;
- });
- }, [dataSource]);
-
- const paginationTotal = tableProps.pagination ? tableProps.pagination.total ?? 0 : 0;
- const handlePageChange = (page: number, nextPageSize?: number) => {
- if (typeof nextPageSize === "number" && nextPageSize !== pageSize) {
- setPageSize(nextPageSize);
- }
- setCurrentPage(page);
- };
- const handlePageSizeChange = (_current: number, size: number) => {
- setPageSize(size);
- setCurrentPage(1);
- };
- const applySearchFilter = (nextSearch: string) => {
- const trimmedSearch = nextSearch.trim();
- const nextFilters: CrudFilter[] = [];
- (filters ?? []).forEach((filter) => {
- if ("field" in filter && filter.field !== "search") {
- nextFilters.push(filter);
- }
- });
- if (trimmedSearch.length > 0) {
- nextFilters.push({
- field: "search",
- operator: "contains",
- value: [trimmedSearch],
+ // 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);
});
- }
- setFilters(nextFilters, "replace");
- setCurrentPage(1);
- };
-
- const selectUnselectFiltered = (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],
+ );
- const handleSelectItem = (item: number) => {
+ const handleSelectItem = useCallback((item: number) => {
setSelectedItems((prevSelected) =>
prevSelected.includes(item) ? prevSelected.filter((selected) => selected !== item) : [...prevSelected, item],
);
- };
+ }, []);
- const isAllFilteredSelected = dataSource.every((spool) => selectedSet.has(spool.id));
+ const isAllFilteredSelected = dataSource.every((spool) => selectedItems.includes(spool.id));
const isSomeButNotAllFilteredSelected =
- dataSource.some((spool) => selectedSet.has(spool.id)) && !isAllFilteredSelected;
- const hasActiveFilters = searchValue.trim().length > 0 || (filters?.length ?? 0) > 0;
+ dataSource.some((spool) => selectedItems.includes(spool.id)) && !isAllFilteredSelected;
const commonProps = {
t,
@@ -193,223 +130,122 @@ const SpoolSelectModal = ({ description, initialSelectedIds, onExport, onPrint }
return (
<>
{contextHolder}
-
- {(description || tableProps.pagination) && (
-
- {description && {description}
}
- {tableProps.pagination && (
-
-
-
- )}
-
- )}
-
-
- {
- const value = event.target.value;
- setSearchValue(value);
- if (value === "") {
- applySearchFilter("");
- }
- }}
- onSearch={(value) => {
- setSearchValue(value);
- applySearchFilter(value);
- }}
- />
-
-
-
-
- }
- onClick={() => {
- setSearchValue("");
- setFilters([], "replace");
- setCurrentPage(1);
+
+ {description && {description}
}
+ (
+ handleSelectItem(item.id)} />
+ ),
+ },
+ SortedColumn({
+ ...commonProps,
+ id: "id",
+ i18ncat: "spool",
+ width: 80,
+ }),
+ SpoolIconColumn({
+ ...commonProps,
+ id: "filament.combined_name",
+ dataId: "filament.combined_name",
+ i18nkey: "spool.fields.filament_name",
+ color: (record: ISpoolCollapsed) => record.filament.color_hex,
+ filterValueQuery: useSpoolmanFilamentFilter(),
+ }),
+ FilteredQueryColumn({
+ ...commonProps,
+ id: "filament.material",
+ i18nkey: "spool.fields.material",
+ filterValueQuery: useSpoolmanMaterials(),
+ }),
+ ])}
+ />
+
+
+ {
+ selectUnselectFiltered(e.target.checked);
}}
>
- {t("buttons.clearFilters")}
-
+ {t("printing.spoolSelect.selectAll")}
+
-
- ({
- key: columnId,
- label: getColumnLabel(columnId),
- })),
- selectedKeys: showColumns,
- selectable: true,
- multiple: true,
- onDeselect: (info) => {
- setShowColumns(info.selectedKeys as string[]);
- },
- onSelect: (info) => {
- setShowColumns(info.selectedKeys as string[]);
- },
- }}
- >
- }>{t("buttons.hideColumns")}
-
+
+
+ {t("printing.spoolSelect.selectedTotal", {
+ count: selectedItems.length,
+ })}
+
-
-
+
{
+ setShowArchived(e.target.checked);
+ if (!e.target.checked) {
+ // 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,
+ ),
+ );
+ }
}}
>
- {
- selectUnselectFiltered(e.target.checked);
- }}
- >
- {t("printing.spoolSelect.selectAll")}
-
- {
- setShowArchived(e.target.checked);
- if (!e.target.checked) {
- setSelectedItems((prevSelected) =>
- prevSelected.filter((selected) => selectedArchivedMap[selected] !== true),
- );
- }
- }}
- >
- {t("printing.spoolSelect.showArchived")}
-
-
- {t("printing.spoolSelect.selectedTotal", {
- count: selectedItems.length,
- })}
-
-
- {onPrint && (
-
- )}
- {onExport && (
-
- )}
-
-
+ {t("printing.spoolSelect.showArchived")}
+
+
+
+
+ {onPrint && (
+
+ )}
+ {onExport && (
+
+ )}
+
-
- (
- handleSelectItem(item.id)} />
- ),
- },
- SortedColumn({
- ...commonProps,
- id: "id",
- i18ncat: "spool",
- width: 70,
- }),
- SpoolIconColumn({
- ...commonProps,
- id: "filament.combined_name",
- dataId: "filament.combined_name",
- i18nkey: "spool.fields.filament_name",
- width: 360,
- color: (record: ISpoolCollapsed) =>
- record.filament.multi_color_hexes
- ? {
- colors: record.filament.multi_color_hexes.split(","),
- vertical: record.filament.multi_color_direction === "longitudinal",
- }
- : record.filament.color_hex,
- filterValueQuery: useSpoolmanFilamentFilter(),
- }),
- FilteredQueryColumn({
- ...commonProps,
- id: "filament.material",
- i18nkey: "spool.fields.material",
- filterValueQuery: useSpoolmanMaterials(),
- width: 140,
- }),
- FilteredQueryColumn({
- ...commonProps,
- id: "location",
- i18ncat: "spool",
- filterValueQuery: useSpoolmanLocations(),
- emptyFilterLabel: "",
- width: 160,
- }),
- FilteredQueryColumn({
- ...commonProps,
- id: "lot_nr",
- i18ncat: "spool",
- filterValueQuery: useSpoolmanLotNumbers(),
- width: 160,
- }),
- ])}
- />
-
-
+
>
);
};
diff --git a/client/src/pages/printing/titleTextBlock.tsx b/client/src/pages/printing/titleTextBlock.tsx
index 57587065c..d8da3464b 100644
--- a/client/src/pages/printing/titleTextBlock.tsx
+++ b/client/src/pages/printing/titleTextBlock.tsx
@@ -14,7 +14,13 @@ const alignToFlex = (value: "left" | "center" | "right"): "flex-start" | "center
return "flex-start";
};
-const TitleTextBlock = ({ children, fitToWidth, align, maxTextSizeMm, onEffectiveTextSizeChange }: TitleTextBlockProps) => {
+const TitleTextBlock = ({
+ children,
+ fitToWidth,
+ align,
+ maxTextSizeMm,
+ onEffectiveTextSizeChange,
+}: TitleTextBlockProps) => {
const containerRef = useRef(null);
const contentRef = useRef(null);
const [scale, setScale] = useState(1);
diff --git a/client/src/pages/settings/extraFieldsSettings.tsx b/client/src/pages/settings/extraFieldsSettings.tsx
index 397bb5382..76599ea76 100644
--- a/client/src/pages/settings/extraFieldsSettings.tsx
+++ b/client/src/pages/settings/extraFieldsSettings.tsx
@@ -11,6 +11,7 @@ import {
Popconfirm,
Select,
Space,
+ Table,
message,
} from "antd";
import { FormItemProps, Rule } from "antd/es/form";
@@ -23,7 +24,6 @@ import { useState } from "react";
import { Trans } from "react-i18next";
import { useParams } from "react-router";
import { DateTimePicker } from "../../components/dateTimePicker";
-import ResizableTable from "../../components/resizableTable";
import { InputNumberRange } from "../../components/inputNumberRange";
import { EntityType, Field, FieldType, useDeleteField, useGetFields, useSetField } from "../../utils/queryFields";
@@ -608,8 +608,7 @@ export function ExtraFieldsSettings() {
}}
/>