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;
|
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
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.active(),
|
||||||
actor.role(),
|
actor.role(),
|
||||||
sampleNumber + 1,
|
sampleNumber + 1,
|
||||||
|
actor.customerNumber(),
|
||||||
actor.createdAt(),
|
actor.createdAt(),
|
||||||
LocalDateTime.now()
|
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;
|
iban: string | null;
|
||||||
bic: string | null;
|
bic: string | null;
|
||||||
role: UserRole;
|
role: UserRole;
|
||||||
|
customerNumber: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SessionResponse {
|
export interface SessionResponse {
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user