feat(validation): add editing versioning revalidation and aligned reports
This commit is contained in:
parent
f73a24df13
commit
302e542fda
28 changed files with 2691 additions and 406 deletions
|
|
@ -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
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
@ -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")
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
]
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
@ -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))
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
|
||||||
45
validation-suite/backend/mercury/app/modules/orion/html.py
Normal file
45
validation-suite/backend/mercury/app/modules/orion/html.py
Normal 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>"
|
||||||
|
|
@ -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]
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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>"""
|
||||||
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
@ -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*"]
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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} />;
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
import ValidationEditor from "@/components/validations/validation-editor";
|
||||||
|
|
||||||
|
export default function NewValidationPage() {
|
||||||
|
return <ValidationEditor />;
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
39
validation-suite/frontend/atlas/components/action-button.tsx
Normal file
39
validation-suite/frontend/atlas/components/action-button.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue