feat(validation): add editing versioning revalidation and aligned reports

This commit is contained in:
Schubert Ferenc 2026-07-11 10:26:18 +02:00
parent f73a24df13
commit 302e542fda
28 changed files with 2691 additions and 406 deletions

View file

@ -0,0 +1,42 @@
from __future__ import annotations
from alembic import op
revision = "202607110001"
down_revision = "202607100002"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.execute("ALTER TABLE validations ALTER COLUMN status TYPE varchar(40) USING status::text")
op.execute(
"""
UPDATE validations
SET status = CASE status
WHEN 'draft' THEN 'ENTWURF'
WHEN 'ready_for_report' THEN 'BEREIT_ZUR_PRUEFUNG'
WHEN 'in_progress' THEN 'IN_PRUEFUNG'
WHEN 'completed' THEN 'ABGESCHLOSSEN'
ELSE status
END
"""
)
op.execute("ALTER TABLE validations ALTER COLUMN status SET DEFAULT 'ENTWURF'")
def downgrade() -> None:
op.execute(
"""
UPDATE validations
SET status = CASE status
WHEN 'ENTWURF' THEN 'draft'
WHEN 'BEREIT_ZUR_PRUEFUNG' THEN 'ready_for_report'
WHEN 'IN_PRUEFUNG' THEN 'in_progress'
WHEN 'FREIGEGEBEN' THEN 'ready_for_report'
WHEN 'ABGESCHLOSSEN' THEN 'completed'
WHEN 'STORNIERT' THEN 'completed'
ELSE status
END
"""
)

View file

@ -0,0 +1,25 @@
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "202607110002"
down_revision = "202607110001"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("validations", sa.Column("revalidation_interval_months", sa.Integer(), nullable=False, server_default="24"))
op.add_column("validations", sa.Column("next_validation_manually_overridden", sa.Boolean(), nullable=False, server_default=sa.false()))
op.add_column("validations", sa.Column("version", sa.Integer(), nullable=False, server_default="1"))
op.add_column("validations", sa.Column("previous_validation_id", sa.String(), nullable=True))
op.create_foreign_key("fk_validations_previous_validation_id_validations", "validations", "validations", ["previous_validation_id"], ["id"])
def downgrade() -> None:
op.drop_constraint("fk_validations_previous_validation_id_validations", "validations", type_="foreignkey")
op.drop_column("validations", "previous_validation_id")
op.drop_column("validations", "version")
op.drop_column("validations", "next_validation_manually_overridden")
op.drop_column("validations", "revalidation_interval_months")

View file

@ -2,8 +2,13 @@ from __future__ import annotations
from typing import Any from typing import Any
from fastapi import APIRouter, Depends, Query, Response import json
from pathlib import Path
from fastapi import APIRouter, Depends, Query, Response, UploadFile
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
from sqlalchemy import func, select from sqlalchemy import func, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.api.dependencies import current_user from app.api.dependencies import current_user
@ -14,6 +19,7 @@ from app.models.device import Device
from app.models.equipment import Equipment from app.models.equipment import Equipment
from app.models.location import Location from app.models.location import Location
from app.models.validation import Validation from app.models.validation import Validation
from app.modules.orion.service import OrionReportService
from app.schemas.common import PaginatedResponse from app.schemas.common import PaginatedResponse
from app.schemas.domain import ( from app.schemas.domain import (
ContactCreate, ContactCreate,
@ -32,16 +38,22 @@ from app.schemas.domain import (
LocationRead, LocationRead,
LocationUpdate, LocationUpdate,
ValidationCreate, ValidationCreate,
ValidationImportPreview,
ValidationImportRequest,
ValidationImportSummary,
ValidationRead, ValidationRead,
ValidationReview,
ValidationUpdate, ValidationUpdate,
) )
from app.services.domain_service import CrudService, DomainServices from app.services.domain_service import CrudService, DomainServices
from app.services.validation_workflow import ValidationWorkflowService
router = APIRouter(tags=["domain"], dependencies=[Depends(current_user)]) router = APIRouter(tags=["domain"], dependencies=[Depends(current_user)])
@router.get("/dashboard") @router.get("/dashboard")
def dashboard(session: Session = Depends(get_session)) -> dict[str, int]: def dashboard(session: Session = Depends(get_session)) -> dict[str, int]:
today = func.current_date()
return { return {
"customers": session.scalar(select(func.count()).select_from(Customer)) or 0, "customers": session.scalar(select(func.count()).select_from(Customer)) or 0,
"locations": session.scalar(select(func.count()).select_from(Location)) or 0, "locations": session.scalar(select(func.count()).select_from(Location)) or 0,
@ -49,6 +61,11 @@ def dashboard(session: Session = Depends(get_session)) -> dict[str, int]:
"devices": session.scalar(select(func.count()).select_from(Device)) or 0, "devices": session.scalar(select(func.count()).select_from(Device)) or 0,
"equipment": session.scalar(select(func.count()).select_from(Equipment)) or 0, "equipment": session.scalar(select(func.count()).select_from(Equipment)) or 0,
"validations": session.scalar(select(func.count()).select_from(Validation)) or 0, "validations": session.scalar(select(func.count()).select_from(Validation)) or 0,
"validation_drafts": session.scalar(select(func.count()).select_from(Validation).where(Validation.status == "ENTWURF")) or 0,
"validation_ready": session.scalar(select(func.count()).select_from(Validation).where(Validation.status == "BEREIT_ZUR_PRUEFUNG")) or 0,
"validation_in_review": session.scalar(select(func.count()).select_from(Validation).where(Validation.status == "IN_PRUEFUNG")) or 0,
"validation_approved": session.scalar(select(func.count()).select_from(Validation).where(Validation.status == "FREIGEGEBEN")) or 0,
"validation_overdue": session.scalar(select(func.count()).select_from(Validation).where(Validation.next_validation_on < today)) or 0,
} }
@ -61,17 +78,33 @@ def paging(
def commit_create(session: Session, service: CrudService, payload): def commit_create(session: Session, service: CrudService, payload):
try:
item = service.create(payload.model_dump()) item = service.create(payload.model_dump())
if isinstance(item, Validation):
ValidationWorkflowService(session).apply_revalidation_date(item)
session.commit() session.commit()
session.refresh(item) session.refresh(item)
return item return item
except IntegrityError as exc:
session.rollback()
from fastapi import HTTPException
raise HTTPException(status_code=409, detail="Datensatz verletzt Datenbankbeziehungen.") from exc
def commit_update(session: Session, service: CrudService, item_id: str, payload): def commit_update(session: Session, service: CrudService, item_id: str, payload):
try:
item = service.update(item_id, payload.model_dump()) item = service.update(item_id, payload.model_dump())
if isinstance(item, Validation):
ValidationWorkflowService(session).apply_revalidation_date(item)
session.commit() session.commit()
session.refresh(item) session.refresh(item)
return item return item
except IntegrityError as exc:
session.rollback()
from fastapi import HTTPException
raise HTTPException(status_code=409, detail="Datensatz verletzt Datenbankbeziehungen.") from exc
def commit_delete(session: Session, service: CrudService, item_id: str) -> Response: def commit_delete(session: Session, service: CrudService, item_id: str) -> Response:
@ -181,8 +214,39 @@ def delete_equipment(item_id: str, session: Session = Depends(get_session)):
@router.get("/validations", response_model=PaginatedResponse[ValidationRead]) @router.get("/validations", response_model=PaginatedResponse[ValidationRead])
def list_validations(params: dict = Depends(paging), session: Session = Depends(get_session)): def list_validations(
return DomainServices(session).validations.list(**params) search: str | None = Query(default=None, max_length=120),
page: int = Query(default=1, ge=1),
page_size: int = Query(default=20, ge=1, le=100),
sort_by: str = Query(default="updated_at"),
sort_order: str = Query(default="desc", pattern="^(asc|desc)$"),
status: str | None = None,
customer_id: str | None = None,
device_id: str | None = None,
validation_type: str | None = None,
result: str | None = None,
date_from: str | None = None,
date_to: str | None = None,
overdue_only: bool = False,
session: Session = Depends(get_session),
):
return ValidationWorkflowService(session).query_validations(
search=search,
page=page,
page_size=page_size,
sort_by=sort_by,
sort_order=sort_order,
filters={
"status": status,
"customer_id": customer_id,
"device_id": device_id,
"validation_type": validation_type,
"result": result,
"date_from": date_from,
"date_to": date_to,
"overdue_only": overdue_only,
},
)
@router.get("/validations/next-report-number") @router.get("/validations/next-report-number")
@ -213,4 +277,101 @@ def update_validation(item_id: str, payload: ValidationUpdate, session: Session
@router.delete("/validations/{item_id}", status_code=204) @router.delete("/validations/{item_id}", status_code=204)
def delete_validation(item_id: str, session: Session = Depends(get_session)): def delete_validation(item_id: str, session: Session = Depends(get_session)):
item = DomainServices(session).validations.repository.get(item_id)
if item and item.status != "ENTWURF":
from fastapi import HTTPException
raise HTTPException(status_code=409, detail="Only draft validations can be deleted")
return commit_delete(session, DomainServices(session).validations, item_id) return commit_delete(session, DomainServices(session).validations, item_id)
@router.post("/validations/{item_id}/review", response_model=ValidationReview)
def review_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")
review = ValidationWorkflowService(session).mark_ready_for_review(item)
session.commit()
return review
@router.post("/validations/{item_id}/duplicate", response_model=ValidationRead, status_code=201)
def duplicate_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")
clone = ValidationWorkflowService(session).duplicate(item)
session.commit()
session.refresh(clone)
return clone
@router.post("/validations/{item_id}/new-version", response_model=ValidationRead, status_code=201)
def new_validation_version(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")
clone = ValidationWorkflowService(session).create_new_version(item)
session.commit()
session.refresh(clone)
return clone
@router.post("/validations/{item_id}/cancel", response_model=ValidationRead)
def cancel_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")
item.status = "STORNIERT"
session.commit()
session.refresh(item)
return item
@router.get("/validations/{item_id}/export.json")
def export_validation_json(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")
data = ValidationWorkflowService(session).export_json(item)
return JSONResponse(
content=json.loads(json.dumps(data, default=str)),
headers={"Content-Disposition": f'attachment; filename="{item.report_number}.json"'},
)
@router.post("/validations/import/csv-preview", response_model=ValidationImportPreview)
async def preview_validation_csv(file: UploadFile, session: Session = Depends(get_session)):
return ValidationWorkflowService(session).preview_csv(await file.read())
@router.post("/validations/import/json", response_model=ValidationImportSummary)
def import_validation_json(payload: ValidationImportRequest, session: Session = Depends(get_session)):
summary = ValidationWorkflowService(session).import_rows(payload.rows, payload.duplicate_strategy)
session.commit()
return summary
@router.get("/validations/{item_id}/report.html", response_class=HTMLResponse)
def validation_report_preview(item_id: str, session: Session = Depends(get_session)):
return OrionReportService(session, Path("/app/reports")).render_html(item_id)
@router.get("/validations/{item_id}/report.pdf")
def validation_report_pdf(item_id: str, session: Session = Depends(get_session)):
report_path = OrionReportService(session, Path("/app/reports")).render_pdf(item_id)
return FileResponse(
report_path,
media_type="application/pdf",
filename=report_path.name,
)

View file

@ -3,17 +3,19 @@ from __future__ import annotations
import enum import enum
from datetime import date from datetime import date
from sqlalchemy import Date, Enum, ForeignKey, JSON, String, Text from sqlalchemy import Boolean, Date, ForeignKey, Integer, JSON, String, Text
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base, TimestampMixin, UUIDMixin from app.db.base import Base, TimestampMixin, UUIDMixin
class ValidationStatus(str, enum.Enum): class ValidationStatus(str, enum.Enum):
draft = "draft" draft = "ENTWURF"
in_progress = "in_progress" ready_for_review = "BEREIT_ZUR_PRUEFUNG"
ready_for_report = "ready_for_report" in_review = "IN_PRUEFUNG"
completed = "completed" approved = "FREIGEGEBEN"
completed = "ABGESCHLOSSEN"
cancelled = "STORNIERT"
class Validation(Base, UUIDMixin, TimestampMixin): class Validation(Base, UUIDMixin, TimestampMixin):
@ -22,7 +24,7 @@ class Validation(Base, UUIDMixin, TimestampMixin):
report_number: Mapped[str] = mapped_column(String(80), unique=True, index=True) report_number: Mapped[str] = mapped_column(String(80), unique=True, index=True)
customer_id: Mapped[str] = mapped_column(ForeignKey("customers.id"), 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) location_id: Mapped[str | None] = mapped_column(ForeignKey("locations.id"), index=True)
contact_id: Mapped[str | None] = mapped_column(ForeignKey("contacts.id"), index=True) contact_id: Mapped[str | None] = mapped_column(ForeignKey("contacts.id"), index=True, nullable=True)
device_id: Mapped[str | None] = mapped_column(ForeignKey("devices.id"), index=True) device_id: Mapped[str | None] = mapped_column(ForeignKey("devices.id"), index=True)
validation_type: Mapped[str] = mapped_column(String(120)) validation_type: Mapped[str] = mapped_column(String(120))
project: Mapped[str | None] = mapped_column(String(180)) project: Mapped[str | None] = mapped_column(String(180))
@ -33,8 +35,12 @@ class Validation(Base, UUIDMixin, TimestampMixin):
scheduled_on: Mapped[date | None] = mapped_column(Date) scheduled_on: Mapped[date | None] = mapped_column(Date)
performed_on: Mapped[date | None] = mapped_column(Date) performed_on: Mapped[date | None] = mapped_column(Date)
next_validation_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")) revalidation_interval_months: Mapped[int] = mapped_column(Integer, default=24)
status: Mapped[ValidationStatus] = mapped_column(Enum(ValidationStatus), default=ValidationStatus.draft) next_validation_manually_overridden: Mapped[bool] = mapped_column(Boolean, default=False)
version: Mapped[int] = mapped_column(Integer, default=1)
previous_validation_id: Mapped[str | None] = mapped_column(ForeignKey("validations.id"), nullable=True)
examiner_id: Mapped[str | None] = mapped_column(ForeignKey("users.id"), nullable=True)
status: Mapped[str] = mapped_column(String(40), default=ValidationStatus.draft.value)
result: Mapped[str | None] = mapped_column(String(120)) result: Mapped[str | None] = mapped_column(String(120))
notes: Mapped[str | None] = mapped_column(Text) notes: Mapped[str | None] = mapped_column(Text)
equipment_ids: Mapped[list[str]] = mapped_column(JSON, default=list) equipment_ids: Mapped[list[str]] = mapped_column(JSON, default=list)

View file

@ -0,0 +1,37 @@
from app.modules.orion.components.base import ReportComponent
from app.modules.orion.components.chapters import (
AttachmentComponent,
ChecklistComponent,
CoverComponent,
CustomerComponent,
DeviceComponent,
DryingComponent,
EnvironmentComponent,
EquipmentComponent,
LoadingComponent,
MeasurementComponent,
ProgramComponent,
RecommendationComponent,
StaticTextComponent,
SummaryComponent,
TocComponent,
)
__all__ = [
"AttachmentComponent",
"ChecklistComponent",
"CoverComponent",
"CustomerComponent",
"DeviceComponent",
"DryingComponent",
"EnvironmentComponent",
"EquipmentComponent",
"LoadingComponent",
"MeasurementComponent",
"ProgramComponent",
"RecommendationComponent",
"StaticTextComponent",
"ReportComponent",
"SummaryComponent",
"TocComponent",
]

View file

@ -0,0 +1,15 @@
from __future__ import annotations
from abc import ABC, abstractmethod
from app.modules.orion.context import ReportContext
class ReportComponent(ABC):
anchor: str
title: str
@abstractmethod
def render(self, context: ReportContext) -> str:
raise NotImplementedError

View file

@ -0,0 +1,325 @@
from __future__ import annotations
from app.modules.orion.components.base import ReportComponent
from app.modules.orion.context import ReportContext
from app.modules.orion.html import definition_list, paragraph, section, table, text, yes_no
class CoverComponent(ReportComponent):
anchor = "cover"
title = "Deckblatt"
def render(self, context: ReportContext) -> str:
validation = context.validation
rows = definition_list(
[
("Berichtsnummer", validation.report_number),
("Validierungsart", validation.validation_type),
("Projekt", validation.project),
("Pruefdatum", validation.performed_on),
("Pruefungsort", validation.test_location),
("Pruefer", validation.examiner_name),
("Gesamtergebnis", validation.result),
("Status", validation.status),
("Ansprechpartner", context.contact.full_name if context.contact else "nicht erfasst"),
("Mitwirkende Personen", validation.participants or "nicht erfasst"),
]
)
return (
"<section class=\"cover-page\" id=\"cover\">"
"<div class=\"cover-kicker\">Neutraler Logo-Platzhalter · Validation Suite</div>"
"<h1>Pruefbericht zur Validierung</h1>"
"<p class=\"cover-subtitle\">Funktions- und Leistungsqualifikation Klein-Sterilisator</p>"
f"<p class=\"cover-subtitle\">{text(context.customer.name)}</p>"
f"{rows}"
"<div class=\"signature-grid\"><div>Unterschrift technische Validierung</div><div>Unterschrift Auftraggeber</div></div>"
"</section>"
)
class TocComponent(ReportComponent):
anchor = "toc"
title = "Inhaltsverzeichnis"
def __init__(self, components: list[ReportComponent]) -> None:
self.components = components
def render(self, context: ReportContext) -> str:
links = "".join(
f"<li><a href=\"#{component.anchor}\">{text(component.title)}</a></li>"
for component in self.components
if component.anchor not in {"cover", "toc"}
)
return section(self.anchor, self.title, f"<ol class=\"toc-list\">{links}</ol>")
class SummaryComponent(ReportComponent):
anchor = "summary"
title = "Zusammenfassendes Ergebnis der Validierung"
def render(self, context: ReportContext) -> str:
validation = context.validation
body = definition_list(
[
("Kunde", context.customer.name),
("Standort", context.location.name if context.location else None),
("Geraet", f"{context.device.manufacturer} {context.device.model}" if context.device else None),
("Pruefmittel", len(context.equipment)),
("Ergebnis", validation.result),
("Mitwirkende Personen", validation.participants),
("Hinweis auf naechste Leistungsbeurteilung", validation.next_validation_on or "nicht erfasst"),
]
)
return section(self.anchor, self.title, body)
class StaticTextComponent(ReportComponent):
def __init__(self, anchor: str, title: str, body: str = "nicht erfasst") -> None:
self.anchor = anchor
self.title = title
self.body = body
def render(self, context: ReportContext) -> str:
return section(self.anchor, self.title, f"<p>{text(self.body)}</p>")
class CustomerComponent(ReportComponent):
anchor = "customer"
title = "Kunde, Standort und Ansprechpartner"
def render(self, context: ReportContext) -> str:
customer = context.customer
location = context.location
contact = context.contact
body = "<h3>Kunde</h3>" + definition_list(
[
("Name", customer.name),
("Typ", customer.customer_type.value),
("Adresse", " ".join(filter(None, [customer.street, customer.postal_code, customer.city]))),
("Telefon", customer.phone),
("Mail", customer.email),
("Betreiber", context.validation.operator_name),
("QM", customer.quality_manager),
("Hygienebeauftragter", customer.hygiene_officer),
]
)
if location:
body += "<h3>Standort</h3>" + definition_list(
[
("Name", location.name),
("Adresse", " ".join(filter(None, [location.street, location.postal_code, location.city]))),
("Raum", location.room),
]
)
if contact:
body += "<h3>Ansprechpartner</h3>" + definition_list(
[
("Name", contact.full_name),
("Funktion", contact.function),
("Mail", contact.email),
("Telefon", contact.phone),
]
)
return section(self.anchor, self.title, body)
class DeviceComponent(ReportComponent):
anchor = "device"
title = "Geraet"
def render(self, context: ReportContext) -> str:
device = context.device
if device is None:
return section(self.anchor, self.title, "")
body = definition_list(
[
("Hersteller", device.manufacturer),
("Modell", device.model),
("Typ", device.device_type),
("Seriennummer", device.serial_number),
("Baujahr", device.year_built),
("Inbetriebnahme", device.commissioned_on),
("Kammervolumen", f"{device.chamber_volume_liters} Liter" if device.chamber_volume_liters else None),
("Dampferzeugung", device.steam_generation),
("Wasseraufbereitung", device.water_treatment),
("Dokumentation", device.documentation),
("Lieferant", device.supplier),
]
)
return section(self.anchor, self.title, body)
class EquipmentComponent(ReportComponent):
anchor = "equipment"
title = "Pruefmittel"
def render(self, context: ReportContext) -> str:
rows = [
[
item.kind.value,
item.manufacturer,
item.model,
item.serial_number,
item.calibrated_on,
item.calibration_due_on,
item.status.value,
]
for item in context.equipment
]
return section(
self.anchor,
self.title,
table(
["Art", "Hersteller", "Modell", "Seriennummer", "Kalibriert", "Gueltig bis", "Status"],
rows,
),
)
class EnvironmentComponent(ReportComponent):
anchor = "environment"
title = "Umgebungsbedingungen"
def render(self, context: ReportContext) -> str:
data = context.validation.environment_conditions or {}
body = definition_list(
[
("Raumtemperatur", data.get("room_temperature")),
("relative Luftfeuchtigkeit", data.get("humidity")),
("Pruefzeit", data.get("test_time")),
]
)
rows = [[item.get("text"), yes_no(item.get("value")), item.get("comment")] for item in data.get("checks", [])]
if rows:
body += table(["Pruefpunkt", "Bewertung", "Kommentar"], rows)
return section(self.anchor, self.title, body)
class ChecklistComponent(ReportComponent):
anchor = "checklists"
title = "Dokumentations- und Leistungschecklisten"
def render(self, context: ReportContext) -> str:
documentation = context.validation.documentation_checklist or []
performance = context.validation.performance_checklist or []
body = "<h3>Dokumentation</h3>" + self._render_items(documentation)
body += "<h3>Leistung</h3>" + self._render_items(performance)
return section(self.anchor, self.title, body)
def _render_items(self, items: list[dict]) -> str:
rows = [
[item.get("number"), item.get("text"), yes_no(item.get("value")), item.get("comment")]
for item in items
]
return table(["Nr.", "Pruefpunkt", "Bewertung", "Kommentar"], rows)
class ProgramComponent(ReportComponent):
anchor = "programs"
title = "Programme"
def render(self, context: ReportContext) -> str:
programs = [item for item in (context.validation.programs or []) if item.get("selected")]
rows = [[index + 1, item.get("name"), "Eigenes Programm" if item.get("custom") else "Standard"] for index, item in enumerate(programs)]
return section(self.anchor, self.title, table(["Nr.", "Programm", "Typ"], rows))
class LoadingComponent(ReportComponent):
anchor = "loading"
title = "Beladungsmuster"
def render(self, context: ReportContext) -> str:
rows = [
[item.get("run"), item.get("pattern"), item.get("description"), len(item.get("images", []))]
for item in context.validation.loading_patterns or []
]
return section(self.anchor, self.title, table(["Testlauf", "Beladungsmuster", "Beschreibung", "Bilder"], rows))
class MeasurementComponent(ReportComponent):
anchor = "measurements"
title = "Messdaten"
def render(self, context: ReportContext) -> str:
rows = [
[
item.get("name"),
item.get("start_time"),
item.get("end_time"),
item.get("duration"),
item.get("leak_rate"),
item.get("min_temperature"),
item.get("max_temperature"),
item.get("temperature_band"),
item.get("holding_time"),
item.get("pressure"),
item.get("result"),
]
for item in context.validation.measurement_data or []
]
body = table(
[
"Bereich",
"Start",
"Ende",
"Dauer",
"Leckrate",
"Min. Temp.",
"Max. Temp.",
"Band",
"Haltezeit",
"Druck",
"Ergebnis",
],
rows,
"compact",
)
winlog_rows = []
for item in context.validation.measurement_data or []:
for imported in item.get("imports", []):
winlog_rows.append([item.get("name"), imported.get("filename"), imported.get("content_type")])
if winlog_rows:
body += "<h3>Winlog-Dateien</h3>" + table(["Bereich", "Datei", "Typ"], winlog_rows)
return section(self.anchor, self.title, body)
class DryingComponent(ReportComponent):
anchor = "drying"
title = "Trocknung"
def render(self, context: ReportContext) -> str:
data = context.validation.drying or {}
body = definition_list(
[
("Startgewicht", data.get("start_weight")),
("Endgewicht", data.get("end_weight")),
("Differenz", data.get("difference")),
("Bewertung", data.get("rating")),
("Bemerkung", data.get("comment")),
]
)
return section(self.anchor, self.title, body)
class RecommendationComponent(ReportComponent):
anchor = "recommendations"
title = "Empfehlungen und Auflagen"
def render(self, context: ReportContext) -> str:
rows = [
[item.get("number"), item.get("text"), item.get("deadline"), item.get("status")]
for item in context.validation.recommendations or []
]
return section(self.anchor, self.title, table(["Nr.", "Text", "Frist", "Status"], rows))
class AttachmentComponent(ReportComponent):
anchor = "attachments"
title = "Bilder und Anlagen"
def render(self, context: ReportContext) -> str:
rows = [
[item.get("order"), item.get("category"), item.get("filename"), item.get("description")]
for item in sorted(context.validation.attachments or [], key=lambda row: row.get("order") or 0)
]
return section(self.anchor, self.title, table(["Reihenfolge", "Kategorie", "Datei", "Beschreibung"], rows))

View file

@ -0,0 +1,64 @@
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from fastapi import HTTPException, status
from sqlalchemy import select
from sqlalchemy.orm import 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
@dataclass(frozen=True)
class ReportContext:
validation: Validation
customer: Customer
location: Location | None
contact: Contact | None
device: Device | None
equipment: list[Equipment]
generated_dir: Path
class OrionContextBuilder:
def __init__(self, session: Session, generated_dir: Path) -> None:
self.session = session
self.generated_dir = generated_dir
def build(self, validation_id: str) -> ReportContext:
validation = self.session.get(Validation, validation_id)
if validation is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Validation not found")
customer = self.session.get(Customer, validation.customer_id)
if customer is None:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Validation has no customer")
location = self.session.get(Location, validation.location_id) if validation.location_id else None
contact = self.session.get(Contact, validation.contact_id) if validation.contact_id else None
device = self.session.get(Device, validation.device_id) if validation.device_id else None
equipment = []
if validation.equipment_ids:
equipment = list(
self.session.scalars(
select(Equipment).where(Equipment.id.in_(validation.equipment_ids))
)
)
self.generated_dir.mkdir(parents=True, exist_ok=True)
return ReportContext(
validation=validation,
customer=customer,
location=location,
contact=contact,
device=device,
equipment=equipment,
generated_dir=self.generated_dir,
)

View file

@ -0,0 +1,45 @@
from __future__ import annotations
from datetime import date, datetime
from html import escape
from typing import Any
def text(value: Any) -> str:
if value is None or value == "":
return "nicht erfasst"
if isinstance(value, (date, datetime)):
return value.strftime("%d.%m.%Y")
return escape(str(value))
def paragraph(value: Any) -> str:
content = text(value)
return content.replace("\n", "<br>")
def yes_no(value: Any) -> str:
labels = {"yes": "Ja", "no": "Nein", "na": "Nicht zutreffend", True: "Ja", False: "Nein"}
return text(labels.get(value, value))
def definition_list(rows: list[tuple[str, Any]]) -> str:
items = "".join(
f"<div class=\"definition-row\"><dt>{text(label)}</dt><dd>{paragraph(value)}</dd></div>"
for label, value in rows
if value not in (None, "", [])
)
return f"<dl class=\"definition-list\">{items}</dl>"
def table(headers: list[str], rows: list[list[Any]], css_class: str = "") -> str:
head = "".join(f"<th>{text(header)}</th>" for header in headers)
body = "".join(
"<tr>" + "".join(f"<td>{paragraph(cell)}</td>" for cell in row) + "</tr>"
for row in rows
)
return f"<table class=\"data-table {css_class}\"><thead><tr>{head}</tr></thead><tbody>{body}</tbody></table>"
def section(chapter_id: str, title: str, body: str) -> str:
return f"<section class=\"chapter\" id=\"{text(chapter_id)}\"><h2>{text(title)}</h2>{body}</section>"

View file

@ -2,29 +2,75 @@ from __future__ import annotations
from pathlib import Path from pathlib import Path
from weasyprint import HTML from sqlalchemy.orm import Session
from app.modules.orion.components import (
AttachmentComponent,
ChecklistComponent,
CoverComponent,
CustomerComponent,
DeviceComponent,
DryingComponent,
EnvironmentComponent,
EquipmentComponent,
LoadingComponent,
MeasurementComponent,
ProgramComponent,
RecommendationComponent,
ReportComponent,
StaticTextComponent,
SummaryComponent,
TocComponent,
)
from app.modules.orion.context import OrionContextBuilder, ReportContext
from app.modules.orion.templates.report import render_document
class OrionReportService: class OrionReportService:
chapters = [ def __init__(self, session: Session, generated_dir: Path | None = None) -> None:
"Deckblatt", self.session = session
"Inhaltsverzeichnis", self.generated_dir = generated_dir or Path("/app/reports")
"Zusammenfassung",
"Gerät",
"Kunde",
"Normen",
"Prüfmittel",
"Programme",
"Beladung",
"Messungen",
"Diagramme",
"Empfehlungen",
"Anlagen",
]
def render_pdf(self, title: str, output_path: Path) -> Path: def render_html(self, validation_id: str) -> str:
chapter_markup = "".join(f"<section><h2>{chapter}</h2></section>" for chapter in self.chapters) context = OrionContextBuilder(self.session, self.generated_dir).build(validation_id)
html = f"<html><body><h1>{title}</h1>{chapter_markup}</body></html>" components = self._components()
HTML(string=html).write_pdf(output_path) chapters = [component.render(context) for component in components]
return render_document(context, chapters)
def render_pdf(self, validation_id: str) -> Path:
from weasyprint import HTML
context = OrionContextBuilder(self.session, self.generated_dir).build(validation_id)
output_path = context.generated_dir / f"{context.validation.report_number}.pdf"
html = self.render_html(validation_id)
HTML(string=html, base_url=str(context.generated_dir)).write_pdf(output_path)
return output_path return output_path
def _components(self) -> list[ReportComponent]:
chapters: list[ReportComponent] = [
StaticTextComponent("bq", "1. Funktionsqualifikation (BQ)", "Anlass, Ziel und gesetzliche Grundlagen werden anhand der erfassten Validierungsdaten bewertet."),
StaticTextComponent("bq-goal", "1.1 Anlass und Ziel der Pruefung"),
StaticTextComponent("legal", "1.2 Gesetzliche Grundlagen"),
DeviceComponent(),
StaticTextComponent("performance", "1.4 Leistungsueberpruefung"),
ChecklistComponent(),
StaticTextComponent("work-instructions", "Arbeitsanweisungen"),
EnvironmentComponent(),
StaticTextComponent("batch-control", "1.9 Chargenkontrolle"),
ProgramComponent(),
LoadingComponent(),
StaticTextComponent("reference-load", "1.12 Referenzbeladung"),
EquipmentComponent(),
StaticTextComponent("equipment-thermo", "2.2 Pruefmittel zur thermoelektrischen Untersuchung"),
StaticTextComponent("configuration", "3. Pruefkonfiguration"),
MeasurementComponent(),
StaticTextComponent("results", "4. Ergebnisse der Validierung"),
DryingComponent(),
RecommendationComponent(),
AttachmentComponent(),
StaticTextComponent("cycles", "6. Programmablaeufe / Zyklen"),
StaticTextComponent("risk", "7. Risikoeinstufung und Abschlussgespraech"),
StaticTextComponent("certificates", "8. Zertifikate"),
StaticTextComponent("calibration-certificates", "9. Kalibrierzertifikate"),
]
return [CoverComponent(), SummaryComponent(), TocComponent(chapters), CustomerComponent(), *chapters]

View file

@ -0,0 +1,201 @@
@page {
size: A4;
margin: 24mm 16mm 22mm 16mm;
@top-left {
content: "Validation Suite";
color: #4F6A74;
font-size: 9pt;
font-weight: 700;
}
@top-right {
content: string(report-number);
color: #6B7C85;
font-size: 9pt;
}
@bottom-left {
content: "Schubamed";
color: #6B7C85;
font-size: 8pt;
}
@bottom-right {
content: "Seite " counter(page) " von " counter(pages);
color: #6B7C85;
font-size: 8pt;
}
}
@page:first {
margin: 20mm 16mm 18mm 16mm;
@top-left { content: ""; }
@top-right { content: ""; }
}
* {
box-sizing: border-box;
}
html {
color: #2E3B40;
font-family: Inter, Arial, sans-serif;
font-size: 10.5pt;
line-height: 1.45;
}
body {
margin: 0;
}
.report-meta {
string-set: report-number attr(data-report-number);
}
.cover-page {
min-height: 245mm;
display: flex;
flex-direction: column;
justify-content: center;
page-break-after: always;
}
.cover-kicker {
color: #6C8A96;
font-size: 11pt;
font-weight: 700;
letter-spacing: .08em;
margin-bottom: 14mm;
text-transform: uppercase;
}
h1 {
color: #2E3B40;
font-size: 34pt;
line-height: 1.05;
margin: 0 0 8mm 0;
}
.cover-subtitle {
color: #4F6A74;
font-size: 16pt;
margin: 0 0 18mm 0;
}
.signature-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16mm;
margin-top: 18mm;
}
.signature-grid div {
border-top: 1px solid #6B7C85;
color: #6B7C85;
padding-top: 3mm;
}
.chapter {
break-before: page;
}
h2 {
border-bottom: 1px solid #E6EAEA;
color: #2E3B40;
font-size: 18pt;
margin: 0 0 8mm 0;
padding-bottom: 4mm;
}
h3 {
color: #4F6A74;
font-size: 12pt;
margin: 8mm 0 3mm 0;
}
.definition-list {
display: block;
margin: 0;
}
.definition-row {
border-bottom: 1px solid #E6EAEA;
display: grid;
grid-template-columns: 42mm 1fr;
gap: 6mm;
padding: 2.5mm 0;
}
dt {
color: #6B7C85;
font-weight: 700;
}
dd {
margin: 0;
}
.data-table {
border-collapse: collapse;
margin-top: 4mm;
table-layout: fixed;
width: 100%;
}
.data-table th {
background: #F7F8F8;
color: #4F6A74;
font-size: 8.5pt;
font-weight: 700;
text-align: left;
}
.data-table th,
.data-table td {
border: 1px solid #E6EAEA;
padding: 2.4mm;
vertical-align: top;
word-wrap: break-word;
}
.data-table.compact {
font-size: 8.5pt;
}
.toc-list {
counter-reset: toc;
list-style: none;
margin: 0;
padding: 0;
}
.toc-list li {
border-bottom: 1px solid #E6EAEA;
padding: 3mm 0;
}
.toc-list a {
color: #2E3B40;
text-decoration: none;
}
@media screen {
body {
background: #F7F8F8;
padding: 24px;
}
.report-document {
background: #FFFFFF;
box-shadow: 0 14px 40px rgba(46, 59, 64, 0.08);
margin: 0 auto;
max-width: 960px;
padding: 48px;
}
.cover-page {
min-height: auto;
}
.chapter {
break-before: auto;
margin-top: 48px;
}
}

View file

@ -0,0 +1,28 @@
from __future__ import annotations
from pathlib import Path
from app.modules.orion.context import ReportContext
from app.modules.orion.html import text
def render_document(context: ReportContext, chapters: list[str]) -> str:
css = (Path(__file__).resolve().parent / "report.css").read_text(encoding="utf-8")
title = f"Validierungsbericht {context.validation.report_number}"
chapter_markup = "\n".join(chapters)
return f"""<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{text(title)}</title>
<style>{css}</style>
</head>
<body>
<article class="report-document">
<div class="report-meta" data-report-number="{text(context.validation.report_number)}"></div>
{chapter_markup}
</article>
</body>
</html>"""

View file

@ -1,8 +1,9 @@
from __future__ import annotations from __future__ import annotations
from datetime import date from datetime import date
from uuid import UUID
from pydantic import EmailStr, Field from pydantic import EmailStr, Field, field_validator
from app.models.customer import CustomerType from app.models.customer import CustomerType
from app.models.equipment import EquipmentKind, EquipmentStatus from app.models.equipment import EquipmentKind, EquipmentStatus
@ -108,12 +109,12 @@ class EquipmentUpdate(EquipmentCreate):
class ValidationCreate(ORMModel): class ValidationCreate(ORMModel):
report_number: str report_number: str | None = None
customer_id: str customer_id: UUID | None = None
location_id: str | None = None location_id: UUID | None = None
contact_id: str | None = None contact_id: UUID | None = None
device_id: str | None = None device_id: UUID | None = None
validation_type: str validation_type: str | None = None
project: str | None = None project: str | None = None
test_location: str | None = None test_location: str | None = None
examiner_name: str | None = None examiner_name: str | None = None
@ -122,8 +123,12 @@ class ValidationCreate(ORMModel):
scheduled_on: date | None = None scheduled_on: date | None = None
performed_on: date | None = None performed_on: date | None = None
next_validation_on: date | None = None next_validation_on: date | None = None
examiner_id: str | None = None revalidation_interval_months: int = 24
status: ValidationStatus = ValidationStatus.draft next_validation_manually_overridden: bool = False
version: int = 1
previous_validation_id: UUID | None = None
examiner_id: UUID | None = None
status: ValidationStatus | str = ValidationStatus.draft
result: str | None = None result: str | None = None
notes: str | None = None notes: str | None = None
equipment_ids: list[str] = Field(default_factory=list) equipment_ids: list[str] = Field(default_factory=list)
@ -137,6 +142,21 @@ class ValidationCreate(ORMModel):
recommendations: list[dict] = Field(default_factory=list) recommendations: list[dict] = Field(default_factory=list)
attachments: list[dict] = Field(default_factory=list) attachments: list[dict] = Field(default_factory=list)
@field_validator(
"customer_id",
"location_id",
"contact_id",
"device_id",
"examiner_id",
"previous_validation_id",
mode="before",
)
@classmethod
def empty_string_to_none(cls, value):
if value == "":
return None
return value
class ValidationRead(ValidationCreate, EntityRead): class ValidationRead(ValidationCreate, EntityRead):
pass pass
@ -144,3 +164,45 @@ class ValidationRead(ValidationCreate, EntityRead):
class ValidationUpdate(ValidationCreate): class ValidationUpdate(ValidationCreate):
pass pass
class ValidationIssue(ORMModel):
field: str
message: str
section: str
class ValidationReview(ORMModel):
status: str
errors: list[ValidationIssue]
warnings: list[ValidationIssue]
complete_sections: list[str]
class ValidationImportPreviewRow(ORMModel):
row_number: int
data: dict
errors: list[str]
duplicate: bool
resolved_customer_id: str | None = None
resolved_location_id: str | None = None
resolved_device_id: str | None = None
class ValidationImportPreview(ORMModel):
rows: list[ValidationImportPreviewRow]
valid_rows: int
invalid_rows: int
duplicates: int
class ValidationImportRequest(ORMModel):
rows: list[dict]
duplicate_strategy: str = "skip"
class ValidationImportSummary(ORMModel):
successful: int
skipped: int
failed: int
errors: list[str] = Field(default_factory=list)

View file

@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
from typing import TypeVar from typing import TypeVar
from uuid import UUID
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@ -41,16 +42,33 @@ class CrudService:
} }
def create(self, data: dict) -> ModelT: def create(self, data: dict) -> ModelT:
data = self._normalize(data)
return self.repository.add(self.repository.model(**data)) return self.repository.add(self.repository.model(**data))
def update(self, item_id: str, data: dict) -> ModelT: def update(self, item_id: str, data: dict) -> ModelT:
data = self._normalize(data)
item = self.repository.get(item_id) item = self.repository.get(item_id)
if item is None: if item is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Resource not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Resource not found")
if isinstance(item, Validation) and item.status in {"FREIGEGEBEN", "ABGESCHLOSSEN"}:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Freigegebene oder abgeschlossene Validierungen sind schreibgeschuetzt")
for key, value in data.items(): for key, value in data.items():
setattr(item, key, value) setattr(item, key, value)
return item return item
def _normalize(self, data: dict) -> dict:
normalized = {}
for key, value in data.items():
if key.endswith("_id") and value == "":
normalized[key] = None
elif isinstance(value, UUID):
normalized[key] = str(value)
elif isinstance(value, list):
normalized[key] = [str(item) if isinstance(item, UUID) else item for item in value]
else:
normalized[key] = value
return normalized
def delete(self, item_id: str) -> None: def delete(self, item_id: str) -> None:
item = self.repository.get(item_id) item = self.repository.get(item_id)
if item is None: if item is None:

View file

@ -0,0 +1,344 @@
from __future__ import annotations
import csv
import io
from dataclasses import dataclass
from datetime import date, datetime
from uuid import uuid4
from dateutil.relativedelta import relativedelta
from sqlalchemy import and_, or_, select
from sqlalchemy.orm import 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, ValidationStatus
REQUIRED_FIELDS = {
"report_number": ("Allgemeine Angaben", "Berichtsnummer"),
"validation_type": ("Allgemeine Angaben", "Validierungsart"),
"performed_on": ("Allgemeine Angaben", "Pruefdatum"),
"customer_id": ("Kunde und Standort", "Kunde"),
"location_id": ("Kunde und Standort", "Standort"),
"device_id": ("Geraet", "Geraet"),
"examiner_name": ("Allgemeine Angaben", "Pruefer"),
}
@dataclass(frozen=True)
class ReviewIssue:
field: str
message: str
section: str
def as_dict(self) -> dict[str, str]:
return {"field": self.field, "message": self.message, "section": self.section}
class ValidationWorkflowService:
def __init__(self, session: Session) -> None:
self.session = session
def review(self, validation: Validation) -> dict:
errors = self._required_errors(validation) + self._reference_errors(validation)
warnings = self._warnings(validation)
complete_sections = self._complete_sections(validation, errors, warnings)
return {
"status": validation.status,
"errors": [issue.as_dict() for issue in errors],
"warnings": [issue.as_dict() for issue in warnings],
"complete_sections": complete_sections,
}
def mark_ready_for_review(self, validation: Validation) -> dict:
review = self.review(validation)
validation.status = (
ValidationStatus.ready_for_review.value
if not review["errors"]
else ValidationStatus.draft.value
)
self.session.flush()
review["status"] = validation.status
return review
def apply_revalidation_date(self, validation: Validation) -> None:
if validation.performed_on and not validation.next_validation_manually_overridden:
validation.next_validation_on = validation.performed_on + relativedelta(
months=validation.revalidation_interval_months or 24
)
def duplicate(self, validation: Validation) -> Validation:
clone = Validation(
report_number=f"{validation.report_number}-KOPIE-{str(uuid4())[:8]}",
customer_id=validation.customer_id,
location_id=validation.location_id,
contact_id=validation.contact_id,
device_id=validation.device_id,
validation_type=validation.validation_type,
project=validation.project,
test_location=validation.test_location,
examiner_name=validation.examiner_name,
participants=validation.participants,
operator_name=validation.operator_name,
scheduled_on=validation.scheduled_on,
performed_on=validation.performed_on,
next_validation_on=validation.next_validation_on,
revalidation_interval_months=validation.revalidation_interval_months,
next_validation_manually_overridden=validation.next_validation_manually_overridden,
version=validation.version + 1,
previous_validation_id=validation.id,
examiner_id=validation.examiner_id,
status=ValidationStatus.draft.value,
result=validation.result,
notes=validation.notes,
equipment_ids=validation.equipment_ids,
environment_conditions=validation.environment_conditions,
documentation_checklist=validation.documentation_checklist,
performance_checklist=validation.performance_checklist,
programs=validation.programs,
loading_patterns=validation.loading_patterns,
measurement_data=validation.measurement_data,
drying=validation.drying,
recommendations=validation.recommendations,
attachments=validation.attachments,
)
self.session.add(clone)
self.session.flush()
return clone
def create_new_version(self, validation: Validation) -> Validation:
clone = self.duplicate(validation)
clone.report_number = f"{validation.report_number}-V{clone.version}"
return clone
def export_json(self, validation: Validation) -> dict:
return {
column.name: getattr(validation, column.name)
for column in Validation.__table__.columns
if column.name not in {"created_at", "updated_at"}
}
def preview_csv(self, content: bytes) -> dict:
rows = []
reader = csv.DictReader(io.StringIO(content.decode("utf-8-sig")))
for index, row in enumerate(reader, start=2):
rows.append(self._preview_import_row(index, row))
return {
"rows": rows,
"valid_rows": sum(1 for row in rows if not row["errors"]),
"invalid_rows": sum(1 for row in rows if row["errors"]),
"duplicates": sum(1 for row in rows if row["duplicate"]),
}
def import_rows(self, rows: list[dict], duplicate_strategy: str) -> dict:
summary = {"successful": 0, "skipped": 0, "failed": 0, "errors": []}
for index, row in enumerate(rows, start=1):
preview = self._preview_import_row(index, row)
if preview["errors"]:
summary["failed"] += 1
summary["errors"].append(f"Zeile {index}: {', '.join(preview['errors'])}")
continue
existing = self.session.scalar(
select(Validation).where(Validation.report_number == row.get("report_number"))
)
if existing and duplicate_strategy == "skip":
summary["skipped"] += 1
continue
target = existing if existing and duplicate_strategy == "update" else Validation()
target.report_number = (
f"{row.get('report_number')}-IMPORT-{str(uuid4())[:8]}"
if existing and duplicate_strategy == "copy"
else row.get("report_number")
)
target.validation_type = row.get("validation_type")
target.performed_on = self._parse_date(row.get("test_date") or row.get("performed_on"))
target.customer_id = preview["resolved_customer_id"]
target.location_id = preview["resolved_location_id"]
target.contact_id = row.get("contact_id")
target.device_id = preview["resolved_device_id"]
target.examiner_name = row.get("examiner") or row.get("examiner_name")
target.project = row.get("project")
target.test_location = row.get("test_location")
target.participants = row.get("participants")
target.operator_name = row.get("operator_name")
target.next_validation_on = self._parse_date(row.get("next_validation_on"))
target.equipment_ids = row.get("equipment_ids") or []
target.environment_conditions = row.get("environment_conditions") or {}
target.documentation_checklist = row.get("documentation_checklist") or []
target.performance_checklist = row.get("performance_checklist") or []
target.programs = row.get("programs") or []
target.loading_patterns = row.get("loading_patterns") or []
target.measurement_data = row.get("measurement_data") or []
target.drying = row.get("drying") or {}
target.recommendations = row.get("recommendations") or []
target.attachments = row.get("attachments") or []
target.status = row.get("status") or ValidationStatus.draft.value
target.result = row.get("result")
target.notes = row.get("notes")
if target.id is None:
self.session.add(target)
summary["successful"] += 1
self.session.flush()
return summary
def query_validations(
self,
search: str | None,
page: int,
page_size: int,
sort_by: str,
sort_order: str,
filters: dict,
) -> dict:
statement = select(Validation)
count_statement = select(Validation)
conditions = []
if search:
term = f"%{search}%"
conditions.append(
or_(
Validation.report_number.ilike(term),
Validation.validation_type.ilike(term),
Validation.examiner_name.ilike(term),
Validation.result.ilike(term),
)
)
for key in ["status", "customer_id", "device_id", "validation_type", "result"]:
if filters.get(key):
conditions.append(getattr(Validation, key) == filters[key])
if filters.get("date_from"):
conditions.append(Validation.performed_on >= filters["date_from"])
if filters.get("date_to"):
conditions.append(Validation.performed_on <= filters["date_to"])
if filters.get("overdue_only"):
conditions.append(Validation.next_validation_on < date.today())
conditions.append(Validation.status != ValidationStatus.cancelled.value)
if conditions:
statement = statement.where(and_(*conditions))
count_statement = count_statement.where(and_(*conditions))
sort_column = getattr(Validation, sort_by, Validation.updated_at)
if sort_order == "asc":
statement = statement.order_by(sort_column.asc())
else:
statement = statement.order_by(sort_column.desc())
total = len(list(self.session.scalars(count_statement)))
items = list(self.session.scalars(statement.offset((page - 1) * page_size).limit(page_size)))
return {"items": items, "total": total, "page": page, "page_size": page_size}
def revalidation_status(self, validation: Validation) -> str:
if not validation.next_validation_on:
return "nicht_erfasst"
delta = (validation.next_validation_on - date.today()).days
if delta < 0:
return "ueberfaellig"
if delta <= 30:
return "faellig_30"
if delta <= 90:
return "faellig_90"
return "faellig_spaeter"
def _required_errors(self, validation: Validation) -> list[ReviewIssue]:
issues = []
for field, (section, label) in REQUIRED_FIELDS.items():
if not getattr(validation, field):
issues.append(ReviewIssue(field, f"{label} fehlt.", section))
return issues
def _reference_errors(self, validation: Validation) -> list[ReviewIssue]:
issues = []
customer = self.session.get(Customer, validation.customer_id) if validation.customer_id else None
location = self.session.get(Location, validation.location_id) if validation.location_id else None
device = self.session.get(Device, validation.device_id) if validation.device_id else None
if validation.customer_id and customer is None:
issues.append(ReviewIssue("customer_id", "Kunde existiert nicht.", "Kunde und Standort"))
if validation.location_id and location is None:
issues.append(ReviewIssue("location_id", "Standort existiert nicht.", "Kunde und Standort"))
if validation.device_id and device is None:
issues.append(ReviewIssue("device_id", "Geraet existiert nicht.", "Geraet"))
if location and device and device.location_id != location.id:
issues.append(ReviewIssue("device_id", "Geraet gehoert nicht zum gewaehlten Standort.", "Geraet"))
return issues
def _warnings(self, validation: Validation) -> list[ReviewIssue]:
warnings = []
equipment = []
if validation.equipment_ids:
equipment = list(self.session.scalars(select(Equipment).where(Equipment.id.in_(validation.equipment_ids))))
if not validation.equipment_ids:
warnings.append(ReviewIssue("equipment_ids", "Keine Pruefmittel ausgewaehlt.", "Pruefmittel"))
for item in equipment:
if item.calibration_due_on and item.calibration_due_on < date.today():
warnings.append(ReviewIssue("equipment_ids", f"Pruefmittel {item.serial_number} ist abgelaufen.", "Pruefmittel"))
if not any(item.get("selected") for item in validation.programs or []):
warnings.append(ReviewIssue("programs", "Keine Programme ausgewaehlt.", "Programme"))
if not validation.measurement_data:
warnings.append(ReviewIssue("measurement_data", "Keine Messdaten vorhanden.", "Messdaten"))
if not validation.attachments:
warnings.append(ReviewIssue("attachments", "Keine Bilder oder Anlagen vorhanden.", "Bilder und Anlagen"))
if not validation.recommendations:
warnings.append(ReviewIssue("recommendations", "Keine Empfehlungen oder Auflagen erfasst.", "Empfehlungen"))
winlog_imported = any(item.get("imports") for item in validation.measurement_data or [])
if not winlog_imported:
warnings.append(ReviewIssue("measurement_data", "Winlog-Datei noch nicht importiert.", "Messdaten"))
return warnings
def _complete_sections(
self, validation: Validation, errors: list[ReviewIssue], warnings: list[ReviewIssue]
) -> list[str]:
blocked = {issue.section for issue in [*errors, *warnings]}
sections = [
"Allgemeine Angaben",
"Kunde und Standort",
"Geraet",
"Pruefmittel",
"Programme",
"Messdaten",
"Bilder und Anlagen",
"Empfehlungen",
]
return [section for section in sections if section not in blocked]
def _preview_import_row(self, row_number: int, row: dict) -> dict:
errors = []
customer = self.session.get(Customer, row.get("customer_id")) if row.get("customer_id") else self.session.scalar(select(Customer).where(Customer.name == row.get("customer_reference")))
location = self.session.get(Location, row.get("location_id")) if row.get("location_id") else self.session.scalar(select(Location).where(Location.name == row.get("location_reference")))
device = self.session.get(Device, row.get("device_id")) if row.get("device_id") else self.session.scalar(select(Device).where(Device.serial_number == row.get("device_serial_number")))
duplicate = bool(
row.get("report_number")
and self.session.scalar(select(Validation).where(Validation.report_number == row.get("report_number")))
)
required = [
("report_number", row.get("report_number")),
("validation_type", row.get("validation_type")),
("test_date", row.get("test_date") or row.get("performed_on")),
("customer_reference", row.get("customer_reference") or row.get("customer_id")),
("location_reference", row.get("location_reference") or row.get("location_id")),
("device_serial_number", row.get("device_serial_number") or row.get("device_id")),
("examiner", row.get("examiner") or row.get("examiner_name")),
]
for field, value in required:
if not value:
errors.append(f"{field} fehlt")
if row.get("customer_reference") and not customer:
errors.append("Kunde nicht gefunden")
if row.get("location_reference") and not location:
errors.append("Standort nicht gefunden")
if row.get("device_serial_number") and not device:
errors.append("Geraet nicht gefunden")
return {
"row_number": row_number,
"data": row,
"errors": errors,
"duplicate": duplicate,
"resolved_customer_id": customer.id if customer else None,
"resolved_location_id": location.id if location else None,
"resolved_device_id": device.id if device else None,
}
def _parse_date(self, value: str | None) -> date | None:
if not value:
return None
return datetime.strptime(value, "%Y-%m-%d").date()

View file

@ -13,13 +13,14 @@ dependencies = [
"pydantic[email]==2.11.7", "pydantic[email]==2.11.7",
"python-jose[cryptography]==3.5.0", "python-jose[cryptography]==3.5.0",
"python-multipart==0.0.20", "python-multipart==0.0.20",
"python-dateutil==2.9.0.post0",
"sqlalchemy==2.0.41", "sqlalchemy==2.0.41",
"uvicorn[standard]==0.35.0", "uvicorn[standard]==0.35.0",
"weasyprint==62.3" "weasyprint==62.3"
] ]
[project.optional-dependencies] [project.optional-dependencies]
dev = ["black==25.1.0", "isort==6.0.1", "ruff==0.12.4"] dev = ["black==25.1.0", "isort==6.0.1", "pytest==8.3.4", "ruff==0.12.4"]
[tool.black] [tool.black]
line-length = 100 line-length = 100
@ -31,3 +32,5 @@ profile = "black"
line-length = 100 line-length = 100
target-version = "py312" target-version = "py312"
[tool.setuptools.packages.find]
include = ["app*"]

View file

@ -0,0 +1,225 @@
from __future__ import annotations
from datetime import date
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from app.db.base import Base
from app.models.customer import Customer, CustomerType
from app.models.contact import Contact
from app.models.device import Device
from app.models.location import Location
from app.models.validation import Validation, ValidationStatus
from app.services.validation_workflow import ValidationWorkflowService
from app.schemas.domain import ValidationCreate
from app.modules.orion.service import OrionReportService
def session() -> Session:
engine = create_engine("sqlite+pysqlite:///:memory:")
Base.metadata.create_all(engine)
return Session(engine)
def seed(session: Session):
customer = Customer(customer_type=CustomerType.practice, name="Praxis Test")
session.add(customer)
session.flush()
location = Location(customer_id=customer.id, name="OP")
session.add(location)
session.flush()
device = Device(
customer_id=customer.id,
location_id=location.id,
manufacturer="MELAG",
model="Vacuklav",
serial_number="SN-1",
)
session.add(device)
session.flush()
return customer, location, device
def seed_contact(session: Session, customer: Customer) -> Contact:
contact = Contact(customer_id=customer.id, full_name="Kontakt")
session.add(contact)
session.flush()
return contact
def valid_validation(customer: Customer, location: Location, device: Device) -> Validation:
return Validation(
report_number="VAL-TEST-1",
validation_type="Erstvalidierung",
performed_on=date.today(),
customer_id=customer.id,
location_id=location.id,
device_id=device.id,
examiner_name="Pruefer",
status=ValidationStatus.draft.value,
)
def test_required_fields_are_reported():
db = session()
validation = Validation(status=ValidationStatus.draft.value)
review = ValidationWorkflowService(db).review(validation)
assert {item["field"] for item in review["errors"]} >= {
"report_number",
"validation_type",
"performed_on",
"customer_id",
"location_id",
"device_id",
"examiner_name",
}
def test_status_changes_to_ready_when_no_errors():
db = session()
customer, location, device = seed(db)
validation = valid_validation(customer, location, device)
db.add(validation)
db.flush()
review = ValidationWorkflowService(db).mark_ready_for_review(validation)
assert review["errors"] == []
assert validation.status == ValidationStatus.ready_for_review.value
def test_search_finds_report_number():
db = session()
customer, location, device = seed(db)
db.add(valid_validation(customer, location, device))
db.flush()
result = ValidationWorkflowService(db).query_validations(
search="VAL-TEST",
page=1,
page_size=10,
sort_by="updated_at",
sort_order="desc",
filters={},
)
assert result["total"] == 1
def test_csv_import_preview_detects_duplicate_and_missing_fields():
db = session()
customer, location, device = seed(db)
db.add(valid_validation(customer, location, device))
db.flush()
csv_content = (
"report_number,validation_type,test_date,customer_reference,location_reference,"
"device_serial_number,examiner,status,result,notes\n"
"VAL-TEST-1,Erstvalidierung,2026-07-11,Praxis Test,OP,SN-1,Pruefer,ENTWURF,offen,\n"
"VAL-NEW,,,,,,,\n"
).encode()
preview = ValidationWorkflowService(db).preview_csv(csv_content)
assert preview["duplicates"] == 1
assert preview["invalid_rows"] == 1
def test_validation_without_contact_id_can_be_saved():
db = session()
customer, location, device = seed(db)
validation = valid_validation(customer, location, device)
validation.contact_id = None
db.add(validation)
db.flush()
assert validation.contact_id is None
def test_validation_with_valid_contact_id_can_be_saved():
db = session()
customer, location, device = seed(db)
contact = seed_contact(db, customer)
validation = valid_validation(customer, location, device)
validation.contact_id = contact.id
db.add(validation)
db.flush()
assert validation.contact_id == contact.id
def test_empty_contact_id_string_is_normalized_to_none():
payload = ValidationCreate(
report_number="VAL-EMPTY",
validation_type="Erstvalidierung",
performed_on=date.today(),
customer_id=None,
location_id="",
contact_id="",
device_id=None,
examiner_name="Pruefer",
)
assert payload.contact_id is None
assert payload.location_id is None
def test_invalid_uuid_returns_validation_error():
try:
ValidationCreate(
report_number="VAL-BAD",
validation_type="Erstvalidierung",
performed_on=date.today(),
customer_id="not-a-uuid",
device_id=None,
examiner_name="Pruefer",
)
except Exception as exc:
assert "uuid" in str(exc).lower()
else:
raise AssertionError("Invalid UUID was accepted")
def test_revalidation_date_uses_calendar_months():
db = session()
customer, location, device = seed(db)
validation = valid_validation(customer, location, device)
validation.performed_on = date(2026, 1, 31)
validation.revalidation_interval_months = 1
ValidationWorkflowService(db).apply_revalidation_date(validation)
assert validation.next_validation_on == date(2026, 2, 28)
def test_new_version_keeps_structured_json_and_previous_reference():
db = session()
customer, location, device = seed(db)
validation = valid_validation(customer, location, device)
validation.status = ValidationStatus.approved.value
validation.documentation_checklist = [{"text": "A", "value": "yes"}]
db.add(validation)
db.flush()
clone = ValidationWorkflowService(db).create_new_version(validation)
assert clone.previous_validation_id == validation.id
assert clone.version == 2
assert clone.documentation_checklist == validation.documentation_checklist
def test_orion_renders_reference_main_chapters(tmp_path):
db = session()
customer, location, device = seed(db)
validation = valid_validation(customer, location, device)
db.add(validation)
db.flush()
html = OrionReportService(db, tmp_path).render_html(validation.id)
assert "1. Funktionsqualifikation" in html
assert "2.2 Pruefmittel" in html
assert "4. Ergebnisse der Validierung" in html
assert "9. Kalibrierzertifikate" in html

View file

@ -1,26 +1,25 @@
"use client"; "use client";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { Building2, Gauge, MapPin, Stethoscope, UserRound } from "lucide-react"; import { AlertTriangle, CheckCircle2, ClipboardList, Clock, FilePenLine } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { useAuth } from "@/components/auth"; import { useAuth } from "@/components/auth";
import { apiGet } from "@/lib/api"; import { apiGet } from "@/lib/api";
type DashboardData = { type DashboardData = {
customers: number; validation_drafts: number;
locations: number; validation_ready: number;
contacts: number; validation_in_review: number;
devices: number; validation_approved: number;
equipment: number; validation_overdue: number;
validations: number;
}; };
const cards = [ const cards = [
{ label: "Kunden", key: "customers", href: "/customers", icon: Building2 }, { label: "Entwuerfe", key: "validation_drafts", href: "/validations?status=ENTWURF", icon: FilePenLine },
{ label: "Standorte", key: "locations", href: "/locations", icon: MapPin }, { label: "Bereit zur Pruefung", key: "validation_ready", href: "/validations?status=BEREIT_ZUR_PRUEFUNG", icon: ClipboardList },
{ label: "Ansprechpartner", key: "contacts", href: "/contacts", icon: UserRound }, { label: "In Pruefung", key: "validation_in_review", href: "/validations?status=IN_PRUEFUNG", icon: Clock },
{ label: "Geraete", key: "devices", href: "/devices", icon: Stethoscope }, { label: "Freigegeben", key: "validation_approved", href: "/validations?status=FREIGEGEBEN", icon: CheckCircle2 },
{ label: "Pruefmittel", key: "equipment", href: "/equipment", icon: Gauge } { label: "Ueberfaellige Revalidierungen", key: "validation_overdue", href: "/validations?overdue_only=true", icon: AlertTriangle }
] as const; ] as const;
export default function DashboardPage() { export default function DashboardPage() {
@ -35,7 +34,7 @@ export default function DashboardPage() {
<div className="space-y-8"> <div className="space-y-8">
<header> <header>
<h1 className="text-3xl font-semibold text-text">Dashboard</h1> <h1 className="text-3xl font-semibold text-text">Dashboard</h1>
<p className="mt-2 text-text-light">Aktuelle Stammdaten aus PostgreSQL.</p> <p className="mt-2 text-text-light">Validierungsworkflow und faellige Revalidierungen.</p>
</header> </header>
<section className="grid gap-4 sm:grid-cols-2 xl:grid-cols-5"> <section className="grid gap-4 sm:grid-cols-2 xl:grid-cols-5">
{cards.map((item) => { {cards.map((item) => {
@ -52,8 +51,8 @@ export default function DashboardPage() {
})} })}
</section> </section>
<section className="rounded-lg border border-border bg-surface p-6 shadow-soft"> <section className="rounded-lg border border-border bg-surface p-6 shadow-soft">
<h2 className="text-xl font-semibold">Stammdaten</h2> <h2 className="text-xl font-semibold">Zuletzt bearbeitete Validierungen</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> <p className="mt-2 text-sm leading-6 text-text-light">Die Validierungsverwaltung bietet Suche, Filter, Sortierung, Vorschau, Export und Workflow-Aktionen.</p>
</section> </section>
</div> </div>
); );

View file

@ -0,0 +1,9 @@
"use client";
import { useParams } from "next/navigation";
import ValidationEditor from "@/components/validations/validation-editor";
export default function EditValidationPage() {
const params = useParams<{ id: string }>();
return <ValidationEditor validationId={params.id} />;
}

View file

@ -0,0 +1,60 @@
"use client";
import { ArrowLeft, Download } from "lucide-react";
import Link from "next/link";
import { useParams } from "next/navigation";
import { useEffect, useState } from "react";
import { useAuth } from "@/components/auth";
import { API_BASE } from "@/lib/api";
export default function ValidationPreviewPage() {
const { token } = useAuth();
const params = useParams<{ id: string }>();
const [html, setHtml] = useState("");
const [message, setMessage] = useState("Vorschau wird geladen.");
useEffect(() => {
if (!token || !params.id) return;
fetch(`${API_BASE}/validations/${params.id}/report.html`, {
headers: { Authorization: `Bearer ${token}` }
})
.then(async (response) => {
if (!response.ok) throw new Error(await response.text());
return response.text();
})
.then((content) => {
setHtml(content);
setMessage("");
})
.catch(() => setMessage("Vorschau konnte nicht geladen werden."));
}, [params.id, token]);
async function downloadPdf() {
const response = await fetch(`${API_BASE}/validations/${params.id}/report.pdf`, {
headers: { Authorization: `Bearer ${token}` }
});
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = "validierungsbericht.pdf";
link.click();
URL.revokeObjectURL(url);
}
return (
<div className="space-y-5">
<header className="flex flex-col justify-between gap-3 sm:flex-row sm:items-center">
<div>
<h1 className="text-3xl font-semibold">HTML-Vorschau</h1>
<p className="mt-2 text-text-light">Authentifiziert gerenderter Orion-Bericht.</p>
</div>
<div className="flex gap-2">
<Link href="/validations" className="btn btn-secondary h-12"><ArrowLeft className="h-4 w-4" /> Zurueck</Link>
<button type="button" onClick={downloadPdf} className="btn btn-primary h-12"><Download className="h-4 w-4" /> PDF herunterladen</button>
</div>
</header>
{message ? <div className="rounded-lg border border-border bg-surface p-6 shadow-soft">{message}</div> : <iframe title="Validierungsbericht" srcDoc={html} className="h-[78vh] w-full rounded-lg border border-border bg-white shadow-soft" />}
</div>
);
}

View file

@ -0,0 +1,5 @@
import ValidationEditor from "@/components/validations/validation-editor";
export default function NewValidationPage() {
return <ValidationEditor />;
}

View file

@ -1,361 +1,205 @@
"use client"; "use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ChevronDown, Download, FileText, Plus, Save, ShieldCheck, UploadCloud, X } from "lucide-react"; import { ColumnDef, flexRender, getCoreRowModel, getSortedRowModel, SortingState, useReactTable } from "@tanstack/react-table";
import { useEffect, useMemo, useRef, useState } from "react"; import { Copy, Download, Edit2, Eye, FilePlus2, GitBranchPlus, Search, Trash2, XCircle } from "lucide-react";
import { Controller, useFieldArray, useForm, useWatch } from "react-hook-form"; import Link from "next/link";
import { z } from "zod"; import { useEffect, useMemo, useState } from "react";
import { useAuth } from "@/components/auth"; import { useAuth } from "@/components/auth";
import { apiGet, apiSend, Contact, Customer, Device, Equipment, Location, Paginated, ValidationItem } from "@/lib/api"; import { API_BASE, apiDelete, apiGet, apiSend, Customer, Device, Paginated, ValidationItem } from "@/lib/api";
const triState = ["yes", "no", "na"] as const; const statusLabels: Record<string, string> = {
const today = new Date().toISOString().slice(0, 10); ENTWURF: "Entwurf",
BEREIT_ZUR_PRUEFUNG: "Bereit zur Pruefung",
IN_PRUEFUNG: "In Pruefung",
FREIGEGEBEN: "Freigegeben",
ABGESCHLOSSEN: "Abgeschlossen",
STORNIERT: "Storniert"
};
const checklistTexts = [ const statusClass: Record<string, string> = {
"Gebrauchsanweisung und Herstellerdokumentation vorhanden", ENTWURF: "bg-border text-text-light",
"Wartungsnachweise vollstaendig", BEREIT_ZUR_PRUEFUNG: "bg-accent/35 text-primary-dark",
"Kalibrierzertifikate der Pruefmittel gueltig", IN_PRUEFUNG: "bg-warning/15 text-warning",
"Aufstellbedingungen dokumentiert", FREIGEGEBEN: "bg-success/15 text-success",
"Wasserqualitaet dokumentiert", ABGESCHLOSSEN: "bg-primary-dark/15 text-primary-dark",
"Chargendokumentation nachvollziehbar", STORNIERT: "bg-danger/15 text-danger"
"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() { export default function ValidationsPage() {
const { token } = useAuth(); const { token } = useAuth();
const client = useQueryClient(); const client = useQueryClient();
const [draftId, setDraftId] = useState<string | null>(null); const [page, setPage] = useState(1);
const [lastSaved, setLastSaved] = useState<string>(""); const [search, setSearch] = useState("");
const autosaveTimer = useRef<ReturnType<typeof setTimeout> | null>(null); const [status, setStatus] = useState("");
const [customerId, setCustomerId] = useState("");
const nextNumber = useQuery({ queryKey: ["next-report-number", token], queryFn: () => apiGet<{ report_number: string }>("/validations/next-report-number", token ?? ""), enabled: Boolean(token) }); const [deviceId, setDeviceId] = useState("");
const customers = useQuery({ queryKey: ["customers-options", token], queryFn: () => apiGet<Paginated<Customer>>("/customers?page=1&page_size=100", token ?? ""), enabled: Boolean(token) }); const [validationType, setValidationType] = useState("");
const locations = useQuery({ queryKey: ["locations-options", token], queryFn: () => apiGet<Paginated<Location>>("/locations?page=1&page_size=100", token ?? ""), enabled: Boolean(token) }); const [result, setResult] = useState("");
const contacts = useQuery({ queryKey: ["contacts-options", token], queryFn: () => apiGet<Paginated<Contact>>("/contacts?page=1&page_size=100", token ?? ""), enabled: Boolean(token) }); const [dateFrom, setDateFrom] = useState("");
const devices = useQuery({ queryKey: ["devices-options", token], queryFn: () => apiGet<Paginated<Device>>("/devices?page=1&page_size=100", token ?? ""), enabled: Boolean(token) }); const [dateTo, setDateTo] = useState("");
const equipment = useQuery({ queryKey: ["equipment-options", token], queryFn: () => apiGet<Paginated<Equipment>>("/equipment?page=1&page_size=100", token ?? ""), enabled: Boolean(token) }); const [overdueOnly, setOverdueOnly] = useState(false);
const [sorting, setSorting] = useState<SortingState>([{ id: "updated_at", desc: true }]);
const form = useForm<FormValues>({ resolver: zodResolver(schema), defaultValues: defaults() }); const pageSize = 10;
const watched = useWatch({ control: form.control }); const sort = sorting[0] ?? { id: "updated_at", desc: true };
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(() => { useEffect(() => {
if (nextNumber.data?.report_number && !form.getValues("report_number")) { const initial = new URLSearchParams(window.location.search);
form.setValue("report_number", nextNumber.data.report_number); setStatus(initial.get("status") ?? "");
} setOverdueOnly(initial.get("overdue_only") === "true");
}, [form, nextNumber.data]); }, []);
const params = new URLSearchParams({
const saveMutation = useMutation({ page: String(page),
mutationFn: (values: FormValues) => apiSend<ValidationItem>(draftId ? `/validations/${draftId}` : "/validations", token ?? "", draftId ? "PUT" : "POST", values), page_size: String(pageSize),
onSuccess: (item) => { sort_by: sort.id,
setDraftId(item.id); sort_order: sort.desc ? "desc" : "asc"
setLastSaved(new Date().toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" }));
client.invalidateQueries({ queryKey: ["dashboard"] });
}
}); });
if (search) params.set("search", search);
if (status) params.set("status", status);
if (customerId) params.set("customer_id", customerId);
if (deviceId) params.set("device_id", deviceId);
if (validationType) params.set("validation_type", validationType);
if (result) params.set("result", result);
if (dateFrom) params.set("date_from", dateFrom);
if (dateTo) params.set("date_to", dateTo);
if (overdueOnly) params.set("overdue_only", "true");
useEffect(() => { const validations = useQuery({
if (!token || !watched.report_number || !watched.customer_id || !watched.device_id || !watched.project || !watched.performed_on) return; queryKey: ["validations", params.toString(), token],
if (autosaveTimer.current) clearTimeout(autosaveTimer.current); queryFn: () => apiGet<Paginated<ValidationItem>>(`/validations?${params.toString()}`, token ?? ""),
autosaveTimer.current = setTimeout(() => { enabled: Boolean(token)
const values = form.getValues(); });
saveMutation.mutate(values); const customers = useQuery({ queryKey: ["customers-options", token], queryFn: () => apiGet<Paginated<Customer>>("/customers?page=1&page_size=100", token ?? ""), enabled: Boolean(token) });
}, 2500); const devices = useQuery({ queryKey: ["devices-options", token], queryFn: () => apiGet<Paginated<Device>>("/devices?page=1&page_size=100", token ?? ""), enabled: Boolean(token) });
return () => {
if (autosaveTimer.current) clearTimeout(autosaveTimer.current);
};
}, [form, saveMutation, token, watched]);
const customerOptions = customers.data?.items ?? []; const duplicateMutation = useMutation({ mutationFn: (id: string) => apiSend<ValidationItem>(`/validations/${id}/duplicate`, token ?? "", "POST", {}), onSuccess: () => client.invalidateQueries({ queryKey: ["validations"] }) });
const selectedCustomer = customerOptions.find((item) => item.id === form.watch("customer_id")); const versionMutation = useMutation({ mutationFn: (id: string) => apiSend<ValidationItem>(`/validations/${id}/new-version`, token ?? "", "POST", {}), onSuccess: () => client.invalidateQueries({ queryKey: ["validations"] }) });
const filteredLocations = (locations.data?.items ?? []).filter((item) => !form.watch("customer_id") || item.customer_id === form.watch("customer_id")); const cancelMutation = useMutation({ mutationFn: (id: string) => apiSend<ValidationItem>(`/validations/${id}/cancel`, token ?? "", "POST", {}), onSuccess: () => client.invalidateQueries({ queryKey: ["validations"] }) });
const filteredContacts = (contacts.data?.items ?? []).filter((item) => !form.watch("customer_id") || item.customer_id === form.watch("customer_id")); const deleteMutation = useMutation({ mutationFn: (id: string) => apiDelete(`/validations/${id}`, token ?? ""), onSuccess: () => client.invalidateQueries({ queryKey: ["validations"] }) });
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]); async function downloadPdf(id: string, reportNumber: string) {
const response = await fetch(`${API_BASE}/validations/${id}/report.pdf`, { headers: { Authorization: `Bearer ${token}` } });
function submit(values: FormValues) { const blob = await response.blob();
saveMutation.mutate(values); const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = `${reportNumber}.pdf`;
link.click();
URL.revokeObjectURL(url);
} }
function addFiles(files: FileList | null, category: string) { const columns = useMemo<ColumnDef<ValidationItem>[]>(() => [
if (!files) return; { accessorKey: "report_number", header: "Berichtsnummer" },
Array.from(files).forEach((file, index) => attachments.append({ category, filename: file.name, description: "", order: attachments.fields.length + index + 1, preview: URL.createObjectURL(file) })); { accessorKey: "validation_type", header: "Art" },
} { accessorKey: "performed_on", header: "Pruefdatum" },
{ accessorKey: "next_validation_on", header: "Naechste Validierung" },
{ accessorKey: "examiner_name", header: "Pruefer" },
{ accessorKey: "result", header: "Ergebnis" },
{ accessorKey: "updated_at", header: "Zuletzt geaendert" },
{ accessorKey: "status", header: "Status", cell: ({ row }) => <span className={`rounded-full px-3 py-1 text-xs font-semibold ${statusClass[row.original.status] ?? statusClass.ENTWURF}`}>{statusLabels[row.original.status] ?? row.original.status}</span> },
{ id: "actions", header: "Aktionen", enableSorting: false, cell: ({ row }) => {
const editable = ["ENTWURF", "BEREIT_ZUR_PRUEFUNG", "IN_PRUEFUNG"].includes(row.original.status);
const versionable = ["FREIGEGEBEN", "ABGESCHLOSSEN"].includes(row.original.status);
return <div className="flex justify-end gap-2">{editable && <Link className="btn btn-secondary h-10 px-3" title="Weiterbearbeiten" href={`/validations/${row.original.id}/edit`}><Edit2 className="h-4 w-4" /> Weiterbearbeiten</Link>}{versionable && <button className="btn btn-secondary h-10 px-3" title="Neue Version erstellen" onClick={() => versionMutation.mutate(row.original.id)}><GitBranchPlus className="h-4 w-4" /> Neue Version</button>}<Link className="icon-btn" title="HTML-Vorschau" href={`/validations/${row.original.id}/preview`}><Eye className="h-4 w-4" /></Link><button className="icon-btn" title="PDF herunterladen" onClick={() => downloadPdf(row.original.id, row.original.report_number)}><Download className="h-4 w-4" /></button><button className="icon-btn" title={duplicateMutation.isPending ? "Duplizieren laeuft..." : "Duplizieren"} disabled={duplicateMutation.isPending} onClick={() => duplicateMutation.mutate(row.original.id)}>{duplicateMutation.isPending ? <span className="spinner" /> : <Copy className="h-4 w-4" />}</button><button className="icon-btn text-danger" title={cancelMutation.isPending ? "Stornieren laeuft..." : "Stornieren"} disabled={cancelMutation.isPending} onClick={() => cancelMutation.mutate(row.original.id)}>{cancelMutation.isPending ? <span className="spinner" /> : <XCircle className="h-4 w-4" />}</button>{row.original.status === "ENTWURF" && <button className="icon-btn text-danger" title={deleteMutation.isPending ? "Loeschen laeuft..." : "Loeschen"} disabled={deleteMutation.isPending} onClick={() => deleteMutation.mutate(row.original.id)}>{deleteMutation.isPending ? <span className="spinner" /> : <Trash2 className="h-4 w-4" />}</button>}</div>;
} }
], [cancelMutation, deleteMutation, duplicateMutation, token]);
const table = useReactTable({
data: validations.data?.items ?? [],
columns,
state: { sorting },
manualSorting: true,
onSortingChange: setSorting,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel()
});
const totalPages = Math.max(1, Math.ceil((validations.data?.total ?? 0) / pageSize));
return ( return (
<form onSubmit={form.handleSubmit(submit)} className="space-y-5 pb-28"> <div className="space-y-6">
<header> <header className="flex flex-col justify-between gap-4 md:flex-row md:items-center">
<h1 className="text-3xl font-semibold text-text">Validierungsbericht</h1> <div><h1 className="text-3xl font-semibold">Validierungen</h1><p className="mt-2 text-text-light">Verwaltung, Vorschau, Export und Workflow.</p></div>
<p className="mt-2 text-text-light">Eine responsive Seite fuer Erfassung, Pruefung und Berichtserstellung.</p> <Link href="/validations/new" className="btn btn-primary h-12"><FilePlus2 className="h-5 w-5" /> Neue Validierung</Link>
</header> </header>
<section className="rounded-lg border border-border bg-surface p-4 shadow-soft">
<Accordion title="1. Allgemeine Angaben" defaultOpen> <div className="grid gap-3 md:grid-cols-4">
<div className="grid gap-4 md:grid-cols-2"> <label className="flex h-12 items-center gap-2 rounded-lg border border-border px-3 md:col-span-2"><Search className="h-4 w-4 text-primary" /><input value={search} onChange={(event) => { setPage(1); setSearch(event.target.value); }} className="flex-1 outline-none" placeholder="Volltextsuche" /></label>
<Field label="Berichtsnummer"><input className={inputClass} {...form.register("report_number")} /></Field> <select value={status} onChange={(event) => setStatus(event.target.value)} className="h-12 rounded-lg border border-border px-3"><option value="">Alle Status</option>{Object.entries(statusLabels).map(([value, label]) => <option key={value} value={value}>{label}</option>)}</select>
<Field label="Validierungsart"><select className={selectClass} {...form.register("validation_type")}><option>Erstvalidierung</option><option>Revalidierung</option><option>Leistungsbeurteilung</option><option>Sonderpruefung</option></select></Field> <select value={validationType} onChange={(event) => setValidationType(event.target.value)} className="h-12 rounded-lg border border-border px-3"><option value="">Alle Arten</option><option>Erstvalidierung</option><option>Revalidierung</option><option>Leistungsbeurteilung</option><option>Sonderpruefung</option></select>
<Field label="Projekt"><input className={inputClass} {...form.register("project")} /></Field> <select value={customerId} onChange={(event) => setCustomerId(event.target.value)} className="h-12 rounded-lg border border-border px-3"><option value="">Alle Kunden</option>{(customers.data?.items ?? []).map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}</select>
<Field label="Pruefdatum"><input type="date" className={inputClass} {...form.register("performed_on")} /></Field> <select value={deviceId} onChange={(event) => setDeviceId(event.target.value)} className="h-12 rounded-lg border border-border px-3"><option value="">Alle Geraete</option>{(devices.data?.items ?? []).map((item) => <option key={item.id} value={item.id}>{item.manufacturer} {item.model}</option>)}</select>
<Field label="Pruefungsort"><input className={inputClass} {...form.register("test_location")} /></Field> <select value={result} onChange={(event) => setResult(event.target.value)} className="h-12 rounded-lg border border-border px-3"><option value="">Alle Ergebnisse</option><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 label="Pruefer"><input className={inputClass} {...form.register("examiner_name")} /></Field> <input type="date" value={dateFrom} onChange={(event) => setDateFrom(event.target.value)} className="h-12 rounded-lg border border-border px-3" />
<Field label="Mitwirkende Personen"><textarea className={areaClass} {...form.register("participants")} /></Field> <input type="date" value={dateTo} onChange={(event) => setDateTo(event.target.value)} className="h-12 rounded-lg border border-border px-3" />
<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> <label className="flex h-12 items-center gap-3 rounded-lg border border-border px-3"><input type="checkbox" checked={overdueOnly} onChange={(event) => setOverdueOnly(event.target.checked)} /> ueberfaellige Revalidierungen</label>
<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> </div>
</Accordion> </section>
<ImportPanel />
<Accordion title="2. Kunde und Standort"> <section className="overflow-hidden rounded-lg border border-border bg-surface shadow-soft">
<div className="grid gap-4 md:grid-cols-2"> <div className="overflow-x-auto">
<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> <table className="min-w-full text-left text-sm">
<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> <thead className="bg-background text-xs uppercase text-text-light">{table.getHeaderGroups().map((group) => <tr key={group.id}>{group.headers.map((header) => <th key={header.id} onClick={header.column.getToggleSortingHandler()} className="cursor-pointer px-5 py-4 hover:text-primary-dark">{flexRender(header.column.columnDef.header, header.getContext())}</th>)}</tr>)}</thead>
<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> <tbody className="divide-y divide-border">{table.getRowModel().rows.map((row) => <tr key={row.id} className="hover:bg-accent/10">{row.getVisibleCells().map((cell) => <td key={cell.id} className="whitespace-nowrap px-5 py-4">{flexRender(cell.column.columnDef.cell, cell.getContext())}</td>)}</tr>)}</tbody>
<Field label="Betreiber"><input className={inputClass} {...form.register("operator_name")} /></Field> </table>
<Field label="QM-/Hygienebeauftragter"><input className={inputClass} value={[selectedCustomer?.quality_manager, selectedCustomer?.hygiene_officer].filter(Boolean).join(" / ")} readOnly /></Field>
</div> </div>
</Accordion> </section>
<footer className="flex items-center justify-between text-sm text-text-light"><span>{validations.data?.total ?? 0} Datensaetze</span><div className="flex items-center gap-3"><button disabled={page <= 1} title={page <= 1 ? "Erste Seite erreicht" : undefined} onClick={() => setPage((value) => value - 1)} className="btn btn-secondary">Zurueck</button><span>Seite {page} von {totalPages}</span><button disabled={page >= totalPages} title={page >= totalPages ? "Letzte Seite erreicht" : undefined} onClick={() => setPage((value) => value + 1)} className="btn btn-secondary">Weiter</button></div></footer>
<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> </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 }) { type ImportPreview = {
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>; rows: { row_number: number; data: Record<string, string>; errors: string[]; duplicate: boolean }[];
} valid_rows: number;
invalid_rows: number;
duplicates: number;
};
function ProgramRow({ form, index }: { form: ReturnType<typeof useForm<FormValues>>; index: number }) { function ImportPanel() {
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>; const { token } = useAuth();
} const client = useQueryClient();
const [preview, setPreview] = useState<ImportPreview | null>(null);
const [summary, setSummary] = useState("");
const [strategy, setStrategy] = useState("skip");
function LoadingRun({ form, index }: { form: ReturnType<typeof useForm<FormValues>>; index: number }) { async function previewCsv(file: File | null) {
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>; if (!file || !token) return;
} const formData = new FormData();
formData.append("file", file);
const response = await fetch(`${API_BASE}/validations/import/csv-preview`, {
method: "POST",
headers: { Authorization: `Bearer ${token}` },
body: formData
});
setPreview(await response.json());
}
function MeasurementBlock({ form, index }: { form: ReturnType<typeof useForm<FormValues>>; index: number }) { async function importJson(file: File | null) {
const fields = ["start_time", "end_time", "duration", "leak_rate", "min_temperature", "max_temperature", "temperature_band", "equilibration_time", "holding_time", "pressure", "result"]; if (!file || !token) return;
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>; const parsed = JSON.parse(await file.text());
const rows = Array.isArray(parsed) ? parsed : [parsed];
const response = await fetch(`${API_BASE}/validations/import/json`, {
method: "POST",
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
body: JSON.stringify({ rows, duplicate_strategy: strategy })
});
const result = await response.json();
setSummary(`Erfolgreich: ${result.successful}, uebersprungen: ${result.skipped}, fehlerhaft: ${result.failed}`);
client.invalidateQueries({ queryKey: ["validations"] });
}
return (
<section className="rounded-lg border border-border bg-surface p-4 shadow-soft">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div><h2 className="font-semibold">Import</h2><p className="mt-1 text-sm text-text-light">CSV-Vorschau und JSON-Import mit Duplikatstrategie.</p></div>
<select value={strategy} onChange={(event) => setStrategy(event.target.value)} className="h-11 rounded-lg border border-border px-3"><option value="skip">Duplikate ueberspringen</option><option value="update">Duplikate aktualisieren</option><option value="copy">Als Kopie importieren</option></select>
</div>
<div className="mt-4 grid gap-3 md:grid-cols-2">
<label className="rounded-lg border border-dashed border-primary/40 p-4 transition hover:border-primary hover:bg-accent/10 hover:shadow-soft">CSV-Vorschau<input type="file" accept=".csv" className="mt-3 block w-full cursor-pointer" onChange={(event) => previewCsv(event.target.files?.[0] ?? null)} /></label>
<label className="rounded-lg border border-dashed border-primary/40 p-4 transition hover:border-primary hover:bg-accent/10 hover:shadow-soft">JSON importieren<input type="file" accept=".json" className="mt-3 block w-full cursor-pointer" onChange={(event) => importJson(event.target.files?.[0] ?? null)} /></label>
</div>
{summary && <p className="mt-3 text-sm text-primary-dark">{summary}</p>}
{preview && <div className="mt-4 overflow-x-auto"><p className="mb-2 text-sm text-text-light">Gueltig: {preview.valid_rows}, fehlerhaft: {preview.invalid_rows}, Duplikate: {preview.duplicates}</p><table className="min-w-full text-left text-sm"><thead className="bg-background text-xs uppercase text-text-light"><tr><th className="px-3 py-2">Zeile</th><th className="px-3 py-2">Berichtsnummer</th><th className="px-3 py-2">Status</th><th className="px-3 py-2">Fehler</th></tr></thead><tbody>{preview.rows.map((row) => <tr key={row.row_number} className="border-t border-border"><td className="px-3 py-2">{row.row_number}</td><td className="px-3 py-2">{row.data.report_number}</td><td className="px-3 py-2">{row.duplicate ? "Duplikat" : "Neu"}</td><td className="px-3 py-2 text-danger">{row.errors.join(", ")}</td></tr>)}</tbody></table></div>}
</section>
);
} }

View file

@ -25,3 +25,158 @@ textarea {
font: inherit; font: inherit;
} }
button,
a {
transition:
background-color 180ms ease,
border-color 180ms ease,
box-shadow 180ms ease,
color 180ms ease,
opacity 180ms ease,
transform 180ms ease;
}
button {
cursor: pointer;
}
button:hover:not(:disabled) {
box-shadow: 0 12px 28px rgba(46, 59, 64, 0.12);
transform: translateY(-1px);
}
button:hover:not(:disabled) svg,
a:hover svg,
label:hover svg {
transform: scale(1.08);
}
button:active:not(:disabled),
a:active {
transform: scale(0.98);
}
button:disabled,
[aria-disabled="true"] {
cursor: not-allowed;
opacity: 0.45;
}
a:hover {
text-decoration: underline;
text-underline-offset: 4px;
}
svg {
transition: transform 180ms ease, color 180ms ease;
}
input,
select,
textarea {
transition: border-color 180ms ease, box-shadow 180ms ease, background-color 180ms ease;
}
input:focus,
select:focus,
textarea:focus {
border-color: #6C8A96;
box-shadow: 0 0 0 3px rgba(108, 138, 150, 0.18);
outline: none;
}
tbody tr {
transition: background-color 180ms ease;
}
tbody tr:hover {
background: rgba(167, 199, 199, 0.14);
}
.btn {
align-items: center;
border: 1px solid #E6EAEA;
border-radius: 8px;
display: inline-flex;
font-weight: 700;
gap: 8px;
justify-content: center;
min-height: 44px;
padding: 10px 16px;
}
.btn-secondary {
background: #FFFFFF;
color: #2E3B40;
}
.btn-secondary:hover:not(:disabled) {
background: #F7F8F8;
border-color: #A7C7C7;
}
.btn-secondary:active:not(:disabled) {
background: #E6EAEA;
}
.btn-primary {
background: #6C8A96;
border-color: #6C8A96;
color: #FFFFFF;
}
.btn-primary:hover:not(:disabled) {
background: #4F6A74;
}
.btn-primary:active:not(:disabled) {
background: #415B64;
}
.btn-danger {
background: #FFFFFF;
border-color: rgba(201, 92, 84, 0.35);
color: #C95C54;
}
.btn-danger:hover:not(:disabled) {
background: rgba(201, 92, 84, 0.08);
border-color: #C95C54;
}
.btn-success {
background: rgba(102, 162, 107, 0.12);
border-color: rgba(102, 162, 107, 0.35);
color: #427646;
}
.icon-btn {
align-items: center;
background: #FFFFFF;
border: 1px solid #E6EAEA;
border-radius: 8px;
display: inline-flex;
height: 38px;
justify-content: center;
width: 38px;
}
.icon-btn:hover:not(:disabled) {
background: #F7F8F8;
border-color: #A7C7C7;
}
.spinner {
animation: spin 0.8s linear infinite;
border: 2px solid currentColor;
border-right-color: transparent;
border-radius: 999px;
height: 16px;
width: 16px;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}

View file

@ -0,0 +1,39 @@
"use client";
import { Check } from "lucide-react";
export type ActionState = "normal" | "loading" | "success" | "error";
export function ActionButton({
children,
icon,
state = "normal",
loadingText,
successText = "Gespeichert",
disabled,
disabledReason,
variant = "secondary",
className = "",
...props
}: React.ButtonHTMLAttributes<HTMLButtonElement> & {
icon?: React.ReactNode;
state?: ActionState;
loadingText?: string;
successText?: string;
disabledReason?: string;
variant?: "primary" | "secondary" | "danger" | "success";
}) {
const isDisabled = disabled || state === "loading";
const variantClass = state === "success" ? "btn-success" : state === "error" ? "btn-danger" : `btn-${variant}`;
return (
<button
{...props}
disabled={isDisabled}
title={isDisabled ? disabledReason : props.title}
className={`btn ${variantClass} ${className}`}
>
{state === "loading" ? <span className="spinner" /> : state === "success" ? <Check className="h-4 w-4" /> : icon}
<span>{state === "loading" ? loadingText ?? "Laedt..." : state === "success" ? successText : children}</span>
</button>
);
}

View file

@ -45,7 +45,7 @@ export function AppShell({ children }: { children: React.ReactNode }) {
<Link <Link
key={`${item.label}-${index}`} key={`${item.label}-${index}`}
href={item.href} href={item.href}
className={`flex h-11 items-center gap-3 rounded-lg px-3 text-sm font-medium transition ${ className={`flex h-11 items-center gap-3 rounded-lg px-3 text-sm font-medium transition hover:shadow-soft active:scale-[0.98] ${
active ? "bg-accent/35 text-primary-dark" : "text-text-light hover:bg-background hover:text-text" active ? "bg-accent/35 text-primary-dark" : "text-text-light hover:bg-background hover:text-text"
}`} }`}
> >
@ -58,7 +58,7 @@ export function AppShell({ children }: { children: React.ReactNode }) {
<button <button
type="button" type="button"
onClick={auth.logout} 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" className="btn btn-primary absolute bottom-6 left-5 right-5 h-11 text-sm"
> >
<LogOut className="h-4 w-4" /> <LogOut className="h-4 w-4" />
Logout Logout

View file

@ -103,7 +103,7 @@ export function CrudPage<T extends Entity>({
<h1 className="text-3xl font-semibold text-text">{title}</h1> <h1 className="text-3xl font-semibold text-text">{title}</h1>
<p className="mt-2 text-text-light">{subtitle}</p> <p className="mt-2 text-text-light">{subtitle}</p>
</div> </div>
<button onClick={startCreate} className="h-12 rounded-lg bg-primary px-5 font-semibold text-white shadow-soft">Neu</button> <button onClick={startCreate} className="btn btn-primary h-12">Neu</button>
</header> </header>
<div className="flex items-center gap-3 rounded-lg border border-border bg-surface px-4 py-3 shadow-soft"> <div className="flex items-center gap-3 rounded-lg border border-border bg-surface px-4 py-3 shadow-soft">
@ -130,12 +130,12 @@ export function CrudPage<T extends Entity>({
</thead> </thead>
<tbody className="divide-y divide-border"> <tbody className="divide-y divide-border">
{rows.map((item) => ( {rows.map((item) => (
<tr key={item.id}> <tr key={item.id} className="hover:bg-accent/10">
{columns.map((column) => <td key={String(column.key)} className="whitespace-nowrap px-5 py-4">{valueForInput(item[column.key])}</td>)} {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"> <td className="px-5 py-4">
<div className="flex justify-end gap-2"> <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="Bearbeiten" title="Bearbeiten" onClick={() => startEdit(item)} className="icon-btn 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> <button aria-label="Loeschen" title={deleteMutation.isPending ? "Loeschen laeuft..." : "Loeschen"} disabled={deleteMutation.isPending} onClick={() => deleteMutation.mutate(item)} className="icon-btn text-danger">{deleteMutation.isPending ? <span className="spinner" /> : <Trash2 className="h-4 w-4" />}</button>
</div> </div>
</td> </td>
</tr> </tr>
@ -151,9 +151,9 @@ export function CrudPage<T extends Entity>({
<footer className="flex items-center justify-between text-sm text-text-light"> <footer className="flex items-center justify-between text-sm text-text-light">
<span>{query.data?.total ?? 0} Datensaetze</span> <span>{query.data?.total ?? 0} Datensaetze</span>
<div className="flex items-center gap-3"> <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> <button disabled={page <= 1} title={page <= 1 ? "Erste Seite erreicht" : undefined} onClick={() => setPage((value) => value - 1)} className="btn btn-secondary">Zurueck</button>
<span>Seite {page} von {totalPages}</span> <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> <button disabled={page >= totalPages} title={page >= totalPages ? "Letzte Seite erreicht" : undefined} onClick={() => setPage((value) => value + 1)} className="btn btn-secondary">Weiter</button>
</div> </div>
</footer> </footer>
@ -162,7 +162,7 @@ export function CrudPage<T extends Entity>({
<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"> <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"> <div className="mb-5 flex items-center justify-between">
<h2 className="text-xl font-semibold">{editing ? "Bearbeiten" : "Neu"}</h2> <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> <button type="button" onClick={() => setOpen(false)} className="icon-btn"><X className="h-4 w-4" /></button>
</div> </div>
<div className="grid gap-4 sm:grid-cols-2"> <div className="grid gap-4 sm:grid-cols-2">
{fields.map((field) => ( {fields.map((field) => (
@ -184,8 +184,8 @@ export function CrudPage<T extends Entity>({
</div> </div>
{saveMutation.isError && <p className="mt-4 text-sm text-danger">Speichern fehlgeschlagen. Bitte Eingaben pruefen.</p>} {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"> <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="button" onClick={() => setOpen(false)} className="btn btn-secondary">Abbrechen</button>
<button type="submit" className="rounded-lg bg-primary px-5 py-3 font-semibold text-white shadow-soft">Speichern</button> <button type="submit" disabled={saveMutation.isPending} title={saveMutation.isPending ? "Speichern laeuft..." : undefined} className="btn btn-primary">{saveMutation.isPending ? <span className="spinner" /> : null}{saveMutation.isPending ? "Speichern..." : "Speichern"}</button>
</div> </div>
</form> </form>
</div> </div>

View file

@ -0,0 +1,515 @@
"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 { ActionButton, ActionState } from "@/components/action-button";
import { useAuth } from "@/components/auth";
import { API_BASE, apiGet, apiSend, Contact, Customer, Device, Equipment, Location, normalizeValidationPayload, 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 requiredLabels: Record<string, { label: string; section: string }> = {
report_number: { label: "Berichtsnummer", section: "Allgemeine Angaben" },
validation_type: { label: "Validierungsart", section: "Allgemeine Angaben" },
performed_on: { label: "Pruefdatum", section: "Allgemeine Angaben" },
customer_id: { label: "Kunde", section: "Kunde und Standort" },
location_id: { label: "Standort", section: "Kunde und Standort" },
device_id: { label: "Geraet", section: "Geraet" },
examiner_name: { label: "Pruefer", section: "Allgemeine Angaben" }
};
const schema = z.object({
report_number: z.string().min(3),
validation_type: z.string().min(1),
project: z.string().optional(),
performed_on: z.string().min(1),
next_validation_on: z.string().optional().nullable(),
revalidation_interval_months: z.coerce.number().int().min(1),
next_validation_manually_overridden: z.boolean(),
test_location: z.string().optional(),
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().min(1),
contact_id: z.string().optional().nullable(),
examiner_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>;
type ReviewIssue = { field: string; message: string; section: string };
type ReviewResult = { status: string; errors: ReviewIssue[]; warnings: ReviewIssue[]; complete_sections: string[] };
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,
next_validation_on: "",
revalidation_interval_months: 24,
next_validation_manually_overridden: false,
test_location: "",
examiner_name: "",
participants: "",
status: "ENTWURF",
result: "offen",
customer_id: "",
location_id: "",
contact_id: "",
examiner_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 hover:bg-background">
{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 function ValidationEditor({ validationId }: { validationId?: string }) {
const { token } = useAuth();
const client = useQueryClient();
const [draftId, setDraftId] = useState<string | null>(validationId ?? null);
const [lastSaved, setLastSaved] = useState<string>("");
const [formMessage, setFormMessage] = useState("");
const [reviewResult, setReviewResult] = useState<ReviewResult | null>(null);
const [saveState, setSaveState] = useState<ActionState>("normal");
const [reviewState, setReviewState] = useState<ActionState>("normal");
const [pdfState, setPdfState] = useState<ActionState>("normal");
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 existingValidation = useQuery({ queryKey: ["validation", validationId, token], queryFn: () => apiGet<ValidationItem>(`/validations/${validationId}`, token ?? ""), enabled: Boolean(token && validationId) });
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]);
useEffect(() => {
if (!existingValidation.data) return;
const data = existingValidation.data;
form.reset({
...defaults(),
...data,
result: data.result ?? "offen",
project: data.project ?? "",
test_location: data.test_location ?? "",
examiner_name: data.examiner_name ?? "",
participants: data.participants ?? "",
operator_name: data.operator_name ?? "",
performed_on: data.performed_on ?? "",
next_validation_on: data.next_validation_on ?? "",
contact_id: data.contact_id ?? "",
examiner_id: "",
location_id: data.location_id ?? "",
device_id: data.device_id ?? ""
});
setDraftId(data.id);
}, [existingValidation.data, form]);
const readonly = existingValidation.data?.status === "FREIGEGEBEN" || existingValidation.data?.status === "ABGESCHLOSSEN";
const saveMutation = useMutation({
mutationFn: (values: FormValues) => apiSend<ValidationItem>(draftId ? `/validations/${draftId}` : "/validations", token ?? "", draftId ? "PUT" : "POST", normalizeValidationPayload(values)),
onSuccess: (item) => {
setDraftId(item.id);
setLastSaved(new Date().toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" }));
setFormMessage("");
setSaveState("success");
window.setTimeout(() => setSaveState("normal"), 1500);
client.invalidateQueries({ queryKey: ["dashboard"] });
},
onError: (error) => {
setSaveState("error");
setFormMessage(error instanceof Error ? error.message : "Speichern fehlgeschlagen.");
}
});
useEffect(() => {
if (readonly || !token || !watched.report_number || !watched.validation_type || !watched.customer_id || !watched.location_id || !watched.device_id || !watched.examiner_name || !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 missingRequired(values = form.getValues()) {
return Object.entries(requiredLabels).filter(([field]) => !values[field as keyof FormValues]);
}
function scrollToFirstMissing() {
const first = missingRequired()[0];
if (!first) return false;
setFormMessage(`${first[1].label} fehlt in ${first[1].section}.`);
document.querySelector(`[name="${first[0]}"]`)?.scrollIntoView({ behavior: "smooth", block: "center" });
return true;
}
function submit(values: FormValues) {
if (readonly) {
setFormMessage("Diese Validierung ist schreibgeschuetzt.");
return;
}
if (missingRequired(values).length) {
setSaveState("error");
scrollToFirstMissing();
return;
}
setSaveState("loading");
saveMutation.mutate(values);
}
async function reviewValidation() {
if (readonly) {
setFormMessage("Diese Validierung ist schreibgeschuetzt.");
return;
}
if (missingRequired().length) {
setReviewState("error");
scrollToFirstMissing();
return;
}
try {
setReviewState("loading");
const saved = draftId ? null : await saveMutation.mutateAsync(form.getValues());
const id = draftId ?? saved?.id;
if (!id || !token) return;
const response = await fetch(`${API_BASE}/validations/${id}/review`, { method: "POST", headers: { Authorization: `Bearer ${token}` } });
const result = (await response.json()) as ReviewResult;
setReviewResult(result);
setReviewState("success");
window.setTimeout(() => setReviewState("normal"), 1500);
setFormMessage(result.errors.length ? "Pruefung abgeschlossen: Fehler blockieren die Freigabe." : "Pruefung abgeschlossen: keine blockierenden Fehler.");
} catch (error) {
setReviewState("error");
setFormMessage(error instanceof Error ? error.message : "Pruefung fehlgeschlagen.");
}
}
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) }));
}
async function openReportPreview() {
if (!draftId || !token) return;
const response = await fetch(`${API_BASE}/validations/${draftId}/report.html`, {
headers: { Authorization: `Bearer ${token}` }
});
const html = await response.text();
const preview = window.open("", "_blank");
if (preview) {
preview.document.write(html);
preview.document.close();
}
}
async function downloadReport() {
if (!draftId || !token) return;
try {
setPdfState("loading");
const response = await fetch(`${API_BASE}/validations/${draftId}/report.pdf`, {
headers: { Authorization: `Bearer ${token}` }
});
if (!response.ok) throw new Error("PDF konnte nicht erzeugt werden.");
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = `${form.getValues("report_number")}.pdf`;
link.click();
URL.revokeObjectURL(url);
setPdfState("success");
window.setTimeout(() => setPdfState("normal"), 1500);
} catch (error) {
setPdfState("error");
setFormMessage(error instanceof Error ? error.message : "PDF-Download fehlgeschlagen.");
}
}
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">{readonly ? "Schreibgeschuetzte freigegebene oder abgeschlossene Validierung." : "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="Revalidierungsintervall Monate"><input type="number" className={inputClass} {...form.register("revalidation_interval_months")} /></Field>
<Field label="Naechste Validierung"><input type="date" className={inputClass} {...form.register("next_validation_on")} onChange={(event) => { form.setValue("next_validation_manually_overridden", true); form.setValue("next_validation_on", event.target.value); }} /></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="ENTWURF">Entwurf</option><option value="BEREIT_ZUR_PRUEFUNG">Bereit zur Pruefung</option><option value="IN_PRUEFUNG">In Pruefung</option><option value="FREIGEGEBEN">Freigegeben</option><option value="ABGESCHLOSSEN">Abgeschlossen</option><option value="STORNIERT">Storniert</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} />)}
<ActionButton type="button" icon={<Plus className="h-4 w-4" />} onClick={() => form.setValue("programs", [...form.getValues("programs"), { name: "", selected: true, custom: true }])} className="mt-4">Eigenes Programm</ActionButton>
</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>)}
<ActionButton type="button" icon={<Plus className="h-4 w-4" />} onClick={() => recommendations.append({ number: recommendations.fields.length + 1, text: "", deadline: "", status: "offen" })}>Zeile hinzufuegen</ActionButton>
</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>
{reviewResult && <section className="rounded-lg border border-border bg-surface p-5 shadow-soft"><h2 className="text-xl font-semibold">Pruefergebnis</h2><div className="mt-4 grid gap-4 md:grid-cols-3"><IssueList title="Fehler" items={reviewResult.errors} tone="danger" /><IssueList title="Warnungen" items={reviewResult.warnings} tone="warning" /><div><h3 className="font-semibold text-success">Vollstaendige Bereiche</h3>{reviewResult.complete_sections.map((item) => <p key={item} className="mt-2 text-sm">{item}</p>)}</div></div></section>}
{saveMutation.isError && formMessage && <div className="fixed right-4 top-4 z-50 max-w-md rounded-lg border border-danger/30 bg-white p-4 text-sm text-danger shadow-soft">{formMessage}</div>}
<fieldset disabled={readonly} className={readonly ? "pointer-events-none contents opacity-80" : "contents"}></fieldset>
<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">
<div className="text-sm text-text-light"><p>{lastSaved ? `Automatisch gespeichert um ${lastSaved}` : "Autosave startet nach Ausfuellen der Pflichtfelder."}</p>{missingRequired().length > 0 && <p className="text-danger">Fehlt: {missingRequired().map(([, value]) => value.label).join(", ")}</p>}{formMessage && <p className="text-primary-dark">{formMessage}</p>}</div>
<div className="flex flex-wrap gap-2">
<ActionButton type="submit" disabled={readonly} disabledReason="Freigegebene und abgeschlossene Validierungen sind schreibgeschuetzt" icon={<Save className="h-4 w-4" />} state={saveState} loadingText="Speichern..." successText="Gespeichert">Entwurf speichern</ActionButton>
<ActionButton type="button" disabled={readonly} disabledReason="Freigegebene und abgeschlossene Validierungen sind schreibgeschuetzt" icon={<ShieldCheck className="h-4 w-4" />} state={reviewState} loadingText="Pruefung laeuft..." successText="Geprueft" onClick={reviewValidation}>Bericht pruefen</ActionButton>
<ActionButton type="button" variant="primary" icon={<FileText className="h-4 w-4" />} onClick={() => draftId ? window.location.assign(`/validations/${draftId}/preview`) : scrollToFirstMissing()}>HTML-Vorschau</ActionButton>
<ActionButton type="button" icon={<Download className="h-4 w-4" />} state={pdfState} loadingText="PDF wird erzeugt..." successText="PDF erzeugt" onClick={() => draftId ? downloadReport() : scrollToFirstMissing()}>PDF herunterladen</ActionButton>
</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 IssueList({ title, items, tone }: { title: string; items: ReviewIssue[]; tone: "danger" | "warning" }) {
return <div><h3 className={`font-semibold ${tone === "danger" ? "text-danger" : "text-warning"}`}>{title}</h3>{items.length === 0 ? <p className="mt-2 text-sm text-text-light">Keine Eintraege.</p> : items.map((item) => <p key={`${item.field}-${item.message}`} className="mt-2 text-sm">{item.section}: {item.message}</p>)}</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>;
}
export default ValidationEditor;

View file

@ -78,6 +78,10 @@ export type ValidationItem = Entity & {
scheduled_on?: string | null; scheduled_on?: string | null;
performed_on?: string | null; performed_on?: string | null;
next_validation_on?: string | null; next_validation_on?: string | null;
revalidation_interval_months: number;
next_validation_manually_overridden: boolean;
version: number;
previous_validation_id?: string | null;
equipment_ids: string[]; equipment_ids: string[];
environment_conditions: Record<string, unknown>; environment_conditions: Record<string, unknown>;
documentation_checklist: Record<string, unknown>[]; documentation_checklist: Record<string, unknown>[];
@ -90,6 +94,14 @@ export type ValidationItem = Entity & {
attachments: Record<string, unknown>[]; attachments: Record<string, unknown>[];
}; };
export function normalizeValidationPayload<T extends Record<string, unknown>>(values: T): T {
const optionalForeignKeys = ["contact_id", "examiner_id"];
return {
...values,
...Object.fromEntries(optionalForeignKeys.map((key) => [key, values[key] || null]))
};
}
export type Paginated<T> = { export type Paginated<T> = {
items: T[]; items: T[];
total: number; total: number;