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");
|
||||
}
|
||||
}
|
||||
@@ -16,3 +16,4 @@ spring:
|
||||
- Path=/api/**
|
||||
filters:
|
||||
- StripPrefix=1
|
||||
- PreserveHostHeader
|
||||
@@ -75,6 +75,14 @@
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-data-jpa</artifactId>
|
||||
</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 -->
|
||||
<!-- https://mvnrepository.com/artifact/org.projectlombok/lombok -->
|
||||
@@ -96,6 +104,17 @@
|
||||
<groupId>org.flywaydb</groupId>
|
||||
<artifactId>flyway-database-postgresql</artifactId>
|
||||
</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>
|
||||
<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:
|
||||
application:
|
||||
name: server
|
||||
|
||||
security:
|
||||
oauth2:
|
||||
resourceserver:
|
||||
jwt:
|
||||
issuer-uri: https://sso.rhein-software.dev/realms/rhein-sw
|
||||
|
||||
datasource:
|
||||
url: jdbc:postgresql://${DB_HOST}:${DB_PORT}/${DB_NAME}
|
||||
username: ${DB_USERNAME}
|
||||
|
||||
@@ -1,21 +1,22 @@
|
||||
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
|
||||
constraint pk_api_key
|
||||
primary key,
|
||||
key varchar(255) not null
|
||||
constraint uc_api_key_key
|
||||
unique,
|
||||
type varchar(255) not null,
|
||||
enabled boolean not null,
|
||||
frontend_only boolean not null,
|
||||
description text,
|
||||
created_date date default CURRENT_DATE,
|
||||
created_time time default CURRENT_TIME,
|
||||
modified_date date,
|
||||
modified_time time
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
keycloak_id VARCHAR(255) NOT NULL UNIQUE,
|
||||
username VARCHAR(255) NOT NULL UNIQUE,
|
||||
email VARCHAR(255) 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
|
||||
);
|
||||
|
||||
-- 1. CONTACT REQUESTS
|
||||
CREATE TABLE contact_requests
|
||||
(
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
@@ -26,6 +27,72 @@ CREATE TABLE contact_requests
|
||||
phone VARCHAR(20),
|
||||
website VARCHAR(100),
|
||||
captcha_token VARCHAR(1024),
|
||||
submitted_date DATE,
|
||||
submitted_time TIME
|
||||
submitted_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- 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,7 +41,7 @@ export default async function RootLayout({
|
||||
}
|
||||
>
|
||||
<AppSidebar/>
|
||||
<main>
|
||||
|
||||
<SidebarInset>
|
||||
<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">
|
||||
@@ -54,9 +54,8 @@ export default async function RootLayout({
|
||||
<DynamicBreadcrumb/>
|
||||
</div>
|
||||
</header>
|
||||
</SidebarInset>
|
||||
{children}
|
||||
</main>
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
) : (
|
||||
<LoginScreen/>
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
Home,
|
||||
Scale,
|
||||
User2,
|
||||
Settings
|
||||
Settings, LayoutDashboard
|
||||
} from "lucide-react";
|
||||
|
||||
import {
|
||||
@@ -50,6 +50,14 @@ const rheinItems = [
|
||||
},
|
||||
];
|
||||
|
||||
const customerItems = [
|
||||
{
|
||||
title: "Kundenübersicht",
|
||||
url: "/customers",
|
||||
icon: LayoutDashboard,
|
||||
},
|
||||
];
|
||||
|
||||
export function AppSidebar() {
|
||||
return (
|
||||
<Sidebar>
|
||||
@@ -77,6 +85,27 @@ export function AppSidebar() {
|
||||
</SidebarGroupContent>
|
||||
</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 */}
|
||||
<SidebarGroup>
|
||||
<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,
|
||||
} = process.env;
|
||||
|
||||
if (!KEYCLOAK_CLIENT_ID) throw new Error("Missing KEYCLOAK_CLIENT_ID");
|
||||
if (!KEYCLOAK_CLIENT_SECRET) throw new Error("Missing KEYCLOAK_CLIENT_SECRET");
|
||||
if (!KEYCLOAK_ISSUER) throw new Error("Missing KEYCLOAK_ISSUER");
|
||||
if (!NEXTAUTH_SECRET) throw new Error("Missing NEXTAUTH_SECRET");
|
||||
// if (!KEYCLOAK_CLIENT_ID) throw new Error("Missing KEYCLOAK_CLIENT_ID");
|
||||
// if (!KEYCLOAK_CLIENT_SECRET) throw new Error("Missing KEYCLOAK_CLIENT_SECRET");
|
||||
// if (!KEYCLOAK_ISSUER) throw new Error("Missing KEYCLOAK_ISSUER");
|
||||
// if (!NEXTAUTH_SECRET) throw new Error("Missing NEXTAUTH_SECRET");
|
||||
|
||||
console.log("[auth] Using Keycloak provider:");
|
||||
console.log(" - Client ID:", KEYCLOAK_CLIENT_ID);
|
||||
@@ -42,8 +42,8 @@ async function isTokenValid(token: string): Promise<boolean> {
|
||||
export const authOptions: NextAuthOptions = {
|
||||
providers: [
|
||||
KeycloakProvider({
|
||||
clientId: KEYCLOAK_CLIENT_ID,
|
||||
clientSecret: KEYCLOAK_CLIENT_SECRET,
|
||||
clientId: KEYCLOAK_CLIENT_ID as string,
|
||||
clientSecret: KEYCLOAK_CLIENT_SECRET as string,
|
||||
issuer: KEYCLOAK_ISSUER,
|
||||
}),
|
||||
],
|
||||
|
||||
@@ -4,5 +4,6 @@ export const breadcrumbMap: Record<string, string> = {
|
||||
'settings': 'Settings',
|
||||
'demo': 'Demo',
|
||||
'users': 'User Management',
|
||||
'customers': 'Kundenübersicht',
|
||||
// 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-dialog": "^1.1.14",
|
||||
"@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-slot": "^1.2.3",
|
||||
"@radix-ui/react-tooltip": "^1.2.7",
|
||||
"axios": "^1.10.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"framer-motion": "^12.22.0",
|
||||
@@ -24,7 +27,9 @@
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "^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": {
|
||||
"@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": {
|
||||
"version": "2.1.15",
|
||||
"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": {
|
||||
"version": "1.1.10",
|
||||
"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_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": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz",
|
||||
@@ -2918,6 +2976,17 @@
|
||||
"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": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
|
||||
@@ -2993,7 +3062,6 @@
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
||||
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
@@ -3149,6 +3217,18 @@
|
||||
"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": {
|
||||
"version": "0.0.1",
|
||||
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
||||
@@ -3309,6 +3389,15 @@
|
||||
"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": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz",
|
||||
@@ -3342,7 +3431,6 @@
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.1",
|
||||
@@ -3447,7 +3535,6 @@
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
||||
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
@@ -3457,7 +3544,6 @@
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
|
||||
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
@@ -3495,7 +3581,6 @@
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
|
||||
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0"
|
||||
@@ -3508,7 +3593,6 @@
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
|
||||
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
@@ -4115,6 +4199,26 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "0.3.5",
|
||||
"resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz",
|
||||
@@ -4131,6 +4235,22 @@
|
||||
"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": {
|
||||
"version": "12.22.0",
|
||||
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.22.0.tgz",
|
||||
@@ -4162,7 +4282,6 @@
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
@@ -4203,7 +4322,6 @@
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
||||
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.2",
|
||||
@@ -4237,7 +4355,6 @@
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
|
||||
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dunder-proto": "^1.0.1",
|
||||
@@ -4325,7 +4442,6 @@
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
@@ -4404,7 +4520,6 @@
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
||||
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
@@ -4417,7 +4532,6 @@
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
|
||||
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"has-symbols": "^1.0.3"
|
||||
@@ -4433,7 +4547,6 @@
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
||||
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"function-bind": "^1.1.2"
|
||||
@@ -5393,7 +5506,6 @@
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
@@ -5423,6 +5535,27 @@
|
||||
"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": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
|
||||
@@ -6050,6 +6183,12 @@
|
||||
"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": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||
@@ -6572,6 +6711,16 @@
|
||||
"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": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
@@ -7305,6 +7454,35 @@
|
||||
"funding": {
|
||||
"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-dialog": "^1.1.14",
|
||||
"@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-slot": "^1.2.3",
|
||||
"@radix-ui/react-tooltip": "^1.2.7",
|
||||
"axios": "^1.10.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"framer-motion": "^12.22.0",
|
||||
@@ -25,7 +28,9 @@
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "^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": {
|
||||
"@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