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:
@@ -1,6 +1,7 @@
|
||||
package de.svencarstensen.muh.domain;
|
||||
|
||||
import org.springframework.data.annotation.Id;
|
||||
import org.springframework.data.mongodb.core.index.Indexed;
|
||||
import org.springframework.data.mongodb.core.mapping.Document;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
@@ -27,6 +28,7 @@ public record AppUser(
|
||||
boolean active,
|
||||
UserRole role,
|
||||
Long nextSampleNumber,
|
||||
@Indexed(unique = true) String customerNumber,
|
||||
LocalDateTime createdAt,
|
||||
LocalDateTime updatedAt
|
||||
) {
|
||||
|
||||
@@ -2,6 +2,7 @@ package de.svencarstensen.muh.repository;
|
||||
|
||||
import de.svencarstensen.muh.domain.AppUser;
|
||||
import org.springframework.data.mongodb.repository.MongoRepository;
|
||||
import org.springframework.data.mongodb.repository.Query;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
@@ -12,4 +13,9 @@ public interface AppUserRepository extends MongoRepository<AppUser, String> {
|
||||
List<AppUser> findByAccountIdOrderByDisplayNameAsc(String accountId);
|
||||
|
||||
Optional<AppUser> findByEmailIgnoreCase(String email);
|
||||
|
||||
Optional<AppUser> findByCustomerNumber(String customerNumber);
|
||||
|
||||
@Query(value = "{ 'customerNumber': { $exists: true, $ne: null } }", sort = "{ 'customerNumber': -1 }")
|
||||
List<AppUser> findTopByCustomerNumberExistsOrderByCustomerNumberDesc();
|
||||
}
|
||||
|
||||
@@ -48,6 +48,8 @@ public class BearerTokenAuthenticationFilter extends OncePerRequestFilter {
|
||||
.filter(AppUser::active)
|
||||
.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());
|
||||
UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(
|
||||
principal,
|
||||
@@ -55,7 +57,10 @@ public class BearerTokenAuthenticationFilter extends OncePerRequestFilter {
|
||||
List.of(new SimpleGrantedAuthority("ROLE_" + user.role().name()))
|
||||
);
|
||||
SecurityContextHolder.getContext().setAuthentication(authentication);
|
||||
|
||||
System.out.println("[DEBUG] Authentication set: " + authentication.getAuthorities());
|
||||
} catch (RuntimeException exception) {
|
||||
System.err.println("[DEBUG] Authentication failed: " + exception.getMessage());
|
||||
SecurityContextHolder.clearContext();
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.http.HttpMethod;
|
||||
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.configurers.AbstractHttpConfigurer;
|
||||
import org.springframework.security.config.http.SessionCreationPolicy;
|
||||
@@ -11,6 +12,7 @@ import org.springframework.security.web.SecurityFilterChain;
|
||||
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
|
||||
|
||||
@Configuration
|
||||
@EnableMethodSecurity
|
||||
public class SecurityConfig {
|
||||
|
||||
@Bean
|
||||
@@ -23,6 +25,7 @@ public class SecurityConfig {
|
||||
.sessionManagement(configurer -> configurer.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
||||
.authorizeHttpRequests(authorize -> authorize
|
||||
.requestMatchers(HttpMethod.POST, "/api/session/password-login", "/api/session/register").permitAll()
|
||||
.requestMatchers("/api/admin/**").hasRole("ADMIN")
|
||||
.requestMatchers("/api/**").authenticated()
|
||||
.anyRequest().permitAll()
|
||||
)
|
||||
|
||||
@@ -421,6 +421,7 @@ public class CatalogService {
|
||||
}
|
||||
String userId = UUID.randomUUID().toString();
|
||||
boolean adminManaged = actor.role() == UserRole.ADMIN;
|
||||
String customerNumber = generateNextCustomerNumber();
|
||||
AppUser created = appUserRepository.save(new AppUser(
|
||||
userId,
|
||||
adminManaged ? userId : resolveAccountId(actor),
|
||||
@@ -444,6 +445,7 @@ public class CatalogService {
|
||||
mutation.active(),
|
||||
adminManaged ? normalizeManagedRole(mutation.role()) : UserRole.CUSTOMER,
|
||||
100000L,
|
||||
customerNumber,
|
||||
now,
|
||||
now
|
||||
));
|
||||
@@ -481,6 +483,7 @@ public class CatalogService {
|
||||
? (mutation.role() == null ? normalizeStoredRole(existing.role()) : normalizeManagedRole(mutation.role()))
|
||||
: normalizeStoredRole(existing.role()),
|
||||
existing.nextSampleNumber(),
|
||||
existing.customerNumber(),
|
||||
existing.createdAt(),
|
||||
now
|
||||
));
|
||||
@@ -532,6 +535,7 @@ public class CatalogService {
|
||||
existing.active(),
|
||||
existing.role(),
|
||||
existing.nextSampleNumber(),
|
||||
existing.customerNumber(),
|
||||
existing.createdAt(),
|
||||
LocalDateTime.now()
|
||||
));
|
||||
@@ -578,6 +582,7 @@ public class CatalogService {
|
||||
String address = formatAddress(street, houseNumber, postalCode, city);
|
||||
String displayName = companyName;
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
String customerNumber = generateNextCustomerNumber();
|
||||
|
||||
AppUser created = appUserRepository.save(new AppUser(
|
||||
UUID.randomUUID().toString(),
|
||||
@@ -600,6 +605,7 @@ public class CatalogService {
|
||||
false,
|
||||
UserRole.CUSTOMER,
|
||||
100000L,
|
||||
customerNumber,
|
||||
now,
|
||||
now
|
||||
));
|
||||
@@ -624,6 +630,7 @@ public class CatalogService {
|
||||
false,
|
||||
created.role(),
|
||||
created.nextSampleNumber(),
|
||||
created.customerNumber(),
|
||||
created.createdAt(),
|
||||
created.updatedAt()
|
||||
));
|
||||
@@ -645,6 +652,7 @@ public class CatalogService {
|
||||
removeLegacyUserCodeField();
|
||||
backfillDefaultUserEmails();
|
||||
removeLegacyPortalLoginField();
|
||||
migrateCustomerNumbers();
|
||||
ensureDefaultUser("Administrator", "admin@muh.local", "Admin123!", UserRole.ADMIN);
|
||||
}
|
||||
|
||||
@@ -734,6 +742,7 @@ public class CatalogService {
|
||||
user.bic(),
|
||||
user.active(),
|
||||
normalizeStoredRole(user.role()),
|
||||
user.customerNumber(),
|
||||
user.updatedAt()
|
||||
);
|
||||
}
|
||||
@@ -771,7 +780,8 @@ public class CatalogService {
|
||||
user.bankName(),
|
||||
user.iban(),
|
||||
user.bic(),
|
||||
normalizeStoredRole(user.role())
|
||||
normalizeStoredRole(user.role()),
|
||||
user.customerNumber()
|
||||
);
|
||||
}
|
||||
|
||||
@@ -871,11 +881,64 @@ public class CatalogService {
|
||||
user.active(),
|
||||
normalizeStoredRole(user.role()),
|
||||
user.nextSampleNumber(),
|
||||
user.customerNumber(),
|
||||
user.createdAt(),
|
||||
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(
|
||||
String displayName,
|
||||
String email,
|
||||
@@ -910,6 +973,7 @@ public class CatalogService {
|
||||
true,
|
||||
role,
|
||||
100000L,
|
||||
generateNextCustomerNumber(),
|
||||
now,
|
||||
now
|
||||
));
|
||||
@@ -1037,7 +1101,8 @@ public class CatalogService {
|
||||
String bankName,
|
||||
String iban,
|
||||
String bic,
|
||||
UserRole role
|
||||
UserRole role,
|
||||
String customerNumber
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -1112,6 +1177,7 @@ public class CatalogService {
|
||||
String bic,
|
||||
boolean active,
|
||||
UserRole role,
|
||||
String customerNumber,
|
||||
LocalDateTime updatedAt
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -622,6 +622,7 @@ public class SampleService {
|
||||
actor.active(),
|
||||
actor.role(),
|
||||
sampleNumber + 1,
|
||||
actor.customerNumber(),
|
||||
actor.createdAt(),
|
||||
LocalDateTime.now()
|
||||
));
|
||||
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -67,6 +67,7 @@ export interface UserOption {
|
||||
iban: string | null;
|
||||
bic: string | null;
|
||||
role: UserRole;
|
||||
customerNumber: string | null;
|
||||
}
|
||||
|
||||
export interface SessionResponse {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user