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:
@@ -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<string, string> = {
|
||||
NONE: '#64748b',
|
||||
LOW: '#16a34a',
|
||||
MEDIUM: '#ca8a04',
|
||||
HIGH: '#dc2626',
|
||||
}
|
||||
|
||||
const SHADOW_RISK_LABEL: Record<string, string> = {
|
||||
NONE: 'Kein Risiko',
|
||||
LOW: 'Niedrig',
|
||||
MEDIUM: 'Mittel',
|
||||
HIGH: 'Hoch',
|
||||
}
|
||||
|
||||
const STATUS_CHIP_COLOR: Record<string, 'default' | 'success' | 'warning' | 'error' | 'info'> = {
|
||||
ACTIVE: 'info',
|
||||
SNOOZED: 'warning',
|
||||
COMPLETED: 'success',
|
||||
DISMISSED: 'default',
|
||||
}
|
||||
|
||||
const STATUS_LABEL: Record<string, string> = {
|
||||
ACTIVE: 'Aktiv',
|
||||
SNOOZED: 'Schlummernd',
|
||||
COMPLETED: 'Erledigt',
|
||||
DISMISSED: 'Verworfen',
|
||||
}
|
||||
|
||||
const ACTION_LABEL: Record<string, string> = {
|
||||
CREATED: 'Erstellt',
|
||||
SNOOZED: 'Zurückgestellt',
|
||||
COMPLETED: 'Erledigt',
|
||||
DISMISSED: 'Verworfen',
|
||||
NOTED: 'Notiz',
|
||||
}
|
||||
|
||||
function SectionTitle({ icon, children }: { icon: React.ReactNode; children: React.ReactNode }) {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
|
||||
{icon}
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: '#1e293b', fontSize: '0.8125rem' }}>
|
||||
{children}
|
||||
</Typography>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
function DateRow({ label, value }: { label: string; value?: string }) {
|
||||
if (!value) return null
|
||||
return (
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: '120px 1fr', gap: 1, alignItems: 'baseline' }}>
|
||||
<Typography variant="caption" color="text.secondary">{label}</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 500 }}>{new Date(value).toLocaleDateString('de-CH')}</Typography>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
function ActivityEntry({ entry }: { entry: ReminderActivity }) {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', gap: 1.5 }}>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', flexShrink: 0 }}>
|
||||
<Box sx={{ width: 8, height: 8, borderRadius: '50%', bgcolor: '#94a3b8', mt: '4px' }} />
|
||||
<Box sx={{ width: 1, flex: 1, bgcolor: '#e2e8f0', mt: 0.5 }} />
|
||||
</Box>
|
||||
<Box sx={{ pb: 1.5, minWidth: 0 }}>
|
||||
<Box sx={{ display: 'flex', gap: 1, alignItems: 'baseline', flexWrap: 'wrap' }}>
|
||||
<Typography variant="caption" sx={{ fontWeight: 600, color: '#1e293b' }}>
|
||||
{ACTION_LABEL[entry.action]}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{entry.by} · {new Date(entry.at).toLocaleDateString('de-CH')}
|
||||
</Typography>
|
||||
</Box>
|
||||
{entry.note && (
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 0.25 }}>
|
||||
{entry.note}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<Drawer
|
||||
anchor="right"
|
||||
open={drawerOpen}
|
||||
onClose={handleClose}
|
||||
slotProps={{ paper: { sx: { width: 480, display: 'flex', flexDirection: 'column' } } }}
|
||||
>
|
||||
{/* Header */}
|
||||
<Box
|
||||
sx={{
|
||||
px: 2.5,
|
||||
py: 2,
|
||||
borderBottom: '1px solid #e2e8f0',
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
gap: 1,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 1, minWidth: 0 }}>
|
||||
{isNew ? (
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>Neuer Reminder</Typography>
|
||||
) : reminder ? (
|
||||
<>
|
||||
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
<ReminderTypeBadge type={reminder.type} />
|
||||
<ReminderPriorityBadge priority={reminder.priority} />
|
||||
<Chip
|
||||
label={STATUS_LABEL[reminder.status]}
|
||||
color={STATUS_CHIP_COLOR[reminder.status]}
|
||||
size="small"
|
||||
sx={{ height: 20, fontSize: '0.7rem' }}
|
||||
/>
|
||||
</Box>
|
||||
<ReminderDaysIndicator dueDate={reminder.dueDate} />
|
||||
</>
|
||||
) : null}
|
||||
</Box>
|
||||
<IconButton size="small" onClick={handleClose} sx={{ flexShrink: 0 }}>
|
||||
<X size={18} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
|
||||
{/* Scrollable body */}
|
||||
<Box sx={{ flex: 1, overflowY: 'auto', p: 2.5 }} className="flex flex-col gap-5">
|
||||
{isNew && (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Neue Reminder-Erstellung noch nicht implementiert.
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{reminder && (
|
||||
<>
|
||||
{/* 2. Property */}
|
||||
<Box>
|
||||
<SectionTitle icon={<MapPin size={15} color="#64748b" />}>Objekt</SectionTitle>
|
||||
<Box className="flex flex-col gap-1">
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>{reminder.propertyTitle}</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{reminder.propertyCity}{reminder.propertyDistrict ? ` · ${reminder.propertyDistrict}` : ''}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">Mieter: {reminder.tenantName}</Typography>
|
||||
<Typography variant="caption" color="text.secondary">Fläche: {reminder.areaSqm.toLocaleString('de-CH')} m²</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* 3. Dates */}
|
||||
<Box>
|
||||
<SectionTitle icon={<Calendar size={15} color="#64748b" />}>Daten & Fristen</SectionTitle>
|
||||
<Box className="flex flex-col gap-1.5">
|
||||
<DateRow label="Fälligkeitsdatum" value={reminder.dueDate} />
|
||||
<DateRow label="Ereignisdatum" value={reminder.eventDate} />
|
||||
<DateRow label="Vertragsende" value={reminder.contractEndDate} />
|
||||
<DateRow label="Break-Option" value={reminder.breakOptionDate} />
|
||||
{reminder.snoozedUntil && (
|
||||
<DateRow label="Schlummern bis" value={reminder.snoozedUntil} />
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* 4. Financials */}
|
||||
<Box>
|
||||
<SectionTitle icon={<DollarSign size={15} color="#64748b" />}>Finanzen</SectionTitle>
|
||||
<Box className="flex flex-col gap-1.5">
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: '120px 1fr', gap: 1 }}>
|
||||
<Typography variant="caption" color="text.secondary">Miete/m²</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 500 }}>
|
||||
{reminder.currency} {reminder.currentRentPerSqm.toLocaleString('de-CH')} / Monat
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: '120px 1fr', gap: 1 }}>
|
||||
<Typography variant="caption" color="text.secondary">Total / Monat</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 500 }}>
|
||||
{reminder.currency} {(reminder.currentRentPerSqm * reminder.areaSqm).toLocaleString('de-CH')}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* 5. Schattenmarkt */}
|
||||
<Box>
|
||||
<SectionTitle icon={<Eye size={15} color="#64748b" />}>Schattenmarkt-Risiko</SectionTitle>
|
||||
<Box className="flex flex-col gap-2">
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Box
|
||||
sx={{
|
||||
width: 10,
|
||||
height: 10,
|
||||
borderRadius: '50%',
|
||||
bgcolor: SHADOW_RISK_COLOR[reminder.shadowMarketRisk],
|
||||
}}
|
||||
/>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, color: SHADOW_RISK_COLOR[reminder.shadowMarketRisk] }}>
|
||||
{SHADOW_RISK_LABEL[reminder.shadowMarketRisk]}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Tooltip title="Nur Anzeige — Änderung über Objektverwaltung">
|
||||
<FormControlLabel
|
||||
control={<Switch checked={reminder.schattenmarktEnabled} size="small" readOnly />}
|
||||
label={
|
||||
<Typography variant="caption">
|
||||
Schattenmarkt aktiviert
|
||||
</Typography>
|
||||
}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* 6. Note */}
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 1, fontSize: '0.8125rem' }}>Notiz</Typography>
|
||||
<TextField
|
||||
multiline
|
||||
rows={3}
|
||||
fullWidth
|
||||
size="small"
|
||||
value={noteValue || (reminder.note ?? '')}
|
||||
onChange={e => setNoteValue(e.target.value)}
|
||||
placeholder="Notiz hinzufügen…"
|
||||
sx={{ '& .MuiInputBase-root': { fontSize: '0.8125rem' } }}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Actions */}
|
||||
{(reminder.status === ReminderStatus.ACTIVE || reminder.status === ReminderStatus.SNOOZED) && (
|
||||
<>
|
||||
<Divider />
|
||||
<Box className="flex flex-col gap-2">
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, fontSize: '0.8125rem' }}>Aktionen</Typography>
|
||||
<Box className="flex gap-2">
|
||||
<Button
|
||||
size="small"
|
||||
variant="contained"
|
||||
color="success"
|
||||
sx={{ textTransform: 'none', flex: 1 }}
|
||||
onClick={() => complete.mutate({ id: reminder.id, note: noteValue || undefined })}
|
||||
>
|
||||
Erledigt
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
color="error"
|
||||
sx={{ textTransform: 'none', flex: 1 }}
|
||||
onClick={() => dismiss.mutate({ id: reminder.id, note: noteValue || undefined })}
|
||||
>
|
||||
Verwerfen
|
||||
</Button>
|
||||
</Box>
|
||||
<Box className="flex gap-2 items-center">
|
||||
<TextField
|
||||
type="date"
|
||||
size="small"
|
||||
value={snoozeDate}
|
||||
onChange={e => setSnoozeDate(e.target.value)}
|
||||
sx={{ flex: 1, '& .MuiInputBase-root': { fontSize: '0.8125rem' } }}
|
||||
/>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
disabled={!snoozeDate}
|
||||
sx={{ textTransform: 'none', whiteSpace: 'nowrap' }}
|
||||
onClick={() => snoozeDate && snooze.mutate({ id: reminder.id, until: snoozeDate })}
|
||||
>
|
||||
Schlummern bis
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* 7. Activity */}
|
||||
<Box>
|
||||
<SectionTitle icon={<Activity size={15} color="#64748b" />}>Aktivitätslog</SectionTitle>
|
||||
<Box>
|
||||
{[...reminder.activity].reverse().map((entry, i) => (
|
||||
<ActivityEntry key={i} entry={entry} />
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user