diff --git a/src/App.tsx b/src/App.tsx index a98bb28..4df412e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -25,6 +25,7 @@ const MatchCenter = lazy(() => import('./pages/supply/MatchCenter')) const Anfragencenter = lazy(() => import('./pages/supply/Anfragencenter')) const FutureAvailability = lazy(() => import('./pages/supply/FutureAvailability')) const DataQuality = lazy(() => import('./pages/supply/DataQuality')) +const ReminderManager = lazy(() => import('./pages/supply/ReminderManager')) const AISearch = lazy(() => import('./pages/demand/AISearch')) const Results = lazy(() => import('./pages/demand/Results')) @@ -56,6 +57,7 @@ function App() { } /> } /> } /> + } /> } /> diff --git a/src/components/layout/AppShell.tsx b/src/components/layout/AppShell.tsx index cb6ae55..8865ce8 100644 --- a/src/components/layout/AppShell.tsx +++ b/src/components/layout/AppShell.tsx @@ -38,6 +38,7 @@ import { MessageSquare, Menu, Kanban, + BellRing, } from 'lucide-react' import type { LucideIcon } from 'lucide-react' import { OrganizationContextBadge } from './OrganizationContextBadge' @@ -82,6 +83,7 @@ const WORKSPACE_CONFIG: Record = { navItems: [ { path: '/supply/dashboard', label: 'Übersicht', icon: LayoutDashboard }, { path: '/supply/properties', label: 'Meine Objekte', icon: Building2 }, + { path: '/supply/reminder-manager', label: 'Reminder Manager', icon: BellRing }, { path: '/supply/anfragen', label: 'Anfragencenter', icon: MessageSquare }, { path: '/supply/data-quality', label: 'Datenpflege', icon: CheckSquare }, { path: '/supply/market-intelligence', label: 'Markt Intelligence', icon: Radar }, @@ -525,6 +527,22 @@ export function AppShell() { } }, [location.pathname, activeWorkspace, setActiveWorkspace]) + // When the user changes (role switch), redirect to their first allowed workspace + // if the current workspace is no longer permitted + useEffect(() => { + if (!currentUser) return + const allowed = currentUser.allowedWorkspaces + if (!allowed.includes(activeWorkspace)) { + const first = allowed[0] + if (first) { + setActiveWorkspace(first) + navigate(WORKSPACE_CONFIG[first].firstPath, { replace: true }) + } + } + // currentUser object reference changes on every role switch — that's the correct trigger + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [currentUser]) + const handleWorkspaceClick = (workspace: WorkspaceType) => { setActiveWorkspace(workspace) navigate(WORKSPACE_CONFIG[workspace].firstPath) diff --git a/src/components/match-card/IntelligenceMatchCard.tsx b/src/components/match-card/IntelligenceMatchCard.tsx index 757850a..74ee9c5 100644 --- a/src/components/match-card/IntelligenceMatchCard.tsx +++ b/src/components/match-card/IntelligenceMatchCard.tsx @@ -27,12 +27,13 @@ const RESULT_TYPE_META: Record = { } const SOURCE_META: Record = { - JOB_POSTING: { label: 'Stelleninserate', icon: }, - PRESS: { label: 'Pressebericht', icon: }, - CONSTRUCTION_PERMIT:{ label: 'Baubewilligung', icon: }, - COMPANY_REPORT: { label: 'Geschäftsbericht',icon: }, - MARKET_DATA: { label: 'Marktdaten', icon: }, - MANUAL: { label: 'Analyst', icon: }, + JOB_POSTING: { label: 'Stelleninserate', icon: }, + PRESS: { label: 'Pressebericht', icon: }, + CONSTRUCTION_PERMIT:{ label: 'Baubewilligung', icon: }, + COMPANY_REPORT: { label: 'Geschäftsbericht', icon: }, + MARKET_DATA: { label: 'Marktdaten', icon: }, + MANUAL: { label: 'Analyst', icon: }, + LEASE_CONTRACT: { label: 'Vertrag verifiziert', icon: }, } const SIGNAL_TYPE_LABELS: Record = { @@ -42,6 +43,7 @@ const SIGNAL_TYPE_LABELS: Record = { RESTRUCTURING: 'Restrukturierung', PROJECT_DEVELOPMENT: 'Projektentwicklung', SPACE_CONSOLIDATION: 'Flächenkonsolidierung', + LEASE_EXPIRY: 'Vertragsende', } // Human-readable explanation of WHY a signal type is relevant to a space search diff --git a/src/components/match-card/MatchCardViewModel.ts b/src/components/match-card/MatchCardViewModel.ts index 36f10a7..e797c3d 100644 --- a/src/components/match-card/MatchCardViewModel.ts +++ b/src/components/match-card/MatchCardViewModel.ts @@ -50,7 +50,7 @@ export interface MatchCardViewModel { taxCalculatorUrl?: string // deeplink to cantonal tax calculator // Schattenmarkt / FUTURE_AVAILABILITY signal fields - signalSourceType?: 'PRESS' | 'CONSTRUCTION_PERMIT' | 'JOB_POSTING' | 'COMPANY_REPORT' | 'MARKET_DATA' | 'MANUAL' + signalSourceType?: 'PRESS' | 'CONSTRUCTION_PERMIT' | 'JOB_POSTING' | 'COMPANY_REPORT' | 'MARKET_DATA' | 'MANUAL' | 'LEASE_CONTRACT' signalSourceUrl?: string signalSourceCredibility?: 'LOW' | 'MEDIUM' | 'HIGH' signalProbability?: number diff --git a/src/components/supply/PropertyDetailView.tsx b/src/components/supply/PropertyDetailView.tsx index f5fd328..a227294 100644 --- a/src/components/supply/PropertyDetailView.tsx +++ b/src/components/supply/PropertyDetailView.tsx @@ -11,6 +11,7 @@ import { Divider, IconButton, LinearProgress, + Switch, Tab, Tabs, TextField, @@ -18,7 +19,7 @@ import { Typography, } from '@mui/material' import { useQueryClient } from '@tanstack/react-query' -import { ChevronDown, ChevronUp, Edit2, Layers, Save, Users, X } from 'lucide-react' +import { ChevronDown, ChevronUp, Clock, Edit2, Layers, Save, ShieldCheck, Users, X, Zap } from 'lucide-react' import { useOfferWizardStore } from '../../stores/offerWizardStore' import { PropertyMap } from '../shared' import { NeedMatchCard } from './NeedMatchCard' @@ -308,6 +309,160 @@ function UnitStructurePanel({ p }: { p: Property }) { ) } +// ── Schattenmarkt release panel ─────────────────────────────────────────────── + +const MOCK_TODAY = new Date('2026-05-20') + +function SchattenmarktReleasePanel({ p }: { p: Property }) { + const [enabled, setEnabled] = useState(p.schattenmarktRelease?.enabled ?? false) + const [leadTimeMonths, setLeadTimeMonths] = useState(p.schattenmarktRelease?.leadTimeMonths ?? 6) + const [saving, setSaving] = useState(false) + const queryClient = useQueryClient() + const showToast = useToastStore(s => s.showToast) + + if (p.resultType !== 'VERIFIED_PORTFOLIO') return null + if (!p.leaseEndDate && !p.breakoutOptionDate) return null + + const candidates: Date[] = [] + if (p.leaseEndDate) { + const d = new Date(p.leaseEndDate); d.setMonth(d.getMonth() - leadTimeMonths); candidates.push(d) + } + if (p.breakoutOption && p.breakoutOptionDate) { + const d = new Date(p.breakoutOptionDate); d.setMonth(d.getMonth() - leadTimeMonths); candidates.push(d) + } + const triggerDate = candidates.length ? candidates.reduce((a, b) => (a < b ? a : b)) : null + const isActive = triggerDate ? MOCK_TODAY >= triggerDate : false + + const targetDate = (p.breakoutOption && p.breakoutOptionDate) + ? new Date(p.breakoutOptionDate) + : p.leaseEndDate ? new Date(p.leaseEndDate) : null + const monthsUntil = targetDate + ? Math.max(0, Math.round((targetDate.getTime() - MOCK_TODAY.getTime()) / (1000 * 60 * 60 * 24 * 30))) + : null + + async function save(nextEnabled: boolean, nextLeadTime: number) { + setSaving(true) + try { + await propertyService.update(p.id, { schattenmarktRelease: { enabled: nextEnabled, leadTimeMonths: nextLeadTime } }) + await queryClient.invalidateQueries({ queryKey: ['property', p.id] }) + await queryClient.invalidateQueries({ queryKey: ['properties'] }) + showToast(nextEnabled ? 'Schattenmarkt-Freigabe aktiviert.' : 'Schattenmarkt-Freigabe deaktiviert.', 'success') + } catch { + showToast('Fehler beim Speichern.', 'error') + } finally { + setSaving(false) + } + } + + function handleToggle(_: React.ChangeEvent, checked: boolean) { + setEnabled(checked) + save(checked, leadTimeMonths) + } + + function handleLeadTime(months: number) { + setLeadTimeMonths(months) + if (enabled) save(enabled, months) + } + + return ( + <> + + + + + + + + + Für Schattenmarkt freigeben + + + Nachfragesuchende sehen dieses Objekt vor Vertragsende im Feed + + + + + {saving && } + + + + + {enabled && ( + + + + Lead Time + + + {[3, 4, 5, 6, 8, 12].map(m => ( + handleLeadTime(m)} + sx={{ + height: 20, fontSize: '0.68rem', cursor: 'pointer', + bgcolor: leadTimeMonths === m ? '#7c3aed' : '#f1f5f9', + color: leadTimeMonths === m ? 'white' : '#374151', + '&:hover': { bgcolor: leadTimeMonths === m ? '#6d28d9' : '#e2e8f0' }, + }} + /> + ))} + + + + + {isActive + ? + : + } + + {isActive + ? `Signal aktiv seit ${triggerDate?.toLocaleDateString('de-CH')} — Objekt im Nachfrage-Feed sichtbar` + : triggerDate + ? `Signal aktiv ab ${triggerDate.toLocaleDateString('de-CH')} — noch ${monthsUntil} Monate bis Vertragsende` + : 'Kein Vertragsende hinterlegt' + } + + + + )} + + {!enabled && ( + + Wenn aktiviert, erscheint dieses Objekt {leadTimeMonths} Monate vor Vertragsende als + verifiziertes Schattenmarkt-Signal im Nachfrage-Feed. + + )} + + + ) +} + // ── Übersicht tab ───────────────────────────────────────────────────────────── interface OverviewPanelProps { @@ -418,6 +573,9 @@ function OverviewPanel({ p, editing, draft, onDraftChange }: OverviewPanelProps) {/* Floor / unit structure */} + {/* Schattenmarkt release */} + + {/* Object details */} diff --git a/src/components/supply/ReminderCard.tsx b/src/components/supply/ReminderCard.tsx new file mode 100644 index 0000000..25adbb6 --- /dev/null +++ b/src/components/supply/ReminderCard.tsx @@ -0,0 +1,124 @@ +import { Card, CardContent, Box, Typography, Chip, IconButton, Tooltip, Divider } from '@mui/material' +import { Check, Bell, X, MapPin, Calendar } from 'lucide-react' +import type { Reminder } from '../../domain/reminder' +import { ReminderPriorityBadge } from './ReminderPriorityBadge' +import { ReminderTypeBadge } from './ReminderTypeBadge' +import { ReminderDaysIndicator } from './ReminderDaysIndicator' +import { useCompleteReminder, useDismissReminder, useSnoozeReminder } from '../../hooks/useReminders' +import { useReminderStore } from '../../stores/reminderStore' +import { ReminderStatus } from '../../domain/reminder' + +const STATUS_CHIP_COLOR: Record = { + ACTIVE: 'info', + SNOOZED: 'warning', + COMPLETED: 'success', + DISMISSED: 'default', +} + +const STATUS_LABEL: Record = { + ACTIVE: 'Aktiv', + SNOOZED: 'Schlummernd', + COMPLETED: 'Erledigt', + DISMISSED: 'Verworfen', +} + +interface Props { + reminder: Reminder +} + +export function ReminderCard({ reminder }: Props) { + const { setSelectedId, setDrawerOpen } = useReminderStore() + const complete = useCompleteReminder() + const dismiss = useDismissReminder() + const snooze = useSnoozeReminder() + + const isActionable = reminder.status === ReminderStatus.ACTIVE || reminder.status === ReminderStatus.SNOOZED + + return ( + { setSelectedId(reminder.id); setDrawerOpen(true) }} + > + + {/* Top row */} + + + + + + + + + {/* Property */} + + {reminder.propertyTitle} + + + + + {reminder.propertyCity}{reminder.propertyDistrict ? ` · ${reminder.propertyDistrict}` : ''} + + + + {reminder.tenantName} · {reminder.areaSqm.toLocaleString('de-CH')} m² + + + + + {/* Due date */} + + + + Fällig: {new Date(reminder.dueDate).toLocaleDateString('de-CH')} + + + + + + + {/* Actions */} + {isActionable && ( + e.stopPropagation()}> + + complete.mutate({ id: reminder.id })} + sx={{ color: '#16a34a', border: '1px solid #dcfce7', borderRadius: 1 }} + > + + + + + snooze.mutate({ id: reminder.id, until: '2026-05-27' })} + sx={{ color: '#ca8a04', border: '1px solid #fef9c3', borderRadius: 1 }} + > + + + + + dismiss.mutate({ id: reminder.id })} + sx={{ color: '#dc2626', border: '1px solid #fee2e2', borderRadius: 1 }} + > + + + + + )} + + + ) +} diff --git a/src/components/supply/ReminderDaysIndicator.tsx b/src/components/supply/ReminderDaysIndicator.tsx new file mode 100644 index 0000000..b05a6c5 --- /dev/null +++ b/src/components/supply/ReminderDaysIndicator.tsx @@ -0,0 +1,39 @@ +import { Typography } from '@mui/material' + +const MOCK_TODAY = new Date('2026-05-20') + +function getDays(isoDate: string): number { + const due = new Date(isoDate) + return Math.ceil((due.getTime() - MOCK_TODAY.getTime()) / (1000 * 60 * 60 * 24)) +} + +function getColor(days: number): string { + if (days <= 0) return '#dc2626' + if (days <= 14) return '#dc2626' + if (days <= 30) return '#ea580c' + if (days <= 60) return '#ca8a04' + return '#16a34a' +} + +interface Props { + dueDate: string +} + +export function ReminderDaysIndicator({ dueDate }: Props) { + const days = getDays(dueDate) + const color = getColor(days) + + if (days <= 0) { + return ( + + Überfällig + + ) + } + + return ( + + in {days} {days === 1 ? 'Tag' : 'Tagen'} + + ) +} diff --git a/src/components/supply/ReminderDetailDrawer.tsx b/src/components/supply/ReminderDetailDrawer.tsx new file mode 100644 index 0000000..2c72ba4 --- /dev/null +++ b/src/components/supply/ReminderDetailDrawer.tsx @@ -0,0 +1,340 @@ +import { useState } from 'react' +import { + Drawer, + Box, + Typography, + IconButton, + Chip, + Divider, + TextField, + Button, + Switch, + FormControlLabel, + Tooltip, +} from '@mui/material' +import { X, MapPin, Calendar, DollarSign, Eye, Activity } from 'lucide-react' +import { useReminderStore } from '../../stores/reminderStore' +import { useReminder, useCompleteReminder, useDismissReminder, useSnoozeReminder } from '../../hooks/useReminders' +import { ReminderPriorityBadge } from './ReminderPriorityBadge' +import { ReminderTypeBadge } from './ReminderTypeBadge' +import { ReminderDaysIndicator } from './ReminderDaysIndicator' +import { ReminderStatus, ShadowMarketRisk } from '../../domain/reminder' +import type { ReminderActivity } from '../../domain/reminder' + +const SHADOW_RISK_COLOR: Record = { + NONE: '#64748b', + LOW: '#16a34a', + MEDIUM: '#ca8a04', + HIGH: '#dc2626', +} + +const SHADOW_RISK_LABEL: Record = { + NONE: 'Kein Risiko', + LOW: 'Niedrig', + MEDIUM: 'Mittel', + HIGH: 'Hoch', +} + +const STATUS_CHIP_COLOR: Record = { + ACTIVE: 'info', + SNOOZED: 'warning', + COMPLETED: 'success', + DISMISSED: 'default', +} + +const STATUS_LABEL: Record = { + ACTIVE: 'Aktiv', + SNOOZED: 'Schlummernd', + COMPLETED: 'Erledigt', + DISMISSED: 'Verworfen', +} + +const ACTION_LABEL: Record = { + CREATED: 'Erstellt', + SNOOZED: 'Zurückgestellt', + COMPLETED: 'Erledigt', + DISMISSED: 'Verworfen', + NOTED: 'Notiz', +} + +function SectionTitle({ icon, children }: { icon: React.ReactNode; children: React.ReactNode }) { + return ( + + {icon} + + {children} + + + ) +} + +function DateRow({ label, value }: { label: string; value?: string }) { + if (!value) return null + return ( + + {label} + {new Date(value).toLocaleDateString('de-CH')} + + ) +} + +function ActivityEntry({ entry }: { entry: ReminderActivity }) { + return ( + + + + + + + + + {ACTION_LABEL[entry.action]} + + + {entry.by} · {new Date(entry.at).toLocaleDateString('de-CH')} + + + {entry.note && ( + + {entry.note} + + )} + + + ) +} + +export function ReminderDetailDrawer() { + const { selectedId, drawerOpen, setDrawerOpen, setSelectedId } = useReminderStore() + const { data: reminder } = useReminder(selectedId ?? '') + const complete = useCompleteReminder() + const dismiss = useDismissReminder() + const snooze = useSnoozeReminder() + + const [noteValue, setNoteValue] = useState('') + const [snoozeDate, setSnoozeDate] = useState('') + + function handleClose() { + setDrawerOpen(false) + setSelectedId(null) + } + + const isNew = !selectedId + + return ( + + {/* Header */} + + + {isNew ? ( + Neuer Reminder + ) : reminder ? ( + <> + + + + + + + + ) : null} + + + + + + + {/* Scrollable body */} + + {isNew && ( + + Neue Reminder-Erstellung noch nicht implementiert. + + )} + + {reminder && ( + <> + {/* 2. Property */} + + }>Objekt + + {reminder.propertyTitle} + + {reminder.propertyCity}{reminder.propertyDistrict ? ` · ${reminder.propertyDistrict}` : ''} + + Mieter: {reminder.tenantName} + Fläche: {reminder.areaSqm.toLocaleString('de-CH')} m² + + + + + + {/* 3. Dates */} + + }>Daten & Fristen + + + + + + {reminder.snoozedUntil && ( + + )} + + + + + + {/* 4. Financials */} + + }>Finanzen + + + Miete/m² + + {reminder.currency} {reminder.currentRentPerSqm.toLocaleString('de-CH')} / Monat + + + + Total / Monat + + {reminder.currency} {(reminder.currentRentPerSqm * reminder.areaSqm).toLocaleString('de-CH')} + + + + + + + + {/* 5. Schattenmarkt */} + + }>Schattenmarkt-Risiko + + + + + {SHADOW_RISK_LABEL[reminder.shadowMarketRisk]} + + + + } + label={ + + Schattenmarkt aktiviert + + } + /> + + + + + + + {/* 6. Note */} + + Notiz + setNoteValue(e.target.value)} + placeholder="Notiz hinzufügen…" + sx={{ '& .MuiInputBase-root': { fontSize: '0.8125rem' } }} + /> + + + {/* Actions */} + {(reminder.status === ReminderStatus.ACTIVE || reminder.status === ReminderStatus.SNOOZED) && ( + <> + + + Aktionen + + + + + + setSnoozeDate(e.target.value)} + sx={{ flex: 1, '& .MuiInputBase-root': { fontSize: '0.8125rem' } }} + /> + + + + + )} + + + + {/* 7. Activity */} + + }>Aktivitätslog + + {[...reminder.activity].reverse().map((entry, i) => ( + + ))} + + + + )} + + + ) +} diff --git a/src/components/supply/ReminderEmptyState.tsx b/src/components/supply/ReminderEmptyState.tsx new file mode 100644 index 0000000..ae88f66 --- /dev/null +++ b/src/components/supply/ReminderEmptyState.tsx @@ -0,0 +1,48 @@ +import { Box, Typography, Button } from '@mui/material' +import { BellOff } from 'lucide-react' + +interface Props { + onReset?: () => void +} + +export function ReminderEmptyState({ onReset }: Props) { + return ( + + + + + + + Keine Reminder + + + Keine Reminder entsprechen dem aktuellen Filter. + + + {onReset && ( + + )} + + ) +} diff --git a/src/components/supply/ReminderFeed.tsx b/src/components/supply/ReminderFeed.tsx new file mode 100644 index 0000000..43d2a45 --- /dev/null +++ b/src/components/supply/ReminderFeed.tsx @@ -0,0 +1,97 @@ +import { Box, Typography } from '@mui/material' +import { useReminders } from '../../hooks/useReminders' +import { useReminderStore } from '../../stores/reminderStore' +import { ReminderListRow } from './ReminderListRow' +import { ReminderCard } from './ReminderCard' +import { ReminderSkeleton } from './ReminderSkeleton' +import { ReminderEmptyState } from './ReminderEmptyState' +import type { Reminder } from '../../domain/reminder' + +const LIST_HEADER_COLS = '100px 130px 1fr 140px 90px 100px 90px' + +function applyFilters( + reminders: Reminder[], + filterType: string, + filterStatus: string, + filterPriority: string, + searchQuery: string, +): Reminder[] { + return reminders.filter(r => { + if (filterType !== 'ALL' && r.type !== filterType) return false + if (filterStatus !== 'ALL' && r.status !== filterStatus) return false + if (filterPriority !== 'ALL' && r.priority !== filterPriority) return false + if (searchQuery) { + const q = searchQuery.toLowerCase() + const haystack = `${r.propertyTitle} ${r.propertyCity} ${r.tenantName}`.toLowerCase() + if (!haystack.includes(q)) return false + } + return true + }) +} + +export function ReminderFeed() { + const { data, isLoading } = useReminders() + const { + filterType, filterStatus, filterPriority, searchQuery, viewMode, + setFilterType, setFilterStatus, setFilterPriority, setSearchQuery, + } = useReminderStore() + + if (isLoading) return + + const reminders = data ?? [] + const filtered = applyFilters(reminders, filterType, filterStatus, filterPriority, searchQuery) + + function resetFilters() { + setFilterType('ALL') + setFilterStatus('ALL') + setFilterPriority('ALL') + setSearchQuery('') + } + + if (filtered.length === 0) { + return + } + + if (viewMode === 'card') { + return ( + + {filtered.map(r => ( + + ))} + + ) + } + + return ( + + {/* Table header */} + + {['Priorität', 'Typ', 'Objekt / Mieter', 'Fälligkeit', 'Fläche', 'Status', 'Aktionen'].map(h => ( + + {h} + + ))} + + + {filtered.map(r => ( + + ))} + + ) +} diff --git a/src/components/supply/ReminderFilterBar.tsx b/src/components/supply/ReminderFilterBar.tsx new file mode 100644 index 0000000..dbd4974 --- /dev/null +++ b/src/components/supply/ReminderFilterBar.tsx @@ -0,0 +1,136 @@ +import { Box, ToggleButtonGroup, ToggleButton, TextField, InputAdornment, Typography } from '@mui/material' +import { Search } from 'lucide-react' +import { useReminderStore } from '../../stores/reminderStore' +import { ReminderType, ReminderStatus, ReminderPriority } from '../../domain/reminder' +import type { ReminderType as ReminderTypeType, ReminderStatus as ReminderStatusType, ReminderPriority as ReminderPriorityType } from '../../domain/reminder' + +const TYPE_LABELS: Record = { + LEASE_EXPIRY: 'Mietablauf', + BREAK_OPTION: 'Break-Option', + RENT_REVIEW: 'Mietanpassung', + INSPECTION: 'Inspektion', + INSURANCE_RENEWAL: 'Versicherung', + MAINTENANCE: 'Unterhalt', + SCHATTENMARKT_RELEASE: 'Schattenmarkt', + CUSTOM: 'Individuell', +} + +const STATUS_LABELS: Record = { + ACTIVE: 'Aktiv', + SNOOZED: 'Schlummern', + COMPLETED: 'Erledigt', + DISMISSED: 'Verworfen', +} + +const PRIORITY_LABELS: Record = { + URGENT: 'Dringend', + HIGH: 'Hoch', + MEDIUM: 'Mittel', + LOW: 'Niedrig', +} + +export function ReminderFilterBar() { + const { + filterType, setFilterType, + filterStatus, setFilterStatus, + filterPriority, setFilterPriority, + searchQuery, setSearchQuery, + viewMode, setViewMode, + } = useReminderStore() + + return ( + + + {/* Search */} + setSearchQuery(e.target.value)} + slotProps={{ + input: { + startAdornment: ( + + + + ), + }, + }} + sx={{ width: 220, '& .MuiInputBase-root': { fontSize: '0.8125rem' } }} + /> + + {/* View mode */} + + Ansicht: + v && setViewMode(v)} + size="small" + > + Liste + Karten + + + + + + {/* Type filter */} + + Typ: + v && setFilterType(v)} + size="small" + > + Alle + {Object.values(ReminderType).map(t => ( + + {TYPE_LABELS[t]} + + ))} + + + + + + {/* Status filter */} + + Status: + v && setFilterStatus(v)} + size="small" + > + Alle + {Object.values(ReminderStatus).map(s => ( + + {STATUS_LABELS[s]} + + ))} + + + + {/* Priority filter */} + + Priorität: + v && setFilterPriority(v)} + size="small" + > + Alle + {Object.values(ReminderPriority).map(p => ( + + {PRIORITY_LABELS[p]} + + ))} + + + + + ) +} diff --git a/src/components/supply/ReminderHeader.tsx b/src/components/supply/ReminderHeader.tsx new file mode 100644 index 0000000..272401c --- /dev/null +++ b/src/components/supply/ReminderHeader.tsx @@ -0,0 +1,45 @@ +import { Box, Typography, Button } from '@mui/material' +import { Plus } from 'lucide-react' +import { useReminderStore } from '../../stores/reminderStore' + +export function ReminderHeader() { + const { setDrawerOpen, setSelectedId } = useReminderStore() + + function handleCreate() { + setSelectedId(null) + setDrawerOpen(true) + } + + return ( + + + + Reminder Manager + + + Fristen, Vertragsereignisse und Aufgaben für Ihr Portfolio + + + + + ) +} diff --git a/src/components/supply/ReminderKpiBar.tsx b/src/components/supply/ReminderKpiBar.tsx new file mode 100644 index 0000000..7e5093f --- /dev/null +++ b/src/components/supply/ReminderKpiBar.tsx @@ -0,0 +1,95 @@ +import { Box, Paper, Typography, Skeleton } from '@mui/material' +import { AlertTriangle, Calendar, CalendarDays, Eye } from 'lucide-react' +import { useReminderInsights } from '../../hooks/useReminders' + +interface KpiItemProps { + icon: React.ReactNode + label: string + value: number | string + color: string +} + +function KpiItem({ icon, label, value, color }: KpiItemProps) { + return ( + + + {icon} + + + + {value} + + + {label} + + + + ) +} + +export function ReminderKpiBar() { + const { data, isLoading } = useReminderInsights() + + if (isLoading) { + return ( + + {[1, 2, 3, 4].map(i => ( + + ))} + + ) + } + + const insights = data ?? { urgentCount: 0, dueThisWeek: 0, dueThisMonth: 0, schattenmarktReadyCount: 0 } + + return ( + + } + label="Dringend" + value={insights.urgentCount} + color="#dc2626" + /> + } + label="Diese Woche" + value={insights.dueThisWeek} + color="#ea580c" + /> + } + label="Dieser Monat" + value={insights.dueThisMonth} + color="#0369a1" + /> + } + label="Schattenmarkt-Risiko" + value={insights.schattenmarktReadyCount} + color="#be185d" + /> + + ) +} diff --git a/src/components/supply/ReminderListRow.tsx b/src/components/supply/ReminderListRow.tsx new file mode 100644 index 0000000..23e4b96 --- /dev/null +++ b/src/components/supply/ReminderListRow.tsx @@ -0,0 +1,142 @@ +import { Box, Typography, Chip, IconButton, Tooltip } from '@mui/material' +import { Check, Bell, X } from 'lucide-react' +import type { Reminder } from '../../domain/reminder' +import { ReminderPriorityBadge } from './ReminderPriorityBadge' +import { ReminderTypeBadge } from './ReminderTypeBadge' +import { ReminderDaysIndicator } from './ReminderDaysIndicator' +import { useCompleteReminder, useDismissReminder, useSnoozeReminder } from '../../hooks/useReminders' +import { useReminderStore } from '../../stores/reminderStore' +import { ReminderStatus } from '../../domain/reminder' + +const STATUS_CHIP_COLOR: Record = { + ACTIVE: 'info', + SNOOZED: 'warning', + COMPLETED: 'success', + DISMISSED: 'default', +} + +const STATUS_LABEL: Record = { + ACTIVE: 'Aktiv', + SNOOZED: 'Schlummernd', + COMPLETED: 'Erledigt', + DISMISSED: 'Verworfen', +} + +interface Props { + reminder: Reminder +} + +export function ReminderListRow({ reminder }: Props) { + const { setSelectedId, setDrawerOpen } = useReminderStore() + const complete = useCompleteReminder() + const dismiss = useDismissReminder() + const snooze = useSnoozeReminder() + + const isActionable = reminder.status === ReminderStatus.ACTIVE || reminder.status === ReminderStatus.SNOOZED + + function handleRowClick() { + setSelectedId(reminder.id) + setDrawerOpen(true) + } + + function handleComplete(e: React.MouseEvent) { + e.stopPropagation() + complete.mutate({ id: reminder.id }) + } + + function handleDismiss(e: React.MouseEvent) { + e.stopPropagation() + dismiss.mutate({ id: reminder.id }) + } + + function handleSnooze(e: React.MouseEvent) { + e.stopPropagation() + // Snooze 7 days from mock today + snooze.mutate({ id: reminder.id, until: '2026-05-27' }) + } + + return ( + + {/* Priority badge */} + + + + + {/* Type badge */} + + + + + {/* Property + tenant */} + + + {reminder.propertyTitle} + + + {reminder.propertyCity} · {reminder.tenantName} + + + + {/* Due date */} + + + {new Date(reminder.dueDate).toLocaleDateString('de-CH')} + + + + + {/* Area */} + + {reminder.areaSqm.toLocaleString('de-CH')} m² + + + {/* Status chip */} + + + + + {/* Actions */} + e.stopPropagation()}> + {isActionable && ( + <> + + + + + + + + + + + + + + + + + )} + + + ) +} diff --git a/src/components/supply/ReminderPriorityBadge.tsx b/src/components/supply/ReminderPriorityBadge.tsx new file mode 100644 index 0000000..ff39578 --- /dev/null +++ b/src/components/supply/ReminderPriorityBadge.tsx @@ -0,0 +1,25 @@ +import { Box, Typography } from '@mui/material' +import type { ReminderPriority } from '../../domain/reminder' + +const CONFIG: Record = { + URGENT: { color: '#dc2626', label: 'Dringend' }, + HIGH: { color: '#ea580c', label: 'Hoch' }, + MEDIUM: { color: '#ca8a04', label: 'Mittel' }, + LOW: { color: '#64748b', label: 'Niedrig' }, +} + +interface Props { + priority: ReminderPriority +} + +export function ReminderPriorityBadge({ priority }: Props) { + const { color, label } = CONFIG[priority] + return ( + + + + {label} + + + ) +} diff --git a/src/components/supply/ReminderSkeleton.tsx b/src/components/supply/ReminderSkeleton.tsx new file mode 100644 index 0000000..b683368 --- /dev/null +++ b/src/components/supply/ReminderSkeleton.tsx @@ -0,0 +1,30 @@ +import { Box, Skeleton } from '@mui/material' + +export function ReminderSkeleton() { + return ( + + {[1, 2, 3, 4, 5].map((i) => ( + + + + + + + + + ))} + + ) +} diff --git a/src/components/supply/ReminderTypeBadge.tsx b/src/components/supply/ReminderTypeBadge.tsx new file mode 100644 index 0000000..3d8a796 --- /dev/null +++ b/src/components/supply/ReminderTypeBadge.tsx @@ -0,0 +1,43 @@ +import { Box, Typography } from '@mui/material' +import { + FileText, + ArrowRightLeft, + TrendingUp, + ClipboardCheck, + Shield, + Wrench, + Eye, + Tag, +} from 'lucide-react' +import type { LucideIcon } from 'lucide-react' +import type { ReminderType } from '../../domain/reminder' + +const CONFIG: Record = { + LEASE_EXPIRY: { Icon: FileText, label: 'Mietablauf', color: '#1e3a5f' }, + BREAK_OPTION: { Icon: ArrowRightLeft, label: 'Break-Option', color: '#7c3aed' }, + RENT_REVIEW: { Icon: TrendingUp, label: 'Mietanpassung', color: '#0369a1' }, + INSPECTION: { Icon: ClipboardCheck, label: 'Inspektion', color: '#065f46' }, + INSURANCE_RENEWAL: { Icon: Shield, label: 'Versicherung', color: '#92400e' }, + MAINTENANCE: { Icon: Wrench, label: 'Unterhalt', color: '#374151' }, + SCHATTENMARKT_RELEASE:{ Icon: Eye, label: 'Schattenmarkt', color: '#be185d' }, + CUSTOM: { Icon: Tag, label: 'Individuell', color: '#64748b' }, +} + +interface Props { + type: ReminderType + compact?: boolean +} + +export function ReminderTypeBadge({ type, compact = false }: Props) { + const { Icon, label, color } = CONFIG[type] + return ( + + + {!compact && ( + + {label} + + )} + + ) +} diff --git a/src/domain/enums.ts b/src/domain/enums.ts index 8211397..5b1a664 100644 --- a/src/domain/enums.ts +++ b/src/domain/enums.ts @@ -161,6 +161,7 @@ export const SignalType = { RESTRUCTURING: 'RESTRUCTURING', PROJECT_DEVELOPMENT: 'PROJECT_DEVELOPMENT', SPACE_CONSOLIDATION: 'SPACE_CONSOLIDATION', + LEASE_EXPIRY: 'LEASE_EXPIRY', } as const export type SignalType = typeof SignalType[keyof typeof SignalType] diff --git a/src/domain/futureSignal.ts b/src/domain/futureSignal.ts index f99b107..c463e7e 100644 --- a/src/domain/futureSignal.ts +++ b/src/domain/futureSignal.ts @@ -1,7 +1,7 @@ import type { SignalType, RiskLevel, ReviewStatus } from './enums' export interface SignalSource { - type: 'PRESS' | 'CONSTRUCTION_PERMIT' | 'JOB_POSTING' | 'COMPANY_REPORT' | 'MARKET_DATA' | 'MANUAL' + type: 'PRESS' | 'CONSTRUCTION_PERMIT' | 'JOB_POSTING' | 'COMPANY_REPORT' | 'MARKET_DATA' | 'MANUAL' | 'LEASE_CONTRACT' url?: string publishedAt?: string credibility: 'LOW' | 'MEDIUM' | 'HIGH' diff --git a/src/domain/property.ts b/src/domain/property.ts index 57ba142..d61ed33 100644 --- a/src/domain/property.ts +++ b/src/domain/property.ts @@ -180,6 +180,8 @@ export interface Property { importedAt?: string lastUpdatedAt?: string + schattenmarktRelease?: { enabled: boolean; leadTimeMonths: number } + status?: 'ACTIVE' | 'INACTIVE' | 'DRAFT' | 'ARCHIVED' lastReviewedAt?: string createdAt: string diff --git a/src/domain/reminder.ts b/src/domain/reminder.ts new file mode 100644 index 0000000..60e4064 --- /dev/null +++ b/src/domain/reminder.ts @@ -0,0 +1,76 @@ +export const ReminderType = { + LEASE_EXPIRY: 'LEASE_EXPIRY', + BREAK_OPTION: 'BREAK_OPTION', + RENT_REVIEW: 'RENT_REVIEW', + INSPECTION: 'INSPECTION', + INSURANCE_RENEWAL: 'INSURANCE_RENEWAL', + MAINTENANCE: 'MAINTENANCE', + SCHATTENMARKT_RELEASE: 'SCHATTENMARKT_RELEASE', + CUSTOM: 'CUSTOM', +} as const +export type ReminderType = typeof ReminderType[keyof typeof ReminderType] + +export const ReminderPriority = { + URGENT: 'URGENT', // ≤14 days + HIGH: 'HIGH', // 15–30 days + MEDIUM: 'MEDIUM', // 31–60 days + LOW: 'LOW', // >60 days +} as const +export type ReminderPriority = typeof ReminderPriority[keyof typeof ReminderPriority] + +export const ReminderStatus = { + ACTIVE: 'ACTIVE', + SNOOZED: 'SNOOZED', + COMPLETED: 'COMPLETED', + DISMISSED: 'DISMISSED', +} as const +export type ReminderStatus = typeof ReminderStatus[keyof typeof ReminderStatus] + +export const ShadowMarketRisk = { + NONE: 'NONE', + LOW: 'LOW', + MEDIUM: 'MEDIUM', + HIGH: 'HIGH', +} as const +export type ShadowMarketRisk = typeof ShadowMarketRisk[keyof typeof ShadowMarketRisk] + +export interface ReminderActivity { + at: string // ISO date + by: string // user display name + action: 'CREATED' | 'SNOOZED' | 'COMPLETED' | 'DISMISSED' | 'NOTED' + note?: string +} + +export interface Reminder { + id: string + type: ReminderType + priority: ReminderPriority + status: ReminderStatus + + propertyId: string + propertyTitle: string + propertyCity: string + propertyDistrict?: string + tenantName: string + + dueDate: string // ISO date — when action must be taken + eventDate: string // ISO date — contract event date (expiry / review / etc.) + contractEndDate?: string // ISO date — lease end + breakOptionDate?: string // ISO date + + areaSqm: number + currentRentPerSqm: number + currency: 'CHF' + + shadowMarketRisk: ShadowMarketRisk + schattenmarktEnabled: boolean + + note?: string + snoozedUntil?: string // ISO date + + activity: ReminderActivity[] + + organizationId: string + createdAt: string + updatedAt: string +} diff --git a/src/features/matching/matchCardAdapter.ts b/src/features/matching/matchCardAdapter.ts index a0403d2..5dabf18 100644 --- a/src/features/matching/matchCardAdapter.ts +++ b/src/features/matching/matchCardAdapter.ts @@ -87,6 +87,7 @@ export function buildMatchCardViewModel( id: result.matchId, title: property?.title ?? + signal?.title ?? signal?.companyName ?? signal?.locationHint ?? '–', diff --git a/src/hooks/useReminders.ts b/src/hooks/useReminders.ts new file mode 100644 index 0000000..ffbfe64 --- /dev/null +++ b/src/hooks/useReminders.ts @@ -0,0 +1,72 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { reminderService } from '../services/reminderService' + +export function useReminders() { + return useQuery({ + queryKey: ['reminders'], + queryFn: reminderService.getAll, + }) +} + +export function useReminder(id: string) { + return useQuery({ + queryKey: ['reminder', id], + queryFn: () => reminderService.getById(id), + enabled: !!id, + }) +} + +export function useReminderInsights() { + return useQuery({ + queryKey: ['reminder-insights'], + queryFn: reminderService.getInsights, + }) +} + +export function useCompleteReminder() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: ({ id, note }: { id: string; note?: string }) => + reminderService.complete(id, note), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['reminders'] }) + queryClient.invalidateQueries({ queryKey: ['reminder-insights'] }) + }, + }) +} + +export function useDismissReminder() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: ({ id, note }: { id: string; note?: string }) => + reminderService.dismiss(id, note), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['reminders'] }) + queryClient.invalidateQueries({ queryKey: ['reminder-insights'] }) + }, + }) +} + +export function useSnoozeReminder() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: ({ id, until }: { id: string; until: string }) => + reminderService.snooze(id, until), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['reminders'] }) + queryClient.invalidateQueries({ queryKey: ['reminder-insights'] }) + }, + }) +} + +export function useUpdateReminder() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: ({ id, data }: { id: string; data: Partial }) => + reminderService.update(id, data), + onSuccess: (_result, { id }) => { + queryClient.invalidateQueries({ queryKey: ['reminders'] }) + queryClient.invalidateQueries({ queryKey: ['reminder', id] }) + }, + }) +} diff --git a/src/hooks/useSchattenmarktSignals.ts b/src/hooks/useSchattenmarktSignals.ts new file mode 100644 index 0000000..d598a8a --- /dev/null +++ b/src/hooks/useSchattenmarktSignals.ts @@ -0,0 +1,72 @@ +import { useMemo } from 'react' +import type { Property } from '../domain/property' +import type { FutureSignal } from '../domain/futureSignal' +import { SignalType, RiskLevel, ResultType } from '../domain/enums' + +// Matches the mock date used throughout the prototype (currentDate context: 2026-05-20) +const MOCK_TODAY = new Date('2026-05-20') + +export function useSchattenmarktSignals(properties: Property[]): FutureSignal[] { + return useMemo(() => { + const signals: FutureSignal[] = [] + for (const p of properties) { + if (p.resultType !== ResultType.VERIFIED_PORTFOLIO) continue + const rel = p.schattenmarktRelease + if (!rel?.enabled) continue + const triggerDate = getEarliestTriggerDate(p, rel.leadTimeMonths) + if (!triggerDate || MOCK_TODAY < triggerDate) continue + signals.push(buildSignal(p)) + } + return signals + }, [properties]) +} + +function getEarliestTriggerDate(p: Property, leadTimeMonths: number): Date | null { + const candidates: Date[] = [] + if (p.leaseEndDate) { + const d = new Date(p.leaseEndDate) + d.setMonth(d.getMonth() - leadTimeMonths) + candidates.push(d) + } + if (p.breakoutOption && p.breakoutOptionDate) { + const d = new Date(p.breakoutOptionDate) + d.setMonth(d.getMonth() - leadTimeMonths) + candidates.push(d) + } + return candidates.length ? candidates.reduce((a, b) => (a < b ? a : b)) : null +} + +function buildSignal(p: Property): FutureSignal { + const targetDate = p.breakoutOption && p.breakoutOptionDate + ? new Date(p.breakoutOptionDate) + : p.leaseEndDate ? new Date(p.leaseEndDate) : new Date() + + const monthsUntil = Math.max(1, Math.round( + (targetDate.getTime() - MOCK_TODAY.getTime()) / (1000 * 60 * 60 * 24 * 30) + )) + + const locationLabel = `${p.location.city}${p.location.district ? `, ${p.location.district}` : ''}` + const monthName = targetDate.toLocaleDateString('de-CH', { month: 'long', year: 'numeric' }) + + return { + id: `schattenmarkt-${p.id}`, + signalType: SignalType.LEASE_EXPIRY, + propertyId: p.id, + title: `${p.title} — frei ab ${monthName}`, + locationHint: locationLabel, + areaSqmEstimate: p.areaSqm, + probability: 0.92, + confidenceScore: 0.92, + timeHorizonMonths: monthsUntil, + source: { type: 'LEASE_CONTRACT', credibility: 'HIGH' }, + sensitivityLevel: 'INTERNAL', + disclaimer: 'Verwaltung hat dieses Objekt für den Schattenmarkt freigegeben. Vertragsende aus internem ERP bestätigt — höchste Signalqualität.', + riskLevel: RiskLevel.LOW, + relevanceScore: 0.92, + isVerified: true, + organizationId: p.organizationId, + createdAt: MOCK_TODAY.toISOString(), + updatedAt: MOCK_TODAY.toISOString(), + aiSummary: `Vertrag der ${p.currentTenant ?? 'aktuellen Mietpartei'} läuft in ${monthsUntil} Monaten aus (${monthName}). Fläche: ${p.areaSqm.toLocaleString('de-CH')} m² · ${locationLabel}. Die Verwaltung hat dieses Objekt explizit für den Markt freigegeben — vertraglich bestätigt, keine Schätzung.`, + } +} diff --git a/src/hooks/useUnifiedResults.ts b/src/hooks/useUnifiedResults.ts index b9a6218..29c9bf9 100644 --- a/src/hooks/useUnifiedResults.ts +++ b/src/hooks/useUnifiedResults.ts @@ -2,6 +2,7 @@ import { useMemo } from 'react' import { useMatches, useMatchesByNeed } from './useMatches' import { useProperties } from './useProperties' import { useFutureSignals } from './useFutureSignals' +import { useSchattenmarktSignals } from './useSchattenmarktSignals' import type { UnifiedMatchResult, VerifiedPortfolioResult, @@ -25,31 +26,36 @@ export function useUnifiedResults(needId?: string) { const properties = propertiesQuery.data ?? [] const signals = signalsQuery.data ?? [] + const schattenmarktSignals = useSchattenmarktSignals(properties) + const allSignals = useMemo(() => [...signals, ...schattenmarktSignals], [signals, schattenmarktSignals]) + const data = useMemo((): UnifiedMatchResult[] => { return matches .flatMap((match): UnifiedMatchResult[] => { - const rt = match.resultType ?? 'VERIFIED_PORTFOLIO' + const refId = match.resultId ?? match.propertyId - if (rt === 'FUTURE_AVAILABILITY') { - const refId = match.resultId ?? match.propertyId - const signal = signals.find( - s => s.id === refId || s.propertyId === refId, - ) + // Fast path: explicit FUTURE_AVAILABILITY on the match (no property lookup needed) + if (match.resultType === 'FUTURE_AVAILABILITY') { + const signal = allSignals.find(s => s.id === refId || s.propertyId === refId) if (!signal) return [] - const result: FutureAvailabilityResult = { - matchId: match.id, - needId: match.needId, - matchScore: match.matchScore, - resultType: 'FUTURE_AVAILABILITY', - signal, - match, - } - return [result] + return [{ matchId: match.id, needId: match.needId, matchScore: match.matchScore, + resultType: 'FUTURE_AVAILABILITY', signal, match }] } - const property = properties.find(p => p.id === (match.resultId ?? match.propertyId)) + const property = properties.find(p => p.id === refId) if (!property) return [] + // Use match.resultType if set; otherwise fall back to the property's own resultType. + // This lets existing matches without an explicit resultType resolve correctly. + const rt = match.resultType ?? property.resultType ?? 'VERIFIED_PORTFOLIO' + + if (rt === 'FUTURE_AVAILABILITY') { + const signal = allSignals.find(s => s.id === refId || s.propertyId === refId) + if (!signal) return [] + return [{ matchId: match.id, needId: match.needId, matchScore: match.matchScore, + resultType: 'FUTURE_AVAILABILITY', signal, match }] + } + if (rt === 'EXTERNAL_MARKET' || rt === 'MAISON_WORK') { const result: ExternalMarketResult = { matchId: match.id, @@ -73,7 +79,7 @@ export function useUnifiedResults(needId?: string) { return [result] }) .sort((a, b) => b.matchScore - a.matchScore) - }, [matches, properties, signals]) + }, [matches, properties, allSignals]) return { data, isLoading, error } } diff --git a/src/mock-data/futureSignals.ts b/src/mock-data/futureSignals.ts index 9c36d5c..6a24634 100644 --- a/src/mock-data/futureSignals.ts +++ b/src/mock-data/futureSignals.ts @@ -6,6 +6,7 @@ export const mockFutureSignals: FutureSignal[] = [ { id: 'signal-001', signalType: SignalType.EXPANSION, + propertyId: 'prop-005', companyName: 'DataCloud Systems AG', locationHint: 'Zürich-West / Technopark', areaSqmEstimate: 600, @@ -430,4 +431,33 @@ export const mockFutureSignals: FutureSignal[] = [ createdAt: '2025-04-28T09:00:00Z', updatedAt: '2025-05-10T08:00:00Z', }, + + // --- signal-011: Mode Boutique Bern possible move-out Altstadt --- + { + id: 'signal-011', + signalType: SignalType.POSSIBLE_MOVE_OUT, + propertyId: 'prop-035', + companyName: 'Mode Boutique Bern AG', + locationHint: 'Bern Altstadt, Gerechtigkeitsgasse', + areaSqmEstimate: 290, + probability: 0.62, + confidenceScore: 0.58, + timeHorizonMonths: 10, + source: { + type: 'MARKET_DATA', + publishedAt: '2025-04-28', + credibility: 'MEDIUM', + }, + sensitivityLevel: 'INTERNAL', + disclaimer: 'Marktdaten deuten auf mögliche Verkleinerung hin. Kein bestätigter Auszug.', + riskLevel: RiskLevel.MEDIUM, + marketIndicator: 'Stationärer Handel Bern Altstadt: Leerstand +5% 2024', + relevanceScore: 0.66, + isVerified: false, + expiresAt: '2026-04-01', + organizationId: 'org-wincasa', + createdAt: '2025-04-28T09:00:00Z', + updatedAt: '2025-05-15T10:00:00Z', + aiSummary: 'Mode Boutique Bern AG zeigt gemäss LinkedIn-Analyse eine Mitarbeiterreduktion von 12 auf 8 Personen (-33%) innerhalb von 6 Monaten. Das Unternehmen hat ausserdem kürzlich den Sitz auf eine kleinere Adresse in der Berner Innenstadt aktualisiert. Die Kombination aus Stellenabbau und Adressänderung deutet auf eine Verkleinerung des Verkaufsbereichs hin.', + }, ] diff --git a/src/mock-data/matches.ts b/src/mock-data/matches.ts index 4630f8b..b339eba 100644 --- a/src/mock-data/matches.ts +++ b/src/mock-data/matches.ts @@ -42,26 +42,28 @@ export const mockMatches: Match[] = [ id: 'match-002', propertyId: 'prop-004', needId: 'need-001', - matchScore: 93, - matchStrength: MatchStrength.STRONG, + matchScore: 62, + matchStrength: MatchStrength.MODERATE, status: MatchStatus.SHORTLISTED, - scoreBreakdown: { hardMatchScore: 95, softFactorScore: 91, confidenceModifier: 0.82, dataQualityModifier: 0.75, totalScore: 93 }, + scoreBreakdown: { hardMatchScore: 72, softFactorScore: 62, confidenceModifier: 0.68, dataQualityModifier: 0.55, totalScore: 62 }, positiveFactors: [ - { criterion: 'Fläche', weight: 0.20, score: 88, contribution: 17.6, explanation: '1150m² liegt im erweiterten Korridor' }, - { criterion: 'Standort', weight: 0.20, score: 80, contribution: 16, explanation: 'Zürich Kreis 4 nahe bevorzugten Lagen' }, + { criterion: 'Fläche', weight: 0.20, score: 78, contribution: 15.6, explanation: '1150m² überschreitet Zielkorridor leicht (600–1000m²)' }, ], negativeFactors: [ - { criterion: 'Datenqualität', weight: 0.10, score: 40, contribution: 4, explanation: 'Mehrere kritische Felder fehlen – Verlässlichkeit eingeschränkt' }, - { criterion: 'Mietpreis', weight: 0.15, score: 55, contribution: 8.25, explanation: 'CHF 52/m² deutlich über Budget' }, + { criterion: 'Objekttyp', weight: 0.25, score: 30, contribution: 7.5, explanation: 'MIXED-Fläche – Bedarf ist OFFICE, Typ-Mismatch' }, + { criterion: 'Standort', weight: 0.20, score: 55, contribution: 11, explanation: 'Zürich Kreis 4 ist nicht Zürich-West – andere Lage' }, + { criterion: 'Mietpreis', weight: 0.15, score: 40, contribution: 6, explanation: 'CHF 52/m² ist 15% über Budget-Maximum (CHF 45/m²)' }, + { criterion: 'Datenqualität', weight: 0.10, score: 40, contribution: 4, explanation: 'Externe Quelle – Mietpreis und Verfügbarkeit nicht bestätigt' }, ], tradeoffs: [ - { criterion: 'Datenqualität', concern: 'Externe Quelle – Mietpreis und Verfügbarkeit nicht bestätigt', severity: 'HIGH', mitigation: 'Direkte Anfrage beim Anbieter empfohlen' }, - { criterion: 'Budget', concern: 'Mietpreis 30% über Budget-Maximum', severity: 'HIGH' }, + { criterion: 'Objekttyp', concern: 'MIXED-Fläche statt reiner Bürofläche – Nutzungseinschränkungen möglich', severity: 'HIGH' }, + { criterion: 'Budget', concern: 'Mietpreis 15% über Budget-Maximum', severity: 'HIGH' }, + { criterion: 'Standort', concern: 'Kreis 4 ist nicht Zürich-West – längere Pendeldistanz für Innovatech-Team', severity: 'MEDIUM' }, ], - explainabilitySummary: 'Moderater Match – Fläche und Lage passen, aber Mietpreis und Datenqualität sind kritische Vorbehalte.', - confidenceLevel: 0.58, + explainabilitySummary: 'Schwacher Match aufgrund Typ-Mismatch (MIXED statt OFFICE), falschem Stadtteil (Kreis 4 ≠ Zürich-West) und Budgetüberschreitung von 15%.', + confidenceLevel: 0.55, riskLevel: RiskLevel.MEDIUM, - uncertaintyIndicators: ['Daten aus Drittquelle unvollständig', 'Mietpreis nicht verifiziert'], + uncertaintyIndicators: ['Daten aus Drittquelle unvollständig', 'Mietpreis nicht verifiziert', 'Typ-Mismatch'], organizationId: 'org-wincasa', createdAt: '2025-05-10T08:05:00Z', updatedAt: '2025-05-10T08:05:00Z', @@ -181,21 +183,21 @@ export const mockMatches: Match[] = [ id: 'match-009', propertyId: 'prop-015', needId: 'need-001', - matchScore: 86, - matchStrength: MatchStrength.STRONG, + matchScore: 55, + matchStrength: MatchStrength.MODERATE, status: MatchStatus.SHORTLISTED, - scoreBreakdown: { hardMatchScore: 90, softFactorScore: 84, confidenceModifier: 0.76, dataQualityModifier: 0.72, totalScore: 86 }, + scoreBreakdown: { hardMatchScore: 68, softFactorScore: 60, confidenceModifier: 0.70, dataQualityModifier: 0.60, totalScore: 55 }, positiveFactors: [ { criterion: 'Fläche', weight: 0.20, score: 88, contribution: 17.6, explanation: '650m² im Zielkorridor' }, { criterion: 'Budget', weight: 0.15, score: 90, contribution: 13.5, explanation: 'CHF 38/m² unter Maximum' }, ], negativeFactors: [ - { criterion: 'Standort', weight: 0.20, score: 35, contribution: 7, explanation: 'Luzern liegt ausserhalb bevorzugter Lage Zürich' }, + { criterion: 'Standort', weight: 0.20, score: 35, contribution: 7, explanation: 'Luzern ist eine andere Stadt und ein anderer Kanton – nicht Zürich' }, ], tradeoffs: [ - { criterion: 'Standort', concern: 'Luzern ist nicht in Zürich – komplett andere Stadt und Kanton', severity: 'HIGH' }, + { criterion: 'Standort', concern: 'Luzern liegt 55 km von Zürich – kein Pendeln möglich', severity: 'HIGH' }, ], - explainabilitySummary: 'Fläche und Budget passen, aber die Lage in Luzern ist nicht mit dem Bedarf Zürich kompatibel. Nur als letzte Option geeignet.', + explainabilitySummary: 'Luzern passt nicht zu Zürich-West. Trotz passendem Budget und Fläche ist die Lage nicht kompatibel.', confidenceLevel: 0.55, riskLevel: RiskLevel.MEDIUM, uncertaintyIndicators: ['Standort ausserhalb bevorzugter Region'], @@ -466,22 +468,22 @@ export const mockMatches: Match[] = [ id: 'match-017', propertyId: 'prop-018', needId: 'need-003', - matchScore: 87, - matchStrength: MatchStrength.STRONG, + matchScore: 52, + matchStrength: MatchStrength.WEAK, status: MatchStatus.SHORTLISTED, - scoreBreakdown: { hardMatchScore: 92, softFactorScore: 85, confidenceModifier: 0.78, dataQualityModifier: 0.70, totalScore: 87 }, + scoreBreakdown: { hardMatchScore: 62, softFactorScore: 54, confidenceModifier: 0.68, dataQualityModifier: 0.57, totalScore: 52 }, positiveFactors: [ { criterion: 'Budget', weight: 0.20, score: 95, contribution: 19, explanation: 'CHF 31/m² deutlich unter Maximum' }, { criterion: 'Fläche', weight: 0.20, score: 88, contribution: 17.6, explanation: '780m² nahe am Zielkorridor' }, ], negativeFactors: [ - { criterion: 'Standort', weight: 0.25, score: 35, contribution: 8.75, explanation: 'Bern liegt ausserhalb Präferenz Basel' }, + { criterion: 'Standort', weight: 0.25, score: 35, contribution: 8.75, explanation: 'Bern liegt 90 km von Basel – kritisches Ausschlusskriterium' }, { criterion: 'Datenqualität', weight: 0.10, score: 42, contribution: 4.2, explanation: 'Externe Quelle, Renovierungsstand unklar' }, ], tradeoffs: [ - { criterion: 'Standort', concern: 'Bern ist nicht Basel – keine Nähe zur Pharma-Industrie-Achse', severity: 'HIGH' }, + { criterion: 'Standort', concern: 'Bern liegt 90 km von Basel – keine Nähe zur Pharma-Industrie-Achse', severity: 'HIGH' }, ], - explainabilitySummary: 'Schwacher Match aufgrund Standort-Mismatch. Bern ist nicht im Präferenzgebiet Basel. Budget und Fläche ok, aber Lage kritisch.', + explainabilitySummary: 'Bern liegt 90 km von Basel entfernt. Lage ist kritisches Ausschlusskriterium trotz passender Fläche und Budget.', confidenceLevel: 0.54, riskLevel: RiskLevel.MEDIUM, uncertaintyIndicators: ['Standort ausserhalb Präferenzregion', 'Externe Quelle'], @@ -1554,4 +1556,313 @@ export const mockMatches: Match[] = [ createdAt: '2025-05-12T08:15:00Z', updatedAt: '2025-05-12T08:15:00Z', }, + + // ─────────────────────────────────────────────────────────────────────────── + // need-001 · Innovatech AG · OFFICE Zürich-West — new external/maison matches + // ─────────────────────────────────────────────────────────────────────────── + + { + id: 'match-050', + propertyId: 'prop-031', + needId: 'need-001', + matchScore: 91, + matchStrength: MatchStrength.STRONG, + status: MatchStatus.PENDING_REVIEW, + scoreBreakdown: { hardMatchScore: 95, softFactorScore: 88, confidenceModifier: 0.72, dataQualityModifier: 0.64, totalScore: 91 }, + positiveFactors: [ + { criterion: 'Standort', weight: 0.25, score: 100, contribution: 25, explanation: 'Zürich-West trifft bevorzugte Lage exakt' }, + { criterion: 'Fläche', weight: 0.20, score: 100, contribution: 20, explanation: '780m² im Zielkorridor (600–1000m²)' }, + { criterion: 'Budget', weight: 0.15, score: 100, contribution: 15, explanation: 'CHF 35/m² liegt klar unter Maximum (CHF 45/m²)' }, + ], + negativeFactors: [ + { criterion: 'Datenqualität', weight: 0.10, score: 55, contribution: 5.5, explanation: 'Externe Quelle – Konditionen nicht endgültig bestätigt' }, + ], + tradeoffs: [ + { criterion: 'Datenqualität', concern: 'Direktinserat aus Drittquelle – Verfügbarkeit noch verifizieren', severity: 'LOW', mitigation: 'Direkte Anfrage beim Anbieter empfohlen' }, + ], + explainabilitySummary: 'Optimaler Match: Zürich-West, 780m², CHF 35/m² – alle drei Hauptkriterien vollständig erfüllt. Einziger Vorbehalt ist die externe Datenquelle.', + confidenceLevel: 0.72, + riskLevel: RiskLevel.MEDIUM, + uncertaintyIndicators: ['Daten aus Drittquelle'], + organizationId: 'org-wincasa', + createdAt: '2025-05-15T08:00:00Z', + updatedAt: '2025-05-15T08:00:00Z', + }, + + { + id: 'match-051', + propertyId: 'prop-032', + needId: 'need-001', + matchScore: 84, + matchStrength: MatchStrength.STRONG, + status: MatchStatus.PENDING_REVIEW, + scoreBreakdown: { hardMatchScore: 88, softFactorScore: 82, confidenceModifier: 0.71, dataQualityModifier: 0.62, totalScore: 84 }, + positiveFactors: [ + { criterion: 'Fläche', weight: 0.20, score: 100, contribution: 20, explanation: '720m² im Zielkorridor (600–1000m²)' }, + { criterion: 'Budget', weight: 0.15, score: 92, contribution: 13.8, explanation: 'CHF 40/m² unter Maximum (CHF 45/m²)' }, + { criterion: 'Standort', weight: 0.25, score: 88, contribution: 22, explanation: 'Kreis 5 direkt angrenzend an Zürich-West' }, + ], + negativeFactors: [ + { criterion: 'Datenqualität', weight: 0.10, score: 52, contribution: 5.2, explanation: 'Externe Quelle – Ausbaustandard nicht bestätigt' }, + ], + tradeoffs: [ + { criterion: 'Standort', concern: 'Kreis 5 ist nicht Zürich-West, aber angrenzend und ähnliches Profil', severity: 'LOW' }, + { criterion: 'Datenqualität', concern: 'Ausbaustandard aus externer Quelle – Besichtigung empfohlen', severity: 'MEDIUM' }, + ], + explainabilitySummary: 'Starker Match: Fläche und Budget erfüllt, Kreis 5 angrenzend an bevorzugte Lage. Maison Work-Plattform mit bewährten Objektqualitäten.', + confidenceLevel: 0.71, + riskLevel: RiskLevel.MEDIUM, + uncertaintyIndicators: ['Daten aus externer Quelle'], + organizationId: 'org-wincasa', + createdAt: '2025-05-15T08:05:00Z', + updatedAt: '2025-05-15T08:05:00Z', + }, + + // ─────────────────────────────────────────────────────────────────────────── + // need-002 · Schweizer Logistik GmbH · LOGISTICS Basel — new silver external + // ─────────────────────────────────────────────────────────────────────────── + + { + id: 'match-056', + propertyId: 'prop-036', + needId: 'need-002', + matchScore: 86, + matchStrength: MatchStrength.STRONG, + status: MatchStatus.PENDING_REVIEW, + scoreBreakdown: { hardMatchScore: 92, softFactorScore: 84, confidenceModifier: 0.70, dataQualityModifier: 0.60, totalScore: 86 }, + positiveFactors: [ + { criterion: 'Standort', weight: 0.25, score: 98, contribution: 24.5, explanation: 'Basel Kleinhüningen – exakt im Zielgebiet' }, + { criterion: 'Fläche', weight: 0.20, score: 100, contribution: 20, explanation: '2600m² im Zielkorridor (1500–4000m²)' }, + { criterion: 'Budget', weight: 0.15, score: 95, contribution: 14.25, explanation: 'CHF 15/m² unter Maximum (CHF 18/m²)' }, + ], + negativeFactors: [ + { criterion: 'Datenqualität', weight: 0.10, score: 50, contribution: 5, explanation: 'Hallenhöhe und Konditionen nicht final bestätigt' }, + ], + tradeoffs: [ + { criterion: 'Datenqualität', concern: 'Hallenhöhe aus Drittquelle – kritisch für Logistiknutzung', severity: 'MEDIUM', mitigation: 'Hallenhöhe vor Vertragsabschluss verifizieren' }, + ], + explainabilitySummary: 'Starker Match: Basel Kleinhüningen exakt, 2600m², CHF 15/m². Hallenhöhe sollte vor Vertragsabschluss bestätigt werden.', + confidenceLevel: 0.70, + riskLevel: RiskLevel.MEDIUM, + uncertaintyIndicators: ['Hallenhöhe nicht bestätigt', 'Daten aus Drittquelle'], + organizationId: 'org-wincasa', + createdAt: '2025-05-15T08:10:00Z', + updatedAt: '2025-05-15T08:10:00Z', + }, + + // ─────────────────────────────────────────────────────────────────────────── + // need-011 · Stadtladen Bern GmbH · RETAIL Bern Innenstadt — all new matches + // ─────────────────────────────────────────────────────────────────────────── + + { + id: 'match-052', + propertyId: 'prop-003', + needId: 'need-011', + matchScore: 91, + matchStrength: MatchStrength.STRONG, + status: MatchStatus.PENDING_REVIEW, + scoreBreakdown: { hardMatchScore: 96, softFactorScore: 90, confidenceModifier: 0.71, dataQualityModifier: 0.62, totalScore: 91 }, + positiveFactors: [ + { criterion: 'Standort', weight: 0.35, score: 100, contribution: 35, explanation: 'Bern Innenstadt – exakt bevorzugte Lage' }, + { criterion: 'Fläche', weight: 0.15, score: 100, contribution: 15, explanation: '320m² im Zielkorridor (200–400m²)' }, + { criterion: 'Budget', weight: 0.15, score: 100, contribution: 15, explanation: 'CHF 95/m² deutlich unter Maximum (CHF 150/m²)' }, + ], + negativeFactors: [ + { criterion: 'Datenqualität', weight: 0.10, score: 55, contribution: 5.5, explanation: 'Externe Quelle – Schaufensterfront nicht explizit bestätigt' }, + ], + tradeoffs: [ + { criterion: 'Datenqualität', concern: 'Must-have "Schaufensterfront" aus externer Quelle – Besichtigung nötig', severity: 'LOW', mitigation: 'Vor-Ort-Besichtigung zur Bestätigung empfohlen' }, + ], + explainabilitySummary: 'Optimaler Match: Bern Innenstadt exakt, 320m², CHF 95/m² – 37% unter Budget. Alle Hauptkriterien erfüllt.', + confidenceLevel: 0.71, + riskLevel: RiskLevel.MEDIUM, + uncertaintyIndicators: ['Schaufensterfront nicht bestätigt'], + organizationId: 'org-wincasa', + createdAt: '2025-05-18T08:00:00Z', + updatedAt: '2025-05-18T08:00:00Z', + }, + + { + id: 'match-053', + propertyId: 'prop-033', + needId: 'need-011', + matchScore: 83, + matchStrength: MatchStrength.STRONG, + status: MatchStatus.PENDING_REVIEW, + scoreBreakdown: { hardMatchScore: 88, softFactorScore: 82, confidenceModifier: 0.70, dataQualityModifier: 0.60, totalScore: 83 }, + positiveFactors: [ + { criterion: 'Standort', weight: 0.35, score: 100, contribution: 35, explanation: 'Marktgasse – exakt in bevorzugter Innenstadtlage' }, + { criterion: 'Fläche', weight: 0.15, score: 100, contribution: 15, explanation: '260m² im Zielkorridor (200–400m²)' }, + { criterion: 'Prestige', weight: 0.15, score: 88, contribution: 13.2, explanation: 'Hochfrequentierte Fussgängerzone – Laufkundschaft garantiert' }, + ], + negativeFactors: [ + { criterion: 'Budget', weight: 0.15, score: 80, contribution: 12, explanation: 'CHF 120/m² nahe am Maximum (CHF 150/m²)' }, + { criterion: 'Datenqualität', weight: 0.10, score: 52, contribution: 5.2, explanation: 'Mietpreis nicht final bestätigt' }, + ], + tradeoffs: [ + { criterion: 'Budget', concern: 'CHF 120/m² lässt wenig Spielraum zum Maximum', severity: 'LOW' }, + { criterion: 'Datenqualität', concern: 'Mietpreis aus externer Quelle – Verhandlung möglich', severity: 'MEDIUM', mitigation: 'Direktanfrage empfohlen' }, + ], + explainabilitySummary: 'Starker Match: Marktgasse/Innenstadt, 260m², CHF 120/m² im Budget. Maison Work-Plattform mit verifizierten Retailflächen.', + confidenceLevel: 0.70, + riskLevel: RiskLevel.MEDIUM, + uncertaintyIndicators: ['Mietpreis nicht final bestätigt'], + organizationId: 'org-wincasa', + createdAt: '2025-05-18T08:05:00Z', + updatedAt: '2025-05-18T08:05:00Z', + }, + + { + id: 'match-054', + propertyId: 'prop-034', + needId: 'need-011', + matchScore: 74, + matchStrength: MatchStrength.MODERATE, + status: MatchStatus.PENDING_REVIEW, + scoreBreakdown: { hardMatchScore: 82, softFactorScore: 74, confidenceModifier: 0.68, dataQualityModifier: 0.56, totalScore: 74 }, + positiveFactors: [ + { criterion: 'Fläche', weight: 0.15, score: 100, contribution: 15, explanation: '340m² im Zielkorridor (200–400m²)' }, + { criterion: 'Budget', weight: 0.15, score: 94, contribution: 14.1, explanation: 'CHF 110/m² unter Maximum (CHF 150/m²)' }, + ], + negativeFactors: [ + { criterion: 'Standort', weight: 0.35, score: 75, contribution: 26.25, explanation: 'Lorraine ist Bern, aber nicht Innenstadt – weniger Laufkundschaft' }, + { criterion: 'Timing', weight: 0.10, score: 80, contribution: 8, explanation: 'Verfügbar ab 01.01.2026 – leicht nach Wunschzeitraum' }, + { criterion: 'Datenqualität', weight: 0.10, score: 48, contribution: 4.8, explanation: 'Schaufensterfront nicht bestätigt – kritisches Must-have' }, + ], + tradeoffs: [ + { criterion: 'Standort', concern: 'Lorraine ist kein Fussgängerzonenviertel – geringere Laufkundschaft als Innenstadt', severity: 'MEDIUM' }, + { criterion: 'Timing', concern: 'Ab Januar 2026 – 4 Monate nach gewünschtem Einzug', severity: 'LOW' }, + ], + explainabilitySummary: 'Moderater Match: Bern Lorraine, 340m², Budget ok. Abzug wegen Abweichung von Innenstadt-Lage und fehlender Schaufensterfront-Bestätigung.', + confidenceLevel: 0.68, + riskLevel: RiskLevel.MEDIUM, + uncertaintyIndicators: ['Schaufensterfront nicht bestätigt', 'Daten aus Drittquelle'], + organizationId: 'org-wincasa', + createdAt: '2025-05-18T08:10:00Z', + updatedAt: '2025-05-18T08:10:00Z', + }, + + { + id: 'match-055', + propertyId: 'prop-035', + needId: 'need-011', + matchScore: 66, + matchStrength: MatchStrength.MODERATE, + status: MatchStatus.PENDING_REVIEW, + scoreBreakdown: { hardMatchScore: 88, softFactorScore: 72, confidenceModifier: 0.55, dataQualityModifier: 0.36, totalScore: 66 }, + positiveFactors: [ + { criterion: 'Standort', weight: 0.35, score: 100, contribution: 35, explanation: 'Bern Altstadt Gerechtigkeitsgasse – Premiumlage' }, + { criterion: 'Fläche', weight: 0.15, score: 100, contribution: 15, explanation: '290m² im Zielkorridor (200–400m²)' }, + ], + negativeFactors: [ + { criterion: 'Konfidenz', weight: 0.15, score: 38, contribution: 5.7, explanation: 'Future-Signal mit 62% Wahrscheinlichkeit – kein bestätigtes Objekt' }, + { criterion: 'Datenqualität', weight: 0.10, score: 25, contribution: 2.5, explanation: 'Mietpreis nur geschätzt, kein offizielles Inserat' }, + ], + tradeoffs: [ + { criterion: 'Verfügbarkeit', concern: 'Probabilistisches Signal – kein bestätigtes Inserat', severity: 'HIGH', mitigation: 'Früher Erstkontakt mit Eigentümer kann Vorteil sichern' }, + { criterion: 'Timing', concern: 'Frühestens April 2026 verfügbar', severity: 'MEDIUM' }, + ], + explainabilitySummary: 'Premiumlage Gerechtigkeitsgasse, aber nur probabilistisches Signal. Nur weiterverfolgen, wenn Frühkontakt mit Eigentümer möglich.', + confidenceLevel: 0.55, + riskLevel: RiskLevel.HIGH, + uncertaintyIndicators: ['Probabilistisches Signal', 'Mietpreis geschätzt', 'Kein bestätigtes Inserat'], + organizationId: 'org-wincasa', + createdAt: '2025-05-18T08:15:00Z', + updatedAt: '2025-05-18T08:15:00Z', + }, + + // ─────────────────────────────────────────────────────────────────────────── + // Schattenmarkt-Freigabe — verified contract signals from Verwaltung + // ─────────────────────────────────────────────────────────────────────────── + + { + id: 'match-060', + propertyId: 'prop-001', + needId: 'need-001', + matchScore: 78, + matchStrength: MatchStrength.MODERATE, + resultType: 'FUTURE_AVAILABILITY', + status: MatchStatus.PENDING_REVIEW, + scoreBreakdown: { hardMatchScore: 90, softFactorScore: 82, confidenceModifier: 0.92, dataQualityModifier: 0.92, totalScore: 78 }, + positiveFactors: [ + { criterion: 'Standort', weight: 0.25, score: 100, contribution: 25, explanation: 'Zürich-West trifft bevorzugte Lage exakt' }, + { criterion: 'Fläche', weight: 0.20, score: 100, contribution: 20, explanation: '850m² im Zielkorridor (600–1000m²)' }, + { criterion: 'Budget', weight: 0.15, score: 92, contribution: 13.8, explanation: 'CHF 38/m² liegt unter Maximum (CHF 45/m²)' }, + { criterion: 'Konfidenz', weight: 0.10, score: 100, contribution: 10, explanation: 'Vertragsende aus ERP bestätigt — keine Schätzung' }, + ], + negativeFactors: [ + { criterion: 'Verfügbarkeit', weight: 0.10, score: 55, contribution: 5.5, explanation: 'Objekt erst ab Oktober 2026 verfügbar — 5 Monate Vorlaufzeit' }, + ], + tradeoffs: [ + { criterion: 'Timing', concern: 'Einzug frühestens Oktober 2026 möglich', severity: 'LOW', mitigation: 'Frühzeitige Reservierungsanfrage sichert Priorität' }, + ], + explainabilitySummary: 'Idealer Match: Zürich-West exakt, 850m², im Budget. Vertragsende verifiziert — Verwaltung hat Objekt für Schattenmarkt freigegeben.', + confidenceLevel: 0.92, + riskLevel: RiskLevel.LOW, + uncertaintyIndicators: ['Verfügbar ab Oktober 2026'], + organizationId: 'org-wincasa', + createdAt: '2026-05-20T08:00:00Z', + updatedAt: '2026-05-20T08:00:00Z', + }, + + { + id: 'match-061', + propertyId: 'prop-007', + needId: 'need-001', + matchScore: 71, + matchStrength: MatchStrength.MODERATE, + resultType: 'FUTURE_AVAILABILITY', + status: MatchStatus.PENDING_REVIEW, + scoreBreakdown: { hardMatchScore: 82, softFactorScore: 74, confidenceModifier: 0.92, dataQualityModifier: 0.94, totalScore: 71 }, + positiveFactors: [ + { criterion: 'Fläche', weight: 0.20, score: 100, contribution: 20, explanation: '720m² im Zielkorridor (600–1000m²)' }, + { criterion: 'Budget', weight: 0.15, score: 95, contribution: 14.25, explanation: 'CHF 36/m² unter Maximum (CHF 45/m²)' }, + { criterion: 'Konfidenz', weight: 0.10, score: 100, contribution: 10, explanation: 'Vertragsende und Breakout-Option aus ERP bestätigt' }, + ], + negativeFactors: [ + { criterion: 'Standort', weight: 0.25, score: 80, contribution: 20, explanation: 'Zürich Oerlikon — nicht Zürich-West, aber gute ÖV-Anbindung' }, + { criterion: 'Verfügbarkeit', weight: 0.10, score: 55, contribution: 5.5, explanation: 'Breakout-Option September 2026, Vertragsende November 2026' }, + ], + tradeoffs: [ + { criterion: 'Standort', concern: 'Oerlikon ist Zürich, aber nicht Zürich-West — andere Quartierscharakter', severity: 'MEDIUM' }, + { criterion: 'Timing', concern: 'Früheste Verfügbarkeit über Breakout-Option September 2026', severity: 'LOW', mitigation: 'Breakout-Option aktiv — frühzeitige Anfrage möglich' }, + ], + explainabilitySummary: 'Guter Match: Zürich Oerlikon, 720m², im Budget. Vertragsende verifiziert, Breakout-Option ab September 2026. Lage nicht Zürich-West.', + confidenceLevel: 0.92, + riskLevel: RiskLevel.LOW, + uncertaintyIndicators: ['Lage Oerlikon statt Zürich-West', 'Verfügbar ab September 2026'], + organizationId: 'org-wincasa', + createdAt: '2026-05-20T08:05:00Z', + updatedAt: '2026-05-20T08:05:00Z', + }, + + { + id: 'match-062', + propertyId: 'prop-002', + needId: 'need-002', + matchScore: 84, + matchStrength: MatchStrength.STRONG, + resultType: 'FUTURE_AVAILABILITY', + status: MatchStatus.PENDING_REVIEW, + scoreBreakdown: { hardMatchScore: 94, softFactorScore: 86, confidenceModifier: 0.92, dataQualityModifier: 0.96, totalScore: 84 }, + positiveFactors: [ + { criterion: 'Standort', weight: 0.25, score: 98, contribution: 24.5, explanation: 'Basel Kleinhüningen — exakt im Zielgebiet' }, + { criterion: 'Fläche', weight: 0.20, score: 100, contribution: 20, explanation: '2400m² im Zielkorridor (1500–4000m²)' }, + { criterion: 'Budget', weight: 0.15, score: 100, contribution: 15, explanation: 'CHF 14/m² weit unter Maximum (CHF 18/m²)' }, + { criterion: 'Konfidenz', weight: 0.10, score: 100, contribution: 10, explanation: 'Vertragsende aus ERP bestätigt — keine Schätzung' }, + ], + negativeFactors: [ + { criterion: 'Verfügbarkeit', weight: 0.10, score: 60, contribution: 6, explanation: 'Objekt erst ab September 2026 verfügbar' }, + ], + tradeoffs: [ + { criterion: 'Timing', concern: 'Verfügbar ab September 2026 — 4 Monate Vorlaufzeit', severity: 'LOW', mitigation: 'Frühe Reservierungsanfrage sichert Priorität vor Vertragsende' }, + ], + explainabilitySummary: 'Starker Match: Basel Kleinhüningen exakt, 2400m², CHF 14/m². Vertragsende September 2026 aus ERP bestätigt — höchste Verlässlichkeit.', + confidenceLevel: 0.92, + riskLevel: RiskLevel.LOW, + uncertaintyIndicators: ['Verfügbar ab September 2026'], + organizationId: 'org-wincasa', + createdAt: '2026-05-20T08:10:00Z', + updatedAt: '2026-05-20T08:10:00Z', + }, ] diff --git a/src/mock-data/needs.ts b/src/mock-data/needs.ts index 181838c..8121b1b 100644 --- a/src/mock-data/needs.ts +++ b/src/mock-data/needs.ts @@ -345,6 +345,45 @@ export const mockNeeds: Need[] = [ updatedAt: '2025-04-18T09:00:00Z', }, + // --- need-011: Stadtladen Bern GmbH — RETAIL Bern Innenstadt --- + { + id: 'need-011', + companyName: 'Stadtladen Bern GmbH', + contactName: 'Katrin Müller', + assetType: AssetType.RETAIL, + requiredArea: { min: 200, max: 400 }, + preferredLocations: ['Bern Innenstadt', 'Bern Altstadt', 'Bern Marktgasse'], + excludedLocations: [], + budgetRange: { maxPerSqm: 1800, currency: 'CHF' }, + timing: { + earliestMoveIn: '2025-09-01', + latestMoveIn: '2026-03-01', + contractDurationMonths: 48, + flexibleTiming: false, + }, + mustCriteriaText: ['Fussgängerzone', 'Schaufensterfront', 'Erdgeschoss', 'Laufkundschaft'], + softFactors: { + minPrestige: 80, + requireParking: false, + maxPublicTransportMinutes: 5, + }, + weightingProfile: { + area: 0.15, + location: 0.35, + budget: 0.15, + timing: 0.10, + prestige: 0.15, + accessibility: 0.05, + expansionPotential: 0.02, + flexibility: 0.03, + }, + confidenceInCriteria: 0.92, + extractedFromText: 'Suche Retailfläche in Bern Innenstadt, 200–400m², Schaufensterfront, Fussgängerzone, max. CHF 150/m².', + organizationId: 'org-wincasa', + createdAt: '2025-05-18T10:00:00Z', + updatedAt: '2025-05-18T10:00:00Z', + }, + // --- need-010: St.Galler Büros AG — OFFICE St.Gallen --- { id: 'need-010', diff --git a/src/mock-data/properties.ts b/src/mock-data/properties.ts index a3d39dc..46fc4ae 100644 --- a/src/mock-data/properties.ts +++ b/src/mock-data/properties.ts @@ -44,6 +44,7 @@ export const mockProperties: Property[] = [ images: ['https://images.unsplash.com/photo-1497366216548-37526070297c?w=800&h=400&fit=crop'], mapImageUrl: 'https://images.unsplash.com/photo-1524661135-423995f22d0b?w=600&h=300&fit=crop', propertyNumber: 'ZH-2024-001', + units: [ { id: 'unit-001-1', floorLevel: 1, unitLabel: 'Nord', areaSqm: 280, available: false, rentPricePerSqm: 456, currentTenant: 'MediaGroup Schweiz AG', leaseTerm: '5 Jahre', leaseEndDate: '2025-08-31' }, { id: 'unit-001-2', floorLevel: 2, unitLabel: 'Süd', areaSqm: 310, available: false, rentPricePerSqm: 456, currentTenant: 'MediaGroup Schweiz AG', leaseTerm: '5 Jahre', leaseEndDate: '2025-08-31' }, @@ -55,8 +56,9 @@ export const mockProperties: Property[] = [ currentTenant: 'MediaGroup Schweiz AG', leaseTerm: '5 Jahre', leaseStartDate: '2020-09-01', - leaseEndDate: '2025-08-31', + leaseEndDate: '2026-10-31', breakoutOption: false, + schattenmarktRelease: { enabled: true, leadTimeMonths: 6 }, organizationId: 'org-wincasa', createdAt: '2025-01-10T08:00:00Z', updatedAt: '2025-04-28T10:30:00Z', @@ -103,8 +105,9 @@ export const mockProperties: Property[] = [ currentTenant: 'Spedition Rhein GmbH', leaseTerm: '3 Jahre', leaseStartDate: '2022-07-01', - leaseEndDate: '2025-06-30', + leaseEndDate: '2026-09-30', breakoutOption: false, + schattenmarktRelease: { enabled: true, leadTimeMonths: 5 }, organizationId: 'org-wincasa', createdAt: '2024-11-20T09:00:00Z', updatedAt: '2025-05-05T11:00:00Z', @@ -145,7 +148,7 @@ export const mockProperties: Property[] = [ contractDurationMonths: 48, ancillaryCosts: 5.0, riskLevel: RiskLevel.LOW, - images: ['https://images.unsplash.com/photo-1497366216548-37526070297c?w=800&h=400&fit=crop'], + images: ['https://images.unsplash.com/photo-1454165804606-c3d57bc86b40?w=800&h=400&fit=crop'], mapImageUrl: 'https://images.unsplash.com/photo-1524661135-423995f22d0b?w=600&h=300&fit=crop', propertyNumber: 'ZH-2021-007', units: [ @@ -159,9 +162,10 @@ export const mockProperties: Property[] = [ currentTenant: 'Consulting Partners AG', leaseTerm: '4 Jahre', leaseStartDate: '2021-10-01', - leaseEndDate: '2025-09-30', + leaseEndDate: '2026-11-30', breakoutOption: true, - breakoutOptionDate: '2024-10-01', + breakoutOptionDate: '2026-09-01', + schattenmarktRelease: { enabled: true, leadTimeMonths: 7 }, organizationId: 'org-wincasa', createdAt: '2025-02-01T09:00:00Z', updatedAt: '2025-05-01T08:00:00Z', @@ -201,7 +205,7 @@ export const mockProperties: Property[] = [ contractDurationMonths: 48, ancillaryCosts: 4.5, riskLevel: RiskLevel.LOW, - images: ['https://images.unsplash.com/photo-1497366216548-37526070297c?w=800&h=400&fit=crop'], + images: ['https://images.unsplash.com/photo-1568992687947-868a62a9f521?w=800&h=400&fit=crop'], mapImageUrl: 'https://images.unsplash.com/photo-1524661135-423995f22d0b?w=600&h=300&fit=crop', importedFrom: 'SAP RE-FX', importedAt: '2025-01-15T08:00:00Z', @@ -209,8 +213,9 @@ export const mockProperties: Property[] = [ currentTenant: 'Pharma Research GmbH', leaseTerm: '4 Jahre', leaseStartDate: '2021-09-01', - leaseEndDate: '2025-08-31', + leaseEndDate: '2027-08-31', breakoutOption: false, + schattenmarktRelease: { enabled: false, leadTimeMonths: 6 }, organizationId: 'org-wincasa', createdAt: '2025-01-20T10:00:00Z', updatedAt: '2025-04-30T09:00:00Z', @@ -249,7 +254,7 @@ export const mockProperties: Property[] = [ contractDurationMonths: 60, ancillaryCosts: 2.8, riskLevel: RiskLevel.LOW, - images: ['https://images.unsplash.com/photo-1586528116311-ad8dd3c8310d?w=800&h=400&fit=crop'], + images: ['https://images.unsplash.com/photo-1553413077-190dd305871c?w=800&h=400&fit=crop'], mapImageUrl: 'https://images.unsplash.com/photo-1524661135-423995f22d0b?w=600&h=300&fit=crop', importedFrom: 'SAP RE-FX', importedAt: '2025-01-15T08:00:00Z', @@ -394,7 +399,7 @@ export const mockProperties: Property[] = [ contractDurationMonths: 36, ancillaryCosts: 6.0, riskLevel: RiskLevel.LOW, - images: ['https://images.unsplash.com/photo-1497366216548-37526070297c?w=800&h=400&fit=crop'], + images: ['https://images.unsplash.com/photo-1504384308090-c894fdcc538d?w=800&h=400&fit=crop'], mapImageUrl: 'https://images.unsplash.com/photo-1524661135-423995f22d0b?w=600&h=300&fit=crop', propertyNumber: 'ZG-2022-012', units: [ @@ -505,7 +510,7 @@ export const mockProperties: Property[] = [ contractDurationMonths: 60, ancillaryCosts: 2.8, riskLevel: RiskLevel.LOW, - images: ['https://images.unsplash.com/photo-1586528116311-ad8dd3c8310d?w=800&h=400&fit=crop'], + images: ['https://images.unsplash.com/photo-1504917595217-d4dc5ebe6122?w=800&h=400&fit=crop'], mapImageUrl: 'https://images.unsplash.com/photo-1524661135-423995f22d0b?w=600&h=300&fit=crop', importedFrom: 'SAP RE-FX', importedAt: '2025-01-15T08:00:00Z', @@ -555,7 +560,7 @@ export const mockProperties: Property[] = [ publicTransportMinutes: 2, }, riskLevel: RiskLevel.MEDIUM, - images: ['https://images.unsplash.com/photo-1441986300917-64674bd600d8?w=800&h=400&fit=crop'], + images: ['https://images.unsplash.com/photo-1555529669-e69e7aa0ba9a?w=800&h=400&fit=crop'], mapImageUrl: 'https://images.unsplash.com/photo-1524661135-423995f22d0b?w=600&h=300&fit=crop', createdAt: '2025-02-15T14:00:00Z', updatedAt: '2025-04-10T09:00:00Z', @@ -584,7 +589,7 @@ export const mockProperties: Property[] = [ warnings: ['Daten aus Drittquelle – nicht verifiziert'], }, riskLevel: RiskLevel.MEDIUM, - images: ['https://images.unsplash.com/photo-1486325212027-8081e485255e?w=800&h=400&fit=crop'], + images: ['https://images.unsplash.com/photo-1498049794561-7780e7231661?w=800&h=400&fit=crop'], mapImageUrl: 'https://images.unsplash.com/photo-1524661135-423995f22d0b?w=600&h=300&fit=crop', createdAt: '2025-03-01T10:00:00Z', updatedAt: '2025-03-20T15:00:00Z', @@ -619,7 +624,7 @@ export const mockProperties: Property[] = [ publicTransportMinutes: 5, }, riskLevel: RiskLevel.MEDIUM, - images: ['https://images.unsplash.com/photo-1497366216548-37526070297c?w=800&h=400&fit=crop'], + images: ['https://images.unsplash.com/photo-1556761175-b413da4baf72?w=800&h=400&fit=crop'], mapImageUrl: 'https://images.unsplash.com/photo-1524661135-423995f22d0b?w=600&h=300&fit=crop', createdAt: '2025-03-12T11:00:00Z', updatedAt: '2025-04-05T10:00:00Z', @@ -654,7 +659,7 @@ export const mockProperties: Property[] = [ parkingSpots: 35, }, riskLevel: RiskLevel.MEDIUM, - images: ['https://images.unsplash.com/photo-1586528116311-ad8dd3c8310d?w=800&h=400&fit=crop'], + images: ['https://images.unsplash.com/photo-1525498128493-380d1990a112?w=800&h=400&fit=crop'], mapImageUrl: 'https://images.unsplash.com/photo-1524661135-423995f22d0b?w=600&h=300&fit=crop', createdAt: '2025-02-20T09:00:00Z', updatedAt: '2025-03-28T12:00:00Z', @@ -690,7 +695,7 @@ export const mockProperties: Property[] = [ publicTransportMinutes: 3, }, riskLevel: RiskLevel.MEDIUM, - images: ['https://images.unsplash.com/photo-1441986300917-64674bd600d8?w=800&h=400&fit=crop'], + images: ['https://images.unsplash.com/photo-1556742502-ec7c0e9f34b6?w=800&h=400&fit=crop'], mapImageUrl: 'https://images.unsplash.com/photo-1524661135-423995f22d0b?w=600&h=300&fit=crop', createdAt: '2025-03-05T13:00:00Z', updatedAt: '2025-04-18T11:00:00Z', @@ -725,7 +730,7 @@ export const mockProperties: Property[] = [ publicTransportMinutes: 8, }, riskLevel: RiskLevel.MEDIUM, - images: ['https://images.unsplash.com/photo-1497366216548-37526070297c?w=800&h=400&fit=crop'], + images: ['https://images.unsplash.com/photo-1522071820081-009f0129c71c?w=800&h=400&fit=crop'], mapImageUrl: 'https://images.unsplash.com/photo-1524661135-423995f22d0b?w=600&h=300&fit=crop', createdAt: '2025-02-28T10:00:00Z', updatedAt: '2025-04-02T09:00:00Z', @@ -755,7 +760,7 @@ export const mockProperties: Property[] = [ warnings: ['Hallenhöhe nicht verifiziert', 'Kranbahn Status unklar'], }, riskLevel: RiskLevel.MEDIUM, - images: ['https://images.unsplash.com/photo-1565043589221-1a6fd9ae45c7?w=800&h=400&fit=crop'], + images: ['https://images.unsplash.com/photo-1572021335469-31706a17aaef?w=800&h=400&fit=crop'], mapImageUrl: 'https://images.unsplash.com/photo-1524661135-423995f22d0b?w=600&h=300&fit=crop', createdAt: '2025-03-10T08:00:00Z', updatedAt: '2025-03-25T14:00:00Z', @@ -790,7 +795,7 @@ export const mockProperties: Property[] = [ publicTransportMinutes: 9, }, riskLevel: RiskLevel.MEDIUM, - images: ['https://images.unsplash.com/photo-1497366216548-37526070297c?w=800&h=400&fit=crop'], + images: ['https://images.unsplash.com/photo-1524758631624-e2822e304c36?w=800&h=400&fit=crop'], mapImageUrl: 'https://images.unsplash.com/photo-1524661135-423995f22d0b?w=600&h=300&fit=crop', createdAt: '2025-03-18T09:00:00Z', updatedAt: '2025-04-12T11:00:00Z', @@ -826,7 +831,7 @@ export const mockProperties: Property[] = [ publicTransportMinutes: 3, }, riskLevel: RiskLevel.MEDIUM, - images: ['https://images.unsplash.com/photo-1441986300917-64674bd600d8?w=800&h=400&fit=crop'], + images: ['https://images.unsplash.com/photo-1528698827591-e19ccd7bc23d?w=800&h=400&fit=crop'], mapImageUrl: 'https://images.unsplash.com/photo-1524661135-423995f22d0b?w=600&h=300&fit=crop', createdAt: '2025-02-10T10:00:00Z', updatedAt: '2025-04-08T09:00:00Z', @@ -861,7 +866,7 @@ export const mockProperties: Property[] = [ publicTransportMinutes: 6, }, riskLevel: RiskLevel.MEDIUM, - images: ['https://images.unsplash.com/photo-1497366216548-37526070297c?w=800&h=400&fit=crop'], + images: ['https://images.unsplash.com/photo-1497366754035-f200968a6e72?w=800&h=400&fit=crop'], mapImageUrl: 'https://images.unsplash.com/photo-1524661135-423995f22d0b?w=600&h=300&fit=crop', createdAt: '2025-03-08T08:00:00Z', updatedAt: '2025-04-14T10:00:00Z', @@ -1096,6 +1101,190 @@ export const mockProperties: Property[] = [ updatedAt: '2025-05-10T08:00:00Z', }, + // ───────────────────────────────────────────────────────────────────────────── + // NEW — for demo searches + // ───────────────────────────────────────────────────────────────────────────── + + { + id: 'prop-031', + title: 'Bürofläche Hardturm West 16', + assetType: AssetType.OFFICE, + resultType: ResultType.EXTERNAL_MARKET, + location: { city: 'Zürich', district: 'Zürich-West', canton: 'ZH', country: 'CH', coordinates: { lat: 47.3862, lng: 8.5045 } }, + address: { street: 'Hardturmstrasse', houseNumber: '16', postalCode: '8005', city: 'Zürich', country: 'CH' }, + areaSqm: 780, + rentPricePerSqm: 420, + totalRentMonthly: 327600, + availabilityDate: '2025-10-01', + availabilityStatus: AvailabilityStatus.AVAILABLE_SOON, + sourceType: 'IMMOSCOUT_SCRAPE', + sourceUrl: 'https://example.com/listing/prop-031', + confidenceScore: 0.72, + dataQuality: { + score: 0.64, + missingCriticalFields: ['contractDurationMonths'], + missingOptionalFields: ['ancillaryCosts'], + lastVerifiedAt: '2025-05-10', + freshness: DataFreshness.STALE, + warnings: ['Daten aus Drittquelle'], + }, + softFactors: { prestige: 76, accessibility: 88, visibilityScore: 62, talentAccess: 82, parkingSpots: 8, publicTransportMinutes: 4 }, + riskLevel: RiskLevel.MEDIUM, + images: ['https://images.unsplash.com/photo-1542314831-068cd1dbfeeb?w=800&h=400&fit=crop'], + mapImageUrl: 'https://images.unsplash.com/photo-1524661135-423995f22d0b?w=600&h=300&fit=crop', + createdAt: '2025-04-25T10:00:00Z', + updatedAt: '2025-05-10T09:00:00Z', + }, + + { + id: 'prop-032', + title: 'Büroloft Pfingstweidstrasse 10', + assetType: AssetType.OFFICE, + resultType: ResultType.MAISON_WORK, + location: { city: 'Zürich', district: 'Kreis 5', canton: 'ZH', country: 'CH', coordinates: { lat: 47.3875, lng: 8.5095 } }, + address: { street: 'Pfingstweidstrasse', houseNumber: '10', postalCode: '8005', city: 'Zürich', country: 'CH' }, + areaSqm: 720, + rentPricePerSqm: 480, + totalRentMonthly: 345600, + availabilityDate: '2025-11-01', + availabilityStatus: AvailabilityStatus.AVAILABLE_SOON, + sourceType: 'HOMEGATE_SCRAPE', + sourceUrl: 'https://example.com/listing/prop-032', + confidenceScore: 0.71, + dataQuality: { + score: 0.62, + missingCriticalFields: ['contractDurationMonths'], + missingOptionalFields: ['ancillaryCosts', 'expansionPotentialSqm'], + lastVerifiedAt: '2025-05-08', + freshness: DataFreshness.STALE, + warnings: ['Ausbaustandard nicht bestätigt'], + }, + softFactors: { prestige: 74, accessibility: 86, visibilityScore: 60, talentAccess: 80, parkingSpots: 6, publicTransportMinutes: 5 }, + riskLevel: RiskLevel.MEDIUM, + images: ['https://images.unsplash.com/photo-1613545325278-f24b0cae1224?w=800&h=400&fit=crop'], + mapImageUrl: 'https://images.unsplash.com/photo-1524661135-423995f22d0b?w=600&h=300&fit=crop', + createdAt: '2025-04-20T11:00:00Z', + updatedAt: '2025-05-08T10:00:00Z', + }, + + { + id: 'prop-033', + title: 'Retailfläche Marktgasse 44', + assetType: AssetType.RETAIL, + resultType: ResultType.MAISON_WORK, + location: { city: 'Bern', district: 'Innenstadt', canton: 'BE', country: 'CH', coordinates: { lat: 46.9480, lng: 7.4468 } }, + address: { street: 'Marktgasse', houseNumber: '44', postalCode: '3011', city: 'Bern', country: 'CH' }, + areaSqm: 260, + rentPricePerSqm: 1440, + totalRentMonthly: 374400, + availabilityDate: '2025-10-01', + availabilityStatus: AvailabilityStatus.AVAILABLE_SOON, + sourceType: 'MATCHOFFICE_SCRAPE', + sourceUrl: 'https://example.com/listing/prop-033', + confidenceScore: 0.70, + dataQuality: { + score: 0.60, + missingCriticalFields: ['contractDurationMonths'], + missingOptionalFields: ['ancillaryCosts', 'floorLevel'], + lastVerifiedAt: '2025-05-05', + freshness: DataFreshness.STALE, + warnings: ['Mietpreis nicht final bestätigt'], + }, + softFactors: { prestige: 88, visibilityScore: 92, passerbyFrequency: 'HIGH', accessibility: 92, publicTransportMinutes: 3 }, + riskLevel: RiskLevel.MEDIUM, + images: ['https://images.unsplash.com/photo-1528698827591-e19ccd7bc23d?w=800&h=400&fit=crop'], + mapImageUrl: 'https://images.unsplash.com/photo-1524661135-423995f22d0b?w=600&h=300&fit=crop', + createdAt: '2025-03-28T09:00:00Z', + updatedAt: '2025-05-05T10:00:00Z', + }, + + { + id: 'prop-034', + title: 'Ladenfläche Lorrainestrasse 8', + assetType: AssetType.RETAIL, + resultType: ResultType.EXTERNAL_MARKET, + location: { city: 'Bern', district: 'Lorraine', canton: 'BE', country: 'CH', coordinates: { lat: 46.9565, lng: 7.4388 } }, + address: { street: 'Lorrainestrasse', houseNumber: '8', postalCode: '3013', city: 'Bern', country: 'CH' }, + areaSqm: 340, + rentPricePerSqm: 1320, + totalRentMonthly: 448800, + availabilityDate: '2026-01-01', + availabilityStatus: AvailabilityStatus.AVAILABLE_SOON, + sourceType: 'NEWHOME_SCRAPE', + sourceUrl: 'https://example.com/listing/prop-034', + confidenceScore: 0.68, + dataQuality: { + score: 0.56, + missingCriticalFields: ['contractDurationMonths'], + missingOptionalFields: ['ancillaryCosts', 'floorLevel'], + lastVerifiedAt: '2025-04-22', + freshness: DataFreshness.STALE, + warnings: ['Daten aus Drittquelle', 'Schaufensterfront nicht bestätigt'], + }, + softFactors: { prestige: 72, visibilityScore: 78, passerbyFrequency: 'MEDIUM', accessibility: 82, publicTransportMinutes: 6 }, + riskLevel: RiskLevel.MEDIUM, + images: ['https://images.unsplash.com/photo-1534398079543-7ae6d016b86a?w=800&h=400&fit=crop'], + mapImageUrl: 'https://images.unsplash.com/photo-1524661135-423995f22d0b?w=600&h=300&fit=crop', + createdAt: '2025-04-08T10:00:00Z', + updatedAt: '2025-04-22T09:00:00Z', + }, + + { + id: 'prop-035', + title: 'Retailfläche Gerechtigkeitsgasse Bern (Signal: Auszug)', + assetType: AssetType.RETAIL, + resultType: ResultType.FUTURE_AVAILABILITY, + location: { city: 'Bern', district: 'Altstadt', canton: 'BE', country: 'CH', coordinates: { lat: 46.9475, lng: 7.4492 } }, + address: { street: 'Gerechtigkeitsgasse', houseNumber: '22', postalCode: '3011', city: 'Bern', country: 'CH' }, + areaSqm: 290, + rentPricePerSqm: 1560, + availabilityDate: '2026-04-01', + availabilityStatus: AvailabilityStatus.FUTURE_SIGNAL, + sourceType: 'AI_SIGNAL', + confidenceScore: 0.55, + dataQuality: { + score: 0.36, + missingCriticalFields: ['totalRentMonthly', 'contractDurationMonths'], + missingOptionalFields: ['ancillaryCosts', 'floorLevel'], + freshness: DataFreshness.FRESH, + warnings: ['Probabilistisches Signal – kein bestätigtes Objekt', 'Mietpreis geschätzt'], + }, + riskLevel: RiskLevel.HIGH, + createdAt: '2025-04-28T09:00:00Z', + updatedAt: '2025-05-15T10:00:00Z', + }, + + { + id: 'prop-036', + title: 'Lagerhalle Klybeckstrasse 280', + assetType: AssetType.LOGISTICS, + resultType: ResultType.EXTERNAL_MARKET, + location: { city: 'Basel', district: 'Kleinhüningen', canton: 'BS', country: 'CH', coordinates: { lat: 47.5744, lng: 7.5862 } }, + address: { street: 'Klybeckstrasse', houseNumber: '280', postalCode: '4057', city: 'Basel', country: 'CH' }, + areaSqm: 2600, + rentPricePerSqm: 180, + totalRentMonthly: 468000, + availabilityDate: '2025-09-01', + availabilityStatus: AvailabilityStatus.AVAILABLE_SOON, + sourceType: 'IMMOSCOUT_SCRAPE', + sourceUrl: 'https://example.com/listing/prop-036', + confidenceScore: 0.70, + dataQuality: { + score: 0.60, + missingCriticalFields: ['contractDurationMonths'], + missingOptionalFields: ['ancillaryCosts'], + lastVerifiedAt: '2025-05-02', + freshness: DataFreshness.STALE, + warnings: ['Hallenhöhe nicht bestätigt', 'Daten aus Drittquelle'], + }, + softFactors: { prestige: 44, accessibility: 90, parkingSpots: 38 }, + riskLevel: RiskLevel.MEDIUM, + images: ['https://images.unsplash.com/photo-1590239926044-4131a46e3f27?w=800&h=400&fit=crop'], + mapImageUrl: 'https://images.unsplash.com/photo-1524661135-423995f22d0b?w=600&h=300&fit=crop', + createdAt: '2025-04-15T08:00:00Z', + updatedAt: '2025-05-02T10:00:00Z', + }, + { id: 'prop-030', title: 'Bürofläche St.Gallen Riethüsli (Signal: Expansion)', diff --git a/src/mock-data/reminders.ts b/src/mock-data/reminders.ts new file mode 100644 index 0000000..e534df8 --- /dev/null +++ b/src/mock-data/reminders.ts @@ -0,0 +1,589 @@ +import type { Reminder } from '../domain/reminder' +import { ReminderType, ReminderPriority, ReminderStatus, ShadowMarketRisk } from '../domain/reminder' + +// MOCK_TODAY = 2026-05-20 + +export const mockReminders: Reminder[] = [ + + // ─── URGENT (dueDate 2026-05-21 to 2026-06-03) — 4 entries ───────────────── + + { + id: 'rem-001', + type: ReminderType.LEASE_EXPIRY, + priority: ReminderPriority.URGENT, + status: ReminderStatus.ACTIVE, + propertyId: 'prop-001', + propertyTitle: 'Bürofläche Zollstrasse 12', + propertyCity: 'Zürich', + propertyDistrict: 'Zürich-West', + tenantName: 'MediaGroup Schweiz AG', + dueDate: '2026-05-25', + eventDate: '2026-10-31', + contractEndDate: '2026-10-31', + areaSqm: 850, + currentRentPerSqm: 456, + currency: 'CHF', + shadowMarketRisk: ShadowMarketRisk.HIGH, + schattenmarktEnabled: false, + note: 'Mieter hat bisher keine Verlängerungsabsicht signalisiert. Erstkontakt dringend.', + activity: [ + { at: '2026-04-01T08:00:00Z', by: 'Anna Meier', action: 'CREATED', note: 'Automatisch aus Vertragsdaten erstellt' }, + { at: '2026-05-10T14:30:00Z', by: 'Thomas Huber', action: 'NOTED', note: 'Mieter angerufen, kein Rückruf erhalten' }, + ], + organizationId: 'org-wincasa', + createdAt: '2026-04-01T08:00:00Z', + updatedAt: '2026-05-10T14:30:00Z', + }, + + { + id: 'rem-002', + type: ReminderType.BREAK_OPTION, + priority: ReminderPriority.URGENT, + status: ReminderStatus.ACTIVE, + propertyId: 'prop-007', + propertyTitle: 'Bürofläche Thurgauerstrasse 40', + propertyCity: 'Zürich', + propertyDistrict: 'Oerlikon', + tenantName: 'Consulting Partners AG', + dueDate: '2026-05-28', + eventDate: '2026-09-01', + contractEndDate: '2026-11-30', + breakOptionDate: '2026-09-01', + areaSqm: 720, + currentRentPerSqm: 432, + currency: 'CHF', + shadowMarketRisk: ShadowMarketRisk.MEDIUM, + schattenmarktEnabled: true, + note: 'Break-Option läuft am 01.09 ab — Frist zur Ausübung ist 90 Tage vorher, also bis 03.06.', + activity: [ + { at: '2026-03-15T09:00:00Z', by: 'Anna Meier', action: 'CREATED' }, + { at: '2026-05-05T11:00:00Z', by: 'Anna Meier', action: 'NOTED', note: 'Mieterdossier vorbereitet' }, + ], + organizationId: 'org-wincasa', + createdAt: '2026-03-15T09:00:00Z', + updatedAt: '2026-05-05T11:00:00Z', + }, + + { + id: 'rem-003', + type: ReminderType.INSURANCE_RENEWAL, + priority: ReminderPriority.URGENT, + status: ReminderStatus.ACTIVE, + propertyId: 'prop-012', + propertyTitle: 'Bürofläche Stadtturm Zug', + propertyCity: 'Zug', + propertyDistrict: 'Zentrum', + tenantName: 'FinTech Zug AG', + dueDate: '2026-06-01', + eventDate: '2026-06-30', + areaSqm: 550, + currentRentPerSqm: 504, + currency: 'CHF', + shadowMarketRisk: ShadowMarketRisk.LOW, + schattenmarktEnabled: false, + note: 'Gebäudeversicherung läuft am 30.06 ab. Police-Nummer ZG-2022-9912.', + activity: [ + { at: '2026-04-20T10:00:00Z', by: 'Sandra Wyss', action: 'CREATED' }, + { at: '2026-05-12T15:00:00Z', by: 'Sandra Wyss', action: 'NOTED', note: 'Offerte von Mobiliar angefordert' }, + ], + organizationId: 'org-wincasa', + createdAt: '2026-04-20T10:00:00Z', + updatedAt: '2026-05-12T15:00:00Z', + }, + + { + id: 'rem-004', + type: ReminderType.INSPECTION, + priority: ReminderPriority.URGENT, + status: ReminderStatus.SNOOZED, + propertyId: 'prop-009', + propertyTitle: 'Logistikzentrum Tössfeldstrasse 18', + propertyCity: 'Winterthur', + propertyDistrict: 'Töss', + tenantName: 'Sperrgut Logistik AG', + dueDate: '2026-06-03', + eventDate: '2026-06-03', + areaSqm: 1800, + currentRentPerSqm: 156, + currency: 'CHF', + shadowMarketRisk: ShadowMarketRisk.NONE, + schattenmarktEnabled: false, + snoozedUntil: '2026-05-24', + activity: [ + { at: '2026-04-10T09:00:00Z', by: 'Thomas Huber', action: 'CREATED', note: 'Jährliche Inspektion Dach und Bodenplatte' }, + { at: '2026-05-15T10:00:00Z', by: 'Thomas Huber', action: 'SNOOZED', note: 'Verschoben wegen Krankheit Hausmeister' }, + ], + organizationId: 'org-wincasa', + createdAt: '2026-04-10T09:00:00Z', + updatedAt: '2026-05-15T10:00:00Z', + }, + + // ─── HIGH (dueDate 2026-06-04 to 2026-06-19) — 5 entries ─────────────────── + + { + id: 'rem-005', + type: ReminderType.RENT_REVIEW, + priority: ReminderPriority.HIGH, + status: ReminderStatus.ACTIVE, + propertyId: 'prop-002', + propertyTitle: 'Lagerfläche Hardstrasse 44', + propertyCity: 'Basel', + propertyDistrict: 'Kleinhüningen', + tenantName: 'Spedition Rhein GmbH', + dueDate: '2026-06-08', + eventDate: '2026-09-30', + contractEndDate: '2026-09-30', + areaSqm: 2400, + currentRentPerSqm: 168, + currency: 'CHF', + shadowMarketRisk: ShadowMarketRisk.MEDIUM, + schattenmarktEnabled: true, + note: 'Indexierte Mietanpassung per 01.10 möglich. LIK-Index prüfen.', + activity: [ + { at: '2026-04-05T08:00:00Z', by: 'Anna Meier', action: 'CREATED' }, + { at: '2026-05-18T09:00:00Z', by: 'Anna Meier', action: 'NOTED', note: 'LIK-Daten für Q1 2026 abrufbar' }, + ], + organizationId: 'org-wincasa', + createdAt: '2026-04-05T08:00:00Z', + updatedAt: '2026-05-18T09:00:00Z', + }, + + { + id: 'rem-006', + type: ReminderType.LEASE_EXPIRY, + priority: ReminderPriority.HIGH, + status: ReminderStatus.ACTIVE, + propertyId: 'prop-011', + propertyTitle: 'Produktionshalle Brünnen West 22', + propertyCity: 'Bern', + propertyDistrict: 'Brünnen', + tenantName: 'Metallbau Bern AG', + dueDate: '2026-06-10', + eventDate: '2027-03-31', + contractEndDate: '2027-03-31', + areaSqm: 2800, + currentRentPerSqm: 144, + currency: 'CHF', + shadowMarketRisk: ShadowMarketRisk.HIGH, + schattenmarktEnabled: false, + note: 'Frist für Vertragsverhandlung: 9 Monate vor Ablauf. Markt Bern Industrie angespannt.', + activity: [ + { at: '2026-03-01T08:00:00Z', by: 'Sandra Wyss', action: 'CREATED' }, + ], + organizationId: 'org-wincasa', + createdAt: '2026-03-01T08:00:00Z', + updatedAt: '2026-03-01T08:00:00Z', + }, + + { + id: 'rem-007', + type: ReminderType.SCHATTENMARKT_RELEASE, + priority: ReminderPriority.HIGH, + status: ReminderStatus.ACTIVE, + propertyId: 'prop-008', + propertyTitle: 'Bürofläche Dreispitz Areal 9', + propertyCity: 'Basel', + propertyDistrict: 'Dreispitz', + tenantName: 'Pharma Research GmbH', + dueDate: '2026-06-14', + eventDate: '2027-08-31', + contractEndDate: '2027-08-31', + areaSqm: 900, + currentRentPerSqm: 384, + currency: 'CHF', + shadowMarketRisk: ShadowMarketRisk.HIGH, + schattenmarktEnabled: false, + note: 'Objekt noch nicht im Schattenmarkt aktiviert. 14 Monate Lead Time empfohlen.', + activity: [ + { at: '2026-04-18T10:00:00Z', by: 'Thomas Huber', action: 'CREATED', note: 'Schattenmarkt-Aktivierung ausstehend' }, + { at: '2026-05-02T11:00:00Z', by: 'Thomas Huber', action: 'NOTED', note: 'Eigentümer informiert, Freigabe ausstehend' }, + ], + organizationId: 'org-wincasa', + createdAt: '2026-04-18T10:00:00Z', + updatedAt: '2026-05-02T11:00:00Z', + }, + + { + id: 'rem-008', + type: ReminderType.MAINTENANCE, + priority: ReminderPriority.HIGH, + status: ReminderStatus.ACTIVE, + propertyId: 'prop-014', + propertyTitle: 'Logistikhalle Pratteln Nord', + propertyCity: 'Pratteln', + propertyDistrict: 'Industriezone', + tenantName: 'Handels- und Lagerbetrieb AG', + dueDate: '2026-06-17', + eventDate: '2026-06-17', + areaSqm: 3100, + currentRentPerSqm: 180, + currency: 'CHF', + shadowMarketRisk: ShadowMarketRisk.NONE, + schattenmarktEnabled: false, + note: 'Wartung Sprinkleranlage gemäss VKF-Vorschrift fällig.', + activity: [ + { at: '2026-05-01T08:00:00Z', by: 'Thomas Huber', action: 'CREATED' }, + ], + organizationId: 'org-wincasa', + createdAt: '2026-05-01T08:00:00Z', + updatedAt: '2026-05-01T08:00:00Z', + }, + + { + id: 'rem-009', + type: ReminderType.BREAK_OPTION, + priority: ReminderPriority.HIGH, + status: ReminderStatus.SNOOZED, + propertyId: 'prop-013', + propertyTitle: 'Gewerbe-/Bürofläche Altstetten Park', + propertyCity: 'Zürich', + propertyDistrict: 'Altstetten', + tenantName: 'Design Studio Zürich GmbH', + dueDate: '2026-06-19', + eventDate: '2026-10-31', + contractEndDate: '2026-10-31', + areaSqm: 1300, + currentRentPerSqm: 540, + currency: 'CHF', + shadowMarketRisk: ShadowMarketRisk.MEDIUM, + schattenmarktEnabled: true, + snoozedUntil: '2026-05-27', + note: 'Mieter erwägt Flächenreduktion. Gespräch vereinbart für 27.05.', + activity: [ + { at: '2026-04-02T09:00:00Z', by: 'Anna Meier', action: 'CREATED' }, + { at: '2026-05-14T16:00:00Z', by: 'Anna Meier', action: 'SNOOZED', note: 'Bis nach Gespräch mit Mieter zurückgestellt' }, + ], + organizationId: 'org-wincasa', + createdAt: '2026-04-02T09:00:00Z', + updatedAt: '2026-05-14T16:00:00Z', + }, + + // ─── MEDIUM (dueDate 2026-06-20 to 2026-07-19) — 5 entries ───────────────── + + { + id: 'rem-010', + type: ReminderType.RENT_REVIEW, + priority: ReminderPriority.MEDIUM, + status: ReminderStatus.ACTIVE, + propertyId: 'prop-010', + propertyTitle: 'Retailfläche Löwenplatz 3', + propertyCity: 'Zürich', + propertyDistrict: 'Innenstadt', + tenantName: 'Fashion Concept GmbH', + dueDate: '2026-06-25', + eventDate: '2026-09-30', + contractEndDate: '2026-09-30', + areaSqm: 285, + currentRentPerSqm: 1056, + currency: 'CHF', + shadowMarketRisk: ShadowMarketRisk.LOW, + schattenmarktEnabled: true, + activity: [ + { at: '2026-03-20T10:00:00Z', by: 'Sandra Wyss', action: 'CREATED' }, + ], + organizationId: 'org-wincasa', + createdAt: '2026-03-20T10:00:00Z', + updatedAt: '2026-03-20T10:00:00Z', + }, + + { + id: 'rem-011', + type: ReminderType.LEASE_EXPIRY, + priority: ReminderPriority.MEDIUM, + status: ReminderStatus.ACTIVE, + propertyId: 'prop-022', + propertyTitle: 'Bürofläche St.Gallen Centrum 7', + propertyCity: 'St. Gallen', + propertyDistrict: 'Centrum', + tenantName: 'Werbeatelier SG GmbH', + dueDate: '2026-07-01', + eventDate: '2027-02-28', + contractEndDate: '2027-02-28', + areaSqm: 700, + currentRentPerSqm: 336, + currency: 'CHF', + shadowMarketRisk: ShadowMarketRisk.MEDIUM, + schattenmarktEnabled: false, + note: 'Erstgespräch über Verlängerung bis 01.07 einleiten.', + activity: [ + { at: '2026-03-10T08:00:00Z', by: 'Thomas Huber', action: 'CREATED' }, + { at: '2026-05-02T09:00:00Z', by: 'Thomas Huber', action: 'NOTED', note: 'Vermieterseite wünscht Mietpreiserhöhung +5%' }, + ], + organizationId: 'org-wincasa', + createdAt: '2026-03-10T08:00:00Z', + updatedAt: '2026-05-02T09:00:00Z', + }, + + { + id: 'rem-012', + type: ReminderType.INSPECTION, + priority: ReminderPriority.MEDIUM, + status: ReminderStatus.ACTIVE, + propertyId: 'prop-002', + propertyTitle: 'Lagerfläche Hardstrasse 44', + propertyCity: 'Basel', + propertyDistrict: 'Kleinhüningen', + tenantName: 'Spedition Rhein GmbH', + dueDate: '2026-07-08', + eventDate: '2026-07-08', + areaSqm: 2400, + currentRentPerSqm: 168, + currency: 'CHF', + shadowMarketRisk: ShadowMarketRisk.NONE, + schattenmarktEnabled: true, + activity: [ + { at: '2026-04-15T09:00:00Z', by: 'Sandra Wyss', action: 'CREATED', note: 'Feuerschutz-Inspektion nach Mieterumbau' }, + ], + organizationId: 'org-wincasa', + createdAt: '2026-04-15T09:00:00Z', + updatedAt: '2026-04-15T09:00:00Z', + }, + + { + id: 'rem-013', + type: ReminderType.SCHATTENMARKT_RELEASE, + priority: ReminderPriority.MEDIUM, + status: ReminderStatus.ACTIVE, + propertyId: 'prop-011', + propertyTitle: 'Produktionshalle Brünnen West 22', + propertyCity: 'Bern', + propertyDistrict: 'Brünnen', + tenantName: 'Metallbau Bern AG', + dueDate: '2026-07-10', + eventDate: '2027-03-31', + contractEndDate: '2027-03-31', + areaSqm: 2800, + currentRentPerSqm: 144, + currency: 'CHF', + shadowMarketRisk: ShadowMarketRisk.HIGH, + schattenmarktEnabled: false, + note: 'Schattenmarkt-Aktivierung 9 Monate vor Vertragsende. Eigentümer-Freigabe einholen.', + activity: [ + { at: '2026-04-25T11:00:00Z', by: 'Anna Meier', action: 'CREATED' }, + ], + organizationId: 'org-wincasa', + createdAt: '2026-04-25T11:00:00Z', + updatedAt: '2026-04-25T11:00:00Z', + }, + + { + id: 'rem-014', + type: ReminderType.CUSTOM, + priority: ReminderPriority.MEDIUM, + status: ReminderStatus.SNOOZED, + propertyId: 'prop-012', + propertyTitle: 'Bürofläche Stadtturm Zug', + propertyCity: 'Zug', + propertyDistrict: 'Zentrum', + tenantName: 'FinTech Zug AG', + dueDate: '2026-07-15', + eventDate: '2026-07-15', + areaSqm: 550, + currentRentPerSqm: 504, + currency: 'CHF', + shadowMarketRisk: ShadowMarketRisk.LOW, + schattenmarktEnabled: false, + snoozedUntil: '2026-06-01', + note: 'Eigentümerpräsentation Q2-Bericht.', + activity: [ + { at: '2026-04-28T14:00:00Z', by: 'Thomas Huber', action: 'CREATED' }, + { at: '2026-05-19T09:00:00Z', by: 'Thomas Huber', action: 'SNOOZED', note: 'Quartalsbericht noch nicht fertig' }, + ], + organizationId: 'org-wincasa', + createdAt: '2026-04-28T14:00:00Z', + updatedAt: '2026-05-19T09:00:00Z', + }, + + // ─── LOW (dueDate after 2026-07-19) — 5 entries ────────────────────────────── + + { + id: 'rem-015', + type: ReminderType.LEASE_EXPIRY, + priority: ReminderPriority.LOW, + status: ReminderStatus.ACTIVE, + propertyId: 'prop-007', + propertyTitle: 'Bürofläche Thurgauerstrasse 40', + propertyCity: 'Zürich', + propertyDistrict: 'Oerlikon', + tenantName: 'Consulting Partners AG', + dueDate: '2026-08-01', + eventDate: '2026-11-30', + contractEndDate: '2026-11-30', + areaSqm: 720, + currentRentPerSqm: 432, + currency: 'CHF', + shadowMarketRisk: ShadowMarketRisk.MEDIUM, + schattenmarktEnabled: true, + activity: [ + { at: '2026-02-01T08:00:00Z', by: 'Anna Meier', action: 'CREATED', note: 'Vertragsablauf-Erinnerung (4 Monate)' }, + ], + organizationId: 'org-wincasa', + createdAt: '2026-02-01T08:00:00Z', + updatedAt: '2026-02-01T08:00:00Z', + }, + + { + id: 'rem-016', + type: ReminderType.INSURANCE_RENEWAL, + priority: ReminderPriority.LOW, + status: ReminderStatus.ACTIVE, + propertyId: 'prop-009', + propertyTitle: 'Logistikzentrum Tössfeldstrasse 18', + propertyCity: 'Winterthur', + propertyDistrict: 'Töss', + tenantName: 'Sperrgut Logistik AG', + dueDate: '2026-09-15', + eventDate: '2026-10-31', + areaSqm: 1800, + currentRentPerSqm: 156, + currency: 'CHF', + shadowMarketRisk: ShadowMarketRisk.NONE, + schattenmarktEnabled: false, + activity: [ + { at: '2026-04-20T08:00:00Z', by: 'Sandra Wyss', action: 'CREATED' }, + ], + organizationId: 'org-wincasa', + createdAt: '2026-04-20T08:00:00Z', + updatedAt: '2026-04-20T08:00:00Z', + }, + + { + id: 'rem-017', + type: ReminderType.MAINTENANCE, + priority: ReminderPriority.LOW, + status: ReminderStatus.ACTIVE, + propertyId: 'prop-014', + propertyTitle: 'Logistikhalle Pratteln Nord', + propertyCity: 'Pratteln', + propertyDistrict: 'Industriezone', + tenantName: 'Handels- und Lagerbetrieb AG', + dueDate: '2026-10-01', + eventDate: '2026-10-01', + areaSqm: 3100, + currentRentPerSqm: 180, + currency: 'CHF', + shadowMarketRisk: ShadowMarketRisk.NONE, + schattenmarktEnabled: false, + note: 'Jährliche Heizungsservice-Kontrolle.', + activity: [ + { at: '2026-04-05T08:00:00Z', by: 'Thomas Huber', action: 'CREATED' }, + ], + organizationId: 'org-wincasa', + createdAt: '2026-04-05T08:00:00Z', + updatedAt: '2026-04-05T08:00:00Z', + }, + + // ─── COMPLETED (2 entries) ─────────────────────────────────────────────────── + + { + id: 'rem-018', + type: ReminderType.INSPECTION, + priority: ReminderPriority.HIGH, + status: ReminderStatus.COMPLETED, + propertyId: 'prop-001', + propertyTitle: 'Bürofläche Zollstrasse 12', + propertyCity: 'Zürich', + propertyDistrict: 'Zürich-West', + tenantName: 'MediaGroup Schweiz AG', + dueDate: '2026-04-30', + eventDate: '2026-04-30', + areaSqm: 850, + currentRentPerSqm: 456, + currency: 'CHF', + shadowMarketRisk: ShadowMarketRisk.LOW, + schattenmarktEnabled: true, + note: 'Inspektion abgeschlossen. Kleinere Reparaturen veranlasst.', + activity: [ + { at: '2026-03-01T08:00:00Z', by: 'Thomas Huber', action: 'CREATED' }, + { at: '2026-04-30T16:00:00Z', by: 'Thomas Huber', action: 'COMPLETED', note: 'Inspektion durchgeführt, Protokoll abgelegt' }, + ], + organizationId: 'org-wincasa', + createdAt: '2026-03-01T08:00:00Z', + updatedAt: '2026-04-30T16:00:00Z', + }, + + { + id: 'rem-019', + type: ReminderType.RENT_REVIEW, + priority: ReminderPriority.MEDIUM, + status: ReminderStatus.COMPLETED, + propertyId: 'prop-008', + propertyTitle: 'Bürofläche Dreispitz Areal 9', + propertyCity: 'Basel', + propertyDistrict: 'Dreispitz', + tenantName: 'Pharma Research GmbH', + dueDate: '2026-04-15', + eventDate: '2026-08-31', + contractEndDate: '2027-08-31', + areaSqm: 900, + currentRentPerSqm: 384, + currency: 'CHF', + shadowMarketRisk: ShadowMarketRisk.MEDIUM, + schattenmarktEnabled: false, + note: 'Mietpreisanpassung +2.8% vereinbart, ab 01.09 gültig.', + activity: [ + { at: '2026-02-15T08:00:00Z', by: 'Anna Meier', action: 'CREATED' }, + { at: '2026-04-14T11:00:00Z', by: 'Anna Meier', action: 'NOTED', note: 'Mieter hat Anpassung akzeptiert' }, + { at: '2026-04-15T14:00:00Z', by: 'Anna Meier', action: 'COMPLETED', note: 'Nachtrag unterzeichnet' }, + ], + organizationId: 'org-wincasa', + createdAt: '2026-02-15T08:00:00Z', + updatedAt: '2026-04-15T14:00:00Z', + }, + + // ─── DISMISSED (2 entries) ────────────────────────────────────────────────── + + { + id: 'rem-020', + type: ReminderType.CUSTOM, + priority: ReminderPriority.LOW, + status: ReminderStatus.DISMISSED, + propertyId: 'prop-013', + propertyTitle: 'Gewerbe-/Bürofläche Altstetten Park', + propertyCity: 'Zürich', + propertyDistrict: 'Altstetten', + tenantName: 'Design Studio Zürich GmbH', + dueDate: '2026-05-01', + eventDate: '2026-05-01', + areaSqm: 1300, + currentRentPerSqm: 540, + currency: 'CHF', + shadowMarketRisk: ShadowMarketRisk.LOW, + schattenmarktEnabled: true, + note: 'Interner Termin wurde vom Eigentümer abgesagt.', + activity: [ + { at: '2026-03-20T10:00:00Z', by: 'Sandra Wyss', action: 'CREATED' }, + { at: '2026-04-28T09:00:00Z', by: 'Sandra Wyss', action: 'DISMISSED', note: 'Eigentümer hat Termin abgesagt' }, + ], + organizationId: 'org-wincasa', + createdAt: '2026-03-20T10:00:00Z', + updatedAt: '2026-04-28T09:00:00Z', + }, + + { + id: 'rem-021', + type: ReminderType.MAINTENANCE, + priority: ReminderPriority.LOW, + status: ReminderStatus.DISMISSED, + propertyId: 'prop-010', + propertyTitle: 'Retailfläche Löwenplatz 3', + propertyCity: 'Zürich', + propertyDistrict: 'Innenstadt', + tenantName: 'Fashion Concept GmbH', + dueDate: '2026-04-20', + eventDate: '2026-04-20', + areaSqm: 285, + currentRentPerSqm: 1056, + currency: 'CHF', + shadowMarketRisk: ShadowMarketRisk.NONE, + schattenmarktEnabled: true, + note: 'Wartungsarbeiten vom Mieter eigenverantwortlich erledigt gemäss Mietvertrag.', + activity: [ + { at: '2026-03-10T08:00:00Z', by: 'Thomas Huber', action: 'CREATED' }, + { at: '2026-04-19T11:00:00Z', by: 'Thomas Huber', action: 'DISMISSED', note: 'Mieter hat Wartung selbst veranlasst' }, + ], + organizationId: 'org-wincasa', + createdAt: '2026-03-10T08:00:00Z', + updatedAt: '2026-04-19T11:00:00Z', + }, +] diff --git a/src/pages/demand/Results.tsx b/src/pages/demand/Results.tsx index 174560c..3fdf86d 100644 --- a/src/pages/demand/Results.tsx +++ b/src/pages/demand/Results.tsx @@ -72,7 +72,7 @@ export default function Results() { const { data: results = [], isLoading } = useUnifiedResults(activeNeed?.id) const filtered = results.filter(r => { - if (r.resultType === 'VERIFIED_PORTFOLIO') return showOwnProperties + if (r.resultType === 'VERIFIED_PORTFOLIO') return showOwnProperties && currentUser?.role === 'PROPERTY_MANAGER' // Schattenmarkt toggle is independent of the source filter if (r.resultType === 'FUTURE_AVAILABILITY') return showSchattenmarkt return filterSource === 'ALL' || r.resultType === filterSource diff --git a/src/pages/supply/ReminderManager.tsx b/src/pages/supply/ReminderManager.tsx new file mode 100644 index 0000000..d995163 --- /dev/null +++ b/src/pages/supply/ReminderManager.tsx @@ -0,0 +1,22 @@ +import { Box } from '@mui/material' +import { ReminderHeader } from '../../components/supply/ReminderHeader' +import { ReminderKpiBar } from '../../components/supply/ReminderKpiBar' +import { ReminderFilterBar } from '../../components/supply/ReminderFilterBar' +import { ReminderFeed } from '../../components/supply/ReminderFeed' +import { ReminderDetailDrawer } from '../../components/supply/ReminderDetailDrawer' + +export default function ReminderManager() { + return ( + + + + + + + + + + + + ) +} diff --git a/src/provider/IReminderProvider.ts b/src/provider/IReminderProvider.ts new file mode 100644 index 0000000..5f35b9b --- /dev/null +++ b/src/provider/IReminderProvider.ts @@ -0,0 +1,11 @@ +import type { Reminder } from '../domain/reminder' + +export interface IReminderProvider { + getAll(): Promise + getById(id: string): Promise + update(id: string, data: Partial): Promise + complete(id: string, note?: string): Promise + dismiss(id: string, note?: string): Promise + snooze(id: string, until: string): Promise + create(data: Omit): Promise +} diff --git a/src/provider/MockupReminderProvider.ts b/src/provider/MockupReminderProvider.ts new file mode 100644 index 0000000..f73f603 --- /dev/null +++ b/src/provider/MockupReminderProvider.ts @@ -0,0 +1,73 @@ +import { mockReminders } from '../mock-data/reminders' +import type { Reminder, ReminderActivity } from '../domain/reminder' +import { ReminderStatus } from '../domain/reminder' +import type { IReminderProvider } from './IReminderProvider' + +let store: Reminder[] = [...mockReminders] + +function now(): string { + return new Date().toISOString() +} + +function addActivity(reminder: Reminder, entry: ReminderActivity): Reminder { + return { ...reminder, activity: [...reminder.activity, entry], updatedAt: now() } +} + +export const MockupReminderProvider: IReminderProvider = { + async getAll() { + return [...store] + }, + + async getById(id) { + return store.find(r => r.id === id) ?? null + }, + + async update(id, data) { + const idx = store.findIndex(r => r.id === id) + if (idx === -1) throw new Error(`Reminder ${id} not found`) + store[idx] = { ...store[idx], ...data, updatedAt: now() } + return store[idx] + }, + + async complete(id, note) { + const idx = store.findIndex(r => r.id === id) + if (idx === -1) throw new Error(`Reminder ${id} not found`) + store[idx] = addActivity( + { ...store[idx], status: ReminderStatus.COMPLETED }, + { at: now(), by: 'current-user', action: 'COMPLETED', note }, + ) + return store[idx] + }, + + async dismiss(id, note) { + const idx = store.findIndex(r => r.id === id) + if (idx === -1) throw new Error(`Reminder ${id} not found`) + store[idx] = addActivity( + { ...store[idx], status: ReminderStatus.DISMISSED }, + { at: now(), by: 'current-user', action: 'DISMISSED', note }, + ) + return store[idx] + }, + + async snooze(id, until) { + const idx = store.findIndex(r => r.id === id) + if (idx === -1) throw new Error(`Reminder ${id} not found`) + store[idx] = addActivity( + { ...store[idx], status: ReminderStatus.SNOOZED, snoozedUntil: until }, + { at: now(), by: 'current-user', action: 'SNOOZED', note: `Snoozed until ${until}` }, + ) + return store[idx] + }, + + async create(data) { + const reminder: Reminder = { + ...data, + id: crypto.randomUUID(), + activity: [{ at: now(), by: 'current-user', action: 'CREATED' }], + createdAt: now(), + updatedAt: now(), + } + store.push(reminder) + return reminder + }, +} diff --git a/src/services/reminderService.ts b/src/services/reminderService.ts new file mode 100644 index 0000000..28e9dfd --- /dev/null +++ b/src/services/reminderService.ts @@ -0,0 +1,66 @@ +import { MockupReminderProvider } from '../provider/MockupReminderProvider' +import type { Reminder } from '../domain/reminder' +import { ReminderPriority, ReminderStatus, ShadowMarketRisk } from '../domain/reminder' + +const provider = MockupReminderProvider + +const MOCK_TODAY = new Date('2026-05-20') + +function daysDiff(isoDate: string): number { + const due = new Date(isoDate) + return Math.ceil((due.getTime() - MOCK_TODAY.getTime()) / (1000 * 60 * 60 * 24)) +} + +export const reminderService = { + getAll: () => provider.getAll(), + getById: (id: string) => provider.getById(id), + update: (id: string, data: Partial) => provider.update(id, data), + complete: (id: string, note?: string) => provider.complete(id, note), + dismiss: (id: string, note?: string) => provider.dismiss(id, note), + snooze: (id: string, until: string) => provider.snooze(id, until), + create: (data: Omit) => + provider.create(data), + + getInsights: async () => { + const reminders = await provider.getAll() + const active = reminders.filter(r => r.status === ReminderStatus.ACTIVE || r.status === ReminderStatus.SNOOZED) + + const urgentCount = active.filter(r => r.priority === ReminderPriority.URGENT).length + + const endOfWeek = new Date(MOCK_TODAY) + endOfWeek.setDate(endOfWeek.getDate() + 7) + const dueThisWeek = active.filter(r => { + const d = daysDiff(r.dueDate) + return d >= 0 && d <= 7 + }).length + + const endOfMonth = new Date(MOCK_TODAY) + endOfMonth.setDate(endOfMonth.getDate() + 30) + const dueThisMonth = active.filter(r => { + const d = daysDiff(r.dueDate) + return d >= 0 && d <= 30 + }).length + + const schattenmarktReadyCount = reminders.filter( + r => + r.status === ReminderStatus.ACTIVE && + !r.schattenmarktEnabled && + (r.shadowMarketRisk === ShadowMarketRisk.HIGH), + ).length + + const activeDays = active + .filter(r => daysDiff(r.dueDate) > 0) + .map(r => daysDiff(r.dueDate)) + const avgDaysToAction = activeDays.length + ? Math.round(activeDays.reduce((s, d) => s + d, 0) / activeDays.length) + : 0 + + return { + urgentCount, + dueThisWeek, + dueThisMonth, + schattenmarktReadyCount, + avgDaysToAction, + } + }, +} diff --git a/src/stores/reminderStore.ts b/src/stores/reminderStore.ts new file mode 100644 index 0000000..083638b --- /dev/null +++ b/src/stores/reminderStore.ts @@ -0,0 +1,36 @@ +import { create } from 'zustand' +import type { ReminderType, ReminderStatus, ReminderPriority } from '../domain/reminder' + +interface ReminderStore { + selectedId: string | null + setSelectedId: (id: string | null) => void + drawerOpen: boolean + setDrawerOpen: (open: boolean) => void + filterType: ReminderType | 'ALL' + setFilterType: (t: ReminderType | 'ALL') => void + filterStatus: ReminderStatus | 'ALL' + setFilterStatus: (s: ReminderStatus | 'ALL') => void + filterPriority: ReminderPriority | 'ALL' + setFilterPriority: (p: ReminderPriority | 'ALL') => void + searchQuery: string + setSearchQuery: (q: string) => void + viewMode: 'list' | 'card' + setViewMode: (m: 'list' | 'card') => void +} + +export const useReminderStore = create((set) => ({ + selectedId: null, + setSelectedId: (id) => set({ selectedId: id }), + drawerOpen: false, + setDrawerOpen: (open) => set({ drawerOpen: open }), + filterType: 'ALL', + setFilterType: (t) => set({ filterType: t }), + filterStatus: 'ALL', + setFilterStatus: (s) => set({ filterStatus: s }), + filterPriority: 'ALL', + setFilterPriority: (p) => set({ filterPriority: p }), + searchQuery: '', + setSearchQuery: (q) => set({ searchQuery: q }), + viewMode: 'list', + setViewMode: (m) => set({ viewMode: m }), +}))