Files
property-match/src/pages/supply/MyListings.tsx
T
Benjamin Sutter da50f3b5ea feat: premium redesign — DM Serif, refined palette, flat score badges
- Design tokens (ds.ts, theme.ts, scoreTheme.ts): new warm palette
  (#152642 navy, #f9f8f6 warm white, #e8e7e4 borders, #b8975a gold accent),
  flat score tier badges replacing CSS gradients, Inter + DM Serif Display typography
- Card components: white-background cards, DM Serif score numbers, max-2 badge
  chips with +N overflow tooltip, editorial score badge positioning
- Layout shell: gold left-accent nav active state, 64px top bar, outlined
  workspace chip, DM Serif page titles
- Shared atoms: GenericBadge (outlined/solid variants), ResultFilterBar
  (simplified chip styles), DecisionContextPanel (dot metrics, no left accent)
- Global replacement (118 files): #1e3a5f→#152642, #e2e8f0→#e8e7e4,
  #f4f6f9→#f9f8f6 — all handled via Node.js for proper UTF-8 safety

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 22:26:30 +02:00

483 lines
15 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 { memo, useState } from 'react'
import { useNavigate } from 'react-router'
import {
Alert,
Box,
Button,
Chip,
CircularProgress,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
Drawer,
IconButton,
Switch,
Tooltip,
Typography,
} from '@mui/material'
import { ArrowLeft, Plus, Trash2 } from 'lucide-react'
import { useProperties, useUpdateProperty, useRemoveProperty } from '../../hooks/useProperties'
import { useMatchesByProperty } from '../../hooks/useMatches'
import { useLatentNeedById } from '../../hooks/useLatentNeeds'
import { PropertyDetailView } from '../../components/supply'
import { PublicNeedDetail, OfferWizard } from '../../components/anfragencenter'
import { NEED_TO_LATENT_NEED } from '../../lib/needToLatentNeedMap'
import type { Property } from '../../domain/property'
import type { Match } from '../../domain/match'
const ASSET_LABELS: Record<string, string> = {
OFFICE: 'Büro',
RETAIL: 'Einzelhandel',
LIGHT_INDUSTRIAL: 'Gewerbe',
LOGISTICS: 'Logistik',
PRODUCTION: 'Produktion',
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'
}
// ─── Suchabo dialog ────────────────────────────────────────────────────────
function MatchListItem({
match,
onSelect,
}: {
match: Match
onSelect: (needId: string) => void
}) {
const latentNeedId = NEED_TO_LATENT_NEED[match.needId] ?? null
const { data: latentNeed } = useLatentNeedById(latentNeedId)
const companyName = latentNeed?.tenantCompany ?? match.needId
return (
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
px: 2, py: 1.5,
border: '1px solid #e2e8f0',
borderRadius: 1.5,
bgcolor: 'white',
gap: 1,
}}
>
<Box sx={{ minWidth: 0 }}>
<Typography variant="body2" sx={{ fontWeight: 600, color: '#0f172a', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{companyName}
</Typography>
{latentNeed && (
<Typography variant="caption" sx={{ color: '#64748b' }}>
{latentNeed.desiredLocation}
</Typography>
)}
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexShrink: 0 }}>
<Chip
label={`${match.matchScore}%`}
size="small"
sx={{
height: 20,
fontSize: '0.65rem',
fontWeight: 700,
bgcolor: `${matchColor(match.matchScore)}18`,
color: matchColor(match.matchScore),
}}
/>
{latentNeedId ? (
<Button
size="small"
variant="contained"
onClick={() => onSelect(match.needId)}
sx={{
textTransform: 'none',
fontSize: '0.75rem',
bgcolor: '#152642',
'&:hover': { bgcolor: '#162d4a' },
}}
>
Anschreiben
</Button>
) : (
<Tooltip title="Kein öffentliches Suchabo verknüpft">
<span>
<Button size="small" disabled sx={{ textTransform: 'none', fontSize: '0.75rem' }}>
Anschreiben
</Button>
</span>
</Tooltip>
)}
</Box>
</Box>
)
}
interface SuchaboDialogProps {
propertyId: string
matches: Match[]
onClose: () => void
}
function SuchaboDialogContent({ matches }: SuchaboDialogProps) {
const [selectedNeedId, setSelectedNeedId] = useState<string | null>(
matches.length === 1 ? matches[0].needId : null,
)
const latentNeedId = selectedNeedId ? (NEED_TO_LATENT_NEED[selectedNeedId] ?? null) : null
const { data: latentNeed, isLoading } = useLatentNeedById(latentNeedId)
const showList = !selectedNeedId
if (showList) {
return (
<Box sx={{ p: 2.5, display: 'flex', flexDirection: 'column', gap: 1 }}>
{matches.map(m => (
<MatchListItem key={m.id} match={m} onSelect={setSelectedNeedId} />
))}
</Box>
)
}
if (isLoading) {
return (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 6 }}>
<CircularProgress size={28} />
</Box>
)
}
if (!latentNeed) {
return (
<Box sx={{ p: 3, textAlign: 'center' }}>
<Typography variant="body2" color="text.secondary">Suchabo nicht gefunden.</Typography>
</Box>
)
}
return (
<Box>
{matches.length > 1 && (
<Box sx={{ px: 2, pt: 1.5 }}>
<Button
startIcon={<ArrowLeft size={14} />}
size="small"
onClick={() => setSelectedNeedId(null)}
sx={{ textTransform: 'none', color: '#64748b', fontWeight: 500 }}
>
Alle Suchabos
</Button>
</Box>
)}
<PublicNeedDetail need={latentNeed} />
</Box>
)
}
// ─── Row ───────────────────────────────────────────────────────────────────
interface RowProps {
p: Property
isLast: boolean
onDetail: (id: string) => void
onToggleStatus: (p: Property) => void
onDelete: (p: Property) => void
onChipClick: (propertyId: string, matches: Match[]) => void
updatePending: boolean
removePending: boolean
}
const ListingRow = memo(function ListingRow({
p, isLast, onDetail, onToggleStatus, onDelete, onChipClick, 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="Suchabos anzeigen und anschreiben">
<Chip
label={`${matches.length} Suchabo${matches.length !== 1 ? 's' : ''}`}
size="small"
onClick={() => onChipClick(p.id, matches)}
sx={{
height: 20,
fontSize: '0.65rem',
fontWeight: 600,
bgcolor: `${matchColor(topScore)}18`,
color: matchColor(topScore),
cursor: 'pointer',
'&:hover': { opacity: 0.85 },
}}
/>
</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>
)
})
// ─── Page ─────────────────────────────────────────────────────────────────
export default function MyListings() {
const navigate = useNavigate()
const { data: listings = [], isLoading } = useProperties({ sourceType: 'DIRECT' })
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)
const [suchaboDialog, setSuchaboDialog] = useState<{
propertyId: string
matches: Match[]
} | 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: 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>
<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: '#152642', '&: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: GRID_COLS,
px: 2, py: 1,
bgcolor: '#f8fafc',
borderBottom: '1px solid #e2e8f0',
}}>
{['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) => (
<ListingRow
key={p.id}
p={p}
isLast={i === listings.length - 1}
onDetail={setDetailId}
onToggleStatus={handleToggleStatus}
onDelete={setConfirmDelete}
onChipClick={(pid, m) => setSuchaboDialog({ propertyId: pid, matches: m })}
updatePending={updateProperty.isPending}
removePending={removeProperty.isPending}
/>
))}
</Box>
)}
{/* 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)} hideTabs={['Matchability', 'Marktsignale']} />
)}
</Drawer>
{/* Suchabo dialog */}
<Dialog
open={!!suchaboDialog}
onClose={() => setSuchaboDialog(null)}
maxWidth="sm"
fullWidth
slotProps={{ paper: { sx: { maxHeight: '85vh' } } }}
>
<DialogTitle sx={{ fontWeight: 700, fontSize: '1rem', pb: 1 }}>
Suchabos
{suchaboDialog && (
<Typography variant="body2" color="text.secondary" sx={{ fontWeight: 400 }}>
{suchaboDialog.matches.length} Suchabo{suchaboDialog.matches.length !== 1 ? 's' : ''} matchen dieses Inserat
</Typography>
)}
</DialogTitle>
{suchaboDialog && (
<SuchaboDialogContent
propertyId={suchaboDialog.propertyId}
matches={suchaboDialog.matches}
onClose={() => setSuchaboDialog(null)}
/>
)}
</Dialog>
{/* Delete confirmation */}
<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>
{/* OfferWizard — triggered by PublicNeedDetail's "Angebot erstellen" button */}
<OfferWizard />
</Box>
)
}