d200d4e930
Setzt das Umsetzungsbriefing Runde 4 im bestehenden Frontend um. Informationsarchitektur - «Teamübersicht» heisst «Meine Agenten»; ihr bisheriger Inhalt (Kreisgrafik, Auswertung, Verbindungszusammenfassung) ist entfallen. - Personalverwaltung, Bearbeitungsverlauf und Kanäle & Systeme sind jetzt Reiter derselben Seite (?section=), gestaltet wie die Auswahl im Bearbeitungsverlauf. Es wird nur der gewählte Bereich gerendert. - Die alten Unterseitenpfade leiten um, damit Lesezeichen nicht brechen. Agentenbestand - Reto und Lea vollständig entfernt — aus Navigation, Dossiers, Protokoll, Verbindungen, Vorgängen und Porträtbestand. - Retos Aufgaben liegen bei Bruno: Nachbereitung, WhatsApp-Anruf, Protokoll und Kundennotiz, Ablage im CRM. - Livia ist «Exposé Master»: Lageberichte, Inserate, Angebotsbroschüren. - Sidebar führt Ferdi, Bruno, Livia, Nora, Sina mit Porträt und Funktion. Agentenseiten - Ein gemeinsamer AgentWorkspaceHero auf allen fünf Seiten. - Ferdi: Auswertungskarten, Priorität und Typfarben entfallen; Fälligkeit nur bei fünf Tagen oder weniger rot; Objektlinks nach «Meine Objekte»; neu die Terminplanung im verbundenen Kalender mit typgerechtem PDF-Ausschnitt. - Sina: reduzierte, filter- und sortierbare Objektübersicht; Detailansicht direkt editierbar, leere Pflichtfelder rot umrandet. - Nora: Signale ohne Prozentsätze und Konfidenzstufen, nur belegbare Angaben; Mehrfachauswahl leitet Objekte an Livia weiter. - Livia: aktive und archivierte Leads, Arbeitsbereich gleitet an den oberen Rand; dreistufiger Exposé-Prozess Hochladen → Exposé → Export. - Bruno: neue Seite mit Auftragsliste und Vor-/Nachbereitungs-Drawer; Glocke warnt bei Besichtigung unter 24 Stunden ohne Bericht. Datenschicht - Neu: Kalender, Exposé-Leads, Exposé-Entwürfe, Besichtigungsaufträge — je Domain, Provider, Service und Hook. - IAIService um generateExposeText erweitert; der Entwurf nutzt ausschliesslich erfasste Objektdaten und meldet Lücken, statt sie zu füllen. - Alle Objektverweise zeigen auf reale Einträge aus «Meine Objekte»; neue Detailroute /supply/properties/:propertyId. Offen: Chat, Kalender, CRM und DMS sind Frontend-Simulation ohne Anbindung. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
161 lines
5.8 KiB
TypeScript
161 lines
5.8 KiB
TypeScript
import { memo } from 'react'
|
|
import { Box, Button, Typography } from '@mui/material'
|
|
import { Ruler } from 'lucide-react'
|
|
import { useNavigate } from 'react-router'
|
|
import type { Property } from '../../domain/property'
|
|
import type { VerifiedPortfolioResult } from '../../domain/unifiedResult'
|
|
import type { Match } from '../../domain/match'
|
|
import { MatchStrength, ResultType } from '../../domain/enums'
|
|
import { useInquiryStore } from '../../stores/inquiryStore'
|
|
import { usePipelineStore } from '../../stores/pipelineStore'
|
|
import { useCompareStore } from '../../stores/compareStore'
|
|
import { DS_ACCENT, DS_BRAND, DS_SLATE } from '../../lib/ds'
|
|
|
|
interface Props {
|
|
property: Property
|
|
score: number
|
|
needId: string
|
|
}
|
|
|
|
function buildSyntheticResult(property: Property, needId: string, score: number, matchId: string): VerifiedPortfolioResult {
|
|
const strength = score >= 85 ? MatchStrength.STRONG : score >= 70 ? MatchStrength.MODERATE : MatchStrength.WEAK
|
|
const syntheticMatch = {
|
|
id: matchId,
|
|
needId,
|
|
propertyId: property.id,
|
|
matchScore: score,
|
|
matchStrength: strength,
|
|
scoreBreakdown: { hardMatchScore: score, softFactorScore: score, confidenceModifier: 0, dataQualityModifier: 0, totalScore: score },
|
|
confidenceLevel: score / 100,
|
|
} as unknown as Match
|
|
|
|
return {
|
|
matchId,
|
|
needId,
|
|
matchScore: score,
|
|
resultType: ResultType.VERIFIED_PORTFOLIO,
|
|
property,
|
|
match: syntheticMatch,
|
|
}
|
|
}
|
|
|
|
export const PropertyMatchRow = memo(function PropertyMatchRow({ property, score, needId }: Props) {
|
|
const navigate = useNavigate()
|
|
const openInquiryDialog = useInquiryStore(s => s.openInquiryDialog)
|
|
const openSavedDialog = usePipelineStore(s => s.openSavedDialog)
|
|
const { addToCompare, removeFromCompare, isInCompare, isFull } = useCompareStore()
|
|
|
|
const scoreColor = score >= 85 ? '#16a34a' : score >= 70 ? '#d97706' : '#dc2626'
|
|
const scoreBg = score >= 85 ? '#dcfce7' : score >= 70 ? '#fef3c7' : '#fee2e2'
|
|
// Match IDs in the store use double-underscore format: m__propId__prop__needId
|
|
const matchId = `m__${property.id}__prop__${needId}`
|
|
const inCompare = isInCompare(matchId)
|
|
|
|
function handleInquire() {
|
|
openInquiryDialog({
|
|
propertyTitle: property.title,
|
|
location: property.location.city,
|
|
matchScore: score,
|
|
matchId,
|
|
propertyId: property.id,
|
|
areaLabel: property.areaSqm ? `${property.areaSqm} m²` : undefined,
|
|
rentLabel: property.rentPricePerSqm ? `CHF ${property.rentPricePerSqm}/m²/Jahr` : undefined,
|
|
})
|
|
}
|
|
|
|
function handleShortlist() {
|
|
openSavedDialog({
|
|
resultId: matchId,
|
|
resultType: 'VERIFIED_PORTFOLIO',
|
|
title: property.title,
|
|
matchScore: score,
|
|
location: property.location.city,
|
|
propertyId: property.id,
|
|
propertyAddress: `${property.title}, ${property.location.city}`,
|
|
areaLabel: property.areaSqm ? `${property.areaSqm} m²` : undefined,
|
|
rentLabel: property.rentPricePerSqm ? `CHF ${property.rentPricePerSqm}/m²/Jahr` : undefined,
|
|
})
|
|
}
|
|
|
|
function handleCompare() {
|
|
if (inCompare) {
|
|
removeFromCompare(matchId)
|
|
} else if (!isFull()) {
|
|
addToCompare(buildSyntheticResult(property, needId, score, matchId))
|
|
}
|
|
}
|
|
|
|
function handleDetails() {
|
|
navigate(`/demand/results/${matchId}`)
|
|
}
|
|
|
|
function handleUnit() {
|
|
navigate(`/demand/property/${property.id}`)
|
|
}
|
|
|
|
return (
|
|
<Box sx={{ bgcolor: 'white', border: '1px solid #e2e8f0', borderRadius: 1.5, overflow: 'hidden' }}>
|
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, p: 1.25 }}>
|
|
{property.images?.[0] ? (
|
|
<Box component="img" src={property.images[0]} alt=""
|
|
sx={{ width: 52, height: 52, borderRadius: 1, objectFit: 'cover', flexShrink: 0 }} />
|
|
) : (
|
|
<Box sx={{ width: 52, height: 52, borderRadius: 1, bgcolor: DS_ACCENT.indigo.border, flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
|
<Ruler size={18} color="#3730a3" />
|
|
</Box>
|
|
)}
|
|
<Box sx={{ flex: 1, minWidth: 0 }}>
|
|
<Typography variant="body2" sx={{ fontWeight: 600, fontSize: '0.825rem', color: DS_SLATE[900] }} noWrap>
|
|
{property.title}
|
|
</Typography>
|
|
<Typography variant="caption" sx={{ color: DS_SLATE[500], fontSize: '0.75rem' }}>
|
|
{property.location.city} · {property.areaSqm} m²
|
|
</Typography>
|
|
</Box>
|
|
<Box sx={{ bgcolor: scoreBg, color: scoreColor, px: 1, py: 0.375, borderRadius: 1, fontWeight: 700, fontSize: '0.8rem', flexShrink: 0 }}>
|
|
{score}%
|
|
</Box>
|
|
</Box>
|
|
|
|
<Box sx={{ display: 'flex', gap: 0.75, px: 1.25, pb: 1.25, flexWrap: 'wrap' }}>
|
|
<ActionButton onClick={handleInquire} primary>Anfrage</ActionButton>
|
|
<ActionButton onClick={handleShortlist}>Merken</ActionButton>
|
|
<ActionButton onClick={handleCompare} disabled={!inCompare && isFull()}>
|
|
{inCompare ? 'Im Vergleich' : 'Vergleichen'}
|
|
</ActionButton>
|
|
<ActionButton onClick={handleDetails}>Details →</ActionButton>
|
|
<ActionButton onClick={handleUnit}>Zur Einheit →</ActionButton>
|
|
</Box>
|
|
</Box>
|
|
)
|
|
})
|
|
|
|
function ActionButton({ children, onClick, primary, disabled }: {
|
|
children: React.ReactNode
|
|
onClick: () => void
|
|
primary?: boolean
|
|
disabled?: boolean
|
|
}) {
|
|
return (
|
|
<Button
|
|
size="small"
|
|
variant={primary ? 'contained' : 'outlined'}
|
|
onClick={onClick}
|
|
disabled={disabled}
|
|
sx={{
|
|
textTransform: 'none',
|
|
fontWeight: 600,
|
|
fontSize: '0.75rem',
|
|
py: 0.25,
|
|
px: 1,
|
|
minWidth: 0,
|
|
...(primary
|
|
? { bgcolor: DS_BRAND.main, '&:hover': { bgcolor: DS_BRAND.hover } }
|
|
: { borderColor: DS_SLATE[300], color: DS_SLATE[600], '&:hover': { borderColor: DS_BRAND.main, color: DS_BRAND.main } }),
|
|
}}
|
|
>
|
|
{children}
|
|
</Button>
|
|
)
|
|
}
|