feat(agenten): Runde 4 — «Meine Agenten» mit fünf digitalen Mitarbeitenden
Setzt das Umsetzungsbriefing Runde 4 im bestehenden Frontend um. Informationsarchitektur - «Teamübersicht» heisst «Meine Agenten»; ihr bisheriger Inhalt (Kreisgrafik, Auswertung, Verbindungszusammenfassung) ist entfallen. - Personalverwaltung, Bearbeitungsverlauf und Kanäle & Systeme sind jetzt Reiter derselben Seite (?section=), gestaltet wie die Auswahl im Bearbeitungsverlauf. Es wird nur der gewählte Bereich gerendert. - Die alten Unterseitenpfade leiten um, damit Lesezeichen nicht brechen. Agentenbestand - Reto und Lea vollständig entfernt — aus Navigation, Dossiers, Protokoll, Verbindungen, Vorgängen und Porträtbestand. - Retos Aufgaben liegen bei Bruno: Nachbereitung, WhatsApp-Anruf, Protokoll und Kundennotiz, Ablage im CRM. - Livia ist «Exposé Master»: Lageberichte, Inserate, Angebotsbroschüren. - Sidebar führt Ferdi, Bruno, Livia, Nora, Sina mit Porträt und Funktion. Agentenseiten - Ein gemeinsamer AgentWorkspaceHero auf allen fünf Seiten. - Ferdi: Auswertungskarten, Priorität und Typfarben entfallen; Fälligkeit nur bei fünf Tagen oder weniger rot; Objektlinks nach «Meine Objekte»; neu die Terminplanung im verbundenen Kalender mit typgerechtem PDF-Ausschnitt. - Sina: reduzierte, filter- und sortierbare Objektübersicht; Detailansicht direkt editierbar, leere Pflichtfelder rot umrandet. - Nora: Signale ohne Prozentsätze und Konfidenzstufen, nur belegbare Angaben; Mehrfachauswahl leitet Objekte an Livia weiter. - Livia: aktive und archivierte Leads, Arbeitsbereich gleitet an den oberen Rand; dreistufiger Exposé-Prozess Hochladen → Exposé → Export. - Bruno: neue Seite mit Auftragsliste und Vor-/Nachbereitungs-Drawer; Glocke warnt bei Besichtigung unter 24 Stunden ohne Bericht. Datenschicht - Neu: Kalender, Exposé-Leads, Exposé-Entwürfe, Besichtigungsaufträge — je Domain, Provider, Service und Hook. - IAIService um generateExposeText erweitert; der Entwurf nutzt ausschliesslich erfasste Objektdaten und meldet Lücken, statt sie zu füllen. - Alle Objektverweise zeigen auf reale Einträge aus «Meine Objekte»; neue Detailroute /supply/properties/:propertyId. Offen: Chat, Kalender, CRM und DMS sind Frontend-Simulation ohne Anbindung. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
cc5582b7fd
commit
d200d4e930
352 files changed
+8069
-8512
No files matched your search
@@ -1,39 +1,24 @@
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
Drawer, Box, Typography, IconButton, Chip, Divider,
|
||||
TextField, Button, Select, MenuItem, FormControl, InputLabel, Autocomplete,
|
||||
Drawer, Box, Typography, IconButton, Divider, TextField, Button,
|
||||
} from '@mui/material'
|
||||
import { X, Calendar, Activity, FileText, ExternalLink, Eye } from 'lucide-react'
|
||||
import { X, Calendar } from 'lucide-react'
|
||||
import { useReminderStore } from '../../stores/reminderStore'
|
||||
import {
|
||||
useReminder, useCompleteReminder, useDismissReminder,
|
||||
useSnoozeReminder, useCreateReminder,
|
||||
useReminder, useCompleteReminder, useDismissReminder, useSnoozeReminder,
|
||||
} from '../../hooks/useReminders'
|
||||
import { usePropertyById, useProperties } from '../../hooks/useProperties'
|
||||
import { ReminderPriorityBadge } from './ReminderPriorityBadge'
|
||||
import { usePropertyById } from '../../hooks/useProperties'
|
||||
import { ObjectDeepLink } from '../team'
|
||||
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'
|
||||
import { ReminderStatusBadge } from './ReminderStatusBadge'
|
||||
import { ReminderCalendarPlanner } from './ReminderCalendarPlanner'
|
||||
import { ReminderStatus, ReminderType } from '../../domain/reminder'
|
||||
import { SectionTitle, DateRow } from './reminderDetailHelpers'
|
||||
import { DS_BRAND, DS_SLATE, DS_TEXT } from '../../lib/ds'
|
||||
|
||||
// ── 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',
|
||||
@@ -48,139 +33,18 @@ const TYPE_SECOND_LABEL: Partial<Record<ReminderType, string>> = {
|
||||
[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 ───────────────────────────────────────────────────────────────
|
||||
// ── 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 ?? '')
|
||||
@@ -189,7 +53,6 @@ export function ReminderDetailDrawer() {
|
||||
const dismiss = useDismissReminder()
|
||||
const snooze = useSnoozeReminder()
|
||||
|
||||
const [noteValue, setNoteValue] = useState('')
|
||||
const [snoozeDate, setSnoozeDate] = useState('')
|
||||
|
||||
function handleClose() {
|
||||
@@ -197,17 +60,11 @@ export function ReminderDetailDrawer() {
|
||||
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)
|
||||
|
||||
@@ -222,27 +79,17 @@ export function ReminderDetailDrawer() {
|
||||
<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 ? (
|
||||
{reminder && (
|
||||
<>
|
||||
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
<Box sx={{ display: 'flex', gap: 1.5, 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' }}
|
||||
/>
|
||||
<ReminderStatusBadge status={reminder.status} />
|
||||
</Box>
|
||||
<ReminderDaysIndicator dueDate={reminder.dueDate} />
|
||||
</>
|
||||
) : null}
|
||||
)}
|
||||
</Box>
|
||||
<IconButton size="small" onClick={handleClose} sx={{ flexShrink: 0, color: '#94a3b8' }}>
|
||||
<IconButton size="small" onClick={handleClose} sx={{ flexShrink: 0, color: DS_SLATE[400] }}>
|
||||
<X size={18} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
@@ -250,60 +97,20 @@ export function ReminderDetailDrawer() {
|
||||
|
||||
{/* ── 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 */}
|
||||
{/* 1. Objekt — Name führt nach «Meine Objekte» */}
|
||||
<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 }}>
|
||||
<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 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 }} />
|
||||
@@ -324,45 +131,31 @@ export function ReminderDetailDrawer() {
|
||||
|
||||
<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' } }}
|
||||
/>
|
||||
{/* 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={{ my: 2.5 }} />
|
||||
<Box sx={{ mb: 2.5, display: 'flex', flexDirection: 'column', gap: 1.5 }}>
|
||||
<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"
|
||||
color="success"
|
||||
sx={{ textTransform: 'none', flex: 1, fontWeight: 600 }}
|
||||
onClick={() => complete.mutate({ id: reminder.id, note: noteValue || undefined })}
|
||||
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"
|
||||
color="error"
|
||||
sx={{ textTransform: 'none', flex: 1 }}
|
||||
onClick={() => dismiss.mutate({ id: reminder.id, note: noteValue || undefined })}
|
||||
sx={{ textTransform: 'none', flex: 1, borderColor: DS_SLATE[200], color: DS_TEXT.secondary }}
|
||||
onClick={() => dismiss.mutate({ id: reminder.id })}
|
||||
>
|
||||
Verwerfen
|
||||
</Button>
|
||||
@@ -379,7 +172,7 @@ export function ReminderDetailDrawer() {
|
||||
variant="outlined"
|
||||
size="small"
|
||||
disabled={!snoozeDate}
|
||||
sx={{ textTransform: 'none', whiteSpace: 'nowrap', color: '#64748b', borderColor: '#e2e8f0' }}
|
||||
sx={{ textTransform: 'none', whiteSpace: 'nowrap', color: DS_TEXT.secondary, borderColor: DS_SLATE[200] }}
|
||||
onClick={() => snoozeDate && snooze.mutate({ id: reminder.id, until: snoozeDate })}
|
||||
>
|
||||
Schlummern bis
|
||||
@@ -388,18 +181,6 @@ export function ReminderDetailDrawer() {
|
||||
</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>
|
||||
|
||||
Reference in new issue
Block a user