feat: Etiketten-Seitenlayout auf dem A4-Bogen frei waehlbar
Backend Image / build-and-push (push) Successful in 2m16s
Frontend Image / build-and-push (push) Successful in 2m0s

- LabelTemplate bekommt columns/rows (optional, leer = automatisch
  passend), plus konfigurierbare Raender/Abstaende - damit ein Etikett
  exakt auf das vorgedruckte Raster eines physischen Etikettenbogens
  (z.B. Avery) gelegt werden kann statt nur automatisch berechnet
- Startposition beim Drucken waehlbar, fuer bereits angebrochene Boegen
  (erste Seite startet ab der gewaehlten Position, Folgeseiten wieder
  von vorne)
This commit is contained in:
Nick Szaunig committed 2026-09-01 19:43:01 +02:00
1 parent 9659b8d266
commit c6941a797f
11 files changed
+357 -27

No files matched your search

@@ -7,6 +7,8 @@ import { toast } from "sonner";
import { fetchLabelTemplates, printLabels } from "@/lib/label-templates/api";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
@@ -17,6 +19,7 @@ import {
export function PrintLabelsCard({ bottlingBatchId }: { bottlingBatchId: string }) {
const [templateId, setTemplateId] = useState("");
const [startPosition, setStartPosition] = useState(1);
const { data: templates } = useQuery({
queryKey: ["label-templates"],
queryFn: fetchLabelTemplates,
@@ -28,9 +31,13 @@ export function PrintLabelsCard({ bottlingBatchId }: { bottlingBatchId: string }
const selectedTemplate = activeTemplates.find(
(template) => template.id === effectiveTemplateId,
);
const slotsPerSheet =
selectedTemplate?.columns && selectedTemplate?.rows
? selectedTemplate.columns * selectedTemplate.rows
: undefined;
const mutation = useMutation({
mutationFn: () => printLabels(effectiveTemplateId, bottlingBatchId),
mutationFn: () => printLabels(effectiveTemplateId, bottlingBatchId, startPosition),
onError: () => toast.error("Etiketten konnten nicht erstellt werden"),
});
@@ -65,6 +72,23 @@ export function PrintLabelsCard({ bottlingBatchId }: { bottlingBatchId: string }
))}
</SelectContent>
</Select>
<div className="flex flex-col gap-2">
<Label htmlFor="print-start-position">
Startposition auf dem Bogen (1 = oben links)
</Label>
<Input
id="print-start-position"
type="number"
inputMode="numeric"
min={1}
max={slotsPerSheet}
value={startPosition}
onChange={(event) => setStartPosition(Math.max(1, Number(event.target.value)))}
/>
<p className="text-xs text-muted-foreground">
Für bereits angebrochene Bögen: Position der ersten noch freien Etikette.
</p>
</div>
<Button
type="button"
disabled={!effectiveTemplateId || mutation.isPending}
@@ -40,6 +40,12 @@ export default function EditLabelTemplatePage() {
elements: template.elements,
widthMm: template.widthMm,
heightMm: template.heightMm,
columns: template.columns,
rows: template.rows,
marginTopMm: template.marginTopMm,
marginLeftMm: template.marginLeftMm,
gapXMm: template.gapXMm,
gapYMm: template.gapYMm,
isDefault: template.isDefault,
}}
onSubmit={handleSubmit}
@@ -74,6 +74,12 @@ export interface LabelDesignerValue {
elements: LabelElement[];
widthMm: number;
heightMm: number;
columns: number | null;
rows: number | null;
marginTopMm: number;
marginLeftMm: number;
gapXMm: number;
gapYMm: number;
isDefault: boolean;
}
@@ -117,6 +123,12 @@ export function LabelDesigner({ initial, onSubmit, submitLabel }: LabelDesignerP
const [name, setName] = useState(initial?.name ?? "");
const [widthMm, setWidthMm] = useState(initial?.widthMm ?? 90);
const [heightMm, setHeightMm] = useState(initial?.heightMm ?? 35);
const [columns, setColumns] = useState<number | null>(initial?.columns ?? null);
const [rows, setRows] = useState<number | null>(initial?.rows ?? null);
const [marginTopMm, setMarginTopMm] = useState(initial?.marginTopMm ?? 5);
const [marginLeftMm, setMarginLeftMm] = useState(initial?.marginLeftMm ?? 5);
const [gapXMm, setGapXMm] = useState(initial?.gapXMm ?? 2);
const [gapYMm, setGapYMm] = useState(initial?.gapYMm ?? 2);
const [isDefault, setIsDefault] = useState(initial?.isDefault ?? false);
const [elements, setElements] = useState<LabelElement[]>(initial?.elements ?? []);
const [selectedId, setSelectedId] = useState<string | null>(null);
@@ -190,7 +202,19 @@ export function LabelDesigner({ initial, onSubmit, submitLabel }: LabelDesignerP
}
function handleExport() {
const payload: LabelDesignerValue = { name, elements, widthMm, heightMm, isDefault };
const payload: LabelDesignerValue = {
name,
elements,
widthMm,
heightMm,
columns,
rows,
marginTopMm,
marginLeftMm,
gapXMm,
gapYMm,
isDefault,
};
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: "application/json" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
@@ -221,6 +245,12 @@ export function LabelDesigner({ initial, onSubmit, submitLabel }: LabelDesignerP
if (typeof value.name === "string" && value.name && !name) setName(value.name);
if (typeof value.widthMm === "number") setWidthMm(value.widthMm);
if (typeof value.heightMm === "number") setHeightMm(value.heightMm);
if (typeof value.columns === "number" || value.columns === null) setColumns(value.columns);
if (typeof value.rows === "number" || value.rows === null) setRows(value.rows);
if (typeof value.marginTopMm === "number") setMarginTopMm(value.marginTopMm);
if (typeof value.marginLeftMm === "number") setMarginLeftMm(value.marginLeftMm);
if (typeof value.gapXMm === "number") setGapXMm(value.gapXMm);
if (typeof value.gapYMm === "number") setGapYMm(value.gapYMm);
}
setSelectedId(null);
toast.success("Vorlage importiert.");
@@ -238,7 +268,19 @@ export function LabelDesigner({ initial, onSubmit, submitLabel }: LabelDesignerP
}
setIsSubmitting(true);
try {
await onSubmit({ name, elements, widthMm, heightMm, isDefault });
await onSubmit({
name,
elements,
widthMm,
heightMm,
columns,
rows,
marginTopMm,
marginLeftMm,
gapXMm,
gapYMm,
isDefault,
});
router.push("/settings/label-templates");
} catch {
toast.error("Vorlage konnte nicht gespeichert werden.");
@@ -298,6 +340,91 @@ export function LabelDesigner({ initial, onSubmit, submitLabel }: LabelDesignerP
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">Seiten-Layout (A4)</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<p className="text-xs text-muted-foreground">
Spalten/Zeilen leer lassen, um automatisch so viele einzuplanen wie passen. Feste
Werte eintragen, um exakt zum vorgedruckten Raster eines Etikettenbogens (z.B.
Avery-Vorlage) zu passen.
</p>
<div className="flex gap-3">
<div className="flex flex-1 flex-col gap-2">
<Label htmlFor="designer-columns">Spalten</Label>
<Input
id="designer-columns"
type="number"
inputMode="numeric"
placeholder="Auto"
value={columns ?? ""}
onChange={(event) =>
setColumns(event.target.value === "" ? null : Number(event.target.value))
}
/>
</div>
<div className="flex flex-1 flex-col gap-2">
<Label htmlFor="designer-rows">Zeilen</Label>
<Input
id="designer-rows"
type="number"
inputMode="numeric"
placeholder="Auto"
value={rows ?? ""}
onChange={(event) =>
setRows(event.target.value === "" ? null : Number(event.target.value))
}
/>
</div>
</div>
<div className="flex gap-3">
<div className="flex flex-1 flex-col gap-2">
<Label htmlFor="designer-margin-top">Rand oben (mm)</Label>
<Input
id="designer-margin-top"
type="number"
inputMode="decimal"
value={marginTopMm}
onChange={(event) => setMarginTopMm(Number(event.target.value))}
/>
</div>
<div className="flex flex-1 flex-col gap-2">
<Label htmlFor="designer-margin-left">Rand links (mm)</Label>
<Input
id="designer-margin-left"
type="number"
inputMode="decimal"
value={marginLeftMm}
onChange={(event) => setMarginLeftMm(Number(event.target.value))}
/>
</div>
</div>
<div className="flex gap-3">
<div className="flex flex-1 flex-col gap-2">
<Label htmlFor="designer-gap-x">Abstand horizontal (mm)</Label>
<Input
id="designer-gap-x"
type="number"
inputMode="decimal"
value={gapXMm}
onChange={(event) => setGapXMm(Number(event.target.value))}
/>
</div>
<div className="flex flex-1 flex-col gap-2">
<Label htmlFor="designer-gap-y">Abstand vertikal (mm)</Label>
<Input
id="designer-gap-y"
type="number"
inputMode="decimal"
value={gapYMm}
onChange={(event) => setGapYMm(Number(event.target.value))}
/>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between gap-2">
<CardTitle className="text-base">Elemente</CardTitle>
@@ -27,9 +27,14 @@ export function deactivateLabelTemplate(id: string): Promise<LabelTemplate> {
return apiFetch<LabelTemplate>(`/label-templates/${id}`, { method: "DELETE" });
}
export async function printLabels(templateId: string, bottlingBatchId: string): Promise<void> {
export async function printLabels(
templateId: string,
bottlingBatchId: string,
startPosition?: number,
): Promise<void> {
const query = startPosition && startPosition > 1 ? `?startPosition=${startPosition}` : "";
const { blob, filename } = await apiFetchBlob(
`/label-templates/${templateId}/print/${bottlingBatchId}`,
`/label-templates/${templateId}/print/${bottlingBatchId}${query}`,
"etiketten.pdf",
);
downloadBlob(blob, filename);
@@ -75,6 +75,12 @@ export interface LabelTemplate {
elements: LabelElement[];
widthMm: number;
heightMm: number;
columns: number | null;
rows: number | null;
marginTopMm: number;
marginLeftMm: number;
gapXMm: number;
gapYMm: number;
isDefault: boolean;
active: boolean;
createdAt: string;
@@ -86,6 +92,12 @@ export interface CreateLabelTemplateInput {
elements: LabelElement[];
widthMm?: number;
heightMm?: number;
columns?: number | null;
rows?: number | null;
marginTopMm?: number;
marginLeftMm?: number;
gapXMm?: number;
gapYMm?: number;
isDefault?: boolean;
}
@@ -94,5 +106,11 @@ export interface UpdateLabelTemplateInput {
elements?: LabelElement[];
widthMm?: number;
heightMm?: number;
columns?: number | null;
rows?: number | null;
marginTopMm?: number;
marginLeftMm?: number;
gapXMm?: number;
gapYMm?: number;
isDefault?: boolean;
}