**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>
267 lines
10 KiB
TypeScript
267 lines
10 KiB
TypeScript
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 { AiDisclaimer } from '../ui'
|
|
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>
|
|
|
|
{/*
|
|
Der KI-Hinweis steht innerhalb des scrollenden Bereichs. Ausserhalb
|
|
läge er hinter dem `overflow: hidden` des äusseren Rahmens und wäre
|
|
nie zu sehen — der Aktionsbalken darunter ist bewusst der einzige
|
|
Teil, der fix am unteren Rand klebt.
|
|
*/}
|
|
<AiDisclaimer />
|
|
</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>
|
|
)
|
|
}
|