init
This commit is contained in:
22
.claude/settings.local.json
Normal file
22
.claude/settings.local.json
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(java --version)",
|
||||||
|
"Bash(mvn --version)",
|
||||||
|
"Bash(npm --version)",
|
||||||
|
"Bash(docker --version)",
|
||||||
|
"Bash(where.exe mvn:*)",
|
||||||
|
"Bash(where.exe node:*)",
|
||||||
|
"Bash(where.exe npm:*)",
|
||||||
|
"Bash(where.exe gradle:*)",
|
||||||
|
"Bash(where.exe npx:*)",
|
||||||
|
"Bash(docker info:*)",
|
||||||
|
"Bash(docker context:*)",
|
||||||
|
"Bash(mkdir -p backend/src/main/java/de/strichliste/entity)",
|
||||||
|
"Bash(mkdir -p backend/src/main/java/de/strichliste/resource)",
|
||||||
|
"Bash(mkdir -p backend/src/main/java/de/strichliste/filter)",
|
||||||
|
"Bash(mkdir -p backend/src/main/java/de/strichliste/dto)",
|
||||||
|
"Bash(mkdir -p backend/src/main/resources/db/migration)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
backups/
|
||||||
|
*.log
|
||||||
|
.env
|
||||||
6
backend/.gitignore
vendored
Normal file
6
backend/.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
target/
|
||||||
|
.idea/
|
||||||
|
*.iml
|
||||||
|
.settings/
|
||||||
|
.project
|
||||||
|
.classpath
|
||||||
17
backend/Dockerfile
Normal file
17
backend/Dockerfile
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
FROM maven:3.9-eclipse-temurin-21 AS build
|
||||||
|
WORKDIR /app
|
||||||
|
COPY pom.xml .
|
||||||
|
RUN mvn dependency:go-offline -B
|
||||||
|
COPY src ./src
|
||||||
|
RUN mvn package -DskipTests -B
|
||||||
|
|
||||||
|
FROM eclipse-temurin:21-jre
|
||||||
|
WORKDIR /deployments
|
||||||
|
COPY --from=build /app/target/quarkus-app/lib/ ./lib/
|
||||||
|
COPY --from=build /app/target/quarkus-app/*.jar ./
|
||||||
|
COPY --from=build /app/target/quarkus-app/app/ ./app/
|
||||||
|
COPY --from=build /app/target/quarkus-app/quarkus/ ./quarkus/
|
||||||
|
|
||||||
|
EXPOSE 8080
|
||||||
|
|
||||||
|
ENTRYPOINT ["java", "-jar", "/deployments/quarkus-run.jar"]
|
||||||
105
backend/pom.xml
Normal file
105
backend/pom.xml
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||||
|
<modelVersion>4.0.0</modelVersion>
|
||||||
|
|
||||||
|
<groupId>de.strichliste</groupId>
|
||||||
|
<artifactId>backend</artifactId>
|
||||||
|
<version>1.0.0-SNAPSHOT</version>
|
||||||
|
|
||||||
|
<properties>
|
||||||
|
<compiler-plugin.version>3.13.0</compiler-plugin.version>
|
||||||
|
<maven.compiler.release>21</maven.compiler.release>
|
||||||
|
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||||
|
<quarkus.platform.artifact-id>quarkus-bom</quarkus.platform.artifact-id>
|
||||||
|
<quarkus.platform.group-id>io.quarkus.platform</quarkus.platform.group-id>
|
||||||
|
<quarkus.platform.version>3.17.7</quarkus.platform.version>
|
||||||
|
<surefire-plugin.version>3.5.2</surefire-plugin.version>
|
||||||
|
</properties>
|
||||||
|
|
||||||
|
<dependencyManagement>
|
||||||
|
<dependencies>
|
||||||
|
<dependency>
|
||||||
|
<groupId>${quarkus.platform.group-id}</groupId>
|
||||||
|
<artifactId>${quarkus.platform.artifact-id}</artifactId>
|
||||||
|
<version>${quarkus.platform.version}</version>
|
||||||
|
<type>pom</type>
|
||||||
|
<scope>import</scope>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
</dependencyManagement>
|
||||||
|
|
||||||
|
<dependencies>
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.quarkus</groupId>
|
||||||
|
<artifactId>quarkus-rest-jackson</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.quarkus</groupId>
|
||||||
|
<artifactId>quarkus-hibernate-orm-panache</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.quarkus</groupId>
|
||||||
|
<artifactId>quarkus-jdbc-mariadb</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.quarkus</groupId>
|
||||||
|
<artifactId>quarkus-flyway</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.flywaydb</groupId>
|
||||||
|
<artifactId>flyway-mysql</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.quarkus</groupId>
|
||||||
|
<artifactId>quarkus-arc</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.quarkus</groupId>
|
||||||
|
<artifactId>quarkus-junit5</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.rest-assured</groupId>
|
||||||
|
<artifactId>rest-assured</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
|
||||||
|
<build>
|
||||||
|
<plugins>
|
||||||
|
<plugin>
|
||||||
|
<groupId>${quarkus.platform.group-id}</groupId>
|
||||||
|
<artifactId>quarkus-maven-plugin</artifactId>
|
||||||
|
<version>${quarkus.platform.version}</version>
|
||||||
|
<extensions>true</extensions>
|
||||||
|
<executions>
|
||||||
|
<execution>
|
||||||
|
<goals>
|
||||||
|
<goal>build</goal>
|
||||||
|
<goal>generate-code</goal>
|
||||||
|
<goal>generate-code-tests</goal>
|
||||||
|
</goals>
|
||||||
|
</execution>
|
||||||
|
</executions>
|
||||||
|
</plugin>
|
||||||
|
<plugin>
|
||||||
|
<artifactId>maven-compiler-plugin</artifactId>
|
||||||
|
<version>${compiler-plugin.version}</version>
|
||||||
|
<configuration>
|
||||||
|
<parameters>true</parameters>
|
||||||
|
</configuration>
|
||||||
|
</plugin>
|
||||||
|
<plugin>
|
||||||
|
<artifactId>maven-surefire-plugin</artifactId>
|
||||||
|
<version>${surefire-plugin.version}</version>
|
||||||
|
<configuration>
|
||||||
|
<systemPropertyVariables>
|
||||||
|
<java.util.logging.manager>org.jboss.logmanager.LogManager</java.util.logging.manager>
|
||||||
|
</systemPropertyVariables>
|
||||||
|
</configuration>
|
||||||
|
</plugin>
|
||||||
|
</plugins>
|
||||||
|
</build>
|
||||||
|
</project>
|
||||||
14
backend/src/main/docker/Dockerfile.jvm
Normal file
14
backend/src/main/docker/Dockerfile.jvm
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
FROM eclipse-temurin:21-jre
|
||||||
|
|
||||||
|
ENV LANGUAGE='de_DE:de'
|
||||||
|
ENV JAVA_OPTS_APPEND="-Dquarkus.http.host=0.0.0.0 -Djava.util.logging.manager=org.jboss.logmanager.LogManager"
|
||||||
|
|
||||||
|
COPY target/quarkus-app/lib/ /deployments/lib/
|
||||||
|
COPY target/quarkus-app/*.jar /deployments/
|
||||||
|
COPY target/quarkus-app/app/ /deployments/app/
|
||||||
|
COPY target/quarkus-app/quarkus/ /deployments/quarkus/
|
||||||
|
|
||||||
|
EXPOSE 8080
|
||||||
|
USER 185
|
||||||
|
|
||||||
|
ENTRYPOINT ["java", "-jar", "/deployments/quarkus-run.jar"]
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
package de.strichliste.dto;
|
||||||
|
|
||||||
|
public record AccessLinkCreateRequest(String role, Long companyId, String description) {}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
package de.strichliste.dto;
|
||||||
|
|
||||||
|
public record AccessLinkDto(Long id, String token, String role, Long companyId, String description, boolean active) {}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
package de.strichliste.dto;
|
||||||
|
|
||||||
|
public record CompanyCreateRequest(String name) {}
|
||||||
3
backend/src/main/java/de/strichliste/dto/CompanyDto.java
Normal file
3
backend/src/main/java/de/strichliste/dto/CompanyDto.java
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
package de.strichliste.dto;
|
||||||
|
|
||||||
|
public record CompanyDto(Long id, String name, boolean active) {}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
package de.strichliste.dto;
|
||||||
|
|
||||||
|
public record EmployeeCreateRequest(String firstName, String lastName) {}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
package de.strichliste.dto;
|
||||||
|
|
||||||
|
public record EmployeeDto(Long id, Long companyId, String firstName, String lastName, boolean active) {}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package de.strichliste.dto;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public record MonthlyReportDto(
|
||||||
|
String month,
|
||||||
|
Long companyId,
|
||||||
|
String companyName,
|
||||||
|
List<EmployeeReportLine> employees,
|
||||||
|
Long totalCents
|
||||||
|
) {
|
||||||
|
public record EmployeeReportLine(
|
||||||
|
Long employeeId,
|
||||||
|
String firstName,
|
||||||
|
String lastName,
|
||||||
|
Long totalCount,
|
||||||
|
Long totalCents,
|
||||||
|
List<MonthlyTallyDto> products
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package de.strichliste.dto;
|
||||||
|
|
||||||
|
public record MonthlyTallyDto(
|
||||||
|
String productName,
|
||||||
|
Integer priceCents,
|
||||||
|
Long count,
|
||||||
|
Long totalCents
|
||||||
|
) {}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
package de.strichliste.dto;
|
||||||
|
|
||||||
|
public record ProductCreateRequest(String name, int priceCents, String iconPlaceholder) {}
|
||||||
3
backend/src/main/java/de/strichliste/dto/ProductDto.java
Normal file
3
backend/src/main/java/de/strichliste/dto/ProductDto.java
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
package de.strichliste.dto;
|
||||||
|
|
||||||
|
public record ProductDto(Long id, String name, int priceCents, String iconPlaceholder, boolean active) {}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
package de.strichliste.dto;
|
||||||
|
|
||||||
|
public record TallyRequest(Long employeeId, Long productId) {}
|
||||||
45
backend/src/main/java/de/strichliste/entity/AccessLink.java
Normal file
45
backend/src/main/java/de/strichliste/entity/AccessLink.java
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
package de.strichliste.entity;
|
||||||
|
|
||||||
|
import io.quarkus.hibernate.orm.panache.PanacheEntityBase;
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "access_link")
|
||||||
|
public class AccessLink extends PanacheEntityBase {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
public Long id;
|
||||||
|
|
||||||
|
@Column(nullable = false, unique = true, length = 64)
|
||||||
|
public String token;
|
||||||
|
|
||||||
|
@Column(nullable = false, length = 20)
|
||||||
|
public String role;
|
||||||
|
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
|
@JoinColumn(name = "company_id")
|
||||||
|
public Company company;
|
||||||
|
|
||||||
|
public String description;
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
public boolean active = true;
|
||||||
|
|
||||||
|
@Column(name = "created_at", nullable = false, updatable = false)
|
||||||
|
public LocalDateTime createdAt = LocalDateTime.now();
|
||||||
|
|
||||||
|
public static Optional<AccessLink> findByToken(String token) {
|
||||||
|
return find("token = ?1 and active = true", token).firstResultOptional();
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isProviderAdmin() {
|
||||||
|
return "PROVIDER_ADMIN".equals(role);
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isCompanyAdmin() {
|
||||||
|
return "COMPANY_ADMIN".equals(role);
|
||||||
|
}
|
||||||
|
}
|
||||||
31
backend/src/main/java/de/strichliste/entity/Company.java
Normal file
31
backend/src/main/java/de/strichliste/entity/Company.java
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
package de.strichliste.entity;
|
||||||
|
|
||||||
|
import io.quarkus.hibernate.orm.panache.PanacheEntityBase;
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "company")
|
||||||
|
public class Company extends PanacheEntityBase {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
public Long id;
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
public String name;
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
public boolean active = true;
|
||||||
|
|
||||||
|
@Column(name = "created_at", nullable = false, updatable = false)
|
||||||
|
public LocalDateTime createdAt = LocalDateTime.now();
|
||||||
|
|
||||||
|
@OneToMany(mappedBy = "company", fetch = FetchType.LAZY)
|
||||||
|
public List<Employee> employees;
|
||||||
|
|
||||||
|
public static List<Company> findAllActive() {
|
||||||
|
return find("active", true).list();
|
||||||
|
}
|
||||||
|
}
|
||||||
39
backend/src/main/java/de/strichliste/entity/Employee.java
Normal file
39
backend/src/main/java/de/strichliste/entity/Employee.java
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
package de.strichliste.entity;
|
||||||
|
|
||||||
|
import io.quarkus.hibernate.orm.panache.PanacheEntityBase;
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "employee")
|
||||||
|
public class Employee extends PanacheEntityBase {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
public Long id;
|
||||||
|
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
|
@JoinColumn(name = "company_id", nullable = false)
|
||||||
|
public Company company;
|
||||||
|
|
||||||
|
@Column(name = "first_name", nullable = false)
|
||||||
|
public String firstName;
|
||||||
|
|
||||||
|
@Column(name = "last_name", nullable = false)
|
||||||
|
public String lastName;
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
public boolean active = true;
|
||||||
|
|
||||||
|
@Column(name = "created_at", nullable = false, updatable = false)
|
||||||
|
public LocalDateTime createdAt = LocalDateTime.now();
|
||||||
|
|
||||||
|
public static List<Employee> findActiveByCompany(Long companyId) {
|
||||||
|
return find("company.id = ?1 and active = true", companyId).list();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static List<Employee> findAllByCompany(Long companyId) {
|
||||||
|
return find("company.id", companyId).list();
|
||||||
|
}
|
||||||
|
}
|
||||||
34
backend/src/main/java/de/strichliste/entity/Product.java
Normal file
34
backend/src/main/java/de/strichliste/entity/Product.java
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
package de.strichliste.entity;
|
||||||
|
|
||||||
|
import io.quarkus.hibernate.orm.panache.PanacheEntityBase;
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "product")
|
||||||
|
public class Product extends PanacheEntityBase {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
public Long id;
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
public String name;
|
||||||
|
|
||||||
|
@Column(name = "price_cents", nullable = false)
|
||||||
|
public int priceCents;
|
||||||
|
|
||||||
|
@Column(name = "icon_placeholder")
|
||||||
|
public String iconPlaceholder = "coffee";
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
public boolean active = true;
|
||||||
|
|
||||||
|
@Column(name = "created_at", nullable = false, updatable = false)
|
||||||
|
public LocalDateTime createdAt = LocalDateTime.now();
|
||||||
|
|
||||||
|
public static List<Product> findAllActive() {
|
||||||
|
return find("active", true).list();
|
||||||
|
}
|
||||||
|
}
|
||||||
38
backend/src/main/java/de/strichliste/entity/TallyEntry.java
Normal file
38
backend/src/main/java/de/strichliste/entity/TallyEntry.java
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
package de.strichliste.entity;
|
||||||
|
|
||||||
|
import io.quarkus.hibernate.orm.panache.PanacheEntityBase;
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "tally_entry")
|
||||||
|
public class TallyEntry extends PanacheEntityBase {
|
||||||
|
|
||||||
|
private static final DateTimeFormatter MONTH_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM");
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
public Long id;
|
||||||
|
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
|
@JoinColumn(name = "employee_id", nullable = false)
|
||||||
|
public Employee employee;
|
||||||
|
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
|
@JoinColumn(name = "product_id", nullable = false)
|
||||||
|
public Product product;
|
||||||
|
|
||||||
|
@Column(name = "month_key", nullable = false, length = 7)
|
||||||
|
public String monthKey;
|
||||||
|
|
||||||
|
@Column(name = "created_at", nullable = false, updatable = false)
|
||||||
|
public LocalDateTime createdAt = LocalDateTime.now();
|
||||||
|
|
||||||
|
@PrePersist
|
||||||
|
public void setMonthKey() {
|
||||||
|
if (monthKey == null) {
|
||||||
|
monthKey = LocalDateTime.now().format(MONTH_FORMAT);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
74
backend/src/main/java/de/strichliste/filter/AuthFilter.java
Normal file
74
backend/src/main/java/de/strichliste/filter/AuthFilter.java
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
package de.strichliste.filter;
|
||||||
|
|
||||||
|
import de.strichliste.entity.AccessLink;
|
||||||
|
import jakarta.inject.Inject;
|
||||||
|
import jakarta.ws.rs.container.ContainerRequestContext;
|
||||||
|
import jakarta.ws.rs.container.ContainerRequestFilter;
|
||||||
|
import jakarta.ws.rs.container.ResourceInfo;
|
||||||
|
import jakarta.ws.rs.core.Response;
|
||||||
|
import jakarta.ws.rs.ext.Provider;
|
||||||
|
import jakarta.annotation.Priority;
|
||||||
|
import jakarta.ws.rs.Priorities;
|
||||||
|
import jakarta.ws.rs.NameBinding;
|
||||||
|
import java.lang.annotation.*;
|
||||||
|
|
||||||
|
@Provider
|
||||||
|
@AuthFilter.Secured
|
||||||
|
@Priority(Priorities.AUTHENTICATION)
|
||||||
|
public class AuthFilter implements ContainerRequestFilter {
|
||||||
|
|
||||||
|
@NameBinding
|
||||||
|
@Retention(RetentionPolicy.RUNTIME)
|
||||||
|
@Target({ElementType.TYPE, ElementType.METHOD})
|
||||||
|
public @interface Secured {
|
||||||
|
String[] roles() default {};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
ResourceInfo resourceInfo;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void filter(ContainerRequestContext ctx) {
|
||||||
|
String token = ctx.getUriInfo().getQueryParameters().getFirst("token");
|
||||||
|
if (token == null) {
|
||||||
|
token = ctx.getHeaderString("X-Auth-Token");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (token == null || token.isBlank()) {
|
||||||
|
ctx.abortWith(Response.status(Response.Status.UNAUTHORIZED).build());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var linkOpt = AccessLink.findByToken(token);
|
||||||
|
if (linkOpt.isEmpty()) {
|
||||||
|
ctx.abortWith(Response.status(Response.Status.UNAUTHORIZED).build());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
AccessLink link = linkOpt.get();
|
||||||
|
|
||||||
|
Secured secured = resourceInfo.getResourceMethod().getAnnotation(Secured.class);
|
||||||
|
if (secured == null) {
|
||||||
|
secured = resourceInfo.getResourceClass().getAnnotation(Secured.class);
|
||||||
|
}
|
||||||
|
if (secured != null && secured.roles().length > 0) {
|
||||||
|
boolean hasRole = false;
|
||||||
|
for (String role : secured.roles()) {
|
||||||
|
if (role.equals(link.role)) {
|
||||||
|
hasRole = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!hasRole) {
|
||||||
|
ctx.abortWith(Response.status(Response.Status.FORBIDDEN).build());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.setProperty("accessLink", link);
|
||||||
|
ctx.setProperty("role", link.role);
|
||||||
|
if (link.company != null) {
|
||||||
|
ctx.setProperty("companyId", link.company.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
package de.strichliste.resource;
|
||||||
|
|
||||||
|
import de.strichliste.dto.*;
|
||||||
|
import de.strichliste.entity.*;
|
||||||
|
import de.strichliste.filter.AuthFilter.Secured;
|
||||||
|
import jakarta.transaction.Transactional;
|
||||||
|
import jakarta.ws.rs.*;
|
||||||
|
import jakarta.ws.rs.core.Context;
|
||||||
|
import jakarta.ws.rs.core.MediaType;
|
||||||
|
import jakarta.ws.rs.core.Response;
|
||||||
|
import org.jboss.resteasy.reactive.RestResponse;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Path("/api/admin/company")
|
||||||
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
|
@Consumes(MediaType.APPLICATION_JSON)
|
||||||
|
@Secured(roles = {"COMPANY_ADMIN"})
|
||||||
|
public class CompanyAdminResource {
|
||||||
|
|
||||||
|
private static final DateTimeFormatter MONTH_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM");
|
||||||
|
|
||||||
|
@GET
|
||||||
|
@Path("/employees")
|
||||||
|
public Response getEmployees(@QueryParam("token") String token) {
|
||||||
|
AccessLink link = AccessLink.findByToken(token).orElse(null);
|
||||||
|
if (link == null || link.company == null) {
|
||||||
|
return Response.status(Response.Status.FORBIDDEN).build();
|
||||||
|
}
|
||||||
|
|
||||||
|
List<EmployeeDto> employees = Employee.findAllByCompany(link.company.id).stream()
|
||||||
|
.map(e -> new EmployeeDto(e.id, e.company.id, e.firstName, e.lastName, e.active))
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
return Response.ok(employees).build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@POST
|
||||||
|
@Path("/employees")
|
||||||
|
@Transactional
|
||||||
|
public Response createEmployee(@QueryParam("token") String token, EmployeeCreateRequest request) {
|
||||||
|
AccessLink link = AccessLink.findByToken(token).orElse(null);
|
||||||
|
if (link == null || link.company == null) {
|
||||||
|
return Response.status(Response.Status.FORBIDDEN).build();
|
||||||
|
}
|
||||||
|
|
||||||
|
Employee employee = new Employee();
|
||||||
|
employee.company = link.company;
|
||||||
|
employee.firstName = request.firstName();
|
||||||
|
employee.lastName = request.lastName();
|
||||||
|
employee.persist();
|
||||||
|
|
||||||
|
return Response.status(Response.Status.CREATED)
|
||||||
|
.entity(new EmployeeDto(employee.id, employee.company.id, employee.firstName, employee.lastName, employee.active))
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@PUT
|
||||||
|
@Path("/employees/{id}")
|
||||||
|
@Transactional
|
||||||
|
public Response updateEmployee(@QueryParam("token") String token, @PathParam("id") Long id, EmployeeCreateRequest request) {
|
||||||
|
AccessLink link = AccessLink.findByToken(token).orElse(null);
|
||||||
|
if (link == null || link.company == null) {
|
||||||
|
return Response.status(Response.Status.FORBIDDEN).build();
|
||||||
|
}
|
||||||
|
|
||||||
|
Employee employee = Employee.findById(id);
|
||||||
|
if (employee == null || !employee.company.id.equals(link.company.id)) {
|
||||||
|
return Response.status(Response.Status.NOT_FOUND).build();
|
||||||
|
}
|
||||||
|
|
||||||
|
employee.firstName = request.firstName();
|
||||||
|
employee.lastName = request.lastName();
|
||||||
|
|
||||||
|
return Response.ok(new EmployeeDto(employee.id, employee.company.id, employee.firstName, employee.lastName, employee.active)).build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@PUT
|
||||||
|
@Path("/employees/{id}/toggle")
|
||||||
|
@Transactional
|
||||||
|
public Response toggleEmployee(@QueryParam("token") String token, @PathParam("id") Long id) {
|
||||||
|
AccessLink link = AccessLink.findByToken(token).orElse(null);
|
||||||
|
if (link == null || link.company == null) {
|
||||||
|
return Response.status(Response.Status.FORBIDDEN).build();
|
||||||
|
}
|
||||||
|
|
||||||
|
Employee employee = Employee.findById(id);
|
||||||
|
if (employee == null || !employee.company.id.equals(link.company.id)) {
|
||||||
|
return Response.status(Response.Status.NOT_FOUND).build();
|
||||||
|
}
|
||||||
|
|
||||||
|
employee.active = !employee.active;
|
||||||
|
|
||||||
|
return Response.ok(new EmployeeDto(employee.id, employee.company.id, employee.firstName, employee.lastName, employee.active)).build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@GET
|
||||||
|
@Path("/report")
|
||||||
|
public Response getMonthlyReport(@QueryParam("token") String token, @QueryParam("month") String month) {
|
||||||
|
AccessLink link = AccessLink.findByToken(token).orElse(null);
|
||||||
|
if (link == null || link.company == null) {
|
||||||
|
return Response.status(Response.Status.FORBIDDEN).build();
|
||||||
|
}
|
||||||
|
|
||||||
|
String monthKey = month != null ? month : LocalDateTime.now().format(MONTH_FORMAT);
|
||||||
|
return Response.ok(buildCompanyReport(link.company.id, monthKey)).build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@GET
|
||||||
|
@Path("/report/employee/{employeeId}")
|
||||||
|
public Response getEmployeeReport(
|
||||||
|
@QueryParam("token") String token,
|
||||||
|
@PathParam("employeeId") Long employeeId,
|
||||||
|
@QueryParam("month") String month) {
|
||||||
|
|
||||||
|
AccessLink link = AccessLink.findByToken(token).orElse(null);
|
||||||
|
if (link == null || link.company == null) {
|
||||||
|
return Response.status(Response.Status.FORBIDDEN).build();
|
||||||
|
}
|
||||||
|
|
||||||
|
Employee employee = Employee.findById(employeeId);
|
||||||
|
if (employee == null || !employee.company.id.equals(link.company.id)) {
|
||||||
|
return Response.status(Response.Status.NOT_FOUND).build();
|
||||||
|
}
|
||||||
|
|
||||||
|
String monthKey = month != null ? month : LocalDateTime.now().format(MONTH_FORMAT);
|
||||||
|
|
||||||
|
List<MonthlyTallyDto> tallies = TallyEntry.find(
|
||||||
|
"SELECT t.product.name, t.product.priceCents, COUNT(t), COUNT(t) * t.product.priceCents " +
|
||||||
|
"FROM TallyEntry t WHERE t.employee.id = ?1 AND t.monthKey = ?2 " +
|
||||||
|
"GROUP BY t.product.id, t.product.name, t.product.priceCents",
|
||||||
|
employeeId, monthKey)
|
||||||
|
.project(MonthlyTallyDto.class)
|
||||||
|
.list();
|
||||||
|
|
||||||
|
long totalCents = tallies.stream().mapToLong(MonthlyTallyDto::totalCents).sum();
|
||||||
|
long totalCount = tallies.stream().mapToLong(MonthlyTallyDto::count).sum();
|
||||||
|
|
||||||
|
var line = new MonthlyReportDto.EmployeeReportLine(
|
||||||
|
employee.id, employee.firstName, employee.lastName, totalCount, totalCents, tallies);
|
||||||
|
|
||||||
|
return Response.ok(line).build();
|
||||||
|
}
|
||||||
|
|
||||||
|
static MonthlyReportDto buildCompanyReport(Long companyId, String monthKey) {
|
||||||
|
Company company = Company.findById(companyId);
|
||||||
|
List<Employee> employees = Employee.findAllByCompany(companyId);
|
||||||
|
List<MonthlyReportDto.EmployeeReportLine> lines = new ArrayList<>();
|
||||||
|
long companyTotal = 0;
|
||||||
|
|
||||||
|
for (Employee emp : employees) {
|
||||||
|
List<MonthlyTallyDto> tallies = TallyEntry.find(
|
||||||
|
"SELECT t.product.name, t.product.priceCents, COUNT(t), COUNT(t) * t.product.priceCents " +
|
||||||
|
"FROM TallyEntry t WHERE t.employee.id = ?1 AND t.monthKey = ?2 " +
|
||||||
|
"GROUP BY t.product.id, t.product.name, t.product.priceCents",
|
||||||
|
emp.id, monthKey)
|
||||||
|
.project(MonthlyTallyDto.class)
|
||||||
|
.list();
|
||||||
|
|
||||||
|
long totalCents = tallies.stream().mapToLong(MonthlyTallyDto::totalCents).sum();
|
||||||
|
long totalCount = tallies.stream().mapToLong(MonthlyTallyDto::count).sum();
|
||||||
|
companyTotal += totalCents;
|
||||||
|
|
||||||
|
if (totalCount > 0) {
|
||||||
|
lines.add(new MonthlyReportDto.EmployeeReportLine(
|
||||||
|
emp.id, emp.firstName, emp.lastName, totalCount, totalCents, tallies));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new MonthlyReportDto(monthKey, companyId, company != null ? company.name : "", lines, companyTotal);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
package de.strichliste.resource;
|
||||||
|
|
||||||
|
import de.strichliste.dto.*;
|
||||||
|
import de.strichliste.entity.*;
|
||||||
|
import de.strichliste.filter.AuthFilter.Secured;
|
||||||
|
import jakarta.transaction.Transactional;
|
||||||
|
import jakarta.ws.rs.*;
|
||||||
|
import jakarta.ws.rs.core.MediaType;
|
||||||
|
import jakarta.ws.rs.core.Response;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@Path("/api/admin/provider")
|
||||||
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
|
@Consumes(MediaType.APPLICATION_JSON)
|
||||||
|
@Secured(roles = {"PROVIDER_ADMIN"})
|
||||||
|
public class ProviderAdminResource {
|
||||||
|
|
||||||
|
private static final DateTimeFormatter MONTH_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM");
|
||||||
|
|
||||||
|
// --- Companies ---
|
||||||
|
|
||||||
|
@GET
|
||||||
|
@Path("/companies")
|
||||||
|
public List<CompanyDto> getAllCompanies() {
|
||||||
|
return Company.findAll().list().stream()
|
||||||
|
.map(obj -> {
|
||||||
|
Company c = (Company) obj;
|
||||||
|
return new CompanyDto(c.id, c.name, c.active);
|
||||||
|
})
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
@POST
|
||||||
|
@Path("/companies")
|
||||||
|
@Transactional
|
||||||
|
public Response createCompany(CompanyCreateRequest request) {
|
||||||
|
Company company = new Company();
|
||||||
|
company.name = request.name();
|
||||||
|
company.persist();
|
||||||
|
return Response.status(Response.Status.CREATED)
|
||||||
|
.entity(new CompanyDto(company.id, company.name, company.active))
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@PUT
|
||||||
|
@Path("/companies/{id}")
|
||||||
|
@Transactional
|
||||||
|
public Response updateCompany(@PathParam("id") Long id, CompanyCreateRequest request) {
|
||||||
|
Company company = Company.findById(id);
|
||||||
|
if (company == null) {
|
||||||
|
return Response.status(Response.Status.NOT_FOUND).build();
|
||||||
|
}
|
||||||
|
company.name = request.name();
|
||||||
|
return Response.ok(new CompanyDto(company.id, company.name, company.active)).build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@PUT
|
||||||
|
@Path("/companies/{id}/toggle")
|
||||||
|
@Transactional
|
||||||
|
public Response toggleCompany(@PathParam("id") Long id) {
|
||||||
|
Company company = Company.findById(id);
|
||||||
|
if (company == null) {
|
||||||
|
return Response.status(Response.Status.NOT_FOUND).build();
|
||||||
|
}
|
||||||
|
company.active = !company.active;
|
||||||
|
return Response.ok(new CompanyDto(company.id, company.name, company.active)).build();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Products ---
|
||||||
|
|
||||||
|
@GET
|
||||||
|
@Path("/products")
|
||||||
|
public List<ProductDto> getAllProducts() {
|
||||||
|
return Product.findAll().list().stream()
|
||||||
|
.map(obj -> {
|
||||||
|
Product p = (Product) obj;
|
||||||
|
return new ProductDto(p.id, p.name, p.priceCents, p.iconPlaceholder, p.active);
|
||||||
|
})
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
@POST
|
||||||
|
@Path("/products")
|
||||||
|
@Transactional
|
||||||
|
public Response createProduct(ProductCreateRequest request) {
|
||||||
|
Product product = new Product();
|
||||||
|
product.name = request.name();
|
||||||
|
product.priceCents = request.priceCents();
|
||||||
|
product.iconPlaceholder = request.iconPlaceholder() != null ? request.iconPlaceholder() : "coffee";
|
||||||
|
product.persist();
|
||||||
|
return Response.status(Response.Status.CREATED)
|
||||||
|
.entity(new ProductDto(product.id, product.name, product.priceCents, product.iconPlaceholder, product.active))
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@PUT
|
||||||
|
@Path("/products/{id}")
|
||||||
|
@Transactional
|
||||||
|
public Response updateProduct(@PathParam("id") Long id, ProductCreateRequest request) {
|
||||||
|
Product product = Product.findById(id);
|
||||||
|
if (product == null) {
|
||||||
|
return Response.status(Response.Status.NOT_FOUND).build();
|
||||||
|
}
|
||||||
|
product.name = request.name();
|
||||||
|
product.priceCents = request.priceCents();
|
||||||
|
if (request.iconPlaceholder() != null) {
|
||||||
|
product.iconPlaceholder = request.iconPlaceholder();
|
||||||
|
}
|
||||||
|
return Response.ok(new ProductDto(product.id, product.name, product.priceCents, product.iconPlaceholder, product.active)).build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@PUT
|
||||||
|
@Path("/products/{id}/toggle")
|
||||||
|
@Transactional
|
||||||
|
public Response toggleProduct(@PathParam("id") Long id) {
|
||||||
|
Product product = Product.findById(id);
|
||||||
|
if (product == null) {
|
||||||
|
return Response.status(Response.Status.NOT_FOUND).build();
|
||||||
|
}
|
||||||
|
product.active = !product.active;
|
||||||
|
return Response.ok(new ProductDto(product.id, product.name, product.priceCents, product.iconPlaceholder, product.active)).build();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Reports ---
|
||||||
|
|
||||||
|
@GET
|
||||||
|
@Path("/report")
|
||||||
|
public Response getOverallReport(@QueryParam("month") String month) {
|
||||||
|
String monthKey = month != null ? month : LocalDateTime.now().format(MONTH_FORMAT);
|
||||||
|
|
||||||
|
List<Company> companies = Company.findAll().list();
|
||||||
|
List<MonthlyReportDto> reports = new ArrayList<>();
|
||||||
|
|
||||||
|
for (Company company : companies) {
|
||||||
|
MonthlyReportDto report = CompanyAdminResource.buildCompanyReport(company.id, monthKey);
|
||||||
|
if (!report.employees().isEmpty()) {
|
||||||
|
reports.add(report);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
long grandTotal = reports.stream().mapToLong(MonthlyReportDto::totalCents).sum();
|
||||||
|
|
||||||
|
var result = new ProviderReportDto(monthKey, reports, grandTotal);
|
||||||
|
return Response.ok(result).build();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Access Links ---
|
||||||
|
|
||||||
|
@GET
|
||||||
|
@Path("/access-links")
|
||||||
|
public List<AccessLinkDto> getAccessLinks() {
|
||||||
|
return AccessLink.findAll().list().stream()
|
||||||
|
.map(obj -> {
|
||||||
|
AccessLink a = (AccessLink) obj;
|
||||||
|
return new AccessLinkDto(a.id, a.token, a.role, a.company != null ? a.company.id : null, a.description, a.active);
|
||||||
|
})
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
@POST
|
||||||
|
@Path("/access-links")
|
||||||
|
@Transactional
|
||||||
|
public Response createAccessLink(AccessLinkCreateRequest request) {
|
||||||
|
AccessLink link = new AccessLink();
|
||||||
|
link.token = UUID.randomUUID().toString().replace("-", "");
|
||||||
|
link.role = request.role();
|
||||||
|
link.description = request.description();
|
||||||
|
|
||||||
|
if ("COMPANY_ADMIN".equals(request.role()) && request.companyId() != null) {
|
||||||
|
Company company = Company.findById(request.companyId());
|
||||||
|
if (company == null) {
|
||||||
|
return Response.status(Response.Status.BAD_REQUEST).build();
|
||||||
|
}
|
||||||
|
link.company = company;
|
||||||
|
}
|
||||||
|
|
||||||
|
link.persist();
|
||||||
|
return Response.status(Response.Status.CREATED)
|
||||||
|
.entity(new AccessLinkDto(link.id, link.token, link.role, link.company != null ? link.company.id : null, link.description, link.active))
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
public record ProviderReportDto(String month, List<MonthlyReportDto> companies, long grandTotalCents) {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
package de.strichliste.resource;
|
||||||
|
|
||||||
|
import de.strichliste.dto.*;
|
||||||
|
import de.strichliste.entity.*;
|
||||||
|
import jakarta.transaction.Transactional;
|
||||||
|
import jakarta.ws.rs.*;
|
||||||
|
import jakarta.ws.rs.core.MediaType;
|
||||||
|
import jakarta.ws.rs.core.Response;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Path("/api")
|
||||||
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
|
@Consumes(MediaType.APPLICATION_JSON)
|
||||||
|
public class PublicResource {
|
||||||
|
|
||||||
|
private static final DateTimeFormatter MONTH_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM");
|
||||||
|
|
||||||
|
@GET
|
||||||
|
@Path("/companies")
|
||||||
|
public List<CompanyDto> getActiveCompanies() {
|
||||||
|
return Company.findAllActive().stream()
|
||||||
|
.map(c -> new CompanyDto(c.id, c.name, c.active))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
@GET
|
||||||
|
@Path("/companies/{id}/employees")
|
||||||
|
public List<EmployeeDto> getEmployeesByCompany(@PathParam("id") Long companyId) {
|
||||||
|
return Employee.findActiveByCompany(companyId).stream()
|
||||||
|
.map(e -> new EmployeeDto(e.id, e.company.id, e.firstName, e.lastName, e.active))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
@GET
|
||||||
|
@Path("/products")
|
||||||
|
public List<ProductDto> getActiveProducts() {
|
||||||
|
return Product.findAllActive().stream()
|
||||||
|
.map(p -> new ProductDto(p.id, p.name, p.priceCents, p.iconPlaceholder, p.active))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
@POST
|
||||||
|
@Path("/tally")
|
||||||
|
@Transactional
|
||||||
|
public Response createTally(TallyRequest request) {
|
||||||
|
Employee employee = Employee.findById(request.employeeId());
|
||||||
|
if (employee == null) {
|
||||||
|
return Response.status(Response.Status.NOT_FOUND).build();
|
||||||
|
}
|
||||||
|
Product product = Product.findById(request.productId());
|
||||||
|
if (product == null) {
|
||||||
|
return Response.status(Response.Status.NOT_FOUND).build();
|
||||||
|
}
|
||||||
|
|
||||||
|
TallyEntry entry = new TallyEntry();
|
||||||
|
entry.employee = employee;
|
||||||
|
entry.product = product;
|
||||||
|
entry.persist();
|
||||||
|
|
||||||
|
return Response.status(Response.Status.CREATED).build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@GET
|
||||||
|
@Path("/tally/monthly/{employeeId}")
|
||||||
|
public List<MonthlyTallyDto> getMonthlyTally(
|
||||||
|
@PathParam("employeeId") Long employeeId,
|
||||||
|
@QueryParam("month") String month) {
|
||||||
|
|
||||||
|
String monthKey = month != null ? month : LocalDateTime.now().format(MONTH_FORMAT);
|
||||||
|
|
||||||
|
return TallyEntry.find(
|
||||||
|
"SELECT t.product.name, t.product.priceCents, COUNT(t), COUNT(t) * t.product.priceCents " +
|
||||||
|
"FROM TallyEntry t WHERE t.employee.id = ?1 AND t.monthKey = ?2 " +
|
||||||
|
"GROUP BY t.product.id, t.product.name, t.product.priceCents",
|
||||||
|
employeeId, monthKey)
|
||||||
|
.project(MonthlyTallyDto.class)
|
||||||
|
.list();
|
||||||
|
}
|
||||||
|
}
|
||||||
14
backend/src/main/resources/application.properties
Normal file
14
backend/src/main/resources/application.properties
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
# Datasource
|
||||||
|
quarkus.datasource.db-kind=mariadb
|
||||||
|
quarkus.datasource.username=strichliste
|
||||||
|
quarkus.datasource.password=strichliste
|
||||||
|
quarkus.datasource.jdbc.url=jdbc:mariadb://localhost:3306/strichliste
|
||||||
|
|
||||||
|
# Hibernate
|
||||||
|
quarkus.hibernate-orm.database.generation=none
|
||||||
|
|
||||||
|
# Flyway
|
||||||
|
quarkus.flyway.migrate-at-start=true
|
||||||
|
|
||||||
|
# CORS ist deaktiviert, da alle Anfragen über den SvelteKit-Proxy laufen
|
||||||
|
quarkus.http.cors=false
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
CREATE TABLE company (
|
||||||
|
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
CREATE TABLE employee (
|
||||||
|
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
company_id BIGINT NOT NULL,
|
||||||
|
first_name VARCHAR(255) NOT NULL,
|
||||||
|
last_name VARCHAR(255) NOT NULL,
|
||||||
|
active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT fk_employee_company FOREIGN KEY (company_id) REFERENCES company(id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
CREATE TABLE product (
|
||||||
|
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
price_cents INT NOT NULL DEFAULT 0,
|
||||||
|
icon_placeholder VARCHAR(50) DEFAULT 'coffee',
|
||||||
|
active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
CREATE TABLE tally_entry (
|
||||||
|
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
employee_id BIGINT NOT NULL,
|
||||||
|
product_id BIGINT NOT NULL,
|
||||||
|
month_key VARCHAR(7) NOT NULL,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT fk_tally_employee FOREIGN KEY (employee_id) REFERENCES employee(id),
|
||||||
|
CONSTRAINT fk_tally_product FOREIGN KEY (product_id) REFERENCES product(id),
|
||||||
|
INDEX idx_tally_month (month_key),
|
||||||
|
INDEX idx_tally_employee_month (employee_id, month_key)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
CREATE TABLE access_link (
|
||||||
|
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
token VARCHAR(64) NOT NULL UNIQUE,
|
||||||
|
role VARCHAR(20) NOT NULL,
|
||||||
|
company_id BIGINT,
|
||||||
|
description VARCHAR(255),
|
||||||
|
active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT fk_access_link_company FOREIGN KEY (company_id) REFERENCES company(id),
|
||||||
|
CONSTRAINT chk_role CHECK (role IN ('COMPANY_ADMIN', 'PROVIDER_ADMIN'))
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
25
backend/src/main/resources/db/migration/V2__seed_data.sql
Normal file
25
backend/src/main/resources/db/migration/V2__seed_data.sql
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
-- Beispiel-Firmen
|
||||||
|
INSERT INTO company (name) VALUES ('Musterfirma GmbH');
|
||||||
|
INSERT INTO company (name) VALUES ('Beispiel AG');
|
||||||
|
|
||||||
|
-- Beispiel-Mitarbeiter
|
||||||
|
INSERT INTO employee (company_id, first_name, last_name) VALUES (1, 'Max', 'Mustermann');
|
||||||
|
INSERT INTO employee (company_id, first_name, last_name) VALUES (1, 'Erika', 'Musterfrau');
|
||||||
|
INSERT INTO employee (company_id, first_name, last_name) VALUES (2, 'Hans', 'Beispiel');
|
||||||
|
|
||||||
|
-- Beispiel-Produkte
|
||||||
|
INSERT INTO product (name, price_cents, icon_placeholder) VALUES ('Kaffee', 50, 'coffee');
|
||||||
|
INSERT INTO product (name, price_cents, icon_placeholder) VALUES ('Cappuccino', 80, 'coffee');
|
||||||
|
INSERT INTO product (name, price_cents, icon_placeholder) VALUES ('Espresso', 40, 'coffee');
|
||||||
|
INSERT INTO product (name, price_cents, icon_placeholder) VALUES ('Kakao', 60, 'chocolate');
|
||||||
|
INSERT INTO product (name, price_cents, icon_placeholder) VALUES ('Tee', 30, 'tea');
|
||||||
|
|
||||||
|
-- Zugangslinks
|
||||||
|
INSERT INTO access_link (token, role, company_id, description)
|
||||||
|
VALUES ('company1-admin-token', 'COMPANY_ADMIN', 1, 'Admin-Zugang Musterfirma GmbH');
|
||||||
|
|
||||||
|
INSERT INTO access_link (token, role, company_id, description)
|
||||||
|
VALUES ('company2-admin-token', 'COMPANY_ADMIN', 2, 'Admin-Zugang Beispiel AG');
|
||||||
|
|
||||||
|
INSERT INTO access_link (token, role, company_id, description)
|
||||||
|
VALUES ('provider-admin-token', 'PROVIDER_ADMIN', NULL, 'Anbieter-Admin Zugang');
|
||||||
25
db/backup.sh
Normal file
25
db/backup.sh
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# MariaDB Backup Script - läuft als Endlos-Schleife im Container
|
||||||
|
BACKUP_DIR="/backups"
|
||||||
|
RETENTION_DAYS=7
|
||||||
|
|
||||||
|
while true; do
|
||||||
|
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
|
||||||
|
FILENAME="${BACKUP_DIR}/strichliste_${TIMESTAMP}.sql.gz"
|
||||||
|
|
||||||
|
echo "[$(date)] Starting backup..."
|
||||||
|
mariadb-dump -h db -u strichliste -pstrichliste strichliste | gzip > "${FILENAME}"
|
||||||
|
|
||||||
|
if [ $? -eq 0 ]; then
|
||||||
|
echo "[$(date)] Backup saved: ${FILENAME}"
|
||||||
|
else
|
||||||
|
echo "[$(date)] Backup FAILED"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Alte Backups löschen
|
||||||
|
find ${BACKUP_DIR} -name "strichliste_*.sql.gz" -mtime +${RETENTION_DAYS} -delete
|
||||||
|
echo "[$(date)] Cleaned up backups older than ${RETENTION_DAYS} days"
|
||||||
|
|
||||||
|
# 24 Stunden warten
|
||||||
|
sleep 86400
|
||||||
|
done
|
||||||
6
db/seed.sql
Normal file
6
db/seed.sql
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
-- Seed-Daten werden NACH Flyway-Migration eingefügt.
|
||||||
|
-- Diese Datei wird nur beim ERSTEN Start der DB ausgeführt (docker-entrypoint-initdb.d).
|
||||||
|
-- Flyway verwaltet das Schema, diese Datei nur Beispieldaten.
|
||||||
|
|
||||||
|
-- Hinweis: Die Tabellen existieren beim DB-Init noch nicht (Flyway läuft im Backend).
|
||||||
|
-- Daher nutzen wir eine separate Flyway-Migration für Seed-Daten.
|
||||||
57
docker-compose.yml
Normal file
57
docker-compose.yml
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
services:
|
||||||
|
db:
|
||||||
|
image: mariadb:11.4
|
||||||
|
environment:
|
||||||
|
MARIADB_ROOT_PASSWORD: rootpassword
|
||||||
|
MARIADB_DATABASE: strichliste
|
||||||
|
MARIADB_USER: strichliste
|
||||||
|
MARIADB_PASSWORD: strichliste
|
||||||
|
ports:
|
||||||
|
- "3306:3306"
|
||||||
|
volumes:
|
||||||
|
- db-data:/var/lib/mysql
|
||||||
|
- ./db/seed.sql:/docker-entrypoint-initdb.d/seed.sql
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
backend:
|
||||||
|
build: ./backend
|
||||||
|
environment:
|
||||||
|
QUARKUS_DATASOURCE_JDBC_URL: jdbc:mariadb://db:3306/strichliste
|
||||||
|
QUARKUS_DATASOURCE_USERNAME: strichliste
|
||||||
|
QUARKUS_DATASOURCE_PASSWORD: strichliste
|
||||||
|
QUARKUS_HTTP_CORS_ORIGINS: http://localhost:5173,http://localhost:4173
|
||||||
|
ports:
|
||||||
|
- "8080:8080"
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
build: ./frontend
|
||||||
|
environment:
|
||||||
|
API_URL: http://backend:8080
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
|
||||||
|
backup:
|
||||||
|
image: mariadb:11.4.4
|
||||||
|
environment:
|
||||||
|
MARIADB_HOST: db
|
||||||
|
MARIADB_USER: strichliste
|
||||||
|
MARIADB_PASSWORD: strichliste
|
||||||
|
volumes:
|
||||||
|
- ./backups:/backups
|
||||||
|
- ./db/backup.sh:/backup.sh
|
||||||
|
entrypoint: ["/bin/bash", "/backup.sh"]
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
db-data:
|
||||||
5
frontend/.gitignore
vendored
Normal file
5
frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules/
|
||||||
|
build/
|
||||||
|
.svelte-kit/
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
18
frontend/Dockerfile
Normal file
18
frontend/Dockerfile
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
FROM node:22-alpine AS build
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json ./
|
||||||
|
RUN npm install
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM node:22-alpine
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=build /app/build ./build
|
||||||
|
COPY --from=build /app/package.json ./
|
||||||
|
COPY --from=build /app/node_modules ./node_modules
|
||||||
|
|
||||||
|
ENV PORT=3000
|
||||||
|
ENV ORIGIN=http://localhost:3000
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
CMD ["node", "build"]
|
||||||
1901
frontend/package-lock.json
generated
Normal file
1901
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
21
frontend/package.json
Normal file
21
frontend/package.json
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"name": "strichliste-frontend",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite dev",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@sveltejs/adapter-node": "^5.2.0",
|
||||||
|
"@sveltejs/kit": "^2.8.0",
|
||||||
|
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
||||||
|
"svelte": "^5.0.0",
|
||||||
|
"svelte-check": "^4.0.0",
|
||||||
|
"typescript": "^5.6.0",
|
||||||
|
"vite": "^6.0.0"
|
||||||
|
},
|
||||||
|
"type": "module"
|
||||||
|
}
|
||||||
310
frontend/src/app.css
Normal file
310
frontend/src/app.css
Normal file
@@ -0,0 +1,310 @@
|
|||||||
|
:root {
|
||||||
|
--color-bg: #1a1a2e;
|
||||||
|
--color-bg-secondary: #16213e;
|
||||||
|
--color-bg-card: #0f3460;
|
||||||
|
--color-primary: #e94560;
|
||||||
|
--color-primary-hover: #ff6b81;
|
||||||
|
--color-text: #eaeaea;
|
||||||
|
--color-text-muted: #a0a0b0;
|
||||||
|
--color-success: #2ed573;
|
||||||
|
--color-warning: #ffa502;
|
||||||
|
--color-border: #2a2a4a;
|
||||||
|
--radius: 12px;
|
||||||
|
--radius-sm: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html, body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
background-color: var(--color-bg);
|
||||||
|
color: var(--color-text);
|
||||||
|
min-height: 100vh;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
-webkit-touch-callout: none;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: var(--color-primary);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Touch-optimierte Buttons */
|
||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 16px 32px;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.1s, background-color 0.2s;
|
||||||
|
touch-action: manipulation;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:active {
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background-color: var(--color-primary);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
background-color: var(--color-primary-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background-color: var(--color-bg-card);
|
||||||
|
color: var(--color-text);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-success {
|
||||||
|
background-color: var(--color-success);
|
||||||
|
color: #1a1a2e;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Kacheln für Touch-Auswahl */
|
||||||
|
.card-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: var(--color-bg-card);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 24px;
|
||||||
|
text-align: center;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.1s, box-shadow 0.2s;
|
||||||
|
touch-action: manipulation;
|
||||||
|
min-height: 120px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card:active {
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card:hover {
|
||||||
|
box-shadow: 0 4px 20px rgba(233, 69, 96, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card h3 {
|
||||||
|
font-size: 1.3rem;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card .subtitle {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header */
|
||||||
|
.page-header {
|
||||||
|
padding: 24px;
|
||||||
|
text-align: center;
|
||||||
|
background: var(--color-bg-secondary);
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header h1 {
|
||||||
|
font-size: 1.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header .back-btn {
|
||||||
|
position: absolute;
|
||||||
|
left: 16px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
font-size: 1.5rem;
|
||||||
|
color: var(--color-text);
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Toast-Benachrichtigung */
|
||||||
|
.toast {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 40px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
background: var(--color-success);
|
||||||
|
color: #1a1a2e;
|
||||||
|
padding: 16px 32px;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
font-size: 1.2rem;
|
||||||
|
font-weight: 600;
|
||||||
|
z-index: 1000;
|
||||||
|
animation: toast-in 0.3s ease-out, toast-out 0.3s ease-in 1.7s forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes toast-in {
|
||||||
|
from { opacity: 0; transform: translateX(-50%) translateY(20px); }
|
||||||
|
to { opacity: 1; transform: translateX(-50%) translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes toast-out {
|
||||||
|
from { opacity: 1; }
|
||||||
|
to { opacity: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Admin-Tabellen */
|
||||||
|
.admin-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-table th,
|
||||||
|
.admin-table td {
|
||||||
|
padding: 12px 16px;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-table th {
|
||||||
|
background: var(--color-bg-secondary);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-table tr:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Admin-Layout */
|
||||||
|
.admin-container {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-header h1 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Formulare */
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input,
|
||||||
|
.form-group select {
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px 14px;
|
||||||
|
background: var(--color-bg);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--color-text);
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input:focus,
|
||||||
|
.form-group select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modal */
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.7);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
background: var(--color-bg-secondary);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 32px;
|
||||||
|
min-width: 400px;
|
||||||
|
max-width: 90vw;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal h2 {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Badge */
|
||||||
|
.badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-active {
|
||||||
|
background: rgba(46, 213, 115, 0.2);
|
||||||
|
color: var(--color-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-inactive {
|
||||||
|
background: rgba(255, 165, 2, 0.2);
|
||||||
|
color: var(--color-warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Preis-Formatierung */
|
||||||
|
.price {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Monatswähler */
|
||||||
|
.month-selector {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.month-selector input[type="month"] {
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: var(--color-bg);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--color-text);
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
15
frontend/src/app.html
Normal file
15
frontend/src/app.html
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no" />
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||||
|
<link rel="manifest" href="/manifest.json" />
|
||||||
|
<link rel="icon" href="/favicon.png" />
|
||||||
|
%sveltekit.head%
|
||||||
|
</head>
|
||||||
|
<body data-sveltekit-preload-data="hover">
|
||||||
|
<div style="display: contents">%sveltekit.body%</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
24
frontend/src/hooks.server.ts
Normal file
24
frontend/src/hooks.server.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import type { Handle } from '@sveltejs/kit';
|
||||||
|
|
||||||
|
const API_URL = process.env.API_URL ?? 'http://localhost:8080';
|
||||||
|
|
||||||
|
export const handle: Handle = async ({ event, resolve }) => {
|
||||||
|
if (event.url.pathname.startsWith('/api')) {
|
||||||
|
const targetUrl = `${API_URL}${event.url.pathname}${event.url.search}`;
|
||||||
|
|
||||||
|
const response = await fetch(targetUrl, {
|
||||||
|
method: event.request.method,
|
||||||
|
headers: event.request.headers,
|
||||||
|
body: ['GET', 'HEAD'].includes(event.request.method) ? undefined : event.request.body,
|
||||||
|
// @ts-ignore
|
||||||
|
duplex: 'half'
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Response(response.body, {
|
||||||
|
status: response.status,
|
||||||
|
headers: response.headers
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return resolve(event);
|
||||||
|
};
|
||||||
153
frontend/src/lib/api/client.ts
Normal file
153
frontend/src/lib/api/client.ts
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
const API_BASE = '/api';
|
||||||
|
|
||||||
|
async function request<T>(path: string, options?: RequestInit): Promise<T> {
|
||||||
|
const res = await fetch(`${API_BASE}${path}`, {
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
...options
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`API error: ${res.status}`);
|
||||||
|
}
|
||||||
|
if (res.status === 204 || res.headers.get('content-length') === '0') {
|
||||||
|
return undefined as T;
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Company {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
active: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Employee {
|
||||||
|
id: number;
|
||||||
|
companyId: number;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
active: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Product {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
priceCents: number;
|
||||||
|
iconPlaceholder: string;
|
||||||
|
active: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MonthlyTally {
|
||||||
|
productName: string;
|
||||||
|
priceCents: number;
|
||||||
|
count: number;
|
||||||
|
totalCents: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EmployeeReportLine {
|
||||||
|
employeeId: number;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
totalCount: number;
|
||||||
|
totalCents: number;
|
||||||
|
products: MonthlyTally[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MonthlyReport {
|
||||||
|
month: string;
|
||||||
|
companyId: number | null;
|
||||||
|
companyName: string | null;
|
||||||
|
employees: EmployeeReportLine[];
|
||||||
|
totalCents: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AccessLink {
|
||||||
|
id: number;
|
||||||
|
token: string;
|
||||||
|
role: string;
|
||||||
|
companyId: number | null;
|
||||||
|
description: string;
|
||||||
|
active: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProviderReport {
|
||||||
|
month: string;
|
||||||
|
companies: MonthlyReport[];
|
||||||
|
grandTotalCents: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Public (iPad) ---
|
||||||
|
export const api = {
|
||||||
|
getCompanies: () => request<Company[]>('/companies'),
|
||||||
|
getEmployees: (companyId: number) => request<Employee[]>(`/companies/${companyId}/employees`),
|
||||||
|
getProducts: () => request<Product[]>('/products'),
|
||||||
|
createTally: (employeeId: number, productId: number) =>
|
||||||
|
request<void>('/tally', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ employeeId, productId })
|
||||||
|
}),
|
||||||
|
getMonthlyTally: (employeeId: number, month?: string) =>
|
||||||
|
request<MonthlyTally[]>(`/tally/monthly/${employeeId}${month ? `?month=${month}` : ''}`),
|
||||||
|
|
||||||
|
// --- Company Admin ---
|
||||||
|
companyAdmin: {
|
||||||
|
getEmployees: (token: string) =>
|
||||||
|
request<Employee[]>(`/admin/company/employees?token=${token}`),
|
||||||
|
createEmployee: (token: string, firstName: string, lastName: string) =>
|
||||||
|
request<Employee>(`/admin/company/employees?token=${token}`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ firstName, lastName })
|
||||||
|
}),
|
||||||
|
updateEmployee: (token: string, id: number, firstName: string, lastName: string) =>
|
||||||
|
request<Employee>(`/admin/company/employees/${id}?token=${token}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({ firstName, lastName })
|
||||||
|
}),
|
||||||
|
toggleEmployee: (token: string, id: number) =>
|
||||||
|
request<Employee>(`/admin/company/employees/${id}/toggle?token=${token}`, { method: 'PUT' }),
|
||||||
|
getReport: (token: string, month?: string) =>
|
||||||
|
request<MonthlyReport>(`/admin/company/report?token=${token}${month ? `&month=${month}` : ''}`),
|
||||||
|
getEmployeeReport: (token: string, employeeId: number, month?: string) =>
|
||||||
|
request<EmployeeReportLine>(`/admin/company/report/employee/${employeeId}?token=${token}${month ? `&month=${month}` : ''}`)
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- Provider Admin ---
|
||||||
|
providerAdmin: {
|
||||||
|
getCompanies: (token: string) =>
|
||||||
|
request<Company[]>(`/admin/provider/companies?token=${token}`),
|
||||||
|
createCompany: (token: string, name: string) =>
|
||||||
|
request<Company>(`/admin/provider/companies?token=${token}`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ name })
|
||||||
|
}),
|
||||||
|
updateCompany: (token: string, id: number, name: string) =>
|
||||||
|
request<Company>(`/admin/provider/companies/${id}?token=${token}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({ name })
|
||||||
|
}),
|
||||||
|
toggleCompany: (token: string, id: number) =>
|
||||||
|
request<Company>(`/admin/provider/companies/${id}/toggle?token=${token}`, { method: 'PUT' }),
|
||||||
|
getProducts: (token: string) =>
|
||||||
|
request<Product[]>(`/admin/provider/products?token=${token}`),
|
||||||
|
createProduct: (token: string, name: string, priceCents: number, iconPlaceholder?: string) =>
|
||||||
|
request<Product>(`/admin/provider/products?token=${token}`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ name, priceCents, iconPlaceholder })
|
||||||
|
}),
|
||||||
|
updateProduct: (token: string, id: number, name: string, priceCents: number, iconPlaceholder?: string) =>
|
||||||
|
request<Product>(`/admin/provider/products/${id}?token=${token}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({ name, priceCents, iconPlaceholder })
|
||||||
|
}),
|
||||||
|
toggleProduct: (token: string, id: number) =>
|
||||||
|
request<Product>(`/admin/provider/products/${id}/toggle?token=${token}`, { method: 'PUT' }),
|
||||||
|
getReport: (token: string, month?: string) =>
|
||||||
|
request<ProviderReport>(`/admin/provider/report?token=${token}${month ? `&month=${month}` : ''}`),
|
||||||
|
getAccessLinks: (token: string) =>
|
||||||
|
request<AccessLink[]>(`/admin/provider/access-links?token=${token}`),
|
||||||
|
createAccessLink: (token: string, role: string, companyId?: number, description?: string) =>
|
||||||
|
request<AccessLink>(`/admin/provider/access-links?token=${token}`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ role, companyId, description })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
};
|
||||||
5
frontend/src/routes/+layout.svelte
Normal file
5
frontend/src/routes/+layout.svelte
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<script>
|
||||||
|
import '../app.css';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<slot />
|
||||||
36
frontend/src/routes/+page.svelte
Normal file
36
frontend/src/routes/+page.svelte
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { api, type Company } from '$lib/api/client';
|
||||||
|
|
||||||
|
let companies: Company[] = [];
|
||||||
|
let loading = true;
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
companies = await api.getCompanies();
|
||||||
|
loading = false;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Strichliste</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="page-header">
|
||||||
|
<h1>Strichliste</h1>
|
||||||
|
<p style="color: var(--color-text-muted); margin-top: 4px;">Firma auswählen</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if loading}
|
||||||
|
<div style="text-align: center; padding: 48px;">
|
||||||
|
<p>Laden...</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="card-grid" style="padding: 24px;">
|
||||||
|
{#each companies as company}
|
||||||
|
<a href="/company/{company.id}" class="card">
|
||||||
|
<div style="font-size: 2.5rem;">🏢</div>
|
||||||
|
<h3>{company.name}</h3>
|
||||||
|
</a>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
228
frontend/src/routes/admin/company/+page.svelte
Normal file
228
frontend/src/routes/admin/company/+page.svelte
Normal file
@@ -0,0 +1,228 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
import { api, type Employee, type MonthlyReport, type EmployeeReportLine } from '$lib/api/client';
|
||||||
|
|
||||||
|
let token = '';
|
||||||
|
let employees: Employee[] = [];
|
||||||
|
let report: MonthlyReport | null = null;
|
||||||
|
let selectedEmployee: EmployeeReportLine | null = null;
|
||||||
|
let loading = true;
|
||||||
|
let selectedMonth = new Date().toISOString().slice(0, 7);
|
||||||
|
|
||||||
|
// Modal state
|
||||||
|
let showModal = false;
|
||||||
|
let editId: number | null = null;
|
||||||
|
let formFirstName = '';
|
||||||
|
let formLastName = '';
|
||||||
|
|
||||||
|
$: token = $page.url.searchParams.get('token') ?? '';
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
if (!token) return;
|
||||||
|
await loadData();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadData() {
|
||||||
|
loading = true;
|
||||||
|
[employees, report] = await Promise.all([
|
||||||
|
api.companyAdmin.getEmployees(token),
|
||||||
|
api.companyAdmin.getReport(token, selectedMonth)
|
||||||
|
]);
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function changeMonth() {
|
||||||
|
report = await api.companyAdmin.getReport(token, selectedMonth);
|
||||||
|
selectedEmployee = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreate() {
|
||||||
|
editId = null;
|
||||||
|
formFirstName = '';
|
||||||
|
formLastName = '';
|
||||||
|
showModal = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(emp: Employee) {
|
||||||
|
editId = emp.id;
|
||||||
|
formFirstName = emp.firstName;
|
||||||
|
formLastName = emp.lastName;
|
||||||
|
showModal = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveEmployee() {
|
||||||
|
if (editId) {
|
||||||
|
await api.companyAdmin.updateEmployee(token, editId, formFirstName, formLastName);
|
||||||
|
} else {
|
||||||
|
await api.companyAdmin.createEmployee(token, formFirstName, formLastName);
|
||||||
|
}
|
||||||
|
showModal = false;
|
||||||
|
await loadData();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleEmployee(id: number) {
|
||||||
|
await api.companyAdmin.toggleEmployee(token, id);
|
||||||
|
await loadData();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function showEmployeeDetail(employeeId: number) {
|
||||||
|
selectedEmployee = await api.companyAdmin.getEmployeeReport(token, employeeId, selectedMonth);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPrice(cents: number): string {
|
||||||
|
return (cents / 100).toFixed(2).replace('.', ',') + ' €';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Firmen-Admin - Strichliste</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="admin-container">
|
||||||
|
<div class="admin-header">
|
||||||
|
<h1>Firmen-Administration</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if !token}
|
||||||
|
<p>Kein Zugangstoken angegeben.</p>
|
||||||
|
{:else if loading}
|
||||||
|
<p>Laden...</p>
|
||||||
|
{:else}
|
||||||
|
<!-- Mitarbeiterverwaltung -->
|
||||||
|
<section style="margin-bottom: 40px;">
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px;">
|
||||||
|
<h2>Mitarbeiter</h2>
|
||||||
|
<button class="btn btn-primary" on:click={openCreate}>+ Mitarbeiter anlegen</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table class="admin-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Aktionen</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each employees as emp}
|
||||||
|
<tr>
|
||||||
|
<td>{emp.firstName} {emp.lastName}</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge" class:badge-active={emp.active} class:badge-inactive={!emp.active}>
|
||||||
|
{emp.active ? 'Aktiv' : 'Inaktiv'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<button class="btn btn-secondary" style="padding: 6px 12px; font-size: 0.85rem;" on:click={() => openEdit(emp)}>Bearbeiten</button>
|
||||||
|
<button class="btn btn-secondary" style="padding: 6px 12px; font-size: 0.85rem; margin-left: 8px;" on:click={() => toggleEmployee(emp.id)}>
|
||||||
|
{emp.active ? 'Deaktivieren' : 'Aktivieren'}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Monatsauswertung -->
|
||||||
|
<section>
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px;">
|
||||||
|
<h2>Monatsauswertung</h2>
|
||||||
|
<div class="month-selector">
|
||||||
|
<input type="month" bind:value={selectedMonth} on:change={changeMonth} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if report}
|
||||||
|
<table class="admin-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Mitarbeiter</th>
|
||||||
|
<th style="text-align: right;">Anzahl</th>
|
||||||
|
<th style="text-align: right;">Summe</th>
|
||||||
|
<th>Detail</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each report.employees as line}
|
||||||
|
<tr>
|
||||||
|
<td>{line.firstName} {line.lastName}</td>
|
||||||
|
<td style="text-align: right;">{line.totalCount}×</td>
|
||||||
|
<td style="text-align: right;" class="price">{formatPrice(line.totalCents)}</td>
|
||||||
|
<td>
|
||||||
|
<button class="btn btn-secondary" style="padding: 6px 12px; font-size: 0.85rem;" on:click={() => showEmployeeDetail(line.employeeId)}>Details</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
{#if report.employees.length > 0}
|
||||||
|
<tr style="font-weight: 700;">
|
||||||
|
<td>Gesamt</td>
|
||||||
|
<td></td>
|
||||||
|
<td style="text-align: right;" class="price">{formatPrice(report.totalCents)}</td>
|
||||||
|
<td></td>
|
||||||
|
</tr>
|
||||||
|
{:else}
|
||||||
|
<tr><td colspan="4" style="text-align: center; color: var(--color-text-muted);">Keine Einträge in diesem Monat</td></tr>
|
||||||
|
{/if}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if selectedEmployee}
|
||||||
|
<div style="margin-top: 24px; background: var(--color-bg-secondary); padding: 20px; border-radius: var(--radius);">
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: center;">
|
||||||
|
<h3>Detail: {selectedEmployee.firstName} {selectedEmployee.lastName}</h3>
|
||||||
|
<button class="btn btn-secondary" style="padding: 6px 12px; font-size: 0.85rem;" on:click={() => selectedEmployee = null}>Schließen</button>
|
||||||
|
</div>
|
||||||
|
<table class="admin-table" style="margin-top: 12px;">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Produkt</th>
|
||||||
|
<th style="text-align: right;">Einzelpreis</th>
|
||||||
|
<th style="text-align: right;">Anzahl</th>
|
||||||
|
<th style="text-align: right;">Summe</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each selectedEmployee.products as prod}
|
||||||
|
<tr>
|
||||||
|
<td>{prod.productName}</td>
|
||||||
|
<td style="text-align: right;" class="price">{formatPrice(prod.priceCents)}</td>
|
||||||
|
<td style="text-align: right;">{prod.count}×</td>
|
||||||
|
<td style="text-align: right;" class="price">{formatPrice(prod.totalCents)}</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
<tr style="font-weight: 700;">
|
||||||
|
<td>Gesamt</td>
|
||||||
|
<td></td>
|
||||||
|
<td style="text-align: right;">{selectedEmployee.totalCount}×</td>
|
||||||
|
<td style="text-align: right;" class="price">{formatPrice(selectedEmployee.totalCents)}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if showModal}
|
||||||
|
<div class="modal-overlay" on:click|self={() => showModal = false}>
|
||||||
|
<div class="modal">
|
||||||
|
<h2>{editId ? 'Mitarbeiter bearbeiten' : 'Neuer Mitarbeiter'}</h2>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="firstName">Vorname</label>
|
||||||
|
<input id="firstName" type="text" bind:value={formFirstName} />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="lastName">Nachname</label>
|
||||||
|
<input id="lastName" type="text" bind:value={formLastName} />
|
||||||
|
</div>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button class="btn btn-secondary" on:click={() => showModal = false}>Abbrechen</button>
|
||||||
|
<button class="btn btn-primary" on:click={saveEmployee}>Speichern</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
402
frontend/src/routes/admin/provider/+page.svelte
Normal file
402
frontend/src/routes/admin/provider/+page.svelte
Normal file
@@ -0,0 +1,402 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
import { api, type Company, type Product, type ProviderReport, type AccessLink } from '$lib/api/client';
|
||||||
|
|
||||||
|
let token = '';
|
||||||
|
let activeTab: 'companies' | 'products' | 'report' | 'links' = 'companies';
|
||||||
|
|
||||||
|
// Data
|
||||||
|
let companies: Company[] = [];
|
||||||
|
let products: Product[] = [];
|
||||||
|
let report: ProviderReport | null = null;
|
||||||
|
let accessLinks: AccessLink[] = [];
|
||||||
|
let loading = true;
|
||||||
|
let selectedMonth = new Date().toISOString().slice(0, 7);
|
||||||
|
|
||||||
|
// Company Modal
|
||||||
|
let showCompanyModal = false;
|
||||||
|
let editCompanyId: number | null = null;
|
||||||
|
let formCompanyName = '';
|
||||||
|
|
||||||
|
// Product Modal
|
||||||
|
let showProductModal = false;
|
||||||
|
let editProductId: number | null = null;
|
||||||
|
let formProductName = '';
|
||||||
|
let formProductPrice = 0;
|
||||||
|
let formProductIcon = 'coffee';
|
||||||
|
|
||||||
|
// Link Modal
|
||||||
|
let showLinkModal = false;
|
||||||
|
let formLinkRole = 'COMPANY_ADMIN';
|
||||||
|
let formLinkCompanyId: number | null = null;
|
||||||
|
let formLinkDescription = '';
|
||||||
|
|
||||||
|
$: token = $page.url.searchParams.get('token') ?? '';
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
if (!token) return;
|
||||||
|
await loadAll();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadAll() {
|
||||||
|
loading = true;
|
||||||
|
[companies, products, accessLinks] = await Promise.all([
|
||||||
|
api.providerAdmin.getCompanies(token),
|
||||||
|
api.providerAdmin.getProducts(token),
|
||||||
|
api.providerAdmin.getAccessLinks(token)
|
||||||
|
]);
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadReport() {
|
||||||
|
report = await api.providerAdmin.getReport(token, selectedMonth);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function switchTab(tab: typeof activeTab) {
|
||||||
|
activeTab = tab;
|
||||||
|
if (tab === 'report' && !report) {
|
||||||
|
await loadReport();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Company CRUD ---
|
||||||
|
function openCreateCompany() {
|
||||||
|
editCompanyId = null;
|
||||||
|
formCompanyName = '';
|
||||||
|
showCompanyModal = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditCompany(c: Company) {
|
||||||
|
editCompanyId = c.id;
|
||||||
|
formCompanyName = c.name;
|
||||||
|
showCompanyModal = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveCompany() {
|
||||||
|
if (editCompanyId) {
|
||||||
|
await api.providerAdmin.updateCompany(token, editCompanyId, formCompanyName);
|
||||||
|
} else {
|
||||||
|
await api.providerAdmin.createCompany(token, formCompanyName);
|
||||||
|
}
|
||||||
|
showCompanyModal = false;
|
||||||
|
companies = await api.providerAdmin.getCompanies(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleCompany(id: number) {
|
||||||
|
await api.providerAdmin.toggleCompany(token, id);
|
||||||
|
companies = await api.providerAdmin.getCompanies(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Product CRUD ---
|
||||||
|
function openCreateProduct() {
|
||||||
|
editProductId = null;
|
||||||
|
formProductName = '';
|
||||||
|
formProductPrice = 0;
|
||||||
|
formProductIcon = 'coffee';
|
||||||
|
showProductModal = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditProduct(p: Product) {
|
||||||
|
editProductId = p.id;
|
||||||
|
formProductName = p.name;
|
||||||
|
formProductPrice = p.priceCents;
|
||||||
|
formProductIcon = p.iconPlaceholder;
|
||||||
|
showProductModal = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveProduct() {
|
||||||
|
if (editProductId) {
|
||||||
|
await api.providerAdmin.updateProduct(token, editProductId, formProductName, formProductPrice, formProductIcon);
|
||||||
|
} else {
|
||||||
|
await api.providerAdmin.createProduct(token, formProductName, formProductPrice, formProductIcon);
|
||||||
|
}
|
||||||
|
showProductModal = false;
|
||||||
|
products = await api.providerAdmin.getProducts(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleProduct(id: number) {
|
||||||
|
await api.providerAdmin.toggleProduct(token, id);
|
||||||
|
products = await api.providerAdmin.getProducts(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Access Links ---
|
||||||
|
function openCreateLink() {
|
||||||
|
formLinkRole = 'COMPANY_ADMIN';
|
||||||
|
formLinkCompanyId = companies.length > 0 ? companies[0].id : null;
|
||||||
|
formLinkDescription = '';
|
||||||
|
showLinkModal = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveLink() {
|
||||||
|
await api.providerAdmin.createAccessLink(
|
||||||
|
token,
|
||||||
|
formLinkRole,
|
||||||
|
formLinkRole === 'COMPANY_ADMIN' ? formLinkCompanyId ?? undefined : undefined,
|
||||||
|
formLinkDescription
|
||||||
|
);
|
||||||
|
showLinkModal = false;
|
||||||
|
accessLinks = await api.providerAdmin.getAccessLinks(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPrice(cents: number): string {
|
||||||
|
return (cents / 100).toFixed(2).replace('.', ',') + ' €';
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildLink(link: AccessLink): string {
|
||||||
|
const base = window.location.origin;
|
||||||
|
if (link.role === 'COMPANY_ADMIN') {
|
||||||
|
return `${base}/admin/company?token=${link.token}`;
|
||||||
|
}
|
||||||
|
return `${base}/admin/provider?token=${link.token}`;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Anbieter-Admin - Strichliste</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="admin-container">
|
||||||
|
<div class="admin-header">
|
||||||
|
<h1>Anbieter-Administration</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if !token}
|
||||||
|
<p>Kein Zugangstoken angegeben.</p>
|
||||||
|
{:else if loading}
|
||||||
|
<p>Laden...</p>
|
||||||
|
{:else}
|
||||||
|
<!-- Tab Navigation -->
|
||||||
|
<div style="display: flex; gap: 8px; margin-bottom: 24px; border-bottom: 1px solid var(--color-border); padding-bottom: 12px;">
|
||||||
|
{#each [
|
||||||
|
{ key: 'companies', label: 'Firmen' },
|
||||||
|
{ key: 'products', label: 'Produkte' },
|
||||||
|
{ key: 'report', label: 'Auswertung' },
|
||||||
|
{ key: 'links', label: 'Zugangslinks' }
|
||||||
|
] as tab}
|
||||||
|
<button
|
||||||
|
class="btn btn-secondary"
|
||||||
|
style="padding: 8px 20px; {activeTab === tab.key ? 'background: var(--color-primary); color: white; border-color: var(--color-primary);' : ''}"
|
||||||
|
on:click={() => switchTab(tab.key as typeof activeTab)}
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Firmen -->
|
||||||
|
{#if activeTab === 'companies'}
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px;">
|
||||||
|
<h2>Firmen</h2>
|
||||||
|
<button class="btn btn-primary" on:click={openCreateCompany}>+ Firma anlegen</button>
|
||||||
|
</div>
|
||||||
|
<table class="admin-table">
|
||||||
|
<thead>
|
||||||
|
<tr><th>Name</th><th>Status</th><th>Aktionen</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each companies as c}
|
||||||
|
<tr>
|
||||||
|
<td>{c.name}</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge" class:badge-active={c.active} class:badge-inactive={!c.active}>
|
||||||
|
{c.active ? 'Aktiv' : 'Inaktiv'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<button class="btn btn-secondary" style="padding: 6px 12px; font-size: 0.85rem;" on:click={() => openEditCompany(c)}>Bearbeiten</button>
|
||||||
|
<button class="btn btn-secondary" style="padding: 6px 12px; font-size: 0.85rem; margin-left: 8px;" on:click={() => toggleCompany(c.id)}>
|
||||||
|
{c.active ? 'Deaktivieren' : 'Aktivieren'}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Produkte -->
|
||||||
|
{#if activeTab === 'products'}
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px;">
|
||||||
|
<h2>Produkte</h2>
|
||||||
|
<button class="btn btn-primary" on:click={openCreateProduct}>+ Produkt anlegen</button>
|
||||||
|
</div>
|
||||||
|
<table class="admin-table">
|
||||||
|
<thead>
|
||||||
|
<tr><th>Name</th><th style="text-align: right;">Preis</th><th>Icon</th><th>Status</th><th>Aktionen</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each products as p}
|
||||||
|
<tr>
|
||||||
|
<td>{p.name}</td>
|
||||||
|
<td style="text-align: right;" class="price">{formatPrice(p.priceCents)}</td>
|
||||||
|
<td>{p.iconPlaceholder}</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge" class:badge-active={p.active} class:badge-inactive={!p.active}>
|
||||||
|
{p.active ? 'Aktiv' : 'Inaktiv'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<button class="btn btn-secondary" style="padding: 6px 12px; font-size: 0.85rem;" on:click={() => openEditProduct(p)}>Bearbeiten</button>
|
||||||
|
<button class="btn btn-secondary" style="padding: 6px 12px; font-size: 0.85rem; margin-left: 8px;" on:click={() => toggleProduct(p.id)}>
|
||||||
|
{p.active ? 'Deaktivieren' : 'Aktivieren'}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Auswertung -->
|
||||||
|
{#if activeTab === 'report'}
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px;">
|
||||||
|
<h2>Gesamtauswertung</h2>
|
||||||
|
<div class="month-selector">
|
||||||
|
<input type="month" bind:value={selectedMonth} on:change={loadReport} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{#if report}
|
||||||
|
{#each report.companies as companyReport}
|
||||||
|
<div style="margin-bottom: 24px; background: var(--color-bg-secondary); padding: 20px; border-radius: var(--radius);">
|
||||||
|
<h3 style="margin-bottom: 12px;">{companyReport.companyName ?? 'Firma'}</h3>
|
||||||
|
<table class="admin-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Mitarbeiter</th>
|
||||||
|
<th style="text-align: right;">Anzahl</th>
|
||||||
|
<th style="text-align: right;">Summe</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each companyReport.employees as line}
|
||||||
|
<tr>
|
||||||
|
<td>{line.firstName} {line.lastName}</td>
|
||||||
|
<td style="text-align: right;">{line.totalCount}×</td>
|
||||||
|
<td style="text-align: right;" class="price">{formatPrice(line.totalCents)}</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
<tr style="font-weight: 700;">
|
||||||
|
<td>Firma-Gesamt</td>
|
||||||
|
<td></td>
|
||||||
|
<td style="text-align: right;" class="price">{formatPrice(companyReport.totalCents)}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
<div style="text-align: right; font-size: 1.2rem; font-weight: 700; padding: 16px;">
|
||||||
|
Gesamtsumme: <span class="price">{formatPrice(report.grandTotalCents)}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Zugangslinks -->
|
||||||
|
{#if activeTab === 'links'}
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px;">
|
||||||
|
<h2>Zugangslinks</h2>
|
||||||
|
<button class="btn btn-primary" on:click={openCreateLink}>+ Link erstellen</button>
|
||||||
|
</div>
|
||||||
|
<table class="admin-table">
|
||||||
|
<thead>
|
||||||
|
<tr><th>Beschreibung</th><th>Rolle</th><th>Link</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each accessLinks as link}
|
||||||
|
<tr>
|
||||||
|
<td>{link.description ?? '-'}</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge badge-active">
|
||||||
|
{link.role === 'COMPANY_ADMIN' ? 'Firmen-Admin' : 'Anbieter-Admin'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<code style="font-size: 0.8rem; word-break: break-all;">{buildLink(link)}</code>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Company Modal -->
|
||||||
|
{#if showCompanyModal}
|
||||||
|
<div class="modal-overlay" on:click|self={() => showCompanyModal = false}>
|
||||||
|
<div class="modal">
|
||||||
|
<h2>{editCompanyId ? 'Firma bearbeiten' : 'Neue Firma'}</h2>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="companyName">Firmenname</label>
|
||||||
|
<input id="companyName" type="text" bind:value={formCompanyName} />
|
||||||
|
</div>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button class="btn btn-secondary" on:click={() => showCompanyModal = false}>Abbrechen</button>
|
||||||
|
<button class="btn btn-primary" on:click={saveCompany}>Speichern</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Product Modal -->
|
||||||
|
{#if showProductModal}
|
||||||
|
<div class="modal-overlay" on:click|self={() => showProductModal = false}>
|
||||||
|
<div class="modal">
|
||||||
|
<h2>{editProductId ? 'Produkt bearbeiten' : 'Neues Produkt'}</h2>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="productName">Produktname</label>
|
||||||
|
<input id="productName" type="text" bind:value={formProductName} />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="productPrice">Preis (Cent)</label>
|
||||||
|
<input id="productPrice" type="number" bind:value={formProductPrice} min="0" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="productIcon">Icon</label>
|
||||||
|
<select id="productIcon" bind:value={formProductIcon}>
|
||||||
|
<option value="coffee">Kaffee ☕</option>
|
||||||
|
<option value="chocolate">Kakao 🍫</option>
|
||||||
|
<option value="tea">Tee 🍵</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button class="btn btn-secondary" on:click={() => showProductModal = false}>Abbrechen</button>
|
||||||
|
<button class="btn btn-primary" on:click={saveProduct}>Speichern</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Link Modal -->
|
||||||
|
{#if showLinkModal}
|
||||||
|
<div class="modal-overlay" on:click|self={() => showLinkModal = false}>
|
||||||
|
<div class="modal">
|
||||||
|
<h2>Neuer Zugangslink</h2>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="linkRole">Rolle</label>
|
||||||
|
<select id="linkRole" bind:value={formLinkRole}>
|
||||||
|
<option value="COMPANY_ADMIN">Firmen-Admin</option>
|
||||||
|
<option value="PROVIDER_ADMIN">Anbieter-Admin</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{#if formLinkRole === 'COMPANY_ADMIN'}
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="linkCompany">Firma</label>
|
||||||
|
<select id="linkCompany" bind:value={formLinkCompanyId}>
|
||||||
|
{#each companies as c}
|
||||||
|
<option value={c.id}>{c.name}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="linkDesc">Beschreibung</label>
|
||||||
|
<input id="linkDesc" type="text" bind:value={formLinkDescription} placeholder="z.B. Admin-Zugang für Firma X" />
|
||||||
|
</div>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button class="btn btn-secondary" on:click={() => showLinkModal = false}>Abbrechen</button>
|
||||||
|
<button class="btn btn-primary" on:click={saveLink}>Erstellen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
47
frontend/src/routes/company/[id]/+page.svelte
Normal file
47
frontend/src/routes/company/[id]/+page.svelte
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
import { api, type Employee, type Company } from '$lib/api/client';
|
||||||
|
|
||||||
|
let employees: Employee[] = [];
|
||||||
|
let companyName = '';
|
||||||
|
let loading = true;
|
||||||
|
|
||||||
|
$: companyId = Number($page.params.id);
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
const [emps, companies] = await Promise.all([
|
||||||
|
api.getEmployees(companyId),
|
||||||
|
api.getCompanies()
|
||||||
|
]);
|
||||||
|
employees = emps;
|
||||||
|
const company = companies.find((c: Company) => c.id === companyId);
|
||||||
|
companyName = company?.name ?? '';
|
||||||
|
loading = false;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>{companyName} - Strichliste</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="page-header" style="position: relative;">
|
||||||
|
<a href="/" class="back-btn" aria-label="Zurück">←</a>
|
||||||
|
<h1>{companyName}</h1>
|
||||||
|
<p style="color: var(--color-text-muted); margin-top: 4px;">Mitarbeiter auswählen</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if loading}
|
||||||
|
<div style="text-align: center; padding: 48px;">
|
||||||
|
<p>Laden...</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="card-grid" style="padding: 24px;">
|
||||||
|
{#each employees as emp}
|
||||||
|
<a href="/company/{companyId}/tally?employee={emp.id}" class="card">
|
||||||
|
<div style="font-size: 2.5rem;">👤</div>
|
||||||
|
<h3>{emp.firstName} {emp.lastName}</h3>
|
||||||
|
</a>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
107
frontend/src/routes/company/[id]/tally/+page.svelte
Normal file
107
frontend/src/routes/company/[id]/tally/+page.svelte
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
import { api, type Product, type MonthlyTally, type Employee } from '$lib/api/client';
|
||||||
|
|
||||||
|
let products: Product[] = [];
|
||||||
|
let tallies: MonthlyTally[] = [];
|
||||||
|
let employee: Employee | null = null;
|
||||||
|
let loading = true;
|
||||||
|
let showToast = false;
|
||||||
|
let toastMessage = '';
|
||||||
|
|
||||||
|
$: companyId = Number($page.params.id);
|
||||||
|
$: employeeId = Number($page.url.searchParams.get('employee'));
|
||||||
|
|
||||||
|
const ICON_MAP: Record<string, string> = {
|
||||||
|
coffee: '☕',
|
||||||
|
chocolate: '🍫',
|
||||||
|
tea: '🍵'
|
||||||
|
};
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
const [prods, emps] = await Promise.all([
|
||||||
|
api.getProducts(),
|
||||||
|
api.getEmployees(companyId)
|
||||||
|
]);
|
||||||
|
products = prods;
|
||||||
|
employee = emps.find((e: Employee) => e.id === employeeId) ?? null;
|
||||||
|
await loadTallies();
|
||||||
|
loading = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadTallies() {
|
||||||
|
tallies = await api.getMonthlyTally(employeeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addTally(productId: number, productName: string) {
|
||||||
|
await api.createTally(employeeId, productId);
|
||||||
|
toastMessage = `${productName} hinzugefügt!`;
|
||||||
|
showToast = true;
|
||||||
|
setTimeout(() => { showToast = false; }, 2000);
|
||||||
|
await loadTallies();
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPrice(cents: number): string {
|
||||||
|
return (cents / 100).toFixed(2).replace('.', ',') + ' €';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Produkt wählen - Strichliste</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="page-header" style="position: relative;">
|
||||||
|
<a href="/company/{companyId}" class="back-btn" aria-label="Zurück">←</a>
|
||||||
|
<h1>{employee ? `${employee.firstName} ${employee.lastName}` : ''}</h1>
|
||||||
|
<p style="color: var(--color-text-muted); margin-top: 4px;">Produkt auswählen</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if loading}
|
||||||
|
<div style="text-align: center; padding: 48px;">
|
||||||
|
<p>Laden...</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="card-grid" style="padding: 24px;">
|
||||||
|
{#each products as product}
|
||||||
|
<button class="card" on:click={() => addTally(product.id, product.name)}>
|
||||||
|
<div style="font-size: 2.5rem;">{ICON_MAP[product.iconPlaceholder] ?? '☕'}</div>
|
||||||
|
<h3>{product.name}</h3>
|
||||||
|
<span class="subtitle price">{formatPrice(product.priceCents)}</span>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if tallies.length > 0}
|
||||||
|
<div style="padding: 24px;">
|
||||||
|
<h2 style="margin-bottom: 12px; font-size: 1.2rem;">Diesen Monat</h2>
|
||||||
|
<table class="admin-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Produkt</th>
|
||||||
|
<th style="text-align: right;">Anzahl</th>
|
||||||
|
<th style="text-align: right;">Summe</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each tallies as tally}
|
||||||
|
<tr>
|
||||||
|
<td>{tally.productName}</td>
|
||||||
|
<td style="text-align: right;">{tally.count}×</td>
|
||||||
|
<td style="text-align: right;" class="price">{formatPrice(tally.totalCents)}</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
<tr style="font-weight: 700;">
|
||||||
|
<td>Gesamt</td>
|
||||||
|
<td style="text-align: right;">{tallies.reduce((s, t) => s + t.count, 0)}×</td>
|
||||||
|
<td style="text-align: right;" class="price">{formatPrice(tallies.reduce((s, t) => s + t.totalCents, 0))}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if showToast}
|
||||||
|
<div class="toast">✓ {toastMessage}</div>
|
||||||
|
{/if}
|
||||||
22
frontend/static/manifest.json
Normal file
22
frontend/static/manifest.json
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"name": "Strichliste",
|
||||||
|
"short_name": "Strichliste",
|
||||||
|
"description": "Digitale Strichliste für die Kaffeeküche",
|
||||||
|
"start_url": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"background_color": "#1a1a2e",
|
||||||
|
"theme_color": "#16213e",
|
||||||
|
"orientation": "portrait",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/icon-192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icon-512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
12
frontend/svelte.config.js
Normal file
12
frontend/svelte.config.js
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import adapter from '@sveltejs/adapter-node';
|
||||||
|
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
|
||||||
|
|
||||||
|
/** @type {import('@sveltejs/kit').Config} */
|
||||||
|
const config = {
|
||||||
|
preprocess: vitePreprocess(),
|
||||||
|
kit: {
|
||||||
|
adapter: adapter({ out: 'build' })
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
14
frontend/tsconfig.json
Normal file
14
frontend/tsconfig.json
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"extends": "./.svelte-kit/tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"allowJs": true,
|
||||||
|
"checkJs": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"sourceMap": true,
|
||||||
|
"strict": true,
|
||||||
|
"moduleResolution": "bundler"
|
||||||
|
}
|
||||||
|
}
|
||||||
11
frontend/vite.config.ts
Normal file
11
frontend/vite.config.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { sveltekit } from '@sveltejs/kit/vite';
|
||||||
|
import { defineConfig } from 'vite';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [sveltekit()],
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
'/api': 'http://localhost:8080'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user