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(git pull *)",
|
||||||
"Bash(node scripts/check-tokens.js)",
|
"Bash(node scripts/check-tokens.js)",
|
||||||
"Bash(echo \"EXIT:$?\")",
|
"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(git commit *)",
|
||||||
"Bash(node -e ' *)",
|
"Bash(node -e ' *)",
|
||||||
"Bash(git stash *)",
|
"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)}
|
{formatInquiryDate(inquiry.createdAt)}
|
||||||
</Typography>
|
</Typography>
|
||||||
{inquiry.matchScore !== undefined && (
|
{inquiry.matchScore !== undefined && (
|
||||||
<Box
|
<Typography sx={{ fontSize: '0.75rem', fontWeight: 700, color: '#152642' }}>
|
||||||
sx={{
|
{inquiry.matchScore}%
|
||||||
px: 1,
|
</Typography>
|
||||||
py: 0.25,
|
|
||||||
borderRadius: 1,
|
|
||||||
bgcolor: '#e0e7ff',
|
|
||||||
color: '#3730a3',
|
|
||||||
fontWeight: 600,
|
|
||||||
fontSize: '0.7rem',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Match {inquiry.matchScore}%
|
|
||||||
</Box>
|
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ export function InquiryChat({
|
|||||||
<InquiryReplyComposer
|
<InquiryReplyComposer
|
||||||
inquiryId={inquiry.id}
|
inquiryId={inquiry.id}
|
||||||
defaultSubject={inquiry.subject}
|
defaultSubject={inquiry.subject}
|
||||||
|
tenantName={inquiry.tenantName}
|
||||||
pendingAttachment={pendingAttachment}
|
pendingAttachment={pendingAttachment}
|
||||||
onPendingAttachmentConsumed={onPendingAttachmentConsumed}
|
onPendingAttachmentConsumed={onPendingAttachmentConsumed}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { Box, Button, CircularProgress, Typography } from '@mui/material'
|
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 { useInquiryById, useMarkThreadAsRead } from '../../hooks/useInquiries'
|
||||||
|
import { usePropertyById } from '../../hooks/useProperties'
|
||||||
import { InquiryChat } from './InquiryChat'
|
import { InquiryChat } from './InquiryChat'
|
||||||
import { RelatedPropertyCardPanel } from './RelatedPropertyCardPanel'
|
import { RelatedPropertyCardPanel } from './RelatedPropertyCardPanel'
|
||||||
import type { Attachment } from '../../domain/inquiry'
|
import type { Attachment } from '../../domain/inquiry'
|
||||||
@@ -12,7 +14,9 @@ interface InquiryDetailPanelProps {
|
|||||||
|
|
||||||
export function InquiryDetailPanel({ inquiryId }: InquiryDetailPanelProps) {
|
export function InquiryDetailPanel({ inquiryId }: InquiryDetailPanelProps) {
|
||||||
const { data: inquiry, isLoading } = useInquiryById(inquiryId)
|
const { data: inquiry, isLoading } = useInquiryById(inquiryId)
|
||||||
|
const { data: property } = usePropertyById(inquiry?.propertyId ?? '')
|
||||||
const markRead = useMarkThreadAsRead()
|
const markRead = useMarkThreadAsRead()
|
||||||
|
const navigate = useNavigate()
|
||||||
const [preparationOpen, setPreparationOpen] = useState(false)
|
const [preparationOpen, setPreparationOpen] = useState(false)
|
||||||
const [offerWizardOpen, setOfferWizardOpen] = useState(false)
|
const [offerWizardOpen, setOfferWizardOpen] = useState(false)
|
||||||
const [pendingAttachment, setPendingAttachment] = useState<Attachment | null>(null)
|
const [pendingAttachment, setPendingAttachment] = useState<Attachment | null>(null)
|
||||||
@@ -43,10 +47,13 @@ export function InquiryDetailPanel({ inquiryId }: InquiryDetailPanelProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const isLatentInquiry = !!inquiry.needId
|
const isLatentInquiry = !!inquiry.needId
|
||||||
|
const propertyImage = property?.images?.[0]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
||||||
|
|
||||||
|
{/* Header: tenant + subject + action */}
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
px: 2.5,
|
px: 2.5,
|
||||||
@@ -92,19 +99,85 @@ export function InquiryDetailPanel({ inquiryId }: InquiryDetailPanelProps) {
|
|||||||
)}
|
)}
|
||||||
</Box>
|
</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
|
<InquiryChat
|
||||||
inquiry={inquiry}
|
inquiry={inquiry}
|
||||||
onCreateOffer={() => setOfferWizardOpen(true)}
|
onCreateOffer={() => setOfferWizardOpen(true)}
|
||||||
pendingAttachment={pendingAttachment}
|
pendingAttachment={pendingAttachment}
|
||||||
onPendingAttachmentConsumed={() => setPendingAttachment(null)}
|
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>
|
||||||
<RelatedPropertyCardPanel
|
|
||||||
propertyId={inquiry.propertyId}
|
{/* Bottom: weitere passende Objekte */}
|
||||||
inquiryId={inquiry.id}
|
<Box sx={{ flexShrink: 0, borderTop: '1px solid #e2e8f0' }}>
|
||||||
/>
|
<RelatedPropertyCardPanel
|
||||||
</Box>
|
propertyId={inquiry.propertyId}
|
||||||
|
inquiryId={inquiry.id}
|
||||||
|
compact
|
||||||
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
|||||||
@@ -17,11 +17,10 @@ export function InquiryListRow({ inquiry, selected, onClick }: InquiryListRowPro
|
|||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
sx={{
|
sx={{
|
||||||
px: 2,
|
px: 2,
|
||||||
py: 1.5,
|
py: 1,
|
||||||
borderBottom: '1px solid #e2e8f0',
|
borderBottom: '1px solid #e2e8f0',
|
||||||
borderLeft: '3px solid',
|
borderLeft: selected ? '4px solid #2563eb' : `3px solid ${hasUnread ? '#2563eb' : 'transparent'}`,
|
||||||
borderLeftColor: selected ? '#152642' : hasUnread ? '#2563eb' : 'transparent',
|
bgcolor: selected ? '#eff6ff' : 'white',
|
||||||
bgcolor: selected ? '#f1f5f9' : 'white',
|
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
transition: 'background-color 0.15s, border-color 0.15s',
|
transition: 'background-color 0.15s, border-color 0.15s',
|
||||||
'&:hover': { bgcolor: selected ? '#f1f5f9' : '#f8fafc' },
|
'&: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 }}>
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 0.5 }}>
|
||||||
<Typography
|
<Typography
|
||||||
variant="body2"
|
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.tenantName}
|
||||||
{inquiry.tenantCompany ? ` · ${inquiry.tenantCompany}` : ''}
|
{inquiry.tenantCompany ? ` · ${inquiry.tenantCompany}` : ''}
|
||||||
@@ -76,20 +75,9 @@ export function InquiryListRow({ inquiry, selected, onClick }: InquiryListRowPro
|
|||||||
{propertyLabelFromId(inquiry.propertyId)}
|
{propertyLabelFromId(inquiry.propertyId)}
|
||||||
</Typography>
|
</Typography>
|
||||||
{inquiry.matchScore !== undefined && (
|
{inquiry.matchScore !== undefined && (
|
||||||
<Box
|
<Typography sx={{ ml: 'auto', fontSize: '0.75rem', fontWeight: 700, color: '#152642' }}>
|
||||||
sx={{
|
|
||||||
ml: 'auto',
|
|
||||||
px: 0.75,
|
|
||||||
py: 0.125,
|
|
||||||
borderRadius: 1,
|
|
||||||
bgcolor: '#e0e7ff',
|
|
||||||
color: '#3730a3',
|
|
||||||
fontWeight: 600,
|
|
||||||
fontSize: '0.7rem',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{inquiry.matchScore}%
|
{inquiry.matchScore}%
|
||||||
</Box>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
<Typography variant="caption" sx={{ color: '#94a3b8', fontSize: '0.7rem', mt: 0.5, display: 'block' }}>
|
<Typography variant="caption" sx={{ color: '#94a3b8', fontSize: '0.7rem', mt: 0.5, display: 'block' }}>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
TextField,
|
TextField,
|
||||||
Typography,
|
Typography,
|
||||||
} from '@mui/material'
|
} from '@mui/material'
|
||||||
import { Send, Paperclip, X } from 'lucide-react'
|
import { Paperclip, Send, Sparkles, X } from 'lucide-react'
|
||||||
import { useSendInquiryReply } from '../../hooks/useInquiries'
|
import { useSendInquiryReply } from '../../hooks/useInquiries'
|
||||||
import { useToastStore } from '../../stores/toastStore'
|
import { useToastStore } from '../../stores/toastStore'
|
||||||
import type { Attachment } from '../../domain/inquiry'
|
import type { Attachment } from '../../domain/inquiry'
|
||||||
@@ -16,6 +16,7 @@ import { formatFileSize } from './inquiryUtils'
|
|||||||
interface InquiryReplyComposerProps {
|
interface InquiryReplyComposerProps {
|
||||||
inquiryId: string
|
inquiryId: string
|
||||||
defaultSubject: string
|
defaultSubject: string
|
||||||
|
tenantName?: string
|
||||||
onSent?: () => void
|
onSent?: () => void
|
||||||
pendingAttachment?: Attachment | null
|
pendingAttachment?: Attachment | null
|
||||||
onPendingAttachmentConsumed?: () => void
|
onPendingAttachmentConsumed?: () => void
|
||||||
@@ -24,6 +25,7 @@ interface InquiryReplyComposerProps {
|
|||||||
export function InquiryReplyComposer({
|
export function InquiryReplyComposer({
|
||||||
inquiryId,
|
inquiryId,
|
||||||
defaultSubject,
|
defaultSubject,
|
||||||
|
tenantName,
|
||||||
onSent,
|
onSent,
|
||||||
pendingAttachment,
|
pendingAttachment,
|
||||||
onPendingAttachmentConsumed,
|
onPendingAttachmentConsumed,
|
||||||
@@ -33,6 +35,7 @@ export function InquiryReplyComposer({
|
|||||||
)
|
)
|
||||||
const [body, setBody] = useState('')
|
const [body, setBody] = useState('')
|
||||||
const [attachments, setAttachments] = useState<Attachment[]>([])
|
const [attachments, setAttachments] = useState<Attachment[]>([])
|
||||||
|
const [isAIDraft, setIsAIDraft] = useState(false)
|
||||||
|
|
||||||
const sendReply = useSendInquiryReply()
|
const sendReply = useSendInquiryReply()
|
||||||
const showToast = useToastStore(s => s.showToast)
|
const showToast = useToastStore(s => s.showToast)
|
||||||
@@ -49,6 +52,14 @@ export function InquiryReplyComposer({
|
|||||||
|
|
||||||
const sending = sendReply.isPending
|
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 handleAddMockAttachment = () => {
|
||||||
const name = `Anhang_${attachments.length + 1}.pdf`
|
const name = `Anhang_${attachments.length + 1}.pdf`
|
||||||
setAttachments(prev => [
|
setAttachments(prev => [
|
||||||
@@ -82,6 +93,7 @@ export function InquiryReplyComposer({
|
|||||||
showToast('Antwort gesendet', 'success')
|
showToast('Antwort gesendet', 'success')
|
||||||
setBody('')
|
setBody('')
|
||||||
setAttachments([])
|
setAttachments([])
|
||||||
|
setIsAIDraft(false)
|
||||||
onSent?.()
|
onSent?.()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,15 +115,26 @@ export function InquiryReplyComposer({
|
|||||||
onChange={e => setSubject(e.target.value)}
|
onChange={e => setSubject(e.target.value)}
|
||||||
fullWidth
|
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
|
<TextField
|
||||||
size="small"
|
size="small"
|
||||||
label="Nachricht"
|
label="Nachricht"
|
||||||
value={body}
|
value={body}
|
||||||
onChange={e => setBody(e.target.value)}
|
onChange={e => { setBody(e.target.value); setIsAIDraft(false) }}
|
||||||
multiline
|
multiline
|
||||||
rows={4}
|
rows={7}
|
||||||
placeholder="Antwort verfassen..."
|
placeholder="Antwort verfassen..."
|
||||||
fullWidth
|
fullWidth
|
||||||
|
sx={isAIDraft ? { '& .MuiOutlinedInput-root': { borderColor: '#ddd6fe' }, '& fieldset': { borderColor: '#ddd6fe !important' } } : {}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{attachments.length > 0 && (
|
{attachments.length > 0 && (
|
||||||
@@ -149,15 +172,32 @@ export function InquiryReplyComposer({
|
|||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, justifyContent: 'space-between' }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, justifyContent: 'space-between' }}>
|
||||||
<Button
|
<Box sx={{ display: 'flex', gap: 0.75 }}>
|
||||||
size="small"
|
<Button
|
||||||
startIcon={<Paperclip size={14} />}
|
size="small"
|
||||||
onClick={handleAddMockAttachment}
|
startIcon={<Sparkles size={13} />}
|
||||||
sx={{ textTransform: 'none' }}
|
onClick={handleAIDraft}
|
||||||
>
|
sx={{
|
||||||
Anhang
|
textTransform: 'none',
|
||||||
</Button>
|
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', fontSize: '0.75rem' }}
|
||||||
|
>
|
||||||
|
Anhang
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
<Button
|
<Button
|
||||||
variant="contained"
|
variant="contained"
|
||||||
size="small"
|
size="small"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Box, Button, CircularProgress, Divider, Typography } from '@mui/material'
|
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 { useNavigate } from 'react-router'
|
||||||
import { usePropertyById } from '../../hooks/useProperties'
|
import { usePropertyById } from '../../hooks/useProperties'
|
||||||
import { useAdditionalMatchesForInquiry } from '../../hooks/useMatches'
|
import { useAdditionalMatchesForInquiry } from '../../hooks/useMatches'
|
||||||
@@ -8,9 +8,10 @@ import type { AdditionalPropertyMatch } from '../../domain/additionalMatch'
|
|||||||
interface RelatedPropertyCardPanelProps {
|
interface RelatedPropertyCardPanelProps {
|
||||||
propertyId: string
|
propertyId: string
|
||||||
inquiryId: 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 { data: property, isLoading } = usePropertyById(propertyId)
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const {
|
const {
|
||||||
@@ -18,7 +19,7 @@ export function RelatedPropertyCardPanel({ propertyId, inquiryId }: RelatedPrope
|
|||||||
isLoading: loadingMatches,
|
isLoading: loadingMatches,
|
||||||
} = useAdditionalMatchesForInquiry(inquiryId, { minScore: 80, excludePropertyId: propertyId })
|
} = useAdditionalMatchesForInquiry(inquiryId, { minScore: 80, excludePropertyId: propertyId })
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading && !compact) {
|
||||||
return (
|
return (
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', p: 4 }}>
|
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', p: 4 }}>
|
||||||
<CircularProgress size={20} />
|
<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) {
|
if (!property) {
|
||||||
return (
|
return (
|
||||||
<Box sx={{ p: 2 }}>
|
<Box sx={{ p: 2 }}>
|
||||||
@@ -51,25 +90,22 @@ export function RelatedPropertyCardPanel({ propertyId, inquiryId }: RelatedPrope
|
|||||||
overflowY: 'auto',
|
overflowY: 'auto',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Typography variant="caption" sx={{ color: '#64748b', fontWeight: 600, textTransform: 'uppercase', letterSpacing: 0.5 }}>
|
|
||||||
Bezogenes Objekt
|
|
||||||
</Typography>
|
|
||||||
|
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
height: 140,
|
aspectRatio: '3/2',
|
||||||
borderRadius: 1.5,
|
borderRadius: 1.5,
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
bgcolor: '#e8e7e4',
|
bgcolor: '#f4f3f0',
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
backgroundImage: image ? `url(${image})` : 'none',
|
|
||||||
backgroundSize: 'cover',
|
|
||||||
backgroundPosition: 'center',
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{!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>
|
</Box>
|
||||||
|
|
||||||
<Typography variant="body1" sx={{ fontWeight: 600, color: '#0f172a', fontSize: '0.95rem' }}>
|
<Typography variant="body1" sx={{ fontWeight: 600, color: '#0f172a', fontSize: '0.95rem' }}>
|
||||||
@@ -77,20 +113,20 @@ export function RelatedPropertyCardPanel({ propertyId, inquiryId }: RelatedPrope
|
|||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75 }}>
|
<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} />
|
<MapPin size={14} />
|
||||||
<Typography variant="body2" sx={{ fontSize: '0.8125rem' }}>
|
<Typography variant="body2" sx={{ fontSize: '0.8125rem' }}>
|
||||||
{property.location.city}
|
{property.location.city}
|
||||||
{property.location.district ? `, ${property.location.district}` : ''}
|
{property.location.district ? `, ${property.location.district}` : ''}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</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} />
|
<Ruler size={14} />
|
||||||
<Typography variant="body2" sx={{ fontSize: '0.8125rem' }}>
|
<Typography variant="body2" sx={{ fontSize: '0.8125rem' }}>
|
||||||
{property.areaSqm.toLocaleString('de-CH')} m² · CHF {property.rentPricePerSqm}/m²/Jahr
|
{property.areaSqm.toLocaleString('de-CH')} m² · CHF {property.rentPricePerSqm}/m²/Jahr
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</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} />
|
<Calendar size={14} />
|
||||||
<Typography variant="body2" sx={{ fontSize: '0.8125rem' }}>
|
<Typography variant="body2" sx={{ fontSize: '0.8125rem' }}>
|
||||||
Verfügbar ab {new Date(property.availabilityDate).toLocaleDateString('de-CH')}
|
Verfügbar ab {new Date(property.availabilityDate).toLocaleDateString('de-CH')}
|
||||||
@@ -114,14 +150,10 @@ export function RelatedPropertyCardPanel({ propertyId, inquiryId }: RelatedPrope
|
|||||||
Objekt ansehen
|
Objekt ansehen
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{/* Weitere Matches */}
|
|
||||||
<Divider sx={{ my: 0.5 }} />
|
<Divider sx={{ my: 0.5 }} />
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
|
<Typography variant="caption" sx={{ color: '#64748b', fontWeight: 600, textTransform: 'uppercase', letterSpacing: 0.5, fontSize: '0.68rem' }}>
|
||||||
<Trophy size={14} color="#d97706" />
|
Weitere passende Objekte
|
||||||
<Typography variant="caption" sx={{ color: '#64748b', fontWeight: 600, textTransform: 'uppercase', letterSpacing: 0.5, fontSize: '0.68rem' }}>
|
</Typography>
|
||||||
Weitere passende Objekte
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{loadingMatches ? (
|
{loadingMatches ? (
|
||||||
<CircularProgress size={16} sx={{ alignSelf: 'center' }} />
|
<CircularProgress size={16} sx={{ alignSelf: 'center' }} />
|
||||||
@@ -140,74 +172,117 @@ export function RelatedPropertyCardPanel({ propertyId, inquiryId }: RelatedPrope
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function MatchSliderCard({ match }: { match: AdditionalPropertyMatch }) {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
onClick={() => navigate('/supply/properties')}
|
||||||
|
sx={{
|
||||||
|
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)',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Image — 16:9 crop, compact supplementary card */}
|
||||||
|
<Box sx={{ position: 'relative', height: 60, bgcolor: '#f4f3f0', overflow: 'hidden' }}>
|
||||||
|
{match.imageUrl ? (
|
||||||
|
<Box
|
||||||
|
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>
|
||||||
|
)}
|
||||||
|
{/* Score badge overlay */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 6,
|
||||||
|
right: 6,
|
||||||
|
bgcolor: 'rgba(15,23,42,0.72)',
|
||||||
|
color: 'white',
|
||||||
|
fontSize: '0.7rem',
|
||||||
|
fontWeight: 700,
|
||||||
|
px: 0.75,
|
||||||
|
py: 0.25,
|
||||||
|
borderRadius: '4px',
|
||||||
|
lineHeight: 1.4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{match.matchScore}%
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
<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 }}
|
||||||
|
>
|
||||||
|
{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 }) {
|
function AdditionalMatchCard({ match }: { match: AdditionalPropertyMatch }) {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
border: '1px solid #e2e8f0',
|
borderTop: '1px solid #f1f5f9',
|
||||||
borderRadius: 1.5,
|
pt: 1,
|
||||||
overflow: 'hidden',
|
'&:first-of-type': { borderTop: 'none', pt: 0 },
|
||||||
bgcolor: 'white',
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{match.imageUrl && (
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', mb: 0.25 }}>
|
||||||
<Box
|
<Typography sx={{ fontWeight: 600, color: '#0f172a', fontSize: '0.8rem', lineHeight: 1.3, mr: 0.5 }}>
|
||||||
sx={{
|
{match.title}
|
||||||
height: 70,
|
</Typography>
|
||||||
backgroundImage: `url(${match.imageUrl})`,
|
<Typography sx={{ fontSize: '0.75rem', fontWeight: 700, color: '#152642', flexShrink: 0 }}>
|
||||||
backgroundSize: 'cover',
|
{match.matchScore}%
|
||||||
backgroundPosition: 'center',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<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>
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
px: 0.75,
|
|
||||||
py: 0.125,
|
|
||||||
borderRadius: 1,
|
|
||||||
bgcolor: match.matchScore >= 90 ? '#fef3c7' : '#e0e7ff',
|
|
||||||
color: match.matchScore >= 90 ? '#92400e' : '#3730a3',
|
|
||||||
fontWeight: 700,
|
|
||||||
fontSize: '0.65rem',
|
|
||||||
flexShrink: 0,
|
|
||||||
ml: 0.5,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{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>
|
</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' }}
|
|
||||||
>
|
|
||||||
Ansehen
|
|
||||||
</Button>
|
|
||||||
</Box>
|
</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>
|
</Box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState } from 'react'
|
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 type { ParsedNeedCriteria } from '../../domain/needBuilder'
|
||||||
import { AssetType } from '../../domain/enums'
|
import { AssetType } from '../../domain/enums'
|
||||||
import { NeedExtendedRequirements } from './NeedExtendedRequirements'
|
import { NeedExtendedRequirements } from './NeedExtendedRequirements'
|
||||||
@@ -242,31 +242,7 @@ export function NeedInput({ criteria: c, onCriteriaChange: set }: Props) {
|
|||||||
</Stack>
|
</Stack>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Anonymous search */}
|
<NeedExtendedRequirements criteria={c} onChange={set} />
|
||||||
<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} />
|
|
||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ export function PageHeader({
|
|||||||
sx,
|
sx,
|
||||||
}: PageHeaderProps) {
|
}: PageHeaderProps) {
|
||||||
return (
|
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 && breadcrumbs.length > 0 && (
|
||||||
<Breadcrumbs separator="/" sx={{ mb: 1 }}>
|
<Breadcrumbs separator="/" sx={{ mb: 1 }}>
|
||||||
{breadcrumbs.map((crumb) =>
|
{breadcrumbs.map((crumb) =>
|
||||||
@@ -52,7 +52,7 @@ export function PageHeader({
|
|||||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
||||||
<Box>
|
<Box>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
<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}
|
{title}
|
||||||
</Typography>
|
</Typography>
|
||||||
{badge}
|
{badge}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Box, Button, Chip, Tooltip, Typography } from '@mui/material'
|
import { Box, Button, Chip, Typography } from '@mui/material'
|
||||||
import { Building2, CheckCircle2 } from 'lucide-react'
|
import { Building2, CheckCircle2, MapPin } from 'lucide-react'
|
||||||
import { useSessionStore } from '../../stores/sessionStore'
|
import { useSessionStore } from '../../stores/sessionStore'
|
||||||
import { useInquiryStore } from '../../stores/inquiryStore'
|
import { useInquiryStore } from '../../stores/inquiryStore'
|
||||||
import { LocationPreview } from '../shared/LocationPreview'
|
import { LocationPreview } from '../shared/LocationPreview'
|
||||||
@@ -34,13 +34,8 @@ export function IntelligenceMatchCard({ vm, imageUrl, lat, lng, cityLabel, overl
|
|||||||
return <FutureAvailabilityCard vm={vm} />
|
return <FutureAvailabilityCard vm={vm} />
|
||||||
}
|
}
|
||||||
|
|
||||||
// secondary chips — max 2 visible
|
// secondary chips — max 1 visible (availability only), no overflow chip
|
||||||
const chips: Array<{ label: string; color?: string }> = []
|
const secondaryChip = vm.availabilityLabel ?? vm.fitOutLabel ?? null
|
||||||
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)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{
|
<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)',
|
boxShadow: '0 1px 3px rgba(0,0,0,0.06)',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
transition: 'border-color 0.15s',
|
position: 'relative',
|
||||||
'&:hover': { borderColor: '#c8c7c4' },
|
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 ── */}
|
{/* ── Hero zone ── */}
|
||||||
@@ -67,15 +69,29 @@ export function IntelligenceMatchCard({ vm, imageUrl, lat, lng, cityLabel, overl
|
|||||||
border: `1px solid ${theme.border}`,
|
border: `1px solid ${theme.border}`,
|
||||||
}}>
|
}}>
|
||||||
<Typography sx={{
|
<Typography sx={{
|
||||||
fontFamily: '"DM Serif Display", serif',
|
fontWeight: 700, fontSize: '1.625rem',
|
||||||
fontWeight: 400, fontSize: '1.625rem',
|
color: '#ffffff', lineHeight: 1, letterSpacing: '-0.02em',
|
||||||
color: '#ffffff', lineHeight: 1,
|
|
||||||
}}>
|
}}>
|
||||||
{vm.matchScore}%
|
{vm.matchScore}%
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</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' }}>
|
<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 }} />
|
<Chip label={rt.label} size="small" sx={{ bgcolor: rt.color, color: 'white', fontWeight: 600, fontSize: 10, height: 20 }} />
|
||||||
{vm.resultType === 'VERIFIED_PORTFOLIO' && isPortfolioOwner && (
|
{vm.resultType === 'VERIFIED_PORTFOLIO' && isPortfolioOwner && (
|
||||||
@@ -116,19 +132,13 @@ export function IntelligenceMatchCard({ vm, imageUrl, lat, lng, cityLabel, overl
|
|||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Confidence + secondary chips */}
|
{/* Confidence + one secondary chip max */}
|
||||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5, mt: 1, alignItems: 'center' }}>
|
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5, mt: 1, alignItems: 'center' }}>
|
||||||
<Chip label={`${confPct}% Konfidenz`} size="small"
|
<Chip label={`${confPct}% Konfidenz`} size="small"
|
||||||
sx={{ bgcolor: confidenceHex(vm.confidenceScore), color: 'white', fontSize: 10, height: 20 }} />
|
sx={{ bgcolor: confidenceHex(vm.confidenceScore), color: 'white', fontSize: 10, height: 20 }} />
|
||||||
{visibleChips.map((c, i) => (
|
{secondaryChip && (
|
||||||
<Chip key={i} label={c.label} size="small" variant="outlined"
|
<Chip label={secondaryChip} size="small" variant="outlined"
|
||||||
sx={{ fontSize: 10, height: 20, borderColor: DS_BORDER.default, color: DS_TEXT.secondary }} />
|
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>
|
</Box>
|
||||||
|
|
||||||
@@ -145,7 +155,7 @@ export function IntelligenceMatchCard({ vm, imageUrl, lat, lng, cityLabel, overl
|
|||||||
|
|
||||||
{vm.reasons.length > 0 && (
|
{vm.reasons.length > 0 && (
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5, mt: 1 }}>
|
<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 }}>
|
<Box key={i} sx={{ display: 'flex', alignItems: 'flex-start', gap: 0.75 }}>
|
||||||
<CheckCircle2 size={13} color="#1d6342" style={{ marginTop: 2, flexShrink: 0 }} />
|
<CheckCircle2 size={13} color="#1d6342" style={{ marginTop: 2, flexShrink: 0 }} />
|
||||||
<Box>
|
<Box>
|
||||||
|
|||||||
@@ -1,23 +1,21 @@
|
|||||||
import { memo } from 'react'
|
import { memo } from 'react'
|
||||||
import { Alert, Box, Button, Chip, Divider, Tooltip, Typography } from '@mui/material'
|
import { Alert, Box, Button, Chip, Typography } from '@mui/material'
|
||||||
import { MapPin, Building2, CheckCircle2, AlertTriangle } from 'lucide-react'
|
import { MapPin, AlertTriangle } from 'lucide-react'
|
||||||
import { useSessionStore } from '../../stores/sessionStore'
|
import { useSessionStore } from '../../stores/sessionStore'
|
||||||
import { useInquiryStore } from '../../stores/inquiryStore'
|
import { useInquiryStore } from '../../stores/inquiryStore'
|
||||||
import { HeatBadge } from '../shared/HeatBadge'
|
import { HeatBadge } from '../shared/HeatBadge'
|
||||||
import { getScoreTier, SCORE_THEME } from '../shared/scoreTheme'
|
import { getScoreTier, SCORE_THEME } from '../shared/scoreTheme'
|
||||||
import { RESULT_TYPE_META, DS_TEXT, DS_BORDER } from '../../lib/ds'
|
import { RESULT_TYPE_META, DS_TEXT, DS_BORDER } from '../../lib/ds'
|
||||||
import { confidenceHex } from '../../lib/utils'
|
import { confidenceHex } from '../../lib/utils'
|
||||||
import { ScoreInlineBreakdown } from './ScoreInlineBreakdown'
|
|
||||||
import { MatchCardRestrictedState } from './MatchCardRestrictedState'
|
import { MatchCardRestrictedState } from './MatchCardRestrictedState'
|
||||||
import type { MatchCardViewModel } from './MatchCardViewModel'
|
import type { MatchCardViewModel } from './MatchCardViewModel'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
vm: MatchCardViewModel
|
vm: MatchCardViewModel
|
||||||
imageUrl?: string
|
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 />
|
if (vm.isRestricted) return <MatchCardRestrictedState />
|
||||||
|
|
||||||
const { currentUser } = useSessionStore()
|
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 leftAccent = vm.isCompareSelected ? '#5b3f8a' : vm.isSelected ? '#152642' : 'transparent'
|
||||||
|
|
||||||
const extraChips: string[] = []
|
// Compact metric line — area · rent · availability
|
||||||
if (vm.availabilityLabel) extraChips.push(vm.availabilityLabel)
|
const metricParts: string[] = []
|
||||||
if (vm.fitOutLabel) extraChips.push(vm.fitOutLabel)
|
if (vm.areaSqm) metricParts.push(`${vm.areaSqm.toLocaleString('de-CH')} m²`)
|
||||||
if (vm.isDivisible && vm.minDivisibleUnitSqm) extraChips.push(`Teilbar ab ${vm.minDivisibleUnitSqm} m²`)
|
if (vm.rentPerSqm) metricParts.push(`CHF ${vm.rentPerSqm}/m²/J`)
|
||||||
if (topRisk) extraChips.push(topRisk.level === 'CRITICAL' ? 'Kritisch' : 'Hohes Risiko')
|
if (vm.availabilityLabel) metricParts.push(vm.availabilityLabel)
|
||||||
const visibleExtra = extraChips.slice(0, 2)
|
const metricLine = metricParts.join(' · ')
|
||||||
const hiddenExtra = extraChips.slice(2)
|
|
||||||
|
// 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 (
|
return (
|
||||||
<Box sx={{
|
<Box sx={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
mb: 1.5,
|
|
||||||
borderRadius: 2,
|
borderRadius: 2,
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
border: `1px solid ${DS_BORDER.default}`,
|
border: `1px solid ${DS_BORDER.default}`,
|
||||||
@@ -51,12 +51,19 @@ export const MatchCardCompact = memo(function MatchCardCompact({ vm, imageUrl, o
|
|||||||
bgcolor: '#ffffff',
|
bgcolor: '#ffffff',
|
||||||
boxShadow: '0 1px 3px rgba(0,0,0,0.06)',
|
boxShadow: '0 1px 3px rgba(0,0,0,0.06)',
|
||||||
opacity: vm.isStaleData ? 0.75 : 1,
|
opacity: vm.isStaleData ? 0.75 : 1,
|
||||||
transition: 'border-color 0.15s',
|
transition: 'border-color 0.15s, transform 0.18s ease, box-shadow 0.18s ease',
|
||||||
'&:hover': { borderColor: '#c8c7c4' },
|
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 ── */}
|
{/* ── Image strip — 180px, Ginesta proportion ── */}
|
||||||
<Box sx={{ width: 120, flexShrink: 0, position: 'relative', bgcolor: '#f4f3f0' }}>
|
<Box sx={{ width: 180, flexShrink: 0, position: 'relative', bgcolor: '#f4f3f0' }}>
|
||||||
{imageUrl ? (
|
{imageUrl ? (
|
||||||
<img
|
<img
|
||||||
src={imageUrl}
|
src={imageUrl}
|
||||||
@@ -73,7 +80,7 @@ export const MatchCardCompact = memo(function MatchCardCompact({ vm, imageUrl, o
|
|||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Score badge */}
|
{/* Score badge — top left */}
|
||||||
<Box sx={{
|
<Box sx={{
|
||||||
position: 'absolute', top: 8, left: 8,
|
position: 'absolute', top: 8, left: 8,
|
||||||
bgcolor: scoreBadgeBg, borderRadius: '6px',
|
bgcolor: scoreBadgeBg, borderRadius: '6px',
|
||||||
@@ -81,58 +88,50 @@ export const MatchCardCompact = memo(function MatchCardCompact({ vm, imageUrl, o
|
|||||||
border: `1px solid ${SCORE_THEME[tier].border}`,
|
border: `1px solid ${SCORE_THEME[tier].border}`,
|
||||||
}}>
|
}}>
|
||||||
<Typography sx={{
|
<Typography sx={{
|
||||||
fontFamily: '"DM Serif Display", serif',
|
fontWeight: 700, fontSize: '1.125rem',
|
||||||
fontWeight: 400, fontSize: '1.125rem',
|
color: '#ffffff', lineHeight: 1, letterSpacing: '-0.01em',
|
||||||
color: '#ffffff', lineHeight: 1,
|
|
||||||
}}>
|
}}>
|
||||||
{vm.matchScore}%
|
{vm.matchScore}%
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{overlayTypeLabel && (
|
{/* Location pill — Ginesta-style, top right */}
|
||||||
<Box sx={{
|
<Box sx={{
|
||||||
position: 'absolute', bottom: 0, left: 0, right: 0,
|
position: 'absolute', top: 8, right: 8,
|
||||||
px: 0.75, py: 0.5,
|
bgcolor: 'rgba(255,255,255,0.92)',
|
||||||
background: 'linear-gradient(to top, rgba(0,0,0,0.55) 0%, transparent 100%)',
|
borderRadius: '20px',
|
||||||
}}>
|
px: 1, py: 0.35,
|
||||||
<Typography sx={{ fontSize: '0.6rem', fontWeight: 600, color: 'white', lineHeight: 1.3 }}>
|
display: 'flex', alignItems: 'center', gap: 0.4,
|
||||||
{overlayTypeLabel}
|
backdropFilter: 'blur(4px)',
|
||||||
</Typography>
|
}}>
|
||||||
</Box>
|
<MapPin size={9} color="#64748b" />
|
||||||
)}
|
<Typography sx={{ fontSize: '0.65rem', fontWeight: 500, color: '#374151', lineHeight: 1 }}>
|
||||||
|
{vm.locationLabel}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* ── Content ── */}
|
{/* ── 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 && (
|
{vm.disclaimer && (
|
||||||
<Alert severity="warning" sx={{ mb: 1, py: 0.25, fontSize: '0.72rem' }}>{vm.disclaimer}</Alert>
|
<Alert severity="warning" sx={{ mb: 1, py: 0.25, fontSize: '0.72rem' }}>{vm.disclaimer}</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Badges row */}
|
{/* Chips — result type + heat + confidence */}
|
||||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5, alignItems: 'center', mb: 0.75 }}>
|
<Box sx={{ display: 'flex', gap: 0.5, alignItems: 'center', mb: 0.875 }}>
|
||||||
<HeatBadge propertyId={vm.propertyId} size="sm" />
|
|
||||||
<Chip label={rt.label} size="small"
|
<Chip label={rt.label} size="small"
|
||||||
sx={{ bgcolor: rt.color, color: 'white', fontWeight: 600, fontSize: 10, height: 20 }} />
|
sx={{ bgcolor: rt.color, color: 'white', fontWeight: 600, fontSize: 10, height: 20 }} />
|
||||||
{vm.resultType === 'VERIFIED_PORTFOLIO' && isPortfolioOwner && (
|
<HeatBadge propertyId={vm.propertyId} size="sm" />
|
||||||
<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 } }} />
|
|
||||||
)}
|
|
||||||
<Chip label={`${confPct}% Konfidenz`} size="small"
|
<Chip label={`${confPct}% Konfidenz`} size="small"
|
||||||
sx={{ bgcolor: confidenceHex(vm.confidenceScore), color: 'white', fontSize: 10, height: 20 }} />
|
sx={{ bgcolor: confidenceHex(vm.confidenceScore), color: 'white', fontSize: 10, height: 20 }} />
|
||||||
{visibleExtra.map((label, i) => (
|
{vm.resultType === 'VERIFIED_PORTFOLIO' && isPortfolioOwner && (
|
||||||
<Chip key={i} label={label} size="small" variant="outlined"
|
<Chip label="Ihr Objekt" size="small"
|
||||||
sx={{ fontSize: 10, height: 20, borderColor: DS_BORDER.default, color: DS_TEXT.secondary }} />
|
sx={{ bgcolor: 'rgba(21,38,66,0.10)', color: '#152642', fontWeight: 700, fontSize: 9, height: 18 }} />
|
||||||
))}
|
|
||||||
{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>
|
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Title + location */}
|
{/* Title */}
|
||||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, lineHeight: 1.3, color: DS_TEXT.primary }} noWrap>
|
<Typography variant="subtitle2" sx={{ fontWeight: 700, lineHeight: 1.3, color: DS_TEXT.primary }} noWrap>
|
||||||
{vm.title}
|
{vm.title}
|
||||||
</Typography>
|
</Typography>
|
||||||
@@ -141,40 +140,25 @@ export const MatchCardCompact = memo(function MatchCardCompact({ vm, imageUrl, o
|
|||||||
{vm.propertySubtitle}
|
{vm.propertySubtitle}
|
||||||
</Typography>
|
</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 && (
|
{vm.explainabilitySummary && (
|
||||||
<Typography variant="body2" sx={{ color: DS_TEXT.secondary, fontWeight: 500, mt: 0.75, lineHeight: 1.5,
|
<Typography variant="body2" sx={{
|
||||||
overflow: 'hidden', display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical' }}>
|
color: DS_TEXT.secondary, fontWeight: 500, mt: 0.875, lineHeight: 1.5,
|
||||||
|
overflow: 'hidden', display: '-webkit-box', WebkitLineClamp: 1, WebkitBoxOrient: 'vertical',
|
||||||
|
}}>
|
||||||
{vm.explainabilitySummary}
|
{vm.explainabilitySummary}
|
||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{vm.reasons.length > 0 && (
|
{/* Critical risk only */}
|
||||||
<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} />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{topRisk && topRisk.level === 'CRITICAL' && (
|
{topRisk && topRisk.level === 'CRITICAL' && (
|
||||||
<Alert icon={<AlertTriangle size={13} />} severity="error"
|
<Alert icon={<AlertTriangle size={13} />} severity="error"
|
||||||
sx={{ mt: 0.75, py: 0.25, fontSize: '0.72rem' }}>
|
sx={{ mt: 0.75, py: 0.25, fontSize: '0.72rem' }}>
|
||||||
@@ -182,8 +166,8 @@ export const MatchCardCompact = memo(function MatchCardCompact({ vm, imageUrl, o
|
|||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Actions */}
|
{/* 2 Buttons — Anfrage + Details */}
|
||||||
<Box sx={{ display: 'flex', gap: 0.625, mt: 'auto', pt: 1, flexWrap: 'wrap' }}>
|
<Box sx={{ display: 'flex', gap: 0.625, mt: 'auto', pt: 1.25 }}>
|
||||||
<Button
|
<Button
|
||||||
size="small"
|
size="small"
|
||||||
variant="contained"
|
variant="contained"
|
||||||
@@ -201,18 +185,16 @@ export const MatchCardCompact = memo(function MatchCardCompact({ vm, imageUrl, o
|
|||||||
>
|
>
|
||||||
Anfrage
|
Anfrage
|
||||||
</Button>
|
</Button>
|
||||||
{vm.actions.map(a => (
|
{detailAction && (
|
||||||
<Button
|
<Button
|
||||||
key={a.id}
|
|
||||||
size="small"
|
size="small"
|
||||||
variant={a.variant === 'primary' ? 'contained' : 'outlined'}
|
variant="outlined"
|
||||||
onClick={a.onClick}
|
onClick={detailAction.onClick}
|
||||||
disabled={a.disabled}
|
|
||||||
sx={{ textTransform: 'none', fontSize: '0.7rem', py: 0.375, px: 1 }}
|
sx={{ textTransform: 'none', fontSize: '0.7rem', py: 0.375, px: 1 }}
|
||||||
>
|
>
|
||||||
{a.label}
|
Details →
|
||||||
</Button>
|
</Button>
|
||||||
))}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -86,6 +86,10 @@ export interface MatchCardViewModel {
|
|||||||
preMarketUnit?: PropertyUnit // specific unit being released pre-market
|
preMarketUnit?: PropertyUnit // specific unit being released pre-market
|
||||||
preMarketAllUnits?: PropertyUnit[] // all units of the backing property
|
preMarketAllUnits?: PropertyUnit[] // all units of the backing property
|
||||||
|
|
||||||
|
// Key metrics for compact display (Ginesta-style metric line)
|
||||||
|
areaSqm?: number
|
||||||
|
rentPerSqm?: number
|
||||||
|
|
||||||
// Score transparency
|
// Score transparency
|
||||||
scoreBreakdown?: {
|
scoreBreakdown?: {
|
||||||
hardMatchScore: number
|
hardMatchScore: number
|
||||||
|
|||||||
@@ -125,16 +125,6 @@ export function UnifiedResultCard({ result, view = 'list' }: Props) {
|
|||||||
const listImageUrl = result.resultType !== 'FUTURE_AVAILABILITY'
|
const listImageUrl = result.resultType !== 'FUTURE_AVAILABILITY'
|
||||||
? result.property.images?.[0]
|
? result.property.images?.[0]
|
||||||
: undefined
|
: 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>
|
||||||
) : (
|
) : (
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||||
{items.map(result => (
|
{items.map(result => (
|
||||||
<UnifiedResultCard key={result.matchId} result={result} view="list" />
|
<UnifiedResultCard key={result.matchId} result={result} view="list" />
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -20,13 +20,13 @@ export function DashboardHeader({ orgName = 'Demo Organisation', lastUpdated }:
|
|||||||
return (
|
return (
|
||||||
<Box>
|
<Box>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.5 }}>
|
<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}
|
{orgName}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Chip label="Demo" size="small" color="info" variant="outlined" />
|
<Chip label="Demo" size="small" color="info" variant="outlined" />
|
||||||
</Box>
|
</Box>
|
||||||
{lastUpdated && (
|
{lastUpdated && (
|
||||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
<Typography variant="body2" color="text.secondary">
|
||||||
Zuletzt aktualisiert: {formatLastUpdated(lastUpdated)}
|
Zuletzt aktualisiert: {formatLastUpdated(lastUpdated)}
|
||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -43,19 +43,26 @@ export const PropertyIntelligenceCard = memo(function PropertyIntelligenceCard({
|
|||||||
<Box
|
<Box
|
||||||
onClick={() => onSelect(p.id)}
|
onClick={() => onSelect(p.id)}
|
||||||
sx={{
|
sx={{
|
||||||
borderRadius: 1,
|
borderRadius: 2,
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
border: `1px solid ${DS_BORDER.default}`,
|
border: `1px solid ${DS_BORDER.default}`,
|
||||||
bgcolor: '#ffffff',
|
bgcolor: '#ffffff',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
transition: 'border-color 0.15s',
|
position: 'relative',
|
||||||
'&:hover': { borderColor: '#c8c7c4' },
|
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 ── */}
|
{/* ── 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 ? (
|
{hasImage ? (
|
||||||
<Box component="img" src={p.images![0]} alt={p.title}
|
<Box component="img" src={p.images![0]} alt={p.title}
|
||||||
sx={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }} />
|
sx={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }} />
|
||||||
@@ -78,24 +85,24 @@ export const PropertyIntelligenceCard = memo(function PropertyIntelligenceCard({
|
|||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* ── Body ── */}
|
{/* ── 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 */}
|
{/* Title + location + status inline */}
|
||||||
<Box>
|
<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}
|
{p.title}
|
||||||
</Typography>
|
</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 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.4 }}>
|
||||||
<MapPin size={11} color="#9a9a9a" />
|
<MapPin size={12} color="#9a9a9a" />
|
||||||
<Typography sx={{ fontSize: '0.75rem', color: DS_TEXT.muted }}>
|
<Typography sx={{ fontSize: '0.8125rem', color: DS_TEXT.muted }}>
|
||||||
{p.location.city}{p.location.district ? ` · ${p.location.district}` : ''}
|
{p.location.city}{p.location.district ? ` · ${p.location.district}` : ''}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
{/* Status — plain dot + text, no pill container */}
|
{/* Status — plain dot + text, no pill container */}
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||||
<Box sx={{ width: 6, height: 6, borderRadius: '50%', bgcolor: status.dot, flexShrink: 0 }} />
|
<Box sx={{ width: 7, height: 7, borderRadius: '50%', bgcolor: status.dot, flexShrink: 0 }} />
|
||||||
<Typography sx={{ fontSize: '0.72rem', fontWeight: 500, color: status.text, lineHeight: 1 }}>
|
<Typography sx={{ fontSize: '0.8rem', fontWeight: 500, color: status.text, lineHeight: 1 }}>
|
||||||
{status.label}
|
{status.label}
|
||||||
{p.availabilityStatus === 'AVAILABLE_SOON' && availDate ? ` · ab ${availDate}` : ''}
|
{p.availabilityStatus === 'AVAILABLE_SOON' && availDate ? ` · ab ${availDate}` : ''}
|
||||||
</Typography>
|
</Typography>
|
||||||
@@ -104,62 +111,58 @@ export const PropertyIntelligenceCard = memo(function PropertyIntelligenceCard({
|
|||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* 3 key metrics — no boxes, whitespace only */}
|
{/* 3 key metrics — no boxes, whitespace only */}
|
||||||
<Box sx={{ display: 'flex', gap: 2 }}>
|
<Box sx={{ display: 'flex', gap: 2.5 }}>
|
||||||
<Box>
|
<Box>
|
||||||
<Typography sx={{ fontSize: '0.68rem', color: DS_TEXT.muted, mb: 0.25 }}>Fläche</Typography>
|
<Typography sx={{ fontSize: '0.75rem', color: DS_TEXT.muted, mb: 0.35 }}>Fläche</Typography>
|
||||||
<Typography sx={{ fontWeight: 600, fontSize: '0.875rem', color: DS_TEXT.primary }}>
|
<Typography sx={{ fontWeight: 600, fontSize: '1rem', color: DS_TEXT.primary }}>
|
||||||
{p.areaSqm.toLocaleString('de-CH')} m²
|
{p.areaSqm.toLocaleString('de-CH')} m²
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Box>
|
<Box>
|
||||||
<Typography sx={{ fontSize: '0.68rem', color: DS_TEXT.muted, mb: 0.25 }}>Miete</Typography>
|
<Typography sx={{ fontSize: '0.75rem', color: DS_TEXT.muted, mb: 0.35 }}>Miete</Typography>
|
||||||
<Typography sx={{ fontWeight: 600, fontSize: '0.875rem', color: DS_TEXT.primary }}>
|
<Typography sx={{ fontWeight: 600, fontSize: '1rem', color: DS_TEXT.primary }}>
|
||||||
{p.rentPricePerSqm}
|
{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>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Tooltip title={hasInquiries ? `${inquiryCount} aktive Anfragen` : 'Noch keine Anfragen'} placement="top" arrow>
|
<Tooltip title={hasInquiries ? `${inquiryCount} aktive Anfragen` : 'Noch keine Anfragen'} placement="top" arrow>
|
||||||
<Box>
|
<Box>
|
||||||
<Typography sx={{ fontSize: '0.68rem', color: DS_TEXT.muted, mb: 0.25 }}>Anfragen</Typography>
|
<Typography sx={{ fontSize: '0.75rem', color: DS_TEXT.muted, mb: 0.35 }}>Anfragen</Typography>
|
||||||
<Typography sx={{ fontWeight: 600, fontSize: '0.875rem', color: hasInquiries ? '#a16207' : DS_TEXT.muted }}>
|
<Typography sx={{ fontWeight: 600, fontSize: '1rem', color: hasInquiries ? '#a16207' : DS_TEXT.muted }}>
|
||||||
{inquiryCount ?? 0}
|
{inquiryCount ?? 0}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Property spec chips */}
|
{/* Chips + Datenvollständigkeit in einer Zeile */}
|
||||||
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap' }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1, mt: 'auto' }}>
|
||||||
{p.hardFacts?.fitOut && (() => {
|
<Box sx={{ display: 'flex', gap: 0.75, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||||
const meta = FIT_OUT_META[p.hardFacts!.fitOut!] ?? { label: p.hardFacts!.fitOut!, tip: '' }
|
{p.hardFacts?.fitOut && (() => {
|
||||||
return (
|
const meta = FIT_OUT_META[p.hardFacts!.fitOut!] ?? { label: p.hardFacts!.fitOut!, tip: '' }
|
||||||
<Tooltip title={meta.tip} placement="top" arrow>
|
return (
|
||||||
<Chip label={meta.label} size="small"
|
<Tooltip title={meta.tip} placement="top" arrow>
|
||||||
sx={{ height: 20, fontSize: '0.68rem', bgcolor: '#f4f3f0', color: DS_TEXT.secondary, border: `1px solid ${DS_BORDER.default}`, cursor: 'help' }} />
|
<Chip label={meta.label} size="small"
|
||||||
</Tooltip>
|
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"
|
{p.contractDurationMonths && (
|
||||||
sx={{ height: 20, fontSize: '0.68rem', bgcolor: '#f4f3f0', color: DS_TEXT.secondary, border: `1px solid ${DS_BORDER.default}` }} />
|
<Chip label={`${p.contractDurationMonths}M Vertrag`} size="small"
|
||||||
)}
|
sx={{ height: 22, fontSize: '0.73rem', bgcolor: '#f4f3f0', color: DS_TEXT.secondary, border: `1px solid ${DS_BORDER.default}` }} />
|
||||||
</Box>
|
)}
|
||||||
|
</Box>
|
||||||
{/* Data completeness */}
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, flexShrink: 0 }}>
|
||||||
<Box sx={{ mt: 'auto' }}>
|
<Typography sx={{ fontSize: '0.68rem', color: DS_TEXT.disabled }}>Daten</Typography>
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 0.5 }}>
|
<LinearProgress variant="determinate" value={confPct}
|
||||||
<Typography sx={{ fontSize: '0.62rem', color: DS_TEXT.disabled, letterSpacing: '0.03em' }}>
|
sx={{ width: 44, height: 3, borderRadius: 1, bgcolor: '#f4f3f0',
|
||||||
Datenvollständigkeit
|
'& .MuiLinearProgress-bar': { bgcolor: '#152642', borderRadius: 1 } }} />
|
||||||
</Typography>
|
<Typography sx={{ fontSize: '0.78rem', fontWeight: 700, color: p.confidenceScore >= 0.75 ? '#b8975a' : DS_TEXT.muted }}>
|
||||||
<Typography sx={{ fontSize: '0.75rem', fontWeight: 700, color: p.confidenceScore >= 0.75 ? '#b8975a' : DS_TEXT.muted }}>
|
|
||||||
{confPct}%
|
{confPct}%
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
<LinearProgress variant="determinate" value={confPct}
|
|
||||||
sx={{ height: 3, borderRadius: 1, bgcolor: '#f4f3f0',
|
|
||||||
'& .MuiLinearProgress-bar': { bgcolor: '#152642', borderRadius: 1 } }} />
|
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -14,17 +14,16 @@ export function ReminderHeader() {
|
|||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
bgcolor: 'white',
|
bgcolor: 'white',
|
||||||
borderBottom: '1px solid',
|
borderBottom: '1px solid #e8e7e4',
|
||||||
borderColor: 'divider',
|
|
||||||
px: 3,
|
px: 3,
|
||||||
py: 2,
|
py: 2.5,
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'flex-start',
|
alignItems: 'flex-start',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Box>
|
<Box>
|
||||||
<Typography variant="h5" sx={{ fontWeight: 700, color: '#1e293b' }}>
|
<Typography sx={{ fontWeight: 700, fontSize: '1.125rem', lineHeight: 1.3, color: '#0f172a' }}>
|
||||||
Reminder Manager
|
Reminder Manager
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="body2" color="text.secondary">
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
|||||||
@@ -208,8 +208,8 @@ export default function AISearch() {
|
|||||||
return (
|
return (
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
||||||
{/* Header */}
|
{/* 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 }}>
|
||||||
<Typography variant="h5" sx={{ fontWeight: 700 }}>Flächensuche</Typography>
|
<Typography sx={{ fontWeight: 700, fontSize: '1.125rem', lineHeight: 1.3, color: '#0f172a' }}>Flächensuche</Typography>
|
||||||
<Typography variant="body2" color="text.secondary">
|
<Typography variant="body2" color="text.secondary">
|
||||||
Sprechen, schreiben oder Felder ausfüllen — dann sofort suchen oder als Suchprofil speichern
|
Sprechen, schreiben oder Felder ausfüllen — dann sofort suchen oder als Suchprofil speichern
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|||||||
@@ -49,8 +49,8 @@ export default function Compare() {
|
|||||||
if (compareItems.length === 0) {
|
if (compareItems.length === 0) {
|
||||||
return (
|
return (
|
||||||
<Box>
|
<Box>
|
||||||
<Box sx={{ bgcolor: 'white', borderBottom: '1px solid #e2e8f0', px: 3, py: 2 }}>
|
<Box sx={{ bgcolor: 'white', borderBottom: '1px solid #e8e7e4', px: 3, py: 2.5 }}>
|
||||||
<Typography variant="h5" sx={{ fontWeight: 700 }}>Vergleich</Typography>
|
<Typography sx={{ fontWeight: 700, fontSize: '1.125rem', lineHeight: 1.3, color: '#0f172a' }}>Vergleich</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
<CompareEmptyState />
|
<CompareEmptyState />
|
||||||
</Box>
|
</Box>
|
||||||
@@ -62,9 +62,9 @@ export default function Compare() {
|
|||||||
<AddToPipelineDialog />
|
<AddToPipelineDialog />
|
||||||
|
|
||||||
{/* Page header */}
|
{/* 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 }}>
|
<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" />
|
<Chip label={`${compareItems.length} Ergebnisse`} size="small" />
|
||||||
</Box>
|
</Box>
|
||||||
<Button variant="outlined" size="small" color="error" onClick={clearCompare}>
|
<Button variant="outlined" size="small" color="error" onClick={clearCompare}>
|
||||||
|
|||||||
@@ -73,11 +73,11 @@ export default function Pipeline() {
|
|||||||
|
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<Box sx={{
|
<Box sx={{
|
||||||
bgcolor: 'white', borderBottom: '1px solid #e2e8f0',
|
bgcolor: 'white', borderBottom: '1px solid #e8e7e4',
|
||||||
px: 3, py: 2, display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexShrink: 0,
|
px: 3, py: 2.5, display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexShrink: 0,
|
||||||
}}>
|
}}>
|
||||||
<Box>
|
<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">
|
<Typography variant="body2" color="text.secondary">
|
||||||
Von der ersten Idee bis zum Abschluss — per Drag & Drop verschieben
|
Von der ersten Idee bis zum Abschluss — per Drag & Drop verschieben
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|||||||
+22
-102
@@ -1,11 +1,10 @@
|
|||||||
import { useState, useMemo, useEffect } from 'react'
|
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 { Zap } from 'lucide-react'
|
||||||
import { useNavigate, useLocation } from 'react-router'
|
import { useNavigate, useLocation } from 'react-router'
|
||||||
import { useQueryClient } from '@tanstack/react-query'
|
import { useQueryClient } from '@tanstack/react-query'
|
||||||
import { useUnifiedResults } from '../../hooks/useUnifiedResults'
|
import { useUnifiedResults } from '../../hooks/useUnifiedResults'
|
||||||
import { useNeeds } from '../../hooks/useNeeds'
|
import { useNeeds } from '../../hooks/useNeeds'
|
||||||
import { DecisionContextPanel } from '../../components/ui'
|
|
||||||
import {
|
import {
|
||||||
FeedEmptyState,
|
FeedEmptyState,
|
||||||
FeedSkeleton,
|
FeedSkeleton,
|
||||||
@@ -72,16 +71,14 @@ export default function Results() {
|
|||||||
const isStaff = currentUser?.role === 'PROPERTY_MANAGER' || currentUser?.role === 'ORGANIZATION_ADMIN'
|
const isStaff = currentUser?.role === 'PROPERTY_MANAGER' || currentUser?.role === 'ORGANIZATION_ADMIN'
|
||||||
|
|
||||||
// Counts over the full result set — single pass, stable across filter changes
|
// Counts over the full result set — single pass, stable across filter changes
|
||||||
const { platformCount, maisonWorkCount, futureCount, missingDataCount } = useMemo(() => {
|
const { platformCount, maisonWorkCount, futureCount } = useMemo(() => {
|
||||||
let platform = 0, maison = 0, future = 0, missing = 0
|
let platform = 0, maison = 0, future = 0
|
||||||
for (const r of results) {
|
for (const r of results) {
|
||||||
if (r.resultType === 'VERIFIED_PORTFOLIO') platform++
|
if (r.resultType === 'VERIFIED_PORTFOLIO') platform++
|
||||||
else if (r.resultType === 'MAISON_WORK') maison++
|
else if (r.resultType === 'MAISON_WORK') maison++
|
||||||
else if (r.resultType === 'FUTURE_AVAILABILITY') future++
|
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])
|
}, [results])
|
||||||
|
|
||||||
// Filter + sort in one memo — only reruns when inputs change
|
// Filter + sort in one memo — only reruns when inputs change
|
||||||
@@ -98,7 +95,6 @@ export default function Results() {
|
|||||||
return sortResults(filtered, sortBy)
|
return sortResults(filtered, sortBy)
|
||||||
}, [results, filterSource, sortBy, showFutureAvailability, showOwnProperties])
|
}, [results, filterSource, sortBy, showFutureAvailability, showOwnProperties])
|
||||||
|
|
||||||
const strongCount = useMemo(() => sorted.filter(r => r.matchScore >= 80).length, [sorted])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
<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) }}
|
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 }}>
|
<Box sx={{ flex: 1, overflowY: 'auto', px: 3, py: 3, pb: 10 }}>
|
||||||
|
|
||||||
|
{/* Aktive Suche */}
|
||||||
{activeNeed && (
|
{activeNeed && (
|
||||||
<Card sx={{ bgcolor: '#eff6ff', p: 2, mb: 2, border: '1px solid #bfdbfe' }}>
|
<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', alignItems: 'flex-start', justifyContent: 'space-between', gap: 2 }}>
|
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: '4px 16px', alignItems: 'center' }}>
|
||||||
<Box>
|
<Typography variant="caption" sx={{ fontWeight: 700, color: '#152642' }}>
|
||||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 0.5 }} color="#152642">
|
{activeNeed.companyName}
|
||||||
Aktive Suche: {activeNeed.companyName}
|
</Typography>
|
||||||
</Typography>
|
<Typography variant="caption" color="text.secondary">{activeNeed.assetType}</Typography>
|
||||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: '4px 16px' }}>
|
<Typography variant="caption" color="text.secondary">{activeNeed.requiredArea.min}–{activeNeed.requiredArea.max} m²</Typography>
|
||||||
<Typography variant="caption" color="text.secondary">
|
<Typography variant="caption" color="text.secondary">{activeNeed.preferredLocations.join(', ')}</Typography>
|
||||||
<strong>Typ:</strong> {activeNeed.assetType}
|
{activeNeed.budgetRange?.maxPerSqm > 0 && (
|
||||||
</Typography>
|
<Typography variant="caption" color="text.secondary">max. CHF {activeNeed.budgetRange.maxPerSqm}/m²/J</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(' · ')}
|
|
||||||
</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 }}
|
|
||||||
>
|
|
||||||
Suche ändern
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
</Box>
|
||||||
</Card>
|
<Button size="small" variant="text" onClick={() => navigate('/demand/ai-search')}
|
||||||
|
sx={{ color: '#152642', flexShrink: 0, textTransform: 'none', fontSize: '0.75rem' }}>
|
||||||
|
Suche ändern
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<ResultFilterBar
|
<ResultFilterBar
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ export default function Anfragencenter() {
|
|||||||
const [tab, setTab] = useState(initialTab)
|
const [tab, setTab] = useState(initialTab)
|
||||||
return (
|
return (
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
||||||
<Box sx={{ borderBottom: '1px solid #e2e8f0', px: 3, bgcolor: 'white', flexShrink: 0 }}>
|
<Box sx={{ borderBottom: '1px solid #e8e7e4', px: 3, bgcolor: 'white', flexShrink: 0 }}>
|
||||||
<Typography variant="h5" sx={{ fontWeight: 700, pt: 2.5, pb: 1 }}>
|
<Typography sx={{ fontWeight: 700, fontSize: '1.125rem', lineHeight: 1.3, color: '#0f172a', pt: 2.5, pb: 1, display: 'block' }}>
|
||||||
Anfragencenter
|
Anfragencenter
|
||||||
</Typography>
|
</Typography>
|
||||||
<Tabs
|
<Tabs
|
||||||
|
|||||||
@@ -263,9 +263,9 @@ export default function DataQuality() {
|
|||||||
return (
|
return (
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<Box sx={{ px: 3, py: 2, bgcolor: 'white', borderBottom: '1px solid #e2e8f0', flexShrink: 0 }}>
|
<Box sx={{ px: 3, py: 2.5, bgcolor: 'white', borderBottom: '1px solid #e8e7e4', flexShrink: 0 }}>
|
||||||
<Typography variant="h6" sx={{ fontWeight: 700 }}>Datenpflege</Typography>
|
<Typography sx={{ fontWeight: 700, fontSize: '1.125rem', lineHeight: 1.3, color: '#0f172a' }}>Datenpflege</Typography>
|
||||||
<Typography variant="caption" color="text.secondary">
|
<Typography variant="body2" color="text.secondary">
|
||||||
Vollständigkeit, Aktualität und Vertrauen der Objektdaten · Klick auf KPI-Karte filtert die Liste
|
Vollständigkeit, Aktualität und Vertrauen der Objektdaten · Klick auf KPI-Karte filtert die Liste
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -49,10 +49,10 @@ export default function FutureAvailability() {
|
|||||||
<AddToShortlistDialog />
|
<AddToShortlistDialog />
|
||||||
|
|
||||||
{/* Header */}
|
{/* 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 sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 1 }}>
|
||||||
<Box>
|
<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>
|
<Typography variant="body2" color="text.secondary">Probabilistische Markt- und Verfügbarkeitssignale</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
<Chip
|
<Chip
|
||||||
|
|||||||
@@ -1046,9 +1046,9 @@ export default function MarketIntelligence() {
|
|||||||
return (
|
return (
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
||||||
{/* Page header */}
|
{/* Page header */}
|
||||||
<Box sx={{ px: 3, py: 2, bgcolor: 'white', borderBottom: '1px solid #e2e8f0', flexShrink: 0 }}>
|
<Box sx={{ px: 3, py: 2.5, bgcolor: 'white', borderBottom: '1px solid #e8e7e4', flexShrink: 0 }}>
|
||||||
<Typography variant="h6" sx={{ fontWeight: 700, lineHeight: 1.2 }}>Marktchancen</Typography>
|
<Typography sx={{ fontWeight: 700, fontSize: '1.125rem', lineHeight: 1.3, color: '#0f172a' }}>Marktchancen</Typography>
|
||||||
<Typography variant="caption" color="text.secondary">
|
<Typography variant="body2" color="text.secondary">
|
||||||
KI-Signale aus Web-Quellen und menschliche Netzwerk-Hinweise — mit Portfolio-Match
|
KI-Signale aus Web-Quellen und menschliche Netzwerk-Hinweise — mit Portfolio-Match
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -76,9 +76,9 @@ export default function MatchCenter() {
|
|||||||
<Box sx={{ display: 'flex', flexDirection: 'column', height: 'calc(100vh - 64px)', overflow: 'hidden' }}>
|
<Box sx={{ display: 'flex', flexDirection: 'column', height: 'calc(100vh - 64px)', overflow: 'hidden' }}>
|
||||||
|
|
||||||
{/* Header */}
|
{/* 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 }}>
|
<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>
|
<Typography variant="body2" color="text.secondary">Automatisch berechnete Matches</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
|
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
|
||||||
|
|||||||
@@ -339,10 +339,11 @@ export default function MyListings() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ maxWidth: 1040, mx: 'auto', px: 3, py: 4 }}>
|
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 3 }}>
|
{/* Header */}
|
||||||
|
<Box sx={{ bgcolor: 'white', borderBottom: '1px solid #e8e7e4', px: 3, py: 2.5, flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
<Box>
|
<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">
|
<Typography variant="body2" color="text.secondary">
|
||||||
Direkt erstellte Inserate — unabhängig vom Portfolio
|
Direkt erstellte Inserate — unabhängig vom Portfolio
|
||||||
</Typography>
|
</Typography>
|
||||||
@@ -357,6 +358,8 @@ export default function MyListings() {
|
|||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ flex: 1, overflowY: 'auto', px: 3, py: 3, maxWidth: 1040, width: '100%', mx: 'auto', boxSizing: 'border-box' }}>
|
||||||
|
|
||||||
{actionError && (
|
{actionError && (
|
||||||
<Alert severity="error" sx={{ mb: 2 }} onClose={() => setActionError(null)}>
|
<Alert severity="error" sx={{ mb: 2 }} onClose={() => setActionError(null)}>
|
||||||
{actionError}
|
{actionError}
|
||||||
@@ -415,6 +418,8 @@ export default function MyListings() {
|
|||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
</Box>{/* end scroll container */}
|
||||||
|
|
||||||
{/* Detail / edit drawer */}
|
{/* Detail / edit drawer */}
|
||||||
<Drawer
|
<Drawer
|
||||||
anchor="right"
|
anchor="right"
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ export default function NewListing() {
|
|||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</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 }}>
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
|
||||||
{form.isPrefilled
|
{form.isPrefilled
|
||||||
? `Einheit ${pre.unitLabel ?? ''} aus Portfolio vorausgefüllt — Angaben prüfen und veröffentlichen.`
|
? `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'>(() =>
|
const [view, setView] = useState<'list' | 'grid'>(() =>
|
||||||
(localStorage.getItem('view-properties') as 'list' | 'grid') ?? 'list'
|
(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(() =>
|
const [headerOpen, setHeaderOpen] = useState(() =>
|
||||||
localStorage.getItem('props-header-open') !== 'false'
|
localStorage.getItem('props-header-open') !== 'false'
|
||||||
)
|
)
|
||||||
@@ -109,10 +112,31 @@ export default function Properties() {
|
|||||||
title="Objektverwaltung"
|
title="Objektverwaltung"
|
||||||
subtitle={`${filtered.length} von ${properties.length} Objekten`}
|
subtitle={`${filtered.length} von ${properties.length} Objekten`}
|
||||||
secondaryActions={
|
secondaryActions={
|
||||||
<ViewToggle
|
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
|
||||||
view={view}
|
{view === 'grid' && (
|
||||||
onChange={v => { setView(v); localStorage.setItem('view-properties', v) }}
|
<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' }}>
|
<Box sx={{ flex: 1, overflowY: 'auto' }}>
|
||||||
{view === 'grid' ? (
|
{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 => (
|
{filtered.map(p => (
|
||||||
<PropertyIntelligenceCard key={p.id} property={p} onSelect={setSelectedId} inquiryCount={inquiryCountByProperty.get(p.id) ?? 0} />
|
<PropertyIntelligenceCard key={p.id} property={p} onSelect={setSelectedId} inquiryCount={inquiryCountByProperty.get(p.id) ?? 0} />
|
||||||
))}
|
))}
|
||||||
|
|||||||
Reference in New Issue
Block a user