diff --git a/src/App.tsx b/src/App.tsx
index 2f94c41..85db5b5 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -28,6 +28,7 @@ const DataQuality = lazy(() => import('./pages/supply/DataQuality'))
const ReminderManager = lazy(() => import('./pages/supply/ReminderManager'))
const MarketLeads = lazy(() => import('./pages/supply/MarketLeads'))
const NewListing = lazy(() => import('./pages/supply/NewListing'))
+const MyListings = lazy(() => import('./pages/supply/MyListings'))
const AISearch = lazy(() => import('./pages/demand/AISearch'))
const Results = lazy(() => import('./pages/demand/Results'))
@@ -64,6 +65,7 @@ function App() {
} />
} />
} />
+ } />
{/* Demand Workspace */}
diff --git a/src/components/layout/AppShell.tsx b/src/components/layout/AppShell.tsx
index 8307067..e6aa327 100644
--- a/src/components/layout/AppShell.tsx
+++ b/src/components/layout/AppShell.tsx
@@ -89,6 +89,7 @@ const WORKSPACE_CONFIG: Record = {
{ path: '/supply/anfragen', label: 'Anfragencenter', icon: MessageSquare },
{ path: '/supply/data-quality', label: 'Datenpflege', icon: CheckSquare },
{ path: '/supply/market-intelligence', label: 'Markt Intelligence', icon: Radar },
+ { path: '/supply/my-listings', label: 'Meine Inserate', icon: ClipboardList },
{ path: '/supply/new-listing', label: 'Neues Inserat', icon: Plus },
],
},
diff --git a/src/pages/supply/MyListings.tsx b/src/pages/supply/MyListings.tsx
new file mode 100644
index 0000000..8746b6f
--- /dev/null
+++ b/src/pages/supply/MyListings.tsx
@@ -0,0 +1,247 @@
+import { useState } from 'react'
+import { useNavigate } from 'react-router'
+import { useQueryClient } from '@tanstack/react-query'
+import {
+ Alert,
+ Box,
+ Button,
+ Chip,
+ CircularProgress,
+ Dialog,
+ DialogActions,
+ DialogContent,
+ DialogTitle,
+ IconButton,
+ Switch,
+ Tooltip,
+ Typography,
+} from '@mui/material'
+import { Plus, Trash2 } from 'lucide-react'
+import { useProperties } from '../../hooks/useProperties'
+import { propertyService } from '../../services/propertyService'
+import type { Property } from '../../domain/property'
+
+const ASSET_LABELS: Record = {
+ OFFICE: 'Büro',
+ RETAIL: 'Einzelhandel',
+ LIGHT_INDUSTRIAL: 'Leichtindustrie',
+ LOGISTICS: 'Logistik',
+ PRODUCTION: 'Produktion',
+ MIXED: 'Gemischt',
+}
+
+function formatDate(iso?: string) {
+ if (!iso) return '–'
+ return new Date(iso).toLocaleDateString('de-CH', { day: '2-digit', month: '2-digit', year: 'numeric' })
+}
+
+export default function MyListings() {
+ const navigate = useNavigate()
+ const qc = useQueryClient()
+
+ const { data: listings = [], isLoading } = useProperties({ sourceType: 'DIRECT' })
+
+ const [togglingId, setTogglingId] = useState(null)
+ const [deletingId, setDeletingId] = useState(null)
+ const [confirmDelete, setConfirmDelete] = useState(null)
+ const [actionError, setActionError] = useState(null)
+
+ async function handleToggleStatus(p: Property) {
+ setTogglingId(p.id)
+ setActionError(null)
+ try {
+ const next = p.status === 'ACTIVE' ? 'INACTIVE' : 'ACTIVE'
+ await propertyService.update(p.id, { status: next })
+ qc.invalidateQueries({ queryKey: ['properties'] })
+ } catch {
+ setActionError('Status konnte nicht geändert werden.')
+ } finally {
+ setTogglingId(null)
+ }
+ }
+
+ async function handleDelete(p: Property) {
+ setDeletingId(p.id)
+ setActionError(null)
+ try {
+ await propertyService.remove(p.id)
+ qc.invalidateQueries({ queryKey: ['properties'] })
+ } catch {
+ setActionError('Inserat konnte nicht gelöscht werden.')
+ } finally {
+ setDeletingId(null)
+ setConfirmDelete(null)
+ }
+ }
+
+ return (
+
+
+
+ Meine Inserate
+
+ Direkt erstellte Inserate — unabhängig vom Portfolio
+
+
+ }
+ onClick={() => navigate('/supply/new-listing')}
+ sx={{ bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#162d4a' }, textTransform: 'none' }}
+ >
+ Neues Inserat
+
+
+
+ {actionError && (
+ setActionError(null)}>
+ {actionError}
+
+ )}
+
+ {isLoading ? (
+
+
+
+ ) : listings.length === 0 ? (
+
+ Noch keine Inserate
+
+ Erstellen Sie Ihr erstes direktes Inserat — ohne vollständiges Objekt im Portfolio.
+
+ }
+ onClick={() => navigate('/supply/new-listing')}
+ sx={{ textTransform: 'none' }}
+ >
+ Erstes Inserat erstellen
+
+
+ ) : (
+
+ {/* Header */}
+
+ {['Inserat', 'Ort', 'Fläche', 'Preis/m²/J', 'Erstellt', 'Aktiv', ''].map(h => (
+
+ {h}
+
+ ))}
+
+
+ {listings.map((p, i) => {
+ const isLast = i === listings.length - 1
+ return (
+
+ {/* Title + type */}
+
+
+ {p.title}
+
+
+
+
+ {/* City */}
+
+ {p.location.city}
+
+
+ {/* Area */}
+
+ {p.areaSqm.toLocaleString('de-CH')} m²
+
+
+ {/* Rent */}
+
+ CHF {p.rentPricePerSqm.toLocaleString('de-CH')}
+
+
+ {/* Created */}
+
+ {formatDate(p.createdAt)}
+
+
+ {/* Status toggle */}
+
+ {togglingId === p.id ? (
+
+ ) : (
+
+ handleToggleStatus(p)}
+ sx={{ '& .MuiSwitch-thumb': { width: 14, height: 14 } }}
+ />
+
+ )}
+
+
+ {/* Delete */}
+
+ {deletingId === p.id ? (
+
+ ) : (
+
+ setConfirmDelete(p)}
+ sx={{ color: '#94a3b8', '&:hover': { color: '#ef4444' } }}
+ >
+
+
+
+ )}
+
+
+ )
+ })}
+
+ )}
+
+ {/* Delete confirmation dialog */}
+
+
+ )
+}
diff --git a/src/pages/supply/NewListing.tsx b/src/pages/supply/NewListing.tsx
index def9aa6..8508a92 100644
--- a/src/pages/supply/NewListing.tsx
+++ b/src/pages/supply/NewListing.tsx
@@ -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 = {
@@ -25,6 +29,37 @@ const ASSET_TYPE_LABELS: Record = {
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 = {
+ 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>(
+ 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([])
+ 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(null)
- const [created, setCreated] = useState(false)
+ const [error, setError] = useState(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 (
-
+
Inserat erstellt
- 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.
-
@@ -140,7 +260,8 @@ export default function NewListing() {
}
return (
-
+
+ {/* Header */}
+ {/* AI Hilfe */}
+
+
+
+
+ KI-Hilfe — Formular automatisch ausfüllen
+
+
+
+ Beschreiben Sie die Fläche in eigenen Worten — die KI füllt die Felder automatisch aus.
+
+ setAiText(e.target.value)}
+ sx={{ mb: 1.5, bgcolor: '#fff' }}
+ />
+ {aiApplied && (
+
+ Felder wurden automatisch ausgefüllt — bitte überprüfen und ergänzen.
+
+ )}
+ : }
+ onClick={handleAiParse}
+ disabled={!aiText.trim() || aiParsing}
+ sx={{ bgcolor: '#7c3aed', '&:hover': { bgcolor: '#6d28d9' }, textTransform: 'none' }}
+ >
+ {aiParsing ? 'Analysiert…' : 'KI analysieren'}
+
+
+
+ {/* Flächendetails */}
Flächendetails
-
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]) => (
@@ -178,116 +333,174 @@ export default function NewListing() {
setAreaSqm(e.target.value)}
- size="small"
- type="number"
- inputProps={{ min: 1 }}
- fullWidth
+ size="small" type="number" inputProps={{ min: 1 }} fullWidth
/>
setRentPerSqm(e.target.value)}
- size="small"
- type="number"
- inputProps={{ min: 1 }}
- fullWidth
+ size="small" type="number" inputProps={{ min: 1 }} fullWidth
/>
setAvailableFrom(e.target.value)}
- size="small"
- type="date"
- slotProps={{ inputLabel: { shrink: true } }}
- fullWidth
+ size="small" type="date"
+ slotProps={{ inputLabel: { shrink: true } }} fullWidth
/>
-
setDescription(e.target.value)}
- size="small"
- multiline
- rows={3}
- fullWidth
- sx={{ mt: 2 }}
+ size="small" multiline rows={3} fullWidth sx={{ mt: 2 }}
/>
+ {/* Adresse */}
Adresse
setStreet(e.target.value)}
- size="small"
- fullWidth
+ label="Strasse" value={street}
+ onChange={e => setStreet(e.target.value)} size="small" fullWidth
/>
setHouseNumber(e.target.value)}
- size="small"
- fullWidth
+ label="Nr." value={houseNumber}
+ onChange={e => setHouseNumber(e.target.value)} size="small" fullWidth
/>
setPostalCode(e.target.value)}
- size="small"
- fullWidth
+ label="PLZ" value={postalCode}
+ onChange={e => setPostalCode(e.target.value)} size="small" fullWidth
/>
setCity(e.target.value)}
- size="small"
- fullWidth
+ label="Ort" value={city}
+ onChange={e => setCity(e.target.value)} size="small" fullWidth
/>
+ {/* Lage & Ausstrahlung */}
+
+
+ Lage & Ausstrahlung
+
+ Diese Angaben verbessern die Match-Qualität erheblich.
+
+
+
+ {SOFT_FACTORS.map(({ key, label }) => (
+ setSoftLevels(prev => ({ ...prev, [key]: e.target.value }))}
+ size="small"
+ fullWidth
+ >
+ {LEVEL_OPTIONS.map(o => (
+
+ ))}
+
+ ))}
+
+
+
+ {/* Technische Details */}
+
+ Technische Details (optional)
+
+ setFloor(e.target.value)}
+ size="small" type="number" fullWidth
+ placeholder="0 = EG"
+ />
+ setFitOut(e.target.value)} size="small" fullWidth
+ >
+ {FIT_OUT_OPTIONS.map(o => (
+
+ ))}
+
+ setParking(e.target.value)}
+ size="small" type="number" inputProps={{ min: 0 }} fullWidth
+ />
+ setCeilingHeight(e.target.value)}
+ size="small" type="number" inputProps={{ step: 0.1, min: 2 }} fullWidth
+ />
+
+
+
+ {/* Bilder */}
+
+ Bilder (optional)
+
+ setImageInput(e.target.value)}
+ onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); addImage() } }}
+ size="small"
+ fullWidth
+ placeholder="https://…"
+ />
+ }
+ onClick={addImage}
+ disabled={!imageInput.trim()}
+ sx={{ whiteSpace: 'nowrap', textTransform: 'none' }}
+ >
+ Hinzufügen
+
+
+ {images.length > 0 && (
+
+ {images.map((url, i) => (
+ 40 ? url.slice(0, 40) + '…' : url}
+ size="small"
+ onDelete={() => setImages(prev => prev.filter((_, j) => j !== i))}
+ sx={{ maxWidth: 300 }}
+ />
+ ))}
+
+ )}
+
+
+ {/* Kontakt */}
Kontakt (optional)
setContactName(e.target.value)}
- size="small"
- fullWidth
+ label="Name" value={contactName}
+ onChange={e => setContactName(e.target.value)} size="small" fullWidth
/>
setContactPhone(e.target.value)}
- size="small"
- fullWidth
+ label="Telefon" value={contactPhone}
+ onChange={e => setContactPhone(e.target.value)} size="small" fullWidth
/>
setContactEmail(e.target.value)}
- size="small"
- type="email"
- fullWidth
- sx={{ gridColumn: '1 / -1' }}
+ size="small" type="email" fullWidth sx={{ gridColumn: '1 / -1' }}
/>
- {error && (
- {error}
- )}
+ {error && {error}}
diff --git a/src/provider/IPropertyProvider.ts b/src/provider/IPropertyProvider.ts
index 4401a36..cbccd87 100644
--- a/src/provider/IPropertyProvider.ts
+++ b/src/provider/IPropertyProvider.ts
@@ -4,6 +4,7 @@ import type { AssetType, ResultType } from '../domain/enums'
export interface PropertyFilters {
assetType?: AssetType
resultType?: ResultType
+ sourceType?: string
city?: string
minAreaSqm?: number
maxRentPerSqm?: number
diff --git a/src/provider/MockupPropertyProvider.ts b/src/provider/MockupPropertyProvider.ts
index b3af131..a613acc 100644
--- a/src/provider/MockupPropertyProvider.ts
+++ b/src/provider/MockupPropertyProvider.ts
@@ -13,6 +13,7 @@ export const MockupPropertyProvider: IPropertyProvider = {
if (filters?.city) results = results.filter(p => p.location.city.toLowerCase().includes(filters.city!.toLowerCase()))
if (filters?.minAreaSqm) results = results.filter(p => p.areaSqm >= filters.minAreaSqm!)
if (filters?.maxRentPerSqm) results = results.filter(p => p.rentPricePerSqm <= filters.maxRentPerSqm!)
+ if (filters?.sourceType) results = results.filter(p => p.sourceType === filters.sourceType)
if (filters?.organizationId) results = results.filter(p => p.organizationId === filters.organizationId)
return results
},
diff --git a/src/services/aiService.ts b/src/services/aiService.ts
index 318a416..a5f8f6d 100644
--- a/src/services/aiService.ts
+++ b/src/services/aiService.ts
@@ -445,3 +445,78 @@ export const aiService = {
}
},
}
+
+// ── Listing Parser (Supply Side) ──────────────────────────────────────────────
+
+export interface ParsedListingData {
+ assetType?: string
+ areaSqm?: number
+ rentPerSqm?: number
+ city?: string
+ softLevels?: Record
+ parking?: number
+ fitOut?: string
+}
+
+export async function parseListingText(text: string): Promise {
+ await new Promise(r => setTimeout(r, 900))
+ const t = text.toLowerCase()
+ const result: ParsedListingData = {}
+
+ // Asset type
+ if (t.includes('laden') || t.includes('retail') || t.includes('shop') || t.includes('geschäft')) {
+ result.assetType = 'RETAIL'
+ } else if (t.includes('lager') || t.includes('logistik')) {
+ result.assetType = 'LOGISTICS'
+ } else if (t.includes('produktion') || t.includes('industrie') || t.includes('werkstatt')) {
+ result.assetType = 'PRODUCTION'
+ } else {
+ result.assetType = 'OFFICE'
+ }
+
+ // Area
+ const areaMatch = text.match(/(\d{2,5})\s*m²/) ?? text.match(/(\d{2,5})\s*Quadratmeter/)
+ if (areaMatch) result.areaSqm = parseInt(areaMatch[1])
+
+ // Price — if raw < 500 assume monthly → convert to annual
+ const priceMatch = text.match(/(\d{2,4})\s*(?:CHF|Fr\.?)\s*\/\s*m²/) ?? text.match(/CHF\s*(\d{2,4})/)
+ if (priceMatch) {
+ const raw = parseInt(priceMatch[1])
+ result.rentPerSqm = raw < 500 ? raw * 12 : raw
+ }
+
+ // City
+ const CITIES = ['Zürich', 'Bern', 'Basel', 'Genf', 'Lausanne', 'Zug', 'Winterthur', 'St. Gallen', 'Lugano', 'Luzern', 'Biel', 'Thun', 'Kloten', 'Opfikon', 'Uster']
+ for (const city of CITIES) {
+ if (t.includes(city.toLowerCase())) { result.city = city; break }
+ }
+
+ // Soft factors
+ const soft: Record = {}
+ soft.prestige = (t.includes('zentrum') || t.includes('innenstadt') || t.includes('hauptbahnhof') || t.includes('repräsentativ') || t.includes('prestige'))
+ ? 'HIGH' : (t.includes('gewerbegebiet') || t.includes('peripherie') || t.includes('industriezone'))
+ ? 'LOW' : 'MEDIUM'
+ soft.accessibility = (t.includes('bahnhof') || t.includes('s-bahn') || t.includes('tram') || t.includes('öv') || t.includes('zentrum'))
+ ? 'HIGH' : (t.includes('autobahn') || t.includes('gewerbegebiet'))
+ ? 'LOW' : 'MEDIUM'
+ if (result.assetType === 'RETAIL') {
+ soft.visibility = t.includes('fussgänger') || t.includes('passanten') || t.includes('frequenz') ? 'HIGH' : 'MEDIUM'
+ soft.footfall = soft.visibility
+ }
+ if (t.includes('nachhaltig') || t.includes('minergie') || t.includes('esg') || t.includes('zertifiziert')) soft.esg = 'HIGH'
+ if (t.includes('expansion') || t.includes('erweiter') || t.includes('wachstum')) soft.expansionPotential = 'HIGH'
+ if (result.city === 'Zug') soft.taxEnvironment = 'HIGH'
+ if (t.includes('hochwertig') || t.includes('premium') || t.includes('erstklassig')) {
+ soft.prestige = 'HIGH'
+ result.fitOut = 'PREMIUM'
+ } else if (t.includes('einfach') || t.includes('standard-ausbau')) {
+ result.fitOut = 'BASIC'
+ }
+ result.softLevels = soft
+
+ // Parking
+ const parkingMatch = text.match(/(\d+)\s*Parkplätze?/) ?? text.match(/(\d+)\s*PP/)
+ if (parkingMatch) result.parking = parseInt(parkingMatch[1])
+
+ return result
+}