feat: Inserate — edit drawer + Suchabo match count per listing
Each listing row now shows a color-coded Suchabo chip (green ≥80 / amber ≥60 / red) with a tooltip listing individual scores. Clicking a row opens a right-side PropertyDetailView drawer with full edit + Matchability tab (which shows the matching Suchabos). Added 7 mock matches across the 6 direct listings. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+155
-89
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react'
|
||||
import { memo, useState } from 'react'
|
||||
import { useNavigate } from 'react-router'
|
||||
import {
|
||||
Alert,
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
Drawer,
|
||||
IconButton,
|
||||
Switch,
|
||||
Tooltip,
|
||||
@@ -17,6 +18,8 @@ import {
|
||||
} from '@mui/material'
|
||||
import { Plus, Trash2 } from 'lucide-react'
|
||||
import { useProperties, useUpdateProperty, useRemoveProperty } from '../../hooks/useProperties'
|
||||
import { useMatchesByProperty } from '../../hooks/useMatches'
|
||||
import { PropertyDetailView } from '../../components/supply'
|
||||
import type { Property } from '../../domain/property'
|
||||
|
||||
const ASSET_LABELS: Record<string, string> = {
|
||||
@@ -28,11 +31,131 @@ const ASSET_LABELS: Record<string, string> = {
|
||||
MIXED: 'Gemischt',
|
||||
}
|
||||
|
||||
const GRID_COLS = '2fr 1fr 80px 100px 110px 120px 80px 48px'
|
||||
|
||||
function formatDate(iso?: string) {
|
||||
if (!iso) return '–'
|
||||
return new Date(iso).toLocaleDateString('de-CH', { day: '2-digit', month: '2-digit', year: 'numeric' })
|
||||
}
|
||||
|
||||
function matchColor(score: number): string {
|
||||
if (score >= 80) return '#1a7a4a'
|
||||
if (score >= 60) return '#d97706'
|
||||
return '#dc2626'
|
||||
}
|
||||
|
||||
interface RowProps {
|
||||
p: Property
|
||||
isLast: boolean
|
||||
onDetail: (id: string) => void
|
||||
onToggleStatus: (p: Property) => void
|
||||
onDelete: (p: Property) => void
|
||||
updatePending: boolean
|
||||
removePending: boolean
|
||||
}
|
||||
|
||||
const ListingRow = memo(function ListingRow({ p, isLast, onDetail, onToggleStatus, onDelete, updatePending, removePending }: RowProps) {
|
||||
const { data: matches = [] } = useMatchesByProperty(p.id)
|
||||
const topScore = matches.length > 0 ? Math.max(...matches.map(m => m.matchScore)) : 0
|
||||
|
||||
return (
|
||||
<Box
|
||||
onClick={() => onDetail(p.id)}
|
||||
sx={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: GRID_COLS,
|
||||
px: 2, py: 1.25,
|
||||
alignItems: 'center',
|
||||
borderBottom: isLast ? 'none' : '1px solid #f1f5f9',
|
||||
cursor: 'pointer',
|
||||
'&: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>
|
||||
|
||||
{/* Match count */}
|
||||
<Box onClick={e => e.stopPropagation()}>
|
||||
{matches.length > 0 ? (
|
||||
<Tooltip title={matches.map(m => `${m.matchScore}% · need-${m.needId.slice(-3)}`).join('\n')}>
|
||||
<Chip
|
||||
label={`${matches.length} Suchabo${matches.length !== 1 ? 's' : ''}`}
|
||||
size="small"
|
||||
sx={{
|
||||
height: 20,
|
||||
fontSize: '0.65rem',
|
||||
fontWeight: 600,
|
||||
bgcolor: `${matchColor(topScore)}18`,
|
||||
color: matchColor(topScore),
|
||||
cursor: 'default',
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Typography variant="caption" sx={{ color: '#cbd5e1' }}>—</Typography>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Created */}
|
||||
<Typography variant="caption" sx={{ color: '#64748b' }}>
|
||||
{formatDate(p.createdAt)}
|
||||
</Typography>
|
||||
|
||||
{/* Status toggle */}
|
||||
<Box onClick={e => e.stopPropagation()}>
|
||||
<Tooltip title={p.status === 'ACTIVE' ? 'Deaktivieren' : 'Aktivieren'}>
|
||||
<Switch
|
||||
size="small"
|
||||
checked={p.status === 'ACTIVE'}
|
||||
onChange={() => onToggleStatus(p)}
|
||||
disabled={updatePending}
|
||||
sx={{ '& .MuiSwitch-thumb': { width: 14, height: 14 } }}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
|
||||
{/* Delete */}
|
||||
<Box onClick={e => e.stopPropagation()}>
|
||||
<Tooltip title="Inserat löschen">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => onDelete(p)}
|
||||
disabled={removePending}
|
||||
sx={{ color: '#94a3b8', '&:hover': { color: '#ef4444' } }}
|
||||
>
|
||||
<Trash2 size={15} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
})
|
||||
|
||||
export default function MyListings() {
|
||||
const navigate = useNavigate()
|
||||
|
||||
@@ -40,6 +163,7 @@ export default function MyListings() {
|
||||
const updateProperty = useUpdateProperty()
|
||||
const removeProperty = useRemoveProperty()
|
||||
|
||||
const [detailId, setDetailId] = useState<string | null>(null)
|
||||
const [confirmDelete, setConfirmDelete] = useState<Property | null>(null)
|
||||
const [actionError, setActionError] = useState<string | null>(null)
|
||||
|
||||
@@ -47,19 +171,13 @@ export default function MyListings() {
|
||||
const next = p.status === 'ACTIVE' ? 'INACTIVE' : 'ACTIVE'
|
||||
updateProperty.mutate(
|
||||
{ id: p.id, input: { status: next } },
|
||||
{
|
||||
onError: () => {
|
||||
setActionError('Status konnte nicht geändert werden.')
|
||||
},
|
||||
},
|
||||
{ onError: () => setActionError('Status konnte nicht geändert werden.') },
|
||||
)
|
||||
}
|
||||
|
||||
function handleDelete(p: Property) {
|
||||
removeProperty.mutate(p.id, {
|
||||
onSuccess: () => {
|
||||
setConfirmDelete(null)
|
||||
},
|
||||
onSuccess: () => setConfirmDelete(null),
|
||||
onError: () => {
|
||||
setActionError('Inserat konnte nicht gelöscht werden.')
|
||||
setConfirmDelete(null)
|
||||
@@ -68,7 +186,7 @@ export default function MyListings() {
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ maxWidth: 960, mx: 'auto', px: 3, py: 4 }}>
|
||||
<Box sx={{ maxWidth: 1040, mx: 'auto', px: 3, py: 4 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 3 }}>
|
||||
<Box>
|
||||
<Typography variant="h5" sx={{ fontWeight: 700 }}>Inserate</Typography>
|
||||
@@ -116,98 +234,46 @@ export default function MyListings() {
|
||||
{/* Header */}
|
||||
<Box sx={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '2fr 1fr 80px 100px 120px 80px 48px',
|
||||
gridTemplateColumns: GRID_COLS,
|
||||
px: 2, py: 1,
|
||||
bgcolor: '#f8fafc',
|
||||
borderBottom: '1px solid #e2e8f0',
|
||||
}}>
|
||||
{['Inserat', 'Ort', 'Fläche', 'Preis/m²/J', 'Erstellt', 'Aktiv', ''].map(h => (
|
||||
{['Inserat', 'Ort', 'Fläche', 'Preis/m²/J', 'Suchabos', '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>
|
||||
)
|
||||
})}
|
||||
{listings.map((p, i) => (
|
||||
<ListingRow
|
||||
key={p.id}
|
||||
p={p}
|
||||
isLast={i === listings.length - 1}
|
||||
onDetail={setDetailId}
|
||||
onToggleStatus={handleToggleStatus}
|
||||
onDelete={setConfirmDelete}
|
||||
updatePending={updateProperty.isPending}
|
||||
removePending={removeProperty.isPending}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Delete confirmation dialog */}
|
||||
{/* Detail / edit drawer */}
|
||||
<Drawer
|
||||
anchor="right"
|
||||
open={!!detailId}
|
||||
onClose={() => setDetailId(null)}
|
||||
slotProps={{ paper: { sx: { width: { xs: '100%', sm: 560 } } } }}
|
||||
>
|
||||
{detailId && (
|
||||
<PropertyDetailView propertyId={detailId} onClose={() => setDetailId(null)} />
|
||||
)}
|
||||
</Drawer>
|
||||
|
||||
{/* Delete confirmation */}
|
||||
<Dialog open={!!confirmDelete} onClose={() => setConfirmDelete(null)} maxWidth="xs" fullWidth>
|
||||
<DialogTitle>Inserat löschen?</DialogTitle>
|
||||
<DialogContent>
|
||||
|
||||
Reference in New Issue
Block a user