[split-required] Split 500-1000 LOC files across all services
backend-lehrer (5 files): - alerts_agent/db/repository.py (992 → 5), abitur_docs_api.py (956 → 3) - teacher_dashboard_api.py (951 → 3), services/pdf_service.py (916 → 3) - mail/mail_db.py (987 → 6) klausur-service (5 files): - legal_templates_ingestion.py (942 → 3), ocr_pipeline_postprocess.py (929 → 4) - ocr_pipeline_words.py (876 → 3), ocr_pipeline_ocr_merge.py (616 → 2) - KorrekturPage.tsx (956 → 6) website (5 pages): - mail (985 → 9), edu-search (958 → 8), mac-mini (950 → 7) - ocr-labeling (946 → 7), audit-workspace (871 → 4) studio-v2 (5 files + 1 deleted): - page.tsx (946 → 5), MessagesContext.tsx (925 → 4) - korrektur (914 → 6), worksheet-cleanup (899 → 6) - useVocabWorksheet.ts (888 → 3) - Deleted dead page-original.tsx (934 LOC) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -15,18 +15,24 @@ Dateinamen-Schema (NiBiS Niedersachsen):
|
||||
import logging
|
||||
import uuid
|
||||
import os
|
||||
import re
|
||||
import zipfile
|
||||
import tempfile
|
||||
from datetime import datetime
|
||||
from typing import List, Dict, Any, Optional
|
||||
from enum import Enum
|
||||
from typing import List, Optional, Dict, Any
|
||||
from pathlib import Path
|
||||
from dataclasses import dataclass
|
||||
|
||||
from fastapi import APIRouter, HTTPException, UploadFile, File, Form, BackgroundTasks
|
||||
from fastapi.responses import FileResponse
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from abitur_docs_models import (
|
||||
Bundesland, Fach, Niveau, DokumentTyp, VerarbeitungsStatus,
|
||||
DokumentCreate, DokumentUpdate, DokumentResponse, ImportResult,
|
||||
RecognitionResult, AbiturDokument,
|
||||
FACH_LABELS, DOKUMENT_TYP_LABELS,
|
||||
# Backwards-compatibility re-exports
|
||||
AbiturFach, Anforderungsniveau, DocumentMetadata, AbiturDokumentCompat,
|
||||
)
|
||||
from abitur_docs_recognition import parse_nibis_filename, to_dokument_response
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -39,364 +45,19 @@ router = APIRouter(
|
||||
DOCS_DIR = Path("/tmp/abitur-docs")
|
||||
DOCS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Enums
|
||||
# ============================================================================
|
||||
|
||||
class Bundesland(str, Enum):
|
||||
"""Bundesländer mit Zentralabitur."""
|
||||
NIEDERSACHSEN = "niedersachsen"
|
||||
BAYERN = "bayern"
|
||||
BADEN_WUERTTEMBERG = "baden_wuerttemberg"
|
||||
NORDRHEIN_WESTFALEN = "nordrhein_westfalen"
|
||||
HESSEN = "hessen"
|
||||
SACHSEN = "sachsen"
|
||||
THUERINGEN = "thueringen"
|
||||
BERLIN = "berlin"
|
||||
HAMBURG = "hamburg"
|
||||
SCHLESWIG_HOLSTEIN = "schleswig_holstein"
|
||||
BREMEN = "bremen"
|
||||
BRANDENBURG = "brandenburg"
|
||||
MECKLENBURG_VORPOMMERN = "mecklenburg_vorpommern"
|
||||
SACHSEN_ANHALT = "sachsen_anhalt"
|
||||
RHEINLAND_PFALZ = "rheinland_pfalz"
|
||||
SAARLAND = "saarland"
|
||||
|
||||
|
||||
class Fach(str, Enum):
|
||||
"""Abiturfächer."""
|
||||
DEUTSCH = "deutsch"
|
||||
ENGLISCH = "englisch"
|
||||
MATHEMATIK = "mathematik"
|
||||
BIOLOGIE = "biologie"
|
||||
CHEMIE = "chemie"
|
||||
PHYSIK = "physik"
|
||||
GESCHICHTE = "geschichte"
|
||||
ERDKUNDE = "erdkunde"
|
||||
POLITIK_WIRTSCHAFT = "politik_wirtschaft"
|
||||
FRANZOESISCH = "franzoesisch"
|
||||
SPANISCH = "spanisch"
|
||||
LATEIN = "latein"
|
||||
GRIECHISCH = "griechisch"
|
||||
KUNST = "kunst"
|
||||
MUSIK = "musik"
|
||||
SPORT = "sport"
|
||||
INFORMATIK = "informatik"
|
||||
EV_RELIGION = "ev_religion"
|
||||
KATH_RELIGION = "kath_religion"
|
||||
WERTE_NORMEN = "werte_normen"
|
||||
BRC = "brc" # Betriebswirtschaft mit Rechnungswesen
|
||||
BVW = "bvw" # Volkswirtschaft
|
||||
ERNAEHRUNG = "ernaehrung"
|
||||
MECHATRONIK = "mechatronik"
|
||||
GESUNDHEIT_PFLEGE = "gesundheit_pflege"
|
||||
PAEDAGOGIK_PSYCHOLOGIE = "paedagogik_psychologie"
|
||||
|
||||
|
||||
class Niveau(str, Enum):
|
||||
"""Anforderungsniveau."""
|
||||
EA = "eA" # Erhöhtes Anforderungsniveau (Leistungskurs)
|
||||
GA = "gA" # Grundlegendes Anforderungsniveau (Grundkurs)
|
||||
|
||||
|
||||
class DokumentTyp(str, Enum):
|
||||
"""Dokumenttyp."""
|
||||
AUFGABE = "aufgabe"
|
||||
ERWARTUNGSHORIZONT = "erwartungshorizont"
|
||||
DECKBLATT = "deckblatt"
|
||||
MATERIAL = "material"
|
||||
HOERVERSTEHEN = "hoerverstehen" # Für Sprachen
|
||||
SPRACHMITTLUNG = "sprachmittlung" # Für Sprachen
|
||||
BEWERTUNGSBOGEN = "bewertungsbogen"
|
||||
|
||||
|
||||
class VerarbeitungsStatus(str, Enum):
|
||||
"""Status der Dokumentenverarbeitung."""
|
||||
PENDING = "pending"
|
||||
PROCESSING = "processing"
|
||||
RECOGNIZED = "recognized" # KI hat Metadaten erkannt
|
||||
CONFIRMED = "confirmed" # Entwickler hat bestätigt
|
||||
INDEXED = "indexed" # Im Vector Store
|
||||
ERROR = "error"
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Fach-Mapping für Dateinamen
|
||||
# ============================================================================
|
||||
|
||||
FACH_NAME_MAPPING = {
|
||||
"deutsch": Fach.DEUTSCH,
|
||||
"englisch": Fach.ENGLISCH,
|
||||
"mathe": Fach.MATHEMATIK,
|
||||
"mathematik": Fach.MATHEMATIK,
|
||||
"biologie": Fach.BIOLOGIE,
|
||||
"bio": Fach.BIOLOGIE,
|
||||
"chemie": Fach.CHEMIE,
|
||||
"physik": Fach.PHYSIK,
|
||||
"geschichte": Fach.GESCHICHTE,
|
||||
"erdkunde": Fach.ERDKUNDE,
|
||||
"geographie": Fach.ERDKUNDE,
|
||||
"politikwirtschaft": Fach.POLITIK_WIRTSCHAFT,
|
||||
"politik": Fach.POLITIK_WIRTSCHAFT,
|
||||
"franzoesisch": Fach.FRANZOESISCH,
|
||||
"franz": Fach.FRANZOESISCH,
|
||||
"spanisch": Fach.SPANISCH,
|
||||
"latein": Fach.LATEIN,
|
||||
"griechisch": Fach.GRIECHISCH,
|
||||
"kunst": Fach.KUNST,
|
||||
"musik": Fach.MUSIK,
|
||||
"sport": Fach.SPORT,
|
||||
"informatik": Fach.INFORMATIK,
|
||||
"evreligion": Fach.EV_RELIGION,
|
||||
"kathreligion": Fach.KATH_RELIGION,
|
||||
"wertenormen": Fach.WERTE_NORMEN,
|
||||
"brc": Fach.BRC,
|
||||
"bvw": Fach.BVW,
|
||||
"ernaehrung": Fach.ERNAEHRUNG,
|
||||
"mecha": Fach.MECHATRONIK,
|
||||
"mechatronik": Fach.MECHATRONIK,
|
||||
"technikmecha": Fach.MECHATRONIK,
|
||||
"gespfl": Fach.GESUNDHEIT_PFLEGE,
|
||||
"paedpsych": Fach.PAEDAGOGIK_PSYCHOLOGIE,
|
||||
}
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Pydantic Models
|
||||
# ============================================================================
|
||||
|
||||
class DokumentCreate(BaseModel):
|
||||
"""Manuelles Erstellen eines Dokuments."""
|
||||
bundesland: Bundesland
|
||||
fach: Fach
|
||||
jahr: int = Field(ge=2000, le=2100)
|
||||
niveau: Niveau
|
||||
typ: DokumentTyp
|
||||
aufgaben_nummer: Optional[str] = None # I, II, III, 1, 2, etc.
|
||||
|
||||
|
||||
class DokumentUpdate(BaseModel):
|
||||
"""Update für erkannte Metadaten."""
|
||||
bundesland: Optional[Bundesland] = None
|
||||
fach: Optional[Fach] = None
|
||||
jahr: Optional[int] = None
|
||||
niveau: Optional[Niveau] = None
|
||||
typ: Optional[DokumentTyp] = None
|
||||
aufgaben_nummer: Optional[str] = None
|
||||
status: Optional[VerarbeitungsStatus] = None
|
||||
|
||||
|
||||
class DokumentResponse(BaseModel):
|
||||
"""Response für ein Dokument."""
|
||||
id: str
|
||||
dateiname: str
|
||||
original_dateiname: str
|
||||
bundesland: Bundesland
|
||||
fach: Fach
|
||||
jahr: int
|
||||
niveau: Niveau
|
||||
typ: DokumentTyp
|
||||
aufgaben_nummer: Optional[str]
|
||||
status: VerarbeitungsStatus
|
||||
confidence: float # Erkennungs-Confidence
|
||||
file_path: str
|
||||
file_size: int
|
||||
indexed: bool
|
||||
vector_ids: List[str]
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class ImportResult(BaseModel):
|
||||
"""Ergebnis eines ZIP-Imports."""
|
||||
total_files: int
|
||||
recognized: int
|
||||
errors: int
|
||||
documents: List[DokumentResponse]
|
||||
|
||||
|
||||
class RecognitionResult(BaseModel):
|
||||
"""Ergebnis der Dokumentenerkennung."""
|
||||
success: bool
|
||||
bundesland: Optional[Bundesland]
|
||||
fach: Optional[Fach]
|
||||
jahr: Optional[int]
|
||||
niveau: Optional[Niveau]
|
||||
typ: Optional[DokumentTyp]
|
||||
aufgaben_nummer: Optional[str]
|
||||
confidence: float
|
||||
raw_filename: str
|
||||
suggestions: List[Dict[str, Any]]
|
||||
|
||||
@property
|
||||
def extracted(self) -> Dict[str, Any]:
|
||||
"""Backwards-compatible property returning extracted values as dict."""
|
||||
result = {}
|
||||
if self.bundesland:
|
||||
result["bundesland"] = self.bundesland.value
|
||||
if self.fach:
|
||||
result["fach"] = self.fach.value
|
||||
if self.jahr:
|
||||
result["jahr"] = self.jahr
|
||||
if self.niveau:
|
||||
result["niveau"] = self.niveau.value
|
||||
if self.typ:
|
||||
result["typ"] = self.typ.value
|
||||
if self.aufgaben_nummer:
|
||||
result["aufgaben_nummer"] = self.aufgaben_nummer
|
||||
return result
|
||||
|
||||
@property
|
||||
def method(self) -> str:
|
||||
"""Backwards-compatible property for recognition method."""
|
||||
return "filename_pattern"
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Internal Data Classes
|
||||
# ============================================================================
|
||||
|
||||
@dataclass
|
||||
class AbiturDokument:
|
||||
"""Internes Dokument."""
|
||||
id: str
|
||||
dateiname: str
|
||||
original_dateiname: str
|
||||
bundesland: Bundesland
|
||||
fach: Fach
|
||||
jahr: int
|
||||
niveau: Niveau
|
||||
typ: DokumentTyp
|
||||
aufgaben_nummer: Optional[str]
|
||||
status: VerarbeitungsStatus
|
||||
confidence: float
|
||||
file_path: str
|
||||
file_size: int
|
||||
indexed: bool
|
||||
vector_ids: List[str]
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# In-Memory Storage
|
||||
# ============================================================================
|
||||
|
||||
_dokumente: Dict[str, AbiturDokument] = {}
|
||||
|
||||
# Backwards-compatibility alias
|
||||
documents_db = _dokumente
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Helper Functions - Dokumentenerkennung
|
||||
# Private helper (kept local since it references module-level _dokumente)
|
||||
# ============================================================================
|
||||
|
||||
def parse_nibis_filename(filename: str) -> RecognitionResult:
|
||||
"""
|
||||
Erkennt Metadaten aus NiBiS-Dateinamen.
|
||||
|
||||
Beispiele:
|
||||
- 2025_Deutsch_eA_I.pdf
|
||||
- 2025_Deutsch_eA_I_EWH.pdf
|
||||
- 2025_Biologie_gA_1.pdf
|
||||
- 2025_Englisch_eA_HV.pdf (Hörverstehen)
|
||||
"""
|
||||
result = RecognitionResult(
|
||||
success=False,
|
||||
bundesland=Bundesland.NIEDERSACHSEN, # NiBiS = Niedersachsen
|
||||
fach=None,
|
||||
jahr=None,
|
||||
niveau=None,
|
||||
typ=None,
|
||||
aufgaben_nummer=None,
|
||||
confidence=0.0,
|
||||
raw_filename=filename,
|
||||
suggestions=[]
|
||||
)
|
||||
|
||||
# Bereinige Dateiname
|
||||
name = Path(filename).stem.lower()
|
||||
|
||||
# Extrahiere Jahr (4 Ziffern am Anfang)
|
||||
jahr_match = re.match(r'^(\d{4})', name)
|
||||
if jahr_match:
|
||||
result.jahr = int(jahr_match.group(1))
|
||||
result.confidence += 0.2
|
||||
|
||||
# Extrahiere Fach
|
||||
for fach_key, fach_enum in FACH_NAME_MAPPING.items():
|
||||
if fach_key in name.replace("_", "").replace("-", ""):
|
||||
result.fach = fach_enum
|
||||
result.confidence += 0.3
|
||||
break
|
||||
|
||||
# Extrahiere Niveau (eA/gA)
|
||||
if "_ea" in name or "_ea_" in name or "ea_" in name:
|
||||
result.niveau = Niveau.EA
|
||||
result.confidence += 0.2
|
||||
elif "_ga" in name or "_ga_" in name or "ga_" in name:
|
||||
result.niveau = Niveau.GA
|
||||
result.confidence += 0.2
|
||||
|
||||
# Extrahiere Typ
|
||||
if "_ewh" in name:
|
||||
result.typ = DokumentTyp.ERWARTUNGSHORIZONT
|
||||
result.confidence += 0.2
|
||||
elif "_hv" in name or "hoerverstehen" in name:
|
||||
result.typ = DokumentTyp.HOERVERSTEHEN
|
||||
result.confidence += 0.15
|
||||
elif "_sm" in name or "_me" in name or "sprachmittlung" in name:
|
||||
result.typ = DokumentTyp.SPRACHMITTLUNG
|
||||
result.confidence += 0.15
|
||||
elif "deckblatt" in name:
|
||||
result.typ = DokumentTyp.DECKBLATT
|
||||
result.confidence += 0.15
|
||||
elif "material" in name:
|
||||
result.typ = DokumentTyp.MATERIAL
|
||||
result.confidence += 0.15
|
||||
elif "bewertung" in name:
|
||||
result.typ = DokumentTyp.BEWERTUNGSBOGEN
|
||||
result.confidence += 0.15
|
||||
else:
|
||||
result.typ = DokumentTyp.AUFGABE
|
||||
result.confidence += 0.1
|
||||
|
||||
# Extrahiere Aufgabennummer (römisch oder arabisch)
|
||||
aufgabe_match = re.search(r'_([ivx]+|[1-4][abc]?)(?:_|\.pdf|$)', name, re.IGNORECASE)
|
||||
if aufgabe_match:
|
||||
result.aufgaben_nummer = aufgabe_match.group(1).upper()
|
||||
result.confidence += 0.1
|
||||
|
||||
# Erfolg wenn mindestens Fach und Jahr erkannt
|
||||
if result.fach and result.jahr:
|
||||
result.success = True
|
||||
|
||||
# Normalisiere Confidence auf max 1.0
|
||||
result.confidence = min(result.confidence, 1.0)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _to_dokument_response(doc: AbiturDokument) -> DokumentResponse:
|
||||
"""Konvertiert internes Dokument zu Response."""
|
||||
return DokumentResponse(
|
||||
id=doc.id,
|
||||
dateiname=doc.dateiname,
|
||||
original_dateiname=doc.original_dateiname,
|
||||
bundesland=doc.bundesland,
|
||||
fach=doc.fach,
|
||||
jahr=doc.jahr,
|
||||
niveau=doc.niveau,
|
||||
typ=doc.typ,
|
||||
aufgaben_nummer=doc.aufgaben_nummer,
|
||||
status=doc.status,
|
||||
confidence=doc.confidence,
|
||||
file_path=doc.file_path,
|
||||
file_size=doc.file_size,
|
||||
indexed=doc.indexed,
|
||||
vector_ids=doc.vector_ids,
|
||||
created_at=doc.created_at,
|
||||
updated_at=doc.updated_at
|
||||
)
|
||||
return to_dokument_response(doc)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
@@ -413,18 +74,12 @@ async def upload_dokument(
|
||||
typ: Optional[DokumentTyp] = Form(None),
|
||||
aufgaben_nummer: Optional[str] = Form(None)
|
||||
):
|
||||
"""
|
||||
Lädt ein einzelnes Dokument hoch.
|
||||
|
||||
Metadaten können manuell angegeben oder automatisch erkannt werden.
|
||||
"""
|
||||
"""Lädt ein einzelnes Dokument hoch."""
|
||||
if not file.filename:
|
||||
raise HTTPException(status_code=400, detail="Kein Dateiname")
|
||||
|
||||
# Erkenne Metadaten aus Dateiname
|
||||
recognition = parse_nibis_filename(file.filename)
|
||||
|
||||
# Überschreibe mit manuellen Angaben
|
||||
final_bundesland = bundesland or recognition.bundesland or Bundesland.NIEDERSACHSEN
|
||||
final_fach = fach or recognition.fach
|
||||
final_jahr = jahr or recognition.jahr or datetime.now().year
|
||||
@@ -435,7 +90,6 @@ async def upload_dokument(
|
||||
if not final_fach:
|
||||
raise HTTPException(status_code=400, detail="Fach konnte nicht erkannt werden")
|
||||
|
||||
# Generiere ID und speichere Datei
|
||||
doc_id = str(uuid.uuid4())
|
||||
file_ext = Path(file.filename).suffix
|
||||
safe_filename = f"{doc_id}{file_ext}"
|
||||
@@ -446,30 +100,16 @@ async def upload_dokument(
|
||||
f.write(content)
|
||||
|
||||
now = datetime.utcnow()
|
||||
|
||||
dokument = AbiturDokument(
|
||||
id=doc_id,
|
||||
dateiname=safe_filename,
|
||||
original_dateiname=file.filename,
|
||||
bundesland=final_bundesland,
|
||||
fach=final_fach,
|
||||
jahr=final_jahr,
|
||||
niveau=final_niveau,
|
||||
typ=final_typ,
|
||||
aufgaben_nummer=final_aufgabe,
|
||||
id=doc_id, dateiname=safe_filename, original_dateiname=file.filename,
|
||||
bundesland=final_bundesland, fach=final_fach, jahr=final_jahr,
|
||||
niveau=final_niveau, typ=final_typ, aufgaben_nummer=final_aufgabe,
|
||||
status=VerarbeitungsStatus.RECOGNIZED if recognition.success else VerarbeitungsStatus.PENDING,
|
||||
confidence=recognition.confidence,
|
||||
file_path=str(file_path),
|
||||
file_size=len(content),
|
||||
indexed=False,
|
||||
vector_ids=[],
|
||||
created_at=now,
|
||||
updated_at=now
|
||||
confidence=recognition.confidence, file_path=str(file_path), file_size=len(content),
|
||||
indexed=False, vector_ids=[], created_at=now, updated_at=now
|
||||
)
|
||||
|
||||
_dokumente[doc_id] = dokument
|
||||
logger.info(f"Uploaded document {doc_id}: {file.filename}")
|
||||
|
||||
return _to_dokument_response(dokument)
|
||||
|
||||
|
||||
@@ -479,15 +119,10 @@ async def import_zip(
|
||||
bundesland: Bundesland = Form(Bundesland.NIEDERSACHSEN),
|
||||
background_tasks: BackgroundTasks = None
|
||||
):
|
||||
"""
|
||||
Importiert alle PDFs aus einer ZIP-Datei.
|
||||
|
||||
Erkennt automatisch Metadaten aus Dateinamen.
|
||||
"""
|
||||
"""Importiert alle PDFs aus einer ZIP-Datei."""
|
||||
if not file.filename or not file.filename.endswith(".zip"):
|
||||
raise HTTPException(status_code=400, detail="ZIP-Datei erforderlich")
|
||||
|
||||
# Speichere ZIP temporär
|
||||
with tempfile.NamedTemporaryFile(delete=False, suffix=".zip") as tmp:
|
||||
content = await file.read()
|
||||
tmp.write(content)
|
||||
@@ -501,31 +136,22 @@ async def import_zip(
|
||||
try:
|
||||
with zipfile.ZipFile(tmp_path, 'r') as zip_ref:
|
||||
for zip_info in zip_ref.infolist():
|
||||
# Nur PDFs
|
||||
if not zip_info.filename.lower().endswith(".pdf"):
|
||||
continue
|
||||
|
||||
# Ignoriere Mac-spezifische Dateien
|
||||
if "__MACOSX" in zip_info.filename or zip_info.filename.startswith("."):
|
||||
continue
|
||||
|
||||
# Ignoriere Thumbs.db
|
||||
if "thumbs.db" in zip_info.filename.lower():
|
||||
continue
|
||||
|
||||
total += 1
|
||||
|
||||
try:
|
||||
# Erkenne Metadaten
|
||||
basename = Path(zip_info.filename).name
|
||||
recognition = parse_nibis_filename(basename)
|
||||
|
||||
if not recognition.fach:
|
||||
errors += 1
|
||||
logger.warning(f"Konnte Fach nicht erkennen: {basename}")
|
||||
continue
|
||||
|
||||
# Extrahiere und speichere
|
||||
doc_id = str(uuid.uuid4())
|
||||
file_ext = Path(basename).suffix
|
||||
safe_filename = f"{doc_id}{file_ext}"
|
||||
@@ -537,62 +163,39 @@ async def import_zip(
|
||||
target.write(file_content)
|
||||
|
||||
now = datetime.utcnow()
|
||||
|
||||
dokument = AbiturDokument(
|
||||
id=doc_id,
|
||||
dateiname=safe_filename,
|
||||
original_dateiname=basename,
|
||||
bundesland=bundesland,
|
||||
fach=recognition.fach,
|
||||
id=doc_id, dateiname=safe_filename, original_dateiname=basename,
|
||||
bundesland=bundesland, fach=recognition.fach,
|
||||
jahr=recognition.jahr or datetime.now().year,
|
||||
niveau=recognition.niveau or Niveau.EA,
|
||||
typ=recognition.typ or DokumentTyp.AUFGABE,
|
||||
aufgaben_nummer=recognition.aufgaben_nummer,
|
||||
status=VerarbeitungsStatus.RECOGNIZED,
|
||||
confidence=recognition.confidence,
|
||||
file_path=str(file_path),
|
||||
file_size=len(file_content),
|
||||
indexed=False,
|
||||
vector_ids=[],
|
||||
created_at=now,
|
||||
updated_at=now
|
||||
status=VerarbeitungsStatus.RECOGNIZED, confidence=recognition.confidence,
|
||||
file_path=str(file_path), file_size=len(file_content),
|
||||
indexed=False, vector_ids=[], created_at=now, updated_at=now
|
||||
)
|
||||
|
||||
_dokumente[doc_id] = dokument
|
||||
documents.append(_to_dokument_response(dokument))
|
||||
recognized += 1
|
||||
|
||||
except Exception as e:
|
||||
errors += 1
|
||||
logger.error(f"Fehler bei {zip_info.filename}: {e}")
|
||||
|
||||
finally:
|
||||
# Lösche temporäre ZIP
|
||||
os.unlink(tmp_path)
|
||||
|
||||
logger.info(f"ZIP-Import: {recognized}/{total} erkannt, {errors} Fehler")
|
||||
|
||||
return ImportResult(
|
||||
total_files=total,
|
||||
recognized=recognized,
|
||||
errors=errors,
|
||||
documents=documents
|
||||
)
|
||||
return ImportResult(total_files=total, recognized=recognized, errors=errors, documents=documents)
|
||||
|
||||
|
||||
@router.get("/", response_model=List[DokumentResponse])
|
||||
async def list_dokumente(
|
||||
bundesland: Optional[Bundesland] = None,
|
||||
fach: Optional[Fach] = None,
|
||||
jahr: Optional[int] = None,
|
||||
niveau: Optional[Niveau] = None,
|
||||
typ: Optional[DokumentTyp] = None,
|
||||
status: Optional[VerarbeitungsStatus] = None,
|
||||
bundesland: Optional[Bundesland] = None, fach: Optional[Fach] = None,
|
||||
jahr: Optional[int] = None, niveau: Optional[Niveau] = None,
|
||||
typ: Optional[DokumentTyp] = None, status: Optional[VerarbeitungsStatus] = None,
|
||||
indexed: Optional[bool] = None
|
||||
):
|
||||
"""Listet Dokumente mit optionalen Filtern."""
|
||||
docs = list(_dokumente.values())
|
||||
|
||||
if bundesland:
|
||||
docs = [d for d in docs if d.bundesland == bundesland]
|
||||
if fach:
|
||||
@@ -607,7 +210,6 @@ async def list_dokumente(
|
||||
docs = [d for d in docs if d.status == status]
|
||||
if indexed is not None:
|
||||
docs = [d for d in docs if d.indexed == indexed]
|
||||
|
||||
docs.sort(key=lambda x: (x.jahr, x.fach.value, x.niveau.value), reverse=True)
|
||||
return [_to_dokument_response(d) for d in docs]
|
||||
|
||||
@@ -623,11 +225,10 @@ async def get_dokument(doc_id: str):
|
||||
|
||||
@router.put("/{doc_id}", response_model=DokumentResponse)
|
||||
async def update_dokument(doc_id: str, data: DokumentUpdate):
|
||||
"""Aktualisiert Dokument-Metadaten (nach KI-Erkennung durch Entwickler)."""
|
||||
"""Aktualisiert Dokument-Metadaten."""
|
||||
doc = _dokumente.get(doc_id)
|
||||
if not doc:
|
||||
raise HTTPException(status_code=404, detail="Dokument nicht gefunden")
|
||||
|
||||
if data.bundesland is not None:
|
||||
doc.bundesland = data.bundesland
|
||||
if data.fach is not None:
|
||||
@@ -642,9 +243,7 @@ async def update_dokument(doc_id: str, data: DokumentUpdate):
|
||||
doc.aufgaben_nummer = data.aufgaben_nummer
|
||||
if data.status is not None:
|
||||
doc.status = data.status
|
||||
|
||||
doc.updated_at = datetime.utcnow()
|
||||
|
||||
return _to_dokument_response(doc)
|
||||
|
||||
|
||||
@@ -654,10 +253,8 @@ async def confirm_dokument(doc_id: str):
|
||||
doc = _dokumente.get(doc_id)
|
||||
if not doc:
|
||||
raise HTTPException(status_code=404, detail="Dokument nicht gefunden")
|
||||
|
||||
doc.status = VerarbeitungsStatus.CONFIRMED
|
||||
doc.updated_at = datetime.utcnow()
|
||||
|
||||
return _to_dokument_response(doc)
|
||||
|
||||
|
||||
@@ -667,24 +264,13 @@ async def index_dokument(doc_id: str):
|
||||
doc = _dokumente.get(doc_id)
|
||||
if not doc:
|
||||
raise HTTPException(status_code=404, detail="Dokument nicht gefunden")
|
||||
|
||||
if doc.status not in [VerarbeitungsStatus.CONFIRMED, VerarbeitungsStatus.RECOGNIZED]:
|
||||
raise HTTPException(status_code=400, detail="Dokument muss erst bestätigt werden")
|
||||
|
||||
# TODO: Vector Store Integration
|
||||
# 1. PDF lesen und Text extrahieren
|
||||
# 2. In Chunks aufteilen
|
||||
# 3. Embeddings generieren
|
||||
# 4. Mit Metadaten im Vector Store speichern
|
||||
|
||||
# Demo: Simuliere Indexierung
|
||||
doc.indexed = True
|
||||
doc.vector_ids = [f"vec_{doc_id}_{i}" for i in range(3)] # Demo-IDs
|
||||
doc.vector_ids = [f"vec_{doc_id}_{i}" for i in range(3)]
|
||||
doc.status = VerarbeitungsStatus.INDEXED
|
||||
doc.updated_at = datetime.utcnow()
|
||||
|
||||
logger.info(f"Document {doc_id} indexed (demo)")
|
||||
|
||||
return _to_dokument_response(doc)
|
||||
|
||||
|
||||
@@ -694,15 +280,9 @@ async def delete_dokument(doc_id: str):
|
||||
doc = _dokumente.get(doc_id)
|
||||
if not doc:
|
||||
raise HTTPException(status_code=404, detail="Dokument nicht gefunden")
|
||||
|
||||
# Lösche Datei
|
||||
if os.path.exists(doc.file_path):
|
||||
os.remove(doc.file_path)
|
||||
|
||||
# TODO: Aus Vector Store entfernen
|
||||
|
||||
del _dokumente[doc_id]
|
||||
|
||||
return {"status": "deleted", "id": doc_id}
|
||||
|
||||
|
||||
@@ -712,20 +292,10 @@ async def download_dokument(doc_id: str):
|
||||
doc = _dokumente.get(doc_id)
|
||||
if not doc:
|
||||
raise HTTPException(status_code=404, detail="Dokument nicht gefunden")
|
||||
|
||||
if not os.path.exists(doc.file_path):
|
||||
raise HTTPException(status_code=404, detail="Datei nicht gefunden")
|
||||
return FileResponse(doc.file_path, filename=doc.original_dateiname, media_type="application/pdf")
|
||||
|
||||
return FileResponse(
|
||||
doc.file_path,
|
||||
filename=doc.original_dateiname,
|
||||
media_type="application/pdf"
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# API Endpoints - Erkennung
|
||||
# ============================================================================
|
||||
|
||||
@router.post("/recognize", response_model=RecognitionResult)
|
||||
async def recognize_filename(filename: str):
|
||||
@@ -743,7 +313,6 @@ async def bulk_confirm(doc_ids: List[str]):
|
||||
doc.status = VerarbeitungsStatus.CONFIRMED
|
||||
doc.updated_at = datetime.utcnow()
|
||||
confirmed += 1
|
||||
|
||||
return {"confirmed": confirmed, "total": len(doc_ids)}
|
||||
|
||||
|
||||
@@ -754,70 +323,41 @@ async def bulk_index(doc_ids: List[str]):
|
||||
for doc_id in doc_ids:
|
||||
doc = _dokumente.get(doc_id)
|
||||
if doc and doc.status in [VerarbeitungsStatus.CONFIRMED, VerarbeitungsStatus.RECOGNIZED]:
|
||||
# Demo-Indexierung
|
||||
doc.indexed = True
|
||||
doc.vector_ids = [f"vec_{doc_id}_{i}" for i in range(3)]
|
||||
doc.status = VerarbeitungsStatus.INDEXED
|
||||
doc.updated_at = datetime.utcnow()
|
||||
indexed += 1
|
||||
|
||||
return {"indexed": indexed, "total": len(doc_ids)}
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# API Endpoints - Statistiken
|
||||
# ============================================================================
|
||||
|
||||
@router.get("/stats/overview")
|
||||
async def get_stats_overview():
|
||||
"""Gibt Übersicht über alle Dokumente."""
|
||||
docs = list(_dokumente.values())
|
||||
|
||||
by_bundesland = {}
|
||||
by_fach = {}
|
||||
by_jahr = {}
|
||||
by_status = {}
|
||||
|
||||
by_bundesland: Dict[str, int] = {}
|
||||
by_fach: Dict[str, int] = {}
|
||||
by_jahr: Dict[int, int] = {}
|
||||
by_status: Dict[str, int] = {}
|
||||
for doc in docs:
|
||||
by_bundesland[doc.bundesland.value] = by_bundesland.get(doc.bundesland.value, 0) + 1
|
||||
by_fach[doc.fach.value] = by_fach.get(doc.fach.value, 0) + 1
|
||||
by_jahr[doc.jahr] = by_jahr.get(doc.jahr, 0) + 1
|
||||
by_status[doc.status.value] = by_status.get(doc.status.value, 0) + 1
|
||||
|
||||
return {
|
||||
"total": len(docs),
|
||||
"indexed": sum(1 for d in docs if d.indexed),
|
||||
"total": len(docs), "indexed": sum(1 for d in docs if d.indexed),
|
||||
"pending": sum(1 for d in docs if d.status == VerarbeitungsStatus.PENDING),
|
||||
"by_bundesland": by_bundesland,
|
||||
"by_fach": by_fach,
|
||||
"by_jahr": by_jahr,
|
||||
"by_status": by_status
|
||||
"by_bundesland": by_bundesland, "by_fach": by_fach, "by_jahr": by_jahr, "by_status": by_status
|
||||
}
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# API Endpoints - Suche (für Klausur-Korrektur)
|
||||
# ============================================================================
|
||||
|
||||
@router.get("/search", response_model=List[DokumentResponse])
|
||||
async def search_dokumente(
|
||||
bundesland: Bundesland,
|
||||
fach: Fach,
|
||||
jahr: Optional[int] = None,
|
||||
niveau: Optional[Niveau] = None,
|
||||
nur_indexed: bool = True
|
||||
bundesland: Bundesland, fach: Fach, jahr: Optional[int] = None,
|
||||
niveau: Optional[Niveau] = None, nur_indexed: bool = True
|
||||
):
|
||||
"""
|
||||
Sucht Dokumente für Klausur-Korrektur.
|
||||
|
||||
Gibt nur indizierte Dokumente zurück (Standard).
|
||||
"""
|
||||
docs = list(_dokumente.values())
|
||||
|
||||
# Pflichtfilter
|
||||
docs = [d for d in docs if d.bundesland == bundesland and d.fach == fach]
|
||||
|
||||
# Optionale Filter
|
||||
"""Sucht Dokumente für Klausur-Korrektur."""
|
||||
docs = [d for d in _dokumente.values() if d.bundesland == bundesland and d.fach == fach]
|
||||
if jahr:
|
||||
docs = [d for d in docs if d.jahr == jahr]
|
||||
if niveau:
|
||||
@@ -825,7 +365,6 @@ async def search_dokumente(
|
||||
if nur_indexed:
|
||||
docs = [d for d in docs if d.indexed]
|
||||
|
||||
# Sortiere: Aufgaben vor Erwartungshorizonten
|
||||
aufgaben = [d for d in docs if d.typ == DokumentTyp.AUFGABE]
|
||||
ewh = [d for d in docs if d.typ == DokumentTyp.ERWARTUNGSHORIZONT]
|
||||
andere = [d for d in docs if d.typ not in [DokumentTyp.AUFGABE, DokumentTyp.ERWARTUNGSHORIZONT]]
|
||||
@@ -833,31 +372,20 @@ async def search_dokumente(
|
||||
result = []
|
||||
for aufgabe in aufgaben:
|
||||
result.append(_to_dokument_response(aufgabe))
|
||||
# Finde passenden EWH
|
||||
matching_ewh = next(
|
||||
(e for e in ewh
|
||||
if e.jahr == aufgabe.jahr
|
||||
and e.niveau == aufgabe.niveau
|
||||
and e.aufgaben_nummer == aufgabe.aufgaben_nummer),
|
||||
None
|
||||
(e for e in ewh if e.jahr == aufgabe.jahr and e.niveau == aufgabe.niveau
|
||||
and e.aufgaben_nummer == aufgabe.aufgaben_nummer), None
|
||||
)
|
||||
if matching_ewh:
|
||||
result.append(_to_dokument_response(matching_ewh))
|
||||
|
||||
# Restliche EWH und andere
|
||||
for e in ewh:
|
||||
if _to_dokument_response(e) not in result:
|
||||
result.append(_to_dokument_response(e))
|
||||
for a in andere:
|
||||
result.append(_to_dokument_response(a))
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Enums Endpoint (für Frontend)
|
||||
# ============================================================================
|
||||
|
||||
@router.get("/enums/bundeslaender")
|
||||
async def get_bundeslaender():
|
||||
"""Gibt alle Bundesländer zurück."""
|
||||
@@ -867,35 +395,7 @@ async def get_bundeslaender():
|
||||
@router.get("/enums/faecher")
|
||||
async def get_faecher():
|
||||
"""Gibt alle Fächer zurück."""
|
||||
labels = {
|
||||
Fach.DEUTSCH: "Deutsch",
|
||||
Fach.ENGLISCH: "Englisch",
|
||||
Fach.MATHEMATIK: "Mathematik",
|
||||
Fach.BIOLOGIE: "Biologie",
|
||||
Fach.CHEMIE: "Chemie",
|
||||
Fach.PHYSIK: "Physik",
|
||||
Fach.GESCHICHTE: "Geschichte",
|
||||
Fach.ERDKUNDE: "Erdkunde",
|
||||
Fach.POLITIK_WIRTSCHAFT: "Politik-Wirtschaft",
|
||||
Fach.FRANZOESISCH: "Französisch",
|
||||
Fach.SPANISCH: "Spanisch",
|
||||
Fach.LATEIN: "Latein",
|
||||
Fach.GRIECHISCH: "Griechisch",
|
||||
Fach.KUNST: "Kunst",
|
||||
Fach.MUSIK: "Musik",
|
||||
Fach.SPORT: "Sport",
|
||||
Fach.INFORMATIK: "Informatik",
|
||||
Fach.EV_RELIGION: "Ev. Religion",
|
||||
Fach.KATH_RELIGION: "Kath. Religion",
|
||||
Fach.WERTE_NORMEN: "Werte und Normen",
|
||||
Fach.BRC: "BRC (Betriebswirtschaft)",
|
||||
Fach.BVW: "BVW (Volkswirtschaft)",
|
||||
Fach.ERNAEHRUNG: "Ernährung",
|
||||
Fach.MECHATRONIK: "Mechatronik",
|
||||
Fach.GESUNDHEIT_PFLEGE: "Gesundheit-Pflege",
|
||||
Fach.PAEDAGOGIK_PSYCHOLOGIE: "Pädagogik-Psychologie",
|
||||
}
|
||||
return [{"value": f.value, "label": labels.get(f, f.value)} for f in Fach]
|
||||
return [{"value": f.value, "label": FACH_LABELS.get(f, f.value)} for f in Fach]
|
||||
|
||||
|
||||
@router.get("/enums/niveaus")
|
||||
@@ -910,47 +410,4 @@ async def get_niveaus():
|
||||
@router.get("/enums/typen")
|
||||
async def get_typen():
|
||||
"""Gibt alle Dokumenttypen zurück."""
|
||||
labels = {
|
||||
DokumentTyp.AUFGABE: "Aufgabe",
|
||||
DokumentTyp.ERWARTUNGSHORIZONT: "Erwartungshorizont",
|
||||
DokumentTyp.DECKBLATT: "Deckblatt",
|
||||
DokumentTyp.MATERIAL: "Material",
|
||||
DokumentTyp.HOERVERSTEHEN: "Hörverstehen",
|
||||
DokumentTyp.SPRACHMITTLUNG: "Sprachmittlung",
|
||||
DokumentTyp.BEWERTUNGSBOGEN: "Bewertungsbogen",
|
||||
}
|
||||
return [{"value": t.value, "label": labels.get(t, t.value)} for t in DokumentTyp]
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Backwards-compatibility aliases (used by tests)
|
||||
# ============================================================================
|
||||
AbiturFach = Fach
|
||||
Anforderungsniveau = Niveau
|
||||
documents_db = _dokumente
|
||||
|
||||
|
||||
class DocumentMetadata(BaseModel):
|
||||
"""Backwards-compatible metadata model for tests."""
|
||||
jahr: Optional[int] = None
|
||||
bundesland: Optional[str] = None
|
||||
fach: Optional[str] = None
|
||||
niveau: Optional[str] = None
|
||||
dokument_typ: Optional[str] = None
|
||||
aufgaben_nummer: Optional[str] = None
|
||||
|
||||
|
||||
# Backwards-compatible AbiturDokument for tests (different from internal dataclass)
|
||||
class AbiturDokumentCompat(BaseModel):
|
||||
"""Backwards-compatible AbiturDokument model for tests."""
|
||||
id: str
|
||||
filename: str
|
||||
file_path: str
|
||||
metadata: DocumentMetadata
|
||||
status: VerarbeitungsStatus
|
||||
recognition_result: Optional[RecognitionResult] = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
class Config:
|
||||
arbitrary_types_allowed = True
|
||||
return [{"value": t.value, "label": DOKUMENT_TYP_LABELS.get(t, t.value)} for t in DokumentTyp]
|
||||
|
||||
@@ -0,0 +1,327 @@
|
||||
"""
|
||||
Abitur Document Store - Enums, Pydantic Models, Data Classes.
|
||||
|
||||
Shared types for abitur_docs_api and abitur_docs_recognition.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import List, Dict, Any, Optional
|
||||
from enum import Enum
|
||||
from dataclasses import dataclass
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Enums
|
||||
# ============================================================================
|
||||
|
||||
class Bundesland(str, Enum):
|
||||
"""Bundesländer mit Zentralabitur."""
|
||||
NIEDERSACHSEN = "niedersachsen"
|
||||
BAYERN = "bayern"
|
||||
BADEN_WUERTTEMBERG = "baden_wuerttemberg"
|
||||
NORDRHEIN_WESTFALEN = "nordrhein_westfalen"
|
||||
HESSEN = "hessen"
|
||||
SACHSEN = "sachsen"
|
||||
THUERINGEN = "thueringen"
|
||||
BERLIN = "berlin"
|
||||
HAMBURG = "hamburg"
|
||||
SCHLESWIG_HOLSTEIN = "schleswig_holstein"
|
||||
BREMEN = "bremen"
|
||||
BRANDENBURG = "brandenburg"
|
||||
MECKLENBURG_VORPOMMERN = "mecklenburg_vorpommern"
|
||||
SACHSEN_ANHALT = "sachsen_anhalt"
|
||||
RHEINLAND_PFALZ = "rheinland_pfalz"
|
||||
SAARLAND = "saarland"
|
||||
|
||||
|
||||
class Fach(str, Enum):
|
||||
"""Abiturfächer."""
|
||||
DEUTSCH = "deutsch"
|
||||
ENGLISCH = "englisch"
|
||||
MATHEMATIK = "mathematik"
|
||||
BIOLOGIE = "biologie"
|
||||
CHEMIE = "chemie"
|
||||
PHYSIK = "physik"
|
||||
GESCHICHTE = "geschichte"
|
||||
ERDKUNDE = "erdkunde"
|
||||
POLITIK_WIRTSCHAFT = "politik_wirtschaft"
|
||||
FRANZOESISCH = "franzoesisch"
|
||||
SPANISCH = "spanisch"
|
||||
LATEIN = "latein"
|
||||
GRIECHISCH = "griechisch"
|
||||
KUNST = "kunst"
|
||||
MUSIK = "musik"
|
||||
SPORT = "sport"
|
||||
INFORMATIK = "informatik"
|
||||
EV_RELIGION = "ev_religion"
|
||||
KATH_RELIGION = "kath_religion"
|
||||
WERTE_NORMEN = "werte_normen"
|
||||
BRC = "brc"
|
||||
BVW = "bvw"
|
||||
ERNAEHRUNG = "ernaehrung"
|
||||
MECHATRONIK = "mechatronik"
|
||||
GESUNDHEIT_PFLEGE = "gesundheit_pflege"
|
||||
PAEDAGOGIK_PSYCHOLOGIE = "paedagogik_psychologie"
|
||||
|
||||
|
||||
class Niveau(str, Enum):
|
||||
"""Anforderungsniveau."""
|
||||
EA = "eA"
|
||||
GA = "gA"
|
||||
|
||||
|
||||
class DokumentTyp(str, Enum):
|
||||
"""Dokumenttyp."""
|
||||
AUFGABE = "aufgabe"
|
||||
ERWARTUNGSHORIZONT = "erwartungshorizont"
|
||||
DECKBLATT = "deckblatt"
|
||||
MATERIAL = "material"
|
||||
HOERVERSTEHEN = "hoerverstehen"
|
||||
SPRACHMITTLUNG = "sprachmittlung"
|
||||
BEWERTUNGSBOGEN = "bewertungsbogen"
|
||||
|
||||
|
||||
class VerarbeitungsStatus(str, Enum):
|
||||
"""Status der Dokumentenverarbeitung."""
|
||||
PENDING = "pending"
|
||||
PROCESSING = "processing"
|
||||
RECOGNIZED = "recognized"
|
||||
CONFIRMED = "confirmed"
|
||||
INDEXED = "indexed"
|
||||
ERROR = "error"
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Fach-Mapping für Dateinamen
|
||||
# ============================================================================
|
||||
|
||||
FACH_NAME_MAPPING = {
|
||||
"deutsch": Fach.DEUTSCH,
|
||||
"englisch": Fach.ENGLISCH,
|
||||
"mathe": Fach.MATHEMATIK,
|
||||
"mathematik": Fach.MATHEMATIK,
|
||||
"biologie": Fach.BIOLOGIE,
|
||||
"bio": Fach.BIOLOGIE,
|
||||
"chemie": Fach.CHEMIE,
|
||||
"physik": Fach.PHYSIK,
|
||||
"geschichte": Fach.GESCHICHTE,
|
||||
"erdkunde": Fach.ERDKUNDE,
|
||||
"geographie": Fach.ERDKUNDE,
|
||||
"politikwirtschaft": Fach.POLITIK_WIRTSCHAFT,
|
||||
"politik": Fach.POLITIK_WIRTSCHAFT,
|
||||
"franzoesisch": Fach.FRANZOESISCH,
|
||||
"franz": Fach.FRANZOESISCH,
|
||||
"spanisch": Fach.SPANISCH,
|
||||
"latein": Fach.LATEIN,
|
||||
"griechisch": Fach.GRIECHISCH,
|
||||
"kunst": Fach.KUNST,
|
||||
"musik": Fach.MUSIK,
|
||||
"sport": Fach.SPORT,
|
||||
"informatik": Fach.INFORMATIK,
|
||||
"evreligion": Fach.EV_RELIGION,
|
||||
"kathreligion": Fach.KATH_RELIGION,
|
||||
"wertenormen": Fach.WERTE_NORMEN,
|
||||
"brc": Fach.BRC,
|
||||
"bvw": Fach.BVW,
|
||||
"ernaehrung": Fach.ERNAEHRUNG,
|
||||
"mecha": Fach.MECHATRONIK,
|
||||
"mechatronik": Fach.MECHATRONIK,
|
||||
"technikmecha": Fach.MECHATRONIK,
|
||||
"gespfl": Fach.GESUNDHEIT_PFLEGE,
|
||||
"paedpsych": Fach.PAEDAGOGIK_PSYCHOLOGIE,
|
||||
}
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Pydantic Models
|
||||
# ============================================================================
|
||||
|
||||
class DokumentCreate(BaseModel):
|
||||
"""Manuelles Erstellen eines Dokuments."""
|
||||
bundesland: Bundesland
|
||||
fach: Fach
|
||||
jahr: int = Field(ge=2000, le=2100)
|
||||
niveau: Niveau
|
||||
typ: DokumentTyp
|
||||
aufgaben_nummer: Optional[str] = None
|
||||
|
||||
|
||||
class DokumentUpdate(BaseModel):
|
||||
"""Update für erkannte Metadaten."""
|
||||
bundesland: Optional[Bundesland] = None
|
||||
fach: Optional[Fach] = None
|
||||
jahr: Optional[int] = None
|
||||
niveau: Optional[Niveau] = None
|
||||
typ: Optional[DokumentTyp] = None
|
||||
aufgaben_nummer: Optional[str] = None
|
||||
status: Optional[VerarbeitungsStatus] = None
|
||||
|
||||
|
||||
class DokumentResponse(BaseModel):
|
||||
"""Response für ein Dokument."""
|
||||
id: str
|
||||
dateiname: str
|
||||
original_dateiname: str
|
||||
bundesland: Bundesland
|
||||
fach: Fach
|
||||
jahr: int
|
||||
niveau: Niveau
|
||||
typ: DokumentTyp
|
||||
aufgaben_nummer: Optional[str]
|
||||
status: VerarbeitungsStatus
|
||||
confidence: float
|
||||
file_path: str
|
||||
file_size: int
|
||||
indexed: bool
|
||||
vector_ids: List[str]
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class ImportResult(BaseModel):
|
||||
"""Ergebnis eines ZIP-Imports."""
|
||||
total_files: int
|
||||
recognized: int
|
||||
errors: int
|
||||
documents: List[DokumentResponse]
|
||||
|
||||
|
||||
class RecognitionResult(BaseModel):
|
||||
"""Ergebnis der Dokumentenerkennung."""
|
||||
success: bool
|
||||
bundesland: Optional[Bundesland]
|
||||
fach: Optional[Fach]
|
||||
jahr: Optional[int]
|
||||
niveau: Optional[Niveau]
|
||||
typ: Optional[DokumentTyp]
|
||||
aufgaben_nummer: Optional[str]
|
||||
confidence: float
|
||||
raw_filename: str
|
||||
suggestions: List[Dict[str, Any]]
|
||||
|
||||
@property
|
||||
def extracted(self) -> Dict[str, Any]:
|
||||
"""Backwards-compatible property returning extracted values as dict."""
|
||||
result = {}
|
||||
if self.bundesland:
|
||||
result["bundesland"] = self.bundesland.value
|
||||
if self.fach:
|
||||
result["fach"] = self.fach.value
|
||||
if self.jahr:
|
||||
result["jahr"] = self.jahr
|
||||
if self.niveau:
|
||||
result["niveau"] = self.niveau.value
|
||||
if self.typ:
|
||||
result["typ"] = self.typ.value
|
||||
if self.aufgaben_nummer:
|
||||
result["aufgaben_nummer"] = self.aufgaben_nummer
|
||||
return result
|
||||
|
||||
@property
|
||||
def method(self) -> str:
|
||||
"""Backwards-compatible property for recognition method."""
|
||||
return "filename_pattern"
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Internal Data Classes
|
||||
# ============================================================================
|
||||
|
||||
@dataclass
|
||||
class AbiturDokument:
|
||||
"""Internes Dokument."""
|
||||
id: str
|
||||
dateiname: str
|
||||
original_dateiname: str
|
||||
bundesland: Bundesland
|
||||
fach: Fach
|
||||
jahr: int
|
||||
niveau: Niveau
|
||||
typ: DokumentTyp
|
||||
aufgaben_nummer: Optional[str]
|
||||
status: VerarbeitungsStatus
|
||||
confidence: float
|
||||
file_path: str
|
||||
file_size: int
|
||||
indexed: bool
|
||||
vector_ids: List[str]
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Backwards-compatibility aliases (used by tests)
|
||||
# ============================================================================
|
||||
AbiturFach = Fach
|
||||
Anforderungsniveau = Niveau
|
||||
|
||||
|
||||
class DocumentMetadata(BaseModel):
|
||||
"""Backwards-compatible metadata model for tests."""
|
||||
jahr: Optional[int] = None
|
||||
bundesland: Optional[str] = None
|
||||
fach: Optional[str] = None
|
||||
niveau: Optional[str] = None
|
||||
dokument_typ: Optional[str] = None
|
||||
aufgaben_nummer: Optional[str] = None
|
||||
|
||||
|
||||
class AbiturDokumentCompat(BaseModel):
|
||||
"""Backwards-compatible AbiturDokument model for tests."""
|
||||
id: str
|
||||
filename: str
|
||||
file_path: str
|
||||
metadata: DocumentMetadata
|
||||
status: VerarbeitungsStatus
|
||||
recognition_result: Optional[RecognitionResult] = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
class Config:
|
||||
arbitrary_types_allowed = True
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Fach Labels (für Frontend Enum-Endpoint)
|
||||
# ============================================================================
|
||||
|
||||
FACH_LABELS = {
|
||||
Fach.DEUTSCH: "Deutsch",
|
||||
Fach.ENGLISCH: "Englisch",
|
||||
Fach.MATHEMATIK: "Mathematik",
|
||||
Fach.BIOLOGIE: "Biologie",
|
||||
Fach.CHEMIE: "Chemie",
|
||||
Fach.PHYSIK: "Physik",
|
||||
Fach.GESCHICHTE: "Geschichte",
|
||||
Fach.ERDKUNDE: "Erdkunde",
|
||||
Fach.POLITIK_WIRTSCHAFT: "Politik-Wirtschaft",
|
||||
Fach.FRANZOESISCH: "Französisch",
|
||||
Fach.SPANISCH: "Spanisch",
|
||||
Fach.LATEIN: "Latein",
|
||||
Fach.GRIECHISCH: "Griechisch",
|
||||
Fach.KUNST: "Kunst",
|
||||
Fach.MUSIK: "Musik",
|
||||
Fach.SPORT: "Sport",
|
||||
Fach.INFORMATIK: "Informatik",
|
||||
Fach.EV_RELIGION: "Ev. Religion",
|
||||
Fach.KATH_RELIGION: "Kath. Religion",
|
||||
Fach.WERTE_NORMEN: "Werte und Normen",
|
||||
Fach.BRC: "BRC (Betriebswirtschaft)",
|
||||
Fach.BVW: "BVW (Volkswirtschaft)",
|
||||
Fach.ERNAEHRUNG: "Ernährung",
|
||||
Fach.MECHATRONIK: "Mechatronik",
|
||||
Fach.GESUNDHEIT_PFLEGE: "Gesundheit-Pflege",
|
||||
Fach.PAEDAGOGIK_PSYCHOLOGIE: "Pädagogik-Psychologie",
|
||||
}
|
||||
|
||||
DOKUMENT_TYP_LABELS = {
|
||||
DokumentTyp.AUFGABE: "Aufgabe",
|
||||
DokumentTyp.ERWARTUNGSHORIZONT: "Erwartungshorizont",
|
||||
DokumentTyp.DECKBLATT: "Deckblatt",
|
||||
DokumentTyp.MATERIAL: "Material",
|
||||
DokumentTyp.HOERVERSTEHEN: "Hörverstehen",
|
||||
DokumentTyp.SPRACHMITTLUNG: "Sprachmittlung",
|
||||
DokumentTyp.BEWERTUNGSBOGEN: "Bewertungsbogen",
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
"""
|
||||
Abitur Document Store - Dateinamen-Erkennung und Helfer.
|
||||
|
||||
Erkennt Metadaten aus NiBiS-Dateinamen (Niedersachsen).
|
||||
"""
|
||||
|
||||
import re
|
||||
from typing import Dict, Any
|
||||
from pathlib import Path
|
||||
|
||||
from abitur_docs_models import (
|
||||
Bundesland, Fach, Niveau, DokumentTyp, VerarbeitungsStatus,
|
||||
RecognitionResult, AbiturDokument, DokumentResponse,
|
||||
FACH_NAME_MAPPING,
|
||||
)
|
||||
|
||||
|
||||
def parse_nibis_filename(filename: str) -> RecognitionResult:
|
||||
"""
|
||||
Erkennt Metadaten aus NiBiS-Dateinamen.
|
||||
|
||||
Beispiele:
|
||||
- 2025_Deutsch_eA_I.pdf
|
||||
- 2025_Deutsch_eA_I_EWH.pdf
|
||||
- 2025_Biologie_gA_1.pdf
|
||||
- 2025_Englisch_eA_HV.pdf (Hörverstehen)
|
||||
"""
|
||||
result = RecognitionResult(
|
||||
success=False,
|
||||
bundesland=Bundesland.NIEDERSACHSEN,
|
||||
fach=None,
|
||||
jahr=None,
|
||||
niveau=None,
|
||||
typ=None,
|
||||
aufgaben_nummer=None,
|
||||
confidence=0.0,
|
||||
raw_filename=filename,
|
||||
suggestions=[]
|
||||
)
|
||||
|
||||
# Bereinige Dateiname
|
||||
name = Path(filename).stem.lower()
|
||||
|
||||
# Extrahiere Jahr (4 Ziffern am Anfang)
|
||||
jahr_match = re.match(r'^(\d{4})', name)
|
||||
if jahr_match:
|
||||
result.jahr = int(jahr_match.group(1))
|
||||
result.confidence += 0.2
|
||||
|
||||
# Extrahiere Fach
|
||||
for fach_key, fach_enum in FACH_NAME_MAPPING.items():
|
||||
if fach_key in name.replace("_", "").replace("-", ""):
|
||||
result.fach = fach_enum
|
||||
result.confidence += 0.3
|
||||
break
|
||||
|
||||
# Extrahiere Niveau (eA/gA)
|
||||
if "_ea" in name or "_ea_" in name or "ea_" in name:
|
||||
result.niveau = Niveau.EA
|
||||
result.confidence += 0.2
|
||||
elif "_ga" in name or "_ga_" in name or "ga_" in name:
|
||||
result.niveau = Niveau.GA
|
||||
result.confidence += 0.2
|
||||
|
||||
# Extrahiere Typ
|
||||
if "_ewh" in name:
|
||||
result.typ = DokumentTyp.ERWARTUNGSHORIZONT
|
||||
result.confidence += 0.2
|
||||
elif "_hv" in name or "hoerverstehen" in name:
|
||||
result.typ = DokumentTyp.HOERVERSTEHEN
|
||||
result.confidence += 0.15
|
||||
elif "_sm" in name or "_me" in name or "sprachmittlung" in name:
|
||||
result.typ = DokumentTyp.SPRACHMITTLUNG
|
||||
result.confidence += 0.15
|
||||
elif "deckblatt" in name:
|
||||
result.typ = DokumentTyp.DECKBLATT
|
||||
result.confidence += 0.15
|
||||
elif "material" in name:
|
||||
result.typ = DokumentTyp.MATERIAL
|
||||
result.confidence += 0.15
|
||||
elif "bewertung" in name:
|
||||
result.typ = DokumentTyp.BEWERTUNGSBOGEN
|
||||
result.confidence += 0.15
|
||||
else:
|
||||
result.typ = DokumentTyp.AUFGABE
|
||||
result.confidence += 0.1
|
||||
|
||||
# Extrahiere Aufgabennummer (römisch oder arabisch)
|
||||
aufgabe_match = re.search(r'_([ivx]+|[1-4][abc]?)(?:_|\.pdf|$)', name, re.IGNORECASE)
|
||||
if aufgabe_match:
|
||||
result.aufgaben_nummer = aufgabe_match.group(1).upper()
|
||||
result.confidence += 0.1
|
||||
|
||||
# Erfolg wenn mindestens Fach und Jahr erkannt
|
||||
if result.fach and result.jahr:
|
||||
result.success = True
|
||||
|
||||
# Normalisiere Confidence auf max 1.0
|
||||
result.confidence = min(result.confidence, 1.0)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def to_dokument_response(doc: AbiturDokument) -> DokumentResponse:
|
||||
"""Konvertiert internes Dokument zu Response."""
|
||||
return DokumentResponse(
|
||||
id=doc.id,
|
||||
dateiname=doc.dateiname,
|
||||
original_dateiname=doc.original_dateiname,
|
||||
bundesland=doc.bundesland,
|
||||
fach=doc.fach,
|
||||
jahr=doc.jahr,
|
||||
niveau=doc.niveau,
|
||||
typ=doc.typ,
|
||||
aufgaben_nummer=doc.aufgaben_nummer,
|
||||
status=doc.status,
|
||||
confidence=doc.confidence,
|
||||
file_path=doc.file_path,
|
||||
file_size=doc.file_size,
|
||||
indexed=doc.indexed,
|
||||
vector_ids=doc.vector_ids,
|
||||
created_at=doc.created_at,
|
||||
updated_at=doc.updated_at
|
||||
)
|
||||
@@ -0,0 +1,394 @@
|
||||
"""
|
||||
Repository für Alert Items (einzelne Alerts/Artikel).
|
||||
"""
|
||||
import hashlib
|
||||
import urllib.parse
|
||||
import uuid
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, List, Dict, Any
|
||||
from sqlalchemy.orm import Session as DBSession
|
||||
from sqlalchemy import or_, func
|
||||
|
||||
from .models import (
|
||||
AlertItemDB, AlertSourceEnum, AlertStatusEnum, RelevanceDecisionEnum
|
||||
)
|
||||
|
||||
|
||||
class AlertItemRepository:
|
||||
"""Repository für Alert Items (einzelne Alerts/Artikel)."""
|
||||
|
||||
def __init__(self, db: DBSession):
|
||||
self.db = db
|
||||
|
||||
# ==================== CREATE ====================
|
||||
|
||||
def create(
|
||||
self,
|
||||
topic_id: str,
|
||||
title: str,
|
||||
url: str,
|
||||
snippet: str = "",
|
||||
source: str = "google_alerts_rss",
|
||||
published_at: datetime = None,
|
||||
lang: str = "de",
|
||||
) -> AlertItemDB:
|
||||
"""Erstellt einen neuen Alert."""
|
||||
url_hash = self._compute_url_hash(url)
|
||||
|
||||
alert = AlertItemDB(
|
||||
id=str(uuid.uuid4()),
|
||||
topic_id=topic_id,
|
||||
title=title,
|
||||
url=url,
|
||||
snippet=snippet,
|
||||
source=AlertSourceEnum(source),
|
||||
published_at=published_at,
|
||||
lang=lang,
|
||||
url_hash=url_hash,
|
||||
canonical_url=self._normalize_url(url),
|
||||
)
|
||||
self.db.add(alert)
|
||||
self.db.commit()
|
||||
self.db.refresh(alert)
|
||||
return alert
|
||||
|
||||
def create_if_not_exists(
|
||||
self,
|
||||
topic_id: str,
|
||||
title: str,
|
||||
url: str,
|
||||
snippet: str = "",
|
||||
source: str = "google_alerts_rss",
|
||||
published_at: datetime = None,
|
||||
) -> Optional[AlertItemDB]:
|
||||
"""Erstellt einen Alert nur wenn URL noch nicht existiert."""
|
||||
url_hash = self._compute_url_hash(url)
|
||||
|
||||
existing = self.db.query(AlertItemDB).filter(
|
||||
AlertItemDB.url_hash == url_hash
|
||||
).first()
|
||||
|
||||
if existing:
|
||||
return None # Duplikat
|
||||
|
||||
return self.create(
|
||||
topic_id=topic_id,
|
||||
title=title,
|
||||
url=url,
|
||||
snippet=snippet,
|
||||
source=source,
|
||||
published_at=published_at,
|
||||
)
|
||||
|
||||
# ==================== READ ====================
|
||||
|
||||
def get_by_id(self, alert_id: str) -> Optional[AlertItemDB]:
|
||||
"""Holt einen Alert nach ID."""
|
||||
return self.db.query(AlertItemDB).filter(
|
||||
AlertItemDB.id == alert_id
|
||||
).first()
|
||||
|
||||
def get_by_url_hash(self, url_hash: str) -> Optional[AlertItemDB]:
|
||||
"""Holt einen Alert nach URL-Hash."""
|
||||
return self.db.query(AlertItemDB).filter(
|
||||
AlertItemDB.url_hash == url_hash
|
||||
).first()
|
||||
|
||||
def get_inbox(
|
||||
self,
|
||||
user_id: str = None,
|
||||
topic_id: str = None,
|
||||
decision: str = None,
|
||||
status: str = None,
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
) -> List[AlertItemDB]:
|
||||
"""
|
||||
Holt Inbox-Items mit Filtern.
|
||||
|
||||
Ohne decision werden KEEP und REVIEW angezeigt.
|
||||
"""
|
||||
query = self.db.query(AlertItemDB)
|
||||
|
||||
if topic_id:
|
||||
query = query.filter(AlertItemDB.topic_id == topic_id)
|
||||
|
||||
if decision:
|
||||
query = query.filter(
|
||||
AlertItemDB.relevance_decision == RelevanceDecisionEnum(decision)
|
||||
)
|
||||
else:
|
||||
# Default: KEEP und REVIEW
|
||||
query = query.filter(
|
||||
or_(
|
||||
AlertItemDB.relevance_decision == RelevanceDecisionEnum.KEEP,
|
||||
AlertItemDB.relevance_decision == RelevanceDecisionEnum.REVIEW,
|
||||
AlertItemDB.relevance_decision.is_(None)
|
||||
)
|
||||
)
|
||||
|
||||
if status:
|
||||
query = query.filter(AlertItemDB.status == AlertStatusEnum(status))
|
||||
|
||||
return query.order_by(
|
||||
AlertItemDB.relevance_score.desc().nullslast(),
|
||||
AlertItemDB.fetched_at.desc()
|
||||
).offset(offset).limit(limit).all()
|
||||
|
||||
def get_unscored(
|
||||
self,
|
||||
topic_id: str = None,
|
||||
limit: int = 100,
|
||||
) -> List[AlertItemDB]:
|
||||
"""Holt alle unbewerteten Alerts."""
|
||||
query = self.db.query(AlertItemDB).filter(
|
||||
AlertItemDB.status == AlertStatusEnum.NEW
|
||||
)
|
||||
|
||||
if topic_id:
|
||||
query = query.filter(AlertItemDB.topic_id == topic_id)
|
||||
|
||||
return query.order_by(AlertItemDB.fetched_at.desc()).limit(limit).all()
|
||||
|
||||
def get_by_topic(
|
||||
self,
|
||||
topic_id: str,
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
) -> List[AlertItemDB]:
|
||||
"""Holt alle Alerts eines Topics."""
|
||||
return self.db.query(AlertItemDB).filter(
|
||||
AlertItemDB.topic_id == topic_id
|
||||
).order_by(
|
||||
AlertItemDB.fetched_at.desc()
|
||||
).offset(offset).limit(limit).all()
|
||||
|
||||
def count_by_status(self, topic_id: str = None) -> Dict[str, int]:
|
||||
"""Zählt Alerts nach Status."""
|
||||
query = self.db.query(
|
||||
AlertItemDB.status,
|
||||
func.count(AlertItemDB.id).label('count')
|
||||
)
|
||||
|
||||
if topic_id:
|
||||
query = query.filter(AlertItemDB.topic_id == topic_id)
|
||||
|
||||
results = query.group_by(AlertItemDB.status).all()
|
||||
|
||||
return {r[0].value: r[1] for r in results}
|
||||
|
||||
def count_by_decision(self, topic_id: str = None) -> Dict[str, int]:
|
||||
"""Zählt Alerts nach Relevanz-Entscheidung."""
|
||||
query = self.db.query(
|
||||
AlertItemDB.relevance_decision,
|
||||
func.count(AlertItemDB.id).label('count')
|
||||
)
|
||||
|
||||
if topic_id:
|
||||
query = query.filter(AlertItemDB.topic_id == topic_id)
|
||||
|
||||
results = query.group_by(AlertItemDB.relevance_decision).all()
|
||||
|
||||
return {
|
||||
(r[0].value if r[0] else "unscored"): r[1]
|
||||
for r in results
|
||||
}
|
||||
|
||||
# ==================== UPDATE ====================
|
||||
|
||||
def update_scoring(
|
||||
self,
|
||||
alert_id: str,
|
||||
score: float,
|
||||
decision: str,
|
||||
reasons: List[str] = None,
|
||||
summary: str = None,
|
||||
model: str = None,
|
||||
) -> Optional[AlertItemDB]:
|
||||
"""Aktualisiert das Scoring eines Alerts."""
|
||||
alert = self.get_by_id(alert_id)
|
||||
if not alert:
|
||||
return None
|
||||
|
||||
alert.relevance_score = score
|
||||
alert.relevance_decision = RelevanceDecisionEnum(decision)
|
||||
alert.relevance_reasons = reasons or []
|
||||
alert.relevance_summary = summary
|
||||
alert.scored_by_model = model
|
||||
alert.scored_at = datetime.utcnow()
|
||||
alert.status = AlertStatusEnum.SCORED
|
||||
alert.processed_at = datetime.utcnow()
|
||||
|
||||
self.db.commit()
|
||||
self.db.refresh(alert)
|
||||
return alert
|
||||
|
||||
def update_status(
|
||||
self,
|
||||
alert_id: str,
|
||||
status: str,
|
||||
) -> Optional[AlertItemDB]:
|
||||
"""Aktualisiert den Status eines Alerts."""
|
||||
alert = self.get_by_id(alert_id)
|
||||
if not alert:
|
||||
return None
|
||||
|
||||
alert.status = AlertStatusEnum(status)
|
||||
|
||||
self.db.commit()
|
||||
self.db.refresh(alert)
|
||||
return alert
|
||||
|
||||
def mark_reviewed(
|
||||
self,
|
||||
alert_id: str,
|
||||
is_relevant: bool,
|
||||
notes: str = None,
|
||||
tags: List[str] = None,
|
||||
) -> Optional[AlertItemDB]:
|
||||
"""Markiert einen Alert als reviewed mit Feedback."""
|
||||
alert = self.get_by_id(alert_id)
|
||||
if not alert:
|
||||
return None
|
||||
|
||||
alert.status = AlertStatusEnum.REVIEWED
|
||||
alert.user_marked_relevant = is_relevant
|
||||
if notes:
|
||||
alert.user_notes = notes
|
||||
if tags:
|
||||
alert.user_tags = tags
|
||||
|
||||
self.db.commit()
|
||||
self.db.refresh(alert)
|
||||
return alert
|
||||
|
||||
def archive(self, alert_id: str) -> Optional[AlertItemDB]:
|
||||
"""Archiviert einen Alert."""
|
||||
return self.update_status(alert_id, "archived")
|
||||
|
||||
# ==================== DELETE ====================
|
||||
|
||||
def delete(self, alert_id: str) -> bool:
|
||||
"""Löscht einen Alert."""
|
||||
alert = self.get_by_id(alert_id)
|
||||
if not alert:
|
||||
return False
|
||||
|
||||
self.db.delete(alert)
|
||||
self.db.commit()
|
||||
return True
|
||||
|
||||
def delete_old(self, days: int = 90, topic_id: str = None) -> int:
|
||||
"""Löscht alte archivierte Alerts."""
|
||||
cutoff = datetime.utcnow() - timedelta(days=days)
|
||||
|
||||
query = self.db.query(AlertItemDB).filter(
|
||||
AlertItemDB.status == AlertStatusEnum.ARCHIVED,
|
||||
AlertItemDB.fetched_at < cutoff,
|
||||
)
|
||||
|
||||
if topic_id:
|
||||
query = query.filter(AlertItemDB.topic_id == topic_id)
|
||||
|
||||
count = query.delete()
|
||||
self.db.commit()
|
||||
return count
|
||||
|
||||
# ==================== FOR RSS FETCHER ====================
|
||||
|
||||
def get_existing_urls(self, topic_id: str) -> set:
|
||||
"""
|
||||
Holt alle bekannten URL-Hashes für ein Topic.
|
||||
|
||||
Wird vom RSS-Fetcher verwendet um Duplikate zu vermeiden.
|
||||
"""
|
||||
results = self.db.query(AlertItemDB.url_hash).filter(
|
||||
AlertItemDB.topic_id == topic_id
|
||||
).all()
|
||||
|
||||
return {r[0] for r in results if r[0]}
|
||||
|
||||
def create_from_alert_item(self, alert_item, topic_id: str) -> AlertItemDB:
|
||||
"""
|
||||
Erstellt einen Alert aus einem AlertItem-Objekt vom RSS-Fetcher.
|
||||
|
||||
Args:
|
||||
alert_item: AlertItem from rss_fetcher
|
||||
topic_id: Topic ID to associate with
|
||||
|
||||
Returns:
|
||||
Created AlertItemDB instance
|
||||
"""
|
||||
return self.create(
|
||||
topic_id=topic_id,
|
||||
title=alert_item.title,
|
||||
url=alert_item.url,
|
||||
snippet=alert_item.snippet or "",
|
||||
source=alert_item.source.value if hasattr(alert_item.source, 'value') else str(alert_item.source),
|
||||
published_at=alert_item.published_at,
|
||||
)
|
||||
|
||||
# ==================== HELPER ====================
|
||||
|
||||
def _compute_url_hash(self, url: str) -> str:
|
||||
"""Berechnet SHA256 Hash der normalisierten URL."""
|
||||
normalized = self._normalize_url(url)
|
||||
return hashlib.sha256(normalized.encode()).hexdigest()[:16]
|
||||
|
||||
def _normalize_url(self, url: str) -> str:
|
||||
"""Normalisiert URL für Deduplizierung."""
|
||||
parsed = urllib.parse.urlparse(url)
|
||||
|
||||
# Tracking-Parameter entfernen
|
||||
tracking_params = {
|
||||
"utm_source", "utm_medium", "utm_campaign", "utm_content", "utm_term",
|
||||
"fbclid", "gclid", "ref", "source"
|
||||
}
|
||||
|
||||
query_params = urllib.parse.parse_qs(parsed.query)
|
||||
cleaned_params = {k: v for k, v in query_params.items()
|
||||
if k.lower() not in tracking_params}
|
||||
|
||||
cleaned_query = urllib.parse.urlencode(cleaned_params, doseq=True)
|
||||
|
||||
# Rekonstruiere URL ohne Fragment
|
||||
normalized = urllib.parse.urlunparse((
|
||||
parsed.scheme,
|
||||
parsed.netloc.lower(),
|
||||
parsed.path.rstrip("/"),
|
||||
parsed.params,
|
||||
cleaned_query,
|
||||
"" # No fragment
|
||||
))
|
||||
|
||||
return normalized
|
||||
|
||||
# ==================== CONVERSION ====================
|
||||
|
||||
def to_dict(self, alert: AlertItemDB) -> Dict[str, Any]:
|
||||
"""Konvertiert DB-Model zu Dictionary."""
|
||||
return {
|
||||
"id": alert.id,
|
||||
"topic_id": alert.topic_id,
|
||||
"title": alert.title,
|
||||
"url": alert.url,
|
||||
"snippet": alert.snippet,
|
||||
"source": alert.source.value,
|
||||
"lang": alert.lang,
|
||||
"published_at": alert.published_at.isoformat() if alert.published_at else None,
|
||||
"fetched_at": alert.fetched_at.isoformat() if alert.fetched_at else None,
|
||||
"status": alert.status.value,
|
||||
"relevance": {
|
||||
"score": alert.relevance_score,
|
||||
"decision": alert.relevance_decision.value if alert.relevance_decision else None,
|
||||
"reasons": alert.relevance_reasons,
|
||||
"summary": alert.relevance_summary,
|
||||
"model": alert.scored_by_model,
|
||||
"scored_at": alert.scored_at.isoformat() if alert.scored_at else None,
|
||||
},
|
||||
"user_feedback": {
|
||||
"marked_relevant": alert.user_marked_relevant,
|
||||
"tags": alert.user_tags,
|
||||
"notes": alert.user_notes,
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
"""
|
||||
Repository für Alert Profiles (Nutzer-Profile für Relevanz-Scoring).
|
||||
"""
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Optional, List, Dict, Any
|
||||
from sqlalchemy.orm import Session as DBSession
|
||||
from sqlalchemy.orm.attributes import flag_modified
|
||||
|
||||
from .models import AlertProfileDB
|
||||
|
||||
|
||||
class ProfileRepository:
|
||||
"""Repository für Alert Profiles (Nutzer-Profile für Relevanz-Scoring)."""
|
||||
|
||||
def __init__(self, db: DBSession):
|
||||
self.db = db
|
||||
|
||||
# ==================== CREATE / GET-OR-CREATE ====================
|
||||
|
||||
def get_or_create(self, user_id: str = None) -> AlertProfileDB:
|
||||
"""Holt oder erstellt ein Profil."""
|
||||
profile = self.get_by_user_id(user_id)
|
||||
if profile:
|
||||
return profile
|
||||
|
||||
# Neues Profil erstellen
|
||||
profile = AlertProfileDB(
|
||||
id=str(uuid.uuid4()),
|
||||
user_id=user_id,
|
||||
name="Default" if not user_id else f"Profile {user_id[:8]}",
|
||||
)
|
||||
self.db.add(profile)
|
||||
self.db.commit()
|
||||
self.db.refresh(profile)
|
||||
return profile
|
||||
|
||||
def create_default_education_profile(self, user_id: str = None) -> AlertProfileDB:
|
||||
"""Erstellt ein Standard-Profil für Bildungsthemen."""
|
||||
profile = AlertProfileDB(
|
||||
id=str(uuid.uuid4()),
|
||||
user_id=user_id,
|
||||
name="Bildung Default",
|
||||
priorities=[
|
||||
{
|
||||
"label": "Inklusion",
|
||||
"weight": 0.9,
|
||||
"keywords": ["inklusiv", "Förderbedarf", "Behinderung", "Barrierefreiheit"],
|
||||
"description": "Inklusive Bildung, Förderschulen, Nachteilsausgleich"
|
||||
},
|
||||
{
|
||||
"label": "Datenschutz Schule",
|
||||
"weight": 0.85,
|
||||
"keywords": ["DSGVO", "Schülerfotos", "Einwilligung", "personenbezogene Daten"],
|
||||
"description": "DSGVO in Schulen, Datenschutz bei Klassenfotos"
|
||||
},
|
||||
{
|
||||
"label": "Schulrecht Bayern",
|
||||
"weight": 0.8,
|
||||
"keywords": ["BayEUG", "Schulordnung", "Kultusministerium", "Bayern"],
|
||||
"description": "Bayerisches Schulrecht, Verordnungen"
|
||||
},
|
||||
{
|
||||
"label": "Digitalisierung Schule",
|
||||
"weight": 0.7,
|
||||
"keywords": ["DigitalPakt", "Tablet-Klasse", "Lernplattform"],
|
||||
"description": "Digitale Medien im Unterricht"
|
||||
},
|
||||
],
|
||||
exclusions=["Stellenanzeige", "Praktikum gesucht", "Werbung", "Pressemitteilung"],
|
||||
policies={
|
||||
"prefer_german_sources": True,
|
||||
"max_age_days": 30,
|
||||
"min_content_length": 100,
|
||||
}
|
||||
)
|
||||
self.db.add(profile)
|
||||
self.db.commit()
|
||||
self.db.refresh(profile)
|
||||
return profile
|
||||
|
||||
# ==================== READ ====================
|
||||
|
||||
def get_by_id(self, profile_id: str) -> Optional[AlertProfileDB]:
|
||||
"""Holt ein Profil nach ID."""
|
||||
return self.db.query(AlertProfileDB).filter(
|
||||
AlertProfileDB.id == profile_id
|
||||
).first()
|
||||
|
||||
def get_by_user_id(self, user_id: str) -> Optional[AlertProfileDB]:
|
||||
"""Holt ein Profil nach User-ID."""
|
||||
if not user_id:
|
||||
# Default-Profil ohne User
|
||||
return self.db.query(AlertProfileDB).filter(
|
||||
AlertProfileDB.user_id.is_(None)
|
||||
).first()
|
||||
|
||||
return self.db.query(AlertProfileDB).filter(
|
||||
AlertProfileDB.user_id == user_id
|
||||
).first()
|
||||
|
||||
# ==================== UPDATE ====================
|
||||
|
||||
def update_priorities(
|
||||
self,
|
||||
profile_id: str,
|
||||
priorities: List[Dict],
|
||||
) -> Optional[AlertProfileDB]:
|
||||
"""Aktualisiert die Prioritäten eines Profils."""
|
||||
profile = self.get_by_id(profile_id)
|
||||
if not profile:
|
||||
return None
|
||||
|
||||
profile.priorities = priorities
|
||||
self.db.commit()
|
||||
self.db.refresh(profile)
|
||||
return profile
|
||||
|
||||
def update_exclusions(
|
||||
self,
|
||||
profile_id: str,
|
||||
exclusions: List[str],
|
||||
) -> Optional[AlertProfileDB]:
|
||||
"""Aktualisiert die Ausschlüsse eines Profils."""
|
||||
profile = self.get_by_id(profile_id)
|
||||
if not profile:
|
||||
return None
|
||||
|
||||
profile.exclusions = exclusions
|
||||
self.db.commit()
|
||||
self.db.refresh(profile)
|
||||
return profile
|
||||
|
||||
def add_feedback(
|
||||
self,
|
||||
profile_id: str,
|
||||
title: str,
|
||||
url: str,
|
||||
is_relevant: bool,
|
||||
reason: str = "",
|
||||
) -> Optional[AlertProfileDB]:
|
||||
"""Fügt Feedback als Beispiel hinzu."""
|
||||
profile = self.get_by_id(profile_id)
|
||||
if not profile:
|
||||
return None
|
||||
|
||||
example = {
|
||||
"title": title,
|
||||
"url": url,
|
||||
"reason": reason,
|
||||
"added_at": datetime.utcnow().isoformat(),
|
||||
}
|
||||
|
||||
if is_relevant:
|
||||
examples = list(profile.positive_examples or [])
|
||||
examples.append(example)
|
||||
profile.positive_examples = examples[-20:] # Max 20
|
||||
profile.total_kept += 1
|
||||
flag_modified(profile, "positive_examples")
|
||||
else:
|
||||
examples = list(profile.negative_examples or [])
|
||||
examples.append(example)
|
||||
profile.negative_examples = examples[-20:] # Max 20
|
||||
profile.total_dropped += 1
|
||||
flag_modified(profile, "negative_examples")
|
||||
|
||||
profile.total_scored += 1
|
||||
self.db.commit()
|
||||
self.db.refresh(profile)
|
||||
return profile
|
||||
|
||||
def update_stats(
|
||||
self,
|
||||
profile_id: str,
|
||||
kept: int = 0,
|
||||
dropped: int = 0,
|
||||
) -> Optional[AlertProfileDB]:
|
||||
"""Aktualisiert die Statistiken eines Profils."""
|
||||
profile = self.get_by_id(profile_id)
|
||||
if not profile:
|
||||
return None
|
||||
|
||||
profile.total_scored += kept + dropped
|
||||
profile.total_kept += kept
|
||||
profile.total_dropped += dropped
|
||||
|
||||
self.db.commit()
|
||||
self.db.refresh(profile)
|
||||
return profile
|
||||
|
||||
# ==================== DELETE ====================
|
||||
|
||||
def delete(self, profile_id: str) -> bool:
|
||||
"""Löscht ein Profil."""
|
||||
profile = self.get_by_id(profile_id)
|
||||
if not profile:
|
||||
return False
|
||||
|
||||
self.db.delete(profile)
|
||||
self.db.commit()
|
||||
return True
|
||||
|
||||
# ==================== CONVERSION ====================
|
||||
|
||||
def to_dict(self, profile: AlertProfileDB) -> Dict[str, Any]:
|
||||
"""Konvertiert DB-Model zu Dictionary."""
|
||||
return {
|
||||
"id": profile.id,
|
||||
"user_id": profile.user_id,
|
||||
"name": profile.name,
|
||||
"priorities": profile.priorities,
|
||||
"exclusions": profile.exclusions,
|
||||
"policies": profile.policies,
|
||||
"examples": {
|
||||
"positive": len(profile.positive_examples or []),
|
||||
"negative": len(profile.negative_examples or []),
|
||||
},
|
||||
"stats": {
|
||||
"total_scored": profile.total_scored,
|
||||
"total_kept": profile.total_kept,
|
||||
"total_dropped": profile.total_dropped,
|
||||
"accuracy_estimate": profile.accuracy_estimate,
|
||||
},
|
||||
"created_at": profile.created_at.isoformat() if profile.created_at else None,
|
||||
"updated_at": profile.updated_at.isoformat() if profile.updated_at else None,
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,187 @@
|
||||
"""
|
||||
Repository für Alert Rules (Filterregeln).
|
||||
"""
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Optional, List, Dict, Any
|
||||
from sqlalchemy.orm import Session as DBSession
|
||||
from sqlalchemy import or_
|
||||
|
||||
from .models import AlertRuleDB, RuleActionEnum
|
||||
|
||||
|
||||
class RuleRepository:
|
||||
"""Repository für Alert Rules (Filterregeln)."""
|
||||
|
||||
def __init__(self, db: DBSession):
|
||||
self.db = db
|
||||
|
||||
# ==================== CREATE ====================
|
||||
|
||||
def create(
|
||||
self,
|
||||
name: str,
|
||||
conditions: List[Dict],
|
||||
action_type: str = "keep",
|
||||
action_config: Dict = None,
|
||||
topic_id: str = None,
|
||||
user_id: str = None,
|
||||
description: str = "",
|
||||
priority: int = 0,
|
||||
) -> AlertRuleDB:
|
||||
"""Erstellt eine neue Regel."""
|
||||
rule = AlertRuleDB(
|
||||
id=str(uuid.uuid4()),
|
||||
topic_id=topic_id,
|
||||
user_id=user_id,
|
||||
name=name,
|
||||
description=description,
|
||||
conditions=conditions,
|
||||
action_type=RuleActionEnum(action_type),
|
||||
action_config=action_config or {},
|
||||
priority=priority,
|
||||
)
|
||||
self.db.add(rule)
|
||||
self.db.commit()
|
||||
self.db.refresh(rule)
|
||||
return rule
|
||||
|
||||
# ==================== READ ====================
|
||||
|
||||
def get_by_id(self, rule_id: str) -> Optional[AlertRuleDB]:
|
||||
"""Holt eine Regel nach ID."""
|
||||
return self.db.query(AlertRuleDB).filter(
|
||||
AlertRuleDB.id == rule_id
|
||||
).first()
|
||||
|
||||
def get_active(
|
||||
self,
|
||||
topic_id: str = None,
|
||||
user_id: str = None,
|
||||
) -> List[AlertRuleDB]:
|
||||
"""Holt alle aktiven Regeln, sortiert nach Priorität."""
|
||||
query = self.db.query(AlertRuleDB).filter(
|
||||
AlertRuleDB.is_active == True
|
||||
)
|
||||
|
||||
if topic_id:
|
||||
# Topic-spezifische und globale Regeln
|
||||
query = query.filter(
|
||||
or_(
|
||||
AlertRuleDB.topic_id == topic_id,
|
||||
AlertRuleDB.topic_id.is_(None)
|
||||
)
|
||||
)
|
||||
|
||||
if user_id:
|
||||
query = query.filter(
|
||||
or_(
|
||||
AlertRuleDB.user_id == user_id,
|
||||
AlertRuleDB.user_id.is_(None)
|
||||
)
|
||||
)
|
||||
|
||||
return query.order_by(AlertRuleDB.priority.desc()).all()
|
||||
|
||||
def get_all(
|
||||
self,
|
||||
user_id: str = None,
|
||||
topic_id: str = None,
|
||||
is_active: bool = None,
|
||||
) -> List[AlertRuleDB]:
|
||||
"""Holt alle Regeln mit optionalen Filtern."""
|
||||
query = self.db.query(AlertRuleDB)
|
||||
|
||||
if user_id:
|
||||
query = query.filter(AlertRuleDB.user_id == user_id)
|
||||
if topic_id:
|
||||
query = query.filter(AlertRuleDB.topic_id == topic_id)
|
||||
if is_active is not None:
|
||||
query = query.filter(AlertRuleDB.is_active == is_active)
|
||||
|
||||
return query.order_by(AlertRuleDB.priority.desc()).all()
|
||||
|
||||
# ==================== UPDATE ====================
|
||||
|
||||
def update(
|
||||
self,
|
||||
rule_id: str,
|
||||
name: str = None,
|
||||
description: str = None,
|
||||
conditions: List[Dict] = None,
|
||||
action_type: str = None,
|
||||
action_config: Dict = None,
|
||||
priority: int = None,
|
||||
is_active: bool = None,
|
||||
) -> Optional[AlertRuleDB]:
|
||||
"""Aktualisiert eine Regel."""
|
||||
rule = self.get_by_id(rule_id)
|
||||
if not rule:
|
||||
return None
|
||||
|
||||
if name is not None:
|
||||
rule.name = name
|
||||
if description is not None:
|
||||
rule.description = description
|
||||
if conditions is not None:
|
||||
rule.conditions = conditions
|
||||
if action_type is not None:
|
||||
rule.action_type = RuleActionEnum(action_type)
|
||||
if action_config is not None:
|
||||
rule.action_config = action_config
|
||||
if priority is not None:
|
||||
rule.priority = priority
|
||||
if is_active is not None:
|
||||
rule.is_active = is_active
|
||||
|
||||
self.db.commit()
|
||||
self.db.refresh(rule)
|
||||
return rule
|
||||
|
||||
def increment_match_count(self, rule_id: str) -> Optional[AlertRuleDB]:
|
||||
"""Erhöht den Match-Counter einer Regel."""
|
||||
rule = self.get_by_id(rule_id)
|
||||
if not rule:
|
||||
return None
|
||||
|
||||
rule.match_count += 1
|
||||
rule.last_matched_at = datetime.utcnow()
|
||||
|
||||
self.db.commit()
|
||||
self.db.refresh(rule)
|
||||
return rule
|
||||
|
||||
# ==================== DELETE ====================
|
||||
|
||||
def delete(self, rule_id: str) -> bool:
|
||||
"""Löscht eine Regel."""
|
||||
rule = self.get_by_id(rule_id)
|
||||
if not rule:
|
||||
return False
|
||||
|
||||
self.db.delete(rule)
|
||||
self.db.commit()
|
||||
return True
|
||||
|
||||
# ==================== CONVERSION ====================
|
||||
|
||||
def to_dict(self, rule: AlertRuleDB) -> Dict[str, Any]:
|
||||
"""Konvertiert DB-Model zu Dictionary."""
|
||||
return {
|
||||
"id": rule.id,
|
||||
"topic_id": rule.topic_id,
|
||||
"user_id": rule.user_id,
|
||||
"name": rule.name,
|
||||
"description": rule.description,
|
||||
"conditions": rule.conditions,
|
||||
"action_type": rule.action_type.value,
|
||||
"action_config": rule.action_config,
|
||||
"priority": rule.priority,
|
||||
"is_active": rule.is_active,
|
||||
"stats": {
|
||||
"match_count": rule.match_count,
|
||||
"last_matched_at": rule.last_matched_at.isoformat() if rule.last_matched_at else None,
|
||||
},
|
||||
"created_at": rule.created_at.isoformat() if rule.created_at else None,
|
||||
"updated_at": rule.updated_at.isoformat() if rule.updated_at else None,
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
"""
|
||||
Repository für Alert Topics (Feed-Quellen).
|
||||
"""
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Optional, List, Dict, Any
|
||||
from sqlalchemy.orm import Session as DBSession
|
||||
|
||||
from .models import AlertTopicDB, FeedTypeEnum
|
||||
|
||||
|
||||
class TopicRepository:
|
||||
"""Repository für Alert Topics (Feed-Quellen)."""
|
||||
|
||||
def __init__(self, db: DBSession):
|
||||
self.db = db
|
||||
|
||||
# ==================== CREATE ====================
|
||||
|
||||
def create(
|
||||
self,
|
||||
name: str,
|
||||
feed_url: str = None,
|
||||
feed_type: str = "rss",
|
||||
user_id: str = None,
|
||||
description: str = "",
|
||||
fetch_interval_minutes: int = 60,
|
||||
is_active: bool = True,
|
||||
) -> AlertTopicDB:
|
||||
"""Erstellt ein neues Topic."""
|
||||
topic = AlertTopicDB(
|
||||
id=str(uuid.uuid4()),
|
||||
user_id=user_id,
|
||||
name=name,
|
||||
description=description,
|
||||
feed_url=feed_url,
|
||||
feed_type=FeedTypeEnum(feed_type),
|
||||
fetch_interval_minutes=fetch_interval_minutes,
|
||||
is_active=is_active,
|
||||
)
|
||||
self.db.add(topic)
|
||||
self.db.commit()
|
||||
self.db.refresh(topic)
|
||||
return topic
|
||||
|
||||
# ==================== READ ====================
|
||||
|
||||
def get_by_id(self, topic_id: str) -> Optional[AlertTopicDB]:
|
||||
"""Holt ein Topic nach ID."""
|
||||
return self.db.query(AlertTopicDB).filter(
|
||||
AlertTopicDB.id == topic_id
|
||||
).first()
|
||||
|
||||
def get_all(
|
||||
self,
|
||||
user_id: str = None,
|
||||
is_active: bool = None,
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
) -> List[AlertTopicDB]:
|
||||
"""Holt alle Topics mit optionalen Filtern."""
|
||||
query = self.db.query(AlertTopicDB)
|
||||
|
||||
if user_id:
|
||||
query = query.filter(AlertTopicDB.user_id == user_id)
|
||||
if is_active is not None:
|
||||
query = query.filter(AlertTopicDB.is_active == is_active)
|
||||
|
||||
return query.order_by(
|
||||
AlertTopicDB.created_at.desc()
|
||||
).offset(offset).limit(limit).all()
|
||||
|
||||
def get_active_for_fetch(self) -> List[AlertTopicDB]:
|
||||
"""Holt alle aktiven Topics die gefetcht werden sollten."""
|
||||
return self.db.query(AlertTopicDB).filter(
|
||||
AlertTopicDB.is_active == True,
|
||||
AlertTopicDB.feed_url.isnot(None),
|
||||
).all()
|
||||
|
||||
# ==================== UPDATE ====================
|
||||
|
||||
def update(
|
||||
self,
|
||||
topic_id: str,
|
||||
name: str = None,
|
||||
description: str = None,
|
||||
feed_url: str = None,
|
||||
feed_type: str = None,
|
||||
is_active: bool = None,
|
||||
fetch_interval_minutes: int = None,
|
||||
) -> Optional[AlertTopicDB]:
|
||||
"""Aktualisiert ein Topic."""
|
||||
topic = self.get_by_id(topic_id)
|
||||
if not topic:
|
||||
return None
|
||||
|
||||
if name is not None:
|
||||
topic.name = name
|
||||
if description is not None:
|
||||
topic.description = description
|
||||
if feed_url is not None:
|
||||
topic.feed_url = feed_url
|
||||
if feed_type is not None:
|
||||
topic.feed_type = FeedTypeEnum(feed_type)
|
||||
if is_active is not None:
|
||||
topic.is_active = is_active
|
||||
if fetch_interval_minutes is not None:
|
||||
topic.fetch_interval_minutes = fetch_interval_minutes
|
||||
|
||||
self.db.commit()
|
||||
self.db.refresh(topic)
|
||||
return topic
|
||||
|
||||
def update_fetch_status(
|
||||
self,
|
||||
topic_id: str,
|
||||
last_fetch_error: str = None,
|
||||
items_fetched: int = 0,
|
||||
) -> Optional[AlertTopicDB]:
|
||||
"""Aktualisiert den Fetch-Status eines Topics."""
|
||||
topic = self.get_by_id(topic_id)
|
||||
if not topic:
|
||||
return None
|
||||
|
||||
topic.last_fetched_at = datetime.utcnow()
|
||||
topic.last_fetch_error = last_fetch_error
|
||||
topic.total_items_fetched += items_fetched
|
||||
|
||||
self.db.commit()
|
||||
self.db.refresh(topic)
|
||||
return topic
|
||||
|
||||
def increment_stats(
|
||||
self,
|
||||
topic_id: str,
|
||||
kept: int = 0,
|
||||
dropped: int = 0,
|
||||
) -> Optional[AlertTopicDB]:
|
||||
"""Erhöht die Statistiken eines Topics."""
|
||||
topic = self.get_by_id(topic_id)
|
||||
if not topic:
|
||||
return None
|
||||
|
||||
topic.items_kept += kept
|
||||
topic.items_dropped += dropped
|
||||
|
||||
self.db.commit()
|
||||
self.db.refresh(topic)
|
||||
return topic
|
||||
|
||||
# ==================== DELETE ====================
|
||||
|
||||
def delete(self, topic_id: str) -> bool:
|
||||
"""Löscht ein Topic (und alle zugehörigen Items via CASCADE)."""
|
||||
topic = self.get_by_id(topic_id)
|
||||
if not topic:
|
||||
return False
|
||||
|
||||
self.db.delete(topic)
|
||||
self.db.commit()
|
||||
return True
|
||||
|
||||
# ==================== CONVERSION ====================
|
||||
|
||||
def to_dict(self, topic: AlertTopicDB) -> Dict[str, Any]:
|
||||
"""Konvertiert DB-Model zu Dictionary."""
|
||||
return {
|
||||
"id": topic.id,
|
||||
"user_id": topic.user_id,
|
||||
"name": topic.name,
|
||||
"description": topic.description,
|
||||
"feed_url": topic.feed_url,
|
||||
"feed_type": topic.feed_type.value,
|
||||
"is_active": topic.is_active,
|
||||
"fetch_interval_minutes": topic.fetch_interval_minutes,
|
||||
"last_fetched_at": topic.last_fetched_at.isoformat() if topic.last_fetched_at else None,
|
||||
"last_fetch_error": topic.last_fetch_error,
|
||||
"stats": {
|
||||
"total_items_fetched": topic.total_items_fetched,
|
||||
"items_kept": topic.items_kept,
|
||||
"items_dropped": topic.items_dropped,
|
||||
},
|
||||
"created_at": topic.created_at.isoformat() if topic.created_at else None,
|
||||
"updated_at": topic.updated_at.isoformat() if topic.updated_at else None,
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
"""
|
||||
PDF Service - Data Models and Shared Types.
|
||||
|
||||
Dataclasses for letters, certificates, and corrections.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Dict, Optional, List
|
||||
|
||||
|
||||
@dataclass
|
||||
class SchoolInfo:
|
||||
"""Schulinformationen für Header."""
|
||||
name: str
|
||||
address: str
|
||||
phone: str
|
||||
email: str
|
||||
logo_path: Optional[str] = None
|
||||
website: Optional[str] = None
|
||||
principal: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class LetterData:
|
||||
"""Daten für Elternbrief-PDF."""
|
||||
recipient_name: str
|
||||
recipient_address: str
|
||||
student_name: str
|
||||
student_class: str
|
||||
subject: str
|
||||
content: str
|
||||
date: str
|
||||
teacher_name: str
|
||||
teacher_title: Optional[str] = None
|
||||
school_info: Optional[SchoolInfo] = None
|
||||
letter_type: str = "general" # general, halbjahr, fehlzeiten, elternabend, lob
|
||||
tone: str = "professional"
|
||||
legal_references: Optional[List[Dict[str, str]]] = None
|
||||
gfk_principles_applied: Optional[List[str]] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class CertificateData:
|
||||
"""Daten für Zeugnis-PDF."""
|
||||
student_name: str
|
||||
student_birthdate: str
|
||||
student_class: str
|
||||
school_year: str
|
||||
certificate_type: str # halbjahr, jahres, abschluss
|
||||
subjects: List[Dict[str, Any]] # [{name, grade, note}]
|
||||
attendance: Dict[str, int] # {days_absent, days_excused, days_unexcused}
|
||||
remarks: Optional[str] = None
|
||||
class_teacher: str = ""
|
||||
principal: str = ""
|
||||
school_info: Optional[SchoolInfo] = None
|
||||
issue_date: str = ""
|
||||
social_behavior: Optional[str] = None # A, B, C, D
|
||||
work_behavior: Optional[str] = None # A, B, C, D
|
||||
|
||||
|
||||
@dataclass
|
||||
class StudentInfo:
|
||||
"""Schülerinformationen für Korrektur-PDFs."""
|
||||
student_id: str
|
||||
name: str
|
||||
class_name: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class CorrectionData:
|
||||
"""Daten für Korrektur-Übersicht PDF."""
|
||||
student: StudentInfo
|
||||
exam_title: str
|
||||
subject: str
|
||||
date: str
|
||||
max_points: int
|
||||
achieved_points: int
|
||||
grade: str
|
||||
percentage: float
|
||||
corrections: List[Dict[str, Any]] # [{question, answer, points, feedback}]
|
||||
teacher_notes: str = ""
|
||||
ai_feedback: str = ""
|
||||
grade_distribution: Optional[Dict[str, int]] = None # {note: anzahl}
|
||||
class_average: Optional[float] = None
|
||||
@@ -7,101 +7,37 @@ Shared Service für:
|
||||
- Correction (Korrektur-Übersichten)
|
||||
|
||||
Verwendet WeasyPrint für PDF-Rendering und Jinja2 für Templates.
|
||||
|
||||
Split structure:
|
||||
- pdf_models.py: Data classes (SchoolInfo, LetterData, CertificateData, etc.)
|
||||
- pdf_templates.py: Inline HTML templates (letter, certificate, correction)
|
||||
- pdf_service.py: Core PDFService class + convenience functions (this file)
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional, List
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from jinja2 import Environment, FileSystemLoader, select_autoescape
|
||||
from weasyprint import HTML, CSS
|
||||
from weasyprint.text.fonts import FontConfiguration
|
||||
|
||||
from .pdf_models import (
|
||||
SchoolInfo, LetterData, CertificateData, StudentInfo, CorrectionData,
|
||||
)
|
||||
from .pdf_templates import (
|
||||
get_letter_template_html,
|
||||
get_certificate_template_html,
|
||||
get_correction_template_html,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Template directory
|
||||
TEMPLATES_DIR = Path(__file__).parent.parent / "templates" / "pdf"
|
||||
|
||||
|
||||
@dataclass
|
||||
class SchoolInfo:
|
||||
"""Schulinformationen für Header."""
|
||||
name: str
|
||||
address: str
|
||||
phone: str
|
||||
email: str
|
||||
logo_path: Optional[str] = None
|
||||
website: Optional[str] = None
|
||||
principal: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class LetterData:
|
||||
"""Daten für Elternbrief-PDF."""
|
||||
recipient_name: str
|
||||
recipient_address: str
|
||||
student_name: str
|
||||
student_class: str
|
||||
subject: str
|
||||
content: str
|
||||
date: str
|
||||
teacher_name: str
|
||||
teacher_title: Optional[str] = None
|
||||
school_info: Optional[SchoolInfo] = None
|
||||
letter_type: str = "general" # general, halbjahr, fehlzeiten, elternabend, lob
|
||||
tone: str = "professional"
|
||||
legal_references: Optional[List[Dict[str, str]]] = None
|
||||
gfk_principles_applied: Optional[List[str]] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class CertificateData:
|
||||
"""Daten für Zeugnis-PDF."""
|
||||
student_name: str
|
||||
student_birthdate: str
|
||||
student_class: str
|
||||
school_year: str
|
||||
certificate_type: str # halbjahr, jahres, abschluss
|
||||
subjects: List[Dict[str, Any]] # [{name, grade, note}]
|
||||
attendance: Dict[str, int] # {days_absent, days_excused, days_unexcused}
|
||||
remarks: Optional[str] = None
|
||||
class_teacher: str = ""
|
||||
principal: str = ""
|
||||
school_info: Optional[SchoolInfo] = None
|
||||
issue_date: str = ""
|
||||
social_behavior: Optional[str] = None # A, B, C, D
|
||||
work_behavior: Optional[str] = None # A, B, C, D
|
||||
|
||||
|
||||
@dataclass
|
||||
class StudentInfo:
|
||||
"""Schülerinformationen für Korrektur-PDFs."""
|
||||
student_id: str
|
||||
name: str
|
||||
class_name: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class CorrectionData:
|
||||
"""Daten für Korrektur-Übersicht PDF."""
|
||||
student: StudentInfo
|
||||
exam_title: str
|
||||
subject: str
|
||||
date: str
|
||||
max_points: int
|
||||
achieved_points: int
|
||||
grade: str
|
||||
percentage: float
|
||||
corrections: List[Dict[str, Any]] # [{question, answer, points, feedback}]
|
||||
teacher_notes: str = ""
|
||||
ai_feedback: str = ""
|
||||
grade_distribution: Optional[Dict[str, int]] = None # {note: anzahl}
|
||||
class_average: Optional[float] = None
|
||||
|
||||
|
||||
class PDFService:
|
||||
"""
|
||||
Zentrale PDF-Generierung für BreakPilot.
|
||||
@@ -113,18 +49,9 @@ class PDFService:
|
||||
"""
|
||||
|
||||
def __init__(self, templates_dir: Optional[Path] = None):
|
||||
"""
|
||||
Initialisiert den PDF-Service.
|
||||
|
||||
Args:
|
||||
templates_dir: Optionaler Pfad zu Templates (Standard: backend/templates/pdf)
|
||||
"""
|
||||
self.templates_dir = templates_dir or TEMPLATES_DIR
|
||||
|
||||
# Ensure templates directory exists
|
||||
self.templates_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Initialize Jinja2 environment
|
||||
self.jinja_env = Environment(
|
||||
loader=FileSystemLoader(str(self.templates_dir)),
|
||||
autoescape=select_autoescape(['html', 'xml']),
|
||||
@@ -132,13 +59,10 @@ class PDFService:
|
||||
lstrip_blocks=True
|
||||
)
|
||||
|
||||
# Add custom filters
|
||||
self.jinja_env.filters['date_format'] = self._date_format
|
||||
self.jinja_env.filters['grade_color'] = self._grade_color
|
||||
|
||||
# Font configuration for WeasyPrint
|
||||
self.font_config = FontConfiguration()
|
||||
|
||||
logger.info(f"PDFService initialized with templates from {self.templates_dir}")
|
||||
|
||||
@staticmethod
|
||||
@@ -156,16 +80,9 @@ class PDFService:
|
||||
def _grade_color(grade: str) -> str:
|
||||
"""Gibt Farbe basierend auf Note zurück."""
|
||||
grade_colors = {
|
||||
"1": "#27ae60", # Grün
|
||||
"2": "#2ecc71", # Hellgrün
|
||||
"3": "#f1c40f", # Gelb
|
||||
"4": "#e67e22", # Orange
|
||||
"5": "#e74c3c", # Rot
|
||||
"6": "#c0392b", # Dunkelrot
|
||||
"A": "#27ae60",
|
||||
"B": "#2ecc71",
|
||||
"C": "#f1c40f",
|
||||
"D": "#e74c3c",
|
||||
"1": "#27ae60", "2": "#2ecc71", "3": "#f1c40f",
|
||||
"4": "#e67e22", "5": "#e74c3c", "6": "#c0392b",
|
||||
"A": "#27ae60", "B": "#2ecc71", "C": "#f1c40f", "D": "#e74c3c",
|
||||
}
|
||||
return grade_colors.get(str(grade), "#333333")
|
||||
|
||||
@@ -181,291 +98,73 @@ class PDFService:
|
||||
color: #666;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'DejaVu Sans', 'Liberation Sans', Arial, sans-serif;
|
||||
font-size: 11pt;
|
||||
line-height: 1.5;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
h1, h2, h3 {
|
||||
font-weight: bold;
|
||||
margin-top: 1em;
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
|
||||
h1 { font-size: 16pt; }
|
||||
h2 { font-size: 14pt; }
|
||||
h3 { font-size: 12pt; }
|
||||
|
||||
.header {
|
||||
border-bottom: 2px solid #2c3e50;
|
||||
padding-bottom: 15px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.school-name {
|
||||
font-size: 18pt;
|
||||
font-weight: bold;
|
||||
color: #2c3e50;
|
||||
}
|
||||
|
||||
.school-info {
|
||||
font-size: 9pt;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.letter-date {
|
||||
text-align: right;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.recipient {
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.subject {
|
||||
font-weight: bold;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.content {
|
||||
text-align: justify;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.signature {
|
||||
margin-top: 40px;
|
||||
}
|
||||
|
||||
.legal-references {
|
||||
font-size: 9pt;
|
||||
color: #666;
|
||||
border-top: 1px solid #ddd;
|
||||
margin-top: 30px;
|
||||
padding-top: 10px;
|
||||
}
|
||||
|
||||
.gfk-badge {
|
||||
display: inline-block;
|
||||
background: #e8f5e9;
|
||||
color: #27ae60;
|
||||
font-size: 8pt;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
/* Zeugnis-Styles */
|
||||
.certificate-header {
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.certificate-title {
|
||||
font-size: 20pt;
|
||||
font-weight: bold;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.student-info {
|
||||
margin-bottom: 20px;
|
||||
padding: 15px;
|
||||
background: #f9f9f9;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.grades-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.grades-table th,
|
||||
.grades-table td {
|
||||
border: 1px solid #ddd;
|
||||
padding: 8px 12px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.grades-table th {
|
||||
background: #2c3e50;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.grades-table tr:nth-child(even) {
|
||||
background: #f9f9f9;
|
||||
}
|
||||
|
||||
.grade-cell {
|
||||
text-align: center;
|
||||
font-weight: bold;
|
||||
font-size: 12pt;
|
||||
}
|
||||
|
||||
.attendance-box {
|
||||
background: #fff3cd;
|
||||
padding: 15px;
|
||||
border-radius: 5px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.signatures-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-top: 50px;
|
||||
}
|
||||
|
||||
.signature-block {
|
||||
text-align: center;
|
||||
width: 40%;
|
||||
}
|
||||
|
||||
.signature-line {
|
||||
border-top: 1px solid #333;
|
||||
margin-top: 40px;
|
||||
padding-top: 5px;
|
||||
}
|
||||
|
||||
/* Korrektur-Styles */
|
||||
.exam-header {
|
||||
background: #2c3e50;
|
||||
color: white;
|
||||
padding: 15px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.result-box {
|
||||
background: #e8f5e9;
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
margin-bottom: 20px;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.result-grade {
|
||||
font-size: 36pt;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.result-points {
|
||||
font-size: 14pt;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.corrections-list {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.correction-item {
|
||||
border: 1px solid #ddd;
|
||||
padding: 15px;
|
||||
margin-bottom: 10px;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.correction-question {
|
||||
font-weight: bold;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.correction-feedback {
|
||||
background: #fff8e1;
|
||||
padding: 10px;
|
||||
margin-top: 10px;
|
||||
border-left: 3px solid #ffc107;
|
||||
font-size: 10pt;
|
||||
}
|
||||
|
||||
.stats-table {
|
||||
width: 100%;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.stats-table td {
|
||||
padding: 5px 10px;
|
||||
font-size: 11pt; line-height: 1.5; color: #333;
|
||||
}
|
||||
h1, h2, h3 { font-weight: bold; margin-top: 1em; margin-bottom: 0.5em; }
|
||||
h1 { font-size: 16pt; } h2 { font-size: 14pt; } h3 { font-size: 12pt; }
|
||||
.header { border-bottom: 2px solid #2c3e50; padding-bottom: 15px; margin-bottom: 20px; }
|
||||
.school-name { font-size: 18pt; font-weight: bold; color: #2c3e50; }
|
||||
.school-info { font-size: 9pt; color: #666; }
|
||||
.letter-date { text-align: right; margin-bottom: 20px; }
|
||||
.recipient { margin-bottom: 30px; }
|
||||
.subject { font-weight: bold; margin-bottom: 20px; }
|
||||
.content { text-align: justify; margin-bottom: 30px; }
|
||||
.signature { margin-top: 40px; }
|
||||
.legal-references { font-size: 9pt; color: #666; border-top: 1px solid #ddd; margin-top: 30px; padding-top: 10px; }
|
||||
.gfk-badge { display: inline-block; background: #e8f5e9; color: #27ae60; font-size: 8pt; padding: 2px 8px; border-radius: 10px; margin-right: 5px; }
|
||||
.certificate-header { text-align: center; margin-bottom: 30px; }
|
||||
.certificate-title { font-size: 20pt; font-weight: bold; margin-bottom: 10px; }
|
||||
.student-info { margin-bottom: 20px; padding: 15px; background: #f9f9f9; border-radius: 5px; }
|
||||
.grades-table { width: 100%; border-collapse: collapse; margin-bottom: 20px; }
|
||||
.grades-table th, .grades-table td { border: 1px solid #ddd; padding: 8px 12px; text-align: left; }
|
||||
.grades-table th { background: #2c3e50; color: white; }
|
||||
.grades-table tr:nth-child(even) { background: #f9f9f9; }
|
||||
.grade-cell { text-align: center; font-weight: bold; font-size: 12pt; }
|
||||
.attendance-box { background: #fff3cd; padding: 15px; border-radius: 5px; margin-bottom: 20px; }
|
||||
.signatures-row { display: flex; justify-content: space-between; margin-top: 50px; }
|
||||
.signature-block { text-align: center; width: 40%; }
|
||||
.signature-line { border-top: 1px solid #333; margin-top: 40px; padding-top: 5px; }
|
||||
.exam-header { background: #2c3e50; color: white; padding: 15px; margin-bottom: 20px; }
|
||||
.result-box { background: #e8f5e9; padding: 20px; text-align: center; margin-bottom: 20px; border-radius: 5px; }
|
||||
.result-grade { font-size: 36pt; font-weight: bold; }
|
||||
.result-points { font-size: 14pt; color: #666; }
|
||||
.corrections-list { margin-bottom: 20px; }
|
||||
.correction-item { border: 1px solid #ddd; padding: 15px; margin-bottom: 10px; border-radius: 5px; }
|
||||
.correction-question { font-weight: bold; margin-bottom: 5px; }
|
||||
.correction-feedback { background: #fff8e1; padding: 10px; margin-top: 10px; border-left: 3px solid #ffc107; font-size: 10pt; }
|
||||
.stats-table { width: 100%; margin-top: 20px; }
|
||||
.stats-table td { padding: 5px 10px; }
|
||||
"""
|
||||
|
||||
def generate_letter_pdf(self, data: LetterData) -> bytes:
|
||||
"""
|
||||
Generiert PDF für Elternbrief.
|
||||
|
||||
Args:
|
||||
data: LetterData mit allen Briefinformationen
|
||||
|
||||
Returns:
|
||||
PDF als bytes
|
||||
"""
|
||||
"""Generiert PDF für Elternbrief."""
|
||||
logger.info(f"Generating letter PDF for student: {data.student_name}")
|
||||
|
||||
template = self._get_letter_template()
|
||||
html_content = template.render(
|
||||
data=data,
|
||||
generated_at=datetime.now().strftime("%d.%m.%Y %H:%M")
|
||||
)
|
||||
|
||||
html_content = template.render(data=data, generated_at=datetime.now().strftime("%d.%m.%Y %H:%M"))
|
||||
css = CSS(string=self._get_base_css(), font_config=self.font_config)
|
||||
pdf_bytes = HTML(string=html_content).write_pdf(
|
||||
stylesheets=[css],
|
||||
font_config=self.font_config
|
||||
)
|
||||
|
||||
pdf_bytes = HTML(string=html_content).write_pdf(stylesheets=[css], font_config=self.font_config)
|
||||
logger.info(f"Letter PDF generated: {len(pdf_bytes)} bytes")
|
||||
return pdf_bytes
|
||||
|
||||
def generate_certificate_pdf(self, data: CertificateData) -> bytes:
|
||||
"""
|
||||
Generiert PDF für Schulzeugnis.
|
||||
|
||||
Args:
|
||||
data: CertificateData mit allen Zeugnisinformationen
|
||||
|
||||
Returns:
|
||||
PDF als bytes
|
||||
"""
|
||||
"""Generiert PDF für Schulzeugnis."""
|
||||
logger.info(f"Generating certificate PDF for: {data.student_name}")
|
||||
|
||||
template = self._get_certificate_template()
|
||||
html_content = template.render(
|
||||
data=data,
|
||||
generated_at=datetime.now().strftime("%d.%m.%Y %H:%M")
|
||||
)
|
||||
|
||||
html_content = template.render(data=data, generated_at=datetime.now().strftime("%d.%m.%Y %H:%M"))
|
||||
css = CSS(string=self._get_base_css(), font_config=self.font_config)
|
||||
pdf_bytes = HTML(string=html_content).write_pdf(
|
||||
stylesheets=[css],
|
||||
font_config=self.font_config
|
||||
)
|
||||
|
||||
pdf_bytes = HTML(string=html_content).write_pdf(stylesheets=[css], font_config=self.font_config)
|
||||
logger.info(f"Certificate PDF generated: {len(pdf_bytes)} bytes")
|
||||
return pdf_bytes
|
||||
|
||||
def generate_correction_pdf(self, data: CorrectionData) -> bytes:
|
||||
"""
|
||||
Generiert PDF für Korrektur-Übersicht.
|
||||
|
||||
Args:
|
||||
data: CorrectionData mit allen Korrekturinformationen
|
||||
|
||||
Returns:
|
||||
PDF als bytes
|
||||
"""
|
||||
"""Generiert PDF für Korrektur-Übersicht."""
|
||||
logger.info(f"Generating correction PDF for: {data.student.name}")
|
||||
|
||||
template = self._get_correction_template()
|
||||
html_content = template.render(
|
||||
data=data,
|
||||
generated_at=datetime.now().strftime("%d.%m.%Y %H:%M")
|
||||
)
|
||||
|
||||
html_content = template.render(data=data, generated_at=datetime.now().strftime("%d.%m.%Y %H:%M"))
|
||||
css = CSS(string=self._get_base_css(), font_config=self.font_config)
|
||||
pdf_bytes = HTML(string=html_content).write_pdf(
|
||||
stylesheets=[css],
|
||||
font_config=self.font_config
|
||||
)
|
||||
|
||||
pdf_bytes = HTML(string=html_content).write_pdf(stylesheets=[css], font_config=self.font_config)
|
||||
logger.info(f"Correction PDF generated: {len(pdf_bytes)} bytes")
|
||||
return pdf_bytes
|
||||
|
||||
@@ -474,321 +173,27 @@ class PDFService:
|
||||
template_path = self.templates_dir / "letter.html"
|
||||
if template_path.exists():
|
||||
return self.jinja_env.get_template("letter.html")
|
||||
|
||||
# Inline-Template als Fallback
|
||||
return self.jinja_env.from_string(self._get_letter_template_html())
|
||||
return self.jinja_env.from_string(get_letter_template_html())
|
||||
|
||||
def _get_certificate_template(self):
|
||||
"""Gibt Certificate-Template zurück."""
|
||||
template_path = self.templates_dir / "certificate.html"
|
||||
if template_path.exists():
|
||||
return self.jinja_env.get_template("certificate.html")
|
||||
|
||||
return self.jinja_env.from_string(self._get_certificate_template_html())
|
||||
return self.jinja_env.from_string(get_certificate_template_html())
|
||||
|
||||
def _get_correction_template(self):
|
||||
"""Gibt Correction-Template zurück."""
|
||||
template_path = self.templates_dir / "correction.html"
|
||||
if template_path.exists():
|
||||
return self.jinja_env.get_template("correction.html")
|
||||
|
||||
return self.jinja_env.from_string(self._get_correction_template_html())
|
||||
|
||||
@staticmethod
|
||||
def _get_letter_template_html() -> str:
|
||||
"""Inline HTML-Template für Elternbriefe."""
|
||||
return """
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>{{ data.subject }}</title>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
{% if data.school_info %}
|
||||
<div class="school-name">{{ data.school_info.name }}</div>
|
||||
<div class="school-info">
|
||||
{{ data.school_info.address }}<br>
|
||||
Tel: {{ data.school_info.phone }} | E-Mail: {{ data.school_info.email }}
|
||||
{% if data.school_info.website %} | {{ data.school_info.website }}{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="school-name">Schule</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="letter-date">
|
||||
{{ data.date }}
|
||||
</div>
|
||||
|
||||
<div class="recipient">
|
||||
{{ data.recipient_name }}<br>
|
||||
{{ data.recipient_address | replace('\\n', '<br>') | safe }}
|
||||
</div>
|
||||
|
||||
<div class="subject">
|
||||
Betreff: {{ data.subject }}
|
||||
</div>
|
||||
|
||||
<div class="meta-info" style="font-size: 10pt; color: #666; margin-bottom: 20px;">
|
||||
Schüler/in: {{ data.student_name }} | Klasse: {{ data.student_class }}
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
{{ data.content | replace('\\n', '<br>') | safe }}
|
||||
</div>
|
||||
|
||||
{% if data.gfk_principles_applied %}
|
||||
<div style="margin-bottom: 20px;">
|
||||
{% for principle in data.gfk_principles_applied %}
|
||||
<span class="gfk-badge">✓ {{ principle }}</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="signature">
|
||||
<p>Mit freundlichen Grüßen</p>
|
||||
<p style="margin-top: 30px;">
|
||||
{{ data.teacher_name }}
|
||||
{% if data.teacher_title %}<br><span style="font-size: 10pt;">{{ data.teacher_title }}</span>{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{% if data.legal_references %}
|
||||
<div class="legal-references">
|
||||
<strong>Rechtliche Grundlagen:</strong><br>
|
||||
{% for ref in data.legal_references %}
|
||||
• {{ ref.law }} {{ ref.paragraph }}: {{ ref.title }}<br>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div style="font-size: 8pt; color: #999; margin-top: 30px; text-align: center;">
|
||||
Erstellt mit BreakPilot | {{ generated_at }}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def _get_certificate_template_html() -> str:
|
||||
"""Inline HTML-Template für Zeugnisse."""
|
||||
return """
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Zeugnis - {{ data.student_name }}</title>
|
||||
</head>
|
||||
<body>
|
||||
<div class="certificate-header">
|
||||
{% if data.school_info %}
|
||||
<div class="school-name" style="font-size: 14pt;">{{ data.school_info.name }}</div>
|
||||
{% endif %}
|
||||
<div class="certificate-title">
|
||||
{% if data.certificate_type == 'halbjahr' %}
|
||||
Halbjahreszeugnis
|
||||
{% elif data.certificate_type == 'jahres' %}
|
||||
Jahreszeugnis
|
||||
{% else %}
|
||||
Abschlusszeugnis
|
||||
{% endif %}
|
||||
</div>
|
||||
<div>Schuljahr {{ data.school_year }}</div>
|
||||
</div>
|
||||
|
||||
<div class="student-info">
|
||||
<table style="width: 100%;">
|
||||
<tr>
|
||||
<td><strong>Name:</strong> {{ data.student_name }}</td>
|
||||
<td><strong>Geburtsdatum:</strong> {{ data.student_birthdate }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Klasse:</strong> {{ data.student_class }}</td>
|
||||
<td> </td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<h3>Leistungen</h3>
|
||||
<table class="grades-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 70%;">Fach</th>
|
||||
<th style="width: 15%;">Note</th>
|
||||
<th style="width: 15%;">Punkte</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for subject in data.subjects %}
|
||||
<tr>
|
||||
<td>{{ subject.name }}</td>
|
||||
<td class="grade-cell" style="color: {{ subject.grade | grade_color }};">
|
||||
{{ subject.grade }}
|
||||
</td>
|
||||
<td class="grade-cell">{{ subject.points | default('-') }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{% if data.social_behavior or data.work_behavior %}
|
||||
<h3>Verhalten</h3>
|
||||
<table class="grades-table" style="width: 50%;">
|
||||
{% if data.social_behavior %}
|
||||
<tr>
|
||||
<td>Sozialverhalten</td>
|
||||
<td class="grade-cell">{{ data.social_behavior }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% if data.work_behavior %}
|
||||
<tr>
|
||||
<td>Arbeitsverhalten</td>
|
||||
<td class="grade-cell">{{ data.work_behavior }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</table>
|
||||
{% endif %}
|
||||
|
||||
<div class="attendance-box">
|
||||
<strong>Versäumte Tage:</strong> {{ data.attendance.days_absent | default(0) }}
|
||||
(davon entschuldigt: {{ data.attendance.days_excused | default(0) }},
|
||||
unentschuldigt: {{ data.attendance.days_unexcused | default(0) }})
|
||||
</div>
|
||||
|
||||
{% if data.remarks %}
|
||||
<div style="margin-bottom: 20px;">
|
||||
<strong>Bemerkungen:</strong><br>
|
||||
{{ data.remarks }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div style="margin-top: 30px;">
|
||||
<strong>Ausgestellt am:</strong> {{ data.issue_date }}
|
||||
</div>
|
||||
|
||||
<div class="signatures-row">
|
||||
<div class="signature-block">
|
||||
<div class="signature-line">{{ data.class_teacher }}</div>
|
||||
<div style="font-size: 9pt;">Klassenlehrer/in</div>
|
||||
</div>
|
||||
<div class="signature-block">
|
||||
<div class="signature-line">{{ data.principal }}</div>
|
||||
<div style="font-size: 9pt;">Schulleiter/in</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="text-align: center; margin-top: 40px;">
|
||||
<div style="font-size: 9pt; color: #666;">Siegel der Schule</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def _get_correction_template_html() -> str:
|
||||
"""Inline HTML-Template für Korrektur-Übersichten."""
|
||||
return """
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Korrektur - {{ data.exam_title }}</title>
|
||||
</head>
|
||||
<body>
|
||||
<div class="exam-header">
|
||||
<h1 style="margin: 0; color: white;">{{ data.exam_title }}</h1>
|
||||
<div>{{ data.subject }} | {{ data.date }}</div>
|
||||
</div>
|
||||
|
||||
<div class="student-info">
|
||||
<strong>{{ data.student.name }}</strong> | Klasse {{ data.student.class_name }}
|
||||
</div>
|
||||
|
||||
<div class="result-box">
|
||||
<div class="result-grade" style="color: {{ data.grade | grade_color }};">
|
||||
Note: {{ data.grade }}
|
||||
</div>
|
||||
<div class="result-points">
|
||||
{{ data.achieved_points }} von {{ data.max_points }} Punkten
|
||||
({{ data.percentage | round(1) }}%)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3>Detaillierte Auswertung</h3>
|
||||
<div class="corrections-list">
|
||||
{% for item in data.corrections %}
|
||||
<div class="correction-item">
|
||||
<div class="correction-question">
|
||||
{{ item.question }}
|
||||
</div>
|
||||
{% if item.answer %}
|
||||
<div style="margin: 5px 0; font-style: italic; color: #555;">
|
||||
<strong>Antwort:</strong> {{ item.answer }}
|
||||
</div>
|
||||
{% endif %}
|
||||
<div>
|
||||
<strong>Punkte:</strong> {{ item.points }}
|
||||
</div>
|
||||
{% if item.feedback %}
|
||||
<div class="correction-feedback">
|
||||
{{ item.feedback }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{% if data.teacher_notes %}
|
||||
<div style="background: #e3f2fd; padding: 15px; border-radius: 5px; margin-bottom: 20px;">
|
||||
<strong>Lehrerkommentar:</strong><br>
|
||||
{{ data.teacher_notes }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if data.ai_feedback %}
|
||||
<div style="background: #f3e5f5; padding: 15px; border-radius: 5px; margin-bottom: 20px;">
|
||||
<strong>KI-Feedback:</strong><br>
|
||||
{{ data.ai_feedback }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if data.class_average or data.grade_distribution %}
|
||||
<h3>Klassenstatistik</h3>
|
||||
<table class="stats-table">
|
||||
{% if data.class_average %}
|
||||
<tr>
|
||||
<td><strong>Klassendurchschnitt:</strong></td>
|
||||
<td>{{ data.class_average }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% if data.grade_distribution %}
|
||||
<tr>
|
||||
<td><strong>Notenverteilung:</strong></td>
|
||||
<td>
|
||||
{% for grade, count in data.grade_distribution.items() %}
|
||||
Note {{ grade }}: {{ count }}x{% if not loop.last %}, {% endif %}
|
||||
{% endfor %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</table>
|
||||
{% endif %}
|
||||
|
||||
<div class="signature" style="margin-top: 40px;">
|
||||
<p style="font-size: 9pt; color: #666;">Datum: {{ data.date }}</p>
|
||||
</div>
|
||||
|
||||
<div style="font-size: 8pt; color: #999; margin-top: 30px; text-align: center;">
|
||||
Erstellt mit BreakPilot | {{ generated_at }}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
return self.jinja_env.from_string(get_correction_template_html())
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Convenience functions for direct usage
|
||||
# =============================================================================
|
||||
|
||||
_pdf_service: Optional[PDFService] = None
|
||||
|
||||
|
||||
@@ -801,18 +206,8 @@ def get_pdf_service() -> PDFService:
|
||||
|
||||
|
||||
def generate_letter_pdf(data: Dict[str, Any]) -> bytes:
|
||||
"""
|
||||
Convenience function zum Generieren eines Elternbrief-PDFs.
|
||||
|
||||
Args:
|
||||
data: Dict mit allen Briefdaten
|
||||
|
||||
Returns:
|
||||
PDF als bytes
|
||||
"""
|
||||
"""Convenience function zum Generieren eines Elternbrief-PDFs."""
|
||||
service = get_pdf_service()
|
||||
|
||||
# Convert dict to LetterData
|
||||
school_info = None
|
||||
if data.get("school_info"):
|
||||
school_info = SchoolInfo(**data["school_info"])
|
||||
@@ -833,22 +228,12 @@ def generate_letter_pdf(data: Dict[str, Any]) -> bytes:
|
||||
legal_references=data.get("legal_references"),
|
||||
gfk_principles_applied=data.get("gfk_principles_applied")
|
||||
)
|
||||
|
||||
return service.generate_letter_pdf(letter_data)
|
||||
|
||||
|
||||
def generate_certificate_pdf(data: Dict[str, Any]) -> bytes:
|
||||
"""
|
||||
Convenience function zum Generieren eines Zeugnis-PDFs.
|
||||
|
||||
Args:
|
||||
data: Dict mit allen Zeugnisdaten
|
||||
|
||||
Returns:
|
||||
PDF als bytes
|
||||
"""
|
||||
"""Convenience function zum Generieren eines Zeugnis-PDFs."""
|
||||
service = get_pdf_service()
|
||||
|
||||
school_info = None
|
||||
if data.get("school_info"):
|
||||
school_info = SchoolInfo(**data["school_info"])
|
||||
@@ -869,30 +254,19 @@ def generate_certificate_pdf(data: Dict[str, Any]) -> bytes:
|
||||
social_behavior=data.get("social_behavior"),
|
||||
work_behavior=data.get("work_behavior")
|
||||
)
|
||||
|
||||
return service.generate_certificate_pdf(cert_data)
|
||||
|
||||
|
||||
def generate_correction_pdf(data: Dict[str, Any]) -> bytes:
|
||||
"""
|
||||
Convenience function zum Generieren eines Korrektur-PDFs.
|
||||
|
||||
Args:
|
||||
data: Dict mit allen Korrekturdaten
|
||||
|
||||
Returns:
|
||||
PDF als bytes
|
||||
"""
|
||||
"""Convenience function zum Generieren eines Korrektur-PDFs."""
|
||||
service = get_pdf_service()
|
||||
|
||||
# Create StudentInfo from dict
|
||||
student = StudentInfo(
|
||||
student_id=data.get("student_id", "unknown"),
|
||||
name=data.get("student_name", data.get("name", "")),
|
||||
class_name=data.get("student_class", data.get("class_name", ""))
|
||||
)
|
||||
|
||||
# Calculate percentage if not provided
|
||||
max_points = data.get("max_points", data.get("total_points", 0))
|
||||
achieved_points = data.get("achieved_points", 0)
|
||||
percentage = data.get("percentage", (achieved_points / max_points * 100) if max_points > 0 else 0.0)
|
||||
@@ -912,5 +286,4 @@ def generate_correction_pdf(data: Dict[str, Any]) -> bytes:
|
||||
grade_distribution=data.get("grade_distribution"),
|
||||
class_average=data.get("class_average")
|
||||
)
|
||||
|
||||
return service.generate_correction_pdf(correction_data)
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
"""
|
||||
PDF Service - Inline HTML Templates.
|
||||
|
||||
Fallback templates when external template files don't exist.
|
||||
"""
|
||||
|
||||
|
||||
def get_letter_template_html() -> str:
|
||||
"""Inline HTML-Template für Elternbriefe."""
|
||||
return """
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>{{ data.subject }}</title>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
{% if data.school_info %}
|
||||
<div class="school-name">{{ data.school_info.name }}</div>
|
||||
<div class="school-info">
|
||||
{{ data.school_info.address }}<br>
|
||||
Tel: {{ data.school_info.phone }} | E-Mail: {{ data.school_info.email }}
|
||||
{% if data.school_info.website %} | {{ data.school_info.website }}{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="school-name">Schule</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="letter-date">
|
||||
{{ data.date }}
|
||||
</div>
|
||||
|
||||
<div class="recipient">
|
||||
{{ data.recipient_name }}<br>
|
||||
{{ data.recipient_address | replace('\\n', '<br>') | safe }}
|
||||
</div>
|
||||
|
||||
<div class="subject">
|
||||
Betreff: {{ data.subject }}
|
||||
</div>
|
||||
|
||||
<div class="meta-info" style="font-size: 10pt; color: #666; margin-bottom: 20px;">
|
||||
Schüler/in: {{ data.student_name }} | Klasse: {{ data.student_class }}
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
{{ data.content | replace('\\n', '<br>') | safe }}
|
||||
</div>
|
||||
|
||||
{% if data.gfk_principles_applied %}
|
||||
<div style="margin-bottom: 20px;">
|
||||
{% for principle in data.gfk_principles_applied %}
|
||||
<span class="gfk-badge">✓ {{ principle }}</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="signature">
|
||||
<p>Mit freundlichen Grüßen</p>
|
||||
<p style="margin-top: 30px;">
|
||||
{{ data.teacher_name }}
|
||||
{% if data.teacher_title %}<br><span style="font-size: 10pt;">{{ data.teacher_title }}</span>{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{% if data.legal_references %}
|
||||
<div class="legal-references">
|
||||
<strong>Rechtliche Grundlagen:</strong><br>
|
||||
{% for ref in data.legal_references %}
|
||||
• {{ ref.law }} {{ ref.paragraph }}: {{ ref.title }}<br>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div style="font-size: 8pt; color: #999; margin-top: 30px; text-align: center;">
|
||||
Erstellt mit BreakPilot | {{ generated_at }}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
|
||||
def get_certificate_template_html() -> str:
|
||||
"""Inline HTML-Template für Zeugnisse."""
|
||||
return """
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Zeugnis - {{ data.student_name }}</title>
|
||||
</head>
|
||||
<body>
|
||||
<div class="certificate-header">
|
||||
{% if data.school_info %}
|
||||
<div class="school-name" style="font-size: 14pt;">{{ data.school_info.name }}</div>
|
||||
{% endif %}
|
||||
<div class="certificate-title">
|
||||
{% if data.certificate_type == 'halbjahr' %}
|
||||
Halbjahreszeugnis
|
||||
{% elif data.certificate_type == 'jahres' %}
|
||||
Jahreszeugnis
|
||||
{% else %}
|
||||
Abschlusszeugnis
|
||||
{% endif %}
|
||||
</div>
|
||||
<div>Schuljahr {{ data.school_year }}</div>
|
||||
</div>
|
||||
|
||||
<div class="student-info">
|
||||
<table style="width: 100%;">
|
||||
<tr>
|
||||
<td><strong>Name:</strong> {{ data.student_name }}</td>
|
||||
<td><strong>Geburtsdatum:</strong> {{ data.student_birthdate }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Klasse:</strong> {{ data.student_class }}</td>
|
||||
<td> </td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<h3>Leistungen</h3>
|
||||
<table class="grades-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 70%;">Fach</th>
|
||||
<th style="width: 15%;">Note</th>
|
||||
<th style="width: 15%;">Punkte</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for subject in data.subjects %}
|
||||
<tr>
|
||||
<td>{{ subject.name }}</td>
|
||||
<td class="grade-cell" style="color: {{ subject.grade | grade_color }};">
|
||||
{{ subject.grade }}
|
||||
</td>
|
||||
<td class="grade-cell">{{ subject.points | default('-') }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{% if data.social_behavior or data.work_behavior %}
|
||||
<h3>Verhalten</h3>
|
||||
<table class="grades-table" style="width: 50%;">
|
||||
{% if data.social_behavior %}
|
||||
<tr>
|
||||
<td>Sozialverhalten</td>
|
||||
<td class="grade-cell">{{ data.social_behavior }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% if data.work_behavior %}
|
||||
<tr>
|
||||
<td>Arbeitsverhalten</td>
|
||||
<td class="grade-cell">{{ data.work_behavior }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</table>
|
||||
{% endif %}
|
||||
|
||||
<div class="attendance-box">
|
||||
<strong>Versäumte Tage:</strong> {{ data.attendance.days_absent | default(0) }}
|
||||
(davon entschuldigt: {{ data.attendance.days_excused | default(0) }},
|
||||
unentschuldigt: {{ data.attendance.days_unexcused | default(0) }})
|
||||
</div>
|
||||
|
||||
{% if data.remarks %}
|
||||
<div style="margin-bottom: 20px;">
|
||||
<strong>Bemerkungen:</strong><br>
|
||||
{{ data.remarks }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div style="margin-top: 30px;">
|
||||
<strong>Ausgestellt am:</strong> {{ data.issue_date }}
|
||||
</div>
|
||||
|
||||
<div class="signatures-row">
|
||||
<div class="signature-block">
|
||||
<div class="signature-line">{{ data.class_teacher }}</div>
|
||||
<div style="font-size: 9pt;">Klassenlehrer/in</div>
|
||||
</div>
|
||||
<div class="signature-block">
|
||||
<div class="signature-line">{{ data.principal }}</div>
|
||||
<div style="font-size: 9pt;">Schulleiter/in</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="text-align: center; margin-top: 40px;">
|
||||
<div style="font-size: 9pt; color: #666;">Siegel der Schule</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
|
||||
def get_correction_template_html() -> str:
|
||||
"""Inline HTML-Template für Korrektur-Übersichten."""
|
||||
return """
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Korrektur - {{ data.exam_title }}</title>
|
||||
</head>
|
||||
<body>
|
||||
<div class="exam-header">
|
||||
<h1 style="margin: 0; color: white;">{{ data.exam_title }}</h1>
|
||||
<div>{{ data.subject }} | {{ data.date }}</div>
|
||||
</div>
|
||||
|
||||
<div class="student-info">
|
||||
<strong>{{ data.student.name }}</strong> | Klasse {{ data.student.class_name }}
|
||||
</div>
|
||||
|
||||
<div class="result-box">
|
||||
<div class="result-grade" style="color: {{ data.grade | grade_color }};">
|
||||
Note: {{ data.grade }}
|
||||
</div>
|
||||
<div class="result-points">
|
||||
{{ data.achieved_points }} von {{ data.max_points }} Punkten
|
||||
({{ data.percentage | round(1) }}%)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3>Detaillierte Auswertung</h3>
|
||||
<div class="corrections-list">
|
||||
{% for item in data.corrections %}
|
||||
<div class="correction-item">
|
||||
<div class="correction-question">
|
||||
{{ item.question }}
|
||||
</div>
|
||||
{% if item.answer %}
|
||||
<div style="margin: 5px 0; font-style: italic; color: #555;">
|
||||
<strong>Antwort:</strong> {{ item.answer }}
|
||||
</div>
|
||||
{% endif %}
|
||||
<div>
|
||||
<strong>Punkte:</strong> {{ item.points }}
|
||||
</div>
|
||||
{% if item.feedback %}
|
||||
<div class="correction-feedback">
|
||||
{{ item.feedback }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{% if data.teacher_notes %}
|
||||
<div style="background: #e3f2fd; padding: 15px; border-radius: 5px; margin-bottom: 20px;">
|
||||
<strong>Lehrerkommentar:</strong><br>
|
||||
{{ data.teacher_notes }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if data.ai_feedback %}
|
||||
<div style="background: #f3e5f5; padding: 15px; border-radius: 5px; margin-bottom: 20px;">
|
||||
<strong>KI-Feedback:</strong><br>
|
||||
{{ data.ai_feedback }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if data.class_average or data.grade_distribution %}
|
||||
<h3>Klassenstatistik</h3>
|
||||
<table class="stats-table">
|
||||
{% if data.class_average %}
|
||||
<tr>
|
||||
<td><strong>Klassendurchschnitt:</strong></td>
|
||||
<td>{{ data.class_average }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% if data.grade_distribution %}
|
||||
<tr>
|
||||
<td><strong>Notenverteilung:</strong></td>
|
||||
<td>
|
||||
{% for grade, count in data.grade_distribution.items() %}
|
||||
Note {{ grade }}: {{ count }}x{% if not loop.last %}, {% endif %}
|
||||
{% endfor %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</table>
|
||||
{% endif %}
|
||||
|
||||
<div class="signature" style="margin-top: 40px;">
|
||||
<p style="font-size: 9pt; color: #666;">Datum: {{ data.date }}</p>
|
||||
</div>
|
||||
|
||||
<div style="font-size: 8pt; color: #999; margin-top: 30px; text-align: center;">
|
||||
Erstellt mit BreakPilot | {{ generated_at }}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
@@ -0,0 +1,267 @@
|
||||
# ==============================================
|
||||
# Teacher Dashboard - Analytics & Progress Routes
|
||||
# ==============================================
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query, Depends, Request
|
||||
from typing import List, Optional, Dict, Any
|
||||
from datetime import datetime, timedelta
|
||||
import logging
|
||||
|
||||
from teacher_dashboard_models import (
|
||||
UnitAssignmentStatus, TeacherControlSettings,
|
||||
UnitAssignment, StudentUnitProgress, ClassUnitProgress,
|
||||
MisconceptionReport, ClassAnalyticsSummary, ContentResource,
|
||||
get_current_teacher, get_teacher_database,
|
||||
get_classes_for_teacher, get_students_in_class,
|
||||
REQUIRE_AUTH,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(tags=["Teacher Dashboard"])
|
||||
|
||||
# Shared in-memory store reference (set from teacher_dashboard_api)
|
||||
_assignments_store: Dict[str, Dict[str, Any]] = {}
|
||||
|
||||
|
||||
def set_assignments_store(store: Dict[str, Dict[str, Any]]):
|
||||
"""Share the in-memory assignments store from the main module."""
|
||||
global _assignments_store
|
||||
_assignments_store = store
|
||||
|
||||
|
||||
# ==============================================
|
||||
# API Endpoints - Progress & Analytics
|
||||
# ==============================================
|
||||
|
||||
@router.get("/assignments/{assignment_id}/progress", response_model=ClassUnitProgress)
|
||||
async def get_assignment_progress(
|
||||
assignment_id: str,
|
||||
teacher: Dict[str, Any] = Depends(get_current_teacher)
|
||||
) -> ClassUnitProgress:
|
||||
"""Get detailed progress for an assignment."""
|
||||
db = await get_teacher_database()
|
||||
assignment = None
|
||||
if db:
|
||||
try:
|
||||
assignment = await db.get_assignment(assignment_id)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get assignment: {e}")
|
||||
if not assignment and assignment_id in _assignments_store:
|
||||
assignment = _assignments_store[assignment_id]
|
||||
if not assignment or assignment["teacher_id"] != teacher["user_id"]:
|
||||
raise HTTPException(status_code=404, detail="Assignment not found")
|
||||
|
||||
students = await get_students_in_class(assignment["class_id"])
|
||||
student_progress = []
|
||||
total_completion = 0.0
|
||||
total_precheck = 0.0
|
||||
total_postcheck = 0.0
|
||||
total_time = 0
|
||||
precheck_count = 0
|
||||
postcheck_count = 0
|
||||
started = 0
|
||||
completed = 0
|
||||
|
||||
for student in students:
|
||||
student_id = student.get("id", student.get("student_id"))
|
||||
progress = StudentUnitProgress(
|
||||
student_id=student_id,
|
||||
student_name=student.get("name", f"Student {student_id[:8]}"),
|
||||
status="not_started", completion_rate=0.0, stops_completed=0, total_stops=0,
|
||||
)
|
||||
if db:
|
||||
try:
|
||||
session_data = await db.get_student_unit_session(
|
||||
student_id=student_id, unit_id=assignment["unit_id"]
|
||||
)
|
||||
if session_data:
|
||||
progress.session_id = session_data.get("session_id")
|
||||
progress.status = "completed" if session_data.get("completed_at") else "in_progress"
|
||||
progress.completion_rate = session_data.get("completion_rate", 0.0)
|
||||
progress.precheck_score = session_data.get("precheck_score")
|
||||
progress.postcheck_score = session_data.get("postcheck_score")
|
||||
progress.time_spent_minutes = session_data.get("duration_seconds", 0) // 60
|
||||
progress.last_activity = session_data.get("updated_at")
|
||||
progress.stops_completed = session_data.get("stops_completed", 0)
|
||||
progress.total_stops = session_data.get("total_stops", 0)
|
||||
if progress.precheck_score is not None and progress.postcheck_score is not None:
|
||||
progress.learning_gain = progress.postcheck_score - progress.precheck_score
|
||||
total_completion += progress.completion_rate
|
||||
total_time += progress.time_spent_minutes
|
||||
if progress.precheck_score is not None:
|
||||
total_precheck += progress.precheck_score
|
||||
precheck_count += 1
|
||||
if progress.postcheck_score is not None:
|
||||
total_postcheck += progress.postcheck_score
|
||||
postcheck_count += 1
|
||||
if progress.status != "not_started":
|
||||
started += 1
|
||||
if progress.status == "completed":
|
||||
completed += 1
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get student progress: {e}")
|
||||
student_progress.append(progress)
|
||||
|
||||
total_students = len(students) or 1
|
||||
return ClassUnitProgress(
|
||||
assignment_id=assignment_id, unit_id=assignment["unit_id"],
|
||||
unit_title=f"Unit {assignment['unit_id']}", class_id=assignment["class_id"],
|
||||
class_name=f"Class {assignment['class_id'][:8]}", total_students=len(students),
|
||||
started_count=started, completed_count=completed,
|
||||
avg_completion_rate=total_completion / total_students,
|
||||
avg_precheck_score=total_precheck / precheck_count if precheck_count > 0 else None,
|
||||
avg_postcheck_score=total_postcheck / postcheck_count if postcheck_count > 0 else None,
|
||||
avg_learning_gain=(total_postcheck / postcheck_count - total_precheck / precheck_count)
|
||||
if precheck_count > 0 and postcheck_count > 0 else None,
|
||||
avg_time_minutes=total_time / started if started > 0 else 0,
|
||||
students=student_progress,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/classes/{class_id}/analytics", response_model=ClassAnalyticsSummary)
|
||||
async def get_class_analytics(
|
||||
class_id: str,
|
||||
teacher: Dict[str, Any] = Depends(get_current_teacher)
|
||||
) -> ClassAnalyticsSummary:
|
||||
"""Get summary analytics for a class."""
|
||||
db = await get_teacher_database()
|
||||
assignments = []
|
||||
if db:
|
||||
try:
|
||||
assignments = await db.list_assignments(teacher_id=teacher["user_id"], class_id=class_id)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to list assignments: {e}")
|
||||
if not assignments:
|
||||
assignments = [
|
||||
a for a in _assignments_store.values()
|
||||
if a["class_id"] == class_id and a["teacher_id"] == teacher["user_id"]
|
||||
]
|
||||
|
||||
total_units = len(assignments)
|
||||
completed_units = sum(1 for a in assignments if a.get("status") == "completed")
|
||||
active_units = sum(1 for a in assignments if a.get("status") == "active")
|
||||
|
||||
students = await get_students_in_class(class_id)
|
||||
student_scores = {}
|
||||
misconceptions = []
|
||||
if db:
|
||||
try:
|
||||
for student in students:
|
||||
student_id = student.get("id", student.get("student_id"))
|
||||
analytics = await db.get_student_analytics(student_id)
|
||||
if analytics:
|
||||
student_scores[student_id] = {
|
||||
"name": student.get("name", student_id[:8]),
|
||||
"avg_score": analytics.get("avg_postcheck_score", 0),
|
||||
"total_time": analytics.get("total_time_minutes", 0),
|
||||
}
|
||||
misconceptions_data = await db.get_class_misconceptions(class_id)
|
||||
for m in misconceptions_data:
|
||||
misconceptions.append(MisconceptionReport(
|
||||
concept_id=m["concept_id"], concept_label=m["concept_label"],
|
||||
misconception=m["misconception"], affected_students=m["affected_students"],
|
||||
frequency=m["frequency"], unit_id=m["unit_id"], stop_id=m["stop_id"],
|
||||
))
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to aggregate analytics: {e}")
|
||||
|
||||
sorted_students = sorted(student_scores.items(), key=lambda x: x[1]["avg_score"], reverse=True)
|
||||
top_performers = [s[1]["name"] for s in sorted_students[:3]]
|
||||
struggling_students = [s[1]["name"] for s in sorted_students[-3:] if s[1]["avg_score"] < 0.6]
|
||||
total_time = sum(s["total_time"] for s in student_scores.values())
|
||||
avg_scores = [s["avg_score"] for s in student_scores.values() if s["avg_score"] > 0]
|
||||
avg_completion = sum(avg_scores) / len(avg_scores) if avg_scores else 0
|
||||
|
||||
return ClassAnalyticsSummary(
|
||||
class_id=class_id, class_name=f"Klasse {class_id[:8]}",
|
||||
total_units_assigned=total_units, units_completed=completed_units,
|
||||
active_units=active_units, avg_completion_rate=avg_completion,
|
||||
avg_learning_gain=None, total_time_hours=total_time / 60,
|
||||
top_performers=top_performers, struggling_students=struggling_students,
|
||||
common_misconceptions=misconceptions[:5],
|
||||
)
|
||||
|
||||
|
||||
@router.get("/students/{student_id}/progress")
|
||||
async def get_student_progress(
|
||||
student_id: str,
|
||||
teacher: Dict[str, Any] = Depends(get_current_teacher)
|
||||
) -> Dict[str, Any]:
|
||||
"""Get detailed progress for a specific student."""
|
||||
db = await get_teacher_database()
|
||||
if db:
|
||||
try:
|
||||
progress = await db.get_student_full_progress(student_id)
|
||||
return progress
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get student progress: {e}")
|
||||
return {
|
||||
"student_id": student_id, "units_attempted": 0, "units_completed": 0,
|
||||
"avg_score": 0.0, "total_time_minutes": 0, "sessions": [],
|
||||
}
|
||||
|
||||
|
||||
# ==============================================
|
||||
# API Endpoints - Content Resources
|
||||
# ==============================================
|
||||
|
||||
@router.get("/assignments/{assignment_id}/resources", response_model=List[ContentResource])
|
||||
async def get_assignment_resources(
|
||||
assignment_id: str,
|
||||
teacher: Dict[str, Any] = Depends(get_current_teacher),
|
||||
request: Request = None
|
||||
) -> List[ContentResource]:
|
||||
"""Get generated content resources for an assignment."""
|
||||
db = await get_teacher_database()
|
||||
assignment = None
|
||||
if db:
|
||||
try:
|
||||
assignment = await db.get_assignment(assignment_id)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get assignment: {e}")
|
||||
if not assignment and assignment_id in _assignments_store:
|
||||
assignment = _assignments_store[assignment_id]
|
||||
if not assignment or assignment["teacher_id"] != teacher["user_id"]:
|
||||
raise HTTPException(status_code=404, detail="Assignment not found")
|
||||
|
||||
unit_id = assignment["unit_id"]
|
||||
base_url = str(request.base_url).rstrip("/") if request else "http://localhost:8000"
|
||||
return [
|
||||
ContentResource(resource_type="h5p", title=f"{unit_id} - H5P Aktivitaeten",
|
||||
url=f"{base_url}/api/units/content/{unit_id}/h5p",
|
||||
generated_at=datetime.utcnow(), unit_id=unit_id),
|
||||
ContentResource(resource_type="worksheet", title=f"{unit_id} - Arbeitsblatt (HTML)",
|
||||
url=f"{base_url}/api/units/content/{unit_id}/worksheet",
|
||||
generated_at=datetime.utcnow(), unit_id=unit_id),
|
||||
ContentResource(resource_type="pdf", title=f"{unit_id} - Arbeitsblatt (PDF)",
|
||||
url=f"{base_url}/api/units/content/{unit_id}/worksheet.pdf",
|
||||
generated_at=datetime.utcnow(), unit_id=unit_id),
|
||||
]
|
||||
|
||||
|
||||
@router.post("/assignments/{assignment_id}/regenerate-content")
|
||||
async def regenerate_content(
|
||||
assignment_id: str,
|
||||
resource_type: str = Query("all", description="h5p, pdf, or all"),
|
||||
teacher: Dict[str, Any] = Depends(get_current_teacher)
|
||||
) -> Dict[str, Any]:
|
||||
"""Trigger regeneration of content resources."""
|
||||
db = await get_teacher_database()
|
||||
assignment = None
|
||||
if db:
|
||||
try:
|
||||
assignment = await db.get_assignment(assignment_id)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get assignment: {e}")
|
||||
if not assignment and assignment_id in _assignments_store:
|
||||
assignment = _assignments_store[assignment_id]
|
||||
if not assignment or assignment["teacher_id"] != teacher["user_id"]:
|
||||
raise HTTPException(status_code=404, detail="Assignment not found")
|
||||
|
||||
logger.info(f"Content regeneration triggered for {assignment['unit_id']}: {resource_type}")
|
||||
return {
|
||||
"status": "queued", "assignment_id": assignment_id,
|
||||
"unit_id": assignment["unit_id"], "resource_type": resource_type,
|
||||
"message": "Content regeneration has been queued",
|
||||
}
|
||||
@@ -1,245 +1,42 @@
|
||||
# ==============================================
|
||||
# Breakpilot Drive - Teacher Dashboard API
|
||||
# ==============================================
|
||||
# Lehrer-Dashboard fuer Unit-Zuweisung und Analytics:
|
||||
# - Units zu Klassen zuweisen
|
||||
# - Schueler-Fortschritt einsehen
|
||||
# - Klassen-Analytics
|
||||
# - H5P und PDF Content verwalten
|
||||
# - Unit-Einstellungen pro Klasse
|
||||
# Lehrer-Dashboard fuer Unit-Zuweisung und Analytics.
|
||||
#
|
||||
# Split structure:
|
||||
# - teacher_dashboard_models.py: Models, Auth, DB/School helpers
|
||||
# - teacher_dashboard_analytics.py: Progress, analytics, content routes
|
||||
# - teacher_dashboard_api.py: Assignment CRUD, dashboard, units (this file)
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query, Depends, Request
|
||||
from pydantic import BaseModel, Field
|
||||
from fastapi import APIRouter, HTTPException, Query, Depends
|
||||
from typing import List, Optional, Dict, Any
|
||||
from datetime import datetime, timedelta
|
||||
from enum import Enum
|
||||
import uuid
|
||||
import os
|
||||
import logging
|
||||
import httpx
|
||||
|
||||
from teacher_dashboard_models import (
|
||||
UnitAssignmentStatus, TeacherControlSettings, AssignUnitRequest,
|
||||
UnitAssignment,
|
||||
get_current_teacher, get_teacher_database,
|
||||
get_classes_for_teacher,
|
||||
REQUIRE_AUTH,
|
||||
)
|
||||
from teacher_dashboard_analytics import (
|
||||
router as analytics_router,
|
||||
set_assignments_store,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Feature flags
|
||||
USE_DATABASE = os.getenv("GAME_USE_DATABASE", "true").lower() == "true"
|
||||
REQUIRE_AUTH = os.getenv("TEACHER_REQUIRE_AUTH", "true").lower() == "true"
|
||||
SCHOOL_SERVICE_URL = os.getenv("SCHOOL_SERVICE_URL", "http://school-service:8084")
|
||||
|
||||
router = APIRouter(prefix="/api/teacher", tags=["Teacher Dashboard"])
|
||||
|
||||
|
||||
# ==============================================
|
||||
# Pydantic Models
|
||||
# ==============================================
|
||||
|
||||
class UnitAssignmentStatus(str, Enum):
|
||||
"""Status of a unit assignment"""
|
||||
DRAFT = "draft"
|
||||
ACTIVE = "active"
|
||||
COMPLETED = "completed"
|
||||
ARCHIVED = "archived"
|
||||
|
||||
|
||||
class TeacherControlSettings(BaseModel):
|
||||
"""Unit settings that teachers can configure"""
|
||||
allow_skip: bool = True
|
||||
allow_replay: bool = True
|
||||
max_time_per_stop_sec: int = 90
|
||||
show_hints: bool = True
|
||||
require_precheck: bool = True
|
||||
require_postcheck: bool = True
|
||||
|
||||
|
||||
class AssignUnitRequest(BaseModel):
|
||||
"""Request to assign a unit to a class"""
|
||||
unit_id: str
|
||||
class_id: str
|
||||
due_date: Optional[datetime] = None
|
||||
settings: Optional[TeacherControlSettings] = None
|
||||
notes: Optional[str] = None
|
||||
|
||||
|
||||
class UnitAssignment(BaseModel):
|
||||
"""Unit assignment record"""
|
||||
assignment_id: str
|
||||
unit_id: str
|
||||
class_id: str
|
||||
teacher_id: str
|
||||
status: UnitAssignmentStatus
|
||||
settings: TeacherControlSettings
|
||||
due_date: Optional[datetime] = None
|
||||
notes: Optional[str] = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class StudentUnitProgress(BaseModel):
|
||||
"""Progress of a single student on a unit"""
|
||||
student_id: str
|
||||
student_name: str
|
||||
session_id: Optional[str] = None
|
||||
status: str # "not_started", "in_progress", "completed"
|
||||
completion_rate: float = 0.0
|
||||
precheck_score: Optional[float] = None
|
||||
postcheck_score: Optional[float] = None
|
||||
learning_gain: Optional[float] = None
|
||||
time_spent_minutes: int = 0
|
||||
last_activity: Optional[datetime] = None
|
||||
current_stop: Optional[str] = None
|
||||
stops_completed: int = 0
|
||||
total_stops: int = 0
|
||||
|
||||
|
||||
class ClassUnitProgress(BaseModel):
|
||||
"""Overall progress of a class on a unit"""
|
||||
assignment_id: str
|
||||
unit_id: str
|
||||
unit_title: str
|
||||
class_id: str
|
||||
class_name: str
|
||||
total_students: int
|
||||
started_count: int
|
||||
completed_count: int
|
||||
avg_completion_rate: float
|
||||
avg_precheck_score: Optional[float] = None
|
||||
avg_postcheck_score: Optional[float] = None
|
||||
avg_learning_gain: Optional[float] = None
|
||||
avg_time_minutes: float
|
||||
students: List[StudentUnitProgress]
|
||||
|
||||
|
||||
class MisconceptionReport(BaseModel):
|
||||
"""Report of detected misconceptions"""
|
||||
concept_id: str
|
||||
concept_label: str
|
||||
misconception: str
|
||||
affected_students: List[str]
|
||||
frequency: int
|
||||
unit_id: str
|
||||
stop_id: str
|
||||
|
||||
|
||||
class ClassAnalyticsSummary(BaseModel):
|
||||
"""Summary analytics for a class"""
|
||||
class_id: str
|
||||
class_name: str
|
||||
total_units_assigned: int
|
||||
units_completed: int
|
||||
active_units: int
|
||||
avg_completion_rate: float
|
||||
avg_learning_gain: Optional[float]
|
||||
total_time_hours: float
|
||||
top_performers: List[str]
|
||||
struggling_students: List[str]
|
||||
common_misconceptions: List[MisconceptionReport]
|
||||
|
||||
|
||||
class ContentResource(BaseModel):
|
||||
"""Generated content resource"""
|
||||
resource_type: str # "h5p", "pdf", "worksheet"
|
||||
title: str
|
||||
url: str
|
||||
generated_at: datetime
|
||||
unit_id: str
|
||||
|
||||
|
||||
# ==============================================
|
||||
# Auth Dependency
|
||||
# ==============================================
|
||||
|
||||
async def get_current_teacher(request: Request) -> Dict[str, Any]:
|
||||
"""Get current teacher from JWT token."""
|
||||
if not REQUIRE_AUTH:
|
||||
# Dev mode: return demo teacher
|
||||
return {
|
||||
"user_id": "e9484ad9-32ee-4f2b-a4e1-d182e02ccf20",
|
||||
"email": "demo@breakpilot.app",
|
||||
"role": "teacher",
|
||||
"name": "Demo Lehrer"
|
||||
}
|
||||
|
||||
auth_header = request.headers.get("Authorization", "")
|
||||
if not auth_header.startswith("Bearer "):
|
||||
raise HTTPException(status_code=401, detail="Missing authorization token")
|
||||
|
||||
try:
|
||||
import jwt
|
||||
token = auth_header[7:]
|
||||
secret = os.getenv("JWT_SECRET", "dev-secret-key")
|
||||
payload = jwt.decode(token, secret, algorithms=["HS256"])
|
||||
|
||||
if payload.get("role") not in ["teacher", "admin"]:
|
||||
raise HTTPException(status_code=403, detail="Teacher or admin role required")
|
||||
|
||||
return payload
|
||||
except jwt.ExpiredSignatureError:
|
||||
raise HTTPException(status_code=401, detail="Token expired")
|
||||
except jwt.InvalidTokenError:
|
||||
raise HTTPException(status_code=401, detail="Invalid token")
|
||||
|
||||
|
||||
# ==============================================
|
||||
# Database Integration
|
||||
# ==============================================
|
||||
|
||||
_teacher_db = None
|
||||
|
||||
async def get_teacher_database():
|
||||
"""Get teacher database instance with lazy initialization."""
|
||||
global _teacher_db
|
||||
if not USE_DATABASE:
|
||||
return None
|
||||
if _teacher_db is None:
|
||||
try:
|
||||
from unit.database import get_teacher_db
|
||||
_teacher_db = await get_teacher_db()
|
||||
logger.info("Teacher database initialized")
|
||||
except ImportError:
|
||||
logger.warning("Teacher database module not available")
|
||||
except Exception as e:
|
||||
logger.warning(f"Teacher database not available: {e}")
|
||||
return _teacher_db
|
||||
|
||||
|
||||
# ==============================================
|
||||
# School Service Integration
|
||||
# ==============================================
|
||||
|
||||
async def get_classes_for_teacher(teacher_id: str) -> List[Dict[str, Any]]:
|
||||
"""Get classes assigned to a teacher from school service."""
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
try:
|
||||
response = await client.get(
|
||||
f"{SCHOOL_SERVICE_URL}/api/v1/school/classes",
|
||||
headers={"X-Teacher-ID": teacher_id}
|
||||
)
|
||||
if response.status_code == 200:
|
||||
return response.json()
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get classes from school service: {e}")
|
||||
return []
|
||||
|
||||
|
||||
async def get_students_in_class(class_id: str) -> List[Dict[str, Any]]:
|
||||
"""Get students in a class from school service."""
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
try:
|
||||
response = await client.get(
|
||||
f"{SCHOOL_SERVICE_URL}/api/v1/school/classes/{class_id}/students"
|
||||
)
|
||||
if response.status_code == 200:
|
||||
return response.json()
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get students from school service: {e}")
|
||||
return []
|
||||
|
||||
|
||||
# ==============================================
|
||||
# In-Memory Storage (Fallback)
|
||||
# ==============================================
|
||||
|
||||
_assignments_store: Dict[str, Dict[str, Any]] = {}
|
||||
|
||||
# Share the store with the analytics module and include its routes
|
||||
set_assignments_store(_assignments_store)
|
||||
router.include_router(analytics_router)
|
||||
|
||||
|
||||
# ==============================================
|
||||
# API Endpoints - Unit Assignment
|
||||
@@ -250,28 +47,17 @@ async def assign_unit_to_class(
|
||||
request_data: AssignUnitRequest,
|
||||
teacher: Dict[str, Any] = Depends(get_current_teacher)
|
||||
) -> UnitAssignment:
|
||||
"""
|
||||
Assign a unit to a class.
|
||||
|
||||
Creates an assignment that allows students in the class to play the unit.
|
||||
Teacher can configure settings like skip, replay, time limits.
|
||||
"""
|
||||
"""Assign a unit to a class."""
|
||||
assignment_id = str(uuid.uuid4())
|
||||
now = datetime.utcnow()
|
||||
|
||||
settings = request_data.settings or TeacherControlSettings()
|
||||
|
||||
assignment = {
|
||||
"assignment_id": assignment_id,
|
||||
"unit_id": request_data.unit_id,
|
||||
"class_id": request_data.class_id,
|
||||
"teacher_id": teacher["user_id"],
|
||||
"status": UnitAssignmentStatus.ACTIVE,
|
||||
"settings": settings.model_dump(),
|
||||
"due_date": request_data.due_date,
|
||||
"notes": request_data.notes,
|
||||
"created_at": now,
|
||||
"updated_at": now,
|
||||
"assignment_id": assignment_id, "unit_id": request_data.unit_id,
|
||||
"class_id": request_data.class_id, "teacher_id": teacher["user_id"],
|
||||
"status": UnitAssignmentStatus.ACTIVE, "settings": settings.model_dump(),
|
||||
"due_date": request_data.due_date, "notes": request_data.notes,
|
||||
"created_at": now, "updated_at": now,
|
||||
}
|
||||
|
||||
db = await get_teacher_database()
|
||||
@@ -281,22 +67,15 @@ async def assign_unit_to_class(
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to store assignment: {e}")
|
||||
|
||||
# Fallback: store in memory
|
||||
_assignments_store[assignment_id] = assignment
|
||||
|
||||
logger.info(f"Unit {request_data.unit_id} assigned to class {request_data.class_id}")
|
||||
|
||||
return UnitAssignment(
|
||||
assignment_id=assignment_id,
|
||||
unit_id=request_data.unit_id,
|
||||
class_id=request_data.class_id,
|
||||
teacher_id=teacher["user_id"],
|
||||
status=UnitAssignmentStatus.ACTIVE,
|
||||
settings=settings,
|
||||
due_date=request_data.due_date,
|
||||
notes=request_data.notes,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
assignment_id=assignment_id, unit_id=request_data.unit_id,
|
||||
class_id=request_data.class_id, teacher_id=teacher["user_id"],
|
||||
status=UnitAssignmentStatus.ACTIVE, settings=settings,
|
||||
due_date=request_data.due_date, notes=request_data.notes,
|
||||
created_at=now, updated_at=now,
|
||||
)
|
||||
|
||||
|
||||
@@ -306,11 +85,7 @@ async def list_assignments(
|
||||
status: Optional[UnitAssignmentStatus] = Query(None, description="Filter by status"),
|
||||
teacher: Dict[str, Any] = Depends(get_current_teacher)
|
||||
) -> List[UnitAssignment]:
|
||||
"""
|
||||
List all unit assignments for the teacher.
|
||||
|
||||
Optionally filter by class or status.
|
||||
"""
|
||||
"""List all unit assignments for the teacher."""
|
||||
db = await get_teacher_database()
|
||||
assignments = []
|
||||
|
||||
@@ -325,7 +100,6 @@ async def list_assignments(
|
||||
logger.error(f"Failed to list assignments: {e}")
|
||||
|
||||
if not assignments:
|
||||
# Fallback: filter in-memory store
|
||||
for assignment in _assignments_store.values():
|
||||
if assignment["teacher_id"] != teacher["user_id"]:
|
||||
continue
|
||||
@@ -337,16 +111,11 @@ async def list_assignments(
|
||||
|
||||
return [
|
||||
UnitAssignment(
|
||||
assignment_id=a["assignment_id"],
|
||||
unit_id=a["unit_id"],
|
||||
class_id=a["class_id"],
|
||||
teacher_id=a["teacher_id"],
|
||||
status=a["status"],
|
||||
settings=TeacherControlSettings(**a["settings"]),
|
||||
due_date=a.get("due_date"),
|
||||
notes=a.get("notes"),
|
||||
created_at=a["created_at"],
|
||||
updated_at=a["updated_at"],
|
||||
assignment_id=a["assignment_id"], unit_id=a["unit_id"],
|
||||
class_id=a["class_id"], teacher_id=a["teacher_id"],
|
||||
status=a["status"], settings=TeacherControlSettings(**a["settings"]),
|
||||
due_date=a.get("due_date"), notes=a.get("notes"),
|
||||
created_at=a["created_at"], updated_at=a["updated_at"],
|
||||
)
|
||||
for a in assignments
|
||||
]
|
||||
@@ -359,41 +128,30 @@ async def get_assignment(
|
||||
) -> UnitAssignment:
|
||||
"""Get details of a specific assignment."""
|
||||
db = await get_teacher_database()
|
||||
|
||||
if db:
|
||||
try:
|
||||
assignment = await db.get_assignment(assignment_id)
|
||||
if assignment and assignment["teacher_id"] == teacher["user_id"]:
|
||||
return UnitAssignment(
|
||||
assignment_id=assignment["assignment_id"],
|
||||
unit_id=assignment["unit_id"],
|
||||
class_id=assignment["class_id"],
|
||||
teacher_id=assignment["teacher_id"],
|
||||
assignment_id=assignment["assignment_id"], unit_id=assignment["unit_id"],
|
||||
class_id=assignment["class_id"], teacher_id=assignment["teacher_id"],
|
||||
status=assignment["status"],
|
||||
settings=TeacherControlSettings(**assignment["settings"]),
|
||||
due_date=assignment.get("due_date"),
|
||||
notes=assignment.get("notes"),
|
||||
created_at=assignment["created_at"],
|
||||
updated_at=assignment["updated_at"],
|
||||
due_date=assignment.get("due_date"), notes=assignment.get("notes"),
|
||||
created_at=assignment["created_at"], updated_at=assignment["updated_at"],
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get assignment: {e}")
|
||||
|
||||
# Fallback
|
||||
if assignment_id in _assignments_store:
|
||||
a = _assignments_store[assignment_id]
|
||||
if a["teacher_id"] == teacher["user_id"]:
|
||||
return UnitAssignment(
|
||||
assignment_id=a["assignment_id"],
|
||||
unit_id=a["unit_id"],
|
||||
class_id=a["class_id"],
|
||||
teacher_id=a["teacher_id"],
|
||||
status=a["status"],
|
||||
settings=TeacherControlSettings(**a["settings"]),
|
||||
due_date=a.get("due_date"),
|
||||
notes=a.get("notes"),
|
||||
created_at=a["created_at"],
|
||||
updated_at=a["updated_at"],
|
||||
assignment_id=a["assignment_id"], unit_id=a["unit_id"],
|
||||
class_id=a["class_id"], teacher_id=a["teacher_id"],
|
||||
status=a["status"], settings=TeacherControlSettings(**a["settings"]),
|
||||
due_date=a.get("due_date"), notes=a.get("notes"),
|
||||
created_at=a["created_at"], updated_at=a["updated_at"],
|
||||
)
|
||||
|
||||
raise HTTPException(status_code=404, detail="Assignment not found")
|
||||
@@ -424,7 +182,6 @@ async def update_assignment(
|
||||
if not assignment or assignment["teacher_id"] != teacher["user_id"]:
|
||||
raise HTTPException(status_code=404, detail="Assignment not found")
|
||||
|
||||
# Update fields
|
||||
if settings:
|
||||
assignment["settings"] = settings.model_dump()
|
||||
if status:
|
||||
@@ -444,16 +201,11 @@ async def update_assignment(
|
||||
_assignments_store[assignment_id] = assignment
|
||||
|
||||
return UnitAssignment(
|
||||
assignment_id=assignment["assignment_id"],
|
||||
unit_id=assignment["unit_id"],
|
||||
class_id=assignment["class_id"],
|
||||
teacher_id=assignment["teacher_id"],
|
||||
status=assignment["status"],
|
||||
settings=TeacherControlSettings(**assignment["settings"]),
|
||||
due_date=assignment.get("due_date"),
|
||||
notes=assignment.get("notes"),
|
||||
created_at=assignment["created_at"],
|
||||
updated_at=assignment["updated_at"],
|
||||
assignment_id=assignment["assignment_id"], unit_id=assignment["unit_id"],
|
||||
class_id=assignment["class_id"], teacher_id=assignment["teacher_id"],
|
||||
status=assignment["status"], settings=TeacherControlSettings(**assignment["settings"]),
|
||||
due_date=assignment.get("due_date"), notes=assignment.get("notes"),
|
||||
created_at=assignment["created_at"], updated_at=assignment["updated_at"],
|
||||
)
|
||||
|
||||
|
||||
@@ -464,7 +216,6 @@ async def delete_assignment(
|
||||
) -> Dict[str, str]:
|
||||
"""Delete/archive an assignment."""
|
||||
db = await get_teacher_database()
|
||||
|
||||
if db:
|
||||
try:
|
||||
assignment = await db.get_assignment(assignment_id)
|
||||
@@ -485,339 +236,6 @@ async def delete_assignment(
|
||||
raise HTTPException(status_code=404, detail="Assignment not found")
|
||||
|
||||
|
||||
# ==============================================
|
||||
# API Endpoints - Progress & Analytics
|
||||
# ==============================================
|
||||
|
||||
@router.get("/assignments/{assignment_id}/progress", response_model=ClassUnitProgress)
|
||||
async def get_assignment_progress(
|
||||
assignment_id: str,
|
||||
teacher: Dict[str, Any] = Depends(get_current_teacher)
|
||||
) -> ClassUnitProgress:
|
||||
"""
|
||||
Get detailed progress for an assignment.
|
||||
|
||||
Shows each student's status, scores, and time spent.
|
||||
"""
|
||||
db = await get_teacher_database()
|
||||
assignment = None
|
||||
|
||||
if db:
|
||||
try:
|
||||
assignment = await db.get_assignment(assignment_id)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get assignment: {e}")
|
||||
|
||||
if not assignment and assignment_id in _assignments_store:
|
||||
assignment = _assignments_store[assignment_id]
|
||||
|
||||
if not assignment or assignment["teacher_id"] != teacher["user_id"]:
|
||||
raise HTTPException(status_code=404, detail="Assignment not found")
|
||||
|
||||
# Get students in class
|
||||
students = await get_students_in_class(assignment["class_id"])
|
||||
|
||||
# Get progress for each student
|
||||
student_progress = []
|
||||
total_completion = 0.0
|
||||
total_precheck = 0.0
|
||||
total_postcheck = 0.0
|
||||
total_time = 0
|
||||
precheck_count = 0
|
||||
postcheck_count = 0
|
||||
started = 0
|
||||
completed = 0
|
||||
|
||||
for student in students:
|
||||
student_id = student.get("id", student.get("student_id"))
|
||||
progress = StudentUnitProgress(
|
||||
student_id=student_id,
|
||||
student_name=student.get("name", f"Student {student_id[:8]}"),
|
||||
status="not_started",
|
||||
completion_rate=0.0,
|
||||
stops_completed=0,
|
||||
total_stops=0,
|
||||
)
|
||||
|
||||
if db:
|
||||
try:
|
||||
session_data = await db.get_student_unit_session(
|
||||
student_id=student_id,
|
||||
unit_id=assignment["unit_id"]
|
||||
)
|
||||
if session_data:
|
||||
progress.session_id = session_data.get("session_id")
|
||||
progress.status = "completed" if session_data.get("completed_at") else "in_progress"
|
||||
progress.completion_rate = session_data.get("completion_rate", 0.0)
|
||||
progress.precheck_score = session_data.get("precheck_score")
|
||||
progress.postcheck_score = session_data.get("postcheck_score")
|
||||
progress.time_spent_minutes = session_data.get("duration_seconds", 0) // 60
|
||||
progress.last_activity = session_data.get("updated_at")
|
||||
progress.stops_completed = session_data.get("stops_completed", 0)
|
||||
progress.total_stops = session_data.get("total_stops", 0)
|
||||
|
||||
if progress.precheck_score is not None and progress.postcheck_score is not None:
|
||||
progress.learning_gain = progress.postcheck_score - progress.precheck_score
|
||||
|
||||
# Aggregate stats
|
||||
total_completion += progress.completion_rate
|
||||
total_time += progress.time_spent_minutes
|
||||
if progress.precheck_score is not None:
|
||||
total_precheck += progress.precheck_score
|
||||
precheck_count += 1
|
||||
if progress.postcheck_score is not None:
|
||||
total_postcheck += progress.postcheck_score
|
||||
postcheck_count += 1
|
||||
if progress.status != "not_started":
|
||||
started += 1
|
||||
if progress.status == "completed":
|
||||
completed += 1
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get student progress: {e}")
|
||||
|
||||
student_progress.append(progress)
|
||||
|
||||
total_students = len(students) or 1 # Avoid division by zero
|
||||
|
||||
return ClassUnitProgress(
|
||||
assignment_id=assignment_id,
|
||||
unit_id=assignment["unit_id"],
|
||||
unit_title=f"Unit {assignment['unit_id']}", # Would load from unit definition
|
||||
class_id=assignment["class_id"],
|
||||
class_name=f"Class {assignment['class_id'][:8]}", # Would load from school service
|
||||
total_students=len(students),
|
||||
started_count=started,
|
||||
completed_count=completed,
|
||||
avg_completion_rate=total_completion / total_students,
|
||||
avg_precheck_score=total_precheck / precheck_count if precheck_count > 0 else None,
|
||||
avg_postcheck_score=total_postcheck / postcheck_count if postcheck_count > 0 else None,
|
||||
avg_learning_gain=(total_postcheck / postcheck_count - total_precheck / precheck_count)
|
||||
if precheck_count > 0 and postcheck_count > 0 else None,
|
||||
avg_time_minutes=total_time / started if started > 0 else 0,
|
||||
students=student_progress,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/classes/{class_id}/analytics", response_model=ClassAnalyticsSummary)
|
||||
async def get_class_analytics(
|
||||
class_id: str,
|
||||
teacher: Dict[str, Any] = Depends(get_current_teacher)
|
||||
) -> ClassAnalyticsSummary:
|
||||
"""
|
||||
Get summary analytics for a class.
|
||||
|
||||
Includes all unit assignments, overall progress, and common misconceptions.
|
||||
"""
|
||||
db = await get_teacher_database()
|
||||
|
||||
# Get all assignments for this class
|
||||
assignments = []
|
||||
if db:
|
||||
try:
|
||||
assignments = await db.list_assignments(
|
||||
teacher_id=teacher["user_id"],
|
||||
class_id=class_id
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to list assignments: {e}")
|
||||
|
||||
if not assignments:
|
||||
assignments = [
|
||||
a for a in _assignments_store.values()
|
||||
if a["class_id"] == class_id and a["teacher_id"] == teacher["user_id"]
|
||||
]
|
||||
|
||||
total_units = len(assignments)
|
||||
completed_units = sum(1 for a in assignments if a.get("status") == "completed")
|
||||
active_units = sum(1 for a in assignments if a.get("status") == "active")
|
||||
|
||||
# Aggregate student performance
|
||||
students = await get_students_in_class(class_id)
|
||||
student_scores = {}
|
||||
misconceptions = []
|
||||
|
||||
if db:
|
||||
try:
|
||||
for student in students:
|
||||
student_id = student.get("id", student.get("student_id"))
|
||||
analytics = await db.get_student_analytics(student_id)
|
||||
if analytics:
|
||||
student_scores[student_id] = {
|
||||
"name": student.get("name", student_id[:8]),
|
||||
"avg_score": analytics.get("avg_postcheck_score", 0),
|
||||
"total_time": analytics.get("total_time_minutes", 0),
|
||||
}
|
||||
|
||||
# Get common misconceptions
|
||||
misconceptions_data = await db.get_class_misconceptions(class_id)
|
||||
for m in misconceptions_data:
|
||||
misconceptions.append(MisconceptionReport(
|
||||
concept_id=m["concept_id"],
|
||||
concept_label=m["concept_label"],
|
||||
misconception=m["misconception"],
|
||||
affected_students=m["affected_students"],
|
||||
frequency=m["frequency"],
|
||||
unit_id=m["unit_id"],
|
||||
stop_id=m["stop_id"],
|
||||
))
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to aggregate analytics: {e}")
|
||||
|
||||
# Identify top and struggling students
|
||||
sorted_students = sorted(
|
||||
student_scores.items(),
|
||||
key=lambda x: x[1]["avg_score"],
|
||||
reverse=True
|
||||
)
|
||||
top_performers = [s[1]["name"] for s in sorted_students[:3]]
|
||||
struggling_students = [s[1]["name"] for s in sorted_students[-3:] if s[1]["avg_score"] < 0.6]
|
||||
|
||||
total_time = sum(s["total_time"] for s in student_scores.values())
|
||||
avg_scores = [s["avg_score"] for s in student_scores.values() if s["avg_score"] > 0]
|
||||
avg_completion = sum(avg_scores) / len(avg_scores) if avg_scores else 0
|
||||
|
||||
return ClassAnalyticsSummary(
|
||||
class_id=class_id,
|
||||
class_name=f"Klasse {class_id[:8]}",
|
||||
total_units_assigned=total_units,
|
||||
units_completed=completed_units,
|
||||
active_units=active_units,
|
||||
avg_completion_rate=avg_completion,
|
||||
avg_learning_gain=None, # Would calculate from pre/post scores
|
||||
total_time_hours=total_time / 60,
|
||||
top_performers=top_performers,
|
||||
struggling_students=struggling_students,
|
||||
common_misconceptions=misconceptions[:5],
|
||||
)
|
||||
|
||||
|
||||
@router.get("/students/{student_id}/progress")
|
||||
async def get_student_progress(
|
||||
student_id: str,
|
||||
teacher: Dict[str, Any] = Depends(get_current_teacher)
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Get detailed progress for a specific student.
|
||||
|
||||
Shows all units attempted and their performance.
|
||||
"""
|
||||
db = await get_teacher_database()
|
||||
|
||||
if db:
|
||||
try:
|
||||
progress = await db.get_student_full_progress(student_id)
|
||||
return progress
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get student progress: {e}")
|
||||
|
||||
return {
|
||||
"student_id": student_id,
|
||||
"units_attempted": 0,
|
||||
"units_completed": 0,
|
||||
"avg_score": 0.0,
|
||||
"total_time_minutes": 0,
|
||||
"sessions": [],
|
||||
}
|
||||
|
||||
|
||||
# ==============================================
|
||||
# API Endpoints - Content Resources
|
||||
# ==============================================
|
||||
|
||||
@router.get("/assignments/{assignment_id}/resources", response_model=List[ContentResource])
|
||||
async def get_assignment_resources(
|
||||
assignment_id: str,
|
||||
teacher: Dict[str, Any] = Depends(get_current_teacher),
|
||||
request: Request = None
|
||||
) -> List[ContentResource]:
|
||||
"""
|
||||
Get generated content resources for an assignment.
|
||||
|
||||
Returns links to H5P activities and PDF worksheets.
|
||||
"""
|
||||
db = await get_teacher_database()
|
||||
assignment = None
|
||||
|
||||
if db:
|
||||
try:
|
||||
assignment = await db.get_assignment(assignment_id)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get assignment: {e}")
|
||||
|
||||
if not assignment and assignment_id in _assignments_store:
|
||||
assignment = _assignments_store[assignment_id]
|
||||
|
||||
if not assignment or assignment["teacher_id"] != teacher["user_id"]:
|
||||
raise HTTPException(status_code=404, detail="Assignment not found")
|
||||
|
||||
unit_id = assignment["unit_id"]
|
||||
base_url = str(request.base_url).rstrip("/") if request else "http://localhost:8000"
|
||||
|
||||
resources = [
|
||||
ContentResource(
|
||||
resource_type="h5p",
|
||||
title=f"{unit_id} - H5P Aktivitaeten",
|
||||
url=f"{base_url}/api/units/content/{unit_id}/h5p",
|
||||
generated_at=datetime.utcnow(),
|
||||
unit_id=unit_id,
|
||||
),
|
||||
ContentResource(
|
||||
resource_type="worksheet",
|
||||
title=f"{unit_id} - Arbeitsblatt (HTML)",
|
||||
url=f"{base_url}/api/units/content/{unit_id}/worksheet",
|
||||
generated_at=datetime.utcnow(),
|
||||
unit_id=unit_id,
|
||||
),
|
||||
ContentResource(
|
||||
resource_type="pdf",
|
||||
title=f"{unit_id} - Arbeitsblatt (PDF)",
|
||||
url=f"{base_url}/api/units/content/{unit_id}/worksheet.pdf",
|
||||
generated_at=datetime.utcnow(),
|
||||
unit_id=unit_id,
|
||||
),
|
||||
]
|
||||
|
||||
return resources
|
||||
|
||||
|
||||
@router.post("/assignments/{assignment_id}/regenerate-content")
|
||||
async def regenerate_content(
|
||||
assignment_id: str,
|
||||
resource_type: str = Query("all", description="h5p, pdf, or all"),
|
||||
teacher: Dict[str, Any] = Depends(get_current_teacher)
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Trigger regeneration of content resources.
|
||||
|
||||
Useful after updating unit definitions.
|
||||
"""
|
||||
db = await get_teacher_database()
|
||||
assignment = None
|
||||
|
||||
if db:
|
||||
try:
|
||||
assignment = await db.get_assignment(assignment_id)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get assignment: {e}")
|
||||
|
||||
if not assignment and assignment_id in _assignments_store:
|
||||
assignment = _assignments_store[assignment_id]
|
||||
|
||||
if not assignment or assignment["teacher_id"] != teacher["user_id"]:
|
||||
raise HTTPException(status_code=404, detail="Assignment not found")
|
||||
|
||||
# In production, this would trigger async job to regenerate content
|
||||
logger.info(f"Content regeneration triggered for {assignment['unit_id']}: {resource_type}")
|
||||
|
||||
return {
|
||||
"status": "queued",
|
||||
"assignment_id": assignment_id,
|
||||
"unit_id": assignment["unit_id"],
|
||||
"resource_type": resource_type,
|
||||
"message": "Content regeneration has been queued",
|
||||
}
|
||||
|
||||
|
||||
# ==============================================
|
||||
# API Endpoints - Available Units
|
||||
# ==============================================
|
||||
@@ -829,51 +247,30 @@ async def list_available_units(
|
||||
locale: str = Query("de-DE", description="Locale"),
|
||||
teacher: Dict[str, Any] = Depends(get_current_teacher)
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
List all available units for assignment.
|
||||
|
||||
Teachers see all published units matching their criteria.
|
||||
"""
|
||||
"""List all available units for assignment."""
|
||||
db = await get_teacher_database()
|
||||
|
||||
if db:
|
||||
try:
|
||||
units = await db.list_available_units(
|
||||
grade=grade,
|
||||
template=template,
|
||||
locale=locale
|
||||
)
|
||||
units = await db.list_available_units(grade=grade, template=template, locale=locale)
|
||||
return units
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to list units: {e}")
|
||||
|
||||
# Fallback: return demo units
|
||||
return [
|
||||
{
|
||||
"unit_id": "bio_eye_lightpath_v1",
|
||||
"title": "Auge - Lichtstrahl-Flug",
|
||||
"template": "flight_path",
|
||||
"grade_band": ["5", "6", "7"],
|
||||
"duration_minutes": 8,
|
||||
"difficulty": "base",
|
||||
"unit_id": "bio_eye_lightpath_v1", "title": "Auge - Lichtstrahl-Flug",
|
||||
"template": "flight_path", "grade_band": ["5", "6", "7"],
|
||||
"duration_minutes": 8, "difficulty": "base",
|
||||
"description": "Reise durch das Auge und folge dem Lichtstrahl",
|
||||
"learning_objectives": [
|
||||
"Verstehen des Lichtwegs durch das Auge",
|
||||
"Funktionen der Augenbestandteile benennen",
|
||||
],
|
||||
"learning_objectives": ["Verstehen des Lichtwegs durch das Auge",
|
||||
"Funktionen der Augenbestandteile benennen"],
|
||||
},
|
||||
{
|
||||
"unit_id": "math_pizza_equivalence_v1",
|
||||
"title": "Pizza-Boxenstopp - Brueche und Prozent",
|
||||
"template": "station_loop",
|
||||
"grade_band": ["5", "6"],
|
||||
"duration_minutes": 10,
|
||||
"difficulty": "base",
|
||||
"template": "station_loop", "grade_band": ["5", "6"],
|
||||
"duration_minutes": 10, "difficulty": "base",
|
||||
"description": "Entdecke die Verbindung zwischen Bruechen, Dezimalzahlen und Prozent",
|
||||
"learning_objectives": [
|
||||
"Brueche in Prozent umrechnen",
|
||||
"Aequivalenzen erkennen",
|
||||
],
|
||||
"learning_objectives": ["Brueche in Prozent umrechnen", "Aequivalenzen erkennen"],
|
||||
},
|
||||
]
|
||||
|
||||
@@ -886,54 +283,38 @@ async def list_available_units(
|
||||
async def get_dashboard(
|
||||
teacher: Dict[str, Any] = Depends(get_current_teacher)
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Get teacher dashboard overview.
|
||||
|
||||
Summary of all classes, active assignments, and alerts.
|
||||
"""
|
||||
"""Get teacher dashboard overview."""
|
||||
db = await get_teacher_database()
|
||||
|
||||
# Get teacher's classes
|
||||
classes = await get_classes_for_teacher(teacher["user_id"])
|
||||
|
||||
# Get all active assignments
|
||||
active_assignments = []
|
||||
if db:
|
||||
try:
|
||||
active_assignments = await db.list_assignments(
|
||||
teacher_id=teacher["user_id"],
|
||||
status="active"
|
||||
teacher_id=teacher["user_id"], status="active"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to list assignments: {e}")
|
||||
|
||||
if not active_assignments:
|
||||
active_assignments = [
|
||||
a for a in _assignments_store.values()
|
||||
if a["teacher_id"] == teacher["user_id"] and a.get("status") == "active"
|
||||
]
|
||||
|
||||
# Calculate alerts (students falling behind, due dates, etc.)
|
||||
alerts = []
|
||||
for assignment in active_assignments:
|
||||
if assignment.get("due_date") and assignment["due_date"] < datetime.utcnow() + timedelta(days=2):
|
||||
alerts.append({
|
||||
"type": "due_soon",
|
||||
"assignment_id": assignment["assignment_id"],
|
||||
"message": f"Zuweisung endet in weniger als 2 Tagen",
|
||||
"type": "due_soon", "assignment_id": assignment["assignment_id"],
|
||||
"message": "Zuweisung endet in weniger als 2 Tagen",
|
||||
})
|
||||
|
||||
return {
|
||||
"teacher": {
|
||||
"id": teacher["user_id"],
|
||||
"name": teacher.get("name", "Lehrer"),
|
||||
"email": teacher.get("email"),
|
||||
},
|
||||
"classes": len(classes),
|
||||
"active_assignments": len(active_assignments),
|
||||
"teacher": {"id": teacher["user_id"], "name": teacher.get("name", "Lehrer"),
|
||||
"email": teacher.get("email")},
|
||||
"classes": len(classes), "active_assignments": len(active_assignments),
|
||||
"total_students": sum(c.get("student_count", 0) for c in classes),
|
||||
"alerts": alerts,
|
||||
"recent_activity": [], # Would load recent session completions
|
||||
"alerts": alerts, "recent_activity": [],
|
||||
}
|
||||
|
||||
|
||||
@@ -942,10 +323,7 @@ async def health_check() -> Dict[str, Any]:
|
||||
"""Health check for teacher dashboard API."""
|
||||
db = await get_teacher_database()
|
||||
db_status = "connected" if db else "in-memory"
|
||||
|
||||
return {
|
||||
"status": "healthy",
|
||||
"service": "teacher-dashboard",
|
||||
"database": db_status,
|
||||
"auth_required": REQUIRE_AUTH,
|
||||
"status": "healthy", "service": "teacher-dashboard",
|
||||
"database": db_status, "auth_required": REQUIRE_AUTH,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
"""
|
||||
Teacher Dashboard - Pydantic Models, Auth Dependency, and Service Helpers.
|
||||
"""
|
||||
|
||||
import os
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import List, Optional, Dict, Any
|
||||
from enum import Enum
|
||||
|
||||
from fastapi import HTTPException, Request
|
||||
from pydantic import BaseModel
|
||||
import httpx
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Feature flags
|
||||
USE_DATABASE = os.getenv("GAME_USE_DATABASE", "true").lower() == "true"
|
||||
REQUIRE_AUTH = os.getenv("TEACHER_REQUIRE_AUTH", "true").lower() == "true"
|
||||
SCHOOL_SERVICE_URL = os.getenv("SCHOOL_SERVICE_URL", "http://school-service:8084")
|
||||
|
||||
|
||||
# ==============================================
|
||||
# Pydantic Models
|
||||
# ==============================================
|
||||
|
||||
class UnitAssignmentStatus(str, Enum):
|
||||
"""Status of a unit assignment"""
|
||||
DRAFT = "draft"
|
||||
ACTIVE = "active"
|
||||
COMPLETED = "completed"
|
||||
ARCHIVED = "archived"
|
||||
|
||||
|
||||
class TeacherControlSettings(BaseModel):
|
||||
"""Unit settings that teachers can configure"""
|
||||
allow_skip: bool = True
|
||||
allow_replay: bool = True
|
||||
max_time_per_stop_sec: int = 90
|
||||
show_hints: bool = True
|
||||
require_precheck: bool = True
|
||||
require_postcheck: bool = True
|
||||
|
||||
|
||||
class AssignUnitRequest(BaseModel):
|
||||
"""Request to assign a unit to a class"""
|
||||
unit_id: str
|
||||
class_id: str
|
||||
due_date: Optional[datetime] = None
|
||||
settings: Optional[TeacherControlSettings] = None
|
||||
notes: Optional[str] = None
|
||||
|
||||
|
||||
class UnitAssignment(BaseModel):
|
||||
"""Unit assignment record"""
|
||||
assignment_id: str
|
||||
unit_id: str
|
||||
class_id: str
|
||||
teacher_id: str
|
||||
status: UnitAssignmentStatus
|
||||
settings: TeacherControlSettings
|
||||
due_date: Optional[datetime] = None
|
||||
notes: Optional[str] = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class StudentUnitProgress(BaseModel):
|
||||
"""Progress of a single student on a unit"""
|
||||
student_id: str
|
||||
student_name: str
|
||||
session_id: Optional[str] = None
|
||||
status: str # "not_started", "in_progress", "completed"
|
||||
completion_rate: float = 0.0
|
||||
precheck_score: Optional[float] = None
|
||||
postcheck_score: Optional[float] = None
|
||||
learning_gain: Optional[float] = None
|
||||
time_spent_minutes: int = 0
|
||||
last_activity: Optional[datetime] = None
|
||||
current_stop: Optional[str] = None
|
||||
stops_completed: int = 0
|
||||
total_stops: int = 0
|
||||
|
||||
|
||||
class ClassUnitProgress(BaseModel):
|
||||
"""Overall progress of a class on a unit"""
|
||||
assignment_id: str
|
||||
unit_id: str
|
||||
unit_title: str
|
||||
class_id: str
|
||||
class_name: str
|
||||
total_students: int
|
||||
started_count: int
|
||||
completed_count: int
|
||||
avg_completion_rate: float
|
||||
avg_precheck_score: Optional[float] = None
|
||||
avg_postcheck_score: Optional[float] = None
|
||||
avg_learning_gain: Optional[float] = None
|
||||
avg_time_minutes: float
|
||||
students: List[StudentUnitProgress]
|
||||
|
||||
|
||||
class MisconceptionReport(BaseModel):
|
||||
"""Report of detected misconceptions"""
|
||||
concept_id: str
|
||||
concept_label: str
|
||||
misconception: str
|
||||
affected_students: List[str]
|
||||
frequency: int
|
||||
unit_id: str
|
||||
stop_id: str
|
||||
|
||||
|
||||
class ClassAnalyticsSummary(BaseModel):
|
||||
"""Summary analytics for a class"""
|
||||
class_id: str
|
||||
class_name: str
|
||||
total_units_assigned: int
|
||||
units_completed: int
|
||||
active_units: int
|
||||
avg_completion_rate: float
|
||||
avg_learning_gain: Optional[float]
|
||||
total_time_hours: float
|
||||
top_performers: List[str]
|
||||
struggling_students: List[str]
|
||||
common_misconceptions: List[MisconceptionReport]
|
||||
|
||||
|
||||
class ContentResource(BaseModel):
|
||||
"""Generated content resource"""
|
||||
resource_type: str # "h5p", "pdf", "worksheet"
|
||||
title: str
|
||||
url: str
|
||||
generated_at: datetime
|
||||
unit_id: str
|
||||
|
||||
|
||||
# ==============================================
|
||||
# Auth Dependency
|
||||
# ==============================================
|
||||
|
||||
async def get_current_teacher(request: Request) -> Dict[str, Any]:
|
||||
"""Get current teacher from JWT token."""
|
||||
if not REQUIRE_AUTH:
|
||||
return {
|
||||
"user_id": "e9484ad9-32ee-4f2b-a4e1-d182e02ccf20",
|
||||
"email": "demo@breakpilot.app",
|
||||
"role": "teacher",
|
||||
"name": "Demo Lehrer"
|
||||
}
|
||||
|
||||
auth_header = request.headers.get("Authorization", "")
|
||||
if not auth_header.startswith("Bearer "):
|
||||
raise HTTPException(status_code=401, detail="Missing authorization token")
|
||||
|
||||
try:
|
||||
import jwt
|
||||
token = auth_header[7:]
|
||||
secret = os.getenv("JWT_SECRET", "dev-secret-key")
|
||||
payload = jwt.decode(token, secret, algorithms=["HS256"])
|
||||
|
||||
if payload.get("role") not in ["teacher", "admin"]:
|
||||
raise HTTPException(status_code=403, detail="Teacher or admin role required")
|
||||
|
||||
return payload
|
||||
except jwt.ExpiredSignatureError:
|
||||
raise HTTPException(status_code=401, detail="Token expired")
|
||||
except jwt.InvalidTokenError:
|
||||
raise HTTPException(status_code=401, detail="Invalid token")
|
||||
|
||||
|
||||
# ==============================================
|
||||
# Database Integration
|
||||
# ==============================================
|
||||
|
||||
_teacher_db = None
|
||||
|
||||
|
||||
async def get_teacher_database():
|
||||
"""Get teacher database instance with lazy initialization."""
|
||||
global _teacher_db
|
||||
if not USE_DATABASE:
|
||||
return None
|
||||
if _teacher_db is None:
|
||||
try:
|
||||
from unit.database import get_teacher_db
|
||||
_teacher_db = await get_teacher_db()
|
||||
logger.info("Teacher database initialized")
|
||||
except ImportError:
|
||||
logger.warning("Teacher database module not available")
|
||||
except Exception as e:
|
||||
logger.warning(f"Teacher database not available: {e}")
|
||||
return _teacher_db
|
||||
|
||||
|
||||
# ==============================================
|
||||
# School Service Integration
|
||||
# ==============================================
|
||||
|
||||
async def get_classes_for_teacher(teacher_id: str) -> List[Dict[str, Any]]:
|
||||
"""Get classes assigned to a teacher from school service."""
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
try:
|
||||
response = await client.get(
|
||||
f"{SCHOOL_SERVICE_URL}/api/v1/school/classes",
|
||||
headers={"X-Teacher-ID": teacher_id}
|
||||
)
|
||||
if response.status_code == 200:
|
||||
return response.json()
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get classes from school service: {e}")
|
||||
return []
|
||||
|
||||
|
||||
async def get_students_in_class(class_id: str) -> List[Dict[str, Any]]:
|
||||
"""Get students in a class from school service."""
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
try:
|
||||
response = await client.get(
|
||||
f"{SCHOOL_SERVICE_URL}/api/v1/school/classes/{class_id}/students"
|
||||
)
|
||||
if response.status_code == 200:
|
||||
return response.json()
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get students from school service: {e}")
|
||||
return []
|
||||
@@ -0,0 +1,282 @@
|
||||
"""
|
||||
Legal Templates Chunking — text splitting, type inference, and chunk creation.
|
||||
|
||||
Extracted from legal_templates_ingestion.py to keep files under 500 LOC.
|
||||
|
||||
Lizenz: Apache 2.0
|
||||
"""
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
|
||||
from template_sources import SourceConfig
|
||||
from github_crawler import ExtractedDocument
|
||||
|
||||
|
||||
# Chunking configuration defaults (can be overridden by env vars in ingestion module)
|
||||
DEFAULT_CHUNK_SIZE = 1000
|
||||
DEFAULT_CHUNK_OVERLAP = 200
|
||||
|
||||
|
||||
@dataclass
|
||||
class TemplateChunk:
|
||||
"""A chunk of template text ready for indexing."""
|
||||
text: str
|
||||
chunk_index: int
|
||||
document_title: str
|
||||
template_type: str
|
||||
clause_category: Optional[str]
|
||||
language: str
|
||||
jurisdiction: str
|
||||
license_id: str
|
||||
license_name: str
|
||||
license_url: str
|
||||
attribution_required: bool
|
||||
share_alike: bool
|
||||
no_derivatives: bool
|
||||
commercial_use: bool
|
||||
source_name: str
|
||||
source_url: str
|
||||
source_repo: Optional[str]
|
||||
source_commit: Optional[str]
|
||||
source_file: str
|
||||
source_hash: str
|
||||
attribution_text: Optional[str]
|
||||
copyright_notice: Optional[str]
|
||||
is_complete_document: bool
|
||||
is_modular: bool
|
||||
requires_customization: bool
|
||||
placeholders: List[str]
|
||||
training_allowed: bool
|
||||
output_allowed: bool
|
||||
modification_allowed: bool
|
||||
distortion_prohibited: bool
|
||||
|
||||
|
||||
@dataclass
|
||||
class IngestionStatus:
|
||||
"""Status of a source ingestion."""
|
||||
source_name: str
|
||||
status: str # "pending", "running", "completed", "failed"
|
||||
documents_found: int = 0
|
||||
chunks_created: int = 0
|
||||
chunks_indexed: int = 0
|
||||
errors: List[str] = field(default_factory=list)
|
||||
started_at: Optional[datetime] = None
|
||||
completed_at: Optional[datetime] = None
|
||||
|
||||
|
||||
def split_sentences(text: str) -> List[str]:
|
||||
"""Split text into sentences with basic abbreviation handling."""
|
||||
# Protect common abbreviations
|
||||
abbreviations = ['bzw', 'ca', 'd.h', 'etc', 'ggf', 'inkl', 'u.a', 'usw', 'z.B', 'z.b', 'e.g', 'i.e', 'vs', 'no']
|
||||
protected = text
|
||||
for abbr in abbreviations:
|
||||
pattern = re.compile(r'\b' + re.escape(abbr) + r'\.', re.IGNORECASE)
|
||||
protected = pattern.sub(abbr.replace('.', '<DOT>') + '<ABBR>', protected)
|
||||
|
||||
# Protect decimal numbers
|
||||
protected = re.sub(r'(\d)\.(\d)', r'\1<DECIMAL>\2', protected)
|
||||
|
||||
# Split on sentence endings
|
||||
sentences = re.split(r'(?<=[.!?])\s+', protected)
|
||||
|
||||
# Restore protected characters
|
||||
result = []
|
||||
for s in sentences:
|
||||
s = s.replace('<DOT>', '.').replace('<ABBR>', '.').replace('<DECIMAL>', '.')
|
||||
s = s.strip()
|
||||
if s:
|
||||
result.append(s)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def chunk_text(
|
||||
text: str,
|
||||
chunk_size: int = DEFAULT_CHUNK_SIZE,
|
||||
overlap: int = DEFAULT_CHUNK_OVERLAP,
|
||||
) -> List[str]:
|
||||
"""
|
||||
Split text into overlapping chunks.
|
||||
Respects paragraph and sentence boundaries where possible.
|
||||
"""
|
||||
if not text:
|
||||
return []
|
||||
|
||||
if len(text) <= chunk_size:
|
||||
return [text.strip()]
|
||||
|
||||
# Split into paragraphs first
|
||||
paragraphs = text.split('\n\n')
|
||||
chunks = []
|
||||
current_chunk: List[str] = []
|
||||
current_length = 0
|
||||
|
||||
for para in paragraphs:
|
||||
para = para.strip()
|
||||
if not para:
|
||||
continue
|
||||
|
||||
para_length = len(para)
|
||||
|
||||
if para_length > chunk_size:
|
||||
# Large paragraph: split by sentences
|
||||
if current_chunk:
|
||||
chunks.append('\n\n'.join(current_chunk))
|
||||
current_chunk = []
|
||||
current_length = 0
|
||||
|
||||
# Split long paragraph by sentences
|
||||
sentences = split_sentences(para)
|
||||
for sentence in sentences:
|
||||
if current_length + len(sentence) + 1 > chunk_size:
|
||||
if current_chunk:
|
||||
chunks.append(' '.join(current_chunk))
|
||||
# Keep overlap
|
||||
overlap_count = max(1, len(current_chunk) // 3)
|
||||
current_chunk = current_chunk[-overlap_count:]
|
||||
current_length = sum(len(s) + 1 for s in current_chunk)
|
||||
current_chunk.append(sentence)
|
||||
current_length += len(sentence) + 1
|
||||
|
||||
elif current_length + para_length + 2 > chunk_size:
|
||||
# Paragraph would exceed chunk size
|
||||
if current_chunk:
|
||||
chunks.append('\n\n'.join(current_chunk))
|
||||
current_chunk = []
|
||||
current_length = 0
|
||||
current_chunk.append(para)
|
||||
current_length = para_length
|
||||
|
||||
else:
|
||||
current_chunk.append(para)
|
||||
current_length += para_length + 2
|
||||
|
||||
# Add final chunk
|
||||
if current_chunk:
|
||||
chunks.append('\n\n'.join(current_chunk))
|
||||
|
||||
return [c.strip() for c in chunks if c.strip()]
|
||||
|
||||
|
||||
def infer_template_type(doc: ExtractedDocument, source: SourceConfig) -> str:
|
||||
"""Infer the template type from document content and metadata."""
|
||||
text_lower = doc.text.lower()
|
||||
title_lower = doc.title.lower()
|
||||
|
||||
# Check known indicators
|
||||
type_indicators = {
|
||||
"privacy_policy": ["datenschutz", "privacy", "personal data", "personenbezogen"],
|
||||
"terms_of_service": ["nutzungsbedingungen", "terms of service", "terms of use", "agb"],
|
||||
"cookie_banner": ["cookie", "cookies", "tracking"],
|
||||
"impressum": ["impressum", "legal notice", "imprint"],
|
||||
"widerruf": ["widerruf", "cancellation", "withdrawal", "right to cancel"],
|
||||
"dpa": ["auftragsverarbeitung", "data processing agreement", "dpa"],
|
||||
"sla": ["service level", "availability", "uptime"],
|
||||
"nda": ["confidential", "non-disclosure", "geheimhaltung", "vertraulich"],
|
||||
"community_guidelines": ["community", "guidelines", "conduct", "verhaltens"],
|
||||
"acceptable_use": ["acceptable use", "acceptable usage", "nutzungsrichtlinien"],
|
||||
}
|
||||
|
||||
for template_type, indicators in type_indicators.items():
|
||||
for indicator in indicators:
|
||||
if indicator in text_lower or indicator in title_lower:
|
||||
return template_type
|
||||
|
||||
# Fall back to source's first template type
|
||||
if source.template_types:
|
||||
return source.template_types[0]
|
||||
|
||||
return "clause" # Generic fallback
|
||||
|
||||
|
||||
def infer_clause_category(text: str) -> Optional[str]:
|
||||
"""Infer the clause category from text content."""
|
||||
text_lower = text.lower()
|
||||
|
||||
categories = {
|
||||
"haftung": ["haftung", "liability", "haftungsausschluss", "limitation"],
|
||||
"datenschutz": ["datenschutz", "privacy", "personal data", "personenbezogen"],
|
||||
"widerruf": ["widerruf", "cancellation", "withdrawal"],
|
||||
"gewaehrleistung": ["gewaehrleistung", "warranty", "garantie"],
|
||||
"kuendigung": ["kuendigung", "termination", "beendigung"],
|
||||
"zahlung": ["zahlung", "payment", "preis", "price"],
|
||||
"gerichtsstand": ["gerichtsstand", "jurisdiction", "governing law"],
|
||||
"aenderungen": ["aenderung", "modification", "amendment"],
|
||||
"schlussbestimmungen": ["schlussbestimmung", "miscellaneous", "final provisions"],
|
||||
}
|
||||
|
||||
for category, indicators in categories.items():
|
||||
for indicator in indicators:
|
||||
if indicator in text_lower:
|
||||
return category
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def create_chunks(
|
||||
doc: ExtractedDocument,
|
||||
source: SourceConfig,
|
||||
chunk_size: int = DEFAULT_CHUNK_SIZE,
|
||||
chunk_overlap: int = DEFAULT_CHUNK_OVERLAP,
|
||||
) -> List[TemplateChunk]:
|
||||
"""Create template chunks from an extracted document."""
|
||||
license_info = source.license_info
|
||||
template_type = infer_template_type(doc, source)
|
||||
|
||||
# Chunk the text
|
||||
text_chunks = chunk_text(doc.text, chunk_size, chunk_overlap)
|
||||
|
||||
chunks = []
|
||||
for i, chunk_text_str in enumerate(text_chunks):
|
||||
# Determine if this is a complete document or a clause
|
||||
is_complete = len(text_chunks) == 1 and len(chunk_text_str) > 500
|
||||
is_modular = len(doc.sections) > 0 or '##' in doc.text
|
||||
requires_customization = len(doc.placeholders) > 0
|
||||
|
||||
# Generate attribution text
|
||||
attribution_text = None
|
||||
if license_info.attribution_required:
|
||||
attribution_text = license_info.get_attribution_text(
|
||||
source.name,
|
||||
doc.source_url or source.get_source_url()
|
||||
)
|
||||
|
||||
chunk = TemplateChunk(
|
||||
text=chunk_text_str,
|
||||
chunk_index=i,
|
||||
document_title=doc.title,
|
||||
template_type=template_type,
|
||||
clause_category=infer_clause_category(chunk_text_str),
|
||||
language=doc.language,
|
||||
jurisdiction=source.jurisdiction,
|
||||
license_id=license_info.id.value,
|
||||
license_name=license_info.name,
|
||||
license_url=license_info.url,
|
||||
attribution_required=license_info.attribution_required,
|
||||
share_alike=license_info.share_alike,
|
||||
no_derivatives=license_info.no_derivatives,
|
||||
commercial_use=license_info.commercial_use,
|
||||
source_name=source.name,
|
||||
source_url=doc.source_url or source.get_source_url(),
|
||||
source_repo=source.repo_url,
|
||||
source_commit=doc.source_commit,
|
||||
source_file=doc.file_path,
|
||||
source_hash=doc.source_hash,
|
||||
attribution_text=attribution_text,
|
||||
copyright_notice=None,
|
||||
is_complete_document=is_complete,
|
||||
is_modular=is_modular,
|
||||
requires_customization=requires_customization,
|
||||
placeholders=doc.placeholders,
|
||||
training_allowed=license_info.training_allowed,
|
||||
output_allowed=license_info.output_allowed,
|
||||
modification_allowed=license_info.modification_allowed,
|
||||
distortion_prohibited=license_info.distortion_prohibited,
|
||||
)
|
||||
chunks.append(chunk)
|
||||
|
||||
return chunks
|
||||
@@ -0,0 +1,165 @@
|
||||
"""
|
||||
Legal Templates CLI — command-line entry point for ingestion and search.
|
||||
|
||||
Extracted from legal_templates_ingestion.py to keep files under 500 LOC.
|
||||
|
||||
Usage:
|
||||
python legal_templates_cli.py --ingest-all
|
||||
python legal_templates_cli.py --ingest-source github-site-policy
|
||||
python legal_templates_cli.py --status
|
||||
python legal_templates_cli.py --search "Datenschutzerklaerung"
|
||||
|
||||
Lizenz: Apache 2.0
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
from template_sources import TEMPLATE_SOURCES, LicenseType
|
||||
from legal_templates_ingestion import LegalTemplatesIngestion
|
||||
|
||||
|
||||
async def main():
|
||||
"""CLI entry point."""
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(description="Legal Templates Ingestion")
|
||||
parser.add_argument(
|
||||
"--ingest-all",
|
||||
action="store_true",
|
||||
help="Ingest all enabled sources"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--ingest-source",
|
||||
type=str,
|
||||
metavar="NAME",
|
||||
help="Ingest a specific source by name"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--ingest-license",
|
||||
type=str,
|
||||
choices=["cc0", "mit", "cc_by_4", "public_domain"],
|
||||
help="Ingest all sources of a specific license type"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--max-priority",
|
||||
type=int,
|
||||
default=3,
|
||||
help="Maximum priority level to ingest (1=highest, 5=lowest)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--status",
|
||||
action="store_true",
|
||||
help="Show collection status"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--search",
|
||||
type=str,
|
||||
metavar="QUERY",
|
||||
help="Test search query"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--template-type",
|
||||
type=str,
|
||||
help="Filter search by template type"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--language",
|
||||
type=str,
|
||||
help="Filter search by language"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--reset",
|
||||
action="store_true",
|
||||
help="Reset (delete and recreate) the collection"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--delete-source",
|
||||
type=str,
|
||||
metavar="NAME",
|
||||
help="Delete all chunks from a source"
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
ingestion = LegalTemplatesIngestion()
|
||||
|
||||
try:
|
||||
if args.reset:
|
||||
ingestion.reset_collection()
|
||||
print("Collection reset successfully")
|
||||
|
||||
elif args.delete_source:
|
||||
count = ingestion.delete_source(args.delete_source)
|
||||
print(f"Deleted {count} chunks from {args.delete_source}")
|
||||
|
||||
elif args.status:
|
||||
status = ingestion.get_status()
|
||||
print(json.dumps(status, indent=2, default=str))
|
||||
|
||||
elif args.ingest_all:
|
||||
print(f"Ingesting all sources (max priority: {args.max_priority})...")
|
||||
results = await ingestion.ingest_all(max_priority=args.max_priority)
|
||||
print("\nResults:")
|
||||
for name, status in results.items():
|
||||
print(f" {name}: {status.chunks_indexed} chunks ({status.status})")
|
||||
if status.errors:
|
||||
for error in status.errors:
|
||||
print(f" ERROR: {error}")
|
||||
total = sum(s.chunks_indexed for s in results.values())
|
||||
print(f"\nTotal: {total} chunks indexed")
|
||||
|
||||
elif args.ingest_source:
|
||||
source = next(
|
||||
(s for s in TEMPLATE_SOURCES if s.name == args.ingest_source),
|
||||
None
|
||||
)
|
||||
if not source:
|
||||
print(f"Unknown source: {args.ingest_source}")
|
||||
print("Available sources:")
|
||||
for s in TEMPLATE_SOURCES:
|
||||
print(f" - {s.name}")
|
||||
return
|
||||
|
||||
print(f"Ingesting: {source.name}")
|
||||
status = await ingestion.ingest_source(source)
|
||||
print(f"\nResult: {status.chunks_indexed} chunks ({status.status})")
|
||||
if status.errors:
|
||||
for error in status.errors:
|
||||
print(f" ERROR: {error}")
|
||||
|
||||
elif args.ingest_license:
|
||||
license_type = LicenseType(args.ingest_license)
|
||||
print(f"Ingesting all {license_type.value} sources...")
|
||||
results = await ingestion.ingest_by_license(license_type)
|
||||
print("\nResults:")
|
||||
for name, status in results.items():
|
||||
print(f" {name}: {status.chunks_indexed} chunks ({status.status})")
|
||||
|
||||
elif args.search:
|
||||
print(f"Searching: {args.search}")
|
||||
results = await ingestion.search(
|
||||
args.search,
|
||||
template_type=args.template_type,
|
||||
language=args.language,
|
||||
)
|
||||
print(f"\nFound {len(results)} results:")
|
||||
for i, result in enumerate(results, 1):
|
||||
print(f"\n{i}. [{result['template_type']}] {result['document_title']}")
|
||||
print(f" Score: {result['score']:.3f}")
|
||||
print(f" License: {result['license_name']}")
|
||||
print(f" Source: {result['source_name']}")
|
||||
print(f" Language: {result['language']}")
|
||||
if result['attribution_required']:
|
||||
print(f" Attribution: {result['attribution_text']}")
|
||||
print(f" Text: {result['text'][:200]}...")
|
||||
|
||||
else:
|
||||
parser.print_help()
|
||||
|
||||
finally:
|
||||
await ingestion.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -8,18 +8,16 @@ proper attribution tracking.
|
||||
Collection: bp_legal_templates
|
||||
|
||||
Usage:
|
||||
python legal_templates_ingestion.py --ingest-all
|
||||
python legal_templates_ingestion.py --ingest-source github-site-policy
|
||||
python legal_templates_ingestion.py --status
|
||||
python legal_templates_ingestion.py --search "Datenschutzerklaerung"
|
||||
python legal_templates_cli.py --ingest-all
|
||||
python legal_templates_cli.py --ingest-source github-site-policy
|
||||
python legal_templates_cli.py --status
|
||||
python legal_templates_cli.py --search "Datenschutzerklaerung"
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional
|
||||
from urllib.parse import urlparse
|
||||
@@ -50,6 +48,17 @@ from github_crawler import (
|
||||
RepositoryDownloader,
|
||||
)
|
||||
|
||||
# Re-export from chunking module for backward compatibility
|
||||
from legal_templates_chunking import ( # noqa: F401
|
||||
IngestionStatus,
|
||||
TemplateChunk,
|
||||
chunk_text,
|
||||
create_chunks,
|
||||
infer_clause_category,
|
||||
infer_template_type,
|
||||
split_sentences,
|
||||
)
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -78,54 +87,6 @@ MAX_RETRIES = 3
|
||||
RETRY_DELAY = 3.0
|
||||
|
||||
|
||||
@dataclass
|
||||
class IngestionStatus:
|
||||
"""Status of a source ingestion."""
|
||||
source_name: str
|
||||
status: str # "pending", "running", "completed", "failed"
|
||||
documents_found: int = 0
|
||||
chunks_created: int = 0
|
||||
chunks_indexed: int = 0
|
||||
errors: List[str] = field(default_factory=list)
|
||||
started_at: Optional[datetime] = None
|
||||
completed_at: Optional[datetime] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class TemplateChunk:
|
||||
"""A chunk of template text ready for indexing."""
|
||||
text: str
|
||||
chunk_index: int
|
||||
document_title: str
|
||||
template_type: str
|
||||
clause_category: Optional[str]
|
||||
language: str
|
||||
jurisdiction: str
|
||||
license_id: str
|
||||
license_name: str
|
||||
license_url: str
|
||||
attribution_required: bool
|
||||
share_alike: bool
|
||||
no_derivatives: bool
|
||||
commercial_use: bool
|
||||
source_name: str
|
||||
source_url: str
|
||||
source_repo: Optional[str]
|
||||
source_commit: Optional[str]
|
||||
source_file: str
|
||||
source_hash: str
|
||||
attribution_text: Optional[str]
|
||||
copyright_notice: Optional[str]
|
||||
is_complete_document: bool
|
||||
is_modular: bool
|
||||
requires_customization: bool
|
||||
placeholders: List[str]
|
||||
training_allowed: bool
|
||||
output_allowed: bool
|
||||
modification_allowed: bool
|
||||
distortion_prohibited: bool
|
||||
|
||||
|
||||
class LegalTemplatesIngestion:
|
||||
"""Handles ingestion of legal templates into Qdrant."""
|
||||
|
||||
@@ -168,212 +129,6 @@ class LegalTemplatesIngestion:
|
||||
logger.error(f"Embedding generation failed: {e}")
|
||||
raise
|
||||
|
||||
def _chunk_text(self, text: str, chunk_size: int = CHUNK_SIZE, overlap: int = CHUNK_OVERLAP) -> List[str]:
|
||||
"""
|
||||
Split text into overlapping chunks.
|
||||
Respects paragraph and sentence boundaries where possible.
|
||||
"""
|
||||
if not text:
|
||||
return []
|
||||
|
||||
if len(text) <= chunk_size:
|
||||
return [text.strip()]
|
||||
|
||||
# Split into paragraphs first
|
||||
paragraphs = text.split('\n\n')
|
||||
chunks = []
|
||||
current_chunk = []
|
||||
current_length = 0
|
||||
|
||||
for para in paragraphs:
|
||||
para = para.strip()
|
||||
if not para:
|
||||
continue
|
||||
|
||||
para_length = len(para)
|
||||
|
||||
if para_length > chunk_size:
|
||||
# Large paragraph: split by sentences
|
||||
if current_chunk:
|
||||
chunks.append('\n\n'.join(current_chunk))
|
||||
current_chunk = []
|
||||
current_length = 0
|
||||
|
||||
# Split long paragraph by sentences
|
||||
sentences = self._split_sentences(para)
|
||||
for sentence in sentences:
|
||||
if current_length + len(sentence) + 1 > chunk_size:
|
||||
if current_chunk:
|
||||
chunks.append(' '.join(current_chunk))
|
||||
# Keep overlap
|
||||
overlap_count = max(1, len(current_chunk) // 3)
|
||||
current_chunk = current_chunk[-overlap_count:]
|
||||
current_length = sum(len(s) + 1 for s in current_chunk)
|
||||
current_chunk.append(sentence)
|
||||
current_length += len(sentence) + 1
|
||||
|
||||
elif current_length + para_length + 2 > chunk_size:
|
||||
# Paragraph would exceed chunk size
|
||||
if current_chunk:
|
||||
chunks.append('\n\n'.join(current_chunk))
|
||||
current_chunk = []
|
||||
current_length = 0
|
||||
current_chunk.append(para)
|
||||
current_length = para_length
|
||||
|
||||
else:
|
||||
current_chunk.append(para)
|
||||
current_length += para_length + 2
|
||||
|
||||
# Add final chunk
|
||||
if current_chunk:
|
||||
chunks.append('\n\n'.join(current_chunk))
|
||||
|
||||
return [c.strip() for c in chunks if c.strip()]
|
||||
|
||||
def _split_sentences(self, text: str) -> List[str]:
|
||||
"""Split text into sentences with basic abbreviation handling."""
|
||||
import re
|
||||
|
||||
# Protect common abbreviations
|
||||
abbreviations = ['bzw', 'ca', 'd.h', 'etc', 'ggf', 'inkl', 'u.a', 'usw', 'z.B', 'z.b', 'e.g', 'i.e', 'vs', 'no']
|
||||
protected = text
|
||||
for abbr in abbreviations:
|
||||
pattern = re.compile(r'\b' + re.escape(abbr) + r'\.', re.IGNORECASE)
|
||||
protected = pattern.sub(abbr.replace('.', '<DOT>') + '<ABBR>', protected)
|
||||
|
||||
# Protect decimal numbers
|
||||
protected = re.sub(r'(\d)\.(\d)', r'\1<DECIMAL>\2', protected)
|
||||
|
||||
# Split on sentence endings
|
||||
sentences = re.split(r'(?<=[.!?])\s+', protected)
|
||||
|
||||
# Restore protected characters
|
||||
result = []
|
||||
for s in sentences:
|
||||
s = s.replace('<DOT>', '.').replace('<ABBR>', '.').replace('<DECIMAL>', '.')
|
||||
s = s.strip()
|
||||
if s:
|
||||
result.append(s)
|
||||
|
||||
return result
|
||||
|
||||
def _infer_template_type(self, doc: ExtractedDocument, source: SourceConfig) -> str:
|
||||
"""Infer the template type from document content and metadata."""
|
||||
text_lower = doc.text.lower()
|
||||
title_lower = doc.title.lower()
|
||||
|
||||
# Check known indicators
|
||||
type_indicators = {
|
||||
"privacy_policy": ["datenschutz", "privacy", "personal data", "personenbezogen"],
|
||||
"terms_of_service": ["nutzungsbedingungen", "terms of service", "terms of use", "agb"],
|
||||
"cookie_banner": ["cookie", "cookies", "tracking"],
|
||||
"impressum": ["impressum", "legal notice", "imprint"],
|
||||
"widerruf": ["widerruf", "cancellation", "withdrawal", "right to cancel"],
|
||||
"dpa": ["auftragsverarbeitung", "data processing agreement", "dpa"],
|
||||
"sla": ["service level", "availability", "uptime"],
|
||||
"nda": ["confidential", "non-disclosure", "geheimhaltung", "vertraulich"],
|
||||
"community_guidelines": ["community", "guidelines", "conduct", "verhaltens"],
|
||||
"acceptable_use": ["acceptable use", "acceptable usage", "nutzungsrichtlinien"],
|
||||
}
|
||||
|
||||
for template_type, indicators in type_indicators.items():
|
||||
for indicator in indicators:
|
||||
if indicator in text_lower or indicator in title_lower:
|
||||
return template_type
|
||||
|
||||
# Fall back to source's first template type
|
||||
if source.template_types:
|
||||
return source.template_types[0]
|
||||
|
||||
return "clause" # Generic fallback
|
||||
|
||||
def _infer_clause_category(self, text: str) -> Optional[str]:
|
||||
"""Infer the clause category from text content."""
|
||||
text_lower = text.lower()
|
||||
|
||||
categories = {
|
||||
"haftung": ["haftung", "liability", "haftungsausschluss", "limitation"],
|
||||
"datenschutz": ["datenschutz", "privacy", "personal data", "personenbezogen"],
|
||||
"widerruf": ["widerruf", "cancellation", "withdrawal"],
|
||||
"gewaehrleistung": ["gewaehrleistung", "warranty", "garantie"],
|
||||
"kuendigung": ["kuendigung", "termination", "beendigung"],
|
||||
"zahlung": ["zahlung", "payment", "preis", "price"],
|
||||
"gerichtsstand": ["gerichtsstand", "jurisdiction", "governing law"],
|
||||
"aenderungen": ["aenderung", "modification", "amendment"],
|
||||
"schlussbestimmungen": ["schlussbestimmung", "miscellaneous", "final provisions"],
|
||||
}
|
||||
|
||||
for category, indicators in categories.items():
|
||||
for indicator in indicators:
|
||||
if indicator in text_lower:
|
||||
return category
|
||||
|
||||
return None
|
||||
|
||||
def _create_chunks(
|
||||
self,
|
||||
doc: ExtractedDocument,
|
||||
source: SourceConfig,
|
||||
) -> List[TemplateChunk]:
|
||||
"""Create template chunks from an extracted document."""
|
||||
license_info = source.license_info
|
||||
template_type = self._infer_template_type(doc, source)
|
||||
|
||||
# Chunk the text
|
||||
text_chunks = self._chunk_text(doc.text)
|
||||
|
||||
chunks = []
|
||||
for i, chunk_text in enumerate(text_chunks):
|
||||
# Determine if this is a complete document or a clause
|
||||
is_complete = len(text_chunks) == 1 and len(chunk_text) > 500
|
||||
is_modular = len(doc.sections) > 0 or '##' in doc.text
|
||||
requires_customization = len(doc.placeholders) > 0
|
||||
|
||||
# Generate attribution text
|
||||
attribution_text = None
|
||||
if license_info.attribution_required:
|
||||
attribution_text = license_info.get_attribution_text(
|
||||
source.name,
|
||||
doc.source_url or source.get_source_url()
|
||||
)
|
||||
|
||||
chunk = TemplateChunk(
|
||||
text=chunk_text,
|
||||
chunk_index=i,
|
||||
document_title=doc.title,
|
||||
template_type=template_type,
|
||||
clause_category=self._infer_clause_category(chunk_text),
|
||||
language=doc.language,
|
||||
jurisdiction=source.jurisdiction,
|
||||
license_id=license_info.id.value,
|
||||
license_name=license_info.name,
|
||||
license_url=license_info.url,
|
||||
attribution_required=license_info.attribution_required,
|
||||
share_alike=license_info.share_alike,
|
||||
no_derivatives=license_info.no_derivatives,
|
||||
commercial_use=license_info.commercial_use,
|
||||
source_name=source.name,
|
||||
source_url=doc.source_url or source.get_source_url(),
|
||||
source_repo=source.repo_url,
|
||||
source_commit=doc.source_commit,
|
||||
source_file=doc.file_path,
|
||||
source_hash=doc.source_hash,
|
||||
attribution_text=attribution_text,
|
||||
copyright_notice=None, # Could be extracted from doc if present
|
||||
is_complete_document=is_complete,
|
||||
is_modular=is_modular,
|
||||
requires_customization=requires_customization,
|
||||
placeholders=doc.placeholders,
|
||||
training_allowed=license_info.training_allowed,
|
||||
output_allowed=license_info.output_allowed,
|
||||
modification_allowed=license_info.modification_allowed,
|
||||
distortion_prohibited=license_info.distortion_prohibited,
|
||||
)
|
||||
chunks.append(chunk)
|
||||
|
||||
return chunks
|
||||
|
||||
async def ingest_source(self, source: SourceConfig) -> IngestionStatus:
|
||||
"""Ingest a single source into Qdrant."""
|
||||
status = IngestionStatus(
|
||||
@@ -405,7 +160,7 @@ class LegalTemplatesIngestion:
|
||||
# Create chunks from all documents
|
||||
all_chunks: List[TemplateChunk] = []
|
||||
for doc in documents:
|
||||
chunks = self._create_chunks(doc, source)
|
||||
chunks = create_chunks(doc, source, CHUNK_SIZE, CHUNK_OVERLAP)
|
||||
all_chunks.extend(chunks)
|
||||
status.chunks_created += len(chunks)
|
||||
|
||||
@@ -637,21 +392,7 @@ class LegalTemplatesIngestion:
|
||||
attribution_required: Optional[bool] = None,
|
||||
top_k: int = 10,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Search the legal templates collection.
|
||||
|
||||
Args:
|
||||
query: Search query text
|
||||
template_type: Filter by template type (e.g., "privacy_policy")
|
||||
license_types: Filter by license types (e.g., ["cc0", "mit"])
|
||||
language: Filter by language (e.g., "de")
|
||||
jurisdiction: Filter by jurisdiction (e.g., "DE")
|
||||
attribution_required: Filter by attribution requirement
|
||||
top_k: Number of results to return
|
||||
|
||||
Returns:
|
||||
List of search results with full metadata
|
||||
"""
|
||||
"""Search the legal templates collection."""
|
||||
# Generate query embedding
|
||||
embeddings = await self._generate_embeddings([query])
|
||||
query_vector = embeddings[0]
|
||||
@@ -661,45 +402,27 @@ class LegalTemplatesIngestion:
|
||||
|
||||
if template_type:
|
||||
must_conditions.append(
|
||||
FieldCondition(
|
||||
key="template_type",
|
||||
match=MatchValue(value=template_type),
|
||||
)
|
||||
FieldCondition(key="template_type", match=MatchValue(value=template_type))
|
||||
)
|
||||
|
||||
if language:
|
||||
must_conditions.append(
|
||||
FieldCondition(
|
||||
key="language",
|
||||
match=MatchValue(value=language),
|
||||
)
|
||||
FieldCondition(key="language", match=MatchValue(value=language))
|
||||
)
|
||||
|
||||
if jurisdiction:
|
||||
must_conditions.append(
|
||||
FieldCondition(
|
||||
key="jurisdiction",
|
||||
match=MatchValue(value=jurisdiction),
|
||||
)
|
||||
FieldCondition(key="jurisdiction", match=MatchValue(value=jurisdiction))
|
||||
)
|
||||
|
||||
if attribution_required is not None:
|
||||
must_conditions.append(
|
||||
FieldCondition(
|
||||
key="attribution_required",
|
||||
match=MatchValue(value=attribution_required),
|
||||
)
|
||||
FieldCondition(key="attribution_required", match=MatchValue(value=attribution_required))
|
||||
)
|
||||
|
||||
# License type filter (OR condition)
|
||||
should_conditions = []
|
||||
if license_types:
|
||||
for license_type in license_types:
|
||||
for lt in license_types:
|
||||
should_conditions.append(
|
||||
FieldCondition(
|
||||
key="license_id",
|
||||
match=MatchValue(value=license_type),
|
||||
)
|
||||
FieldCondition(key="license_id", match=MatchValue(value=lt))
|
||||
)
|
||||
|
||||
# Construct filter
|
||||
@@ -747,196 +470,31 @@ class LegalTemplatesIngestion:
|
||||
|
||||
def delete_source(self, source_name: str) -> int:
|
||||
"""Delete all chunks from a specific source."""
|
||||
# First count how many we're deleting
|
||||
count_result = self.qdrant.count(
|
||||
collection_name=LEGAL_TEMPLATES_COLLECTION,
|
||||
count_filter=Filter(
|
||||
must=[
|
||||
FieldCondition(
|
||||
key="source_name",
|
||||
match=MatchValue(value=source_name),
|
||||
)
|
||||
]
|
||||
must=[FieldCondition(key="source_name", match=MatchValue(value=source_name))]
|
||||
),
|
||||
)
|
||||
|
||||
# Delete by filter
|
||||
self.qdrant.delete(
|
||||
collection_name=LEGAL_TEMPLATES_COLLECTION,
|
||||
points_selector=Filter(
|
||||
must=[
|
||||
FieldCondition(
|
||||
key="source_name",
|
||||
match=MatchValue(value=source_name),
|
||||
)
|
||||
]
|
||||
must=[FieldCondition(key="source_name", match=MatchValue(value=source_name))]
|
||||
),
|
||||
)
|
||||
|
||||
return count_result.count
|
||||
|
||||
def reset_collection(self):
|
||||
"""Delete and recreate the collection."""
|
||||
logger.warning(f"Resetting collection: {LEGAL_TEMPLATES_COLLECTION}")
|
||||
|
||||
# Delete collection
|
||||
try:
|
||||
self.qdrant.delete_collection(LEGAL_TEMPLATES_COLLECTION)
|
||||
except Exception:
|
||||
pass # Collection might not exist
|
||||
|
||||
# Recreate
|
||||
pass
|
||||
self._ensure_collection()
|
||||
self._ingestion_status.clear()
|
||||
|
||||
logger.info(f"Collection {LEGAL_TEMPLATES_COLLECTION} reset")
|
||||
|
||||
async def close(self):
|
||||
"""Close HTTP client."""
|
||||
await self.http_client.aclose()
|
||||
|
||||
|
||||
async def main():
|
||||
"""CLI entry point."""
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(description="Legal Templates Ingestion")
|
||||
parser.add_argument(
|
||||
"--ingest-all",
|
||||
action="store_true",
|
||||
help="Ingest all enabled sources"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--ingest-source",
|
||||
type=str,
|
||||
metavar="NAME",
|
||||
help="Ingest a specific source by name"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--ingest-license",
|
||||
type=str,
|
||||
choices=["cc0", "mit", "cc_by_4", "public_domain"],
|
||||
help="Ingest all sources of a specific license type"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--max-priority",
|
||||
type=int,
|
||||
default=3,
|
||||
help="Maximum priority level to ingest (1=highest, 5=lowest)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--status",
|
||||
action="store_true",
|
||||
help="Show collection status"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--search",
|
||||
type=str,
|
||||
metavar="QUERY",
|
||||
help="Test search query"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--template-type",
|
||||
type=str,
|
||||
help="Filter search by template type"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--language",
|
||||
type=str,
|
||||
help="Filter search by language"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--reset",
|
||||
action="store_true",
|
||||
help="Reset (delete and recreate) the collection"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--delete-source",
|
||||
type=str,
|
||||
metavar="NAME",
|
||||
help="Delete all chunks from a source"
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
ingestion = LegalTemplatesIngestion()
|
||||
|
||||
try:
|
||||
if args.reset:
|
||||
ingestion.reset_collection()
|
||||
print("Collection reset successfully")
|
||||
|
||||
elif args.delete_source:
|
||||
count = ingestion.delete_source(args.delete_source)
|
||||
print(f"Deleted {count} chunks from {args.delete_source}")
|
||||
|
||||
elif args.status:
|
||||
status = ingestion.get_status()
|
||||
print(json.dumps(status, indent=2, default=str))
|
||||
|
||||
elif args.ingest_all:
|
||||
print(f"Ingesting all sources (max priority: {args.max_priority})...")
|
||||
results = await ingestion.ingest_all(max_priority=args.max_priority)
|
||||
print("\nResults:")
|
||||
for name, status in results.items():
|
||||
print(f" {name}: {status.chunks_indexed} chunks ({status.status})")
|
||||
if status.errors:
|
||||
for error in status.errors:
|
||||
print(f" ERROR: {error}")
|
||||
total = sum(s.chunks_indexed for s in results.values())
|
||||
print(f"\nTotal: {total} chunks indexed")
|
||||
|
||||
elif args.ingest_source:
|
||||
source = next(
|
||||
(s for s in TEMPLATE_SOURCES if s.name == args.ingest_source),
|
||||
None
|
||||
)
|
||||
if not source:
|
||||
print(f"Unknown source: {args.ingest_source}")
|
||||
print("Available sources:")
|
||||
for s in TEMPLATE_SOURCES:
|
||||
print(f" - {s.name}")
|
||||
return
|
||||
|
||||
print(f"Ingesting: {source.name}")
|
||||
status = await ingestion.ingest_source(source)
|
||||
print(f"\nResult: {status.chunks_indexed} chunks ({status.status})")
|
||||
if status.errors:
|
||||
for error in status.errors:
|
||||
print(f" ERROR: {error}")
|
||||
|
||||
elif args.ingest_license:
|
||||
license_type = LicenseType(args.ingest_license)
|
||||
print(f"Ingesting all {license_type.value} sources...")
|
||||
results = await ingestion.ingest_by_license(license_type)
|
||||
print("\nResults:")
|
||||
for name, status in results.items():
|
||||
print(f" {name}: {status.chunks_indexed} chunks ({status.status})")
|
||||
|
||||
elif args.search:
|
||||
print(f"Searching: {args.search}")
|
||||
results = await ingestion.search(
|
||||
args.search,
|
||||
template_type=args.template_type,
|
||||
language=args.language,
|
||||
)
|
||||
print(f"\nFound {len(results)} results:")
|
||||
for i, result in enumerate(results, 1):
|
||||
print(f"\n{i}. [{result['template_type']}] {result['document_title']}")
|
||||
print(f" Score: {result['score']:.3f}")
|
||||
print(f" License: {result['license_name']}")
|
||||
print(f" Source: {result['source_name']}")
|
||||
print(f" Language: {result['language']}")
|
||||
if result['attribution_required']:
|
||||
print(f" Attribution: {result['attribution_text']}")
|
||||
print(f" Text: {result['text'][:200]}...")
|
||||
|
||||
else:
|
||||
parser.print_help()
|
||||
|
||||
finally:
|
||||
await ingestion.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,156 @@
|
||||
"""
|
||||
Mail Database - Email Account Operations.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from typing import Optional, List, Dict
|
||||
|
||||
from .mail_db_pool import get_pool
|
||||
|
||||
|
||||
async def create_email_account(
|
||||
user_id: str,
|
||||
tenant_id: str,
|
||||
email: str,
|
||||
display_name: str,
|
||||
account_type: str,
|
||||
imap_host: str,
|
||||
imap_port: int,
|
||||
imap_ssl: bool,
|
||||
smtp_host: str,
|
||||
smtp_port: int,
|
||||
smtp_ssl: bool,
|
||||
vault_path: str,
|
||||
) -> Optional[str]:
|
||||
"""Create a new email account. Returns the account ID."""
|
||||
pool = await get_pool()
|
||||
if pool is None:
|
||||
return None
|
||||
|
||||
account_id = str(uuid.uuid4())
|
||||
try:
|
||||
async with pool.acquire() as conn:
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO external_email_accounts
|
||||
(id, user_id, tenant_id, email, display_name, account_type,
|
||||
imap_host, imap_port, imap_ssl, smtp_host, smtp_port, smtp_ssl, vault_path)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
|
||||
""",
|
||||
account_id, user_id, tenant_id, email, display_name, account_type,
|
||||
imap_host, imap_port, imap_ssl, smtp_host, smtp_port, smtp_ssl, vault_path
|
||||
)
|
||||
return account_id
|
||||
except Exception as e:
|
||||
print(f"Failed to create email account: {e}")
|
||||
return None
|
||||
|
||||
|
||||
async def get_email_accounts(
|
||||
user_id: str,
|
||||
tenant_id: Optional[str] = None,
|
||||
) -> List[Dict]:
|
||||
"""Get all email accounts for a user."""
|
||||
pool = await get_pool()
|
||||
if pool is None:
|
||||
return []
|
||||
|
||||
try:
|
||||
async with pool.acquire() as conn:
|
||||
if tenant_id:
|
||||
rows = await conn.fetch(
|
||||
"""
|
||||
SELECT * FROM external_email_accounts
|
||||
WHERE user_id = $1 AND tenant_id = $2
|
||||
ORDER BY created_at
|
||||
""",
|
||||
user_id, tenant_id
|
||||
)
|
||||
else:
|
||||
rows = await conn.fetch(
|
||||
"""
|
||||
SELECT * FROM external_email_accounts
|
||||
WHERE user_id = $1
|
||||
ORDER BY created_at
|
||||
""",
|
||||
user_id
|
||||
)
|
||||
return [dict(r) for r in rows]
|
||||
except Exception as e:
|
||||
print(f"Failed to get email accounts: {e}")
|
||||
return []
|
||||
|
||||
|
||||
async def get_email_account(account_id: str, user_id: str) -> Optional[Dict]:
|
||||
"""Get a single email account."""
|
||||
pool = await get_pool()
|
||||
if pool is None:
|
||||
return None
|
||||
|
||||
try:
|
||||
async with pool.acquire() as conn:
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
SELECT * FROM external_email_accounts
|
||||
WHERE id = $1 AND user_id = $2
|
||||
""",
|
||||
account_id, user_id
|
||||
)
|
||||
return dict(row) if row else None
|
||||
except Exception as e:
|
||||
print(f"Failed to get email account: {e}")
|
||||
return None
|
||||
|
||||
|
||||
async def update_account_status(
|
||||
account_id: str,
|
||||
status: str,
|
||||
sync_error: Optional[str] = None,
|
||||
email_count: Optional[int] = None,
|
||||
unread_count: Optional[int] = None,
|
||||
) -> bool:
|
||||
"""Update account sync status."""
|
||||
pool = await get_pool()
|
||||
if pool is None:
|
||||
return False
|
||||
|
||||
try:
|
||||
async with pool.acquire() as conn:
|
||||
await conn.execute(
|
||||
"""
|
||||
UPDATE external_email_accounts SET
|
||||
status = $2,
|
||||
sync_error = $3,
|
||||
email_count = COALESCE($4, email_count),
|
||||
unread_count = COALESCE($5, unread_count),
|
||||
last_sync = NOW(),
|
||||
updated_at = NOW()
|
||||
WHERE id = $1
|
||||
""",
|
||||
account_id, status, sync_error, email_count, unread_count
|
||||
)
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"Failed to update account status: {e}")
|
||||
return False
|
||||
|
||||
|
||||
async def delete_email_account(account_id: str, user_id: str) -> bool:
|
||||
"""Delete an email account (cascades to emails)."""
|
||||
pool = await get_pool()
|
||||
if pool is None:
|
||||
return False
|
||||
|
||||
try:
|
||||
async with pool.acquire() as conn:
|
||||
result = await conn.execute(
|
||||
"""
|
||||
DELETE FROM external_email_accounts
|
||||
WHERE id = $1 AND user_id = $2
|
||||
""",
|
||||
account_id, user_id
|
||||
)
|
||||
return "DELETE" in result
|
||||
except Exception as e:
|
||||
print(f"Failed to delete email account: {e}")
|
||||
return False
|
||||
@@ -0,0 +1,225 @@
|
||||
"""
|
||||
Mail Database - Aggregated Email Operations.
|
||||
"""
|
||||
|
||||
import json
|
||||
import uuid
|
||||
from typing import Optional, List, Dict
|
||||
from datetime import datetime
|
||||
|
||||
from .mail_db_pool import get_pool
|
||||
|
||||
|
||||
async def upsert_email(
|
||||
account_id: str,
|
||||
user_id: str,
|
||||
tenant_id: str,
|
||||
message_id: str,
|
||||
subject: str,
|
||||
sender_email: str,
|
||||
sender_name: Optional[str],
|
||||
recipients: List[str],
|
||||
cc: List[str],
|
||||
body_preview: Optional[str],
|
||||
body_text: Optional[str],
|
||||
body_html: Optional[str],
|
||||
has_attachments: bool,
|
||||
attachments: List[Dict],
|
||||
headers: Dict,
|
||||
folder: str,
|
||||
date_sent: datetime,
|
||||
date_received: datetime,
|
||||
) -> Optional[str]:
|
||||
"""Insert or update an email. Returns the email ID."""
|
||||
pool = await get_pool()
|
||||
if pool is None:
|
||||
return None
|
||||
|
||||
email_id = str(uuid.uuid4())
|
||||
try:
|
||||
async with pool.acquire() as conn:
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
INSERT INTO aggregated_emails
|
||||
(id, account_id, user_id, tenant_id, message_id, subject,
|
||||
sender_email, sender_name, recipients, cc, body_preview,
|
||||
body_text, body_html, has_attachments, attachments, headers,
|
||||
folder, date_sent, date_received)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19)
|
||||
ON CONFLICT (account_id, message_id) DO UPDATE SET
|
||||
subject = EXCLUDED.subject,
|
||||
is_read = EXCLUDED.is_read,
|
||||
folder = EXCLUDED.folder
|
||||
RETURNING id
|
||||
""",
|
||||
email_id, account_id, user_id, tenant_id, message_id, subject,
|
||||
sender_email, sender_name, json.dumps(recipients), json.dumps(cc),
|
||||
body_preview, body_text, body_html, has_attachments,
|
||||
json.dumps(attachments), json.dumps(headers), folder,
|
||||
date_sent, date_received
|
||||
)
|
||||
return row['id'] if row else None
|
||||
except Exception as e:
|
||||
print(f"Failed to upsert email: {e}")
|
||||
return None
|
||||
|
||||
|
||||
async def get_unified_inbox(
|
||||
user_id: str,
|
||||
account_ids: Optional[List[str]] = None,
|
||||
categories: Optional[List[str]] = None,
|
||||
is_read: Optional[bool] = None,
|
||||
is_starred: Optional[bool] = None,
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
) -> List[Dict]:
|
||||
"""Get unified inbox with filtering."""
|
||||
pool = await get_pool()
|
||||
if pool is None:
|
||||
return []
|
||||
|
||||
try:
|
||||
async with pool.acquire() as conn:
|
||||
conditions = ["user_id = $1", "is_deleted = FALSE"]
|
||||
params = [user_id]
|
||||
param_idx = 2
|
||||
|
||||
if account_ids:
|
||||
conditions.append(f"account_id = ANY(${param_idx})")
|
||||
params.append(account_ids)
|
||||
param_idx += 1
|
||||
|
||||
if categories:
|
||||
conditions.append(f"category = ANY(${param_idx})")
|
||||
params.append(categories)
|
||||
param_idx += 1
|
||||
|
||||
if is_read is not None:
|
||||
conditions.append(f"is_read = ${param_idx}")
|
||||
params.append(is_read)
|
||||
param_idx += 1
|
||||
|
||||
if is_starred is not None:
|
||||
conditions.append(f"is_starred = ${param_idx}")
|
||||
params.append(is_starred)
|
||||
param_idx += 1
|
||||
|
||||
where_clause = " AND ".join(conditions)
|
||||
params.extend([limit, offset])
|
||||
|
||||
query = f"""
|
||||
SELECT e.*, a.email as account_email, a.display_name as account_name
|
||||
FROM aggregated_emails e
|
||||
JOIN external_email_accounts a ON e.account_id = a.id
|
||||
WHERE {where_clause}
|
||||
ORDER BY e.date_received DESC
|
||||
LIMIT ${param_idx} OFFSET ${param_idx + 1}
|
||||
"""
|
||||
|
||||
rows = await conn.fetch(query, *params)
|
||||
return [dict(r) for r in rows]
|
||||
except Exception as e:
|
||||
print(f"Failed to get unified inbox: {e}")
|
||||
return []
|
||||
|
||||
|
||||
async def get_email(email_id: str, user_id: str) -> Optional[Dict]:
|
||||
"""Get a single email by ID."""
|
||||
pool = await get_pool()
|
||||
if pool is None:
|
||||
return None
|
||||
|
||||
try:
|
||||
async with pool.acquire() as conn:
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
SELECT e.*, a.email as account_email, a.display_name as account_name
|
||||
FROM aggregated_emails e
|
||||
JOIN external_email_accounts a ON e.account_id = a.id
|
||||
WHERE e.id = $1 AND e.user_id = $2
|
||||
""",
|
||||
email_id, user_id
|
||||
)
|
||||
return dict(row) if row else None
|
||||
except Exception as e:
|
||||
print(f"Failed to get email: {e}")
|
||||
return None
|
||||
|
||||
|
||||
async def update_email_ai_analysis(
|
||||
email_id: str,
|
||||
category: str,
|
||||
sender_type: str,
|
||||
sender_authority_name: Optional[str],
|
||||
detected_deadlines: List[Dict],
|
||||
suggested_priority: str,
|
||||
ai_summary: Optional[str],
|
||||
) -> bool:
|
||||
"""Update email with AI analysis results."""
|
||||
pool = await get_pool()
|
||||
if pool is None:
|
||||
return False
|
||||
|
||||
try:
|
||||
async with pool.acquire() as conn:
|
||||
await conn.execute(
|
||||
"""
|
||||
UPDATE aggregated_emails SET
|
||||
category = $2,
|
||||
sender_type = $3,
|
||||
sender_authority_name = $4,
|
||||
detected_deadlines = $5,
|
||||
suggested_priority = $6,
|
||||
ai_summary = $7,
|
||||
ai_analyzed_at = NOW()
|
||||
WHERE id = $1
|
||||
""",
|
||||
email_id, category, sender_type, sender_authority_name,
|
||||
json.dumps(detected_deadlines), suggested_priority, ai_summary
|
||||
)
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"Failed to update email AI analysis: {e}")
|
||||
return False
|
||||
|
||||
|
||||
async def mark_email_read(email_id: str, user_id: str, is_read: bool = True) -> bool:
|
||||
"""Mark email as read/unread."""
|
||||
pool = await get_pool()
|
||||
if pool is None:
|
||||
return False
|
||||
|
||||
try:
|
||||
async with pool.acquire() as conn:
|
||||
await conn.execute(
|
||||
"""
|
||||
UPDATE aggregated_emails SET is_read = $3
|
||||
WHERE id = $1 AND user_id = $2
|
||||
""",
|
||||
email_id, user_id, is_read
|
||||
)
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"Failed to mark email read: {e}")
|
||||
return False
|
||||
|
||||
|
||||
async def mark_email_starred(email_id: str, user_id: str, is_starred: bool = True) -> bool:
|
||||
"""Mark email as starred/unstarred."""
|
||||
pool = await get_pool()
|
||||
if pool is None:
|
||||
return False
|
||||
|
||||
try:
|
||||
async with pool.acquire() as conn:
|
||||
await conn.execute(
|
||||
"""
|
||||
UPDATE aggregated_emails SET is_starred = $3
|
||||
WHERE id = $1 AND user_id = $2
|
||||
""",
|
||||
email_id, user_id, is_starred
|
||||
)
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"Failed to mark email starred: {e}")
|
||||
return False
|
||||
@@ -0,0 +1,253 @@
|
||||
"""
|
||||
Mail Database - Connection Pool and Schema Initialization.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
# Database Configuration - from Vault or environment (test default for CI)
|
||||
DATABASE_URL = os.getenv("DATABASE_URL", "postgresql://test:test@localhost:5432/test")
|
||||
|
||||
# Flag to check if using test defaults
|
||||
_DB_CONFIGURED = DATABASE_URL != "postgresql://test:test@localhost:5432/test"
|
||||
|
||||
# Connection pool (shared with metrics_db)
|
||||
_pool = None
|
||||
|
||||
|
||||
async def get_pool():
|
||||
"""Get or create database connection pool."""
|
||||
global _pool
|
||||
if _pool is None:
|
||||
try:
|
||||
import asyncpg
|
||||
_pool = await asyncpg.create_pool(DATABASE_URL, min_size=2, max_size=10)
|
||||
except ImportError:
|
||||
print("Warning: asyncpg not installed. Mail database disabled.")
|
||||
return None
|
||||
except Exception as e:
|
||||
print(f"Warning: Failed to connect to PostgreSQL: {e}")
|
||||
return None
|
||||
return _pool
|
||||
|
||||
|
||||
async def init_mail_tables() -> bool:
|
||||
"""Initialize mail tables in PostgreSQL."""
|
||||
pool = await get_pool()
|
||||
if pool is None:
|
||||
return False
|
||||
|
||||
create_tables_sql = """
|
||||
-- =============================================================================
|
||||
-- External Email Accounts
|
||||
-- =============================================================================
|
||||
CREATE TABLE IF NOT EXISTS external_email_accounts (
|
||||
id VARCHAR(36) PRIMARY KEY,
|
||||
user_id VARCHAR(36) NOT NULL,
|
||||
tenant_id VARCHAR(36) NOT NULL,
|
||||
email VARCHAR(255) NOT NULL,
|
||||
display_name VARCHAR(255),
|
||||
account_type VARCHAR(50) DEFAULT 'personal',
|
||||
|
||||
-- IMAP Settings (password stored in Vault)
|
||||
imap_host VARCHAR(255) NOT NULL,
|
||||
imap_port INTEGER DEFAULT 993,
|
||||
imap_ssl BOOLEAN DEFAULT TRUE,
|
||||
|
||||
-- SMTP Settings
|
||||
smtp_host VARCHAR(255) NOT NULL,
|
||||
smtp_port INTEGER DEFAULT 465,
|
||||
smtp_ssl BOOLEAN DEFAULT TRUE,
|
||||
|
||||
-- Vault path for credentials
|
||||
vault_path VARCHAR(500),
|
||||
|
||||
-- Status tracking
|
||||
status VARCHAR(20) DEFAULT 'pending',
|
||||
last_sync TIMESTAMP,
|
||||
sync_error TEXT,
|
||||
email_count INTEGER DEFAULT 0,
|
||||
unread_count INTEGER DEFAULT 0,
|
||||
|
||||
-- Timestamps
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW(),
|
||||
|
||||
-- Constraints
|
||||
UNIQUE(user_id, email)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_mail_accounts_user ON external_email_accounts(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_mail_accounts_tenant ON external_email_accounts(tenant_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_mail_accounts_status ON external_email_accounts(status);
|
||||
|
||||
-- =============================================================================
|
||||
-- Aggregated Emails
|
||||
-- =============================================================================
|
||||
CREATE TABLE IF NOT EXISTS aggregated_emails (
|
||||
id VARCHAR(36) PRIMARY KEY,
|
||||
account_id VARCHAR(36) REFERENCES external_email_accounts(id) ON DELETE CASCADE,
|
||||
user_id VARCHAR(36) NOT NULL,
|
||||
tenant_id VARCHAR(36) NOT NULL,
|
||||
|
||||
-- Email identification
|
||||
message_id VARCHAR(500) NOT NULL,
|
||||
folder VARCHAR(100) DEFAULT 'INBOX',
|
||||
|
||||
-- Email content
|
||||
subject TEXT,
|
||||
sender_email VARCHAR(255),
|
||||
sender_name VARCHAR(255),
|
||||
recipients JSONB DEFAULT '[]',
|
||||
cc JSONB DEFAULT '[]',
|
||||
body_preview TEXT,
|
||||
body_text TEXT,
|
||||
body_html TEXT,
|
||||
has_attachments BOOLEAN DEFAULT FALSE,
|
||||
attachments JSONB DEFAULT '[]',
|
||||
headers JSONB DEFAULT '{}',
|
||||
|
||||
-- Status flags
|
||||
is_read BOOLEAN DEFAULT FALSE,
|
||||
is_starred BOOLEAN DEFAULT FALSE,
|
||||
is_deleted BOOLEAN DEFAULT FALSE,
|
||||
|
||||
-- Dates
|
||||
date_sent TIMESTAMP,
|
||||
date_received TIMESTAMP,
|
||||
|
||||
-- AI enrichment
|
||||
category VARCHAR(50),
|
||||
sender_type VARCHAR(50),
|
||||
sender_authority_name VARCHAR(255),
|
||||
detected_deadlines JSONB DEFAULT '[]',
|
||||
suggested_priority VARCHAR(20),
|
||||
ai_summary TEXT,
|
||||
ai_analyzed_at TIMESTAMP,
|
||||
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
|
||||
-- Prevent duplicate imports
|
||||
UNIQUE(account_id, message_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_emails_account ON aggregated_emails(account_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_emails_user ON aggregated_emails(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_emails_tenant ON aggregated_emails(tenant_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_emails_date ON aggregated_emails(date_received DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_emails_category ON aggregated_emails(category);
|
||||
CREATE INDEX IF NOT EXISTS idx_emails_unread ON aggregated_emails(is_read) WHERE is_read = FALSE;
|
||||
CREATE INDEX IF NOT EXISTS idx_emails_starred ON aggregated_emails(is_starred) WHERE is_starred = TRUE;
|
||||
CREATE INDEX IF NOT EXISTS idx_emails_sender ON aggregated_emails(sender_email);
|
||||
|
||||
-- =============================================================================
|
||||
-- Inbox Tasks (Arbeitsvorrat)
|
||||
-- =============================================================================
|
||||
CREATE TABLE IF NOT EXISTS inbox_tasks (
|
||||
id VARCHAR(36) PRIMARY KEY,
|
||||
user_id VARCHAR(36) NOT NULL,
|
||||
tenant_id VARCHAR(36) NOT NULL,
|
||||
email_id VARCHAR(36) REFERENCES aggregated_emails(id) ON DELETE SET NULL,
|
||||
account_id VARCHAR(36) REFERENCES external_email_accounts(id) ON DELETE SET NULL,
|
||||
|
||||
-- Task content
|
||||
title VARCHAR(500) NOT NULL,
|
||||
description TEXT,
|
||||
priority VARCHAR(20) DEFAULT 'medium',
|
||||
status VARCHAR(20) DEFAULT 'pending',
|
||||
deadline TIMESTAMP,
|
||||
|
||||
-- Source information
|
||||
source_email_subject TEXT,
|
||||
source_sender VARCHAR(255),
|
||||
source_sender_type VARCHAR(50),
|
||||
|
||||
-- AI extraction info
|
||||
ai_extracted BOOLEAN DEFAULT FALSE,
|
||||
confidence_score FLOAT,
|
||||
|
||||
-- Completion tracking
|
||||
completed_at TIMESTAMP,
|
||||
reminder_at TIMESTAMP,
|
||||
|
||||
-- Timestamps
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_tasks_user ON inbox_tasks(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_tasks_tenant ON inbox_tasks(tenant_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_tasks_status ON inbox_tasks(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_tasks_deadline ON inbox_tasks(deadline) WHERE deadline IS NOT NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_tasks_priority ON inbox_tasks(priority);
|
||||
CREATE INDEX IF NOT EXISTS idx_tasks_email ON inbox_tasks(email_id) WHERE email_id IS NOT NULL;
|
||||
|
||||
-- =============================================================================
|
||||
-- Email Templates
|
||||
-- =============================================================================
|
||||
CREATE TABLE IF NOT EXISTS email_templates (
|
||||
id VARCHAR(36) PRIMARY KEY,
|
||||
user_id VARCHAR(36), -- NULL for system templates
|
||||
tenant_id VARCHAR(36),
|
||||
|
||||
name VARCHAR(255) NOT NULL,
|
||||
category VARCHAR(100),
|
||||
subject_template TEXT,
|
||||
body_template TEXT,
|
||||
variables JSONB DEFAULT '[]',
|
||||
|
||||
is_system BOOLEAN DEFAULT FALSE,
|
||||
usage_count INTEGER DEFAULT 0,
|
||||
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_templates_user ON email_templates(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_templates_tenant ON email_templates(tenant_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_templates_system ON email_templates(is_system);
|
||||
|
||||
-- =============================================================================
|
||||
-- Mail Audit Log
|
||||
-- =============================================================================
|
||||
CREATE TABLE IF NOT EXISTS mail_audit_log (
|
||||
id VARCHAR(36) PRIMARY KEY,
|
||||
user_id VARCHAR(36) NOT NULL,
|
||||
tenant_id VARCHAR(36),
|
||||
action VARCHAR(100) NOT NULL,
|
||||
entity_type VARCHAR(50), -- account, email, task
|
||||
entity_id VARCHAR(36),
|
||||
details JSONB,
|
||||
ip_address VARCHAR(45),
|
||||
user_agent TEXT,
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_mail_audit_user ON mail_audit_log(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_mail_audit_created ON mail_audit_log(created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_mail_audit_action ON mail_audit_log(action);
|
||||
|
||||
-- =============================================================================
|
||||
-- Sync Status Tracking
|
||||
-- =============================================================================
|
||||
CREATE TABLE IF NOT EXISTS mail_sync_status (
|
||||
id VARCHAR(36) PRIMARY KEY,
|
||||
account_id VARCHAR(36) REFERENCES external_email_accounts(id) ON DELETE CASCADE,
|
||||
folder VARCHAR(100),
|
||||
last_uid INTEGER DEFAULT 0,
|
||||
last_sync TIMESTAMP,
|
||||
sync_errors INTEGER DEFAULT 0,
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW(),
|
||||
|
||||
UNIQUE(account_id, folder)
|
||||
);
|
||||
"""
|
||||
|
||||
try:
|
||||
async with pool.acquire() as conn:
|
||||
await conn.execute(create_tables_sql)
|
||||
print("Mail tables initialized successfully")
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"Failed to initialize mail tables: {e}")
|
||||
return False
|
||||
@@ -0,0 +1,118 @@
|
||||
"""
|
||||
Mail Database - Statistics and Audit Log Operations.
|
||||
"""
|
||||
|
||||
import json
|
||||
import uuid
|
||||
from typing import Optional, Dict
|
||||
from datetime import datetime
|
||||
|
||||
from .mail_db_pool import get_pool
|
||||
|
||||
|
||||
async def get_mail_stats(user_id: str) -> Dict:
|
||||
"""Get overall mail statistics for a user."""
|
||||
pool = await get_pool()
|
||||
if pool is None:
|
||||
return {}
|
||||
|
||||
try:
|
||||
async with pool.acquire() as conn:
|
||||
today = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
|
||||
# Account stats
|
||||
accounts = await conn.fetch(
|
||||
"""
|
||||
SELECT id, email, display_name, status, email_count, unread_count, last_sync
|
||||
FROM external_email_accounts
|
||||
WHERE user_id = $1
|
||||
""",
|
||||
user_id
|
||||
)
|
||||
|
||||
# Email counts
|
||||
email_stats = await conn.fetchrow(
|
||||
"""
|
||||
SELECT
|
||||
COUNT(*) as total_emails,
|
||||
COUNT(*) FILTER (WHERE is_read = FALSE) as unread_emails,
|
||||
COUNT(*) FILTER (WHERE date_received >= $2) as emails_today,
|
||||
COUNT(*) FILTER (WHERE ai_analyzed_at >= $2) as ai_analyses_today
|
||||
FROM aggregated_emails
|
||||
WHERE user_id = $1
|
||||
""",
|
||||
user_id, today
|
||||
)
|
||||
|
||||
# Task counts
|
||||
task_stats = await conn.fetchrow(
|
||||
"""
|
||||
SELECT
|
||||
COUNT(*) as total_tasks,
|
||||
COUNT(*) FILTER (WHERE status = 'pending') as pending_tasks,
|
||||
COUNT(*) FILTER (WHERE status != 'completed' AND deadline < NOW()) as overdue_tasks
|
||||
FROM inbox_tasks
|
||||
WHERE user_id = $1
|
||||
""",
|
||||
user_id
|
||||
)
|
||||
|
||||
return {
|
||||
"total_accounts": len(accounts),
|
||||
"active_accounts": sum(1 for a in accounts if a['status'] == 'active'),
|
||||
"error_accounts": sum(1 for a in accounts if a['status'] == 'error'),
|
||||
"total_emails": email_stats['total_emails'] or 0,
|
||||
"unread_emails": email_stats['unread_emails'] or 0,
|
||||
"total_tasks": task_stats['total_tasks'] or 0,
|
||||
"pending_tasks": task_stats['pending_tasks'] or 0,
|
||||
"overdue_tasks": task_stats['overdue_tasks'] or 0,
|
||||
"emails_today": email_stats['emails_today'] or 0,
|
||||
"ai_analyses_today": email_stats['ai_analyses_today'] or 0,
|
||||
"per_account": [
|
||||
{
|
||||
"id": a['id'],
|
||||
"email": a['email'],
|
||||
"display_name": a['display_name'],
|
||||
"status": a['status'],
|
||||
"email_count": a['email_count'],
|
||||
"unread_count": a['unread_count'],
|
||||
"last_sync": a['last_sync'].isoformat() if a['last_sync'] else None,
|
||||
}
|
||||
for a in accounts
|
||||
],
|
||||
}
|
||||
except Exception as e:
|
||||
print(f"Failed to get mail stats: {e}")
|
||||
return {}
|
||||
|
||||
|
||||
async def log_mail_audit(
|
||||
user_id: str,
|
||||
action: str,
|
||||
entity_type: Optional[str] = None,
|
||||
entity_id: Optional[str] = None,
|
||||
details: Optional[Dict] = None,
|
||||
tenant_id: Optional[str] = None,
|
||||
ip_address: Optional[str] = None,
|
||||
user_agent: Optional[str] = None,
|
||||
) -> bool:
|
||||
"""Log a mail action for audit trail."""
|
||||
pool = await get_pool()
|
||||
if pool is None:
|
||||
return False
|
||||
|
||||
try:
|
||||
async with pool.acquire() as conn:
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO mail_audit_log
|
||||
(id, user_id, tenant_id, action, entity_type, entity_id, details, ip_address, user_agent)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
""",
|
||||
str(uuid.uuid4()), user_id, tenant_id, action, entity_type, entity_id,
|
||||
json.dumps(details) if details else None, ip_address, user_agent
|
||||
)
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"Failed to log mail audit: {e}")
|
||||
return False
|
||||
@@ -0,0 +1,247 @@
|
||||
"""
|
||||
Mail Database - Inbox Task Operations.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from typing import Optional, List, Dict
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from .mail_db_pool import get_pool
|
||||
|
||||
|
||||
async def create_task(
|
||||
user_id: str,
|
||||
tenant_id: str,
|
||||
title: str,
|
||||
description: Optional[str] = None,
|
||||
priority: str = "medium",
|
||||
deadline: Optional[datetime] = None,
|
||||
email_id: Optional[str] = None,
|
||||
account_id: Optional[str] = None,
|
||||
source_email_subject: Optional[str] = None,
|
||||
source_sender: Optional[str] = None,
|
||||
source_sender_type: Optional[str] = None,
|
||||
ai_extracted: bool = False,
|
||||
confidence_score: Optional[float] = None,
|
||||
) -> Optional[str]:
|
||||
"""Create a new inbox task."""
|
||||
pool = await get_pool()
|
||||
if pool is None:
|
||||
return None
|
||||
|
||||
task_id = str(uuid.uuid4())
|
||||
try:
|
||||
async with pool.acquire() as conn:
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO inbox_tasks
|
||||
(id, user_id, tenant_id, title, description, priority, deadline,
|
||||
email_id, account_id, source_email_subject, source_sender,
|
||||
source_sender_type, ai_extracted, confidence_score)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
|
||||
""",
|
||||
task_id, user_id, tenant_id, title, description, priority, deadline,
|
||||
email_id, account_id, source_email_subject, source_sender,
|
||||
source_sender_type, ai_extracted, confidence_score
|
||||
)
|
||||
return task_id
|
||||
except Exception as e:
|
||||
print(f"Failed to create task: {e}")
|
||||
return None
|
||||
|
||||
|
||||
async def get_tasks(
|
||||
user_id: str,
|
||||
status: Optional[str] = None,
|
||||
priority: Optional[str] = None,
|
||||
include_completed: bool = False,
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
) -> List[Dict]:
|
||||
"""Get tasks for a user."""
|
||||
pool = await get_pool()
|
||||
if pool is None:
|
||||
return []
|
||||
|
||||
try:
|
||||
async with pool.acquire() as conn:
|
||||
conditions = ["user_id = $1"]
|
||||
params = [user_id]
|
||||
param_idx = 2
|
||||
|
||||
if not include_completed:
|
||||
conditions.append("status != 'completed'")
|
||||
|
||||
if status:
|
||||
conditions.append(f"status = ${param_idx}")
|
||||
params.append(status)
|
||||
param_idx += 1
|
||||
|
||||
if priority:
|
||||
conditions.append(f"priority = ${param_idx}")
|
||||
params.append(priority)
|
||||
param_idx += 1
|
||||
|
||||
where_clause = " AND ".join(conditions)
|
||||
params.extend([limit, offset])
|
||||
|
||||
query = f"""
|
||||
SELECT * FROM inbox_tasks
|
||||
WHERE {where_clause}
|
||||
ORDER BY
|
||||
CASE priority
|
||||
WHEN 'urgent' THEN 1
|
||||
WHEN 'high' THEN 2
|
||||
WHEN 'medium' THEN 3
|
||||
WHEN 'low' THEN 4
|
||||
END,
|
||||
deadline ASC NULLS LAST,
|
||||
created_at DESC
|
||||
LIMIT ${param_idx} OFFSET ${param_idx + 1}
|
||||
"""
|
||||
|
||||
rows = await conn.fetch(query, *params)
|
||||
return [dict(r) for r in rows]
|
||||
except Exception as e:
|
||||
print(f"Failed to get tasks: {e}")
|
||||
return []
|
||||
|
||||
|
||||
async def get_task(task_id: str, user_id: str) -> Optional[Dict]:
|
||||
"""Get a single task."""
|
||||
pool = await get_pool()
|
||||
if pool is None:
|
||||
return None
|
||||
|
||||
try:
|
||||
async with pool.acquire() as conn:
|
||||
row = await conn.fetchrow(
|
||||
"SELECT * FROM inbox_tasks WHERE id = $1 AND user_id = $2",
|
||||
task_id, user_id
|
||||
)
|
||||
return dict(row) if row else None
|
||||
except Exception as e:
|
||||
print(f"Failed to get task: {e}")
|
||||
return None
|
||||
|
||||
|
||||
async def update_task(
|
||||
task_id: str,
|
||||
user_id: str,
|
||||
title: Optional[str] = None,
|
||||
description: Optional[str] = None,
|
||||
priority: Optional[str] = None,
|
||||
status: Optional[str] = None,
|
||||
deadline: Optional[datetime] = None,
|
||||
) -> bool:
|
||||
"""Update a task."""
|
||||
pool = await get_pool()
|
||||
if pool is None:
|
||||
return False
|
||||
|
||||
try:
|
||||
async with pool.acquire() as conn:
|
||||
updates = ["updated_at = NOW()"]
|
||||
params = [task_id, user_id]
|
||||
param_idx = 3
|
||||
|
||||
if title is not None:
|
||||
updates.append(f"title = ${param_idx}")
|
||||
params.append(title)
|
||||
param_idx += 1
|
||||
|
||||
if description is not None:
|
||||
updates.append(f"description = ${param_idx}")
|
||||
params.append(description)
|
||||
param_idx += 1
|
||||
|
||||
if priority is not None:
|
||||
updates.append(f"priority = ${param_idx}")
|
||||
params.append(priority)
|
||||
param_idx += 1
|
||||
|
||||
if status is not None:
|
||||
updates.append(f"status = ${param_idx}")
|
||||
params.append(status)
|
||||
param_idx += 1
|
||||
if status == "completed":
|
||||
updates.append("completed_at = NOW()")
|
||||
|
||||
if deadline is not None:
|
||||
updates.append(f"deadline = ${param_idx}")
|
||||
params.append(deadline)
|
||||
param_idx += 1
|
||||
|
||||
set_clause = ", ".join(updates)
|
||||
await conn.execute(
|
||||
f"UPDATE inbox_tasks SET {set_clause} WHERE id = $1 AND user_id = $2",
|
||||
*params
|
||||
)
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"Failed to update task: {e}")
|
||||
return False
|
||||
|
||||
|
||||
async def get_task_dashboard_stats(user_id: str) -> Dict:
|
||||
"""Get dashboard statistics for tasks."""
|
||||
pool = await get_pool()
|
||||
if pool is None:
|
||||
return {}
|
||||
|
||||
try:
|
||||
async with pool.acquire() as conn:
|
||||
now = datetime.now()
|
||||
today_end = now.replace(hour=23, minute=59, second=59)
|
||||
week_end = now + timedelta(days=7)
|
||||
|
||||
stats = await conn.fetchrow(
|
||||
"""
|
||||
SELECT
|
||||
COUNT(*) as total_tasks,
|
||||
COUNT(*) FILTER (WHERE status = 'pending') as pending_tasks,
|
||||
COUNT(*) FILTER (WHERE status = 'in_progress') as in_progress_tasks,
|
||||
COUNT(*) FILTER (WHERE status = 'completed') as completed_tasks,
|
||||
COUNT(*) FILTER (WHERE status != 'completed' AND deadline < $2) as overdue_tasks,
|
||||
COUNT(*) FILTER (WHERE status != 'completed' AND deadline <= $3) as due_today,
|
||||
COUNT(*) FILTER (WHERE status != 'completed' AND deadline <= $4) as due_this_week
|
||||
FROM inbox_tasks
|
||||
WHERE user_id = $1
|
||||
""",
|
||||
user_id, now, today_end, week_end
|
||||
)
|
||||
|
||||
by_priority = await conn.fetch(
|
||||
"""
|
||||
SELECT priority, COUNT(*) as count
|
||||
FROM inbox_tasks
|
||||
WHERE user_id = $1 AND status != 'completed'
|
||||
GROUP BY priority
|
||||
""",
|
||||
user_id
|
||||
)
|
||||
|
||||
by_sender = await conn.fetch(
|
||||
"""
|
||||
SELECT source_sender_type, COUNT(*) as count
|
||||
FROM inbox_tasks
|
||||
WHERE user_id = $1 AND status != 'completed' AND source_sender_type IS NOT NULL
|
||||
GROUP BY source_sender_type
|
||||
""",
|
||||
user_id
|
||||
)
|
||||
|
||||
return {
|
||||
"total_tasks": stats['total_tasks'] or 0,
|
||||
"pending_tasks": stats['pending_tasks'] or 0,
|
||||
"in_progress_tasks": stats['in_progress_tasks'] or 0,
|
||||
"completed_tasks": stats['completed_tasks'] or 0,
|
||||
"overdue_tasks": stats['overdue_tasks'] or 0,
|
||||
"due_today": stats['due_today'] or 0,
|
||||
"due_this_week": stats['due_this_week'] or 0,
|
||||
"by_priority": {r['priority']: r['count'] for r in by_priority},
|
||||
"by_sender_type": {r['source_sender_type']: r['count'] for r in by_sender},
|
||||
}
|
||||
except Exception as e:
|
||||
print(f"Failed to get task stats: {e}")
|
||||
return {}
|
||||
@@ -0,0 +1,272 @@
|
||||
"""
|
||||
OCR Merge Helpers — functions for combining PaddleOCR/RapidOCR with Tesseract results.
|
||||
|
||||
Extracted from ocr_pipeline_ocr_merge.py.
|
||||
|
||||
Lizenz: Apache 2.0
|
||||
DATENSCHUTZ: Alle Verarbeitung erfolgt lokal.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import List
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _split_paddle_multi_words(words: list) -> list:
|
||||
"""Split PaddleOCR multi-word boxes into individual word boxes.
|
||||
|
||||
PaddleOCR often returns entire phrases as a single box, e.g.
|
||||
"More than 200 singers took part in the" with one bounding box.
|
||||
This splits them into individual words with proportional widths.
|
||||
Also handles leading "!" (e.g. "!Betonung" -> ["!", "Betonung"])
|
||||
and IPA brackets (e.g. "badge[bxd3]" -> ["badge", "[bxd3]"]).
|
||||
"""
|
||||
import re
|
||||
|
||||
result = []
|
||||
for w in words:
|
||||
raw_text = w.get("text", "").strip()
|
||||
if not raw_text:
|
||||
continue
|
||||
# Split on whitespace, before "[" (IPA), and after "!" before letter
|
||||
tokens = re.split(
|
||||
r'\s+|(?=\[)|(?<=!)(?=[A-Za-z\u00c0-\u024f])', raw_text
|
||||
)
|
||||
tokens = [t for t in tokens if t]
|
||||
|
||||
if len(tokens) <= 1:
|
||||
result.append(w)
|
||||
else:
|
||||
# Split proportionally by character count
|
||||
total_chars = sum(len(t) for t in tokens)
|
||||
if total_chars == 0:
|
||||
continue
|
||||
n_gaps = len(tokens) - 1
|
||||
gap_px = w["width"] * 0.02
|
||||
usable_w = w["width"] - gap_px * n_gaps
|
||||
cursor = w["left"]
|
||||
for t in tokens:
|
||||
token_w = max(1, usable_w * len(t) / total_chars)
|
||||
result.append({
|
||||
"text": t,
|
||||
"left": round(cursor),
|
||||
"top": w["top"],
|
||||
"width": round(token_w),
|
||||
"height": w["height"],
|
||||
"conf": w.get("conf", 0),
|
||||
})
|
||||
cursor += token_w + gap_px
|
||||
return result
|
||||
|
||||
|
||||
def _group_words_into_rows(words: list, row_gap: int = 12) -> list:
|
||||
"""Group words into rows by Y-position clustering.
|
||||
|
||||
Words whose vertical centers are within `row_gap` pixels are on the same row.
|
||||
Returns list of rows, each row is a list of words sorted left-to-right.
|
||||
"""
|
||||
if not words:
|
||||
return []
|
||||
# Sort by vertical center
|
||||
sorted_words = sorted(words, key=lambda w: w["top"] + w.get("height", 0) / 2)
|
||||
rows: list = []
|
||||
current_row: list = [sorted_words[0]]
|
||||
current_cy = sorted_words[0]["top"] + sorted_words[0].get("height", 0) / 2
|
||||
|
||||
for w in sorted_words[1:]:
|
||||
cy = w["top"] + w.get("height", 0) / 2
|
||||
if abs(cy - current_cy) <= row_gap:
|
||||
current_row.append(w)
|
||||
else:
|
||||
# Sort current row left-to-right before saving
|
||||
rows.append(sorted(current_row, key=lambda w: w["left"]))
|
||||
current_row = [w]
|
||||
current_cy = cy
|
||||
if current_row:
|
||||
rows.append(sorted(current_row, key=lambda w: w["left"]))
|
||||
return rows
|
||||
|
||||
|
||||
def _row_center_y(row: list) -> float:
|
||||
"""Average vertical center of a row of words."""
|
||||
if not row:
|
||||
return 0.0
|
||||
return sum(w["top"] + w.get("height", 0) / 2 for w in row) / len(row)
|
||||
|
||||
|
||||
def _merge_row_sequences(paddle_row: list, tess_row: list) -> list:
|
||||
"""Merge two word sequences from the same row using sequence alignment.
|
||||
|
||||
Both sequences are sorted left-to-right. Walk through both simultaneously:
|
||||
- If words match (same/similar text): take Paddle text with averaged coords
|
||||
- If they don't match: the extra word is unique to one engine, include it
|
||||
"""
|
||||
merged = []
|
||||
pi, ti = 0, 0
|
||||
|
||||
while pi < len(paddle_row) and ti < len(tess_row):
|
||||
pw = paddle_row[pi]
|
||||
tw = tess_row[ti]
|
||||
|
||||
pt = pw.get("text", "").lower().strip()
|
||||
tt = tw.get("text", "").lower().strip()
|
||||
|
||||
is_same = (pt == tt) or (len(pt) > 1 and len(tt) > 1 and (pt in tt or tt in pt))
|
||||
|
||||
# Spatial overlap check
|
||||
spatial_match = False
|
||||
if not is_same:
|
||||
overlap_left = max(pw["left"], tw["left"])
|
||||
overlap_right = min(
|
||||
pw["left"] + pw.get("width", 0),
|
||||
tw["left"] + tw.get("width", 0),
|
||||
)
|
||||
overlap_w = max(0, overlap_right - overlap_left)
|
||||
min_w = min(pw.get("width", 1), tw.get("width", 1))
|
||||
if min_w > 0 and overlap_w / min_w >= 0.4:
|
||||
is_same = True
|
||||
spatial_match = True
|
||||
|
||||
if is_same:
|
||||
pc = pw.get("conf", 80)
|
||||
tc = tw.get("conf", 50)
|
||||
total = pc + tc
|
||||
if total == 0:
|
||||
total = 1
|
||||
if spatial_match and pc < tc:
|
||||
best_text = tw["text"]
|
||||
else:
|
||||
best_text = pw["text"]
|
||||
merged.append({
|
||||
"text": best_text,
|
||||
"left": round((pw["left"] * pc + tw["left"] * tc) / total),
|
||||
"top": round((pw["top"] * pc + tw["top"] * tc) / total),
|
||||
"width": round((pw["width"] * pc + tw["width"] * tc) / total),
|
||||
"height": round((pw["height"] * pc + tw["height"] * tc) / total),
|
||||
"conf": max(pc, tc),
|
||||
})
|
||||
pi += 1
|
||||
ti += 1
|
||||
else:
|
||||
paddle_ahead = any(
|
||||
tess_row[t].get("text", "").lower().strip() == pt
|
||||
for t in range(ti + 1, min(ti + 4, len(tess_row)))
|
||||
)
|
||||
tess_ahead = any(
|
||||
paddle_row[p].get("text", "").lower().strip() == tt
|
||||
for p in range(pi + 1, min(pi + 4, len(paddle_row)))
|
||||
)
|
||||
|
||||
if paddle_ahead and not tess_ahead:
|
||||
if tw.get("conf", 0) >= 30:
|
||||
merged.append(tw)
|
||||
ti += 1
|
||||
elif tess_ahead and not paddle_ahead:
|
||||
merged.append(pw)
|
||||
pi += 1
|
||||
else:
|
||||
if pw["left"] <= tw["left"]:
|
||||
merged.append(pw)
|
||||
pi += 1
|
||||
else:
|
||||
if tw.get("conf", 0) >= 30:
|
||||
merged.append(tw)
|
||||
ti += 1
|
||||
|
||||
while pi < len(paddle_row):
|
||||
merged.append(paddle_row[pi])
|
||||
pi += 1
|
||||
while ti < len(tess_row):
|
||||
tw = tess_row[ti]
|
||||
if tw.get("conf", 0) >= 30:
|
||||
merged.append(tw)
|
||||
ti += 1
|
||||
|
||||
return merged
|
||||
|
||||
|
||||
def _merge_paddle_tesseract(paddle_words: list, tess_words: list) -> list:
|
||||
"""Merge word boxes from PaddleOCR and Tesseract using row-based sequence alignment."""
|
||||
if not paddle_words and not tess_words:
|
||||
return []
|
||||
if not paddle_words:
|
||||
return [w for w in tess_words if w.get("conf", 0) >= 40]
|
||||
if not tess_words:
|
||||
return list(paddle_words)
|
||||
|
||||
paddle_rows = _group_words_into_rows(paddle_words)
|
||||
tess_rows = _group_words_into_rows(tess_words)
|
||||
|
||||
used_tess_rows: set = set()
|
||||
merged_all: list = []
|
||||
|
||||
for pr in paddle_rows:
|
||||
pr_cy = _row_center_y(pr)
|
||||
best_dist, best_tri = float("inf"), -1
|
||||
for tri, tr in enumerate(tess_rows):
|
||||
if tri in used_tess_rows:
|
||||
continue
|
||||
tr_cy = _row_center_y(tr)
|
||||
dist = abs(pr_cy - tr_cy)
|
||||
if dist < best_dist:
|
||||
best_dist, best_tri = dist, tri
|
||||
|
||||
max_row_dist = max(
|
||||
max((w.get("height", 20) for w in pr), default=20),
|
||||
15,
|
||||
)
|
||||
|
||||
if best_tri >= 0 and best_dist <= max_row_dist:
|
||||
tr = tess_rows[best_tri]
|
||||
used_tess_rows.add(best_tri)
|
||||
merged_all.extend(_merge_row_sequences(pr, tr))
|
||||
else:
|
||||
merged_all.extend(pr)
|
||||
|
||||
for tri, tr in enumerate(tess_rows):
|
||||
if tri not in used_tess_rows:
|
||||
for tw in tr:
|
||||
if tw.get("conf", 0) >= 40:
|
||||
merged_all.append(tw)
|
||||
|
||||
return merged_all
|
||||
|
||||
|
||||
def _deduplicate_words(words: list) -> list:
|
||||
"""Remove duplicate words with same text at overlapping positions."""
|
||||
if not words:
|
||||
return words
|
||||
|
||||
result: list = []
|
||||
for w in words:
|
||||
wt = w.get("text", "").lower().strip()
|
||||
if not wt:
|
||||
continue
|
||||
is_dup = False
|
||||
w_right = w["left"] + w.get("width", 0)
|
||||
w_bottom = w["top"] + w.get("height", 0)
|
||||
for existing in result:
|
||||
et = existing.get("text", "").lower().strip()
|
||||
if wt != et:
|
||||
continue
|
||||
ox_l = max(w["left"], existing["left"])
|
||||
ox_r = min(w_right, existing["left"] + existing.get("width", 0))
|
||||
ox = max(0, ox_r - ox_l)
|
||||
min_w = min(w.get("width", 1), existing.get("width", 1))
|
||||
if min_w <= 0 or ox / min_w < 0.5:
|
||||
continue
|
||||
oy_t = max(w["top"], existing["top"])
|
||||
oy_b = min(w_bottom, existing["top"] + existing.get("height", 0))
|
||||
oy = max(0, oy_b - oy_t)
|
||||
min_h = min(w.get("height", 1), existing.get("height", 1))
|
||||
if min_h > 0 and oy / min_h >= 0.5:
|
||||
is_dup = True
|
||||
break
|
||||
if not is_dup:
|
||||
result.append(w)
|
||||
|
||||
removed = len(words) - len(result)
|
||||
if removed:
|
||||
logger.info("dedup: removed %d duplicate words", removed)
|
||||
return result
|
||||
@@ -0,0 +1,209 @@
|
||||
"""
|
||||
OCR Pipeline LLM Review — LLM-based correction endpoints.
|
||||
|
||||
Extracted from ocr_pipeline_postprocess.py.
|
||||
|
||||
Lizenz: Apache 2.0
|
||||
DATENSCHUTZ: Alle Verarbeitung erfolgt lokal.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Dict, List
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
from cv_vocab_pipeline import (
|
||||
OLLAMA_REVIEW_MODEL,
|
||||
llm_review_entries,
|
||||
llm_review_entries_streaming,
|
||||
)
|
||||
from ocr_pipeline_session_store import (
|
||||
get_session_db,
|
||||
update_session_db,
|
||||
)
|
||||
from ocr_pipeline_common import (
|
||||
_cache,
|
||||
_append_pipeline_log,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/v1/ocr-pipeline", tags=["ocr-pipeline"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Step 8: LLM Review
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.post("/sessions/{session_id}/llm-review")
|
||||
async def run_llm_review(session_id: str, request: Request, stream: bool = False):
|
||||
"""Run LLM-based correction on vocab entries from Step 5.
|
||||
|
||||
Query params:
|
||||
stream: false (default) for JSON response, true for SSE streaming
|
||||
"""
|
||||
session = await get_session_db(session_id)
|
||||
if not session:
|
||||
raise HTTPException(status_code=404, detail=f"Session {session_id} not found")
|
||||
|
||||
word_result = session.get("word_result")
|
||||
if not word_result:
|
||||
raise HTTPException(status_code=400, detail="No word result found — run Step 5 first")
|
||||
|
||||
entries = word_result.get("vocab_entries") or word_result.get("entries") or []
|
||||
if not entries:
|
||||
raise HTTPException(status_code=400, detail="No vocab entries found — run Step 5 first")
|
||||
|
||||
# Optional model override from request body
|
||||
body = {}
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
pass
|
||||
model = body.get("model") or OLLAMA_REVIEW_MODEL
|
||||
|
||||
if stream:
|
||||
return StreamingResponse(
|
||||
_llm_review_stream_generator(session_id, entries, word_result, model, request),
|
||||
media_type="text/event-stream",
|
||||
headers={"Cache-Control": "no-cache", "Connection": "keep-alive", "X-Accel-Buffering": "no"},
|
||||
)
|
||||
|
||||
# Non-streaming path
|
||||
try:
|
||||
result = await llm_review_entries(entries, model=model)
|
||||
except Exception as e:
|
||||
import traceback
|
||||
logger.error(f"LLM review failed for session {session_id}: {type(e).__name__}: {e}\n{traceback.format_exc()}")
|
||||
raise HTTPException(status_code=502, detail=f"LLM review failed ({type(e).__name__}): {e}")
|
||||
|
||||
# Store result inside word_result as a sub-key
|
||||
word_result["llm_review"] = {
|
||||
"changes": result["changes"],
|
||||
"model_used": result["model_used"],
|
||||
"duration_ms": result["duration_ms"],
|
||||
"entries_corrected": result["entries_corrected"],
|
||||
}
|
||||
await update_session_db(session_id, word_result=word_result, current_step=9)
|
||||
|
||||
if session_id in _cache:
|
||||
_cache[session_id]["word_result"] = word_result
|
||||
|
||||
logger.info(f"LLM review session {session_id}: {len(result['changes'])} changes, "
|
||||
f"{result['duration_ms']}ms, model={result['model_used']}")
|
||||
|
||||
await _append_pipeline_log(session_id, "correction", {
|
||||
"engine": "llm",
|
||||
"model": result["model_used"],
|
||||
"total_entries": len(entries),
|
||||
"corrections_proposed": len(result["changes"]),
|
||||
}, duration_ms=result["duration_ms"])
|
||||
|
||||
return {
|
||||
"session_id": session_id,
|
||||
"changes": result["changes"],
|
||||
"model_used": result["model_used"],
|
||||
"duration_ms": result["duration_ms"],
|
||||
"total_entries": len(entries),
|
||||
"corrections_found": len(result["changes"]),
|
||||
}
|
||||
|
||||
|
||||
async def _llm_review_stream_generator(
|
||||
session_id: str,
|
||||
entries: List[Dict],
|
||||
word_result: Dict,
|
||||
model: str,
|
||||
request: Request,
|
||||
):
|
||||
"""SSE generator that yields batch-by-batch LLM review progress."""
|
||||
try:
|
||||
async for event in llm_review_entries_streaming(entries, model=model):
|
||||
if await request.is_disconnected():
|
||||
logger.info(f"SSE: client disconnected during LLM review for {session_id}")
|
||||
return
|
||||
|
||||
yield f"data: {json.dumps(event, ensure_ascii=False)}\n\n"
|
||||
|
||||
# On complete: persist to DB
|
||||
if event.get("type") == "complete":
|
||||
word_result["llm_review"] = {
|
||||
"changes": event["changes"],
|
||||
"model_used": event["model_used"],
|
||||
"duration_ms": event["duration_ms"],
|
||||
"entries_corrected": event["entries_corrected"],
|
||||
}
|
||||
await update_session_db(session_id, word_result=word_result, current_step=9)
|
||||
if session_id in _cache:
|
||||
_cache[session_id]["word_result"] = word_result
|
||||
|
||||
logger.info(f"LLM review SSE session {session_id}: {event['corrections_found']} changes, "
|
||||
f"{event['duration_ms']}ms, skipped={event['skipped']}, model={event['model_used']}")
|
||||
|
||||
except Exception as e:
|
||||
import traceback
|
||||
logger.error(f"LLM review SSE failed for {session_id}: {type(e).__name__}: {e}\n{traceback.format_exc()}")
|
||||
error_event = {"type": "error", "detail": f"{type(e).__name__}: {e}"}
|
||||
yield f"data: {json.dumps(error_event)}\n\n"
|
||||
|
||||
|
||||
@router.post("/sessions/{session_id}/llm-review/apply")
|
||||
async def apply_llm_corrections(session_id: str, request: Request):
|
||||
"""Apply selected LLM corrections to vocab entries."""
|
||||
session = await get_session_db(session_id)
|
||||
if not session:
|
||||
raise HTTPException(status_code=404, detail=f"Session {session_id} not found")
|
||||
|
||||
word_result = session.get("word_result")
|
||||
if not word_result:
|
||||
raise HTTPException(status_code=400, detail="No word result found")
|
||||
|
||||
llm_review = word_result.get("llm_review")
|
||||
if not llm_review:
|
||||
raise HTTPException(status_code=400, detail="No LLM review found — run /llm-review first")
|
||||
|
||||
body = await request.json()
|
||||
accepted_indices = set(body.get("accepted_indices", [])) # indices into changes[]
|
||||
|
||||
changes = llm_review.get("changes", [])
|
||||
entries = word_result.get("vocab_entries") or word_result.get("entries") or []
|
||||
|
||||
# Build a lookup: (row_index, field) -> new_value for accepted changes
|
||||
corrections = {}
|
||||
applied_count = 0
|
||||
for idx, change in enumerate(changes):
|
||||
if idx in accepted_indices:
|
||||
key = (change["row_index"], change["field"])
|
||||
corrections[key] = change["new"]
|
||||
applied_count += 1
|
||||
|
||||
# Apply corrections to entries
|
||||
for entry in entries:
|
||||
row_idx = entry.get("row_index", -1)
|
||||
for field_name in ("english", "german", "example"):
|
||||
key = (row_idx, field_name)
|
||||
if key in corrections:
|
||||
entry[field_name] = corrections[key]
|
||||
entry["llm_corrected"] = True
|
||||
|
||||
# Update word_result
|
||||
word_result["vocab_entries"] = entries
|
||||
word_result["entries"] = entries
|
||||
word_result["llm_review"]["applied_count"] = applied_count
|
||||
word_result["llm_review"]["applied_at"] = datetime.utcnow().isoformat()
|
||||
|
||||
await update_session_db(session_id, word_result=word_result)
|
||||
|
||||
if session_id in _cache:
|
||||
_cache[session_id]["word_result"] = word_result
|
||||
|
||||
logger.info(f"Applied {applied_count}/{len(changes)} LLM corrections for session {session_id}")
|
||||
|
||||
return {
|
||||
"session_id": session_id,
|
||||
"applied_count": applied_count,
|
||||
"total_changes": len(changes),
|
||||
}
|
||||
@@ -1,10 +1,8 @@
|
||||
"""
|
||||
OCR Merge Helpers and Kombi Endpoints.
|
||||
OCR Merge Kombi Endpoints — paddle-kombi and rapid-kombi endpoints.
|
||||
|
||||
Contains merge helper functions for combining PaddleOCR/RapidOCR with Tesseract
|
||||
results, plus the paddle-kombi and rapid-kombi endpoints.
|
||||
|
||||
Extracted from ocr_pipeline_api.py for modularity.
|
||||
Merge helper functions live in ocr_merge_helpers.py.
|
||||
This module re-exports them for backward compatibility.
|
||||
|
||||
Lizenz: Apache 2.0
|
||||
DATENSCHUTZ: Alle Verarbeitung erfolgt lokal.
|
||||
@@ -12,10 +10,8 @@ DATENSCHUTZ: Alle Verarbeitung erfolgt lokal.
|
||||
|
||||
import logging
|
||||
import time
|
||||
from typing import Any, Dict, List
|
||||
|
||||
import cv2
|
||||
import httpx
|
||||
import numpy as np
|
||||
from fastapi import APIRouter, HTTPException
|
||||
|
||||
@@ -23,356 +19,23 @@ from cv_words_first import build_grid_from_words
|
||||
from ocr_pipeline_common import _cache, _append_pipeline_log
|
||||
from ocr_pipeline_session_store import get_session_image, update_session_db
|
||||
|
||||
# Re-export merge helpers for backward compatibility
|
||||
from ocr_merge_helpers import ( # noqa: F401
|
||||
_split_paddle_multi_words,
|
||||
_group_words_into_rows,
|
||||
_row_center_y,
|
||||
_merge_row_sequences,
|
||||
_merge_paddle_tesseract,
|
||||
_deduplicate_words,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/v1/ocr-pipeline", tags=["ocr-pipeline"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Merge helper functions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _split_paddle_multi_words(words: list) -> list:
|
||||
"""Split PaddleOCR multi-word boxes into individual word boxes.
|
||||
|
||||
PaddleOCR often returns entire phrases as a single box, e.g.
|
||||
"More than 200 singers took part in the" with one bounding box.
|
||||
This splits them into individual words with proportional widths.
|
||||
Also handles leading "!" (e.g. "!Betonung" → ["!", "Betonung"])
|
||||
and IPA brackets (e.g. "badge[bxd3]" → ["badge", "[bxd3]"]).
|
||||
"""
|
||||
import re
|
||||
|
||||
result = []
|
||||
for w in words:
|
||||
raw_text = w.get("text", "").strip()
|
||||
if not raw_text:
|
||||
continue
|
||||
# Split on whitespace, before "[" (IPA), and after "!" before letter
|
||||
tokens = re.split(
|
||||
r'\s+|(?=\[)|(?<=!)(?=[A-Za-z\u00c0-\u024f])', raw_text
|
||||
)
|
||||
tokens = [t for t in tokens if t]
|
||||
|
||||
if len(tokens) <= 1:
|
||||
result.append(w)
|
||||
else:
|
||||
# Split proportionally by character count
|
||||
total_chars = sum(len(t) for t in tokens)
|
||||
if total_chars == 0:
|
||||
continue
|
||||
n_gaps = len(tokens) - 1
|
||||
gap_px = w["width"] * 0.02
|
||||
usable_w = w["width"] - gap_px * n_gaps
|
||||
cursor = w["left"]
|
||||
for t in tokens:
|
||||
token_w = max(1, usable_w * len(t) / total_chars)
|
||||
result.append({
|
||||
"text": t,
|
||||
"left": round(cursor),
|
||||
"top": w["top"],
|
||||
"width": round(token_w),
|
||||
"height": w["height"],
|
||||
"conf": w.get("conf", 0),
|
||||
})
|
||||
cursor += token_w + gap_px
|
||||
return result
|
||||
|
||||
|
||||
def _group_words_into_rows(words: list, row_gap: int = 12) -> list:
|
||||
"""Group words into rows by Y-position clustering.
|
||||
|
||||
Words whose vertical centers are within `row_gap` pixels are on the same row.
|
||||
Returns list of rows, each row is a list of words sorted left-to-right.
|
||||
"""
|
||||
if not words:
|
||||
return []
|
||||
# Sort by vertical center
|
||||
sorted_words = sorted(words, key=lambda w: w["top"] + w.get("height", 0) / 2)
|
||||
rows: list = []
|
||||
current_row: list = [sorted_words[0]]
|
||||
current_cy = sorted_words[0]["top"] + sorted_words[0].get("height", 0) / 2
|
||||
|
||||
for w in sorted_words[1:]:
|
||||
cy = w["top"] + w.get("height", 0) / 2
|
||||
if abs(cy - current_cy) <= row_gap:
|
||||
current_row.append(w)
|
||||
else:
|
||||
# Sort current row left-to-right before saving
|
||||
rows.append(sorted(current_row, key=lambda w: w["left"]))
|
||||
current_row = [w]
|
||||
current_cy = cy
|
||||
if current_row:
|
||||
rows.append(sorted(current_row, key=lambda w: w["left"]))
|
||||
return rows
|
||||
|
||||
|
||||
def _row_center_y(row: list) -> float:
|
||||
"""Average vertical center of a row of words."""
|
||||
if not row:
|
||||
return 0.0
|
||||
return sum(w["top"] + w.get("height", 0) / 2 for w in row) / len(row)
|
||||
|
||||
|
||||
def _merge_row_sequences(paddle_row: list, tess_row: list) -> list:
|
||||
"""Merge two word sequences from the same row using sequence alignment.
|
||||
|
||||
Both sequences are sorted left-to-right. Walk through both simultaneously:
|
||||
- If words match (same/similar text): take Paddle text with averaged coords
|
||||
- If they don't match: the extra word is unique to one engine, include it
|
||||
|
||||
This prevents duplicates because both engines produce words in the same order.
|
||||
"""
|
||||
merged = []
|
||||
pi, ti = 0, 0
|
||||
|
||||
while pi < len(paddle_row) and ti < len(tess_row):
|
||||
pw = paddle_row[pi]
|
||||
tw = tess_row[ti]
|
||||
|
||||
# Check if these are the same word
|
||||
pt = pw.get("text", "").lower().strip()
|
||||
tt = tw.get("text", "").lower().strip()
|
||||
|
||||
# Same text or one contains the other
|
||||
is_same = (pt == tt) or (len(pt) > 1 and len(tt) > 1 and (pt in tt or tt in pt))
|
||||
|
||||
# Spatial overlap check: if words overlap >= 40% horizontally,
|
||||
# they're the same physical word regardless of OCR text differences.
|
||||
# (40% catches borderline cases like "Stick"/"Stück" at 48% overlap)
|
||||
spatial_match = False
|
||||
if not is_same:
|
||||
overlap_left = max(pw["left"], tw["left"])
|
||||
overlap_right = min(
|
||||
pw["left"] + pw.get("width", 0),
|
||||
tw["left"] + tw.get("width", 0),
|
||||
)
|
||||
overlap_w = max(0, overlap_right - overlap_left)
|
||||
min_w = min(pw.get("width", 1), tw.get("width", 1))
|
||||
if min_w > 0 and overlap_w / min_w >= 0.4:
|
||||
is_same = True
|
||||
spatial_match = True
|
||||
|
||||
if is_same:
|
||||
# Matched — average coordinates weighted by confidence
|
||||
pc = pw.get("conf", 80)
|
||||
tc = tw.get("conf", 50)
|
||||
total = pc + tc
|
||||
if total == 0:
|
||||
total = 1
|
||||
# Text: prefer higher-confidence engine when texts differ
|
||||
# (e.g. Tesseract "Stück" conf=98 vs PaddleOCR "Stick" conf=80)
|
||||
if spatial_match and pc < tc:
|
||||
best_text = tw["text"]
|
||||
else:
|
||||
best_text = pw["text"]
|
||||
merged.append({
|
||||
"text": best_text,
|
||||
"left": round((pw["left"] * pc + tw["left"] * tc) / total),
|
||||
"top": round((pw["top"] * pc + tw["top"] * tc) / total),
|
||||
"width": round((pw["width"] * pc + tw["width"] * tc) / total),
|
||||
"height": round((pw["height"] * pc + tw["height"] * tc) / total),
|
||||
"conf": max(pc, tc),
|
||||
})
|
||||
pi += 1
|
||||
ti += 1
|
||||
else:
|
||||
# Different text — one engine found something extra
|
||||
# Look ahead: is the current Paddle word somewhere in Tesseract ahead?
|
||||
paddle_ahead = any(
|
||||
tess_row[t].get("text", "").lower().strip() == pt
|
||||
for t in range(ti + 1, min(ti + 4, len(tess_row)))
|
||||
)
|
||||
# Is the current Tesseract word somewhere in Paddle ahead?
|
||||
tess_ahead = any(
|
||||
paddle_row[p].get("text", "").lower().strip() == tt
|
||||
for p in range(pi + 1, min(pi + 4, len(paddle_row)))
|
||||
)
|
||||
|
||||
if paddle_ahead and not tess_ahead:
|
||||
# Tesseract has an extra word (e.g. "!" or bullet) → include it
|
||||
if tw.get("conf", 0) >= 30:
|
||||
merged.append(tw)
|
||||
ti += 1
|
||||
elif tess_ahead and not paddle_ahead:
|
||||
# Paddle has an extra word → include it
|
||||
merged.append(pw)
|
||||
pi += 1
|
||||
else:
|
||||
# Both have unique words or neither found ahead → take leftmost first
|
||||
if pw["left"] <= tw["left"]:
|
||||
merged.append(pw)
|
||||
pi += 1
|
||||
else:
|
||||
if tw.get("conf", 0) >= 30:
|
||||
merged.append(tw)
|
||||
ti += 1
|
||||
|
||||
# Remaining words from either engine
|
||||
while pi < len(paddle_row):
|
||||
merged.append(paddle_row[pi])
|
||||
pi += 1
|
||||
while ti < len(tess_row):
|
||||
tw = tess_row[ti]
|
||||
if tw.get("conf", 0) >= 30:
|
||||
merged.append(tw)
|
||||
ti += 1
|
||||
|
||||
return merged
|
||||
|
||||
|
||||
def _merge_paddle_tesseract(paddle_words: list, tess_words: list) -> list:
|
||||
"""Merge word boxes from PaddleOCR and Tesseract using row-based sequence alignment.
|
||||
|
||||
Strategy:
|
||||
1. Group each engine's words into rows (by Y-position clustering)
|
||||
2. Match rows between engines (by vertical center proximity)
|
||||
3. Within each matched row: merge sequences left-to-right, deduplicating
|
||||
words that appear in both engines at the same sequence position
|
||||
4. Unmatched rows from either engine: keep as-is
|
||||
|
||||
This prevents:
|
||||
- Cross-line averaging (words from different lines being merged)
|
||||
- Duplicate words (same word from both engines shown twice)
|
||||
"""
|
||||
if not paddle_words and not tess_words:
|
||||
return []
|
||||
if not paddle_words:
|
||||
return [w for w in tess_words if w.get("conf", 0) >= 40]
|
||||
if not tess_words:
|
||||
return list(paddle_words)
|
||||
|
||||
# Step 1: Group into rows
|
||||
paddle_rows = _group_words_into_rows(paddle_words)
|
||||
tess_rows = _group_words_into_rows(tess_words)
|
||||
|
||||
# Step 2: Match rows between engines by vertical center proximity
|
||||
used_tess_rows: set = set()
|
||||
merged_all: list = []
|
||||
|
||||
for pr in paddle_rows:
|
||||
pr_cy = _row_center_y(pr)
|
||||
best_dist, best_tri = float("inf"), -1
|
||||
for tri, tr in enumerate(tess_rows):
|
||||
if tri in used_tess_rows:
|
||||
continue
|
||||
tr_cy = _row_center_y(tr)
|
||||
dist = abs(pr_cy - tr_cy)
|
||||
if dist < best_dist:
|
||||
best_dist, best_tri = dist, tri
|
||||
|
||||
# Row height threshold — rows must be within ~1.5x typical line height
|
||||
max_row_dist = max(
|
||||
max((w.get("height", 20) for w in pr), default=20),
|
||||
15,
|
||||
)
|
||||
|
||||
if best_tri >= 0 and best_dist <= max_row_dist:
|
||||
# Matched row — merge sequences
|
||||
tr = tess_rows[best_tri]
|
||||
used_tess_rows.add(best_tri)
|
||||
merged_all.extend(_merge_row_sequences(pr, tr))
|
||||
else:
|
||||
# No matching Tesseract row — keep Paddle row as-is
|
||||
merged_all.extend(pr)
|
||||
|
||||
# Add unmatched Tesseract rows
|
||||
for tri, tr in enumerate(tess_rows):
|
||||
if tri not in used_tess_rows:
|
||||
for tw in tr:
|
||||
if tw.get("conf", 0) >= 40:
|
||||
merged_all.append(tw)
|
||||
|
||||
return merged_all
|
||||
|
||||
|
||||
def _deduplicate_words(words: list) -> list:
|
||||
"""Remove duplicate words with same text at overlapping positions.
|
||||
|
||||
PaddleOCR can return overlapping phrases (e.g. "von jm." and "jm. =")
|
||||
that produce duplicate words after splitting. This pass removes them.
|
||||
|
||||
A word is a duplicate only when BOTH horizontal AND vertical overlap
|
||||
exceed 50% — same text on the same visual line at the same position.
|
||||
"""
|
||||
if not words:
|
||||
return words
|
||||
|
||||
result: list = []
|
||||
for w in words:
|
||||
wt = w.get("text", "").lower().strip()
|
||||
if not wt:
|
||||
continue
|
||||
is_dup = False
|
||||
w_right = w["left"] + w.get("width", 0)
|
||||
w_bottom = w["top"] + w.get("height", 0)
|
||||
for existing in result:
|
||||
et = existing.get("text", "").lower().strip()
|
||||
if wt != et:
|
||||
continue
|
||||
# Horizontal overlap
|
||||
ox_l = max(w["left"], existing["left"])
|
||||
ox_r = min(w_right, existing["left"] + existing.get("width", 0))
|
||||
ox = max(0, ox_r - ox_l)
|
||||
min_w = min(w.get("width", 1), existing.get("width", 1))
|
||||
if min_w <= 0 or ox / min_w < 0.5:
|
||||
continue
|
||||
# Vertical overlap — must also be on the same line
|
||||
oy_t = max(w["top"], existing["top"])
|
||||
oy_b = min(w_bottom, existing["top"] + existing.get("height", 0))
|
||||
oy = max(0, oy_b - oy_t)
|
||||
min_h = min(w.get("height", 1), existing.get("height", 1))
|
||||
if min_h > 0 and oy / min_h >= 0.5:
|
||||
is_dup = True
|
||||
break
|
||||
if not is_dup:
|
||||
result.append(w)
|
||||
|
||||
removed = len(words) - len(result)
|
||||
if removed:
|
||||
logger.info("dedup: removed %d duplicate words", removed)
|
||||
return result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Kombi endpoints
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.post("/sessions/{session_id}/paddle-kombi")
|
||||
async def paddle_kombi(session_id: str):
|
||||
"""Run PaddleOCR + Tesseract on the preprocessed image and merge results.
|
||||
|
||||
Both engines run on the same preprocessed (cropped/dewarped) image.
|
||||
Word boxes are matched by IoU and coordinates are averaged weighted by
|
||||
confidence. Unmatched Tesseract words (bullets, symbols) are added.
|
||||
"""
|
||||
img_png = await get_session_image(session_id, "cropped")
|
||||
if not img_png:
|
||||
img_png = await get_session_image(session_id, "dewarped")
|
||||
if not img_png:
|
||||
img_png = await get_session_image(session_id, "original")
|
||||
if not img_png:
|
||||
raise HTTPException(status_code=404, detail="No image found for this session")
|
||||
|
||||
img_arr = np.frombuffer(img_png, dtype=np.uint8)
|
||||
img_bgr = cv2.imdecode(img_arr, cv2.IMREAD_COLOR)
|
||||
if img_bgr is None:
|
||||
raise HTTPException(status_code=400, detail="Failed to decode image")
|
||||
|
||||
img_h, img_w = img_bgr.shape[:2]
|
||||
|
||||
from cv_ocr_engines import ocr_region_paddle
|
||||
|
||||
t0 = time.time()
|
||||
|
||||
# --- PaddleOCR ---
|
||||
paddle_words = await ocr_region_paddle(img_bgr, region=None)
|
||||
if not paddle_words:
|
||||
paddle_words = []
|
||||
|
||||
# --- Tesseract ---
|
||||
def _run_tesseract_words(img_bgr) -> list:
|
||||
"""Run Tesseract OCR on an image and return word dicts."""
|
||||
from PIL import Image
|
||||
import pytesseract
|
||||
|
||||
@@ -397,15 +60,98 @@ async def paddle_kombi(session_id: str):
|
||||
"height": data["height"][i],
|
||||
"conf": conf,
|
||||
})
|
||||
return tess_words
|
||||
|
||||
|
||||
def _build_kombi_word_result(
|
||||
cells: list,
|
||||
columns_meta: list,
|
||||
img_w: int,
|
||||
img_h: int,
|
||||
duration: float,
|
||||
engine_name: str,
|
||||
raw_engine_words: list,
|
||||
raw_engine_words_split: list,
|
||||
tess_words: list,
|
||||
merged_words: list,
|
||||
raw_engine_key: str = "raw_paddle_words",
|
||||
raw_split_key: str = "raw_paddle_words_split",
|
||||
) -> dict:
|
||||
"""Build the word_result dict for kombi endpoints."""
|
||||
n_rows = len(set(c["row_index"] for c in cells)) if cells else 0
|
||||
n_cols = len(columns_meta)
|
||||
col_types = {c.get("type") for c in columns_meta}
|
||||
is_vocab = bool(col_types & {"column_en", "column_de"})
|
||||
|
||||
return {
|
||||
"cells": cells,
|
||||
"grid_shape": {"rows": n_rows, "cols": n_cols, "total_cells": len(cells)},
|
||||
"columns_used": columns_meta,
|
||||
"layout": "vocab" if is_vocab else "generic",
|
||||
"image_width": img_w,
|
||||
"image_height": img_h,
|
||||
"duration_seconds": round(duration, 2),
|
||||
"ocr_engine": engine_name,
|
||||
"grid_method": engine_name,
|
||||
raw_engine_key: raw_engine_words,
|
||||
raw_split_key: raw_engine_words_split,
|
||||
"raw_tesseract_words": tess_words,
|
||||
"summary": {
|
||||
"total_cells": len(cells),
|
||||
"non_empty_cells": sum(1 for c in cells if c.get("text")),
|
||||
"low_confidence": sum(1 for c in cells if 0 < c.get("confidence", 0) < 50),
|
||||
raw_engine_key.replace("raw_", "").replace("_words", "_words"): len(raw_engine_words),
|
||||
raw_split_key.replace("raw_", "").replace("_words_split", "_words_split"): len(raw_engine_words_split),
|
||||
"tesseract_words": len(tess_words),
|
||||
"merged_words": len(merged_words),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
async def _load_session_image(session_id: str):
|
||||
"""Load preprocessed image for kombi endpoints."""
|
||||
img_png = await get_session_image(session_id, "cropped")
|
||||
if not img_png:
|
||||
img_png = await get_session_image(session_id, "dewarped")
|
||||
if not img_png:
|
||||
img_png = await get_session_image(session_id, "original")
|
||||
if not img_png:
|
||||
raise HTTPException(status_code=404, detail="No image found for this session")
|
||||
|
||||
img_arr = np.frombuffer(img_png, dtype=np.uint8)
|
||||
img_bgr = cv2.imdecode(img_arr, cv2.IMREAD_COLOR)
|
||||
if img_bgr is None:
|
||||
raise HTTPException(status_code=400, detail="Failed to decode image")
|
||||
|
||||
return img_png, img_bgr
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Kombi endpoints
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.post("/sessions/{session_id}/paddle-kombi")
|
||||
async def paddle_kombi(session_id: str):
|
||||
"""Run PaddleOCR + Tesseract on the preprocessed image and merge results."""
|
||||
img_png, img_bgr = await _load_session_image(session_id)
|
||||
img_h, img_w = img_bgr.shape[:2]
|
||||
|
||||
from cv_ocr_engines import ocr_region_paddle
|
||||
|
||||
t0 = time.time()
|
||||
|
||||
paddle_words = await ocr_region_paddle(img_bgr, region=None)
|
||||
if not paddle_words:
|
||||
paddle_words = []
|
||||
|
||||
tess_words = _run_tesseract_words(img_bgr)
|
||||
|
||||
# --- Split multi-word Paddle boxes into individual words ---
|
||||
paddle_words_split = _split_paddle_multi_words(paddle_words)
|
||||
logger.info(
|
||||
"paddle_kombi: split %d paddle boxes → %d individual words",
|
||||
"paddle_kombi: split %d paddle boxes -> %d individual words",
|
||||
len(paddle_words), len(paddle_words_split),
|
||||
)
|
||||
|
||||
# --- Merge ---
|
||||
if not paddle_words_split and not tess_words:
|
||||
raise HTTPException(status_code=400, detail="Both OCR engines returned no words")
|
||||
|
||||
@@ -418,49 +164,23 @@ async def paddle_kombi(session_id: str):
|
||||
for cell in cells:
|
||||
cell["ocr_engine"] = "kombi"
|
||||
|
||||
n_rows = len(set(c["row_index"] for c in cells)) if cells else 0
|
||||
n_cols = len(columns_meta)
|
||||
col_types = {c.get("type") for c in columns_meta}
|
||||
is_vocab = bool(col_types & {"column_en", "column_de"})
|
||||
|
||||
word_result = {
|
||||
"cells": cells,
|
||||
"grid_shape": {"rows": n_rows, "cols": n_cols, "total_cells": len(cells)},
|
||||
"columns_used": columns_meta,
|
||||
"layout": "vocab" if is_vocab else "generic",
|
||||
"image_width": img_w,
|
||||
"image_height": img_h,
|
||||
"duration_seconds": round(duration, 2),
|
||||
"ocr_engine": "kombi",
|
||||
"grid_method": "kombi",
|
||||
"raw_paddle_words": paddle_words,
|
||||
"raw_paddle_words_split": paddle_words_split,
|
||||
"raw_tesseract_words": tess_words,
|
||||
"summary": {
|
||||
"total_cells": len(cells),
|
||||
"non_empty_cells": sum(1 for c in cells if c.get("text")),
|
||||
"low_confidence": sum(1 for c in cells if 0 < c.get("confidence", 0) < 50),
|
||||
"paddle_words": len(paddle_words),
|
||||
"paddle_words_split": len(paddle_words_split),
|
||||
"tesseract_words": len(tess_words),
|
||||
"merged_words": len(merged_words),
|
||||
},
|
||||
}
|
||||
word_result = _build_kombi_word_result(
|
||||
cells, columns_meta, img_w, img_h, duration, "kombi",
|
||||
paddle_words, paddle_words_split, tess_words, merged_words,
|
||||
"raw_paddle_words", "raw_paddle_words_split",
|
||||
)
|
||||
|
||||
await update_session_db(
|
||||
session_id,
|
||||
word_result=word_result,
|
||||
cropped_png=img_png,
|
||||
current_step=8,
|
||||
session_id, word_result=word_result, cropped_png=img_png, current_step=8,
|
||||
)
|
||||
# Update in-memory cache so detect-structure can access word_result
|
||||
if session_id in _cache:
|
||||
_cache[session_id]["word_result"] = word_result
|
||||
|
||||
logger.info(
|
||||
"paddle_kombi session %s: %d cells (%d rows, %d cols) in %.2fs "
|
||||
"[paddle=%d, tess=%d, merged=%d]",
|
||||
session_id, len(cells), n_rows, n_cols, duration,
|
||||
session_id, len(cells), word_result["grid_shape"]["rows"],
|
||||
word_result["grid_shape"]["cols"], duration,
|
||||
len(paddle_words), len(tess_words), len(merged_words),
|
||||
)
|
||||
|
||||
@@ -478,24 +198,8 @@ async def paddle_kombi(session_id: str):
|
||||
|
||||
@router.post("/sessions/{session_id}/rapid-kombi")
|
||||
async def rapid_kombi(session_id: str):
|
||||
"""Run RapidOCR + Tesseract on the preprocessed image and merge results.
|
||||
|
||||
Same merge logic as paddle-kombi, but uses local RapidOCR (ONNX Runtime)
|
||||
instead of remote PaddleOCR service.
|
||||
"""
|
||||
img_png = await get_session_image(session_id, "cropped")
|
||||
if not img_png:
|
||||
img_png = await get_session_image(session_id, "dewarped")
|
||||
if not img_png:
|
||||
img_png = await get_session_image(session_id, "original")
|
||||
if not img_png:
|
||||
raise HTTPException(status_code=404, detail="No image found for this session")
|
||||
|
||||
img_arr = np.frombuffer(img_png, dtype=np.uint8)
|
||||
img_bgr = cv2.imdecode(img_arr, cv2.IMREAD_COLOR)
|
||||
if img_bgr is None:
|
||||
raise HTTPException(status_code=400, detail="Failed to decode image")
|
||||
|
||||
"""Run RapidOCR + Tesseract on the preprocessed image and merge results."""
|
||||
img_png, img_bgr = await _load_session_image(session_id)
|
||||
img_h, img_w = img_bgr.shape[:2]
|
||||
|
||||
from cv_ocr_engines import ocr_region_rapid
|
||||
@@ -503,7 +207,6 @@ async def rapid_kombi(session_id: str):
|
||||
|
||||
t0 = time.time()
|
||||
|
||||
# --- RapidOCR (local, synchronous) ---
|
||||
full_region = PageRegion(
|
||||
type="full_page", x=0, y=0, width=img_w, height=img_h,
|
||||
)
|
||||
@@ -511,40 +214,14 @@ async def rapid_kombi(session_id: str):
|
||||
if not rapid_words:
|
||||
rapid_words = []
|
||||
|
||||
# --- Tesseract ---
|
||||
from PIL import Image
|
||||
import pytesseract
|
||||
tess_words = _run_tesseract_words(img_bgr)
|
||||
|
||||
pil_img = Image.fromarray(cv2.cvtColor(img_bgr, cv2.COLOR_BGR2RGB))
|
||||
data = pytesseract.image_to_data(
|
||||
pil_img, lang="eng+deu",
|
||||
config="--psm 6 --oem 3",
|
||||
output_type=pytesseract.Output.DICT,
|
||||
)
|
||||
tess_words = []
|
||||
for i in range(len(data["text"])):
|
||||
text = str(data["text"][i]).strip()
|
||||
conf_raw = str(data["conf"][i])
|
||||
conf = int(conf_raw) if conf_raw.lstrip("-").isdigit() else -1
|
||||
if not text or conf < 20:
|
||||
continue
|
||||
tess_words.append({
|
||||
"text": text,
|
||||
"left": data["left"][i],
|
||||
"top": data["top"][i],
|
||||
"width": data["width"][i],
|
||||
"height": data["height"][i],
|
||||
"conf": conf,
|
||||
})
|
||||
|
||||
# --- Split multi-word RapidOCR boxes into individual words ---
|
||||
rapid_words_split = _split_paddle_multi_words(rapid_words)
|
||||
logger.info(
|
||||
"rapid_kombi: split %d rapid boxes → %d individual words",
|
||||
"rapid_kombi: split %d rapid boxes -> %d individual words",
|
||||
len(rapid_words), len(rapid_words_split),
|
||||
)
|
||||
|
||||
# --- Merge ---
|
||||
if not rapid_words_split and not tess_words:
|
||||
raise HTTPException(status_code=400, detail="Both OCR engines returned no words")
|
||||
|
||||
@@ -557,49 +234,23 @@ async def rapid_kombi(session_id: str):
|
||||
for cell in cells:
|
||||
cell["ocr_engine"] = "rapid_kombi"
|
||||
|
||||
n_rows = len(set(c["row_index"] for c in cells)) if cells else 0
|
||||
n_cols = len(columns_meta)
|
||||
col_types = {c.get("type") for c in columns_meta}
|
||||
is_vocab = bool(col_types & {"column_en", "column_de"})
|
||||
|
||||
word_result = {
|
||||
"cells": cells,
|
||||
"grid_shape": {"rows": n_rows, "cols": n_cols, "total_cells": len(cells)},
|
||||
"columns_used": columns_meta,
|
||||
"layout": "vocab" if is_vocab else "generic",
|
||||
"image_width": img_w,
|
||||
"image_height": img_h,
|
||||
"duration_seconds": round(duration, 2),
|
||||
"ocr_engine": "rapid_kombi",
|
||||
"grid_method": "rapid_kombi",
|
||||
"raw_rapid_words": rapid_words,
|
||||
"raw_rapid_words_split": rapid_words_split,
|
||||
"raw_tesseract_words": tess_words,
|
||||
"summary": {
|
||||
"total_cells": len(cells),
|
||||
"non_empty_cells": sum(1 for c in cells if c.get("text")),
|
||||
"low_confidence": sum(1 for c in cells if 0 < c.get("confidence", 0) < 50),
|
||||
"rapid_words": len(rapid_words),
|
||||
"rapid_words_split": len(rapid_words_split),
|
||||
"tesseract_words": len(tess_words),
|
||||
"merged_words": len(merged_words),
|
||||
},
|
||||
}
|
||||
word_result = _build_kombi_word_result(
|
||||
cells, columns_meta, img_w, img_h, duration, "rapid_kombi",
|
||||
rapid_words, rapid_words_split, tess_words, merged_words,
|
||||
"raw_rapid_words", "raw_rapid_words_split",
|
||||
)
|
||||
|
||||
await update_session_db(
|
||||
session_id,
|
||||
word_result=word_result,
|
||||
cropped_png=img_png,
|
||||
current_step=8,
|
||||
session_id, word_result=word_result, cropped_png=img_png, current_step=8,
|
||||
)
|
||||
# Update in-memory cache so detect-structure can access word_result
|
||||
if session_id in _cache:
|
||||
_cache[session_id]["word_result"] = word_result
|
||||
|
||||
logger.info(
|
||||
"rapid_kombi session %s: %d cells (%d rows, %d cols) in %.2fs "
|
||||
"[rapid=%d, tess=%d, merged=%d]",
|
||||
session_id, len(cells), n_rows, n_cols, duration,
|
||||
session_id, len(cells), word_result["grid_shape"]["rows"],
|
||||
word_result["grid_shape"]["cols"], duration,
|
||||
len(rapid_words), len(tess_words), len(merged_words),
|
||||
)
|
||||
|
||||
|
||||
@@ -1,929 +1,26 @@
|
||||
"""
|
||||
OCR Pipeline Postprocessing API — LLM review, reconstruction, export, validation,
|
||||
image detection/generation, and handwriting removal endpoints.
|
||||
OCR Pipeline Postprocessing API — composite router assembling LLM review,
|
||||
reconstruction, export, validation, image detection/generation, and
|
||||
handwriting removal endpoints.
|
||||
|
||||
Extracted from ocr_pipeline_api.py to keep the main module manageable.
|
||||
Split into sub-modules:
|
||||
ocr_pipeline_llm_review — LLM review + apply corrections
|
||||
ocr_pipeline_reconstruction — reconstruction save, Fabric JSON, merged entries, PDF/DOCX
|
||||
ocr_pipeline_validation — image detection, generation, validation, handwriting removal
|
||||
|
||||
Lizenz: Apache 2.0
|
||||
DATENSCHUTZ: Alle Verarbeitung erfolgt lokal.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import StreamingResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
from cv_vocab_pipeline import (
|
||||
OLLAMA_REVIEW_MODEL,
|
||||
llm_review_entries,
|
||||
llm_review_entries_streaming,
|
||||
)
|
||||
from ocr_pipeline_session_store import (
|
||||
get_session_db,
|
||||
get_session_image,
|
||||
get_sub_sessions,
|
||||
update_session_db,
|
||||
)
|
||||
from ocr_pipeline_common import (
|
||||
_cache,
|
||||
_load_session_to_cache,
|
||||
_get_cached,
|
||||
_get_base_image_png,
|
||||
_append_pipeline_log,
|
||||
RemoveHandwritingRequest,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/v1/ocr-pipeline", tags=["ocr-pipeline"])
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pydantic Models
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
STYLE_SUFFIXES = {
|
||||
"educational": "educational illustration, textbook style, clear, colorful",
|
||||
"cartoon": "cartoon, child-friendly, simple shapes",
|
||||
"sketch": "pencil sketch, hand-drawn, black and white",
|
||||
"clipart": "clipart, flat vector style, simple",
|
||||
"realistic": "photorealistic, high detail",
|
||||
}
|
||||
|
||||
|
||||
class ValidationRequest(BaseModel):
|
||||
notes: Optional[str] = None
|
||||
score: Optional[int] = None
|
||||
|
||||
|
||||
class GenerateImageRequest(BaseModel):
|
||||
region_index: int
|
||||
prompt: str
|
||||
style: str = "educational"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Step 8: LLM Review
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.post("/sessions/{session_id}/llm-review")
|
||||
async def run_llm_review(session_id: str, request: Request, stream: bool = False):
|
||||
"""Run LLM-based correction on vocab entries from Step 5.
|
||||
|
||||
Query params:
|
||||
stream: false (default) for JSON response, true for SSE streaming
|
||||
"""
|
||||
session = await get_session_db(session_id)
|
||||
if not session:
|
||||
raise HTTPException(status_code=404, detail=f"Session {session_id} not found")
|
||||
|
||||
word_result = session.get("word_result")
|
||||
if not word_result:
|
||||
raise HTTPException(status_code=400, detail="No word result found — run Step 5 first")
|
||||
|
||||
entries = word_result.get("vocab_entries") or word_result.get("entries") or []
|
||||
if not entries:
|
||||
raise HTTPException(status_code=400, detail="No vocab entries found — run Step 5 first")
|
||||
|
||||
# Optional model override from request body
|
||||
body = {}
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
pass
|
||||
model = body.get("model") or OLLAMA_REVIEW_MODEL
|
||||
|
||||
if stream:
|
||||
return StreamingResponse(
|
||||
_llm_review_stream_generator(session_id, entries, word_result, model, request),
|
||||
media_type="text/event-stream",
|
||||
headers={"Cache-Control": "no-cache", "Connection": "keep-alive", "X-Accel-Buffering": "no"},
|
||||
)
|
||||
|
||||
# Non-streaming path
|
||||
try:
|
||||
result = await llm_review_entries(entries, model=model)
|
||||
except Exception as e:
|
||||
import traceback
|
||||
logger.error(f"LLM review failed for session {session_id}: {type(e).__name__}: {e}\n{traceback.format_exc()}")
|
||||
raise HTTPException(status_code=502, detail=f"LLM review failed ({type(e).__name__}): {e}")
|
||||
|
||||
# Store result inside word_result as a sub-key
|
||||
word_result["llm_review"] = {
|
||||
"changes": result["changes"],
|
||||
"model_used": result["model_used"],
|
||||
"duration_ms": result["duration_ms"],
|
||||
"entries_corrected": result["entries_corrected"],
|
||||
}
|
||||
await update_session_db(session_id, word_result=word_result, current_step=9)
|
||||
|
||||
if session_id in _cache:
|
||||
_cache[session_id]["word_result"] = word_result
|
||||
|
||||
logger.info(f"LLM review session {session_id}: {len(result['changes'])} changes, "
|
||||
f"{result['duration_ms']}ms, model={result['model_used']}")
|
||||
|
||||
await _append_pipeline_log(session_id, "correction", {
|
||||
"engine": "llm",
|
||||
"model": result["model_used"],
|
||||
"total_entries": len(entries),
|
||||
"corrections_proposed": len(result["changes"]),
|
||||
}, duration_ms=result["duration_ms"])
|
||||
|
||||
return {
|
||||
"session_id": session_id,
|
||||
"changes": result["changes"],
|
||||
"model_used": result["model_used"],
|
||||
"duration_ms": result["duration_ms"],
|
||||
"total_entries": len(entries),
|
||||
"corrections_found": len(result["changes"]),
|
||||
}
|
||||
|
||||
|
||||
async def _llm_review_stream_generator(
|
||||
session_id: str,
|
||||
entries: List[Dict],
|
||||
word_result: Dict,
|
||||
model: str,
|
||||
request: Request,
|
||||
):
|
||||
"""SSE generator that yields batch-by-batch LLM review progress."""
|
||||
try:
|
||||
async for event in llm_review_entries_streaming(entries, model=model):
|
||||
if await request.is_disconnected():
|
||||
logger.info(f"SSE: client disconnected during LLM review for {session_id}")
|
||||
return
|
||||
|
||||
yield f"data: {json.dumps(event, ensure_ascii=False)}\n\n"
|
||||
|
||||
# On complete: persist to DB
|
||||
if event.get("type") == "complete":
|
||||
word_result["llm_review"] = {
|
||||
"changes": event["changes"],
|
||||
"model_used": event["model_used"],
|
||||
"duration_ms": event["duration_ms"],
|
||||
"entries_corrected": event["entries_corrected"],
|
||||
}
|
||||
await update_session_db(session_id, word_result=word_result, current_step=9)
|
||||
if session_id in _cache:
|
||||
_cache[session_id]["word_result"] = word_result
|
||||
|
||||
logger.info(f"LLM review SSE session {session_id}: {event['corrections_found']} changes, "
|
||||
f"{event['duration_ms']}ms, skipped={event['skipped']}, model={event['model_used']}")
|
||||
|
||||
except Exception as e:
|
||||
import traceback
|
||||
logger.error(f"LLM review SSE failed for {session_id}: {type(e).__name__}: {e}\n{traceback.format_exc()}")
|
||||
error_event = {"type": "error", "detail": f"{type(e).__name__}: {e}"}
|
||||
yield f"data: {json.dumps(error_event)}\n\n"
|
||||
|
||||
|
||||
@router.post("/sessions/{session_id}/llm-review/apply")
|
||||
async def apply_llm_corrections(session_id: str, request: Request):
|
||||
"""Apply selected LLM corrections to vocab entries."""
|
||||
session = await get_session_db(session_id)
|
||||
if not session:
|
||||
raise HTTPException(status_code=404, detail=f"Session {session_id} not found")
|
||||
|
||||
word_result = session.get("word_result")
|
||||
if not word_result:
|
||||
raise HTTPException(status_code=400, detail="No word result found")
|
||||
|
||||
llm_review = word_result.get("llm_review")
|
||||
if not llm_review:
|
||||
raise HTTPException(status_code=400, detail="No LLM review found — run /llm-review first")
|
||||
|
||||
body = await request.json()
|
||||
accepted_indices = set(body.get("accepted_indices", [])) # indices into changes[]
|
||||
|
||||
changes = llm_review.get("changes", [])
|
||||
entries = word_result.get("vocab_entries") or word_result.get("entries") or []
|
||||
|
||||
# Build a lookup: (row_index, field) -> new_value for accepted changes
|
||||
corrections = {}
|
||||
applied_count = 0
|
||||
for idx, change in enumerate(changes):
|
||||
if idx in accepted_indices:
|
||||
key = (change["row_index"], change["field"])
|
||||
corrections[key] = change["new"]
|
||||
applied_count += 1
|
||||
|
||||
# Apply corrections to entries
|
||||
for entry in entries:
|
||||
row_idx = entry.get("row_index", -1)
|
||||
for field_name in ("english", "german", "example"):
|
||||
key = (row_idx, field_name)
|
||||
if key in corrections:
|
||||
entry[field_name] = corrections[key]
|
||||
entry["llm_corrected"] = True
|
||||
|
||||
# Update word_result
|
||||
word_result["vocab_entries"] = entries
|
||||
word_result["entries"] = entries
|
||||
word_result["llm_review"]["applied_count"] = applied_count
|
||||
word_result["llm_review"]["applied_at"] = datetime.utcnow().isoformat()
|
||||
|
||||
await update_session_db(session_id, word_result=word_result)
|
||||
|
||||
if session_id in _cache:
|
||||
_cache[session_id]["word_result"] = word_result
|
||||
|
||||
logger.info(f"Applied {applied_count}/{len(changes)} LLM corrections for session {session_id}")
|
||||
|
||||
return {
|
||||
"session_id": session_id,
|
||||
"applied_count": applied_count,
|
||||
"total_changes": len(changes),
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Step 9: Reconstruction + Fabric JSON export
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.post("/sessions/{session_id}/reconstruction")
|
||||
async def save_reconstruction(session_id: str, request: Request):
|
||||
"""Save edited cell texts from reconstruction step."""
|
||||
session = await get_session_db(session_id)
|
||||
if not session:
|
||||
raise HTTPException(status_code=404, detail=f"Session {session_id} not found")
|
||||
|
||||
word_result = session.get("word_result")
|
||||
if not word_result:
|
||||
raise HTTPException(status_code=400, detail="No word result found")
|
||||
|
||||
body = await request.json()
|
||||
cell_updates = body.get("cells", [])
|
||||
|
||||
if not cell_updates:
|
||||
await update_session_db(session_id, current_step=10)
|
||||
return {"session_id": session_id, "updated": 0}
|
||||
|
||||
# Build update map: cell_id -> new text
|
||||
update_map = {c["cell_id"]: c["text"] for c in cell_updates}
|
||||
|
||||
# Separate sub-session updates (cell_ids prefixed with "box{N}_")
|
||||
sub_updates: Dict[int, Dict[str, str]] = {} # box_index -> {original_cell_id: text}
|
||||
main_updates: Dict[str, str] = {}
|
||||
for cell_id, text in update_map.items():
|
||||
m = re.match(r'^box(\d+)_(.+)$', cell_id)
|
||||
if m:
|
||||
bi = int(m.group(1))
|
||||
original_id = m.group(2)
|
||||
sub_updates.setdefault(bi, {})[original_id] = text
|
||||
else:
|
||||
main_updates[cell_id] = text
|
||||
|
||||
# Update main session cells
|
||||
cells = word_result.get("cells", [])
|
||||
updated_count = 0
|
||||
for cell in cells:
|
||||
if cell["cell_id"] in main_updates:
|
||||
cell["text"] = main_updates[cell["cell_id"]]
|
||||
cell["status"] = "edited"
|
||||
updated_count += 1
|
||||
|
||||
word_result["cells"] = cells
|
||||
|
||||
# Also update vocab_entries if present
|
||||
entries = word_result.get("vocab_entries") or word_result.get("entries") or []
|
||||
if entries:
|
||||
# Map cell_id pattern "R{row}_C{col}" to entry fields
|
||||
for entry in entries:
|
||||
row_idx = entry.get("row_index", -1)
|
||||
# Check each field's cell
|
||||
for col_idx, field_name in enumerate(["english", "german", "example"]):
|
||||
cell_id = f"R{row_idx:02d}_C{col_idx}"
|
||||
# Also try without zero-padding
|
||||
cell_id_alt = f"R{row_idx}_C{col_idx}"
|
||||
new_text = main_updates.get(cell_id) or main_updates.get(cell_id_alt)
|
||||
if new_text is not None:
|
||||
entry[field_name] = new_text
|
||||
|
||||
word_result["vocab_entries"] = entries
|
||||
if "entries" in word_result:
|
||||
word_result["entries"] = entries
|
||||
|
||||
await update_session_db(session_id, word_result=word_result, current_step=10)
|
||||
|
||||
if session_id in _cache:
|
||||
_cache[session_id]["word_result"] = word_result
|
||||
|
||||
# Route sub-session updates
|
||||
sub_updated = 0
|
||||
if sub_updates:
|
||||
subs = await get_sub_sessions(session_id)
|
||||
sub_by_index = {s.get("box_index"): s["id"] for s in subs}
|
||||
for bi, updates in sub_updates.items():
|
||||
sub_id = sub_by_index.get(bi)
|
||||
if not sub_id:
|
||||
continue
|
||||
sub_session = await get_session_db(sub_id)
|
||||
if not sub_session:
|
||||
continue
|
||||
sub_word = sub_session.get("word_result")
|
||||
if not sub_word:
|
||||
continue
|
||||
sub_cells = sub_word.get("cells", [])
|
||||
for cell in sub_cells:
|
||||
if cell["cell_id"] in updates:
|
||||
cell["text"] = updates[cell["cell_id"]]
|
||||
cell["status"] = "edited"
|
||||
sub_updated += 1
|
||||
sub_word["cells"] = sub_cells
|
||||
await update_session_db(sub_id, word_result=sub_word)
|
||||
if sub_id in _cache:
|
||||
_cache[sub_id]["word_result"] = sub_word
|
||||
|
||||
total_updated = updated_count + sub_updated
|
||||
logger.info(f"Reconstruction saved for session {session_id}: "
|
||||
f"{updated_count} main + {sub_updated} sub-session cells updated")
|
||||
|
||||
return {
|
||||
"session_id": session_id,
|
||||
"updated": total_updated,
|
||||
"main_updated": updated_count,
|
||||
"sub_updated": sub_updated,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/sessions/{session_id}/reconstruction/fabric-json")
|
||||
async def get_fabric_json(session_id: str):
|
||||
"""Return cell grid as Fabric.js-compatible JSON for the canvas editor.
|
||||
|
||||
If the session has sub-sessions (box regions), their cells are merged
|
||||
into the result at the correct Y positions.
|
||||
"""
|
||||
session = await get_session_db(session_id)
|
||||
if not session:
|
||||
raise HTTPException(status_code=404, detail=f"Session {session_id} not found")
|
||||
|
||||
word_result = session.get("word_result")
|
||||
if not word_result:
|
||||
raise HTTPException(status_code=400, detail="No word result found")
|
||||
|
||||
cells = list(word_result.get("cells", []))
|
||||
img_w = word_result.get("image_width", 800)
|
||||
img_h = word_result.get("image_height", 600)
|
||||
|
||||
# Merge sub-session cells at box positions
|
||||
subs = await get_sub_sessions(session_id)
|
||||
if subs:
|
||||
column_result = session.get("column_result") or {}
|
||||
zones = column_result.get("zones") or []
|
||||
box_zones = [z for z in zones if z.get("zone_type") == "box" and z.get("box")]
|
||||
|
||||
for sub in subs:
|
||||
sub_session = await get_session_db(sub["id"])
|
||||
if not sub_session:
|
||||
continue
|
||||
sub_word = sub_session.get("word_result")
|
||||
if not sub_word or not sub_word.get("cells"):
|
||||
continue
|
||||
|
||||
bi = sub.get("box_index", 0)
|
||||
if bi < len(box_zones):
|
||||
box = box_zones[bi]["box"]
|
||||
box_y, box_x = box["y"], box["x"]
|
||||
else:
|
||||
box_y, box_x = 0, 0
|
||||
|
||||
# Offset sub-session cells to absolute page coordinates
|
||||
for cell in sub_word["cells"]:
|
||||
cell_copy = dict(cell)
|
||||
# Prefix cell_id with box index
|
||||
cell_copy["cell_id"] = f"box{bi}_{cell_copy.get('cell_id', '')}"
|
||||
cell_copy["source"] = f"box_{bi}"
|
||||
# Offset bbox_px
|
||||
bbox = cell_copy.get("bbox_px", {})
|
||||
if bbox:
|
||||
bbox = dict(bbox)
|
||||
bbox["x"] = bbox.get("x", 0) + box_x
|
||||
bbox["y"] = bbox.get("y", 0) + box_y
|
||||
cell_copy["bbox_px"] = bbox
|
||||
cells.append(cell_copy)
|
||||
|
||||
from services.layout_reconstruction_service import cells_to_fabric_json
|
||||
fabric_json = cells_to_fabric_json(cells, img_w, img_h)
|
||||
|
||||
return fabric_json
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Vocab entries merged + PDF/DOCX export
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.get("/sessions/{session_id}/vocab-entries/merged")
|
||||
async def get_merged_vocab_entries(session_id: str):
|
||||
"""Return vocab entries from main session + all sub-sessions, sorted by Y position."""
|
||||
session = await get_session_db(session_id)
|
||||
if not session:
|
||||
raise HTTPException(status_code=404, detail=f"Session {session_id} not found")
|
||||
|
||||
word_result = session.get("word_result") or {}
|
||||
entries = list(word_result.get("vocab_entries") or word_result.get("entries") or [])
|
||||
|
||||
# Tag main entries
|
||||
for e in entries:
|
||||
e.setdefault("source", "main")
|
||||
|
||||
# Merge sub-session entries
|
||||
subs = await get_sub_sessions(session_id)
|
||||
if subs:
|
||||
column_result = session.get("column_result") or {}
|
||||
zones = column_result.get("zones") or []
|
||||
box_zones = [z for z in zones if z.get("zone_type") == "box" and z.get("box")]
|
||||
|
||||
for sub in subs:
|
||||
sub_session = await get_session_db(sub["id"])
|
||||
if not sub_session:
|
||||
continue
|
||||
sub_word = sub_session.get("word_result") or {}
|
||||
sub_entries = sub_word.get("vocab_entries") or sub_word.get("entries") or []
|
||||
|
||||
bi = sub.get("box_index", 0)
|
||||
box_y = 0
|
||||
if bi < len(box_zones):
|
||||
box_y = box_zones[bi]["box"]["y"]
|
||||
|
||||
for e in sub_entries:
|
||||
e_copy = dict(e)
|
||||
e_copy["source"] = f"box_{bi}"
|
||||
e_copy["source_y"] = box_y # for sorting
|
||||
entries.append(e_copy)
|
||||
|
||||
# Sort by approximate Y position
|
||||
def _sort_key(e):
|
||||
if e.get("source", "main") == "main":
|
||||
return e.get("row_index", 0) * 100 # main entries by row index
|
||||
return e.get("source_y", 0) * 100 + e.get("row_index", 0)
|
||||
|
||||
entries.sort(key=_sort_key)
|
||||
|
||||
return {
|
||||
"session_id": session_id,
|
||||
"entries": entries,
|
||||
"total": len(entries),
|
||||
"sources": list(set(e.get("source", "main") for e in entries)),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/sessions/{session_id}/reconstruction/export/pdf")
|
||||
async def export_reconstruction_pdf(session_id: str):
|
||||
"""Export the reconstructed cell grid as a PDF table."""
|
||||
session = await get_session_db(session_id)
|
||||
if not session:
|
||||
raise HTTPException(status_code=404, detail=f"Session {session_id} not found")
|
||||
|
||||
word_result = session.get("word_result")
|
||||
if not word_result:
|
||||
raise HTTPException(status_code=400, detail="No word result found")
|
||||
|
||||
cells = word_result.get("cells", [])
|
||||
columns_used = word_result.get("columns_used", [])
|
||||
grid_shape = word_result.get("grid_shape", {})
|
||||
n_rows = grid_shape.get("rows", 0)
|
||||
n_cols = grid_shape.get("cols", 0)
|
||||
|
||||
# Build table data: rows x columns
|
||||
table_data: list[list[str]] = []
|
||||
header = [c.get("label", c.get("type", f"Col {i}")) for i, c in enumerate(columns_used)]
|
||||
if not header:
|
||||
header = [f"Col {i}" for i in range(n_cols)]
|
||||
table_data.append(header)
|
||||
|
||||
for r in range(n_rows):
|
||||
row_texts = []
|
||||
for ci in range(n_cols):
|
||||
cell_id = f"R{r:02d}_C{ci}"
|
||||
cell = next((c for c in cells if c.get("cell_id") == cell_id), None)
|
||||
row_texts.append(cell.get("text", "") if cell else "")
|
||||
table_data.append(row_texts)
|
||||
|
||||
# Generate PDF with reportlab
|
||||
try:
|
||||
from reportlab.lib.pagesizes import A4
|
||||
from reportlab.lib import colors
|
||||
from reportlab.platypus import SimpleDocTemplate, Table, TableStyle
|
||||
import io as _io
|
||||
|
||||
buf = _io.BytesIO()
|
||||
doc = SimpleDocTemplate(buf, pagesize=A4)
|
||||
if not table_data or not table_data[0]:
|
||||
raise HTTPException(status_code=400, detail="No data to export")
|
||||
|
||||
t = Table(table_data)
|
||||
t.setStyle(TableStyle([
|
||||
('BACKGROUND', (0, 0), (-1, 0), colors.HexColor('#0d9488')),
|
||||
('TEXTCOLOR', (0, 0), (-1, 0), colors.white),
|
||||
('FONTSIZE', (0, 0), (-1, -1), 9),
|
||||
('GRID', (0, 0), (-1, -1), 0.5, colors.grey),
|
||||
('VALIGN', (0, 0), (-1, -1), 'TOP'),
|
||||
('WORDWRAP', (0, 0), (-1, -1), True),
|
||||
]))
|
||||
doc.build([t])
|
||||
buf.seek(0)
|
||||
|
||||
from fastapi.responses import StreamingResponse
|
||||
return StreamingResponse(
|
||||
buf,
|
||||
media_type="application/pdf",
|
||||
headers={"Content-Disposition": f'attachment; filename="reconstruction_{session_id}.pdf"'},
|
||||
)
|
||||
except ImportError:
|
||||
raise HTTPException(status_code=501, detail="reportlab not installed")
|
||||
|
||||
|
||||
@router.get("/sessions/{session_id}/reconstruction/export/docx")
|
||||
async def export_reconstruction_docx(session_id: str):
|
||||
"""Export the reconstructed cell grid as a DOCX table."""
|
||||
session = await get_session_db(session_id)
|
||||
if not session:
|
||||
raise HTTPException(status_code=404, detail=f"Session {session_id} not found")
|
||||
|
||||
word_result = session.get("word_result")
|
||||
if not word_result:
|
||||
raise HTTPException(status_code=400, detail="No word result found")
|
||||
|
||||
cells = word_result.get("cells", [])
|
||||
columns_used = word_result.get("columns_used", [])
|
||||
grid_shape = word_result.get("grid_shape", {})
|
||||
n_rows = grid_shape.get("rows", 0)
|
||||
n_cols = grid_shape.get("cols", 0)
|
||||
|
||||
try:
|
||||
from docx import Document
|
||||
from docx.shared import Pt
|
||||
import io as _io
|
||||
|
||||
doc = Document()
|
||||
doc.add_heading(f'Rekonstruktion – Session {session_id[:8]}', level=1)
|
||||
|
||||
# Build header
|
||||
header = [c.get("label", c.get("type", f"Col {i}")) for i, c in enumerate(columns_used)]
|
||||
if not header:
|
||||
header = [f"Col {i}" for i in range(n_cols)]
|
||||
|
||||
table = doc.add_table(rows=1 + n_rows, cols=max(n_cols, 1))
|
||||
table.style = 'Table Grid'
|
||||
|
||||
# Header row
|
||||
for ci, h in enumerate(header):
|
||||
table.rows[0].cells[ci].text = h
|
||||
|
||||
# Data rows
|
||||
for r in range(n_rows):
|
||||
for ci in range(n_cols):
|
||||
cell_id = f"R{r:02d}_C{ci}"
|
||||
cell = next((c for c in cells if c.get("cell_id") == cell_id), None)
|
||||
table.rows[r + 1].cells[ci].text = cell.get("text", "") if cell else ""
|
||||
|
||||
buf = _io.BytesIO()
|
||||
doc.save(buf)
|
||||
buf.seek(0)
|
||||
|
||||
from fastapi.responses import StreamingResponse
|
||||
return StreamingResponse(
|
||||
buf,
|
||||
media_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
headers={"Content-Disposition": f'attachment; filename="reconstruction_{session_id}.docx"'},
|
||||
)
|
||||
except ImportError:
|
||||
raise HTTPException(status_code=501, detail="python-docx not installed")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Step 8: Validation — Original vs. Reconstruction
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.post("/sessions/{session_id}/reconstruction/detect-images")
|
||||
async def detect_image_regions(session_id: str):
|
||||
"""Detect illustration/image regions in the original scan using VLM.
|
||||
|
||||
Sends the original image to qwen2.5vl to find non-text, non-table
|
||||
image areas, returning bounding boxes (in %) and descriptions.
|
||||
"""
|
||||
import base64
|
||||
import httpx
|
||||
import re
|
||||
|
||||
session = await get_session_db(session_id)
|
||||
if not session:
|
||||
raise HTTPException(status_code=404, detail=f"Session {session_id} not found")
|
||||
|
||||
# Get original image bytes
|
||||
original_png = await get_session_image(session_id, "original")
|
||||
if not original_png:
|
||||
raise HTTPException(status_code=400, detail="No original image found")
|
||||
|
||||
# Build context from vocab entries for richer descriptions
|
||||
word_result = session.get("word_result") or {}
|
||||
entries = word_result.get("vocab_entries") or word_result.get("entries") or []
|
||||
vocab_context = ""
|
||||
if entries:
|
||||
sample = entries[:10]
|
||||
words = [f"{e.get('english', '')} / {e.get('german', '')}" for e in sample if e.get('english')]
|
||||
if words:
|
||||
vocab_context = f"\nContext: This is a vocabulary page with words like: {', '.join(words)}"
|
||||
|
||||
ollama_base = os.getenv("OLLAMA_BASE_URL", "http://host.docker.internal:11434")
|
||||
model = os.getenv("OLLAMA_HTR_MODEL", "qwen2.5vl:32b")
|
||||
|
||||
prompt = (
|
||||
"Analyze this scanned page. Find ALL illustration/image/picture regions "
|
||||
"(NOT text, NOT table cells, NOT blank areas). "
|
||||
"For each image region found, return its bounding box as percentage of page dimensions "
|
||||
"and a short English description of what the image shows. "
|
||||
"Reply with ONLY a JSON array like: "
|
||||
'[{"x": 10, "y": 20, "w": 30, "h": 25, "description": "drawing of a cat"}] '
|
||||
"where x, y, w, h are percentages (0-100) of the page width/height. "
|
||||
"If there are NO images on the page, return an empty array: []"
|
||||
f"{vocab_context}"
|
||||
)
|
||||
|
||||
img_b64 = base64.b64encode(original_png).decode("utf-8")
|
||||
payload = {
|
||||
"model": model,
|
||||
"prompt": prompt,
|
||||
"images": [img_b64],
|
||||
"stream": False,
|
||||
}
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=120.0) as client:
|
||||
resp = await client.post(f"{ollama_base}/api/generate", json=payload)
|
||||
resp.raise_for_status()
|
||||
text = resp.json().get("response", "")
|
||||
|
||||
# Parse JSON array from response
|
||||
match = re.search(r'\[.*?\]', text, re.DOTALL)
|
||||
if match:
|
||||
raw_regions = json.loads(match.group(0))
|
||||
else:
|
||||
raw_regions = []
|
||||
|
||||
# Normalize to ImageRegion format
|
||||
regions = []
|
||||
for r in raw_regions:
|
||||
regions.append({
|
||||
"bbox_pct": {
|
||||
"x": max(0, min(100, float(r.get("x", 0)))),
|
||||
"y": max(0, min(100, float(r.get("y", 0)))),
|
||||
"w": max(1, min(100, float(r.get("w", 10)))),
|
||||
"h": max(1, min(100, float(r.get("h", 10)))),
|
||||
},
|
||||
"description": r.get("description", ""),
|
||||
"prompt": r.get("description", ""),
|
||||
"image_b64": None,
|
||||
"style": "educational",
|
||||
})
|
||||
|
||||
# Enrich prompts with nearby vocab context
|
||||
if entries:
|
||||
for region in regions:
|
||||
ry = region["bbox_pct"]["y"]
|
||||
rh = region["bbox_pct"]["h"]
|
||||
nearby = [
|
||||
e for e in entries
|
||||
if e.get("bbox") and abs(e["bbox"].get("y", 0) - ry) < rh + 10
|
||||
]
|
||||
if nearby:
|
||||
en_words = [e.get("english", "") for e in nearby if e.get("english")]
|
||||
de_words = [e.get("german", "") for e in nearby if e.get("german")]
|
||||
if en_words or de_words:
|
||||
context = f" (vocabulary context: {', '.join(en_words[:5])}"
|
||||
if de_words:
|
||||
context += f" / {', '.join(de_words[:5])}"
|
||||
context += ")"
|
||||
region["prompt"] = region["description"] + context
|
||||
|
||||
# Save to ground_truth JSONB
|
||||
ground_truth = session.get("ground_truth") or {}
|
||||
validation = ground_truth.get("validation") or {}
|
||||
validation["image_regions"] = regions
|
||||
validation["detected_at"] = datetime.utcnow().isoformat()
|
||||
ground_truth["validation"] = validation
|
||||
await update_session_db(session_id, ground_truth=ground_truth)
|
||||
|
||||
if session_id in _cache:
|
||||
_cache[session_id]["ground_truth"] = ground_truth
|
||||
|
||||
logger.info(f"Detected {len(regions)} image regions for session {session_id}")
|
||||
|
||||
return {"regions": regions, "count": len(regions)}
|
||||
|
||||
except httpx.ConnectError:
|
||||
logger.warning(f"VLM not available at {ollama_base} for image detection")
|
||||
return {"regions": [], "count": 0, "error": "VLM not available"}
|
||||
except Exception as e:
|
||||
logger.error(f"Image detection failed for {session_id}: {e}")
|
||||
return {"regions": [], "count": 0, "error": str(e)}
|
||||
|
||||
|
||||
@router.post("/sessions/{session_id}/reconstruction/generate-image")
|
||||
async def generate_image_for_region(session_id: str, req: GenerateImageRequest):
|
||||
"""Generate a replacement image for a detected region using mflux.
|
||||
|
||||
Sends the prompt (with style suffix) to the mflux-service running
|
||||
natively on the Mac Mini (Metal GPU required).
|
||||
"""
|
||||
import httpx
|
||||
|
||||
session = await get_session_db(session_id)
|
||||
if not session:
|
||||
raise HTTPException(status_code=404, detail=f"Session {session_id} not found")
|
||||
|
||||
ground_truth = session.get("ground_truth") or {}
|
||||
validation = ground_truth.get("validation") or {}
|
||||
regions = validation.get("image_regions") or []
|
||||
|
||||
if req.region_index < 0 or req.region_index >= len(regions):
|
||||
raise HTTPException(status_code=400, detail=f"Invalid region_index {req.region_index}, have {len(regions)} regions")
|
||||
|
||||
mflux_url = os.getenv("MFLUX_URL", "http://host.docker.internal:8095")
|
||||
style_suffix = STYLE_SUFFIXES.get(req.style, STYLE_SUFFIXES["educational"])
|
||||
full_prompt = f"{req.prompt}, {style_suffix}"
|
||||
|
||||
# Determine image size from region aspect ratio (snap to multiples of 64)
|
||||
region = regions[req.region_index]
|
||||
bbox = region["bbox_pct"]
|
||||
aspect = bbox["w"] / max(bbox["h"], 1)
|
||||
if aspect > 1.3:
|
||||
width, height = 768, 512
|
||||
elif aspect < 0.7:
|
||||
width, height = 512, 768
|
||||
else:
|
||||
width, height = 512, 512
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=300.0) as client:
|
||||
resp = await client.post(f"{mflux_url}/generate", json={
|
||||
"prompt": full_prompt,
|
||||
"width": width,
|
||||
"height": height,
|
||||
"steps": 4,
|
||||
})
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
image_b64 = data.get("image_b64")
|
||||
|
||||
if not image_b64:
|
||||
return {"image_b64": None, "success": False, "error": "No image returned"}
|
||||
|
||||
# Save to ground_truth
|
||||
regions[req.region_index]["image_b64"] = image_b64
|
||||
regions[req.region_index]["prompt"] = req.prompt
|
||||
regions[req.region_index]["style"] = req.style
|
||||
validation["image_regions"] = regions
|
||||
ground_truth["validation"] = validation
|
||||
await update_session_db(session_id, ground_truth=ground_truth)
|
||||
|
||||
if session_id in _cache:
|
||||
_cache[session_id]["ground_truth"] = ground_truth
|
||||
|
||||
logger.info(f"Generated image for session {session_id} region {req.region_index}")
|
||||
return {"image_b64": image_b64, "success": True}
|
||||
|
||||
except httpx.ConnectError:
|
||||
logger.warning(f"mflux-service not available at {mflux_url}")
|
||||
return {"image_b64": None, "success": False, "error": f"mflux-service not available at {mflux_url}"}
|
||||
except Exception as e:
|
||||
logger.error(f"Image generation failed for {session_id}: {e}")
|
||||
return {"image_b64": None, "success": False, "error": str(e)}
|
||||
|
||||
|
||||
@router.post("/sessions/{session_id}/reconstruction/validate")
|
||||
async def save_validation(session_id: str, req: ValidationRequest):
|
||||
"""Save final validation results for step 8.
|
||||
|
||||
Stores notes, score, and preserves any detected/generated image regions.
|
||||
Sets current_step = 10 to mark pipeline as complete.
|
||||
"""
|
||||
session = await get_session_db(session_id)
|
||||
if not session:
|
||||
raise HTTPException(status_code=404, detail=f"Session {session_id} not found")
|
||||
|
||||
ground_truth = session.get("ground_truth") or {}
|
||||
validation = ground_truth.get("validation") or {}
|
||||
validation["validated_at"] = datetime.utcnow().isoformat()
|
||||
validation["notes"] = req.notes
|
||||
validation["score"] = req.score
|
||||
ground_truth["validation"] = validation
|
||||
|
||||
await update_session_db(session_id, ground_truth=ground_truth, current_step=11)
|
||||
|
||||
if session_id in _cache:
|
||||
_cache[session_id]["ground_truth"] = ground_truth
|
||||
|
||||
logger.info(f"Validation saved for session {session_id}: score={req.score}")
|
||||
|
||||
return {"session_id": session_id, "validation": validation}
|
||||
|
||||
|
||||
@router.get("/sessions/{session_id}/reconstruction/validation")
|
||||
async def get_validation(session_id: str):
|
||||
"""Retrieve saved validation data for step 8."""
|
||||
session = await get_session_db(session_id)
|
||||
if not session:
|
||||
raise HTTPException(status_code=404, detail=f"Session {session_id} not found")
|
||||
|
||||
ground_truth = session.get("ground_truth") or {}
|
||||
validation = ground_truth.get("validation")
|
||||
|
||||
return {
|
||||
"session_id": session_id,
|
||||
"validation": validation,
|
||||
"word_result": session.get("word_result"),
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Remove handwriting
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.post("/sessions/{session_id}/remove-handwriting")
|
||||
async def remove_handwriting_endpoint(session_id: str, req: RemoveHandwritingRequest):
|
||||
"""
|
||||
Remove handwriting from a session image using inpainting.
|
||||
|
||||
Steps:
|
||||
1. Load source image (auto -> deskewed if available, else original)
|
||||
2. Detect handwriting mask (filtered by target_ink)
|
||||
3. Dilate mask to cover stroke edges
|
||||
4. Inpaint the image
|
||||
5. Store result as clean_png in the session
|
||||
|
||||
Returns metadata including the URL to fetch the clean image.
|
||||
"""
|
||||
import time as _time
|
||||
t0 = _time.monotonic()
|
||||
|
||||
from services.handwriting_detection import detect_handwriting
|
||||
from services.inpainting_service import inpaint_image, dilate_mask as _dilate_mask, InpaintingMethod, image_to_png
|
||||
|
||||
session = await get_session_db(session_id)
|
||||
if not session:
|
||||
raise HTTPException(status_code=404, detail=f"Session {session_id} not found")
|
||||
|
||||
# 1. Determine source image
|
||||
source = req.use_source
|
||||
if source == "auto":
|
||||
deskewed = await get_session_image(session_id, "deskewed")
|
||||
source = "deskewed" if deskewed else "original"
|
||||
|
||||
image_bytes = await get_session_image(session_id, source)
|
||||
if not image_bytes:
|
||||
raise HTTPException(status_code=404, detail=f"Source image '{source}' not available")
|
||||
|
||||
# 2. Detect handwriting mask
|
||||
detection = detect_handwriting(image_bytes, target_ink=req.target_ink)
|
||||
|
||||
# 3. Convert mask to PNG bytes and dilate
|
||||
import io
|
||||
from PIL import Image as _PILImage
|
||||
mask_img = _PILImage.fromarray(detection.mask)
|
||||
mask_buf = io.BytesIO()
|
||||
mask_img.save(mask_buf, format="PNG")
|
||||
mask_bytes = mask_buf.getvalue()
|
||||
|
||||
if req.dilation > 0:
|
||||
mask_bytes = _dilate_mask(mask_bytes, iterations=req.dilation)
|
||||
|
||||
# 4. Inpaint
|
||||
method_map = {
|
||||
"telea": InpaintingMethod.OPENCV_TELEA,
|
||||
"ns": InpaintingMethod.OPENCV_NS,
|
||||
"auto": InpaintingMethod.AUTO,
|
||||
}
|
||||
inpaint_method = method_map.get(req.method, InpaintingMethod.AUTO)
|
||||
|
||||
result = inpaint_image(image_bytes, mask_bytes, method=inpaint_method)
|
||||
if not result.success:
|
||||
raise HTTPException(status_code=500, detail="Inpainting failed")
|
||||
|
||||
elapsed_ms = int((_time.monotonic() - t0) * 1000)
|
||||
|
||||
meta = {
|
||||
"method_used": result.method_used.value if hasattr(result.method_used, "value") else str(result.method_used),
|
||||
"handwriting_ratio": round(detection.handwriting_ratio, 4),
|
||||
"detection_confidence": round(detection.confidence, 4),
|
||||
"target_ink": req.target_ink,
|
||||
"dilation": req.dilation,
|
||||
"source_image": source,
|
||||
"processing_time_ms": elapsed_ms,
|
||||
}
|
||||
|
||||
# 5. Persist clean image (convert BGR ndarray -> PNG bytes)
|
||||
clean_png_bytes = image_to_png(result.image)
|
||||
await update_session_db(session_id, clean_png=clean_png_bytes, handwriting_removal_meta=meta)
|
||||
|
||||
return {
|
||||
**meta,
|
||||
"image_url": f"/api/v1/ocr-pipeline/sessions/{session_id}/image/clean",
|
||||
"session_id": session_id,
|
||||
}
|
||||
from fastapi import APIRouter
|
||||
|
||||
from ocr_pipeline_llm_review import router as _llm_review_router
|
||||
from ocr_pipeline_reconstruction import router as _reconstruction_router
|
||||
from ocr_pipeline_validation import router as _validation_router
|
||||
|
||||
# Composite router — drop-in replacement for the old monolithic router.
|
||||
# ocr_pipeline_api.py imports ``from ocr_pipeline_postprocess import router``.
|
||||
router = APIRouter()
|
||||
router.include_router(_llm_review_router)
|
||||
router.include_router(_reconstruction_router)
|
||||
router.include_router(_validation_router)
|
||||
|
||||
@@ -0,0 +1,362 @@
|
||||
"""
|
||||
OCR Pipeline Reconstruction — save edits, Fabric JSON export, merged entries, PDF/DOCX export.
|
||||
|
||||
Extracted from ocr_pipeline_postprocess.py.
|
||||
|
||||
Lizenz: Apache 2.0
|
||||
DATENSCHUTZ: Alle Verarbeitung erfolgt lokal.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import re
|
||||
from typing import Dict
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
from ocr_pipeline_session_store import (
|
||||
get_session_db,
|
||||
get_sub_sessions,
|
||||
update_session_db,
|
||||
)
|
||||
from ocr_pipeline_common import _cache
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/v1/ocr-pipeline", tags=["ocr-pipeline"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Step 9: Reconstruction + Fabric JSON export
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.post("/sessions/{session_id}/reconstruction")
|
||||
async def save_reconstruction(session_id: str, request: Request):
|
||||
"""Save edited cell texts from reconstruction step."""
|
||||
session = await get_session_db(session_id)
|
||||
if not session:
|
||||
raise HTTPException(status_code=404, detail=f"Session {session_id} not found")
|
||||
|
||||
word_result = session.get("word_result")
|
||||
if not word_result:
|
||||
raise HTTPException(status_code=400, detail="No word result found")
|
||||
|
||||
body = await request.json()
|
||||
cell_updates = body.get("cells", [])
|
||||
|
||||
if not cell_updates:
|
||||
await update_session_db(session_id, current_step=10)
|
||||
return {"session_id": session_id, "updated": 0}
|
||||
|
||||
# Build update map: cell_id -> new text
|
||||
update_map = {c["cell_id"]: c["text"] for c in cell_updates}
|
||||
|
||||
# Separate sub-session updates (cell_ids prefixed with "box{N}_")
|
||||
sub_updates: Dict[int, Dict[str, str]] = {} # box_index -> {original_cell_id: text}
|
||||
main_updates: Dict[str, str] = {}
|
||||
for cell_id, text in update_map.items():
|
||||
m = re.match(r'^box(\d+)_(.+)$', cell_id)
|
||||
if m:
|
||||
bi = int(m.group(1))
|
||||
original_id = m.group(2)
|
||||
sub_updates.setdefault(bi, {})[original_id] = text
|
||||
else:
|
||||
main_updates[cell_id] = text
|
||||
|
||||
# Update main session cells
|
||||
cells = word_result.get("cells", [])
|
||||
updated_count = 0
|
||||
for cell in cells:
|
||||
if cell["cell_id"] in main_updates:
|
||||
cell["text"] = main_updates[cell["cell_id"]]
|
||||
cell["status"] = "edited"
|
||||
updated_count += 1
|
||||
|
||||
word_result["cells"] = cells
|
||||
|
||||
# Also update vocab_entries if present
|
||||
entries = word_result.get("vocab_entries") or word_result.get("entries") or []
|
||||
if entries:
|
||||
for entry in entries:
|
||||
row_idx = entry.get("row_index", -1)
|
||||
for col_idx, field_name in enumerate(["english", "german", "example"]):
|
||||
cell_id = f"R{row_idx:02d}_C{col_idx}"
|
||||
cell_id_alt = f"R{row_idx}_C{col_idx}"
|
||||
new_text = main_updates.get(cell_id) or main_updates.get(cell_id_alt)
|
||||
if new_text is not None:
|
||||
entry[field_name] = new_text
|
||||
|
||||
word_result["vocab_entries"] = entries
|
||||
if "entries" in word_result:
|
||||
word_result["entries"] = entries
|
||||
|
||||
await update_session_db(session_id, word_result=word_result, current_step=10)
|
||||
|
||||
if session_id in _cache:
|
||||
_cache[session_id]["word_result"] = word_result
|
||||
|
||||
# Route sub-session updates
|
||||
sub_updated = 0
|
||||
if sub_updates:
|
||||
subs = await get_sub_sessions(session_id)
|
||||
sub_by_index = {s.get("box_index"): s["id"] for s in subs}
|
||||
for bi, updates in sub_updates.items():
|
||||
sub_id = sub_by_index.get(bi)
|
||||
if not sub_id:
|
||||
continue
|
||||
sub_session = await get_session_db(sub_id)
|
||||
if not sub_session:
|
||||
continue
|
||||
sub_word = sub_session.get("word_result")
|
||||
if not sub_word:
|
||||
continue
|
||||
sub_cells = sub_word.get("cells", [])
|
||||
for cell in sub_cells:
|
||||
if cell["cell_id"] in updates:
|
||||
cell["text"] = updates[cell["cell_id"]]
|
||||
cell["status"] = "edited"
|
||||
sub_updated += 1
|
||||
sub_word["cells"] = sub_cells
|
||||
await update_session_db(sub_id, word_result=sub_word)
|
||||
if sub_id in _cache:
|
||||
_cache[sub_id]["word_result"] = sub_word
|
||||
|
||||
total_updated = updated_count + sub_updated
|
||||
logger.info(f"Reconstruction saved for session {session_id}: "
|
||||
f"{updated_count} main + {sub_updated} sub-session cells updated")
|
||||
|
||||
return {
|
||||
"session_id": session_id,
|
||||
"updated": total_updated,
|
||||
"main_updated": updated_count,
|
||||
"sub_updated": sub_updated,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/sessions/{session_id}/reconstruction/fabric-json")
|
||||
async def get_fabric_json(session_id: str):
|
||||
"""Return cell grid as Fabric.js-compatible JSON for the canvas editor."""
|
||||
session = await get_session_db(session_id)
|
||||
if not session:
|
||||
raise HTTPException(status_code=404, detail=f"Session {session_id} not found")
|
||||
|
||||
word_result = session.get("word_result")
|
||||
if not word_result:
|
||||
raise HTTPException(status_code=400, detail="No word result found")
|
||||
|
||||
cells = list(word_result.get("cells", []))
|
||||
img_w = word_result.get("image_width", 800)
|
||||
img_h = word_result.get("image_height", 600)
|
||||
|
||||
# Merge sub-session cells at box positions
|
||||
subs = await get_sub_sessions(session_id)
|
||||
if subs:
|
||||
column_result = session.get("column_result") or {}
|
||||
zones = column_result.get("zones") or []
|
||||
box_zones = [z for z in zones if z.get("zone_type") == "box" and z.get("box")]
|
||||
|
||||
for sub in subs:
|
||||
sub_session = await get_session_db(sub["id"])
|
||||
if not sub_session:
|
||||
continue
|
||||
sub_word = sub_session.get("word_result")
|
||||
if not sub_word or not sub_word.get("cells"):
|
||||
continue
|
||||
|
||||
bi = sub.get("box_index", 0)
|
||||
if bi < len(box_zones):
|
||||
box = box_zones[bi]["box"]
|
||||
box_y, box_x = box["y"], box["x"]
|
||||
else:
|
||||
box_y, box_x = 0, 0
|
||||
|
||||
for cell in sub_word["cells"]:
|
||||
cell_copy = dict(cell)
|
||||
cell_copy["cell_id"] = f"box{bi}_{cell_copy.get('cell_id', '')}"
|
||||
cell_copy["source"] = f"box_{bi}"
|
||||
bbox = cell_copy.get("bbox_px", {})
|
||||
if bbox:
|
||||
bbox = dict(bbox)
|
||||
bbox["x"] = bbox.get("x", 0) + box_x
|
||||
bbox["y"] = bbox.get("y", 0) + box_y
|
||||
cell_copy["bbox_px"] = bbox
|
||||
cells.append(cell_copy)
|
||||
|
||||
from services.layout_reconstruction_service import cells_to_fabric_json
|
||||
fabric_json = cells_to_fabric_json(cells, img_w, img_h)
|
||||
|
||||
return fabric_json
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Vocab entries merged + PDF/DOCX export
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.get("/sessions/{session_id}/vocab-entries/merged")
|
||||
async def get_merged_vocab_entries(session_id: str):
|
||||
"""Return vocab entries from main session + all sub-sessions, sorted by Y position."""
|
||||
session = await get_session_db(session_id)
|
||||
if not session:
|
||||
raise HTTPException(status_code=404, detail=f"Session {session_id} not found")
|
||||
|
||||
word_result = session.get("word_result") or {}
|
||||
entries = list(word_result.get("vocab_entries") or word_result.get("entries") or [])
|
||||
|
||||
for e in entries:
|
||||
e.setdefault("source", "main")
|
||||
|
||||
subs = await get_sub_sessions(session_id)
|
||||
if subs:
|
||||
column_result = session.get("column_result") or {}
|
||||
zones = column_result.get("zones") or []
|
||||
box_zones = [z for z in zones if z.get("zone_type") == "box" and z.get("box")]
|
||||
|
||||
for sub in subs:
|
||||
sub_session = await get_session_db(sub["id"])
|
||||
if not sub_session:
|
||||
continue
|
||||
sub_word = sub_session.get("word_result") or {}
|
||||
sub_entries = sub_word.get("vocab_entries") or sub_word.get("entries") or []
|
||||
|
||||
bi = sub.get("box_index", 0)
|
||||
box_y = 0
|
||||
if bi < len(box_zones):
|
||||
box_y = box_zones[bi]["box"]["y"]
|
||||
|
||||
for e in sub_entries:
|
||||
e_copy = dict(e)
|
||||
e_copy["source"] = f"box_{bi}"
|
||||
e_copy["source_y"] = box_y
|
||||
entries.append(e_copy)
|
||||
|
||||
def _sort_key(e):
|
||||
if e.get("source", "main") == "main":
|
||||
return e.get("row_index", 0) * 100
|
||||
return e.get("source_y", 0) * 100 + e.get("row_index", 0)
|
||||
|
||||
entries.sort(key=_sort_key)
|
||||
|
||||
return {
|
||||
"session_id": session_id,
|
||||
"entries": entries,
|
||||
"total": len(entries),
|
||||
"sources": list(set(e.get("source", "main") for e in entries)),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/sessions/{session_id}/reconstruction/export/pdf")
|
||||
async def export_reconstruction_pdf(session_id: str):
|
||||
"""Export the reconstructed cell grid as a PDF table."""
|
||||
session = await get_session_db(session_id)
|
||||
if not session:
|
||||
raise HTTPException(status_code=404, detail=f"Session {session_id} not found")
|
||||
|
||||
word_result = session.get("word_result")
|
||||
if not word_result:
|
||||
raise HTTPException(status_code=400, detail="No word result found")
|
||||
|
||||
cells = word_result.get("cells", [])
|
||||
columns_used = word_result.get("columns_used", [])
|
||||
grid_shape = word_result.get("grid_shape", {})
|
||||
n_rows = grid_shape.get("rows", 0)
|
||||
n_cols = grid_shape.get("cols", 0)
|
||||
|
||||
# Build table data: rows x columns
|
||||
table_data: list[list[str]] = []
|
||||
header = [c.get("label", c.get("type", f"Col {i}")) for i, c in enumerate(columns_used)]
|
||||
if not header:
|
||||
header = [f"Col {i}" for i in range(n_cols)]
|
||||
table_data.append(header)
|
||||
|
||||
for r in range(n_rows):
|
||||
row_texts = []
|
||||
for ci in range(n_cols):
|
||||
cell_id = f"R{r:02d}_C{ci}"
|
||||
cell = next((c for c in cells if c.get("cell_id") == cell_id), None)
|
||||
row_texts.append(cell.get("text", "") if cell else "")
|
||||
table_data.append(row_texts)
|
||||
|
||||
try:
|
||||
from reportlab.lib.pagesizes import A4
|
||||
from reportlab.lib import colors
|
||||
from reportlab.platypus import SimpleDocTemplate, Table, TableStyle
|
||||
import io as _io
|
||||
|
||||
buf = _io.BytesIO()
|
||||
doc = SimpleDocTemplate(buf, pagesize=A4)
|
||||
if not table_data or not table_data[0]:
|
||||
raise HTTPException(status_code=400, detail="No data to export")
|
||||
|
||||
t = Table(table_data)
|
||||
t.setStyle(TableStyle([
|
||||
('BACKGROUND', (0, 0), (-1, 0), colors.HexColor('#0d9488')),
|
||||
('TEXTCOLOR', (0, 0), (-1, 0), colors.white),
|
||||
('FONTSIZE', (0, 0), (-1, -1), 9),
|
||||
('GRID', (0, 0), (-1, -1), 0.5, colors.grey),
|
||||
('VALIGN', (0, 0), (-1, -1), 'TOP'),
|
||||
('WORDWRAP', (0, 0), (-1, -1), True),
|
||||
]))
|
||||
doc.build([t])
|
||||
buf.seek(0)
|
||||
|
||||
return StreamingResponse(
|
||||
buf,
|
||||
media_type="application/pdf",
|
||||
headers={"Content-Disposition": f'attachment; filename="reconstruction_{session_id}.pdf"'},
|
||||
)
|
||||
except ImportError:
|
||||
raise HTTPException(status_code=501, detail="reportlab not installed")
|
||||
|
||||
|
||||
@router.get("/sessions/{session_id}/reconstruction/export/docx")
|
||||
async def export_reconstruction_docx(session_id: str):
|
||||
"""Export the reconstructed cell grid as a DOCX table."""
|
||||
session = await get_session_db(session_id)
|
||||
if not session:
|
||||
raise HTTPException(status_code=404, detail=f"Session {session_id} not found")
|
||||
|
||||
word_result = session.get("word_result")
|
||||
if not word_result:
|
||||
raise HTTPException(status_code=400, detail="No word result found")
|
||||
|
||||
cells = word_result.get("cells", [])
|
||||
columns_used = word_result.get("columns_used", [])
|
||||
grid_shape = word_result.get("grid_shape", {})
|
||||
n_rows = grid_shape.get("rows", 0)
|
||||
n_cols = grid_shape.get("cols", 0)
|
||||
|
||||
try:
|
||||
from docx import Document
|
||||
from docx.shared import Pt
|
||||
import io as _io
|
||||
|
||||
doc = Document()
|
||||
doc.add_heading(f'Rekonstruktion -- Session {session_id[:8]}', level=1)
|
||||
|
||||
header = [c.get("label", c.get("type", f"Col {i}")) for i, c in enumerate(columns_used)]
|
||||
if not header:
|
||||
header = [f"Col {i}" for i in range(n_cols)]
|
||||
|
||||
table = doc.add_table(rows=1 + n_rows, cols=max(n_cols, 1))
|
||||
table.style = 'Table Grid'
|
||||
|
||||
for ci, h in enumerate(header):
|
||||
table.rows[0].cells[ci].text = h
|
||||
|
||||
for r in range(n_rows):
|
||||
for ci in range(n_cols):
|
||||
cell_id = f"R{r:02d}_C{ci}"
|
||||
cell = next((c for c in cells if c.get("cell_id") == cell_id), None)
|
||||
table.rows[r + 1].cells[ci].text = cell.get("text", "") if cell else ""
|
||||
|
||||
buf = _io.BytesIO()
|
||||
doc.save(buf)
|
||||
buf.seek(0)
|
||||
|
||||
return StreamingResponse(
|
||||
buf,
|
||||
media_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
headers={"Content-Disposition": f'attachment; filename="reconstruction_{session_id}.docx"'},
|
||||
)
|
||||
except ImportError:
|
||||
raise HTTPException(status_code=501, detail="python-docx not installed")
|
||||
@@ -0,0 +1,362 @@
|
||||
"""
|
||||
OCR Pipeline Validation — image detection, generation, validation save,
|
||||
and handwriting removal endpoints.
|
||||
|
||||
Extracted from ocr_pipeline_postprocess.py.
|
||||
|
||||
Lizenz: Apache 2.0
|
||||
DATENSCHUTZ: Alle Verarbeitung erfolgt lokal.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ocr_pipeline_session_store import (
|
||||
get_session_db,
|
||||
get_session_image,
|
||||
update_session_db,
|
||||
)
|
||||
from ocr_pipeline_common import (
|
||||
_cache,
|
||||
RemoveHandwritingRequest,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/v1/ocr-pipeline", tags=["ocr-pipeline"])
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pydantic Models
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
STYLE_SUFFIXES = {
|
||||
"educational": "educational illustration, textbook style, clear, colorful",
|
||||
"cartoon": "cartoon, child-friendly, simple shapes",
|
||||
"sketch": "pencil sketch, hand-drawn, black and white",
|
||||
"clipart": "clipart, flat vector style, simple",
|
||||
"realistic": "photorealistic, high detail",
|
||||
}
|
||||
|
||||
|
||||
class ValidationRequest(BaseModel):
|
||||
notes: Optional[str] = None
|
||||
score: Optional[int] = None
|
||||
|
||||
|
||||
class GenerateImageRequest(BaseModel):
|
||||
region_index: int
|
||||
prompt: str
|
||||
style: str = "educational"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Image detection + generation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.post("/sessions/{session_id}/reconstruction/detect-images")
|
||||
async def detect_image_regions(session_id: str):
|
||||
"""Detect illustration/image regions in the original scan using VLM."""
|
||||
import base64
|
||||
import httpx
|
||||
import re
|
||||
|
||||
session = await get_session_db(session_id)
|
||||
if not session:
|
||||
raise HTTPException(status_code=404, detail=f"Session {session_id} not found")
|
||||
|
||||
original_png = await get_session_image(session_id, "original")
|
||||
if not original_png:
|
||||
raise HTTPException(status_code=400, detail="No original image found")
|
||||
|
||||
word_result = session.get("word_result") or {}
|
||||
entries = word_result.get("vocab_entries") or word_result.get("entries") or []
|
||||
vocab_context = ""
|
||||
if entries:
|
||||
sample = entries[:10]
|
||||
words = [f"{e.get('english', '')} / {e.get('german', '')}" for e in sample if e.get('english')]
|
||||
if words:
|
||||
vocab_context = f"\nContext: This is a vocabulary page with words like: {', '.join(words)}"
|
||||
|
||||
ollama_base = os.getenv("OLLAMA_BASE_URL", "http://host.docker.internal:11434")
|
||||
model = os.getenv("OLLAMA_HTR_MODEL", "qwen2.5vl:32b")
|
||||
|
||||
prompt = (
|
||||
"Analyze this scanned page. Find ALL illustration/image/picture regions "
|
||||
"(NOT text, NOT table cells, NOT blank areas). "
|
||||
"For each image region found, return its bounding box as percentage of page dimensions "
|
||||
"and a short English description of what the image shows. "
|
||||
"Reply with ONLY a JSON array like: "
|
||||
'[{"x": 10, "y": 20, "w": 30, "h": 25, "description": "drawing of a cat"}] '
|
||||
"where x, y, w, h are percentages (0-100) of the page width/height. "
|
||||
"If there are NO images on the page, return an empty array: []"
|
||||
f"{vocab_context}"
|
||||
)
|
||||
|
||||
img_b64 = base64.b64encode(original_png).decode("utf-8")
|
||||
payload = {
|
||||
"model": model,
|
||||
"prompt": prompt,
|
||||
"images": [img_b64],
|
||||
"stream": False,
|
||||
}
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=120.0) as client:
|
||||
resp = await client.post(f"{ollama_base}/api/generate", json=payload)
|
||||
resp.raise_for_status()
|
||||
text = resp.json().get("response", "")
|
||||
|
||||
match = re.search(r'\[.*?\]', text, re.DOTALL)
|
||||
if match:
|
||||
raw_regions = json.loads(match.group(0))
|
||||
else:
|
||||
raw_regions = []
|
||||
|
||||
regions = []
|
||||
for r in raw_regions:
|
||||
regions.append({
|
||||
"bbox_pct": {
|
||||
"x": max(0, min(100, float(r.get("x", 0)))),
|
||||
"y": max(0, min(100, float(r.get("y", 0)))),
|
||||
"w": max(1, min(100, float(r.get("w", 10)))),
|
||||
"h": max(1, min(100, float(r.get("h", 10)))),
|
||||
},
|
||||
"description": r.get("description", ""),
|
||||
"prompt": r.get("description", ""),
|
||||
"image_b64": None,
|
||||
"style": "educational",
|
||||
})
|
||||
|
||||
# Enrich prompts with nearby vocab context
|
||||
if entries:
|
||||
for region in regions:
|
||||
ry = region["bbox_pct"]["y"]
|
||||
rh = region["bbox_pct"]["h"]
|
||||
nearby = [
|
||||
e for e in entries
|
||||
if e.get("bbox") and abs(e["bbox"].get("y", 0) - ry) < rh + 10
|
||||
]
|
||||
if nearby:
|
||||
en_words = [e.get("english", "") for e in nearby if e.get("english")]
|
||||
de_words = [e.get("german", "") for e in nearby if e.get("german")]
|
||||
if en_words or de_words:
|
||||
context = f" (vocabulary context: {', '.join(en_words[:5])}"
|
||||
if de_words:
|
||||
context += f" / {', '.join(de_words[:5])}"
|
||||
context += ")"
|
||||
region["prompt"] = region["description"] + context
|
||||
|
||||
ground_truth = session.get("ground_truth") or {}
|
||||
validation = ground_truth.get("validation") or {}
|
||||
validation["image_regions"] = regions
|
||||
validation["detected_at"] = datetime.utcnow().isoformat()
|
||||
ground_truth["validation"] = validation
|
||||
await update_session_db(session_id, ground_truth=ground_truth)
|
||||
|
||||
if session_id in _cache:
|
||||
_cache[session_id]["ground_truth"] = ground_truth
|
||||
|
||||
logger.info(f"Detected {len(regions)} image regions for session {session_id}")
|
||||
|
||||
return {"regions": regions, "count": len(regions)}
|
||||
|
||||
except httpx.ConnectError:
|
||||
logger.warning(f"VLM not available at {ollama_base} for image detection")
|
||||
return {"regions": [], "count": 0, "error": "VLM not available"}
|
||||
except Exception as e:
|
||||
logger.error(f"Image detection failed for {session_id}: {e}")
|
||||
return {"regions": [], "count": 0, "error": str(e)}
|
||||
|
||||
|
||||
@router.post("/sessions/{session_id}/reconstruction/generate-image")
|
||||
async def generate_image_for_region(session_id: str, req: GenerateImageRequest):
|
||||
"""Generate a replacement image for a detected region using mflux."""
|
||||
import httpx
|
||||
|
||||
session = await get_session_db(session_id)
|
||||
if not session:
|
||||
raise HTTPException(status_code=404, detail=f"Session {session_id} not found")
|
||||
|
||||
ground_truth = session.get("ground_truth") or {}
|
||||
validation = ground_truth.get("validation") or {}
|
||||
regions = validation.get("image_regions") or []
|
||||
|
||||
if req.region_index < 0 or req.region_index >= len(regions):
|
||||
raise HTTPException(status_code=400, detail=f"Invalid region_index {req.region_index}, have {len(regions)} regions")
|
||||
|
||||
mflux_url = os.getenv("MFLUX_URL", "http://host.docker.internal:8095")
|
||||
style_suffix = STYLE_SUFFIXES.get(req.style, STYLE_SUFFIXES["educational"])
|
||||
full_prompt = f"{req.prompt}, {style_suffix}"
|
||||
|
||||
region = regions[req.region_index]
|
||||
bbox = region["bbox_pct"]
|
||||
aspect = bbox["w"] / max(bbox["h"], 1)
|
||||
if aspect > 1.3:
|
||||
width, height = 768, 512
|
||||
elif aspect < 0.7:
|
||||
width, height = 512, 768
|
||||
else:
|
||||
width, height = 512, 512
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=300.0) as client:
|
||||
resp = await client.post(f"{mflux_url}/generate", json={
|
||||
"prompt": full_prompt,
|
||||
"width": width,
|
||||
"height": height,
|
||||
"steps": 4,
|
||||
})
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
image_b64 = data.get("image_b64")
|
||||
|
||||
if not image_b64:
|
||||
return {"image_b64": None, "success": False, "error": "No image returned"}
|
||||
|
||||
regions[req.region_index]["image_b64"] = image_b64
|
||||
regions[req.region_index]["prompt"] = req.prompt
|
||||
regions[req.region_index]["style"] = req.style
|
||||
validation["image_regions"] = regions
|
||||
ground_truth["validation"] = validation
|
||||
await update_session_db(session_id, ground_truth=ground_truth)
|
||||
|
||||
if session_id in _cache:
|
||||
_cache[session_id]["ground_truth"] = ground_truth
|
||||
|
||||
logger.info(f"Generated image for session {session_id} region {req.region_index}")
|
||||
return {"image_b64": image_b64, "success": True}
|
||||
|
||||
except httpx.ConnectError:
|
||||
logger.warning(f"mflux-service not available at {mflux_url}")
|
||||
return {"image_b64": None, "success": False, "error": f"mflux-service not available at {mflux_url}"}
|
||||
except Exception as e:
|
||||
logger.error(f"Image generation failed for {session_id}: {e}")
|
||||
return {"image_b64": None, "success": False, "error": str(e)}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Validation save/get
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.post("/sessions/{session_id}/reconstruction/validate")
|
||||
async def save_validation(session_id: str, req: ValidationRequest):
|
||||
"""Save final validation results for step 8."""
|
||||
session = await get_session_db(session_id)
|
||||
if not session:
|
||||
raise HTTPException(status_code=404, detail=f"Session {session_id} not found")
|
||||
|
||||
ground_truth = session.get("ground_truth") or {}
|
||||
validation = ground_truth.get("validation") or {}
|
||||
validation["validated_at"] = datetime.utcnow().isoformat()
|
||||
validation["notes"] = req.notes
|
||||
validation["score"] = req.score
|
||||
ground_truth["validation"] = validation
|
||||
|
||||
await update_session_db(session_id, ground_truth=ground_truth, current_step=11)
|
||||
|
||||
if session_id in _cache:
|
||||
_cache[session_id]["ground_truth"] = ground_truth
|
||||
|
||||
logger.info(f"Validation saved for session {session_id}: score={req.score}")
|
||||
|
||||
return {"session_id": session_id, "validation": validation}
|
||||
|
||||
|
||||
@router.get("/sessions/{session_id}/reconstruction/validation")
|
||||
async def get_validation(session_id: str):
|
||||
"""Retrieve saved validation data for step 8."""
|
||||
session = await get_session_db(session_id)
|
||||
if not session:
|
||||
raise HTTPException(status_code=404, detail=f"Session {session_id} not found")
|
||||
|
||||
ground_truth = session.get("ground_truth") or {}
|
||||
validation = ground_truth.get("validation")
|
||||
|
||||
return {
|
||||
"session_id": session_id,
|
||||
"validation": validation,
|
||||
"word_result": session.get("word_result"),
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Remove handwriting
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.post("/sessions/{session_id}/remove-handwriting")
|
||||
async def remove_handwriting_endpoint(session_id: str, req: RemoveHandwritingRequest):
|
||||
"""Remove handwriting from a session image using inpainting."""
|
||||
import time as _time
|
||||
|
||||
from services.handwriting_detection import detect_handwriting
|
||||
from services.inpainting_service import inpaint_image, dilate_mask as _dilate_mask, InpaintingMethod, image_to_png
|
||||
|
||||
session = await get_session_db(session_id)
|
||||
if not session:
|
||||
raise HTTPException(status_code=404, detail=f"Session {session_id} not found")
|
||||
|
||||
t0 = _time.monotonic()
|
||||
|
||||
# 1. Determine source image
|
||||
source = req.use_source
|
||||
if source == "auto":
|
||||
deskewed = await get_session_image(session_id, "deskewed")
|
||||
source = "deskewed" if deskewed else "original"
|
||||
|
||||
image_bytes = await get_session_image(session_id, source)
|
||||
if not image_bytes:
|
||||
raise HTTPException(status_code=404, detail=f"Source image '{source}' not available")
|
||||
|
||||
# 2. Detect handwriting mask
|
||||
detection = detect_handwriting(image_bytes, target_ink=req.target_ink)
|
||||
|
||||
# 3. Convert mask to PNG bytes and dilate
|
||||
import io
|
||||
from PIL import Image as _PILImage
|
||||
mask_img = _PILImage.fromarray(detection.mask)
|
||||
mask_buf = io.BytesIO()
|
||||
mask_img.save(mask_buf, format="PNG")
|
||||
mask_bytes = mask_buf.getvalue()
|
||||
|
||||
if req.dilation > 0:
|
||||
mask_bytes = _dilate_mask(mask_bytes, iterations=req.dilation)
|
||||
|
||||
# 4. Inpaint
|
||||
method_map = {
|
||||
"telea": InpaintingMethod.OPENCV_TELEA,
|
||||
"ns": InpaintingMethod.OPENCV_NS,
|
||||
"auto": InpaintingMethod.AUTO,
|
||||
}
|
||||
inpaint_method = method_map.get(req.method, InpaintingMethod.AUTO)
|
||||
|
||||
result = inpaint_image(image_bytes, mask_bytes, method=inpaint_method)
|
||||
if not result.success:
|
||||
raise HTTPException(status_code=500, detail="Inpainting failed")
|
||||
|
||||
elapsed_ms = int((_time.monotonic() - t0) * 1000)
|
||||
|
||||
meta = {
|
||||
"method_used": result.method_used.value if hasattr(result.method_used, "value") else str(result.method_used),
|
||||
"handwriting_ratio": round(detection.handwriting_ratio, 4),
|
||||
"detection_confidence": round(detection.confidence, 4),
|
||||
"target_ink": req.target_ink,
|
||||
"dilation": req.dilation,
|
||||
"source_image": source,
|
||||
"processing_time_ms": elapsed_ms,
|
||||
}
|
||||
|
||||
# 5. Persist clean image
|
||||
clean_png_bytes = image_to_png(result.image)
|
||||
await update_session_db(session_id, clean_png=clean_png_bytes, handwriting_removal_meta=meta)
|
||||
|
||||
return {
|
||||
**meta,
|
||||
"image_url": f"/api/v1/ocr-pipeline/sessions/{session_id}/image/clean",
|
||||
"session_id": session_id,
|
||||
}
|
||||
@@ -1,18 +1,18 @@
|
||||
"""
|
||||
OCR Pipeline Words - Word detection and ground truth endpoints.
|
||||
OCR Pipeline Words — composite router for word detection, PaddleOCR direct,
|
||||
and ground truth endpoints.
|
||||
|
||||
Extracted from ocr_pipeline_api.py.
|
||||
Handles:
|
||||
- POST /sessions/{session_id}/words — main SSE streaming word detection
|
||||
- POST /sessions/{session_id}/paddle-direct — PaddleOCR direct endpoint
|
||||
- POST /sessions/{session_id}/ground-truth/words — save ground truth
|
||||
- GET /sessions/{session_id}/ground-truth/words — get ground truth
|
||||
Split into sub-modules:
|
||||
ocr_pipeline_words_detect — main detect_words endpoint (Step 7)
|
||||
ocr_pipeline_words_stream — SSE streaming generators
|
||||
|
||||
This barrel module contains the PaddleOCR direct endpoint and ground truth
|
||||
endpoints, and assembles all word-related routers.
|
||||
|
||||
Lizenz: Apache 2.0
|
||||
DATENSCHUTZ: Alle Verarbeitung erfolgt lokal.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from datetime import datetime
|
||||
@@ -20,22 +20,9 @@ from typing import Any, Dict, List, Optional
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import StreamingResponse
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
from cv_vocab_pipeline import (
|
||||
PageRegion,
|
||||
RowGeometry,
|
||||
_cells_to_vocab_entries,
|
||||
_fix_character_confusion,
|
||||
_fix_phonetic_brackets,
|
||||
fix_cell_phonetics,
|
||||
build_cell_grid_v2,
|
||||
build_cell_grid_v2_streaming,
|
||||
create_ocr_image,
|
||||
detect_column_geometry,
|
||||
)
|
||||
from cv_words_first import build_grid_from_words
|
||||
from ocr_pipeline_session_store import (
|
||||
get_session_db,
|
||||
@@ -44,15 +31,13 @@ from ocr_pipeline_session_store import (
|
||||
)
|
||||
from ocr_pipeline_common import (
|
||||
_cache,
|
||||
_load_session_to_cache,
|
||||
_get_cached,
|
||||
_get_base_image_png,
|
||||
_append_pipeline_log,
|
||||
)
|
||||
from ocr_pipeline_words_detect import router as _detect_router
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/v1/ocr-pipeline", tags=["ocr-pipeline"])
|
||||
_local_router = APIRouter(prefix="/api/v1/ocr-pipeline", tags=["ocr-pipeline"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -65,689 +50,13 @@ class WordGroundTruthRequest(BaseModel):
|
||||
notes: Optional[str] = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Word Detection Endpoint (Step 7)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.post("/sessions/{session_id}/words")
|
||||
async def detect_words(
|
||||
session_id: str,
|
||||
request: Request,
|
||||
engine: str = "auto",
|
||||
pronunciation: str = "british",
|
||||
stream: bool = False,
|
||||
skip_heal_gaps: bool = False,
|
||||
grid_method: str = "v2",
|
||||
):
|
||||
"""Build word grid from columns × rows, OCR each cell.
|
||||
|
||||
Query params:
|
||||
engine: 'auto' (default), 'tesseract', 'rapid', or 'paddle'
|
||||
pronunciation: 'british' (default) or 'american' — for IPA dictionary lookup
|
||||
stream: false (default) for JSON response, true for SSE streaming
|
||||
skip_heal_gaps: false (default). When true, cells keep exact row geometry
|
||||
positions without gap-healing expansion. Better for overlay rendering.
|
||||
grid_method: 'v2' (default) or 'words_first' — grid construction strategy.
|
||||
'v2' uses pre-detected columns/rows (top-down).
|
||||
'words_first' clusters words bottom-up (no column/row detection needed).
|
||||
"""
|
||||
# PaddleOCR is full-page remote OCR → force words_first grid method
|
||||
if engine == "paddle" and grid_method != "words_first":
|
||||
logger.info("detect_words: engine=paddle requires words_first, overriding grid_method=%s", grid_method)
|
||||
grid_method = "words_first"
|
||||
|
||||
if session_id not in _cache:
|
||||
logger.info("detect_words: session %s not in cache, loading from DB", session_id)
|
||||
await _load_session_to_cache(session_id)
|
||||
cached = _get_cached(session_id)
|
||||
|
||||
dewarped_bgr = cached.get("cropped_bgr") if cached.get("cropped_bgr") is not None else cached.get("dewarped_bgr")
|
||||
if dewarped_bgr is None:
|
||||
logger.warning("detect_words: no cropped/dewarped image for session %s (cache keys: %s)",
|
||||
session_id, [k for k in cached.keys() if k.endswith('_bgr')])
|
||||
raise HTTPException(status_code=400, detail="Crop or dewarp must be completed before word detection")
|
||||
|
||||
session = await get_session_db(session_id)
|
||||
if not session:
|
||||
raise HTTPException(status_code=404, detail=f"Session {session_id} not found")
|
||||
|
||||
column_result = session.get("column_result")
|
||||
row_result = session.get("row_result")
|
||||
if not column_result or not column_result.get("columns"):
|
||||
# No column detection — synthesize a single full-page pseudo-column.
|
||||
# This enables the overlay pipeline which skips column detection.
|
||||
img_h_tmp, img_w_tmp = dewarped_bgr.shape[:2]
|
||||
column_result = {
|
||||
"columns": [{
|
||||
"type": "column_text",
|
||||
"x": 0, "y": 0,
|
||||
"width": img_w_tmp, "height": img_h_tmp,
|
||||
"classification_confidence": 1.0,
|
||||
"classification_method": "full_page_fallback",
|
||||
}],
|
||||
"zones": [],
|
||||
"duration_seconds": 0,
|
||||
}
|
||||
logger.info("detect_words: no column_result — using full-page pseudo-column %dx%d", img_w_tmp, img_h_tmp)
|
||||
if grid_method != "words_first" and (not row_result or not row_result.get("rows")):
|
||||
raise HTTPException(status_code=400, detail="Row detection must be completed first")
|
||||
|
||||
# Convert column dicts back to PageRegion objects
|
||||
col_regions = [
|
||||
PageRegion(
|
||||
type=c["type"],
|
||||
x=c["x"], y=c["y"],
|
||||
width=c["width"], height=c["height"],
|
||||
classification_confidence=c.get("classification_confidence", 1.0),
|
||||
classification_method=c.get("classification_method", ""),
|
||||
)
|
||||
for c in column_result["columns"]
|
||||
]
|
||||
|
||||
# Convert row dicts back to RowGeometry objects
|
||||
row_geoms = [
|
||||
RowGeometry(
|
||||
index=r["index"],
|
||||
x=r["x"], y=r["y"],
|
||||
width=r["width"], height=r["height"],
|
||||
word_count=r.get("word_count", 0),
|
||||
words=[],
|
||||
row_type=r.get("row_type", "content"),
|
||||
gap_before=r.get("gap_before", 0),
|
||||
)
|
||||
for r in row_result["rows"]
|
||||
]
|
||||
|
||||
# Cell-First OCR (v2): no full-page word re-population needed.
|
||||
# Each cell is cropped and OCR'd in isolation → no neighbour bleeding.
|
||||
# We still need word_count > 0 for row filtering in build_cell_grid_v2,
|
||||
# so populate from cached words if available (just for counting).
|
||||
word_dicts = cached.get("_word_dicts")
|
||||
if word_dicts is None:
|
||||
ocr_img_tmp = create_ocr_image(dewarped_bgr)
|
||||
geo_result = detect_column_geometry(ocr_img_tmp, dewarped_bgr)
|
||||
if geo_result is not None:
|
||||
_geoms, left_x, right_x, top_y, bottom_y, word_dicts, inv = geo_result
|
||||
cached["_word_dicts"] = word_dicts
|
||||
cached["_inv"] = inv
|
||||
cached["_content_bounds"] = (left_x, right_x, top_y, bottom_y)
|
||||
|
||||
if word_dicts:
|
||||
content_bounds = cached.get("_content_bounds")
|
||||
if content_bounds:
|
||||
_lx, _rx, top_y, _by = content_bounds
|
||||
else:
|
||||
top_y = min(r.y for r in row_geoms) if row_geoms else 0
|
||||
|
||||
for row in row_geoms:
|
||||
row_y_rel = row.y - top_y
|
||||
row_bottom_rel = row_y_rel + row.height
|
||||
row.words = [
|
||||
w for w in word_dicts
|
||||
if row_y_rel <= w['top'] + w['height'] / 2 < row_bottom_rel
|
||||
]
|
||||
row.word_count = len(row.words)
|
||||
|
||||
# Exclude rows that fall within box zones.
|
||||
# Use inner box range (shrunk by border_thickness) so that rows at
|
||||
# the boundary (overlapping with the box border) are NOT excluded.
|
||||
zones = column_result.get("zones") or []
|
||||
box_ranges_inner = []
|
||||
for zone in zones:
|
||||
if zone.get("zone_type") == "box" and zone.get("box"):
|
||||
box = zone["box"]
|
||||
bt = max(box.get("border_thickness", 0), 5) # minimum 5px margin
|
||||
box_ranges_inner.append((box["y"] + bt, box["y"] + box["height"] - bt))
|
||||
|
||||
if box_ranges_inner:
|
||||
def _row_in_box(r):
|
||||
center_y = r.y + r.height / 2
|
||||
return any(by_s <= center_y < by_e for by_s, by_e in box_ranges_inner)
|
||||
|
||||
before_count = len(row_geoms)
|
||||
row_geoms = [r for r in row_geoms if not _row_in_box(r)]
|
||||
excluded = before_count - len(row_geoms)
|
||||
if excluded:
|
||||
logger.info(f"detect_words: excluded {excluded} rows inside box zones")
|
||||
|
||||
# --- Words-First path: bottom-up grid from word boxes ---
|
||||
if grid_method == "words_first":
|
||||
t0 = time.time()
|
||||
img_h, img_w = dewarped_bgr.shape[:2]
|
||||
|
||||
# For paddle engine: run remote PaddleOCR full-page instead of Tesseract
|
||||
if engine == "paddle":
|
||||
from cv_ocr_engines import ocr_region_paddle
|
||||
|
||||
wf_word_dicts = await ocr_region_paddle(dewarped_bgr, region=None)
|
||||
# PaddleOCR returns absolute coordinates, no content_bounds offset needed
|
||||
cached["_paddle_word_dicts"] = wf_word_dicts
|
||||
else:
|
||||
# Get word_dicts from cache or run Tesseract full-page
|
||||
wf_word_dicts = cached.get("_word_dicts")
|
||||
if wf_word_dicts is None:
|
||||
ocr_img_tmp = create_ocr_image(dewarped_bgr)
|
||||
geo_result = detect_column_geometry(ocr_img_tmp, dewarped_bgr)
|
||||
if geo_result is not None:
|
||||
_geoms, left_x, right_x, top_y, bottom_y, wf_word_dicts, inv = geo_result
|
||||
cached["_word_dicts"] = wf_word_dicts
|
||||
cached["_inv"] = inv
|
||||
cached["_content_bounds"] = (left_x, right_x, top_y, bottom_y)
|
||||
|
||||
if not wf_word_dicts:
|
||||
raise HTTPException(status_code=400, detail="No words detected — cannot build words-first grid")
|
||||
|
||||
# Convert word coordinates to absolute image coordinates if needed
|
||||
# (detect_column_geometry returns words relative to content ROI)
|
||||
# PaddleOCR already returns absolute coordinates — skip offset.
|
||||
if engine != "paddle":
|
||||
content_bounds = cached.get("_content_bounds")
|
||||
if content_bounds:
|
||||
lx, _rx, ty, _by = content_bounds
|
||||
abs_words = []
|
||||
for w in wf_word_dicts:
|
||||
abs_words.append({
|
||||
**w,
|
||||
'left': w['left'] + lx,
|
||||
'top': w['top'] + ty,
|
||||
})
|
||||
wf_word_dicts = abs_words
|
||||
|
||||
# Extract box rects for box-aware column clustering
|
||||
box_rects = []
|
||||
for zone in zones:
|
||||
if zone.get("zone_type") == "box" and zone.get("box"):
|
||||
box_rects.append(zone["box"])
|
||||
|
||||
cells, columns_meta = build_grid_from_words(
|
||||
wf_word_dicts, img_w, img_h, box_rects=box_rects or None,
|
||||
)
|
||||
duration = time.time() - t0
|
||||
|
||||
# Apply IPA phonetic fixes
|
||||
fix_cell_phonetics(cells, pronunciation=pronunciation)
|
||||
|
||||
# Add zone_index for backward compat
|
||||
for cell in cells:
|
||||
cell.setdefault("zone_index", 0)
|
||||
|
||||
col_types = {c['type'] for c in columns_meta}
|
||||
is_vocab = bool(col_types & {'column_en', 'column_de'})
|
||||
n_rows = len(set(c['row_index'] for c in cells)) if cells else 0
|
||||
n_cols = len(columns_meta)
|
||||
used_engine = "paddle" if engine == "paddle" else "words_first"
|
||||
|
||||
word_result = {
|
||||
"cells": cells,
|
||||
"grid_shape": {
|
||||
"rows": n_rows,
|
||||
"cols": n_cols,
|
||||
"total_cells": len(cells),
|
||||
},
|
||||
"columns_used": columns_meta,
|
||||
"layout": "vocab" if is_vocab else "generic",
|
||||
"image_width": img_w,
|
||||
"image_height": img_h,
|
||||
"duration_seconds": round(duration, 2),
|
||||
"ocr_engine": used_engine,
|
||||
"grid_method": "words_first",
|
||||
"summary": {
|
||||
"total_cells": len(cells),
|
||||
"non_empty_cells": sum(1 for c in cells if c.get("text")),
|
||||
"low_confidence": sum(1 for c in cells if 0 < c.get("confidence", 0) < 50),
|
||||
},
|
||||
}
|
||||
|
||||
if is_vocab or 'column_text' in col_types:
|
||||
entries = _cells_to_vocab_entries(cells, columns_meta)
|
||||
entries = _fix_phonetic_brackets(entries, pronunciation=pronunciation)
|
||||
word_result["vocab_entries"] = entries
|
||||
word_result["entries"] = entries
|
||||
word_result["entry_count"] = len(entries)
|
||||
word_result["summary"]["total_entries"] = len(entries)
|
||||
word_result["summary"]["with_english"] = sum(1 for e in entries if e.get("english"))
|
||||
word_result["summary"]["with_german"] = sum(1 for e in entries if e.get("german"))
|
||||
|
||||
await update_session_db(session_id, word_result=word_result, current_step=8)
|
||||
cached["word_result"] = word_result
|
||||
|
||||
logger.info(f"OCR Pipeline: words-first session {session_id}: "
|
||||
f"{len(cells)} cells ({duration:.2f}s), {n_rows} rows, {n_cols} cols")
|
||||
|
||||
await _append_pipeline_log(session_id, "words", {
|
||||
"grid_method": "words_first",
|
||||
"total_cells": len(cells),
|
||||
"non_empty_cells": word_result["summary"]["non_empty_cells"],
|
||||
"ocr_engine": used_engine,
|
||||
"layout": word_result["layout"],
|
||||
}, duration_ms=int(duration * 1000))
|
||||
|
||||
return {"session_id": session_id, **word_result}
|
||||
|
||||
if stream:
|
||||
# Cell-First OCR v2: use batch-then-stream approach instead of
|
||||
# per-cell streaming. The parallel ThreadPoolExecutor in
|
||||
# build_cell_grid_v2 is much faster than sequential streaming.
|
||||
return StreamingResponse(
|
||||
_word_batch_stream_generator(
|
||||
session_id, cached, col_regions, row_geoms,
|
||||
dewarped_bgr, engine, pronunciation, request,
|
||||
skip_heal_gaps=skip_heal_gaps,
|
||||
),
|
||||
media_type="text/event-stream",
|
||||
headers={
|
||||
"Cache-Control": "no-cache",
|
||||
"Connection": "keep-alive",
|
||||
"X-Accel-Buffering": "no",
|
||||
},
|
||||
)
|
||||
|
||||
# --- Non-streaming path (grid_method=v2) ---
|
||||
t0 = time.time()
|
||||
|
||||
# Create binarized OCR image (for Tesseract)
|
||||
ocr_img = create_ocr_image(dewarped_bgr)
|
||||
img_h, img_w = dewarped_bgr.shape[:2]
|
||||
|
||||
# Build cell grid using Cell-First OCR (v2) — each cell cropped in isolation
|
||||
cells, columns_meta = build_cell_grid_v2(
|
||||
ocr_img, col_regions, row_geoms, img_w, img_h,
|
||||
ocr_engine=engine, img_bgr=dewarped_bgr,
|
||||
skip_heal_gaps=skip_heal_gaps,
|
||||
)
|
||||
duration = time.time() - t0
|
||||
|
||||
# Add zone_index to each cell (default 0 for backward compatibility)
|
||||
for cell in cells:
|
||||
cell.setdefault("zone_index", 0)
|
||||
|
||||
# Layout detection
|
||||
col_types = {c['type'] for c in columns_meta}
|
||||
is_vocab = bool(col_types & {'column_en', 'column_de'})
|
||||
|
||||
# Count content rows and columns for grid_shape
|
||||
n_content_rows = len([r for r in row_geoms if r.row_type == 'content'])
|
||||
n_cols = len(columns_meta)
|
||||
|
||||
# Determine which engine was actually used
|
||||
used_engine = cells[0].get("ocr_engine", "tesseract") if cells else engine
|
||||
|
||||
# Apply IPA phonetic fixes directly to cell texts (for overlay mode)
|
||||
fix_cell_phonetics(cells, pronunciation=pronunciation)
|
||||
|
||||
# Grid result (always generic)
|
||||
word_result = {
|
||||
"cells": cells,
|
||||
"grid_shape": {
|
||||
"rows": n_content_rows,
|
||||
"cols": n_cols,
|
||||
"total_cells": len(cells),
|
||||
},
|
||||
"columns_used": columns_meta,
|
||||
"layout": "vocab" if is_vocab else "generic",
|
||||
"image_width": img_w,
|
||||
"image_height": img_h,
|
||||
"duration_seconds": round(duration, 2),
|
||||
"ocr_engine": used_engine,
|
||||
"summary": {
|
||||
"total_cells": len(cells),
|
||||
"non_empty_cells": sum(1 for c in cells if c.get("text")),
|
||||
"low_confidence": sum(1 for c in cells if 0 < c.get("confidence", 0) < 50),
|
||||
},
|
||||
}
|
||||
|
||||
# For vocab layout or single-column (box sub-sessions): map cells 1:1
|
||||
# to vocab entries (row→entry).
|
||||
has_text_col = 'column_text' in col_types
|
||||
if is_vocab or has_text_col:
|
||||
entries = _cells_to_vocab_entries(cells, columns_meta)
|
||||
entries = _fix_phonetic_brackets(entries, pronunciation=pronunciation)
|
||||
word_result["vocab_entries"] = entries
|
||||
word_result["entries"] = entries
|
||||
word_result["entry_count"] = len(entries)
|
||||
word_result["summary"]["total_entries"] = len(entries)
|
||||
word_result["summary"]["with_english"] = sum(1 for e in entries if e.get("english"))
|
||||
word_result["summary"]["with_german"] = sum(1 for e in entries if e.get("german"))
|
||||
|
||||
# Persist to DB
|
||||
await update_session_db(
|
||||
session_id,
|
||||
word_result=word_result,
|
||||
current_step=8,
|
||||
)
|
||||
|
||||
cached["word_result"] = word_result
|
||||
|
||||
logger.info(f"OCR Pipeline: words session {session_id}: "
|
||||
f"layout={word_result['layout']}, "
|
||||
f"{len(cells)} cells ({duration:.2f}s), summary: {word_result['summary']}")
|
||||
|
||||
await _append_pipeline_log(session_id, "words", {
|
||||
"total_cells": len(cells),
|
||||
"non_empty_cells": word_result["summary"]["non_empty_cells"],
|
||||
"low_confidence_count": word_result["summary"]["low_confidence"],
|
||||
"ocr_engine": used_engine,
|
||||
"layout": word_result["layout"],
|
||||
"entry_count": word_result.get("entry_count", 0),
|
||||
}, duration_ms=int(duration * 1000))
|
||||
|
||||
return {
|
||||
"session_id": session_id,
|
||||
**word_result,
|
||||
}
|
||||
|
||||
|
||||
async def _word_batch_stream_generator(
|
||||
session_id: str,
|
||||
cached: Dict[str, Any],
|
||||
col_regions: List[PageRegion],
|
||||
row_geoms: List[RowGeometry],
|
||||
dewarped_bgr: np.ndarray,
|
||||
engine: str,
|
||||
pronunciation: str,
|
||||
request: Request,
|
||||
skip_heal_gaps: bool = False,
|
||||
):
|
||||
"""SSE generator that runs batch OCR (parallel) then streams results.
|
||||
|
||||
Unlike the old per-cell streaming, this uses build_cell_grid_v2 with
|
||||
ThreadPoolExecutor for parallel OCR, then emits all cells as SSE events.
|
||||
The 'preparing' event keeps the connection alive during OCR processing.
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
t0 = time.time()
|
||||
ocr_img = create_ocr_image(dewarped_bgr)
|
||||
img_h, img_w = dewarped_bgr.shape[:2]
|
||||
|
||||
_skip_types = {'column_ignore', 'header', 'footer', 'margin_top', 'margin_bottom', 'margin_left', 'margin_right'}
|
||||
n_content_rows = len([r for r in row_geoms if r.row_type == 'content'])
|
||||
n_cols = len([c for c in col_regions if c.type not in _skip_types])
|
||||
col_types = {c.type for c in col_regions if c.type not in _skip_types}
|
||||
is_vocab = bool(col_types & {'column_en', 'column_de'})
|
||||
total_cells = n_content_rows * n_cols
|
||||
|
||||
# 1. Send meta event immediately
|
||||
meta_event = {
|
||||
"type": "meta",
|
||||
"grid_shape": {"rows": n_content_rows, "cols": n_cols, "total_cells": total_cells},
|
||||
"layout": "vocab" if is_vocab else "generic",
|
||||
}
|
||||
yield f"data: {json.dumps(meta_event)}\n\n"
|
||||
|
||||
# 2. Send preparing event (keepalive for proxy)
|
||||
yield f"data: {json.dumps({'type': 'preparing', 'message': 'Cell-First OCR laeuft parallel...'})}\n\n"
|
||||
|
||||
# 3. Run batch OCR in thread pool with periodic keepalive events.
|
||||
# The OCR takes 30-60s and proxy servers (Nginx) may drop idle SSE
|
||||
# connections after 30-60s. Send keepalive every 5s to prevent this.
|
||||
loop = asyncio.get_event_loop()
|
||||
ocr_future = loop.run_in_executor(
|
||||
None,
|
||||
lambda: build_cell_grid_v2(
|
||||
ocr_img, col_regions, row_geoms, img_w, img_h,
|
||||
ocr_engine=engine, img_bgr=dewarped_bgr,
|
||||
skip_heal_gaps=skip_heal_gaps,
|
||||
),
|
||||
)
|
||||
|
||||
# Send keepalive events every 5 seconds while OCR runs
|
||||
keepalive_count = 0
|
||||
while not ocr_future.done():
|
||||
try:
|
||||
cells, columns_meta = await asyncio.wait_for(
|
||||
asyncio.shield(ocr_future), timeout=5.0,
|
||||
)
|
||||
break # OCR finished
|
||||
except asyncio.TimeoutError:
|
||||
keepalive_count += 1
|
||||
elapsed = int(time.time() - t0)
|
||||
yield f"data: {json.dumps({'type': 'keepalive', 'elapsed': elapsed, 'message': f'OCR laeuft... ({elapsed}s)'})}\n\n"
|
||||
if await request.is_disconnected():
|
||||
logger.info(f"SSE batch: client disconnected during OCR for {session_id}")
|
||||
ocr_future.cancel()
|
||||
return
|
||||
else:
|
||||
cells, columns_meta = ocr_future.result()
|
||||
|
||||
if await request.is_disconnected():
|
||||
logger.info(f"SSE batch: client disconnected after OCR for {session_id}")
|
||||
return
|
||||
|
||||
# 4. Apply IPA phonetic fixes directly to cell texts (for overlay mode)
|
||||
fix_cell_phonetics(cells, pronunciation=pronunciation)
|
||||
|
||||
# 5. Send columns meta
|
||||
if columns_meta:
|
||||
yield f"data: {json.dumps({'type': 'columns', 'columns_used': columns_meta})}\n\n"
|
||||
|
||||
# 6. Stream all cells
|
||||
for idx, cell in enumerate(cells):
|
||||
cell_event = {
|
||||
"type": "cell",
|
||||
"cell": cell,
|
||||
"progress": {"current": idx + 1, "total": len(cells)},
|
||||
}
|
||||
yield f"data: {json.dumps(cell_event)}\n\n"
|
||||
|
||||
# 6. Build final result and persist
|
||||
duration = time.time() - t0
|
||||
used_engine = cells[0].get("ocr_engine", "tesseract") if cells else engine
|
||||
|
||||
word_result = {
|
||||
"cells": cells,
|
||||
"grid_shape": {"rows": n_content_rows, "cols": n_cols, "total_cells": len(cells)},
|
||||
"columns_used": columns_meta,
|
||||
"layout": "vocab" if is_vocab else "generic",
|
||||
"image_width": img_w,
|
||||
"image_height": img_h,
|
||||
"duration_seconds": round(duration, 2),
|
||||
"ocr_engine": used_engine,
|
||||
"summary": {
|
||||
"total_cells": len(cells),
|
||||
"non_empty_cells": sum(1 for c in cells if c.get("text")),
|
||||
"low_confidence": sum(1 for c in cells if 0 < c.get("confidence", 0) < 50),
|
||||
},
|
||||
}
|
||||
|
||||
vocab_entries = None
|
||||
has_text_col = 'column_text' in col_types
|
||||
if is_vocab or has_text_col:
|
||||
entries = _cells_to_vocab_entries(cells, columns_meta)
|
||||
entries = _fix_phonetic_brackets(entries, pronunciation=pronunciation)
|
||||
word_result["vocab_entries"] = entries
|
||||
word_result["entries"] = entries
|
||||
word_result["entry_count"] = len(entries)
|
||||
word_result["summary"]["total_entries"] = len(entries)
|
||||
word_result["summary"]["with_english"] = sum(1 for e in entries if e.get("english"))
|
||||
word_result["summary"]["with_german"] = sum(1 for e in entries if e.get("german"))
|
||||
vocab_entries = entries
|
||||
|
||||
await update_session_db(session_id, word_result=word_result, current_step=8)
|
||||
cached["word_result"] = word_result
|
||||
|
||||
logger.info(f"OCR Pipeline SSE batch: words session {session_id}: "
|
||||
f"layout={word_result['layout']}, {len(cells)} cells ({duration:.2f}s)")
|
||||
|
||||
# 7. Send complete event
|
||||
complete_event = {
|
||||
"type": "complete",
|
||||
"summary": word_result["summary"],
|
||||
"duration_seconds": round(duration, 2),
|
||||
"ocr_engine": used_engine,
|
||||
}
|
||||
if vocab_entries is not None:
|
||||
complete_event["vocab_entries"] = vocab_entries
|
||||
yield f"data: {json.dumps(complete_event)}\n\n"
|
||||
|
||||
|
||||
async def _word_stream_generator(
|
||||
session_id: str,
|
||||
cached: Dict[str, Any],
|
||||
col_regions: List[PageRegion],
|
||||
row_geoms: List[RowGeometry],
|
||||
dewarped_bgr: np.ndarray,
|
||||
engine: str,
|
||||
pronunciation: str,
|
||||
request: Request,
|
||||
):
|
||||
"""SSE generator that yields cell-by-cell OCR progress."""
|
||||
t0 = time.time()
|
||||
|
||||
ocr_img = create_ocr_image(dewarped_bgr)
|
||||
img_h, img_w = dewarped_bgr.shape[:2]
|
||||
|
||||
# Compute grid shape upfront for the meta event
|
||||
n_content_rows = len([r for r in row_geoms if r.row_type == 'content'])
|
||||
_skip_types = {'column_ignore', 'header', 'footer', 'margin_top', 'margin_bottom', 'margin_left', 'margin_right'}
|
||||
n_cols = len([c for c in col_regions if c.type not in _skip_types])
|
||||
|
||||
# Determine layout
|
||||
col_types = {c.type for c in col_regions if c.type not in _skip_types}
|
||||
is_vocab = bool(col_types & {'column_en', 'column_de'})
|
||||
|
||||
# Start streaming — first event: meta
|
||||
columns_meta = None # will be set from first yield
|
||||
total_cells = n_content_rows * n_cols
|
||||
|
||||
meta_event = {
|
||||
"type": "meta",
|
||||
"grid_shape": {"rows": n_content_rows, "cols": n_cols, "total_cells": total_cells},
|
||||
"layout": "vocab" if is_vocab else "generic",
|
||||
}
|
||||
yield f"data: {json.dumps(meta_event)}\n\n"
|
||||
|
||||
# Keepalive: send preparing event so proxy doesn't timeout during OCR init
|
||||
yield f"data: {json.dumps({'type': 'preparing', 'message': 'Cell-First OCR wird initialisiert...'})}\n\n"
|
||||
|
||||
# Stream cells one by one
|
||||
all_cells: List[Dict[str, Any]] = []
|
||||
cell_idx = 0
|
||||
last_keepalive = time.time()
|
||||
|
||||
for cell, cols_meta, total in build_cell_grid_v2_streaming(
|
||||
ocr_img, col_regions, row_geoms, img_w, img_h,
|
||||
ocr_engine=engine, img_bgr=dewarped_bgr,
|
||||
):
|
||||
if await request.is_disconnected():
|
||||
logger.info(f"SSE: client disconnected during streaming for {session_id}")
|
||||
return
|
||||
|
||||
if columns_meta is None:
|
||||
columns_meta = cols_meta
|
||||
# Send columns_used as part of first cell or update meta
|
||||
meta_update = {
|
||||
"type": "columns",
|
||||
"columns_used": cols_meta,
|
||||
}
|
||||
yield f"data: {json.dumps(meta_update)}\n\n"
|
||||
|
||||
all_cells.append(cell)
|
||||
cell_idx += 1
|
||||
|
||||
cell_event = {
|
||||
"type": "cell",
|
||||
"cell": cell,
|
||||
"progress": {"current": cell_idx, "total": total},
|
||||
}
|
||||
yield f"data: {json.dumps(cell_event)}\n\n"
|
||||
|
||||
# All cells done — build final result
|
||||
duration = time.time() - t0
|
||||
if columns_meta is None:
|
||||
columns_meta = []
|
||||
|
||||
# Post-OCR: remove rows where ALL cells are empty (inter-row gaps
|
||||
# that had stray Tesseract artifacts giving word_count > 0).
|
||||
rows_with_text: set = set()
|
||||
for c in all_cells:
|
||||
if c.get("text", "").strip():
|
||||
rows_with_text.add(c["row_index"])
|
||||
before_filter = len(all_cells)
|
||||
all_cells = [c for c in all_cells if c["row_index"] in rows_with_text]
|
||||
empty_rows_removed = (before_filter - len(all_cells)) // max(n_cols, 1)
|
||||
if empty_rows_removed > 0:
|
||||
logger.info(f"SSE: removed {empty_rows_removed} all-empty rows after OCR")
|
||||
|
||||
used_engine = all_cells[0].get("ocr_engine", "tesseract") if all_cells else engine
|
||||
|
||||
# Apply IPA phonetic fixes directly to cell texts (for overlay mode)
|
||||
fix_cell_phonetics(all_cells, pronunciation=pronunciation)
|
||||
|
||||
word_result = {
|
||||
"cells": all_cells,
|
||||
"grid_shape": {
|
||||
"rows": n_content_rows,
|
||||
"cols": n_cols,
|
||||
"total_cells": len(all_cells),
|
||||
},
|
||||
"columns_used": columns_meta,
|
||||
"layout": "vocab" if is_vocab else "generic",
|
||||
"image_width": img_w,
|
||||
"image_height": img_h,
|
||||
"duration_seconds": round(duration, 2),
|
||||
"ocr_engine": used_engine,
|
||||
"summary": {
|
||||
"total_cells": len(all_cells),
|
||||
"non_empty_cells": sum(1 for c in all_cells if c.get("text")),
|
||||
"low_confidence": sum(1 for c in all_cells if 0 < c.get("confidence", 0) < 50),
|
||||
},
|
||||
}
|
||||
|
||||
# For vocab layout or single-column (box sub-sessions): map cells 1:1
|
||||
# to vocab entries (row→entry).
|
||||
vocab_entries = None
|
||||
has_text_col = 'column_text' in col_types
|
||||
if is_vocab or has_text_col:
|
||||
entries = _cells_to_vocab_entries(all_cells, columns_meta)
|
||||
entries = _fix_character_confusion(entries)
|
||||
entries = _fix_phonetic_brackets(entries, pronunciation=pronunciation)
|
||||
word_result["vocab_entries"] = entries
|
||||
word_result["entries"] = entries
|
||||
word_result["entry_count"] = len(entries)
|
||||
word_result["summary"]["total_entries"] = len(entries)
|
||||
word_result["summary"]["with_english"] = sum(1 for e in entries if e.get("english"))
|
||||
word_result["summary"]["with_german"] = sum(1 for e in entries if e.get("german"))
|
||||
vocab_entries = entries
|
||||
|
||||
# Persist to DB
|
||||
await update_session_db(
|
||||
session_id,
|
||||
word_result=word_result,
|
||||
current_step=8,
|
||||
)
|
||||
cached["word_result"] = word_result
|
||||
|
||||
logger.info(f"OCR Pipeline SSE: words session {session_id}: "
|
||||
f"layout={word_result['layout']}, "
|
||||
f"{len(all_cells)} cells ({duration:.2f}s)")
|
||||
|
||||
# Final complete event
|
||||
complete_event = {
|
||||
"type": "complete",
|
||||
"summary": word_result["summary"],
|
||||
"duration_seconds": round(duration, 2),
|
||||
"ocr_engine": used_engine,
|
||||
}
|
||||
if vocab_entries is not None:
|
||||
complete_event["vocab_entries"] = vocab_entries
|
||||
yield f"data: {json.dumps(complete_event)}\n\n"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PaddleOCR Direct Endpoint
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.post("/sessions/{session_id}/paddle-direct")
|
||||
@_local_router.post("/sessions/{session_id}/paddle-direct")
|
||||
async def paddle_direct(session_id: str):
|
||||
"""Run PaddleOCR on the preprocessed image and build a word grid directly.
|
||||
|
||||
Expects orientation/deskew/dewarp/crop to be done already.
|
||||
Uses the cropped image (falls back to dewarped, then original).
|
||||
The used image is stored as cropped_png so OverlayReconstruction
|
||||
can display it as the background.
|
||||
"""
|
||||
# Try preprocessed images first (crop > dewarp > original)
|
||||
"""Run PaddleOCR on the preprocessed image and build a word grid directly."""
|
||||
img_png = await get_session_image(session_id, "cropped")
|
||||
if not img_png:
|
||||
img_png = await get_session_image(session_id, "dewarped")
|
||||
@@ -770,13 +79,9 @@ async def paddle_direct(session_id: str):
|
||||
if not word_dicts:
|
||||
raise HTTPException(status_code=400, detail="PaddleOCR returned no words")
|
||||
|
||||
# Reuse build_grid_from_words — same function that works in the regular
|
||||
# pipeline with PaddleOCR (engine=paddle, grid_method=words_first).
|
||||
# Handles phrase splitting, column clustering, and reading order.
|
||||
cells, columns_meta = build_grid_from_words(word_dicts, img_w, img_h)
|
||||
duration = time.time() - t0
|
||||
|
||||
# Tag cells as paddle_direct
|
||||
for cell in cells:
|
||||
cell["ocr_engine"] = "paddle_direct"
|
||||
|
||||
@@ -787,11 +92,7 @@ async def paddle_direct(session_id: str):
|
||||
|
||||
word_result = {
|
||||
"cells": cells,
|
||||
"grid_shape": {
|
||||
"rows": n_rows,
|
||||
"cols": n_cols,
|
||||
"total_cells": len(cells),
|
||||
},
|
||||
"grid_shape": {"rows": n_rows, "cols": n_cols, "total_cells": len(cells)},
|
||||
"columns_used": columns_meta,
|
||||
"layout": "vocab" if is_vocab else "generic",
|
||||
"image_width": img_w,
|
||||
@@ -806,7 +107,6 @@ async def paddle_direct(session_id: str):
|
||||
},
|
||||
}
|
||||
|
||||
# Store preprocessed image as cropped_png so OverlayReconstruction shows it
|
||||
await update_session_db(
|
||||
session_id,
|
||||
word_result=word_result,
|
||||
@@ -832,7 +132,7 @@ async def paddle_direct(session_id: str):
|
||||
# Ground Truth Words Endpoints
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.post("/sessions/{session_id}/ground-truth/words")
|
||||
@_local_router.post("/sessions/{session_id}/ground-truth/words")
|
||||
async def save_word_ground_truth(session_id: str, req: WordGroundTruthRequest):
|
||||
"""Save ground truth feedback for the word recognition step."""
|
||||
session = await get_session_db(session_id)
|
||||
@@ -857,7 +157,7 @@ async def save_word_ground_truth(session_id: str, req: WordGroundTruthRequest):
|
||||
return {"session_id": session_id, "ground_truth": gt}
|
||||
|
||||
|
||||
@router.get("/sessions/{session_id}/ground-truth/words")
|
||||
@_local_router.get("/sessions/{session_id}/ground-truth/words")
|
||||
async def get_word_ground_truth(session_id: str):
|
||||
"""Retrieve saved ground truth for word recognition."""
|
||||
session = await get_session_db(session_id)
|
||||
@@ -874,3 +174,12 @@ async def get_word_ground_truth(session_id: str):
|
||||
"words_gt": words_gt,
|
||||
"words_auto": session.get("word_result"),
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Composite router
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
router = APIRouter()
|
||||
router.include_router(_detect_router)
|
||||
router.include_router(_local_router)
|
||||
|
||||
@@ -0,0 +1,393 @@
|
||||
"""
|
||||
OCR Pipeline Words Detect — main word detection endpoint (Step 7).
|
||||
|
||||
Extracted from ocr_pipeline_words.py. Contains the ``detect_words``
|
||||
endpoint which handles both v2 and words_first grid methods.
|
||||
|
||||
Lizenz: Apache 2.0
|
||||
DATENSCHUTZ: Alle Verarbeitung erfolgt lokal.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from typing import Any, Dict, List
|
||||
|
||||
import numpy as np
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
from cv_vocab_pipeline import (
|
||||
PageRegion,
|
||||
RowGeometry,
|
||||
_cells_to_vocab_entries,
|
||||
_fix_phonetic_brackets,
|
||||
fix_cell_phonetics,
|
||||
build_cell_grid_v2,
|
||||
create_ocr_image,
|
||||
detect_column_geometry,
|
||||
)
|
||||
from cv_words_first import build_grid_from_words
|
||||
from ocr_pipeline_session_store import (
|
||||
get_session_db,
|
||||
update_session_db,
|
||||
)
|
||||
from ocr_pipeline_common import (
|
||||
_cache,
|
||||
_load_session_to_cache,
|
||||
_get_cached,
|
||||
_append_pipeline_log,
|
||||
)
|
||||
from ocr_pipeline_words_stream import (
|
||||
_word_batch_stream_generator,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/v1/ocr-pipeline", tags=["ocr-pipeline"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Word Detection Endpoint (Step 7)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.post("/sessions/{session_id}/words")
|
||||
async def detect_words(
|
||||
session_id: str,
|
||||
request: Request,
|
||||
engine: str = "auto",
|
||||
pronunciation: str = "british",
|
||||
stream: bool = False,
|
||||
skip_heal_gaps: bool = False,
|
||||
grid_method: str = "v2",
|
||||
):
|
||||
"""Build word grid from columns x rows, OCR each cell.
|
||||
|
||||
Query params:
|
||||
engine: 'auto' (default), 'tesseract', 'rapid', or 'paddle'
|
||||
pronunciation: 'british' (default) or 'american'
|
||||
stream: false (default) for JSON response, true for SSE streaming
|
||||
skip_heal_gaps: false (default). When true, cells keep exact row geometry.
|
||||
grid_method: 'v2' (default) or 'words_first'
|
||||
"""
|
||||
# PaddleOCR is full-page remote OCR -> force words_first grid method
|
||||
if engine == "paddle" and grid_method != "words_first":
|
||||
logger.info("detect_words: engine=paddle requires words_first, overriding grid_method=%s", grid_method)
|
||||
grid_method = "words_first"
|
||||
|
||||
if session_id not in _cache:
|
||||
logger.info("detect_words: session %s not in cache, loading from DB", session_id)
|
||||
await _load_session_to_cache(session_id)
|
||||
cached = _get_cached(session_id)
|
||||
|
||||
dewarped_bgr = cached.get("cropped_bgr") if cached.get("cropped_bgr") is not None else cached.get("dewarped_bgr")
|
||||
if dewarped_bgr is None:
|
||||
logger.warning("detect_words: no cropped/dewarped image for session %s (cache keys: %s)",
|
||||
session_id, [k for k in cached.keys() if k.endswith('_bgr')])
|
||||
raise HTTPException(status_code=400, detail="Crop or dewarp must be completed before word detection")
|
||||
|
||||
session = await get_session_db(session_id)
|
||||
if not session:
|
||||
raise HTTPException(status_code=404, detail=f"Session {session_id} not found")
|
||||
|
||||
column_result = session.get("column_result")
|
||||
row_result = session.get("row_result")
|
||||
if not column_result or not column_result.get("columns"):
|
||||
img_h_tmp, img_w_tmp = dewarped_bgr.shape[:2]
|
||||
column_result = {
|
||||
"columns": [{
|
||||
"type": "column_text",
|
||||
"x": 0, "y": 0,
|
||||
"width": img_w_tmp, "height": img_h_tmp,
|
||||
"classification_confidence": 1.0,
|
||||
"classification_method": "full_page_fallback",
|
||||
}],
|
||||
"zones": [],
|
||||
"duration_seconds": 0,
|
||||
}
|
||||
logger.info("detect_words: no column_result -- using full-page pseudo-column %dx%d", img_w_tmp, img_h_tmp)
|
||||
if grid_method != "words_first" and (not row_result or not row_result.get("rows")):
|
||||
raise HTTPException(status_code=400, detail="Row detection must be completed first")
|
||||
|
||||
# Convert column dicts back to PageRegion objects
|
||||
col_regions = [
|
||||
PageRegion(
|
||||
type=c["type"],
|
||||
x=c["x"], y=c["y"],
|
||||
width=c["width"], height=c["height"],
|
||||
classification_confidence=c.get("classification_confidence", 1.0),
|
||||
classification_method=c.get("classification_method", ""),
|
||||
)
|
||||
for c in column_result["columns"]
|
||||
]
|
||||
|
||||
# Convert row dicts back to RowGeometry objects
|
||||
row_geoms = [
|
||||
RowGeometry(
|
||||
index=r["index"],
|
||||
x=r["x"], y=r["y"],
|
||||
width=r["width"], height=r["height"],
|
||||
word_count=r.get("word_count", 0),
|
||||
words=[],
|
||||
row_type=r.get("row_type", "content"),
|
||||
gap_before=r.get("gap_before", 0),
|
||||
)
|
||||
for r in row_result["rows"]
|
||||
]
|
||||
|
||||
# Populate word counts from cached words
|
||||
word_dicts = cached.get("_word_dicts")
|
||||
if word_dicts is None:
|
||||
ocr_img_tmp = create_ocr_image(dewarped_bgr)
|
||||
geo_result = detect_column_geometry(ocr_img_tmp, dewarped_bgr)
|
||||
if geo_result is not None:
|
||||
_geoms, left_x, right_x, top_y, bottom_y, word_dicts, inv = geo_result
|
||||
cached["_word_dicts"] = word_dicts
|
||||
cached["_inv"] = inv
|
||||
cached["_content_bounds"] = (left_x, right_x, top_y, bottom_y)
|
||||
|
||||
if word_dicts:
|
||||
content_bounds = cached.get("_content_bounds")
|
||||
if content_bounds:
|
||||
_lx, _rx, top_y, _by = content_bounds
|
||||
else:
|
||||
top_y = min(r.y for r in row_geoms) if row_geoms else 0
|
||||
|
||||
for row in row_geoms:
|
||||
row_y_rel = row.y - top_y
|
||||
row_bottom_rel = row_y_rel + row.height
|
||||
row.words = [
|
||||
w for w in word_dicts
|
||||
if row_y_rel <= w['top'] + w['height'] / 2 < row_bottom_rel
|
||||
]
|
||||
row.word_count = len(row.words)
|
||||
|
||||
# Exclude rows that fall within box zones
|
||||
zones = column_result.get("zones") or []
|
||||
box_ranges_inner = []
|
||||
for zone in zones:
|
||||
if zone.get("zone_type") == "box" and zone.get("box"):
|
||||
box = zone["box"]
|
||||
bt = max(box.get("border_thickness", 0), 5)
|
||||
box_ranges_inner.append((box["y"] + bt, box["y"] + box["height"] - bt))
|
||||
|
||||
if box_ranges_inner:
|
||||
def _row_in_box(r):
|
||||
center_y = r.y + r.height / 2
|
||||
return any(by_s <= center_y < by_e for by_s, by_e in box_ranges_inner)
|
||||
|
||||
before_count = len(row_geoms)
|
||||
row_geoms = [r for r in row_geoms if not _row_in_box(r)]
|
||||
excluded = before_count - len(row_geoms)
|
||||
if excluded:
|
||||
logger.info(f"detect_words: excluded {excluded} rows inside box zones")
|
||||
|
||||
# --- Words-First path ---
|
||||
if grid_method == "words_first":
|
||||
return await _words_first_path(
|
||||
session_id, cached, dewarped_bgr, engine, pronunciation, zones,
|
||||
)
|
||||
|
||||
if stream:
|
||||
return StreamingResponse(
|
||||
_word_batch_stream_generator(
|
||||
session_id, cached, col_regions, row_geoms,
|
||||
dewarped_bgr, engine, pronunciation, request,
|
||||
skip_heal_gaps=skip_heal_gaps,
|
||||
),
|
||||
media_type="text/event-stream",
|
||||
headers={
|
||||
"Cache-Control": "no-cache",
|
||||
"Connection": "keep-alive",
|
||||
"X-Accel-Buffering": "no",
|
||||
},
|
||||
)
|
||||
|
||||
# --- Non-streaming path (grid_method=v2) ---
|
||||
return await _v2_path(
|
||||
session_id, cached, col_regions, row_geoms,
|
||||
dewarped_bgr, engine, pronunciation, skip_heal_gaps,
|
||||
)
|
||||
|
||||
|
||||
async def _words_first_path(
|
||||
session_id: str,
|
||||
cached: Dict[str, Any],
|
||||
dewarped_bgr: np.ndarray,
|
||||
engine: str,
|
||||
pronunciation: str,
|
||||
zones: list,
|
||||
) -> dict:
|
||||
"""Words-first grid construction path."""
|
||||
t0 = time.time()
|
||||
img_h, img_w = dewarped_bgr.shape[:2]
|
||||
|
||||
if engine == "paddle":
|
||||
from cv_ocr_engines import ocr_region_paddle
|
||||
wf_word_dicts = await ocr_region_paddle(dewarped_bgr, region=None)
|
||||
cached["_paddle_word_dicts"] = wf_word_dicts
|
||||
else:
|
||||
wf_word_dicts = cached.get("_word_dicts")
|
||||
if wf_word_dicts is None:
|
||||
ocr_img_tmp = create_ocr_image(dewarped_bgr)
|
||||
geo_result = detect_column_geometry(ocr_img_tmp, dewarped_bgr)
|
||||
if geo_result is not None:
|
||||
_geoms, left_x, right_x, top_y, bottom_y, wf_word_dicts, inv = geo_result
|
||||
cached["_word_dicts"] = wf_word_dicts
|
||||
cached["_inv"] = inv
|
||||
cached["_content_bounds"] = (left_x, right_x, top_y, bottom_y)
|
||||
|
||||
if not wf_word_dicts:
|
||||
raise HTTPException(status_code=400, detail="No words detected -- cannot build words-first grid")
|
||||
|
||||
# Convert word coordinates to absolute if needed
|
||||
if engine != "paddle":
|
||||
content_bounds = cached.get("_content_bounds")
|
||||
if content_bounds:
|
||||
lx, _rx, ty, _by = content_bounds
|
||||
abs_words = []
|
||||
for w in wf_word_dicts:
|
||||
abs_words.append({**w, 'left': w['left'] + lx, 'top': w['top'] + ty})
|
||||
wf_word_dicts = abs_words
|
||||
|
||||
box_rects = []
|
||||
for zone in zones:
|
||||
if zone.get("zone_type") == "box" and zone.get("box"):
|
||||
box_rects.append(zone["box"])
|
||||
|
||||
cells, columns_meta = build_grid_from_words(
|
||||
wf_word_dicts, img_w, img_h, box_rects=box_rects or None,
|
||||
)
|
||||
duration = time.time() - t0
|
||||
|
||||
fix_cell_phonetics(cells, pronunciation=pronunciation)
|
||||
for cell in cells:
|
||||
cell.setdefault("zone_index", 0)
|
||||
|
||||
col_types = {c['type'] for c in columns_meta}
|
||||
is_vocab = bool(col_types & {'column_en', 'column_de'})
|
||||
n_rows = len(set(c['row_index'] for c in cells)) if cells else 0
|
||||
n_cols = len(columns_meta)
|
||||
used_engine = "paddle" if engine == "paddle" else "words_first"
|
||||
|
||||
word_result = {
|
||||
"cells": cells,
|
||||
"grid_shape": {"rows": n_rows, "cols": n_cols, "total_cells": len(cells)},
|
||||
"columns_used": columns_meta,
|
||||
"layout": "vocab" if is_vocab else "generic",
|
||||
"image_width": img_w,
|
||||
"image_height": img_h,
|
||||
"duration_seconds": round(duration, 2),
|
||||
"ocr_engine": used_engine,
|
||||
"grid_method": "words_first",
|
||||
"summary": {
|
||||
"total_cells": len(cells),
|
||||
"non_empty_cells": sum(1 for c in cells if c.get("text")),
|
||||
"low_confidence": sum(1 for c in cells if 0 < c.get("confidence", 0) < 50),
|
||||
},
|
||||
}
|
||||
|
||||
if is_vocab or 'column_text' in col_types:
|
||||
entries = _cells_to_vocab_entries(cells, columns_meta)
|
||||
entries = _fix_phonetic_brackets(entries, pronunciation=pronunciation)
|
||||
word_result["vocab_entries"] = entries
|
||||
word_result["entries"] = entries
|
||||
word_result["entry_count"] = len(entries)
|
||||
word_result["summary"]["total_entries"] = len(entries)
|
||||
word_result["summary"]["with_english"] = sum(1 for e in entries if e.get("english"))
|
||||
word_result["summary"]["with_german"] = sum(1 for e in entries if e.get("german"))
|
||||
|
||||
await update_session_db(session_id, word_result=word_result, current_step=8)
|
||||
cached["word_result"] = word_result
|
||||
|
||||
logger.info(f"OCR Pipeline: words-first session {session_id}: "
|
||||
f"{len(cells)} cells ({duration:.2f}s), {n_rows} rows, {n_cols} cols")
|
||||
|
||||
await _append_pipeline_log(session_id, "words", {
|
||||
"grid_method": "words_first",
|
||||
"total_cells": len(cells),
|
||||
"non_empty_cells": word_result["summary"]["non_empty_cells"],
|
||||
"ocr_engine": used_engine,
|
||||
"layout": word_result["layout"],
|
||||
}, duration_ms=int(duration * 1000))
|
||||
|
||||
return {"session_id": session_id, **word_result}
|
||||
|
||||
|
||||
async def _v2_path(
|
||||
session_id: str,
|
||||
cached: Dict[str, Any],
|
||||
col_regions: List[PageRegion],
|
||||
row_geoms: List[RowGeometry],
|
||||
dewarped_bgr: np.ndarray,
|
||||
engine: str,
|
||||
pronunciation: str,
|
||||
skip_heal_gaps: bool,
|
||||
) -> dict:
|
||||
"""Cell-First OCR v2 non-streaming path."""
|
||||
t0 = time.time()
|
||||
ocr_img = create_ocr_image(dewarped_bgr)
|
||||
img_h, img_w = dewarped_bgr.shape[:2]
|
||||
|
||||
cells, columns_meta = build_cell_grid_v2(
|
||||
ocr_img, col_regions, row_geoms, img_w, img_h,
|
||||
ocr_engine=engine, img_bgr=dewarped_bgr,
|
||||
skip_heal_gaps=skip_heal_gaps,
|
||||
)
|
||||
duration = time.time() - t0
|
||||
|
||||
for cell in cells:
|
||||
cell.setdefault("zone_index", 0)
|
||||
|
||||
col_types = {c['type'] for c in columns_meta}
|
||||
is_vocab = bool(col_types & {'column_en', 'column_de'})
|
||||
n_content_rows = len([r for r in row_geoms if r.row_type == 'content'])
|
||||
n_cols = len(columns_meta)
|
||||
used_engine = cells[0].get("ocr_engine", "tesseract") if cells else engine
|
||||
|
||||
fix_cell_phonetics(cells, pronunciation=pronunciation)
|
||||
|
||||
word_result = {
|
||||
"cells": cells,
|
||||
"grid_shape": {"rows": n_content_rows, "cols": n_cols, "total_cells": len(cells)},
|
||||
"columns_used": columns_meta,
|
||||
"layout": "vocab" if is_vocab else "generic",
|
||||
"image_width": img_w,
|
||||
"image_height": img_h,
|
||||
"duration_seconds": round(duration, 2),
|
||||
"ocr_engine": used_engine,
|
||||
"summary": {
|
||||
"total_cells": len(cells),
|
||||
"non_empty_cells": sum(1 for c in cells if c.get("text")),
|
||||
"low_confidence": sum(1 for c in cells if 0 < c.get("confidence", 0) < 50),
|
||||
},
|
||||
}
|
||||
|
||||
has_text_col = 'column_text' in col_types
|
||||
if is_vocab or has_text_col:
|
||||
entries = _cells_to_vocab_entries(cells, columns_meta)
|
||||
entries = _fix_phonetic_brackets(entries, pronunciation=pronunciation)
|
||||
word_result["vocab_entries"] = entries
|
||||
word_result["entries"] = entries
|
||||
word_result["entry_count"] = len(entries)
|
||||
word_result["summary"]["total_entries"] = len(entries)
|
||||
word_result["summary"]["with_english"] = sum(1 for e in entries if e.get("english"))
|
||||
word_result["summary"]["with_german"] = sum(1 for e in entries if e.get("german"))
|
||||
|
||||
await update_session_db(session_id, word_result=word_result, current_step=8)
|
||||
cached["word_result"] = word_result
|
||||
|
||||
logger.info(f"OCR Pipeline: words session {session_id}: "
|
||||
f"layout={word_result['layout']}, "
|
||||
f"{len(cells)} cells ({duration:.2f}s), summary: {word_result['summary']}")
|
||||
|
||||
await _append_pipeline_log(session_id, "words", {
|
||||
"total_cells": len(cells),
|
||||
"non_empty_cells": word_result["summary"]["non_empty_cells"],
|
||||
"low_confidence_count": word_result["summary"]["low_confidence"],
|
||||
"ocr_engine": used_engine,
|
||||
"layout": word_result["layout"],
|
||||
"entry_count": word_result.get("entry_count", 0),
|
||||
}, duration_ms=int(duration * 1000))
|
||||
|
||||
return {"session_id": session_id, **word_result}
|
||||
@@ -0,0 +1,303 @@
|
||||
"""
|
||||
OCR Pipeline Words Stream — SSE streaming generators for word detection.
|
||||
|
||||
Extracted from ocr_pipeline_words.py.
|
||||
|
||||
Lizenz: Apache 2.0
|
||||
DATENSCHUTZ: Alle Verarbeitung erfolgt lokal.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from typing import Any, Dict, List
|
||||
|
||||
import numpy as np
|
||||
from fastapi import Request
|
||||
|
||||
from cv_vocab_pipeline import (
|
||||
PageRegion,
|
||||
RowGeometry,
|
||||
_cells_to_vocab_entries,
|
||||
_fix_character_confusion,
|
||||
_fix_phonetic_brackets,
|
||||
fix_cell_phonetics,
|
||||
build_cell_grid_v2,
|
||||
build_cell_grid_v2_streaming,
|
||||
create_ocr_image,
|
||||
)
|
||||
from ocr_pipeline_session_store import update_session_db
|
||||
from ocr_pipeline_common import _cache
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def _word_batch_stream_generator(
|
||||
session_id: str,
|
||||
cached: Dict[str, Any],
|
||||
col_regions: List[PageRegion],
|
||||
row_geoms: List[RowGeometry],
|
||||
dewarped_bgr: np.ndarray,
|
||||
engine: str,
|
||||
pronunciation: str,
|
||||
request: Request,
|
||||
skip_heal_gaps: bool = False,
|
||||
):
|
||||
"""SSE generator that runs batch OCR (parallel) then streams results.
|
||||
|
||||
Uses build_cell_grid_v2 with ThreadPoolExecutor for parallel OCR,
|
||||
then emits all cells as SSE events.
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
t0 = time.time()
|
||||
ocr_img = create_ocr_image(dewarped_bgr)
|
||||
img_h, img_w = dewarped_bgr.shape[:2]
|
||||
|
||||
_skip_types = {'column_ignore', 'header', 'footer', 'margin_top', 'margin_bottom', 'margin_left', 'margin_right'}
|
||||
n_content_rows = len([r for r in row_geoms if r.row_type == 'content'])
|
||||
n_cols = len([c for c in col_regions if c.type not in _skip_types])
|
||||
col_types = {c.type for c in col_regions if c.type not in _skip_types}
|
||||
is_vocab = bool(col_types & {'column_en', 'column_de'})
|
||||
total_cells = n_content_rows * n_cols
|
||||
|
||||
# 1. Send meta event immediately
|
||||
meta_event = {
|
||||
"type": "meta",
|
||||
"grid_shape": {"rows": n_content_rows, "cols": n_cols, "total_cells": total_cells},
|
||||
"layout": "vocab" if is_vocab else "generic",
|
||||
}
|
||||
yield f"data: {json.dumps(meta_event)}\n\n"
|
||||
|
||||
# 2. Send preparing event (keepalive for proxy)
|
||||
yield f"data: {json.dumps({'type': 'preparing', 'message': 'Cell-First OCR laeuft parallel...'})}\n\n"
|
||||
|
||||
# 3. Run batch OCR in thread pool with periodic keepalive events.
|
||||
loop = asyncio.get_event_loop()
|
||||
ocr_future = loop.run_in_executor(
|
||||
None,
|
||||
lambda: build_cell_grid_v2(
|
||||
ocr_img, col_regions, row_geoms, img_w, img_h,
|
||||
ocr_engine=engine, img_bgr=dewarped_bgr,
|
||||
skip_heal_gaps=skip_heal_gaps,
|
||||
),
|
||||
)
|
||||
|
||||
# Send keepalive events every 5 seconds while OCR runs
|
||||
keepalive_count = 0
|
||||
while not ocr_future.done():
|
||||
try:
|
||||
cells, columns_meta = await asyncio.wait_for(
|
||||
asyncio.shield(ocr_future), timeout=5.0,
|
||||
)
|
||||
break # OCR finished
|
||||
except asyncio.TimeoutError:
|
||||
keepalive_count += 1
|
||||
elapsed = int(time.time() - t0)
|
||||
yield f"data: {json.dumps({'type': 'keepalive', 'elapsed': elapsed, 'message': f'OCR laeuft... ({elapsed}s)'})}\n\n"
|
||||
if await request.is_disconnected():
|
||||
logger.info(f"SSE batch: client disconnected during OCR for {session_id}")
|
||||
ocr_future.cancel()
|
||||
return
|
||||
else:
|
||||
cells, columns_meta = ocr_future.result()
|
||||
|
||||
if await request.is_disconnected():
|
||||
logger.info(f"SSE batch: client disconnected after OCR for {session_id}")
|
||||
return
|
||||
|
||||
# 4. Apply IPA phonetic fixes
|
||||
fix_cell_phonetics(cells, pronunciation=pronunciation)
|
||||
|
||||
# 5. Send columns meta
|
||||
if columns_meta:
|
||||
yield f"data: {json.dumps({'type': 'columns', 'columns_used': columns_meta})}\n\n"
|
||||
|
||||
# 6. Stream all cells
|
||||
for idx, cell in enumerate(cells):
|
||||
cell_event = {
|
||||
"type": "cell",
|
||||
"cell": cell,
|
||||
"progress": {"current": idx + 1, "total": len(cells)},
|
||||
}
|
||||
yield f"data: {json.dumps(cell_event)}\n\n"
|
||||
|
||||
# 7. Build final result and persist
|
||||
duration = time.time() - t0
|
||||
used_engine = cells[0].get("ocr_engine", "tesseract") if cells else engine
|
||||
|
||||
word_result = {
|
||||
"cells": cells,
|
||||
"grid_shape": {"rows": n_content_rows, "cols": n_cols, "total_cells": len(cells)},
|
||||
"columns_used": columns_meta,
|
||||
"layout": "vocab" if is_vocab else "generic",
|
||||
"image_width": img_w,
|
||||
"image_height": img_h,
|
||||
"duration_seconds": round(duration, 2),
|
||||
"ocr_engine": used_engine,
|
||||
"summary": {
|
||||
"total_cells": len(cells),
|
||||
"non_empty_cells": sum(1 for c in cells if c.get("text")),
|
||||
"low_confidence": sum(1 for c in cells if 0 < c.get("confidence", 0) < 50),
|
||||
},
|
||||
}
|
||||
|
||||
vocab_entries = None
|
||||
has_text_col = 'column_text' in col_types
|
||||
if is_vocab or has_text_col:
|
||||
entries = _cells_to_vocab_entries(cells, columns_meta)
|
||||
entries = _fix_phonetic_brackets(entries, pronunciation=pronunciation)
|
||||
word_result["vocab_entries"] = entries
|
||||
word_result["entries"] = entries
|
||||
word_result["entry_count"] = len(entries)
|
||||
word_result["summary"]["total_entries"] = len(entries)
|
||||
word_result["summary"]["with_english"] = sum(1 for e in entries if e.get("english"))
|
||||
word_result["summary"]["with_german"] = sum(1 for e in entries if e.get("german"))
|
||||
vocab_entries = entries
|
||||
|
||||
await update_session_db(session_id, word_result=word_result, current_step=8)
|
||||
cached["word_result"] = word_result
|
||||
|
||||
logger.info(f"OCR Pipeline SSE batch: words session {session_id}: "
|
||||
f"layout={word_result['layout']}, {len(cells)} cells ({duration:.2f}s)")
|
||||
|
||||
# 8. Send complete event
|
||||
complete_event = {
|
||||
"type": "complete",
|
||||
"summary": word_result["summary"],
|
||||
"duration_seconds": round(duration, 2),
|
||||
"ocr_engine": used_engine,
|
||||
}
|
||||
if vocab_entries is not None:
|
||||
complete_event["vocab_entries"] = vocab_entries
|
||||
yield f"data: {json.dumps(complete_event)}\n\n"
|
||||
|
||||
|
||||
async def _word_stream_generator(
|
||||
session_id: str,
|
||||
cached: Dict[str, Any],
|
||||
col_regions: List[PageRegion],
|
||||
row_geoms: List[RowGeometry],
|
||||
dewarped_bgr: np.ndarray,
|
||||
engine: str,
|
||||
pronunciation: str,
|
||||
request: Request,
|
||||
):
|
||||
"""SSE generator that yields cell-by-cell OCR progress."""
|
||||
t0 = time.time()
|
||||
|
||||
ocr_img = create_ocr_image(dewarped_bgr)
|
||||
img_h, img_w = dewarped_bgr.shape[:2]
|
||||
|
||||
n_content_rows = len([r for r in row_geoms if r.row_type == 'content'])
|
||||
_skip_types = {'column_ignore', 'header', 'footer', 'margin_top', 'margin_bottom', 'margin_left', 'margin_right'}
|
||||
n_cols = len([c for c in col_regions if c.type not in _skip_types])
|
||||
|
||||
col_types = {c.type for c in col_regions if c.type not in _skip_types}
|
||||
is_vocab = bool(col_types & {'column_en', 'column_de'})
|
||||
|
||||
columns_meta = None
|
||||
total_cells = n_content_rows * n_cols
|
||||
|
||||
meta_event = {
|
||||
"type": "meta",
|
||||
"grid_shape": {"rows": n_content_rows, "cols": n_cols, "total_cells": total_cells},
|
||||
"layout": "vocab" if is_vocab else "generic",
|
||||
}
|
||||
yield f"data: {json.dumps(meta_event)}\n\n"
|
||||
|
||||
yield f"data: {json.dumps({'type': 'preparing', 'message': 'Cell-First OCR wird initialisiert...'})}\n\n"
|
||||
|
||||
all_cells: List[Dict[str, Any]] = []
|
||||
cell_idx = 0
|
||||
last_keepalive = time.time()
|
||||
|
||||
for cell, cols_meta, total in build_cell_grid_v2_streaming(
|
||||
ocr_img, col_regions, row_geoms, img_w, img_h,
|
||||
ocr_engine=engine, img_bgr=dewarped_bgr,
|
||||
):
|
||||
if await request.is_disconnected():
|
||||
logger.info(f"SSE: client disconnected during streaming for {session_id}")
|
||||
return
|
||||
|
||||
if columns_meta is None:
|
||||
columns_meta = cols_meta
|
||||
meta_update = {"type": "columns", "columns_used": cols_meta}
|
||||
yield f"data: {json.dumps(meta_update)}\n\n"
|
||||
|
||||
all_cells.append(cell)
|
||||
cell_idx += 1
|
||||
|
||||
cell_event = {
|
||||
"type": "cell",
|
||||
"cell": cell,
|
||||
"progress": {"current": cell_idx, "total": total},
|
||||
}
|
||||
yield f"data: {json.dumps(cell_event)}\n\n"
|
||||
|
||||
# All cells done
|
||||
duration = time.time() - t0
|
||||
if columns_meta is None:
|
||||
columns_meta = []
|
||||
|
||||
# Remove all-empty rows
|
||||
rows_with_text: set = set()
|
||||
for c in all_cells:
|
||||
if c.get("text", "").strip():
|
||||
rows_with_text.add(c["row_index"])
|
||||
before_filter = len(all_cells)
|
||||
all_cells = [c for c in all_cells if c["row_index"] in rows_with_text]
|
||||
empty_rows_removed = (before_filter - len(all_cells)) // max(n_cols, 1)
|
||||
if empty_rows_removed > 0:
|
||||
logger.info(f"SSE: removed {empty_rows_removed} all-empty rows after OCR")
|
||||
|
||||
used_engine = all_cells[0].get("ocr_engine", "tesseract") if all_cells else engine
|
||||
|
||||
fix_cell_phonetics(all_cells, pronunciation=pronunciation)
|
||||
|
||||
word_result = {
|
||||
"cells": all_cells,
|
||||
"grid_shape": {"rows": n_content_rows, "cols": n_cols, "total_cells": len(all_cells)},
|
||||
"columns_used": columns_meta,
|
||||
"layout": "vocab" if is_vocab else "generic",
|
||||
"image_width": img_w,
|
||||
"image_height": img_h,
|
||||
"duration_seconds": round(duration, 2),
|
||||
"ocr_engine": used_engine,
|
||||
"summary": {
|
||||
"total_cells": len(all_cells),
|
||||
"non_empty_cells": sum(1 for c in all_cells if c.get("text")),
|
||||
"low_confidence": sum(1 for c in all_cells if 0 < c.get("confidence", 0) < 50),
|
||||
},
|
||||
}
|
||||
|
||||
vocab_entries = None
|
||||
has_text_col = 'column_text' in col_types
|
||||
if is_vocab or has_text_col:
|
||||
entries = _cells_to_vocab_entries(all_cells, columns_meta)
|
||||
entries = _fix_character_confusion(entries)
|
||||
entries = _fix_phonetic_brackets(entries, pronunciation=pronunciation)
|
||||
word_result["vocab_entries"] = entries
|
||||
word_result["entries"] = entries
|
||||
word_result["entry_count"] = len(entries)
|
||||
word_result["summary"]["total_entries"] = len(entries)
|
||||
word_result["summary"]["with_english"] = sum(1 for e in entries if e.get("english"))
|
||||
word_result["summary"]["with_german"] = sum(1 for e in entries if e.get("german"))
|
||||
vocab_entries = entries
|
||||
|
||||
await update_session_db(session_id, word_result=word_result, current_step=8)
|
||||
cached["word_result"] = word_result
|
||||
|
||||
logger.info(f"OCR Pipeline SSE: words session {session_id}: "
|
||||
f"layout={word_result['layout']}, "
|
||||
f"{len(all_cells)} cells ({duration:.2f}s)")
|
||||
|
||||
complete_event = {
|
||||
"type": "complete",
|
||||
"summary": word_result["summary"],
|
||||
"duration_seconds": round(duration, 2),
|
||||
"ocr_engine": used_engine,
|
||||
}
|
||||
if vocab_entries is not None:
|
||||
complete_event["vocab_entries"] = vocab_entries
|
||||
yield f"data: {json.dumps(complete_event)}\n\n"
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* KorrekturDocumentViewer — center panel document display.
|
||||
*
|
||||
* Extracted from KorrekturPage.tsx.
|
||||
*/
|
||||
|
||||
import { StudentKlausur } from '../services/api'
|
||||
|
||||
interface KorrekturDocumentViewerProps {
|
||||
currentStudent: StudentKlausur | null
|
||||
}
|
||||
|
||||
export default function KorrekturDocumentViewer({ currentStudent }: KorrekturDocumentViewerProps) {
|
||||
return (
|
||||
<div className="korrektur-main">
|
||||
<div className="viewer-container">
|
||||
<div className="viewer-toolbar">
|
||||
<div style={{ fontSize: 14, fontWeight: 500 }}>
|
||||
{currentStudent ? currentStudent.student_name : 'Dokument-Ansicht'}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
{currentStudent && (
|
||||
<>
|
||||
<button className="btn btn-ghost" style={{ padding: '6px 12px' }}>
|
||||
OCR-Text
|
||||
</button>
|
||||
<button className="btn btn-ghost" style={{ padding: '6px 12px' }}>
|
||||
Original
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="viewer-content">
|
||||
{!currentStudent ? (
|
||||
<div className="document-placeholder">
|
||||
<div className="document-placeholder-icon">{'\uD83D\uDCC4'}</div>
|
||||
<div style={{ fontSize: 16, marginBottom: 8 }}>Keine Arbeit ausgewaehlt</div>
|
||||
<div style={{ fontSize: 14 }}>
|
||||
Waehlen Sie eine Schuelerarbeit aus der Liste oder laden Sie eine neue hoch
|
||||
</div>
|
||||
</div>
|
||||
) : currentStudent.file_path ? (
|
||||
<div className="document-viewer">
|
||||
<div className="document-info-bar">
|
||||
<span className="file-name">{'\uD83D\uDCC4'} {currentStudent.student_name}</span>
|
||||
<span className="file-status">{'\u2713'} Hochgeladen</span>
|
||||
</div>
|
||||
<div className="document-frame">
|
||||
{currentStudent.file_path.endsWith('.pdf') ? (
|
||||
<iframe
|
||||
src={`/api/v1/students/${currentStudent.id}/file`}
|
||||
title="Schuelerarbeit"
|
||||
style={{ width: '100%', height: '100%', minHeight: '600px' }}
|
||||
/>
|
||||
) : (
|
||||
<img
|
||||
src={`/api/v1/students/${currentStudent.id}/file`}
|
||||
alt={`Arbeit von ${currentStudent.student_name}`}
|
||||
style={{ maxWidth: '100%', maxHeight: '100%', objectFit: 'contain' }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="document-placeholder">
|
||||
<div className="document-placeholder-icon">{'\uD83D\uDCC4'}</div>
|
||||
<div style={{ fontSize: 16, marginBottom: 8 }}>Keine Datei vorhanden</div>
|
||||
<div style={{ fontSize: 14 }}>
|
||||
Laden Sie eine Schuelerarbeit hoch, um mit der Korrektur zu beginnen.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
/**
|
||||
* KorrekturModals — upload modal and EH prompt modal.
|
||||
*
|
||||
* Extracted from KorrekturPage.tsx.
|
||||
*/
|
||||
|
||||
import { useRef, useState } from 'react'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Upload Modal
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface UploadModalProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
studentName: string
|
||||
onStudentNameChange: (name: string) => void
|
||||
classStudents: Array<{ id: string; name: string }>
|
||||
onUpload: () => void
|
||||
uploading: boolean
|
||||
selectedFile: File | null
|
||||
onFileSelect: (e: React.ChangeEvent<HTMLInputElement>) => void
|
||||
}
|
||||
|
||||
export function UploadModal({
|
||||
open,
|
||||
onClose,
|
||||
studentName,
|
||||
onStudentNameChange,
|
||||
classStudents,
|
||||
onUpload,
|
||||
uploading,
|
||||
selectedFile,
|
||||
onFileSelect,
|
||||
}: UploadModalProps) {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const [useStudentDropdown, setUseStudentDropdown] = useState(true)
|
||||
|
||||
if (!open) return null
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<div className="modal" onClick={e => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<div className="modal-title">Schuelerarbeit hochladen</div>
|
||||
<button className="modal-close" onClick={onClose}>{'\u00D7'}</button>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
<div className="form-group">
|
||||
<label className="form-label">Schueler zuweisen</label>
|
||||
|
||||
{classStudents.length > 0 && (
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer', fontSize: 13 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={useStudentDropdown}
|
||||
onChange={(e) => setUseStudentDropdown(e.target.checked)}
|
||||
/>
|
||||
Aus Klassenliste waehlen
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{useStudentDropdown && classStudents.length > 0 ? (
|
||||
<select
|
||||
className="input"
|
||||
value={studentName}
|
||||
onChange={e => onStudentNameChange(e.target.value)}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
<option value="">-- Schueler waehlen --</option>
|
||||
{classStudents.map(s => (
|
||||
<option key={s.id} value={s.name}>{s.name}</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<input
|
||||
type="text"
|
||||
className="input"
|
||||
placeholder="z.B. Max Mustermann"
|
||||
value={studentName}
|
||||
onChange={e => onStudentNameChange(e.target.value)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{classStudents.length === 0 && (
|
||||
<p style={{ fontSize: 12, color: 'var(--bp-text-muted)', marginTop: 8 }}>
|
||||
Keine Klassenliste verfuegbar. Bitte Namen manuell eingeben.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label className="form-label">Datei (PDF oder Bild)</label>
|
||||
<div
|
||||
className={`upload-area ${selectedFile ? 'selected' : ''}`}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".pdf,.png,.jpg,.jpeg"
|
||||
style={{ display: 'none' }}
|
||||
onChange={onFileSelect}
|
||||
/>
|
||||
{selectedFile ? (
|
||||
<>
|
||||
<div className="upload-icon">{'\uD83D\uDCC4'}</div>
|
||||
<div className="upload-text">{selectedFile.name}</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="upload-icon">{'\uD83D\uDCC1'}</div>
|
||||
<div className="upload-text">
|
||||
Klicken Sie hier oder ziehen Sie eine Datei hinein
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button className="btn btn-secondary" onClick={onClose}>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
disabled={!studentName || !selectedFile || uploading}
|
||||
onClick={onUpload}
|
||||
>
|
||||
{uploading ? 'Wird hochgeladen...' : 'Hochladen'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// EH Prompt Modal
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface EHPromptModalProps {
|
||||
open: boolean
|
||||
onUpload: () => void
|
||||
onDismiss: () => void
|
||||
}
|
||||
|
||||
export function EHPromptModal({ open, onUpload, onDismiss }: EHPromptModalProps) {
|
||||
if (!open) return null
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onDismiss}>
|
||||
<div className="modal" onClick={e => e.stopPropagation()} style={{ maxWidth: 500 }}>
|
||||
<div className="modal-header">
|
||||
<div className="modal-title">{'\uD83D\uDCCB'} Erwartungshorizont hochladen?</div>
|
||||
<button className="modal-close" onClick={onDismiss}>{'\u00D7'}</button>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
<p style={{ marginBottom: 16, lineHeight: 1.6 }}>
|
||||
Sie haben die erste Schuelerarbeit hochgeladen. Moechten Sie jetzt einen
|
||||
<strong> Erwartungshorizont</strong> hinzufuegen?
|
||||
</p>
|
||||
|
||||
<div style={{
|
||||
background: 'var(--bp-bg-light)',
|
||||
border: '1px solid var(--bp-border)',
|
||||
borderRadius: 8,
|
||||
padding: 16,
|
||||
marginBottom: 16
|
||||
}}>
|
||||
<div style={{ fontWeight: 500, marginBottom: 8 }}>{'\u2713'} Vorteile:</div>
|
||||
<ul style={{ margin: 0, paddingLeft: 20, color: 'var(--bp-text-muted)', fontSize: 14, lineHeight: 1.8 }}>
|
||||
<li>KI-gestuetzte Korrekturvorschlaege basierend auf Ihrem EH</li>
|
||||
<li>Bessere und konsistentere Bewertungen</li>
|
||||
<li>Automatisch fuer alle Korrektoren verfuegbar</li>
|
||||
<li>Ende-zu-Ende verschluesselt - nur Sie haben den Schluessel</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<p style={{ fontSize: 13, color: 'var(--bp-text-muted)' }}>
|
||||
Sie koennen den Erwartungshorizont auch spaeter hochladen.
|
||||
</p>
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button className="btn btn-secondary" onClick={onDismiss}>
|
||||
Spaeter
|
||||
</button>
|
||||
<button className="btn btn-primary" onClick={onUpload}>
|
||||
Jetzt hochladen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* KorrekturSidebar — collapsible left sidebar with klausur info and student list.
|
||||
*
|
||||
* Extracted from KorrekturPage.tsx.
|
||||
*/
|
||||
|
||||
import { StudentKlausur } from '../services/api'
|
||||
|
||||
interface KorrekturSidebarProps {
|
||||
collapsed: boolean
|
||||
onToggle: () => void
|
||||
klausurTitle: string
|
||||
klausurModus: string
|
||||
students: StudentKlausur[]
|
||||
currentStudentId?: string
|
||||
onSelectStudent: (id: string) => void
|
||||
onDeleteStudent: (id: string, e: React.MouseEvent) => void
|
||||
onUploadClick: () => void
|
||||
}
|
||||
|
||||
export default function KorrekturSidebar({
|
||||
collapsed,
|
||||
onToggle,
|
||||
klausurTitle,
|
||||
klausurModus,
|
||||
students,
|
||||
currentStudentId,
|
||||
onSelectStudent,
|
||||
onDeleteStudent,
|
||||
onUploadClick,
|
||||
}: KorrekturSidebarProps) {
|
||||
return (
|
||||
<div className={`korrektur-sidebar ${collapsed ? 'collapsed' : ''}`}>
|
||||
<button
|
||||
className="sidebar-toggle"
|
||||
onClick={onToggle}
|
||||
title={collapsed ? 'Sidebar einblenden' : 'Sidebar ausblenden'}
|
||||
>
|
||||
{collapsed ? '\u2192' : '\u2190'}
|
||||
</button>
|
||||
|
||||
{!collapsed && (
|
||||
<>
|
||||
<div className="sidebar-section">
|
||||
<div className="sidebar-section-title">Klausur</div>
|
||||
<div className="klausur-item active">
|
||||
<div className="klausur-icon">{'\uD83D\uDCCB'}</div>
|
||||
<div className="klausur-info">
|
||||
<div className="klausur-name">{klausurTitle}</div>
|
||||
<div className="klausur-meta">
|
||||
{klausurModus === 'landes_abitur' ? 'Abitur' : 'Vorabitur'} {'\u2022'} {students.length} Schueler
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="sidebar-section" style={{ flex: 1 }}>
|
||||
<div className="sidebar-section-title">Schuelerarbeiten</div>
|
||||
|
||||
{students.length === 0 ? (
|
||||
<div style={{ color: 'var(--bp-text-muted)', fontSize: 13, padding: '8px 0' }}>
|
||||
Noch keine Arbeiten hochgeladen
|
||||
</div>
|
||||
) : (
|
||||
students.map((student) => (
|
||||
<div
|
||||
key={student.id}
|
||||
className={`klausur-item ${currentStudentId === student.id ? 'active' : ''}`}
|
||||
onClick={() => onSelectStudent(student.id)}
|
||||
>
|
||||
<div className="klausur-icon">{'\uD83D\uDCC4'}</div>
|
||||
<div className="klausur-info">
|
||||
<div className="klausur-name">{student.student_name}</div>
|
||||
<div className="klausur-meta">
|
||||
{student.status === 'completed' ? `${student.grade_points} Punkte` : student.status}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
className="delete-btn"
|
||||
onClick={(e) => onDeleteStudent(student.id, e)}
|
||||
title="Loeschen"
|
||||
>
|
||||
{'\uD83D\uDDD1\uFE0F'}
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
style={{ width: '100%', marginTop: 16 }}
|
||||
onClick={onUploadClick}
|
||||
>
|
||||
+ Arbeit hochladen
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
/**
|
||||
* KorrekturWizardSteps — right panel wizard content for korrektur, bewertung, gutachten.
|
||||
*
|
||||
* Extracted from KorrekturPage.tsx.
|
||||
*/
|
||||
|
||||
import { StudentKlausur } from '../services/api'
|
||||
import { CRITERIA, GRADE_LABELS, WizardStep } from '../pages/KorrekturConstants'
|
||||
|
||||
interface WizardStepIndicatorProps {
|
||||
current: WizardStep
|
||||
}
|
||||
|
||||
function WizardStepIndicator({ current }: WizardStepIndicatorProps) {
|
||||
const steps: { key: WizardStep; label: string; number: string }[] = [
|
||||
{ key: 'korrektur', label: 'Korrektur', number: '1' },
|
||||
{ key: 'bewertung', label: 'Bewertung', number: '2' },
|
||||
{ key: 'gutachten', label: 'Gutachten', number: '3' },
|
||||
]
|
||||
|
||||
const currentIdx = steps.findIndex(s => s.key === current)
|
||||
|
||||
return (
|
||||
<div className="wizard-steps">
|
||||
{steps.map((step, idx) => {
|
||||
const isCompleted = idx < currentIdx
|
||||
const isActive = idx === currentIdx
|
||||
return (
|
||||
<div key={step.key} className={`wizard-step ${isCompleted ? 'completed' : ''} ${isActive ? 'active' : ''}`}>
|
||||
<span className="wizard-step-number">{isCompleted ? '\u2713' : step.number}</span>
|
||||
<span className="wizard-step-label">{step.label}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface KorrekturStepProps {
|
||||
korrekturNotes: string
|
||||
onNotesChange: (notes: string) => void
|
||||
onComplete: () => void
|
||||
}
|
||||
|
||||
export function KorrekturStep({ korrekturNotes, onNotesChange, onComplete }: KorrekturStepProps) {
|
||||
return (
|
||||
<>
|
||||
<WizardStepIndicator current="korrektur" />
|
||||
|
||||
<div className="panel-section">
|
||||
<div className="panel-section-title">{'\u270F\uFE0F'} Korrektur durchfuehren</div>
|
||||
<p style={{ color: 'var(--bp-text-muted)', fontSize: 13, marginBottom: 16 }}>
|
||||
Lesen Sie die Arbeit sorgfaeltig und machen Sie Anmerkungen direkt im Dokument.
|
||||
Notieren Sie hier Ihre wichtigsten Beobachtungen.
|
||||
</p>
|
||||
|
||||
<div className="form-group">
|
||||
<label className="form-label">Korrektur-Notizen</label>
|
||||
<textarea
|
||||
className="textarea"
|
||||
placeholder="Ihre Notizen waehrend der Korrektur..."
|
||||
style={{ minHeight: 200 }}
|
||||
value={korrekturNotes}
|
||||
onChange={(e) => onNotesChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="panel-section">
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
style={{ width: '100%' }}
|
||||
onClick={onComplete}
|
||||
>
|
||||
Weiter zur Bewertung {'\u2192'}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
interface BewertungStepProps {
|
||||
gradePoints: number
|
||||
totalPercentage: number
|
||||
localScores: Record<string, number>
|
||||
savingCriteria: boolean
|
||||
allCriteriaFilled: boolean
|
||||
onCriteriaChange: (criterion: string, value: number) => void
|
||||
onBack: () => void
|
||||
onComplete: () => void
|
||||
}
|
||||
|
||||
export function BewertungStep({
|
||||
gradePoints,
|
||||
totalPercentage,
|
||||
localScores,
|
||||
savingCriteria,
|
||||
allCriteriaFilled,
|
||||
onCriteriaChange,
|
||||
onBack,
|
||||
onComplete,
|
||||
}: BewertungStepProps) {
|
||||
return (
|
||||
<>
|
||||
<WizardStepIndicator current="bewertung" />
|
||||
|
||||
<div className="panel-section">
|
||||
<div className="panel-section-title">{'\uD83D\uDCCA'} Gesamtnote</div>
|
||||
<div className="grade-display">
|
||||
<div className="grade-points">{gradePoints}</div>
|
||||
<div className="grade-label">
|
||||
{GRADE_LABELS[gradePoints]} ({totalPercentage}%)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="panel-section">
|
||||
<div className="panel-section-title">
|
||||
{'\u270F\uFE0F'} Bewertungskriterien
|
||||
{savingCriteria && <span style={{ fontSize: 11, color: 'var(--bp-text-muted)' }}> (Speichert...)</span>}
|
||||
</div>
|
||||
|
||||
{CRITERIA.map(c => (
|
||||
<div key={c.key} className="criterion-item">
|
||||
<div className="criterion-header">
|
||||
<span className="criterion-label">{c.label} ({Math.round(c.weight * 100)}%)</span>
|
||||
<span className="criterion-score">{localScores[c.key] || 0}%</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
className="criterion-slider"
|
||||
min="0"
|
||||
max="100"
|
||||
value={localScores[c.key] || 0}
|
||||
onChange={(e) => onCriteriaChange(c.key, Number(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="panel-section">
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
style={{ flex: 1 }}
|
||||
onClick={onBack}
|
||||
>
|
||||
{'\u2190'} Zurueck
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
style={{ flex: 1 }}
|
||||
onClick={onComplete}
|
||||
disabled={!allCriteriaFilled}
|
||||
>
|
||||
Weiter {'\u2192'}
|
||||
</button>
|
||||
</div>
|
||||
{!allCriteriaFilled && (
|
||||
<p style={{ color: 'var(--bp-warning)', fontSize: 12, marginTop: 8, textAlign: 'center' }}>
|
||||
Bitte alle Kriterien bewerten
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
interface GutachtenStepProps {
|
||||
gradePoints: number
|
||||
currentStudent: StudentKlausur
|
||||
localGutachten: { einleitung: string; hauptteil: string; fazit: string }
|
||||
generatingGutachten: boolean
|
||||
savingGutachten: boolean
|
||||
finalizingStudent: boolean
|
||||
onGutachtenChange: (field: 'einleitung' | 'hauptteil' | 'fazit', value: string) => void
|
||||
onGenerate: () => void
|
||||
onSave: () => void
|
||||
onFinalize: () => void
|
||||
onBack: () => void
|
||||
}
|
||||
|
||||
export function GutachtenStep({
|
||||
gradePoints,
|
||||
currentStudent,
|
||||
localGutachten,
|
||||
generatingGutachten,
|
||||
savingGutachten,
|
||||
finalizingStudent,
|
||||
onGutachtenChange,
|
||||
onGenerate,
|
||||
onSave,
|
||||
onFinalize,
|
||||
onBack,
|
||||
}: GutachtenStepProps) {
|
||||
return (
|
||||
<>
|
||||
<WizardStepIndicator current="gutachten" />
|
||||
|
||||
<div className="panel-section">
|
||||
<div className="panel-section-title">
|
||||
{'\uD83D\uDCCA'} Endergebnis: {gradePoints} Punkte ({GRADE_LABELS[gradePoints]})
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="panel-section">
|
||||
<div className="panel-section-title">
|
||||
{'\uD83D\uDCDD'} Gutachten
|
||||
{savingGutachten && <span style={{ fontSize: 11, color: 'var(--bp-text-muted)' }}> (Speichert...)</span>}
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
style={{ width: '100%', marginBottom: 16 }}
|
||||
onClick={onGenerate}
|
||||
disabled={generatingGutachten}
|
||||
>
|
||||
{generatingGutachten ? '\u231B KI generiert...' : '\uD83E\uDD16 KI-Gutachten generieren'}
|
||||
</button>
|
||||
|
||||
<div className="form-group">
|
||||
<label className="form-label">Einleitung</label>
|
||||
<textarea
|
||||
className="textarea"
|
||||
placeholder="Allgemeine Einordnung der Arbeit..."
|
||||
value={localGutachten.einleitung}
|
||||
onChange={(e) => onGutachtenChange('einleitung', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Hauptteil</label>
|
||||
<textarea
|
||||
className="textarea"
|
||||
placeholder="Detaillierte Bewertung..."
|
||||
style={{ minHeight: 120 }}
|
||||
value={localGutachten.hauptteil}
|
||||
onChange={(e) => onGutachtenChange('hauptteil', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Fazit</label>
|
||||
<textarea
|
||||
className="textarea"
|
||||
placeholder="Zusammenfassung und Empfehlungen..."
|
||||
value={localGutachten.fazit}
|
||||
onChange={(e) => onGutachtenChange('fazit', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
style={{ width: '100%', marginBottom: 8 }}
|
||||
onClick={onSave}
|
||||
disabled={savingGutachten}
|
||||
>
|
||||
{savingGutachten ? '\uD83D\uDCBE Speichert...' : '\uD83D\uDCBE Gutachten speichern'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="panel-section">
|
||||
<div style={{ display: 'flex', gap: 8, marginBottom: 8 }}>
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
style={{ flex: 1 }}
|
||||
onClick={onBack}
|
||||
>
|
||||
{'\u2190'} Zurueck
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
style={{ width: '100%' }}
|
||||
onClick={onFinalize}
|
||||
disabled={finalizingStudent || currentStudent.status === 'completed'}
|
||||
>
|
||||
{currentStudent.status === 'completed'
|
||||
? '\u2713 Abgeschlossen'
|
||||
: finalizingStudent
|
||||
? 'Wird abgeschlossen...'
|
||||
: '\u2713 Bewertung abschliessen'}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* KorrekturPage Constants — grade tables, criteria definitions, types.
|
||||
*
|
||||
* Extracted from KorrekturPage.tsx.
|
||||
*/
|
||||
|
||||
// Grade calculation
|
||||
export const GRADE_THRESHOLDS: Record<number, number> = {
|
||||
15: 95, 14: 90, 13: 85, 12: 80, 11: 75, 10: 70,
|
||||
9: 65, 8: 60, 7: 55, 6: 50, 5: 45, 4: 40,
|
||||
3: 33, 2: 27, 1: 20, 0: 0
|
||||
}
|
||||
|
||||
export const GRADE_LABELS: Record<number, string> = {
|
||||
15: '1+', 14: '1', 13: '1-', 12: '2+', 11: '2', 10: '2-',
|
||||
9: '3+', 8: '3', 7: '3-', 6: '4+', 5: '4', 4: '4-',
|
||||
3: '5+', 2: '5', 1: '5-', 0: '6'
|
||||
}
|
||||
|
||||
export const CRITERIA = [
|
||||
{ key: 'inhalt', label: 'Inhaltliche Leistung', weight: 0.40 },
|
||||
{ key: 'struktur', label: 'Aufbau & Struktur', weight: 0.15 },
|
||||
{ key: 'stil', label: 'Ausdruck & Stil', weight: 0.15 },
|
||||
{ key: 'grammatik', label: 'Grammatik', weight: 0.15 },
|
||||
{ key: 'rechtschreibung', label: 'Rechtschreibung', weight: 0.15 }
|
||||
] as const
|
||||
|
||||
export type WizardStep = 'korrektur' | 'bewertung' | 'gutachten'
|
||||
|
||||
export function calculateGradePoints(percentage: number): number {
|
||||
for (const [points, threshold] of Object.entries(GRADE_THRESHOLDS).sort((a, b) => Number(b[0]) - Number(a[0]))) {
|
||||
if (percentage >= threshold) {
|
||||
return Number(points)
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,41 @@
|
||||
'use client'
|
||||
|
||||
interface BackgroundBlobsProps {
|
||||
isDark: boolean
|
||||
}
|
||||
|
||||
export function BackgroundBlobs({ isDark }: BackgroundBlobsProps) {
|
||||
return (
|
||||
<>
|
||||
<div className="absolute inset-0 overflow-hidden pointer-events-none">
|
||||
<div className={`absolute -top-40 -right-40 w-80 h-80 rounded-full mix-blend-multiply filter blur-3xl animate-blob ${
|
||||
isDark ? 'bg-purple-500 opacity-70' : 'bg-purple-300 opacity-50'
|
||||
}`} />
|
||||
<div className={`absolute -bottom-40 -left-40 w-80 h-80 rounded-full mix-blend-multiply filter blur-3xl animate-blob animation-delay-2000 ${
|
||||
isDark ? 'bg-blue-500 opacity-70' : 'bg-blue-300 opacity-50'
|
||||
}`} />
|
||||
<div className={`absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 w-80 h-80 rounded-full mix-blend-multiply filter blur-3xl animate-blob animation-delay-4000 ${
|
||||
isDark ? 'bg-pink-500 opacity-70' : 'bg-pink-300 opacity-50'
|
||||
}`} />
|
||||
</div>
|
||||
|
||||
<style jsx>{`
|
||||
@keyframes blob {
|
||||
0% { transform: translate(0px, 0px) scale(1); }
|
||||
33% { transform: translate(30px, -50px) scale(1.1); }
|
||||
66% { transform: translate(-20px, 20px) scale(0.9); }
|
||||
100% { transform: translate(0px, 0px) scale(1); }
|
||||
}
|
||||
.animate-blob {
|
||||
animation: blob 7s infinite;
|
||||
}
|
||||
.animation-delay-2000 {
|
||||
animation-delay: 2s;
|
||||
}
|
||||
.animation-delay-4000 {
|
||||
animation-delay: 4s;
|
||||
}
|
||||
`}</style>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,385 @@
|
||||
'use client'
|
||||
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useLanguage } from '@/lib/LanguageContext'
|
||||
import { useTheme } from '@/lib/ThemeContext'
|
||||
import { useAlerts } from '@/lib/AlertsContext'
|
||||
import { useMessages, formatMessageTime, getContactInitials } from '@/lib/MessagesContext'
|
||||
import { useActivity, formatDurationCompact } from '@/lib/ActivityContext'
|
||||
|
||||
interface StatsItem {
|
||||
labelKey: string
|
||||
value: string
|
||||
icon: string
|
||||
color: string
|
||||
}
|
||||
|
||||
interface RecentKlausur {
|
||||
id: number
|
||||
title: string
|
||||
students: number
|
||||
completed: number
|
||||
statusKey: string
|
||||
}
|
||||
|
||||
interface DashboardContentProps {
|
||||
documents: { id: string }[]
|
||||
setShowUploadModal: (show: boolean) => void
|
||||
setSelectedTab: (tab: string) => void
|
||||
}
|
||||
|
||||
export function DashboardContent({ documents, setShowUploadModal, setSelectedTab }: DashboardContentProps) {
|
||||
const router = useRouter()
|
||||
const { t } = useLanguage()
|
||||
const { isDark } = useTheme()
|
||||
const { alerts, unreadCount: alertsUnreadCount, markAsRead } = useAlerts()
|
||||
const { conversations, unreadCount: messagesUnreadCount, contacts, markAsRead: markMessageAsRead } = useMessages()
|
||||
const { stats: activityStats } = useActivity()
|
||||
|
||||
const timeSaved = formatDurationCompact(activityStats.weekSavedSeconds)
|
||||
const timeSavedDisplay = activityStats.weekSavedSeconds > 0
|
||||
? `${timeSaved.value}${timeSaved.unit}`
|
||||
: '0min'
|
||||
|
||||
const stats: StatsItem[] = [
|
||||
{ labelKey: 'stat_open_corrections', value: '12', icon: '📋', color: 'from-blue-400 to-blue-600' },
|
||||
{ labelKey: 'stat_completed_week', value: String(activityStats.activityCount), icon: '✅', color: 'from-green-400 to-green-600' },
|
||||
{ labelKey: 'stat_average', value: '2.3', icon: '📈', color: 'from-purple-400 to-purple-600' },
|
||||
{ labelKey: 'stat_time_saved', value: timeSavedDisplay, icon: '⏱', color: 'from-orange-400 to-orange-600' },
|
||||
]
|
||||
|
||||
const recentKlausuren: RecentKlausur[] = [
|
||||
{ id: 1, title: 'Deutsch LK - Textanalyse', students: 24, completed: 18, statusKey: 'status_in_progress' },
|
||||
{ id: 2, title: 'Deutsch GK - Erörterung', students: 28, completed: 28, statusKey: 'status_completed' },
|
||||
{ id: 3, title: 'Vorabitur - Gedichtanalyse', students: 22, completed: 10, statusKey: 'status_in_progress' },
|
||||
]
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Stats Kacheln */}
|
||||
<div className="grid grid-cols-4 gap-6 mb-8">
|
||||
{stats.map((stat, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`backdrop-blur-xl border rounded-3xl p-6 transition-all hover:scale-105 hover:shadow-xl cursor-pointer ${
|
||||
isDark
|
||||
? 'bg-white/10 border-white/20 hover:bg-white/15'
|
||||
: 'bg-white/70 border-black/10 hover:bg-white/90 shadow-lg'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className={`w-12 h-12 bg-gradient-to-br ${stat.color} rounded-2xl flex items-center justify-center text-2xl shadow-lg`}>
|
||||
{stat.icon}
|
||||
</div>
|
||||
<svg className={`w-5 h-5 ${isDark ? 'text-white/40' : 'text-slate-400'}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className={`text-sm mb-1 ${isDark ? 'text-white/60' : 'text-slate-600'}`}>{t(stat.labelKey)}</p>
|
||||
<p className={`text-3xl font-bold ${isDark ? 'text-white' : 'text-slate-900'}`}>{stat.value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Dashboard Grid */}
|
||||
<div className="grid grid-cols-3 gap-6">
|
||||
{/* Aktuelle Klausuren Kachel */}
|
||||
<div className={`col-span-2 backdrop-blur-xl border rounded-3xl p-6 ${
|
||||
isDark
|
||||
? 'bg-white/10 border-white/20'
|
||||
: 'bg-white/70 border-black/10 shadow-lg'
|
||||
}`}>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className={`text-xl font-semibold ${isDark ? 'text-white' : 'text-slate-900'}`}>{t('recent_klausuren')}</h2>
|
||||
<button className={`text-sm transition-colors ${
|
||||
isDark ? 'text-white/60 hover:text-white' : 'text-slate-500 hover:text-slate-900'
|
||||
}`}>
|
||||
{t('show_all')} →
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{recentKlausuren.map((klausur) => (
|
||||
<div
|
||||
key={klausur.id}
|
||||
className={`flex items-center gap-4 p-4 rounded-2xl transition-all cursor-pointer group ${
|
||||
isDark
|
||||
? 'bg-white/5 hover:bg-white/10'
|
||||
: 'bg-slate-50 hover:bg-slate-100'
|
||||
}`}
|
||||
>
|
||||
<div className="w-14 h-14 bg-gradient-to-br from-blue-400 to-purple-500 rounded-2xl flex items-center justify-center">
|
||||
<span className="text-2xl">📝</span>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className={`font-medium ${isDark ? 'text-white' : 'text-slate-900'}`}>{klausur.title}</h3>
|
||||
<p className={`text-sm ${isDark ? 'text-white/50' : 'text-slate-500'}`}>{klausur.students} {t('students')}</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<span className={`px-3 py-1 rounded-full text-xs font-medium ${
|
||||
klausur.statusKey === 'status_completed'
|
||||
? isDark ? 'bg-green-500/20 text-green-300' : 'bg-green-100 text-green-700'
|
||||
: isDark ? 'bg-yellow-500/20 text-yellow-300' : 'bg-yellow-100 text-yellow-700'
|
||||
}`}>
|
||||
{t(klausur.statusKey)}
|
||||
</span>
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<div className={`w-24 h-1.5 rounded-full overflow-hidden ${isDark ? 'bg-white/10' : 'bg-slate-200'}`}>
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-cyan-400 to-blue-500 rounded-full"
|
||||
style={{ width: `${(klausur.completed / klausur.students) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className={`text-xs ${isDark ? 'text-white/50' : 'text-slate-500'}`}>{klausur.completed}/{klausur.students}</span>
|
||||
</div>
|
||||
</div>
|
||||
<svg className={`w-5 h-5 transition-colors ${
|
||||
isDark ? 'text-white/30 group-hover:text-white/60' : 'text-slate-300 group-hover:text-slate-600'
|
||||
}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Schnellaktionen Kachel */}
|
||||
<div className={`backdrop-blur-xl border rounded-3xl p-6 ${
|
||||
isDark
|
||||
? 'bg-white/10 border-white/20'
|
||||
: 'bg-white/70 border-black/10 shadow-lg'
|
||||
}`}>
|
||||
<h2 className={`text-xl font-semibold mb-6 ${isDark ? 'text-white' : 'text-slate-900'}`}>{t('quick_actions')}</h2>
|
||||
|
||||
<div className="space-y-3">
|
||||
<button className="w-full flex items-center gap-4 p-4 bg-gradient-to-r from-blue-500 to-purple-500 rounded-2xl text-white hover:shadow-xl hover:shadow-purple-500/30 transition-all hover:scale-105">
|
||||
<span className="text-2xl">➕</span>
|
||||
<span className="font-medium">{t('create_klausur')}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setShowUploadModal(true)}
|
||||
className={`w-full flex items-center gap-4 p-4 rounded-2xl transition-all ${
|
||||
isDark
|
||||
? 'bg-white/10 text-white hover:bg-white/20'
|
||||
: 'bg-slate-100 text-slate-800 hover:bg-slate-200'
|
||||
}`}>
|
||||
<span className="text-2xl">📤</span>
|
||||
<span className="font-medium">{t('upload_work')}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setSelectedTab('dokumente')}
|
||||
className={`w-full flex items-center justify-between p-4 rounded-2xl transition-all ${
|
||||
isDark
|
||||
? 'bg-white/10 text-white hover:bg-white/20'
|
||||
: 'bg-slate-100 text-slate-800 hover:bg-slate-200'
|
||||
}`}>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-2xl">📁</span>
|
||||
<span className="font-medium">{t('nav_dokumente')}</span>
|
||||
</div>
|
||||
{documents.length > 0 && (
|
||||
<span className={`px-2 py-1 text-xs font-medium rounded-full ${
|
||||
isDark ? 'bg-white/20' : 'bg-slate-200'
|
||||
}`}>
|
||||
{documents.length}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => router.push('/worksheet-editor')}
|
||||
className={`w-full flex items-center gap-4 p-4 rounded-2xl transition-all ${
|
||||
isDark
|
||||
? 'bg-gradient-to-r from-purple-500/20 to-pink-500/20 text-white hover:from-purple-500/30 hover:to-pink-500/30 border border-purple-500/30'
|
||||
: 'bg-gradient-to-r from-purple-50 to-pink-50 text-slate-800 hover:from-purple-100 hover:to-pink-100 border border-purple-200'
|
||||
}`}>
|
||||
<span className="text-2xl">🎨</span>
|
||||
<span className="font-medium">{t('nav_worksheet_editor')}</span>
|
||||
</button>
|
||||
|
||||
<button className={`w-full flex items-center gap-4 p-4 rounded-2xl transition-all ${
|
||||
isDark
|
||||
? 'bg-white/10 text-white hover:bg-white/20'
|
||||
: 'bg-slate-100 text-slate-800 hover:bg-slate-200'
|
||||
}`}>
|
||||
<span className="text-2xl">✨</span>
|
||||
<span className="font-medium">{t('magic_help')}</span>
|
||||
</button>
|
||||
|
||||
<button className={`w-full flex items-center gap-4 p-4 rounded-2xl transition-all ${
|
||||
isDark
|
||||
? 'bg-white/10 text-white hover:bg-white/20'
|
||||
: 'bg-slate-100 text-slate-800 hover:bg-slate-200'
|
||||
}`}>
|
||||
<span className="text-2xl">📊</span>
|
||||
<span className="font-medium">{t('fairness_check')}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* AI Insight mini */}
|
||||
<div className={`mt-6 p-4 rounded-2xl border ${
|
||||
isDark
|
||||
? 'bg-gradient-to-r from-pink-500/20 to-purple-500/20 border-pink-500/30'
|
||||
: 'bg-gradient-to-r from-pink-50 to-purple-50 border-pink-200'
|
||||
}`}>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<span className="text-lg">🤖</span>
|
||||
<span className={`text-sm font-medium ${isDark ? 'text-white/80' : 'text-slate-700'}`}>{t('ai_tip')}</span>
|
||||
</div>
|
||||
<p className={`text-sm ${isDark ? 'text-white/60' : 'text-slate-600'}`}>
|
||||
{t('ai_tip_text')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Alerts Kachel */}
|
||||
<div className={`mt-6 backdrop-blur-xl border rounded-2xl p-4 ${
|
||||
isDark
|
||||
? 'bg-gradient-to-r from-amber-500/10 to-orange-500/10 border-amber-500/30'
|
||||
: 'bg-gradient-to-r from-amber-50 to-orange-50 border-amber-200'
|
||||
}`}>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className={`font-semibold flex items-center gap-2 ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
<span>🔔</span> Aktuelle Alerts
|
||||
</h3>
|
||||
{alertsUnreadCount > 0 && (
|
||||
<span className="text-xs px-2 py-1 rounded-full bg-amber-500/20 text-amber-500 font-medium">
|
||||
{alertsUnreadCount} neu
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{alerts.slice(0, 3).map(alert => (
|
||||
<button
|
||||
key={alert.id}
|
||||
onClick={() => {
|
||||
markAsRead(alert.id)
|
||||
router.push('/alerts')
|
||||
}}
|
||||
className={`w-full text-left p-2 rounded-lg transition-all text-sm ${
|
||||
isDark
|
||||
? `hover:bg-white/10 ${!alert.isRead ? 'bg-white/5' : ''}`
|
||||
: `hover:bg-white ${!alert.isRead ? 'bg-white/50' : ''}`
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
{!alert.isRead && (
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-amber-500 mt-1.5 flex-shrink-0" />
|
||||
)}
|
||||
<p className={`truncate ${isDark ? 'text-white/80' : 'text-slate-700'}`}>
|
||||
{alert.title}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
{alerts.length === 0 && (
|
||||
<p className={`text-sm ${isDark ? 'text-white/40' : 'text-slate-400'}`}>
|
||||
Keine Alerts vorhanden
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => router.push('/alerts')}
|
||||
className={`w-full mt-3 text-sm font-medium ${
|
||||
isDark ? 'text-amber-400 hover:text-amber-300' : 'text-amber-600 hover:text-amber-700'
|
||||
}`}
|
||||
>
|
||||
Alle Alerts anzeigen →
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Nachrichten Kachel */}
|
||||
<div className={`mt-6 backdrop-blur-xl border rounded-2xl p-4 ${
|
||||
isDark
|
||||
? 'bg-gradient-to-r from-green-500/10 to-emerald-500/10 border-green-500/30'
|
||||
: 'bg-gradient-to-r from-green-50 to-emerald-50 border-green-200'
|
||||
}`}>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className={`font-semibold flex items-center gap-2 ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
<span>💬</span> {t('nav_messages')}
|
||||
</h3>
|
||||
{messagesUnreadCount > 0 && (
|
||||
<span className="text-xs px-2 py-1 rounded-full bg-green-500/20 text-green-500 font-medium">
|
||||
{messagesUnreadCount} neu
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{conversations.slice(0, 3).map(conv => {
|
||||
const contact = contacts.find(c => conv.participant_ids.includes(c.id))
|
||||
return (
|
||||
<button
|
||||
key={conv.id}
|
||||
onClick={() => {
|
||||
if (conv.unread_count > 0) {
|
||||
markMessageAsRead(conv.id)
|
||||
}
|
||||
router.push('/messages')
|
||||
}}
|
||||
className={`w-full text-left p-2 rounded-lg transition-all text-sm ${
|
||||
isDark
|
||||
? `hover:bg-white/10 ${conv.unread_count > 0 ? 'bg-white/5' : ''}`
|
||||
: `hover:bg-white ${conv.unread_count > 0 ? 'bg-white/50' : ''}`
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-xs font-medium flex-shrink-0 ${
|
||||
contact?.online
|
||||
? isDark
|
||||
? 'bg-green-500/30 text-green-300'
|
||||
: 'bg-green-200 text-green-700'
|
||||
: isDark
|
||||
? 'bg-slate-600 text-slate-300'
|
||||
: 'bg-slate-200 text-slate-600'
|
||||
}`}>
|
||||
{conv.title ? getContactInitials(conv.title) : '?'}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
{conv.unread_count > 0 && (
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-green-500 flex-shrink-0" />
|
||||
)}
|
||||
<span className={`font-medium truncate ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
{conv.title || 'Unbenannt'}
|
||||
</span>
|
||||
</div>
|
||||
{conv.last_message && (
|
||||
<p className={`text-xs truncate ${isDark ? 'text-white/50' : 'text-slate-500'}`}>
|
||||
{conv.last_message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{conv.last_message_time && (
|
||||
<span className={`text-xs flex-shrink-0 ${isDark ? 'text-white/40' : 'text-slate-400'}`}>
|
||||
{formatMessageTime(conv.last_message_time)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
{conversations.length === 0 && (
|
||||
<p className={`text-sm ${isDark ? 'text-white/40' : 'text-slate-400'}`}>
|
||||
Keine Nachrichten vorhanden
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => router.push('/messages')}
|
||||
className={`w-full mt-3 text-sm font-medium ${
|
||||
isDark ? 'text-green-400 hover:text-green-300' : 'text-green-600 hover:text-green-700'
|
||||
}`}
|
||||
>
|
||||
Alle Nachrichten anzeigen →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
'use client'
|
||||
|
||||
import { useTheme } from '@/lib/ThemeContext'
|
||||
import { DocumentSpace } from '@/components/DocumentSpace'
|
||||
|
||||
interface StoredDocument {
|
||||
id: string
|
||||
name: string
|
||||
type: string
|
||||
size: number
|
||||
uploadedAt: Date
|
||||
url?: string
|
||||
}
|
||||
|
||||
interface DocumentsTabProps {
|
||||
documents: StoredDocument[]
|
||||
onDelete: (id: string) => void
|
||||
onRename: (id: string, newName: string) => void
|
||||
setShowUploadModal: (show: boolean) => void
|
||||
setShowQRModal: (show: boolean) => void
|
||||
}
|
||||
|
||||
export function DocumentsTab({ documents, onDelete, onRename, setShowUploadModal, setShowQRModal }: DocumentsTabProps) {
|
||||
const { isDark } = useTheme()
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Upload-Optionen */}
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<button
|
||||
onClick={() => setShowUploadModal(true)}
|
||||
className={`p-6 rounded-3xl border backdrop-blur-xl text-left transition-all hover:scale-105 ${
|
||||
isDark
|
||||
? 'bg-white/10 border-white/20 hover:bg-white/15'
|
||||
: 'bg-white/70 border-black/10 hover:bg-white/90 shadow-lg'
|
||||
}`}
|
||||
>
|
||||
<div className={`w-14 h-14 rounded-2xl flex items-center justify-center text-3xl mb-4 ${
|
||||
isDark ? 'bg-blue-500/20' : 'bg-blue-100'
|
||||
}`}>
|
||||
📤
|
||||
</div>
|
||||
<h3 className={`text-lg font-semibold mb-2 ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
Direkt hochladen
|
||||
</h3>
|
||||
<p className={`text-sm ${isDark ? 'text-white/60' : 'text-slate-500'}`}>
|
||||
Ziehen Sie Dateien hierher oder klicken Sie zum Auswaehlen
|
||||
</p>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setShowQRModal(true)}
|
||||
className={`p-6 rounded-3xl border backdrop-blur-xl text-left transition-all hover:scale-105 ${
|
||||
isDark
|
||||
? 'bg-white/10 border-white/20 hover:bg-white/15'
|
||||
: 'bg-white/70 border-black/10 hover:bg-white/90 shadow-lg'
|
||||
}`}
|
||||
>
|
||||
<div className={`w-14 h-14 rounded-2xl flex items-center justify-center text-3xl mb-4 ${
|
||||
isDark ? 'bg-purple-500/20' : 'bg-purple-100'
|
||||
}`}>
|
||||
📱
|
||||
</div>
|
||||
<h3 className={`text-lg font-semibold mb-2 ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
Mit Mobiltelefon hochladen
|
||||
</h3>
|
||||
<p className={`text-sm ${isDark ? 'text-white/60' : 'text-slate-500'}`}>
|
||||
QR-Code scannen (nur im lokalen Netzwerk)
|
||||
</p>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Document Space */}
|
||||
<div className={`rounded-3xl border backdrop-blur-xl p-6 ${
|
||||
isDark
|
||||
? 'bg-white/10 border-white/20'
|
||||
: 'bg-white/70 border-black/10 shadow-lg'
|
||||
}`}>
|
||||
<h2 className={`text-xl font-semibold mb-6 ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
Meine Dokumente
|
||||
</h2>
|
||||
<DocumentSpace
|
||||
documents={documents}
|
||||
onDelete={onDelete}
|
||||
onRename={onRename}
|
||||
onOpen={(doc) => doc.url && window.open(doc.url, '_blank')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
'use client'
|
||||
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useLanguage } from '@/lib/LanguageContext'
|
||||
import { useTheme } from '@/lib/ThemeContext'
|
||||
import { useAlerts, getImportanceColor, getRelativeTime } from '@/lib/AlertsContext'
|
||||
import { LanguageDropdown } from '@/components/LanguageDropdown'
|
||||
import { ThemeToggle } from '@/components/ThemeToggle'
|
||||
|
||||
interface HeaderBarProps {
|
||||
showAlertsDropdown: boolean
|
||||
setShowAlertsDropdown: (show: boolean) => void
|
||||
}
|
||||
|
||||
export function HeaderBar({ showAlertsDropdown, setShowAlertsDropdown }: HeaderBarProps) {
|
||||
const router = useRouter()
|
||||
const { t } = useLanguage()
|
||||
const { isDark } = useTheme()
|
||||
const { alerts, unreadCount, markAsRead } = useAlerts()
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<h1 className={`text-4xl font-bold mb-2 ${isDark ? 'text-white' : 'text-slate-900'}`}>{t('dashboard')}</h1>
|
||||
<p className={isDark ? 'text-white/60' : 'text-slate-600'}>{t('dashboard_subtitle')}</p>
|
||||
</div>
|
||||
|
||||
{/* Search, Language & Actions */}
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t('search_placeholder')}
|
||||
className={`backdrop-blur-xl border rounded-2xl px-5 py-3 pl-12 focus:outline-none focus:ring-2 w-64 transition-all ${
|
||||
isDark
|
||||
? 'bg-white/10 border-white/20 text-white placeholder-white/40 focus:ring-white/30'
|
||||
: 'bg-white/70 border-black/10 text-slate-900 placeholder-slate-400 focus:ring-indigo-300'
|
||||
}`}
|
||||
/>
|
||||
<svg className={`absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 ${isDark ? 'text-white/40' : 'text-slate-400'}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<LanguageDropdown />
|
||||
<ThemeToggle />
|
||||
|
||||
{/* Notifications Bell with Glow Effect */}
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setShowAlertsDropdown(!showAlertsDropdown)}
|
||||
className={`relative p-3 backdrop-blur-xl border rounded-2xl transition-all ${
|
||||
unreadCount > 0
|
||||
? 'animate-pulse bg-gradient-to-r from-amber-500/20 to-orange-500/20 border-amber-500/30 shadow-lg shadow-amber-500/30'
|
||||
: isDark
|
||||
? 'bg-white/10 border-white/20 hover:bg-white/20'
|
||||
: 'bg-black/5 border-black/10 hover:bg-black/10'
|
||||
} ${isDark ? 'text-white' : 'text-slate-700'}`}
|
||||
>
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" />
|
||||
</svg>
|
||||
{unreadCount > 0 && (
|
||||
<span className="absolute -top-1 -right-1 w-5 h-5 bg-red-500 rounded-full text-xs text-white flex items-center justify-center font-medium">
|
||||
{unreadCount > 9 ? '9+' : unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Alerts Dropdown */}
|
||||
{showAlertsDropdown && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-40" onClick={() => setShowAlertsDropdown(false)} />
|
||||
<div className={`absolute right-0 mt-2 w-80 rounded-2xl border shadow-xl z-50 ${
|
||||
isDark
|
||||
? 'bg-slate-900 border-white/20'
|
||||
: 'bg-white border-slate-200'
|
||||
}`}>
|
||||
<div className={`p-4 border-b ${isDark ? 'border-white/10' : 'border-slate-100'}`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className={`font-semibold ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
Aktuelle Alerts
|
||||
</h3>
|
||||
{unreadCount > 0 && (
|
||||
<span className="px-2 py-0.5 text-xs font-medium rounded-full bg-amber-500/20 text-amber-500">
|
||||
{unreadCount} neu
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="max-h-80 overflow-y-auto">
|
||||
{alerts.slice(0, 5).map(alert => (
|
||||
<button
|
||||
key={alert.id}
|
||||
onClick={() => {
|
||||
markAsRead(alert.id)
|
||||
setShowAlertsDropdown(false)
|
||||
router.push('/alerts')
|
||||
}}
|
||||
className={`w-full text-left p-4 transition-all ${
|
||||
isDark
|
||||
? `hover:bg-white/5 ${!alert.isRead ? 'bg-amber-500/5 border-l-2 border-amber-500' : ''}`
|
||||
: `hover:bg-slate-50 ${!alert.isRead ? 'bg-amber-50 border-l-2 border-amber-500' : ''}`
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<span className={`px-1.5 py-0.5 rounded text-xs font-medium ${getImportanceColor(alert.importance, isDark)}`}>
|
||||
{alert.importance.slice(0, 4)}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className={`text-sm font-medium truncate ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
{alert.title}
|
||||
</p>
|
||||
<p className={`text-xs ${isDark ? 'text-white/40' : 'text-slate-400'}`}>
|
||||
{getRelativeTime(alert.timestamp)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
{alerts.length === 0 && (
|
||||
<div className={`p-8 text-center ${isDark ? 'text-white/40' : 'text-slate-400'}`}>
|
||||
<span className="text-2xl block mb-2">📭</span>
|
||||
<p className="text-sm">Keine Alerts</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className={`p-3 border-t ${isDark ? 'border-white/10' : 'border-slate-100'}`}>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowAlertsDropdown(false)
|
||||
router.push('/alerts')
|
||||
}}
|
||||
className={`w-full py-2 text-sm font-medium rounded-lg transition-all ${
|
||||
isDark
|
||||
? 'text-amber-400 hover:bg-amber-500/10'
|
||||
: 'text-amber-600 hover:bg-amber-50'
|
||||
}`}
|
||||
>
|
||||
Alle Alerts anzeigen →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
'use client'
|
||||
|
||||
import { useTheme } from '@/lib/ThemeContext'
|
||||
import { DocumentUpload } from '@/components/DocumentUpload'
|
||||
import { QRCodeUpload } from '@/components/QRCodeUpload'
|
||||
|
||||
interface StoredDocument {
|
||||
id: string
|
||||
name: string
|
||||
type: string
|
||||
size: number
|
||||
uploadedAt: Date
|
||||
url?: string
|
||||
}
|
||||
|
||||
interface UploadModalProps {
|
||||
documents: StoredDocument[]
|
||||
onUploadComplete: (docs: any[]) => void
|
||||
onClose: () => void
|
||||
onGoToDocuments: () => void
|
||||
}
|
||||
|
||||
export function UploadModal({ documents, onUploadComplete, onClose, onGoToDocuments }: UploadModalProps) {
|
||||
const { isDark } = useTheme()
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" onClick={onClose} />
|
||||
<div className={`relative w-full max-w-2xl rounded-3xl border p-6 max-h-[90vh] overflow-y-auto ${
|
||||
isDark
|
||||
? 'bg-slate-900 border-white/20'
|
||||
: 'bg-white border-slate-200 shadow-2xl'
|
||||
}`}>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className={`text-xl font-semibold ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
Dokumente hochladen
|
||||
</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className={`p-2 rounded-lg ${isDark ? 'hover:bg-white/10' : 'hover:bg-slate-100'}`}
|
||||
>
|
||||
<svg className={`w-5 h-5 ${isDark ? 'text-white/60' : 'text-slate-400'}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<DocumentUpload
|
||||
onUploadComplete={(docs) => {
|
||||
onUploadComplete(docs)
|
||||
}}
|
||||
/>
|
||||
{/* Aktions-Buttons */}
|
||||
<div className={`mt-6 pt-6 border-t flex justify-between items-center ${
|
||||
isDark ? 'border-white/10' : 'border-slate-200'
|
||||
}`}>
|
||||
<p className={`text-sm ${isDark ? 'text-white/50' : 'text-slate-500'}`}>
|
||||
{documents.length > 0 ? `${documents.length} Dokument${documents.length !== 1 ? 'e' : ''} gespeichert` : 'Noch keine Dokumente'}
|
||||
</p>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className={`px-4 py-2 rounded-xl text-sm font-medium ${
|
||||
isDark ? 'text-white/60 hover:text-white' : 'text-slate-500 hover:text-slate-700'
|
||||
}`}
|
||||
>
|
||||
Schliessen
|
||||
</button>
|
||||
<button
|
||||
onClick={onGoToDocuments}
|
||||
className="px-4 py-2 bg-gradient-to-r from-purple-500 to-pink-500 text-white rounded-xl text-sm font-medium hover:shadow-lg transition-all"
|
||||
>
|
||||
Zu meinen Dokumenten
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface QRModalProps {
|
||||
sessionId: string
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function QRModal({ sessionId, onClose }: QRModalProps) {
|
||||
const { isDark } = useTheme()
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" onClick={onClose} />
|
||||
<div className={`relative w-full max-w-md rounded-3xl ${
|
||||
isDark ? 'bg-slate-900' : 'bg-white'
|
||||
}`}>
|
||||
<QRCodeUpload
|
||||
sessionId={sessionId}
|
||||
onClose={onClose}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import type { CreateKlausurData } from '../types'
|
||||
import { GlassCard } from './GlassCard'
|
||||
|
||||
interface CreateKlausurModalProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
onSubmit: (data: CreateKlausurData) => void
|
||||
isLoading: boolean
|
||||
isDark?: boolean
|
||||
}
|
||||
|
||||
export function CreateKlausurModal({ isOpen, onClose, onSubmit, isLoading, isDark = true }: CreateKlausurModalProps) {
|
||||
const [title, setTitle] = useState('')
|
||||
const [subject, setSubject] = useState('Deutsch')
|
||||
const [year, setYear] = useState(new Date().getFullYear())
|
||||
const [semester, setSemester] = useState('Abitur')
|
||||
const [modus, setModus] = useState<'landes_abitur' | 'vorabitur'>('landes_abitur')
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
onSubmit({ title, subject, year, semester, modus })
|
||||
}
|
||||
|
||||
const inputClasses = isDark
|
||||
? 'bg-white/10 border-white/20 text-white placeholder-white/40'
|
||||
: 'bg-slate-100 border-slate-300 text-slate-900 placeholder-slate-400'
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" onClick={onClose} />
|
||||
<GlassCard className="relative w-full max-w-md" size="lg" delay={0} isDark={isDark}>
|
||||
<h2 className={`text-xl font-bold mb-6 ${isDark ? 'text-white' : 'text-slate-900'}`}>Neue Klausur erstellen</h2>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="klausur-titel" className={`block text-sm mb-2 ${isDark ? 'text-white/60' : 'text-slate-600'}`}>Titel</label>
|
||||
<input
|
||||
id="klausur-titel"
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="z.B. Deutsch LK Q4"
|
||||
className={`w-full p-3 rounded-xl border focus:ring-2 focus:ring-purple-500 focus:border-transparent ${inputClasses}`}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label htmlFor="klausur-fach" className={`block text-sm mb-2 ${isDark ? 'text-white/60' : 'text-slate-600'}`}>Fach</label>
|
||||
<select id="klausur-fach" value={subject} onChange={(e) => setSubject(e.target.value)}
|
||||
className={`w-full p-3 rounded-xl border focus:ring-2 focus:ring-purple-500 ${inputClasses}`}>
|
||||
<option value="Deutsch">Deutsch</option>
|
||||
<option value="Englisch">Englisch</option>
|
||||
<option value="Mathematik">Mathematik</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="klausur-jahr" className={`block text-sm mb-2 ${isDark ? 'text-white/60' : 'text-slate-600'}`}>Jahr</label>
|
||||
<input id="klausur-jahr" type="number" value={year} onChange={(e) => setYear(Number(e.target.value))}
|
||||
className={`w-full p-3 rounded-xl border focus:ring-2 focus:ring-purple-500 ${inputClasses}`} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label htmlFor="klausur-semester" className={`block text-sm mb-2 ${isDark ? 'text-white/60' : 'text-slate-600'}`}>Semester</label>
|
||||
<select id="klausur-semester" value={semester} onChange={(e) => setSemester(e.target.value)}
|
||||
className={`w-full p-3 rounded-xl border focus:ring-2 focus:ring-purple-500 ${inputClasses}`}>
|
||||
<option value="Abitur">Abitur</option>
|
||||
<option value="Q1">Q1</option>
|
||||
<option value="Q2">Q2</option>
|
||||
<option value="Q3">Q3</option>
|
||||
<option value="Q4">Q4</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="klausur-modus" className={`block text-sm mb-2 ${isDark ? 'text-white/60' : 'text-slate-600'}`}>Modus</label>
|
||||
<select id="klausur-modus" value={modus} onChange={(e) => setModus(e.target.value as 'landes_abitur' | 'vorabitur')}
|
||||
className={`w-full p-3 rounded-xl border focus:ring-2 focus:ring-purple-500 ${inputClasses}`}>
|
||||
<option value="landes_abitur">Landes-Abitur (NiBiS EH)</option>
|
||||
<option value="vorabitur">Vorabitur (Eigener EH)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 pt-4">
|
||||
<button type="button" onClick={onClose}
|
||||
className={`flex-1 px-4 py-3 rounded-xl transition-colors ${isDark ? 'bg-white/10 text-white hover:bg-white/20' : 'bg-slate-200 text-slate-700 hover:bg-slate-300'}`}>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button type="submit" disabled={isLoading || !title.trim()}
|
||||
className="flex-1 px-4 py-3 rounded-xl bg-gradient-to-r from-purple-500 to-pink-500 text-white font-semibold hover:shadow-lg hover:shadow-purple-500/30 transition-all disabled:opacity-50">
|
||||
{isLoading ? 'Erstelle...' : 'Erstellen'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</GlassCard>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
|
||||
interface GlassCardProps {
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
onClick?: () => void
|
||||
size?: 'sm' | 'md' | 'lg'
|
||||
delay?: number
|
||||
isDark?: boolean
|
||||
}
|
||||
|
||||
export function GlassCard({ children, className = '', onClick, size = 'md', delay = 0, isDark = true }: GlassCardProps) {
|
||||
const [isVisible, setIsVisible] = useState(false)
|
||||
const [isHovered, setIsHovered] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setIsVisible(true), delay)
|
||||
return () => clearTimeout(timer)
|
||||
}, [delay])
|
||||
|
||||
const sizeClasses = { sm: 'p-4', md: 'p-5', lg: 'p-6' }
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`rounded-3xl ${sizeClasses[size]} ${onClick ? 'cursor-pointer' : ''} ${className}`}
|
||||
style={{
|
||||
background: isDark
|
||||
? (isHovered ? 'rgba(255, 255, 255, 0.12)' : 'rgba(255, 255, 255, 0.08)')
|
||||
: (isHovered ? 'rgba(255, 255, 255, 0.9)' : 'rgba(255, 255, 255, 0.7)'),
|
||||
backdropFilter: 'blur(24px) saturate(180%)',
|
||||
WebkitBackdropFilter: 'blur(24px) saturate(180%)',
|
||||
border: isDark ? '1px solid rgba(255, 255, 255, 0.1)' : '1px solid rgba(0, 0, 0, 0.1)',
|
||||
boxShadow: isDark
|
||||
? '0 4px 24px rgba(0, 0, 0, 0.15), inset 0 1px 0 rgba(255, 255, 255, 0.05)'
|
||||
: '0 4px 24px rgba(0, 0, 0, 0.08), inset 0 1px 0 rgba(255, 255, 255, 0.5)',
|
||||
opacity: isVisible ? 1 : 0,
|
||||
transform: isVisible ? `translateY(0) scale(${isHovered ? 1.01 : 1})` : 'translateY(20px)',
|
||||
transition: 'all 0.4s cubic-bezier(0.34, 1.56, 0.64, 1)',
|
||||
}}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
onClick={onClick}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
'use client'
|
||||
|
||||
import type { Klausur } from '../types'
|
||||
import { GlassCard } from './GlassCard'
|
||||
|
||||
interface KlausurCardProps {
|
||||
klausur: Klausur
|
||||
onClick: () => void
|
||||
delay?: number
|
||||
isDark?: boolean
|
||||
}
|
||||
|
||||
export function KlausurCard({ klausur, onClick, delay = 0, isDark = true }: KlausurCardProps) {
|
||||
const progress = klausur.student_count
|
||||
? Math.round(((klausur.completed_count || 0) / klausur.student_count) * 100)
|
||||
: 0
|
||||
|
||||
const statusColor = klausur.status === 'completed'
|
||||
? '#22c55e'
|
||||
: klausur.status === 'in_progress'
|
||||
? '#f97316'
|
||||
: '#6b7280'
|
||||
|
||||
return (
|
||||
<GlassCard onClick={onClick} delay={delay} className="min-h-[180px]" isDark={isDark}>
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<h3 className={`text-lg font-semibold ${isDark ? 'text-white' : 'text-slate-900'}`}>{klausur.title}</h3>
|
||||
<span
|
||||
className="px-2 py-1 rounded-full text-xs font-medium"
|
||||
style={{ backgroundColor: `${statusColor}20`, color: statusColor }}
|
||||
>
|
||||
{klausur.status === 'completed' ? 'Fertig' : klausur.status === 'in_progress' ? 'In Arbeit' : 'Entwurf'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p className={`text-sm mb-4 ${isDark ? 'text-white/50' : 'text-slate-500'}`}>
|
||||
{klausur.subject} {klausur.semester} {klausur.year}
|
||||
</p>
|
||||
|
||||
<div className="mt-auto">
|
||||
<div className="flex justify-between text-sm mb-2">
|
||||
<span className={isDark ? 'text-white/50' : 'text-slate-500'}>{klausur.student_count || 0} Arbeiten</span>
|
||||
<span className={isDark ? 'text-white' : 'text-slate-900'}>{progress}%</span>
|
||||
</div>
|
||||
|
||||
<div className={`h-2 rounded-full overflow-hidden ${isDark ? 'bg-white/10' : 'bg-slate-200'}`}>
|
||||
<div
|
||||
className="h-full rounded-full transition-all duration-500"
|
||||
style={{
|
||||
width: `${progress}%`,
|
||||
background: `linear-gradient(90deg, ${statusColor}, ${statusColor}80)`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</GlassCard>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
'use client'
|
||||
|
||||
import { GlassCard } from './GlassCard'
|
||||
|
||||
interface StatCardProps {
|
||||
label: string
|
||||
value: string | number
|
||||
icon: React.ReactNode
|
||||
color?: string
|
||||
delay?: number
|
||||
isDark?: boolean
|
||||
}
|
||||
|
||||
export function StatCard({ label, value, icon, color = '#a78bfa', delay = 0, isDark = true }: StatCardProps) {
|
||||
return (
|
||||
<GlassCard size="sm" delay={delay} isDark={isDark}>
|
||||
<div className="flex items-center gap-4">
|
||||
<div
|
||||
className="w-12 h-12 rounded-2xl flex items-center justify-center"
|
||||
style={{ backgroundColor: `${color}20` }}
|
||||
>
|
||||
<span style={{ color }}>{icon}</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className={`text-sm ${isDark ? 'text-white/50' : 'text-slate-500'}`}>{label}</p>
|
||||
<p className={`text-2xl font-bold ${isDark ? 'text-white' : 'text-slate-900'}`}>{value}</p>
|
||||
</div>
|
||||
</div>
|
||||
</GlassCard>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
'use client'
|
||||
|
||||
import { QRCodeUpload, UploadedFile } from '@/components/QRCodeUpload'
|
||||
import { GlassCard } from './GlassCard'
|
||||
|
||||
// =============================================================================
|
||||
// Direct Upload Modal
|
||||
// =============================================================================
|
||||
|
||||
interface DirectUploadModalProps {
|
||||
isDark: boolean
|
||||
isDragging: boolean
|
||||
uploadedFiles: File[]
|
||||
isUploading: boolean
|
||||
error: string | null
|
||||
onDragOver: (e: React.DragEvent) => void
|
||||
onDragLeave: (e: React.DragEvent) => void
|
||||
onDrop: (e: React.DragEvent) => void
|
||||
onFileSelect: (e: React.ChangeEvent<HTMLInputElement>) => void
|
||||
onRemoveFile: (idx: number) => void
|
||||
onUpload: () => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function DirectUploadModal({
|
||||
isDark, isDragging, uploadedFiles, isUploading, error,
|
||||
onDragOver, onDragLeave, onDrop, onFileSelect, onRemoveFile, onUpload, onClose
|
||||
}: DirectUploadModalProps) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" onClick={onClose} />
|
||||
<GlassCard className="relative w-full max-w-lg" size="lg" delay={0} isDark={isDark}>
|
||||
<h2 className={`text-xl font-bold mb-4 ${isDark ? 'text-white' : 'text-slate-900'}`}>Arbeiten hochladen</h2>
|
||||
<p className={`mb-4 ${isDark ? 'text-white/60' : 'text-slate-600'}`}>
|
||||
Ziehen Sie eingescannte Klausuren hierher oder klicken Sie zum Auswaehlen.
|
||||
</p>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-3 rounded-lg bg-red-500/20 border border-red-500/30 text-red-300 text-sm">{error}</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
onDragOver={onDragOver} onDragLeave={onDragLeave} onDrop={onDrop}
|
||||
className={`relative p-8 rounded-2xl border-2 border-dashed transition-colors ${
|
||||
isDragging ? 'border-purple-400 bg-purple-500/10'
|
||||
: isDark ? 'border-white/20 hover:border-white/40' : 'border-slate-300 hover:border-slate-400'
|
||||
}`}
|
||||
>
|
||||
<input type="file" accept=".pdf,image/*" multiple onChange={onFileSelect}
|
||||
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer" />
|
||||
<div className="text-center">
|
||||
<svg className={`w-12 h-12 mx-auto mb-4 ${isDark ? 'text-white/40' : 'text-slate-400'}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
|
||||
</svg>
|
||||
<p className={`font-medium ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
{isDragging ? 'Dateien hier ablegen' : 'Dateien hierher ziehen'}
|
||||
</p>
|
||||
<p className={`text-sm mt-1 ${isDark ? 'text-white/50' : 'text-slate-500'}`}>PDF oder Bilder (JPG, PNG)</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{uploadedFiles.length > 0 && (
|
||||
<div className="mt-4 space-y-2">
|
||||
<p className={`text-sm font-medium ${isDark ? 'text-white/60' : 'text-slate-600'}`}>
|
||||
{uploadedFiles.length} Datei(en) ausgewaehlt:
|
||||
</p>
|
||||
<div className="max-h-32 overflow-y-auto space-y-1">
|
||||
{uploadedFiles.map((file, idx) => (
|
||||
<div key={idx} className={`flex items-center justify-between p-2 rounded-lg ${isDark ? 'bg-white/5' : 'bg-slate-100'}`}>
|
||||
<span className={`text-sm truncate ${isDark ? 'text-white/80' : 'text-slate-700'}`}>{file.name}</span>
|
||||
<button onClick={() => onRemoveFile(idx)} className="text-red-400 hover:text-red-300">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3 mt-6">
|
||||
<button onClick={onClose}
|
||||
className={`flex-1 px-4 py-3 rounded-xl transition-colors ${isDark ? 'bg-white/10 text-white hover:bg-white/20' : 'bg-slate-200 text-slate-700 hover:bg-slate-300'}`}>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button onClick={onUpload} disabled={uploadedFiles.length === 0 || isUploading}
|
||||
className="flex-1 px-4 py-3 rounded-xl bg-gradient-to-r from-purple-500 to-pink-500 text-white font-semibold hover:shadow-lg hover:shadow-purple-500/30 transition-all disabled:opacity-50">
|
||||
{isUploading ? 'Hochladen...' : `${uploadedFiles.length} Arbeiten hochladen`}
|
||||
</button>
|
||||
</div>
|
||||
</GlassCard>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// EH Upload Modal
|
||||
// =============================================================================
|
||||
|
||||
interface EHUploadModalProps {
|
||||
isDark: boolean
|
||||
isDragging: boolean
|
||||
ehFile: File | null
|
||||
isUploading: boolean
|
||||
onDragOver: (e: React.DragEvent) => void
|
||||
onDragLeave: (e: React.DragEvent) => void
|
||||
onDrop: (e: React.DragEvent) => void
|
||||
onFileSelect: (e: React.ChangeEvent<HTMLInputElement>) => void
|
||||
onRemoveFile: () => void
|
||||
onUpload: () => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function EHUploadModal({
|
||||
isDark, isDragging, ehFile, isUploading,
|
||||
onDragOver, onDragLeave, onDrop, onFileSelect, onRemoveFile, onUpload, onClose
|
||||
}: EHUploadModalProps) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" onClick={onClose} />
|
||||
<GlassCard className="relative w-full max-w-lg" size="lg" delay={0} isDark={isDark}>
|
||||
<h2 className={`text-xl font-bold mb-4 ${isDark ? 'text-white' : 'text-slate-900'}`}>Erwartungshorizont hochladen</h2>
|
||||
<p className={`mb-6 ${isDark ? 'text-white/60' : 'text-slate-600'}`}>
|
||||
Laden Sie einen eigenen Erwartungshorizont fuer Vorabitur-Klausuren hoch.
|
||||
</p>
|
||||
|
||||
<div
|
||||
onDragOver={onDragOver} onDragLeave={onDragLeave} onDrop={onDrop}
|
||||
className={`relative p-8 rounded-2xl border-2 border-dashed transition-colors ${
|
||||
isDragging ? 'border-orange-400 bg-orange-500/10'
|
||||
: isDark ? 'border-white/20 hover:border-white/40' : 'border-slate-300 hover:border-slate-400'
|
||||
}`}
|
||||
>
|
||||
<input type="file" accept=".pdf,.docx,.doc" onChange={onFileSelect}
|
||||
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer" />
|
||||
<div className="text-center">
|
||||
<svg className={`w-12 h-12 mx-auto mb-4 ${isDark ? 'text-white/40' : 'text-slate-400'}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
<p className={`font-medium ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
{ehFile ? ehFile.name : 'EH-Datei hierher ziehen'}
|
||||
</p>
|
||||
<p className={`text-sm mt-1 ${isDark ? 'text-white/50' : 'text-slate-500'}`}>PDF oder Word-Dokument</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{ehFile && (
|
||||
<div className={`mt-4 flex items-center justify-between p-3 rounded-lg ${isDark ? 'bg-white/5' : 'bg-slate-100'}`}>
|
||||
<div className="flex items-center gap-3">
|
||||
<svg className="w-6 h-6 text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
<span className={`text-sm truncate ${isDark ? 'text-white/80' : 'text-slate-700'}`}>{ehFile.name}</span>
|
||||
</div>
|
||||
<button onClick={onRemoveFile} className="text-red-400 hover:text-red-300">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3 mt-6">
|
||||
<button onClick={onClose}
|
||||
className={`flex-1 px-4 py-3 rounded-xl transition-colors ${isDark ? 'bg-white/10 text-white hover:bg-white/20' : 'bg-slate-200 text-slate-700 hover:bg-slate-300'}`}>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button onClick={onUpload} disabled={!ehFile || isUploading}
|
||||
className="flex-1 px-4 py-3 rounded-xl bg-gradient-to-r from-orange-500 to-red-500 text-white font-semibold hover:shadow-lg hover:shadow-orange-500/30 transition-all disabled:opacity-50">
|
||||
{isUploading ? 'Hochladen...' : 'EH hochladen'}
|
||||
</button>
|
||||
</div>
|
||||
</GlassCard>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// QR Code Modal
|
||||
// =============================================================================
|
||||
|
||||
interface QRCodeModalProps {
|
||||
isDark: boolean
|
||||
sessionId: string
|
||||
onClose: () => void
|
||||
onFileUploaded?: (file: UploadedFile) => void
|
||||
}
|
||||
|
||||
export function QRCodeModal({ isDark, sessionId, onClose, onFileUploaded }: QRCodeModalProps) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" onClick={onClose} />
|
||||
<div className={`relative w-full max-w-md rounded-3xl ${isDark ? 'bg-slate-900' : 'bg-white'}`}>
|
||||
<QRCodeUpload sessionId={sessionId} onClose={onClose} onFileUploaded={onFileUploaded} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,934 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { BPIcon } from '@/components/Logo'
|
||||
import { useLanguage } from '@/lib/LanguageContext'
|
||||
import { useTheme } from '@/lib/ThemeContext'
|
||||
import { useAlerts, getImportanceColor, getRelativeTime } from '@/lib/AlertsContext'
|
||||
import { useMessages, formatMessageTime, getContactInitials } from '@/lib/MessagesContext'
|
||||
import { LanguageDropdown } from '@/components/LanguageDropdown'
|
||||
import { ThemeToggle } from '@/components/ThemeToggle'
|
||||
import { Footer } from '@/components/Footer'
|
||||
import { Sidebar } from '@/components/Sidebar'
|
||||
import { OnboardingWizard, OnboardingData } from '@/components/OnboardingWizard'
|
||||
import { DocumentUpload } from '@/components/DocumentUpload'
|
||||
import { QRCodeUpload } from '@/components/QRCodeUpload'
|
||||
import { DocumentSpace } from '@/components/DocumentSpace'
|
||||
import { ChatOverlay } from '@/components/ChatOverlay'
|
||||
|
||||
// LocalStorage Keys
|
||||
const ONBOARDING_KEY = 'bp_onboarding_complete'
|
||||
const USER_DATA_KEY = 'bp_user_data'
|
||||
const DOCUMENTS_KEY = 'bp_documents'
|
||||
const FIRST_VISIT_KEY = 'bp_first_dashboard_visit'
|
||||
const SESSION_ID_KEY = 'bp_session_id'
|
||||
|
||||
// BreakPilot Studio v2 - Glassmorphism Design
|
||||
|
||||
interface StoredDocument {
|
||||
id: string
|
||||
name: string
|
||||
type: string
|
||||
size: number
|
||||
uploadedAt: Date
|
||||
url?: string
|
||||
}
|
||||
|
||||
export default function HomePage() {
|
||||
const router = useRouter()
|
||||
const [selectedTab, setSelectedTab] = useState('dashboard')
|
||||
const [showOnboarding, setShowOnboarding] = useState<boolean | null>(null)
|
||||
const [userData, setUserData] = useState<OnboardingData | null>(null)
|
||||
const [documents, setDocuments] = useState<StoredDocument[]>([])
|
||||
const [showUploadModal, setShowUploadModal] = useState(false)
|
||||
const [showQRModal, setShowQRModal] = useState(false)
|
||||
const [isFirstVisit, setIsFirstVisit] = useState(false)
|
||||
const [sessionId, setSessionId] = useState<string>('')
|
||||
const [showAlertsDropdown, setShowAlertsDropdown] = useState(false)
|
||||
const { t } = useLanguage()
|
||||
const { isDark } = useTheme()
|
||||
const { alerts, unreadCount, markAsRead } = useAlerts()
|
||||
const { conversations, unreadCount: messagesUnreadCount, contacts, markAsRead: markMessageAsRead } = useMessages()
|
||||
|
||||
// Funktion zum Laden von Uploads aus der API
|
||||
const fetchUploadsFromAPI = useCallback(async (sid: string) => {
|
||||
if (!sid) return
|
||||
try {
|
||||
const response = await fetch(`/api/uploads?sessionId=${encodeURIComponent(sid)}`)
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
if (data.uploads && data.uploads.length > 0) {
|
||||
// Konvertiere API-Uploads zu StoredDocument Format
|
||||
const apiDocs: StoredDocument[] = data.uploads.map((u: any) => ({
|
||||
id: u.id,
|
||||
name: u.name,
|
||||
type: u.type,
|
||||
size: u.size,
|
||||
uploadedAt: new Date(u.uploadedAt),
|
||||
url: u.dataUrl // Data URL direkt verwenden
|
||||
}))
|
||||
// Merge mit existierenden Dokumenten (ohne Duplikate)
|
||||
setDocuments(prev => {
|
||||
const existingIds = new Set(prev.map(d => d.id))
|
||||
const newDocs = apiDocs.filter(d => !existingIds.has(d.id))
|
||||
if (newDocs.length > 0) {
|
||||
return [...prev, ...newDocs]
|
||||
}
|
||||
return prev
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching uploads:', error)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Prüfe beim Laden, ob Onboarding abgeschlossen ist
|
||||
useEffect(() => {
|
||||
const onboardingComplete = localStorage.getItem(ONBOARDING_KEY)
|
||||
const storedUserData = localStorage.getItem(USER_DATA_KEY)
|
||||
const storedDocs = localStorage.getItem(DOCUMENTS_KEY)
|
||||
const firstVisit = localStorage.getItem(FIRST_VISIT_KEY)
|
||||
let storedSessionId = localStorage.getItem(SESSION_ID_KEY)
|
||||
|
||||
// Session ID generieren falls nicht vorhanden
|
||||
if (!storedSessionId) {
|
||||
storedSessionId = `session-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
|
||||
localStorage.setItem(SESSION_ID_KEY, storedSessionId)
|
||||
}
|
||||
setSessionId(storedSessionId)
|
||||
|
||||
if (onboardingComplete === 'true' && storedUserData) {
|
||||
setUserData(JSON.parse(storedUserData))
|
||||
setShowOnboarding(false)
|
||||
|
||||
// Dokumente laden
|
||||
if (storedDocs) {
|
||||
setDocuments(JSON.parse(storedDocs))
|
||||
}
|
||||
|
||||
// Erster Dashboard-Besuch nach Onboarding?
|
||||
if (!firstVisit) {
|
||||
setIsFirstVisit(true)
|
||||
localStorage.setItem(FIRST_VISIT_KEY, 'true')
|
||||
}
|
||||
|
||||
// Initialer Fetch von der API
|
||||
fetchUploadsFromAPI(storedSessionId)
|
||||
} else {
|
||||
setShowOnboarding(true)
|
||||
}
|
||||
}, [fetchUploadsFromAPI])
|
||||
|
||||
// Polling fuer neue Uploads von der API (alle 3 Sekunden)
|
||||
useEffect(() => {
|
||||
if (!sessionId || showOnboarding) return
|
||||
|
||||
const interval = setInterval(() => {
|
||||
fetchUploadsFromAPI(sessionId)
|
||||
}, 3000)
|
||||
|
||||
return () => clearInterval(interval)
|
||||
}, [sessionId, showOnboarding, fetchUploadsFromAPI])
|
||||
|
||||
// Dokumente in localStorage speichern
|
||||
useEffect(() => {
|
||||
if (documents.length > 0) {
|
||||
localStorage.setItem(DOCUMENTS_KEY, JSON.stringify(documents))
|
||||
}
|
||||
}, [documents])
|
||||
|
||||
// Handler fuer neue Uploads
|
||||
const handleUploadComplete = (uploadedDocs: any[]) => {
|
||||
const newDocs: StoredDocument[] = uploadedDocs.map(d => ({
|
||||
id: d.id,
|
||||
name: d.name,
|
||||
type: d.type,
|
||||
size: d.size,
|
||||
uploadedAt: d.uploadedAt,
|
||||
url: d.url
|
||||
}))
|
||||
setDocuments(prev => [...prev, ...newDocs])
|
||||
setIsFirstVisit(false)
|
||||
}
|
||||
|
||||
// Dokument loeschen (aus State und API)
|
||||
const handleDeleteDocument = async (id: string) => {
|
||||
setDocuments(prev => prev.filter(d => d.id !== id))
|
||||
// Auch aus API loeschen
|
||||
try {
|
||||
await fetch(`/api/uploads?id=${encodeURIComponent(id)}`, { method: 'DELETE' })
|
||||
} catch (error) {
|
||||
console.error('Error deleting from API:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Dokument umbenennen
|
||||
const handleRenameDocument = (id: string, newName: string) => {
|
||||
setDocuments(prev => prev.map(d => d.id === id ? { ...d, name: newName } : d))
|
||||
}
|
||||
|
||||
// Onboarding abschließen
|
||||
const handleOnboardingComplete = (data: OnboardingData) => {
|
||||
localStorage.setItem(ONBOARDING_KEY, 'true')
|
||||
localStorage.setItem(USER_DATA_KEY, JSON.stringify(data))
|
||||
setUserData(data)
|
||||
setShowOnboarding(false)
|
||||
}
|
||||
|
||||
// Zeige Ladebildschirm während der Prüfung
|
||||
if (showOnboarding === null) {
|
||||
return (
|
||||
<div className={`min-h-screen flex items-center justify-center ${
|
||||
isDark
|
||||
? 'bg-gradient-to-br from-indigo-900 via-purple-900 to-pink-800'
|
||||
: 'bg-gradient-to-br from-slate-100 via-blue-50 to-indigo-100'
|
||||
}`}>
|
||||
<div className="flex items-center gap-4">
|
||||
<BPIcon variant="cupertino" size={48} className="animate-pulse" />
|
||||
<span className={`text-xl font-medium ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
Laden...
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Zeige Onboarding falls noch nicht abgeschlossen
|
||||
if (showOnboarding) {
|
||||
return <OnboardingWizard onComplete={handleOnboardingComplete} />
|
||||
}
|
||||
|
||||
// Ab hier: Dashboard (bestehender Code)
|
||||
|
||||
const stats = [
|
||||
{ labelKey: 'stat_open_corrections', value: '12', icon: '📋', color: 'from-blue-400 to-blue-600' },
|
||||
{ labelKey: 'stat_completed_week', value: '28', icon: '✅', color: 'from-green-400 to-green-600' },
|
||||
{ labelKey: 'stat_average', value: '2.3', icon: '📈', color: 'from-purple-400 to-purple-600' },
|
||||
{ labelKey: 'stat_time_saved', value: '4.2h', icon: '⏱', color: 'from-orange-400 to-orange-600' },
|
||||
]
|
||||
|
||||
const recentKlausuren = [
|
||||
{ id: 1, title: 'Deutsch LK - Textanalyse', students: 24, completed: 18, statusKey: 'status_in_progress' },
|
||||
{ id: 2, title: 'Deutsch GK - Erörterung', students: 28, completed: 28, statusKey: 'status_completed' },
|
||||
{ id: 3, title: 'Vorabitur - Gedichtanalyse', students: 22, completed: 10, statusKey: 'status_in_progress' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className={`min-h-screen relative overflow-hidden flex flex-col ${
|
||||
isDark
|
||||
? 'bg-gradient-to-br from-indigo-900 via-purple-900 to-pink-800'
|
||||
: 'bg-gradient-to-br from-slate-100 via-blue-50 to-indigo-100'
|
||||
}`}>
|
||||
{/* Animated Background Blobs */}
|
||||
<div className="absolute inset-0 overflow-hidden pointer-events-none">
|
||||
<div className={`absolute -top-40 -right-40 w-80 h-80 rounded-full mix-blend-multiply filter blur-3xl animate-blob ${
|
||||
isDark ? 'bg-purple-500 opacity-70' : 'bg-purple-300 opacity-50'
|
||||
}`} />
|
||||
<div className={`absolute -bottom-40 -left-40 w-80 h-80 rounded-full mix-blend-multiply filter blur-3xl animate-blob animation-delay-2000 ${
|
||||
isDark ? 'bg-blue-500 opacity-70' : 'bg-blue-300 opacity-50'
|
||||
}`} />
|
||||
<div className={`absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 w-80 h-80 rounded-full mix-blend-multiply filter blur-3xl animate-blob animation-delay-4000 ${
|
||||
isDark ? 'bg-pink-500 opacity-70' : 'bg-pink-300 opacity-50'
|
||||
}`} />
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 flex min-h-screen gap-6 p-4">
|
||||
{/* Sidebar */}
|
||||
<Sidebar selectedTab={selectedTab} onTabChange={setSelectedTab} />
|
||||
|
||||
{/* ============================================
|
||||
ARBEITSFLAECHE (Main Content)
|
||||
============================================ */}
|
||||
<main className="flex-1">
|
||||
{/* Kopfleiste (Header) */}
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<h1 className={`text-4xl font-bold mb-2 ${isDark ? 'text-white' : 'text-slate-900'}`}>{t('dashboard')}</h1>
|
||||
<p className={isDark ? 'text-white/60' : 'text-slate-600'}>{t('dashboard_subtitle')}</p>
|
||||
</div>
|
||||
|
||||
{/* Search, Language & Actions */}
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t('search_placeholder')}
|
||||
className={`backdrop-blur-xl border rounded-2xl px-5 py-3 pl-12 focus:outline-none focus:ring-2 w-64 transition-all ${
|
||||
isDark
|
||||
? 'bg-white/10 border-white/20 text-white placeholder-white/40 focus:ring-white/30'
|
||||
: 'bg-white/70 border-black/10 text-slate-900 placeholder-slate-400 focus:ring-indigo-300'
|
||||
}`}
|
||||
/>
|
||||
<svg className={`absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 ${isDark ? 'text-white/40' : 'text-slate-400'}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Language Dropdown */}
|
||||
<LanguageDropdown />
|
||||
|
||||
{/* Theme Toggle */}
|
||||
<ThemeToggle />
|
||||
|
||||
{/* Notifications Bell with Glow Effect */}
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setShowAlertsDropdown(!showAlertsDropdown)}
|
||||
className={`relative p-3 backdrop-blur-xl border rounded-2xl transition-all ${
|
||||
unreadCount > 0
|
||||
? 'animate-pulse bg-gradient-to-r from-amber-500/20 to-orange-500/20 border-amber-500/30 shadow-lg shadow-amber-500/30'
|
||||
: isDark
|
||||
? 'bg-white/10 border-white/20 hover:bg-white/20'
|
||||
: 'bg-black/5 border-black/10 hover:bg-black/10'
|
||||
} ${isDark ? 'text-white' : 'text-slate-700'}`}
|
||||
>
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" />
|
||||
</svg>
|
||||
{unreadCount > 0 && (
|
||||
<span className="absolute -top-1 -right-1 w-5 h-5 bg-red-500 rounded-full text-xs text-white flex items-center justify-center font-medium">
|
||||
{unreadCount > 9 ? '9+' : unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Alerts Dropdown */}
|
||||
{showAlertsDropdown && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-40" onClick={() => setShowAlertsDropdown(false)} />
|
||||
<div className={`absolute right-0 mt-2 w-80 rounded-2xl border shadow-xl z-50 ${
|
||||
isDark
|
||||
? 'bg-slate-900 border-white/20'
|
||||
: 'bg-white border-slate-200'
|
||||
}`}>
|
||||
<div className={`p-4 border-b ${isDark ? 'border-white/10' : 'border-slate-100'}`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className={`font-semibold ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
Aktuelle Alerts
|
||||
</h3>
|
||||
{unreadCount > 0 && (
|
||||
<span className="px-2 py-0.5 text-xs font-medium rounded-full bg-amber-500/20 text-amber-500">
|
||||
{unreadCount} neu
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="max-h-80 overflow-y-auto">
|
||||
{alerts.slice(0, 5).map(alert => (
|
||||
<button
|
||||
key={alert.id}
|
||||
onClick={() => {
|
||||
markAsRead(alert.id)
|
||||
setShowAlertsDropdown(false)
|
||||
router.push('/alerts')
|
||||
}}
|
||||
className={`w-full text-left p-4 transition-all ${
|
||||
isDark
|
||||
? `hover:bg-white/5 ${!alert.isRead ? 'bg-amber-500/5 border-l-2 border-amber-500' : ''}`
|
||||
: `hover:bg-slate-50 ${!alert.isRead ? 'bg-amber-50 border-l-2 border-amber-500' : ''}`
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<span className={`px-1.5 py-0.5 rounded text-xs font-medium ${getImportanceColor(alert.importance, isDark)}`}>
|
||||
{alert.importance.slice(0, 4)}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className={`text-sm font-medium truncate ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
{alert.title}
|
||||
</p>
|
||||
<p className={`text-xs ${isDark ? 'text-white/40' : 'text-slate-400'}`}>
|
||||
{getRelativeTime(alert.timestamp)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
{alerts.length === 0 && (
|
||||
<div className={`p-8 text-center ${isDark ? 'text-white/40' : 'text-slate-400'}`}>
|
||||
<span className="text-2xl block mb-2">📭</span>
|
||||
<p className="text-sm">Keine Alerts</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className={`p-3 border-t ${isDark ? 'border-white/10' : 'border-slate-100'}`}>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowAlertsDropdown(false)
|
||||
router.push('/alerts')
|
||||
}}
|
||||
className={`w-full py-2 text-sm font-medium rounded-lg transition-all ${
|
||||
isDark
|
||||
? 'text-amber-400 hover:bg-amber-500/10'
|
||||
: 'text-amber-600 hover:bg-amber-50'
|
||||
}`}
|
||||
>
|
||||
Alle Alerts anzeigen →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Willkommensnachricht fuer ersten Besuch */}
|
||||
{isFirstVisit && documents.length === 0 && (
|
||||
<div className={`mb-8 p-6 rounded-3xl border backdrop-blur-xl ${
|
||||
isDark
|
||||
? 'bg-gradient-to-r from-purple-500/20 to-pink-500/20 border-purple-500/30'
|
||||
: 'bg-gradient-to-r from-purple-50 to-pink-50 border-purple-200'
|
||||
}`}>
|
||||
<div className="flex items-start gap-6">
|
||||
<div className={`w-16 h-16 rounded-2xl flex items-center justify-center text-3xl ${
|
||||
isDark ? 'bg-white/20' : 'bg-white shadow-lg'
|
||||
}`}>
|
||||
🎉
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h2 className={`text-xl font-bold mb-2 ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
Willkommen bei BreakPilot Studio!
|
||||
</h2>
|
||||
<p className={`mb-4 ${isDark ? 'text-white/70' : 'text-slate-600'}`}>
|
||||
Grossartig, dass Sie hier sind! Laden Sie jetzt Ihr erstes Dokument hoch,
|
||||
um die KI-gestuetzte Korrektur zu erleben. Sie koennen Dateien von Ihrem
|
||||
Computer oder Mobiltelefon hochladen.
|
||||
</p>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={() => setShowUploadModal(true)}
|
||||
className="px-6 py-3 bg-gradient-to-r from-purple-500 to-pink-500 text-white rounded-xl font-medium hover:shadow-lg hover:shadow-purple-500/30 transition-all hover:scale-105"
|
||||
>
|
||||
Dokument hochladen
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowQRModal(true)}
|
||||
className={`px-6 py-3 rounded-xl font-medium transition-all ${
|
||||
isDark
|
||||
? 'bg-white/20 text-white hover:bg-white/30'
|
||||
: 'bg-white text-slate-700 hover:bg-slate-50 shadow'
|
||||
}`}
|
||||
>
|
||||
Mit Mobiltelefon hochladen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setIsFirstVisit(false)}
|
||||
className={`p-2 rounded-lg ${isDark ? 'hover:bg-white/10' : 'hover:bg-white'}`}
|
||||
>
|
||||
<svg className={`w-5 h-5 ${isDark ? 'text-white/60' : 'text-slate-400'}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stats Kacheln */}
|
||||
<div className="grid grid-cols-4 gap-6 mb-8">
|
||||
{stats.map((stat, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`backdrop-blur-xl border rounded-3xl p-6 transition-all hover:scale-105 hover:shadow-xl cursor-pointer ${
|
||||
isDark
|
||||
? 'bg-white/10 border-white/20 hover:bg-white/15'
|
||||
: 'bg-white/70 border-black/10 hover:bg-white/90 shadow-lg'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className={`w-12 h-12 bg-gradient-to-br ${stat.color} rounded-2xl flex items-center justify-center text-2xl shadow-lg`}>
|
||||
{stat.icon}
|
||||
</div>
|
||||
<svg className={`w-5 h-5 ${isDark ? 'text-white/40' : 'text-slate-400'}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className={`text-sm mb-1 ${isDark ? 'text-white/60' : 'text-slate-600'}`}>{t(stat.labelKey)}</p>
|
||||
<p className={`text-3xl font-bold ${isDark ? 'text-white' : 'text-slate-900'}`}>{stat.value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Tab-Content */}
|
||||
{selectedTab === 'dokumente' ? (
|
||||
/* Dokumente-Tab */
|
||||
<div className="space-y-6">
|
||||
{/* Upload-Optionen */}
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<button
|
||||
onClick={() => setShowUploadModal(true)}
|
||||
className={`p-6 rounded-3xl border backdrop-blur-xl text-left transition-all hover:scale-105 ${
|
||||
isDark
|
||||
? 'bg-white/10 border-white/20 hover:bg-white/15'
|
||||
: 'bg-white/70 border-black/10 hover:bg-white/90 shadow-lg'
|
||||
}`}
|
||||
>
|
||||
<div className={`w-14 h-14 rounded-2xl flex items-center justify-center text-3xl mb-4 ${
|
||||
isDark ? 'bg-blue-500/20' : 'bg-blue-100'
|
||||
}`}>
|
||||
📤
|
||||
</div>
|
||||
<h3 className={`text-lg font-semibold mb-2 ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
Direkt hochladen
|
||||
</h3>
|
||||
<p className={`text-sm ${isDark ? 'text-white/60' : 'text-slate-500'}`}>
|
||||
Ziehen Sie Dateien hierher oder klicken Sie zum Auswaehlen
|
||||
</p>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setShowQRModal(true)}
|
||||
className={`p-6 rounded-3xl border backdrop-blur-xl text-left transition-all hover:scale-105 ${
|
||||
isDark
|
||||
? 'bg-white/10 border-white/20 hover:bg-white/15'
|
||||
: 'bg-white/70 border-black/10 hover:bg-white/90 shadow-lg'
|
||||
}`}
|
||||
>
|
||||
<div className={`w-14 h-14 rounded-2xl flex items-center justify-center text-3xl mb-4 ${
|
||||
isDark ? 'bg-purple-500/20' : 'bg-purple-100'
|
||||
}`}>
|
||||
📱
|
||||
</div>
|
||||
<h3 className={`text-lg font-semibold mb-2 ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
Mit Mobiltelefon hochladen
|
||||
</h3>
|
||||
<p className={`text-sm ${isDark ? 'text-white/60' : 'text-slate-500'}`}>
|
||||
QR-Code scannen (nur im lokalen Netzwerk)
|
||||
</p>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Document Space */}
|
||||
<div className={`rounded-3xl border backdrop-blur-xl p-6 ${
|
||||
isDark
|
||||
? 'bg-white/10 border-white/20'
|
||||
: 'bg-white/70 border-black/10 shadow-lg'
|
||||
}`}>
|
||||
<h2 className={`text-xl font-semibold mb-6 ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
Meine Dokumente
|
||||
</h2>
|
||||
<DocumentSpace
|
||||
documents={documents}
|
||||
onDelete={handleDeleteDocument}
|
||||
onRename={handleRenameDocument}
|
||||
onOpen={(doc) => doc.url && window.open(doc.url, '_blank')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
/* Dashboard-Tab (Standard) */
|
||||
<div className="grid grid-cols-3 gap-6">
|
||||
{/* Aktuelle Klausuren Kachel */}
|
||||
<div className={`col-span-2 backdrop-blur-xl border rounded-3xl p-6 ${
|
||||
isDark
|
||||
? 'bg-white/10 border-white/20'
|
||||
: 'bg-white/70 border-black/10 shadow-lg'
|
||||
}`}>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className={`text-xl font-semibold ${isDark ? 'text-white' : 'text-slate-900'}`}>{t('recent_klausuren')}</h2>
|
||||
<button className={`text-sm transition-colors ${
|
||||
isDark ? 'text-white/60 hover:text-white' : 'text-slate-500 hover:text-slate-900'
|
||||
}`}>
|
||||
{t('show_all')} →
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{recentKlausuren.map((klausur) => (
|
||||
<div
|
||||
key={klausur.id}
|
||||
className={`flex items-center gap-4 p-4 rounded-2xl transition-all cursor-pointer group ${
|
||||
isDark
|
||||
? 'bg-white/5 hover:bg-white/10'
|
||||
: 'bg-slate-50 hover:bg-slate-100'
|
||||
}`}
|
||||
>
|
||||
<div className="w-14 h-14 bg-gradient-to-br from-blue-400 to-purple-500 rounded-2xl flex items-center justify-center">
|
||||
<span className="text-2xl">📝</span>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className={`font-medium ${isDark ? 'text-white' : 'text-slate-900'}`}>{klausur.title}</h3>
|
||||
<p className={`text-sm ${isDark ? 'text-white/50' : 'text-slate-500'}`}>{klausur.students} {t('students')}</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<span className={`px-3 py-1 rounded-full text-xs font-medium ${
|
||||
klausur.statusKey === 'status_completed'
|
||||
? isDark ? 'bg-green-500/20 text-green-300' : 'bg-green-100 text-green-700'
|
||||
: isDark ? 'bg-yellow-500/20 text-yellow-300' : 'bg-yellow-100 text-yellow-700'
|
||||
}`}>
|
||||
{t(klausur.statusKey)}
|
||||
</span>
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<div className={`w-24 h-1.5 rounded-full overflow-hidden ${isDark ? 'bg-white/10' : 'bg-slate-200'}`}>
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-cyan-400 to-blue-500 rounded-full"
|
||||
style={{ width: `${(klausur.completed / klausur.students) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className={`text-xs ${isDark ? 'text-white/50' : 'text-slate-500'}`}>{klausur.completed}/{klausur.students}</span>
|
||||
</div>
|
||||
</div>
|
||||
<svg className={`w-5 h-5 transition-colors ${
|
||||
isDark ? 'text-white/30 group-hover:text-white/60' : 'text-slate-300 group-hover:text-slate-600'
|
||||
}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Schnellaktionen Kachel */}
|
||||
<div className={`backdrop-blur-xl border rounded-3xl p-6 ${
|
||||
isDark
|
||||
? 'bg-white/10 border-white/20'
|
||||
: 'bg-white/70 border-black/10 shadow-lg'
|
||||
}`}>
|
||||
<h2 className={`text-xl font-semibold mb-6 ${isDark ? 'text-white' : 'text-slate-900'}`}>{t('quick_actions')}</h2>
|
||||
|
||||
<div className="space-y-3">
|
||||
<button className="w-full flex items-center gap-4 p-4 bg-gradient-to-r from-blue-500 to-purple-500 rounded-2xl text-white hover:shadow-xl hover:shadow-purple-500/30 transition-all hover:scale-105">
|
||||
<span className="text-2xl">➕</span>
|
||||
<span className="font-medium">{t('create_klausur')}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setShowUploadModal(true)}
|
||||
className={`w-full flex items-center gap-4 p-4 rounded-2xl transition-all ${
|
||||
isDark
|
||||
? 'bg-white/10 text-white hover:bg-white/20'
|
||||
: 'bg-slate-100 text-slate-800 hover:bg-slate-200'
|
||||
}`}>
|
||||
<span className="text-2xl">📤</span>
|
||||
<span className="font-medium">{t('upload_work')}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setSelectedTab('dokumente')}
|
||||
className={`w-full flex items-center justify-between p-4 rounded-2xl transition-all ${
|
||||
isDark
|
||||
? 'bg-white/10 text-white hover:bg-white/20'
|
||||
: 'bg-slate-100 text-slate-800 hover:bg-slate-200'
|
||||
}`}>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-2xl">📁</span>
|
||||
<span className="font-medium">{t('nav_dokumente')}</span>
|
||||
</div>
|
||||
{documents.length > 0 && (
|
||||
<span className={`px-2 py-1 text-xs font-medium rounded-full ${
|
||||
isDark ? 'bg-white/20' : 'bg-slate-200'
|
||||
}`}>
|
||||
{documents.length}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => router.push('/worksheet-editor')}
|
||||
className={`w-full flex items-center gap-4 p-4 rounded-2xl transition-all ${
|
||||
isDark
|
||||
? 'bg-gradient-to-r from-purple-500/20 to-pink-500/20 text-white hover:from-purple-500/30 hover:to-pink-500/30 border border-purple-500/30'
|
||||
: 'bg-gradient-to-r from-purple-50 to-pink-50 text-slate-800 hover:from-purple-100 hover:to-pink-100 border border-purple-200'
|
||||
}`}>
|
||||
<span className="text-2xl">🎨</span>
|
||||
<span className="font-medium">{t('nav_worksheet_editor')}</span>
|
||||
</button>
|
||||
|
||||
<button className={`w-full flex items-center gap-4 p-4 rounded-2xl transition-all ${
|
||||
isDark
|
||||
? 'bg-white/10 text-white hover:bg-white/20'
|
||||
: 'bg-slate-100 text-slate-800 hover:bg-slate-200'
|
||||
}`}>
|
||||
<span className="text-2xl">✨</span>
|
||||
<span className="font-medium">{t('magic_help')}</span>
|
||||
</button>
|
||||
|
||||
<button className={`w-full flex items-center gap-4 p-4 rounded-2xl transition-all ${
|
||||
isDark
|
||||
? 'bg-white/10 text-white hover:bg-white/20'
|
||||
: 'bg-slate-100 text-slate-800 hover:bg-slate-200'
|
||||
}`}>
|
||||
<span className="text-2xl">📊</span>
|
||||
<span className="font-medium">{t('fairness_check')}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* AI Insight mini */}
|
||||
<div className={`mt-6 p-4 rounded-2xl border ${
|
||||
isDark
|
||||
? 'bg-gradient-to-r from-pink-500/20 to-purple-500/20 border-pink-500/30'
|
||||
: 'bg-gradient-to-r from-pink-50 to-purple-50 border-pink-200'
|
||||
}`}>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<span className="text-lg">🤖</span>
|
||||
<span className={`text-sm font-medium ${isDark ? 'text-white/80' : 'text-slate-700'}`}>{t('ai_tip')}</span>
|
||||
</div>
|
||||
<p className={`text-sm ${isDark ? 'text-white/60' : 'text-slate-600'}`}>
|
||||
{t('ai_tip_text')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Alerts Kachel */}
|
||||
<div className={`mt-6 backdrop-blur-xl border rounded-2xl p-4 ${
|
||||
isDark
|
||||
? 'bg-gradient-to-r from-amber-500/10 to-orange-500/10 border-amber-500/30'
|
||||
: 'bg-gradient-to-r from-amber-50 to-orange-50 border-amber-200'
|
||||
}`}>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className={`font-semibold flex items-center gap-2 ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
<span>🔔</span> Aktuelle Alerts
|
||||
</h3>
|
||||
{unreadCount > 0 && (
|
||||
<span className="text-xs px-2 py-1 rounded-full bg-amber-500/20 text-amber-500 font-medium">
|
||||
{unreadCount} neu
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Headlines Liste */}
|
||||
<div className="space-y-2">
|
||||
{alerts.slice(0, 3).map(alert => (
|
||||
<button
|
||||
key={alert.id}
|
||||
onClick={() => {
|
||||
markAsRead(alert.id)
|
||||
router.push('/alerts')
|
||||
}}
|
||||
className={`w-full text-left p-2 rounded-lg transition-all text-sm ${
|
||||
isDark
|
||||
? `hover:bg-white/10 ${!alert.isRead ? 'bg-white/5' : ''}`
|
||||
: `hover:bg-white ${!alert.isRead ? 'bg-white/50' : ''}`
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
{!alert.isRead && (
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-amber-500 mt-1.5 flex-shrink-0" />
|
||||
)}
|
||||
<p className={`truncate ${isDark ? 'text-white/80' : 'text-slate-700'}`}>
|
||||
{alert.title}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
{alerts.length === 0 && (
|
||||
<p className={`text-sm ${isDark ? 'text-white/40' : 'text-slate-400'}`}>
|
||||
Keine Alerts vorhanden
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Mehr anzeigen */}
|
||||
<button
|
||||
onClick={() => router.push('/alerts')}
|
||||
className={`w-full mt-3 text-sm font-medium ${
|
||||
isDark ? 'text-amber-400 hover:text-amber-300' : 'text-amber-600 hover:text-amber-700'
|
||||
}`}
|
||||
>
|
||||
Alle Alerts anzeigen →
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Nachrichten Kachel */}
|
||||
<div className={`mt-6 backdrop-blur-xl border rounded-2xl p-4 ${
|
||||
isDark
|
||||
? 'bg-gradient-to-r from-green-500/10 to-emerald-500/10 border-green-500/30'
|
||||
: 'bg-gradient-to-r from-green-50 to-emerald-50 border-green-200'
|
||||
}`}>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className={`font-semibold flex items-center gap-2 ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
<span>💬</span> {t('nav_messages')}
|
||||
</h3>
|
||||
{messagesUnreadCount > 0 && (
|
||||
<span className="text-xs px-2 py-1 rounded-full bg-green-500/20 text-green-500 font-medium">
|
||||
{messagesUnreadCount} neu
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Conversations Liste */}
|
||||
<div className="space-y-2">
|
||||
{conversations.slice(0, 3).map(conv => {
|
||||
const contact = contacts.find(c => conv.participant_ids.includes(c.id))
|
||||
return (
|
||||
<button
|
||||
key={conv.id}
|
||||
onClick={() => {
|
||||
if (conv.unread_count > 0) {
|
||||
markMessageAsRead(conv.id)
|
||||
}
|
||||
router.push('/messages')
|
||||
}}
|
||||
className={`w-full text-left p-2 rounded-lg transition-all text-sm ${
|
||||
isDark
|
||||
? `hover:bg-white/10 ${conv.unread_count > 0 ? 'bg-white/5' : ''}`
|
||||
: `hover:bg-white ${conv.unread_count > 0 ? 'bg-white/50' : ''}`
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Avatar */}
|
||||
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-xs font-medium flex-shrink-0 ${
|
||||
contact?.online
|
||||
? isDark
|
||||
? 'bg-green-500/30 text-green-300'
|
||||
: 'bg-green-200 text-green-700'
|
||||
: isDark
|
||||
? 'bg-slate-600 text-slate-300'
|
||||
: 'bg-slate-200 text-slate-600'
|
||||
}`}>
|
||||
{conv.title ? getContactInitials(conv.title) : '?'}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
{conv.unread_count > 0 && (
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-green-500 flex-shrink-0" />
|
||||
)}
|
||||
<span className={`font-medium truncate ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
{conv.title || 'Unbenannt'}
|
||||
</span>
|
||||
</div>
|
||||
{conv.last_message && (
|
||||
<p className={`text-xs truncate ${isDark ? 'text-white/50' : 'text-slate-500'}`}>
|
||||
{conv.last_message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{conv.last_message_time && (
|
||||
<span className={`text-xs flex-shrink-0 ${isDark ? 'text-white/40' : 'text-slate-400'}`}>
|
||||
{formatMessageTime(conv.last_message_time)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
{conversations.length === 0 && (
|
||||
<p className={`text-sm ${isDark ? 'text-white/40' : 'text-slate-400'}`}>
|
||||
Keine Nachrichten vorhanden
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Mehr anzeigen */}
|
||||
<button
|
||||
onClick={() => router.push('/messages')}
|
||||
className={`w-full mt-3 text-sm font-medium ${
|
||||
isDark ? 'text-green-400 hover:text-green-300' : 'text-green-600 hover:text-green-700'
|
||||
}`}
|
||||
>
|
||||
Alle Nachrichten anzeigen →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{/* Upload Modal */}
|
||||
{showUploadModal && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" onClick={() => setShowUploadModal(false)} />
|
||||
<div className={`relative w-full max-w-2xl rounded-3xl border p-6 max-h-[90vh] overflow-y-auto ${
|
||||
isDark
|
||||
? 'bg-slate-900 border-white/20'
|
||||
: 'bg-white border-slate-200 shadow-2xl'
|
||||
}`}>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className={`text-xl font-semibold ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
Dokumente hochladen
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => setShowUploadModal(false)}
|
||||
className={`p-2 rounded-lg ${isDark ? 'hover:bg-white/10' : 'hover:bg-slate-100'}`}
|
||||
>
|
||||
<svg className={`w-5 h-5 ${isDark ? 'text-white/60' : 'text-slate-400'}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<DocumentUpload
|
||||
onUploadComplete={(docs) => {
|
||||
handleUploadComplete(docs)
|
||||
}}
|
||||
/>
|
||||
{/* Aktions-Buttons */}
|
||||
<div className={`mt-6 pt-6 border-t flex justify-between items-center ${
|
||||
isDark ? 'border-white/10' : 'border-slate-200'
|
||||
}`}>
|
||||
<p className={`text-sm ${isDark ? 'text-white/50' : 'text-slate-500'}`}>
|
||||
{documents.length > 0 ? `${documents.length} Dokument${documents.length !== 1 ? 'e' : ''} gespeichert` : 'Noch keine Dokumente'}
|
||||
</p>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={() => setShowUploadModal(false)}
|
||||
className={`px-4 py-2 rounded-xl text-sm font-medium ${
|
||||
isDark ? 'text-white/60 hover:text-white' : 'text-slate-500 hover:text-slate-700'
|
||||
}`}
|
||||
>
|
||||
Schliessen
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowUploadModal(false)
|
||||
setSelectedTab('dokumente')
|
||||
}}
|
||||
className="px-4 py-2 bg-gradient-to-r from-purple-500 to-pink-500 text-white rounded-xl text-sm font-medium hover:shadow-lg transition-all"
|
||||
>
|
||||
Zu meinen Dokumenten
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* QR Code Modal */}
|
||||
{showQRModal && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" onClick={() => setShowQRModal(false)} />
|
||||
<div className={`relative w-full max-w-md rounded-3xl ${
|
||||
isDark ? 'bg-slate-900' : 'bg-white'
|
||||
}`}>
|
||||
<QRCodeUpload
|
||||
sessionId={sessionId}
|
||||
onClose={() => setShowQRModal(false)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Diegetic Chat Overlay - Cinematic message notifications */}
|
||||
<ChatOverlay
|
||||
typewriterEnabled={true}
|
||||
typewriterSpeed={25}
|
||||
autoDismissMs={0}
|
||||
maxQueue={5}
|
||||
/>
|
||||
|
||||
{/* Footer */}
|
||||
<Footer />
|
||||
|
||||
{/* Blob Animation Styles */}
|
||||
<style jsx>{`
|
||||
@keyframes blob {
|
||||
0% { transform: translate(0px, 0px) scale(1); }
|
||||
33% { transform: translate(30px, -50px) scale(1.1); }
|
||||
66% { transform: translate(-20px, 20px) scale(0.9); }
|
||||
100% { transform: translate(0px, 0px) scale(1); }
|
||||
}
|
||||
.animate-blob {
|
||||
animation: blob 7s infinite;
|
||||
}
|
||||
.animation-delay-2000 {
|
||||
animation-delay: 2s;
|
||||
}
|
||||
.animation-delay-4000 {
|
||||
animation-delay: 4s;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+47
-742
@@ -1,23 +1,18 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { BPIcon } from '@/components/Logo'
|
||||
import { useLanguage } from '@/lib/LanguageContext'
|
||||
import { useTheme } from '@/lib/ThemeContext'
|
||||
import { useAlerts, getImportanceColor, getRelativeTime } from '@/lib/AlertsContext'
|
||||
import { useMessages, formatMessageTime, getContactInitials } from '@/lib/MessagesContext'
|
||||
import { useActivity, formatDurationCompact } from '@/lib/ActivityContext'
|
||||
import { LanguageDropdown } from '@/components/LanguageDropdown'
|
||||
import { ThemeToggle } from '@/components/ThemeToggle'
|
||||
import { Footer } from '@/components/Footer'
|
||||
import { Sidebar } from '@/components/Sidebar'
|
||||
import { OnboardingWizard, OnboardingData } from '@/components/OnboardingWizard'
|
||||
import { DocumentUpload } from '@/components/DocumentUpload'
|
||||
import { QRCodeUpload } from '@/components/QRCodeUpload'
|
||||
import { DocumentSpace } from '@/components/DocumentSpace'
|
||||
import { ChatOverlay } from '@/components/ChatOverlay'
|
||||
import { AiPrompt } from '@/components/AiPrompt'
|
||||
import { Footer } from '@/components/Footer'
|
||||
import { BackgroundBlobs } from './_components/BackgroundBlobs'
|
||||
import { HeaderBar } from './_components/HeaderBar'
|
||||
import { DashboardContent } from './_components/DashboardContent'
|
||||
import { DocumentsTab } from './_components/DocumentsTab'
|
||||
import { UploadModal, QRModal } from './_components/UploadModals'
|
||||
|
||||
// LocalStorage Keys
|
||||
const ONBOARDING_KEY = 'bp_onboarding_complete'
|
||||
@@ -26,8 +21,6 @@ const DOCUMENTS_KEY = 'bp_documents'
|
||||
const FIRST_VISIT_KEY = 'bp_first_dashboard_visit'
|
||||
const SESSION_ID_KEY = 'bp_session_id'
|
||||
|
||||
// BreakPilot Studio v2 - Glassmorphism Design
|
||||
|
||||
interface StoredDocument {
|
||||
id: string
|
||||
name: string
|
||||
@@ -38,7 +31,6 @@ interface StoredDocument {
|
||||
}
|
||||
|
||||
export default function HomePage() {
|
||||
const router = useRouter()
|
||||
const [selectedTab, setSelectedTab] = useState('dashboard')
|
||||
const [showOnboarding, setShowOnboarding] = useState<boolean | null>(null)
|
||||
const [userData, setUserData] = useState<OnboardingData | null>(null)
|
||||
@@ -48,11 +40,7 @@ export default function HomePage() {
|
||||
const [isFirstVisit, setIsFirstVisit] = useState(false)
|
||||
const [sessionId, setSessionId] = useState<string>('')
|
||||
const [showAlertsDropdown, setShowAlertsDropdown] = useState(false)
|
||||
const { t } = useLanguage()
|
||||
const { isDark } = useTheme()
|
||||
const { alerts, unreadCount, markAsRead } = useAlerts()
|
||||
const { conversations, unreadCount: messagesUnreadCount, contacts, markAsRead: markMessageAsRead } = useMessages()
|
||||
const { stats: activityStats } = useActivity()
|
||||
|
||||
// Funktion zum Laden von Uploads aus der API
|
||||
const fetchUploadsFromAPI = useCallback(async (sid: string) => {
|
||||
@@ -62,23 +50,14 @@ export default function HomePage() {
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
if (data.uploads && data.uploads.length > 0) {
|
||||
// Konvertiere API-Uploads zu StoredDocument Format
|
||||
const apiDocs: StoredDocument[] = data.uploads.map((u: any) => ({
|
||||
id: u.id,
|
||||
name: u.name,
|
||||
type: u.type,
|
||||
size: u.size,
|
||||
uploadedAt: new Date(u.uploadedAt),
|
||||
url: u.dataUrl // Data URL direkt verwenden
|
||||
id: u.id, name: u.name, type: u.type, size: u.size,
|
||||
uploadedAt: new Date(u.uploadedAt), url: u.dataUrl
|
||||
}))
|
||||
// Merge mit existierenden Dokumenten (ohne Duplikate)
|
||||
setDocuments(prev => {
|
||||
const existingIds = new Set(prev.map(d => d.id))
|
||||
const newDocs = apiDocs.filter(d => !existingIds.has(d.id))
|
||||
if (newDocs.length > 0) {
|
||||
return [...prev, ...newDocs]
|
||||
}
|
||||
return prev
|
||||
return newDocs.length > 0 ? [...prev, ...newDocs] : prev
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -95,7 +74,6 @@ export default function HomePage() {
|
||||
const firstVisit = localStorage.getItem(FIRST_VISIT_KEY)
|
||||
let storedSessionId = localStorage.getItem(SESSION_ID_KEY)
|
||||
|
||||
// Session ID generieren falls nicht vorhanden
|
||||
if (!storedSessionId) {
|
||||
storedSessionId = `session-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
|
||||
localStorage.setItem(SESSION_ID_KEY, storedSessionId)
|
||||
@@ -105,33 +83,21 @@ export default function HomePage() {
|
||||
if (onboardingComplete === 'true' && storedUserData) {
|
||||
setUserData(JSON.parse(storedUserData))
|
||||
setShowOnboarding(false)
|
||||
|
||||
// Dokumente laden
|
||||
if (storedDocs) {
|
||||
setDocuments(JSON.parse(storedDocs))
|
||||
}
|
||||
|
||||
// Erster Dashboard-Besuch nach Onboarding?
|
||||
if (storedDocs) setDocuments(JSON.parse(storedDocs))
|
||||
if (!firstVisit) {
|
||||
setIsFirstVisit(true)
|
||||
localStorage.setItem(FIRST_VISIT_KEY, 'true')
|
||||
}
|
||||
|
||||
// Initialer Fetch von der API
|
||||
fetchUploadsFromAPI(storedSessionId)
|
||||
} else {
|
||||
setShowOnboarding(true)
|
||||
}
|
||||
}, [fetchUploadsFromAPI])
|
||||
|
||||
// Polling fuer neue Uploads von der API (alle 3 Sekunden)
|
||||
// Polling fuer neue Uploads
|
||||
useEffect(() => {
|
||||
if (!sessionId || showOnboarding) return
|
||||
|
||||
const interval = setInterval(() => {
|
||||
fetchUploadsFromAPI(sessionId)
|
||||
}, 3000)
|
||||
|
||||
const interval = setInterval(() => fetchUploadsFromAPI(sessionId), 3000)
|
||||
return () => clearInterval(interval)
|
||||
}, [sessionId, showOnboarding, fetchUploadsFromAPI])
|
||||
|
||||
@@ -142,24 +108,17 @@ export default function HomePage() {
|
||||
}
|
||||
}, [documents])
|
||||
|
||||
// Handler fuer neue Uploads
|
||||
const handleUploadComplete = (uploadedDocs: any[]) => {
|
||||
const newDocs: StoredDocument[] = uploadedDocs.map(d => ({
|
||||
id: d.id,
|
||||
name: d.name,
|
||||
type: d.type,
|
||||
size: d.size,
|
||||
uploadedAt: d.uploadedAt,
|
||||
url: d.url
|
||||
id: d.id, name: d.name, type: d.type, size: d.size,
|
||||
uploadedAt: d.uploadedAt, url: d.url
|
||||
}))
|
||||
setDocuments(prev => [...prev, ...newDocs])
|
||||
setIsFirstVisit(false)
|
||||
}
|
||||
|
||||
// Dokument loeschen (aus State und API)
|
||||
const handleDeleteDocument = async (id: string) => {
|
||||
setDocuments(prev => prev.filter(d => d.id !== id))
|
||||
// Auch aus API loeschen
|
||||
try {
|
||||
await fetch(`/api/uploads?id=${encodeURIComponent(id)}`, { method: 'DELETE' })
|
||||
} catch (error) {
|
||||
@@ -167,12 +126,10 @@ export default function HomePage() {
|
||||
}
|
||||
}
|
||||
|
||||
// Dokument umbenennen
|
||||
const handleRenameDocument = (id: string, newName: string) => {
|
||||
setDocuments(prev => prev.map(d => d.id === id ? { ...d, name: newName } : d))
|
||||
}
|
||||
|
||||
// Onboarding abschließen
|
||||
const handleOnboardingComplete = (data: OnboardingData) => {
|
||||
localStorage.setItem(ONBOARDING_KEY, 'true')
|
||||
localStorage.setItem(USER_DATA_KEY, JSON.stringify(data))
|
||||
@@ -180,7 +137,7 @@ export default function HomePage() {
|
||||
setShowOnboarding(false)
|
||||
}
|
||||
|
||||
// Zeige Ladebildschirm während der Prüfung
|
||||
// Loading screen
|
||||
if (showOnboarding === null) {
|
||||
return (
|
||||
<div className={`min-h-screen flex items-center justify-center ${
|
||||
@@ -190,200 +147,31 @@ export default function HomePage() {
|
||||
}`}>
|
||||
<div className="flex items-center gap-4">
|
||||
<BPIcon variant="cupertino" size={48} className="animate-pulse" />
|
||||
<span className={`text-xl font-medium ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
Laden...
|
||||
</span>
|
||||
<span className={`text-xl font-medium ${isDark ? 'text-white' : 'text-slate-900'}`}>Laden...</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Zeige Onboarding falls noch nicht abgeschlossen
|
||||
if (showOnboarding) {
|
||||
return <OnboardingWizard onComplete={handleOnboardingComplete} />
|
||||
}
|
||||
|
||||
// Ab hier: Dashboard (bestehender Code)
|
||||
|
||||
// Calculate time saved from activity tracking
|
||||
const timeSaved = formatDurationCompact(activityStats.weekSavedSeconds)
|
||||
const timeSavedDisplay = activityStats.weekSavedSeconds > 0
|
||||
? `${timeSaved.value}${timeSaved.unit}`
|
||||
: '0min'
|
||||
|
||||
const stats = [
|
||||
{ labelKey: 'stat_open_corrections', value: '12', icon: '📋', color: 'from-blue-400 to-blue-600' },
|
||||
{ labelKey: 'stat_completed_week', value: String(activityStats.activityCount), icon: '✅', color: 'from-green-400 to-green-600' },
|
||||
{ labelKey: 'stat_average', value: '2.3', icon: '📈', color: 'from-purple-400 to-purple-600' },
|
||||
{ labelKey: 'stat_time_saved', value: timeSavedDisplay, icon: '⏱', color: 'from-orange-400 to-orange-600' },
|
||||
]
|
||||
|
||||
const recentKlausuren = [
|
||||
{ id: 1, title: 'Deutsch LK - Textanalyse', students: 24, completed: 18, statusKey: 'status_in_progress' },
|
||||
{ id: 2, title: 'Deutsch GK - Erörterung', students: 28, completed: 28, statusKey: 'status_completed' },
|
||||
{ id: 3, title: 'Vorabitur - Gedichtanalyse', students: 22, completed: 10, statusKey: 'status_in_progress' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className={`min-h-screen relative overflow-hidden flex flex-col ${
|
||||
isDark
|
||||
? 'bg-gradient-to-br from-indigo-900 via-purple-900 to-pink-800'
|
||||
: 'bg-gradient-to-br from-slate-100 via-blue-50 to-indigo-100'
|
||||
}`}>
|
||||
{/* Animated Background Blobs */}
|
||||
<div className="absolute inset-0 overflow-hidden pointer-events-none">
|
||||
<div className={`absolute -top-40 -right-40 w-80 h-80 rounded-full mix-blend-multiply filter blur-3xl animate-blob ${
|
||||
isDark ? 'bg-purple-500 opacity-70' : 'bg-purple-300 opacity-50'
|
||||
}`} />
|
||||
<div className={`absolute -bottom-40 -left-40 w-80 h-80 rounded-full mix-blend-multiply filter blur-3xl animate-blob animation-delay-2000 ${
|
||||
isDark ? 'bg-blue-500 opacity-70' : 'bg-blue-300 opacity-50'
|
||||
}`} />
|
||||
<div className={`absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 w-80 h-80 rounded-full mix-blend-multiply filter blur-3xl animate-blob animation-delay-4000 ${
|
||||
isDark ? 'bg-pink-500 opacity-70' : 'bg-pink-300 opacity-50'
|
||||
}`} />
|
||||
</div>
|
||||
<BackgroundBlobs isDark={isDark} />
|
||||
|
||||
<div className="relative z-10 flex min-h-screen gap-6 p-4">
|
||||
{/* Sidebar */}
|
||||
<Sidebar selectedTab={selectedTab} onTabChange={setSelectedTab} />
|
||||
|
||||
{/* ============================================
|
||||
ARBEITSFLAECHE (Main Content)
|
||||
============================================ */}
|
||||
<main className="flex-1">
|
||||
{/* Kopfleiste (Header) */}
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<h1 className={`text-4xl font-bold mb-2 ${isDark ? 'text-white' : 'text-slate-900'}`}>{t('dashboard')}</h1>
|
||||
<p className={isDark ? 'text-white/60' : 'text-slate-600'}>{t('dashboard_subtitle')}</p>
|
||||
</div>
|
||||
<HeaderBar showAlertsDropdown={showAlertsDropdown} setShowAlertsDropdown={setShowAlertsDropdown} />
|
||||
|
||||
{/* Search, Language & Actions */}
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t('search_placeholder')}
|
||||
className={`backdrop-blur-xl border rounded-2xl px-5 py-3 pl-12 focus:outline-none focus:ring-2 w-64 transition-all ${
|
||||
isDark
|
||||
? 'bg-white/10 border-white/20 text-white placeholder-white/40 focus:ring-white/30'
|
||||
: 'bg-white/70 border-black/10 text-slate-900 placeholder-slate-400 focus:ring-indigo-300'
|
||||
}`}
|
||||
/>
|
||||
<svg className={`absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 ${isDark ? 'text-white/40' : 'text-slate-400'}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Language Dropdown */}
|
||||
<LanguageDropdown />
|
||||
|
||||
{/* Theme Toggle */}
|
||||
<ThemeToggle />
|
||||
|
||||
{/* Notifications Bell with Glow Effect */}
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setShowAlertsDropdown(!showAlertsDropdown)}
|
||||
className={`relative p-3 backdrop-blur-xl border rounded-2xl transition-all ${
|
||||
unreadCount > 0
|
||||
? 'animate-pulse bg-gradient-to-r from-amber-500/20 to-orange-500/20 border-amber-500/30 shadow-lg shadow-amber-500/30'
|
||||
: isDark
|
||||
? 'bg-white/10 border-white/20 hover:bg-white/20'
|
||||
: 'bg-black/5 border-black/10 hover:bg-black/10'
|
||||
} ${isDark ? 'text-white' : 'text-slate-700'}`}
|
||||
>
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" />
|
||||
</svg>
|
||||
{unreadCount > 0 && (
|
||||
<span className="absolute -top-1 -right-1 w-5 h-5 bg-red-500 rounded-full text-xs text-white flex items-center justify-center font-medium">
|
||||
{unreadCount > 9 ? '9+' : unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Alerts Dropdown */}
|
||||
{showAlertsDropdown && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-40" onClick={() => setShowAlertsDropdown(false)} />
|
||||
<div className={`absolute right-0 mt-2 w-80 rounded-2xl border shadow-xl z-50 ${
|
||||
isDark
|
||||
? 'bg-slate-900 border-white/20'
|
||||
: 'bg-white border-slate-200'
|
||||
}`}>
|
||||
<div className={`p-4 border-b ${isDark ? 'border-white/10' : 'border-slate-100'}`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className={`font-semibold ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
Aktuelle Alerts
|
||||
</h3>
|
||||
{unreadCount > 0 && (
|
||||
<span className="px-2 py-0.5 text-xs font-medium rounded-full bg-amber-500/20 text-amber-500">
|
||||
{unreadCount} neu
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="max-h-80 overflow-y-auto">
|
||||
{alerts.slice(0, 5).map(alert => (
|
||||
<button
|
||||
key={alert.id}
|
||||
onClick={() => {
|
||||
markAsRead(alert.id)
|
||||
setShowAlertsDropdown(false)
|
||||
router.push('/alerts')
|
||||
}}
|
||||
className={`w-full text-left p-4 transition-all ${
|
||||
isDark
|
||||
? `hover:bg-white/5 ${!alert.isRead ? 'bg-amber-500/5 border-l-2 border-amber-500' : ''}`
|
||||
: `hover:bg-slate-50 ${!alert.isRead ? 'bg-amber-50 border-l-2 border-amber-500' : ''}`
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<span className={`px-1.5 py-0.5 rounded text-xs font-medium ${getImportanceColor(alert.importance, isDark)}`}>
|
||||
{alert.importance.slice(0, 4)}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className={`text-sm font-medium truncate ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
{alert.title}
|
||||
</p>
|
||||
<p className={`text-xs ${isDark ? 'text-white/40' : 'text-slate-400'}`}>
|
||||
{getRelativeTime(alert.timestamp)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
{alerts.length === 0 && (
|
||||
<div className={`p-8 text-center ${isDark ? 'text-white/40' : 'text-slate-400'}`}>
|
||||
<span className="text-2xl block mb-2">📭</span>
|
||||
<p className="text-sm">Keine Alerts</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className={`p-3 border-t ${isDark ? 'border-white/10' : 'border-slate-100'}`}>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowAlertsDropdown(false)
|
||||
router.push('/alerts')
|
||||
}}
|
||||
className={`w-full py-2 text-sm font-medium rounded-lg transition-all ${
|
||||
isDark
|
||||
? 'text-amber-400 hover:bg-amber-500/10'
|
||||
: 'text-amber-600 hover:bg-amber-50'
|
||||
}`}
|
||||
>
|
||||
Alle Alerts anzeigen →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Willkommensnachricht fuer ersten Besuch */}
|
||||
{/* Welcome message for first visit */}
|
||||
{isFirstVisit && documents.length === 0 && (
|
||||
<div className={`mb-8 p-6 rounded-3xl border backdrop-blur-xl ${
|
||||
isDark
|
||||
@@ -393,9 +181,7 @@ export default function HomePage() {
|
||||
<div className="flex items-start gap-6">
|
||||
<div className={`w-16 h-16 rounded-2xl flex items-center justify-center text-3xl ${
|
||||
isDark ? 'bg-white/20' : 'bg-white shadow-lg'
|
||||
}`}>
|
||||
🎉
|
||||
</div>
|
||||
}`}>🎉</div>
|
||||
<div className="flex-1">
|
||||
<h2 className={`text-xl font-bold mb-2 ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
Willkommen bei BreakPilot Studio!
|
||||
@@ -406,28 +192,20 @@ export default function HomePage() {
|
||||
Computer oder Mobiltelefon hochladen.
|
||||
</p>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={() => setShowUploadModal(true)}
|
||||
className="px-6 py-3 bg-gradient-to-r from-purple-500 to-pink-500 text-white rounded-xl font-medium hover:shadow-lg hover:shadow-purple-500/30 transition-all hover:scale-105"
|
||||
>
|
||||
<button onClick={() => setShowUploadModal(true)}
|
||||
className="px-6 py-3 bg-gradient-to-r from-purple-500 to-pink-500 text-white rounded-xl font-medium hover:shadow-lg hover:shadow-purple-500/30 transition-all hover:scale-105">
|
||||
Dokument hochladen
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowQRModal(true)}
|
||||
<button onClick={() => setShowQRModal(true)}
|
||||
className={`px-6 py-3 rounded-xl font-medium transition-all ${
|
||||
isDark
|
||||
? 'bg-white/20 text-white hover:bg-white/30'
|
||||
: 'bg-white text-slate-700 hover:bg-slate-50 shadow'
|
||||
}`}
|
||||
>
|
||||
isDark ? 'bg-white/20 text-white hover:bg-white/30' : 'bg-white text-slate-700 hover:bg-slate-50 shadow'
|
||||
}`}>
|
||||
Mit Mobiltelefon hochladen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setIsFirstVisit(false)}
|
||||
className={`p-2 rounded-lg ${isDark ? 'hover:bg-white/10' : 'hover:bg-white'}`}
|
||||
>
|
||||
<button onClick={() => setIsFirstVisit(false)}
|
||||
className={`p-2 rounded-lg ${isDark ? 'hover:bg-white/10' : 'hover:bg-white'}`}>
|
||||
<svg className={`w-5 h-5 ${isDark ? 'text-white/60' : 'text-slate-400'}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
@@ -436,511 +214,38 @@ export default function HomePage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* KI-Assistent */}
|
||||
<AiPrompt />
|
||||
|
||||
{/* Stats Kacheln */}
|
||||
<div className="grid grid-cols-4 gap-6 mb-8">
|
||||
{stats.map((stat, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`backdrop-blur-xl border rounded-3xl p-6 transition-all hover:scale-105 hover:shadow-xl cursor-pointer ${
|
||||
isDark
|
||||
? 'bg-white/10 border-white/20 hover:bg-white/15'
|
||||
: 'bg-white/70 border-black/10 hover:bg-white/90 shadow-lg'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className={`w-12 h-12 bg-gradient-to-br ${stat.color} rounded-2xl flex items-center justify-center text-2xl shadow-lg`}>
|
||||
{stat.icon}
|
||||
</div>
|
||||
<svg className={`w-5 h-5 ${isDark ? 'text-white/40' : 'text-slate-400'}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className={`text-sm mb-1 ${isDark ? 'text-white/60' : 'text-slate-600'}`}>{t(stat.labelKey)}</p>
|
||||
<p className={`text-3xl font-bold ${isDark ? 'text-white' : 'text-slate-900'}`}>{stat.value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Tab-Content */}
|
||||
{selectedTab === 'dokumente' ? (
|
||||
/* Dokumente-Tab */
|
||||
<div className="space-y-6">
|
||||
{/* Upload-Optionen */}
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<button
|
||||
onClick={() => setShowUploadModal(true)}
|
||||
className={`p-6 rounded-3xl border backdrop-blur-xl text-left transition-all hover:scale-105 ${
|
||||
isDark
|
||||
? 'bg-white/10 border-white/20 hover:bg-white/15'
|
||||
: 'bg-white/70 border-black/10 hover:bg-white/90 shadow-lg'
|
||||
}`}
|
||||
>
|
||||
<div className={`w-14 h-14 rounded-2xl flex items-center justify-center text-3xl mb-4 ${
|
||||
isDark ? 'bg-blue-500/20' : 'bg-blue-100'
|
||||
}`}>
|
||||
📤
|
||||
</div>
|
||||
<h3 className={`text-lg font-semibold mb-2 ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
Direkt hochladen
|
||||
</h3>
|
||||
<p className={`text-sm ${isDark ? 'text-white/60' : 'text-slate-500'}`}>
|
||||
Ziehen Sie Dateien hierher oder klicken Sie zum Auswaehlen
|
||||
</p>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setShowQRModal(true)}
|
||||
className={`p-6 rounded-3xl border backdrop-blur-xl text-left transition-all hover:scale-105 ${
|
||||
isDark
|
||||
? 'bg-white/10 border-white/20 hover:bg-white/15'
|
||||
: 'bg-white/70 border-black/10 hover:bg-white/90 shadow-lg'
|
||||
}`}
|
||||
>
|
||||
<div className={`w-14 h-14 rounded-2xl flex items-center justify-center text-3xl mb-4 ${
|
||||
isDark ? 'bg-purple-500/20' : 'bg-purple-100'
|
||||
}`}>
|
||||
📱
|
||||
</div>
|
||||
<h3 className={`text-lg font-semibold mb-2 ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
Mit Mobiltelefon hochladen
|
||||
</h3>
|
||||
<p className={`text-sm ${isDark ? 'text-white/60' : 'text-slate-500'}`}>
|
||||
QR-Code scannen (nur im lokalen Netzwerk)
|
||||
</p>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Document Space */}
|
||||
<div className={`rounded-3xl border backdrop-blur-xl p-6 ${
|
||||
isDark
|
||||
? 'bg-white/10 border-white/20'
|
||||
: 'bg-white/70 border-black/10 shadow-lg'
|
||||
}`}>
|
||||
<h2 className={`text-xl font-semibold mb-6 ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
Meine Dokumente
|
||||
</h2>
|
||||
<DocumentSpace
|
||||
documents={documents}
|
||||
onDelete={handleDeleteDocument}
|
||||
onRename={handleRenameDocument}
|
||||
onOpen={(doc) => doc.url && window.open(doc.url, '_blank')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DocumentsTab
|
||||
documents={documents}
|
||||
onDelete={handleDeleteDocument}
|
||||
onRename={handleRenameDocument}
|
||||
setShowUploadModal={setShowUploadModal}
|
||||
setShowQRModal={setShowQRModal}
|
||||
/>
|
||||
) : (
|
||||
/* Dashboard-Tab (Standard) */
|
||||
<div className="grid grid-cols-3 gap-6">
|
||||
{/* Aktuelle Klausuren Kachel */}
|
||||
<div className={`col-span-2 backdrop-blur-xl border rounded-3xl p-6 ${
|
||||
isDark
|
||||
? 'bg-white/10 border-white/20'
|
||||
: 'bg-white/70 border-black/10 shadow-lg'
|
||||
}`}>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className={`text-xl font-semibold ${isDark ? 'text-white' : 'text-slate-900'}`}>{t('recent_klausuren')}</h2>
|
||||
<button className={`text-sm transition-colors ${
|
||||
isDark ? 'text-white/60 hover:text-white' : 'text-slate-500 hover:text-slate-900'
|
||||
}`}>
|
||||
{t('show_all')} →
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{recentKlausuren.map((klausur) => (
|
||||
<div
|
||||
key={klausur.id}
|
||||
className={`flex items-center gap-4 p-4 rounded-2xl transition-all cursor-pointer group ${
|
||||
isDark
|
||||
? 'bg-white/5 hover:bg-white/10'
|
||||
: 'bg-slate-50 hover:bg-slate-100'
|
||||
}`}
|
||||
>
|
||||
<div className="w-14 h-14 bg-gradient-to-br from-blue-400 to-purple-500 rounded-2xl flex items-center justify-center">
|
||||
<span className="text-2xl">📝</span>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className={`font-medium ${isDark ? 'text-white' : 'text-slate-900'}`}>{klausur.title}</h3>
|
||||
<p className={`text-sm ${isDark ? 'text-white/50' : 'text-slate-500'}`}>{klausur.students} {t('students')}</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<span className={`px-3 py-1 rounded-full text-xs font-medium ${
|
||||
klausur.statusKey === 'status_completed'
|
||||
? isDark ? 'bg-green-500/20 text-green-300' : 'bg-green-100 text-green-700'
|
||||
: isDark ? 'bg-yellow-500/20 text-yellow-300' : 'bg-yellow-100 text-yellow-700'
|
||||
}`}>
|
||||
{t(klausur.statusKey)}
|
||||
</span>
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<div className={`w-24 h-1.5 rounded-full overflow-hidden ${isDark ? 'bg-white/10' : 'bg-slate-200'}`}>
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-cyan-400 to-blue-500 rounded-full"
|
||||
style={{ width: `${(klausur.completed / klausur.students) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className={`text-xs ${isDark ? 'text-white/50' : 'text-slate-500'}`}>{klausur.completed}/{klausur.students}</span>
|
||||
</div>
|
||||
</div>
|
||||
<svg className={`w-5 h-5 transition-colors ${
|
||||
isDark ? 'text-white/30 group-hover:text-white/60' : 'text-slate-300 group-hover:text-slate-600'
|
||||
}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Schnellaktionen Kachel */}
|
||||
<div className={`backdrop-blur-xl border rounded-3xl p-6 ${
|
||||
isDark
|
||||
? 'bg-white/10 border-white/20'
|
||||
: 'bg-white/70 border-black/10 shadow-lg'
|
||||
}`}>
|
||||
<h2 className={`text-xl font-semibold mb-6 ${isDark ? 'text-white' : 'text-slate-900'}`}>{t('quick_actions')}</h2>
|
||||
|
||||
<div className="space-y-3">
|
||||
<button className="w-full flex items-center gap-4 p-4 bg-gradient-to-r from-blue-500 to-purple-500 rounded-2xl text-white hover:shadow-xl hover:shadow-purple-500/30 transition-all hover:scale-105">
|
||||
<span className="text-2xl">➕</span>
|
||||
<span className="font-medium">{t('create_klausur')}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setShowUploadModal(true)}
|
||||
className={`w-full flex items-center gap-4 p-4 rounded-2xl transition-all ${
|
||||
isDark
|
||||
? 'bg-white/10 text-white hover:bg-white/20'
|
||||
: 'bg-slate-100 text-slate-800 hover:bg-slate-200'
|
||||
}`}>
|
||||
<span className="text-2xl">📤</span>
|
||||
<span className="font-medium">{t('upload_work')}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setSelectedTab('dokumente')}
|
||||
className={`w-full flex items-center justify-between p-4 rounded-2xl transition-all ${
|
||||
isDark
|
||||
? 'bg-white/10 text-white hover:bg-white/20'
|
||||
: 'bg-slate-100 text-slate-800 hover:bg-slate-200'
|
||||
}`}>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-2xl">📁</span>
|
||||
<span className="font-medium">{t('nav_dokumente')}</span>
|
||||
</div>
|
||||
{documents.length > 0 && (
|
||||
<span className={`px-2 py-1 text-xs font-medium rounded-full ${
|
||||
isDark ? 'bg-white/20' : 'bg-slate-200'
|
||||
}`}>
|
||||
{documents.length}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => router.push('/worksheet-editor')}
|
||||
className={`w-full flex items-center gap-4 p-4 rounded-2xl transition-all ${
|
||||
isDark
|
||||
? 'bg-gradient-to-r from-purple-500/20 to-pink-500/20 text-white hover:from-purple-500/30 hover:to-pink-500/30 border border-purple-500/30'
|
||||
: 'bg-gradient-to-r from-purple-50 to-pink-50 text-slate-800 hover:from-purple-100 hover:to-pink-100 border border-purple-200'
|
||||
}`}>
|
||||
<span className="text-2xl">🎨</span>
|
||||
<span className="font-medium">{t('nav_worksheet_editor')}</span>
|
||||
</button>
|
||||
|
||||
<button className={`w-full flex items-center gap-4 p-4 rounded-2xl transition-all ${
|
||||
isDark
|
||||
? 'bg-white/10 text-white hover:bg-white/20'
|
||||
: 'bg-slate-100 text-slate-800 hover:bg-slate-200'
|
||||
}`}>
|
||||
<span className="text-2xl">✨</span>
|
||||
<span className="font-medium">{t('magic_help')}</span>
|
||||
</button>
|
||||
|
||||
<button className={`w-full flex items-center gap-4 p-4 rounded-2xl transition-all ${
|
||||
isDark
|
||||
? 'bg-white/10 text-white hover:bg-white/20'
|
||||
: 'bg-slate-100 text-slate-800 hover:bg-slate-200'
|
||||
}`}>
|
||||
<span className="text-2xl">📊</span>
|
||||
<span className="font-medium">{t('fairness_check')}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* AI Insight mini */}
|
||||
<div className={`mt-6 p-4 rounded-2xl border ${
|
||||
isDark
|
||||
? 'bg-gradient-to-r from-pink-500/20 to-purple-500/20 border-pink-500/30'
|
||||
: 'bg-gradient-to-r from-pink-50 to-purple-50 border-pink-200'
|
||||
}`}>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<span className="text-lg">🤖</span>
|
||||
<span className={`text-sm font-medium ${isDark ? 'text-white/80' : 'text-slate-700'}`}>{t('ai_tip')}</span>
|
||||
</div>
|
||||
<p className={`text-sm ${isDark ? 'text-white/60' : 'text-slate-600'}`}>
|
||||
{t('ai_tip_text')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Alerts Kachel */}
|
||||
<div className={`mt-6 backdrop-blur-xl border rounded-2xl p-4 ${
|
||||
isDark
|
||||
? 'bg-gradient-to-r from-amber-500/10 to-orange-500/10 border-amber-500/30'
|
||||
: 'bg-gradient-to-r from-amber-50 to-orange-50 border-amber-200'
|
||||
}`}>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className={`font-semibold flex items-center gap-2 ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
<span>🔔</span> Aktuelle Alerts
|
||||
</h3>
|
||||
{unreadCount > 0 && (
|
||||
<span className="text-xs px-2 py-1 rounded-full bg-amber-500/20 text-amber-500 font-medium">
|
||||
{unreadCount} neu
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Headlines Liste */}
|
||||
<div className="space-y-2">
|
||||
{alerts.slice(0, 3).map(alert => (
|
||||
<button
|
||||
key={alert.id}
|
||||
onClick={() => {
|
||||
markAsRead(alert.id)
|
||||
router.push('/alerts')
|
||||
}}
|
||||
className={`w-full text-left p-2 rounded-lg transition-all text-sm ${
|
||||
isDark
|
||||
? `hover:bg-white/10 ${!alert.isRead ? 'bg-white/5' : ''}`
|
||||
: `hover:bg-white ${!alert.isRead ? 'bg-white/50' : ''}`
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
{!alert.isRead && (
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-amber-500 mt-1.5 flex-shrink-0" />
|
||||
)}
|
||||
<p className={`truncate ${isDark ? 'text-white/80' : 'text-slate-700'}`}>
|
||||
{alert.title}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
{alerts.length === 0 && (
|
||||
<p className={`text-sm ${isDark ? 'text-white/40' : 'text-slate-400'}`}>
|
||||
Keine Alerts vorhanden
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Mehr anzeigen */}
|
||||
<button
|
||||
onClick={() => router.push('/alerts')}
|
||||
className={`w-full mt-3 text-sm font-medium ${
|
||||
isDark ? 'text-amber-400 hover:text-amber-300' : 'text-amber-600 hover:text-amber-700'
|
||||
}`}
|
||||
>
|
||||
Alle Alerts anzeigen →
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Nachrichten Kachel */}
|
||||
<div className={`mt-6 backdrop-blur-xl border rounded-2xl p-4 ${
|
||||
isDark
|
||||
? 'bg-gradient-to-r from-green-500/10 to-emerald-500/10 border-green-500/30'
|
||||
: 'bg-gradient-to-r from-green-50 to-emerald-50 border-green-200'
|
||||
}`}>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className={`font-semibold flex items-center gap-2 ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
<span>💬</span> {t('nav_messages')}
|
||||
</h3>
|
||||
{messagesUnreadCount > 0 && (
|
||||
<span className="text-xs px-2 py-1 rounded-full bg-green-500/20 text-green-500 font-medium">
|
||||
{messagesUnreadCount} neu
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Conversations Liste */}
|
||||
<div className="space-y-2">
|
||||
{conversations.slice(0, 3).map(conv => {
|
||||
const contact = contacts.find(c => conv.participant_ids.includes(c.id))
|
||||
return (
|
||||
<button
|
||||
key={conv.id}
|
||||
onClick={() => {
|
||||
if (conv.unread_count > 0) {
|
||||
markMessageAsRead(conv.id)
|
||||
}
|
||||
router.push('/messages')
|
||||
}}
|
||||
className={`w-full text-left p-2 rounded-lg transition-all text-sm ${
|
||||
isDark
|
||||
? `hover:bg-white/10 ${conv.unread_count > 0 ? 'bg-white/5' : ''}`
|
||||
: `hover:bg-white ${conv.unread_count > 0 ? 'bg-white/50' : ''}`
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Avatar */}
|
||||
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-xs font-medium flex-shrink-0 ${
|
||||
contact?.online
|
||||
? isDark
|
||||
? 'bg-green-500/30 text-green-300'
|
||||
: 'bg-green-200 text-green-700'
|
||||
: isDark
|
||||
? 'bg-slate-600 text-slate-300'
|
||||
: 'bg-slate-200 text-slate-600'
|
||||
}`}>
|
||||
{conv.title ? getContactInitials(conv.title) : '?'}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
{conv.unread_count > 0 && (
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-green-500 flex-shrink-0" />
|
||||
)}
|
||||
<span className={`font-medium truncate ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
{conv.title || 'Unbenannt'}
|
||||
</span>
|
||||
</div>
|
||||
{conv.last_message && (
|
||||
<p className={`text-xs truncate ${isDark ? 'text-white/50' : 'text-slate-500'}`}>
|
||||
{conv.last_message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{conv.last_message_time && (
|
||||
<span className={`text-xs flex-shrink-0 ${isDark ? 'text-white/40' : 'text-slate-400'}`}>
|
||||
{formatMessageTime(conv.last_message_time)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
{conversations.length === 0 && (
|
||||
<p className={`text-sm ${isDark ? 'text-white/40' : 'text-slate-400'}`}>
|
||||
Keine Nachrichten vorhanden
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Mehr anzeigen */}
|
||||
<button
|
||||
onClick={() => router.push('/messages')}
|
||||
className={`w-full mt-3 text-sm font-medium ${
|
||||
isDark ? 'text-green-400 hover:text-green-300' : 'text-green-600 hover:text-green-700'
|
||||
}`}
|
||||
>
|
||||
Alle Nachrichten anzeigen →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DashboardContent
|
||||
documents={documents}
|
||||
setShowUploadModal={setShowUploadModal}
|
||||
setSelectedTab={setSelectedTab}
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{/* Upload Modal */}
|
||||
{showUploadModal && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" onClick={() => setShowUploadModal(false)} />
|
||||
<div className={`relative w-full max-w-2xl rounded-3xl border p-6 max-h-[90vh] overflow-y-auto ${
|
||||
isDark
|
||||
? 'bg-slate-900 border-white/20'
|
||||
: 'bg-white border-slate-200 shadow-2xl'
|
||||
}`}>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className={`text-xl font-semibold ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
Dokumente hochladen
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => setShowUploadModal(false)}
|
||||
className={`p-2 rounded-lg ${isDark ? 'hover:bg-white/10' : 'hover:bg-slate-100'}`}
|
||||
>
|
||||
<svg className={`w-5 h-5 ${isDark ? 'text-white/60' : 'text-slate-400'}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<DocumentUpload
|
||||
onUploadComplete={(docs) => {
|
||||
handleUploadComplete(docs)
|
||||
}}
|
||||
/>
|
||||
{/* Aktions-Buttons */}
|
||||
<div className={`mt-6 pt-6 border-t flex justify-between items-center ${
|
||||
isDark ? 'border-white/10' : 'border-slate-200'
|
||||
}`}>
|
||||
<p className={`text-sm ${isDark ? 'text-white/50' : 'text-slate-500'}`}>
|
||||
{documents.length > 0 ? `${documents.length} Dokument${documents.length !== 1 ? 'e' : ''} gespeichert` : 'Noch keine Dokumente'}
|
||||
</p>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={() => setShowUploadModal(false)}
|
||||
className={`px-4 py-2 rounded-xl text-sm font-medium ${
|
||||
isDark ? 'text-white/60 hover:text-white' : 'text-slate-500 hover:text-slate-700'
|
||||
}`}
|
||||
>
|
||||
Schliessen
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowUploadModal(false)
|
||||
setSelectedTab('dokumente')
|
||||
}}
|
||||
className="px-4 py-2 bg-gradient-to-r from-purple-500 to-pink-500 text-white rounded-xl text-sm font-medium hover:shadow-lg transition-all"
|
||||
>
|
||||
Zu meinen Dokumenten
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<UploadModal
|
||||
documents={documents}
|
||||
onUploadComplete={handleUploadComplete}
|
||||
onClose={() => setShowUploadModal(false)}
|
||||
onGoToDocuments={() => { setShowUploadModal(false); setSelectedTab('dokumente') }}
|
||||
/>
|
||||
)}
|
||||
{showQRModal && <QRModal sessionId={sessionId} onClose={() => setShowQRModal(false)} />}
|
||||
|
||||
{/* QR Code Modal */}
|
||||
{showQRModal && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" onClick={() => setShowQRModal(false)} />
|
||||
<div className={`relative w-full max-w-md rounded-3xl ${
|
||||
isDark ? 'bg-slate-900' : 'bg-white'
|
||||
}`}>
|
||||
<QRCodeUpload
|
||||
sessionId={sessionId}
|
||||
onClose={() => setShowQRModal(false)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Diegetic Chat Overlay - Cinematic message notifications */}
|
||||
<ChatOverlay
|
||||
typewriterEnabled={true}
|
||||
typewriterSpeed={25}
|
||||
autoDismissMs={0}
|
||||
maxQueue={5}
|
||||
/>
|
||||
|
||||
{/* Footer */}
|
||||
<ChatOverlay typewriterEnabled={true} typewriterSpeed={25} autoDismissMs={0} maxQueue={5} />
|
||||
<Footer />
|
||||
|
||||
{/* Blob Animation Styles */}
|
||||
<style jsx>{`
|
||||
@keyframes blob {
|
||||
0% { transform: translate(0px, 0px) scale(1); }
|
||||
33% { transform: translate(30px, -50px) scale(1.1); }
|
||||
66% { transform: translate(-20px, 20px) scale(0.9); }
|
||||
100% { transform: translate(0px, 0px) scale(1); }
|
||||
}
|
||||
.animate-blob {
|
||||
animation: blob 7s infinite;
|
||||
}
|
||||
.animation-delay-2000 {
|
||||
animation-delay: 2s;
|
||||
}
|
||||
.animation-delay-4000 {
|
||||
animation-delay: 4s;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import type { VocabularyEntry, OcrPrompts, IpaMode, SyllableMode } from './types'
|
||||
import { getApiBase } from './constants'
|
||||
|
||||
/**
|
||||
* Process a single page and return vocabulary + optional scan quality info.
|
||||
*/
|
||||
export async function processSinglePage(
|
||||
sessionId: string,
|
||||
pageIndex: number,
|
||||
ipa: IpaMode,
|
||||
syllable: SyllableMode,
|
||||
ocrPrompts: OcrPrompts,
|
||||
ocrEnhance: boolean,
|
||||
ocrMaxCols: number,
|
||||
ocrMinConf: number,
|
||||
): Promise<{ success: boolean; vocabulary: VocabularyEntry[]; error?: string; scanQuality?: any }> {
|
||||
const API_BASE = getApiBase()
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
ipa_mode: ipa,
|
||||
syllable_mode: syllable,
|
||||
enhance: String(ocrEnhance),
|
||||
max_cols: String(ocrMaxCols),
|
||||
min_conf: String(ocrMinConf),
|
||||
})
|
||||
const res = await fetch(`${API_BASE}/api/v1/vocab/sessions/${sessionId}/process-single-page/${pageIndex}?${params}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ocr_prompts: ocrPrompts }),
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const errBody = await res.json().catch(() => ({}))
|
||||
const detail = errBody.detail || `HTTP ${res.status}`
|
||||
return { success: false, vocabulary: [], error: `Seite ${pageIndex + 1}: ${detail}` }
|
||||
}
|
||||
|
||||
const data = await res.json()
|
||||
if (!data.success) {
|
||||
return { success: false, vocabulary: [], error: data.error || `Seite ${pageIndex + 1}: Unbekannter Fehler` }
|
||||
}
|
||||
|
||||
return { success: true, vocabulary: data.vocabulary || [], scanQuality: data.scan_quality }
|
||||
} catch (e) {
|
||||
return { success: false, vocabulary: [], error: `Seite ${pageIndex + 1}: ${e instanceof Error ? e.message : 'Netzwerkfehler'}` }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reprocess pages with updated IPA/syllable settings.
|
||||
* Returns the new vocabulary array.
|
||||
*/
|
||||
export async function reprocessPagesFlow(
|
||||
sessionId: string,
|
||||
pagesToReprocess: number[],
|
||||
ipa: IpaMode,
|
||||
syllable: SyllableMode,
|
||||
ocrPrompts: OcrPrompts,
|
||||
ocrEnhance: boolean,
|
||||
ocrMaxCols: number,
|
||||
ocrMinConf: number,
|
||||
setExtractionStatus: (s: string) => void,
|
||||
): Promise<{ vocabulary: VocabularyEntry[]; qualityInfo: string }> {
|
||||
const API_BASE = getApiBase()
|
||||
const allVocab: VocabularyEntry[] = []
|
||||
let lastQuality: any = null
|
||||
|
||||
for (const pageIndex of pagesToReprocess) {
|
||||
setExtractionStatus(`Verarbeite Seite ${pageIndex + 1}...`)
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
ipa_mode: ipa,
|
||||
syllable_mode: syllable,
|
||||
enhance: String(ocrEnhance),
|
||||
max_cols: String(ocrMaxCols),
|
||||
min_conf: String(ocrMinConf),
|
||||
})
|
||||
const res = await fetch(`${API_BASE}/api/v1/vocab/sessions/${sessionId}/process-single-page/${pageIndex}?${params}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ocr_prompts: ocrPrompts }),
|
||||
})
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
if (data.vocabulary) allVocab.push(...data.vocabulary)
|
||||
if (data.scan_quality) lastQuality = data.scan_quality
|
||||
}
|
||||
} catch { /* ignore individual page failures */ }
|
||||
}
|
||||
|
||||
const qualityInfo = lastQuality
|
||||
? ` | Qualitaet: ${lastQuality.quality_pct}%${lastQuality.is_degraded ? ' (degradiert!)' : ''} | Blur: ${lastQuality.blur_score} | Kontrast: ${lastQuality.contrast_score}`
|
||||
: ''
|
||||
|
||||
return { vocabulary: allVocab, qualityInfo }
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import type {
|
||||
VocabularyEntry, Session, StoredDocument, OcrPrompts, IpaMode, SyllableMode,
|
||||
} from './types'
|
||||
import { getApiBase } from './constants'
|
||||
|
||||
/**
|
||||
* Start a new session: create on server, upload document, process first page or PDF.
|
||||
*/
|
||||
export async function startSessionFlow(params: {
|
||||
sessionName: string
|
||||
selectedDocumentId: string | null
|
||||
directFile: File | null
|
||||
selectedMobileFile: { dataUrl: string; type: string; name: string } | null
|
||||
storedDocuments: StoredDocument[]
|
||||
ocrPrompts: OcrPrompts
|
||||
startActivity: (type: string, meta: any) => void
|
||||
setSession: (s: Session | null | ((prev: Session | null) => Session | null)) => void
|
||||
setWorksheetTitle: (t: string) => void
|
||||
setExtractionStatus: (s: string) => void
|
||||
setPdfPageCount: (n: number) => void
|
||||
setSelectedPages: (p: number[]) => void
|
||||
setPagesThumbnails: (t: string[]) => void
|
||||
setIsLoadingThumbnails: (l: boolean) => void
|
||||
setVocabulary: (v: VocabularyEntry[]) => void
|
||||
setActiveTab: (t: 'upload' | 'pages' | 'vocabulary' | 'spreadsheet' | 'worksheet' | 'export' | 'settings') => void
|
||||
setError: (e: string | null) => void
|
||||
}): Promise<Session | null> {
|
||||
const {
|
||||
sessionName, selectedDocumentId, directFile, selectedMobileFile, storedDocuments,
|
||||
ocrPrompts, startActivity, setSession, setWorksheetTitle, setExtractionStatus,
|
||||
setPdfPageCount, setSelectedPages, setPagesThumbnails, setIsLoadingThumbnails,
|
||||
setVocabulary, setActiveTab, setError,
|
||||
} = params
|
||||
|
||||
setError(null)
|
||||
setExtractionStatus('Session wird erstellt...')
|
||||
|
||||
const API_BASE = getApiBase()
|
||||
|
||||
const sessionRes = await fetch(`${API_BASE}/api/v1/vocab/sessions`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: sessionName, ocr_prompts: ocrPrompts }),
|
||||
})
|
||||
|
||||
if (!sessionRes.ok) throw new Error('Session konnte nicht erstellt werden')
|
||||
|
||||
const sessionData = await sessionRes.json()
|
||||
setSession(sessionData)
|
||||
setWorksheetTitle(sessionName)
|
||||
startActivity('vocab_extraction', { description: sessionName })
|
||||
|
||||
let file: File
|
||||
let isPdf = false
|
||||
|
||||
if (directFile) {
|
||||
file = directFile
|
||||
isPdf = directFile.type === 'application/pdf'
|
||||
} else if (selectedMobileFile) {
|
||||
isPdf = selectedMobileFile.type === 'application/pdf'
|
||||
const base64Data = selectedMobileFile.dataUrl.split(',')[1]
|
||||
const byteCharacters = atob(base64Data)
|
||||
const byteNumbers = new Array(byteCharacters.length)
|
||||
for (let i = 0; i < byteCharacters.length; i++) byteNumbers[i] = byteCharacters.charCodeAt(i)
|
||||
const blob = new Blob([new Uint8Array(byteNumbers)], { type: selectedMobileFile.type })
|
||||
file = new File([blob], selectedMobileFile.name, { type: selectedMobileFile.type })
|
||||
} else {
|
||||
const selectedDoc = storedDocuments.find(d => d.id === selectedDocumentId)
|
||||
if (!selectedDoc || !selectedDoc.url) throw new Error('Das ausgewaehlte Dokument ist nicht verfuegbar.')
|
||||
isPdf = selectedDoc.type === 'application/pdf'
|
||||
const base64Data = selectedDoc.url.split(',')[1]
|
||||
const byteCharacters = atob(base64Data)
|
||||
const byteNumbers = new Array(byteCharacters.length)
|
||||
for (let i = 0; i < byteCharacters.length; i++) byteNumbers[i] = byteCharacters.charCodeAt(i)
|
||||
const blob = new Blob([new Uint8Array(byteNumbers)], { type: selectedDoc.type })
|
||||
file = new File([blob], selectedDoc.name, { type: selectedDoc.type })
|
||||
}
|
||||
|
||||
if (isPdf) {
|
||||
setExtractionStatus('PDF wird hochgeladen...')
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
const pdfInfoRes = await fetch(`${API_BASE}/api/v1/vocab/sessions/${sessionData.id}/upload-pdf-info`, {
|
||||
method: 'POST', body: formData,
|
||||
})
|
||||
if (!pdfInfoRes.ok) throw new Error('PDF konnte nicht verarbeitet werden')
|
||||
const pdfInfo = await pdfInfoRes.json()
|
||||
setPdfPageCount(pdfInfo.page_count)
|
||||
setSelectedPages(Array.from({ length: pdfInfo.page_count }, (_, i) => i))
|
||||
setActiveTab('pages')
|
||||
setExtractionStatus(`${pdfInfo.page_count} Seiten erkannt. Vorschau wird geladen...`)
|
||||
setIsLoadingThumbnails(true)
|
||||
const thumbnails: string[] = []
|
||||
for (let i = 0; i < pdfInfo.page_count; i++) {
|
||||
try {
|
||||
const thumbRes = await fetch(`${API_BASE}/api/v1/vocab/sessions/${sessionData.id}/pdf-thumbnail/${i}?hires=true`)
|
||||
if (thumbRes.ok) { const blob = await thumbRes.blob(); thumbnails.push(URL.createObjectURL(blob)) }
|
||||
} catch (e) { console.error(`Failed to load thumbnail for page ${i}`) }
|
||||
}
|
||||
setPagesThumbnails(thumbnails)
|
||||
setIsLoadingThumbnails(false)
|
||||
setExtractionStatus(`${pdfInfo.page_count} Seiten bereit. Waehlen Sie die zu verarbeitenden Seiten.`)
|
||||
} else {
|
||||
setExtractionStatus('KI analysiert das Bild... (kann 30-60 Sekunden dauern)')
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
const uploadRes = await fetch(`${API_BASE}/api/v1/vocab/sessions/${sessionData.id}/upload`, {
|
||||
method: 'POST', body: formData,
|
||||
})
|
||||
if (!uploadRes.ok) throw new Error('Bild konnte nicht verarbeitet werden')
|
||||
const uploadData = await uploadRes.json()
|
||||
setSession(prev => prev ? { ...prev, status: 'extracted', vocabulary_count: uploadData.vocabulary_count } : null)
|
||||
const vocabRes = await fetch(`${API_BASE}/api/v1/vocab/sessions/${sessionData.id}/vocabulary`)
|
||||
if (vocabRes.ok) {
|
||||
const vocabData = await vocabRes.json()
|
||||
setVocabulary(vocabData.vocabulary || [])
|
||||
setExtractionStatus(`${vocabData.vocabulary?.length || 0} Vokabeln gefunden!`)
|
||||
}
|
||||
await new Promise(r => setTimeout(r, 1000))
|
||||
setActiveTab('vocabulary')
|
||||
}
|
||||
|
||||
return sessionData
|
||||
}
|
||||
|
||||
/**
|
||||
* Resume an existing session from the API.
|
||||
*/
|
||||
export async function resumeSessionFlow(
|
||||
existingSession: Session,
|
||||
setSession: (s: Session) => void,
|
||||
setWorksheetTitle: (t: string) => void,
|
||||
setVocabulary: (v: VocabularyEntry[]) => void,
|
||||
setActiveTab: (t: 'upload' | 'pages' | 'vocabulary' | 'spreadsheet' | 'worksheet' | 'export' | 'settings') => void,
|
||||
setExtractionStatus: (s: string) => void,
|
||||
): Promise<void> {
|
||||
const API_BASE = getApiBase()
|
||||
const sessionRes = await fetch(`${API_BASE}/api/v1/vocab/sessions/${existingSession.id}`)
|
||||
if (!sessionRes.ok) throw new Error('Session nicht gefunden')
|
||||
const sessionData = await sessionRes.json()
|
||||
setSession(sessionData)
|
||||
setWorksheetTitle(sessionData.name)
|
||||
|
||||
if (sessionData.status === 'extracted' || sessionData.status === 'completed') {
|
||||
const vocabRes = await fetch(`${API_BASE}/api/v1/vocab/sessions/${existingSession.id}/vocabulary`)
|
||||
if (vocabRes.ok) { const vd = await vocabRes.json(); setVocabulary(vd.vocabulary || []) }
|
||||
setActiveTab('vocabulary')
|
||||
setExtractionStatus('')
|
||||
} else if (sessionData.status === 'pending') {
|
||||
setActiveTab('upload')
|
||||
setExtractionStatus('Diese Session hat noch keine Vokabeln. Bitte laden Sie ein Dokument hoch.')
|
||||
} else {
|
||||
setActiveTab('vocabulary')
|
||||
setExtractionStatus('')
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,6 @@
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { useTheme } from '@/lib/ThemeContext'
|
||||
import { useLanguage } from '@/lib/LanguageContext'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useActivity } from '@/lib/ActivityContext'
|
||||
import type { UploadedFile } from '@/components/QRCodeUpload'
|
||||
|
||||
@@ -16,11 +15,12 @@ import {
|
||||
getApiBase, DOCUMENTS_KEY, OCR_PROMPTS_KEY, SESSION_ID_KEY,
|
||||
defaultOcrPrompts, formatFileSize,
|
||||
} from './constants'
|
||||
import { startSessionFlow, resumeSessionFlow } from './useSessionHandlers'
|
||||
import { processSinglePage, reprocessPagesFlow } from './usePageProcessing'
|
||||
|
||||
export function useVocabWorksheet(): VocabWorksheetHook {
|
||||
const { isDark } = useTheme()
|
||||
const { t } = useLanguage()
|
||||
const router = useRouter()
|
||||
const { startActivity, completeActivity } = useActivity()
|
||||
const [mounted, setMounted] = useState(false)
|
||||
|
||||
@@ -34,39 +34,39 @@ export function useVocabWorksheet(): VocabWorksheetHook {
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [extractionStatus, setExtractionStatus] = useState<string>('')
|
||||
|
||||
// Existing sessions list
|
||||
// Existing sessions
|
||||
const [existingSessions, setExistingSessions] = useState<Session[]>([])
|
||||
const [isLoadingSessions, setIsLoadingSessions] = useState(true)
|
||||
|
||||
// Documents from storage
|
||||
// Documents
|
||||
const [storedDocuments, setStoredDocuments] = useState<StoredDocument[]>([])
|
||||
const [selectedDocumentId, setSelectedDocumentId] = useState<string | null>(null)
|
||||
|
||||
// Direct file upload
|
||||
// Direct file
|
||||
const [directFile, setDirectFile] = useState<File | null>(null)
|
||||
const [directFilePreview, setDirectFilePreview] = useState<string | null>(null)
|
||||
const [showFullPreview, setShowFullPreview] = useState(false)
|
||||
const directFileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
// PDF page selection state
|
||||
// PDF pages
|
||||
const [pdfPageCount, setPdfPageCount] = useState<number>(0)
|
||||
const [selectedPages, setSelectedPages] = useState<number[]>([])
|
||||
const [pagesThumbnails, setPagesThumbnails] = useState<string[]>([])
|
||||
const [isLoadingThumbnails, setIsLoadingThumbnails] = useState(false)
|
||||
const [excludedPages, setExcludedPages] = useState<number[]>([])
|
||||
|
||||
// Dynamic extra columns per source page
|
||||
// Extra columns
|
||||
const [pageExtraColumns, setPageExtraColumns] = useState<Record<number, ExtraColumn[]>>({})
|
||||
|
||||
// Upload state
|
||||
// Upload
|
||||
const [uploadedImage, setUploadedImage] = useState<string | null>(null)
|
||||
const [isExtracting, setIsExtracting] = useState(false)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
// Vocabulary state
|
||||
// Vocabulary
|
||||
const [vocabulary, setVocabulary] = useState<VocabularyEntry[]>([])
|
||||
|
||||
// Worksheet state
|
||||
// Worksheet
|
||||
const [selectedTypes, setSelectedTypes] = useState<WorksheetType[]>(['en_to_de'])
|
||||
const [worksheetTitle, setWorksheetTitle] = useState('')
|
||||
const [includeSolutions, setIncludeSolutions] = useState(true)
|
||||
@@ -75,27 +75,25 @@ export function useVocabWorksheet(): VocabWorksheetHook {
|
||||
const [ipaMode, setIpaMode] = useState<IpaMode>('none')
|
||||
const [syllableMode, setSyllableMode] = useState<SyllableMode>('none')
|
||||
|
||||
// Export state
|
||||
// Export
|
||||
const [worksheetId, setWorksheetId] = useState<string | null>(null)
|
||||
const [isGenerating, setIsGenerating] = useState(false)
|
||||
|
||||
// Processing results
|
||||
// Processing
|
||||
const [processingErrors, setProcessingErrors] = useState<string[]>([])
|
||||
const [successfulPages, setSuccessfulPages] = useState<number[]>([])
|
||||
const [failedPages, setFailedPages] = useState<number[]>([])
|
||||
const [currentlyProcessingPage, setCurrentlyProcessingPage] = useState<number | null>(null)
|
||||
const [processingQueue, setProcessingQueue] = useState<number[]>([])
|
||||
|
||||
// OCR Prompts/Settings
|
||||
// OCR Settings
|
||||
const [ocrPrompts, setOcrPrompts] = useState<OcrPrompts>(defaultOcrPrompts)
|
||||
const [showSettings, setShowSettings] = useState(false)
|
||||
const [ocrEnhance, setOcrEnhance] = useState(true)
|
||||
const [ocrMaxCols, setOcrMaxCols] = useState(3)
|
||||
const [ocrMinConf, setOcrMinConf] = useState(0)
|
||||
|
||||
// OCR Quality Steps (toggle individually for A/B testing)
|
||||
const [ocrEnhance, setOcrEnhance] = useState(true) // Step 3: CLAHE + denoise
|
||||
const [ocrMaxCols, setOcrMaxCols] = useState(3) // Step 2: max columns (0=unlimited)
|
||||
const [ocrMinConf, setOcrMinConf] = useState(0) // Step 1: 0=auto from quality score
|
||||
|
||||
// QR Code Upload
|
||||
// QR
|
||||
const [showQRModal, setShowQRModal] = useState(false)
|
||||
const [uploadSessionId, setUploadSessionId] = useState('')
|
||||
const [mobileUploadedFiles, setMobileUploadedFiles] = useState<UploadedFile[]>([])
|
||||
@@ -109,772 +107,260 @@ export function useVocabWorksheet(): VocabWorksheetHook {
|
||||
const [ocrCompareError, setOcrCompareError] = useState<string | null>(null)
|
||||
|
||||
// --- Effects ---
|
||||
|
||||
// SSR Safety
|
||||
useEffect(() => {
|
||||
setMounted(true)
|
||||
let storedSessionId = localStorage.getItem(SESSION_ID_KEY)
|
||||
if (!storedSessionId) {
|
||||
storedSessionId = `vocab-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
|
||||
localStorage.setItem(SESSION_ID_KEY, storedSessionId)
|
||||
}
|
||||
setUploadSessionId(storedSessionId)
|
||||
let sid = localStorage.getItem(SESSION_ID_KEY)
|
||||
if (!sid) { sid = `vocab-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; localStorage.setItem(SESSION_ID_KEY, sid) }
|
||||
setUploadSessionId(sid)
|
||||
}, [])
|
||||
|
||||
// Load OCR prompts from localStorage
|
||||
useEffect(() => {
|
||||
if (!mounted) return
|
||||
const stored = localStorage.getItem(OCR_PROMPTS_KEY)
|
||||
if (stored) {
|
||||
try {
|
||||
setOcrPrompts({ ...defaultOcrPrompts, ...JSON.parse(stored) })
|
||||
} catch (e) {
|
||||
console.error('Failed to parse OCR prompts:', e)
|
||||
}
|
||||
}
|
||||
if (stored) { try { setOcrPrompts({ ...defaultOcrPrompts, ...JSON.parse(stored) }) } catch {} }
|
||||
}, [mounted])
|
||||
|
||||
// Load documents from localStorage
|
||||
useEffect(() => {
|
||||
if (!mounted) return
|
||||
const stored = localStorage.getItem(DOCUMENTS_KEY)
|
||||
if (stored) {
|
||||
try {
|
||||
const docs = JSON.parse(stored)
|
||||
const imagesDocs = docs.filter((d: StoredDocument) =>
|
||||
d.type?.startsWith('image/') || d.type === 'application/pdf'
|
||||
)
|
||||
setStoredDocuments(imagesDocs)
|
||||
} catch (e) {
|
||||
console.error('Failed to parse stored documents:', e)
|
||||
}
|
||||
try { setStoredDocuments(JSON.parse(stored).filter((d: StoredDocument) => d.type?.startsWith('image/') || d.type === 'application/pdf')) } catch {}
|
||||
}
|
||||
}, [mounted])
|
||||
|
||||
// Load existing sessions from API
|
||||
useEffect(() => {
|
||||
if (!mounted) return
|
||||
const loadSessions = async () => {
|
||||
const API_BASE = getApiBase()
|
||||
;(async () => {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/v1/vocab/sessions`)
|
||||
if (res.ok) {
|
||||
const sessions = await res.json()
|
||||
setExistingSessions(sessions)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load sessions:', e)
|
||||
} finally {
|
||||
setIsLoadingSessions(false)
|
||||
}
|
||||
}
|
||||
loadSessions()
|
||||
const res = await fetch(`${getApiBase()}/api/v1/vocab/sessions`)
|
||||
if (res.ok) setExistingSessions(await res.json())
|
||||
} catch {} finally { setIsLoadingSessions(false) }
|
||||
})()
|
||||
}, [mounted])
|
||||
|
||||
// --- Glassmorphism styles ---
|
||||
|
||||
const glassCard = isDark
|
||||
? 'backdrop-blur-xl bg-white/10 border border-white/20'
|
||||
: 'backdrop-blur-xl bg-white/70 border border-black/10'
|
||||
|
||||
const glassInput = isDark
|
||||
? 'bg-white/10 border-white/20 text-white placeholder-white/40 focus:border-purple-400'
|
||||
: 'bg-white/50 border-black/10 text-slate-900 placeholder-slate-400 focus:border-purple-500'
|
||||
const glassCard = isDark ? 'backdrop-blur-xl bg-white/10 border border-white/20' : 'backdrop-blur-xl bg-white/70 border border-black/10'
|
||||
const glassInput = isDark ? 'bg-white/10 border-white/20 text-white placeholder-white/40 focus:border-purple-400' : 'bg-white/50 border-black/10 text-slate-900 placeholder-slate-400 focus:border-purple-500'
|
||||
|
||||
// --- Handlers ---
|
||||
|
||||
const saveOcrPrompts = (prompts: OcrPrompts) => {
|
||||
setOcrPrompts(prompts)
|
||||
localStorage.setItem(OCR_PROMPTS_KEY, JSON.stringify(prompts))
|
||||
}
|
||||
const saveOcrPrompts = (prompts: OcrPrompts) => { setOcrPrompts(prompts); localStorage.setItem(OCR_PROMPTS_KEY, JSON.stringify(prompts)) }
|
||||
|
||||
const handleDirectFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
setDirectFile(file)
|
||||
setSelectedDocumentId(null)
|
||||
setSelectedMobileFile(null)
|
||||
|
||||
const file = e.target.files?.[0]; if (!file) return
|
||||
setDirectFile(file); setSelectedDocumentId(null); setSelectedMobileFile(null)
|
||||
if (file.type.startsWith('image/')) {
|
||||
const reader = new FileReader()
|
||||
reader.onload = (ev) => {
|
||||
setDirectFilePreview(ev.target?.result as string)
|
||||
}
|
||||
reader.readAsDataURL(file)
|
||||
} else if (file.type === 'application/pdf') {
|
||||
setDirectFilePreview(URL.createObjectURL(file))
|
||||
} else {
|
||||
setDirectFilePreview(null)
|
||||
}
|
||||
const reader = new FileReader(); reader.onload = (ev) => setDirectFilePreview(ev.target?.result as string); reader.readAsDataURL(file)
|
||||
} else if (file.type === 'application/pdf') { setDirectFilePreview(URL.createObjectURL(file)) }
|
||||
else { setDirectFilePreview(null) }
|
||||
}
|
||||
|
||||
const startSession = async () => {
|
||||
if (!sessionName.trim()) {
|
||||
setError('Bitte geben Sie einen Namen fuer die Session ein.')
|
||||
return
|
||||
}
|
||||
if (!selectedDocumentId && !directFile && !selectedMobileFile) {
|
||||
setError('Bitte waehlen Sie ein Dokument aus oder laden Sie eine Datei hoch.')
|
||||
return
|
||||
}
|
||||
|
||||
setError(null)
|
||||
if (!sessionName.trim()) { setError('Bitte geben Sie einen Namen fuer die Session ein.'); return }
|
||||
if (!selectedDocumentId && !directFile && !selectedMobileFile) { setError('Bitte waehlen Sie ein Dokument aus oder laden Sie eine Datei hoch.'); return }
|
||||
setIsCreatingSession(true)
|
||||
setExtractionStatus('Session wird erstellt...')
|
||||
|
||||
const API_BASE = getApiBase()
|
||||
|
||||
try {
|
||||
const sessionRes = await fetch(`${API_BASE}/api/v1/vocab/sessions`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: sessionName,
|
||||
ocr_prompts: ocrPrompts
|
||||
}),
|
||||
await startSessionFlow({
|
||||
sessionName, selectedDocumentId, directFile, selectedMobileFile, storedDocuments,
|
||||
ocrPrompts, startActivity, setSession, setWorksheetTitle, setExtractionStatus,
|
||||
setPdfPageCount, setSelectedPages, setPagesThumbnails, setIsLoadingThumbnails,
|
||||
setVocabulary, setActiveTab, setError,
|
||||
})
|
||||
|
||||
if (!sessionRes.ok) {
|
||||
throw new Error('Session konnte nicht erstellt werden')
|
||||
}
|
||||
|
||||
const sessionData = await sessionRes.json()
|
||||
setSession(sessionData)
|
||||
setWorksheetTitle(sessionName)
|
||||
|
||||
startActivity('vocab_extraction', { description: sessionName })
|
||||
|
||||
let file: File
|
||||
let isPdf = false
|
||||
|
||||
if (directFile) {
|
||||
file = directFile
|
||||
isPdf = directFile.type === 'application/pdf'
|
||||
} else if (selectedMobileFile) {
|
||||
isPdf = selectedMobileFile.type === 'application/pdf'
|
||||
const base64Data = selectedMobileFile.dataUrl.split(',')[1]
|
||||
const byteCharacters = atob(base64Data)
|
||||
const byteNumbers = new Array(byteCharacters.length)
|
||||
for (let i = 0; i < byteCharacters.length; i++) {
|
||||
byteNumbers[i] = byteCharacters.charCodeAt(i)
|
||||
}
|
||||
const byteArray = new Uint8Array(byteNumbers)
|
||||
const blob = new Blob([byteArray], { type: selectedMobileFile.type })
|
||||
file = new File([blob], selectedMobileFile.name, { type: selectedMobileFile.type })
|
||||
} else {
|
||||
const selectedDoc = storedDocuments.find(d => d.id === selectedDocumentId)
|
||||
if (!selectedDoc || !selectedDoc.url) {
|
||||
throw new Error('Das ausgewaehlte Dokument ist nicht verfuegbar.')
|
||||
}
|
||||
|
||||
isPdf = selectedDoc.type === 'application/pdf'
|
||||
|
||||
const base64Data = selectedDoc.url.split(',')[1]
|
||||
const byteCharacters = atob(base64Data)
|
||||
const byteNumbers = new Array(byteCharacters.length)
|
||||
for (let i = 0; i < byteCharacters.length; i++) {
|
||||
byteNumbers[i] = byteCharacters.charCodeAt(i)
|
||||
}
|
||||
const byteArray = new Uint8Array(byteNumbers)
|
||||
const blob = new Blob([byteArray], { type: selectedDoc.type })
|
||||
file = new File([blob], selectedDoc.name, { type: selectedDoc.type })
|
||||
}
|
||||
|
||||
if (isPdf) {
|
||||
setExtractionStatus('PDF wird hochgeladen...')
|
||||
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
|
||||
const pdfInfoRes = await fetch(`${API_BASE}/api/v1/vocab/sessions/${sessionData.id}/upload-pdf-info`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
})
|
||||
|
||||
if (!pdfInfoRes.ok) {
|
||||
throw new Error('PDF konnte nicht verarbeitet werden')
|
||||
}
|
||||
|
||||
const pdfInfo = await pdfInfoRes.json()
|
||||
setPdfPageCount(pdfInfo.page_count)
|
||||
setSelectedPages(Array.from({ length: pdfInfo.page_count }, (_, i) => i))
|
||||
|
||||
setActiveTab('pages')
|
||||
setExtractionStatus(`${pdfInfo.page_count} Seiten erkannt. Vorschau wird geladen...`)
|
||||
setIsLoadingThumbnails(true)
|
||||
|
||||
const thumbnails: string[] = []
|
||||
for (let i = 0; i < pdfInfo.page_count; i++) {
|
||||
try {
|
||||
const thumbRes = await fetch(`${API_BASE}/api/v1/vocab/sessions/${sessionData.id}/pdf-thumbnail/${i}?hires=true`)
|
||||
if (thumbRes.ok) {
|
||||
const blob = await thumbRes.blob()
|
||||
thumbnails.push(URL.createObjectURL(blob))
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`Failed to load thumbnail for page ${i}`)
|
||||
}
|
||||
}
|
||||
|
||||
setPagesThumbnails(thumbnails)
|
||||
setIsLoadingThumbnails(false)
|
||||
setExtractionStatus(`${pdfInfo.page_count} Seiten bereit. Waehlen Sie die zu verarbeitenden Seiten.`)
|
||||
|
||||
} else {
|
||||
setExtractionStatus('KI analysiert das Bild... (kann 30-60 Sekunden dauern)')
|
||||
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
|
||||
const uploadRes = await fetch(`${API_BASE}/api/v1/vocab/sessions/${sessionData.id}/upload`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
})
|
||||
|
||||
if (!uploadRes.ok) {
|
||||
throw new Error('Bild konnte nicht verarbeitet werden')
|
||||
}
|
||||
|
||||
const uploadData = await uploadRes.json()
|
||||
setSession(prev => prev ? { ...prev, status: 'extracted', vocabulary_count: uploadData.vocabulary_count } : null)
|
||||
|
||||
const vocabRes = await fetch(`${API_BASE}/api/v1/vocab/sessions/${sessionData.id}/vocabulary`)
|
||||
if (vocabRes.ok) {
|
||||
const vocabData = await vocabRes.json()
|
||||
setVocabulary(vocabData.vocabulary || [])
|
||||
setExtractionStatus(`${vocabData.vocabulary?.length || 0} Vokabeln gefunden!`)
|
||||
}
|
||||
|
||||
await new Promise(r => setTimeout(r, 1000))
|
||||
setActiveTab('vocabulary')
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Session start failed:', error)
|
||||
setError(error instanceof Error ? error.message : 'Ein Fehler ist aufgetreten')
|
||||
setExtractionStatus('')
|
||||
setSession(null)
|
||||
} finally {
|
||||
setIsCreatingSession(false)
|
||||
}
|
||||
}
|
||||
|
||||
const processSinglePage = async (pageIndex: number, ipa: IpaMode, syllable: SyllableMode): Promise<{ success: boolean; vocabulary: VocabularyEntry[]; error?: string; scanQuality?: any }> => {
|
||||
const API_BASE = getApiBase()
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
ipa_mode: ipa,
|
||||
syllable_mode: syllable,
|
||||
enhance: String(ocrEnhance),
|
||||
max_cols: String(ocrMaxCols),
|
||||
min_conf: String(ocrMinConf),
|
||||
})
|
||||
const res = await fetch(`${API_BASE}/api/v1/vocab/sessions/${session!.id}/process-single-page/${pageIndex}?${params}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ocr_prompts: ocrPrompts }),
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const errBody = await res.json().catch(() => ({}))
|
||||
const detail = errBody.detail || `HTTP ${res.status}`
|
||||
return { success: false, vocabulary: [], error: `Seite ${pageIndex + 1}: ${detail}` }
|
||||
}
|
||||
|
||||
const data = await res.json()
|
||||
|
||||
if (!data.success) {
|
||||
return { success: false, vocabulary: [], error: data.error || `Seite ${pageIndex + 1}: Unbekannter Fehler` }
|
||||
}
|
||||
|
||||
return { success: true, vocabulary: data.vocabulary || [], scanQuality: data.scan_quality }
|
||||
} catch (e) {
|
||||
return { success: false, vocabulary: [], error: `Seite ${pageIndex + 1}: ${e instanceof Error ? e.message : 'Netzwerkfehler'}` }
|
||||
}
|
||||
setExtractionStatus(''); setSession(null)
|
||||
} finally { setIsCreatingSession(false) }
|
||||
}
|
||||
|
||||
const processSelectedPages = async () => {
|
||||
if (!session || selectedPages.length === 0) return
|
||||
|
||||
const pagesToProcess = [...selectedPages].sort((a, b) => a - b)
|
||||
setIsExtracting(true); setProcessingErrors([]); setSuccessfulPages([]); setFailedPages([])
|
||||
setProcessingQueue(pagesToProcess); setVocabulary([]); setActiveTab('vocabulary')
|
||||
|
||||
setIsExtracting(true)
|
||||
setProcessingErrors([])
|
||||
setSuccessfulPages([])
|
||||
setFailedPages([])
|
||||
setProcessingQueue(pagesToProcess)
|
||||
setVocabulary([])
|
||||
|
||||
setActiveTab('vocabulary')
|
||||
|
||||
const API_BASE = getApiBase()
|
||||
const errors: string[] = []
|
||||
const successful: number[] = []
|
||||
const failed: number[] = []
|
||||
const errors: string[] = []; const successful: number[] = []; const failed: number[] = []
|
||||
|
||||
for (let i = 0; i < pagesToProcess.length; i++) {
|
||||
const pageIndex = pagesToProcess[i]
|
||||
setCurrentlyProcessingPage(pageIndex + 1)
|
||||
setExtractionStatus(`Verarbeite Seite ${pageIndex + 1} von ${pagesToProcess.length}... (kann 30-60 Sekunden dauern)`)
|
||||
|
||||
const result = await processSinglePage(pageIndex, ipaMode, syllableMode)
|
||||
|
||||
const result = await processSinglePage(session.id, pageIndex, ipaMode, syllableMode, ocrPrompts, ocrEnhance, ocrMaxCols, ocrMinConf)
|
||||
if (result.success) {
|
||||
successful.push(pageIndex + 1)
|
||||
setSuccessfulPages([...successful])
|
||||
setVocabulary(prev => [...prev, ...result.vocabulary])
|
||||
const qualityInfo = result.scanQuality
|
||||
? ` | Qualitaet: ${result.scanQuality.quality_pct}%${result.scanQuality.is_degraded ? ' (degradiert!)' : ''}`
|
||||
: ''
|
||||
setExtractionStatus(`Seite ${pageIndex + 1} fertig: ${result.vocabulary.length} Vokabeln gefunden${qualityInfo}`)
|
||||
successful.push(pageIndex + 1); setSuccessfulPages([...successful]); setVocabulary(prev => [...prev, ...result.vocabulary])
|
||||
const qi = result.scanQuality ? ` | Qualitaet: ${result.scanQuality.quality_pct}%${result.scanQuality.is_degraded ? ' (degradiert!)' : ''}` : ''
|
||||
setExtractionStatus(`Seite ${pageIndex + 1} fertig: ${result.vocabulary.length} Vokabeln gefunden${qi}`)
|
||||
} else {
|
||||
failed.push(pageIndex + 1)
|
||||
setFailedPages([...failed])
|
||||
if (result.error) {
|
||||
errors.push(result.error)
|
||||
setProcessingErrors([...errors])
|
||||
}
|
||||
failed.push(pageIndex + 1); setFailedPages([...failed])
|
||||
if (result.error) { errors.push(result.error); setProcessingErrors([...errors]) }
|
||||
setExtractionStatus(`Seite ${pageIndex + 1} fehlgeschlagen`)
|
||||
}
|
||||
|
||||
await new Promise(r => setTimeout(r, 500))
|
||||
}
|
||||
|
||||
setCurrentlyProcessingPage(null)
|
||||
setProcessingQueue([])
|
||||
setIsExtracting(false)
|
||||
setCurrentlyProcessingPage(null); setProcessingQueue([]); setIsExtracting(false)
|
||||
|
||||
if (successful.length === pagesToProcess.length) {
|
||||
setExtractionStatus(`Fertig! Alle ${successful.length} Seiten verarbeitet.`)
|
||||
} else if (successful.length > 0) {
|
||||
setExtractionStatus(`${successful.length} von ${pagesToProcess.length} Seiten verarbeitet. ${failed.length} fehlgeschlagen.`)
|
||||
} else {
|
||||
setExtractionStatus(`Alle Seiten fehlgeschlagen.`)
|
||||
}
|
||||
if (successful.length === pagesToProcess.length) setExtractionStatus(`Fertig! Alle ${successful.length} Seiten verarbeitet.`)
|
||||
else if (successful.length > 0) setExtractionStatus(`${successful.length} von ${pagesToProcess.length} Seiten verarbeitet. ${failed.length} fehlgeschlagen.`)
|
||||
else setExtractionStatus(`Alle Seiten fehlgeschlagen.`)
|
||||
|
||||
// Reload thumbnails for processed pages (server may have rotated them)
|
||||
// Reload thumbnails for processed pages
|
||||
if (successful.length > 0 && session) {
|
||||
const updatedThumbs = [...pagesThumbnails]
|
||||
const API_BASE = getApiBase(); const updatedThumbs = [...pagesThumbnails]
|
||||
for (const pageNum of successful) {
|
||||
const idx = pageNum - 1
|
||||
try {
|
||||
const thumbRes = await fetch(`${API_BASE}/api/v1/vocab/sessions/${session.id}/pdf-thumbnail/${idx}?hires=true&t=${Date.now()}`)
|
||||
if (thumbRes.ok) {
|
||||
const blob = await thumbRes.blob()
|
||||
if (updatedThumbs[idx]) URL.revokeObjectURL(updatedThumbs[idx])
|
||||
updatedThumbs[idx] = URL.createObjectURL(blob)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`Failed to refresh thumbnail for page ${pageNum}`)
|
||||
}
|
||||
const res = await fetch(`${API_BASE}/api/v1/vocab/sessions/${session.id}/pdf-thumbnail/${idx}?hires=true&t=${Date.now()}`)
|
||||
if (res.ok) { if (updatedThumbs[idx]) URL.revokeObjectURL(updatedThumbs[idx]); updatedThumbs[idx] = URL.createObjectURL(await res.blob()) }
|
||||
} catch {}
|
||||
}
|
||||
setPagesThumbnails(updatedThumbs)
|
||||
}
|
||||
|
||||
setSession(prev => prev ? { ...prev, status: 'extracted' } : null)
|
||||
}
|
||||
|
||||
const togglePageSelection = (pageIndex: number) => {
|
||||
setSelectedPages(prev =>
|
||||
prev.includes(pageIndex)
|
||||
? prev.filter(p => p !== pageIndex)
|
||||
: [...prev, pageIndex].sort((a, b) => a - b)
|
||||
)
|
||||
}
|
||||
|
||||
const selectAllPages = () => setSelectedPages(
|
||||
Array.from({ length: pdfPageCount }, (_, i) => i).filter(p => !excludedPages.includes(p))
|
||||
)
|
||||
const togglePageSelection = (i: number) => { setSelectedPages(p => p.includes(i) ? p.filter(x => x !== i) : [...p, i].sort((a, b) => a - b)) }
|
||||
const selectAllPages = () => setSelectedPages(Array.from({ length: pdfPageCount }, (_, i) => i).filter(p => !excludedPages.includes(p)))
|
||||
const selectNoPages = () => setSelectedPages([])
|
||||
|
||||
const excludePage = (pageIndex: number, e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
setExcludedPages(prev => [...prev, pageIndex])
|
||||
setSelectedPages(prev => prev.filter(p => p !== pageIndex))
|
||||
}
|
||||
|
||||
const restoreExcludedPages = () => {
|
||||
setExcludedPages([])
|
||||
}
|
||||
const excludePage = (i: number, e: React.MouseEvent) => { e.stopPropagation(); setExcludedPages(p => [...p, i]); setSelectedPages(p => p.filter(x => x !== i)) }
|
||||
const restoreExcludedPages = () => setExcludedPages([])
|
||||
|
||||
const runOcrComparison = async (pageIndex: number) => {
|
||||
if (!session) return
|
||||
|
||||
setOcrComparePageIndex(pageIndex)
|
||||
setShowOcrComparison(true)
|
||||
setIsComparingOcr(true)
|
||||
setOcrCompareError(null)
|
||||
setOcrCompareResult(null)
|
||||
|
||||
const API_BASE = getApiBase()
|
||||
|
||||
setOcrComparePageIndex(pageIndex); setShowOcrComparison(true); setIsComparingOcr(true); setOcrCompareError(null); setOcrCompareResult(null)
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/v1/vocab/sessions/${session.id}/compare-ocr/${pageIndex}`, {
|
||||
method: 'POST',
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`HTTP ${res.status}`)
|
||||
}
|
||||
|
||||
const data = await res.json()
|
||||
setOcrCompareResult(data)
|
||||
} catch (e) {
|
||||
setOcrCompareError(e instanceof Error ? e.message : 'Vergleich fehlgeschlagen')
|
||||
} finally {
|
||||
setIsComparingOcr(false)
|
||||
}
|
||||
const res = await fetch(`${getApiBase()}/api/v1/vocab/sessions/${session.id}/compare-ocr/${pageIndex}`, { method: 'POST' })
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
setOcrCompareResult(await res.json())
|
||||
} catch (e) { setOcrCompareError(e instanceof Error ? e.message : 'Vergleich fehlgeschlagen') }
|
||||
finally { setIsComparingOcr(false) }
|
||||
}
|
||||
|
||||
const updateVocabularyEntry = (id: string, field: string, value: string) => {
|
||||
setVocabulary(prev => prev.map(v => {
|
||||
if (v.id !== id) return v
|
||||
if (field === 'english' || field === 'german' || field === 'example_sentence' || field === 'word_type') {
|
||||
return { ...v, [field]: value }
|
||||
}
|
||||
if (field === 'english' || field === 'german' || field === 'example_sentence' || field === 'word_type') return { ...v, [field]: value }
|
||||
return { ...v, extras: { ...(v.extras || {}), [field]: value } }
|
||||
}))
|
||||
}
|
||||
|
||||
const addExtraColumn = (sourcePage: number) => {
|
||||
const label = prompt('Spaltenname:')
|
||||
if (!label || !label.trim()) return
|
||||
const label = prompt('Spaltenname:'); if (!label || !label.trim()) return
|
||||
const key = `extra_${Date.now()}`
|
||||
setPageExtraColumns(prev => ({
|
||||
...prev,
|
||||
[sourcePage]: [...(prev[sourcePage] || []), { key, label: label.trim() }],
|
||||
}))
|
||||
setPageExtraColumns(prev => ({ ...prev, [sourcePage]: [...(prev[sourcePage] || []), { key, label: label.trim() }] }))
|
||||
}
|
||||
|
||||
const removeExtraColumn = (sourcePage: number, key: string) => {
|
||||
setPageExtraColumns(prev => ({
|
||||
...prev,
|
||||
[sourcePage]: (prev[sourcePage] || []).filter(c => c.key !== key),
|
||||
}))
|
||||
setVocabulary(prev => prev.map(v => {
|
||||
if (!v.extras || !(key in v.extras)) return v
|
||||
const { [key]: _, ...rest } = v.extras
|
||||
return { ...v, extras: rest }
|
||||
}))
|
||||
setPageExtraColumns(prev => ({ ...prev, [sourcePage]: (prev[sourcePage] || []).filter(c => c.key !== key) }))
|
||||
setVocabulary(prev => prev.map(v => { if (!v.extras || !(key in v.extras)) return v; const { [key]: _, ...rest } = v.extras; return { ...v, extras: rest } }))
|
||||
}
|
||||
|
||||
const getExtraColumnsForPage = (sourcePage: number): ExtraColumn[] => {
|
||||
const global = pageExtraColumns[0] || []
|
||||
const pageSpecific = pageExtraColumns[sourcePage] || []
|
||||
return [...global, ...pageSpecific]
|
||||
}
|
||||
const getExtraColumnsForPage = (sourcePage: number): ExtraColumn[] => [...(pageExtraColumns[0] || []), ...(pageExtraColumns[sourcePage] || [])]
|
||||
|
||||
const getAllExtraColumns = (): ExtraColumn[] => {
|
||||
const seen = new Set<string>()
|
||||
const result: ExtraColumn[] = []
|
||||
for (const cols of Object.values(pageExtraColumns)) {
|
||||
for (const col of cols) {
|
||||
if (!seen.has(col.key)) {
|
||||
seen.add(col.key)
|
||||
result.push(col)
|
||||
}
|
||||
}
|
||||
}
|
||||
const seen = new Set<string>(); const result: ExtraColumn[] = []
|
||||
for (const cols of Object.values(pageExtraColumns)) for (const col of cols) { if (!seen.has(col.key)) { seen.add(col.key); result.push(col) } }
|
||||
return result
|
||||
}
|
||||
|
||||
const deleteVocabularyEntry = (id: string) => {
|
||||
setVocabulary(prev => prev.filter(v => v.id !== id))
|
||||
}
|
||||
|
||||
const toggleVocabularySelection = (id: string) => {
|
||||
setVocabulary(prev => prev.map(v =>
|
||||
v.id === id ? { ...v, selected: !v.selected } : v
|
||||
))
|
||||
}
|
||||
|
||||
const toggleAllSelection = () => {
|
||||
const allSelected = vocabulary.every(v => v.selected)
|
||||
setVocabulary(prev => prev.map(v => ({ ...v, selected: !allSelected })))
|
||||
}
|
||||
const deleteVocabularyEntry = (id: string) => setVocabulary(prev => prev.filter(v => v.id !== id))
|
||||
const toggleVocabularySelection = (id: string) => setVocabulary(prev => prev.map(v => v.id === id ? { ...v, selected: !v.selected } : v))
|
||||
const toggleAllSelection = () => { const all = vocabulary.every(v => v.selected); setVocabulary(prev => prev.map(v => ({ ...v, selected: !all }))) }
|
||||
|
||||
const addVocabularyEntry = (atIndex?: number) => {
|
||||
const newEntry: VocabularyEntry = {
|
||||
id: `new-${Date.now()}`,
|
||||
english: '',
|
||||
german: '',
|
||||
example_sentence: '',
|
||||
selected: true
|
||||
}
|
||||
setVocabulary(prev => {
|
||||
if (atIndex === undefined) {
|
||||
return [...prev, newEntry]
|
||||
}
|
||||
const newList = [...prev]
|
||||
newList.splice(atIndex, 0, newEntry)
|
||||
return newList
|
||||
})
|
||||
const ne: VocabularyEntry = { id: `new-${Date.now()}`, english: '', german: '', example_sentence: '', selected: true }
|
||||
setVocabulary(prev => { if (atIndex === undefined) return [...prev, ne]; const nl = [...prev]; nl.splice(atIndex, 0, ne); return nl })
|
||||
}
|
||||
|
||||
const saveVocabulary = async () => {
|
||||
if (!session) return
|
||||
const API_BASE = getApiBase()
|
||||
|
||||
try {
|
||||
await fetch(`${API_BASE}/api/v1/vocab/sessions/${session.id}/vocabulary`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ vocabulary }),
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Failed to save vocabulary:', error)
|
||||
}
|
||||
try { await fetch(`${getApiBase()}/api/v1/vocab/sessions/${session.id}/vocabulary`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ vocabulary }) }) }
|
||||
catch (e) { console.error('Failed to save vocabulary:', e) }
|
||||
}
|
||||
|
||||
const generateWorksheet = async () => {
|
||||
if (!session) return
|
||||
if (selectedFormat === 'standard' && selectedTypes.length === 0) return
|
||||
|
||||
if (!session) return; if (selectedFormat === 'standard' && selectedTypes.length === 0) return
|
||||
setIsGenerating(true)
|
||||
const API_BASE = getApiBase()
|
||||
|
||||
try {
|
||||
await saveVocabulary()
|
||||
|
||||
let res: Response
|
||||
|
||||
if (selectedFormat === 'nru') {
|
||||
res = await fetch(`${API_BASE}/api/v1/vocab/sessions/${session.id}/generate-nru`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
title: worksheetTitle || session.name,
|
||||
include_solutions: includeSolutions,
|
||||
}),
|
||||
})
|
||||
} else {
|
||||
res = await fetch(`${API_BASE}/api/v1/vocab/sessions/${session.id}/generate`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
worksheet_types: selectedTypes,
|
||||
title: worksheetTitle || session.name,
|
||||
include_solutions: includeSolutions,
|
||||
line_height: lineHeight,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setWorksheetId(data.worksheet_id || data.id)
|
||||
setActiveTab('export')
|
||||
completeActivity({ vocabCount: vocabulary.length })
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to generate worksheet:', error)
|
||||
} finally {
|
||||
setIsGenerating(false)
|
||||
}
|
||||
const API_BASE = getApiBase()
|
||||
const endpoint = selectedFormat === 'nru' ? 'generate-nru' : 'generate'
|
||||
const body = selectedFormat === 'nru'
|
||||
? { title: worksheetTitle || session.name, include_solutions: includeSolutions }
|
||||
: { worksheet_types: selectedTypes, title: worksheetTitle || session.name, include_solutions: includeSolutions, line_height: lineHeight }
|
||||
const res = await fetch(`${API_BASE}/api/v1/vocab/sessions/${session.id}/${endpoint}`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),
|
||||
})
|
||||
if (res.ok) { const data = await res.json(); setWorksheetId(data.worksheet_id || data.id); setActiveTab('export'); completeActivity({ vocabCount: vocabulary.length }) }
|
||||
} catch (e) { console.error('Failed to generate worksheet:', e) }
|
||||
finally { setIsGenerating(false) }
|
||||
}
|
||||
|
||||
const downloadPDF = (type: 'worksheet' | 'solution') => {
|
||||
if (!worksheetId) return
|
||||
const API_BASE = getApiBase()
|
||||
const endpoint = type === 'worksheet' ? 'pdf' : 'solution'
|
||||
window.open(`${API_BASE}/api/v1/vocab/worksheets/${worksheetId}/${endpoint}`, '_blank')
|
||||
window.open(`${getApiBase()}/api/v1/vocab/worksheets/${worksheetId}/${type === 'worksheet' ? 'pdf' : 'solution'}`, '_blank')
|
||||
}
|
||||
|
||||
const toggleWorksheetType = (type: WorksheetType) => {
|
||||
setSelectedTypes(prev =>
|
||||
prev.includes(type) ? prev.filter(t => t !== type) : [...prev, type]
|
||||
)
|
||||
}
|
||||
const toggleWorksheetType = (type: WorksheetType) => setSelectedTypes(prev => prev.includes(type) ? prev.filter(t => t !== type) : [...prev, type])
|
||||
|
||||
const resumeSession = async (existingSession: Session) => {
|
||||
setError(null)
|
||||
setExtractionStatus('Session wird geladen...')
|
||||
|
||||
const API_BASE = getApiBase()
|
||||
|
||||
try {
|
||||
const sessionRes = await fetch(`${API_BASE}/api/v1/vocab/sessions/${existingSession.id}`)
|
||||
if (!sessionRes.ok) throw new Error('Session nicht gefunden')
|
||||
const sessionData = await sessionRes.json()
|
||||
setSession(sessionData)
|
||||
setWorksheetTitle(sessionData.name)
|
||||
|
||||
if (sessionData.status === 'extracted' || sessionData.status === 'completed') {
|
||||
const vocabRes = await fetch(`${API_BASE}/api/v1/vocab/sessions/${existingSession.id}/vocabulary`)
|
||||
if (vocabRes.ok) {
|
||||
const vocabData = await vocabRes.json()
|
||||
setVocabulary(vocabData.vocabulary || [])
|
||||
}
|
||||
setActiveTab('vocabulary')
|
||||
setExtractionStatus('')
|
||||
} else if (sessionData.status === 'pending') {
|
||||
setActiveTab('upload')
|
||||
setExtractionStatus('Diese Session hat noch keine Vokabeln. Bitte laden Sie ein Dokument hoch.')
|
||||
} else {
|
||||
setActiveTab('vocabulary')
|
||||
setExtractionStatus('')
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to resume session:', error)
|
||||
setError(error instanceof Error ? error.message : 'Fehler beim Laden der Session')
|
||||
setExtractionStatus('')
|
||||
}
|
||||
setError(null); setExtractionStatus('Session wird geladen...')
|
||||
try { await resumeSessionFlow(existingSession, setSession, setWorksheetTitle, setVocabulary, setActiveTab, setExtractionStatus) }
|
||||
catch (e) { setError(e instanceof Error ? e.message : 'Fehler beim Laden der Session'); setExtractionStatus('') }
|
||||
}
|
||||
|
||||
const resetSession = async () => {
|
||||
setSession(null)
|
||||
setSessionName('')
|
||||
setVocabulary([])
|
||||
setUploadedImage(null)
|
||||
setWorksheetId(null)
|
||||
setSelectedDocumentId(null)
|
||||
setDirectFile(null)
|
||||
setDirectFilePreview(null)
|
||||
setShowFullPreview(false)
|
||||
setPdfPageCount(0)
|
||||
setSelectedPages([])
|
||||
setPagesThumbnails([])
|
||||
setExcludedPages([])
|
||||
setActiveTab('upload')
|
||||
setError(null)
|
||||
setExtractionStatus('')
|
||||
|
||||
const API_BASE = getApiBase()
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/v1/vocab/sessions`)
|
||||
if (res.ok) {
|
||||
const sessions = await res.json()
|
||||
setExistingSessions(sessions)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to reload sessions:', e)
|
||||
}
|
||||
setSession(null); setSessionName(''); setVocabulary([]); setUploadedImage(null); setWorksheetId(null)
|
||||
setSelectedDocumentId(null); setDirectFile(null); setDirectFilePreview(null); setShowFullPreview(false)
|
||||
setPdfPageCount(0); setSelectedPages([]); setPagesThumbnails([]); setExcludedPages([])
|
||||
setActiveTab('upload'); setError(null); setExtractionStatus('')
|
||||
try { const res = await fetch(`${getApiBase()}/api/v1/vocab/sessions`); if (res.ok) setExistingSessions(await res.json()) } catch {}
|
||||
}
|
||||
|
||||
const deleteSession = async (sessionId: string, e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
if (!confirm('Session wirklich loeschen? Diese Aktion kann nicht rueckgaengig gemacht werden.')) {
|
||||
return
|
||||
}
|
||||
|
||||
const API_BASE = getApiBase()
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/v1/vocab/sessions/${sessionId}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
if (res.ok) {
|
||||
setExistingSessions(prev => prev.filter(s => s.id !== sessionId))
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to delete session:', e)
|
||||
}
|
||||
if (!confirm('Session wirklich loeschen? Diese Aktion kann nicht rueckgaengig gemacht werden.')) return
|
||||
try { const res = await fetch(`${getApiBase()}/api/v1/vocab/sessions/${sessionId}`, { method: 'DELETE' }); if (res.ok) setExistingSessions(prev => prev.filter(s => s.id !== sessionId)) } catch {}
|
||||
}
|
||||
|
||||
// Reprocess all successful pages with new IPA/syllable modes
|
||||
const reprocessPages = (ipa: IpaMode, syllable: SyllableMode) => {
|
||||
if (!session) return
|
||||
let pages: number[]
|
||||
if (successfulPages.length > 0) pages = successfulPages.map(p => p - 1)
|
||||
else if (vocabulary.length > 0) pages = [...new Set(vocabulary.map(v => (v.source_page || 1) - 1))].sort((a, b) => a - b)
|
||||
else if (selectedPages.length > 0) pages = [...selectedPages]
|
||||
else pages = [0]
|
||||
if (pages.length === 0) return
|
||||
|
||||
// Determine pages to reprocess: use successfulPages if available,
|
||||
// otherwise derive from vocabulary source_page or selectedPages
|
||||
let pagesToReprocess: number[]
|
||||
if (successfulPages.length > 0) {
|
||||
pagesToReprocess = successfulPages.map(p => p - 1)
|
||||
} else if (vocabulary.length > 0) {
|
||||
// Derive from vocabulary entries' source_page (1-indexed → 0-indexed)
|
||||
const pageSet = new Set(vocabulary.map(v => (v.source_page || 1) - 1))
|
||||
pagesToReprocess = [...pageSet].sort((a, b) => a - b)
|
||||
} else if (selectedPages.length > 0) {
|
||||
pagesToReprocess = [...selectedPages]
|
||||
} else {
|
||||
// Fallback: try page 0
|
||||
pagesToReprocess = [0]
|
||||
}
|
||||
|
||||
if (pagesToReprocess.length === 0) return
|
||||
|
||||
setIsExtracting(true)
|
||||
setExtractionStatus('Verarbeite mit neuen Einstellungen...')
|
||||
const API_BASE = getApiBase()
|
||||
|
||||
setIsExtracting(true); setExtractionStatus('Verarbeite mit neuen Einstellungen...')
|
||||
;(async () => {
|
||||
const allVocab: VocabularyEntry[] = []
|
||||
let lastQuality: any = null
|
||||
for (const pageIndex of pagesToReprocess) {
|
||||
setExtractionStatus(`Verarbeite Seite ${pageIndex + 1}...`)
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
ipa_mode: ipa,
|
||||
syllable_mode: syllable,
|
||||
enhance: String(ocrEnhance),
|
||||
max_cols: String(ocrMaxCols),
|
||||
min_conf: String(ocrMinConf),
|
||||
})
|
||||
const res = await fetch(`${API_BASE}/api/v1/vocab/sessions/${session.id}/process-single-page/${pageIndex}?${params}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ocr_prompts: ocrPrompts }),
|
||||
})
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
if (data.vocabulary) allVocab.push(...data.vocabulary)
|
||||
if (data.scan_quality) lastQuality = data.scan_quality
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
setVocabulary(allVocab)
|
||||
setIsExtracting(false)
|
||||
const qualityInfo = lastQuality
|
||||
? ` | Qualitaet: ${lastQuality.quality_pct}%${lastQuality.is_degraded ? ' (degradiert!)' : ''} | Blur: ${lastQuality.blur_score} | Kontrast: ${lastQuality.contrast_score}`
|
||||
: ''
|
||||
const { vocabulary: allVocab, qualityInfo } = await reprocessPagesFlow(
|
||||
session.id, pages, ipa, syllable, ocrPrompts, ocrEnhance, ocrMaxCols, ocrMinConf, setExtractionStatus
|
||||
)
|
||||
setVocabulary(allVocab); setIsExtracting(false)
|
||||
setExtractionStatus(`${allVocab.length} Vokabeln mit neuen Einstellungen${qualityInfo}`)
|
||||
})()
|
||||
}
|
||||
|
||||
return {
|
||||
// Mounted
|
||||
mounted,
|
||||
// Theme
|
||||
isDark, glassCard, glassInput,
|
||||
// Tab
|
||||
mounted, isDark, glassCard, glassInput,
|
||||
activeTab, setActiveTab,
|
||||
// Session
|
||||
session, sessionName, setSessionName, isCreatingSession, error, setError, extractionStatus,
|
||||
// Existing sessions
|
||||
existingSessions, isLoadingSessions,
|
||||
// Documents
|
||||
storedDocuments, selectedDocumentId, setSelectedDocumentId,
|
||||
// Direct file
|
||||
directFile, setDirectFile, directFilePreview, showFullPreview, setShowFullPreview, directFileInputRef,
|
||||
// PDF pages
|
||||
pdfPageCount, selectedPages, pagesThumbnails, isLoadingThumbnails, excludedPages,
|
||||
// Extra columns
|
||||
pageExtraColumns,
|
||||
// Upload
|
||||
uploadedImage, isExtracting,
|
||||
// Vocabulary
|
||||
vocabulary,
|
||||
// Worksheet
|
||||
selectedTypes, worksheetTitle, setWorksheetTitle,
|
||||
includeSolutions, setIncludeSolutions,
|
||||
lineHeight, setLineHeight,
|
||||
selectedFormat, setSelectedFormat,
|
||||
ipaMode, setIpaMode, syllableMode, setSyllableMode,
|
||||
// Export
|
||||
includeSolutions, setIncludeSolutions, lineHeight, setLineHeight,
|
||||
selectedFormat, setSelectedFormat, ipaMode, setIpaMode, syllableMode, setSyllableMode,
|
||||
worksheetId, isGenerating,
|
||||
// Processing
|
||||
processingErrors, successfulPages, failedPages, currentlyProcessingPage,
|
||||
// OCR settings
|
||||
ocrPrompts, showSettings, setShowSettings,
|
||||
// QR
|
||||
showQRModal, setShowQRModal, uploadSessionId,
|
||||
mobileUploadedFiles, selectedMobileFile, setSelectedMobileFile, setMobileUploadedFiles,
|
||||
// OCR Comparison
|
||||
showOcrComparison, setShowOcrComparison,
|
||||
ocrComparePageIndex, ocrCompareResult, isComparingOcr, ocrCompareError,
|
||||
// Handlers
|
||||
handleDirectFileSelect, startSession, processSelectedPages,
|
||||
togglePageSelection, selectAllPages, selectNoPages, excludePage, restoreExcludedPages,
|
||||
runOcrComparison,
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
|
||||
interface GlassCardProps {
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
onClick?: () => void
|
||||
size?: 'sm' | 'md' | 'lg'
|
||||
delay?: number
|
||||
isDark?: boolean
|
||||
}
|
||||
|
||||
export function GlassCard({ children, className = '', onClick, size = 'md', delay = 0, isDark = true }: GlassCardProps) {
|
||||
const [isVisible, setIsVisible] = useState(false)
|
||||
const [isHovered, setIsHovered] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setIsVisible(true), delay)
|
||||
return () => clearTimeout(timer)
|
||||
}, [delay])
|
||||
|
||||
const sizeClasses = { sm: 'p-4', md: 'p-5', lg: 'p-6' }
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`rounded-3xl ${sizeClasses[size]} ${onClick ? 'cursor-pointer' : ''} ${className}`}
|
||||
style={{
|
||||
background: isDark
|
||||
? (isHovered ? 'rgba(255, 255, 255, 0.12)' : 'rgba(255, 255, 255, 0.08)')
|
||||
: (isHovered ? 'rgba(255, 255, 255, 0.9)' : 'rgba(255, 255, 255, 0.7)'),
|
||||
backdropFilter: 'blur(24px) saturate(180%)',
|
||||
WebkitBackdropFilter: 'blur(24px) saturate(180%)',
|
||||
border: isDark ? '1px solid rgba(255, 255, 255, 0.1)' : '1px solid rgba(0, 0, 0, 0.1)',
|
||||
boxShadow: isDark
|
||||
? '0 4px 24px rgba(0, 0, 0, 0.15), inset 0 1px 0 rgba(255, 255, 255, 0.05)'
|
||||
: '0 4px 24px rgba(0, 0, 0, 0.08), inset 0 1px 0 rgba(255, 255, 255, 0.5)',
|
||||
opacity: isVisible ? 1 : 0,
|
||||
transform: isVisible ? `translateY(0) scale(${isHovered ? 1.01 : 1})` : 'translateY(20px)',
|
||||
transition: 'all 0.4s cubic-bezier(0.34, 1.56, 0.64, 1)',
|
||||
}}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
onClick={onClick}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
'use client'
|
||||
|
||||
import { GlassCard } from './GlassCard'
|
||||
import { ProgressRing } from './ProgressRing'
|
||||
|
||||
interface PreviewResult {
|
||||
has_handwriting: boolean
|
||||
confidence: number
|
||||
handwriting_ratio: number
|
||||
image_width: number
|
||||
image_height: number
|
||||
estimated_times_ms: {
|
||||
detection: number
|
||||
inpainting: number
|
||||
reconstruction: number
|
||||
total: number
|
||||
}
|
||||
}
|
||||
|
||||
interface PreviewStepProps {
|
||||
previewResult: PreviewResult
|
||||
previewUrl: string | null
|
||||
maskUrl: string | null
|
||||
removeHandwriting: boolean
|
||||
reconstructLayout: boolean
|
||||
isProcessing: boolean
|
||||
onBack: () => void
|
||||
onCleanup: () => void
|
||||
onGetMask: () => void
|
||||
}
|
||||
|
||||
export function PreviewStep({
|
||||
previewResult, previewUrl, maskUrl,
|
||||
removeHandwriting, reconstructLayout, isProcessing,
|
||||
onBack, onCleanup, onGetMask
|
||||
}: PreviewStepProps) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<GlassCard delay={100}>
|
||||
<h3 className="text-lg font-semibold text-white mb-6">Analyse</h3>
|
||||
<div className="flex justify-around">
|
||||
<ProgressRing
|
||||
progress={previewResult.confidence * 100}
|
||||
label="Konfidenz"
|
||||
value={`${Math.round(previewResult.confidence * 100)}%`}
|
||||
color={previewResult.has_handwriting ? '#f97316' : '#22c55e'}
|
||||
/>
|
||||
<ProgressRing
|
||||
progress={previewResult.handwriting_ratio * 100 * 10}
|
||||
label="Handschrift"
|
||||
value={`${(previewResult.handwriting_ratio * 100).toFixed(1)}%`}
|
||||
color="#a78bfa"
|
||||
/>
|
||||
</div>
|
||||
<div className={`mt-6 p-4 rounded-xl text-center ${
|
||||
previewResult.has_handwriting ? 'bg-orange-500/20 text-orange-300' : 'bg-green-500/20 text-green-300'
|
||||
}`}>
|
||||
{previewResult.has_handwriting ? 'Handschrift erkannt' : 'Keine Handschrift gefunden'}
|
||||
</div>
|
||||
</GlassCard>
|
||||
|
||||
<GlassCard delay={200}>
|
||||
<h3 className="text-lg font-semibold text-white mb-4">Geschätzte Zeit</h3>
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between text-white/70">
|
||||
<span>Erkennung</span>
|
||||
<span className="text-white">~{Math.round(previewResult.estimated_times_ms.detection / 1000)}s</span>
|
||||
</div>
|
||||
{removeHandwriting && previewResult.has_handwriting && (
|
||||
<div className="flex justify-between text-white/70">
|
||||
<span>Bereinigung</span>
|
||||
<span className="text-white">~{Math.round(previewResult.estimated_times_ms.inpainting / 1000)}s</span>
|
||||
</div>
|
||||
)}
|
||||
{reconstructLayout && (
|
||||
<div className="flex justify-between text-white/70">
|
||||
<span>Layout</span>
|
||||
<span className="text-white">~{Math.round(previewResult.estimated_times_ms.reconstruction / 1000)}s</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between pt-3 border-t border-white/10 font-medium">
|
||||
<span className="text-white">Gesamt</span>
|
||||
<span className="text-purple-300">~{Math.round(previewResult.estimated_times_ms.total / 1000)}s</span>
|
||||
</div>
|
||||
</div>
|
||||
</GlassCard>
|
||||
|
||||
<GlassCard delay={300}>
|
||||
<h3 className="text-lg font-semibold text-white mb-4">Bild-Info</h3>
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between text-white/70"><span>Breite</span><span className="text-white">{previewResult.image_width}px</span></div>
|
||||
<div className="flex justify-between text-white/70"><span>Höhe</span><span className="text-white">{previewResult.image_height}px</span></div>
|
||||
<div className="flex justify-between text-white/70"><span>Pixel</span><span className="text-white">{(previewResult.image_width * previewResult.image_height / 1000000).toFixed(1)}MP</span></div>
|
||||
</div>
|
||||
<button onClick={onGetMask}
|
||||
className="w-full mt-4 px-4 py-2 rounded-xl bg-white/10 text-white/70 hover:bg-white/20 hover:text-white transition-all text-sm">
|
||||
Maske anzeigen
|
||||
</button>
|
||||
</GlassCard>
|
||||
|
||||
<GlassCard className="col-span-1 lg:col-span-2" delay={400}>
|
||||
<h3 className="text-lg font-semibold text-white mb-4">Original</h3>
|
||||
{previewUrl && <img src={previewUrl} alt="Original" className="w-full max-h-96 object-contain rounded-xl" />}
|
||||
</GlassCard>
|
||||
|
||||
{maskUrl && (
|
||||
<GlassCard delay={500}>
|
||||
<h3 className="text-lg font-semibold text-white mb-4">Maske</h3>
|
||||
<img src={maskUrl} alt="Mask" className="w-full max-h-96 object-contain rounded-xl" />
|
||||
</GlassCard>
|
||||
)}
|
||||
|
||||
<div className="col-span-1 lg:col-span-3 flex justify-center gap-4">
|
||||
<button onClick={onBack}
|
||||
className="px-6 py-3 rounded-xl bg-white/10 text-white hover:bg-white/20 transition-all flex items-center gap-2">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" /></svg>
|
||||
Zurück
|
||||
</button>
|
||||
<button onClick={onCleanup} disabled={isProcessing}
|
||||
className="px-8 py-3 rounded-xl font-semibold bg-gradient-to-r from-orange-500 to-red-500 text-white hover:shadow-lg hover:shadow-orange-500/30 transition-all disabled:opacity-50 flex items-center gap-2">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19.428 15.428a2 2 0 00-1.022-.547l-2.387-.477a6 6 0 00-3.86.517l-.318.158a6 6 0 01-3.86.517L6.05 15.21a2 2 0 00-1.806.547M8 4h8l-1 1v5.172a2 2 0 00.586 1.414l5 5c1.26 1.26.367 3.414-1.415 3.414H4.828c-1.782 0-2.674-2.154-1.414-3.414l5-5A2 2 0 009 10.172V5L8 4z" /></svg>
|
||||
Bereinigen starten
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
'use client'
|
||||
|
||||
interface ProgressRingProps {
|
||||
progress: number
|
||||
size?: number
|
||||
strokeWidth?: number
|
||||
label: string
|
||||
value: string
|
||||
color?: string
|
||||
}
|
||||
|
||||
export function ProgressRing({
|
||||
progress,
|
||||
size = 80,
|
||||
strokeWidth = 6,
|
||||
label,
|
||||
value,
|
||||
color = '#a78bfa'
|
||||
}: ProgressRingProps) {
|
||||
const radius = (size - strokeWidth) / 2
|
||||
const circumference = radius * 2 * Math.PI
|
||||
const offset = circumference - (progress / 100) * circumference
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="relative" style={{ width: size, height: size }}>
|
||||
<svg className="transform -rotate-90" width={size} height={size}>
|
||||
<circle
|
||||
cx={size / 2} cy={size / 2} r={radius}
|
||||
fill="none" stroke="rgba(255, 255, 255, 0.1)" strokeWidth={strokeWidth}
|
||||
/>
|
||||
<circle
|
||||
cx={size / 2} cy={size / 2} r={radius}
|
||||
fill="none" stroke={color} strokeWidth={strokeWidth}
|
||||
strokeDasharray={circumference} strokeDashoffset={offset}
|
||||
strokeLinecap="round"
|
||||
style={{ transition: 'stroke-dashoffset 0.5s ease' }}
|
||||
/>
|
||||
</svg>
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<span className="text-lg font-bold text-white">{value}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span className="mt-2 text-xs text-white/50">{label}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
'use client'
|
||||
|
||||
import { GlassCard } from './GlassCard'
|
||||
|
||||
interface PipelineResult {
|
||||
success: boolean
|
||||
handwriting_detected: boolean
|
||||
handwriting_removed: boolean
|
||||
layout_reconstructed: boolean
|
||||
cleaned_image_base64?: string
|
||||
fabric_json?: any
|
||||
metadata: any
|
||||
}
|
||||
|
||||
interface ResultStepProps {
|
||||
pipelineResult: PipelineResult
|
||||
previewUrl: string | null
|
||||
cleanedUrl: string | null
|
||||
onReset: () => void
|
||||
onOpenInEditor: () => void
|
||||
}
|
||||
|
||||
export function ResultStep({ pipelineResult, previewUrl, cleanedUrl, onReset, onOpenInEditor }: ResultStepProps) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<GlassCard className="col-span-1 lg:col-span-2" delay={100}>
|
||||
<div className={`flex items-center gap-4 ${pipelineResult.success ? 'text-green-300' : 'text-red-300'}`}>
|
||||
<div className={`w-12 h-12 rounded-full flex items-center justify-center ${pipelineResult.success ? 'bg-green-500/20' : 'bg-red-500/20'}`}>
|
||||
{pipelineResult.success ? (
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" /></svg>
|
||||
) : (
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /></svg>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold">
|
||||
{pipelineResult.success ? 'Erfolgreich bereinigt!' : 'Verarbeitung fehlgeschlagen'}
|
||||
</h3>
|
||||
<p className="text-white/50">
|
||||
{pipelineResult.handwriting_removed
|
||||
? `Handschrift wurde entfernt. ${pipelineResult.metadata?.layout?.element_count || 0} Elemente erkannt.`
|
||||
: pipelineResult.handwriting_detected
|
||||
? 'Handschrift erkannt, aber nicht entfernt'
|
||||
: 'Keine Handschrift im Bild gefunden'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</GlassCard>
|
||||
|
||||
<GlassCard delay={200}>
|
||||
<h3 className="text-lg font-semibold text-white mb-4">Original</h3>
|
||||
{previewUrl && <img src={previewUrl} alt="Original" className="w-full rounded-xl" />}
|
||||
</GlassCard>
|
||||
|
||||
<GlassCard delay={300}>
|
||||
<h3 className="text-lg font-semibold text-white mb-4">Bereinigt</h3>
|
||||
{cleanedUrl ? (
|
||||
<img src={cleanedUrl} alt="Cleaned" className="w-full rounded-xl" />
|
||||
) : (
|
||||
<div className="aspect-video rounded-xl bg-white/5 flex items-center justify-center text-white/40">Kein Bild</div>
|
||||
)}
|
||||
</GlassCard>
|
||||
|
||||
<div className="col-span-1 lg:col-span-2 flex justify-center gap-4">
|
||||
<button onClick={onReset}
|
||||
className="px-6 py-3 rounded-xl bg-white/10 text-white hover:bg-white/20 transition-all flex items-center gap-2">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" /></svg>
|
||||
Neues Bild
|
||||
</button>
|
||||
{cleanedUrl && (
|
||||
<a href={cleanedUrl} download="bereinigt.png"
|
||||
className="px-6 py-3 rounded-xl bg-white/10 text-white hover:bg-white/20 transition-all flex items-center gap-2">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" /></svg>
|
||||
Download
|
||||
</a>
|
||||
)}
|
||||
{pipelineResult.layout_reconstructed && pipelineResult.fabric_json && (
|
||||
<button onClick={onOpenInEditor}
|
||||
className="px-8 py-3 rounded-xl font-semibold bg-gradient-to-r from-green-500 to-emerald-500 text-white hover:shadow-lg hover:shadow-green-500/30 transition-all flex items-center gap-2">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" /></svg>
|
||||
Im Editor öffnen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
'use client'
|
||||
|
||||
import { GlassCard } from './GlassCard'
|
||||
|
||||
interface UploadStepProps {
|
||||
isDark: boolean
|
||||
previewUrl: string | null
|
||||
file: File | null
|
||||
removeHandwriting: boolean
|
||||
setRemoveHandwriting: (v: boolean) => void
|
||||
reconstructLayout: boolean
|
||||
setReconstructLayout: (v: boolean) => void
|
||||
inpaintingMethod: string
|
||||
setInpaintingMethod: (v: string) => void
|
||||
isPreviewing: boolean
|
||||
onDrop: (e: React.DragEvent) => void
|
||||
onFileSelect: (file: File) => void
|
||||
onPreview: () => void
|
||||
onQRClick: () => void
|
||||
}
|
||||
|
||||
export function UploadStep({
|
||||
isDark, previewUrl, file,
|
||||
removeHandwriting, setRemoveHandwriting,
|
||||
reconstructLayout, setReconstructLayout,
|
||||
inpaintingMethod, setInpaintingMethod,
|
||||
isPreviewing, onDrop, onFileSelect, onPreview, onQRClick
|
||||
}: UploadStepProps) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<GlassCard className="col-span-1" delay={100}>
|
||||
<div
|
||||
className="border-2 border-dashed border-white/20 rounded-2xl p-8 text-center cursor-pointer transition-all hover:border-purple-400/50 hover:bg-white/5 min-h-[280px] flex flex-col items-center justify-center"
|
||||
onDrop={onDrop}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onClick={() => document.getElementById('file-input')?.click()}
|
||||
>
|
||||
<input id="file-input" type="file" accept="image/*"
|
||||
onChange={(e) => e.target.files?.[0] && onFileSelect(e.target.files[0])}
|
||||
className="hidden" />
|
||||
{previewUrl ? (
|
||||
<div className="space-y-4">
|
||||
<img src={previewUrl} alt="Preview" className="max-h-40 mx-auto rounded-xl shadow-2xl" />
|
||||
<p className="text-white font-medium text-sm">{file?.name}</p>
|
||||
<p className="text-white/50 text-xs">Klicke zum Ändern</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<svg className="w-16 h-16 mx-auto mb-4 text-white/30" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
|
||||
</svg>
|
||||
<p className="text-xl font-semibold text-white mb-2">Datei auswählen</p>
|
||||
<p className="text-white/50 text-sm mb-2">Ziehe ein Bild hierher oder klicke</p>
|
||||
<p className="text-white/30 text-xs">PNG, JPG, JPEG</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</GlassCard>
|
||||
|
||||
<GlassCard className="col-span-1" delay={150}>
|
||||
<div
|
||||
className="border-2 border-dashed border-white/20 rounded-2xl p-8 text-center cursor-pointer transition-all hover:border-purple-400/50 hover:bg-white/5 min-h-[280px] flex flex-col items-center justify-center"
|
||||
onClick={onQRClick}
|
||||
>
|
||||
<div className="w-16 h-16 mx-auto mb-4 rounded-xl bg-purple-500/20 flex items-center justify-center">
|
||||
<span className="text-3xl">📱</span>
|
||||
</div>
|
||||
<p className="text-xl font-semibold text-white mb-2">Mit Handy scannen</p>
|
||||
<p className="text-white/50 text-sm mb-2">QR-Code scannen um Foto hochzuladen</p>
|
||||
<p className="text-white/30 text-xs">Im lokalen Netzwerk</p>
|
||||
</div>
|
||||
</GlassCard>
|
||||
|
||||
{file && (
|
||||
<>
|
||||
<GlassCard delay={200}>
|
||||
<h3 className="text-lg font-semibold text-white mb-4">Optionen</h3>
|
||||
<div className="space-y-4">
|
||||
<label className="flex items-center gap-4 cursor-pointer group">
|
||||
<input type="checkbox" checked={removeHandwriting} onChange={(e) => setRemoveHandwriting(e.target.checked)}
|
||||
className="w-5 h-5 rounded bg-white/10 border-white/20 text-purple-500 focus:ring-purple-500" />
|
||||
<div>
|
||||
<span className="text-white font-medium group-hover:text-purple-300 transition-colors">Handschrift entfernen</span>
|
||||
<p className="text-white/40 text-sm">Erkennt und entfernt handgeschriebene Inhalte</p>
|
||||
</div>
|
||||
</label>
|
||||
<label className="flex items-center gap-4 cursor-pointer group">
|
||||
<input type="checkbox" checked={reconstructLayout} onChange={(e) => setReconstructLayout(e.target.checked)}
|
||||
className="w-5 h-5 rounded bg-white/10 border-white/20 text-purple-500 focus:ring-purple-500" />
|
||||
<div>
|
||||
<span className="text-white font-medium group-hover:text-purple-300 transition-colors">Layout rekonstruieren</span>
|
||||
<p className="text-white/40 text-sm">Erstellt bearbeitbare Textblöcke</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</GlassCard>
|
||||
|
||||
<GlassCard delay={300}>
|
||||
<h3 className="text-lg font-semibold text-white mb-4">Methode</h3>
|
||||
<select value={inpaintingMethod} onChange={(e) => setInpaintingMethod(e.target.value)}
|
||||
className="w-full p-3 rounded-xl bg-white/10 border border-white/20 text-white focus:ring-2 focus:ring-purple-500 focus:border-transparent">
|
||||
<option value="auto">Automatisch (empfohlen)</option>
|
||||
<option value="opencv_telea">OpenCV Telea (schnell)</option>
|
||||
<option value="opencv_ns">OpenCV NS (glatter)</option>
|
||||
</select>
|
||||
<p className="text-white/40 text-sm mt-3">
|
||||
Die automatische Methode wählt die beste Option basierend auf dem Bildinhalt.
|
||||
</p>
|
||||
</GlassCard>
|
||||
|
||||
<div className="col-span-1 lg:col-span-2 flex justify-center">
|
||||
<button onClick={onPreview} disabled={isPreviewing}
|
||||
className="px-8 py-4 rounded-2xl font-semibold text-lg bg-gradient-to-r from-purple-500 to-pink-500 text-white hover:shadow-lg hover:shadow-purple-500/30 transition-all disabled:opacity-50 flex items-center gap-3">
|
||||
{isPreviewing ? (
|
||||
<><div className="w-5 h-5 border-2 border-white border-t-transparent rounded-full animate-spin" />Analysiere...</>
|
||||
) : (
|
||||
<><svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" /></svg>Vorschau</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -7,220 +7,56 @@ import { Sidebar } from '@/components/Sidebar'
|
||||
import { ThemeToggle } from '@/components/ThemeToggle'
|
||||
import { LanguageDropdown } from '@/components/LanguageDropdown'
|
||||
import { QRCodeUpload, UploadedFile } from '@/components/QRCodeUpload'
|
||||
import { GlassCard } from './_components/GlassCard'
|
||||
import { UploadStep } from './_components/UploadStep'
|
||||
import { PreviewStep } from './_components/PreviewStep'
|
||||
import { ResultStep } from './_components/ResultStep'
|
||||
|
||||
// LocalStorage Key for upload session
|
||||
const SESSION_ID_KEY = 'bp_cleanup_session'
|
||||
|
||||
/**
|
||||
* Worksheet Cleanup Page - Apple Weather Dashboard Style
|
||||
*
|
||||
* Design principles:
|
||||
* - Dark gradient background
|
||||
* - Ultra-translucent glass cards (~8% opacity)
|
||||
* - White text, monochrome palette
|
||||
* - Step-by-step cleanup wizard
|
||||
*/
|
||||
|
||||
// =============================================================================
|
||||
// GLASS CARD - Ultra Transparent
|
||||
// =============================================================================
|
||||
|
||||
interface GlassCardProps {
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
onClick?: () => void
|
||||
size?: 'sm' | 'md' | 'lg'
|
||||
delay?: number
|
||||
}
|
||||
|
||||
function GlassCard({ children, className = '', onClick, size = 'md', delay = 0, isDark = true }: GlassCardProps & { isDark?: boolean }) {
|
||||
const [isVisible, setIsVisible] = useState(false)
|
||||
const [isHovered, setIsHovered] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setIsVisible(true), delay)
|
||||
return () => clearTimeout(timer)
|
||||
}, [delay])
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'p-4',
|
||||
md: 'p-5',
|
||||
lg: 'p-6',
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
rounded-3xl
|
||||
${sizeClasses[size]}
|
||||
${onClick ? 'cursor-pointer' : ''}
|
||||
${className}
|
||||
`}
|
||||
style={{
|
||||
background: isDark
|
||||
? (isHovered ? 'rgba(255, 255, 255, 0.12)' : 'rgba(255, 255, 255, 0.08)')
|
||||
: (isHovered ? 'rgba(255, 255, 255, 0.9)' : 'rgba(255, 255, 255, 0.7)'),
|
||||
backdropFilter: 'blur(24px) saturate(180%)',
|
||||
WebkitBackdropFilter: 'blur(24px) saturate(180%)',
|
||||
border: isDark ? '1px solid rgba(255, 255, 255, 0.1)' : '1px solid rgba(0, 0, 0, 0.1)',
|
||||
boxShadow: isDark
|
||||
? '0 4px 24px rgba(0, 0, 0, 0.15), inset 0 1px 0 rgba(255, 255, 255, 0.05)'
|
||||
: '0 4px 24px rgba(0, 0, 0, 0.08), inset 0 1px 0 rgba(255, 255, 255, 0.5)',
|
||||
opacity: isVisible ? 1 : 0,
|
||||
transform: isVisible
|
||||
? `translateY(0) scale(${isHovered ? 1.01 : 1})`
|
||||
: 'translateY(20px)',
|
||||
transition: 'all 0.4s cubic-bezier(0.34, 1.56, 0.64, 1)',
|
||||
}}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
onClick={onClick}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// PROGRESS RING
|
||||
// =============================================================================
|
||||
|
||||
interface ProgressRingProps {
|
||||
progress: number
|
||||
size?: number
|
||||
strokeWidth?: number
|
||||
label: string
|
||||
value: string
|
||||
color?: string
|
||||
}
|
||||
|
||||
function ProgressRing({
|
||||
progress,
|
||||
size = 80,
|
||||
strokeWidth = 6,
|
||||
label,
|
||||
value,
|
||||
color = '#a78bfa'
|
||||
}: ProgressRingProps) {
|
||||
const radius = (size - strokeWidth) / 2
|
||||
const circumference = radius * 2 * Math.PI
|
||||
const offset = circumference - (progress / 100) * circumference
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="relative" style={{ width: size, height: size }}>
|
||||
<svg className="transform -rotate-90" width={size} height={size}>
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke="rgba(255, 255, 255, 0.1)"
|
||||
strokeWidth={strokeWidth}
|
||||
/>
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth={strokeWidth}
|
||||
strokeDasharray={circumference}
|
||||
strokeDashoffset={offset}
|
||||
strokeLinecap="round"
|
||||
style={{ transition: 'stroke-dashoffset 0.5s ease' }}
|
||||
/>
|
||||
</svg>
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<span className="text-lg font-bold text-white">{value}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span className="mt-2 text-xs text-white/50">{label}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// TYPES
|
||||
// =============================================================================
|
||||
|
||||
interface PreviewResult {
|
||||
has_handwriting: boolean
|
||||
confidence: number
|
||||
handwriting_ratio: number
|
||||
image_width: number
|
||||
image_height: number
|
||||
estimated_times_ms: {
|
||||
detection: number
|
||||
inpainting: number
|
||||
reconstruction: number
|
||||
total: number
|
||||
}
|
||||
has_handwriting: boolean; confidence: number; handwriting_ratio: number
|
||||
image_width: number; image_height: number
|
||||
estimated_times_ms: { detection: number; inpainting: number; reconstruction: number; total: number }
|
||||
}
|
||||
|
||||
interface PipelineResult {
|
||||
success: boolean
|
||||
handwriting_detected: boolean
|
||||
handwriting_removed: boolean
|
||||
layout_reconstructed: boolean
|
||||
cleaned_image_base64?: string
|
||||
fabric_json?: any
|
||||
metadata: any
|
||||
success: boolean; handwriting_detected: boolean; handwriting_removed: boolean
|
||||
layout_reconstructed: boolean; cleaned_image_base64?: string; fabric_json?: any; metadata: any
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// MAIN PAGE
|
||||
// =============================================================================
|
||||
|
||||
export default function WorksheetCleanupPage() {
|
||||
const { isDark } = useTheme()
|
||||
const router = useRouter()
|
||||
|
||||
// File state
|
||||
const [file, setFile] = useState<File | null>(null)
|
||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null)
|
||||
const [cleanedUrl, setCleanedUrl] = useState<string | null>(null)
|
||||
const [maskUrl, setMaskUrl] = useState<string | null>(null)
|
||||
|
||||
// Loading states
|
||||
const [isPreviewing, setIsPreviewing] = useState(false)
|
||||
const [isProcessing, setIsProcessing] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Results
|
||||
const [previewResult, setPreviewResult] = useState<PreviewResult | null>(null)
|
||||
const [pipelineResult, setPipelineResult] = useState<PipelineResult | null>(null)
|
||||
|
||||
// Options
|
||||
const [removeHandwriting, setRemoveHandwriting] = useState(true)
|
||||
const [reconstructLayout, setReconstructLayout] = useState(true)
|
||||
const [inpaintingMethod, setInpaintingMethod] = useState<string>('auto')
|
||||
|
||||
// Step tracking
|
||||
const [currentStep, setCurrentStep] = useState<'upload' | 'preview' | 'processing' | 'result'>('upload')
|
||||
|
||||
// QR Code Upload
|
||||
const [showQRModal, setShowQRModal] = useState(false)
|
||||
const [uploadSessionId, setUploadSessionId] = useState('')
|
||||
const [mobileUploadedFiles, setMobileUploadedFiles] = useState<UploadedFile[]>([])
|
||||
|
||||
// Format file size
|
||||
const formatFileSize = (bytes: number): string => {
|
||||
if (bytes === 0) return '0 B'
|
||||
const k = 1024
|
||||
const sizes = ['B', 'KB', 'MB', 'GB']
|
||||
const k = 1024; const sizes = ['B', 'KB', 'MB', 'GB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]
|
||||
}
|
||||
|
||||
// Initialize upload session ID
|
||||
useEffect(() => {
|
||||
let storedSessionId = localStorage.getItem(SESSION_ID_KEY)
|
||||
if (!storedSessionId) {
|
||||
storedSessionId = `cleanup-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
|
||||
localStorage.setItem(SESSION_ID_KEY, storedSessionId)
|
||||
}
|
||||
setUploadSessionId(storedSessionId)
|
||||
let sid = localStorage.getItem(SESSION_ID_KEY)
|
||||
if (!sid) { sid = `cleanup-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; localStorage.setItem(SESSION_ID_KEY, sid) }
|
||||
setUploadSessionId(sid)
|
||||
}, [])
|
||||
|
||||
const getApiUrl = useCallback(() => {
|
||||
@@ -229,661 +65,182 @@ export default function WorksheetCleanupPage() {
|
||||
return hostname === 'localhost' ? 'http://localhost:8086' : `${protocol}//${hostname}:8086`
|
||||
}, [])
|
||||
|
||||
// Handle file selection
|
||||
const handleFileSelect = useCallback((selectedFile: File) => {
|
||||
setFile(selectedFile)
|
||||
setError(null)
|
||||
setPreviewResult(null)
|
||||
setPipelineResult(null)
|
||||
setCleanedUrl(null)
|
||||
setMaskUrl(null)
|
||||
|
||||
const url = URL.createObjectURL(selectedFile)
|
||||
setPreviewUrl(url)
|
||||
setCurrentStep('upload')
|
||||
setFile(selectedFile); setError(null); setPreviewResult(null); setPipelineResult(null)
|
||||
setCleanedUrl(null); setMaskUrl(null)
|
||||
setPreviewUrl(URL.createObjectURL(selectedFile)); setCurrentStep('upload')
|
||||
}, [])
|
||||
|
||||
// Handle mobile file selection - convert to File and trigger handleFileSelect
|
||||
const handleMobileFileSelect = useCallback(async (uploadedFile: UploadedFile) => {
|
||||
try {
|
||||
const base64Data = uploadedFile.dataUrl.split(',')[1]
|
||||
const byteCharacters = atob(base64Data)
|
||||
const byteNumbers = new Array(byteCharacters.length)
|
||||
for (let i = 0; i < byteCharacters.length; i++) {
|
||||
byteNumbers[i] = byteCharacters.charCodeAt(i)
|
||||
}
|
||||
const byteArray = new Uint8Array(byteNumbers)
|
||||
const blob = new Blob([byteArray], { type: uploadedFile.type })
|
||||
const file = new File([blob], uploadedFile.name, { type: uploadedFile.type })
|
||||
handleFileSelect(file)
|
||||
for (let i = 0; i < byteCharacters.length; i++) byteNumbers[i] = byteCharacters.charCodeAt(i)
|
||||
const blob = new Blob([new Uint8Array(byteNumbers)], { type: uploadedFile.type })
|
||||
handleFileSelect(new File([blob], uploadedFile.name, { type: uploadedFile.type }))
|
||||
setShowQRModal(false)
|
||||
} catch (error) {
|
||||
console.error('Failed to convert mobile file:', error)
|
||||
setError('Fehler beim Laden der Datei vom Handy')
|
||||
}
|
||||
} catch { setError('Fehler beim Laden der Datei vom Handy') }
|
||||
}, [handleFileSelect])
|
||||
|
||||
// Handle drop
|
||||
const handleDrop = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
const droppedFile = e.dataTransfer.files[0]
|
||||
if (droppedFile && droppedFile.type.startsWith('image/')) {
|
||||
handleFileSelect(droppedFile)
|
||||
}
|
||||
const f = e.dataTransfer.files[0]
|
||||
if (f && f.type.startsWith('image/')) handleFileSelect(f)
|
||||
}, [handleFileSelect])
|
||||
|
||||
// Preview cleanup
|
||||
const handlePreview = useCallback(async () => {
|
||||
if (!file) return
|
||||
|
||||
setIsPreviewing(true)
|
||||
setError(null)
|
||||
|
||||
if (!file) return; setIsPreviewing(true); setError(null)
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('image', file)
|
||||
|
||||
const response = await fetch(`${getApiUrl()}/api/v1/worksheet/preview-cleanup`, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`)
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
setPreviewResult(result)
|
||||
setCurrentStep('preview')
|
||||
} catch (err) {
|
||||
console.error('Preview failed:', err)
|
||||
setError(err instanceof Error ? err.message : 'Vorschau fehlgeschlagen')
|
||||
} finally {
|
||||
setIsPreviewing(false)
|
||||
}
|
||||
const fd = new FormData(); fd.append('image', file)
|
||||
const res = await fetch(`${getApiUrl()}/api/v1/worksheet/preview-cleanup`, { method: 'POST', body: fd })
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
setPreviewResult(await res.json()); setCurrentStep('preview')
|
||||
} catch (err) { setError(err instanceof Error ? err.message : 'Vorschau fehlgeschlagen') }
|
||||
finally { setIsPreviewing(false) }
|
||||
}, [file, getApiUrl])
|
||||
|
||||
// Run full cleanup pipeline
|
||||
const handleCleanup = useCallback(async () => {
|
||||
if (!file) return
|
||||
|
||||
setIsProcessing(true)
|
||||
setCurrentStep('processing')
|
||||
setError(null)
|
||||
|
||||
if (!file) return; setIsProcessing(true); setCurrentStep('processing'); setError(null)
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('image', file)
|
||||
formData.append('remove_handwriting', String(removeHandwriting))
|
||||
formData.append('reconstruct', String(reconstructLayout))
|
||||
formData.append('inpainting_method', inpaintingMethod)
|
||||
|
||||
const response = await fetch(`${getApiUrl()}/api/v1/worksheet/cleanup-pipeline`, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({ detail: 'Unknown error' }))
|
||||
throw new Error(errorData.detail || `HTTP ${response.status}`)
|
||||
}
|
||||
|
||||
const result: PipelineResult = await response.json()
|
||||
setPipelineResult(result)
|
||||
|
||||
// Create cleaned image URL
|
||||
const fd = new FormData(); fd.append('image', file)
|
||||
fd.append('remove_handwriting', String(removeHandwriting))
|
||||
fd.append('reconstruct', String(reconstructLayout)); fd.append('inpainting_method', inpaintingMethod)
|
||||
const res = await fetch(`${getApiUrl()}/api/v1/worksheet/cleanup-pipeline`, { method: 'POST', body: fd })
|
||||
if (!res.ok) { const ed = await res.json().catch(() => ({ detail: 'Unknown error' })); throw new Error(ed.detail || `HTTP ${res.status}`) }
|
||||
const result: PipelineResult = await res.json(); setPipelineResult(result)
|
||||
if (result.cleaned_image_base64) {
|
||||
const cleanedBlob = await fetch(`data:image/png;base64,${result.cleaned_image_base64}`).then(r => r.blob())
|
||||
setCleanedUrl(URL.createObjectURL(cleanedBlob))
|
||||
const blob = await fetch(`data:image/png;base64,${result.cleaned_image_base64}`).then(r => r.blob())
|
||||
setCleanedUrl(URL.createObjectURL(blob))
|
||||
}
|
||||
|
||||
setCurrentStep('result')
|
||||
} catch (err) {
|
||||
console.error('Cleanup failed:', err)
|
||||
setError(err instanceof Error ? err.message : 'Bereinigung fehlgeschlagen')
|
||||
setCurrentStep('preview')
|
||||
} finally {
|
||||
setIsProcessing(false)
|
||||
}
|
||||
} catch (err) { setError(err instanceof Error ? err.message : 'Bereinigung fehlgeschlagen'); setCurrentStep('preview') }
|
||||
finally { setIsProcessing(false) }
|
||||
}, [file, removeHandwriting, reconstructLayout, inpaintingMethod, getApiUrl])
|
||||
|
||||
// Get detection mask
|
||||
const handleGetMask = useCallback(async () => {
|
||||
if (!file) return
|
||||
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('image', file)
|
||||
|
||||
const response = await fetch(`${getApiUrl()}/api/v1/worksheet/detect-handwriting/mask`, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`)
|
||||
}
|
||||
|
||||
const blob = await response.blob()
|
||||
setMaskUrl(URL.createObjectURL(blob))
|
||||
} catch (err) {
|
||||
console.error('Mask fetch failed:', err)
|
||||
}
|
||||
const fd = new FormData(); fd.append('image', file)
|
||||
const res = await fetch(`${getApiUrl()}/api/v1/worksheet/detect-handwriting/mask`, { method: 'POST', body: fd })
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
setMaskUrl(URL.createObjectURL(await res.blob()))
|
||||
} catch (err) { console.error('Mask fetch failed:', err) }
|
||||
}, [file, getApiUrl])
|
||||
|
||||
// Open in worksheet editor
|
||||
const handleOpenInEditor = useCallback(() => {
|
||||
if (pipelineResult?.fabric_json) {
|
||||
// Store the fabric JSON in sessionStorage
|
||||
sessionStorage.setItem('worksheetCleanupResult', JSON.stringify(pipelineResult.fabric_json))
|
||||
router.push('/worksheet-editor')
|
||||
}
|
||||
}, [pipelineResult, router])
|
||||
|
||||
// Reset to start
|
||||
const handleReset = useCallback(() => {
|
||||
setFile(null)
|
||||
setPreviewUrl(null)
|
||||
setCleanedUrl(null)
|
||||
setMaskUrl(null)
|
||||
setPreviewResult(null)
|
||||
setPipelineResult(null)
|
||||
setError(null)
|
||||
setCurrentStep('upload')
|
||||
setFile(null); setPreviewUrl(null); setCleanedUrl(null); setMaskUrl(null)
|
||||
setPreviewResult(null); setPipelineResult(null); setError(null); setCurrentStep('upload')
|
||||
}, [])
|
||||
|
||||
const steps = ['upload', 'preview', 'processing', 'result'] as const
|
||||
const currentStepIdx = steps.indexOf(currentStep)
|
||||
|
||||
return (
|
||||
<div className={`min-h-screen flex relative overflow-hidden ${
|
||||
isDark
|
||||
? 'bg-gradient-to-br from-indigo-900 via-purple-900 to-pink-800'
|
||||
isDark ? 'bg-gradient-to-br from-indigo-900 via-purple-900 to-pink-800'
|
||||
: 'bg-gradient-to-br from-slate-100 via-blue-50 to-indigo-100'
|
||||
}`}>
|
||||
{/* Animated Background Blobs */}
|
||||
<div className={`absolute -top-40 -right-40 w-96 h-96 rounded-full mix-blend-multiply filter blur-3xl animate-blob ${isDark ? 'bg-purple-500 opacity-30' : 'bg-purple-300 opacity-40'}`} />
|
||||
<div className={`absolute top-1/2 -left-40 w-96 h-96 rounded-full mix-blend-multiply filter blur-3xl animate-blob animation-delay-2000 ${isDark ? 'bg-pink-500 opacity-30' : 'bg-pink-300 opacity-40'}`} />
|
||||
<div className={`absolute -bottom-40 right-1/3 w-96 h-96 rounded-full mix-blend-multiply filter blur-3xl animate-blob animation-delay-4000 ${isDark ? 'bg-blue-500 opacity-30' : 'bg-blue-300 opacity-40'}`} />
|
||||
|
||||
{/* Sidebar */}
|
||||
<div className="relative z-10 p-4">
|
||||
<Sidebar />
|
||||
</div>
|
||||
<div className="relative z-10 p-4"><Sidebar /></div>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="flex-1 flex flex-col relative z-10 p-6 overflow-y-auto">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<h1 className={`text-3xl font-bold mb-2 ${isDark ? 'text-white' : 'text-slate-900'}`}>Arbeitsblatt bereinigen</h1>
|
||||
<p className={isDark ? 'text-white/50' : 'text-slate-500'}>Handschrift entfernen und Layout rekonstruieren</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<ThemeToggle />
|
||||
<LanguageDropdown />
|
||||
</div>
|
||||
<div className="flex items-center gap-3"><ThemeToggle /><LanguageDropdown /></div>
|
||||
</div>
|
||||
|
||||
{/* Step Indicator */}
|
||||
<div className="flex items-center justify-center gap-4 mb-8">
|
||||
{['upload', 'preview', 'processing', 'result'].map((step, idx) => (
|
||||
{steps.map((step, idx) => (
|
||||
<div key={step} className="flex items-center">
|
||||
<div className={`
|
||||
w-10 h-10 rounded-full flex items-center justify-center font-medium transition-all
|
||||
${currentStep === step
|
||||
? 'bg-purple-500 text-white shadow-lg shadow-purple-500/50'
|
||||
: ['upload', 'preview', 'processing', 'result'].indexOf(currentStep) > idx
|
||||
? 'bg-green-500 text-white'
|
||||
: isDark ? 'bg-white/10 text-white/40' : 'bg-slate-200 text-slate-400'
|
||||
}
|
||||
`}>
|
||||
{['upload', 'preview', 'processing', 'result'].indexOf(currentStep) > idx ? (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
) : (
|
||||
idx + 1
|
||||
)}
|
||||
<div className={`w-10 h-10 rounded-full flex items-center justify-center font-medium transition-all ${
|
||||
currentStep === step ? 'bg-purple-500 text-white shadow-lg shadow-purple-500/50'
|
||||
: currentStepIdx > idx ? 'bg-green-500 text-white'
|
||||
: isDark ? 'bg-white/10 text-white/40' : 'bg-slate-200 text-slate-400'
|
||||
}`}>
|
||||
{currentStepIdx > idx ? (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" /></svg>
|
||||
) : idx + 1}
|
||||
</div>
|
||||
{idx < 3 && (
|
||||
<div className={`w-16 h-0.5 mx-2 ${
|
||||
['upload', 'preview', 'processing', 'result'].indexOf(currentStep) > idx
|
||||
? 'bg-green-500'
|
||||
: isDark ? 'bg-white/20' : 'bg-slate-300'
|
||||
}`} />
|
||||
)}
|
||||
{idx < 3 && <div className={`w-16 h-0.5 mx-2 ${currentStepIdx > idx ? 'bg-green-500' : isDark ? 'bg-white/20' : 'bg-slate-300'}`} />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Error Display */}
|
||||
{error && (
|
||||
<GlassCard className="mb-6" size="sm" isDark={isDark}>
|
||||
<div className="flex items-center gap-3 text-red-400">
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
</GlassCard>
|
||||
)}
|
||||
|
||||
{/* Content based on step */}
|
||||
<div className="flex-1">
|
||||
{/* Step 1: Upload */}
|
||||
{currentStep === 'upload' && (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Upload Options - File and QR Code side by side */}
|
||||
<GlassCard className="col-span-1" delay={100}>
|
||||
<div
|
||||
className="border-2 border-dashed border-white/20 rounded-2xl p-8 text-center cursor-pointer transition-all hover:border-purple-400/50 hover:bg-white/5 min-h-[280px] flex flex-col items-center justify-center"
|
||||
onDrop={handleDrop}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onClick={() => document.getElementById('file-input')?.click()}
|
||||
>
|
||||
<input
|
||||
id="file-input"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={(e) => e.target.files?.[0] && handleFileSelect(e.target.files[0])}
|
||||
className="hidden"
|
||||
/>
|
||||
{previewUrl ? (
|
||||
<div className="space-y-4">
|
||||
<img
|
||||
src={previewUrl}
|
||||
alt="Preview"
|
||||
className="max-h-40 mx-auto rounded-xl shadow-2xl"
|
||||
/>
|
||||
<p className="text-white font-medium text-sm">{file?.name}</p>
|
||||
<p className="text-white/50 text-xs">Klicke zum Ändern</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<svg className="w-16 h-16 mx-auto mb-4 text-white/30" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
|
||||
</svg>
|
||||
<p className="text-xl font-semibold text-white mb-2">Datei auswählen</p>
|
||||
<p className="text-white/50 text-sm mb-2">Ziehe ein Bild hierher oder klicke</p>
|
||||
<p className="text-white/30 text-xs">PNG, JPG, JPEG</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</GlassCard>
|
||||
|
||||
{/* QR Code Upload */}
|
||||
<GlassCard className="col-span-1" delay={150}>
|
||||
<div
|
||||
className="border-2 border-dashed border-white/20 rounded-2xl p-8 text-center cursor-pointer transition-all hover:border-purple-400/50 hover:bg-white/5 min-h-[280px] flex flex-col items-center justify-center"
|
||||
onClick={() => setShowQRModal(true)}
|
||||
>
|
||||
<div className="w-16 h-16 mx-auto mb-4 rounded-xl bg-purple-500/20 flex items-center justify-center">
|
||||
<span className="text-3xl">📱</span>
|
||||
</div>
|
||||
<p className="text-xl font-semibold text-white mb-2">Mit Handy scannen</p>
|
||||
<p className="text-white/50 text-sm mb-2">QR-Code scannen um Foto hochzuladen</p>
|
||||
<p className="text-white/30 text-xs">Im lokalen Netzwerk</p>
|
||||
</div>
|
||||
</GlassCard>
|
||||
|
||||
{/* Options */}
|
||||
{file && (
|
||||
<>
|
||||
<GlassCard delay={200}>
|
||||
<h3 className="text-lg font-semibold text-white mb-4">Optionen</h3>
|
||||
<div className="space-y-4">
|
||||
<label className="flex items-center gap-4 cursor-pointer group">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={removeHandwriting}
|
||||
onChange={(e) => setRemoveHandwriting(e.target.checked)}
|
||||
className="w-5 h-5 rounded bg-white/10 border-white/20 text-purple-500 focus:ring-purple-500"
|
||||
/>
|
||||
<div>
|
||||
<span className="text-white font-medium group-hover:text-purple-300 transition-colors">
|
||||
Handschrift entfernen
|
||||
</span>
|
||||
<p className="text-white/40 text-sm">Erkennt und entfernt handgeschriebene Inhalte</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-4 cursor-pointer group">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={reconstructLayout}
|
||||
onChange={(e) => setReconstructLayout(e.target.checked)}
|
||||
className="w-5 h-5 rounded bg-white/10 border-white/20 text-purple-500 focus:ring-purple-500"
|
||||
/>
|
||||
<div>
|
||||
<span className="text-white font-medium group-hover:text-purple-300 transition-colors">
|
||||
Layout rekonstruieren
|
||||
</span>
|
||||
<p className="text-white/40 text-sm">Erstellt bearbeitbare Textblöcke</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</GlassCard>
|
||||
|
||||
<GlassCard delay={300}>
|
||||
<h3 className="text-lg font-semibold text-white mb-4">Methode</h3>
|
||||
<select
|
||||
value={inpaintingMethod}
|
||||
onChange={(e) => setInpaintingMethod(e.target.value)}
|
||||
className="w-full p-3 rounded-xl bg-white/10 border border-white/20 text-white focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||
>
|
||||
<option value="auto">Automatisch (empfohlen)</option>
|
||||
<option value="opencv_telea">OpenCV Telea (schnell)</option>
|
||||
<option value="opencv_ns">OpenCV NS (glatter)</option>
|
||||
</select>
|
||||
<p className="text-white/40 text-sm mt-3">
|
||||
Die automatische Methode wählt die beste Option basierend auf dem Bildinhalt.
|
||||
</p>
|
||||
</GlassCard>
|
||||
|
||||
{/* Action Button */}
|
||||
<div className="col-span-1 lg:col-span-2 flex justify-center">
|
||||
<button
|
||||
onClick={handlePreview}
|
||||
disabled={isPreviewing}
|
||||
className="px-8 py-4 rounded-2xl font-semibold text-lg bg-gradient-to-r from-purple-500 to-pink-500 text-white hover:shadow-lg hover:shadow-purple-500/30 transition-all disabled:opacity-50 flex items-center gap-3"
|
||||
>
|
||||
{isPreviewing ? (
|
||||
<>
|
||||
<div className="w-5 h-5 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
||||
Analysiere...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||
</svg>
|
||||
Vorschau
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<UploadStep isDark={isDark} previewUrl={previewUrl} file={file}
|
||||
removeHandwriting={removeHandwriting} setRemoveHandwriting={setRemoveHandwriting}
|
||||
reconstructLayout={reconstructLayout} setReconstructLayout={setReconstructLayout}
|
||||
inpaintingMethod={inpaintingMethod} setInpaintingMethod={setInpaintingMethod}
|
||||
isPreviewing={isPreviewing} onDrop={handleDrop} onFileSelect={handleFileSelect}
|
||||
onPreview={handlePreview} onQRClick={() => setShowQRModal(true)} />
|
||||
)}
|
||||
|
||||
{/* Step 2: Preview */}
|
||||
{currentStep === 'preview' && previewResult && (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Stats */}
|
||||
<GlassCard delay={100}>
|
||||
<h3 className="text-lg font-semibold text-white mb-6">Analyse</h3>
|
||||
<div className="flex justify-around">
|
||||
<ProgressRing
|
||||
progress={previewResult.confidence * 100}
|
||||
label="Konfidenz"
|
||||
value={`${Math.round(previewResult.confidence * 100)}%`}
|
||||
color={previewResult.has_handwriting ? '#f97316' : '#22c55e'}
|
||||
/>
|
||||
<ProgressRing
|
||||
progress={previewResult.handwriting_ratio * 100 * 10}
|
||||
label="Handschrift"
|
||||
value={`${(previewResult.handwriting_ratio * 100).toFixed(1)}%`}
|
||||
color="#a78bfa"
|
||||
/>
|
||||
</div>
|
||||
<div className={`mt-6 p-4 rounded-xl text-center ${
|
||||
previewResult.has_handwriting
|
||||
? 'bg-orange-500/20 text-orange-300'
|
||||
: 'bg-green-500/20 text-green-300'
|
||||
}`}>
|
||||
{previewResult.has_handwriting
|
||||
? 'Handschrift erkannt'
|
||||
: 'Keine Handschrift gefunden'}
|
||||
</div>
|
||||
</GlassCard>
|
||||
|
||||
{/* Time Estimates */}
|
||||
<GlassCard delay={200}>
|
||||
<h3 className="text-lg font-semibold text-white mb-4">Geschätzte Zeit</h3>
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between text-white/70">
|
||||
<span>Erkennung</span>
|
||||
<span className="text-white">~{Math.round(previewResult.estimated_times_ms.detection / 1000)}s</span>
|
||||
</div>
|
||||
{removeHandwriting && previewResult.has_handwriting && (
|
||||
<div className="flex justify-between text-white/70">
|
||||
<span>Bereinigung</span>
|
||||
<span className="text-white">~{Math.round(previewResult.estimated_times_ms.inpainting / 1000)}s</span>
|
||||
</div>
|
||||
)}
|
||||
{reconstructLayout && (
|
||||
<div className="flex justify-between text-white/70">
|
||||
<span>Layout</span>
|
||||
<span className="text-white">~{Math.round(previewResult.estimated_times_ms.reconstruction / 1000)}s</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between pt-3 border-t border-white/10 font-medium">
|
||||
<span className="text-white">Gesamt</span>
|
||||
<span className="text-purple-300">~{Math.round(previewResult.estimated_times_ms.total / 1000)}s</span>
|
||||
</div>
|
||||
</div>
|
||||
</GlassCard>
|
||||
|
||||
{/* Image Info */}
|
||||
<GlassCard delay={300}>
|
||||
<h3 className="text-lg font-semibold text-white mb-4">Bild-Info</h3>
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between text-white/70">
|
||||
<span>Breite</span>
|
||||
<span className="text-white">{previewResult.image_width}px</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-white/70">
|
||||
<span>Höhe</span>
|
||||
<span className="text-white">{previewResult.image_height}px</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-white/70">
|
||||
<span>Pixel</span>
|
||||
<span className="text-white">{(previewResult.image_width * previewResult.image_height / 1000000).toFixed(1)}MP</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleGetMask}
|
||||
className="w-full mt-4 px-4 py-2 rounded-xl bg-white/10 text-white/70 hover:bg-white/20 hover:text-white transition-all text-sm"
|
||||
>
|
||||
Maske anzeigen
|
||||
</button>
|
||||
</GlassCard>
|
||||
|
||||
{/* Preview Images */}
|
||||
<GlassCard className="col-span-1 lg:col-span-2" delay={400}>
|
||||
<h3 className="text-lg font-semibold text-white mb-4">Original</h3>
|
||||
{previewUrl && (
|
||||
<img
|
||||
src={previewUrl}
|
||||
alt="Original"
|
||||
className="w-full max-h-96 object-contain rounded-xl"
|
||||
/>
|
||||
)}
|
||||
</GlassCard>
|
||||
|
||||
{maskUrl && (
|
||||
<GlassCard delay={500}>
|
||||
<h3 className="text-lg font-semibold text-white mb-4">Maske</h3>
|
||||
<img
|
||||
src={maskUrl}
|
||||
alt="Mask"
|
||||
className="w-full max-h-96 object-contain rounded-xl"
|
||||
/>
|
||||
</GlassCard>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="col-span-1 lg:col-span-3 flex justify-center gap-4">
|
||||
<button
|
||||
onClick={() => setCurrentStep('upload')}
|
||||
className="px-6 py-3 rounded-xl bg-white/10 text-white hover:bg-white/20 transition-all flex items-center gap-2"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
||||
</svg>
|
||||
Zurück
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCleanup}
|
||||
disabled={isProcessing}
|
||||
className="px-8 py-3 rounded-xl font-semibold bg-gradient-to-r from-orange-500 to-red-500 text-white hover:shadow-lg hover:shadow-orange-500/30 transition-all disabled:opacity-50 flex items-center gap-2"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19.428 15.428a2 2 0 00-1.022-.547l-2.387-.477a6 6 0 00-3.86.517l-.318.158a6 6 0 01-3.86.517L6.05 15.21a2 2 0 00-1.806.547M8 4h8l-1 1v5.172a2 2 0 00.586 1.414l5 5c1.26 1.26.367 3.414-1.415 3.414H4.828c-1.782 0-2.674-2.154-1.414-3.414l5-5A2 2 0 009 10.172V5L8 4z" />
|
||||
</svg>
|
||||
Bereinigen starten
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<PreviewStep previewResult={previewResult} previewUrl={previewUrl} maskUrl={maskUrl}
|
||||
removeHandwriting={removeHandwriting} reconstructLayout={reconstructLayout}
|
||||
isProcessing={isProcessing} onBack={() => setCurrentStep('upload')}
|
||||
onCleanup={handleCleanup} onGetMask={handleGetMask} />
|
||||
)}
|
||||
|
||||
{/* Step 3: Processing */}
|
||||
{currentStep === 'processing' && (
|
||||
<div className="flex flex-col items-center justify-center py-20">
|
||||
<GlassCard className="text-center max-w-md" delay={0}>
|
||||
<div className="w-20 h-20 mx-auto mb-6 relative">
|
||||
<div className="absolute inset-0 rounded-full border-4 border-white/10"></div>
|
||||
<div className="absolute inset-0 rounded-full border-4 border-purple-500 border-t-transparent animate-spin"></div>
|
||||
<div className="absolute inset-0 rounded-full border-4 border-white/10" />
|
||||
<div className="absolute inset-0 rounded-full border-4 border-purple-500 border-t-transparent animate-spin" />
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold text-white mb-2">Verarbeite Bild...</h3>
|
||||
<p className="text-white/50">
|
||||
{removeHandwriting ? 'Handschrift wird erkannt und entfernt' : 'Bild wird analysiert'}
|
||||
</p>
|
||||
<p className="text-white/50">{removeHandwriting ? 'Handschrift wird erkannt und entfernt' : 'Bild wird analysiert'}</p>
|
||||
</GlassCard>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 4: Result */}
|
||||
{currentStep === 'result' && pipelineResult && (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Status */}
|
||||
<GlassCard className="col-span-1 lg:col-span-2" delay={100}>
|
||||
<div className={`flex items-center gap-4 ${
|
||||
pipelineResult.success ? 'text-green-300' : 'text-red-300'
|
||||
}`}>
|
||||
<div className={`w-12 h-12 rounded-full flex items-center justify-center ${
|
||||
pipelineResult.success ? 'bg-green-500/20' : 'bg-red-500/20'
|
||||
}`}>
|
||||
{pipelineResult.success ? (
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold">
|
||||
{pipelineResult.success ? 'Erfolgreich bereinigt!' : 'Verarbeitung fehlgeschlagen'}
|
||||
</h3>
|
||||
<p className="text-white/50">
|
||||
{pipelineResult.handwriting_removed
|
||||
? `Handschrift wurde entfernt. ${pipelineResult.metadata?.layout?.element_count || 0} Elemente erkannt.`
|
||||
: pipelineResult.handwriting_detected
|
||||
? 'Handschrift erkannt, aber nicht entfernt'
|
||||
: 'Keine Handschrift im Bild gefunden'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</GlassCard>
|
||||
|
||||
{/* Original */}
|
||||
<GlassCard delay={200}>
|
||||
<h3 className="text-lg font-semibold text-white mb-4">Original</h3>
|
||||
{previewUrl && (
|
||||
<img
|
||||
src={previewUrl}
|
||||
alt="Original"
|
||||
className="w-full rounded-xl"
|
||||
/>
|
||||
)}
|
||||
</GlassCard>
|
||||
|
||||
{/* Cleaned */}
|
||||
<GlassCard delay={300}>
|
||||
<h3 className="text-lg font-semibold text-white mb-4">Bereinigt</h3>
|
||||
{cleanedUrl ? (
|
||||
<img
|
||||
src={cleanedUrl}
|
||||
alt="Cleaned"
|
||||
className="w-full rounded-xl"
|
||||
/>
|
||||
) : (
|
||||
<div className="aspect-video rounded-xl bg-white/5 flex items-center justify-center text-white/40">
|
||||
Kein Bild
|
||||
</div>
|
||||
)}
|
||||
</GlassCard>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="col-span-1 lg:col-span-2 flex justify-center gap-4">
|
||||
<button
|
||||
onClick={handleReset}
|
||||
className="px-6 py-3 rounded-xl bg-white/10 text-white hover:bg-white/20 transition-all flex items-center gap-2"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
Neues Bild
|
||||
</button>
|
||||
{cleanedUrl && (
|
||||
<a
|
||||
href={cleanedUrl}
|
||||
download="bereinigt.png"
|
||||
className="px-6 py-3 rounded-xl bg-white/10 text-white hover:bg-white/20 transition-all flex items-center gap-2"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
</svg>
|
||||
Download
|
||||
</a>
|
||||
)}
|
||||
{pipelineResult.layout_reconstructed && pipelineResult.fabric_json && (
|
||||
<button
|
||||
onClick={handleOpenInEditor}
|
||||
className="px-8 py-3 rounded-xl font-semibold bg-gradient-to-r from-green-500 to-emerald-500 text-white hover:shadow-lg hover:shadow-green-500/30 transition-all flex items-center gap-2"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
Im Editor öffnen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<ResultStep pipelineResult={pipelineResult} previewUrl={previewUrl} cleanedUrl={cleanedUrl}
|
||||
onReset={handleReset} onOpenInEditor={handleOpenInEditor} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* QR Code Modal */}
|
||||
{showQRModal && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" onClick={() => setShowQRModal(false)} />
|
||||
<div className="relative w-full max-w-md rounded-3xl bg-slate-900">
|
||||
<QRCodeUpload
|
||||
sessionId={uploadSessionId}
|
||||
onClose={() => setShowQRModal(false)}
|
||||
onFilesChanged={(files) => {
|
||||
setMobileUploadedFiles(files)
|
||||
}}
|
||||
/>
|
||||
{/* Select button for mobile files */}
|
||||
<QRCodeUpload sessionId={uploadSessionId} onClose={() => setShowQRModal(false)}
|
||||
onFilesChanged={(files) => setMobileUploadedFiles(files)} />
|
||||
{mobileUploadedFiles.length > 0 && (
|
||||
<div className="p-4 border-t border-white/10">
|
||||
<p className="text-white/60 text-sm mb-3">Datei auswählen:</p>
|
||||
<div className="space-y-2 max-h-40 overflow-y-auto">
|
||||
{mobileUploadedFiles.map((file) => (
|
||||
<button
|
||||
key={file.id}
|
||||
onClick={() => handleMobileFileSelect(file)}
|
||||
className="w-full flex items-center gap-3 p-3 rounded-xl text-left transition-all bg-white/5 hover:bg-white/10 border border-white/10"
|
||||
>
|
||||
<span className="text-xl">{file.type.startsWith('image/') ? '🖼️' : '📄'}</span>
|
||||
{mobileUploadedFiles.map((f) => (
|
||||
<button key={f.id} onClick={() => handleMobileFileSelect(f)}
|
||||
className="w-full flex items-center gap-3 p-3 rounded-xl text-left transition-all bg-white/5 hover:bg-white/10 border border-white/10">
|
||||
<span className="text-xl">{f.type.startsWith('image/') ? '🖼️' : '📄'}</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-white font-medium truncate">{file.name}</p>
|
||||
<p className="text-white/50 text-xs">{formatFileSize(file.size)}</p>
|
||||
<p className="text-white font-medium truncate">{f.name}</p>
|
||||
<p className="text-white/50 text-xs">{formatFileSize(f.size)}</p>
|
||||
</div>
|
||||
<span className="text-purple-400 text-sm">Verwenden →</span>
|
||||
</button>
|
||||
|
||||
@@ -1,733 +1,80 @@
|
||||
'use client'
|
||||
|
||||
import { createContext, useContext, useState, useEffect, useCallback, ReactNode } from 'react'
|
||||
import type { Contact, Conversation, Message, MessageTemplate, MessagesStats, MessagesContextType } from './messages/types'
|
||||
import { mockContacts, mockConversations, mockMessages, mockTemplates } from './messages/mock-data'
|
||||
|
||||
// ============================================
|
||||
// TYPES
|
||||
// ============================================
|
||||
|
||||
export interface Contact {
|
||||
id: string
|
||||
name: string
|
||||
email?: string
|
||||
phone?: string
|
||||
role: 'parent' | 'teacher' | 'staff' | 'student'
|
||||
student_name?: string
|
||||
class_name?: string
|
||||
notes?: string
|
||||
tags: string[]
|
||||
avatar_url?: string
|
||||
preferred_channel: 'email' | 'matrix' | 'pwa'
|
||||
online: boolean
|
||||
last_seen?: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface Message {
|
||||
id: string
|
||||
conversation_id: string
|
||||
sender_id: string // "self" for own messages
|
||||
content: string
|
||||
content_type: 'text' | 'file' | 'image' | 'voice'
|
||||
file_url?: string
|
||||
file_name?: string
|
||||
timestamp: string
|
||||
read: boolean
|
||||
read_at?: string
|
||||
delivered: boolean
|
||||
send_email: boolean
|
||||
email_sent: boolean
|
||||
email_sent_at?: string
|
||||
email_error?: string
|
||||
reply_to?: string // ID of message being replied to
|
||||
reactions?: { emoji: string; user_id: string }[]
|
||||
}
|
||||
|
||||
export interface Conversation {
|
||||
id: string
|
||||
participant_ids: string[]
|
||||
group_id?: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
last_message?: string
|
||||
last_message_time?: string
|
||||
unread_count: number
|
||||
is_group: boolean
|
||||
title?: string
|
||||
typing?: boolean // Someone is typing
|
||||
pinned?: boolean
|
||||
muted?: boolean
|
||||
archived?: boolean
|
||||
}
|
||||
|
||||
export interface MessageTemplate {
|
||||
id: string
|
||||
name: string
|
||||
content: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface MessagesStats {
|
||||
total_contacts: number
|
||||
total_conversations: number
|
||||
total_messages: number
|
||||
unread_messages: number
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// CONTEXT INTERFACE
|
||||
// ============================================
|
||||
|
||||
interface MessagesContextType {
|
||||
// Data
|
||||
contacts: Contact[]
|
||||
conversations: Conversation[]
|
||||
messages: Record<string, Message[]> // conversationId -> messages
|
||||
templates: MessageTemplate[]
|
||||
stats: MessagesStats
|
||||
|
||||
// Computed
|
||||
unreadCount: number
|
||||
recentConversations: Conversation[]
|
||||
|
||||
// Actions
|
||||
fetchContacts: () => Promise<void>
|
||||
fetchConversations: () => Promise<void>
|
||||
fetchMessages: (conversationId: string) => Promise<Message[]>
|
||||
sendMessage: (conversationId: string, content: string, sendEmail?: boolean, replyTo?: string) => Promise<Message | null>
|
||||
markAsRead: (conversationId: string) => Promise<void>
|
||||
createConversation: (contactId: string) => Promise<Conversation | null>
|
||||
addReaction: (messageId: string, emoji: string) => void
|
||||
deleteMessage: (conversationId: string, messageId: string) => void
|
||||
pinConversation: (conversationId: string) => void
|
||||
muteConversation: (conversationId: string) => void
|
||||
|
||||
// State
|
||||
isLoading: boolean
|
||||
error: string | null
|
||||
currentConversationId: string | null
|
||||
setCurrentConversationId: (id: string | null) => void
|
||||
}
|
||||
// Re-export types and helpers for backward compatibility
|
||||
export type { Contact, Conversation, Message, MessageTemplate, MessagesStats } from './messages/types'
|
||||
export { formatMessageTime, formatMessageDate, getContactInitials, getRoleLabel, getRoleColor, emojiCategories } from './messages/helpers'
|
||||
|
||||
const MessagesContext = createContext<MessagesContextType | null>(null)
|
||||
|
||||
// ============================================
|
||||
// MOCK DATA - Realistic German school context
|
||||
// ============================================
|
||||
|
||||
const mockContacts: Contact[] = [
|
||||
{
|
||||
id: 'contact_mueller',
|
||||
name: 'Familie Mueller',
|
||||
email: 'familie.mueller@gmail.com',
|
||||
phone: '+49 170 1234567',
|
||||
role: 'parent',
|
||||
student_name: 'Max Mueller',
|
||||
class_name: '10a',
|
||||
notes: 'Bevorzugt Kommunikation per E-Mail',
|
||||
tags: ['aktiv', 'Elternbeirat'],
|
||||
preferred_channel: 'email',
|
||||
online: false,
|
||||
last_seen: new Date(Date.now() - 1800000).toISOString(),
|
||||
created_at: new Date(Date.now() - 86400000 * 30).toISOString(),
|
||||
updated_at: new Date().toISOString()
|
||||
},
|
||||
{
|
||||
id: 'contact_schmidt',
|
||||
name: 'Petra Schmidt',
|
||||
email: 'p.schmidt@web.de',
|
||||
phone: '+49 171 9876543',
|
||||
role: 'parent',
|
||||
student_name: 'Lisa Schmidt',
|
||||
class_name: '10a',
|
||||
tags: ['responsive'],
|
||||
preferred_channel: 'pwa',
|
||||
online: true,
|
||||
created_at: new Date(Date.now() - 86400000 * 60).toISOString(),
|
||||
updated_at: new Date().toISOString()
|
||||
},
|
||||
{
|
||||
id: 'contact_weber',
|
||||
name: 'Sabine Weber',
|
||||
email: 's.weber@schule-musterstadt.de',
|
||||
role: 'teacher',
|
||||
tags: ['Fachschaft Deutsch', 'Klassenleitung 9b'],
|
||||
preferred_channel: 'pwa',
|
||||
online: true,
|
||||
last_seen: new Date().toISOString(),
|
||||
created_at: new Date(Date.now() - 86400000 * 90).toISOString(),
|
||||
updated_at: new Date().toISOString()
|
||||
},
|
||||
{
|
||||
id: 'contact_hoffmann',
|
||||
name: 'Thomas Hoffmann',
|
||||
email: 't.hoffmann@schule-musterstadt.de',
|
||||
role: 'teacher',
|
||||
tags: ['Fachschaft Mathe', 'Oberstufenkoordinator'],
|
||||
preferred_channel: 'pwa',
|
||||
online: false,
|
||||
last_seen: new Date(Date.now() - 3600000 * 2).toISOString(),
|
||||
created_at: new Date(Date.now() - 86400000 * 120).toISOString(),
|
||||
updated_at: new Date().toISOString()
|
||||
},
|
||||
{
|
||||
id: 'contact_becker',
|
||||
name: 'Familie Becker',
|
||||
email: 'becker.familie@gmx.de',
|
||||
phone: '+49 172 5551234',
|
||||
role: 'parent',
|
||||
student_name: 'Tim Becker',
|
||||
class_name: '10a',
|
||||
tags: [],
|
||||
preferred_channel: 'email',
|
||||
online: false,
|
||||
last_seen: new Date(Date.now() - 86400000).toISOString(),
|
||||
created_at: new Date(Date.now() - 86400000 * 45).toISOString(),
|
||||
updated_at: new Date().toISOString()
|
||||
},
|
||||
{
|
||||
id: 'contact_klein',
|
||||
name: 'Monika Klein',
|
||||
email: 'm.klein@schule-musterstadt.de',
|
||||
role: 'staff',
|
||||
tags: ['Sekretariat'],
|
||||
preferred_channel: 'pwa',
|
||||
online: true,
|
||||
created_at: new Date(Date.now() - 86400000 * 180).toISOString(),
|
||||
updated_at: new Date().toISOString()
|
||||
},
|
||||
{
|
||||
id: 'contact_fischer',
|
||||
name: 'Familie Fischer',
|
||||
email: 'fischer@t-online.de',
|
||||
phone: '+49 173 4445566',
|
||||
role: 'parent',
|
||||
student_name: 'Anna Fischer',
|
||||
class_name: '11b',
|
||||
tags: ['Foerderverein'],
|
||||
preferred_channel: 'pwa',
|
||||
online: false,
|
||||
last_seen: new Date(Date.now() - 7200000).toISOString(),
|
||||
created_at: new Date(Date.now() - 86400000 * 75).toISOString(),
|
||||
updated_at: new Date().toISOString()
|
||||
},
|
||||
{
|
||||
id: 'contact_meyer',
|
||||
name: 'Dr. Hans Meyer',
|
||||
email: 'h.meyer@schule-musterstadt.de',
|
||||
role: 'teacher',
|
||||
tags: ['Schulleitung', 'Stellvertretender Schulleiter'],
|
||||
preferred_channel: 'email',
|
||||
online: false,
|
||||
last_seen: new Date(Date.now() - 3600000).toISOString(),
|
||||
created_at: new Date(Date.now() - 86400000 * 365).toISOString(),
|
||||
updated_at: new Date().toISOString()
|
||||
}
|
||||
]
|
||||
|
||||
const mockConversations: Conversation[] = [
|
||||
{
|
||||
id: 'conv_mueller',
|
||||
participant_ids: ['contact_mueller'],
|
||||
created_at: new Date(Date.now() - 86400000 * 7).toISOString(),
|
||||
updated_at: new Date(Date.now() - 300000).toISOString(),
|
||||
last_message: 'Vielen Dank fuer die Info! Max freut sich schon auf die Klassenfahrt 🎉',
|
||||
last_message_time: new Date(Date.now() - 300000).toISOString(),
|
||||
unread_count: 2,
|
||||
is_group: false,
|
||||
title: 'Familie Mueller',
|
||||
pinned: true
|
||||
},
|
||||
{
|
||||
id: 'conv_schmidt',
|
||||
participant_ids: ['contact_schmidt'],
|
||||
created_at: new Date(Date.now() - 86400000 * 14).toISOString(),
|
||||
updated_at: new Date(Date.now() - 3600000).toISOString(),
|
||||
last_message: 'Lisa war heute krank, sie kommt morgen wieder.',
|
||||
last_message_time: new Date(Date.now() - 3600000).toISOString(),
|
||||
unread_count: 0,
|
||||
is_group: false,
|
||||
title: 'Petra Schmidt'
|
||||
},
|
||||
{
|
||||
id: 'conv_weber',
|
||||
participant_ids: ['contact_weber'],
|
||||
created_at: new Date(Date.now() - 86400000 * 30).toISOString(),
|
||||
updated_at: new Date(Date.now() - 7200000).toISOString(),
|
||||
last_message: 'Koenntest du mir die Klausuraufgaben bis Freitag schicken? 📝',
|
||||
last_message_time: new Date(Date.now() - 7200000).toISOString(),
|
||||
unread_count: 1,
|
||||
is_group: false,
|
||||
title: 'Sabine Weber',
|
||||
typing: true
|
||||
},
|
||||
{
|
||||
id: 'conv_hoffmann',
|
||||
participant_ids: ['contact_hoffmann'],
|
||||
created_at: new Date(Date.now() - 86400000 * 5).toISOString(),
|
||||
updated_at: new Date(Date.now() - 86400000).toISOString(),
|
||||
last_message: 'Die Notenkonferenz ist am 15.02. um 14:00 Uhr.',
|
||||
last_message_time: new Date(Date.now() - 86400000).toISOString(),
|
||||
unread_count: 0,
|
||||
is_group: false,
|
||||
title: 'Thomas Hoffmann'
|
||||
},
|
||||
{
|
||||
id: 'conv_becker',
|
||||
participant_ids: ['contact_becker'],
|
||||
created_at: new Date(Date.now() - 86400000 * 3).toISOString(),
|
||||
updated_at: new Date(Date.now() - 172800000).toISOString(),
|
||||
last_message: 'Wir haben die Einverstaendniserklaerung unterschrieben.',
|
||||
last_message_time: new Date(Date.now() - 172800000).toISOString(),
|
||||
unread_count: 0,
|
||||
is_group: false,
|
||||
title: 'Familie Becker',
|
||||
muted: true
|
||||
},
|
||||
{
|
||||
id: 'conv_fachschaft',
|
||||
participant_ids: ['contact_weber', 'contact_hoffmann', 'contact_meyer'],
|
||||
created_at: new Date(Date.now() - 86400000 * 60).toISOString(),
|
||||
updated_at: new Date(Date.now() - 14400000).toISOString(),
|
||||
last_message: 'Sabine: Hat jemand die neuen Lehrplaene schon gelesen?',
|
||||
last_message_time: new Date(Date.now() - 14400000).toISOString(),
|
||||
unread_count: 3,
|
||||
is_group: true,
|
||||
title: 'Fachschaft Deutsch 📚'
|
||||
}
|
||||
]
|
||||
|
||||
const mockMessages: Record<string, Message[]> = {
|
||||
'conv_mueller': [
|
||||
{
|
||||
id: 'msg_m1',
|
||||
conversation_id: 'conv_mueller',
|
||||
sender_id: 'self',
|
||||
content: 'Guten Tag Frau Mueller,\n\nich moechte Sie ueber die anstehende Klassenfahrt nach Berlin informieren. Die Reise findet vom 15.-19. April statt.',
|
||||
content_type: 'text',
|
||||
timestamp: new Date(Date.now() - 86400000).toISOString(),
|
||||
read: true,
|
||||
delivered: true,
|
||||
send_email: true,
|
||||
email_sent: true,
|
||||
email_sent_at: new Date(Date.now() - 86400000).toISOString()
|
||||
},
|
||||
{
|
||||
id: 'msg_m2',
|
||||
conversation_id: 'conv_mueller',
|
||||
sender_id: 'self',
|
||||
content: 'Die Kosten belaufen sich auf 280 Euro pro Schueler. Bitte ueberweisen Sie den Betrag bis zum 01.03. auf das Schulkonto.',
|
||||
content_type: 'text',
|
||||
timestamp: new Date(Date.now() - 86400000 + 60000).toISOString(),
|
||||
read: true,
|
||||
delivered: true,
|
||||
send_email: false,
|
||||
email_sent: false
|
||||
},
|
||||
{
|
||||
id: 'msg_m3',
|
||||
conversation_id: 'conv_mueller',
|
||||
sender_id: 'contact_mueller',
|
||||
content: 'Vielen Dank fuer die Information! Wir werden den Betrag diese Woche ueberweisen.',
|
||||
content_type: 'text',
|
||||
timestamp: new Date(Date.now() - 3600000).toISOString(),
|
||||
read: false,
|
||||
delivered: true,
|
||||
send_email: false,
|
||||
email_sent: false,
|
||||
reactions: [{ emoji: '👍', user_id: 'self' }]
|
||||
},
|
||||
{
|
||||
id: 'msg_m4',
|
||||
conversation_id: 'conv_mueller',
|
||||
sender_id: 'contact_mueller',
|
||||
content: 'Vielen Dank fuer die Info! Max freut sich schon auf die Klassenfahrt 🎉',
|
||||
content_type: 'text',
|
||||
timestamp: new Date(Date.now() - 300000).toISOString(),
|
||||
read: false,
|
||||
delivered: true,
|
||||
send_email: false,
|
||||
email_sent: false
|
||||
}
|
||||
],
|
||||
'conv_schmidt': [
|
||||
{
|
||||
id: 'msg_s1',
|
||||
conversation_id: 'conv_schmidt',
|
||||
sender_id: 'contact_schmidt',
|
||||
content: 'Guten Morgen! Lisa ist heute leider krank und kann nicht zur Schule kommen.',
|
||||
content_type: 'text',
|
||||
timestamp: new Date(Date.now() - 86400000 * 2).toISOString(),
|
||||
read: true,
|
||||
delivered: true,
|
||||
send_email: false,
|
||||
email_sent: false
|
||||
},
|
||||
{
|
||||
id: 'msg_s2',
|
||||
conversation_id: 'conv_schmidt',
|
||||
sender_id: 'self',
|
||||
content: 'Gute Besserung an Lisa! 🤒 Soll ich ihr die Hausaufgaben zukommen lassen?',
|
||||
content_type: 'text',
|
||||
timestamp: new Date(Date.now() - 86400000 * 2 + 1800000).toISOString(),
|
||||
read: true,
|
||||
delivered: true,
|
||||
send_email: false,
|
||||
email_sent: false
|
||||
},
|
||||
{
|
||||
id: 'msg_s3',
|
||||
conversation_id: 'conv_schmidt',
|
||||
sender_id: 'contact_schmidt',
|
||||
content: 'Das waere sehr nett, vielen Dank! 🙏',
|
||||
content_type: 'text',
|
||||
timestamp: new Date(Date.now() - 86400000 * 2 + 3600000).toISOString(),
|
||||
read: true,
|
||||
delivered: true,
|
||||
send_email: false,
|
||||
email_sent: false
|
||||
},
|
||||
{
|
||||
id: 'msg_s4',
|
||||
conversation_id: 'conv_schmidt',
|
||||
sender_id: 'self',
|
||||
content: 'Hier sind die Hausaufgaben fuer diese Woche:\n\n📖 Deutsch: Seite 45-48 lesen\n📝 Mathe: Aufgaben 1-5 auf Seite 112\n🔬 Bio: Referat vorbereiten',
|
||||
content_type: 'text',
|
||||
timestamp: new Date(Date.now() - 86400000).toISOString(),
|
||||
read: true,
|
||||
delivered: true,
|
||||
send_email: true,
|
||||
email_sent: true
|
||||
},
|
||||
{
|
||||
id: 'msg_s5',
|
||||
conversation_id: 'conv_schmidt',
|
||||
sender_id: 'contact_schmidt',
|
||||
content: 'Lisa war heute krank, sie kommt morgen wieder.',
|
||||
content_type: 'text',
|
||||
timestamp: new Date(Date.now() - 3600000).toISOString(),
|
||||
read: true,
|
||||
delivered: true,
|
||||
send_email: false,
|
||||
email_sent: false
|
||||
}
|
||||
],
|
||||
'conv_weber': [
|
||||
{
|
||||
id: 'msg_w1',
|
||||
conversation_id: 'conv_weber',
|
||||
sender_id: 'contact_weber',
|
||||
content: 'Hi! Hast du schon die neuen Abi-Themen gesehen?',
|
||||
content_type: 'text',
|
||||
timestamp: new Date(Date.now() - 86400000 * 3).toISOString(),
|
||||
read: true,
|
||||
delivered: true,
|
||||
send_email: false,
|
||||
email_sent: false
|
||||
},
|
||||
{
|
||||
id: 'msg_w2',
|
||||
conversation_id: 'conv_weber',
|
||||
sender_id: 'self',
|
||||
content: 'Ja, habe ich! Finde ich ganz gut machbar dieses Jahr. 📚',
|
||||
content_type: 'text',
|
||||
timestamp: new Date(Date.now() - 86400000 * 3 + 1800000).toISOString(),
|
||||
read: true,
|
||||
delivered: true,
|
||||
send_email: false,
|
||||
email_sent: false
|
||||
},
|
||||
{
|
||||
id: 'msg_w3',
|
||||
conversation_id: 'conv_weber',
|
||||
sender_id: 'contact_weber',
|
||||
content: 'Koenntest du mir die Klausuraufgaben bis Freitag schicken? 📝',
|
||||
content_type: 'text',
|
||||
timestamp: new Date(Date.now() - 7200000).toISOString(),
|
||||
read: false,
|
||||
delivered: true,
|
||||
send_email: false,
|
||||
email_sent: false
|
||||
}
|
||||
],
|
||||
'conv_hoffmann': [
|
||||
{
|
||||
id: 'msg_h1',
|
||||
conversation_id: 'conv_hoffmann',
|
||||
sender_id: 'contact_hoffmann',
|
||||
content: 'Kurze Info: Die Notenkonferenz ist am 15.02. um 14:00 Uhr.',
|
||||
content_type: 'text',
|
||||
timestamp: new Date(Date.now() - 86400000 * 2).toISOString(),
|
||||
read: true,
|
||||
delivered: true,
|
||||
send_email: false,
|
||||
email_sent: false
|
||||
},
|
||||
{
|
||||
id: 'msg_h2',
|
||||
conversation_id: 'conv_hoffmann',
|
||||
sender_id: 'self',
|
||||
content: 'Danke fuer die Info! Bin dabei. 👍',
|
||||
content_type: 'text',
|
||||
timestamp: new Date(Date.now() - 86400000 * 2 + 3600000).toISOString(),
|
||||
read: true,
|
||||
delivered: true,
|
||||
send_email: false,
|
||||
email_sent: false
|
||||
},
|
||||
{
|
||||
id: 'msg_h3',
|
||||
conversation_id: 'conv_hoffmann',
|
||||
sender_id: 'contact_hoffmann',
|
||||
content: 'Die Notenkonferenz ist am 15.02. um 14:00 Uhr.',
|
||||
content_type: 'text',
|
||||
timestamp: new Date(Date.now() - 86400000).toISOString(),
|
||||
read: true,
|
||||
delivered: true,
|
||||
send_email: false,
|
||||
email_sent: false
|
||||
}
|
||||
],
|
||||
'conv_becker': [
|
||||
{
|
||||
id: 'msg_b1',
|
||||
conversation_id: 'conv_becker',
|
||||
sender_id: 'self',
|
||||
content: 'Guten Tag Familie Becker,\n\nbitte vergessen Sie nicht, die Einverstaendniserklaerung fuer den Schwimmunterricht zu unterschreiben.',
|
||||
content_type: 'text',
|
||||
timestamp: new Date(Date.now() - 86400000 * 4).toISOString(),
|
||||
read: true,
|
||||
delivered: true,
|
||||
send_email: true,
|
||||
email_sent: true
|
||||
},
|
||||
{
|
||||
id: 'msg_b2',
|
||||
conversation_id: 'conv_becker',
|
||||
sender_id: 'contact_becker',
|
||||
content: 'Wir haben die Einverstaendniserklaerung unterschrieben.',
|
||||
content_type: 'text',
|
||||
timestamp: new Date(Date.now() - 172800000).toISOString(),
|
||||
read: true,
|
||||
delivered: true,
|
||||
send_email: false,
|
||||
email_sent: false
|
||||
}
|
||||
],
|
||||
'conv_fachschaft': [
|
||||
{
|
||||
id: 'msg_f1',
|
||||
conversation_id: 'conv_fachschaft',
|
||||
sender_id: 'contact_meyer',
|
||||
content: 'Liebe Kolleginnen und Kollegen,\n\ndie neuen Lehrplaene sind jetzt online verfuegbar.',
|
||||
content_type: 'text',
|
||||
timestamp: new Date(Date.now() - 86400000).toISOString(),
|
||||
read: true,
|
||||
delivered: true,
|
||||
send_email: false,
|
||||
email_sent: false
|
||||
},
|
||||
{
|
||||
id: 'msg_f2',
|
||||
conversation_id: 'conv_fachschaft',
|
||||
sender_id: 'contact_hoffmann',
|
||||
content: 'Danke fuer die Info! Werde ich mir heute Abend anschauen.',
|
||||
content_type: 'text',
|
||||
timestamp: new Date(Date.now() - 72000000).toISOString(),
|
||||
read: true,
|
||||
delivered: true,
|
||||
send_email: false,
|
||||
email_sent: false
|
||||
},
|
||||
{
|
||||
id: 'msg_f3',
|
||||
conversation_id: 'conv_fachschaft',
|
||||
sender_id: 'contact_weber',
|
||||
content: 'Hat jemand die neuen Lehrplaene schon gelesen?',
|
||||
content_type: 'text',
|
||||
timestamp: new Date(Date.now() - 14400000).toISOString(),
|
||||
read: false,
|
||||
delivered: true,
|
||||
send_email: false,
|
||||
email_sent: false
|
||||
},
|
||||
{
|
||||
id: 'msg_f4',
|
||||
conversation_id: 'conv_fachschaft',
|
||||
sender_id: 'contact_hoffmann',
|
||||
content: 'Noch nicht komplett, aber sieht interessant aus! 📖',
|
||||
content_type: 'text',
|
||||
timestamp: new Date(Date.now() - 10800000).toISOString(),
|
||||
read: false,
|
||||
delivered: true,
|
||||
send_email: false,
|
||||
email_sent: false
|
||||
},
|
||||
{
|
||||
id: 'msg_f5',
|
||||
conversation_id: 'conv_fachschaft',
|
||||
sender_id: 'contact_meyer',
|
||||
content: 'Wir sollten naechste Woche eine Besprechung ansetzen.',
|
||||
content_type: 'text',
|
||||
timestamp: new Date(Date.now() - 7200000).toISOString(),
|
||||
read: false,
|
||||
delivered: true,
|
||||
send_email: false,
|
||||
email_sent: false
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const mockTemplates: MessageTemplate[] = [
|
||||
{
|
||||
id: 'tpl_1',
|
||||
name: 'Krankmeldung bestaetigen',
|
||||
content: 'Vielen Dank fuer die Krankmeldung. Gute Besserung! 🤒',
|
||||
created_at: new Date().toISOString()
|
||||
},
|
||||
{
|
||||
id: 'tpl_2',
|
||||
name: 'Hausaufgaben senden',
|
||||
content: 'Hier sind die Hausaufgaben fuer diese Woche:\n\n📖 Deutsch: \n📝 Mathe: \n🔬 Bio: ',
|
||||
created_at: new Date().toISOString()
|
||||
},
|
||||
{
|
||||
id: 'tpl_3',
|
||||
name: 'Elterngespraech anfragen',
|
||||
content: 'Guten Tag,\n\nich wuerde gerne ein Elterngespraech mit Ihnen vereinbaren. Wann haetten Sie Zeit?',
|
||||
created_at: new Date().toISOString()
|
||||
},
|
||||
{
|
||||
id: 'tpl_4',
|
||||
name: 'Termin bestaetigen',
|
||||
content: 'Vielen Dank, der Termin ist bestaetigt. Ich freue mich auf unser Gespraech! 📅',
|
||||
created_at: new Date().toISOString()
|
||||
}
|
||||
]
|
||||
|
||||
// ============================================
|
||||
// PROVIDER
|
||||
// ============================================
|
||||
|
||||
export function MessagesProvider({ children }: { children: ReactNode }) {
|
||||
const [contacts, setContacts] = useState<Contact[]>(mockContacts)
|
||||
const [conversations, setConversations] = useState<Conversation[]>(mockConversations)
|
||||
const [messages, setMessages] = useState<Record<string, Message[]>>(mockMessages)
|
||||
const [templates, setTemplates] = useState<MessageTemplate[]>(mockTemplates)
|
||||
const [stats, setStats] = useState<MessagesStats>({
|
||||
const [templates] = useState<MessageTemplate[]>(mockTemplates)
|
||||
const [stats] = useState<MessagesStats>({
|
||||
total_contacts: mockContacts.length,
|
||||
total_conversations: mockConversations.length,
|
||||
total_messages: Object.values(mockMessages).flat().length,
|
||||
unread_messages: mockConversations.reduce((sum, c) => sum + c.unread_count, 0)
|
||||
})
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [isLoading] = useState(false)
|
||||
const [error] = useState<string | null>(null)
|
||||
const [currentConversationId, setCurrentConversationId] = useState<string | null>(null)
|
||||
const [mounted, setMounted] = useState(false)
|
||||
|
||||
// Initialize
|
||||
useEffect(() => {
|
||||
setMounted(true)
|
||||
}, [])
|
||||
useEffect(() => { setMounted(true) }, [])
|
||||
|
||||
// Computed: unread count
|
||||
const unreadCount = conversations.reduce((sum, c) => sum + c.unread_count, 0)
|
||||
|
||||
// Computed: recent conversations (sorted by last_message_time, pinned first)
|
||||
const recentConversations = [...conversations]
|
||||
.sort((a, b) => {
|
||||
// Pinned conversations first
|
||||
if (a.pinned && !b.pinned) return -1
|
||||
if (!a.pinned && b.pinned) return 1
|
||||
// Then by last_message_time
|
||||
const aTime = a.last_message_time ? new Date(a.last_message_time).getTime() : 0
|
||||
const bTime = b.last_message_time ? new Date(b.last_message_time).getTime() : 0
|
||||
return bTime - aTime
|
||||
})
|
||||
|
||||
// Actions
|
||||
const fetchContacts = useCallback(async () => {
|
||||
// Using mock data directly
|
||||
setContacts(mockContacts)
|
||||
}, [])
|
||||
|
||||
const fetchConversations = useCallback(async () => {
|
||||
// Using mock data directly
|
||||
setConversations(mockConversations)
|
||||
}, [])
|
||||
const recentConversations = [...conversations].sort((a, b) => {
|
||||
if (a.pinned && !b.pinned) return -1
|
||||
if (!a.pinned && b.pinned) return 1
|
||||
const aTime = a.last_message_time ? new Date(a.last_message_time).getTime() : 0
|
||||
const bTime = b.last_message_time ? new Date(b.last_message_time).getTime() : 0
|
||||
return bTime - aTime
|
||||
})
|
||||
|
||||
const fetchContacts = useCallback(async () => { setContacts(mockContacts) }, [])
|
||||
const fetchConversations = useCallback(async () => { setConversations(mockConversations) }, [])
|
||||
const fetchMessages = useCallback(async (conversationId: string): Promise<Message[]> => {
|
||||
return messages[conversationId] || []
|
||||
}, [messages])
|
||||
|
||||
const sendMessage = useCallback(async (
|
||||
conversationId: string,
|
||||
content: string,
|
||||
sendEmail: boolean = false,
|
||||
replyTo?: string
|
||||
conversationId: string, content: string, sendEmail = false, replyTo?: string
|
||||
): Promise<Message | null> => {
|
||||
const newMsg: Message = {
|
||||
id: `msg_${Date.now()}`,
|
||||
conversation_id: conversationId,
|
||||
sender_id: 'self',
|
||||
content,
|
||||
content_type: 'text',
|
||||
timestamp: new Date().toISOString(),
|
||||
read: true,
|
||||
delivered: true,
|
||||
send_email: sendEmail,
|
||||
email_sent: sendEmail,
|
||||
reply_to: replyTo
|
||||
id: `msg_${Date.now()}`, conversation_id: conversationId, sender_id: 'self',
|
||||
content, content_type: 'text', timestamp: new Date().toISOString(),
|
||||
read: true, delivered: true, send_email: sendEmail, email_sent: sendEmail, reply_to: replyTo
|
||||
}
|
||||
|
||||
setMessages(prev => ({
|
||||
...prev,
|
||||
[conversationId]: [...(prev[conversationId] || []), newMsg]
|
||||
}))
|
||||
|
||||
// Update conversation
|
||||
setMessages(prev => ({ ...prev, [conversationId]: [...(prev[conversationId] || []), newMsg] }))
|
||||
setConversations(prev => prev.map(c =>
|
||||
c.id === conversationId
|
||||
? {
|
||||
...c,
|
||||
last_message: content.length > 50 ? content.slice(0, 50) + '...' : content,
|
||||
last_message_time: newMsg.timestamp,
|
||||
updated_at: newMsg.timestamp
|
||||
}
|
||||
? { ...c, last_message: content.length > 50 ? content.slice(0, 50) + '...' : content,
|
||||
last_message_time: newMsg.timestamp, updated_at: newMsg.timestamp }
|
||||
: c
|
||||
))
|
||||
|
||||
return newMsg
|
||||
}, [])
|
||||
|
||||
const markAsRead = useCallback(async (conversationId: string) => {
|
||||
setMessages(prev => ({
|
||||
...prev,
|
||||
[conversationId]: (prev[conversationId] || []).map(m => ({ ...m, read: true }))
|
||||
}))
|
||||
setConversations(prev => prev.map(c =>
|
||||
c.id === conversationId ? { ...c, unread_count: 0 } : c
|
||||
))
|
||||
setMessages(prev => ({ ...prev, [conversationId]: (prev[conversationId] || []).map(m => ({ ...m, read: true })) }))
|
||||
setConversations(prev => prev.map(c => c.id === conversationId ? { ...c, unread_count: 0 } : c))
|
||||
}, [])
|
||||
|
||||
const createConversation = useCallback(async (contactId: string): Promise<Conversation | null> => {
|
||||
// Check if conversation exists
|
||||
const existing = conversations.find(c =>
|
||||
!c.is_group && c.participant_ids.includes(contactId)
|
||||
)
|
||||
const existing = conversations.find(c => !c.is_group && c.participant_ids.includes(contactId))
|
||||
if (existing) return existing
|
||||
|
||||
// Create new conversation
|
||||
const contact = contacts.find(c => c.id === contactId)
|
||||
const newConv: Conversation = {
|
||||
id: `conv_${Date.now()}`,
|
||||
participant_ids: [contactId],
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
unread_count: 0,
|
||||
is_group: false,
|
||||
title: contact?.name || 'Neue Konversation'
|
||||
id: `conv_${Date.now()}`, participant_ids: [contactId],
|
||||
created_at: new Date().toISOString(), updated_at: new Date().toISOString(),
|
||||
unread_count: 0, is_group: false, title: contact?.name || 'Neue Konversation'
|
||||
}
|
||||
setConversations(prev => [newConv, ...prev])
|
||||
setMessages(prev => ({ ...prev, [newConv.id]: [] }))
|
||||
@@ -739,22 +86,14 @@ export function MessagesProvider({ children }: { children: ReactNode }) {
|
||||
const newMessages = { ...prev }
|
||||
for (const convId of Object.keys(newMessages)) {
|
||||
newMessages[convId] = newMessages[convId].map(msg => {
|
||||
if (msg.id === messageId) {
|
||||
const reactions = msg.reactions || []
|
||||
const existingIndex = reactions.findIndex(r => r.user_id === 'self')
|
||||
if (existingIndex >= 0) {
|
||||
// Toggle or change reaction
|
||||
if (reactions[existingIndex].emoji === emoji) {
|
||||
reactions.splice(existingIndex, 1)
|
||||
} else {
|
||||
reactions[existingIndex].emoji = emoji
|
||||
}
|
||||
} else {
|
||||
reactions.push({ emoji, user_id: 'self' })
|
||||
}
|
||||
return { ...msg, reactions }
|
||||
}
|
||||
return msg
|
||||
if (msg.id !== messageId) return msg
|
||||
const reactions = [...(msg.reactions || [])]
|
||||
const existingIndex = reactions.findIndex(r => r.user_id === 'self')
|
||||
if (existingIndex >= 0) {
|
||||
if (reactions[existingIndex].emoji === emoji) { reactions.splice(existingIndex, 1) }
|
||||
else { reactions[existingIndex] = { ...reactions[existingIndex], emoji } }
|
||||
} else { reactions.push({ emoji, user_id: 'self' }) }
|
||||
return { ...msg, reactions }
|
||||
})
|
||||
}
|
||||
return newMessages
|
||||
@@ -762,86 +101,30 @@ export function MessagesProvider({ children }: { children: ReactNode }) {
|
||||
}, [])
|
||||
|
||||
const deleteMessage = useCallback((conversationId: string, messageId: string) => {
|
||||
setMessages(prev => ({
|
||||
...prev,
|
||||
[conversationId]: (prev[conversationId] || []).filter(m => m.id !== messageId)
|
||||
}))
|
||||
setMessages(prev => ({ ...prev, [conversationId]: (prev[conversationId] || []).filter(m => m.id !== messageId) }))
|
||||
}, [])
|
||||
|
||||
const pinConversation = useCallback((conversationId: string) => {
|
||||
setConversations(prev => prev.map(c =>
|
||||
c.id === conversationId ? { ...c, pinned: !c.pinned } : c
|
||||
))
|
||||
setConversations(prev => prev.map(c => c.id === conversationId ? { ...c, pinned: !c.pinned } : c))
|
||||
}, [])
|
||||
|
||||
const muteConversation = useCallback((conversationId: string) => {
|
||||
setConversations(prev => prev.map(c =>
|
||||
c.id === conversationId ? { ...c, muted: !c.muted } : c
|
||||
))
|
||||
setConversations(prev => prev.map(c => c.id === conversationId ? { ...c, muted: !c.muted } : c))
|
||||
}, [])
|
||||
|
||||
// SSR safety
|
||||
if (!mounted) {
|
||||
return (
|
||||
<MessagesContext.Provider
|
||||
value={{
|
||||
contacts: [],
|
||||
conversations: [],
|
||||
messages: {},
|
||||
templates: [],
|
||||
stats: { total_contacts: 0, total_conversations: 0, total_messages: 0, unread_messages: 0 },
|
||||
unreadCount: 0,
|
||||
recentConversations: [],
|
||||
fetchContacts: async () => {},
|
||||
fetchConversations: async () => {},
|
||||
fetchMessages: async () => [],
|
||||
sendMessage: async () => null,
|
||||
markAsRead: async () => {},
|
||||
createConversation: async () => null,
|
||||
addReaction: () => {},
|
||||
deleteMessage: () => {},
|
||||
pinConversation: () => {},
|
||||
muteConversation: () => {},
|
||||
isLoading: false,
|
||||
error: null,
|
||||
currentConversationId: null,
|
||||
setCurrentConversationId: () => {}
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</MessagesContext.Provider>
|
||||
)
|
||||
const value: MessagesContextType = {
|
||||
contacts: mounted ? contacts : [], conversations: mounted ? conversations : [],
|
||||
messages: mounted ? messages : {}, templates: mounted ? templates : [],
|
||||
stats: mounted ? stats : { total_contacts: 0, total_conversations: 0, total_messages: 0, unread_messages: 0 },
|
||||
unreadCount: mounted ? unreadCount : 0,
|
||||
recentConversations: mounted ? recentConversations : [],
|
||||
fetchContacts, fetchConversations, fetchMessages, sendMessage, markAsRead,
|
||||
createConversation, addReaction, deleteMessage, pinConversation, muteConversation,
|
||||
isLoading, error, currentConversationId,
|
||||
setCurrentConversationId: mounted ? setCurrentConversationId : () => {}
|
||||
}
|
||||
|
||||
return (
|
||||
<MessagesContext.Provider
|
||||
value={{
|
||||
contacts,
|
||||
conversations,
|
||||
messages,
|
||||
templates,
|
||||
stats,
|
||||
unreadCount,
|
||||
recentConversations,
|
||||
fetchContacts,
|
||||
fetchConversations,
|
||||
fetchMessages,
|
||||
sendMessage,
|
||||
markAsRead,
|
||||
createConversation,
|
||||
addReaction,
|
||||
deleteMessage,
|
||||
pinConversation,
|
||||
muteConversation,
|
||||
isLoading,
|
||||
error,
|
||||
currentConversationId,
|
||||
setCurrentConversationId
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</MessagesContext.Provider>
|
||||
)
|
||||
return <MessagesContext.Provider value={value}>{children}</MessagesContext.Provider>
|
||||
}
|
||||
|
||||
export function useMessages() {
|
||||
@@ -851,75 +134,3 @@ export function useMessages() {
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// HELPER FUNCTIONS
|
||||
// ============================================
|
||||
|
||||
export function formatMessageTime(timestamp: string): string {
|
||||
const date = new Date(timestamp)
|
||||
const now = new Date()
|
||||
const diffMs = now.getTime() - date.getTime()
|
||||
const diffMins = Math.floor(diffMs / 60000)
|
||||
const diffHours = Math.floor(diffMs / 3600000)
|
||||
const diffDays = Math.floor(diffMs / 86400000)
|
||||
|
||||
if (diffMins < 1) return 'Gerade eben'
|
||||
if (diffMins < 60) return `${diffMins} Min.`
|
||||
if (diffHours < 24) return `${diffHours} Std.`
|
||||
if (diffDays === 1) return 'Gestern'
|
||||
if (diffDays < 7) return `${diffDays} Tage`
|
||||
|
||||
return date.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' })
|
||||
}
|
||||
|
||||
export function formatMessageDate(timestamp: string): string {
|
||||
const date = new Date(timestamp)
|
||||
const now = new Date()
|
||||
const diffDays = Math.floor((now.getTime() - date.getTime()) / 86400000)
|
||||
|
||||
if (diffDays === 0) return 'Heute'
|
||||
if (diffDays === 1) return 'Gestern'
|
||||
if (diffDays < 7) {
|
||||
return date.toLocaleDateString('de-DE', { weekday: 'long' })
|
||||
}
|
||||
|
||||
return date.toLocaleDateString('de-DE', { day: '2-digit', month: 'long', year: 'numeric' })
|
||||
}
|
||||
|
||||
export function getContactInitials(name: string): string {
|
||||
const parts = name.split(' ').filter(p => p.length > 0)
|
||||
if (parts.length >= 2) {
|
||||
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase()
|
||||
}
|
||||
return name.slice(0, 2).toUpperCase()
|
||||
}
|
||||
|
||||
export function getRoleLabel(role: Contact['role']): string {
|
||||
const labels: Record<Contact['role'], string> = {
|
||||
parent: 'Eltern',
|
||||
teacher: 'Lehrkraft',
|
||||
staff: 'Verwaltung',
|
||||
student: 'Schueler/in'
|
||||
}
|
||||
return labels[role] || role
|
||||
}
|
||||
|
||||
export function getRoleColor(role: Contact['role'], isDark: boolean): string {
|
||||
const colors: Record<Contact['role'], { dark: string; light: string }> = {
|
||||
parent: { dark: 'bg-blue-500/20 text-blue-300', light: 'bg-blue-100 text-blue-700' },
|
||||
teacher: { dark: 'bg-purple-500/20 text-purple-300', light: 'bg-purple-100 text-purple-700' },
|
||||
staff: { dark: 'bg-amber-500/20 text-amber-300', light: 'bg-amber-100 text-amber-700' },
|
||||
student: { dark: 'bg-green-500/20 text-green-300', light: 'bg-green-100 text-green-700' }
|
||||
}
|
||||
return isDark ? colors[role].dark : colors[role].light
|
||||
}
|
||||
|
||||
// Emoji categories for picker
|
||||
export const emojiCategories = {
|
||||
'Häufig': ['👍', '❤️', '😊', '😂', '🙏', '👏', '🎉', '✅', '📝', '📚'],
|
||||
'Smileys': ['😀', '😃', '😄', '😁', '😅', '😂', '🤣', '😊', '😇', '🙂', '😉', '😌', '😍', '🥰', '😘'],
|
||||
'Gesten': ['👍', '👎', '👌', '✌️', '🤞', '🤝', '👏', '🙌', '👋', '✋', '🤚', '🖐️', '🙏'],
|
||||
'Symbole': ['❤️', '💙', '💚', '💛', '🧡', '💜', '✅', '❌', '⭐', '🌟', '💯', '📌', '📎'],
|
||||
'Schule': ['📚', '📖', '📝', '✏️', '📓', '📕', '📗', '📘', '🎓', '🏫', '📅', '⏰', '🔔']
|
||||
}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import type { Contact } from './types'
|
||||
|
||||
export function formatMessageTime(timestamp: string): string {
|
||||
const date = new Date(timestamp)
|
||||
const now = new Date()
|
||||
const diffMs = now.getTime() - date.getTime()
|
||||
const diffMins = Math.floor(diffMs / 60000)
|
||||
const diffHours = Math.floor(diffMs / 3600000)
|
||||
const diffDays = Math.floor(diffMs / 86400000)
|
||||
|
||||
if (diffMins < 1) return 'Gerade eben'
|
||||
if (diffMins < 60) return `${diffMins} Min.`
|
||||
if (diffHours < 24) return `${diffHours} Std.`
|
||||
if (diffDays === 1) return 'Gestern'
|
||||
if (diffDays < 7) return `${diffDays} Tage`
|
||||
|
||||
return date.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' })
|
||||
}
|
||||
|
||||
export function formatMessageDate(timestamp: string): string {
|
||||
const date = new Date(timestamp)
|
||||
const now = new Date()
|
||||
const diffDays = Math.floor((now.getTime() - date.getTime()) / 86400000)
|
||||
|
||||
if (diffDays === 0) return 'Heute'
|
||||
if (diffDays === 1) return 'Gestern'
|
||||
if (diffDays < 7) {
|
||||
return date.toLocaleDateString('de-DE', { weekday: 'long' })
|
||||
}
|
||||
|
||||
return date.toLocaleDateString('de-DE', { day: '2-digit', month: 'long', year: 'numeric' })
|
||||
}
|
||||
|
||||
export function getContactInitials(name: string): string {
|
||||
const parts = name.split(' ').filter(p => p.length > 0)
|
||||
if (parts.length >= 2) {
|
||||
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase()
|
||||
}
|
||||
return name.slice(0, 2).toUpperCase()
|
||||
}
|
||||
|
||||
export function getRoleLabel(role: Contact['role']): string {
|
||||
const labels: Record<Contact['role'], string> = {
|
||||
parent: 'Eltern',
|
||||
teacher: 'Lehrkraft',
|
||||
staff: 'Verwaltung',
|
||||
student: 'Schueler/in'
|
||||
}
|
||||
return labels[role] || role
|
||||
}
|
||||
|
||||
export function getRoleColor(role: Contact['role'], isDark: boolean): string {
|
||||
const colors: Record<Contact['role'], { dark: string; light: string }> = {
|
||||
parent: { dark: 'bg-blue-500/20 text-blue-300', light: 'bg-blue-100 text-blue-700' },
|
||||
teacher: { dark: 'bg-purple-500/20 text-purple-300', light: 'bg-purple-100 text-purple-700' },
|
||||
staff: { dark: 'bg-amber-500/20 text-amber-300', light: 'bg-amber-100 text-amber-700' },
|
||||
student: { dark: 'bg-green-500/20 text-green-300', light: 'bg-green-100 text-green-700' }
|
||||
}
|
||||
return isDark ? colors[role].dark : colors[role].light
|
||||
}
|
||||
|
||||
// Emoji categories for picker
|
||||
export const emojiCategories = {
|
||||
'Häufig': ['👍', '❤️', '😊', '😂', '🙏', '👏', '🎉', '✅', '📝', '📚'],
|
||||
'Smileys': ['😀', '😃', '😄', '😁', '😅', '😂', '🤣', '😊', '😇', '🙂', '😉', '😌', '😍', '🥰', '😘'],
|
||||
'Gesten': ['👍', '👎', '👌', '✌️', '🤞', '🤝', '👏', '🙌', '👋', '✋', '🤚', '🖐️', '🙏'],
|
||||
'Symbole': ['❤️', '💙', '💚', '💛', '🧡', '💜', '✅', '❌', '⭐', '🌟', '💯', '📌', '📎'],
|
||||
'Schule': ['📚', '📖', '📝', '✏️', '📓', '📕', '📗', '📘', '🎓', '🏫', '📅', '⏰', '🔔']
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
import type { Contact, Conversation, Message, MessageTemplate } from './types'
|
||||
|
||||
export const mockContacts: Contact[] = [
|
||||
{
|
||||
id: 'contact_mueller',
|
||||
name: 'Familie Mueller',
|
||||
email: 'familie.mueller@gmail.com',
|
||||
phone: '+49 170 1234567',
|
||||
role: 'parent',
|
||||
student_name: 'Max Mueller',
|
||||
class_name: '10a',
|
||||
notes: 'Bevorzugt Kommunikation per E-Mail',
|
||||
tags: ['aktiv', 'Elternbeirat'],
|
||||
preferred_channel: 'email',
|
||||
online: false,
|
||||
last_seen: new Date(Date.now() - 1800000).toISOString(),
|
||||
created_at: new Date(Date.now() - 86400000 * 30).toISOString(),
|
||||
updated_at: new Date().toISOString()
|
||||
},
|
||||
{
|
||||
id: 'contact_schmidt',
|
||||
name: 'Petra Schmidt',
|
||||
email: 'p.schmidt@web.de',
|
||||
phone: '+49 171 9876543',
|
||||
role: 'parent',
|
||||
student_name: 'Lisa Schmidt',
|
||||
class_name: '10a',
|
||||
tags: ['responsive'],
|
||||
preferred_channel: 'pwa',
|
||||
online: true,
|
||||
created_at: new Date(Date.now() - 86400000 * 60).toISOString(),
|
||||
updated_at: new Date().toISOString()
|
||||
},
|
||||
{
|
||||
id: 'contact_weber',
|
||||
name: 'Sabine Weber',
|
||||
email: 's.weber@schule-musterstadt.de',
|
||||
role: 'teacher',
|
||||
tags: ['Fachschaft Deutsch', 'Klassenleitung 9b'],
|
||||
preferred_channel: 'pwa',
|
||||
online: true,
|
||||
last_seen: new Date().toISOString(),
|
||||
created_at: new Date(Date.now() - 86400000 * 90).toISOString(),
|
||||
updated_at: new Date().toISOString()
|
||||
},
|
||||
{
|
||||
id: 'contact_hoffmann',
|
||||
name: 'Thomas Hoffmann',
|
||||
email: 't.hoffmann@schule-musterstadt.de',
|
||||
role: 'teacher',
|
||||
tags: ['Fachschaft Mathe', 'Oberstufenkoordinator'],
|
||||
preferred_channel: 'pwa',
|
||||
online: false,
|
||||
last_seen: new Date(Date.now() - 3600000 * 2).toISOString(),
|
||||
created_at: new Date(Date.now() - 86400000 * 120).toISOString(),
|
||||
updated_at: new Date().toISOString()
|
||||
},
|
||||
{
|
||||
id: 'contact_becker',
|
||||
name: 'Familie Becker',
|
||||
email: 'becker.familie@gmx.de',
|
||||
phone: '+49 172 5551234',
|
||||
role: 'parent',
|
||||
student_name: 'Tim Becker',
|
||||
class_name: '10a',
|
||||
tags: [],
|
||||
preferred_channel: 'email',
|
||||
online: false,
|
||||
last_seen: new Date(Date.now() - 86400000).toISOString(),
|
||||
created_at: new Date(Date.now() - 86400000 * 45).toISOString(),
|
||||
updated_at: new Date().toISOString()
|
||||
},
|
||||
{
|
||||
id: 'contact_klein',
|
||||
name: 'Monika Klein',
|
||||
email: 'm.klein@schule-musterstadt.de',
|
||||
role: 'staff',
|
||||
tags: ['Sekretariat'],
|
||||
preferred_channel: 'pwa',
|
||||
online: true,
|
||||
created_at: new Date(Date.now() - 86400000 * 180).toISOString(),
|
||||
updated_at: new Date().toISOString()
|
||||
},
|
||||
{
|
||||
id: 'contact_fischer',
|
||||
name: 'Familie Fischer',
|
||||
email: 'fischer@t-online.de',
|
||||
phone: '+49 173 4445566',
|
||||
role: 'parent',
|
||||
student_name: 'Anna Fischer',
|
||||
class_name: '11b',
|
||||
tags: ['Foerderverein'],
|
||||
preferred_channel: 'pwa',
|
||||
online: false,
|
||||
last_seen: new Date(Date.now() - 7200000).toISOString(),
|
||||
created_at: new Date(Date.now() - 86400000 * 75).toISOString(),
|
||||
updated_at: new Date().toISOString()
|
||||
},
|
||||
{
|
||||
id: 'contact_meyer',
|
||||
name: 'Dr. Hans Meyer',
|
||||
email: 'h.meyer@schule-musterstadt.de',
|
||||
role: 'teacher',
|
||||
tags: ['Schulleitung', 'Stellvertretender Schulleiter'],
|
||||
preferred_channel: 'email',
|
||||
online: false,
|
||||
last_seen: new Date(Date.now() - 3600000).toISOString(),
|
||||
created_at: new Date(Date.now() - 86400000 * 365).toISOString(),
|
||||
updated_at: new Date().toISOString()
|
||||
}
|
||||
]
|
||||
|
||||
export const mockConversations: Conversation[] = [
|
||||
{
|
||||
id: 'conv_mueller',
|
||||
participant_ids: ['contact_mueller'],
|
||||
created_at: new Date(Date.now() - 86400000 * 7).toISOString(),
|
||||
updated_at: new Date(Date.now() - 300000).toISOString(),
|
||||
last_message: 'Vielen Dank fuer die Info! Max freut sich schon auf die Klassenfahrt 🎉',
|
||||
last_message_time: new Date(Date.now() - 300000).toISOString(),
|
||||
unread_count: 2,
|
||||
is_group: false,
|
||||
title: 'Familie Mueller',
|
||||
pinned: true
|
||||
},
|
||||
{
|
||||
id: 'conv_schmidt',
|
||||
participant_ids: ['contact_schmidt'],
|
||||
created_at: new Date(Date.now() - 86400000 * 14).toISOString(),
|
||||
updated_at: new Date(Date.now() - 3600000).toISOString(),
|
||||
last_message: 'Lisa war heute krank, sie kommt morgen wieder.',
|
||||
last_message_time: new Date(Date.now() - 3600000).toISOString(),
|
||||
unread_count: 0,
|
||||
is_group: false,
|
||||
title: 'Petra Schmidt'
|
||||
},
|
||||
{
|
||||
id: 'conv_weber',
|
||||
participant_ids: ['contact_weber'],
|
||||
created_at: new Date(Date.now() - 86400000 * 30).toISOString(),
|
||||
updated_at: new Date(Date.now() - 7200000).toISOString(),
|
||||
last_message: 'Koenntest du mir die Klausuraufgaben bis Freitag schicken? 📝',
|
||||
last_message_time: new Date(Date.now() - 7200000).toISOString(),
|
||||
unread_count: 1,
|
||||
is_group: false,
|
||||
title: 'Sabine Weber',
|
||||
typing: true
|
||||
},
|
||||
{
|
||||
id: 'conv_hoffmann',
|
||||
participant_ids: ['contact_hoffmann'],
|
||||
created_at: new Date(Date.now() - 86400000 * 5).toISOString(),
|
||||
updated_at: new Date(Date.now() - 86400000).toISOString(),
|
||||
last_message: 'Die Notenkonferenz ist am 15.02. um 14:00 Uhr.',
|
||||
last_message_time: new Date(Date.now() - 86400000).toISOString(),
|
||||
unread_count: 0,
|
||||
is_group: false,
|
||||
title: 'Thomas Hoffmann'
|
||||
},
|
||||
{
|
||||
id: 'conv_becker',
|
||||
participant_ids: ['contact_becker'],
|
||||
created_at: new Date(Date.now() - 86400000 * 3).toISOString(),
|
||||
updated_at: new Date(Date.now() - 172800000).toISOString(),
|
||||
last_message: 'Wir haben die Einverstaendniserklaerung unterschrieben.',
|
||||
last_message_time: new Date(Date.now() - 172800000).toISOString(),
|
||||
unread_count: 0,
|
||||
is_group: false,
|
||||
title: 'Familie Becker',
|
||||
muted: true
|
||||
},
|
||||
{
|
||||
id: 'conv_fachschaft',
|
||||
participant_ids: ['contact_weber', 'contact_hoffmann', 'contact_meyer'],
|
||||
created_at: new Date(Date.now() - 86400000 * 60).toISOString(),
|
||||
updated_at: new Date(Date.now() - 14400000).toISOString(),
|
||||
last_message: 'Sabine: Hat jemand die neuen Lehrplaene schon gelesen?',
|
||||
last_message_time: new Date(Date.now() - 14400000).toISOString(),
|
||||
unread_count: 3,
|
||||
is_group: true,
|
||||
title: 'Fachschaft Deutsch 📚'
|
||||
}
|
||||
]
|
||||
|
||||
export const mockMessages: Record<string, Message[]> = {
|
||||
'conv_mueller': [
|
||||
{ id: 'msg_m1', conversation_id: 'conv_mueller', sender_id: 'self', content: 'Guten Tag Frau Mueller,\n\nich moechte Sie ueber die anstehende Klassenfahrt nach Berlin informieren. Die Reise findet vom 15.-19. April statt.', content_type: 'text', timestamp: new Date(Date.now() - 86400000).toISOString(), read: true, delivered: true, send_email: true, email_sent: true, email_sent_at: new Date(Date.now() - 86400000).toISOString() },
|
||||
{ id: 'msg_m2', conversation_id: 'conv_mueller', sender_id: 'self', content: 'Die Kosten belaufen sich auf 280 Euro pro Schueler. Bitte ueberweisen Sie den Betrag bis zum 01.03. auf das Schulkonto.', content_type: 'text', timestamp: new Date(Date.now() - 86400000 + 60000).toISOString(), read: true, delivered: true, send_email: false, email_sent: false },
|
||||
{ id: 'msg_m3', conversation_id: 'conv_mueller', sender_id: 'contact_mueller', content: 'Vielen Dank fuer die Information! Wir werden den Betrag diese Woche ueberweisen.', content_type: 'text', timestamp: new Date(Date.now() - 3600000).toISOString(), read: false, delivered: true, send_email: false, email_sent: false, reactions: [{ emoji: '👍', user_id: 'self' }] },
|
||||
{ id: 'msg_m4', conversation_id: 'conv_mueller', sender_id: 'contact_mueller', content: 'Vielen Dank fuer die Info! Max freut sich schon auf die Klassenfahrt 🎉', content_type: 'text', timestamp: new Date(Date.now() - 300000).toISOString(), read: false, delivered: true, send_email: false, email_sent: false },
|
||||
],
|
||||
'conv_schmidt': [
|
||||
{ id: 'msg_s1', conversation_id: 'conv_schmidt', sender_id: 'contact_schmidt', content: 'Guten Morgen! Lisa ist heute leider krank und kann nicht zur Schule kommen.', content_type: 'text', timestamp: new Date(Date.now() - 86400000 * 2).toISOString(), read: true, delivered: true, send_email: false, email_sent: false },
|
||||
{ id: 'msg_s2', conversation_id: 'conv_schmidt', sender_id: 'self', content: 'Gute Besserung an Lisa! 🤒 Soll ich ihr die Hausaufgaben zukommen lassen?', content_type: 'text', timestamp: new Date(Date.now() - 86400000 * 2 + 1800000).toISOString(), read: true, delivered: true, send_email: false, email_sent: false },
|
||||
{ id: 'msg_s3', conversation_id: 'conv_schmidt', sender_id: 'contact_schmidt', content: 'Das waere sehr nett, vielen Dank! 🙏', content_type: 'text', timestamp: new Date(Date.now() - 86400000 * 2 + 3600000).toISOString(), read: true, delivered: true, send_email: false, email_sent: false },
|
||||
{ id: 'msg_s4', conversation_id: 'conv_schmidt', sender_id: 'self', content: 'Hier sind die Hausaufgaben fuer diese Woche:\n\n📖 Deutsch: Seite 45-48 lesen\n📝 Mathe: Aufgaben 1-5 auf Seite 112\n🔬 Bio: Referat vorbereiten', content_type: 'text', timestamp: new Date(Date.now() - 86400000).toISOString(), read: true, delivered: true, send_email: true, email_sent: true },
|
||||
{ id: 'msg_s5', conversation_id: 'conv_schmidt', sender_id: 'contact_schmidt', content: 'Lisa war heute krank, sie kommt morgen wieder.', content_type: 'text', timestamp: new Date(Date.now() - 3600000).toISOString(), read: true, delivered: true, send_email: false, email_sent: false },
|
||||
],
|
||||
'conv_weber': [
|
||||
{ id: 'msg_w1', conversation_id: 'conv_weber', sender_id: 'contact_weber', content: 'Hi! Hast du schon die neuen Abi-Themen gesehen?', content_type: 'text', timestamp: new Date(Date.now() - 86400000 * 3).toISOString(), read: true, delivered: true, send_email: false, email_sent: false },
|
||||
{ id: 'msg_w2', conversation_id: 'conv_weber', sender_id: 'self', content: 'Ja, habe ich! Finde ich ganz gut machbar dieses Jahr. 📚', content_type: 'text', timestamp: new Date(Date.now() - 86400000 * 3 + 1800000).toISOString(), read: true, delivered: true, send_email: false, email_sent: false },
|
||||
{ id: 'msg_w3', conversation_id: 'conv_weber', sender_id: 'contact_weber', content: 'Koenntest du mir die Klausuraufgaben bis Freitag schicken? 📝', content_type: 'text', timestamp: new Date(Date.now() - 7200000).toISOString(), read: false, delivered: true, send_email: false, email_sent: false },
|
||||
],
|
||||
'conv_hoffmann': [
|
||||
{ id: 'msg_h1', conversation_id: 'conv_hoffmann', sender_id: 'contact_hoffmann', content: 'Kurze Info: Die Notenkonferenz ist am 15.02. um 14:00 Uhr.', content_type: 'text', timestamp: new Date(Date.now() - 86400000 * 2).toISOString(), read: true, delivered: true, send_email: false, email_sent: false },
|
||||
{ id: 'msg_h2', conversation_id: 'conv_hoffmann', sender_id: 'self', content: 'Danke fuer die Info! Bin dabei. 👍', content_type: 'text', timestamp: new Date(Date.now() - 86400000 * 2 + 3600000).toISOString(), read: true, delivered: true, send_email: false, email_sent: false },
|
||||
{ id: 'msg_h3', conversation_id: 'conv_hoffmann', sender_id: 'contact_hoffmann', content: 'Die Notenkonferenz ist am 15.02. um 14:00 Uhr.', content_type: 'text', timestamp: new Date(Date.now() - 86400000).toISOString(), read: true, delivered: true, send_email: false, email_sent: false },
|
||||
],
|
||||
'conv_becker': [
|
||||
{ id: 'msg_b1', conversation_id: 'conv_becker', sender_id: 'self', content: 'Guten Tag Familie Becker,\n\nbitte vergessen Sie nicht, die Einverstaendniserklaerung fuer den Schwimmunterricht zu unterschreiben.', content_type: 'text', timestamp: new Date(Date.now() - 86400000 * 4).toISOString(), read: true, delivered: true, send_email: true, email_sent: true },
|
||||
{ id: 'msg_b2', conversation_id: 'conv_becker', sender_id: 'contact_becker', content: 'Wir haben die Einverstaendniserklaerung unterschrieben.', content_type: 'text', timestamp: new Date(Date.now() - 172800000).toISOString(), read: true, delivered: true, send_email: false, email_sent: false },
|
||||
],
|
||||
'conv_fachschaft': [
|
||||
{ id: 'msg_f1', conversation_id: 'conv_fachschaft', sender_id: 'contact_meyer', content: 'Liebe Kolleginnen und Kollegen,\n\ndie neuen Lehrplaene sind jetzt online verfuegbar.', content_type: 'text', timestamp: new Date(Date.now() - 86400000).toISOString(), read: true, delivered: true, send_email: false, email_sent: false },
|
||||
{ id: 'msg_f2', conversation_id: 'conv_fachschaft', sender_id: 'contact_hoffmann', content: 'Danke fuer die Info! Werde ich mir heute Abend anschauen.', content_type: 'text', timestamp: new Date(Date.now() - 72000000).toISOString(), read: true, delivered: true, send_email: false, email_sent: false },
|
||||
{ id: 'msg_f3', conversation_id: 'conv_fachschaft', sender_id: 'contact_weber', content: 'Hat jemand die neuen Lehrplaene schon gelesen?', content_type: 'text', timestamp: new Date(Date.now() - 14400000).toISOString(), read: false, delivered: true, send_email: false, email_sent: false },
|
||||
{ id: 'msg_f4', conversation_id: 'conv_fachschaft', sender_id: 'contact_hoffmann', content: 'Noch nicht komplett, aber sieht interessant aus! 📖', content_type: 'text', timestamp: new Date(Date.now() - 10800000).toISOString(), read: false, delivered: true, send_email: false, email_sent: false },
|
||||
{ id: 'msg_f5', conversation_id: 'conv_fachschaft', sender_id: 'contact_meyer', content: 'Wir sollten naechste Woche eine Besprechung ansetzen.', content_type: 'text', timestamp: new Date(Date.now() - 7200000).toISOString(), read: false, delivered: true, send_email: false, email_sent: false },
|
||||
]
|
||||
}
|
||||
|
||||
export const mockTemplates: MessageTemplate[] = [
|
||||
{ id: 'tpl_1', name: 'Krankmeldung bestaetigen', content: 'Vielen Dank fuer die Krankmeldung. Gute Besserung! 🤒', created_at: new Date().toISOString() },
|
||||
{ id: 'tpl_2', name: 'Hausaufgaben senden', content: 'Hier sind die Hausaufgaben fuer diese Woche:\n\n📖 Deutsch: \n📝 Mathe: \n🔬 Bio: ', created_at: new Date().toISOString() },
|
||||
{ id: 'tpl_3', name: 'Elterngespraech anfragen', content: 'Guten Tag,\n\nich wuerde gerne ein Elterngespraech mit Ihnen vereinbaren. Wann haetten Sie Zeit?', created_at: new Date().toISOString() },
|
||||
{ id: 'tpl_4', name: 'Termin bestaetigen', content: 'Vielen Dank, der Termin ist bestaetigt. Ich freue mich auf unser Gespraech! 📅', created_at: new Date().toISOString() },
|
||||
]
|
||||
@@ -0,0 +1,99 @@
|
||||
export interface Contact {
|
||||
id: string
|
||||
name: string
|
||||
email?: string
|
||||
phone?: string
|
||||
role: 'parent' | 'teacher' | 'staff' | 'student'
|
||||
student_name?: string
|
||||
class_name?: string
|
||||
notes?: string
|
||||
tags: string[]
|
||||
avatar_url?: string
|
||||
preferred_channel: 'email' | 'matrix' | 'pwa'
|
||||
online: boolean
|
||||
last_seen?: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface Message {
|
||||
id: string
|
||||
conversation_id: string
|
||||
sender_id: string // "self" for own messages
|
||||
content: string
|
||||
content_type: 'text' | 'file' | 'image' | 'voice'
|
||||
file_url?: string
|
||||
file_name?: string
|
||||
timestamp: string
|
||||
read: boolean
|
||||
read_at?: string
|
||||
delivered: boolean
|
||||
send_email: boolean
|
||||
email_sent: boolean
|
||||
email_sent_at?: string
|
||||
email_error?: string
|
||||
reply_to?: string // ID of message being replied to
|
||||
reactions?: { emoji: string; user_id: string }[]
|
||||
}
|
||||
|
||||
export interface Conversation {
|
||||
id: string
|
||||
participant_ids: string[]
|
||||
group_id?: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
last_message?: string
|
||||
last_message_time?: string
|
||||
unread_count: number
|
||||
is_group: boolean
|
||||
title?: string
|
||||
typing?: boolean // Someone is typing
|
||||
pinned?: boolean
|
||||
muted?: boolean
|
||||
archived?: boolean
|
||||
}
|
||||
|
||||
export interface MessageTemplate {
|
||||
id: string
|
||||
name: string
|
||||
content: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface MessagesStats {
|
||||
total_contacts: number
|
||||
total_conversations: number
|
||||
total_messages: number
|
||||
unread_messages: number
|
||||
}
|
||||
|
||||
export interface MessagesContextType {
|
||||
// Data
|
||||
contacts: Contact[]
|
||||
conversations: Conversation[]
|
||||
messages: Record<string, Message[]> // conversationId -> messages
|
||||
templates: MessageTemplate[]
|
||||
stats: MessagesStats
|
||||
|
||||
// Computed
|
||||
unreadCount: number
|
||||
recentConversations: Conversation[]
|
||||
|
||||
// Actions
|
||||
fetchContacts: () => Promise<void>
|
||||
fetchConversations: () => Promise<void>
|
||||
fetchMessages: (conversationId: string) => Promise<Message[]>
|
||||
sendMessage: (conversationId: string, content: string, sendEmail?: boolean, replyTo?: string) => Promise<Message | null>
|
||||
markAsRead: (conversationId: string) => Promise<void>
|
||||
createConversation: (contactId: string) => Promise<Conversation | null>
|
||||
addReaction: (messageId: string, emoji: string) => void
|
||||
deleteMessage: (conversationId: string, messageId: string) => void
|
||||
pinConversation: (conversationId: string) => void
|
||||
muteConversation: (conversationId: string) => void
|
||||
|
||||
// State
|
||||
isLoading: boolean
|
||||
error: string | null
|
||||
currentConversationId: string | null
|
||||
setCurrentConversationId: (id: string | null) => void
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import type { Requirement, Regulation, RequirementUpdate, AIInterpretation } from '../types'
|
||||
import { IMPLEMENTATION_STATUS, AUDIT_STATUS } from '../types'
|
||||
|
||||
export default function RequirementDetailPanel({
|
||||
requirement,
|
||||
regulation,
|
||||
onUpdate,
|
||||
saving,
|
||||
}: {
|
||||
requirement: Requirement
|
||||
regulation: Regulation | undefined
|
||||
onUpdate: (updates: RequirementUpdate) => void
|
||||
saving: boolean
|
||||
}) {
|
||||
const BACKEND_URL = process.env.NEXT_PUBLIC_BACKEND_URL || 'http://localhost:8000'
|
||||
const [editMode, setEditMode] = useState(false)
|
||||
const [aiLoading, setAiLoading] = useState(false)
|
||||
const [aiInterpretation, setAiInterpretation] = useState<AIInterpretation | null>(null)
|
||||
const [showAiPanel, setShowAiPanel] = useState(false)
|
||||
const [localData, setLocalData] = useState({
|
||||
implementation_status: requirement.implementation_status,
|
||||
implementation_details: requirement.implementation_details || '',
|
||||
evidence_description: requirement.evidence_description || '',
|
||||
audit_status: requirement.audit_status,
|
||||
auditor_notes: requirement.auditor_notes || '',
|
||||
is_applicable: requirement.is_applicable,
|
||||
applicability_reason: requirement.applicability_reason || '',
|
||||
})
|
||||
const [newCodeRef, setNewCodeRef] = useState({ file: '', line: '', description: '' })
|
||||
|
||||
useEffect(() => {
|
||||
setLocalData({
|
||||
implementation_status: requirement.implementation_status,
|
||||
implementation_details: requirement.implementation_details || '',
|
||||
evidence_description: requirement.evidence_description || '',
|
||||
audit_status: requirement.audit_status,
|
||||
auditor_notes: requirement.auditor_notes || '',
|
||||
is_applicable: requirement.is_applicable,
|
||||
applicability_reason: requirement.applicability_reason || '',
|
||||
})
|
||||
setEditMode(false)
|
||||
setAiInterpretation(null)
|
||||
setShowAiPanel(false)
|
||||
}, [requirement.id])
|
||||
|
||||
const generateAiInterpretation = async () => {
|
||||
setAiLoading(true)
|
||||
setShowAiPanel(true)
|
||||
try {
|
||||
const res = await fetch(`${BACKEND_URL}/api/v1/compliance/ai/interpret`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ requirement_id: requirement.id }),
|
||||
})
|
||||
if (res.ok) {
|
||||
setAiInterpretation(await res.json())
|
||||
} else {
|
||||
const err = await res.json()
|
||||
setAiInterpretation({
|
||||
summary: '', applicability: '', technical_measures: [],
|
||||
affected_modules: [], risk_level: 'unknown', implementation_hints: [],
|
||||
confidence_score: 0, error: err.detail || 'Fehler bei AI-Analyse'
|
||||
})
|
||||
}
|
||||
} catch (err) {
|
||||
setAiInterpretation({
|
||||
summary: '', applicability: '', technical_measures: [],
|
||||
affected_modules: [], risk_level: 'unknown', implementation_hints: [],
|
||||
confidence_score: 0, error: 'Netzwerkfehler bei AI-Analyse'
|
||||
})
|
||||
} finally {
|
||||
setAiLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSave = () => {
|
||||
onUpdate(localData)
|
||||
setEditMode(false)
|
||||
}
|
||||
|
||||
const addCodeReference = () => {
|
||||
if (!newCodeRef.file) return
|
||||
const refs = requirement.code_references || []
|
||||
onUpdate({
|
||||
code_references: [...refs, {
|
||||
file: newCodeRef.file,
|
||||
line: newCodeRef.line ? parseInt(newCodeRef.line) : undefined,
|
||||
description: newCodeRef.description,
|
||||
}],
|
||||
})
|
||||
setNewCodeRef({ file: '', line: '', description: '' })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg border border-slate-200 overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="p-4 border-b border-slate-200 bg-slate-50">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="font-mono text-lg font-semibold text-slate-900">
|
||||
{requirement.article}{requirement.paragraph ? ` ${requirement.paragraph}` : ''}
|
||||
</span>
|
||||
<span className={`px-2 py-0.5 text-xs font-medium rounded ${
|
||||
AUDIT_STATUS[requirement.audit_status as keyof typeof AUDIT_STATUS]?.color || 'bg-slate-200'
|
||||
} text-white`}>
|
||||
{AUDIT_STATUS[requirement.audit_status as keyof typeof AUDIT_STATUS]?.label || requirement.audit_status}
|
||||
</span>
|
||||
<span className={`px-2 py-0.5 text-xs font-medium rounded ${
|
||||
IMPLEMENTATION_STATUS[requirement.implementation_status as keyof typeof IMPLEMENTATION_STATUS]?.color || 'bg-slate-200'
|
||||
} text-white`}>
|
||||
{IMPLEMENTATION_STATUS[requirement.implementation_status as keyof typeof IMPLEMENTATION_STATUS]?.label || requirement.implementation_status}
|
||||
</span>
|
||||
</div>
|
||||
<h2 className="text-lg font-medium text-slate-900 mt-1">{requirement.title}</h2>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{editMode ? (
|
||||
<>
|
||||
<button onClick={() => setEditMode(false)} className="px-3 py-1.5 text-sm text-slate-600 hover:text-slate-800">Abbrechen</button>
|
||||
<button onClick={handleSave} disabled={saving} className="px-3 py-1.5 text-sm bg-primary-600 text-white rounded-lg hover:bg-primary-700 disabled:opacity-50">
|
||||
{saving ? 'Speichern...' : 'Speichern'}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button onClick={() => setEditMode(true)} className="px-3 py-1.5 text-sm bg-slate-100 text-slate-700 rounded-lg hover:bg-slate-200">Bearbeiten</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 space-y-6">
|
||||
{/* Original Requirement Text */}
|
||||
<section>
|
||||
<h3 className="text-sm font-semibold text-slate-700 uppercase tracking-wide mb-2 flex items-center gap-2">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" /></svg>
|
||||
Originaler Anforderungstext
|
||||
</h3>
|
||||
<div className="bg-slate-50 border border-slate-200 rounded-lg p-4">
|
||||
<p className="text-sm text-slate-700 whitespace-pre-wrap">{requirement.requirement_text || 'Kein Originaltext hinterlegt'}</p>
|
||||
{requirement.source_page && (
|
||||
<p className="text-xs text-slate-500 mt-2">Quelle: {regulation?.code} Seite {requirement.source_page}{requirement.source_section ? `, ${requirement.source_section}` : ''}</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Applicability */}
|
||||
<section>
|
||||
<h3 className="text-sm font-semibold text-slate-700 uppercase tracking-wide mb-2">Anwendbarkeit auf Breakpilot</h3>
|
||||
{editMode ? (
|
||||
<div className="space-y-2">
|
||||
<label className="flex items-center gap-2">
|
||||
<input type="checkbox" checked={localData.is_applicable} onChange={(e) => setLocalData({ ...localData, is_applicable: e.target.checked })} className="rounded" />
|
||||
<span className="text-sm text-slate-700">Anwendbar</span>
|
||||
</label>
|
||||
<textarea value={localData.applicability_reason} onChange={(e) => setLocalData({ ...localData, applicability_reason: e.target.value })} placeholder="Begruendung fuer Anwendbarkeit..." className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm" rows={2} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-start gap-3">
|
||||
<span className={`px-2 py-1 text-xs font-medium rounded ${requirement.is_applicable ? 'bg-green-100 text-green-700' : 'bg-slate-100 text-slate-600'}`}>
|
||||
{requirement.is_applicable ? 'Anwendbar' : 'Nicht anwendbar'}
|
||||
</span>
|
||||
{requirement.applicability_reason && <p className="text-sm text-slate-600">{requirement.applicability_reason}</p>}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Interpretation & AI Analysis */}
|
||||
<section>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h3 className="text-sm font-semibold text-slate-700 uppercase tracking-wide flex items-center gap-2">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" /></svg>
|
||||
Interpretation
|
||||
</h3>
|
||||
<button onClick={generateAiInterpretation} disabled={aiLoading} className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-white bg-gradient-to-r from-purple-600 to-pink-600 rounded-lg hover:from-purple-700 hover:to-pink-700 disabled:opacity-50 transition-all">
|
||||
{aiLoading ? (
|
||||
<><svg className="animate-spin h-3 w-3" viewBox="0 0 24 24"><circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" /><path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" /></svg> AI analysiert...</>
|
||||
) : (
|
||||
<><svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" /></svg> AI Analyse</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-3">
|
||||
<p className="text-sm text-blue-800">{requirement.breakpilot_interpretation || 'Keine Interpretation hinterlegt'}</p>
|
||||
</div>
|
||||
|
||||
{/* AI Panel */}
|
||||
{showAiPanel && (
|
||||
<div className="bg-gradient-to-br from-purple-50 to-pink-50 border border-purple-200 rounded-lg p-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-sm font-semibold text-purple-800 flex items-center gap-2">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" /></svg>
|
||||
AI-generierte Analyse
|
||||
</h4>
|
||||
{aiInterpretation?.confidence_score ? <span className="text-xs text-purple-600">Konfidenz: {Math.round(aiInterpretation.confidence_score * 100)}%</span> : null}
|
||||
</div>
|
||||
{aiLoading && <div className="text-center py-4"><div className="animate-pulse text-purple-600">Claude analysiert die Anforderung...</div></div>}
|
||||
{aiInterpretation?.error && <div className="bg-red-100 text-red-700 p-3 rounded text-sm">{aiInterpretation.error}</div>}
|
||||
{aiInterpretation && !aiInterpretation.error && !aiLoading && (
|
||||
<div className="space-y-3 text-sm">
|
||||
{aiInterpretation.summary && <div><div className="font-medium text-purple-700 mb-1">Zusammenfassung</div><p className="text-slate-700">{aiInterpretation.summary}</p></div>}
|
||||
{aiInterpretation.applicability && <div><div className="font-medium text-purple-700 mb-1">Anwendbarkeit auf Breakpilot</div><p className="text-slate-700">{aiInterpretation.applicability}</p></div>}
|
||||
{aiInterpretation.risk_level && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-purple-700">Risiko:</span>
|
||||
<span className={`px-2 py-0.5 rounded text-xs font-medium ${aiInterpretation.risk_level === 'critical' ? 'bg-red-100 text-red-700' : aiInterpretation.risk_level === 'high' ? 'bg-orange-100 text-orange-700' : aiInterpretation.risk_level === 'medium' ? 'bg-yellow-100 text-yellow-700' : 'bg-green-100 text-green-700'}`}>{aiInterpretation.risk_level}</span>
|
||||
</div>
|
||||
)}
|
||||
{aiInterpretation.technical_measures?.length > 0 && <div><div className="font-medium text-purple-700 mb-1">Technische Massnahmen</div><ul className="list-disc list-inside text-slate-700 space-y-1">{aiInterpretation.technical_measures.map((m, i) => <li key={i}>{m}</li>)}</ul></div>}
|
||||
{aiInterpretation.affected_modules?.length > 0 && <div><div className="font-medium text-purple-700 mb-1">Betroffene Module</div><div className="flex flex-wrap gap-1">{aiInterpretation.affected_modules.map((m, i) => <span key={i} className="px-2 py-0.5 bg-purple-100 text-purple-700 rounded text-xs">{m}</span>)}</div></div>}
|
||||
{aiInterpretation.implementation_hints?.length > 0 && <div><div className="font-medium text-purple-700 mb-1">Implementierungshinweise</div><ul className="list-disc list-inside text-slate-700 space-y-1">{aiInterpretation.implementation_hints.map((h, i) => <li key={i}>{h}</li>)}</ul></div>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Implementation Details */}
|
||||
<section>
|
||||
<h3 className="text-sm font-semibold text-slate-700 uppercase tracking-wide mb-2 flex items-center gap-2">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" /></svg>
|
||||
Umsetzung (fuer Auditor)
|
||||
</h3>
|
||||
{editMode ? (
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-xs text-slate-500 mb-1">Implementierungsstatus</label>
|
||||
<select value={localData.implementation_status} onChange={(e) => setLocalData({ ...localData, implementation_status: e.target.value })} className="px-3 py-2 border border-slate-300 rounded-lg text-sm">
|
||||
{Object.entries(IMPLEMENTATION_STATUS).map(([key, { label }]) => <option key={key} value={key}>{label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<textarea value={localData.implementation_details} onChange={(e) => setLocalData({ ...localData, implementation_details: e.target.value })} placeholder="Beschreiben Sie, wie diese Anforderung in Breakpilot umgesetzt wurde..." className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm" rows={4} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-green-50 border border-green-200 rounded-lg p-4">
|
||||
<p className="text-sm text-green-800 whitespace-pre-wrap">{requirement.implementation_details || 'Noch keine Umsetzungsdetails dokumentiert'}</p>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Code References */}
|
||||
<section>
|
||||
<h3 className="text-sm font-semibold text-slate-700 uppercase tracking-wide mb-2">Code-Referenzen</h3>
|
||||
<div className="space-y-2">
|
||||
{(requirement.code_references || []).map((ref, idx) => (
|
||||
<div key={idx} className="flex items-center gap-2 bg-slate-50 p-2 rounded-lg text-sm">
|
||||
<code className="text-primary-600">{ref.file}{ref.line ? `:${ref.line}` : ''}</code>
|
||||
<span className="text-slate-500">-</span>
|
||||
<span className="text-slate-700">{ref.description}</span>
|
||||
</div>
|
||||
))}
|
||||
{editMode && (
|
||||
<div className="flex gap-2">
|
||||
<input type="text" value={newCodeRef.file} onChange={(e) => setNewCodeRef({ ...newCodeRef, file: e.target.value })} placeholder="Datei (z.B. backend/auth.py)" className="flex-1 px-2 py-1.5 text-sm border border-slate-300 rounded" />
|
||||
<input type="text" value={newCodeRef.line} onChange={(e) => setNewCodeRef({ ...newCodeRef, line: e.target.value })} placeholder="Zeile" className="w-20 px-2 py-1.5 text-sm border border-slate-300 rounded" />
|
||||
<input type="text" value={newCodeRef.description} onChange={(e) => setNewCodeRef({ ...newCodeRef, description: e.target.value })} placeholder="Beschreibung" className="flex-1 px-2 py-1.5 text-sm border border-slate-300 rounded" />
|
||||
<button onClick={addCodeReference} className="px-3 py-1.5 bg-primary-600 text-white rounded text-sm">+</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Evidence */}
|
||||
<section>
|
||||
<h3 className="text-sm font-semibold text-slate-700 uppercase tracking-wide mb-2 flex items-center gap-2">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" /></svg>
|
||||
Nachweis / Evidence
|
||||
</h3>
|
||||
{editMode ? (
|
||||
<textarea value={localData.evidence_description} onChange={(e) => setLocalData({ ...localData, evidence_description: e.target.value })} placeholder="Welche Nachweise belegen die Erfuellung dieser Anforderung?" className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm" rows={3} />
|
||||
) : (
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4">
|
||||
<p className="text-sm text-amber-800">{requirement.evidence_description || 'Keine Nachweise beschrieben'}</p>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Auditor Section */}
|
||||
<section className="border-t border-slate-200 pt-4">
|
||||
<h3 className="text-sm font-semibold text-slate-700 uppercase tracking-wide mb-2 flex items-center gap-2">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" /></svg>
|
||||
Auditor-Bereich
|
||||
</h3>
|
||||
{editMode ? (
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-xs text-slate-500 mb-1">Audit-Status</label>
|
||||
<select value={localData.audit_status} onChange={(e) => setLocalData({ ...localData, audit_status: e.target.value })} className="px-3 py-2 border border-slate-300 rounded-lg text-sm">
|
||||
{Object.entries(AUDIT_STATUS).map(([key, { label }]) => <option key={key} value={key}>{label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<textarea value={localData.auditor_notes} onChange={(e) => setLocalData({ ...localData, auditor_notes: e.target.value })} placeholder="Notizen des Auditors..." className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm" rows={3} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-slate-50 border border-slate-200 rounded-lg p-4">
|
||||
<p className="text-sm text-slate-700">{requirement.auditor_notes || 'Keine Auditor-Notizen'}</p>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
'use client'
|
||||
|
||||
import type { Requirement, Regulation, Category } from '../types'
|
||||
import { AUDIT_STATUS, IMPLEMENTATION_STATUS, PRIORITY_LABELS } from '../types'
|
||||
|
||||
export default function RequirementList({
|
||||
regulations,
|
||||
selectedRegulation,
|
||||
setSelectedRegulation,
|
||||
filteredRequirements,
|
||||
selectedRequirement,
|
||||
setSelectedRequirement,
|
||||
searchQuery,
|
||||
setSearchQuery,
|
||||
filterAuditStatus,
|
||||
setFilterAuditStatus,
|
||||
filterImplStatus,
|
||||
setFilterImplStatus,
|
||||
}: {
|
||||
regulations: Regulation[]
|
||||
selectedRegulation: string | null
|
||||
setSelectedRegulation: (code: string) => void
|
||||
filteredRequirements: Requirement[]
|
||||
selectedRequirement: Requirement | null
|
||||
setSelectedRequirement: (req: Requirement) => void
|
||||
searchQuery: string
|
||||
setSearchQuery: (q: string) => void
|
||||
filterAuditStatus: string
|
||||
setFilterAuditStatus: (s: string) => void
|
||||
filterImplStatus: string
|
||||
setFilterImplStatus: (s: string) => void
|
||||
}) {
|
||||
const currentRegulation = regulations.find(r => r.code === selectedRegulation)
|
||||
|
||||
return (
|
||||
<div className="col-span-4 space-y-4">
|
||||
{/* Regulation Selector */}
|
||||
<div className="bg-white rounded-lg border border-slate-200 p-4">
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">
|
||||
Verordnung / Standard
|
||||
</label>
|
||||
<select
|
||||
value={selectedRegulation || ''}
|
||||
onChange={(e) => setSelectedRegulation(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
>
|
||||
{regulations.map(reg => (
|
||||
<option key={reg.code} value={reg.code}>
|
||||
{reg.code} - {reg.name} ({reg.requirement_count})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{currentRegulation?.source_url && (
|
||||
<a
|
||||
href={currentRegulation.source_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="mt-2 text-sm text-primary-600 hover:text-primary-800 flex items-center gap-1"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
</svg>
|
||||
Originaldokument oeffnen
|
||||
</a>
|
||||
)}
|
||||
|
||||
{currentRegulation?.local_pdf_path && (
|
||||
<a
|
||||
href={`/docs/${currentRegulation.local_pdf_path}`}
|
||||
target="_blank"
|
||||
className="mt-1 text-sm text-slate-600 hover:text-slate-800 flex items-center gap-1"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
|
||||
</svg>
|
||||
Lokale PDF
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="bg-white rounded-lg border border-slate-200 p-4 space-y-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-500 mb-1">Suche</label>
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Artikel, Titel..."
|
||||
className="w-full px-3 py-1.5 text-sm border border-slate-300 rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-500 mb-1">Audit-Status</label>
|
||||
<select
|
||||
value={filterAuditStatus}
|
||||
onChange={(e) => setFilterAuditStatus(e.target.value)}
|
||||
className="w-full px-2 py-1.5 text-sm border border-slate-300 rounded-lg"
|
||||
>
|
||||
<option value="all">Alle</option>
|
||||
{Object.entries(AUDIT_STATUS).map(([key, { label }]) => (
|
||||
<option key={key} value={key}>{label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-500 mb-1">Impl.-Status</label>
|
||||
<select
|
||||
value={filterImplStatus}
|
||||
onChange={(e) => setFilterImplStatus(e.target.value)}
|
||||
className="w-full px-2 py-1.5 text-sm border border-slate-300 rounded-lg"
|
||||
>
|
||||
<option value="all">Alle</option>
|
||||
{Object.entries(IMPLEMENTATION_STATUS).map(([key, { label }]) => (
|
||||
<option key={key} value={key}>{label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Requirements List */}
|
||||
<div className="bg-white rounded-lg border border-slate-200 overflow-hidden">
|
||||
<div className="p-3 border-b border-slate-200 bg-slate-50">
|
||||
<span className="text-sm font-medium text-slate-700">
|
||||
Anforderungen ({filteredRequirements.length})
|
||||
</span>
|
||||
</div>
|
||||
<div className="max-h-[500px] overflow-y-auto">
|
||||
{filteredRequirements.map(req => (
|
||||
<button
|
||||
key={req.id}
|
||||
onClick={() => setSelectedRequirement(req)}
|
||||
className={`w-full text-left p-3 border-b border-slate-100 hover:bg-slate-50 transition-colors ${
|
||||
selectedRequirement?.id === req.id ? 'bg-primary-50 border-l-4 border-l-primary-500' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono text-sm text-slate-600">
|
||||
{req.article}{req.paragraph ? ` ${req.paragraph}` : ''}
|
||||
</span>
|
||||
<span className={`text-xs ${PRIORITY_LABELS[req.priority]?.color || 'text-slate-500'}`}>
|
||||
{PRIORITY_LABELS[req.priority]?.label || ''}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-slate-900 truncate mt-0.5">{req.title}</p>
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
<span className={`w-2 h-2 rounded-full ${AUDIT_STATUS[req.audit_status as keyof typeof AUDIT_STATUS]?.color || 'bg-slate-300'}`} />
|
||||
<span className={`w-2 h-2 rounded-full ${IMPLEMENTATION_STATUS[req.implementation_status as keyof typeof IMPLEMENTATION_STATUS]?.color || 'bg-slate-300'}`} />
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -15,72 +15,9 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import Link from 'next/link'
|
||||
import AdminLayout from '@/components/admin/AdminLayout'
|
||||
|
||||
// Types
|
||||
interface Regulation {
|
||||
id: string
|
||||
code: string
|
||||
name: string
|
||||
full_name: string
|
||||
regulation_type: string
|
||||
source_url: string | null
|
||||
local_pdf_path: string | null
|
||||
requirement_count: number
|
||||
}
|
||||
|
||||
interface Requirement {
|
||||
id: string
|
||||
regulation_id: string
|
||||
regulation_code?: string
|
||||
article: string
|
||||
paragraph: string | null
|
||||
title: string
|
||||
description: string | null
|
||||
requirement_text: string | null
|
||||
breakpilot_interpretation: string | null
|
||||
implementation_status: string
|
||||
implementation_details: string | null
|
||||
code_references: Array<{ file: string; line?: number; description: string }> | null
|
||||
evidence_description: string | null
|
||||
audit_status: string
|
||||
auditor_notes: string | null
|
||||
is_applicable: boolean
|
||||
applicability_reason: string | null
|
||||
priority: number
|
||||
source_page: number | null
|
||||
source_section: string | null
|
||||
}
|
||||
|
||||
interface RequirementUpdate {
|
||||
implementation_status?: string
|
||||
implementation_details?: string
|
||||
code_references?: Array<{ file: string; line?: number; description: string }>
|
||||
evidence_description?: string
|
||||
audit_status?: string
|
||||
auditor_notes?: string
|
||||
is_applicable?: boolean
|
||||
applicability_reason?: string
|
||||
}
|
||||
|
||||
const IMPLEMENTATION_STATUS = {
|
||||
not_started: { label: 'Nicht gestartet', color: 'bg-slate-400' },
|
||||
in_progress: { label: 'In Arbeit', color: 'bg-yellow-500' },
|
||||
implemented: { label: 'Implementiert', color: 'bg-blue-500' },
|
||||
verified: { label: 'Verifiziert', color: 'bg-green-500' },
|
||||
}
|
||||
|
||||
const AUDIT_STATUS = {
|
||||
pending: { label: 'Ausstehend', color: 'bg-slate-400' },
|
||||
in_review: { label: 'In Pruefung', color: 'bg-yellow-500' },
|
||||
approved: { label: 'Genehmigt', color: 'bg-green-500' },
|
||||
rejected: { label: 'Abgelehnt', color: 'bg-red-500' },
|
||||
}
|
||||
|
||||
const PRIORITY_LABELS: Record<number, { label: string; color: string }> = {
|
||||
1: { label: 'Kritisch', color: 'text-red-600' },
|
||||
2: { label: 'Hoch', color: 'text-orange-600' },
|
||||
3: { label: 'Mittel', color: 'text-yellow-600' },
|
||||
}
|
||||
import type { Regulation, Requirement, RequirementUpdate } from './types'
|
||||
import RequirementList from './_components/RequirementList'
|
||||
import RequirementDetailPanel from './_components/RequirementDetailPanel'
|
||||
|
||||
export default function AuditWorkspacePage() {
|
||||
const [regulations, setRegulations] = useState<Regulation[]>([])
|
||||
@@ -95,14 +32,10 @@ export default function AuditWorkspacePage() {
|
||||
|
||||
const BACKEND_URL = process.env.NEXT_PUBLIC_BACKEND_URL || 'http://localhost:8000'
|
||||
|
||||
useEffect(() => {
|
||||
loadRegulations()
|
||||
}, [])
|
||||
useEffect(() => { loadRegulations() }, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedRegulation) {
|
||||
loadRequirements(selectedRegulation)
|
||||
}
|
||||
if (selectedRegulation) loadRequirements(selectedRegulation)
|
||||
}, [selectedRegulation])
|
||||
|
||||
const loadRegulations = async () => {
|
||||
@@ -111,10 +44,7 @@ export default function AuditWorkspacePage() {
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setRegulations(data.regulations || [])
|
||||
// Select first regulation by default
|
||||
if (data.regulations?.length > 0) {
|
||||
setSelectedRegulation(data.regulations[0].code)
|
||||
}
|
||||
if (data.regulations?.length > 0) setSelectedRegulation(data.regulations[0].code)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load regulations:', err)
|
||||
@@ -143,9 +73,7 @@ export default function AuditWorkspacePage() {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(updates),
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
const updated = await res.json()
|
||||
setRequirements(prev => prev.map(r => r.id === reqId ? { ...r, ...updates } : r))
|
||||
if (selectedRequirement?.id === reqId) {
|
||||
setSelectedRequirement({ ...selectedRequirement, ...updates })
|
||||
@@ -174,7 +102,6 @@ export default function AuditWorkspacePage() {
|
||||
|
||||
const currentRegulation = regulations.find(r => r.code === selectedRegulation)
|
||||
|
||||
// Statistics
|
||||
const stats = {
|
||||
total: requirements.length,
|
||||
verified: requirements.filter(r => r.implementation_status === 'verified').length,
|
||||
@@ -204,135 +131,22 @@ export default function AuditWorkspacePage() {
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-12 gap-6">
|
||||
{/* Left Sidebar - Regulation & Requirement List */}
|
||||
<div className="col-span-4 space-y-4">
|
||||
{/* Regulation Selector */}
|
||||
<div className="bg-white rounded-lg border border-slate-200 p-4">
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">
|
||||
Verordnung / Standard
|
||||
</label>
|
||||
<select
|
||||
value={selectedRegulation || ''}
|
||||
onChange={(e) => setSelectedRegulation(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
>
|
||||
{regulations.map(reg => (
|
||||
<option key={reg.code} value={reg.code}>
|
||||
{reg.code} - {reg.name} ({reg.requirement_count})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<RequirementList
|
||||
regulations={regulations}
|
||||
selectedRegulation={selectedRegulation}
|
||||
setSelectedRegulation={setSelectedRegulation}
|
||||
filteredRequirements={filteredRequirements}
|
||||
selectedRequirement={selectedRequirement}
|
||||
setSelectedRequirement={setSelectedRequirement}
|
||||
searchQuery={searchQuery}
|
||||
setSearchQuery={setSearchQuery}
|
||||
filterAuditStatus={filterAuditStatus}
|
||||
setFilterAuditStatus={setFilterAuditStatus}
|
||||
filterImplStatus={filterImplStatus}
|
||||
setFilterImplStatus={setFilterImplStatus}
|
||||
/>
|
||||
|
||||
{currentRegulation?.source_url && (
|
||||
<a
|
||||
href={currentRegulation.source_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="mt-2 text-sm text-primary-600 hover:text-primary-800 flex items-center gap-1"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
</svg>
|
||||
Originaldokument oeffnen
|
||||
</a>
|
||||
)}
|
||||
|
||||
{currentRegulation?.local_pdf_path && (
|
||||
<a
|
||||
href={`/docs/${currentRegulation.local_pdf_path}`}
|
||||
target="_blank"
|
||||
className="mt-1 text-sm text-slate-600 hover:text-slate-800 flex items-center gap-1"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
|
||||
</svg>
|
||||
Lokale PDF
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="bg-white rounded-lg border border-slate-200 p-4 space-y-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-500 mb-1">Suche</label>
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Artikel, Titel..."
|
||||
className="w-full px-3 py-1.5 text-sm border border-slate-300 rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-500 mb-1">Audit-Status</label>
|
||||
<select
|
||||
value={filterAuditStatus}
|
||||
onChange={(e) => setFilterAuditStatus(e.target.value)}
|
||||
className="w-full px-2 py-1.5 text-sm border border-slate-300 rounded-lg"
|
||||
>
|
||||
<option value="all">Alle</option>
|
||||
{Object.entries(AUDIT_STATUS).map(([key, { label }]) => (
|
||||
<option key={key} value={key}>{label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-500 mb-1">Impl.-Status</label>
|
||||
<select
|
||||
value={filterImplStatus}
|
||||
onChange={(e) => setFilterImplStatus(e.target.value)}
|
||||
className="w-full px-2 py-1.5 text-sm border border-slate-300 rounded-lg"
|
||||
>
|
||||
<option value="all">Alle</option>
|
||||
{Object.entries(IMPLEMENTATION_STATUS).map(([key, { label }]) => (
|
||||
<option key={key} value={key}>{label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Requirements List */}
|
||||
<div className="bg-white rounded-lg border border-slate-200 overflow-hidden">
|
||||
<div className="p-3 border-b border-slate-200 bg-slate-50">
|
||||
<span className="text-sm font-medium text-slate-700">
|
||||
Anforderungen ({filteredRequirements.length})
|
||||
</span>
|
||||
</div>
|
||||
<div className="max-h-[500px] overflow-y-auto">
|
||||
{filteredRequirements.map(req => (
|
||||
<button
|
||||
key={req.id}
|
||||
onClick={() => setSelectedRequirement(req)}
|
||||
className={`w-full text-left p-3 border-b border-slate-100 hover:bg-slate-50 transition-colors ${
|
||||
selectedRequirement?.id === req.id ? 'bg-primary-50 border-l-4 border-l-primary-500' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono text-sm text-slate-600">
|
||||
{req.article}{req.paragraph ? ` ${req.paragraph}` : ''}
|
||||
</span>
|
||||
<span className={`text-xs ${PRIORITY_LABELS[req.priority]?.color || 'text-slate-500'}`}>
|
||||
{PRIORITY_LABELS[req.priority]?.label || ''}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-slate-900 truncate mt-0.5">{req.title}</p>
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
<span className={`w-2 h-2 rounded-full ${AUDIT_STATUS[req.audit_status as keyof typeof AUDIT_STATUS]?.color || 'bg-slate-300'}`} />
|
||||
<span className={`w-2 h-2 rounded-full ${IMPLEMENTATION_STATUS[req.implementation_status as keyof typeof IMPLEMENTATION_STATUS]?.color || 'bg-slate-300'}`} />
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Panel - Requirement Detail */}
|
||||
{/* Right Panel */}
|
||||
<div className="col-span-8">
|
||||
{selectedRequirement ? (
|
||||
<RequirementDetailPanel
|
||||
@@ -354,518 +168,3 @@ export default function AuditWorkspacePage() {
|
||||
</AdminLayout>
|
||||
)
|
||||
}
|
||||
|
||||
// AI Interpretation Types
|
||||
interface AIInterpretation {
|
||||
summary: string
|
||||
applicability: string
|
||||
technical_measures: string[]
|
||||
affected_modules: string[]
|
||||
risk_level: string
|
||||
implementation_hints: string[]
|
||||
confidence_score: number
|
||||
error?: string
|
||||
}
|
||||
|
||||
// Requirement Detail Panel Component
|
||||
function RequirementDetailPanel({
|
||||
requirement,
|
||||
regulation,
|
||||
onUpdate,
|
||||
saving,
|
||||
}: {
|
||||
requirement: Requirement
|
||||
regulation: Regulation | undefined
|
||||
onUpdate: (updates: RequirementUpdate) => void
|
||||
saving: boolean
|
||||
}) {
|
||||
const BACKEND_URL = process.env.NEXT_PUBLIC_BACKEND_URL || 'http://localhost:8000'
|
||||
const [editMode, setEditMode] = useState(false)
|
||||
const [aiLoading, setAiLoading] = useState(false)
|
||||
const [aiInterpretation, setAiInterpretation] = useState<AIInterpretation | null>(null)
|
||||
const [showAiPanel, setShowAiPanel] = useState(false)
|
||||
const [localData, setLocalData] = useState({
|
||||
implementation_status: requirement.implementation_status,
|
||||
implementation_details: requirement.implementation_details || '',
|
||||
evidence_description: requirement.evidence_description || '',
|
||||
audit_status: requirement.audit_status,
|
||||
auditor_notes: requirement.auditor_notes || '',
|
||||
is_applicable: requirement.is_applicable,
|
||||
applicability_reason: requirement.applicability_reason || '',
|
||||
})
|
||||
const [newCodeRef, setNewCodeRef] = useState({ file: '', line: '', description: '' })
|
||||
|
||||
useEffect(() => {
|
||||
setLocalData({
|
||||
implementation_status: requirement.implementation_status,
|
||||
implementation_details: requirement.implementation_details || '',
|
||||
evidence_description: requirement.evidence_description || '',
|
||||
audit_status: requirement.audit_status,
|
||||
auditor_notes: requirement.auditor_notes || '',
|
||||
is_applicable: requirement.is_applicable,
|
||||
applicability_reason: requirement.applicability_reason || '',
|
||||
})
|
||||
setEditMode(false)
|
||||
setAiInterpretation(null)
|
||||
setShowAiPanel(false)
|
||||
}, [requirement.id])
|
||||
|
||||
const generateAiInterpretation = async () => {
|
||||
setAiLoading(true)
|
||||
setShowAiPanel(true)
|
||||
try {
|
||||
const res = await fetch(`${BACKEND_URL}/api/v1/compliance/ai/interpret`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ requirement_id: requirement.id }),
|
||||
})
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setAiInterpretation(data)
|
||||
} else {
|
||||
const err = await res.json()
|
||||
setAiInterpretation({
|
||||
summary: '', applicability: '', technical_measures: [],
|
||||
affected_modules: [], risk_level: 'unknown', implementation_hints: [],
|
||||
confidence_score: 0, error: err.detail || 'Fehler bei AI-Analyse'
|
||||
})
|
||||
}
|
||||
} catch (err) {
|
||||
setAiInterpretation({
|
||||
summary: '', applicability: '', technical_measures: [],
|
||||
affected_modules: [], risk_level: 'unknown', implementation_hints: [],
|
||||
confidence_score: 0, error: 'Netzwerkfehler bei AI-Analyse'
|
||||
})
|
||||
} finally {
|
||||
setAiLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSave = () => {
|
||||
onUpdate(localData)
|
||||
setEditMode(false)
|
||||
}
|
||||
|
||||
const addCodeReference = () => {
|
||||
if (!newCodeRef.file) return
|
||||
const refs = requirement.code_references || []
|
||||
onUpdate({
|
||||
code_references: [...refs, {
|
||||
file: newCodeRef.file,
|
||||
line: newCodeRef.line ? parseInt(newCodeRef.line) : undefined,
|
||||
description: newCodeRef.description,
|
||||
}],
|
||||
})
|
||||
setNewCodeRef({ file: '', line: '', description: '' })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg border border-slate-200 overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="p-4 border-b border-slate-200 bg-slate-50">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="font-mono text-lg font-semibold text-slate-900">
|
||||
{requirement.article}{requirement.paragraph ? ` ${requirement.paragraph}` : ''}
|
||||
</span>
|
||||
<span className={`px-2 py-0.5 text-xs font-medium rounded ${
|
||||
AUDIT_STATUS[requirement.audit_status as keyof typeof AUDIT_STATUS]?.color || 'bg-slate-200'
|
||||
} text-white`}>
|
||||
{AUDIT_STATUS[requirement.audit_status as keyof typeof AUDIT_STATUS]?.label || requirement.audit_status}
|
||||
</span>
|
||||
<span className={`px-2 py-0.5 text-xs font-medium rounded ${
|
||||
IMPLEMENTATION_STATUS[requirement.implementation_status as keyof typeof IMPLEMENTATION_STATUS]?.color || 'bg-slate-200'
|
||||
} text-white`}>
|
||||
{IMPLEMENTATION_STATUS[requirement.implementation_status as keyof typeof IMPLEMENTATION_STATUS]?.label || requirement.implementation_status}
|
||||
</span>
|
||||
</div>
|
||||
<h2 className="text-lg font-medium text-slate-900 mt-1">{requirement.title}</h2>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{editMode ? (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setEditMode(false)}
|
||||
className="px-3 py-1.5 text-sm text-slate-600 hover:text-slate-800"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="px-3 py-1.5 text-sm bg-primary-600 text-white rounded-lg hover:bg-primary-700 disabled:opacity-50"
|
||||
>
|
||||
{saving ? 'Speichern...' : 'Speichern'}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setEditMode(true)}
|
||||
className="px-3 py-1.5 text-sm bg-slate-100 text-slate-700 rounded-lg hover:bg-slate-200"
|
||||
>
|
||||
Bearbeiten
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 space-y-6">
|
||||
{/* Original Requirement Text */}
|
||||
<section>
|
||||
<h3 className="text-sm font-semibold text-slate-700 uppercase tracking-wide mb-2 flex items-center gap-2">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
Originaler Anforderungstext
|
||||
</h3>
|
||||
<div className="bg-slate-50 border border-slate-200 rounded-lg p-4">
|
||||
<p className="text-sm text-slate-700 whitespace-pre-wrap">
|
||||
{requirement.requirement_text || 'Kein Originaltext hinterlegt'}
|
||||
</p>
|
||||
{requirement.source_page && (
|
||||
<p className="text-xs text-slate-500 mt-2">
|
||||
Quelle: {regulation?.code} Seite {requirement.source_page}
|
||||
{requirement.source_section ? `, ${requirement.source_section}` : ''}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Applicability */}
|
||||
<section>
|
||||
<h3 className="text-sm font-semibold text-slate-700 uppercase tracking-wide mb-2">
|
||||
Anwendbarkeit auf Breakpilot
|
||||
</h3>
|
||||
{editMode ? (
|
||||
<div className="space-y-2">
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={localData.is_applicable}
|
||||
onChange={(e) => setLocalData({ ...localData, is_applicable: e.target.checked })}
|
||||
className="rounded"
|
||||
/>
|
||||
<span className="text-sm text-slate-700">Anwendbar</span>
|
||||
</label>
|
||||
<textarea
|
||||
value={localData.applicability_reason}
|
||||
onChange={(e) => setLocalData({ ...localData, applicability_reason: e.target.value })}
|
||||
placeholder="Begruendung fuer Anwendbarkeit..."
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-start gap-3">
|
||||
<span className={`px-2 py-1 text-xs font-medium rounded ${
|
||||
requirement.is_applicable ? 'bg-green-100 text-green-700' : 'bg-slate-100 text-slate-600'
|
||||
}`}>
|
||||
{requirement.is_applicable ? 'Anwendbar' : 'Nicht anwendbar'}
|
||||
</span>
|
||||
{requirement.applicability_reason && (
|
||||
<p className="text-sm text-slate-600">{requirement.applicability_reason}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Breakpilot Interpretation & AI Analysis */}
|
||||
<section>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h3 className="text-sm font-semibold text-slate-700 uppercase tracking-wide flex items-center gap-2">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
|
||||
</svg>
|
||||
Interpretation
|
||||
</h3>
|
||||
<button
|
||||
onClick={generateAiInterpretation}
|
||||
disabled={aiLoading}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-white bg-gradient-to-r from-purple-600 to-pink-600 rounded-lg hover:from-purple-700 hover:to-pink-700 disabled:opacity-50 transition-all"
|
||||
>
|
||||
{aiLoading ? (
|
||||
<>
|
||||
<svg className="animate-spin h-3 w-3" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
AI analysiert...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
AI Analyse
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Existing interpretation */}
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-3">
|
||||
<p className="text-sm text-blue-800">
|
||||
{requirement.breakpilot_interpretation || 'Keine Interpretation hinterlegt'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* AI Interpretation Panel */}
|
||||
{showAiPanel && (
|
||||
<div className="bg-gradient-to-br from-purple-50 to-pink-50 border border-purple-200 rounded-lg p-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-sm font-semibold text-purple-800 flex items-center gap-2">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||
</svg>
|
||||
AI-generierte Analyse
|
||||
</h4>
|
||||
{aiInterpretation?.confidence_score && (
|
||||
<span className="text-xs text-purple-600">
|
||||
Konfidenz: {Math.round(aiInterpretation.confidence_score * 100)}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{aiLoading && (
|
||||
<div className="text-center py-4">
|
||||
<div className="animate-pulse text-purple-600">Claude analysiert die Anforderung...</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{aiInterpretation?.error && (
|
||||
<div className="bg-red-100 text-red-700 p-3 rounded text-sm">
|
||||
{aiInterpretation.error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{aiInterpretation && !aiInterpretation.error && !aiLoading && (
|
||||
<div className="space-y-3 text-sm">
|
||||
{/* Summary */}
|
||||
{aiInterpretation.summary && (
|
||||
<div>
|
||||
<div className="font-medium text-purple-700 mb-1">Zusammenfassung</div>
|
||||
<p className="text-slate-700">{aiInterpretation.summary}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Applicability */}
|
||||
{aiInterpretation.applicability && (
|
||||
<div>
|
||||
<div className="font-medium text-purple-700 mb-1">Anwendbarkeit auf Breakpilot</div>
|
||||
<p className="text-slate-700">{aiInterpretation.applicability}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Risk Level */}
|
||||
{aiInterpretation.risk_level && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-purple-700">Risiko:</span>
|
||||
<span className={`px-2 py-0.5 rounded text-xs font-medium ${
|
||||
aiInterpretation.risk_level === 'critical' ? 'bg-red-100 text-red-700' :
|
||||
aiInterpretation.risk_level === 'high' ? 'bg-orange-100 text-orange-700' :
|
||||
aiInterpretation.risk_level === 'medium' ? 'bg-yellow-100 text-yellow-700' :
|
||||
'bg-green-100 text-green-700'
|
||||
}`}>
|
||||
{aiInterpretation.risk_level}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Technical Measures */}
|
||||
{aiInterpretation.technical_measures?.length > 0 && (
|
||||
<div>
|
||||
<div className="font-medium text-purple-700 mb-1">Technische Massnahmen</div>
|
||||
<ul className="list-disc list-inside text-slate-700 space-y-1">
|
||||
{aiInterpretation.technical_measures.map((m, i) => (
|
||||
<li key={i}>{m}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Affected Modules */}
|
||||
{aiInterpretation.affected_modules?.length > 0 && (
|
||||
<div>
|
||||
<div className="font-medium text-purple-700 mb-1">Betroffene Module</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{aiInterpretation.affected_modules.map((m, i) => (
|
||||
<span key={i} className="px-2 py-0.5 bg-purple-100 text-purple-700 rounded text-xs">
|
||||
{m}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Implementation Hints */}
|
||||
{aiInterpretation.implementation_hints?.length > 0 && (
|
||||
<div>
|
||||
<div className="font-medium text-purple-700 mb-1">Implementierungshinweise</div>
|
||||
<ul className="list-disc list-inside text-slate-700 space-y-1">
|
||||
{aiInterpretation.implementation_hints.map((h, i) => (
|
||||
<li key={i}>{h}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Implementation Details */}
|
||||
<section>
|
||||
<h3 className="text-sm font-semibold text-slate-700 uppercase tracking-wide mb-2 flex items-center gap-2">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" />
|
||||
</svg>
|
||||
Umsetzung (fuer Auditor)
|
||||
</h3>
|
||||
{editMode ? (
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-xs text-slate-500 mb-1">Implementierungsstatus</label>
|
||||
<select
|
||||
value={localData.implementation_status}
|
||||
onChange={(e) => setLocalData({ ...localData, implementation_status: e.target.value })}
|
||||
className="px-3 py-2 border border-slate-300 rounded-lg text-sm"
|
||||
>
|
||||
{Object.entries(IMPLEMENTATION_STATUS).map(([key, { label }]) => (
|
||||
<option key={key} value={key}>{label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<textarea
|
||||
value={localData.implementation_details}
|
||||
onChange={(e) => setLocalData({ ...localData, implementation_details: e.target.value })}
|
||||
placeholder="Beschreiben Sie, wie diese Anforderung in Breakpilot umgesetzt wurde..."
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
|
||||
rows={4}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-green-50 border border-green-200 rounded-lg p-4">
|
||||
<p className="text-sm text-green-800 whitespace-pre-wrap">
|
||||
{requirement.implementation_details || 'Noch keine Umsetzungsdetails dokumentiert'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Code References */}
|
||||
<section>
|
||||
<h3 className="text-sm font-semibold text-slate-700 uppercase tracking-wide mb-2">
|
||||
Code-Referenzen
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{(requirement.code_references || []).map((ref, idx) => (
|
||||
<div key={idx} className="flex items-center gap-2 bg-slate-50 p-2 rounded-lg text-sm">
|
||||
<code className="text-primary-600">{ref.file}{ref.line ? `:${ref.line}` : ''}</code>
|
||||
<span className="text-slate-500">-</span>
|
||||
<span className="text-slate-700">{ref.description}</span>
|
||||
</div>
|
||||
))}
|
||||
{editMode && (
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={newCodeRef.file}
|
||||
onChange={(e) => setNewCodeRef({ ...newCodeRef, file: e.target.value })}
|
||||
placeholder="Datei (z.B. backend/auth.py)"
|
||||
className="flex-1 px-2 py-1.5 text-sm border border-slate-300 rounded"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={newCodeRef.line}
|
||||
onChange={(e) => setNewCodeRef({ ...newCodeRef, line: e.target.value })}
|
||||
placeholder="Zeile"
|
||||
className="w-20 px-2 py-1.5 text-sm border border-slate-300 rounded"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={newCodeRef.description}
|
||||
onChange={(e) => setNewCodeRef({ ...newCodeRef, description: e.target.value })}
|
||||
placeholder="Beschreibung"
|
||||
className="flex-1 px-2 py-1.5 text-sm border border-slate-300 rounded"
|
||||
/>
|
||||
<button
|
||||
onClick={addCodeReference}
|
||||
className="px-3 py-1.5 bg-primary-600 text-white rounded text-sm"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Evidence */}
|
||||
<section>
|
||||
<h3 className="text-sm font-semibold text-slate-700 uppercase tracking-wide mb-2 flex items-center gap-2">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||
</svg>
|
||||
Nachweis / Evidence
|
||||
</h3>
|
||||
{editMode ? (
|
||||
<textarea
|
||||
value={localData.evidence_description}
|
||||
onChange={(e) => setLocalData({ ...localData, evidence_description: e.target.value })}
|
||||
placeholder="Welche Nachweise belegen die Erfuellung dieser Anforderung?"
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
|
||||
rows={3}
|
||||
/>
|
||||
) : (
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4">
|
||||
<p className="text-sm text-amber-800">
|
||||
{requirement.evidence_description || 'Keine Nachweise beschrieben'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Auditor Section */}
|
||||
<section className="border-t border-slate-200 pt-4">
|
||||
<h3 className="text-sm font-semibold text-slate-700 uppercase tracking-wide mb-2 flex items-center gap-2">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
||||
</svg>
|
||||
Auditor-Bereich
|
||||
</h3>
|
||||
{editMode ? (
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-xs text-slate-500 mb-1">Audit-Status</label>
|
||||
<select
|
||||
value={localData.audit_status}
|
||||
onChange={(e) => setLocalData({ ...localData, audit_status: e.target.value })}
|
||||
className="px-3 py-2 border border-slate-300 rounded-lg text-sm"
|
||||
>
|
||||
{Object.entries(AUDIT_STATUS).map(([key, { label }]) => (
|
||||
<option key={key} value={key}>{label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<textarea
|
||||
value={localData.auditor_notes}
|
||||
onChange={(e) => setLocalData({ ...localData, auditor_notes: e.target.value })}
|
||||
placeholder="Notizen des Auditors..."
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-slate-50 border border-slate-200 rounded-lg p-4">
|
||||
<p className="text-sm text-slate-700">
|
||||
{requirement.auditor_notes || 'Keine Auditor-Notizen'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
export interface Regulation {
|
||||
id: string
|
||||
code: string
|
||||
name: string
|
||||
full_name: string
|
||||
regulation_type: string
|
||||
source_url: string | null
|
||||
local_pdf_path: string | null
|
||||
requirement_count: number
|
||||
}
|
||||
|
||||
export interface Requirement {
|
||||
id: string
|
||||
regulation_id: string
|
||||
regulation_code?: string
|
||||
article: string
|
||||
paragraph: string | null
|
||||
title: string
|
||||
description: string | null
|
||||
requirement_text: string | null
|
||||
breakpilot_interpretation: string | null
|
||||
implementation_status: string
|
||||
implementation_details: string | null
|
||||
code_references: Array<{ file: string; line?: number; description: string }> | null
|
||||
evidence_description: string | null
|
||||
audit_status: string
|
||||
auditor_notes: string | null
|
||||
is_applicable: boolean
|
||||
applicability_reason: string | null
|
||||
priority: number
|
||||
source_page: number | null
|
||||
source_section: string | null
|
||||
}
|
||||
|
||||
export interface RequirementUpdate {
|
||||
implementation_status?: string
|
||||
implementation_details?: string
|
||||
code_references?: Array<{ file: string; line?: number; description: string }>
|
||||
evidence_description?: string
|
||||
audit_status?: string
|
||||
auditor_notes?: string
|
||||
is_applicable?: boolean
|
||||
applicability_reason?: string
|
||||
}
|
||||
|
||||
export interface AIInterpretation {
|
||||
summary: string
|
||||
applicability: string
|
||||
technical_measures: string[]
|
||||
affected_modules: string[]
|
||||
risk_level: string
|
||||
implementation_hints: string[]
|
||||
confidence_score: number
|
||||
error?: string
|
||||
}
|
||||
|
||||
export const IMPLEMENTATION_STATUS = {
|
||||
not_started: { label: 'Nicht gestartet', color: 'bg-slate-400' },
|
||||
in_progress: { label: 'In Arbeit', color: 'bg-yellow-500' },
|
||||
implemented: { label: 'Implementiert', color: 'bg-blue-500' },
|
||||
verified: { label: 'Verifiziert', color: 'bg-green-500' },
|
||||
}
|
||||
|
||||
export const AUDIT_STATUS = {
|
||||
pending: { label: 'Ausstehend', color: 'bg-slate-400' },
|
||||
in_review: { label: 'In Pruefung', color: 'bg-yellow-500' },
|
||||
approved: { label: 'Genehmigt', color: 'bg-green-500' },
|
||||
rejected: { label: 'Abgelehnt', color: 'bg-red-500' },
|
||||
}
|
||||
|
||||
export const PRIORITY_LABELS: Record<number, { label: string; color: string }> = {
|
||||
1: { label: 'Kritisch', color: 'text-red-600' },
|
||||
2: { label: 'Hoch', color: 'text-orange-600' },
|
||||
3: { label: 'Mittel', color: 'text-yellow-600' },
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
'use client'
|
||||
|
||||
import type { CrawlStats } from '../types'
|
||||
|
||||
export default function CrawlTab({
|
||||
stats,
|
||||
loading,
|
||||
onStartCrawl,
|
||||
}: {
|
||||
stats: CrawlStats
|
||||
loading: boolean
|
||||
onStartCrawl: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Crawl Status */}
|
||||
<div className="bg-slate-50 rounded-lg p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-slate-900">Crawl-Status</h3>
|
||||
<p className="text-sm text-slate-500">
|
||||
Letzter Crawl: {stats.lastCrawlTime ? new Date(stats.lastCrawlTime).toLocaleString('de-DE') : 'Noch nie'}
|
||||
</p>
|
||||
</div>
|
||||
<div className={`px-3 py-1.5 rounded-full text-sm font-medium ${
|
||||
stats.crawlStatus === 'running' ? 'bg-blue-100 text-blue-700' :
|
||||
stats.crawlStatus === 'error' ? 'bg-red-100 text-red-700' :
|
||||
'bg-green-100 text-green-700'
|
||||
}`}>
|
||||
{stats.crawlStatus === 'running' ? '🔄 Läuft...' :
|
||||
stats.crawlStatus === 'error' ? '❌ Fehler' :
|
||||
'✅ Bereit'}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onStartCrawl}
|
||||
disabled={loading || stats.crawlStatus === 'running'}
|
||||
className="px-6 py-3 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<svg className="w-5 h-5 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
Crawl läuft...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
Crawl starten
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Crawl Settings */}
|
||||
<div className="grid md:grid-cols-2 gap-6">
|
||||
<div className="bg-white border border-slate-200 rounded-lg p-6">
|
||||
<h4 className="font-semibold text-slate-900 mb-4">Crawl-Einstellungen</h4>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Max. Seiten pro Crawl</label>
|
||||
<input type="number" defaultValue={500} className="w-full px-3 py-2 border border-slate-300 rounded-lg" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Rate-Limit (Requests/Sek)</label>
|
||||
<input type="number" defaultValue={0.2} step={0.1} className="w-full px-3 py-2 border border-slate-300 rounded-lg" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Max. Crawl-Tiefe</label>
|
||||
<input type="number" defaultValue={4} className="w-full px-3 py-2 border border-slate-300 rounded-lg" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white border border-slate-200 rounded-lg p-6">
|
||||
<h4 className="font-semibold text-slate-900 mb-4">Scheduler</h4>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<input type="checkbox" id="autoSchedule" className="rounded border-slate-300" />
|
||||
<label htmlFor="autoSchedule" className="text-sm text-slate-700">Automatischer Crawl aktiviert</label>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Intervall</label>
|
||||
<select className="w-full px-3 py-2 border border-slate-300 rounded-lg">
|
||||
<option value="daily">Täglich</option>
|
||||
<option value="weekly">Wöchentlich</option>
|
||||
<option value="monthly">Monatlich</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
'use client'
|
||||
|
||||
export default function RulesTab() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="text-2xl">⚠️</span>
|
||||
<div>
|
||||
<h4 className="font-semibold text-amber-800">Tagging-Regeln Editor</h4>
|
||||
<p className="text-sm text-amber-700">
|
||||
Die Tagging-Regeln werden aktuell über YAML-Dateien verwaltet.
|
||||
Ein visueller Editor ist in Entwicklung.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-6">
|
||||
{[
|
||||
{ name: 'Doc-Type Regeln', file: 'doc_type_rules.yaml', desc: 'Klassifiziert Dokumente (Lehrplan, Arbeitsblatt, etc.)' },
|
||||
{ name: 'Fach-Regeln', file: 'subject_rules.yaml', desc: 'Erkennt Unterrichtsfächer' },
|
||||
{ name: 'Schulstufen-Regeln', file: 'level_rules.yaml', desc: 'Erkennt Primar, SekI, SekII, etc.' },
|
||||
{ name: 'Trust-Score Regeln', file: 'trust_rules.yaml', desc: 'Domain-basierte Vertrauensbewertung' },
|
||||
].map(rule => (
|
||||
<div key={rule.file} className="bg-white border border-slate-200 rounded-lg p-6">
|
||||
<h4 className="font-semibold text-slate-900 mb-2">{rule.name}</h4>
|
||||
<p className="text-sm text-slate-500 mb-4">{rule.desc}</p>
|
||||
<code className="text-xs bg-slate-100 px-2 py-1 rounded text-slate-600">
|
||||
/rules/{rule.file}
|
||||
</code>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import type { SeedURL, Category } from '../types'
|
||||
|
||||
export default function SeedModal({
|
||||
seed,
|
||||
categories,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: {
|
||||
seed?: SeedURL | null
|
||||
categories: Category[]
|
||||
onClose: () => void
|
||||
onSaved: () => void
|
||||
}) {
|
||||
const [formData, setFormData] = useState<Partial<SeedURL>>(seed || {
|
||||
url: '',
|
||||
category: 'federal',
|
||||
name: '',
|
||||
description: '',
|
||||
trustBoost: 0.5,
|
||||
enabled: true,
|
||||
})
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [saveError, setSaveError] = useState<string | null>(null)
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setSaving(true)
|
||||
setSaveError(null)
|
||||
|
||||
try {
|
||||
const category = categories.find(c => c.name === formData.category || c.id === formData.category)
|
||||
|
||||
const payload = {
|
||||
url: formData.url,
|
||||
name: formData.name,
|
||||
description: formData.description || '',
|
||||
category_id: category?.id || null,
|
||||
trust_boost: formData.trustBoost,
|
||||
enabled: formData.enabled,
|
||||
source_type: 'GOV',
|
||||
scope: 'FEDERAL',
|
||||
}
|
||||
|
||||
if (seed) {
|
||||
const res = await fetch(`/api/admin/edu-search?id=${seed.id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const errData = await res.json()
|
||||
throw new Error(errData.detail || errData.error || `HTTP ${res.status}`)
|
||||
}
|
||||
} else {
|
||||
const res = await fetch(`/api/admin/edu-search?action=seed`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const errData = await res.json()
|
||||
throw new Error(errData.detail || errData.error || `HTTP ${res.status}`)
|
||||
}
|
||||
}
|
||||
|
||||
onSaved()
|
||||
onClose()
|
||||
} catch (err) {
|
||||
console.error('Failed to save seed:', err)
|
||||
setSaveError(err instanceof Error ? err.message : 'Fehler beim Speichern')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-xl shadow-xl w-full max-w-lg mx-4">
|
||||
<div className="px-6 py-4 border-b border-slate-200">
|
||||
<h3 className="text-lg font-semibold">{seed ? 'Seed bearbeiten' : 'Neue Seed-URL hinzufügen'}</h3>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit} className="p-6 space-y-4">
|
||||
{saveError && (
|
||||
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-2 rounded-lg text-sm">
|
||||
{saveError}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">URL *</label>
|
||||
<input
|
||||
type="url"
|
||||
required
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
placeholder="https://www.example.de"
|
||||
value={formData.url}
|
||||
onChange={e => setFormData({ ...formData, url: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Name *</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
placeholder="Name der Quelle"
|
||||
value={formData.name}
|
||||
onChange={e => setFormData({ ...formData, name: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Kategorie *</label>
|
||||
<select
|
||||
required
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
value={formData.category}
|
||||
onChange={e => setFormData({ ...formData, category: e.target.value })}
|
||||
>
|
||||
{categories.map(cat => (
|
||||
<option key={cat.id} value={cat.name}>{cat.icon} {cat.display_name || cat.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Beschreibung</label>
|
||||
<textarea
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
rows={2}
|
||||
placeholder="Kurze Beschreibung der Quelle"
|
||||
value={formData.description}
|
||||
onChange={e => setFormData({ ...formData, description: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">
|
||||
Trust-Boost: {formData.trustBoost?.toFixed(2)}
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.05"
|
||||
className="w-full"
|
||||
value={formData.trustBoost}
|
||||
onChange={e => setFormData({ ...formData, trustBoost: parseFloat(e.target.value) })}
|
||||
/>
|
||||
<p className="text-xs text-slate-500 mt-1">
|
||||
Höhere Werte für vertrauenswürdigere Quellen (1.0 = max für offizielle Regierungsquellen)
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="enabled"
|
||||
className="rounded border-slate-300 text-primary-600 focus:ring-primary-500"
|
||||
checked={formData.enabled}
|
||||
onChange={e => setFormData({ ...formData, enabled: e.target.checked })}
|
||||
/>
|
||||
<label htmlFor="enabled" className="text-sm text-slate-700">Aktiv (wird beim nächsten Crawl berücksichtigt)</label>
|
||||
</div>
|
||||
<div className="flex justify-end gap-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={saving}
|
||||
className="px-4 py-2 text-slate-700 hover:bg-slate-100 rounded-lg transition-colors disabled:opacity-50"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors disabled:opacity-50 flex items-center gap-2"
|
||||
>
|
||||
{saving && (
|
||||
<svg className="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
)}
|
||||
{seed ? 'Speichern' : 'Hinzufügen'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import type { SeedURL, Category } from '../types'
|
||||
import SeedModal from './SeedModal'
|
||||
|
||||
export default function SeedsTab({
|
||||
seeds,
|
||||
allSeeds,
|
||||
categories,
|
||||
searchQuery,
|
||||
setSearchQuery,
|
||||
selectedCategory,
|
||||
setSelectedCategory,
|
||||
onToggleEnabled,
|
||||
onDelete,
|
||||
onSaved,
|
||||
}: {
|
||||
seeds: SeedURL[]
|
||||
allSeeds: SeedURL[]
|
||||
categories: Category[]
|
||||
searchQuery: string
|
||||
setSearchQuery: (q: string) => void
|
||||
selectedCategory: string
|
||||
setSelectedCategory: (cat: string) => void
|
||||
onToggleEnabled: (id: string) => void
|
||||
onDelete: (id: string) => void
|
||||
onSaved: () => void
|
||||
}) {
|
||||
const [showAddModal, setShowAddModal] = useState(false)
|
||||
const [editingSeed, setEditingSeed] = useState<SeedURL | null>(null)
|
||||
|
||||
const filteredSeeds = seeds.filter(seed => {
|
||||
const matchesCategory = selectedCategory === 'all' || seed.category === selectedCategory
|
||||
const matchesSearch = seed.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
seed.url.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
return matchesCategory && matchesSearch
|
||||
})
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Header with filters */}
|
||||
<div className="flex flex-wrap items-center gap-4 mb-6">
|
||||
<div className="flex-1 min-w-[200px]">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Suche nach Name oder URL..."
|
||||
className="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
value={searchQuery}
|
||||
onChange={e => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
className="px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
value={selectedCategory}
|
||||
onChange={e => setSelectedCategory(e.target.value)}
|
||||
>
|
||||
<option value="all">Alle Kategorien</option>
|
||||
{categories.map(cat => (
|
||||
<option key={cat.id} value={cat.name}>{cat.icon} {cat.display_name || cat.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
onClick={() => setShowAddModal(true)}
|
||||
className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors flex items-center gap-2"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
Neue Seed-URL
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Category Quick Stats */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-5 gap-4 mb-6">
|
||||
{categories.map(cat => {
|
||||
const count = allSeeds.filter(s => s.category === cat.name).length
|
||||
return (
|
||||
<button
|
||||
key={cat.id}
|
||||
onClick={() => setSelectedCategory(selectedCategory === cat.name ? 'all' : cat.name)}
|
||||
className={`p-4 rounded-lg border transition-colors text-left ${
|
||||
selectedCategory === cat.name
|
||||
? 'border-primary-500 bg-primary-50'
|
||||
: 'border-slate-200 hover:border-slate-300'
|
||||
}`}
|
||||
>
|
||||
<div className="text-2xl mb-1">{cat.icon}</div>
|
||||
<div className="font-medium text-slate-900 text-sm">{cat.display_name || cat.name}</div>
|
||||
<div className="text-sm text-slate-500">{count} Seeds</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Seeds Table */}
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-200">
|
||||
<th className="text-left py-3 px-4 font-medium text-slate-700">Status</th>
|
||||
<th className="text-left py-3 px-4 font-medium text-slate-700">Name</th>
|
||||
<th className="text-left py-3 px-4 font-medium text-slate-700">URL</th>
|
||||
<th className="text-left py-3 px-4 font-medium text-slate-700">Kategorie</th>
|
||||
<th className="text-left py-3 px-4 font-medium text-slate-700">Trust</th>
|
||||
<th className="text-left py-3 px-4 font-medium text-slate-700">Dokumente</th>
|
||||
<th className="text-left py-3 px-4 font-medium text-slate-700">Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredSeeds.map(seed => (
|
||||
<tr key={seed.id} className="border-b border-slate-100 hover:bg-slate-50">
|
||||
<td className="py-3 px-4">
|
||||
<button
|
||||
onClick={() => onToggleEnabled(seed.id)}
|
||||
className={`w-10 h-6 rounded-full transition-colors ${
|
||||
seed.enabled ? 'bg-green-500' : 'bg-slate-300'
|
||||
}`}
|
||||
>
|
||||
<span className={`block w-4 h-4 bg-white rounded-full transform transition-transform ${
|
||||
seed.enabled ? 'translate-x-5' : 'translate-x-1'
|
||||
}`} />
|
||||
</button>
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<div className="font-medium text-slate-900">{seed.name}</div>
|
||||
<div className="text-sm text-slate-500">{seed.description}</div>
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<a href={seed.url} target="_blank" rel="noopener noreferrer" className="text-primary-600 hover:underline text-sm">
|
||||
{seed.url.replace(/^https?:\/\/(www\.)?/, '').slice(0, 30)}...
|
||||
</a>
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<span className="inline-flex items-center gap-1 px-2 py-1 bg-slate-100 rounded text-sm">
|
||||
{categories.find(c => c.name === seed.category)?.icon || '📁'}
|
||||
{categories.find(c => c.name === seed.category)?.display_name || seed.category}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<span className={`inline-flex px-2 py-1 rounded text-sm font-medium ${
|
||||
seed.trustBoost >= 0.4 ? 'bg-green-100 text-green-700' :
|
||||
seed.trustBoost >= 0.2 ? 'bg-yellow-100 text-yellow-700' :
|
||||
'bg-slate-100 text-slate-700'
|
||||
}`}>
|
||||
+{seed.trustBoost.toFixed(2)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3 px-4 text-slate-600">
|
||||
{seed.documentCount?.toLocaleString() || '-'}
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setEditingSeed(seed)}
|
||||
className="p-1.5 text-slate-400 hover:text-slate-600 hover:bg-slate-100 rounded"
|
||||
title="Bearbeiten"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onDelete(seed.id)}
|
||||
className="p-1.5 text-slate-400 hover:text-red-600 hover:bg-red-50 rounded"
|
||||
title="Löschen"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{filteredSeeds.length === 0 && (
|
||||
<div className="text-center py-12 text-slate-500">
|
||||
Keine Seed-URLs gefunden
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Modals */}
|
||||
{(showAddModal || editingSeed) && (
|
||||
<SeedModal
|
||||
seed={editingSeed}
|
||||
categories={categories}
|
||||
onClose={() => {
|
||||
setShowAddModal(false)
|
||||
setEditingSeed(null)
|
||||
}}
|
||||
onSaved={onSaved}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
'use client'
|
||||
|
||||
import type { CrawlStats, Category } from '../types'
|
||||
|
||||
export default function StatsTab({
|
||||
stats,
|
||||
categories,
|
||||
}: {
|
||||
stats: CrawlStats
|
||||
categories: Category[]
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Overview Stats */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div className="bg-gradient-to-br from-blue-500 to-blue-600 rounded-xl p-5 text-white">
|
||||
<div className="text-3xl font-bold">{stats.totalDocuments.toLocaleString()}</div>
|
||||
<div className="text-blue-100">Dokumente indexiert</div>
|
||||
</div>
|
||||
<div className="bg-gradient-to-br from-green-500 to-green-600 rounded-xl p-5 text-white">
|
||||
<div className="text-3xl font-bold">{stats.totalSeeds}</div>
|
||||
<div className="text-green-100">Seed-URLs aktiv</div>
|
||||
</div>
|
||||
<div className="bg-gradient-to-br from-purple-500 to-purple-600 rounded-xl p-5 text-white">
|
||||
<div className="text-3xl font-bold">{(stats.avgTrustScore * 100).toFixed(0)}%</div>
|
||||
<div className="text-purple-100">Ø Trust-Score</div>
|
||||
</div>
|
||||
<div className="bg-gradient-to-br from-orange-500 to-orange-600 rounded-xl p-5 text-white">
|
||||
<div className="text-3xl font-bold">{Object.keys(stats.documentsPerDocType).length}</div>
|
||||
<div className="text-orange-100">Dokumenttypen</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Charts */}
|
||||
<div className="grid md:grid-cols-2 gap-6">
|
||||
<div className="bg-white border border-slate-200 rounded-lg p-6">
|
||||
<h4 className="font-semibold text-slate-900 mb-4">Dokumente nach Kategorie</h4>
|
||||
<div className="space-y-3">
|
||||
{Object.entries(stats.documentsPerCategory).map(([cat, count]) => {
|
||||
const category = categories.find(c => c.name === cat)
|
||||
const percentage = stats.totalDocuments > 0 ? (count / stats.totalDocuments) * 100 : 0
|
||||
return (
|
||||
<div key={cat}>
|
||||
<div className="flex justify-between text-sm mb-1">
|
||||
<span>{category?.icon || '📁'} {category?.display_name || cat}</span>
|
||||
<span className="text-slate-500">{count.toLocaleString()} ({percentage.toFixed(1)}%)</span>
|
||||
</div>
|
||||
<div className="h-2 bg-slate-100 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-primary-500 rounded-full"
|
||||
style={{ width: `${percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white border border-slate-200 rounded-lg p-6">
|
||||
<h4 className="font-semibold text-slate-900 mb-4">Dokumente nach Typ</h4>
|
||||
<div className="space-y-3">
|
||||
{Object.entries(stats.documentsPerDocType)
|
||||
.sort(([,a], [,b]) => b - a)
|
||||
.slice(0, 6)
|
||||
.map(([docType, count]) => {
|
||||
const percentage = (count / stats.totalDocuments) * 100
|
||||
return (
|
||||
<div key={docType}>
|
||||
<div className="flex justify-between text-sm mb-1">
|
||||
<span>{docType.replace(/_/g, ' ')}</span>
|
||||
<span className="text-slate-500">{count.toLocaleString()} ({percentage.toFixed(1)}%)</span>
|
||||
</div>
|
||||
<div className="h-2 bg-slate-100 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-green-500 rounded-full"
|
||||
style={{ width: `${percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -7,531 +7,34 @@
|
||||
* edu-search-service (Tavily alternative for German education content)
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useState } from 'react'
|
||||
import AdminLayout from '@/components/admin/AdminLayout'
|
||||
import { useEduSearchData } from './useEduSearchData'
|
||||
import SeedsTab from './_components/SeedsTab'
|
||||
import CrawlTab from './_components/CrawlTab'
|
||||
import StatsTab from './_components/StatsTab'
|
||||
import RulesTab from './_components/RulesTab'
|
||||
|
||||
// All API calls go through Next.js API proxy at /api/admin/edu-search
|
||||
// This avoids CORS issues since browser calls same-origin API routes
|
||||
|
||||
// Types
|
||||
interface SeedURL {
|
||||
id: string
|
||||
url: string
|
||||
category: string
|
||||
category_id?: string
|
||||
name: string
|
||||
description: string
|
||||
trustBoost: number
|
||||
enabled: boolean
|
||||
lastCrawled?: string
|
||||
documentCount?: number
|
||||
source_type?: string
|
||||
scope?: string
|
||||
state?: string
|
||||
crawl_depth?: number
|
||||
crawl_frequency?: string
|
||||
}
|
||||
|
||||
interface CrawlStats {
|
||||
totalDocuments: number
|
||||
totalSeeds: number
|
||||
lastCrawlTime?: string
|
||||
crawlStatus: 'idle' | 'running' | 'error'
|
||||
documentsPerCategory: Record<string, number>
|
||||
documentsPerDocType: Record<string, number>
|
||||
avgTrustScore: number
|
||||
}
|
||||
|
||||
interface Category {
|
||||
id: string
|
||||
name: string
|
||||
display_name?: string
|
||||
description: string
|
||||
icon: string
|
||||
sort_order?: number
|
||||
is_active?: boolean
|
||||
}
|
||||
|
||||
interface ApiSeed {
|
||||
id: string
|
||||
url: string
|
||||
name: string
|
||||
description: string | null
|
||||
category: string | null // Backend returns 'category' not 'category_name'
|
||||
category_display_name: string | null
|
||||
source_type: string
|
||||
scope: string
|
||||
state: string | null
|
||||
trust_boost: number
|
||||
enabled: boolean
|
||||
crawl_depth: number
|
||||
crawl_frequency: string
|
||||
last_crawled_at: string | null
|
||||
last_crawl_status: string | null
|
||||
last_crawl_docs: number
|
||||
total_documents: number
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
// Default categories (fallback if API fails)
|
||||
const DEFAULT_CATEGORIES: Category[] = [
|
||||
{ id: 'federal', name: 'federal', display_name: 'Bundesebene', description: 'KMK, BMBF, Bildungsserver', icon: '🏛️' },
|
||||
{ id: 'states', name: 'states', display_name: 'Bundesländer', description: 'Ministerien, Landesbildungsserver', icon: '🗺️' },
|
||||
{ id: 'science', name: 'science', display_name: 'Wissenschaft', description: 'Bertelsmann, PISA, IGLU, TIMSS', icon: '🔬' },
|
||||
{ id: 'universities', name: 'universities', display_name: 'Hochschulen', description: 'Universitäten, Fachhochschulen, Pädagogische Hochschulen', icon: '🎓' },
|
||||
{ id: 'legal', name: 'legal', display_name: 'Recht & Schulgesetze', description: 'Schulgesetze, Erlasse, Verordnungen, Datenschutzrecht', icon: '⚖️' },
|
||||
{ id: 'portals', name: 'portals', display_name: 'Bildungsportale', description: 'Lehrer-Online, 4teachers, ZUM', icon: '📚' },
|
||||
{ id: 'authorities', name: 'authorities', display_name: 'Schulbehörden', description: 'Regierungspräsidien, Schulämter', icon: '📋' },
|
||||
const tabDefs = [
|
||||
{ id: 'seeds' as const, name: 'Seed-URLs', icon: '🌱' },
|
||||
{ id: 'crawl' as const, name: 'Crawl-Steuerung', icon: '🕷️' },
|
||||
{ id: 'stats' as const, name: 'Statistiken', icon: '📊' },
|
||||
{ id: 'rules' as const, name: 'Tagging-Regeln', icon: '🏷️' },
|
||||
]
|
||||
|
||||
// Convert API seed to frontend format
|
||||
function apiSeedToFrontend(seed: ApiSeed): SeedURL {
|
||||
return {
|
||||
id: seed.id,
|
||||
url: seed.url,
|
||||
name: seed.name,
|
||||
description: seed.description || '',
|
||||
category: seed.category || 'federal', // Backend uses 'category' field
|
||||
category_id: undefined,
|
||||
trustBoost: seed.trust_boost,
|
||||
enabled: seed.enabled,
|
||||
lastCrawled: seed.last_crawled_at || undefined,
|
||||
documentCount: seed.total_documents,
|
||||
source_type: seed.source_type,
|
||||
scope: seed.scope,
|
||||
state: seed.state || undefined,
|
||||
crawl_depth: seed.crawl_depth,
|
||||
crawl_frequency: seed.crawl_frequency,
|
||||
}
|
||||
}
|
||||
|
||||
// Default empty stats (loaded from API)
|
||||
const DEFAULT_STATS: CrawlStats = {
|
||||
totalDocuments: 0,
|
||||
totalSeeds: 0,
|
||||
lastCrawlTime: undefined,
|
||||
crawlStatus: 'idle',
|
||||
documentsPerCategory: {},
|
||||
documentsPerDocType: {},
|
||||
avgTrustScore: 0,
|
||||
}
|
||||
|
||||
export default function EduSearchAdminPage() {
|
||||
const [activeTab, setActiveTab] = useState<'seeds' | 'crawl' | 'stats' | 'rules'>('seeds')
|
||||
const [seeds, setSeeds] = useState<SeedURL[]>([])
|
||||
const [allSeeds, setAllSeeds] = useState<SeedURL[]>([]) // All seeds for category counts
|
||||
const [categories, setCategories] = useState<Category[]>(DEFAULT_CATEGORIES)
|
||||
const [stats, setStats] = useState<CrawlStats>(DEFAULT_STATS)
|
||||
const [selectedCategory, setSelectedCategory] = useState<string>('all')
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [showAddModal, setShowAddModal] = useState(false)
|
||||
const [editingSeed, setEditingSeed] = useState<SeedURL | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [initialLoading, setInitialLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Fetch categories from API (via proxy)
|
||||
const fetchCategories = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/admin/edu-search?action=categories`)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
if (data.categories && data.categories.length > 0) {
|
||||
setCategories(data.categories.map((cat: { id: string; name: string; display_name: string; description: string; icon: string; sort_order: number; is_active: boolean }) => ({
|
||||
id: cat.id,
|
||||
name: cat.name,
|
||||
display_name: cat.display_name,
|
||||
description: cat.description || '',
|
||||
icon: cat.icon || '📁',
|
||||
sort_order: cat.sort_order,
|
||||
is_active: cat.is_active,
|
||||
})))
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch categories:', err)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Fetch all seeds from API (for category counts)
|
||||
const fetchAllSeeds = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/admin/edu-search?action=seeds`)
|
||||
if (!res.ok) {
|
||||
throw new Error(`HTTP ${res.status}`)
|
||||
}
|
||||
const data = await res.json()
|
||||
setAllSeeds((data.seeds || []).map(apiSeedToFrontend))
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch all seeds:', err)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Fetch seeds from API (via proxy) - filtered by category
|
||||
const fetchSeeds = useCallback(async () => {
|
||||
try {
|
||||
const params = new URLSearchParams()
|
||||
params.append('action', 'seeds')
|
||||
if (selectedCategory !== 'all') {
|
||||
params.append('category', selectedCategory)
|
||||
}
|
||||
const res = await fetch(`/api/admin/edu-search?${params}`)
|
||||
if (!res.ok) {
|
||||
throw new Error(`HTTP ${res.status}`)
|
||||
}
|
||||
const data = await res.json()
|
||||
const fetchedSeeds = (data.seeds || []).map(apiSeedToFrontend)
|
||||
setSeeds(fetchedSeeds)
|
||||
// If fetching all, also update allSeeds for counts
|
||||
if (selectedCategory === 'all') {
|
||||
setAllSeeds(fetchedSeeds)
|
||||
}
|
||||
setError(null)
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch seeds:', err)
|
||||
setError('Seeds konnten nicht geladen werden. API nicht erreichbar.')
|
||||
}
|
||||
}, [selectedCategory])
|
||||
|
||||
// Fetch stats from API (via proxy)
|
||||
const fetchStats = useCallback(async (preserveCrawlStatus = false) => {
|
||||
try {
|
||||
const res = await fetch(`/api/admin/edu-search?action=stats`)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setStats(prev => ({
|
||||
totalDocuments: data.total_documents || 0,
|
||||
totalSeeds: data.total_seeds || 0,
|
||||
lastCrawlTime: data.last_crawl_time || prev.lastCrawlTime,
|
||||
crawlStatus: preserveCrawlStatus ? prev.crawlStatus : (data.crawl_status || 'idle'),
|
||||
documentsPerCategory: data.seeds_per_category || {},
|
||||
documentsPerDocType: {},
|
||||
avgTrustScore: data.avg_trust_boost || 0,
|
||||
}))
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch stats:', err)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Initial load
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
setInitialLoading(true)
|
||||
await Promise.all([fetchCategories(), fetchSeeds(), fetchAllSeeds(), fetchStats()])
|
||||
setInitialLoading(false)
|
||||
}
|
||||
loadData()
|
||||
}, [fetchCategories, fetchSeeds, fetchAllSeeds, fetchStats])
|
||||
|
||||
// Reload seeds when category filter changes
|
||||
useEffect(() => {
|
||||
if (!initialLoading) {
|
||||
fetchSeeds()
|
||||
}
|
||||
}, [selectedCategory, initialLoading, fetchSeeds])
|
||||
|
||||
// Filter seeds
|
||||
const filteredSeeds = seeds.filter(seed => {
|
||||
const matchesCategory = selectedCategory === 'all' || seed.category === selectedCategory
|
||||
const matchesSearch = seed.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
seed.url.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
return matchesCategory && matchesSearch
|
||||
})
|
||||
|
||||
// Add/Edit Seed Modal
|
||||
const SeedModal = ({ seed, onClose }: { seed?: SeedURL | null, onClose: () => void }) => {
|
||||
const [formData, setFormData] = useState<Partial<SeedURL>>(seed || {
|
||||
url: '',
|
||||
category: 'federal',
|
||||
name: '',
|
||||
description: '',
|
||||
trustBoost: 0.5,
|
||||
enabled: true,
|
||||
})
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [saveError, setSaveError] = useState<string | null>(null)
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setSaving(true)
|
||||
setSaveError(null)
|
||||
|
||||
try {
|
||||
// Find category ID by name
|
||||
const category = categories.find(c => c.name === formData.category || c.id === formData.category)
|
||||
|
||||
const payload = {
|
||||
url: formData.url,
|
||||
name: formData.name,
|
||||
description: formData.description || '',
|
||||
category_id: category?.id || null,
|
||||
trust_boost: formData.trustBoost,
|
||||
enabled: formData.enabled,
|
||||
source_type: 'GOV',
|
||||
scope: 'FEDERAL',
|
||||
}
|
||||
|
||||
if (seed) {
|
||||
// Update existing seed (via proxy)
|
||||
const res = await fetch(`/api/admin/edu-search?id=${seed.id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const errData = await res.json()
|
||||
throw new Error(errData.detail || errData.error || `HTTP ${res.status}`)
|
||||
}
|
||||
} else {
|
||||
// Create new seed (via proxy)
|
||||
const res = await fetch(`/api/admin/edu-search?action=seed`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const errData = await res.json()
|
||||
throw new Error(errData.detail || errData.error || `HTTP ${res.status}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Reload seeds and close modal
|
||||
await fetchSeeds()
|
||||
await fetchStats()
|
||||
onClose()
|
||||
} catch (err) {
|
||||
console.error('Failed to save seed:', err)
|
||||
setSaveError(err instanceof Error ? err.message : 'Fehler beim Speichern')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-xl shadow-xl w-full max-w-lg mx-4">
|
||||
<div className="px-6 py-4 border-b border-slate-200">
|
||||
<h3 className="text-lg font-semibold">{seed ? 'Seed bearbeiten' : 'Neue Seed-URL hinzufügen'}</h3>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit} className="p-6 space-y-4">
|
||||
{saveError && (
|
||||
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-2 rounded-lg text-sm">
|
||||
{saveError}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">URL *</label>
|
||||
<input
|
||||
type="url"
|
||||
required
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
placeholder="https://www.example.de"
|
||||
value={formData.url}
|
||||
onChange={e => setFormData({ ...formData, url: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Name *</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
placeholder="Name der Quelle"
|
||||
value={formData.name}
|
||||
onChange={e => setFormData({ ...formData, name: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Kategorie *</label>
|
||||
<select
|
||||
required
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
value={formData.category}
|
||||
onChange={e => setFormData({ ...formData, category: e.target.value })}
|
||||
>
|
||||
{categories.map(cat => (
|
||||
<option key={cat.id} value={cat.name}>{cat.icon} {cat.display_name || cat.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Beschreibung</label>
|
||||
<textarea
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
rows={2}
|
||||
placeholder="Kurze Beschreibung der Quelle"
|
||||
value={formData.description}
|
||||
onChange={e => setFormData({ ...formData, description: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">
|
||||
Trust-Boost: {formData.trustBoost?.toFixed(2)}
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.05"
|
||||
className="w-full"
|
||||
value={formData.trustBoost}
|
||||
onChange={e => setFormData({ ...formData, trustBoost: parseFloat(e.target.value) })}
|
||||
/>
|
||||
<p className="text-xs text-slate-500 mt-1">
|
||||
Höhere Werte für vertrauenswürdigere Quellen (1.0 = max für offizielle Regierungsquellen)
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="enabled"
|
||||
className="rounded border-slate-300 text-primary-600 focus:ring-primary-500"
|
||||
checked={formData.enabled}
|
||||
onChange={e => setFormData({ ...formData, enabled: e.target.checked })}
|
||||
/>
|
||||
<label htmlFor="enabled" className="text-sm text-slate-700">Aktiv (wird beim nächsten Crawl berücksichtigt)</label>
|
||||
</div>
|
||||
<div className="flex justify-end gap-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={saving}
|
||||
className="px-4 py-2 text-slate-700 hover:bg-slate-100 rounded-lg transition-colors disabled:opacity-50"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors disabled:opacity-50 flex items-center gap-2"
|
||||
>
|
||||
{saving && (
|
||||
<svg className="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
)}
|
||||
{seed ? 'Speichern' : 'Hinzufügen'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm('Seed-URL wirklich löschen?')) return
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/admin/edu-search?id=${id}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
if (!res.ok) {
|
||||
throw new Error(`HTTP ${res.status}`)
|
||||
}
|
||||
await fetchSeeds()
|
||||
await fetchStats()
|
||||
} catch (err) {
|
||||
console.error('Failed to delete seed:', err)
|
||||
alert('Fehler beim Löschen')
|
||||
}
|
||||
}
|
||||
|
||||
const handleToggleEnabled = async (id: string) => {
|
||||
const seed = seeds.find(s => s.id === id)
|
||||
if (!seed) return
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/admin/edu-search?id=${id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ enabled: !seed.enabled }),
|
||||
})
|
||||
if (!res.ok) {
|
||||
throw new Error(`HTTP ${res.status}`)
|
||||
}
|
||||
// Optimistic update
|
||||
setSeeds(seeds.map(s => s.id === id ? { ...s, enabled: !s.enabled } : s))
|
||||
} catch (err) {
|
||||
console.error('Failed to toggle seed:', err)
|
||||
// Reload on error
|
||||
await fetchSeeds()
|
||||
}
|
||||
}
|
||||
|
||||
// Poll for crawl status from backend
|
||||
const pollCrawlStatus = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/admin/edu-search?action=legal-crawler-status', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({}),
|
||||
})
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
return data.status // 'running', 'idle', 'completed', 'error'
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors
|
||||
}
|
||||
return 'idle'
|
||||
}, [])
|
||||
|
||||
const handleStartCrawl = async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
setStats(prev => ({ ...prev, crawlStatus: 'running', lastCrawlTime: new Date().toISOString() }))
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/admin/edu-search?action=crawl', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({}),
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (response.ok) {
|
||||
// Crawl gestartet - kontinuierlich Status prüfen
|
||||
const checkStatus = async () => {
|
||||
const status = await pollCrawlStatus()
|
||||
|
||||
if (status === 'running') {
|
||||
// Noch am Laufen - weiter pollen
|
||||
setStats(prev => ({ ...prev, crawlStatus: 'running' }))
|
||||
setTimeout(checkStatus, 3000)
|
||||
} else if (status === 'completed' || status === 'idle') {
|
||||
// Fertig
|
||||
setStats(prev => ({ ...prev, crawlStatus: 'idle' }))
|
||||
setLoading(false)
|
||||
await fetchStats(false) // Refresh stats
|
||||
} else {
|
||||
// Fehler oder unbekannter Status
|
||||
setStats(prev => ({ ...prev, crawlStatus: 'error' }))
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Start polling nach kurzer Verzögerung
|
||||
setTimeout(checkStatus, 2000)
|
||||
} else {
|
||||
setError(data.error || 'Fehler beim Starten des Crawls')
|
||||
setLoading(false)
|
||||
setStats(prev => ({ ...prev, crawlStatus: 'idle' }))
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Netzwerkfehler beim Starten des Crawls')
|
||||
setLoading(false)
|
||||
setStats(prev => ({ ...prev, crawlStatus: 'idle' }))
|
||||
}
|
||||
}
|
||||
const {
|
||||
seeds, allSeeds, categories, stats, selectedCategory, setSelectedCategory,
|
||||
loading, initialLoading, error,
|
||||
handleStartCrawl, handleDelete, handleToggleEnabled, handleSaved,
|
||||
fetchSeeds, fetchStats,
|
||||
} = useEduSearchData()
|
||||
|
||||
return (
|
||||
<AdminLayout title="Education Search" description="Bildungsquellen & Crawler verwalten">
|
||||
{/* Loading State */}
|
||||
{initialLoading && (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<svg className="w-8 h-8 animate-spin text-primary-600" fill="none" viewBox="0 0 24 24">
|
||||
@@ -542,7 +45,6 @@ export default function EduSearchAdminPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error State */}
|
||||
{error && !initialLoading && (
|
||||
<div className="mb-6 bg-red-50 border border-red-200 rounded-lg p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -563,395 +65,45 @@ export default function EduSearchAdminPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tabs */}
|
||||
{!initialLoading && (
|
||||
<div className="bg-white rounded-xl shadow-sm border border-slate-200 mb-6">
|
||||
<div className="border-b border-slate-200">
|
||||
<nav className="flex -mb-px">
|
||||
{[
|
||||
{ id: 'seeds', name: 'Seed-URLs', icon: '🌱' },
|
||||
{ id: 'crawl', name: 'Crawl-Steuerung', icon: '🕷️' },
|
||||
{ id: 'stats', name: 'Statistiken', icon: '📊' },
|
||||
{ id: 'rules', name: 'Tagging-Regeln', icon: '🏷️' },
|
||||
].map(tab => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id as typeof activeTab)}
|
||||
className={`px-6 py-4 text-sm font-medium border-b-2 transition-colors ${
|
||||
activeTab === tab.id
|
||||
? 'border-primary-600 text-primary-600'
|
||||
: 'border-transparent text-slate-500 hover:text-slate-700 hover:border-slate-300'
|
||||
}`}
|
||||
>
|
||||
<span className="mr-2">{tab.icon}</span>
|
||||
{tab.name}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div className="p-6">
|
||||
{/* Seeds Tab */}
|
||||
{activeTab === 'seeds' && (
|
||||
<div>
|
||||
{/* Header with filters */}
|
||||
<div className="flex flex-wrap items-center gap-4 mb-6">
|
||||
<div className="flex-1 min-w-[200px]">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Suche nach Name oder URL..."
|
||||
className="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
value={searchQuery}
|
||||
onChange={e => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
className="px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
value={selectedCategory}
|
||||
onChange={e => setSelectedCategory(e.target.value)}
|
||||
>
|
||||
<option value="all">Alle Kategorien</option>
|
||||
{categories.map(cat => (
|
||||
<option key={cat.id} value={cat.name}>{cat.icon} {cat.display_name || cat.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="bg-white rounded-xl shadow-sm border border-slate-200 mb-6">
|
||||
<div className="border-b border-slate-200">
|
||||
<nav className="flex -mb-px">
|
||||
{tabDefs.map(tab => (
|
||||
<button
|
||||
onClick={() => setShowAddModal(true)}
|
||||
className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors flex items-center gap-2"
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`px-6 py-4 text-sm font-medium border-b-2 transition-colors ${
|
||||
activeTab === tab.id
|
||||
? 'border-primary-600 text-primary-600'
|
||||
: 'border-transparent text-slate-500 hover:text-slate-700 hover:border-slate-300'
|
||||
}`}
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
Neue Seed-URL
|
||||
<span className="mr-2">{tab.icon}</span>
|
||||
{tab.name}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Category Quick Stats - show all categories, use allSeeds for counts */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-5 gap-4 mb-6">
|
||||
{categories.map(cat => {
|
||||
const count = allSeeds.filter(s => s.category === cat.name).length
|
||||
return (
|
||||
<button
|
||||
key={cat.id}
|
||||
onClick={() => setSelectedCategory(selectedCategory === cat.name ? 'all' : cat.name)}
|
||||
className={`p-4 rounded-lg border transition-colors text-left ${
|
||||
selectedCategory === cat.name
|
||||
? 'border-primary-500 bg-primary-50'
|
||||
: 'border-slate-200 hover:border-slate-300'
|
||||
}`}
|
||||
>
|
||||
<div className="text-2xl mb-1">{cat.icon}</div>
|
||||
<div className="font-medium text-slate-900 text-sm">{cat.display_name || cat.name}</div>
|
||||
<div className="text-sm text-slate-500">{count} Seeds</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Seeds Table */}
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-200">
|
||||
<th className="text-left py-3 px-4 font-medium text-slate-700">Status</th>
|
||||
<th className="text-left py-3 px-4 font-medium text-slate-700">Name</th>
|
||||
<th className="text-left py-3 px-4 font-medium text-slate-700">URL</th>
|
||||
<th className="text-left py-3 px-4 font-medium text-slate-700">Kategorie</th>
|
||||
<th className="text-left py-3 px-4 font-medium text-slate-700">Trust</th>
|
||||
<th className="text-left py-3 px-4 font-medium text-slate-700">Dokumente</th>
|
||||
<th className="text-left py-3 px-4 font-medium text-slate-700">Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredSeeds.map(seed => (
|
||||
<tr key={seed.id} className="border-b border-slate-100 hover:bg-slate-50">
|
||||
<td className="py-3 px-4">
|
||||
<button
|
||||
onClick={() => handleToggleEnabled(seed.id)}
|
||||
className={`w-10 h-6 rounded-full transition-colors ${
|
||||
seed.enabled ? 'bg-green-500' : 'bg-slate-300'
|
||||
}`}
|
||||
>
|
||||
<span className={`block w-4 h-4 bg-white rounded-full transform transition-transform ${
|
||||
seed.enabled ? 'translate-x-5' : 'translate-x-1'
|
||||
}`} />
|
||||
</button>
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<div className="font-medium text-slate-900">{seed.name}</div>
|
||||
<div className="text-sm text-slate-500">{seed.description}</div>
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<a href={seed.url} target="_blank" rel="noopener noreferrer" className="text-primary-600 hover:underline text-sm">
|
||||
{seed.url.replace(/^https?:\/\/(www\.)?/, '').slice(0, 30)}...
|
||||
</a>
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<span className="inline-flex items-center gap-1 px-2 py-1 bg-slate-100 rounded text-sm">
|
||||
{categories.find(c => c.name === seed.category)?.icon || '📁'}
|
||||
{categories.find(c => c.name === seed.category)?.display_name || seed.category}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<span className={`inline-flex px-2 py-1 rounded text-sm font-medium ${
|
||||
seed.trustBoost >= 0.4 ? 'bg-green-100 text-green-700' :
|
||||
seed.trustBoost >= 0.2 ? 'bg-yellow-100 text-yellow-700' :
|
||||
'bg-slate-100 text-slate-700'
|
||||
}`}>
|
||||
+{seed.trustBoost.toFixed(2)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3 px-4 text-slate-600">
|
||||
{seed.documentCount?.toLocaleString() || '-'}
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setEditingSeed(seed)}
|
||||
className="p-1.5 text-slate-400 hover:text-slate-600 hover:bg-slate-100 rounded"
|
||||
title="Bearbeiten"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(seed.id)}
|
||||
className="p-1.5 text-slate-400 hover:text-red-600 hover:bg-red-50 rounded"
|
||||
title="Löschen"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{filteredSeeds.length === 0 && (
|
||||
<div className="text-center py-12 text-slate-500">
|
||||
Keine Seed-URLs gefunden
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Crawl Tab */}
|
||||
{activeTab === 'crawl' && (
|
||||
<div className="space-y-6">
|
||||
{/* Crawl Status */}
|
||||
<div className="bg-slate-50 rounded-lg p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-slate-900">Crawl-Status</h3>
|
||||
<p className="text-sm text-slate-500">
|
||||
Letzter Crawl: {stats.lastCrawlTime ? new Date(stats.lastCrawlTime).toLocaleString('de-DE') : 'Noch nie'}
|
||||
</p>
|
||||
</div>
|
||||
<div className={`px-3 py-1.5 rounded-full text-sm font-medium ${
|
||||
stats.crawlStatus === 'running' ? 'bg-blue-100 text-blue-700' :
|
||||
stats.crawlStatus === 'error' ? 'bg-red-100 text-red-700' :
|
||||
'bg-green-100 text-green-700'
|
||||
}`}>
|
||||
{stats.crawlStatus === 'running' ? '🔄 Läuft...' :
|
||||
stats.crawlStatus === 'error' ? '❌ Fehler' :
|
||||
'✅ Bereit'}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleStartCrawl}
|
||||
disabled={loading || stats.crawlStatus === 'running'}
|
||||
className="px-6 py-3 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<svg className="w-5 h-5 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
Crawl läuft...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
Crawl starten
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Crawl Settings */}
|
||||
<div className="grid md:grid-cols-2 gap-6">
|
||||
<div className="bg-white border border-slate-200 rounded-lg p-6">
|
||||
<h4 className="font-semibold text-slate-900 mb-4">Crawl-Einstellungen</h4>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Max. Seiten pro Crawl</label>
|
||||
<input type="number" defaultValue={500} className="w-full px-3 py-2 border border-slate-300 rounded-lg" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Rate-Limit (Requests/Sek)</label>
|
||||
<input type="number" defaultValue={0.2} step={0.1} className="w-full px-3 py-2 border border-slate-300 rounded-lg" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Max. Crawl-Tiefe</label>
|
||||
<input type="number" defaultValue={4} className="w-full px-3 py-2 border border-slate-300 rounded-lg" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white border border-slate-200 rounded-lg p-6">
|
||||
<h4 className="font-semibold text-slate-900 mb-4">Scheduler</h4>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<input type="checkbox" id="autoSchedule" className="rounded border-slate-300" />
|
||||
<label htmlFor="autoSchedule" className="text-sm text-slate-700">Automatischer Crawl aktiviert</label>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Intervall</label>
|
||||
<select className="w-full px-3 py-2 border border-slate-300 rounded-lg">
|
||||
<option value="daily">Täglich</option>
|
||||
<option value="weekly">Wöchentlich</option>
|
||||
<option value="monthly">Monatlich</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stats Tab */}
|
||||
{activeTab === 'stats' && (
|
||||
<div className="space-y-6">
|
||||
{/* Overview Stats */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div className="bg-gradient-to-br from-blue-500 to-blue-600 rounded-xl p-5 text-white">
|
||||
<div className="text-3xl font-bold">{stats.totalDocuments.toLocaleString()}</div>
|
||||
<div className="text-blue-100">Dokumente indexiert</div>
|
||||
</div>
|
||||
<div className="bg-gradient-to-br from-green-500 to-green-600 rounded-xl p-5 text-white">
|
||||
<div className="text-3xl font-bold">{stats.totalSeeds}</div>
|
||||
<div className="text-green-100">Seed-URLs aktiv</div>
|
||||
</div>
|
||||
<div className="bg-gradient-to-br from-purple-500 to-purple-600 rounded-xl p-5 text-white">
|
||||
<div className="text-3xl font-bold">{(stats.avgTrustScore * 100).toFixed(0)}%</div>
|
||||
<div className="text-purple-100">Ø Trust-Score</div>
|
||||
</div>
|
||||
<div className="bg-gradient-to-br from-orange-500 to-orange-600 rounded-xl p-5 text-white">
|
||||
<div className="text-3xl font-bold">{Object.keys(stats.documentsPerDocType).length}</div>
|
||||
<div className="text-orange-100">Dokumenttypen</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Charts */}
|
||||
<div className="grid md:grid-cols-2 gap-6">
|
||||
<div className="bg-white border border-slate-200 rounded-lg p-6">
|
||||
<h4 className="font-semibold text-slate-900 mb-4">Dokumente nach Kategorie</h4>
|
||||
<div className="space-y-3">
|
||||
{Object.entries(stats.documentsPerCategory).map(([cat, count]) => {
|
||||
const category = categories.find(c => c.name === cat)
|
||||
const percentage = stats.totalDocuments > 0 ? (count / stats.totalDocuments) * 100 : 0
|
||||
return (
|
||||
<div key={cat}>
|
||||
<div className="flex justify-between text-sm mb-1">
|
||||
<span>{category?.icon || '📁'} {category?.display_name || cat}</span>
|
||||
<span className="text-slate-500">{count.toLocaleString()} ({percentage.toFixed(1)}%)</span>
|
||||
</div>
|
||||
<div className="h-2 bg-slate-100 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-primary-500 rounded-full"
|
||||
style={{ width: `${percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white border border-slate-200 rounded-lg p-6">
|
||||
<h4 className="font-semibold text-slate-900 mb-4">Dokumente nach Typ</h4>
|
||||
<div className="space-y-3">
|
||||
{Object.entries(stats.documentsPerDocType)
|
||||
.sort(([,a], [,b]) => b - a)
|
||||
.slice(0, 6)
|
||||
.map(([docType, count]) => {
|
||||
const percentage = (count / stats.totalDocuments) * 100
|
||||
return (
|
||||
<div key={docType}>
|
||||
<div className="flex justify-between text-sm mb-1">
|
||||
<span>{docType.replace(/_/g, ' ')}</span>
|
||||
<span className="text-slate-500">{count.toLocaleString()} ({percentage.toFixed(1)}%)</span>
|
||||
</div>
|
||||
<div className="h-2 bg-slate-100 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-green-500 rounded-full"
|
||||
style={{ width: `${percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Rules Tab */}
|
||||
{activeTab === 'rules' && (
|
||||
<div className="space-y-6">
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="text-2xl">⚠️</span>
|
||||
<div>
|
||||
<h4 className="font-semibold text-amber-800">Tagging-Regeln Editor</h4>
|
||||
<p className="text-sm text-amber-700">
|
||||
Die Tagging-Regeln werden aktuell über YAML-Dateien verwaltet.
|
||||
Ein visueller Editor ist in Entwicklung.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-6">
|
||||
{[
|
||||
{ name: 'Doc-Type Regeln', file: 'doc_type_rules.yaml', desc: 'Klassifiziert Dokumente (Lehrplan, Arbeitsblatt, etc.)' },
|
||||
{ name: 'Fach-Regeln', file: 'subject_rules.yaml', desc: 'Erkennt Unterrichtsfächer' },
|
||||
{ name: 'Schulstufen-Regeln', file: 'level_rules.yaml', desc: 'Erkennt Primar, SekI, SekII, etc.' },
|
||||
{ name: 'Trust-Score Regeln', file: 'trust_rules.yaml', desc: 'Domain-basierte Vertrauensbewertung' },
|
||||
].map(rule => (
|
||||
<div key={rule.file} className="bg-white border border-slate-200 rounded-lg p-6">
|
||||
<h4 className="font-semibold text-slate-900 mb-2">{rule.name}</h4>
|
||||
<p className="text-sm text-slate-500 mb-4">{rule.desc}</p>
|
||||
<code className="text-xs bg-slate-100 px-2 py-1 rounded text-slate-600">
|
||||
/rules/{rule.file}
|
||||
</code>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="p-6">
|
||||
{activeTab === 'seeds' && (
|
||||
<SeedsTab
|
||||
seeds={seeds} allSeeds={allSeeds} categories={categories}
|
||||
searchQuery={searchQuery} setSearchQuery={setSearchQuery}
|
||||
selectedCategory={selectedCategory} setSelectedCategory={setSelectedCategory}
|
||||
onToggleEnabled={handleToggleEnabled} onDelete={handleDelete} onSaved={handleSaved}
|
||||
/>
|
||||
)}
|
||||
{activeTab === 'crawl' && (
|
||||
<CrawlTab stats={stats} loading={loading} onStartCrawl={handleStartCrawl} />
|
||||
)}
|
||||
{activeTab === 'stats' && (
|
||||
<StatsTab stats={stats} categories={categories} />
|
||||
)}
|
||||
{activeTab === 'rules' && <RulesTab />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Modals */}
|
||||
{(showAddModal || editingSeed) && (
|
||||
<SeedModal
|
||||
seed={editingSeed}
|
||||
onClose={() => {
|
||||
setShowAddModal(false)
|
||||
setEditingSeed(null)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</AdminLayout>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
export interface SeedURL {
|
||||
id: string
|
||||
url: string
|
||||
category: string
|
||||
category_id?: string
|
||||
name: string
|
||||
description: string
|
||||
trustBoost: number
|
||||
enabled: boolean
|
||||
lastCrawled?: string
|
||||
documentCount?: number
|
||||
source_type?: string
|
||||
scope?: string
|
||||
state?: string
|
||||
crawl_depth?: number
|
||||
crawl_frequency?: string
|
||||
}
|
||||
|
||||
export interface CrawlStats {
|
||||
totalDocuments: number
|
||||
totalSeeds: number
|
||||
lastCrawlTime?: string
|
||||
crawlStatus: 'idle' | 'running' | 'error'
|
||||
documentsPerCategory: Record<string, number>
|
||||
documentsPerDocType: Record<string, number>
|
||||
avgTrustScore: number
|
||||
}
|
||||
|
||||
export interface Category {
|
||||
id: string
|
||||
name: string
|
||||
display_name?: string
|
||||
description: string
|
||||
icon: string
|
||||
sort_order?: number
|
||||
is_active?: boolean
|
||||
}
|
||||
|
||||
export interface ApiSeed {
|
||||
id: string
|
||||
url: string
|
||||
name: string
|
||||
description: string | null
|
||||
category: string | null
|
||||
category_display_name: string | null
|
||||
source_type: string
|
||||
scope: string
|
||||
state: string | null
|
||||
trust_boost: number
|
||||
enabled: boolean
|
||||
crawl_depth: number
|
||||
crawl_frequency: string
|
||||
last_crawled_at: string | null
|
||||
last_crawl_status: string | null
|
||||
last_crawl_docs: number
|
||||
total_documents: number
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
// Default categories (fallback if API fails)
|
||||
export const DEFAULT_CATEGORIES: Category[] = [
|
||||
{ id: 'federal', name: 'federal', display_name: 'Bundesebene', description: 'KMK, BMBF, Bildungsserver', icon: '🏛️' },
|
||||
{ id: 'states', name: 'states', display_name: 'Bundesländer', description: 'Ministerien, Landesbildungsserver', icon: '🗺️' },
|
||||
{ id: 'science', name: 'science', display_name: 'Wissenschaft', description: 'Bertelsmann, PISA, IGLU, TIMSS', icon: '🔬' },
|
||||
{ id: 'universities', name: 'universities', display_name: 'Hochschulen', description: 'Universitäten, Fachhochschulen, Pädagogische Hochschulen', icon: '🎓' },
|
||||
{ id: 'legal', name: 'legal', display_name: 'Recht & Schulgesetze', description: 'Schulgesetze, Erlasse, Verordnungen, Datenschutzrecht', icon: '⚖️' },
|
||||
{ id: 'portals', name: 'portals', display_name: 'Bildungsportale', description: 'Lehrer-Online, 4teachers, ZUM', icon: '📚' },
|
||||
{ id: 'authorities', name: 'authorities', display_name: 'Schulbehörden', description: 'Regierungspräsidien, Schulämter', icon: '📋' },
|
||||
]
|
||||
|
||||
// Default empty stats (loaded from API)
|
||||
export const DEFAULT_STATS: CrawlStats = {
|
||||
totalDocuments: 0,
|
||||
totalSeeds: 0,
|
||||
lastCrawlTime: undefined,
|
||||
crawlStatus: 'idle',
|
||||
documentsPerCategory: {},
|
||||
documentsPerDocType: {},
|
||||
avgTrustScore: 0,
|
||||
}
|
||||
|
||||
// Convert API seed to frontend format
|
||||
export function apiSeedToFrontend(seed: ApiSeed): SeedURL {
|
||||
return {
|
||||
id: seed.id,
|
||||
url: seed.url,
|
||||
name: seed.name,
|
||||
description: seed.description || '',
|
||||
category: seed.category || 'federal',
|
||||
category_id: undefined,
|
||||
trustBoost: seed.trust_boost,
|
||||
enabled: seed.enabled,
|
||||
lastCrawled: seed.last_crawled_at || undefined,
|
||||
documentCount: seed.total_documents,
|
||||
source_type: seed.source_type,
|
||||
scope: seed.scope,
|
||||
state: seed.state || undefined,
|
||||
crawl_depth: seed.crawl_depth,
|
||||
crawl_frequency: seed.crawl_frequency,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import type { SeedURL, Category, CrawlStats } from './types'
|
||||
import { DEFAULT_CATEGORIES, DEFAULT_STATS, apiSeedToFrontend } from './types'
|
||||
|
||||
export function useEduSearchData() {
|
||||
const [seeds, setSeeds] = useState<SeedURL[]>([])
|
||||
const [allSeeds, setAllSeeds] = useState<SeedURL[]>([])
|
||||
const [categories, setCategories] = useState<Category[]>(DEFAULT_CATEGORIES)
|
||||
const [stats, setStats] = useState<CrawlStats>(DEFAULT_STATS)
|
||||
const [selectedCategory, setSelectedCategory] = useState<string>('all')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [initialLoading, setInitialLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const fetchCategories = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/admin/edu-search?action=categories`)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
if (data.categories && data.categories.length > 0) {
|
||||
setCategories(data.categories.map((cat: { id: string; name: string; display_name: string; description: string; icon: string; sort_order: number; is_active: boolean }) => ({
|
||||
id: cat.id, name: cat.name, display_name: cat.display_name,
|
||||
description: cat.description || '', icon: cat.icon || '📁',
|
||||
sort_order: cat.sort_order, is_active: cat.is_active,
|
||||
})))
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch categories:', err)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const fetchAllSeeds = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/admin/edu-search?action=seeds`)
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
const data = await res.json()
|
||||
setAllSeeds((data.seeds || []).map(apiSeedToFrontend))
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch all seeds:', err)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const fetchSeeds = useCallback(async () => {
|
||||
try {
|
||||
const params = new URLSearchParams()
|
||||
params.append('action', 'seeds')
|
||||
if (selectedCategory !== 'all') params.append('category', selectedCategory)
|
||||
const res = await fetch(`/api/admin/edu-search?${params}`)
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
const data = await res.json()
|
||||
const fetchedSeeds = (data.seeds || []).map(apiSeedToFrontend)
|
||||
setSeeds(fetchedSeeds)
|
||||
if (selectedCategory === 'all') setAllSeeds(fetchedSeeds)
|
||||
setError(null)
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch seeds:', err)
|
||||
setError('Seeds konnten nicht geladen werden. API nicht erreichbar.')
|
||||
}
|
||||
}, [selectedCategory])
|
||||
|
||||
const fetchStats = useCallback(async (preserveCrawlStatus = false) => {
|
||||
try {
|
||||
const res = await fetch(`/api/admin/edu-search?action=stats`)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setStats(prev => ({
|
||||
totalDocuments: data.total_documents || 0,
|
||||
totalSeeds: data.total_seeds || 0,
|
||||
lastCrawlTime: data.last_crawl_time || prev.lastCrawlTime,
|
||||
crawlStatus: preserveCrawlStatus ? prev.crawlStatus : (data.crawl_status || 'idle'),
|
||||
documentsPerCategory: data.seeds_per_category || {},
|
||||
documentsPerDocType: {},
|
||||
avgTrustScore: data.avg_trust_boost || 0,
|
||||
}))
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch stats:', err)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
setInitialLoading(true)
|
||||
await Promise.all([fetchCategories(), fetchSeeds(), fetchAllSeeds(), fetchStats()])
|
||||
setInitialLoading(false)
|
||||
}
|
||||
loadData()
|
||||
}, [fetchCategories, fetchSeeds, fetchAllSeeds, fetchStats])
|
||||
|
||||
useEffect(() => {
|
||||
if (!initialLoading) fetchSeeds()
|
||||
}, [selectedCategory, initialLoading, fetchSeeds])
|
||||
|
||||
const pollCrawlStatus = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/admin/edu-search?action=legal-crawler-status', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}),
|
||||
})
|
||||
if (res.ok) return (await res.json()).status
|
||||
} catch { /* Ignore */ }
|
||||
return 'idle'
|
||||
}, [])
|
||||
|
||||
const handleStartCrawl = async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
setStats(prev => ({ ...prev, crawlStatus: 'running', lastCrawlTime: new Date().toISOString() }))
|
||||
try {
|
||||
const response = await fetch('/api/admin/edu-search?action=crawl', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}),
|
||||
})
|
||||
const data = await response.json()
|
||||
if (response.ok) {
|
||||
const checkStatus = async () => {
|
||||
const status = await pollCrawlStatus()
|
||||
if (status === 'running') { setStats(prev => ({ ...prev, crawlStatus: 'running' })); setTimeout(checkStatus, 3000) }
|
||||
else if (status === 'completed' || status === 'idle') { setStats(prev => ({ ...prev, crawlStatus: 'idle' })); setLoading(false); await fetchStats(false) }
|
||||
else { setStats(prev => ({ ...prev, crawlStatus: 'error' })); setLoading(false) }
|
||||
}
|
||||
setTimeout(checkStatus, 2000)
|
||||
} else {
|
||||
setError(data.error || 'Fehler beim Starten des Crawls')
|
||||
setLoading(false)
|
||||
setStats(prev => ({ ...prev, crawlStatus: 'idle' }))
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Netzwerkfehler beim Starten des Crawls')
|
||||
setLoading(false)
|
||||
setStats(prev => ({ ...prev, crawlStatus: 'idle' }))
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm('Seed-URL wirklich löschen?')) return
|
||||
try {
|
||||
const res = await fetch(`/api/admin/edu-search?id=${id}`, { method: 'DELETE' })
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
await fetchSeeds(); await fetchStats()
|
||||
} catch (err) {
|
||||
console.error('Failed to delete seed:', err)
|
||||
alert('Fehler beim Löschen')
|
||||
}
|
||||
}
|
||||
|
||||
const handleToggleEnabled = async (id: string) => {
|
||||
const seed = seeds.find(s => s.id === id)
|
||||
if (!seed) return
|
||||
try {
|
||||
const res = await fetch(`/api/admin/edu-search?id=${id}`, {
|
||||
method: 'PUT', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ enabled: !seed.enabled }),
|
||||
})
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
setSeeds(seeds.map(s => s.id === id ? { ...s, enabled: !s.enabled } : s))
|
||||
} catch (err) {
|
||||
console.error('Failed to toggle seed:', err); await fetchSeeds()
|
||||
}
|
||||
}
|
||||
|
||||
const handleSaved = async () => { await fetchSeeds(); await fetchStats() }
|
||||
|
||||
return {
|
||||
seeds, allSeeds, categories, stats, selectedCategory, setSelectedCategory,
|
||||
loading, initialLoading, error, setError,
|
||||
handleStartCrawl, handleDelete, handleToggleEnabled, handleSaved,
|
||||
fetchSeeds, fetchStats,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
'use client'
|
||||
|
||||
import type { MacMiniStatus } from '../types'
|
||||
|
||||
export default function DockerSection({
|
||||
status,
|
||||
actionLoading,
|
||||
onDockerUp,
|
||||
onDockerDown,
|
||||
}: {
|
||||
status: MacMiniStatus | null
|
||||
actionLoading: string | null
|
||||
onDockerUp: () => void
|
||||
onDockerDown: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6 mb-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="font-semibold text-slate-900 flex items-center gap-2">
|
||||
<span className="text-2xl">🐳</span> Docker Container
|
||||
</h3>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={onDockerUp}
|
||||
disabled={actionLoading !== null || !status?.online}
|
||||
className="px-3 py-1.5 bg-green-600 text-white text-sm rounded-lg font-medium hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{actionLoading === 'docker-up' ? '...' : '▶ Start'}
|
||||
</button>
|
||||
<button
|
||||
onClick={onDockerDown}
|
||||
disabled={actionLoading !== null || !status?.online}
|
||||
className="px-3 py-1.5 bg-red-600 text-white text-sm rounded-lg font-medium hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{actionLoading === 'docker-down' ? '...' : '⏹ Stop'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{status?.containers && status.containers.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{status.containers.map((container, idx) => (
|
||||
<div key={idx} className="flex items-center justify-between bg-slate-50 rounded-lg p-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className={`w-2 h-2 rounded-full ${
|
||||
container.status.includes('Up') ? 'bg-green-500' : 'bg-red-500'
|
||||
}`}></span>
|
||||
<span className="font-medium text-slate-700">{container.name}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
{container.ports && (
|
||||
<span className="text-sm text-slate-500 font-mono">{container.ports}</span>
|
||||
)}
|
||||
<span className={`text-sm ${
|
||||
container.status.includes('Up') ? 'text-green-600' : 'text-red-500'
|
||||
}`}>
|
||||
{container.status}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-slate-500 text-center py-4">
|
||||
{status?.online ? 'Keine Container gefunden' : 'Server nicht erreichbar'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
'use client'
|
||||
|
||||
import { INTERNET_REQUIRED_ACTIONS } from '../constants'
|
||||
|
||||
export default function InternetStatus({ internet }: { internet?: boolean }) {
|
||||
return (
|
||||
<div className={`rounded-xl border p-4 mb-6 ${
|
||||
internet
|
||||
? 'bg-green-50 border-green-200'
|
||||
: 'bg-amber-50 border-amber-200'
|
||||
}`}>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex gap-3">
|
||||
<span className="text-2xl">{internet ? '🌐' : '📴'}</span>
|
||||
<div>
|
||||
<h3 className={`font-semibold ${internet ? 'text-green-900' : 'text-amber-900'}`}>
|
||||
Internet: {internet ? 'Verbunden' : 'Offline (Normalbetrieb)'}
|
||||
</h3>
|
||||
<p className={`text-sm mt-1 ${internet ? 'text-green-700' : 'text-amber-700'}`}>
|
||||
{internet
|
||||
? 'Mac Mini hat Internet-Zugang. LLM-Downloads und Updates möglich.'
|
||||
: 'Mac Mini arbeitet offline. Für bestimmte Aktionen muss Internet aktiviert werden.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className={`px-3 py-1 rounded-full text-sm font-semibold ${
|
||||
internet
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-amber-100 text-amber-800'
|
||||
}`}>
|
||||
{internet ? 'Online' : 'Offline'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{!internet && (
|
||||
<div className="mt-4 pt-4 border-t border-amber-200">
|
||||
<h4 className="font-medium text-amber-900 mb-2">⚠️ Diese Aktionen benötigen Internet:</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
|
||||
{INTERNET_REQUIRED_ACTIONS.map((item, idx) => (
|
||||
<div key={idx} className="flex items-start gap-2 text-sm">
|
||||
<span className="text-amber-600 mt-0.5">•</span>
|
||||
<div>
|
||||
<span className="font-medium text-amber-800">{item.action}</span>
|
||||
<span className="text-amber-600 ml-1">– {item.description}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-amber-600 mt-3 italic">
|
||||
💡 Tipp: Internet am Router/Switch nur bei Bedarf für den Mac Mini aktivieren.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import type { MacMiniStatus, DownloadProgress, ModelDescription } from '../types'
|
||||
import { MODEL_DATABASE, RECOMMENDED_MODELS } from '../constants'
|
||||
|
||||
function getModelInfo(modelName: string): ModelDescription | null {
|
||||
if (MODEL_DATABASE[modelName]) return MODEL_DATABASE[modelName]
|
||||
const baseName = modelName.split(':')[0]
|
||||
const matchingKey = Object.keys(MODEL_DATABASE).find(key =>
|
||||
key.startsWith(baseName) || key === baseName
|
||||
)
|
||||
return matchingKey ? MODEL_DATABASE[matchingKey] : null
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number) {
|
||||
if (bytes === 0) return '0 B'
|
||||
const k = 1024
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
|
||||
}
|
||||
|
||||
export default function OllamaSection({
|
||||
status,
|
||||
actionLoading,
|
||||
downloadProgress,
|
||||
modelInput,
|
||||
setModelInput,
|
||||
onPullModel,
|
||||
}: {
|
||||
status: MacMiniStatus | null
|
||||
actionLoading: string | null
|
||||
downloadProgress: DownloadProgress | null
|
||||
modelInput: string
|
||||
setModelInput: (v: string) => void
|
||||
onPullModel: () => void
|
||||
}) {
|
||||
const [selectedModel, setSelectedModel] = useState<string | null>(null)
|
||||
const [showRecommendations, setShowRecommendations] = useState(false)
|
||||
|
||||
const isModelInstalled = (modelName: string): boolean => {
|
||||
if (!status?.models) return false
|
||||
return status.models.some(m =>
|
||||
m.name === modelName || m.name.startsWith(modelName.split(':')[0])
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6">
|
||||
<h3 className="font-semibold text-slate-900 flex items-center gap-2 mb-4">
|
||||
<span className="text-2xl">🤖</span> Ollama LLM Modelle
|
||||
</h3>
|
||||
|
||||
{/* Installed Models */}
|
||||
{status?.models && status.models.length > 0 ? (
|
||||
<div className="space-y-2 mb-6">
|
||||
{status.models.map((model, idx) => {
|
||||
const modelInfo = getModelInfo(model.name)
|
||||
return (
|
||||
<div key={idx} className="flex items-center justify-between bg-slate-50 rounded-lg p-3 hover:bg-slate-100 transition-colors">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="w-2 h-2 rounded-full bg-green-500"></span>
|
||||
<span className="font-medium text-slate-700">{model.name}</span>
|
||||
{modelInfo && (
|
||||
<button
|
||||
onClick={() => setSelectedModel(model.name)}
|
||||
className="text-blue-500 hover:text-blue-700 transition-colors"
|
||||
title="Modell-Info anzeigen"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
{modelInfo?.category === 'vision' && (
|
||||
<span className="px-2 py-0.5 text-xs bg-purple-100 text-purple-700 rounded-full">Vision</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-sm text-slate-500">{model.size}</span>
|
||||
<span className="text-sm text-slate-400">{model.modified}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-slate-500 text-center py-4 mb-6">
|
||||
{status?.ollama ? 'Keine Modelle installiert' : 'Ollama nicht erreichbar'}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Model Info Modal */}
|
||||
{selectedModel && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50" onClick={() => setSelectedModel(null)}>
|
||||
<div className="bg-white rounded-xl p-6 max-w-lg w-full mx-4 shadow-2xl" onClick={e => e.stopPropagation()}>
|
||||
{(() => {
|
||||
const info = getModelInfo(selectedModel)
|
||||
if (!info) return <p>Keine Informationen verfügbar</p>
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div>
|
||||
<h3 className="text-xl font-bold text-slate-900">{info.name}</h3>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<span className={`px-2 py-0.5 text-xs rounded-full ${
|
||||
info.category === 'vision' ? 'bg-purple-100 text-purple-700' :
|
||||
info.category === 'text' ? 'bg-blue-100 text-blue-700' :
|
||||
'bg-slate-100 text-slate-700'
|
||||
}`}>
|
||||
{info.category === 'vision' ? '👁️ Vision' : info.category === 'text' ? '📝 Text' : info.category}
|
||||
</span>
|
||||
<span className="text-sm text-slate-500">{info.size}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={() => setSelectedModel(null)} className="text-slate-400 hover:text-slate-600">
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-slate-600 mb-4">{info.description}</p>
|
||||
<div>
|
||||
<h4 className="font-medium text-slate-700 mb-2">Geeignet für:</h4>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{info.useCases.map((useCase, i) => (
|
||||
<span key={i} className="px-3 py-1 bg-slate-100 text-slate-700 rounded-full text-sm">
|
||||
{useCase}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Download New Model */}
|
||||
<div className="border-t border-slate-200 pt-6">
|
||||
<h4 className="font-medium text-slate-700 mb-3">Neues Modell herunterladen</h4>
|
||||
<div className="flex gap-3 mb-4">
|
||||
<input
|
||||
type="text"
|
||||
value={modelInput}
|
||||
onChange={(e) => setModelInput(e.target.value)}
|
||||
placeholder="z.B. llama3.2, mistral, qwen2.5:14b"
|
||||
className="flex-1 px-4 py-2 border border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
disabled={actionLoading === 'pull'}
|
||||
/>
|
||||
<button
|
||||
onClick={onPullModel}
|
||||
disabled={actionLoading !== null || !status?.ollama || !modelInput.trim()}
|
||||
className="px-6 py-2 bg-primary-600 text-white rounded-lg font-medium hover:bg-primary-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{actionLoading === 'pull' ? 'Lädt...' : 'Herunterladen'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Download Progress */}
|
||||
{downloadProgress && (
|
||||
<div className="bg-slate-50 rounded-lg p-4">
|
||||
<div className="flex justify-between mb-2">
|
||||
<span className="font-medium text-slate-700">{downloadProgress.model}</span>
|
||||
<span className="text-sm text-slate-500">
|
||||
{formatBytes(downloadProgress.completed)} / {formatBytes(downloadProgress.total)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-3 bg-slate-200 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-primary-500 to-primary-600 transition-all duration-300"
|
||||
style={{ width: `${downloadProgress.percent}%` }}
|
||||
></div>
|
||||
</div>
|
||||
<div className="text-center mt-2 text-sm font-medium text-slate-600">
|
||||
{downloadProgress.percent}%
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Toggle Recommendations */}
|
||||
<button
|
||||
onClick={() => setShowRecommendations(!showRecommendations)}
|
||||
className="mt-4 text-primary-600 hover:text-primary-700 font-medium text-sm flex items-center gap-2"
|
||||
>
|
||||
<svg className={`w-4 h-4 transition-transform ${showRecommendations ? 'rotate-180' : ''}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
{showRecommendations ? 'Empfehlungen ausblenden' : 'Modell-Empfehlungen für Klausurkorrektur & Handschrift anzeigen'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Recommendations Section */}
|
||||
{showRecommendations && (
|
||||
<div className="border-t border-slate-200 pt-6 mt-6">
|
||||
<h4 className="font-semibold text-slate-900 mb-4">📚 Empfohlene Modelle</h4>
|
||||
|
||||
{/* Handwriting Recognition */}
|
||||
<div className="mb-6">
|
||||
<h5 className="font-medium text-slate-700 flex items-center gap-2 mb-3">
|
||||
<span className="text-lg">✍️</span> Handschrifterkennung (Vision-Modelle)
|
||||
</h5>
|
||||
<div className="space-y-2">
|
||||
{RECOMMENDED_MODELS.handwriting.map((rec, idx) => {
|
||||
const info = MODEL_DATABASE[rec.model]
|
||||
const installed = isModelInstalled(rec.model)
|
||||
return (
|
||||
<div key={idx} className={`flex items-center justify-between rounded-lg p-3 ${installed ? 'bg-green-50 border border-green-200' : 'bg-slate-50'}`}>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-slate-700">{info?.name || rec.model}</span>
|
||||
<span className="px-2 py-0.5 text-xs bg-purple-100 text-purple-700 rounded-full">Vision</span>
|
||||
{info?.recommended && <span className="px-2 py-0.5 text-xs bg-yellow-100 text-yellow-700 rounded-full">⭐ Empfohlen</span>}
|
||||
{installed && <span className="px-2 py-0.5 text-xs bg-green-100 text-green-700 rounded-full">✓ Installiert</span>}
|
||||
</div>
|
||||
<p className="text-sm text-slate-500 mt-1">{rec.reason}</p>
|
||||
<p className="text-xs text-slate-400 mt-0.5">Größe: {info?.size || 'unbekannt'}</p>
|
||||
</div>
|
||||
{!installed && (
|
||||
<button
|
||||
onClick={() => { setModelInput(rec.model); onPullModel() }}
|
||||
disabled={actionLoading !== null || !status?.ollama}
|
||||
className="ml-4 px-4 py-2 bg-primary-600 text-white text-sm rounded-lg font-medium hover:bg-primary-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
Installieren
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Grading / Text Analysis */}
|
||||
<div>
|
||||
<h5 className="font-medium text-slate-700 flex items-center gap-2 mb-3">
|
||||
<span className="text-lg">📝</span> Klausurkorrektur (Text-Modelle)
|
||||
</h5>
|
||||
<div className="space-y-2">
|
||||
{RECOMMENDED_MODELS.grading.map((rec, idx) => {
|
||||
const info = MODEL_DATABASE[rec.model]
|
||||
const installed = isModelInstalled(rec.model)
|
||||
return (
|
||||
<div key={idx} className={`flex items-center justify-between rounded-lg p-3 ${installed ? 'bg-green-50 border border-green-200' : 'bg-slate-50'}`}>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-slate-700">{info?.name || rec.model}</span>
|
||||
<span className="px-2 py-0.5 text-xs bg-blue-100 text-blue-700 rounded-full">Text</span>
|
||||
{info?.recommended && <span className="px-2 py-0.5 text-xs bg-yellow-100 text-yellow-700 rounded-full">⭐ Empfohlen</span>}
|
||||
{installed && <span className="px-2 py-0.5 text-xs bg-green-100 text-green-700 rounded-full">✓ Installiert</span>}
|
||||
</div>
|
||||
<p className="text-sm text-slate-500 mt-1">{rec.reason}</p>
|
||||
<p className="text-xs text-slate-400 mt-0.5">Größe: {info?.size || 'unbekannt'}</p>
|
||||
</div>
|
||||
{!installed && (
|
||||
<button
|
||||
onClick={() => { setModelInput(rec.model); onPullModel() }}
|
||||
disabled={actionLoading !== null || !status?.ollama}
|
||||
className="ml-4 px-4 py-2 bg-primary-600 text-white text-sm rounded-lg font-medium hover:bg-primary-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
Installieren
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info Box */}
|
||||
<div className="mt-6 bg-amber-50 border border-amber-200 rounded-lg p-4">
|
||||
<div className="flex gap-3">
|
||||
<span className="text-xl">💡</span>
|
||||
<div>
|
||||
<h5 className="font-medium text-amber-900">Tipp: Modell-Kombinationen</h5>
|
||||
<p className="text-sm text-amber-800 mt-1">
|
||||
Für beste Ergebnisse bei Klausuren mit Handschrift kombiniere ein <strong>Vision-Modell</strong> (für OCR/Handschrifterkennung)
|
||||
mit einem <strong>Text-Modell</strong> (für Bewertung und Feedback). Beispiel: <code className="bg-amber-100 px-1 rounded">llama3.2-vision:11b</code> + <code className="bg-amber-100 px-1 rounded">qwen2.5:14b</code>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
'use client'
|
||||
|
||||
import type { MacMiniStatus } from '../types'
|
||||
|
||||
export default function PowerControls({
|
||||
status,
|
||||
loading,
|
||||
actionLoading,
|
||||
message,
|
||||
error,
|
||||
onWake,
|
||||
onRestart,
|
||||
onShutdown,
|
||||
onRefresh,
|
||||
}: {
|
||||
status: MacMiniStatus | null
|
||||
loading: boolean
|
||||
actionLoading: string | null
|
||||
message: string | null
|
||||
error: string | null
|
||||
onWake: () => void
|
||||
onRestart: () => void
|
||||
onShutdown: () => void
|
||||
onRefresh: () => void
|
||||
}) {
|
||||
const getStatusBadge = (online: boolean) => {
|
||||
return online
|
||||
? 'px-3 py-1 rounded-full text-sm font-semibold bg-green-100 text-green-800'
|
||||
: 'px-3 py-1 rounded-full text-sm font-semibold bg-red-100 text-red-800'
|
||||
}
|
||||
|
||||
const getServiceStatus = (ok: boolean) => {
|
||||
return ok
|
||||
? 'flex items-center gap-2 text-green-600'
|
||||
: 'flex items-center gap-2 text-red-500'
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6 mb-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="text-4xl">🖥️</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-slate-900">Mac Mini Headless</h2>
|
||||
<p className="text-slate-500 text-sm">IP: {status?.ip || '192.168.178.100'}</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className={getStatusBadge(status?.online || false)}>
|
||||
{loading ? 'Laden...' : status?.online ? 'Online' : 'Offline'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Power Buttons */}
|
||||
<div className="flex items-center gap-4 mb-6">
|
||||
<button
|
||||
onClick={onWake}
|
||||
disabled={actionLoading !== null}
|
||||
className="px-4 py-2 bg-green-600 text-white rounded-lg font-medium hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{actionLoading === 'wake' ? '...' : '⚡ Wake on LAN'}
|
||||
</button>
|
||||
<button
|
||||
onClick={onRestart}
|
||||
disabled={actionLoading !== null || !status?.online}
|
||||
className="px-4 py-2 bg-yellow-600 text-white rounded-lg font-medium hover:bg-yellow-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{actionLoading === 'restart' ? '...' : '🔄 Neustart'}
|
||||
</button>
|
||||
<button
|
||||
onClick={onShutdown}
|
||||
disabled={actionLoading !== null || !status?.online}
|
||||
className="px-4 py-2 bg-red-600 text-white rounded-lg font-medium hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{actionLoading === 'shutdown' ? '...' : '⏻ Herunterfahren'}
|
||||
</button>
|
||||
<button
|
||||
onClick={onRefresh}
|
||||
disabled={loading}
|
||||
className="px-4 py-2 border border-slate-300 text-slate-700 rounded-lg font-medium hover:bg-slate-50 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{loading ? '...' : '🔍 Status aktualisieren'}
|
||||
</button>
|
||||
|
||||
{message && <span className="ml-4 text-sm text-green-600 font-medium">{message}</span>}
|
||||
{error && <span className="ml-4 text-sm text-red-600 font-medium">{error}</span>}
|
||||
</div>
|
||||
|
||||
{/* Service Status Grid */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
|
||||
<div className="bg-slate-50 rounded-lg p-4">
|
||||
<div className="text-sm text-slate-500 mb-1">Ping</div>
|
||||
<div className={getServiceStatus(status?.ping || false)}>
|
||||
<span className={`w-2 h-2 rounded-full ${status?.ping ? 'bg-green-500' : 'bg-red-500'}`}></span>
|
||||
{status?.ping ? 'Erreichbar' : 'Nicht erreichbar'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-slate-50 rounded-lg p-4">
|
||||
<div className="text-sm text-slate-500 mb-1">SSH</div>
|
||||
<div className={getServiceStatus(status?.ssh || false)}>
|
||||
<span className={`w-2 h-2 rounded-full ${status?.ssh ? 'bg-green-500' : 'bg-red-500'}`}></span>
|
||||
{status?.ssh ? 'Verbunden' : 'Getrennt'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-slate-50 rounded-lg p-4">
|
||||
<div className="text-sm text-slate-500 mb-1">Docker</div>
|
||||
<div className={getServiceStatus(status?.docker || false)}>
|
||||
<span className={`w-2 h-2 rounded-full ${status?.docker ? 'bg-green-500' : 'bg-red-500'}`}></span>
|
||||
{status?.docker ? 'Aktiv' : 'Inaktiv'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-slate-50 rounded-lg p-4">
|
||||
<div className="text-sm text-slate-500 mb-1">Ollama</div>
|
||||
<div className={getServiceStatus(status?.ollama || false)}>
|
||||
<span className={`w-2 h-2 rounded-full ${status?.ollama ? 'bg-green-500' : 'bg-red-500'}`}></span>
|
||||
{status?.ollama ? 'Bereit' : 'Nicht bereit'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-slate-50 rounded-lg p-4">
|
||||
<div className="text-sm text-slate-500 mb-1">Uptime</div>
|
||||
<div className="font-semibold text-slate-700">
|
||||
{status?.uptime || '-'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import type { ModelDescription } from './types'
|
||||
|
||||
export const API_BASE = 'http://192.168.178.100:8000/api/mac-mini'
|
||||
|
||||
// Aktionen die Internet benötigen
|
||||
export const INTERNET_REQUIRED_ACTIONS = [
|
||||
{ action: 'LLM Modelle herunterladen', description: 'Ollama pull benötigt Verbindung zu ollama.com' },
|
||||
{ action: 'Docker Base Images pullen', description: 'Neue Images von Docker Hub/GHCR' },
|
||||
{ action: 'npm/pip/go Packages', description: 'Beim ersten Build oder neuen Dependencies' },
|
||||
{ action: 'Git Pull/Push', description: 'Code-Synchronisation mit Remote-Repository' },
|
||||
]
|
||||
|
||||
export const MODEL_DATABASE: Record<string, ModelDescription> = {
|
||||
'llama3.2-vision:11b': {
|
||||
name: 'Llama 3.2 Vision 11B', category: 'vision', size: '7.8 GB',
|
||||
description: 'Metas multimodales Vision-Modell. Kann Bilder und PDFs analysieren, Text aus Handschrift extrahieren.',
|
||||
useCases: ['Handschrifterkennung', 'Bild-Analyse', 'Dokumentenverarbeitung', 'OCR-Aufgaben'],
|
||||
recommended: true
|
||||
},
|
||||
'llama3.2-vision:90b': {
|
||||
name: 'Llama 3.2 Vision 90B', category: 'vision', size: '55 GB',
|
||||
description: 'Größte Version von Llama Vision. Beste Qualität für komplexe Bildanalyse.',
|
||||
useCases: ['Komplexe Handschrift', 'Detaillierte Bild-Analyse', 'Mathematische Formeln'],
|
||||
},
|
||||
'minicpm-v': {
|
||||
name: 'MiniCPM-V', category: 'vision', size: '5.5 GB',
|
||||
description: 'Kompaktes Vision-Modell mit gutem Preis-Leistungs-Verhältnis für OCR.',
|
||||
useCases: ['Schnelle OCR', 'Einfache Handschrift', 'Tabellen-Erkennung'],
|
||||
recommended: true
|
||||
},
|
||||
'llava:13b': {
|
||||
name: 'LLaVA 13B', category: 'vision', size: '8 GB',
|
||||
description: 'Large Language-and-Vision Assistant. Gut für Bild-zu-Text Aufgaben.',
|
||||
useCases: ['Bildbeschreibung', 'Handschrift', 'Diagramm-Analyse'],
|
||||
},
|
||||
'llava:34b': {
|
||||
name: 'LLaVA 34B', category: 'vision', size: '20 GB',
|
||||
description: 'Größere LLaVA-Version mit besserer Genauigkeit.',
|
||||
useCases: ['Komplexe Dokumente', 'Wissenschaftliche Notation', 'Detailanalyse'],
|
||||
},
|
||||
'bakllava': {
|
||||
name: 'BakLLaVA', category: 'vision', size: '4.7 GB',
|
||||
description: 'Verbesserte LLaVA-Variante mit Mistral-Basis.',
|
||||
useCases: ['Schnelle Bildanalyse', 'Handschrift', 'Formular-Verarbeitung'],
|
||||
},
|
||||
'qwen2.5:14b': {
|
||||
name: 'Qwen 2.5 14B', category: 'text', size: '9 GB',
|
||||
description: 'Alibabas neuestes Sprachmodell. Exzellent für deutsche Texte und Bewertungsaufgaben.',
|
||||
useCases: ['Klausurkorrektur', 'Aufsatzbewertung', 'Feedback-Generierung', 'Grammatikprüfung'],
|
||||
recommended: true
|
||||
},
|
||||
'qwen2.5:7b': {
|
||||
name: 'Qwen 2.5 7B', category: 'text', size: '4.7 GB',
|
||||
description: 'Kleinere Qwen-Version, schneller bei ähnlicher Qualität.',
|
||||
useCases: ['Schnelle Korrektur', 'Einfache Bewertungen', 'Rechtschreibprüfung'],
|
||||
},
|
||||
'qwen2.5:32b': {
|
||||
name: 'Qwen 2.5 32B', category: 'text', size: '19 GB',
|
||||
description: 'Große Qwen-Version für komplexe Bewertungsaufgaben.',
|
||||
useCases: ['Detaillierte Analyse', 'Abitur-Klausuren', 'Komplexe Argumentation'],
|
||||
},
|
||||
'llama3.1:8b': {
|
||||
name: 'Llama 3.1 8B', category: 'text', size: '4.7 GB',
|
||||
description: 'Metas schnelles Textmodell. Gute Balance aus Geschwindigkeit und Qualität.',
|
||||
useCases: ['Allgemeine Korrektur', 'Schnelles Feedback', 'Zusammenfassungen'],
|
||||
},
|
||||
'llama3.1:70b': {
|
||||
name: 'Llama 3.1 70B', category: 'text', size: '40 GB',
|
||||
description: 'Großes Llama-Modell für anspruchsvolle Aufgaben.',
|
||||
useCases: ['Komplexe Klausuren', 'Tiefgehende Analyse', 'Wissenschaftliche Texte'],
|
||||
},
|
||||
'mistral': {
|
||||
name: 'Mistral 7B', category: 'text', size: '4.1 GB',
|
||||
description: 'Effizientes europäisches Modell mit guter deutscher Sprachunterstützung.',
|
||||
useCases: ['Deutsche Texte', 'Schnelle Verarbeitung', 'Allgemeine Korrektur'],
|
||||
},
|
||||
'mixtral:8x7b': {
|
||||
name: 'Mixtral 8x7B', category: 'text', size: '26 GB',
|
||||
description: 'Mixture-of-Experts Modell. Kombiniert Geschwindigkeit mit hoher Qualität.',
|
||||
useCases: ['Komplexe Korrektur', 'Multi-Aspekt-Bewertung', 'Wissenschaftliche Arbeiten'],
|
||||
},
|
||||
'gemma2:9b': {
|
||||
name: 'Gemma 2 9B', category: 'text', size: '5.5 GB',
|
||||
description: 'Googles kompaktes Modell. Gut für Instruktionen und Bewertungen.',
|
||||
useCases: ['Strukturierte Bewertung', 'Feedback', 'Zusammenfassungen'],
|
||||
},
|
||||
'phi3': {
|
||||
name: 'Phi-3', category: 'text', size: '2.3 GB',
|
||||
description: 'Microsofts kleines aber leistungsfähiges Modell.',
|
||||
useCases: ['Schnelle Checks', 'Einfache Korrektur', 'Ressourcenschonend'],
|
||||
},
|
||||
}
|
||||
|
||||
export const RECOMMENDED_MODELS = {
|
||||
handwriting: [
|
||||
{ model: 'llama3.2-vision:11b', reason: 'Beste Balance aus Qualität und Geschwindigkeit für Handschrift' },
|
||||
{ model: 'minicpm-v', reason: 'Schnell und ressourcenschonend für einfache Handschrift' },
|
||||
{ model: 'llava:13b', reason: 'Gute Alternative mit bewährter Vision-Architektur' },
|
||||
],
|
||||
grading: [
|
||||
{ model: 'qwen2.5:14b', reason: 'Beste Qualität für deutsche Klausurkorrektur' },
|
||||
{ model: 'llama3.1:8b', reason: 'Schnell für einfache Bewertungen' },
|
||||
{ model: 'mistral', reason: 'Europäisches Modell mit guter Sprachqualität' },
|
||||
]
|
||||
}
|
||||
@@ -12,188 +12,12 @@
|
||||
|
||||
import AdminLayout from '@/components/admin/AdminLayout'
|
||||
import { useEffect, useState, useCallback, useRef } from 'react'
|
||||
|
||||
interface MacMiniStatus {
|
||||
online: boolean
|
||||
ping: boolean
|
||||
ssh: boolean
|
||||
docker: boolean
|
||||
ollama: boolean
|
||||
internet: boolean // Neuer Status: Hat Mac Mini Internet-Zugang?
|
||||
ip: string
|
||||
uptime?: string
|
||||
cpu_load?: string
|
||||
memory?: string
|
||||
containers?: ContainerInfo[]
|
||||
models?: ModelInfo[]
|
||||
error?: string
|
||||
}
|
||||
|
||||
// Aktionen die Internet benötigen
|
||||
const INTERNET_REQUIRED_ACTIONS = [
|
||||
{ action: 'LLM Modelle herunterladen', description: 'Ollama pull benötigt Verbindung zu ollama.com' },
|
||||
{ action: 'Docker Base Images pullen', description: 'Neue Images von Docker Hub/GHCR' },
|
||||
{ action: 'npm/pip/go Packages', description: 'Beim ersten Build oder neuen Dependencies' },
|
||||
{ action: 'Git Pull/Push', description: 'Code-Synchronisation mit Remote-Repository' },
|
||||
]
|
||||
|
||||
interface ContainerInfo {
|
||||
name: string
|
||||
status: string
|
||||
ports?: string
|
||||
}
|
||||
|
||||
interface ModelInfo {
|
||||
name: string
|
||||
size: string
|
||||
modified: string
|
||||
}
|
||||
|
||||
interface DownloadProgress {
|
||||
model: string
|
||||
status: string
|
||||
completed: number
|
||||
total: number
|
||||
percent: number
|
||||
}
|
||||
|
||||
// Modell-Informationen für Beschreibungen und Empfehlungen
|
||||
interface ModelDescription {
|
||||
name: string
|
||||
category: 'vision' | 'text' | 'code' | 'embedding'
|
||||
size: string
|
||||
description: string
|
||||
useCases: string[]
|
||||
recommended?: boolean
|
||||
}
|
||||
|
||||
const MODEL_DATABASE: Record<string, ModelDescription> = {
|
||||
// Vision-Modelle (Handschrifterkennung)
|
||||
'llama3.2-vision:11b': {
|
||||
name: 'Llama 3.2 Vision 11B',
|
||||
category: 'vision',
|
||||
size: '7.8 GB',
|
||||
description: 'Metas multimodales Vision-Modell. Kann Bilder und PDFs analysieren, Text aus Handschrift extrahieren.',
|
||||
useCases: ['Handschrifterkennung', 'Bild-Analyse', 'Dokumentenverarbeitung', 'OCR-Aufgaben'],
|
||||
recommended: true
|
||||
},
|
||||
'llama3.2-vision:90b': {
|
||||
name: 'Llama 3.2 Vision 90B',
|
||||
category: 'vision',
|
||||
size: '55 GB',
|
||||
description: 'Größte Version von Llama Vision. Beste Qualität für komplexe Bildanalyse.',
|
||||
useCases: ['Komplexe Handschrift', 'Detaillierte Bild-Analyse', 'Mathematische Formeln'],
|
||||
},
|
||||
'minicpm-v': {
|
||||
name: 'MiniCPM-V',
|
||||
category: 'vision',
|
||||
size: '5.5 GB',
|
||||
description: 'Kompaktes Vision-Modell mit gutem Preis-Leistungs-Verhältnis für OCR.',
|
||||
useCases: ['Schnelle OCR', 'Einfache Handschrift', 'Tabellen-Erkennung'],
|
||||
recommended: true
|
||||
},
|
||||
'llava:13b': {
|
||||
name: 'LLaVA 13B',
|
||||
category: 'vision',
|
||||
size: '8 GB',
|
||||
description: 'Large Language-and-Vision Assistant. Gut für Bild-zu-Text Aufgaben.',
|
||||
useCases: ['Bildbeschreibung', 'Handschrift', 'Diagramm-Analyse'],
|
||||
},
|
||||
'llava:34b': {
|
||||
name: 'LLaVA 34B',
|
||||
category: 'vision',
|
||||
size: '20 GB',
|
||||
description: 'Größere LLaVA-Version mit besserer Genauigkeit.',
|
||||
useCases: ['Komplexe Dokumente', 'Wissenschaftliche Notation', 'Detailanalyse'],
|
||||
},
|
||||
'bakllava': {
|
||||
name: 'BakLLaVA',
|
||||
category: 'vision',
|
||||
size: '4.7 GB',
|
||||
description: 'Verbesserte LLaVA-Variante mit Mistral-Basis.',
|
||||
useCases: ['Schnelle Bildanalyse', 'Handschrift', 'Formular-Verarbeitung'],
|
||||
},
|
||||
|
||||
// Text-Modelle (Klausurkorrektur)
|
||||
'qwen2.5:14b': {
|
||||
name: 'Qwen 2.5 14B',
|
||||
category: 'text',
|
||||
size: '9 GB',
|
||||
description: 'Alibabas neuestes Sprachmodell. Exzellent für deutsche Texte und Bewertungsaufgaben.',
|
||||
useCases: ['Klausurkorrektur', 'Aufsatzbewertung', 'Feedback-Generierung', 'Grammatikprüfung'],
|
||||
recommended: true
|
||||
},
|
||||
'qwen2.5:7b': {
|
||||
name: 'Qwen 2.5 7B',
|
||||
category: 'text',
|
||||
size: '4.7 GB',
|
||||
description: 'Kleinere Qwen-Version, schneller bei ähnlicher Qualität.',
|
||||
useCases: ['Schnelle Korrektur', 'Einfache Bewertungen', 'Rechtschreibprüfung'],
|
||||
},
|
||||
'qwen2.5:32b': {
|
||||
name: 'Qwen 2.5 32B',
|
||||
category: 'text',
|
||||
size: '19 GB',
|
||||
description: 'Große Qwen-Version für komplexe Bewertungsaufgaben.',
|
||||
useCases: ['Detaillierte Analyse', 'Abitur-Klausuren', 'Komplexe Argumentation'],
|
||||
},
|
||||
'llama3.1:8b': {
|
||||
name: 'Llama 3.1 8B',
|
||||
category: 'text',
|
||||
size: '4.7 GB',
|
||||
description: 'Metas schnelles Textmodell. Gute Balance aus Geschwindigkeit und Qualität.',
|
||||
useCases: ['Allgemeine Korrektur', 'Schnelles Feedback', 'Zusammenfassungen'],
|
||||
},
|
||||
'llama3.1:70b': {
|
||||
name: 'Llama 3.1 70B',
|
||||
category: 'text',
|
||||
size: '40 GB',
|
||||
description: 'Großes Llama-Modell für anspruchsvolle Aufgaben.',
|
||||
useCases: ['Komplexe Klausuren', 'Tiefgehende Analyse', 'Wissenschaftliche Texte'],
|
||||
},
|
||||
'mistral': {
|
||||
name: 'Mistral 7B',
|
||||
category: 'text',
|
||||
size: '4.1 GB',
|
||||
description: 'Effizientes europäisches Modell mit guter deutscher Sprachunterstützung.',
|
||||
useCases: ['Deutsche Texte', 'Schnelle Verarbeitung', 'Allgemeine Korrektur'],
|
||||
},
|
||||
'mixtral:8x7b': {
|
||||
name: 'Mixtral 8x7B',
|
||||
category: 'text',
|
||||
size: '26 GB',
|
||||
description: 'Mixture-of-Experts Modell. Kombiniert Geschwindigkeit mit hoher Qualität.',
|
||||
useCases: ['Komplexe Korrektur', 'Multi-Aspekt-Bewertung', 'Wissenschaftliche Arbeiten'],
|
||||
},
|
||||
'gemma2:9b': {
|
||||
name: 'Gemma 2 9B',
|
||||
category: 'text',
|
||||
size: '5.5 GB',
|
||||
description: 'Googles kompaktes Modell. Gut für Instruktionen und Bewertungen.',
|
||||
useCases: ['Strukturierte Bewertung', 'Feedback', 'Zusammenfassungen'],
|
||||
},
|
||||
'phi3': {
|
||||
name: 'Phi-3',
|
||||
category: 'text',
|
||||
size: '2.3 GB',
|
||||
description: 'Microsofts kleines aber leistungsfähiges Modell.',
|
||||
useCases: ['Schnelle Checks', 'Einfache Korrektur', 'Ressourcenschonend'],
|
||||
},
|
||||
}
|
||||
|
||||
// Empfohlene Modelle für spezifische Anwendungsfälle
|
||||
const RECOMMENDED_MODELS = {
|
||||
handwriting: [
|
||||
{ model: 'llama3.2-vision:11b', reason: 'Beste Balance aus Qualität und Geschwindigkeit für Handschrift' },
|
||||
{ model: 'minicpm-v', reason: 'Schnell und ressourcenschonend für einfache Handschrift' },
|
||||
{ model: 'llava:13b', reason: 'Gute Alternative mit bewährter Vision-Architektur' },
|
||||
],
|
||||
grading: [
|
||||
{ model: 'qwen2.5:14b', reason: 'Beste Qualität für deutsche Klausurkorrektur' },
|
||||
{ model: 'llama3.1:8b', reason: 'Schnell für einfache Bewertungen' },
|
||||
{ model: 'mistral', reason: 'Europäisches Modell mit guter Sprachqualität' },
|
||||
]
|
||||
}
|
||||
import type { MacMiniStatus, DownloadProgress } from './types'
|
||||
import { API_BASE } from './constants'
|
||||
import PowerControls from './_components/PowerControls'
|
||||
import InternetStatus from './_components/InternetStatus'
|
||||
import DockerSection from './_components/DockerSection'
|
||||
import OllamaSection from './_components/OllamaSection'
|
||||
|
||||
export default function MacMiniControlPage() {
|
||||
const [status, setStatus] = useState<MacMiniStatus | null>(null)
|
||||
@@ -203,57 +27,21 @@ export default function MacMiniControlPage() {
|
||||
const [message, setMessage] = useState<string | null>(null)
|
||||
const [downloadProgress, setDownloadProgress] = useState<DownloadProgress | null>(null)
|
||||
const [modelInput, setModelInput] = useState('')
|
||||
const [selectedModel, setSelectedModel] = useState<string | null>(null)
|
||||
const [showRecommendations, setShowRecommendations] = useState(false)
|
||||
const eventSourceRef = useRef<EventSource | null>(null)
|
||||
|
||||
// Get model info from database
|
||||
const getModelInfo = (modelName: string): ModelDescription | null => {
|
||||
// Try exact match first
|
||||
if (MODEL_DATABASE[modelName]) return MODEL_DATABASE[modelName]
|
||||
// Try base name (without tag)
|
||||
const baseName = modelName.split(':')[0]
|
||||
const matchingKey = Object.keys(MODEL_DATABASE).find(key =>
|
||||
key.startsWith(baseName) || key === baseName
|
||||
)
|
||||
return matchingKey ? MODEL_DATABASE[matchingKey] : null
|
||||
}
|
||||
|
||||
// Check if model is installed
|
||||
const isModelInstalled = (modelName: string): boolean => {
|
||||
if (!status?.models) return false
|
||||
return status.models.some(m =>
|
||||
m.name === modelName || m.name.startsWith(modelName.split(':')[0])
|
||||
)
|
||||
}
|
||||
|
||||
// API Endpoint (Mac Mini Backend or local proxy)
|
||||
const API_BASE = 'http://192.168.178.100:8000/api/mac-mini'
|
||||
|
||||
// Fetch status
|
||||
const fetchStatus = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/status`)
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.detail || `HTTP ${response.status}`)
|
||||
}
|
||||
|
||||
if (!response.ok) throw new Error(data.detail || `HTTP ${response.status}`)
|
||||
setStatus(data)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Verbindungsfehler')
|
||||
setStatus({
|
||||
online: false,
|
||||
ping: false,
|
||||
ssh: false,
|
||||
docker: false,
|
||||
ollama: false,
|
||||
internet: false,
|
||||
ip: '192.168.178.100',
|
||||
online: false, ping: false, ssh: false, docker: false,
|
||||
ollama: false, internet: false, ip: '192.168.178.100',
|
||||
error: 'Verbindung fehlgeschlagen'
|
||||
})
|
||||
} finally {
|
||||
@@ -261,161 +49,81 @@ export default function MacMiniControlPage() {
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Initial load
|
||||
useEffect(() => {
|
||||
fetchStatus()
|
||||
}, [fetchStatus])
|
||||
|
||||
// Auto-refresh every 30 seconds
|
||||
useEffect(() => { fetchStatus() }, [fetchStatus])
|
||||
useEffect(() => {
|
||||
const interval = setInterval(fetchStatus, 30000)
|
||||
return () => clearInterval(interval)
|
||||
}, [fetchStatus])
|
||||
|
||||
// Wake on LAN
|
||||
const wakeOnLan = async () => {
|
||||
setActionLoading('wake')
|
||||
const performAction = async (action: string, endpoint: string, confirmMsg?: string) => {
|
||||
if (confirmMsg && !confirm(confirmMsg)) return
|
||||
setActionLoading(action)
|
||||
setError(null)
|
||||
setMessage(null)
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/wake`, { method: 'POST' })
|
||||
const response = await fetch(`${API_BASE}/${endpoint}`, { method: 'POST' })
|
||||
const data = await response.json()
|
||||
if (!response.ok) throw new Error(data.detail || `${action} fehlgeschlagen`)
|
||||
return data
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : `Fehler bei ${action}`)
|
||||
return null
|
||||
} finally {
|
||||
setActionLoading(null)
|
||||
}
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.detail || 'Wake-on-LAN fehlgeschlagen')
|
||||
}
|
||||
|
||||
const wakeOnLan = async () => {
|
||||
const result = await performAction('wake', 'wake')
|
||||
if (result) {
|
||||
setMessage('Wake-on-LAN Paket gesendet')
|
||||
setTimeout(fetchStatus, 5000)
|
||||
setTimeout(fetchStatus, 15000)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Fehler beim Aufwecken')
|
||||
} finally {
|
||||
setActionLoading(null)
|
||||
}
|
||||
}
|
||||
|
||||
// Restart
|
||||
const restart = async () => {
|
||||
if (!confirm('Mac Mini wirklich neu starten?')) return
|
||||
|
||||
setActionLoading('restart')
|
||||
setError(null)
|
||||
setMessage(null)
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/restart`, { method: 'POST' })
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.detail || 'Neustart fehlgeschlagen')
|
||||
}
|
||||
|
||||
const result = await performAction('restart', 'restart', 'Mac Mini wirklich neu starten?')
|
||||
if (result) {
|
||||
setMessage('Neustart eingeleitet')
|
||||
setTimeout(fetchStatus, 30000)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Fehler beim Neustart')
|
||||
} finally {
|
||||
setActionLoading(null)
|
||||
}
|
||||
}
|
||||
|
||||
// Shutdown
|
||||
const shutdown = async () => {
|
||||
if (!confirm('Mac Mini wirklich herunterfahren?')) return
|
||||
|
||||
setActionLoading('shutdown')
|
||||
setError(null)
|
||||
setMessage(null)
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/shutdown`, { method: 'POST' })
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.detail || 'Shutdown fehlgeschlagen')
|
||||
}
|
||||
|
||||
const result = await performAction('shutdown', 'shutdown', 'Mac Mini wirklich herunterfahren?')
|
||||
if (result) {
|
||||
setMessage('Shutdown eingeleitet')
|
||||
setTimeout(fetchStatus, 10000)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Fehler beim Herunterfahren')
|
||||
} finally {
|
||||
setActionLoading(null)
|
||||
}
|
||||
}
|
||||
|
||||
// Docker Up
|
||||
const dockerUp = async () => {
|
||||
setActionLoading('docker-up')
|
||||
setError(null)
|
||||
setMessage(null)
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/docker/up`, { method: 'POST' })
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.detail || 'Docker Start fehlgeschlagen')
|
||||
}
|
||||
|
||||
const result = await performAction('docker-up', 'docker/up')
|
||||
if (result) {
|
||||
setMessage('Docker Container werden gestartet...')
|
||||
setTimeout(fetchStatus, 5000)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Fehler beim Docker Start')
|
||||
} finally {
|
||||
setActionLoading(null)
|
||||
}
|
||||
}
|
||||
|
||||
// Docker Down
|
||||
const dockerDown = async () => {
|
||||
if (!confirm('Docker Container wirklich stoppen?')) return
|
||||
|
||||
setActionLoading('docker-down')
|
||||
setError(null)
|
||||
setMessage(null)
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/docker/down`, { method: 'POST' })
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.detail || 'Docker Stop fehlgeschlagen')
|
||||
}
|
||||
|
||||
const result = await performAction('docker-down', 'docker/down', 'Docker Container wirklich stoppen?')
|
||||
if (result) {
|
||||
setMessage('Docker Container werden gestoppt...')
|
||||
setTimeout(fetchStatus, 5000)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Fehler beim Docker Stop')
|
||||
} finally {
|
||||
setActionLoading(null)
|
||||
}
|
||||
}
|
||||
|
||||
// Pull Model with SSE Progress
|
||||
const pullModel = async () => {
|
||||
if (!modelInput.trim()) return
|
||||
|
||||
setActionLoading('pull')
|
||||
setError(null)
|
||||
setMessage(null)
|
||||
setDownloadProgress({
|
||||
model: modelInput,
|
||||
status: 'starting',
|
||||
completed: 0,
|
||||
total: 0,
|
||||
percent: 0
|
||||
})
|
||||
setDownloadProgress({ model: modelInput, status: 'starting', completed: 0, total: 0, percent: 0 })
|
||||
|
||||
try {
|
||||
// Close any existing EventSource
|
||||
if (eventSourceRef.current) {
|
||||
eventSourceRef.current.close()
|
||||
}
|
||||
if (eventSourceRef.current) eventSourceRef.current.close()
|
||||
|
||||
// Use fetch with streaming for progress
|
||||
const response = await fetch(`${API_BASE}/ollama/pull`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -434,19 +142,15 @@ export default function MacMiniControlPage() {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
|
||||
const text = decoder.decode(value)
|
||||
const lines = text.split('\n').filter(line => line.trim())
|
||||
|
||||
for (const line of lines) {
|
||||
try {
|
||||
const data = JSON.parse(line)
|
||||
if (data.status === 'downloading' && data.total) {
|
||||
setDownloadProgress({
|
||||
model: modelInput,
|
||||
status: data.status,
|
||||
completed: data.completed || 0,
|
||||
total: data.total,
|
||||
model: modelInput, status: data.status,
|
||||
completed: data.completed || 0, total: data.total,
|
||||
percent: Math.round((data.completed || 0) / data.total * 100)
|
||||
})
|
||||
} else if (data.status === 'success') {
|
||||
@@ -457,9 +161,7 @@ export default function MacMiniControlPage() {
|
||||
} else if (data.error) {
|
||||
throw new Error(data.error)
|
||||
}
|
||||
} catch (e) {
|
||||
// Skip parsing errors for incomplete chunks
|
||||
}
|
||||
} catch (e) { /* Skip parsing errors for incomplete chunks */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -471,463 +173,37 @@ export default function MacMiniControlPage() {
|
||||
}
|
||||
}
|
||||
|
||||
// Format bytes
|
||||
const formatBytes = (bytes: number) => {
|
||||
if (bytes === 0) return '0 B'
|
||||
const k = 1024
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
|
||||
}
|
||||
|
||||
// Status badge styling
|
||||
const getStatusBadge = (online: boolean) => {
|
||||
return online
|
||||
? 'px-3 py-1 rounded-full text-sm font-semibold bg-green-100 text-green-800'
|
||||
: 'px-3 py-1 rounded-full text-sm font-semibold bg-red-100 text-red-800'
|
||||
}
|
||||
|
||||
const getServiceStatus = (ok: boolean) => {
|
||||
return ok
|
||||
? 'flex items-center gap-2 text-green-600'
|
||||
: 'flex items-center gap-2 text-red-500'
|
||||
}
|
||||
|
||||
return (
|
||||
<AdminLayout title="Mac Mini Control" description="Headless Server Management">
|
||||
{/* Power Controls */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6 mb-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="text-4xl">🖥️</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-slate-900">Mac Mini Headless</h2>
|
||||
<p className="text-slate-500 text-sm">IP: {status?.ip || '192.168.178.100'}</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className={getStatusBadge(status?.online || false)}>
|
||||
{loading ? 'Laden...' : status?.online ? 'Online' : 'Offline'}
|
||||
</span>
|
||||
</div>
|
||||
<PowerControls
|
||||
status={status}
|
||||
loading={loading}
|
||||
actionLoading={actionLoading}
|
||||
message={message}
|
||||
error={error}
|
||||
onWake={wakeOnLan}
|
||||
onRestart={restart}
|
||||
onShutdown={shutdown}
|
||||
onRefresh={fetchStatus}
|
||||
/>
|
||||
|
||||
{/* Power Buttons */}
|
||||
<div className="flex items-center gap-4 mb-6">
|
||||
<button
|
||||
onClick={wakeOnLan}
|
||||
disabled={actionLoading !== null}
|
||||
className="px-4 py-2 bg-green-600 text-white rounded-lg font-medium hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{actionLoading === 'wake' ? '...' : '⚡ Wake on LAN'}
|
||||
</button>
|
||||
<button
|
||||
onClick={restart}
|
||||
disabled={actionLoading !== null || !status?.online}
|
||||
className="px-4 py-2 bg-yellow-600 text-white rounded-lg font-medium hover:bg-yellow-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{actionLoading === 'restart' ? '...' : '🔄 Neustart'}
|
||||
</button>
|
||||
<button
|
||||
onClick={shutdown}
|
||||
disabled={actionLoading !== null || !status?.online}
|
||||
className="px-4 py-2 bg-red-600 text-white rounded-lg font-medium hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{actionLoading === 'shutdown' ? '...' : '⏻ Herunterfahren'}
|
||||
</button>
|
||||
<button
|
||||
onClick={fetchStatus}
|
||||
disabled={loading}
|
||||
className="px-4 py-2 border border-slate-300 text-slate-700 rounded-lg font-medium hover:bg-slate-50 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{loading ? '...' : '🔍 Status aktualisieren'}
|
||||
</button>
|
||||
<InternetStatus internet={status?.internet} />
|
||||
|
||||
{message && <span className="ml-4 text-sm text-green-600 font-medium">{message}</span>}
|
||||
{error && <span className="ml-4 text-sm text-red-600 font-medium">{error}</span>}
|
||||
</div>
|
||||
<DockerSection
|
||||
status={status}
|
||||
actionLoading={actionLoading}
|
||||
onDockerUp={dockerUp}
|
||||
onDockerDown={dockerDown}
|
||||
/>
|
||||
|
||||
{/* Service Status Grid */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
|
||||
<div className="bg-slate-50 rounded-lg p-4">
|
||||
<div className="text-sm text-slate-500 mb-1">Ping</div>
|
||||
<div className={getServiceStatus(status?.ping || false)}>
|
||||
<span className={`w-2 h-2 rounded-full ${status?.ping ? 'bg-green-500' : 'bg-red-500'}`}></span>
|
||||
{status?.ping ? 'Erreichbar' : 'Nicht erreichbar'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-slate-50 rounded-lg p-4">
|
||||
<div className="text-sm text-slate-500 mb-1">SSH</div>
|
||||
<div className={getServiceStatus(status?.ssh || false)}>
|
||||
<span className={`w-2 h-2 rounded-full ${status?.ssh ? 'bg-green-500' : 'bg-red-500'}`}></span>
|
||||
{status?.ssh ? 'Verbunden' : 'Getrennt'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-slate-50 rounded-lg p-4">
|
||||
<div className="text-sm text-slate-500 mb-1">Docker</div>
|
||||
<div className={getServiceStatus(status?.docker || false)}>
|
||||
<span className={`w-2 h-2 rounded-full ${status?.docker ? 'bg-green-500' : 'bg-red-500'}`}></span>
|
||||
{status?.docker ? 'Aktiv' : 'Inaktiv'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-slate-50 rounded-lg p-4">
|
||||
<div className="text-sm text-slate-500 mb-1">Ollama</div>
|
||||
<div className={getServiceStatus(status?.ollama || false)}>
|
||||
<span className={`w-2 h-2 rounded-full ${status?.ollama ? 'bg-green-500' : 'bg-red-500'}`}></span>
|
||||
{status?.ollama ? 'Bereit' : 'Nicht bereit'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-slate-50 rounded-lg p-4">
|
||||
<div className="text-sm text-slate-500 mb-1">Uptime</div>
|
||||
<div className="font-semibold text-slate-700">
|
||||
{status?.uptime || '-'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Internet Status Banner */}
|
||||
<div className={`rounded-xl border p-4 mb-6 ${
|
||||
status?.internet
|
||||
? 'bg-green-50 border-green-200'
|
||||
: 'bg-amber-50 border-amber-200'
|
||||
}`}>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex gap-3">
|
||||
<span className="text-2xl">{status?.internet ? '🌐' : '📴'}</span>
|
||||
<div>
|
||||
<h3 className={`font-semibold ${status?.internet ? 'text-green-900' : 'text-amber-900'}`}>
|
||||
Internet: {status?.internet ? 'Verbunden' : 'Offline (Normalbetrieb)'}
|
||||
</h3>
|
||||
<p className={`text-sm mt-1 ${status?.internet ? 'text-green-700' : 'text-amber-700'}`}>
|
||||
{status?.internet
|
||||
? 'Mac Mini hat Internet-Zugang. LLM-Downloads und Updates möglich.'
|
||||
: 'Mac Mini arbeitet offline. Für bestimmte Aktionen muss Internet aktiviert werden.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className={`px-3 py-1 rounded-full text-sm font-semibold ${
|
||||
status?.internet
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-amber-100 text-amber-800'
|
||||
}`}>
|
||||
{status?.internet ? 'Online' : 'Offline'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Internet Required Actions - nur anzeigen wenn offline */}
|
||||
{!status?.internet && (
|
||||
<div className="mt-4 pt-4 border-t border-amber-200">
|
||||
<h4 className="font-medium text-amber-900 mb-2">⚠️ Diese Aktionen benötigen Internet:</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
|
||||
{INTERNET_REQUIRED_ACTIONS.map((item, idx) => (
|
||||
<div key={idx} className="flex items-start gap-2 text-sm">
|
||||
<span className="text-amber-600 mt-0.5">•</span>
|
||||
<div>
|
||||
<span className="font-medium text-amber-800">{item.action}</span>
|
||||
<span className="text-amber-600 ml-1">– {item.description}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-amber-600 mt-3 italic">
|
||||
💡 Tipp: Internet am Router/Switch nur bei Bedarf für den Mac Mini aktivieren.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Docker Section */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6 mb-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="font-semibold text-slate-900 flex items-center gap-2">
|
||||
<span className="text-2xl">🐳</span> Docker Container
|
||||
</h3>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={dockerUp}
|
||||
disabled={actionLoading !== null || !status?.online}
|
||||
className="px-3 py-1.5 bg-green-600 text-white text-sm rounded-lg font-medium hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{actionLoading === 'docker-up' ? '...' : '▶ Start'}
|
||||
</button>
|
||||
<button
|
||||
onClick={dockerDown}
|
||||
disabled={actionLoading !== null || !status?.online}
|
||||
className="px-3 py-1.5 bg-red-600 text-white text-sm rounded-lg font-medium hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{actionLoading === 'docker-down' ? '...' : '⏹ Stop'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{status?.containers && status.containers.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{status.containers.map((container, idx) => (
|
||||
<div key={idx} className="flex items-center justify-between bg-slate-50 rounded-lg p-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className={`w-2 h-2 rounded-full ${
|
||||
container.status.includes('Up') ? 'bg-green-500' : 'bg-red-500'
|
||||
}`}></span>
|
||||
<span className="font-medium text-slate-700">{container.name}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
{container.ports && (
|
||||
<span className="text-sm text-slate-500 font-mono">{container.ports}</span>
|
||||
)}
|
||||
<span className={`text-sm ${
|
||||
container.status.includes('Up') ? 'text-green-600' : 'text-red-500'
|
||||
}`}>
|
||||
{container.status}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-slate-500 text-center py-4">
|
||||
{status?.online ? 'Keine Container gefunden' : 'Server nicht erreichbar'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Ollama Section */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6">
|
||||
<h3 className="font-semibold text-slate-900 flex items-center gap-2 mb-4">
|
||||
<span className="text-2xl">🤖</span> Ollama LLM Modelle
|
||||
</h3>
|
||||
|
||||
{/* Installed Models */}
|
||||
{status?.models && status.models.length > 0 ? (
|
||||
<div className="space-y-2 mb-6">
|
||||
{status.models.map((model, idx) => {
|
||||
const modelInfo = getModelInfo(model.name)
|
||||
return (
|
||||
<div key={idx} className="flex items-center justify-between bg-slate-50 rounded-lg p-3 hover:bg-slate-100 transition-colors">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="w-2 h-2 rounded-full bg-green-500"></span>
|
||||
<span className="font-medium text-slate-700">{model.name}</span>
|
||||
{modelInfo && (
|
||||
<button
|
||||
onClick={() => setSelectedModel(model.name)}
|
||||
className="text-blue-500 hover:text-blue-700 transition-colors"
|
||||
title="Modell-Info anzeigen"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
{modelInfo?.category === 'vision' && (
|
||||
<span className="px-2 py-0.5 text-xs bg-purple-100 text-purple-700 rounded-full">Vision</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-sm text-slate-500">{model.size}</span>
|
||||
<span className="text-sm text-slate-400">{model.modified}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-slate-500 text-center py-4 mb-6">
|
||||
{status?.ollama ? 'Keine Modelle installiert' : 'Ollama nicht erreichbar'}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Model Info Modal */}
|
||||
{selectedModel && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50" onClick={() => setSelectedModel(null)}>
|
||||
<div className="bg-white rounded-xl p-6 max-w-lg w-full mx-4 shadow-2xl" onClick={e => e.stopPropagation()}>
|
||||
{(() => {
|
||||
const info = getModelInfo(selectedModel)
|
||||
if (!info) return <p>Keine Informationen verfügbar</p>
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div>
|
||||
<h3 className="text-xl font-bold text-slate-900">{info.name}</h3>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<span className={`px-2 py-0.5 text-xs rounded-full ${
|
||||
info.category === 'vision' ? 'bg-purple-100 text-purple-700' :
|
||||
info.category === 'text' ? 'bg-blue-100 text-blue-700' :
|
||||
'bg-slate-100 text-slate-700'
|
||||
}`}>
|
||||
{info.category === 'vision' ? '👁️ Vision' : info.category === 'text' ? '📝 Text' : info.category}
|
||||
</span>
|
||||
<span className="text-sm text-slate-500">{info.size}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={() => setSelectedModel(null)} className="text-slate-400 hover:text-slate-600">
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-slate-600 mb-4">{info.description}</p>
|
||||
<div>
|
||||
<h4 className="font-medium text-slate-700 mb-2">Geeignet für:</h4>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{info.useCases.map((useCase, i) => (
|
||||
<span key={i} className="px-3 py-1 bg-slate-100 text-slate-700 rounded-full text-sm">
|
||||
{useCase}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Download New Model */}
|
||||
<div className="border-t border-slate-200 pt-6">
|
||||
<h4 className="font-medium text-slate-700 mb-3">Neues Modell herunterladen</h4>
|
||||
<div className="flex gap-3 mb-4">
|
||||
<input
|
||||
type="text"
|
||||
value={modelInput}
|
||||
onChange={(e) => setModelInput(e.target.value)}
|
||||
placeholder="z.B. llama3.2, mistral, qwen2.5:14b"
|
||||
className="flex-1 px-4 py-2 border border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
disabled={actionLoading === 'pull'}
|
||||
/>
|
||||
<button
|
||||
onClick={pullModel}
|
||||
disabled={actionLoading !== null || !status?.ollama || !modelInput.trim()}
|
||||
className="px-6 py-2 bg-primary-600 text-white rounded-lg font-medium hover:bg-primary-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{actionLoading === 'pull' ? 'Lädt...' : 'Herunterladen'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Download Progress */}
|
||||
{downloadProgress && (
|
||||
<div className="bg-slate-50 rounded-lg p-4">
|
||||
<div className="flex justify-between mb-2">
|
||||
<span className="font-medium text-slate-700">{downloadProgress.model}</span>
|
||||
<span className="text-sm text-slate-500">
|
||||
{formatBytes(downloadProgress.completed)} / {formatBytes(downloadProgress.total)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-3 bg-slate-200 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-primary-500 to-primary-600 transition-all duration-300"
|
||||
style={{ width: `${downloadProgress.percent}%` }}
|
||||
></div>
|
||||
</div>
|
||||
<div className="text-center mt-2 text-sm font-medium text-slate-600">
|
||||
{downloadProgress.percent}%
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Toggle Recommendations */}
|
||||
<button
|
||||
onClick={() => setShowRecommendations(!showRecommendations)}
|
||||
className="mt-4 text-primary-600 hover:text-primary-700 font-medium text-sm flex items-center gap-2"
|
||||
>
|
||||
<svg className={`w-4 h-4 transition-transform ${showRecommendations ? 'rotate-180' : ''}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
{showRecommendations ? 'Empfehlungen ausblenden' : 'Modell-Empfehlungen für Klausurkorrektur & Handschrift anzeigen'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Recommendations Section */}
|
||||
{showRecommendations && (
|
||||
<div className="border-t border-slate-200 pt-6 mt-6">
|
||||
<h4 className="font-semibold text-slate-900 mb-4">📚 Empfohlene Modelle</h4>
|
||||
|
||||
{/* Handwriting Recognition */}
|
||||
<div className="mb-6">
|
||||
<h5 className="font-medium text-slate-700 flex items-center gap-2 mb-3">
|
||||
<span className="text-lg">✍️</span> Handschrifterkennung (Vision-Modelle)
|
||||
</h5>
|
||||
<div className="space-y-2">
|
||||
{RECOMMENDED_MODELS.handwriting.map((rec, idx) => {
|
||||
const info = MODEL_DATABASE[rec.model]
|
||||
const installed = isModelInstalled(rec.model)
|
||||
return (
|
||||
<div key={idx} className={`flex items-center justify-between rounded-lg p-3 ${installed ? 'bg-green-50 border border-green-200' : 'bg-slate-50'}`}>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-slate-700">{info?.name || rec.model}</span>
|
||||
<span className="px-2 py-0.5 text-xs bg-purple-100 text-purple-700 rounded-full">Vision</span>
|
||||
{info?.recommended && <span className="px-2 py-0.5 text-xs bg-yellow-100 text-yellow-700 rounded-full">⭐ Empfohlen</span>}
|
||||
{installed && <span className="px-2 py-0.5 text-xs bg-green-100 text-green-700 rounded-full">✓ Installiert</span>}
|
||||
</div>
|
||||
<p className="text-sm text-slate-500 mt-1">{rec.reason}</p>
|
||||
<p className="text-xs text-slate-400 mt-0.5">Größe: {info?.size || 'unbekannt'}</p>
|
||||
</div>
|
||||
{!installed && (
|
||||
<button
|
||||
onClick={() => { setModelInput(rec.model); pullModel() }}
|
||||
disabled={actionLoading !== null || !status?.ollama}
|
||||
className="ml-4 px-4 py-2 bg-primary-600 text-white text-sm rounded-lg font-medium hover:bg-primary-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
Installieren
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Grading / Text Analysis */}
|
||||
<div>
|
||||
<h5 className="font-medium text-slate-700 flex items-center gap-2 mb-3">
|
||||
<span className="text-lg">📝</span> Klausurkorrektur (Text-Modelle)
|
||||
</h5>
|
||||
<div className="space-y-2">
|
||||
{RECOMMENDED_MODELS.grading.map((rec, idx) => {
|
||||
const info = MODEL_DATABASE[rec.model]
|
||||
const installed = isModelInstalled(rec.model)
|
||||
return (
|
||||
<div key={idx} className={`flex items-center justify-between rounded-lg p-3 ${installed ? 'bg-green-50 border border-green-200' : 'bg-slate-50'}`}>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-slate-700">{info?.name || rec.model}</span>
|
||||
<span className="px-2 py-0.5 text-xs bg-blue-100 text-blue-700 rounded-full">Text</span>
|
||||
{info?.recommended && <span className="px-2 py-0.5 text-xs bg-yellow-100 text-yellow-700 rounded-full">⭐ Empfohlen</span>}
|
||||
{installed && <span className="px-2 py-0.5 text-xs bg-green-100 text-green-700 rounded-full">✓ Installiert</span>}
|
||||
</div>
|
||||
<p className="text-sm text-slate-500 mt-1">{rec.reason}</p>
|
||||
<p className="text-xs text-slate-400 mt-0.5">Größe: {info?.size || 'unbekannt'}</p>
|
||||
</div>
|
||||
{!installed && (
|
||||
<button
|
||||
onClick={() => { setModelInput(rec.model); pullModel() }}
|
||||
disabled={actionLoading !== null || !status?.ollama}
|
||||
className="ml-4 px-4 py-2 bg-primary-600 text-white text-sm rounded-lg font-medium hover:bg-primary-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
Installieren
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info Box */}
|
||||
<div className="mt-6 bg-amber-50 border border-amber-200 rounded-lg p-4">
|
||||
<div className="flex gap-3">
|
||||
<span className="text-xl">💡</span>
|
||||
<div>
|
||||
<h5 className="font-medium text-amber-900">Tipp: Modell-Kombinationen</h5>
|
||||
<p className="text-sm text-amber-800 mt-1">
|
||||
Für beste Ergebnisse bei Klausuren mit Handschrift kombiniere ein <strong>Vision-Modell</strong> (für OCR/Handschrifterkennung)
|
||||
mit einem <strong>Text-Modell</strong> (für Bewertung und Feedback). Beispiel: <code className="bg-amber-100 px-1 rounded">llama3.2-vision:11b</code> + <code className="bg-amber-100 px-1 rounded">qwen2.5:14b</code>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<OllamaSection
|
||||
status={status}
|
||||
actionLoading={actionLoading}
|
||||
downloadProgress={downloadProgress}
|
||||
modelInput={modelInput}
|
||||
setModelInput={setModelInput}
|
||||
onPullModel={pullModel}
|
||||
/>
|
||||
|
||||
{/* Info */}
|
||||
<div className="mt-6 bg-blue-50 border border-blue-200 rounded-xl p-4">
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
export interface MacMiniStatus {
|
||||
online: boolean
|
||||
ping: boolean
|
||||
ssh: boolean
|
||||
docker: boolean
|
||||
ollama: boolean
|
||||
internet: boolean
|
||||
ip: string
|
||||
uptime?: string
|
||||
cpu_load?: string
|
||||
memory?: string
|
||||
containers?: ContainerInfo[]
|
||||
models?: ModelInfo[]
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface ContainerInfo {
|
||||
name: string
|
||||
status: string
|
||||
ports?: string
|
||||
}
|
||||
|
||||
export interface ModelInfo {
|
||||
name: string
|
||||
size: string
|
||||
modified: string
|
||||
}
|
||||
|
||||
export interface DownloadProgress {
|
||||
model: string
|
||||
status: string
|
||||
completed: number
|
||||
total: number
|
||||
percent: number
|
||||
}
|
||||
|
||||
export interface ModelDescription {
|
||||
name: string
|
||||
category: 'vision' | 'text' | 'code' | 'embedding'
|
||||
size: string
|
||||
description: string
|
||||
useCases: string[]
|
||||
recommended?: boolean
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
|
||||
export default function AISettingsTab() {
|
||||
const [settings, setSettings] = useState({
|
||||
autoAnalyze: true,
|
||||
autoCreateTasks: true,
|
||||
analysisModel: 'breakpilot-teacher-8b',
|
||||
confidenceThreshold: 0.7,
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-slate-900">KI-Einstellungen</h2>
|
||||
<p className="text-sm text-slate-500">Konfigurieren Sie die automatische E-Mail-Analyse</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg border border-slate-200 p-6 space-y-6">
|
||||
{/* Auto-Analyze */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-slate-900">Automatische Analyse</h3>
|
||||
<p className="text-sm text-slate-500">E-Mails automatisch beim Empfang analysieren</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setSettings({ ...settings, autoAnalyze: !settings.autoAnalyze })}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
||||
settings.autoAnalyze ? 'bg-primary-600' : 'bg-slate-200'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
||||
settings.autoAnalyze ? 'translate-x-6' : 'translate-x-1'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Auto-Create Tasks */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-slate-900">Aufgaben automatisch erstellen</h3>
|
||||
<p className="text-sm text-slate-500">Erkannte Fristen als Aufgaben anlegen</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setSettings({ ...settings, autoCreateTasks: !settings.autoCreateTasks })}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
||||
settings.autoCreateTasks ? 'bg-primary-600' : 'bg-slate-200'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
||||
settings.autoCreateTasks ? 'translate-x-6' : 'translate-x-1'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Model Selection */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">Analyse-Modell</label>
|
||||
<select
|
||||
value={settings.analysisModel}
|
||||
onChange={(e) => setSettings({ ...settings, analysisModel: e.target.value })}
|
||||
className="w-full md:w-64 px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500"
|
||||
>
|
||||
<option value="breakpilot-teacher-8b">BreakPilot Teacher 8B (schnell)</option>
|
||||
<option value="breakpilot-teacher-70b">BreakPilot Teacher 70B (genau)</option>
|
||||
<option value="llama-3.1-8b-instruct">Llama 3.1 8B Instruct</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Confidence Threshold */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">
|
||||
Konfidenz-Schwelle: {Math.round(settings.confidenceThreshold * 100)}%
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min="0.5"
|
||||
max="0.95"
|
||||
step="0.05"
|
||||
value={settings.confidenceThreshold}
|
||||
onChange={(e) => setSettings({ ...settings, confidenceThreshold: parseFloat(e.target.value) })}
|
||||
className="w-full md:w-64"
|
||||
/>
|
||||
<p className="text-xs text-slate-500 mt-1">
|
||||
Mindest-Konfidenz für automatische Aufgabenerstellung
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sender Classification */}
|
||||
<div className="bg-white rounded-lg border border-slate-200 p-6">
|
||||
<h3 className="text-sm font-medium text-slate-700 mb-4">Bekannte Absender (Niedersachsen)</h3>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
|
||||
{[
|
||||
{ domain: '@mk.niedersachsen.de', type: 'Kultusministerium', priority: 'Hoch' },
|
||||
{ domain: '@rlsb.de', type: 'RLSB', priority: 'Hoch' },
|
||||
{ domain: '@landesschulbehoerde-nds.de', type: 'Landesschulbehörde', priority: 'Hoch' },
|
||||
{ domain: '@nibis.de', type: 'NiBiS', priority: 'Mittel' },
|
||||
{ domain: '@schultraeger.de', type: 'Schulträger', priority: 'Mittel' },
|
||||
].map((sender) => (
|
||||
<div key={sender.domain} className="p-3 bg-slate-50 rounded-lg">
|
||||
<p className="text-sm font-mono text-slate-700">{sender.domain}</p>
|
||||
<p className="text-xs text-slate-500">{sender.type}</p>
|
||||
<span className={`text-xs px-2 py-0.5 rounded ${
|
||||
sender.priority === 'Hoch' ? 'bg-red-100 text-red-700' : 'bg-yellow-100 text-yellow-700'
|
||||
}`}>
|
||||
{sender.priority}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import type { EmailAccount } from '../types'
|
||||
import { API_BASE } from '../constants'
|
||||
import AddAccountModal from './AddAccountModal'
|
||||
|
||||
export default function AccountsTab({
|
||||
accounts,
|
||||
loading,
|
||||
onRefresh
|
||||
}: {
|
||||
accounts: EmailAccount[]
|
||||
loading: boolean
|
||||
onRefresh: () => void
|
||||
}) {
|
||||
const [showAddModal, setShowAddModal] = useState(false)
|
||||
|
||||
const testConnection = async (accountId: string) => {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/v1/mail/accounts/${accountId}/test`, {
|
||||
method: 'POST',
|
||||
})
|
||||
if (res.ok) {
|
||||
alert('Verbindung erfolgreich!')
|
||||
} else {
|
||||
alert('Verbindungsfehler')
|
||||
}
|
||||
} catch (err) {
|
||||
alert('Verbindungsfehler')
|
||||
}
|
||||
}
|
||||
|
||||
const statusColors = {
|
||||
active: 'bg-green-100 text-green-800',
|
||||
inactive: 'bg-gray-100 text-gray-800',
|
||||
error: 'bg-red-100 text-red-800',
|
||||
syncing: 'bg-yellow-100 text-yellow-800',
|
||||
}
|
||||
|
||||
const statusLabels = {
|
||||
active: 'Aktiv',
|
||||
inactive: 'Inaktiv',
|
||||
error: 'Fehler',
|
||||
syncing: 'Synchronisiert...',
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-slate-900">E-Mail-Konten</h2>
|
||||
<p className="text-sm text-slate-500">Verwalten Sie die verbundenen E-Mail-Konten</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowAddModal(true)}
|
||||
className="px-4 py-2 text-sm font-medium text-white bg-primary-600 rounded-lg hover:bg-primary-700 flex items-center gap-2"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
||||
</svg>
|
||||
Konto hinzufügen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Loading State */}
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Accounts Grid */}
|
||||
{!loading && (
|
||||
<div className="grid gap-4">
|
||||
{accounts.length === 0 ? (
|
||||
<div className="bg-slate-50 rounded-lg p-8 text-center">
|
||||
<svg className="w-12 h-12 text-slate-400 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<h3 className="text-lg font-medium text-slate-900 mb-2">Keine E-Mail-Konten</h3>
|
||||
<p className="text-slate-500 mb-4">Fügen Sie Ihr erstes E-Mail-Konto hinzu.</p>
|
||||
</div>
|
||||
) : (
|
||||
accounts.map((account) => (
|
||||
<div
|
||||
key={account.id}
|
||||
className="bg-white rounded-lg border border-slate-200 p-6 hover:shadow-md transition-shadow"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 bg-primary-100 rounded-lg flex items-center justify-center">
|
||||
<svg className="w-6 h-6 text-primary-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-slate-900">
|
||||
{account.displayName || account.email}
|
||||
</h3>
|
||||
<p className="text-sm text-slate-500">{account.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className={`px-3 py-1 rounded-full text-xs font-medium ${statusColors[account.status]}`}>
|
||||
{statusLabels[account.status]}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => testConnection(account.id)}
|
||||
className="p-2 text-slate-400 hover:text-slate-600"
|
||||
title="Verbindung testen"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<p className="text-xs text-slate-500 uppercase tracking-wider">E-Mails</p>
|
||||
<p className="text-lg font-semibold text-slate-900">{account.emailCount}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-slate-500 uppercase tracking-wider">Ungelesen</p>
|
||||
<p className="text-lg font-semibold text-slate-900">{account.unreadCount}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-slate-500 uppercase tracking-wider">IMAP</p>
|
||||
<p className="text-sm font-mono text-slate-700">{account.imapHost}:{account.imapPort}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-slate-500 uppercase tracking-wider">Letzte Sync</p>
|
||||
<p className="text-sm text-slate-700">
|
||||
{account.lastSync
|
||||
? new Date(account.lastSync).toLocaleString('de-DE')
|
||||
: 'Nie'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add Account Modal */}
|
||||
{showAddModal && (
|
||||
<AddAccountModal onClose={() => setShowAddModal(false)} onSuccess={() => { setShowAddModal(false); onRefresh(); }} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { API_BASE } from '../constants'
|
||||
|
||||
export default function AddAccountModal({
|
||||
onClose,
|
||||
onSuccess
|
||||
}: {
|
||||
onClose: () => void
|
||||
onSuccess: () => void
|
||||
}) {
|
||||
const [formData, setFormData] = useState({
|
||||
email: '',
|
||||
displayName: '',
|
||||
imapHost: '',
|
||||
imapPort: 993,
|
||||
smtpHost: '',
|
||||
smtpPort: 587,
|
||||
username: '',
|
||||
password: '',
|
||||
})
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setSubmitting(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/v1/mail/accounts`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
email: formData.email,
|
||||
display_name: formData.displayName,
|
||||
imap_host: formData.imapHost,
|
||||
imap_port: formData.imapPort,
|
||||
smtp_host: formData.smtpHost,
|
||||
smtp_port: formData.smtpPort,
|
||||
username: formData.username,
|
||||
password: formData.password,
|
||||
}),
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
onSuccess()
|
||||
} else {
|
||||
const data = await res.json()
|
||||
setError(data.detail || 'Fehler beim Hinzufügen des Kontos')
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Netzwerkfehler')
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-lg shadow-xl max-w-lg w-full mx-4 max-h-[90vh] overflow-y-auto">
|
||||
<div className="p-6 border-b border-slate-200">
|
||||
<h2 className="text-lg font-semibold text-slate-900">E-Mail-Konto hinzufügen</h2>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="p-6 space-y-4">
|
||||
{error && (
|
||||
<div className="p-3 bg-red-50 text-red-700 rounded-lg text-sm">{error}</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="col-span-2">
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">E-Mail-Adresse</label>
|
||||
<input
|
||||
type="email"
|
||||
required
|
||||
value={formData.email}
|
||||
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500"
|
||||
placeholder="schulleitung@grundschule-xy.de"
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Anzeigename</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.displayName}
|
||||
onChange={(e) => setFormData({ ...formData, displayName: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500"
|
||||
placeholder="Schulleitung"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">IMAP Server</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={formData.imapHost}
|
||||
onChange={(e) => setFormData({ ...formData, imapHost: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500"
|
||||
placeholder="imap.example.com"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">IMAP Port</label>
|
||||
<input
|
||||
type="number"
|
||||
required
|
||||
value={formData.imapPort}
|
||||
onChange={(e) => setFormData({ ...formData, imapPort: parseInt(e.target.value) })}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">SMTP Server</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={formData.smtpHost}
|
||||
onChange={(e) => setFormData({ ...formData, smtpHost: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500"
|
||||
placeholder="smtp.example.com"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">SMTP Port</label>
|
||||
<input
|
||||
type="number"
|
||||
required
|
||||
value={formData.smtpPort}
|
||||
onChange={(e) => setFormData({ ...formData, smtpPort: parseInt(e.target.value) })}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="col-span-2">
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Benutzername</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={formData.username}
|
||||
onChange={(e) => setFormData({ ...formData, username: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="col-span-2">
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Passwort</label>
|
||||
<input
|
||||
type="password"
|
||||
required
|
||||
value={formData.password}
|
||||
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500"
|
||||
/>
|
||||
<p className="text-xs text-slate-500 mt-1">
|
||||
Das Passwort wird verschlüsselt in Vault gespeichert.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 pt-4 border-t border-slate-200">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-100 rounded-lg"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
className="px-4 py-2 text-sm font-medium text-white bg-primary-600 rounded-lg hover:bg-primary-700 disabled:opacity-50"
|
||||
>
|
||||
{submitting ? 'Speichern...' : 'Konto hinzufügen'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
|
||||
export default function AuditLogTab() {
|
||||
const [logs] = useState([
|
||||
{ id: '1', action: 'account_created', user: 'admin@breakpilot.de', timestamp: new Date().toISOString(), details: 'Konto schulleitung@example.de hinzugefügt' },
|
||||
{ id: '2', action: 'email_analyzed', user: 'system', timestamp: new Date(Date.now() - 3600000).toISOString(), details: '5 E-Mails analysiert' },
|
||||
{ id: '3', action: 'task_created', user: 'system', timestamp: new Date(Date.now() - 7200000).toISOString(), details: 'Aufgabe aus Fristenerkennung erstellt' },
|
||||
])
|
||||
|
||||
const actionLabels: Record<string, string> = {
|
||||
account_created: 'Konto erstellt',
|
||||
email_analyzed: 'E-Mail analysiert',
|
||||
task_created: 'Aufgabe erstellt',
|
||||
sync_completed: 'Sync abgeschlossen',
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-slate-900">Audit-Log</h2>
|
||||
<p className="text-sm text-slate-500">Alle Aktionen im Mail-System</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg border border-slate-200 overflow-hidden">
|
||||
<table className="min-w-full divide-y divide-slate-200">
|
||||
<thead className="bg-slate-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase">Zeit</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase">Aktion</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase">Benutzer</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase">Details</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-200">
|
||||
{logs.map((log) => (
|
||||
<tr key={log.id} className="hover:bg-slate-50">
|
||||
<td className="px-6 py-4 text-sm text-slate-500">
|
||||
{new Date(log.timestamp).toLocaleString('de-DE')}
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<span className="px-2 py-1 bg-blue-100 text-blue-700 text-xs rounded font-medium">
|
||||
{actionLabels[log.action] || log.action}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-slate-700">{log.user}</td>
|
||||
<td className="px-6 py-4 text-sm text-slate-500">{log.details}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
'use client'
|
||||
|
||||
import type { MailStats, SyncStatus } from '../types'
|
||||
import { API_BASE } from '../constants'
|
||||
|
||||
function StatCard({
|
||||
title,
|
||||
value,
|
||||
subtitle,
|
||||
color = 'blue'
|
||||
}: {
|
||||
title: string
|
||||
value: number
|
||||
subtitle?: string
|
||||
color?: 'blue' | 'green' | 'yellow' | 'red'
|
||||
}) {
|
||||
const colorClasses = {
|
||||
blue: 'text-blue-600',
|
||||
green: 'text-green-600',
|
||||
yellow: 'text-yellow-600',
|
||||
red: 'text-red-600',
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg border border-slate-200 p-6">
|
||||
<p className="text-xs text-slate-500 uppercase tracking-wider mb-1">{title}</p>
|
||||
<p className={`text-3xl font-bold ${colorClasses[color]}`}>{value.toLocaleString()}</p>
|
||||
{subtitle && <p className="text-sm text-slate-500 mt-1">{subtitle}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function OverviewTab({
|
||||
stats,
|
||||
syncStatus,
|
||||
loading,
|
||||
onRefresh
|
||||
}: {
|
||||
stats: MailStats | null
|
||||
syncStatus: SyncStatus | null
|
||||
loading: boolean
|
||||
onRefresh: () => void
|
||||
}) {
|
||||
const triggerSync = async () => {
|
||||
try {
|
||||
await fetch(`${API_BASE}/api/v1/mail/sync/all`, {
|
||||
method: 'POST',
|
||||
})
|
||||
onRefresh()
|
||||
} catch (err) {
|
||||
console.error('Failed to trigger sync:', err)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-slate-900">System-Übersicht</h2>
|
||||
<p className="text-sm text-slate-500">Status aller E-Mail-Konten und Aufgaben</p>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={onRefresh}
|
||||
className="px-4 py-2 text-sm font-medium text-slate-700 bg-white border border-slate-300 rounded-lg hover:bg-slate-50"
|
||||
>
|
||||
Aktualisieren
|
||||
</button>
|
||||
<button
|
||||
onClick={triggerSync}
|
||||
disabled={syncStatus?.running}
|
||||
className="px-4 py-2 text-sm font-medium text-white bg-primary-600 rounded-lg hover:bg-primary-700 disabled:opacity-50"
|
||||
>
|
||||
{syncStatus?.running ? 'Synchronisiert...' : 'Alle synchronisieren'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Loading State */}
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stats Grid */}
|
||||
{!loading && stats && (
|
||||
<>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<StatCard
|
||||
title="E-Mail-Konten"
|
||||
value={stats.totalAccounts}
|
||||
subtitle={`${stats.activeAccounts} aktiv`}
|
||||
color="blue"
|
||||
/>
|
||||
<StatCard
|
||||
title="E-Mails gesamt"
|
||||
value={stats.totalEmails}
|
||||
subtitle={`${stats.unreadEmails} ungelesen`}
|
||||
color="green"
|
||||
/>
|
||||
<StatCard
|
||||
title="Aufgaben"
|
||||
value={stats.totalTasks}
|
||||
subtitle={`${stats.pendingTasks} offen`}
|
||||
color="yellow"
|
||||
/>
|
||||
<StatCard
|
||||
title="Überfällig"
|
||||
value={stats.overdueTasks}
|
||||
color={stats.overdueTasks > 0 ? 'red' : 'green'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Sync Status */}
|
||||
<div className="bg-white rounded-lg border border-slate-200 p-6">
|
||||
<h3 className="text-sm font-medium text-slate-700 mb-4">Synchronisierung</h3>
|
||||
<div className="flex items-center gap-4">
|
||||
{syncStatus?.running ? (
|
||||
<>
|
||||
<div className="w-3 h-3 bg-yellow-500 rounded-full animate-pulse"></div>
|
||||
<span className="text-slate-600">
|
||||
Synchronisiere {syncStatus.accountsInProgress.length} Konto(en)...
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="w-3 h-3 bg-green-500 rounded-full"></div>
|
||||
<span className="text-slate-600">Bereit</span>
|
||||
</>
|
||||
)}
|
||||
{stats.lastSyncTime && (
|
||||
<span className="text-sm text-slate-500 ml-auto">
|
||||
Letzte Sync: {new Date(stats.lastSyncTime).toLocaleString('de-DE')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{syncStatus?.errors && syncStatus.errors.length > 0 && (
|
||||
<div className="mt-4 p-4 bg-red-50 rounded-lg">
|
||||
<h4 className="text-sm font-medium text-red-800 mb-2">Fehler</h4>
|
||||
<ul className="text-sm text-red-700 space-y-1">
|
||||
{syncStatus.errors.slice(0, 3).map((error, i) => (
|
||||
<li key={i}>{error}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* AI Stats */}
|
||||
<div className="bg-white rounded-lg border border-slate-200 p-6">
|
||||
<h3 className="text-sm font-medium text-slate-700 mb-4">KI-Analyse</h3>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-6">
|
||||
<div>
|
||||
<p className="text-xs text-slate-500 uppercase tracking-wider">Analysiert</p>
|
||||
<p className="text-2xl font-bold text-slate-900">{stats.aiAnalyzedCount}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-slate-500 uppercase tracking-wider">Analyse-Rate</p>
|
||||
<p className="text-2xl font-bold text-slate-900">
|
||||
{stats.totalEmails > 0
|
||||
? `${Math.round((stats.aiAnalyzedCount / stats.totalEmails) * 100)}%`
|
||||
: '0%'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
|
||||
export default function TemplatesTab() {
|
||||
const [templates] = useState([
|
||||
{ id: '1', name: 'Eingangsbestätigung', category: 'Standard', usageCount: 45 },
|
||||
{ id: '2', name: 'Terminbestätigung', category: 'Termine', usageCount: 23 },
|
||||
{ id: '3', name: 'Elternbrief-Vorlage', category: 'Eltern', usageCount: 67 },
|
||||
])
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-slate-900">E-Mail-Vorlagen</h2>
|
||||
<p className="text-sm text-slate-500">Verwalten Sie Antwort-Templates</p>
|
||||
</div>
|
||||
<button className="px-4 py-2 text-sm font-medium text-white bg-primary-600 rounded-lg hover:bg-primary-700 flex items-center gap-2">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
||||
</svg>
|
||||
Vorlage erstellen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg border border-slate-200 overflow-hidden">
|
||||
<table className="min-w-full divide-y divide-slate-200">
|
||||
<thead className="bg-slate-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase">Name</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase">Kategorie</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase">Verwendet</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-slate-500 uppercase">Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-200">
|
||||
{templates.map((template) => (
|
||||
<tr key={template.id} className="hover:bg-slate-50">
|
||||
<td className="px-6 py-4 text-sm font-medium text-slate-900">{template.name}</td>
|
||||
<td className="px-6 py-4">
|
||||
<span className="px-2 py-1 bg-slate-100 text-slate-700 text-xs rounded">{template.category}</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-slate-500">{template.usageCount}x</td>
|
||||
<td className="px-6 py-4 text-right">
|
||||
<button className="text-primary-600 hover:text-primary-800 text-sm font-medium">Bearbeiten</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { TabId } from './types'
|
||||
|
||||
// API Base URL for klausur-service
|
||||
export const API_BASE = process.env.NEXT_PUBLIC_KLAUSUR_SERVICE_URL || 'http://localhost:8086'
|
||||
|
||||
// Tab definitions
|
||||
export const tabs: { id: TabId; name: string; icon: JSX.Element }[] = [
|
||||
{
|
||||
id: 'overview',
|
||||
name: 'Übersicht',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'accounts',
|
||||
name: 'Konten',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'ai-settings',
|
||||
name: 'KI-Einstellungen',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'templates',
|
||||
name: 'Vorlagen',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'logs',
|
||||
name: 'Audit-Log',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
]
|
||||
@@ -11,97 +11,14 @@
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import AdminLayout from '@/components/admin/AdminLayout'
|
||||
import type { MailStats, SyncStatus, EmailAccount, TabId } from './types'
|
||||
import { API_BASE, tabs } from './constants'
|
||||
import OverviewTab from './_components/OverviewTab'
|
||||
import AccountsTab from './_components/AccountsTab'
|
||||
import AISettingsTab from './_components/AISettingsTab'
|
||||
import TemplatesTab from './_components/TemplatesTab'
|
||||
import AuditLogTab from './_components/AuditLogTab'
|
||||
|
||||
// API Base URL for klausur-service
|
||||
const API_BASE = process.env.NEXT_PUBLIC_KLAUSUR_SERVICE_URL || 'http://localhost:8086'
|
||||
|
||||
// Types
|
||||
interface EmailAccount {
|
||||
id: string
|
||||
email: string
|
||||
displayName: string
|
||||
imapHost: string
|
||||
imapPort: number
|
||||
smtpHost: string
|
||||
smtpPort: number
|
||||
status: 'active' | 'inactive' | 'error' | 'syncing'
|
||||
lastSync: string | null
|
||||
emailCount: number
|
||||
unreadCount: number
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
interface MailStats {
|
||||
totalAccounts: number
|
||||
activeAccounts: number
|
||||
totalEmails: number
|
||||
unreadEmails: number
|
||||
totalTasks: number
|
||||
pendingTasks: number
|
||||
overdueTasks: number
|
||||
aiAnalyzedCount: number
|
||||
lastSyncTime: string | null
|
||||
}
|
||||
|
||||
interface SyncStatus {
|
||||
running: boolean
|
||||
accountsInProgress: string[]
|
||||
lastCompleted: string | null
|
||||
errors: string[]
|
||||
}
|
||||
|
||||
// Tab definitions
|
||||
type TabId = 'overview' | 'accounts' | 'ai-settings' | 'templates' | 'logs'
|
||||
|
||||
const tabs: { id: TabId; name: string; icon: JSX.Element }[] = [
|
||||
{
|
||||
id: 'overview',
|
||||
name: 'Übersicht',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'accounts',
|
||||
name: 'Konten',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'ai-settings',
|
||||
name: 'KI-Einstellungen',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'templates',
|
||||
name: 'Vorlagen',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'logs',
|
||||
name: 'Audit-Log',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
// Main Component
|
||||
export default function MailAdminPage() {
|
||||
const [activeTab, setActiveTab] = useState<TabId>('overview')
|
||||
const [stats, setStats] = useState<MailStats | null>(null)
|
||||
@@ -241,745 +158,3 @@ export default function MailAdminPage() {
|
||||
</AdminLayout>
|
||||
)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Overview Tab
|
||||
// ============================================================================
|
||||
|
||||
function OverviewTab({
|
||||
stats,
|
||||
syncStatus,
|
||||
loading,
|
||||
onRefresh
|
||||
}: {
|
||||
stats: MailStats | null
|
||||
syncStatus: SyncStatus | null
|
||||
loading: boolean
|
||||
onRefresh: () => void
|
||||
}) {
|
||||
const triggerSync = async () => {
|
||||
try {
|
||||
await fetch(`${API_BASE}/api/v1/mail/sync/all`, {
|
||||
method: 'POST',
|
||||
})
|
||||
onRefresh()
|
||||
} catch (err) {
|
||||
console.error('Failed to trigger sync:', err)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-slate-900">System-Übersicht</h2>
|
||||
<p className="text-sm text-slate-500">Status aller E-Mail-Konten und Aufgaben</p>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={onRefresh}
|
||||
className="px-4 py-2 text-sm font-medium text-slate-700 bg-white border border-slate-300 rounded-lg hover:bg-slate-50"
|
||||
>
|
||||
Aktualisieren
|
||||
</button>
|
||||
<button
|
||||
onClick={triggerSync}
|
||||
disabled={syncStatus?.running}
|
||||
className="px-4 py-2 text-sm font-medium text-white bg-primary-600 rounded-lg hover:bg-primary-700 disabled:opacity-50"
|
||||
>
|
||||
{syncStatus?.running ? 'Synchronisiert...' : 'Alle synchronisieren'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Loading State */}
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stats Grid */}
|
||||
{!loading && stats && (
|
||||
<>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<StatCard
|
||||
title="E-Mail-Konten"
|
||||
value={stats.totalAccounts}
|
||||
subtitle={`${stats.activeAccounts} aktiv`}
|
||||
color="blue"
|
||||
/>
|
||||
<StatCard
|
||||
title="E-Mails gesamt"
|
||||
value={stats.totalEmails}
|
||||
subtitle={`${stats.unreadEmails} ungelesen`}
|
||||
color="green"
|
||||
/>
|
||||
<StatCard
|
||||
title="Aufgaben"
|
||||
value={stats.totalTasks}
|
||||
subtitle={`${stats.pendingTasks} offen`}
|
||||
color="yellow"
|
||||
/>
|
||||
<StatCard
|
||||
title="Überfällig"
|
||||
value={stats.overdueTasks}
|
||||
color={stats.overdueTasks > 0 ? 'red' : 'green'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Sync Status */}
|
||||
<div className="bg-white rounded-lg border border-slate-200 p-6">
|
||||
<h3 className="text-sm font-medium text-slate-700 mb-4">Synchronisierung</h3>
|
||||
<div className="flex items-center gap-4">
|
||||
{syncStatus?.running ? (
|
||||
<>
|
||||
<div className="w-3 h-3 bg-yellow-500 rounded-full animate-pulse"></div>
|
||||
<span className="text-slate-600">
|
||||
Synchronisiere {syncStatus.accountsInProgress.length} Konto(en)...
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="w-3 h-3 bg-green-500 rounded-full"></div>
|
||||
<span className="text-slate-600">Bereit</span>
|
||||
</>
|
||||
)}
|
||||
{stats.lastSyncTime && (
|
||||
<span className="text-sm text-slate-500 ml-auto">
|
||||
Letzte Sync: {new Date(stats.lastSyncTime).toLocaleString('de-DE')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{syncStatus?.errors && syncStatus.errors.length > 0 && (
|
||||
<div className="mt-4 p-4 bg-red-50 rounded-lg">
|
||||
<h4 className="text-sm font-medium text-red-800 mb-2">Fehler</h4>
|
||||
<ul className="text-sm text-red-700 space-y-1">
|
||||
{syncStatus.errors.slice(0, 3).map((error, i) => (
|
||||
<li key={i}>{error}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* AI Stats */}
|
||||
<div className="bg-white rounded-lg border border-slate-200 p-6">
|
||||
<h3 className="text-sm font-medium text-slate-700 mb-4">KI-Analyse</h3>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-6">
|
||||
<div>
|
||||
<p className="text-xs text-slate-500 uppercase tracking-wider">Analysiert</p>
|
||||
<p className="text-2xl font-bold text-slate-900">{stats.aiAnalyzedCount}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-slate-500 uppercase tracking-wider">Analyse-Rate</p>
|
||||
<p className="text-2xl font-bold text-slate-900">
|
||||
{stats.totalEmails > 0
|
||||
? `${Math.round((stats.aiAnalyzedCount / stats.totalEmails) * 100)}%`
|
||||
: '0%'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StatCard({
|
||||
title,
|
||||
value,
|
||||
subtitle,
|
||||
color = 'blue'
|
||||
}: {
|
||||
title: string
|
||||
value: number
|
||||
subtitle?: string
|
||||
color?: 'blue' | 'green' | 'yellow' | 'red'
|
||||
}) {
|
||||
const colorClasses = {
|
||||
blue: 'text-blue-600',
|
||||
green: 'text-green-600',
|
||||
yellow: 'text-yellow-600',
|
||||
red: 'text-red-600',
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg border border-slate-200 p-6">
|
||||
<p className="text-xs text-slate-500 uppercase tracking-wider mb-1">{title}</p>
|
||||
<p className={`text-3xl font-bold ${colorClasses[color]}`}>{value.toLocaleString()}</p>
|
||||
{subtitle && <p className="text-sm text-slate-500 mt-1">{subtitle}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Accounts Tab
|
||||
// ============================================================================
|
||||
|
||||
function AccountsTab({
|
||||
accounts,
|
||||
loading,
|
||||
onRefresh
|
||||
}: {
|
||||
accounts: EmailAccount[]
|
||||
loading: boolean
|
||||
onRefresh: () => void
|
||||
}) {
|
||||
const [showAddModal, setShowAddModal] = useState(false)
|
||||
|
||||
const testConnection = async (accountId: string) => {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/v1/mail/accounts/${accountId}/test`, {
|
||||
method: 'POST',
|
||||
})
|
||||
if (res.ok) {
|
||||
alert('Verbindung erfolgreich!')
|
||||
} else {
|
||||
alert('Verbindungsfehler')
|
||||
}
|
||||
} catch (err) {
|
||||
alert('Verbindungsfehler')
|
||||
}
|
||||
}
|
||||
|
||||
const statusColors = {
|
||||
active: 'bg-green-100 text-green-800',
|
||||
inactive: 'bg-gray-100 text-gray-800',
|
||||
error: 'bg-red-100 text-red-800',
|
||||
syncing: 'bg-yellow-100 text-yellow-800',
|
||||
}
|
||||
|
||||
const statusLabels = {
|
||||
active: 'Aktiv',
|
||||
inactive: 'Inaktiv',
|
||||
error: 'Fehler',
|
||||
syncing: 'Synchronisiert...',
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-slate-900">E-Mail-Konten</h2>
|
||||
<p className="text-sm text-slate-500">Verwalten Sie die verbundenen E-Mail-Konten</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowAddModal(true)}
|
||||
className="px-4 py-2 text-sm font-medium text-white bg-primary-600 rounded-lg hover:bg-primary-700 flex items-center gap-2"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
||||
</svg>
|
||||
Konto hinzufügen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Loading State */}
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Accounts Grid */}
|
||||
{!loading && (
|
||||
<div className="grid gap-4">
|
||||
{accounts.length === 0 ? (
|
||||
<div className="bg-slate-50 rounded-lg p-8 text-center">
|
||||
<svg className="w-12 h-12 text-slate-400 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<h3 className="text-lg font-medium text-slate-900 mb-2">Keine E-Mail-Konten</h3>
|
||||
<p className="text-slate-500 mb-4">Fügen Sie Ihr erstes E-Mail-Konto hinzu.</p>
|
||||
</div>
|
||||
) : (
|
||||
accounts.map((account) => (
|
||||
<div
|
||||
key={account.id}
|
||||
className="bg-white rounded-lg border border-slate-200 p-6 hover:shadow-md transition-shadow"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 bg-primary-100 rounded-lg flex items-center justify-center">
|
||||
<svg className="w-6 h-6 text-primary-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-slate-900">
|
||||
{account.displayName || account.email}
|
||||
</h3>
|
||||
<p className="text-sm text-slate-500">{account.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className={`px-3 py-1 rounded-full text-xs font-medium ${statusColors[account.status]}`}>
|
||||
{statusLabels[account.status]}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => testConnection(account.id)}
|
||||
className="p-2 text-slate-400 hover:text-slate-600"
|
||||
title="Verbindung testen"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<p className="text-xs text-slate-500 uppercase tracking-wider">E-Mails</p>
|
||||
<p className="text-lg font-semibold text-slate-900">{account.emailCount}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-slate-500 uppercase tracking-wider">Ungelesen</p>
|
||||
<p className="text-lg font-semibold text-slate-900">{account.unreadCount}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-slate-500 uppercase tracking-wider">IMAP</p>
|
||||
<p className="text-sm font-mono text-slate-700">{account.imapHost}:{account.imapPort}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-slate-500 uppercase tracking-wider">Letzte Sync</p>
|
||||
<p className="text-sm text-slate-700">
|
||||
{account.lastSync
|
||||
? new Date(account.lastSync).toLocaleString('de-DE')
|
||||
: 'Nie'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add Account Modal */}
|
||||
{showAddModal && (
|
||||
<AddAccountModal onClose={() => setShowAddModal(false)} onSuccess={() => { setShowAddModal(false); onRefresh(); }} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AddAccountModal({
|
||||
onClose,
|
||||
onSuccess
|
||||
}: {
|
||||
onClose: () => void
|
||||
onSuccess: () => void
|
||||
}) {
|
||||
const [formData, setFormData] = useState({
|
||||
email: '',
|
||||
displayName: '',
|
||||
imapHost: '',
|
||||
imapPort: 993,
|
||||
smtpHost: '',
|
||||
smtpPort: 587,
|
||||
username: '',
|
||||
password: '',
|
||||
})
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setSubmitting(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/v1/mail/accounts`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
email: formData.email,
|
||||
display_name: formData.displayName,
|
||||
imap_host: formData.imapHost,
|
||||
imap_port: formData.imapPort,
|
||||
smtp_host: formData.smtpHost,
|
||||
smtp_port: formData.smtpPort,
|
||||
username: formData.username,
|
||||
password: formData.password,
|
||||
}),
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
onSuccess()
|
||||
} else {
|
||||
const data = await res.json()
|
||||
setError(data.detail || 'Fehler beim Hinzufügen des Kontos')
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Netzwerkfehler')
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-lg shadow-xl max-w-lg w-full mx-4 max-h-[90vh] overflow-y-auto">
|
||||
<div className="p-6 border-b border-slate-200">
|
||||
<h2 className="text-lg font-semibold text-slate-900">E-Mail-Konto hinzufügen</h2>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="p-6 space-y-4">
|
||||
{error && (
|
||||
<div className="p-3 bg-red-50 text-red-700 rounded-lg text-sm">{error}</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="col-span-2">
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">E-Mail-Adresse</label>
|
||||
<input
|
||||
type="email"
|
||||
required
|
||||
value={formData.email}
|
||||
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500"
|
||||
placeholder="schulleitung@grundschule-xy.de"
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Anzeigename</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.displayName}
|
||||
onChange={(e) => setFormData({ ...formData, displayName: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500"
|
||||
placeholder="Schulleitung"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">IMAP Server</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={formData.imapHost}
|
||||
onChange={(e) => setFormData({ ...formData, imapHost: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500"
|
||||
placeholder="imap.example.com"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">IMAP Port</label>
|
||||
<input
|
||||
type="number"
|
||||
required
|
||||
value={formData.imapPort}
|
||||
onChange={(e) => setFormData({ ...formData, imapPort: parseInt(e.target.value) })}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">SMTP Server</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={formData.smtpHost}
|
||||
onChange={(e) => setFormData({ ...formData, smtpHost: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500"
|
||||
placeholder="smtp.example.com"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">SMTP Port</label>
|
||||
<input
|
||||
type="number"
|
||||
required
|
||||
value={formData.smtpPort}
|
||||
onChange={(e) => setFormData({ ...formData, smtpPort: parseInt(e.target.value) })}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="col-span-2">
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Benutzername</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={formData.username}
|
||||
onChange={(e) => setFormData({ ...formData, username: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="col-span-2">
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Passwort</label>
|
||||
<input
|
||||
type="password"
|
||||
required
|
||||
value={formData.password}
|
||||
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500"
|
||||
/>
|
||||
<p className="text-xs text-slate-500 mt-1">
|
||||
Das Passwort wird verschlüsselt in Vault gespeichert.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 pt-4 border-t border-slate-200">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-100 rounded-lg"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
className="px-4 py-2 text-sm font-medium text-white bg-primary-600 rounded-lg hover:bg-primary-700 disabled:opacity-50"
|
||||
>
|
||||
{submitting ? 'Speichern...' : 'Konto hinzufügen'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// AI Settings Tab
|
||||
// ============================================================================
|
||||
|
||||
function AISettingsTab() {
|
||||
const [settings, setSettings] = useState({
|
||||
autoAnalyze: true,
|
||||
autoCreateTasks: true,
|
||||
analysisModel: 'breakpilot-teacher-8b',
|
||||
confidenceThreshold: 0.7,
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-slate-900">KI-Einstellungen</h2>
|
||||
<p className="text-sm text-slate-500">Konfigurieren Sie die automatische E-Mail-Analyse</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg border border-slate-200 p-6 space-y-6">
|
||||
{/* Auto-Analyze */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-slate-900">Automatische Analyse</h3>
|
||||
<p className="text-sm text-slate-500">E-Mails automatisch beim Empfang analysieren</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setSettings({ ...settings, autoAnalyze: !settings.autoAnalyze })}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
||||
settings.autoAnalyze ? 'bg-primary-600' : 'bg-slate-200'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
||||
settings.autoAnalyze ? 'translate-x-6' : 'translate-x-1'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Auto-Create Tasks */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-slate-900">Aufgaben automatisch erstellen</h3>
|
||||
<p className="text-sm text-slate-500">Erkannte Fristen als Aufgaben anlegen</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setSettings({ ...settings, autoCreateTasks: !settings.autoCreateTasks })}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
||||
settings.autoCreateTasks ? 'bg-primary-600' : 'bg-slate-200'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
||||
settings.autoCreateTasks ? 'translate-x-6' : 'translate-x-1'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Model Selection */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">Analyse-Modell</label>
|
||||
<select
|
||||
value={settings.analysisModel}
|
||||
onChange={(e) => setSettings({ ...settings, analysisModel: e.target.value })}
|
||||
className="w-full md:w-64 px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500"
|
||||
>
|
||||
<option value="breakpilot-teacher-8b">BreakPilot Teacher 8B (schnell)</option>
|
||||
<option value="breakpilot-teacher-70b">BreakPilot Teacher 70B (genau)</option>
|
||||
<option value="llama-3.1-8b-instruct">Llama 3.1 8B Instruct</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Confidence Threshold */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">
|
||||
Konfidenz-Schwelle: {Math.round(settings.confidenceThreshold * 100)}%
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min="0.5"
|
||||
max="0.95"
|
||||
step="0.05"
|
||||
value={settings.confidenceThreshold}
|
||||
onChange={(e) => setSettings({ ...settings, confidenceThreshold: parseFloat(e.target.value) })}
|
||||
className="w-full md:w-64"
|
||||
/>
|
||||
<p className="text-xs text-slate-500 mt-1">
|
||||
Mindest-Konfidenz für automatische Aufgabenerstellung
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sender Classification */}
|
||||
<div className="bg-white rounded-lg border border-slate-200 p-6">
|
||||
<h3 className="text-sm font-medium text-slate-700 mb-4">Bekannte Absender (Niedersachsen)</h3>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
|
||||
{[
|
||||
{ domain: '@mk.niedersachsen.de', type: 'Kultusministerium', priority: 'Hoch' },
|
||||
{ domain: '@rlsb.de', type: 'RLSB', priority: 'Hoch' },
|
||||
{ domain: '@landesschulbehoerde-nds.de', type: 'Landesschulbehörde', priority: 'Hoch' },
|
||||
{ domain: '@nibis.de', type: 'NiBiS', priority: 'Mittel' },
|
||||
{ domain: '@schultraeger.de', type: 'Schulträger', priority: 'Mittel' },
|
||||
].map((sender) => (
|
||||
<div key={sender.domain} className="p-3 bg-slate-50 rounded-lg">
|
||||
<p className="text-sm font-mono text-slate-700">{sender.domain}</p>
|
||||
<p className="text-xs text-slate-500">{sender.type}</p>
|
||||
<span className={`text-xs px-2 py-0.5 rounded ${
|
||||
sender.priority === 'Hoch' ? 'bg-red-100 text-red-700' : 'bg-yellow-100 text-yellow-700'
|
||||
}`}>
|
||||
{sender.priority}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Templates Tab
|
||||
// ============================================================================
|
||||
|
||||
function TemplatesTab() {
|
||||
const [templates] = useState([
|
||||
{ id: '1', name: 'Eingangsbestätigung', category: 'Standard', usageCount: 45 },
|
||||
{ id: '2', name: 'Terminbestätigung', category: 'Termine', usageCount: 23 },
|
||||
{ id: '3', name: 'Elternbrief-Vorlage', category: 'Eltern', usageCount: 67 },
|
||||
])
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-slate-900">E-Mail-Vorlagen</h2>
|
||||
<p className="text-sm text-slate-500">Verwalten Sie Antwort-Templates</p>
|
||||
</div>
|
||||
<button className="px-4 py-2 text-sm font-medium text-white bg-primary-600 rounded-lg hover:bg-primary-700 flex items-center gap-2">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
||||
</svg>
|
||||
Vorlage erstellen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg border border-slate-200 overflow-hidden">
|
||||
<table className="min-w-full divide-y divide-slate-200">
|
||||
<thead className="bg-slate-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase">Name</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase">Kategorie</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase">Verwendet</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-slate-500 uppercase">Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-200">
|
||||
{templates.map((template) => (
|
||||
<tr key={template.id} className="hover:bg-slate-50">
|
||||
<td className="px-6 py-4 text-sm font-medium text-slate-900">{template.name}</td>
|
||||
<td className="px-6 py-4">
|
||||
<span className="px-2 py-1 bg-slate-100 text-slate-700 text-xs rounded">{template.category}</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-slate-500">{template.usageCount}x</td>
|
||||
<td className="px-6 py-4 text-right">
|
||||
<button className="text-primary-600 hover:text-primary-800 text-sm font-medium">Bearbeiten</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Audit Log Tab
|
||||
// ============================================================================
|
||||
|
||||
function AuditLogTab() {
|
||||
const [logs] = useState([
|
||||
{ id: '1', action: 'account_created', user: 'admin@breakpilot.de', timestamp: new Date().toISOString(), details: 'Konto schulleitung@example.de hinzugefügt' },
|
||||
{ id: '2', action: 'email_analyzed', user: 'system', timestamp: new Date(Date.now() - 3600000).toISOString(), details: '5 E-Mails analysiert' },
|
||||
{ id: '3', action: 'task_created', user: 'system', timestamp: new Date(Date.now() - 7200000).toISOString(), details: 'Aufgabe aus Fristenerkennung erstellt' },
|
||||
])
|
||||
|
||||
const actionLabels: Record<string, string> = {
|
||||
account_created: 'Konto erstellt',
|
||||
email_analyzed: 'E-Mail analysiert',
|
||||
task_created: 'Aufgabe erstellt',
|
||||
sync_completed: 'Sync abgeschlossen',
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-slate-900">Audit-Log</h2>
|
||||
<p className="text-sm text-slate-500">Alle Aktionen im Mail-System</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg border border-slate-200 overflow-hidden">
|
||||
<table className="min-w-full divide-y divide-slate-200">
|
||||
<thead className="bg-slate-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase">Zeit</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase">Aktion</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase">Benutzer</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase">Details</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-200">
|
||||
{logs.map((log) => (
|
||||
<tr key={log.id} className="hover:bg-slate-50">
|
||||
<td className="px-6 py-4 text-sm text-slate-500">
|
||||
{new Date(log.timestamp).toLocaleString('de-DE')}
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<span className="px-2 py-1 bg-blue-100 text-blue-700 text-xs rounded font-medium">
|
||||
{actionLabels[log.action] || log.action}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-slate-700">{log.user}</td>
|
||||
<td className="px-6 py-4 text-sm text-slate-500">{log.details}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
export interface EmailAccount {
|
||||
id: string
|
||||
email: string
|
||||
displayName: string
|
||||
imapHost: string
|
||||
imapPort: number
|
||||
smtpHost: string
|
||||
smtpPort: number
|
||||
status: 'active' | 'inactive' | 'error' | 'syncing'
|
||||
lastSync: string | null
|
||||
emailCount: number
|
||||
unreadCount: number
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface MailStats {
|
||||
totalAccounts: number
|
||||
activeAccounts: number
|
||||
totalEmails: number
|
||||
unreadEmails: number
|
||||
totalTasks: number
|
||||
pendingTasks: number
|
||||
overdueTasks: number
|
||||
aiAnalyzedCount: number
|
||||
lastSyncTime: string | null
|
||||
}
|
||||
|
||||
export interface SyncStatus {
|
||||
running: boolean
|
||||
accountsInProgress: string[]
|
||||
lastCompleted: string | null
|
||||
errors: string[]
|
||||
}
|
||||
|
||||
export type TabId = 'overview' | 'accounts' | 'ai-settings' | 'templates' | 'logs'
|
||||
@@ -0,0 +1,113 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import type { OCRSession, OCRStats } from '../types'
|
||||
|
||||
const API_BASE = process.env.NEXT_PUBLIC_KLAUSUR_SERVICE_URL || 'http://localhost:8086'
|
||||
|
||||
export default function ExportTab({
|
||||
sessions,
|
||||
selectedSession,
|
||||
setSelectedSession,
|
||||
stats,
|
||||
onError,
|
||||
}: {
|
||||
sessions: OCRSession[]
|
||||
selectedSession: string | null
|
||||
setSelectedSession: (id: string | null) => void
|
||||
stats: OCRStats | null
|
||||
onError: (msg: string) => void
|
||||
}) {
|
||||
const [exportFormat, setExportFormat] = useState<'generic' | 'trocr' | 'llama_vision'>('generic')
|
||||
const [exporting, setExporting] = useState(false)
|
||||
const [exportResult, setExportResult] = useState<any>(null)
|
||||
|
||||
const handleExport = async () => {
|
||||
setExporting(true)
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/v1/ocr-label/export`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
export_format: exportFormat,
|
||||
session_id: selectedSession,
|
||||
}),
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setExportResult(data)
|
||||
} else {
|
||||
onError('Export fehlgeschlagen')
|
||||
}
|
||||
} catch (err) {
|
||||
onError('Netzwerkfehler')
|
||||
} finally {
|
||||
setExporting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">Training-Daten exportieren</h3>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Export-Format</label>
|
||||
<select
|
||||
value={exportFormat}
|
||||
onChange={(e) => setExportFormat(e.target.value as typeof exportFormat)}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500"
|
||||
>
|
||||
<option value="generic">Generic JSON</option>
|
||||
<option value="trocr">TrOCR Fine-Tuning</option>
|
||||
<option value="llama_vision">Llama Vision Fine-Tuning</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Session (optional)</label>
|
||||
<select
|
||||
value={selectedSession || ''}
|
||||
onChange={(e) => setSelectedSession(e.target.value || null)}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500"
|
||||
>
|
||||
<option value="">Alle Sessions</option>
|
||||
{sessions.map((session) => (
|
||||
<option key={session.id} value={session.id}>{session.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleExport}
|
||||
disabled={exporting || (stats?.exportable_items || 0) === 0}
|
||||
className="w-full px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 disabled:opacity-50"
|
||||
>
|
||||
{exporting ? 'Exportiere...' : `${stats?.exportable_items || 0} Samples exportieren`}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{exportResult && (
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">Export-Ergebnis</h3>
|
||||
<div className="bg-green-50 border border-green-200 rounded-lg p-4 mb-4">
|
||||
<p className="text-green-800">
|
||||
{exportResult.exported_count} Samples erfolgreich exportiert
|
||||
</p>
|
||||
<p className="text-sm text-green-600">
|
||||
Batch: {exportResult.batch_id}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-slate-50 p-4 rounded-lg overflow-auto max-h-64">
|
||||
<pre className="text-xs">{JSON.stringify(exportResult.samples?.slice(0, 3), null, 2)}</pre>
|
||||
{(exportResult.samples?.length || 0) > 3 && (
|
||||
<p className="text-slate-500 mt-2">... und {exportResult.samples.length - 3} weitere</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
'use client'
|
||||
|
||||
import type { OCRItem } from '../types'
|
||||
|
||||
const API_BASE = process.env.NEXT_PUBLIC_KLAUSUR_SERVICE_URL || 'http://localhost:8086'
|
||||
|
||||
export default function LabelingTab({
|
||||
queue,
|
||||
currentItem,
|
||||
currentIndex,
|
||||
correctedText,
|
||||
setCorrectedText,
|
||||
onGoToPrev,
|
||||
onGoToNext,
|
||||
onConfirm,
|
||||
onCorrect,
|
||||
onSkip,
|
||||
onSelectItem,
|
||||
}: {
|
||||
queue: OCRItem[]
|
||||
currentItem: OCRItem | null
|
||||
currentIndex: number
|
||||
correctedText: string
|
||||
setCorrectedText: (text: string) => void
|
||||
onGoToPrev: () => void
|
||||
onGoToNext: () => void
|
||||
onConfirm: () => void
|
||||
onCorrect: () => void
|
||||
onSkip: () => void
|
||||
onSelectItem: (item: OCRItem, index: number) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Left: Image Viewer */}
|
||||
<div className="lg:col-span-2 bg-white rounded-lg shadow p-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold">Bild</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={onGoToPrev}
|
||||
disabled={currentIndex === 0}
|
||||
className="p-2 rounded hover:bg-slate-100 disabled:opacity-50"
|
||||
title="Zurueck (Pfeiltaste links)"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
<span className="text-sm text-slate-600">
|
||||
{currentIndex + 1} / {queue.length}
|
||||
</span>
|
||||
<button
|
||||
onClick={onGoToNext}
|
||||
disabled={currentIndex >= queue.length - 1}
|
||||
className="p-2 rounded hover:bg-slate-100 disabled:opacity-50"
|
||||
title="Weiter (Pfeiltaste rechts)"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{currentItem ? (
|
||||
<div className="relative bg-slate-100 rounded-lg overflow-hidden" style={{ minHeight: '400px' }}>
|
||||
<img
|
||||
src={currentItem.image_url || `${API_BASE}${currentItem.image_path}`}
|
||||
alt="OCR Bild"
|
||||
className="w-full h-auto max-h-[600px] object-contain"
|
||||
onError={(e) => {
|
||||
const target = e.target as HTMLImageElement
|
||||
target.style.display = 'none'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-64 bg-slate-100 rounded-lg">
|
||||
<p className="text-slate-500">Keine Bilder in der Warteschlange</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right: OCR Text & Actions */}
|
||||
<div className="bg-white rounded-lg shadow p-4">
|
||||
<div className="space-y-4">
|
||||
{/* OCR Result */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h3 className="text-lg font-semibold">OCR-Ergebnis</h3>
|
||||
{currentItem?.ocr_confidence && (
|
||||
<span className={`text-sm px-2 py-1 rounded ${
|
||||
currentItem.ocr_confidence > 0.8
|
||||
? 'bg-green-100 text-green-800'
|
||||
: currentItem.ocr_confidence > 0.5
|
||||
? 'bg-yellow-100 text-yellow-800'
|
||||
: 'bg-red-100 text-red-800'
|
||||
}`}>
|
||||
{Math.round(currentItem.ocr_confidence * 100)}% Konfidenz
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="bg-slate-50 p-3 rounded-lg min-h-[100px] text-sm">
|
||||
{currentItem?.ocr_text || <span className="text-slate-400">Kein OCR-Text</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Correction Input */}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-2">Korrektur</h3>
|
||||
<textarea
|
||||
value={correctedText}
|
||||
onChange={(e) => setCorrectedText(e.target.value)}
|
||||
placeholder="Korrigierter Text..."
|
||||
className="w-full h-32 p-3 border border-slate-200 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<button
|
||||
onClick={onConfirm}
|
||||
disabled={!currentItem}
|
||||
className="w-full px-4 py-3 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50 flex items-center justify-center gap-2"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
Korrekt (Enter)
|
||||
</button>
|
||||
<button
|
||||
onClick={onCorrect}
|
||||
disabled={!currentItem || !correctedText.trim() || correctedText === currentItem?.ocr_text}
|
||||
className="w-full px-4 py-3 bg-primary-600 text-white rounded-lg hover:bg-primary-700 disabled:opacity-50 flex items-center justify-center gap-2"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
|
||||
</svg>
|
||||
Korrektur speichern
|
||||
</button>
|
||||
<button
|
||||
onClick={onSkip}
|
||||
disabled={!currentItem}
|
||||
className="w-full px-4 py-2 bg-slate-200 text-slate-700 rounded-lg hover:bg-slate-300 disabled:opacity-50 flex items-center justify-center gap-2"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 5l7 7-7 7M5 5l7 7-7 7" />
|
||||
</svg>
|
||||
Ueberspringen (S)
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Keyboard Shortcuts */}
|
||||
<div className="text-xs text-slate-500 mt-4">
|
||||
<p className="font-medium mb-1">Tastaturkuerzel:</p>
|
||||
<p>Enter = Bestaetigen | S = Ueberspringen</p>
|
||||
<p>Pfeiltasten = Navigation</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom: Queue Preview */}
|
||||
<div className="lg:col-span-3 bg-white rounded-lg shadow p-4">
|
||||
<h3 className="text-lg font-semibold mb-4">Warteschlange ({queue.length} Items)</h3>
|
||||
<div className="flex gap-2 overflow-x-auto pb-2">
|
||||
{queue.slice(0, 10).map((item, idx) => (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => onSelectItem(item, idx)}
|
||||
className={`flex-shrink-0 w-24 h-24 rounded-lg overflow-hidden border-2 ${
|
||||
idx === currentIndex
|
||||
? 'border-primary-500'
|
||||
: 'border-transparent hover:border-slate-300'
|
||||
}`}
|
||||
>
|
||||
<img
|
||||
src={item.image_url || `${API_BASE}${item.image_path}`}
|
||||
alt=""
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
{queue.length > 10 && (
|
||||
<div className="flex-shrink-0 w-24 h-24 rounded-lg bg-slate-100 flex items-center justify-center text-slate-500">
|
||||
+{queue.length - 10} mehr
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import type { OCRSession, CreateSessionRequest, OCRModel } from '../types'
|
||||
|
||||
const API_BASE = process.env.NEXT_PUBLIC_KLAUSUR_SERVICE_URL || 'http://localhost:8086'
|
||||
|
||||
export default function SessionsTab({
|
||||
sessions,
|
||||
selectedSession,
|
||||
setSelectedSession,
|
||||
onSessionCreated,
|
||||
onError,
|
||||
}: {
|
||||
sessions: OCRSession[]
|
||||
selectedSession: string | null
|
||||
setSelectedSession: (id: string | null) => void
|
||||
onSessionCreated: () => void
|
||||
onError: (msg: string) => void
|
||||
}) {
|
||||
const [newSession, setNewSession] = useState<CreateSessionRequest>({
|
||||
name: '',
|
||||
source_type: 'klausur',
|
||||
description: '',
|
||||
ocr_model: 'llama3.2-vision:11b',
|
||||
})
|
||||
|
||||
const createSession = async () => {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/v1/ocr-label/sessions`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(newSession),
|
||||
})
|
||||
if (res.ok) {
|
||||
setNewSession({ name: '', source_type: 'klausur', description: '', ocr_model: 'llama3.2-vision:11b' })
|
||||
onSessionCreated()
|
||||
} else {
|
||||
onError('Session erstellen fehlgeschlagen')
|
||||
}
|
||||
} catch (err) {
|
||||
onError('Netzwerkfehler')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Create Session */}
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">Neue Session erstellen</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newSession.name}
|
||||
onChange={(e) => setNewSession(prev => ({ ...prev, name: e.target.value }))}
|
||||
placeholder="z.B. Mathe Klausur Q1 2025"
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Typ</label>
|
||||
<select
|
||||
value={newSession.source_type}
|
||||
onChange={(e) => setNewSession(prev => ({ ...prev, source_type: e.target.value as 'klausur' | 'handwriting_sample' | 'scan' }))}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500"
|
||||
>
|
||||
<option value="klausur">Klausur</option>
|
||||
<option value="handwriting_sample">Handschriftprobe</option>
|
||||
<option value="scan">Scan</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">OCR Modell</label>
|
||||
<select
|
||||
value={newSession.ocr_model}
|
||||
onChange={(e) => setNewSession(prev => ({ ...prev, ocr_model: e.target.value as OCRModel }))}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500"
|
||||
>
|
||||
<option value="llama3.2-vision:11b">llama3.2-vision:11b - Vision LLM (Standard)</option>
|
||||
<option value="trocr">TrOCR - Microsoft Transformer (schnell)</option>
|
||||
<option value="paddleocr">PaddleOCR + LLM (4x schneller)</option>
|
||||
<option value="donut">Donut - Document Understanding (strukturiert)</option>
|
||||
</select>
|
||||
<p className="mt-1 text-xs text-slate-500">
|
||||
{newSession.ocr_model === 'paddleocr' && 'PaddleOCR erkennt Text schnell, LLM strukturiert die Ergebnisse.'}
|
||||
{newSession.ocr_model === 'donut' && 'Speziell fuer Dokumente mit Tabellen und Formularen.'}
|
||||
{newSession.ocr_model === 'trocr' && 'Schnelles Transformer-Modell fuer gedruckten Text.'}
|
||||
{newSession.ocr_model === 'llama3.2-vision:11b' && 'Beste Qualitaet bei Handschrift, aber langsamer.'}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Beschreibung</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newSession.description}
|
||||
onChange={(e) => setNewSession(prev => ({ ...prev, description: e.target.value }))}
|
||||
placeholder="Optional..."
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={createSession}
|
||||
disabled={!newSession.name}
|
||||
className="mt-4 px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 disabled:opacity-50"
|
||||
>
|
||||
Session erstellen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Sessions List */}
|
||||
<div className="bg-white rounded-lg shadow">
|
||||
<div className="px-6 py-4 border-b border-slate-200">
|
||||
<h3 className="text-lg font-semibold">Sessions ({sessions.length})</h3>
|
||||
</div>
|
||||
<div className="divide-y divide-slate-200">
|
||||
{sessions.map((session) => (
|
||||
<div
|
||||
key={session.id}
|
||||
className={`p-4 hover:bg-slate-50 cursor-pointer ${
|
||||
selectedSession === session.id ? 'bg-primary-50 border-l-4 border-primary-500' : ''
|
||||
}`}
|
||||
onClick={() => setSelectedSession(session.id === selectedSession ? null : session.id)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h4 className="font-medium">{session.name}</h4>
|
||||
<p className="text-sm text-slate-500">
|
||||
{session.source_type} | {session.ocr_model}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-sm font-medium">
|
||||
{session.labeled_items}/{session.total_items} gelabelt
|
||||
</p>
|
||||
<div className="w-32 bg-slate-200 rounded-full h-2 mt-1">
|
||||
<div
|
||||
className="bg-primary-600 rounded-full h-2"
|
||||
style={{
|
||||
width: `${session.total_items > 0 ? (session.labeled_items / session.total_items) * 100 : 0}%`
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{session.description && (
|
||||
<p className="text-sm text-slate-600 mt-2">{session.description}</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{sessions.length === 0 && (
|
||||
<p className="p-4 text-slate-500 text-center">Keine Sessions vorhanden</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
'use client'
|
||||
|
||||
import type { OCRStats } from '../types'
|
||||
|
||||
export default function StatsTab({ stats }: { stats: OCRStats | null }) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Global Stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h4 className="text-sm font-medium text-slate-500">Gesamt Items</h4>
|
||||
<p className="text-3xl font-bold mt-2">{stats?.total_items || 0}</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h4 className="text-sm font-medium text-slate-500">Gelabelt</h4>
|
||||
<p className="text-3xl font-bold mt-2 text-green-600">{stats?.labeled_items || 0}</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h4 className="text-sm font-medium text-slate-500">Ausstehend</h4>
|
||||
<p className="text-3xl font-bold mt-2 text-yellow-600">{stats?.pending_items || 0}</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h4 className="text-sm font-medium text-slate-500">OCR-Genauigkeit</h4>
|
||||
<p className="text-3xl font-bold mt-2">{stats?.accuracy_rate || 0}%</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Detailed Stats */}
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">Details</h3>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<p className="text-sm text-slate-500">Bestaetigt</p>
|
||||
<p className="text-xl font-semibold text-green-600">{stats?.confirmed_items || 0}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-slate-500">Korrigiert</p>
|
||||
<p className="text-xl font-semibold text-primary-600">{stats?.corrected_items || 0}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-slate-500">Exportierbar</p>
|
||||
<p className="text-xl font-semibold">{stats?.exportable_items || 0}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-slate-500">Durchschn. Label-Zeit</p>
|
||||
<p className="text-xl font-semibold">{stats?.avg_label_time_seconds || 0}s</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress Bar */}
|
||||
{stats?.total_items ? (
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">Fortschritt</h3>
|
||||
<div className="w-full bg-slate-200 rounded-full h-4">
|
||||
<div
|
||||
className="bg-primary-600 rounded-full h-4 transition-all"
|
||||
style={{ width: `${(stats.labeled_items / stats.total_items) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-sm text-slate-500 mt-2">
|
||||
{Math.round((stats.labeled_items / stats.total_items) * 100)}% abgeschlossen
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useRef } from 'react'
|
||||
import type { OCRSession } from '../types'
|
||||
|
||||
const API_BASE = process.env.NEXT_PUBLIC_KLAUSUR_SERVICE_URL || 'http://localhost:8086'
|
||||
|
||||
export default function UploadTab({
|
||||
sessions,
|
||||
selectedSession,
|
||||
setSelectedSession,
|
||||
onUploadComplete,
|
||||
onError,
|
||||
}: {
|
||||
sessions: OCRSession[]
|
||||
selectedSession: string | null
|
||||
setSelectedSession: (id: string | null) => void
|
||||
onUploadComplete: () => void
|
||||
onError: (msg: string) => void
|
||||
}) {
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [uploadResults, setUploadResults] = useState<any[]>([])
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const handleUpload = async (files: FileList) => {
|
||||
if (!selectedSession) {
|
||||
onError('Bitte zuerst eine Session auswaehlen')
|
||||
return
|
||||
}
|
||||
|
||||
setUploading(true)
|
||||
const formData = new FormData()
|
||||
Array.from(files).forEach(file => formData.append('files', file))
|
||||
formData.append('run_ocr', 'true')
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/v1/ocr-label/sessions/${selectedSession}/upload`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setUploadResults(data.items || [])
|
||||
onUploadComplete()
|
||||
} else {
|
||||
onError('Upload fehlgeschlagen')
|
||||
}
|
||||
} catch (err) {
|
||||
onError('Netzwerkfehler beim Upload')
|
||||
} finally {
|
||||
setUploading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Session Selection */}
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">Session auswaehlen</h3>
|
||||
<select
|
||||
value={selectedSession || ''}
|
||||
onChange={(e) => setSelectedSession(e.target.value || null)}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500"
|
||||
>
|
||||
<option value="">-- Session waehlen --</option>
|
||||
{sessions.map((session) => (
|
||||
<option key={session.id} value={session.id}>
|
||||
{session.name} ({session.total_items} Items)
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Upload Area */}
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">Bilder hochladen</h3>
|
||||
<div
|
||||
className={`border-2 border-dashed rounded-lg p-8 text-center ${
|
||||
selectedSession ? 'border-slate-300 hover:border-primary-500' : 'border-slate-200 opacity-50'
|
||||
}`}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault()
|
||||
e.currentTarget.classList.add('border-primary-500', 'bg-primary-50')
|
||||
}}
|
||||
onDragLeave={(e) => {
|
||||
e.currentTarget.classList.remove('border-primary-500', 'bg-primary-50')
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault()
|
||||
e.currentTarget.classList.remove('border-primary-500', 'bg-primary-50')
|
||||
if (e.dataTransfer.files.length > 0) {
|
||||
handleUpload(e.dataTransfer.files)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
accept="image/png,image/jpeg,image/jpg"
|
||||
onChange={(e) => e.target.files && handleUpload(e.target.files)}
|
||||
className="hidden"
|
||||
disabled={!selectedSession}
|
||||
/>
|
||||
{uploading ? (
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600" />
|
||||
<p>Hochladen & OCR ausfuehren...</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<svg className="w-12 h-12 text-slate-400 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<p className="text-slate-600 mb-2">
|
||||
Bilder hierher ziehen oder{' '}
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={!selectedSession}
|
||||
className="text-primary-600 hover:underline"
|
||||
>
|
||||
auswaehlen
|
||||
</button>
|
||||
</p>
|
||||
<p className="text-sm text-slate-500">PNG, JPG (max. 10MB pro Bild)</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Upload Results */}
|
||||
{uploadResults.length > 0 && (
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">Upload-Ergebnisse ({uploadResults.length})</h3>
|
||||
<div className="space-y-2">
|
||||
{uploadResults.map((result) => (
|
||||
<div key={result.id} className="flex items-center justify-between p-2 bg-slate-50 rounded">
|
||||
<span className="text-sm">{result.filename}</span>
|
||||
<span className={`text-xs px-2 py-1 rounded ${
|
||||
result.ocr_text ? 'bg-green-100 text-green-800' : 'bg-yellow-100 text-yellow-800'
|
||||
}`}>
|
||||
{result.ocr_text ? `OCR OK (${Math.round((result.ocr_confidence || 0) * 100)}%)` : 'Kein OCR'}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
export const API_BASE = process.env.NEXT_PUBLIC_KLAUSUR_SERVICE_URL || 'http://localhost:8086'
|
||||
|
||||
export type TabId = 'labeling' | 'sessions' | 'upload' | 'stats' | 'export'
|
||||
|
||||
export const tabs: { id: TabId; name: string; icon: JSX.Element }[] = [
|
||||
{ id: 'labeling', name: 'Labeling', icon: <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" /></svg> },
|
||||
{ id: 'sessions', name: 'Sessions', icon: <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" /></svg> },
|
||||
{ id: 'upload', name: 'Upload', icon: <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" /></svg> },
|
||||
{ id: 'stats', name: 'Statistiken', icon: <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" /></svg> },
|
||||
{ id: 'export', name: 'Export', icon: <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" /></svg> },
|
||||
]
|
||||
@@ -7,70 +7,16 @@
|
||||
* DSGVO-konform: Alle Verarbeitung lokal auf Mac Mini (Ollama).
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import AdminLayout from '@/components/admin/AdminLayout'
|
||||
import type {
|
||||
OCRSession,
|
||||
OCRItem,
|
||||
OCRStats,
|
||||
TrainingSample,
|
||||
CreateSessionRequest,
|
||||
OCRModel,
|
||||
} from './types'
|
||||
|
||||
// API Base URL for klausur-service
|
||||
const API_BASE = process.env.NEXT_PUBLIC_KLAUSUR_SERVICE_URL || 'http://localhost:8086'
|
||||
|
||||
// Tab definitions
|
||||
type TabId = 'labeling' | 'sessions' | 'upload' | 'stats' | 'export'
|
||||
|
||||
const tabs: { id: TabId; name: string; icon: JSX.Element }[] = [
|
||||
{
|
||||
id: 'labeling',
|
||||
name: 'Labeling',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'sessions',
|
||||
name: 'Sessions',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'upload',
|
||||
name: 'Upload',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'stats',
|
||||
name: 'Statistiken',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'export',
|
||||
name: 'Export',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
]
|
||||
import type { OCRSession, OCRItem, OCRStats } from './types'
|
||||
import { API_BASE, tabs } from './constants'
|
||||
import type { TabId } from './constants'
|
||||
import LabelingTab from './_components/LabelingTab'
|
||||
import SessionsTab from './_components/SessionsTab'
|
||||
import UploadTab from './_components/UploadTab'
|
||||
import StatsTab from './_components/StatsTab'
|
||||
import ExportTab from './_components/ExportTab'
|
||||
|
||||
export default function OCRLabelingPage() {
|
||||
const [activeTab, setActiveTab] = useState<TabId>('labeling')
|
||||
@@ -85,819 +31,65 @@ export default function OCRLabelingPage() {
|
||||
const [correctedText, setCorrectedText] = useState('')
|
||||
const [labelStartTime, setLabelStartTime] = useState<number | null>(null)
|
||||
|
||||
// Fetch sessions
|
||||
const fetchSessions = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/v1/ocr-label/sessions`)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setSessions(data)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch sessions:', err)
|
||||
}
|
||||
try { const r = await fetch(`${API_BASE}/api/v1/ocr-label/sessions`); if (r.ok) setSessions(await r.json()) }
|
||||
catch (e) { console.error('Failed to fetch sessions:', e) }
|
||||
}, [])
|
||||
|
||||
// Fetch queue
|
||||
const fetchQueue = useCallback(async () => {
|
||||
try {
|
||||
const url = selectedSession
|
||||
? `${API_BASE}/api/v1/ocr-label/queue?session_id=${selectedSession}&limit=20`
|
||||
: `${API_BASE}/api/v1/ocr-label/queue?limit=20`
|
||||
const res = await fetch(url)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setQueue(data)
|
||||
if (data.length > 0 && !currentItem) {
|
||||
setCurrentItem(data[0])
|
||||
setCurrentIndex(0)
|
||||
setCorrectedText(data[0].ocr_text || '')
|
||||
setLabelStartTime(Date.now())
|
||||
}
|
||||
const url = selectedSession ? `${API_BASE}/api/v1/ocr-label/queue?session_id=${selectedSession}&limit=20` : `${API_BASE}/api/v1/ocr-label/queue?limit=20`
|
||||
const r = await fetch(url)
|
||||
if (r.ok) {
|
||||
const data = await r.json(); setQueue(data)
|
||||
if (data.length > 0 && !currentItem) { setCurrentItem(data[0]); setCurrentIndex(0); setCorrectedText(data[0].ocr_text || ''); setLabelStartTime(Date.now()) }
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch queue:', err)
|
||||
}
|
||||
} catch (e) { console.error('Failed to fetch queue:', e) }
|
||||
}, [selectedSession, currentItem])
|
||||
|
||||
// Fetch stats
|
||||
const fetchStats = useCallback(async () => {
|
||||
try {
|
||||
const url = selectedSession
|
||||
? `${API_BASE}/api/v1/ocr-label/stats?session_id=${selectedSession}`
|
||||
: `${API_BASE}/api/v1/ocr-label/stats`
|
||||
const res = await fetch(url)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setStats(data)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch stats:', err)
|
||||
}
|
||||
const url = selectedSession ? `${API_BASE}/api/v1/ocr-label/stats?session_id=${selectedSession}` : `${API_BASE}/api/v1/ocr-label/stats`
|
||||
const r = await fetch(url); if (r.ok) setStats(await r.json())
|
||||
} catch (e) { console.error('Failed to fetch stats:', e) }
|
||||
}, [selectedSession])
|
||||
|
||||
// Initial data load
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
setLoading(true)
|
||||
await Promise.all([fetchSessions(), fetchQueue(), fetchStats()])
|
||||
setLoading(false)
|
||||
}
|
||||
loadData()
|
||||
}, [fetchSessions, fetchQueue, fetchStats])
|
||||
useEffect(() => { setLoading(true); Promise.all([fetchSessions(), fetchQueue(), fetchStats()]).then(() => setLoading(false)) }, [fetchSessions, fetchQueue, fetchStats])
|
||||
useEffect(() => { setCurrentItem(null); setCurrentIndex(0); fetchQueue(); fetchStats() }, [selectedSession, fetchQueue, fetchStats])
|
||||
|
||||
// Refresh queue when session changes
|
||||
useEffect(() => {
|
||||
setCurrentItem(null)
|
||||
setCurrentIndex(0)
|
||||
fetchQueue()
|
||||
fetchStats()
|
||||
}, [selectedSession, fetchQueue, fetchStats])
|
||||
const getLabelTime = () => labelStartTime ? Math.round((Date.now() - labelStartTime) / 1000) : undefined
|
||||
|
||||
const setItem = (item: OCRItem, idx: number) => { setCurrentIndex(idx); setCurrentItem(item); setCorrectedText(item.ocr_text || ''); setLabelStartTime(Date.now()) }
|
||||
|
||||
// Navigate to next item
|
||||
const goToNext = () => {
|
||||
if (currentIndex < queue.length - 1) {
|
||||
const nextIndex = currentIndex + 1
|
||||
setCurrentIndex(nextIndex)
|
||||
setCurrentItem(queue[nextIndex])
|
||||
setCorrectedText(queue[nextIndex].ocr_text || '')
|
||||
setLabelStartTime(Date.now())
|
||||
} else {
|
||||
// Refresh queue
|
||||
fetchQueue()
|
||||
}
|
||||
if (currentIndex < queue.length - 1) setItem(queue[currentIndex + 1], currentIndex + 1)
|
||||
else fetchQueue()
|
||||
}
|
||||
const goToPrev = () => { if (currentIndex > 0) setItem(queue[currentIndex - 1], currentIndex - 1) }
|
||||
|
||||
const postAction = async (endpoint: string, body: object) => {
|
||||
const r = await fetch(`${API_BASE}/api/v1/ocr-label/${endpoint}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })
|
||||
if (r.ok) { setQueue(prev => prev.filter(i => i.id !== currentItem?.id)); goToNext(); fetchStats() }
|
||||
else setError(`${endpoint} fehlgeschlagen`)
|
||||
}
|
||||
|
||||
// Navigate to previous item
|
||||
const goToPrev = () => {
|
||||
if (currentIndex > 0) {
|
||||
const prevIndex = currentIndex - 1
|
||||
setCurrentIndex(prevIndex)
|
||||
setCurrentItem(queue[prevIndex])
|
||||
setCorrectedText(queue[prevIndex].ocr_text || '')
|
||||
setLabelStartTime(Date.now())
|
||||
}
|
||||
}
|
||||
const confirmItem = () => { if (currentItem) postAction('confirm', { item_id: currentItem.id, label_time_seconds: getLabelTime() }).catch(() => setError('Netzwerkfehler')) }
|
||||
const correctItem = () => { if (currentItem && correctedText.trim()) postAction('correct', { item_id: currentItem.id, ground_truth: correctedText.trim(), label_time_seconds: getLabelTime() }).catch(() => setError('Netzwerkfehler')) }
|
||||
const skipItem = () => { if (currentItem) postAction('skip', { item_id: currentItem.id }).catch(() => setError('Netzwerkfehler')) }
|
||||
|
||||
// Calculate label time
|
||||
const getLabelTime = (): number | undefined => {
|
||||
if (!labelStartTime) return undefined
|
||||
return Math.round((Date.now() - labelStartTime) / 1000)
|
||||
}
|
||||
|
||||
// Confirm item
|
||||
const confirmItem = async () => {
|
||||
if (!currentItem) return
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/v1/ocr-label/confirm`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
item_id: currentItem.id,
|
||||
label_time_seconds: getLabelTime(),
|
||||
}),
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
// Remove from queue and go to next
|
||||
setQueue(prev => prev.filter(item => item.id !== currentItem.id))
|
||||
goToNext()
|
||||
fetchStats()
|
||||
} else {
|
||||
setError('Bestaetigung fehlgeschlagen')
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Netzwerkfehler')
|
||||
}
|
||||
}
|
||||
|
||||
// Correct item
|
||||
const correctItem = async () => {
|
||||
if (!currentItem || !correctedText.trim()) return
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/v1/ocr-label/correct`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
item_id: currentItem.id,
|
||||
ground_truth: correctedText.trim(),
|
||||
label_time_seconds: getLabelTime(),
|
||||
}),
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
setQueue(prev => prev.filter(item => item.id !== currentItem.id))
|
||||
goToNext()
|
||||
fetchStats()
|
||||
} else {
|
||||
setError('Korrektur fehlgeschlagen')
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Netzwerkfehler')
|
||||
}
|
||||
}
|
||||
|
||||
// Skip item
|
||||
const skipItem = async () => {
|
||||
if (!currentItem) return
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/v1/ocr-label/skip`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ item_id: currentItem.id }),
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
setQueue(prev => prev.filter(item => item.id !== currentItem.id))
|
||||
goToNext()
|
||||
fetchStats()
|
||||
} else {
|
||||
setError('Ueberspringen fehlgeschlagen')
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Netzwerkfehler')
|
||||
}
|
||||
}
|
||||
|
||||
// Keyboard shortcuts
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
// Only handle if not in text input
|
||||
const h = (e: KeyboardEvent) => {
|
||||
if (e.target instanceof HTMLTextAreaElement) return
|
||||
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
confirmItem()
|
||||
} else if (e.key === 'ArrowRight') {
|
||||
goToNext()
|
||||
} else if (e.key === 'ArrowLeft') {
|
||||
goToPrev()
|
||||
} else if (e.key === 's' && !e.ctrlKey && !e.metaKey) {
|
||||
skipItem()
|
||||
}
|
||||
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); confirmItem() }
|
||||
else if (e.key === 'ArrowRight') goToNext()
|
||||
else if (e.key === 'ArrowLeft') goToPrev()
|
||||
else if (e.key === 's' && !e.ctrlKey && !e.metaKey) skipItem()
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
window.addEventListener('keydown', h); return () => window.removeEventListener('keydown', h)
|
||||
}, [currentItem, correctedText])
|
||||
|
||||
// Render Labeling Tab
|
||||
const renderLabelingTab = () => (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Left: Image Viewer */}
|
||||
<div className="lg:col-span-2 bg-white rounded-lg shadow p-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold">Bild</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={goToPrev}
|
||||
disabled={currentIndex === 0}
|
||||
className="p-2 rounded hover:bg-slate-100 disabled:opacity-50"
|
||||
title="Zurueck (Pfeiltaste links)"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
<span className="text-sm text-slate-600">
|
||||
{currentIndex + 1} / {queue.length}
|
||||
</span>
|
||||
<button
|
||||
onClick={goToNext}
|
||||
disabled={currentIndex >= queue.length - 1}
|
||||
className="p-2 rounded hover:bg-slate-100 disabled:opacity-50"
|
||||
title="Weiter (Pfeiltaste rechts)"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{currentItem ? (
|
||||
<div className="relative bg-slate-100 rounded-lg overflow-hidden" style={{ minHeight: '400px' }}>
|
||||
<img
|
||||
src={currentItem.image_url || `${API_BASE}${currentItem.image_path}`}
|
||||
alt="OCR Bild"
|
||||
className="w-full h-auto max-h-[600px] object-contain"
|
||||
onError={(e) => {
|
||||
// Fallback if image fails to load
|
||||
const target = e.target as HTMLImageElement
|
||||
target.style.display = 'none'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-64 bg-slate-100 rounded-lg">
|
||||
<p className="text-slate-500">Keine Bilder in der Warteschlange</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right: OCR Text & Actions */}
|
||||
<div className="bg-white rounded-lg shadow p-4">
|
||||
<div className="space-y-4">
|
||||
{/* OCR Result */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h3 className="text-lg font-semibold">OCR-Ergebnis</h3>
|
||||
{currentItem?.ocr_confidence && (
|
||||
<span className={`text-sm px-2 py-1 rounded ${
|
||||
currentItem.ocr_confidence > 0.8
|
||||
? 'bg-green-100 text-green-800'
|
||||
: currentItem.ocr_confidence > 0.5
|
||||
? 'bg-yellow-100 text-yellow-800'
|
||||
: 'bg-red-100 text-red-800'
|
||||
}`}>
|
||||
{Math.round(currentItem.ocr_confidence * 100)}% Konfidenz
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="bg-slate-50 p-3 rounded-lg min-h-[100px] text-sm">
|
||||
{currentItem?.ocr_text || <span className="text-slate-400">Kein OCR-Text</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Correction Input */}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-2">Korrektur</h3>
|
||||
<textarea
|
||||
value={correctedText}
|
||||
onChange={(e) => setCorrectedText(e.target.value)}
|
||||
placeholder="Korrigierter Text..."
|
||||
className="w-full h-32 p-3 border border-slate-200 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<button
|
||||
onClick={confirmItem}
|
||||
disabled={!currentItem}
|
||||
className="w-full px-4 py-3 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50 flex items-center justify-center gap-2"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
Korrekt (Enter)
|
||||
</button>
|
||||
<button
|
||||
onClick={correctItem}
|
||||
disabled={!currentItem || !correctedText.trim() || correctedText === currentItem?.ocr_text}
|
||||
className="w-full px-4 py-3 bg-primary-600 text-white rounded-lg hover:bg-primary-700 disabled:opacity-50 flex items-center justify-center gap-2"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
|
||||
</svg>
|
||||
Korrektur speichern
|
||||
</button>
|
||||
<button
|
||||
onClick={skipItem}
|
||||
disabled={!currentItem}
|
||||
className="w-full px-4 py-2 bg-slate-200 text-slate-700 rounded-lg hover:bg-slate-300 disabled:opacity-50 flex items-center justify-center gap-2"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 5l7 7-7 7M5 5l7 7-7 7" />
|
||||
</svg>
|
||||
Ueberspringen (S)
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Keyboard Shortcuts */}
|
||||
<div className="text-xs text-slate-500 mt-4">
|
||||
<p className="font-medium mb-1">Tastaturkuerzel:</p>
|
||||
<p>Enter = Bestaetigen | S = Ueberspringen</p>
|
||||
<p>Pfeiltasten = Navigation</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom: Queue Preview */}
|
||||
<div className="lg:col-span-3 bg-white rounded-lg shadow p-4">
|
||||
<h3 className="text-lg font-semibold mb-4">Warteschlange ({queue.length} Items)</h3>
|
||||
<div className="flex gap-2 overflow-x-auto pb-2">
|
||||
{queue.slice(0, 10).map((item, idx) => (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => {
|
||||
setCurrentIndex(idx)
|
||||
setCurrentItem(item)
|
||||
setCorrectedText(item.ocr_text || '')
|
||||
setLabelStartTime(Date.now())
|
||||
}}
|
||||
className={`flex-shrink-0 w-24 h-24 rounded-lg overflow-hidden border-2 ${
|
||||
idx === currentIndex
|
||||
? 'border-primary-500'
|
||||
: 'border-transparent hover:border-slate-300'
|
||||
}`}
|
||||
>
|
||||
<img
|
||||
src={item.image_url || `${API_BASE}${item.image_path}`}
|
||||
alt=""
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
{queue.length > 10 && (
|
||||
<div className="flex-shrink-0 w-24 h-24 rounded-lg bg-slate-100 flex items-center justify-center text-slate-500">
|
||||
+{queue.length - 10} mehr
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
// Render Sessions Tab
|
||||
const renderSessionsTab = () => {
|
||||
const [newSession, setNewSession] = useState<CreateSessionRequest>({
|
||||
name: '',
|
||||
source_type: 'klausur',
|
||||
description: '',
|
||||
ocr_model: 'llama3.2-vision:11b',
|
||||
})
|
||||
|
||||
const createSession = async () => {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/v1/ocr-label/sessions`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(newSession),
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
setNewSession({ name: '', source_type: 'klausur', description: '', ocr_model: 'llama3.2-vision:11b' })
|
||||
fetchSessions()
|
||||
} else {
|
||||
setError('Session erstellen fehlgeschlagen')
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Netzwerkfehler')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Create Session */}
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">Neue Session erstellen</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newSession.name}
|
||||
onChange={(e) => setNewSession(prev => ({ ...prev, name: e.target.value }))}
|
||||
placeholder="z.B. Mathe Klausur Q1 2025"
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Typ</label>
|
||||
<select
|
||||
value={newSession.source_type}
|
||||
onChange={(e) => setNewSession(prev => ({ ...prev, source_type: e.target.value as 'klausur' | 'handwriting_sample' | 'scan' }))}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500"
|
||||
>
|
||||
<option value="klausur">Klausur</option>
|
||||
<option value="handwriting_sample">Handschriftprobe</option>
|
||||
<option value="scan">Scan</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">OCR Modell</label>
|
||||
<select
|
||||
value={newSession.ocr_model}
|
||||
onChange={(e) => setNewSession(prev => ({ ...prev, ocr_model: e.target.value as OCRModel }))}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500"
|
||||
>
|
||||
<option value="llama3.2-vision:11b">llama3.2-vision:11b - Vision LLM (Standard)</option>
|
||||
<option value="trocr">TrOCR - Microsoft Transformer (schnell)</option>
|
||||
<option value="paddleocr">PaddleOCR + LLM (4x schneller)</option>
|
||||
<option value="donut">Donut - Document Understanding (strukturiert)</option>
|
||||
</select>
|
||||
<p className="mt-1 text-xs text-slate-500">
|
||||
{newSession.ocr_model === 'paddleocr' && 'PaddleOCR erkennt Text schnell, LLM strukturiert die Ergebnisse.'}
|
||||
{newSession.ocr_model === 'donut' && 'Speziell fuer Dokumente mit Tabellen und Formularen.'}
|
||||
{newSession.ocr_model === 'trocr' && 'Schnelles Transformer-Modell fuer gedruckten Text.'}
|
||||
{newSession.ocr_model === 'llama3.2-vision:11b' && 'Beste Qualitaet bei Handschrift, aber langsamer.'}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Beschreibung</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newSession.description}
|
||||
onChange={(e) => setNewSession(prev => ({ ...prev, description: e.target.value }))}
|
||||
placeholder="Optional..."
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={createSession}
|
||||
disabled={!newSession.name}
|
||||
className="mt-4 px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 disabled:opacity-50"
|
||||
>
|
||||
Session erstellen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Sessions List */}
|
||||
<div className="bg-white rounded-lg shadow">
|
||||
<div className="px-6 py-4 border-b border-slate-200">
|
||||
<h3 className="text-lg font-semibold">Sessions ({sessions.length})</h3>
|
||||
</div>
|
||||
<div className="divide-y divide-slate-200">
|
||||
{sessions.map((session) => (
|
||||
<div
|
||||
key={session.id}
|
||||
className={`p-4 hover:bg-slate-50 cursor-pointer ${
|
||||
selectedSession === session.id ? 'bg-primary-50 border-l-4 border-primary-500' : ''
|
||||
}`}
|
||||
onClick={() => setSelectedSession(session.id === selectedSession ? null : session.id)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h4 className="font-medium">{session.name}</h4>
|
||||
<p className="text-sm text-slate-500">
|
||||
{session.source_type} | {session.ocr_model}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-sm font-medium">
|
||||
{session.labeled_items}/{session.total_items} gelabelt
|
||||
</p>
|
||||
<div className="w-32 bg-slate-200 rounded-full h-2 mt-1">
|
||||
<div
|
||||
className="bg-primary-600 rounded-full h-2"
|
||||
style={{
|
||||
width: `${session.total_items > 0 ? (session.labeled_items / session.total_items) * 100 : 0}%`
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{session.description && (
|
||||
<p className="text-sm text-slate-600 mt-2">{session.description}</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{sessions.length === 0 && (
|
||||
<p className="p-4 text-slate-500 text-center">Keine Sessions vorhanden</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Render Upload Tab
|
||||
const renderUploadTab = () => {
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [uploadResults, setUploadResults] = useState<any[]>([])
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const handleUpload = async (files: FileList) => {
|
||||
if (!selectedSession) {
|
||||
setError('Bitte zuerst eine Session auswaehlen')
|
||||
return
|
||||
}
|
||||
|
||||
setUploading(true)
|
||||
const formData = new FormData()
|
||||
Array.from(files).forEach(file => formData.append('files', file))
|
||||
formData.append('run_ocr', 'true')
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/v1/ocr-label/sessions/${selectedSession}/upload`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setUploadResults(data.items || [])
|
||||
fetchQueue()
|
||||
fetchStats()
|
||||
} else {
|
||||
setError('Upload fehlgeschlagen')
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Netzwerkfehler beim Upload')
|
||||
} finally {
|
||||
setUploading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Session Selection */}
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">Session auswaehlen</h3>
|
||||
<select
|
||||
value={selectedSession || ''}
|
||||
onChange={(e) => setSelectedSession(e.target.value || null)}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500"
|
||||
>
|
||||
<option value="">-- Session waehlen --</option>
|
||||
{sessions.map((session) => (
|
||||
<option key={session.id} value={session.id}>
|
||||
{session.name} ({session.total_items} Items)
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Upload Area */}
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">Bilder hochladen</h3>
|
||||
<div
|
||||
className={`border-2 border-dashed rounded-lg p-8 text-center ${
|
||||
selectedSession ? 'border-slate-300 hover:border-primary-500' : 'border-slate-200 opacity-50'
|
||||
}`}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault()
|
||||
e.currentTarget.classList.add('border-primary-500', 'bg-primary-50')
|
||||
}}
|
||||
onDragLeave={(e) => {
|
||||
e.currentTarget.classList.remove('border-primary-500', 'bg-primary-50')
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault()
|
||||
e.currentTarget.classList.remove('border-primary-500', 'bg-primary-50')
|
||||
if (e.dataTransfer.files.length > 0) {
|
||||
handleUpload(e.dataTransfer.files)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
accept="image/png,image/jpeg,image/jpg"
|
||||
onChange={(e) => e.target.files && handleUpload(e.target.files)}
|
||||
className="hidden"
|
||||
disabled={!selectedSession}
|
||||
/>
|
||||
{uploading ? (
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600" />
|
||||
<p>Hochladen & OCR ausfuehren...</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<svg className="w-12 h-12 text-slate-400 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<p className="text-slate-600 mb-2">
|
||||
Bilder hierher ziehen oder{' '}
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={!selectedSession}
|
||||
className="text-primary-600 hover:underline"
|
||||
>
|
||||
auswaehlen
|
||||
</button>
|
||||
</p>
|
||||
<p className="text-sm text-slate-500">PNG, JPG (max. 10MB pro Bild)</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Upload Results */}
|
||||
{uploadResults.length > 0 && (
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">Upload-Ergebnisse ({uploadResults.length})</h3>
|
||||
<div className="space-y-2">
|
||||
{uploadResults.map((result) => (
|
||||
<div key={result.id} className="flex items-center justify-between p-2 bg-slate-50 rounded">
|
||||
<span className="text-sm">{result.filename}</span>
|
||||
<span className={`text-xs px-2 py-1 rounded ${
|
||||
result.ocr_text ? 'bg-green-100 text-green-800' : 'bg-yellow-100 text-yellow-800'
|
||||
}`}>
|
||||
{result.ocr_text ? `OCR OK (${Math.round((result.ocr_confidence || 0) * 100)}%)` : 'Kein OCR'}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Render Stats Tab
|
||||
const renderStatsTab = () => (
|
||||
<div className="space-y-6">
|
||||
{/* Global Stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h4 className="text-sm font-medium text-slate-500">Gesamt Items</h4>
|
||||
<p className="text-3xl font-bold mt-2">{stats?.total_items || 0}</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h4 className="text-sm font-medium text-slate-500">Gelabelt</h4>
|
||||
<p className="text-3xl font-bold mt-2 text-green-600">{stats?.labeled_items || 0}</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h4 className="text-sm font-medium text-slate-500">Ausstehend</h4>
|
||||
<p className="text-3xl font-bold mt-2 text-yellow-600">{stats?.pending_items || 0}</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h4 className="text-sm font-medium text-slate-500">OCR-Genauigkeit</h4>
|
||||
<p className="text-3xl font-bold mt-2">{stats?.accuracy_rate || 0}%</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Detailed Stats */}
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">Details</h3>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<p className="text-sm text-slate-500">Bestaetigt</p>
|
||||
<p className="text-xl font-semibold text-green-600">{stats?.confirmed_items || 0}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-slate-500">Korrigiert</p>
|
||||
<p className="text-xl font-semibold text-primary-600">{stats?.corrected_items || 0}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-slate-500">Exportierbar</p>
|
||||
<p className="text-xl font-semibold">{stats?.exportable_items || 0}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-slate-500">Durchschn. Label-Zeit</p>
|
||||
<p className="text-xl font-semibold">{stats?.avg_label_time_seconds || 0}s</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress Bar */}
|
||||
{stats?.total_items ? (
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">Fortschritt</h3>
|
||||
<div className="w-full bg-slate-200 rounded-full h-4">
|
||||
<div
|
||||
className="bg-primary-600 rounded-full h-4 transition-all"
|
||||
style={{ width: `${(stats.labeled_items / stats.total_items) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-sm text-slate-500 mt-2">
|
||||
{Math.round((stats.labeled_items / stats.total_items) * 100)}% abgeschlossen
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
|
||||
// Render Export Tab
|
||||
const renderExportTab = () => {
|
||||
const [exportFormat, setExportFormat] = useState<'generic' | 'trocr' | 'llama_vision'>('generic')
|
||||
const [exporting, setExporting] = useState(false)
|
||||
const [exportResult, setExportResult] = useState<any>(null)
|
||||
|
||||
const handleExport = async () => {
|
||||
setExporting(true)
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/v1/ocr-label/export`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
export_format: exportFormat,
|
||||
session_id: selectedSession,
|
||||
}),
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setExportResult(data)
|
||||
} else {
|
||||
setError('Export fehlgeschlagen')
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Netzwerkfehler')
|
||||
} finally {
|
||||
setExporting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">Training-Daten exportieren</h3>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Export-Format</label>
|
||||
<select
|
||||
value={exportFormat}
|
||||
onChange={(e) => setExportFormat(e.target.value as typeof exportFormat)}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500"
|
||||
>
|
||||
<option value="generic">Generic JSON</option>
|
||||
<option value="trocr">TrOCR Fine-Tuning</option>
|
||||
<option value="llama_vision">Llama Vision Fine-Tuning</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Session (optional)</label>
|
||||
<select
|
||||
value={selectedSession || ''}
|
||||
onChange={(e) => setSelectedSession(e.target.value || null)}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500"
|
||||
>
|
||||
<option value="">Alle Sessions</option>
|
||||
{sessions.map((session) => (
|
||||
<option key={session.id} value={session.id}>{session.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleExport}
|
||||
disabled={exporting || (stats?.exportable_items || 0) === 0}
|
||||
className="w-full px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 disabled:opacity-50"
|
||||
>
|
||||
{exporting ? 'Exportiere...' : `${stats?.exportable_items || 0} Samples exportieren`}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{exportResult && (
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">Export-Ergebnis</h3>
|
||||
<div className="bg-green-50 border border-green-200 rounded-lg p-4 mb-4">
|
||||
<p className="text-green-800">
|
||||
{exportResult.exported_count} Samples erfolgreich exportiert
|
||||
</p>
|
||||
<p className="text-sm text-green-600">
|
||||
Batch: {exportResult.batch_id}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-slate-50 p-4 rounded-lg overflow-auto max-h-64">
|
||||
<pre className="text-xs">{JSON.stringify(exportResult.samples?.slice(0, 3), null, 2)}</pre>
|
||||
{(exportResult.samples?.length || 0) > 3 && (
|
||||
<p className="text-slate-500 mt-2">... und {exportResult.samples.length - 3} weitere</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<AdminLayout
|
||||
title="OCR-Labeling"
|
||||
description="Handschrift-Training & Ground Truth Erfassung"
|
||||
>
|
||||
{/* Error Toast */}
|
||||
<AdminLayout title="OCR-Labeling" description="Handschrift-Training & Ground Truth Erfassung">
|
||||
{error && (
|
||||
<div className="fixed top-4 right-4 bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded z-50">
|
||||
<span>{error}</span>
|
||||
@@ -905,40 +97,25 @@ export default function OCRLabelingPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="mb-6">
|
||||
<div className="border-b border-slate-200">
|
||||
<nav className="flex space-x-4" aria-label="Tabs">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`flex items-center gap-2 px-4 py-3 border-b-2 font-medium text-sm transition-colors ${
|
||||
activeTab === tab.id
|
||||
? 'border-primary-500 text-primary-600'
|
||||
: 'border-transparent text-slate-500 hover:text-slate-700 hover:border-slate-300'
|
||||
}`}
|
||||
>
|
||||
{tab.icon}
|
||||
{tab.name}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
<div className="mb-6 border-b border-slate-200">
|
||||
<nav className="flex space-x-4" aria-label="Tabs">
|
||||
{tabs.map((tab) => (
|
||||
<button key={tab.id} onClick={() => setActiveTab(tab.id)} className={`flex items-center gap-2 px-4 py-3 border-b-2 font-medium text-sm transition-colors ${activeTab === tab.id ? 'border-primary-500 text-primary-600' : 'border-transparent text-slate-500 hover:text-slate-700 hover:border-slate-300'}`}>
|
||||
{tab.icon}{tab.name}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600" />
|
||||
</div>
|
||||
<div className="flex items-center justify-center h-64"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600" /></div>
|
||||
) : (
|
||||
<>
|
||||
{activeTab === 'labeling' && renderLabelingTab()}
|
||||
{activeTab === 'sessions' && renderSessionsTab()}
|
||||
{activeTab === 'upload' && renderUploadTab()}
|
||||
{activeTab === 'stats' && renderStatsTab()}
|
||||
{activeTab === 'export' && renderExportTab()}
|
||||
{activeTab === 'labeling' && <LabelingTab queue={queue} currentItem={currentItem} currentIndex={currentIndex} correctedText={correctedText} setCorrectedText={setCorrectedText} onGoToPrev={goToPrev} onGoToNext={goToNext} onConfirm={confirmItem} onCorrect={correctItem} onSkip={skipItem} onSelectItem={setItem} />}
|
||||
{activeTab === 'sessions' && <SessionsTab sessions={sessions} selectedSession={selectedSession} setSelectedSession={setSelectedSession} onSessionCreated={fetchSessions} onError={setError} />}
|
||||
{activeTab === 'upload' && <UploadTab sessions={sessions} selectedSession={selectedSession} setSelectedSession={setSelectedSession} onUploadComplete={() => { fetchQueue(); fetchStats() }} onError={setError} />}
|
||||
{activeTab === 'stats' && <StatsTab stats={stats} />}
|
||||
{activeTab === 'export' && <ExportTab sessions={sessions} selectedSession={selectedSession} setSelectedSession={setSelectedSession} stats={stats} onError={setError} />}
|
||||
</>
|
||||
)}
|
||||
</AdminLayout>
|
||||
|
||||
Reference in New Issue
Block a user