Files
property-match/src/components/demand/NeedInput.tsx
T
Benjamin Sutter e95490eb72 feat: Grundriss-Feature, Listenansicht mit Bildern, Gewerbe-Label, Image-Pool
- Grundriss (FloorPlanSection): PropertyUnit.floorPlanUrl?, Property.floorPlanUrl?;
  FloorPlanSection in MatchDetail + PropertyDetail; FloorPlanUrlSection in NewListing-Formular
- Listenansicht (MatchCardCompact): horizontales Layout mit 120px Bildstreifen,
  Score-Badge, Asset-Label-Overlay, alle 3 grünen Punkte, Anfrage-Button
- Light Industrial → "Gewerbe" überall (NeedInput, NeedCardPreview, CriteriaReviewPanel,
  newListingConstants, MyListings, propertyHelpers)
- "Zum Originalinserat"-Button nur bei Maison-Work-Objekten
- Image-Pool: propertyImageResolver mit sequentiellem Pool-Index (keine doppelten Bilder),
  nur Innenaufnahmen, rotate()-Trick für Sub-Pools
- Overlay-Labels (Objekttyp + Stadtteil) in LocationPreview + IntelligenceMatchCard

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 22:12:43 +02:00

227 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 { useState } from 'react'
import { Box, Card, Chip, Stack, TextField, Typography } from '@mui/material'
import type { ParsedNeedCriteria } from '../../domain/needBuilder'
import { AssetType } from '../../domain/enums'
import { NeedExtendedRequirements } from './NeedExtendedRequirements'
interface Props {
criteria: ParsedNeedCriteria
onCriteriaChange: (c: ParsedNeedCriteria) => void
}
const ASSET_OPTIONS = [
{ label: 'Büro', value: AssetType.OFFICE },
{ label: 'Retail', value: AssetType.RETAIL },
{ label: 'Logistik', value: AssetType.LOGISTICS },
{ label: 'Produktion', value: AssetType.PRODUCTION },
{ label: 'Gewerbe', value: AssetType.LIGHT_INDUSTRIAL },
{ label: 'Gemischt', value: AssetType.MIXED },
]
const AREA_PRESETS = [
{ label: '200500 m²', min: 200, max: 500 },
{ label: '5001000 m²', min: 500, max: 1000 },
{ label: '10002000 m²', min: 1000, max: 2000 },
{ label: '20005000 m²', min: 2000, max: 5000 },
]
const BUDGET_PRESETS = [
{ label: '300 CHF/m²/J', value: 300 },
{ label: '420 CHF/m²/J', value: 420 },
{ label: '540 CHF/m²/J', value: 540 },
{ label: '780 CHF/m²/J', value: 780 },
]
const LOCATION_PRESETS = ['Zürich', 'Zürich-West', 'Zürich-Nord', 'Bern', 'Basel', 'Luzern']
function FieldLabel({ children }: { children: React.ReactNode }) {
return (
<Typography variant="caption" color="text.secondary" sx={{ fontWeight: 600, display: 'block', mb: 0.75 }}>
{children}
</Typography>
)
}
export function NeedInput({ criteria: c, onCriteriaChange: set }: Props) {
const [locationDraft, setLocationDraft] = useState('')
const [mustHaveDraft, setMustHaveDraft] = useState('')
function addLocations(raw: string) {
const tokens = raw.split(',').map(x => x.trim()).filter(Boolean)
if (!tokens.length) return
set({ ...c, preferredLocations: [...new Set([...(c.preferredLocations ?? []), ...tokens])] })
setLocationDraft('')
}
function addMustHaves(raw: string) {
const tokens = raw.split(',').map(x => x.trim()).filter(Boolean)
if (!tokens.length) return
set({ ...c, mustHaveCriteria: [...new Set([...(c.mustHaveCriteria ?? []), ...tokens])] })
setMustHaveDraft('')
}
return (
<Card elevation={0} sx={{ p: 3, border: '1px solid #e2e8f0', height: '100%' }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700, mb: 0.5 }}>Kriterien verfeinern</Typography>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 2.5 }}>
Ergänzen oder korrigieren Sie die extrahierten Felder.
</Typography>
{/* Asset Type */}
<FieldLabel>Nutzungstyp</FieldLabel>
<Stack direction="row" spacing={0.5} sx={{ flexWrap: 'wrap', gap: 0.5, mb: 2.5 }}>
{ASSET_OPTIONS.map(opt => (
<Chip
key={opt.value}
label={opt.label}
size="small"
variant={c.assetType === opt.value ? 'filled' : 'outlined'}
clickable
onClick={() => set({ ...c, assetType: c.assetType === opt.value ? undefined : opt.value })}
sx={c.assetType === opt.value
? { bgcolor: '#1e3a5f', color: 'white', '& .MuiChip-label': { color: 'white' } }
: {}}
/>
))}
</Stack>
{/* Area */}
<FieldLabel>Fläche (m²)</FieldLabel>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.75 }}>
<TextField
size="small" type="number" placeholder="Min"
value={c.areaRange?.min || ''}
onChange={e => set({ ...c, areaRange: { min: parseInt(e.target.value) || 0, max: c.areaRange?.max ?? 0 } })}
sx={{ width: 100 }}
slotProps={{ htmlInput: { min: 0 } }}
/>
<Typography variant="body2" color="text.secondary"></Typography>
<TextField
size="small" type="number" placeholder="Max"
value={c.areaRange?.max || ''}
onChange={e => set({ ...c, areaRange: { min: c.areaRange?.min ?? 0, max: parseInt(e.target.value) || 0 } })}
sx={{ width: 100 }}
slotProps={{ htmlInput: { min: 0 } }}
/>
<Typography variant="caption" color="text.secondary">m²</Typography>
</Box>
<Stack direction="row" spacing={0.5} sx={{ flexWrap: 'wrap', gap: 0.5, mb: 2.5 }}>
{AREA_PRESETS.map(p => (
<Chip
key={p.label}
label={p.label}
size="small"
variant="outlined"
clickable
onClick={() => set({ ...c, areaRange: { min: p.min, max: p.max } })}
sx={{ fontSize: '0.68rem', height: 20, color: '#64748b', borderColor: '#cbd5e1' }}
/>
))}
</Stack>
{/* Location */}
<FieldLabel>Standort</FieldLabel>
<Stack direction="row" spacing={0.5} sx={{ flexWrap: 'wrap', gap: 0.5, mb: 0.75 }}>
{LOCATION_PRESETS.map(loc => (
<Chip
key={loc}
label={loc}
size="small"
variant={c.preferredLocations?.includes(loc) ? 'filled' : 'outlined'}
clickable
onClick={() => {
const already = c.preferredLocations?.includes(loc)
set({ ...c, preferredLocations: already
? (c.preferredLocations ?? []).filter(l => l !== loc)
: [...new Set([...(c.preferredLocations ?? []), loc])]
})
}}
sx={c.preferredLocations?.includes(loc)
? { fontSize: '0.68rem', height: 22, bgcolor: '#1e3a5f', color: 'white' }
: { fontSize: '0.68rem', height: 22, color: '#64748b', borderColor: '#cbd5e1' }
}
/>
))}
</Stack>
<TextField
size="small" fullWidth
placeholder="Weitere Stadt oder Region — Enter zum Hinzufügen"
value={locationDraft}
onChange={e => setLocationDraft(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter' && locationDraft.trim()) addLocations(locationDraft) }}
onBlur={() => { if (locationDraft.trim()) addLocations(locationDraft) }}
sx={{ mb: 0.75 }}
/>
{(c.preferredLocations?.filter(l => !LOCATION_PRESETS.includes(l)).length ?? 0) > 0 && (
<Stack direction="row" spacing={0.5} sx={{ flexWrap: 'wrap', gap: 0.5, mb: 2.5 }}>
{c.preferredLocations!.filter(l => !LOCATION_PRESETS.includes(l)).map(loc => (
<Chip key={loc} label={loc} size="small"
onDelete={() => set({ ...c, preferredLocations: c.preferredLocations!.filter(l => l !== loc) })}
/>
))}
</Stack>
)}
<Box sx={{ mb: (c.preferredLocations?.filter(l => !LOCATION_PRESETS.includes(l)).length ?? 0) > 0 ? 0 : 2.5 }} />
{/* Budget */}
<FieldLabel>Budget (max CHF/m²/Jahr)</FieldLabel>
<Stack direction="row" spacing={0.5} sx={{ flexWrap: 'wrap', gap: 0.5, mb: 0.75 }}>
{BUDGET_PRESETS.map(p => (
<Chip
key={p.label}
label={p.label}
size="small"
variant={c.budgetRange?.maxPerSqm === p.value ? 'filled' : 'outlined'}
clickable
onClick={() => set({ ...c, budgetRange: { maxPerSqm: p.value, currency: 'CHF' } })}
sx={c.budgetRange?.maxPerSqm === p.value
? { fontSize: '0.68rem', height: 22, bgcolor: '#1e3a5f', color: 'white' }
: { fontSize: '0.68rem', height: 22, color: '#64748b', borderColor: '#cbd5e1' }
}
/>
))}
</Stack>
<TextField
size="small" type="number" placeholder="oder eigener Wert"
value={c.budgetRange?.maxPerSqm || ''}
onChange={e => set({ ...c, budgetRange: { maxPerSqm: parseInt(e.target.value) || 0, currency: 'CHF' } })}
sx={{ width: 180, mb: 2.5 }}
slotProps={{ htmlInput: { min: 0 } }}
/>
{/* Timing */}
<FieldLabel>Verfügbar ab</FieldLabel>
<TextField
size="small"
placeholder="z.B. Q3 2025 oder 01.09.2025"
value={c.timing?.earliestMoveIn ?? ''}
onChange={e => set({ ...c, timing: { earliestMoveIn: e.target.value, latestMoveIn: e.target.value, flexibleTiming: true } })}
sx={{ width: 220, mb: 2.5 }}
/>
{/* Must-haves */}
<FieldLabel>Must-haves</FieldLabel>
<TextField
size="small" fullWidth
placeholder="z.B. ÖV-Anbindung, Parkplätze — Enter zum Hinzufügen"
value={mustHaveDraft}
onChange={e => setMustHaveDraft(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter' && mustHaveDraft.trim()) addMustHaves(mustHaveDraft) }}
onBlur={() => { if (mustHaveDraft.trim()) addMustHaves(mustHaveDraft) }}
sx={{ mb: 0.75 }}
/>
{(c.mustHaveCriteria?.length ?? 0) > 0 && (
<Stack direction="row" spacing={0.5} sx={{ flexWrap: 'wrap', gap: 0.5, mb: 1.5 }}>
{c.mustHaveCriteria!.map(item => (
<Chip key={item} label={item} size="small"
onDelete={() => set({ ...c, mustHaveCriteria: c.mustHaveCriteria!.filter(m => m !== item) })}
/>
))}
</Stack>
)}
<NeedExtendedRequirements criteria={c} onChange={set} />
</Card>
)
}