diff --git a/CHANGELOG.md b/CHANGELOG.md index 166be3b1..de8639d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,8 @@ Formát je inspirovaný Keep a Changelog. - Voucher Template Editor nyní automaticky využívá dostupný prostor canvasu, zobrazuje resize handles jen u aktivní oblasti, formátuje číselné hodnoty bez floating-point artefaktů a nabízí kompaktnější toggle ovládání vodítek a spadávky. - Resize voucherových oblastí nyní používá pouze čtyři rohové handles; QR oblast drží čtvercový poměr stran včetně ručních rozměrů a textové oblasti lze měnit nezávisle v obou osách. - Toggle `Vodítka` nyní skutečně řídí render středových os a `Spadávka` řídí viditelný 3mm bleed overlay; oba pracovní overlaye zůstávají mimo uložený layout a PDF export. +- Voucher editor a PDF renderer nyní sdílejí pravidla fittingu textu včetně výšky oblasti, insetů, baseline a automatického řádkování; publish preflight odmítne overflow a nové uložení či publikace drží dynamický obsah uvnitř TrimBoxu, zatímco historické layouty zůstávají renderovatelné pro již vydané kusy. +- Uložení draftu šablony používá optimistic concurrency přes `updatedAt`, neaktivní šablony se správně označují v UI a již vytištěné převzaté kusy lze aktivovat i po deaktivaci jejich immutable designu. ## [3.30.0] - 2026-09-23 diff --git a/src/app/(admin)/admin/vouchery/sablony/[templateId]/page.tsx b/src/app/(admin)/admin/vouchery/sablony/[templateId]/page.tsx index cb0bba32..3f13b499 100644 --- a/src/app/(admin)/admin/vouchery/sablony/[templateId]/page.tsx +++ b/src/app/(admin)/admin/vouchery/sablony/[templateId]/page.tsx @@ -13,7 +13,7 @@ import { VoucherTemplateLayoutEditor } from "@/features/admin/components/voucher import { VoucherTemplateOverflowMenu } from "@/features/admin/components/voucher-template-overflow-menu"; import { AdminStatePill } from "@/features/admin/components/admin-state-pill"; import { AdminVoucherTemplatePreview } from "@/features/admin/components/admin-voucher-template-preview"; -import { voucherTemplateLayoutSchema } from "@/features/vouchers/lib/voucher-template-layout"; +import { voucherTemplateStoredLayoutSchema } from "@/features/vouchers/lib/voucher-template-layout"; import { requireVoucherTemplateById } from "@/features/vouchers/lib/voucher-template-repository"; import { requireRole } from "@/lib/auth/session"; @@ -27,6 +27,9 @@ export default async function VoucherTemplateDetailPage({ params }: { params: Pr } const templateId = template.id; + const statusLabel = template.status === "DRAFT" ? "Draft" : template.status === "PUBLISHED" ? "Publikováno" : "Neaktivní"; + const statusDescription = template.status === "DRAFT" ? "v přípravě" : template.status === "PUBLISHED" ? "publikovaná" : "neaktivní"; + const statusTone = template.status === "DRAFT" ? "accent" : template.status === "PUBLISHED" ? "active" : "muted"; const previewUrl = template.previewStoragePath && template.masterSha256 ? `/api/admin/voucher-templates/${template.id}/preview?v=${template.updatedAt.getTime()}` : undefined; @@ -35,7 +38,7 @@ export default async function VoucherTemplateDetailPage({ params }: { params: Pr @@ -60,12 +63,12 @@ export default async function VoucherTemplateDetailPage({ params }: { params: Pr ); diff --git a/src/features/admin/actions/voucher-template-actions.test.ts b/src/features/admin/actions/voucher-template-actions.test.ts index 512ea621..74e51f97 100644 --- a/src/features/admin/actions/voucher-template-actions.test.ts +++ b/src/features/admin/actions/voucher-template-actions.test.ts @@ -34,12 +34,15 @@ test("template lifecycle actions vyžadují OWNER a volají domain operace", asy deleteVoucherTemplateDraft: async (id: string, actor: string) => { calls.push(["delete", id, actor]); }, publishVoucherTemplate: async (id: string, actor: string) => { calls.push(["publish", id, actor]); }, replaceVoucherTemplateMaster: async () => undefined, - updateVoucherTemplateDraft: async (_id: string, input: { layout: typeof defaultVoucherTemplateLayout }) => { savedLayout = input.layout; }, + updateVoucherTemplateDraft: async (_id: string, input: { layout: typeof defaultVoucherTemplateLayout }) => { + savedLayout = input.layout; + return { updatedAt: new Date("2026-09-26T06:00:00.000Z") }; + }, }, }); t.mock.module("@/features/vouchers/lib/voucher-template-repository", { exports: { - requireVoucherTemplateById: async () => ({ id: "draft-1", status: "DRAFT", key: "classic-v1", label: "Klasický" }), + requireVoucherTemplateById: async () => ({ id: "draft-1", status: "DRAFT", key: "classic-v1", label: "Klasický", allowedTypes: ["VALUE", "SERVICE"] }), resolveVoucherTemplate: async () => ({ id: "draft-1" }), }, }); @@ -53,7 +56,8 @@ test("template lifecycle actions vyžadují OWNER a volají domain operace", asy await actions.cloneVoucherTemplateVersionAction("template-1"); await actions.deleteVoucherTemplateDraftAction("template-1"); const editedLayout = { ...defaultVoucherTemplateLayout, serviceArea: { ...defaultVoucherTemplateLayout.serviceArea, typography: { ...defaultVoucherTemplateLayout.serviceArea.typography, preferredFontSizePt: 12, minFontSizePt: 7, fontWeight: "regular" as const, alignment: "center" as const } } }; - await actions.saveVoucherTemplateLayoutAction("template-1", editedLayout); + const saveResult = await actions.saveVoucherTemplateLayoutAction("template-1", editedLayout); + assert.equal(saveResult.updatedAt, "2026-09-26T06:00:00.000Z"); assert.equal(savedLayout?.serviceArea.typography.preferredFontSizePt, 12); assert.equal(savedLayout?.serviceArea.typography.minFontSizePt, 7); assert.equal(savedLayout?.serviceArea.typography.fontWeight, "regular"); diff --git a/src/features/admin/actions/voucher-template-actions.ts b/src/features/admin/actions/voucher-template-actions.ts index a4c7bfe3..1dbc3848 100644 --- a/src/features/admin/actions/voucher-template-actions.ts +++ b/src/features/admin/actions/voucher-template-actions.ts @@ -36,14 +36,23 @@ export async function uploadVoucherTemplateMasterAction(formData: FormData) { revalidatePath("/admin/nastaveni"); } -export async function saveVoucherTemplateLayoutAction(templateId: string, layout: VoucherTemplateLayoutV1) { +export async function saveVoucherTemplateLayoutAction(templateId: string, layout: VoucherTemplateLayoutV1, expectedUpdatedAt?: string) { const session = await requireRole([AdminRole.OWNER]); const parsed = voucherTemplateLayoutSchema.safeParse(layout); if (!parsed.success) throw new Error(parsed.error.issues[0]?.message ?? "Layout šablony obsahuje neplatné hodnoty."); + const parsedRevision = expectedUpdatedAt ? z.iso.datetime().safeParse(expectedUpdatedAt) : null; + if (expectedUpdatedAt && !parsedRevision?.success) throw new Error("Revize draftu není platná."); const { requireVoucherTemplateById } = await import("@/features/vouchers/lib/voucher-template-repository"); const template = await requireVoucherTemplateById(templateId); - await updateVoucherTemplateDraft(templateId, { layout, allowedTypes: template.allowedTypes, label: template.label, actorUserId: session.sub }); + const updated = await updateVoucherTemplateDraft(templateId, { + layout, + allowedTypes: template.allowedTypes, + label: template.label, + actorUserId: session.sub, + expectedUpdatedAt: parsedRevision?.success ? new Date(parsedRevision.data) : undefined, + }); revalidatePath(`/admin/vouchery/sablony/${templateId}`); + return { updatedAt: updated.updatedAt.toISOString() }; } export async function publishVoucherTemplateAction(templateId: string) { diff --git a/src/features/admin/components/voucher-template-layout-editor.tsx b/src/features/admin/components/voucher-template-layout-editor.tsx index bfbf777a..1bf3491f 100644 --- a/src/features/admin/components/voucher-template-layout-editor.tsx +++ b/src/features/admin/components/voucher-template-layout-editor.tsx @@ -16,6 +16,7 @@ import { type VoucherTemplateLayoutV1, type VoucherTemplateTypographyPatch, } from "@/features/vouchers/lib/voucher-template-layout"; +import { VOUCHER_TEXT_HORIZONTAL_INSET_MM } from "@/features/vouchers/lib/voucher-text-fit"; import { createVoucherTemplatePreviewTextMeasurer, @@ -26,7 +27,6 @@ import { getVoucherTemplatePreviewText, isVoucherTemplatePreviewAreaVisible, VOUCHER_TEMPLATE_PREVIEW_FONT_FAMILIES, - VOUCHER_TEMPLATE_PREVIEW_HORIZONTAL_PADDING_MM, type ServicePreviewScenario, type VoucherTemplatePreviewMode, } from "./voucher-template-layout-preview"; @@ -48,8 +48,9 @@ const cornerResizeEnable = { top: false, right: false, bottom: false, left: fals function isAspectRatioLocked(key: AreaKey) { return key === "qrArea"; } function minimumSizeMm(key: AreaKey) { return isAspectRatioLocked(key) ? MIN_QR_SIZE_MM : MIN_TEXT_AREA_MM; } -export function VoucherTemplateLayoutEditor({ templateId, initialLayout, previewSrc }: { templateId: string; initialLayout: VoucherTemplateLayoutV1; previewSrc?: string }) { +export function VoucherTemplateLayoutEditor({ templateId, initialLayout, initialUpdatedAt, previewSrc }: { templateId: string; initialLayout: VoucherTemplateLayoutV1; initialUpdatedAt: string; previewSrc?: string }) { const [layout, setLayout] = useState(initialLayout); + const [revision, setRevision] = useState(initialUpdatedAt); const [selected, setSelected] = useState("valueArea"); const [previewMode, setPreviewMode] = useState("VALUE"); const [serviceScenario, setServiceScenario] = useState("normal"); @@ -157,10 +158,11 @@ export function VoucherTemplateLayoutEditor({ templateId, initialLayout, preview } startTransition(async () => { try { - await saveVoucherTemplateLayoutAction(templateId, layout); + const result = await saveVoucherTemplateLayoutAction(templateId, layout, revision); + setRevision(result.updatedAt); setSaveError(null); } catch { - setSaveError("Draft se nepodařilo uložit. Zkontrolujte hodnoty a zkuste to znovu."); + setSaveError("Draft se nepodařilo uložit. Mohl být změněn v jiném okně; obnovte stránku a zkuste to znovu."); } }); }; @@ -201,12 +203,13 @@ export function VoucherTemplateLayoutEditor({ templateId, initialLayout, preview const item = layout[key]; const baseline = "baselineMm" in item ? item.baselineMm : null; const previewVisible = isVoucherTemplatePreviewAreaVisible(key, previewMode); - const textPreview = fontsReady && isVoucherTemplateTextAreaKey(key) && previewVisible ? (() => { const text = getVoucherTemplatePreviewText(key, serviceScenario); const fit = fitVoucherTemplatePreviewText(text, item as VoucherTemplateLayoutV1["valueArea"], previewTextMeasurer); return { text, fit }; })() : null; + const horizontalInsetMm = isVoucherTemplateTextAreaKey(key) ? VOUCHER_TEXT_HORIZONTAL_INSET_MM[key] : 0; + const textPreview = fontsReady && isVoucherTemplateTextAreaKey(key) && previewVisible ? (() => { const text = getVoucherTemplatePreviewText(key, serviceScenario); const fit = fitVoucherTemplatePreviewText(text, item as VoucherTemplateLayoutV1["valueArea"], previewTextMeasurer, horizontalInsetMm); return { text, fit }; })() : null; const baselinePx = baseline === null ? null : getVoucherTemplatePreviewBaselineTopPx(item as VoucherTemplateLayoutV1["valueArea"], canvasScale); const isSelected = selected === key; return selectArea(key)} onDragStart={() => { selectArea(key); setIsInteracting(true); }} onResizeStart={(_, direction) => { resizeStartRef.current = { key, area: item, direction }; selectArea(key); setIsInteracting(true); }} onResize={(_, direction, ref, __, pos) => applyResize(key, direction, ref, pos)} onDragStop={(_, data) => { setIsInteracting(false); update(key, { xMm: snapToHalfMm(data.x / canvasScale), yMm: snapToHalfMm(browserTopToPdfBottom(data.y / canvasScale, item.heightMm)) }); }} onResizeStop={(_, direction, ref, __, pos) => { setIsInteracting(false); applyResize(key, direction, ref, pos); resizeStartRef.current = null; }} resizeHandleStyles={cornerHandleStyles} className={`group relative cursor-move overflow-visible border transition-colors ${isSelected ? "z-20 border-[var(--color-accent-soft)] bg-[rgba(190,160,120,0.08)] outline outline-1 outline-offset-2 outline-[var(--color-accent)]/70" : "border-white/8 bg-transparent opacity-35 hover:border-white/40 hover:opacity-80"}`}> {isSelected ? {labels[key]} : {shortLabels[key]}} -
{key === "qrArea" ? : textPreview && baselinePx !== null ? : null}{isSelected && baselinePx !== null ? : null}
+
{key === "qrArea" ? : textPreview && baselinePx !== null ? : null}{isSelected && baselinePx !== null ? : null}
; })} @@ -239,7 +242,7 @@ function ToggleButton({ label, checked, onClick }: { label: string; checked: boo const handleStyle = { width: 8, height: 8, borderRadius: 3, background: "#dbc2a5", border: "1px solid #171311", boxShadow: "0 0 0 1px rgba(135,105,65,.55)" }; const cornerHandleStyles = { topLeft: { ...handleStyle, cursor: "nwse-resize" }, topRight: { ...handleStyle, cursor: "nesw-resize" }, bottomLeft: { ...handleStyle, cursor: "nesw-resize" }, bottomRight: { ...handleStyle, cursor: "nwse-resize" } }; -function PreviewCanvas({ area, preview, baselinePx, fontMetricsVersion, scale }: { area: VoucherTemplateLayoutV1["valueArea"]; preview: { text: string; fit: ReturnType }; baselinePx: number; fontMetricsVersion: number; scale: number }) { +function PreviewCanvas({ area, preview, baselinePx, fontMetricsVersion, scale, horizontalInsetMm }: { area: VoucherTemplateLayoutV1["valueArea"]; preview: { text: string; fit: ReturnType }; baselinePx: number; fontMetricsVersion: number; scale: number; horizontalInsetMm: number }) { const canvasRef = useRef(null); const fontFamily = VOUCHER_TEMPLATE_PREVIEW_FONT_FAMILIES[area.typography.fontFamilyKey] ?? '"Noto Sans", sans-serif'; useEffect(() => { @@ -259,11 +262,16 @@ function PreviewCanvas({ area, preview, baselinePx, fontMetricsVersion, scale }: context.font = `${area.typography.fontWeight === "bold" ? 700 : 400} ${getVoucherTemplatePreviewFontSizePx(preview.fit.fontSizePt, scale)}px ${fontFamily}`; context.textBaseline = "alphabetic"; context.textAlign = area.typography.alignment; - context.fillStyle = "#2e241f"; - const x = area.typography.alignment === "center" ? widthPx / 2 : VOUCHER_TEMPLATE_PREVIEW_HORIZONTAL_PADDING_MM * scale / 2; + context.fillStyle = cmykToCssRgb(area.typography.color); + const x = area.typography.alignment === "center" ? widthPx / 2 : horizontalInsetMm * scale; preview.fit.lines.forEach((line, index) => { const lineBaselinePx = getVoucherTemplatePreviewLineBaselinePx(baselinePx, preview.fit.lines.length, index, preview.fit.lineHeightMm, scale); context.fillText(line, x, lineBaselinePx); }); - }, [area, baselinePx, fontFamily, fontMetricsVersion, preview, scale]); + }, [area, baselinePx, fontFamily, fontMetricsVersion, horizontalInsetMm, preview, scale]); return