Files
property-match/src/components/anfragencenter/OfferPropertySelectionStep.tsx
T
Benjamin Sutter a825672197 feat: collapsible filter bar, compact property cards, offer wizard field selection
- Properties page: collapsible decision/filter strip (localStorage state), slim
  single-row Datenpflege status bar (chips + button), 4-column card grid
- PropertyIntelligenceCard: reduced image height (160→120px), tighter body padding
- OfferWizard (latente Anfragen): new "Felder wählen" step between property
  selection and PDF preview; uses ReportObjectFieldSelector per property
- MockPdfPreview: adds second PDF page showing selected properties side by side
  with photo, title, location and all chosen hard/soft fact fields
- offerWizardStore: adds select_fields step type and fieldSelections state

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 15:36:32 +02:00

152 lines
5.6 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 { useEffect, useMemo } from 'react'
import { Box, Button, CircularProgress, Typography } from '@mui/material'
import { ArrowRight } from 'lucide-react'
import { useProperties } from '../../hooks/useProperties'
import { ResultType } from '../../domain/enums'
import { useOfferWizardStore } from '../../stores/offerWizardStore'
import { useLatentNeedById } from '../../hooks/useLatentNeeds'
import { useCreateOfferDraft } from '../../hooks/useOffers'
import { useToastStore } from '../../stores/toastStore'
import { SelectablePropertyMatchCard } from './SelectablePropertyMatchCard'
import { deterministicMatchScore, assetTypeLabel } from './latentNeedUtils'
function buildReason(propertyAssetType: string, needAssetType: string, city: string, location: string): string {
if (propertyAssetType === needAssetType) {
const cityMatch = location.toLowerCase().includes(city.toLowerCase()) || city.toLowerCase().includes(location.toLowerCase())
if (cityMatch) return 'Nutzungstyp und Standort passen sehr gut'
return 'Passender Nutzungstyp, alternative Lage'
}
return 'Alternatives Profil — Detailprüfung empfohlen'
}
export function OfferPropertySelectionStep() {
const needId = useOfferWizardStore(s => s.selectedNeedId)
const selectedIds = useOfferWizardStore(s => s.selectedPropertyIds)
const toggle = useOfferWizardStore(s => s.toggleProperty)
const setSelected = useOfferWizardStore(s => s.setSelectedProperties)
const setOfferDraftId = useOfferWizardStore(s => s.setOfferDraftId)
const setStep = useOfferWizardStore(s => s.setStep)
const { data: need } = useLatentNeedById(needId)
const { data: properties = [], isLoading } = useProperties({ resultType: ResultType.VERIFIED_PORTFOLIO })
const createDraft = useCreateOfferDraft()
const showToast = useToastStore(s => s.showToast)
const scored = useMemo(() => {
if (!need) return []
return properties
.map(p => ({
property: p,
score: deterministicMatchScore(p.id, need.id),
reason: buildReason(p.assetType, need.assetType, p.location.city, need.desiredLocation),
}))
.sort((a, b) => b.score - a.score)
}, [properties, need])
// Pre-select top 2 if nothing selected
useEffect(() => {
if (scored.length > 0 && selectedIds.length === 0) {
setSelected(scored.slice(0, 2).map(s => s.property.id))
}
}, [scored, selectedIds.length, setSelected])
const handleNext = async () => {
if (!need) return
if (selectedIds.length === 0) {
showToast('Bitte mindestens ein Objekt auswählen', 'warning')
return
}
const res = await createDraft.mutateAsync({
needId: need.id,
selectedPropertyIds: selectedIds,
needTitle: need.title,
location: need.desiredLocation,
assetType: need.assetType,
sizeRange: need.sizeRange,
})
if (res.error || !res.data) {
showToast(`Fehler: ${res.error}`, 'error')
return
}
setOfferDraftId(res.data.id)
setStep('select_fields')
}
if (!need) {
return (
<Box sx={{ p: 4, display: 'flex', justifyContent: 'center' }}>
<CircularProgress size={24} />
</Box>
)
}
return (
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
{/* Need summary header */}
<Box
sx={{
p: 2.5,
bgcolor: '#f8fafc',
borderBottom: '1px solid #e2e8f0',
flexShrink: 0,
}}
>
<Typography variant="caption" sx={{ color: '#64748b', fontWeight: 600, textTransform: 'uppercase', letterSpacing: 0.5 }}>
Bedarf
</Typography>
<Typography variant="h6" sx={{ fontWeight: 700, color: '#0f172a', fontSize: '1.1rem', mt: 0.25 }}>
{need.title}
</Typography>
<Typography variant="caption" sx={{ color: '#475569', fontSize: '0.8rem', mt: 0.5, display: 'block' }}>
{assetTypeLabel(need.assetType)} · {need.desiredLocation} · {need.sizeRange.min}{need.sizeRange.max} m²
</Typography>
</Box>
<Box sx={{ flex: 1, overflowY: 'auto', p: 2 }}>
<Typography variant="body2" sx={{ fontWeight: 600, color: '#0f172a', mb: 1.5 }}>
Wählen Sie passende Objekte aus Ihrem Portfolio
</Typography>
{isLoading && <CircularProgress size={20} />}
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{scored.map(({ property, score, reason }) => (
<SelectablePropertyMatchCard
key={property.id}
property={property}
matchScore={score}
selected={selectedIds.includes(property.id)}
onToggle={() => toggle(property.id)}
reason={reason}
/>
))}
</Box>
</Box>
<Box
sx={{
p: 2,
borderTop: '1px solid #e2e8f0',
bgcolor: 'white',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
flexShrink: 0,
}}
>
<Typography variant="body2" sx={{ color: '#64748b' }}>
{selectedIds.length} Objekt{selectedIds.length === 1 ? '' : 'e'} ausgewählt
</Typography>
<Button
variant="contained"
endIcon={createDraft.isPending ? <CircularProgress size={14} sx={{ color: 'white' }} /> : <ArrowRight size={14} />}
onClick={handleNext}
disabled={selectedIds.length === 0 || createDraft.isPending}
sx={{ textTransform: 'none', bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#16304d' } }}
>
Weiter
</Button>
</Box>
</Box>
)
}