refactor: architecture compliance pass — DS tokens, hook boundary, god component split, AI hardening
- DS token migration: Anfragen.tsx + child components (AnfragenInquiryItem, AnfragenMessageBubble) fully migrated; DS_TEXT.brandDark added; scoreTheme.ts moved to src/lib/ with re-export proxy - Hook boundary: Results.tsx no longer calls needService directly — routes through useNeeds() with optional refetchOnMount/gcTime overrides - NewListing.tsx (440L) split into useNewListingForm hook + 8 section components under src/components/new-listing/; page shell reduced to 121 lines - AI hardening: Zod .strict() on all schemas, AIProvenance extended with schemaVersion/ fallbackReason/traceId/latencyMs, AITraceStore stats with p50/p90/p99 + failure breakdowns, MockAIService buildFollowUpQuestions with priority ordering + area-ambiguity detection, prompt templates updated (LIGHT_INDUSTRIAL, budget unit, ambiguity detection, decimal precision) - Tests: all 154 passing; fixed test regression caused by OfferEmailResponseSchema body min(50) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,45 @@
|
||||
import { Box, Card, TextField, Typography } from '@mui/material'
|
||||
|
||||
interface Props {
|
||||
street: string
|
||||
onStreetChange: (v: string) => void
|
||||
houseNumber: string
|
||||
onHouseNumberChange: (v: string) => void
|
||||
postalCode: string
|
||||
onPostalCodeChange: (v: string) => void
|
||||
city: string
|
||||
onCityChange: (v: string) => void
|
||||
}
|
||||
|
||||
export function AddressSection({
|
||||
street, onStreetChange,
|
||||
houseNumber, onHouseNumberChange,
|
||||
postalCode, onPostalCodeChange,
|
||||
city, onCityChange,
|
||||
}: Props) {
|
||||
return (
|
||||
<Card sx={{ p: 3, mb: 3 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 2 }}>Adresse</Typography>
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: '3fr 1fr', gap: 2, mb: 2 }}>
|
||||
<TextField
|
||||
label="Strasse" value={street}
|
||||
onChange={e => onStreetChange(e.target.value)} size="small" fullWidth
|
||||
/>
|
||||
<TextField
|
||||
label="Nr." value={houseNumber}
|
||||
onChange={e => onHouseNumberChange(e.target.value)} size="small" fullWidth
|
||||
/>
|
||||
</Box>
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 2fr', gap: 2 }}>
|
||||
<TextField
|
||||
label="PLZ" value={postalCode}
|
||||
onChange={e => onPostalCodeChange(e.target.value)} size="small" fullWidth
|
||||
/>
|
||||
<TextField
|
||||
label="Ort" value={city}
|
||||
onChange={e => onCityChange(e.target.value)} size="small" fullWidth
|
||||
/>
|
||||
</Box>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { Alert, Box, Button, Card, CircularProgress, TextField, Typography } from '@mui/material'
|
||||
import { Sparkles } from 'lucide-react'
|
||||
import { DS_TEXT, DS_SURFACE, DS_BORDER } from '../../lib/ds'
|
||||
|
||||
interface Props {
|
||||
text: string
|
||||
onTextChange: (v: string) => void
|
||||
onParse: () => void
|
||||
parsing: boolean
|
||||
applied: boolean
|
||||
}
|
||||
|
||||
export function AiAssistCard({ text, onTextChange, onParse, parsing, applied }: Props) {
|
||||
return (
|
||||
<Card sx={{ p: 3, mb: 3, border: `1px solid ${DS_SURFACE.indigo.border}`, bgcolor: DS_SURFACE.indigo.bg }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
|
||||
<Sparkles size={16} color={DS_TEXT.brand} />
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: DS_TEXT.brand }}>
|
||||
KI-Hilfe — Formular automatisch ausfüllen
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 1.5 }}>
|
||||
Beschreiben Sie die Fläche in eigenen Worten — die KI füllt die Felder automatisch aus.
|
||||
</Typography>
|
||||
<TextField
|
||||
multiline
|
||||
rows={3}
|
||||
fullWidth
|
||||
size="small"
|
||||
placeholder="z.B.: Bürofläche 450 m² im Zentrum Zürich, 3. OG, CHF 280/m²/Jahr, sehr gute ÖV-Anbindung, 4 Parkplätze, Vollausbau, verfügbar ab Juli 2025"
|
||||
value={text}
|
||||
onChange={e => onTextChange(e.target.value)}
|
||||
sx={{ mb: 1.5, bgcolor: 'white' }}
|
||||
/>
|
||||
{applied && (
|
||||
<Alert severity="success" sx={{ mb: 1.5, py: 0.5 }}>
|
||||
Felder wurden automatisch ausgefüllt — bitte überprüfen und ergänzen.
|
||||
</Alert>
|
||||
)}
|
||||
<Button
|
||||
variant="contained"
|
||||
size="small"
|
||||
startIcon={parsing ? <CircularProgress size={13} color="inherit" /> : <Sparkles size={13} />}
|
||||
onClick={onParse}
|
||||
disabled={!text.trim() || parsing}
|
||||
sx={{ bgcolor: DS_TEXT.brand, '&:hover': { bgcolor: DS_BORDER.strong }, textTransform: 'none' }}
|
||||
>
|
||||
{parsing ? 'Analysiert…' : 'KI analysieren'}
|
||||
</Button>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { Box, Card, MenuItem, TextField, Typography } from '@mui/material'
|
||||
import { ASSET_TYPE_LABELS } from '../../pages/supply/newListingConstants'
|
||||
|
||||
interface Props {
|
||||
assetType: string
|
||||
onAssetTypeChange: (v: string) => void
|
||||
areaSqm: string
|
||||
onAreaSqmChange: (v: string) => void
|
||||
rentPerSqm: string
|
||||
onRentPerSqmChange: (v: string) => void
|
||||
availableFrom: string
|
||||
onAvailableFromChange: (v: string) => void
|
||||
description: string
|
||||
onDescriptionChange: (v: string) => void
|
||||
}
|
||||
|
||||
export function AreaDetailsSection({
|
||||
assetType, onAssetTypeChange,
|
||||
areaSqm, onAreaSqmChange,
|
||||
rentPerSqm, onRentPerSqmChange,
|
||||
availableFrom, onAvailableFromChange,
|
||||
description, onDescriptionChange,
|
||||
}: Props) {
|
||||
return (
|
||||
<Card sx={{ p: 3, mb: 3 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 2 }}>Flächendetails</Typography>
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 2 }}>
|
||||
<TextField
|
||||
select label="Flächentyp" value={assetType}
|
||||
onChange={e => onAssetTypeChange(e.target.value)} size="small" fullWidth
|
||||
>
|
||||
{Object.entries(ASSET_TYPE_LABELS).map(([v, l]) => (
|
||||
<MenuItem key={v} value={v}>{l}</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
|
||||
<TextField
|
||||
label="Fläche (m²)" value={areaSqm}
|
||||
onChange={e => onAreaSqmChange(e.target.value)}
|
||||
size="small" type="number" inputProps={{ min: 1 }} fullWidth
|
||||
/>
|
||||
|
||||
<TextField
|
||||
label="Mietpreis (CHF/m²/Jahr)" value={rentPerSqm}
|
||||
onChange={e => onRentPerSqmChange(e.target.value)}
|
||||
size="small" type="number" inputProps={{ min: 1 }} fullWidth
|
||||
/>
|
||||
|
||||
<TextField
|
||||
label="Verfügbar ab" value={availableFrom}
|
||||
onChange={e => onAvailableFromChange(e.target.value)}
|
||||
size="small" type="date"
|
||||
slotProps={{ inputLabel: { shrink: true } }} fullWidth
|
||||
/>
|
||||
</Box>
|
||||
<TextField
|
||||
label="Beschreibung (optional)" value={description}
|
||||
onChange={e => onDescriptionChange(e.target.value)}
|
||||
size="small" multiline rows={3} fullWidth sx={{ mt: 2 }}
|
||||
/>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Box, Card, TextField, Typography } from '@mui/material'
|
||||
|
||||
interface Props {
|
||||
name: string
|
||||
onNameChange: (v: string) => void
|
||||
email: string
|
||||
onEmailChange: (v: string) => void
|
||||
phone: string
|
||||
onPhoneChange: (v: string) => void
|
||||
}
|
||||
|
||||
export function ContactSection({ name, onNameChange, email, onEmailChange, phone, onPhoneChange }: Props) {
|
||||
return (
|
||||
<Card sx={{ p: 3, mb: 3 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 2 }}>Kontakt (optional)</Typography>
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 2 }}>
|
||||
<TextField
|
||||
label="Name" value={name}
|
||||
onChange={e => onNameChange(e.target.value)} size="small" fullWidth
|
||||
/>
|
||||
<TextField
|
||||
label="Telefon" value={phone}
|
||||
onChange={e => onPhoneChange(e.target.value)} size="small" fullWidth
|
||||
/>
|
||||
<TextField
|
||||
label="E-Mail" value={email}
|
||||
onChange={e => onEmailChange(e.target.value)}
|
||||
size="small" type="email" fullWidth sx={{ gridColumn: '1 / -1' }}
|
||||
/>
|
||||
</Box>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Box, Button, Typography } from '@mui/material'
|
||||
import { CheckCircle } from 'lucide-react'
|
||||
import { DS_TEXT } from '../../lib/ds'
|
||||
|
||||
interface Props {
|
||||
onViewListings: () => void
|
||||
onCreateAnother: () => void
|
||||
}
|
||||
|
||||
export function CreatedScreen({ onViewListings, onCreateAnother }: Props) {
|
||||
return (
|
||||
<Box sx={{ maxWidth: 520, mx: 'auto', mt: 8, px: 3, textAlign: 'center' }}>
|
||||
<CheckCircle size={48} color={DS_TEXT.success} style={{ marginBottom: 16 }} />
|
||||
<Typography variant="h5" sx={{ fontWeight: 700, mb: 1 }}>Inserat erstellt</Typography>
|
||||
<Typography color="text.secondary" sx={{ mb: 3 }}>
|
||||
Das Inserat wurde veröffentlicht und ist für passende Suchanfragen sichtbar.
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 2, justifyContent: 'center' }}>
|
||||
<Button variant="outlined" onClick={onViewListings}>
|
||||
Meine Inserate
|
||||
</Button>
|
||||
<Button variant="contained" sx={{ bgcolor: DS_TEXT.brand, '&:hover': { bgcolor: DS_TEXT.brandDark } }} onClick={onCreateAnother}>
|
||||
Weiteres Inserat
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { Box, Button, Card, Chip, TextField, Typography } from '@mui/material'
|
||||
import { ImagePlus } from 'lucide-react'
|
||||
|
||||
interface Props {
|
||||
images: string[]
|
||||
imageInput: string
|
||||
onImageInputChange: (v: string) => void
|
||||
onAdd: () => void
|
||||
onRemove: (index: number) => void
|
||||
}
|
||||
|
||||
export function ImageUrlSection({ images, imageInput, onImageInputChange, onAdd, onRemove }: Props) {
|
||||
return (
|
||||
<Card sx={{ p: 3, mb: 3 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 2 }}>Bilder (optional)</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 1, mb: 1.5 }}>
|
||||
<TextField
|
||||
label="Bild-URL eingeben"
|
||||
value={imageInput}
|
||||
onChange={e => onImageInputChange(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); onAdd() } }}
|
||||
size="small"
|
||||
fullWidth
|
||||
placeholder="https://…"
|
||||
/>
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
startIcon={<ImagePlus size={15} />}
|
||||
onClick={onAdd}
|
||||
disabled={!imageInput.trim()}
|
||||
sx={{ whiteSpace: 'nowrap', textTransform: 'none' }}
|
||||
>
|
||||
Hinzufügen
|
||||
</Button>
|
||||
</Box>
|
||||
{images.length > 0 && (
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
|
||||
{images.map((url, i) => (
|
||||
<Chip
|
||||
key={i}
|
||||
label={url.length > 40 ? url.slice(0, 40) + '…' : url}
|
||||
size="small"
|
||||
onDelete={() => onRemove(i)}
|
||||
sx={{ maxWidth: 300 }}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Box, Card, MenuItem, TextField, Typography } from '@mui/material'
|
||||
import { SOFT_FACTORS, LEVEL_OPTIONS } from '../../pages/supply/newListingConstants'
|
||||
|
||||
interface Props {
|
||||
softLevels: Record<string, string>
|
||||
onChange: (key: string, value: string) => void
|
||||
}
|
||||
|
||||
export function SoftFactorsSection({ softLevels, onChange }: Props) {
|
||||
return (
|
||||
<Card sx={{ p: 3, mb: 3 }}>
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>Lage & Ausstrahlung</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Diese Angaben verbessern die Match-Qualität erheblich.
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 2 }}>
|
||||
{SOFT_FACTORS.map(({ key, label }) => (
|
||||
<TextField
|
||||
key={key}
|
||||
select
|
||||
label={label}
|
||||
value={softLevels[key] ?? ''}
|
||||
onChange={e => onChange(key, e.target.value)}
|
||||
size="small"
|
||||
fullWidth
|
||||
>
|
||||
{LEVEL_OPTIONS.map(o => (
|
||||
<MenuItem key={o.value} value={o.value}>{o.label}</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
))}
|
||||
</Box>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { Box, Card, MenuItem, TextField, Typography } from '@mui/material'
|
||||
import { FIT_OUT_OPTIONS } from '../../pages/supply/newListingConstants'
|
||||
|
||||
interface Props {
|
||||
floor: string
|
||||
onFloorChange: (v: string) => void
|
||||
fitOut: string
|
||||
onFitOutChange: (v: string) => void
|
||||
parking: string
|
||||
onParkingChange: (v: string) => void
|
||||
ceilingHeight: string
|
||||
onCeilingHeightChange: (v: string) => void
|
||||
}
|
||||
|
||||
export function TechnicalDetailsSection({
|
||||
floor, onFloorChange,
|
||||
fitOut, onFitOutChange,
|
||||
parking, onParkingChange,
|
||||
ceilingHeight, onCeilingHeightChange,
|
||||
}: Props) {
|
||||
return (
|
||||
<Card sx={{ p: 3, mb: 3 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 2 }}>Technische Details (optional)</Typography>
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr 1fr', gap: 2 }}>
|
||||
<TextField
|
||||
label="Stockwerk" value={floor}
|
||||
onChange={e => onFloorChange(e.target.value)}
|
||||
size="small" type="number" fullWidth placeholder="0 = EG"
|
||||
/>
|
||||
<TextField
|
||||
select label="Ausbaustandard" value={fitOut}
|
||||
onChange={e => onFitOutChange(e.target.value)} size="small" fullWidth
|
||||
>
|
||||
{FIT_OUT_OPTIONS.map(o => (
|
||||
<MenuItem key={o.value} value={o.value}>{o.label}</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
<TextField
|
||||
label="Parkplätze" value={parking}
|
||||
onChange={e => onParkingChange(e.target.value)}
|
||||
size="small" type="number" inputProps={{ min: 0 }} fullWidth
|
||||
/>
|
||||
<TextField
|
||||
label="Deckenhöhe (m)" value={ceilingHeight}
|
||||
onChange={e => onCeilingHeightChange(e.target.value)}
|
||||
size="small" type="number" inputProps={{ step: 0.1, min: 2 }} fullWidth
|
||||
/>
|
||||
</Box>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export { AiAssistCard } from './AiAssistCard'
|
||||
export { AreaDetailsSection } from './AreaDetailsSection'
|
||||
export { AddressSection } from './AddressSection'
|
||||
export { SoftFactorsSection } from './SoftFactorsSection'
|
||||
export { TechnicalDetailsSection } from './TechnicalDetailsSection'
|
||||
export { ImageUrlSection } from './ImageUrlSection'
|
||||
export { ContactSection } from './ContactSection'
|
||||
export { CreatedScreen } from './CreatedScreen'
|
||||
Reference in New Issue
Block a user