`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>
317 lines
13 KiB
TypeScript
317 lines
13 KiB
TypeScript
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 & erstellen
|
|
</Button>
|
|
</Box>
|
|
</Box>
|
|
)
|
|
}
|