feat: Suchabo inline contact flow — chip opens need detail + OfferWizard
- Add >80% STRONG matches for all 6 direct listings (match-072–075) - Add 4 new Needs (need-012–015) tailored to each previously unmatched listing - Add 8 new LatentNeeds (lneed-007–014) for all needs in direct listing matches - Add need→latentNeed mapping (src/lib/needToLatentNeedMap.ts) - MyListings: clicking Suchabo chip opens dialog with company name, score, PublicNeedDetail view and OfferWizard — no navigation to Anfragencenter needed Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -16,11 +16,15 @@ import {
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from '@mui/material'
|
||||
import { Plus, Trash2 } from 'lucide-react'
|
||||
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',
|
||||
@@ -44,17 +48,158 @@ function matchColor(score: number): string {
|
||||
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: '#1e3a5f',
|
||||
'&: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, updatePending, removePending }: RowProps) {
|
||||
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
|
||||
|
||||
@@ -102,17 +247,19 @@ const ListingRow = memo(function ListingRow({ p, isLast, onDetail, onToggleStatu
|
||||
{/* Match count */}
|
||||
<Box onClick={e => e.stopPropagation()}>
|
||||
{matches.length > 0 ? (
|
||||
<Tooltip title={matches.map(m => `${m.matchScore}% · need-${m.needId.slice(-3)}`).join('\n')}>
|
||||
<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: 'default',
|
||||
cursor: 'pointer',
|
||||
'&:hover': { opacity: 0.85 },
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
@@ -156,6 +303,8 @@ const ListingRow = memo(function ListingRow({ p, isLast, onDetail, onToggleStatu
|
||||
)
|
||||
})
|
||||
|
||||
// ─── Page ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function MyListings() {
|
||||
const navigate = useNavigate()
|
||||
|
||||
@@ -166,6 +315,10 @@ export default function MyListings() {
|
||||
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'
|
||||
@@ -254,6 +407,7 @@ export default function MyListings() {
|
||||
onDetail={setDetailId}
|
||||
onToggleStatus={handleToggleStatus}
|
||||
onDelete={setConfirmDelete}
|
||||
onChipClick={(pid, m) => setSuchaboDialog({ propertyId: pid, matches: m })}
|
||||
updatePending={updateProperty.isPending}
|
||||
removePending={removeProperty.isPending}
|
||||
/>
|
||||
@@ -273,6 +427,31 @@ export default function MyListings() {
|
||||
)}
|
||||
</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>
|
||||
@@ -295,6 +474,9 @@ export default function MyListings() {
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
{/* OfferWizard — triggered by PublicNeedDetail's "Angebot erstellen" button */}
|
||||
<OfferWizard />
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user