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:
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user