Merge branch 'customer-handling' into 'dev'

Add customer management

See merge request rheinsw/rheinsw-mono-repo!17
This commit was merged in pull request #17.
This commit is contained in:
2025-07-06 08:31:48 +00:00
57 changed files with 2442 additions and 161 deletions

View File

@@ -1,38 +0,0 @@
package dev.rheinsw.shared.entity;
import jakarta.persistence.Column;
import jakarta.persistence.MappedSuperclass;
import jakarta.persistence.PrePersist;
import jakarta.persistence.PreUpdate;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
/**
* Base Entity
*
* @author Thatsaphorn Atchariyaphap
* @since 26.04.25
*/
@Getter
@Setter
@MappedSuperclass
public abstract class BaseEntity {
@Column(name = "createdDateTime", nullable = false, updatable = false)
private LocalDateTime createdDateTime;
@Column(name = "modifiedDateTime")
private LocalDateTime modifiedDateTime;
@PrePersist
protected void onCreate() {
createdDateTime = LocalDateTime.now();
}
@PreUpdate
protected void onUpdate() {
modifiedDateTime = LocalDateTime.now();
}
}

View File

@@ -1,58 +0,0 @@
package dev.rheinsw.shared.entity;
import org.junit.jupiter.api.Test;
import java.time.LocalDateTime;
import static org.junit.jupiter.api.Assertions.*;
class BaseEntityTest {
// Dummy entity for testing
static class DummyEntity extends BaseEntity {
}
@Test
void onCreate_shouldSetCreatedDateTime() {
// Arrange
DummyEntity entity = new DummyEntity();
// Act
entity.onCreate();
// Assert
assertNotNull(entity.getCreatedDateTime(), "createdDateTime should be set");
assertNull(entity.getModifiedDateTime(), "modifiedDateTime should still be null after creation");
}
@Test
void onUpdate_shouldSetModifiedDateTime() {
// Arrange
DummyEntity entity = new DummyEntity();
// Act
entity.onUpdate();
// Assert
assertNotNull(entity.getModifiedDateTime(), "modifiedDateTime should be set");
assertNull(entity.getCreatedDateTime(), "createdDateTime should still be null if onCreate() is not called");
}
@Test
void onCreate_thenOnUpdate_shouldSetBothTimestamps() throws InterruptedException {
// Arrange
DummyEntity entity = new DummyEntity();
// Act
entity.onCreate();
LocalDateTime created = entity.getCreatedDateTime();
Thread.sleep(10); // slight pause to differentiate timestamps
entity.onUpdate();
LocalDateTime modified = entity.getModifiedDateTime();
// Assert
assertNotNull(created);
assertNotNull(modified);
assertTrue(modified.isAfter(created), "modifiedDateTime should be after createdDateTime");
}
}

View File

@@ -15,4 +15,5 @@ spring:
predicates: predicates:
- Path=/api/** - Path=/api/**
filters: filters:
- StripPrefix=1 - StripPrefix=1
- PreserveHostHeader

View File

@@ -75,6 +75,14 @@
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId> <artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency> </dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!-- Tools --> <!-- Tools -->
<!-- https://mvnrepository.com/artifact/org.projectlombok/lombok --> <!-- https://mvnrepository.com/artifact/org.projectlombok/lombok -->
@@ -96,6 +104,17 @@
<groupId>org.flywaydb</groupId> <groupId>org.flywaydb</groupId>
<artifactId>flyway-database-postgresql</artifactId> <artifactId>flyway-database-postgresql</artifactId>
</dependency> </dependency>
<!-- FIX: com.fasterxml.jackson.databind.exc.InvalidDefinitionException: Java 8 date/time type `java.time.Instant` not supported by default: add Module "com.fasterxml.jackson.datatype:jackson-datatype-jsr310" to enable handling (through reference chain: java.util.ArrayList[0]->dev.rheinsw.server.customer.model.records.CustomerNote["createdAt"]) -->
<dependency>
<groupId>com.fasterxml.jackson.datatype</groupId>
<artifactId>jackson-datatype-jsr310</artifactId>
</dependency>
<dependency>
<groupId>com.vladmihalcea</groupId>
<artifactId>hibernate-types-60</artifactId> <!-- for Hibernate 6 -->
<version>2.21.1</version>
</dependency>
<dependency> <dependency>
<groupId>dev.rheinsw</groupId> <groupId>dev.rheinsw</groupId>

View File

@@ -0,0 +1,39 @@
package dev.rheinsw.server.common.controller;
import dev.rheinsw.server.security.session.CurrentSessionProvider;
import dev.rheinsw.server.security.session.model.CurrentSession;
import dev.rheinsw.server.security.user.UserService;
import dev.rheinsw.server.common.entity.User;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Autowired;
/**
* @author Thatsaphorn Atchariyaphap
* @since 04.07.25
*/
@RequiredArgsConstructor
public abstract class AbstractController {
protected CurrentSessionProvider currentSessionProvider;
protected UserService userService;
@Autowired
protected void setCurrentSessionProvider(CurrentSessionProvider currentSessionProvider) {
this.currentSessionProvider = currentSessionProvider;
}
protected CurrentSession getCurrentSession() {
return currentSessionProvider.getCurrentSession();
}
@Autowired
protected void setUserService(UserService userService) {
this.userService = userService;
}
protected User getUserFromCurrentSession() {
return userService.getUserBySession(getCurrentSession());
}
}

View File

@@ -0,0 +1,15 @@
package dev.rheinsw.server.common.controller.exception;
/**
* @author Thatsaphorn Atchariyaphap
* @since 06.07.25
*/
public class ApiException extends RuntimeException {
public ApiException(String message) {
super(message);
}
public ApiException(Throwable cause) {
super(cause);
}
}

View File

@@ -0,0 +1,47 @@
package dev.rheinsw.server.common.controller.exception.handler;
import dev.rheinsw.server.common.controller.exception.ApiException;
import dev.rheinsw.server.common.usecase.exception.UseCaseException;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.time.Instant;
import java.util.List;
/**
* @author Thatsaphorn Atchariyaphap
* @since 06.07.25
*/
@ControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(ApiException.class)
public ResponseEntity<ApiErrorResponse> handleBusinessException(ApiException ex) {
return ResponseEntity.badRequest().body(
new ApiErrorResponse(Instant.now(), ex.getMessage(), List.of(ex.getMessage()))
);
}
@ExceptionHandler(UseCaseException.class)
public ResponseEntity<ApiErrorResponse> handleUseCaseException(UseCaseException ex) {
return ResponseEntity.badRequest().body(
new ApiErrorResponse(Instant.now(), ex.getMessage(), List.of(ex.getMessage()))
);
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ApiErrorResponse> handleGeneric(Exception ex) {
ex.printStackTrace(); // log the stack trace
return ResponseEntity.internalServerError().body(
new ApiErrorResponse(Instant.now(), "Ein unerwarteter Fehler ist aufgetreten", List.of(ex.getMessage()))
);
}
public record ApiErrorResponse(
Instant timestamp,
String message,
List<String> errors
) {
}
}

View File

@@ -0,0 +1,48 @@
package dev.rheinsw.server.common.entity;
import jakarta.persistence.Column;
import jakarta.persistence.EntityListeners;
import jakarta.persistence.MappedSuperclass;
import jakarta.persistence.Version;
import lombok.Getter;
import lombok.Setter;
import org.springframework.data.annotation.CreatedBy;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedBy;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
import java.time.Instant;
/**
* Base Entity
*
* @author Thatsaphorn Atchariyaphap
* @since 26.04.25
*/
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
@Getter
@Setter
public abstract class BaseEntity {
@CreatedDate
@Column(name = "created_at", nullable = false, updatable = false)
protected Instant createdAt;
@LastModifiedDate
@Column(name = "updated_at")
protected Instant updatedAt;
@CreatedBy
@Column(name = "created_by", updatable = false)
protected Long createdBy;
@LastModifiedBy
@Column(name = "updated_by")
protected Long updatedBy;
@Version
@Column(name = "version")
protected Long version;
}

View File

@@ -0,0 +1,68 @@
package dev.rheinsw.server.common.entity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import jakarta.persistence.Version;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import org.springframework.data.annotation.CreatedBy;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedBy;
import org.springframework.data.annotation.LastModifiedDate;
import java.time.Instant;
/**
* @author Thatsaphorn Atchariyaphap
* @since 04.07.25
*/
@Entity
@Table(name = "users")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY) // auto-increment
private Long id;
@Column(nullable = false, unique = true)
private String keycloakId; // the `sub` field from JWT, as a string
@Column(nullable = false, unique = true)
private String username;
@Column(nullable = false)
private String email;
@CreatedDate
@Column(name = "created_at", nullable = false, updatable = false)
protected Instant createdAt;
@LastModifiedDate
@Column(name = "updated_at")
protected Instant updatedAt;
@CreatedBy
@Column(name = "created_by", updatable = false)
protected Long createdBy;
@LastModifiedBy
@Column(name = "updated_by")
protected Long updatedBy;
@Version
@Column(name = "version")
protected Long version;
}

View File

@@ -0,0 +1,13 @@
package dev.rheinsw.server.common.entity.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
/**
* @author Thatsaphorn Atchariyaphap
* @since 04.07.25
*/
@Configuration
@EnableJpaAuditing(auditorAwareRef = "auditorProvider")
public class JpaAuditingConfig {
}

View File

@@ -0,0 +1,15 @@
package dev.rheinsw.server.common.usecase.exception;
/**
* @author Thatsaphorn Atchariyaphap
* @since 06.07.25
*/
public class UseCaseException extends RuntimeException {
public UseCaseException(String message) {
super(message);
}
public UseCaseException(String message, Throwable cause) {
super(message, cause);
}
}

View File

@@ -0,0 +1,79 @@
package dev.rheinsw.server.customer.controller;
import dev.rheinsw.server.common.controller.AbstractController;
import dev.rheinsw.server.customer.dtos.CreateCustomerDto;
import dev.rheinsw.server.customer.dtos.CustomerValidationRequest;
import dev.rheinsw.server.customer.model.Customer;
import dev.rheinsw.server.customer.repository.CustomerRepository;
import dev.rheinsw.server.customer.usecase.LoadCustomerQuery;
import dev.rheinsw.server.customer.usecase.RegisterCustomerUseCase;
import lombok.RequiredArgsConstructor;
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.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Collections;
import java.util.List;
import java.util.UUID;
/**
* @author Thatsaphorn Atchariyaphap
* @since 02.07.25
*/
@RestController
@RequestMapping("/customers")
@RequiredArgsConstructor
public class CustomerController extends AbstractController {
private final CustomerRepository repository;
private final RegisterCustomerUseCase registerCustomerUseCase;
private final LoadCustomerQuery loadCustomerQuery;
@PostMapping
public ResponseEntity<UUID> register(@RequestBody CreateCustomerDto request) {
var currentUser = getUserFromCurrentSession();
var result = registerCustomerUseCase.register(
currentUser,
request.email(),
request.name(),
request.companyName(),
request.phoneNumbers(),
request.street(),
request.zip(),
request.city(),
request.notes()
);
return ResponseEntity.ok(result);
}
@GetMapping("/{id}")
public ResponseEntity<Customer> loadById(@PathVariable UUID id) {
return ResponseEntity.ok(loadCustomerQuery.loadById(id));
}
@GetMapping
public ResponseEntity<List<Customer>> findAll() {
var result = loadCustomerQuery.findAll();
return ResponseEntity.ok(result);
}
@PostMapping("/validate")
public ResponseEntity<List<Customer>> validateCustomer(@RequestBody CustomerValidationRequest request) {
List<Customer> matches = repository.findPotentialDuplicates(
request.email(),
request.companyName(),
request.street(),
request.zip(),
request.city()
);
return ResponseEntity.ok(matches);
}
}

View File

@@ -0,0 +1,22 @@
package dev.rheinsw.server.customer.dtos;
import dev.rheinsw.server.customer.model.records.CustomerNote;
import dev.rheinsw.server.customer.model.records.CustomerPhoneNumber;
import java.util.List;
/**
* @author Thatsaphorn Atchariyaphap
* @since 06.07.25
*/
public record CreateCustomerDto(
String email,
String name,
String companyName,
List<CustomerPhoneNumber> phoneNumbers,
String street,
String zip,
String city,
List<CustomerNote> notes
) {
}

View File

@@ -0,0 +1,14 @@
package dev.rheinsw.server.customer.dtos;
/**
* @author Thatsaphorn Atchariyaphap
* @since 06.07.25
*/
public record CustomerValidationRequest(
String email,
String companyName,
String street,
String zip,
String city
) {
}

View File

@@ -0,0 +1,41 @@
package dev.rheinsw.server.customer.model;
import com.vladmihalcea.hibernate.type.json.JsonType;
import dev.rheinsw.server.common.entity.BaseEntity;
import dev.rheinsw.server.customer.model.records.CustomerNote;
import dev.rheinsw.server.customer.model.records.CustomerPhoneNumber;
import jakarta.persistence.*;
import lombok.*;
import org.hibernate.annotations.Type;
import java.util.List;
import java.util.UUID;
@Entity
@Table(name = "customer")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class Customer extends BaseEntity {
@Id
private UUID id;
private String email;
private String name;
private String companyName;
@Column(name = "phone_numbers", columnDefinition = "jsonb")
@Type(JsonType.class)
private List<CustomerPhoneNumber> phoneNumbers;
private String street;
private String zip;
private String city;
@Column(name = "notes", columnDefinition = "jsonb")
@Type(JsonType.class)
private List<CustomerNote> notes;
}

View File

@@ -0,0 +1,16 @@
package dev.rheinsw.server.customer.model.records;
import java.time.Instant;
/**
* @author Thatsaphorn Atchariyaphap
* @since 02.07.25
*/
public record CustomerNote(
String text,
Long createdBy,
Long updatedBy,
Instant createdAt,
Instant updatedAt
) {
}

View File

@@ -0,0 +1,17 @@
package dev.rheinsw.server.customer.model.records;
import java.time.Instant;
/**
* @author Thatsaphorn Atchariyaphap
* @since 02.07.25
*/
public record CustomerPhoneNumber(
String number,
String note,
Long createdBy,
Long updatedBy,
Instant createdAt,
Instant updatedAt
) {
}

View File

@@ -0,0 +1,37 @@
package dev.rheinsw.server.customer.repository;
import dev.rheinsw.server.customer.model.Customer;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
/**
* @author Thatsaphorn Atchariyaphap
* @since 02.07.25
*/
public interface CustomerRepository extends JpaRepository<Customer, UUID> {
Optional<Customer> findByEmail(String email);
@Query("""
SELECT c FROM Customer c
WHERE LOWER(c.email) = LOWER(:email)
OR LOWER(c.companyName) = LOWER(:companyName)
OR (
LOWER(c.street) = LOWER(:street)
AND LOWER(c.zip) = LOWER(:zip)
AND LOWER(c.city) = LOWER(:city)
)
""")
List<Customer> findPotentialDuplicates(
@Param("email") String email,
@Param("companyName") String companyName,
@Param("street") String street,
@Param("zip") String zip,
@Param("city") String city
);
}

View File

@@ -0,0 +1,78 @@
package dev.rheinsw.server.customer.usecase;
import dev.rheinsw.server.common.controller.exception.ApiException;
import dev.rheinsw.server.common.usecase.exception.UseCaseException;
import dev.rheinsw.server.customer.model.Customer;
import dev.rheinsw.server.customer.model.records.CustomerNote;
import dev.rheinsw.server.customer.model.records.CustomerPhoneNumber;
import dev.rheinsw.server.customer.repository.CustomerRepository;
import dev.rheinsw.server.common.entity.User;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import java.time.Instant;
import java.util.List;
import java.util.UUID;
/**
* @author Thatsaphorn Atchariyaphap
* @since 02.07.25
*/
@Service
@RequiredArgsConstructor
public class CustomerUseCaseImpl implements RegisterCustomerUseCase, LoadCustomerQuery {
private final CustomerRepository repository;
@Override
public UUID register(
User creator,
String email,
String name,
String companyName,
List<CustomerPhoneNumber> phoneNumbers,
String street,
String zip,
String city,
List<CustomerNote> notes) {
if (repository.findByEmail(email).isPresent()) {
throw new UseCaseException("Ein Kunde mit dieser E-Mail-Adresse existiert bereits.");
}
final var now = Instant.now();
var enrichedPhoneNumbers = phoneNumbers.stream()
.map(p -> new CustomerPhoneNumber(p.number(), p.note(), creator.getId(), creator.getId(), now, now))
.toList();
var enrichedNotes = notes.stream()
.map(n -> new CustomerNote(n.text(), creator.getId(), creator.getId(), now, now))
.toList();
Customer customer = Customer.builder()
.id(UUID.randomUUID())
.email(email)
.name(name)
.companyName(companyName)
.phoneNumbers(enrichedPhoneNumbers)
.street(street)
.zip(zip)
.city(city)
.notes(enrichedNotes)
.build();
var result = repository.save(customer);
return result.getId();
}
@Override
public Customer loadById(UUID id) {
return repository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Customer not found: " + id));
}
@Override
public List<Customer> findAll() {
return repository.findAll();
}
}

View File

@@ -0,0 +1,15 @@
package dev.rheinsw.server.customer.usecase;
import dev.rheinsw.server.customer.model.Customer;
import java.util.List;
import java.util.UUID;
/**
* @author Thatsaphorn Atchariyaphap
* @since 02.07.25
*/
public interface LoadCustomerQuery {
Customer loadById(UUID id);
List<Customer> findAll();
}

View File

@@ -0,0 +1,26 @@
package dev.rheinsw.server.customer.usecase;
import dev.rheinsw.server.customer.model.records.CustomerNote;
import dev.rheinsw.server.customer.model.records.CustomerPhoneNumber;
import dev.rheinsw.server.common.entity.User;
import java.util.List;
import java.util.UUID;
/**
* @author Thatsaphorn Atchariyaphap
* @since 02.07.25
*/
public interface RegisterCustomerUseCase {
UUID register(
User creator,
String email,
String name,
String companyName,
List<CustomerPhoneNumber> phoneNumbers,
String street,
String zip,
String city,
List<CustomerNote> notes
);
}

View File

@@ -0,0 +1,47 @@
package dev.rheinsw.server.demo.model;
import dev.rheinsw.server.customer.model.Customer;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import java.time.LocalDate;
import java.time.LocalTime;
import java.util.UUID;
/**
* @author Thatsaphorn Atchariyaphap
* @since 02.07.25
*/
@Entity
@Table(name = "demo")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class Demo {
@Id
private UUID id;
@ManyToOne
@JoinColumn(name = "customer_id")
private Customer customer;
private String name;
private String demoUrl;
private String containerName;
private LocalDate createdDate;
private LocalTime createdTime;
private LocalDate updatedDate;
private LocalTime updatedTime;
}

View File

@@ -0,0 +1,51 @@
package dev.rheinsw.server.demo.model;
import dev.rheinsw.server.customer.model.Customer;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import java.time.LocalDate;
import java.time.LocalTime;
import java.util.UUID;
/**
* @author Thatsaphorn Atchariyaphap
* @since 02.07.25
*/
@Entity
@Table(name = "demo_access")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class DemoAccess {
@Id
private UUID id;
@ManyToOne
@JoinColumn(name = "customer_id")
private Customer customer;
@ManyToOne
@JoinColumn(name = "demo_id")
private Demo demo;
private String codeHash;
private LocalDate codeExpiresDate;
private LocalTime codeExpiresTime;
private boolean used;
private LocalDate createdDate;
private LocalTime createdTime;
private LocalDate updatedDate;
private LocalTime updatedTime;
}

View File

@@ -0,0 +1,43 @@
package dev.rheinsw.server.demo.model;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import java.time.LocalDate;
import java.time.LocalTime;
import java.util.UUID;
/**
* @author Thatsaphorn Atchariyaphap
* @since 02.07.25
*/
@Entity
@Table(name = "demo_access_history")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class DemoAccessHistory {
@Id
private UUID id;
@ManyToOne
@JoinColumn(name = "demo_access_id")
private DemoAccess demoAccess;
private String ipAddress;
private LocalDate accessedDate;
private LocalTime accessedTime;
private LocalDate updatedDate;
private LocalTime updatedTime;
}

View File

@@ -0,0 +1,50 @@
package dev.rheinsw.server.security;
import dev.rheinsw.server.security.session.UserSessionFilter;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter;
import org.springframework.security.oauth2.server.resource.authentication.JwtGrantedAuthoritiesConverter;
import org.springframework.security.web.SecurityFilterChain;
/**
* @author Thatsaphorn Atchariyaphap
* @since 04.07.25
*/
@Configuration
@EnableMethodSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final UserSessionFilter userSessionEnrichmentFilter;
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/public/**").permitAll()
.anyRequest().authenticated()
)
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(jwt -> jwt
.jwtAuthenticationConverter(jwtAuthenticationConverter())
)
).addFilterAfter(userSessionEnrichmentFilter, org.springframework.security.oauth2.server.resource.web.BearerTokenAuthenticationFilter.class);
;
return http.build();
}
@Bean
public JwtAuthenticationConverter jwtAuthenticationConverter() {
JwtGrantedAuthoritiesConverter grantedAuthoritiesConverter = new JwtGrantedAuthoritiesConverter();
grantedAuthoritiesConverter.setAuthoritiesClaimName("realm_access.roles");
grantedAuthoritiesConverter.setAuthorityPrefix("ROLE_");
JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
converter.setJwtGrantedAuthoritiesConverter(grantedAuthoritiesConverter);
return converter;
}
}

View File

@@ -0,0 +1,35 @@
package dev.rheinsw.server.security.audit;
import dev.rheinsw.server.security.session.CurrentSessionProvider;
import dev.rheinsw.server.security.user.UserService;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.domain.AuditorAware;
import java.util.Optional;
/**
* @author Thatsaphorn Atchariyaphap
* @since 04.07.25
*/
@Configuration
@RequiredArgsConstructor
public class AuditorConfig {
private final CurrentSessionProvider sessionProvider;
private final UserService userService;
@Bean
public AuditorAware<Long> auditorProvider() {
return () -> {
try {
var session = sessionProvider.getCurrentSession();
var user = userService.getUserBySession(session);
return Optional.of(user.getId());
} catch (Exception e) {
return Optional.empty(); // anonymous access (public endpoints)
}
};
}
}

View File

@@ -0,0 +1,28 @@
package dev.rheinsw.server.security.session;
import dev.rheinsw.server.security.session.model.CurrentSession;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.stereotype.Component;
/**
* @author Thatsaphorn Atchariyaphap
* @since 04.07.25
*/
@Component
public class CurrentSessionProvider {
public CurrentSession getCurrentSession() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (!(auth.getPrincipal() instanceof Jwt jwt)) {
throw new IllegalStateException("JWT is missing or invalid");
}
return new CurrentSession(
jwt.getClaimAsString("sub"),
jwt.getClaimAsString("preferred_username"),
jwt.getClaimAsString("email")
);
}
}

View File

@@ -0,0 +1,40 @@
package dev.rheinsw.server.security.session;
import dev.rheinsw.server.security.session.model.CurrentSession;
import dev.rheinsw.server.security.user.UserService;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.NonNull;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
/**
* @author Thatsaphorn Atchariyaphap
* @since 04.07.25
*/
@Component
@RequiredArgsConstructor
public class UserSessionFilter extends OncePerRequestFilter {
private final UserService userService;
private final CurrentSessionProvider currentSessionProvider;
@Override
protected void doFilterInternal(@NonNull HttpServletRequest request,
@NonNull HttpServletResponse response,
@NonNull FilterChain filterChain) throws ServletException, IOException {
try {
CurrentSession session = currentSessionProvider.getCurrentSession();
userService.getUserBySession(session);
} catch (Exception e) {
// You might want to log this but not block the request
}
filterChain.doFilter(request, response);
}
}

View File

@@ -0,0 +1,10 @@
package dev.rheinsw.server.security.session.model;
/**
* Current authenticated keycloak session
*
* @author Thatsaphorn Atchariyaphap
* @since 04.07.25
*/
public record CurrentSession(String keycloakId, String username, String email) {
}

View File

@@ -0,0 +1,38 @@
package dev.rheinsw.server.security.user;
import dev.rheinsw.server.common.entity.User;
import dev.rheinsw.server.security.session.model.CurrentSession;
import dev.rheinsw.server.security.user.repository.UserRepository;
import jakarta.transaction.Transactional;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.time.Instant;
/**
* @author Thatsaphorn Atchariyaphap
* @since 04.07.25
*/
@Service
@RequiredArgsConstructor
public class UserService {
private final UserRepository userRepository;
@Transactional
public User getUserBySession(CurrentSession session) {
return userRepository.findByKeycloakId(session.keycloakId())
.orElseGet(() -> createUser(session));
}
private User createUser(CurrentSession session) {
User newUser = User.builder()
.keycloakId(session.keycloakId())
.username(session.username())
.email(session.email())
.createdAt(Instant.now())
.build();
return userRepository.save(newUser);
}
}

View File

@@ -0,0 +1,14 @@
package dev.rheinsw.server.security.user.repository;
import dev.rheinsw.server.common.entity.User;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
/**
* @author Thatsaphorn Atchariyaphap
* @since 04.07.25
*/
public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByKeycloakId(String keycloakId);
}

View File

@@ -4,6 +4,13 @@ server:
spring: spring:
application: application:
name: server name: server
security:
oauth2:
resourceserver:
jwt:
issuer-uri: https://sso.rhein-software.dev/realms/rhein-sw
datasource: datasource:
url: jdbc:postgresql://${DB_HOST}:${DB_PORT}/${DB_NAME} url: jdbc:postgresql://${DB_HOST}:${DB_PORT}/${DB_NAME}
username: ${DB_USERNAME} username: ${DB_USERNAME}

View File

@@ -1,31 +1,98 @@
create table api_key -- Enable UUID extension
CREATE
EXTENSION IF NOT EXISTS "uuid-ossp";
-- 0. USERS
CREATE TABLE users
( (
id bigint generated by default as identity id BIGSERIAL PRIMARY KEY,
constraint pk_api_key keycloak_id VARCHAR(255) NOT NULL UNIQUE,
primary key, username VARCHAR(255) NOT NULL UNIQUE,
key varchar(255) not null email VARCHAR(255) NOT NULL,
constraint uc_api_key_key created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
unique, updated_at TIMESTAMPTZ,
type varchar(255) not null, created_by BIGINT REFERENCES users (id),
enabled boolean not null, updated_by BIGINT REFERENCES users (id),
frontend_only boolean not null, version BIGINT
description text,
created_date date default CURRENT_DATE,
created_time time default CURRENT_TIME,
modified_date date,
modified_time time
); );
-- 1. CONTACT REQUESTS
CREATE TABLE contact_requests CREATE TABLE contact_requests
( (
id BIGSERIAL PRIMARY KEY, id BIGSERIAL PRIMARY KEY,
name VARCHAR(100), name VARCHAR(100),
email VARCHAR(100), email VARCHAR(100),
message VARCHAR(1000), message VARCHAR(1000),
company VARCHAR(100), company VARCHAR(100),
phone VARCHAR(20), phone VARCHAR(20),
website VARCHAR(100), website VARCHAR(100),
captcha_token VARCHAR(1024), captcha_token VARCHAR(1024),
submitted_date DATE, submitted_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
submitted_time TIME );
);
-- 2. CUSTOMER
CREATE TABLE customer
(
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
email TEXT NOT NULL UNIQUE,
name TEXT,
company_name TEXT,
phone_numbers JSONB,
street TEXT,
zip TEXT,
city TEXT,
notes JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ,
created_by BIGINT REFERENCES users (id),
updated_by BIGINT REFERENCES users (id),
version BIGINT
);
CREATE INDEX idx_customer_email ON customer (email);
-- 3. DEMO
CREATE TABLE demo
(
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
customer_id UUID NOT NULL REFERENCES customer (id) ON DELETE CASCADE,
name TEXT NOT NULL,
demo_url TEXT NOT NULL,
container_name TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ,
created_by BIGINT REFERENCES users (id),
updated_by BIGINT REFERENCES users (id),
version BIGINT
);
CREATE INDEX idx_demo_customer_id ON demo (customer_id);
-- 4. DEMO ACCESS
CREATE TABLE demo_access
(
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
customer_id UUID NOT NULL REFERENCES customer (id) ON DELETE CASCADE,
demo_id UUID NOT NULL REFERENCES demo (id) ON DELETE CASCADE,
code_hash TEXT NOT NULL,
code_expires_at TIMESTAMPTZ NOT NULL,
used BOOLEAN DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ,
created_by BIGINT REFERENCES users (id),
updated_by BIGINT REFERENCES users (id),
version BIGINT
);
CREATE INDEX idx_demo_access_demo_id ON demo_access (demo_id);
-- 5. DEMO ACCESS HISTORY
CREATE TABLE demo_access_history
(
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
demo_access_id UUID NOT NULL REFERENCES demo_access (id) ON DELETE CASCADE,
ip_address TEXT NOT NULL,
accessed_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ,
created_by BIGINT REFERENCES users (id),
updated_by BIGINT REFERENCES users (id),
version BIGINT
);

View File

@@ -0,0 +1,4 @@
export const customerRoutes = {
create: "/customers",
validate: "/customers/validate",
};

View File

@@ -0,0 +1,14 @@
import {NextRequest, NextResponse} from "next/server";
import {serverCall} from "@/lib/api/serverCall";
export async function GET() {
const data = await serverCall("/customers", "GET");
const customers = await data.json();
return NextResponse.json(customers);
}
export async function POST(req: NextRequest) {
const body = await req.json()
const result = await serverCall("/customers", "POST", body);
return NextResponse.json(result.json());
}

View File

@@ -0,0 +1,196 @@
"use client";
import {useState, useEffect, useMemo} from "react";
import {useRouter} from "next/navigation";
import {Button} from "@/components/ui/button";
import {Input} from "@/components/ui/input";
import {Card, CardContent} from "@/components/ui/card";
import {
Pagination,
PaginationContent,
PaginationItem,
PaginationNext,
PaginationPrevious
} from "@/components/ui/pagination";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow
} from "@/components/ui/table";
import {motion} from "framer-motion";
import {ArrowRight} from "lucide-react";
import {NewCustomerModal} from "@/components/customers/modal/NewCustomerModal";
import axios from "axios";
export interface CustomerPhoneNumber {
number: string;
note: string;
creator: string;
lastModifier: string;
createdAt: string;
updatedAt: string;
}
export interface CustomerNote {
text: string;
creator: string;
lastModifier: string;
createdAt: string;
updatedAt: string;
}
export interface Customer {
id: string;
email: string;
name: string;
companyName: string;
phoneNumbers: CustomerPhoneNumber[];
street: string;
zip: string;
city: string;
notes: CustomerNote[];
createdAt: string;
}
export default function CustomersPage() {
const router = useRouter();
const [customers, setCustomers] = useState<Customer[]>([]);
const [search, setSearch] = useState("");
const [loading, setLoading] = useState(true);
const [page, setPage] = useState(1);
const pageSize = 15;
useEffect(() => {
axios.get("/api/customers").then((res) => {
setCustomers(res.data);
setLoading(false);
});
}, []);
const filtered = useMemo(() => {
return customers.filter(
(c) =>
c.name.toLowerCase().includes(search.toLowerCase()) ||
c.email.toLowerCase().includes(search.toLowerCase())
);
}, [customers, search]);
const paginated = useMemo(() => {
const start = (page - 1) * pageSize;
return filtered.slice(start, start + pageSize);
}, [filtered, page]);
const totalPages = Math.ceil(filtered.length / pageSize);
return (
<div className="p-6 space-y-6 text-sm overflow-x-auto">
<motion.div
initial={{opacity: 0}}
animate={{opacity: 1}}
transition={{duration: 0.3}}
className="grid grid-cols-1 md:grid-cols-3 gap-4"
>
<Card>
<CardContent>
<div className="text-sm text-muted-foreground">Kunden</div>
<div className="text-3xl font-bold">{customers.length}</div>
</CardContent>
</Card>
<Card>
<CardContent>
<div className="text-sm text-muted-foreground">Demo-Statistik</div>
<div className="text-3xl font-bold"></div>
</CardContent>
</Card>
<Card>
<CardContent>
<div className="text-sm text-muted-foreground">Letzte Aktivität</div>
<div className="text-3xl font-bold"></div>
</CardContent>
</Card>
</motion.div>
<motion.div initial={{opacity: 0}} animate={{opacity: 1}} transition={{duration: 0.3}}>
<Card>
<CardContent className="space-y-6">
<div className="flex flex-col md:flex-row justify-between items-center gap-4">
<Input placeholder="Suche" value={search} onChange={(e) => setSearch(e.target.value)}/>
<NewCustomerModal/>
</div>
{customers.length === 0 && loading ? (
<div className="text-center text-muted-foreground">Lade Kunden...</div>
) : (
<div className="overflow-x-auto">
<Table className="text-xs min-w-[700px]">
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>E-Mail</TableHead>
<TableHead>Firma</TableHead>
<TableHead>Telefon</TableHead>
<TableHead>Straße</TableHead>
<TableHead>PLZ</TableHead>
<TableHead>Ort</TableHead>
<TableHead>Erstellt am</TableHead>
<TableHead>Aktionen</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{paginated.map((customer) => (
<TableRow key={customer.id}>
<TableCell
className="max-w-[180px] truncate">{customer.name}</TableCell>
<TableCell
className="max-w-[180px] truncate">{customer.email}</TableCell>
<TableCell
className="max-w-[180px] truncate">{customer.companyName}</TableCell>
<TableCell className="max-w-[140px] truncate">
{customer.phoneNumbers?.[0]?.number}
</TableCell>
<TableCell
className="max-w-[180px] truncate">{customer.street}</TableCell>
<TableCell>{customer.zip}</TableCell>
<TableCell>{customer.city}</TableCell>
<TableCell>{new Date(customer.createdAt).toLocaleString()}</TableCell>
<TableCell>
<Button
variant="ghost"
size="icon"
onClick={() => router.push(`/customers/${customer.id}`)}
>
<ArrowRight className="w-4 h-4"/>
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
<Pagination className="justify-center pt-4">
<PaginationContent>
<PaginationItem>
<PaginationPrevious
onClick={() => setPage((p) => Math.max(1, p - 1))}
className={page === 1 ? "pointer-events-none opacity-50" : ""}
/>
</PaginationItem>
<PaginationItem>
<PaginationNext
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
className={page === totalPages ? "pointer-events-none opacity-50" : ""}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
</CardContent>
</Card>
</motion.div>
</div>
);
}

View File

@@ -41,22 +41,21 @@ export default async function RootLayout({
} }
> >
<AppSidebar/> <AppSidebar/>
<main>
<SidebarInset> <SidebarInset>
<header <header
className="flex h-16 shrink-0 items-center gap-2 transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-12"> className="flex h-16 shrink-0 items-center gap-2 transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-12">
<div className="flex items-center gap-2 px-4"> <div className="flex items-center gap-2 px-4">
<SidebarTrigger className="-ml-1"/> <SidebarTrigger className="-ml-1"/>
<Separator <Separator
orientation="vertical" orientation="vertical"
className="mr-2 data-[orientation=vertical]:h-4" className="mr-2 data-[orientation=vertical]:h-4"
/> />
<DynamicBreadcrumb/> <DynamicBreadcrumb/>
</div> </div>
</header> </header>
</SidebarInset>
{children} {children}
</main> </SidebarInset>
</SidebarProvider> </SidebarProvider>
) : ( ) : (
<LoginScreen/> <LoginScreen/>

View File

@@ -5,7 +5,7 @@ import {
Home, Home,
Scale, Scale,
User2, User2,
Settings Settings, LayoutDashboard
} from "lucide-react"; } from "lucide-react";
import { import {
@@ -50,6 +50,14 @@ const rheinItems = [
}, },
]; ];
const customerItems = [
{
title: "Kundenübersicht",
url: "/customers",
icon: LayoutDashboard,
},
];
export function AppSidebar() { export function AppSidebar() {
return ( return (
<Sidebar> <Sidebar>
@@ -77,6 +85,27 @@ export function AppSidebar() {
</SidebarGroupContent> </SidebarGroupContent>
</SidebarGroup> </SidebarGroup>
<SidebarGroup>
<SidebarGroupLabel>Kunden</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu className="flex flex-col gap-y-1">
{customerItems.map((item) => (
<SidebarMenuItem key={item.title}>
<SidebarMenuButton
asChild
className="hover:bg-accent hover:text-accent-foreground"
>
<a href={item.url}>
<item.icon/>
<span>{item.title}</span>
</a>
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
{/* Demos section */} {/* Demos section */}
<SidebarGroup> <SidebarGroup>
<SidebarGroupLabel>Demos</SidebarGroupLabel> <SidebarGroupLabel>Demos</SidebarGroupLabel>

View File

@@ -0,0 +1,280 @@
"use client";
import {useState} from "react";
import {motion} from "framer-motion";
import {Trash2} from "lucide-react";
import {Button} from "@/components/ui/button";
import {Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger,} from "@/components/ui/dialog";
import {Label} from "@/components/ui/label";
import {Input} from "@/components/ui/input";
import {Textarea} from "@/components/ui/textarea";
import {Progress} from "@/components/ui/progress";
import {Card, CardContent, CardHeader} from "@/components/ui/card";
import {CreateCustomerDto, NoteDto, PhoneNumberDto} from "@/services/customers/dtos/createCustomer.dto";
import {addCustomer} from "@/services/customers/usecases/addCustomer";
import {validateCustomer} from "@/services/customers/usecases/validateCustomer";
export function NewCustomerModal() {
const [step, setStep] = useState(1);
const [open, setOpen] = useState(false);
const [email, setEmail] = useState("");
const [name, setName] = useState("");
const [companyName, setCompanyName] = useState("");
const [phoneNumbers, setPhoneNumbers] = useState<PhoneNumberDto[]>([{note: "", number: ""}]);
const [notes, setNotes] = useState<NoteDto[]>([{text: ""}]);
const [street, setStreet] = useState("");
const [zip, setZip] = useState("");
const [city, setCity] = useState("");
const [matches, setMatches] = useState<CustomerMatch[]>([]);
const [showDetailModal, setShowDetailModal] = useState(false);
const [selectedCustomer] = useState<CustomerMatch | null>(null);
type CustomerMatch = {
id: string;
name: string;
email: string;
companyName: string;
street: string;
zip: string;
city: string;
};
const emailExists = matches.some(m => m.email.toLowerCase() === email.toLowerCase());
const companyExists = matches.some(m => m.companyName.toLowerCase() === companyName.toLowerCase());
const addressExists = matches.some(m =>
m.street.toLowerCase() === street.toLowerCase() &&
m.zip.toLowerCase() === zip.toLowerCase() &&
m.city.toLowerCase() === city.toLowerCase()
);
const validateField = async () => {
try {
const result = await validateCustomer({email, companyName, street, zip, city});
setMatches(result);
} catch (err) {
console.error("Validation failed", err);
}
};
const handleSubmit = async () => {
if (!email || !name || !companyName || !street || !zip || !city) return;
const payload: CreateCustomerDto = {email, name, companyName, street, zip, city, phoneNumbers, notes};
await addCustomer(payload);
location.reload();
};
const renderFormInput = (
label: string,
value: string,
onChange: (value: string) => void,
error?: boolean,
className?: string
) => (
<div className="space-y-2">
<Label>{label} *</Label>
<Input
value={value}
onChange={(e) => onChange(e.target.value)}
onBlur={validateField}
className={error ? "border border-red-500" : className}
/>
</div>
);
const renderCustomerInfo = (customer: CustomerMatch) => (
<div className="space-y-1 text-sm">
<div><strong>Name:</strong> {customer.name}</div>
<div><strong>Firma:</strong> {customer.companyName}</div>
<div><strong>E-Mail:</strong> {customer.email}</div>
<div><strong>Adresse:</strong> {customer.street}, {customer.zip} {customer.city}</div>
</div>
);
const updatePhoneNumber = (i: number, key: keyof PhoneNumberDto, value: string) => {
const updated = [...phoneNumbers];
updated[i][key] = value;
setPhoneNumbers(updated);
};
const removePhoneNumber = (i: number) => {
setPhoneNumbers(phoneNumbers.filter((_, idx) => idx !== i));
};
const updateNote = (i: number, value: string) => {
const updated = [...notes];
updated[i].text = value;
setNotes(updated);
};
const renderStepOne = () => (
<div className="space-y-4">
<div className="space-y-2">
{renderFormInput("Name", name, setName)}
{renderFormInput("Firma", companyName, setCompanyName, companyExists)}
{renderFormInput("E-Mail", email, setEmail, emailExists)}
{emailExists && (
<div className="text-red-500 text-sm">Ein Kunde mit dieser E-Mail existiert bereits.</div>
)}
</div>
<div className="space-y-2">
<Label>Telefonnummern *</Label>
{phoneNumbers.map((p, i) => (
<div key={i} className="grid grid-cols-[2fr_3fr_auto] gap-2 items-center w-full">
<Input
placeholder="Nummer"
value={p.number}
onChange={(e) => updatePhoneNumber(i, "number", e.target.value)}
/>
<Input
placeholder="Notiz"
value={p.note}
onChange={(e) => updatePhoneNumber(i, "note", e.target.value)}
/>
<Button variant="ghost" size="icon" onClick={() => removePhoneNumber(i)}>
<Trash2 className="w-4 h-4"/>
</Button>
</div>
))}
<Button
variant="outline"
size="sm"
onClick={() => setPhoneNumbers([...phoneNumbers, {note: "", number: ""}])}
>
+ Telefonnummer hinzufügen
</Button>
</div>
</div>
);
const renderStepTwo = () => (
<div className="space-y-4">
<div className="grid grid-cols-12 gap-4">
<div className="col-span-6">
{renderFormInput("Straße", street, setStreet, addressExists)}
</div>
<div className="col-span-3">
{renderFormInput("PLZ", zip, setZip, addressExists)}
</div>
<div className="col-span-3">
{renderFormInput("Ort", city, setCity, addressExists)}
</div>
</div>
<div className="space-y-2">
<Label>Notizen</Label>
{notes.map((note, i) => (
<div key={i} className="flex items-start gap-2">
<Textarea
placeholder={`Notiz ${i + 1}`}
value={note.text}
rows={3}
className="w-full resize-y"
onChange={(e) => updateNote(i, e.target.value)}
/>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => setNotes(notes.filter((_, idx) => idx !== i))}
>
<Trash2 className="w-4 h-4"/>
</Button>
</div>
))}
<Button
type="button"
onClick={() => setNotes([...notes, {text: ""}])}
variant="outline"
size="sm"
>
+ Notiz hinzufügen
</Button>
</div>
</div>
);
const renderDuplicationCard = () => (
<motion.div initial={{opacity: 0}} animate={{opacity: 1}} transition={{duration: 0.3}} className="mt-6">
<Card
className={emailExists ? "border-red-500 bg-red-100 dark:bg-red-900/20" : "border-yellow-500 bg-yellow-100 dark:bg-yellow-900/20"}>
<CardHeader className="text-sm font-semibold">
{matches.length} mögliche Duplikate gefunden
</CardHeader>
<CardContent>
<Button variant="outline" size="sm" className="w-full"
onClick={() => setShowDetailModal(true)}>Details</Button>
</CardContent>
</Card>
</motion.div>
);
return (
<>
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button onClick={() => {
setOpen(true);
setStep(1);
}}>Neue Kunde</Button>
</DialogTrigger>
<DialogContent className="w-full max-w-5xl max-h-[95vh] overflow-y-auto">
<DialogHeader><DialogTitle>Neuen Kunden anlegen</DialogTitle></DialogHeader>
<div className="mb-4">
<div className="text-xs font-semibold mb-2">Schritt {step} von 2</div>
<Progress value={step === 1 ? 50 : 100}/>
</div>
{step === 1 ? renderStepOne() : renderStepTwo()}
{matches.length > 0 && renderDuplicationCard()}
<div className="flex justify-between mt-6">
<Button variant="secondary" disabled={step === 1} onClick={() => setStep(step - 1)}>
Zurück
</Button>
{step === 1 ? (
<Button
onClick={() => setStep(2)}
disabled={!email || !name || !companyName || phoneNumbers.length === 0 || !phoneNumbers.some(p => p.number.trim()) || emailExists}
>
Weiter
</Button>
) : (
<Button onClick={handleSubmit} disabled={!street || !zip || !city}>
Anlegen
</Button>
)}
</div>
</DialogContent>
</Dialog>
<Dialog open={showDetailModal} onOpenChange={setShowDetailModal}>
<DialogContent className="max-w-3xl max-h-[90vh] overflow-y-auto space-y-4">
<DialogHeader>
<DialogTitle>Kundendetails</DialogTitle>
</DialogHeader>
{matches.length === 1 ? (
renderCustomerInfo(matches[0])
) : (
<div className="space-y-4">
{matches.map((customer) => (
<Card key={customer.id}>
<CardContent className="pt-4">
{renderCustomerInfo(customer)}
</CardContent>
</Card>
))}
{selectedCustomer && (
<motion.div
initial={{opacity: 0, y: 10}}
animate={{opacity: 1, y: 0}}
className="p-4 border rounded-md bg-muted"
>
{renderCustomerInfo(selectedCustomer)}
</motion.div>
)}
</div>
)}
</DialogContent>
</Dialog>
</>
);
}

View File

@@ -0,0 +1,143 @@
"use client"
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { XIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean
}) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
)
}
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
)
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}

View File

@@ -0,0 +1,24 @@
"use client"
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cn } from "@/lib/utils"
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }

View File

@@ -0,0 +1,127 @@
import * as React from "react"
import {
ChevronLeftIcon,
ChevronRightIcon,
MoreHorizontalIcon,
} from "lucide-react"
import { cn } from "@/lib/utils"
import { Button, buttonVariants } from "@/components/ui/button"
function Pagination({ className, ...props }: React.ComponentProps<"nav">) {
return (
<nav
role="navigation"
aria-label="pagination"
data-slot="pagination"
className={cn("mx-auto flex w-full justify-center", className)}
{...props}
/>
)
}
function PaginationContent({
className,
...props
}: React.ComponentProps<"ul">) {
return (
<ul
data-slot="pagination-content"
className={cn("flex flex-row items-center gap-1", className)}
{...props}
/>
)
}
function PaginationItem({ ...props }: React.ComponentProps<"li">) {
return <li data-slot="pagination-item" {...props} />
}
type PaginationLinkProps = {
isActive?: boolean
} & Pick<React.ComponentProps<typeof Button>, "size"> &
React.ComponentProps<"a">
function PaginationLink({
className,
isActive,
size = "icon",
...props
}: PaginationLinkProps) {
return (
<a
aria-current={isActive ? "page" : undefined}
data-slot="pagination-link"
data-active={isActive}
className={cn(
buttonVariants({
variant: isActive ? "outline" : "ghost",
size,
}),
className
)}
{...props}
/>
)
}
function PaginationPrevious({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) {
return (
<PaginationLink
aria-label="Go to previous page"
size="default"
className={cn("gap-1 px-2.5 sm:pl-2.5", className)}
{...props}
>
<ChevronLeftIcon />
<span className="hidden sm:block">Previous</span>
</PaginationLink>
)
}
function PaginationNext({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) {
return (
<PaginationLink
aria-label="Go to next page"
size="default"
className={cn("gap-1 px-2.5 sm:pr-2.5", className)}
{...props}
>
<span className="hidden sm:block">Next</span>
<ChevronRightIcon />
</PaginationLink>
)
}
function PaginationEllipsis({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
aria-hidden
data-slot="pagination-ellipsis"
className={cn("flex size-9 items-center justify-center", className)}
{...props}
>
<MoreHorizontalIcon className="size-4" />
<span className="sr-only">More pages</span>
</span>
)
}
export {
Pagination,
PaginationContent,
PaginationLink,
PaginationItem,
PaginationPrevious,
PaginationNext,
PaginationEllipsis,
}

View File

@@ -0,0 +1,31 @@
"use client"
import * as React from "react"
import * as ProgressPrimitive from "@radix-ui/react-progress"
import { cn } from "@/lib/utils"
function Progress({
className,
value,
...props
}: React.ComponentProps<typeof ProgressPrimitive.Root>) {
return (
<ProgressPrimitive.Root
data-slot="progress"
className={cn(
"bg-primary/20 relative h-2 w-full overflow-hidden rounded-full",
className
)}
{...props}
>
<ProgressPrimitive.Indicator
data-slot="progress-indicator"
className="bg-primary h-full w-full flex-1 transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
)
}
export { Progress }

View File

@@ -0,0 +1,116 @@
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
function Table({ className, ...props }: React.ComponentProps<"table">) {
return (
<div
data-slot="table-container"
className="relative w-full overflow-x-auto"
>
<table
data-slot="table"
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
)
}
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
return (
<thead
data-slot="table-header"
className={cn("[&_tr]:border-b", className)}
{...props}
/>
)
}
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
return (
<tbody
data-slot="table-body"
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
)
}
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
return (
<tfoot
data-slot="table-footer"
className={cn(
"bg-muted/50 border-t font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
)
}
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
return (
<tr
data-slot="table-row"
className={cn(
"hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors",
className
)}
{...props}
/>
)
}
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
return (
<th
data-slot="table-head"
className={cn(
"text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
)
}
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
return (
<td
data-slot="table-cell"
className={cn(
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
)
}
function TableCaption({
className,
...props
}: React.ComponentProps<"caption">) {
return (
<caption
data-slot="table-caption"
className={cn("text-muted-foreground mt-4 text-sm", className)}
{...props}
/>
)
}
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}

View File

@@ -0,0 +1,18 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
{...props}
/>
)
}
export { Textarea }

View File

@@ -0,0 +1,28 @@
// lib/api/callApi.ts
import {serverCall} from "@/lib/api/serverCall";
export async function callApi<TResponse, TRequest = unknown>(
path: string,
method: "GET" | "POST" | "PUT" | "DELETE" | "PATCH",
body?: TRequest
): Promise<TResponse> {
const res = await serverCall(path, method, body);
const contentType = res.headers.get("content-type") ?? "";
const isJson = contentType.includes("application/json");
const rawBody = isJson ? await res.json() : await res.text();
console.log(`[api ${path}] Response:`, res.status, rawBody);
if (!res.ok) {
const errorMessage = isJson
? (rawBody?.message ?? rawBody?.errors?.join(", ")) ?? "Unbekannter Fehler"
: String(rawBody);
console.error(`[api ${path}] Error:`, errorMessage);
throw new Error(errorMessage);
}
return rawBody as TResponse;
}

View File

@@ -0,0 +1,28 @@
// lib/callBackendApi.ts
import {getServerSession} from "next-auth";
import {authOptions} from "@/lib/auth/authOptions";
export async function serverCall(
path: string,
method: "GET" | "POST" | "PUT" | "DELETE" | "PATCH",
body?: unknown
): Promise<Response> {
const url = `${process.env.INTERNAL_BACKEND_URL ?? "http://localhost:8080/api"}${path}`;
const session = await getServerSession(authOptions);
const headers: Record<string, string> = {
"Content-Type": "application/json",
};
if (session != null) {
headers["Authorization"] = `Bearer ${session.accessToken}`;
}
console.log("[api] Calling backend API: ", method, url, body);
return fetch(url, {
method,
headers,
body: body ? JSON.stringify(body) : undefined,
});
}

View File

@@ -15,10 +15,10 @@ const {
NEXTAUTH_SECRET, NEXTAUTH_SECRET,
} = process.env; } = process.env;
if (!KEYCLOAK_CLIENT_ID) throw new Error("Missing KEYCLOAK_CLIENT_ID"); // if (!KEYCLOAK_CLIENT_ID) throw new Error("Missing KEYCLOAK_CLIENT_ID");
if (!KEYCLOAK_CLIENT_SECRET) throw new Error("Missing KEYCLOAK_CLIENT_SECRET"); // if (!KEYCLOAK_CLIENT_SECRET) throw new Error("Missing KEYCLOAK_CLIENT_SECRET");
if (!KEYCLOAK_ISSUER) throw new Error("Missing KEYCLOAK_ISSUER"); // if (!KEYCLOAK_ISSUER) throw new Error("Missing KEYCLOAK_ISSUER");
if (!NEXTAUTH_SECRET) throw new Error("Missing NEXTAUTH_SECRET"); // if (!NEXTAUTH_SECRET) throw new Error("Missing NEXTAUTH_SECRET");
console.log("[auth] Using Keycloak provider:"); console.log("[auth] Using Keycloak provider:");
console.log(" - Client ID:", KEYCLOAK_CLIENT_ID); console.log(" - Client ID:", KEYCLOAK_CLIENT_ID);
@@ -42,8 +42,8 @@ async function isTokenValid(token: string): Promise<boolean> {
export const authOptions: NextAuthOptions = { export const authOptions: NextAuthOptions = {
providers: [ providers: [
KeycloakProvider({ KeycloakProvider({
clientId: KEYCLOAK_CLIENT_ID, clientId: KEYCLOAK_CLIENT_ID as string,
clientSecret: KEYCLOAK_CLIENT_SECRET, clientSecret: KEYCLOAK_CLIENT_SECRET as string,
issuer: KEYCLOAK_ISSUER, issuer: KEYCLOAK_ISSUER,
}), }),
], ],

View File

@@ -4,5 +4,6 @@ export const breadcrumbMap: Record<string, string> = {
'settings': 'Settings', 'settings': 'Settings',
'demo': 'Demo', 'demo': 'Demo',
'users': 'User Management', 'users': 'User Management',
'customers': 'Kundenübersicht',
// Add more mappings as needed // Add more mappings as needed
}; };

View File

@@ -0,0 +1,16 @@
// lib/ui/showError.ts
import {toast} from "sonner";
export function showError(error: unknown, fallback = "Ein unbekannter Fehler ist aufgetreten") {
let message: string;
if (error instanceof Error) {
message = error.message;
} else if (typeof error === "string") {
message = error;
} else {
message = fallback;
}
toast.error(message);
}

View File

@@ -11,9 +11,12 @@
"@radix-ui/react-collapsible": "^1.1.11", "@radix-ui/react-collapsible": "^1.1.11",
"@radix-ui/react-dialog": "^1.1.14", "@radix-ui/react-dialog": "^1.1.14",
"@radix-ui/react-dropdown-menu": "^2.1.15", "@radix-ui/react-dropdown-menu": "^2.1.15",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-progress": "^1.1.7",
"@radix-ui/react-separator": "^1.1.7", "@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-tooltip": "^1.2.7", "@radix-ui/react-tooltip": "^1.2.7",
"axios": "^1.10.0",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"framer-motion": "^12.22.0", "framer-motion": "^12.22.0",
@@ -24,7 +27,9 @@
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"tailwind-merge": "^3.3.1" "sonner": "^2.0.6",
"tailwind-merge": "^3.3.1",
"zustand": "^5.0.6"
}, },
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "^3", "@eslint/eslintrc": "^3",
@@ -1308,6 +1313,29 @@
} }
} }
}, },
"node_modules/@radix-ui/react-label": {
"version": "2.1.7",
"resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.7.tgz",
"integrity": "sha512-YT1GqPSL8kJn20djelMX7/cTRp/Y9w5IZHvfxQTVHrOqa2yMl7i/UfMqKRU5V7mEyKTrUVgJXhNQPVCG8PBLoQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-primitive": "2.1.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-menu": { "node_modules/@radix-ui/react-menu": {
"version": "2.1.15", "version": "2.1.15",
"resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.15.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.15.tgz",
@@ -1451,6 +1479,30 @@
} }
} }
}, },
"node_modules/@radix-ui/react-progress": {
"version": "1.1.7",
"resolved": "https://registry.npmjs.org/@radix-ui/react-progress/-/react-progress-1.1.7.tgz",
"integrity": "sha512-vPdg/tF6YC/ynuBIJlk1mm7Le0VgW6ub6J2UWnTQ7/D23KXcPI1qy+0vBkgKgd38RCMJavBXpB83HPNFMTb0Fg==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-primitive": "2.1.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-roving-focus": { "node_modules/@radix-ui/react-roving-focus": {
"version": "1.1.10", "version": "1.1.10",
"resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.10.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.10.tgz",
@@ -2892,6 +2944,12 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"license": "MIT"
},
"node_modules/available-typed-arrays": { "node_modules/available-typed-arrays": {
"version": "1.0.7", "version": "1.0.7",
"resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz",
@@ -2918,6 +2976,17 @@
"node": ">=4" "node": ">=4"
} }
}, },
"node_modules/axios": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.10.0.tgz",
"integrity": "sha512-/1xYAC4MP/HEG+3duIhFr4ZQXR4sQXOIe+o6sdqzeykGLx6Upp/1p8MHqhINOvGeP7xyNHe7tsiJByc4SSVUxw==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.6",
"form-data": "^4.0.0",
"proxy-from-env": "^1.1.0"
}
},
"node_modules/axobject-query": { "node_modules/axobject-query": {
"version": "4.1.0", "version": "4.1.0",
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
@@ -2993,7 +3062,6 @@
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"es-errors": "^1.3.0", "es-errors": "^1.3.0",
@@ -3149,6 +3217,18 @@
"simple-swizzle": "^0.2.2" "simple-swizzle": "^0.2.2"
} }
}, },
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"license": "MIT",
"dependencies": {
"delayed-stream": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/concat-map": { "node_modules/concat-map": {
"version": "0.0.1", "version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
@@ -3309,6 +3389,15 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"license": "MIT",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/detect-libc": { "node_modules/detect-libc": {
"version": "2.0.4", "version": "2.0.4",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz",
@@ -3342,7 +3431,6 @@
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"call-bind-apply-helpers": "^1.0.1", "call-bind-apply-helpers": "^1.0.1",
@@ -3447,7 +3535,6 @@
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">= 0.4" "node": ">= 0.4"
@@ -3457,7 +3544,6 @@
"version": "1.3.0", "version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">= 0.4" "node": ">= 0.4"
@@ -3495,7 +3581,6 @@
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"es-errors": "^1.3.0" "es-errors": "^1.3.0"
@@ -3508,7 +3593,6 @@
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"es-errors": "^1.3.0", "es-errors": "^1.3.0",
@@ -4115,6 +4199,26 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/follow-redirects": {
"version": "1.15.9",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz",
"integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/RubenVerborgh"
}
],
"license": "MIT",
"engines": {
"node": ">=4.0"
},
"peerDependenciesMeta": {
"debug": {
"optional": true
}
}
},
"node_modules/for-each": { "node_modules/for-each": {
"version": "0.3.5", "version": "0.3.5",
"resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz",
@@ -4131,6 +4235,22 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/form-data": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.3.tgz",
"integrity": "sha512-qsITQPfmvMOSAdeyZ+12I1c+CKSstAFAwu+97zrnWAbIr5u8wfsExUzCesVLC8NgHuRUqNN4Zy6UPWUTRGslcA==",
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.2",
"mime-types": "^2.1.12"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/framer-motion": { "node_modules/framer-motion": {
"version": "12.22.0", "version": "12.22.0",
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.22.0.tgz", "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.22.0.tgz",
@@ -4162,7 +4282,6 @@
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
"dev": true,
"license": "MIT", "license": "MIT",
"funding": { "funding": {
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
@@ -4203,7 +4322,6 @@
"version": "1.3.0", "version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"call-bind-apply-helpers": "^1.0.2", "call-bind-apply-helpers": "^1.0.2",
@@ -4237,7 +4355,6 @@
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"dunder-proto": "^1.0.1", "dunder-proto": "^1.0.1",
@@ -4325,7 +4442,6 @@
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">= 0.4" "node": ">= 0.4"
@@ -4404,7 +4520,6 @@
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">= 0.4" "node": ">= 0.4"
@@ -4417,7 +4532,6 @@
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"has-symbols": "^1.0.3" "has-symbols": "^1.0.3"
@@ -4433,7 +4547,6 @@
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"function-bind": "^1.1.2" "function-bind": "^1.1.2"
@@ -5393,7 +5506,6 @@
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">= 0.4" "node": ">= 0.4"
@@ -5423,6 +5535,27 @@
"node": ">=8.6" "node": ">=8.6"
} }
}, },
"node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/minimatch": { "node_modules/minimatch": {
"version": "3.1.2", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
@@ -6050,6 +6183,12 @@
"react-is": "^16.13.1" "react-is": "^16.13.1"
} }
}, },
"node_modules/proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
"license": "MIT"
},
"node_modules/punycode": { "node_modules/punycode": {
"version": "2.3.1", "version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
@@ -6572,6 +6711,16 @@
"is-arrayish": "^0.3.1" "is-arrayish": "^0.3.1"
} }
}, },
"node_modules/sonner": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.6.tgz",
"integrity": "sha512-yHFhk8T/DK3YxjFQXIrcHT1rGEeTLliVzWbO0xN8GberVun2RiBnxAjXAYpZrqwEVHBG9asI/Li8TAAhN9m59Q==",
"license": "MIT",
"peerDependencies": {
"react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc",
"react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc"
}
},
"node_modules/source-map-js": { "node_modules/source-map-js": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -7305,6 +7454,35 @@
"funding": { "funding": {
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
},
"node_modules/zustand": {
"version": "5.0.6",
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.6.tgz",
"integrity": "sha512-ihAqNeUVhe0MAD+X8M5UzqyZ9k3FFZLBTtqo6JLPwV53cbRB/mJwBI0PxcIgqhBBHlEs8G45OTDTMq3gNcLq3A==",
"license": "MIT",
"engines": {
"node": ">=12.20.0"
},
"peerDependencies": {
"@types/react": ">=18.0.0",
"immer": ">=9.0.6",
"react": ">=18.0.0",
"use-sync-external-store": ">=1.2.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"immer": {
"optional": true
},
"react": {
"optional": true
},
"use-sync-external-store": {
"optional": true
}
}
} }
} }
} }

View File

@@ -12,9 +12,12 @@
"@radix-ui/react-collapsible": "^1.1.11", "@radix-ui/react-collapsible": "^1.1.11",
"@radix-ui/react-dialog": "^1.1.14", "@radix-ui/react-dialog": "^1.1.14",
"@radix-ui/react-dropdown-menu": "^2.1.15", "@radix-ui/react-dropdown-menu": "^2.1.15",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-progress": "^1.1.7",
"@radix-ui/react-separator": "^1.1.7", "@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-tooltip": "^1.2.7", "@radix-ui/react-tooltip": "^1.2.7",
"axios": "^1.10.0",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"framer-motion": "^12.22.0", "framer-motion": "^12.22.0",
@@ -25,7 +28,9 @@
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"tailwind-merge": "^3.3.1" "sonner": "^2.0.6",
"tailwind-merge": "^3.3.1",
"zustand": "^5.0.6"
}, },
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "^3", "@eslint/eslintrc": "^3",

View File

@@ -0,0 +1,19 @@
export interface PhoneNumberDto {
number: string;
note: string;
}
export interface NoteDto {
text: string;
}
export interface CreateCustomerDto {
email: string;
name: string;
companyName: string;
phoneNumbers: PhoneNumberDto[];
street: string;
zip: string;
city: string;
notes: NoteDto[];
}

View File

@@ -0,0 +1,32 @@
export interface PhoneNumber {
number: string;
note: string;
createdBy: string;
createdAt: string;
updatedBy: string;
updatedAt: string;
}
export interface Note {
text: string;
createdBy: string;
createdAt: string;
updatedBy: string;
updatedAt: string;
}
export interface Customer {
id: string;
email: string;
name: string;
companyName: string;
phoneNumbers: PhoneNumber[];
street: string;
zip: string;
city: string;
notes: Note[];
createdBy: string;
createdAt: string;
updatedBy: string;
updatedAt: string;
}

View File

@@ -0,0 +1,13 @@
import {Customer} from "@/services/customers/entities/customer";
import {CreateCustomerDto} from "@/services/customers/dtos/createCustomer.dto";
import {callApi} from "@/lib/api/callApi";
export class CustomerRepository {
static async getAll(): Promise<Customer[]> {
return await callApi<Customer[]>("/customers", "GET");
}
static async create(payload: CreateCustomerDto): Promise<void> {
await callApi<void, CreateCustomerDto>("/customers", "POST", payload);
}
}

View File

@@ -0,0 +1,21 @@
"use server";
import {CreateCustomerDto} from "@/services/customers/dtos/createCustomer.dto";
import {CustomerRepository} from "@/services/customers/repositories/customerRepository";
export async function addCustomer(params: CreateCustomerDto): Promise<void> {
const {email, name, companyName, street, zip, city, phoneNumbers, notes} = params;
const payload: CreateCustomerDto = {
email,
name,
companyName,
street,
zip,
city,
phoneNumbers,
notes: notes.map(({text}) => ({text})),
};
await CustomerRepository.create(payload);
}

View File

@@ -0,0 +1,15 @@
"use server";
import {callApi} from "@/lib/api/callApi";
import {Customer} from "@/app/customers/page";
import {customerRoutes} from "@/app/api/customers/customerRoutes";
export async function validateCustomer(input: {
email: string;
companyName: string;
street: string;
zip: string;
city: string;
}): Promise<Customer[]> {
return await callApi<Customer[]>(customerRoutes.validate, "POST", input);
}