Files
property-match/src/components/supply/ReminderDetailDrawer.tsx
T
Benjamin SutterandClaude Opus 5 c6f3b2d6e3 feat(agenten): Runde 9 — statisches Sitzungszimmer, kompakte Tabelle, Chats, KI-Hinweis
**Sitzungszimmer statisch.** Die Kamera folgte dem Zeiger um bis zu 7,5
Zentimeter — als Belebung gedacht, als Wackeln wahrgenommen. Entfernt ist nur
der Aufruf der Parallaxe samt der nun unbenutzten Methode; Kameraposition,
Blickziel, Sitzordnung, Bilder und Beschriftungen sind unverändert. Das
Erkennen des Agenten unter dem Zeiger und der Klick auf seinen Platz bleiben —
das ist keine Bewegung.

**«Meine Objekte» ohne Querscrollen.** Bei automatischer Spaltenbreite richtet
sich jede Spalte nach ihrem längsten Inhalt; bei elf Spalten summierte sich das
über die Fensterbreite hinaus. Jetzt feste Anteile, die zusammen 100 % ergeben,
schmaleres Zellenpolster und Wortumbruch statt `nowrap` beim Mieter. Keine
Spalte entfernt, kein Wert gekürzt.

**Chats antworten.** Bisher meldete das Absenden nur, der Chat sei nicht
angebunden. Jetzt beantwortet jeder Agent — Thomas eingeschlossen — drei
Fragetypen aus dem Bearbeitungsverlauf seiner eigenen Seite: Rolle, Anzahl
Vorgänge im Log, Vorgänge im Vormonat. Schlüsselwortlogik statt Sprachmodell,
wie gefordert.

Die Zeitfrage wird vor der Mengenfrage geprüft: «Wie viele Aufträge wurden
letzten Monat bearbeitet?» enthält auch das Wort «Aufträge» und würde sonst als
Frage nach dem Log gelesen. Findet sich im Zeitraum nichts, sagt der Agent das
und nennt zum Vergleich den Gesamtbestand — statt eine plausible Zahl zu
erfinden. Eine erfundene Zahl im Chat ist schlimmer als keine Antwort: sie
sieht aus wie eine Auskunft.

Der Verlauf lebt nur, solange die Seite offen ist. Ein Verlauf, der Tage
überdauert, weckte die Erwartung, der Agent erinnere sich.

**KI-Hinweis überall gleich.** «KI kann Fehler machen, bitte überprüfen Sie
sämtliche Angaben» steht als eine Konstante und eine Komponente in allen sechs
Detailansichten. Bei Livia ersetzt er den Quellenvermerk und den Block
«Empfohlene nächste Handlung»; die Herkunft steht ohnehin oben bei der Quelle.

Geprüft im Browser gegen den Produktionsbau: Szene reagiert nicht mehr auf
Mausbewegung (Pixelvergleich), Tabelle 11 Spalten ohne Querscrollen
(scrollWidth = clientWidth), alle sechs Chats antworten agentenspezifisch mit
Zahlen, die den Mockdaten entsprechen, unbekannte Fragen werden sauber
abgewiesen, Hinweis in allen sechs Detailansichten vorhanden, 0 Laufzeitfehler.
423/423 Tests grün, tsc und eslint sauber.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 20:52:43 +02:00

213 lines
9.5 KiB
TypeScript

import { useState } from 'react'
import {
Drawer, Box, Typography, IconButton, Divider, TextField, Button,
} from '@mui/material'
import { X, Calendar } from 'lucide-react'
import { useReminderStore } from '../../stores/reminderStore'
import { AiDisclaimer } from '../ui'
import {
useReminder, useCompleteReminder, useDismissReminder, useSnoozeReminder,
} from '../../hooks/useReminders'
import { usePropertyById } from '../../hooks/useProperties'
import { ObjectDeepLink } from '../team'
import { ReminderTypeBadge } from './ReminderTypeBadge'
import { ReminderDaysIndicator } from './ReminderDaysIndicator'
import { ReminderStatusBadge } from './ReminderStatusBadge'
import { ReminderCalendarPlanner } from './ReminderCalendarPlanner'
import { ReminderContractPanel } from './ReminderContractPanel'
import { ReminderStatus, ReminderType } from '../../domain/reminder'
import { SectionTitle, DateRow } from './reminderDetailHelpers'
import { DS_BRAND, DS_SLATE, DS_TEXT } from '../../lib/ds'
// ── Constants ────────────────────────────────────────────────────────────────
/**
* Das zweite Datum je Termintyp. Bei den Vertragstypen aus Runde 8 (§4) ist das
* bewusst das Vertragsende — als Einordnung, nicht als Fälligkeit. Die
* Fälligkeit steht darüber und liegt früher; genau das ist der Punkt.
*/
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',
[ReminderType.NOTICE_PERIOD]: 'contractEndDate',
[ReminderType.REAL_OPTION]: 'contractEndDate',
[ReminderType.UNREAL_OPTION]: 'contractEndDate',
[ReminderType.NEGOTIATION_WINDOW]: 'contractEndDate',
[ReminderType.CONTRACT_TALK]: 'contractEndDate',
}
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',
[ReminderType.NOTICE_PERIOD]: 'Vertragsende',
[ReminderType.REAL_OPTION]: 'Vertragsende',
[ReminderType.UNREAL_OPTION]: 'Vertragsende',
[ReminderType.NEGOTIATION_WINDOW]: 'Vertragsende',
[ReminderType.CONTRACT_TALK]: 'Vertragsende',
}
// ── Drawer ───────────────────────────────────────────────────────────────────
/**
* Reminder-Detailansicht (Runde 4, §5.5 und §5.6).
*
* Entfallen sind: Priorität, das «Aktiv»-Etikett, die zusätzlichen Status- und
* Typfarben, der Verlauf am Ende, der Vertrags- und Pre-Market-Hinweis unter
* dem Objekt sowie der Notizbereich. Das Anlegen neuer Reminder ist mit dem
* Knopf «Reminder erstellen» weggefallen — der Drawer zeigt nur noch Bestehendes.
*
* Neu an der Stelle des Notizbereichs: die Terminplanung im verbundenen Kalender.
*/
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 [snoozeDate, setSnoozeDate] = useState('')
function handleClose() {
setDrawerOpen(false)
setSelectedId(null)
}
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 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 }}>
{reminder && (
<>
<Box sx={{ display: 'flex', gap: 1.5, flexWrap: 'wrap', alignItems: 'center' }}>
<ReminderTypeBadge type={reminder.type} />
<ReminderStatusBadge status={reminder.status} />
</Box>
<ReminderDaysIndicator dueDate={reminder.dueDate} />
</>
)}
</Box>
<IconButton size="small" onClick={handleClose} sx={{ flexShrink: 0, color: DS_SLATE[400] }}>
<X size={18} />
</IconButton>
</Box>
</Box>
{/* ── Body ── */}
<Box sx={{ flex: 1, overflowY: 'auto', p: 2.5, display: 'flex', flexDirection: 'column' }}>
{reminder && (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0 }}>
{/* 1. Objekt — Name führt nach «Meine Objekte» */}
<Box sx={{ mb: 2.5 }}>
<ObjectDeepLink
propertyId={reminder.propertyId}
label={reminder.propertyTitle}
variant="body1"
fontWeight={700}
noWrap={false}
/>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 0.25 }}>
{reminder.propertyCity}{reminder.propertyDistrict ? ` · ${reminder.propertyDistrict}` : ''}&nbsp;·&nbsp;{reminder.tenantName}
</Typography>
</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 }} />
{/* 2b. Vertrag — welcher Vertrag, welcher Zeitpunkt, weshalb,
was tun (Runde 8, §4). Erscheint nur bei Vertragsterminen. */}
<ReminderContractPanel reminder={reminder} />
{/* 3. Terminplanung — steht, wo bis Runde 3 der Notizbereich war */}
<Box sx={{ mb: 2.5 }}>
<ReminderCalendarPlanner reminder={reminder} property={reminderProperty} />
</Box>
{/* 4. Aktionen */}
{isActionable && (
<>
<Divider sx={{ mb: 2.5 }} />
<Box sx={{ 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"
sx={{ textTransform: 'none', flex: 1, fontWeight: 600, bgcolor: DS_BRAND.main, '&:hover': { bgcolor: DS_BRAND.hover } }}
onClick={() => complete.mutate({ id: reminder.id })}
>
Erledigt
</Button>
<Button
variant="outlined"
sx={{ textTransform: 'none', flex: 1, borderColor: DS_SLATE[200], color: DS_TEXT.secondary }}
onClick={() => dismiss.mutate({ id: reminder.id })}
>
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: DS_TEXT.secondary, borderColor: DS_SLATE[200] }}
onClick={() => snoozeDate && snooze.mutate({ id: reminder.id, until: snoozeDate })}
>
Schlummern bis
</Button>
</Box>
</Box>
</>
)}
<AiDisclaimer />
</Box>
)}
</Box>
</Drawer>
)
}