feat: expand NewListing with AI, soft factors, hard facts, images + MyListings manager

- KI-Hilfe: text description → auto-fills all fields (parseListingText mock parser)
- Lage & Ausstrahlung: 9 soft factor selects (Tief/Mittel/Hoch) mapped to scoring engine
- Technische Details: floor, fit-out, parking, ceiling height
- Bilder: URL list with add/remove
- New /supply/my-listings page: list, status toggle, delete for DIRECT listings
- Added sourceType filter to PropertyFilters + MockupPropertyProvider
- Nav entry "Meine Inserate" added to supply sidebar

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Benjamin Sutter
2026-05-21 21:19:45 +02:00
parent 0dac528f98
commit c9324e9cc8
7 changed files with 635 additions and 95 deletions
+308 -95
View File
@@ -5,15 +5,19 @@ import {
Box,
Button,
Card,
Chip,
CircularProgress,
Divider,
IconButton,
MenuItem,
TextField,
Tooltip,
Typography,
} from '@mui/material'
import { ArrowLeft, CheckCircle } from 'lucide-react'
import { AssetType, ResultType, AvailabilityStatus } from '../../domain/enums'
import { ArrowLeft, CheckCircle, ImagePlus, Sparkles, X } from 'lucide-react'
import { AssetType, AvailabilityStatus, ResultType } from '../../domain/enums'
import { propertyService } from '../../services/propertyService'
import { parseListingText } from '../../services/aiService'
import type { CreatePropertyInput } from '../../domain/property'
const ASSET_TYPE_LABELS: Record<string, string> = {
@@ -25,6 +29,37 @@ const ASSET_TYPE_LABELS: Record<string, string> = {
MIXED: 'Gemischt',
}
const SOFT_FACTORS = [
{ key: 'prestige', label: 'Prestige / Adressqualität' },
{ key: 'accessibility', label: 'ÖV-Anbindung' },
{ key: 'visibility', label: 'Sichtbarkeit' },
{ key: 'footfall', label: 'Passantenfrequenz' },
{ key: 'talentAccess', label: 'Fachkräfte / Talent' },
{ key: 'esg', label: 'ESG / Nachhaltigkeit' },
{ key: 'flexibility', label: 'Flexibilität Grundriss' },
{ key: 'expansionPotential', label: 'Expansionspotenzial' },
{ key: 'taxEnvironment', label: 'Steuerumgebung' },
]
const LEVEL_OPTIONS = [
{ value: '', label: 'Keine Angabe' },
{ value: 'LOW', label: 'Tief' },
{ value: 'MEDIUM', label: 'Mittel' },
{ value: 'HIGH', label: 'Hoch' },
]
const LEVEL_TO_SCORE: Record<string, number | undefined> = {
LOW: 3, MEDIUM: 5, HIGH: 8, '': undefined,
}
const FIT_OUT_OPTIONS = [
{ value: '', label: 'Keine Angabe' },
{ value: 'SHELL', label: 'Rohbau' },
{ value: 'BASIC', label: 'Basis-Ausbau' },
{ value: 'FULL', label: 'Vollausbau' },
{ value: 'PREMIUM', label: 'Premiumausbau' },
]
interface LocationState {
prefill?: {
assetType?: string
@@ -44,25 +79,75 @@ export default function NewListing() {
const { state } = useLocation() as { state: LocationState | null }
const pre = state?.prefill ?? {}
const [assetType, setAssetType] = useState(pre.assetType ?? 'OFFICE')
const [street, setStreet] = useState(pre.street ?? '')
// Core fields
const [assetType, setAssetType] = useState(pre.assetType ?? 'OFFICE')
const [street, setStreet] = useState(pre.street ?? '')
const [houseNumber, setHouseNumber] = useState(pre.houseNumber ?? '')
const [postalCode, setPostalCode] = useState(pre.postalCode ?? '')
const [city, setCity] = useState(pre.city ?? '')
const [areaSqm, setAreaSqm] = useState(pre.areaSqm ? String(pre.areaSqm) : '')
const [rentPerSqm, setRentPerSqm] = useState(pre.rentPricePerSqm ? String(pre.rentPricePerSqm) : '')
const [postalCode, setPostalCode] = useState(pre.postalCode ?? '')
const [city, setCity] = useState(pre.city ?? '')
const [areaSqm, setAreaSqm] = useState(pre.areaSqm ? String(pre.areaSqm) : '')
const [rentPerSqm, setRentPerSqm] = useState(pre.rentPricePerSqm ? String(pre.rentPricePerSqm) : '')
const [availableFrom, setAvailableFrom] = useState('')
const [description, setDescription] = useState('')
const [contactName, setContactName] = useState('')
const [contactName, setContactName] = useState('')
const [contactEmail, setContactEmail] = useState('')
const [contactPhone, setContactPhone] = useState('')
// Soft factors
const [softLevels, setSoftLevels] = useState<Record<string, string>>(
Object.fromEntries(SOFT_FACTORS.map(f => [f.key, '']))
)
// Hard facts
const [floor, setFloor] = useState('')
const [fitOut, setFitOut] = useState('')
const [parking, setParking] = useState('')
const [ceilingHeight, setCeilingHeight] = useState('')
// Images
const [images, setImages] = useState<string[]>([])
const [imageInput, setImageInput] = useState('')
// AI
const [aiText, setAiText] = useState('')
const [aiParsing, setAiParsing] = useState(false)
const [aiApplied, setAiApplied] = useState(false)
// Submit
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
const [created, setCreated] = useState(false)
const [error, setError] = useState<string | null>(null)
const [created, setCreated] = useState(false)
const isPrefilled = !!pre.propertyId
async function handleAiParse() {
if (!aiText.trim()) return
setAiParsing(true)
try {
const parsed = await parseListingText(aiText)
if (parsed.assetType) setAssetType(parsed.assetType)
if (parsed.areaSqm) setAreaSqm(String(parsed.areaSqm))
if (parsed.rentPerSqm) setRentPerSqm(String(parsed.rentPerSqm))
if (parsed.city && !city) setCity(parsed.city)
if (parsed.fitOut) setFitOut(parsed.fitOut)
if (parsed.parking) setParking(String(parsed.parking))
if (parsed.softLevels) {
setSoftLevels(prev => ({ ...prev, ...parsed.softLevels }))
}
setAiApplied(true)
} finally {
setAiParsing(false)
}
}
function addImage() {
const url = imageInput.trim()
if (url && !images.includes(url)) {
setImages(prev => [...prev, url])
}
setImageInput('')
}
function validate(): string | null {
if (!street.trim()) return 'Strasse erforderlich'
if (!postalCode.trim()) return 'PLZ erforderlich'
@@ -81,19 +166,47 @@ export default function NewListing() {
const area = Number(areaSqm)
const rent = Number(rentPerSqm)
const sf = {
prestigeScore: LEVEL_TO_SCORE[softLevels.prestige ?? ''],
commuterAccessScore: LEVEL_TO_SCORE[softLevels.accessibility ?? ''],
visibilityScore: LEVEL_TO_SCORE[softLevels.visibility ?? ''],
footfallScore: LEVEL_TO_SCORE[softLevels.footfall ?? ''],
talentAccessScore: LEVEL_TO_SCORE[softLevels.talentAccess ?? ''],
esgScore: LEVEL_TO_SCORE[softLevels.esg ?? ''],
flexibilityScore: LEVEL_TO_SCORE[softLevels.flexibility ?? ''],
expansionPotentialScore: LEVEL_TO_SCORE[softLevels.expansionPotential ?? ''],
taxEnvironmentScore: LEVEL_TO_SCORE[softLevels.taxEnvironment ?? ''],
}
const hf = {
floor: floor ? parseInt(floor) : undefined,
fitOut: (fitOut || undefined) as 'SHELL' | 'BASIC' | 'FULL' | 'PREMIUM' | undefined,
parking: parking ? parseInt(parking) : undefined,
ceilingHeightM: ceilingHeight ? parseFloat(ceilingHeight) : undefined,
}
const input: CreatePropertyInput = {
title: `${ASSET_TYPE_LABELS[assetType] ?? assetType} · ${city}`,
assetType: assetType as typeof AssetType[keyof typeof AssetType],
resultType: ResultType.EXTERNAL_MARKET,
sourceType: 'DIRECT',
location: { city, country: 'CH' },
address: { street: street.trim(), houseNumber: houseNumber.trim(), postalCode: postalCode.trim(), city: city.trim(), country: 'CH' },
address: {
street: street.trim(),
houseNumber: houseNumber.trim(),
postalCode: postalCode.trim(),
city: city.trim(),
country: 'CH',
},
areaSqm: area,
rentPricePerSqm: rent,
availabilityDate: availableFrom || new Date().toISOString().slice(0, 10),
availabilityStatus: availableFrom ? AvailabilityStatus.AVAILABLE_SOON : AvailabilityStatus.AVAILABLE_NOW,
confidenceScore: 1.0,
description: description.trim() || undefined,
softFactors: sf,
hardFacts: hf,
images: images.length > 0 ? images : undefined,
dataQuality: {
score: 1.0,
missingCriticalFields: [],
@@ -114,24 +227,31 @@ export default function NewListing() {
}
}
function resetForm() {
setAssetType('OFFICE')
setStreet(''); setHouseNumber(''); setPostalCode(''); setCity('')
setAreaSqm(''); setRentPerSqm(''); setAvailableFrom(''); setDescription('')
setContactName(''); setContactEmail(''); setContactPhone('')
setSoftLevels(Object.fromEntries(SOFT_FACTORS.map(f => [f.key, ''])))
setFloor(''); setFitOut(''); setParking(''); setCeilingHeight('')
setImages([]); setImageInput('')
setAiText(''); setAiApplied(false)
setCreated(false)
}
if (created) {
return (
<Box sx={{ maxWidth: 560, mx: 'auto', mt: 8, px: 3, textAlign: 'center' }}>
<Box sx={{ maxWidth: 520, mx: 'auto', mt: 8, px: 3, textAlign: 'center' }}>
<CheckCircle size={48} color="#16a34a" style={{ marginBottom: 16 }} />
<Typography variant="h5" sx={{ fontWeight: 700, mb: 1 }}>Inserat erstellt</Typography>
<Typography color="text.secondary" sx={{ mb: 3 }}>
Das Inserat wurde erfolgreich veröffentlicht und ist jetzt für passende Suchanfragen sichtbar.
Das Inserat wurde veröffentlicht und ist für passende Suchanfragen sichtbar.
</Typography>
<Box sx={{ display: 'flex', gap: 2, justifyContent: 'center' }}>
<Button variant="outlined" onClick={() => navigate('/supply/properties')}>
Zu Meine Objekte
<Button variant="outlined" onClick={() => navigate('/supply/my-listings')}>
Meine Inserate
</Button>
<Button variant="contained" sx={{ bgcolor: '#1e3a5f' }} onClick={() => {
setCreated(false)
setStreet(''); setHouseNumber(''); setPostalCode(''); setCity('')
setAreaSqm(''); setRentPerSqm(''); setAvailableFrom(''); setDescription('')
setContactName(''); setContactEmail(''); setContactPhone('')
}}>
<Button variant="contained" sx={{ bgcolor: '#1e3a5f' }} onClick={resetForm}>
Weiteres Inserat
</Button>
</Box>
@@ -140,7 +260,8 @@ export default function NewListing() {
}
return (
<Box sx={{ maxWidth: 720, mx: 'auto', px: 3, py: 4 }}>
<Box sx={{ maxWidth: 760, mx: 'auto', px: 3, py: 4 }}>
{/* Header */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, mb: 3 }}>
<Button
variant="text"
@@ -160,17 +281,51 @@ export default function NewListing() {
: 'Fläche direkt inserieren — ohne vollständiges Objekt im Portfolio.'}
</Typography>
{/* AI Hilfe */}
<Card sx={{ p: 3, mb: 3, border: '1px solid #e0e7ff', bgcolor: '#f5f3ff' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
<Sparkles size={16} color="#7c3aed" />
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: '#7c3aed' }}>
KI-Hilfe Formular automatisch ausfüllen
</Typography>
</Box>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 1.5 }}>
Beschreiben Sie die Fläche in eigenen Worten die KI füllt die Felder automatisch aus.
</Typography>
<TextField
multiline
rows={3}
fullWidth
size="small"
placeholder="z.B.: Bürofläche 450 m² im Zentrum Zürich, 3. OG, CHF 280/m²/Jahr, sehr gute ÖV-Anbindung, 4 Parkplätze, Vollausbau, verfügbar ab Juli 2025"
value={aiText}
onChange={e => setAiText(e.target.value)}
sx={{ mb: 1.5, bgcolor: '#fff' }}
/>
{aiApplied && (
<Alert severity="success" sx={{ mb: 1.5, py: 0.5 }}>
Felder wurden automatisch ausgefüllt bitte überprüfen und ergänzen.
</Alert>
)}
<Button
variant="contained"
size="small"
startIcon={aiParsing ? <CircularProgress size={13} color="inherit" /> : <Sparkles size={13} />}
onClick={handleAiParse}
disabled={!aiText.trim() || aiParsing}
sx={{ bgcolor: '#7c3aed', '&:hover': { bgcolor: '#6d28d9' }, textTransform: 'none' }}
>
{aiParsing ? 'Analysiert…' : 'KI analysieren'}
</Button>
</Card>
{/* Flächendetails */}
<Card sx={{ p: 3, mb: 3 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 2 }}>Flächendetails</Typography>
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 2 }}>
<TextField
select
label="Flächentyp"
value={assetType}
onChange={e => setAssetType(e.target.value)}
size="small"
fullWidth
select label="Flächentyp" value={assetType}
onChange={e => setAssetType(e.target.value)} size="small" fullWidth
>
{Object.entries(ASSET_TYPE_LABELS).map(([v, l]) => (
<MenuItem key={v} value={v}>{l}</MenuItem>
@@ -178,116 +333,174 @@ export default function NewListing() {
</TextField>
<TextField
label="Fläche (m²)"
value={areaSqm}
label="Fläche (m²)" value={areaSqm}
onChange={e => setAreaSqm(e.target.value)}
size="small"
type="number"
inputProps={{ min: 1 }}
fullWidth
size="small" type="number" inputProps={{ min: 1 }} fullWidth
/>
<TextField
label="Mietpreis (CHF/m²/Jahr)"
value={rentPerSqm}
label="Mietpreis (CHF/m²/Jahr)" value={rentPerSqm}
onChange={e => setRentPerSqm(e.target.value)}
size="small"
type="number"
inputProps={{ min: 1 }}
fullWidth
size="small" type="number" inputProps={{ min: 1 }} fullWidth
/>
<TextField
label="Verfügbar ab"
value={availableFrom}
label="Verfügbar ab" value={availableFrom}
onChange={e => setAvailableFrom(e.target.value)}
size="small"
type="date"
slotProps={{ inputLabel: { shrink: true } }}
fullWidth
size="small" type="date"
slotProps={{ inputLabel: { shrink: true } }} fullWidth
/>
</Box>
<TextField
label="Beschreibung (optional)"
value={description}
label="Beschreibung (optional)" value={description}
onChange={e => setDescription(e.target.value)}
size="small"
multiline
rows={3}
fullWidth
sx={{ mt: 2 }}
size="small" multiline rows={3} fullWidth sx={{ mt: 2 }}
/>
</Card>
{/* Adresse */}
<Card sx={{ p: 3, mb: 3 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 2 }}>Adresse</Typography>
<Box sx={{ display: 'grid', gridTemplateColumns: '3fr 1fr', gap: 2, mb: 2 }}>
<TextField
label="Strasse"
value={street}
onChange={e => setStreet(e.target.value)}
size="small"
fullWidth
label="Strasse" value={street}
onChange={e => setStreet(e.target.value)} size="small" fullWidth
/>
<TextField
label="Nr."
value={houseNumber}
onChange={e => setHouseNumber(e.target.value)}
size="small"
fullWidth
label="Nr." value={houseNumber}
onChange={e => setHouseNumber(e.target.value)} size="small" fullWidth
/>
</Box>
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 2fr', gap: 2 }}>
<TextField
label="PLZ"
value={postalCode}
onChange={e => setPostalCode(e.target.value)}
size="small"
fullWidth
label="PLZ" value={postalCode}
onChange={e => setPostalCode(e.target.value)} size="small" fullWidth
/>
<TextField
label="Ort"
value={city}
onChange={e => setCity(e.target.value)}
size="small"
fullWidth
label="Ort" value={city}
onChange={e => setCity(e.target.value)} size="small" fullWidth
/>
</Box>
</Card>
{/* Lage & Ausstrahlung */}
<Card sx={{ p: 3, mb: 3 }}>
<Box sx={{ mb: 2 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>Lage & Ausstrahlung</Typography>
<Typography variant="caption" color="text.secondary">
Diese Angaben verbessern die Match-Qualität erheblich.
</Typography>
</Box>
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 2 }}>
{SOFT_FACTORS.map(({ key, label }) => (
<TextField
key={key}
select
label={label}
value={softLevels[key] ?? ''}
onChange={e => setSoftLevels(prev => ({ ...prev, [key]: e.target.value }))}
size="small"
fullWidth
>
{LEVEL_OPTIONS.map(o => (
<MenuItem key={o.value} value={o.value}>{o.label}</MenuItem>
))}
</TextField>
))}
</Box>
</Card>
{/* Technische Details */}
<Card sx={{ p: 3, mb: 3 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 2 }}>Technische Details (optional)</Typography>
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr 1fr', gap: 2 }}>
<TextField
label="Stockwerk" value={floor}
onChange={e => setFloor(e.target.value)}
size="small" type="number" fullWidth
placeholder="0 = EG"
/>
<TextField
select label="Ausbaustandard" value={fitOut}
onChange={e => setFitOut(e.target.value)} size="small" fullWidth
>
{FIT_OUT_OPTIONS.map(o => (
<MenuItem key={o.value} value={o.value}>{o.label}</MenuItem>
))}
</TextField>
<TextField
label="Parkplätze" value={parking}
onChange={e => setParking(e.target.value)}
size="small" type="number" inputProps={{ min: 0 }} fullWidth
/>
<TextField
label="Deckenhöhe (m)" value={ceilingHeight}
onChange={e => setCeilingHeight(e.target.value)}
size="small" type="number" inputProps={{ step: 0.1, min: 2 }} fullWidth
/>
</Box>
</Card>
{/* Bilder */}
<Card sx={{ p: 3, mb: 3 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 2 }}>Bilder (optional)</Typography>
<Box sx={{ display: 'flex', gap: 1, mb: 1.5 }}>
<TextField
label="Bild-URL eingeben"
value={imageInput}
onChange={e => setImageInput(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); addImage() } }}
size="small"
fullWidth
placeholder="https://…"
/>
<Button
variant="outlined"
size="small"
startIcon={<ImagePlus size={15} />}
onClick={addImage}
disabled={!imageInput.trim()}
sx={{ whiteSpace: 'nowrap', textTransform: 'none' }}
>
Hinzufügen
</Button>
</Box>
{images.length > 0 && (
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
{images.map((url, i) => (
<Chip
key={i}
label={url.length > 40 ? url.slice(0, 40) + '…' : url}
size="small"
onDelete={() => setImages(prev => prev.filter((_, j) => j !== i))}
sx={{ maxWidth: 300 }}
/>
))}
</Box>
)}
</Card>
{/* Kontakt */}
<Card sx={{ p: 3, mb: 3 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 2 }}>Kontakt (optional)</Typography>
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 2 }}>
<TextField
label="Name"
value={contactName}
onChange={e => setContactName(e.target.value)}
size="small"
fullWidth
label="Name" value={contactName}
onChange={e => setContactName(e.target.value)} size="small" fullWidth
/>
<TextField
label="Telefon"
value={contactPhone}
onChange={e => setContactPhone(e.target.value)}
size="small"
fullWidth
label="Telefon" value={contactPhone}
onChange={e => setContactPhone(e.target.value)} size="small" fullWidth
/>
<TextField
label="E-Mail"
value={contactEmail}
label="E-Mail" value={contactEmail}
onChange={e => setContactEmail(e.target.value)}
size="small"
type="email"
fullWidth
sx={{ gridColumn: '1 / -1' }}
size="small" type="email" fullWidth sx={{ gridColumn: '1 / -1' }}
/>
</Box>
</Card>
{error && (
<Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>
)}
{error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>}
<Divider sx={{ mb: 2 }} />