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