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:
1 parent
ba8dc39483
commit
2037e5a463
33 files changed
+4431
No files matched your search
@@ -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 & erstellen
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user