6aa4f96bd2
- 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>
409 lines
16 KiB
TypeScript
409 lines
16 KiB
TypeScript
import { useState } from 'react'
|
|
import {
|
|
Drawer, Box, Typography, IconButton, Chip, Divider,
|
|
TextField, Button, Select, MenuItem, FormControl, InputLabel, Autocomplete,
|
|
} from '@mui/material'
|
|
import { X, Calendar, Activity, FileText, ExternalLink, Eye } from 'lucide-react'
|
|
import { useReminderStore } from '../../stores/reminderStore'
|
|
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, 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()
|
|
const { data: reminder } = useReminder(selectedId ?? '')
|
|
const { data: reminderProperty } = usePropertyById(reminder?.propertyId ?? '')
|
|
const complete = useCompleteReminder()
|
|
const dismiss = useDismissReminder()
|
|
const snooze = useSnoozeReminder()
|
|
|
|
const [noteValue, setNoteValue] = useState('')
|
|
const [snoozeDate, setSnoozeDate] = useState('')
|
|
|
|
function handleClose() {
|
|
setDrawerOpen(false)
|
|
setSelectedId(null)
|
|
}
|
|
|
|
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: 460, display: 'flex', flexDirection: 'column' } } }}
|
|
>
|
|
{/* ── 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>
|
|
</Box>
|
|
|
|
{/* ── Body ── */}
|
|
<Box sx={{ flex: 1, overflowY: 'auto', p: 2.5, display: 'flex', flexDirection: 'column' }}>
|
|
|
|
{/* Create mode */}
|
|
{isCreateMode && <CreateForm onClose={handleClose} />}
|
|
|
|
{/* Detail mode */}
|
|
{reminder && (
|
|
<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}` : ''} · {reminder.tenantName}
|
|
</Typography>
|
|
|
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, flexWrap: 'wrap' }}>
|
|
{reminderProperty?.leaseContractUrl ? (
|
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
|
<FileText size={12} color="#64748b" />
|
|
<Button
|
|
size="small"
|
|
variant="outlined"
|
|
endIcon={<ExternalLink size={10} />}
|
|
href={reminderProperty.leaseContractUrl}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
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 }}>
|
|
<FileText size={12} color="#cbd5e1" />
|
|
<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 sx={{ mb: 2.5 }} />
|
|
|
|
{/* 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="Schlummert bis" value={reminder.snoozedUntil} />
|
|
)}
|
|
</Box>
|
|
</Box>
|
|
|
|
<Divider sx={{ mb: 2.5 }} />
|
|
|
|
{/* 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}
|
|
fullWidth
|
|
size="small"
|
|
value={noteValue || (reminder.note ?? '')}
|
|
onChange={e => setNoteValue(e.target.value)}
|
|
placeholder="Notiz hinzufügen…"
|
|
sx={{ '& .MuiInputBase-root': { fontSize: '0.8125rem' } }}
|
|
/>
|
|
</Box>
|
|
|
|
{/* 4. Aktionen */}
|
|
{isActionable && (
|
|
<>
|
|
<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
|
|
variant="contained"
|
|
color="success"
|
|
sx={{ textTransform: 'none', flex: 1, fontWeight: 600 }}
|
|
onClick={() => complete.mutate({ id: reminder.id, note: noteValue || undefined })}
|
|
>
|
|
Erledigt
|
|
</Button>
|
|
<Button
|
|
variant="outlined"
|
|
color="error"
|
|
sx={{ textTransform: 'none', flex: 1 }}
|
|
onClick={() => dismiss.mutate({ id: reminder.id, note: noteValue || undefined })}
|
|
>
|
|
Verwerfen
|
|
</Button>
|
|
</Box>
|
|
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
|
|
<TextField
|
|
type="date"
|
|
size="small"
|
|
value={snoozeDate}
|
|
onChange={e => setSnoozeDate(e.target.value)}
|
|
sx={{ flex: 1, '& .MuiInputBase-root': { fontSize: '0.8125rem' } }}
|
|
/>
|
|
<Button
|
|
variant="outlined"
|
|
size="small"
|
|
disabled={!snoozeDate}
|
|
sx={{ textTransform: 'none', whiteSpace: 'nowrap', color: '#64748b', borderColor: '#e2e8f0' }}
|
|
onClick={() => snoozeDate && snooze.mutate({ id: reminder.id, until: snoozeDate })}
|
|
>
|
|
Schlummern bis
|
|
</Button>
|
|
</Box>
|
|
</Box>
|
|
</>
|
|
)}
|
|
|
|
<Divider sx={{ mb: 2.5 }} />
|
|
|
|
{/* 5. Verlauf */}
|
|
<Box>
|
|
<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>
|
|
)
|
|
}
|