feat(customers): add initial admin bootstrap and csv import

This commit is contained in:
DS | Schubert 2026-07-03 09:14:56 +02:00
parent c816e9869d
commit ecab0fe6b6
27 changed files with 1197 additions and 34 deletions

14
.env.example Normal file
View file

@ -0,0 +1,14 @@
DATABASE_URL=postgresql+psycopg://olympus:change-me@postgres:5432/olympus
SECRET_KEY=change-me
ACCESS_TOKEN_EXPIRE_MINUTES=60
JWT_ISSUER=hermes
LOG_LEVEL=INFO
AUTH_COOKIE_SECURE=false
ATHENA_PUBLIC_ORIGIN=http://localhost:3001
# Optional fuer neue Installationen ohne aktive Benutzer.
INITIAL_ADMIN_USERNAME=
INITIAL_ADMIN_EMAIL=
INITIAL_ADMIN_PASSWORD=
INITIAL_ADMIN_FIRST_NAME=
INITIAL_ADMIN_LAST_NAME=

View file

@ -93,6 +93,12 @@ Hermes setzt kein Cookie.
2. Athena loescht das HttpOnly-Cookie. 2. Athena loescht das HttpOnly-Cookie.
3. Der Benutzer wird zur Login-Seite gefuehrt. 3. Der Benutzer wird zur Login-Seite gefuehrt.
### Aktueller Benutzer
Athena stellt `GET /api/me` bereit und ruft dafuer serverseitig Hermes `GET /auth/me` auf. Der Header laedt den aktuellen Benutzer ausschliesslich ueber diese BFF-Route und zeigt bevorzugt Vorname plus Nachname, danach den Benutzernamen und vor dem Laden `Benutzer`.
Der Browser erhaelt dabei kein Token und ruft Hermes nicht direkt auf.
### HttpOnly Cookie ### HttpOnly Cookie
Das Auth-Cookie wird von Athena gesetzt. Das Auth-Cookie wird von Athena gesetzt.
@ -238,6 +244,56 @@ Kunden:
- `customers.read` - `customers.read`
- `customers.create` - `customers.create`
- `customers.update` - `customers.update`
### Initial Admin Bootstrap
Hermes prueft beim Startup nach dem RBAC-Seed, ob mindestens ein aktiver Benutzer existiert. Existiert ein aktiver Benutzer, wird kein Benutzer automatisch erstellt.
Existiert kein aktiver Benutzer, kann Hermes ueber optionale Umgebungsvariablen einen initialen Administrator anlegen:
- `INITIAL_ADMIN_USERNAME`
- `INITIAL_ADMIN_EMAIL`
- `INITIAL_ADMIN_PASSWORD`
- `INITIAL_ADMIN_FIRST_NAME`
- `INITIAL_ADMIN_LAST_NAME`
Benutzername, E-Mail und Passwort sind fuer die automatische Anlage erforderlich. Fehlen Werte oder ist die E-Mail ungueltig, startet Hermes weiter und schreibt nur eine Warnung ohne Secrets. Das Passwort wird mit der bestehenden `hash_password`-Funktion gehasht. Der Benutzer erhaelt `role_id` der Systemrolle `administrator` und das Legacy-Feld `role=administrator`.
### Kundenimport
Hermes stellt fuer CSV-Importe zwei Endpunkte bereit:
- `POST /customers/import/preview`
- `POST /customers/import/commit`
Athena stellt die Browser-BFF-Routen bereit:
- `POST /api/customers/import/preview`
- `POST /api/customers/import/commit`
- `GET /api/customers/import/template`
Der Browser sendet Multipart-FormData nur an Athena. Athena leitet die Datei serverseitig mit Bearer Token aus dem HttpOnly-Cookie an Hermes weiter. Hermes begrenzt CSV-Dateien auf 5 MB, bevorzugt UTF-8, erkennt Semikolon und Komma und ignoriert leere Zeilen.
Importmodi:
- `create_only`: neue Kunden erstellen, bestehende Kundennummern ueberspringen
- `update_existing`: bestehende Kunden anhand der Kundennummer aktualisieren
- `upsert`: bestehende Kunden aktualisieren und neue Kunden erstellen
RBAC:
- Preview benoetigt `customers.read`
- Commit mit `create_only` benoetigt `customers.create`
- Commit mit `update_existing` benoetigt `customers.update`
- Commit mit `upsert` benoetigt `customers.create` und `customers.update`
CSV-Spalten:
```text
customer_number;company_name;legal_name;customer_type;status;industry;website;email;phone;tax_number;vat_id;notes;address_type;street;postal_code;city;state;country;address_is_primary;contact_first_name;contact_last_name;contact_position;contact_email;contact_phone;contact_mobile;contact_is_primary;contact_notes
```
Pflichtfeld ist `company_name`. Wenn `customer_number`, `customer_type` oder `status` fehlen, erzeugt der Import fuer neue Kunden eine Kundennummer bzw. nutzt produktive Defaults und weist in der Preview darauf hin. Vollstaendige CSV-Inhalte werden nicht im Audit Log gespeichert.
- `customers.delete` - `customers.delete`
Projekte: Projekte:

View file

@ -105,6 +105,7 @@ uv run alembic upgrade head
- Erfolg und Fehler in mutierenden CRUD-Flows ueber den Toast-Provider melden. - Erfolg und Fehler in mutierenden CRUD-Flows ueber den Toast-Provider melden.
- Keine Browser-Dialoge wie `alert()` oder `confirm()`. - Keine Browser-Dialoge wie `alert()` oder `confirm()`.
- Keine Tokens in Browser-JavaScript speichern. - Keine Tokens in Browser-JavaScript speichern.
- Datei-Uploads vom Browser laufen ueber Athena-BFF-Routen und werden serverseitig an Hermes weitergeleitet.
### Backend ### Backend
@ -118,6 +119,7 @@ uv run alembic upgrade head
- Berechtigungen mit `require_permission`, `require_any_permission` oder `require_all_permissions` pruefen. - Berechtigungen mit `require_permission`, `require_any_permission` oder `require_all_permissions` pruefen.
- Mutierende Kernaktionen mit Audit Logs erfassen, sofern fachlich relevant. - Mutierende Kernaktionen mit Audit Logs erfassen, sofern fachlich relevant.
- Sensible Felder vor Persistenz in Logs oder Audit-Daten maskieren. - Sensible Felder vor Persistenz in Logs oder Audit-Daten maskieren.
- Import- und Bootstrap-Flows duerfen keine Passwoerter, Tokens, Secrets oder vollstaendige CSV-Inhalte loggen.
### Allgemein ### Allgemein
@ -143,6 +145,7 @@ Diese Regeln sind verbindlich:
- Frontend-Permissions dienen nur der UI und ersetzen keine Backend-Pruefung. - Frontend-Permissions dienen nur der UI und ersetzen keine Backend-Pruefung.
- Audit Logs werden serverseitig in Hermes geschrieben. - Audit Logs werden serverseitig in Hermes geschrieben.
- Neue Plattform-Endpunkte sollen das einheitliche API-Response-Format nutzen. - Neue Plattform-Endpunkte sollen das einheitliche API-Response-Format nutzen.
- Header- und Benutzerkontext wird ueber `GET /api/me` geladen; hart codierte Benutzernamen sind unzulaessig.
Wenn eine Aufgabe diese Regeln zu verletzen scheint, muss zuerst die Architekturentscheidung geklaert werden. Wenn eine Aufgabe diese Regeln zu verletzen scheint, muss zuerst die Architekturentscheidung geklaert werden.
@ -196,6 +199,31 @@ Ein neues CRM-Modul soll sich am Kundenmodul orientieren:
Dashboard-Widgets fuer noch nicht implementierte Module muessen Empty States anzeigen statt hart codierter Beispieldaten. Dashboard-Widgets fuer noch nicht implementierte Module muessen Empty States anzeigen statt hart codierter Beispieldaten.
### Kundenimport
Kundenimporte muessen die bestehende Kundenarchitektur nutzen und ueber Athena-BFF-Routen laufen. Der Browser darf keine Hermes-URL kennen.
CSV-Vorgaben:
- UTF-8 bevorzugen
- Semikolon und Komma unterstuetzen
- leere Zeilen ignorieren
- Fehler und Warnungen pro Zeile zurueckgeben
- Preview darf keine Datenbank-Aenderungen ausloesen
- Commit schreibt Audit Logs ohne CSV-Rohinhalt
Importmodi:
- `create_only`
- `update_existing`
- `upsert`
Die Excel-freundliche Vorlage liegt unter `GET /api/customers/import/template`.
### Deployment
Neue Installationen koennen optional ueber `INITIAL_ADMIN_*` einen initialen Administrator anlegen. Diese Variablen werden nur von Hermes gelesen und duerfen nicht im Frontend oder in Logs erscheinen.
### Neue Permissions ### Neue Permissions
Neue Module muessen eigene stabile Permission-Strings erhalten. Neue Module muessen eigene stabile Permission-Strings erhalten.

View file

@ -34,6 +34,16 @@ Die Roadmap beschreibt die geplante fachliche Entwicklung von Olympus CRM. Archi
- Globale Toasts fuer CRUD-Erfolg und Fehler - Globale Toasts fuer CRUD-Erfolg und Fehler
- Docker-Konfiguration fuer Logging-Level - Docker-Konfiguration fuer Logging-Level
## v0.5.1 - Bootstrap und Kundenimport
- Initial-Admin-Bootstrap fuer neue Installationen ohne aktive Benutzer
- Optionale `INITIAL_ADMIN_*` Deployment-Variablen
- Dynamische Header-Benutzeranzeige ueber `GET /api/me`
- CSV-Kundenimport mit Preview und Commit
- Importmodi `create_only`, `update_existing` und `upsert`
- Excel-freundliche CSV-Vorlage ueber Athena
- Audit Log fuer Kundenimporte ohne CSV-Rohdaten
## v0.6.0 - Projektmodul, geplant ## v0.6.0 - Projektmodul, geplant
- Projektstammdaten - Projektstammdaten

View file

@ -4,3 +4,12 @@ SECRET_KEY=CHANGE_ME
APP_NAME=Hermes API APP_NAME=Hermes API
APP_VERSION=0.1.0 APP_VERSION=0.1.0
ACCESS_TOKEN_EXPIRE_MINUTES=60
JWT_ISSUER=hermes
LOG_LEVEL=INFO
INITIAL_ADMIN_USERNAME=
INITIAL_ADMIN_EMAIL=
INITIAL_ADMIN_PASSWORD=
INITIAL_ADMIN_FIRST_NAME=
INITIAL_ADMIN_LAST_NAME=

View file

@ -81,6 +81,8 @@ def login(
def me(current_user: User = Depends(get_current_active_user)): def me(current_user: User = Depends(get_current_active_user)):
return { return {
"id": current_user.id, "id": current_user.id,
"first_name": current_user.first_name,
"last_name": current_user.last_name,
"username": current_user.username, "username": current_user.username,
"email": current_user.email, "email": current_user.email,
"role": current_user.primary_role.name, "role": current_user.primary_role.name,

View file

@ -1,10 +1,10 @@
import logging import logging
from fastapi import APIRouter, Depends, HTTPException, Response, status from fastapi import APIRouter, Depends, File, Form, HTTPException, Response, UploadFile, status
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from starlette.requests import Request from starlette.requests import Request
from app.core.rbac import require_permission from app.core.rbac import get_current_active_user, get_user_permission_names, require_permission
from app.db.database import get_db from app.db.database import get_db
from app.models.customer import Customer, CustomerContact from app.models.customer import Customer, CustomerContact
from app.models.user import User from app.models.user import User
@ -17,7 +17,13 @@ from app.schemas.customer import (
CustomerResponse, CustomerResponse,
CustomerUpdate, CustomerUpdate,
) )
from app.schemas.customer_import import (
CustomerImportCommitResponse,
CustomerImportPreviewResponse,
ImportMode,
)
from app.services.audit_service import sanitize, write_audit_log from app.services.audit_service import sanitize, write_audit_log
from app.services.customer_import_service import CustomerImportService
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -26,6 +32,23 @@ router = APIRouter(
tags=["Customers"], tags=["Customers"],
) )
MAX_IMPORT_SIZE_BYTES = 5 * 1024 * 1024
async def read_import_file(file: UploadFile) -> bytes:
content = await file.read(MAX_IMPORT_SIZE_BYTES + 1)
if len(content) > MAX_IMPORT_SIZE_BYTES:
raise HTTPException(
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
detail="CSV-Datei darf maximal 5 MB groß sein",
)
if not content:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="CSV-Datei ist leer",
)
return content
def get_customer_or_404(db: Session, customer_id: int) -> Customer: def get_customer_or_404(db: Session, customer_id: int) -> Customer:
customer = CustomerRepository.get_by_id(db, customer_id) customer = CustomerRepository.get_by_id(db, customer_id)
@ -116,6 +139,44 @@ def create_customer(
return created_customer return created_customer
@router.post("/import/preview", response_model=CustomerImportPreviewResponse)
async def preview_customer_import(
mode: ImportMode = Form(...),
file: UploadFile = File(...),
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("customers.read")),
):
logger.info("customers.import.preview", extra={"actor_user_id": current_user.id, "mode": mode})
content = await read_import_file(file)
try:
return CustomerImportService.preview(db, content, mode)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
@router.post("/import/commit", response_model=CustomerImportCommitResponse)
async def commit_customer_import(
request: Request,
mode: ImportMode = Form(...),
file: UploadFile = File(...),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_active_user),
):
permissions = get_user_permission_names(current_user)
required_permissions = {"customers.create"} if mode == "create_only" else {"customers.update"}
if mode == "upsert":
required_permissions = {"customers.create", "customers.update"}
if not required_permissions.issubset(permissions):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Keine Berechtigung")
logger.info("customers.import.commit", extra={"actor_user_id": current_user.id, "mode": mode})
content = await read_import_file(file)
try:
return CustomerImportService.commit(db, content, mode, actor=current_user, request=request)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
@router.put("/{customer_id}", response_model=CustomerResponse) @router.put("/{customer_id}", response_model=CustomerResponse)
def update_customer( def update_customer(
customer_id: int, customer_id: int,

View file

@ -10,6 +10,11 @@ class Settings(BaseSettings):
access_token_expire_minutes: int = 60 access_token_expire_minutes: int = 60
jwt_issuer: str = "hermes" jwt_issuer: str = "hermes"
log_level: str = "INFO" log_level: str = "INFO"
initial_admin_username: str | None = None
initial_admin_email: str | None = None
initial_admin_password: str | None = None
initial_admin_first_name: str = ""
initial_admin_last_name: str = ""
model_config = SettingsConfigDict( model_config = SettingsConfigDict(
env_file=".env", env_file=".env",

View file

@ -20,6 +20,7 @@ from app.db.database import SessionLocal
from app.db.health import check_database from app.db.health import check_database
from app.core.logging import configure_logging from app.core.logging import configure_logging
from app.rbac.seed import seed_rbac from app.rbac.seed import seed_rbac
from app.services.initial_admin_bootstrap import bootstrap_initial_admin
configure_logging() configure_logging()
@ -73,6 +74,7 @@ def startup_seed_rbac():
db = SessionLocal() db = SessionLocal()
try: try:
seed_rbac(db) seed_rbac(db)
bootstrap_initial_admin(db)
finally: finally:
db.close() db.close()

View file

@ -0,0 +1,49 @@
from typing import Literal
from pydantic import BaseModel, Field
ImportMode = Literal["create_only", "update_existing", "upsert"]
ImportAction = Literal["create", "update", "skip", "error"]
class CustomerImportIssue(BaseModel):
row: int
field: str = ""
message: str
class CustomerImportPreviewRow(BaseModel):
row: int
customer_number: str = ""
company_name: str = ""
action: ImportAction
errors: list[CustomerImportIssue] = Field(default_factory=list)
warnings: list[CustomerImportIssue] = Field(default_factory=list)
class CustomerImportSummary(BaseModel):
total_rows: int
valid_rows: int
error_count: int
warning_count: int
duplicate_count: int
create_count: int
update_count: int
skip_count: int
class CustomerImportPreviewResponse(BaseModel):
mode: ImportMode
summary: CustomerImportSummary
rows: list[CustomerImportPreviewRow]
errors: list[CustomerImportIssue]
warnings: list[CustomerImportIssue]
duplicates: list[CustomerImportIssue]
class CustomerImportCommitResponse(BaseModel):
mode: ImportMode
summary: CustomerImportSummary
created: int
updated: int
skipped: int

View file

@ -68,6 +68,8 @@ class RoleResponse(BaseModel):
class CurrentUserResponse(BaseModel): class CurrentUserResponse(BaseModel):
id: int id: int
first_name: str
last_name: str
username: str username: str
email: str email: str
role: str role: str

View file

@ -126,9 +126,11 @@ def action_title(action: str) -> str:
"customers.create": "Kunde erstellt", "customers.create": "Kunde erstellt",
"customers.update": "Kunde bearbeitet", "customers.update": "Kunde bearbeitet",
"customers.delete": "Kunde gelöscht", "customers.delete": "Kunde gelöscht",
"customers.import": "Kunden importiert",
"customer_contacts.create": "Ansprechpartner erstellt", "customer_contacts.create": "Ansprechpartner erstellt",
"customer_contacts.update": "Ansprechpartner bearbeitet", "customer_contacts.update": "Ansprechpartner bearbeitet",
"customer_contacts.delete": "Ansprechpartner gelöscht", "customer_contacts.delete": "Ansprechpartner gelöscht",
"users.initial_admin_bootstrap": "Initialer Administrator erstellt",
} }
return labels.get(action, action) return labels.get(action, action)

View file

@ -0,0 +1,407 @@
import csv
import io
import uuid
from dataclasses import dataclass
from pydantic import EmailStr, HttpUrl, TypeAdapter, ValidationError
from sqlalchemy.orm import Session
from app.models.customer import Customer, CustomerAddress, CustomerContact
from app.models.user import User
from app.repositories.customer_repository import CustomerRepository
from app.schemas.customer_import import (
CustomerImportCommitResponse,
CustomerImportIssue,
CustomerImportPreviewResponse,
CustomerImportPreviewRow,
CustomerImportSummary,
ImportMode,
)
from app.services.audit_service import write_audit_log
CSV_COLUMNS = [
"customer_number",
"company_name",
"legal_name",
"customer_type",
"status",
"industry",
"website",
"email",
"phone",
"tax_number",
"vat_id",
"notes",
"address_type",
"street",
"postal_code",
"city",
"state",
"country",
"address_is_primary",
"contact_first_name",
"contact_last_name",
"contact_position",
"contact_email",
"contact_phone",
"contact_mobile",
"contact_is_primary",
"contact_notes",
]
VALID_STATUSES = {"lead", "active", "inactive", "blocked", "archived"}
VALID_TYPES = {"company", "private", "public_sector", "partner", "supplier"}
VALID_ADDRESS_TYPES = {"billing", "shipping", "primary", "other"}
TRUE_VALUES = {"1", "true", "yes", "ja", "j", "x"}
FALSE_VALUES = {"0", "false", "no", "nein", "n", ""}
email_adapter = TypeAdapter(EmailStr)
url_adapter = TypeAdapter(HttpUrl)
@dataclass
class ParsedImportRow:
row_number: int
data: dict[str, str]
action: str
errors: list[CustomerImportIssue]
warnings: list[CustomerImportIssue]
existing_customer: Customer | None = None
def get_customer_import_template() -> str:
return ";".join(CSV_COLUMNS) + "\n"
def decode_csv(content: bytes) -> str:
try:
return content.decode("utf-8-sig")
except UnicodeDecodeError as exc:
raise ValueError("CSV-Datei muss UTF-8-kodiert sein") from exc
def parse_bool(value: str) -> bool:
normalized = value.strip().lower()
if normalized in TRUE_VALUES:
return True
if normalized in FALSE_VALUES:
return False
return False
def parse_bool_default(value: str, default: bool = False) -> bool:
if value.strip() == "":
return default
return parse_bool(value)
def sniff_dialect(text: str) -> csv.Dialect:
sample = text[:4096]
try:
return csv.Sniffer().sniff(sample, delimiters=",;")
except csv.Error:
return csv.excel
def read_csv_rows(content: bytes) -> list[tuple[int, dict[str, str]]]:
text = decode_csv(content)
reader = csv.DictReader(io.StringIO(text), dialect=sniff_dialect(text))
rows: list[tuple[int, dict[str, str]]] = []
for index, row in enumerate(reader, start=2):
normalized = {column: (row.get(column) or "").strip() for column in CSV_COLUMNS}
if not any(normalized.values()):
continue
rows.append((index, normalized))
return rows
def validate_email(value: str, row_number: int, field: str, errors: list[CustomerImportIssue]) -> str:
if not value:
return ""
try:
return str(email_adapter.validate_python(value))
except ValidationError:
errors.append(CustomerImportIssue(row=row_number, field=field, message="E-Mail ist ungültig"))
return value
def validate_url(value: str, row_number: int, errors: list[CustomerImportIssue]) -> str:
if not value:
return ""
candidate = value if value.startswith(("http://", "https://")) else f"https://{value}"
try:
return str(url_adapter.validate_python(candidate))
except ValidationError:
errors.append(CustomerImportIssue(row=row_number, field="website", message="Website ist ungültig"))
return value
def planned_action(mode: ImportMode, existing_customer: Customer | None) -> str:
if existing_customer is None:
return "create" if mode in {"create_only", "upsert"} else "skip"
if mode == "create_only":
return "skip"
return "update"
class CustomerImportService:
@staticmethod
def preview(db: Session, content: bytes, mode: ImportMode) -> CustomerImportPreviewResponse:
rows = CustomerImportService._parse_and_validate(db, content, mode)
return CustomerImportService._build_preview(mode, rows)
@staticmethod
def commit(
db: Session,
content: bytes,
mode: ImportMode,
*,
actor: User,
request,
) -> CustomerImportCommitResponse:
rows = CustomerImportService._parse_and_validate(db, content, mode)
preview = CustomerImportService._build_preview(mode, rows)
if preview.summary.valid_rows == 0:
return CustomerImportCommitResponse(
mode=mode,
summary=preview.summary,
created=0,
updated=0,
skipped=preview.summary.skip_count,
)
created = 0
updated = 0
skipped = 0
for row in rows:
if row.errors or row.action == "error":
continue
if row.action == "skip":
skipped += 1
continue
if row.action == "create":
CustomerImportService._create_customer(db, row.data)
created += 1
elif row.action == "update" and row.existing_customer is not None:
CustomerImportService._update_customer(db, row.existing_customer, row.data)
updated += 1
db.commit()
write_audit_log(
db,
action="customers.import",
entity_type="customers",
actor=actor,
request=request,
metadata={
"mode": mode,
"total_rows": preview.summary.total_rows,
"created": created,
"updated": updated,
"skipped": skipped,
"errors": preview.summary.error_count,
"warnings": preview.summary.warning_count,
},
)
summary = CustomerImportService._build_preview(mode, rows).summary
return CustomerImportCommitResponse(
mode=mode,
summary=summary,
created=created,
updated=updated,
skipped=skipped,
)
@staticmethod
def _parse_and_validate(db: Session, content: bytes, mode: ImportMode) -> list[ParsedImportRow]:
csv_rows = read_csv_rows(content)
number_rows: dict[str, int] = {}
duplicates: set[str] = set()
for row_number, data in csv_rows:
customer_number = data["customer_number"]
if not customer_number:
continue
if customer_number in number_rows:
duplicates.add(customer_number)
else:
number_rows[customer_number] = row_number
parsed_rows: list[ParsedImportRow] = []
for row_number, data in csv_rows:
errors: list[CustomerImportIssue] = []
warnings: list[CustomerImportIssue] = []
if not data["company_name"]:
errors.append(CustomerImportIssue(row=row_number, field="company_name", message="Firmenname ist erforderlich"))
if not data["customer_type"]:
data["customer_type"] = "company"
warnings.append(CustomerImportIssue(row=row_number, field="customer_type", message="Typ fehlt und wird als company importiert"))
elif data["customer_type"] not in VALID_TYPES:
errors.append(CustomerImportIssue(row=row_number, field="customer_type", message="Typ ist ungültig"))
if not data["status"]:
data["status"] = "active"
warnings.append(CustomerImportIssue(row=row_number, field="status", message="Status fehlt und wird als active importiert"))
elif data["status"] not in VALID_STATUSES:
errors.append(CustomerImportIssue(row=row_number, field="status", message="Status ist ungültig"))
if not data["address_type"]:
data["address_type"] = "primary"
elif data["address_type"] not in VALID_ADDRESS_TYPES:
errors.append(CustomerImportIssue(row=row_number, field="address_type", message="Adresstyp ist ungültig"))
data["email"] = validate_email(data["email"], row_number, "email", errors)
data["contact_email"] = validate_email(data["contact_email"], row_number, "contact_email", errors)
data["website"] = validate_url(data["website"], row_number, errors)
if data["customer_number"] and data["customer_number"] in duplicates:
errors.append(CustomerImportIssue(row=row_number, field="customer_number", message="Kundennummer ist in der CSV mehrfach vorhanden"))
if not data["customer_number"] and mode == "update_existing":
errors.append(CustomerImportIssue(row=row_number, field="customer_number", message="Kundennummer ist für update_existing erforderlich"))
if not data["customer_number"]:
warnings.append(CustomerImportIssue(row=row_number, field="customer_number", message="Kundennummer fehlt und wird beim Erstellen generiert"))
existing_customer = (
CustomerRepository.get_by_number(db, data["customer_number"])
if data["customer_number"]
else None
)
action = planned_action(mode, existing_customer)
if action == "skip":
reason = "Kunde existiert bereits" if existing_customer is not None else "Kunde existiert nicht"
warnings.append(CustomerImportIssue(row=row_number, field="customer_number", message=f"Zeile wird übersprungen: {reason}"))
if errors:
action = "error"
parsed_rows.append(
ParsedImportRow(
row_number=row_number,
data=data,
action=action,
errors=errors,
warnings=warnings,
existing_customer=existing_customer,
)
)
return parsed_rows
@staticmethod
def _build_preview(mode: ImportMode, rows: list[ParsedImportRow]) -> CustomerImportPreviewResponse:
preview_rows = [
CustomerImportPreviewRow(
row=row.row_number,
customer_number=row.data["customer_number"],
company_name=row.data["company_name"],
action=row.action,
errors=row.errors,
warnings=row.warnings,
)
for row in rows
]
errors = [issue for row in rows for issue in row.errors]
warnings = [issue for row in rows for issue in row.warnings]
duplicates = [issue for issue in errors if issue.field == "customer_number" and "mehrfach" in issue.message]
summary = CustomerImportSummary(
total_rows=len(rows),
valid_rows=sum(1 for row in rows if not row.errors and row.action in {"create", "update"}),
error_count=len(errors),
warning_count=len(warnings),
duplicate_count=len(duplicates),
create_count=sum(1 for row in rows if not row.errors and row.action == "create"),
update_count=sum(1 for row in rows if not row.errors and row.action == "update"),
skip_count=sum(1 for row in rows if not row.errors and row.action == "skip"),
)
return CustomerImportPreviewResponse(
mode=mode,
summary=summary,
rows=preview_rows,
errors=errors,
warnings=warnings,
duplicates=duplicates,
)
@staticmethod
def _customer_number(data: dict[str, str]) -> str:
return data["customer_number"] or f"IMP-{uuid.uuid4().hex[:10].upper()}"
@staticmethod
def _address(data: dict[str, str]) -> CustomerAddress:
return CustomerAddress(
type=data["address_type"],
street=data["street"],
postal_code=data["postal_code"],
city=data["city"],
state=data["state"],
country=data["country"] or "Deutschland",
is_primary=parse_bool_default(data["address_is_primary"], default=True),
)
@staticmethod
def _contact(data: dict[str, str]) -> CustomerContact | None:
if not any(data[key] for key in ("contact_first_name", "contact_last_name", "contact_email", "contact_phone", "contact_mobile")):
return None
return CustomerContact(
first_name=data["contact_first_name"],
last_name=data["contact_last_name"],
position=data["contact_position"],
email=data["contact_email"],
phone=data["contact_phone"],
mobile=data["contact_mobile"],
is_primary=parse_bool(data["contact_is_primary"]),
notes=data["contact_notes"],
)
@staticmethod
def _create_customer(db: Session, data: dict[str, str]) -> Customer:
customer = Customer(
customer_number=CustomerImportService._customer_number(data),
company_name=data["company_name"],
legal_name=data["legal_name"],
customer_type=data["customer_type"],
status=data["status"],
industry=data["industry"],
website=data["website"],
email=data["email"],
phone=data["phone"],
tax_number=data["tax_number"],
vat_id=data["vat_id"],
notes=data["notes"],
addresses=[CustomerImportService._address(data)],
)
contact = CustomerImportService._contact(data)
if contact is not None:
customer.contacts = [contact]
db.add(customer)
return customer
@staticmethod
def _update_customer(db: Session, customer: Customer, data: dict[str, str]) -> Customer:
customer.company_name = data["company_name"]
customer.legal_name = data["legal_name"]
customer.customer_type = data["customer_type"]
customer.status = data["status"]
customer.industry = data["industry"]
customer.website = data["website"]
customer.email = data["email"]
customer.phone = data["phone"]
customer.tax_number = data["tax_number"]
customer.vat_id = data["vat_id"]
customer.notes = data["notes"]
customer.addresses = [CustomerImportService._address(data)]
contact = CustomerImportService._contact(data)
if contact is not None:
if contact.is_primary:
for existing_contact in customer.contacts:
existing_contact.is_primary = False
customer.contacts.append(contact)
db.add(customer)
return customer

View file

@ -0,0 +1,82 @@
import logging
from pydantic import EmailStr, TypeAdapter, ValidationError
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.core.config import settings
from app.core.security import hash_password
from app.models.user import User
from app.repositories.rbac_repository import RbacRepository
from app.services.audit_service import write_audit_log
logger = logging.getLogger(__name__)
email_adapter = TypeAdapter(EmailStr)
def bootstrap_initial_admin(db: Session) -> None:
active_user_exists = db.scalar(select(User.id).where(User.is_active.is_(True)).limit(1))
if active_user_exists is not None:
logger.info("initial_admin.skipped_active_user_exists")
return
username = (settings.initial_admin_username or "").strip()
email = (settings.initial_admin_email or "").strip()
password = settings.initial_admin_password or ""
if not username or not email or not password:
logger.warning(
"initial_admin.not_configured",
extra={"reason": "missing_required_initial_admin_environment"},
)
return
try:
normalized_email = str(email_adapter.validate_python(email))
except ValidationError:
logger.warning("initial_admin.invalid_email")
return
administrator_role = RbacRepository.get_role_by_name(db, "administrator")
if administrator_role is None:
logger.warning("initial_admin.missing_administrator_role")
return
existing_user = db.scalar(select(User).where((User.username == username) | (User.email == normalized_email)))
if existing_user is not None:
logger.info("initial_admin.skipped_user_already_exists")
return
admin = User(
first_name=settings.initial_admin_first_name.strip(),
last_name=settings.initial_admin_last_name.strip(),
username=username,
email=normalized_email,
password_hash=hash_password(password),
role="administrator",
role_id=administrator_role.id,
is_active=True,
)
db.add(admin)
db.commit()
db.refresh(admin)
write_audit_log(
db,
action="users.initial_admin_bootstrap",
entity_type="users",
entity_id=admin.id,
entity_label=admin.username,
actor_username="system",
after_data={
"id": admin.id,
"username": admin.username,
"email": admin.email,
"role": admin.role,
"role_id": admin.role_id,
"is_active": admin.is_active,
},
metadata={"source": "initial_admin_bootstrap"},
)
logger.info("initial_admin.created", extra={"user_id": admin.id})

View file

@ -5,10 +5,15 @@ services:
restart: unless-stopped restart: unless-stopped
environment: environment:
DATABASE_URL: postgresql+psycopg://olympus:FsFs03285310!!!@olympus-db:5432/olympus DATABASE_URL: ${DATABASE_URL}
APP_NAME: Hermes API APP_NAME: Hermes API
APP_VERSION: 0.1.0 APP_VERSION: 0.1.0
SECRET_KEY: ${SECRET_KEY} SECRET_KEY: ${SECRET_KEY}
INITIAL_ADMIN_USERNAME: ${INITIAL_ADMIN_USERNAME:-}
INITIAL_ADMIN_EMAIL: ${INITIAL_ADMIN_EMAIL:-}
INITIAL_ADMIN_PASSWORD: ${INITIAL_ADMIN_PASSWORD:-}
INITIAL_ADMIN_FIRST_NAME: ${INITIAL_ADMIN_FIRST_NAME:-}
INITIAL_ADMIN_LAST_NAME: ${INITIAL_ADMIN_LAST_NAME:-}
ACCESS_TOKEN_EXPIRE_MINUTES: ${ACCESS_TOKEN_EXPIRE_MINUTES:-60} ACCESS_TOKEN_EXPIRE_MINUTES: ${ACCESS_TOKEN_EXPIRE_MINUTES:-60}
JWT_ISSUER: ${JWT_ISSUER:-hermes} JWT_ISSUER: ${JWT_ISSUER:-hermes}
LOG_LEVEL: ${LOG_LEVEL:-INFO} LOG_LEVEL: ${LOG_LEVEL:-INFO}

View file

@ -15,6 +15,11 @@ services:
ACCESS_TOKEN_EXPIRE_MINUTES: ${ACCESS_TOKEN_EXPIRE_MINUTES:-60} ACCESS_TOKEN_EXPIRE_MINUTES: ${ACCESS_TOKEN_EXPIRE_MINUTES:-60}
JWT_ISSUER: ${JWT_ISSUER:-hermes} JWT_ISSUER: ${JWT_ISSUER:-hermes}
LOG_LEVEL: ${LOG_LEVEL:-INFO} LOG_LEVEL: ${LOG_LEVEL:-INFO}
INITIAL_ADMIN_USERNAME: ${INITIAL_ADMIN_USERNAME:-}
INITIAL_ADMIN_EMAIL: ${INITIAL_ADMIN_EMAIL:-}
INITIAL_ADMIN_PASSWORD: ${INITIAL_ADMIN_PASSWORD:-}
INITIAL_ADMIN_FIRST_NAME: ${INITIAL_ADMIN_FIRST_NAME:-}
INITIAL_ADMIN_LAST_NAME: ${INITIAL_ADMIN_LAST_NAME:-}
expose: expose:
- "8000" - "8000"

View file

@ -0,0 +1,14 @@
import { NextRequest } from "next/server";
import { assertSameOrigin } from "@/lib/server/request-guards";
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
export async function POST(request: NextRequest) {
const originError = assertSameOrigin(request);
if (originError) {
return originError;
}
return proxyHermesRequest(request, "/customers/import/commit");
}

View file

@ -0,0 +1,14 @@
import { NextRequest } from "next/server";
import { assertSameOrigin } from "@/lib/server/request-guards";
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
export async function POST(request: NextRequest) {
const originError = assertSameOrigin(request);
if (originError) {
return originError;
}
return proxyHermesRequest(request, "/customers/import/preview");
}

View file

@ -0,0 +1,40 @@
import { NextResponse } from "next/server";
const columns = [
"customer_number",
"company_name",
"legal_name",
"customer_type",
"status",
"industry",
"website",
"email",
"phone",
"tax_number",
"vat_id",
"notes",
"address_type",
"street",
"postal_code",
"city",
"state",
"country",
"address_is_primary",
"contact_first_name",
"contact_last_name",
"contact_position",
"contact_email",
"contact_phone",
"contact_mobile",
"contact_is_primary",
"contact_notes",
];
export async function GET() {
return new NextResponse(`${columns.join(";")}\n`, {
headers: {
"Content-Disposition": 'attachment; filename="kundenimport-vorlage.csv"',
"Content-Type": "text/csv; charset=utf-8",
},
});
}

View file

@ -2,7 +2,7 @@
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { Edit, Eye, Plus, Trash2 } from "lucide-react"; import { Edit, Eye, Plus, Trash2, Upload } from "lucide-react";
import ConfirmDialog from "@/components/common/ConfirmDialog"; import ConfirmDialog from "@/components/common/ConfirmDialog";
import DataTable, { type DataTableColumn } from "@/components/common/DataTable"; import DataTable, { type DataTableColumn } from "@/components/common/DataTable";
@ -10,11 +10,12 @@ import SearchInput from "@/components/common/SearchInput";
import { useToast } from "@/components/common/ToastProvider"; import { useToast } from "@/components/common/ToastProvider";
import { Button, buttonVariants } from "@/components/ui/button"; import { Button, buttonVariants } from "@/components/ui/button";
import CustomerFormDialog from "@/components/customers/CustomerFormDialog"; import CustomerFormDialog from "@/components/customers/CustomerFormDialog";
import CustomerImportDialog from "@/components/customers/CustomerImportDialog";
import CustomerStatusBadge from "@/components/customers/CustomerStatusBadge"; import CustomerStatusBadge from "@/components/customers/CustomerStatusBadge";
import { api } from "@/lib/api"; import { api } from "@/lib/api";
import { hasPermission } from "@/lib/permissions"; import { hasPermission } from "@/lib/permissions";
import type { CurrentUser } from "@/types/rbac"; import type { CurrentUser } from "@/types/rbac";
import type { Customer, CustomerPayload, CustomerStatus, CustomerType } from "@/types/customer"; import type { Customer, CustomerImportCommitResponse, CustomerPayload, CustomerStatus, CustomerType } from "@/types/customer";
const pageSize = 10; const pageSize = 10;
@ -64,6 +65,7 @@ export default function CustomersPage() {
const [sortDirection, setSortDirection] = useState<"asc" | "desc">("desc"); const [sortDirection, setSortDirection] = useState<"asc" | "desc">("desc");
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [formOpen, setFormOpen] = useState(false); const [formOpen, setFormOpen] = useState(false);
const [importOpen, setImportOpen] = useState(false);
const [selectedCustomer, setSelectedCustomer] = useState<Customer | null>(null); const [selectedCustomer, setSelectedCustomer] = useState<Customer | null>(null);
const [deleteCustomer, setDeleteCustomer] = useState<Customer | null>(null); const [deleteCustomer, setDeleteCustomer] = useState<Customer | null>(null);
@ -122,6 +124,7 @@ export default function CustomersPage() {
const pageCount = Math.max(1, Math.ceil(filteredCustomers.length / pageSize)); const pageCount = Math.max(1, Math.ceil(filteredCustomers.length / pageSize));
const currentPage = Math.min(page, pageCount); const currentPage = Math.min(page, pageCount);
const pageCustomers = filteredCustomers.slice((currentPage - 1) * pageSize, currentPage * pageSize); const pageCustomers = filteredCustomers.slice((currentPage - 1) * pageSize, currentPage * pageSize);
const canImportCustomers = hasPermission(currentUser, "customers.create") || hasPermission(currentUser, "customers.update");
const columns: DataTableColumn<Customer>[] = [ const columns: DataTableColumn<Customer>[] = [
{ key: "customer_number", label: "Kundennummer", sortable: true, render: (customer) => customer.customer_number }, { key: "customer_number", label: "Kundennummer", sortable: true, render: (customer) => customer.customer_number },
@ -221,6 +224,16 @@ export default function CustomersPage() {
} }
} }
async function handleImportCommitted(result: CustomerImportCommitResponse) {
showToast({
type: "success",
title: "Kundenimport abgeschlossen",
description: `${result.created} erstellt, ${result.updated} aktualisiert, ${result.skipped} übersprungen`,
});
setLoading(true);
await loadCustomers();
}
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between"> <div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
@ -228,12 +241,20 @@ export default function CustomersPage() {
<h1 className="text-3xl font-bold text-slate-950">Kunden</h1> <h1 className="text-3xl font-bold text-slate-950">Kunden</h1>
<p className="mt-1 text-sm text-slate-500">{filteredCustomers.length} von {customers.length} Kunden</p> <p className="mt-1 text-sm text-slate-500">{filteredCustomers.length} von {customers.length} Kunden</p>
</div> </div>
{hasPermission(currentUser, "customers.create") && ( <div className="flex flex-wrap gap-2">
<Button type="button" onClick={openCreateDialog}> {canImportCustomers && (
<Plus size={16} /> <Button type="button" variant="outline" onClick={() => setImportOpen(true)}>
Neuer Kunde <Upload size={16} />
</Button> Import
)} </Button>
)}
{hasPermission(currentUser, "customers.create") && (
<Button type="button" onClick={openCreateDialog}>
<Plus size={16} />
Neuer Kunde
</Button>
)}
</div>
</div> </div>
<div className="flex flex-col gap-3 rounded-lg border bg-white p-4 lg:flex-row lg:items-center"> <div className="flex flex-col gap-3 rounded-lg border bg-white p-4 lg:flex-row lg:items-center">
@ -286,6 +307,13 @@ export default function CustomersPage() {
onSubmit={saveCustomer} onSubmit={saveCustomer}
/> />
<CustomerImportDialog
open={importOpen}
onOpenChange={setImportOpen}
onCommitted={handleImportCommitted}
onError={(message) => showToast({ type: "error", title: "Kundenimport fehlgeschlagen", description: message })}
/>
<ConfirmDialog <ConfirmDialog
open={Boolean(deleteCustomer)} open={Boolean(deleteCustomer)}
title="Kunde löschen" title="Kunde löschen"

View file

@ -1,31 +1,51 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import LogoutButton from "@/components/LogoutButton"; import LogoutButton from "@/components/LogoutButton";
import { api } from "@/lib/api";
import type { CurrentUser } from "@/types/rbac";
export default function Header() { export default function Header() {
const [currentUser, setCurrentUser] = useState<CurrentUser | null>(null);
return ( useEffect(() => {
let active = true;
<header className="h-20 bg-white border-b flex items-center justify-between px-8"> api.get<CurrentUser>("/me")
.then((response) => {
if (active) {
setCurrentUser(response.data);
}
})
.catch(() => {
if (active) {
setCurrentUser(null);
}
});
<h1 className="text-3xl font-bold"> return () => {
active = false;
};
}, []);
Dashboard const displayName = useMemo(() => {
if (!currentUser) {
return "Benutzer";
}
</h1> const fullName = `${currentUser.first_name} ${currentUser.last_name}`.trim();
return fullName || currentUser.username || "Benutzer";
}, [currentUser]);
<div className="flex items-center gap-4"> return (
<header className="flex h-20 items-center justify-between border-b bg-white px-8">
<span className="font-semibold"> <h1 className="text-3xl font-bold">Dashboard</h1>
admin.schubert
</span>
<LogoutButton />
</div>
</header>
);
<div className="flex items-center gap-4">
<span className="font-semibold">{displayName}</span>
<LogoutButton />
</div>
</header>
);
} }

View file

@ -0,0 +1,177 @@
"use client";
import { Download, FileUp, Play, Upload } from "lucide-react";
import Link from "next/link";
import { useState } from "react";
import ImportPreviewTable from "@/components/customers/ImportPreviewTable";
import ImportSummaryCard from "@/components/customers/ImportSummaryCard";
import { Button, buttonVariants } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { api } from "@/lib/api";
import type {
CustomerImportCommitResponse,
CustomerImportMode,
CustomerImportPreviewResponse,
} from "@/types/customer";
type Props = {
open: boolean;
onOpenChange: (open: boolean) => void;
onCommitted: (result: CustomerImportCommitResponse) => void;
onError: (message: string) => void;
};
function errorMessage(error: unknown) {
if (typeof error === "object" && error !== null && "response" in error) {
const response = (error as { response?: { data?: { detail?: string } } }).response;
return response?.data?.detail ?? "Import konnte nicht ausgeführt werden";
}
return "Import konnte nicht ausgeführt werden";
}
function buildFormData(file: File, mode: CustomerImportMode) {
const formData = new FormData();
formData.append("mode", mode);
formData.append("file", file);
return formData;
}
export default function CustomerImportDialog({ open, onOpenChange, onCommitted, onError }: Props) {
const [file, setFile] = useState<File | null>(null);
const [mode, setMode] = useState<CustomerImportMode>("upsert");
const [preview, setPreview] = useState<CustomerImportPreviewResponse | null>(null);
const [pending, setPending] = useState(false);
const [serverError, setServerError] = useState("");
async function runPreview() {
if (!file) {
setServerError("Bitte CSV-Datei auswählen");
return;
}
setPending(true);
setServerError("");
try {
const response = await api.post<CustomerImportPreviewResponse>(
"/customers/import/preview",
buildFormData(file, mode),
);
setPreview(response.data);
} catch (error) {
const message = errorMessage(error);
setServerError(message);
onError(message);
} finally {
setPending(false);
}
}
async function commitImport() {
if (!file || !preview || preview.summary.valid_rows === 0) {
return;
}
setPending(true);
setServerError("");
try {
const response = await api.post<CustomerImportCommitResponse>(
"/customers/import/commit",
buildFormData(file, mode),
);
onCommitted(response.data);
setPreview(null);
setFile(null);
onOpenChange(false);
} catch (error) {
const message = errorMessage(error);
setServerError(message);
onError(message);
} finally {
setPending(false);
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-5xl">
<DialogHeader>
<DialogTitle>Kunden importieren</DialogTitle>
<DialogDescription>CSV-Datei prüfen und anschließend gültige Zeilen übernehmen.</DialogDescription>
</DialogHeader>
<div className="grid gap-4">
<div className="grid gap-4 lg:grid-cols-[1fr_14rem_auto] lg:items-end">
<div className="grid gap-2">
<Label htmlFor="customer-import-file">CSV-Datei</Label>
<input
id="customer-import-file"
type="file"
accept=".csv,text/csv"
className="h-10 rounded-lg border border-input bg-white px-3 py-2 text-sm"
onChange={(event) => {
setFile(event.target.files?.[0] ?? null);
setPreview(null);
setServerError("");
}}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="customer-import-mode">Importmodus</Label>
<select
id="customer-import-mode"
value={mode}
className="h-10 rounded-lg border border-input bg-white px-3 text-sm"
onChange={(event) => {
setMode(event.target.value as CustomerImportMode);
setPreview(null);
}}
>
<option value="upsert">Upsert</option>
<option value="create_only">Nur erstellen</option>
<option value="update_existing">Bestehende aktualisieren</option>
</select>
</div>
<Link href="/api/customers/import/template" className={buttonVariants({ variant: "outline", size: "default" })}>
<Download size={16} />
Vorlage
</Link>
</div>
{serverError && <p className="rounded-lg bg-red-50 p-3 text-sm text-red-700">{serverError}</p>}
{preview && (
<div className="grid gap-4">
<ImportSummaryCard summary={preview.summary} />
<ImportPreviewTable rows={preview.rows} />
</div>
)}
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={pending}>
Schließen
</Button>
<Button type="button" variant="outline" onClick={runPreview} disabled={pending || !file}>
<Play size={16} />
Preview starten
</Button>
<Button type="button" onClick={commitImport} disabled={pending || !preview || preview.summary.valid_rows === 0}>
{pending ? <FileUp size={16} /> : <Upload size={16} />}
Import übernehmen
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View file

@ -0,0 +1,56 @@
import type { CustomerImportPreviewRow } from "@/types/customer";
const actionLabels = {
create: "Create",
update: "Update",
skip: "Skip",
error: "Error",
};
const actionClasses = {
create: "bg-emerald-50 text-emerald-700",
update: "bg-blue-50 text-blue-700",
skip: "bg-slate-100 text-slate-600",
error: "bg-red-50 text-red-700",
};
function issueText(row: CustomerImportPreviewRow) {
const issues = [...row.errors, ...row.warnings];
if (issues.length === 0) {
return "-";
}
return issues.map((issue) => `${issue.field || "Zeile"}: ${issue.message}`).join(" · ");
}
export default function ImportPreviewTable({ rows }: { rows: CustomerImportPreviewRow[] }) {
return (
<div className="max-h-80 overflow-auto rounded-lg border">
<table className="w-full min-w-[720px] border-collapse text-sm">
<thead className="sticky top-0 bg-slate-50 text-left text-xs uppercase text-slate-500">
<tr>
<th className="px-3 py-2">Zeile</th>
<th className="px-3 py-2">Aktion</th>
<th className="px-3 py-2">Kundennummer</th>
<th className="px-3 py-2">Firma</th>
<th className="px-3 py-2">Hinweise</th>
</tr>
</thead>
<tbody className="divide-y bg-white">
{rows.map((row) => (
<tr key={row.row}>
<td className="px-3 py-2 font-medium">{row.row}</td>
<td className="px-3 py-2">
<span className={`rounded-full px-2 py-1 text-xs font-semibold ${actionClasses[row.action]}`}>
{actionLabels[row.action]}
</span>
</td>
<td className="px-3 py-2">{row.customer_number || "-"}</td>
<td className="px-3 py-2">{row.company_name || "-"}</td>
<td className="px-3 py-2 text-slate-600">{issueText(row)}</td>
</tr>
))}
</tbody>
</table>
</div>
);
}

View file

@ -0,0 +1,24 @@
import type { CustomerImportSummary } from "@/types/customer";
const summaryItems = [
["Gesamt", "total_rows"],
["Gültig", "valid_rows"],
["Fehler", "error_count"],
["Warnungen", "warning_count"],
["Creates", "create_count"],
["Updates", "update_count"],
["Skips", "skip_count"],
] as const;
export default function ImportSummaryCard({ summary }: { summary: CustomerImportSummary }) {
return (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4 lg:grid-cols-7">
{summaryItems.map(([label, key]) => (
<div key={key} className="rounded-lg border bg-white p-3">
<p className="text-xs text-slate-500">{label}</p>
<p className="mt-1 text-xl font-semibold text-slate-950">{summary[key]}</p>
</div>
))}
</div>
);
}

View file

@ -27,6 +27,8 @@ export async function proxyHermesRequest(
} }
let hermesResponse: Response; let hermesResponse: Response;
const isMultipart = request.headers.get("content-type")?.includes("multipart/form-data") ?? false;
const hasBody = request.method !== "GET" && request.method !== "DELETE";
try { try {
hermesResponse = await fetch(`${hermesUrl}${path}`, { hermesResponse = await fetch(`${hermesUrl}${path}`, {
@ -34,11 +36,13 @@ export async function proxyHermesRequest(
headers: { headers: {
Authorization: `Bearer ${token}`, Authorization: `Bearer ${token}`,
Accept: "application/json", Accept: "application/json",
"Content-Type": "application/json", ...(!isMultipart ? { "Content-Type": "application/json" } : {}),
}, },
body: request.method === "GET" || request.method === "DELETE" body: !hasBody
? undefined ? undefined
: await request.text(), : isMultipart
? await request.formData()
: await request.text(),
cache: "no-store", cache: "no-store",
}); });
} catch { } catch {

View file

@ -1,6 +1,8 @@
export type CustomerStatus = "lead" | "active" | "inactive" | "blocked" | "archived"; export type CustomerStatus = "lead" | "active" | "inactive" | "blocked" | "archived";
export type CustomerType = "company" | "private" | "public_sector" | "partner" | "supplier"; export type CustomerType = "company" | "private" | "public_sector" | "partner" | "supplier";
export type AddressType = "billing" | "shipping" | "primary" | "other"; export type AddressType = "billing" | "shipping" | "primary" | "other";
export type CustomerImportMode = "create_only" | "update_existing" | "upsert";
export type CustomerImportAction = "create" | "update" | "skip" | "error";
export interface CustomerAddress { export interface CustomerAddress {
id: number; id: number;
@ -87,3 +89,46 @@ export type CustomerContactPayload = {
is_primary: boolean; is_primary: boolean;
notes: string; notes: string;
}; };
export interface CustomerImportIssue {
row: number;
field: string;
message: string;
}
export interface CustomerImportPreviewRow {
row: number;
customer_number: string;
company_name: string;
action: CustomerImportAction;
errors: CustomerImportIssue[];
warnings: CustomerImportIssue[];
}
export interface CustomerImportSummary {
total_rows: number;
valid_rows: number;
error_count: number;
warning_count: number;
duplicate_count: number;
create_count: number;
update_count: number;
skip_count: number;
}
export interface CustomerImportPreviewResponse {
mode: CustomerImportMode;
summary: CustomerImportSummary;
rows: CustomerImportPreviewRow[];
errors: CustomerImportIssue[];
warnings: CustomerImportIssue[];
duplicates: CustomerImportIssue[];
}
export interface CustomerImportCommitResponse {
mode: CustomerImportMode;
summary: CustomerImportSummary;
created: number;
updated: number;
skipped: number;
}

View file

@ -30,6 +30,8 @@ export interface RolePayload {
export interface CurrentUser { export interface CurrentUser {
id: number; id: number;
first_name: string;
last_name: string;
username: string; username: string;
email: string; email: string;
role: string; role: string;