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