feat: Aktivitätslog tab in PropertyDetailView — per-property timeline

Adds a new "Aktivitätslog" tab (9th tab) to the supply-side property
drawer. Shows match approvals/rejections, need registrations, data
quality flags, and governance actions linked to this specific property.
Only visible to Verwaltung users in their own property view.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Benjamin Sutter
2026-05-18 18:12:50 +02:00
parent c4c29ef7d3
commit fdf4de37db
4 changed files with 316 additions and 10 deletions
@@ -0,0 +1,256 @@
import { Box, Chip, CircularProgress, Tooltip, Typography } from '@mui/material'
import {
AlertTriangle,
Bookmark,
BookmarkCheck,
Bot,
Building2,
CheckCircle,
ClipboardList,
Edit,
FileText,
GitMerge,
Radar,
Search,
ServerCog,
TrendingUp,
User,
XCircle,
} from 'lucide-react'
import { usePropertyActivityLog } from '../../hooks/useActivityLog'
import type { ActivityCategory, ActivityEventType } from '../../services/governanceService'
const EVENT_LABELS: Record<ActivityEventType, string> = {
PROPERTY_CREATED: 'Objekt erstellt',
PROPERTY_UPDATED: 'Objekt aktualisiert',
MATCH_APPROVED: 'Match genehmigt',
MATCH_REJECTED: 'Match abgelehnt',
SIGNAL_VERIFIED: 'Signal verifiziert',
NEED_CREATED: 'Bedarf registriert',
REVIEW_REQUESTED: 'Überprüfung angefordert',
AI_PARSE_COMPLETED: 'KI-Analyse abgeschlossen',
AI_OUTPUT_REVIEWED: 'KI-Output geprüft',
MATCH_GENERATED: 'Matches generiert',
FUTURE_SIGNAL_DETECTED: 'Zukunftssignal erkannt',
FUTURE_SIGNAL_CONVERTED: 'Signal konvertiert',
SHORTLIST_CREATED: 'Shortlist erstellt',
SHORTLIST_FINALIZED: 'Shortlist finalisiert',
DECISION_BRIEF_CREATED: 'Entscheidungsbriefing erstellt',
SOURCE_CRAWLED: 'Quelle gecrawlt',
DATA_QUALITY_FLAGGED: 'Datenqualität markiert',
REVIEW_COMPLETED: 'Prüfung abgeschlossen',
}
const EVENT_COLORS: Record<ActivityEventType, string> = {
PROPERTY_CREATED: '#1e3a5f',
PROPERTY_UPDATED: '#1e3a5f',
MATCH_APPROVED: '#1a7a4a',
MATCH_REJECTED: '#c0392b',
SIGNAL_VERIFIED: '#7c3aed',
NEED_CREATED: '#0891b2',
REVIEW_REQUESTED: '#d97706',
AI_PARSE_COMPLETED: '#0891b2',
AI_OUTPUT_REVIEWED: '#1a7a4a',
MATCH_GENERATED: '#0891b2',
FUTURE_SIGNAL_DETECTED: '#7c3aed',
FUTURE_SIGNAL_CONVERTED: '#7c3aed',
SHORTLIST_CREATED: '#0f766e',
SHORTLIST_FINALIZED: '#0f766e',
DECISION_BRIEF_CREATED: '#0f766e',
SOURCE_CRAWLED: '#6366f1',
DATA_QUALITY_FLAGGED: '#ea580c',
REVIEW_COMPLETED: '#1a7a4a',
}
const CATEGORY_META: Record<ActivityCategory, { label: string; color: string }> = {
SUCHE: { label: 'Suche', color: '#0891b2' },
MATCHING: { label: 'Matching', color: '#1a7a4a' },
INTELLIGENCE: { label: 'Intelligence', color: '#7c3aed' },
REVIEW: { label: 'Review', color: '#d97706' },
GOVERNANCE: { label: 'Governance', color: '#1e3a5f' },
}
function getEventIcon(type: ActivityEventType) {
const s = 12
switch (type) {
case 'PROPERTY_CREATED': return <Building2 size={s} color="white" />
case 'PROPERTY_UPDATED': return <Edit size={s} color="white" />
case 'MATCH_APPROVED': return <CheckCircle size={s} color="white" />
case 'MATCH_REJECTED': return <XCircle size={s} color="white" />
case 'SIGNAL_VERIFIED': return <TrendingUp size={s} color="white" />
case 'NEED_CREATED': return <Search size={s} color="white" />
case 'REVIEW_REQUESTED': return <ClipboardList size={s} color="white" />
case 'AI_PARSE_COMPLETED':
case 'AI_OUTPUT_REVIEWED':
case 'MATCH_GENERATED':
case 'DECISION_BRIEF_CREATED': return <Bot size={s} color="white" />
case 'FUTURE_SIGNAL_DETECTED':
case 'FUTURE_SIGNAL_CONVERTED': return <Radar size={s} color="white" />
case 'SHORTLIST_CREATED': return <Bookmark size={s} color="white" />
case 'SHORTLIST_FINALIZED': return <BookmarkCheck size={s} color="white" />
case 'SOURCE_CRAWLED': return <ServerCog size={s} color="white" />
case 'DATA_QUALITY_FLAGGED': return <AlertTriangle size={s} color="white" />
case 'REVIEW_COMPLETED': return <GitMerge size={s} color="white" />
default: return <FileText size={s} color="white" />
}
}
function formatDate(iso: string): string {
return new Date(iso).toLocaleDateString('de-CH', { day: '2-digit', month: 'long', year: 'numeric' })
}
function formatTime(iso: string): string {
return new Date(iso).toLocaleTimeString('de-CH', { hour: '2-digit', minute: '2-digit' })
}
function dateKey(iso: string): string {
return iso.slice(0, 10)
}
interface ActivityEvent {
id: string
type: ActivityEventType
category: ActivityCategory
entityId: string
entityType: string
performedBy: string
isAiAction: boolean
organizationId: string
notes?: string
createdAt: string
propertyId?: string
}
function groupByDate(events: ActivityEvent[]) {
const map = new Map<string, ActivityEvent[]>()
for (const e of events) {
const k = dateKey(e.createdAt)
if (!map.has(k)) map.set(k, [])
map.get(k)!.push(e)
}
return [...map.entries()]
.sort(([a], [b]) => b.localeCompare(a))
.map(([key, evts]) => ({ key, label: formatDate(evts[0].createdAt), events: evts }))
}
function EventRow({ event, isLast }: { event: ActivityEvent; isLast: boolean }) {
const color = EVENT_COLORS[event.type] ?? '#64748b'
const catMeta = CATEGORY_META[event.category]
return (
<Box sx={{ display: 'flex', gap: 1.25, position: 'relative' }}>
{!isLast && (
<Box sx={{ position: 'absolute', left: 12, top: 28, bottom: -4, width: 2, bgcolor: '#e2e8f0', zIndex: 0 }} />
)}
<Box
sx={{
width: 26, height: 26, borderRadius: '50%', bgcolor: color,
display: 'flex', alignItems: 'center', justifyContent: 'center',
flexShrink: 0, zIndex: 1, boxShadow: '0 0 0 3px white', mt: 0.25,
}}
>
{getEventIcon(event.type)}
</Box>
<Box sx={{ flex: 1, minWidth: 0, pb: isLast ? 0 : 1.75 }}>
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 1 }}>
<Box sx={{ flex: 1, minWidth: 0 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, flexWrap: 'wrap' }}>
<Typography variant="body2" sx={{ fontWeight: 600, fontSize: '0.775rem' }}>
{EVENT_LABELS[event.type]}
</Typography>
<Chip
label={catMeta.label}
size="small"
sx={{ height: 14, fontSize: '0.6rem', bgcolor: catMeta.color + '18', color: catMeta.color, fontWeight: 600 }}
/>
{event.isAiAction && (
<Tooltip title="KI-System">
<Chip
label="KI"
size="small"
sx={{ height: 14, fontSize: '0.6rem', bgcolor: '#f1f5f9', color: '#6366f1', fontWeight: 700, cursor: 'default' }}
/>
</Tooltip>
)}
</Box>
{event.notes && (
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 0.2, lineHeight: 1.45, fontSize: '0.72rem' }}>
{event.notes}
</Typography>
)}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.4, mt: 0.4 }}>
{event.isAiAction
? <Bot size={10} color="#6366f1" />
: <User size={10} color="#94a3b8" />
}
<Typography variant="caption" sx={{ color: '#94a3b8', fontSize: '0.68rem' }}>
{event.isAiAction ? 'KI-System' : event.performedBy}
</Typography>
</Box>
</Box>
<Typography variant="caption" sx={{ color: '#94a3b8', fontSize: '0.68rem', flexShrink: 0, pt: 0.25 }}>
{formatTime(event.createdAt)}
</Typography>
</Box>
</Box>
</Box>
)
}
interface PropertyActivityLogPanelProps {
propertyId: string
}
export function PropertyActivityLogPanel({ propertyId }: PropertyActivityLogPanelProps) {
const { data: events = [], isLoading } = usePropertyActivityLog(propertyId)
if (isLoading) {
return (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
<CircularProgress size={20} />
</Box>
)
}
if (events.length === 0) {
return (
<Box sx={{ py: 4, textAlign: 'center' }}>
<Typography variant="body2" color="text.secondary" sx={{ fontSize: '0.85rem' }}>
Noch keine Aktivitäten für dieses Objekt.
</Typography>
</Box>
)
}
const grouped = groupByDate(events)
return (
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
<Typography variant="caption" sx={{ color: '#64748b', fontWeight: 600, fontSize: '0.72rem', textTransform: 'uppercase', letterSpacing: 0.4 }}>
{events.length} Ereignisse · Nur für Verwaltung
</Typography>
</Box>
{grouped.map(({ key, label, events: dayEvents }) => (
<Box key={key} sx={{ mb: 2.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.25 }}>
<Typography
variant="caption"
sx={{ fontWeight: 700, fontSize: '0.7rem', color: '#64748b', textTransform: 'uppercase', letterSpacing: 0.4, whiteSpace: 'nowrap' }}
>
{label}
</Typography>
<Box sx={{ flex: 1, height: 1, bgcolor: '#e2e8f0' }} />
<Typography variant="caption" sx={{ color: '#94a3b8', fontSize: '0.65rem', whiteSpace: 'nowrap' }}>
{dayEvents.length}×
</Typography>
</Box>
{dayEvents.map((evt, idx) => (
<EventRow key={evt.id} event={evt} isLast={idx === dayEvents.length - 1} />
))}
</Box>
))}
</Box>
)
}
+3 -1
View File
@@ -18,6 +18,7 @@ import type { Property } from '../../domain/property'
import type { Match } from '../../domain/match'
import type { FutureSignal } from '../../domain/futureSignal'
import { usePropertyById, usePropertyMatches, usePropertySignals } from '../../hooks/useProperties'
import { PropertyActivityLogPanel } from './PropertyActivityLogPanel'
import { PropertyDetailSkeleton } from './PropertyDetailSkeleton'
import {
getAssetTypeColor,
@@ -310,7 +311,7 @@ function SignalsPanel({ signals }: { signals: FutureSignal[] }) {
// ── Main component ────────────────────────────────────────────────────────────
const TABS = ['Übersicht', 'Verhandlung & Markt', 'Hard Facts', 'Soft Factors', 'Matchability', 'Datenqualität', 'Quelle', 'Signale'] as const
const TABS = ['Übersicht', 'Verhandlung & Markt', 'Hard Facts', 'Soft Factors', 'Matchability', 'Datenqualität', 'Quelle', 'Signale', 'Aktivitätslog'] as const
export function PropertyDetailView({ propertyId, onClose }: PropertyDetailViewProps) {
const [tab, setTab] = useState(0)
@@ -414,6 +415,7 @@ export function PropertyDetailView({ propertyId, onClose }: PropertyDetailViewPr
{tab === 5 && <DQPanel property={property} />}
{tab === 6 && <ProvenancePanel property={property} />}
{tab === 7 && <SignalsPanel signals={signals} />}
{tab === 8 && <PropertyActivityLogPanel propertyId={propertyId} />}
</Box>
</Box>
)