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
+247
View File
@@ -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>
)
}