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:
@@ -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 */}
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import { Card, CardContent, Box, Typography, Chip, IconButton, Tooltip, Divider } from '@mui/material'
|
||||
import { Check, Bell, X, MapPin, Calendar } from 'lucide-react'
|
||||
import type { Reminder } from '../../domain/reminder'
|
||||
import { ReminderPriorityBadge } from './ReminderPriorityBadge'
|
||||
import { ReminderTypeBadge } from './ReminderTypeBadge'
|
||||
import { ReminderDaysIndicator } from './ReminderDaysIndicator'
|
||||
import { useCompleteReminder, useDismissReminder, useSnoozeReminder } from '../../hooks/useReminders'
|
||||
import { useReminderStore } from '../../stores/reminderStore'
|
||||
import { ReminderStatus } from '../../domain/reminder'
|
||||
|
||||
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',
|
||||
}
|
||||
|
||||
interface Props {
|
||||
reminder: Reminder
|
||||
}
|
||||
|
||||
export function ReminderCard({ reminder }: Props) {
|
||||
const { setSelectedId, setDrawerOpen } = useReminderStore()
|
||||
const complete = useCompleteReminder()
|
||||
const dismiss = useDismissReminder()
|
||||
const snooze = useSnoozeReminder()
|
||||
|
||||
const isActionable = reminder.status === ReminderStatus.ACTIVE || reminder.status === ReminderStatus.SNOOZED
|
||||
|
||||
return (
|
||||
<Card
|
||||
variant="outlined"
|
||||
sx={{
|
||||
cursor: 'pointer',
|
||||
'&:hover': { boxShadow: 2 },
|
||||
transition: 'box-shadow 0.15s',
|
||||
}}
|
||||
onClick={() => { setSelectedId(reminder.id); setDrawerOpen(true) }}
|
||||
>
|
||||
<CardContent sx={{ p: 2, '&:last-child': { pb: 2 } }}>
|
||||
{/* Top row */}
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', mb: 1 }}>
|
||||
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
|
||||
<ReminderTypeBadge type={reminder.type} />
|
||||
<ReminderPriorityBadge priority={reminder.priority} />
|
||||
</Box>
|
||||
<Chip
|
||||
label={STATUS_LABEL[reminder.status]}
|
||||
color={STATUS_CHIP_COLOR[reminder.status]}
|
||||
size="small"
|
||||
sx={{ height: 20, fontSize: '0.7rem' }}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Property */}
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, mb: 0.25 }}>
|
||||
{reminder.propertyTitle}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mb: 0.5 }}>
|
||||
<MapPin size={12} color="#94a3b8" />
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{reminder.propertyCity}{reminder.propertyDistrict ? ` · ${reminder.propertyDistrict}` : ''}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 1 }}>
|
||||
{reminder.tenantName} · {reminder.areaSqm.toLocaleString('de-CH')} m²
|
||||
</Typography>
|
||||
|
||||
<Divider sx={{ my: 1 }} />
|
||||
|
||||
{/* Due date */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mb: 1 }}>
|
||||
<Calendar size={13} color="#64748b" />
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Fällig: {new Date(reminder.dueDate).toLocaleDateString('de-CH')}
|
||||
</Typography>
|
||||
<Box sx={{ ml: 'auto' }}>
|
||||
<ReminderDaysIndicator dueDate={reminder.dueDate} />
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Actions */}
|
||||
{isActionable && (
|
||||
<Box sx={{ display: 'flex', gap: 0.5 }} onClick={e => e.stopPropagation()}>
|
||||
<Tooltip title="Erledigen">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => complete.mutate({ id: reminder.id })}
|
||||
sx={{ color: '#16a34a', border: '1px solid #dcfce7', borderRadius: 1 }}
|
||||
>
|
||||
<Check size={14} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="7 Tage schlummern">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => snooze.mutate({ id: reminder.id, until: '2026-05-27' })}
|
||||
sx={{ color: '#ca8a04', border: '1px solid #fef9c3', borderRadius: 1 }}
|
||||
>
|
||||
<Bell size={14} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="Verwerfen">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => dismiss.mutate({ id: reminder.id })}
|
||||
sx={{ color: '#dc2626', border: '1px solid #fee2e2', borderRadius: 1 }}
|
||||
>
|
||||
<X size={14} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { Typography } from '@mui/material'
|
||||
|
||||
const MOCK_TODAY = new Date('2026-05-20')
|
||||
|
||||
function getDays(isoDate: string): number {
|
||||
const due = new Date(isoDate)
|
||||
return Math.ceil((due.getTime() - MOCK_TODAY.getTime()) / (1000 * 60 * 60 * 24))
|
||||
}
|
||||
|
||||
function getColor(days: number): string {
|
||||
if (days <= 0) return '#dc2626'
|
||||
if (days <= 14) return '#dc2626'
|
||||
if (days <= 30) return '#ea580c'
|
||||
if (days <= 60) return '#ca8a04'
|
||||
return '#16a34a'
|
||||
}
|
||||
|
||||
interface Props {
|
||||
dueDate: string
|
||||
}
|
||||
|
||||
export function ReminderDaysIndicator({ dueDate }: Props) {
|
||||
const days = getDays(dueDate)
|
||||
const color = getColor(days)
|
||||
|
||||
if (days <= 0) {
|
||||
return (
|
||||
<Typography variant="caption" sx={{ color: '#dc2626', fontWeight: 700, whiteSpace: 'nowrap' }}>
|
||||
Überfällig
|
||||
</Typography>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Typography variant="caption" sx={{ color, fontWeight: 600, whiteSpace: 'nowrap' }}>
|
||||
in {days} {days === 1 ? 'Tag' : 'Tagen'}
|
||||
</Typography>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { Box, Typography, Button } from '@mui/material'
|
||||
import { BellOff } from 'lucide-react'
|
||||
|
||||
interface Props {
|
||||
onReset?: () => void
|
||||
}
|
||||
|
||||
export function ReminderEmptyState({ onReset }: Props) {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
py: 10,
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
width: 64,
|
||||
height: 64,
|
||||
borderRadius: '50%',
|
||||
bgcolor: '#f1f5f9',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<BellOff size={28} color="#94a3b8" />
|
||||
</Box>
|
||||
<Box sx={{ textAlign: 'center' }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 600, color: '#1e293b', mb: 0.5 }}>
|
||||
Keine Reminder
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Keine Reminder entsprechen dem aktuellen Filter.
|
||||
</Typography>
|
||||
</Box>
|
||||
{onReset && (
|
||||
<Button variant="outlined" size="small" onClick={onReset} sx={{ textTransform: 'none' }}>
|
||||
Filter zurücksetzen
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { Box, Typography } from '@mui/material'
|
||||
import { useReminders } from '../../hooks/useReminders'
|
||||
import { useReminderStore } from '../../stores/reminderStore'
|
||||
import { ReminderListRow } from './ReminderListRow'
|
||||
import { ReminderCard } from './ReminderCard'
|
||||
import { ReminderSkeleton } from './ReminderSkeleton'
|
||||
import { ReminderEmptyState } from './ReminderEmptyState'
|
||||
import type { Reminder } from '../../domain/reminder'
|
||||
|
||||
const LIST_HEADER_COLS = '100px 130px 1fr 140px 90px 100px 90px'
|
||||
|
||||
function applyFilters(
|
||||
reminders: Reminder[],
|
||||
filterType: string,
|
||||
filterStatus: string,
|
||||
filterPriority: string,
|
||||
searchQuery: string,
|
||||
): Reminder[] {
|
||||
return reminders.filter(r => {
|
||||
if (filterType !== 'ALL' && r.type !== filterType) return false
|
||||
if (filterStatus !== 'ALL' && r.status !== filterStatus) return false
|
||||
if (filterPriority !== 'ALL' && r.priority !== filterPriority) return false
|
||||
if (searchQuery) {
|
||||
const q = searchQuery.toLowerCase()
|
||||
const haystack = `${r.propertyTitle} ${r.propertyCity} ${r.tenantName}`.toLowerCase()
|
||||
if (!haystack.includes(q)) return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
export function ReminderFeed() {
|
||||
const { data, isLoading } = useReminders()
|
||||
const {
|
||||
filterType, filterStatus, filterPriority, searchQuery, viewMode,
|
||||
setFilterType, setFilterStatus, setFilterPriority, setSearchQuery,
|
||||
} = useReminderStore()
|
||||
|
||||
if (isLoading) return <ReminderSkeleton />
|
||||
|
||||
const reminders = data ?? []
|
||||
const filtered = applyFilters(reminders, filterType, filterStatus, filterPriority, searchQuery)
|
||||
|
||||
function resetFilters() {
|
||||
setFilterType('ALL')
|
||||
setFilterStatus('ALL')
|
||||
setFilterPriority('ALL')
|
||||
setSearchQuery('')
|
||||
}
|
||||
|
||||
if (filtered.length === 0) {
|
||||
return <ReminderEmptyState onReset={resetFilters} />
|
||||
}
|
||||
|
||||
if (viewMode === 'card') {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(300px, 1fr))',
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
{filtered.map(r => (
|
||||
<ReminderCard key={r.id} reminder={r} />
|
||||
))}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ border: '1px solid #e2e8f0', borderRadius: 1, overflow: 'hidden' }}>
|
||||
{/* Table header */}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: LIST_HEADER_COLS,
|
||||
gap: 1,
|
||||
px: 2,
|
||||
py: 1,
|
||||
bgcolor: '#f8fafc',
|
||||
borderBottom: '1px solid #e2e8f0',
|
||||
}}
|
||||
>
|
||||
{['Priorität', 'Typ', 'Objekt / Mieter', 'Fälligkeit', 'Fläche', 'Status', 'Aktionen'].map(h => (
|
||||
<Typography key={h} variant="caption" sx={{ fontWeight: 600, color: '#64748b', fontSize: '0.7rem' }}>
|
||||
{h}
|
||||
</Typography>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
{filtered.map(r => (
|
||||
<ReminderListRow key={r.id} reminder={r} />
|
||||
))}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import { Box, ToggleButtonGroup, ToggleButton, TextField, InputAdornment, Typography } from '@mui/material'
|
||||
import { Search } from 'lucide-react'
|
||||
import { useReminderStore } from '../../stores/reminderStore'
|
||||
import { ReminderType, ReminderStatus, ReminderPriority } from '../../domain/reminder'
|
||||
import type { ReminderType as ReminderTypeType, ReminderStatus as ReminderStatusType, ReminderPriority as ReminderPriorityType } from '../../domain/reminder'
|
||||
|
||||
const TYPE_LABELS: Record<ReminderTypeType, string> = {
|
||||
LEASE_EXPIRY: 'Mietablauf',
|
||||
BREAK_OPTION: 'Break-Option',
|
||||
RENT_REVIEW: 'Mietanpassung',
|
||||
INSPECTION: 'Inspektion',
|
||||
INSURANCE_RENEWAL: 'Versicherung',
|
||||
MAINTENANCE: 'Unterhalt',
|
||||
SCHATTENMARKT_RELEASE: 'Schattenmarkt',
|
||||
CUSTOM: 'Individuell',
|
||||
}
|
||||
|
||||
const STATUS_LABELS: Record<ReminderStatusType, string> = {
|
||||
ACTIVE: 'Aktiv',
|
||||
SNOOZED: 'Schlummern',
|
||||
COMPLETED: 'Erledigt',
|
||||
DISMISSED: 'Verworfen',
|
||||
}
|
||||
|
||||
const PRIORITY_LABELS: Record<ReminderPriorityType, string> = {
|
||||
URGENT: 'Dringend',
|
||||
HIGH: 'Hoch',
|
||||
MEDIUM: 'Mittel',
|
||||
LOW: 'Niedrig',
|
||||
}
|
||||
|
||||
export function ReminderFilterBar() {
|
||||
const {
|
||||
filterType, setFilterType,
|
||||
filterStatus, setFilterStatus,
|
||||
filterPriority, setFilterPriority,
|
||||
searchQuery, setSearchQuery,
|
||||
viewMode, setViewMode,
|
||||
} = useReminderStore()
|
||||
|
||||
return (
|
||||
<Box className="flex flex-col gap-3">
|
||||
<Box className="flex items-center gap-3 flex-wrap">
|
||||
{/* Search */}
|
||||
<TextField
|
||||
size="small"
|
||||
placeholder="Suchen…"
|
||||
value={searchQuery}
|
||||
onChange={e => setSearchQuery(e.target.value)}
|
||||
slotProps={{
|
||||
input: {
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<Search size={16} color="#94a3b8" />
|
||||
</InputAdornment>
|
||||
),
|
||||
},
|
||||
}}
|
||||
sx={{ width: 220, '& .MuiInputBase-root': { fontSize: '0.8125rem' } }}
|
||||
/>
|
||||
|
||||
{/* View mode */}
|
||||
<Box sx={{ ml: 'auto', display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Typography variant="caption" color="text.secondary">Ansicht:</Typography>
|
||||
<ToggleButtonGroup
|
||||
value={viewMode}
|
||||
exclusive
|
||||
onChange={(_, v) => v && setViewMode(v)}
|
||||
size="small"
|
||||
>
|
||||
<ToggleButton value="list" sx={{ textTransform: 'none', fontSize: '0.75rem', px: 1.5, py: 0.5 }}>Liste</ToggleButton>
|
||||
<ToggleButton value="card" sx={{ textTransform: 'none', fontSize: '0.75rem', px: 1.5, py: 0.5 }}>Karten</ToggleButton>
|
||||
</ToggleButtonGroup>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box className="flex flex-wrap gap-3">
|
||||
{/* Type filter */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ flexShrink: 0 }}>Typ:</Typography>
|
||||
<ToggleButtonGroup
|
||||
value={filterType}
|
||||
exclusive
|
||||
onChange={(_, v) => v && setFilterType(v)}
|
||||
size="small"
|
||||
>
|
||||
<ToggleButton value="ALL" sx={{ textTransform: 'none', fontSize: '0.7rem', px: 1, py: 0.375 }}>Alle</ToggleButton>
|
||||
{Object.values(ReminderType).map(t => (
|
||||
<ToggleButton key={t} value={t} sx={{ textTransform: 'none', fontSize: '0.7rem', px: 1, py: 0.375 }}>
|
||||
{TYPE_LABELS[t]}
|
||||
</ToggleButton>
|
||||
))}
|
||||
</ToggleButtonGroup>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box className="flex flex-wrap gap-3">
|
||||
{/* Status filter */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ flexShrink: 0 }}>Status:</Typography>
|
||||
<ToggleButtonGroup
|
||||
value={filterStatus}
|
||||
exclusive
|
||||
onChange={(_, v) => v && setFilterStatus(v)}
|
||||
size="small"
|
||||
>
|
||||
<ToggleButton value="ALL" sx={{ textTransform: 'none', fontSize: '0.7rem', px: 1, py: 0.375 }}>Alle</ToggleButton>
|
||||
{Object.values(ReminderStatus).map(s => (
|
||||
<ToggleButton key={s} value={s} sx={{ textTransform: 'none', fontSize: '0.7rem', px: 1, py: 0.375 }}>
|
||||
{STATUS_LABELS[s]}
|
||||
</ToggleButton>
|
||||
))}
|
||||
</ToggleButtonGroup>
|
||||
</Box>
|
||||
|
||||
{/* Priority filter */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ flexShrink: 0 }}>Priorität:</Typography>
|
||||
<ToggleButtonGroup
|
||||
value={filterPriority}
|
||||
exclusive
|
||||
onChange={(_, v) => v && setFilterPriority(v)}
|
||||
size="small"
|
||||
>
|
||||
<ToggleButton value="ALL" sx={{ textTransform: 'none', fontSize: '0.7rem', px: 1, py: 0.375 }}>Alle</ToggleButton>
|
||||
{Object.values(ReminderPriority).map(p => (
|
||||
<ToggleButton key={p} value={p} sx={{ textTransform: 'none', fontSize: '0.7rem', px: 1, py: 0.375 }}>
|
||||
{PRIORITY_LABELS[p]}
|
||||
</ToggleButton>
|
||||
))}
|
||||
</ToggleButtonGroup>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { Box, Typography, Button } from '@mui/material'
|
||||
import { Plus } from 'lucide-react'
|
||||
import { useReminderStore } from '../../stores/reminderStore'
|
||||
|
||||
export function ReminderHeader() {
|
||||
const { setDrawerOpen, setSelectedId } = useReminderStore()
|
||||
|
||||
function handleCreate() {
|
||||
setSelectedId(null)
|
||||
setDrawerOpen(true)
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
bgcolor: 'white',
|
||||
borderBottom: '1px solid',
|
||||
borderColor: 'divider',
|
||||
px: 3,
|
||||
py: 2,
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
justifyContent: 'space-between',
|
||||
}}
|
||||
>
|
||||
<Box>
|
||||
<Typography variant="h5" sx={{ fontWeight: 700, color: '#1e293b' }}>
|
||||
Reminder Manager
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Fristen, Vertragsereignisse und Aufgaben für Ihr Portfolio
|
||||
</Typography>
|
||||
</Box>
|
||||
<Button
|
||||
variant="contained"
|
||||
size="small"
|
||||
startIcon={<Plus size={16} />}
|
||||
onClick={handleCreate}
|
||||
sx={{ textTransform: 'none', fontWeight: 600 }}
|
||||
>
|
||||
Reminder erstellen
|
||||
</Button>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { Box, Paper, Typography, Skeleton } from '@mui/material'
|
||||
import { AlertTriangle, Calendar, CalendarDays, Eye } from 'lucide-react'
|
||||
import { useReminderInsights } from '../../hooks/useReminders'
|
||||
|
||||
interface KpiItemProps {
|
||||
icon: React.ReactNode
|
||||
label: string
|
||||
value: number | string
|
||||
color: string
|
||||
}
|
||||
|
||||
function KpiItem({ icon, label, value, color }: KpiItemProps) {
|
||||
return (
|
||||
<Paper
|
||||
variant="outlined"
|
||||
sx={{
|
||||
flex: 1,
|
||||
p: 1.5,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1.5,
|
||||
borderColor: '#e2e8f0',
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 1,
|
||||
bgcolor: `${color}18`,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
</Box>
|
||||
<Box sx={{ minWidth: 0 }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 700, color, lineHeight: 1.2, fontSize: '1.25rem' }}>
|
||||
{value}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ whiteSpace: 'nowrap' }}>
|
||||
{label}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Paper>
|
||||
)
|
||||
}
|
||||
|
||||
export function ReminderKpiBar() {
|
||||
const { data, isLoading } = useReminderInsights()
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Box className="flex gap-3">
|
||||
{[1, 2, 3, 4].map(i => (
|
||||
<Skeleton key={i} variant="rounded" height={68} sx={{ flex: 1 }} />
|
||||
))}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
const insights = data ?? { urgentCount: 0, dueThisWeek: 0, dueThisMonth: 0, schattenmarktReadyCount: 0 }
|
||||
|
||||
return (
|
||||
<Box className="flex gap-3">
|
||||
<KpiItem
|
||||
icon={<AlertTriangle size={18} color="#dc2626" />}
|
||||
label="Dringend"
|
||||
value={insights.urgentCount}
|
||||
color="#dc2626"
|
||||
/>
|
||||
<KpiItem
|
||||
icon={<Calendar size={18} color="#ea580c" />}
|
||||
label="Diese Woche"
|
||||
value={insights.dueThisWeek}
|
||||
color="#ea580c"
|
||||
/>
|
||||
<KpiItem
|
||||
icon={<CalendarDays size={18} color="#0369a1" />}
|
||||
label="Dieser Monat"
|
||||
value={insights.dueThisMonth}
|
||||
color="#0369a1"
|
||||
/>
|
||||
<KpiItem
|
||||
icon={<Eye size={18} color="#be185d" />}
|
||||
label="Schattenmarkt-Risiko"
|
||||
value={insights.schattenmarktReadyCount}
|
||||
color="#be185d"
|
||||
/>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import { Box, Typography, Chip, IconButton, Tooltip } from '@mui/material'
|
||||
import { Check, Bell, X } from 'lucide-react'
|
||||
import type { Reminder } from '../../domain/reminder'
|
||||
import { ReminderPriorityBadge } from './ReminderPriorityBadge'
|
||||
import { ReminderTypeBadge } from './ReminderTypeBadge'
|
||||
import { ReminderDaysIndicator } from './ReminderDaysIndicator'
|
||||
import { useCompleteReminder, useDismissReminder, useSnoozeReminder } from '../../hooks/useReminders'
|
||||
import { useReminderStore } from '../../stores/reminderStore'
|
||||
import { ReminderStatus } from '../../domain/reminder'
|
||||
|
||||
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',
|
||||
}
|
||||
|
||||
interface Props {
|
||||
reminder: Reminder
|
||||
}
|
||||
|
||||
export function ReminderListRow({ reminder }: Props) {
|
||||
const { setSelectedId, setDrawerOpen } = useReminderStore()
|
||||
const complete = useCompleteReminder()
|
||||
const dismiss = useDismissReminder()
|
||||
const snooze = useSnoozeReminder()
|
||||
|
||||
const isActionable = reminder.status === ReminderStatus.ACTIVE || reminder.status === ReminderStatus.SNOOZED
|
||||
|
||||
function handleRowClick() {
|
||||
setSelectedId(reminder.id)
|
||||
setDrawerOpen(true)
|
||||
}
|
||||
|
||||
function handleComplete(e: React.MouseEvent) {
|
||||
e.stopPropagation()
|
||||
complete.mutate({ id: reminder.id })
|
||||
}
|
||||
|
||||
function handleDismiss(e: React.MouseEvent) {
|
||||
e.stopPropagation()
|
||||
dismiss.mutate({ id: reminder.id })
|
||||
}
|
||||
|
||||
function handleSnooze(e: React.MouseEvent) {
|
||||
e.stopPropagation()
|
||||
// Snooze 7 days from mock today
|
||||
snooze.mutate({ id: reminder.id, until: '2026-05-27' })
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
onClick={handleRowClick}
|
||||
sx={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '100px 130px 1fr 140px 90px 100px 90px',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
px: 2,
|
||||
py: 1.25,
|
||||
borderBottom: '1px solid #f1f5f9',
|
||||
bgcolor: 'white',
|
||||
cursor: 'pointer',
|
||||
'&:hover': { bgcolor: '#f8fafc' },
|
||||
transition: 'background-color 0.1s',
|
||||
}}
|
||||
>
|
||||
{/* Priority badge */}
|
||||
<Box>
|
||||
<ReminderPriorityBadge priority={reminder.priority} />
|
||||
</Box>
|
||||
|
||||
{/* Type badge */}
|
||||
<Box>
|
||||
<ReminderTypeBadge type={reminder.type} />
|
||||
</Box>
|
||||
|
||||
{/* Property + tenant */}
|
||||
<Box sx={{ minWidth: 0 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{reminder.propertyTitle}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{reminder.propertyCity} · {reminder.tenantName}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Due date */}
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', fontSize: '0.7rem' }}>
|
||||
{new Date(reminder.dueDate).toLocaleDateString('de-CH')}
|
||||
</Typography>
|
||||
<ReminderDaysIndicator dueDate={reminder.dueDate} />
|
||||
</Box>
|
||||
|
||||
{/* Area */}
|
||||
<Typography variant="caption" color="text.secondary" sx={{ whiteSpace: 'nowrap' }}>
|
||||
{reminder.areaSqm.toLocaleString('de-CH')} m²
|
||||
</Typography>
|
||||
|
||||
{/* Status chip */}
|
||||
<Box>
|
||||
<Chip
|
||||
label={STATUS_LABEL[reminder.status]}
|
||||
color={STATUS_CHIP_COLOR[reminder.status]}
|
||||
size="small"
|
||||
sx={{ height: 20, fontSize: '0.7rem' }}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Actions */}
|
||||
<Box sx={{ display: 'flex', gap: 0.25 }} onClick={e => e.stopPropagation()}>
|
||||
{isActionable && (
|
||||
<>
|
||||
<Tooltip title="Erledigen">
|
||||
<IconButton size="small" onClick={handleComplete} sx={{ color: '#16a34a' }}>
|
||||
<Check size={14} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="7 Tage schlummern">
|
||||
<IconButton size="small" onClick={handleSnooze} sx={{ color: '#ca8a04' }}>
|
||||
<Bell size={14} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="Verwerfen">
|
||||
<IconButton size="small" onClick={handleDismiss} sx={{ color: '#dc2626' }}>
|
||||
<X size={14} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Box, Typography } from '@mui/material'
|
||||
import type { ReminderPriority } from '../../domain/reminder'
|
||||
|
||||
const CONFIG: Record<ReminderPriority, { color: string; label: string }> = {
|
||||
URGENT: { color: '#dc2626', label: 'Dringend' },
|
||||
HIGH: { color: '#ea580c', label: 'Hoch' },
|
||||
MEDIUM: { color: '#ca8a04', label: 'Mittel' },
|
||||
LOW: { color: '#64748b', label: 'Niedrig' },
|
||||
}
|
||||
|
||||
interface Props {
|
||||
priority: ReminderPriority
|
||||
}
|
||||
|
||||
export function ReminderPriorityBadge({ priority }: Props) {
|
||||
const { color, label } = CONFIG[priority]
|
||||
return (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
|
||||
<Box sx={{ width: 8, height: 8, borderRadius: '50%', bgcolor: color, flexShrink: 0 }} />
|
||||
<Typography variant="caption" sx={{ color, fontWeight: 600, fontSize: '0.7rem' }}>
|
||||
{label}
|
||||
</Typography>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Box, Skeleton } from '@mui/material'
|
||||
|
||||
export function ReminderSkeleton() {
|
||||
return (
|
||||
<Box className="flex flex-col gap-2">
|
||||
{[1, 2, 3, 4, 5].map((i) => (
|
||||
<Box
|
||||
key={i}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 2,
|
||||
px: 2,
|
||||
py: 1.5,
|
||||
border: '1px solid #e2e8f0',
|
||||
borderRadius: 1,
|
||||
bgcolor: 'white',
|
||||
}}
|
||||
>
|
||||
<Skeleton variant="circular" width={8} height={8} />
|
||||
<Skeleton variant="text" width={80} height={20} />
|
||||
<Skeleton variant="text" width={120} height={20} sx={{ flex: 1 }} />
|
||||
<Skeleton variant="text" width={100} height={20} />
|
||||
<Skeleton variant="text" width={70} height={20} />
|
||||
<Skeleton variant="rounded" width={60} height={22} />
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { Box, Typography } from '@mui/material'
|
||||
import {
|
||||
FileText,
|
||||
ArrowRightLeft,
|
||||
TrendingUp,
|
||||
ClipboardCheck,
|
||||
Shield,
|
||||
Wrench,
|
||||
Eye,
|
||||
Tag,
|
||||
} from 'lucide-react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import type { ReminderType } from '../../domain/reminder'
|
||||
|
||||
const CONFIG: Record<ReminderType, { Icon: LucideIcon; label: string; color: string }> = {
|
||||
LEASE_EXPIRY: { Icon: FileText, label: 'Mietablauf', color: '#1e3a5f' },
|
||||
BREAK_OPTION: { Icon: ArrowRightLeft, label: 'Break-Option', color: '#7c3aed' },
|
||||
RENT_REVIEW: { Icon: TrendingUp, label: 'Mietanpassung', color: '#0369a1' },
|
||||
INSPECTION: { Icon: ClipboardCheck, label: 'Inspektion', color: '#065f46' },
|
||||
INSURANCE_RENEWAL: { Icon: Shield, label: 'Versicherung', color: '#92400e' },
|
||||
MAINTENANCE: { Icon: Wrench, label: 'Unterhalt', color: '#374151' },
|
||||
SCHATTENMARKT_RELEASE:{ Icon: Eye, label: 'Schattenmarkt', color: '#be185d' },
|
||||
CUSTOM: { Icon: Tag, label: 'Individuell', color: '#64748b' },
|
||||
}
|
||||
|
||||
interface Props {
|
||||
type: ReminderType
|
||||
compact?: boolean
|
||||
}
|
||||
|
||||
export function ReminderTypeBadge({ type, compact = false }: Props) {
|
||||
const { Icon, label, color } = CONFIG[type]
|
||||
return (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||
<Icon size={compact ? 12 : 14} color={color} />
|
||||
{!compact && (
|
||||
<Typography variant="caption" sx={{ color, fontWeight: 500, fontSize: '0.75rem', whiteSpace: 'nowrap' }}>
|
||||
{label}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user