From 490be6a89b82b114ba126edbd871686b01d6b4be Mon Sep 17 00:00:00 2001 From: Sven Carstensen Date: Fri, 13 Mar 2026 16:02:46 +0100 Subject: [PATCH] Add persistent invoice template management --- .../muh/domain/InvoiceTemplate.java | 16 + .../muh/domain/InvoiceTemplateElement.java | 21 + .../repository/InvoiceTemplateRepository.java | 7 + .../muh/security/AuthorizationService.java | 8 +- .../BearerTokenAuthenticationFilter.java | 10 +- .../muh/service/CatalogService.java | 2 +- .../muh/service/InvoiceTemplateService.java | 181 +++ .../muh/service/ReportService.java | 5 +- .../muh/service/SampleService.java | 2 - .../muh/web/CatalogController.java | 1 - .../muh/web/PortalController.java | 1 - .../muh/web/SessionController.java | 31 +- frontend/src/lib/storage.ts | 1 - frontend/src/pages/InvoiceTemplatePage.tsx | 1021 ++++++++++++++--- frontend/src/pages/LoginPage.tsx | 21 +- frontend/src/styles/global.css | 72 +- 16 files changed, 1254 insertions(+), 146 deletions(-) create mode 100644 backend/src/main/java/de/svencarstensen/muh/domain/InvoiceTemplate.java create mode 100644 backend/src/main/java/de/svencarstensen/muh/domain/InvoiceTemplateElement.java create mode 100644 backend/src/main/java/de/svencarstensen/muh/repository/InvoiceTemplateRepository.java create mode 100644 backend/src/main/java/de/svencarstensen/muh/service/InvoiceTemplateService.java diff --git a/backend/src/main/java/de/svencarstensen/muh/domain/InvoiceTemplate.java b/backend/src/main/java/de/svencarstensen/muh/domain/InvoiceTemplate.java new file mode 100644 index 0000000..153ae6d --- /dev/null +++ b/backend/src/main/java/de/svencarstensen/muh/domain/InvoiceTemplate.java @@ -0,0 +1,16 @@ +package de.svencarstensen.muh.domain; + +import org.springframework.data.annotation.Id; +import org.springframework.data.mongodb.core.mapping.Document; + +import java.time.LocalDateTime; +import java.util.List; + +@Document("invoiceTemplates") +public record InvoiceTemplate( + @Id String userId, + List elements, + LocalDateTime createdAt, + LocalDateTime updatedAt +) { +} diff --git a/backend/src/main/java/de/svencarstensen/muh/domain/InvoiceTemplateElement.java b/backend/src/main/java/de/svencarstensen/muh/domain/InvoiceTemplateElement.java new file mode 100644 index 0000000..011df63 --- /dev/null +++ b/backend/src/main/java/de/svencarstensen/muh/domain/InvoiceTemplateElement.java @@ -0,0 +1,21 @@ +package de.svencarstensen.muh.domain; + +public record InvoiceTemplateElement( + String id, + String paletteId, + String kind, + String label, + String content, + Integer x, + Integer y, + Integer width, + Integer height, + Integer fontSize, + Integer fontWeight, + String textAlign, + String lineOrientation, + String imageSrc, + Integer imageNaturalWidth, + Integer imageNaturalHeight +) { +} diff --git a/backend/src/main/java/de/svencarstensen/muh/repository/InvoiceTemplateRepository.java b/backend/src/main/java/de/svencarstensen/muh/repository/InvoiceTemplateRepository.java new file mode 100644 index 0000000..d96e7a0 --- /dev/null +++ b/backend/src/main/java/de/svencarstensen/muh/repository/InvoiceTemplateRepository.java @@ -0,0 +1,7 @@ +package de.svencarstensen.muh.repository; + +import de.svencarstensen.muh.domain.InvoiceTemplate; +import org.springframework.data.mongodb.repository.MongoRepository; + +public interface InvoiceTemplateRepository extends MongoRepository { +} diff --git a/backend/src/main/java/de/svencarstensen/muh/security/AuthorizationService.java b/backend/src/main/java/de/svencarstensen/muh/security/AuthorizationService.java index d590be1..a186d7d 100644 --- a/backend/src/main/java/de/svencarstensen/muh/security/AuthorizationService.java +++ b/backend/src/main/java/de/svencarstensen/muh/security/AuthorizationService.java @@ -4,9 +4,12 @@ import de.svencarstensen.muh.domain.AppUser; import de.svencarstensen.muh.domain.UserRole; import de.svencarstensen.muh.repository.AppUserRepository; import org.springframework.http.HttpStatus; +import org.springframework.lang.NonNull; import org.springframework.stereotype.Service; import org.springframework.web.server.ResponseStatusException; +import java.util.Objects; + @Service public class AuthorizationService { @@ -43,10 +46,11 @@ public class AuthorizationService { return user.accountId().trim(); } - private String requireText(String value, String message) { + private @NonNull String requireText(String value, String message) { if (value == null || value.isBlank()) { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, message); } - return value.trim(); + String sanitized = Objects.requireNonNull(value).trim(); + return Objects.requireNonNull(sanitized); } } diff --git a/backend/src/main/java/de/svencarstensen/muh/security/BearerTokenAuthenticationFilter.java b/backend/src/main/java/de/svencarstensen/muh/security/BearerTokenAuthenticationFilter.java index 6d75ca7..17453cc 100644 --- a/backend/src/main/java/de/svencarstensen/muh/security/BearerTokenAuthenticationFilter.java +++ b/backend/src/main/java/de/svencarstensen/muh/security/BearerTokenAuthenticationFilter.java @@ -7,6 +7,7 @@ import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import org.springframework.http.HttpHeaders; +import org.springframework.lang.NonNull; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.context.SecurityContextHolder; @@ -15,6 +16,7 @@ import org.springframework.web.filter.OncePerRequestFilter; import java.io.IOException; import java.util.List; +import java.util.Objects; @Component public class BearerTokenAuthenticationFilter extends OncePerRequestFilter { @@ -28,7 +30,11 @@ public class BearerTokenAuthenticationFilter extends OncePerRequestFilter { } @Override - protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + protected void doFilterInternal( + @NonNull HttpServletRequest request, + @NonNull HttpServletResponse response, + @NonNull FilterChain filterChain + ) throws ServletException, IOException { String authorization = request.getHeader(HttpHeaders.AUTHORIZATION); if (authorization == null || !authorization.startsWith("Bearer ")) { @@ -38,7 +44,7 @@ public class BearerTokenAuthenticationFilter extends OncePerRequestFilter { try { AuthenticatedUser tokenUser = authTokenService.parseToken(authorization.substring(7)); - AppUser user = appUserRepository.findById(tokenUser.id()) + AppUser user = appUserRepository.findById(Objects.requireNonNull(tokenUser.id())) .filter(AppUser::active) .orElseThrow(() -> new IllegalArgumentException("Benutzer ungueltig")); diff --git a/backend/src/main/java/de/svencarstensen/muh/service/CatalogService.java b/backend/src/main/java/de/svencarstensen/muh/service/CatalogService.java index c7ab3cb..243bdff 100644 --- a/backend/src/main/java/de/svencarstensen/muh/service/CatalogService.java +++ b/backend/src/main/java/de/svencarstensen/muh/service/CatalogService.java @@ -485,7 +485,7 @@ public class CatalogService { if (isPrimaryUser(existing) && actor.role() != UserRole.ADMIN) { throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Der Hauptbenutzer kann nicht geloescht werden"); } - appUserRepository.deleteById(existing.id()); + appUserRepository.deleteById(requireText(existing.id(), "Benutzer-ID fehlt")); } public void changePassword(String actorId, String id, String newPassword) { diff --git a/backend/src/main/java/de/svencarstensen/muh/service/InvoiceTemplateService.java b/backend/src/main/java/de/svencarstensen/muh/service/InvoiceTemplateService.java new file mode 100644 index 0000000..51bdd7a --- /dev/null +++ b/backend/src/main/java/de/svencarstensen/muh/service/InvoiceTemplateService.java @@ -0,0 +1,181 @@ +package de.svencarstensen.muh.service; + +import de.svencarstensen.muh.domain.AppUser; +import de.svencarstensen.muh.domain.InvoiceTemplate; +import de.svencarstensen.muh.domain.InvoiceTemplateElement; +import de.svencarstensen.muh.repository.AppUserRepository; +import de.svencarstensen.muh.repository.InvoiceTemplateRepository; +import org.springframework.http.HttpStatus; +import org.springframework.lang.NonNull; +import org.springframework.stereotype.Service; +import org.springframework.web.server.ResponseStatusException; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Objects; + +@Service +public class InvoiceTemplateService { + + private final AppUserRepository appUserRepository; + private final InvoiceTemplateRepository invoiceTemplateRepository; + + public InvoiceTemplateService( + AppUserRepository appUserRepository, + InvoiceTemplateRepository invoiceTemplateRepository + ) { + this.appUserRepository = appUserRepository; + this.invoiceTemplateRepository = invoiceTemplateRepository; + } + + public InvoiceTemplateResponse currentTemplate(String actorId) { + String userId = requireActorId(actorId); + requireActiveUser(userId); + return invoiceTemplateRepository.findById(userId) + .map(this::toResponse) + .orElseGet(() -> new InvoiceTemplateResponse(false, List.of(), null)); + } + + public InvoiceTemplateResponse saveTemplate(String actorId, List payloadElements) { + String userId = requireActorId(actorId); + requireActiveUser(userId); + InvoiceTemplate existing = invoiceTemplateRepository.findById(userId).orElse(null); + LocalDateTime now = LocalDateTime.now(); + + InvoiceTemplate saved = invoiceTemplateRepository.save(new InvoiceTemplate( + userId, + sanitizeElements(payloadElements), + existing != null ? existing.createdAt() : now, + now + )); + return toResponse(saved); + } + + private AppUser requireActiveUser(@NonNull String actorId) { + return appUserRepository.findById(actorId) + .filter(AppUser::active) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.FORBIDDEN, "Nicht berechtigt")); + } + + private @NonNull String requireActorId(String actorId) { + if (isBlank(actorId)) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Nicht berechtigt"); + } + String sanitized = Objects.requireNonNull(actorId).trim(); + return Objects.requireNonNull(sanitized); + } + + private List sanitizeElements(List payloadElements) { + if (payloadElements == null || payloadElements.isEmpty()) { + return List.of(); + } + + return payloadElements.stream() + .map(this::sanitizeElement) + .filter(Objects::nonNull) + .toList(); + } + + private InvoiceTemplateElement sanitizeElement(InvoiceTemplateElementPayload payload) { + if (payload == null + || isBlank(payload.id()) + || isBlank(payload.paletteId()) + || isBlank(payload.kind()) + || payload.x() == null + || payload.y() == null + || payload.width() == null + || payload.fontSize() == null) { + return null; + } + + return new InvoiceTemplateElement( + payload.id().trim(), + payload.paletteId().trim(), + payload.kind().trim(), + trimOrEmpty(payload.label()), + nullToEmpty(payload.content()), + payload.x(), + payload.y(), + payload.width(), + payload.height(), + payload.fontSize(), + payload.fontWeight(), + blankToNull(payload.textAlign()), + blankToNull(payload.lineOrientation()), + blankToNull(payload.imageSrc()), + payload.imageNaturalWidth(), + payload.imageNaturalHeight() + ); + } + + private InvoiceTemplateResponse toResponse(InvoiceTemplate template) { + List elements = template.elements() == null + ? List.of() + : template.elements().stream().map(this::toPayload).toList(); + return new InvoiceTemplateResponse(true, elements, template.updatedAt()); + } + + private InvoiceTemplateElementPayload toPayload(InvoiceTemplateElement element) { + return new InvoiceTemplateElementPayload( + element.id(), + element.paletteId(), + element.kind(), + element.label(), + element.content(), + element.x(), + element.y(), + element.width(), + element.height(), + element.fontSize(), + element.fontWeight(), + element.textAlign(), + element.lineOrientation(), + element.imageSrc(), + element.imageNaturalWidth(), + element.imageNaturalHeight() + ); + } + + private boolean isBlank(String value) { + return value == null || value.isBlank(); + } + + private String blankToNull(String value) { + return isBlank(value) ? null : value.trim(); + } + + private String trimOrEmpty(String value) { + return value == null ? "" : value.trim(); + } + + private String nullToEmpty(String value) { + return value == null ? "" : value; + } + + public record InvoiceTemplateElementPayload( + String id, + String paletteId, + String kind, + String label, + String content, + Integer x, + Integer y, + Integer width, + Integer height, + Integer fontSize, + Integer fontWeight, + String textAlign, + String lineOrientation, + String imageSrc, + Integer imageNaturalWidth, + Integer imageNaturalHeight + ) { + } + + public record InvoiceTemplateResponse( + boolean stored, + List elements, + LocalDateTime updatedAt + ) { + } +} diff --git a/backend/src/main/java/de/svencarstensen/muh/service/ReportService.java b/backend/src/main/java/de/svencarstensen/muh/service/ReportService.java index 61277fc..811bf9c 100644 --- a/backend/src/main/java/de/svencarstensen/muh/service/ReportService.java +++ b/backend/src/main/java/de/svencarstensen/muh/service/ReportService.java @@ -102,7 +102,7 @@ public class ReportService { helper.setFrom(requireText(mailFrom, "Absender fehlt")); helper.setTo(requireText(sample.farmerEmail(), "Empfänger fehlt")); helper.setSubject("MUH-Bericht Probe " + sample.sampleNumber()); - helper.setText(buildMailBody(sample, customerSignature), false); + helper.setText(Objects.requireNonNull(buildMailBody(sample, customerSignature)), false); helper.addAttachment("MUH-Bericht-" + sample.sampleNumber() + ".pdf", new ByteArrayResource(Objects.requireNonNull(pdf))); sender.send(message); } catch (Exception exception) { @@ -171,7 +171,8 @@ public class ReportService { return defaultCustomerSignature(); } - AppUser actor = appUserRepository.findById(actorId.trim()).orElse(null); + String normalizedActorId = Objects.requireNonNull(actorId).trim(); + AppUser actor = appUserRepository.findById(Objects.requireNonNull(normalizedActorId)).orElse(null); if (actor == null) { return defaultCustomerSignature(); } diff --git a/backend/src/main/java/de/svencarstensen/muh/service/SampleService.java b/backend/src/main/java/de/svencarstensen/muh/service/SampleService.java index a2f7266..54953fd 100644 --- a/backend/src/main/java/de/svencarstensen/muh/service/SampleService.java +++ b/backend/src/main/java/de/svencarstensen/muh/service/SampleService.java @@ -28,8 +28,6 @@ import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Objects; -import java.util.Optional; -import java.util.function.Function; import java.util.stream.Collectors; @Service diff --git a/backend/src/main/java/de/svencarstensen/muh/web/CatalogController.java b/backend/src/main/java/de/svencarstensen/muh/web/CatalogController.java index 1d1555d..6b2251b 100644 --- a/backend/src/main/java/de/svencarstensen/muh/web/CatalogController.java +++ b/backend/src/main/java/de/svencarstensen/muh/web/CatalogController.java @@ -8,7 +8,6 @@ import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RestController; import java.util.List; diff --git a/backend/src/main/java/de/svencarstensen/muh/web/PortalController.java b/backend/src/main/java/de/svencarstensen/muh/web/PortalController.java index da29ced..6a98bc8 100644 --- a/backend/src/main/java/de/svencarstensen/muh/web/PortalController.java +++ b/backend/src/main/java/de/svencarstensen/muh/web/PortalController.java @@ -14,7 +14,6 @@ import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; diff --git a/backend/src/main/java/de/svencarstensen/muh/web/SessionController.java b/backend/src/main/java/de/svencarstensen/muh/web/SessionController.java index 90aea77..d247a60 100644 --- a/backend/src/main/java/de/svencarstensen/muh/web/SessionController.java +++ b/backend/src/main/java/de/svencarstensen/muh/web/SessionController.java @@ -1,24 +1,33 @@ package de.svencarstensen.muh.web; import de.svencarstensen.muh.service.CatalogService; +import de.svencarstensen.muh.service.InvoiceTemplateService; import de.svencarstensen.muh.security.SecuritySupport; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import jakarta.validation.constraints.NotBlank; +import java.util.List; @RestController @RequestMapping("/api/session") public class SessionController { private final CatalogService catalogService; + private final InvoiceTemplateService invoiceTemplateService; private final SecuritySupport securitySupport; - public SessionController(CatalogService catalogService, SecuritySupport securitySupport) { + public SessionController( + CatalogService catalogService, + InvoiceTemplateService invoiceTemplateService, + SecuritySupport securitySupport + ) { this.catalogService = catalogService; + this.invoiceTemplateService = invoiceTemplateService; this.securitySupport = securitySupport; } @@ -27,6 +36,11 @@ public class SessionController { return catalogService.currentUser(securitySupport.currentUser().id()); } + @GetMapping("/invoice-template") + public InvoiceTemplateService.InvoiceTemplateResponse currentInvoiceTemplate() { + return invoiceTemplateService.currentTemplate(securitySupport.currentUser().id()); + } + @PostMapping("/password-login") public CatalogService.SessionResponse passwordLogin(@RequestBody PasswordLoginRequest request) { return catalogService.loginWithPassword(request.email(), request.password()); @@ -46,6 +60,16 @@ public class SessionController { )); } + @PutMapping("/invoice-template") + public InvoiceTemplateService.InvoiceTemplateResponse saveInvoiceTemplate( + @RequestBody InvoiceTemplateRequest request + ) { + return invoiceTemplateService.saveTemplate( + securitySupport.currentUser().id(), + request.elements() + ); + } + public record PasswordLoginRequest(@NotBlank String email, @NotBlank String password) { } @@ -60,4 +84,9 @@ public class SessionController { @NotBlank String password ) { } + + public record InvoiceTemplateRequest( + List elements + ) { + } } diff --git a/frontend/src/lib/storage.ts b/frontend/src/lib/storage.ts index 3557239..1b0036b 100644 --- a/frontend/src/lib/storage.ts +++ b/frontend/src/lib/storage.ts @@ -1,3 +1,2 @@ export const USER_STORAGE_KEY = "muh.current-user"; export const AUTH_TOKEN_STORAGE_KEY = "muh.auth-token"; -export const INVOICE_TEMPLATE_STORAGE_KEY = "muh.invoice-template"; diff --git a/frontend/src/pages/InvoiceTemplatePage.tsx b/frontend/src/pages/InvoiceTemplatePage.tsx index dfcdb5b..d515896 100644 --- a/frontend/src/pages/InvoiceTemplatePage.tsx +++ b/frontend/src/pages/InvoiceTemplatePage.tsx @@ -1,13 +1,14 @@ import { useEffect, - useRef, useState, + useRef, + type ChangeEvent as ReactChangeEvent, type DragEvent, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent, } from "react"; +import { apiGet, apiPut } from "../lib/api"; import { useSession } from "../lib/session"; -import { INVOICE_TEMPLATE_STORAGE_KEY } from "../lib/storage"; import type { UserOption } from "../lib/types"; const CANVAS_WIDTH = 794; @@ -20,8 +21,37 @@ const PDF_SCALE = PDF_PAGE_WIDTH / CANVAS_WIDTH; const PDF_TEXT_PADDING_X = 12; const PDF_TEXT_PADDING_Y = 10; const PDF_LINE_HEIGHT = 1.35; -const CANVAS_BOTTOM_GAP = 8; +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_LENGTH = 40; +const MIN_FONT_SIZE = 12; +const MAX_FONT_SIZE = 44; +const LOCKED_TEXT_PALETTE_IDS = new Set([ + "invoice-title", + "company-name", + "contact-person", + "street", + "house-number", + "postal-code", + "city", + "email", + "phone", + "invoice-number", + "invoice-date", + "due-date", + "customer-number", + "line-item", + "amount", + "iban", + "bic", + "footer", +]); +type ElementKind = "text" | "line" | "image"; +type LineOrientation = "horizontal" | "vertical"; type TextAlign = "left" | "center" | "right"; type FontWeight = 400 | 500 | 600 | 700; @@ -29,24 +59,33 @@ interface PaletteItem { id: string; label: string; description: string; + kind?: ElementKind; width: number; + height?: number; fontSize: number; fontWeight: FontWeight; textAlign: TextAlign; + lineOrientation?: LineOrientation; defaultContent: (user: UserOption | null) => string; } interface TemplateElement { id: string; paletteId: string; + kind: ElementKind; label: string; content: string; x: number; y: number; width: number; + height?: number; fontSize: number; fontWeight: FontWeight; textAlign: TextAlign; + lineOrientation?: LineOrientation; + imageSrc?: string | null; + imageNaturalWidth?: number | null; + imageNaturalHeight?: number | null; } interface CanvasViewport { @@ -56,6 +95,26 @@ interface CanvasViewport { width: number; } +interface PdfImageResource { + body: string; + elementId: string; + name: string; + pixelHeight: number; + pixelWidth: number; +} + +interface UploadedImageAsset { + dataUrl: string; + height: number; + width: number; +} + +interface InvoiceTemplateResponse { + elements: unknown; + stored: boolean; + updatedAt: string | null; +} + type DragPayload = | { kind: "palette"; @@ -284,6 +343,44 @@ const PALETTE_ITEMS: PaletteItem[] = [ textAlign: "left", defaultContent: () => "Vielen Dank fuer Ihren Auftrag.", }, + { + id: "horizontal-line", + label: "Horizontale Linie", + description: "Trennlinie ueber die Seitenbreite", + kind: "line", + width: 260, + height: 3, + fontSize: 16, + fontWeight: 400, + textAlign: "left", + 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", + label: "Bild", + description: "Logo oder Produktbild mit Upload", + kind: "image", + width: 180, + height: 120, + fontSize: 16, + fontWeight: 400, + textAlign: "left", + defaultContent: () => "Bild", + }, ]; function clamp(value: number, minimum: number, maximum: number) { @@ -395,10 +492,187 @@ function encodePdfText(text: string) { return bytes.map((value) => value.toString(16).padStart(2, "0")).join("").toUpperCase(); } -function createPdfContentStream(elements: TemplateElement[]) { +function getDefaultElementHeight( + kind: ElementKind, + fontSize: number, + lineOrientation?: LineOrientation, +) { + if (kind === "line") { + return lineOrientation === "vertical" ? 180 : 3; + } + if (kind === "image") { + return 120; + } + return Math.max(fontSize + 16, 28); +} + +function getMinimumWidthForElement(kind: ElementKind, lineOrientation?: LineOrientation) { + if (kind === "line") { + return lineOrientation === "vertical" ? MIN_LINE_THICKNESS : MIN_LINE_LENGTH; + } + if (kind === "image") { + return MIN_MEDIA_WIDTH; + } + return MIN_ELEMENT_WIDTH; +} + +function getMinimumHeightForElement( + kind: ElementKind, + fontSize: number, + lineOrientation?: LineOrientation, +) { + if (kind === "line") { + return lineOrientation === "vertical" ? MIN_LINE_LENGTH : MIN_LINE_THICKNESS; + } + if (kind === "image") { + return MIN_MEDIA_HEIGHT; + } + return getDefaultElementHeight(kind, fontSize, lineOrientation); +} + +function getElementHeight( + element: Pick, +) { + return Math.max( + 1, + Math.round( + element.height ?? getDefaultElementHeight(element.kind, element.fontSize, element.lineOrientation), + ), + ); +} + +function fitContainedSize( + containerWidth: number, + containerHeight: number, + assetWidth: number, + assetHeight: number, +) { + if (assetWidth <= 0 || assetHeight <= 0) { + return { + height: containerHeight, + offsetX: 0, + offsetY: 0, + width: containerWidth, + }; + } + + const scale = Math.min(containerWidth / assetWidth, containerHeight / assetHeight); + const width = assetWidth * scale; + const height = assetHeight * scale; + + return { + height, + offsetX: (containerWidth - width) / 2, + offsetY: (containerHeight - height) / 2, + width, + }; +} + +function decodeBase64DataUrl(dataUrl: string) { + const match = dataUrl.match(/^data:([^;]+);base64,(.+)$/); + if (!match) { + return null; + } + + const [, mimeType, base64Payload] = match; + const binary = atob(base64Payload); + const bytes = new Uint8Array(binary.length); + + for (let index = 0; index < binary.length; index += 1) { + bytes[index] = binary.charCodeAt(index); + } + + return { + bytes, + mimeType, + }; +} + +function bytesToHex(bytes: Uint8Array) { + let hex = ""; + for (const value of bytes) { + hex += value.toString(16).padStart(2, "0").toUpperCase(); + } + return hex; +} + +function createPdfImageResources(elements: TemplateElement[]) { + const imageElements = elements.filter( + (element) => element.kind === "image" && typeof element.imageSrc === "string" && element.imageSrc, + ); + + return imageElements.reduce((resources, element, index) => { + const decoded = decodeBase64DataUrl(element.imageSrc ?? ""); + if (!decoded || !/image\/jpe?g/i.test(decoded.mimeType)) { + return resources; + } + + const hexPayload = `${bytesToHex(decoded.bytes)}>`; + resources.push({ + body: + `<< /Type /XObject /Subtype /Image /Width ${Math.max(1, Math.round(element.imageNaturalWidth ?? element.width))}` + + ` /Height ${Math.max(1, Math.round(element.imageNaturalHeight ?? getElementHeight(element)))}` + + " /ColorSpace /DeviceRGB /BitsPerComponent 8" + + " /Filter [/ASCIIHexDecode /DCTDecode]" + + ` /Length ${hexPayload.length} >>\nstream\n${hexPayload}\nendstream`, + elementId: element.id, + name: `/Im${index + 1}`, + pixelHeight: Math.max(1, Math.round(element.imageNaturalHeight ?? getElementHeight(element))), + pixelWidth: Math.max(1, Math.round(element.imageNaturalWidth ?? element.width)), + }); + + return resources; + }, []); +} + +function createPdfContentStream( + elements: TemplateElement[], + imageResources: Map, +) { const commands = ["0 g"]; for (const element of elements) { + if (element.kind === "line") { + const elementHeight = getElementHeight(element); + const pdfX = element.x * PDF_SCALE; + const pdfY = PDF_PAGE_HEIGHT - (element.y + elementHeight) * PDF_SCALE; + const pdfWidth = element.width * PDF_SCALE; + const pdfHeight = elementHeight * PDF_SCALE; + + commands.push( + `${formatPdfNumber(pdfX)} ${formatPdfNumber(pdfY)} ${formatPdfNumber(pdfWidth)} ${formatPdfNumber(pdfHeight)} re f`, + ); + continue; + } + + if (element.kind === "image") { + const imageResource = imageResources.get(element.id); + if (!imageResource) { + continue; + } + + const boxHeight = getElementHeight(element); + const fitted = fitContainedSize( + element.width, + boxHeight, + imageResource.pixelWidth, + imageResource.pixelHeight, + ); + const pdfX = (element.x + fitted.offsetX) * PDF_SCALE; + const pdfY = + PDF_PAGE_HEIGHT - (element.y + fitted.offsetY + fitted.height) * PDF_SCALE; + const pdfWidth = fitted.width * PDF_SCALE; + const pdfHeight = fitted.height * PDF_SCALE; + + commands.push("q"); + commands.push( + `${formatPdfNumber(pdfWidth)} 0 0 ${formatPdfNumber(pdfHeight)} ${formatPdfNumber(pdfX)} ${formatPdfNumber(pdfY)} cm`, + ); + commands.push(`${imageResource.name} Do`); + commands.push("Q"); + continue; + } + const fontName = element.fontWeight >= 600 ? "/F2" : "/F1"; const fontSize = element.fontSize * PDF_SCALE; const lineHeight = element.fontSize * PDF_LINE_HEIGHT; @@ -440,13 +714,23 @@ function createPdfContentStream(elements: TemplateElement[]) { } function createPdfBlob(elements: TemplateElement[]) { - const contentStream = createPdfContentStream(elements); + const imageResources = createPdfImageResources(elements); + const imageResourceMap = new Map(imageResources.map((resource) => [resource.elementId, resource])); + const contentStream = createPdfContentStream(elements, imageResourceMap); + const imageObjectNumberStart = 6; + const contentObjectNumber = imageObjectNumberStart + imageResources.length; + const xObjectResources = imageResources.length + ? ` /XObject << ${imageResources + .map((resource, index) => `${resource.name} ${imageObjectNumberStart + index} 0 R`) + .join(" ")} >>` + : ""; const objects = [ "<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Count 1 /Kids [3 0 R] >>", - `<< /Type /Page /Parent 2 0 R /MediaBox [0 0 ${formatPdfNumber(PDF_PAGE_WIDTH)} ${formatPdfNumber(PDF_PAGE_HEIGHT)}] /Resources << /Font << /F1 4 0 R /F2 5 0 R >> >> /Contents 6 0 R >>`, + `<< /Type /Page /Parent 2 0 R /MediaBox [0 0 ${formatPdfNumber(PDF_PAGE_WIDTH)} ${formatPdfNumber(PDF_PAGE_HEIGHT)}] /Resources << /Font << /F1 4 0 R /F2 5 0 R >>${xObjectResources} >> /Contents ${contentObjectNumber} 0 R >>`, "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>", "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding >>", + ...imageResources.map((resource) => resource.body), `<< /Length ${contentStream.length} >>\nstream\n${contentStream}\nendstream`, ]; @@ -486,20 +770,66 @@ function normalizeTextAlign(value: unknown): TextAlign { return value === "center" || value === "right" ? value : "left"; } +function normalizeElementKind(value: unknown): ElementKind { + return value === "line" || value === "image" ? value : "text"; +} + +function normalizeLineOrientation(value: unknown): LineOrientation { + return value === "vertical" ? "vertical" : "horizontal"; +} + function normalizeElement(element: TemplateElement): TemplateElement { - const width = clamp(Math.round(element.width), 100, CANVAS_WIDTH - 32); - const fontSize = clamp(Math.round(element.fontSize), 12, 44); + const kind = normalizeElementKind(element.kind); + const lineOrientation = kind === "line" ? normalizeLineOrientation(element.lineOrientation) : undefined; + const fontSize = clamp(Math.round(element.fontSize), MIN_FONT_SIZE, MAX_FONT_SIZE); + let width = Math.round(element.width); + let height = getElementHeight({ + fontSize, + height: element.height, + kind, + lineOrientation, + }); + + if (kind === "line") { + width = clamp(width, getMinimumWidthForElement(kind, lineOrientation), CANVAS_WIDTH); + height = clamp(height, getMinimumHeightForElement(kind, fontSize, lineOrientation), CANVAS_HEIGHT); + } else if (kind === "image") { + width = clamp(width, getMinimumWidthForElement(kind), CANVAS_WIDTH - 32); + height = clamp(height, getMinimumHeightForElement(kind, fontSize), CANVAS_HEIGHT - 32); + } else { + width = clamp(width, getMinimumWidthForElement(kind), CANVAS_WIDTH - 32); + height = getDefaultElementHeight(kind, fontSize, lineOrientation); + } + const x = clamp(snapToGrid(element.x), 0, CANVAS_WIDTH - width); - const y = clamp(snapToGrid(element.y), 0, CANVAS_HEIGHT - 40); + const y = clamp( + snapToGrid(element.y), + 0, + kind === "text" ? CANVAS_HEIGHT - 40 : CANVAS_HEIGHT - height, + ); + const imageNaturalWidth = + kind === "image" && typeof element.imageNaturalWidth === "number" && element.imageNaturalWidth > 0 + ? Math.round(element.imageNaturalWidth) + : null; + const imageNaturalHeight = + kind === "image" && typeof element.imageNaturalHeight === "number" && element.imageNaturalHeight > 0 + ? Math.round(element.imageNaturalHeight) + : null; return { ...element, + kind, width, + height: kind === "text" ? undefined : height, fontSize, x, y, fontWeight: normalizeFontWeight(element.fontWeight), textAlign: normalizeTextAlign(element.textAlign), + lineOrientation, + imageSrc: kind === "image" ? element.imageSrc ?? null : undefined, + imageNaturalWidth, + imageNaturalHeight, }; } @@ -515,6 +845,20 @@ function requirePaletteItem(paletteId: string) { return paletteItem; } +function getPalettePreviewText(paletteItem: PaletteItem, user: UserOption | null) { + if (paletteItem.kind === "image") { + return "Bild auswaehlen"; + } + if (paletteItem.kind === "line") { + return paletteItem.lineOrientation === "vertical" ? "Vertikale Linie" : "Horizontale Linie"; + } + return paletteItem.defaultContent(user); +} + +function isElementContentEditable(element: Pick) { + return element.kind === "text" && !LOCKED_TEXT_PALETTE_IDS.has(element.paletteId); +} + function createElementFromPalette( paletteItem: PaletteItem, user: UserOption | null, @@ -523,14 +867,20 @@ function createElementFromPalette( return normalizeElement({ id: generateId(), paletteId: paletteItem.id, + kind: paletteItem.kind ?? "text", label: paletteItem.label, content: paletteItem.defaultContent(user), x: position.x, y: position.y, width: paletteItem.width, + height: paletteItem.height, fontSize: paletteItem.fontSize, fontWeight: paletteItem.fontWeight, textAlign: paletteItem.textAlign, + lineOrientation: paletteItem.lineOrientation, + imageSrc: null, + imageNaturalWidth: null, + imageNaturalHeight: null, }); } @@ -549,57 +899,133 @@ function createStarterLayout(user: UserOption | null) { ]; } -function loadStoredElements(storageKey: string) { - const raw = window.localStorage.getItem(storageKey); - if (!raw) { +function readFileAsDataUrl(file: File) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onerror = () => reject(new Error("Bild konnte nicht geladen werden.")); + reader.onload = () => { + if (typeof reader.result === "string") { + resolve(reader.result); + return; + } + reject(new Error("Bild konnte nicht geladen werden.")); + }; + reader.readAsDataURL(file); + }); +} + +function loadImageAsset(dataUrl: string) { + return new Promise((resolve, reject) => { + const image = new Image(); + image.onerror = () => reject(new Error("Bild konnte nicht gelesen werden.")); + image.onload = () => + resolve({ + dataUrl, + height: image.naturalHeight, + width: image.naturalWidth, + }); + image.src = dataUrl; + }); +} + +async function convertImageFileToJpeg(file: File) { + const source = await readFileAsDataUrl(file); + const asset = await loadImageAsset(source); + const canvas = document.createElement("canvas"); + canvas.width = asset.width; + canvas.height = asset.height; + + const context = canvas.getContext("2d"); + if (!context) { + throw new Error("Bild konnte nicht verarbeitet werden."); + } + + const image = await loadImageAsset(source); + context.fillStyle = "#ffffff"; + context.fillRect(0, 0, canvas.width, canvas.height); + const renderImage = new Image(); + + await new Promise((resolve, reject) => { + renderImage.onerror = () => reject(new Error("Bild konnte nicht verarbeitet werden.")); + renderImage.onload = () => { + context.drawImage(renderImage, 0, 0, canvas.width, canvas.height); + resolve(); + }; + renderImage.src = image.dataUrl; + }); + + return { + dataUrl: canvas.toDataURL("image/jpeg", 0.92), + height: asset.height, + width: asset.width, + }; +} + +function normalizeTemplateElements(raw: unknown) { + if (!Array.isArray(raw)) { return null; } - try { - const parsed = JSON.parse(raw); - if (!Array.isArray(parsed)) { - return null; - } + const elements = raw + .map((entry) => { + if (!entry || typeof entry !== "object") { + return null; + } - const elements = parsed - .map((entry) => { - if (!entry || typeof entry !== "object") { - return null; - } + const candidate = entry as Partial; + if ( + typeof candidate.id !== "string" || + typeof candidate.paletteId !== "string" || + typeof candidate.label !== "string" || + typeof candidate.content !== "string" || + typeof candidate.x !== "number" || + typeof candidate.y !== "number" || + typeof candidate.width !== "number" || + typeof candidate.fontSize !== "number" + ) { + return null; + } - const candidate = entry as Partial; - if ( - typeof candidate.id !== "string" || - typeof candidate.paletteId !== "string" || - typeof candidate.label !== "string" || - typeof candidate.content !== "string" || - typeof candidate.x !== "number" || - typeof candidate.y !== "number" || - typeof candidate.width !== "number" || - typeof candidate.fontSize !== "number" - ) { - return null; - } + return normalizeElement({ + id: candidate.id, + paletteId: candidate.paletteId, + kind: normalizeElementKind(candidate.kind), + label: candidate.label, + content: candidate.content, + x: candidate.x, + y: candidate.y, + width: candidate.width, + height: typeof candidate.height === "number" ? candidate.height : undefined, + fontSize: candidate.fontSize, + fontWeight: normalizeFontWeight(candidate.fontWeight), + textAlign: normalizeTextAlign(candidate.textAlign), + lineOrientation: normalizeLineOrientation(candidate.lineOrientation), + imageSrc: typeof candidate.imageSrc === "string" ? candidate.imageSrc : null, + imageNaturalWidth: + typeof candidate.imageNaturalWidth === "number" ? candidate.imageNaturalWidth : null, + imageNaturalHeight: + typeof candidate.imageNaturalHeight === "number" ? candidate.imageNaturalHeight : null, + }); + }) + .filter((entry): entry is TemplateElement => entry !== null); - return normalizeElement({ - id: candidate.id, - paletteId: candidate.paletteId, - label: candidate.label, - content: candidate.content, - x: candidate.x, - y: candidate.y, - width: candidate.width, - fontSize: candidate.fontSize, - fontWeight: normalizeFontWeight(candidate.fontWeight), - textAlign: normalizeTextAlign(candidate.textAlign), - }); - }) - .filter((entry): entry is TemplateElement => entry !== null); + return elements; +} - return elements; - } catch { +function formatTemplateTimestamp(value: string | null) { + if (!value) { return null; } + + const parsed = new Date(value); + if (Number.isNaN(parsed.getTime())) { + return null; + } + + return new Intl.DateTimeFormat("de-DE", { + dateStyle: "medium", + timeStyle: "short", + }).format(parsed); } function readDragPayload(event: DragEvent): DragPayload | null { @@ -624,14 +1050,6 @@ function readDragPayload(event: DragEvent): DragPayload | null { export default function InvoiceTemplatePage() { const { user } = useSession(); - const storageKey = `${INVOICE_TEMPLATE_STORAGE_KEY}.${user?.id ?? "default"}`; - const initialElementsRef = useRef(null); - - if (initialElementsRef.current === null) { - initialElementsRef.current = loadStoredElements(storageKey) ?? createStarterLayout(user); - } - - const initialElements = initialElementsRef.current ?? []; const pageRef = useRef(null); const cardRef = useRef(null); const canvasRef = useRef(null); @@ -640,13 +1058,16 @@ export default function InvoiceTemplatePage() { const mouseDragCleanupRef = useRef<(() => void) | null>(null); const moveOffsetRef = useRef<{ x: number; y: number } | null>(null); - const [elements, setElements] = useState(initialElements); - const [selectedElementId, setSelectedElementId] = useState( - initialElements[0]?.id ?? null, - ); + const [elements, setElements] = useState(() => createStarterLayout(user)); + const [selectedElementId, setSelectedElementId] = useState(null); const [isCanvasActive, setIsCanvasActive] = useState(false); const [draggingElementId, setDraggingElementId] = useState(null); + const [resizingElementId, setResizingElementId] = useState(null); const [pdfError, setPdfError] = useState(null); + const [templateError, setTemplateError] = useState(null); + const [templateUpdatedAt, setTemplateUpdatedAt] = useState(null); + const [isTemplateLoading, setIsTemplateLoading] = useState(false); + const [isTemplateSaving, setIsTemplateSaving] = useState(false); const [pdfPreviewUrl, setPdfPreviewUrl] = useState(null); const [pageViewportHeight, setPageViewportHeight] = useState(null); const [canvasViewport, setCanvasViewport] = useState({ @@ -657,10 +1078,77 @@ export default function InvoiceTemplatePage() { }); const selectedElement = elements.find((entry) => entry.id === selectedElementId) ?? null; + const selectedElementHeight = selectedElement ? getElementHeight(selectedElement) : null; + const selectedElementContentEditable = selectedElement + ? isElementContentEditable(selectedElement) + : false; + const selectedElementMinimumWidth = selectedElement + ? getMinimumWidthForElement(selectedElement.kind, selectedElement.lineOrientation) + : MIN_ELEMENT_WIDTH; + const selectedElementMinimumHeight = selectedElement + ? getMinimumHeightForElement( + selectedElement.kind, + selectedElement.fontSize, + selectedElement.lineOrientation, + ) + : MIN_MEDIA_HEIGHT; + const templateTimestampLabel = formatTemplateTimestamp(templateUpdatedAt); useEffect(() => { - window.localStorage.setItem(storageKey, JSON.stringify(elements)); - }, [elements, storageKey]); + if (!user) { + const starterLayout = createStarterLayout(null); + setElements(starterLayout); + setSelectedElementId(starterLayout[0]?.id ?? null); + setResizingElementId(null); + setIsTemplateLoading(false); + setTemplateUpdatedAt(null); + setTemplateError(null); + return; + } + + let cancelled = false; + setTemplateError(null); + setIsTemplateLoading(true); + + void apiGet("/session/invoice-template") + .then((response) => { + if (cancelled) { + return; + } + + setTemplateUpdatedAt(response.updatedAt); + if (!response.stored) { + const starterLayout = createStarterLayout(user); + setElements(starterLayout); + setSelectedElementId(starterLayout[0]?.id ?? null); + setResizingElementId(null); + return; + } + + const loadedElements = normalizeTemplateElements(response.elements); + if (!loadedElements) { + throw new Error("Gespeichertes Template konnte nicht geladen werden."); + } + + setElements(loadedElements); + setSelectedElementId(loadedElements[0]?.id ?? null); + setResizingElementId(null); + }) + .catch((error) => { + if (!cancelled) { + setTemplateError((error as Error).message); + } + }) + .finally(() => { + if (!cancelled) { + setIsTemplateLoading(false); + } + }); + + return () => { + cancelled = true; + }; + }, [user]); useEffect(() => { if (selectedElementId && elements.some((entry) => entry.id === selectedElementId)) { @@ -740,7 +1228,7 @@ export default function InvoiceTemplatePage() { 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.floor(CANVAS_HEIGHT * nextScale); + const nextHeight = Math.ceil(CANVAS_HEIGHT * nextScale); setCanvasViewport((current) => { if ( @@ -781,14 +1269,24 @@ export default function InvoiceTemplatePage() { function updateElement(elementId: string, patch: Partial) { setElements((current) => current.map((entry) => - entry.id === elementId ? normalizeElement({ ...entry, ...patch }) : entry, + entry.id === elementId + ? normalizeElement({ + ...entry, + ...(!isElementContentEditable(entry) && + Object.prototype.hasOwnProperty.call(patch, "content") + ? { ...patch, content: entry.content } + : patch), + }) + : entry, ), ); } function removeElement(elementId: string) { + mouseDragCleanupRef.current?.(); setElements((current) => current.filter((entry) => entry.id !== elementId)); setSelectedElementId((current) => (current === elementId ? null : current)); + setResizingElementId((current) => (current === elementId ? null : current)); } function placePaletteElement(paletteId: string, clientX: number, clientY: number) { @@ -799,8 +1297,13 @@ export default function InvoiceTemplatePage() { } const rect = canvasElement.getBoundingClientRect(); + const previewHeight = getDefaultElementHeight( + paletteItem.kind ?? "text", + paletteItem.fontSize, + paletteItem.lineOrientation, + ); const x = (clientX - rect.left) / canvasViewport.scale - paletteItem.width / 2; - const y = (clientY - rect.top) / canvasViewport.scale - paletteItem.fontSize; + const y = (clientY - rect.top) / canvasViewport.scale - previewHeight / 2; const nextElement = createElementFromPalette(paletteItem, user, { x, y }); setElements((current) => [...current, nextElement]); @@ -859,14 +1362,18 @@ export default function InvoiceTemplatePage() { } function handleResetToStarter() { + mouseDragCleanupRef.current?.(); const starterLayout = createStarterLayout(user); setElements(starterLayout); setSelectedElementId(starterLayout[0]?.id ?? null); + setResizingElementId(null); } function handleClearCanvas() { + mouseDragCleanupRef.current?.(); setElements([]); setSelectedElementId(null); + setResizingElementId(null); } function handleCreatePdfPreview() { @@ -880,6 +1387,71 @@ export default function InvoiceTemplatePage() { } } + async function handleSaveTemplate() { + if (!user) { + setTemplateError("Vorlagen koennen nur fuer angemeldete Benutzer gespeichert werden."); + return; + } + + setTemplateError(null); + setIsTemplateSaving(true); + + try { + const response = await apiPut("/session/invoice-template", { + elements, + }); + const savedElements = normalizeTemplateElements(response.elements); + if (!savedElements) { + throw new Error("Gespeichertes Template konnte nicht gelesen werden."); + } + + setElements(savedElements); + setSelectedElementId((current) => + savedElements.some((entry) => entry.id === current) ? current : savedElements[0]?.id ?? null, + ); + setResizingElementId(null); + setTemplateUpdatedAt(response.updatedAt); + } catch (error) { + setTemplateError((error as Error).message); + } finally { + setIsTemplateSaving(false); + } + } + + async function handleImageUpload( + event: ReactChangeEvent, + elementId: string, + ) { + const file = event.target.files?.[0]; + event.target.value = ""; + if (!file) { + return; + } + + const currentElement = elements.find((entry) => entry.id === elementId); + if (!currentElement || currentElement.kind !== "image") { + return; + } + + try { + const image = await convertImageFileToJpeg(file); + const nextHeight = Math.max( + MIN_MEDIA_HEIGHT, + Math.round((currentElement.width * image.height) / image.width), + ); + + updateElement(elementId, { + height: nextHeight, + imageNaturalHeight: image.height, + imageNaturalWidth: image.width, + imageSrc: image.dataUrl, + }); + setPdfError(null); + } catch (error) { + setPdfError((error as Error).message); + } + } + function handleElementMouseDown( event: ReactMouseEvent, elementId: string, @@ -889,6 +1461,8 @@ export default function InvoiceTemplatePage() { return; } + mouseDragCleanupRef.current?.(); + const rect = event.currentTarget.getBoundingClientRect(); const startOffset = { x: (event.clientX - rect.left) / canvasViewport.scale, @@ -898,6 +1472,8 @@ export default function InvoiceTemplatePage() { moveOffsetRef.current = startOffset; setSelectedElementId(elementId); setDraggingElementId(elementId); + setResizingElementId(null); + event.currentTarget.focus(); event.preventDefault(); function handleMouseMove(moveEvent: MouseEvent) { @@ -921,6 +1497,55 @@ export default function InvoiceTemplatePage() { mouseDragCleanupRef.current = stopDragging; } + function handleResizeMouseDown( + event: ReactMouseEvent, + elementId: string, + ) { + const currentElement = elements.find((entry) => entry.id === elementId); + if (!currentElement || event.button !== 0) { + return; + } + + mouseDragCleanupRef.current?.(); + setSelectedElementId(elementId); + setDraggingElementId(null); + setResizingElementId(elementId); + event.currentTarget.parentElement?.focus(); + event.preventDefault(); + event.stopPropagation(); + + const startWidth = currentElement.width; + const startHeight = getElementHeight(currentElement); + const elementKind = currentElement.kind; + 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; + + updateElement(elementId, { + width: startWidth + deltaX, + ...(elementKind === "text" ? {} : { height: startHeight + deltaY }), + }); + } + + function stopResizing() { + setResizingElementId(null); + window.removeEventListener("mousemove", handleMouseMove); + window.removeEventListener("mouseup", handleMouseUp); + mouseDragCleanupRef.current = null; + } + + function handleMouseUp() { + stopResizing(); + } + + window.addEventListener("mousemove", handleMouseMove); + window.addEventListener("mouseup", handleMouseUp); + mouseDragCleanupRef.current = stopResizing; + } + function handleElementKeyDown( event: ReactKeyboardEvent, elementId: string, @@ -930,6 +1555,12 @@ export default function InvoiceTemplatePage() { return; } + if (event.key === "Delete") { + event.preventDefault(); + removeElement(elementId); + return; + } + let patch: Partial | null = null; if (event.key === "ArrowLeft") { @@ -971,6 +1602,16 @@ export default function InvoiceTemplatePage() { + @@ -981,6 +1622,7 @@ export default function InvoiceTemplatePage() { {pdfError ?
{pdfError}
: null} + {templateError ?
{templateError}
: null}
@@ -1016,6 +1658,13 @@ export default function InvoiceTemplatePage() {

Rechnungsvorlage

+ + {isTemplateLoading + ? "Gespeicherte Vorlage wird geladen..." + : templateTimestampLabel + ? `In Datenbank gespeichert: ${templateTimestampLabel}` + : "Noch keine gespeicherte Vorlage vorhanden"} +
@@ -1041,23 +1690,55 @@ export default function InvoiceTemplatePage() { )) ) : ( @@ -1093,15 +1774,44 @@ export default function InvoiceTemplatePage() { /> -