feat(validation): complete validation editor and master data modules
This commit is contained in:
parent
2b5c765e41
commit
f73a24df13
73 changed files with 10194 additions and 0 deletions
8
validation-suite/.env.example
Normal file
8
validation-suite/.env.example
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
POSTGRES_DB=validation_suite
|
||||
POSTGRES_USER=validation
|
||||
POSTGRES_PASSWORD=validation123
|
||||
DATABASE_URL=postgresql+psycopg://validation:validation123@postgres:5432/validation_suite
|
||||
JWT_SECRET=replace-this-secret
|
||||
ADMIN_EMAIL=admin@schubamed.de
|
||||
ADMIN_PASSWORD=ValidationSuite!2026
|
||||
NEXT_PUBLIC_API_BASE_URL=http://localhost:8000/api/v1
|
||||
12
validation-suite/ARCHITECTURE.md
Normal file
12
validation-suite/ARCHITECTURE.md
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
# Architecture
|
||||
|
||||
Validation Suite besteht aus Atlas, Mercury und PostgreSQL.
|
||||
|
||||
Atlas ist das responsive Next.js-Frontend mit App Router, TypeScript, TailwindCSS, Lucide Icons, React Hook Form, Zod, TanStack Table und TanStack Query.
|
||||
|
||||
Mercury ist die FastAPI-Anwendung mit Pydantic v2, SQLAlchemy 2.x, Alembic, JWT Authentication, bcrypt, Repository Pattern und Service Layer.
|
||||
|
||||
Orion ist das serverseitige PDF-Modul. Berichte werden aus modularen Kapiteln erzeugt und als HTML/CSS gerendert.
|
||||
|
||||
Helios ist das Import-Modul fuer Messdaten. CSV-Import ist vorbereitet, direkter Winlog-Import kann auf derselben Service-Grenze erweitert werden.
|
||||
|
||||
36
validation-suite/README.md
Normal file
36
validation-suite/README.md
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
# Validation Suite
|
||||
|
||||
Professionelle Enterprise-Anwendung fuer medizinische Validierungsprozesse.
|
||||
|
||||
## Start
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Frontend: `http://localhost:3000`
|
||||
|
||||
API: `http://localhost:8000/docs`
|
||||
|
||||
Initialer Administrator:
|
||||
|
||||
```text
|
||||
admin@schubamed.de
|
||||
ValidationSuite!2026
|
||||
```
|
||||
|
||||
## Services
|
||||
|
||||
- `atlas-web`: Next.js 16 App Router Frontend
|
||||
- `mercury-api`: FastAPI Backend
|
||||
- `postgres`: PostgreSQL Datenbank
|
||||
|
||||
## Struktur
|
||||
|
||||
```text
|
||||
frontend/atlas
|
||||
backend/mercury
|
||||
database
|
||||
docker
|
||||
docs
|
||||
```
|
||||
23
validation-suite/ROADMAP.md
Normal file
23
validation-suite/ROADMAP.md
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
# Roadmap
|
||||
|
||||
## Phase 1
|
||||
|
||||
- Authentifizierung
|
||||
- Benutzerrollen
|
||||
- Stammdaten fuer Kunden, Standorte, Ansprechpartner, Geraete und Pruefmittel
|
||||
- Validierungs-Wizard
|
||||
- Dokumentenablage
|
||||
|
||||
## Phase 2
|
||||
|
||||
- Orion PDF-Kapitel mit finalen Berichtsvorlagen
|
||||
- Helios Winlog-Import
|
||||
- Messwertdiagramme
|
||||
- Audit- und Historienfunktionen
|
||||
|
||||
## Phase 3
|
||||
|
||||
- Erweiterte Rechteverwaltung
|
||||
- Mehrmandantenfaehigkeit
|
||||
- Produktionsbetrieb mit Backup- und Monitoring-Konzept
|
||||
|
||||
26
validation-suite/backend/mercury/Dockerfile
Normal file
26
validation-suite/backend/mercury/Dockerfile
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PYTHONPATH=/app
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends build-essential libpq-dev libcairo2 pango1.0-tools libpango-1.0-0 libgdk-pixbuf-2.0-0 libffi-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY pyproject.toml .
|
||||
RUN pip install --no-cache-dir .
|
||||
|
||||
COPY alembic.ini .
|
||||
COPY alembic ./alembic
|
||||
COPY app ./app
|
||||
|
||||
COPY docker-entrypoint.sh /docker-entrypoint.sh
|
||||
RUN chmod +x /docker-entrypoint.sh
|
||||
|
||||
EXPOSE 8000
|
||||
ENTRYPOINT ["/docker-entrypoint.sh"]
|
||||
CMD ["gunicorn", "app.main:app", "-k", "uvicorn.workers.UvicornWorker", "--bind", "0.0.0.0:8000", "--workers", "2"]
|
||||
|
||||
37
validation-suite/backend/mercury/alembic.ini
Normal file
37
validation-suite/backend/mercury/alembic.ini
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
[alembic]
|
||||
script_location = alembic
|
||||
prepend_sys_path = .
|
||||
sqlalchemy.url = postgresql+psycopg://validation:validation123@postgres:5432/validation_suite
|
||||
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARN
|
||||
handlers = console
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARN
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
|
||||
48
validation-suite/backend/mercury/alembic/env.py
Normal file
48
validation-suite/backend/mercury/alembic/env.py
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from logging.config import fileConfig
|
||||
|
||||
from alembic import context
|
||||
from sqlalchemy import engine_from_config, pool
|
||||
|
||||
from app.core.config import settings
|
||||
from app.db.base import Base
|
||||
from app.models import contact, customer, device, document, equipment, location, program, user, validation
|
||||
|
||||
config = context.config
|
||||
config.set_main_option("sqlalchemy.url", settings.database_url)
|
||||
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
target_metadata = Base.metadata
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
context.configure(
|
||||
url=settings.database_url,
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
dialect_opts={"paramstyle": "named"},
|
||||
)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
connectable = engine_from_config(
|
||||
config.get_section(config.config_ini_section, {}),
|
||||
prefix="sqlalchemy.",
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
with connectable.connect() as connection:
|
||||
context.configure(connection=connection, target_metadata=target_metadata)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
|
||||
|
|
@ -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)
|
||||
|
||||
|
|
@ -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)
|
||||
32
validation-suite/backend/mercury/app/api/dependencies.py
Normal file
32
validation-suite/backend/mercury/app/api/dependencies.py
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
from jose import JWTError, jwt
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.config import settings
|
||||
from app.db.session import get_session
|
||||
from app.models.user import User
|
||||
|
||||
bearer = HTTPBearer()
|
||||
|
||||
|
||||
def current_user(
|
||||
credentials: HTTPAuthorizationCredentials = Depends(bearer),
|
||||
session: Session = Depends(get_session),
|
||||
) -> User:
|
||||
try:
|
||||
payload = jwt.decode(
|
||||
credentials.credentials,
|
||||
settings.jwt_secret,
|
||||
algorithms=[settings.jwt_algorithm],
|
||||
)
|
||||
user_id = payload.get("sub")
|
||||
except JWTError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token") from exc
|
||||
user = session.get(User, user_id)
|
||||
if user is None or not user.is_active:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Inactive user")
|
||||
return user
|
||||
|
||||
24
validation-suite/backend/mercury/app/api/v1/auth.py
Normal file
24
validation-suite/backend/mercury/app/api/v1/auth.py
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.dependencies import current_user
|
||||
from app.db.session import get_session
|
||||
from app.models.user import User
|
||||
from app.schemas.auth import LoginRequest, TokenResponse, UserRead
|
||||
from app.services.auth_service import AuthService
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
|
||||
|
||||
@router.post("/login", response_model=TokenResponse)
|
||||
def login(payload: LoginRequest, session: Session = Depends(get_session)) -> TokenResponse:
|
||||
token = AuthService(session).login(payload.email, payload.password)
|
||||
return TokenResponse(access_token=token)
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserRead)
|
||||
def me(user: User = Depends(current_user)) -> User:
|
||||
return user
|
||||
|
||||
216
validation-suite/backend/mercury/app/api/v1/domain.py
Normal file
216
validation-suite/backend/mercury/app/api/v1/domain.py
Normal file
|
|
@ -0,0 +1,216 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, Query, Response
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.dependencies import current_user
|
||||
from app.db.session import get_session
|
||||
from app.models.contact import Contact
|
||||
from app.models.customer import Customer
|
||||
from app.models.device import Device
|
||||
from app.models.equipment import Equipment
|
||||
from app.models.location import Location
|
||||
from app.models.validation import Validation
|
||||
from app.schemas.common import PaginatedResponse
|
||||
from app.schemas.domain import (
|
||||
ContactCreate,
|
||||
ContactRead,
|
||||
ContactUpdate,
|
||||
CustomerCreate,
|
||||
CustomerRead,
|
||||
CustomerUpdate,
|
||||
DeviceCreate,
|
||||
DeviceRead,
|
||||
DeviceUpdate,
|
||||
EquipmentCreate,
|
||||
EquipmentRead,
|
||||
EquipmentUpdate,
|
||||
LocationCreate,
|
||||
LocationRead,
|
||||
LocationUpdate,
|
||||
ValidationCreate,
|
||||
ValidationRead,
|
||||
ValidationUpdate,
|
||||
)
|
||||
from app.services.domain_service import CrudService, DomainServices
|
||||
|
||||
router = APIRouter(tags=["domain"], dependencies=[Depends(current_user)])
|
||||
|
||||
|
||||
@router.get("/dashboard")
|
||||
def dashboard(session: Session = Depends(get_session)) -> dict[str, int]:
|
||||
return {
|
||||
"customers": session.scalar(select(func.count()).select_from(Customer)) or 0,
|
||||
"locations": session.scalar(select(func.count()).select_from(Location)) or 0,
|
||||
"contacts": session.scalar(select(func.count()).select_from(Contact)) or 0,
|
||||
"devices": session.scalar(select(func.count()).select_from(Device)) or 0,
|
||||
"equipment": session.scalar(select(func.count()).select_from(Equipment)) or 0,
|
||||
"validations": session.scalar(select(func.count()).select_from(Validation)) or 0,
|
||||
}
|
||||
|
||||
|
||||
def paging(
|
||||
page: int = Query(default=1, ge=1),
|
||||
page_size: int = Query(default=20, ge=1, le=100),
|
||||
search: str | None = Query(default=None, max_length=120),
|
||||
) -> dict[str, Any]:
|
||||
return {"page": page, "page_size": page_size, "search": search}
|
||||
|
||||
|
||||
def commit_create(session: Session, service: CrudService, payload):
|
||||
item = service.create(payload.model_dump())
|
||||
session.commit()
|
||||
session.refresh(item)
|
||||
return item
|
||||
|
||||
|
||||
def commit_update(session: Session, service: CrudService, item_id: str, payload):
|
||||
item = service.update(item_id, payload.model_dump())
|
||||
session.commit()
|
||||
session.refresh(item)
|
||||
return item
|
||||
|
||||
|
||||
def commit_delete(session: Session, service: CrudService, item_id: str) -> Response:
|
||||
service.delete(item_id)
|
||||
session.commit()
|
||||
return Response(status_code=204)
|
||||
|
||||
|
||||
@router.get("/customers", response_model=PaginatedResponse[CustomerRead])
|
||||
def list_customers(params: dict = Depends(paging), session: Session = Depends(get_session)):
|
||||
return DomainServices(session).customers.list(**params)
|
||||
|
||||
|
||||
@router.post("/customers", response_model=CustomerRead, status_code=201)
|
||||
def create_customer(payload: CustomerCreate, session: Session = Depends(get_session)):
|
||||
return commit_create(session, DomainServices(session).customers, payload)
|
||||
|
||||
|
||||
@router.put("/customers/{item_id}", response_model=CustomerRead)
|
||||
def update_customer(item_id: str, payload: CustomerUpdate, session: Session = Depends(get_session)):
|
||||
return commit_update(session, DomainServices(session).customers, item_id, payload)
|
||||
|
||||
|
||||
@router.delete("/customers/{item_id}", status_code=204)
|
||||
def delete_customer(item_id: str, session: Session = Depends(get_session)):
|
||||
return commit_delete(session, DomainServices(session).customers, item_id)
|
||||
|
||||
|
||||
@router.get("/locations", response_model=PaginatedResponse[LocationRead])
|
||||
def list_locations(params: dict = Depends(paging), session: Session = Depends(get_session)):
|
||||
return DomainServices(session).locations.list(**params)
|
||||
|
||||
|
||||
@router.post("/locations", response_model=LocationRead, status_code=201)
|
||||
def create_location(payload: LocationCreate, session: Session = Depends(get_session)):
|
||||
return commit_create(session, DomainServices(session).locations, payload)
|
||||
|
||||
|
||||
@router.put("/locations/{item_id}", response_model=LocationRead)
|
||||
def update_location(item_id: str, payload: LocationUpdate, session: Session = Depends(get_session)):
|
||||
return commit_update(session, DomainServices(session).locations, item_id, payload)
|
||||
|
||||
|
||||
@router.delete("/locations/{item_id}", status_code=204)
|
||||
def delete_location(item_id: str, session: Session = Depends(get_session)):
|
||||
return commit_delete(session, DomainServices(session).locations, item_id)
|
||||
|
||||
|
||||
@router.get("/contacts", response_model=PaginatedResponse[ContactRead])
|
||||
def list_contacts(params: dict = Depends(paging), session: Session = Depends(get_session)):
|
||||
return DomainServices(session).contacts.list(**params)
|
||||
|
||||
|
||||
@router.post("/contacts", response_model=ContactRead, status_code=201)
|
||||
def create_contact(payload: ContactCreate, session: Session = Depends(get_session)):
|
||||
return commit_create(session, DomainServices(session).contacts, payload)
|
||||
|
||||
|
||||
@router.put("/contacts/{item_id}", response_model=ContactRead)
|
||||
def update_contact(item_id: str, payload: ContactUpdate, session: Session = Depends(get_session)):
|
||||
return commit_update(session, DomainServices(session).contacts, item_id, payload)
|
||||
|
||||
|
||||
@router.delete("/contacts/{item_id}", status_code=204)
|
||||
def delete_contact(item_id: str, session: Session = Depends(get_session)):
|
||||
return commit_delete(session, DomainServices(session).contacts, item_id)
|
||||
|
||||
|
||||
@router.get("/devices", response_model=PaginatedResponse[DeviceRead])
|
||||
def list_devices(params: dict = Depends(paging), session: Session = Depends(get_session)):
|
||||
return DomainServices(session).devices.list(**params)
|
||||
|
||||
|
||||
@router.post("/devices", response_model=DeviceRead, status_code=201)
|
||||
def create_device(payload: DeviceCreate, session: Session = Depends(get_session)):
|
||||
return commit_create(session, DomainServices(session).devices, payload)
|
||||
|
||||
|
||||
@router.put("/devices/{item_id}", response_model=DeviceRead)
|
||||
def update_device(item_id: str, payload: DeviceUpdate, session: Session = Depends(get_session)):
|
||||
return commit_update(session, DomainServices(session).devices, item_id, payload)
|
||||
|
||||
|
||||
@router.delete("/devices/{item_id}", status_code=204)
|
||||
def delete_device(item_id: str, session: Session = Depends(get_session)):
|
||||
return commit_delete(session, DomainServices(session).devices, item_id)
|
||||
|
||||
|
||||
@router.get("/equipment", response_model=PaginatedResponse[EquipmentRead])
|
||||
def list_equipment(params: dict = Depends(paging), session: Session = Depends(get_session)):
|
||||
return DomainServices(session).equipment.list(**params)
|
||||
|
||||
|
||||
@router.post("/equipment", response_model=EquipmentRead, status_code=201)
|
||||
def create_equipment(payload: EquipmentCreate, session: Session = Depends(get_session)):
|
||||
return commit_create(session, DomainServices(session).equipment, payload)
|
||||
|
||||
|
||||
@router.put("/equipment/{item_id}", response_model=EquipmentRead)
|
||||
def update_equipment(item_id: str, payload: EquipmentUpdate, session: Session = Depends(get_session)):
|
||||
return commit_update(session, DomainServices(session).equipment, item_id, payload)
|
||||
|
||||
|
||||
@router.delete("/equipment/{item_id}", status_code=204)
|
||||
def delete_equipment(item_id: str, session: Session = Depends(get_session)):
|
||||
return commit_delete(session, DomainServices(session).equipment, item_id)
|
||||
|
||||
|
||||
@router.get("/validations", response_model=PaginatedResponse[ValidationRead])
|
||||
def list_validations(params: dict = Depends(paging), session: Session = Depends(get_session)):
|
||||
return DomainServices(session).validations.list(**params)
|
||||
|
||||
|
||||
@router.get("/validations/next-report-number")
|
||||
def next_report_number(session: Session = Depends(get_session)) -> dict[str, str]:
|
||||
count = session.scalar(select(func.count()).select_from(Validation)) or 0
|
||||
return {"report_number": f"VAL-{count + 1:05d}"}
|
||||
|
||||
|
||||
@router.get("/validations/{item_id}", response_model=ValidationRead)
|
||||
def get_validation(item_id: str, session: Session = Depends(get_session)):
|
||||
item = DomainServices(session).validations.repository.get(item_id)
|
||||
if item is None:
|
||||
from fastapi import HTTPException
|
||||
|
||||
raise HTTPException(status_code=404, detail="Resource not found")
|
||||
return item
|
||||
|
||||
|
||||
@router.post("/validations", response_model=ValidationRead, status_code=201)
|
||||
def create_validation(payload: ValidationCreate, session: Session = Depends(get_session)):
|
||||
return commit_create(session, DomainServices(session).validations, payload)
|
||||
|
||||
|
||||
@router.put("/validations/{item_id}", response_model=ValidationRead)
|
||||
def update_validation(item_id: str, payload: ValidationUpdate, session: Session = Depends(get_session)):
|
||||
return commit_update(session, DomainServices(session).validations, item_id, payload)
|
||||
|
||||
|
||||
@router.delete("/validations/{item_id}", status_code=204)
|
||||
def delete_validation(item_id: str, session: Session = Depends(get_session)):
|
||||
return commit_delete(session, DomainServices(session).validations, item_id)
|
||||
10
validation-suite/backend/mercury/app/api/v1/router.py
Normal file
10
validation-suite/backend/mercury/app/api/v1/router.py
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.api.v1 import auth, domain
|
||||
|
||||
api_router = APIRouter(prefix="/api/v1")
|
||||
api_router.include_router(auth.router)
|
||||
api_router.include_router(domain.router)
|
||||
|
||||
24
validation-suite/backend/mercury/app/core/config.py
Normal file
24
validation-suite/backend/mercury/app/core/config.py
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from pydantic import Field
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
|
||||
|
||||
app_name: str = "Validation Suite"
|
||||
environment: str = "local"
|
||||
database_url: str = Field(
|
||||
default="postgresql+psycopg://validation:validation123@postgres:5432/validation_suite",
|
||||
alias="DATABASE_URL",
|
||||
)
|
||||
jwt_secret: str = Field(default="change-me-in-production", alias="JWT_SECRET")
|
||||
jwt_algorithm: str = "HS256"
|
||||
access_token_minutes: int = 60 * 8
|
||||
cors_origins: list[str] = ["http://localhost:3000"]
|
||||
admin_email: str = Field(default="admin@schubamed.de", alias="ADMIN_EMAIL")
|
||||
admin_password: str = Field(default="ValidationSuite!2026", alias="ADMIN_PASSWORD")
|
||||
|
||||
|
||||
settings = Settings()
|
||||
23
validation-suite/backend/mercury/app/core/security.py
Normal file
23
validation-suite/backend/mercury/app/core/security.py
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import bcrypt
|
||||
from jose import jwt
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
|
||||
|
||||
|
||||
def verify_password(password: str, password_hash: str) -> bool:
|
||||
return bcrypt.checkpw(password.encode("utf-8"), password_hash.encode("utf-8"))
|
||||
|
||||
|
||||
def create_access_token(subject: str, role: str) -> str:
|
||||
expires_at = datetime.now(UTC) + timedelta(minutes=settings.access_token_minutes)
|
||||
payload = {"sub": subject, "role": role, "exp": expires_at}
|
||||
return jwt.encode(payload, settings.jwt_secret, algorithm=settings.jwt_algorithm)
|
||||
|
||||
31
validation-suite/backend/mercury/app/db/base.py
Normal file
31
validation-suite/backend/mercury/app/db/base.py
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from uuid import uuid4
|
||||
|
||||
from sqlalchemy import DateTime, MetaData, func
|
||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
||||
|
||||
convention = {
|
||||
"ix": "ix_%(column_0_label)s",
|
||||
"uq": "uq_%(table_name)s_%(column_0_name)s",
|
||||
"ck": "ck_%(table_name)s_%(constraint_name)s",
|
||||
"fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s",
|
||||
"pk": "pk_%(table_name)s",
|
||||
}
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
metadata = MetaData(naming_convention=convention)
|
||||
|
||||
|
||||
class UUIDMixin:
|
||||
id: Mapped[str] = mapped_column(primary_key=True, default=lambda: str(uuid4()))
|
||||
|
||||
|
||||
class TimestampMixin:
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
|
||||
30
validation-suite/backend/mercury/app/db/seed.py
Normal file
30
validation-suite/backend/mercury/app/db/seed.py
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.security import hash_password
|
||||
from app.db.session import SessionLocal
|
||||
from app.models.user import User, UserRole
|
||||
|
||||
|
||||
def seed_admin() -> None:
|
||||
with SessionLocal() as session:
|
||||
existing = session.scalar(select(User).where(User.email == settings.admin_email.lower()))
|
||||
if existing is not None:
|
||||
return
|
||||
session.add(
|
||||
User(
|
||||
email=settings.admin_email.lower(),
|
||||
full_name="Validation Suite Administrator",
|
||||
role=UserRole.admin,
|
||||
password_hash=hash_password(settings.admin_password),
|
||||
is_active=True,
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
seed_admin()
|
||||
|
||||
20
validation-suite/backend/mercury/app/db/session.py
Normal file
20
validation-suite/backend/mercury/app/db/session.py
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Generator
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
engine = create_engine(settings.database_url, pool_pre_ping=True)
|
||||
SessionLocal = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False)
|
||||
|
||||
|
||||
def get_session() -> Generator[Session, None, None]:
|
||||
session = SessionLocal()
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
23
validation-suite/backend/mercury/app/main.py
Normal file
23
validation-suite/backend/mercury/app/main.py
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from app.api.v1.router import api_router
|
||||
from app.core.config import settings
|
||||
|
||||
app = FastAPI(title=settings.app_name, version="0.1.0")
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=settings.cors_origins,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
app.include_router(api_router)
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
def health() -> dict[str, str]:
|
||||
return {"status": "ok"}
|
||||
|
||||
21
validation-suite/backend/mercury/app/models/__init__.py
Normal file
21
validation-suite/backend/mercury/app/models/__init__.py
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
from app.models.contact import Contact
|
||||
from app.models.customer import Customer
|
||||
from app.models.device import Device
|
||||
from app.models.document import Document
|
||||
from app.models.equipment import Equipment
|
||||
from app.models.location import Location
|
||||
from app.models.program import Program
|
||||
from app.models.user import User
|
||||
from app.models.validation import Validation
|
||||
|
||||
__all__ = [
|
||||
"Contact",
|
||||
"Customer",
|
||||
"Device",
|
||||
"Document",
|
||||
"Equipment",
|
||||
"Location",
|
||||
"Program",
|
||||
"User",
|
||||
"Validation",
|
||||
]
|
||||
19
validation-suite/backend/mercury/app/models/contact.py
Normal file
19
validation-suite/backend/mercury/app/models/contact.py
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import ForeignKey, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.db.base import Base, TimestampMixin, UUIDMixin
|
||||
|
||||
|
||||
class Contact(Base, UUIDMixin, TimestampMixin):
|
||||
__tablename__ = "contacts"
|
||||
|
||||
customer_id: Mapped[str] = mapped_column(ForeignKey("customers.id", ondelete="CASCADE"), index=True)
|
||||
full_name: Mapped[str] = mapped_column(String(160))
|
||||
function: Mapped[str | None] = mapped_column(String(120))
|
||||
email: Mapped[str | None] = mapped_column(String(255))
|
||||
phone: Mapped[str | None] = mapped_column(String(80))
|
||||
|
||||
customer: Mapped["Customer"] = relationship(back_populates="contacts")
|
||||
|
||||
32
validation-suite/backend/mercury/app/models/customer.py
Normal file
32
validation-suite/backend/mercury/app/models/customer.py
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import enum
|
||||
|
||||
from sqlalchemy import Enum, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.db.base import Base, TimestampMixin, UUIDMixin
|
||||
|
||||
|
||||
class CustomerType(str, enum.Enum):
|
||||
practice = "practice"
|
||||
clinic = "clinic"
|
||||
|
||||
|
||||
class Customer(Base, UUIDMixin, TimestampMixin):
|
||||
__tablename__ = "customers"
|
||||
|
||||
customer_type: Mapped[CustomerType] = mapped_column(Enum(CustomerType))
|
||||
name: Mapped[str] = mapped_column(String(220), index=True)
|
||||
street: Mapped[str | None] = mapped_column(String(220))
|
||||
postal_code: Mapped[str | None] = mapped_column(String(20))
|
||||
city: Mapped[str | None] = mapped_column(String(120))
|
||||
phone: Mapped[str | None] = mapped_column(String(80))
|
||||
email: Mapped[str | None] = mapped_column(String(255))
|
||||
hygiene_officer: Mapped[str | None] = mapped_column(String(160))
|
||||
quality_manager: Mapped[str | None] = mapped_column(String(160))
|
||||
notes: Mapped[str | None] = mapped_column(Text)
|
||||
|
||||
contacts: Mapped[list["Contact"]] = relationship(back_populates="customer", cascade="all, delete-orphan")
|
||||
locations: Mapped[list["Location"]] = relationship(back_populates="customer", cascade="all, delete-orphan")
|
||||
|
||||
29
validation-suite/backend/mercury/app/models/device.py
Normal file
29
validation-suite/backend/mercury/app/models/device.py
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
|
||||
from sqlalchemy import Date, ForeignKey, Integer, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.db.base import Base, TimestampMixin, UUIDMixin
|
||||
|
||||
|
||||
class Device(Base, UUIDMixin, TimestampMixin):
|
||||
__tablename__ = "devices"
|
||||
|
||||
customer_id: Mapped[str] = mapped_column(ForeignKey("customers.id"), index=True)
|
||||
location_id: Mapped[str | None] = mapped_column(ForeignKey("locations.id"), index=True)
|
||||
manufacturer: Mapped[str] = mapped_column(String(140))
|
||||
model: Mapped[str] = mapped_column(String(140))
|
||||
device_type: Mapped[str | None] = mapped_column(String(120))
|
||||
serial_number: Mapped[str] = mapped_column(String(140), unique=True, index=True)
|
||||
year_built: Mapped[int | None] = mapped_column(Integer)
|
||||
commissioned_on: Mapped[date | None] = mapped_column(Date)
|
||||
chamber_volume_liters: Mapped[int | None] = mapped_column(Integer)
|
||||
steam_generation: Mapped[str | None] = mapped_column(String(220))
|
||||
water_treatment: Mapped[str | None] = mapped_column(String(220))
|
||||
documentation: Mapped[str | None] = mapped_column(Text)
|
||||
supplier: Mapped[str | None] = mapped_column(String(180))
|
||||
|
||||
location: Mapped["Location | None"] = relationship(back_populates="devices")
|
||||
|
||||
26
validation-suite/backend/mercury/app/models/document.py
Normal file
26
validation-suite/backend/mercury/app/models/document.py
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import enum
|
||||
|
||||
from sqlalchemy import Enum, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base import Base, TimestampMixin, UUIDMixin
|
||||
|
||||
|
||||
class DocumentOwnerType(str, enum.Enum):
|
||||
customer = "customer"
|
||||
device = "device"
|
||||
validation = "validation"
|
||||
equipment = "equipment"
|
||||
|
||||
|
||||
class Document(Base, UUIDMixin, TimestampMixin):
|
||||
__tablename__ = "documents"
|
||||
|
||||
owner_type: Mapped[DocumentOwnerType] = mapped_column(Enum(DocumentOwnerType), index=True)
|
||||
owner_id: Mapped[str] = mapped_column(String(80), index=True)
|
||||
filename: Mapped[str] = mapped_column(String(255))
|
||||
content_type: Mapped[str] = mapped_column(String(120))
|
||||
storage_path: Mapped[str] = mapped_column(String(500))
|
||||
|
||||
35
validation-suite/backend/mercury/app/models/equipment.py
Normal file
35
validation-suite/backend/mercury/app/models/equipment.py
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import enum
|
||||
from datetime import date
|
||||
|
||||
from sqlalchemy import Date, Enum, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base import Base, TimestampMixin, UUIDMixin
|
||||
|
||||
|
||||
class EquipmentKind(str, enum.Enum):
|
||||
temperature_logger = "temperature_logger"
|
||||
pressure_logger = "pressure_logger"
|
||||
sensor = "sensor"
|
||||
|
||||
|
||||
class EquipmentStatus(str, enum.Enum):
|
||||
green = "green"
|
||||
yellow = "yellow"
|
||||
red = "red"
|
||||
|
||||
|
||||
class Equipment(Base, UUIDMixin, TimestampMixin):
|
||||
__tablename__ = "equipment"
|
||||
|
||||
kind: Mapped[EquipmentKind] = mapped_column(Enum(EquipmentKind))
|
||||
manufacturer: Mapped[str | None] = mapped_column(String(140))
|
||||
model: Mapped[str | None] = mapped_column(String(140))
|
||||
serial_number: Mapped[str] = mapped_column(String(140), unique=True, index=True)
|
||||
calibrated_on: Mapped[date | None] = mapped_column(Date)
|
||||
calibration_due_on: Mapped[date | None] = mapped_column(Date)
|
||||
certificate_document_id: Mapped[str | None] = mapped_column(String(80))
|
||||
status: Mapped[EquipmentStatus] = mapped_column(Enum(EquipmentStatus), default=EquipmentStatus.green)
|
||||
|
||||
21
validation-suite/backend/mercury/app/models/location.py
Normal file
21
validation-suite/backend/mercury/app/models/location.py
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import ForeignKey, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.db.base import Base, TimestampMixin, UUIDMixin
|
||||
|
||||
|
||||
class Location(Base, UUIDMixin, TimestampMixin):
|
||||
__tablename__ = "locations"
|
||||
|
||||
customer_id: Mapped[str] = mapped_column(ForeignKey("customers.id", ondelete="CASCADE"), index=True)
|
||||
name: Mapped[str] = mapped_column(String(160))
|
||||
street: Mapped[str | None] = mapped_column(String(220))
|
||||
postal_code: Mapped[str | None] = mapped_column(String(20))
|
||||
city: Mapped[str | None] = mapped_column(String(120))
|
||||
room: Mapped[str | None] = mapped_column(String(120))
|
||||
|
||||
customer: Mapped["Customer"] = relationship(back_populates="locations")
|
||||
devices: Mapped[list["Device"]] = relationship(back_populates="location")
|
||||
|
||||
17
validation-suite/backend/mercury/app/models/program.py
Normal file
17
validation-suite/backend/mercury/app/models/program.py
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import ForeignKey, Integer, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base import Base, TimestampMixin, UUIDMixin
|
||||
|
||||
|
||||
class Program(Base, UUIDMixin, TimestampMixin):
|
||||
__tablename__ = "programs"
|
||||
|
||||
device_id: Mapped[str] = mapped_column(ForeignKey("devices.id"), index=True)
|
||||
name: Mapped[str] = mapped_column(String(160))
|
||||
temperature_celsius: Mapped[int | None] = mapped_column(Integer)
|
||||
holding_time_minutes: Mapped[int | None] = mapped_column(Integer)
|
||||
drying_time_minutes: Mapped[int | None] = mapped_column(Integer)
|
||||
|
||||
25
validation-suite/backend/mercury/app/models/user.py
Normal file
25
validation-suite/backend/mercury/app/models/user.py
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import enum
|
||||
|
||||
from sqlalchemy import Boolean, Enum, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base import Base, TimestampMixin, UUIDMixin
|
||||
|
||||
|
||||
class UserRole(str, enum.Enum):
|
||||
admin = "admin"
|
||||
employee = "employee"
|
||||
auditor = "auditor"
|
||||
|
||||
|
||||
class User(Base, UUIDMixin, TimestampMixin):
|
||||
__tablename__ = "users"
|
||||
|
||||
email: Mapped[str] = mapped_column(String(255), unique=True, index=True)
|
||||
full_name: Mapped[str] = mapped_column(String(160))
|
||||
role: Mapped[UserRole] = mapped_column(Enum(UserRole), default=UserRole.employee)
|
||||
password_hash: Mapped[str] = mapped_column(String(255))
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
|
||||
49
validation-suite/backend/mercury/app/models/validation.py
Normal file
49
validation-suite/backend/mercury/app/models/validation.py
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import enum
|
||||
from datetime import date
|
||||
|
||||
from sqlalchemy import Date, Enum, ForeignKey, JSON, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base import Base, TimestampMixin, UUIDMixin
|
||||
|
||||
|
||||
class ValidationStatus(str, enum.Enum):
|
||||
draft = "draft"
|
||||
in_progress = "in_progress"
|
||||
ready_for_report = "ready_for_report"
|
||||
completed = "completed"
|
||||
|
||||
|
||||
class Validation(Base, UUIDMixin, TimestampMixin):
|
||||
__tablename__ = "validations"
|
||||
|
||||
report_number: Mapped[str] = mapped_column(String(80), unique=True, index=True)
|
||||
customer_id: Mapped[str] = mapped_column(ForeignKey("customers.id"), index=True)
|
||||
location_id: Mapped[str | None] = mapped_column(ForeignKey("locations.id"), index=True)
|
||||
contact_id: Mapped[str | None] = mapped_column(ForeignKey("contacts.id"), index=True)
|
||||
device_id: Mapped[str | None] = mapped_column(ForeignKey("devices.id"), index=True)
|
||||
validation_type: Mapped[str] = mapped_column(String(120))
|
||||
project: Mapped[str | None] = mapped_column(String(180))
|
||||
test_location: Mapped[str | None] = mapped_column(String(180))
|
||||
examiner_name: Mapped[str | None] = mapped_column(String(180))
|
||||
participants: Mapped[str | None] = mapped_column(Text)
|
||||
operator_name: Mapped[str | None] = mapped_column(String(180))
|
||||
scheduled_on: Mapped[date | None] = mapped_column(Date)
|
||||
performed_on: Mapped[date | None] = mapped_column(Date)
|
||||
next_validation_on: Mapped[date | None] = mapped_column(Date)
|
||||
examiner_id: Mapped[str | None] = mapped_column(ForeignKey("users.id"))
|
||||
status: Mapped[ValidationStatus] = mapped_column(Enum(ValidationStatus), default=ValidationStatus.draft)
|
||||
result: Mapped[str | None] = mapped_column(String(120))
|
||||
notes: Mapped[str | None] = mapped_column(Text)
|
||||
equipment_ids: Mapped[list[str]] = mapped_column(JSON, default=list)
|
||||
environment_conditions: Mapped[dict] = mapped_column(JSON, default=dict)
|
||||
documentation_checklist: Mapped[list[dict]] = mapped_column(JSON, default=list)
|
||||
performance_checklist: Mapped[list[dict]] = mapped_column(JSON, default=list)
|
||||
programs: Mapped[list[dict]] = mapped_column(JSON, default=list)
|
||||
loading_patterns: Mapped[list[dict]] = mapped_column(JSON, default=list)
|
||||
measurement_data: Mapped[list[dict]] = mapped_column(JSON, default=list)
|
||||
drying: Mapped[dict] = mapped_column(JSON, default=dict)
|
||||
recommendations: Mapped[list[dict]] = mapped_column(JSON, default=list)
|
||||
attachments: Mapped[list[dict]] = mapped_column(JSON, default=list)
|
||||
|
|
@ -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))
|
||||
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from weasyprint import HTML
|
||||
|
||||
|
||||
class OrionReportService:
|
||||
chapters = [
|
||||
"Deckblatt",
|
||||
"Inhaltsverzeichnis",
|
||||
"Zusammenfassung",
|
||||
"Gerät",
|
||||
"Kunde",
|
||||
"Normen",
|
||||
"Prüfmittel",
|
||||
"Programme",
|
||||
"Beladung",
|
||||
"Messungen",
|
||||
"Diagramme",
|
||||
"Empfehlungen",
|
||||
"Anlagen",
|
||||
]
|
||||
|
||||
def render_pdf(self, title: str, output_path: Path) -> Path:
|
||||
chapter_markup = "".join(f"<section><h2>{chapter}</h2></section>" for chapter in self.chapters)
|
||||
html = f"<html><body><h1>{title}</h1>{chapter_markup}</body></html>"
|
||||
HTML(string=html).write_pdf(output_path)
|
||||
return output_path
|
||||
|
||||
45
validation-suite/backend/mercury/app/repositories/base.py
Normal file
45
validation-suite/backend/mercury/app/repositories/base.py
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import Generic, TypeVar
|
||||
|
||||
from sqlalchemy import Select, func, or_, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
ModelT = TypeVar("ModelT")
|
||||
|
||||
|
||||
class Repository(Generic[ModelT]):
|
||||
model: type[ModelT]
|
||||
|
||||
def __init__(self, session: Session) -> None:
|
||||
self.session = session
|
||||
|
||||
def get(self, item_id: str) -> ModelT | None:
|
||||
return self.session.get(self.model, item_id)
|
||||
|
||||
search_columns: tuple[str, ...] = ()
|
||||
|
||||
def _search_statement(self, search: str | None = None) -> Select[tuple[ModelT]]:
|
||||
statement: Select[tuple[ModelT]] = select(self.model)
|
||||
if search and self.search_columns:
|
||||
term = f"%{search.strip()}%"
|
||||
filters = [getattr(self.model, column).ilike(term) for column in self.search_columns]
|
||||
statement = statement.where(or_(*filters))
|
||||
return statement
|
||||
|
||||
def list(self, limit: int = 20, offset: int = 0, search: str | None = None) -> list[ModelT]:
|
||||
statement = self._search_statement(search).offset(offset).limit(limit)
|
||||
return list(self.session.scalars(statement))
|
||||
|
||||
def count(self, search: str | None = None) -> int:
|
||||
subquery = self._search_statement(search).subquery()
|
||||
return self.session.scalar(select(func.count()).select_from(subquery)) or 0
|
||||
|
||||
def add(self, item: ModelT) -> ModelT:
|
||||
self.session.add(item)
|
||||
self.session.flush()
|
||||
return item
|
||||
|
||||
def delete(self, item: ModelT) -> None:
|
||||
self.session.delete(item)
|
||||
self.session.flush()
|
||||
67
validation-suite/backend/mercury/app/repositories/domain.py
Normal file
67
validation-suite/backend/mercury/app/repositories/domain.py
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.customer import Customer
|
||||
from app.models.device import Device
|
||||
from app.models.document import Document
|
||||
from app.models.equipment import Equipment
|
||||
from app.models.contact import Contact
|
||||
from app.models.location import Location
|
||||
from app.models.user import User
|
||||
from app.models.validation import Validation
|
||||
from app.repositories.base import Repository
|
||||
|
||||
|
||||
class UserRepository(Repository[User]):
|
||||
model = User
|
||||
|
||||
def by_email(self, email: str) -> User | None:
|
||||
return self.session.scalar(select(User).where(User.email == email.lower()))
|
||||
|
||||
|
||||
class CustomerRepository(Repository[Customer]):
|
||||
model = Customer
|
||||
search_columns = ("name", "city", "email", "phone")
|
||||
|
||||
|
||||
class LocationRepository(Repository[Location]):
|
||||
model = Location
|
||||
search_columns = ("name", "city", "room")
|
||||
|
||||
|
||||
class ContactRepository(Repository[Contact]):
|
||||
model = Contact
|
||||
search_columns = ("full_name", "function", "email", "phone")
|
||||
|
||||
|
||||
class DeviceRepository(Repository[Device]):
|
||||
model = Device
|
||||
search_columns = ("manufacturer", "model", "serial_number", "device_type")
|
||||
|
||||
|
||||
class EquipmentRepository(Repository[Equipment]):
|
||||
model = Equipment
|
||||
search_columns = ("manufacturer", "model", "serial_number")
|
||||
|
||||
|
||||
class ValidationRepository(Repository[Validation]):
|
||||
model = Validation
|
||||
|
||||
|
||||
class DocumentRepository(Repository[Document]):
|
||||
model = Document
|
||||
|
||||
|
||||
def repositories(session: Session) -> dict[str, Repository]:
|
||||
return {
|
||||
"users": UserRepository(session),
|
||||
"customers": CustomerRepository(session),
|
||||
"locations": LocationRepository(session),
|
||||
"contacts": ContactRepository(session),
|
||||
"devices": DeviceRepository(session),
|
||||
"equipment": EquipmentRepository(session),
|
||||
"validations": ValidationRepository(session),
|
||||
"documents": DocumentRepository(session),
|
||||
}
|
||||
24
validation-suite/backend/mercury/app/schemas/auth.py
Normal file
24
validation-suite/backend/mercury/app/schemas/auth.py
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel, EmailStr
|
||||
|
||||
from app.models.user import UserRole
|
||||
from app.schemas.common import EntityRead
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
email: EmailStr
|
||||
password: str
|
||||
|
||||
|
||||
class TokenResponse(BaseModel):
|
||||
access_token: str
|
||||
token_type: str = "bearer"
|
||||
|
||||
|
||||
class UserRead(EntityRead):
|
||||
email: EmailStr
|
||||
full_name: str
|
||||
role: UserRole
|
||||
is_active: bool
|
||||
|
||||
25
validation-suite/backend/mercury/app/schemas/common.py
Normal file
25
validation-suite/backend/mercury/app/schemas/common.py
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from typing import Generic, TypeVar
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
class ORMModel(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class EntityRead(ORMModel):
|
||||
id: str
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class PaginatedResponse(BaseModel, Generic[T]):
|
||||
items: list[T]
|
||||
total: int
|
||||
page: int
|
||||
page_size: int
|
||||
146
validation-suite/backend/mercury/app/schemas/domain.py
Normal file
146
validation-suite/backend/mercury/app/schemas/domain.py
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
|
||||
from pydantic import EmailStr, Field
|
||||
|
||||
from app.models.customer import CustomerType
|
||||
from app.models.equipment import EquipmentKind, EquipmentStatus
|
||||
from app.models.validation import ValidationStatus
|
||||
from app.schemas.common import EntityRead, ORMModel
|
||||
|
||||
|
||||
class CustomerCreate(ORMModel):
|
||||
customer_type: CustomerType
|
||||
name: str
|
||||
street: str | None = None
|
||||
postal_code: str | None = None
|
||||
city: str | None = None
|
||||
phone: str | None = None
|
||||
email: EmailStr | None = None
|
||||
hygiene_officer: str | None = None
|
||||
quality_manager: str | None = None
|
||||
notes: str | None = None
|
||||
|
||||
|
||||
class CustomerRead(CustomerCreate, EntityRead):
|
||||
pass
|
||||
|
||||
|
||||
class CustomerUpdate(CustomerCreate):
|
||||
pass
|
||||
|
||||
|
||||
class LocationCreate(ORMModel):
|
||||
customer_id: str
|
||||
name: str
|
||||
street: str | None = None
|
||||
postal_code: str | None = None
|
||||
city: str | None = None
|
||||
room: str | None = None
|
||||
|
||||
|
||||
class LocationRead(LocationCreate, EntityRead):
|
||||
pass
|
||||
|
||||
|
||||
class LocationUpdate(LocationCreate):
|
||||
pass
|
||||
|
||||
|
||||
class ContactCreate(ORMModel):
|
||||
customer_id: str
|
||||
full_name: str
|
||||
function: str | None = None
|
||||
email: EmailStr | None = None
|
||||
phone: str | None = None
|
||||
|
||||
|
||||
class ContactRead(ContactCreate, EntityRead):
|
||||
pass
|
||||
|
||||
|
||||
class ContactUpdate(ContactCreate):
|
||||
pass
|
||||
|
||||
|
||||
class DeviceCreate(ORMModel):
|
||||
customer_id: str
|
||||
location_id: str | None = None
|
||||
manufacturer: str
|
||||
model: str
|
||||
device_type: str | None = None
|
||||
serial_number: str
|
||||
year_built: int | None = None
|
||||
commissioned_on: date | None = None
|
||||
chamber_volume_liters: int | None = None
|
||||
steam_generation: str | None = None
|
||||
water_treatment: str | None = None
|
||||
documentation: str | None = None
|
||||
supplier: str | None = None
|
||||
|
||||
|
||||
class DeviceRead(DeviceCreate, EntityRead):
|
||||
pass
|
||||
|
||||
|
||||
class DeviceUpdate(DeviceCreate):
|
||||
pass
|
||||
|
||||
|
||||
class EquipmentCreate(ORMModel):
|
||||
kind: EquipmentKind
|
||||
manufacturer: str | None = None
|
||||
model: str | None = None
|
||||
serial_number: str
|
||||
calibrated_on: date | None = None
|
||||
calibration_due_on: date | None = None
|
||||
certificate_document_id: str | None = None
|
||||
status: EquipmentStatus = EquipmentStatus.green
|
||||
|
||||
|
||||
class EquipmentRead(EquipmentCreate, EntityRead):
|
||||
pass
|
||||
|
||||
|
||||
class EquipmentUpdate(EquipmentCreate):
|
||||
pass
|
||||
|
||||
|
||||
class ValidationCreate(ORMModel):
|
||||
report_number: str
|
||||
customer_id: str
|
||||
location_id: str | None = None
|
||||
contact_id: str | None = None
|
||||
device_id: str | None = None
|
||||
validation_type: str
|
||||
project: str | None = None
|
||||
test_location: str | None = None
|
||||
examiner_name: str | None = None
|
||||
participants: str | None = None
|
||||
operator_name: str | None = None
|
||||
scheduled_on: date | None = None
|
||||
performed_on: date | None = None
|
||||
next_validation_on: date | None = None
|
||||
examiner_id: str | None = None
|
||||
status: ValidationStatus = ValidationStatus.draft
|
||||
result: str | None = None
|
||||
notes: str | None = None
|
||||
equipment_ids: list[str] = Field(default_factory=list)
|
||||
environment_conditions: dict = Field(default_factory=dict)
|
||||
documentation_checklist: list[dict] = Field(default_factory=list)
|
||||
performance_checklist: list[dict] = Field(default_factory=list)
|
||||
programs: list[dict] = Field(default_factory=list)
|
||||
loading_patterns: list[dict] = Field(default_factory=list)
|
||||
measurement_data: list[dict] = Field(default_factory=list)
|
||||
drying: dict = Field(default_factory=dict)
|
||||
recommendations: list[dict] = Field(default_factory=list)
|
||||
attachments: list[dict] = Field(default_factory=list)
|
||||
|
||||
|
||||
class ValidationRead(ValidationCreate, EntityRead):
|
||||
pass
|
||||
|
||||
|
||||
class ValidationUpdate(ValidationCreate):
|
||||
pass
|
||||
|
|
@ -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)
|
||||
|
||||
|
|
@ -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))
|
||||
7
validation-suite/backend/mercury/docker-entrypoint.sh
Normal file
7
validation-suite/backend/mercury/docker-entrypoint.sh
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
#!/usr/bin/env sh
|
||||
set -eu
|
||||
|
||||
alembic upgrade head
|
||||
python -m app.db.seed
|
||||
exec "$@"
|
||||
|
||||
33
validation-suite/backend/mercury/pyproject.toml
Normal file
33
validation-suite/backend/mercury/pyproject.toml
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
[project]
|
||||
name = "mercury"
|
||||
version = "0.1.0"
|
||||
description = "Validation Suite backend"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"alembic==1.16.4",
|
||||
"bcrypt==4.3.0",
|
||||
"fastapi==0.116.1",
|
||||
"gunicorn==23.0.0",
|
||||
"psycopg[binary]==3.2.9",
|
||||
"pydantic-settings==2.10.1",
|
||||
"pydantic[email]==2.11.7",
|
||||
"python-jose[cryptography]==3.5.0",
|
||||
"python-multipart==0.0.20",
|
||||
"sqlalchemy==2.0.41",
|
||||
"uvicorn[standard]==0.35.0",
|
||||
"weasyprint==62.3"
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = ["black==25.1.0", "isort==6.0.1", "ruff==0.12.4"]
|
||||
|
||||
[tool.black]
|
||||
line-length = 100
|
||||
|
||||
[tool.isort]
|
||||
profile = "black"
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 100
|
||||
target-version = "py312"
|
||||
|
||||
4
validation-suite/database/README.md
Normal file
4
validation-suite/database/README.md
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
# Database
|
||||
|
||||
PostgreSQL wird durch Docker Compose gestartet. Schema-Aenderungen werden in `backend/mercury/alembic/versions` versioniert und beim Start von `mercury-api` automatisch migriert.
|
||||
|
||||
49
validation-suite/docker-compose.yml
Normal file
49
validation-suite/docker-compose.yml
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
services:
|
||||
postgres:
|
||||
image: postgres:17
|
||||
container_name: postgres
|
||||
environment:
|
||||
POSTGRES_DB: validation_suite
|
||||
POSTGRES_USER: validation
|
||||
POSTGRES_PASSWORD: validation123
|
||||
volumes:
|
||||
- validation_suite_postgres:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U validation -d validation_suite"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 20
|
||||
|
||||
mercury-api:
|
||||
build:
|
||||
context: ./backend/mercury
|
||||
container_name: mercury-api
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
DATABASE_URL: postgresql+psycopg://validation:validation123@postgres:5432/validation_suite
|
||||
JWT_SECRET: validation-suite-local-jwt-secret
|
||||
ADMIN_EMAIL: admin@schubamed.de
|
||||
ADMIN_PASSWORD: ValidationSuite!2026
|
||||
ports:
|
||||
- "8000:8000"
|
||||
volumes:
|
||||
- mercury_uploads:/app/uploads
|
||||
- mercury_reports:/app/reports
|
||||
|
||||
atlas-web:
|
||||
build:
|
||||
context: ./frontend/atlas
|
||||
container_name: atlas-web
|
||||
depends_on:
|
||||
- mercury-api
|
||||
environment:
|
||||
NEXT_PUBLIC_API_BASE_URL: http://localhost:8000/api/v1
|
||||
ports:
|
||||
- "3000:3000"
|
||||
|
||||
volumes:
|
||||
validation_suite_postgres:
|
||||
mercury_uploads:
|
||||
mercury_reports:
|
||||
8
validation-suite/docker/README.md
Normal file
8
validation-suite/docker/README.md
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
# Docker
|
||||
|
||||
Die Container werden ueber die Compose-Datei im Projektstamm gestartet:
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
13
validation-suite/docs/API.md
Normal file
13
validation-suite/docs/API.md
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
# API
|
||||
|
||||
Die Mercury API ist unter `/api/v1` versioniert.
|
||||
|
||||
- `POST /auth/login`
|
||||
- `GET /auth/me`
|
||||
- `GET|POST /customers`
|
||||
- `GET|POST /devices`
|
||||
- `GET|POST /equipment`
|
||||
- `GET|POST /validations`
|
||||
|
||||
Authentifizierung erfolgt per Bearer JWT.
|
||||
|
||||
20
validation-suite/frontend/atlas/Dockerfile
Normal file
20
validation-suite/frontend/atlas/Dockerfile
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
FROM node:22-alpine AS deps
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
|
||||
FROM node:22-alpine AS builder
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM node:22-alpine AS runner
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
COPY --from=builder /app/.next ./.next
|
||||
COPY --from=builder /app/public ./public
|
||||
COPY --from=builder /app/package.json ./package.json
|
||||
COPY --from=builder /app/node_modules ./node_modules
|
||||
EXPOSE 3000
|
||||
CMD ["npm", "run", "start"]
|
||||
47
validation-suite/frontend/atlas/app/(app)/contacts/page.tsx
Normal file
47
validation-suite/frontend/atlas/app/(app)/contacts/page.tsx
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
"use client";
|
||||
|
||||
import { z } from "zod";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAuth } from "@/components/auth";
|
||||
import { CrudPage } from "@/components/crud-page";
|
||||
import { apiGet, Contact, Customer, Paginated } from "@/lib/api";
|
||||
|
||||
const schema = z.object({
|
||||
customer_id: z.string().min(1),
|
||||
full_name: z.string().min(2),
|
||||
function: z.string().optional().nullable(),
|
||||
email: z.union([z.string().email(), z.literal(""), z.null()]).optional(),
|
||||
phone: z.string().optional().nullable()
|
||||
});
|
||||
|
||||
export default function ContactsPage() {
|
||||
const { token } = useAuth();
|
||||
const customers = useQuery({
|
||||
queryKey: ["customers-options", token],
|
||||
queryFn: () => apiGet<Paginated<Customer>>("/customers?page=1&page_size=100", token ?? ""),
|
||||
enabled: Boolean(token)
|
||||
});
|
||||
const customerOptions = (customers.data?.items ?? []).map((item) => ({ label: item.name, value: item.id }));
|
||||
return (
|
||||
<CrudPage<Contact>
|
||||
title="Ansprechpartner"
|
||||
subtitle="Kontaktpersonen, Funktionen und Kommunikationsdaten."
|
||||
endpoint="/contacts"
|
||||
columns={[
|
||||
{ key: "full_name", label: "Name" },
|
||||
{ key: "function", label: "Funktion" },
|
||||
{ key: "email", label: "Mail" },
|
||||
{ key: "phone", label: "Telefon" }
|
||||
]}
|
||||
fields={[
|
||||
{ name: "customer_id", label: "Kunde", type: "select", options: customerOptions },
|
||||
{ name: "full_name", label: "Name" },
|
||||
{ name: "function", label: "Funktion" },
|
||||
{ name: "email", label: "Mail", type: "email" },
|
||||
{ name: "phone", label: "Telefon" }
|
||||
]}
|
||||
schema={schema}
|
||||
emptyValues={{ customer_id: "", full_name: "", function: "", email: "", phone: "" }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
50
validation-suite/frontend/atlas/app/(app)/customers/page.tsx
Normal file
50
validation-suite/frontend/atlas/app/(app)/customers/page.tsx
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
"use client";
|
||||
|
||||
import { z } from "zod";
|
||||
import { CrudPage } from "@/components/crud-page";
|
||||
import { Customer } from "@/lib/api";
|
||||
|
||||
const schema = z.object({
|
||||
customer_type: z.enum(["practice", "clinic"]),
|
||||
name: z.string().min(2),
|
||||
street: z.string().optional().nullable(),
|
||||
postal_code: z.string().optional().nullable(),
|
||||
city: z.string().optional().nullable(),
|
||||
phone: z.string().optional().nullable(),
|
||||
email: z.union([z.string().email(), z.literal(""), z.null()]).optional(),
|
||||
hygiene_officer: z.string().optional().nullable(),
|
||||
quality_manager: z.string().optional().nullable(),
|
||||
notes: z.string().optional().nullable()
|
||||
});
|
||||
|
||||
export default function CustomersPage() {
|
||||
return (
|
||||
<CrudPage<Customer>
|
||||
title="Kunden"
|
||||
subtitle="Praxen und Kliniken mit Kontakt- und Qualitaetsdaten."
|
||||
endpoint="/customers"
|
||||
columns={[
|
||||
{ key: "name", label: "Name" },
|
||||
{ key: "customer_type", label: "Typ" },
|
||||
{ key: "city", label: "Ort" },
|
||||
{ key: "phone", label: "Telefon" },
|
||||
{ key: "email", label: "Mail" }
|
||||
]}
|
||||
fields={[
|
||||
{ name: "customer_type", label: "Typ", type: "select", options: [{ label: "Praxis", value: "practice" }, { label: "Klinik", value: "clinic" }] },
|
||||
{ name: "name", label: "Name", required: true },
|
||||
{ name: "street", label: "Adresse" },
|
||||
{ name: "postal_code", label: "PLZ" },
|
||||
{ name: "city", label: "Ort" },
|
||||
{ name: "phone", label: "Telefon" },
|
||||
{ name: "email", label: "Mail", type: "email" },
|
||||
{ name: "hygiene_officer", label: "Hygienebeauftragter" },
|
||||
{ name: "quality_manager", label: "QM" },
|
||||
{ name: "notes", label: "Bemerkungen", type: "textarea" }
|
||||
]}
|
||||
schema={schema}
|
||||
emptyValues={{ customer_type: "practice", name: "", street: "", postal_code: "", city: "", phone: "", email: "", hygiene_officer: "", quality_manager: "", notes: "" }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
60
validation-suite/frontend/atlas/app/(app)/dashboard/page.tsx
Normal file
60
validation-suite/frontend/atlas/app/(app)/dashboard/page.tsx
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Building2, Gauge, MapPin, Stethoscope, UserRound } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useAuth } from "@/components/auth";
|
||||
import { apiGet } from "@/lib/api";
|
||||
|
||||
type DashboardData = {
|
||||
customers: number;
|
||||
locations: number;
|
||||
contacts: number;
|
||||
devices: number;
|
||||
equipment: number;
|
||||
validations: number;
|
||||
};
|
||||
|
||||
const cards = [
|
||||
{ label: "Kunden", key: "customers", href: "/customers", icon: Building2 },
|
||||
{ label: "Standorte", key: "locations", href: "/locations", icon: MapPin },
|
||||
{ label: "Ansprechpartner", key: "contacts", href: "/contacts", icon: UserRound },
|
||||
{ label: "Geraete", key: "devices", href: "/devices", icon: Stethoscope },
|
||||
{ label: "Pruefmittel", key: "equipment", href: "/equipment", icon: Gauge }
|
||||
] as const;
|
||||
|
||||
export default function DashboardPage() {
|
||||
const { token } = useAuth();
|
||||
const query = useQuery({
|
||||
queryKey: ["dashboard", token],
|
||||
queryFn: () => apiGet<DashboardData>("/dashboard", token ?? ""),
|
||||
enabled: Boolean(token)
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<header>
|
||||
<h1 className="text-3xl font-semibold text-text">Dashboard</h1>
|
||||
<p className="mt-2 text-text-light">Aktuelle Stammdaten aus PostgreSQL.</p>
|
||||
</header>
|
||||
<section className="grid gap-4 sm:grid-cols-2 xl:grid-cols-5">
|
||||
{cards.map((item) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<Link key={item.key} href={item.href} className="rounded-lg border border-border bg-surface p-6 shadow-soft transition hover:-translate-y-0.5 hover:border-primary/40">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-text-light">{item.label}</p>
|
||||
<Icon className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<p className="mt-6 text-4xl font-semibold text-text">{query.data?.[item.key] ?? 0}</p>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
<section className="rounded-lg border border-border bg-surface p-6 shadow-soft">
|
||||
<h2 className="text-xl font-semibold">Stammdaten</h2>
|
||||
<p className="mt-2 text-sm leading-6 text-text-light">Kunden, Standorte, Ansprechpartner, Geraete und Pruefmittel koennen produktiv angelegt, bearbeitet, gesucht und geloescht werden.</p>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
70
validation-suite/frontend/atlas/app/(app)/devices/page.tsx
Normal file
70
validation-suite/frontend/atlas/app/(app)/devices/page.tsx
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
"use client";
|
||||
|
||||
import { z } from "zod";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAuth } from "@/components/auth";
|
||||
import { CrudPage } from "@/components/crud-page";
|
||||
import { apiGet, Customer, Device, Location, Paginated } from "@/lib/api";
|
||||
|
||||
const optionalNumber = z.union([z.coerce.number().int().positive(), z.literal(""), z.null()]).optional();
|
||||
const schema = z.object({
|
||||
customer_id: z.string().min(1),
|
||||
location_id: z.string().optional().nullable(),
|
||||
manufacturer: z.string().min(2),
|
||||
model: z.string().min(1),
|
||||
device_type: z.string().optional().nullable(),
|
||||
serial_number: z.string().min(2),
|
||||
year_built: optionalNumber,
|
||||
commissioned_on: z.string().optional().nullable(),
|
||||
chamber_volume_liters: optionalNumber,
|
||||
steam_generation: z.string().optional().nullable(),
|
||||
water_treatment: z.string().optional().nullable(),
|
||||
documentation: z.string().optional().nullable(),
|
||||
supplier: z.string().optional().nullable()
|
||||
});
|
||||
|
||||
export default function DevicesPage() {
|
||||
const { token } = useAuth();
|
||||
const customers = useQuery({
|
||||
queryKey: ["customers-options", token],
|
||||
queryFn: () => apiGet<Paginated<Customer>>("/customers?page=1&page_size=100", token ?? ""),
|
||||
enabled: Boolean(token)
|
||||
});
|
||||
const locations = useQuery({
|
||||
queryKey: ["locations-options", token],
|
||||
queryFn: () => apiGet<Paginated<Location>>("/locations?page=1&page_size=100", token ?? ""),
|
||||
enabled: Boolean(token)
|
||||
});
|
||||
const customerOptions = (customers.data?.items ?? []).map((item) => ({ label: item.name, value: item.id }));
|
||||
const locationOptions = (locations.data?.items ?? []).map((item) => ({ label: item.name, value: item.id }));
|
||||
return (
|
||||
<CrudPage<Device>
|
||||
title="Geraete"
|
||||
subtitle="Geraetestammdaten, technische Daten, Standort und Dokumentation."
|
||||
endpoint="/devices"
|
||||
columns={[
|
||||
{ key: "manufacturer", label: "Hersteller" },
|
||||
{ key: "model", label: "Modell" },
|
||||
{ key: "device_type", label: "Typ" },
|
||||
{ key: "serial_number", label: "Seriennummer" }
|
||||
]}
|
||||
fields={[
|
||||
{ name: "customer_id", label: "Kunde", type: "select", options: customerOptions },
|
||||
{ name: "location_id", label: "Standort", type: "select", options: locationOptions },
|
||||
{ name: "manufacturer", label: "Hersteller" },
|
||||
{ name: "model", label: "Modell" },
|
||||
{ name: "device_type", label: "Typ" },
|
||||
{ name: "serial_number", label: "Seriennummer" },
|
||||
{ name: "year_built", label: "Baujahr", type: "number" },
|
||||
{ name: "commissioned_on", label: "Inbetriebnahme", type: "date" },
|
||||
{ name: "chamber_volume_liters", label: "Kammervolumen Liter", type: "number" },
|
||||
{ name: "steam_generation", label: "Dampferzeugung" },
|
||||
{ name: "water_treatment", label: "Wasseraufbereitung" },
|
||||
{ name: "supplier", label: "Lieferant" },
|
||||
{ name: "documentation", label: "Dokumentation", type: "textarea" }
|
||||
]}
|
||||
schema={schema}
|
||||
emptyValues={{ customer_id: "", location_id: "", manufacturer: "", model: "", device_type: "", serial_number: "", year_built: "", commissioned_on: "", chamber_volume_liters: "", steam_generation: "", water_treatment: "", documentation: "", supplier: "" }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
20
validation-suite/frontend/atlas/app/(app)/documents/page.tsx
Normal file
20
validation-suite/frontend/atlas/app/(app)/documents/page.tsx
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import { Upload } from "lucide-react";
|
||||
|
||||
export default function DocumentsPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<header>
|
||||
<h1 className="text-3xl font-semibold text-text">Dokumente</h1>
|
||||
<p className="mt-2 text-text-light">PDF, JPG, PNG, DOCX und XLSX mit Zuordnung zu Kunden, Geraeten, Validierungen und Pruefmitteln.</p>
|
||||
</header>
|
||||
<section className="flex min-h-80 items-center justify-center rounded-lg border border-dashed border-primary/40 bg-surface p-8 text-center shadow-soft">
|
||||
<div>
|
||||
<Upload className="mx-auto h-10 w-10 text-primary" />
|
||||
<h2 className="mt-4 text-xl font-semibold">Drag & Drop Upload</h2>
|
||||
<p className="mt-2 max-w-xl text-sm leading-6 text-text-light">Die Dokumentenablage ist im Backend modelliert und fuer Upload-Flows vorbereitet.</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
47
validation-suite/frontend/atlas/app/(app)/equipment/page.tsx
Normal file
47
validation-suite/frontend/atlas/app/(app)/equipment/page.tsx
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
"use client";
|
||||
|
||||
import { z } from "zod";
|
||||
import { CrudPage } from "@/components/crud-page";
|
||||
import { Equipment } from "@/lib/api";
|
||||
|
||||
const schema = z.object({
|
||||
kind: z.enum(["temperature_logger", "pressure_logger", "sensor"]),
|
||||
manufacturer: z.string().optional().nullable(),
|
||||
model: z.string().optional().nullable(),
|
||||
serial_number: z.string().min(2),
|
||||
calibrated_on: z.string().optional().nullable(),
|
||||
calibration_due_on: z.string().optional().nullable(),
|
||||
certificate_document_id: z.string().optional().nullable(),
|
||||
status: z.enum(["green", "yellow", "red"])
|
||||
});
|
||||
|
||||
export default function EquipmentPage() {
|
||||
return (
|
||||
<CrudPage<Equipment>
|
||||
title="Pruefmittel"
|
||||
subtitle="Temperaturlogger, Drucklogger, Sensoren, Kalibrierstatus und Zertifikate."
|
||||
endpoint="/equipment"
|
||||
columns={[
|
||||
{ key: "kind", label: "Art" },
|
||||
{ key: "manufacturer", label: "Hersteller" },
|
||||
{ key: "model", label: "Modell" },
|
||||
{ key: "serial_number", label: "Seriennummer" },
|
||||
{ key: "calibration_due_on", label: "Gueltig bis" },
|
||||
{ key: "status", label: "Status" }
|
||||
]}
|
||||
fields={[
|
||||
{ name: "kind", label: "Art", type: "select", options: [{ label: "Temperaturlogger", value: "temperature_logger" }, { label: "Drucklogger", value: "pressure_logger" }, { label: "Sensor", value: "sensor" }] },
|
||||
{ name: "manufacturer", label: "Hersteller" },
|
||||
{ name: "model", label: "Modell" },
|
||||
{ name: "serial_number", label: "Seriennummer" },
|
||||
{ name: "calibrated_on", label: "Kalibrierung", type: "date" },
|
||||
{ name: "calibration_due_on", label: "Kalibrierung gueltig bis", type: "date" },
|
||||
{ name: "certificate_document_id", label: "Kalibrierzertifikat Dokument-ID" },
|
||||
{ name: "status", label: "Ampel", type: "select", options: [{ label: "Gruen", value: "green" }, { label: "Gelb", value: "yellow" }, { label: "Rot", value: "red" }] }
|
||||
]}
|
||||
schema={schema}
|
||||
emptyValues={{ kind: "temperature_logger", manufacturer: "", model: "", serial_number: "", calibrated_on: "", calibration_due_on: "", certificate_document_id: "", status: "green" }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
14
validation-suite/frontend/atlas/app/(app)/layout.tsx
Normal file
14
validation-suite/frontend/atlas/app/(app)/layout.tsx
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import { AppShell } from "@/components/app-shell";
|
||||
import { AuthProvider } from "@/components/auth";
|
||||
import { QueryProvider } from "@/components/query-provider";
|
||||
|
||||
export default function WorkspaceLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<QueryProvider>
|
||||
<AppShell>{children}</AppShell>
|
||||
</QueryProvider>
|
||||
</AuthProvider>
|
||||
);
|
||||
}
|
||||
|
||||
49
validation-suite/frontend/atlas/app/(app)/locations/page.tsx
Normal file
49
validation-suite/frontend/atlas/app/(app)/locations/page.tsx
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
"use client";
|
||||
|
||||
import { z } from "zod";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAuth } from "@/components/auth";
|
||||
import { CrudPage } from "@/components/crud-page";
|
||||
import { apiGet, Customer, Location, Paginated } from "@/lib/api";
|
||||
|
||||
const schema = z.object({
|
||||
customer_id: z.string().min(1),
|
||||
name: z.string().min(2),
|
||||
street: z.string().optional().nullable(),
|
||||
postal_code: z.string().optional().nullable(),
|
||||
city: z.string().optional().nullable(),
|
||||
room: z.string().optional().nullable()
|
||||
});
|
||||
|
||||
export default function LocationsPage() {
|
||||
const { token } = useAuth();
|
||||
const customers = useQuery({
|
||||
queryKey: ["customers-options", token],
|
||||
queryFn: () => apiGet<Paginated<Customer>>("/customers?page=1&page_size=100", token ?? ""),
|
||||
enabled: Boolean(token)
|
||||
});
|
||||
const customerOptions = (customers.data?.items ?? []).map((item) => ({ label: item.name, value: item.id }));
|
||||
return (
|
||||
<CrudPage<Location>
|
||||
title="Standorte"
|
||||
subtitle="Standorte und Raeume mit Zuordnung zum Kunden."
|
||||
endpoint="/locations"
|
||||
columns={[
|
||||
{ key: "name", label: "Name" },
|
||||
{ key: "city", label: "Ort" },
|
||||
{ key: "room", label: "Raum" },
|
||||
{ key: "customer_id", label: "Kunden-ID" }
|
||||
]}
|
||||
fields={[
|
||||
{ name: "customer_id", label: "Kunde", type: "select", options: customerOptions },
|
||||
{ name: "name", label: "Name" },
|
||||
{ name: "street", label: "Adresse" },
|
||||
{ name: "postal_code", label: "PLZ" },
|
||||
{ name: "city", label: "Ort" },
|
||||
{ name: "room", label: "Raum" }
|
||||
]}
|
||||
schema={schema}
|
||||
emptyValues={{ customer_id: "", name: "", street: "", postal_code: "", city: "", room: "" }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
361
validation-suite/frontend/atlas/app/(app)/validations/page.tsx
Normal file
361
validation-suite/frontend/atlas/app/(app)/validations/page.tsx
Normal file
|
|
@ -0,0 +1,361 @@
|
|||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { ChevronDown, Download, FileText, Plus, Save, ShieldCheck, UploadCloud, X } from "lucide-react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Controller, useFieldArray, useForm, useWatch } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { useAuth } from "@/components/auth";
|
||||
import { apiGet, apiSend, Contact, Customer, Device, Equipment, Location, Paginated, ValidationItem } from "@/lib/api";
|
||||
|
||||
const triState = ["yes", "no", "na"] as const;
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
|
||||
const checklistTexts = [
|
||||
"Gebrauchsanweisung und Herstellerdokumentation vorhanden",
|
||||
"Wartungsnachweise vollstaendig",
|
||||
"Kalibrierzertifikate der Pruefmittel gueltig",
|
||||
"Aufstellbedingungen dokumentiert",
|
||||
"Wasserqualitaet dokumentiert",
|
||||
"Chargendokumentation nachvollziehbar",
|
||||
"Routinekontrollen definiert",
|
||||
"Freigabeverfahren beschrieben",
|
||||
"Personal eingewiesen",
|
||||
"Abweichungen bewertet"
|
||||
];
|
||||
|
||||
const performanceTexts = [
|
||||
"Vakuumtest entspricht Vorgaben",
|
||||
"Bowie-Dick / Leerkammerprofil entspricht Vorgaben",
|
||||
"Temperaturband innerhalb Spezifikation",
|
||||
"Haltezeit erreicht",
|
||||
"Druckverlauf plausibel",
|
||||
"Trocknungsergebnis akzeptabel",
|
||||
"Beladungsmuster reproduzierbar",
|
||||
"Sensorpositionen dokumentiert"
|
||||
];
|
||||
|
||||
const attachmentCategories = [
|
||||
"Aufbereitungsraum",
|
||||
"reiner Bereich",
|
||||
"unreiner Bereich",
|
||||
"Sterilisator",
|
||||
"Beladung",
|
||||
"Sensorposition",
|
||||
"Chargenprotokoll",
|
||||
"Indikator",
|
||||
"Zertifikat",
|
||||
"Kalibrierschein",
|
||||
"Winlog-Auswertung"
|
||||
];
|
||||
|
||||
const schema = z.object({
|
||||
report_number: z.string().min(3),
|
||||
validation_type: z.string().min(1),
|
||||
project: z.string().min(1),
|
||||
performed_on: z.string().min(1),
|
||||
test_location: z.string().min(1),
|
||||
examiner_name: z.string().min(1),
|
||||
participants: z.string().optional(),
|
||||
status: z.string().min(1),
|
||||
result: z.string().min(1),
|
||||
customer_id: z.string().min(1),
|
||||
location_id: z.string().optional().nullable(),
|
||||
contact_id: z.string().optional().nullable(),
|
||||
operator_name: z.string().optional(),
|
||||
device_id: z.string().min(1),
|
||||
equipment_ids: z.array(z.string()),
|
||||
environment_conditions: z.record(z.unknown()),
|
||||
documentation_checklist: z.array(z.record(z.unknown())),
|
||||
performance_checklist: z.array(z.record(z.unknown())),
|
||||
programs: z.array(z.record(z.unknown())),
|
||||
loading_patterns: z.array(z.record(z.unknown())),
|
||||
measurement_data: z.array(z.record(z.unknown())),
|
||||
drying: z.record(z.unknown()),
|
||||
recommendations: z.array(z.record(z.unknown())),
|
||||
attachments: z.array(z.record(z.unknown()))
|
||||
});
|
||||
|
||||
type FormValues = z.infer<typeof schema>;
|
||||
|
||||
function checklist(items: string[]) {
|
||||
return items.map((text, index) => ({ number: index + 1, text, value: "na", comment: "" }));
|
||||
}
|
||||
|
||||
function defaults(reportNumber = ""): FormValues {
|
||||
return {
|
||||
report_number: reportNumber,
|
||||
validation_type: "Erstvalidierung",
|
||||
project: "",
|
||||
performed_on: today,
|
||||
test_location: "",
|
||||
examiner_name: "",
|
||||
participants: "",
|
||||
status: "draft",
|
||||
result: "offen",
|
||||
customer_id: "",
|
||||
location_id: "",
|
||||
contact_id: "",
|
||||
operator_name: "",
|
||||
device_id: "",
|
||||
equipment_ids: [],
|
||||
environment_conditions: {
|
||||
room_temperature: "",
|
||||
humidity: "",
|
||||
test_time: "",
|
||||
checks: [
|
||||
{ text: "Raumbedingungen stabil", value: "na", comment: "" },
|
||||
{ text: "Aufstellort frei zugaenglich", value: "na", comment: "" },
|
||||
{ text: "Medienversorgung verfuegbar", value: "na", comment: "" }
|
||||
]
|
||||
},
|
||||
documentation_checklist: checklist(checklistTexts),
|
||||
performance_checklist: checklist(performanceTexts),
|
||||
programs: [
|
||||
{ name: "Vakuumtest", selected: false, custom: false },
|
||||
{ name: "Bowie-Dick / Leerkammerprofil", selected: false, custom: false },
|
||||
{ name: "134 C hohl verpackt", selected: false, custom: false }
|
||||
],
|
||||
loading_patterns: [1, 2, 3].map((run) => ({ run, pattern: "", description: "", images: [] })),
|
||||
measurement_data: ["Vakuumtest", "Leerkammerprofil", "Testlauf 1", "Testlauf 2", "Testlauf 3"].map((name) => ({
|
||||
name,
|
||||
start_time: "",
|
||||
end_time: "",
|
||||
duration: "",
|
||||
leak_rate: "",
|
||||
min_temperature: "",
|
||||
max_temperature: "",
|
||||
temperature_band: "",
|
||||
equilibration_time: "",
|
||||
holding_time: "",
|
||||
pressure: "",
|
||||
result: "",
|
||||
imports: []
|
||||
})),
|
||||
drying: { start_weight: "", end_weight: "", difference: "", rating: "", comment: "" },
|
||||
recommendations: [],
|
||||
attachments: []
|
||||
};
|
||||
}
|
||||
|
||||
function Accordion({ title, children, defaultOpen = false }: { title: string; children: React.ReactNode; defaultOpen?: boolean }) {
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
return (
|
||||
<section className="rounded-lg border border-border bg-surface shadow-soft">
|
||||
<button type="button" onClick={() => setOpen((value) => !value)} className="flex w-full items-center justify-between px-5 py-4 text-left text-lg font-semibold">
|
||||
{title}
|
||||
<ChevronDown className={`h-5 w-5 transition ${open ? "rotate-180" : ""}`} />
|
||||
</button>
|
||||
{open && <div className="border-t border-border p-5">{children}</div>}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return <label className="block"><span className="text-sm font-medium text-text">{label}</span><div className="mt-2">{children}</div></label>;
|
||||
}
|
||||
|
||||
const inputClass = "h-12 w-full rounded-lg border border-border bg-white px-4 outline-none focus:border-primary";
|
||||
const selectClass = inputClass;
|
||||
const areaClass = "min-h-24 w-full rounded-lg border border-border bg-white px-4 py-3 outline-none focus:border-primary";
|
||||
|
||||
export default function ValidationsPage() {
|
||||
const { token } = useAuth();
|
||||
const client = useQueryClient();
|
||||
const [draftId, setDraftId] = useState<string | null>(null);
|
||||
const [lastSaved, setLastSaved] = useState<string>("");
|
||||
const autosaveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const nextNumber = useQuery({ queryKey: ["next-report-number", token], queryFn: () => apiGet<{ report_number: string }>("/validations/next-report-number", token ?? ""), enabled: Boolean(token) });
|
||||
const customers = useQuery({ queryKey: ["customers-options", token], queryFn: () => apiGet<Paginated<Customer>>("/customers?page=1&page_size=100", token ?? ""), enabled: Boolean(token) });
|
||||
const locations = useQuery({ queryKey: ["locations-options", token], queryFn: () => apiGet<Paginated<Location>>("/locations?page=1&page_size=100", token ?? ""), enabled: Boolean(token) });
|
||||
const contacts = useQuery({ queryKey: ["contacts-options", token], queryFn: () => apiGet<Paginated<Contact>>("/contacts?page=1&page_size=100", token ?? ""), enabled: Boolean(token) });
|
||||
const devices = useQuery({ queryKey: ["devices-options", token], queryFn: () => apiGet<Paginated<Device>>("/devices?page=1&page_size=100", token ?? ""), enabled: Boolean(token) });
|
||||
const equipment = useQuery({ queryKey: ["equipment-options", token], queryFn: () => apiGet<Paginated<Equipment>>("/equipment?page=1&page_size=100", token ?? ""), enabled: Boolean(token) });
|
||||
|
||||
const form = useForm<FormValues>({ resolver: zodResolver(schema), defaultValues: defaults() });
|
||||
const watched = useWatch({ control: form.control });
|
||||
const selectedDevice = devices.data?.items.find((item) => item.id === form.watch("device_id"));
|
||||
const selectedEquipment = equipment.data?.items.filter((item) => form.watch("equipment_ids").includes(item.id)) ?? [];
|
||||
const recommendations = useFieldArray({ control: form.control, name: "recommendations" });
|
||||
const attachments = useFieldArray({ control: form.control, name: "attachments" });
|
||||
|
||||
useEffect(() => {
|
||||
if (nextNumber.data?.report_number && !form.getValues("report_number")) {
|
||||
form.setValue("report_number", nextNumber.data.report_number);
|
||||
}
|
||||
}, [form, nextNumber.data]);
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: (values: FormValues) => apiSend<ValidationItem>(draftId ? `/validations/${draftId}` : "/validations", token ?? "", draftId ? "PUT" : "POST", values),
|
||||
onSuccess: (item) => {
|
||||
setDraftId(item.id);
|
||||
setLastSaved(new Date().toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" }));
|
||||
client.invalidateQueries({ queryKey: ["dashboard"] });
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!token || !watched.report_number || !watched.customer_id || !watched.device_id || !watched.project || !watched.performed_on) return;
|
||||
if (autosaveTimer.current) clearTimeout(autosaveTimer.current);
|
||||
autosaveTimer.current = setTimeout(() => {
|
||||
const values = form.getValues();
|
||||
saveMutation.mutate(values);
|
||||
}, 2500);
|
||||
return () => {
|
||||
if (autosaveTimer.current) clearTimeout(autosaveTimer.current);
|
||||
};
|
||||
}, [form, saveMutation, token, watched]);
|
||||
|
||||
const customerOptions = customers.data?.items ?? [];
|
||||
const selectedCustomer = customerOptions.find((item) => item.id === form.watch("customer_id"));
|
||||
const filteredLocations = (locations.data?.items ?? []).filter((item) => !form.watch("customer_id") || item.customer_id === form.watch("customer_id"));
|
||||
const filteredContacts = (contacts.data?.items ?? []).filter((item) => !form.watch("customer_id") || item.customer_id === form.watch("customer_id"));
|
||||
const filteredDevices = (devices.data?.items ?? []).filter((item) => !form.watch("customer_id") || item.customer_id === form.watch("customer_id"));
|
||||
|
||||
const calibrationWarnings = useMemo(() => selectedEquipment.filter((item) => item.calibration_due_on && item.calibration_due_on < today), [selectedEquipment]);
|
||||
|
||||
function submit(values: FormValues) {
|
||||
saveMutation.mutate(values);
|
||||
}
|
||||
|
||||
function addFiles(files: FileList | null, category: string) {
|
||||
if (!files) return;
|
||||
Array.from(files).forEach((file, index) => attachments.append({ category, filename: file.name, description: "", order: attachments.fields.length + index + 1, preview: URL.createObjectURL(file) }));
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={form.handleSubmit(submit)} className="space-y-5 pb-28">
|
||||
<header>
|
||||
<h1 className="text-3xl font-semibold text-text">Validierungsbericht</h1>
|
||||
<p className="mt-2 text-text-light">Eine responsive Seite fuer Erfassung, Pruefung und Berichtserstellung.</p>
|
||||
</header>
|
||||
|
||||
<Accordion title="1. Allgemeine Angaben" defaultOpen>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Field label="Berichtsnummer"><input className={inputClass} {...form.register("report_number")} /></Field>
|
||||
<Field label="Validierungsart"><select className={selectClass} {...form.register("validation_type")}><option>Erstvalidierung</option><option>Revalidierung</option><option>Leistungsbeurteilung</option><option>Sonderpruefung</option></select></Field>
|
||||
<Field label="Projekt"><input className={inputClass} {...form.register("project")} /></Field>
|
||||
<Field label="Pruefdatum"><input type="date" className={inputClass} {...form.register("performed_on")} /></Field>
|
||||
<Field label="Pruefungsort"><input className={inputClass} {...form.register("test_location")} /></Field>
|
||||
<Field label="Pruefer"><input className={inputClass} {...form.register("examiner_name")} /></Field>
|
||||
<Field label="Mitwirkende Personen"><textarea className={areaClass} {...form.register("participants")} /></Field>
|
||||
<Field label="Status"><select className={selectClass} {...form.register("status")}><option value="draft">Entwurf</option><option value="in_progress">In Pruefung</option><option value="ready_for_report">Bericht bereit</option><option value="completed">Abgeschlossen</option></select></Field>
|
||||
<Field label="Gesamtergebnis"><select className={selectClass} {...form.register("result")}><option value="offen">Offen</option><option value="bestanden">Bestanden</option><option value="nicht_bestanden">Nicht bestanden</option><option value="mit_auflagen">Mit Auflagen</option></select></Field>
|
||||
</div>
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="2. Kunde und Standort">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Field label="Kunde"><select className={selectClass} {...form.register("customer_id")}><option value="">Bitte waehlen</option>{customerOptions.map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}</select></Field>
|
||||
<Field label="Standort"><select className={selectClass} {...form.register("location_id")}><option value="">Bitte waehlen</option>{filteredLocations.map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}</select></Field>
|
||||
<Field label="Ansprechpartner"><select className={selectClass} {...form.register("contact_id")}><option value="">Bitte waehlen</option>{filteredContacts.map((item) => <option key={item.id} value={item.id}>{item.full_name}</option>)}</select></Field>
|
||||
<Field label="Betreiber"><input className={inputClass} {...form.register("operator_name")} /></Field>
|
||||
<Field label="QM-/Hygienebeauftragter"><input className={inputClass} value={[selectedCustomer?.quality_manager, selectedCustomer?.hygiene_officer].filter(Boolean).join(" / ")} readOnly /></Field>
|
||||
</div>
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="3. Geraet">
|
||||
<Field label="Geraet"><select className={selectClass} {...form.register("device_id")}><option value="">Bitte waehlen</option>{filteredDevices.map((item) => <option key={item.id} value={item.id}>{item.manufacturer} {item.model} - {item.serial_number}</option>)}</select></Field>
|
||||
<div className="mt-4 grid gap-4 md:grid-cols-3">{[
|
||||
["Hersteller", selectedDevice?.manufacturer],
|
||||
["Modell", selectedDevice?.model],
|
||||
["Seriennummer", selectedDevice?.serial_number],
|
||||
["Baujahr", selectedDevice?.year_built],
|
||||
["Inbetriebnahme", selectedDevice?.commissioned_on],
|
||||
["Kammervolumen", selectedDevice?.chamber_volume_liters],
|
||||
["Dampferzeugung", selectedDevice?.steam_generation],
|
||||
["Wasseraufbereitung", selectedDevice?.water_treatment],
|
||||
["Dokumentation", selectedDevice?.documentation],
|
||||
["Lieferant", selectedDevice?.supplier]
|
||||
].map(([label, value]) => <div key={label as string} className="rounded-lg border border-border bg-background p-4"><p className="text-xs text-text-light">{label}</p><p className="mt-1 font-medium">{String(value ?? "-")}</p></div>)}</div>
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="4. Pruefmittel">
|
||||
<Controller control={form.control} name="equipment_ids" render={({ field }) => (
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
{(equipment.data?.items ?? []).map((item) => {
|
||||
const expired = Boolean(item.calibration_due_on && item.calibration_due_on < today);
|
||||
return <label key={item.id} className="flex items-start gap-3 rounded-lg border border-border p-4"><input type="checkbox" className="mt-1 h-5 w-5" checked={field.value.includes(item.id)} onChange={(event) => field.onChange(event.target.checked ? [...field.value, item.id] : field.value.filter((id: string) => id !== item.id))} /><span><strong>{item.kind}</strong><br />{item.serial_number} · {item.calibrated_on ?? "-"} · {item.status}{expired && <span className="ml-2 text-danger">Kalibrierung abgelaufen</span>}</span></label>;
|
||||
})}
|
||||
</div>
|
||||
)} />
|
||||
{calibrationWarnings.length > 0 && <p className="mt-4 rounded-lg border border-danger/30 bg-danger/5 p-4 text-danger">Mindestens ein ausgewaehltes Pruefmittel hat eine abgelaufene Kalibrierung.</p>}
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="5. Umgebungsbedingungen">
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<Field label="Raumtemperatur"><input className={inputClass} {...form.register("environment_conditions.room_temperature")} /></Field>
|
||||
<Field label="relative Luftfeuchtigkeit"><input className={inputClass} {...form.register("environment_conditions.humidity")} /></Field>
|
||||
<Field label="Pruefzeit"><input className={inputClass} {...form.register("environment_conditions.test_time")} /></Field>
|
||||
</div>
|
||||
{[0, 1, 2].map((index) => <ChecklistRow key={index} form={form} path={`environment_conditions.checks.${index}`} />)}
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="6. Dokumentations- und Leistungschecklisten">
|
||||
<h3 className="font-semibold">Dokumentation</h3>{checklistTexts.map((_, index) => <ChecklistRow key={index} form={form} path={`documentation_checklist.${index}`} />)}
|
||||
<h3 className="mt-6 font-semibold">Leistung</h3>{performanceTexts.map((_, index) => <ChecklistRow key={index} form={form} path={`performance_checklist.${index}`} />)}
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="7. Programme">
|
||||
{[0, 1, 2].map((index) => <ProgramRow key={index} form={form} index={index} />)}
|
||||
<button type="button" onClick={() => form.setValue("programs", [...form.getValues("programs"), { name: "", selected: true, custom: true }])} className="mt-4 inline-flex items-center gap-2 rounded-lg border border-border px-4 py-3 font-semibold"><Plus className="h-4 w-4" /> Eigenes Programm</button>
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="8. Beladungsmuster">
|
||||
{[0, 1, 2].map((index) => <LoadingRun key={index} form={form} index={index} />)}
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="9. Messdaten">
|
||||
{[0, 1, 2, 3, 4].map((index) => <MeasurementBlock key={index} form={form} index={index} />)}
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="10. Trocknung">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{["start_weight", "end_weight", "difference", "rating"].map((name) => <Field key={name} label={name}><input className={inputClass} {...form.register(`drying.${name}`)} /></Field>)}
|
||||
<Field label="Bemerkung"><textarea className={areaClass} {...form.register("drying.comment")} /></Field>
|
||||
</div>
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="11. Empfehlungen und Auflagen">
|
||||
{recommendations.fields.map((field, index) => <div key={field.id} className="mb-3 grid gap-3 rounded-lg border border-border p-4 md:grid-cols-4"><input className={inputClass} placeholder="Nummer" {...form.register(`recommendations.${index}.number`)} /><input className={inputClass} placeholder="Text" {...form.register(`recommendations.${index}.text`)} /><input type="date" className={inputClass} {...form.register(`recommendations.${index}.deadline`)} /><input className={inputClass} placeholder="Status" {...form.register(`recommendations.${index}.status`)} /></div>)}
|
||||
<button type="button" onClick={() => recommendations.append({ number: recommendations.fields.length + 1, text: "", deadline: "", status: "offen" })} className="inline-flex items-center gap-2 rounded-lg border border-border px-4 py-3 font-semibold"><Plus className="h-4 w-4" /> Zeile hinzufuegen</button>
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="12. Bilder und Anlagen">
|
||||
<div className="grid gap-3 md:grid-cols-2">{attachmentCategories.map((category) => <label key={category} className="rounded-lg border border-dashed border-primary/40 p-4"><UploadCloud className="mb-2 h-5 w-5 text-primary" />{category}<input type="file" multiple className="mt-3 block w-full text-sm" onChange={(event) => addFiles(event.target.files, category)} /></label>)}</div>
|
||||
<div className="mt-5 grid gap-3 md:grid-cols-2">{attachments.fields.map((field, index) => <div key={field.id} className="rounded-lg border border-border p-4"><div className="flex justify-between gap-3"><strong>{String(form.watch(`attachments.${index}.filename`) ?? "")}</strong><button type="button" onClick={() => attachments.remove(index)}><X className="h-4 w-4 text-danger" /></button></div><input className={`${inputClass} mt-3`} placeholder="Beschreibung" {...form.register(`attachments.${index}.description`)} /><input className={`${inputClass} mt-3`} placeholder="Reihenfolge" {...form.register(`attachments.${index}.order`)} /></div>)}</div>
|
||||
</Accordion>
|
||||
|
||||
<div className="fixed inset-x-0 bottom-0 z-40 border-t border-border bg-surface/95 px-4 py-3 shadow-soft backdrop-blur">
|
||||
<div className="mx-auto flex max-w-7xl flex-wrap items-center justify-between gap-3">
|
||||
<span className="text-sm text-text-light">{lastSaved ? `Automatisch gespeichert um ${lastSaved}` : "Autosave aktiv, sobald Pflichtfelder ausgefuellt sind."}</span>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button type="submit" className="inline-flex h-12 items-center gap-2 rounded-lg border border-border px-4 font-semibold"><Save className="h-4 w-4" /> Entwurf speichern</button>
|
||||
<button type="button" onClick={() => form.setValue("status", "ready_for_report")} className="inline-flex h-12 items-center gap-2 rounded-lg border border-border px-4 font-semibold"><ShieldCheck className="h-4 w-4" /> Validierung pruefen</button>
|
||||
<button type="button" className="inline-flex h-12 items-center gap-2 rounded-lg bg-primary px-4 font-semibold text-white shadow-soft"><FileText className="h-4 w-4" /> Bericht erzeugen</button>
|
||||
<button type="button" className="inline-flex h-12 items-center gap-2 rounded-lg border border-border px-4 font-semibold"><Download className="h-4 w-4" /> Bericht herunterladen</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function ChecklistRow({ form, path }: { form: ReturnType<typeof useForm<FormValues>>; path: string }) {
|
||||
return <div className="mt-3 grid gap-3 rounded-lg border border-border p-4 md:grid-cols-[1fr_180px_1fr]"><input className={inputClass} readOnly {...form.register(`${path}.text` as never)} /><select className={selectClass} {...form.register(`${path}.value` as never)}>{triState.map((value) => <option key={value} value={value}>{value === "yes" ? "Ja" : value === "no" ? "Nein" : "Nicht zutreffend"}</option>)}</select><input className={inputClass} placeholder="Kommentar" {...form.register(`${path}.comment` as never)} /></div>;
|
||||
}
|
||||
|
||||
function ProgramRow({ form, index }: { form: ReturnType<typeof useForm<FormValues>>; index: number }) {
|
||||
return <div className="mb-3 grid gap-3 rounded-lg border border-border p-4 md:grid-cols-[80px_1fr]"><input type="checkbox" className="h-6 w-6" {...form.register(`programs.${index}.selected`)} /><input className={inputClass} {...form.register(`programs.${index}.name`)} /></div>;
|
||||
}
|
||||
|
||||
function LoadingRun({ form, index }: { form: ReturnType<typeof useForm<FormValues>>; index: number }) {
|
||||
return <div className="mb-4 rounded-lg border border-border p-4"><h3 className="font-semibold">Testlauf {index + 1}</h3><div className="mt-3 grid gap-3 md:grid-cols-2"><input className={inputClass} placeholder="Beladungsmuster" {...form.register(`loading_patterns.${index}.pattern`)} /><input className={inputClass} placeholder="Beschreibung" {...form.register(`loading_patterns.${index}.description`)} /><input type="file" multiple className="rounded-lg border border-border p-3 md:col-span-2" /></div></div>;
|
||||
}
|
||||
|
||||
function MeasurementBlock({ form, index }: { form: ReturnType<typeof useForm<FormValues>>; index: number }) {
|
||||
const fields = ["start_time", "end_time", "duration", "leak_rate", "min_temperature", "max_temperature", "temperature_band", "equilibration_time", "holding_time", "pressure", "result"];
|
||||
return <div className="mb-4 rounded-lg border border-border p-4"><h3 className="font-semibold">{String(form.watch(`measurement_data.${index}.name`) ?? "")}</h3><div className="mt-3 grid gap-3 md:grid-cols-3">{fields.map((field) => <input key={field} className={inputClass} placeholder={field} {...form.register(`measurement_data.${index}.${field}`)} />)}<input type="file" multiple accept=".csv,.pdf" className="rounded-lg border border-border p-3 md:col-span-3" /></div></div>;
|
||||
}
|
||||
76
validation-suite/frontend/atlas/app/(auth)/login/page.tsx
Normal file
76
validation-suite/frontend/atlas/app/(auth)/login/page.tsx
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { LogIn } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { AuthProvider, useAuth } from "@/components/auth";
|
||||
import { login } from "@/lib/api";
|
||||
|
||||
const schema = z.object({
|
||||
email: z.string().email(),
|
||||
password: z.string().min(8)
|
||||
});
|
||||
|
||||
type LoginForm = z.infer<typeof schema>;
|
||||
|
||||
function LoginPanel() {
|
||||
const router = useRouter();
|
||||
const auth = useAuth();
|
||||
const form = useForm<LoginForm>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: { email: "admin@schubamed.de", password: "" }
|
||||
});
|
||||
|
||||
async function onSubmit(values: LoginForm) {
|
||||
const result = await login(values.email, values.password);
|
||||
auth.setToken(result.access_token);
|
||||
router.push("/dashboard");
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="flex min-h-screen items-center justify-center bg-background px-4 py-10">
|
||||
<section className="w-full max-w-md rounded-lg border border-border bg-surface p-8 shadow-soft">
|
||||
<div className="mb-8">
|
||||
<p className="text-sm font-medium text-primary">Validation Suite</p>
|
||||
<h1 className="mt-2 text-3xl font-semibold text-text">Anmelden</h1>
|
||||
<p className="mt-3 text-sm leading-6 text-text-light">Sicherer Zugriff auf Atlas Workspace.</p>
|
||||
</div>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-5">
|
||||
<label className="block">
|
||||
<span className="text-sm font-medium text-text">E-Mail</span>
|
||||
<input
|
||||
type="email"
|
||||
className="mt-2 h-12 w-full rounded-lg border border-border bg-white px-4 outline-none transition focus:border-primary"
|
||||
{...form.register("email")}
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="text-sm font-medium text-text">Passwort</span>
|
||||
<input
|
||||
type="password"
|
||||
className="mt-2 h-12 w-full rounded-lg border border-border bg-white px-4 outline-none transition focus:border-primary"
|
||||
{...form.register("password")}
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="submit"
|
||||
className="flex h-12 w-full items-center justify-center gap-2 rounded-lg bg-primary px-5 font-semibold text-white shadow-soft transition hover:bg-primary-dark"
|
||||
>
|
||||
<LogIn className="h-5 w-5" />
|
||||
Einloggen
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
export default function LoginPage() {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<LoginPanel />
|
||||
</AuthProvider>
|
||||
);
|
||||
}
|
||||
27
validation-suite/frontend/atlas/app/globals.css
Normal file
27
validation-suite/frontend/atlas/app/globals.css
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
color: #2E3B40;
|
||||
background: #F7F8F8;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
background: #F7F8F8;
|
||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
16
validation-suite/frontend/atlas/app/layout.tsx
Normal file
16
validation-suite/frontend/atlas/app/layout.tsx
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import type { Metadata } from "next";
|
||||
import "./globals.css";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Validation Suite",
|
||||
description: "Professionelle Validierungsplattform fuer medizinische Prozesse"
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
|
||||
return (
|
||||
<html lang="de">
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
6
validation-suite/frontend/atlas/app/page.tsx
Normal file
6
validation-suite/frontend/atlas/app/page.tsx
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function Home() {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
72
validation-suite/frontend/atlas/components/app-shell.tsx
Normal file
72
validation-suite/frontend/atlas/components/app-shell.tsx
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
"use client";
|
||||
|
||||
import {
|
||||
Building2,
|
||||
ClipboardCheck,
|
||||
FileArchive,
|
||||
Gauge,
|
||||
LayoutDashboard,
|
||||
LogOut,
|
||||
MapPin,
|
||||
Stethoscope,
|
||||
UserRound
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useAuth } from "@/components/auth";
|
||||
|
||||
const navigation = [
|
||||
{ href: "/dashboard", label: "Dashboard", icon: LayoutDashboard },
|
||||
{ href: "/customers", label: "Kunden", icon: Building2 },
|
||||
{ href: "/locations", label: "Standorte", icon: MapPin },
|
||||
{ href: "/contacts", label: "Ansprechpartner", icon: UserRound },
|
||||
{ href: "/devices", label: "Geraete", icon: Stethoscope },
|
||||
{ href: "/equipment", label: "Pruefmittel", icon: Gauge },
|
||||
{ href: "/validations", label: "Validierungen", icon: ClipboardCheck },
|
||||
{ href: "/documents", label: "Dokumente", icon: FileArchive }
|
||||
];
|
||||
|
||||
export function AppShell({ children }: { children: React.ReactNode }) {
|
||||
const pathname = usePathname();
|
||||
const auth = useAuth();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background text-text">
|
||||
<aside className="fixed inset-y-0 left-0 z-30 hidden w-72 border-r border-border bg-surface px-5 py-6 lg:block">
|
||||
<div className="mb-8">
|
||||
<div className="text-xl font-semibold text-primary-dark">Validation Suite</div>
|
||||
<div className="mt-1 text-sm text-text-light">Atlas Workspace</div>
|
||||
</div>
|
||||
<nav className="space-y-1">
|
||||
{navigation.map((item, index) => {
|
||||
const Icon = item.icon;
|
||||
const active = pathname === item.href || pathname.startsWith(`${item.href}/`);
|
||||
return (
|
||||
<Link
|
||||
key={`${item.label}-${index}`}
|
||||
href={item.href}
|
||||
className={`flex h-11 items-center gap-3 rounded-lg px-3 text-sm font-medium transition ${
|
||||
active ? "bg-accent/35 text-primary-dark" : "text-text-light hover:bg-background hover:text-text"
|
||||
}`}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
<span>{item.label}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
<button
|
||||
type="button"
|
||||
onClick={auth.logout}
|
||||
className="absolute bottom-6 left-5 right-5 flex h-11 items-center justify-center gap-2 rounded-lg bg-primary px-4 text-sm font-semibold text-white shadow-soft"
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
Logout
|
||||
</button>
|
||||
</aside>
|
||||
<main className="lg:pl-72">
|
||||
<div className="mx-auto min-h-screen max-w-7xl px-4 py-5 sm:px-6 lg:px-10 lg:py-8">{children}</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
51
validation-suite/frontend/atlas/components/auth.tsx
Normal file
51
validation-suite/frontend/atlas/components/auth.tsx
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { createContext, useContext, useEffect, useMemo, useState } from "react";
|
||||
|
||||
type AuthContextValue = {
|
||||
token: string | null;
|
||||
setToken: (value: string | null) => void;
|
||||
logout: () => void;
|
||||
};
|
||||
|
||||
const AuthContext = createContext<AuthContextValue | null>(null);
|
||||
|
||||
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
const router = useRouter();
|
||||
const [token, setTokenState] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setTokenState(window.localStorage.getItem("atlas_token"));
|
||||
}, []);
|
||||
|
||||
const value = useMemo<AuthContextValue>(() => {
|
||||
const setToken = (next: string | null) => {
|
||||
setTokenState(next);
|
||||
if (next) {
|
||||
window.localStorage.setItem("atlas_token", next);
|
||||
} else {
|
||||
window.localStorage.removeItem("atlas_token");
|
||||
}
|
||||
};
|
||||
return {
|
||||
token,
|
||||
setToken,
|
||||
logout: () => {
|
||||
setToken(null);
|
||||
router.push("/login");
|
||||
}
|
||||
};
|
||||
}, [router, token]);
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const value = useContext(AuthContext);
|
||||
if (!value) {
|
||||
throw new Error("useAuth must be used inside AuthProvider");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
195
validation-suite/frontend/atlas/components/crud-page.tsx
Normal file
195
validation-suite/frontend/atlas/components/crud-page.tsx
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Edit2, Search, Trash2, X } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { useAuth } from "@/components/auth";
|
||||
import { apiDelete, apiGet, apiSend, Entity, Paginated } from "@/lib/api";
|
||||
|
||||
export type FieldOption = { label: string; value: string };
|
||||
export type FieldConfig = {
|
||||
name: string;
|
||||
label: string;
|
||||
type?: "text" | "email" | "number" | "date" | "textarea" | "select";
|
||||
required?: boolean;
|
||||
options?: FieldOption[];
|
||||
};
|
||||
export type ColumnConfig<T> = { key: keyof T; label: string };
|
||||
|
||||
function valueForInput(value: unknown) {
|
||||
return value === null || value === undefined ? "" : String(value);
|
||||
}
|
||||
|
||||
export function CrudPage<T extends Entity>({
|
||||
title,
|
||||
subtitle,
|
||||
endpoint,
|
||||
columns,
|
||||
fields,
|
||||
schema,
|
||||
emptyValues
|
||||
}: {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
endpoint: string;
|
||||
columns: ColumnConfig<T>[];
|
||||
fields: FieldConfig[];
|
||||
schema: z.ZodTypeAny;
|
||||
emptyValues: Record<string, unknown>;
|
||||
}) {
|
||||
const { token } = useAuth();
|
||||
const client = useQueryClient();
|
||||
const [page, setPage] = useState(1);
|
||||
const [search, setSearch] = useState("");
|
||||
const [editing, setEditing] = useState<T | null>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const pageSize = 10;
|
||||
const queryPath = `${endpoint}?page=${page}&page_size=${pageSize}&search=${encodeURIComponent(search)}`;
|
||||
const form = useForm<Record<string, unknown>>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: emptyValues
|
||||
});
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: [endpoint, page, pageSize, search, token],
|
||||
queryFn: () => apiGet<Paginated<T>>(queryPath, token ?? ""),
|
||||
enabled: Boolean(token)
|
||||
});
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil((query.data?.total ?? 0) / pageSize));
|
||||
const invalidate = () => client.invalidateQueries({ queryKey: [endpoint] });
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: (values: Record<string, unknown>) => {
|
||||
const cleaned = Object.fromEntries(
|
||||
Object.entries(values).map(([key, value]) => [key, value === "" ? null : value])
|
||||
);
|
||||
return apiSend<T>(editing ? `${endpoint}/${editing.id}` : endpoint, token ?? "", editing ? "PUT" : "POST", cleaned);
|
||||
},
|
||||
onSuccess: () => {
|
||||
setOpen(false);
|
||||
setEditing(null);
|
||||
form.reset(emptyValues);
|
||||
invalidate();
|
||||
}
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (item: T) => apiDelete(`${endpoint}/${item.id}`, token ?? ""),
|
||||
onSuccess: invalidate
|
||||
});
|
||||
|
||||
const rows = useMemo(() => query.data?.items ?? [], [query.data]);
|
||||
|
||||
function startCreate() {
|
||||
setEditing(null);
|
||||
form.reset(emptyValues);
|
||||
setOpen(true);
|
||||
}
|
||||
|
||||
function startEdit(item: T) {
|
||||
setEditing(item);
|
||||
form.reset(Object.fromEntries(Object.keys(emptyValues).map((key) => [key, valueForInput(item[key as keyof T])])));
|
||||
setOpen(true);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<header className="flex flex-col justify-between gap-4 sm:flex-row sm:items-center">
|
||||
<div>
|
||||
<h1 className="text-3xl font-semibold text-text">{title}</h1>
|
||||
<p className="mt-2 text-text-light">{subtitle}</p>
|
||||
</div>
|
||||
<button onClick={startCreate} className="h-12 rounded-lg bg-primary px-5 font-semibold text-white shadow-soft">Neu</button>
|
||||
</header>
|
||||
|
||||
<div className="flex items-center gap-3 rounded-lg border border-border bg-surface px-4 py-3 shadow-soft">
|
||||
<Search className="h-5 w-5 text-primary" />
|
||||
<input
|
||||
value={search}
|
||||
onChange={(event) => {
|
||||
setPage(1);
|
||||
setSearch(event.target.value);
|
||||
}}
|
||||
placeholder="Suchen"
|
||||
className="h-9 flex-1 bg-transparent outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<section className="overflow-hidden rounded-lg border border-border bg-surface shadow-soft">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full text-left text-sm">
|
||||
<thead className="bg-background text-xs uppercase text-text-light">
|
||||
<tr>
|
||||
{columns.map((column) => <th key={String(column.key)} className="px-5 py-4">{column.label}</th>)}
|
||||
<th className="px-5 py-4 text-right">Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{rows.map((item) => (
|
||||
<tr key={item.id}>
|
||||
{columns.map((column) => <td key={String(column.key)} className="whitespace-nowrap px-5 py-4">{valueForInput(item[column.key])}</td>)}
|
||||
<td className="px-5 py-4">
|
||||
<div className="flex justify-end gap-2">
|
||||
<button aria-label="Bearbeiten" onClick={() => startEdit(item)} className="rounded-lg border border-border p-2 text-primary-dark"><Edit2 className="h-4 w-4" /></button>
|
||||
<button aria-label="Loeschen" onClick={() => deleteMutation.mutate(item)} className="rounded-lg border border-border p-2 text-danger"><Trash2 className="h-4 w-4" /></button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{!query.isLoading && rows.length === 0 && (
|
||||
<tr><td colSpan={columns.length + 1} className="px-5 py-10 text-center text-text-light">Keine Datensaetze gefunden.</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer className="flex items-center justify-between text-sm text-text-light">
|
||||
<span>{query.data?.total ?? 0} Datensaetze</span>
|
||||
<div className="flex items-center gap-3">
|
||||
<button disabled={page <= 1} onClick={() => setPage((value) => value - 1)} className="rounded-lg border border-border px-4 py-2 disabled:opacity-40">Zurueck</button>
|
||||
<span>Seite {page} von {totalPages}</span>
|
||||
<button disabled={page >= totalPages} onClick={() => setPage((value) => value + 1)} className="rounded-lg border border-border px-4 py-2 disabled:opacity-40">Weiter</button>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
{open && (
|
||||
<div className="fixed inset-0 z-50 flex items-end bg-black/20 p-3 sm:items-center sm:justify-center">
|
||||
<form onSubmit={form.handleSubmit((values) => saveMutation.mutate(values))} className="max-h-[92vh] w-full max-w-3xl overflow-y-auto rounded-lg bg-surface p-6 shadow-soft">
|
||||
<div className="mb-5 flex items-center justify-between">
|
||||
<h2 className="text-xl font-semibold">{editing ? "Bearbeiten" : "Neu"}</h2>
|
||||
<button type="button" onClick={() => setOpen(false)} className="rounded-lg border border-border p-2"><X className="h-4 w-4" /></button>
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{fields.map((field) => (
|
||||
<label key={field.name} className={field.type === "textarea" ? "sm:col-span-2" : ""}>
|
||||
<span className="text-sm font-medium">{field.label}</span>
|
||||
{field.type === "select" ? (
|
||||
<select {...form.register(field.name)} className="mt-2 h-11 w-full rounded-lg border border-border px-3">
|
||||
<option value="">Bitte waehlen</option>
|
||||
{field.options?.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
|
||||
</select>
|
||||
) : field.type === "textarea" ? (
|
||||
<textarea {...form.register(field.name)} className="mt-2 min-h-24 w-full rounded-lg border border-border px-3 py-2" />
|
||||
) : (
|
||||
<input type={field.type ?? "text"} {...form.register(field.name)} className="mt-2 h-11 w-full rounded-lg border border-border px-3" />
|
||||
)}
|
||||
{form.formState.errors[field.name] && <span className="mt-1 block text-xs text-danger">Bitte gueltig ausfuellen.</span>}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
{saveMutation.isError && <p className="mt-4 text-sm text-danger">Speichern fehlgeschlagen. Bitte Eingaben pruefen.</p>}
|
||||
<div className="mt-6 flex justify-end gap-3">
|
||||
<button type="button" onClick={() => setOpen(false)} className="rounded-lg border border-border px-5 py-3 font-semibold">Abbrechen</button>
|
||||
<button type="submit" className="rounded-lg bg-primary px-5 py-3 font-semibold text-white shadow-soft">Speichern</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
44
validation-suite/frontend/atlas/components/data-table.tsx
Normal file
44
validation-suite/frontend/atlas/components/data-table.tsx
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
"use client";
|
||||
|
||||
import {
|
||||
ColumnDef,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
useReactTable
|
||||
} from "@tanstack/react-table";
|
||||
|
||||
export function DataTable<T>({ data, columns }: { data: T[]; columns: ColumnDef<T>[] }) {
|
||||
const table = useReactTable({ data, columns, getCoreRowModel: getCoreRowModel() });
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden rounded-lg border border-border bg-surface shadow-soft">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full border-collapse text-left text-sm">
|
||||
<thead className="bg-background text-xs uppercase tracking-wide text-text-light">
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<tr key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<th key={header.id} className="px-5 py-4 font-semibold">
|
||||
{flexRender(header.column.columnDef.header, header.getContext())}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{table.getRowModel().rows.map((row) => (
|
||||
<tr key={row.id} className="hover:bg-background/70">
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<td key={cell.id} className="whitespace-nowrap px-5 py-4 text-text">
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
"use client";
|
||||
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
|
||||
export function QueryProvider({ children }: { children: React.ReactNode }) {
|
||||
const [client] = useState(() => new QueryClient());
|
||||
return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
|
||||
}
|
||||
|
||||
50
validation-suite/frontend/atlas/components/resource-page.tsx
Normal file
50
validation-suite/frontend/atlas/components/resource-page.tsx
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { Plus } from "lucide-react";
|
||||
import { useAuth } from "@/components/auth";
|
||||
import { DataTable } from "@/components/data-table";
|
||||
import { apiGet } from "@/lib/api";
|
||||
|
||||
export function ResourcePage<T>({
|
||||
title,
|
||||
subtitle,
|
||||
path,
|
||||
columns
|
||||
}: {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
path: string;
|
||||
columns: ColumnDef<T>[];
|
||||
}) {
|
||||
const { token } = useAuth();
|
||||
const query = useQuery({
|
||||
queryKey: [path, token],
|
||||
queryFn: () => apiGet<T[]>(path, token ?? ""),
|
||||
enabled: Boolean(token)
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<header className="flex flex-col justify-between gap-4 sm:flex-row sm:items-center">
|
||||
<div>
|
||||
<h1 className="text-3xl font-semibold text-text">{title}</h1>
|
||||
<p className="mt-2 text-text-light">{subtitle}</p>
|
||||
</div>
|
||||
<button type="button" className="inline-flex h-12 items-center justify-center gap-2 rounded-lg bg-primary px-5 font-semibold text-white shadow-soft">
|
||||
<Plus className="h-5 w-5" />
|
||||
Neu
|
||||
</button>
|
||||
</header>
|
||||
{query.isLoading ? (
|
||||
<div className="rounded-lg border border-border bg-surface p-8 text-text-light shadow-soft">Daten werden geladen.</div>
|
||||
) : query.isError ? (
|
||||
<div className="rounded-lg border border-danger/30 bg-surface p-8 text-danger shadow-soft">Daten konnten nicht geladen werden.</div>
|
||||
) : (
|
||||
<DataTable data={query.data ?? []} columns={columns} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
144
validation-suite/frontend/atlas/lib/api.ts
Normal file
144
validation-suite/frontend/atlas/lib/api.ts
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
export const API_BASE = process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8000/api/v1";
|
||||
|
||||
export type Entity = {
|
||||
id: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type Customer = Entity & {
|
||||
customer_type: "practice" | "clinic";
|
||||
name: string;
|
||||
city?: string | null;
|
||||
phone?: string | null;
|
||||
email?: string | null;
|
||||
hygiene_officer?: string | null;
|
||||
quality_manager?: string | null;
|
||||
notes?: string | null;
|
||||
};
|
||||
|
||||
export type Location = Entity & {
|
||||
customer_id: string;
|
||||
name: string;
|
||||
street?: string | null;
|
||||
postal_code?: string | null;
|
||||
city?: string | null;
|
||||
room?: string | null;
|
||||
};
|
||||
|
||||
export type Contact = Entity & {
|
||||
customer_id: string;
|
||||
full_name: string;
|
||||
function?: string | null;
|
||||
email?: string | null;
|
||||
phone?: string | null;
|
||||
};
|
||||
|
||||
export type Device = Entity & {
|
||||
customer_id: string;
|
||||
location_id?: string | null;
|
||||
manufacturer: string;
|
||||
model: string;
|
||||
serial_number: string;
|
||||
device_type?: string | null;
|
||||
year_built?: number | null;
|
||||
commissioned_on?: string | null;
|
||||
chamber_volume_liters?: number | null;
|
||||
steam_generation?: string | null;
|
||||
water_treatment?: string | null;
|
||||
documentation?: string | null;
|
||||
supplier?: string | null;
|
||||
};
|
||||
|
||||
export type Equipment = Entity & {
|
||||
kind: string;
|
||||
manufacturer?: string | null;
|
||||
model?: string | null;
|
||||
serial_number: string;
|
||||
calibrated_on?: string | null;
|
||||
calibration_due_on?: string | null;
|
||||
certificate_document_id?: string | null;
|
||||
status: "green" | "yellow" | "red";
|
||||
};
|
||||
|
||||
export type ValidationItem = Entity & {
|
||||
report_number: string;
|
||||
customer_id: string;
|
||||
location_id?: string | null;
|
||||
contact_id?: string | null;
|
||||
device_id?: string | null;
|
||||
validation_type: string;
|
||||
project?: string | null;
|
||||
test_location?: string | null;
|
||||
examiner_name?: string | null;
|
||||
participants?: string | null;
|
||||
operator_name?: string | null;
|
||||
status: string;
|
||||
result?: string | null;
|
||||
scheduled_on?: string | null;
|
||||
performed_on?: string | null;
|
||||
next_validation_on?: string | null;
|
||||
equipment_ids: string[];
|
||||
environment_conditions: Record<string, unknown>;
|
||||
documentation_checklist: Record<string, unknown>[];
|
||||
performance_checklist: Record<string, unknown>[];
|
||||
programs: Record<string, unknown>[];
|
||||
loading_patterns: Record<string, unknown>[];
|
||||
measurement_data: Record<string, unknown>[];
|
||||
drying: Record<string, unknown>;
|
||||
recommendations: Record<string, unknown>[];
|
||||
attachments: Record<string, unknown>[];
|
||||
};
|
||||
|
||||
export type Paginated<T> = {
|
||||
items: T[];
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
};
|
||||
|
||||
export async function login(email: string, password: string) {
|
||||
const response = await fetch(`${API_BASE}/auth/login`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email, password })
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error("Anmeldung fehlgeschlagen");
|
||||
}
|
||||
return response.json() as Promise<{ access_token: string; token_type: string }>;
|
||||
}
|
||||
|
||||
export async function apiGet<T>(path: string, token: string): Promise<T> {
|
||||
const response = await fetch(`${API_BASE}${path}`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
cache: "no-store"
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`API request failed: ${response.status}`);
|
||||
}
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
export async function apiSend<T>(path: string, token: string, method: "POST" | "PUT", body: unknown): Promise<T> {
|
||||
const response = await fetch(`${API_BASE}${path}`, {
|
||||
method,
|
||||
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
if (!response.ok) {
|
||||
const detail = await response.text();
|
||||
throw new Error(detail || `API request failed: ${response.status}`);
|
||||
}
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
export async function apiDelete(path: string, token: string): Promise<void> {
|
||||
const response = await fetch(`${API_BASE}${path}`, {
|
||||
method: "DELETE",
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`API request failed: ${response.status}`);
|
||||
}
|
||||
}
|
||||
6
validation-suite/frontend/atlas/next-env.d.ts
vendored
Normal file
6
validation-suite/frontend/atlas/next-env.d.ts
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/types/routes.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
8
validation-suite/frontend/atlas/next.config.ts
Normal file
8
validation-suite/frontend/atlas/next.config.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: "standalone",
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
||||
6915
validation-suite/frontend/atlas/package-lock.json
generated
Normal file
6915
validation-suite/frontend/atlas/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
34
validation-suite/frontend/atlas/package.json
Normal file
34
validation-suite/frontend/atlas/package.json
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
{
|
||||
"name": "atlas",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^3.10.0",
|
||||
"@tanstack/react-query": "^5.83.0",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"lucide-react": "^0.468.0",
|
||||
"next": "^16.0.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-hook-form": "^7.61.0",
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.13.0",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"eslint": "^9.31.0",
|
||||
"eslint-config-next": "^16.0.0",
|
||||
"postcss": "^8.5.1",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "^5.8.3"
|
||||
}
|
||||
}
|
||||
|
||||
7
validation-suite/frontend/atlas/postcss.config.js
Normal file
7
validation-suite/frontend/atlas/postcss.config.js
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
|
||||
0
validation-suite/frontend/atlas/public/.gitkeep
Normal file
0
validation-suite/frontend/atlas/public/.gitkeep
Normal file
32
validation-suite/frontend/atlas/tailwind.config.ts
Normal file
32
validation-suite/frontend/atlas/tailwind.config.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import type { Config } from "tailwindcss";
|
||||
|
||||
const config: Config = {
|
||||
content: ["./app/**/*.{ts,tsx}", "./components/**/*.{ts,tsx}", "./lib/**/*.{ts,tsx}"],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
primary: "#6C8A96",
|
||||
"primary-dark": "#4F6A74",
|
||||
accent: "#A7C7C7",
|
||||
background: "#F7F8F8",
|
||||
surface: "#FFFFFF",
|
||||
border: "#E6EAEA",
|
||||
text: "#2E3B40",
|
||||
"text-light": "#6B7C85",
|
||||
success: "#66A26B",
|
||||
warning: "#D6A53A",
|
||||
danger: "#C95C54"
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ["Inter", "ui-sans-serif", "system-ui"]
|
||||
},
|
||||
boxShadow: {
|
||||
soft: "0 14px 40px rgba(46, 59, 64, 0.08)"
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: []
|
||||
};
|
||||
|
||||
export default config;
|
||||
|
||||
42
validation-suite/frontend/atlas/tsconfig.json
Normal file
42
validation-suite/frontend/atlas/tsconfig.json
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
"allowJs": false,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"baseUrl": ".",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "react-jsx",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts",
|
||||
".next/dev/types/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue