feat(repairs): add repair documents
This commit is contained in:
parent
884a20e043
commit
6e7e75f864
21 changed files with 1222 additions and 6 deletions
5
.gitignore
vendored
5
.gitignore
vendored
|
|
@ -39,6 +39,11 @@ out/
|
||||||
# ===========================
|
# ===========================
|
||||||
*.log
|
*.log
|
||||||
|
|
||||||
|
# ===========================
|
||||||
|
# Runtime storage
|
||||||
|
# ===========================
|
||||||
|
storage/
|
||||||
|
|
||||||
# ===========================
|
# ===========================
|
||||||
# Coverage
|
# Coverage
|
||||||
# ===========================
|
# ===========================
|
||||||
|
|
|
||||||
|
|
@ -322,6 +322,64 @@ Hermes-Endpunkte:
|
||||||
|
|
||||||
Athena ruft diese Endpunkte ausschliesslich ueber BFF-Routen unter `/api/system-settings/...` auf. Mutierende Requests verwenden den bestehenden Same-Origin-Schutz. Zugriff erfordert `system_settings.manage`, das der Administratorrolle zugewiesen ist.
|
Athena ruft diese Endpunkte ausschliesslich ueber BFF-Routen unter `/api/system-settings/...` auf. Mutierende Requests verwenden den bestehenden Same-Origin-Schutz. Zugriff erfordert `system_settings.manage`, das der Administratorrolle zugewiesen ist.
|
||||||
|
|
||||||
|
### Reparaturdokumente
|
||||||
|
|
||||||
|
Ab v0.8.5 verwaltet Olympus Bilder und Dokumente direkt an Reparaturen. Die Fachlogik liegt im Repair-Modul, Dateizugriffe laufen ueber das zentrale Storage Framework.
|
||||||
|
|
||||||
|
Datenmodell `repair_documents`:
|
||||||
|
|
||||||
|
- `repair_id`
|
||||||
|
- `title`
|
||||||
|
- `document_type`
|
||||||
|
- `original_filename`
|
||||||
|
- `stored_filename`
|
||||||
|
- `storage_path`
|
||||||
|
- `mime_type`
|
||||||
|
- `size_bytes`
|
||||||
|
- `checksum_sha256`
|
||||||
|
- `visibility`
|
||||||
|
- `note`
|
||||||
|
- `uploaded_by_user_id`
|
||||||
|
- Zeitstempel
|
||||||
|
|
||||||
|
Dokumenttypen:
|
||||||
|
|
||||||
|
- `device_photo`
|
||||||
|
- `fault_photo`
|
||||||
|
- `measurement`
|
||||||
|
- `estimate`
|
||||||
|
- `repair_report`
|
||||||
|
- `shipping`
|
||||||
|
- `other`
|
||||||
|
|
||||||
|
`visibility` kann `internal` oder `customer` sein. Die Kundensicht ist nur vorbereitet; in v0.8.5 werden Dokumente nicht oeffentlich fuer Kunden ausgeliefert.
|
||||||
|
|
||||||
|
Storage:
|
||||||
|
|
||||||
|
- Namespace: `repairs/<repair_id>/documents`
|
||||||
|
- Erlaubte Uploads: JPG, PNG, WEBP und PDF
|
||||||
|
- Dateinamen werden normalisiert
|
||||||
|
- SHA-256 wird gespeichert
|
||||||
|
- Dateiinhalte werden nicht geloggt
|
||||||
|
- Storage-Pfade werden nicht in Audit-Metadaten geschrieben
|
||||||
|
- Browser erhalten Dateien nur ueber Athena-BFF und Hermes-RBAC
|
||||||
|
|
||||||
|
Hermes-Endpunkte:
|
||||||
|
|
||||||
|
- `GET /repairs/{id}/documents`
|
||||||
|
- `POST /repairs/{id}/documents/upload`
|
||||||
|
- `GET /repairs/{id}/documents/{document_id}`
|
||||||
|
- `PUT /repairs/{id}/documents/{document_id}`
|
||||||
|
- `GET /repairs/{id}/documents/{document_id}/download`
|
||||||
|
- `DELETE /repairs/{id}/documents/{document_id}`
|
||||||
|
|
||||||
|
RBAC:
|
||||||
|
|
||||||
|
- Liste, Detail und Download erfordern `repairs.read`.
|
||||||
|
- Upload, Aenderung und Loeschung erfordern `repairs.update`.
|
||||||
|
|
||||||
|
Athena ruft diese Endpunkte ausschliesslich ueber `/api/repairs/[id]/documents...` auf. Mutierende Requests verwenden den Same-Origin-Schutz.
|
||||||
|
|
||||||
### Website Repair Intake
|
### Website Repair Intake
|
||||||
|
|
||||||
Hermes stellt `POST /public/repair-intake` fuer eine spaetere serverseitige Website-Anbindung bereit.
|
Hermes stellt `POST /public/repair-intake` fuer eine spaetere serverseitige Website-Anbindung bereit.
|
||||||
|
|
@ -741,6 +799,8 @@ Seit v0.7.0 laufen Knowledge-Dateien ueber das zentrale Storage Framework. Knowl
|
||||||
|
|
||||||
Neue Uploads werden im Namespace `knowledge/documents/<manufacturer_id>` gespeichert. Die Datenbank-Metadaten bleiben kompatibel: `knowledge_documents.file_path` enthaelt den Storage-Key fuer neue Dateien oder einen bestehenden Legacy-Pfad fuer alte Dateien.
|
Neue Uploads werden im Namespace `knowledge/documents/<manufacturer_id>` gespeichert. Die Datenbank-Metadaten bleiben kompatibel: `knowledge_documents.file_path` enthaelt den Storage-Key fuer neue Dateien oder einen bestehenden Legacy-Pfad fuer alte Dateien.
|
||||||
|
|
||||||
|
Reparaturdokumente werden ab v0.8.5 im Namespace `repairs/<repair_id>/documents` gespeichert. Zulaessig sind JPG, PNG, WEBP und PDF. PDF-Anzeige bleibt auf Inline-Open/Download begrenzt; ein eigener PDF-Viewer ist ein spaeteres Feature.
|
||||||
|
|
||||||
Konfiguration:
|
Konfiguration:
|
||||||
|
|
||||||
- `STORAGE_PROVIDER`, Default `local`
|
- `STORAGE_PROVIDER`, Default `local`
|
||||||
|
|
|
||||||
|
|
@ -154,6 +154,10 @@ Athena erreicht Reparaturen ausschliesslich ueber BFF-Routen:
|
||||||
|
|
||||||
- `/api/repairs`
|
- `/api/repairs`
|
||||||
- `/api/repairs/[id]`
|
- `/api/repairs/[id]`
|
||||||
|
- `/api/repairs/[id]/documents`
|
||||||
|
- `/api/repairs/[id]/documents/upload`
|
||||||
|
- `/api/repairs/[id]/documents/[documentId]`
|
||||||
|
- `/api/repairs/[id]/documents/[documentId]/download`
|
||||||
- `/api/repairs/[id]/status`
|
- `/api/repairs/[id]/status`
|
||||||
- `/api/repairs/[id]/history`
|
- `/api/repairs/[id]/history`
|
||||||
- `/api/repairs/[id]/public-link`
|
- `/api/repairs/[id]/public-link`
|
||||||
|
|
@ -185,6 +189,15 @@ Benachrichtigungen:
|
||||||
- `SMTP_PASSWORD` wird nicht an Athena zurueckgegeben und gehoert nie ins Git oder in Logs.
|
- `SMTP_PASSWORD` wird nicht an Athena zurueckgegeben und gehoert nie ins Git oder in Logs.
|
||||||
- Verschluesselung at rest ist fuer v0.8.x vorbereitet/geplant; bis dahin bleibt das Passwort serverseitig in `system_settings` und wird in Responses/Audits maskiert.
|
- Verschluesselung at rest ist fuer v0.8.x vorbereitet/geplant; bis dahin bleibt das Passwort serverseitig in `system_settings` und wird in Responses/Audits maskiert.
|
||||||
|
|
||||||
|
Reparaturdokumente:
|
||||||
|
|
||||||
|
- Ab v0.8.5 koennen Dokumente und Bilder direkt an Reparaturen gepflegt werden.
|
||||||
|
- Hermes speichert Uploads ueber `StorageService` unter `repairs/<repair_id>/documents`.
|
||||||
|
- Erlaubte Dateitypen: JPG, PNG, WEBP und PDF.
|
||||||
|
- Metadaten liegen in `repair_documents`; Dateiinhalte liegen nie im Git oder in `public`.
|
||||||
|
- `visibility` ist mit `internal` und `customer` vorbereitet. In v0.8.5 gibt es noch keine oeffentliche Kundenanzeige fuer diese Dateien.
|
||||||
|
- PDF-Dateien koennen inline oder als Download ueber Athena-BFF geoeffnet werden; ein vollstaendiger PDF-Viewer ist ein Folgefeature.
|
||||||
|
|
||||||
Vorbereitete Website-/Portal-Routen fuer spaeter:
|
Vorbereitete Website-/Portal-Routen fuer spaeter:
|
||||||
|
|
||||||
- `/status/<token>`
|
- `/status/<token>`
|
||||||
|
|
|
||||||
13
ROADMAP.md
13
ROADMAP.md
|
|
@ -123,7 +123,18 @@ Die Roadmap beschreibt die geplante fachliche Entwicklung von Olympus CRM. Archi
|
||||||
- Passwort wird nicht an Athena zurueckgegeben
|
- Passwort wird nicht an Athena zurueckgegeben
|
||||||
- Encryption at rest fuer Secrets bleibt als v0.8.x-Haertung geplant
|
- Encryption at rest fuer Secrets bleibt als v0.8.x-Haertung geplant
|
||||||
|
|
||||||
## v0.8.5 - Kundenportal, geplant
|
## v0.8.5 - Reparaturdokumente
|
||||||
|
|
||||||
|
- Dokumente und Bilder direkt an Reparaturen verwalten
|
||||||
|
- Upload ueber Athena-BFF und Hermes StorageService
|
||||||
|
- Storage-Pfad `repairs/<repair_id>/documents`
|
||||||
|
- Erlaubte Dateitypen JPG, PNG, WEBP und PDF
|
||||||
|
- Dokumenttypen fuer Geraetefotos, Fehlerbilder, Messbilder, Kostenvoranschlag, Reparaturbericht, Versandbeleg und Sonstiges
|
||||||
|
- Sichtbarkeit `internal`/`customer` vorbereitet, aber noch keine oeffentliche Kundenanzeige
|
||||||
|
- Bildvorschau mit Lightbox, PDF inline/download
|
||||||
|
- Audit Logs fuer Upload, Aenderung und Loeschung
|
||||||
|
|
||||||
|
## v0.8.6 - Kundenportal, geplant
|
||||||
|
|
||||||
- `/portal/login` fuer spaeteren Kundenlogin
|
- `/portal/login` fuer spaeteren Kundenlogin
|
||||||
- Separates Authentifizierungsmodell fuer Kunden
|
- Separates Authentifizierungsmodell fuer Kunden
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,58 @@
|
||||||
|
"""extend repair documents
|
||||||
|
|
||||||
|
Revision ID: a1c5f9e2d430
|
||||||
|
Revises: f2b8d4e6a910
|
||||||
|
Create Date: 2026-07-05 09:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision: str = "a1c5f9e2d430"
|
||||||
|
down_revision: Union[str, Sequence[str], None] = "f2b8d4e6a910"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column("repair_documents", sa.Column("original_filename", sa.String(length=255), server_default="", nullable=False))
|
||||||
|
op.add_column("repair_documents", sa.Column("stored_filename", sa.String(length=255), server_default="", nullable=False))
|
||||||
|
op.alter_column("repair_documents", "storage_path", existing_type=sa.String(length=500), server_default="", nullable=False)
|
||||||
|
op.add_column("repair_documents", sa.Column("mime_type", sa.String(length=120), server_default="", nullable=False))
|
||||||
|
op.add_column("repair_documents", sa.Column("size_bytes", sa.BigInteger(), server_default="0", nullable=False))
|
||||||
|
op.add_column("repair_documents", sa.Column("checksum_sha256", sa.String(length=64), server_default="", nullable=False))
|
||||||
|
op.add_column("repair_documents", sa.Column("visibility", sa.String(length=40), server_default="internal", nullable=False))
|
||||||
|
op.add_column("repair_documents", sa.Column("note", sa.Text(), nullable=True))
|
||||||
|
op.add_column("repair_documents", sa.Column("uploaded_by_user_id", sa.Integer(), nullable=True))
|
||||||
|
op.add_column("repair_documents", sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False))
|
||||||
|
op.create_foreign_key(
|
||||||
|
op.f("fk_repair_documents_uploaded_by_user_id_users"),
|
||||||
|
"repair_documents",
|
||||||
|
"users",
|
||||||
|
["uploaded_by_user_id"],
|
||||||
|
["id"],
|
||||||
|
ondelete="SET NULL",
|
||||||
|
)
|
||||||
|
op.create_index(op.f("ix_repair_documents_checksum_sha256"), "repair_documents", ["checksum_sha256"], unique=False)
|
||||||
|
op.create_index(op.f("ix_repair_documents_uploaded_by_user_id"), "repair_documents", ["uploaded_by_user_id"], unique=False)
|
||||||
|
op.create_index(op.f("ix_repair_documents_visibility"), "repair_documents", ["visibility"], unique=False)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index(op.f("ix_repair_documents_visibility"), table_name="repair_documents")
|
||||||
|
op.drop_index(op.f("ix_repair_documents_uploaded_by_user_id"), table_name="repair_documents")
|
||||||
|
op.drop_index(op.f("ix_repair_documents_checksum_sha256"), table_name="repair_documents")
|
||||||
|
op.drop_constraint(op.f("fk_repair_documents_uploaded_by_user_id_users"), "repair_documents", type_="foreignkey")
|
||||||
|
op.drop_column("repair_documents", "updated_at")
|
||||||
|
op.drop_column("repair_documents", "uploaded_by_user_id")
|
||||||
|
op.drop_column("repair_documents", "note")
|
||||||
|
op.drop_column("repair_documents", "visibility")
|
||||||
|
op.drop_column("repair_documents", "checksum_sha256")
|
||||||
|
op.drop_column("repair_documents", "size_bytes")
|
||||||
|
op.drop_column("repair_documents", "mime_type")
|
||||||
|
op.alter_column("repair_documents", "storage_path", existing_type=sa.String(length=500), nullable=True, server_default=None)
|
||||||
|
op.drop_column("repair_documents", "stored_filename")
|
||||||
|
op.drop_column("repair_documents", "original_filename")
|
||||||
|
|
@ -70,6 +70,7 @@ def get_dashboard_summary(
|
||||||
MetricCard(label="Statusmails heute", value=RepairRepository.count_status_mails_sent_today(db)),
|
MetricCard(label="Statusmails heute", value=RepairRepository.count_status_mails_sent_today(db)),
|
||||||
MetricCard(label="Fehlgeschlagene Mails", value=RepairRepository.count_failed_status_mails(db)),
|
MetricCard(label="Fehlgeschlagene Mails", value=RepairRepository.count_failed_status_mails(db)),
|
||||||
MetricCard(label="Offen ohne Kundenmail", value=RepairRepository.count_open_repairs_without_customer_email(db)),
|
MetricCard(label="Offen ohne Kundenmail", value=RepairRepository.count_open_repairs_without_customer_email(db)),
|
||||||
|
MetricCard(label="Reparaturdokumente", value=RepairRepository.count_documents(db)),
|
||||||
]
|
]
|
||||||
|
|
||||||
if "system_settings.manage" in permissions:
|
if "system_settings.manage" in permissions:
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
from fastapi import APIRouter, Depends, Header, HTTPException, Query, status
|
from fastapi import APIRouter, Depends, File, Form, Header, HTTPException, Query, UploadFile, status
|
||||||
|
from fastapi.responses import FileResponse
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from starlette.requests import Request
|
from starlette.requests import Request
|
||||||
from typing import cast
|
from typing import cast
|
||||||
|
|
@ -11,6 +12,10 @@ from app.models.user import User
|
||||||
from app.repositories.repair_repository import RepairRepository
|
from app.repositories.repair_repository import RepairRepository
|
||||||
from app.schemas.repair import (
|
from app.schemas.repair import (
|
||||||
RepairCreate,
|
RepairCreate,
|
||||||
|
RepairDocumentResponse,
|
||||||
|
RepairDocumentType,
|
||||||
|
RepairDocumentUpdate,
|
||||||
|
RepairDocumentVisibility,
|
||||||
RepairIntakePayload,
|
RepairIntakePayload,
|
||||||
RepairIntakeResponse,
|
RepairIntakeResponse,
|
||||||
RepairNotificationEventResponse,
|
RepairNotificationEventResponse,
|
||||||
|
|
@ -26,6 +31,7 @@ from app.schemas.repair import (
|
||||||
RepairUpdate,
|
RepairUpdate,
|
||||||
)
|
)
|
||||||
from app.services.repair_notification_service import RepairNotificationService
|
from app.services.repair_notification_service import RepairNotificationService
|
||||||
|
from app.services.repair_document_service import RepairDocumentService
|
||||||
from app.services.repair_public_link_service import RepairPublicLinkService
|
from app.services.repair_public_link_service import RepairPublicLinkService
|
||||||
from app.services.repair_service import RepairService
|
from app.services.repair_service import RepairService
|
||||||
|
|
||||||
|
|
@ -130,6 +136,117 @@ def get_repair_history(
|
||||||
return RepairRepository.get_history(db, repair_id)
|
return RepairRepository.get_history(db, repair_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/repairs/{repair_id}/documents", response_model=list[RepairDocumentResponse])
|
||||||
|
def list_repair_documents(
|
||||||
|
repair_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_permission("repairs.read")),
|
||||||
|
):
|
||||||
|
get_repair_or_404(db, repair_id)
|
||||||
|
return RepairRepository.list_documents(db, repair_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/repairs/{repair_id}/documents/upload", response_model=RepairDocumentResponse, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def upload_repair_document(
|
||||||
|
repair_id: int,
|
||||||
|
request: Request,
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
title: str = Form(..., min_length=1, max_length=255),
|
||||||
|
document_type: RepairDocumentType = Form("other"),
|
||||||
|
visibility: RepairDocumentVisibility = Form("internal"),
|
||||||
|
note: str | None = Form(None),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_permission("repairs.update")),
|
||||||
|
):
|
||||||
|
db_repair = get_repair_or_404(db, repair_id)
|
||||||
|
normalized_title = title.strip()
|
||||||
|
normalized_note = note.strip() if note else None
|
||||||
|
if not normalized_title:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Titel ist erforderlich")
|
||||||
|
return await RepairDocumentService.upload_document(
|
||||||
|
db,
|
||||||
|
db_repair,
|
||||||
|
file=file,
|
||||||
|
title=normalized_title,
|
||||||
|
document_type=document_type,
|
||||||
|
visibility=visibility,
|
||||||
|
note=normalized_note,
|
||||||
|
actor=current_user,
|
||||||
|
request=request,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/repairs/{repair_id}/documents/{document_id}", response_model=RepairDocumentResponse)
|
||||||
|
def get_repair_document(
|
||||||
|
repair_id: int,
|
||||||
|
document_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_permission("repairs.read")),
|
||||||
|
):
|
||||||
|
get_repair_or_404(db, repair_id)
|
||||||
|
document = RepairRepository.get_document(db, repair_id=repair_id, document_id=document_id)
|
||||||
|
if document is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Dokument nicht gefunden")
|
||||||
|
return document
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/repairs/{repair_id}/documents/{document_id}", response_model=RepairDocumentResponse)
|
||||||
|
def update_repair_document(
|
||||||
|
repair_id: int,
|
||||||
|
document_id: int,
|
||||||
|
payload: RepairDocumentUpdate,
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_permission("repairs.update")),
|
||||||
|
):
|
||||||
|
db_repair = get_repair_or_404(db, repair_id)
|
||||||
|
document = RepairRepository.get_document(db, repair_id=repair_id, document_id=document_id)
|
||||||
|
if document is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Dokument nicht gefunden")
|
||||||
|
return RepairDocumentService.update_document(db, db_repair, document, payload, actor=current_user, request=request)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/repairs/{repair_id}/documents/{document_id}/download")
|
||||||
|
def download_repair_document(
|
||||||
|
repair_id: int,
|
||||||
|
document_id: int,
|
||||||
|
disposition: str = Query(default="inline", pattern="^(inline|attachment)$"),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_permission("repairs.read")),
|
||||||
|
):
|
||||||
|
get_repair_or_404(db, repair_id)
|
||||||
|
document = RepairRepository.get_document(db, repair_id=repair_id, document_id=document_id)
|
||||||
|
if document is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Dokument nicht gefunden")
|
||||||
|
file_path = RepairDocumentService.open_document_file(document)
|
||||||
|
return FileResponse(
|
||||||
|
file_path,
|
||||||
|
media_type=document.mime_type or "application/octet-stream",
|
||||||
|
headers={
|
||||||
|
"Content-Disposition": RepairDocumentService.content_disposition(
|
||||||
|
document,
|
||||||
|
mode="attachment" if disposition == "attachment" else "inline",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/repairs/{repair_id}/documents/{document_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
def delete_repair_document(
|
||||||
|
repair_id: int,
|
||||||
|
document_id: int,
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_permission("repairs.update")),
|
||||||
|
):
|
||||||
|
db_repair = get_repair_or_404(db, repair_id)
|
||||||
|
document = RepairRepository.get_document(db, repair_id=repair_id, document_id=document_id)
|
||||||
|
if document is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Dokument nicht gefunden")
|
||||||
|
RepairDocumentService.delete_document(db, db_repair, document, actor=current_user, request=request)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
@router.get("/repairs/{repair_id}/public-link", response_model=RepairPublicLinkResponse)
|
@router.get("/repairs/{repair_id}/public-link", response_model=RepairPublicLinkResponse)
|
||||||
def get_public_link(
|
def get_public_link(
|
||||||
repair_id: int,
|
repair_id: int,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, JSON, String, Text, func
|
from sqlalchemy import BigInteger, Boolean, DateTime, ForeignKey, Integer, JSON, String, Text, func
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
from app.db.database import Base
|
from app.db.database import Base
|
||||||
|
|
@ -41,6 +41,7 @@ class Repair(Base):
|
||||||
history: Mapped[list["RepairStatusHistory"]] = relationship(back_populates="repair", cascade="all, delete-orphan", lazy="selectin")
|
history: Mapped[list["RepairStatusHistory"]] = relationship(back_populates="repair", cascade="all, delete-orphan", lazy="selectin")
|
||||||
public_access_tokens: Mapped[list["RepairPublicAccessToken"]] = relationship(back_populates="repair", cascade="all, delete-orphan")
|
public_access_tokens: Mapped[list["RepairPublicAccessToken"]] = relationship(back_populates="repair", cascade="all, delete-orphan")
|
||||||
notification_events: Mapped[list["RepairNotificationEvent"]] = relationship(back_populates="repair", cascade="all, delete-orphan")
|
notification_events: Mapped[list["RepairNotificationEvent"]] = relationship(back_populates="repair", cascade="all, delete-orphan")
|
||||||
|
documents: Mapped[list["RepairDocument"]] = relationship(back_populates="repair", cascade="all, delete-orphan")
|
||||||
|
|
||||||
|
|
||||||
class RepairStatusHistory(Base):
|
class RepairStatusHistory(Base):
|
||||||
|
|
@ -89,10 +90,33 @@ class RepairDocument(Base):
|
||||||
id: Mapped[int] = mapped_column(primary_key=True)
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
repair_id: Mapped[int] = mapped_column(ForeignKey("repairs.id", ondelete="CASCADE"), index=True)
|
repair_id: Mapped[int] = mapped_column(ForeignKey("repairs.id", ondelete="CASCADE"), index=True)
|
||||||
file_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
file_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
storage_path: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
|
||||||
title: Mapped[str] = mapped_column(String(255))
|
title: Mapped[str] = mapped_column(String(255))
|
||||||
document_type: Mapped[str] = mapped_column(String(80), index=True)
|
document_type: Mapped[str] = mapped_column(String(80), index=True)
|
||||||
|
original_filename: Mapped[str] = mapped_column(String(255), default="", server_default="")
|
||||||
|
stored_filename: Mapped[str] = mapped_column(String(255), default="", server_default="")
|
||||||
|
storage_path: Mapped[str] = mapped_column(String(500), default="", server_default="")
|
||||||
|
mime_type: Mapped[str] = mapped_column(String(120), default="", server_default="")
|
||||||
|
size_bytes: Mapped[int] = mapped_column(BigInteger, default=0, server_default="0")
|
||||||
|
checksum_sha256: Mapped[str] = mapped_column(String(64), default="", server_default="", index=True)
|
||||||
|
visibility: Mapped[str] = mapped_column(String(40), default="internal", server_default="internal", index=True)
|
||||||
|
note: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
uploaded_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||||
|
|
||||||
|
repair: Mapped[Repair] = relationship(back_populates="documents")
|
||||||
|
uploaded_by = relationship("User", lazy="joined")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def uploaded_by_username(self) -> str:
|
||||||
|
return self.uploaded_by.username if self.uploaded_by is not None else ""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def uploaded_by_display_name(self) -> str:
|
||||||
|
if self.uploaded_by is None:
|
||||||
|
return ""
|
||||||
|
display_name = f"{self.uploaded_by.first_name} {self.uploaded_by.last_name}".strip()
|
||||||
|
return display_name or self.uploaded_by.username
|
||||||
|
|
||||||
|
|
||||||
class RepairPublicAccessToken(Base):
|
class RepairPublicAccessToken(Base):
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,8 @@ from datetime import UTC, datetime
|
||||||
from sqlalchemy import Select, func, or_, select
|
from sqlalchemy import Select, func, or_, select
|
||||||
from sqlalchemy.orm import Session, selectinload
|
from sqlalchemy.orm import Session, selectinload
|
||||||
|
|
||||||
from app.models.repair import Repair, RepairIntakeEvent, RepairNotificationEvent, RepairPublicAccessToken, RepairStatusHistory
|
from app.models.repair import Repair, RepairDocument, RepairIntakeEvent, RepairNotificationEvent, RepairPublicAccessToken, RepairStatusHistory
|
||||||
from app.schemas.repair import RepairCreate, RepairStatusUpdate, RepairUpdate
|
from app.schemas.repair import RepairCreate, RepairDocumentUpdate, RepairStatusUpdate, RepairUpdate
|
||||||
|
|
||||||
|
|
||||||
class RepairRepository:
|
class RepairRepository:
|
||||||
|
|
@ -362,6 +362,77 @@ class RepairRepository:
|
||||||
.where((Repair.customer_email == "") | Repair.customer_email.is_(None))
|
.where((Repair.customer_email == "") | Repair.customer_email.is_(None))
|
||||||
) or 0
|
) or 0
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def list_documents(db: Session, repair_id: int) -> list[RepairDocument]:
|
||||||
|
return list(
|
||||||
|
db.scalars(
|
||||||
|
select(RepairDocument)
|
||||||
|
.where(RepairDocument.repair_id == repair_id)
|
||||||
|
.order_by(RepairDocument.created_at.desc(), RepairDocument.id.desc())
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_document(db: Session, *, repair_id: int, document_id: int) -> RepairDocument | None:
|
||||||
|
return db.scalar(
|
||||||
|
select(RepairDocument)
|
||||||
|
.where(RepairDocument.repair_id == repair_id)
|
||||||
|
.where(RepairDocument.id == document_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def create_document(
|
||||||
|
db: Session,
|
||||||
|
*,
|
||||||
|
repair_id: int,
|
||||||
|
title: str,
|
||||||
|
document_type: str,
|
||||||
|
original_filename: str,
|
||||||
|
stored_filename: str,
|
||||||
|
storage_path: str,
|
||||||
|
mime_type: str,
|
||||||
|
size_bytes: int,
|
||||||
|
checksum_sha256: str,
|
||||||
|
visibility: str,
|
||||||
|
note: str | None,
|
||||||
|
uploaded_by_user_id: int | None,
|
||||||
|
) -> RepairDocument:
|
||||||
|
document = RepairDocument(
|
||||||
|
repair_id=repair_id,
|
||||||
|
title=title,
|
||||||
|
document_type=document_type,
|
||||||
|
original_filename=original_filename,
|
||||||
|
stored_filename=stored_filename,
|
||||||
|
storage_path=storage_path,
|
||||||
|
mime_type=mime_type,
|
||||||
|
size_bytes=size_bytes,
|
||||||
|
checksum_sha256=checksum_sha256,
|
||||||
|
visibility=visibility,
|
||||||
|
note=note,
|
||||||
|
uploaded_by_user_id=uploaded_by_user_id,
|
||||||
|
)
|
||||||
|
db.add(document)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(document)
|
||||||
|
return RepairRepository.get_document(db, repair_id=repair_id, document_id=document.id) or document
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def update_document(db: Session, document: RepairDocument, payload: RepairDocumentUpdate) -> RepairDocument:
|
||||||
|
for key, value in payload.model_dump().items():
|
||||||
|
setattr(document, key, value)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(document)
|
||||||
|
return RepairRepository.get_document(db, repair_id=document.repair_id, document_id=document.id) or document
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def delete_document(db: Session, document: RepairDocument) -> None:
|
||||||
|
db.delete(document)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def count_documents(db: Session) -> int:
|
||||||
|
return db.scalar(select(func.count(RepairDocument.id))) or 0
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _payload_data(payload: RepairCreate | RepairUpdate) -> dict:
|
def _payload_data(payload: RepairCreate | RepairUpdate) -> dict:
|
||||||
data = payload.model_dump()
|
data = payload.model_dump()
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,16 @@ RepairStatus = Literal[
|
||||||
]
|
]
|
||||||
RepairPriority = Literal["low", "normal", "high", "urgent"]
|
RepairPriority = Literal["low", "normal", "high", "urgent"]
|
||||||
RepairSource = Literal["manual", "website", "customer_portal", "email", "phone"]
|
RepairSource = Literal["manual", "website", "customer_portal", "email", "phone"]
|
||||||
|
RepairDocumentType = Literal[
|
||||||
|
"device_photo",
|
||||||
|
"fault_photo",
|
||||||
|
"measurement",
|
||||||
|
"estimate",
|
||||||
|
"repair_report",
|
||||||
|
"shipping",
|
||||||
|
"other",
|
||||||
|
]
|
||||||
|
RepairDocumentVisibility = Literal["internal", "customer"]
|
||||||
|
|
||||||
|
|
||||||
def normalize_text(value: object) -> str:
|
def normalize_text(value: object) -> str:
|
||||||
|
|
@ -222,3 +232,39 @@ class RepairNotificationEventResponse(BaseModel):
|
||||||
class RepairNotificationOverviewResponse(BaseModel):
|
class RepairNotificationOverviewResponse(BaseModel):
|
||||||
templates: list[RepairNotificationTemplateResponse]
|
templates: list[RepairNotificationTemplateResponse]
|
||||||
events: list[RepairNotificationEventResponse]
|
events: list[RepairNotificationEventResponse]
|
||||||
|
|
||||||
|
|
||||||
|
class RepairDocumentBase(BaseModel):
|
||||||
|
title: str = Field(min_length=1, max_length=255)
|
||||||
|
document_type: RepairDocumentType = "other"
|
||||||
|
visibility: RepairDocumentVisibility = "internal"
|
||||||
|
note: str | None = None
|
||||||
|
|
||||||
|
@field_validator("title", "note", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def normalize_document_strings(cls, value: object) -> str | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
return normalize_text(value)
|
||||||
|
|
||||||
|
|
||||||
|
class RepairDocumentUpdate(RepairDocumentBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class RepairDocumentResponse(RepairDocumentBase):
|
||||||
|
id: int
|
||||||
|
repair_id: int
|
||||||
|
original_filename: str
|
||||||
|
stored_filename: str
|
||||||
|
storage_path: str
|
||||||
|
mime_type: str
|
||||||
|
size_bytes: int
|
||||||
|
checksum_sha256: str
|
||||||
|
uploaded_by_user_id: int | None
|
||||||
|
uploaded_by_username: str = ""
|
||||||
|
uploaded_by_display_name: str = ""
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
|
||||||
|
|
@ -169,6 +169,9 @@ def action_title(action: str) -> str:
|
||||||
"repairs.public_link.regenerate": "Reparatur-Statuslink erneut erstellt",
|
"repairs.public_link.regenerate": "Reparatur-Statuslink erneut erstellt",
|
||||||
"repairs.status_mail.sent": "Statusmail versendet",
|
"repairs.status_mail.sent": "Statusmail versendet",
|
||||||
"repairs.status_mail.failed": "Statusmail fehlgeschlagen",
|
"repairs.status_mail.failed": "Statusmail fehlgeschlagen",
|
||||||
|
"repairs.documents.upload": "Reparaturdokument hochgeladen",
|
||||||
|
"repairs.documents.update": "Reparaturdokument geändert",
|
||||||
|
"repairs.documents.delete": "Reparaturdokument gelöscht",
|
||||||
"system_settings.smtp.update": "SMTP-Konfiguration geändert",
|
"system_settings.smtp.update": "SMTP-Konfiguration geändert",
|
||||||
"system_settings.smtp.test_sent": "SMTP-Testmail versendet",
|
"system_settings.smtp.test_sent": "SMTP-Testmail versendet",
|
||||||
"system_settings.smtp.test_failed": "SMTP-Testmail fehlgeschlagen",
|
"system_settings.smtp.test_failed": "SMTP-Testmail fehlgeschlagen",
|
||||||
|
|
|
||||||
225
backend/hermes/app/services/repair_document_service.py
Normal file
225
backend/hermes/app/services/repair_document_service.py
Normal file
|
|
@ -0,0 +1,225 @@
|
||||||
|
import mimetypes
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Literal
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
|
from fastapi import HTTPException, UploadFile, status
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from starlette.requests import Request
|
||||||
|
|
||||||
|
from app.models.repair import Repair, RepairDocument
|
||||||
|
from app.models.user import User
|
||||||
|
from app.repositories.repair_repository import RepairRepository
|
||||||
|
from app.schemas.repair import RepairDocumentUpdate
|
||||||
|
from app.services.audit_service import write_audit_log
|
||||||
|
from app.storage import get_storage_service
|
||||||
|
from app.storage.exceptions import StorageFileNotFoundError, StorageValidationError
|
||||||
|
|
||||||
|
ALLOWED_REPAIR_DOCUMENT_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp", ".pdf"}
|
||||||
|
ALLOWED_REPAIR_DOCUMENT_MIME_TYPES = {
|
||||||
|
"image/jpeg",
|
||||||
|
"image/png",
|
||||||
|
"image/webp",
|
||||||
|
"application/pdf",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
DOCUMENT_TYPE_LABELS = {
|
||||||
|
"device_photo": "Gerätefoto",
|
||||||
|
"fault_photo": "Fehlerbild",
|
||||||
|
"measurement": "Messbild",
|
||||||
|
"estimate": "Kostenvoranschlag",
|
||||||
|
"repair_report": "Reparaturbericht",
|
||||||
|
"shipping": "Versandbeleg",
|
||||||
|
"other": "Dokument",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def storage_validation_error(exc: StorageValidationError) -> HTTPException:
|
||||||
|
detail = str(exc) or "Ungültige Datei"
|
||||||
|
status_code = status.HTTP_413_REQUEST_ENTITY_TOO_LARGE if "groß" in detail else status.HTTP_400_BAD_REQUEST
|
||||||
|
return HTTPException(status_code=status_code, detail=detail)
|
||||||
|
|
||||||
|
|
||||||
|
def _repair_label(repair: Repair) -> str:
|
||||||
|
return f"{repair.repair_number} · {repair.customer_name}"
|
||||||
|
|
||||||
|
|
||||||
|
def _document_label(document: RepairDocument) -> str:
|
||||||
|
return f"{document.title} · {document.original_filename}"
|
||||||
|
|
||||||
|
|
||||||
|
def _audit_document_data(document: RepairDocument) -> dict:
|
||||||
|
return {
|
||||||
|
"id": document.id,
|
||||||
|
"repair_id": document.repair_id,
|
||||||
|
"title": document.title,
|
||||||
|
"document_type": document.document_type,
|
||||||
|
"original_filename": document.original_filename,
|
||||||
|
"mime_type": document.mime_type,
|
||||||
|
"size_bytes": document.size_bytes,
|
||||||
|
"visibility": document.visibility,
|
||||||
|
"uploaded_by_user_id": document.uploaded_by_user_id,
|
||||||
|
"created_at": document.created_at,
|
||||||
|
"updated_at": document.updated_at,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class RepairDocumentService:
|
||||||
|
@staticmethod
|
||||||
|
async def upload_document(
|
||||||
|
db: Session,
|
||||||
|
repair: Repair,
|
||||||
|
*,
|
||||||
|
file: UploadFile,
|
||||||
|
title: str,
|
||||||
|
document_type: str,
|
||||||
|
visibility: str,
|
||||||
|
note: str | None,
|
||||||
|
actor: User,
|
||||||
|
request: Request,
|
||||||
|
) -> RepairDocument:
|
||||||
|
storage_service = get_storage_service()
|
||||||
|
max_bytes = storage_service.max_upload_mb * 1024 * 1024
|
||||||
|
content = await file.read(max_bytes + 1)
|
||||||
|
original_filename = file.filename or ""
|
||||||
|
mime_type = RepairDocumentService._resolve_mime_type(original_filename, file.content_type)
|
||||||
|
|
||||||
|
RepairDocumentService._validate_repair_document_type(original_filename, mime_type)
|
||||||
|
|
||||||
|
try:
|
||||||
|
metadata = storage_service.save_file(
|
||||||
|
namespace=f"repairs/{repair.id}/documents",
|
||||||
|
content=content,
|
||||||
|
original_filename=original_filename,
|
||||||
|
mime_type=mime_type,
|
||||||
|
)
|
||||||
|
except StorageValidationError as exc:
|
||||||
|
raise storage_validation_error(exc) from exc
|
||||||
|
|
||||||
|
try:
|
||||||
|
document = RepairRepository.create_document(
|
||||||
|
db,
|
||||||
|
repair_id=repair.id,
|
||||||
|
title=title,
|
||||||
|
document_type=document_type,
|
||||||
|
original_filename=metadata.original_filename,
|
||||||
|
stored_filename=metadata.stored_filename,
|
||||||
|
storage_path=metadata.storage_key,
|
||||||
|
mime_type=metadata.mime_type,
|
||||||
|
size_bytes=metadata.size,
|
||||||
|
checksum_sha256=metadata.checksum_sha256,
|
||||||
|
visibility=visibility,
|
||||||
|
note=note,
|
||||||
|
uploaded_by_user_id=actor.id,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
storage_service.delete_file(metadata.storage_key)
|
||||||
|
raise
|
||||||
|
|
||||||
|
write_audit_log(
|
||||||
|
db,
|
||||||
|
action="repairs.documents.upload",
|
||||||
|
entity_type="repair_documents",
|
||||||
|
entity_id=document.id,
|
||||||
|
entity_label=_document_label(document),
|
||||||
|
actor=actor,
|
||||||
|
request=request,
|
||||||
|
metadata={
|
||||||
|
"repair_id": repair.id,
|
||||||
|
"repair_number": repair.repair_number,
|
||||||
|
"document_type": document.document_type,
|
||||||
|
"visibility": document.visibility,
|
||||||
|
"mime_type": document.mime_type,
|
||||||
|
"size_bytes": document.size_bytes,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return document
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def update_document(
|
||||||
|
db: Session,
|
||||||
|
repair: Repair,
|
||||||
|
document: RepairDocument,
|
||||||
|
payload: RepairDocumentUpdate,
|
||||||
|
*,
|
||||||
|
actor: User,
|
||||||
|
request: Request,
|
||||||
|
) -> RepairDocument:
|
||||||
|
before_data = _audit_document_data(document)
|
||||||
|
updated = RepairRepository.update_document(db, document, payload)
|
||||||
|
write_audit_log(
|
||||||
|
db,
|
||||||
|
action="repairs.documents.update",
|
||||||
|
entity_type="repair_documents",
|
||||||
|
entity_id=updated.id,
|
||||||
|
entity_label=_document_label(updated),
|
||||||
|
actor=actor,
|
||||||
|
request=request,
|
||||||
|
before_data=before_data,
|
||||||
|
after_data=_audit_document_data(updated),
|
||||||
|
metadata={"repair_id": repair.id, "repair_number": repair.repair_number},
|
||||||
|
)
|
||||||
|
return updated
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def delete_document(
|
||||||
|
db: Session,
|
||||||
|
repair: Repair,
|
||||||
|
document: RepairDocument,
|
||||||
|
*,
|
||||||
|
actor: User,
|
||||||
|
request: Request,
|
||||||
|
) -> None:
|
||||||
|
before_data = _audit_document_data(document)
|
||||||
|
storage_path = document.storage_path
|
||||||
|
label = _document_label(document)
|
||||||
|
try:
|
||||||
|
get_storage_service().delete_file(storage_path)
|
||||||
|
except StorageValidationError as exc:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Ungültiger Dateipfad") from exc
|
||||||
|
|
||||||
|
RepairRepository.delete_document(db, document)
|
||||||
|
write_audit_log(
|
||||||
|
db,
|
||||||
|
action="repairs.documents.delete",
|
||||||
|
entity_type="repair_documents",
|
||||||
|
entity_id=document.id,
|
||||||
|
entity_label=label,
|
||||||
|
actor=actor,
|
||||||
|
request=request,
|
||||||
|
before_data=before_data,
|
||||||
|
metadata={"repair_id": repair.id, "repair_number": repair.repair_number},
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def open_document_file(document: RepairDocument) -> Path:
|
||||||
|
if not document.storage_path:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Datei nicht gefunden")
|
||||||
|
try:
|
||||||
|
return get_storage_service().open_file(document.storage_path)
|
||||||
|
except StorageFileNotFoundError as exc:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Datei nicht gefunden") from exc
|
||||||
|
except StorageValidationError as exc:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Ungültiger Dateipfad") from exc
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def content_disposition(document: RepairDocument, *, mode: Literal["inline", "attachment"]) -> str:
|
||||||
|
filename = document.original_filename or document.stored_filename or "reparatur-dokument"
|
||||||
|
safe_filename = filename.replace('"', "")
|
||||||
|
encoded_filename = quote(filename)
|
||||||
|
return f'{mode}; filename="{safe_filename}"; filename*=UTF-8\'\'{encoded_filename}'
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _resolve_mime_type(original_filename: str, content_type: str | None) -> str:
|
||||||
|
if content_type and content_type != "application/octet-stream":
|
||||||
|
return content_type
|
||||||
|
return mimetypes.guess_type(original_filename)[0] or "application/octet-stream"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _validate_repair_document_type(original_filename: str, mime_type: str) -> None:
|
||||||
|
extension = Path(original_filename or "").suffix.lower()
|
||||||
|
if extension not in ALLOWED_REPAIR_DOCUMENT_EXTENSIONS:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Dateityp ist nicht erlaubt")
|
||||||
|
if mime_type not in ALLOWED_REPAIR_DOCUMENT_MIME_TYPES:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="MIME-Type ist nicht erlaubt")
|
||||||
|
|
@ -87,6 +87,7 @@ class LocalDiskStorageProvider(StorageProvider):
|
||||||
"customers",
|
"customers",
|
||||||
"projects",
|
"projects",
|
||||||
"tickets",
|
"tickets",
|
||||||
|
"repairs",
|
||||||
"imports",
|
"imports",
|
||||||
"temp",
|
"temp",
|
||||||
]:
|
]:
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
import { NextRequest } from "next/server";
|
||||||
|
|
||||||
|
import { proxyHermesStreamRequest } from "@/lib/server/hermes-proxy";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
params: Promise<{
|
||||||
|
id: string;
|
||||||
|
documentId: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest, { params }: Params) {
|
||||||
|
const { id, documentId } = await params;
|
||||||
|
return proxyHermesStreamRequest(
|
||||||
|
request,
|
||||||
|
`/repairs/${id}/documents/${documentId}/download${request.nextUrl.search}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,40 @@
|
||||||
|
import { NextRequest } from "next/server";
|
||||||
|
|
||||||
|
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
|
||||||
|
import { assertSameOrigin } from "@/lib/server/request-guards";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
params: Promise<{
|
||||||
|
id: string;
|
||||||
|
documentId: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function proxyDocumentRequest(request: NextRequest, { params }: Params) {
|
||||||
|
const { id, documentId } = await params;
|
||||||
|
return proxyHermesRequest(request, `/repairs/${id}/documents/${documentId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest, context: Params) {
|
||||||
|
return proxyDocumentRequest(request, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PUT(request: NextRequest, context: Params) {
|
||||||
|
const originError = assertSameOrigin(request);
|
||||||
|
|
||||||
|
if (originError) {
|
||||||
|
return originError;
|
||||||
|
}
|
||||||
|
|
||||||
|
return proxyDocumentRequest(request, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(request: NextRequest, context: Params) {
|
||||||
|
const originError = assertSameOrigin(request);
|
||||||
|
|
||||||
|
if (originError) {
|
||||||
|
return originError;
|
||||||
|
}
|
||||||
|
|
||||||
|
return proxyDocumentRequest(request, context);
|
||||||
|
}
|
||||||
14
frontend/athena/app/api/repairs/[id]/documents/route.ts
Normal file
14
frontend/athena/app/api/repairs/[id]/documents/route.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
import { NextRequest } from "next/server";
|
||||||
|
|
||||||
|
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
params: Promise<{
|
||||||
|
id: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest, { params }: Params) {
|
||||||
|
const { id } = await params;
|
||||||
|
return proxyHermesRequest(request, `/repairs/${id}/documents`);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
import { NextRequest } from "next/server";
|
||||||
|
|
||||||
|
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
|
||||||
|
import { assertSameOrigin } from "@/lib/server/request-guards";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
params: Promise<{
|
||||||
|
id: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest, { params }: Params) {
|
||||||
|
const originError = assertSameOrigin(request);
|
||||||
|
|
||||||
|
if (originError) {
|
||||||
|
return originError;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
return proxyHermesRequest(request, `/repairs/${id}/documents/upload`);
|
||||||
|
}
|
||||||
|
|
@ -6,6 +6,7 @@ import { ArrowLeft, CheckCircle2, Copy, Edit, Link2, Mail, Send, ShieldCheck, Wr
|
||||||
|
|
||||||
import DetailSection from "@/components/common/DetailSection";
|
import DetailSection from "@/components/common/DetailSection";
|
||||||
import { useToast } from "@/components/common/ToastProvider";
|
import { useToast } from "@/components/common/ToastProvider";
|
||||||
|
import RepairDocumentsSection from "@/components/repairs/RepairDocumentsSection";
|
||||||
import RepairFormDialog from "@/components/repairs/RepairFormDialog";
|
import RepairFormDialog from "@/components/repairs/RepairFormDialog";
|
||||||
import { RepairPriorityBadge, RepairStatusBadge, statusLabels } from "@/components/repairs/RepairStatusBadge";
|
import { RepairPriorityBadge, RepairStatusBadge, statusLabels } from "@/components/repairs/RepairStatusBadge";
|
||||||
import RepairStatusDialog from "@/components/repairs/RepairStatusDialog";
|
import RepairStatusDialog from "@/components/repairs/RepairStatusDialog";
|
||||||
|
|
@ -426,6 +427,10 @@ export default function RepairDetailPage({ params }: Params) {
|
||||||
})()}
|
})()}
|
||||||
</DetailSection>
|
</DetailSection>
|
||||||
|
|
||||||
|
<DetailSection title="Dokumente & Bilder">
|
||||||
|
<RepairDocumentsSection repairId={repair.id} canUpdate={canUpdate} />
|
||||||
|
</DetailSection>
|
||||||
|
|
||||||
<DetailSection title="Audit und Aktivität vorbereitet">
|
<DetailSection title="Audit und Aktivität vorbereitet">
|
||||||
<p className="text-sm text-slate-500">Reparaturaktionen werden in den Audit Logs erfasst und erscheinen im Activity Feed, wenn `repairs.read` vorhanden ist.</p>
|
<p className="text-sm text-slate-500">Reparaturaktionen werden in den Audit Logs erfasst und erscheinen im Activity Feed, wenn `repairs.read` vorhanden ist.</p>
|
||||||
<Link href="/audit-logs" className={buttonVariants({ variant: "outline", size: "sm", className: "mt-4" })}>Audit Logs öffnen</Link>
|
<Link href="/audit-logs" className={buttonVariants({ variant: "outline", size: "sm", className: "mt-4" })}>Audit Logs öffnen</Link>
|
||||||
|
|
|
||||||
454
frontend/athena/components/repairs/RepairDocumentsSection.tsx
Normal file
454
frontend/athena/components/repairs/RepairDocumentsSection.tsx
Normal file
|
|
@ -0,0 +1,454 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import {
|
||||||
|
Download,
|
||||||
|
Eye,
|
||||||
|
FileText,
|
||||||
|
ImageIcon,
|
||||||
|
Plus,
|
||||||
|
Trash2,
|
||||||
|
Upload,
|
||||||
|
X,
|
||||||
|
ZoomIn,
|
||||||
|
ZoomOut,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
import ConfirmDialog from "@/components/common/ConfirmDialog";
|
||||||
|
import { useToast } from "@/components/common/ToastProvider";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Button, buttonVariants } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
import type { RepairDocument, RepairDocumentType, RepairDocumentVisibility } from "@/types/repair";
|
||||||
|
|
||||||
|
const documentTypeLabels: Record<RepairDocumentType, string> = {
|
||||||
|
device_photo: "Gerätefoto",
|
||||||
|
fault_photo: "Fehlerbild",
|
||||||
|
measurement: "Messbild",
|
||||||
|
estimate: "Kostenvoranschlag",
|
||||||
|
repair_report: "Reparaturbericht",
|
||||||
|
shipping: "Versandbeleg",
|
||||||
|
other: "Sonstiges",
|
||||||
|
};
|
||||||
|
|
||||||
|
const documentTypes = Object.entries(documentTypeLabels) as Array<[RepairDocumentType, string]>;
|
||||||
|
|
||||||
|
function getErrorMessage(error: unknown) {
|
||||||
|
if (typeof error === "object" && error !== null && "response" in error) {
|
||||||
|
const response = (error as { response?: { data?: { detail?: string; message?: string } } }).response;
|
||||||
|
return response?.data?.detail ?? response?.data?.message ?? "Aktion konnte nicht abgeschlossen werden";
|
||||||
|
}
|
||||||
|
return "Aktion konnte nicht abgeschlossen werden";
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(value: string) {
|
||||||
|
return new Intl.DateTimeFormat("de-DE", { dateStyle: "short", timeStyle: "short" }).format(new Date(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSize(value: number) {
|
||||||
|
if (value < 1024) return `${value} B`;
|
||||||
|
if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KB`;
|
||||||
|
return `${(value / 1024 / 1024).toFixed(1)} MB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isImage(document: RepairDocument) {
|
||||||
|
return document.mime_type.startsWith("image/");
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPdf(document: RepairDocument) {
|
||||||
|
return document.mime_type === "application/pdf";
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadUrl(repairId: number, documentId: number, disposition: "inline" | "attachment" = "inline") {
|
||||||
|
return `/api/repairs/${repairId}/documents/${documentId}/download?disposition=${disposition}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
repairId: number;
|
||||||
|
canUpdate: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RepairDocumentsSection({ repairId, canUpdate }: Props) {
|
||||||
|
const { showToast } = useToast();
|
||||||
|
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
const [documents, setDocuments] = useState<RepairDocument[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [uploadOpen, setUploadOpen] = useState(false);
|
||||||
|
const [title, setTitle] = useState("");
|
||||||
|
const [documentType, setDocumentType] = useState<RepairDocumentType>("device_photo");
|
||||||
|
const [visibility, setVisibility] = useState<RepairDocumentVisibility>("internal");
|
||||||
|
const [note, setNote] = useState("");
|
||||||
|
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const [uploadProgress, setUploadProgress] = useState(0);
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<RepairDocument | null>(null);
|
||||||
|
const [deleting, setDeleting] = useState(false);
|
||||||
|
const [previewDocument, setPreviewDocument] = useState<RepairDocument | null>(null);
|
||||||
|
const [zoom, setZoom] = useState(1);
|
||||||
|
|
||||||
|
const loadDocuments = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const response = await api.get<RepairDocument[]>(`/repairs/${repairId}/documents`);
|
||||||
|
setDocuments(response.data);
|
||||||
|
} catch (err) {
|
||||||
|
setError(getErrorMessage(err));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [repairId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
queueMicrotask(() => {
|
||||||
|
void loadDocuments();
|
||||||
|
});
|
||||||
|
}, [loadDocuments]);
|
||||||
|
|
||||||
|
const imageDocuments = useMemo(() => documents.filter(isImage), [documents]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!previewDocument) return;
|
||||||
|
|
||||||
|
function handleKeyDown(event: KeyboardEvent) {
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
setPreviewDocument(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener("keydown", handleKeyDown);
|
||||||
|
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||||
|
}, [previewDocument]);
|
||||||
|
|
||||||
|
function resetUploadForm() {
|
||||||
|
setTitle("");
|
||||||
|
setDocumentType("device_photo");
|
||||||
|
setVisibility("internal");
|
||||||
|
setNote("");
|
||||||
|
setSelectedFile(null);
|
||||||
|
setUploadProgress(0);
|
||||||
|
if (fileInputRef.current) {
|
||||||
|
fileInputRef.current.value = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadDocument() {
|
||||||
|
if (!selectedFile) {
|
||||||
|
showToast({ type: "error", title: "Keine Datei ausgewählt" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("file", selectedFile);
|
||||||
|
formData.append("title", title.trim() || selectedFile.name);
|
||||||
|
formData.append("document_type", documentType);
|
||||||
|
formData.append("visibility", visibility);
|
||||||
|
formData.append("note", note.trim());
|
||||||
|
|
||||||
|
setUploading(true);
|
||||||
|
setUploadProgress(0);
|
||||||
|
try {
|
||||||
|
await api.post<RepairDocument>(`/repairs/${repairId}/documents/upload`, formData, {
|
||||||
|
onUploadProgress: (event) => {
|
||||||
|
if (event.total) {
|
||||||
|
setUploadProgress(Math.round((event.loaded / event.total) * 100));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await loadDocuments();
|
||||||
|
resetUploadForm();
|
||||||
|
setUploadOpen(false);
|
||||||
|
showToast({ type: "success", title: "Dokument hochgeladen" });
|
||||||
|
} catch (err) {
|
||||||
|
showToast({
|
||||||
|
type: "error",
|
||||||
|
title: "Upload fehlgeschlagen",
|
||||||
|
description: getErrorMessage(err),
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setUploading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteDocument() {
|
||||||
|
if (!deleteTarget) return;
|
||||||
|
setDeleting(true);
|
||||||
|
try {
|
||||||
|
await api.delete(`/repairs/${repairId}/documents/${deleteTarget.id}`);
|
||||||
|
setDocuments((current) => current.filter((item) => item.id !== deleteTarget.id));
|
||||||
|
setDeleteTarget(null);
|
||||||
|
showToast({ type: "success", title: "Dokument gelöscht" });
|
||||||
|
} catch (err) {
|
||||||
|
showToast({
|
||||||
|
type: "error",
|
||||||
|
title: "Dokument konnte nicht gelöscht werden",
|
||||||
|
description: getErrorMessage(err),
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setDeleting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openDocument(document: RepairDocument) {
|
||||||
|
if (isImage(document)) {
|
||||||
|
setPreviewDocument(document);
|
||||||
|
setZoom(1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window.open(downloadUrl(repairId, document.id, "inline"), "_blank", "noopener,noreferrer");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <div className="rounded-lg border bg-slate-50 p-5 text-sm text-slate-500">Dokumente werden geladen...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return <div className="rounded-lg border bg-red-50 p-5 text-sm text-red-700">{error}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<p className="text-sm text-slate-500">
|
||||||
|
{documents.length} Datei{documents.length === 1 ? "" : "en"} gespeichert
|
||||||
|
</p>
|
||||||
|
{canUpdate && (
|
||||||
|
<Button type="button" onClick={() => setUploadOpen(true)}>
|
||||||
|
<Plus />
|
||||||
|
Dokument hochladen
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{documents.length === 0 ? (
|
||||||
|
<div className="rounded-lg border border-dashed bg-slate-50 p-8 text-center">
|
||||||
|
<h3 className="text-lg font-semibold text-slate-950">Noch keine Dokumente oder Bilder vorhanden.</h3>
|
||||||
|
<p className="mx-auto mt-2 max-w-xl text-sm text-slate-500">
|
||||||
|
Gerätefotos, Messbilder, PDF-Berichte und Belege werden sicher im Olympus Storage gespeichert.
|
||||||
|
</p>
|
||||||
|
{canUpdate && (
|
||||||
|
<div className="mt-5 flex justify-center">
|
||||||
|
<Button type="button" onClick={() => setUploadOpen(true)}><Upload />Upload starten</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||||
|
{documents.map((document) => (
|
||||||
|
<article key={document.id} className="overflow-hidden rounded-lg border bg-white">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex aspect-video w-full items-center justify-center bg-slate-100 text-slate-500"
|
||||||
|
onClick={() => openDocument(document)}
|
||||||
|
aria-label={`${document.title} öffnen`}
|
||||||
|
>
|
||||||
|
{isImage(document) ? (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img src={downloadUrl(repairId, document.id)} alt={document.title} className="h-full w-full object-cover" />
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col items-center gap-2">
|
||||||
|
<FileText className="h-10 w-10" />
|
||||||
|
<span className="text-xs font-medium">{isPdf(document) ? "PDF" : document.mime_type}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="space-y-4 p-4">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
{isImage(document) ? <ImageIcon className="mt-0.5 h-4 w-4 shrink-0 text-slate-500" /> : <FileText className="mt-0.5 h-4 w-4 shrink-0 text-slate-500" />}
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h3 className="truncate font-semibold text-slate-950">{document.title}</h3>
|
||||||
|
<p className="mt-1 truncate text-xs text-slate-500">{document.original_filename}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{document.note && <p className="mt-3 line-clamp-2 text-sm text-slate-600">{document.note}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<dl className="grid grid-cols-2 gap-3 text-xs">
|
||||||
|
<Meta label="Typ" value={documentTypeLabels[document.document_type]} />
|
||||||
|
<Meta label="Größe" value={formatSize(document.size_bytes)} />
|
||||||
|
<Meta label="MIME" value={document.mime_type} />
|
||||||
|
<Meta label="Sichtbarkeit" value={document.visibility === "customer" ? "Kunde" : "Intern"} />
|
||||||
|
<Meta label="Upload" value={formatDate(document.created_at)} />
|
||||||
|
<Meta label="Von" value={document.uploaded_by_display_name || document.uploaded_by_username || "System"} />
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button type="button" size="sm" variant="outline" onClick={() => openDocument(document)}>
|
||||||
|
<Eye />
|
||||||
|
Vorschau
|
||||||
|
</Button>
|
||||||
|
<a href={downloadUrl(repairId, document.id, "attachment")} className={buttonVariants({ variant: "outline", size: "sm" })}>
|
||||||
|
<Download />
|
||||||
|
Download
|
||||||
|
</a>
|
||||||
|
{canUpdate && (
|
||||||
|
<Button type="button" size="sm" variant="destructive" onClick={() => setDeleteTarget(document)}>
|
||||||
|
<Trash2 />
|
||||||
|
Löschen
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Dialog open={uploadOpen} onOpenChange={(open) => {
|
||||||
|
setUploadOpen(open);
|
||||||
|
if (!open && !uploading) resetUploadForm();
|
||||||
|
}}>
|
||||||
|
<DialogContent className="sm:max-w-xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Dokument hochladen</DialogTitle>
|
||||||
|
<DialogDescription>JPG, PNG, WEBP und PDF werden im Reparatur-Storage abgelegt.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="grid gap-4">
|
||||||
|
<Field label="Datei">
|
||||||
|
<Input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept=".jpg,.jpeg,.png,.webp,.pdf,image/jpeg,image/png,image/webp,application/pdf"
|
||||||
|
onChange={(event) => {
|
||||||
|
const file = event.target.files?.[0] ?? null;
|
||||||
|
setSelectedFile(file);
|
||||||
|
if (file && !title) setTitle(file.name);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Titel">
|
||||||
|
<Input value={title} onChange={(event) => setTitle(event.target.value)} />
|
||||||
|
</Field>
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<Field label="Dokumenttyp">
|
||||||
|
<select
|
||||||
|
className="h-8 w-full rounded-lg border border-input bg-transparent px-2.5 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
|
||||||
|
value={documentType}
|
||||||
|
onChange={(event) => setDocumentType(event.target.value as RepairDocumentType)}
|
||||||
|
>
|
||||||
|
{documentTypes.map(([value, label]) => (
|
||||||
|
<option key={value} value={value}>{label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
<Field label="Sichtbarkeit">
|
||||||
|
<select
|
||||||
|
className="h-8 w-full rounded-lg border border-input bg-transparent px-2.5 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
|
||||||
|
value={visibility}
|
||||||
|
onChange={(event) => setVisibility(event.target.value as RepairDocumentVisibility)}
|
||||||
|
>
|
||||||
|
<option value="internal">Intern</option>
|
||||||
|
<option value="customer">Kunde vorbereitet</option>
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
<Field label="Notiz">
|
||||||
|
<textarea
|
||||||
|
className="min-h-24 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
|
||||||
|
value={note}
|
||||||
|
onChange={(event) => setNote(event.target.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
{uploading && (
|
||||||
|
<div className="rounded-lg border bg-slate-50 p-3">
|
||||||
|
<div className="h-2 overflow-hidden rounded-full bg-slate-200">
|
||||||
|
<div className="h-full bg-slate-900 transition-all" style={{ width: `${uploadProgress}%` }} />
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-xs text-slate-500">{uploadProgress}% hochgeladen</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="outline" onClick={() => setUploadOpen(false)} disabled={uploading}>
|
||||||
|
Abbrechen
|
||||||
|
</Button>
|
||||||
|
<Button type="button" onClick={() => void uploadDocument()} disabled={uploading || !selectedFile}>
|
||||||
|
<Upload />
|
||||||
|
{uploading ? "Lädt hoch..." : "Upload"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={Boolean(deleteTarget)}
|
||||||
|
title="Dokument löschen?"
|
||||||
|
description="Die Datei wird aus dem Storage entfernt und kann nicht wiederhergestellt werden."
|
||||||
|
pending={deleting}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open && !deleting) setDeleteTarget(null);
|
||||||
|
}}
|
||||||
|
onConfirm={() => void deleteDocument()}
|
||||||
|
>
|
||||||
|
{deleteTarget && (
|
||||||
|
<p className="text-sm text-slate-600">
|
||||||
|
{deleteTarget.title}
|
||||||
|
<br />
|
||||||
|
<span className="text-slate-500">{deleteTarget.original_filename}</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</ConfirmDialog>
|
||||||
|
|
||||||
|
{previewDocument && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 p-4" role="dialog" aria-modal="true">
|
||||||
|
<div className="absolute right-4 top-4 flex gap-2">
|
||||||
|
<Button type="button" variant="secondary" size="icon" onClick={() => setZoom((current) => Math.max(0.5, current - 0.25))} aria-label="Verkleinern">
|
||||||
|
<ZoomOut />
|
||||||
|
</Button>
|
||||||
|
<Button type="button" variant="secondary" size="icon" onClick={() => setZoom((current) => Math.min(3, current + 0.25))} aria-label="Vergrößern">
|
||||||
|
<ZoomIn />
|
||||||
|
</Button>
|
||||||
|
<Button type="button" variant="secondary" size="icon" onClick={() => setPreviewDocument(null)} aria-label="Vorschau schließen">
|
||||||
|
<X />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<button type="button" className="absolute inset-0 cursor-default" onClick={() => setPreviewDocument(null)} aria-label="Vorschau schließen" />
|
||||||
|
<div className="relative max-h-[90vh] max-w-[90vw] overflow-auto">
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img
|
||||||
|
src={downloadUrl(repairId, previewDocument.id)}
|
||||||
|
alt={previewDocument.title}
|
||||||
|
className="relative block max-h-[85vh] max-w-[85vw] object-contain transition-transform"
|
||||||
|
style={{ transform: `scale(${zoom})` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="absolute bottom-4 left-1/2 max-w-[80vw] -translate-x-1/2 truncate rounded bg-black/60 px-3 py-2 text-sm text-white">
|
||||||
|
{previewDocument.title} · {imageDocuments.findIndex((item) => item.id === previewDocument.id) + 1}/{imageDocuments.length}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Meta({ label, value }: { label: string; value: string }) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<dt className="font-medium uppercase tracking-wide text-slate-500">{label}</dt>
|
||||||
|
<dd className="mt-1 break-words text-slate-950">{value}</dd>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Field({ label, children }: { label: string; children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-1.5 block text-sm font-medium text-slate-700">{label}</span>
|
||||||
|
{children}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -14,6 +14,15 @@ export type RepairStatus =
|
||||||
|
|
||||||
export type RepairPriority = "low" | "normal" | "high" | "urgent";
|
export type RepairPriority = "low" | "normal" | "high" | "urgent";
|
||||||
export type RepairSource = "manual" | "website" | "customer_portal" | "email" | "phone";
|
export type RepairSource = "manual" | "website" | "customer_portal" | "email" | "phone";
|
||||||
|
export type RepairDocumentType =
|
||||||
|
| "device_photo"
|
||||||
|
| "fault_photo"
|
||||||
|
| "measurement"
|
||||||
|
| "estimate"
|
||||||
|
| "repair_report"
|
||||||
|
| "shipping"
|
||||||
|
| "other";
|
||||||
|
export type RepairDocumentVisibility = "internal" | "customer";
|
||||||
|
|
||||||
export interface Repair {
|
export interface Repair {
|
||||||
id: number;
|
id: number;
|
||||||
|
|
@ -132,3 +141,23 @@ export interface RepairNotificationOverview {
|
||||||
templates: RepairNotificationTemplate[];
|
templates: RepairNotificationTemplate[];
|
||||||
events: RepairNotificationEvent[];
|
events: RepairNotificationEvent[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface RepairDocument {
|
||||||
|
id: number;
|
||||||
|
repair_id: number;
|
||||||
|
title: string;
|
||||||
|
document_type: RepairDocumentType;
|
||||||
|
original_filename: string;
|
||||||
|
stored_filename: string;
|
||||||
|
storage_path: string;
|
||||||
|
mime_type: string;
|
||||||
|
size_bytes: number;
|
||||||
|
checksum_sha256: string;
|
||||||
|
visibility: RepairDocumentVisibility;
|
||||||
|
note: string | null;
|
||||||
|
uploaded_by_user_id: number | null;
|
||||||
|
uploaded_by_username: string;
|
||||||
|
uploaded_by_display_name: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
|
||||||
Binary file not shown.
Loading…
Add table
Add a link
Reference in a new issue