Compare commits
16 Commits
9c77e90c92
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 9c80470953 | |||
| 41aea49762 | |||
| 08c9f4b8b0 | |||
| 4186190182 | |||
| 67852e6bf9 | |||
| 4fc5fe5778 | |||
| 00585653c7 | |||
| 9b85476c2b | |||
| a0cceeef78 | |||
| e57eb7277f | |||
| 57c075eb25 | |||
| 12b84ec6be | |||
| fa3dbe180a | |||
| 3047e89326 | |||
| ea3efd3767 | |||
| 56cc400d8f |
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
13
backend/src/main/java/de/strichliste/dto/TallyEntryDto.java
Normal file
13
backend/src/main/java/de/strichliste/dto/TallyEntryDto.java
Normal 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
|
||||||
|
) {}
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
);
|
||||||
|
|||||||
@@ -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
6
build.env
Normal 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
115
build.sh
@@ -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
BIN
data/qaffee.db
Normal file
Binary file not shown.
@@ -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
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
104
db/migrate_to_sqlite.py
Normal 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()
|
||||||
@@ -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:
|
|
||||||
|
|||||||
@@ -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 ---
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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 & 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;">
|
||||||
|
|||||||
147
frontend/src/routes/admin/company/entries/+page.svelte
Normal file
147
frontend/src/routes/admin/company/entries/+page.svelte
Normal 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}
|
||||||
Reference in New Issue
Block a user