From 67d3d06a53a8bcf45fb09dd651fbf6555eb6f1eb Mon Sep 17 00:00:00 2001 From: Benjamin Sutter Date: Thu, 21 May 2026 21:10:41 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20Inserat=20erstellen=20=E2=80=94=20direc?= =?UTF-8?q?t=20listing=20flow=20for=20units=20and=20standalone?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- src/App.tsx | 2 + src/components/layout/AppShell.tsx | 2 + .../match-detail/SourceProvenancePanel.tsx | 1 + src/components/supply/PropertyDetailView.tsx | 23 ++ src/pages/supply/NewListing.tsx | 310 ++++++++++++++++++ 5 files changed, 338 insertions(+) create mode 100644 src/pages/supply/NewListing.tsx diff --git a/src/App.tsx b/src/App.tsx index 9832131..2f94c41 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -27,6 +27,7 @@ const FutureAvailability = lazy(() => import('./pages/supply/FutureAvailability' 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 AISearch = lazy(() => import('./pages/demand/AISearch')) const Results = lazy(() => import('./pages/demand/Results')) @@ -62,6 +63,7 @@ function App() { } /> } /> } /> + } /> {/* Demand Workspace */} diff --git a/src/components/layout/AppShell.tsx b/src/components/layout/AppShell.tsx index e07056b..8307067 100644 --- a/src/components/layout/AppShell.tsx +++ b/src/components/layout/AppShell.tsx @@ -39,6 +39,7 @@ import { Menu, Kanban, BellRing, + Plus, } from 'lucide-react' import type { LucideIcon } from 'lucide-react' import { OrganizationContextBadge } from './OrganizationContextBadge' @@ -88,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/new-listing', label: 'Neues Inserat', icon: Plus }, ], }, [WorkspaceType.DEMAND]: { diff --git a/src/components/match-detail/SourceProvenancePanel.tsx b/src/components/match-detail/SourceProvenancePanel.tsx index 10c6718..aa0380d 100644 --- a/src/components/match-detail/SourceProvenancePanel.tsx +++ b/src/components/match-detail/SourceProvenancePanel.tsx @@ -13,6 +13,7 @@ interface Props { export function SourceProvenancePanel({ property }: Props) { if (!property) return null + if (property.sourceType === 'DIRECT' || property.sourceMeta?.sourceType === 'DIRECT') return null const freshness = property.dataQuality?.freshness const freshnessMeta = freshness ? (FRESHNESS_META[freshness] ?? null) : null diff --git a/src/components/supply/PropertyDetailView.tsx b/src/components/supply/PropertyDetailView.tsx index 1ea10ca..df57862 100644 --- a/src/components/supply/PropertyDetailView.tsx +++ b/src/components/supply/PropertyDetailView.tsx @@ -106,6 +106,7 @@ function MatchPill({ m }: { m: UnitNeedMatch }) { } function UnitStructurePanel({ p }: { p: Property }) { + const navigate = useNavigate() const [selectedIds, setSelectedIds] = useState>(new Set()) const [expandedUnit, setExpandedUnit] = useState(null) @@ -204,6 +205,28 @@ function UnitStructurePanel({ p }: { p: Property }) { {u.isFlexible && ( )} + ) : ( {u.currentTenant ?? 'Vermietet'} diff --git a/src/pages/supply/NewListing.tsx b/src/pages/supply/NewListing.tsx new file mode 100644 index 0000000..def9aa6 --- /dev/null +++ b/src/pages/supply/NewListing.tsx @@ -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 = { + 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(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 ( + + + Inserat erstellt + + Das Inserat wurde erfolgreich veröffentlicht und ist jetzt für passende Suchanfragen sichtbar. + + + + + + + ) + } + + return ( + + + + + + Neues Inserat erstellen + + {isPrefilled + ? `Einheit ${pre.unitLabel ?? ''} aus Portfolio vorausgefüllt — Angaben prüfen und veröffentlichen.` + : 'Fläche direkt inserieren — ohne vollständiges Objekt im Portfolio.'} + + + + Flächendetails + + + setAssetType(e.target.value)} + size="small" + fullWidth + > + {Object.entries(ASSET_TYPE_LABELS).map(([v, l]) => ( + {l} + ))} + + + setAreaSqm(e.target.value)} + size="small" + type="number" + inputProps={{ min: 1 }} + fullWidth + /> + + setRentPerSqm(e.target.value)} + size="small" + type="number" + inputProps={{ min: 1 }} + fullWidth + /> + + setAvailableFrom(e.target.value)} + size="small" + type="date" + slotProps={{ inputLabel: { shrink: true } }} + fullWidth + /> + + + setDescription(e.target.value)} + size="small" + multiline + rows={3} + fullWidth + sx={{ mt: 2 }} + /> + + + + Adresse + + setStreet(e.target.value)} + size="small" + fullWidth + /> + setHouseNumber(e.target.value)} + size="small" + fullWidth + /> + + + setPostalCode(e.target.value)} + size="small" + fullWidth + /> + setCity(e.target.value)} + size="small" + fullWidth + /> + + + + + Kontakt (optional) + + setContactName(e.target.value)} + size="small" + fullWidth + /> + setContactPhone(e.target.value)} + size="small" + fullWidth + /> + setContactEmail(e.target.value)} + size="small" + type="email" + fullWidth + sx={{ gridColumn: '1 / -1' }} + /> + + + + {error && ( + {error} + )} + + + + + + + + + ) +}