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
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user