da50f3b5ea
- 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>
483 lines
15 KiB
TypeScript
483 lines
15 KiB
TypeScript
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>
|
||
)
|
||
}
|