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>
)
+12
View File
@@ -0,0 +1,12 @@
import { useQuery } from '@tanstack/react-query'
import { governanceService } from '../services/governanceService'
export function usePropertyActivityLog(propertyId: string) {
return useQuery({
queryKey: ['activityLog', 'property', propertyId],
queryFn: () => governanceService.getActivityLogByProperty(propertyId),
staleTime: 30_000,
select: (res) => res.data ?? [],
enabled: !!propertyId,
})
}
+45 -9
View File
@@ -33,38 +33,70 @@ export interface ActivityEvent {
organizationId: string
notes?: string
createdAt: string
propertyId?: string
}
const mockActivityLog: ActivityEvent[] = [
// Day 1 — 2026-05-13
{ id: 'evt-001', type: 'SOURCE_CRAWLED', category: 'INTELLIGENCE', entityId: 'src-001', entityType: 'SOURCE', performedBy: 'system@property-match', isAiAction: true, organizationId: 'org-wincasa', notes: 'Baubewilligung-Feed Kanton ZH: 47 neue Einträge gefunden', createdAt: '2026-05-13T06:15:00Z' },
{ id: 'evt-002', type: 'FUTURE_SIGNAL_DETECTED', category: 'INTELLIGENCE', entityId: 'sig-maag', entityType: 'SIGNAL', performedBy: 'system@property-match', isAiAction: true, organizationId: 'org-wincasa', notes: 'Potenzielle Grossfläche: Maag Areal Zürich — Wahrscheinlichkeit 82%', createdAt: '2026-05-13T06:18:00Z' },
{ id: 'evt-003', type: 'DATA_QUALITY_FLAGGED', category: 'GOVERNANCE', entityId: 'prop-007', entityType: 'PROPERTY', performedBy: 'system@property-match', isAiAction: true, organizationId: 'org-wincasa', notes: 'Grundriss fehlt; Qualitätsscore 61 — unter Schwellenwert', createdAt: '2026-05-13T07:30:00Z' },
{ id: 'evt-004', type: 'PROPERTY_CREATED', category: 'GOVERNANCE', entityId: 'prop-022', entityType: 'PROPERTY', performedBy: 'manager@wincasa.ch', isAiAction: false, organizationId: 'org-wincasa', notes: 'Neues Industrieobjekt Schlieren erfasst', createdAt: '2026-05-13T09:45:00Z' },
{ id: 'evt-003', type: 'DATA_QUALITY_FLAGGED', category: 'GOVERNANCE', entityId: 'prop-007', entityType: 'PROPERTY', performedBy: 'system@property-match', isAiAction: true, organizationId: 'org-wincasa', notes: 'Grundriss fehlt; Qualitätsscore 61 — unter Schwellenwert', createdAt: '2026-05-13T07:30:00Z', propertyId: 'prop-007' },
{ id: 'evt-004', type: 'PROPERTY_CREATED', category: 'GOVERNANCE', entityId: 'prop-022', entityType: 'PROPERTY', performedBy: 'manager@wincasa.ch', isAiAction: false, organizationId: 'org-wincasa', notes: 'Neues Industrieobjekt Schlieren erfasst', createdAt: '2026-05-13T09:45:00Z', propertyId: 'prop-022' },
// Day 2 — 2026-05-14
{ id: 'evt-005', type: 'SOURCE_CRAWLED', category: 'INTELLIGENCE', entityId: 'src-002', entityType: 'SOURCE', performedBy: 'system@property-match', isAiAction: true, organizationId: 'org-wincasa', notes: 'Handelsregister-Crawler: 12 Unternehmensumzüge identifiziert', createdAt: '2026-05-14T06:00:00Z' },
{ id: 'evt-006', type: 'FUTURE_SIGNAL_DETECTED', category: 'INTELLIGENCE', entityId: 'sig-alstom', entityType: 'SIGNAL', performedBy: 'system@property-match', isAiAction: true, organizationId: 'org-wincasa', notes: 'Alstom AG Standortanalyse — interne Dokumente verweisen auf Expansionspläne', createdAt: '2026-05-14T06:05:00Z' },
{ id: 'evt-007', type: 'PROPERTY_UPDATED', category: 'GOVERNANCE', entityId: 'prop-003', entityType: 'PROPERTY', performedBy: 'manager@wincasa.ch', isAiAction: false, organizationId: 'org-wincasa', notes: 'Verfügbarkeit aktualisiert: sofort verfügbar', createdAt: '2026-05-14T10:20:00Z' },
{ id: 'evt-007', type: 'PROPERTY_UPDATED', category: 'GOVERNANCE', entityId: 'prop-003', entityType: 'PROPERTY', performedBy: 'manager@wincasa.ch', isAiAction: false, organizationId: 'org-wincasa', notes: 'Verfügbarkeit aktualisiert: sofort verfügbar', createdAt: '2026-05-14T10:20:00Z', propertyId: 'prop-003' },
{ id: 'evt-008', type: 'REVIEW_REQUESTED', category: 'REVIEW', entityId: 'ai-out-003', entityType: 'AI_OUTPUT', performedBy: 'admin@ideal-sharing.ch', isAiAction: false, organizationId: 'org-wincasa', notes: 'Need-Parse-Output zur manuellen Prüfung eingereicht', createdAt: '2026-05-14T14:00:00Z' },
{ id: 'evt-009', type: 'AI_OUTPUT_REVIEWED', category: 'REVIEW', entityId: 'ai-out-003', entityType: 'AI_OUTPUT', performedBy: 'reviewer@ideal-sharing.ch', isAiAction: false, organizationId: 'org-wincasa', notes: 'Output genehmigt — Kriterienextraktion korrekt', createdAt: '2026-05-14T15:30:00Z' },
// Day 3 — 2026-05-15
{ id: 'evt-010', type: 'NEED_CREATED', category: 'SUCHE', entityId: 'need-042', entityType: 'NEED', performedBy: 'demand@globocorp.ch', isAiAction: false, organizationId: 'org-wincasa', notes: 'Natürlichsprachliche Suche: "2500m² Büro Zürich West, offen, Rep.'+ "'" + 'resentanz-qualität"', createdAt: '2026-05-15T09:10:00Z' },
{ id: 'evt-010', type: 'NEED_CREATED', category: 'SUCHE', entityId: 'need-042', entityType: 'NEED', performedBy: 'demand@globocorp.ch', isAiAction: false, organizationId: 'org-wincasa', notes: 'Natürlichsprachliche Suche: "2500m² Büro Zürich West, offen, Repsentanz-qualität"', createdAt: '2026-05-15T09:10:00Z' },
{ id: 'evt-011', type: 'AI_PARSE_COMPLETED', category: 'SUCHE', entityId: 'need-042', entityType: 'NEED', performedBy: 'system@property-match', isAiAction: true, organizationId: 'org-wincasa', notes: 'Konfidenz 87% — Kriterien: OFFICE 20003000m², Zürich West, CHF 280/m², Einzug Q3 2026', createdAt: '2026-05-15T09:10:04Z' },
{ id: 'evt-012', type: 'MATCH_GENERATED', category: 'MATCHING', entityId: 'need-042', entityType: 'NEED', performedBy: 'system@property-match', isAiAction: true, organizationId: 'org-wincasa', notes: '14 Kandidaten bewertet — 3 STARK (≥80), 6 MITTEL, 5 SCHWACH', createdAt: '2026-05-15T09:10:07Z' },
{ id: 'evt-013', type: 'SIGNAL_VERIFIED', category: 'INTELLIGENCE', entityId: 'sig-maag', entityType: 'SIGNAL', performedBy: 'admin@ideal-sharing.ch', isAiAction: false, organizationId: 'org-wincasa', notes: 'Maag Areal Signal verifiziert — Baubewilligung bestätigt', createdAt: '2026-05-15T10:00:00Z' },
{ id: 'evt-014', type: 'SHORTLIST_CREATED', category: 'SUCHE', entityId: 'sl-globo-01', entityType: 'SHORTLIST', performedBy: 'demand@globocorp.ch', isAiAction: false, organizationId: 'org-wincasa', notes: '4 Objekte auf Shortlist "Zürich West Q3 2026"', createdAt: '2026-05-15T11:45:00Z' },
{ id: 'evt-015', type: 'FUTURE_SIGNAL_CONVERTED', category: 'INTELLIGENCE', entityId: 'sig-maag', entityType: 'SIGNAL', performedBy: 'admin@ideal-sharing.ch', isAiAction: false, organizationId: 'org-wincasa', notes: 'Signal in zukünftige Verfügbarkeit umgewandelt — erscheint im Unified Feed', createdAt: '2026-05-15T13:00:00Z' },
// Day 4 — 2026-05-16
{ id: 'evt-016', type: 'MATCH_APPROVED', category: 'MATCHING', entityId: 'match-003', entityType: 'MATCH', performedBy: 'admin@ideal-sharing.ch', isAiAction: false, organizationId: 'org-wincasa', notes: 'Score 91 — Starker Match Hardturmstrasse 201 × GloboCorp bestätigt', createdAt: '2026-05-16T09:00:00Z' },
{ id: 'evt-016', type: 'MATCH_APPROVED', category: 'MATCHING', entityId: 'match-003', entityType: 'MATCH', performedBy: 'admin@ideal-sharing.ch', isAiAction: false, organizationId: 'org-wincasa', notes: 'Score 91 — Starker Match Hardturmstrasse 201 × GloboCorp bestätigt', createdAt: '2026-05-16T09:00:00Z', propertyId: 'prop-001' },
{ id: 'evt-017', type: 'REVIEW_REQUESTED', category: 'REVIEW', entityId: 'sig-alstom', entityType: 'SIGNAL', performedBy: 'admin@ideal-sharing.ch', isAiAction: false, organizationId: 'org-wincasa', notes: 'Vertrauliches Signal zur Governance-Prüfung eingereicht', createdAt: '2026-05-16T10:15:00Z' },
{ id: 'evt-018', type: 'DECISION_BRIEF_CREATED', category: 'SUCHE', entityId: 'sl-globo-01', entityType: 'SHORTLIST', performedBy: 'system@property-match', isAiAction: true, organizationId: 'org-wincasa', notes: 'KI-Entscheidungsbriefing für Shortlist generiert — Empfehlung: Hardturmstrasse 201', createdAt: '2026-05-16T11:30:00Z' },
{ id: 'evt-018', type: 'DECISION_BRIEF_CREATED', category: 'SUCHE', entityId: 'sl-globo-01', entityType: 'SHORTLIST', performedBy: 'system@property-match', isAiAction: true, organizationId: 'org-wincasa', notes: 'KI-Entscheidungsbriefing für Shortlist generiert — Empfehlung: Hardturmstrasse 201', createdAt: '2026-05-16T11:30:00Z', propertyId: 'prop-001' },
{ id: 'evt-019', type: 'REVIEW_COMPLETED', category: 'REVIEW', entityId: 'sig-alstom', entityType: 'SIGNAL', performedBy: 'reviewer@ideal-sharing.ch', isAiAction: false, organizationId: 'org-wincasa', notes: 'GENEHMIGT — Vertraulichkeitsstufe bestätigt, Signal für Demand-Pipeline freigegeben', createdAt: '2026-05-16T14:00:00Z' },
{ id: 'evt-020', type: 'SHORTLIST_FINALIZED', category: 'SUCHE', entityId: 'sl-globo-01', entityType: 'SHORTLIST', performedBy: 'demand@globocorp.ch', isAiAction: false, organizationId: 'org-wincasa', notes: 'Shortlist finalisiert — Kundenentscheid: Objekt 1 und 3 zur Besichtigung', createdAt: '2026-05-16T16:45:00Z' },
// Day 5 — 2026-05-17 (heute)
// Day 5 — 2026-05-17
{ id: 'evt-021', type: 'SOURCE_CRAWLED', category: 'INTELLIGENCE', entityId: 'src-003', entityType: 'SOURCE', performedBy: 'system@property-match', isAiAction: true, organizationId: 'org-wincasa', notes: 'LinkedIn-Jobpostings-Crawler: 8 Expansionssignale gefunden', createdAt: '2026-05-17T06:00:00Z' },
{ id: 'evt-022', type: 'FUTURE_SIGNAL_DETECTED', category: 'INTELLIGENCE', entityId: 'sig-novartis', entityType: 'SIGNAL', performedBy: 'system@property-match', isAiAction: true, organizationId: 'org-wincasa', notes: 'Novartis Basel: 47 Stelleninserate für "Basel Life Sciences Hub" — Flächensignal', createdAt: '2026-05-17T06:12:00Z' },
{ id: 'evt-023', type: 'MATCH_REJECTED', category: 'MATCHING', entityId: 'match-009', entityType: 'MATCH', performedBy: 'admin@ideal-sharing.ch', isAiAction: false, organizationId: 'org-wincasa', notes: 'Score 64 — zu schwach, manuell abgelehnt', createdAt: '2026-05-17T08:30:00Z' },
{ id: 'evt-024', type: 'DATA_QUALITY_FLAGGED', category: 'GOVERNANCE', entityId: 'prop-015', entityType: 'PROPERTY', performedBy: 'system@property-match', isAiAction: true, organizationId: 'org-wincasa', notes: 'Preisangabe 18 Monate alt — automatische Qualitätswarnung', createdAt: '2026-05-17T09:00:00Z' },
{ id: 'evt-023', type: 'MATCH_REJECTED', category: 'MATCHING', entityId: 'match-009', entityType: 'MATCH', performedBy: 'admin@ideal-sharing.ch', isAiAction: false, organizationId: 'org-wincasa', notes: 'Score 64 — zu schwach, manuell abgelehnt', createdAt: '2026-05-17T08:30:00Z', propertyId: 'prop-003' },
{ id: 'evt-024', type: 'DATA_QUALITY_FLAGGED', category: 'GOVERNANCE', entityId: 'prop-015', entityType: 'PROPERTY', performedBy: 'system@property-match', isAiAction: true, organizationId: 'org-wincasa', notes: 'Preisangabe 18 Monate alt — automatische Qualitätswarnung', createdAt: '2026-05-17T09:00:00Z', propertyId: 'prop-015' },
// ── Property-specific events (visible in per-property activity log) ───────────
// prop-001
{ id: 'p1-001', type: 'PROPERTY_CREATED', category: 'GOVERNANCE', entityId: 'prop-001', entityType: 'PROPERTY', performedBy: 'manager@wincasa.ch', isAiAction: false, organizationId: 'org-wincasa', notes: 'Bürofläche Hardturmstrasse 201 neu erfasst', createdAt: '2026-04-02T09:00:00Z', propertyId: 'prop-001' },
{ id: 'p1-002', type: 'MATCH_GENERATED', category: 'MATCHING', entityId: 'need-031', entityType: 'NEED', performedBy: 'system@property-match', isAiAction: true, organizationId: 'org-wincasa', notes: 'Score 91 — Bedarf GloboCorp: sehr starke Übereinstimmung bei Fläche, Lage, Einzugstermin', createdAt: '2026-05-10T08:14:00Z', propertyId: 'prop-001' },
{ id: 'p1-003', type: 'NEED_CREATED', category: 'SUCHE', entityId: 'need-039', entityType: 'NEED', performedBy: 'demand@helvetia.ch', isAiAction: false, organizationId: 'org-wincasa', notes: 'Helvetia Versicherungen: Bedarf 1800m² Büro Zürich West — Objekt als Kandidat vorgemerkt', createdAt: '2026-05-12T10:30:00Z', propertyId: 'prop-001' },
{ id: 'p1-004', type: 'MATCH_APPROVED', category: 'MATCHING', entityId: 'match-003', entityType: 'MATCH', performedBy: 'admin@ideal-sharing.ch', isAiAction: false, organizationId: 'org-wincasa', notes: 'Match GloboCorp genehmigt — Besichtigung vereinbart für 20.05.2026', createdAt: '2026-05-16T09:00:00Z', propertyId: 'prop-001' },
{ id: 'p1-005', type: 'MATCH_REJECTED', category: 'MATCHING', entityId: 'match-011', entityType: 'MATCH', performedBy: 'admin@ideal-sharing.ch', isAiAction: false, organizationId: 'org-wincasa', notes: 'Score 71 — Miete über Budget des Interessenten, abgelehnt', createdAt: '2026-05-17T11:00:00Z', propertyId: 'prop-001' },
// prop-003
{ id: 'p3-001', type: 'PROPERTY_CREATED', category: 'GOVERNANCE', entityId: 'prop-003', entityType: 'PROPERTY', performedBy: 'manager@wincasa.ch', isAiAction: false, organizationId: 'org-wincasa', notes: 'Logistikfläche Pratteln initial erfasst', createdAt: '2026-03-15T08:30:00Z', propertyId: 'prop-003' },
{ id: 'p3-002', type: 'DATA_QUALITY_FLAGGED', category: 'GOVERNANCE', entityId: 'prop-003', entityType: 'PROPERTY', performedBy: 'system@property-match', isAiAction: true, organizationId: 'org-wincasa', notes: 'Laderampen-Anzahl fehlt; Deckenhöhe nicht angegeben — Score 68', createdAt: '2026-04-10T07:45:00Z', propertyId: 'prop-003' },
{ id: 'p3-003', type: 'PROPERTY_UPDATED', category: 'GOVERNANCE', entityId: 'prop-003', entityType: 'PROPERTY', performedBy: 'manager@wincasa.ch', isAiAction: false, organizationId: 'org-wincasa', notes: 'Verfügbarkeit aktualisiert: sofort verfügbar, Laderampen und Deckenhöhe ergänzt', createdAt: '2026-05-14T10:20:00Z', propertyId: 'prop-003' },
{ id: 'p3-004', type: 'MATCH_GENERATED', category: 'MATCHING', entityId: 'need-044', entityType: 'NEED', performedBy: 'system@property-match', isAiAction: true, organizationId: 'org-wincasa', notes: '3 Bedarfe ausgewertet — Score-Spanne 6478, kein STARK-Match', createdAt: '2026-05-15T14:20:00Z', propertyId: 'prop-003' },
{ id: 'p3-005', type: 'MATCH_REJECTED', category: 'MATCHING', entityId: 'match-009', entityType: 'MATCH', performedBy: 'admin@ideal-sharing.ch', isAiAction: false, organizationId: 'org-wincasa', notes: 'Score 64 — zu schwach, manuell abgelehnt', createdAt: '2026-05-17T08:30:00Z', propertyId: 'prop-003' },
// prop-007
{ id: 'p7-001', type: 'PROPERTY_CREATED', category: 'GOVERNANCE', entityId: 'prop-007', entityType: 'PROPERTY', performedBy: 'manager@wincasa.ch', isAiAction: false, organizationId: 'org-wincasa', notes: 'Retail-Fläche Basel Steinenvorstadt erfasst', createdAt: '2026-02-28T11:00:00Z', propertyId: 'prop-007' },
{ id: 'p7-002', type: 'MATCH_GENERATED', category: 'MATCHING', entityId: 'need-037', entityType: 'NEED', performedBy: 'system@property-match', isAiAction: true, organizationId: 'org-wincasa', notes: 'Score 83 — Bedarf Retail-Kette Innenstadt Basel: guter Match', createdAt: '2026-04-20T09:30:00Z', propertyId: 'prop-007' },
{ id: 'p7-003', type: 'DATA_QUALITY_FLAGGED', category: 'GOVERNANCE', entityId: 'prop-007', entityType: 'PROPERTY', performedBy: 'system@property-match', isAiAction: true, organizationId: 'org-wincasa', notes: 'Grundriss fehlt; Qualitätsscore 61 — unter Schwellenwert', createdAt: '2026-05-13T07:30:00Z', propertyId: 'prop-007' },
// prop-014
{ id: 'p14-001', type: 'PROPERTY_CREATED', category: 'GOVERNANCE', entityId: 'prop-014', entityType: 'PROPERTY', performedBy: 'manager@wincasa.ch', isAiAction: false, organizationId: 'org-wincasa', notes: 'Logistikhalle Pratteln erfasst', createdAt: '2026-01-10T09:00:00Z', propertyId: 'prop-014' },
{ id: 'p14-002', type: 'MATCH_GENERATED', category: 'MATCHING', entityId: 'need-040', entityType: 'NEED', performedBy: 'system@property-match', isAiAction: true, organizationId: 'org-wincasa', notes: 'Score 88 — Bedarf Logistik-Unternehmen: starke Übereinstimmung', createdAt: '2026-04-28T10:15:00Z', propertyId: 'prop-014' },
{ id: 'p14-003', type: 'REVIEW_REQUESTED', category: 'REVIEW', entityId: 'match-014', entityType: 'MATCH', performedBy: 'admin@ideal-sharing.ch', isAiAction: false, organizationId: 'org-wincasa', notes: 'Match zur Governance-Prüfung eingereicht — Interessent aus EU-Ausland', createdAt: '2026-05-05T13:00:00Z', propertyId: 'prop-014' },
{ id: 'p14-004', type: 'REVIEW_COMPLETED', category: 'REVIEW', entityId: 'match-014', entityType: 'MATCH', performedBy: 'reviewer@ideal-sharing.ch', isAiAction: false, organizationId: 'org-wincasa', notes: 'GENEHMIGT — Bonitätsprüfung bestanden, Match freigegeben', createdAt: '2026-05-07T15:30:00Z', propertyId: 'prop-014' },
// prop-015
{ id: 'p15-001', type: 'PROPERTY_CREATED', category: 'GOVERNANCE', entityId: 'prop-015', entityType: 'PROPERTY', performedBy: 'manager@wincasa.ch', isAiAction: false, organizationId: 'org-wincasa', notes: 'Bürofläche Zürich Oerlikon erfasst', createdAt: '2026-02-01T10:00:00Z', propertyId: 'prop-015' },
{ id: 'p15-002', type: 'PROPERTY_UPDATED', category: 'GOVERNANCE', entityId: 'prop-015', entityType: 'PROPERTY', performedBy: 'manager@wincasa.ch', isAiAction: false, organizationId: 'org-wincasa', notes: 'Mietpreis angepasst: CHF 195 → 210/m²/Jahr', createdAt: '2026-03-18T14:30:00Z', propertyId: 'prop-015' },
{ id: 'p15-003', type: 'DATA_QUALITY_FLAGGED', category: 'GOVERNANCE', entityId: 'prop-015', entityType: 'PROPERTY', performedBy: 'system@property-match', isAiAction: true, organizationId: 'org-wincasa', notes: 'Preisangabe 18 Monate alt — automatische Qualitätswarnung', createdAt: '2026-05-17T09:00:00Z', propertyId: 'prop-015' },
]
const store = [...mockActivityLog]
@@ -74,6 +106,10 @@ export const governanceService = {
const data = organizationId ? store.filter(e => e.organizationId === organizationId) : [...store]
return { data: data.sort((a, b) => b.createdAt.localeCompare(a.createdAt)), meta: { total: data.length, page: 1, pageSize: data.length, hasMore: false } }
},
async getActivityLogByProperty(propertyId: string): Promise<ListResponse<ActivityEvent>> {
const data = store.filter(e => e.propertyId === propertyId)
return { data: data.sort((a, b) => b.createdAt.localeCompare(a.createdAt)), meta: { total: data.length, page: 1, pageSize: data.length, hasMore: false } }
},
async logEvent(event: Omit<ActivityEvent, 'id' | 'createdAt'>): Promise<ItemResponse<ActivityEvent>> {
const data: ActivityEvent = { id: crypto.randomUUID(), ...event, createdAt: new Date().toISOString() }
store.push(data)