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:
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -17,6 +17,22 @@ const ASSET_OPTIONS = [
|
||||
{ label: 'Gemischt', value: AssetType.MIXED },
|
||||
]
|
||||
|
||||
const AREA_PRESETS = [
|
||||
{ label: '200–500 m²', min: 200, max: 500 },
|
||||
{ label: '500–1000 m²', min: 500, max: 1000 },
|
||||
{ label: '1000–2000 m²', min: 1000, max: 2000 },
|
||||
{ label: '2000–5000 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 } }}
|
||||
/>
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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' },
|
||||
]
|
||||
@@ -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