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:
@@ -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() {
|
||||
<Route path="/supply/market-leads" element={<MarketLeads />} />
|
||||
<Route path="/supply/market-intelligence" element={<MarketIntelligence />} />
|
||||
<Route path="/supply/new-listing" element={<NewListing />} />
|
||||
<Route path="/supply/my-listings" element={<MyListings />} />
|
||||
</Route>
|
||||
|
||||
{/* Demand Workspace */}
|
||||
|
||||
@@ -89,6 +89,7 @@ const WORKSPACE_CONFIG: Record<WorkspaceType, WorkspaceConfig> = {
|
||||
{ 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 },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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<string, string> = {
|
||||
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<string | null>(null)
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null)
|
||||
const [confirmDelete, setConfirmDelete] = useState<Property | null>(null)
|
||||
const [actionError, setActionError] = useState<string | null>(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 (
|
||||
<Box sx={{ maxWidth: 960, mx: 'auto', px: 3, py: 4 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 3 }}>
|
||||
<Box>
|
||||
<Typography variant="h5" sx={{ fontWeight: 700 }}>Meine Inserate</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Direkt erstellte Inserate — unabhängig vom Portfolio
|
||||
</Typography>
|
||||
</Box>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<Plus size={16} />}
|
||||
onClick={() => navigate('/supply/new-listing')}
|
||||
sx={{ bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#162d4a' }, textTransform: 'none' }}
|
||||
>
|
||||
Neues Inserat
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{actionError && (
|
||||
<Alert severity="error" sx={{ mb: 2 }} onClose={() => setActionError(null)}>
|
||||
{actionError}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 8 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
) : listings.length === 0 ? (
|
||||
<Box sx={{ textAlign: 'center', py: 10, color: 'text.secondary' }}>
|
||||
<Typography variant="h6" sx={{ mb: 1, fontWeight: 500 }}>Noch keine Inserate</Typography>
|
||||
<Typography variant="body2" sx={{ mb: 3 }}>
|
||||
Erstellen Sie Ihr erstes direktes Inserat — ohne vollständiges Objekt im Portfolio.
|
||||
</Typography>
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<Plus size={15} />}
|
||||
onClick={() => navigate('/supply/new-listing')}
|
||||
sx={{ textTransform: 'none' }}
|
||||
>
|
||||
Erstes Inserat erstellen
|
||||
</Button>
|
||||
</Box>
|
||||
) : (
|
||||
<Box sx={{ border: '1px solid #e2e8f0', borderRadius: 1.5, overflow: 'hidden' }}>
|
||||
{/* Header */}
|
||||
<Box sx={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '2fr 1fr 80px 100px 120px 80px 48px',
|
||||
px: 2, py: 1,
|
||||
bgcolor: '#f8fafc',
|
||||
borderBottom: '1px solid #e2e8f0',
|
||||
}}>
|
||||
{['Inserat', 'Ort', 'Fläche', 'Preis/m²/J', 'Erstellt', 'Aktiv', ''].map(h => (
|
||||
<Typography key={h} variant="caption" sx={{ fontWeight: 600, color: '#64748b', fontSize: '0.65rem', textTransform: 'uppercase' }}>
|
||||
{h}
|
||||
</Typography>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
{listings.map((p, i) => {
|
||||
const isLast = i === listings.length - 1
|
||||
return (
|
||||
<Box
|
||||
key={p.id}
|
||||
sx={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '2fr 1fr 80px 100px 120px 80px 48px',
|
||||
px: 2, py: 1.25,
|
||||
alignItems: 'center',
|
||||
borderBottom: isLast ? 'none' : '1px solid #f1f5f9',
|
||||
'&:hover': { bgcolor: '#f8fafc' },
|
||||
transition: 'background 0.1s',
|
||||
}}
|
||||
>
|
||||
{/* Title + type */}
|
||||
<Box>
|
||||
<Typography variant="body2" sx={{ fontWeight: 500, color: '#0f172a', lineHeight: 1.3 }}>
|
||||
{p.title}
|
||||
</Typography>
|
||||
<Chip
|
||||
label={ASSET_LABELS[p.assetType] ?? p.assetType}
|
||||
size="small"
|
||||
sx={{ height: 16, fontSize: '0.6rem', mt: 0.25, bgcolor: '#f1f5f9', color: '#475569' }}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* City */}
|
||||
<Typography variant="body2" sx={{ color: '#374151' }}>
|
||||
{p.location.city}
|
||||
</Typography>
|
||||
|
||||
{/* Area */}
|
||||
<Typography variant="body2" sx={{ color: '#374151' }}>
|
||||
{p.areaSqm.toLocaleString('de-CH')} m²
|
||||
</Typography>
|
||||
|
||||
{/* Rent */}
|
||||
<Typography variant="body2" sx={{ color: '#374151' }}>
|
||||
CHF {p.rentPricePerSqm.toLocaleString('de-CH')}
|
||||
</Typography>
|
||||
|
||||
{/* Created */}
|
||||
<Typography variant="caption" sx={{ color: '#64748b' }}>
|
||||
{formatDate(p.createdAt)}
|
||||
</Typography>
|
||||
|
||||
{/* Status toggle */}
|
||||
<Box>
|
||||
{togglingId === p.id ? (
|
||||
<CircularProgress size={16} />
|
||||
) : (
|
||||
<Tooltip title={p.status === 'ACTIVE' ? 'Deaktivieren' : 'Aktivieren'}>
|
||||
<Switch
|
||||
size="small"
|
||||
checked={p.status === 'ACTIVE'}
|
||||
onChange={() => handleToggleStatus(p)}
|
||||
sx={{ '& .MuiSwitch-thumb': { width: 14, height: 14 } }}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Delete */}
|
||||
<Box>
|
||||
{deletingId === p.id ? (
|
||||
<CircularProgress size={16} />
|
||||
) : (
|
||||
<Tooltip title="Inserat löschen">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => setConfirmDelete(p)}
|
||||
sx={{ color: '#94a3b8', '&:hover': { color: '#ef4444' } }}
|
||||
>
|
||||
<Trash2 size={15} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
})}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Delete confirmation dialog */}
|
||||
<Dialog open={!!confirmDelete} onClose={() => setConfirmDelete(null)} maxWidth="xs" fullWidth>
|
||||
<DialogTitle>Inserat löschen?</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography variant="body2">
|
||||
<strong>{confirmDelete?.title}</strong> wird unwiderruflich gelöscht.
|
||||
</Typography>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setConfirmDelete(null)} sx={{ textTransform: 'none' }}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button
|
||||
color="error"
|
||||
variant="contained"
|
||||
onClick={() => confirmDelete && handleDelete(confirmDelete)}
|
||||
sx={{ textTransform: 'none' }}
|
||||
>
|
||||
Löschen
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
+308
-95
@@ -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 }} />
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
},
|
||||
|
||||
@@ -445,3 +445,78 @@ export const aiService = {
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
// ── Listing Parser (Supply Side) ──────────────────────────────────────────────
|
||||
|
||||
export interface ParsedListingData {
|
||||
assetType?: string
|
||||
areaSqm?: number
|
||||
rentPerSqm?: number
|
||||
city?: string
|
||||
softLevels?: Record<string, string>
|
||||
parking?: number
|
||||
fitOut?: string
|
||||
}
|
||||
|
||||
export async function parseListingText(text: string): Promise<ParsedListingData> {
|
||||
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<string, string> = {}
|
||||
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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user