docs(agenten): Fallakte Livia und Agenten-Briefing sichern

`LIVIA_PROFIL_UND_PROMPT.md` hält Livia als Exposé Master fest, wie sie vor
Runde 8 war: Personalblatt, Aufgabenkatalog, Kanäle und Systeme, das
Exposé-Dossier mit elf Bereichen und neun Pflichtfeldern, die
`missingFacts`-Regel der Textmaschine — dazu ein System-Prompt, mit dem sich
diese Fassung ausserhalb des Repositories rekonstruieren lässt.

`archive/livia/` ergänzt die Beschreibung um die Wiederherstellung: die 23
Dateien der Exposé-Funktion als wörtliche Kopie im Original-Pfadlayout sowie
ein Manifest jeder Stelle, an der Livia in geteilten Dateien verdrahtet war.

Beides ist mit Runde 8 nicht überholt, sondern der Referenzstand: die
Exposé-Funktion wurde nicht entfernt, sondern nachgeordnet. Das Manifest
beschreibt allerdings den Rückbau eines vollständigen Entfernens und damit ein
Szenario, das so nicht eingetreten ist.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Benjamin SutterandClaude Opus 5 committed 2026-08-22 00:35:57 +02:00
1 parent ba8dc39483
commit 2037e5a463
33 files changed
+4431

No files matched your search

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

@@ -0,0 +1,138 @@
import { Box, Chip, MenuItem, TextField, Typography } from '@mui/material'
import type { ExposeField, ExposeSection } from '../../lib/exposeFields'
import type { ExposeValues } from '../../domain/expose'
import { DS_SLATE, DS_TEXT } from '../../lib/ds'
interface Props {
section: ExposeSection
values: ExposeValues
missing: Set<string>
onChange: (key: string, value: string) => void
/** Zusatzinhalt am Ende des Abschnitts, z. B. der KI-Entwurfsknopf. */
footer?: React.ReactNode
}
/** Mehrfachauswahl als Wert: kommagetrennt, damit ein flaches Wörterbuch reicht. */
function toggleChip(current: string, option: string): string {
const set = new Set(current.split(',').map(s => s.trim()).filter(Boolean))
if (set.has(option)) set.delete(option)
else set.add(option)
return [...set].join(', ')
}
function isSelected(current: string, option: string): boolean {
return current.split(',').map(s => s.trim()).includes(option)
}
function FieldControl({
field, value, isMissing, onChange,
}: {
field: ExposeField
value: string
isMissing: boolean
onChange: (value: string) => void
}) {
const label = field.suffix ? `${field.label} (${field.suffix})` : field.label
if (field.type === 'chips') {
return (
<Box>
<Typography
variant="caption"
sx={{ display: 'block', mb: 0.75, color: DS_TEXT.secondary, textTransform: 'uppercase', letterSpacing: 0.4, fontSize: '0.65rem' }}
>
{label}
</Typography>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.75 }}>
{(field.options ?? []).map(o => {
const selected = isSelected(value, o)
return (
<Chip
key={o}
label={o}
size="small"
variant={selected ? 'filled' : 'outlined'}
onClick={() => onChange(toggleChip(value, o))}
sx={{
cursor: 'pointer',
bgcolor: selected ? '#152642' : 'transparent',
color: selected ? 'white' : DS_TEXT.secondary,
borderColor: DS_SLATE[200],
}}
/>
)
})}
</Box>
</Box>
)
}
return (
<TextField
select={field.type === 'select'}
multiline={field.type === 'multiline'}
rows={field.type === 'multiline' ? 3 : undefined}
type={field.type === 'number' ? 'number' : 'text'}
label={field.required ? `${label} *` : label}
size="small"
fullWidth
value={value}
onChange={e => onChange(e.target.value)}
sx={{
'& .MuiOutlinedInput-notchedOutline': isMissing
? { borderColor: DS_TEXT.error, borderWidth: 2 }
: {},
}}
>
{field.type === 'select' &&
(field.options ?? []).map(o => <MenuItem key={o} value={o}>{o}</MenuItem>)}
</TextField>
)
}
/**
* Ein fachlicher Bereich des Exposé-Dossiers.
*
* Rendert generisch aus `lib/exposeFields.ts` — die Bereiche sind Daten, keine
* elf handgeschriebenen Formulare. Leere Pflichtfelder sind rot umrandet
* (Runde 4, §8.5.2); sie werden nicht mit plausiblen Werten gefüllt.
*/
export function ExposeFieldGroup({ section, values, missing, onChange, footer }: Props) {
return (
<Box component="section" sx={{ mb: 4 }}>
<Typography sx={{ fontWeight: 700, fontSize: '1rem', color: DS_TEXT.primary }}>
{section.title}
</Typography>
{section.description && (
<Typography variant="caption" sx={{ color: DS_TEXT.muted, display: 'block', mb: 1.5 }}>
{section.description}
</Typography>
)}
<Box
sx={{
display: 'grid',
gridTemplateColumns: { xs: '1fr', md: 'repeat(3, minmax(0, 1fr))' },
gap: 1.5,
mt: section.description ? 0 : 1.5,
}}
>
{section.fields.map(field => (
<Box
key={field.key}
sx={{ gridColumn: field.wide || field.type === 'chips' || field.type === 'multiline' ? '1 / -1' : 'auto' }}
>
<FieldControl
field={field}
value={values[field.key] ?? ''}
isMissing={missing.has(field.key)}
onChange={v => onChange(field.key, v)}
/>
</Box>
))}
</Box>
{footer && <Box sx={{ mt: 1.5 }}>{footer}</Box>}
</Box>
)
}
@@ -0,0 +1,221 @@
import { useCallback, useRef } from 'react'
import {
Box, Button, Checkbox, FormControlLabel, IconButton, MenuItem, TextField, Tooltip, Typography,
} from '@mui/material'
import { ArrowDown, ArrowUp, Star, Upload } from 'lucide-react'
import type { ExposeImage } from '../../domain/expose'
import { ExposeImageCategory } from '../../domain/expose'
import { EXPOSE_IMAGE_CATEGORY_LABELS } from '../../lib/constants'
import { DS_SLATE, DS_TEXT } from '../../lib/ds'
interface Props {
images: ExposeImage[]
onChange: (next: ExposeImage[]) => void
}
/**
* Schritt 1 «Hochladen» und die Medien-Kuration in einem (Runde 4, §8.5.1).
*
* Livia importiert die Objektbilder aus «Meine Objekte» selbst; das Uploadfeld
* ist ausdrücklich nur für Bilder gedacht, die dort noch nicht liegen. Deshalb
* steht die Liste der bereits importierten Bilder gleichberechtigt neben der
* Ablagefläche und nicht darunter versteckt.
*
* Kategorie, Bildunterschrift, Sichtbarkeit, Reihenfolge und Titelbild lassen
* sich je Bild setzen. Genau ein Bild ist Titelbild — das Setzen eines neuen
* nimmt dem alten die Markierung, statt zwei Titelbilder zuzulassen.
*/
export function ExposeMediaManager({ images, onChange }: Props) {
const fileInput = useRef<HTMLInputElement>(null)
const patch = useCallback(
(id: string, changes: Partial<ExposeImage>) => {
onChange(images.map(img => (img.id === id ? { ...img, ...changes } : img)))
},
[images, onChange],
)
const setCover = useCallback(
(id: string) => onChange(images.map(img => ({ ...img, isCover: img.id === id }))),
[images, onChange],
)
const move = useCallback(
(index: number, delta: number) => {
const target = index + delta
if (target < 0 || target >= images.length) return
const next = [...images]
const [moved] = next.splice(index, 1)
next.splice(target, 0, moved)
onChange(next)
},
[images, onChange],
)
/**
* Zusätzliche Bilder. Doppelte werden über den Dateinamen abgewiesen — die
* häufigste Verwechslung ist, ein bereits importiertes Bild nochmals von der
* Festplatte zu wählen.
*/
const handleFiles = useCallback(
(files: FileList | null) => {
if (!files) return
const known = new Set(images.map(i => i.fileName))
const added: ExposeImage[] = []
for (const file of Array.from(files)) {
if (known.has(file.name)) continue
known.add(file.name)
added.push({
id: `img-upload-${file.name}-${added.length}`,
url: URL.createObjectURL(file),
fileName: file.name,
category: ExposeImageCategory.OTHER,
caption: '',
visible: true,
isCover: false,
imported: false,
})
}
if (added.length > 0) onChange([...images, ...added])
},
[images, onChange],
)
const importedCount = images.filter(i => i.imported).length
return (
<Box>
{/* Uploadfläche */}
<Box
onClick={() => fileInput.current?.click()}
onDragOver={e => e.preventDefault()}
onDrop={e => { e.preventDefault(); handleFiles(e.dataTransfer.files) }}
sx={{
border: '1px dashed #cbd5e1',
borderRadius: 2,
py: 5,
px: 3,
textAlign: 'center',
cursor: 'pointer',
bgcolor: DS_SLATE[50],
'&:hover': { bgcolor: DS_SLATE[100] },
}}
>
<Upload size={22} color="#64748b" />
<Typography variant="body2" sx={{ mt: 1, fontWeight: 600, color: DS_TEXT.primary }}>
Zusätzliche Bilder hierher ziehen oder auswählen
</Typography>
<Typography variant="caption" sx={{ color: DS_TEXT.muted }}>
Nur für Bilder, die noch nicht in «Meine Objekte» hinterlegt sind — JPG oder PNG
</Typography>
<input
ref={fileInput}
type="file"
accept="image/*"
multiple
hidden
onChange={e => handleFiles(e.target.files)}
/>
</Box>
<Typography variant="caption" sx={{ display: 'block', mt: 1.5, mb: 1, color: DS_TEXT.secondary }}>
{importedCount} Bild(er) automatisch aus «Meine Objekte» importiert · {images.length} insgesamt
</Typography>
{/* Medien-Kuration */}
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{images.map((img, index) => (
<Box
key={img.id}
sx={{
display: 'flex',
gap: 1.5,
p: 1.25,
border: '1px solid #e2e8f0',
borderRadius: 1.5,
bgcolor: 'white',
}}
>
{/* Reihenfolge */}
<Box sx={{ display: 'flex', flexDirection: 'column', justifyContent: 'center' }}>
<IconButton size="small" onClick={() => move(index, -1)} disabled={index === 0} aria-label="Nach oben">
<ArrowUp size={13} />
</IconButton>
<IconButton size="small" onClick={() => move(index, 1)} disabled={index === images.length - 1} aria-label="Nach unten">
<ArrowDown size={13} />
</IconButton>
</Box>
<Box
component="img"
src={img.url}
alt={img.caption || img.fileName}
sx={{ width: 96, height: 72, objectFit: 'cover', borderRadius: 1, flexShrink: 0 }}
/>
<Box sx={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', gap: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
<Typography variant="caption" sx={{ fontWeight: 600, color: DS_TEXT.primary }} noWrap>
{img.fileName}
</Typography>
<Typography variant="caption" sx={{ color: DS_TEXT.muted }}>
{img.imported ? 'aus «Meine Objekte»' : 'hochgeladen'}
</Typography>
<Tooltip title="Als Titelbild">
<IconButton
size="small"
onClick={() => setCover(img.id)}
aria-label="Als Titelbild"
sx={{ ml: 'auto', color: img.isCover ? '#b8975a' : '#cbd5e1' }}
>
<Star size={14} fill={img.isCover ? '#b8975a' : 'none'} />
</IconButton>
</Tooltip>
</Box>
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap', alignItems: 'center' }}>
<TextField
select size="small" label="Kategorie"
value={img.category}
onChange={e => patch(img.id, { category: e.target.value as ExposeImage['category'] })}
sx={{ width: 170, '& .MuiInputBase-root': { fontSize: '0.8125rem' } }}
>
{Object.values(ExposeImageCategory).map(c => (
<MenuItem key={c} value={c}>{EXPOSE_IMAGE_CATEGORY_LABELS[c]}</MenuItem>
))}
</TextField>
<TextField
size="small" label="Bildunterschrift"
value={img.caption}
onChange={e => patch(img.id, { caption: e.target.value })}
sx={{ flex: 1, minWidth: 180, '& .MuiInputBase-root': { fontSize: '0.8125rem' } }}
/>
<FormControlLabel
control={
<Checkbox
size="small"
checked={img.visible}
onChange={e => patch(img.id, { visible: e.target.checked })}
/>
}
label={<Typography variant="caption">Im Exposé sichtbar</Typography>}
/>
</Box>
</Box>
</Box>
))}
</Box>
{images.length === 0 && (
<Button
variant="outlined"
size="small"
onClick={() => fileInput.current?.click()}
sx={{ mt: 1, textTransform: 'none' }}
>
Bilder auswählen
</Button>
)}
</Box>
)
}
@@ -0,0 +1,316 @@
import { useCallback, useMemo, useState } from 'react'
import {
Alert, Box, Button, Divider, MenuItem, Step, StepButton, Stepper, TextField, Typography,
} from '@mui/material'
import { Download, Eye, FileText, Sparkles } from 'lucide-react'
import type { ExposeDraft, ExposeValues } from '../../domain/expose'
import { ExposeBranding } from '../../domain/expose'
import type { Property } from '../../domain/property'
import { ExposeMediaManager } from './ExposeMediaManager'
import { ExposeFieldGroup } from './ExposeFieldGroup'
import { PanelLoadingState } from '../ui'
import { useExposeDraft, useGenerateExpose, useGenerateExposeText, useSaveExposeDraft } from '../../hooks/useExpose'
import { textFactsFrom } from '../../services/exposeService'
import { downloadExposePdf, downloadExposeWord, openExposePreview } from '../../services/exposeExport'
import { EXPOSE_SECTIONS, missingRequiredExposeKeys } from '../../lib/exposeFields'
import { EXPOSE_STEPS, EXPOSE_TONALITY_LABELS } from '../../lib/constants'
import { ExposeTextSection } from '../../services/ai/IAIService'
import type { ExposeTonality } from '../../services/ai/IAIService'
import { useToastStore } from '../../stores/toastStore'
import { DS_BRAND, DS_NEUTRAL, DS_SLATE, DS_TEXT } from '../../lib/ds'
/** Welcher Textabschnitt gehört zu welchem Feld? Basis für «KI-Entwurf erstellen». */
const TEXT_FIELD_SECTIONS: { key: string; section: ExposeTextSection; label: string }[] = [
{ key: 'exposeTitel', section: ExposeTextSection.TITLE, label: 'Exposé-Titel' },
{ key: 'kurzbeschrieb', section: ExposeTextSection.TEASER, label: 'Kurzbeschrieb' },
{ key: 'objektbeschrieb', section: ExposeTextSection.OBJECT, label: 'Objektbeschrieb' },
{ key: 'lagebeschrieb', section: ExposeTextSection.LOCATION, label: 'Lagebeschrieb' },
{ key: 'gemeindebeschrieb', section: ExposeTextSection.MUNICIPALITY, label: 'Gemeindebeschrieb' },
{ key: 'ausstattungsbeschrieb',section: ExposeTextSection.FEATURES, label: 'Ausstattungsbeschrieb' },
{ key: 'highlights', section: ExposeTextSection.HIGHLIGHTS, label: 'Highlights' },
]
interface Props {
leadId: string
property: Property
}
/**
* Der dreistufige Exposé-Prozess: Hochladen → Exposé → Export (Runde 4, §8.5).
*
* Lädt den Entwurf und übergibt ihn als Startwert an den Arbeitsbereich. Die
* Trennung in Lader und Arbeitsbereich hat einen Grund: der lokale Entwurf soll
* genau einmal aus dem gespeicherten Stand entstehen. Über `key` löst der
* Wechsel auf ein anderes Objekt einen frischen Arbeitsbereich aus, statt einen
* Effekt zu brauchen, der den lokalen Stand nachträglich überschreibt.
*/
export function ExposeWizard({ leadId, property }: Props) {
const { data: stored, isLoading } = useExposeDraft(leadId, property)
if (isLoading || !stored) return <PanelLoadingState />
return <ExposeWizardBody key={stored.id} initialDraft={stored} />
}
/**
* Der Entwurf lebt lokal, solange gearbeitet wird, und geht erst beim
* Speichern in den Service. Das ist Absicht: bei jedem Tastendruck zu
* speichern hiesse, jeden Zwischenstand zum gültigen Stand zu erklären.
*
* «Speichern» und «Erstellen» sind getrennt — Speichern hält den Zwischenstand
* fest, Erstellen prüft die Vollständigkeit und schaltet den Export frei.
*/
function ExposeWizardBody({ initialDraft }: { initialDraft: ExposeDraft }) {
const saveDraft = useSaveExposeDraft()
const generate = useGenerateExpose()
const generateText = useGenerateExposeText()
const showToast = useToastStore(s => s.showToast)
const [step, setStep] = useState(0)
const [draft, setDraft] = useState<ExposeDraft>(initialDraft)
const missing = useMemo(
() => new Set(missingRequiredExposeKeys(draft.values)),
[draft],
)
const setValue = useCallback((key: string, value: string) => {
setDraft(prev => ({ ...prev, values: { ...prev.values, [key]: value } }))
}, [])
const setValues = useCallback((patch: ExposeValues) => {
setDraft(prev => ({ ...prev, values: { ...prev.values, ...patch } }))
}, [])
const runTextDraft = useCallback(
async (target: { key: string; section: ExposeTextSection; label: string }) => {
const result = await generateText.mutateAsync({
section: target.section,
tonality: draft.tonality,
facts: textFactsFrom(draft.values),
})
if (result.missingFacts.length > 0) {
showToast(
`${target.label}: Es fehlen belegte Angaben (${result.missingFacts.join(', ')}) — bitte zuerst erfassen.`,
'warning',
)
return
}
setValues({ [target.key]: result.text })
},
[draft, generateText, setValues, showToast],
)
const runAllTextDrafts = useCallback(async () => {
for (const target of TEXT_FIELD_SECTIONS) {
await runTextDraft(target)
}
}, [runTextDraft])
const isGenerated = Boolean(draft.generatedAt)
return (
<Box sx={{ bgcolor: DS_NEUTRAL.paper, border: '1px solid #e8e7e4', borderRadius: 2 }}>
{/* Schrittleiste */}
<Box sx={{ px: 3, pt: 2.5, pb: 1, borderBottom: '1px solid #e8e7e4' }}>
<Stepper nonLinear activeStep={step}>
{EXPOSE_STEPS.map((label, index) => (
<Step key={label} completed={index < step}>
<StepButton onClick={() => setStep(index)} sx={{ textTransform: 'none' }}>
{label}
</StepButton>
</Step>
))}
</Stepper>
</Box>
<Box sx={{ px: 3, py: 2.5, maxHeight: '65vh', overflowY: 'auto' }}>
{/* ── Schritt 1: Hochladen ── */}
{step === 0 && (
<ExposeMediaManager
images={draft.images}
onChange={images => setDraft({ ...draft, images })}
/>
)}
{/* ── Schritt 2: Exposé ── */}
{step === 1 && (
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, mb: 2.5, flexWrap: 'wrap' }}>
<TextField
select size="small" label="Tonalität für KI-Entwürfe"
value={draft.tonality}
onChange={e => setDraft({ ...draft, tonality: e.target.value as ExposeTonality })}
sx={{ width: 210 }}
>
{Object.entries(EXPOSE_TONALITY_LABELS).map(([value, label]) => (
<MenuItem key={value} value={value}>{label}</MenuItem>
))}
</TextField>
<Button
size="small"
variant="outlined"
startIcon={<Sparkles size={14} />}
onClick={runAllTextDrafts}
disabled={generateText.isPending}
sx={{ textTransform: 'none', borderColor: DS_SLATE[200], color: DS_TEXT.primary }}
>
Alle KI-Entwürfe erstellen
</Button>
{missing.size > 0 && (
<Typography variant="caption" sx={{ color: DS_TEXT.error, fontWeight: 600 }}>
{missing.size} Pflichtangabe(n) fehlen
</Typography>
)}
</Box>
{EXPOSE_SECTIONS.map(section => (
<ExposeFieldGroup
key={section.id}
section={section}
values={draft.values}
missing={missing}
onChange={setValue}
footer={
section.id === 'texte' ? (
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
{TEXT_FIELD_SECTIONS.map(t => (
<Button
key={t.key}
size="small"
startIcon={<Sparkles size={13} />}
onClick={() => runTextDraft(t)}
disabled={generateText.isPending}
sx={{ textTransform: 'none', color: DS_TEXT.secondary, fontSize: '0.75rem' }}
>
{t.label}
</Button>
))}
</Box>
) : undefined
}
/>
))}
{/* Medien-Kuration bleibt aus Schritt 1 erhalten und wird hier nur
verlinkt — dieselbe Liste zweimal zu rendern wäre Duplikat. */}
<Divider sx={{ my: 3 }} />
<Typography sx={{ fontWeight: 700, fontSize: '1rem', color: DS_TEXT.primary, mb: 0.5 }}>
Kontakte & CI
</Typography>
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', md: 'repeat(3, 1fr)' }, gap: 1.5, mb: 2 }}>
<TextField
size="small" label="Ansprechperson fürs Exposé"
value={draft.contactPerson}
onChange={e => setDraft({ ...draft, contactPerson: e.target.value })}
/>
<TextField
select size="small" label="Farben"
value={draft.branding}
onChange={e => setDraft({ ...draft, branding: e.target.value as ExposeBranding })}
>
<MenuItem value={ExposeBranding.FROM_PROFILE}>Aus Maklerprofil übernehmen</MenuItem>
<MenuItem value={ExposeBranding.MANUAL}>Manuell setzen</MenuItem>
</TextField>
{draft.branding === ExposeBranding.MANUAL && (
<TextField
size="small" label="Farbwert (Hex)"
placeholder="#152642"
value={draft.brandColor ?? ''}
onChange={e => setDraft({ ...draft, brandColor: e.target.value })}
/>
)}
</Box>
</Box>
)}
{/* ── Schritt 3: Export ── */}
{step === 2 && (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{!isGenerated && (
<Alert severity="info" sx={{ fontSize: '0.85rem' }}>
Das Exposé ist noch nicht erstellt. Vorschau und Download zeigen den aktuellen Stand.
</Alert>
)}
{missing.size > 0 && (
<Alert severity="warning" sx={{ fontSize: '0.85rem' }}>
Es fehlen noch {missing.size} Pflichtangabe(n) — im Schritt «Exposé» rot umrandet.
</Alert>
)}
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1.5 }}>
<Button
variant="contained"
startIcon={<Eye size={15} />}
onClick={() => {
if (!openExposePreview(draft)) {
showToast('Die Vorschau wurde vom Browser blockiert — bitte Pop-ups erlauben.', 'error')
}
}}
sx={{ textTransform: 'none', bgcolor: DS_BRAND.main, '&:hover': { bgcolor: DS_BRAND.hover } }}
>
Vorschau im neuen Tab
</Button>
<Button
variant="outlined"
startIcon={<FileText size={15} />}
onClick={() => {
if (!downloadExposePdf(draft)) {
showToast('Der PDF-Export wurde vom Browser blockiert — bitte Pop-ups erlauben.', 'error')
}
}}
sx={{ textTransform: 'none', borderColor: DS_SLATE[200], color: DS_TEXT.primary }}
>
Als PDF
</Button>
<Button
variant="outlined"
startIcon={<Download size={15} />}
onClick={() => downloadExposeWord(draft)}
sx={{ textTransform: 'none', borderColor: DS_SLATE[200], color: DS_TEXT.primary }}
>
Als Word
</Button>
</Box>
<Typography variant="caption" sx={{ color: DS_TEXT.muted }}>
Der PDF-Export nutzt den Druckdialog des Browsers («Als PDF speichern»); CI, Bilder,
Texte und Anhänge folgen der Exposé-Konfiguration.
</Typography>
</Box>
)}
</Box>
{/* Speichern und Erstellen sind klar getrennt (§8.5.2). */}
<Box
sx={{
px: 3, py: 1.75,
borderTop: '1px solid #e8e7e4',
display: 'flex', alignItems: 'center', gap: 1,
}}
>
<Typography variant="caption" sx={{ color: DS_TEXT.muted, flex: 1 }}>
{isGenerated
? `Erstellt am ${new Date(draft.generatedAt!).toLocaleString('de-CH')}`
: 'Noch kein Exposé erstellt.'}
</Typography>
<Button
size="small"
onClick={() => saveDraft.mutate(draft)}
disabled={saveDraft.isPending}
sx={{ textTransform: 'none', color: DS_TEXT.secondary }}
>
Speichern
</Button>
<Button
size="small"
variant="contained"
onClick={() => generate.mutate(draft, { onSuccess: () => setStep(2) })}
disabled={generate.isPending}
sx={{ textTransform: 'none', bgcolor: DS_BRAND.main, '&:hover': { bgcolor: DS_BRAND.hover } }}
>
Speichern &amp; erstellen
</Button>
</Box>
</Box>
)
}
@@ -0,0 +1,149 @@
import { memo, useCallback, useRef } from 'react'
import { Box, Typography } from '@mui/material'
import type { ExposeLead } from '../../domain/exposeLead'
import type { Property } from '../../domain/property'
import { ObjectDeepLink } from '../team'
import { LeadWorkspace } from './LeadWorkspace'
import { DS_SLATE, DS_TEXT } from '../../lib/ds'
export const LEAD_COLS = '110px 1.2fr 1.4fr 1fr 1.4fr'
const COLUMN_LABELS = ['Datum', 'Möglicher Interessent', 'Kontaktdaten', 'Raum', 'Objektempfehlung']
interface RowProps {
lead: ExposeLead
properties: Property[]
selected: boolean
onSelect: (id: string, row: HTMLElement | null) => void
}
const LeadRow = memo(function LeadRow({ lead, properties, selected, onSelect }: RowProps) {
const ref = useRef<HTMLDivElement>(null)
const recommended = lead.propertyIds
.map(id => properties.find(p => p.id === id))
.filter((p): p is Property => Boolean(p))
return (
<Box
ref={ref}
onClick={() => onSelect(lead.id, ref.current)}
sx={{
display: 'grid',
gridTemplateColumns: LEAD_COLS,
alignItems: 'flex-start',
gap: 1,
px: 2, py: 1.5,
borderBottom: '1px solid #f1f5f9',
bgcolor: selected ? '#f8fafc' : 'white',
cursor: 'pointer',
'&:hover': { bgcolor: DS_SLATE[50] },
transition: 'background-color 0.1s',
}}
>
<Typography variant="caption" sx={{ color: DS_TEXT.secondary, pt: 0.25 }}>
{new Date(lead.receivedAt).toLocaleDateString('de-CH')}
</Typography>
<Typography variant="body2" sx={{ fontWeight: 600, color: DS_TEXT.primary }}>
{lead.prospect}
</Typography>
<Box sx={{ minWidth: 0 }}>
{lead.contacts.length === 0 ? (
<Typography variant="caption" sx={{ color: DS_TEXT.disabled }}>–</Typography>
) : (
lead.contacts.map(c => (
<Typography
key={c}
variant="caption"
sx={{ display: 'block', color: DS_TEXT.secondary, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}
>
{c}
</Typography>
))
)}
</Box>
<Typography variant="body2" sx={{ color: DS_TEXT.secondary }}>
{lead.locationHint}
</Typography>
{/* Mehrere Objekte je Lead — jedes verlinkt nach «Meine Objekte». */}
<Box sx={{ minWidth: 0 }} onClick={e => e.stopPropagation()}>
{recommended.length === 0 ? (
<Typography variant="caption" sx={{ color: DS_TEXT.disabled }}>–</Typography>
) : (
recommended.map(p => (
<ObjectDeepLink key={p.id} propertyId={p.id} label={p.title} />
))
)}
</Box>
</Box>
)
})
interface Props {
leads: ExposeLead[]
properties: Property[]
selectedId: string | null
onSelect: (id: string, row: HTMLElement | null) => void
/** Arbeitsbereich nur bei aktiven Leads — Archiviertes wird nicht bearbeitet. */
withWorkspace: boolean
emptyText: string
}
/**
* Livias Leadliste im Grunddesign der Ferdi-Liste (Runde 4, §8.3).
*
* Der Arbeitsbereich öffnet sich zwischen der gewählten Zeile und den übrigen
* Leads — nicht in einem Drawer. Das hält den Bezug zum Lead sichtbar, während
* gearbeitet wird.
*/
export function LeadTable({ leads, properties, selectedId, onSelect, withWorkspace, emptyText }: Props) {
const handleSelect = useCallback(
(id: string, row: HTMLElement | null) => onSelect(id, row),
[onSelect],
)
if (leads.length === 0) {
return (
<Box sx={{ py: 6, textAlign: 'center' }}>
<Typography variant="body2" sx={{ color: DS_TEXT.muted }}>{emptyText}</Typography>
</Box>
)
}
return (
<Box sx={{ border: '1px solid #e2e8f0', borderRadius: 1.5, overflow: 'hidden' }}>
<Box
sx={{
display: 'grid',
gridTemplateColumns: LEAD_COLS,
gap: 1,
px: 2, py: 1,
bgcolor: DS_SLATE[50],
borderBottom: '1px solid #e2e8f0',
}}
>
{COLUMN_LABELS.map(h => (
<Typography key={h} variant="caption" sx={{ fontWeight: 600, color: DS_TEXT.secondary, fontSize: '0.7rem' }}>
{h}
</Typography>
))}
</Box>
{leads.map(lead => (
<Box key={lead.id}>
<LeadRow
lead={lead}
properties={properties}
selected={lead.id === selectedId}
onSelect={handleSelect}
/>
{withWorkspace && lead.id === selectedId && <LeadWorkspace lead={lead} />}
</Box>
))}
</Box>
)
}
@@ -0,0 +1,64 @@
import { useState } from 'react'
import { Alert, Box, Tab, Tabs } from '@mui/material'
import type { ExposeLead } from '../../domain/exposeLead'
import { useProperties } from '../../hooks/useProperties'
import { ExposeWizard } from './ExposeWizard'
import { PanelLoadingState } from '../ui'
import { DS_SLATE } from '../../lib/ds'
interface Props {
lead: ExposeLead
}
/**
* Arbeitsbereich zur Exposé-Erstellung, der sich unter dem gewählten Lead öffnet.
*
* Ein Lead kann mehrere Objekte empfehlen; jedes bekommt sein eigenes Exposé.
* Deshalb steht hier eine Objektauswahl darüber und nicht eine gemeinsame
* Broschüre für alles — ein Exposé beschreibt genau ein Objekt.
*/
export function LeadWorkspace({ lead }: Props) {
const { data: properties = [], isLoading } = useProperties()
// Kein Rücksetz-Effekt beim Leadwechsel nötig: die Liste rendert je Lead eine
// eigene Zeile, ein anderer Lead ist also ein neuer Arbeitsbereich.
const [propertyId, setPropertyId] = useState<string>(lead.propertyIds[0] ?? '')
if (isLoading) return <PanelLoadingState />
// Objektempfehlungen sind auf reale Einträge aus «Meine Objekte» beschränkt.
const recommended = lead.propertyIds
.map(id => properties.find(p => p.id === id))
.filter((p): p is NonNullable<typeof p> => Boolean(p))
if (recommended.length === 0) {
return (
<Box sx={{ p: 3 }}>
<Alert severity="warning">
Zu diesem Lead ist kein Objekt aus «Meine Objekte» hinterlegt — ohne Objekt lässt sich kein Exposé erstellen.
</Alert>
</Box>
)
}
const active = recommended.find(p => p.id === propertyId) ?? recommended[0]
return (
<Box sx={{ px: 3, py: 2.5, bgcolor: DS_SLATE[50], borderTop: '1px solid #e8e7e4', borderBottom: '1px solid #e8e7e4' }}>
{recommended.length > 1 && (
<Tabs
value={active.id}
onChange={(_, v: string) => setPropertyId(v)}
variant="scrollable"
scrollButtons="auto"
sx={{ mb: 2, '& .MuiTab-root': { textTransform: 'none', fontSize: '0.8125rem' } }}
>
{recommended.map(p => (
<Tab key={p.id} value={p.id} label={p.title} />
))}
</Tabs>
)}
<ExposeWizard leadId={lead.id} property={active} />
</Box>
)
}
@@ -0,0 +1,7 @@
// Property On — Livia, Exposé Master. Barrel-Export (CLAUDE.md §13).
export { LeadTable, LEAD_COLS } from './LeadTable'
export { LeadWorkspace } from './LeadWorkspace'
export { ExposeWizard } from './ExposeWizard'
export { ExposeMediaManager } from './ExposeMediaManager'
export { ExposeFieldGroup } from './ExposeFieldGroup'
+86
View File
@@ -0,0 +1,86 @@
/**
* Property On — Exposé-Dossier von Livia.
*
* Der Datensatz bildet die fachlichen Bereiche der Referenzvorlage ab
* (Runde 4, §8.5.2). Bewusst als flaches Feld-Wörterbuch statt als tief
* geschachtelte Struktur: die Bereiche sind reine Gliederung des Formulars,
* kein Domänenmodell. Eine Schachtelung würde jedes Feld doppelt benennen —
* einmal im Typ, einmal in der Feldliste — und beide könnten auseinanderlaufen.
*
* Was aus «Meine Objekte» belegbar ist, füllt Livia; alles andere bleibt leer
* und wird als fehlend markiert. Es werden keine plausiblen Werte erfunden.
*/
import type { ExposeTonality } from '../services/ai/IAIService'
/** Kategorien für die Medien-Kuration. */
export const ExposeImageCategory = {
EXTERIOR: 'EXTERIOR',
INTERIOR: 'INTERIOR',
FLOORPLAN: 'FLOORPLAN',
SURROUNDINGS: 'SURROUNDINGS',
OTHER: 'OTHER',
} as const
export type ExposeImageCategory = typeof ExposeImageCategory[keyof typeof ExposeImageCategory]
export interface ExposeImage {
id: string
url: string
fileName: string
category: ExposeImageCategory
caption: string
/** Im Exposé sichtbar. */
visible: boolean
isCover: boolean
/** true = aus «Meine Objekte» importiert, false = zusätzlich hochgeladen. */
imported: boolean
}
export const ExposeDocumentType = {
FLOORPLAN: 'FLOORPLAN',
LEASE_CONTRACT: 'LEASE_CONTRACT',
ENERGY_CERTIFICATE: 'ENERGY_CERTIFICATE',
SITE_PLAN: 'SITE_PLAN',
OTHER: 'OTHER',
} as const
export type ExposeDocumentType = typeof ExposeDocumentType[keyof typeof ExposeDocumentType]
export interface ExposeDocument {
id: string
type: ExposeDocumentType
title: string
fileName: string
/** Als Anhang im Exposé sichtbar. */
visibleAsAttachment: boolean
}
/** Herkunft der Firmenfarben im Exposé. */
export const ExposeBranding = {
FROM_PROFILE: 'FROM_PROFILE',
MANUAL: 'MANUAL',
} as const
export type ExposeBranding = typeof ExposeBranding[keyof typeof ExposeBranding]
/**
* Sämtliche Formularwerte. Schlüssel siehe `lib/exposeFields.ts` — dort stehen
* Beschriftung, Feldtyp, Bereich und Pflichtangabe an genau einer Stelle.
*/
export type ExposeValues = Record<string, string>
export interface ExposeDraft {
id: string
leadId: string
/** Objekt aus «Meine Objekte», aus dem die Angaben stammen. */
propertyId: string
values: ExposeValues
tonality: ExposeTonality
images: ExposeImage[]
documents: ExposeDocument[]
branding: ExposeBranding
brandColor?: string
/** Ansprechperson fürs Exposé — aus dem Maklerprofil oder manuell. */
contactPerson: string
updatedAt: string
/** Zeitpunkt der letzten Erstellung; leer, solange nur gespeichert wurde. */
generatedAt?: string
}
+38
View File
@@ -0,0 +1,38 @@
/**
* Property On — Leads, die bei Livia zur Exposé-Erstellung liegen.
*
* Ein Lead entsteht auf zwei Wegen: Nora erkennt ein Nachfragesignal und ein
* Bewirtschafter leitet es mit den passenden Objekten an Livia weiter, oder er
* erfasst ihn selbst. Der Typ bildet deshalb nur ab, was Livia zum Arbeiten
* braucht — Interessent, Kontaktdaten, Raum und Objektempfehlung.
*/
export const ExposeLeadStatus = {
ACTIVE: 'ACTIVE',
ARCHIVED: 'ARCHIVED',
} as const
export type ExposeLeadStatus = typeof ExposeLeadStatus[keyof typeof ExposeLeadStatus]
export interface ExposeLead {
id: string
/** Eingangsdatum, ISO. */
receivedAt: string
/** Möglicher Interessent — Firma oder Person, wie sie belegt ist. */
prospect: string
/** Belegbare Kontaktangaben. Unsichere Angaben stehen hier bewusst nicht. */
contacts: string[]
/** Gesuchter Raum, z. B. «Zürich, Innenstadt / Paradeplatz». */
locationHint: string
/** Objektempfehlung — ausschliesslich IDs aus «Meine Objekte». */
propertyIds: string[]
status: ExposeLeadStatus
/** Ursprungssignal bei Nora, sofern der Lead von dort kam. */
sourceSignalId?: string
/** Wer den Lead weitergeleitet hat. */
forwardedBy?: string
}
export type CreateExposeLeadInput = Omit<ExposeLead, 'id' | 'receivedAt' | 'status'> & {
receivedAt?: string
status?: ExposeLeadStatus
}
+60
View File
@@ -0,0 +1,60 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { exposeService } from '../services/exposeService'
import { aiService } from '../services/aiService'
import type { ExposeDraft } from '../domain/expose'
import type { Property } from '../domain/property'
import type { ExposeTextInput } from '../services/ai/IAIService'
import { STALE_EXPOSE_LEADS } from '../lib/constants'
import { useToastStore } from '../stores/toastStore'
export function useExposeDraft(leadId: string, property: Property | undefined) {
return useQuery({
queryKey: ['expose-draft', leadId, property?.id],
queryFn: async () => (await exposeService.getOrCreateDraft(leadId, property!)).data,
enabled: !!leadId && !!property,
staleTime: STALE_EXPOSE_LEADS,
})
}
export function useSaveExposeDraft() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (draft: ExposeDraft) => (await exposeService.save(draft)).data,
onSuccess: (draft) => {
queryClient.setQueryData(['expose-draft', draft.leadId, draft.propertyId], draft)
},
onError: () => {
useToastStore.getState().showToast('Exposé konnte nicht gespeichert werden.', 'error')
},
})
}
export function useGenerateExpose() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (draft: ExposeDraft) => (await exposeService.generate(draft)).data,
onSuccess: (draft) => {
queryClient.setQueryData(['expose-draft', draft.leadId, draft.propertyId], draft)
useToastStore.getState().showToast('Exposé erstellt — bereit für den Export.', 'success')
},
onError: (err: Error) => {
useToastStore.getState().showToast(err.message || 'Exposé konnte nicht erstellt werden.', 'error')
},
})
}
/**
* KI-Textentwurf für einen Abschnitt.
*
* Fehlen belegte Angaben, liefert der Dienst keinen Text, sondern die Liste der
* Lücken — die Oberfläche sagt das dann so, statt einen Text zu zeigen, der
* Angaben behauptet, die niemand geprüft hat (§8.5.2, §9.6).
*/
export function useGenerateExposeText() {
return useMutation({
mutationFn: async (input: ExposeTextInput) => (await aiService.generateExposeText(input)).data,
onError: () => {
useToastStore.getState().showToast('Textentwurf konnte nicht erzeugt werden — bitte manuell erfassen.', 'error')
},
})
}
+39
View File
@@ -0,0 +1,39 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { exposeLeadService } from '../services/exposeLeadService'
import type { CreateExposeLeadInput } from '../domain/exposeLead'
import { STALE_EXPOSE_LEADS } from '../lib/constants'
import { useToastStore } from '../stores/toastStore'
export function useExposeLeads() {
return useQuery({
queryKey: ['expose-leads'],
queryFn: async () => (await exposeLeadService.getAll()).data,
staleTime: STALE_EXPOSE_LEADS,
})
}
export function useCreateExposeLead() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (input: CreateExposeLeadInput) => (await exposeLeadService.create(input)).data,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['expose-leads'] })
},
onError: (err: Error) => {
useToastStore.getState().showToast(err.message || 'Lead konnte nicht weitergeleitet werden.', 'error')
},
})
}
export function useArchiveExposeLead() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (id: string) => (await exposeLeadService.archive(id)).data,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['expose-leads'] })
},
onError: () => {
useToastStore.getState().showToast('Lead konnte nicht archiviert werden.', 'error')
},
})
}
+204
View File
@@ -0,0 +1,204 @@
/**
* Property On — die fachlichen Bereiche und Felder des Exposé-Dossiers.
*
* Eine einzige Liste für Formular, Pflichtfeldprüfung, Vorschau und Export.
* Sie ist die Umsetzung der Referenzvorlage aus Runde 4, §8.5.2 — dort stehen
* dieselben Bereiche in derselben Reihenfolge.
*
* `required: true` heisst: ohne diese Angabe ist das Exposé unvollständig und
* das Feld wird rot umrandet. Der Rest ist Kür — eine Broschüre ohne Kubatur
* ist verkaufbar, eine ohne Mietpreis nicht.
*/
export type ExposeFieldType = 'text' | 'number' | 'select' | 'multiline' | 'chips'
export interface ExposeField {
key: string
label: string
type: ExposeFieldType
required?: boolean
options?: string[]
/** Einheit oder Hinweis in der Feldbeschriftung. */
suffix?: string
/** Nimmt die ganze Zeilenbreite ein. */
wide?: boolean
}
export interface ExposeSection {
id: string
title: string
description?: string
fields: ExposeField[]
/** Abschnitt, für den sich ein KI-Textentwurf auslösen lässt. */
aiSection?: string
}
export const EXPOSE_SECTIONS: ExposeSection[] = [
{
id: 'eckdaten',
title: 'Eckdaten & Vermarktung',
description: 'Objektart und Vermarktungsart steuern die Pflichtfelder des Dossiers.',
fields: [
{ key: 'objektart', label: 'Objektart', type: 'select', required: true, options: ['Büro', 'Retail', 'Gastronomie', 'Logistik', 'Gewerbe', 'Produktion', 'Gemischt'] },
{ key: 'subtyp', label: 'Subtyp', type: 'text' },
{ key: 'vermarktungsart', label: 'Vermarktungsart', type: 'select', required: true, options: ['Miete', 'Kauf', 'Untermiete'] },
{ key: 'status', label: 'Status', type: 'select', options: ['Aktiv in Vermarktung', 'In Vorbereitung', 'Reserviert', 'Abgeschlossen'] },
{ key: 'objektreferenz', label: 'Objektreferenz', type: 'text' },
],
},
{
id: 'lage',
title: 'Lage',
description: 'Strukturierte Adresse; Distanzen erscheinen im Exposé und sind portal-tauglich.',
fields: [
{ key: 'strasse', label: 'Strasse', type: 'text', required: true },
{ key: 'hausnummer', label: 'Nr.', type: 'text' },
{ key: 'plz', label: 'PLZ', type: 'text', required: true },
{ key: 'ort', label: 'Ort', type: 'text', required: true },
{ key: 'gemeinde', label: 'Gemeinde', type: 'text' },
{ key: 'kanton', label: 'Kanton', type: 'text' },
{ key: 'land', label: 'Land', type: 'text' },
],
},
{
id: 'koordinaten',
title: 'Koordinaten & Distanzen',
description: 'Quellen: Swisstopo, ÖV-Fahrplan, OpenStreetMap — Werte bleiben überschreibbar.',
fields: [
{ key: 'breitengrad', label: 'Breitengrad', type: 'text' },
{ key: 'laengengrad', label: 'Längengrad', type: 'text' },
{ key: 'oevHaltestelle', label: 'ÖV-Haltestelle', type: 'number', suffix: 'm' },
{ key: 'einkauf', label: 'Einkauf', type: 'number', suffix: 'm' },
{ key: 'schule', label: 'Schule / Kindergarten', type: 'number', suffix: 'm' },
{ key: 'autobahn', label: 'Autobahnanschluss', type: 'number', suffix: 'm' },
],
},
{
id: 'gemeindedaten',
title: 'Gebäude- & Gemeindedaten',
description: 'Quellen: ARE (ÖV-Güteklasse), BFE Sonnendach, BFS, ESTV — überschreibbar.',
fields: [
{ key: 'oevGueteklasse', label: 'ÖV-Güteklasse', type: 'text' },
{ key: 'solareignung', label: 'Solareignung Dach', type: 'text' },
{ key: 'einwohner', label: 'Einwohner', type: 'number' },
{ key: 'bevoelkerungswachstum', label: 'Bevölkerungswachstum', type: 'number', suffix: '%/J' },
{ key: 'steuerbelastung', label: 'Steuerbelastung', type: 'number', suffix: '%' },
{ key: 'leerwohnungsziffer', label: 'Leerwohnungsziffer', type: 'number', suffix: '%' },
],
},
{
id: 'flaechen',
title: 'Flächen & Gebäude',
description: 'Flächen in m², Schweizer Konvention.',
fields: [
{ key: 'zimmer', label: 'Zimmer', type: 'number' },
{ key: 'schlafzimmer', label: 'Schlafzimmer', type: 'number' },
{ key: 'badezimmer', label: 'Badezimmer', type: 'number' },
{ key: 'sepWc', label: 'Sep. WC', type: 'number' },
{ key: 'wohnflaeche', label: 'Wohnfläche', type: 'number', suffix: 'm²' },
{ key: 'flaeche', label: 'Nutzfläche', type: 'number', suffix: 'm²', required: true },
{ key: 'grundstuecksflaeche', label: 'Grundstücksfläche', type: 'number', suffix: 'm²' },
{ key: 'balkon', label: 'Balkon / Terrasse', type: 'number', suffix: 'm²' },
{ key: 'keller', label: 'Keller', type: 'number', suffix: 'm²' },
{ key: 'raumhoehe', label: 'Raumhöhe', type: 'number', suffix: 'm' },
{ key: 'kubatur', label: 'Kubatur SIA 416', type: 'number', suffix: 'm³' },
],
},
{
id: 'verfuegbarkeit',
title: 'Gebäude & Verfügbarkeit',
fields: [
{ key: 'stockwerk', label: 'Stockwerk', type: 'text' },
{ key: 'geschosse', label: 'Geschosse im Gebäude', type: 'number' },
{ key: 'baujahr', label: 'Baujahr', type: 'number' },
{ key: 'letzteRenovation', label: 'Letzte Renovation', type: 'number' },
{ key: 'zustand', label: 'Zustand', type: 'select', options: ['Neuwertig', 'Gut', 'Renovationsbedürftig', 'Rohbau'] },
{ key: 'verfuegbarkeit', label: 'Verfügbarkeit', type: 'text', required: true },
],
},
{
id: 'baurecht',
title: 'Grundstück & Baurecht',
description: 'Quelle: geodienste/Swisstopo — Ziffern aus dem Bau-/Zonenreglement.',
fields: [
{ key: 'nutzungszone', label: 'Nutzungszone', type: 'text' },
{ key: 'ueberbauungsziffer', label: 'Überbauungsziffer', type: 'text' },
{ key: 'ausnuetzungsziffer', label: 'Ausnützungsziffer', type: 'text' },
],
},
{
id: 'preise',
title: 'Preise & Kosten',
fields: [
{ key: 'nettomiete', label: 'Nettomiete', type: 'number', suffix: 'CHF/m²/J', required: true },
{ key: 'nebenkosten', label: 'Nebenkosten', type: 'number', suffix: 'CHF/m²/J' },
{ key: 'nkArt', label: 'NK-Art', type: 'select', options: ['Akonto', 'Pauschal', 'Nach Aufwand'] },
{ key: 'kaution', label: 'Kaution', type: 'number', suffix: 'Monatsmieten' },
{ key: 'befristung', label: 'Befristung', type: 'text' },
{ key: 'parkierung', label: 'Parkierung', type: 'select', options: ['Tiefgarage', 'Aussenparkplatz', 'Besucherparkplätze', 'Keine'] },
{ key: 'parkplaetze', label: 'Anzahl Plätze', type: 'number' },
{ key: 'mietpreisParkplatz', label: 'Mietpreis Parkplatz', type: 'number', suffix: 'CHF' },
],
},
{
id: 'ausstattung',
title: 'Ausstattung & Merkmale',
description: 'Mehrfachauswahl — Merkmale erscheinen als Liste im Exposé.',
fields: [
{ key: 'aussenbereich', label: 'Aussenbereich', type: 'chips', options: ['Terrasse', 'Sitzplatz', 'Garten', 'Balkon'] },
{ key: 'energieSmart', label: 'Energie & Smart', type: 'chips', options: ['Photovoltaik-Anlage', 'Wärmepumpe', 'E-Ladestation', 'Smart Building', 'Glasfaser-Anschluss'] },
{ key: 'gebaeude', label: 'Gebäude', type: 'chips', options: ['Lift', 'Rollstuhlgängig', 'Kellerabteil', 'Waschküche', 'Minergie-Standard', 'Neubau', 'Erstbezug', 'Video-Gegensprechanlage'] },
{ key: 'innenausbau', label: 'Innenausbau', type: 'chips', options: ['Teeküche', 'Klimaanlage', 'Serverraum', 'Bodenheizung', 'Einbauschränke', 'Hohe Räume', 'Gäste-WC'] },
{ key: 'parkierungMerkmale', label: 'Parkierung', type: 'chips', options: ['Tiefgaragenplatz', 'Aussenparkplatz', 'Besucherparkplätze'] },
{ key: 'umgebung', label: 'Umgebung & Sicht', type: 'chips', options: ['Seesicht', 'Bergsicht', 'Unverbaubare Weitsicht', 'Südausrichtung', 'Ruhige Lage', 'Zentrale Lage'] },
{ key: 'merkmale', label: 'Weitere Merkmale', type: 'text', wide: true },
],
},
{
id: 'energie',
title: 'Energie & Technik',
fields: [
{ key: 'heizsystem', label: 'Heizsystem', type: 'select', options: ['Wärmepumpe', 'Fernwärme', 'Gas', 'Öl', 'Pellets', 'Elektro'] },
{ key: 'waermeverteilung', label: 'Wärmeverteilung', type: 'select', options: ['Bodenheizung', 'Radiatoren', 'Deckenstrahlung', 'Luft'] },
{ key: 'geak', label: 'GEAK', type: 'text' },
{ key: 'photovoltaik', label: 'Photovoltaik-Leistung', type: 'number', suffix: 'kWp' },
{ key: 'eLadestationen', label: 'E-Ladestationen', type: 'number' },
],
},
{
id: 'texte',
title: 'Texte & Beschriebe',
description: 'Diese Texte bilden das Herz des Exposés. Der KI-Entwurf nutzt ausschliesslich die erfassten Objektdaten.',
aiSection: 'texte',
fields: [
{ key: 'exposeTitel', label: 'Exposé-Titel', type: 'text', required: true, wide: true },
{ key: 'kurzbeschrieb', label: 'Kurzbeschrieb (Teaser)', type: 'multiline', required: true, wide: true },
{ key: 'objektbeschrieb', label: 'Objektbeschrieb', type: 'multiline', required: true, wide: true },
{ key: 'lagebeschrieb', label: 'Lagebeschrieb', type: 'multiline', wide: true },
{ key: 'gemeindebeschrieb', label: 'Gemeindebeschrieb', type: 'multiline', wide: true },
{ key: 'ausstattungsbeschrieb', label: 'Ausstattungsbeschrieb', type: 'multiline', wide: true },
{ key: 'highlights', label: 'Highlights (eine Zeile pro Punkt)', type: 'multiline', wide: true },
{ key: 'videoUrl', label: 'Video-URL', type: 'text' },
{ key: 'rundgangUrl', label: 'Virtueller Rundgang (URL)', type: 'text' },
],
},
]
/** Alle Feldschlüssel, die für ein vollständiges Exposé gesetzt sein müssen. */
export const EXPOSE_REQUIRED_KEYS: string[] = EXPOSE_SECTIONS
.flatMap(s => s.fields)
.filter(f => f.required)
.map(f => f.key)
export function exposeFieldByKey(key: string): ExposeField | undefined {
for (const section of EXPOSE_SECTIONS) {
const field = section.fields.find(f => f.key === key)
if (field) return field
}
return undefined
}
/** Welche Pflichtfelder sind noch leer? Basis für die roten Umrandungen. */
export function missingRequiredExposeKeys(values: Record<string, string>): string[] {
return EXPOSE_REQUIRED_KEYS.filter(k => !values[k] || values[k].trim() === '')
}
+416
View File
@@ -0,0 +1,416 @@
/**
* Property On — Personalblatt «Livia», Lage-Analystin (Vermarktung).
*
* Fachliche Wahrheit aus dem Agenten-Katalog: Rolle, Personalnummer, E-Mail und
* die elf Katalogaufgaben werden hier unverändert abgebildet.
*/
import type { TeamAgent } from '../../domain/teamAgent'
import {
AgentStatus,
AgentAutonomyLevel,
AgentChannelType,
AgentChannelDirection,
AgentConnectionStatus,
AgentSystemType,
AgentAccessLevel,
AgentSettingKind,
AgentSettingGroup,
AgentAvatarTone,
} from '../../domain/teamAgent'
export const liviaAgent: TeamAgent = {
id: 'livia',
name: 'Livia',
role: 'Exposé Master',
personnelNumber: 'PO-ZD-21',
department: 'Vermarktung',
email: 'livia@property-on.ch',
avatarTone: AgentAvatarTone.WARNING,
status: AgentStatus.ACTIVE,
autonomyLevel: AgentAutonomyLevel.AUTONOMOUS,
shortDescription:
'Erstellt Lageberichte, Inserate sowie Angebots- und Offertenbroschüren aus den Daten und Bildern aus «Meine Objekte».',
autonomyNote:
'Autonom. Livia recherchiert, schreibt und frischt Lageberichte, Inserate und Exposés ohne vorgängige Freigabe auf. Jede Aussage wird mit Quelle und Abrufdatum protokolliert, damit die Vermarktung jeden Satz nachprüfen kann.',
profile: {
purpose:
'Livia macht aus Objektdaten, Lageinformationen und vorhandenen Bildern ein verkaufsfähiges Dokument: Lagebericht, Inserat oder vollständige Angebots- beziehungsweise Offertenbroschüre. Grundlage sind ausschliesslich die Objekte aus «Meine Objekte».',
input: [
'Objektdaten und Objektbilder aus «Meine Objekte»',
'Leads, die Nora erkannt und ein Bewirtschafter zur Exposé-Erstellung weitergeleitet hat',
'Objektadresse aus dem Vermarktungsauftrag',
'Öffentliche Umfeld- und Verkehrsdaten zu Quartier, Anbindung und Nahversorgung',
'Amtliche Publikationen zu Bauprojekten, Zonenänderungen und Umfeldentwicklungen',
'Bestehende Objektunterlagen aus der Dokumentenablage',
],
coreFlow: [
'Adresse aufnehmen und dem richtigen Quartier zuordnen, etwa Zürich Kreis 5 oder Winterthur Neuhegi.',
'Umfeld im festgelegten Radius abgehen: Nachbarschaft, Erdgeschossnutzungen, Erreichbarkeit, Einkaufen und Verpflegung.',
'Gemeinde und Region einordnen: Wirtschaftsstruktur, Steuerlage, Entwicklung der Nachfrage.',
'Amtliche Publikationen und Bauprojekte im Umfeld sichten und bewerten, ob sie für das Objekt relevant sind.',
'Aus den gesammelten Bausteinen einen Kurztext fürs Inserat und einen Langtext fürs Exposé schreiben.',
'Quellen anhängen, Ergebnis je Objekt ablegen und quartalsweise gegen den aktuellen Stand prüfen.',
],
output: [
'Lagebericht mit Mikro-, Makro- und Umfeldteil',
'Kurztext für Inserat und Portalauftritt',
'Angebots- beziehungsweise Offertenbroschüre als PDF oder Word',
'Quellenverzeichnis mit Abrufdatum je Aussage',
'Änderungshinweise, sobald sich das Umfeld spürbar verändert',
],
},
responsibilities: [
'Lageberichte für kommerziell genutzte Immobilien erstellen und nachvollziehbar belegen',
'Inserate aus den Objektdaten in «Meine Objekte» erstellen',
'Angebots- und Offertenbroschüren aus Objektdaten, Lageinformationen und vorhandenen Bildern erstellen',
'Weitergeleitete Leads von Nora zügig zu einem Exposé verarbeiten',
'Erreichbarkeit, Nahversorgung und geplante Umfeldentwicklungen sauber trennen und darstellen',
'Jede Aussage mit einer nachprüfbaren Quelle und einem Abrufdatum hinterlegen',
'Fehlende Pflichtangaben als Lücke ausweisen, statt plausible Werte zu erfinden',
'Lageberichte quartalsweise auffrischen und relevante Abweichungen an die Vermarktung melden',
],
tasks: [
{
id: 'livia-task-01',
title: 'Objektadresse aufnehmen',
description:
'Übernimmt Strasse, Hausnummer, Postleitzahl und Ort aus dem Vermarktungsauftrag, prüft die Schreibweise gegen die amtlichen Adressdaten und ordnet das Objekt einem Quartier zu, etwa Zürich Kreis 5, Winterthur Neuhegi oder Basel Gundeldingen.',
enabled: true,
schedule: 'bei Eingang eines Vermarktungsauftrags',
requiresApproval: false,
dependsOnSystem: AgentSystemType.IMMOTOP2,
},
{
id: 'livia-task-02',
title: 'Mikrolage analysieren',
description:
'Beschreibt das unmittelbare Umfeld im eingestellten Radius: Nachbarschaft, Erdgeschossnutzungen, Lärm- und Immissionslage, Publikumsfrequenz sowie Parkierung. Trennt beobachtbare Fakten klar von Einschätzungen.',
enabled: true,
schedule: 'nach der Adressaufnahme',
requiresApproval: false,
dependsOnSystem: AgentSystemType.PUBLIC_WEB,
},
{
id: 'livia-task-03',
title: 'Makrolage einordnen',
description:
'Ordnet Gemeinde und Wirtschaftsraum ein: Bevölkerungs- und Beschäftigtenentwicklung, Branchenmix, Steuerlage und Bedeutung des Standorts innerhalb der Region, etwa Zug im Wirtschaftsraum Zentralschweiz oder St. Gallen als Zentrum der Ostschweiz.',
enabled: true,
schedule: 'nach der Mikrolage-Analyse',
requiresApproval: false,
dependsOnSystem: AgentSystemType.PUBLIC_WEB,
},
{
id: 'livia-task-04',
title: 'Erreichbarkeit und Nahversorgung darstellen',
description:
'Stellt Anbindung an öffentlichen Verkehr, Autobahnanschluss, Velo- und Fusswege sowie Einkaufen, Verpflegung, Kinderbetreuung und Gesundheitsversorgung dar — jeweils mit Distanz oder Wegzeit statt mit unbelegten Werbeaussagen.',
enabled: true,
schedule: 'nach der Makrolage-Einordnung',
requiresApproval: false,
dependsOnSystem: AgentSystemType.PUBLIC_WEB,
},
{
id: 'livia-task-05',
title: 'Geplante Projekte und Umfeldentwicklungen aufnehmen',
description:
'Sichtet amtliche Publikationen und Bauprojekte im Umfeld — Arealentwicklungen, Zonenänderungen, Verkehrsvorhaben, neue Haltestellen — und hält fest, ab wann sie für das Objekt spürbar werden, etwa das Entwicklungsgebiet Bern Wankdorf.',
enabled: true,
schedule: 'montags 07:00',
requiresApproval: false,
dependsOnSystem: AgentSystemType.SHAB,
},
{
id: 'livia-task-06',
title: 'Kurzen Inserattext erstellen',
description:
'Verdichtet die Lagebausteine auf einen Kurztext für Inserat und Portalauftritt, innerhalb des eingestellten Zeichenlimits und in der gewählten Firmen-Tonalität.',
enabled: true,
schedule: 'sobald alle Lagebausteine vorliegen',
requiresApproval: false,
},
{
id: 'livia-task-07',
title: 'Ausführlichen Exposé-Text erstellen',
description:
'Schreibt den Langtext für das Exposé mit Mikrolage, Makrolage, Erreichbarkeit, Nahversorgung und Umfeldentwicklung in getrennten Abschnitten, damit sich Interessenten gezielt einlesen können.',
enabled: true,
schedule: 'direkt im Anschluss an den Kurztext',
requiresApproval: false,
},
{
id: 'livia-task-08',
title: 'Quellen hinterlegen',
description:
'Hängt jeder Aussage die verwendete Quelle mit Bezeichnung und Abrufdatum an. Lässt sich eine Aussage nicht belegen, wird sie gestrichen statt abgeschwächt.',
enabled: true,
schedule: 'bei jeder Texterstellung',
requiresApproval: false,
},
{
id: 'livia-task-09',
title: 'Ergebnisse je Objekt lokal zwischenspeichern',
description:
'Legt Kurztext, Langtext und Quellenverzeichnis je Objekt in der Dokumentenablage ab, sodass der nächste Arbeitsauftrag auf dem bestehenden Stand aufsetzt und nichts doppelt recherchiert wird.',
enabled: true,
schedule: 'nach jedem abgeschlossenen Arbeitsauftrag',
requiresApproval: false,
dependsOnSystem: AgentSystemType.DMS,
},
{
id: 'livia-task-10',
title: 'Quartalsweisen Aktualisierungslauf simulieren',
description:
'Führt den «Quartals-Refresh» durch: prüft alle abgelegten Lagebeschriebe gegen den aktuellen Stand der Quellen, markiert veraltete Abschnitte und erneuert sie. Der Lauf wird protokolliert und ist je Objekt nachvollziehbar.',
enabled: true,
schedule: 'quartalsweise am ersten Werktag 05:30',
requiresApproval: false,
dependsOnSystem: AgentSystemType.DMS,
},
{
id: 'livia-task-11',
title: 'Relevante Änderungen melden',
description:
'Meldet der Vermarktung nur, was den Lagebeschrieb wirklich verändert — neue Haltestelle, bewilligte Arealentwicklung, wegfallende Nahversorgung — mit Hinweis auf den betroffenen Textabschnitt und die Quelle.',
enabled: true,
schedule: 'bei erkannter Abweichung im Aktualisierungslauf',
requiresApproval: false,
dependsOnChannel: AgentChannelType.WORKSPACE_CHAT,
},
{
id: 'livia-task-12',
title: 'Weitergeleiteten Lead aufnehmen',
description:
'Nimmt Leads entgegen, die Nora erkannt und ein Bewirtschafter zur Exposé-Erstellung weitergeleitet hat, samt der mitgegebenen Objektempfehlung. Die Objekte stammen ausschliesslich aus «Meine Objekte».',
enabled: true,
schedule: 'bei Weiterleitung',
requiresApproval: false,
dependsOnChannel: AgentChannelType.WORKSPACE_CHAT,
},
{
id: 'livia-task-13',
title: 'Objektbilder aus «Meine Objekte» importieren',
description:
'Importiert alle im Objektbestand hinterlegten Bilder automatisch in das Exposé, vermeidet Doppelimporte und übernimmt Kategorie, Reihenfolge und Titelbild-Markierung, soweit vorhanden.',
enabled: true,
schedule: 'beim Start eines Exposé-Auftrags',
requiresApproval: false,
dependsOnSystem: AgentSystemType.IMMOTOP2,
},
{
id: 'livia-task-14',
title: 'Exposé-Felder aus den Objektdaten befüllen',
description:
'Befüllt Eckdaten, Lage, Flächen, Preise, Ausstattung sowie Energie und Technik aus den in «Meine Objekte» verfügbaren Angaben. Nicht verfügbare Pflichtfelder bleiben leer und werden als fehlend markiert — es werden keine plausiblen Werte erfunden.',
enabled: true,
schedule: 'nach dem Bildimport',
requiresApproval: false,
dependsOnSystem: AgentSystemType.IMMOTOP2,
},
{
id: 'livia-task-15',
title: 'Angebots- beziehungsweise Offertenbroschüre erstellen',
description:
'Setzt aus den erfassten Objektdaten, Texten, Bildern und Anhängen die Broschüre in der Firmen-CI zusammen und stellt sie als PDF und Word zum Export bereit. Speichern und Erstellen bleiben getrennte Schritte.',
enabled: true,
schedule: 'auf Auslösung im Exposé-Arbeitsbereich',
requiresApproval: false,
dependsOnSystem: AgentSystemType.DMS,
},
],
channels: [
{
id: 'livia-ch-workspace-chat',
type: AgentChannelType.WORKSPACE_CHAT,
direction: AgentChannelDirection.BOTH,
description:
'Livia nimmt Objektadressen und Rückfragen im Arbeitsbereich entgegen und liefert Kurztext, Langtext und Änderungshinweise direkt im Kanal der Vermarktung.',
status: AgentConnectionStatus.CONNECTED,
enabled: true,
config: {
displayName: 'Livia — Lage & Umfeld',
inboxAddress: 'arbeitsbereich/vermarktung/lage',
defaultRecipients: ['vermarktung@property-on.ch'],
autoReplyEnabled: true,
},
},
{
id: 'livia-ch-email',
type: AgentChannelType.EMAIL,
direction: AgentChannelDirection.OUTBOUND,
description:
'Versand der fertigen Lagebeschriebe und der Quartalsmeldung an Vermarktung und Bewirtschaftung. Eingehende Mails werden nicht bearbeitet.',
status: AgentConnectionStatus.CONNECTED,
enabled: true,
config: {
displayName: 'Livia (Property On)',
senderAddress: 'livia@property-on.ch',
defaultRecipients: ['vermarktung@property-on.ch', 'bewirtschaftung@property-on.ch'],
autoReplyEnabled: false,
},
},
],
systems: [
{
id: 'livia-sys-public-web',
type: AgentSystemType.PUBLIC_WEB,
access: AgentAccessLevel.READ,
status: AgentConnectionStatus.CONNECTED,
usage:
'Öffentliche Umfeld- und Verkehrsdaten: Haltestellen und Fahrpläne, Wegzeiten, Einkaufen und Verpflegung, Gemeindeangaben zu Bevölkerung und Beschäftigung.',
permissionNote: 'Nur lesend. Livia ruft ausschliesslich frei zugängliche Seiten ab.',
},
{
id: 'livia-sys-shab',
type: AgentSystemType.SHAB,
access: AgentAccessLevel.READ,
status: AgentConnectionStatus.CONNECTED,
usage:
'Amtliche Publikationen zu Baugesuchen, Zonenänderungen und Arealentwicklungen im Umfeld des Objekts.',
permissionNote: 'Nur lesend. Meldungen werden als Quelle mit Publikationsdatum übernommen.',
},
{
id: 'livia-sys-immotop2',
type: AgentSystemType.IMMOTOP2,
access: AgentAccessLevel.READ,
status: AgentConnectionStatus.CONNECTED,
usage:
'Objektstammdaten und Adressen aus der Bewirtschaftung als Ausgangspunkt jedes Lagebeschriebs.',
permissionNote: 'Nur lesend. Livia verändert keine Stammdaten in der Bewirtschaftungslösung.',
},
{
id: 'livia-sys-dms',
type: AgentSystemType.DMS,
access: AgentAccessLevel.READ_WRITE,
status: AgentConnectionStatus.CONNECTED,
usage:
'Ablage von Kurztext, Langtext und Quellenverzeichnis je Objekt sowie Zwischenstand für den Quartalslauf.',
permissionNote:
'Schreibt ausschliesslich in den Ordner «Lagebeschriebe» des jeweiligen Objekts; bestehende Fassungen bleiben als Version erhalten.',
},
{
id: 'livia-sys-workspace',
type: AgentSystemType.WORKSPACE,
access: AgentAccessLevel.READ_WRITE,
status: AgentConnectionStatus.CONNECTED,
usage:
'Aufträge, Rückfragen und Änderungshinweise im Arbeitsbereich der Vermarktung, inklusive Protokoll der Aktualisierungsläufe.',
permissionNote:
'Schreibt nur in den eigenen Bereich «Lage & Umfeld»; Objektdossiers anderer Teams bleiben unberührt.',
},
],
settings: [
{
id: 'livia-set-short-limit',
label: 'Kurztext-Zeichenlimit',
description: 'Obergrenze für den Lagetext im Inserat und auf den Portalen.',
kind: AgentSettingKind.NUMBER,
group: AgentSettingGroup.SPECIFIC,
value: 600,
unit: 'Zeichen',
min: 200,
max: 1200,
},
{
id: 'livia-set-long-limit',
label: 'Langtext-Zeichenlimit',
description: 'Obergrenze für den ausführlichen Lageteil im Exposé.',
kind: AgentSettingKind.NUMBER,
group: AgentSettingGroup.SPECIFIC,
value: 2400,
unit: 'Zeichen',
min: 800,
max: 5000,
},
{
id: 'livia-set-tonality',
label: 'Firmen-Tonalität',
description: 'Sprachstil, in dem Kurz- und Langtext verfasst werden.',
kind: AgentSettingKind.SELECT,
group: AgentSettingGroup.GENERAL,
value: 'sachlich',
options: [
{ value: 'sachlich', label: 'sachlich' },
{ value: 'hochwertig', label: 'hochwertig' },
{ value: 'zurueckhaltend', label: 'zurückhaltend' },
],
},
{
id: 'livia-set-refresh-interval',
label: 'Aktualisierungsintervall',
description: 'Rhythmus, in dem bestehende Lagebeschriebe gegen die Quellen geprüft werden.',
kind: AgentSettingKind.SELECT,
group: AgentSettingGroup.SPECIFIC,
value: 'quartalsweise',
options: [
{ value: 'monatlich', label: 'monatlich' },
{ value: 'quartalsweise', label: 'quartalsweise' },
{ value: 'halbjaehrlich', label: 'halbjährlich' },
{ value: 'jaehrlich', label: 'jährlich' },
],
},
{
id: 'livia-set-analysis-radius',
label: 'Radius der Umfeldanalyse',
description: 'Umkreis um die Objektadresse, der für die Mikrolage ausgewertet wird.',
kind: AgentSettingKind.NUMBER,
group: AgentSettingGroup.SPECIFIC,
value: 800,
unit: 'Meter',
min: 200,
max: 3000,
},
{
id: 'livia-set-source-types',
label: 'Relevante Quellentypen',
description: 'Welche Quellen Livia für Lagebeschriebe und Aktualisierungsläufe heranzieht.',
kind: AgentSettingKind.MULTI_SELECT,
group: AgentSettingGroup.SPECIFIC,
value: ['amtliche_publikationen', 'verkehrsdaten', 'bauprojekte', 'nahversorgung'],
options: [
{ value: 'amtliche_publikationen', label: 'amtliche Publikationen' },
{ value: 'verkehrsdaten', label: 'Verkehrsdaten' },
{ value: 'bauprojekte', label: 'Bauprojekte' },
{ value: 'nahversorgung', label: 'Nahversorgung' },
{ value: 'medienberichte', label: 'Medienberichte' },
],
},
],
metrics: [
{
id: 'livia-metric-objects',
label: 'Analysierte Objekte',
value: '184',
hint: 'Objekte mit vollständigem Lagebeschrieb, unter anderem in Zürich, Winterthur, Zug und St. Gallen.',
},
{
id: 'livia-metric-current-blocks',
label: 'Aktuelle Lagebausteine',
value: '162',
hint: 'Bausteine, die im letzten Aktualisierungslauf gegen die Quellen bestätigt wurden.',
},
{
id: 'livia-metric-due-refresh',
label: 'Fällige Aktualisierungen',
value: '9',
hint: 'Objekte, deren Lagebeschrieb im nächsten Quartals-Refresh erneuert wird.',
},
{
id: 'livia-metric-detected-changes',
label: 'Erkannte Umfeldänderungen',
value: '14',
hint: 'Seit dem letzten Lauf gemeldete Änderungen, etwa die Arealentwicklung Winterthur Neuhegi.',
},
],
headlineMetricId: 'livia-metric-objects',
lastRun: '2026-05-20T06:00:00.000Z',
}
@@ -0,0 +1,68 @@
/**
* Property On — Leads im Backlog von Livia.
*
* Herkunft: Nachfragesignale, die Nora erkannt hat, plus zwei bereits
* abgeschlossene Vorgänge im Archiv. Sämtliche Objekt-IDs stammen aus
* «Meine Objekte» (`mock-data/properties.ts`) — es werden hier keine Objekte
* erfunden, sonst führte die Objektempfehlung ins Leere.
*
* Zeitanker: 20.05.2026, wie alle Property-On-Mockdaten.
*/
import type { ExposeLead } from '../domain/exposeLead'
import { ExposeLeadStatus } from '../domain/exposeLead'
export const mockExposeLeads: ExposeLead[] = [
{
id: 'lead-001',
receivedAt: '2026-05-19T14:20:00.000Z',
prospect: 'Alpbach Advisors AG',
contacts: ['zuerich@alpbach-advisors.ch', 'https://alpbach-advisors.ch', 'Dr. Markus Suter (Managing Partner)'],
locationHint: 'Zürich, Innenstadt / Paradeplatz',
propertyIds: ['prop-043', 'prop-044'],
status: ExposeLeadStatus.ACTIVE,
sourceSignalId: 'signal-001',
forwardedBy: 'Nadine Brunner',
},
{
id: 'lead-002',
receivedAt: '2026-05-18T09:05:00.000Z',
prospect: 'Novabio Pharma AG',
contacts: ['https://novabio-pharma.ch'],
locationHint: 'Basel, Allschwil',
propertyIds: ['prop-008'],
status: ExposeLeadStatus.ACTIVE,
sourceSignalId: 'signal-004',
forwardedBy: 'Nadine Brunner',
},
{
id: 'lead-003',
receivedAt: '2026-05-15T11:40:00.000Z',
prospect: 'Textilhaus Zürich AG',
contacts: ['kontakt@textilhaus-zuerich.ch'],
locationHint: 'Zürich, Kreis 4 / Langstrasse',
propertyIds: ['prop-040', 'prop-041', 'prop-046'],
status: ExposeLeadStatus.ACTIVE,
forwardedBy: 'Marc Wyss',
},
{
id: 'lead-004',
receivedAt: '2026-04-28T08:15:00.000Z',
prospect: 'Helvetia Produktion GmbH',
contacts: ['info@helvetia-produktion.ch'],
locationHint: 'Pratteln / Basel-Land',
propertyIds: ['prop-014'],
status: ExposeLeadStatus.ARCHIVED,
forwardedBy: 'Marc Wyss',
},
{
id: 'lead-005',
receivedAt: '2026-04-12T16:00:00.000Z',
prospect: 'Schweizer Grosshandel AG',
contacts: ['beschaffung@schweizer-grosshandel.ch'],
locationHint: 'Winterthur',
propertyIds: ['prop-009'],
status: ExposeLeadStatus.ARCHIVED,
forwardedBy: 'Nadine Brunner',
},
]
@@ -0,0 +1,113 @@
import { useCallback, useMemo, useRef, useState } from 'react'
import { useSearchParams } from 'react-router'
import { Box, Tab, Tabs } from '@mui/material'
import { AgentWorkspaceHero } from '../../components/team'
import { LeadTable } from '../../components/expose'
import { LoadingPage, ErrorState } from '../../components/ui'
import { useExposeLeads } from '../../hooks/useExposeLeads'
import { useProperties } from '../../hooks/useProperties'
import { ExposeLeadStatus } from '../../domain/exposeLead'
import { EXPOSE_LEAD_TABS, EXPOSE_LEAD_TAB_LABELS } from '../../lib/constants'
import type { ExposeLeadTab } from '../../lib/constants'
import { agentWorkspaceById } from '../../lib/agentWorkspaces'
const LIVIA = agentWorkspaceById('livia')!
const PARAM_TAB = 'tab'
function resolveTab(raw: string | null): ExposeLeadTab {
return raw === EXPOSE_LEAD_TABS.ARCHIVED ? EXPOSE_LEAD_TABS.ARCHIVED : EXPOSE_LEAD_TABS.ACTIVE
}
/**
* Livia — Exposé Master (Runde 4, §8).
*
* Die frühere Inserate-Seite ist vollständig ersetzt: Titel, Beschreibung, der
* Knopf «Neues Inserat» und die Inserate-Tabelle sind entfallen. Ziel der Seite
* ist die schnelle Verarbeitung von Leads, die Nora erkannt und ein
* Bewirtschafter zur Exposé-Erstellung weitergeleitet hat.
*/
export default function MyListings() {
const [params, setParams] = useSearchParams()
const tab = resolveTab(params.get(PARAM_TAB))
const [selectedId, setSelectedId] = useState<string | null>(null)
const scrollRef = useRef<HTMLDivElement>(null)
const { data: leads = [], isLoading, isError, refetch } = useExposeLeads()
const { data: properties = [] } = useProperties()
const visible = useMemo(() => {
const wanted = tab === EXPOSE_LEAD_TABS.ARCHIVED ? ExposeLeadStatus.ARCHIVED : ExposeLeadStatus.ACTIVE
return leads
.filter(l => l.status === wanted)
.sort((a, b) => b.receivedAt.localeCompare(a.receivedAt))
}, [leads, tab])
/**
* Beim Öffnen gleitet die gewählte Zeile an den oberen Rand des sichtbaren
* Inhaltsbereichs; die nachfolgenden Leads rücken nach unten und dürfen aus
* dem Blickfeld verschwinden (Runde 4, §8.4). Umgesetzt mit `scrollTo` auf dem
* bestehenden Scrollcontainer — dafür braucht es keine Animationsbibliothek.
*
* Bei `prefers-reduced-motion` wird ohne Bewegung direkt gesprungen.
*/
const handleSelect = useCallback((id: string, row: HTMLElement | null) => {
const next = id === selectedId ? null : id
setSelectedId(next)
if (!next || !row || !scrollRef.current) return
const container = scrollRef.current
const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches
const top = container.scrollTop + row.getBoundingClientRect().top - container.getBoundingClientRect().top
// Nach dem Zustandswechsel scrollen, sonst misst der Browser die alte Höhe.
requestAnimationFrame(() => {
container.scrollTo({ top, behavior: reduced ? 'auto' : 'smooth' })
})
}, [selectedId])
const handleTabChange = useCallback((next: ExposeLeadTab) => {
setSelectedId(null)
setParams({ [PARAM_TAB]: next })
}, [setParams])
if (isLoading) return <LoadingPage />
if (isError) return <ErrorState message="Die Leads konnten nicht geladen werden." onRetry={() => refetch()} />
return (
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
<Box ref={scrollRef} sx={{ flex: 1, overflowY: 'auto' }}>
<AgentWorkspaceHero agentId={LIVIA.id} name={LIVIA.name} role={LIVIA.role} />
<Box sx={{ px: 3, pb: 5 }}>
<Tabs
value={tab}
onChange={(_, v: ExposeLeadTab) => handleTabChange(v)}
sx={{ mb: 2, '& .MuiTab-root': { textTransform: 'none', fontWeight: 600, fontSize: '0.9rem' } }}
>
{Object.values(EXPOSE_LEAD_TABS).map(value => (
<Tab key={value} value={value} label={EXPOSE_LEAD_TAB_LABELS[value]} />
))}
</Tabs>
<LeadTable
leads={visible}
properties={properties}
selectedId={selectedId}
onSelect={handleSelect}
withWorkspace={tab === EXPOSE_LEAD_TABS.ACTIVE}
emptyText={
tab === EXPOSE_LEAD_TABS.ACTIVE
? 'Keine aktiven Leads. Sobald Nora ein Signal erkennt und es weitergeleitet wird, erscheint es hier.'
: 'Noch keine archivierten Leads.'
}
/>
{/* Weissraum unter der Liste, damit auch die letzte Zeile an den
oberen Rand gleiten kann. */}
<Box sx={{ height: '60vh' }} />
</Box>
</Box>
</Box>
)
}
@@ -0,0 +1,8 @@
import type { CreateExposeLeadInput, ExposeLead } from '../domain/exposeLead'
export interface IExposeLeadProvider {
getAll(): Promise<ExposeLead[]>
getById(id: string): Promise<ExposeLead | null>
create(input: CreateExposeLeadInput): Promise<ExposeLead>
archive(id: string): Promise<ExposeLead>
}
@@ -0,0 +1,9 @@
import type { ExposeDraft } from '../domain/expose'
export interface IExposeProvider {
/** Entwurf zu einem Lead und Objekt — null, solange keiner besteht. */
getDraft(leadId: string, propertyId: string): Promise<ExposeDraft | null>
save(draft: ExposeDraft): Promise<ExposeDraft>
/** Setzt den Erstellungszeitpunkt — trennt «gespeichert» von «erstellt». */
markGenerated(draftId: string): Promise<ExposeDraft>
}
@@ -0,0 +1,36 @@
import { mockExposeLeads } from '../mock-data/exposeLeads'
import type { ExposeLead } from '../domain/exposeLead'
import { ExposeLeadStatus } from '../domain/exposeLead'
import type { IExposeLeadProvider } from './IExposeLeadProvider'
let store: ExposeLead[] = [...mockExposeLeads]
let sequence = store.length
export const MockupExposeLeadProvider: IExposeLeadProvider = {
async getAll() {
return [...store]
},
async getById(id) {
return store.find(l => l.id === id) ?? null
},
async create(input) {
sequence += 1
const lead: ExposeLead = {
...input,
id: `lead-${String(sequence).padStart(3, '0')}`,
receivedAt: input.receivedAt ?? new Date().toISOString(),
status: input.status ?? ExposeLeadStatus.ACTIVE,
}
store = [lead, ...store]
return lead
},
async archive(id) {
const idx = store.findIndex(l => l.id === id)
if (idx === -1) throw new Error(`Lead ${id} nicht gefunden`)
store[idx] = { ...store[idx], status: ExposeLeadStatus.ARCHIVED }
return store[idx]
},
}
@@ -0,0 +1,37 @@
import type { ExposeDraft } from '../domain/expose'
import type { IExposeProvider } from './IExposeProvider'
/**
* Entwurfsablage der Demo — in-memory, wie alle Mockup-Provider.
*
* Der Schlüssel ist das Paar aus Lead und Objekt: derselbe Lead kann mehrere
* Objekte empfehlen, und jedes bekommt sein eigenes Exposé.
*/
const store = new Map<string, ExposeDraft>()
function key(leadId: string, propertyId: string): string {
return `${leadId}::${propertyId}`
}
export const MockupExposeProvider: IExposeProvider = {
async getDraft(leadId, propertyId) {
return store.get(key(leadId, propertyId)) ?? null
},
async save(draft) {
const saved: ExposeDraft = { ...draft, updatedAt: new Date().toISOString() }
store.set(key(draft.leadId, draft.propertyId), saved)
return saved
},
async markGenerated(draftId) {
for (const [k, d] of store) {
if (d.id === draftId) {
const updated: ExposeDraft = { ...d, generatedAt: new Date().toISOString() }
store.set(k, updated)
return updated
}
}
throw new Error(`Exposé-Entwurf ${draftId} nicht gefunden`)
},
}
@@ -0,0 +1,105 @@
import type { ExposeTextInput, ExposeTextResult } from '../IAIService'
import { ExposeTextSection } from '../IAIService'
/**
* Deterministische Exposé-Texte für Entwicklung und Demo.
*
* Kernregel der Vorgabe: keine unbelegten Fakten erfinden (Runde 4, §8.5.2).
* Der Builder verwendet deshalb ausschliesslich die übergebenen Angaben. Was
* fehlt, wird nicht plausibel ergänzt, sondern in `missingFacts` genannt —
* die Oberfläche markiert es rot, statt einen glatten Text zu zeigen, der
* nachher nicht stimmt.
*/
const TONE_OPENER: Record<string, string> = {
SACHLICH: '',
HOCHWERTIG: 'Repräsentativ und sorgfältig ausgebaut: ',
EINLADEND: 'Willkommen in Ihrer neuen Fläche — ',
}
/** Welche Angaben braucht ein Abschnitt, um überhaupt geschrieben werden zu können? */
const REQUIRED: Record<string, string[]> = {
[ExposeTextSection.TITLE]: ['objektart', 'ort'],
[ExposeTextSection.TEASER]: ['objektart', 'flaeche', 'ort'],
[ExposeTextSection.OBJECT]: ['objektart', 'flaeche', 'ausbaustandard'],
[ExposeTextSection.LOCATION]: ['strasse', 'ort'],
[ExposeTextSection.MUNICIPALITY]: ['ort'],
[ExposeTextSection.FEATURES]: ['ausbaustandard'],
[ExposeTextSection.HIGHLIGHTS]: ['objektart', 'ort'],
}
function value(facts: ExposeTextInput['facts'], key: string): string | undefined {
const raw = facts[key]
if (raw === undefined || raw === null || raw === '') return undefined
return String(raw)
}
function compose(input: ExposeTextInput): string {
const f = input.facts
const v = (k: string) => value(f, k)
const opener = TONE_OPENER[input.tonality] ?? ''
switch (input.section) {
case ExposeTextSection.TITLE:
return [v('objektart'), v('flaeche') && `${v('flaeche')} m²`, 'in', v('ort')]
.filter(Boolean).join(' ')
case ExposeTextSection.TEASER:
return `${opener}${v('objektart')} mit ${v('flaeche')} m² in ${v('ort')}` +
(v('verfuegbarkeit') ? `, verfügbar ab ${v('verfuegbarkeit')}` : '') + '.'
case ExposeTextSection.OBJECT:
return [
`${opener}Die Fläche umfasst ${v('flaeche')} m² und ist im Ausbaustandard «${v('ausbaustandard')}» ausgeführt.`,
v('stockwerk') ? `Sie liegt im ${v('stockwerk')}. Stockwerk.` : '',
v('parkplaetze') ? `Zur Fläche gehören ${v('parkplaetze')} Parkplätze.` : '',
v('raumhoehe') ? `Die Raumhöhe beträgt ${v('raumhoehe')} m.` : '',
].filter(Boolean).join(' ')
case ExposeTextSection.LOCATION:
return [
`Das Objekt befindet sich an der ${v('strasse')} in ${v('plz') ?? ''} ${v('ort')}.`.replace(/\s+/g, ' '),
v('oevHaltestelle') ? `Die nächste ÖV-Haltestelle liegt rund ${v('oevHaltestelle')} m entfernt.` : '',
v('einkauf') ? `Einkaufsmöglichkeiten sind in etwa ${v('einkauf')} m erreichbar.` : '',
v('autobahn') ? `Der Autobahnanschluss ist rund ${v('autobahn')} m entfernt.` : '',
].filter(Boolean).join(' ')
case ExposeTextSection.MUNICIPALITY:
return [
`${v('ort')}${v('kanton') ? ` (${v('kanton')})` : ''} als Standort:`,
v('einwohner') ? `${v('einwohner')} Einwohnerinnen und Einwohner.` : '',
v('bevoelkerungswachstum') ? `Bevölkerungswachstum ${v('bevoelkerungswachstum')} % pro Jahr.` : '',
v('steuerbelastung') ? `Steuerbelastung ${v('steuerbelastung')} %.` : '',
v('leerwohnungsziffer') ? `Leerwohnungsziffer ${v('leerwohnungsziffer')} %.` : '',
].filter(Boolean).join(' ')
case ExposeTextSection.FEATURES:
return [
`Ausbaustandard «${v('ausbaustandard')}».`,
v('heizsystem') ? `Beheizt über ${v('heizsystem')}.` : '',
v('merkmale') ? `Weitere Merkmale: ${v('merkmale')}.` : '',
].filter(Boolean).join(' ')
case ExposeTextSection.HIGHLIGHTS:
return [
v('flaeche') ? `${v('flaeche')} m² ${v('objektart')}` : '',
v('ort') ? `Standort ${v('ort')}` : '',
v('parkplaetze') ? `${v('parkplaetze')} Parkplätze` : '',
v('oevHaltestelle') ? `ÖV in ${v('oevHaltestelle')} m` : '',
].filter(Boolean).join('\n')
default:
return ''
}
}
export function buildExposeText(input: ExposeTextInput): ExposeTextResult {
const missingFacts = (REQUIRED[input.section] ?? []).filter(k => value(input.facts, k) === undefined)
// Fehlt eine Grundangabe, wird kein Text behauptet — die Lücke ist die Antwort.
if (missingFacts.length > 0) {
return { section: input.section, text: '', missingFacts }
}
return { section: input.section, text: compose(input).trim(), missingFacts: [] }
}
+165
View File
@@ -0,0 +1,165 @@
/**
* Property On — Vorschau und Export der Angebotsbroschüre (Runde 4, §8.5.3).
*
* Bewusst ohne PDF-Bibliothek: der Browser kann drucken und in PDF speichern,
* und Word öffnet HTML mit `.doc`-Endung als bearbeitbares Dokument. Eine
* zusätzliche Abhängigkeit von mehreren hundert Kilobyte brächte für eine
* Broschüre aus Text und Bildern keinen Gegenwert.
*
* CI, Bilder, Texte und Anhänge folgen der Exposé-Konfiguration.
*/
import type { ExposeDraft } from '../domain/expose'
import { EXPOSE_SECTIONS } from '../lib/exposeFields'
import { EXPOSE_DOCUMENT_TYPE_LABELS } from '../lib/constants'
/** Firmenfarbe der «Beispiel Immobilien AG», sofern nicht manuell gesetzt. */
const DEFAULT_BRAND_COLOR = '#152642'
function escapeHtml(value: string): string {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
function paragraphs(text: string): string {
return text
.split('\n')
.filter(line => line.trim() !== '')
.map(line => `<p>${escapeHtml(line)}</p>`)
.join('')
}
export function buildExposeHtml(draft: ExposeDraft): string {
const v = draft.values
const brand = draft.brandColor?.trim() || DEFAULT_BRAND_COLOR
const visibleImages = draft.images.filter(i => i.visible)
const cover = visibleImages.find(i => i.isCover) ?? visibleImages[0]
const gallery = visibleImages.filter(i => i !== cover)
const attachments = draft.documents.filter(d => d.visibleAsAttachment)
// Alle Bereiche ausser den Texten erscheinen als Faktentabelle; leere Felder
// werden weggelassen statt mit einem Strich gefüllt.
const factRows = EXPOSE_SECTIONS
.filter(s => s.id !== 'texte')
.map(section => {
const rows = section.fields
.filter(f => (v[f.key] ?? '').trim() !== '')
.map(f => `<tr><th>${escapeHtml(f.label)}</th><td>${escapeHtml(v[f.key])}${f.suffix ? ` ${escapeHtml(f.suffix)}` : ''}</td></tr>`)
.join('')
return rows ? `<h2>${escapeHtml(section.title)}</h2><table>${rows}</table>` : ''
})
.join('')
const textBlocks = [
['Kurzbeschrieb', v.kurzbeschrieb],
['Objektbeschrieb', v.objektbeschrieb],
['Lagebeschrieb', v.lagebeschrieb],
['Die Gemeinde', v.gemeindebeschrieb],
['Ausstattung', v.ausstattungsbeschrieb],
]
.filter(([, text]) => (text ?? '').trim() !== '')
.map(([title, text]) => `<h2>${escapeHtml(title!)}</h2>${paragraphs(text!)}`)
.join('')
const highlights = (v.highlights ?? '').trim()
? `<h2>Highlights</h2><ul>${v.highlights.split('\n').filter(Boolean).map(h => `<li>${escapeHtml(h)}</li>`).join('')}</ul>`
: ''
const attachmentList = attachments.length
? `<h2>Anhänge</h2><ul>${attachments.map(d => `<li>${escapeHtml(EXPOSE_DOCUMENT_TYPE_LABELS[d.type] ?? d.type)}: ${escapeHtml(d.title || d.fileName)}</li>`).join('')}</ul>`
: ''
const contact = draft.contactPerson.trim()
? `<h2>Kontakt</h2><p>${escapeHtml(draft.contactPerson)}</p>`
: ''
return `<!doctype html>
<html lang="de-CH">
<head>
<meta charset="utf-8">
<title>${escapeHtml(v.exposeTitel || 'Exposé')}</title>
<style>
body { font-family: Georgia, 'Times New Roman', serif; color: #141414; margin: 0; padding: 32px 40px; }
header { border-bottom: 4px solid ${brand}; padding-bottom: 12px; margin-bottom: 20px; }
h1 { color: ${brand}; font-size: 26px; margin: 0 0 4px; }
.sub { color: #5c5c5c; font-size: 13px; }
h2 { color: ${brand}; font-size: 15px; text-transform: uppercase; letter-spacing: .06em; margin: 24px 0 8px; }
table { width: 100%; border-collapse: collapse; font-size: 13px; }
th { text-align: left; width: 38%; font-weight: 600; color: #5c5c5c; padding: 4px 8px 4px 0; vertical-align: top; }
td { padding: 4px 0; }
tr + tr th, tr + tr td { border-top: 1px solid #e8e7e4; }
p { font-size: 14px; line-height: 1.6; margin: 0 0 10px; }
ul { font-size: 14px; line-height: 1.6; }
.cover img { width: 100%; max-height: 340px; object-fit: cover; }
.gallery { display: grid; grid-template-columns: repeat(2, 1fr); gap: 10px; }
.gallery figure { margin: 0; }
.gallery img { width: 100%; height: 170px; object-fit: cover; }
figcaption { font-size: 11px; color: #5c5c5c; margin-top: 3px; }
@media print { body { padding: 0; } }
</style>
</head>
<body>
<header>
<h1>${escapeHtml(v.exposeTitel || 'Exposé')}</h1>
<div class="sub">${escapeHtml([v.strasse, v.hausnummer].filter(Boolean).join(' '))}${v.ort ? `, ${escapeHtml(v.plz ?? '')} ${escapeHtml(v.ort)}` : ''}</div>
</header>
${cover ? `<div class="cover"><img src="${escapeHtml(cover.url)}" alt="${escapeHtml(cover.caption || v.exposeTitel || '')}"></div>` : ''}
${textBlocks}
${highlights}
${factRows}
${gallery.length ? `<h2>Bilder</h2><div class="gallery">${gallery.map(i => `<figure><img src="${escapeHtml(i.url)}" alt="${escapeHtml(i.caption)}">${i.caption ? `<figcaption>${escapeHtml(i.caption)}</figcaption>` : ''}</figure>`).join('')}</div>` : ''}
${attachmentList}
${contact}
</body>
</html>`
}
function fileBaseName(draft: ExposeDraft): string {
const title = draft.values.exposeTitel || draft.values.ort || 'expose'
return title
.toLowerCase()
.replace(/ä/g, 'ae').replace(/ö/g, 'oe').replace(/ü/g, 'ue').replace(/ß/g, 'ss')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '') || 'expose'
}
/** Vorschau in einem neuen Browser-Tab; von dort lässt sich als PDF drucken. */
export function openExposePreview(draft: ExposeDraft): boolean {
const win = window.open('', '_blank')
if (!win) return false
win.document.write(buildExposeHtml(draft))
win.document.close()
return true
}
function download(content: string, fileName: string, mime: string) {
const blob = new Blob([content], { type: mime })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = fileName
a.click()
URL.revokeObjectURL(url)
}
/**
* PDF-Download über den Druckdialog des Browsers: dort steht «Als PDF
* speichern». Ohne Serverkomponente ist das der ehrlichste Weg — ein Knopf,
* der eine Datei nur so nennt, wäre keiner.
*/
export function downloadExposePdf(draft: ExposeDraft): boolean {
const win = window.open('', '_blank')
if (!win) return false
win.document.write(buildExposeHtml(draft))
win.document.close()
win.addEventListener('load', () => win.print())
return true
}
/** Word öffnet HTML mit `.doc`-Endung als bearbeitbares Dokument. */
export function downloadExposeWord(draft: ExposeDraft): void {
download(buildExposeHtml(draft), `${fileBaseName(draft)}.doc`, 'application/msword')
}
@@ -0,0 +1,56 @@
import { MockupExposeLeadProvider } from '../provider/MockupExposeLeadProvider'
import type { CreateExposeLeadInput, ExposeLead } from '../domain/exposeLead'
import type { ItemResponse, ListResponse } from './types'
import { throwServiceError } from './errors'
import { useSessionStore } from '../stores/sessionStore'
const provider = MockupExposeLeadProvider
export const exposeLeadService = {
async getAll(): Promise<ListResponse<ExposeLead>> {
try {
const data = await provider.getAll()
return { data, meta: { total: data.length, page: 1, pageSize: data.length, hasMore: false } }
} catch (err) {
throwServiceError(err)
}
},
async getById(id: string): Promise<ItemResponse<ExposeLead | null>> {
try {
return { data: await provider.getById(id) }
} catch (err) {
throwServiceError(err)
}
},
/**
* Weiterleitung von Nora an Livia.
*
* Ohne mindestens ein Objekt hätte Livia nichts, woraus sie ein Exposé bauen
* könnte — deshalb ist die Objektempfehlung Pflicht und keine Kür.
*/
async create(input: CreateExposeLeadInput): Promise<ItemResponse<ExposeLead>> {
try {
if (input.propertyIds.length === 0) {
throw new Error('Bitte mindestens ein Objekt auswählen.')
}
const user = useSessionStore.getState().currentUser
const data = await provider.create({
...input,
forwardedBy: input.forwardedBy ?? user?.name,
})
return { data }
} catch (err) {
throwServiceError(err)
}
},
async archive(id: string): Promise<ItemResponse<ExposeLead>> {
try {
return { data: await provider.archive(id) }
} catch (err) {
throwServiceError(err)
}
},
}
+158
View File
@@ -0,0 +1,158 @@
import { MockupExposeProvider } from '../provider/MockupExposeProvider'
import type { ExposeDraft, ExposeImage, ExposeValues } from '../domain/expose'
import { ExposeBranding, ExposeImageCategory } from '../domain/expose'
import type { Property } from '../domain/property'
import type { ItemResponse } from './types'
import { throwServiceError } from './errors'
import { ASSET_TYPE_LABELS, FIT_OUT_LABELS } from '../lib/constants'
import { missingRequiredExposeKeys } from '../lib/exposeFields'
import type { ExposeTonality } from './ai/IAIService'
const provider = MockupExposeProvider
function str(value: unknown): string {
if (value === undefined || value === null) return ''
return String(value)
}
/**
* Vorbelegung aus «Meine Objekte» (Runde 4, §8.5.2).
*
* Es wird nur übernommen, was im Objektdatensatz tatsächlich steht. Felder ohne
* Entsprechung bleiben leer — sie sind der Grund, weshalb das Exposé rote
* Umrandungen zeigt, und genau das soll es. Ein vorbelegtes «ca. 3.0 m
* Raumhöhe» wäre eine Behauptung, keine Angabe.
*/
export function prefillFromProperty(p: Property): ExposeValues {
const hf = p.hardFacts ?? {}
const sf = p.softFactors ?? {}
return {
objektart: ASSET_TYPE_LABELS[p.assetType] ?? '',
vermarktungsart: 'Miete',
status: 'Aktiv in Vermarktung',
objektreferenz: str(p.propertyNumber),
strasse: str(p.address.street),
hausnummer: str(p.address.houseNumber),
plz: str(p.address.postalCode),
ort: str(p.address.city),
gemeinde: str(p.location.city),
kanton: str(p.location.canton),
land: str(p.address.country),
breitengrad: str(p.location.coordinates?.lat),
laengengrad: str(p.location.coordinates?.lng),
flaeche: str(p.areaSqm),
raumhoehe: str(hf.ceilingHeightM),
stockwerk: str(hf.floor ?? p.floorLevel),
verfuegbarkeit: p.availabilityDate ? new Date(p.availabilityDate).toLocaleDateString('de-CH') : '',
nettomiete: str(p.rentPricePerSqm),
nebenkosten: str(p.ancillaryCosts),
parkplaetze: str(hf.parking ?? sf.parkingSpots),
objektbeschrieb: str(p.description),
merkmale: hf.fitOut ? `Ausbaustandard ${FIT_OUT_LABELS[hf.fitOut] ?? hf.fitOut}` : '',
}
}
/**
* Objektbilder aus «Meine Objekte» automatisch importieren (§8.5.1).
* Doppelte Bilder werden über die URL ausgeschlossen — dieselbe Datei zweimal
* im Exposé wäre kein Fehler des Nutzers, sondern einer des Imports.
*/
export function importPropertyImages(p: Property): ExposeImage[] {
const seen = new Set<string>()
const images: ExposeImage[] = []
for (const url of p.images ?? []) {
if (seen.has(url)) continue
seen.add(url)
const fileName = url.split('/').pop() ?? `bild-${images.length + 1}.jpg`
images.push({
id: `img-${p.id}-${images.length + 1}`,
url,
fileName,
category: images.length === 0 ? ExposeImageCategory.EXTERIOR : ExposeImageCategory.INTERIOR,
caption: '',
visible: true,
isCover: images.length === 0,
imported: true,
})
}
return images
}
/** Die Angaben, aus denen der Textentwurf schöpfen darf — nichts darüber hinaus. */
export function textFactsFrom(values: ExposeValues): Record<string, string> {
const facts: Record<string, string> = {}
for (const [k, v] of Object.entries(values)) {
if (v && v.trim() !== '') facts[k] = v
}
return facts
}
function newDraft(leadId: string, property: Property, tonality: ExposeTonality): ExposeDraft {
return {
id: `expose-${leadId}-${property.id}`,
leadId,
propertyId: property.id,
values: prefillFromProperty(property),
tonality,
images: importPropertyImages(property),
documents: [],
branding: ExposeBranding.FROM_PROFILE,
contactPerson: '',
updatedAt: new Date().toISOString(),
}
}
export const exposeService = {
/**
* Bestehenden Entwurf laden oder einen neuen aus den Objektdaten aufbauen.
* Ein gespeicherter Entwurf gewinnt immer — sonst überschriebe die
* Vorbelegung bei jedem Öffnen die Arbeit des Nutzers.
*/
async getOrCreateDraft(
leadId: string,
property: Property,
tonality: ExposeTonality = 'SACHLICH',
): Promise<ItemResponse<ExposeDraft>> {
try {
const existing = await provider.getDraft(leadId, property.id)
if (existing) return { data: existing }
return { data: await provider.save(newDraft(leadId, property, tonality)) }
} catch (err) {
throwServiceError(err)
}
},
async save(draft: ExposeDraft): Promise<ItemResponse<ExposeDraft>> {
try {
return { data: await provider.save(draft) }
} catch (err) {
throwServiceError(err)
}
},
/**
* Erstellen ist mehr als Speichern: erst hier wird geprüft, ob das Dossier
* vollständig ist. Ein Exposé mit leerem Mietpreis darf nicht in den Export.
*/
async generate(draft: ExposeDraft): Promise<ItemResponse<ExposeDraft>> {
try {
const missing = missingRequiredExposeKeys(draft.values)
if (missing.length > 0) {
throw new Error(`Es fehlen noch ${missing.length} Pflichtangabe(n) — sie sind rot umrandet.`)
}
const saved = await provider.save(draft)
return { data: await provider.markGenerated(saved.id) }
} catch (err) {
throwServiceError(err)
}
},
}