Files
property-match/src/components/anfragencenter/PublicNeedDetail.tsx
T
Benjamin Sutter c4c29ef7d3 feat: F030 Anfragencenter — Aktive & Latente Anfragen + Angebot-Wizard
Neuer Menüpunkt «Anfragencenter» (ehemals «Eingehende Bedarfe»):

Aktive Anfragen:
- Split-View: Anfrageliste (Liste/Grid-Toggle) + Chat-Detailansicht
- Chat-Verlauf mit Sender-Styling (Mieter links, Supply rechts)
- Antwortformular mit Betreff, Nachricht, Anhänge, Statuswechsel
- Zugehörige Property Card neben dem Chat

Latente Anfragen:
- Drei-Spalten-Layout: Need-Liste | Need-Detail | Eigene Objekte
- Need Cards mit AI-Zusammenfassung, Must-Haves, Präferenzen
- Eigene Portfolio-Objekte sortiert nach deterministischem Match-Score
- Checkbox-Selektion für Angebotsauswahl

Angebot-Wizard (4 Schritte):
- Schritt 1: Objekte auswählen (mit Score-Vorschau)
- Schritt 2: PDF-Vorschau + editierbare Textfelder
- Schritt 3: Angebot prüfen & bestätigen
- Schritt 4: Nachricht an Suchenden mit KI-generierter Mail

Architektur:
- Domain: Inquiry, LatentNeed, OfferDraft Types
- Provider: IInquiryProvider, ILatentNeedProvider, IOfferProvider + Mockups
- Services: inquiryService, latentNeedService, offerService
- Hooks: useInquiries, useLatentNeeds, useOffers (TanStack Query)
- Store: offerWizardStore (Zustand, lokaler Wizard-State)
- 27 neue Komponenten, alle Loading/Empty/Error States

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 18:03:05 +02:00

268 lines
9.0 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 { Alert, Box, Button, Chip, Typography } from '@mui/material'
import { CheckCircle2, Calendar, MapPin, Wallet, Info, Ruler, Sparkles } from 'lucide-react'
import type { LatentNeed } from '../../domain/latentNeed'
import { useOfferWizardStore } from '../../stores/offerWizardStore'
import { useSessionStore } from '../../stores/sessionStore'
import { assetTypeLabel, latentStatusBadge } from './latentNeedUtils'
import { UserRole } from '../../domain/enums'
interface PublicNeedDetailProps {
need: LatentNeed
}
export function PublicNeedDetail({ need }: PublicNeedDetailProps) {
const openWizard = useOfferWizardStore(s => s.open)
const currentUser = useSessionStore(s => s.currentUser)
const statusCfg = latentStatusBadge(need.status)
const disabled = currentUser?.role === UserRole.OWNER_VIEWER || need.status !== 'public'
return (
<Box
sx={{
flex: 1,
overflowY: 'auto',
bgcolor: '#f8fafc',
display: 'flex',
flexDirection: 'column',
}}
>
<Box sx={{ p: 3, display: 'flex', flexDirection: 'column', gap: 2.5 }}>
{/* Header */}
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.5 }}>
<Chip
label={assetTypeLabel(need.assetType)}
size="small"
sx={{
bgcolor: '#e0e7ff',
color: '#3730a3',
fontWeight: 600,
fontSize: '0.7rem',
height: 22,
}}
/>
<Chip
label={statusCfg.label}
size="small"
sx={{
bgcolor: statusCfg.bg,
color: statusCfg.fg,
fontWeight: 600,
fontSize: '0.7rem',
height: 22,
}}
/>
</Box>
<Typography variant="h5" sx={{ fontWeight: 700, color: '#0f172a', fontSize: '1.35rem', mb: 0.5 }}>
{need.title}
</Typography>
{need.tenantCompany && (
<Typography variant="body2" sx={{ color: '#64748b' }}>
{need.tenantCompany}
</Typography>
)}
</Box>
{/* AI Summary */}
{need.aiSummary && (
<Alert
icon={<Sparkles size={16} />}
severity="info"
sx={{
bgcolor: '#eff6ff',
borderRadius: 1.5,
border: '1px solid #bfdbfe',
'& .MuiAlert-icon': { color: '#1d4ed8' },
'& .MuiAlert-message': { color: '#1e3a8a', fontSize: '0.875rem' },
}}
>
<Typography variant="caption" sx={{ fontWeight: 700, color: '#1d4ed8', display: 'block', mb: 0.5 }}>
KI-Analyse
</Typography>
{need.aiSummary}
</Alert>
)}
{/* Suchprofil grid */}
<Box>
<Typography
variant="caption"
sx={{ color: '#64748b', fontWeight: 700, textTransform: 'uppercase', letterSpacing: 0.5 }}
>
Suchprofil
</Typography>
<Box
sx={{
mt: 1,
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))',
gap: 1.5,
}}
>
<ProfileBox icon={<MapPin size={14} />} label="Standort" value={need.desiredLocation} />
<ProfileBox
icon={<Ruler size={14} />}
label="Fläche"
value={`${need.sizeRange.min}${need.sizeRange.max}`}
/>
<ProfileBox
icon={<Wallet size={14} />}
label="Budget"
value={
need.budgetRange
? `${need.budgetRange.min ? `CHF ${need.budgetRange.min}` : 'flex'}${
need.budgetRange.max ? `CHF ${need.budgetRange.max}` : 'flex'
} /m²`
: 'Flexibel'
}
/>
<ProfileBox icon={<Calendar size={14} />} label="Timing" value={need.timing} />
</Box>
</Box>
{/* Must-have Kriterien */}
{need.mustHaveCriteria.length > 0 && (
<Box>
<Typography
variant="caption"
sx={{ color: '#64748b', fontWeight: 700, textTransform: 'uppercase', letterSpacing: 0.5 }}
>
Must-have Kriterien
</Typography>
<Box sx={{ mt: 1, display: 'flex', flexDirection: 'column', gap: 0.5 }}>
{need.mustHaveCriteria.map((c, idx) => (
<Box key={idx} sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<CheckCircle2 size={14} color="#16a34a" />
<Typography variant="body2" sx={{ fontSize: '0.875rem', color: '#1e293b' }}>
{c}
</Typography>
</Box>
))}
</Box>
</Box>
)}
{/* Gewichtete Präferenzen */}
{need.weightedPreferences.length > 0 && (
<Box>
<Typography
variant="caption"
sx={{ color: '#64748b', fontWeight: 700, textTransform: 'uppercase', letterSpacing: 0.5 }}
>
Gewichtete Präferenzen
</Typography>
<Box sx={{ mt: 1, display: 'flex', flexDirection: 'column', gap: 1 }}>
{need.weightedPreferences.map((p, idx) => {
const pct = Math.round(p.weight * 100)
return (
<Box
key={idx}
sx={{
bgcolor: 'white',
border: '1px solid #e2e8f0',
borderRadius: 1.5,
px: 1.5,
py: 1,
}}
>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 0.5 }}>
<Typography variant="body2" sx={{ fontWeight: 600, fontSize: '0.85rem' }}>
{p.criterion}
</Typography>
<Typography
variant="caption"
sx={{
fontWeight: 700,
color: '#1e3a5f',
fontSize: '0.75rem',
bgcolor: '#e0e7ff',
px: 1,
py: 0.125,
borderRadius: 1,
}}
>
{pct}%
</Typography>
</Box>
<Box
sx={{
height: 6,
borderRadius: 3,
bgcolor: '#e2e8f0',
overflow: 'hidden',
}}
>
<Box
sx={{
width: `${pct}%`,
height: '100%',
bgcolor: '#1e3a5f',
transition: 'width 0.3s',
}}
/>
</Box>
{p.description && (
<Typography variant="caption" sx={{ color: '#64748b', fontSize: '0.75rem', mt: 0.5, display: 'block' }}>
{p.description}
</Typography>
)}
</Box>
)
})}
</Box>
</Box>
)}
{disabled && need.status !== 'public' && (
<Alert severity="warning" icon={<Info size={16} />} sx={{ fontSize: '0.8125rem' }}>
Dieser Bedarf ist aktuell {statusCfg.label.toLowerCase()} kein Angebot möglich.
</Alert>
)}
<Box sx={{ mt: 'auto', pt: 1 }}>
<Button
variant="contained"
size="large"
fullWidth
onClick={() => openWizard(need.id, need.title)}
disabled={disabled}
sx={{
textTransform: 'none',
bgcolor: '#1e3a5f',
fontWeight: 600,
'&:hover': { bgcolor: '#16304d' },
}}
>
Angebot erstellen
</Button>
</Box>
</Box>
</Box>
)
}
function ProfileBox({ icon, label, value }: { icon: React.ReactNode; label: string; value: string }) {
return (
<Box
sx={{
bgcolor: 'white',
border: '1px solid #e2e8f0',
borderRadius: 1.5,
px: 1.5,
py: 1,
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, color: '#64748b', mb: 0.25 }}>
{icon}
<Typography variant="caption" sx={{ fontSize: '0.7rem', fontWeight: 600, textTransform: 'uppercase', letterSpacing: 0.5 }}>
{label}
</Typography>
</Box>
<Typography variant="body2" sx={{ fontSize: '0.85rem', color: '#0f172a', fontWeight: 500 }}>
{value}
</Typography>
</Box>
)
}