diff --git a/src/App.tsx b/src/App.tsx index fe53ef2..e453573 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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() { } /> } /> } /> + } /> {/* Operations Workspace */} diff --git a/src/components/anfragencenter/OfferPdfReviewStep.tsx b/src/components/anfragencenter/OfferPdfReviewStep.tsx index 1a45c84..efc833d 100644 --- a/src/components/anfragencenter/OfferPdfReviewStep.tsx +++ b/src/components/anfragencenter/OfferPdfReviewStep.tsx @@ -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() { diff --git a/src/components/anfragencenter/OfferWizard.tsx b/src/components/anfragencenter/OfferWizard.tsx index 93abe37..846852d 100644 --- a/src/components/anfragencenter/OfferWizard.tsx +++ b/src/components/anfragencenter/OfferWizard.tsx @@ -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 = { + 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 ( @@ -70,7 +86,7 @@ export function NeedInput({ criteria: c, onCriteriaChange: set }: Props) { {/* Area */} Fläche (m²) - + + + {AREA_PRESETS.map(p => ( + set({ ...c, areaRange: { min: p.min, max: p.max } })} + sx={{ fontSize: '0.68rem', height: 20, color: '#64748b', borderColor: '#cbd5e1' }} + /> + ))} + {/* Location */} Standort + + {LOCATION_PRESETS.map(loc => ( + { + 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' } + } + /> + ))} + 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 && ( - {c.preferredLocations!.map(loc => ( + {c.preferredLocations!.filter(l => !LOCATION_PRESETS.includes(l)).map(loc => ( set({ ...c, preferredLocations: c.preferredLocations!.filter(l => l !== loc) })} /> ))} - ) : } + )} + !LOCATION_PRESETS.includes(l)).length ?? 0) > 0 ? 0 : 2.5 }} /> {/* Budget */} Budget (max CHF/m²) + + {BUDGET_PRESETS.map(p => ( + 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' } + } + /> + ))} + 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 } }} /> diff --git a/src/components/layout/AppShell.tsx b/src/components/layout/AppShell.tsx index 1741e93..3c621f4 100644 --- a/src/components/layout/AppShell.tsx +++ b/src/components/layout/AppShell.tsx @@ -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 = { { 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() { + ) } diff --git a/src/components/onboarding/WelcomeDialog.tsx b/src/components/onboarding/WelcomeDialog.tsx new file mode 100644 index 0000000..6d083a4 --- /dev/null +++ b/src/components/onboarding/WelcomeDialog.tsx @@ -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 ( + + {icon} + {text} + + ) +} + +function Slide0() { + return ( + + + + + + Willkommen bei Property Match + + + Die KI-gestützte Plattform für gewerbliche Immobilien + + + } + color="#1a7a4a" + text="Verifizierte Portfolio-Objekte" + /> + } + color="#1a7a4a" + text="Externer Markt & Inserate" + /> + } + color="#7c3aed" + text="Schattenmarkt-Signale der KI" + /> + + + ) +} + +function Slide1() { + return ( + + + + + + Bedarf beschreiben — KI findet Matches + + + Schreiben Sie Ihren Flächenbedarf in natürlicher Sprache. Die KI extrahiert alle Kriterien + und findet die besten Matches aus Portfolio, Markt und Schattenmarkt. + + + } + color="#d97706" + text="Spracheingabe auf Deutsch" + /> + } + color="#7c3aed" + text="KI analysiert und gewichtet automatisch" + /> + + + ) +} + +function Slide2() { + return ( + + + + + + Schattenmarkt — Vor dem Markt informiert + + + Die KI überwacht Stelleninserate, Pressemeldungen und Baubewilligungen — frühzeitige + Hinweise auf mögliche Verfügbarkeiten, bevor sie ausgeschrieben werden. + + + } + color="#d97706" + text="Presse & Stelleninserate" + /> + } + color="#1a7a4a" + text="Baubewilligungen & Berichte" + /> + + + ) +} + +const SLIDES = [, , ] + +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 ( + + + {SLIDES[activeStep]} + setActiveStep((s) => s - 1)} + disabled={activeStep === 0} + startIcon={} + > + Zurück + + } + nextButton={ + activeStep === 2 ? ( + + ) : ( + + ) + } + /> + + + ) +} diff --git a/src/domain/pipeline.ts b/src/domain/pipeline.ts new file mode 100644 index 0000000..307f60b --- /dev/null +++ b/src/domain/pipeline.ts @@ -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 +} diff --git a/src/mock-data/pipelineItems.ts b/src/mock-data/pipelineItems.ts new file mode 100644 index 0000000..e583016 --- /dev/null +++ b/src/mock-data/pipelineItems.ts @@ -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' }, +] diff --git a/src/pages/demand/Pipeline.tsx b/src/pages/demand/Pipeline.tsx new file mode 100644 index 0000000..e01503f --- /dev/null +++ b/src/pages/demand/Pipeline.tsx @@ -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> = { + DISCOVERED: 'Qualifizieren →', + QUALIFIED: 'Besichtigung planen →', + VISITED: 'Verhandlung →', + NEGOTIATION: 'Als gewonnen markieren →', +} + +const RESULT_TYPE_LABEL: Record = { + VERIFIED_PORTFOLIO: 'Portfolio', + EXTERNAL_MARKET: 'Markt', + FUTURE_AVAILABILITY: 'Schattenmarkt', +} + +const RESULT_TYPE_COLOR: Record = { + 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 ( + + + + {item.title} + + + {item.matchScore}% + + + + + {item.location} + + + + + + + {(item.areaLabel || item.rentLabel) && ( + + {item.areaLabel && ( + + {item.areaLabel} + + )} + {item.rentLabel && ( + + {item.rentLabel} + + )} + + )} + + {item.notes && ( + + {item.notes} + + )} + + {showAdvance && ( + + )} + + ) +} + +export default function Pipeline() { + const [items, setItems] = useState(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 ( + + + + + Deal Pipeline + + + Verfolgen Sie Objekte von der Entdeckung bis zum Abschluss + + + + + + + {STAGES.map((stage) => { + const columnItems = items.filter((i) => i.stage === stage.key) + return ( + + + + {stage.label} + + + + + + {columnItems.map((item) => ( + + ))} + + + ) + })} + + + ) +}