13 Commits

Author SHA1 Message Date
Benjamin Sutter 5259579f85 refactor(PropertyIntelligenceCard): smaller image, larger text, more spacing
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-12 00:02:03 +02:00
Benjamin Sutter beecf754d5 refactor: premium design polish — cards, results, grid view
- PropertyIntelligenceCard: aspect-ratio image, hover animation, compact
  data-completeness row, tighter body padding
- Results: replace verbose Card with compact active-search strip, remove
  DecisionContextPanel
- Properties: add 3/5/10 column toggle in grid view, persist to localStorage
- NeedInput, UnifiedResultCard/Feed, MatchCardViewModel: cleanup from
  earlier session

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 00:06:23 +02:00
Benjamin Sutter 6e67c698ce fix: standardize subtitle typography in all page headers
All page header subtitles now use variant="body2" color="text.secondary"
(0.875rem gray). Fixed caption→body2 in DataQuality and MarketIntelligence,
and aligned DashboardHeader title/subtitle to the global standard.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 00:05:30 +02:00
Benjamin Sutter 3dcac2dcb6 fix: MyListings — sticky header bar matches standard page layout
Restructured from scrollable content-box to flex-column with a
fixed header bar (borderBottom #e8e7e4, py:2.5) and a scrollable
content area below — identical pattern to all other pages.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 00:02:40 +02:00
Benjamin Sutter 5035445d2e refactor: standardize page title typography across all workspaces
All page-level headers now use fontSize:1.125rem, fontWeight:700,
color:#0f172a, container py:2.5, borderBottom:#e8e7e4 — matching
the standard established in PageHeader. Removes variant="h5"/h6"
inconsistencies across Supply, Demand, and shared components.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 00:01:15 +02:00
Benjamin Sutter 017835b083 fix(typography): remove DM Serif Display — all UI text now uses Inter
PageHeader titles, MatchCardCompact score badge, and IntelligenceMatchCard
score badge were the only 3 places using the serif override. All now use
the theme-defined Inter stack (fontWeight 600/700, letterSpacing tight).
PDF/print previews (BerichtDialog, LatentInquiryReportPreview) intentionally
keep their serif fonts — those are document renderers, not UI.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 23:53:15 +02:00
Benjamin Sutter d945fb1ab6 refine(anfragencenter): compact match cards 168px, reply field 7 rows
Match cards reduced to 168px wide, 60px image height — supplementary context
should not visually dominate the primary chat content. Reply textarea grows
to 7 rows so the composer takes more of the available space.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 23:48:35 +02:00
Benjamin Sutter f783cf6881 fix(anfragencenter): use aspectRatio 3/2 for match card images like PropertyIntelligenceCard
Replaces fixed height: 90px with aspectRatio: '3/2' — the same ratio used in
supply property cards. Ensures consistent, undistorted image crops across all
card surfaces. objectFit: cover guaranteed via <img> element.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 23:47:11 +02:00
Benjamin Sutter cf9089b5a2 fix(anfragencenter): match cards fixed width 240px, horizontal scroll rail
Replace flex-stretch layout (cards filling full width = distorted images)
with fixed 240px cards in a horizontal overflow-auto rail. Shows as many
cards as fit the screen naturally; image height increased to 90px for a
cleaner 240×90 (≈ 8:3) proportion.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 23:46:04 +02:00
Benjamin Sutter b0f675c319 refine(anfragencenter): property bar top, matches-only bottom, AI draft button
Layout: property context bar (44x44 thumbnail + title + key facts + Ansehen)
sits between inquiry header and chat. Bottom strip now shows only the
2-card matches slider — no duplicate property info. Images use objectFit:cover
via <img> for consistent non-distorted rendering. Match score shown as badge
overlay on image. Reply composer gains KI-Entwurf button (Sparkles, purple)
that fills a salutation-prefixed template; an inline label marks it as
AI-generated until the user edits.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 23:43:29 +02:00
Benjamin Sutter da3b5b55cc refine(anfragencenter): clearer selection, card-style property strip, 2-item slider
- InquiryListRow: selected state now uses bright blue (4px #2563eb border,
  #eff6ff bg, blue tenant name) — unmistakable at a glance
- RelatedPropertyCardPanel compact: left panel is a mini property card matching
  supply card style (border, borderRadius 2, hover shadow, 78px image);
  right panel shows exactly 2 match slider cards with ChevronLeft/Right nav
  when more than 2 exist; match cards mirror property card visual language
- Strip maxHeight raised to 260px; reply composer rows 4→5

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 23:34:50 +02:00
Benjamin Sutter 76e52dae1d refine(anfragencenter): property strip with image thumbnail + horizontal match rail
Compact strip redesign: left side shows 72x80px property thumbnail + key facts
+ "Ansehen" link. Right side shows a horizontal scroll rail (148px cards) with
placeholder/actual image, title, score, and top match reason for each
additional property. Uses imageUrl from AdditionalPropertyMatch domain.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 23:29:29 +02:00
Benjamin Sutter 4c79d9f969 feat(anfragencenter): 2-column layout — chat top, property strip bottom
Replace 3-panel side-by-side layout with a vertical stack on the right:
InquiryChat takes full height (flex:1), RelatedPropertyCardPanel becomes a
compact 200px bottom strip with a 2-column layout (property info left,
further matches right). Active row selection background deepened to #eef2f8
for clearer focus indication.

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