e95490eb72
- Grundriss (FloorPlanSection): PropertyUnit.floorPlanUrl?, Property.floorPlanUrl?; FloorPlanSection in MatchDetail + PropertyDetail; FloorPlanUrlSection in NewListing-Formular - Listenansicht (MatchCardCompact): horizontales Layout mit 120px Bildstreifen, Score-Badge, Asset-Label-Overlay, alle 3 grünen Punkte, Anfrage-Button - Light Industrial → "Gewerbe" überall (NeedInput, NeedCardPreview, CriteriaReviewPanel, newListingConstants, MyListings, propertyHelpers) - "Zum Originalinserat"-Button nur bei Maison-Work-Objekten - Image-Pool: propertyImageResolver mit sequentiellem Pool-Index (keine doppelten Bilder), nur Innenaufnahmen, rotate()-Trick für Sub-Pools - Overlay-Labels (Objekttyp + Stadtteil) in LocationPreview + IntelligenceMatchCard Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
235 lines
7.8 KiB
TypeScript
235 lines
7.8 KiB
TypeScript
import { useState } from 'react'
|
||
import { useNavigate } from 'react-router'
|
||
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, useUpdateProperty, useRemoveProperty } from '../../hooks/useProperties'
|
||
import type { Property } from '../../domain/property'
|
||
|
||
const ASSET_LABELS: Record<string, string> = {
|
||
OFFICE: 'Büro',
|
||
RETAIL: 'Einzelhandel',
|
||
LIGHT_INDUSTRIAL: 'Gewerbe',
|
||
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 { data: listings = [], isLoading } = useProperties({ sourceType: 'DIRECT' })
|
||
const updateProperty = useUpdateProperty()
|
||
const removeProperty = useRemoveProperty()
|
||
|
||
const [confirmDelete, setConfirmDelete] = useState<Property | null>(null)
|
||
const [actionError, setActionError] = useState<string | null>(null)
|
||
|
||
function handleToggleStatus(p: Property) {
|
||
const next = p.status === 'ACTIVE' ? 'INACTIVE' : 'ACTIVE'
|
||
updateProperty.mutate(
|
||
{ id: p.id, input: { status: next } },
|
||
{
|
||
onError: () => {
|
||
setActionError('Status konnte nicht geändert werden.')
|
||
},
|
||
},
|
||
)
|
||
}
|
||
|
||
function handleDelete(p: Property) {
|
||
removeProperty.mutate(p.id, {
|
||
onSuccess: () => {
|
||
setConfirmDelete(null)
|
||
},
|
||
onError: () => {
|
||
setActionError('Inserat konnte nicht gelöscht werden.')
|
||
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>
|
||
<Tooltip title={p.status === 'ACTIVE' ? 'Deaktivieren' : 'Aktivieren'}>
|
||
<Switch
|
||
size="small"
|
||
checked={p.status === 'ACTIVE'}
|
||
onChange={() => handleToggleStatus(p)}
|
||
disabled={updateProperty.isPending}
|
||
sx={{ '& .MuiSwitch-thumb': { width: 14, height: 14 } }}
|
||
/>
|
||
</Tooltip>
|
||
</Box>
|
||
|
||
{/* Delete */}
|
||
<Box>
|
||
<Tooltip title="Inserat löschen">
|
||
<IconButton
|
||
size="small"
|
||
onClick={() => setConfirmDelete(p)}
|
||
disabled={removeProperty.isPending}
|
||
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>
|
||
)
|
||
}
|