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:
Benjamin SutterandClaude Opus 5 committed 2026-08-05 14:43:16 +02:00
1 parent cc5582b7fd
commit d200d4e930
352 files changed
+8069 -8512

No files matched your search

@@ -0,0 +1,257 @@
import { useMemo, useState } from 'react'
import {
Alert, Box, Button, CircularProgress, IconButton, MenuItem, TextField, Typography,
} from '@mui/material'
import { X } from 'lucide-react'
import type { Property, UpdatePropertyInput } from '../../domain/property'
import { usePropertyById, useUpdateProperty } from '../../hooks/useProperties'
import { PropertyDetailSkeleton } from './PropertyDetailSkeleton'
import { useToastStore } from '../../stores/toastStore'
import { ObjectDeepLink } from '../team'
import { FIT_OUT_OPTIONS } from '../../pages/supply/newListingConstants'
import { DS_BRAND, DS_TEXT } from '../../lib/ds'
/**
* Pflichtfelder für einen brauchbaren Objektdatensatz.
*
* Bewusst nur das, was für Vermarktung und Matching zwingend gebraucht wird —
* eine längere Liste würde jede zweite Zeile rot einrahmen und damit gar nichts
* mehr hervorheben. `label` erscheint im Formular, `isEmpty` entscheidet über
* die rote Umrandung (Runde 4, §6.3).
*/
interface EditableField {
key: string
label: string
required: boolean
type: 'text' | 'number' | 'multiline' | 'select'
options?: { value: string; label: string }[]
get: (p: Property, draft: UpdatePropertyInput) => string
set: (value: string, draft: UpdatePropertyInput, p: Property) => UpdatePropertyInput
}
function hf(p: Property, draft: UpdatePropertyInput) {
return draft.hardFacts ?? p.hardFacts ?? {}
}
const FIELDS: EditableField[] = [
{
key: 'title', label: 'Objektname', required: true, type: 'text',
get: (p, d) => d.title ?? p.title ?? '',
set: (v, d) => ({ ...d, title: v }),
},
{
key: 'areaSqm', label: 'Fläche (m²)', required: true, type: 'number',
get: (p, d) => String(d.areaSqm ?? p.areaSqm ?? ''),
set: (v, d) => ({ ...d, areaSqm: v === '' ? undefined : Number(v) }),
},
{
key: 'rentPricePerSqm', label: 'Mietpreis (CHF/m²/Jahr)', required: true, type: 'number',
get: (p, d) => String(d.rentPricePerSqm ?? p.rentPricePerSqm ?? ''),
set: (v, d) => ({ ...d, rentPricePerSqm: v === '' ? undefined : Number(v) }),
},
{
key: 'ancillaryCosts', label: 'Nebenkosten (CHF/m²)', required: false, type: 'number',
get: (p, d) => String(d.ancillaryCosts ?? p.ancillaryCosts ?? ''),
set: (v, d) => ({ ...d, ancillaryCosts: v === '' ? undefined : Number(v) }),
},
{
key: 'availabilityDate', label: 'Verfügbar ab', required: true, type: 'text',
get: (p, d) => (d.availabilityDate ?? p.availabilityDate ?? '').slice(0, 10),
set: (v, d) => ({ ...d, availabilityDate: v || undefined }),
},
{
key: 'fitOut', label: 'Ausbaustandard', required: true, type: 'select',
options: FIT_OUT_OPTIONS.map(o => ({ value: o.value, label: o.label })),
get: (p, d) => hf(p, d).fitOut ?? '',
set: (v, d, p) => ({
...d,
hardFacts: { ...hf(p, d), fitOut: (v || undefined) as Property['hardFacts'] extends undefined ? never : 'SHELL' | 'BASIC' | 'FULL' | 'PREMIUM' | undefined },
}),
},
{
key: 'parking', label: 'Parkplätze', required: false, type: 'number',
get: (p, d) => String(hf(p, d).parking ?? ''),
set: (v, d, p) => ({ ...d, hardFacts: { ...hf(p, d), parking: v === '' ? undefined : Number(v) } }),
},
{
key: 'ceilingHeightM', label: 'Deckenhöhe (m)', required: false, type: 'number',
get: (p, d) => String(hf(p, d).ceilingHeightM ?? ''),
set: (v, d, p) => ({ ...d, hardFacts: { ...hf(p, d), ceilingHeightM: v === '' ? undefined : Number(v) } }),
},
{
key: 'description', label: 'Beschreibung', required: true, type: 'multiline',
get: (p, d) => d.description ?? p.description ?? '',
set: (v, d) => ({ ...d, description: v }),
},
]
interface Props {
propertyId: string
onClose?: () => void
}
/**
* Sinas Objekt-Detailansicht (Runde 4, §6.3).
*
* Bewusst nicht `PropertyDetailView` mit weiteren Schaltern: dort geht es ums
* Ansehen, hier ums Ausfüllen. Es gibt deshalb keinen Bearbeitungsstift und
* keinen Bearbeitungsmodus — jedes Feld ist immer editierbar. «Speichern» und
* «Abbrechen» erscheinen erst, sobald tatsächlich etwas geändert wurde; ein
* dauerhaft sichtbarer Speichern-Knopf ohne Änderung wäre nur Dekoration.
*
* Leere Pflichtfelder sind rot umrandet — sie sind der Grund, weshalb der
* Datenqualitätsscore nicht steigt. Reine Anzeigeblöcke wie Matchability,
* Marktsignale, Datenqualität und Standort sind entfallen.
*/
export function EditableObjectOverview({ propertyId, onClose }: Props) {
const { data: property, isLoading } = usePropertyById(propertyId)
const updateProperty = useUpdateProperty()
const showToast = useToastStore(s => s.showToast)
const [draft, setDraft] = useState<UpdatePropertyInput>({})
const isDirty = Object.keys(draft).length > 0
const missingRequired = useMemo(() => {
if (!property) return new Set<string>()
return new Set(
FIELDS.filter(f => f.required && f.get(property, draft).trim() === '').map(f => f.key),
)
}, [property, draft])
if (isLoading) return <PropertyDetailSkeleton />
if (!property) {
return (
<Box sx={{ p: 3 }}>
<Alert severity="error">Objekt nicht gefunden.</Alert>
</Box>
)
}
function handleSave() {
if (!property) return
updateProperty.mutate(
{ id: property.id, input: draft },
{
onSuccess: () => {
setDraft({})
showToast('Objekt gespeichert.', 'success')
},
onError: () => showToast('Fehler beim Speichern.', 'error'),
},
)
}
return (
<Box sx={{ height: '100%', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
{/* Kopf — Objektname führt nach «Meine Objekte»; keine Typ- oder
Verfügbarkeitsetiketten und kein «Kritische Felder fehlen»-Hinweis. */}
<Box sx={{ p: 2.5, borderBottom: '1px solid #e2e8f0', flexShrink: 0, display: 'flex', gap: 1 }}>
<Box sx={{ flex: 1, minWidth: 0 }}>
<ObjectDeepLink
propertyId={property.id}
label={property.title}
variant="h6"
fontWeight={600}
noWrap={false}
/>
<Typography variant="caption" color="text.secondary">
{property.address.street} {property.address.houseNumber}, {property.address.postalCode} {property.address.city}
</Typography>
</Box>
{onClose && (
<IconButton size="small" onClick={onClose} sx={{ alignSelf: 'flex-start' }}>
<X size={16} />
</IconButton>
)}
</Box>
{/* Startbild */}
{property.images?.[0] && (
<Box sx={{ flexShrink: 0, height: 160, overflow: 'hidden' }}>
<img
src={property.images[0]}
alt={property.title}
style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
/>
</Box>
)}
{/* Übersicht — direkt editierbar */}
<Box sx={{ flex: 1, overflowY: 'auto', p: 2.5 }}>
<Typography
variant="caption"
sx={{ fontWeight: 700, color: DS_TEXT.primary, textTransform: 'uppercase', letterSpacing: 0.5, display: 'block', mb: 1.5 }}
>
Übersicht
</Typography>
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', sm: '1fr 1fr' }, gap: 1.5 }}>
{FIELDS.map(field => {
const value = field.get(property, draft)
const isMissing = missingRequired.has(field.key)
const fullWidth = field.type === 'multiline' || field.key === 'title'
return (
<Box key={field.key} sx={{ gridColumn: fullWidth ? '1 / -1' : 'auto' }}>
<TextField
select={field.type === 'select'}
multiline={field.type === 'multiline'}
rows={field.type === 'multiline' ? 3 : undefined}
type={field.type === 'number' ? 'number' : 'text'}
label={field.required ? `${field.label} *` : field.label}
size="small"
fullWidth
value={value}
onChange={e => setDraft(field.set(e.target.value, draft, property))}
sx={{
'& .MuiOutlinedInput-notchedOutline': isMissing
? { borderColor: DS_TEXT.error, borderWidth: 2 }
: {},
}}
>
{field.type === 'select' &&
(field.options ?? []).map(o => (
<MenuItem key={o.value} value={o.value}>{o.label}</MenuItem>
))}
</TextField>
</Box>
)
})}
</Box>
</Box>
{/* Speichern erscheint erst bei einer Änderung, «Abbrechen» links davon. */}
{isDirty && (
<Box
sx={{
flexShrink: 0,
px: 2.5,
py: 1.5,
borderTop: '1px solid #e2e8f0',
bgcolor: 'white',
display: 'flex',
justifyContent: 'flex-end',
gap: 1,
}}
>
<Button
size="small"
onClick={() => setDraft({})}
sx={{ textTransform: 'none', color: DS_TEXT.secondary }}
>
Abbrechen
</Button>
<Button
size="small"
variant="contained"
onClick={handleSave}
disabled={updateProperty.isPending}
startIcon={updateProperty.isPending ? <CircularProgress size={12} sx={{ color: 'white' }} /> : undefined}
sx={{ textTransform: 'none', bgcolor: DS_BRAND.main, '&:hover': { bgcolor: DS_BRAND.hover } }}
>
Speichern
</Button>
</Box>
)}
</Box>
)
}