diff --git a/validation-suite/.env.example b/validation-suite/.env.example new file mode 100644 index 00000000..943a7c26 --- /dev/null +++ b/validation-suite/.env.example @@ -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 diff --git a/validation-suite/ARCHITECTURE.md b/validation-suite/ARCHITECTURE.md new file mode 100644 index 00000000..ed1cc7b3 --- /dev/null +++ b/validation-suite/ARCHITECTURE.md @@ -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. + diff --git a/validation-suite/README.md b/validation-suite/README.md new file mode 100644 index 00000000..22564463 --- /dev/null +++ b/validation-suite/README.md @@ -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 +``` diff --git a/validation-suite/ROADMAP.md b/validation-suite/ROADMAP.md new file mode 100644 index 00000000..caa27a09 --- /dev/null +++ b/validation-suite/ROADMAP.md @@ -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 + diff --git a/validation-suite/backend/mercury/Dockerfile b/validation-suite/backend/mercury/Dockerfile new file mode 100644 index 00000000..21a01c5c --- /dev/null +++ b/validation-suite/backend/mercury/Dockerfile @@ -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"] + diff --git a/validation-suite/backend/mercury/alembic.ini b/validation-suite/backend/mercury/alembic.ini new file mode 100644 index 00000000..1ae34ab0 --- /dev/null +++ b/validation-suite/backend/mercury/alembic.ini @@ -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 + diff --git a/validation-suite/backend/mercury/alembic/env.py b/validation-suite/backend/mercury/alembic/env.py new file mode 100644 index 00000000..c26172eb --- /dev/null +++ b/validation-suite/backend/mercury/alembic/env.py @@ -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() + diff --git a/validation-suite/backend/mercury/alembic/versions/202607100001_initial_schema.py b/validation-suite/backend/mercury/alembic/versions/202607100001_initial_schema.py new file mode 100644 index 00000000..5d3d4416 --- /dev/null +++ b/validation-suite/backend/mercury/alembic/versions/202607100001_initial_schema.py @@ -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) + diff --git a/validation-suite/backend/mercury/alembic/versions/202607100002_validation_report_page.py b/validation-suite/backend/mercury/alembic/versions/202607100002_validation_report_page.py new file mode 100644 index 00000000..e8728e95 --- /dev/null +++ b/validation-suite/backend/mercury/alembic/versions/202607100002_validation_report_page.py @@ -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) diff --git a/validation-suite/backend/mercury/app/api/dependencies.py b/validation-suite/backend/mercury/app/api/dependencies.py new file mode 100644 index 00000000..8cb38b92 --- /dev/null +++ b/validation-suite/backend/mercury/app/api/dependencies.py @@ -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 + diff --git a/validation-suite/backend/mercury/app/api/v1/auth.py b/validation-suite/backend/mercury/app/api/v1/auth.py new file mode 100644 index 00000000..a197ef5d --- /dev/null +++ b/validation-suite/backend/mercury/app/api/v1/auth.py @@ -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 + diff --git a/validation-suite/backend/mercury/app/api/v1/domain.py b/validation-suite/backend/mercury/app/api/v1/domain.py new file mode 100644 index 00000000..edc5cf9e --- /dev/null +++ b/validation-suite/backend/mercury/app/api/v1/domain.py @@ -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) diff --git a/validation-suite/backend/mercury/app/api/v1/router.py b/validation-suite/backend/mercury/app/api/v1/router.py new file mode 100644 index 00000000..376e80a1 --- /dev/null +++ b/validation-suite/backend/mercury/app/api/v1/router.py @@ -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) + diff --git a/validation-suite/backend/mercury/app/core/config.py b/validation-suite/backend/mercury/app/core/config.py new file mode 100644 index 00000000..c7aee90a --- /dev/null +++ b/validation-suite/backend/mercury/app/core/config.py @@ -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() diff --git a/validation-suite/backend/mercury/app/core/security.py b/validation-suite/backend/mercury/app/core/security.py new file mode 100644 index 00000000..f6d1c4c1 --- /dev/null +++ b/validation-suite/backend/mercury/app/core/security.py @@ -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) + diff --git a/validation-suite/backend/mercury/app/db/base.py b/validation-suite/backend/mercury/app/db/base.py new file mode 100644 index 00000000..3ba6230f --- /dev/null +++ b/validation-suite/backend/mercury/app/db/base.py @@ -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() + ) + diff --git a/validation-suite/backend/mercury/app/db/seed.py b/validation-suite/backend/mercury/app/db/seed.py new file mode 100644 index 00000000..e16bc113 --- /dev/null +++ b/validation-suite/backend/mercury/app/db/seed.py @@ -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() + diff --git a/validation-suite/backend/mercury/app/db/session.py b/validation-suite/backend/mercury/app/db/session.py new file mode 100644 index 00000000..1f38c4ed --- /dev/null +++ b/validation-suite/backend/mercury/app/db/session.py @@ -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() + diff --git a/validation-suite/backend/mercury/app/main.py b/validation-suite/backend/mercury/app/main.py new file mode 100644 index 00000000..079487a0 --- /dev/null +++ b/validation-suite/backend/mercury/app/main.py @@ -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"} + diff --git a/validation-suite/backend/mercury/app/models/__init__.py b/validation-suite/backend/mercury/app/models/__init__.py new file mode 100644 index 00000000..80d95a36 --- /dev/null +++ b/validation-suite/backend/mercury/app/models/__init__.py @@ -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", +] diff --git a/validation-suite/backend/mercury/app/models/contact.py b/validation-suite/backend/mercury/app/models/contact.py new file mode 100644 index 00000000..efd2228c --- /dev/null +++ b/validation-suite/backend/mercury/app/models/contact.py @@ -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") + diff --git a/validation-suite/backend/mercury/app/models/customer.py b/validation-suite/backend/mercury/app/models/customer.py new file mode 100644 index 00000000..59f5ab49 --- /dev/null +++ b/validation-suite/backend/mercury/app/models/customer.py @@ -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") + diff --git a/validation-suite/backend/mercury/app/models/device.py b/validation-suite/backend/mercury/app/models/device.py new file mode 100644 index 00000000..887979d0 --- /dev/null +++ b/validation-suite/backend/mercury/app/models/device.py @@ -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") + diff --git a/validation-suite/backend/mercury/app/models/document.py b/validation-suite/backend/mercury/app/models/document.py new file mode 100644 index 00000000..cc1e93a1 --- /dev/null +++ b/validation-suite/backend/mercury/app/models/document.py @@ -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)) + diff --git a/validation-suite/backend/mercury/app/models/equipment.py b/validation-suite/backend/mercury/app/models/equipment.py new file mode 100644 index 00000000..b7cb338f --- /dev/null +++ b/validation-suite/backend/mercury/app/models/equipment.py @@ -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) + diff --git a/validation-suite/backend/mercury/app/models/location.py b/validation-suite/backend/mercury/app/models/location.py new file mode 100644 index 00000000..6b82d9a0 --- /dev/null +++ b/validation-suite/backend/mercury/app/models/location.py @@ -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") + diff --git a/validation-suite/backend/mercury/app/models/program.py b/validation-suite/backend/mercury/app/models/program.py new file mode 100644 index 00000000..59b18d67 --- /dev/null +++ b/validation-suite/backend/mercury/app/models/program.py @@ -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) + diff --git a/validation-suite/backend/mercury/app/models/user.py b/validation-suite/backend/mercury/app/models/user.py new file mode 100644 index 00000000..3a921a82 --- /dev/null +++ b/validation-suite/backend/mercury/app/models/user.py @@ -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) + diff --git a/validation-suite/backend/mercury/app/models/validation.py b/validation-suite/backend/mercury/app/models/validation.py new file mode 100644 index 00000000..fa204038 --- /dev/null +++ b/validation-suite/backend/mercury/app/models/validation.py @@ -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) diff --git a/validation-suite/backend/mercury/app/modules/helios/service.py b/validation-suite/backend/mercury/app/modules/helios/service.py new file mode 100644 index 00000000..4a2c93b1 --- /dev/null +++ b/validation-suite/backend/mercury/app/modules/helios/service.py @@ -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)) + diff --git a/validation-suite/backend/mercury/app/modules/orion/service.py b/validation-suite/backend/mercury/app/modules/orion/service.py new file mode 100644 index 00000000..17261ef2 --- /dev/null +++ b/validation-suite/backend/mercury/app/modules/orion/service.py @@ -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"

{chapter}

" for chapter in self.chapters) + html = f"

{title}

{chapter_markup}" + HTML(string=html).write_pdf(output_path) + return output_path + diff --git a/validation-suite/backend/mercury/app/repositories/base.py b/validation-suite/backend/mercury/app/repositories/base.py new file mode 100644 index 00000000..a14c4158 --- /dev/null +++ b/validation-suite/backend/mercury/app/repositories/base.py @@ -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() diff --git a/validation-suite/backend/mercury/app/repositories/domain.py b/validation-suite/backend/mercury/app/repositories/domain.py new file mode 100644 index 00000000..efec5848 --- /dev/null +++ b/validation-suite/backend/mercury/app/repositories/domain.py @@ -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), + } diff --git a/validation-suite/backend/mercury/app/schemas/auth.py b/validation-suite/backend/mercury/app/schemas/auth.py new file mode 100644 index 00000000..85561f5c --- /dev/null +++ b/validation-suite/backend/mercury/app/schemas/auth.py @@ -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 + diff --git a/validation-suite/backend/mercury/app/schemas/common.py b/validation-suite/backend/mercury/app/schemas/common.py new file mode 100644 index 00000000..9c7db7c1 --- /dev/null +++ b/validation-suite/backend/mercury/app/schemas/common.py @@ -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 diff --git a/validation-suite/backend/mercury/app/schemas/domain.py b/validation-suite/backend/mercury/app/schemas/domain.py new file mode 100644 index 00000000..1238af18 --- /dev/null +++ b/validation-suite/backend/mercury/app/schemas/domain.py @@ -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 diff --git a/validation-suite/backend/mercury/app/services/auth_service.py b/validation-suite/backend/mercury/app/services/auth_service.py new file mode 100644 index 00000000..5a15d260 --- /dev/null +++ b/validation-suite/backend/mercury/app/services/auth_service.py @@ -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) + diff --git a/validation-suite/backend/mercury/app/services/domain_service.py b/validation-suite/backend/mercury/app/services/domain_service.py new file mode 100644 index 00000000..f801b3f3 --- /dev/null +++ b/validation-suite/backend/mercury/app/services/domain_service.py @@ -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)) diff --git a/validation-suite/backend/mercury/docker-entrypoint.sh b/validation-suite/backend/mercury/docker-entrypoint.sh new file mode 100644 index 00000000..364542aa --- /dev/null +++ b/validation-suite/backend/mercury/docker-entrypoint.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env sh +set -eu + +alembic upgrade head +python -m app.db.seed +exec "$@" + diff --git a/validation-suite/backend/mercury/pyproject.toml b/validation-suite/backend/mercury/pyproject.toml new file mode 100644 index 00000000..caf0a669 --- /dev/null +++ b/validation-suite/backend/mercury/pyproject.toml @@ -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" + diff --git a/validation-suite/database/README.md b/validation-suite/database/README.md new file mode 100644 index 00000000..34c8d6dd --- /dev/null +++ b/validation-suite/database/README.md @@ -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. + diff --git a/validation-suite/docker-compose.yml b/validation-suite/docker-compose.yml new file mode 100644 index 00000000..28ed2ff8 --- /dev/null +++ b/validation-suite/docker-compose.yml @@ -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: diff --git a/validation-suite/docker/README.md b/validation-suite/docker/README.md new file mode 100644 index 00000000..0e4d3162 --- /dev/null +++ b/validation-suite/docker/README.md @@ -0,0 +1,8 @@ +# Docker + +Die Container werden ueber die Compose-Datei im Projektstamm gestartet: + +```bash +docker compose up -d +``` + diff --git a/validation-suite/docs/API.md b/validation-suite/docs/API.md new file mode 100644 index 00000000..26e2c7b5 --- /dev/null +++ b/validation-suite/docs/API.md @@ -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. + diff --git a/validation-suite/frontend/atlas/Dockerfile b/validation-suite/frontend/atlas/Dockerfile new file mode 100644 index 00000000..1f1bf086 --- /dev/null +++ b/validation-suite/frontend/atlas/Dockerfile @@ -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"] diff --git a/validation-suite/frontend/atlas/app/(app)/contacts/page.tsx b/validation-suite/frontend/atlas/app/(app)/contacts/page.tsx new file mode 100644 index 00000000..a741109c --- /dev/null +++ b/validation-suite/frontend/atlas/app/(app)/contacts/page.tsx @@ -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>("/customers?page=1&page_size=100", token ?? ""), + enabled: Boolean(token) + }); + const customerOptions = (customers.data?.items ?? []).map((item) => ({ label: item.name, value: item.id })); + return ( + + 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: "" }} + /> + ); +} diff --git a/validation-suite/frontend/atlas/app/(app)/customers/page.tsx b/validation-suite/frontend/atlas/app/(app)/customers/page.tsx new file mode 100644 index 00000000..b966bb89 --- /dev/null +++ b/validation-suite/frontend/atlas/app/(app)/customers/page.tsx @@ -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 ( + + 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: "" }} + /> + ); +} + diff --git a/validation-suite/frontend/atlas/app/(app)/dashboard/page.tsx b/validation-suite/frontend/atlas/app/(app)/dashboard/page.tsx new file mode 100644 index 00000000..1f739d5c --- /dev/null +++ b/validation-suite/frontend/atlas/app/(app)/dashboard/page.tsx @@ -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("/dashboard", token ?? ""), + enabled: Boolean(token) + }); + + return ( +
+
+

Dashboard

+

Aktuelle Stammdaten aus PostgreSQL.

+
+
+ {cards.map((item) => { + const Icon = item.icon; + return ( + +
+

{item.label}

+ +
+

{query.data?.[item.key] ?? 0}

+ + ); + })} +
+
+

Stammdaten

+

Kunden, Standorte, Ansprechpartner, Geraete und Pruefmittel koennen produktiv angelegt, bearbeitet, gesucht und geloescht werden.

+
+
+ ); +} diff --git a/validation-suite/frontend/atlas/app/(app)/devices/page.tsx b/validation-suite/frontend/atlas/app/(app)/devices/page.tsx new file mode 100644 index 00000000..955d5c42 --- /dev/null +++ b/validation-suite/frontend/atlas/app/(app)/devices/page.tsx @@ -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>("/customers?page=1&page_size=100", token ?? ""), + enabled: Boolean(token) + }); + const locations = useQuery({ + queryKey: ["locations-options", token], + queryFn: () => apiGet>("/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 ( + + 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: "" }} + /> + ); +} diff --git a/validation-suite/frontend/atlas/app/(app)/documents/page.tsx b/validation-suite/frontend/atlas/app/(app)/documents/page.tsx new file mode 100644 index 00000000..a56a59f1 --- /dev/null +++ b/validation-suite/frontend/atlas/app/(app)/documents/page.tsx @@ -0,0 +1,20 @@ +import { Upload } from "lucide-react"; + +export default function DocumentsPage() { + return ( +
+
+

Dokumente

+

PDF, JPG, PNG, DOCX und XLSX mit Zuordnung zu Kunden, Geraeten, Validierungen und Pruefmitteln.

+
+
+
+ +

Drag & Drop Upload

+

Die Dokumentenablage ist im Backend modelliert und fuer Upload-Flows vorbereitet.

+
+
+
+ ); +} + diff --git a/validation-suite/frontend/atlas/app/(app)/equipment/page.tsx b/validation-suite/frontend/atlas/app/(app)/equipment/page.tsx new file mode 100644 index 00000000..8bf24f21 --- /dev/null +++ b/validation-suite/frontend/atlas/app/(app)/equipment/page.tsx @@ -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 ( + + 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" }} + /> + ); +} + diff --git a/validation-suite/frontend/atlas/app/(app)/layout.tsx b/validation-suite/frontend/atlas/app/(app)/layout.tsx new file mode 100644 index 00000000..a55daafa --- /dev/null +++ b/validation-suite/frontend/atlas/app/(app)/layout.tsx @@ -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 ( + + + {children} + + + ); +} + diff --git a/validation-suite/frontend/atlas/app/(app)/locations/page.tsx b/validation-suite/frontend/atlas/app/(app)/locations/page.tsx new file mode 100644 index 00000000..646618b3 --- /dev/null +++ b/validation-suite/frontend/atlas/app/(app)/locations/page.tsx @@ -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>("/customers?page=1&page_size=100", token ?? ""), + enabled: Boolean(token) + }); + const customerOptions = (customers.data?.items ?? []).map((item) => ({ label: item.name, value: item.id })); + return ( + + 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: "" }} + /> + ); +} diff --git a/validation-suite/frontend/atlas/app/(app)/validations/page.tsx b/validation-suite/frontend/atlas/app/(app)/validations/page.tsx new file mode 100644 index 00000000..41118b82 --- /dev/null +++ b/validation-suite/frontend/atlas/app/(app)/validations/page.tsx @@ -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; + +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 ( +
+ + {open &&
{children}
} +
+ ); +} + +function Field({ label, children }: { label: string; children: React.ReactNode }) { + return ; +} + +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(null); + const [lastSaved, setLastSaved] = useState(""); + const autosaveTimer = useRef | 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>("/customers?page=1&page_size=100", token ?? ""), enabled: Boolean(token) }); + const locations = useQuery({ queryKey: ["locations-options", token], queryFn: () => apiGet>("/locations?page=1&page_size=100", token ?? ""), enabled: Boolean(token) }); + const contacts = useQuery({ queryKey: ["contacts-options", token], queryFn: () => apiGet>("/contacts?page=1&page_size=100", token ?? ""), enabled: Boolean(token) }); + const devices = useQuery({ queryKey: ["devices-options", token], queryFn: () => apiGet>("/devices?page=1&page_size=100", token ?? ""), enabled: Boolean(token) }); + const equipment = useQuery({ queryKey: ["equipment-options", token], queryFn: () => apiGet>("/equipment?page=1&page_size=100", token ?? ""), enabled: Boolean(token) }); + + const form = useForm({ 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(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 ( +
+
+

Validierungsbericht

+

Eine responsive Seite fuer Erfassung, Pruefung und Berichtserstellung.

+
+ + +
+ + + + + + +