**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>
213 lines
9.5 KiB
TypeScript
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}` : ''} · {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>
|
|
)
|
|
}
|