diff --git a/src/App.tsx b/src/App.tsx index 85db5b5..032815e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -34,7 +34,7 @@ const AISearch = lazy(() => import('./pages/demand/AISearch')) 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 Anfragen = lazy(() => import('./pages/demand/Anfragen')) const Pipeline = lazy(() => import('./pages/demand/Pipeline')) const PropertyDetail = lazy(() => import('./pages/demand/PropertyDetail')) @@ -74,7 +74,7 @@ function App() { } /> } /> } /> - } /> + } /> } /> } /> diff --git a/src/components/layout/AppShell.tsx b/src/components/layout/AppShell.tsx index a4544ff..c690fc2 100644 --- a/src/components/layout/AppShell.tsx +++ b/src/components/layout/AppShell.tsx @@ -103,7 +103,7 @@ 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/shortlists', label: 'Shortlists', icon: Bookmark }, + { path: '/demand/anfragen', label: 'Anfragen', icon: MessageSquare }, { path: '/demand/pipeline', label: 'Deal Pipeline', icon: Kanban }, ], }, @@ -134,6 +134,7 @@ function getPageNameFromPath(pathname: string): string { if (/^\/demand\/results\/.+/.test(pathname)) return 'Match Detail' if (/^\/demand\/property\//.test(pathname)) return 'Objekt Detail' if (/^\/supply\/properties\/.+/.test(pathname)) return 'Objekt Detail' + if (pathname === '/demand/anfragen') return 'Anfragen' const segment = pathname.split('/').filter(Boolean).pop() ?? '' return segment.charAt(0).toUpperCase() + segment.slice(1).replace(/-/g, ' ') } diff --git a/src/components/results/UnifiedResultCard.tsx b/src/components/results/UnifiedResultCard.tsx index cc05a17..0c69b05 100644 --- a/src/components/results/UnifiedResultCard.tsx +++ b/src/components/results/UnifiedResultCard.tsx @@ -3,7 +3,7 @@ import { MatchCardCompact } from '../match-card/MatchCardCompact' import { IntelligenceMatchCard } from '../match-card/IntelligenceMatchCard' import { buildMatchCardViewModel } from '../../features/matching/matchCardAdapter' import { useCompareStore } from '../../stores/compareStore' -import { useShortlistStore } from '../../stores/shortlistStore' +import { usePipelineStore } from '../../stores/pipelineStore' import type { UnifiedMatchResult } from '../../domain/unifiedResult' import type { MatchCardAction } from '../match-card/MatchCardViewModel' @@ -22,7 +22,7 @@ function getResultTitle(result: UnifiedMatchResult): string { export function UnifiedResultCard({ result, view = 'list' }: Props) { const navigate = useNavigate() const { addToCompare, removeFromCompare, isInCompare, isFull } = useCompareStore() - const { openAddDialog } = useShortlistStore() + const { openSavedDialog } = usePipelineStore() const inCompare = isInCompare(result.matchId) const isPortfolio = result.resultType === 'VERIFIED_PORTFOLIO' @@ -30,17 +30,15 @@ export function UnifiedResultCard({ result, view = 'list' }: Props) { const actions: MatchCardAction[] = [ { id: 'shortlist', - label: 'Shortlist', + label: 'Merken', actionType: 'SAVE_SHORTLIST', variant: 'secondary', - onClick: () => openAddDialog({ + onClick: () => openSavedDialog({ resultId: result.matchId, resultType: result.resultType, title: getResultTitle(result), matchScore: result.matchScore, - confidenceScore: result.match.confidenceLevel, - sourceLabel: result.resultType, - addedBy: 'admin@ideal-sharing.ch', + location: result.resultType !== 'FUTURE_AVAILABILITY' ? result.property.location?.city : undefined, }), }, { diff --git a/src/components/shortlist/AddToPipelineDialog.tsx b/src/components/shortlist/AddToPipelineDialog.tsx new file mode 100644 index 0000000..fb513af --- /dev/null +++ b/src/components/shortlist/AddToPipelineDialog.tsx @@ -0,0 +1,75 @@ +import { useState } from 'react' +import { + Box, Button, CircularProgress, Dialog, DialogActions, + DialogContent, DialogTitle, TextField, Typography, +} from '@mui/material' +import { Bookmark } from 'lucide-react' +import { usePipelineStore } from '../../stores/pipelineStore' +import { useToastStore } from '../../stores/toastStore' + +export function AddToPipelineDialog() { + const { dialogOpen, pendingItem, closeSavedDialog, confirmSaved } = usePipelineStore() + const showToast = useToastStore((s) => s.showToast) + const [note, setNote] = useState('') + const [saving, setSaving] = useState(false) + + function handleClose() { + closeSavedDialog() + setNote('') + } + + async function handleConfirm() { + setSaving(true) + const result = confirmSaved(note.trim() || undefined) + setSaving(false) + setNote('') + if (result === 'duplicate') { + showToast('Bereits in der Pipeline vorhanden.', 'warning') + } else { + showToast('Zur Pipeline (Gemerkt) hinzugefügt.') + } + } + + return ( + + + + Merken + + + {pendingItem && ( + + {pendingItem.title} + + Match {pendingItem.matchScore}% · {pendingItem.resultType} + + + )} + + Das Objekt wird in Ihrer Pipeline unter Gemerkt gespeichert. Von dort können Sie es qualifizieren und weiterverfolgen. + + setNote(e.target.value)} + /> + + + + + + + ) +} diff --git a/src/components/shortlist/ShortlistDetail.tsx b/src/components/shortlist/ShortlistDetail.tsx index feab206..31d7f6d 100644 --- a/src/components/shortlist/ShortlistDetail.tsx +++ b/src/components/shortlist/ShortlistDetail.tsx @@ -1,7 +1,7 @@ import { useState } from 'react' -import { Alert, Box, Button, Chip, TextField, Typography } from '@mui/material' +import { Alert, Box, Button, Chip, IconButton, TextField, Tooltip, Typography } from '@mui/material' import { useNavigate } from 'react-router' -import { CheckCircle, Columns2, Pencil } from 'lucide-react' +import { CheckCircle, Columns2, LayoutGrid, List, Pencil } from 'lucide-react' import { ShortlistStatusBadge } from './ShortlistStatusBadge' import { ShortlistItemCard } from './ShortlistItemCard' import { ShortlistEmptyState } from './ShortlistEmptyState' @@ -23,6 +23,9 @@ export function ShortlistDetail({ shortlist }: Props) { const [editingTitle, setEditingTitle] = useState(false) const [titleDraft, setTitleDraft] = useState(shortlist.title) + const [view, setView] = useState<'list' | 'grid'>(() => + (localStorage.getItem('view-shortlist') as 'list' | 'grid') ?? 'list' + ) const isFinalized = shortlist.status === ShortlistStatus.FINALIZED const isDraft = shortlist.status === ShortlistStatus.DRAFT @@ -137,7 +140,7 @@ export function ShortlistDetail({ shortlist }: Props) { )} - + {isDraft && ( + + + + ) + + if (view === 'grid') { + return ( + <> + + + + + {item.matchScore} + + + + {!isFinalized && item.resultType !== 'FUTURE_AVAILABILITY' && ( + { e.stopPropagation(); setInquiryOpen(true) }} + sx={{ color: '#94a3b8', '&:hover': { color: '#1e3a5f' } }} + > + + + )} + {removeBtn} + + + {item.title} + + + + + + {item.addedBy} · {addedDate} + + + + {item.note && ( + + {item.note} + + )} + + {inquiryDialog} + + ) + } + + return ( + <> + + + + {item.title} + + + + + {item.sourceLabel ?? item.resultType} · {item.addedBy} · {addedDate} + + {item.note && ( + + {item.note} + + )} + + + {!isFinalized && item.resultType !== 'FUTURE_AVAILABILITY' && ( + { e.stopPropagation(); setInquiryOpen(true) }} + sx={{ color: '#94a3b8', '&:hover': { color: '#1e3a5f' } }} + title="Anfrage stellen" + > + + + )} + {removeBtn} + + + {inquiryDialog} + ) } diff --git a/src/components/shortlist/index.ts b/src/components/shortlist/index.ts index 39b78f9..7400f16 100644 --- a/src/components/shortlist/index.ts +++ b/src/components/shortlist/index.ts @@ -6,3 +6,4 @@ export { ShortlistList } from './ShortlistList' export { ShortlistDetail } from './ShortlistDetail' export { DecisionBriefDraftPanel } from './DecisionBriefDraftPanel' export { AddToShortlistDialog } from './AddToShortlistDialog' +export { AddToPipelineDialog } from './AddToPipelineDialog' diff --git a/src/domain/pipeline.ts b/src/domain/pipeline.ts index 8418818..a6558a6 100644 --- a/src/domain/pipeline.ts +++ b/src/domain/pipeline.ts @@ -1,4 +1,4 @@ -export type PipelineStage = 'DISCOVERED' | 'QUALIFIED' | 'VISITED' | 'NEGOTIATION' | 'CLOSED_WON' | 'CLOSED_LOST' +export type PipelineStage = 'SAVED' | 'DISCOVERED' | 'QUALIFIED' | 'VISITED' | 'NEGOTIATION' | 'CLOSED_WON' | 'CLOSED_LOST' export interface PipelineItem { id: string diff --git a/src/pages/demand/Anfragen.tsx b/src/pages/demand/Anfragen.tsx new file mode 100644 index 0000000..6d9ffda --- /dev/null +++ b/src/pages/demand/Anfragen.tsx @@ -0,0 +1,439 @@ +import { useState, useEffect, useRef } from 'react' +import { + Box, Typography, TextField, Chip, Avatar, IconButton, + InputAdornment, Paper, +} from '@mui/material' +import { Search, Send, Paperclip, ArrowLeft, FileText, Bot, Building2 } from 'lucide-react' +import { mockInquiries } from '../../mock-data/inquiries' +import type { InquiryMessage } from '../../domain/inquiry' + +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' }, +} + +const FILTER_TABS = [ + { key: 'all', label: 'Alle' }, + { key: 'new', label: 'Neu' }, + { key: 'in_progress', label: 'Aktiv' }, + { key: 'answered', label: 'Beantwortet' }, +] + +function MessageBubble({ msg }: { msg: InquiryMessage }) { + const isOwnMessage = msg.senderType === 'tenant' + const isAI = msg.senderType === 'ai' + const time = new Date(msg.createdAt).toLocaleTimeString('de-CH', { hour: '2-digit', minute: '2-digit' }) + const date = new Date(msg.createdAt).toLocaleDateString('de-CH', { day: '2-digit', month: '2-digit' }) + + return ( + + + {!isOwnMessage && isAI && } + {!isOwnMessage && msg.senderType === 'supply_user' && } + + {msg.senderName} · {date} {time} + + + + + {msg.body} + + + {msg.attachments.length > 0 && ( + + {msg.attachments.map(att => ( + + + + {att.fileName} + + {att.fileSize && ( + + {att.fileSize < 1024 * 1024 + ? `${Math.round(att.fileSize / 1024)} KB` + : `${(att.fileSize / 1024 / 1024).toFixed(1)} MB`} + + )} + + ))} + + )} + + + ) +} + +export default function Anfragen() { + const [inquiries, setInquiries] = useState(mockInquiries) + const [selectedId, setSelectedId] = useState(mockInquiries[0]?.id ?? null) + const [search, setSearch] = useState('') + const [statusFilter, setStatusFilter] = useState('all') + const [replyText, setReplyText] = useState('') + const [mobileShowChat, setMobileShowChat] = useState(false) + const threadRef = useRef(null) + + const filtered = inquiries.filter(inq => { + const q = search.toLowerCase() + const matchesSearch = !q || + inq.tenantName.toLowerCase().includes(q) || + inq.subject.toLowerCase().includes(q) || + (inq.tenantCompany?.toLowerCase().includes(q) ?? false) + const matchesStatus = statusFilter === 'all' || inq.status === statusFilter + return matchesSearch && matchesStatus + }) + + const selected = inquiries.find(i => i.id === selectedId) ?? null + const totalUnread = inquiries.reduce((sum, i) => sum + i.unreadCount, 0) + + useEffect(() => { + if (threadRef.current) { + threadRef.current.scrollTop = threadRef.current.scrollHeight + } + }, [selected?.thread.length]) + + function handleSelect(id: string) { + setSelectedId(id) + setInquiries(prev => prev.map(i => i.id === id ? { ...i, isRead: true, unreadCount: 0 } : i)) + setMobileShowChat(true) + } + + function handleSend() { + if (!replyText.trim() || !selectedId) return + const msg: InquiryMessage = { + id: `msg-${Date.now()}`, + inquiryId: selectedId, + senderType: 'tenant', + senderName: 'Sie', + body: replyText.trim(), + attachments: [], + createdAt: new Date().toISOString(), + } + setInquiries(prev => prev.map(i => + i.id === selectedId + ? { ...i, thread: [...i.thread, msg], status: 'in_progress', updatedAt: new Date().toISOString() } + : i + )) + setReplyText('') + } + + return ( + + + {/* ── Left panel: inquiry list ── */} + + {/* List header */} + + + Anfragen + {totalUnread > 0 && ( + + )} + + setSearch(e.target.value)} + InputProps={{ + startAdornment: ( + + + + ), + }} + sx={{ mb: 1.25 }} + /> + + {FILTER_TABS.map(tab => ( + setStatusFilter(tab.key)} + sx={{ + height: 22, fontSize: '0.7rem', cursor: 'pointer', + bgcolor: statusFilter === tab.key ? '#1e3a5f' : '#f1f5f9', + color: statusFilter === tab.key ? 'white' : '#475569', + fontWeight: statusFilter === tab.key ? 700 : 400, + '&:hover': { bgcolor: statusFilter === tab.key ? '#1a3050' : '#e2e8f0' }, + }} + /> + ))} + + + + {/* 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] + + 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} + + + + + {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}% + + )} + + + ) + }) + )} + + + + {/* ── Right panel: chat thread ── */} + + {!selected ? ( + + + Anfrage auswählen + + + Wählen Sie links eine Anfrage aus, um die Konversation zu lesen. + + + ) : ( + <> + {/* Chat header */} + + + setMobileShowChat(false)} + > + + + + {selected.tenantName.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2)} + + + + + {selected.tenantName} + + {selected.tenantCompany && ( + + {selected.tenantCompany} + + )} + + + {selected.subject} + + + + {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'}`, + }} + /> + )} + + + + + + {/* Thread */} + + {selected.thread.map(msg => ( + + ))} + + + {/* Composer */} + + + setReplyText(e.target.value)} + onKeyDown={e => { if (e.key === 'Enter' && e.ctrlKey) handleSend() }} + sx={{ + '& .MuiOutlinedInput-root': { borderRadius: 2 }, + }} + /> + + + + + + + + + + + Ctrl + Enter zum Senden + + + + )} + + + ) +} diff --git a/src/pages/demand/MatchDetail.tsx b/src/pages/demand/MatchDetail.tsx index 5375cf4..6050378 100644 --- a/src/pages/demand/MatchDetail.tsx +++ b/src/pages/demand/MatchDetail.tsx @@ -8,8 +8,8 @@ import { propertyService } from '../../services/propertyService' import { needService } from '../../services/needService' import { futureSignalService } from '../../services/futureSignalService' import { useCompareStore } from '../../stores/compareStore' -import { useShortlistStore } from '../../stores/shortlistStore' -import { AddToShortlistDialog } from '../../components/shortlist' +import { usePipelineStore } from '../../stores/pipelineStore' +import { AddToPipelineDialog } from '../../components/shortlist' import { MatchReasonList } from '../../components/match-card/MatchReasonList' import { MatchScoreDisplay } from '../../components/match-card/MatchScoreDisplay' import { PropertyMap } from '../../components/shared' @@ -117,7 +117,7 @@ export default function MatchDetail() { const { matchId } = useParams<{ matchId: string }>() const navigate = useNavigate() const { addToCompare } = useCompareStore() - const { openAddDialog } = useShortlistStore() + const { openSavedDialog } = usePipelineStore() const { data: match, isLoading } = useMatchDetail(matchId ?? '') const isFuture = match?.resultType === 'FUTURE_AVAILABILITY' @@ -191,15 +191,14 @@ export default function MatchDetail() { } const handleShortlist = () => { - openAddDialog({ + openSavedDialog({ resultId: match.id, resultType: match.resultType ?? 'VERIFIED_PORTFOLIO', title: property?.title ?? signal?.companyName ?? signal?.locationHint ?? match.id, matchScore: match.matchScore, - confidenceScore: match.confidenceLevel, - sourceLabel: property?.sourceLabel ?? match.resultType ?? 'VERIFIED_PORTFOLIO', - addedBy: 'admin@ideal-sharing.ch', - propertyId: property?.id, + location: property?.location?.city, + areaLabel: property?.areaSqm ? `${property.areaSqm.toLocaleString('de-CH')} m²` : undefined, + rentLabel: property?.rentPricePerSqm ? `CHF ${property.rentPricePerSqm}/m²` : undefined, }) } @@ -217,7 +216,7 @@ export default function MatchDetail() { return ( - + {/* Sticky back nav */} > = { - DISCOVERED: 'Qualifizieren →', - QUALIFIED: 'Besichtigung planen →', - VISITED: 'Verhandlung →', - NEGOTIATION: 'Als gewonnen markieren →', +const NEXT_STAGE: Partial> = { + SAVED: { key: 'DISCOVERED', label: 'Als entdeckt markieren' }, + DISCOVERED: { key: 'QUALIFIED', label: 'Qualifizieren' }, + QUALIFIED: { key: 'VISITED', label: 'Besichtigung planen' }, + VISITED: { key: 'NEGOTIATION', label: 'Verhandlung starten' }, + NEGOTIATION: { key: 'CLOSED_WON', label: 'Als gewonnen markieren' }, } -const RESULT_TYPE_LABEL: Record = { +const RESULT_TYPE_LABEL: Record = { VERIFIED_PORTFOLIO: 'Portfolio', EXTERNAL_MARKET: 'Direktinserat', MAISON_WORK: 'Maison Work', - FUTURE_AVAILABILITY: 'Future Availability', + FUTURE_AVAILABILITY: 'Future', } -const RESULT_TYPE_COLOR: Record = { +const RESULT_TYPE_COLOR: Record = { VERIFIED_PORTFOLIO: '#1e3a5f', EXTERNAL_MARKET: '#d97706', MAISON_WORK: '#0369a1', FUTURE_AVAILABILITY: '#7c3aed', } -function scoreColor(score: number): string { - if (score >= 80) return '#1a7a4a' - if (score >= 65) return '#d97706' - return '#c0392b' +const MOCK_DOCS: Record = { + 'pl-001': [ + { name: 'Expose_Zollstrasse12.pdf', date: '05.05.2026' }, + { name: 'Grundriss_EG.pdf', date: '08.05.2026' }, + { name: 'Mietvertrag_Entwurf.docx', date: '14.05.2026' }, + ], + 'pl-007': [ + { name: 'Expose_Stadthaus_Bern.pdf', date: '12.04.2026' }, + { name: 'Mietvertrag_unterschrieben.pdf', date: '02.05.2026' }, + ], } +function scoreColor(score: number) { + return score >= 80 ? '#1a7a4a' : score >= 65 ? '#d97706' : '#c0392b' +} + +function getKiInsight(item: PipelineItem): { summary: string; positives: string[]; risks: string[] } { + if (item.stage === 'SAVED') { + return { + summary: `Merkliste-Eintrag mit ${item.matchScore}% Match. Prüfen Sie, ob dieses Objekt für die Qualifizierung geeignet ist.`, + positives: [`Match-Score ${item.matchScore}%`], + risks: ['Noch nicht qualifiziert — Eignung prüfen'], + } + } + if (item.stage === 'CLOSED_WON') { + return { + summary: `Abschluss erfolgreich. ${item.title} wurde zu ${item.matchScore}% Match-Score abgeschlossen.`, + positives: ['Vertraglich gesichert', `Match ${item.matchScore}%`, 'Alle Kriterien erfüllt'], + risks: [], + } + } + if (item.stage === 'CLOSED_LOST') { + return { + summary: item.notes ?? 'Objekt nicht realisiert.', + positives: [], + risks: ['Nicht verfügbar', 'Alternative Optionen prüfen'], + } + } + const s = item.matchScore + return { + summary: s >= 80 + ? `Starkes Objekt (${s}%) — deckt die wesentlichen Suchkriterien ab. Prozess aktiv weitertreiben.` + : s >= 65 + ? `Solides Objekt (${s}%) mit Potenzial. Gezielte Klärung offener Punkte empfohlen.` + : `Schwächerer Match (${s}%). Kritisch prüfen bevor weitere Ressourcen investiert werden.`, + positives: [ + ...(s >= 80 ? [`Match ${s}% — hohe Übereinstimmung`] : []), + ...(item.areaLabel ? [`Fläche: ${item.areaLabel}`] : []), + ...(item.resultType === 'VERIFIED_PORTFOLIO' ? ['Geprüftes Portfolio-Objekt'] : []), + ...(item.stage === 'NEGOTIATION' ? ['Verhandlung läuft — kurz vor Abschluss'] : []), + ].slice(0, 3), + risks: [ + ...(s < 80 ? [`Match ${s}% — Abweichungen prüfen`] : []), + ...(item.notes?.includes('Budget') ? ['Budget-Diskrepanz erwähnt'] : []), + ...(item.resultType === 'FUTURE_AVAILABILITY' ? ['Verfügbarkeit noch nicht bestätigt'] : []), + ].slice(0, 2), + } +} + +// ── PipelineCard ───────────────────────────────────────────────────────────── + function PipelineCard({ item, - onAdvance, + isSelected, + onSelect, }: { item: PipelineItem - onAdvance: (id: string) => void + isSelected: boolean + onSelect: (item: PipelineItem) => void }) { - const stageConfig = STAGES.find((s) => s.key === item.stage)! - const showAdvance = item.stage !== 'CLOSED_WON' && item.stage !== 'CLOSED_LOST' - return ( onSelect(item)} sx={{ - p: 1.5, - borderRadius: 1.5, - border: '1px solid #e2e8f0', - cursor: 'default', - bgcolor: 'white', + p: 1.5, borderRadius: 1.5, + border: isSelected ? '2px solid #1e3a5f' : '1px solid #e2e8f0', + cursor: 'pointer', + bgcolor: isSelected ? '#eff6ff' : 'white', + '&:hover': { boxShadow: '0 2px 8px rgba(0,0,0,0.1)', borderColor: isSelected ? '#1e3a5f' : '#bfdbfe' }, + transition: 'box-shadow 0.15s, border-color 0.15s', }} > - - + + {item.title} - + {item.matchScore}% - - + {item.location} - - + + {item.areaLabel && {item.areaLabel}} + {item.rentLabel && {item.rentLabel}} - - {(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) +// ── DetailPanel ─────────────────────────────────────────────────────────────── - const activeCount = items.filter( - (i) => i.stage !== 'CLOSED_WON' && i.stage !== 'CLOSED_LOST' - ).length +function DetailPanel({ + item, + onClose, +}: { + item: PipelineItem + onClose: () => void +}) { + const { moveStage, updateNotes, loseItem } = usePipelineStore() + const [notes, setNotes] = useState(item.notes ?? '') + const stageConfig = STAGES.find(s => s.key === item.stage)! + const stageIndex = STAGES.findIndex(s => s.key === item.stage) + const nextStage = NEXT_STAGE[item.stage] + const ki = getKiInsight(item) + const docs = MOCK_DOCS[item.id] ?? [] + const isClosed = item.stage === 'CLOSED_WON' || item.stage === 'CLOSED_LOST' - 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 } - }) - ) - } + // progress bar: SAVED=0, DISCOVERED=1, QUALIFIED=2, VISITED=3, NEGOTIATION=4 + const activeStages = STAGES.slice(0, 5) + const progressIdx = Math.min(stageIndex, 4) return ( - - - - - Deal Pipeline - - - Verfolgen Sie Objekte von der Entdeckung bis zum Abschluss - + + {/* Header */} + + + + + {item.title} + + {item.location} + + + + {item.matchScore}% + + + + + + + + {/* Stage progress bar */} + + + {activeStages.map((s, idx) => ( + + ))} + + - - - {STAGES.map((stage) => { - const columnItems = items.filter((i) => i.stage === stage.key) - return ( - - - - {stage.label} - - - - - - {columnItems.map((item) => ( - - ))} - + + {/* KI insight */} + + + + + KI Einschätzung + + + + {ki.summary} + + {ki.positives.map((p, i) => ( + + + {p} - ) - })} + ))} + {ki.risks.map((r, i) => ( + + + {r} + + ))} + + + + + {/* Stage actions */} + {!isClosed && ( + + + Nächste Aktion + + + {nextStage && ( + + )} + + + + )} + + + + {/* Notes */} + + + + + Notizen + + + setNotes(e.target.value)} + onBlur={() => updateNotes(item.id, notes)} + sx={{ '& .MuiOutlinedInput-root': { fontSize: '0.8rem', borderRadius: 1.5 } }} + /> + + + + + {/* Documents */} + + + + + Dokumente + + + {docs.length === 0 ? ( + + Noch keine Dokumente. + + ) : ( + + {docs.map((doc, i) => ( + + + {doc.name} + {doc.date} + + ))} + + )} + + + + {/* Meta */} + + + + {item.availabilityLabel && ( + + Verfügbar: {item.availabilityLabel} + + )} + {item.assignedTo && ( + + Verantwortlich: {item.assignedTo} + + )} + + Hinzugefügt: {new Date(item.addedAt).toLocaleDateString('de-CH')} + + + + + + ) +} + +// ── Pipeline page ───────────────────────────────────────────────────────────── + +export default function Pipeline() { + const { items } = usePipelineStore() + const [selectedItem, setSelectedItem] = useState(null) + + const activeCount = items.filter(i => i.stage !== 'CLOSED_WON' && i.stage !== 'CLOSED_LOST').length + const wonCount = items.filter(i => i.stage === 'CLOSED_WON').length + const wonScore = wonCount > 0 + ? Math.round(items.filter(i => i.stage === 'CLOSED_WON').reduce((sum, i) => sum + i.matchScore, 0) / wonCount) + : 0 + + function handleSelect(item: PipelineItem) { + setSelectedItem(prev => prev?.id === item.id ? null : item) + } + + // Keep selected item in sync when store updates (stage change, notes, etc.) + const syncedSelected = selectedItem + ? items.find(i => i.id === selectedItem.id) ?? null + : null + + return ( + + + + {/* Header */} + + + Deal Pipeline + + Von der ersten Idee bis zum Abschluss — alles in einer Ansicht + + + + {wonCount > 0 && ( + } + label={`${wonCount} gewonnen · ø ${wonScore}%`} + size="small" + sx={{ bgcolor: '#f0fdf4', color: '#1a7a4a', fontWeight: 600, border: '1px solid #86efac' }} + /> + )} + + + + + {/* Body */} + + {/* Kanban */} + + {STAGES.map(stage => { + const columnItems = items.filter(i => i.stage === stage.key) + return ( + + + + {stage.label} + + + + + {columnItems.map(item => ( + + ))} + {columnItems.length === 0 && ( + + + Leer + + + )} + + + ) + })} + + + {/* Detail panel */} + {syncedSelected && ( + setSelectedItem(null)} + /> + )} ) diff --git a/src/pages/demand/Results.tsx b/src/pages/demand/Results.tsx index f04e6b1..1063577 100644 --- a/src/pages/demand/Results.tsx +++ b/src/pages/demand/Results.tsx @@ -12,7 +12,7 @@ import { ResultFilterBar, UnifiedResultFeed, } from '../../components/results' -import { AddToShortlistDialog } from '../../components/shortlist' +import { AddToPipelineDialog } from '../../components/shortlist' import { useSessionStore } from '../../stores/sessionStore' import type { ResultType } from '../../domain/enums' import type { UnifiedMatchResult } from '../../domain/unifiedResult' @@ -91,7 +91,7 @@ export default function Results() { return ( - + void + closeSavedDialog: () => void + confirmSaved: (notes?: string) => 'added' | 'duplicate' + moveStage: (id: string, stage: PipelineStage) => void + updateNotes: (id: string, notes: string) => void + loseItem: (id: string) => void +} + +export const usePipelineStore = create((set, get) => ({ + items: mockPipelineItems, + dialogOpen: false, + pendingItem: null, + + openSavedDialog: (item) => set({ dialogOpen: true, pendingItem: item }), + closeSavedDialog: () => set({ dialogOpen: false, pendingItem: null }), + + confirmSaved: (notes) => { + const { pendingItem, items } = get() + if (!pendingItem) return 'duplicate' + const alreadyExists = items.some(i => i.id === pendingItem.resultId) + if (!alreadyExists) { + const newItem: PipelineItem = { + id: pendingItem.resultId, + title: pendingItem.title, + location: pendingItem.location ?? '–', + matchScore: pendingItem.matchScore, + resultType: pendingItem.resultType, + stage: 'SAVED', + areaLabel: pendingItem.areaLabel, + rentLabel: pendingItem.rentLabel, + availabilityLabel: pendingItem.availabilityLabel, + notes: notes || undefined, + addedAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + } + set({ items: [...items, newItem], dialogOpen: false, pendingItem: null }) + return 'added' + } + set({ dialogOpen: false, pendingItem: null }) + return 'duplicate' + }, + + moveStage: (id, stage) => set(state => ({ + items: state.items.map(i => + i.id === id ? { ...i, stage, updatedAt: new Date().toISOString() } : i + ), + })), + + updateNotes: (id, notes) => set(state => ({ + items: state.items.map(i => i.id === id ? { ...i, notes } : i), + })), + + loseItem: (id) => set(state => ({ + items: state.items.map(i => + i.id === id ? { ...i, stage: 'CLOSED_LOST' as PipelineStage, updatedAt: new Date().toISOString() } : i + ), + })), +}))