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
+
+
+ }
+ onClick={handleCreate}
+ sx={{ textTransform: 'none', fontWeight: 600 }}
+ >
+ Reminder erstellen
+
+
+ )
+}
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 }),
+}))