feat: all 4 UX priorities — wizard, examples, onboarding, pipeline

- Offer wizard: 4→3 steps by merging 'Prüfen' into PDF review step
- Search mask: quick-select chips for area ranges, budget, and city presets
- Onboarding: WelcomeDialog (3 slides, localStorage) on first visit
- Deal Pipeline: Kanban board (6 stages, 8 mock items, advance-to-next-stage)
  added to demand workspace navigation

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Benjamin Sutter
2026-05-18 22:09:06 +02:00
parent 9cf3e72033
commit 14badd1ca8
9 changed files with 581 additions and 19 deletions
+243
View File
@@ -0,0 +1,243 @@
import { useState } from 'react'
import { Box, Typography, Chip, Paper, Button } from '@mui/material'
import type { PipelineItem, PipelineStage } from '../../domain/pipeline'
import { mockPipelineItems } from '../../mock-data/pipelineItems'
const STAGES = [
{ key: 'DISCOVERED' as PipelineStage, label: 'Entdeckt', color: '#475569', bgColor: '#f8fafc' },
{ key: 'QUALIFIED' as PipelineStage, label: 'Qualifiziert', color: '#1e3a5f', bgColor: '#eff6ff' },
{ key: 'VISITED' as PipelineStage, label: 'Besichtigt', color: '#d97706', bgColor: '#fffbeb' },
{ key: 'NEGOTIATION' as PipelineStage, label: 'Verhandlung', color: '#7c3aed', bgColor: '#faf5ff' },
{ key: 'CLOSED_WON' as PipelineStage, label: 'Gewonnen', color: '#1a7a4a', bgColor: '#f0fdf4' },
{ key: 'CLOSED_LOST' as PipelineStage, label: 'Abgelehnt', color: '#c0392b', bgColor: '#fef2f2' },
] as const
const NEXT_STAGE_LABEL: Partial<Record<PipelineStage, string>> = {
DISCOVERED: 'Qualifizieren →',
QUALIFIED: 'Besichtigung planen →',
VISITED: 'Verhandlung →',
NEGOTIATION: 'Als gewonnen markieren →',
}
const RESULT_TYPE_LABEL: Record<PipelineItem['resultType'], string> = {
VERIFIED_PORTFOLIO: 'Portfolio',
EXTERNAL_MARKET: 'Markt',
FUTURE_AVAILABILITY: 'Schattenmarkt',
}
const RESULT_TYPE_COLOR: Record<PipelineItem['resultType'], string> = {
VERIFIED_PORTFOLIO: '#1e3a5f',
EXTERNAL_MARKET: '#d97706',
FUTURE_AVAILABILITY: '#7c3aed',
}
function scoreColor(score: number): string {
if (score >= 80) return '#1a7a4a'
if (score >= 65) return '#d97706'
return '#c0392b'
}
function PipelineCard({
item,
onAdvance,
}: {
item: PipelineItem
onAdvance: (id: string) => void
}) {
const stageConfig = STAGES.find((s) => s.key === item.stage)!
const showAdvance = item.stage !== 'CLOSED_WON' && item.stage !== 'CLOSED_LOST'
return (
<Paper
elevation={0}
sx={{
p: 1.5,
borderRadius: 1.5,
border: '1px solid #e2e8f0',
cursor: 'default',
bgcolor: 'white',
}}
>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 1 }}>
<Typography
variant="body2"
sx={{ fontWeight: 700, flex: 1, minWidth: 0 }}
noWrap
>
{item.title}
</Typography>
<Typography
sx={{ fontWeight: 900, fontSize: '1rem', color: scoreColor(item.matchScore), flexShrink: 0 }}
>
{item.matchScore}%
</Typography>
</Box>
<Typography variant="caption" color="text.secondary">
{item.location}
</Typography>
<Box>
<Chip
size="small"
label={RESULT_TYPE_LABEL[item.resultType]}
sx={{
bgcolor: RESULT_TYPE_COLOR[item.resultType],
color: 'white',
fontSize: 10,
height: 18,
mt: 0.5,
}}
/>
</Box>
{(item.areaLabel || item.rentLabel) && (
<Box sx={{ display: 'flex', gap: 1, mt: 0.5 }}>
{item.areaLabel && (
<Typography variant="caption" sx={{ color: '#475569' }}>
{item.areaLabel}
</Typography>
)}
{item.rentLabel && (
<Typography variant="caption" sx={{ color: '#475569' }}>
{item.rentLabel}
</Typography>
)}
</Box>
)}
{item.notes && (
<Typography
variant="caption"
sx={{ fontStyle: 'italic', color: 'text.secondary', mt: 0.5, display: 'block' }}
>
{item.notes}
</Typography>
)}
{showAdvance && (
<Button
size="small"
variant="text"
onClick={() => onAdvance(item.id)}
sx={{
color: stageConfig.color,
fontSize: '0.7rem',
p: 0,
mt: 0.75,
minWidth: 0,
textTransform: 'none',
}}
>
{NEXT_STAGE_LABEL[item.stage]}
</Button>
)}
</Paper>
)
}
export default function Pipeline() {
const [items, setItems] = useState<PipelineItem[]>(mockPipelineItems)
const activeCount = items.filter(
(i) => i.stage !== 'CLOSED_WON' && i.stage !== 'CLOSED_LOST'
).length
function handleAdvance(id: string) {
setItems((prev) =>
prev.map((item) => {
if (item.id !== id) return item
const idx = STAGES.findIndex((s) => s.key === item.stage)
if (idx === -1 || idx >= STAGES.length - 1) return item
return { ...item, stage: STAGES[idx + 1].key }
})
)
}
return (
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<Box
sx={{
bgcolor: 'white',
borderBottom: '1px solid #e2e8f0',
px: 3,
py: 2,
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
flexShrink: 0,
position: 'sticky',
top: 0,
zIndex: 1,
}}
>
<Box>
<Typography variant="h5" sx={{ fontWeight: 700 }}>
Deal Pipeline
</Typography>
<Typography variant="body2" color="text.secondary">
Verfolgen Sie Objekte von der Entdeckung bis zum Abschluss
</Typography>
</Box>
<Chip label={`${activeCount} aktiv`} size="small" />
</Box>
<Box
sx={{
display: 'flex',
gap: 2,
px: 3,
py: 2,
overflowX: 'auto',
flex: 1,
alignItems: 'flex-start',
}}
>
{STAGES.map((stage) => {
const columnItems = items.filter((i) => i.stage === stage.key)
return (
<Box
key={stage.key}
sx={{
minWidth: 240,
maxWidth: 280,
flexShrink: 0,
display: 'flex',
flexDirection: 'column',
}}
>
<Box
sx={{
py: 1,
display: 'flex',
alignItems: 'center',
gap: 1,
}}
>
<Typography sx={{ fontWeight: 700, color: stage.color }}>
{stage.label}
</Typography>
<Chip
label={columnItems.length}
size="small"
sx={{
bgcolor: stage.bgColor,
color: stage.color,
border: `1px solid ${stage.color}30`,
fontWeight: 600,
}}
/>
</Box>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1, overflowY: 'auto' }}>
{columnItems.map((item) => (
<PipelineCard key={item.id} item={item} onAdvance={handleAdvance} />
))}
</Box>
</Box>
)
})}
</Box>
</Box>
)
}