da50f3b5ea
- Design tokens (ds.ts, theme.ts, scoreTheme.ts): new warm palette (#152642 navy, #f9f8f6 warm white, #e8e7e4 borders, #b8975a gold accent), flat score tier badges replacing CSS gradients, Inter + DM Serif Display typography - Card components: white-background cards, DM Serif score numbers, max-2 badge chips with +N overflow tooltip, editorial score badge positioning - Layout shell: gold left-accent nav active state, 64px top bar, outlined workspace chip, DM Serif page titles - Shared atoms: GenericBadge (outlined/solid variants), ResultFilterBar (simplified chip styles), DecisionContextPanel (dot metrics, no left accent) - Global replacement (118 files): #1e3a5f→#152642, #e2e8f0→#e8e7e4, #f4f6f9→#f9f8f6 — all handled via Node.js for proper UTF-8 safety Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
268 lines
9.0 KiB
TypeScript
268 lines
9.0 KiB
TypeScript
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} m²`}
|
||
/>
|
||
<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: '#152642',
|
||
fontSize: '0.75rem',
|
||
bgcolor: '#e0e7ff',
|
||
px: 1,
|
||
py: 0.125,
|
||
borderRadius: 1,
|
||
}}
|
||
>
|
||
{pct}%
|
||
</Typography>
|
||
</Box>
|
||
<Box
|
||
sx={{
|
||
height: 6,
|
||
borderRadius: 3,
|
||
bgcolor: '#e8e7e4',
|
||
overflow: 'hidden',
|
||
}}
|
||
>
|
||
<Box
|
||
sx={{
|
||
width: `${pct}%`,
|
||
height: '100%',
|
||
bgcolor: '#152642',
|
||
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: '#152642',
|
||
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>
|
||
)
|
||
}
|