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:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user