Refine invoice template editor interactions
This commit is contained in:
@@ -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");
|
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 = {
|
type ApiErrorPayload = {
|
||||||
message?: string;
|
message?: string;
|
||||||
error?: string;
|
error?: string;
|
||||||
@@ -44,7 +54,7 @@ function authHeaders(): Record<string, string> {
|
|||||||
|
|
||||||
async function handleResponse<T>(response: Response): Promise<T> {
|
async function handleResponse<T>(response: Response): Promise<T> {
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(await readErrorMessage(response));
|
throw new ApiError(await readErrorMessage(response), response.status);
|
||||||
}
|
}
|
||||||
if (response.status === 204) {
|
if (response.status === 204) {
|
||||||
return undefined as T;
|
return undefined as T;
|
||||||
|
|||||||
@@ -7,12 +7,13 @@ import {
|
|||||||
type KeyboardEvent as ReactKeyboardEvent,
|
type KeyboardEvent as ReactKeyboardEvent,
|
||||||
type MouseEvent as ReactMouseEvent,
|
type MouseEvent as ReactMouseEvent,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { apiGet, apiPut } from "../lib/api";
|
import { ApiError, apiGet, apiPut } from "../lib/api";
|
||||||
import { useSession } from "../lib/session";
|
import { useSession } from "../lib/session";
|
||||||
import type { UserOption } from "../lib/types";
|
import type { UserOption } from "../lib/types";
|
||||||
|
|
||||||
const CANVAS_WIDTH = 794;
|
const CANVAS_WIDTH = 794;
|
||||||
const CANVAS_HEIGHT = 1123;
|
const CANVAS_HEIGHT = 1123;
|
||||||
|
const CANVAS_ASPECT_RATIO = CANVAS_WIDTH / CANVAS_HEIGHT;
|
||||||
const GRID_SIZE = 5;
|
const GRID_SIZE = 5;
|
||||||
const DRAG_DATA_TYPE = "application/x-muh-invoice-template";
|
const DRAG_DATA_TYPE = "application/x-muh-invoice-template";
|
||||||
const PDF_PAGE_WIDTH = 595.28;
|
const PDF_PAGE_WIDTH = 595.28;
|
||||||
@@ -25,7 +26,7 @@ const CANVAS_BOTTOM_GAP = 10;
|
|||||||
const MIN_ELEMENT_WIDTH = 100;
|
const MIN_ELEMENT_WIDTH = 100;
|
||||||
const MIN_MEDIA_WIDTH = 40;
|
const MIN_MEDIA_WIDTH = 40;
|
||||||
const MIN_MEDIA_HEIGHT = 40;
|
const MIN_MEDIA_HEIGHT = 40;
|
||||||
const MIN_LINE_THICKNESS = 2;
|
const MIN_LINE_THICKNESS = 1;
|
||||||
const MIN_LINE_LENGTH = 40;
|
const MIN_LINE_LENGTH = 40;
|
||||||
const MIN_FONT_SIZE = 12;
|
const MIN_FONT_SIZE = 12;
|
||||||
const MAX_FONT_SIZE = 44;
|
const MAX_FONT_SIZE = 44;
|
||||||
@@ -54,9 +55,11 @@ type ElementKind = "text" | "line" | "image";
|
|||||||
type LineOrientation = "horizontal" | "vertical";
|
type LineOrientation = "horizontal" | "vertical";
|
||||||
type TextAlign = "left" | "center" | "right";
|
type TextAlign = "left" | "center" | "right";
|
||||||
type FontWeight = 400 | 500 | 600 | 700;
|
type FontWeight = 400 | 500 | 600 | 700;
|
||||||
|
type PaletteCategory = "master-data" | "customer-data" | "free-elements";
|
||||||
|
|
||||||
interface PaletteItem {
|
interface PaletteItem {
|
||||||
id: string;
|
id: string;
|
||||||
|
category: PaletteCategory;
|
||||||
label: string;
|
label: string;
|
||||||
description: string;
|
description: string;
|
||||||
kind?: ElementKind;
|
kind?: ElementKind;
|
||||||
@@ -158,6 +161,7 @@ const CP1252_MAP: Record<number, number> = {
|
|||||||
const PALETTE_ITEMS: PaletteItem[] = [
|
const PALETTE_ITEMS: PaletteItem[] = [
|
||||||
{
|
{
|
||||||
id: "invoice-title",
|
id: "invoice-title",
|
||||||
|
category: "master-data",
|
||||||
label: "Rechnungstitel",
|
label: "Rechnungstitel",
|
||||||
description: "Markante Ueberschrift fuer die Rechnung",
|
description: "Markante Ueberschrift fuer die Rechnung",
|
||||||
width: 240,
|
width: 240,
|
||||||
@@ -168,6 +172,7 @@ const PALETTE_ITEMS: PaletteItem[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "company-name",
|
id: "company-name",
|
||||||
|
category: "master-data",
|
||||||
label: "Firmenname",
|
label: "Firmenname",
|
||||||
description: "Name Ihres Unternehmens",
|
description: "Name Ihres Unternehmens",
|
||||||
width: 280,
|
width: 280,
|
||||||
@@ -178,6 +183,7 @@ const PALETTE_ITEMS: PaletteItem[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "contact-person",
|
id: "contact-person",
|
||||||
|
category: "master-data",
|
||||||
label: "Ansprechpartner",
|
label: "Ansprechpartner",
|
||||||
description: "Name des Ansprechpartners",
|
description: "Name des Ansprechpartners",
|
||||||
width: 240,
|
width: 240,
|
||||||
@@ -188,6 +194,7 @@ const PALETTE_ITEMS: PaletteItem[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "street",
|
id: "street",
|
||||||
|
category: "master-data",
|
||||||
label: "Strasse",
|
label: "Strasse",
|
||||||
description: "Strassenname fuer die Anschrift",
|
description: "Strassenname fuer die Anschrift",
|
||||||
width: 220,
|
width: 220,
|
||||||
@@ -198,6 +205,7 @@ const PALETTE_ITEMS: PaletteItem[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "house-number",
|
id: "house-number",
|
||||||
|
category: "master-data",
|
||||||
label: "Hausnummer",
|
label: "Hausnummer",
|
||||||
description: "Hausnummer der Anschrift",
|
description: "Hausnummer der Anschrift",
|
||||||
width: 100,
|
width: 100,
|
||||||
@@ -208,6 +216,7 @@ const PALETTE_ITEMS: PaletteItem[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "postal-code",
|
id: "postal-code",
|
||||||
|
category: "master-data",
|
||||||
label: "PLZ",
|
label: "PLZ",
|
||||||
description: "Postleitzahl",
|
description: "Postleitzahl",
|
||||||
width: 100,
|
width: 100,
|
||||||
@@ -218,6 +227,7 @@ const PALETTE_ITEMS: PaletteItem[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "city",
|
id: "city",
|
||||||
|
category: "master-data",
|
||||||
label: "Ort",
|
label: "Ort",
|
||||||
description: "Stadt oder Ort",
|
description: "Stadt oder Ort",
|
||||||
width: 180,
|
width: 180,
|
||||||
@@ -228,6 +238,7 @@ const PALETTE_ITEMS: PaletteItem[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "email",
|
id: "email",
|
||||||
|
category: "master-data",
|
||||||
label: "E-Mail",
|
label: "E-Mail",
|
||||||
description: "Kontakt-E-Mail",
|
description: "Kontakt-E-Mail",
|
||||||
width: 260,
|
width: 260,
|
||||||
@@ -238,6 +249,7 @@ const PALETTE_ITEMS: PaletteItem[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "phone",
|
id: "phone",
|
||||||
|
category: "master-data",
|
||||||
label: "Telefon",
|
label: "Telefon",
|
||||||
description: "Telefonnummer fuer Rueckfragen",
|
description: "Telefonnummer fuer Rueckfragen",
|
||||||
width: 220,
|
width: 220,
|
||||||
@@ -248,6 +260,7 @@ const PALETTE_ITEMS: PaletteItem[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "invoice-number",
|
id: "invoice-number",
|
||||||
|
category: "master-data",
|
||||||
label: "Rechnungsnummer",
|
label: "Rechnungsnummer",
|
||||||
description: "Eindeutige Kennung",
|
description: "Eindeutige Kennung",
|
||||||
width: 200,
|
width: 200,
|
||||||
@@ -258,6 +271,7 @@ const PALETTE_ITEMS: PaletteItem[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "invoice-date",
|
id: "invoice-date",
|
||||||
|
category: "master-data",
|
||||||
label: "Rechnungsdatum",
|
label: "Rechnungsdatum",
|
||||||
description: "Ausstellungsdatum der Rechnung",
|
description: "Ausstellungsdatum der Rechnung",
|
||||||
width: 200,
|
width: 200,
|
||||||
@@ -271,6 +285,7 @@ const PALETTE_ITEMS: PaletteItem[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "due-date",
|
id: "due-date",
|
||||||
|
category: "master-data",
|
||||||
label: "Zahlungsziel",
|
label: "Zahlungsziel",
|
||||||
description: "Faelligkeitsdatum",
|
description: "Faelligkeitsdatum",
|
||||||
width: 220,
|
width: 220,
|
||||||
@@ -285,6 +300,7 @@ const PALETTE_ITEMS: PaletteItem[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "customer-number",
|
id: "customer-number",
|
||||||
|
category: "master-data",
|
||||||
label: "Kundennummer",
|
label: "Kundennummer",
|
||||||
description: "Referenz zum Kunden",
|
description: "Referenz zum Kunden",
|
||||||
width: 180,
|
width: 180,
|
||||||
@@ -295,6 +311,7 @@ const PALETTE_ITEMS: PaletteItem[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "line-item",
|
id: "line-item",
|
||||||
|
category: "master-data",
|
||||||
label: "Leistungsposition",
|
label: "Leistungsposition",
|
||||||
description: "Beschreibung einer Position",
|
description: "Beschreibung einer Position",
|
||||||
width: 340,
|
width: 340,
|
||||||
@@ -305,6 +322,7 @@ const PALETTE_ITEMS: PaletteItem[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "amount",
|
id: "amount",
|
||||||
|
category: "master-data",
|
||||||
label: "Betrag",
|
label: "Betrag",
|
||||||
description: "Gesamt- oder Positionsbetrag",
|
description: "Gesamt- oder Positionsbetrag",
|
||||||
width: 180,
|
width: 180,
|
||||||
@@ -315,6 +333,7 @@ const PALETTE_ITEMS: PaletteItem[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "iban",
|
id: "iban",
|
||||||
|
category: "master-data",
|
||||||
label: "IBAN",
|
label: "IBAN",
|
||||||
description: "Bankverbindung fuer die Zahlung",
|
description: "Bankverbindung fuer die Zahlung",
|
||||||
width: 320,
|
width: 320,
|
||||||
@@ -325,6 +344,7 @@ const PALETTE_ITEMS: PaletteItem[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "bic",
|
id: "bic",
|
||||||
|
category: "master-data",
|
||||||
label: "BIC",
|
label: "BIC",
|
||||||
description: "BIC der Bank",
|
description: "BIC der Bank",
|
||||||
width: 180,
|
width: 180,
|
||||||
@@ -335,6 +355,7 @@ const PALETTE_ITEMS: PaletteItem[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "footer",
|
id: "footer",
|
||||||
|
category: "master-data",
|
||||||
label: "Fusszeile",
|
label: "Fusszeile",
|
||||||
description: "Hinweis oder Dankeszeile",
|
description: "Hinweis oder Dankeszeile",
|
||||||
width: 360,
|
width: 360,
|
||||||
@@ -344,9 +365,76 @@ const PALETTE_ITEMS: PaletteItem[] = [
|
|||||||
defaultContent: () => "Vielen Dank fuer Ihren Auftrag.",
|
defaultContent: () => "Vielen Dank fuer Ihren Auftrag.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "horizontal-line",
|
id: "recipient-company",
|
||||||
label: "Horizontale Linie",
|
category: "customer-data",
|
||||||
description: "Trennlinie ueber die Seitenbreite",
|
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",
|
kind: "line",
|
||||||
width: 260,
|
width: 260,
|
||||||
height: 3,
|
height: 3,
|
||||||
@@ -356,21 +444,9 @@ const PALETTE_ITEMS: PaletteItem[] = [
|
|||||||
lineOrientation: "horizontal",
|
lineOrientation: "horizontal",
|
||||||
defaultContent: () => "",
|
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",
|
id: "image",
|
||||||
|
category: "free-elements",
|
||||||
label: "Bild",
|
label: "Bild",
|
||||||
description: "Logo oder Produktbild mit Upload",
|
description: "Logo oder Produktbild mit Upload",
|
||||||
kind: "image",
|
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) {
|
function clamp(value: number, minimum: number, maximum: number) {
|
||||||
return Math.min(Math.max(value, minimum), maximum);
|
return Math.min(Math.max(value, minimum), maximum);
|
||||||
}
|
}
|
||||||
@@ -850,7 +932,7 @@ function getPalettePreviewText(paletteItem: PaletteItem, user: UserOption | null
|
|||||||
return "Bild auswaehlen";
|
return "Bild auswaehlen";
|
||||||
}
|
}
|
||||||
if (paletteItem.kind === "line") {
|
if (paletteItem.kind === "line") {
|
||||||
return paletteItem.lineOrientation === "vertical" ? "Vertikale Linie" : "Horizontale Linie";
|
return "Linie";
|
||||||
}
|
}
|
||||||
return paletteItem.defaultContent(user);
|
return paletteItem.defaultContent(user);
|
||||||
}
|
}
|
||||||
@@ -1068,6 +1150,7 @@ export default function InvoiceTemplatePage() {
|
|||||||
const [templateUpdatedAt, setTemplateUpdatedAt] = useState<string | null>(null);
|
const [templateUpdatedAt, setTemplateUpdatedAt] = useState<string | null>(null);
|
||||||
const [isTemplateLoading, setIsTemplateLoading] = useState(false);
|
const [isTemplateLoading, setIsTemplateLoading] = useState(false);
|
||||||
const [isTemplateSaving, setIsTemplateSaving] = useState(false);
|
const [isTemplateSaving, setIsTemplateSaving] = useState(false);
|
||||||
|
const [isTemplateApiAvailable, setIsTemplateApiAvailable] = useState(true);
|
||||||
const [pdfPreviewUrl, setPdfPreviewUrl] = useState<string | null>(null);
|
const [pdfPreviewUrl, setPdfPreviewUrl] = useState<string | null>(null);
|
||||||
const [pageViewportHeight, setPageViewportHeight] = useState<number | null>(null);
|
const [pageViewportHeight, setPageViewportHeight] = useState<number | null>(null);
|
||||||
const [canvasViewport, setCanvasViewport] = useState<CanvasViewport>({
|
const [canvasViewport, setCanvasViewport] = useState<CanvasViewport>({
|
||||||
@@ -1100,6 +1183,7 @@ export default function InvoiceTemplatePage() {
|
|||||||
setElements(starterLayout);
|
setElements(starterLayout);
|
||||||
setSelectedElementId(starterLayout[0]?.id ?? null);
|
setSelectedElementId(starterLayout[0]?.id ?? null);
|
||||||
setResizingElementId(null);
|
setResizingElementId(null);
|
||||||
|
setIsTemplateApiAvailable(true);
|
||||||
setIsTemplateLoading(false);
|
setIsTemplateLoading(false);
|
||||||
setTemplateUpdatedAt(null);
|
setTemplateUpdatedAt(null);
|
||||||
setTemplateError(null);
|
setTemplateError(null);
|
||||||
@@ -1116,6 +1200,7 @@ export default function InvoiceTemplatePage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setIsTemplateApiAvailable(true);
|
||||||
setTemplateUpdatedAt(response.updatedAt);
|
setTemplateUpdatedAt(response.updatedAt);
|
||||||
if (!response.stored) {
|
if (!response.stored) {
|
||||||
const starterLayout = createStarterLayout(user);
|
const starterLayout = createStarterLayout(user);
|
||||||
@@ -1136,6 +1221,16 @@ export default function InvoiceTemplatePage() {
|
|||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
if (!cancelled) {
|
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);
|
setTemplateError((error as Error).message);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -1225,10 +1320,13 @@ export default function InvoiceTemplatePage() {
|
|||||||
Math.floor(cardRect.bottom - shellRect.top - cardPaddingBottom - verticalPadding - CANVAS_BOTTOM_GAP),
|
Math.floor(cardRect.bottom - shellRect.top - cardPaddingBottom - verticalPadding - CANVAS_BOTTOM_GAP),
|
||||||
240,
|
240,
|
||||||
);
|
);
|
||||||
const rawScale = Math.min(1, availableWidth / CANVAS_WIDTH, availableHeight / CANVAS_HEIGHT);
|
const nextWidth = Math.min(
|
||||||
const nextScale = Math.floor(rawScale * 10000) / 10000;
|
CANVAS_WIDTH,
|
||||||
const nextWidth = Math.floor(CANVAS_WIDTH * nextScale);
|
availableWidth,
|
||||||
const nextHeight = Math.ceil(CANVAS_HEIGHT * nextScale);
|
Math.floor(availableHeight * CANVAS_ASPECT_RATIO),
|
||||||
|
);
|
||||||
|
const nextHeight = Math.floor(nextWidth / CANVAS_ASPECT_RATIO);
|
||||||
|
const nextScale = nextWidth / CANVAS_WIDTH;
|
||||||
|
|
||||||
setCanvasViewport((current) => {
|
setCanvasViewport((current) => {
|
||||||
if (
|
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();
|
updateCanvasViewport();
|
||||||
window.addEventListener("resize", updateCanvasViewport);
|
window.addEventListener("resize", updateCanvasViewport);
|
||||||
window.addEventListener("scroll", updateCanvasViewport, { passive: true });
|
window.addEventListener("scroll", updateCanvasViewport, { passive: true });
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelAnimationFrame(frameId);
|
cancelAnimationFrame(frameId);
|
||||||
|
resizeObserver?.disconnect();
|
||||||
window.removeEventListener("resize", updateCanvasViewport);
|
window.removeEventListener("resize", updateCanvasViewport);
|
||||||
window.removeEventListener("scroll", updateCanvasViewport);
|
window.removeEventListener("scroll", updateCanvasViewport);
|
||||||
};
|
};
|
||||||
@@ -1370,6 +1488,10 @@ export default function InvoiceTemplatePage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleClearCanvas() {
|
function handleClearCanvas() {
|
||||||
|
if (!window.confirm("Moechten Sie wirklich alle Elemente vom Canvas entfernen?")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
mouseDragCleanupRef.current?.();
|
mouseDragCleanupRef.current?.();
|
||||||
setElements([]);
|
setElements([]);
|
||||||
setSelectedElementId(null);
|
setSelectedElementId(null);
|
||||||
@@ -1392,6 +1514,10 @@ export default function InvoiceTemplatePage() {
|
|||||||
setTemplateError("Vorlagen koennen nur fuer angemeldete Benutzer gespeichert werden.");
|
setTemplateError("Vorlagen koennen nur fuer angemeldete Benutzer gespeichert werden.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!isTemplateApiAvailable) {
|
||||||
|
setTemplateError("Template-Speicherung ist auf diesem Server nicht verfuegbar.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setTemplateError(null);
|
setTemplateError(null);
|
||||||
setIsTemplateSaving(true);
|
setIsTemplateSaving(true);
|
||||||
@@ -1411,8 +1537,14 @@ export default function InvoiceTemplatePage() {
|
|||||||
);
|
);
|
||||||
setResizingElementId(null);
|
setResizingElementId(null);
|
||||||
setTemplateUpdatedAt(response.updatedAt);
|
setTemplateUpdatedAt(response.updatedAt);
|
||||||
|
setIsTemplateApiAvailable(true);
|
||||||
} catch (error) {
|
} 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 {
|
} finally {
|
||||||
setIsTemplateSaving(false);
|
setIsTemplateSaving(false);
|
||||||
}
|
}
|
||||||
@@ -1517,16 +1649,59 @@ export default function InvoiceTemplatePage() {
|
|||||||
const startWidth = currentElement.width;
|
const startWidth = currentElement.width;
|
||||||
const startHeight = getElementHeight(currentElement);
|
const startHeight = getElementHeight(currentElement);
|
||||||
const elementKind = currentElement.kind;
|
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 startClientX = event.clientX;
|
||||||
const startClientY = event.clientY;
|
const startClientY = event.clientY;
|
||||||
|
|
||||||
function handleMouseMove(moveEvent: MouseEvent) {
|
function handleMouseMove(moveEvent: MouseEvent) {
|
||||||
const deltaX = (moveEvent.clientX - startClientX) / canvasViewport.scale;
|
const deltaX = (moveEvent.clientX - startClientX) / canvasViewport.scale;
|
||||||
const deltaY = (moveEvent.clientY - startClientY) / 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, {
|
updateElement(elementId, {
|
||||||
width: startWidth + deltaX,
|
width: nextWidth,
|
||||||
...(elementKind === "text" ? {} : { height: startHeight + deltaY }),
|
...(elementKind === "text" ? {} : { height: nextHeight }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1608,7 +1783,7 @@ export default function InvoiceTemplatePage() {
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
void handleSaveTemplate();
|
void handleSaveTemplate();
|
||||||
}}
|
}}
|
||||||
disabled={isTemplateLoading || isTemplateSaving}
|
disabled={isTemplateLoading || isTemplateSaving || !isTemplateApiAvailable}
|
||||||
>
|
>
|
||||||
{isTemplateSaving ? "Speichert..." : "Template speichern"}
|
{isTemplateSaving ? "Speichert..." : "Template speichern"}
|
||||||
</button>
|
</button>
|
||||||
@@ -1633,23 +1808,35 @@ export default function InvoiceTemplatePage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="invoice-template__palette">
|
<div className="invoice-template__palette">
|
||||||
{PALETTE_ITEMS.map((item) => (
|
{PALETTE_GROUPS.map((group) => (
|
||||||
<button
|
<section key={group.category} className="invoice-template__palette-group">
|
||||||
key={item.id}
|
<div className="invoice-template__palette-group-header">
|
||||||
type="button"
|
<h5>{group.title}</h5>
|
||||||
draggable
|
</div>
|
||||||
className="invoice-template__tile"
|
|
||||||
onDragStart={(event) => {
|
<div className="invoice-template__palette-grid">
|
||||||
event.dataTransfer.effectAllowed = "copy";
|
{PALETTE_ITEMS.filter((item) => item.category === group.category).map((item) => (
|
||||||
event.dataTransfer.setData(
|
<button
|
||||||
DRAG_DATA_TYPE,
|
key={item.id}
|
||||||
JSON.stringify({ kind: "palette", paletteId: item.id } satisfies DragPayload),
|
type="button"
|
||||||
);
|
draggable
|
||||||
}}
|
className="invoice-template__tile"
|
||||||
onDragEnd={resetCanvasDragState}
|
onDragStart={(event) => {
|
||||||
>
|
event.dataTransfer.effectAllowed = "copy";
|
||||||
<span>{getPalettePreviewText(item, user)}</span>
|
event.dataTransfer.setData(
|
||||||
</button>
|
DRAG_DATA_TYPE,
|
||||||
|
JSON.stringify(
|
||||||
|
{ kind: "palette", paletteId: item.id } satisfies DragPayload,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
onDragEnd={resetCanvasDragState}
|
||||||
|
>
|
||||||
|
<span>{getPalettePreviewText(item, user)}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
@@ -1661,6 +1848,8 @@ export default function InvoiceTemplatePage() {
|
|||||||
<span className="invoice-template__panel-note">
|
<span className="invoice-template__panel-note">
|
||||||
{isTemplateLoading
|
{isTemplateLoading
|
||||||
? "Gespeicherte Vorlage wird geladen..."
|
? "Gespeicherte Vorlage wird geladen..."
|
||||||
|
: !isTemplateApiAvailable
|
||||||
|
? "Template-API auf diesem Server nicht verfuegbar"
|
||||||
: templateTimestampLabel
|
: templateTimestampLabel
|
||||||
? `In Datenbank gespeichert: ${templateTimestampLabel}`
|
? `In Datenbank gespeichert: ${templateTimestampLabel}`
|
||||||
: "Noch keine gespeicherte Vorlage vorhanden"}
|
: "Noch keine gespeicherte Vorlage vorhanden"}
|
||||||
|
|||||||
@@ -810,8 +810,7 @@ a {
|
|||||||
|
|
||||||
.invoice-template__palette {
|
.invoice-template__palette {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 10px;
|
gap: 18px;
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding-right: 6px;
|
padding-right: 6px;
|
||||||
@@ -819,6 +818,25 @@ a {
|
|||||||
scrollbar-gutter: stable;
|
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 {
|
.invoice-template__tile {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
@@ -880,7 +898,7 @@ a {
|
|||||||
min-width: 794px;
|
min-width: 794px;
|
||||||
height: 1123px;
|
height: 1123px;
|
||||||
border: 1px dashed rgba(17, 109, 99, 0.22);
|
border: 1px dashed rgba(17, 109, 99, 0.22);
|
||||||
border-radius: 32px;
|
border-radius: 0;
|
||||||
background:
|
background:
|
||||||
linear-gradient(180deg, rgba(17, 109, 99, 0.03), transparent 18%),
|
linear-gradient(180deg, rgba(17, 109, 99, 0.03), transparent 18%),
|
||||||
linear-gradient(transparent 31px, rgba(37, 49, 58, 0.05) 32px),
|
linear-gradient(transparent 31px, rgba(37, 49, 58, 0.05) 32px),
|
||||||
@@ -902,7 +920,7 @@ a {
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 28px;
|
inset: 28px;
|
||||||
border: 1px solid rgba(37, 49, 58, 0.05);
|
border: 1px solid rgba(37, 49, 58, 0.05);
|
||||||
border-radius: 20px;
|
border-radius: 0;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -942,6 +960,10 @@ a {
|
|||||||
background: rgba(17, 109, 99, 0.08);
|
background: rgba(17, 109, 99, 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.invoice-template__canvas-element.is-selected {
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.invoice-template__canvas-element.is-dragging {
|
.invoice-template__canvas-element.is-dragging {
|
||||||
cursor: grabbing;
|
cursor: grabbing;
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
@@ -1141,7 +1163,7 @@ a {
|
|||||||
padding-right: 0;
|
padding-right: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.invoice-template__palette {
|
.invoice-template__palette-grid {
|
||||||
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user