refactor: split cookie_screenshot_ocr.py (642 → 290 + 353 LOC)
CI / detect-changes (push) Successful in 7s
CI / branch-name (push) Has been skipped
CI / guardrail-integrity (push) Has been skipped
CI / secret-scan (push) Has been skipped
CI / dep-audit (push) Has been skipped
CI / sbom-scan (push) Has been skipped
CI / build-sha-integrity (push) Failing after 4s
CI / validate-canonical-controls (push) Successful in 11s
CI / loc-budget (push) Failing after 14s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / nodejs-build (push) Successful in 2m19s
CI / test-go (push) Has been skipped
CI / iace-gt-coverage (push) Has been skipped
CI / test-python-backend (push) Successful in 29s
CI / test-python-document-crawler (push) Has been skipped
CI / test-python-dsms-gateway (push) Has been skipped
CI / detect-changes (push) Successful in 7s
CI / branch-name (push) Has been skipped
CI / guardrail-integrity (push) Has been skipped
CI / secret-scan (push) Has been skipped
CI / dep-audit (push) Has been skipped
CI / sbom-scan (push) Has been skipped
CI / build-sha-integrity (push) Failing after 4s
CI / validate-canonical-controls (push) Successful in 11s
CI / loc-budget (push) Failing after 14s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / nodejs-build (push) Successful in 2m19s
CI / test-go (push) Has been skipped
CI / iace-gt-coverage (push) Has been skipped
CI / test-python-backend (push) Successful in 29s
CI / test-python-document-crawler (push) Has been skipped
CI / test-python-dsms-gateway (push) Has been skipped
CI hard-cap 500 LOC. cookie_screenshot_ocr.py war auf 642 gewachsen,
also gesplittet:
- cookie_screenshot_ocr_engines.py (353 LOC, NEU)
OCR-Engine-Funktionen: _slice_screenshot, Vision-LLM (qwen2.5vl),
PaddleOCR, Tesseract, parse_ocr_cookie_table, parse_vision_response,
Konstanten VISION_MODEL/OLLAMA_URL/VISION_PROMPT.
- cookie_screenshot_ocr.py (290 LOC, REWRITE)
Orchestration: capture_cookie_evidence_slices, _ocr_one_slice,
ocr_slices_extract_cookies, capture_cookie_screenshot,
extract_cookies_via_vision, cookies_to_vendor_records.
Re-Exports der Engine-Funktionen für Backward-Kompat.
Einziger externer Importer (_phase_d1_vendors_raw.py) braucht keinen
Code-Change — Public-API stabil.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,232 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Strukturierter Editor fuer JSONB-Conditions:
|
||||
* { kind: 'all'|'any', clauses: [{field, op, value}] }
|
||||
*
|
||||
* Wird im RuleEditor verwendet. Reine Praesentations-Komponente — Parent
|
||||
* verwaltet State.
|
||||
*/
|
||||
|
||||
import type {
|
||||
ClauseOperator, RuleClause, RuleCondition,
|
||||
} from '../_types'
|
||||
import { OPERATOR_LABELS, PROFILE_FIELDS } from '../_types'
|
||||
|
||||
interface Props {
|
||||
value: RuleCondition
|
||||
onChange: (next: RuleCondition) => void
|
||||
readOnly?: boolean
|
||||
}
|
||||
|
||||
export default function ConditionBuilder({ value, onChange, readOnly }: Props) {
|
||||
const setKind = (kind: 'all' | 'any') => onChange({ ...value, kind })
|
||||
const setClause = (idx: number, clause: RuleClause) => {
|
||||
const next = [...value.clauses]
|
||||
next[idx] = clause
|
||||
onChange({ ...value, clauses: next })
|
||||
}
|
||||
const addClause = () =>
|
||||
onChange({
|
||||
...value,
|
||||
clauses: [
|
||||
...value.clauses,
|
||||
{ field: PROFILE_FIELDS[0].key, op: 'eq', value: '' },
|
||||
],
|
||||
})
|
||||
const removeClause = (idx: number) =>
|
||||
onChange({ ...value, clauses: value.clauses.filter((_, i) => i !== idx) })
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-gray-600">Bedingung:</span>
|
||||
<select
|
||||
className="text-xs px-2 py-1 border border-gray-300 rounded"
|
||||
value={value.kind}
|
||||
disabled={readOnly}
|
||||
onChange={(e) => setKind(e.target.value as 'all' | 'any')}
|
||||
>
|
||||
<option value="all">ALLE Klauseln müssen zutreffen (AND)</option>
|
||||
<option value="any">MIND. EINE Klausel trifft zu (OR)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{value.clauses.length === 0 && (
|
||||
<div className="text-xs text-gray-500 italic px-1">
|
||||
Keine Klauseln — Regel gilt für jedes Profil.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ul className="space-y-1">
|
||||
{value.clauses.map((clause, idx) => (
|
||||
<li key={idx} className="flex items-start gap-1 p-1.5 bg-gray-50 rounded border border-gray-200">
|
||||
<ClauseRow
|
||||
clause={clause}
|
||||
onChange={(c) => setClause(idx, c)}
|
||||
readOnly={!!readOnly}
|
||||
/>
|
||||
{!readOnly && (
|
||||
<button
|
||||
className="text-xs px-1.5 py-0.5 text-rose-700 hover:bg-rose-50 rounded"
|
||||
onClick={() => removeClause(idx)}
|
||||
title="Klausel entfernen"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{!readOnly && (
|
||||
<button
|
||||
className="text-xs px-2 py-1 border border-gray-300 rounded text-gray-700 hover:bg-gray-50"
|
||||
onClick={addClause}
|
||||
>
|
||||
+ Klausel hinzufügen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ClauseRow({
|
||||
clause, onChange, readOnly,
|
||||
}: {
|
||||
clause: RuleClause
|
||||
onChange: (c: RuleClause) => void
|
||||
readOnly: boolean
|
||||
}) {
|
||||
const field = PROFILE_FIELDS.find((f) => f.key === clause.field) || PROFILE_FIELDS[0]
|
||||
const operators: ClauseOperator[] =
|
||||
field.type === 'enum'
|
||||
? ['eq', 'neq', 'in', 'not_in', 'exists', 'truthy', 'falsy']
|
||||
: field.type === 'boolean'
|
||||
? ['truthy', 'falsy', 'eq', 'neq']
|
||||
: field.type === 'number'
|
||||
? ['eq', 'neq', 'gt', 'gte', 'lt', 'lte']
|
||||
: ['eq', 'neq', 'in', 'not_in', 'exists']
|
||||
|
||||
const requiresValue = !['exists', 'truthy', 'falsy'].includes(clause.op)
|
||||
const multiValue = clause.op === 'in' || clause.op === 'not_in'
|
||||
|
||||
return (
|
||||
<div className="flex-1 grid grid-cols-12 gap-1 items-center text-xs">
|
||||
<select
|
||||
className="col-span-4 px-1 py-0.5 border border-gray-300 rounded bg-white truncate"
|
||||
value={clause.field}
|
||||
disabled={readOnly}
|
||||
onChange={(e) => onChange({ ...clause, field: e.target.value })}
|
||||
>
|
||||
{PROFILE_FIELDS.map((f) => (
|
||||
<option key={f.key} value={f.key}>{f.label} ({f.key})</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<select
|
||||
className="col-span-3 px-1 py-0.5 border border-gray-300 rounded bg-white"
|
||||
value={clause.op}
|
||||
disabled={readOnly}
|
||||
onChange={(e) => onChange({ ...clause, op: e.target.value as ClauseOperator })}
|
||||
>
|
||||
{operators.map((op) => (
|
||||
<option key={op} value={op}>{OPERATOR_LABELS[op]}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<div className="col-span-5">
|
||||
{requiresValue && (
|
||||
<ValueInput
|
||||
field={field}
|
||||
multi={multiValue}
|
||||
value={clause.value}
|
||||
onChange={(v) => onChange({ ...clause, value: v })}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ValueInput({
|
||||
field, multi, value, onChange, readOnly,
|
||||
}: {
|
||||
field: typeof PROFILE_FIELDS[number]
|
||||
multi: boolean
|
||||
value: unknown
|
||||
onChange: (v: unknown) => void
|
||||
readOnly: boolean
|
||||
}) {
|
||||
if (field.type === 'enum' && field.options) {
|
||||
if (multi) {
|
||||
const selected = Array.isArray(value) ? (value as string[]) : []
|
||||
return (
|
||||
<select
|
||||
multiple
|
||||
className="w-full px-1 py-0.5 border border-gray-300 rounded bg-white h-16"
|
||||
value={selected}
|
||||
disabled={readOnly}
|
||||
onChange={(e) => {
|
||||
const opts = Array.from(e.target.selectedOptions, (o) => o.value)
|
||||
onChange(opts)
|
||||
}}
|
||||
>
|
||||
{field.options.map((o) => (
|
||||
<option key={o.value} value={o.value}>{o.label}</option>
|
||||
))}
|
||||
</select>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<select
|
||||
className="w-full px-1 py-0.5 border border-gray-300 rounded bg-white"
|
||||
value={typeof value === 'string' ? value : ''}
|
||||
disabled={readOnly}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
>
|
||||
<option value="">— wählen —</option>
|
||||
{field.options.map((o) => (
|
||||
<option key={o.value} value={o.value}>{o.label}</option>
|
||||
))}
|
||||
</select>
|
||||
)
|
||||
}
|
||||
|
||||
if (field.type === 'number') {
|
||||
return (
|
||||
<input
|
||||
type="number"
|
||||
className="w-full px-1 py-0.5 border border-gray-300 rounded"
|
||||
value={typeof value === 'number' ? value : 0}
|
||||
disabled={readOnly}
|
||||
onChange={(e) => onChange(Number(e.target.value))}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (field.type === 'boolean') {
|
||||
return (
|
||||
<select
|
||||
className="w-full px-1 py-0.5 border border-gray-300 rounded bg-white"
|
||||
value={value ? 'true' : 'false'}
|
||||
disabled={readOnly}
|
||||
onChange={(e) => onChange(e.target.value === 'true')}
|
||||
>
|
||||
<option value="true">true</option>
|
||||
<option value="false">false</option>
|
||||
</select>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<input
|
||||
type="text"
|
||||
className="w-full px-1 py-0.5 border border-gray-300 rounded"
|
||||
value={typeof value === 'string' ? value : ''}
|
||||
disabled={readOnly}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,414 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Rechte Spalte: Detail-Editor fuer die ausgewaehlte Regel.
|
||||
*
|
||||
* - Zeigt Live-Version + offenen Draft (falls vorhanden)
|
||||
* - Erlaubt Draft-Edit (classification, conditions, source_citation, rationale)
|
||||
* - Buttons: "Neuen Draft starten" (kopiert von Live), "Einreichen" (mit Pflicht
|
||||
* change_summary-Modal), "Intern freigeben" (DSB), "Publish" (= Mandanten-Freigabe)
|
||||
* - Versionshistorie + Approval-Trail unten als Akkordeon
|
||||
*/
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import type {
|
||||
ApprovalHistoryEntry, Classification, Rule, RuleCondition, RuleVersion,
|
||||
} from '../_types'
|
||||
import { CLASSIFICATION_LABELS, STATUS_LABELS } from '../_types'
|
||||
import ConditionBuilder from './ConditionBuilder'
|
||||
|
||||
interface Props {
|
||||
rule: Rule
|
||||
versions: RuleVersion[]
|
||||
history: ApprovalHistoryEntry[]
|
||||
onCreateDraft: (payload: {
|
||||
classification: Classification
|
||||
conditions: RuleCondition
|
||||
source_citation: string
|
||||
rationale?: string | null
|
||||
}) => Promise<void>
|
||||
onUpdateDraft: (versionId: string, patch: {
|
||||
classification?: Classification
|
||||
conditions?: RuleCondition
|
||||
source_citation?: string
|
||||
rationale?: string | null
|
||||
}) => Promise<void>
|
||||
onSubmitForReview: (versionId: string, changeSummary: string) => Promise<void>
|
||||
onApprove: (versionId: string) => Promise<void>
|
||||
onPublish: (versionId: string) => Promise<void>
|
||||
onReject: (versionId: string, reason: string) => Promise<void>
|
||||
}
|
||||
|
||||
export default function RuleEditor({
|
||||
rule, versions, history,
|
||||
onCreateDraft, onUpdateDraft,
|
||||
onSubmitForReview, onApprove, onPublish, onReject,
|
||||
}: Props) {
|
||||
const liveVersion = useMemo(
|
||||
() => versions.find((v) => v.is_live) || null,
|
||||
[versions],
|
||||
)
|
||||
const draftVersion = useMemo(
|
||||
() => versions.find((v) => ['draft', 'review'].includes(v.status)) || null,
|
||||
[versions],
|
||||
)
|
||||
|
||||
// Edit-State
|
||||
const [classification, setClassification] = useState<Classification>('required')
|
||||
const [conditions, setConditions] = useState<RuleCondition>({ kind: 'all', clauses: [] })
|
||||
const [sourceCitation, setSourceCitation] = useState('')
|
||||
const [rationale, setRationale] = useState('')
|
||||
|
||||
// Modal-State
|
||||
const [showSubmit, setShowSubmit] = useState(false)
|
||||
const [changeSummary, setChangeSummary] = useState('')
|
||||
const [showHistory, setShowHistory] = useState(false)
|
||||
const [rejectReason, setRejectReason] = useState('')
|
||||
const [showReject, setShowReject] = useState(false)
|
||||
|
||||
// Sync Edit-State mit ausgewaehltem Version (Draft hat Vorrang)
|
||||
const sourceVersion = draftVersion || liveVersion
|
||||
useEffect(() => {
|
||||
if (sourceVersion) {
|
||||
setClassification(sourceVersion.classification)
|
||||
setConditions(sourceVersion.conditions)
|
||||
setSourceCitation(sourceVersion.source_citation)
|
||||
setRationale(sourceVersion.rationale || '')
|
||||
}
|
||||
}, [sourceVersion?.id])
|
||||
|
||||
const isDraftMode = !!draftVersion && draftVersion.status === 'draft'
|
||||
const isReviewMode = !!draftVersion && draftVersion.status === 'review'
|
||||
const readOnly = !isDraftMode
|
||||
|
||||
const handleCreateDraft = () => {
|
||||
onCreateDraft({
|
||||
classification: liveVersion?.classification || 'recommended',
|
||||
conditions: liveVersion?.conditions || { kind: 'all', clauses: [] },
|
||||
source_citation: liveVersion?.source_citation || '',
|
||||
rationale: liveVersion?.rationale,
|
||||
})
|
||||
}
|
||||
|
||||
const handleSaveDraft = () => {
|
||||
if (!draftVersion) return
|
||||
onUpdateDraft(draftVersion.id, {
|
||||
classification, conditions, source_citation: sourceCitation, rationale,
|
||||
})
|
||||
}
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!draftVersion || !changeSummary.trim()) return
|
||||
onSubmitForReview(draftVersion.id, changeSummary.trim())
|
||||
setShowSubmit(false)
|
||||
setChangeSummary('')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col overflow-hidden bg-white">
|
||||
<header className="px-5 py-3 border-b border-gray-200">
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-base font-semibold text-gray-800 truncate">{rule.title}</h2>
|
||||
<div className="text-xs text-gray-500">
|
||||
<code>{rule.document_type}</code> · {rule.rule_key}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-gray-600">
|
||||
{liveVersion && (
|
||||
<span>
|
||||
Live: v{liveVersion.version_number} (
|
||||
<code>{liveVersion.classification}</code>)
|
||||
</span>
|
||||
)}
|
||||
{draftVersion && (
|
||||
<span className="px-1.5 py-0.5 bg-amber-100 text-amber-800 rounded border border-amber-300">
|
||||
Draft v{draftVersion.version_number} · {STATUS_LABELS[draftVersion.status]}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-5 space-y-4">
|
||||
{!draftVersion && (
|
||||
<div className="bg-amber-50 border border-amber-200 rounded p-3 flex items-center justify-between">
|
||||
<span className="text-sm text-amber-800">
|
||||
Kein offener Draft. Starte einen neuen Draft, um die Regel zu ändern.
|
||||
</span>
|
||||
<button
|
||||
className="px-3 py-1.5 text-sm bg-amber-600 text-white rounded hover:bg-amber-700"
|
||||
onClick={handleCreateDraft}
|
||||
>
|
||||
+ Neuen Draft starten
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Klassifikation */}
|
||||
<section>
|
||||
<label className="text-xs font-medium text-gray-700 block mb-1">
|
||||
Klassifikation
|
||||
</label>
|
||||
<select
|
||||
className="text-sm px-2 py-1 border border-gray-300 rounded"
|
||||
value={classification}
|
||||
disabled={readOnly}
|
||||
onChange={(e) => setClassification(e.target.value as Classification)}
|
||||
>
|
||||
{(['required', 'recommended', 'optional'] as const).map((c) => (
|
||||
<option key={c} value={c}>{CLASSIFICATION_LABELS[c]}</option>
|
||||
))}
|
||||
</select>
|
||||
</section>
|
||||
|
||||
{/* Bedingung */}
|
||||
<section>
|
||||
<label className="text-xs font-medium text-gray-700 block mb-1">
|
||||
Bedingung
|
||||
</label>
|
||||
<ConditionBuilder
|
||||
value={conditions}
|
||||
onChange={setConditions}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* Source Citation (Pflicht) */}
|
||||
<section>
|
||||
<label className="text-xs font-medium text-gray-700 block mb-1">
|
||||
Quelle / Norm-Citation <span className="text-rose-600">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="w-full text-sm px-2 py-1.5 border border-gray-300 rounded"
|
||||
placeholder="z.B. § 12 HinSchG, Art. 28 DSGVO, EuGH C-311/18"
|
||||
value={sourceCitation}
|
||||
disabled={readOnly}
|
||||
onChange={(e) => setSourceCitation(e.target.value)}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* Rationale */}
|
||||
<section>
|
||||
<label className="text-xs font-medium text-gray-700 block mb-1">
|
||||
Begründung / Rationale (optional)
|
||||
</label>
|
||||
<textarea
|
||||
className="w-full text-sm px-2 py-1.5 border border-gray-300 rounded"
|
||||
rows={3}
|
||||
placeholder="Anwalts-Kommentar, warum die Regel so klassifiziert ist…"
|
||||
value={rationale}
|
||||
disabled={readOnly}
|
||||
onChange={(e) => setRationale(e.target.value)}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* Versionshistorie */}
|
||||
<section>
|
||||
<button
|
||||
className="text-xs text-gray-600 hover:text-gray-800"
|
||||
onClick={() => setShowHistory((v) => !v)}
|
||||
>
|
||||
{showHistory ? '▾' : '▸'} Versionshistorie + Approval-Trail ({versions.length} Versionen)
|
||||
</button>
|
||||
{showHistory && (
|
||||
<HistoryList versions={versions} history={history} />
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* Footer-Aktionen */}
|
||||
<footer className="px-5 py-3 border-t border-gray-200 bg-gray-50 flex items-center gap-2 flex-wrap">
|
||||
{isDraftMode && (
|
||||
<>
|
||||
<button
|
||||
className="px-3 py-1.5 text-sm border border-gray-300 rounded text-gray-700 hover:bg-white"
|
||||
onClick={handleSaveDraft}
|
||||
>
|
||||
Draft speichern
|
||||
</button>
|
||||
<button
|
||||
className="px-3 py-1.5 text-sm bg-amber-600 text-white rounded hover:bg-amber-700 disabled:opacity-50"
|
||||
disabled={!sourceCitation.trim()}
|
||||
onClick={() => setShowSubmit(true)}
|
||||
title={!sourceCitation.trim() ? 'Source Citation ist Pflicht' : ''}
|
||||
>
|
||||
Zur internen Prüfung einreichen
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{isReviewMode && (
|
||||
<>
|
||||
<button
|
||||
className="px-3 py-1.5 text-sm bg-emerald-600 text-white rounded hover:bg-emerald-700"
|
||||
onClick={() => draftVersion && onApprove(draftVersion.id)}
|
||||
>
|
||||
Intern freigeben → Mandant
|
||||
</button>
|
||||
<button
|
||||
className="px-3 py-1.5 text-sm bg-blue-600 text-white rounded hover:bg-blue-700"
|
||||
onClick={() => draftVersion && onPublish(draftVersion.id)}
|
||||
title="Wird sofort live (Test-Modus)"
|
||||
>
|
||||
Publish (sofort live)
|
||||
</button>
|
||||
<button
|
||||
className="px-3 py-1.5 text-sm border border-rose-300 text-rose-700 rounded hover:bg-rose-50"
|
||||
onClick={() => setShowReject(true)}
|
||||
>
|
||||
Ablehnen
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</footer>
|
||||
|
||||
{showSubmit && (
|
||||
<SubmitDialog
|
||||
value={changeSummary}
|
||||
onChange={setChangeSummary}
|
||||
onCancel={() => setShowSubmit(false)}
|
||||
onSubmit={handleSubmit}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showReject && (
|
||||
<RejectDialog
|
||||
value={rejectReason}
|
||||
onChange={setRejectReason}
|
||||
onCancel={() => { setShowReject(false); setRejectReason('') }}
|
||||
onSubmit={() => {
|
||||
if (!draftVersion || !rejectReason.trim()) return
|
||||
onReject(draftVersion.id, rejectReason.trim())
|
||||
setShowReject(false); setRejectReason('')
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function HistoryList({ versions, history }: { versions: RuleVersion[]; history: ApprovalHistoryEntry[] }) {
|
||||
return (
|
||||
<div className="mt-2 space-y-2 text-xs">
|
||||
<div>
|
||||
<div className="font-medium text-gray-700 mb-1">Versionen:</div>
|
||||
<ul className="space-y-1">
|
||||
{versions.map((v) => (
|
||||
<li key={v.id} className="bg-white border border-gray-200 rounded p-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">v{v.version_number}</span>
|
||||
<span className="px-1.5 py-0.5 bg-gray-100 rounded">{STATUS_LABELS[v.status]}</span>
|
||||
{v.is_live && <span className="text-emerald-700">● Live</span>}
|
||||
<span className="text-gray-500 ml-auto">
|
||||
{new Date(v.created_at).toLocaleString('de-DE')}
|
||||
</span>
|
||||
</div>
|
||||
{v.change_summary && (
|
||||
<div className="mt-1 text-gray-600">Änderung: {v.change_summary}</div>
|
||||
)}
|
||||
{v.source_citation && (
|
||||
<div className="mt-0.5 text-gray-500">Quelle: {v.source_citation}</div>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium text-gray-700 mb-1">Approval-Trail:</div>
|
||||
<ul className="space-y-0.5">
|
||||
{history.map((h) => (
|
||||
<li key={h.id} className="text-gray-600">
|
||||
{new Date(h.created_at).toLocaleString('de-DE')} · {h.action}
|
||||
{h.approver && ` · ${h.approver}`}
|
||||
{h.comment && ` — ${h.comment}`}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SubmitDialog({
|
||||
value, onChange, onCancel, onSubmit,
|
||||
}: {
|
||||
value: string
|
||||
onChange: (s: string) => void
|
||||
onCancel: () => void
|
||||
onSubmit: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/30 z-50 flex items-center justify-center" onClick={onCancel}>
|
||||
<div className="bg-white rounded-lg shadow-xl w-[520px]" onClick={(e) => e.stopPropagation()}>
|
||||
<header className="px-5 py-3 border-b border-gray-200">
|
||||
<h3 className="font-semibold">Zur internen Prüfung einreichen</h3>
|
||||
</header>
|
||||
<div className="p-5">
|
||||
<label className="text-xs font-medium text-gray-700">
|
||||
Was wurde geändert? <span className="text-rose-600">*</span>
|
||||
</label>
|
||||
<textarea
|
||||
autoFocus
|
||||
rows={4}
|
||||
className="w-full mt-1 text-sm px-2 py-1.5 border border-gray-300 rounded"
|
||||
placeholder="z.B. Schwelle auf 50 MA angehoben (BAG-Urteil X)"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<footer className="px-5 py-3 border-t border-gray-200 flex justify-end gap-2">
|
||||
<button className="px-3 py-1.5 text-sm text-gray-600" onClick={onCancel}>Abbrechen</button>
|
||||
<button
|
||||
className="px-4 py-1.5 text-sm bg-amber-600 text-white rounded disabled:opacity-50"
|
||||
disabled={!value.trim()}
|
||||
onClick={onSubmit}
|
||||
>
|
||||
Einreichen
|
||||
</button>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RejectDialog({
|
||||
value, onChange, onCancel, onSubmit,
|
||||
}: {
|
||||
value: string
|
||||
onChange: (s: string) => void
|
||||
onCancel: () => void
|
||||
onSubmit: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/30 z-50 flex items-center justify-center" onClick={onCancel}>
|
||||
<div className="bg-white rounded-lg shadow-xl w-[480px]" onClick={(e) => e.stopPropagation()}>
|
||||
<header className="px-5 py-3 border-b border-gray-200">
|
||||
<h3 className="font-semibold">Draft ablehnen</h3>
|
||||
</header>
|
||||
<div className="p-5">
|
||||
<label className="text-xs font-medium text-gray-700">
|
||||
Ablehnungsgrund <span className="text-rose-600">*</span>
|
||||
</label>
|
||||
<textarea
|
||||
autoFocus
|
||||
rows={3}
|
||||
className="w-full mt-1 text-sm px-2 py-1.5 border border-gray-300 rounded"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<footer className="px-5 py-3 border-t border-gray-200 flex justify-end gap-2">
|
||||
<button className="px-3 py-1.5 text-sm text-gray-600" onClick={onCancel}>Abbrechen</button>
|
||||
<button
|
||||
className="px-4 py-1.5 text-sm bg-rose-600 text-white rounded disabled:opacity-50"
|
||||
disabled={!value.trim()}
|
||||
onClick={onSubmit}
|
||||
>
|
||||
Ablehnen
|
||||
</button>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Linke Spalte: Liste der globalen Empfehlungs-Regeln.
|
||||
*
|
||||
* Filterbar nach document_type. Klassifikations-Chip + Live-Indikator.
|
||||
*/
|
||||
|
||||
import { useMemo, useState } from 'react'
|
||||
import type { Rule, RuleVersion } from '../_types'
|
||||
import { CLASSIFICATION_LABELS, STATUS_LABELS } from '../_types'
|
||||
|
||||
interface Props {
|
||||
rules: Rule[]
|
||||
versionsByRule: Record<string, RuleVersion | undefined>
|
||||
selectedRuleId: string | null
|
||||
onSelectRule: (ruleId: string) => void
|
||||
}
|
||||
|
||||
export default function RuleList({
|
||||
rules, versionsByRule, selectedRuleId, onSelectRule,
|
||||
}: Props) {
|
||||
const [filter, setFilter] = useState('')
|
||||
const filtered = useMemo(() => {
|
||||
if (!filter.trim()) return rules
|
||||
const q = filter.toLowerCase()
|
||||
return rules.filter(
|
||||
(r) =>
|
||||
r.title.toLowerCase().includes(q) ||
|
||||
r.rule_key.toLowerCase().includes(q) ||
|
||||
r.document_type.toLowerCase().includes(q),
|
||||
)
|
||||
}, [rules, filter])
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col overflow-hidden border-r border-gray-200 bg-gray-50">
|
||||
<div className="p-3 border-b border-gray-200 bg-white">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Suchen (Titel, Key, Doc-Type)…"
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
className="w-full text-sm px-2 py-1.5 border border-gray-300 rounded"
|
||||
/>
|
||||
<div className="text-xs text-gray-500 mt-1">
|
||||
{filtered.length} von {rules.length} Regeln
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul className="flex-1 overflow-y-auto">
|
||||
{filtered.map((rule) => {
|
||||
const live = versionsByRule[rule.id]
|
||||
const isSelected = rule.id === selectedRuleId
|
||||
return (
|
||||
<li key={rule.id}>
|
||||
<button
|
||||
className={`w-full text-left px-3 py-2 border-b border-gray-100 hover:bg-white ${
|
||||
isSelected ? 'bg-white border-l-4 border-l-amber-500' : ''
|
||||
}`}
|
||||
onClick={() => onSelectRule(rule.id)}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-0.5">
|
||||
{live && (
|
||||
<ClassificationChip classification={live.classification} />
|
||||
)}
|
||||
{!live && (
|
||||
<span className="px-1.5 py-0.5 text-xs rounded bg-gray-200 text-gray-600">
|
||||
ohne Live-Version
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm font-medium text-gray-800 truncate">
|
||||
{rule.title}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 truncate">
|
||||
<code>{rule.document_type}</code> · {rule.rule_key}
|
||||
</div>
|
||||
{live && (
|
||||
<div className="text-[10px] text-gray-500 mt-0.5">
|
||||
v{live.version_number} · {STATUS_LABELS[live.status]}
|
||||
{live.is_live && (
|
||||
<span className="ml-1 inline-block w-1.5 h-1.5 bg-emerald-500 rounded-full" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
{filtered.length === 0 && (
|
||||
<li className="px-3 py-4 text-sm text-gray-500 italic">
|
||||
Keine Regeln gefunden.
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ClassificationChip({ classification }: { classification: 'required' | 'recommended' | 'optional' }) {
|
||||
const colorMap = {
|
||||
required: 'bg-rose-100 text-rose-800 border-rose-300',
|
||||
recommended: 'bg-amber-100 text-amber-800 border-amber-300',
|
||||
optional: 'bg-slate-100 text-slate-700 border-slate-300',
|
||||
} as const
|
||||
return (
|
||||
<span className={`px-1.5 py-0.5 text-[10px] font-medium rounded border ${colorMap[classification]}`}>
|
||||
{CLASSIFICATION_LABELS[classification]}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user