From 6a6ff7f2e2319d4f3233f8836b52d26ff937ea6d Mon Sep 17 00:00:00 2001 From: Benjamin Sutter Date: Fri, 22 May 2026 21:42:21 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20Pipeline=E2=86=94Anfragen=20integration?= =?UTF-8?q?=20+=20Compare=E2=86=92Pipeline=20+=20KI=20stage=20detection?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Navigation: Deal Pipeline moved after Vergleich (before Anfragen) Compare → Pipeline: - Bookmark icon per column header; BookmarkCheck when already in pipeline - Passes propertyId, propertyAddress, area/rent labels on save Pipeline cards now unit-level: - propertyAddress shown with MapPin on every card - Chat icon (MessageSquare) on cards with linked inquiry → navigates to /demand/anfragen?inquiry=xxx - Detail panel: Chat chip links to specific inquiry thread, propertyAddress displayed Anfragen → Pipeline KI detection: - Keyword scan on every sent message (besichtigung → VISITED, mietvertrag → NEGOTIATION, unterschrieben → CLOSED_WON) - Only advances stage, never goes back - Purple KI alert banner with direct Pipeline link, auto-dismisses after 6s - Pipeline badge in inquiry list + stage chip in chat header with nav link - URL param ?inquiry=xxx pre-selects inquiry (used from Pipeline chat button) Domain: PipelineItem gains propertyId, unitId, propertyAddress, inquiryId Mock data: pl-001/pl-002/pl-004 linked to inq-001/inq-005/inq-004 Co-Authored-By: Claude Sonnet 4.6 --- .../compare/CompareColumnHeader.tsx | 59 ++- src/components/layout/AppShell.tsx | 2 +- src/components/results/UnifiedResultCard.tsx | 21 +- src/domain/pipeline.ts | 7 + src/mock-data/pipelineItems.ts | 126 ++++- src/pages/demand/Anfragen.tsx | 438 ++++++++++-------- src/pages/demand/Pipeline.tsx | 64 ++- src/stores/pipelineStore.ts | 21 +- 8 files changed, 505 insertions(+), 233 deletions(-) diff --git a/src/components/compare/CompareColumnHeader.tsx b/src/components/compare/CompareColumnHeader.tsx index c1b7c42..d055827 100644 --- a/src/components/compare/CompareColumnHeader.tsx +++ b/src/components/compare/CompareColumnHeader.tsx @@ -1,5 +1,6 @@ import { Box, Chip, IconButton, Tooltip, Typography } from '@mui/material' -import { X, AlertTriangle } from 'lucide-react' +import { X, AlertTriangle, Bookmark, BookmarkCheck } from 'lucide-react' +import { usePipelineStore } from '../../stores/pipelineStore' import type { UnifiedMatchResult } from '../../domain/unifiedResult' const TYPE_META: Record = { @@ -17,30 +18,70 @@ interface Props { } export function CompareColumnHeader({ item, onRemove }: Props) { + const { items: pipelineItems, openSavedDialog } = usePipelineStore() + const meta = TYPE_META[item.resultType] ?? { label: item.resultType, color: '#64748b' } const prop = item.resultType !== 'FUTURE_AVAILABILITY' ? (item as any).property : null const sig = item.resultType === 'FUTURE_AVAILABILITY' ? (item as any).signal : null - const title = prop?.title ?? sig?.companyName ?? sig?.locationHint ?? '–' - const subtitle = prop?.location?.city ?? sig?.locationHint ?? '–' + const title = prop?.title ?? sig?.companyName ?? sig?.locationHint ?? '–' + const subtitle = prop?.location?.city ?? sig?.locationHint ?? '–' + const district = prop?.location?.district const availability = prop?.availabilityDate ?? (sig ? `~${sig.timeHorizonMonths} Monate` : null) - const confidence = Math.round(item.match.confidenceLevel * 100) - const source = prop?.sourceLabel ?? sig?.source?.type ?? '–' + const confidence = Math.round(item.match.confidenceLevel * 100) + const source = prop?.sourceLabel ?? sig?.source?.type ?? '–' + + const isInPipeline = pipelineItems.some( + pi => pi.id === item.matchId || (prop?.id && pi.propertyId === prop.id) + ) + + function handleSave() { + if (isInPipeline) return + openSavedDialog({ + resultId: item.matchId, + resultType: item.resultType, + title, + location: prop?.location?.city ?? sig?.locationHint, + matchScore: item.matchScore, + propertyId: prop?.id, + propertyAddress: prop ? `${title}, ${subtitle}${district ? `, ${district}` : ''}` : undefined, + areaLabel: prop?.areaSqm ? `${prop.areaSqm.toLocaleString('de-CH')} m²` : undefined, + rentLabel: prop?.rentPricePerSqm ? `CHF ${prop.rentPricePerSqm}/m²` : undefined, + availabilityLabel: prop?.availabilityDate ?? undefined, + }) + } return ( - - - + + + + {isInPipeline + ? + : } + + + + + + {title} - {subtitle} + {subtitle}{district ? `, ${district}` : ''} diff --git a/src/components/layout/AppShell.tsx b/src/components/layout/AppShell.tsx index c690fc2..6b842fb 100644 --- a/src/components/layout/AppShell.tsx +++ b/src/components/layout/AppShell.tsx @@ -103,8 +103,8 @@ const WORKSPACE_CONFIG: Record = { { path: '/demand/ai-search', label: 'Flächensuche', icon: Search }, { path: '/demand/results', label: 'Ergebnisse', icon: List }, { path: '/demand/compare', label: 'Vergleich', icon: Columns2 }, - { path: '/demand/anfragen', label: 'Anfragen', icon: MessageSquare }, { path: '/demand/pipeline', label: 'Deal Pipeline', icon: Kanban }, + { path: '/demand/anfragen', label: 'Anfragen', icon: MessageSquare }, ], }, } diff --git a/src/components/results/UnifiedResultCard.tsx b/src/components/results/UnifiedResultCard.tsx index 0c69b05..c076495 100644 --- a/src/components/results/UnifiedResultCard.tsx +++ b/src/components/results/UnifiedResultCard.tsx @@ -33,13 +33,20 @@ export function UnifiedResultCard({ result, view = 'list' }: Props) { label: 'Merken', actionType: 'SAVE_SHORTLIST', variant: 'secondary', - onClick: () => openSavedDialog({ - resultId: result.matchId, - resultType: result.resultType, - title: getResultTitle(result), - matchScore: result.matchScore, - location: result.resultType !== 'FUTURE_AVAILABILITY' ? result.property.location?.city : undefined, - }), + onClick: () => { + const prop = result.resultType !== 'FUTURE_AVAILABILITY' ? (result as any).property : null + openSavedDialog({ + resultId: result.matchId, + resultType: result.resultType, + title: getResultTitle(result), + matchScore: result.matchScore, + location: prop?.location?.city, + propertyId: prop?.id, + propertyAddress: prop ? `${prop.title}, ${prop.location?.city ?? ''}` : undefined, + areaLabel: prop?.areaSqm ? `${prop.areaSqm.toLocaleString('de-CH')} m²` : undefined, + rentLabel: prop?.rentPricePerSqm ? `CHF ${prop.rentPricePerSqm}/m²` : undefined, + }) + }, }, { id: 'compare', diff --git a/src/domain/pipeline.ts b/src/domain/pipeline.ts index a6558a6..aed508f 100644 --- a/src/domain/pipeline.ts +++ b/src/domain/pipeline.ts @@ -7,6 +7,13 @@ export interface PipelineItem { matchScore: number resultType: 'VERIFIED_PORTFOLIO' | 'EXTERNAL_MARKET' | 'MAISON_WORK' | 'FUTURE_AVAILABILITY' stage: PipelineStage + // Property / unit reference — always set for unit-level tracking + propertyId?: string + unitId?: string + propertyAddress?: string + // Linked inquiry (chat thread) + inquiryId?: string + // Display labels areaLabel?: string rentLabel?: string availabilityLabel?: string diff --git a/src/mock-data/pipelineItems.ts b/src/mock-data/pipelineItems.ts index f4dd231..c7f8e00 100644 --- a/src/mock-data/pipelineItems.ts +++ b/src/mock-data/pipelineItems.ts @@ -1,12 +1,122 @@ 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: 'MAISON_WORK', 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: 'MAISON_WORK', 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' }, + { + id: 'pl-001', + propertyId: 'prop-001', + inquiryId: 'inq-001', + propertyAddress: 'Zollstrasse 12, Zürich-West', + 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: '2026-09-01', + addedAt: '2026-05-01T10:00:00Z', + updatedAt: '2026-05-15T14:00:00Z', + assignedTo: 'B. Sutter', + }, + { + id: 'pl-002', + propertyId: 'prop-007', + inquiryId: 'inq-005', + propertyAddress: 'Thurgauerstrasse 40, Zürich-Oerlikon', + title: 'Büro Oerlikon Thurgauerstrasse', + location: 'Zürich-Oerlikon', + matchScore: 87, + resultType: 'VERIFIED_PORTFOLIO', + stage: 'QUALIFIED', + areaLabel: '1200 m²', + rentLabel: 'CHF 38/m²', + availabilityLabel: '2026-10-01', + addedAt: '2026-05-03T10:00:00Z', + updatedAt: '2026-05-14T09:00:00Z', + }, + { + id: 'pl-003', + propertyAddress: 'Binzstrasse 23, Zürich-Binz', + title: 'Büro Binzstrasse 23', + location: 'Zürich, Binz', + matchScore: 83, + resultType: 'MAISON_WORK', + stage: 'VISITED', + areaLabel: '720 m²', + rentLabel: 'CHF 47/m²', + addedAt: '2026-05-04T10:00:00Z', + updatedAt: '2026-05-13T10:00:00Z', + }, + { + id: 'pl-004', + propertyId: 'prop-012', + inquiryId: 'inq-004', + propertyAddress: 'Baarerstrasse 14, Zug', + title: 'Bürofläche Zug Baarerstrasse', + location: 'Zug', + matchScore: 93, + resultType: 'VERIFIED_PORTFOLIO', + stage: 'VISITED', + areaLabel: '950 m²', + rentLabel: 'CHF 56/m²', + addedAt: '2026-05-05T10:00:00Z', + updatedAt: '2026-05-12T10:00:00Z', + notes: 'Besichtigungstermin 22.05. bestätigt', + }, + { + id: 'pl-005', + propertyAddress: 'Technopark, Zürich-West', + title: 'DataCloud Systems AG', + location: 'Zürich-West / Technopark', + matchScore: 76, + resultType: 'FUTURE_AVAILABILITY', + stage: 'QUALIFIED', + areaLabel: '~600 m²', + availabilityLabel: '~10 Monate', + addedAt: '2026-05-06T10:00:00Z', + updatedAt: '2026-05-11T10:00:00Z', + }, + { + id: 'pl-006', + propertyAddress: 'Wankdorf Business Park, Bern', + title: 'Neubau Wankdorf Business', + location: 'Bern, Wankdorf', + matchScore: 72, + resultType: 'FUTURE_AVAILABILITY', + stage: 'SAVED', + areaLabel: '~4500 m²', + availabilityLabel: '~24 Monate', + addedAt: '2026-05-07T10:00:00Z', + updatedAt: '2026-05-10T10:00:00Z', + }, + { + id: 'pl-007', + propertyAddress: 'Stadthaus-Gasse 1, Bern Innenstadt', + 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: '2026-07-01', + addedAt: '2026-04-10T10:00:00Z', + updatedAt: '2026-05-08T10:00:00Z', + assignedTo: 'B. Sutter', + notes: 'Vertrag unterschrieben', + }, + { + id: 'pl-008', + propertyAddress: 'Güterstrasse 22, Basel', + title: 'Gewerbe Güterstrasse Basel', + location: 'Basel', + matchScore: 68, + resultType: 'EXTERNAL_MARKET', + stage: 'CLOSED_LOST', + areaLabel: '800 m²', + rentLabel: 'CHF 28/m²', + addedAt: '2026-04-15T10:00:00Z', + updatedAt: '2026-05-05T10:00:00Z', + notes: 'Vermieter hat anderes Unternehmen bevorzugt', + }, ] diff --git a/src/pages/demand/Anfragen.tsx b/src/pages/demand/Anfragen.tsx index 6d9ffda..cccce81 100644 --- a/src/pages/demand/Anfragen.tsx +++ b/src/pages/demand/Anfragen.tsx @@ -1,26 +1,70 @@ import { useState, useEffect, useRef } from 'react' +import { useNavigate, useSearchParams } from 'react-router' import { Box, Typography, TextField, Chip, Avatar, IconButton, - InputAdornment, Paper, + InputAdornment, Alert, } from '@mui/material' -import { Search, Send, Paperclip, ArrowLeft, FileText, Bot, Building2 } from 'lucide-react' +import { Search, Send, Paperclip, ArrowLeft, FileText, Bot, Building2, Kanban } from 'lucide-react' import { mockInquiries } from '../../mock-data/inquiries' +import { usePipelineStore } from '../../stores/pipelineStore' +import { useToastStore } from '../../stores/toastStore' import type { InquiryMessage } from '../../domain/inquiry' +import type { PipelineStage } from '../../domain/pipeline' + +// ── Config ──────────────────────────────────────────────────────────────────── const STATUS_CONFIG: Record = { - new: { label: 'Neu', color: '#dc2626', bgColor: '#fef2f2' }, - in_progress: { label: 'Aktiv', color: '#d97706', bgColor: '#fffbeb' }, - answered: { label: 'Beantwortet', color: '#1a7a4a', bgColor: '#f0fdf4' }, - archived: { label: 'Archiviert', color: '#64748b', bgColor: '#f8fafc' }, + new: { label: 'Neu', color: '#dc2626', bgColor: '#fef2f2' }, + in_progress:{ label: 'Aktiv', color: '#d97706', bgColor: '#fffbeb' }, + answered: { label: 'Beantwortet', color: '#1a7a4a', bgColor: '#f0fdf4' }, + archived: { label: 'Archiviert', color: '#64748b', bgColor: '#f8fafc' }, } const FILTER_TABS = [ - { key: 'all', label: 'Alle' }, - { key: 'new', label: 'Neu' }, + { key: 'all', label: 'Alle' }, + { key: 'new', label: 'Neu' }, { key: 'in_progress', label: 'Aktiv' }, - { key: 'answered', label: 'Beantwortet' }, + { key: 'answered', label: 'Beantwortet' }, ] +// Stage order for auto-advance (KI only advances, never goes back) +const STAGE_ORDER: PipelineStage[] = ['SAVED', 'DISCOVERED', 'QUALIFIED', 'VISITED', 'NEGOTIATION', 'CLOSED_WON', 'CLOSED_LOST'] +const STAGE_LABELS: Record = { + SAVED: 'Gemerkt', DISCOVERED: 'Entdeckt', QUALIFIED: 'Qualifiziert', + VISITED: 'Besichtigt', NEGOTIATION: 'Verhandlung', CLOSED_WON: 'Gewonnen', CLOSED_LOST: 'Abgelehnt', +} + +// KI keyword detection +const KI_RULES: { keywords: string[]; stage: PipelineStage; label: string }[] = [ + { + keywords: ['vertrag unterschrieben', 'unterschrieben', 'deal abgeschlossen', 'abgeschlossen und fix', 'mietbeginn bestätigt'], + stage: 'CLOSED_WON', + label: 'Abschluss erkannt', + }, + { + keywords: ['mietvertrag', 'vertragsvorlage', 'anbiet', 'konditionen verhandl', 'preisvorstellung'], + stage: 'NEGOTIATION', + label: 'Verhandlung erkannt', + }, + { + keywords: ['besichtigungstermin', 'besichtigung', 'besichtigen', 'vorort termin', 'vor ort', 'terminvorschlag', 'termin bestätigt', 'termin vereinbart'], + stage: 'VISITED', + label: 'Besichtigungstermin erkannt', + }, +] + +function detectKiStage(text: string): { stage: PipelineStage; label: string } | null { + const lower = text.toLowerCase() + for (const rule of KI_RULES) { + if (rule.keywords.some(kw => lower.includes(kw))) { + return { stage: rule.stage, label: rule.label } + } + } + return null +} + +// ── MessageBubble ───────────────────────────────────────────────────────────── + function MessageBubble({ msg }: { msg: InquiryMessage }) { const isOwnMessage = msg.senderType === 'tenant' const isAI = msg.senderType === 'ai' @@ -50,14 +94,11 @@ function MessageBubble({ msg }: { msg: InquiryMessage }) { variant="body2" sx={{ color: isOwnMessage ? 'white' : isAI ? '#5b21b6' : '#1e293b', - whiteSpace: 'pre-wrap', - lineHeight: 1.65, - fontSize: '0.875rem', + whiteSpace: 'pre-wrap', lineHeight: 1.65, fontSize: '0.875rem', }} > {msg.body} - {msg.attachments.length > 0 && ( {msg.attachments.map(att => ( @@ -91,13 +132,25 @@ function MessageBubble({ msg }: { msg: InquiryMessage }) { ) } +// ── Anfragen page ───────────────────────────────────────────────────────────── + export default function Anfragen() { + const navigate = useNavigate() + const [searchParams] = useSearchParams() + const { findByPropertyId, findByInquiryId, moveStage } = usePipelineStore() + const showToast = useToastStore(s => s.showToast) + + const preselectedId = searchParams.get('inquiry') + const [inquiries, setInquiries] = useState(mockInquiries) - const [selectedId, setSelectedId] = useState(mockInquiries[0]?.id ?? null) + const [selectedId, setSelectedId] = useState( + preselectedId ?? mockInquiries[0]?.id ?? null + ) const [search, setSearch] = useState('') const [statusFilter, setStatusFilter] = useState('all') const [replyText, setReplyText] = useState('') - const [mobileShowChat, setMobileShowChat] = useState(false) + const [mobileShowChat, setMobileShowChat] = useState(!!preselectedId) + const [kiAlert, setKiAlert] = useState<{ title: string; stage: string } | null>(null) const threadRef = useRef(null) const filtered = inquiries.filter(inq => { @@ -113,26 +166,41 @@ export default function Anfragen() { const selected = inquiries.find(i => i.id === selectedId) ?? null const totalUnread = inquiries.reduce((sum, i) => sum + i.unreadCount, 0) + // Pipeline link for currently selected inquiry + const linkedPipelineItem = selected?.propertyId + ? findByPropertyId(selected.propertyId) + : (selected ? findByInquiryId(selected.id) : undefined) + useEffect(() => { if (threadRef.current) { threadRef.current.scrollTop = threadRef.current.scrollHeight } }, [selected?.thread.length]) + // Auto-dismiss KI alert after 6s + useEffect(() => { + if (!kiAlert) return + const t = setTimeout(() => setKiAlert(null), 6000) + return () => clearTimeout(t) + }, [kiAlert]) + function handleSelect(id: string) { setSelectedId(id) setInquiries(prev => prev.map(i => i.id === id ? { ...i, isRead: true, unreadCount: 0 } : i)) setMobileShowChat(true) + setKiAlert(null) } function handleSend() { if (!replyText.trim() || !selectedId) return + const text = replyText.trim() + const msg: InquiryMessage = { id: `msg-${Date.now()}`, inquiryId: selectedId, senderType: 'tenant', senderName: 'Sie', - body: replyText.trim(), + body: text, attachments: [], createdAt: new Date().toISOString(), } @@ -142,12 +210,28 @@ export default function Anfragen() { : i )) setReplyText('') + + // KI: detect stage transition from message content + const ki = detectKiStage(text) + if (ki && selected) { + const pipelineItem = selected.propertyId + ? findByPropertyId(selected.propertyId) + : findByInquiryId(selectedId) + if (pipelineItem) { + const currentIdx = STAGE_ORDER.indexOf(pipelineItem.stage) + const targetIdx = STAGE_ORDER.indexOf(ki.stage) + if (targetIdx > currentIdx) { + moveStage(pipelineItem.id, ki.stage) + setKiAlert({ title: pipelineItem.title, stage: STAGE_LABELS[ki.stage] }) + } + } + } } return ( - {/* ── Left panel: inquiry list ── */} + {/* ── Left panel ── */} - {/* List header */} Anfragen {totalUnread > 0 && ( - + )} setSearch(e.target.value)} - InputProps={{ - startAdornment: ( - - - - ), - }} + size="small" placeholder="Suchen..." fullWidth + value={search} onChange={e => setSearch(e.target.value)} + InputProps={{ startAdornment: }} sx={{ mb: 1.25 }} /> {FILTER_TABS.map(tab => ( - setStatusFilter(tab.key)} + setStatusFilter(tab.key)} sx={{ height: 22, fontSize: '0.7rem', cursor: 'pointer', bgcolor: statusFilter === tab.key ? '#1e3a5f' : '#f1f5f9', @@ -206,136 +273,101 @@ export default function Anfragen() { - {/* List body */} {filtered.length === 0 ? ( Keine Anfragen gefunden. - ) : ( - filtered.map(inq => { - const cfg = STATUS_CONFIG[inq.status ?? 'new'] - const isSelected = inq.id === selectedId - const displayDate = new Date(inq.updatedAt).toLocaleDateString('de-CH', { day: '2-digit', month: '2-digit' }) - const lastMsg = inq.thread[inq.thread.length - 1] + ) : filtered.map(inq => { + const cfg = STATUS_CONFIG[inq.status ?? 'new'] + const isSelected = inq.id === selectedId + const displayDate = new Date(inq.updatedAt).toLocaleDateString('de-CH', { day: '2-digit', month: '2-digit' }) + const lastMsg = inq.thread[inq.thread.length - 1] + const hasPipeline = !!(inq.propertyId ? findByPropertyId(inq.propertyId) : findByInquiryId(inq.id)) - return ( - handleSelect(inq.id)} - sx={{ - px: 2, py: 1.5, - borderBottom: '1px solid #f1f5f9', - cursor: 'pointer', - bgcolor: isSelected ? '#eff6ff' : !inq.isRead ? 'rgba(220,38,38,0.03)' : 'transparent', - borderLeft: isSelected ? '3px solid #1e3a5f' : '3px solid transparent', - '&:hover': { bgcolor: isSelected ? '#eff6ff' : '#f8fafc' }, - transition: 'background-color 0.1s ease', - }} - > - - - {!inq.isRead && ( - - )} - - {inq.tenantName} - - - - {inq.unreadCount > 0 && ( - - - {inq.unreadCount} - - - )} - - {displayDate} - - + return ( + handleSelect(inq.id)} sx={{ + px: 2, py: 1.5, + borderBottom: '1px solid #f1f5f9', + cursor: 'pointer', + bgcolor: isSelected ? '#eff6ff' : !inq.isRead ? 'rgba(220,38,38,0.03)' : 'transparent', + borderLeft: isSelected ? '3px solid #1e3a5f' : '3px solid transparent', + '&:hover': { bgcolor: isSelected ? '#eff6ff' : '#f8fafc' }, + transition: 'background-color 0.1s ease', + }}> + + + {!inq.isRead && } + + {inq.tenantName} + - - {inq.tenantCompany && ( - - {inq.tenantCompany} - - )} - - - {inq.subject} - - - {lastMsg && ( - - {lastMsg.senderType === 'tenant' ? 'Sie: ' : `${lastMsg.senderName}: `} - {lastMsg.body.split('\n')[0]} - - )} - - - - {inq.matchScore && ( - = 80 ? '#1a7a4a' : '#d97706', fontWeight: 700, fontSize: '0.7rem' }} - > - Match {inq.matchScore}% - + + {inq.unreadCount > 0 && ( + + {inq.unreadCount} + )} + {displayDate} - ) - }) - )} + {inq.tenantCompany && ( + + {inq.tenantCompany} + + )} + + {inq.subject} + + {lastMsg && ( + + {lastMsg.senderType === 'tenant' ? 'Sie: ' : `${lastMsg.senderName}: `} + {lastMsg.body.split('\n')[0]} + + )} + + + {inq.matchScore && ( + = 80 ? '#1a7a4a' : '#d97706', fontWeight: 700, fontSize: '0.7rem' }}> + {inq.matchScore}% + + )} + {hasPipeline && ( + } + label="Pipeline" + size="small" + sx={{ height: 16, fontSize: '0.6rem', bgcolor: '#eff6ff', color: '#1e3a5f', fontWeight: 600, '& .MuiChip-icon': { color: '#1e3a5f' } }} + /> + )} + + + ) + })} - {/* ── Right panel: chat thread ── */} - + {/* ── Right panel: chat ── */} + {!selected ? ( - - Anfrage auswählen - - - Wählen Sie links eine Anfrage aus, um die Konversation zu lesen. - + Anfrage auswählen + Wählen Sie links eine Anfrage aus. ) : ( <> {/* Chat header */} - setMobileShowChat(false)} - > + setMobileShowChat(false)}> @@ -343,14 +375,8 @@ export default function Anfragen() { - - {selected.tenantName} - - {selected.tenantCompany && ( - - {selected.tenantCompany} - - )} + {selected.tenantName} + {selected.tenantCompany && {selected.tenantCompany}} {selected.subject} @@ -358,35 +384,67 @@ export default function Anfragen() { {selected.matchScore && ( - = 80 ? '#f0fdf4' : '#fffbeb', - color: selected.matchScore >= 80 ? '#1a7a4a' : '#d97706', - fontWeight: 700, height: 22, fontSize: '0.75rem', - border: `1px solid ${selected.matchScore >= 80 ? '#86efac' : '#fde68a'}`, - }} - /> + = 80 ? '#f0fdf4' : '#fffbeb', + color: selected.matchScore >= 80 ? '#1a7a4a' : '#d97706', + fontWeight: 700, height: 22, fontSize: '0.75rem', + border: `1px solid ${selected.matchScore >= 80 ? '#86efac' : '#fde68a'}`, + }} /> )} + {/* Pipeline link */} + {linkedPipelineItem && ( + } + label={STAGE_LABELS[linkedPipelineItem.stage] ?? linkedPipelineItem.stage} + size="small" + onClick={() => navigate('/demand/pipeline')} + sx={{ + height: 22, fontSize: '0.75rem', cursor: 'pointer', + bgcolor: '#eff6ff', color: '#1e3a5f', fontWeight: 600, + border: '1px solid #bfdbfe', + '& .MuiChip-icon': { color: '#1e3a5f' }, + '&:hover': { bgcolor: '#dbeafe' }, + }} + /> + )} + + {/* Property reference */} + {(linkedPipelineItem?.propertyAddress ?? selected.subject) && ( + + + + {linkedPipelineItem?.propertyAddress ?? selected.subject} + + + )} + {/* KI stage-change alert */} + {kiAlert && ( + + } + onClose={() => setKiAlert(null)} + sx={{ py: 0.5, bgcolor: '#faf5ff', color: '#4c1d95', border: '1px solid #ddd6fe', '& .MuiAlert-icon': { color: '#7c3aed' } }} + > + KI erkannt: „{kiAlert.title}" wurde in der Pipeline auf {kiAlert.stage} verschoben.{' '} + navigate('/demand/pipeline')}> + Pipeline öffnen + + + + )} + {/* Thread */} - + {selected.thread.map(msg => ( ))} @@ -396,39 +454,23 @@ export default function Anfragen() { setReplyText(e.target.value)} onKeyDown={e => { if (e.key === 'Enter' && e.ctrlKey) handleSend() }} - sx={{ - '& .MuiOutlinedInput-root': { borderRadius: 2 }, - }} + sx={{ '& .MuiOutlinedInput-root': { borderRadius: 2 } }} /> - - - - + + - Ctrl + Enter zum Senden + Ctrl + Enter · KI erkennt Terminvereinbarungen automatisch diff --git a/src/pages/demand/Pipeline.tsx b/src/pages/demand/Pipeline.tsx index 90b3ac6..ececc34 100644 --- a/src/pages/demand/Pipeline.tsx +++ b/src/pages/demand/Pipeline.tsx @@ -1,6 +1,7 @@ import { useState } from 'react' +import { useNavigate } from 'react-router' import { - Box, Typography, Chip, Paper, Button, IconButton, TextField, Divider, + Box, Typography, Chip, Paper, Button, IconButton, TextField, Divider, Tooltip, } from '@mui/material' import { DndContext, DragOverlay, PointerSensor, useSensor, useSensors, @@ -8,7 +9,7 @@ import { } from '@dnd-kit/core' import type { DragEndEvent, DragStartEvent } from '@dnd-kit/core' import { CSS } from '@dnd-kit/utilities' -import { X, Sparkles, FileText, StickyNote, ChevronRight, TrendingUp, AlertTriangle, CheckCircle, Bookmark } from 'lucide-react' +import { X, Sparkles, FileText, StickyNote, ChevronRight, TrendingUp, AlertTriangle, CheckCircle, MessageSquare, MapPin } from 'lucide-react' import { usePipelineStore } from '../../stores/pipelineStore' import { AddToPipelineDialog } from '../../components/shortlist' import type { PipelineItem, PipelineStage } from '../../domain/pipeline' @@ -107,11 +108,13 @@ function DraggableCard({ isSelected, onSelect, isDragOverlay = false, + onChatClick, }: { item: PipelineItem isSelected: boolean onSelect: (item: PipelineItem) => void isDragOverlay?: boolean + onChatClick?: (e: React.MouseEvent) => void }) { const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({ id: item.id }) @@ -144,17 +147,29 @@ function DraggableCard({ }} {...(isDragOverlay ? {} : { ...attributes, ...listeners })} > - + {item.title} - - {item.matchScore}% + + {item.inquiryId && onChatClick && !isDragOverlay && ( + + + + + + )} + + {item.matchScore}% + + + + + + + {item.propertyAddress ?? item.location} - - {item.location} - void + onChatClick: (inquiryId: string) => void isOver: boolean }) { const { setNodeRef } = useDroppable({ id: stage.key }) @@ -211,6 +228,7 @@ function DroppableColumn({ item={item} isSelected={item.id === selectedId} onSelect={onSelect} + onChatClick={item.inquiryId ? (e) => { e.stopPropagation(); onChatClick(item.inquiryId!) } : undefined} /> ))} {items.length === 0 && ( @@ -227,6 +245,7 @@ function DroppableColumn({ // ── DetailPanel ─────────────────────────────────────────────────────────────── function DetailPanel({ item, onClose }: { item: PipelineItem; onClose: () => void }) { + const navigate = useNavigate() const { moveStage, updateNotes, loseItem } = usePipelineStore() const [notes, setNotes] = useState(item.notes ?? '') const stageConfig = STAGES.find(s => s.key === item.stage)! @@ -265,8 +284,33 @@ function DetailPanel({ item, onClose }: { item: PipelineItem; onClose: () => voi ))} - + + + {item.inquiryId && ( + } + label="Chat" + size="small" + onClick={() => navigate(`/demand/anfragen?inquiry=${item.inquiryId}`)} + sx={{ + height: 22, fontSize: '0.75rem', cursor: 'pointer', + bgcolor: '#eff6ff', color: '#1e3a5f', fontWeight: 600, + border: '1px solid #bfdbfe', + '& .MuiChip-icon': { color: '#1e3a5f' }, + '&:hover': { bgcolor: '#dbeafe' }, + }} + /> + )} + + + {/* Property / unit address */} + {item.propertyAddress && ( + + + {item.propertyAddress} + + )} @@ -391,6 +435,7 @@ function DetailPanel({ item, onClose }: { item: PipelineItem; onClose: () => voi // ── Pipeline page ───────────────────────────────────────────────────────────── export default function Pipeline() { + const navigate = useNavigate() const { items, moveStage } = usePipelineStore() const [selectedItem, setSelectedItem] = useState(null) const [activeId, setActiveId] = useState(null) @@ -492,6 +537,7 @@ export default function Pipeline() { items={columnItems} selectedId={syncedSelected?.id ?? null} onSelect={handleSelect} + onChatClick={(inquiryId) => navigate(`/demand/anfragen?inquiry=${inquiryId}`)} isOver={isOver} /> diff --git a/src/stores/pipelineStore.ts b/src/stores/pipelineStore.ts index 09c83c8..d81de9c 100644 --- a/src/stores/pipelineStore.ts +++ b/src/stores/pipelineStore.ts @@ -8,6 +8,9 @@ export interface PendingPipelineItem { title: string location?: string matchScore: number + propertyId?: string + unitId?: string + propertyAddress?: string areaLabel?: string rentLabel?: string availabilityLabel?: string @@ -23,6 +26,9 @@ interface PipelineStore { moveStage: (id: string, stage: PipelineStage) => void updateNotes: (id: string, notes: string) => void loseItem: (id: string) => void + linkInquiry: (id: string, inquiryId: string) => void + findByPropertyId: (propertyId: string) => PipelineItem | undefined + findByInquiryId: (inquiryId: string) => PipelineItem | undefined } export const usePipelineStore = create((set, get) => ({ @@ -36,7 +42,10 @@ export const usePipelineStore = create((set, get) => ({ confirmSaved: (notes) => { const { pendingItem, items } = get() if (!pendingItem) return 'duplicate' - const alreadyExists = items.some(i => i.id === pendingItem.resultId) + const alreadyExists = items.some( + i => i.id === pendingItem.resultId || + (pendingItem.propertyId && i.propertyId === pendingItem.propertyId) + ) if (!alreadyExists) { const newItem: PipelineItem = { id: pendingItem.resultId, @@ -45,6 +54,9 @@ export const usePipelineStore = create((set, get) => ({ matchScore: pendingItem.matchScore, resultType: pendingItem.resultType, stage: 'SAVED', + propertyId: pendingItem.propertyId, + unitId: pendingItem.unitId, + propertyAddress: pendingItem.propertyAddress, areaLabel: pendingItem.areaLabel, rentLabel: pendingItem.rentLabel, availabilityLabel: pendingItem.availabilityLabel, @@ -74,4 +86,11 @@ export const usePipelineStore = create((set, get) => ({ i.id === id ? { ...i, stage: 'CLOSED_LOST' as PipelineStage, updatedAt: new Date().toISOString() } : i ), })), + + linkInquiry: (id, inquiryId) => set(state => ({ + items: state.items.map(i => i.id === id ? { ...i, inquiryId } : i), + })), + + findByPropertyId: (propertyId) => get().items.find(i => i.propertyId === propertyId), + findByInquiryId: (inquiryId) => get().items.find(i => i.inquiryId === inquiryId), }))