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:
@@ -1759,4 +1759,202 @@ export const mockMatches: Match[] = [
|
|||||||
createdAt: '2026-05-20T08:20:00Z',
|
createdAt: '2026-05-20T08:20:00Z',
|
||||||
updatedAt: '2026-05-20T08:20:00Z',
|
updatedAt: '2026-05-20T08:20:00Z',
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ───────────────────────────────────────────────────────────────────────────
|
||||||
|
// DIRECT LISTING MATCHES (prop-d01 … prop-d06)
|
||||||
|
// ───────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
{
|
||||||
|
id: 'match-065',
|
||||||
|
propertyId: 'prop-d01',
|
||||||
|
needId: 'need-005',
|
||||||
|
matchScore: 83,
|
||||||
|
matchStrength: MatchStrength.STRONG,
|
||||||
|
status: MatchStatus.PENDING_REVIEW,
|
||||||
|
scoreBreakdown: { hardMatchScore: 88, softFactorScore: 80, confidenceModifier: 0.80, dataQualityModifier: 0.78, totalScore: 83 },
|
||||||
|
positiveFactors: [
|
||||||
|
{ criterion: 'Fläche', weight: 0.17, score: 95, contribution: 16.2, explanation: '320m² liegt im Zielkorridor (300–700m²)' },
|
||||||
|
{ criterion: 'Standort', weight: 0.22, score: 90, contribution: 19.8, explanation: 'Zürich Altstetten — bevorzugte Region Zürich' },
|
||||||
|
{ criterion: 'Budget', weight: 0.17, score: 88, contribution: 14.9, explanation: 'CHF 380/m²/J unter Maximum CHF 576/m²/J' },
|
||||||
|
],
|
||||||
|
negativeFactors: [
|
||||||
|
{ criterion: 'ÖV', weight: 0.10, score: 65, contribution: 6.5, explanation: '8 Minuten zur S-Bahn — leicht über Wunsch-Maximum (10 Min.)' },
|
||||||
|
],
|
||||||
|
tradeoffs: [],
|
||||||
|
explainabilitySummary: 'Sehr guter Match für TechStart GmbH: Fläche, Standort Zürich und Budget passen. Minimaler ÖV-Abzug.',
|
||||||
|
confidenceLevel: 0.80,
|
||||||
|
riskLevel: RiskLevel.LOW,
|
||||||
|
uncertaintyIndicators: [],
|
||||||
|
organizationId: 'org-wincasa',
|
||||||
|
createdAt: '2026-04-15T09:00:00Z',
|
||||||
|
updatedAt: '2026-04-15T09:00:00Z',
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
id: 'match-066',
|
||||||
|
propertyId: 'prop-d01',
|
||||||
|
needId: 'need-001',
|
||||||
|
matchScore: 64,
|
||||||
|
matchStrength: MatchStrength.MODERATE,
|
||||||
|
status: MatchStatus.PENDING_REVIEW,
|
||||||
|
scoreBreakdown: { hardMatchScore: 68, softFactorScore: 72, confidenceModifier: 0.80, dataQualityModifier: 0.78, totalScore: 64 },
|
||||||
|
positiveFactors: [
|
||||||
|
{ criterion: 'Standort', weight: 0.18, score: 88, contribution: 15.8, explanation: 'Zürich Altstetten trifft bevorzugte Region' },
|
||||||
|
{ criterion: 'Budget', weight: 0.14, score: 90, contribution: 12.6, explanation: 'CHF 380/m²/J unter Maximum CHF 540/m²/J' },
|
||||||
|
],
|
||||||
|
negativeFactors: [
|
||||||
|
{ criterion: 'Fläche', weight: 0.18, score: 40, contribution: 7.2, explanation: '320m² unterschreitet Minimum von 600m² erheblich' },
|
||||||
|
],
|
||||||
|
tradeoffs: [
|
||||||
|
{ criterion: 'Fläche', concern: '320m² — halb so gross wie Mindestbedarf (600m²)', severity: 'HIGH', mitigation: 'Evtl. Erweiterungsoptionen im Gebäude prüfen' },
|
||||||
|
],
|
||||||
|
explainabilitySummary: 'Standort und Budget passen für Innovatech AG, aber Fläche deutlich zu klein. Nur als Notfall-Option.',
|
||||||
|
confidenceLevel: 0.80,
|
||||||
|
riskLevel: RiskLevel.MEDIUM,
|
||||||
|
uncertaintyIndicators: ['Fläche unter Minimum'],
|
||||||
|
organizationId: 'org-wincasa',
|
||||||
|
createdAt: '2026-04-15T09:05:00Z',
|
||||||
|
updatedAt: '2026-04-15T09:05:00Z',
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
id: 'match-067',
|
||||||
|
propertyId: 'prop-d02',
|
||||||
|
needId: 'need-002',
|
||||||
|
matchScore: 66,
|
||||||
|
matchStrength: MatchStrength.MODERATE,
|
||||||
|
status: MatchStatus.PENDING_REVIEW,
|
||||||
|
scoreBreakdown: { hardMatchScore: 70, softFactorScore: 78, confidenceModifier: 0.85, dataQualityModifier: 0.82, totalScore: 66 },
|
||||||
|
positiveFactors: [
|
||||||
|
{ criterion: 'Standort', weight: 0.18, score: 98, contribution: 17.6, explanation: 'Pratteln — explizit bevorzugte Region für Schweizer Logistik GmbH' },
|
||||||
|
{ criterion: 'Budget', weight: 0.18, score: 95, contribution: 17.1, explanation: 'CHF 145/m²/J weit unter Maximum CHF 216/m²/J' },
|
||||||
|
{ criterion: 'Hallenhöhe', weight: 0.10, score: 90, contribution: 9.0, explanation: '6.5m Hallenhöhe — Mindestanforderung 6m erfüllt' },
|
||||||
|
],
|
||||||
|
negativeFactors: [
|
||||||
|
{ criterion: 'Fläche', weight: 0.23, score: 22, contribution: 5.1, explanation: '580m² vs. Minimum 1500m² — erhebliche Unterdeckung' },
|
||||||
|
],
|
||||||
|
tradeoffs: [
|
||||||
|
{ criterion: 'Fläche', concern: 'Nur 39% des Mindestbedarfs gedeckt', severity: 'HIGH', mitigation: 'Kombinierbar mit benachbarten Einheiten falls verfügbar' },
|
||||||
|
],
|
||||||
|
explainabilitySummary: 'Idealer Standort und Preis für Schweizer Logistik GmbH — Fläche reicht aber nicht aus.',
|
||||||
|
confidenceLevel: 0.85,
|
||||||
|
riskLevel: RiskLevel.MEDIUM,
|
||||||
|
uncertaintyIndicators: ['Fläche zu gering'],
|
||||||
|
organizationId: 'org-wincasa',
|
||||||
|
createdAt: '2026-04-20T10:00:00Z',
|
||||||
|
updatedAt: '2026-04-20T10:00:00Z',
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
id: 'match-068',
|
||||||
|
propertyId: 'prop-d03',
|
||||||
|
needId: 'need-005',
|
||||||
|
matchScore: 61,
|
||||||
|
matchStrength: MatchStrength.MODERATE,
|
||||||
|
status: MatchStatus.PENDING_REVIEW,
|
||||||
|
scoreBreakdown: { hardMatchScore: 65, softFactorScore: 70, confidenceModifier: 0.78, dataQualityModifier: 0.74, totalScore: 61 },
|
||||||
|
positiveFactors: [
|
||||||
|
{ criterion: 'Budget', weight: 0.17, score: 100, contribution: 17.0, explanation: 'CHF 295/m²/J deutlich unter Maximum CHF 576/m²/J' },
|
||||||
|
{ criterion: 'Ausbau', weight: 0.08, score: 85, contribution: 6.8, explanation: 'Vollausbau trifft "Moderner Ausbau"-Anforderung' },
|
||||||
|
],
|
||||||
|
negativeFactors: [
|
||||||
|
{ criterion: 'Standort', weight: 0.22, score: 30, contribution: 6.6, explanation: 'Bern liegt ausserhalb bevorzugter Regionen Zürich/Zug' },
|
||||||
|
{ criterion: 'Fläche', weight: 0.17, score: 62, contribution: 10.5, explanation: '240m² leicht unter Minimum 300m²' },
|
||||||
|
],
|
||||||
|
tradeoffs: [
|
||||||
|
{ criterion: 'Standort', concern: 'Bern liegt nicht in Zürich/Zug — TechStart GmbH hat keine Niederlassung in Bern', severity: 'MEDIUM', mitigation: 'Falls Remote-Team Bern ausgebaut wird, attraktive Option' },
|
||||||
|
],
|
||||||
|
explainabilitySummary: 'Gutes Budget-Match für TechStart, aber Standort Bern ist nicht ihre bevorzugte Region.',
|
||||||
|
confidenceLevel: 0.78,
|
||||||
|
riskLevel: RiskLevel.MEDIUM,
|
||||||
|
uncertaintyIndicators: ['Standort ausserhalb Präferenz'],
|
||||||
|
organizationId: 'org-wincasa',
|
||||||
|
createdAt: '2026-04-22T11:00:00Z',
|
||||||
|
updatedAt: '2026-04-22T11:00:00Z',
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
id: 'match-069',
|
||||||
|
propertyId: 'prop-d04',
|
||||||
|
needId: 'need-004',
|
||||||
|
matchScore: 62,
|
||||||
|
matchStrength: MatchStrength.MODERATE,
|
||||||
|
status: MatchStatus.PENDING_REVIEW,
|
||||||
|
scoreBreakdown: { hardMatchScore: 68, softFactorScore: 72, confidenceModifier: 0.72, dataQualityModifier: 0.68, totalScore: 62 },
|
||||||
|
positiveFactors: [
|
||||||
|
{ criterion: 'Budget', weight: 0.12, score: 100, contribution: 12.0, explanation: 'CHF 360/m²/J weit unter Maximum CHF 1200/m²/J' },
|
||||||
|
{ criterion: 'Schaufensterfront', weight: 0.12, score: 100, contribution: 12.0, explanation: 'Schaufensterfront vorhanden — Pflichtkriterium erfüllt' },
|
||||||
|
{ criterion: 'Erdgeschoss', weight: 0.15, score: 100, contribution: 15.0, explanation: 'EG-Lage wie gefordert' },
|
||||||
|
],
|
||||||
|
negativeFactors: [
|
||||||
|
{ criterion: 'Standort', weight: 0.20, score: 32, contribution: 6.4, explanation: 'Winterthur vs. Zürich Innenstadt — andere Stadt' },
|
||||||
|
{ criterion: 'Fläche', weight: 0.10, score: 55, contribution: 5.5, explanation: '185m² leicht unter Minimum 200m²' },
|
||||||
|
],
|
||||||
|
tradeoffs: [
|
||||||
|
{ criterion: 'Standort', concern: 'Winterthur Stadtzentrum ≠ Zürich Innenstadt', severity: 'MEDIUM', mitigation: 'Winterthur hat hohe Fussgängerfrequenz, günstigere Miete' },
|
||||||
|
],
|
||||||
|
explainabilitySummary: 'Gute Retail-Attribute, aber Winterthur ist nicht das gesuchte Zürich-Innenstadt-Format.',
|
||||||
|
confidenceLevel: 0.72,
|
||||||
|
riskLevel: RiskLevel.MEDIUM,
|
||||||
|
uncertaintyIndicators: ['Standort differiert'],
|
||||||
|
organizationId: 'org-wincasa',
|
||||||
|
createdAt: '2026-02-15T14:00:00Z',
|
||||||
|
updatedAt: '2026-02-15T14:00:00Z',
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
id: 'match-070',
|
||||||
|
propertyId: 'prop-d05',
|
||||||
|
needId: 'need-002',
|
||||||
|
matchScore: 81,
|
||||||
|
matchStrength: MatchStrength.STRONG,
|
||||||
|
status: MatchStatus.PENDING_REVIEW,
|
||||||
|
scoreBreakdown: { hardMatchScore: 85, softFactorScore: 82, confidenceModifier: 0.88, dataQualityModifier: 0.85, totalScore: 81 },
|
||||||
|
positiveFactors: [
|
||||||
|
{ criterion: 'Standort', weight: 0.18, score: 98, contribution: 17.6, explanation: 'Muttenz — explizit bevorzugte Region für Schweizer Logistik GmbH' },
|
||||||
|
{ criterion: 'Budget', weight: 0.18, score: 100, contribution: 18.0, explanation: 'CHF 130/m²/J deutlich unter Maximum CHF 216/m²/J' },
|
||||||
|
{ criterion: 'Hallenhöhe', weight: 0.10, score: 100, contribution: 10.0, explanation: '9m — deutlich über Mindestanforderung 6m' },
|
||||||
|
{ criterion: 'Ladelucken', weight: 0.08, score: 100, contribution: 8.0, explanation: '4 Ladelucken vorhanden — Anforderung erfüllt' },
|
||||||
|
],
|
||||||
|
negativeFactors: [
|
||||||
|
{ criterion: 'Fläche', weight: 0.23, score: 65, contribution: 14.9, explanation: '1200m² leicht unter Minimum 1500m² (-20%)' },
|
||||||
|
],
|
||||||
|
tradeoffs: [
|
||||||
|
{ criterion: 'Fläche', concern: '1200m² vs. Minimum 1500m² — 20% Unterdeckung', severity: 'LOW', mitigation: 'Erweiterungspotenzial auf Nachbargrundstück vorhanden' },
|
||||||
|
],
|
||||||
|
explainabilitySummary: 'Starker Logistik-Match: Muttenz, Hallenhöhe, Budget und Ladelucken passen. Fläche leicht unter Minimum.',
|
||||||
|
confidenceLevel: 0.88,
|
||||||
|
riskLevel: RiskLevel.LOW,
|
||||||
|
uncertaintyIndicators: [],
|
||||||
|
organizationId: 'org-wincasa',
|
||||||
|
createdAt: '2026-05-01T08:00:00Z',
|
||||||
|
updatedAt: '2026-05-01T08:00:00Z',
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
id: 'match-071',
|
||||||
|
propertyId: 'prop-d06',
|
||||||
|
needId: 'need-005',
|
||||||
|
matchScore: 70,
|
||||||
|
matchStrength: MatchStrength.MODERATE,
|
||||||
|
status: MatchStatus.PENDING_REVIEW,
|
||||||
|
scoreBreakdown: { hardMatchScore: 75, softFactorScore: 72, confidenceModifier: 0.70, dataQualityModifier: 0.65, totalScore: 70 },
|
||||||
|
positiveFactors: [
|
||||||
|
{ criterion: 'Fläche', weight: 0.17, score: 95, contribution: 16.2, explanation: '450m² liegt im Zielkorridor (300–700m²)' },
|
||||||
|
{ criterion: 'Budget', weight: 0.17, score: 100, contribution: 17.0, explanation: 'CHF 260/m²/J deutlich unter Maximum CHF 576/m²/J' },
|
||||||
|
],
|
||||||
|
negativeFactors: [
|
||||||
|
{ criterion: 'Standort', weight: 0.22, score: 42, contribution: 9.2, explanation: 'Luzern liegt ausserhalb bevorzugter Regionen Zürich/Zug/Baar' },
|
||||||
|
],
|
||||||
|
tradeoffs: [
|
||||||
|
{ criterion: 'Standort', concern: 'Luzern ist 45 Min. von Zürich entfernt', severity: 'MEDIUM', mitigation: 'Attraktiver Preis und S-Bahn-Direktverbindung könnten Kompromiss sein' },
|
||||||
|
],
|
||||||
|
explainabilitySummary: 'Fläche und Budget passen sehr gut für TechStart GmbH. Luzern ist nicht ihre Wunschregion, aber preislich attraktiv.',
|
||||||
|
confidenceLevel: 0.70,
|
||||||
|
riskLevel: RiskLevel.MEDIUM,
|
||||||
|
uncertaintyIndicators: ['Standort ausserhalb Präferenz', 'Inserat inaktiv'],
|
||||||
|
organizationId: 'org-wincasa',
|
||||||
|
createdAt: '2026-01-15T09:00:00Z',
|
||||||
|
updatedAt: '2026-01-15T09:00:00Z',
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|||||||
+155
-89
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react'
|
import { memo, useState } from 'react'
|
||||||
import { useNavigate } from 'react-router'
|
import { useNavigate } from 'react-router'
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
DialogActions,
|
DialogActions,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
|
Drawer,
|
||||||
IconButton,
|
IconButton,
|
||||||
Switch,
|
Switch,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
@@ -17,6 +18,8 @@ import {
|
|||||||
} from '@mui/material'
|
} from '@mui/material'
|
||||||
import { Plus, Trash2 } from 'lucide-react'
|
import { Plus, Trash2 } from 'lucide-react'
|
||||||
import { useProperties, useUpdateProperty, useRemoveProperty } from '../../hooks/useProperties'
|
import { useProperties, useUpdateProperty, useRemoveProperty } from '../../hooks/useProperties'
|
||||||
|
import { useMatchesByProperty } from '../../hooks/useMatches'
|
||||||
|
import { PropertyDetailView } from '../../components/supply'
|
||||||
import type { Property } from '../../domain/property'
|
import type { Property } from '../../domain/property'
|
||||||
|
|
||||||
const ASSET_LABELS: Record<string, string> = {
|
const ASSET_LABELS: Record<string, string> = {
|
||||||
@@ -28,11 +31,131 @@ const ASSET_LABELS: Record<string, string> = {
|
|||||||
MIXED: 'Gemischt',
|
MIXED: 'Gemischt',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const GRID_COLS = '2fr 1fr 80px 100px 110px 120px 80px 48px'
|
||||||
|
|
||||||
function formatDate(iso?: string) {
|
function formatDate(iso?: string) {
|
||||||
if (!iso) return '–'
|
if (!iso) return '–'
|
||||||
return new Date(iso).toLocaleDateString('de-CH', { day: '2-digit', month: '2-digit', year: 'numeric' })
|
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() {
|
export default function MyListings() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
|
||||||
@@ -40,6 +163,7 @@ export default function MyListings() {
|
|||||||
const updateProperty = useUpdateProperty()
|
const updateProperty = useUpdateProperty()
|
||||||
const removeProperty = useRemoveProperty()
|
const removeProperty = useRemoveProperty()
|
||||||
|
|
||||||
|
const [detailId, setDetailId] = useState<string | null>(null)
|
||||||
const [confirmDelete, setConfirmDelete] = useState<Property | null>(null)
|
const [confirmDelete, setConfirmDelete] = useState<Property | null>(null)
|
||||||
const [actionError, setActionError] = useState<string | null>(null)
|
const [actionError, setActionError] = useState<string | null>(null)
|
||||||
|
|
||||||
@@ -47,19 +171,13 @@ export default function MyListings() {
|
|||||||
const next = p.status === 'ACTIVE' ? 'INACTIVE' : 'ACTIVE'
|
const next = p.status === 'ACTIVE' ? 'INACTIVE' : 'ACTIVE'
|
||||||
updateProperty.mutate(
|
updateProperty.mutate(
|
||||||
{ id: p.id, input: { status: next } },
|
{ 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) {
|
function handleDelete(p: Property) {
|
||||||
removeProperty.mutate(p.id, {
|
removeProperty.mutate(p.id, {
|
||||||
onSuccess: () => {
|
onSuccess: () => setConfirmDelete(null),
|
||||||
setConfirmDelete(null)
|
|
||||||
},
|
|
||||||
onError: () => {
|
onError: () => {
|
||||||
setActionError('Inserat konnte nicht gelöscht werden.')
|
setActionError('Inserat konnte nicht gelöscht werden.')
|
||||||
setConfirmDelete(null)
|
setConfirmDelete(null)
|
||||||
@@ -68,7 +186,7 @@ export default function MyListings() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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 sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 3 }}>
|
||||||
<Box>
|
<Box>
|
||||||
<Typography variant="h5" sx={{ fontWeight: 700 }}>Inserate</Typography>
|
<Typography variant="h5" sx={{ fontWeight: 700 }}>Inserate</Typography>
|
||||||
@@ -116,98 +234,46 @@ export default function MyListings() {
|
|||||||
{/* Header */}
|
{/* Header */}
|
||||||
<Box sx={{
|
<Box sx={{
|
||||||
display: 'grid',
|
display: 'grid',
|
||||||
gridTemplateColumns: '2fr 1fr 80px 100px 120px 80px 48px',
|
gridTemplateColumns: GRID_COLS,
|
||||||
px: 2, py: 1,
|
px: 2, py: 1,
|
||||||
bgcolor: '#f8fafc',
|
bgcolor: '#f8fafc',
|
||||||
borderBottom: '1px solid #e2e8f0',
|
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' }}>
|
<Typography key={h} variant="caption" sx={{ fontWeight: 600, color: '#64748b', fontSize: '0.65rem', textTransform: 'uppercase' }}>
|
||||||
{h}
|
{h}
|
||||||
</Typography>
|
</Typography>
|
||||||
))}
|
))}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{listings.map((p, i) => {
|
{listings.map((p, i) => (
|
||||||
const isLast = i === listings.length - 1
|
<ListingRow
|
||||||
return (
|
key={p.id}
|
||||||
<Box
|
p={p}
|
||||||
key={p.id}
|
isLast={i === listings.length - 1}
|
||||||
sx={{
|
onDetail={setDetailId}
|
||||||
display: 'grid',
|
onToggleStatus={handleToggleStatus}
|
||||||
gridTemplateColumns: '2fr 1fr 80px 100px 120px 80px 48px',
|
onDelete={setConfirmDelete}
|
||||||
px: 2, py: 1.25,
|
updatePending={updateProperty.isPending}
|
||||||
alignItems: 'center',
|
removePending={removeProperty.isPending}
|
||||||
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>
|
</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>
|
<Dialog open={!!confirmDelete} onClose={() => setConfirmDelete(null)} maxWidth="xs" fullWidth>
|
||||||
<DialogTitle>Inserat löschen?</DialogTitle>
|
<DialogTitle>Inserat löschen?</DialogTitle>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
|
|||||||
Reference in New Issue
Block a user