Some checks failed
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-school (push) Successful in 37s
CI / test-go-edu-search (push) Successful in 27s
CI / test-python-klausur (push) Failing after 2m1s
CI / test-python-agent-core (push) Successful in 15s
CI / test-nodejs-website (push) Successful in 17s
handleOrientationComplete was checking subSessions from React state, but due to batching the state was still empty when the user clicked "Seiten verarbeiten". Now fetches session data directly from the API to reliably detect sub-sessions and auto-open the first one. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
657 lines
26 KiB
TypeScript
657 lines
26 KiB
TypeScript
'use client'
|
|
|
|
import { useCallback, useEffect, useState } from 'react'
|
|
import { PagePurpose } from '@/components/common/PagePurpose'
|
|
import { PipelineStepper } from '@/components/ocr-pipeline/PipelineStepper'
|
|
import { StepOrientation } from '@/components/ocr-pipeline/StepOrientation'
|
|
import { StepCrop } from '@/components/ocr-pipeline/StepCrop'
|
|
import { StepDeskew } from '@/components/ocr-pipeline/StepDeskew'
|
|
import { StepDewarp } from '@/components/ocr-pipeline/StepDewarp'
|
|
import { StepStructureDetection } from '@/components/ocr-pipeline/StepStructureDetection'
|
|
import { StepColumnDetection } from '@/components/ocr-pipeline/StepColumnDetection'
|
|
import { StepRowDetection } from '@/components/ocr-pipeline/StepRowDetection'
|
|
import { StepWordRecognition } from '@/components/ocr-pipeline/StepWordRecognition'
|
|
import { StepLlmReview } from '@/components/ocr-pipeline/StepLlmReview'
|
|
import { StepReconstruction } from '@/components/ocr-pipeline/StepReconstruction'
|
|
import { StepGroundTruth } from '@/components/ocr-pipeline/StepGroundTruth'
|
|
import { BoxSessionTabs } from '@/components/ocr-pipeline/BoxSessionTabs'
|
|
import { PIPELINE_STEPS, DOCUMENT_CATEGORIES, type PipelineStep, type SessionListItem, type DocumentTypeResult, type DocumentCategory, type SubSession } from './types'
|
|
|
|
const KLAUSUR_API = '/klausur-api'
|
|
|
|
export default function OcrPipelinePage() {
|
|
const [currentStep, setCurrentStep] = useState(0)
|
|
const [sessionId, setSessionId] = useState<string | null>(null)
|
|
const [sessionName, setSessionName] = useState<string>('')
|
|
const [sessions, setSessions] = useState<SessionListItem[]>([])
|
|
const [loadingSessions, setLoadingSessions] = useState(true)
|
|
const [editingName, setEditingName] = useState<string | null>(null)
|
|
const [editNameValue, setEditNameValue] = useState('')
|
|
const [editingCategory, setEditingCategory] = useState<string | null>(null)
|
|
const [docTypeResult, setDocTypeResult] = useState<DocumentTypeResult | null>(null)
|
|
const [activeCategory, setActiveCategory] = useState<DocumentCategory | undefined>(undefined)
|
|
const [subSessions, setSubSessions] = useState<SubSession[]>([])
|
|
const [parentSessionId, setParentSessionId] = useState<string | null>(null)
|
|
const [steps, setSteps] = useState<PipelineStep[]>(
|
|
PIPELINE_STEPS.map((s, i) => ({
|
|
...s,
|
|
status: i === 0 ? 'active' : 'pending',
|
|
})),
|
|
)
|
|
|
|
// Load session list on mount
|
|
useEffect(() => {
|
|
loadSessions()
|
|
}, [])
|
|
|
|
const loadSessions = async () => {
|
|
setLoadingSessions(true)
|
|
try {
|
|
const res = await fetch(`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions`)
|
|
if (res.ok) {
|
|
const data = await res.json()
|
|
setSessions(data.sessions || [])
|
|
}
|
|
} catch (e) {
|
|
console.error('Failed to load sessions:', e)
|
|
} finally {
|
|
setLoadingSessions(false)
|
|
}
|
|
}
|
|
|
|
const openSession = useCallback(async (sid: string, keepSubSessions?: boolean) => {
|
|
try {
|
|
const res = await fetch(`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sid}`)
|
|
if (!res.ok) return
|
|
const data = await res.json()
|
|
|
|
setSessionId(sid)
|
|
setSessionName(data.name || data.filename || '')
|
|
setActiveCategory(data.document_category || undefined)
|
|
|
|
// Sub-session handling
|
|
if (data.sub_sessions && data.sub_sessions.length > 0) {
|
|
setSubSessions(data.sub_sessions)
|
|
setParentSessionId(sid)
|
|
} else if (data.parent_session_id) {
|
|
// This is a sub-session — keep parent info but don't reset sub-session list
|
|
setParentSessionId(data.parent_session_id)
|
|
} else if (!keepSubSessions) {
|
|
setSubSessions([])
|
|
setParentSessionId(null)
|
|
}
|
|
|
|
// Restore doc type result if available
|
|
const savedDocType: DocumentTypeResult | null = data.doc_type_result || null
|
|
setDocTypeResult(savedDocType)
|
|
|
|
// Determine which step to jump to based on current_step
|
|
const dbStep = data.current_step || 1
|
|
// DB steps: 1=start, 2=orientation, 3=deskew, 4=dewarp, 5=crop, 6=columns, ...
|
|
// UI steps are 0-indexed: 0=orientation, 1=deskew, 2=dewarp, 3=crop, 4=columns, ...
|
|
let uiStep = Math.max(0, dbStep - 1)
|
|
const skipSteps = [...(savedDocType?.skip_steps || [])]
|
|
|
|
// Sub-session handling depends on how they were created:
|
|
// - Crop-based (current_step >= 5): image already cropped, skip all pre-processing
|
|
// - Page-split (current_step 2): orientation done on parent, skip only orientation
|
|
// - Page-split from original (current_step 1): needs full pipeline
|
|
const isSubSession = !!data.parent_session_id
|
|
if (isSubSession) {
|
|
if (dbStep >= 5) {
|
|
// Crop-based sub-sessions: image already cropped
|
|
const SUB_SESSION_SKIP = ['orientation', 'deskew', 'dewarp', 'crop']
|
|
for (const s of SUB_SESSION_SKIP) {
|
|
if (!skipSteps.includes(s)) skipSteps.push(s)
|
|
}
|
|
if (uiStep < 4) uiStep = 4 // columns step (index 4)
|
|
} else if (dbStep >= 2) {
|
|
// Page-split sub-session: parent orientation applied, skip only orientation
|
|
if (!skipSteps.includes('orientation')) skipSteps.push('orientation')
|
|
}
|
|
// dbStep === 1: page-split from original image, needs full pipeline
|
|
}
|
|
|
|
setSteps(
|
|
PIPELINE_STEPS.map((s, i) => ({
|
|
...s,
|
|
status: skipSteps.includes(s.id)
|
|
? 'skipped'
|
|
: i < uiStep ? 'completed' : i === uiStep ? 'active' : 'pending',
|
|
})),
|
|
)
|
|
setCurrentStep(uiStep)
|
|
} catch (e) {
|
|
console.error('Failed to open session:', e)
|
|
}
|
|
}, [])
|
|
|
|
const deleteSession = useCallback(async (sid: string) => {
|
|
try {
|
|
await fetch(`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sid}`, { method: 'DELETE' })
|
|
setSessions((prev) => prev.filter((s) => s.id !== sid))
|
|
if (sessionId === sid) {
|
|
setSessionId(null)
|
|
setCurrentStep(0)
|
|
setDocTypeResult(null)
|
|
setSubSessions([])
|
|
setParentSessionId(null)
|
|
setSteps(PIPELINE_STEPS.map((s, i) => ({ ...s, status: i === 0 ? 'active' : 'pending' })))
|
|
}
|
|
} catch (e) {
|
|
console.error('Failed to delete session:', e)
|
|
}
|
|
}, [sessionId])
|
|
|
|
const renameSession = useCallback(async (sid: string, newName: string) => {
|
|
try {
|
|
await fetch(`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sid}`, {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ name: newName }),
|
|
})
|
|
setSessions((prev) => prev.map((s) => (s.id === sid ? { ...s, name: newName } : s)))
|
|
if (sessionId === sid) setSessionName(newName)
|
|
} catch (e) {
|
|
console.error('Failed to rename session:', e)
|
|
}
|
|
setEditingName(null)
|
|
}, [sessionId])
|
|
|
|
const updateCategory = useCallback(async (sid: string, category: DocumentCategory) => {
|
|
try {
|
|
await fetch(`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sid}`, {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ document_category: category }),
|
|
})
|
|
setSessions((prev) => prev.map((s) => (s.id === sid ? { ...s, document_category: category } : s)))
|
|
if (sessionId === sid) setActiveCategory(category)
|
|
} catch (e) {
|
|
console.error('Failed to update category:', e)
|
|
}
|
|
setEditingCategory(null)
|
|
}, [sessionId])
|
|
|
|
const deleteAllSessions = useCallback(async () => {
|
|
if (!confirm('Alle Sessions loeschen? Dies kann nicht rueckgaengig gemacht werden.')) return
|
|
try {
|
|
await fetch(`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions`, { method: 'DELETE' })
|
|
setSessions([])
|
|
setSessionId(null)
|
|
setCurrentStep(0)
|
|
setDocTypeResult(null)
|
|
setActiveCategory(undefined)
|
|
setSubSessions([])
|
|
setParentSessionId(null)
|
|
setSteps(PIPELINE_STEPS.map((s, i) => ({ ...s, status: i === 0 ? 'active' : 'pending' })))
|
|
} catch (e) {
|
|
console.error('Failed to delete all sessions:', e)
|
|
}
|
|
}, [])
|
|
|
|
const handleStepClick = (index: number) => {
|
|
if (index <= currentStep || steps[index].status === 'completed') {
|
|
setCurrentStep(index)
|
|
}
|
|
}
|
|
|
|
const goToStep = (step: number) => {
|
|
setCurrentStep(step)
|
|
setSteps((prev) =>
|
|
prev.map((s, i) => ({
|
|
...s,
|
|
status: i < step ? 'completed' : i === step ? 'active' : 'pending',
|
|
})),
|
|
)
|
|
}
|
|
|
|
const handleNext = () => {
|
|
if (currentStep >= steps.length - 1) {
|
|
// Last step completed
|
|
if (parentSessionId && sessionId !== parentSessionId) {
|
|
// Sub-session completed — update its status and stay in tab view
|
|
setSubSessions((prev) =>
|
|
prev.map((s) => s.id === sessionId ? { ...s, status: 'completed', current_step: 10 } : s)
|
|
)
|
|
// Switch back to parent
|
|
handleSessionChange(parentSessionId)
|
|
return
|
|
}
|
|
// Main session: return to session list
|
|
setSteps(PIPELINE_STEPS.map((s, i) => ({ ...s, status: i === 0 ? 'active' : 'pending' })))
|
|
setCurrentStep(0)
|
|
setSessionId(null)
|
|
setSubSessions([])
|
|
setParentSessionId(null)
|
|
loadSessions()
|
|
return
|
|
}
|
|
|
|
// Find the next non-skipped step
|
|
const skipSteps = docTypeResult?.skip_steps || []
|
|
let nextStep = currentStep + 1
|
|
while (nextStep < steps.length && skipSteps.includes(PIPELINE_STEPS[nextStep]?.id)) {
|
|
nextStep++
|
|
}
|
|
if (nextStep >= steps.length) nextStep = steps.length - 1
|
|
|
|
setSteps((prev) =>
|
|
prev.map((s, i) => {
|
|
if (i === currentStep) return { ...s, status: 'completed' }
|
|
if (i === nextStep) return { ...s, status: 'active' }
|
|
// Mark skipped steps between current and next
|
|
if (i > currentStep && i < nextStep && skipSteps.includes(PIPELINE_STEPS[i]?.id)) {
|
|
return { ...s, status: 'skipped' }
|
|
}
|
|
return s
|
|
}),
|
|
)
|
|
setCurrentStep(nextStep)
|
|
}
|
|
|
|
const handleOrientationComplete = async (sid: string) => {
|
|
setSessionId(sid)
|
|
loadSessions()
|
|
|
|
// Check for page-split sub-sessions directly from API
|
|
// (React state may not be committed yet due to batching)
|
|
try {
|
|
const res = await fetch(`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sid}`)
|
|
if (res.ok) {
|
|
const data = await res.json()
|
|
if (data.sub_sessions?.length > 0) {
|
|
const subs: SubSession[] = data.sub_sessions.map((s: SubSession) => ({
|
|
id: s.id,
|
|
name: s.name,
|
|
box_index: s.box_index,
|
|
current_step: s.current_step,
|
|
}))
|
|
setSubSessions(subs)
|
|
setParentSessionId(sid)
|
|
openSession(subs[0].id, true)
|
|
return
|
|
}
|
|
}
|
|
} catch (e) {
|
|
console.error('Failed to check for sub-sessions:', e)
|
|
}
|
|
|
|
handleNext()
|
|
}
|
|
|
|
const handleCropNext = async () => {
|
|
// Auto-detect document type after crop (last image-processing step), then advance
|
|
if (sessionId) {
|
|
try {
|
|
const res = await fetch(
|
|
`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/detect-type`,
|
|
{ method: 'POST' },
|
|
)
|
|
if (res.ok) {
|
|
const data: DocumentTypeResult = await res.json()
|
|
setDocTypeResult(data)
|
|
|
|
// Mark skipped steps immediately
|
|
const skipSteps = data.skip_steps || []
|
|
if (skipSteps.length > 0) {
|
|
setSteps((prev) =>
|
|
prev.map((s) =>
|
|
skipSteps.includes(s.id) ? { ...s, status: 'skipped' } : s,
|
|
),
|
|
)
|
|
}
|
|
}
|
|
} catch (e) {
|
|
console.error('Doc type detection failed:', e)
|
|
// Not critical — continue without it
|
|
}
|
|
}
|
|
handleNext()
|
|
}
|
|
|
|
const handleDocTypeChange = (newDocType: DocumentTypeResult['doc_type']) => {
|
|
if (!docTypeResult) return
|
|
|
|
// Build new skip_steps based on doc type
|
|
let skipSteps: string[] = []
|
|
if (newDocType === 'full_text') {
|
|
skipSteps = ['columns', 'rows']
|
|
}
|
|
// vocab_table and generic_table: no skips
|
|
|
|
const updated: DocumentTypeResult = {
|
|
...docTypeResult,
|
|
doc_type: newDocType,
|
|
skip_steps: skipSteps,
|
|
pipeline: newDocType === 'full_text' ? 'full_page' : 'cell_first',
|
|
}
|
|
setDocTypeResult(updated)
|
|
|
|
// Update step statuses
|
|
setSteps((prev) =>
|
|
prev.map((s) => {
|
|
if (skipSteps.includes(s.id)) return { ...s, status: 'skipped' as const }
|
|
if (s.status === 'skipped') return { ...s, status: 'pending' as const }
|
|
return s
|
|
}),
|
|
)
|
|
}
|
|
|
|
const handleNewSession = () => {
|
|
setSessionId(null)
|
|
setSessionName('')
|
|
setCurrentStep(0)
|
|
setDocTypeResult(null)
|
|
setSubSessions([])
|
|
setParentSessionId(null)
|
|
setSteps(PIPELINE_STEPS.map((s, i) => ({ ...s, status: i === 0 ? 'active' : 'pending' })))
|
|
}
|
|
|
|
const handleSessionChange = useCallback((newSessionId: string) => {
|
|
openSession(newSessionId, true)
|
|
}, [openSession])
|
|
|
|
const handleBoxSessionsCreated = useCallback((subs: SubSession[]) => {
|
|
setSubSessions(subs)
|
|
if (sessionId) setParentSessionId(sessionId)
|
|
}, [sessionId])
|
|
|
|
const stepNames: Record<number, string> = {
|
|
1: 'Orientierung',
|
|
2: 'Begradigung',
|
|
3: 'Entzerrung',
|
|
4: 'Zuschneiden',
|
|
5: 'Spalten',
|
|
6: 'Zeilen',
|
|
7: 'Woerter',
|
|
8: 'Struktur',
|
|
9: 'Korrektur',
|
|
10: 'Rekonstruktion',
|
|
11: 'Validierung',
|
|
}
|
|
|
|
const reprocessFromStep = useCallback(async (uiStep: number) => {
|
|
if (!sessionId) return
|
|
const dbStep = uiStep + 1 // UI is 0-indexed, DB is 1-indexed
|
|
if (!confirm(`Ab Schritt ${dbStep} (${stepNames[dbStep] || '?'}) neu verarbeiten? Nachfolgende Daten werden geloescht.`)) return
|
|
try {
|
|
const res = await fetch(`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/reprocess`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ from_step: dbStep }),
|
|
})
|
|
if (!res.ok) {
|
|
const data = await res.json().catch(() => ({}))
|
|
console.error('Reprocess failed:', data.detail || res.status)
|
|
return
|
|
}
|
|
// Reset UI steps
|
|
goToStep(uiStep)
|
|
} catch (e) {
|
|
console.error('Reprocess error:', e)
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [sessionId, goToStep])
|
|
|
|
const renderStep = () => {
|
|
switch (currentStep) {
|
|
case 0:
|
|
return <StepOrientation sessionId={sessionId} onNext={handleOrientationComplete} onSubSessionsCreated={handleBoxSessionsCreated} />
|
|
case 1:
|
|
return <StepDeskew sessionId={sessionId} onNext={handleNext} />
|
|
case 2:
|
|
return <StepDewarp sessionId={sessionId} onNext={handleNext} />
|
|
case 3:
|
|
return <StepCrop sessionId={sessionId} onNext={handleCropNext} />
|
|
case 4:
|
|
return <StepColumnDetection sessionId={sessionId} onNext={handleNext} onBoxSessionsCreated={handleBoxSessionsCreated} />
|
|
case 5:
|
|
return <StepRowDetection sessionId={sessionId} onNext={handleNext} />
|
|
case 6:
|
|
return <StepWordRecognition sessionId={sessionId} onNext={handleNext} goToStep={goToStep} />
|
|
case 7:
|
|
return <StepStructureDetection sessionId={sessionId} onNext={handleNext} />
|
|
case 8:
|
|
return <StepLlmReview sessionId={sessionId} onNext={handleNext} />
|
|
case 9:
|
|
return <StepReconstruction sessionId={sessionId} onNext={handleNext} />
|
|
case 10:
|
|
return <StepGroundTruth sessionId={sessionId} onNext={handleNext} />
|
|
default:
|
|
return null
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<PagePurpose
|
|
title="OCR Pipeline"
|
|
purpose="Schrittweise Seitenrekonstruktion: Scan begradigen, Spalten erkennen, Woerter lokalisieren und die Seite Wort fuer Wort nachbauen. Ziel: 10 Vokabelseiten fehlerfrei rekonstruieren."
|
|
audience={['Entwickler', 'Data Scientists']}
|
|
architecture={{
|
|
services: ['klausur-service (FastAPI)', 'OpenCV', 'Tesseract'],
|
|
databases: ['PostgreSQL Sessions'],
|
|
}}
|
|
relatedPages={[
|
|
{ name: 'OCR Vergleich', href: '/ai/ocr-compare', description: 'Methoden-Vergleich' },
|
|
{ name: 'OCR-Labeling', href: '/ai/ocr-labeling', description: 'Trainingsdaten' },
|
|
]}
|
|
defaultCollapsed
|
|
/>
|
|
|
|
{/* Session List */}
|
|
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-4">
|
|
<div className="flex items-center justify-between mb-3">
|
|
<h3 className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
|
Sessions ({sessions.length})
|
|
</h3>
|
|
<div className="flex gap-2">
|
|
{sessions.length > 0 && (
|
|
<button
|
|
onClick={deleteAllSessions}
|
|
className="text-xs px-3 py-1.5 text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg transition-colors"
|
|
title="Alle Sessions loeschen"
|
|
>
|
|
Alle loeschen
|
|
</button>
|
|
)}
|
|
<button
|
|
onClick={handleNewSession}
|
|
className="text-xs px-3 py-1.5 bg-teal-600 text-white rounded-lg hover:bg-teal-700 transition-colors"
|
|
>
|
|
+ Neue Session
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{loadingSessions ? (
|
|
<div className="text-sm text-gray-400 py-2">Lade Sessions...</div>
|
|
) : sessions.length === 0 ? (
|
|
<div className="text-sm text-gray-400 py-2">Noch keine Sessions vorhanden.</div>
|
|
) : (
|
|
<div className="space-y-1.5 max-h-[320px] overflow-y-auto">
|
|
{sessions.map((s) => {
|
|
const catInfo = DOCUMENT_CATEGORIES.find(c => c.value === s.document_category)
|
|
return (
|
|
<div
|
|
key={s.id}
|
|
className={`relative flex items-start gap-3 px-3 py-2.5 rounded-lg text-sm transition-colors cursor-pointer ${
|
|
sessionId === s.id
|
|
? 'bg-teal-50 dark:bg-teal-900/30 border border-teal-200 dark:border-teal-700'
|
|
: 'hover:bg-gray-50 dark:hover:bg-gray-700/50'
|
|
}`}
|
|
>
|
|
{/* Thumbnail */}
|
|
<div
|
|
className="flex-shrink-0 w-12 h-12 rounded-md overflow-hidden bg-gray-100 dark:bg-gray-700"
|
|
onClick={() => openSession(s.id)}
|
|
>
|
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
<img
|
|
src={`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${s.id}/thumbnail?size=96`}
|
|
alt=""
|
|
className="w-full h-full object-cover"
|
|
loading="lazy"
|
|
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none' }}
|
|
/>
|
|
</div>
|
|
|
|
{/* Info */}
|
|
<div className="flex-1 min-w-0" onClick={() => openSession(s.id)}>
|
|
{editingName === s.id ? (
|
|
<input
|
|
autoFocus
|
|
value={editNameValue}
|
|
onChange={(e) => setEditNameValue(e.target.value)}
|
|
onBlur={() => renameSession(s.id, editNameValue)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter') renameSession(s.id, editNameValue)
|
|
if (e.key === 'Escape') setEditingName(null)
|
|
}}
|
|
onClick={(e) => e.stopPropagation()}
|
|
className="w-full px-1 py-0.5 text-sm border rounded dark:bg-gray-700 dark:border-gray-600"
|
|
/>
|
|
) : (
|
|
<div className="truncate font-medium text-gray-700 dark:text-gray-300">
|
|
{s.name || s.filename}
|
|
</div>
|
|
)}
|
|
{/* ID row */}
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation()
|
|
navigator.clipboard.writeText(s.id)
|
|
const btn = e.currentTarget
|
|
btn.textContent = 'Kopiert!'
|
|
setTimeout(() => { btn.textContent = `ID: ${s.id.slice(0, 8)}` }, 1500)
|
|
}}
|
|
className="text-[10px] font-mono text-gray-400 hover:text-teal-500 transition-colors"
|
|
title={`Volle ID: ${s.id} — Klick zum Kopieren`}
|
|
>
|
|
ID: {s.id.slice(0, 8)}
|
|
</button>
|
|
<div className="text-xs text-gray-400 flex gap-2 mt-0.5">
|
|
<span>{new Date(s.created_at).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: '2-digit', hour: '2-digit', minute: '2-digit' })}</span>
|
|
<span>Schritt {s.current_step}: {stepNames[s.current_step] || '?'}</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Badges */}
|
|
<div className="flex flex-col gap-1 items-end flex-shrink-0" onClick={(e) => e.stopPropagation()}>
|
|
{/* Category Badge */}
|
|
<button
|
|
onClick={() => setEditingCategory(editingCategory === s.id ? null : s.id)}
|
|
className={`text-[10px] px-1.5 py-0.5 rounded-full border transition-colors ${
|
|
catInfo
|
|
? 'bg-teal-50 dark:bg-teal-900/30 border-teal-200 dark:border-teal-700 text-teal-700 dark:text-teal-300'
|
|
: 'bg-gray-50 dark:bg-gray-700 border-gray-200 dark:border-gray-600 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300'
|
|
}`}
|
|
title="Kategorie setzen"
|
|
>
|
|
{catInfo ? `${catInfo.icon} ${catInfo.label}` : '+ Kategorie'}
|
|
</button>
|
|
{/* Doc Type Badge (read-only) */}
|
|
{s.doc_type && (
|
|
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 border border-gray-200 dark:border-gray-600">
|
|
{s.doc_type}
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
{/* Action buttons */}
|
|
<div className="flex flex-col gap-0.5 flex-shrink-0">
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation()
|
|
setEditNameValue(s.name || s.filename)
|
|
setEditingName(s.id)
|
|
}}
|
|
className="p-1 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
|
title="Umbenennen"
|
|
>
|
|
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" 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>
|
|
</button>
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation()
|
|
if (confirm('Session loeschen?')) deleteSession(s.id)
|
|
}}
|
|
className="p-1 text-gray-400 hover:text-red-500"
|
|
title="Loeschen"
|
|
>
|
|
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" 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>
|
|
|
|
{/* Category dropdown (inline) */}
|
|
{editingCategory === s.id && (
|
|
<div
|
|
className="absolute right-0 top-full mt-1 z-20 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg p-2 grid grid-cols-2 gap-1 w-64"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
{DOCUMENT_CATEGORIES.map((cat) => (
|
|
<button
|
|
key={cat.value}
|
|
onClick={() => updateCategory(s.id, cat.value)}
|
|
className={`text-xs px-2 py-1.5 rounded-md text-left transition-colors ${
|
|
s.document_category === cat.value
|
|
? 'bg-teal-100 dark:bg-teal-900/40 text-teal-700 dark:text-teal-300'
|
|
: 'hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-600 dark:text-gray-400'
|
|
}`}
|
|
>
|
|
{cat.icon} {cat.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Active session info */}
|
|
{sessionId && sessionName && (
|
|
<div className="flex items-center gap-3 text-sm text-gray-500 dark:text-gray-400">
|
|
<span>Aktive Session: <span className="font-medium text-gray-700 dark:text-gray-300">{sessionName}</span></span>
|
|
{activeCategory && (() => {
|
|
const cat = DOCUMENT_CATEGORIES.find(c => c.value === activeCategory)
|
|
return cat ? <span className="text-xs px-2 py-0.5 rounded-full bg-teal-50 dark:bg-teal-900/30 border border-teal-200 dark:border-teal-700 text-teal-700 dark:text-teal-300">{cat.icon} {cat.label}</span> : null
|
|
})()}
|
|
{docTypeResult && (
|
|
<span className="text-xs px-2 py-0.5 rounded-full bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 border border-gray-200 dark:border-gray-600">
|
|
{docTypeResult.doc_type}
|
|
</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
<PipelineStepper
|
|
steps={steps}
|
|
currentStep={currentStep}
|
|
onStepClick={handleStepClick}
|
|
onReprocess={sessionId ? reprocessFromStep : undefined}
|
|
docTypeResult={docTypeResult}
|
|
onDocTypeChange={handleDocTypeChange}
|
|
/>
|
|
|
|
{subSessions.length > 0 && parentSessionId && sessionId && (
|
|
<BoxSessionTabs
|
|
parentSessionId={parentSessionId}
|
|
subSessions={subSessions}
|
|
activeSessionId={sessionId}
|
|
onSessionChange={handleSessionChange}
|
|
/>
|
|
)}
|
|
|
|
<div className="min-h-[400px]">{renderStep()}</div>
|
|
</div>
|
|
)
|
|
}
|