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

@@ -1,6 +1,7 @@
package de.svencarstensen.muh.domain; package de.svencarstensen.muh.domain;
import org.springframework.data.annotation.Id; import org.springframework.data.annotation.Id;
import org.springframework.data.mongodb.core.index.Indexed;
import org.springframework.data.mongodb.core.mapping.Document; import org.springframework.data.mongodb.core.mapping.Document;
import java.time.LocalDateTime; import java.time.LocalDateTime;
@@ -27,6 +28,7 @@ public record AppUser(
boolean active, boolean active,
UserRole role, UserRole role,
Long nextSampleNumber, Long nextSampleNumber,
@Indexed(unique = true) String customerNumber,
LocalDateTime createdAt, LocalDateTime createdAt,
LocalDateTime updatedAt LocalDateTime updatedAt
) { ) {

View File

@@ -2,6 +2,7 @@ package de.svencarstensen.muh.repository;
import de.svencarstensen.muh.domain.AppUser; import de.svencarstensen.muh.domain.AppUser;
import org.springframework.data.mongodb.repository.MongoRepository; import org.springframework.data.mongodb.repository.MongoRepository;
import org.springframework.data.mongodb.repository.Query;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
@@ -12,4 +13,9 @@ public interface AppUserRepository extends MongoRepository<AppUser, String> {
List<AppUser> findByAccountIdOrderByDisplayNameAsc(String accountId); List<AppUser> findByAccountIdOrderByDisplayNameAsc(String accountId);
Optional<AppUser> findByEmailIgnoreCase(String email); Optional<AppUser> findByEmailIgnoreCase(String email);
Optional<AppUser> findByCustomerNumber(String customerNumber);
@Query(value = "{ 'customerNumber': { $exists: true, $ne: null } }", sort = "{ 'customerNumber': -1 }")
List<AppUser> findTopByCustomerNumberExistsOrderByCustomerNumberDesc();
} }

View File

@@ -48,6 +48,8 @@ public class BearerTokenAuthenticationFilter extends OncePerRequestFilter {
.filter(AppUser::active) .filter(AppUser::active)
.orElseThrow(() -> new IllegalArgumentException("Benutzer ungueltig")); .orElseThrow(() -> new IllegalArgumentException("Benutzer ungueltig"));
System.out.println("[DEBUG] Authenticating user: " + user.id() + ", role: " + user.role());
AuthenticatedUser principal = new AuthenticatedUser(user.id(), user.displayName(), user.role()); AuthenticatedUser principal = new AuthenticatedUser(user.id(), user.displayName(), user.role());
UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken( UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(
principal, principal,
@@ -55,7 +57,10 @@ public class BearerTokenAuthenticationFilter extends OncePerRequestFilter {
List.of(new SimpleGrantedAuthority("ROLE_" + user.role().name())) List.of(new SimpleGrantedAuthority("ROLE_" + user.role().name()))
); );
SecurityContextHolder.getContext().setAuthentication(authentication); SecurityContextHolder.getContext().setAuthentication(authentication);
System.out.println("[DEBUG] Authentication set: " + authentication.getAuthorities());
} catch (RuntimeException exception) { } catch (RuntimeException exception) {
System.err.println("[DEBUG] Authentication failed: " + exception.getMessage());
SecurityContextHolder.clearContext(); SecurityContextHolder.clearContext();
} }

View File

@@ -4,6 +4,7 @@ import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod; import org.springframework.http.HttpMethod;
import org.springframework.security.config.Customizer; import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.config.http.SessionCreationPolicy;
@@ -11,6 +12,7 @@ import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
@Configuration @Configuration
@EnableMethodSecurity
public class SecurityConfig { public class SecurityConfig {
@Bean @Bean
@@ -23,6 +25,7 @@ public class SecurityConfig {
.sessionManagement(configurer -> configurer.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .sessionManagement(configurer -> configurer.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(authorize -> authorize .authorizeHttpRequests(authorize -> authorize
.requestMatchers(HttpMethod.POST, "/api/session/password-login", "/api/session/register").permitAll() .requestMatchers(HttpMethod.POST, "/api/session/password-login", "/api/session/register").permitAll()
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.requestMatchers("/api/**").authenticated() .requestMatchers("/api/**").authenticated()
.anyRequest().permitAll() .anyRequest().permitAll()
) )

View File

@@ -421,6 +421,7 @@ public class CatalogService {
} }
String userId = UUID.randomUUID().toString(); String userId = UUID.randomUUID().toString();
boolean adminManaged = actor.role() == UserRole.ADMIN; boolean adminManaged = actor.role() == UserRole.ADMIN;
String customerNumber = generateNextCustomerNumber();
AppUser created = appUserRepository.save(new AppUser( AppUser created = appUserRepository.save(new AppUser(
userId, userId,
adminManaged ? userId : resolveAccountId(actor), adminManaged ? userId : resolveAccountId(actor),
@@ -444,6 +445,7 @@ public class CatalogService {
mutation.active(), mutation.active(),
adminManaged ? normalizeManagedRole(mutation.role()) : UserRole.CUSTOMER, adminManaged ? normalizeManagedRole(mutation.role()) : UserRole.CUSTOMER,
100000L, 100000L,
customerNumber,
now, now,
now now
)); ));
@@ -481,6 +483,7 @@ public class CatalogService {
? (mutation.role() == null ? normalizeStoredRole(existing.role()) : normalizeManagedRole(mutation.role())) ? (mutation.role() == null ? normalizeStoredRole(existing.role()) : normalizeManagedRole(mutation.role()))
: normalizeStoredRole(existing.role()), : normalizeStoredRole(existing.role()),
existing.nextSampleNumber(), existing.nextSampleNumber(),
existing.customerNumber(),
existing.createdAt(), existing.createdAt(),
now now
)); ));
@@ -532,6 +535,7 @@ public class CatalogService {
existing.active(), existing.active(),
existing.role(), existing.role(),
existing.nextSampleNumber(), existing.nextSampleNumber(),
existing.customerNumber(),
existing.createdAt(), existing.createdAt(),
LocalDateTime.now() LocalDateTime.now()
)); ));
@@ -578,6 +582,7 @@ public class CatalogService {
String address = formatAddress(street, houseNumber, postalCode, city); String address = formatAddress(street, houseNumber, postalCode, city);
String displayName = companyName; String displayName = companyName;
LocalDateTime now = LocalDateTime.now(); LocalDateTime now = LocalDateTime.now();
String customerNumber = generateNextCustomerNumber();
AppUser created = appUserRepository.save(new AppUser( AppUser created = appUserRepository.save(new AppUser(
UUID.randomUUID().toString(), UUID.randomUUID().toString(),
@@ -600,6 +605,7 @@ public class CatalogService {
false, false,
UserRole.CUSTOMER, UserRole.CUSTOMER,
100000L, 100000L,
customerNumber,
now, now,
now now
)); ));
@@ -624,6 +630,7 @@ public class CatalogService {
false, false,
created.role(), created.role(),
created.nextSampleNumber(), created.nextSampleNumber(),
created.customerNumber(),
created.createdAt(), created.createdAt(),
created.updatedAt() created.updatedAt()
)); ));
@@ -645,6 +652,7 @@ public class CatalogService {
removeLegacyUserCodeField(); removeLegacyUserCodeField();
backfillDefaultUserEmails(); backfillDefaultUserEmails();
removeLegacyPortalLoginField(); removeLegacyPortalLoginField();
migrateCustomerNumbers();
ensureDefaultUser("Administrator", "admin@muh.local", "Admin123!", UserRole.ADMIN); ensureDefaultUser("Administrator", "admin@muh.local", "Admin123!", UserRole.ADMIN);
} }
@@ -734,6 +742,7 @@ public class CatalogService {
user.bic(), user.bic(),
user.active(), user.active(),
normalizeStoredRole(user.role()), normalizeStoredRole(user.role()),
user.customerNumber(),
user.updatedAt() user.updatedAt()
); );
} }
@@ -771,7 +780,8 @@ public class CatalogService {
user.bankName(), user.bankName(),
user.iban(), user.iban(),
user.bic(), user.bic(),
normalizeStoredRole(user.role()) normalizeStoredRole(user.role()),
user.customerNumber()
); );
} }
@@ -871,11 +881,64 @@ public class CatalogService {
user.active(), user.active(),
normalizeStoredRole(user.role()), normalizeStoredRole(user.role()),
user.nextSampleNumber(), user.nextSampleNumber(),
user.customerNumber(),
user.createdAt(), user.createdAt(),
now now
))); )));
} }
private void migrateCustomerNumbers() {
LocalDateTime now = LocalDateTime.now();
appUserRepository.findAll().stream()
.filter(user -> isBlank(user.customerNumber()))
.forEach(user -> appUserRepository.save(new AppUser(
user.id(),
user.accountId(),
user.primaryUser(),
user.displayName(),
user.companyName(),
user.address(),
user.street(),
user.houseNumber(),
user.postalCode(),
user.city(),
user.email(),
user.phoneNumber(),
user.accountHolder(),
user.bankName(),
user.iban(),
user.bic(),
user.passwordHash(),
user.active(),
user.role(),
user.nextSampleNumber(),
generateNextCustomerNumber(),
user.createdAt(),
now
)));
}
private String generateNextCustomerNumber() {
List<AppUser> usersWithNumbers = appUserRepository.findTopByCustomerNumberExistsOrderByCustomerNumberDesc();
int nextNumber = 1000;
if (!usersWithNumbers.isEmpty()) {
String highestNumber = usersWithNumbers.get(0).customerNumber();
if (highestNumber != null && highestNumber.startsWith("K")) {
try {
int highest = Integer.parseInt(highestNumber.substring(1));
nextNumber = highest + 1;
} catch (NumberFormatException e) {
// Fallback to 1000 if parsing fails
}
}
}
// Ensure uniqueness in case of concurrent operations
while (appUserRepository.findByCustomerNumber("K" + nextNumber).isPresent()) {
nextNumber++;
}
return "K" + nextNumber;
}
private void ensureDefaultUser( private void ensureDefaultUser(
String displayName, String displayName,
String email, String email,
@@ -910,6 +973,7 @@ public class CatalogService {
true, true,
role, role,
100000L, 100000L,
generateNextCustomerNumber(),
now, now,
now now
)); ));
@@ -1037,7 +1101,8 @@ public class CatalogService {
String bankName, String bankName,
String iban, String iban,
String bic, String bic,
UserRole role UserRole role,
String customerNumber
) { ) {
} }
@@ -1112,6 +1177,7 @@ public class CatalogService {
String bic, String bic,
boolean active, boolean active,
UserRole role, UserRole role,
String customerNumber,
LocalDateTime updatedAt LocalDateTime updatedAt
) { ) {
} }

View File

@@ -0,0 +1,222 @@
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.domain.SystemPricing;
import de.svencarstensen.muh.domain.Template;
import de.svencarstensen.muh.domain.TemplateType;
import de.svencarstensen.muh.repository.AppUserRepository;
import de.svencarstensen.muh.repository.InvoiceTemplateRepository;
import de.svencarstensen.muh.repository.TemplateRepository;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.web.server.ResponseStatusException;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.Locale;
import java.util.Optional;
@Service
public class InvoiceService {
private final AppUserRepository appUserRepository;
private final TemplateRepository templateRepository;
private final InvoiceTemplateRepository invoiceTemplateRepository;
private final SystemPricingService pricingService;
public InvoiceService(
AppUserRepository appUserRepository,
TemplateRepository templateRepository,
InvoiceTemplateRepository invoiceTemplateRepository,
SystemPricingService pricingService
) {
this.appUserRepository = appUserRepository;
this.templateRepository = templateRepository;
this.invoiceTemplateRepository = invoiceTemplateRepository;
this.pricingService = pricingService;
}
public List<CustomerDto> listPrimaryCustomers() {
return appUserRepository.findAll().stream()
.filter(user -> Boolean.TRUE.equals(user.primaryUser()))
.filter(user -> user.role() != de.svencarstensen.muh.domain.UserRole.ADMIN)
.map(this::toCustomerDto)
.sorted((a, b) -> a.displayName().compareToIgnoreCase(b.displayName()))
.toList();
}
public InvoiceData getInvoiceData(String actorId, String customerId) {
AppUser customer = appUserRepository.findById(customerId != null ? customerId : "")
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Kunde nicht gefunden"));
if (!Boolean.TRUE.equals(customer.primaryUser())) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Nur Hauptbenutzer können Rechnungen erhalten");
}
// Use the issuing admin's template for invoice generation
List<TemplateElementDto> templateElements = getTemplateElements(actorId);
// Generate invoice number: R-YYYY-NNNN
String invoiceNumber = generateInvoiceNumber();
// Calculate dates
LocalDate invoiceDate = LocalDate.now();
LocalDate dueDate = invoiceDate.plusDays(14);
// Get pricing
double monthlyPrice = getMonthlyPrice();
double vatRate = 0.19;
double netAmount = monthlyPrice;
double vatAmount = netAmount * vatRate;
double grossAmount = netAmount + vatAmount;
return new InvoiceData(
invoiceNumber,
invoiceDate.format(DateTimeFormatter.ofPattern("dd.MM.yyyy")),
dueDate.format(DateTimeFormatter.ofPattern("dd.MM.yyyy")),
toCustomerDto(customer),
templateElements,
netAmount,
vatAmount,
grossAmount,
monthlyPrice
);
}
private String generateInvoiceNumber() {
// Format: R-YYYY-NNNN (starting from 1000)
String year = String.valueOf(LocalDate.now().getYear());
// For now, use a simple counter based on current time
// In production, this should query the database for the last invoice number
int sequence = (int) (System.currentTimeMillis() % 9000) + 1000;
return "R-" + year + "-" + sequence;
}
private double getMonthlyPrice() {
try {
var pricing = pricingService.getCurrentPricing();
return pricing.map(SystemPricing::monthlyPrice).orElse(49.00);
} catch (Exception e) {
return 49.00; // Default price
}
}
private List<TemplateElementDto> getTemplateElements(String userId) {
// Try to get user's template first
Optional<Template> userTemplate = templateRepository.findByUserIdAndType(userId, TemplateType.INVOICE);
if (userTemplate.isPresent()) {
return mapTemplateElements(userTemplate.get().elements());
}
// Fall back to legacy template
Optional<InvoiceTemplate> legacyTemplate = invoiceTemplateRepository.findById(userId != null ? userId : "");
if (legacyTemplate.isPresent()) {
return mapTemplateElements(legacyTemplate.get().elements());
}
// Return empty list - frontend will use default layout
return List.of();
}
private List<TemplateElementDto> mapTemplateElements(List<?> elements) {
if (elements == null) {
return List.of();
}
return elements.stream()
.filter(InvoiceTemplateElement.class::isInstance)
.map(InvoiceTemplateElement.class::cast)
.map(element -> new TemplateElementDto(
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()
))
.toList();
}
private CustomerDto toCustomerDto(AppUser user) {
return new CustomerDto(
user.id(),
user.displayName(),
user.companyName(),
user.street(),
user.houseNumber(),
user.postalCode(),
user.city(),
user.email(),
user.phoneNumber(),
user.accountHolder(),
user.bankName(),
user.iban(),
user.bic(),
user.customerNumber()
);
}
public record CustomerDto(
String id,
String displayName,
String companyName,
String street,
String houseNumber,
String postalCode,
String city,
String email,
String phoneNumber,
String accountHolder,
String bankName,
String iban,
String bic,
String customerNumber
) {
}
public record InvoiceData(
String invoiceNumber,
String invoiceDate,
String dueDate,
CustomerDto customer,
List<TemplateElementDto> templateElements,
double netAmount,
double vatAmount,
double grossAmount,
double monthlyPrice
) {
}
public record TemplateElementDto(
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
) {
}
}

View File

@@ -622,6 +622,7 @@ public class SampleService {
actor.active(), actor.active(),
actor.role(), actor.role(),
sampleNumber + 1, sampleNumber + 1,
actor.customerNumber(),
actor.createdAt(), actor.createdAt(),
LocalDateTime.now() LocalDateTime.now()
)); ));

View File

@@ -0,0 +1,68 @@
package de.svencarstensen.muh.web;
import de.svencarstensen.muh.service.InvoiceService;
import de.svencarstensen.muh.security.SecuritySupport;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/admin")
public class InvoiceController {
private final InvoiceService invoiceService;
private final SecuritySupport securitySupport;
public InvoiceController(InvoiceService invoiceService, SecuritySupport securitySupport) {
this.invoiceService = invoiceService;
this.securitySupport = securitySupport;
}
@GetMapping("/customers/primary")
public List<InvoiceService.CustomerDto> listPrimaryCustomers() {
return invoiceService.listPrimaryCustomers();
}
@GetMapping("/customers/{customerId}/invoice-data")
public ResponseEntity<?> getInvoiceData(@PathVariable String customerId) {
try {
InvoiceService.InvoiceData data = invoiceService.getInvoiceData(
securitySupport.currentUser().id(),
customerId
);
return ResponseEntity.ok(data);
} catch (ResponseStatusException e) {
return ResponseEntity.status(e.getStatusCode()).body(Map.of("message", e.getReason()));
} catch (Exception e) {
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.of("message", "Fehler beim Erstellen der Rechnung: " + e.getMessage()));
}
}
@GetMapping("/invoices")
public InvoiceOverview getInvoices() {
// Mock implementation - returns empty list for now
return new InvoiceOverview(List.of());
}
public record InvoiceOverview(List<InvoiceSummary> invoices) {
}
public record InvoiceSummary(
String id,
String invoiceNumber,
String customerName,
String invoiceDate,
String dueDate,
double totalAmount,
String status
) {
}
}

View File

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

View File

@@ -1,5 +1,14 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { apiGet } from "../lib/api"; 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 { interface InvoiceSummary {
id: string; id: string;
@@ -15,6 +24,35 @@ interface InvoiceOverview {
invoices: InvoiceSummary[]; 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> = { const STATUS_LABELS: Record<InvoiceSummary["status"], string> = {
DRAFT: "Entwurf", DRAFT: "Entwurf",
SENT: "Versendet", SENT: "Versendet",
@@ -31,10 +69,126 @@ const STATUS_CLASSES: Record<InvoiceSummary["status"], string> = {
CANCELLED: "status-badge--neutral", 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() { export default function InvoiceManagementPage() {
const { user } = useSession();
const [invoices, setInvoices] = useState<InvoiceSummary[]>([]); const [invoices, setInvoices] = useState<InvoiceSummary[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); 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(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
@@ -47,10 +201,8 @@ export default function InvoiceManagementPage() {
setInvoices(response.invoices); setInvoices(response.invoices);
} }
}) })
.catch((err) => { .catch(() => {
if (!cancelled) { if (!cancelled) {
// Für den Moment zeigen wir einfach eine leere Liste an
// bis das Backend implementiert ist
setInvoices([]); setInvoices([]);
setError(null); 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) => { const formatCurrency = (amount: number) => {
return new Intl.NumberFormat("de-DE", { return new Intl.NumberFormat("de-DE", {
style: "currency", style: "currency",
@@ -79,6 +253,59 @@ export default function InvoiceManagementPage() {
}).format(new Date(dateString)); }).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 ( return (
<div className="page-stack"> <div className="page-stack">
<section className="section-card section-card--hero"> <section className="section-card section-card--hero">
@@ -101,7 +328,11 @@ export default function InvoiceManagementPage() {
<h3>Rechnungsliste</h3> <h3>Rechnungsliste</h3>
</div> </div>
<div className="page-actions"> <div className="page-actions">
<button type="button" className="accent-button" disabled> <button
type="button"
className="accent-button"
onClick={handleNewInvoice}
>
Neue Rechnung Neue Rechnung
</button> </button>
</div> </div>
@@ -113,7 +344,7 @@ export default function InvoiceManagementPage() {
<div className="empty-state"> <div className="empty-state">
<p>Noch keine Rechnungen vorhanden.</p> <p>Noch keine Rechnungen vorhanden.</p>
<p className="muted-text"> <p className="muted-text">
Die Rechnungsverwaltung wird in Kürze verfügbar sein. Erstellen Sie Ihre erste Rechnung mit dem Button "Neue Rechnung".
</p> </p>
</div> </div>
) : ( ) : (
@@ -191,6 +422,219 @@ export default function InvoiceManagementPage() {
</table> </table>
</div> </div>
</section> </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> </div>
); );
} }

View File

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

View File

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