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:
@@ -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'
|
||||
Reference in New Issue
Block a user