Compare commits

...

16 Commits

Author SHA1 Message Date
9c80470953 LEGACY Added old migrated prod data and migrate script 2026-05-08 13:08:18 +02:00
41aea49762 Migrated sources from mariadb to sqlite 2026-05-08 13:07:47 +02:00
08c9f4b8b0 Added caching of maven dependencies 2026-05-08 13:06:49 +02:00
4186190182 Changed build script to work with buildx to build ARM at local devices 2026-05-08 13:03:29 +02:00
67852e6bf9 BUGFIX IMAGE_TAG was meant to be IMAGE_VERSION 2026-05-07 10:28:42 +02:00
4fc5fe5778 Added reloading in error situations. 2026-05-07 10:28:08 +02:00
00585653c7 bump 2026-05-06 11:21:11 +02:00
9b85476c2b ADD zurück button auf firmen admin eintrag seite 2026-05-06 11:16:26 +02:00
a0cceeef78 FIX delete buchung call 2026-05-06 11:14:38 +02:00
e57eb7277f CHANGE docker-compose to use the portainer stack definition 2026-05-06 11:05:34 +02:00
57c075eb25 CHANGE evict stale connections after a DB restart 2026-05-06 11:04:45 +02:00
12b84ec6be ADD neue seite für firmen admins auf der sie die produkteinträge einzeln löschen können 2026-04-28 14:38:51 +02:00
fa3dbe180a change build.sh better comments 2026-04-28 14:28:59 +02:00
3047e89326 change build.sh to get SNAPSHOT_TAG from .env 2026-04-28 13:52:36 +02:00
ea3efd3767 update build.sh to accept generic services 2026-04-28 13:38:55 +02:00
56cc400d8f update documentation 2026-04-28 13:22:26 +02:00
18 changed files with 569 additions and 152 deletions

View File

@@ -1,9 +1,16 @@
FROM maven:3.9-eclipse-temurin-21 AS build FROM maven:3.9-eclipse-temurin-21 AS build
WORKDIR /app WORKDIR /app
# Separate layer for dependencies: only re-runs when pom.xml changes.
# The cache mount keeps ~/.m2 across builds so even pom.xml changes
# don't require a full re-download.
COPY pom.xml . COPY pom.xml .
RUN mvn dependency:go-offline -B RUN --mount=type=cache,target=/root/.m2 \
mvn dependency:go-offline -B -q
COPY src ./src COPY src ./src
RUN mvn package -DskipTests -B RUN --mount=type=cache,target=/root/.m2 \
mvn package -DskipTests -B
FROM eclipse-temurin:21-jre FROM eclipse-temurin:21-jre
WORKDIR /deployments WORKDIR /deployments

View File

@@ -40,17 +40,18 @@
<artifactId>quarkus-hibernate-orm-panache</artifactId> <artifactId>quarkus-hibernate-orm-panache</artifactId>
</dependency> </dependency>
<dependency> <dependency>
<groupId>io.quarkus</groupId> <groupId>org.xerial</groupId>
<artifactId>quarkus-jdbc-mariadb</artifactId> <artifactId>sqlite-jdbc</artifactId>
<version>3.47.1.0</version>
</dependency>
<dependency>
<groupId>org.hibernate.orm</groupId>
<artifactId>hibernate-community-dialects</artifactId>
</dependency> </dependency>
<dependency> <dependency>
<groupId>io.quarkus</groupId> <groupId>io.quarkus</groupId>
<artifactId>quarkus-flyway</artifactId> <artifactId>quarkus-flyway</artifactId>
</dependency> </dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-mysql</artifactId>
</dependency>
<dependency> <dependency>
<groupId>io.quarkus</groupId> <groupId>io.quarkus</groupId>
<artifactId>quarkus-arc</artifactId> <artifactId>quarkus-arc</artifactId>

View File

@@ -0,0 +1,13 @@
package de.strichliste.dto;
import java.time.LocalDateTime;
public record TallyEntryDto(
Long id,
Long employeeId,
String employeeFirstName,
String employeeLastName,
String productName,
int priceCents,
LocalDateTime createdAt
) {}

View File

@@ -187,6 +187,53 @@ public class CompanyAdminResource {
return Response.ok(line).build(); return Response.ok(line).build();
} }
@GET
@Path("/entries")
public Response getTallyEntries(@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);
List<TallyEntry> entries = TallyEntry.find(
"SELECT t FROM TallyEntry t JOIN FETCH t.employee JOIN FETCH t.product " +
"WHERE t.employee.company.id = ?1 AND t.monthKey = ?2 ORDER BY t.createdAt DESC",
link.company.id, monthKey).list();
List<TallyEntryDto> dtos = entries.stream()
.map(t -> new TallyEntryDto(
t.id,
t.employee.id,
t.employee.firstName,
t.employee.lastName,
t.product.name,
t.priceCentsAtBooking,
t.createdAt))
.toList();
return Response.ok(dtos).build();
}
@DELETE
@Path("/entries/{id}")
@Transactional
public Response deleteTallyEntry(@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();
}
TallyEntry entry = TallyEntry.findById(id);
if (entry == null || !entry.employee.company.id.equals(link.company.id)) {
return Response.status(Response.Status.NOT_FOUND).build();
}
entry.delete();
return Response.noContent().build();
}
static MonthlyReportDto buildCompanyReport(Long companyId, String monthKey) { static MonthlyReportDto buildCompanyReport(Long companyId, String monthKey) {
Company company = Company.findById(companyId); Company company = Company.findById(companyId);
List<Employee> employees = Employee.findAllByCompany(companyId); List<Employee> employees = Employee.findAllByCompany(companyId);

View File

@@ -1,14 +1,23 @@
# Datasource # Datasource SQLite
quarkus.datasource.db-kind=mariadb quarkus.datasource.db-kind=other
quarkus.datasource.username=strichliste quarkus.datasource.jdbc.driver=org.sqlite.JDBC
quarkus.datasource.password=strichliste quarkus.datasource.jdbc.url=jdbc:sqlite:${DB_PATH:/data/qaffee.db}
quarkus.datasource.jdbc.url=jdbc:mariadb://localhost:3306/strichliste
# Connection Pool (SQLite: single writer)
quarkus.datasource.jdbc.min-size=1
quarkus.datasource.jdbc.max-size=1
# Hibernate # Hibernate
quarkus.hibernate-orm.dialect=org.hibernate.community.dialect.SQLiteDialect
quarkus.hibernate-orm.database.generation=none quarkus.hibernate-orm.database.generation=none
# Flyway # Flyway
quarkus.flyway.migrate-at-start=true quarkus.flyway.migrate-at-start=true
# Baseline: treat V1-V4 as already applied when no history table exists (post-migration start)
quarkus.flyway.baseline-on-migrate=true
quarkus.flyway.baseline-version=4
# Checksums der MariaDB-Dateien passen nicht mehr zu den SQLite-kompatiblen Versionen
quarkus.flyway.validate-on-migrate=false
# CORS ist deaktiviert, da alle Anfragen über den SvelteKit-Proxy laufen # CORS ist deaktiviert, da alle Anfragen über den SvelteKit-Proxy laufen
quarkus.http.cors=false quarkus.http.cors=false

View File

@@ -1,49 +1,50 @@
CREATE TABLE company ( CREATE TABLE company (
id BIGINT AUTO_INCREMENT PRIMARY KEY, id INTEGER PRIMARY KEY,
name VARCHAR(255) NOT NULL, name VARCHAR(255) NOT NULL,
active BOOLEAN NOT NULL DEFAULT TRUE, active TINYINT(1) NOT NULL DEFAULT 1,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; );
CREATE TABLE employee ( CREATE TABLE employee (
id BIGINT AUTO_INCREMENT PRIMARY KEY, id INTEGER PRIMARY KEY,
company_id BIGINT NOT NULL, company_id BIGINT NOT NULL,
first_name VARCHAR(255) NOT NULL, first_name VARCHAR(255) NOT NULL,
last_name VARCHAR(255) NOT NULL, last_name VARCHAR(255) NOT NULL,
active BOOLEAN NOT NULL DEFAULT TRUE, active TINYINT(1) NOT NULL DEFAULT 1,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_employee_company FOREIGN KEY (company_id) REFERENCES company(id) FOREIGN KEY (company_id) REFERENCES company(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; );
CREATE TABLE product ( CREATE TABLE product (
id BIGINT AUTO_INCREMENT PRIMARY KEY, id INTEGER PRIMARY KEY,
name VARCHAR(255) NOT NULL, name VARCHAR(255) NOT NULL,
price_cents INT NOT NULL DEFAULT 0, price_cents INT NOT NULL DEFAULT 0,
icon_placeholder VARCHAR(50) DEFAULT 'coffee', icon_placeholder VARCHAR(255) DEFAULT 'coffee',
active BOOLEAN NOT NULL DEFAULT TRUE, active TINYINT(1) NOT NULL DEFAULT 1,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; );
CREATE TABLE tally_entry ( CREATE TABLE tally_entry (
id BIGINT AUTO_INCREMENT PRIMARY KEY, id INTEGER PRIMARY KEY,
employee_id BIGINT NOT NULL, employee_id BIGINT NOT NULL,
product_id BIGINT NOT NULL, product_id BIGINT NOT NULL,
month_key VARCHAR(7) NOT NULL, month_key VARCHAR(7) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_tally_employee FOREIGN KEY (employee_id) REFERENCES employee(id), FOREIGN KEY (employee_id) REFERENCES employee(id),
CONSTRAINT fk_tally_product FOREIGN KEY (product_id) REFERENCES product(id), 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 INDEX idx_tally_month ON tally_entry(month_key);
CREATE INDEX idx_tally_employee_month ON tally_entry(employee_id, month_key);
CREATE TABLE access_link ( CREATE TABLE access_link (
id BIGINT AUTO_INCREMENT PRIMARY KEY, id INTEGER PRIMARY KEY,
token VARCHAR(64) NOT NULL UNIQUE, token VARCHAR(64) NOT NULL UNIQUE,
role VARCHAR(20) NOT NULL, role VARCHAR(50) NOT NULL,
company_id BIGINT, company_id BIGINT,
description VARCHAR(255), description TEXT,
active BOOLEAN NOT NULL DEFAULT TRUE, active TINYINT(1) NOT NULL DEFAULT 1,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_access_link_company FOREIGN KEY (company_id) REFERENCES company(id), FOREIGN KEY (company_id) REFERENCES company(id),
CONSTRAINT chk_role CHECK (role IN ('COMPANY_ADMIN', 'PROVIDER_ADMIN')) CHECK (role IN ('COMPANY_ADMIN', 'PROVIDER_ADMIN'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; );

View File

@@ -1,2 +1,2 @@
ALTER TABLE company ADD COLUMN logo MEDIUMBLOB; ALTER TABLE company ADD COLUMN logo MEDIUMBLOB;
ALTER TABLE company ADD COLUMN logo_content_type VARCHAR(50); ALTER TABLE company ADD COLUMN logo_content_type VARCHAR(255);

6
build.env Normal file
View File

@@ -0,0 +1,6 @@
REGISTRY='push.registry.cloud.aquantico.de'
IMAGE_NAME='qaffee'
IMAGE_VERSION='1.0.8'
SERVICES=(backend frontend)
SNAPSHOT_TAG='SNAPSHOT'
platform=linux/amd64,linux/arm64

115
build.sh
View File

@@ -1,35 +1,44 @@
#set -euo pipefail #!/bin/bash
set -u
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# build.sh Build and push Docker images for Strichliste # build.sh Build and push Docker images
# #
# Usage: # Usage:
# ./build.sh [OPTIONS] # ./build.sh [OPTIONS]
# #
# Options: # Options:
# -v, --version VERSION Image tag / version (default: git short SHA) # -s, --snapshot Version from git ref for prototyping
# -r, --registry REGISTRY Registry prefix (default: "") # -r, --release Version from .env for releases
# -v, --version VERSION Custom version
# -p, --push Push images after build # -p, --push Push images after build
# --backend-only Only build backend # -o, --only SERVICE Only build this service (repeatable)
# --frontend-only Only build frontend
# -h, --help Show this help # -h, --help Show this help
# #
# Examples: # Examples:
# ./build.sh -v 1.2.3 -r ghcr.io/aquantico --push # ./build.sh -r -p
# ./build.sh --version latest --registry registry.example.com --push # ./build.sh -s -o frontend
# ./build.sh -v dev # build only, no push # ./build.sh -v dev -o backend -o frontend
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # -e: Exit immediately if any command returns a non-zero exit code
source ".env" # -u: Treat unset variables as errors
# -o pipefail: Make pipelines fail if any command in the pipe fails, not just the last one
set -euo pipefail
#default: snapshot #cd to script dir
VERSION=$(git rev-parse --short HEAD 2>/dev/null || echo "local") pushd "$(dirname "${BASH_SOURCE[0]}")" > /dev/null
SCRIPT_DIR="$(pwd)"
source "${SCRIPT_DIR}/build.env"
# read from .env:
# REGISTRY, IMAGE_NAME, IMAGE_VERSION, SNAPSHOT_TAG
# SERVICES (subfolder name = image suffix)
popd > /dev/null
#default: build snapshot
VERSION="${IMAGE_VERSION}-${SNAPSHOT_TAG}" # read from .env
PUSH=false PUSH=false
BUILD_BACKEND=true ONLY=()
BUILD_FRONTEND=true
usage() { usage() {
sed -n '/^# Usage:/,/^# ---/p' "$0" | sed 's/^# //' | sed 's/^#//' sed -n '/^# Usage:/,/^# ---/p' "$0" | sed 's/^# //' | sed 's/^#//'
@@ -39,69 +48,59 @@ usage() {
# --- parse arguments ------------------------------------------------------- # --- parse arguments -------------------------------------------------------
while [[ $# -gt 0 ]]; do while [[ $# -gt 0 ]]; do
case $1 in case $1 in
-s|--snapshot) VERSION=$(git rev-parse --short HEAD 2>/dev/null || echo "local"); shift ;; -s|--snapshot) VERSION="${IMAGE_VERSION}-SNAPSHOT$(git rev-parse --short HEAD 2>/dev/null || echo "local")"; shift ;;
-r|--release) VERSION=${IMAGE_TAG}; shift ;; -r|--release) VERSION="${IMAGE_VERSION}"; shift ;;
-v|--version) VERSION=$2; shift 2 ;; -v|--version) VERSION="$2"; shift 2 ;;
-p|--push) PUSH=true; shift ;; -p|--push) PUSH=true; shift ;;
-b|--backend-only) BUILD_FRONTEND=false; shift ;; -o|--only) ONLY+=("$2"); shift 2 ;;
-f|--frontend-only) BUILD_BACKEND=false; shift ;; -h|--help) usage ;;
-h|--help) usage ;;
*) echo "Unknown option: $1" >&2; exit 1 ;; *) echo "Unknown option: $1" >&2; exit 1 ;;
esac esac
done done
# --- derive image names ----------------------------------------------------- # if --only was given, filter SERVICES down to those entries
registry_prefix="${REGISTRY}/" if [[ ${#ONLY[@]} -gt 0 ]]; then
BACKEND_IMAGE="${registry_prefix}${IMAGE_NAME}-backend:${VERSION}" SERVICES=("${ONLY[@]}")
FRONTEND_IMAGE="${registry_prefix}${IMAGE_NAME}-frontend:${VERSION}" fi
#SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
echo "============================================" echo "============================================"
echo " ${IMAGE_NAME} - Docker Build" echo " Docker Build - ${IMAGE_NAME}"
echo ""
echo " Services : ${SERVICES[*]}"
echo " Version : ${VERSION}" echo " Version : ${VERSION}"
echo " Registry : ${REGISTRY}" echo " Registry : ${REGISTRY}"
echo " Push : ${PUSH}" echo " Push : ${PUSH}"
echo "============================================" echo "============================================"
# --- build ------------------------------------------------------------------ # --- build ------------------------------------------------------------------
if $BUILD_BACKEND; then for SERVICE in "${SERVICES[@]}"; do
IMAGE="${REGISTRY}/${IMAGE_NAME}-${SERVICE}:${VERSION}"
echo "" echo ""
echo ">>> Building backend: ${BACKEND_IMAGE}" echo ">>> Building ${SERVICE}: ${IMAGE}"
docker build \
--tag "${BACKEND_IMAGE}" \
"${SCRIPT_DIR}/backend"
echo " Backend built successfully."
fi
if $BUILD_FRONTEND; then docker buildx build \
echo "" --platform ${platform} \
echo ">>> Building frontend: ${FRONTEND_IMAGE}" -t "${IMAGE}" \
docker build \ $($PUSH && echo "--push" || echo "") \
--tag "${FRONTEND_IMAGE}" \ "${SCRIPT_DIR}/${SERVICE}"
"${SCRIPT_DIR}/frontend" echo " ${SERVICE} built successfully."
echo " Frontend built successfully." done
fi
# --- push ------------------------------------------------------------------- # --- push -------------------------------------------------------------------
if $PUSH; then if $PUSH; then
docker login ${REGISTRY} docker login "${REGISTRY}" # read from .env
for SERVICE in "${SERVICES[@]}"; do
if $BUILD_BACKEND; then IMAGE="${REGISTRY}/${IMAGE_NAME}-${SERVICE}:${VERSION}"
echo "" echo ""
echo ">>> Pushing ${BACKEND_IMAGE}" echo ">>> Pushing ${IMAGE}"
docker push "${BACKEND_IMAGE}" docker push "${IMAGE}"
fi done
if $BUILD_FRONTEND; then
echo ""
echo ">>> Pushing ${FRONTEND_IMAGE}"
docker push "${FRONTEND_IMAGE}"
fi
echo "" echo ""
echo "Images pushed successfully." echo "Images pushed successfully."
fi fi
echo "" echo ""
echo "Done." echo "Done."
if $BUILD_BACKEND; then echo " Backend → ${BACKEND_IMAGE}"; fi for SERVICE in "${SERVICES[@]}"; do
if $BUILD_FRONTEND; then echo " Frontend → ${FRONTEND_IMAGE}"; fi echo " ${SERVICE}${REGISTRY}/${IMAGE_NAME}-${SERVICE}:${VERSION}"
done

BIN
data/qaffee.db Normal file

Binary file not shown.

View File

@@ -8,7 +8,7 @@ while true; do
FILENAME="${BACKUP_DIR}/strichliste_${TIMESTAMP}.sql.gz" FILENAME="${BACKUP_DIR}/strichliste_${TIMESTAMP}.sql.gz"
echo "[$(date)] Starting backup..." echo "[$(date)] Starting backup..."
mariadb-dump -h db -u strichliste -pstrichliste strichliste | gzip > "${FILENAME}" mariadb-dump -h db -u strichliste -pstrichliste --single-transaction --skip-lock-tables strichliste | gzip > "${FILENAME}"
if [ $? -eq 0 ]; then if [ $? -eq 0 ]; then
echo "[$(date)] Backup saved: ${FILENAME}" echo "[$(date)] Backup saved: ${FILENAME}"

63
db/mariadb_export.sql Normal file

File diff suppressed because one or more lines are too long

104
db/migrate_to_sqlite.py Normal file
View File

@@ -0,0 +1,104 @@
#!/usr/bin/env python3
"""
Migrate MariaDB export to SQLite for qaffee.
Usage:
python3 db/migrate_to_sqlite.py
Creates data/qaffee.db ready for use with the Quarkus backend.
Flyway will baseline at V4 on first startup (no re-migration needed).
"""
import re
import sqlite3
import os
import sys
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
REPO_ROOT = os.path.join(SCRIPT_DIR, '..')
MIGRATION_DIR = os.path.join(REPO_ROOT, 'backend', 'src', 'main', 'resources', 'db', 'migration')
EXPORT_FILE = os.path.join(SCRIPT_DIR, 'mariadb_export.sql')
OUTPUT_FILE = os.path.join(REPO_ROOT, 'data', 'qaffee.db')
# V2 (seed data) is intentionally skipped it inserts dev/test rows that
# conflict with production IDs in the export.
MIGRATIONS = [
'V1__initial_schema.sql',
'V3__company_logo.sql',
'V4__tally_price_at_booking.sql',
]
# Tables to import in FK-safe order (parents before children)
DATA_TABLES = ['company', 'employee', 'product', 'access_link', 'tally_entry']
def apply_migrations(conn: sqlite3.Connection) -> None:
for filename in MIGRATIONS:
path = os.path.join(MIGRATION_DIR, filename)
with open(path, 'r', encoding='utf-8') as f:
sql = f.read()
conn.executescript(sql)
print(f' schema {filename}')
def mariadb_to_sqlite_sql(sql: str) -> str:
# Remove MariaDB sandbox-mode comment at the top
sql = re.sub(r'/\*M!.*?\*/', '', sql, flags=re.DOTALL)
# Strip backtick identifier quotes
sql = sql.replace('`', '')
# Convert 0xABCD hex literals to SQLite X'ABCD' blob literals
sql = re.sub(r'\b0x([0-9A-Fa-f]+)', r"X'\1'", sql)
return sql
def extract_insert(sql: str, table: str) -> str | None:
"""Return the full INSERT INTO <table> VALUES ...; statement, or None."""
pattern = rf'(INSERT INTO {re.escape(table)} VALUES\s*.*?;)'
match = re.search(pattern, sql, re.DOTALL)
return match.group(1) if match else None
def main() -> None:
os.makedirs(os.path.dirname(OUTPUT_FILE), exist_ok=True)
if os.path.exists(OUTPUT_FILE):
os.remove(OUTPUT_FILE)
print(f'Removed existing {OUTPUT_FILE}')
print('Reading MariaDB export ...')
with open(EXPORT_FILE, 'r', encoding='utf-8') as f:
raw_sql = f.read()
sqlite_sql = mariadb_to_sqlite_sql(raw_sql)
print('Creating SQLite database ...')
conn = sqlite3.connect(OUTPUT_FILE)
print('Applying schema migrations:')
apply_migrations(conn)
print('Importing data:')
for table in DATA_TABLES:
stmt = extract_insert(sqlite_sql, table)
if stmt is None:
print(f' skip {table} (no data in export)')
continue
conn.executescript(stmt)
# Count rows for feedback
count = conn.execute(f'SELECT COUNT(*) FROM {table}').fetchone()[0]
print(f' import {table} ({count} rows)')
conn.commit()
conn.close()
size_kb = os.path.getsize(OUTPUT_FILE) // 1024
print(f'\nDone {OUTPUT_FILE} ({size_kb} KB)')
print()
print('Next steps:')
print(' 1. docker compose up --build')
print(' Flyway will baseline at V4 and skip all migrations.')
print(' 2. Verify at https://qaffee.cloud.aquantico.de')
if __name__ == '__main__':
main()

View File

@@ -1,57 +1,41 @@
version: "3"
services: 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: backend:
build: ./backend build: ./backend
container_name: qaffee-backend
environment: environment:
QUARKUS_DATASOURCE_JDBC_URL: jdbc:mariadb://db:3306/strichliste - "QUARKUS_HTTP_CORS_ORIGINS=https://qaffee.cloud.aquantico.de"
QUARKUS_DATASOURCE_USERNAME: strichliste volumes:
QUARKUS_DATASOURCE_PASSWORD: strichliste - ./data:/data
QUARKUS_HTTP_CORS_ORIGINS: http://localhost:5173,http://localhost:4173 labels:
ports: traefik.enable: "true"
- "8080:8080" traefik.docker.network: "traefik-net"
depends_on: traefik.http.routers.qaffee-backend.entrypoints: "websecure"
db: traefik.http.routers.qaffee-backend.rule: "Host(`qaffee-api.cloud.aquantico.de`)"
condition: service_healthy traefik.http.routers.qaffee-backend.tls: "true"
traefik.http.routers.qaffee-backend.tls.certresolver: "myresolver"
traefik.http.services.qaffee-backend.loadbalancer.server.port: 8080
networks:
- traefik-net
frontend: frontend:
build: ./frontend build: ./frontend
container_name: qaffee-frontend
environment: environment:
API_URL: http://backend:8080 - "API_URL=http://backend:8080"
ports: ports:
- "3000:3000" - "3000:3000"
labels:
traefik.enable: "true"
traefik.docker.network: "traefik-net"
traefik.http.routers.qaffee-frontend.entrypoints: "websecure"
traefik.http.routers.qaffee-frontend.rule: "Host(`qaffee.cloud.aquantico.de`)"
traefik.http.routers.qaffee-frontend.tls: "true"
traefik.http.routers.qaffee-frontend.tls.certresolver: "myresolver"
traefik.http.services.qaffee-frontend.loadbalancer.server.port: 3000
depends_on: depends_on:
- backend - backend
networks:
backup: - traefik-net
image: mariadb:11.4.4 networks:
environment: traefik-net:
MARIADB_HOST: db external: false
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:

View File

@@ -44,6 +44,16 @@ export interface MonthlyTally {
totalCents: number; totalCents: number;
} }
export interface TallyEntry {
id: number;
employeeId: number;
employeeFirstName: string;
employeeLastName: string;
productName: string;
priceCents: number;
createdAt: string;
}
export interface EmployeeReportLine { export interface EmployeeReportLine {
employeeId: number; employeeId: number;
firstName: string; firstName: string;
@@ -119,7 +129,11 @@ export const api = {
getReport: (token: string, month?: string) => getReport: (token: string, month?: string) =>
request<MonthlyReport>(`/admin/company/report?token=${token}${month ? `&month=${month}` : ''}`), request<MonthlyReport>(`/admin/company/report?token=${token}${month ? `&month=${month}` : ''}`),
getEmployeeReport: (token: string, employeeId: number, month?: string) => getEmployeeReport: (token: string, employeeId: number, month?: string) =>
request<EmployeeReportLine>(`/admin/company/report/employee/${employeeId}?token=${token}${month ? `&month=${month}` : ''}`) request<EmployeeReportLine>(`/admin/company/report/employee/${employeeId}?token=${token}${month ? `&month=${month}` : ''}`),
getTallyEntries: (token: string, month?: string) =>
request<TallyEntry[]>(`/admin/company/entries?token=${token}${month ? `&month=${month}` : ''}`),
deleteTallyEntry: (token: string, id: number) =>
request<void>(`/admin/company/entries/${id}?token=${token}`, { method: 'DELETE' })
}, },
// --- Provider Admin --- // --- Provider Admin ---

View File

@@ -5,10 +5,17 @@
let companies: Company[] = []; let companies: Company[] = [];
let loading = true; let loading = true;
let error = false;
onMount(async () => { onMount(async () => {
companies = await api.getCompanies(); try {
loading = false; companies = await api.getCompanies();
} catch {
error = true;
setTimeout(() => location.reload(), 5000);
} finally {
loading = false;
}
}); });
</script> </script>
@@ -22,6 +29,10 @@
<div style="text-align: center; padding: 48px;"> <div style="text-align: center; padding: 48px;">
<p>Laden...</p> <p>Laden...</p>
</div> </div>
{:else if error}
<div style="text-align: center; padding: 48px;">
<p style="color: var(--color-warning);">Verbindungsfehler Seite wird in 5 Sekunden neu geladen…</p>
</div>
{:else} {:else}
<div class="card-grid" style="padding: 24px;"> <div class="card-grid" style="padding: 24px;">
{#each companies as company} {#each companies as company}

View File

@@ -179,6 +179,17 @@
</table> </table>
</section> </section>
<!-- Einträge verwalten -->
<section style="margin-bottom: 40px;">
<h2 style="margin-bottom: 12px;">Einträge verwalten</h2>
<p style="color: var(--color-text-muted); margin-bottom: 12px; font-size: 0.9rem;">
Einzelne Produktbuchungen einsehen und bei Bedarf löschen.
</p>
<a class="btn btn-secondary" href="/admin/company/entries?token={token}">
Einträge anzeigen &amp; löschen
</a>
</section>
<!-- Monatsauswertung --> <!-- Monatsauswertung -->
<section> <section>
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px;"> <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px;">

View File

@@ -0,0 +1,147 @@
<script lang="ts">
import { onMount } from 'svelte';
import { page } from '$app/stores';
import { api, type TallyEntry } from '$lib/api/client';
import Header from '$lib/components/Header.svelte';
import MonthPicker from '$lib/components/MonthPicker.svelte';
let token = '';
let entries: TallyEntry[] = [];
let loading = true;
let selectedMonth = new Date().toISOString().slice(0, 7);
let deletingId: number | null = null;
let confirmId: number | null = null;
$: token = $page.url.searchParams.get('token') ?? '';
onMount(async () => {
if (!token) return;
await loadEntries();
});
async function loadEntries() {
loading = true;
entries = await api.companyAdmin.getTallyEntries(token, selectedMonth);
loading = false;
}
async function changeMonth(e: CustomEvent<string>) {
selectedMonth = e.detail;
await loadEntries();
}
function askConfirm(id: number) {
confirmId = id;
}
async function confirmDelete() {
if (confirmId === null) return;
const idToDelete = confirmId;
deletingId = idToDelete;
confirmId = null;
await api.companyAdmin.deleteTallyEntry(token, idToDelete);
deletingId = null;
await loadEntries();
}
function formatPrice(cents: number): string {
return (cents / 100).toFixed(2).replace('.', ',') + ' €';
}
function formatDateTime(iso: string): string {
const d = new Date(iso);
return d.toLocaleDateString('de-DE') + ' ' + d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
}
</script>
<svelte:head>
<title>Einträge löschen Qaffee</title>
</svelte:head>
<Header title="Einträge verwalten" backHref="/admin/company?token={token}" />
<div class="admin-container">
{#if !token}
<p>Kein Zugangstoken angegeben.</p>
{:else}
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px;">
<h2>Produkteinträge</h2>
<MonthPicker bind:value={selectedMonth} on:change={changeMonth} />
</div>
{#if loading}
<p>Laden...</p>
{:else if entries.length === 0}
<p style="color: var(--color-text-muted); text-align: center; margin-top: 40px;">
Keine Einträge in diesem Monat.
</p>
{:else}
<p style="color: var(--color-text-muted); margin-bottom: 12px; font-size: 0.9rem;">
{entries.length} Einträge · {formatPrice(entries.reduce((s, e) => s + e.priceCents, 0))} gesamt
</p>
<table class="admin-table">
<thead>
<tr>
<th>Zeitpunkt</th>
<th>Mitarbeiter</th>
<th>Produkt</th>
<th style="text-align: right;">Preis</th>
<th></th>
</tr>
</thead>
<tbody>
{#each entries as entry}
<tr>
<td style="color: var(--color-text-muted); font-size: 0.85rem; white-space: nowrap;">
{formatDateTime(entry.createdAt)}
</td>
<td>{entry.employeeFirstName} {entry.employeeLastName}</td>
<td>{entry.productName}</td>
<td style="text-align: right;" class="price">{formatPrice(entry.priceCents)}</td>
<td>
{#if deletingId === entry.id}
<span style="font-size: 0.85rem; color: var(--color-text-muted);">Löschen...</span>
{:else}
<button
class="btn btn-secondary"
style="padding: 5px 10px; font-size: 0.8rem; color: #e05555; border-color: #e05555;"
on:click={() => askConfirm(entry.id)}
>
Löschen
</button>
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
{/if}
{/if}
</div>
{#if confirmId !== null}
{@const confirmEntry = entries.find(e => e.id === confirmId)}
<div class="modal-overlay" on:click|self={() => confirmId = null}>
<div class="modal">
<h2>Eintrag löschen?</h2>
{#if confirmEntry}
<p style="margin: 16px 0; color: var(--color-text-muted);">
<strong style="color: var(--color-text-muted);">{confirmEntry.employeeFirstName} {confirmEntry.employeeLastName}</strong>
{confirmEntry.productName} ({formatPrice(confirmEntry.priceCents)})
am {formatDateTime(confirmEntry.createdAt)}
</p>
{/if}
<p style="margin-bottom: 20px; font-size: 0.9rem;">Dieser Eintrag wird unwiderruflich gelöscht.</p>
<div class="modal-actions">
<button class="btn btn-secondary" on:click={() => confirmId = null}>Abbrechen</button>
<button
class="btn btn-primary"
style="background: #e05555; border-color: #e05555;"
on:click={confirmDelete}
>
Löschen
</button>
</div>
</div>
</div>
{/if}