fix: fehlende Dateien fuer Etiketten-Elemente-Umbau nachreichen
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.
This commit is contained in:
1 parent
5f4e1a7ba9
commit
938239e16e
9 files changed
+1134
No files matched your search
@@ -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 '[]';
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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 (
|
||||||
|
<main className="flex flex-col gap-4 p-4 sm:p-6">
|
||||||
|
<PageHeader
|
||||||
|
icon={Tag}
|
||||||
|
title="Etikettenvorlage bearbeiten"
|
||||||
|
description="Etikett per Drag & Drop gestalten"
|
||||||
|
gradient="var(--gradient-sky)"
|
||||||
|
/>
|
||||||
|
{isLoading ? <p className="text-sm text-muted-foreground">Lädt…</p> : null}
|
||||||
|
{isError || (!isLoading && !template) ? (
|
||||||
|
<p role="alert" className="text-sm text-destructive">
|
||||||
|
Etikettenvorlage konnte nicht geladen werden.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{template ? (
|
||||||
|
<LabelDesigner
|
||||||
|
initial={{
|
||||||
|
name: template.name,
|
||||||
|
elements: template.elements,
|
||||||
|
widthMm: template.widthMm,
|
||||||
|
heightMm: template.heightMm,
|
||||||
|
isDefault: template.isDefault,
|
||||||
|
}}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
submitLabel="Speichern"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
+305
@@ -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<LabelFontFamily, string> = {
|
||||||
|
sans: "Serifenlos",
|
||||||
|
serif: "Serifenschrift",
|
||||||
|
mono: "Festbreite",
|
||||||
|
};
|
||||||
|
|
||||||
|
const ALIGN_LABELS: Record<LabelTextAlign, string> = {
|
||||||
|
left: "Links",
|
||||||
|
center: "Zentriert",
|
||||||
|
right: "Rechts",
|
||||||
|
};
|
||||||
|
|
||||||
|
interface ElementPropertiesPanelProps {
|
||||||
|
element: LabelElement;
|
||||||
|
onChange: (patch: Partial<LabelElement>) => void;
|
||||||
|
onDelete: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ElementPropertiesPanel({
|
||||||
|
element,
|
||||||
|
onChange,
|
||||||
|
onDelete,
|
||||||
|
}: ElementPropertiesPanelProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-sm font-medium text-muted-foreground">
|
||||||
|
{element.type === "text" ? "Text" : element.type === "qrCode" ? "QR-Code" : element.type === "image" ? "Bild" : "Linie"}
|
||||||
|
</p>
|
||||||
|
<Button type="button" variant="ghost" size="icon-sm" onClick={onDelete}>
|
||||||
|
<Trash2 className="size-3.5 text-destructive" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{element.type === "text" ? <TextFields element={element} onChange={onChange} /> : null}
|
||||||
|
{element.type === "qrCode" ? (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="element-qr-size">Größe (mm)</Label>
|
||||||
|
<Input
|
||||||
|
id="element-qr-size"
|
||||||
|
type="number"
|
||||||
|
inputMode="decimal"
|
||||||
|
value={element.size}
|
||||||
|
onChange={(event) => onChange({ size: Number(event.target.value) })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{element.type === "image" ? (
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<div className="flex flex-1 flex-col gap-2">
|
||||||
|
<Label htmlFor="element-image-width">Breite (mm)</Label>
|
||||||
|
<Input
|
||||||
|
id="element-image-width"
|
||||||
|
type="number"
|
||||||
|
inputMode="decimal"
|
||||||
|
value={element.width}
|
||||||
|
onChange={(event) => onChange({ width: Number(event.target.value) })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-1 flex-col gap-2">
|
||||||
|
<Label htmlFor="element-image-height">Höhe (mm)</Label>
|
||||||
|
<Input
|
||||||
|
id="element-image-height"
|
||||||
|
type="number"
|
||||||
|
inputMode="decimal"
|
||||||
|
value={element.height}
|
||||||
|
onChange={(event) => onChange({ height: Number(event.target.value) })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{element.type === "line" ? <LineFields element={element} onChange={onChange} /> : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TextFields({
|
||||||
|
element,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
element: Extract<LabelElement, { type: "text" }>;
|
||||||
|
onChange: (patch: Partial<LabelElement>) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="element-text-source">Inhalt</Label>
|
||||||
|
<Select
|
||||||
|
value={element.placeholderKey ?? "__literal"}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
onChange({
|
||||||
|
placeholderKey: value === "__literal" ? null : (value as PlaceholderKey),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-full" id="element-text-source">
|
||||||
|
<SelectValue>
|
||||||
|
{element.placeholderKey ? PLACEHOLDER_LABELS[element.placeholderKey] : "Fester Text"}
|
||||||
|
</SelectValue>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="__literal">Fester Text</SelectItem>
|
||||||
|
{PLACEHOLDER_KEYS.map((key) => (
|
||||||
|
<SelectItem key={key} value={key}>
|
||||||
|
{PLACEHOLDER_LABELS[key]}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
{!element.placeholderKey ? (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="element-text-content">Text</Label>
|
||||||
|
<Input
|
||||||
|
id="element-text-content"
|
||||||
|
value={element.text}
|
||||||
|
onChange={(event) => onChange({ text: event.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<div className="flex flex-1 flex-col gap-2">
|
||||||
|
<Label htmlFor="element-text-width">Breite (mm)</Label>
|
||||||
|
<Input
|
||||||
|
id="element-text-width"
|
||||||
|
type="number"
|
||||||
|
inputMode="decimal"
|
||||||
|
value={element.width}
|
||||||
|
onChange={(event) => onChange({ width: Number(event.target.value) })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-1 flex-col gap-2">
|
||||||
|
<Label htmlFor="element-text-height">Höhe (mm)</Label>
|
||||||
|
<Input
|
||||||
|
id="element-text-height"
|
||||||
|
type="number"
|
||||||
|
inputMode="decimal"
|
||||||
|
value={element.height}
|
||||||
|
onChange={(event) => onChange({ height: Number(event.target.value) })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="element-text-font">Schriftart</Label>
|
||||||
|
<Select
|
||||||
|
value={element.fontFamily}
|
||||||
|
onValueChange={(value) => onChange({ fontFamily: value as LabelFontFamily })}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-full" id="element-text-font">
|
||||||
|
<SelectValue>{FONT_FAMILY_LABELS[element.fontFamily]}</SelectValue>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{(Object.keys(FONT_FAMILY_LABELS) as LabelFontFamily[]).map((family) => (
|
||||||
|
<SelectItem key={family} value={family}>
|
||||||
|
{FONT_FAMILY_LABELS[family]}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<div className="flex flex-1 flex-col gap-2">
|
||||||
|
<Label htmlFor="element-text-size">Schriftgröße (pt)</Label>
|
||||||
|
<Input
|
||||||
|
id="element-text-size"
|
||||||
|
type="number"
|
||||||
|
inputMode="decimal"
|
||||||
|
value={element.fontSize}
|
||||||
|
onChange={(event) => onChange({ fontSize: Number(event.target.value) })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-1 flex-col gap-2">
|
||||||
|
<Label htmlFor="element-text-color">Farbe</Label>
|
||||||
|
<input
|
||||||
|
id="element-text-color"
|
||||||
|
type="color"
|
||||||
|
className="h-8 w-full cursor-pointer rounded-md border border-input"
|
||||||
|
value={element.color}
|
||||||
|
onChange={(event) => onChange({ color: event.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1.5">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant={element.bold ? "default" : "outline"}
|
||||||
|
size="icon-sm"
|
||||||
|
onClick={() => onChange({ bold: !element.bold })}
|
||||||
|
>
|
||||||
|
<Bold className="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant={element.italic ? "default" : "outline"}
|
||||||
|
size="icon-sm"
|
||||||
|
onClick={() => onChange({ italic: !element.italic })}
|
||||||
|
>
|
||||||
|
<Italic className="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
<Select
|
||||||
|
value={element.align}
|
||||||
|
onValueChange={(value) => onChange({ align: value as LabelTextAlign })}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="flex-1" id="element-text-align">
|
||||||
|
<SelectValue>{ALIGN_LABELS[element.align]}</SelectValue>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{(Object.keys(ALIGN_LABELS) as LabelTextAlign[]).map((align) => (
|
||||||
|
<SelectItem key={align} value={align}>
|
||||||
|
{ALIGN_LABELS[align]}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LineFields({
|
||||||
|
element,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
element: Extract<LabelElement, { type: "line" }>;
|
||||||
|
onChange: (patch: Partial<LabelElement>) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<div className="flex flex-1 flex-col gap-2">
|
||||||
|
<Label htmlFor="element-line-length">Länge (mm)</Label>
|
||||||
|
<Input
|
||||||
|
id="element-line-length"
|
||||||
|
type="number"
|
||||||
|
inputMode="decimal"
|
||||||
|
value={element.length}
|
||||||
|
onChange={(event) => onChange({ length: Number(event.target.value) })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-1 flex-col gap-2">
|
||||||
|
<Label htmlFor="element-line-thickness">Dicke (pt)</Label>
|
||||||
|
<Input
|
||||||
|
id="element-line-thickness"
|
||||||
|
type="number"
|
||||||
|
inputMode="decimal"
|
||||||
|
value={element.thickness}
|
||||||
|
onChange={(event) => onChange({ thickness: Number(event.target.value) })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="element-line-color">Farbe</Label>
|
||||||
|
<input
|
||||||
|
id="element-line-color"
|
||||||
|
type="color"
|
||||||
|
className="h-8 w-full cursor-pointer rounded-md border border-input"
|
||||||
|
value={element.color}
|
||||||
|
onChange={(event) => onChange({ color: event.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1.5">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant={element.orientation === "horizontal" ? "default" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
className="flex-1"
|
||||||
|
onClick={() => onChange({ orientation: "horizontal" })}
|
||||||
|
>
|
||||||
|
Waagerecht
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant={element.orientation === "vertical" ? "default" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
className="flex-1"
|
||||||
|
onClick={() => onChange({ orientation: "vertical" })}
|
||||||
|
>
|
||||||
|
Senkrecht
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<LabelFontFamily, string> = {
|
||||||
|
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<LabelElement>) => 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 (
|
||||||
|
<div
|
||||||
|
className="relative overflow-hidden rounded-lg border border-dashed border-border bg-white shadow-sm"
|
||||||
|
style={{ width: widthMm * CANVAS_SCALE, height: heightMm * CANVAS_SCALE }}
|
||||||
|
onPointerDown={() => onSelect(null)}
|
||||||
|
>
|
||||||
|
{elements.map((element) => (
|
||||||
|
<ElementBox
|
||||||
|
key={element.id}
|
||||||
|
element={element}
|
||||||
|
selected={element.id === selectedId}
|
||||||
|
onStartMove={(event) => startDrag(event, element, "move")}
|
||||||
|
onStartResize={(event) => startDrag(event, element, "resize")}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ElementBox({
|
||||||
|
element,
|
||||||
|
selected,
|
||||||
|
onStartMove,
|
||||||
|
onStartResize,
|
||||||
|
}: {
|
||||||
|
element: LabelElement;
|
||||||
|
selected: boolean;
|
||||||
|
onStartMove: (event: ReactPointerEvent) => void;
|
||||||
|
onStartResize: (event: ReactPointerEvent) => void;
|
||||||
|
}) {
|
||||||
|
const box = boxFor(element);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"absolute cursor-move touch-none select-none",
|
||||||
|
selected && "outline outline-2 outline-primary",
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
left: box.left,
|
||||||
|
top: box.top,
|
||||||
|
width: box.width,
|
||||||
|
height: box.height,
|
||||||
|
}}
|
||||||
|
onPointerDown={onStartMove}
|
||||||
|
>
|
||||||
|
{renderContent(element)}
|
||||||
|
{selected ? (
|
||||||
|
<div
|
||||||
|
className="absolute right-0 bottom-0 size-3 translate-x-1/2 translate-y-1/2 cursor-nwse-resize rounded-full border-2 border-primary bg-background"
|
||||||
|
onPointerDown={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
onStartResize(event);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div
|
||||||
|
className="size-full overflow-hidden whitespace-pre-wrap"
|
||||||
|
style={{
|
||||||
|
fontFamily: FONT_CSS[element.fontFamily],
|
||||||
|
fontSize: element.fontSize * PT_TO_MM * CANVAS_SCALE,
|
||||||
|
fontWeight: element.bold ? 700 : 400,
|
||||||
|
fontStyle: element.italic ? "italic" : "normal",
|
||||||
|
color: element.color,
|
||||||
|
textAlign: element.align,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{display}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
case "qrCode":
|
||||||
|
return (
|
||||||
|
<div className="flex size-full items-center justify-center bg-muted/40">
|
||||||
|
<QrCode className="size-2/3 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
case "image":
|
||||||
|
return (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element -- data: URL preview, next/image can't handle this
|
||||||
|
<img src={element.src} alt="" className="size-full object-contain" />
|
||||||
|
);
|
||||||
|
case "line":
|
||||||
|
return <div className="size-full" style={{ background: element.color }} />;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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> = {}): 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<void>;
|
||||||
|
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<LabelElement[]>(initial?.elements ?? []);
|
||||||
|
const [selectedId, setSelectedId] = useState<string | null>(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<LabelElement>) {
|
||||||
|
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<HTMLInputElement>) {
|
||||||
|
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 (
|
||||||
|
<div className="grid grid-cols-1 items-start gap-4 xl:grid-cols-[minmax(0,1fr)_320px]">
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">Vorlage</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex flex-col gap-4">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="designer-name">Name</Label>
|
||||||
|
<Input
|
||||||
|
id="designer-name"
|
||||||
|
value={name}
|
||||||
|
onChange={(event) => setName(event.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<div className="flex flex-1 flex-col gap-2">
|
||||||
|
<Label htmlFor="designer-width">Breite (mm)</Label>
|
||||||
|
<Input
|
||||||
|
id="designer-width"
|
||||||
|
type="number"
|
||||||
|
inputMode="decimal"
|
||||||
|
value={widthMm}
|
||||||
|
onChange={(event) => setWidthMm(Number(event.target.value))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-1 flex-col gap-2">
|
||||||
|
<Label htmlFor="designer-height">Höhe (mm)</Label>
|
||||||
|
<Input
|
||||||
|
id="designer-height"
|
||||||
|
type="number"
|
||||||
|
inputMode="decimal"
|
||||||
|
value={heightMm}
|
||||||
|
onChange={(event) => setHeightMm(Number(event.target.value))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label className="flex items-center gap-2 text-sm" htmlFor="designer-default">
|
||||||
|
<input
|
||||||
|
id="designer-default"
|
||||||
|
type="checkbox"
|
||||||
|
checked={isDefault}
|
||||||
|
onChange={(event) => setIsDefault(event.target.checked)}
|
||||||
|
/>
|
||||||
|
Als Standardvorlage verwenden
|
||||||
|
</label>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">Elemente</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex flex-wrap items-center gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => addElement(newTextElement())}
|
||||||
|
>
|
||||||
|
<Type className="size-4" />
|
||||||
|
Text
|
||||||
|
</Button>
|
||||||
|
<Select onValueChange={(value) => handlePlaceholderInsert(value as PlaceholderKey)}>
|
||||||
|
<SelectTrigger className="w-auto" size="sm">
|
||||||
|
<SelectValue placeholder="Platzhalter einfügen" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{PLACEHOLDER_KEYS.map((key) => (
|
||||||
|
<SelectItem key={key} value={key}>
|
||||||
|
{PLACEHOLDER_LABELS[key]}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() =>
|
||||||
|
addElement({ id: newId(), type: "qrCode", x: 5, y: 5, size: 20 })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<QrCode className="size-4" />
|
||||||
|
QR-Code
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() =>
|
||||||
|
addElement({
|
||||||
|
id: newId(),
|
||||||
|
type: "line",
|
||||||
|
x: 5,
|
||||||
|
y: 5,
|
||||||
|
length: 40,
|
||||||
|
thickness: 1,
|
||||||
|
color: "#c9a34e",
|
||||||
|
orientation: "horizontal",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Minus className="size-4" />
|
||||||
|
Linie
|
||||||
|
</Button>
|
||||||
|
<Button type="button" variant="outline" size="sm" render={
|
||||||
|
<label className="cursor-pointer">
|
||||||
|
<ImageIcon className="size-4" />
|
||||||
|
Bild/Logo
|
||||||
|
<input type="file" accept="image/png,image/jpeg" className="hidden" onChange={handleImageUpload} />
|
||||||
|
</label>
|
||||||
|
} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="overflow-auto rounded-lg bg-muted/30 p-6">
|
||||||
|
<LabelCanvas
|
||||||
|
widthMm={widthMm}
|
||||||
|
heightMm={heightMm}
|
||||||
|
elements={elements}
|
||||||
|
selectedId={selectedId}
|
||||||
|
onSelect={setSelectedId}
|
||||||
|
onChangeElement={changeElement}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button type="button" onClick={() => void handleSubmit()} disabled={isSubmitting} className="self-start">
|
||||||
|
{isSubmitting ? "Speichert…" : submitLabel}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card className="sticky top-4">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">Eigenschaften</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{selectedElement ? (
|
||||||
|
<ElementPropertiesPanel
|
||||||
|
element={selectedElement}
|
||||||
|
onChange={(patch) => changeElement(selectedElement.id, patch)}
|
||||||
|
onDelete={deleteSelected}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Element im Etikett anklicken, um es zu bearbeiten.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<main className="flex flex-col gap-4 p-4 sm:p-6">
|
||||||
|
<PageHeader
|
||||||
|
icon={Tag}
|
||||||
|
title="Neue Etikettenvorlage"
|
||||||
|
description="Etikett per Drag & Drop gestalten"
|
||||||
|
gradient="var(--gradient-sky)"
|
||||||
|
/>
|
||||||
|
<LabelDesigner onSubmit={handleSubmit} submitLabel="Anlegen" />
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in new issue
Block a user