diff --git a/src/components/results/UnifiedResultCard.tsx b/src/components/results/UnifiedResultCard.tsx index 25e4c41..537e982 100644 --- a/src/components/results/UnifiedResultCard.tsx +++ b/src/components/results/UnifiedResultCard.tsx @@ -2,6 +2,7 @@ import { useNavigate } from 'react-router' import { MatchCardCompact } from '../match-card/MatchCardCompact' import { buildMatchCardViewModel } from '../../features/matching/matchCardAdapter' import { useCompareStore } from '../../stores/compareStore' +import { useShortlistStore } from '../../stores/shortlistStore' import type { UnifiedMatchResult } from '../../domain/unifiedResult' import type { MatchCardAction } from '../match-card/MatchCardViewModel' @@ -9,9 +10,17 @@ interface Props { 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) { const navigate = useNavigate() const { addToCompare, removeFromCompare, isInCompare, isFull } = useCompareStore() + const { openAddDialog } = useShortlistStore() const inCompare = isInCompare(result.matchId) const actions: MatchCardAction[] = [ @@ -20,8 +29,15 @@ export function UnifiedResultCard({ result }: Props) { label: 'Shortlist', actionType: 'SAVE_SHORTLIST', variant: 'secondary', - disabled: true, - onClick: () => {}, + onClick: () => openAddDialog({ + 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', diff --git a/src/components/shortlist/AddToShortlistDialog.tsx b/src/components/shortlist/AddToShortlistDialog.tsx new file mode 100644 index 0000000..75bf1bf --- /dev/null +++ b/src/components/shortlist/AddToShortlistDialog.tsx @@ -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('') + const [creatingNew, setCreatingNew] = useState(false) + const [newTitle, setNewTitle] = useState('') + const [note, setNote] = useState('') + const [snackbar, setSnackbar] = useState(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 ( + <> + + Zur Shortlist hinzufügen + + {pendingItem && ( + + {pendingItem.title} + + Score {pendingItem.matchScore} · {pendingItem.resultType} + + + )} + + {isLoading ? ( + + + + ) : ( + { + if (e.target.value === '__new__') { setCreatingNew(true); setSelectedId('') } + else { setCreatingNew(false); setSelectedId(e.target.value) } + }}> + {shortlists.map(sl => ( + } + label={ + + {sl.title} + + + } + /> + ))} + } + label={ + + + Neue Shortlist erstellen + + } + /> + + )} + + {creatingNew && ( + setNewTitle(e.target.value)} + sx={{ mt: 1.5 }} + /> + )} + + setNote(e.target.value)} + sx={{ mt: 2 }} + /> + + + + + + + + setSnackbar(null)} + message={snackbar} + anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }} + /> + + ) +} diff --git a/src/components/shortlist/DecisionBriefDraftPanel.tsx b/src/components/shortlist/DecisionBriefDraftPanel.tsx new file mode 100644 index 0000000..b76c4f0 --- /dev/null +++ b/src/components/shortlist/DecisionBriefDraftPanel.tsx @@ -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(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 ( + + + KI Decision Brief + + + + {loading ? ( + + + Brief wird generiert… + + ) : brief ? ( + + } sx={{ mb: 2, py: 0.5 }}> + KI-ENTWURF + + Automatisch generiert — bitte prüfen und anpassen. + + + + {brief.summary} + + {brief.sections.map((section, i) => ( + + } sx={{ minHeight: 40, '& .MuiAccordionSummary-content': { my: 0.5 } }}> + {section.title} + + + {section.body} + + + ))} + + + + ) : ( + + + + Decision Brief + + KI analysiert Ihre Shortlist und erstellt einen strukturierten Entscheidungsentwurf. + + + + + )} + + + ) +} diff --git a/src/components/shortlist/ShortlistCard.tsx b/src/components/shortlist/ShortlistCard.tsx new file mode 100644 index 0000000..577d12e --- /dev/null +++ b/src/components/shortlist/ShortlistCard.tsx @@ -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 ( + 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' }, + }} + > + + + {shortlist.title} + + + + + + {updatedDate} + + + ) +} diff --git a/src/components/shortlist/ShortlistDetail.tsx b/src/components/shortlist/ShortlistDetail.tsx new file mode 100644 index 0000000..2eeb0d4 --- /dev/null +++ b/src/components/shortlist/ShortlistDetail.tsx @@ -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 ( + + {/* Header */} + + + {editingTitle ? ( + 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 } }} + /> + ) : ( + + {shortlist.title} + {!isFinalized && ( + + )} + + )} + + + + + + {shortlist.items.length} Objekte + + + Erstellt von {shortlist.createdBy} + + {shortlist.organizationId && ( + + {shortlist.organizationId} + + )} + + Aktualisiert {updatedDate} + + + + {isFinalized && ( + } sx={{ mb: 1.5, py: 0.25 }}> + Diese Shortlist ist finalisiert — keine Änderungen möglich. + + )} + + + {isDraft && ( + + )} + {isReviewReady && ( + + )} + {shortlist.items.length > 0 && ( + + )} + {shortlist.description && ( + + )} + + + + {/* Items */} + + {shortlist.items.length === 0 + ? + : shortlist.items.map(item => ( + + )) + } + + + ) +} diff --git a/src/components/shortlist/ShortlistEmptyState.tsx b/src/components/shortlist/ShortlistEmptyState.tsx new file mode 100644 index 0000000..70a242b --- /dev/null +++ b/src/components/shortlist/ShortlistEmptyState.tsx @@ -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 = { + 'no-shortlists': { + icon: , + title: 'Noch keine Shortlists', + desc: 'Speichern Sie Suchergebnisse in einer Shortlist, um sie gezielt zu vergleichen und zu entscheiden.', + }, + 'empty-shortlist': { + icon: , + 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 ( + + {icon} + {title} + {desc} + + ) +} diff --git a/src/components/shortlist/ShortlistItemCard.tsx b/src/components/shortlist/ShortlistItemCard.tsx new file mode 100644 index 0000000..10deedd --- /dev/null +++ b/src/components/shortlist/ShortlistItemCard.tsx @@ -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 = { + 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 ( + + + + {item.title} + + + + + {item.sourceLabel ?? item.resultType} · {item.addedBy} · {addedDate} + + {item.note && ( + + {item.note} + + )} + + {!isFinalized && ( + removeItem.mutate({ shortlistId, resultId: item.resultId })} + sx={{ color: '#94a3b8', '&:hover': { color: '#c0392b' }, flexShrink: 0 }} + > + + + )} + + ) +} diff --git a/src/components/shortlist/ShortlistList.tsx b/src/components/shortlist/ShortlistList.tsx new file mode 100644 index 0000000..0251c7a --- /dev/null +++ b/src/components/shortlist/ShortlistList.tsx @@ -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 ( + + {[0, 1, 2].map(i => ( + + + + + ))} + + ) + } + + return ( + + + {shortlists.length === 0 && !creating + ? + : shortlists.map(sl => ) + } + + + + {creating ? ( + + 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 + ? + : ( + + ) + } + + ) : ( + + )} + + + ) +} diff --git a/src/components/shortlist/ShortlistStatusBadge.tsx b/src/components/shortlist/ShortlistStatusBadge.tsx new file mode 100644 index 0000000..e7025dc --- /dev/null +++ b/src/components/shortlist/ShortlistStatusBadge.tsx @@ -0,0 +1,23 @@ +import { Chip } from '@mui/material' +import type { ShortlistStatus } from '../../domain/enums' + +const STATUS_META: Record = { + 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 ( + + ) +} diff --git a/src/components/shortlist/index.ts b/src/components/shortlist/index.ts new file mode 100644 index 0000000..39b78f9 --- /dev/null +++ b/src/components/shortlist/index.ts @@ -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' diff --git a/src/domain/enums.ts b/src/domain/enums.ts index 6bb9349..67a7d64 100644 --- a/src/domain/enums.ts +++ b/src/domain/enums.ts @@ -131,6 +131,9 @@ export type SensitivityLevel = typeof SensitivityLevel[keyof typeof SensitivityL // ── Shortlist Status ────────────────────────────────────────────────────────── export const ShortlistStatus = { + DRAFT: 'DRAFT', + REVIEW_READY: 'REVIEW_READY', + FINALIZED: 'FINALIZED', ACTIVE: 'ACTIVE', SHARED: 'SHARED', ARCHIVED: 'ARCHIVED', diff --git a/src/domain/shortlist.ts b/src/domain/shortlist.ts index 1438cd4..b6d2787 100644 --- a/src/domain/shortlist.ts +++ b/src/domain/shortlist.ts @@ -1,9 +1,30 @@ import type { ShortlistStatus } from './enums' export interface ShortlistItem { - propertyId: string + resultId: string + resultType: string + title: string + matchScore: number + confidenceScore?: number + dataQualityScore?: number + sourceLabel?: string addedAt: string + addedBy: 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 { @@ -11,6 +32,8 @@ export interface Shortlist { title: string description?: string needId?: string + ownerUserId?: string + decisionBriefId?: string items: ShortlistItem[] status: ShortlistStatus createdBy: string diff --git a/src/hooks/useShortlists.ts b/src/hooks/useShortlists.ts new file mode 100644 index 0000000..8a21b2a --- /dev/null +++ b/src/hooks/useShortlists.ts @@ -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] }) + }, + }) +} diff --git a/src/mock-data/shortlists.ts b/src/mock-data/shortlists.ts index f236cf4..9fe11ab 100644 --- a/src/mock-data/shortlists.ts +++ b/src/mock-data/shortlists.ts @@ -8,8 +8,32 @@ export const mockShortlists: Shortlist[] = [ description: 'Beste Bürooptionen für Innovatech AG', needId: 'need-001', 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, createdBy: 'user-001', @@ -23,9 +47,21 @@ export const mockShortlists: Shortlist[] = [ description: 'Auswahl für Schweizer Logistik GmbH', needId: 'need-002', 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', organizationId: 'org-wincasa', sharedWith: ['client@schweizer-logistik.ch'], diff --git a/src/pages/demand/MatchDetail.tsx b/src/pages/demand/MatchDetail.tsx index bdfe252..618289c 100644 --- a/src/pages/demand/MatchDetail.tsx +++ b/src/pages/demand/MatchDetail.tsx @@ -6,6 +6,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 { MatchReasonList } from '../../components/match-card/MatchReasonList' import { MatchDetailHeader, @@ -37,6 +39,7 @@ export default function MatchDetail() { const { matchId } = useParams<{ matchId: string }>() const navigate = useNavigate() const { addToCompare } = useCompareStore() + const { openAddDialog } = useShortlistStore() const { data: match, isLoading } = useMatchDetail(matchId ?? '') @@ -112,15 +115,30 @@ export default function MatchDetail() { 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 ( + {}} + onShortlist={handleShortlist} /> @@ -165,7 +183,7 @@ export default function MatchDetail() { {}} + onShortlist={handleShortlist} onReview={() => {}} onReject={() => {}} /> diff --git a/src/pages/demand/Results.tsx b/src/pages/demand/Results.tsx index 20ef734..7380259 100644 --- a/src/pages/demand/Results.tsx +++ b/src/pages/demand/Results.tsx @@ -1,7 +1,7 @@ import { useState } from 'react' import { Box, Button, Card, Typography } from '@mui/material' -import { useNavigate } from 'react-router' -import { useQuery } from '@tanstack/react-query' +import { useNavigate, useLocation } from 'react-router' +import { useQuery, useQueryClient } from '@tanstack/react-query' import { useUnifiedResults } from '../../hooks/useUnifiedResults' import { needService } from '../../services/needService' import { @@ -11,6 +11,7 @@ import { ResultFilterBar, UnifiedResultFeed, } from '../../components/results' +import { AddToShortlistDialog } from '../../components/shortlist' import type { ResultType } from '../../domain/enums' import type { UnifiedMatchResult } from '../../domain/unifiedResult' @@ -30,16 +31,37 @@ function sortResults(results: UnifiedMatchResult[], sortBy: SortBy): UnifiedMatc export default function Results() { const navigate = useNavigate() + const location = useLocation() + const queryClient = useQueryClient() const [filterSource, setFilterSource] = useState('ALL') const [sortBy, setSortBy] = useState('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({ queryKey: ['needs'], 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 = filterSource === 'ALL' ? results : results.filter(r => r.resultType === filterSource) @@ -52,6 +74,7 @@ export default function Results() { return ( + {activeNeed && ( - + - + Aktive Suche: {activeNeed.companyName} - - {activeNeed.assetType} · {activeNeed.requiredArea.min}–{activeNeed.requiredArea.max} m² ·{' '} - {activeNeed.preferredLocations.join(', ')} - + + + Typ: {activeNeed.assetType} + + + Fläche: {activeNeed.requiredArea.min}–{activeNeed.requiredArea.max} m² + + + Standort: {activeNeed.preferredLocations.join(', ')} + + {activeNeed.budgetRange && ( + + Budget: max. CHF {activeNeed.budgetRange.maxPerSqm}/m² + + )} + {activeNeed.timing && ( + + Bezug ab: {new Date(activeNeed.timing.earliestMoveIn).toLocaleDateString('de-CH', { month: 'long', year: 'numeric' })} + + )} + {activeNeed.mustCriteriaText && activeNeed.mustCriteriaText.length > 0 && ( + + Must-haves: {activeNeed.mustCriteriaText.slice(0, 3).join(' · ')} + + )} + diff --git a/src/pages/demand/Shortlists.tsx b/src/pages/demand/Shortlists.tsx index 6292557..2690a3a 100644 --- a/src/pages/demand/Shortlists.tsx +++ b/src/pages/demand/Shortlists.tsx @@ -1,173 +1,79 @@ +import { Box, Paper, Typography } from '@mui/material' +import { useShortlists } from '../../hooks/useShortlists' +import { useShortlistStore } from '../../stores/shortlistStore' import { - Box, - Button, - Card, - Chip, - Typography, - Stack, - Divider, - List, - ListItem, - ListItemText, -} from '@mui/material' -import { Bookmark } from 'lucide-react' + ShortlistList, + ShortlistDetail, + DecisionBriefDraftPanel, + AddToShortlistDialog, +} from '../../components/shortlist' -interface MockShortlist { - id: string - title: string - company: string - assetType: string - objectCount: number - createdAt: string - updatedAt: string - properties: string[] +const PANEL_HEADER_SX = { + px: 2, + py: 1.5, + borderBottom: '1px solid #e2e8f0', + bgcolor: 'white', + position: 'sticky' as const, + top: 0, + zIndex: 1, + 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() { + const { data: shortlists = [], isLoading } = useShortlists() + const { selectedShortlistId } = useShortlistStore() + + const selectedShortlist = shortlists.find(s => s.id === selectedShortlistId) ?? null + return ( - - {/* Page Header */} - - - - Shortlists - - - Gespeicherte Objektlisten und Entscheidungsvorlagen - + + + {/* Left: shortlist list */} + + + Shortlists + {shortlists.length} gespeichert - - + + - - - {MOCK_SHORTLISTS.map(sl => ( - - {/* Card header */} - - - - {sl.title} - - - {sl.company} · {sl.assetType} - - - - - - {/* Dates */} - - - Erstellt: {sl.createdAt} - - - Zuletzt aktualisiert: {sl.updatedAt} - - - - - - {/* Property list */} - - {sl.properties.map((prop, i) => ( - - - {prop}} - /> - - ))} - - - {/* Actions */} - - - - - - ))} - - {/* Empty shortlist prompt */} - - - Objekte aus den Suchergebnissen zur Shortlist hinzufügen + {/* Center: detail */} + + {selectedShortlist ? ( + + ) : ( + + + Shortlist aus der Liste wählen - - + + )} + + {/* Right: decision brief — only when a shortlist is selected */} + {selectedShortlist && ( + + + + )} + + ) } diff --git a/src/provider/IShortlistProvider.ts b/src/provider/IShortlistProvider.ts index 21181fb..eff3d14 100644 --- a/src/provider/IShortlistProvider.ts +++ b/src/provider/IShortlistProvider.ts @@ -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' export interface ShortlistFilters { @@ -13,7 +13,7 @@ export interface IShortlistProvider { getById(id: string): Promise create(data: CreateShortlistInput): Promise update(id: string, data: UpdateShortlistInput): Promise - addItem(id: string, propertyId: string, note?: string): Promise - removeItem(id: string, propertyId: string): Promise + addItem(id: string, item: ShortlistItemInput): Promise + removeItem(id: string, resultId: string): Promise remove(id: string): Promise } diff --git a/src/provider/MockupShortlistProvider.ts b/src/provider/MockupShortlistProvider.ts index d26644c..c948dfd 100644 --- a/src/provider/MockupShortlistProvider.ts +++ b/src/provider/MockupShortlistProvider.ts @@ -1,5 +1,5 @@ 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 { mockDelay } from '../lib/mockUtils' @@ -36,25 +36,25 @@ export const MockupShortlistProvider: IShortlistProvider = { store[idx] = { ...store[idx], ...data, updatedAt: new Date().toISOString() } return store[idx] }, - async addItem(id, propertyId, note?) { + async addItem(id, item: ShortlistItemInput) { await mockDelay() 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) { 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(), } } return store[idx] }, - async removeItem(id, propertyId) { + async removeItem(id, resultId) { await mockDelay() const idx = store.findIndex(s => s.id === id) 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(), } return store[idx] diff --git a/src/services/aiService.ts b/src/services/aiService.ts index 63f937d..8fd04a5 100644 --- a/src/services/aiService.ts +++ b/src/services/aiService.ts @@ -5,6 +5,45 @@ import type { AssetType } from '../domain/enums' import type { ParseNeedResult, ParsedNeedCriteria, FollowUpQuestion } from '../domain/needBuilder' 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 ─────────────────────────────────────────────────────────── export interface ComparisonSummary { @@ -284,7 +323,7 @@ function mockParseNeed(input: string): ParseNeedResult { timing, mustHaveCriteria, infrastructureRequirements: [], - accessibilityRequirements: mustHaveCriteria.includes('Gute ÖV-Anbindung') ? ['ÖV-Anbindung'] : [], + accessibilityRequirements: [], prestigeImportance, flexibilityNeed: lower.includes('flexibel') ? 'HIGH' : 'MEDIUM', expansionPotential: lower.includes('wachstum') || lower.includes('expansion'), @@ -377,4 +416,9 @@ export const aiService = { const result = mockParseNeed(JSON.stringify(criteria)) return { data: result.followUpQuestionCandidates } }, + + async generateDecisionBrief(shortlistId: string): Promise> { + await new Promise(r => setTimeout(r, 1800)) + return { data: buildMockDecisionBrief(shortlistId) } + }, } diff --git a/src/services/shortlistService.ts b/src/services/shortlistService.ts index 734a034..6b8692f 100644 --- a/src/services/shortlistService.ts +++ b/src/services/shortlistService.ts @@ -1,6 +1,6 @@ import { MockupShortlistProvider } from '../provider/MockupShortlistProvider' 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' const provider = MockupShortlistProvider @@ -22,12 +22,12 @@ export const shortlistService = { const data = await provider.update(id, input) return { data } }, - async addItem(id: string, propertyId: string, note?: string): Promise> { - const data = await provider.addItem(id, propertyId, note) + async addItem(id: string, item: ShortlistItemInput): Promise> { + const data = await provider.addItem(id, item) return { data } }, - async removeItem(id: string, propertyId: string): Promise> { - const data = await provider.removeItem(id, propertyId) + async removeItem(id: string, resultId: string): Promise> { + const data = await provider.removeItem(id, resultId) return { data } }, async remove(id: string): Promise> { diff --git a/src/stores/shortlistStore.ts b/src/stores/shortlistStore.ts new file mode 100644 index 0000000..43c2ad4 --- /dev/null +++ b/src/stores/shortlistStore.ts @@ -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((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 }), +}))