feat: responsive layout + Reminder Manager redesign

- Auto-collapse sidebar at <1536px (all laptops), expand at ≥1536px
- Responsive drawer/panel widths across Pipeline, Properties, MyListings, Anfragen, MatchDetail, AISearch
- Reminder Manager: dot+label priority badge, Fläche column removed, status only for non-active rows
- ReminderKpiBar: focal Überfällig card, three secondary KPIs
- ReminderDetailDrawer: Task Panel redesign — Finanzen removed, Notiz promoted, Pre-Market as inline badge, full create form
- useCreateReminder hook wired to reminderService.create with cache invalidation
- Mock data: contract-derived reminders (LEASE_EXPIRY, BREAK_OPTION, RENT_REVIEW, INSURANCE_RENEWAL, SCHATTENMARKT_RELEASE) now show auto-creation note in Verlauf

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Benjamin Sutter
2026-06-16 14:30:06 +02:00
parent 5259579f85
commit 6aa4f96bd2
17 changed files with 463 additions and 273 deletions
+285 -157
View File
@@ -1,26 +1,185 @@
import { useState } from 'react'
import {
Drawer,
Box,
Typography,
IconButton,
Chip,
Divider,
TextField,
Button,
Switch,
FormControlLabel,
Tooltip,
Drawer, Box, Typography, IconButton, Chip, Divider,
TextField, Button, Select, MenuItem, FormControl, InputLabel, Autocomplete,
} from '@mui/material'
import { X, MapPin, Calendar, DollarSign, Eye, Activity, FileText, ExternalLink } from 'lucide-react'
import { X, Calendar, Activity, FileText, ExternalLink, Eye } from 'lucide-react'
import { useReminderStore } from '../../stores/reminderStore'
import { useReminder, useCompleteReminder, useDismissReminder, useSnoozeReminder } from '../../hooks/useReminders'
import { usePropertyById } from '../../hooks/useProperties'
import {
useReminder, useCompleteReminder, useDismissReminder,
useSnoozeReminder, useCreateReminder,
} from '../../hooks/useReminders'
import { usePropertyById, useProperties } from '../../hooks/useProperties'
import { ReminderPriorityBadge } from './ReminderPriorityBadge'
import { ReminderTypeBadge } from './ReminderTypeBadge'
import { ReminderDaysIndicator } from './ReminderDaysIndicator'
import { ReminderStatus } from '../../domain/reminder'
import { SHADOW_RISK_COLOR, SHADOW_RISK_LABEL, STATUS_CHIP_COLOR, STATUS_LABEL, SectionTitle, DateRow, ActivityEntry } from './reminderDetailHelpers'
import { ReminderStatus, ReminderType, ReminderPriority } from '../../domain/reminder'
import type { Property } from '../../domain/property'
import {
SHADOW_RISK_COLOR, SHADOW_RISK_LABEL,
STATUS_CHIP_COLOR, STATUS_LABEL,
SectionTitle, DateRow, ActivityEntry,
} from './reminderDetailHelpers'
// ── Constants ────────────────────────────────────────────────────────────────
const TYPE_LABELS: Record<ReminderType, string> = {
LEASE_EXPIRY: 'Mietablauf',
BREAK_OPTION: 'Break-Option',
RENT_REVIEW: 'Mietanpassung',
INSPECTION: 'Inspektion',
INSURANCE_RENEWAL: 'Versicherung',
MAINTENANCE: 'Unterhalt',
SCHATTENMARKT_RELEASE: 'Pre-Market',
CUSTOM: 'Individuell',
}
const TYPE_SECOND_DATE: Partial<Record<ReminderType, 'contractEndDate' | 'breakOptionDate' | 'eventDate'>> = {
[ReminderType.LEASE_EXPIRY]: 'contractEndDate',
[ReminderType.BREAK_OPTION]: 'breakOptionDate',
[ReminderType.RENT_REVIEW]: 'eventDate',
[ReminderType.SCHATTENMARKT_RELEASE]: 'eventDate',
}
const TYPE_SECOND_LABEL: Partial<Record<ReminderType, string>> = {
[ReminderType.LEASE_EXPIRY]: 'Vertragsende',
[ReminderType.BREAK_OPTION]: 'Break-Option',
[ReminderType.RENT_REVIEW]: 'Ereignisdatum',
[ReminderType.SCHATTENMARKT_RELEASE]: 'Ereignisdatum',
}
const MOCK_TODAY = new Date('2026-05-20')
function calcPriority(dueDateStr: string): ReminderPriority {
const days = Math.ceil((new Date(dueDateStr).getTime() - MOCK_TODAY.getTime()) / (1000 * 60 * 60 * 24))
if (days <= 14) return ReminderPriority.URGENT
if (days <= 30) return ReminderPriority.HIGH
if (days <= 60) return ReminderPriority.MEDIUM
return ReminderPriority.LOW
}
// ── Create Form ───────────────────────────────────────────────────────────────
function CreateForm({ onClose }: { onClose: () => void }) {
const { data: properties = [] } = useProperties()
const create = useCreateReminder()
const [type, setType] = useState<ReminderType>(ReminderType.LEASE_EXPIRY)
const [selectedProperty, setSelectedProperty] = useState<Property | null>(null)
const [tenantName, setTenantName] = useState('')
const [dueDate, setDueDate] = useState('')
const [note, setNote] = useState('')
function handlePropertyChange(_: unknown, prop: Property | null) {
setSelectedProperty(prop)
setTenantName(prop?.currentTenant ?? '')
}
function handleSubmit() {
if (!selectedProperty || !dueDate) return
create.mutate(
{
type,
priority: calcPriority(dueDate),
status: ReminderStatus.ACTIVE,
propertyId: selectedProperty.id,
propertyTitle: selectedProperty.title,
propertyCity: selectedProperty.location.city,
propertyDistrict: selectedProperty.location.district,
tenantName: tenantName || '—',
dueDate,
eventDate: dueDate,
areaSqm: selectedProperty.areaSqm,
currentRentPerSqm: selectedProperty.rentPricePerSqm,
currency: 'CHF',
shadowMarketRisk: 'NONE',
schattenmarktEnabled: false,
note: note || undefined,
organizationId: selectedProperty.organizationId ?? 'org-1',
},
{ onSuccess: onClose },
)
}
const canSubmit = !!selectedProperty && !!dueDate && !create.isPending
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2.5 }}>
{/* Typ */}
<FormControl size="small" fullWidth>
<InputLabel>Typ</InputLabel>
<Select
value={type}
label="Typ"
onChange={e => setType(e.target.value as ReminderType)}
>
{Object.values(ReminderType).map(t => (
<MenuItem key={t} value={t}>{TYPE_LABELS[t]}</MenuItem>
))}
</Select>
</FormControl>
{/* Objekt */}
<Autocomplete
options={properties}
getOptionLabel={p => p.title}
value={selectedProperty}
onChange={handlePropertyChange}
size="small"
renderInput={params => (
<TextField {...params} label="Objekt" placeholder="Objekt auswählen…" />
)}
/>
{/* Mieter — nur wenn Objekt ausgewählt */}
{selectedProperty && (
<TextField
label="Mieter"
size="small"
fullWidth
value={tenantName}
onChange={e => setTenantName(e.target.value)}
placeholder="Mietername eingeben…"
/>
)}
{/* Fälligkeitsdatum */}
<TextField
label="Fälligkeitsdatum"
type="date"
size="small"
fullWidth
value={dueDate}
onChange={e => setDueDate(e.target.value)}
slotProps={{ inputLabel: { shrink: true } }}
/>
{/* Notiz */}
<TextField
label="Notiz (optional)"
multiline
rows={3}
size="small"
fullWidth
value={note}
onChange={e => setNote(e.target.value)}
placeholder="Kontext oder Hinweise…"
/>
<Button
variant="contained"
fullWidth
disabled={!canSubmit}
onClick={handleSubmit}
sx={{ textTransform: 'none', fontWeight: 600 }}
>
{create.isPending ? 'Wird erstellt…' : 'Reminder erstellen'}
</Button>
</Box>
)
}
// ── Main Drawer ───────────────────────────────────────────────────────────────
export function ReminderDetailDrawer() {
const { selectedId, drawerOpen, setDrawerOpen, setSelectedId } = useReminderStore()
@@ -38,74 +197,79 @@ export function ReminderDetailDrawer() {
setSelectedId(null)
}
const isNew = !selectedId
const isCreateMode = !selectedId
const secondDateKey = reminder ? TYPE_SECOND_DATE[reminder.type] : undefined
const secondDateLabel = reminder ? TYPE_SECOND_LABEL[reminder.type] : undefined
const secondDateValue = secondDateKey && reminder ? reminder[secondDateKey] : undefined
const showSecondDate = secondDateValue && reminder && secondDateValue !== reminder.dueDate
const showPreMarket = reminder &&
reminder.shadowMarketRisk !== 'NONE' &&
reminder.shadowMarketRisk !== 'LOW'
const isActionable = reminder &&
(reminder.status === ReminderStatus.ACTIVE || reminder.status === ReminderStatus.SNOOZED)
return (
<Drawer
anchor="right"
open={drawerOpen}
onClose={handleClose}
slotProps={{ paper: { sx: { width: 480, display: 'flex', flexDirection: 'column' } } }}
slotProps={{ paper: { sx: { width: 460, 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}
{/* ── Header ── */}
<Box sx={{ px: 2.5, py: 2, borderBottom: '1px solid #e2e8f0', flexShrink: 0 }}>
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1 }}>
<Box sx={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 1, minWidth: 0 }}>
{isCreateMode ? (
<Typography variant="subtitle1" sx={{ fontWeight: 700, color: '#0f172a' }}>
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, color: '#94a3b8' }}>
<X size={18} />
</IconButton>
</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>
)}
{/* ── Body ── */}
<Box sx={{ flex: 1, overflowY: 'auto', p: 2.5, display: 'flex', flexDirection: 'column' }}>
{/* Create mode */}
{isCreateMode && <CreateForm onClose={handleClose} />}
{/* Detail mode */}
{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 sx={{ display: 'flex', flexDirection: 'column', gap: 0 }}>
{/* 1. Objekt */}
<Box sx={{ mb: 2.5 }}>
<Typography variant="body1" sx={{ fontWeight: 700, lineHeight: 1.3, mb: 0.25 }}>
{reminder.propertyTitle}
</Typography>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 1 }}>
{reminder.propertyCity}{reminder.propertyDistrict ? ` · ${reminder.propertyDistrict}` : ''}&nbsp;·&nbsp;{reminder.tenantName}
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, flexWrap: 'wrap' }}>
{reminderProperty?.leaseContractUrl ? (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mt: 0.5 }}>
<FileText size={12} color="#152642" />
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<FileText size={12} color="#64748b" />
<Button
size="small"
variant="outlined"
@@ -113,94 +277,58 @@ export function ReminderDetailDrawer() {
href={reminderProperty.leaseContractUrl}
target="_blank"
rel="noopener noreferrer"
sx={{ textTransform: 'none', fontSize: '0.68rem', py: 0.125, px: 0.75, borderColor: '#cbd5e1', color: '#152642' }}
sx={{
textTransform: 'none', fontSize: '0.68rem',
py: 0.125, px: 0.75, borderColor: '#cbd5e1', color: '#334155',
}}
>
{reminderProperty.leaseContractName ?? 'Mietvertrag'}
</Button>
</Box>
) : (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mt: 0.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<FileText size={12} color="#cbd5e1" />
<Typography variant="caption" sx={{ color: '#cbd5e1' }}>Kein Mietvertrag hinterlegt</Typography>
<Typography variant="caption" sx={{ color: '#cbd5e1' }}>Kein Vertrag</Typography>
</Box>
)}
{showPreMarket && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.4 }}>
<Eye size={11} color={SHADOW_RISK_COLOR[reminder.shadowMarketRisk]} />
<Typography variant="caption" sx={{
color: SHADOW_RISK_COLOR[reminder.shadowMarketRisk],
fontWeight: 600, fontSize: '0.68rem',
}}>
Pre-Mkt: {SHADOW_RISK_LABEL[reminder.shadowMarketRisk]}
</Typography>
</Box>
)}
</Box>
</Box>
<Divider />
<Divider sx={{ mb: 2.5 }} />
{/* 3. Dates */}
<Box>
<SectionTitle icon={<Calendar size={15} color="#64748b" />}>Daten &amp; 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} />
{/* 2. Fristen */}
<Box sx={{ mb: 2.5 }}>
<SectionTitle icon={<Calendar size={14} color="#64748b" />}>Fristen</SectionTitle>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
<DateRow label="Fälligkeit" value={reminder.dueDate} />
{showSecondDate && (
<DateRow label={secondDateLabel!} value={secondDateValue} />
)}
{reminder.snoozedUntil && (
<DateRow label="Schlummern bis" value={reminder.snoozedUntil} />
<DateRow label="Schlummert bis" value={reminder.snoozedUntil} />
)}
</Box>
</Box>
<Divider />
<Divider sx={{ mb: 2.5 }} />
{/* 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. Pre-Market */}
<Box>
<SectionTitle icon={<Eye size={15} color="#64748b" />}>Pre-Market 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">
Pre-Market aktiviert
</Typography>
}
/>
</Tooltip>
</Box>
</Box>
<Divider />
{/* 6. Note */}
<Box>
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 1, fontSize: '0.8125rem' }}>Notiz</Typography>
{/* 3. Notiz */}
<Box sx={{ mb: isActionable ? 0 : 2.5 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 1, fontSize: '0.8125rem' }}>
Notiz
</Typography>
<TextField
multiline
rows={3}
@@ -213,24 +341,24 @@ export function ReminderDetailDrawer() {
/>
</Box>
{/* Actions */}
{(reminder.status === ReminderStatus.ACTIVE || reminder.status === ReminderStatus.SNOOZED) && (
{/* 4. Aktionen */}
{isActionable && (
<>
<Divider />
<Box className="flex flex-col gap-2">
<Typography variant="subtitle2" sx={{ fontWeight: 700, fontSize: '0.8125rem' }}>Aktionen</Typography>
<Box className="flex gap-2">
<Divider sx={{ my: 2.5 }} />
<Box sx={{ mb: 2.5, display: 'flex', flexDirection: 'column', gap: 1.5 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700, fontSize: '0.8125rem' }}>
Aktionen
</Typography>
<Box sx={{ display: 'flex', gap: 1.5 }}>
<Button
size="small"
variant="contained"
color="success"
sx={{ textTransform: 'none', flex: 1 }}
sx={{ textTransform: 'none', flex: 1, fontWeight: 600 }}
onClick={() => complete.mutate({ id: reminder.id, note: noteValue || undefined })}
>
Erledigt
</Button>
<Button
size="small"
variant="outlined"
color="error"
sx={{ textTransform: 'none', flex: 1 }}
@@ -239,7 +367,7 @@ export function ReminderDetailDrawer() {
Verwerfen
</Button>
</Box>
<Box className="flex gap-2 items-center">
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
<TextField
type="date"
size="small"
@@ -248,10 +376,10 @@ export function ReminderDetailDrawer() {
sx={{ flex: 1, '& .MuiInputBase-root': { fontSize: '0.8125rem' } }}
/>
<Button
size="small"
variant="outlined"
size="small"
disabled={!snoozeDate}
sx={{ textTransform: 'none', whiteSpace: 'nowrap' }}
sx={{ textTransform: 'none', whiteSpace: 'nowrap', color: '#64748b', borderColor: '#e2e8f0' }}
onClick={() => snoozeDate && snooze.mutate({ id: reminder.id, until: snoozeDate })}
>
Schlummern bis
@@ -261,18 +389,18 @@ export function ReminderDetailDrawer() {
</>
)}
<Divider />
<Divider sx={{ mb: 2.5 }} />
{/* 7. Activity */}
{/* 5. Verlauf */}
<Box>
<SectionTitle icon={<Activity size={15} color="#64748b" />}>Aktivitätslog</SectionTitle>
<SectionTitle icon={<Activity size={14} color="#64748b" />}>Verlauf</SectionTitle>
<Box>
{[...reminder.activity].reverse().map((entry, i) => (
<ActivityEntry key={i} entry={entry} />
))}
</Box>
</Box>
</>
</Box>
)}
</Box>
</Drawer>