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:
@@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -15,4 +15,5 @@ spring:
|
|||||||
predicates:
|
predicates:
|
||||||
- Path=/api/**
|
- Path=/api/**
|
||||||
filters:
|
filters:
|
||||||
- StripPrefix=1
|
- StripPrefix=1
|
||||||
|
- PreserveHostHeader
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
) {
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
) {
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
) {
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
) {
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
);
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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) {
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
|
|||||||
@@ -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
|
||||||
|
);
|
||||||
|
|||||||
4
internal_frontend/app/api/customers/customerRoutes.ts
Normal file
4
internal_frontend/app/api/customers/customerRoutes.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export const customerRoutes = {
|
||||||
|
create: "/customers",
|
||||||
|
validate: "/customers/validate",
|
||||||
|
};
|
||||||
14
internal_frontend/app/api/customers/route.ts
Normal file
14
internal_frontend/app/api/customers/route.ts
Normal 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());
|
||||||
|
}
|
||||||
196
internal_frontend/app/customers/page.tsx
Normal file
196
internal_frontend/app/customers/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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/>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
143
internal_frontend/components/ui/dialog.tsx
Normal file
143
internal_frontend/components/ui/dialog.tsx
Normal 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,
|
||||||
|
}
|
||||||
24
internal_frontend/components/ui/label.tsx
Normal file
24
internal_frontend/components/ui/label.tsx
Normal 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 }
|
||||||
127
internal_frontend/components/ui/pagination.tsx
Normal file
127
internal_frontend/components/ui/pagination.tsx
Normal 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,
|
||||||
|
}
|
||||||
31
internal_frontend/components/ui/progress.tsx
Normal file
31
internal_frontend/components/ui/progress.tsx
Normal 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 }
|
||||||
116
internal_frontend/components/ui/table.tsx
Normal file
116
internal_frontend/components/ui/table.tsx
Normal 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,
|
||||||
|
}
|
||||||
18
internal_frontend/components/ui/textarea.tsx
Normal file
18
internal_frontend/components/ui/textarea.tsx
Normal 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 }
|
||||||
28
internal_frontend/lib/api/callApi.ts
Normal file
28
internal_frontend/lib/api/callApi.ts
Normal 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;
|
||||||
|
}
|
||||||
28
internal_frontend/lib/api/serverCall.ts
Normal file
28
internal_frontend/lib/api/serverCall.ts
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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
|
||||||
};
|
};
|
||||||
16
internal_frontend/lib/ui/showError.ts
Normal file
16
internal_frontend/lib/ui/showError.ts
Normal 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);
|
||||||
|
}
|
||||||
208
internal_frontend/package-lock.json
generated
208
internal_frontend/package-lock.json
generated
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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[];
|
||||||
|
}
|
||||||
32
internal_frontend/services/customers/entities/customer.ts
Normal file
32
internal_frontend/services/customers/entities/customer.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
21
internal_frontend/services/customers/usecases/addCustomer.ts
Normal file
21
internal_frontend/services/customers/usecases/addCustomer.ts
Normal 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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user