feat: Inserat erstellen — direct listing flow for units and standalone
- New /supply/new-listing page (Path A: pre-filled from portfolio unit, Path B: standalone blank form); creates EXTERNAL_MARKET + sourceType DIRECT - "+ Inserat" button on available units in PropertyDetailView navigates with prefilled address/area/rent - Nav entry "Neues Inserat" added to supply sidebar - SourceProvenancePanel hidden for DIRECT source listings (no external source) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -27,6 +27,7 @@ const FutureAvailability = lazy(() => import('./pages/supply/FutureAvailability'
|
|||||||
const DataQuality = lazy(() => import('./pages/supply/DataQuality'))
|
const DataQuality = lazy(() => import('./pages/supply/DataQuality'))
|
||||||
const ReminderManager = lazy(() => import('./pages/supply/ReminderManager'))
|
const ReminderManager = lazy(() => import('./pages/supply/ReminderManager'))
|
||||||
const MarketLeads = lazy(() => import('./pages/supply/MarketLeads'))
|
const MarketLeads = lazy(() => import('./pages/supply/MarketLeads'))
|
||||||
|
const NewListing = lazy(() => import('./pages/supply/NewListing'))
|
||||||
|
|
||||||
const AISearch = lazy(() => import('./pages/demand/AISearch'))
|
const AISearch = lazy(() => import('./pages/demand/AISearch'))
|
||||||
const Results = lazy(() => import('./pages/demand/Results'))
|
const Results = lazy(() => import('./pages/demand/Results'))
|
||||||
@@ -62,6 +63,7 @@ function App() {
|
|||||||
<Route path="/supply/reminder-manager" element={<ReminderManager />} />
|
<Route path="/supply/reminder-manager" element={<ReminderManager />} />
|
||||||
<Route path="/supply/market-leads" element={<MarketLeads />} />
|
<Route path="/supply/market-leads" element={<MarketLeads />} />
|
||||||
<Route path="/supply/market-intelligence" element={<MarketIntelligence />} />
|
<Route path="/supply/market-intelligence" element={<MarketIntelligence />} />
|
||||||
|
<Route path="/supply/new-listing" element={<NewListing />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
{/* Demand Workspace */}
|
{/* Demand Workspace */}
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ import {
|
|||||||
Menu,
|
Menu,
|
||||||
Kanban,
|
Kanban,
|
||||||
BellRing,
|
BellRing,
|
||||||
|
Plus,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import type { LucideIcon } from 'lucide-react'
|
import type { LucideIcon } from 'lucide-react'
|
||||||
import { OrganizationContextBadge } from './OrganizationContextBadge'
|
import { OrganizationContextBadge } from './OrganizationContextBadge'
|
||||||
@@ -88,6 +89,7 @@ const WORKSPACE_CONFIG: Record<WorkspaceType, WorkspaceConfig> = {
|
|||||||
{ path: '/supply/anfragen', label: 'Anfragencenter', icon: MessageSquare },
|
{ path: '/supply/anfragen', label: 'Anfragencenter', icon: MessageSquare },
|
||||||
{ path: '/supply/data-quality', label: 'Datenpflege', icon: CheckSquare },
|
{ path: '/supply/data-quality', label: 'Datenpflege', icon: CheckSquare },
|
||||||
{ path: '/supply/market-intelligence', label: 'Markt Intelligence', icon: Radar },
|
{ path: '/supply/market-intelligence', label: 'Markt Intelligence', icon: Radar },
|
||||||
|
{ path: '/supply/new-listing', label: 'Neues Inserat', icon: Plus },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
[WorkspaceType.DEMAND]: {
|
[WorkspaceType.DEMAND]: {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ interface Props {
|
|||||||
|
|
||||||
export function SourceProvenancePanel({ property }: Props) {
|
export function SourceProvenancePanel({ property }: Props) {
|
||||||
if (!property) return null
|
if (!property) return null
|
||||||
|
if (property.sourceType === 'DIRECT' || property.sourceMeta?.sourceType === 'DIRECT') return null
|
||||||
|
|
||||||
const freshness = property.dataQuality?.freshness
|
const freshness = property.dataQuality?.freshness
|
||||||
const freshnessMeta = freshness ? (FRESHNESS_META[freshness] ?? null) : null
|
const freshnessMeta = freshness ? (FRESHNESS_META[freshness] ?? null) : null
|
||||||
|
|||||||
@@ -106,6 +106,7 @@ function MatchPill({ m }: { m: UnitNeedMatch }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function UnitStructurePanel({ p }: { p: Property }) {
|
function UnitStructurePanel({ p }: { p: Property }) {
|
||||||
|
const navigate = useNavigate()
|
||||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
||||||
const [expandedUnit, setExpandedUnit] = useState<string | null>(null)
|
const [expandedUnit, setExpandedUnit] = useState<string | null>(null)
|
||||||
|
|
||||||
@@ -204,6 +205,28 @@ function UnitStructurePanel({ p }: { p: Property }) {
|
|||||||
{u.isFlexible && (
|
{u.isFlexible && (
|
||||||
<Chip label="Teilfläche möglich" size="small" sx={{ height: 16, fontSize: '0.58rem', bgcolor: '#eff6ff', color: '#1d4ed8', border: '1px solid #bfdbfe' }} />
|
<Chip label="Teilfläche möglich" size="small" sx={{ height: 16, fontSize: '0.58rem', bgcolor: '#eff6ff', color: '#1d4ed8', border: '1px solid #bfdbfe' }} />
|
||||||
)}
|
)}
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="text"
|
||||||
|
sx={{ height: 16, fontSize: '0.58rem', p: 0, minWidth: 0, color: '#7c3aed', textTransform: 'none', lineHeight: 1 }}
|
||||||
|
onClick={() => navigate('/supply/new-listing', {
|
||||||
|
state: {
|
||||||
|
prefill: {
|
||||||
|
assetType: p.assetType,
|
||||||
|
street: p.address?.street,
|
||||||
|
houseNumber: p.address?.houseNumber,
|
||||||
|
postalCode: p.address?.postalCode,
|
||||||
|
city: p.address?.city,
|
||||||
|
areaSqm: u.offeredSqm ?? u.areaSqm,
|
||||||
|
rentPricePerSqm: u.rentPricePerSqm ?? p.rentPricePerSqm,
|
||||||
|
unitLabel: u.unitLabel,
|
||||||
|
propertyId: p.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
+ Inserat
|
||||||
|
</Button>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<Typography variant="caption" sx={{ color: '#64748b' }} noWrap>{u.currentTenant ?? 'Vermietet'}</Typography>
|
<Typography variant="caption" sx={{ color: '#64748b' }} noWrap>{u.currentTenant ?? 'Vermietet'}</Typography>
|
||||||
|
|||||||
@@ -0,0 +1,310 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { useLocation, useNavigate } from 'react-router'
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
CircularProgress,
|
||||||
|
Divider,
|
||||||
|
MenuItem,
|
||||||
|
TextField,
|
||||||
|
Typography,
|
||||||
|
} from '@mui/material'
|
||||||
|
import { ArrowLeft, CheckCircle } from 'lucide-react'
|
||||||
|
import { AssetType, ResultType, AvailabilityStatus } from '../../domain/enums'
|
||||||
|
import { propertyService } from '../../services/propertyService'
|
||||||
|
import type { CreatePropertyInput } from '../../domain/property'
|
||||||
|
|
||||||
|
const ASSET_TYPE_LABELS: Record<string, string> = {
|
||||||
|
OFFICE: 'Büro',
|
||||||
|
RETAIL: 'Einzelhandel',
|
||||||
|
LIGHT_INDUSTRIAL: 'Leichtindustrie',
|
||||||
|
LOGISTICS: 'Logistik',
|
||||||
|
PRODUCTION: 'Produktion',
|
||||||
|
MIXED: 'Gemischt',
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LocationState {
|
||||||
|
prefill?: {
|
||||||
|
assetType?: string
|
||||||
|
street?: string
|
||||||
|
houseNumber?: string
|
||||||
|
postalCode?: string
|
||||||
|
city?: string
|
||||||
|
areaSqm?: number
|
||||||
|
rentPricePerSqm?: number
|
||||||
|
unitLabel?: string
|
||||||
|
propertyId?: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function NewListing() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const { state } = useLocation() as { state: LocationState | null }
|
||||||
|
const pre = state?.prefill ?? {}
|
||||||
|
|
||||||
|
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 [availableFrom, setAvailableFrom] = useState('')
|
||||||
|
const [description, setDescription] = useState('')
|
||||||
|
const [contactName, setContactName] = useState('')
|
||||||
|
const [contactEmail, setContactEmail] = useState('')
|
||||||
|
const [contactPhone, setContactPhone] = useState('')
|
||||||
|
|
||||||
|
const [submitting, setSubmitting] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [created, setCreated] = useState(false)
|
||||||
|
|
||||||
|
const isPrefilled = !!pre.propertyId
|
||||||
|
|
||||||
|
function validate(): string | null {
|
||||||
|
if (!street.trim()) return 'Strasse erforderlich'
|
||||||
|
if (!postalCode.trim()) return 'PLZ erforderlich'
|
||||||
|
if (!city.trim()) return 'Ort erforderlich'
|
||||||
|
if (!areaSqm || isNaN(Number(areaSqm)) || Number(areaSqm) <= 0) return 'Gültige Fläche eingeben'
|
||||||
|
if (!rentPerSqm || isNaN(Number(rentPerSqm)) || Number(rentPerSqm) <= 0) return 'Gültigen Mietpreis eingeben'
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
const err = validate()
|
||||||
|
if (err) { setError(err); return }
|
||||||
|
setError(null)
|
||||||
|
setSubmitting(true)
|
||||||
|
|
||||||
|
const area = Number(areaSqm)
|
||||||
|
const rent = Number(rentPerSqm)
|
||||||
|
|
||||||
|
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' },
|
||||||
|
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,
|
||||||
|
dataQuality: {
|
||||||
|
score: 1.0,
|
||||||
|
missingCriticalFields: [],
|
||||||
|
missingOptionalFields: [],
|
||||||
|
freshness: 'FRESH',
|
||||||
|
warnings: [],
|
||||||
|
},
|
||||||
|
status: 'ACTIVE',
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await propertyService.create(input)
|
||||||
|
setCreated(true)
|
||||||
|
} catch {
|
||||||
|
setError('Fehler beim Erstellen des Inserats. Bitte erneut versuchen.')
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (created) {
|
||||||
|
return (
|
||||||
|
<Box sx={{ maxWidth: 560, 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.
|
||||||
|
</Typography>
|
||||||
|
<Box sx={{ display: 'flex', gap: 2, justifyContent: 'center' }}>
|
||||||
|
<Button variant="outlined" onClick={() => navigate('/supply/properties')}>
|
||||||
|
Zu Meine Objekte
|
||||||
|
</Button>
|
||||||
|
<Button variant="contained" sx={{ bgcolor: '#1e3a5f' }} onClick={() => {
|
||||||
|
setCreated(false)
|
||||||
|
setStreet(''); setHouseNumber(''); setPostalCode(''); setCity('')
|
||||||
|
setAreaSqm(''); setRentPerSqm(''); setAvailableFrom(''); setDescription('')
|
||||||
|
setContactName(''); setContactEmail(''); setContactPhone('')
|
||||||
|
}}>
|
||||||
|
Weiteres Inserat
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ maxWidth: 720, mx: 'auto', px: 3, py: 4 }}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, mb: 3 }}>
|
||||||
|
<Button
|
||||||
|
variant="text"
|
||||||
|
size="small"
|
||||||
|
startIcon={<ArrowLeft size={16} />}
|
||||||
|
onClick={() => navigate(-1)}
|
||||||
|
sx={{ color: '#64748b', textTransform: 'none', px: 0 }}
|
||||||
|
>
|
||||||
|
Zurück
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Typography variant="h5" sx={{ fontWeight: 700, mb: 0.5 }}>Neues Inserat erstellen</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
|
||||||
|
{isPrefilled
|
||||||
|
? `Einheit ${pre.unitLabel ?? ''} aus Portfolio vorausgefüllt — Angaben prüfen und veröffentlichen.`
|
||||||
|
: 'Fläche direkt inserieren — ohne vollständiges Objekt im Portfolio.'}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<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
|
||||||
|
>
|
||||||
|
{Object.entries(ASSET_TYPE_LABELS).map(([v, l]) => (
|
||||||
|
<MenuItem key={v} value={v}>{l}</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
label="Fläche (m²)"
|
||||||
|
value={areaSqm}
|
||||||
|
onChange={e => setAreaSqm(e.target.value)}
|
||||||
|
size="small"
|
||||||
|
type="number"
|
||||||
|
inputProps={{ min: 1 }}
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
label="Mietpreis (CHF/m²/Jahr)"
|
||||||
|
value={rentPerSqm}
|
||||||
|
onChange={e => setRentPerSqm(e.target.value)}
|
||||||
|
size="small"
|
||||||
|
type="number"
|
||||||
|
inputProps={{ min: 1 }}
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
label="Verfügbar ab"
|
||||||
|
value={availableFrom}
|
||||||
|
onChange={e => setAvailableFrom(e.target.value)}
|
||||||
|
size="small"
|
||||||
|
type="date"
|
||||||
|
slotProps={{ inputLabel: { shrink: true } }}
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
label="Beschreibung (optional)"
|
||||||
|
value={description}
|
||||||
|
onChange={e => setDescription(e.target.value)}
|
||||||
|
size="small"
|
||||||
|
multiline
|
||||||
|
rows={3}
|
||||||
|
fullWidth
|
||||||
|
sx={{ mt: 2 }}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<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
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
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
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Ort"
|
||||||
|
value={city}
|
||||||
|
onChange={e => setCity(e.target.value)}
|
||||||
|
size="small"
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<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
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Telefon"
|
||||||
|
value={contactPhone}
|
||||||
|
onChange={e => setContactPhone(e.target.value)}
|
||||||
|
size="small"
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="E-Mail"
|
||||||
|
value={contactEmail}
|
||||||
|
onChange={e => setContactEmail(e.target.value)}
|
||||||
|
size="small"
|
||||||
|
type="email"
|
||||||
|
fullWidth
|
||||||
|
sx={{ gridColumn: '1 / -1' }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Divider sx={{ mb: 2 }} />
|
||||||
|
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 2 }}>
|
||||||
|
<Button variant="outlined" onClick={() => navigate(-1)} disabled={submitting}>
|
||||||
|
Abbrechen
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
sx={{ bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#162d4a' } }}
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={submitting}
|
||||||
|
startIcon={submitting ? <CircularProgress size={14} color="inherit" /> : null}
|
||||||
|
>
|
||||||
|
{submitting ? 'Wird erstellt…' : 'Inserat veröffentlichen'}
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user