feat(validation): complete validation editor and master data modules

This commit is contained in:
Schubert Ferenc 2026-07-10 22:42:03 +02:00
parent 2b5c765e41
commit f73a24df13
73 changed files with 10194 additions and 0 deletions

View file

@ -0,0 +1,8 @@
POSTGRES_DB=validation_suite
POSTGRES_USER=validation
POSTGRES_PASSWORD=validation123
DATABASE_URL=postgresql+psycopg://validation:validation123@postgres:5432/validation_suite
JWT_SECRET=replace-this-secret
ADMIN_EMAIL=admin@schubamed.de
ADMIN_PASSWORD=ValidationSuite!2026
NEXT_PUBLIC_API_BASE_URL=http://localhost:8000/api/v1

View file

@ -0,0 +1,12 @@
# Architecture
Validation Suite besteht aus Atlas, Mercury und PostgreSQL.
Atlas ist das responsive Next.js-Frontend mit App Router, TypeScript, TailwindCSS, Lucide Icons, React Hook Form, Zod, TanStack Table und TanStack Query.
Mercury ist die FastAPI-Anwendung mit Pydantic v2, SQLAlchemy 2.x, Alembic, JWT Authentication, bcrypt, Repository Pattern und Service Layer.
Orion ist das serverseitige PDF-Modul. Berichte werden aus modularen Kapiteln erzeugt und als HTML/CSS gerendert.
Helios ist das Import-Modul fuer Messdaten. CSV-Import ist vorbereitet, direkter Winlog-Import kann auf derselben Service-Grenze erweitert werden.

View file

@ -0,0 +1,36 @@
# Validation Suite
Professionelle Enterprise-Anwendung fuer medizinische Validierungsprozesse.
## Start
```bash
docker compose up -d
```
Frontend: `http://localhost:3000`
API: `http://localhost:8000/docs`
Initialer Administrator:
```text
admin@schubamed.de
ValidationSuite!2026
```
## Services
- `atlas-web`: Next.js 16 App Router Frontend
- `mercury-api`: FastAPI Backend
- `postgres`: PostgreSQL Datenbank
## Struktur
```text
frontend/atlas
backend/mercury
database
docker
docs
```

View file

@ -0,0 +1,23 @@
# Roadmap
## Phase 1
- Authentifizierung
- Benutzerrollen
- Stammdaten fuer Kunden, Standorte, Ansprechpartner, Geraete und Pruefmittel
- Validierungs-Wizard
- Dokumentenablage
## Phase 2
- Orion PDF-Kapitel mit finalen Berichtsvorlagen
- Helios Winlog-Import
- Messwertdiagramme
- Audit- und Historienfunktionen
## Phase 3
- Erweiterte Rechteverwaltung
- Mehrmandantenfaehigkeit
- Produktionsbetrieb mit Backup- und Monitoring-Konzept

View file

@ -0,0 +1,26 @@
FROM python:3.12-slim
WORKDIR /app
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PYTHONPATH=/app
RUN apt-get update \
&& apt-get install -y --no-install-recommends build-essential libpq-dev libcairo2 pango1.0-tools libpango-1.0-0 libgdk-pixbuf-2.0-0 libffi-dev \
&& rm -rf /var/lib/apt/lists/*
COPY pyproject.toml .
RUN pip install --no-cache-dir .
COPY alembic.ini .
COPY alembic ./alembic
COPY app ./app
COPY docker-entrypoint.sh /docker-entrypoint.sh
RUN chmod +x /docker-entrypoint.sh
EXPOSE 8000
ENTRYPOINT ["/docker-entrypoint.sh"]
CMD ["gunicorn", "app.main:app", "-k", "uvicorn.workers.UvicornWorker", "--bind", "0.0.0.0:8000", "--workers", "2"]

View file

@ -0,0 +1,37 @@
[alembic]
script_location = alembic
prepend_sys_path = .
sqlalchemy.url = postgresql+psycopg://validation:validation123@postgres:5432/validation_suite
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s

View file

@ -0,0 +1,48 @@
from __future__ import annotations
from logging.config import fileConfig
from alembic import context
from sqlalchemy import engine_from_config, pool
from app.core.config import settings
from app.db.base import Base
from app.models import contact, customer, device, document, equipment, location, program, user, validation
config = context.config
config.set_main_option("sqlalchemy.url", settings.database_url)
if config.config_file_name is not None:
fileConfig(config.config_file_name)
target_metadata = Base.metadata
def run_migrations_offline() -> None:
context.configure(
url=settings.database_url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

View file

@ -0,0 +1,209 @@
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "202607100001"
down_revision = None
branch_labels = None
depends_on = None
def upgrade() -> None:
user_role = sa.Enum("admin", "employee", "auditor", name="userrole")
customer_type = sa.Enum("practice", "clinic", name="customertype")
equipment_kind = sa.Enum("temperature_logger", "pressure_logger", "sensor", name="equipmentkind")
equipment_status = sa.Enum("green", "yellow", "red", name="equipmentstatus")
validation_status = sa.Enum("draft", "in_progress", "ready_for_report", "completed", name="validationstatus")
document_owner_type = sa.Enum("customer", "device", "validation", "equipment", name="documentownertype")
op.create_table(
"users",
sa.Column("email", sa.String(length=255), nullable=False),
sa.Column("full_name", sa.String(length=160), nullable=False),
sa.Column("role", user_role, nullable=False),
sa.Column("password_hash", sa.String(length=255), nullable=False),
sa.Column("is_active", sa.Boolean(), nullable=False),
sa.Column("id", sa.String(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("email"),
)
op.create_index("ix_users_email", "users", ["email"])
op.create_table(
"customers",
sa.Column("customer_type", customer_type, nullable=False),
sa.Column("name", sa.String(length=220), nullable=False),
sa.Column("street", sa.String(length=220), nullable=True),
sa.Column("postal_code", sa.String(length=20), nullable=True),
sa.Column("city", sa.String(length=120), nullable=True),
sa.Column("phone", sa.String(length=80), nullable=True),
sa.Column("email", sa.String(length=255), nullable=True),
sa.Column("hygiene_officer", sa.String(length=160), nullable=True),
sa.Column("quality_manager", sa.String(length=160), nullable=True),
sa.Column("notes", sa.Text(), nullable=True),
sa.Column("id", sa.String(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_customers_name", "customers", ["name"])
op.create_table(
"equipment",
sa.Column("kind", equipment_kind, nullable=False),
sa.Column("manufacturer", sa.String(length=140), nullable=True),
sa.Column("model", sa.String(length=140), nullable=True),
sa.Column("serial_number", sa.String(length=140), nullable=False),
sa.Column("calibrated_on", sa.Date(), nullable=True),
sa.Column("calibration_due_on", sa.Date(), nullable=True),
sa.Column("certificate_document_id", sa.String(length=80), nullable=True),
sa.Column("status", equipment_status, nullable=False),
sa.Column("id", sa.String(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("serial_number"),
)
op.create_index("ix_equipment_serial_number", "equipment", ["serial_number"])
op.create_table(
"contacts",
sa.Column("customer_id", sa.String(), nullable=False),
sa.Column("full_name", sa.String(length=160), nullable=False),
sa.Column("function", sa.String(length=120), nullable=True),
sa.Column("email", sa.String(length=255), nullable=True),
sa.Column("phone", sa.String(length=80), nullable=True),
sa.Column("id", sa.String(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.ForeignKeyConstraint(["customer_id"], ["customers.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_contacts_customer_id", "contacts", ["customer_id"])
op.create_table(
"locations",
sa.Column("customer_id", sa.String(), nullable=False),
sa.Column("name", sa.String(length=160), nullable=False),
sa.Column("street", sa.String(length=220), nullable=True),
sa.Column("postal_code", sa.String(length=20), nullable=True),
sa.Column("city", sa.String(length=120), nullable=True),
sa.Column("room", sa.String(length=120), nullable=True),
sa.Column("id", sa.String(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.ForeignKeyConstraint(["customer_id"], ["customers.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_locations_customer_id", "locations", ["customer_id"])
op.create_table(
"documents",
sa.Column("owner_type", document_owner_type, nullable=False),
sa.Column("owner_id", sa.String(length=80), nullable=False),
sa.Column("filename", sa.String(length=255), nullable=False),
sa.Column("content_type", sa.String(length=120), nullable=False),
sa.Column("storage_path", sa.String(length=500), nullable=False),
sa.Column("id", sa.String(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_documents_owner_id", "documents", ["owner_id"])
op.create_index("ix_documents_owner_type", "documents", ["owner_type"])
op.create_table(
"devices",
sa.Column("customer_id", sa.String(), nullable=False),
sa.Column("location_id", sa.String(), nullable=True),
sa.Column("manufacturer", sa.String(length=140), nullable=False),
sa.Column("model", sa.String(length=140), nullable=False),
sa.Column("device_type", sa.String(length=120), nullable=True),
sa.Column("serial_number", sa.String(length=140), nullable=False),
sa.Column("year_built", sa.Integer(), nullable=True),
sa.Column("commissioned_on", sa.Date(), nullable=True),
sa.Column("chamber_volume_liters", sa.Integer(), nullable=True),
sa.Column("steam_generation", sa.String(length=220), nullable=True),
sa.Column("water_treatment", sa.String(length=220), nullable=True),
sa.Column("documentation", sa.Text(), nullable=True),
sa.Column("supplier", sa.String(length=180), nullable=True),
sa.Column("id", sa.String(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.ForeignKeyConstraint(["customer_id"], ["customers.id"]),
sa.ForeignKeyConstraint(["location_id"], ["locations.id"]),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("serial_number"),
)
op.create_index("ix_devices_customer_id", "devices", ["customer_id"])
op.create_index("ix_devices_location_id", "devices", ["location_id"])
op.create_index("ix_devices_serial_number", "devices", ["serial_number"])
op.create_table(
"programs",
sa.Column("device_id", sa.String(), nullable=False),
sa.Column("name", sa.String(length=160), nullable=False),
sa.Column("temperature_celsius", sa.Integer(), nullable=True),
sa.Column("holding_time_minutes", sa.Integer(), nullable=True),
sa.Column("drying_time_minutes", sa.Integer(), nullable=True),
sa.Column("id", sa.String(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.ForeignKeyConstraint(["device_id"], ["devices.id"]),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_programs_device_id", "programs", ["device_id"])
op.create_table(
"validations",
sa.Column("report_number", sa.String(length=80), nullable=False),
sa.Column("customer_id", sa.String(), nullable=False),
sa.Column("location_id", sa.String(), nullable=True),
sa.Column("device_id", sa.String(), nullable=True),
sa.Column("validation_type", sa.String(length=120), nullable=False),
sa.Column("scheduled_on", sa.Date(), nullable=True),
sa.Column("performed_on", sa.Date(), nullable=True),
sa.Column("next_validation_on", sa.Date(), nullable=True),
sa.Column("examiner_id", sa.String(), nullable=True),
sa.Column("status", validation_status, nullable=False),
sa.Column("result", sa.String(length=120), nullable=True),
sa.Column("notes", sa.Text(), nullable=True),
sa.Column("id", sa.String(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.ForeignKeyConstraint(["customer_id"], ["customers.id"]),
sa.ForeignKeyConstraint(["device_id"], ["devices.id"]),
sa.ForeignKeyConstraint(["examiner_id"], ["users.id"]),
sa.ForeignKeyConstraint(["location_id"], ["locations.id"]),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("report_number"),
)
op.create_index("ix_validations_customer_id", "validations", ["customer_id"])
op.create_index("ix_validations_device_id", "validations", ["device_id"])
op.create_index("ix_validations_location_id", "validations", ["location_id"])
op.create_index("ix_validations_report_number", "validations", ["report_number"])
def downgrade() -> None:
op.drop_table("validations")
op.drop_table("programs")
op.drop_table("devices")
op.drop_table("documents")
op.drop_table("locations")
op.drop_table("contacts")
op.drop_table("equipment")
op.drop_table("customers")
op.drop_table("users")
for enum_name in [
"documentownertype",
"validationstatus",
"equipmentstatus",
"equipmentkind",
"customertype",
"userrole",
]:
sa.Enum(name=enum_name).drop(op.get_bind(), checkfirst=True)

View file

@ -0,0 +1,54 @@
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "202607100002"
down_revision = "202607100001"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("validations", sa.Column("contact_id", sa.String(), nullable=True))
op.add_column("validations", sa.Column("project", sa.String(length=180), nullable=True))
op.add_column("validations", sa.Column("test_location", sa.String(length=180), nullable=True))
op.add_column("validations", sa.Column("examiner_name", sa.String(length=180), nullable=True))
op.add_column("validations", sa.Column("participants", sa.Text(), nullable=True))
op.add_column("validations", sa.Column("operator_name", sa.String(length=180), nullable=True))
op.add_column("validations", sa.Column("equipment_ids", sa.JSON(), nullable=False, server_default="[]"))
op.add_column("validations", sa.Column("environment_conditions", sa.JSON(), nullable=False, server_default="{}"))
op.add_column("validations", sa.Column("documentation_checklist", sa.JSON(), nullable=False, server_default="[]"))
op.add_column("validations", sa.Column("performance_checklist", sa.JSON(), nullable=False, server_default="[]"))
op.add_column("validations", sa.Column("programs", sa.JSON(), nullable=False, server_default="[]"))
op.add_column("validations", sa.Column("loading_patterns", sa.JSON(), nullable=False, server_default="[]"))
op.add_column("validations", sa.Column("measurement_data", sa.JSON(), nullable=False, server_default="[]"))
op.add_column("validations", sa.Column("drying", sa.JSON(), nullable=False, server_default="{}"))
op.add_column("validations", sa.Column("recommendations", sa.JSON(), nullable=False, server_default="[]"))
op.add_column("validations", sa.Column("attachments", sa.JSON(), nullable=False, server_default="[]"))
op.create_index("ix_validations_contact_id", "validations", ["contact_id"])
op.create_foreign_key("fk_validations_contact_id_contacts", "validations", "contacts", ["contact_id"], ["id"])
def downgrade() -> None:
op.drop_constraint("fk_validations_contact_id_contacts", "validations", type_="foreignkey")
op.drop_index("ix_validations_contact_id", table_name="validations")
for column in [
"attachments",
"recommendations",
"drying",
"measurement_data",
"loading_patterns",
"programs",
"performance_checklist",
"documentation_checklist",
"environment_conditions",
"equipment_ids",
"operator_name",
"participants",
"examiner_name",
"test_location",
"project",
"contact_id",
]:
op.drop_column("validations", column)

View file

@ -0,0 +1,32 @@
from __future__ import annotations
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from jose import JWTError, jwt
from sqlalchemy.orm import Session
from app.core.config import settings
from app.db.session import get_session
from app.models.user import User
bearer = HTTPBearer()
def current_user(
credentials: HTTPAuthorizationCredentials = Depends(bearer),
session: Session = Depends(get_session),
) -> User:
try:
payload = jwt.decode(
credentials.credentials,
settings.jwt_secret,
algorithms=[settings.jwt_algorithm],
)
user_id = payload.get("sub")
except JWTError as exc:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token") from exc
user = session.get(User, user_id)
if user is None or not user.is_active:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Inactive user")
return user

View file

@ -0,0 +1,24 @@
from __future__ import annotations
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from app.api.dependencies import current_user
from app.db.session import get_session
from app.models.user import User
from app.schemas.auth import LoginRequest, TokenResponse, UserRead
from app.services.auth_service import AuthService
router = APIRouter(prefix="/auth", tags=["auth"])
@router.post("/login", response_model=TokenResponse)
def login(payload: LoginRequest, session: Session = Depends(get_session)) -> TokenResponse:
token = AuthService(session).login(payload.email, payload.password)
return TokenResponse(access_token=token)
@router.get("/me", response_model=UserRead)
def me(user: User = Depends(current_user)) -> User:
return user

View file

@ -0,0 +1,216 @@
from __future__ import annotations
from typing import Any
from fastapi import APIRouter, Depends, Query, Response
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.api.dependencies import current_user
from app.db.session import get_session
from app.models.contact import Contact
from app.models.customer import Customer
from app.models.device import Device
from app.models.equipment import Equipment
from app.models.location import Location
from app.models.validation import Validation
from app.schemas.common import PaginatedResponse
from app.schemas.domain import (
ContactCreate,
ContactRead,
ContactUpdate,
CustomerCreate,
CustomerRead,
CustomerUpdate,
DeviceCreate,
DeviceRead,
DeviceUpdate,
EquipmentCreate,
EquipmentRead,
EquipmentUpdate,
LocationCreate,
LocationRead,
LocationUpdate,
ValidationCreate,
ValidationRead,
ValidationUpdate,
)
from app.services.domain_service import CrudService, DomainServices
router = APIRouter(tags=["domain"], dependencies=[Depends(current_user)])
@router.get("/dashboard")
def dashboard(session: Session = Depends(get_session)) -> dict[str, int]:
return {
"customers": session.scalar(select(func.count()).select_from(Customer)) or 0,
"locations": session.scalar(select(func.count()).select_from(Location)) or 0,
"contacts": session.scalar(select(func.count()).select_from(Contact)) or 0,
"devices": session.scalar(select(func.count()).select_from(Device)) or 0,
"equipment": session.scalar(select(func.count()).select_from(Equipment)) or 0,
"validations": session.scalar(select(func.count()).select_from(Validation)) or 0,
}
def paging(
page: int = Query(default=1, ge=1),
page_size: int = Query(default=20, ge=1, le=100),
search: str | None = Query(default=None, max_length=120),
) -> dict[str, Any]:
return {"page": page, "page_size": page_size, "search": search}
def commit_create(session: Session, service: CrudService, payload):
item = service.create(payload.model_dump())
session.commit()
session.refresh(item)
return item
def commit_update(session: Session, service: CrudService, item_id: str, payload):
item = service.update(item_id, payload.model_dump())
session.commit()
session.refresh(item)
return item
def commit_delete(session: Session, service: CrudService, item_id: str) -> Response:
service.delete(item_id)
session.commit()
return Response(status_code=204)
@router.get("/customers", response_model=PaginatedResponse[CustomerRead])
def list_customers(params: dict = Depends(paging), session: Session = Depends(get_session)):
return DomainServices(session).customers.list(**params)
@router.post("/customers", response_model=CustomerRead, status_code=201)
def create_customer(payload: CustomerCreate, session: Session = Depends(get_session)):
return commit_create(session, DomainServices(session).customers, payload)
@router.put("/customers/{item_id}", response_model=CustomerRead)
def update_customer(item_id: str, payload: CustomerUpdate, session: Session = Depends(get_session)):
return commit_update(session, DomainServices(session).customers, item_id, payload)
@router.delete("/customers/{item_id}", status_code=204)
def delete_customer(item_id: str, session: Session = Depends(get_session)):
return commit_delete(session, DomainServices(session).customers, item_id)
@router.get("/locations", response_model=PaginatedResponse[LocationRead])
def list_locations(params: dict = Depends(paging), session: Session = Depends(get_session)):
return DomainServices(session).locations.list(**params)
@router.post("/locations", response_model=LocationRead, status_code=201)
def create_location(payload: LocationCreate, session: Session = Depends(get_session)):
return commit_create(session, DomainServices(session).locations, payload)
@router.put("/locations/{item_id}", response_model=LocationRead)
def update_location(item_id: str, payload: LocationUpdate, session: Session = Depends(get_session)):
return commit_update(session, DomainServices(session).locations, item_id, payload)
@router.delete("/locations/{item_id}", status_code=204)
def delete_location(item_id: str, session: Session = Depends(get_session)):
return commit_delete(session, DomainServices(session).locations, item_id)
@router.get("/contacts", response_model=PaginatedResponse[ContactRead])
def list_contacts(params: dict = Depends(paging), session: Session = Depends(get_session)):
return DomainServices(session).contacts.list(**params)
@router.post("/contacts", response_model=ContactRead, status_code=201)
def create_contact(payload: ContactCreate, session: Session = Depends(get_session)):
return commit_create(session, DomainServices(session).contacts, payload)
@router.put("/contacts/{item_id}", response_model=ContactRead)
def update_contact(item_id: str, payload: ContactUpdate, session: Session = Depends(get_session)):
return commit_update(session, DomainServices(session).contacts, item_id, payload)
@router.delete("/contacts/{item_id}", status_code=204)
def delete_contact(item_id: str, session: Session = Depends(get_session)):
return commit_delete(session, DomainServices(session).contacts, item_id)
@router.get("/devices", response_model=PaginatedResponse[DeviceRead])
def list_devices(params: dict = Depends(paging), session: Session = Depends(get_session)):
return DomainServices(session).devices.list(**params)
@router.post("/devices", response_model=DeviceRead, status_code=201)
def create_device(payload: DeviceCreate, session: Session = Depends(get_session)):
return commit_create(session, DomainServices(session).devices, payload)
@router.put("/devices/{item_id}", response_model=DeviceRead)
def update_device(item_id: str, payload: DeviceUpdate, session: Session = Depends(get_session)):
return commit_update(session, DomainServices(session).devices, item_id, payload)
@router.delete("/devices/{item_id}", status_code=204)
def delete_device(item_id: str, session: Session = Depends(get_session)):
return commit_delete(session, DomainServices(session).devices, item_id)
@router.get("/equipment", response_model=PaginatedResponse[EquipmentRead])
def list_equipment(params: dict = Depends(paging), session: Session = Depends(get_session)):
return DomainServices(session).equipment.list(**params)
@router.post("/equipment", response_model=EquipmentRead, status_code=201)
def create_equipment(payload: EquipmentCreate, session: Session = Depends(get_session)):
return commit_create(session, DomainServices(session).equipment, payload)
@router.put("/equipment/{item_id}", response_model=EquipmentRead)
def update_equipment(item_id: str, payload: EquipmentUpdate, session: Session = Depends(get_session)):
return commit_update(session, DomainServices(session).equipment, item_id, payload)
@router.delete("/equipment/{item_id}", status_code=204)
def delete_equipment(item_id: str, session: Session = Depends(get_session)):
return commit_delete(session, DomainServices(session).equipment, item_id)
@router.get("/validations", response_model=PaginatedResponse[ValidationRead])
def list_validations(params: dict = Depends(paging), session: Session = Depends(get_session)):
return DomainServices(session).validations.list(**params)
@router.get("/validations/next-report-number")
def next_report_number(session: Session = Depends(get_session)) -> dict[str, str]:
count = session.scalar(select(func.count()).select_from(Validation)) or 0
return {"report_number": f"VAL-{count + 1:05d}"}
@router.get("/validations/{item_id}", response_model=ValidationRead)
def get_validation(item_id: str, session: Session = Depends(get_session)):
item = DomainServices(session).validations.repository.get(item_id)
if item is None:
from fastapi import HTTPException
raise HTTPException(status_code=404, detail="Resource not found")
return item
@router.post("/validations", response_model=ValidationRead, status_code=201)
def create_validation(payload: ValidationCreate, session: Session = Depends(get_session)):
return commit_create(session, DomainServices(session).validations, payload)
@router.put("/validations/{item_id}", response_model=ValidationRead)
def update_validation(item_id: str, payload: ValidationUpdate, session: Session = Depends(get_session)):
return commit_update(session, DomainServices(session).validations, item_id, payload)
@router.delete("/validations/{item_id}", status_code=204)
def delete_validation(item_id: str, session: Session = Depends(get_session)):
return commit_delete(session, DomainServices(session).validations, item_id)

View file

@ -0,0 +1,10 @@
from __future__ import annotations
from fastapi import APIRouter
from app.api.v1 import auth, domain
api_router = APIRouter(prefix="/api/v1")
api_router.include_router(auth.router)
api_router.include_router(domain.router)

View file

@ -0,0 +1,24 @@
from __future__ import annotations
from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
app_name: str = "Validation Suite"
environment: str = "local"
database_url: str = Field(
default="postgresql+psycopg://validation:validation123@postgres:5432/validation_suite",
alias="DATABASE_URL",
)
jwt_secret: str = Field(default="change-me-in-production", alias="JWT_SECRET")
jwt_algorithm: str = "HS256"
access_token_minutes: int = 60 * 8
cors_origins: list[str] = ["http://localhost:3000"]
admin_email: str = Field(default="admin@schubamed.de", alias="ADMIN_EMAIL")
admin_password: str = Field(default="ValidationSuite!2026", alias="ADMIN_PASSWORD")
settings = Settings()

View file

@ -0,0 +1,23 @@
from __future__ import annotations
from datetime import UTC, datetime, timedelta
import bcrypt
from jose import jwt
from app.core.config import settings
def hash_password(password: str) -> str:
return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
def verify_password(password: str, password_hash: str) -> bool:
return bcrypt.checkpw(password.encode("utf-8"), password_hash.encode("utf-8"))
def create_access_token(subject: str, role: str) -> str:
expires_at = datetime.now(UTC) + timedelta(minutes=settings.access_token_minutes)
payload = {"sub": subject, "role": role, "exp": expires_at}
return jwt.encode(payload, settings.jwt_secret, algorithm=settings.jwt_algorithm)

View file

@ -0,0 +1,31 @@
from __future__ import annotations
from datetime import datetime
from uuid import uuid4
from sqlalchemy import DateTime, MetaData, func
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
convention = {
"ix": "ix_%(column_0_label)s",
"uq": "uq_%(table_name)s_%(column_0_name)s",
"ck": "ck_%(table_name)s_%(constraint_name)s",
"fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s",
"pk": "pk_%(table_name)s",
}
class Base(DeclarativeBase):
metadata = MetaData(naming_convention=convention)
class UUIDMixin:
id: Mapped[str] = mapped_column(primary_key=True, default=lambda: str(uuid4()))
class TimestampMixin:
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)

View file

@ -0,0 +1,30 @@
from __future__ import annotations
from sqlalchemy import select
from app.core.config import settings
from app.core.security import hash_password
from app.db.session import SessionLocal
from app.models.user import User, UserRole
def seed_admin() -> None:
with SessionLocal() as session:
existing = session.scalar(select(User).where(User.email == settings.admin_email.lower()))
if existing is not None:
return
session.add(
User(
email=settings.admin_email.lower(),
full_name="Validation Suite Administrator",
role=UserRole.admin,
password_hash=hash_password(settings.admin_password),
is_active=True,
)
)
session.commit()
if __name__ == "__main__":
seed_admin()

View file

@ -0,0 +1,20 @@
from __future__ import annotations
from collections.abc import Generator
from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker
from app.core.config import settings
engine = create_engine(settings.database_url, pool_pre_ping=True)
SessionLocal = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False)
def get_session() -> Generator[Session, None, None]:
session = SessionLocal()
try:
yield session
finally:
session.close()

View file

@ -0,0 +1,23 @@
from __future__ import annotations
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.api.v1.router import api_router
from app.core.config import settings
app = FastAPI(title=settings.app_name, version="0.1.0")
app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(api_router)
@app.get("/health")
def health() -> dict[str, str]:
return {"status": "ok"}

View file

@ -0,0 +1,21 @@
from app.models.contact import Contact
from app.models.customer import Customer
from app.models.device import Device
from app.models.document import Document
from app.models.equipment import Equipment
from app.models.location import Location
from app.models.program import Program
from app.models.user import User
from app.models.validation import Validation
__all__ = [
"Contact",
"Customer",
"Device",
"Document",
"Equipment",
"Location",
"Program",
"User",
"Validation",
]

View file

@ -0,0 +1,19 @@
from __future__ import annotations
from sqlalchemy import ForeignKey, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.base import Base, TimestampMixin, UUIDMixin
class Contact(Base, UUIDMixin, TimestampMixin):
__tablename__ = "contacts"
customer_id: Mapped[str] = mapped_column(ForeignKey("customers.id", ondelete="CASCADE"), index=True)
full_name: Mapped[str] = mapped_column(String(160))
function: Mapped[str | None] = mapped_column(String(120))
email: Mapped[str | None] = mapped_column(String(255))
phone: Mapped[str | None] = mapped_column(String(80))
customer: Mapped["Customer"] = relationship(back_populates="contacts")

View file

@ -0,0 +1,32 @@
from __future__ import annotations
import enum
from sqlalchemy import Enum, String, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.base import Base, TimestampMixin, UUIDMixin
class CustomerType(str, enum.Enum):
practice = "practice"
clinic = "clinic"
class Customer(Base, UUIDMixin, TimestampMixin):
__tablename__ = "customers"
customer_type: Mapped[CustomerType] = mapped_column(Enum(CustomerType))
name: Mapped[str] = mapped_column(String(220), index=True)
street: Mapped[str | None] = mapped_column(String(220))
postal_code: Mapped[str | None] = mapped_column(String(20))
city: Mapped[str | None] = mapped_column(String(120))
phone: Mapped[str | None] = mapped_column(String(80))
email: Mapped[str | None] = mapped_column(String(255))
hygiene_officer: Mapped[str | None] = mapped_column(String(160))
quality_manager: Mapped[str | None] = mapped_column(String(160))
notes: Mapped[str | None] = mapped_column(Text)
contacts: Mapped[list["Contact"]] = relationship(back_populates="customer", cascade="all, delete-orphan")
locations: Mapped[list["Location"]] = relationship(back_populates="customer", cascade="all, delete-orphan")

View file

@ -0,0 +1,29 @@
from __future__ import annotations
from datetime import date
from sqlalchemy import Date, ForeignKey, Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.base import Base, TimestampMixin, UUIDMixin
class Device(Base, UUIDMixin, TimestampMixin):
__tablename__ = "devices"
customer_id: Mapped[str] = mapped_column(ForeignKey("customers.id"), index=True)
location_id: Mapped[str | None] = mapped_column(ForeignKey("locations.id"), index=True)
manufacturer: Mapped[str] = mapped_column(String(140))
model: Mapped[str] = mapped_column(String(140))
device_type: Mapped[str | None] = mapped_column(String(120))
serial_number: Mapped[str] = mapped_column(String(140), unique=True, index=True)
year_built: Mapped[int | None] = mapped_column(Integer)
commissioned_on: Mapped[date | None] = mapped_column(Date)
chamber_volume_liters: Mapped[int | None] = mapped_column(Integer)
steam_generation: Mapped[str | None] = mapped_column(String(220))
water_treatment: Mapped[str | None] = mapped_column(String(220))
documentation: Mapped[str | None] = mapped_column(Text)
supplier: Mapped[str | None] = mapped_column(String(180))
location: Mapped["Location | None"] = relationship(back_populates="devices")

View file

@ -0,0 +1,26 @@
from __future__ import annotations
import enum
from sqlalchemy import Enum, String
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base, TimestampMixin, UUIDMixin
class DocumentOwnerType(str, enum.Enum):
customer = "customer"
device = "device"
validation = "validation"
equipment = "equipment"
class Document(Base, UUIDMixin, TimestampMixin):
__tablename__ = "documents"
owner_type: Mapped[DocumentOwnerType] = mapped_column(Enum(DocumentOwnerType), index=True)
owner_id: Mapped[str] = mapped_column(String(80), index=True)
filename: Mapped[str] = mapped_column(String(255))
content_type: Mapped[str] = mapped_column(String(120))
storage_path: Mapped[str] = mapped_column(String(500))

View file

@ -0,0 +1,35 @@
from __future__ import annotations
import enum
from datetime import date
from sqlalchemy import Date, Enum, String
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base, TimestampMixin, UUIDMixin
class EquipmentKind(str, enum.Enum):
temperature_logger = "temperature_logger"
pressure_logger = "pressure_logger"
sensor = "sensor"
class EquipmentStatus(str, enum.Enum):
green = "green"
yellow = "yellow"
red = "red"
class Equipment(Base, UUIDMixin, TimestampMixin):
__tablename__ = "equipment"
kind: Mapped[EquipmentKind] = mapped_column(Enum(EquipmentKind))
manufacturer: Mapped[str | None] = mapped_column(String(140))
model: Mapped[str | None] = mapped_column(String(140))
serial_number: Mapped[str] = mapped_column(String(140), unique=True, index=True)
calibrated_on: Mapped[date | None] = mapped_column(Date)
calibration_due_on: Mapped[date | None] = mapped_column(Date)
certificate_document_id: Mapped[str | None] = mapped_column(String(80))
status: Mapped[EquipmentStatus] = mapped_column(Enum(EquipmentStatus), default=EquipmentStatus.green)

View file

@ -0,0 +1,21 @@
from __future__ import annotations
from sqlalchemy import ForeignKey, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.base import Base, TimestampMixin, UUIDMixin
class Location(Base, UUIDMixin, TimestampMixin):
__tablename__ = "locations"
customer_id: Mapped[str] = mapped_column(ForeignKey("customers.id", ondelete="CASCADE"), index=True)
name: Mapped[str] = mapped_column(String(160))
street: Mapped[str | None] = mapped_column(String(220))
postal_code: Mapped[str | None] = mapped_column(String(20))
city: Mapped[str | None] = mapped_column(String(120))
room: Mapped[str | None] = mapped_column(String(120))
customer: Mapped["Customer"] = relationship(back_populates="locations")
devices: Mapped[list["Device"]] = relationship(back_populates="location")

View file

@ -0,0 +1,17 @@
from __future__ import annotations
from sqlalchemy import ForeignKey, Integer, String
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base, TimestampMixin, UUIDMixin
class Program(Base, UUIDMixin, TimestampMixin):
__tablename__ = "programs"
device_id: Mapped[str] = mapped_column(ForeignKey("devices.id"), index=True)
name: Mapped[str] = mapped_column(String(160))
temperature_celsius: Mapped[int | None] = mapped_column(Integer)
holding_time_minutes: Mapped[int | None] = mapped_column(Integer)
drying_time_minutes: Mapped[int | None] = mapped_column(Integer)

View file

@ -0,0 +1,25 @@
from __future__ import annotations
import enum
from sqlalchemy import Boolean, Enum, String
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base, TimestampMixin, UUIDMixin
class UserRole(str, enum.Enum):
admin = "admin"
employee = "employee"
auditor = "auditor"
class User(Base, UUIDMixin, TimestampMixin):
__tablename__ = "users"
email: Mapped[str] = mapped_column(String(255), unique=True, index=True)
full_name: Mapped[str] = mapped_column(String(160))
role: Mapped[UserRole] = mapped_column(Enum(UserRole), default=UserRole.employee)
password_hash: Mapped[str] = mapped_column(String(255))
is_active: Mapped[bool] = mapped_column(Boolean, default=True)

View file

@ -0,0 +1,49 @@
from __future__ import annotations
import enum
from datetime import date
from sqlalchemy import Date, Enum, ForeignKey, JSON, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base, TimestampMixin, UUIDMixin
class ValidationStatus(str, enum.Enum):
draft = "draft"
in_progress = "in_progress"
ready_for_report = "ready_for_report"
completed = "completed"
class Validation(Base, UUIDMixin, TimestampMixin):
__tablename__ = "validations"
report_number: Mapped[str] = mapped_column(String(80), unique=True, index=True)
customer_id: Mapped[str] = mapped_column(ForeignKey("customers.id"), index=True)
location_id: Mapped[str | None] = mapped_column(ForeignKey("locations.id"), index=True)
contact_id: Mapped[str | None] = mapped_column(ForeignKey("contacts.id"), index=True)
device_id: Mapped[str | None] = mapped_column(ForeignKey("devices.id"), index=True)
validation_type: Mapped[str] = mapped_column(String(120))
project: Mapped[str | None] = mapped_column(String(180))
test_location: Mapped[str | None] = mapped_column(String(180))
examiner_name: Mapped[str | None] = mapped_column(String(180))
participants: Mapped[str | None] = mapped_column(Text)
operator_name: Mapped[str | None] = mapped_column(String(180))
scheduled_on: Mapped[date | None] = mapped_column(Date)
performed_on: Mapped[date | None] = mapped_column(Date)
next_validation_on: Mapped[date | None] = mapped_column(Date)
examiner_id: Mapped[str | None] = mapped_column(ForeignKey("users.id"))
status: Mapped[ValidationStatus] = mapped_column(Enum(ValidationStatus), default=ValidationStatus.draft)
result: Mapped[str | None] = mapped_column(String(120))
notes: Mapped[str | None] = mapped_column(Text)
equipment_ids: Mapped[list[str]] = mapped_column(JSON, default=list)
environment_conditions: Mapped[dict] = mapped_column(JSON, default=dict)
documentation_checklist: Mapped[list[dict]] = mapped_column(JSON, default=list)
performance_checklist: Mapped[list[dict]] = mapped_column(JSON, default=list)
programs: Mapped[list[dict]] = mapped_column(JSON, default=list)
loading_patterns: Mapped[list[dict]] = mapped_column(JSON, default=list)
measurement_data: Mapped[list[dict]] = mapped_column(JSON, default=list)
drying: Mapped[dict] = mapped_column(JSON, default=dict)
recommendations: Mapped[list[dict]] = mapped_column(JSON, default=list)
attachments: Mapped[list[dict]] = mapped_column(JSON, default=list)

View file

@ -0,0 +1,19 @@
from __future__ import annotations
import csv
from dataclasses import dataclass
from pathlib import Path
@dataclass(frozen=True)
class MeasurementSeries:
headers: list[str]
rows: list[dict[str, str]]
class HeliosImportService:
def import_csv(self, path: Path) -> MeasurementSeries:
with path.open(newline="", encoding="utf-8-sig") as handle:
reader = csv.DictReader(handle)
return MeasurementSeries(headers=reader.fieldnames or [], rows=list(reader))

View file

@ -0,0 +1,30 @@
from __future__ import annotations
from pathlib import Path
from weasyprint import HTML
class OrionReportService:
chapters = [
"Deckblatt",
"Inhaltsverzeichnis",
"Zusammenfassung",
"Gerät",
"Kunde",
"Normen",
"Prüfmittel",
"Programme",
"Beladung",
"Messungen",
"Diagramme",
"Empfehlungen",
"Anlagen",
]
def render_pdf(self, title: str, output_path: Path) -> Path:
chapter_markup = "".join(f"<section><h2>{chapter}</h2></section>" for chapter in self.chapters)
html = f"<html><body><h1>{title}</h1>{chapter_markup}</body></html>"
HTML(string=html).write_pdf(output_path)
return output_path

View file

@ -0,0 +1,45 @@
from __future__ import annotations
from typing import Generic, TypeVar
from sqlalchemy import Select, func, or_, select
from sqlalchemy.orm import Session
ModelT = TypeVar("ModelT")
class Repository(Generic[ModelT]):
model: type[ModelT]
def __init__(self, session: Session) -> None:
self.session = session
def get(self, item_id: str) -> ModelT | None:
return self.session.get(self.model, item_id)
search_columns: tuple[str, ...] = ()
def _search_statement(self, search: str | None = None) -> Select[tuple[ModelT]]:
statement: Select[tuple[ModelT]] = select(self.model)
if search and self.search_columns:
term = f"%{search.strip()}%"
filters = [getattr(self.model, column).ilike(term) for column in self.search_columns]
statement = statement.where(or_(*filters))
return statement
def list(self, limit: int = 20, offset: int = 0, search: str | None = None) -> list[ModelT]:
statement = self._search_statement(search).offset(offset).limit(limit)
return list(self.session.scalars(statement))
def count(self, search: str | None = None) -> int:
subquery = self._search_statement(search).subquery()
return self.session.scalar(select(func.count()).select_from(subquery)) or 0
def add(self, item: ModelT) -> ModelT:
self.session.add(item)
self.session.flush()
return item
def delete(self, item: ModelT) -> None:
self.session.delete(item)
self.session.flush()

View file

@ -0,0 +1,67 @@
from __future__ import annotations
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.models.customer import Customer
from app.models.device import Device
from app.models.document import Document
from app.models.equipment import Equipment
from app.models.contact import Contact
from app.models.location import Location
from app.models.user import User
from app.models.validation import Validation
from app.repositories.base import Repository
class UserRepository(Repository[User]):
model = User
def by_email(self, email: str) -> User | None:
return self.session.scalar(select(User).where(User.email == email.lower()))
class CustomerRepository(Repository[Customer]):
model = Customer
search_columns = ("name", "city", "email", "phone")
class LocationRepository(Repository[Location]):
model = Location
search_columns = ("name", "city", "room")
class ContactRepository(Repository[Contact]):
model = Contact
search_columns = ("full_name", "function", "email", "phone")
class DeviceRepository(Repository[Device]):
model = Device
search_columns = ("manufacturer", "model", "serial_number", "device_type")
class EquipmentRepository(Repository[Equipment]):
model = Equipment
search_columns = ("manufacturer", "model", "serial_number")
class ValidationRepository(Repository[Validation]):
model = Validation
class DocumentRepository(Repository[Document]):
model = Document
def repositories(session: Session) -> dict[str, Repository]:
return {
"users": UserRepository(session),
"customers": CustomerRepository(session),
"locations": LocationRepository(session),
"contacts": ContactRepository(session),
"devices": DeviceRepository(session),
"equipment": EquipmentRepository(session),
"validations": ValidationRepository(session),
"documents": DocumentRepository(session),
}

View file

@ -0,0 +1,24 @@
from __future__ import annotations
from pydantic import BaseModel, EmailStr
from app.models.user import UserRole
from app.schemas.common import EntityRead
class LoginRequest(BaseModel):
email: EmailStr
password: str
class TokenResponse(BaseModel):
access_token: str
token_type: str = "bearer"
class UserRead(EntityRead):
email: EmailStr
full_name: str
role: UserRole
is_active: bool

View file

@ -0,0 +1,25 @@
from __future__ import annotations
from datetime import datetime
from pydantic import BaseModel, ConfigDict
from typing import Generic, TypeVar
T = TypeVar("T")
class ORMModel(BaseModel):
model_config = ConfigDict(from_attributes=True)
class EntityRead(ORMModel):
id: str
created_at: datetime
updated_at: datetime
class PaginatedResponse(BaseModel, Generic[T]):
items: list[T]
total: int
page: int
page_size: int

View file

@ -0,0 +1,146 @@
from __future__ import annotations
from datetime import date
from pydantic import EmailStr, Field
from app.models.customer import CustomerType
from app.models.equipment import EquipmentKind, EquipmentStatus
from app.models.validation import ValidationStatus
from app.schemas.common import EntityRead, ORMModel
class CustomerCreate(ORMModel):
customer_type: CustomerType
name: str
street: str | None = None
postal_code: str | None = None
city: str | None = None
phone: str | None = None
email: EmailStr | None = None
hygiene_officer: str | None = None
quality_manager: str | None = None
notes: str | None = None
class CustomerRead(CustomerCreate, EntityRead):
pass
class CustomerUpdate(CustomerCreate):
pass
class LocationCreate(ORMModel):
customer_id: str
name: str
street: str | None = None
postal_code: str | None = None
city: str | None = None
room: str | None = None
class LocationRead(LocationCreate, EntityRead):
pass
class LocationUpdate(LocationCreate):
pass
class ContactCreate(ORMModel):
customer_id: str
full_name: str
function: str | None = None
email: EmailStr | None = None
phone: str | None = None
class ContactRead(ContactCreate, EntityRead):
pass
class ContactUpdate(ContactCreate):
pass
class DeviceCreate(ORMModel):
customer_id: str
location_id: str | None = None
manufacturer: str
model: str
device_type: str | None = None
serial_number: str
year_built: int | None = None
commissioned_on: date | None = None
chamber_volume_liters: int | None = None
steam_generation: str | None = None
water_treatment: str | None = None
documentation: str | None = None
supplier: str | None = None
class DeviceRead(DeviceCreate, EntityRead):
pass
class DeviceUpdate(DeviceCreate):
pass
class EquipmentCreate(ORMModel):
kind: EquipmentKind
manufacturer: str | None = None
model: str | None = None
serial_number: str
calibrated_on: date | None = None
calibration_due_on: date | None = None
certificate_document_id: str | None = None
status: EquipmentStatus = EquipmentStatus.green
class EquipmentRead(EquipmentCreate, EntityRead):
pass
class EquipmentUpdate(EquipmentCreate):
pass
class ValidationCreate(ORMModel):
report_number: str
customer_id: str
location_id: str | None = None
contact_id: str | None = None
device_id: str | None = None
validation_type: str
project: str | None = None
test_location: str | None = None
examiner_name: str | None = None
participants: str | None = None
operator_name: str | None = None
scheduled_on: date | None = None
performed_on: date | None = None
next_validation_on: date | None = None
examiner_id: str | None = None
status: ValidationStatus = ValidationStatus.draft
result: str | None = None
notes: str | None = None
equipment_ids: list[str] = Field(default_factory=list)
environment_conditions: dict = Field(default_factory=dict)
documentation_checklist: list[dict] = Field(default_factory=list)
performance_checklist: list[dict] = Field(default_factory=list)
programs: list[dict] = Field(default_factory=list)
loading_patterns: list[dict] = Field(default_factory=list)
measurement_data: list[dict] = Field(default_factory=list)
drying: dict = Field(default_factory=dict)
recommendations: list[dict] = Field(default_factory=list)
attachments: list[dict] = Field(default_factory=list)
class ValidationRead(ValidationCreate, EntityRead):
pass
class ValidationUpdate(ValidationCreate):
pass

View file

@ -0,0 +1,23 @@
from __future__ import annotations
from fastapi import HTTPException, status
from sqlalchemy.orm import Session
from app.core.security import create_access_token, verify_password
from app.repositories.domain import UserRepository
class AuthService:
def __init__(self, session: Session) -> None:
self.users = UserRepository(session)
def login(self, email: str, password: str) -> str:
user = self.users.by_email(email)
if user is None or not user.is_active or not verify_password(password, user.password_hash):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid credentials",
headers={"WWW-Authenticate": "Bearer"},
)
return create_access_token(user.id, user.role.value)

View file

@ -0,0 +1,68 @@
from __future__ import annotations
from typing import TypeVar
from sqlalchemy.orm import Session
from fastapi import HTTPException, status
from app.models.contact import Contact
from app.models.customer import Customer
from app.models.device import Device
from app.models.equipment import Equipment
from app.models.location import Location
from app.models.validation import Validation
from app.repositories.base import Repository
from app.repositories.domain import (
ContactRepository,
CustomerRepository,
DeviceRepository,
EquipmentRepository,
LocationRepository,
ValidationRepository,
)
ModelT = TypeVar("ModelT")
class CrudService:
def __init__(self, repository: Repository[ModelT]) -> None:
self.repository = repository
def list(self, page: int = 1, page_size: int = 20, search: str | None = None) -> dict:
safe_page = max(page, 1)
safe_page_size = min(max(page_size, 1), 100)
offset = (safe_page - 1) * safe_page_size
return {
"items": self.repository.list(safe_page_size, offset, search),
"total": self.repository.count(search),
"page": safe_page,
"page_size": safe_page_size,
}
def create(self, data: dict) -> ModelT:
return self.repository.add(self.repository.model(**data))
def update(self, item_id: str, data: dict) -> ModelT:
item = self.repository.get(item_id)
if item is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Resource not found")
for key, value in data.items():
setattr(item, key, value)
return item
def delete(self, item_id: str) -> None:
item = self.repository.get(item_id)
if item is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Resource not found")
self.repository.delete(item)
class DomainServices:
def __init__(self, session: Session) -> None:
self.customers: CrudService[Customer] = CrudService(CustomerRepository(session))
self.locations: CrudService[Location] = CrudService(LocationRepository(session))
self.contacts: CrudService[Contact] = CrudService(ContactRepository(session))
self.devices: CrudService[Device] = CrudService(DeviceRepository(session))
self.equipment: CrudService[Equipment] = CrudService(EquipmentRepository(session))
self.validations: CrudService[Validation] = CrudService(ValidationRepository(session))

View file

@ -0,0 +1,7 @@
#!/usr/bin/env sh
set -eu
alembic upgrade head
python -m app.db.seed
exec "$@"

View file

@ -0,0 +1,33 @@
[project]
name = "mercury"
version = "0.1.0"
description = "Validation Suite backend"
requires-python = ">=3.12"
dependencies = [
"alembic==1.16.4",
"bcrypt==4.3.0",
"fastapi==0.116.1",
"gunicorn==23.0.0",
"psycopg[binary]==3.2.9",
"pydantic-settings==2.10.1",
"pydantic[email]==2.11.7",
"python-jose[cryptography]==3.5.0",
"python-multipart==0.0.20",
"sqlalchemy==2.0.41",
"uvicorn[standard]==0.35.0",
"weasyprint==62.3"
]
[project.optional-dependencies]
dev = ["black==25.1.0", "isort==6.0.1", "ruff==0.12.4"]
[tool.black]
line-length = 100
[tool.isort]
profile = "black"
[tool.ruff]
line-length = 100
target-version = "py312"

View file

@ -0,0 +1,4 @@
# Database
PostgreSQL wird durch Docker Compose gestartet. Schema-Aenderungen werden in `backend/mercury/alembic/versions` versioniert und beim Start von `mercury-api` automatisch migriert.

View file

@ -0,0 +1,49 @@
services:
postgres:
image: postgres:17
container_name: postgres
environment:
POSTGRES_DB: validation_suite
POSTGRES_USER: validation
POSTGRES_PASSWORD: validation123
volumes:
- validation_suite_postgres:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U validation -d validation_suite"]
interval: 5s
timeout: 5s
retries: 20
mercury-api:
build:
context: ./backend/mercury
container_name: mercury-api
depends_on:
postgres:
condition: service_healthy
environment:
DATABASE_URL: postgresql+psycopg://validation:validation123@postgres:5432/validation_suite
JWT_SECRET: validation-suite-local-jwt-secret
ADMIN_EMAIL: admin@schubamed.de
ADMIN_PASSWORD: ValidationSuite!2026
ports:
- "8000:8000"
volumes:
- mercury_uploads:/app/uploads
- mercury_reports:/app/reports
atlas-web:
build:
context: ./frontend/atlas
container_name: atlas-web
depends_on:
- mercury-api
environment:
NEXT_PUBLIC_API_BASE_URL: http://localhost:8000/api/v1
ports:
- "3000:3000"
volumes:
validation_suite_postgres:
mercury_uploads:
mercury_reports:

View file

@ -0,0 +1,8 @@
# Docker
Die Container werden ueber die Compose-Datei im Projektstamm gestartet:
```bash
docker compose up -d
```

View file

@ -0,0 +1,13 @@
# API
Die Mercury API ist unter `/api/v1` versioniert.
- `POST /auth/login`
- `GET /auth/me`
- `GET|POST /customers`
- `GET|POST /devices`
- `GET|POST /equipment`
- `GET|POST /validations`
Authentifizierung erfolgt per Bearer JWT.

View file

@ -0,0 +1,20 @@
FROM node:22-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
FROM node:22-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY --from=builder /app/.next ./.next
COPY --from=builder /app/public ./public
COPY --from=builder /app/package.json ./package.json
COPY --from=builder /app/node_modules ./node_modules
EXPOSE 3000
CMD ["npm", "run", "start"]

View file

@ -0,0 +1,47 @@
"use client";
import { z } from "zod";
import { useQuery } from "@tanstack/react-query";
import { useAuth } from "@/components/auth";
import { CrudPage } from "@/components/crud-page";
import { apiGet, Contact, Customer, Paginated } from "@/lib/api";
const schema = z.object({
customer_id: z.string().min(1),
full_name: z.string().min(2),
function: z.string().optional().nullable(),
email: z.union([z.string().email(), z.literal(""), z.null()]).optional(),
phone: z.string().optional().nullable()
});
export default function ContactsPage() {
const { token } = useAuth();
const customers = useQuery({
queryKey: ["customers-options", token],
queryFn: () => apiGet<Paginated<Customer>>("/customers?page=1&page_size=100", token ?? ""),
enabled: Boolean(token)
});
const customerOptions = (customers.data?.items ?? []).map((item) => ({ label: item.name, value: item.id }));
return (
<CrudPage<Contact>
title="Ansprechpartner"
subtitle="Kontaktpersonen, Funktionen und Kommunikationsdaten."
endpoint="/contacts"
columns={[
{ key: "full_name", label: "Name" },
{ key: "function", label: "Funktion" },
{ key: "email", label: "Mail" },
{ key: "phone", label: "Telefon" }
]}
fields={[
{ name: "customer_id", label: "Kunde", type: "select", options: customerOptions },
{ name: "full_name", label: "Name" },
{ name: "function", label: "Funktion" },
{ name: "email", label: "Mail", type: "email" },
{ name: "phone", label: "Telefon" }
]}
schema={schema}
emptyValues={{ customer_id: "", full_name: "", function: "", email: "", phone: "" }}
/>
);
}

View file

@ -0,0 +1,50 @@
"use client";
import { z } from "zod";
import { CrudPage } from "@/components/crud-page";
import { Customer } from "@/lib/api";
const schema = z.object({
customer_type: z.enum(["practice", "clinic"]),
name: z.string().min(2),
street: z.string().optional().nullable(),
postal_code: z.string().optional().nullable(),
city: z.string().optional().nullable(),
phone: z.string().optional().nullable(),
email: z.union([z.string().email(), z.literal(""), z.null()]).optional(),
hygiene_officer: z.string().optional().nullable(),
quality_manager: z.string().optional().nullable(),
notes: z.string().optional().nullable()
});
export default function CustomersPage() {
return (
<CrudPage<Customer>
title="Kunden"
subtitle="Praxen und Kliniken mit Kontakt- und Qualitaetsdaten."
endpoint="/customers"
columns={[
{ key: "name", label: "Name" },
{ key: "customer_type", label: "Typ" },
{ key: "city", label: "Ort" },
{ key: "phone", label: "Telefon" },
{ key: "email", label: "Mail" }
]}
fields={[
{ name: "customer_type", label: "Typ", type: "select", options: [{ label: "Praxis", value: "practice" }, { label: "Klinik", value: "clinic" }] },
{ name: "name", label: "Name", required: true },
{ name: "street", label: "Adresse" },
{ name: "postal_code", label: "PLZ" },
{ name: "city", label: "Ort" },
{ name: "phone", label: "Telefon" },
{ name: "email", label: "Mail", type: "email" },
{ name: "hygiene_officer", label: "Hygienebeauftragter" },
{ name: "quality_manager", label: "QM" },
{ name: "notes", label: "Bemerkungen", type: "textarea" }
]}
schema={schema}
emptyValues={{ customer_type: "practice", name: "", street: "", postal_code: "", city: "", phone: "", email: "", hygiene_officer: "", quality_manager: "", notes: "" }}
/>
);
}

View file

@ -0,0 +1,60 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { Building2, Gauge, MapPin, Stethoscope, UserRound } from "lucide-react";
import Link from "next/link";
import { useAuth } from "@/components/auth";
import { apiGet } from "@/lib/api";
type DashboardData = {
customers: number;
locations: number;
contacts: number;
devices: number;
equipment: number;
validations: number;
};
const cards = [
{ label: "Kunden", key: "customers", href: "/customers", icon: Building2 },
{ label: "Standorte", key: "locations", href: "/locations", icon: MapPin },
{ label: "Ansprechpartner", key: "contacts", href: "/contacts", icon: UserRound },
{ label: "Geraete", key: "devices", href: "/devices", icon: Stethoscope },
{ label: "Pruefmittel", key: "equipment", href: "/equipment", icon: Gauge }
] as const;
export default function DashboardPage() {
const { token } = useAuth();
const query = useQuery({
queryKey: ["dashboard", token],
queryFn: () => apiGet<DashboardData>("/dashboard", token ?? ""),
enabled: Boolean(token)
});
return (
<div className="space-y-8">
<header>
<h1 className="text-3xl font-semibold text-text">Dashboard</h1>
<p className="mt-2 text-text-light">Aktuelle Stammdaten aus PostgreSQL.</p>
</header>
<section className="grid gap-4 sm:grid-cols-2 xl:grid-cols-5">
{cards.map((item) => {
const Icon = item.icon;
return (
<Link key={item.key} href={item.href} className="rounded-lg border border-border bg-surface p-6 shadow-soft transition hover:-translate-y-0.5 hover:border-primary/40">
<div className="flex items-center justify-between">
<p className="text-sm text-text-light">{item.label}</p>
<Icon className="h-5 w-5 text-primary" />
</div>
<p className="mt-6 text-4xl font-semibold text-text">{query.data?.[item.key] ?? 0}</p>
</Link>
);
})}
</section>
<section className="rounded-lg border border-border bg-surface p-6 shadow-soft">
<h2 className="text-xl font-semibold">Stammdaten</h2>
<p className="mt-2 text-sm leading-6 text-text-light">Kunden, Standorte, Ansprechpartner, Geraete und Pruefmittel koennen produktiv angelegt, bearbeitet, gesucht und geloescht werden.</p>
</section>
</div>
);
}

View file

@ -0,0 +1,70 @@
"use client";
import { z } from "zod";
import { useQuery } from "@tanstack/react-query";
import { useAuth } from "@/components/auth";
import { CrudPage } from "@/components/crud-page";
import { apiGet, Customer, Device, Location, Paginated } from "@/lib/api";
const optionalNumber = z.union([z.coerce.number().int().positive(), z.literal(""), z.null()]).optional();
const schema = z.object({
customer_id: z.string().min(1),
location_id: z.string().optional().nullable(),
manufacturer: z.string().min(2),
model: z.string().min(1),
device_type: z.string().optional().nullable(),
serial_number: z.string().min(2),
year_built: optionalNumber,
commissioned_on: z.string().optional().nullable(),
chamber_volume_liters: optionalNumber,
steam_generation: z.string().optional().nullable(),
water_treatment: z.string().optional().nullable(),
documentation: z.string().optional().nullable(),
supplier: z.string().optional().nullable()
});
export default function DevicesPage() {
const { token } = useAuth();
const customers = useQuery({
queryKey: ["customers-options", token],
queryFn: () => apiGet<Paginated<Customer>>("/customers?page=1&page_size=100", token ?? ""),
enabled: Boolean(token)
});
const locations = useQuery({
queryKey: ["locations-options", token],
queryFn: () => apiGet<Paginated<Location>>("/locations?page=1&page_size=100", token ?? ""),
enabled: Boolean(token)
});
const customerOptions = (customers.data?.items ?? []).map((item) => ({ label: item.name, value: item.id }));
const locationOptions = (locations.data?.items ?? []).map((item) => ({ label: item.name, value: item.id }));
return (
<CrudPage<Device>
title="Geraete"
subtitle="Geraetestammdaten, technische Daten, Standort und Dokumentation."
endpoint="/devices"
columns={[
{ key: "manufacturer", label: "Hersteller" },
{ key: "model", label: "Modell" },
{ key: "device_type", label: "Typ" },
{ key: "serial_number", label: "Seriennummer" }
]}
fields={[
{ name: "customer_id", label: "Kunde", type: "select", options: customerOptions },
{ name: "location_id", label: "Standort", type: "select", options: locationOptions },
{ name: "manufacturer", label: "Hersteller" },
{ name: "model", label: "Modell" },
{ name: "device_type", label: "Typ" },
{ name: "serial_number", label: "Seriennummer" },
{ name: "year_built", label: "Baujahr", type: "number" },
{ name: "commissioned_on", label: "Inbetriebnahme", type: "date" },
{ name: "chamber_volume_liters", label: "Kammervolumen Liter", type: "number" },
{ name: "steam_generation", label: "Dampferzeugung" },
{ name: "water_treatment", label: "Wasseraufbereitung" },
{ name: "supplier", label: "Lieferant" },
{ name: "documentation", label: "Dokumentation", type: "textarea" }
]}
schema={schema}
emptyValues={{ customer_id: "", location_id: "", manufacturer: "", model: "", device_type: "", serial_number: "", year_built: "", commissioned_on: "", chamber_volume_liters: "", steam_generation: "", water_treatment: "", documentation: "", supplier: "" }}
/>
);
}

View file

@ -0,0 +1,20 @@
import { Upload } from "lucide-react";
export default function DocumentsPage() {
return (
<div className="space-y-6">
<header>
<h1 className="text-3xl font-semibold text-text">Dokumente</h1>
<p className="mt-2 text-text-light">PDF, JPG, PNG, DOCX und XLSX mit Zuordnung zu Kunden, Geraeten, Validierungen und Pruefmitteln.</p>
</header>
<section className="flex min-h-80 items-center justify-center rounded-lg border border-dashed border-primary/40 bg-surface p-8 text-center shadow-soft">
<div>
<Upload className="mx-auto h-10 w-10 text-primary" />
<h2 className="mt-4 text-xl font-semibold">Drag & Drop Upload</h2>
<p className="mt-2 max-w-xl text-sm leading-6 text-text-light">Die Dokumentenablage ist im Backend modelliert und fuer Upload-Flows vorbereitet.</p>
</div>
</section>
</div>
);
}

View file

@ -0,0 +1,47 @@
"use client";
import { z } from "zod";
import { CrudPage } from "@/components/crud-page";
import { Equipment } from "@/lib/api";
const schema = z.object({
kind: z.enum(["temperature_logger", "pressure_logger", "sensor"]),
manufacturer: z.string().optional().nullable(),
model: z.string().optional().nullable(),
serial_number: z.string().min(2),
calibrated_on: z.string().optional().nullable(),
calibration_due_on: z.string().optional().nullable(),
certificate_document_id: z.string().optional().nullable(),
status: z.enum(["green", "yellow", "red"])
});
export default function EquipmentPage() {
return (
<CrudPage<Equipment>
title="Pruefmittel"
subtitle="Temperaturlogger, Drucklogger, Sensoren, Kalibrierstatus und Zertifikate."
endpoint="/equipment"
columns={[
{ key: "kind", label: "Art" },
{ key: "manufacturer", label: "Hersteller" },
{ key: "model", label: "Modell" },
{ key: "serial_number", label: "Seriennummer" },
{ key: "calibration_due_on", label: "Gueltig bis" },
{ key: "status", label: "Status" }
]}
fields={[
{ name: "kind", label: "Art", type: "select", options: [{ label: "Temperaturlogger", value: "temperature_logger" }, { label: "Drucklogger", value: "pressure_logger" }, { label: "Sensor", value: "sensor" }] },
{ name: "manufacturer", label: "Hersteller" },
{ name: "model", label: "Modell" },
{ name: "serial_number", label: "Seriennummer" },
{ name: "calibrated_on", label: "Kalibrierung", type: "date" },
{ name: "calibration_due_on", label: "Kalibrierung gueltig bis", type: "date" },
{ name: "certificate_document_id", label: "Kalibrierzertifikat Dokument-ID" },
{ name: "status", label: "Ampel", type: "select", options: [{ label: "Gruen", value: "green" }, { label: "Gelb", value: "yellow" }, { label: "Rot", value: "red" }] }
]}
schema={schema}
emptyValues={{ kind: "temperature_logger", manufacturer: "", model: "", serial_number: "", calibrated_on: "", calibration_due_on: "", certificate_document_id: "", status: "green" }}
/>
);
}

View file

@ -0,0 +1,14 @@
import { AppShell } from "@/components/app-shell";
import { AuthProvider } from "@/components/auth";
import { QueryProvider } from "@/components/query-provider";
export default function WorkspaceLayout({ children }: { children: React.ReactNode }) {
return (
<AuthProvider>
<QueryProvider>
<AppShell>{children}</AppShell>
</QueryProvider>
</AuthProvider>
);
}

View file

@ -0,0 +1,49 @@
"use client";
import { z } from "zod";
import { useQuery } from "@tanstack/react-query";
import { useAuth } from "@/components/auth";
import { CrudPage } from "@/components/crud-page";
import { apiGet, Customer, Location, Paginated } from "@/lib/api";
const schema = z.object({
customer_id: z.string().min(1),
name: z.string().min(2),
street: z.string().optional().nullable(),
postal_code: z.string().optional().nullable(),
city: z.string().optional().nullable(),
room: z.string().optional().nullable()
});
export default function LocationsPage() {
const { token } = useAuth();
const customers = useQuery({
queryKey: ["customers-options", token],
queryFn: () => apiGet<Paginated<Customer>>("/customers?page=1&page_size=100", token ?? ""),
enabled: Boolean(token)
});
const customerOptions = (customers.data?.items ?? []).map((item) => ({ label: item.name, value: item.id }));
return (
<CrudPage<Location>
title="Standorte"
subtitle="Standorte und Raeume mit Zuordnung zum Kunden."
endpoint="/locations"
columns={[
{ key: "name", label: "Name" },
{ key: "city", label: "Ort" },
{ key: "room", label: "Raum" },
{ key: "customer_id", label: "Kunden-ID" }
]}
fields={[
{ name: "customer_id", label: "Kunde", type: "select", options: customerOptions },
{ name: "name", label: "Name" },
{ name: "street", label: "Adresse" },
{ name: "postal_code", label: "PLZ" },
{ name: "city", label: "Ort" },
{ name: "room", label: "Raum" }
]}
schema={schema}
emptyValues={{ customer_id: "", name: "", street: "", postal_code: "", city: "", room: "" }}
/>
);
}

View file

@ -0,0 +1,361 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ChevronDown, Download, FileText, Plus, Save, ShieldCheck, UploadCloud, X } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import { Controller, useFieldArray, useForm, useWatch } from "react-hook-form";
import { z } from "zod";
import { useAuth } from "@/components/auth";
import { apiGet, apiSend, Contact, Customer, Device, Equipment, Location, Paginated, ValidationItem } from "@/lib/api";
const triState = ["yes", "no", "na"] as const;
const today = new Date().toISOString().slice(0, 10);
const checklistTexts = [
"Gebrauchsanweisung und Herstellerdokumentation vorhanden",
"Wartungsnachweise vollstaendig",
"Kalibrierzertifikate der Pruefmittel gueltig",
"Aufstellbedingungen dokumentiert",
"Wasserqualitaet dokumentiert",
"Chargendokumentation nachvollziehbar",
"Routinekontrollen definiert",
"Freigabeverfahren beschrieben",
"Personal eingewiesen",
"Abweichungen bewertet"
];
const performanceTexts = [
"Vakuumtest entspricht Vorgaben",
"Bowie-Dick / Leerkammerprofil entspricht Vorgaben",
"Temperaturband innerhalb Spezifikation",
"Haltezeit erreicht",
"Druckverlauf plausibel",
"Trocknungsergebnis akzeptabel",
"Beladungsmuster reproduzierbar",
"Sensorpositionen dokumentiert"
];
const attachmentCategories = [
"Aufbereitungsraum",
"reiner Bereich",
"unreiner Bereich",
"Sterilisator",
"Beladung",
"Sensorposition",
"Chargenprotokoll",
"Indikator",
"Zertifikat",
"Kalibrierschein",
"Winlog-Auswertung"
];
const schema = z.object({
report_number: z.string().min(3),
validation_type: z.string().min(1),
project: z.string().min(1),
performed_on: z.string().min(1),
test_location: z.string().min(1),
examiner_name: z.string().min(1),
participants: z.string().optional(),
status: z.string().min(1),
result: z.string().min(1),
customer_id: z.string().min(1),
location_id: z.string().optional().nullable(),
contact_id: z.string().optional().nullable(),
operator_name: z.string().optional(),
device_id: z.string().min(1),
equipment_ids: z.array(z.string()),
environment_conditions: z.record(z.unknown()),
documentation_checklist: z.array(z.record(z.unknown())),
performance_checklist: z.array(z.record(z.unknown())),
programs: z.array(z.record(z.unknown())),
loading_patterns: z.array(z.record(z.unknown())),
measurement_data: z.array(z.record(z.unknown())),
drying: z.record(z.unknown()),
recommendations: z.array(z.record(z.unknown())),
attachments: z.array(z.record(z.unknown()))
});
type FormValues = z.infer<typeof schema>;
function checklist(items: string[]) {
return items.map((text, index) => ({ number: index + 1, text, value: "na", comment: "" }));
}
function defaults(reportNumber = ""): FormValues {
return {
report_number: reportNumber,
validation_type: "Erstvalidierung",
project: "",
performed_on: today,
test_location: "",
examiner_name: "",
participants: "",
status: "draft",
result: "offen",
customer_id: "",
location_id: "",
contact_id: "",
operator_name: "",
device_id: "",
equipment_ids: [],
environment_conditions: {
room_temperature: "",
humidity: "",
test_time: "",
checks: [
{ text: "Raumbedingungen stabil", value: "na", comment: "" },
{ text: "Aufstellort frei zugaenglich", value: "na", comment: "" },
{ text: "Medienversorgung verfuegbar", value: "na", comment: "" }
]
},
documentation_checklist: checklist(checklistTexts),
performance_checklist: checklist(performanceTexts),
programs: [
{ name: "Vakuumtest", selected: false, custom: false },
{ name: "Bowie-Dick / Leerkammerprofil", selected: false, custom: false },
{ name: "134 C hohl verpackt", selected: false, custom: false }
],
loading_patterns: [1, 2, 3].map((run) => ({ run, pattern: "", description: "", images: [] })),
measurement_data: ["Vakuumtest", "Leerkammerprofil", "Testlauf 1", "Testlauf 2", "Testlauf 3"].map((name) => ({
name,
start_time: "",
end_time: "",
duration: "",
leak_rate: "",
min_temperature: "",
max_temperature: "",
temperature_band: "",
equilibration_time: "",
holding_time: "",
pressure: "",
result: "",
imports: []
})),
drying: { start_weight: "", end_weight: "", difference: "", rating: "", comment: "" },
recommendations: [],
attachments: []
};
}
function Accordion({ title, children, defaultOpen = false }: { title: string; children: React.ReactNode; defaultOpen?: boolean }) {
const [open, setOpen] = useState(defaultOpen);
return (
<section className="rounded-lg border border-border bg-surface shadow-soft">
<button type="button" onClick={() => setOpen((value) => !value)} className="flex w-full items-center justify-between px-5 py-4 text-left text-lg font-semibold">
{title}
<ChevronDown className={`h-5 w-5 transition ${open ? "rotate-180" : ""}`} />
</button>
{open && <div className="border-t border-border p-5">{children}</div>}
</section>
);
}
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return <label className="block"><span className="text-sm font-medium text-text">{label}</span><div className="mt-2">{children}</div></label>;
}
const inputClass = "h-12 w-full rounded-lg border border-border bg-white px-4 outline-none focus:border-primary";
const selectClass = inputClass;
const areaClass = "min-h-24 w-full rounded-lg border border-border bg-white px-4 py-3 outline-none focus:border-primary";
export default function ValidationsPage() {
const { token } = useAuth();
const client = useQueryClient();
const [draftId, setDraftId] = useState<string | null>(null);
const [lastSaved, setLastSaved] = useState<string>("");
const autosaveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const nextNumber = useQuery({ queryKey: ["next-report-number", token], queryFn: () => apiGet<{ report_number: string }>("/validations/next-report-number", token ?? ""), enabled: Boolean(token) });
const customers = useQuery({ queryKey: ["customers-options", token], queryFn: () => apiGet<Paginated<Customer>>("/customers?page=1&page_size=100", token ?? ""), enabled: Boolean(token) });
const locations = useQuery({ queryKey: ["locations-options", token], queryFn: () => apiGet<Paginated<Location>>("/locations?page=1&page_size=100", token ?? ""), enabled: Boolean(token) });
const contacts = useQuery({ queryKey: ["contacts-options", token], queryFn: () => apiGet<Paginated<Contact>>("/contacts?page=1&page_size=100", token ?? ""), enabled: Boolean(token) });
const devices = useQuery({ queryKey: ["devices-options", token], queryFn: () => apiGet<Paginated<Device>>("/devices?page=1&page_size=100", token ?? ""), enabled: Boolean(token) });
const equipment = useQuery({ queryKey: ["equipment-options", token], queryFn: () => apiGet<Paginated<Equipment>>("/equipment?page=1&page_size=100", token ?? ""), enabled: Boolean(token) });
const form = useForm<FormValues>({ resolver: zodResolver(schema), defaultValues: defaults() });
const watched = useWatch({ control: form.control });
const selectedDevice = devices.data?.items.find((item) => item.id === form.watch("device_id"));
const selectedEquipment = equipment.data?.items.filter((item) => form.watch("equipment_ids").includes(item.id)) ?? [];
const recommendations = useFieldArray({ control: form.control, name: "recommendations" });
const attachments = useFieldArray({ control: form.control, name: "attachments" });
useEffect(() => {
if (nextNumber.data?.report_number && !form.getValues("report_number")) {
form.setValue("report_number", nextNumber.data.report_number);
}
}, [form, nextNumber.data]);
const saveMutation = useMutation({
mutationFn: (values: FormValues) => apiSend<ValidationItem>(draftId ? `/validations/${draftId}` : "/validations", token ?? "", draftId ? "PUT" : "POST", values),
onSuccess: (item) => {
setDraftId(item.id);
setLastSaved(new Date().toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" }));
client.invalidateQueries({ queryKey: ["dashboard"] });
}
});
useEffect(() => {
if (!token || !watched.report_number || !watched.customer_id || !watched.device_id || !watched.project || !watched.performed_on) return;
if (autosaveTimer.current) clearTimeout(autosaveTimer.current);
autosaveTimer.current = setTimeout(() => {
const values = form.getValues();
saveMutation.mutate(values);
}, 2500);
return () => {
if (autosaveTimer.current) clearTimeout(autosaveTimer.current);
};
}, [form, saveMutation, token, watched]);
const customerOptions = customers.data?.items ?? [];
const selectedCustomer = customerOptions.find((item) => item.id === form.watch("customer_id"));
const filteredLocations = (locations.data?.items ?? []).filter((item) => !form.watch("customer_id") || item.customer_id === form.watch("customer_id"));
const filteredContacts = (contacts.data?.items ?? []).filter((item) => !form.watch("customer_id") || item.customer_id === form.watch("customer_id"));
const filteredDevices = (devices.data?.items ?? []).filter((item) => !form.watch("customer_id") || item.customer_id === form.watch("customer_id"));
const calibrationWarnings = useMemo(() => selectedEquipment.filter((item) => item.calibration_due_on && item.calibration_due_on < today), [selectedEquipment]);
function submit(values: FormValues) {
saveMutation.mutate(values);
}
function addFiles(files: FileList | null, category: string) {
if (!files) return;
Array.from(files).forEach((file, index) => attachments.append({ category, filename: file.name, description: "", order: attachments.fields.length + index + 1, preview: URL.createObjectURL(file) }));
}
return (
<form onSubmit={form.handleSubmit(submit)} className="space-y-5 pb-28">
<header>
<h1 className="text-3xl font-semibold text-text">Validierungsbericht</h1>
<p className="mt-2 text-text-light">Eine responsive Seite fuer Erfassung, Pruefung und Berichtserstellung.</p>
</header>
<Accordion title="1. Allgemeine Angaben" defaultOpen>
<div className="grid gap-4 md:grid-cols-2">
<Field label="Berichtsnummer"><input className={inputClass} {...form.register("report_number")} /></Field>
<Field label="Validierungsart"><select className={selectClass} {...form.register("validation_type")}><option>Erstvalidierung</option><option>Revalidierung</option><option>Leistungsbeurteilung</option><option>Sonderpruefung</option></select></Field>
<Field label="Projekt"><input className={inputClass} {...form.register("project")} /></Field>
<Field label="Pruefdatum"><input type="date" className={inputClass} {...form.register("performed_on")} /></Field>
<Field label="Pruefungsort"><input className={inputClass} {...form.register("test_location")} /></Field>
<Field label="Pruefer"><input className={inputClass} {...form.register("examiner_name")} /></Field>
<Field label="Mitwirkende Personen"><textarea className={areaClass} {...form.register("participants")} /></Field>
<Field label="Status"><select className={selectClass} {...form.register("status")}><option value="draft">Entwurf</option><option value="in_progress">In Pruefung</option><option value="ready_for_report">Bericht bereit</option><option value="completed">Abgeschlossen</option></select></Field>
<Field label="Gesamtergebnis"><select className={selectClass} {...form.register("result")}><option value="offen">Offen</option><option value="bestanden">Bestanden</option><option value="nicht_bestanden">Nicht bestanden</option><option value="mit_auflagen">Mit Auflagen</option></select></Field>
</div>
</Accordion>
<Accordion title="2. Kunde und Standort">
<div className="grid gap-4 md:grid-cols-2">
<Field label="Kunde"><select className={selectClass} {...form.register("customer_id")}><option value="">Bitte waehlen</option>{customerOptions.map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}</select></Field>
<Field label="Standort"><select className={selectClass} {...form.register("location_id")}><option value="">Bitte waehlen</option>{filteredLocations.map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}</select></Field>
<Field label="Ansprechpartner"><select className={selectClass} {...form.register("contact_id")}><option value="">Bitte waehlen</option>{filteredContacts.map((item) => <option key={item.id} value={item.id}>{item.full_name}</option>)}</select></Field>
<Field label="Betreiber"><input className={inputClass} {...form.register("operator_name")} /></Field>
<Field label="QM-/Hygienebeauftragter"><input className={inputClass} value={[selectedCustomer?.quality_manager, selectedCustomer?.hygiene_officer].filter(Boolean).join(" / ")} readOnly /></Field>
</div>
</Accordion>
<Accordion title="3. Geraet">
<Field label="Geraet"><select className={selectClass} {...form.register("device_id")}><option value="">Bitte waehlen</option>{filteredDevices.map((item) => <option key={item.id} value={item.id}>{item.manufacturer} {item.model} - {item.serial_number}</option>)}</select></Field>
<div className="mt-4 grid gap-4 md:grid-cols-3">{[
["Hersteller", selectedDevice?.manufacturer],
["Modell", selectedDevice?.model],
["Seriennummer", selectedDevice?.serial_number],
["Baujahr", selectedDevice?.year_built],
["Inbetriebnahme", selectedDevice?.commissioned_on],
["Kammervolumen", selectedDevice?.chamber_volume_liters],
["Dampferzeugung", selectedDevice?.steam_generation],
["Wasseraufbereitung", selectedDevice?.water_treatment],
["Dokumentation", selectedDevice?.documentation],
["Lieferant", selectedDevice?.supplier]
].map(([label, value]) => <div key={label as string} className="rounded-lg border border-border bg-background p-4"><p className="text-xs text-text-light">{label}</p><p className="mt-1 font-medium">{String(value ?? "-")}</p></div>)}</div>
</Accordion>
<Accordion title="4. Pruefmittel">
<Controller control={form.control} name="equipment_ids" render={({ field }) => (
<div className="grid gap-3 md:grid-cols-2">
{(equipment.data?.items ?? []).map((item) => {
const expired = Boolean(item.calibration_due_on && item.calibration_due_on < today);
return <label key={item.id} className="flex items-start gap-3 rounded-lg border border-border p-4"><input type="checkbox" className="mt-1 h-5 w-5" checked={field.value.includes(item.id)} onChange={(event) => field.onChange(event.target.checked ? [...field.value, item.id] : field.value.filter((id: string) => id !== item.id))} /><span><strong>{item.kind}</strong><br />{item.serial_number} · {item.calibrated_on ?? "-"} · {item.status}{expired && <span className="ml-2 text-danger">Kalibrierung abgelaufen</span>}</span></label>;
})}
</div>
)} />
{calibrationWarnings.length > 0 && <p className="mt-4 rounded-lg border border-danger/30 bg-danger/5 p-4 text-danger">Mindestens ein ausgewaehltes Pruefmittel hat eine abgelaufene Kalibrierung.</p>}
</Accordion>
<Accordion title="5. Umgebungsbedingungen">
<div className="grid gap-4 md:grid-cols-3">
<Field label="Raumtemperatur"><input className={inputClass} {...form.register("environment_conditions.room_temperature")} /></Field>
<Field label="relative Luftfeuchtigkeit"><input className={inputClass} {...form.register("environment_conditions.humidity")} /></Field>
<Field label="Pruefzeit"><input className={inputClass} {...form.register("environment_conditions.test_time")} /></Field>
</div>
{[0, 1, 2].map((index) => <ChecklistRow key={index} form={form} path={`environment_conditions.checks.${index}`} />)}
</Accordion>
<Accordion title="6. Dokumentations- und Leistungschecklisten">
<h3 className="font-semibold">Dokumentation</h3>{checklistTexts.map((_, index) => <ChecklistRow key={index} form={form} path={`documentation_checklist.${index}`} />)}
<h3 className="mt-6 font-semibold">Leistung</h3>{performanceTexts.map((_, index) => <ChecklistRow key={index} form={form} path={`performance_checklist.${index}`} />)}
</Accordion>
<Accordion title="7. Programme">
{[0, 1, 2].map((index) => <ProgramRow key={index} form={form} index={index} />)}
<button type="button" onClick={() => form.setValue("programs", [...form.getValues("programs"), { name: "", selected: true, custom: true }])} className="mt-4 inline-flex items-center gap-2 rounded-lg border border-border px-4 py-3 font-semibold"><Plus className="h-4 w-4" /> Eigenes Programm</button>
</Accordion>
<Accordion title="8. Beladungsmuster">
{[0, 1, 2].map((index) => <LoadingRun key={index} form={form} index={index} />)}
</Accordion>
<Accordion title="9. Messdaten">
{[0, 1, 2, 3, 4].map((index) => <MeasurementBlock key={index} form={form} index={index} />)}
</Accordion>
<Accordion title="10. Trocknung">
<div className="grid gap-4 md:grid-cols-2">
{["start_weight", "end_weight", "difference", "rating"].map((name) => <Field key={name} label={name}><input className={inputClass} {...form.register(`drying.${name}`)} /></Field>)}
<Field label="Bemerkung"><textarea className={areaClass} {...form.register("drying.comment")} /></Field>
</div>
</Accordion>
<Accordion title="11. Empfehlungen und Auflagen">
{recommendations.fields.map((field, index) => <div key={field.id} className="mb-3 grid gap-3 rounded-lg border border-border p-4 md:grid-cols-4"><input className={inputClass} placeholder="Nummer" {...form.register(`recommendations.${index}.number`)} /><input className={inputClass} placeholder="Text" {...form.register(`recommendations.${index}.text`)} /><input type="date" className={inputClass} {...form.register(`recommendations.${index}.deadline`)} /><input className={inputClass} placeholder="Status" {...form.register(`recommendations.${index}.status`)} /></div>)}
<button type="button" onClick={() => recommendations.append({ number: recommendations.fields.length + 1, text: "", deadline: "", status: "offen" })} className="inline-flex items-center gap-2 rounded-lg border border-border px-4 py-3 font-semibold"><Plus className="h-4 w-4" /> Zeile hinzufuegen</button>
</Accordion>
<Accordion title="12. Bilder und Anlagen">
<div className="grid gap-3 md:grid-cols-2">{attachmentCategories.map((category) => <label key={category} className="rounded-lg border border-dashed border-primary/40 p-4"><UploadCloud className="mb-2 h-5 w-5 text-primary" />{category}<input type="file" multiple className="mt-3 block w-full text-sm" onChange={(event) => addFiles(event.target.files, category)} /></label>)}</div>
<div className="mt-5 grid gap-3 md:grid-cols-2">{attachments.fields.map((field, index) => <div key={field.id} className="rounded-lg border border-border p-4"><div className="flex justify-between gap-3"><strong>{String(form.watch(`attachments.${index}.filename`) ?? "")}</strong><button type="button" onClick={() => attachments.remove(index)}><X className="h-4 w-4 text-danger" /></button></div><input className={`${inputClass} mt-3`} placeholder="Beschreibung" {...form.register(`attachments.${index}.description`)} /><input className={`${inputClass} mt-3`} placeholder="Reihenfolge" {...form.register(`attachments.${index}.order`)} /></div>)}</div>
</Accordion>
<div className="fixed inset-x-0 bottom-0 z-40 border-t border-border bg-surface/95 px-4 py-3 shadow-soft backdrop-blur">
<div className="mx-auto flex max-w-7xl flex-wrap items-center justify-between gap-3">
<span className="text-sm text-text-light">{lastSaved ? `Automatisch gespeichert um ${lastSaved}` : "Autosave aktiv, sobald Pflichtfelder ausgefuellt sind."}</span>
<div className="flex flex-wrap gap-2">
<button type="submit" className="inline-flex h-12 items-center gap-2 rounded-lg border border-border px-4 font-semibold"><Save className="h-4 w-4" /> Entwurf speichern</button>
<button type="button" onClick={() => form.setValue("status", "ready_for_report")} className="inline-flex h-12 items-center gap-2 rounded-lg border border-border px-4 font-semibold"><ShieldCheck className="h-4 w-4" /> Validierung pruefen</button>
<button type="button" className="inline-flex h-12 items-center gap-2 rounded-lg bg-primary px-4 font-semibold text-white shadow-soft"><FileText className="h-4 w-4" /> Bericht erzeugen</button>
<button type="button" className="inline-flex h-12 items-center gap-2 rounded-lg border border-border px-4 font-semibold"><Download className="h-4 w-4" /> Bericht herunterladen</button>
</div>
</div>
</div>
</form>
);
}
function ChecklistRow({ form, path }: { form: ReturnType<typeof useForm<FormValues>>; path: string }) {
return <div className="mt-3 grid gap-3 rounded-lg border border-border p-4 md:grid-cols-[1fr_180px_1fr]"><input className={inputClass} readOnly {...form.register(`${path}.text` as never)} /><select className={selectClass} {...form.register(`${path}.value` as never)}>{triState.map((value) => <option key={value} value={value}>{value === "yes" ? "Ja" : value === "no" ? "Nein" : "Nicht zutreffend"}</option>)}</select><input className={inputClass} placeholder="Kommentar" {...form.register(`${path}.comment` as never)} /></div>;
}
function ProgramRow({ form, index }: { form: ReturnType<typeof useForm<FormValues>>; index: number }) {
return <div className="mb-3 grid gap-3 rounded-lg border border-border p-4 md:grid-cols-[80px_1fr]"><input type="checkbox" className="h-6 w-6" {...form.register(`programs.${index}.selected`)} /><input className={inputClass} {...form.register(`programs.${index}.name`)} /></div>;
}
function LoadingRun({ form, index }: { form: ReturnType<typeof useForm<FormValues>>; index: number }) {
return <div className="mb-4 rounded-lg border border-border p-4"><h3 className="font-semibold">Testlauf {index + 1}</h3><div className="mt-3 grid gap-3 md:grid-cols-2"><input className={inputClass} placeholder="Beladungsmuster" {...form.register(`loading_patterns.${index}.pattern`)} /><input className={inputClass} placeholder="Beschreibung" {...form.register(`loading_patterns.${index}.description`)} /><input type="file" multiple className="rounded-lg border border-border p-3 md:col-span-2" /></div></div>;
}
function MeasurementBlock({ form, index }: { form: ReturnType<typeof useForm<FormValues>>; index: number }) {
const fields = ["start_time", "end_time", "duration", "leak_rate", "min_temperature", "max_temperature", "temperature_band", "equilibration_time", "holding_time", "pressure", "result"];
return <div className="mb-4 rounded-lg border border-border p-4"><h3 className="font-semibold">{String(form.watch(`measurement_data.${index}.name`) ?? "")}</h3><div className="mt-3 grid gap-3 md:grid-cols-3">{fields.map((field) => <input key={field} className={inputClass} placeholder={field} {...form.register(`measurement_data.${index}.${field}`)} />)}<input type="file" multiple accept=".csv,.pdf" className="rounded-lg border border-border p-3 md:col-span-3" /></div></div>;
}

View file

@ -0,0 +1,76 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { LogIn } from "lucide-react";
import { useRouter } from "next/navigation";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { AuthProvider, useAuth } from "@/components/auth";
import { login } from "@/lib/api";
const schema = z.object({
email: z.string().email(),
password: z.string().min(8)
});
type LoginForm = z.infer<typeof schema>;
function LoginPanel() {
const router = useRouter();
const auth = useAuth();
const form = useForm<LoginForm>({
resolver: zodResolver(schema),
defaultValues: { email: "admin@schubamed.de", password: "" }
});
async function onSubmit(values: LoginForm) {
const result = await login(values.email, values.password);
auth.setToken(result.access_token);
router.push("/dashboard");
}
return (
<main className="flex min-h-screen items-center justify-center bg-background px-4 py-10">
<section className="w-full max-w-md rounded-lg border border-border bg-surface p-8 shadow-soft">
<div className="mb-8">
<p className="text-sm font-medium text-primary">Validation Suite</p>
<h1 className="mt-2 text-3xl font-semibold text-text">Anmelden</h1>
<p className="mt-3 text-sm leading-6 text-text-light">Sicherer Zugriff auf Atlas Workspace.</p>
</div>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-5">
<label className="block">
<span className="text-sm font-medium text-text">E-Mail</span>
<input
type="email"
className="mt-2 h-12 w-full rounded-lg border border-border bg-white px-4 outline-none transition focus:border-primary"
{...form.register("email")}
/>
</label>
<label className="block">
<span className="text-sm font-medium text-text">Passwort</span>
<input
type="password"
className="mt-2 h-12 w-full rounded-lg border border-border bg-white px-4 outline-none transition focus:border-primary"
{...form.register("password")}
/>
</label>
<button
type="submit"
className="flex h-12 w-full items-center justify-center gap-2 rounded-lg bg-primary px-5 font-semibold text-white shadow-soft transition hover:bg-primary-dark"
>
<LogIn className="h-5 w-5" />
Einloggen
</button>
</form>
</section>
</main>
);
}
export default function LoginPage() {
return (
<AuthProvider>
<LoginPanel />
</AuthProvider>
);
}

View file

@ -0,0 +1,27 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
color: #2E3B40;
background: #F7F8F8;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
background: #F7F8F8;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
button,
input,
select,
textarea {
font: inherit;
}

View file

@ -0,0 +1,16 @@
import type { Metadata } from "next";
import "./globals.css";
export const metadata: Metadata = {
title: "Validation Suite",
description: "Professionelle Validierungsplattform fuer medizinische Prozesse"
};
export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
return (
<html lang="de">
<body>{children}</body>
</html>
);
}

View file

@ -0,0 +1,6 @@
import { redirect } from "next/navigation";
export default function Home() {
redirect("/login");
}

View file

@ -0,0 +1,72 @@
"use client";
import {
Building2,
ClipboardCheck,
FileArchive,
Gauge,
LayoutDashboard,
LogOut,
MapPin,
Stethoscope,
UserRound
} from "lucide-react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useAuth } from "@/components/auth";
const navigation = [
{ href: "/dashboard", label: "Dashboard", icon: LayoutDashboard },
{ href: "/customers", label: "Kunden", icon: Building2 },
{ href: "/locations", label: "Standorte", icon: MapPin },
{ href: "/contacts", label: "Ansprechpartner", icon: UserRound },
{ href: "/devices", label: "Geraete", icon: Stethoscope },
{ href: "/equipment", label: "Pruefmittel", icon: Gauge },
{ href: "/validations", label: "Validierungen", icon: ClipboardCheck },
{ href: "/documents", label: "Dokumente", icon: FileArchive }
];
export function AppShell({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
const auth = useAuth();
return (
<div className="min-h-screen bg-background text-text">
<aside className="fixed inset-y-0 left-0 z-30 hidden w-72 border-r border-border bg-surface px-5 py-6 lg:block">
<div className="mb-8">
<div className="text-xl font-semibold text-primary-dark">Validation Suite</div>
<div className="mt-1 text-sm text-text-light">Atlas Workspace</div>
</div>
<nav className="space-y-1">
{navigation.map((item, index) => {
const Icon = item.icon;
const active = pathname === item.href || pathname.startsWith(`${item.href}/`);
return (
<Link
key={`${item.label}-${index}`}
href={item.href}
className={`flex h-11 items-center gap-3 rounded-lg px-3 text-sm font-medium transition ${
active ? "bg-accent/35 text-primary-dark" : "text-text-light hover:bg-background hover:text-text"
}`}
>
<Icon className="h-4 w-4" />
<span>{item.label}</span>
</Link>
);
})}
</nav>
<button
type="button"
onClick={auth.logout}
className="absolute bottom-6 left-5 right-5 flex h-11 items-center justify-center gap-2 rounded-lg bg-primary px-4 text-sm font-semibold text-white shadow-soft"
>
<LogOut className="h-4 w-4" />
Logout
</button>
</aside>
<main className="lg:pl-72">
<div className="mx-auto min-h-screen max-w-7xl px-4 py-5 sm:px-6 lg:px-10 lg:py-8">{children}</div>
</main>
</div>
);
}

View file

@ -0,0 +1,51 @@
"use client";
import { useRouter } from "next/navigation";
import { createContext, useContext, useEffect, useMemo, useState } from "react";
type AuthContextValue = {
token: string | null;
setToken: (value: string | null) => void;
logout: () => void;
};
const AuthContext = createContext<AuthContextValue | null>(null);
export function AuthProvider({ children }: { children: React.ReactNode }) {
const router = useRouter();
const [token, setTokenState] = useState<string | null>(null);
useEffect(() => {
setTokenState(window.localStorage.getItem("atlas_token"));
}, []);
const value = useMemo<AuthContextValue>(() => {
const setToken = (next: string | null) => {
setTokenState(next);
if (next) {
window.localStorage.setItem("atlas_token", next);
} else {
window.localStorage.removeItem("atlas_token");
}
};
return {
token,
setToken,
logout: () => {
setToken(null);
router.push("/login");
}
};
}, [router, token]);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
export function useAuth() {
const value = useContext(AuthContext);
if (!value) {
throw new Error("useAuth must be used inside AuthProvider");
}
return value;
}

View file

@ -0,0 +1,195 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Edit2, Search, Trash2, X } from "lucide-react";
import { useMemo, useState } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { useAuth } from "@/components/auth";
import { apiDelete, apiGet, apiSend, Entity, Paginated } from "@/lib/api";
export type FieldOption = { label: string; value: string };
export type FieldConfig = {
name: string;
label: string;
type?: "text" | "email" | "number" | "date" | "textarea" | "select";
required?: boolean;
options?: FieldOption[];
};
export type ColumnConfig<T> = { key: keyof T; label: string };
function valueForInput(value: unknown) {
return value === null || value === undefined ? "" : String(value);
}
export function CrudPage<T extends Entity>({
title,
subtitle,
endpoint,
columns,
fields,
schema,
emptyValues
}: {
title: string;
subtitle: string;
endpoint: string;
columns: ColumnConfig<T>[];
fields: FieldConfig[];
schema: z.ZodTypeAny;
emptyValues: Record<string, unknown>;
}) {
const { token } = useAuth();
const client = useQueryClient();
const [page, setPage] = useState(1);
const [search, setSearch] = useState("");
const [editing, setEditing] = useState<T | null>(null);
const [open, setOpen] = useState(false);
const pageSize = 10;
const queryPath = `${endpoint}?page=${page}&page_size=${pageSize}&search=${encodeURIComponent(search)}`;
const form = useForm<Record<string, unknown>>({
resolver: zodResolver(schema),
defaultValues: emptyValues
});
const query = useQuery({
queryKey: [endpoint, page, pageSize, search, token],
queryFn: () => apiGet<Paginated<T>>(queryPath, token ?? ""),
enabled: Boolean(token)
});
const totalPages = Math.max(1, Math.ceil((query.data?.total ?? 0) / pageSize));
const invalidate = () => client.invalidateQueries({ queryKey: [endpoint] });
const saveMutation = useMutation({
mutationFn: (values: Record<string, unknown>) => {
const cleaned = Object.fromEntries(
Object.entries(values).map(([key, value]) => [key, value === "" ? null : value])
);
return apiSend<T>(editing ? `${endpoint}/${editing.id}` : endpoint, token ?? "", editing ? "PUT" : "POST", cleaned);
},
onSuccess: () => {
setOpen(false);
setEditing(null);
form.reset(emptyValues);
invalidate();
}
});
const deleteMutation = useMutation({
mutationFn: (item: T) => apiDelete(`${endpoint}/${item.id}`, token ?? ""),
onSuccess: invalidate
});
const rows = useMemo(() => query.data?.items ?? [], [query.data]);
function startCreate() {
setEditing(null);
form.reset(emptyValues);
setOpen(true);
}
function startEdit(item: T) {
setEditing(item);
form.reset(Object.fromEntries(Object.keys(emptyValues).map((key) => [key, valueForInput(item[key as keyof T])])));
setOpen(true);
}
return (
<div className="space-y-6">
<header className="flex flex-col justify-between gap-4 sm:flex-row sm:items-center">
<div>
<h1 className="text-3xl font-semibold text-text">{title}</h1>
<p className="mt-2 text-text-light">{subtitle}</p>
</div>
<button onClick={startCreate} className="h-12 rounded-lg bg-primary px-5 font-semibold text-white shadow-soft">Neu</button>
</header>
<div className="flex items-center gap-3 rounded-lg border border-border bg-surface px-4 py-3 shadow-soft">
<Search className="h-5 w-5 text-primary" />
<input
value={search}
onChange={(event) => {
setPage(1);
setSearch(event.target.value);
}}
placeholder="Suchen"
className="h-9 flex-1 bg-transparent outline-none"
/>
</div>
<section className="overflow-hidden rounded-lg border border-border bg-surface shadow-soft">
<div className="overflow-x-auto">
<table className="min-w-full text-left text-sm">
<thead className="bg-background text-xs uppercase text-text-light">
<tr>
{columns.map((column) => <th key={String(column.key)} className="px-5 py-4">{column.label}</th>)}
<th className="px-5 py-4 text-right">Aktionen</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{rows.map((item) => (
<tr key={item.id}>
{columns.map((column) => <td key={String(column.key)} className="whitespace-nowrap px-5 py-4">{valueForInput(item[column.key])}</td>)}
<td className="px-5 py-4">
<div className="flex justify-end gap-2">
<button aria-label="Bearbeiten" onClick={() => startEdit(item)} className="rounded-lg border border-border p-2 text-primary-dark"><Edit2 className="h-4 w-4" /></button>
<button aria-label="Loeschen" onClick={() => deleteMutation.mutate(item)} className="rounded-lg border border-border p-2 text-danger"><Trash2 className="h-4 w-4" /></button>
</div>
</td>
</tr>
))}
{!query.isLoading && rows.length === 0 && (
<tr><td colSpan={columns.length + 1} className="px-5 py-10 text-center text-text-light">Keine Datensaetze gefunden.</td></tr>
)}
</tbody>
</table>
</div>
</section>
<footer className="flex items-center justify-between text-sm text-text-light">
<span>{query.data?.total ?? 0} Datensaetze</span>
<div className="flex items-center gap-3">
<button disabled={page <= 1} onClick={() => setPage((value) => value - 1)} className="rounded-lg border border-border px-4 py-2 disabled:opacity-40">Zurueck</button>
<span>Seite {page} von {totalPages}</span>
<button disabled={page >= totalPages} onClick={() => setPage((value) => value + 1)} className="rounded-lg border border-border px-4 py-2 disabled:opacity-40">Weiter</button>
</div>
</footer>
{open && (
<div className="fixed inset-0 z-50 flex items-end bg-black/20 p-3 sm:items-center sm:justify-center">
<form onSubmit={form.handleSubmit((values) => saveMutation.mutate(values))} className="max-h-[92vh] w-full max-w-3xl overflow-y-auto rounded-lg bg-surface p-6 shadow-soft">
<div className="mb-5 flex items-center justify-between">
<h2 className="text-xl font-semibold">{editing ? "Bearbeiten" : "Neu"}</h2>
<button type="button" onClick={() => setOpen(false)} className="rounded-lg border border-border p-2"><X className="h-4 w-4" /></button>
</div>
<div className="grid gap-4 sm:grid-cols-2">
{fields.map((field) => (
<label key={field.name} className={field.type === "textarea" ? "sm:col-span-2" : ""}>
<span className="text-sm font-medium">{field.label}</span>
{field.type === "select" ? (
<select {...form.register(field.name)} className="mt-2 h-11 w-full rounded-lg border border-border px-3">
<option value="">Bitte waehlen</option>
{field.options?.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
) : field.type === "textarea" ? (
<textarea {...form.register(field.name)} className="mt-2 min-h-24 w-full rounded-lg border border-border px-3 py-2" />
) : (
<input type={field.type ?? "text"} {...form.register(field.name)} className="mt-2 h-11 w-full rounded-lg border border-border px-3" />
)}
{form.formState.errors[field.name] && <span className="mt-1 block text-xs text-danger">Bitte gueltig ausfuellen.</span>}
</label>
))}
</div>
{saveMutation.isError && <p className="mt-4 text-sm text-danger">Speichern fehlgeschlagen. Bitte Eingaben pruefen.</p>}
<div className="mt-6 flex justify-end gap-3">
<button type="button" onClick={() => setOpen(false)} className="rounded-lg border border-border px-5 py-3 font-semibold">Abbrechen</button>
<button type="submit" className="rounded-lg bg-primary px-5 py-3 font-semibold text-white shadow-soft">Speichern</button>
</div>
</form>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,44 @@
"use client";
import {
ColumnDef,
flexRender,
getCoreRowModel,
useReactTable
} from "@tanstack/react-table";
export function DataTable<T>({ data, columns }: { data: T[]; columns: ColumnDef<T>[] }) {
const table = useReactTable({ data, columns, getCoreRowModel: getCoreRowModel() });
return (
<div className="overflow-hidden rounded-lg border border-border bg-surface shadow-soft">
<div className="overflow-x-auto">
<table className="min-w-full border-collapse text-left text-sm">
<thead className="bg-background text-xs uppercase tracking-wide text-text-light">
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<th key={header.id} className="px-5 py-4 font-semibold">
{flexRender(header.column.columnDef.header, header.getContext())}
</th>
))}
</tr>
))}
</thead>
<tbody className="divide-y divide-border">
{table.getRowModel().rows.map((row) => (
<tr key={row.id} className="hover:bg-background/70">
{row.getVisibleCells().map((cell) => (
<td key={cell.id} className="whitespace-nowrap px-5 py-4 text-text">
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}

View file

@ -0,0 +1,10 @@
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useState } from "react";
export function QueryProvider({ children }: { children: React.ReactNode }) {
const [client] = useState(() => new QueryClient());
return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
}

View file

@ -0,0 +1,50 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { ColumnDef } from "@tanstack/react-table";
import { Plus } from "lucide-react";
import { useAuth } from "@/components/auth";
import { DataTable } from "@/components/data-table";
import { apiGet } from "@/lib/api";
export function ResourcePage<T>({
title,
subtitle,
path,
columns
}: {
title: string;
subtitle: string;
path: string;
columns: ColumnDef<T>[];
}) {
const { token } = useAuth();
const query = useQuery({
queryKey: [path, token],
queryFn: () => apiGet<T[]>(path, token ?? ""),
enabled: Boolean(token)
});
return (
<div className="space-y-6">
<header className="flex flex-col justify-between gap-4 sm:flex-row sm:items-center">
<div>
<h1 className="text-3xl font-semibold text-text">{title}</h1>
<p className="mt-2 text-text-light">{subtitle}</p>
</div>
<button type="button" className="inline-flex h-12 items-center justify-center gap-2 rounded-lg bg-primary px-5 font-semibold text-white shadow-soft">
<Plus className="h-5 w-5" />
Neu
</button>
</header>
{query.isLoading ? (
<div className="rounded-lg border border-border bg-surface p-8 text-text-light shadow-soft">Daten werden geladen.</div>
) : query.isError ? (
<div className="rounded-lg border border-danger/30 bg-surface p-8 text-danger shadow-soft">Daten konnten nicht geladen werden.</div>
) : (
<DataTable data={query.data ?? []} columns={columns} />
)}
</div>
);
}

View file

@ -0,0 +1,144 @@
export const API_BASE = process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8000/api/v1";
export type Entity = {
id: string;
created_at: string;
updated_at: string;
};
export type Customer = Entity & {
customer_type: "practice" | "clinic";
name: string;
city?: string | null;
phone?: string | null;
email?: string | null;
hygiene_officer?: string | null;
quality_manager?: string | null;
notes?: string | null;
};
export type Location = Entity & {
customer_id: string;
name: string;
street?: string | null;
postal_code?: string | null;
city?: string | null;
room?: string | null;
};
export type Contact = Entity & {
customer_id: string;
full_name: string;
function?: string | null;
email?: string | null;
phone?: string | null;
};
export type Device = Entity & {
customer_id: string;
location_id?: string | null;
manufacturer: string;
model: string;
serial_number: string;
device_type?: string | null;
year_built?: number | null;
commissioned_on?: string | null;
chamber_volume_liters?: number | null;
steam_generation?: string | null;
water_treatment?: string | null;
documentation?: string | null;
supplier?: string | null;
};
export type Equipment = Entity & {
kind: string;
manufacturer?: string | null;
model?: string | null;
serial_number: string;
calibrated_on?: string | null;
calibration_due_on?: string | null;
certificate_document_id?: string | null;
status: "green" | "yellow" | "red";
};
export type ValidationItem = Entity & {
report_number: string;
customer_id: string;
location_id?: string | null;
contact_id?: string | null;
device_id?: string | null;
validation_type: string;
project?: string | null;
test_location?: string | null;
examiner_name?: string | null;
participants?: string | null;
operator_name?: string | null;
status: string;
result?: string | null;
scheduled_on?: string | null;
performed_on?: string | null;
next_validation_on?: string | null;
equipment_ids: string[];
environment_conditions: Record<string, unknown>;
documentation_checklist: Record<string, unknown>[];
performance_checklist: Record<string, unknown>[];
programs: Record<string, unknown>[];
loading_patterns: Record<string, unknown>[];
measurement_data: Record<string, unknown>[];
drying: Record<string, unknown>;
recommendations: Record<string, unknown>[];
attachments: Record<string, unknown>[];
};
export type Paginated<T> = {
items: T[];
total: number;
page: number;
page_size: number;
};
export async function login(email: string, password: string) {
const response = await fetch(`${API_BASE}/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password })
});
if (!response.ok) {
throw new Error("Anmeldung fehlgeschlagen");
}
return response.json() as Promise<{ access_token: string; token_type: string }>;
}
export async function apiGet<T>(path: string, token: string): Promise<T> {
const response = await fetch(`${API_BASE}${path}`, {
headers: { Authorization: `Bearer ${token}` },
cache: "no-store"
});
if (!response.ok) {
throw new Error(`API request failed: ${response.status}`);
}
return response.json() as Promise<T>;
}
export async function apiSend<T>(path: string, token: string, method: "POST" | "PUT", body: unknown): Promise<T> {
const response = await fetch(`${API_BASE}${path}`, {
method,
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
body: JSON.stringify(body)
});
if (!response.ok) {
const detail = await response.text();
throw new Error(detail || `API request failed: ${response.status}`);
}
return response.json() as Promise<T>;
}
export async function apiDelete(path: string, token: string): Promise<void> {
const response = await fetch(`${API_BASE}${path}`, {
method: "DELETE",
headers: { Authorization: `Bearer ${token}` }
});
if (!response.ok) {
throw new Error(`API request failed: ${response.status}`);
}
}

View file

@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

View file

@ -0,0 +1,8 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "standalone",
};
export default nextConfig;

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,34 @@
{
"name": "atlas",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"@hookform/resolvers": "^3.10.0",
"@tanstack/react-query": "^5.83.0",
"@tanstack/react-table": "^8.21.3",
"lucide-react": "^0.468.0",
"next": "^16.0.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-hook-form": "^7.61.0",
"zod": "^3.25.76"
},
"devDependencies": {
"@types/node": "^22.13.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"autoprefixer": "^10.4.20",
"eslint": "^9.31.0",
"eslint-config-next": "^16.0.0",
"postcss": "^8.5.1",
"tailwindcss": "^3.4.17",
"typescript": "^5.8.3"
}
}

View file

@ -0,0 +1,7 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

View file

@ -0,0 +1,32 @@
import type { Config } from "tailwindcss";
const config: Config = {
content: ["./app/**/*.{ts,tsx}", "./components/**/*.{ts,tsx}", "./lib/**/*.{ts,tsx}"],
theme: {
extend: {
colors: {
primary: "#6C8A96",
"primary-dark": "#4F6A74",
accent: "#A7C7C7",
background: "#F7F8F8",
surface: "#FFFFFF",
border: "#E6EAEA",
text: "#2E3B40",
"text-light": "#6B7C85",
success: "#66A26B",
warning: "#D6A53A",
danger: "#C95C54"
},
fontFamily: {
sans: ["Inter", "ui-sans-serif", "system-ui"]
},
boxShadow: {
soft: "0 14px 40px rgba(46, 59, 64, 0.08)"
}
}
},
plugins: []
};
export default config;

View file

@ -0,0 +1,42 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": false,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"baseUrl": ".",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": [
"./*"
]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}