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
+159 -1
View File
@@ -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<HTMLInputElement>, checked: boolean) {
setEnabled(checked)
save(checked, leadTimeMonths)
}
function handleLeadTime(months: number) {
setLeadTimeMonths(months)
if (enabled) save(enabled, months)
}
return (
<>
<Divider sx={{ my: 2 }} />
<SectionTitle title="Schattenmarkt" />
<Box
sx={{
border: '1px solid',
borderColor: enabled ? '#8b5cf6' : '#e2e8f0',
borderRadius: 1.5,
p: 1.75,
bgcolor: enabled ? '#faf5ff' : 'transparent',
transition: 'background 0.2s, border-color 0.2s',
}}
>
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between' }}>
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1 }}>
<Zap size={15} color={enabled ? '#7c3aed' : '#94a3b8'} style={{ marginTop: 2 }} />
<Box>
<Typography variant="body2" sx={{ fontWeight: 600, lineHeight: 1.3 }}>
Für Schattenmarkt freigeben
</Typography>
<Typography variant="caption" color="text.secondary">
Nachfragesuchende sehen dieses Objekt vor Vertragsende im Feed
</Typography>
</Box>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, ml: 1, flexShrink: 0 }}>
{saving && <CircularProgress size={12} sx={{ color: '#7c3aed' }} />}
<Switch
checked={enabled}
onChange={handleToggle}
size="small"
sx={{
'& .MuiSwitch-switchBase.Mui-checked': { color: '#7c3aed' },
'& .MuiSwitch-switchBase.Mui-checked + .MuiSwitch-track': { bgcolor: '#8b5cf6' },
}}
/>
</Box>
</Box>
{enabled && (
<Box sx={{ mt: 1.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.25 }}>
<Typography variant="caption" sx={{ color: '#374151', fontWeight: 500, minWidth: 72 }}>
Lead Time
</Typography>
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap' }}>
{[3, 4, 5, 6, 8, 12].map(m => (
<Chip
key={m}
label={`${m} M`}
size="small"
onClick={() => 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' },
}}
/>
))}
</Box>
</Box>
<Box
sx={{
display: 'flex', alignItems: 'flex-start', gap: 0.75, p: 1,
borderRadius: 1, border: '1px solid',
bgcolor: isActive ? '#f0fdf4' : '#fff7ed',
borderColor: isActive ? '#bbf7d0' : '#fed7aa',
}}
>
{isActive
? <ShieldCheck size={13} color="#166534" style={{ marginTop: 1, flexShrink: 0 }} />
: <Clock size={13} color="#92400e" style={{ marginTop: 1, flexShrink: 0 }} />
}
<Typography variant="caption" sx={{ color: isActive ? '#166534' : '#92400e', fontWeight: 500, lineHeight: 1.4 }}>
{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'
}
</Typography>
</Box>
</Box>
)}
{!enabled && (
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 1 }}>
Wenn aktiviert, erscheint dieses Objekt {leadTimeMonths} Monate vor Vertragsende als
verifiziertes Schattenmarkt-Signal im Nachfrage-Feed.
</Typography>
)}
</Box>
</>
)
}
// ── Übersicht tab ─────────────────────────────────────────────────────────────
interface OverviewPanelProps {
@@ -418,6 +573,9 @@ function OverviewPanel({ p, editing, draft, onDraftChange }: OverviewPanelProps)
{/* Floor / unit structure */}
<UnitStructurePanel p={p} />
{/* Schattenmarkt release */}
<SchattenmarktReleasePanel p={p} />
<Divider sx={{ my: 2 }} />
{/* Object details */}