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:
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user