From 938239e16e7691abd352318a24eba9d554d17b81 Mon Sep 17 00:00:00 2001 From: Nick Szaunig Date: Tue, 1 Sep 2026 16:59:48 +0200 Subject: [PATCH] fix: fehlende Dateien fuer Etiketten-Elemente-Umbau nachreichen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit git add -u im letzten Commit hat neue (untracked) Dateien übersprungen: die Migration für LabelTemplate.elements, die neuen Typ-/DTO-Dateien und den kompletten Drag-and-Drop-Editor fehlten dadurch im Build. --- .../migration.sql | 5 + .../permission-catalog-sync.service.spec.ts | 78 +++++ .../label-templates/dto/label-element.dto.ts | 116 +++++++ .../label-templates/label-element.types.ts | 57 ++++ .../settings/label-templates/[id]/page.tsx | 51 +++ .../designer/element-properties-panel.tsx | 305 ++++++++++++++++++ .../label-templates/designer/label-canvas.tsx | 208 ++++++++++++ .../designer/label-designer.tsx | 290 +++++++++++++++++ .../settings/label-templates/new/page.tsx | 24 ++ 9 files changed, 1134 insertions(+) create mode 100644 ApiCore-Backend/prisma/migrations/20260901114849_label_template_elements/migration.sql create mode 100644 ApiCore-Backend/src/auth/permissions/permission-catalog-sync.service.spec.ts create mode 100644 ApiCore-Backend/src/label-templates/dto/label-element.dto.ts create mode 100644 ApiCore-Backend/src/label-templates/label-element.types.ts create mode 100644 ApiCore-Frontend/src/app/(app)/settings/label-templates/[id]/page.tsx create mode 100644 ApiCore-Frontend/src/app/(app)/settings/label-templates/designer/element-properties-panel.tsx create mode 100644 ApiCore-Frontend/src/app/(app)/settings/label-templates/designer/label-canvas.tsx create mode 100644 ApiCore-Frontend/src/app/(app)/settings/label-templates/designer/label-designer.tsx create mode 100644 ApiCore-Frontend/src/app/(app)/settings/label-templates/new/page.tsx diff --git a/ApiCore-Backend/prisma/migrations/20260901114849_label_template_elements/migration.sql b/ApiCore-Backend/prisma/migrations/20260901114849_label_template_elements/migration.sql new file mode 100644 index 0000000..762e81a --- /dev/null +++ b/ApiCore-Backend/prisma/migrations/20260901114849_label_template_elements/migration.sql @@ -0,0 +1,5 @@ +-- LabelTemplate switches from a plain-text "content" field to a structured +-- "elements" JSON array driving the canvas designer + PDF renderer. The +-- feature was never deployed live, so no existing rows need preserving. +ALTER TABLE "LabelTemplate" DROP COLUMN "content"; +ALTER TABLE "LabelTemplate" ADD COLUMN "elements" JSONB NOT NULL DEFAULT '[]'; diff --git a/ApiCore-Backend/src/auth/permissions/permission-catalog-sync.service.spec.ts b/ApiCore-Backend/src/auth/permissions/permission-catalog-sync.service.spec.ts new file mode 100644 index 0000000..c310263 --- /dev/null +++ b/ApiCore-Backend/src/auth/permissions/permission-catalog-sync.service.spec.ts @@ -0,0 +1,78 @@ +import { PermissionCatalogSyncService } from './permission-catalog-sync.service'; +import { PrismaService } from '../../prisma/prisma.service'; +import { PERMISSION_CATALOG } from './permission-catalog'; + +describe('PermissionCatalogSyncService', () => { + let service: PermissionCatalogSyncService; + let prisma: { + permission: { findMany: jest.Mock; create: jest.Mock }; + role: { findMany: jest.Mock }; + rolePermission: { createMany: jest.Mock }; + }; + + beforeEach(() => { + prisma = { + permission: { + findMany: jest.fn(), + create: jest.fn((args: { data: { key: string } }) => + Promise.resolve({ id: `perm-${args.data.key}`, ...args.data }), + ), + }, + role: { findMany: jest.fn().mockResolvedValue([]) }, + rolePermission: { createMany: jest.fn().mockResolvedValue({ count: 0 }) }, + }; + service = new PermissionCatalogSyncService( + prisma as unknown as PrismaService, + ); + }); + + it('does nothing when every catalog permission already exists', async () => { + prisma.permission.findMany.mockResolvedValue( + PERMISSION_CATALOG.map((entry) => ({ key: entry.key })), + ); + + await service.onModuleInit(); + + expect(prisma.permission.create).not.toHaveBeenCalled(); + expect(prisma.role.findMany).not.toHaveBeenCalled(); + expect(prisma.rolePermission.createMany).not.toHaveBeenCalled(); + }); + + it('inserts missing catalog permissions and grants them to every system role', async () => { + const [first, ...rest] = PERMISSION_CATALOG; + prisma.permission.findMany.mockResolvedValue( + rest.map((entry) => ({ key: entry.key })), + ); + prisma.role.findMany.mockResolvedValue([ + { id: 'role-admin' }, + { id: 'role-owner' }, + ]); + + await service.onModuleInit(); + + expect(prisma.permission.create).toHaveBeenCalledWith({ data: first }); + expect(prisma.role.findMany).toHaveBeenCalledWith({ + where: { isSystem: true }, + select: { id: true }, + }); + expect(prisma.rolePermission.createMany).toHaveBeenCalledWith({ + data: [ + { roleId: 'role-admin', permissionId: `perm-${first.key}` }, + { roleId: 'role-owner', permissionId: `perm-${first.key}` }, + ], + skipDuplicates: true, + }); + }); + + it('skips the role grant step when no system role exists', async () => { + prisma.permission.findMany.mockResolvedValue([]); + prisma.role.findMany.mockResolvedValue([]); + + await service.onModuleInit(); + + expect(prisma.permission.create).toHaveBeenCalledTimes( + PERMISSION_CATALOG.length, + ); + expect(prisma.rolePermission.createMany).not.toHaveBeenCalled(); + }); +}); diff --git a/ApiCore-Backend/src/label-templates/dto/label-element.dto.ts b/ApiCore-Backend/src/label-templates/dto/label-element.dto.ts new file mode 100644 index 0000000..f344e74 --- /dev/null +++ b/ApiCore-Backend/src/label-templates/dto/label-element.dto.ts @@ -0,0 +1,116 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { + IsBoolean, + IsIn, + IsNumber, + IsOptional, + IsString, + MaxLength, +} from 'class-validator'; +import { PLACEHOLDER_KEYS } from '../label-element.types'; + +const ELEMENT_TYPES = ['text', 'qrCode', 'image', 'line'] as const; +const FONT_FAMILIES = ['sans', 'serif', 'mono'] as const; +const TEXT_ALIGNS = ['left', 'center', 'right'] as const; +const LINE_ORIENTATIONS = ['horizontal', 'vertical'] as const; + +// One DTO shared by all element types rather than a discriminated-union of +// four DTOs: this is an internal, permission-gated design surface (not +// public input), so the pragmatic boundary check is "every field has the +// right primitive type" - the service picks which fields it reads per +// `type` at render time. +export class LabelElementDto { + @ApiProperty() + @IsString() + id!: string; + + @ApiProperty({ enum: ELEMENT_TYPES }) + @IsIn(ELEMENT_TYPES) + type!: (typeof ELEMENT_TYPES)[number]; + + @ApiProperty() + @IsNumber() + x!: number; + + @ApiProperty() + @IsNumber() + y!: number; + + @ApiPropertyOptional() + @IsOptional() + @IsNumber() + width?: number; + + @ApiPropertyOptional() + @IsOptional() + @IsNumber() + height?: number; + + @ApiPropertyOptional() + @IsOptional() + @IsNumber() + size?: number; + + @ApiPropertyOptional() + @IsOptional() + @IsNumber() + length?: number; + + @ApiPropertyOptional() + @IsOptional() + @IsNumber() + thickness?: number; + + @ApiPropertyOptional({ description: 'text-Element: literaler Text' }) + @IsOptional() + @IsString() + @MaxLength(500) + text?: string; + + @ApiPropertyOptional({ enum: PLACEHOLDER_KEYS }) + @IsOptional() + @IsIn(PLACEHOLDER_KEYS) + placeholderKey?: (typeof PLACEHOLDER_KEYS)[number] | null; + + @ApiPropertyOptional() + @IsOptional() + @IsNumber() + fontSize?: number; + + @ApiPropertyOptional({ enum: FONT_FAMILIES }) + @IsOptional() + @IsIn(FONT_FAMILIES) + fontFamily?: (typeof FONT_FAMILIES)[number]; + + @ApiPropertyOptional() + @IsOptional() + @IsBoolean() + bold?: boolean; + + @ApiPropertyOptional() + @IsOptional() + @IsBoolean() + italic?: boolean; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + @MaxLength(20) + color?: string; + + @ApiPropertyOptional({ enum: TEXT_ALIGNS }) + @IsOptional() + @IsIn(TEXT_ALIGNS) + align?: (typeof TEXT_ALIGNS)[number]; + + @ApiPropertyOptional({ enum: LINE_ORIENTATIONS }) + @IsOptional() + @IsIn(LINE_ORIENTATIONS) + orientation?: (typeof LINE_ORIENTATIONS)[number]; + + @ApiPropertyOptional({ description: 'image-Element: data: URL (PNG/JPEG)' }) + @IsOptional() + @IsString() + @MaxLength(3_000_000) + src?: string; +} diff --git a/ApiCore-Backend/src/label-templates/label-element.types.ts b/ApiCore-Backend/src/label-templates/label-element.types.ts new file mode 100644 index 0000000..c976f25 --- /dev/null +++ b/ApiCore-Backend/src/label-templates/label-element.types.ts @@ -0,0 +1,57 @@ +export type LabelFontFamily = 'sans' | 'serif' | 'mono'; +export type LabelTextAlign = 'left' | 'center' | 'right'; +export type LabelLineOrientation = 'horizontal' | 'vertical'; + +export const PLACEHOLDER_KEYS = [ + 'batchNumber', + 'honeyType', + 'lotNumber', + 'bottlingDate', + 'bestBeforeDate', + 'jarSize', + 'productName', +] as const; +export type PlaceholderKey = (typeof PLACEHOLDER_KEYS)[number]; + +interface LabelElementBase { + id: string; + x: number; + y: number; +} + +export interface LabelTextElement extends LabelElementBase { + type: 'text'; + width: number; + height: number; + text: string; + placeholderKey?: PlaceholderKey | null; + fontSize: number; + fontFamily: LabelFontFamily; + bold: boolean; + italic: boolean; + color: string; + align: LabelTextAlign; +} + +export interface LabelQrCodeElement extends LabelElementBase { + type: 'qrCode'; + size: number; +} + +export interface LabelImageElement extends LabelElementBase { + type: 'image'; + width: number; + height: number; + src: string; +} + +export interface LabelLineElement extends LabelElementBase { + type: 'line'; + length: number; + thickness: number; + color: string; + orientation: LabelLineOrientation; +} + +export type LabelElement = + LabelTextElement | LabelQrCodeElement | LabelImageElement | LabelLineElement; diff --git a/ApiCore-Frontend/src/app/(app)/settings/label-templates/[id]/page.tsx b/ApiCore-Frontend/src/app/(app)/settings/label-templates/[id]/page.tsx new file mode 100644 index 0000000..4ed9dba --- /dev/null +++ b/ApiCore-Frontend/src/app/(app)/settings/label-templates/[id]/page.tsx @@ -0,0 +1,51 @@ +"use client"; + +import { useParams } from "next/navigation"; +import { useQuery } from "@tanstack/react-query"; +import { Tag } from "lucide-react"; +import { fetchLabelTemplates, updateLabelTemplate } from "@/lib/label-templates/api"; +import { PageHeader } from "@/components/page-header"; +import { LabelDesigner, LabelDesignerValue } from "../designer/label-designer"; + +export default function EditLabelTemplatePage() { + const params = useParams<{ id: string }>(); + const { data: templates, isLoading, isError } = useQuery({ + queryKey: ["label-templates"], + queryFn: fetchLabelTemplates, + }); + const template = templates?.find((entry) => entry.id === params.id); + + async function handleSubmit(value: LabelDesignerValue) { + await updateLabelTemplate(params.id, value); + } + + return ( +
+ + {isLoading ?

Lädt…

: null} + {isError || (!isLoading && !template) ? ( +

+ Etikettenvorlage konnte nicht geladen werden. +

+ ) : null} + {template ? ( + + ) : null} +
+ ); +} diff --git a/ApiCore-Frontend/src/app/(app)/settings/label-templates/designer/element-properties-panel.tsx b/ApiCore-Frontend/src/app/(app)/settings/label-templates/designer/element-properties-panel.tsx new file mode 100644 index 0000000..2c0e3a1 --- /dev/null +++ b/ApiCore-Frontend/src/app/(app)/settings/label-templates/designer/element-properties-panel.tsx @@ -0,0 +1,305 @@ +"use client"; + +import { Bold, Italic, Trash2 } from "lucide-react"; +import { + LabelElement, + LabelFontFamily, + LabelTextAlign, + PLACEHOLDER_KEYS, + PLACEHOLDER_LABELS, + PlaceholderKey, +} from "@/lib/label-templates/types"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; + +const FONT_FAMILY_LABELS: Record = { + sans: "Serifenlos", + serif: "Serifenschrift", + mono: "Festbreite", +}; + +const ALIGN_LABELS: Record = { + left: "Links", + center: "Zentriert", + right: "Rechts", +}; + +interface ElementPropertiesPanelProps { + element: LabelElement; + onChange: (patch: Partial) => void; + onDelete: () => void; +} + +export function ElementPropertiesPanel({ + element, + onChange, + onDelete, +}: ElementPropertiesPanelProps) { + return ( +
+
+

+ {element.type === "text" ? "Text" : element.type === "qrCode" ? "QR-Code" : element.type === "image" ? "Bild" : "Linie"} +

+ +
+ + {element.type === "text" ? : null} + {element.type === "qrCode" ? ( +
+ + onChange({ size: Number(event.target.value) })} + /> +
+ ) : null} + {element.type === "image" ? ( +
+
+ + onChange({ width: Number(event.target.value) })} + /> +
+
+ + onChange({ height: Number(event.target.value) })} + /> +
+
+ ) : null} + {element.type === "line" ? : null} +
+ ); +} + +function TextFields({ + element, + onChange, +}: { + element: Extract; + onChange: (patch: Partial) => void; +}) { + return ( + <> +
+ + +
+ {!element.placeholderKey ? ( +
+ + onChange({ text: event.target.value })} + /> +
+ ) : null} +
+
+ + onChange({ width: Number(event.target.value) })} + /> +
+
+ + onChange({ height: Number(event.target.value) })} + /> +
+
+
+ + +
+
+
+ + onChange({ fontSize: Number(event.target.value) })} + /> +
+
+ + onChange({ color: event.target.value })} + /> +
+
+
+ + + +
+ + ); +} + +function LineFields({ + element, + onChange, +}: { + element: Extract; + onChange: (patch: Partial) => void; +}) { + return ( + <> +
+
+ + onChange({ length: Number(event.target.value) })} + /> +
+
+ + onChange({ thickness: Number(event.target.value) })} + /> +
+
+
+ + onChange({ color: event.target.value })} + /> +
+
+ + +
+ + ); +} diff --git a/ApiCore-Frontend/src/app/(app)/settings/label-templates/designer/label-canvas.tsx b/ApiCore-Frontend/src/app/(app)/settings/label-templates/designer/label-canvas.tsx new file mode 100644 index 0000000..2874b41 --- /dev/null +++ b/ApiCore-Frontend/src/app/(app)/settings/label-templates/designer/label-canvas.tsx @@ -0,0 +1,208 @@ +"use client"; + +import { PointerEvent as ReactPointerEvent } from "react"; +import { QrCode } from "lucide-react"; +import { + LabelElement, + LabelFontFamily, +} from "@/lib/label-templates/types"; +import { cn } from "@/lib/utils"; + +export const CANVAS_SCALE = 4; // px per mm +const PT_TO_MM = 25.4 / 72; +const MIN_SIZE_MM = 5; + +const FONT_CSS: Record = { + sans: "Helvetica, Arial, sans-serif", + serif: "Georgia, 'Times New Roman', serif", + mono: "'Courier New', monospace", +}; + +function round(value: number): number { + return Math.round(value * 10) / 10; +} + +interface LabelCanvasProps { + widthMm: number; + heightMm: number; + elements: LabelElement[]; + selectedId: string | null; + onSelect: (id: string | null) => void; + onChangeElement: (id: string, patch: Partial) => void; +} + +export function LabelCanvas({ + widthMm, + heightMm, + elements, + selectedId, + onSelect, + onChangeElement, +}: LabelCanvasProps) { + function startDrag( + event: ReactPointerEvent, + element: LabelElement, + mode: "move" | "resize", + ) { + event.stopPropagation(); + event.preventDefault(); + onSelect(element.id); + const startX = event.clientX; + const startY = event.clientY; + const origin = element; + + function onMove(moveEvent: PointerEvent) { + const dxMm = (moveEvent.clientX - startX) / CANVAS_SCALE; + const dyMm = (moveEvent.clientY - startY) / CANVAS_SCALE; + + if (mode === "move") { + onChangeElement(origin.id, { + x: Math.max(0, round(origin.x + dxMm)), + y: Math.max(0, round(origin.y + dyMm)), + }); + return; + } + + if (origin.type === "text" || origin.type === "image") { + onChangeElement(origin.id, { + width: Math.max(MIN_SIZE_MM, round(origin.width + dxMm)), + height: Math.max(MIN_SIZE_MM, round(origin.height + dyMm)), + }); + } else if (origin.type === "qrCode") { + onChangeElement(origin.id, { + size: Math.max(MIN_SIZE_MM, round(origin.size + dxMm)), + }); + } else if (origin.type === "line") { + const delta = origin.orientation === "horizontal" ? dxMm : dyMm; + onChangeElement(origin.id, { + length: Math.max(MIN_SIZE_MM, round(origin.length + delta)), + }); + } + } + + function onUp() { + window.removeEventListener("pointermove", onMove); + window.removeEventListener("pointerup", onUp); + } + + window.addEventListener("pointermove", onMove); + window.addEventListener("pointerup", onUp); + } + + return ( +
onSelect(null)} + > + {elements.map((element) => ( + startDrag(event, element, "move")} + onStartResize={(event) => startDrag(event, element, "resize")} + /> + ))} +
+ ); +} + +function ElementBox({ + element, + selected, + onStartMove, + onStartResize, +}: { + element: LabelElement; + selected: boolean; + onStartMove: (event: ReactPointerEvent) => void; + onStartResize: (event: ReactPointerEvent) => void; +}) { + const box = boxFor(element); + + return ( +
+ {renderContent(element)} + {selected ? ( +
{ + event.stopPropagation(); + onStartResize(event); + }} + /> + ) : null} +
+ ); +} + +function boxFor(element: LabelElement): { + left: number; + top: number; + width: number; + height: number; +} { + const left = element.x * CANVAS_SCALE; + const top = element.y * CANVAS_SCALE; + if (element.type === "qrCode") { + return { left, top, width: element.size * CANVAS_SCALE, height: element.size * CANVAS_SCALE }; + } + if (element.type === "line") { + const thicknessPx = Math.max(2, element.thickness * PT_TO_MM * CANVAS_SCALE); + return element.orientation === "horizontal" + ? { left, top: top - thicknessPx / 2, width: element.length * CANVAS_SCALE, height: thicknessPx } + : { left: left - thicknessPx / 2, top, width: thicknessPx, height: element.length * CANVAS_SCALE }; + } + return { left, top, width: element.width * CANVAS_SCALE, height: element.height * CANVAS_SCALE }; +} + +function renderContent(element: LabelElement) { + switch (element.type) { + case "text": { + const display = element.placeholderKey + ? `{{${element.placeholderKey}}}` + : element.text || "Text"; + return ( +
+ {display} +
+ ); + } + case "qrCode": + return ( +
+ +
+ ); + case "image": + return ( + // eslint-disable-next-line @next/next/no-img-element -- data: URL preview, next/image can't handle this + + ); + case "line": + return
; + } +} diff --git a/ApiCore-Frontend/src/app/(app)/settings/label-templates/designer/label-designer.tsx b/ApiCore-Frontend/src/app/(app)/settings/label-templates/designer/label-designer.tsx new file mode 100644 index 0000000..10cf2a0 --- /dev/null +++ b/ApiCore-Frontend/src/app/(app)/settings/label-templates/designer/label-designer.tsx @@ -0,0 +1,290 @@ +"use client"; + +import { ChangeEvent, useState } from "react"; +import { useRouter } from "next/navigation"; +import { Image as ImageIcon, Minus, QrCode, Type } from "lucide-react"; +import { toast } from "sonner"; +import { + LabelElement, + LabelTextElement, + PLACEHOLDER_KEYS, + PLACEHOLDER_LABELS, + PlaceholderKey, +} from "@/lib/label-templates/types"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { LabelCanvas } from "./label-canvas"; +import { ElementPropertiesPanel } from "./element-properties-panel"; + +function newId(): string { + return crypto.randomUUID(); +} + +function newTextElement(overrides: Partial = {}): LabelTextElement { + return { + id: newId(), + type: "text", + x: 5, + y: 5, + width: 40, + height: 10, + text: "Text", + placeholderKey: null, + fontSize: 10, + fontFamily: "sans", + bold: false, + italic: false, + color: "#000000", + align: "left", + ...overrides, + }; +} + +export interface LabelDesignerValue { + name: string; + elements: LabelElement[]; + widthMm: number; + heightMm: number; + isDefault: boolean; +} + +interface LabelDesignerProps { + initial?: LabelDesignerValue; + onSubmit: (value: LabelDesignerValue) => Promise; + submitLabel: string; +} + +export function LabelDesigner({ initial, onSubmit, submitLabel }: LabelDesignerProps) { + const router = useRouter(); + const [name, setName] = useState(initial?.name ?? ""); + const [widthMm, setWidthMm] = useState(initial?.widthMm ?? 90); + const [heightMm, setHeightMm] = useState(initial?.heightMm ?? 35); + const [isDefault, setIsDefault] = useState(initial?.isDefault ?? false); + const [elements, setElements] = useState(initial?.elements ?? []); + const [selectedId, setSelectedId] = useState(null); + const [isSubmitting, setIsSubmitting] = useState(false); + + const selectedElement = elements.find((element) => element.id === selectedId) ?? null; + + function addElement(element: LabelElement) { + setElements((current) => [...current, element]); + setSelectedId(element.id); + } + + function changeElement(id: string, patch: Partial) { + setElements((current) => + current.map((element) => + element.id === id ? ({ ...element, ...patch } as LabelElement) : element, + ), + ); + } + + function deleteSelected() { + if (!selectedId) return; + setElements((current) => current.filter((element) => element.id !== selectedId)); + setSelectedId(null); + } + + function handlePlaceholderInsert(key: PlaceholderKey) { + addElement(newTextElement({ placeholderKey: key, text: "" })); + } + + function handleImageUpload(event: ChangeEvent) { + const file = event.target.files?.[0]; + event.target.value = ""; + if (!file) return; + const reader = new FileReader(); + reader.onload = () => { + addElement({ + id: newId(), + type: "image", + x: 5, + y: 5, + width: 20, + height: 20, + src: String(reader.result), + }); + }; + reader.readAsDataURL(file); + } + + async function handleSubmit() { + if (!name.trim()) { + toast.error("Bitte einen Namen für die Vorlage angeben."); + return; + } + setIsSubmitting(true); + try { + await onSubmit({ name, elements, widthMm, heightMm, isDefault }); + router.push("/settings/label-templates"); + } catch { + toast.error("Vorlage konnte nicht gespeichert werden."); + } finally { + setIsSubmitting(false); + } + } + + return ( +
+
+ + + Vorlage + + +
+ + setName(event.target.value)} + required + /> +
+
+
+ + setWidthMm(Number(event.target.value))} + /> +
+
+ + setHeightMm(Number(event.target.value))} + /> +
+
+ +
+
+ + + + Elemente + + + + + + + +
+ + + + Eigenschaften + + + {selectedElement ? ( + changeElement(selectedElement.id, patch)} + onDelete={deleteSelected} + /> + ) : ( +

+ Element im Etikett anklicken, um es zu bearbeiten. +

+ )} +
+
+
+ ); +} diff --git a/ApiCore-Frontend/src/app/(app)/settings/label-templates/new/page.tsx b/ApiCore-Frontend/src/app/(app)/settings/label-templates/new/page.tsx new file mode 100644 index 0000000..901a280 --- /dev/null +++ b/ApiCore-Frontend/src/app/(app)/settings/label-templates/new/page.tsx @@ -0,0 +1,24 @@ +"use client"; + +import { Tag } from "lucide-react"; +import { createLabelTemplate } from "@/lib/label-templates/api"; +import { PageHeader } from "@/components/page-header"; +import { LabelDesigner, LabelDesignerValue } from "../designer/label-designer"; + +export default function NewLabelTemplatePage() { + async function handleSubmit(value: LabelDesignerValue) { + await createLabelTemplate(value); + } + + return ( +
+ + +
+ ); +}