Refine invoice template editor interactions

This commit is contained in:
2026-03-13 16:59:14 +01:00
parent 490be6a89b
commit 5fd349dee2
3 changed files with 271 additions and 50 deletions

View File

@@ -2,6 +2,16 @@ import { AUTH_TOKEN_STORAGE_KEY } from "./storage";
const API_ROOT = import.meta.env.VITE_API_URL ?? (import.meta.env.DEV ? "http://localhost:8090/api" : "/api");
export class ApiError extends Error {
status: number;
constructor(message: string, status: number) {
super(message);
this.name = "ApiError";
this.status = status;
}
}
type ApiErrorPayload = {
message?: string;
error?: string;
@@ -44,7 +54,7 @@ function authHeaders(): Record<string, string> {
async function handleResponse<T>(response: Response): Promise<T> {
if (!response.ok) {
throw new Error(await readErrorMessage(response));
throw new ApiError(await readErrorMessage(response), response.status);
}
if (response.status === 204) {
return undefined as T;

View File

@@ -7,12 +7,13 @@ import {
type KeyboardEvent as ReactKeyboardEvent,
type MouseEvent as ReactMouseEvent,
} from "react";
import { apiGet, apiPut } from "../lib/api";
import { ApiError, apiGet, apiPut } from "../lib/api";
import { useSession } from "../lib/session";
import type { UserOption } from "../lib/types";
const CANVAS_WIDTH = 794;
const CANVAS_HEIGHT = 1123;
const CANVAS_ASPECT_RATIO = CANVAS_WIDTH / CANVAS_HEIGHT;
const GRID_SIZE = 5;
const DRAG_DATA_TYPE = "application/x-muh-invoice-template";
const PDF_PAGE_WIDTH = 595.28;
@@ -25,7 +26,7 @@ const CANVAS_BOTTOM_GAP = 10;
const MIN_ELEMENT_WIDTH = 100;
const MIN_MEDIA_WIDTH = 40;
const MIN_MEDIA_HEIGHT = 40;
const MIN_LINE_THICKNESS = 2;
const MIN_LINE_THICKNESS = 1;
const MIN_LINE_LENGTH = 40;
const MIN_FONT_SIZE = 12;
const MAX_FONT_SIZE = 44;
@@ -54,9 +55,11 @@ type ElementKind = "text" | "line" | "image";
type LineOrientation = "horizontal" | "vertical";
type TextAlign = "left" | "center" | "right";
type FontWeight = 400 | 500 | 600 | 700;
type PaletteCategory = "master-data" | "customer-data" | "free-elements";
interface PaletteItem {
id: string;
category: PaletteCategory;
label: string;
description: string;
kind?: ElementKind;
@@ -158,6 +161,7 @@ const CP1252_MAP: Record<number, number> = {
const PALETTE_ITEMS: PaletteItem[] = [
{
id: "invoice-title",
category: "master-data",
label: "Rechnungstitel",
description: "Markante Ueberschrift fuer die Rechnung",
width: 240,
@@ -168,6 +172,7 @@ const PALETTE_ITEMS: PaletteItem[] = [
},
{
id: "company-name",
category: "master-data",
label: "Firmenname",
description: "Name Ihres Unternehmens",
width: 280,
@@ -178,6 +183,7 @@ const PALETTE_ITEMS: PaletteItem[] = [
},
{
id: "contact-person",
category: "master-data",
label: "Ansprechpartner",
description: "Name des Ansprechpartners",
width: 240,
@@ -188,6 +194,7 @@ const PALETTE_ITEMS: PaletteItem[] = [
},
{
id: "street",
category: "master-data",
label: "Strasse",
description: "Strassenname fuer die Anschrift",
width: 220,
@@ -198,6 +205,7 @@ const PALETTE_ITEMS: PaletteItem[] = [
},
{
id: "house-number",
category: "master-data",
label: "Hausnummer",
description: "Hausnummer der Anschrift",
width: 100,
@@ -208,6 +216,7 @@ const PALETTE_ITEMS: PaletteItem[] = [
},
{
id: "postal-code",
category: "master-data",
label: "PLZ",
description: "Postleitzahl",
width: 100,
@@ -218,6 +227,7 @@ const PALETTE_ITEMS: PaletteItem[] = [
},
{
id: "city",
category: "master-data",
label: "Ort",
description: "Stadt oder Ort",
width: 180,
@@ -228,6 +238,7 @@ const PALETTE_ITEMS: PaletteItem[] = [
},
{
id: "email",
category: "master-data",
label: "E-Mail",
description: "Kontakt-E-Mail",
width: 260,
@@ -238,6 +249,7 @@ const PALETTE_ITEMS: PaletteItem[] = [
},
{
id: "phone",
category: "master-data",
label: "Telefon",
description: "Telefonnummer fuer Rueckfragen",
width: 220,
@@ -248,6 +260,7 @@ const PALETTE_ITEMS: PaletteItem[] = [
},
{
id: "invoice-number",
category: "master-data",
label: "Rechnungsnummer",
description: "Eindeutige Kennung",
width: 200,
@@ -258,6 +271,7 @@ const PALETTE_ITEMS: PaletteItem[] = [
},
{
id: "invoice-date",
category: "master-data",
label: "Rechnungsdatum",
description: "Ausstellungsdatum der Rechnung",
width: 200,
@@ -271,6 +285,7 @@ const PALETTE_ITEMS: PaletteItem[] = [
},
{
id: "due-date",
category: "master-data",
label: "Zahlungsziel",
description: "Faelligkeitsdatum",
width: 220,
@@ -285,6 +300,7 @@ const PALETTE_ITEMS: PaletteItem[] = [
},
{
id: "customer-number",
category: "master-data",
label: "Kundennummer",
description: "Referenz zum Kunden",
width: 180,
@@ -295,6 +311,7 @@ const PALETTE_ITEMS: PaletteItem[] = [
},
{
id: "line-item",
category: "master-data",
label: "Leistungsposition",
description: "Beschreibung einer Position",
width: 340,
@@ -305,6 +322,7 @@ const PALETTE_ITEMS: PaletteItem[] = [
},
{
id: "amount",
category: "master-data",
label: "Betrag",
description: "Gesamt- oder Positionsbetrag",
width: 180,
@@ -315,6 +333,7 @@ const PALETTE_ITEMS: PaletteItem[] = [
},
{
id: "iban",
category: "master-data",
label: "IBAN",
description: "Bankverbindung fuer die Zahlung",
width: 320,
@@ -325,6 +344,7 @@ const PALETTE_ITEMS: PaletteItem[] = [
},
{
id: "bic",
category: "master-data",
label: "BIC",
description: "BIC der Bank",
width: 180,
@@ -335,6 +355,7 @@ const PALETTE_ITEMS: PaletteItem[] = [
},
{
id: "footer",
category: "master-data",
label: "Fusszeile",
description: "Hinweis oder Dankeszeile",
width: 360,
@@ -344,9 +365,76 @@ const PALETTE_ITEMS: PaletteItem[] = [
defaultContent: () => "Vielen Dank fuer Ihren Auftrag.",
},
{
id: "horizontal-line",
label: "Horizontale Linie",
description: "Trennlinie ueber die Seitenbreite",
id: "recipient-company",
category: "customer-data",
label: "Empfaengerfirma",
description: "Firma des Rechnungsempfaengers",
width: 280,
fontSize: 16,
fontWeight: 600,
textAlign: "left",
defaultContent: () => "Firma Rechnungsempfaenger",
},
{
id: "recipient-name",
category: "customer-data",
label: "Empfaengername",
description: "Name des Rechnungsempfaengers",
width: 240,
fontSize: 16,
fontWeight: 500,
textAlign: "left",
defaultContent: () => "Name Rechnungsempfaenger",
},
{
id: "recipient-street",
category: "customer-data",
label: "Empfaengerstrasse",
description: "Strasse des Rechnungsempfaengers",
width: 220,
fontSize: 16,
fontWeight: 400,
textAlign: "left",
defaultContent: () => "Strasse Rechnungsempfaenger",
},
{
id: "recipient-house-number",
category: "customer-data",
label: "Empfaenger Hausnummer",
description: "Hausnummer des Rechnungsempfaengers",
width: 120,
fontSize: 16,
fontWeight: 400,
textAlign: "left",
defaultContent: () => "Hausnummer",
},
{
id: "recipient-postal-code",
category: "customer-data",
label: "Empfaenger PLZ",
description: "Postleitzahl des Rechnungsempfaengers",
width: 100,
fontSize: 16,
fontWeight: 400,
textAlign: "left",
defaultContent: () => "PLZ",
},
{
id: "recipient-city",
category: "customer-data",
label: "Empfaenger Ort",
description: "Ort des Rechnungsempfaengers",
width: 180,
fontSize: 16,
fontWeight: 400,
textAlign: "left",
defaultContent: () => "Ort Rechnungsempfaenger",
},
{
id: "line",
category: "free-elements",
label: "Linie",
description: "Linie mit umschaltbarer Ausrichtung",
kind: "line",
width: 260,
height: 3,
@@ -356,21 +444,9 @@ const PALETTE_ITEMS: PaletteItem[] = [
lineOrientation: "horizontal",
defaultContent: () => "",
},
{
id: "vertical-line",
label: "Vertikale Linie",
description: "Senkrechte Trennlinie fuer Bereiche",
kind: "line",
width: 3,
height: 180,
fontSize: 16,
fontWeight: 400,
textAlign: "left",
lineOrientation: "vertical",
defaultContent: () => "",
},
{
id: "image",
category: "free-elements",
label: "Bild",
description: "Logo oder Produktbild mit Upload",
kind: "image",
@@ -383,6 +459,12 @@ const PALETTE_ITEMS: PaletteItem[] = [
},
];
const PALETTE_GROUPS: Array<{ category: PaletteCategory; title: string }> = [
{ category: "master-data", title: "Stammdaten" },
{ category: "customer-data", title: "Kundendaten" },
{ category: "free-elements", title: "Freie Elemente" },
];
function clamp(value: number, minimum: number, maximum: number) {
return Math.min(Math.max(value, minimum), maximum);
}
@@ -850,7 +932,7 @@ function getPalettePreviewText(paletteItem: PaletteItem, user: UserOption | null
return "Bild auswaehlen";
}
if (paletteItem.kind === "line") {
return paletteItem.lineOrientation === "vertical" ? "Vertikale Linie" : "Horizontale Linie";
return "Linie";
}
return paletteItem.defaultContent(user);
}
@@ -1068,6 +1150,7 @@ export default function InvoiceTemplatePage() {
const [templateUpdatedAt, setTemplateUpdatedAt] = useState<string | null>(null);
const [isTemplateLoading, setIsTemplateLoading] = useState(false);
const [isTemplateSaving, setIsTemplateSaving] = useState(false);
const [isTemplateApiAvailable, setIsTemplateApiAvailable] = useState(true);
const [pdfPreviewUrl, setPdfPreviewUrl] = useState<string | null>(null);
const [pageViewportHeight, setPageViewportHeight] = useState<number | null>(null);
const [canvasViewport, setCanvasViewport] = useState<CanvasViewport>({
@@ -1100,6 +1183,7 @@ export default function InvoiceTemplatePage() {
setElements(starterLayout);
setSelectedElementId(starterLayout[0]?.id ?? null);
setResizingElementId(null);
setIsTemplateApiAvailable(true);
setIsTemplateLoading(false);
setTemplateUpdatedAt(null);
setTemplateError(null);
@@ -1116,6 +1200,7 @@ export default function InvoiceTemplatePage() {
return;
}
setIsTemplateApiAvailable(true);
setTemplateUpdatedAt(response.updatedAt);
if (!response.stored) {
const starterLayout = createStarterLayout(user);
@@ -1136,6 +1221,16 @@ export default function InvoiceTemplatePage() {
})
.catch((error) => {
if (!cancelled) {
if (error instanceof ApiError && error.status === 404) {
const starterLayout = createStarterLayout(user);
setElements(starterLayout);
setSelectedElementId(starterLayout[0]?.id ?? null);
setResizingElementId(null);
setTemplateUpdatedAt(null);
setTemplateError(null);
setIsTemplateApiAvailable(false);
return;
}
setTemplateError((error as Error).message);
}
})
@@ -1225,10 +1320,13 @@ export default function InvoiceTemplatePage() {
Math.floor(cardRect.bottom - shellRect.top - cardPaddingBottom - verticalPadding - CANVAS_BOTTOM_GAP),
240,
);
const rawScale = Math.min(1, availableWidth / CANVAS_WIDTH, availableHeight / CANVAS_HEIGHT);
const nextScale = Math.floor(rawScale * 10000) / 10000;
const nextWidth = Math.floor(CANVAS_WIDTH * nextScale);
const nextHeight = Math.ceil(CANVAS_HEIGHT * nextScale);
const nextWidth = Math.min(
CANVAS_WIDTH,
availableWidth,
Math.floor(availableHeight * CANVAS_ASPECT_RATIO),
);
const nextHeight = Math.floor(nextWidth / CANVAS_ASPECT_RATIO);
const nextScale = nextWidth / CANVAS_WIDTH;
setCanvasViewport((current) => {
if (
@@ -1250,12 +1348,32 @@ export default function InvoiceTemplatePage() {
});
}
const resizeObserver =
typeof ResizeObserver === "undefined"
? null
: new ResizeObserver(() => {
updateCanvasViewport();
});
if (resizeObserver) {
if (pageRef.current) {
resizeObserver.observe(pageRef.current);
}
if (cardRef.current) {
resizeObserver.observe(cardRef.current);
}
if (canvasShellRef.current) {
resizeObserver.observe(canvasShellRef.current);
}
}
updateCanvasViewport();
window.addEventListener("resize", updateCanvasViewport);
window.addEventListener("scroll", updateCanvasViewport, { passive: true });
return () => {
cancelAnimationFrame(frameId);
resizeObserver?.disconnect();
window.removeEventListener("resize", updateCanvasViewport);
window.removeEventListener("scroll", updateCanvasViewport);
};
@@ -1370,6 +1488,10 @@ export default function InvoiceTemplatePage() {
}
function handleClearCanvas() {
if (!window.confirm("Moechten Sie wirklich alle Elemente vom Canvas entfernen?")) {
return;
}
mouseDragCleanupRef.current?.();
setElements([]);
setSelectedElementId(null);
@@ -1392,6 +1514,10 @@ export default function InvoiceTemplatePage() {
setTemplateError("Vorlagen koennen nur fuer angemeldete Benutzer gespeichert werden.");
return;
}
if (!isTemplateApiAvailable) {
setTemplateError("Template-Speicherung ist auf diesem Server nicht verfuegbar.");
return;
}
setTemplateError(null);
setIsTemplateSaving(true);
@@ -1411,8 +1537,14 @@ export default function InvoiceTemplatePage() {
);
setResizingElementId(null);
setTemplateUpdatedAt(response.updatedAt);
setIsTemplateApiAvailable(true);
} catch (error) {
setTemplateError((error as Error).message);
if (error instanceof ApiError && error.status === 404) {
setIsTemplateApiAvailable(false);
setTemplateError("Template-Speicherung ist auf diesem Server nicht verfuegbar.");
} else {
setTemplateError((error as Error).message);
}
} finally {
setIsTemplateSaving(false);
}
@@ -1517,16 +1649,59 @@ export default function InvoiceTemplatePage() {
const startWidth = currentElement.width;
const startHeight = getElementHeight(currentElement);
const elementKind = currentElement.kind;
const elementFontSize = currentElement.fontSize;
const elementLineOrientation = currentElement.lineOrientation;
const imageNaturalWidth = currentElement.imageNaturalWidth;
const imageNaturalHeight = currentElement.imageNaturalHeight;
const startClientX = event.clientX;
const startClientY = event.clientY;
function handleMouseMove(moveEvent: MouseEvent) {
const deltaX = (moveEvent.clientX - startClientX) / canvasViewport.scale;
const deltaY = (moveEvent.clientY - startClientY) / canvasViewport.scale;
const nextWidth = Math.max(
getMinimumWidthForElement(elementKind, elementLineOrientation),
snapToGrid(startWidth + deltaX),
);
const nextHeight = Math.max(
getMinimumHeightForElement(
elementKind,
elementFontSize,
elementLineOrientation,
),
snapToGrid(startHeight + deltaY),
);
if (elementKind === "image") {
const assetWidth = imageNaturalWidth ?? startWidth;
const assetHeight = imageNaturalHeight ?? startHeight;
const widthScale = nextWidth / assetWidth;
const heightScale = nextHeight / assetHeight;
if (widthScale <= heightScale) {
updateElement(elementId, {
height: Math.max(
getMinimumHeightForElement(elementKind, elementFontSize),
Math.floor(((nextWidth * assetHeight) / assetWidth) / GRID_SIZE) * GRID_SIZE,
),
width: nextWidth,
});
return;
}
updateElement(elementId, {
height: nextHeight,
width: Math.max(
getMinimumWidthForElement(elementKind),
Math.floor(((nextHeight * assetWidth) / assetHeight) / GRID_SIZE) * GRID_SIZE,
),
});
return;
}
updateElement(elementId, {
width: startWidth + deltaX,
...(elementKind === "text" ? {} : { height: startHeight + deltaY }),
width: nextWidth,
...(elementKind === "text" ? {} : { height: nextHeight }),
});
}
@@ -1608,7 +1783,7 @@ export default function InvoiceTemplatePage() {
onClick={() => {
void handleSaveTemplate();
}}
disabled={isTemplateLoading || isTemplateSaving}
disabled={isTemplateLoading || isTemplateSaving || !isTemplateApiAvailable}
>
{isTemplateSaving ? "Speichert..." : "Template speichern"}
</button>
@@ -1633,23 +1808,35 @@ export default function InvoiceTemplatePage() {
</div>
<div className="invoice-template__palette">
{PALETTE_ITEMS.map((item) => (
<button
key={item.id}
type="button"
draggable
className="invoice-template__tile"
onDragStart={(event) => {
event.dataTransfer.effectAllowed = "copy";
event.dataTransfer.setData(
DRAG_DATA_TYPE,
JSON.stringify({ kind: "palette", paletteId: item.id } satisfies DragPayload),
);
}}
onDragEnd={resetCanvasDragState}
>
<span>{getPalettePreviewText(item, user)}</span>
</button>
{PALETTE_GROUPS.map((group) => (
<section key={group.category} className="invoice-template__palette-group">
<div className="invoice-template__palette-group-header">
<h5>{group.title}</h5>
</div>
<div className="invoice-template__palette-grid">
{PALETTE_ITEMS.filter((item) => item.category === group.category).map((item) => (
<button
key={item.id}
type="button"
draggable
className="invoice-template__tile"
onDragStart={(event) => {
event.dataTransfer.effectAllowed = "copy";
event.dataTransfer.setData(
DRAG_DATA_TYPE,
JSON.stringify(
{ kind: "palette", paletteId: item.id } satisfies DragPayload,
),
);
}}
onDragEnd={resetCanvasDragState}
>
<span>{getPalettePreviewText(item, user)}</span>
</button>
))}
</div>
</section>
))}
</div>
</aside>
@@ -1661,6 +1848,8 @@ export default function InvoiceTemplatePage() {
<span className="invoice-template__panel-note">
{isTemplateLoading
? "Gespeicherte Vorlage wird geladen..."
: !isTemplateApiAvailable
? "Template-API auf diesem Server nicht verfuegbar"
: templateTimestampLabel
? `In Datenbank gespeichert: ${templateTimestampLabel}`
: "Noch keine gespeicherte Vorlage vorhanden"}

View File

@@ -810,8 +810,7 @@ a {
.invoice-template__palette {
display: grid;
gap: 10px;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 18px;
min-height: 0;
overflow-y: auto;
padding-right: 6px;
@@ -819,6 +818,25 @@ a {
scrollbar-gutter: stable;
}
.invoice-template__palette-group {
display: grid;
gap: 10px;
}
.invoice-template__palette-group-header h5 {
margin: 0;
color: var(--muted);
font-size: 0.8rem;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.invoice-template__palette-grid {
display: grid;
gap: 10px;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.invoice-template__tile {
display: grid;
gap: 6px;
@@ -880,7 +898,7 @@ a {
min-width: 794px;
height: 1123px;
border: 1px dashed rgba(17, 109, 99, 0.22);
border-radius: 32px;
border-radius: 0;
background:
linear-gradient(180deg, rgba(17, 109, 99, 0.03), transparent 18%),
linear-gradient(transparent 31px, rgba(37, 49, 58, 0.05) 32px),
@@ -902,7 +920,7 @@ a {
position: absolute;
inset: 28px;
border: 1px solid rgba(37, 49, 58, 0.05);
border-radius: 20px;
border-radius: 0;
pointer-events: none;
}
@@ -942,6 +960,10 @@ a {
background: rgba(17, 109, 99, 0.08);
}
.invoice-template__canvas-element.is-selected {
border-radius: 0;
}
.invoice-template__canvas-element.is-dragging {
cursor: grabbing;
z-index: 2;
@@ -1141,7 +1163,7 @@ a {
padding-right: 0;
}
.invoice-template__palette {
.invoice-template__palette-grid {
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
}