feat: add customer number generation and invoice creation

Backend:
- Add customerNumber field to AppUser with unique index
- Add customer number generation starting at K1000
- Add InvoiceService for creating invoices with PDF data
- Add InvoiceController with endpoints for listing customers and generating invoice data
- Update CatalogService to assign customer numbers to new users
- Update SampleService to preserve customerNumber on updates
- Fix SecurityConfig to enable method security and admin role checks

Frontend:
- Update UserOption/UserRow types to include customerNumber
- Add customer selection dialog in InvoiceManagementPage
- Add PDF generation and preview for invoices
- Fix encodePdfText function for proper PDF encoding
- Fix LocalStorage key for auth token
This commit is contained in:
2026-03-18 20:10:38 +01:00
parent 58c78bbbbd
commit f9e370afe2
12 changed files with 841 additions and 16 deletions

View File

@@ -67,6 +67,7 @@ export interface UserOption {
iban: string | null;
bic: string | null;
role: UserRole;
customerNumber: string | null;
}
export interface SessionResponse {

View File

@@ -1,5 +1,14 @@
import { useEffect, useState } from "react";
import { apiGet } from "../lib/api";
import { useSession } from "../lib/session";
import type { UserOption } from "../lib/types";
import {
createDefaultInvoiceStarterLayout,
createMuhInvoiceContent,
createPdfBlob as createTemplatePdfBlob,
normalizeTemplateElements,
type TemplateElement,
} from "./InvoiceTemplatePage";
interface InvoiceSummary {
id: string;
@@ -15,6 +24,35 @@ interface InvoiceOverview {
invoices: InvoiceSummary[];
}
interface CustomerDto {
id: string;
displayName: string;
companyName: string | null;
street: string | null;
houseNumber: string | null;
postalCode: string | null;
city: string | null;
email: string | null;
phoneNumber: string | null;
accountHolder: string | null;
bankName: string | null;
iban: string | null;
bic: string | null;
customerNumber: string | null;
}
interface InvoiceData {
invoiceNumber: string;
invoiceDate: string;
dueDate: string;
customer: CustomerDto;
templateElements: unknown[];
netAmount: number;
vatAmount: number;
grossAmount: number;
monthlyPrice: number;
}
const STATUS_LABELS: Record<InvoiceSummary["status"], string> = {
DRAFT: "Entwurf",
SENT: "Versendet",
@@ -31,10 +69,126 @@ const STATUS_CLASSES: Record<InvoiceSummary["status"], string> = {
CANCELLED: "status-badge--neutral",
};
function buildIssuerContact(user: UserOption | null) {
const lines: string[] = [];
if (user?.phoneNumber) {
lines.push(`Tel.: ${user.phoneNumber}`);
}
if (user?.email) {
lines.push(`E-Mail: ${user.email}`);
}
return lines.join("\n");
}
function buildBankDetails(user: UserOption | null) {
const lines = ["Bankverbindung:"];
if (user?.accountHolder) {
lines.push(`Kontoinhaber: ${user.accountHolder}`);
}
if (user?.iban) {
lines.push(`IBAN: ${user.iban}`);
}
if (user?.bic) {
lines.push(`BIC: ${user.bic}`);
}
if (user?.bankName) {
lines.push(`Bank: ${user.bankName}`);
}
return lines.join("\n") || "Bankverbindung:";
}
function resolveTemplateContent(
element: TemplateElement,
invoiceData: InvoiceData,
issuer: UserOption | null,
) {
const customer = invoiceData.customer;
switch (element.paletteId) {
case "issuer-name":
return issuer?.companyName ?? issuer?.displayName ?? element.content;
case "issuer-street":
return issuer?.street ?? "";
case "issuer-house-number":
return issuer?.houseNumber ?? "";
case "issuer-postal-code":
return issuer?.postalCode ?? "";
case "issuer-city":
return issuer?.city ?? "";
case "issuer-contact":
return buildIssuerContact(issuer);
case "invoice-title":
return "Rechnung";
case "invoice-number":
return `Rechnungsnr.: ${invoiceData.invoiceNumber}`;
case "invoice-date":
return `Datum: ${invoiceData.invoiceDate}`;
case "invoice-due-date":
return `Fällig bis: ${invoiceData.dueDate}`;
case "customer-name":
return customer.companyName || customer.displayName;
case "customer-street":
return customer.street || "";
case "customer-house-number":
return customer.houseNumber || "";
case "customer-postal-code":
return customer.postalCode || "";
case "customer-city":
return customer.city || "";
case "customer-email":
return customer.email ? `E-Mail: ${customer.email}` : "";
case "customer-phone":
return customer.phoneNumber ? `Tel.: ${customer.phoneNumber}` : "";
case "customer-number":
return `Kunden-Nr.: ${customer.customerNumber || "-"}`;
case "invoice-items-muh":
return createMuhInvoiceContent(invoiceData.monthlyPrice, element.width);
case "payment-terms":
return "Zahlungsbedingungen: Zahlung innerhalb von 14 Tagen ohne Abzug.";
case "bank-details":
return buildBankDetails(issuer);
default:
return element.content;
}
}
function buildInvoiceElements(invoiceData: InvoiceData, issuer: UserOption | null) {
const storedElements = normalizeTemplateElements(invoiceData.templateElements);
const templateElements =
storedElements && storedElements.length > 0
? storedElements
: createDefaultInvoiceStarterLayout(issuer, invoiceData.monthlyPrice);
return templateElements.map((element) => {
if (element.kind !== "text") {
return element;
}
return {
...element,
content: resolveTemplateContent(element, invoiceData, issuer),
};
});
}
function createInvoicePdfBlob(invoiceData: InvoiceData, issuer: UserOption | null) {
return createTemplatePdfBlob(buildInvoiceElements(invoiceData, issuer), invoiceData.monthlyPrice);
}
export default function InvoiceManagementPage() {
const { user } = useSession();
const [invoices, setInvoices] = useState<InvoiceSummary[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Dialog states
const [showCustomerDialog, setShowCustomerDialog] = useState(false);
const [showPdfPreview, setShowPdfPreview] = useState(false);
const [customers, setCustomers] = useState<CustomerDto[]>([]);
const [selectedCustomerId, setSelectedCustomerId] = useState<string>("");
const [invoiceData, setInvoiceData] = useState<InvoiceData | null>(null);
const [pdfUrl, setPdfUrl] = useState<string | null>(null);
const [isLoadingInvoice, setIsLoadingInvoice] = useState(false);
useEffect(() => {
let cancelled = false;
@@ -47,10 +201,8 @@ export default function InvoiceManagementPage() {
setInvoices(response.invoices);
}
})
.catch((err) => {
.catch(() => {
if (!cancelled) {
// Für den Moment zeigen wir einfach eine leere Liste an
// bis das Backend implementiert ist
setInvoices([]);
setError(null);
}
@@ -66,6 +218,28 @@ export default function InvoiceManagementPage() {
};
}, []);
// Cleanup PDF URL
useEffect(() => {
return () => {
if (pdfUrl) {
URL.revokeObjectURL(pdfUrl);
}
};
}, [pdfUrl]);
// Handle Escape key for PDF preview
useEffect(() => {
function handleEscape(event: KeyboardEvent) {
if (event.key === "Escape") {
setShowPdfPreview(false);
}
}
if (showPdfPreview) {
window.addEventListener("keydown", handleEscape);
return () => window.removeEventListener("keydown", handleEscape);
}
}, [showPdfPreview]);
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat("de-DE", {
style: "currency",
@@ -79,6 +253,59 @@ export default function InvoiceManagementPage() {
}).format(new Date(dateString));
};
const handleNewInvoice = async () => {
setError(null);
try {
const customerList = await apiGet<CustomerDto[]>("/admin/customers/primary");
setCustomers(customerList);
setSelectedCustomerId(customerList[0]?.id || "");
setShowCustomerDialog(true);
} catch (err) {
const errorMessage = err instanceof Error ? err.message : "Unbekannter Fehler";
setError(`Kunden konnten nicht geladen werden: ${errorMessage}`);
}
};
const handleCreateInvoice = async () => {
if (!selectedCustomerId) return;
setIsLoadingInvoice(true);
setError(null);
try {
const data = await apiGet<InvoiceData>(`/admin/customers/${selectedCustomerId}/invoice-data`);
if (!data.customer) {
throw new Error("Ungültige Rechnungsdaten: Kunde fehlt");
}
setInvoiceData(data);
if (pdfUrl) {
URL.revokeObjectURL(pdfUrl);
}
const pdfBlob = createInvoicePdfBlob(data, user);
const url = URL.createObjectURL(pdfBlob);
setPdfUrl(url);
setShowCustomerDialog(false);
setShowPdfPreview(true);
} catch (err) {
const errorMessage = err instanceof Error ? err.message : "Unbekannter Fehler";
setError(`Rechnung konnte nicht erstellt werden: ${errorMessage}`);
} finally {
setIsLoadingInvoice(false);
}
};
const handleClosePdfPreview = () => {
setShowPdfPreview(false);
if (pdfUrl) {
URL.revokeObjectURL(pdfUrl);
setPdfUrl(null);
}
};
return (
<div className="page-stack">
<section className="section-card section-card--hero">
@@ -101,7 +328,11 @@ export default function InvoiceManagementPage() {
<h3>Rechnungsliste</h3>
</div>
<div className="page-actions">
<button type="button" className="accent-button" disabled>
<button
type="button"
className="accent-button"
onClick={handleNewInvoice}
>
Neue Rechnung
</button>
</div>
@@ -113,7 +344,7 @@ export default function InvoiceManagementPage() {
<div className="empty-state">
<p>Noch keine Rechnungen vorhanden.</p>
<p className="muted-text">
Die Rechnungsverwaltung wird in Kürze verfügbar sein.
Erstellen Sie Ihre erste Rechnung mit dem Button "Neue Rechnung".
</p>
</div>
) : (
@@ -191,6 +422,219 @@ export default function InvoiceManagementPage() {
</table>
</div>
</section>
{/* Customer Selection Dialog */}
{showCustomerDialog && (
<div
className="modal-overlay"
onClick={() => setShowCustomerDialog(false)}
style={{
position: "fixed",
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: "rgba(0, 0, 0, 0.5)",
display: "flex",
alignItems: "center",
justifyContent: "center",
zIndex: 1000,
}}
>
<div
className="modal-content"
onClick={(e) => e.stopPropagation()}
style={{
backgroundColor: "white",
borderRadius: "8px",
padding: "24px",
width: "100%",
maxWidth: "480px",
maxHeight: "90vh",
overflow: "auto",
}}
>
<h3 style={{ marginTop: 0, marginBottom: "16px" }}>
Rechnung erstellen
</h3>
<p style={{ marginBottom: "20px", color: "#666" }}>
Wählen Sie einen Hauptbenutzer aus, für den die Rechnung erstellt werden soll:
</p>
<div style={{ marginBottom: "24px" }}>
<label
htmlFor="customer-select"
style={{
display: "block",
marginBottom: "8px",
fontWeight: 500,
fontSize: "14px",
}}
>
Kunde
</label>
<select
id="customer-select"
value={selectedCustomerId}
onChange={(e) => setSelectedCustomerId(e.target.value)}
style={{
width: "100%",
padding: "10px 12px",
borderRadius: "6px",
border: "1px solid #d1d5db",
fontSize: "14px",
backgroundColor: "white",
}}
>
{customers.length === 0 && (
<option value="">Keine Kunden verfügbar</option>
)}
{customers.map((customer) => (
<option key={customer.id} value={customer.id}>
{customer.companyName || customer.displayName}
{customer.customerNumber ? ` (${customer.customerNumber})` : ""}
</option>
))}
</select>
</div>
<div style={{ display: "flex", gap: "12px", justifyContent: "flex-end" }}>
<button
type="button"
className="secondary-button"
onClick={() => setShowCustomerDialog(false)}
style={{
padding: "10px 20px",
borderRadius: "6px",
border: "1px solid #d1d5db",
backgroundColor: "white",
cursor: "pointer",
}}
>
Abbrechen
</button>
<button
type="button"
className="accent-button"
onClick={handleCreateInvoice}
disabled={!selectedCustomerId || isLoadingInvoice}
style={{
padding: "10px 20px",
borderRadius: "6px",
border: "none",
backgroundColor: "var(--primary-600, #2563eb)",
color: "white",
cursor: !selectedCustomerId || isLoadingInvoice ? "not-allowed" : "pointer",
opacity: !selectedCustomerId || isLoadingInvoice ? 0.6 : 1,
}}
>
{isLoadingInvoice ? "Wird erstellt..." : "Rechnung erstellen"}
</button>
</div>
</div>
</div>
)}
{/* PDF Preview Dialog */}
{showPdfPreview && pdfUrl && (
<div
className="modal-overlay"
onClick={handleClosePdfPreview}
style={{
position: "fixed",
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: "rgba(0, 0, 0, 0.7)",
display: "flex",
alignItems: "center",
justifyContent: "center",
zIndex: 1000,
}}
>
<div
className="modal-content"
onClick={(e) => e.stopPropagation()}
style={{
backgroundColor: "white",
borderRadius: "8px",
width: "95%",
maxWidth: "900px",
height: "90vh",
display: "flex",
flexDirection: "column",
overflow: "hidden",
}}
>
<div
style={{
padding: "16px 24px",
borderBottom: "1px solid #e5e7eb",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}
>
<div>
<h3 style={{ margin: 0, fontSize: "18px" }}>
Rechnungsvorschau
</h3>
{invoiceData && (
<p style={{ margin: "4px 0 0 0", fontSize: "14px", color: "#666" }}>
{invoiceData.invoiceNumber} - {invoiceData.customer.companyName || invoiceData.customer.displayName}
</p>
)}
</div>
<div style={{ display: "flex", gap: "12px" }}>
<a
href={pdfUrl}
download={invoiceData ? `${invoiceData.invoiceNumber}.pdf` : "rechnung.pdf"}
style={{
padding: "8px 16px",
borderRadius: "6px",
border: "1px solid #d1d5db",
backgroundColor: "white",
color: "#374151",
textDecoration: "none",
fontSize: "14px",
cursor: "pointer",
}}
>
Download
</a>
<button
type="button"
onClick={handleClosePdfPreview}
style={{
padding: "8px 16px",
borderRadius: "6px",
border: "none",
backgroundColor: "var(--primary-600, #2563eb)",
color: "white",
fontSize: "14px",
cursor: "pointer",
}}
>
Schließen
</button>
</div>
</div>
<div style={{ flex: 1, overflow: "auto", backgroundColor: "#f3f4f6" }}>
<iframe
src={pdfUrl}
title="Rechnungsvorschau"
style={{
width: "100%",
height: "100%",
border: "none",
}}
/>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -58,10 +58,10 @@ const INVOICE_LOCKED_TEXT_PALETTE_IDS = new Set([
"invoice-items-muh",
]);
type ElementKind = "text" | "line" | "image";
type LineOrientation = "horizontal" | "vertical";
type TextAlign = "left" | "center" | "right";
type FontWeight = 400 | 500 | 600 | 700;
export type ElementKind = "text" | "line" | "image";
export type LineOrientation = "horizontal" | "vertical";
export type TextAlign = "left" | "center" | "right";
export type FontWeight = 400 | 500 | 600 | 700;
type PaletteCategory = string;
function isMuhInvoicePaletteId(paletteId?: string) {
@@ -87,7 +87,7 @@ interface PaletteItem {
defaultContent: (user: UserOption | null) => string;
}
interface TemplateElement {
export interface TemplateElement {
id: string;
paletteId: string;
kind: ElementKind;
@@ -1006,7 +1006,7 @@ function createPdfContentStream(
return commands.join("\n");
}
function createPdfBlob(elements: TemplateElement[], monthlyPrice: number | null) {
export function createPdfBlob(elements: TemplateElement[], monthlyPrice: number | null) {
const imageResources = createPdfImageResources(elements);
const imageResourceMap = new Map(imageResources.map((resource) => [resource.elementId, resource]));
const contentStream = createPdfContentStream(elements, imageResourceMap, monthlyPrice);
@@ -1227,7 +1227,7 @@ function getMuhInvoiceRows(monthlyPrice: number | null): MuhInvoiceRow[] {
];
}
function createMuhInvoiceContent(monthlyPrice: number | null, _elementWidth: number = 646): string {
export function createMuhInvoiceContent(monthlyPrice: number | null, _elementWidth: number = 646): string {
return getMuhInvoiceRows(monthlyPrice)
.map((row) => `${row.label} | ${row.amount}`)
.join("\n");
@@ -1319,6 +1319,13 @@ function createInvoiceStarterLayout(user: UserOption | null, paletteItems: Palet
];
}
export function createDefaultInvoiceStarterLayout(
user: UserOption | null,
monthlyPrice: number | null = null,
) {
return createInvoiceStarterLayout(user, INVOICE_PALETTE_ITEMS, monthlyPrice);
}
function readFileAsDataUrl(file: File) {
return new Promise<string>((resolve, reject) => {
const reader = new FileReader();
@@ -1381,7 +1388,7 @@ async function convertImageFileToJpeg(file: File) {
};
}
function normalizeTemplateElements(raw: unknown) {
export function normalizeTemplateElements(raw: unknown) {
if (!Array.isArray(raw)) {
return null;
}

View File

@@ -978,7 +978,7 @@ a {
position: absolute;
display: grid;
gap: 0;
padding: 4px 5px;
padding: 4px 11px;
border: 1px solid transparent;
border-radius: 16px;
background: transparent;