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
@@ -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>
)
}