+ {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
+ />
+
+
+
+
+
+
+
+
+ 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 (
+
+
+
+
+ );
+}