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 (
+
+ )
+}
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 && (
)}
+
+
+ { setView('list'); localStorage.setItem('view-shortlist', 'list') }}
+ sx={{ color: view === 'list' ? '#1e3a5f' : '#94a3b8', bgcolor: view === 'list' ? '#eff6ff' : 'transparent' }}
+ >
+
+
+
+
+ { setView('grid'); localStorage.setItem('view-shortlist', 'grid') }}
+ sx={{ color: view === 'grid' ? '#1e3a5f' : '#94a3b8', bgcolor: view === 'grid' ? '#eff6ff' : 'transparent' }}
+ >
+
+
+
+
{/* Items */}
-
+
{shortlist.items.length === 0
?
- : shortlist.items.map(item => (
-
- ))
+ : view === 'grid'
+ ? (
+
+ {shortlist.items.map(item => (
+
+ ))}
+
+ )
+ : shortlist.items.map(item => (
+
+ ))
}
diff --git a/src/components/shortlist/ShortlistItemCard.tsx b/src/components/shortlist/ShortlistItemCard.tsx
index 6e21573..e9a49fb 100644
--- a/src/components/shortlist/ShortlistItemCard.tsx
+++ b/src/components/shortlist/ShortlistItemCard.tsx
@@ -1,5 +1,10 @@
-import { Box, Chip, IconButton, Typography } from '@mui/material'
-import { X } from 'lucide-react'
+import { useState } from 'react'
+import { useNavigate } from 'react-router'
+import {
+ Box, Card, Chip, IconButton, Typography,
+ Dialog, DialogTitle, DialogContent, DialogActions, Button, TextField,
+} from '@mui/material'
+import { X, MessageSquare, ExternalLink } from 'lucide-react'
import { useRemoveFromShortlist } from '../../hooks/useShortlists'
import { useToastStore } from '../../stores/toastStore'
import type { ShortlistItem } from '../../domain/shortlist'
@@ -17,62 +22,213 @@ interface Props {
item: ShortlistItem
shortlistId: string
isFinalized: boolean
+ view?: 'list' | 'grid'
}
-export function ShortlistItemCard({ item, shortlistId, isFinalized }: Props) {
+export function ShortlistItemCard({ item, shortlistId, isFinalized, view = 'list' }: Props) {
+ const navigate = useNavigate()
const removeItem = useRemoveFromShortlist()
const showToast = useToastStore((s) => s.showToast)
+ const [inquiryOpen, setInquiryOpen] = useState(false)
+ const [inquiryMsg, setInquiryMsg] = useState('')
const addedDate = new Date(item.addedAt).toLocaleDateString('de-CH', { day: '2-digit', month: '2-digit', year: 'numeric' })
- return (
-
-
-
- {item.title}
-
-
-
-
- {item.sourceLabel ?? item.resultType} · {item.addedBy} · {addedDate}
+ const detailPath = item.propertyId
+ ? `/demand/property/${item.propertyId}`
+ : `/demand/results/${item.resultId}`
+
+ function handleCardClick() {
+ navigate(detailPath)
+ }
+
+ function handleSendInquiry() {
+ if (!inquiryMsg.trim()) return
+ showToast('Anfrage gesendet. Sie finden sie unter Anfragen.')
+ setInquiryOpen(false)
+ setInquiryMsg('')
+ navigate('/demand/anfragen')
+ }
+
+ const removeBtn = !isFinalized && (
+ {
+ e.stopPropagation()
+ removeItem.mutate(
+ { shortlistId, resultId: item.resultId },
+ {
+ onSuccess: () => showToast('Objekt aus Shortlist entfernt.'),
+ onError: () => showToast('Entfernen fehlgeschlagen.', 'error'),
+ }
+ )
+ }}
+ sx={{ color: '#94a3b8', '&:hover': { color: '#c0392b' }, flexShrink: 0 }}
+ >
+
+
+ )
+
+ const inquiryDialog = (
+
- {!isFinalized && (
- removeItem.mutate(
- { shortlistId, resultId: item.resultId },
- {
- onSuccess: () => showToast('Objekt aus Shortlist entfernt.'),
- onError: () => showToast('Entfernen fehlgeschlagen.', 'error'),
- }
- )}
- sx={{ color: '#94a3b8', '&:hover': { color: '#c0392b' }, flexShrink: 0 }}
+ fullWidth
+ defaultValue={`Anfrage zu ${item.title}`}
+ sx={{ mb: 2 }}
+ />
+ setInquiryMsg(e.target.value)}
+ />
+
+
+
+
- )}
-
+ Anfrage senden
+
+
+
+ )
+
+ 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 && (
+ }
+ onClick={() => moveStage(item.id, nextStage.key)}
+ sx={{ bgcolor: stageConfig.color, '&:hover': { filter: 'brightness(0.9)' }, fontSize: '0.75rem', py: 0.5 }}
+ >
+ {nextStage.label}
+
+ )}
+
+
+
+ )}
+
+
+
+ {/* 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
+ ),
+ })),
+}))