Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5259579f85 | |||
| beecf754d5 | |||
| 6e67c698ce | |||
| 3dcac2dcb6 | |||
| 5035445d2e | |||
| 017835b083 | |||
| d945fb1ab6 | |||
| f783cf6881 | |||
| cf9089b5a2 | |||
| b0f675c319 | |||
| da3b5b55cc | |||
| 76e52dae1d | |||
| 4c79d9f969 |
+17
-1
@@ -13,7 +13,23 @@
|
||||
"Bash(git pull *)",
|
||||
"Bash(node scripts/check-tokens.js)",
|
||||
"Bash(echo \"EXIT:$?\")",
|
||||
"Bash(echo \"EXIT_CODE:$?\")"
|
||||
"Bash(echo \"EXIT_CODE:$?\")",
|
||||
"Bash(Get-ChildItem \"c:\\\\Users\\\\beni_\\\\AppData\\\\Local\\\\Temp\\\\docx-gen\\\\node_modules\\\\docx\" -Recurse -Name)",
|
||||
"Bash(start \"C:\\\\Users\\\\beni_\\\\OneDrive\\\\Desktop\\\\property-match\\\\Aenderungsprotokoll_Meeting_26052026.docx\")",
|
||||
"Bash(start http://localhost:5176)",
|
||||
"Bash(start http://localhost:5176/demand/ai-search)",
|
||||
"Bash(node -e ' *)",
|
||||
"Skill(run)",
|
||||
"Skill(run:*)",
|
||||
"Bash(curl -s -o /dev/null -w \"%{http_code}\" http://localhost:5173)",
|
||||
"Bash(powershell.exe -Command \"Start-Process 'http://localhost:5173'\")",
|
||||
"Bash(findstr \"LISTENING\")",
|
||||
"Bash(findstr \":517\")",
|
||||
"Bash(findstr \"LISTEN\")",
|
||||
"Bash(findstr \":5\")",
|
||||
"Bash(echo \"Exit: $?\")",
|
||||
"Bash(npx vercel *)",
|
||||
"Bash(Start-Process \"http://localhost:5180\")"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,23 @@
|
||||
"Bash(git commit *)",
|
||||
"Bash(node -e ' *)",
|
||||
"Bash(git stash *)",
|
||||
"Read(//c/Users/beni_/.claude/projects/c--Users-beni--OneDrive-Desktop-property-match/8e388ddd-e02e-47fe-8bb9-cdb8164c9fc3/tool-results/**)"
|
||||
"Read(//c/Users/beni_/.claude/projects/c--Users-beni--OneDrive-Desktop-property-match/8e388ddd-e02e-47fe-8bb9-cdb8164c9fc3/tool-results/**)",
|
||||
"Bash(pandoc --version)",
|
||||
"Bash(npm list *)",
|
||||
"Read(//c/Program Files/LibreOffice/program/**)",
|
||||
"Read(//c/Program Files/Microsoft Office/**)",
|
||||
"Read(//c/Program Files \\(x86\\)/**)",
|
||||
"Bash(mkdir -p /tmp/docx-gen)",
|
||||
"Read(//tmp/**)",
|
||||
"Bash(npm init *)",
|
||||
"Bash(node generate.js)",
|
||||
"Bash(timeout 8 bash -c \"until curl -s http://localhost:5173 > /dev/null; do sleep 1; done\")",
|
||||
"Bash(dir \"c:\\\\Users\\\\beni_\\\\OneDrive\\\\Desktop\\\\property-match\\\\src\" -Depth 2)",
|
||||
"Bash(start \"\" \"http://localhost:5178/demand/results\")",
|
||||
"Bash(start \"\" \"http://localhost:5178/supply/properties\")"
|
||||
],
|
||||
"additionalDirectories": [
|
||||
"c:\\Users\\beni_\\AppData\\Local\\Temp\\docx-gen"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,19 +96,9 @@ export function InquiryCard({ inquiry, selected, onClick }: InquiryCardProps) {
|
||||
{formatInquiryDate(inquiry.createdAt)}
|
||||
</Typography>
|
||||
{inquiry.matchScore !== undefined && (
|
||||
<Box
|
||||
sx={{
|
||||
px: 1,
|
||||
py: 0.25,
|
||||
borderRadius: 1,
|
||||
bgcolor: '#e0e7ff',
|
||||
color: '#3730a3',
|
||||
fontWeight: 600,
|
||||
fontSize: '0.7rem',
|
||||
}}
|
||||
>
|
||||
Match {inquiry.matchScore}%
|
||||
</Box>
|
||||
<Typography sx={{ fontSize: '0.75rem', fontWeight: 700, color: '#152642' }}>
|
||||
{inquiry.matchScore}%
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
@@ -56,6 +56,7 @@ export function InquiryChat({
|
||||
<InquiryReplyComposer
|
||||
inquiryId={inquiry.id}
|
||||
defaultSubject={inquiry.subject}
|
||||
tenantName={inquiry.tenantName}
|
||||
pendingAttachment={pendingAttachment}
|
||||
onPendingAttachmentConsumed={onPendingAttachmentConsumed}
|
||||
/>
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Box, Button, CircularProgress, Typography } from '@mui/material'
|
||||
import { ClipboardList } from 'lucide-react'
|
||||
import { ArrowRight, Building2, ClipboardList, MapPin, Ruler } from 'lucide-react'
|
||||
import { useNavigate } from 'react-router'
|
||||
import { useInquiryById, useMarkThreadAsRead } from '../../hooks/useInquiries'
|
||||
import { usePropertyById } from '../../hooks/useProperties'
|
||||
import { InquiryChat } from './InquiryChat'
|
||||
import { RelatedPropertyCardPanel } from './RelatedPropertyCardPanel'
|
||||
import type { Attachment } from '../../domain/inquiry'
|
||||
@@ -12,7 +14,9 @@ interface InquiryDetailPanelProps {
|
||||
|
||||
export function InquiryDetailPanel({ inquiryId }: InquiryDetailPanelProps) {
|
||||
const { data: inquiry, isLoading } = useInquiryById(inquiryId)
|
||||
const { data: property } = usePropertyById(inquiry?.propertyId ?? '')
|
||||
const markRead = useMarkThreadAsRead()
|
||||
const navigate = useNavigate()
|
||||
const [preparationOpen, setPreparationOpen] = useState(false)
|
||||
const [offerWizardOpen, setOfferWizardOpen] = useState(false)
|
||||
const [pendingAttachment, setPendingAttachment] = useState<Attachment | null>(null)
|
||||
@@ -43,10 +47,13 @@ export function InquiryDetailPanel({ inquiryId }: InquiryDetailPanelProps) {
|
||||
}
|
||||
|
||||
const isLatentInquiry = !!inquiry.needId
|
||||
const propertyImage = property?.images?.[0]
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
||||
|
||||
{/* Header: tenant + subject + action */}
|
||||
<Box
|
||||
sx={{
|
||||
px: 2.5,
|
||||
@@ -92,21 +99,87 @@ export function InquiryDetailPanel({ inquiryId }: InquiryDetailPanelProps) {
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box sx={{ flex: 1, display: 'flex', flexDirection: { xs: 'column', md: 'row' }, overflow: 'hidden' }}>
|
||||
{/* Property context bar */}
|
||||
<Box
|
||||
sx={{
|
||||
px: 2,
|
||||
py: 1,
|
||||
borderBottom: '1px solid #e2e8f0',
|
||||
bgcolor: '#f8fafc',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1.5,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 1,
|
||||
overflow: 'hidden',
|
||||
flexShrink: 0,
|
||||
bgcolor: '#e8e7e4',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
{propertyImage ? (
|
||||
<Box
|
||||
component="img"
|
||||
src={propertyImage}
|
||||
alt={property?.title}
|
||||
sx={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
|
||||
/>
|
||||
) : (
|
||||
<Building2 size={18} color="#94a3b8" />
|
||||
)}
|
||||
</Box>
|
||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||
<Typography
|
||||
sx={{ fontWeight: 600, color: '#0f172a', fontSize: '0.85rem', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', lineHeight: 1.3 }}
|
||||
>
|
||||
{property?.title ?? '—'}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, color: '#64748b' }}>
|
||||
<MapPin size={11} style={{ flexShrink: 0 }} />
|
||||
<Typography variant="caption" sx={{ fontSize: '0.72rem', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{property?.location.city}
|
||||
{property?.areaSqm ? ` · ${property.areaSqm.toLocaleString('de-CH')} m²` : ''}
|
||||
{property?.rentPricePerSqm ? ` · CHF ${property.rentPricePerSqm}/m²/J.` : ''}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
<Button
|
||||
size="small"
|
||||
endIcon={<ArrowRight size={12} />}
|
||||
onClick={() => navigate('/supply/properties')}
|
||||
sx={{ textTransform: 'none', fontSize: '0.75rem', color: '#152642', whiteSpace: 'nowrap', flexShrink: 0 }}
|
||||
>
|
||||
Objekt ansehen
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{/* Chat (flex: 1) */}
|
||||
<Box sx={{ flex: 1, overflow: 'hidden' }}>
|
||||
<InquiryChat
|
||||
inquiry={inquiry}
|
||||
onCreateOffer={() => setOfferWizardOpen(true)}
|
||||
pendingAttachment={pendingAttachment}
|
||||
onPendingAttachmentConsumed={() => setPendingAttachment(null)}
|
||||
/>
|
||||
<Box sx={{ width: { xs: '100%', md: 300 }, flexShrink: 0, borderTop: { xs: '1px solid #e2e8f0', md: 'none' }, maxHeight: { xs: 280, md: 'none' }, overflowY: 'auto' }}>
|
||||
</Box>
|
||||
|
||||
{/* Bottom: weitere passende Objekte */}
|
||||
<Box sx={{ flexShrink: 0, borderTop: '1px solid #e2e8f0' }}>
|
||||
<RelatedPropertyCardPanel
|
||||
propertyId={inquiry.propertyId}
|
||||
inquiryId={inquiry.id}
|
||||
compact
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{preparationOpen && (
|
||||
<PreparationWizardLazy
|
||||
|
||||
@@ -17,11 +17,10 @@ export function InquiryListRow({ inquiry, selected, onClick }: InquiryListRowPro
|
||||
onClick={onClick}
|
||||
sx={{
|
||||
px: 2,
|
||||
py: 1.5,
|
||||
py: 1,
|
||||
borderBottom: '1px solid #e2e8f0',
|
||||
borderLeft: '3px solid',
|
||||
borderLeftColor: selected ? '#152642' : hasUnread ? '#2563eb' : 'transparent',
|
||||
bgcolor: selected ? '#f1f5f9' : 'white',
|
||||
borderLeft: selected ? '4px solid #2563eb' : `3px solid ${hasUnread ? '#2563eb' : 'transparent'}`,
|
||||
bgcolor: selected ? '#eff6ff' : 'white',
|
||||
cursor: 'pointer',
|
||||
transition: 'background-color 0.15s, border-color 0.15s',
|
||||
'&:hover': { bgcolor: selected ? '#f1f5f9' : '#f8fafc' },
|
||||
@@ -30,7 +29,7 @@ export function InquiryListRow({ inquiry, selected, onClick }: InquiryListRowPro
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 0.5 }}>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{ fontWeight: hasUnread ? 700 : 600, color: '#0f172a', fontSize: '0.85rem' }}
|
||||
sx={{ fontWeight: selected || hasUnread ? 700 : 600, color: selected ? '#1d4ed8' : '#0f172a', fontSize: '0.85rem' }}
|
||||
>
|
||||
{inquiry.tenantName}
|
||||
{inquiry.tenantCompany ? ` · ${inquiry.tenantCompany}` : ''}
|
||||
@@ -76,20 +75,9 @@ export function InquiryListRow({ inquiry, selected, onClick }: InquiryListRowPro
|
||||
{propertyLabelFromId(inquiry.propertyId)}
|
||||
</Typography>
|
||||
{inquiry.matchScore !== undefined && (
|
||||
<Box
|
||||
sx={{
|
||||
ml: 'auto',
|
||||
px: 0.75,
|
||||
py: 0.125,
|
||||
borderRadius: 1,
|
||||
bgcolor: '#e0e7ff',
|
||||
color: '#3730a3',
|
||||
fontWeight: 600,
|
||||
fontSize: '0.7rem',
|
||||
}}
|
||||
>
|
||||
<Typography sx={{ ml: 'auto', fontSize: '0.75rem', fontWeight: 700, color: '#152642' }}>
|
||||
{inquiry.matchScore}%
|
||||
</Box>
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
<Typography variant="caption" sx={{ color: '#94a3b8', fontSize: '0.7rem', mt: 0.5, display: 'block' }}>
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
TextField,
|
||||
Typography,
|
||||
} from '@mui/material'
|
||||
import { Send, Paperclip, X } from 'lucide-react'
|
||||
import { Paperclip, Send, Sparkles, X } from 'lucide-react'
|
||||
import { useSendInquiryReply } from '../../hooks/useInquiries'
|
||||
import { useToastStore } from '../../stores/toastStore'
|
||||
import type { Attachment } from '../../domain/inquiry'
|
||||
@@ -16,6 +16,7 @@ import { formatFileSize } from './inquiryUtils'
|
||||
interface InquiryReplyComposerProps {
|
||||
inquiryId: string
|
||||
defaultSubject: string
|
||||
tenantName?: string
|
||||
onSent?: () => void
|
||||
pendingAttachment?: Attachment | null
|
||||
onPendingAttachmentConsumed?: () => void
|
||||
@@ -24,6 +25,7 @@ interface InquiryReplyComposerProps {
|
||||
export function InquiryReplyComposer({
|
||||
inquiryId,
|
||||
defaultSubject,
|
||||
tenantName,
|
||||
onSent,
|
||||
pendingAttachment,
|
||||
onPendingAttachmentConsumed,
|
||||
@@ -33,6 +35,7 @@ export function InquiryReplyComposer({
|
||||
)
|
||||
const [body, setBody] = useState('')
|
||||
const [attachments, setAttachments] = useState<Attachment[]>([])
|
||||
const [isAIDraft, setIsAIDraft] = useState(false)
|
||||
|
||||
const sendReply = useSendInquiryReply()
|
||||
const showToast = useToastStore(s => s.showToast)
|
||||
@@ -49,6 +52,14 @@ export function InquiryReplyComposer({
|
||||
|
||||
const sending = sendReply.isPending
|
||||
|
||||
function handleAIDraft() {
|
||||
const salutation = tenantName ? `Sehr geehrte/r ${tenantName}` : 'Sehr geehrte Damen und Herren'
|
||||
setBody(
|
||||
`${salutation},\n\nVielen Dank für Ihre Anfrage. Gerne bestätigen wir, dass das Objekt zum gewünschten Zeitpunkt verfügbar ist.\n\nWir würden Ihnen gerne einen Besichtigungstermin vorschlagen — bitte teilen Sie uns Ihre Verfügbarkeit mit, damit wir einen passenden Termin finden können.\n\nFür Rückfragen stehen wir Ihnen jederzeit gerne zur Verfügung.\n\nMit freundlichen Grüssen`
|
||||
)
|
||||
setIsAIDraft(true)
|
||||
}
|
||||
|
||||
const handleAddMockAttachment = () => {
|
||||
const name = `Anhang_${attachments.length + 1}.pdf`
|
||||
setAttachments(prev => [
|
||||
@@ -82,6 +93,7 @@ export function InquiryReplyComposer({
|
||||
showToast('Antwort gesendet', 'success')
|
||||
setBody('')
|
||||
setAttachments([])
|
||||
setIsAIDraft(false)
|
||||
onSent?.()
|
||||
}
|
||||
|
||||
@@ -103,15 +115,26 @@ export function InquiryReplyComposer({
|
||||
onChange={e => setSubject(e.target.value)}
|
||||
fullWidth
|
||||
/>
|
||||
|
||||
{isAIDraft && (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
|
||||
<Sparkles size={12} color="#7c3aed" />
|
||||
<Typography variant="caption" sx={{ color: '#7c3aed', fontSize: '0.7rem', fontWeight: 500 }}>
|
||||
KI-Entwurf — bitte prüfen und anpassen
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<TextField
|
||||
size="small"
|
||||
label="Nachricht"
|
||||
value={body}
|
||||
onChange={e => setBody(e.target.value)}
|
||||
onChange={e => { setBody(e.target.value); setIsAIDraft(false) }}
|
||||
multiline
|
||||
rows={4}
|
||||
rows={7}
|
||||
placeholder="Antwort verfassen..."
|
||||
fullWidth
|
||||
sx={isAIDraft ? { '& .MuiOutlinedInput-root': { borderColor: '#ddd6fe' }, '& fieldset': { borderColor: '#ddd6fe !important' } } : {}}
|
||||
/>
|
||||
|
||||
{attachments.length > 0 && (
|
||||
@@ -149,15 +172,32 @@ export function InquiryReplyComposer({
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, justifyContent: 'space-between' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, justifyContent: 'space-between' }}>
|
||||
<Box sx={{ display: 'flex', gap: 0.75 }}>
|
||||
<Button
|
||||
size="small"
|
||||
startIcon={<Paperclip size={14} />}
|
||||
startIcon={<Sparkles size={13} />}
|
||||
onClick={handleAIDraft}
|
||||
sx={{
|
||||
textTransform: 'none',
|
||||
fontSize: '0.75rem',
|
||||
color: '#7c3aed',
|
||||
borderColor: '#ddd6fe',
|
||||
'&:hover': { bgcolor: '#f5f3ff', borderColor: '#c4b5fd' },
|
||||
}}
|
||||
variant="outlined"
|
||||
>
|
||||
KI-Entwurf
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
startIcon={<Paperclip size={13} />}
|
||||
onClick={handleAddMockAttachment}
|
||||
sx={{ textTransform: 'none' }}
|
||||
sx={{ textTransform: 'none', fontSize: '0.75rem' }}
|
||||
>
|
||||
Anhang
|
||||
</Button>
|
||||
</Box>
|
||||
<Button
|
||||
variant="contained"
|
||||
size="small"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Box, Button, CircularProgress, Divider, Typography } from '@mui/material'
|
||||
import { ArrowRight, Building2, Calendar, MapPin, Ruler, Trophy } from 'lucide-react'
|
||||
import { ArrowRight, Building2, Calendar, MapPin, Ruler } from 'lucide-react'
|
||||
import { useNavigate } from 'react-router'
|
||||
import { usePropertyById } from '../../hooks/useProperties'
|
||||
import { useAdditionalMatchesForInquiry } from '../../hooks/useMatches'
|
||||
@@ -8,9 +8,10 @@ import type { AdditionalPropertyMatch } from '../../domain/additionalMatch'
|
||||
interface RelatedPropertyCardPanelProps {
|
||||
propertyId: string
|
||||
inquiryId: string
|
||||
compact?: boolean
|
||||
}
|
||||
|
||||
export function RelatedPropertyCardPanel({ propertyId, inquiryId }: RelatedPropertyCardPanelProps) {
|
||||
export function RelatedPropertyCardPanel({ propertyId, inquiryId, compact }: RelatedPropertyCardPanelProps) {
|
||||
const { data: property, isLoading } = usePropertyById(propertyId)
|
||||
const navigate = useNavigate()
|
||||
const {
|
||||
@@ -18,7 +19,7 @@ export function RelatedPropertyCardPanel({ propertyId, inquiryId }: RelatedPrope
|
||||
isLoading: loadingMatches,
|
||||
} = useAdditionalMatchesForInquiry(inquiryId, { minScore: 80, excludePropertyId: propertyId })
|
||||
|
||||
if (isLoading) {
|
||||
if (isLoading && !compact) {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', p: 4 }}>
|
||||
<CircularProgress size={20} />
|
||||
@@ -26,6 +27,44 @@ export function RelatedPropertyCardPanel({ propertyId, inquiryId }: RelatedPrope
|
||||
)
|
||||
}
|
||||
|
||||
if (compact) {
|
||||
return (
|
||||
<Box sx={{ px: 1.5, pt: 1.25, pb: 1.5, bgcolor: 'white' }}>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{ color: '#64748b', fontWeight: 600, textTransform: 'uppercase', letterSpacing: 0.5, fontSize: '0.65rem', display: 'block', mb: 1 }}
|
||||
>
|
||||
Weitere passende Objekte
|
||||
</Typography>
|
||||
|
||||
{loadingMatches ? (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 1.5 }}>
|
||||
<CircularProgress size={16} />
|
||||
</Box>
|
||||
) : additionalMatches.length === 0 ? (
|
||||
<Typography variant="caption" sx={{ color: '#94a3b8', fontSize: '0.72rem' }}>
|
||||
Keine weiteren Matches
|
||||
</Typography>
|
||||
) : (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
gap: 1.25,
|
||||
overflowX: 'auto',
|
||||
pb: 0.5,
|
||||
'&::-webkit-scrollbar': { height: 3 },
|
||||
'&::-webkit-scrollbar-thumb': { bgcolor: '#cbd5e1', borderRadius: 2 },
|
||||
}}
|
||||
>
|
||||
{additionalMatches.map(m => (
|
||||
<MatchSliderCard key={m.propertyId} match={m} />
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
if (!property) {
|
||||
return (
|
||||
<Box sx={{ p: 2 }}>
|
||||
@@ -51,25 +90,22 @@ export function RelatedPropertyCardPanel({ propertyId, inquiryId }: RelatedPrope
|
||||
overflowY: 'auto',
|
||||
}}
|
||||
>
|
||||
<Typography variant="caption" sx={{ color: '#64748b', fontWeight: 600, textTransform: 'uppercase', letterSpacing: 0.5 }}>
|
||||
Bezogenes Objekt
|
||||
</Typography>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
height: 140,
|
||||
aspectRatio: '3/2',
|
||||
borderRadius: 1.5,
|
||||
overflow: 'hidden',
|
||||
bgcolor: '#e8e7e4',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundImage: image ? `url(${image})` : 'none',
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center',
|
||||
bgcolor: '#f4f3f0',
|
||||
}}
|
||||
>
|
||||
{!image && <Building2 size={32} color="#94a3b8" />}
|
||||
{image ? (
|
||||
<Box component="img" src={image} alt={property.title}
|
||||
sx={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }} />
|
||||
) : (
|
||||
<Box sx={{ width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Building2 size={32} color="#94a3b8" />
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Typography variant="body1" sx={{ fontWeight: 600, color: '#0f172a', fontSize: '0.95rem' }}>
|
||||
@@ -77,20 +113,20 @@ export function RelatedPropertyCardPanel({ propertyId, inquiryId }: RelatedPrope
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, color: '#475569', fontSize: '0.8125rem' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, color: '#475569' }}>
|
||||
<MapPin size={14} />
|
||||
<Typography variant="body2" sx={{ fontSize: '0.8125rem' }}>
|
||||
{property.location.city}
|
||||
{property.location.district ? `, ${property.location.district}` : ''}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, color: '#475569', fontSize: '0.8125rem' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, color: '#475569' }}>
|
||||
<Ruler size={14} />
|
||||
<Typography variant="body2" sx={{ fontSize: '0.8125rem' }}>
|
||||
{property.areaSqm.toLocaleString('de-CH')} m² · CHF {property.rentPricePerSqm}/m²/Jahr
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, color: '#475569', fontSize: '0.8125rem' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, color: '#475569' }}>
|
||||
<Calendar size={14} />
|
||||
<Typography variant="body2" sx={{ fontSize: '0.8125rem' }}>
|
||||
Verfügbar ab {new Date(property.availabilityDate).toLocaleDateString('de-CH')}
|
||||
@@ -114,14 +150,10 @@ export function RelatedPropertyCardPanel({ propertyId, inquiryId }: RelatedPrope
|
||||
Objekt ansehen
|
||||
</Button>
|
||||
|
||||
{/* Weitere Matches */}
|
||||
<Divider sx={{ my: 0.5 }} />
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
|
||||
<Trophy size={14} color="#d97706" />
|
||||
<Typography variant="caption" sx={{ color: '#64748b', fontWeight: 600, textTransform: 'uppercase', letterSpacing: 0.5, fontSize: '0.68rem' }}>
|
||||
Weitere passende Objekte
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{loadingMatches ? (
|
||||
<CircularProgress size={16} sx={{ alignSelf: 'center' }} />
|
||||
@@ -140,74 +172,117 @@ export function RelatedPropertyCardPanel({ propertyId, inquiryId }: RelatedPrope
|
||||
)
|
||||
}
|
||||
|
||||
function AdditionalMatchCard({ match }: { match: AdditionalPropertyMatch }) {
|
||||
function MatchSliderCard({ match }: { match: AdditionalPropertyMatch }) {
|
||||
const navigate = useNavigate()
|
||||
return (
|
||||
<Box
|
||||
onClick={() => navigate('/supply/properties')}
|
||||
sx={{
|
||||
border: '1px solid #e2e8f0',
|
||||
flexShrink: 0,
|
||||
width: 168,
|
||||
border: '1px solid #e8e7e4',
|
||||
borderRadius: 1.5,
|
||||
overflow: 'hidden',
|
||||
cursor: 'pointer',
|
||||
bgcolor: 'white',
|
||||
transition: 'border-color 0.15s, box-shadow 0.15s',
|
||||
'&:hover': {
|
||||
borderColor: '#b0aead',
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.08)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{match.imageUrl && (
|
||||
{/* Image — 16:9 crop, compact supplementary card */}
|
||||
<Box sx={{ position: 'relative', height: 60, bgcolor: '#f4f3f0', overflow: 'hidden' }}>
|
||||
{match.imageUrl ? (
|
||||
<Box
|
||||
sx={{
|
||||
height: 70,
|
||||
backgroundImage: `url(${match.imageUrl})`,
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center',
|
||||
}}
|
||||
component="img"
|
||||
src={match.imageUrl}
|
||||
alt={match.title}
|
||||
sx={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
|
||||
/>
|
||||
) : (
|
||||
<Box sx={{ width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Building2 size={16} color="#94a3b8" />
|
||||
</Box>
|
||||
)}
|
||||
<Box sx={{ p: 1 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', mb: 0.5 }}>
|
||||
<Typography variant="caption" sx={{ fontWeight: 600, color: '#0f172a', fontSize: '0.75rem', lineHeight: 1.3 }}>
|
||||
{match.title}
|
||||
</Typography>
|
||||
{/* Score badge overlay */}
|
||||
<Box
|
||||
sx={{
|
||||
px: 0.75,
|
||||
py: 0.125,
|
||||
borderRadius: 1,
|
||||
bgcolor: match.matchScore >= 90 ? '#fef3c7' : '#e0e7ff',
|
||||
color: match.matchScore >= 90 ? '#92400e' : '#3730a3',
|
||||
position: 'absolute',
|
||||
top: 6,
|
||||
right: 6,
|
||||
bgcolor: 'rgba(15,23,42,0.72)',
|
||||
color: 'white',
|
||||
fontSize: '0.7rem',
|
||||
fontWeight: 700,
|
||||
fontSize: '0.65rem',
|
||||
flexShrink: 0,
|
||||
ml: 0.5,
|
||||
px: 0.75,
|
||||
py: 0.25,
|
||||
borderRadius: '4px',
|
||||
lineHeight: 1.4,
|
||||
}}
|
||||
>
|
||||
{match.matchScore}%
|
||||
</Box>
|
||||
</Box>
|
||||
<Typography variant="caption" sx={{ color: '#64748b', fontSize: '0.7rem', display: 'block', mb: 0.5 }}>
|
||||
{match.location} · {match.areaSqm.toLocaleString('de-CH')} m² · CHF {match.rentPricePerSqm}/m²/Jahr
|
||||
</Typography>
|
||||
{match.reasons.length > 0 && (
|
||||
<Box sx={{ mb: 0.5 }}>
|
||||
{match.reasons.map((r, i) => (
|
||||
<Typography key={i} variant="caption" sx={{ color: '#15803d', fontSize: '0.68rem', display: 'block', lineHeight: 1.4 }}>
|
||||
+ {r}
|
||||
</Typography>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
{match.topUncertainty && (
|
||||
<Typography variant="caption" sx={{ color: '#92400e', fontSize: '0.68rem', display: 'block', lineHeight: 1.4, mb: 0.25 }}>
|
||||
⚠ {match.topUncertainty}
|
||||
</Typography>
|
||||
)}
|
||||
<Button
|
||||
size="small"
|
||||
endIcon={<ArrowRight size={11} />}
|
||||
onClick={() => navigate('/supply/properties')}
|
||||
sx={{ textTransform: 'none', fontSize: '0.7rem', mt: 0.5, p: 0, minWidth: 0, color: '#152642' }}
|
||||
<Box sx={{ p: 0.75 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', mb: 0.2 }}>
|
||||
<Typography
|
||||
sx={{ fontWeight: 600, color: '#0f172a', fontSize: '0.75rem', lineHeight: 1.3, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1, mr: 0.5 }}
|
||||
>
|
||||
Ansehen
|
||||
</Button>
|
||||
{match.title}
|
||||
</Typography>
|
||||
<Typography sx={{ fontSize: '0.7rem', fontWeight: 700, color: '#152642', flexShrink: 0 }}>
|
||||
{match.matchScore}%
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography variant="caption" sx={{ color: '#64748b', fontSize: '0.68rem', display: 'block', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{match.location}
|
||||
</Typography>
|
||||
{match.reasons[0] && (
|
||||
<Typography variant="caption" sx={{ color: '#15803d', fontSize: '0.67rem', display: 'block', lineHeight: 1.4, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', mt: 0.2 }}>
|
||||
+ {match.reasons[0]}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
function AdditionalMatchCard({ match }: { match: AdditionalPropertyMatch }) {
|
||||
const navigate = useNavigate()
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
borderTop: '1px solid #f1f5f9',
|
||||
pt: 1,
|
||||
'&:first-of-type': { borderTop: 'none', pt: 0 },
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', mb: 0.25 }}>
|
||||
<Typography sx={{ fontWeight: 600, color: '#0f172a', fontSize: '0.8rem', lineHeight: 1.3, mr: 0.5 }}>
|
||||
{match.title}
|
||||
</Typography>
|
||||
<Typography sx={{ fontSize: '0.75rem', fontWeight: 700, color: '#152642', flexShrink: 0 }}>
|
||||
{match.matchScore}%
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography variant="caption" sx={{ color: '#64748b', fontSize: '0.72rem', display: 'block', mb: 0.375 }}>
|
||||
{match.location} · {match.areaSqm.toLocaleString('de-CH')} m²
|
||||
</Typography>
|
||||
{match.reasons[0] && (
|
||||
<Typography variant="caption" sx={{ color: '#15803d', fontSize: '0.7rem', display: 'block', mb: 0.375, lineHeight: 1.4 }}>
|
||||
+ {match.reasons[0]}
|
||||
</Typography>
|
||||
)}
|
||||
<Button
|
||||
size="small"
|
||||
endIcon={<ArrowRight size={10} />}
|
||||
onClick={() => navigate('/supply/properties')}
|
||||
sx={{ textTransform: 'none', fontSize: '0.7rem', p: 0, minWidth: 0, color: '#152642' }}
|
||||
>
|
||||
Ansehen
|
||||
</Button>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from 'react'
|
||||
import { Box, Card, Chip, FormControlLabel, Slider, Stack, Switch, TextField, Typography } from '@mui/material'
|
||||
import { Box, Card, Chip, Slider, Stack, TextField, Typography } from '@mui/material'
|
||||
import type { ParsedNeedCriteria } from '../../domain/needBuilder'
|
||||
import { AssetType } from '../../domain/enums'
|
||||
import { NeedExtendedRequirements } from './NeedExtendedRequirements'
|
||||
@@ -242,31 +242,7 @@ export function NeedInput({ criteria: c, onCriteriaChange: set }: Props) {
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{/* Anonymous search */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1, mt: 0.5, py: 1, px: 1.5, borderRadius: 1.5, bgcolor: c.isAnonymous ? 'rgba(124,58,237,0.07)' : '#f8fafc', border: `1px solid ${c.isAnonymous ? '#7c3aed' : '#e8e7e4'}` }}>
|
||||
<Box>
|
||||
<Typography variant="caption" sx={{ fontWeight: 700, color: c.isAnonymous ? '#7c3aed' : '#334155' }}>
|
||||
Anonyme Suche
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', fontSize: '0.65rem' }}>
|
||||
Firmenname wird nicht an Vermieter übermittelt
|
||||
</Typography>
|
||||
</Box>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
size="small"
|
||||
checked={c.isAnonymous ?? false}
|
||||
onChange={e => set({ ...c, isAnonymous: e.target.checked })}
|
||||
sx={{ '& .MuiSwitch-thumb': { bgcolor: c.isAnonymous ? '#7c3aed' : undefined }, '& .Mui-checked + .MuiSwitch-track': { bgcolor: '#7c3aed' } }}
|
||||
/>
|
||||
}
|
||||
label=""
|
||||
sx={{ m: 0 }}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<NeedExtendedRequirements criteria={c} onChange={set} />
|
||||
<NeedExtendedRequirements criteria={c} onChange={set} />
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ export function PageHeader({
|
||||
sx,
|
||||
}: PageHeaderProps) {
|
||||
return (
|
||||
<Box sx={{ px: 3, py: 3, borderBottom: '1px solid #e8e7e4', ...sx }}>
|
||||
<Box sx={{ px: 3, py: 2.5, borderBottom: '1px solid #e8e7e4', bgcolor: 'white', ...sx }}>
|
||||
{breadcrumbs && breadcrumbs.length > 0 && (
|
||||
<Breadcrumbs separator="/" sx={{ mb: 1 }}>
|
||||
{breadcrumbs.map((crumb) =>
|
||||
@@ -52,7 +52,7 @@ export function PageHeader({
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Typography variant="h6" sx={{ fontFamily: '"DM Serif Display", serif', fontWeight: 400, fontSize: '1.25rem', lineHeight: 1.3 }}>
|
||||
<Typography sx={{ fontWeight: 700, fontSize: '1.125rem', lineHeight: 1.3, color: '#0f172a' }}>
|
||||
{title}
|
||||
</Typography>
|
||||
{badge}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Box, Button, Chip, Tooltip, Typography } from '@mui/material'
|
||||
import { Building2, CheckCircle2 } from 'lucide-react'
|
||||
import { Box, Button, Chip, Typography } from '@mui/material'
|
||||
import { Building2, CheckCircle2, MapPin } from 'lucide-react'
|
||||
import { useSessionStore } from '../../stores/sessionStore'
|
||||
import { useInquiryStore } from '../../stores/inquiryStore'
|
||||
import { LocationPreview } from '../shared/LocationPreview'
|
||||
@@ -34,13 +34,8 @@ export function IntelligenceMatchCard({ vm, imageUrl, lat, lng, cityLabel, overl
|
||||
return <FutureAvailabilityCard vm={vm} />
|
||||
}
|
||||
|
||||
// secondary chips — max 2 visible
|
||||
const chips: Array<{ label: string; color?: string }> = []
|
||||
if (vm.availabilityLabel) chips.push({ label: vm.availabilityLabel })
|
||||
if (vm.fitOutLabel) chips.push({ label: vm.fitOutLabel })
|
||||
if (vm.isDivisible && vm.minDivisibleUnitSqm) chips.push({ label: `Teilbar ab ${vm.minDivisibleUnitSqm} m²` })
|
||||
const visibleChips = chips.slice(0, 2)
|
||||
const hiddenChips = chips.slice(2)
|
||||
// secondary chips — max 1 visible (availability only), no overflow chip
|
||||
const secondaryChip = vm.availabilityLabel ?? vm.fitOutLabel ?? null
|
||||
|
||||
return (
|
||||
<Box sx={{
|
||||
@@ -51,8 +46,15 @@ export function IntelligenceMatchCard({ vm, imageUrl, lat, lng, cityLabel, overl
|
||||
boxShadow: '0 1px 3px rgba(0,0,0,0.06)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
transition: 'border-color 0.15s',
|
||||
'&:hover': { borderColor: '#c8c7c4' },
|
||||
position: 'relative',
|
||||
zIndex: 0,
|
||||
transition: 'transform 0.18s ease, box-shadow 0.18s ease, border-color 0.15s',
|
||||
'&:hover': {
|
||||
borderColor: '#b0aead',
|
||||
transform: 'scale(1.025)',
|
||||
boxShadow: '0 10px 32px rgba(0,0,0,0.13)',
|
||||
zIndex: 1,
|
||||
},
|
||||
}}>
|
||||
|
||||
{/* ── Hero zone ── */}
|
||||
@@ -67,15 +69,29 @@ export function IntelligenceMatchCard({ vm, imageUrl, lat, lng, cityLabel, overl
|
||||
border: `1px solid ${theme.border}`,
|
||||
}}>
|
||||
<Typography sx={{
|
||||
fontFamily: '"DM Serif Display", serif',
|
||||
fontWeight: 400, fontSize: '1.625rem',
|
||||
color: '#ffffff', lineHeight: 1,
|
||||
fontWeight: 700, fontSize: '1.625rem',
|
||||
color: '#ffffff', lineHeight: 1, letterSpacing: '-0.02em',
|
||||
}}>
|
||||
{vm.matchScore}%
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Top-right badge strip */}
|
||||
{/* Location pill — Ginesta-style, top-left */}
|
||||
<Box sx={{
|
||||
position: 'absolute', top: 10, left: 10,
|
||||
bgcolor: 'rgba(255,255,255,0.92)',
|
||||
borderRadius: '20px',
|
||||
px: 1, py: 0.35,
|
||||
display: 'flex', alignItems: 'center', gap: 0.4,
|
||||
backdropFilter: 'blur(4px)',
|
||||
}}>
|
||||
<MapPin size={9} color="#64748b" />
|
||||
<Typography sx={{ fontSize: '0.65rem', fontWeight: 500, color: '#374151', lineHeight: 1 }}>
|
||||
{vm.locationLabel}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Top-right badge strip — result type + owner + heat */}
|
||||
<Box sx={{ position: 'absolute', top: 10, right: 10, display: 'flex', flexDirection: 'column', gap: 0.5, alignItems: 'flex-end' }}>
|
||||
<Chip label={rt.label} size="small" sx={{ bgcolor: rt.color, color: 'white', fontWeight: 600, fontSize: 10, height: 20 }} />
|
||||
{vm.resultType === 'VERIFIED_PORTFOLIO' && isPortfolioOwner && (
|
||||
@@ -116,19 +132,13 @@ export function IntelligenceMatchCard({ vm, imageUrl, lat, lng, cityLabel, overl
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{/* Confidence + secondary chips */}
|
||||
{/* Confidence + one secondary chip max */}
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5, mt: 1, alignItems: 'center' }}>
|
||||
<Chip label={`${confPct}% Konfidenz`} size="small"
|
||||
sx={{ bgcolor: confidenceHex(vm.confidenceScore), color: 'white', fontSize: 10, height: 20 }} />
|
||||
{visibleChips.map((c, i) => (
|
||||
<Chip key={i} label={c.label} size="small" variant="outlined"
|
||||
{secondaryChip && (
|
||||
<Chip label={secondaryChip} size="small" variant="outlined"
|
||||
sx={{ fontSize: 10, height: 20, borderColor: DS_BORDER.default, color: DS_TEXT.secondary }} />
|
||||
))}
|
||||
{hiddenChips.length > 0 && (
|
||||
<Tooltip title={hiddenChips.map(c => c.label).join(' · ')} arrow>
|
||||
<Chip label={`+${hiddenChips.length}`} size="small" variant="outlined"
|
||||
sx={{ fontSize: 10, height: 20, borderColor: DS_BORDER.default, color: DS_TEXT.muted }} />
|
||||
</Tooltip>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
@@ -145,7 +155,7 @@ export function IntelligenceMatchCard({ vm, imageUrl, lat, lng, cityLabel, overl
|
||||
|
||||
{vm.reasons.length > 0 && (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5, mt: 1 }}>
|
||||
{vm.reasons.slice(0, 3).map((r, i) => (
|
||||
{vm.reasons.slice(0, 2).map((r, i) => (
|
||||
<Box key={i} sx={{ display: 'flex', alignItems: 'flex-start', gap: 0.75 }}>
|
||||
<CheckCircle2 size={13} color="#1d6342" style={{ marginTop: 2, flexShrink: 0 }} />
|
||||
<Box>
|
||||
|
||||
@@ -1,23 +1,21 @@
|
||||
import { memo } from 'react'
|
||||
import { Alert, Box, Button, Chip, Divider, Tooltip, Typography } from '@mui/material'
|
||||
import { MapPin, Building2, CheckCircle2, AlertTriangle } from 'lucide-react'
|
||||
import { Alert, Box, Button, Chip, Typography } from '@mui/material'
|
||||
import { MapPin, AlertTriangle } from 'lucide-react'
|
||||
import { useSessionStore } from '../../stores/sessionStore'
|
||||
import { useInquiryStore } from '../../stores/inquiryStore'
|
||||
import { HeatBadge } from '../shared/HeatBadge'
|
||||
import { getScoreTier, SCORE_THEME } from '../shared/scoreTheme'
|
||||
import { RESULT_TYPE_META, DS_TEXT, DS_BORDER } from '../../lib/ds'
|
||||
import { confidenceHex } from '../../lib/utils'
|
||||
import { ScoreInlineBreakdown } from './ScoreInlineBreakdown'
|
||||
import { MatchCardRestrictedState } from './MatchCardRestrictedState'
|
||||
import type { MatchCardViewModel } from './MatchCardViewModel'
|
||||
|
||||
interface Props {
|
||||
vm: MatchCardViewModel
|
||||
imageUrl?: string
|
||||
overlayTypeLabel?: string
|
||||
}
|
||||
|
||||
export const MatchCardCompact = memo(function MatchCardCompact({ vm, imageUrl, overlayTypeLabel }: Props) {
|
||||
export const MatchCardCompact = memo(function MatchCardCompact({ vm, imageUrl }: Props) {
|
||||
if (vm.isRestricted) return <MatchCardRestrictedState />
|
||||
|
||||
const { currentUser } = useSessionStore()
|
||||
@@ -32,18 +30,20 @@ export const MatchCardCompact = memo(function MatchCardCompact({ vm, imageUrl, o
|
||||
|
||||
const leftAccent = vm.isCompareSelected ? '#5b3f8a' : vm.isSelected ? '#152642' : 'transparent'
|
||||
|
||||
const extraChips: string[] = []
|
||||
if (vm.availabilityLabel) extraChips.push(vm.availabilityLabel)
|
||||
if (vm.fitOutLabel) extraChips.push(vm.fitOutLabel)
|
||||
if (vm.isDivisible && vm.minDivisibleUnitSqm) extraChips.push(`Teilbar ab ${vm.minDivisibleUnitSqm} m²`)
|
||||
if (topRisk) extraChips.push(topRisk.level === 'CRITICAL' ? 'Kritisch' : 'Hohes Risiko')
|
||||
const visibleExtra = extraChips.slice(0, 2)
|
||||
const hiddenExtra = extraChips.slice(2)
|
||||
// Compact metric line — area · rent · availability
|
||||
const metricParts: string[] = []
|
||||
if (vm.areaSqm) metricParts.push(`${vm.areaSqm.toLocaleString('de-CH')} m²`)
|
||||
if (vm.rentPerSqm) metricParts.push(`CHF ${vm.rentPerSqm}/m²/J`)
|
||||
if (vm.availabilityLabel) metricParts.push(vm.availabilityLabel)
|
||||
const metricLine = metricParts.join(' · ')
|
||||
|
||||
// Detail action — prefer the "Details →" action if available, fallback to first action
|
||||
const detailAction = vm.actions.find(a => a.actionType === 'OPEN_DETAIL' && a.label.includes('Detail'))
|
||||
?? vm.actions.find(a => a.actionType === 'OPEN_DETAIL')
|
||||
|
||||
return (
|
||||
<Box sx={{
|
||||
display: 'flex',
|
||||
mb: 1.5,
|
||||
borderRadius: 2,
|
||||
overflow: 'hidden',
|
||||
border: `1px solid ${DS_BORDER.default}`,
|
||||
@@ -51,12 +51,19 @@ export const MatchCardCompact = memo(function MatchCardCompact({ vm, imageUrl, o
|
||||
bgcolor: '#ffffff',
|
||||
boxShadow: '0 1px 3px rgba(0,0,0,0.06)',
|
||||
opacity: vm.isStaleData ? 0.75 : 1,
|
||||
transition: 'border-color 0.15s',
|
||||
'&:hover': { borderColor: '#c8c7c4' },
|
||||
transition: 'border-color 0.15s, transform 0.18s ease, box-shadow 0.18s ease',
|
||||
position: 'relative',
|
||||
zIndex: 0,
|
||||
'&:hover': {
|
||||
borderColor: '#b0aead',
|
||||
transform: 'scale(1.012)',
|
||||
boxShadow: '0 6px 20px rgba(0,0,0,0.10)',
|
||||
zIndex: 1,
|
||||
},
|
||||
}}>
|
||||
|
||||
{/* ── Image strip ── */}
|
||||
<Box sx={{ width: 120, flexShrink: 0, position: 'relative', bgcolor: '#f4f3f0' }}>
|
||||
{/* ── Image strip — 180px, Ginesta proportion ── */}
|
||||
<Box sx={{ width: 180, flexShrink: 0, position: 'relative', bgcolor: '#f4f3f0' }}>
|
||||
{imageUrl ? (
|
||||
<img
|
||||
src={imageUrl}
|
||||
@@ -73,7 +80,7 @@ export const MatchCardCompact = memo(function MatchCardCompact({ vm, imageUrl, o
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Score badge */}
|
||||
{/* Score badge — top left */}
|
||||
<Box sx={{
|
||||
position: 'absolute', top: 8, left: 8,
|
||||
bgcolor: scoreBadgeBg, borderRadius: '6px',
|
||||
@@ -81,58 +88,50 @@ export const MatchCardCompact = memo(function MatchCardCompact({ vm, imageUrl, o
|
||||
border: `1px solid ${SCORE_THEME[tier].border}`,
|
||||
}}>
|
||||
<Typography sx={{
|
||||
fontFamily: '"DM Serif Display", serif',
|
||||
fontWeight: 400, fontSize: '1.125rem',
|
||||
color: '#ffffff', lineHeight: 1,
|
||||
fontWeight: 700, fontSize: '1.125rem',
|
||||
color: '#ffffff', lineHeight: 1, letterSpacing: '-0.01em',
|
||||
}}>
|
||||
{vm.matchScore}%
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{overlayTypeLabel && (
|
||||
{/* Location pill — Ginesta-style, top right */}
|
||||
<Box sx={{
|
||||
position: 'absolute', bottom: 0, left: 0, right: 0,
|
||||
px: 0.75, py: 0.5,
|
||||
background: 'linear-gradient(to top, rgba(0,0,0,0.55) 0%, transparent 100%)',
|
||||
position: 'absolute', top: 8, right: 8,
|
||||
bgcolor: 'rgba(255,255,255,0.92)',
|
||||
borderRadius: '20px',
|
||||
px: 1, py: 0.35,
|
||||
display: 'flex', alignItems: 'center', gap: 0.4,
|
||||
backdropFilter: 'blur(4px)',
|
||||
}}>
|
||||
<Typography sx={{ fontSize: '0.6rem', fontWeight: 600, color: 'white', lineHeight: 1.3 }}>
|
||||
{overlayTypeLabel}
|
||||
<MapPin size={9} color="#64748b" />
|
||||
<Typography sx={{ fontSize: '0.65rem', fontWeight: 500, color: '#374151', lineHeight: 1 }}>
|
||||
{vm.locationLabel}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* ── Content ── */}
|
||||
<Box sx={{ flex: 1, p: 2, display: 'flex', flexDirection: 'column', minWidth: 0 }}>
|
||||
<Box sx={{ flex: 1, px: 2, py: 2.5, display: 'flex', flexDirection: 'column', minWidth: 0 }}>
|
||||
|
||||
{vm.disclaimer && (
|
||||
<Alert severity="warning" sx={{ mb: 1, py: 0.25, fontSize: '0.72rem' }}>{vm.disclaimer}</Alert>
|
||||
)}
|
||||
|
||||
{/* Badges row */}
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5, alignItems: 'center', mb: 0.75 }}>
|
||||
<HeatBadge propertyId={vm.propertyId} size="sm" />
|
||||
{/* Chips — result type + heat + confidence */}
|
||||
<Box sx={{ display: 'flex', gap: 0.5, alignItems: 'center', mb: 0.875 }}>
|
||||
<Chip label={rt.label} size="small"
|
||||
sx={{ bgcolor: rt.color, color: 'white', fontWeight: 600, fontSize: 10, height: 20 }} />
|
||||
{vm.resultType === 'VERIFIED_PORTFOLIO' && isPortfolioOwner && (
|
||||
<Chip icon={<Building2 size={9} color="#152642" />} label="Ihr Objekt" size="small"
|
||||
sx={{ bgcolor: 'rgba(21,38,66,0.10)', color: '#152642', fontWeight: 700, fontSize: 9, height: 18, '& .MuiChip-icon': { ml: 0.5 } }} />
|
||||
)}
|
||||
<HeatBadge propertyId={vm.propertyId} size="sm" />
|
||||
<Chip label={`${confPct}% Konfidenz`} size="small"
|
||||
sx={{ bgcolor: confidenceHex(vm.confidenceScore), color: 'white', fontSize: 10, height: 20 }} />
|
||||
{visibleExtra.map((label, i) => (
|
||||
<Chip key={i} label={label} size="small" variant="outlined"
|
||||
sx={{ fontSize: 10, height: 20, borderColor: DS_BORDER.default, color: DS_TEXT.secondary }} />
|
||||
))}
|
||||
{hiddenExtra.length > 0 && (
|
||||
<Tooltip title={hiddenExtra.join(' · ')} arrow>
|
||||
<Chip label={`+${hiddenExtra.length}`} size="small" variant="outlined"
|
||||
sx={{ fontSize: 10, height: 20, borderColor: DS_BORDER.default, color: DS_TEXT.muted }} />
|
||||
</Tooltip>
|
||||
{vm.resultType === 'VERIFIED_PORTFOLIO' && isPortfolioOwner && (
|
||||
<Chip label="Ihr Objekt" size="small"
|
||||
sx={{ bgcolor: 'rgba(21,38,66,0.10)', color: '#152642', fontWeight: 700, fontSize: 9, height: 18 }} />
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Title + location */}
|
||||
{/* Title */}
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, lineHeight: 1.3, color: DS_TEXT.primary }} noWrap>
|
||||
{vm.title}
|
||||
</Typography>
|
||||
@@ -141,40 +140,25 @@ export const MatchCardCompact = memo(function MatchCardCompact({ vm, imageUrl, o
|
||||
{vm.propertySubtitle}
|
||||
</Typography>
|
||||
)}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.4, mt: 0.25 }}>
|
||||
<MapPin size={11} color="#9a9a9a" />
|
||||
<Typography variant="caption" sx={{ color: DS_TEXT.muted }}>
|
||||
{vm.locationLabel}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Metric line — Ginesta-style */}
|
||||
{metricLine && (
|
||||
<Typography sx={{ fontSize: '0.75rem', color: '#94a3b8', mt: 0.5, letterSpacing: 0.1 }}>
|
||||
{metricLine}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{/* Explainability summary — 1 line */}
|
||||
{vm.explainabilitySummary && (
|
||||
<Typography variant="body2" sx={{ color: DS_TEXT.secondary, fontWeight: 500, mt: 0.75, lineHeight: 1.5,
|
||||
overflow: 'hidden', display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical' }}>
|
||||
<Typography variant="body2" sx={{
|
||||
color: DS_TEXT.secondary, fontWeight: 500, mt: 0.875, lineHeight: 1.5,
|
||||
overflow: 'hidden', display: '-webkit-box', WebkitLineClamp: 1, WebkitBoxOrient: 'vertical',
|
||||
}}>
|
||||
{vm.explainabilitySummary}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{vm.reasons.length > 0 && (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.35, mt: 0.75 }}>
|
||||
{vm.reasons.slice(0, 2).map((r, i) => (
|
||||
<Box key={i} sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||
<CheckCircle2 size={11} color="#1d6342" style={{ flexShrink: 0 }} />
|
||||
<Typography sx={{ fontSize: '0.72rem', color: DS_TEXT.secondary, lineHeight: 1.3 }}>
|
||||
{r.label}
|
||||
</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{vm.scoreBreakdown && vm.allFactors && vm.allFactors.length > 0 && (
|
||||
<>
|
||||
<Divider sx={{ my: 0.75 }} />
|
||||
<ScoreInlineBreakdown scoreBreakdown={vm.scoreBreakdown} allFactors={vm.allFactors} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Critical risk only */}
|
||||
{topRisk && topRisk.level === 'CRITICAL' && (
|
||||
<Alert icon={<AlertTriangle size={13} />} severity="error"
|
||||
sx={{ mt: 0.75, py: 0.25, fontSize: '0.72rem' }}>
|
||||
@@ -182,8 +166,8 @@ export const MatchCardCompact = memo(function MatchCardCompact({ vm, imageUrl, o
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<Box sx={{ display: 'flex', gap: 0.625, mt: 'auto', pt: 1, flexWrap: 'wrap' }}>
|
||||
{/* 2 Buttons — Anfrage + Details */}
|
||||
<Box sx={{ display: 'flex', gap: 0.625, mt: 'auto', pt: 1.25 }}>
|
||||
<Button
|
||||
size="small"
|
||||
variant="contained"
|
||||
@@ -201,18 +185,16 @@ export const MatchCardCompact = memo(function MatchCardCompact({ vm, imageUrl, o
|
||||
>
|
||||
Anfrage
|
||||
</Button>
|
||||
{vm.actions.map(a => (
|
||||
{detailAction && (
|
||||
<Button
|
||||
key={a.id}
|
||||
size="small"
|
||||
variant={a.variant === 'primary' ? 'contained' : 'outlined'}
|
||||
onClick={a.onClick}
|
||||
disabled={a.disabled}
|
||||
variant="outlined"
|
||||
onClick={detailAction.onClick}
|
||||
sx={{ textTransform: 'none', fontSize: '0.7rem', py: 0.375, px: 1 }}
|
||||
>
|
||||
{a.label}
|
||||
Details →
|
||||
</Button>
|
||||
))}
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
@@ -86,6 +86,10 @@ export interface MatchCardViewModel {
|
||||
preMarketUnit?: PropertyUnit // specific unit being released pre-market
|
||||
preMarketAllUnits?: PropertyUnit[] // all units of the backing property
|
||||
|
||||
// Key metrics for compact display (Ginesta-style metric line)
|
||||
areaSqm?: number
|
||||
rentPerSqm?: number
|
||||
|
||||
// Score transparency
|
||||
scoreBreakdown?: {
|
||||
hardMatchScore: number
|
||||
|
||||
@@ -125,16 +125,6 @@ export function UnifiedResultCard({ result, view = 'list' }: Props) {
|
||||
const listImageUrl = result.resultType !== 'FUTURE_AVAILABILITY'
|
||||
? result.property.images?.[0]
|
||||
: undefined
|
||||
const listOverlay = result.resultType !== 'FUTURE_AVAILABILITY'
|
||||
? resolveImageLabel({
|
||||
assetType: result.property.assetType,
|
||||
city: result.property.location.city,
|
||||
district: result.property.location.district,
|
||||
prestige: result.property.softFactors?.prestige,
|
||||
floorLevel: result.property.floorLevel,
|
||||
propertyId: result.property.id,
|
||||
})
|
||||
: null
|
||||
|
||||
return <MatchCardCompact vm={vm} imageUrl={listImageUrl} overlayTypeLabel={listOverlay?.type} />
|
||||
return <MatchCardCompact vm={vm} imageUrl={listImageUrl} />
|
||||
}
|
||||
|
||||
@@ -86,7 +86,7 @@ export function UnifiedResultFeed({ results, view = 'list' }: Props) {
|
||||
))}
|
||||
</Box>
|
||||
) : (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
{items.map(result => (
|
||||
<UnifiedResultCard key={result.matchId} result={result} view="list" />
|
||||
))}
|
||||
|
||||
@@ -20,13 +20,13 @@ export function DashboardHeader({ orgName = 'Demo Organisation', lastUpdated }:
|
||||
return (
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.5 }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 600 }}>
|
||||
<Typography sx={{ fontWeight: 700, fontSize: '1.125rem', lineHeight: 1.3, color: '#0f172a' }}>
|
||||
{orgName}
|
||||
</Typography>
|
||||
<Chip label="Demo" size="small" color="info" variant="outlined" />
|
||||
</Box>
|
||||
{lastUpdated && (
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Zuletzt aktualisiert: {formatLastUpdated(lastUpdated)}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
@@ -43,19 +43,26 @@ export const PropertyIntelligenceCard = memo(function PropertyIntelligenceCard({
|
||||
<Box
|
||||
onClick={() => onSelect(p.id)}
|
||||
sx={{
|
||||
borderRadius: 1,
|
||||
borderRadius: 2,
|
||||
overflow: 'hidden',
|
||||
border: `1px solid ${DS_BORDER.default}`,
|
||||
bgcolor: '#ffffff',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
cursor: 'pointer',
|
||||
transition: 'border-color 0.15s',
|
||||
'&:hover': { borderColor: '#c8c7c4' },
|
||||
position: 'relative',
|
||||
zIndex: 0,
|
||||
transition: 'transform 0.18s ease, box-shadow 0.18s ease, border-color 0.15s',
|
||||
'&:hover': {
|
||||
borderColor: '#b0aead',
|
||||
transform: 'scale(1.025)',
|
||||
boxShadow: '0 10px 32px rgba(0,0,0,0.13)',
|
||||
zIndex: 1,
|
||||
},
|
||||
}}
|
||||
>
|
||||
{/* ── Image — clean, no text overlay ── */}
|
||||
<Box sx={{ position: 'relative', height: 150, flexShrink: 0, bgcolor: '#f4f3f0', overflow: 'hidden' }}>
|
||||
<Box sx={{ position: 'relative', aspectRatio: '15/8', flexShrink: 0, bgcolor: '#f4f3f0', overflow: 'hidden' }}>
|
||||
{hasImage ? (
|
||||
<Box component="img" src={p.images![0]} alt={p.title}
|
||||
sx={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }} />
|
||||
@@ -78,24 +85,24 @@ export const PropertyIntelligenceCard = memo(function PropertyIntelligenceCard({
|
||||
</Box>
|
||||
|
||||
{/* ── Body ── */}
|
||||
<Box sx={{ px: 1.75, pt: 1.5, pb: 1.75, display: 'flex', flexDirection: 'column', gap: 1.125, flex: 1 }}>
|
||||
<Box sx={{ px: 2.5, pt: 2.25, pb: 2.5, display: 'flex', flexDirection: 'column', gap: 2, flex: 1 }}>
|
||||
|
||||
{/* Title + location + status inline */}
|
||||
<Box>
|
||||
<Typography sx={{ fontWeight: 600, fontSize: '0.9375rem', color: DS_TEXT.primary, lineHeight: 1.3 }} noWrap>
|
||||
<Typography sx={{ fontWeight: 600, fontSize: '1.0625rem', color: DS_TEXT.primary, lineHeight: 1.3 }} noWrap>
|
||||
{p.title}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mt: 0.4 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mt: 0.6 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.4 }}>
|
||||
<MapPin size={11} color="#9a9a9a" />
|
||||
<Typography sx={{ fontSize: '0.75rem', color: DS_TEXT.muted }}>
|
||||
<MapPin size={12} color="#9a9a9a" />
|
||||
<Typography sx={{ fontSize: '0.8125rem', color: DS_TEXT.muted }}>
|
||||
{p.location.city}{p.location.district ? ` · ${p.location.district}` : ''}
|
||||
</Typography>
|
||||
</Box>
|
||||
{/* Status — plain dot + text, no pill container */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||
<Box sx={{ width: 6, height: 6, borderRadius: '50%', bgcolor: status.dot, flexShrink: 0 }} />
|
||||
<Typography sx={{ fontSize: '0.72rem', fontWeight: 500, color: status.text, lineHeight: 1 }}>
|
||||
<Box sx={{ width: 7, height: 7, borderRadius: '50%', bgcolor: status.dot, flexShrink: 0 }} />
|
||||
<Typography sx={{ fontSize: '0.8rem', fontWeight: 500, color: status.text, lineHeight: 1 }}>
|
||||
{status.label}
|
||||
{p.availabilityStatus === 'AVAILABLE_SOON' && availDate ? ` · ab ${availDate}` : ''}
|
||||
</Typography>
|
||||
@@ -104,62 +111,58 @@ export const PropertyIntelligenceCard = memo(function PropertyIntelligenceCard({
|
||||
</Box>
|
||||
|
||||
{/* 3 key metrics — no boxes, whitespace only */}
|
||||
<Box sx={{ display: 'flex', gap: 2 }}>
|
||||
<Box sx={{ display: 'flex', gap: 2.5 }}>
|
||||
<Box>
|
||||
<Typography sx={{ fontSize: '0.68rem', color: DS_TEXT.muted, mb: 0.25 }}>Fläche</Typography>
|
||||
<Typography sx={{ fontWeight: 600, fontSize: '0.875rem', color: DS_TEXT.primary }}>
|
||||
<Typography sx={{ fontSize: '0.75rem', color: DS_TEXT.muted, mb: 0.35 }}>Fläche</Typography>
|
||||
<Typography sx={{ fontWeight: 600, fontSize: '1rem', color: DS_TEXT.primary }}>
|
||||
{p.areaSqm.toLocaleString('de-CH')} m²
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Typography sx={{ fontSize: '0.68rem', color: DS_TEXT.muted, mb: 0.25 }}>Miete</Typography>
|
||||
<Typography sx={{ fontWeight: 600, fontSize: '0.875rem', color: DS_TEXT.primary }}>
|
||||
<Typography sx={{ fontSize: '0.75rem', color: DS_TEXT.muted, mb: 0.35 }}>Miete</Typography>
|
||||
<Typography sx={{ fontWeight: 600, fontSize: '1rem', color: DS_TEXT.primary }}>
|
||||
{p.rentPricePerSqm}
|
||||
<Typography component="span" sx={{ fontSize: '0.62rem', fontWeight: 400, color: DS_TEXT.muted }}> CHF/m²/J</Typography>
|
||||
<Typography component="span" sx={{ fontSize: '0.7rem', fontWeight: 400, color: DS_TEXT.muted }}> CHF/m²/J</Typography>
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Tooltip title={hasInquiries ? `${inquiryCount} aktive Anfragen` : 'Noch keine Anfragen'} placement="top" arrow>
|
||||
<Box>
|
||||
<Typography sx={{ fontSize: '0.68rem', color: DS_TEXT.muted, mb: 0.25 }}>Anfragen</Typography>
|
||||
<Typography sx={{ fontWeight: 600, fontSize: '0.875rem', color: hasInquiries ? '#a16207' : DS_TEXT.muted }}>
|
||||
<Typography sx={{ fontSize: '0.75rem', color: DS_TEXT.muted, mb: 0.35 }}>Anfragen</Typography>
|
||||
<Typography sx={{ fontWeight: 600, fontSize: '1rem', color: hasInquiries ? '#a16207' : DS_TEXT.muted }}>
|
||||
{inquiryCount ?? 0}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
|
||||
{/* Property spec chips */}
|
||||
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap' }}>
|
||||
{/* Chips + Datenvollständigkeit in einer Zeile */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1, mt: 'auto' }}>
|
||||
<Box sx={{ display: 'flex', gap: 0.75, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
{p.hardFacts?.fitOut && (() => {
|
||||
const meta = FIT_OUT_META[p.hardFacts!.fitOut!] ?? { label: p.hardFacts!.fitOut!, tip: '' }
|
||||
return (
|
||||
<Tooltip title={meta.tip} placement="top" arrow>
|
||||
<Chip label={meta.label} size="small"
|
||||
sx={{ height: 20, fontSize: '0.68rem', bgcolor: '#f4f3f0', color: DS_TEXT.secondary, border: `1px solid ${DS_BORDER.default}`, cursor: 'help' }} />
|
||||
sx={{ height: 22, fontSize: '0.73rem', bgcolor: '#f4f3f0', color: DS_TEXT.secondary, border: `1px solid ${DS_BORDER.default}`, cursor: 'help' }} />
|
||||
</Tooltip>
|
||||
)
|
||||
})()}
|
||||
{p.contractDurationMonths && (
|
||||
<Chip label={`${p.contractDurationMonths}M Vertrag`} size="small"
|
||||
sx={{ height: 20, fontSize: '0.68rem', bgcolor: '#f4f3f0', color: DS_TEXT.secondary, border: `1px solid ${DS_BORDER.default}` }} />
|
||||
sx={{ height: 22, fontSize: '0.73rem', bgcolor: '#f4f3f0', color: DS_TEXT.secondary, border: `1px solid ${DS_BORDER.default}` }} />
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Data completeness */}
|
||||
<Box sx={{ mt: 'auto' }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 0.5 }}>
|
||||
<Typography sx={{ fontSize: '0.62rem', color: DS_TEXT.disabled, letterSpacing: '0.03em' }}>
|
||||
Datenvollständigkeit
|
||||
</Typography>
|
||||
<Typography sx={{ fontSize: '0.75rem', fontWeight: 700, color: p.confidenceScore >= 0.75 ? '#b8975a' : DS_TEXT.muted }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, flexShrink: 0 }}>
|
||||
<Typography sx={{ fontSize: '0.68rem', color: DS_TEXT.disabled }}>Daten</Typography>
|
||||
<LinearProgress variant="determinate" value={confPct}
|
||||
sx={{ width: 44, height: 3, borderRadius: 1, bgcolor: '#f4f3f0',
|
||||
'& .MuiLinearProgress-bar': { bgcolor: '#152642', borderRadius: 1 } }} />
|
||||
<Typography sx={{ fontSize: '0.78rem', fontWeight: 700, color: p.confidenceScore >= 0.75 ? '#b8975a' : DS_TEXT.muted }}>
|
||||
{confPct}%
|
||||
</Typography>
|
||||
</Box>
|
||||
<LinearProgress variant="determinate" value={confPct}
|
||||
sx={{ height: 3, borderRadius: 1, bgcolor: '#f4f3f0',
|
||||
'& .MuiLinearProgress-bar': { bgcolor: '#152642', borderRadius: 1 } }} />
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
@@ -14,17 +14,16 @@ export function ReminderHeader() {
|
||||
<Box
|
||||
sx={{
|
||||
bgcolor: 'white',
|
||||
borderBottom: '1px solid',
|
||||
borderColor: 'divider',
|
||||
borderBottom: '1px solid #e8e7e4',
|
||||
px: 3,
|
||||
py: 2,
|
||||
py: 2.5,
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
justifyContent: 'space-between',
|
||||
}}
|
||||
>
|
||||
<Box>
|
||||
<Typography variant="h5" sx={{ fontWeight: 700, color: '#1e293b' }}>
|
||||
<Typography sx={{ fontWeight: 700, fontSize: '1.125rem', lineHeight: 1.3, color: '#0f172a' }}>
|
||||
Reminder Manager
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
|
||||
@@ -208,8 +208,8 @@ export default function AISearch() {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
||||
{/* Header */}
|
||||
<Box sx={{ bgcolor: 'white', borderBottom: '1px solid #e2e8f0', px: 3, py: 2, flexShrink: 0 }}>
|
||||
<Typography variant="h5" sx={{ fontWeight: 700 }}>Flächensuche</Typography>
|
||||
<Box sx={{ bgcolor: 'white', borderBottom: '1px solid #e8e7e4', px: 3, py: 2.5, flexShrink: 0 }}>
|
||||
<Typography sx={{ fontWeight: 700, fontSize: '1.125rem', lineHeight: 1.3, color: '#0f172a' }}>Flächensuche</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Sprechen, schreiben oder Felder ausfüllen — dann sofort suchen oder als Suchprofil speichern
|
||||
</Typography>
|
||||
|
||||
@@ -49,8 +49,8 @@ export default function Compare() {
|
||||
if (compareItems.length === 0) {
|
||||
return (
|
||||
<Box>
|
||||
<Box sx={{ bgcolor: 'white', borderBottom: '1px solid #e2e8f0', px: 3, py: 2 }}>
|
||||
<Typography variant="h5" sx={{ fontWeight: 700 }}>Vergleich</Typography>
|
||||
<Box sx={{ bgcolor: 'white', borderBottom: '1px solid #e8e7e4', px: 3, py: 2.5 }}>
|
||||
<Typography sx={{ fontWeight: 700, fontSize: '1.125rem', lineHeight: 1.3, color: '#0f172a' }}>Vergleich</Typography>
|
||||
</Box>
|
||||
<CompareEmptyState />
|
||||
</Box>
|
||||
@@ -62,9 +62,9 @@ export default function Compare() {
|
||||
<AddToPipelineDialog />
|
||||
|
||||
{/* Page header */}
|
||||
<Box sx={{ bgcolor: 'white', borderBottom: '1px solid #e2e8f0', px: 3, py: 2, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Box sx={{ bgcolor: 'white', borderBottom: '1px solid #e8e7e4', px: 3, py: 2.5, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<Typography variant="h5" sx={{ fontWeight: 700 }}>Vergleich</Typography>
|
||||
<Typography sx={{ fontWeight: 700, fontSize: '1.125rem', lineHeight: 1.3, color: '#0f172a' }}>Vergleich</Typography>
|
||||
<Chip label={`${compareItems.length} Ergebnisse`} size="small" />
|
||||
</Box>
|
||||
<Button variant="outlined" size="small" color="error" onClick={clearCompare}>
|
||||
|
||||
@@ -73,11 +73,11 @@ export default function Pipeline() {
|
||||
|
||||
{/* Header */}
|
||||
<Box sx={{
|
||||
bgcolor: 'white', borderBottom: '1px solid #e2e8f0',
|
||||
px: 3, py: 2, display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexShrink: 0,
|
||||
bgcolor: 'white', borderBottom: '1px solid #e8e7e4',
|
||||
px: 3, py: 2.5, display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexShrink: 0,
|
||||
}}>
|
||||
<Box>
|
||||
<Typography variant="h5" sx={{ fontWeight: 700 }}>Deal Pipeline</Typography>
|
||||
<Typography sx={{ fontWeight: 700, fontSize: '1.125rem', lineHeight: 1.3, color: '#0f172a' }}>Deal Pipeline</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Von der ersten Idee bis zum Abschluss — per Drag & Drop verschieben
|
||||
</Typography>
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { useState, useMemo, useEffect } from 'react'
|
||||
import { Box, Button, Card, Chip, Typography } from '@mui/material'
|
||||
import { Box, Button, Typography } from '@mui/material'
|
||||
import { Zap } from 'lucide-react'
|
||||
import { useNavigate, useLocation } from 'react-router'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { useUnifiedResults } from '../../hooks/useUnifiedResults'
|
||||
import { useNeeds } from '../../hooks/useNeeds'
|
||||
import { DecisionContextPanel } from '../../components/ui'
|
||||
import {
|
||||
FeedEmptyState,
|
||||
FeedSkeleton,
|
||||
@@ -72,16 +71,14 @@ export default function Results() {
|
||||
const isStaff = currentUser?.role === 'PROPERTY_MANAGER' || currentUser?.role === 'ORGANIZATION_ADMIN'
|
||||
|
||||
// Counts over the full result set — single pass, stable across filter changes
|
||||
const { platformCount, maisonWorkCount, futureCount, missingDataCount } = useMemo(() => {
|
||||
let platform = 0, maison = 0, future = 0, missing = 0
|
||||
const { platformCount, maisonWorkCount, futureCount } = useMemo(() => {
|
||||
let platform = 0, maison = 0, future = 0
|
||||
for (const r of results) {
|
||||
if (r.resultType === 'VERIFIED_PORTFOLIO') platform++
|
||||
else if (r.resultType === 'MAISON_WORK') maison++
|
||||
else if (r.resultType === 'FUTURE_AVAILABILITY') future++
|
||||
if ('match' in r && Array.isArray((r as { match?: { missingData?: unknown[] } }).match?.missingData) &&
|
||||
((r as { match?: { missingData?: unknown[] } }).match?.missingData?.length ?? 0) > 0) missing++
|
||||
}
|
||||
return { platformCount: platform, maisonWorkCount: maison, futureCount: future, missingDataCount: missing }
|
||||
return { platformCount: platform, maisonWorkCount: maison, futureCount: future }
|
||||
}, [results])
|
||||
|
||||
// Filter + sort in one memo — only reruns when inputs change
|
||||
@@ -98,7 +95,6 @@ export default function Results() {
|
||||
return sortResults(filtered, sortBy)
|
||||
}, [results, filterSource, sortBy, showFutureAvailability, showOwnProperties])
|
||||
|
||||
const strongCount = useMemo(() => sorted.filter(r => r.matchScore >= 80).length, [sorted])
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
||||
@@ -113,103 +109,27 @@ export default function Results() {
|
||||
onViewChange={v => { setView(v); localStorage.setItem('view-results', v) }}
|
||||
/>
|
||||
|
||||
{!isLoading && results.length > 0 && (
|
||||
<DecisionContextPanel
|
||||
decision="Welche Treffer lohnen sich für die Shortlist — und welche tragen Risiken, die zuerst geprüft werden müssen?"
|
||||
context={activeNeed ? `Suche: ${activeNeed.assetType} · ${activeNeed.requiredArea.min}–${activeNeed.requiredArea.max} m² · ${activeNeed.preferredLocations.join(', ')}` : undefined}
|
||||
metrics={[
|
||||
...(strongCount > 0 ? [{ label: 'starke Treffer (≥80)', value: strongCount, severity: 'positive' as const }] : []),
|
||||
...(maisonWorkCount > 0 ? [{ label: 'Maison Work', value: maisonWorkCount, severity: 'neutral' as const }] : []),
|
||||
...(futureCount > 0 ? [{ label: 'Zukunftssignale', value: futureCount, severity: 'neutral' as const }] : []),
|
||||
...(missingDataCount > 0 ? [{ label: 'mit Datenlücken', value: missingDataCount, severity: 'warning' as const }] : []),
|
||||
]}
|
||||
risks={[
|
||||
...(missingDataCount > 0 ? [`${missingDataCount} Treffer mit fehlenden Daten — Einschätzung eingeschränkt`] : []),
|
||||
]}
|
||||
actions={[
|
||||
{ label: 'Vergleich öffnen', onClick: () => navigate('/demand/compare') },
|
||||
{ label: 'Suche anpassen', onClick: () => navigate('/demand/ai-search') },
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Box sx={{ flex: 1, overflowY: 'auto', px: 3, py: 3, pb: 10 }}>
|
||||
|
||||
{/* Aktive Suche */}
|
||||
{activeNeed && (
|
||||
<Card sx={{ bgcolor: '#eff6ff', p: 2, mb: 2, border: '1px solid #bfdbfe' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 2 }}>
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 0.5 }} color="#152642">
|
||||
Aktive Suche: {activeNeed.companyName}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: '4px 16px' }}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
<strong>Typ:</strong> {activeNeed.assetType}
|
||||
</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 && activeNeed.budgetRange.maxPerSqm > 0 && (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
<strong>Budget:</strong> max. CHF {activeNeed.budgetRange.maxPerSqm}/m²/Jahr
|
||||
</Typography>
|
||||
)}
|
||||
{activeNeed.timing?.earliestMoveIn && !isNaN(new Date(activeNeed.timing.earliestMoveIn).getTime()) && (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
<strong>Bezug ab:</strong> {new Date(activeNeed.timing.earliestMoveIn).toLocaleDateString('de-CH', { month: 'long', year: 'numeric' })}
|
||||
</Typography>
|
||||
)}
|
||||
{activeNeed.searchRadius && (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
<strong>Radius:</strong> {activeNeed.searchRadius} km
|
||||
</Typography>
|
||||
)}
|
||||
{activeNeed.requiresDivisibility && activeNeed.minDivisibleUnit && (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
<strong>Teilbar ab:</strong> {activeNeed.minDivisibleUnit} m²
|
||||
</Typography>
|
||||
)}
|
||||
{activeNeed.requiredFitOut && (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
<strong>Ausbaustandard:</strong> min. {
|
||||
activeNeed.requiredFitOut === 'BASIC' ? 'Basisausbau' :
|
||||
activeNeed.requiredFitOut === 'FULL' ? 'Vollausbau' : 'Premiumausbau'
|
||||
}
|
||||
</Typography>
|
||||
)}
|
||||
{activeNeed.fitOutBudgetMaxPerSqm && (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
<strong>Ausbaubudget:</strong> max. CHF {activeNeed.fitOutBudgetMaxPerSqm}/m²
|
||||
</Typography>
|
||||
)}
|
||||
{activeNeed.mustCriteriaText && activeNeed.mustCriteriaText.length > 0 && (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
<strong>Must-haves:</strong> {activeNeed.mustCriteriaText.slice(0, 3).join(' · ')}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 2, bgcolor: '#eff6ff', border: '1px solid #bfdbfe', borderRadius: 1.5, px: 2, py: 1.25, mb: 2 }}>
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: '4px 16px', alignItems: 'center' }}>
|
||||
<Typography variant="caption" sx={{ fontWeight: 700, color: '#152642' }}>
|
||||
{activeNeed.companyName}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">{activeNeed.assetType}</Typography>
|
||||
<Typography variant="caption" color="text.secondary">{activeNeed.requiredArea.min}–{activeNeed.requiredArea.max} m²</Typography>
|
||||
<Typography variant="caption" color="text.secondary">{activeNeed.preferredLocations.join(', ')}</Typography>
|
||||
{activeNeed.budgetRange?.maxPerSqm > 0 && (
|
||||
<Typography variant="caption" color="text.secondary">max. CHF {activeNeed.budgetRange.maxPerSqm}/m²/J</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexShrink: 0 }}>
|
||||
{activeNeed.isAnonymous && (
|
||||
<Chip
|
||||
label="Anonyme Suche"
|
||||
size="small"
|
||||
sx={{ bgcolor: '#7c3aed', color: 'white', fontWeight: 700, fontSize: 10, height: 20 }}
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
size="small"
|
||||
variant="text"
|
||||
onClick={() => navigate('/demand/ai-search')}
|
||||
sx={{ color: '#152642', flexShrink: 0 }}
|
||||
>
|
||||
<Button size="small" variant="text" onClick={() => navigate('/demand/ai-search')}
|
||||
sx={{ color: '#152642', flexShrink: 0, textTransform: 'none', fontSize: '0.75rem' }}>
|
||||
Suche ändern
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<ResultFilterBar
|
||||
|
||||
@@ -9,8 +9,8 @@ export default function Anfragencenter() {
|
||||
const [tab, setTab] = useState(initialTab)
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
||||
<Box sx={{ borderBottom: '1px solid #e2e8f0', px: 3, bgcolor: 'white', flexShrink: 0 }}>
|
||||
<Typography variant="h5" sx={{ fontWeight: 700, pt: 2.5, pb: 1 }}>
|
||||
<Box sx={{ borderBottom: '1px solid #e8e7e4', px: 3, bgcolor: 'white', flexShrink: 0 }}>
|
||||
<Typography sx={{ fontWeight: 700, fontSize: '1.125rem', lineHeight: 1.3, color: '#0f172a', pt: 2.5, pb: 1, display: 'block' }}>
|
||||
Anfragencenter
|
||||
</Typography>
|
||||
<Tabs
|
||||
|
||||
@@ -263,9 +263,9 @@ export default function DataQuality() {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
||||
{/* Header */}
|
||||
<Box sx={{ px: 3, py: 2, bgcolor: 'white', borderBottom: '1px solid #e2e8f0', flexShrink: 0 }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 700 }}>Datenpflege</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
<Box sx={{ px: 3, py: 2.5, bgcolor: 'white', borderBottom: '1px solid #e8e7e4', flexShrink: 0 }}>
|
||||
<Typography sx={{ fontWeight: 700, fontSize: '1.125rem', lineHeight: 1.3, color: '#0f172a' }}>Datenpflege</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Vollständigkeit, Aktualität und Vertrauen der Objektdaten · Klick auf KPI-Karte filtert die Liste
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
@@ -49,10 +49,10 @@ export default function FutureAvailability() {
|
||||
<AddToShortlistDialog />
|
||||
|
||||
{/* Header */}
|
||||
<Box sx={{ bgcolor: 'white', borderBottom: '1px solid #e2e8f0', px: 3, py: 2, flexShrink: 0 }}>
|
||||
<Box sx={{ bgcolor: 'white', borderBottom: '1px solid #e8e7e4', px: 3, py: 2.5, flexShrink: 0 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 1 }}>
|
||||
<Box>
|
||||
<Typography variant="h5" sx={{ fontWeight: 700 }}>Zukunftssignale</Typography>
|
||||
<Typography sx={{ fontWeight: 700, fontSize: '1.125rem', lineHeight: 1.3, color: '#0f172a' }}>Zukunftssignale</Typography>
|
||||
<Typography variant="body2" color="text.secondary">Probabilistische Markt- und Verfügbarkeitssignale</Typography>
|
||||
</Box>
|
||||
<Chip
|
||||
|
||||
@@ -1046,9 +1046,9 @@ export default function MarketIntelligence() {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
||||
{/* Page header */}
|
||||
<Box sx={{ px: 3, py: 2, bgcolor: 'white', borderBottom: '1px solid #e2e8f0', flexShrink: 0 }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 700, lineHeight: 1.2 }}>Marktchancen</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
<Box sx={{ px: 3, py: 2.5, bgcolor: 'white', borderBottom: '1px solid #e8e7e4', flexShrink: 0 }}>
|
||||
<Typography sx={{ fontWeight: 700, fontSize: '1.125rem', lineHeight: 1.3, color: '#0f172a' }}>Marktchancen</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
KI-Signale aus Web-Quellen und menschliche Netzwerk-Hinweise — mit Portfolio-Match
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
@@ -76,9 +76,9 @@ export default function MatchCenter() {
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', height: 'calc(100vh - 64px)', overflow: 'hidden' }}>
|
||||
|
||||
{/* Header */}
|
||||
<Box sx={{ bgcolor: 'white', borderBottom: '1px solid #e2e8f0', px: 3, py: 2, flexShrink: 0 }}>
|
||||
<Box sx={{ bgcolor: 'white', borderBottom: '1px solid #e8e7e4', px: 3, py: 2.5, flexShrink: 0 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'baseline', gap: 2, mb: 1 }}>
|
||||
<Typography variant="h5" sx={{ fontWeight: 700 }}>Match Center</Typography>
|
||||
<Typography sx={{ fontWeight: 700, fontSize: '1.125rem', lineHeight: 1.3, color: '#0f172a' }}>Match Center</Typography>
|
||||
<Typography variant="body2" color="text.secondary">Automatisch berechnete Matches</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
|
||||
|
||||
@@ -339,10 +339,11 @@ export default function MyListings() {
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ maxWidth: 1040, mx: 'auto', px: 3, py: 4 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 3 }}>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
||||
{/* Header */}
|
||||
<Box sx={{ bgcolor: 'white', borderBottom: '1px solid #e8e7e4', px: 3, py: 2.5, flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Box>
|
||||
<Typography variant="h5" sx={{ fontWeight: 700 }}>Inserate</Typography>
|
||||
<Typography sx={{ fontWeight: 700, fontSize: '1.125rem', lineHeight: 1.3, color: '#0f172a' }}>Inserate</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Direkt erstellte Inserate — unabhängig vom Portfolio
|
||||
</Typography>
|
||||
@@ -357,6 +358,8 @@ export default function MyListings() {
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ flex: 1, overflowY: 'auto', px: 3, py: 3, maxWidth: 1040, width: '100%', mx: 'auto', boxSizing: 'border-box' }}>
|
||||
|
||||
{actionError && (
|
||||
<Alert severity="error" sx={{ mb: 2 }} onClose={() => setActionError(null)}>
|
||||
{actionError}
|
||||
@@ -415,6 +418,8 @@ export default function MyListings() {
|
||||
</Box>
|
||||
)}
|
||||
|
||||
</Box>{/* end scroll container */}
|
||||
|
||||
{/* Detail / edit drawer */}
|
||||
<Drawer
|
||||
anchor="right"
|
||||
|
||||
@@ -46,7 +46,7 @@ export default function NewListing() {
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<Typography variant="h5" sx={{ fontWeight: 700, mb: 0.5 }}>Neues Inserat erstellen</Typography>
|
||||
<Typography sx={{ fontWeight: 700, fontSize: '1.125rem', lineHeight: 1.3, color: '#0f172a', mb: 0.5 }}>Neues Inserat erstellen</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
|
||||
{form.isPrefilled
|
||||
? `Einheit ${pre.unitLabel ?? ''} aus Portfolio vorausgefüllt — Angaben prüfen und veröffentlichen.`
|
||||
|
||||
@@ -57,6 +57,9 @@ export default function Properties() {
|
||||
const [view, setView] = useState<'list' | 'grid'>(() =>
|
||||
(localStorage.getItem('view-properties') as 'list' | 'grid') ?? 'list'
|
||||
)
|
||||
const [columns, setColumns] = useState<3 | 5 | 10>(() =>
|
||||
(Number(localStorage.getItem('view-properties-cols')) as 3 | 5 | 10) || 3
|
||||
)
|
||||
const [headerOpen, setHeaderOpen] = useState(() =>
|
||||
localStorage.getItem('props-header-open') !== 'false'
|
||||
)
|
||||
@@ -109,10 +112,31 @@ export default function Properties() {
|
||||
title="Objektverwaltung"
|
||||
subtitle={`${filtered.length} von ${properties.length} Objekten`}
|
||||
secondaryActions={
|
||||
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
|
||||
{view === 'grid' && (
|
||||
<Box sx={{ display: 'flex', border: '1px solid #e2e8f0', borderRadius: 1, overflow: 'hidden' }}>
|
||||
{([3, 5, 10] as const).map(n => (
|
||||
<Box
|
||||
key={n}
|
||||
onClick={() => { setColumns(n); localStorage.setItem('view-properties-cols', String(n)) }}
|
||||
sx={{
|
||||
width: 32, height: 32, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
cursor: 'pointer', fontSize: '0.72rem', fontWeight: 600,
|
||||
bgcolor: columns === n ? '#152642' : 'transparent',
|
||||
color: columns === n ? 'white' : '#64748b',
|
||||
'&:hover': { bgcolor: columns === n ? '#162d4a' : '#f1f5f9' },
|
||||
}}
|
||||
>
|
||||
{n}
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
<ViewToggle
|
||||
view={view}
|
||||
onChange={v => { setView(v); localStorage.setItem('view-properties', v) }}
|
||||
/>
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -201,7 +225,7 @@ export default function Properties() {
|
||||
|
||||
<Box sx={{ flex: 1, overflowY: 'auto' }}>
|
||||
{view === 'grid' ? (
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(320px, 1fr))', gap: 2, p: 2.5 }}>
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: `repeat(${columns}, 1fr)`, gap: 3, p: 3 }}>
|
||||
{filtered.map(p => (
|
||||
<PropertyIntelligenceCard key={p.id} property={p} onSelect={setSelectedId} inquiryCount={inquiryCountByProperty.get(p.id) ?? 0} />
|
||||
))}
|
||||
|
||||
Reference in New Issue
Block a user