feat: F015 shortlist management
3-panel Shortlists page with left list, center detail, right decision brief panel. AddToShortlistDialog wired into result feed cards and match detail. Full DRAFT→REVIEW_READY→FINALIZED workflow, AI-generated decision brief draft, duplicate prevention, and compare-view integration. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,7 @@ import { useNavigate } from 'react-router'
|
|||||||
import { MatchCardCompact } from '../match-card/MatchCardCompact'
|
import { MatchCardCompact } from '../match-card/MatchCardCompact'
|
||||||
import { buildMatchCardViewModel } from '../../features/matching/matchCardAdapter'
|
import { buildMatchCardViewModel } from '../../features/matching/matchCardAdapter'
|
||||||
import { useCompareStore } from '../../stores/compareStore'
|
import { useCompareStore } from '../../stores/compareStore'
|
||||||
|
import { useShortlistStore } from '../../stores/shortlistStore'
|
||||||
import type { UnifiedMatchResult } from '../../domain/unifiedResult'
|
import type { UnifiedMatchResult } from '../../domain/unifiedResult'
|
||||||
import type { MatchCardAction } from '../match-card/MatchCardViewModel'
|
import type { MatchCardAction } from '../match-card/MatchCardViewModel'
|
||||||
|
|
||||||
@@ -9,9 +10,17 @@ interface Props {
|
|||||||
result: UnifiedMatchResult
|
result: UnifiedMatchResult
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getResultTitle(result: UnifiedMatchResult): string {
|
||||||
|
if (result.resultType !== 'FUTURE_AVAILABILITY') {
|
||||||
|
return (result as any).property?.title ?? result.matchId
|
||||||
|
}
|
||||||
|
return (result as any).signal?.companyName ?? result.matchId
|
||||||
|
}
|
||||||
|
|
||||||
export function UnifiedResultCard({ result }: Props) {
|
export function UnifiedResultCard({ result }: Props) {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const { addToCompare, removeFromCompare, isInCompare, isFull } = useCompareStore()
|
const { addToCompare, removeFromCompare, isInCompare, isFull } = useCompareStore()
|
||||||
|
const { openAddDialog } = useShortlistStore()
|
||||||
const inCompare = isInCompare(result.matchId)
|
const inCompare = isInCompare(result.matchId)
|
||||||
|
|
||||||
const actions: MatchCardAction[] = [
|
const actions: MatchCardAction[] = [
|
||||||
@@ -20,8 +29,15 @@ export function UnifiedResultCard({ result }: Props) {
|
|||||||
label: 'Shortlist',
|
label: 'Shortlist',
|
||||||
actionType: 'SAVE_SHORTLIST',
|
actionType: 'SAVE_SHORTLIST',
|
||||||
variant: 'secondary',
|
variant: 'secondary',
|
||||||
disabled: true,
|
onClick: () => openAddDialog({
|
||||||
onClick: () => {},
|
resultId: result.matchId,
|
||||||
|
resultType: result.resultType,
|
||||||
|
title: getResultTitle(result),
|
||||||
|
matchScore: result.matchScore,
|
||||||
|
confidenceScore: result.match.confidenceLevel,
|
||||||
|
sourceLabel: result.resultType,
|
||||||
|
addedBy: 'admin@ideal-sharing.ch',
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'compare',
|
id: 'compare',
|
||||||
|
|||||||
@@ -0,0 +1,174 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
CircularProgress,
|
||||||
|
Dialog,
|
||||||
|
DialogActions,
|
||||||
|
DialogContent,
|
||||||
|
DialogTitle,
|
||||||
|
FormControlLabel,
|
||||||
|
Radio,
|
||||||
|
RadioGroup,
|
||||||
|
Snackbar,
|
||||||
|
TextField,
|
||||||
|
Typography,
|
||||||
|
} from '@mui/material'
|
||||||
|
import { Plus } from 'lucide-react'
|
||||||
|
import { ShortlistStatusBadge } from './ShortlistStatusBadge'
|
||||||
|
import { useShortlists, useCreateShortlist, useAddToShortlist } from '../../hooks/useShortlists'
|
||||||
|
import { useShortlistStore } from '../../stores/shortlistStore'
|
||||||
|
import { ShortlistStatus } from '../../domain/enums'
|
||||||
|
|
||||||
|
export function AddToShortlistDialog() {
|
||||||
|
const { dialogOpen, pendingItem, closeAddDialog } = useShortlistStore()
|
||||||
|
const { data: shortlists = [], isLoading } = useShortlists()
|
||||||
|
const createShortlist = useCreateShortlist()
|
||||||
|
const addToShortlist = useAddToShortlist()
|
||||||
|
|
||||||
|
const [selectedId, setSelectedId] = useState<string>('')
|
||||||
|
const [creatingNew, setCreatingNew] = useState(false)
|
||||||
|
const [newTitle, setNewTitle] = useState('')
|
||||||
|
const [note, setNote] = useState('')
|
||||||
|
const [snackbar, setSnackbar] = useState<string | null>(null)
|
||||||
|
|
||||||
|
function handleClose() {
|
||||||
|
closeAddDialog()
|
||||||
|
setSelectedId('')
|
||||||
|
setCreatingNew(false)
|
||||||
|
setNewTitle('')
|
||||||
|
setNote('')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleConfirm() {
|
||||||
|
if (!pendingItem) return
|
||||||
|
const itemWithNote = { ...pendingItem, note: note.trim() || undefined }
|
||||||
|
|
||||||
|
let targetId = selectedId
|
||||||
|
|
||||||
|
if (creatingNew) {
|
||||||
|
if (!newTitle.trim()) return
|
||||||
|
const created = await createShortlist.mutateAsync({
|
||||||
|
title: newTitle.trim(),
|
||||||
|
items: [],
|
||||||
|
status: ShortlistStatus.DRAFT,
|
||||||
|
createdBy: 'admin@ideal-sharing.ch',
|
||||||
|
organizationId: 'org-wincasa',
|
||||||
|
})
|
||||||
|
targetId = created.data.id
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!targetId) return
|
||||||
|
|
||||||
|
const prevShortlist = shortlists.find(s => s.id === targetId)
|
||||||
|
const alreadyExists = prevShortlist?.items.some(i => i.resultId === pendingItem.resultId)
|
||||||
|
|
||||||
|
await addToShortlist.mutateAsync({ shortlistId: targetId, item: itemWithNote })
|
||||||
|
|
||||||
|
if (alreadyExists) {
|
||||||
|
setSnackbar('Bereits in dieser Shortlist vorhanden')
|
||||||
|
} else {
|
||||||
|
setSnackbar('Zur Shortlist hinzugefügt')
|
||||||
|
}
|
||||||
|
handleClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
const isBusy = createShortlist.isPending || addToShortlist.isPending
|
||||||
|
const canConfirm = !isBusy && ((creatingNew && !!newTitle.trim()) || (!creatingNew && !!selectedId))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Dialog open={dialogOpen} onClose={handleClose} maxWidth="xs" fullWidth>
|
||||||
|
<DialogTitle sx={{ fontWeight: 700, pb: 1 }}>Zur Shortlist hinzufügen</DialogTitle>
|
||||||
|
<DialogContent sx={{ pt: 0 }}>
|
||||||
|
{pendingItem && (
|
||||||
|
<Box sx={{ bgcolor: '#f8fafc', border: '1px solid #e2e8f0', borderRadius: 1, p: 1.5, mb: 2 }}>
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 600 }}>{pendingItem.title}</Typography>
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
Score {pendingItem.matchScore} · {pendingItem.resultType}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'center', py: 2 }}>
|
||||||
|
<CircularProgress size={20} />
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<RadioGroup value={creatingNew ? '__new__' : selectedId} onChange={e => {
|
||||||
|
if (e.target.value === '__new__') { setCreatingNew(true); setSelectedId('') }
|
||||||
|
else { setCreatingNew(false); setSelectedId(e.target.value) }
|
||||||
|
}}>
|
||||||
|
{shortlists.map(sl => (
|
||||||
|
<FormControlLabel
|
||||||
|
key={sl.id}
|
||||||
|
value={sl.id}
|
||||||
|
control={<Radio size="small" />}
|
||||||
|
label={
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
|
<Typography variant="body2">{sl.title}</Typography>
|
||||||
|
<ShortlistStatusBadge status={sl.status} />
|
||||||
|
</Box>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<FormControlLabel
|
||||||
|
value="__new__"
|
||||||
|
control={<Radio size="small" />}
|
||||||
|
label={
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||||
|
<Plus size={14} />
|
||||||
|
<Typography variant="body2">Neue Shortlist erstellen</Typography>
|
||||||
|
</Box>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</RadioGroup>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{creatingNew && (
|
||||||
|
<TextField
|
||||||
|
size="small"
|
||||||
|
fullWidth
|
||||||
|
autoFocus
|
||||||
|
label="Titel der neuen Shortlist"
|
||||||
|
value={newTitle}
|
||||||
|
onChange={e => setNewTitle(e.target.value)}
|
||||||
|
sx={{ mt: 1.5 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
size="small"
|
||||||
|
fullWidth
|
||||||
|
multiline
|
||||||
|
rows={2}
|
||||||
|
label="Notiz (optional)"
|
||||||
|
value={note}
|
||||||
|
onChange={e => setNote(e.target.value)}
|
||||||
|
sx={{ mt: 2 }}
|
||||||
|
/>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions sx={{ px: 3, pb: 2 }}>
|
||||||
|
<Button onClick={handleClose} disabled={isBusy}>Abbrechen</Button>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
onClick={handleConfirm}
|
||||||
|
disabled={!canConfirm}
|
||||||
|
endIcon={isBusy ? <CircularProgress size={14} color="inherit" /> : undefined}
|
||||||
|
sx={{ bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#162d4a' } }}
|
||||||
|
>
|
||||||
|
Hinzufügen
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<Snackbar
|
||||||
|
open={!!snackbar}
|
||||||
|
autoHideDuration={3000}
|
||||||
|
onClose={() => setSnackbar(null)}
|
||||||
|
message={snackbar}
|
||||||
|
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { Accordion, AccordionDetails, AccordionSummary, Alert, Box, Button, CircularProgress, Typography } from '@mui/material'
|
||||||
|
import { ChevronDown, Sparkles } from 'lucide-react'
|
||||||
|
import { aiService } from '../../services/aiService'
|
||||||
|
import type { DecisionBrief } from '../../services/aiService'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
shortlistId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DecisionBriefDraftPanel({ shortlistId }: Props) {
|
||||||
|
const [brief, setBrief] = useState<DecisionBrief | null>(null)
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
|
async function handleGenerate() {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const resp = await aiService.generateDecisionBrief(shortlistId)
|
||||||
|
setBrief(resp.data)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
||||||
|
<Box sx={{ px: 2, py: 1.5, borderBottom: '1px solid #e2e8f0', bgcolor: 'white', flexShrink: 0 }}>
|
||||||
|
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>KI Decision Brief</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ flex: 1, overflowY: 'auto', p: 2 }}>
|
||||||
|
{loading ? (
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 1.5, py: 6 }}>
|
||||||
|
<CircularProgress size={28} />
|
||||||
|
<Typography variant="body2" color="text.secondary">Brief wird generiert…</Typography>
|
||||||
|
</Box>
|
||||||
|
) : brief ? (
|
||||||
|
<Box>
|
||||||
|
<Alert severity="warning" icon={<Sparkles size={16} />} sx={{ mb: 2, py: 0.5 }}>
|
||||||
|
<Typography variant="caption" sx={{ fontWeight: 600 }}>KI-ENTWURF</Typography>
|
||||||
|
<Typography variant="caption" sx={{ display: 'block' }}>
|
||||||
|
Automatisch generiert — bitte prüfen und anpassen.
|
||||||
|
</Typography>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<Typography variant="body2" sx={{ mb: 2, color: '#475569' }}>{brief.summary}</Typography>
|
||||||
|
|
||||||
|
{brief.sections.map((section, i) => (
|
||||||
|
<Accordion key={i} disableGutters elevation={0} sx={{ border: '1px solid #e2e8f0', mb: 0.5, '&:before': { display: 'none' } }}>
|
||||||
|
<AccordionSummary expandIcon={<ChevronDown size={16} />} sx={{ minHeight: 40, '& .MuiAccordionSummary-content': { my: 0.5 } }}>
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 600 }}>{section.title}</Typography>
|
||||||
|
</AccordionSummary>
|
||||||
|
<AccordionDetails sx={{ pt: 0 }}>
|
||||||
|
<Typography variant="body2" color="text.secondary">{section.body}</Typography>
|
||||||
|
</AccordionDetails>
|
||||||
|
</Accordion>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
fullWidth
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
startIcon={<Sparkles size={14} />}
|
||||||
|
onClick={handleGenerate}
|
||||||
|
sx={{ mt: 2 }}
|
||||||
|
>
|
||||||
|
Neu generieren
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 2, py: 6, textAlign: 'center' }}>
|
||||||
|
<Sparkles size={32} color="#94a3b8" />
|
||||||
|
<Box>
|
||||||
|
<Typography variant="subtitle2" color="text.secondary" sx={{ mb: 0.5 }}>Decision Brief</Typography>
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
KI analysiert Ihre Shortlist und erstellt einen strukturierten Entscheidungsentwurf.
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
size="small"
|
||||||
|
startIcon={<Sparkles size={14} />}
|
||||||
|
onClick={handleGenerate}
|
||||||
|
sx={{ bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#162d4a' } }}
|
||||||
|
>
|
||||||
|
Brief generieren
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { Box, Chip, Typography } from '@mui/material'
|
||||||
|
import { ShortlistStatusBadge } from './ShortlistStatusBadge'
|
||||||
|
import { useShortlistStore } from '../../stores/shortlistStore'
|
||||||
|
import type { Shortlist } from '../../domain/shortlist'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
shortlist: Shortlist
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ShortlistCard({ shortlist }: Props) {
|
||||||
|
const { selectedShortlistId, setSelectedShortlist } = useShortlistStore()
|
||||||
|
const isSelected = selectedShortlistId === shortlist.id
|
||||||
|
|
||||||
|
const updatedDate = new Date(shortlist.updatedAt).toLocaleDateString('de-CH', { day: '2-digit', month: '2-digit', year: 'numeric' })
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
onClick={() => setSelectedShortlist(isSelected ? null : shortlist.id)}
|
||||||
|
sx={{
|
||||||
|
p: 1.5,
|
||||||
|
cursor: 'pointer',
|
||||||
|
borderBottom: '1px solid #f1f5f9',
|
||||||
|
borderLeft: isSelected ? '3px solid #1e3a5f' : '3px solid transparent',
|
||||||
|
bgcolor: isSelected ? '#eff6ff' : 'transparent',
|
||||||
|
'&:hover': { bgcolor: isSelected ? '#eff6ff' : '#f8fafc' },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', mb: 0.5, gap: 0.5 }}>
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 600, lineHeight: 1.3, flex: 1 }}>
|
||||||
|
{shortlist.title}
|
||||||
|
</Typography>
|
||||||
|
<Chip
|
||||||
|
label={shortlist.items.length}
|
||||||
|
size="small"
|
||||||
|
sx={{ bgcolor: '#e2e8f0', color: '#475569', fontSize: 10, height: 18, minWidth: 22, flexShrink: 0 }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, flexWrap: 'wrap' }}>
|
||||||
|
<ShortlistStatusBadge status={shortlist.status} />
|
||||||
|
<Typography variant="caption" color="text.secondary">{updatedDate}</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { Alert, Box, Button, Chip, TextField, Typography } from '@mui/material'
|
||||||
|
import { useNavigate } from 'react-router'
|
||||||
|
import { CheckCircle, Columns2, Pencil } from 'lucide-react'
|
||||||
|
import { ShortlistStatusBadge } from './ShortlistStatusBadge'
|
||||||
|
import { ShortlistItemCard } from './ShortlistItemCard'
|
||||||
|
import { ShortlistEmptyState } from './ShortlistEmptyState'
|
||||||
|
import { useUpdateShortlist } from '../../hooks/useShortlists'
|
||||||
|
import { useCompareStore } from '../../stores/compareStore'
|
||||||
|
import { ShortlistStatus } from '../../domain/enums'
|
||||||
|
import type { Shortlist } from '../../domain/shortlist'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
shortlist: Shortlist
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ShortlistDetail({ shortlist }: Props) {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const updateShortlist = useUpdateShortlist()
|
||||||
|
const { addToCompare, compareItems } = useCompareStore()
|
||||||
|
|
||||||
|
const [editingTitle, setEditingTitle] = useState(false)
|
||||||
|
const [titleDraft, setTitleDraft] = useState(shortlist.title)
|
||||||
|
|
||||||
|
const isFinalized = shortlist.status === ShortlistStatus.FINALIZED
|
||||||
|
const isDraft = shortlist.status === ShortlistStatus.DRAFT
|
||||||
|
const isReviewReady = shortlist.status === ShortlistStatus.REVIEW_READY
|
||||||
|
|
||||||
|
function handleTitleSave() {
|
||||||
|
if (titleDraft.trim() && titleDraft.trim() !== shortlist.title) {
|
||||||
|
updateShortlist.mutate({ id: shortlist.id, data: { title: titleDraft.trim() } })
|
||||||
|
}
|
||||||
|
setEditingTitle(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleMarkReviewReady() {
|
||||||
|
updateShortlist.mutate({ id: shortlist.id, data: { status: ShortlistStatus.REVIEW_READY } })
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleFinalize() {
|
||||||
|
updateShortlist.mutate({ id: shortlist.id, data: { status: ShortlistStatus.FINALIZED } })
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleOpenCompare() {
|
||||||
|
for (const item of shortlist.items) {
|
||||||
|
if (item.resultType === 'FUTURE_AVAILABILITY') continue
|
||||||
|
// Build a minimal UnifiedMatchResult-compatible object for compare
|
||||||
|
const fakeResult = {
|
||||||
|
matchId: item.resultId,
|
||||||
|
needId: '',
|
||||||
|
matchScore: item.matchScore,
|
||||||
|
resultType: item.resultType as 'VERIFIED_PORTFOLIO' | 'EXTERNAL_MARKET',
|
||||||
|
property: { id: item.propertyId ?? item.resultId, title: item.title } as any,
|
||||||
|
match: { id: item.resultId, matchScore: item.matchScore, confidenceLevel: item.confidenceScore ?? 0.8 } as any,
|
||||||
|
}
|
||||||
|
if (compareItems.length < 4) addToCompare(fakeResult as any)
|
||||||
|
}
|
||||||
|
navigate('/demand/compare')
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedDate = new Date(shortlist.updatedAt).toLocaleDateString('de-CH', {
|
||||||
|
day: '2-digit', month: '2-digit', year: 'numeric',
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
||||||
|
{/* Header */}
|
||||||
|
<Box sx={{ px: 3, py: 2.5, borderBottom: '1px solid #e2e8f0', bgcolor: 'white', flexShrink: 0 }}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1.5, mb: 1.5 }}>
|
||||||
|
{editingTitle ? (
|
||||||
|
<TextField
|
||||||
|
size="small"
|
||||||
|
autoFocus
|
||||||
|
value={titleDraft}
|
||||||
|
onChange={e => setTitleDraft(e.target.value)}
|
||||||
|
onBlur={handleTitleSave}
|
||||||
|
onKeyDown={e => { if (e.key === 'Enter') handleTitleSave(); if (e.key === 'Escape') { setTitleDraft(shortlist.title); setEditingTitle(false) } }}
|
||||||
|
sx={{ flex: 1, '& .MuiInputBase-input': { fontSize: '1.1rem', fontWeight: 700 } }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, flex: 1 }}>
|
||||||
|
<Typography variant="h6" sx={{ fontWeight: 700 }}>{shortlist.title}</Typography>
|
||||||
|
{!isFinalized && (
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
onClick={() => { setTitleDraft(shortlist.title); setEditingTitle(true) }}
|
||||||
|
sx={{ minWidth: 0, p: 0.5, color: '#94a3b8' }}
|
||||||
|
>
|
||||||
|
<Pencil size={14} />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
<ShortlistStatusBadge status={shortlist.status} />
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: '4px 16px', mb: 1.5 }}>
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
<strong>{shortlist.items.length}</strong> Objekte
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
Erstellt von {shortlist.createdBy}
|
||||||
|
</Typography>
|
||||||
|
{shortlist.organizationId && (
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
{shortlist.organizationId}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
Aktualisiert {updatedDate}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{isFinalized && (
|
||||||
|
<Alert severity="warning" icon={<CheckCircle size={16} />} sx={{ mb: 1.5, py: 0.25 }}>
|
||||||
|
Diese Shortlist ist finalisiert — keine Änderungen möglich.
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
|
||||||
|
{isDraft && (
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
onClick={handleMarkReviewReady}
|
||||||
|
disabled={updateShortlist.isPending}
|
||||||
|
sx={{ color: '#d97706', borderColor: '#d97706' }}
|
||||||
|
>
|
||||||
|
Prüfbereit markieren
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{isReviewReady && (
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
onClick={handleFinalize}
|
||||||
|
disabled={updateShortlist.isPending}
|
||||||
|
sx={{ color: '#1a7a4a', borderColor: '#1a7a4a' }}
|
||||||
|
>
|
||||||
|
Finalisieren
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{shortlist.items.length > 0 && (
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
startIcon={<Columns2 size={14} />}
|
||||||
|
onClick={handleOpenCompare}
|
||||||
|
>
|
||||||
|
Im Vergleich öffnen
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{shortlist.description && (
|
||||||
|
<Chip label={shortlist.description} size="small" variant="outlined" sx={{ fontSize: 11 }} />
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Items */}
|
||||||
|
<Box sx={{ flex: 1, overflowY: 'auto' }}>
|
||||||
|
{shortlist.items.length === 0
|
||||||
|
? <ShortlistEmptyState context="empty-shortlist" />
|
||||||
|
: shortlist.items.map(item => (
|
||||||
|
<ShortlistItemCard
|
||||||
|
key={item.resultId}
|
||||||
|
item={item}
|
||||||
|
shortlistId={shortlist.id}
|
||||||
|
isFinalized={isFinalized}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { Box, Typography } from '@mui/material'
|
||||||
|
import { Bookmark, BookmarkPlus } from 'lucide-react'
|
||||||
|
|
||||||
|
type EmptyContext = 'no-shortlists' | 'empty-shortlist'
|
||||||
|
|
||||||
|
const META: Record<EmptyContext, { icon: React.ReactNode; title: string; desc: string }> = {
|
||||||
|
'no-shortlists': {
|
||||||
|
icon: <Bookmark size={32} color="#94a3b8" />,
|
||||||
|
title: 'Noch keine Shortlists',
|
||||||
|
desc: 'Speichern Sie Suchergebnisse in einer Shortlist, um sie gezielt zu vergleichen und zu entscheiden.',
|
||||||
|
},
|
||||||
|
'empty-shortlist': {
|
||||||
|
icon: <BookmarkPlus size={32} color="#94a3b8" />,
|
||||||
|
title: 'Shortlist ist leer',
|
||||||
|
desc: 'Fügen Sie Objekte aus den Suchergebnissen oder dem Match-Feed hinzu.',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ShortlistEmptyState({ context }: { context: EmptyContext }) {
|
||||||
|
const { icon, title, desc } = META[context]
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 1.5, py: 8, px: 3 }}>
|
||||||
|
{icon}
|
||||||
|
<Typography variant="subtitle1" sx={{ fontWeight: 600 }} color="text.secondary">{title}</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ textAlign: 'center', maxWidth: 280 }}>{desc}</Typography>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { Box, Chip, IconButton, Typography } from '@mui/material'
|
||||||
|
import { X } from 'lucide-react'
|
||||||
|
import { useRemoveFromShortlist } from '../../hooks/useShortlists'
|
||||||
|
import type { ShortlistItem } from '../../domain/shortlist'
|
||||||
|
|
||||||
|
const RESULT_TYPE_LABEL: Record<string, string> = {
|
||||||
|
VERIFIED_PORTFOLIO: 'Portfolio',
|
||||||
|
EXTERNAL_MARKET: 'Markt',
|
||||||
|
FUTURE_AVAILABILITY: 'Signal',
|
||||||
|
}
|
||||||
|
|
||||||
|
const SCORE_COLOR = (s: number) => s >= 75 ? '#1a7a4a' : s >= 55 ? '#d97706' : '#c0392b'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
item: ShortlistItem
|
||||||
|
shortlistId: string
|
||||||
|
isFinalized: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ShortlistItemCard({ item, shortlistId, isFinalized }: Props) {
|
||||||
|
const removeItem = useRemoveFromShortlist()
|
||||||
|
|
||||||
|
const addedDate = new Date(item.addedAt).toLocaleDateString('de-CH', { day: '2-digit', month: '2-digit', year: 'numeric' })
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{
|
||||||
|
p: 2,
|
||||||
|
borderBottom: '1px solid #f1f5f9',
|
||||||
|
opacity: isFinalized ? 0.75 : 1,
|
||||||
|
display: 'flex',
|
||||||
|
gap: 1.5,
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
}}>
|
||||||
|
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.5, flexWrap: 'wrap' }}>
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 600 }}>{item.title}</Typography>
|
||||||
|
<Chip
|
||||||
|
label={RESULT_TYPE_LABEL[item.resultType] ?? item.resultType}
|
||||||
|
size="small"
|
||||||
|
sx={{ fontSize: 10, height: 18 }}
|
||||||
|
/>
|
||||||
|
<Chip
|
||||||
|
label={`${item.matchScore}`}
|
||||||
|
size="small"
|
||||||
|
sx={{ bgcolor: SCORE_COLOR(item.matchScore), color: 'white', fontSize: 10, height: 18, fontWeight: 700 }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
<Typography variant="caption" color="text.secondary" sx={{ display: 'block' }}>
|
||||||
|
{item.sourceLabel ?? item.resultType} · {item.addedBy} · {addedDate}
|
||||||
|
</Typography>
|
||||||
|
{item.note && (
|
||||||
|
<Typography variant="caption" sx={{ display: 'block', mt: 0.5, color: '#475569', fontStyle: 'italic' }}>
|
||||||
|
{item.note}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
{!isFinalized && (
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
disabled={removeItem.isPending}
|
||||||
|
onClick={() => removeItem.mutate({ shortlistId, resultId: item.resultId })}
|
||||||
|
sx={{ color: '#94a3b8', '&:hover': { color: '#c0392b' }, flexShrink: 0 }}
|
||||||
|
>
|
||||||
|
<X size={14} />
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { Box, Button, CircularProgress, Skeleton, TextField } from '@mui/material'
|
||||||
|
import { Plus } from 'lucide-react'
|
||||||
|
import { ShortlistCard } from './ShortlistCard'
|
||||||
|
import { ShortlistEmptyState } from './ShortlistEmptyState'
|
||||||
|
import { useCreateShortlist } from '../../hooks/useShortlists'
|
||||||
|
import { useShortlistStore } from '../../stores/shortlistStore'
|
||||||
|
import { ShortlistStatus } from '../../domain/enums'
|
||||||
|
import type { Shortlist } from '../../domain/shortlist'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
shortlists: Shortlist[]
|
||||||
|
isLoading: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ShortlistList({ shortlists, isLoading }: Props) {
|
||||||
|
const [creating, setCreating] = useState(false)
|
||||||
|
const [newTitle, setNewTitle] = useState('')
|
||||||
|
const createShortlist = useCreateShortlist()
|
||||||
|
const { setSelectedShortlist } = useShortlistStore()
|
||||||
|
|
||||||
|
async function handleCreate() {
|
||||||
|
if (!newTitle.trim()) return
|
||||||
|
const result = await createShortlist.mutateAsync({
|
||||||
|
title: newTitle.trim(),
|
||||||
|
items: [],
|
||||||
|
status: ShortlistStatus.DRAFT,
|
||||||
|
createdBy: 'admin@ideal-sharing.ch',
|
||||||
|
organizationId: 'org-wincasa',
|
||||||
|
})
|
||||||
|
setNewTitle('')
|
||||||
|
setCreating(false)
|
||||||
|
setSelectedShortlist(result.data.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<Box sx={{ p: 1.5 }}>
|
||||||
|
{[0, 1, 2].map(i => (
|
||||||
|
<Box key={i} sx={{ mb: 1.5 }}>
|
||||||
|
<Skeleton variant="text" width="70%" sx={{ fontSize: '0.875rem' }} />
|
||||||
|
<Skeleton variant="text" width="40%" sx={{ fontSize: '0.75rem' }} />
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', flex: 1, overflow: 'hidden' }}>
|
||||||
|
<Box sx={{ flex: 1, overflowY: 'auto' }}>
|
||||||
|
{shortlists.length === 0 && !creating
|
||||||
|
? <ShortlistEmptyState context="no-shortlists" />
|
||||||
|
: shortlists.map(sl => <ShortlistCard key={sl.id} shortlist={sl} />)
|
||||||
|
}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ p: 1.5, borderTop: '1px solid #f1f5f9', flexShrink: 0 }}>
|
||||||
|
{creating ? (
|
||||||
|
<Box sx={{ display: 'flex', gap: 0.5 }}>
|
||||||
|
<TextField
|
||||||
|
size="small"
|
||||||
|
autoFocus
|
||||||
|
placeholder="Titel der Shortlist"
|
||||||
|
value={newTitle}
|
||||||
|
onChange={e => setNewTitle(e.target.value)}
|
||||||
|
onKeyDown={e => { if (e.key === 'Enter') handleCreate(); if (e.key === 'Escape') { setCreating(false); setNewTitle('') } }}
|
||||||
|
sx={{ flex: 1, '& .MuiInputBase-input': { fontSize: 13 } }}
|
||||||
|
/>
|
||||||
|
{createShortlist.isPending
|
||||||
|
? <CircularProgress size={20} sx={{ alignSelf: 'center', ml: 0.5 }} />
|
||||||
|
: (
|
||||||
|
<Button size="small" variant="contained" onClick={handleCreate} disabled={!newTitle.trim()} sx={{ bgcolor: '#1e3a5f', minWidth: 0, px: 1 }}>
|
||||||
|
OK
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
fullWidth
|
||||||
|
size="small"
|
||||||
|
startIcon={<Plus size={14} />}
|
||||||
|
onClick={() => setCreating(true)}
|
||||||
|
sx={{ color: '#1e3a5f', justifyContent: 'flex-start' }}
|
||||||
|
>
|
||||||
|
Neue Shortlist
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { Chip } from '@mui/material'
|
||||||
|
import type { ShortlistStatus } from '../../domain/enums'
|
||||||
|
|
||||||
|
const STATUS_META: Record<string, { label: string; color: string }> = {
|
||||||
|
DRAFT: { label: 'Entwurf', color: '#64748b' },
|
||||||
|
REVIEW_READY: { label: 'Prüfbereit', color: '#d97706' },
|
||||||
|
FINALIZED: { label: 'Finalisiert', color: '#1a7a4a' },
|
||||||
|
ARCHIVED: { label: 'Archiviert', color: '#475569' },
|
||||||
|
ACTIVE: { label: 'Aktiv', color: '#1e3a5f' },
|
||||||
|
SHARED: { label: 'Geteilt', color: '#7c3aed' },
|
||||||
|
CONVERTED: { label: 'Konvertiert', color: '#0891b2' },
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ShortlistStatusBadge({ status }: { status: ShortlistStatus }) {
|
||||||
|
const meta = STATUS_META[status] ?? { label: status, color: '#64748b' }
|
||||||
|
return (
|
||||||
|
<Chip
|
||||||
|
label={meta.label}
|
||||||
|
size="small"
|
||||||
|
sx={{ bgcolor: meta.color, color: 'white', fontWeight: 600, fontSize: 11 }}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
export { ShortlistStatusBadge } from './ShortlistStatusBadge'
|
||||||
|
export { ShortlistEmptyState } from './ShortlistEmptyState'
|
||||||
|
export { ShortlistItemCard } from './ShortlistItemCard'
|
||||||
|
export { ShortlistCard } from './ShortlistCard'
|
||||||
|
export { ShortlistList } from './ShortlistList'
|
||||||
|
export { ShortlistDetail } from './ShortlistDetail'
|
||||||
|
export { DecisionBriefDraftPanel } from './DecisionBriefDraftPanel'
|
||||||
|
export { AddToShortlistDialog } from './AddToShortlistDialog'
|
||||||
@@ -131,6 +131,9 @@ export type SensitivityLevel = typeof SensitivityLevel[keyof typeof SensitivityL
|
|||||||
|
|
||||||
// ── Shortlist Status ──────────────────────────────────────────────────────────
|
// ── Shortlist Status ──────────────────────────────────────────────────────────
|
||||||
export const ShortlistStatus = {
|
export const ShortlistStatus = {
|
||||||
|
DRAFT: 'DRAFT',
|
||||||
|
REVIEW_READY: 'REVIEW_READY',
|
||||||
|
FINALIZED: 'FINALIZED',
|
||||||
ACTIVE: 'ACTIVE',
|
ACTIVE: 'ACTIVE',
|
||||||
SHARED: 'SHARED',
|
SHARED: 'SHARED',
|
||||||
ARCHIVED: 'ARCHIVED',
|
ARCHIVED: 'ARCHIVED',
|
||||||
|
|||||||
+24
-1
@@ -1,9 +1,30 @@
|
|||||||
import type { ShortlistStatus } from './enums'
|
import type { ShortlistStatus } from './enums'
|
||||||
|
|
||||||
export interface ShortlistItem {
|
export interface ShortlistItem {
|
||||||
propertyId: string
|
resultId: string
|
||||||
|
resultType: string
|
||||||
|
title: string
|
||||||
|
matchScore: number
|
||||||
|
confidenceScore?: number
|
||||||
|
dataQualityScore?: number
|
||||||
|
sourceLabel?: string
|
||||||
addedAt: string
|
addedAt: string
|
||||||
|
addedBy: string
|
||||||
note?: string
|
note?: string
|
||||||
|
propertyId?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ShortlistItemInput {
|
||||||
|
resultId: string
|
||||||
|
resultType: string
|
||||||
|
title: string
|
||||||
|
matchScore: number
|
||||||
|
confidenceScore?: number
|
||||||
|
dataQualityScore?: number
|
||||||
|
sourceLabel?: string
|
||||||
|
addedBy: string
|
||||||
|
note?: string
|
||||||
|
propertyId?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Shortlist {
|
export interface Shortlist {
|
||||||
@@ -11,6 +32,8 @@ export interface Shortlist {
|
|||||||
title: string
|
title: string
|
||||||
description?: string
|
description?: string
|
||||||
needId?: string
|
needId?: string
|
||||||
|
ownerUserId?: string
|
||||||
|
decisionBriefId?: string
|
||||||
items: ShortlistItem[]
|
items: ShortlistItem[]
|
||||||
status: ShortlistStatus
|
status: ShortlistStatus
|
||||||
createdBy: string
|
createdBy: string
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { shortlistService } from '../services/shortlistService'
|
||||||
|
import type { CreateShortlistInput, UpdateShortlistInput, ShortlistItemInput } from '../domain/shortlist'
|
||||||
|
|
||||||
|
export function useShortlists() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['shortlists'],
|
||||||
|
queryFn: () => shortlistService.getAll(),
|
||||||
|
select: (res) => res.data ?? [],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useShortlist(id: string) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['shortlist', id],
|
||||||
|
queryFn: () => shortlistService.getById(id),
|
||||||
|
enabled: !!id,
|
||||||
|
select: (res) => res.data ?? null,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreateShortlist() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (input: CreateShortlistInput) => shortlistService.create(input),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['shortlists'] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAddToShortlist() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ shortlistId, item }: { shortlistId: string; item: ShortlistItemInput }) =>
|
||||||
|
shortlistService.addItem(shortlistId, item),
|
||||||
|
onSuccess: (_data, { shortlistId }) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['shortlists'] })
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['shortlist', shortlistId] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRemoveFromShortlist() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ shortlistId, resultId }: { shortlistId: string; resultId: string }) =>
|
||||||
|
shortlistService.removeItem(shortlistId, resultId),
|
||||||
|
onSuccess: (_data, { shortlistId }) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['shortlists'] })
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['shortlist', shortlistId] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdateShortlist() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ id, data }: { id: string; data: UpdateShortlistInput }) =>
|
||||||
|
shortlistService.update(id, data),
|
||||||
|
onSuccess: (_data, { id }) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['shortlists'] })
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['shortlist', id] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -8,8 +8,32 @@ export const mockShortlists: Shortlist[] = [
|
|||||||
description: 'Beste Bürooptionen für Innovatech AG',
|
description: 'Beste Bürooptionen für Innovatech AG',
|
||||||
needId: 'need-001',
|
needId: 'need-001',
|
||||||
items: [
|
items: [
|
||||||
{ propertyId: 'prop-001', addedAt: '2025-03-01T10:00:00Z', note: 'Erste Wahl' },
|
{
|
||||||
{ propertyId: 'prop-004', addedAt: '2025-03-02T14:30:00Z', note: 'Interessante Alternative' },
|
resultId: 'match-001',
|
||||||
|
resultType: 'VERIFIED_PORTFOLIO',
|
||||||
|
title: 'Bürofläche Zollstrasse 12',
|
||||||
|
matchScore: 88,
|
||||||
|
confidenceScore: 0.92,
|
||||||
|
dataQualityScore: 0.85,
|
||||||
|
sourceLabel: 'Portfolio',
|
||||||
|
addedAt: '2025-03-01T10:00:00Z',
|
||||||
|
addedBy: 'admin@ideal-sharing.ch',
|
||||||
|
note: 'Erste Wahl',
|
||||||
|
propertyId: 'prop-001',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
resultId: 'match-004',
|
||||||
|
resultType: 'VERIFIED_PORTFOLIO',
|
||||||
|
title: 'Gemischte Gewerbeeinheit Europaallee',
|
||||||
|
matchScore: 74,
|
||||||
|
confidenceScore: 0.78,
|
||||||
|
dataQualityScore: 0.80,
|
||||||
|
sourceLabel: 'Portfolio',
|
||||||
|
addedAt: '2025-03-02T14:30:00Z',
|
||||||
|
addedBy: 'admin@ideal-sharing.ch',
|
||||||
|
note: 'Interessante Alternative',
|
||||||
|
propertyId: 'prop-004',
|
||||||
|
},
|
||||||
],
|
],
|
||||||
status: ShortlistStatus.ACTIVE,
|
status: ShortlistStatus.ACTIVE,
|
||||||
createdBy: 'user-001',
|
createdBy: 'user-001',
|
||||||
@@ -23,9 +47,21 @@ export const mockShortlists: Shortlist[] = [
|
|||||||
description: 'Auswahl für Schweizer Logistik GmbH',
|
description: 'Auswahl für Schweizer Logistik GmbH',
|
||||||
needId: 'need-002',
|
needId: 'need-002',
|
||||||
items: [
|
items: [
|
||||||
{ propertyId: 'prop-002', addedAt: '2025-03-05T09:00:00Z', note: 'Perfekte Grösse' },
|
{
|
||||||
|
resultId: 'match-002',
|
||||||
|
resultType: 'VERIFIED_PORTFOLIO',
|
||||||
|
title: 'Lagerfläche Hardstrasse 44',
|
||||||
|
matchScore: 82,
|
||||||
|
confidenceScore: 0.88,
|
||||||
|
dataQualityScore: 0.90,
|
||||||
|
sourceLabel: 'Portfolio',
|
||||||
|
addedAt: '2025-03-05T09:00:00Z',
|
||||||
|
addedBy: 'admin@ideal-sharing.ch',
|
||||||
|
note: 'Perfekte Grösse',
|
||||||
|
propertyId: 'prop-002',
|
||||||
|
},
|
||||||
],
|
],
|
||||||
status: ShortlistStatus.SHARED,
|
status: ShortlistStatus.REVIEW_READY,
|
||||||
createdBy: 'user-001',
|
createdBy: 'user-001',
|
||||||
organizationId: 'org-wincasa',
|
organizationId: 'org-wincasa',
|
||||||
sharedWith: ['client@schweizer-logistik.ch'],
|
sharedWith: ['client@schweizer-logistik.ch'],
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import { propertyService } from '../../services/propertyService'
|
|||||||
import { needService } from '../../services/needService'
|
import { needService } from '../../services/needService'
|
||||||
import { futureSignalService } from '../../services/futureSignalService'
|
import { futureSignalService } from '../../services/futureSignalService'
|
||||||
import { useCompareStore } from '../../stores/compareStore'
|
import { useCompareStore } from '../../stores/compareStore'
|
||||||
|
import { useShortlistStore } from '../../stores/shortlistStore'
|
||||||
|
import { AddToShortlistDialog } from '../../components/shortlist'
|
||||||
import { MatchReasonList } from '../../components/match-card/MatchReasonList'
|
import { MatchReasonList } from '../../components/match-card/MatchReasonList'
|
||||||
import {
|
import {
|
||||||
MatchDetailHeader,
|
MatchDetailHeader,
|
||||||
@@ -37,6 +39,7 @@ export default function MatchDetail() {
|
|||||||
const { matchId } = useParams<{ matchId: string }>()
|
const { matchId } = useParams<{ matchId: string }>()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const { addToCompare } = useCompareStore()
|
const { addToCompare } = useCompareStore()
|
||||||
|
const { openAddDialog } = useShortlistStore()
|
||||||
|
|
||||||
const { data: match, isLoading } = useMatchDetail(matchId ?? '')
|
const { data: match, isLoading } = useMatchDetail(matchId ?? '')
|
||||||
|
|
||||||
@@ -112,15 +115,30 @@ export default function MatchDetail() {
|
|||||||
|
|
||||||
const handleBack = () => navigate(-1)
|
const handleBack = () => navigate(-1)
|
||||||
|
|
||||||
|
const handleShortlist = () => {
|
||||||
|
const title = property?.title ?? signal?.companyName ?? signal?.locationHint ?? match.id
|
||||||
|
openAddDialog({
|
||||||
|
resultId: match.id,
|
||||||
|
resultType: match.resultType ?? 'VERIFIED_PORTFOLIO',
|
||||||
|
title,
|
||||||
|
matchScore: match.matchScore,
|
||||||
|
confidenceScore: match.confidenceLevel,
|
||||||
|
sourceLabel: property?.sourceLabel ?? match.resultType ?? 'VERIFIED_PORTFOLIO',
|
||||||
|
addedBy: 'admin@ideal-sharing.ch',
|
||||||
|
propertyId: property?.id,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ px: 3, py: 2 }}>
|
<Box sx={{ px: 3, py: 2 }}>
|
||||||
|
<AddToShortlistDialog />
|
||||||
<MatchDetailHeader
|
<MatchDetailHeader
|
||||||
match={match}
|
match={match}
|
||||||
property={property}
|
property={property}
|
||||||
signal={signal}
|
signal={signal}
|
||||||
onBack={handleBack}
|
onBack={handleBack}
|
||||||
onCompare={handleCompare}
|
onCompare={handleCompare}
|
||||||
onShortlist={() => {}}
|
onShortlist={handleShortlist}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Box sx={{ display: 'flex', gap: 3, alignItems: 'flex-start' }}>
|
<Box sx={{ display: 'flex', gap: 3, alignItems: 'flex-start' }}>
|
||||||
@@ -165,7 +183,7 @@ export default function MatchDetail() {
|
|||||||
<NextActionsPanel
|
<NextActionsPanel
|
||||||
match={match}
|
match={match}
|
||||||
onCompare={handleCompare}
|
onCompare={handleCompare}
|
||||||
onShortlist={() => {}}
|
onShortlist={handleShortlist}
|
||||||
onReview={() => {}}
|
onReview={() => {}}
|
||||||
onReject={() => {}}
|
onReject={() => {}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { Box, Button, Card, Typography } from '@mui/material'
|
import { Box, Button, Card, Typography } from '@mui/material'
|
||||||
import { useNavigate } from 'react-router'
|
import { useNavigate, useLocation } from 'react-router'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { useUnifiedResults } from '../../hooks/useUnifiedResults'
|
import { useUnifiedResults } from '../../hooks/useUnifiedResults'
|
||||||
import { needService } from '../../services/needService'
|
import { needService } from '../../services/needService'
|
||||||
import {
|
import {
|
||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
ResultFilterBar,
|
ResultFilterBar,
|
||||||
UnifiedResultFeed,
|
UnifiedResultFeed,
|
||||||
} from '../../components/results'
|
} from '../../components/results'
|
||||||
|
import { AddToShortlistDialog } from '../../components/shortlist'
|
||||||
import type { ResultType } from '../../domain/enums'
|
import type { ResultType } from '../../domain/enums'
|
||||||
import type { UnifiedMatchResult } from '../../domain/unifiedResult'
|
import type { UnifiedMatchResult } from '../../domain/unifiedResult'
|
||||||
|
|
||||||
@@ -30,16 +31,37 @@ function sortResults(results: UnifiedMatchResult[], sortBy: SortBy): UnifiedMatc
|
|||||||
|
|
||||||
export default function Results() {
|
export default function Results() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
const location = useLocation()
|
||||||
|
const queryClient = useQueryClient()
|
||||||
const [filterSource, setFilterSource] = useState<FilterSource>('ALL')
|
const [filterSource, setFilterSource] = useState<FilterSource>('ALL')
|
||||||
const [sortBy, setSortBy] = useState<SortBy>('score')
|
const [sortBy, setSortBy] = useState<SortBy>('score')
|
||||||
|
|
||||||
const { data: results = [], isLoading } = useUnifiedResults()
|
// When coming from NeedBuilder, invalidate so the freshly created need is included
|
||||||
|
const activeNeedIdFromNav = (location.state as { activeNeedId?: string } | null)?.activeNeedId
|
||||||
|
|
||||||
const { data: needResp } = useQuery({
|
const { data: needResp } = useQuery({
|
||||||
queryKey: ['needs'],
|
queryKey: ['needs'],
|
||||||
queryFn: () => needService.getAll(),
|
queryFn: () => needService.getAll(),
|
||||||
|
// Refetch on mount when navigating from NeedBuilder to pick up the new need
|
||||||
|
refetchOnMount: activeNeedIdFromNav ? 'always' : true,
|
||||||
|
gcTime: 0,
|
||||||
})
|
})
|
||||||
|
|
||||||
const activeNeed = needResp?.data?.[0]
|
// Prefer the ID passed from NeedBuilder; fall back to most-recently-created
|
||||||
|
const activeNeed = needResp?.data
|
||||||
|
? activeNeedIdFromNav
|
||||||
|
? (needResp.data.find(n => n.id === activeNeedIdFromNav) ?? needResp.data[0])
|
||||||
|
: [...needResp.data].sort((a, b) =>
|
||||||
|
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
|
||||||
|
)[0]
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
// Ensure query cache is invalidated when a new need was just created
|
||||||
|
if (activeNeedIdFromNav) {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['needs'] })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data: results = [], isLoading } = useUnifiedResults(activeNeed?.id)
|
||||||
|
|
||||||
const filtered =
|
const filtered =
|
||||||
filterSource === 'ALL' ? results : results.filter(r => r.resultType === filterSource)
|
filterSource === 'ALL' ? results : results.filter(r => r.resultType === filterSource)
|
||||||
@@ -52,6 +74,7 @@ export default function Results() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Box>
|
<Box>
|
||||||
|
<AddToShortlistDialog />
|
||||||
<ResultFeedHeader
|
<ResultFeedHeader
|
||||||
total={sorted.length}
|
total={sorted.length}
|
||||||
verifiedCount={verifiedCount}
|
verifiedCount={verifiedCount}
|
||||||
@@ -62,21 +85,43 @@ export default function Results() {
|
|||||||
<Box sx={{ px: 3, py: 3 }}>
|
<Box sx={{ px: 3, py: 3 }}>
|
||||||
{activeNeed && (
|
{activeNeed && (
|
||||||
<Card sx={{ bgcolor: '#eff6ff', p: 2, mb: 2, border: '1px solid #bfdbfe' }}>
|
<Card sx={{ bgcolor: '#eff6ff', p: 2, mb: 2, border: '1px solid #bfdbfe' }}>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 2 }}>
|
||||||
<Box>
|
<Box>
|
||||||
<Typography variant="subtitle2" sx={{ fontWeight: 600 }} color="#1e3a5f">
|
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 0.5 }} color="#1e3a5f">
|
||||||
Aktive Suche: {activeNeed.companyName}
|
Aktive Suche: {activeNeed.companyName}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="caption" color="text.secondary">
|
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: '4px 16px' }}>
|
||||||
{activeNeed.assetType} · {activeNeed.requiredArea.min}–{activeNeed.requiredArea.max} m² ·{' '}
|
<Typography variant="caption" color="text.secondary">
|
||||||
{activeNeed.preferredLocations.join(', ')}
|
<strong>Typ:</strong> {activeNeed.assetType}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
<strong>Fläche:</strong> {activeNeed.requiredArea.min}–{activeNeed.requiredArea.max} m²
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
<strong>Standort:</strong> {activeNeed.preferredLocations.join(', ')}
|
||||||
|
</Typography>
|
||||||
|
{activeNeed.budgetRange && (
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
<strong>Budget:</strong> max. CHF {activeNeed.budgetRange.maxPerSqm}/m²
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
{activeNeed.timing && (
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
<strong>Bezug ab:</strong> {new Date(activeNeed.timing.earliestMoveIn).toLocaleDateString('de-CH', { month: 'long', year: 'numeric' })}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
{activeNeed.mustCriteriaText && activeNeed.mustCriteriaText.length > 0 && (
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
<strong>Must-haves:</strong> {activeNeed.mustCriteriaText.slice(0, 3).join(' · ')}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
<Button
|
<Button
|
||||||
size="small"
|
size="small"
|
||||||
variant="text"
|
variant="text"
|
||||||
onClick={() => navigate('/demand/ai-search')}
|
onClick={() => navigate('/demand/ai-search')}
|
||||||
sx={{ color: '#1e3a5f' }}
|
sx={{ color: '#1e3a5f', flexShrink: 0 }}
|
||||||
>
|
>
|
||||||
Suche ändern
|
Suche ändern
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
+66
-160
@@ -1,173 +1,79 @@
|
|||||||
|
import { Box, Paper, Typography } from '@mui/material'
|
||||||
|
import { useShortlists } from '../../hooks/useShortlists'
|
||||||
|
import { useShortlistStore } from '../../stores/shortlistStore'
|
||||||
import {
|
import {
|
||||||
Box,
|
ShortlistList,
|
||||||
Button,
|
ShortlistDetail,
|
||||||
Card,
|
DecisionBriefDraftPanel,
|
||||||
Chip,
|
AddToShortlistDialog,
|
||||||
Typography,
|
} from '../../components/shortlist'
|
||||||
Stack,
|
|
||||||
Divider,
|
|
||||||
List,
|
|
||||||
ListItem,
|
|
||||||
ListItemText,
|
|
||||||
} from '@mui/material'
|
|
||||||
import { Bookmark } from 'lucide-react'
|
|
||||||
|
|
||||||
interface MockShortlist {
|
const PANEL_HEADER_SX = {
|
||||||
id: string
|
px: 2,
|
||||||
title: string
|
py: 1.5,
|
||||||
company: string
|
borderBottom: '1px solid #e2e8f0',
|
||||||
assetType: string
|
bgcolor: 'white',
|
||||||
objectCount: number
|
position: 'sticky' as const,
|
||||||
createdAt: string
|
top: 0,
|
||||||
updatedAt: string
|
zIndex: 1,
|
||||||
properties: string[]
|
flexShrink: 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
const MOCK_SHORTLISTS: MockShortlist[] = [
|
|
||||||
{
|
|
||||||
id: 'sl-001',
|
|
||||||
title: 'Bürosuche Innovatech AG',
|
|
||||||
company: 'Innovatech AG',
|
|
||||||
assetType: 'Büro',
|
|
||||||
objectCount: 2,
|
|
||||||
createdAt: '01.05.2025',
|
|
||||||
updatedAt: '08.05.2025',
|
|
||||||
properties: ['Bürofläche Zollstrasse 12', 'Gemischte Gewerbeeinheit Europaallee'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'sl-002',
|
|
||||||
title: 'Logistik Basel — Schweizer Logistik',
|
|
||||||
company: 'Schweizer Logistik GmbH',
|
|
||||||
assetType: 'Logistik',
|
|
||||||
objectCount: 1,
|
|
||||||
createdAt: '03.05.2025',
|
|
||||||
updatedAt: '03.05.2025',
|
|
||||||
properties: ['Lagerfläche Hardstrasse 44'],
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
export default function Shortlists() {
|
export default function Shortlists() {
|
||||||
|
const { data: shortlists = [], isLoading } = useShortlists()
|
||||||
|
const { selectedShortlistId } = useShortlistStore()
|
||||||
|
|
||||||
|
const selectedShortlist = shortlists.find(s => s.id === selectedShortlistId) ?? null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box>
|
<Box sx={{ display: 'flex', height: 'calc(100vh - 64px)', overflow: 'hidden' }}>
|
||||||
{/* Page Header */}
|
|
||||||
<Box
|
{/* Left: shortlist list */}
|
||||||
sx={{
|
<Paper elevation={0} sx={{
|
||||||
bgcolor: 'white',
|
width: 260,
|
||||||
borderBottom: '1px solid #e2e8f0',
|
flexShrink: 0,
|
||||||
px: 3,
|
display: 'flex',
|
||||||
py: 2,
|
flexDirection: 'column',
|
||||||
display: 'flex',
|
borderRadius: 0,
|
||||||
alignItems: 'center',
|
borderRight: '1px solid #e2e8f0',
|
||||||
justifyContent: 'space-between',
|
overflow: 'hidden',
|
||||||
}}
|
}}>
|
||||||
>
|
<Box sx={PANEL_HEADER_SX}>
|
||||||
<Box>
|
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>Shortlists</Typography>
|
||||||
<Typography variant="h5" sx={{ fontWeight: 700 }} color="text.primary">
|
<Typography variant="caption" color="text.secondary">{shortlists.length} gespeichert</Typography>
|
||||||
Shortlists
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="body2" color="text.secondary">
|
|
||||||
Gespeicherte Objektlisten und Entscheidungsvorlagen
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
</Box>
|
||||||
<Button
|
<ShortlistList shortlists={shortlists} isLoading={isLoading} />
|
||||||
variant="contained"
|
</Paper>
|
||||||
size="small"
|
|
||||||
startIcon={<Bookmark size={14} />}
|
|
||||||
disabled
|
|
||||||
sx={{ bgcolor: '#1e3a5f' }}
|
|
||||||
>
|
|
||||||
Neue Shortlist
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Box sx={{ px: 3, py: 3 }}>
|
{/* Center: detail */}
|
||||||
<Stack spacing={2}>
|
<Box sx={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden', bgcolor: '#f8fafc' }}>
|
||||||
{MOCK_SHORTLISTS.map(sl => (
|
{selectedShortlist ? (
|
||||||
<Card key={sl.id} sx={{ p: 2.5 }}>
|
<ShortlistDetail shortlist={selectedShortlist} />
|
||||||
{/* Card header */}
|
) : (
|
||||||
<Box
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', flex: 1 }}>
|
||||||
sx={{
|
<Typography variant="body2" color="text.secondary">
|
||||||
display: 'flex',
|
Shortlist aus der Liste wählen
|
||||||
alignItems: 'flex-start',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
mb: 1.5,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Box>
|
|
||||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
|
||||||
{sl.title}
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="body2" color="text.secondary">
|
|
||||||
{sl.company} · {sl.assetType}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
<Chip
|
|
||||||
label={`${sl.objectCount} Objekte`}
|
|
||||||
size="small"
|
|
||||||
sx={{ bgcolor: '#eff6ff', color: '#1e3a5f', fontWeight: 600 }}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Dates */}
|
|
||||||
<Stack direction="row" spacing={2} sx={{ mb: 1.5 }}>
|
|
||||||
<Typography variant="caption" color="text.secondary">
|
|
||||||
Erstellt: {sl.createdAt}
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="caption" color="text.secondary">
|
|
||||||
Zuletzt aktualisiert: {sl.updatedAt}
|
|
||||||
</Typography>
|
|
||||||
</Stack>
|
|
||||||
|
|
||||||
<Divider sx={{ mb: 1.5 }} />
|
|
||||||
|
|
||||||
{/* Property list */}
|
|
||||||
<List dense disablePadding sx={{ mb: 1.5 }}>
|
|
||||||
{sl.properties.map((prop, i) => (
|
|
||||||
<ListItem key={i} disableGutters sx={{ py: 0.25 }}>
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
width: 6,
|
|
||||||
height: 6,
|
|
||||||
borderRadius: '50%',
|
|
||||||
bgcolor: '#1e3a5f',
|
|
||||||
mr: 1.5,
|
|
||||||
flexShrink: 0,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<ListItemText
|
|
||||||
primary={<Typography variant="body2">{prop}</Typography>}
|
|
||||||
/>
|
|
||||||
</ListItem>
|
|
||||||
))}
|
|
||||||
</List>
|
|
||||||
|
|
||||||
{/* Actions */}
|
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 1 }}>
|
|
||||||
<Button variant="outlined" size="small" disabled>
|
|
||||||
Teilen
|
|
||||||
</Button>
|
|
||||||
<Button variant="outlined" size="small" disabled>
|
|
||||||
Öffnen
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{/* Empty shortlist prompt */}
|
|
||||||
<Card
|
|
||||||
sx={{
|
|
||||||
p: 2,
|
|
||||||
border: '2px dashed #e2e8f0',
|
|
||||||
boxShadow: 'none',
|
|
||||||
bgcolor: '#fafafa',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Typography variant="body2" color="text.secondary" sx={{ textAlign: 'center' }}>
|
|
||||||
Objekte aus den Suchergebnissen zur Shortlist hinzufügen
|
|
||||||
</Typography>
|
</Typography>
|
||||||
</Card>
|
</Box>
|
||||||
</Stack>
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
{/* Right: decision brief — only when a shortlist is selected */}
|
||||||
|
{selectedShortlist && (
|
||||||
|
<Paper elevation={0} sx={{
|
||||||
|
width: 280,
|
||||||
|
flexShrink: 0,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
borderRadius: 0,
|
||||||
|
borderLeft: '1px solid #e2e8f0',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}>
|
||||||
|
<DecisionBriefDraftPanel shortlistId={selectedShortlist.id} />
|
||||||
|
</Paper>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<AddToShortlistDialog />
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { Shortlist, CreateShortlistInput, UpdateShortlistInput } from '../domain/shortlist'
|
import type { Shortlist, CreateShortlistInput, UpdateShortlistInput, ShortlistItemInput } from '../domain/shortlist'
|
||||||
import type { ShortlistStatus } from '../domain/enums'
|
import type { ShortlistStatus } from '../domain/enums'
|
||||||
|
|
||||||
export interface ShortlistFilters {
|
export interface ShortlistFilters {
|
||||||
@@ -13,7 +13,7 @@ export interface IShortlistProvider {
|
|||||||
getById(id: string): Promise<Shortlist | null>
|
getById(id: string): Promise<Shortlist | null>
|
||||||
create(data: CreateShortlistInput): Promise<Shortlist>
|
create(data: CreateShortlistInput): Promise<Shortlist>
|
||||||
update(id: string, data: UpdateShortlistInput): Promise<Shortlist>
|
update(id: string, data: UpdateShortlistInput): Promise<Shortlist>
|
||||||
addItem(id: string, propertyId: string, note?: string): Promise<Shortlist>
|
addItem(id: string, item: ShortlistItemInput): Promise<Shortlist>
|
||||||
removeItem(id: string, propertyId: string): Promise<Shortlist>
|
removeItem(id: string, resultId: string): Promise<Shortlist>
|
||||||
remove(id: string): Promise<void>
|
remove(id: string): Promise<void>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { IShortlistProvider, ShortlistFilters } from './IShortlistProvider'
|
import type { IShortlistProvider, ShortlistFilters } from './IShortlistProvider'
|
||||||
import type { Shortlist, CreateShortlistInput, UpdateShortlistInput } from '../domain/shortlist'
|
import type { Shortlist, CreateShortlistInput, UpdateShortlistInput, ShortlistItemInput } from '../domain/shortlist'
|
||||||
import { mockShortlists } from '../mock-data/shortlists'
|
import { mockShortlists } from '../mock-data/shortlists'
|
||||||
import { mockDelay } from '../lib/mockUtils'
|
import { mockDelay } from '../lib/mockUtils'
|
||||||
|
|
||||||
@@ -36,25 +36,25 @@ export const MockupShortlistProvider: IShortlistProvider = {
|
|||||||
store[idx] = { ...store[idx], ...data, updatedAt: new Date().toISOString() }
|
store[idx] = { ...store[idx], ...data, updatedAt: new Date().toISOString() }
|
||||||
return store[idx]
|
return store[idx]
|
||||||
},
|
},
|
||||||
async addItem(id, propertyId, note?) {
|
async addItem(id, item: ShortlistItemInput) {
|
||||||
await mockDelay()
|
await mockDelay()
|
||||||
const idx = store.findIndex(s => s.id === id)
|
const idx = store.findIndex(s => s.id === id)
|
||||||
const alreadyAdded = store[idx].items.some(i => i.propertyId === propertyId)
|
const alreadyAdded = store[idx].items.some(i => i.resultId === item.resultId)
|
||||||
if (!alreadyAdded) {
|
if (!alreadyAdded) {
|
||||||
store[idx] = {
|
store[idx] = {
|
||||||
...store[idx],
|
...store[idx],
|
||||||
items: [...store[idx].items, { propertyId, addedAt: new Date().toISOString(), note }],
|
items: [...store[idx].items, { ...item, addedAt: new Date().toISOString() }],
|
||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return store[idx]
|
return store[idx]
|
||||||
},
|
},
|
||||||
async removeItem(id, propertyId) {
|
async removeItem(id, resultId) {
|
||||||
await mockDelay()
|
await mockDelay()
|
||||||
const idx = store.findIndex(s => s.id === id)
|
const idx = store.findIndex(s => s.id === id)
|
||||||
store[idx] = {
|
store[idx] = {
|
||||||
...store[idx],
|
...store[idx],
|
||||||
items: store[idx].items.filter(i => i.propertyId !== propertyId),
|
items: store[idx].items.filter(i => i.resultId !== resultId),
|
||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
}
|
}
|
||||||
return store[idx]
|
return store[idx]
|
||||||
|
|||||||
@@ -5,6 +5,45 @@ import type { AssetType } from '../domain/enums'
|
|||||||
import type { ParseNeedResult, ParsedNeedCriteria, FollowUpQuestion } from '../domain/needBuilder'
|
import type { ParseNeedResult, ParsedNeedCriteria, FollowUpQuestion } from '../domain/needBuilder'
|
||||||
import type { UnifiedMatchResult } from '../domain/unifiedResult'
|
import type { UnifiedMatchResult } from '../domain/unifiedResult'
|
||||||
|
|
||||||
|
// ── Decision Brief ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface DecisionBrief {
|
||||||
|
id: string
|
||||||
|
shortlistId: string
|
||||||
|
summary: string
|
||||||
|
sections: { title: string; body: string }[]
|
||||||
|
generatedAt: string
|
||||||
|
isDraft: true
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildMockDecisionBrief(shortlistId: string): DecisionBrief {
|
||||||
|
return {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
shortlistId,
|
||||||
|
summary: 'Die Shortlist enthält qualitativ hochwertige Matches mit starker Standortübereinstimmung. Die verfügbaren Flächen decken den Bedarf gut ab. Zwei Objekte eignen sich als Erstbesichtigungen.',
|
||||||
|
sections: [
|
||||||
|
{
|
||||||
|
title: 'Zusammenfassung der Objekte',
|
||||||
|
body: 'Die Shortlist umfasst mehrere Objekte aus dem verifizierten Portfolio. Die Matchscores liegen zwischen 74 und 88, was auf eine gute bis sehr gute Übereinstimmung mit den Suchkriterien hinweist.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Standortbewertung',
|
||||||
|
body: 'Die Mehrheit der Objekte befindet sich in bevorzugten Lagen. Die ÖV-Anbindung ist bei allen Objekten als gut bis sehr gut einzustufen.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Budgetanalyse',
|
||||||
|
body: 'Die Mietpreise liegen im budgetkonformen Bereich. Keine der Optionen überschreitet das maximale Budget pro m².',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Empfohlene nächste Schritte',
|
||||||
|
body: '1. Besichtigung der Top-2-Objekte vereinbaren. 2. Detaillierte Flächenpläne anfordern. 3. Vertragskonditionen prüfen lassen.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
generatedAt: new Date().toISOString(),
|
||||||
|
isDraft: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Compare Summary ───────────────────────────────────────────────────────────
|
// ── Compare Summary ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export interface ComparisonSummary {
|
export interface ComparisonSummary {
|
||||||
@@ -284,7 +323,7 @@ function mockParseNeed(input: string): ParseNeedResult {
|
|||||||
timing,
|
timing,
|
||||||
mustHaveCriteria,
|
mustHaveCriteria,
|
||||||
infrastructureRequirements: [],
|
infrastructureRequirements: [],
|
||||||
accessibilityRequirements: mustHaveCriteria.includes('Gute ÖV-Anbindung') ? ['ÖV-Anbindung'] : [],
|
accessibilityRequirements: [],
|
||||||
prestigeImportance,
|
prestigeImportance,
|
||||||
flexibilityNeed: lower.includes('flexibel') ? 'HIGH' : 'MEDIUM',
|
flexibilityNeed: lower.includes('flexibel') ? 'HIGH' : 'MEDIUM',
|
||||||
expansionPotential: lower.includes('wachstum') || lower.includes('expansion'),
|
expansionPotential: lower.includes('wachstum') || lower.includes('expansion'),
|
||||||
@@ -377,4 +416,9 @@ export const aiService = {
|
|||||||
const result = mockParseNeed(JSON.stringify(criteria))
|
const result = mockParseNeed(JSON.stringify(criteria))
|
||||||
return { data: result.followUpQuestionCandidates }
|
return { data: result.followUpQuestionCandidates }
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async generateDecisionBrief(shortlistId: string): Promise<ItemResponse<DecisionBrief>> {
|
||||||
|
await new Promise(r => setTimeout(r, 1800))
|
||||||
|
return { data: buildMockDecisionBrief(shortlistId) }
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { MockupShortlistProvider } from '../provider/MockupShortlistProvider'
|
import { MockupShortlistProvider } from '../provider/MockupShortlistProvider'
|
||||||
import type { ShortlistFilters } from '../provider/IShortlistProvider'
|
import type { ShortlistFilters } from '../provider/IShortlistProvider'
|
||||||
import type { Shortlist, CreateShortlistInput, UpdateShortlistInput } from '../domain/shortlist'
|
import type { Shortlist, CreateShortlistInput, UpdateShortlistInput, ShortlistItemInput } from '../domain/shortlist'
|
||||||
import type { ListResponse, ItemResponse } from './types'
|
import type { ListResponse, ItemResponse } from './types'
|
||||||
|
|
||||||
const provider = MockupShortlistProvider
|
const provider = MockupShortlistProvider
|
||||||
@@ -22,12 +22,12 @@ export const shortlistService = {
|
|||||||
const data = await provider.update(id, input)
|
const data = await provider.update(id, input)
|
||||||
return { data }
|
return { data }
|
||||||
},
|
},
|
||||||
async addItem(id: string, propertyId: string, note?: string): Promise<ItemResponse<Shortlist>> {
|
async addItem(id: string, item: ShortlistItemInput): Promise<ItemResponse<Shortlist>> {
|
||||||
const data = await provider.addItem(id, propertyId, note)
|
const data = await provider.addItem(id, item)
|
||||||
return { data }
|
return { data }
|
||||||
},
|
},
|
||||||
async removeItem(id: string, propertyId: string): Promise<ItemResponse<Shortlist>> {
|
async removeItem(id: string, resultId: string): Promise<ItemResponse<Shortlist>> {
|
||||||
const data = await provider.removeItem(id, propertyId)
|
const data = await provider.removeItem(id, resultId)
|
||||||
return { data }
|
return { data }
|
||||||
},
|
},
|
||||||
async remove(id: string): Promise<ItemResponse<void>> {
|
async remove(id: string): Promise<ItemResponse<void>> {
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { create } from 'zustand'
|
||||||
|
import type { ShortlistItemInput } from '../domain/shortlist'
|
||||||
|
|
||||||
|
interface ShortlistStore {
|
||||||
|
selectedShortlistId: string | null
|
||||||
|
dialogOpen: boolean
|
||||||
|
pendingItem: ShortlistItemInput | null
|
||||||
|
setSelectedShortlist: (id: string | null) => void
|
||||||
|
openAddDialog: (item: ShortlistItemInput) => void
|
||||||
|
closeAddDialog: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useShortlistStore = create<ShortlistStore>((set) => ({
|
||||||
|
selectedShortlistId: null,
|
||||||
|
dialogOpen: false,
|
||||||
|
pendingItem: null,
|
||||||
|
setSelectedShortlist: (id) => set({ selectedShortlistId: id }),
|
||||||
|
openAddDialog: (item) => set({ dialogOpen: true, pendingItem: item }),
|
||||||
|
closeAddDialog: () => set({ dialogOpen: false, pendingItem: null }),
|
||||||
|
}))
|
||||||
Reference in New Issue
Block a user