feat: Reminder Manager + Schattenmarkt-Freigabe + mock data overhaul

- Add Reminder Manager page (/supply/reminder-manager) with KPI bar,
  filter bar, list/card feed, and detail drawer (7 sections incl.
  activity log, snooze, complete, dismiss actions)
- Add Schattenmarkt-Freigabe toggle on PropertyDetailView: Verwaltung
  can opt-in properties for early market exposure before contract expiry
- Auto-generate FutureSignal cards via useSchattenmarktSignals hook
  when leaseEndDate - leadTimeMonths <= MOCK_TODAY
- Fix useUnifiedResults: use property.resultType as fallback (was
  defaulting everything to VERIFIED_PORTFOLIO)
- Fix demand Results: hide VERIFIED_PORTFOLIO from non-manager users
  even when showOwnProperties toggle was previously enabled
- Fix AppShell: redirect to allowed workspace on user role switch
- Fix 3 wrong match scores (match-002: 93→62, match-009: 86→55,
  match-017: 87→52)
- Add 7 new match records (match-050–056) for need-001, need-002,
  need-011
- Add need-011 (Retail Bern Innenstadt)
- Add prop-031–036 (EXTERNAL_MARKET / MAISON_WORK / FUTURE_AVAILABILITY)
- Fix duplicate image URLs across all properties
- Add signal-011 (Bern Altstadt, Mode Boutique) + propertyId to
  signal-001

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Benjamin Sutter
2026-05-20 11:52:24 +02:00
parent 0663757cde
commit 9f391d17cb
36 changed files with 3011 additions and 71 deletions
+72
View File
@@ -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<import('../domain/reminder').Reminder> }) =>
reminderService.update(id, data),
onSuccess: (_result, { id }) => {
queryClient.invalidateQueries({ queryKey: ['reminders'] })
queryClient.invalidateQueries({ queryKey: ['reminder', id] })
},
})
}
+72
View File
@@ -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.`,
}
}
+23 -17
View File
@@ -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 }
}