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
+2
View File
@@ -32,6 +32,7 @@ const Results = lazy(() => import('./pages/demand/Results'))
const MatchDetail = lazy(() => import('./pages/demand/MatchDetail'))
const Compare = lazy(() => import('./pages/demand/Compare'))
const Shortlists = lazy(() => import('./pages/demand/Shortlists'))
const Pipeline = lazy(() => import('./pages/demand/Pipeline'))
const ReviewQueue = lazy(() => import('./pages/ops/ReviewQueue'))
const AIMonitoring = lazy(() => import('./pages/ops/AIMonitoring'))
@@ -71,6 +72,7 @@ function App() {
<Route path="/demand/results/:matchId" element={<MatchDetail />} />
<Route path="/demand/compare" element={<Compare />} />
<Route path="/demand/shortlists" element={<Shortlists />} />
<Route path="/demand/pipeline" element={<Pipeline />} />
</Route>
{/* Operations Workspace */}
@@ -1,9 +1,8 @@
import { useEffect, useMemo, useState } from 'react'
import { Box, Button, CircularProgress, Typography } from '@mui/material'
import { ArrowLeft, ArrowRight } from 'lucide-react'
import { ArrowLeft, Send } from 'lucide-react'
import { useOfferWizardStore } from '../../stores/offerWizardStore'
import { offerService } from '../../services/offerService'
import { useUpdateOfferField, useGeneratePdfPreview } from '../../hooks/useOffers'
import { useUpdateOfferField, useGeneratePdfPreview, useMarkOfferChecked } from '../../hooks/useOffers'
import { useToastStore } from '../../stores/toastStore'
import { MockPdfPreview } from './MockPdfPreview'
import { EditableOfferFieldList } from './EditableOfferFieldList'
@@ -22,6 +21,7 @@ export function OfferPdfReviewStep() {
const updateFieldMut = useUpdateOfferField()
const genPreview = useGeneratePdfPreview()
const markChecked = useMarkOfferChecked()
const showToast = useToastStore(s => s.showToast)
useEffect(() => {
@@ -66,13 +66,34 @@ export function OfferPdfReviewStep() {
}
}
const handleNext = () => {
const needTitle = useOfferWizardStore(s => s.needTitle)
const addAttachment = useOfferWizardStore(s => s.addAttachment)
const setMessageDraft = useOfferWizardStore(s => s.setMessageDraft)
const setMessageSubject = useOfferWizardStore(s => s.setMessageSubject)
const handleNext = async () => {
if (!offerDraftId) return
if (!pdfReady) {
showToast('PDF-Vorschau wird noch generiert...', 'info')
return
}
setStep('checked')
const res = await markChecked.mutateAsync(offerDraftId)
if (res.error) {
showToast(`Fehler: ${res.error}`, 'error')
return
}
addAttachment({
id: crypto.randomUUID(),
fileName: `Angebot_${needTitle.replace(/\s+/g, '_')}.pdf`,
fileType: 'application/pdf',
fileSize: 320_000,
generated: true,
})
setMessageSubject(`Passende Gewerbeflächen zu Ihrer Anfrage: ${needTitle}`)
setMessageDraft(
`Sehr geehrte Damen und Herren,\n\nbitte finden Sie anbei unser Angebot zu Ihrem Bedarf "${needTitle}". Gerne stehen wir für Rückfragen und Besichtigungstermine zur Verfügung.\n\nFreundliche Grüsse\nWincasa AG`,
)
setStep('send')
}
if (loadingDraft || !draft) {
@@ -153,12 +174,12 @@ export function OfferPdfReviewStep() {
</Button>
<Button
variant="contained"
endIcon={<ArrowRight size={14} />}
endIcon={markChecked.isPending ? <CircularProgress size={14} sx={{ color: 'white' }} /> : <Send size={14} />}
onClick={handleNext}
disabled={!pdfReady}
disabled={!pdfReady || markChecked.isPending}
sx={{ textTransform: 'none', bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#16304d' } }}
>
Angebot prüfen
{markChecked.isPending ? 'Wird geprüft…' : 'Angebot senden →'}
</Button>
</Box>
</Box>
+11 -4
View File
@@ -16,13 +16,20 @@ import { OfferPdfReviewStep } from './OfferPdfReviewStep'
import { OfferCheckedAction } from './OfferCheckedAction'
import { OfferChatComposer } from './OfferChatComposer'
// 'checked' is internal — merged into pdf_review step; only 3 steps shown
const STEPS: { key: OfferStep; label: string }[] = [
{ key: 'select_properties', label: 'Objekte wählen' },
{ key: 'pdf_review', label: 'PDF-Vorschau' },
{ key: 'checked', label: 'Prüfen' },
{ key: 'send', label: 'Senden' },
{ key: 'pdf_review', label: 'Vorschau & Prüfen' },
{ key: 'send', label: 'Senden' },
]
const STEP_DISPLAY_INDEX: Record<OfferStep, number> = {
select_properties: 0,
pdf_review: 1,
checked: 1, // same visual step as pdf_review
send: 2,
}
export function OfferWizard() {
const theme = useTheme()
const fullScreen = useMediaQuery(theme.breakpoints.down('md'))
@@ -32,7 +39,7 @@ export function OfferWizard() {
const currentStep = useOfferWizardStore(s => s.currentStep)
const needTitle = useOfferWizardStore(s => s.needTitle)
const activeIndex = STEPS.findIndex(s => s.key === currentStep)
const activeIndex = STEP_DISPLAY_INDEX[currentStep] ?? 0
return (
<Dialog
+75 -7
View File
@@ -17,6 +17,22 @@ const ASSET_OPTIONS = [
{ 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: '25 CHF/m²', value: 25 },
{ label: '45 CHF/m²', value: 45 },
{ label: '65 CHF/m²', value: 65 },
{ label: '100 CHF/m²', value: 100 },
]
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 }}>
@@ -70,7 +86,7 @@ export function NeedInput({ criteria: c, onCriteriaChange: set }: Props) {
{/* Area */}
<FieldLabel>Fläche (m²)</FieldLabel>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.75 }}>
<TextField
size="small" type="number" placeholder="Min"
value={c.areaRange?.min || ''}
@@ -88,35 +104,87 @@ export function NeedInput({ criteria: c, onCriteriaChange: set }: Props) {
/>
<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="Stadt oder Region — Enter zum Hinzufügen"
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?.length ?? 0) > 0 ? (
{(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!.map(loc => (
{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: 2.5 }} />}
)}
<Box sx={{ mb: (c.preferredLocations?.filter(l => !LOCATION_PRESETS.includes(l)).length ?? 0) > 0 ? 0 : 2.5 }} />
{/* Budget */}
<FieldLabel>Budget (max CHF/m²)</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="z.B. 45"
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: 160, mb: 2.5 }}
sx={{ width: 180, mb: 2.5 }}
slotProps={{ htmlInput: { min: 0 } }}
/>
+4
View File
@@ -1,5 +1,6 @@
import { useEffect, useState } from 'react'
import { Outlet, NavLink, useNavigate, useLocation } from 'react-router'
import { WelcomeDialog } from '../onboarding/WelcomeDialog'
import { useLayoutStore } from '../../stores/layoutStore'
import { useSessionStore } from '../../stores/sessionStore'
import { WorkspaceType } from '../../domain/enums'
@@ -37,6 +38,7 @@ import {
GitBranch,
MessageSquare,
Menu,
Kanban,
} from 'lucide-react'
import type { LucideIcon } from 'lucide-react'
import { OrganizationContextBadge } from './OrganizationContextBadge'
@@ -97,6 +99,7 @@ const WORKSPACE_CONFIG: Record<WorkspaceType, WorkspaceConfig> = {
{ path: '/demand/results', label: 'Ergebnisse', icon: List },
{ path: '/demand/compare', label: 'Vergleich', icon: Columns2 },
{ path: '/demand/shortlists', label: 'Shortlists', icon: Bookmark },
{ path: '/demand/pipeline', label: 'Deal Pipeline', icon: Kanban },
],
},
[WorkspaceType.OPERATIONS]: {
@@ -598,6 +601,7 @@ export function AppShell() {
<GlobalAIAssistantDrawer />
<GlobalAIAssistantButton />
<ToastProvider />
<WelcomeDialog />
</Box>
)
}
+188
View File
@@ -0,0 +1,188 @@
import { useState } from 'react'
import {
Dialog,
DialogContent,
Box,
Typography,
MobileStepper,
Button,
} from '@mui/material'
import {
Building2,
CheckCircle2,
Zap,
Search,
Mic,
Sparkles,
Newspaper,
FileCheck,
ArrowLeft,
ArrowRight,
} from 'lucide-react'
function FeatureRow({
icon,
color,
text,
}: {
icon: React.ReactNode
color: string
text: string
}) {
return (
<Box className="flex items-center gap-2 mt-2">
<Box sx={{ color, flexShrink: 0 }}>{icon}</Box>
<Typography variant="body2">{text}</Typography>
</Box>
)
}
function Slide0() {
return (
<Box className="flex flex-col items-center text-center px-2">
<Box className="mb-4">
<Building2 size={48} color="#1e3a5f" />
</Box>
<Typography variant="h5" sx={{ fontWeight: 700, mb: 1 }}>
Willkommen bei Property Match
</Typography>
<Typography variant="body1" color="text.secondary" sx={{ mb: 3 }}>
Die KI-gestützte Plattform für gewerbliche Immobilien
</Typography>
<Box className="w-full text-left">
<FeatureRow
icon={<CheckCircle2 size={18} />}
color="#1a7a4a"
text="Verifizierte Portfolio-Objekte"
/>
<FeatureRow
icon={<CheckCircle2 size={18} />}
color="#1a7a4a"
text="Externer Markt & Inserate"
/>
<FeatureRow
icon={<Zap size={18} />}
color="#7c3aed"
text="Schattenmarkt-Signale der KI"
/>
</Box>
</Box>
)
}
function Slide1() {
return (
<Box className="flex flex-col items-center text-center px-2">
<Box className="mb-4">
<Search size={48} color="#1e3a5f" />
</Box>
<Typography variant="h5" sx={{ fontWeight: 700, mb: 1 }}>
Bedarf beschreiben KI findet Matches
</Typography>
<Typography variant="body1" color="text.secondary" sx={{ mb: 3 }}>
Schreiben Sie Ihren Flächenbedarf in natürlicher Sprache. Die KI extrahiert alle Kriterien
und findet die besten Matches aus Portfolio, Markt und Schattenmarkt.
</Typography>
<Box className="w-full text-left">
<FeatureRow
icon={<Mic size={18} />}
color="#d97706"
text="Spracheingabe auf Deutsch"
/>
<FeatureRow
icon={<Sparkles size={18} />}
color="#7c3aed"
text="KI analysiert und gewichtet automatisch"
/>
</Box>
</Box>
)
}
function Slide2() {
return (
<Box className="flex flex-col items-center text-center px-2">
<Box className="mb-4">
<Zap size={48} color="#7c3aed" />
</Box>
<Typography variant="h5" sx={{ fontWeight: 700, mb: 1 }}>
Schattenmarkt Vor dem Markt informiert
</Typography>
<Typography variant="body1" color="text.secondary" sx={{ mb: 3 }}>
Die KI überwacht Stelleninserate, Pressemeldungen und Baubewilligungen frühzeitige
Hinweise auf mögliche Verfügbarkeiten, bevor sie ausgeschrieben werden.
</Typography>
<Box className="w-full text-left">
<FeatureRow
icon={<Newspaper size={18} />}
color="#d97706"
text="Presse & Stelleninserate"
/>
<FeatureRow
icon={<FileCheck size={18} />}
color="#1a7a4a"
text="Baubewilligungen & Berichte"
/>
</Box>
</Box>
)
}
const SLIDES = [<Slide0 />, <Slide1 />, <Slide2 />]
export function WelcomeDialog() {
const [open, setOpen] = useState(() => {
return localStorage.getItem('property_match_welcomed') === null
})
const [activeStep, setActiveStep] = useState(0)
function dismiss() {
localStorage.setItem('property_match_welcomed', '1')
setOpen(false)
}
return (
<Dialog
open={open}
maxWidth="sm"
fullWidth
disableEscapeKeyDown
>
<DialogContent sx={{ pt: 4, pb: 2 }}>
{SLIDES[activeStep]}
<MobileStepper
variant="dots"
steps={3}
position="static"
activeStep={activeStep}
sx={{ mt: 3, background: 'transparent' }}
backButton={
<Button
size="small"
onClick={() => setActiveStep((s) => s - 1)}
disabled={activeStep === 0}
startIcon={<ArrowLeft size={16} />}
>
Zurück
</Button>
}
nextButton={
activeStep === 2 ? (
<Button size="small" variant="contained" onClick={dismiss}>
Los geht's
</Button>
) : (
<Button
size="small"
onClick={() => setActiveStep((s) => s + 1)}
endIcon={<ArrowRight size={16} />}
>
Weiter
</Button>
)
}
/>
</DialogContent>
</Dialog>
)
}
+17
View File
@@ -0,0 +1,17 @@
export type PipelineStage = 'DISCOVERED' | 'QUALIFIED' | 'VISITED' | 'NEGOTIATION' | 'CLOSED_WON' | 'CLOSED_LOST'
export interface PipelineItem {
id: string
title: string
location: string
matchScore: number
resultType: 'VERIFIED_PORTFOLIO' | 'EXTERNAL_MARKET' | 'FUTURE_AVAILABILITY'
stage: PipelineStage
areaLabel?: string
rentLabel?: string
availabilityLabel?: string
addedAt: string
updatedAt: string
notes?: string
assignedTo?: string
}
+12
View File
@@ -0,0 +1,12 @@
import type { PipelineItem } from '../domain/pipeline'
export const mockPipelineItems: PipelineItem[] = [
{ id: 'pl-001', title: 'Bürofläche Zollstrasse 12', location: 'Zürich, Zürich-West', matchScore: 91, resultType: 'VERIFIED_PORTFOLIO', stage: 'NEGOTIATION', areaLabel: '850 m²', rentLabel: 'CHF 42/m²', availabilityLabel: '2025-09-01', addedAt: '2025-05-01T10:00:00Z', updatedAt: '2025-05-15T14:00:00Z', assignedTo: 'B. Sutter' },
{ id: 'pl-002', title: 'Gewerbe Hardturmstrasse', location: 'Zürich-West', matchScore: 87, resultType: 'VERIFIED_PORTFOLIO', stage: 'VISITED', areaLabel: '1200 m²', rentLabel: 'CHF 38/m²', availabilityLabel: '2025-10-01', addedAt: '2025-05-03T10:00:00Z', updatedAt: '2025-05-14T09:00:00Z' },
{ id: 'pl-003', title: 'Büro Binzstrasse 23', location: 'Zürich, Binz', matchScore: 83, resultType: 'EXTERNAL_MARKET', stage: 'VISITED', areaLabel: '720 m²', rentLabel: 'CHF 47/m²', addedAt: '2025-05-04T10:00:00Z', updatedAt: '2025-05-13T10:00:00Z' },
{ id: 'pl-004', title: 'Bürofläche Bahnhofstrasse', location: 'Zürich, Innenstadt', matchScore: 79, resultType: 'EXTERNAL_MARKET', stage: 'QUALIFIED', areaLabel: '950 m²', rentLabel: 'CHF 85/m²', addedAt: '2025-05-05T10:00:00Z', updatedAt: '2025-05-12T10:00:00Z', notes: 'Budget zu hoch — prüfen' },
{ id: 'pl-005', title: 'DataCloud Systems AG', location: 'Zürich-West / Technopark', matchScore: 76, resultType: 'FUTURE_AVAILABILITY', stage: 'QUALIFIED', areaLabel: '~600 m²', availabilityLabel: '~10 Monate', addedAt: '2025-05-06T10:00:00Z', updatedAt: '2025-05-11T10:00:00Z' },
{ id: 'pl-006', title: 'Neubau Wankdorf Business', location: 'Bern, Wankdorf', matchScore: 72, resultType: 'FUTURE_AVAILABILITY', stage: 'DISCOVERED', areaLabel: '~4500 m²', availabilityLabel: '~24 Monate', addedAt: '2025-05-07T10:00:00Z', updatedAt: '2025-05-10T10:00:00Z' },
{ id: 'pl-007', title: 'Bürofläche Stadthaus Bern', location: 'Bern Innenstadt', matchScore: 88, resultType: 'VERIFIED_PORTFOLIO', stage: 'CLOSED_WON', areaLabel: '650 m²', rentLabel: 'CHF 52/m²', availabilityLabel: '2025-07-01', addedAt: '2025-04-10T10:00:00Z', updatedAt: '2025-05-08T10:00:00Z', assignedTo: 'B. Sutter', notes: 'Vertrag unterschrieben' },
{ id: 'pl-008', title: 'Gewerbe Güterstrasse', location: 'Basel', matchScore: 68, resultType: 'EXTERNAL_MARKET', stage: 'CLOSED_LOST', areaLabel: '800 m²', rentLabel: 'CHF 28/m²', addedAt: '2025-04-15T10:00:00Z', updatedAt: '2025-05-05T10:00:00Z', notes: 'Vermieter hat anderes Unternehmen bevorzugt' },
]
+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>
)
}