feat: 6 Dokumentations-Module auf 100% — VVT Backend, Filter, PDF-Export
Phase 1 — VVT Backend (localStorage → API): - migrations/006_vvt.sql: Neue Tabellen (vvt_organization, vvt_activities, vvt_audit_log) - compliance/db/vvt_models.py: SQLAlchemy-Models für alle VVT-Tabellen - compliance/api/vvt_routes.py: Vollständiger CRUD-Router (10 Endpoints) - compliance/api/__init__.py: VVT-Router registriert - compliance/api/schemas.py: VVT Pydantic-Schemas ergänzt - app/(sdk)/sdk/vvt/page.tsx: API-Client + camelCase↔snake_case Mapping, localStorage durch persistente DB-Calls ersetzt (POST/PUT/DELETE/GET) - tests/test_vvt_routes.py: 18 Tests (alle grün) Phase 3 — Document Generator PDF-Export: - document-generator/page.tsx: "Als PDF exportieren"-Button funktioniert jetzt via window.print() + Print-Window mit korrektem HTML - Fallback-Banner wenn Template-Service (breakpilot-core) nicht erreichbar Phase 4 — Source Policy erweiterte Filter: - SourcesTab.tsx: source_type-Filter (Rechtlich / Leitlinien / Vorlagen / etc.) - PIIRulesTab.tsx: category-Filter (E-Mail / Telefon / IBAN / etc.) - source_policy_router.py: Backend-Endpoints unterstützen jetzt source_type und category als Query-Parameter - requirements.txt: reportlab==4.2.5 ergänzt (fehlende Audit-PDF-Dependency) Phase 2 — Training (Migration-Skripte): - scripts/apply_training_migrations.sh: SSH-Skript für Mac Mini - scripts/apply_vvt_migration.sh: Vollständiges Deploy-Skript für VVT Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -454,6 +454,13 @@ export default function DocumentGeneratorPage() {
|
||||
</button>
|
||||
</StepHeader>
|
||||
|
||||
{/* Service unreachable warning */}
|
||||
{!isLoading && !status && (
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-xl p-4 text-sm text-amber-800">
|
||||
<strong>Template-Service nicht erreichbar.</strong> Stellen Sie sicher, dass breakpilot-core läuft (<code>curl -sf http://macmini:8099/health</code>). Das Suchen und Zusammenstellen von Vorlagen ist erst nach Verbindung möglich.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Status Overview */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
@@ -737,6 +744,24 @@ export default function DocumentGeneratorPage() {
|
||||
Kopieren
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
const printWindow = window.open('', '_blank')
|
||||
if (!printWindow) return
|
||||
let content = combinedContent
|
||||
for (const [key, value] of Object.entries(placeholderValues)) {
|
||||
if (value) {
|
||||
content = content.replace(new RegExp(key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), value)
|
||||
}
|
||||
}
|
||||
const attributions = selectedTemplateObjects
|
||||
.filter(t => t.attributionRequired && t.attributionText)
|
||||
.map(t => `<li>${t.attributionText}</li>`)
|
||||
.join('')
|
||||
printWindow.document.write(`<!DOCTYPE html><html lang="de"><head><meta charset="UTF-8"><title>Datenschutzdokument</title><style>body{font-family:Arial,sans-serif;max-width:800px;margin:40px auto;padding:0 20px;line-height:1.6;color:#222}h2{border-bottom:1px solid #ccc;padding-bottom:8px;margin-top:32px}hr{border:none;border-top:1px solid #eee;margin:24px 0}.attribution{font-size:12px;color:#666;margin-top:48px;border-top:1px solid #ddd;padding-top:16px}@media print{body{margin:0}}</style></head><body><div style="white-space:pre-wrap">${content.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>')}</div>${attributions ? `<div class="attribution"><strong>Quellenangaben:</strong><ul>${attributions}</ul></div>` : ''}</body></html>`)
|
||||
printWindow.document.close()
|
||||
printWindow.focus()
|
||||
setTimeout(() => printWindow.print(), 500)
|
||||
}}
|
||||
className="px-6 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
|
||||
>
|
||||
Als PDF exportieren
|
||||
|
||||
@@ -47,25 +47,149 @@ import type { ProfilingAnswers } from '@/lib/sdk/vvt-profiling'
|
||||
|
||||
type Tab = 'verzeichnis' | 'editor' | 'generator' | 'export'
|
||||
|
||||
const STORAGE_KEY = 'bp_vvt'
|
||||
const PROFILING_STORAGE_KEY = 'bp_vvt_profiling'
|
||||
|
||||
interface VVTData {
|
||||
activities: VVTActivity[]
|
||||
orgHeader: VVTOrganizationHeader
|
||||
profilingAnswers: ProfilingAnswers
|
||||
// =============================================================================
|
||||
// API CLIENT
|
||||
// =============================================================================
|
||||
|
||||
const VVT_API_BASE = '/api/sdk/v1/compliance/vvt'
|
||||
|
||||
function activityFromApi(raw: any): VVTActivity {
|
||||
return {
|
||||
id: raw.id,
|
||||
vvtId: raw.vvt_id,
|
||||
name: raw.name || '',
|
||||
description: raw.description || '',
|
||||
purposes: raw.purposes || [],
|
||||
legalBases: raw.legal_bases || [],
|
||||
dataSubjectCategories: raw.data_subject_categories || [],
|
||||
personalDataCategories: raw.personal_data_categories || [],
|
||||
recipientCategories: raw.recipient_categories || [],
|
||||
thirdCountryTransfers: raw.third_country_transfers || [],
|
||||
retentionPeriod: raw.retention_period || { description: '' },
|
||||
tomDescription: raw.tom_description || '',
|
||||
businessFunction: raw.business_function || 'other',
|
||||
systems: raw.systems || [],
|
||||
deploymentModel: raw.deployment_model || 'cloud',
|
||||
dataSources: raw.data_sources || [],
|
||||
dataFlows: raw.data_flows || [],
|
||||
protectionLevel: raw.protection_level || 'MEDIUM',
|
||||
dpiaRequired: raw.dpia_required || false,
|
||||
structuredToms: raw.structured_toms || { accessControl: [], confidentiality: [], integrity: [], availability: [], separation: [] },
|
||||
status: raw.status || 'DRAFT',
|
||||
responsible: raw.responsible || '',
|
||||
owner: raw.owner || '',
|
||||
createdAt: raw.created_at || new Date().toISOString(),
|
||||
updatedAt: raw.updated_at || raw.created_at || new Date().toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
function loadData(): VVTData {
|
||||
if (typeof window === 'undefined') return { activities: [], orgHeader: createDefaultOrgHeader(), profilingAnswers: {} }
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY)
|
||||
if (stored) return JSON.parse(stored)
|
||||
} catch { /* ignore */ }
|
||||
return { activities: [], orgHeader: createDefaultOrgHeader(), profilingAnswers: {} }
|
||||
function activityToApi(act: VVTActivity): Record<string, unknown> {
|
||||
return {
|
||||
vvt_id: act.vvtId,
|
||||
name: act.name,
|
||||
description: act.description,
|
||||
purposes: act.purposes,
|
||||
legal_bases: act.legalBases,
|
||||
data_subject_categories: act.dataSubjectCategories,
|
||||
personal_data_categories: act.personalDataCategories,
|
||||
recipient_categories: act.recipientCategories,
|
||||
third_country_transfers: act.thirdCountryTransfers,
|
||||
retention_period: act.retentionPeriod,
|
||||
tom_description: act.tomDescription,
|
||||
business_function: act.businessFunction,
|
||||
systems: act.systems,
|
||||
deployment_model: act.deploymentModel,
|
||||
data_sources: act.dataSources,
|
||||
data_flows: act.dataFlows,
|
||||
protection_level: act.protectionLevel,
|
||||
dpia_required: act.dpiaRequired,
|
||||
structured_toms: act.structuredToms,
|
||||
status: act.status,
|
||||
responsible: act.responsible,
|
||||
owner: act.owner,
|
||||
}
|
||||
}
|
||||
|
||||
function saveData(data: VVTData) {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(data))
|
||||
function orgHeaderFromApi(raw: any): VVTOrganizationHeader {
|
||||
return {
|
||||
organizationName: raw.organization_name || '',
|
||||
industry: raw.industry || '',
|
||||
locations: raw.locations || [],
|
||||
employeeCount: raw.employee_count || 0,
|
||||
dpoName: raw.dpo_name || '',
|
||||
dpoContact: raw.dpo_contact || '',
|
||||
vvtVersion: raw.vvt_version || '1.0',
|
||||
lastReviewDate: raw.last_review_date || '',
|
||||
nextReviewDate: raw.next_review_date || '',
|
||||
reviewInterval: raw.review_interval || 'annual',
|
||||
}
|
||||
}
|
||||
|
||||
function orgHeaderToApi(org: VVTOrganizationHeader): Record<string, unknown> {
|
||||
return {
|
||||
organization_name: org.organizationName,
|
||||
industry: org.industry,
|
||||
locations: org.locations,
|
||||
employee_count: org.employeeCount,
|
||||
dpo_name: org.dpoName,
|
||||
dpo_contact: org.dpoContact,
|
||||
vvt_version: org.vvtVersion,
|
||||
last_review_date: org.lastReviewDate || null,
|
||||
next_review_date: org.nextReviewDate || null,
|
||||
review_interval: org.reviewInterval,
|
||||
}
|
||||
}
|
||||
|
||||
async function apiListActivities(): Promise<VVTActivity[]> {
|
||||
const res = await fetch(`${VVT_API_BASE}/activities`)
|
||||
if (!res.ok) throw new Error(`GET activities failed: ${res.status}`)
|
||||
const data = await res.json()
|
||||
return data.map(activityFromApi)
|
||||
}
|
||||
|
||||
async function apiGetOrganization(): Promise<VVTOrganizationHeader | null> {
|
||||
const res = await fetch(`${VVT_API_BASE}/organization`)
|
||||
if (!res.ok) return null
|
||||
const data = await res.json()
|
||||
if (!data) return null
|
||||
return orgHeaderFromApi(data)
|
||||
}
|
||||
|
||||
async function apiCreateActivity(act: VVTActivity): Promise<VVTActivity> {
|
||||
const res = await fetch(`${VVT_API_BASE}/activities`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(activityToApi(act)),
|
||||
})
|
||||
if (!res.ok) throw new Error(`POST activity failed: ${res.status}`)
|
||||
return activityFromApi(await res.json())
|
||||
}
|
||||
|
||||
async function apiUpdateActivity(id: string, act: VVTActivity): Promise<VVTActivity> {
|
||||
const res = await fetch(`${VVT_API_BASE}/activities/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(activityToApi(act)),
|
||||
})
|
||||
if (!res.ok) throw new Error(`PUT activity failed: ${res.status}`)
|
||||
return activityFromApi(await res.json())
|
||||
}
|
||||
|
||||
async function apiDeleteActivity(id: string): Promise<void> {
|
||||
const res = await fetch(`${VVT_API_BASE}/activities/${id}`, { method: 'DELETE' })
|
||||
if (!res.ok) throw new Error(`DELETE activity failed: ${res.status}`)
|
||||
}
|
||||
|
||||
async function apiUpsertOrganization(org: VVTOrganizationHeader): Promise<VVTOrganizationHeader> {
|
||||
const res = await fetch(`${VVT_API_BASE}/organization`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(orgHeaderToApi(org)),
|
||||
})
|
||||
if (!res.ok) throw new Error(`PUT organization failed: ${res.status}`)
|
||||
return orgHeaderFromApi(await res.json())
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
@@ -84,34 +208,45 @@ export default function VVTPage() {
|
||||
const [sortBy, setSortBy] = useState<'name' | 'date' | 'status'>('name')
|
||||
const [generatorStep, setGeneratorStep] = useState(1)
|
||||
const [generatorPreview, setGeneratorPreview] = useState<VVTActivity[] | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [apiError, setApiError] = useState<string | null>(null)
|
||||
|
||||
// Load from localStorage
|
||||
// Load profiling answers from localStorage (UI state only)
|
||||
useEffect(() => {
|
||||
const data = loadData()
|
||||
setActivities(data.activities)
|
||||
setOrgHeader(data.orgHeader)
|
||||
setProfilingAnswers(data.profilingAnswers)
|
||||
try {
|
||||
const stored = localStorage.getItem(PROFILING_STORAGE_KEY)
|
||||
if (stored) setProfilingAnswers(JSON.parse(stored))
|
||||
} catch { /* ignore */ }
|
||||
}, [])
|
||||
|
||||
// Save to localStorage on change
|
||||
const persist = useCallback((acts: VVTActivity[], org: VVTOrganizationHeader, prof: ProfilingAnswers) => {
|
||||
saveData({ activities: acts, orgHeader: org, profilingAnswers: prof })
|
||||
// Load activities + org header from API
|
||||
useEffect(() => {
|
||||
async function loadFromApi() {
|
||||
setIsLoading(true)
|
||||
setApiError(null)
|
||||
try {
|
||||
const [acts, org] = await Promise.all([
|
||||
apiListActivities(),
|
||||
apiGetOrganization(),
|
||||
])
|
||||
setActivities(acts)
|
||||
if (org) setOrgHeader(org)
|
||||
} catch (err) {
|
||||
setApiError('Fehler beim Laden der VVT-Daten. Bitte Verbindung prüfen.')
|
||||
console.error('VVT API load error:', err)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
loadFromApi()
|
||||
}, [])
|
||||
|
||||
const updateActivities = useCallback((acts: VVTActivity[]) => {
|
||||
setActivities(acts)
|
||||
persist(acts, orgHeader, profilingAnswers)
|
||||
}, [orgHeader, profilingAnswers, persist])
|
||||
|
||||
const updateOrgHeader = useCallback((org: VVTOrganizationHeader) => {
|
||||
setOrgHeader(org)
|
||||
persist(activities, org, profilingAnswers)
|
||||
}, [activities, profilingAnswers, persist])
|
||||
|
||||
const updateProfilingAnswers = useCallback((prof: ProfilingAnswers) => {
|
||||
setProfilingAnswers(prof)
|
||||
persist(activities, orgHeader, prof)
|
||||
}, [activities, orgHeader, persist])
|
||||
try {
|
||||
localStorage.setItem(PROFILING_STORAGE_KEY, JSON.stringify(prof))
|
||||
} catch { /* ignore */ }
|
||||
}, [])
|
||||
|
||||
// Computed stats
|
||||
const activeCount = activities.filter(a => a.status === 'APPROVED').length
|
||||
@@ -147,6 +282,14 @@ export default function VVTPage() {
|
||||
{ id: 'export', label: 'Export & Compliance' },
|
||||
]
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-600"></div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<StepHeader
|
||||
@@ -157,6 +300,12 @@ export default function VVTPage() {
|
||||
tips={stepInfo.tips}
|
||||
/>
|
||||
|
||||
{apiError && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4 text-sm text-red-700">
|
||||
{apiError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tab Navigation */}
|
||||
<div className="flex items-center gap-1 bg-gray-100 p-1 rounded-lg">
|
||||
{tabs.map(t => (
|
||||
@@ -193,14 +342,28 @@ export default function VVTPage() {
|
||||
sortBy={sortBy}
|
||||
setSortBy={setSortBy}
|
||||
onEdit={(id) => { setEditingId(id); setTab('editor') }}
|
||||
onNew={() => {
|
||||
onNew={async () => {
|
||||
const vvtId = generateVVTId(activities.map(a => a.vvtId))
|
||||
const newAct = createEmptyActivity(vvtId)
|
||||
updateActivities([...activities, newAct])
|
||||
setEditingId(newAct.id)
|
||||
setTab('editor')
|
||||
try {
|
||||
const created = await apiCreateActivity(newAct)
|
||||
setActivities(prev => [...prev, created])
|
||||
setEditingId(created.id)
|
||||
setTab('editor')
|
||||
} catch (err) {
|
||||
setApiError('Fehler beim Anlegen der Verarbeitung.')
|
||||
console.error(err)
|
||||
}
|
||||
}}
|
||||
onDelete={async (id) => {
|
||||
try {
|
||||
await apiDeleteActivity(id)
|
||||
setActivities(prev => prev.filter(a => a.id !== id))
|
||||
} catch (err) {
|
||||
setApiError('Fehler beim Löschen der Verarbeitung.')
|
||||
console.error(err)
|
||||
}
|
||||
}}
|
||||
onDelete={(id) => updateActivities(activities.filter(a => a.id !== id))}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -208,12 +371,13 @@ export default function VVTPage() {
|
||||
<TabEditor
|
||||
activity={editingActivity}
|
||||
activities={activities}
|
||||
onSave={(updated) => {
|
||||
const idx = activities.findIndex(a => a.id === updated.id)
|
||||
if (idx >= 0) {
|
||||
const copy = [...activities]
|
||||
copy[idx] = { ...updated, updatedAt: new Date().toISOString() }
|
||||
updateActivities(copy)
|
||||
onSave={async (updated) => {
|
||||
try {
|
||||
const saved = await apiUpdateActivity(updated.id, updated)
|
||||
setActivities(prev => prev.map(a => a.id === saved.id ? saved : a))
|
||||
} catch (err) {
|
||||
setApiError('Fehler beim Speichern der Verarbeitung.')
|
||||
console.error(err)
|
||||
}
|
||||
}}
|
||||
onBack={() => setTab('verzeichnis')}
|
||||
@@ -229,8 +393,17 @@ export default function VVTPage() {
|
||||
setAnswers={updateProfilingAnswers}
|
||||
preview={generatorPreview}
|
||||
setPreview={setGeneratorPreview}
|
||||
onAdoptAll={(newActivities) => {
|
||||
updateActivities([...activities, ...newActivities])
|
||||
onAdoptAll={async (newActivities) => {
|
||||
const created: VVTActivity[] = []
|
||||
for (const act of newActivities) {
|
||||
try {
|
||||
const saved = await apiCreateActivity(act)
|
||||
created.push(saved)
|
||||
} catch (err) {
|
||||
console.error('Failed to create activity from generator:', err)
|
||||
}
|
||||
}
|
||||
if (created.length > 0) setActivities(prev => [...prev, ...created])
|
||||
setGeneratorPreview(null)
|
||||
setGeneratorStep(1)
|
||||
setTab('verzeichnis')
|
||||
@@ -242,7 +415,15 @@ export default function VVTPage() {
|
||||
<TabExport
|
||||
activities={activities}
|
||||
orgHeader={orgHeader}
|
||||
onUpdateOrgHeader={updateOrgHeader}
|
||||
onUpdateOrgHeader={async (org) => {
|
||||
try {
|
||||
const saved = await apiUpsertOrganization(org)
|
||||
setOrgHeader(saved)
|
||||
} catch (err) {
|
||||
setApiError('Fehler beim Speichern der Organisationsdaten.')
|
||||
console.error(err)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -56,6 +56,9 @@ export function PIIRulesTab({ apiBase, onUpdate }: PIIRulesTabProps) {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Category filter
|
||||
const [categoryFilter, setCategoryFilter] = useState('')
|
||||
|
||||
// Test panel
|
||||
const [testText, setTestText] = useState('')
|
||||
const [testResult, setTestResult] = useState<PIITestResult | null>(null)
|
||||
@@ -77,12 +80,14 @@ export function PIIRulesTab({ apiBase, onUpdate }: PIIRulesTabProps) {
|
||||
|
||||
useEffect(() => {
|
||||
fetchRules()
|
||||
}, [])
|
||||
}, [categoryFilter])
|
||||
|
||||
const fetchRules = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const res = await fetch(`${apiBase}/pii-rules`)
|
||||
const params = new URLSearchParams()
|
||||
if (categoryFilter) params.append('category', categoryFilter)
|
||||
const res = await fetch(`${apiBase}/pii-rules?${params}`)
|
||||
if (!res.ok) throw new Error('Fehler beim Laden')
|
||||
|
||||
const data = await res.json()
|
||||
@@ -321,17 +326,29 @@ Meine IBAN ist DE89 3704 0044 0532 0130 00."
|
||||
</div>
|
||||
|
||||
{/* Rules List Header */}
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<div className="flex flex-wrap justify-between items-center gap-3 mb-4">
|
||||
<h3 className="font-semibold text-slate-900">PII-Erkennungsregeln</h3>
|
||||
<button
|
||||
onClick={() => setIsNewRule(true)}
|
||||
className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 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="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
Neue Regel
|
||||
</button>
|
||||
<div className="flex gap-3 items-center">
|
||||
<select
|
||||
value={categoryFilter}
|
||||
onChange={(e) => setCategoryFilter(e.target.value)}
|
||||
className="px-3 py-2 border border-slate-200 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||
>
|
||||
<option value="">Alle Kategorien</option>
|
||||
{CATEGORIES.map((c) => (
|
||||
<option key={c.value} value={c.value}>{c.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
onClick={() => setIsNewRule(true)}
|
||||
className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 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="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
Neue Regel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Rules Table */}
|
||||
|
||||
@@ -51,6 +51,7 @@ export function SourcesTab({ apiBase, onUpdate }: SourcesTabProps) {
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const [licenseFilter, setLicenseFilter] = useState('')
|
||||
const [statusFilter, setStatusFilter] = useState<'all' | 'active' | 'inactive'>('all')
|
||||
const [sourceTypeFilter, setSourceTypeFilter] = useState('')
|
||||
|
||||
// Edit modal
|
||||
const [editingSource, setEditingSource] = useState<AllowedSource | null>(null)
|
||||
@@ -69,7 +70,7 @@ export function SourcesTab({ apiBase, onUpdate }: SourcesTabProps) {
|
||||
|
||||
useEffect(() => {
|
||||
fetchSources()
|
||||
}, [licenseFilter, statusFilter])
|
||||
}, [licenseFilter, statusFilter, sourceTypeFilter])
|
||||
|
||||
const fetchSources = async () => {
|
||||
try {
|
||||
@@ -77,6 +78,7 @@ export function SourcesTab({ apiBase, onUpdate }: SourcesTabProps) {
|
||||
const params = new URLSearchParams()
|
||||
if (licenseFilter) params.append('license', licenseFilter)
|
||||
if (statusFilter !== 'all') params.append('active_only', statusFilter === 'active' ? 'true' : 'false')
|
||||
if (sourceTypeFilter) params.append('source_type', sourceTypeFilter)
|
||||
|
||||
const res = await fetch(`${apiBase}/sources?${params}`)
|
||||
if (!res.ok) throw new Error('Fehler beim Laden')
|
||||
@@ -230,6 +232,18 @@ export function SourcesTab({ apiBase, onUpdate }: SourcesTabProps) {
|
||||
<option value="active">Aktiv</option>
|
||||
<option value="inactive">Inaktiv</option>
|
||||
</select>
|
||||
<select
|
||||
value={sourceTypeFilter}
|
||||
onChange={(e) => setSourceTypeFilter(e.target.value)}
|
||||
className="px-4 py-2 border border-slate-200 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||
>
|
||||
<option value="">Alle Typen</option>
|
||||
<option value="legal">Rechtlich</option>
|
||||
<option value="guidance">Leitlinien</option>
|
||||
<option value="template">Vorlagen</option>
|
||||
<option value="technical">Technisch</option>
|
||||
<option value="other">Sonstige</option>
|
||||
</select>
|
||||
<button
|
||||
onClick={() => setIsNewSource(true)}
|
||||
className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 flex items-center gap-2"
|
||||
|
||||
Reference in New Issue
Block a user