Files
property-match/src/pages/supply/MyListings.tsx
T
Benjamin Sutter e95490eb72 feat: Grundriss-Feature, Listenansicht mit Bildern, Gewerbe-Label, Image-Pool
- 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>
2026-05-24 22:12:43 +02:00

235 lines
7.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
)
}