Website (14 monoliths split): - compliance/page.tsx (1,519 → 9), docs/audit (1,262 → 20) - quality (1,231 → 16), alerts (1,203 → 10), docs (1,202 → 11) - i18n.ts (1,173 → 8 language files) - unity-bridge (1,094 → 12), backlog (1,087 → 6) - training (1,066 → 8), rag (1,063 → 8) - Deleted index_original.ts (4,899 LOC dead backup) Studio-v2 (5 monoliths split): - meet/page.tsx (1,481 → 9), messages (1,166 → 9) - AlertsB2BContext.tsx (1,165 → 5 modules) - alerts-b2b/page.tsx (1,019 → 6), korrektur/archiv (1,001 → 6) All existing imports preserved. Zero new TypeScript errors. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
194 lines
6.0 KiB
TypeScript
194 lines
6.0 KiB
TypeScript
'use client'
|
|
|
|
import { useEffect, useState, useCallback } from 'react'
|
|
import type { AlertItem, Topic, Rule, Profile, Stats } from './types'
|
|
|
|
const API_BASE = '/api/alerts'
|
|
|
|
export function useAlertsData() {
|
|
const [stats, setStats] = useState<Stats | null>(null)
|
|
const [alerts, setAlerts] = useState<AlertItem[]>([])
|
|
const [topics, setTopics] = useState<Topic[]>([])
|
|
const [rules, setRules] = useState<Rule[]>([])
|
|
const [profile, setProfile] = useState<Profile | null>(null)
|
|
const [loading, setLoading] = useState(true)
|
|
const [error, setError] = useState<string | null>(null)
|
|
const [inboxFilter, setInboxFilter] = useState<string>('all')
|
|
|
|
const fetchData = useCallback(async () => {
|
|
try {
|
|
const [statsRes, alertsRes, topicsRes, rulesRes, profileRes] = await Promise.all([
|
|
fetch(`${API_BASE}/stats`),
|
|
fetch(`${API_BASE}/inbox?limit=50`),
|
|
fetch(`${API_BASE}/topics`),
|
|
fetch(`${API_BASE}/rules`),
|
|
fetch(`${API_BASE}/profile`),
|
|
])
|
|
|
|
if (statsRes.ok) setStats(await statsRes.json())
|
|
if (alertsRes.ok) {
|
|
const data = await alertsRes.json()
|
|
setAlerts(data.items || [])
|
|
}
|
|
if (topicsRes.ok) {
|
|
const data = await topicsRes.json()
|
|
setTopics(data.items || data || [])
|
|
}
|
|
if (rulesRes.ok) {
|
|
const data = await rulesRes.json()
|
|
setRules(data.items || data || [])
|
|
}
|
|
if (profileRes.ok) setProfile(await profileRes.json())
|
|
|
|
setError(null)
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'Verbindungsfehler')
|
|
setStats({
|
|
total_alerts: 147,
|
|
new_alerts: 23,
|
|
kept_alerts: 89,
|
|
review_alerts: 12,
|
|
dropped_alerts: 23,
|
|
total_topics: 5,
|
|
active_topics: 4,
|
|
total_rules: 8,
|
|
})
|
|
setAlerts([
|
|
{
|
|
id: 'demo_1',
|
|
title: 'Neue Studie zur digitalen Bildung an Schulen',
|
|
url: 'https://example.com/artikel1',
|
|
snippet: 'Eine aktuelle Studie zeigt, dass digitale Lernmittel den Lernerfolg steigern koennen...',
|
|
topic_name: 'Digitale Bildung',
|
|
relevance_score: 0.85,
|
|
relevance_decision: 'KEEP',
|
|
status: 'new',
|
|
fetched_at: new Date().toISOString(),
|
|
published_at: null,
|
|
matched_rule: null,
|
|
tags: ['bildung', 'digital'],
|
|
},
|
|
{
|
|
id: 'demo_2',
|
|
title: 'Inklusion: Fortbildungen fuer Lehrkraefte',
|
|
url: 'https://example.com/artikel2',
|
|
snippet: 'Das Kultusministerium bietet neue Fortbildungsangebote zum Thema Inklusion an...',
|
|
topic_name: 'Inklusion',
|
|
relevance_score: 0.72,
|
|
relevance_decision: 'KEEP',
|
|
status: 'new',
|
|
fetched_at: new Date(Date.now() - 3600000).toISOString(),
|
|
published_at: null,
|
|
matched_rule: null,
|
|
tags: ['inklusion'],
|
|
},
|
|
])
|
|
setTopics([
|
|
{
|
|
id: 'topic_1',
|
|
name: 'Digitale Bildung',
|
|
feed_url: 'https://google.com/alerts/feeds/123',
|
|
feed_type: 'rss',
|
|
is_active: true,
|
|
fetch_interval_minutes: 60,
|
|
last_fetched_at: new Date().toISOString(),
|
|
alert_count: 47,
|
|
},
|
|
{
|
|
id: 'topic_2',
|
|
name: 'Inklusion',
|
|
feed_url: 'https://google.com/alerts/feeds/456',
|
|
feed_type: 'rss',
|
|
is_active: true,
|
|
fetch_interval_minutes: 60,
|
|
last_fetched_at: new Date(Date.now() - 1800000).toISOString(),
|
|
alert_count: 32,
|
|
},
|
|
])
|
|
setRules([
|
|
{
|
|
id: 'rule_1',
|
|
name: 'Stellenanzeigen ausschliessen',
|
|
topic_id: null,
|
|
conditions: [{ field: 'title', operator: 'contains', value: 'Stellenangebot' }],
|
|
action_type: 'drop',
|
|
action_config: {},
|
|
priority: 10,
|
|
is_active: true,
|
|
},
|
|
])
|
|
setProfile({
|
|
priorities: ['Inklusion', 'digitale Bildung'],
|
|
exclusions: ['Stellenanzeigen', 'Werbung'],
|
|
positive_examples: [],
|
|
negative_examples: [],
|
|
policies: { keep_threshold: 0.7, drop_threshold: 0.3 },
|
|
})
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
fetchData()
|
|
}, [fetchData])
|
|
|
|
const filteredAlerts = alerts.filter((alert) => {
|
|
if (inboxFilter === 'all') return true
|
|
if (inboxFilter === 'new') return alert.status === 'new'
|
|
if (inboxFilter === 'keep') return alert.relevance_decision === 'KEEP'
|
|
if (inboxFilter === 'review') return alert.relevance_decision === 'REVIEW'
|
|
return true
|
|
})
|
|
|
|
return {
|
|
stats,
|
|
alerts,
|
|
topics,
|
|
rules,
|
|
profile,
|
|
loading,
|
|
error,
|
|
inboxFilter,
|
|
setInboxFilter,
|
|
filteredAlerts,
|
|
}
|
|
}
|
|
|
|
export function formatTimeAgo(dateStr: string | null): string {
|
|
if (!dateStr) return '-'
|
|
const date = new Date(dateStr)
|
|
const now = new Date()
|
|
const diffMs = now.getTime() - date.getTime()
|
|
const diffMins = Math.floor(diffMs / 60000)
|
|
|
|
if (diffMins < 1) return 'gerade eben'
|
|
if (diffMins < 60) return `vor ${diffMins} Min.`
|
|
if (diffMins < 1440) return `vor ${Math.floor(diffMins / 60)} Std.`
|
|
return `vor ${Math.floor(diffMins / 1440)} Tagen`
|
|
}
|
|
|
|
export function getScoreBadge(score: number | null) {
|
|
if (score === null) return null
|
|
const pct = Math.round(score * 100)
|
|
let cls = 'bg-slate-100 text-slate-600'
|
|
if (pct >= 70) cls = 'bg-green-100 text-green-800'
|
|
else if (pct >= 40) cls = 'bg-amber-100 text-amber-800'
|
|
else cls = 'bg-red-100 text-red-800'
|
|
return <span className={`px-2 py-0.5 rounded text-xs font-semibold ${cls}`}>{pct}%</span>
|
|
}
|
|
|
|
export function getDecisionBadge(decision: string | null) {
|
|
if (!decision) return null
|
|
const styles: Record<string, string> = {
|
|
KEEP: 'bg-green-100 text-green-800',
|
|
REVIEW: 'bg-amber-100 text-amber-800',
|
|
DROP: 'bg-red-100 text-red-800',
|
|
}
|
|
return (
|
|
<span className={`px-2 py-0.5 rounded text-xs font-semibold uppercase ${styles[decision] || 'bg-slate-100'}`}>
|
|
{decision}
|
|
</span>
|
|
)
|
|
}
|