Compare commits
29 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| be5523bcee | |||
| a8b54af0b8 | |||
| 6206447dae | |||
| 8cb6f21581 | |||
| 29be4f6e54 | |||
| 09095a1a32 | |||
| fb029cf0bc | |||
| 3e1e945661 | |||
| 74f9660581 | |||
| fca113e7ab | |||
| fd6ed105bb | |||
| 647493bf3c | |||
| 5d26dd4a6c | |||
| e169f8e310 | |||
| 7a0909e36a | |||
| 6aa4f96bd2 | |||
| 5259579f85 | |||
| beecf754d5 | |||
| 6e67c698ce | |||
| 3dcac2dcb6 | |||
| 5035445d2e | |||
| 017835b083 | |||
| d945fb1ab6 | |||
| f783cf6881 | |||
| cf9089b5a2 | |||
| b0f675c319 | |||
| da3b5b55cc | |||
| 76e52dae1d | |||
| 4c79d9f969 |
+17
-1
@@ -13,7 +13,23 @@
|
||||
"Bash(git pull *)",
|
||||
"Bash(node scripts/check-tokens.js)",
|
||||
"Bash(echo \"EXIT:$?\")",
|
||||
"Bash(echo \"EXIT_CODE:$?\")"
|
||||
"Bash(echo \"EXIT_CODE:$?\")",
|
||||
"Bash(Get-ChildItem \"c:\\\\Users\\\\beni_\\\\AppData\\\\Local\\\\Temp\\\\docx-gen\\\\node_modules\\\\docx\" -Recurse -Name)",
|
||||
"Bash(start \"C:\\\\Users\\\\beni_\\\\OneDrive\\\\Desktop\\\\property-match\\\\Aenderungsprotokoll_Meeting_26052026.docx\")",
|
||||
"Bash(start http://localhost:5176)",
|
||||
"Bash(start http://localhost:5176/demand/ai-search)",
|
||||
"Bash(node -e ' *)",
|
||||
"Skill(run)",
|
||||
"Skill(run:*)",
|
||||
"Bash(curl -s -o /dev/null -w \"%{http_code}\" http://localhost:5173)",
|
||||
"Bash(powershell.exe -Command \"Start-Process 'http://localhost:5173'\")",
|
||||
"Bash(findstr \"LISTENING\")",
|
||||
"Bash(findstr \":517\")",
|
||||
"Bash(findstr \"LISTEN\")",
|
||||
"Bash(findstr \":5\")",
|
||||
"Bash(echo \"Exit: $?\")",
|
||||
"Bash(npx vercel *)",
|
||||
"Bash(Start-Process \"http://localhost:5180\")"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,23 @@
|
||||
"Bash(git commit *)",
|
||||
"Bash(node -e ' *)",
|
||||
"Bash(git stash *)",
|
||||
"Read(//c/Users/beni_/.claude/projects/c--Users-beni--OneDrive-Desktop-property-match/8e388ddd-e02e-47fe-8bb9-cdb8164c9fc3/tool-results/**)"
|
||||
"Read(//c/Users/beni_/.claude/projects/c--Users-beni--OneDrive-Desktop-property-match/8e388ddd-e02e-47fe-8bb9-cdb8164c9fc3/tool-results/**)",
|
||||
"Bash(pandoc --version)",
|
||||
"Bash(npm list *)",
|
||||
"Read(//c/Program Files/LibreOffice/program/**)",
|
||||
"Read(//c/Program Files/Microsoft Office/**)",
|
||||
"Read(//c/Program Files \\(x86\\)/**)",
|
||||
"Bash(mkdir -p /tmp/docx-gen)",
|
||||
"Read(//tmp/**)",
|
||||
"Bash(npm init *)",
|
||||
"Bash(node generate.js)",
|
||||
"Bash(timeout 8 bash -c \"until curl -s http://localhost:5173 > /dev/null; do sleep 1; done\")",
|
||||
"Bash(dir \"c:\\\\Users\\\\beni_\\\\OneDrive\\\\Desktop\\\\property-match\\\\src\" -Depth 2)",
|
||||
"Bash(start \"\" \"http://localhost:5178/demand/results\")",
|
||||
"Bash(start \"\" \"http://localhost:5178/supply/properties\")"
|
||||
],
|
||||
"additionalDirectories": [
|
||||
"c:\\Users\\beni_\\AppData\\Local\\Temp\\docx-gen"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'
|
||||
import { Box, IconButton, Tooltip, Typography, useMediaQuery, useTheme } from '@mui/material'
|
||||
import { ArrowLeft, LayoutGrid, List as ListIcon, Inbox } from 'lucide-react'
|
||||
import { useActiveInquiries } from '../../hooks/useInquiries'
|
||||
import type { InquiryFilters } from '../../provider/IInquiryProvider'
|
||||
import { EmptyState } from '../ui'
|
||||
import { InquiryList } from './InquiryList'
|
||||
import { InquiryCardGrid } from './InquiryCardGrid'
|
||||
@@ -17,8 +18,14 @@ function loadViewMode(): ViewMode {
|
||||
return stored === 'grid' ? 'grid' : 'list'
|
||||
}
|
||||
|
||||
export function ActiveInquiriesTab() {
|
||||
const { data: inquiries = [], isLoading } = useActiveInquiries()
|
||||
interface Props {
|
||||
filters?: InquiryFilters
|
||||
emptyTitle?: string
|
||||
emptyDescription?: string
|
||||
}
|
||||
|
||||
export function ActiveInquiriesTab({ filters, emptyTitle, emptyDescription }: Props = {}) {
|
||||
const { data: inquiries = [], isLoading } = useActiveInquiries(filters)
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null)
|
||||
const [view, setView] = useState<ViewMode>(loadViewMode())
|
||||
const theme = useTheme()
|
||||
@@ -41,8 +48,8 @@ export function ActiveInquiriesTab() {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={<Inbox size={40} />}
|
||||
title="Keine aktiven Anfragen"
|
||||
description="Sobald Interessenten Anfragen zu Ihren Objekten stellen, erscheinen diese hier."
|
||||
title={emptyTitle ?? 'Keine aktiven Anfragen'}
|
||||
description={emptyDescription ?? 'Sobald Interessenten Anfragen zu Ihren Objekten stellen, erscheinen diese hier.'}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -96,19 +96,9 @@ export function InquiryCard({ inquiry, selected, onClick }: InquiryCardProps) {
|
||||
{formatInquiryDate(inquiry.createdAt)}
|
||||
</Typography>
|
||||
{inquiry.matchScore !== undefined && (
|
||||
<Box
|
||||
sx={{
|
||||
px: 1,
|
||||
py: 0.25,
|
||||
borderRadius: 1,
|
||||
bgcolor: '#e0e7ff',
|
||||
color: '#3730a3',
|
||||
fontWeight: 600,
|
||||
fontSize: '0.7rem',
|
||||
}}
|
||||
>
|
||||
Match {inquiry.matchScore}%
|
||||
</Box>
|
||||
<Typography sx={{ fontSize: '0.75rem', fontWeight: 700, color: '#152642' }}>
|
||||
{inquiry.matchScore}%
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
@@ -56,6 +56,7 @@ export function InquiryChat({
|
||||
<InquiryReplyComposer
|
||||
inquiryId={inquiry.id}
|
||||
defaultSubject={inquiry.subject}
|
||||
tenantName={inquiry.tenantName}
|
||||
pendingAttachment={pendingAttachment}
|
||||
onPendingAttachmentConsumed={onPendingAttachmentConsumed}
|
||||
/>
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Box, Button, CircularProgress, Typography } from '@mui/material'
|
||||
import { ClipboardList } from 'lucide-react'
|
||||
import { ArrowRight, Building2, ClipboardList, MapPin, Ruler } from 'lucide-react'
|
||||
import { useNavigate } from 'react-router'
|
||||
import { useInquiryById, useMarkThreadAsRead } from '../../hooks/useInquiries'
|
||||
import { usePropertyById } from '../../hooks/useProperties'
|
||||
import { InquiryChat } from './InquiryChat'
|
||||
import { RelatedPropertyCardPanel } from './RelatedPropertyCardPanel'
|
||||
import type { Attachment } from '../../domain/inquiry'
|
||||
@@ -12,7 +14,9 @@ interface InquiryDetailPanelProps {
|
||||
|
||||
export function InquiryDetailPanel({ inquiryId }: InquiryDetailPanelProps) {
|
||||
const { data: inquiry, isLoading } = useInquiryById(inquiryId)
|
||||
const { data: property } = usePropertyById(inquiry?.propertyId ?? '')
|
||||
const markRead = useMarkThreadAsRead()
|
||||
const navigate = useNavigate()
|
||||
const [preparationOpen, setPreparationOpen] = useState(false)
|
||||
const [offerWizardOpen, setOfferWizardOpen] = useState(false)
|
||||
const [pendingAttachment, setPendingAttachment] = useState<Attachment | null>(null)
|
||||
@@ -43,10 +47,13 @@ export function InquiryDetailPanel({ inquiryId }: InquiryDetailPanelProps) {
|
||||
}
|
||||
|
||||
const isLatentInquiry = !!inquiry.needId
|
||||
const propertyImage = property?.images?.[0]
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
||||
|
||||
{/* Header: tenant + subject + action */}
|
||||
<Box
|
||||
sx={{
|
||||
px: 2.5,
|
||||
@@ -92,21 +99,87 @@ export function InquiryDetailPanel({ inquiryId }: InquiryDetailPanelProps) {
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box sx={{ flex: 1, display: 'flex', flexDirection: { xs: 'column', md: 'row' }, overflow: 'hidden' }}>
|
||||
{/* Property context bar */}
|
||||
<Box
|
||||
sx={{
|
||||
px: 2,
|
||||
py: 1,
|
||||
borderBottom: '1px solid #e2e8f0',
|
||||
bgcolor: '#f8fafc',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1.5,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 1,
|
||||
overflow: 'hidden',
|
||||
flexShrink: 0,
|
||||
bgcolor: '#e8e7e4',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
{propertyImage ? (
|
||||
<Box
|
||||
component="img"
|
||||
src={propertyImage}
|
||||
alt={property?.title}
|
||||
sx={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
|
||||
/>
|
||||
) : (
|
||||
<Building2 size={18} color="#94a3b8" />
|
||||
)}
|
||||
</Box>
|
||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||
<Typography
|
||||
sx={{ fontWeight: 600, color: '#0f172a', fontSize: '0.85rem', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', lineHeight: 1.3 }}
|
||||
>
|
||||
{property?.title ?? '—'}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, color: '#64748b' }}>
|
||||
<MapPin size={11} style={{ flexShrink: 0 }} />
|
||||
<Typography variant="caption" sx={{ fontSize: '0.72rem', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{property?.location.city}
|
||||
{property?.areaSqm ? ` · ${property.areaSqm.toLocaleString('de-CH')} m²` : ''}
|
||||
{property?.rentPricePerSqm ? ` · CHF ${property.rentPricePerSqm}/m²/J.` : ''}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
<Button
|
||||
size="small"
|
||||
endIcon={<ArrowRight size={12} />}
|
||||
onClick={() => navigate('/supply/properties')}
|
||||
sx={{ textTransform: 'none', fontSize: '0.75rem', color: '#152642', whiteSpace: 'nowrap', flexShrink: 0 }}
|
||||
>
|
||||
Objekt ansehen
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{/* Chat (flex: 1) */}
|
||||
<Box sx={{ flex: 1, overflow: 'hidden' }}>
|
||||
<InquiryChat
|
||||
inquiry={inquiry}
|
||||
onCreateOffer={() => setOfferWizardOpen(true)}
|
||||
pendingAttachment={pendingAttachment}
|
||||
onPendingAttachmentConsumed={() => setPendingAttachment(null)}
|
||||
/>
|
||||
<Box sx={{ width: { xs: '100%', md: 300 }, flexShrink: 0, borderTop: { xs: '1px solid #e2e8f0', md: 'none' }, maxHeight: { xs: 280, md: 'none' }, overflowY: 'auto' }}>
|
||||
</Box>
|
||||
|
||||
{/* Bottom: weitere passende Objekte */}
|
||||
<Box sx={{ flexShrink: 0, borderTop: '1px solid #e2e8f0' }}>
|
||||
<RelatedPropertyCardPanel
|
||||
propertyId={inquiry.propertyId}
|
||||
inquiryId={inquiry.id}
|
||||
compact
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{preparationOpen && (
|
||||
<PreparationWizardLazy
|
||||
|
||||
@@ -17,11 +17,10 @@ export function InquiryListRow({ inquiry, selected, onClick }: InquiryListRowPro
|
||||
onClick={onClick}
|
||||
sx={{
|
||||
px: 2,
|
||||
py: 1.5,
|
||||
py: 1,
|
||||
borderBottom: '1px solid #e2e8f0',
|
||||
borderLeft: '3px solid',
|
||||
borderLeftColor: selected ? '#152642' : hasUnread ? '#2563eb' : 'transparent',
|
||||
bgcolor: selected ? '#f1f5f9' : 'white',
|
||||
borderLeft: selected ? '4px solid #2563eb' : `3px solid ${hasUnread ? '#2563eb' : 'transparent'}`,
|
||||
bgcolor: selected ? '#eff6ff' : 'white',
|
||||
cursor: 'pointer',
|
||||
transition: 'background-color 0.15s, border-color 0.15s',
|
||||
'&:hover': { bgcolor: selected ? '#f1f5f9' : '#f8fafc' },
|
||||
@@ -30,7 +29,7 @@ export function InquiryListRow({ inquiry, selected, onClick }: InquiryListRowPro
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 0.5 }}>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{ fontWeight: hasUnread ? 700 : 600, color: '#0f172a', fontSize: '0.85rem' }}
|
||||
sx={{ fontWeight: selected || hasUnread ? 700 : 600, color: selected ? '#1d4ed8' : '#0f172a', fontSize: '0.85rem' }}
|
||||
>
|
||||
{inquiry.tenantName}
|
||||
{inquiry.tenantCompany ? ` · ${inquiry.tenantCompany}` : ''}
|
||||
@@ -76,20 +75,9 @@ export function InquiryListRow({ inquiry, selected, onClick }: InquiryListRowPro
|
||||
{propertyLabelFromId(inquiry.propertyId)}
|
||||
</Typography>
|
||||
{inquiry.matchScore !== undefined && (
|
||||
<Box
|
||||
sx={{
|
||||
ml: 'auto',
|
||||
px: 0.75,
|
||||
py: 0.125,
|
||||
borderRadius: 1,
|
||||
bgcolor: '#e0e7ff',
|
||||
color: '#3730a3',
|
||||
fontWeight: 600,
|
||||
fontSize: '0.7rem',
|
||||
}}
|
||||
>
|
||||
<Typography sx={{ ml: 'auto', fontSize: '0.75rem', fontWeight: 700, color: '#152642' }}>
|
||||
{inquiry.matchScore}%
|
||||
</Box>
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
<Typography variant="caption" sx={{ color: '#94a3b8', fontSize: '0.7rem', mt: 0.5, display: 'block' }}>
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
TextField,
|
||||
Typography,
|
||||
} from '@mui/material'
|
||||
import { Send, Paperclip, X } from 'lucide-react'
|
||||
import { Paperclip, Send, Sparkles, X } from 'lucide-react'
|
||||
import { useSendInquiryReply } from '../../hooks/useInquiries'
|
||||
import { useToastStore } from '../../stores/toastStore'
|
||||
import type { Attachment } from '../../domain/inquiry'
|
||||
@@ -16,6 +16,7 @@ import { formatFileSize } from './inquiryUtils'
|
||||
interface InquiryReplyComposerProps {
|
||||
inquiryId: string
|
||||
defaultSubject: string
|
||||
tenantName?: string
|
||||
onSent?: () => void
|
||||
pendingAttachment?: Attachment | null
|
||||
onPendingAttachmentConsumed?: () => void
|
||||
@@ -24,6 +25,7 @@ interface InquiryReplyComposerProps {
|
||||
export function InquiryReplyComposer({
|
||||
inquiryId,
|
||||
defaultSubject,
|
||||
tenantName,
|
||||
onSent,
|
||||
pendingAttachment,
|
||||
onPendingAttachmentConsumed,
|
||||
@@ -33,6 +35,7 @@ export function InquiryReplyComposer({
|
||||
)
|
||||
const [body, setBody] = useState('')
|
||||
const [attachments, setAttachments] = useState<Attachment[]>([])
|
||||
const [isAIDraft, setIsAIDraft] = useState(false)
|
||||
|
||||
const sendReply = useSendInquiryReply()
|
||||
const showToast = useToastStore(s => s.showToast)
|
||||
@@ -49,6 +52,14 @@ export function InquiryReplyComposer({
|
||||
|
||||
const sending = sendReply.isPending
|
||||
|
||||
function handleAIDraft() {
|
||||
const salutation = tenantName ? `Sehr geehrte/r ${tenantName}` : 'Sehr geehrte Damen und Herren'
|
||||
setBody(
|
||||
`${salutation},\n\nVielen Dank für Ihre Anfrage. Gerne bestätigen wir, dass das Objekt zum gewünschten Zeitpunkt verfügbar ist.\n\nWir würden Ihnen gerne einen Besichtigungstermin vorschlagen — bitte teilen Sie uns Ihre Verfügbarkeit mit, damit wir einen passenden Termin finden können.\n\nFür Rückfragen stehen wir Ihnen jederzeit gerne zur Verfügung.\n\nMit freundlichen Grüssen`
|
||||
)
|
||||
setIsAIDraft(true)
|
||||
}
|
||||
|
||||
const handleAddMockAttachment = () => {
|
||||
const name = `Anhang_${attachments.length + 1}.pdf`
|
||||
setAttachments(prev => [
|
||||
@@ -82,6 +93,7 @@ export function InquiryReplyComposer({
|
||||
showToast('Antwort gesendet', 'success')
|
||||
setBody('')
|
||||
setAttachments([])
|
||||
setIsAIDraft(false)
|
||||
onSent?.()
|
||||
}
|
||||
|
||||
@@ -103,15 +115,26 @@ export function InquiryReplyComposer({
|
||||
onChange={e => setSubject(e.target.value)}
|
||||
fullWidth
|
||||
/>
|
||||
|
||||
{isAIDraft && (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
|
||||
<Sparkles size={12} color="#7c3aed" />
|
||||
<Typography variant="caption" sx={{ color: '#7c3aed', fontSize: '0.7rem', fontWeight: 500 }}>
|
||||
KI-Entwurf — bitte prüfen und anpassen
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<TextField
|
||||
size="small"
|
||||
label="Nachricht"
|
||||
value={body}
|
||||
onChange={e => setBody(e.target.value)}
|
||||
onChange={e => { setBody(e.target.value); setIsAIDraft(false) }}
|
||||
multiline
|
||||
rows={4}
|
||||
rows={7}
|
||||
placeholder="Antwort verfassen..."
|
||||
fullWidth
|
||||
sx={isAIDraft ? { '& .MuiOutlinedInput-root': { borderColor: '#ddd6fe' }, '& fieldset': { borderColor: '#ddd6fe !important' } } : {}}
|
||||
/>
|
||||
|
||||
{attachments.length > 0 && (
|
||||
@@ -149,15 +172,32 @@ export function InquiryReplyComposer({
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, justifyContent: 'space-between' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, justifyContent: 'space-between' }}>
|
||||
<Box sx={{ display: 'flex', gap: 0.75 }}>
|
||||
<Button
|
||||
size="small"
|
||||
startIcon={<Paperclip size={14} />}
|
||||
startIcon={<Sparkles size={13} />}
|
||||
onClick={handleAIDraft}
|
||||
sx={{
|
||||
textTransform: 'none',
|
||||
fontSize: '0.75rem',
|
||||
color: '#7c3aed',
|
||||
borderColor: '#ddd6fe',
|
||||
'&:hover': { bgcolor: '#f5f3ff', borderColor: '#c4b5fd' },
|
||||
}}
|
||||
variant="outlined"
|
||||
>
|
||||
KI-Entwurf
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
startIcon={<Paperclip size={13} />}
|
||||
onClick={handleAddMockAttachment}
|
||||
sx={{ textTransform: 'none' }}
|
||||
sx={{ textTransform: 'none', fontSize: '0.75rem' }}
|
||||
>
|
||||
Anhang
|
||||
</Button>
|
||||
</Box>
|
||||
<Button
|
||||
variant="contained"
|
||||
size="small"
|
||||
|
||||
@@ -2,6 +2,9 @@ import { Box, Button, CircularProgress, IconButton, TextField, Typography } from
|
||||
import { ArrowLeft, FileText, Paperclip, Send, X } from 'lucide-react'
|
||||
import { useOfferWizardStore } from '../../stores/offerWizardStore'
|
||||
import { useSendOffer } from '../../hooks/useOffers'
|
||||
import { useCreateOffer } from '../../hooks/useInquiries'
|
||||
import { useLatentNeedById } from '../../hooks/useLatentNeeds'
|
||||
import { useSessionStore } from '../../stores/sessionStore'
|
||||
import { useToastStore } from '../../stores/toastStore'
|
||||
import { AiOfferEmailButton } from './AiOfferEmailButton'
|
||||
import { mockProperties } from '../../mock-data/properties'
|
||||
@@ -24,6 +27,9 @@ export function OfferChatComposer() {
|
||||
const reset = useOfferWizardStore(s => s.reset)
|
||||
|
||||
const sendOffer = useSendOffer()
|
||||
const createOffer = useCreateOffer()
|
||||
const { data: latentNeed } = useLatentNeedById(needId)
|
||||
const currentUser = useSessionStore(s => s.currentUser)
|
||||
const showToast = useToastStore(s => s.showToast)
|
||||
|
||||
const propertyTitles = selectedPropertyIds.map(id => {
|
||||
@@ -60,6 +66,19 @@ export function OfferChatComposer() {
|
||||
showToast(`Fehler: ${res.error}`, 'error')
|
||||
return
|
||||
}
|
||||
// Angebot als Konversation im Nachfrager-Postfach materialisieren
|
||||
await createOffer.mutateAsync({
|
||||
organizationId: currentUser?.organizationId ?? 'org-wincasa',
|
||||
tenantOrgId: latentNeed?.tenantOrgId ?? 'org-mobimo',
|
||||
needId: needId ?? '',
|
||||
propertyId: selectedPropertyIds[0] ?? '',
|
||||
offeredPropertyIds: selectedPropertyIds,
|
||||
subject,
|
||||
message: body,
|
||||
senderName: currentUser?.organizationName ?? 'Wincasa AG',
|
||||
recipientName: latentNeed?.tenantCompany,
|
||||
attachments,
|
||||
})
|
||||
showToast('Angebot erfolgreich gesendet', 'success')
|
||||
reset()
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Box, Button, CircularProgress, Divider, Typography } from '@mui/material'
|
||||
import { ArrowRight, Building2, Calendar, MapPin, Ruler, Trophy } from 'lucide-react'
|
||||
import { ArrowRight, Building2, Calendar, MapPin, Ruler } from 'lucide-react'
|
||||
import { useNavigate } from 'react-router'
|
||||
import { usePropertyById } from '../../hooks/useProperties'
|
||||
import { useAdditionalMatchesForInquiry } from '../../hooks/useMatches'
|
||||
@@ -8,9 +8,10 @@ import type { AdditionalPropertyMatch } from '../../domain/additionalMatch'
|
||||
interface RelatedPropertyCardPanelProps {
|
||||
propertyId: string
|
||||
inquiryId: string
|
||||
compact?: boolean
|
||||
}
|
||||
|
||||
export function RelatedPropertyCardPanel({ propertyId, inquiryId }: RelatedPropertyCardPanelProps) {
|
||||
export function RelatedPropertyCardPanel({ propertyId, inquiryId, compact }: RelatedPropertyCardPanelProps) {
|
||||
const { data: property, isLoading } = usePropertyById(propertyId)
|
||||
const navigate = useNavigate()
|
||||
const {
|
||||
@@ -18,7 +19,7 @@ export function RelatedPropertyCardPanel({ propertyId, inquiryId }: RelatedPrope
|
||||
isLoading: loadingMatches,
|
||||
} = useAdditionalMatchesForInquiry(inquiryId, { minScore: 80, excludePropertyId: propertyId })
|
||||
|
||||
if (isLoading) {
|
||||
if (isLoading && !compact) {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', p: 4 }}>
|
||||
<CircularProgress size={20} />
|
||||
@@ -26,6 +27,44 @@ export function RelatedPropertyCardPanel({ propertyId, inquiryId }: RelatedPrope
|
||||
)
|
||||
}
|
||||
|
||||
if (compact) {
|
||||
return (
|
||||
<Box sx={{ px: 1.5, pt: 1.25, pb: 1.5, bgcolor: 'white' }}>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{ color: '#64748b', fontWeight: 600, textTransform: 'uppercase', letterSpacing: 0.5, fontSize: '0.65rem', display: 'block', mb: 1 }}
|
||||
>
|
||||
Weitere passende Objekte
|
||||
</Typography>
|
||||
|
||||
{loadingMatches ? (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 1.5 }}>
|
||||
<CircularProgress size={16} />
|
||||
</Box>
|
||||
) : additionalMatches.length === 0 ? (
|
||||
<Typography variant="caption" sx={{ color: '#94a3b8', fontSize: '0.72rem' }}>
|
||||
Keine weiteren Matches
|
||||
</Typography>
|
||||
) : (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
gap: 1.25,
|
||||
overflowX: 'auto',
|
||||
pb: 0.5,
|
||||
'&::-webkit-scrollbar': { height: 3 },
|
||||
'&::-webkit-scrollbar-thumb': { bgcolor: '#cbd5e1', borderRadius: 2 },
|
||||
}}
|
||||
>
|
||||
{additionalMatches.map(m => (
|
||||
<MatchSliderCard key={m.propertyId} match={m} />
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
if (!property) {
|
||||
return (
|
||||
<Box sx={{ p: 2 }}>
|
||||
@@ -51,25 +90,22 @@ export function RelatedPropertyCardPanel({ propertyId, inquiryId }: RelatedPrope
|
||||
overflowY: 'auto',
|
||||
}}
|
||||
>
|
||||
<Typography variant="caption" sx={{ color: '#64748b', fontWeight: 600, textTransform: 'uppercase', letterSpacing: 0.5 }}>
|
||||
Bezogenes Objekt
|
||||
</Typography>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
height: 140,
|
||||
aspectRatio: '3/2',
|
||||
borderRadius: 1.5,
|
||||
overflow: 'hidden',
|
||||
bgcolor: '#e8e7e4',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundImage: image ? `url(${image})` : 'none',
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center',
|
||||
bgcolor: '#f4f3f0',
|
||||
}}
|
||||
>
|
||||
{!image && <Building2 size={32} color="#94a3b8" />}
|
||||
{image ? (
|
||||
<Box component="img" src={image} alt={property.title}
|
||||
sx={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }} />
|
||||
) : (
|
||||
<Box sx={{ width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Building2 size={32} color="#94a3b8" />
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Typography variant="body1" sx={{ fontWeight: 600, color: '#0f172a', fontSize: '0.95rem' }}>
|
||||
@@ -77,20 +113,20 @@ export function RelatedPropertyCardPanel({ propertyId, inquiryId }: RelatedPrope
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, color: '#475569', fontSize: '0.8125rem' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, color: '#475569' }}>
|
||||
<MapPin size={14} />
|
||||
<Typography variant="body2" sx={{ fontSize: '0.8125rem' }}>
|
||||
{property.location.city}
|
||||
{property.location.district ? `, ${property.location.district}` : ''}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, color: '#475569', fontSize: '0.8125rem' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, color: '#475569' }}>
|
||||
<Ruler size={14} />
|
||||
<Typography variant="body2" sx={{ fontSize: '0.8125rem' }}>
|
||||
{property.areaSqm.toLocaleString('de-CH')} m² · CHF {property.rentPricePerSqm}/m²/Jahr
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, color: '#475569', fontSize: '0.8125rem' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, color: '#475569' }}>
|
||||
<Calendar size={14} />
|
||||
<Typography variant="body2" sx={{ fontSize: '0.8125rem' }}>
|
||||
Verfügbar ab {new Date(property.availabilityDate).toLocaleDateString('de-CH')}
|
||||
@@ -114,14 +150,10 @@ export function RelatedPropertyCardPanel({ propertyId, inquiryId }: RelatedPrope
|
||||
Objekt ansehen
|
||||
</Button>
|
||||
|
||||
{/* Weitere Matches */}
|
||||
<Divider sx={{ my: 0.5 }} />
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
|
||||
<Trophy size={14} color="#d97706" />
|
||||
<Typography variant="caption" sx={{ color: '#64748b', fontWeight: 600, textTransform: 'uppercase', letterSpacing: 0.5, fontSize: '0.68rem' }}>
|
||||
Weitere passende Objekte
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{loadingMatches ? (
|
||||
<CircularProgress size={16} sx={{ alignSelf: 'center' }} />
|
||||
@@ -140,74 +172,117 @@ export function RelatedPropertyCardPanel({ propertyId, inquiryId }: RelatedPrope
|
||||
)
|
||||
}
|
||||
|
||||
function AdditionalMatchCard({ match }: { match: AdditionalPropertyMatch }) {
|
||||
function MatchSliderCard({ match }: { match: AdditionalPropertyMatch }) {
|
||||
const navigate = useNavigate()
|
||||
return (
|
||||
<Box
|
||||
onClick={() => navigate('/supply/properties')}
|
||||
sx={{
|
||||
border: '1px solid #e2e8f0',
|
||||
flexShrink: 0,
|
||||
width: 168,
|
||||
border: '1px solid #e8e7e4',
|
||||
borderRadius: 1.5,
|
||||
overflow: 'hidden',
|
||||
cursor: 'pointer',
|
||||
bgcolor: 'white',
|
||||
transition: 'border-color 0.15s, box-shadow 0.15s',
|
||||
'&:hover': {
|
||||
borderColor: '#b0aead',
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.08)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{match.imageUrl && (
|
||||
{/* Image — 16:9 crop, compact supplementary card */}
|
||||
<Box sx={{ position: 'relative', height: 60, bgcolor: '#f4f3f0', overflow: 'hidden' }}>
|
||||
{match.imageUrl ? (
|
||||
<Box
|
||||
sx={{
|
||||
height: 70,
|
||||
backgroundImage: `url(${match.imageUrl})`,
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center',
|
||||
}}
|
||||
component="img"
|
||||
src={match.imageUrl}
|
||||
alt={match.title}
|
||||
sx={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
|
||||
/>
|
||||
) : (
|
||||
<Box sx={{ width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Building2 size={16} color="#94a3b8" />
|
||||
</Box>
|
||||
)}
|
||||
<Box sx={{ p: 1 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', mb: 0.5 }}>
|
||||
<Typography variant="caption" sx={{ fontWeight: 600, color: '#0f172a', fontSize: '0.75rem', lineHeight: 1.3 }}>
|
||||
{match.title}
|
||||
</Typography>
|
||||
{/* Score badge overlay */}
|
||||
<Box
|
||||
sx={{
|
||||
px: 0.75,
|
||||
py: 0.125,
|
||||
borderRadius: 1,
|
||||
bgcolor: match.matchScore >= 90 ? '#fef3c7' : '#e0e7ff',
|
||||
color: match.matchScore >= 90 ? '#92400e' : '#3730a3',
|
||||
position: 'absolute',
|
||||
top: 6,
|
||||
right: 6,
|
||||
bgcolor: 'rgba(15,23,42,0.72)',
|
||||
color: 'white',
|
||||
fontSize: '0.7rem',
|
||||
fontWeight: 700,
|
||||
fontSize: '0.65rem',
|
||||
flexShrink: 0,
|
||||
ml: 0.5,
|
||||
px: 0.75,
|
||||
py: 0.25,
|
||||
borderRadius: '4px',
|
||||
lineHeight: 1.4,
|
||||
}}
|
||||
>
|
||||
{match.matchScore}%
|
||||
</Box>
|
||||
</Box>
|
||||
<Typography variant="caption" sx={{ color: '#64748b', fontSize: '0.7rem', display: 'block', mb: 0.5 }}>
|
||||
{match.location} · {match.areaSqm.toLocaleString('de-CH')} m² · CHF {match.rentPricePerSqm}/m²/Jahr
|
||||
</Typography>
|
||||
{match.reasons.length > 0 && (
|
||||
<Box sx={{ mb: 0.5 }}>
|
||||
{match.reasons.map((r, i) => (
|
||||
<Typography key={i} variant="caption" sx={{ color: '#15803d', fontSize: '0.68rem', display: 'block', lineHeight: 1.4 }}>
|
||||
+ {r}
|
||||
</Typography>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
{match.topUncertainty && (
|
||||
<Typography variant="caption" sx={{ color: '#92400e', fontSize: '0.68rem', display: 'block', lineHeight: 1.4, mb: 0.25 }}>
|
||||
⚠ {match.topUncertainty}
|
||||
</Typography>
|
||||
)}
|
||||
<Button
|
||||
size="small"
|
||||
endIcon={<ArrowRight size={11} />}
|
||||
onClick={() => navigate('/supply/properties')}
|
||||
sx={{ textTransform: 'none', fontSize: '0.7rem', mt: 0.5, p: 0, minWidth: 0, color: '#152642' }}
|
||||
<Box sx={{ p: 0.75 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', mb: 0.2 }}>
|
||||
<Typography
|
||||
sx={{ fontWeight: 600, color: '#0f172a', fontSize: '0.75rem', lineHeight: 1.3, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1, mr: 0.5 }}
|
||||
>
|
||||
Ansehen
|
||||
</Button>
|
||||
{match.title}
|
||||
</Typography>
|
||||
<Typography sx={{ fontSize: '0.7rem', fontWeight: 700, color: '#152642', flexShrink: 0 }}>
|
||||
{match.matchScore}%
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography variant="caption" sx={{ color: '#64748b', fontSize: '0.68rem', display: 'block', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{match.location}
|
||||
</Typography>
|
||||
{match.reasons[0] && (
|
||||
<Typography variant="caption" sx={{ color: '#15803d', fontSize: '0.67rem', display: 'block', lineHeight: 1.4, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', mt: 0.2 }}>
|
||||
+ {match.reasons[0]}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
function AdditionalMatchCard({ match }: { match: AdditionalPropertyMatch }) {
|
||||
const navigate = useNavigate()
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
borderTop: '1px solid #f1f5f9',
|
||||
pt: 1,
|
||||
'&:first-of-type': { borderTop: 'none', pt: 0 },
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', mb: 0.25 }}>
|
||||
<Typography sx={{ fontWeight: 600, color: '#0f172a', fontSize: '0.8rem', lineHeight: 1.3, mr: 0.5 }}>
|
||||
{match.title}
|
||||
</Typography>
|
||||
<Typography sx={{ fontSize: '0.75rem', fontWeight: 700, color: '#152642', flexShrink: 0 }}>
|
||||
{match.matchScore}%
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography variant="caption" sx={{ color: '#64748b', fontSize: '0.72rem', display: 'block', mb: 0.375 }}>
|
||||
{match.location} · {match.areaSqm.toLocaleString('de-CH')} m²
|
||||
</Typography>
|
||||
{match.reasons[0] && (
|
||||
<Typography variant="caption" sx={{ color: '#15803d', fontSize: '0.7rem', display: 'block', mb: 0.375, lineHeight: 1.4 }}>
|
||||
+ {match.reasons[0]}
|
||||
</Typography>
|
||||
)}
|
||||
<Button
|
||||
size="small"
|
||||
endIcon={<ArrowRight size={10} />}
|
||||
onClick={() => navigate('/supply/properties')}
|
||||
sx={{ textTransform: 'none', fontSize: '0.7rem', p: 0, minWidth: 0, color: '#152642' }}
|
||||
>
|
||||
Ansehen
|
||||
</Button>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -20,8 +20,8 @@ import {
|
||||
import { CompareCell, MissingDataCell } from './index'
|
||||
import { RESULT_TYPE_META, DS_COLORS } from '../../lib/ds'
|
||||
import { matchScoreHex } from '../../lib/utils'
|
||||
import { calcFitOutInvestment } from '../../lib/fitOutUtils'
|
||||
import { FITOUT_AMORTIZATION_YEARS } from '../../lib/constants'
|
||||
import { effectiveAnnualBurdenPerSqm } from '../../lib/fitOutUtils'
|
||||
import { FITOUT_AMORTIZATION_YEARS, FITOUT_ANNUITY_RATE, FIT_OUT_LABELS } from '../../lib/constants'
|
||||
import type { UnifiedMatchResult } from '../../domain/unifiedResult'
|
||||
|
||||
// ── Local helper ──────────────────────────────────────────────────────────────
|
||||
@@ -155,11 +155,7 @@ export function CompareTableBody({
|
||||
const prop = getProp(item)
|
||||
if (!prop) return <MissingDataCell reason="Mietpreis nur für bestätigte Objekte verfügbar" />
|
||||
|
||||
const FIT_OUT_LABELS: Record<string, string> = {
|
||||
SHELL: 'Rohbau', BASIC: 'Grundausbau', FULL: 'Vollausbau', PREMIUM: 'Premium-Ausbau',
|
||||
}
|
||||
const READY_TO_MOVE_IN = new Set(['FULL', 'PREMIUM'])
|
||||
|
||||
const fitOutByLandlord = prop.hardFacts?.fitOutByLandlord
|
||||
const monthlyRent = prop.totalRentMonthly
|
||||
?? Math.round(prop.rentPricePerSqm * prop.areaSqm / 12)
|
||||
// ancillaryCosts stored as CHF/m²/Monat
|
||||
@@ -170,21 +166,12 @@ export function CompareTableBody({
|
||||
const fitOutLabel = fitOut ? (FIT_OUT_LABELS[fitOut] ?? fitOut) : null
|
||||
const mabPerSqm = prop.hardFacts?.mieterausbaubeitragPerSqm ?? 0
|
||||
|
||||
// Amortised fit-out monthly cost (mid-range estimate)
|
||||
let fitOutMonthly = 0
|
||||
let fitOutMonthlyLabel: string | null = null
|
||||
if (fitOut && !READY_TO_MOVE_IN.has(fitOut)) {
|
||||
const inv = calcFitOutInvestment(fitOut, prop.areaSqm, mabPerSqm, 0)
|
||||
if (inv && !inv.isFullyCovered) {
|
||||
const months = FITOUT_AMORTIZATION_YEARS * 12
|
||||
const midMin = Math.round(inv.netTotal.min / months)
|
||||
const midMax = Math.round(inv.netTotal.max / months)
|
||||
fitOutMonthly = Math.round((midMin + midMax) / 2)
|
||||
fitOutMonthlyLabel = midMin === midMax
|
||||
? `${midMin.toLocaleString('de-CH')}`
|
||||
: `${midMin.toLocaleString('de-CH')}–${midMax.toLocaleString('de-CH')}`
|
||||
}
|
||||
}
|
||||
// Annuitätischer Ausbau-Aufschlag pro Monat (= 0 bei Vermieter-Übernahme / bezugsfertig)
|
||||
const { fitOutPerSqm } = effectiveAnnualBurdenPerSqm({
|
||||
fitOut, rentPricePerSqm: prop.rentPricePerSqm, mabPerSqm, fitOutByLandlord,
|
||||
})
|
||||
const fitOutMonthly = Math.round(fitOutPerSqm * prop.areaSqm / 12)
|
||||
const fitOutMonthlyLabel = fitOutMonthly > 0 ? fitOutMonthly.toLocaleString('de-CH') : null
|
||||
|
||||
const totalMonthly = monthlyRent
|
||||
+ (monthlyNebenkosten ?? 0)
|
||||
@@ -205,7 +192,7 @@ export function CompareTableBody({
|
||||
)}
|
||||
{fitOutMonthlyLabel && (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
+ {fitOutMonthlyLabel} CHF/Monat (Ausbau ÷ {FITOUT_AMORTIZATION_YEARS} J.)
|
||||
+ {fitOutMonthlyLabel} CHF/Monat (Ausbau annuit. {FITOUT_AMORTIZATION_YEARS} J. / {Math.round(FITOUT_ANNUITY_RATE * 100)}%)
|
||||
</Typography>
|
||||
)}
|
||||
<Typography variant="body2" sx={{ fontWeight: 700, color: '#152642', borderTop: '1px solid #e2e8f0', pt: 0.5, mt: 0.25 }}>
|
||||
@@ -213,7 +200,7 @@ export function CompareTableBody({
|
||||
</Typography>
|
||||
{fitOutLabel && (
|
||||
<Typography variant="caption" sx={{ color: '#64748b', mt: 0.25 }}>
|
||||
Ausbau: {fitOutLabel}{READY_TO_MOVE_IN.has(fitOut ?? '') ? ' (bezugsfertig)' : ''}
|
||||
Ausbau: {fitOutLabel}{fitOutByLandlord ? ' (im Mietzins)' : fitOutMonthly === 0 ? ' (bezugsfertig)' : ''}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
@@ -73,6 +73,7 @@ export function NeedExtendedRequirements({ criteria: c, onChange: set }: Props)
|
||||
slotProps={{ htmlInput: { min: 0, max: 360, step: 12 } }}
|
||||
/>
|
||||
</Box>
|
||||
{c.requiredFitOut && (
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.5 }}>Eigenes Ausbaubudget (max. CHF/m²)</Typography>
|
||||
<TextField
|
||||
@@ -80,9 +81,10 @@ export function NeedExtendedRequirements({ criteria: c, onChange: set }: Props)
|
||||
value={c.fitOutBudgetMaxPerSqm ?? ''}
|
||||
onChange={e => set({ ...c, fitOutBudgetMaxPerSqm: parseInt(e.target.value) || undefined })}
|
||||
slotProps={{ htmlInput: { min: 0, max: 5000, step: 50 } }}
|
||||
helperText="Ihr Beitrag — exkl. MAB des Vermieters"
|
||||
helperText="Überbrückt Flächen unter Ihrem Mindest-Ausbaustandard — Sie tragen die Differenz selbst (exkl. MAB des Vermieters)."
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Divisibility */}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
import { memo } from 'react'
|
||||
import { Box, Button, Typography } from '@mui/material'
|
||||
import { Ruler } from 'lucide-react'
|
||||
import { useNavigate } from 'react-router'
|
||||
import { ROUTES } from '../../lib/constants'
|
||||
import type { Property } from '../../domain/property'
|
||||
import type { VerifiedPortfolioResult } from '../../domain/unifiedResult'
|
||||
import type { Match } from '../../domain/match'
|
||||
import { MatchStrength, ResultType } from '../../domain/enums'
|
||||
import { useInquiryStore } from '../../stores/inquiryStore'
|
||||
import { usePipelineStore } from '../../stores/pipelineStore'
|
||||
import { useCompareStore } from '../../stores/compareStore'
|
||||
|
||||
interface Props {
|
||||
property: Property
|
||||
score: number
|
||||
needId: string
|
||||
}
|
||||
|
||||
function buildSyntheticResult(property: Property, needId: string, score: number, matchId: string): VerifiedPortfolioResult {
|
||||
const strength = score >= 85 ? MatchStrength.STRONG : score >= 70 ? MatchStrength.MODERATE : MatchStrength.WEAK
|
||||
const syntheticMatch = {
|
||||
id: matchId,
|
||||
needId,
|
||||
propertyId: property.id,
|
||||
matchScore: score,
|
||||
matchStrength: strength,
|
||||
scoreBreakdown: { hardMatchScore: score, softFactorScore: score, confidenceModifier: 0, dataQualityModifier: 0, totalScore: score },
|
||||
confidenceLevel: score / 100,
|
||||
} as unknown as Match
|
||||
|
||||
return {
|
||||
matchId,
|
||||
needId,
|
||||
matchScore: score,
|
||||
resultType: ResultType.VERIFIED_PORTFOLIO,
|
||||
property,
|
||||
match: syntheticMatch,
|
||||
}
|
||||
}
|
||||
|
||||
export const PropertyMatchRow = memo(function PropertyMatchRow({ property, score, needId }: Props) {
|
||||
const navigate = useNavigate()
|
||||
const openInquiryDialog = useInquiryStore(s => s.openInquiryDialog)
|
||||
const openSavedDialog = usePipelineStore(s => s.openSavedDialog)
|
||||
const { addToCompare, removeFromCompare, isInCompare, isFull } = useCompareStore()
|
||||
|
||||
const scoreColor = score >= 85 ? '#16a34a' : score >= 70 ? '#d97706' : '#dc2626'
|
||||
const scoreBg = score >= 85 ? '#dcfce7' : score >= 70 ? '#fef3c7' : '#fee2e2'
|
||||
// Match IDs in the store use double-underscore format: m__propId__prop__needId
|
||||
const matchId = `m__${property.id}__prop__${needId}`
|
||||
const inCompare = isInCompare(matchId)
|
||||
|
||||
function handleInquire() {
|
||||
openInquiryDialog({
|
||||
propertyTitle: property.title,
|
||||
location: property.location.city,
|
||||
matchScore: score,
|
||||
matchId,
|
||||
propertyId: property.id,
|
||||
areaLabel: property.areaSqm ? `${property.areaSqm} m²` : undefined,
|
||||
rentLabel: property.rentPricePerSqm ? `CHF ${property.rentPricePerSqm}/m²/Jahr` : undefined,
|
||||
})
|
||||
}
|
||||
|
||||
function handleShortlist() {
|
||||
openSavedDialog({
|
||||
resultId: matchId,
|
||||
resultType: 'VERIFIED_PORTFOLIO',
|
||||
title: property.title,
|
||||
matchScore: score,
|
||||
location: property.location.city,
|
||||
propertyId: property.id,
|
||||
propertyAddress: `${property.title}, ${property.location.city}`,
|
||||
areaLabel: property.areaSqm ? `${property.areaSqm} m²` : undefined,
|
||||
rentLabel: property.rentPricePerSqm ? `CHF ${property.rentPricePerSqm}/m²/Jahr` : undefined,
|
||||
})
|
||||
}
|
||||
|
||||
function handleCompare() {
|
||||
if (inCompare) {
|
||||
removeFromCompare(matchId)
|
||||
} else if (!isFull()) {
|
||||
addToCompare(buildSyntheticResult(property, needId, score, matchId))
|
||||
}
|
||||
}
|
||||
|
||||
function handleDetails() {
|
||||
navigate(`/demand/results/${matchId}`)
|
||||
}
|
||||
|
||||
function handleUnit() {
|
||||
navigate(`/demand/property/${property.id}`)
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ bgcolor: 'white', border: '1px solid #e2e8f0', borderRadius: 1.5, overflow: 'hidden' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, p: 1.25 }}>
|
||||
{property.images?.[0] ? (
|
||||
<Box component="img" src={property.images[0]} alt=""
|
||||
sx={{ width: 52, height: 52, borderRadius: 1, objectFit: 'cover', flexShrink: 0 }} />
|
||||
) : (
|
||||
<Box sx={{ width: 52, height: 52, borderRadius: 1, bgcolor: '#e0e7ff', flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Ruler size={18} color="#3730a3" />
|
||||
</Box>
|
||||
)}
|
||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, fontSize: '0.825rem', color: '#0f172a' }} noWrap>
|
||||
{property.title}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: '#64748b', fontSize: '0.75rem' }}>
|
||||
{property.location.city} · {property.areaSqm} m²
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ bgcolor: scoreBg, color: scoreColor, px: 1, py: 0.375, borderRadius: 1, fontWeight: 700, fontSize: '0.8rem', flexShrink: 0 }}>
|
||||
{score}%
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 0.75, px: 1.25, pb: 1.25, flexWrap: 'wrap' }}>
|
||||
<ActionButton onClick={handleInquire} primary>Anfrage</ActionButton>
|
||||
<ActionButton onClick={handleShortlist}>Merken</ActionButton>
|
||||
<ActionButton onClick={handleCompare} disabled={!inCompare && isFull()}>
|
||||
{inCompare ? 'Im Vergleich' : 'Vergleichen'}
|
||||
</ActionButton>
|
||||
<ActionButton onClick={handleDetails}>Details →</ActionButton>
|
||||
<ActionButton onClick={handleUnit}>Zur Einheit →</ActionButton>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
})
|
||||
|
||||
function ActionButton({ children, onClick, primary, disabled }: {
|
||||
children: React.ReactNode
|
||||
onClick: () => void
|
||||
primary?: boolean
|
||||
disabled?: boolean
|
||||
}) {
|
||||
return (
|
||||
<Button
|
||||
size="small"
|
||||
variant={primary ? 'contained' : 'outlined'}
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
sx={{
|
||||
textTransform: 'none',
|
||||
fontWeight: 600,
|
||||
fontSize: '0.75rem',
|
||||
py: 0.25,
|
||||
px: 1,
|
||||
minWidth: 0,
|
||||
...(primary
|
||||
? { bgcolor: '#152642', '&:hover': { bgcolor: '#16304d' } }
|
||||
: { borderColor: '#cbd5e1', color: '#475569', '&:hover': { borderColor: '#152642', color: '#152642' } }),
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { memo } from 'react'
|
||||
import { Box, Chip, Paper, Typography } from '@mui/material'
|
||||
import { MapPin } from 'lucide-react'
|
||||
import type { Need } from '../../domain/need'
|
||||
import { ASSET_TYPE_LABELS } from '../../lib/constants'
|
||||
|
||||
interface Props {
|
||||
need: Need
|
||||
selected: boolean
|
||||
matchCount: number
|
||||
topScore: number
|
||||
onClick: () => void
|
||||
}
|
||||
|
||||
function statusConfig(status: Need['status']): { label: string; bg: string; fg: string } {
|
||||
if (status === 'DRAFT') return { label: 'Entwurf', bg: '#fef3c7', fg: '#92400e' }
|
||||
return { label: 'Aktiv', bg: '#dcfce7', fg: '#166534' }
|
||||
}
|
||||
|
||||
export const SavedNeedCard = memo(function SavedNeedCard({ need, selected, matchCount, topScore, onClick }: Props) {
|
||||
const statusCfg = statusConfig(need.status)
|
||||
const locationText = need.preferredLocations.slice(0, 2).join(', ')
|
||||
const scoreColor = topScore >= 80 ? '#16a34a' : topScore >= 65 ? '#d97706' : '#dc2626'
|
||||
|
||||
return (
|
||||
<Paper
|
||||
onClick={onClick}
|
||||
elevation={0}
|
||||
sx={{
|
||||
p: 1.5,
|
||||
borderRadius: 1.5,
|
||||
border: '1px solid',
|
||||
borderColor: selected ? '#152642' : '#e2e8f0',
|
||||
bgcolor: selected ? '#f1f5f9' : 'white',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.15s',
|
||||
'&:hover': { borderColor: selected ? '#152642' : '#94a3b8', boxShadow: '0 2px 6px rgba(15,23,42,0.06)' },
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 0.75,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 1 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, flexWrap: 'wrap' }}>
|
||||
<Chip label={ASSET_TYPE_LABELS[need.assetType] ?? need.assetType} size="small"
|
||||
sx={{ bgcolor: '#e0e7ff', color: '#3730a3', fontWeight: 600, fontSize: '0.65rem', height: 20 }} />
|
||||
<Chip label={statusCfg.label} size="small"
|
||||
sx={{ bgcolor: statusCfg.bg, color: statusCfg.fg, fontWeight: 600, fontSize: '0.65rem', height: 20 }} />
|
||||
</Box>
|
||||
{matchCount > 0 && (
|
||||
<Box sx={{ bgcolor: '#dcfce7', color: '#15803d', borderRadius: 1, px: 0.75, py: 0.125, fontSize: '0.68rem', fontWeight: 700, flexShrink: 0 }}>
|
||||
{matchCount} Treffer
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, fontSize: '0.85rem', color: '#0f172a', lineHeight: 1.3 }}>
|
||||
{need.companyName}
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, color: '#64748b' }}>
|
||||
<MapPin size={11} />
|
||||
<Typography variant="caption" sx={{ fontSize: '0.75rem' }}>{locationText || '—'}</Typography>
|
||||
</Box>
|
||||
|
||||
{topScore > 0 && (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Box sx={{ flex: 1, height: 4, borderRadius: 2, bgcolor: '#f1f5f9', overflow: 'hidden' }}>
|
||||
<Box sx={{ width: `${topScore}%`, height: '100%', bgcolor: scoreColor }} />
|
||||
</Box>
|
||||
<Typography variant="caption" sx={{ fontSize: '0.72rem', fontWeight: 700, color: scoreColor, flexShrink: 0 }}>
|
||||
{topScore}%
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Paper>
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,326 @@
|
||||
import { memo, useMemo, useState } from 'react'
|
||||
import {
|
||||
Accordion, AccordionDetails, AccordionSummary,
|
||||
Box, Button, Chip, Divider, LinearProgress, Slider, Switch, TextField, Typography,
|
||||
} from '@mui/material'
|
||||
import { Bell, Calendar, CheckCircle2, ChevronDown, MapPin, Ruler, Target, Wallet } from 'lucide-react'
|
||||
import type { Need, NotificationConfig, UpdateNeedInput } from '../../domain/need'
|
||||
import type { Property } from '../../domain/property'
|
||||
import { ASSET_TYPE_LABELS } from '../../lib/constants'
|
||||
import { confidenceHex } from '../../lib/utils'
|
||||
import { useMatchesByNeed } from '../../hooks/useMatches'
|
||||
import { PropertyMatchRow } from './PropertyMatchRow'
|
||||
|
||||
const WEIGHT_LABELS: Record<string, string> = {
|
||||
area: 'Fläche', location: 'Standort', budget: 'Budget', timing: 'Timing',
|
||||
prestige: 'Prestige', accessibility: 'Erreichbarkeit', expansionPotential: 'Expansionspotenzial',
|
||||
flexibility: 'Flexibilität', visibility: 'Sichtbarkeit', footfall: 'Publikumsverkehr',
|
||||
talentAccess: 'Talentzugang', esg: 'ESG', taxEnvironment: 'Steuerumgebung',
|
||||
}
|
||||
|
||||
function statusConfig(status: Need['status']): { label: string; bg: string; fg: string } {
|
||||
if (status === 'DRAFT') return { label: 'Entwurf', bg: '#fef3c7', fg: '#92400e' }
|
||||
return { label: 'Aktiv', bg: '#dcfce7', fg: '#166534' }
|
||||
}
|
||||
|
||||
interface Props {
|
||||
need: Need
|
||||
properties: Property[]
|
||||
onStart: () => void
|
||||
onEdit: () => void
|
||||
onArchive: () => void
|
||||
onUpdate: (data: UpdateNeedInput) => void
|
||||
}
|
||||
|
||||
export const SavedNeedDetail = memo(function SavedNeedDetail({ need, properties, onStart, onEdit, onArchive, onUpdate }: Props) {
|
||||
const statusCfg = statusConfig(need.status)
|
||||
const conf = need.confidenceInCriteria ?? 0
|
||||
const confColor = confidenceHex(conf)
|
||||
|
||||
const [notif, setNotif] = useState<NotificationConfig>(() => need.notificationConfig ?? { enabled: true, minScore: 80 })
|
||||
const [notifDirty, setNotifDirty] = useState(false)
|
||||
|
||||
function handleNotifChange(patch: Partial<NotificationConfig>) {
|
||||
setNotif(prev => ({ ...prev, ...patch }))
|
||||
setNotifDirty(true)
|
||||
}
|
||||
function handleNotifSave() {
|
||||
onUpdate({ notificationConfig: notif })
|
||||
setNotifDirty(false)
|
||||
}
|
||||
|
||||
const { data: matches = [] } = useMatchesByNeed(need.id)
|
||||
|
||||
const notifThreshold = need.notificationConfig?.minScore ?? 80
|
||||
const matchCount = matches.filter(m => m.matchScore >= notifThreshold).length
|
||||
|
||||
const scoredProperties = useMemo(() => {
|
||||
const seen = new Set<string>()
|
||||
return matches
|
||||
.map(m => {
|
||||
const property = properties.find(p => p.id === m.propertyId)
|
||||
return property ? { property, score: Math.round(m.matchScore) } : null
|
||||
})
|
||||
.filter((x): x is { property: Property; score: number } => x !== null)
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.filter(x => {
|
||||
if (seen.has(x.property.id)) return false
|
||||
seen.add(x.property.id)
|
||||
return true
|
||||
})
|
||||
}, [matches, properties])
|
||||
const topScore = scoredProperties.length > 0 ? scoredProperties[0].score : 0
|
||||
const topProperties = scoredProperties.slice(0, 3)
|
||||
|
||||
const topWeights = useMemo(
|
||||
() => Object.entries(need.weightingProfile).filter(([, v]) => v > 0.03).sort(([, a], [, b]) => b - a).slice(0, 6),
|
||||
[need.weightingProfile],
|
||||
)
|
||||
|
||||
return (
|
||||
<Box sx={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden', bgcolor: '#f8fafc' }}>
|
||||
<Box sx={{ flex: 1, overflowY: 'auto', p: 3, display: 'flex', flexDirection: 'column', gap: 2.5 }}>
|
||||
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.75 }}>
|
||||
<Chip label={ASSET_TYPE_LABELS[need.assetType] ?? need.assetType} size="small"
|
||||
sx={{ bgcolor: '#e0e7ff', color: '#3730a3', fontWeight: 600, fontSize: '0.7rem', height: 22 }} />
|
||||
<Chip label={statusCfg.label} size="small"
|
||||
sx={{ bgcolor: statusCfg.bg, color: statusCfg.fg, fontWeight: 600, fontSize: '0.7rem', height: 22 }} />
|
||||
</Box>
|
||||
<Typography variant="h5" sx={{ fontWeight: 700, color: '#0f172a', fontSize: '1.25rem' }}>
|
||||
{need.companyName}
|
||||
</Typography>
|
||||
{need.contactName && (
|
||||
<Typography variant="body2" sx={{ color: '#64748b', mt: 0.25 }}>{need.contactName}</Typography>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<SectionLabel>Suchkriterien</SectionLabel>
|
||||
<Box sx={{ mt: 1, display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: 1.25 }}>
|
||||
<CriteriaBox icon={<MapPin size={13} />} label="Standort" value={need.preferredLocations.join(', ') || '—'} />
|
||||
<CriteriaBox icon={<Ruler size={13} />} label="Fläche" value={`${need.requiredArea.min}–${need.requiredArea.max} m²`} />
|
||||
<CriteriaBox icon={<Wallet size={13} />} label="Budget" value={`CHF ${need.budgetRange.maxPerSqm} /m²`} />
|
||||
<CriteriaBox icon={<Calendar size={13} />} label="Einzug ab" value={need.timing.earliestMoveIn || '—'} />
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.5 }}>
|
||||
<SectionLabel>Konfidenz der Kriterien</SectionLabel>
|
||||
<Typography variant="caption" sx={{ fontWeight: 700, color: confColor, fontSize: '0.8rem' }}>
|
||||
{Math.round(conf * 100)}%
|
||||
</Typography>
|
||||
</Box>
|
||||
<LinearProgress variant="determinate" value={conf * 100} sx={{
|
||||
height: 6, borderRadius: 3, bgcolor: '#f1f5f9',
|
||||
'& .MuiLinearProgress-bar': { bgcolor: confColor, borderRadius: 3 },
|
||||
}} />
|
||||
</Box>
|
||||
|
||||
{(need.mustCriteriaText?.length ?? 0) > 0 && (
|
||||
<Box>
|
||||
<SectionLabel>Must-have Kriterien</SectionLabel>
|
||||
<Box sx={{ mt: 1, display: 'flex', flexDirection: 'column', gap: 0.5 }}>
|
||||
{need.mustCriteriaText!.map((c, i) => (
|
||||
<Box key={i} sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<CheckCircle2 size={14} color="#16a34a" />
|
||||
<Typography variant="body2" sx={{ fontSize: '0.875rem', color: '#1e293b' }}>{c}</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{topWeights.length > 0 && (
|
||||
<Accordion elevation={0} disableGutters sx={{
|
||||
border: '1px solid #e2e8f0', borderRadius: '8px !important', overflow: 'hidden',
|
||||
'&:before': { display: 'none' }, bgcolor: 'white',
|
||||
}}>
|
||||
<AccordionSummary expandIcon={<ChevronDown size={16} color="#64748b" />}
|
||||
sx={{ px: 2, minHeight: 44, '& .MuiAccordionSummary-content': { my: 0 } }}>
|
||||
<Typography variant="caption" sx={{ color: '#64748b', fontWeight: 700, textTransform: 'uppercase', letterSpacing: 0.5 }}>
|
||||
Gewichtete Präferenzen
|
||||
</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails sx={{ px: 2, pt: 0, pb: 2 }}>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
{topWeights.map(([key, val]) => {
|
||||
const pct = Math.round(val * 100)
|
||||
return (
|
||||
<Box key={key} sx={{ bgcolor: '#f8fafc', border: '1px solid #e2e8f0', borderRadius: 1.5, px: 1.5, py: 1 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.5 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, fontSize: '0.85rem' }}>
|
||||
{WEIGHT_LABELS[key] ?? key}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ fontWeight: 700, color: '#152642', fontSize: '0.75rem', bgcolor: '#e0e7ff', px: 1, borderRadius: 1 }}>
|
||||
{pct}%
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ height: 5, borderRadius: 3, bgcolor: '#e8e7e4', overflow: 'hidden' }}>
|
||||
<Box sx={{ width: `${pct}%`, height: '100%', bgcolor: '#152642', transition: 'width 0.3s' }} />
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
})}
|
||||
</Box>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
)}
|
||||
|
||||
<Accordion elevation={0} disableGutters sx={{
|
||||
border: '1px solid #e2e8f0', borderRadius: '8px !important', overflow: 'hidden',
|
||||
'&:before': { display: 'none' }, bgcolor: 'white',
|
||||
}}>
|
||||
<AccordionSummary expandIcon={<ChevronDown size={16} color="#64748b" />}
|
||||
sx={{ px: 2, minHeight: 44, '& .MuiAccordionSummary-content': { my: 0 } }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Bell size={13} color={notif.enabled ? '#152642' : '#94a3b8'} />
|
||||
<Typography variant="caption" sx={{ color: '#64748b', fontWeight: 700, textTransform: 'uppercase', letterSpacing: 0.5 }}>
|
||||
Benachrichtigungen
|
||||
</Typography>
|
||||
{notif.enabled && (
|
||||
<Box sx={{ bgcolor: '#e0e7ff', color: '#3730a3', fontSize: '0.65rem', fontWeight: 700, px: 0.75, borderRadius: 0.75 }}>
|
||||
ab {notif.minScore}%
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails sx={{ px: 2, pt: 0, pb: 2 }}>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.75 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Box>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, fontSize: '0.85rem' }}>Benachrichtigung aktiv</Typography>
|
||||
<Typography variant="caption" sx={{ color: '#64748b', fontSize: '0.75rem' }}>
|
||||
Neue Treffer oberhalb der Schwelle melden
|
||||
</Typography>
|
||||
</Box>
|
||||
<Switch
|
||||
size="small"
|
||||
checked={notif.enabled}
|
||||
onChange={e => handleNotifChange({ enabled: e.target.checked })}
|
||||
sx={{ '& .MuiSwitch-thumb': { bgcolor: notif.enabled ? '#152642' : undefined } }}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{notif.enabled && (
|
||||
<>
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.5 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, fontSize: '0.85rem' }}>Match-Schwelle</Typography>
|
||||
<Typography variant="caption" sx={{ fontWeight: 700, color: '#152642', fontSize: '0.8rem', bgcolor: '#e0e7ff', px: 0.75, borderRadius: 0.75 }}>
|
||||
{notif.minScore}%
|
||||
</Typography>
|
||||
</Box>
|
||||
<Slider
|
||||
value={notif.minScore}
|
||||
min={60} max={95} step={5}
|
||||
onChange={(_, v) => handleNotifChange({ minScore: v as number })}
|
||||
sx={{ color: '#152642', '& .MuiSlider-thumb': { width: 14, height: 14 } }}
|
||||
/>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||
<Typography variant="caption" sx={{ color: '#94a3b8', fontSize: '0.7rem' }}>60%</Typography>
|
||||
<Typography variant="caption" sx={{ color: '#94a3b8', fontSize: '0.7rem' }}>95%</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, fontSize: '0.85rem', mb: 0.5 }}>
|
||||
E-Mail (optional)
|
||||
</Typography>
|
||||
<TextField
|
||||
size="small"
|
||||
fullWidth
|
||||
placeholder="name@firma.ch"
|
||||
value={notif.emailAddress ?? ''}
|
||||
onChange={e => handleNotifChange({ emailAddress: e.target.value || undefined })}
|
||||
inputProps={{ type: 'email' }}
|
||||
sx={{ '& .MuiOutlinedInput-root': { fontSize: '0.85rem' } }}
|
||||
helperText="Im Produktivsystem wird bei neuen Treffern eine E-Mail ausgelöst"
|
||||
/>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
|
||||
{notifDirty && (
|
||||
<Button
|
||||
variant="contained"
|
||||
size="small"
|
||||
onClick={handleNotifSave}
|
||||
sx={{ textTransform: 'none', fontWeight: 600, bgcolor: '#152642', '&:hover': { bgcolor: '#16304d' }, alignSelf: 'flex-end' }}
|
||||
>
|
||||
Speichern
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
|
||||
{scoredProperties.length > 0 && (
|
||||
<>
|
||||
<Divider />
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.25 }}>
|
||||
<Target size={14} color="#15803d" />
|
||||
<Typography variant="caption" sx={{ color: '#64748b', fontWeight: 700, textTransform: 'uppercase', letterSpacing: 0.5 }}>
|
||||
Passende Objekte
|
||||
</Typography>
|
||||
<Box sx={{ ml: 'auto', bgcolor: '#dcfce7', color: '#15803d', borderRadius: 1, px: 1, py: 0.125, fontWeight: 700, fontSize: '0.72rem' }}>
|
||||
{matchCount} Treffer
|
||||
</Box>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
{topProperties.map(({ property, score }) => (
|
||||
<PropertyMatchRow key={property.id} property={property} score={score} needId={need.id} />
|
||||
))}
|
||||
</Box>
|
||||
{matchCount > 3 && (
|
||||
<Box
|
||||
onClick={onStart}
|
||||
sx={{ mt: 1.25, textAlign: 'center', cursor: 'pointer', color: '#152642', fontSize: '0.8rem', fontWeight: 600,
|
||||
py: 1, border: '1px dashed #cbd5e1', borderRadius: 1.5, '&:hover': { bgcolor: '#f1f5f9', borderColor: '#152642' }, transition: 'all 0.15s' }}
|
||||
>
|
||||
Alle {matchCount} Ergebnisse anzeigen →
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box sx={{ flexShrink: 0, borderTop: '1px solid #e2e8f0', px: 3, py: 2, bgcolor: 'white', display: 'flex', gap: 1.5 }}>
|
||||
<Button variant="outlined" size="large" onClick={onEdit}
|
||||
sx={{ flex: 1, textTransform: 'none', fontWeight: 600, borderColor: '#cbd5e1', color: '#475569' }}>
|
||||
Bearbeiten
|
||||
</Button>
|
||||
<Button variant="contained" size="large" onClick={onStart}
|
||||
sx={{ flex: 2, textTransform: 'none', fontWeight: 600, bgcolor: '#152642', '&:hover': { bgcolor: '#16304d' } }}>
|
||||
Suche starten →
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
})
|
||||
|
||||
function SectionLabel({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<Typography variant="caption" sx={{ color: '#64748b', fontWeight: 700, textTransform: 'uppercase', letterSpacing: 0.5 }}>
|
||||
{children}
|
||||
</Typography>
|
||||
)
|
||||
}
|
||||
|
||||
function CriteriaBox({ icon, label, value }: { icon: React.ReactNode; label: string; value: string }) {
|
||||
return (
|
||||
<Box sx={{ bgcolor: 'white', border: '1px solid #e2e8f0', borderRadius: 1.5, px: 1.5, py: 1 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, color: '#64748b', mb: 0.25 }}>
|
||||
{icon}
|
||||
<Typography variant="caption" sx={{ fontSize: '0.7rem', fontWeight: 600, textTransform: 'uppercase', letterSpacing: 0.5 }}>
|
||||
{label}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography variant="body2" sx={{ fontSize: '0.85rem', color: '#0f172a', fontWeight: 500 }}>{value}</Typography>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Box, Button, CircularProgress, IconButton, Typography, useMediaQuery, useTheme } from '@mui/material'
|
||||
import { ArrowLeft, Plus, Search } from 'lucide-react'
|
||||
import { useNavigate } from 'react-router'
|
||||
import { useNeedProfiles, useArchiveNeed, useUpdateNeed } from '../../hooks/useNeeds'
|
||||
import { useProperties } from '../../hooks/useProperties'
|
||||
import { useMatches } from '../../hooks/useMatches'
|
||||
import { ResultType } from '../../domain/enums'
|
||||
import { ROUTES } from '../../lib/constants'
|
||||
import { EmptyState } from '../ui'
|
||||
import { SavedNeedCard } from './SavedNeedCard'
|
||||
import { SavedNeedDetail } from './SavedNeedDetail'
|
||||
import { needToParsedCriteria } from '../../services/aiSearch/needSearchMapper'
|
||||
|
||||
interface Props {
|
||||
onNewSearch: (prefillNeedId?: string) => void
|
||||
}
|
||||
|
||||
export function SavedProfilesTab({ onNewSearch }: Props) {
|
||||
const navigate = useNavigate()
|
||||
const theme = useTheme()
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down('md'))
|
||||
|
||||
const { data: needs = [], isLoading } = useNeedProfiles()
|
||||
const { data: properties = [] } = useProperties({ resultType: ResultType.VERIFIED_PORTFOLIO })
|
||||
const { data: allMatches = [] } = useMatches()
|
||||
const archiveMutation = useArchiveNeed()
|
||||
const updateMutation = useUpdateNeed()
|
||||
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null)
|
||||
const [mobileView, setMobileView] = useState<'list' | 'detail'>('list')
|
||||
|
||||
const visible = useMemo(
|
||||
() =>
|
||||
needs
|
||||
.filter(n => !n.status || n.status === 'ACTIVE' || n.status === 'DRAFT')
|
||||
.sort((a, b) => {
|
||||
const aActive = !a.status || a.status === 'ACTIVE'
|
||||
const bActive = !b.status || b.status === 'ACTIVE'
|
||||
if (aActive !== bActive) return aActive ? -1 : 1
|
||||
return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
|
||||
}),
|
||||
[needs],
|
||||
)
|
||||
|
||||
const { matchCounts, topScores } = useMemo(() => {
|
||||
const counts: Record<string, number> = {}
|
||||
const tops: Record<string, number> = {}
|
||||
for (const n of visible) {
|
||||
const needMatches = allMatches.filter(m => m.needId === n.id)
|
||||
const threshold = n.notificationConfig?.minScore ?? 80
|
||||
counts[n.id] = needMatches.filter(m => m.matchScore >= threshold).length
|
||||
tops[n.id] = needMatches.length > 0 ? Math.round(Math.max(...needMatches.map(m => m.matchScore))) : 0
|
||||
}
|
||||
return { matchCounts: counts, topScores: tops }
|
||||
}, [visible, allMatches])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isMobile && !selectedId && visible.length > 0) setSelectedId(visible[0].id)
|
||||
}, [visible, selectedId, isMobile])
|
||||
|
||||
const selectedNeed = visible.find(n => n.id === selectedId) ?? null
|
||||
|
||||
function handleSelect(id: string) {
|
||||
setSelectedId(id)
|
||||
if (isMobile) setMobileView('detail')
|
||||
}
|
||||
|
||||
function handleStart(needId: string) {
|
||||
navigate(ROUTES.DEMAND.RESULTS, { state: { fromNeedBuilder: true, activeNeedId: needId } })
|
||||
}
|
||||
|
||||
function handleEdit(needId: string) {
|
||||
onNewSearch(needId)
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return <Box sx={{ display: 'flex', justifyContent: 'center', pt: 8 }}><CircularProgress size={24} /></Box>
|
||||
}
|
||||
|
||||
if (visible.length === 0) {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', pt: 8, gap: 2 }}>
|
||||
<EmptyState title="Keine Suchprofile" description="Sie haben noch keine Suchprofile gespeichert." />
|
||||
<Button variant="outlined" startIcon={<Plus size={16} />} onClick={() => onNewSearch()} sx={{ textTransform: 'none', mt: 1 }}>
|
||||
Neue Suche erstellen
|
||||
</Button>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
const list = (
|
||||
<Box sx={{ width: 280, minWidth: 280, flexShrink: 0, borderRight: '1px solid #e2e8f0', bgcolor: 'white', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
||||
<Box sx={{ px: 2, py: 1.25, borderBottom: '1px solid #e2e8f0', display: 'flex', alignItems: 'center', gap: 1, flexShrink: 0 }}>
|
||||
<Search size={14} color="#7c3aed" />
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, color: '#0f172a' }}>Suchprofile</Typography>
|
||||
<Box sx={{ ml: 'auto', px: 1, py: 0.125, borderRadius: 1, bgcolor: '#152642', color: 'white', fontWeight: 600, fontSize: '0.7rem' }}>
|
||||
{visible.length}
|
||||
</Box>
|
||||
</Box>
|
||||
<Box sx={{ flex: 1, overflowY: 'auto', p: 1.25, display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
{visible.map(n => (
|
||||
<SavedNeedCard
|
||||
key={n.id}
|
||||
need={n}
|
||||
selected={n.id === selectedId}
|
||||
matchCount={matchCounts[n.id] ?? 0}
|
||||
topScore={topScores[n.id] ?? 0}
|
||||
onClick={() => handleSelect(n.id)}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
<Box sx={{ flexShrink: 0, p: 1.5, borderTop: '1px solid #e2e8f0' }}>
|
||||
<Button fullWidth variant="outlined" size="small" startIcon={<Plus size={14} />} onClick={() => onNewSearch()}
|
||||
sx={{ textTransform: 'none', fontSize: '0.8rem' }}>
|
||||
Neue Suche
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
|
||||
if (isMobile) {
|
||||
if (mobileView === 'detail' && selectedNeed) {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
||||
<Box sx={{ px: 1.5, py: 1, borderBottom: '1px solid #e2e8f0', bgcolor: 'white', flexShrink: 0, display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<IconButton size="small" onClick={() => setMobileView('list')}><ArrowLeft size={18} /></IconButton>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, fontSize: '0.85rem' }} noWrap>{selectedNeed.companyName}</Typography>
|
||||
</Box>
|
||||
<Box sx={{ flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
|
||||
<SavedNeedDetail
|
||||
need={selectedNeed}
|
||||
properties={properties}
|
||||
onStart={() => handleStart(selectedNeed.id)}
|
||||
onEdit={() => handleEdit(selectedNeed.id)}
|
||||
onArchive={() => archiveMutation.mutate(selectedNeed.id)}
|
||||
onUpdate={(data) => updateMutation.mutate({ id: selectedNeed.id, ...data })}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
return <Box sx={{ height: '100%', overflow: 'hidden' }}>{list}</Box>
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', height: '100%', overflow: 'hidden' }}>
|
||||
{list}
|
||||
<Box sx={{ flex: 1, display: 'flex', overflow: 'hidden' }}>
|
||||
{selectedNeed ? (
|
||||
<SavedNeedDetail
|
||||
need={selectedNeed}
|
||||
properties={properties}
|
||||
onStart={() => handleStart(selectedNeed.id)}
|
||||
onEdit={() => handleEdit(selectedNeed.id)}
|
||||
onArchive={() => archiveMutation.mutate(selectedNeed.id)}
|
||||
onUpdate={(data) => updateMutation.mutate({ id: selectedNeed.id, ...data })}
|
||||
/>
|
||||
) : (
|
||||
<Box sx={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<EmptyState icon={<Search size={40} />} title="Profil auswählen" description="Wählen Sie ein Suchprofil, um Details zu sehen." />
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -9,3 +9,7 @@ export { NeedCardPreview } from './NeedCardPreview'
|
||||
export { NeedInput } from './NeedInput'
|
||||
export { VoiceNeedInput } from './VoiceNeedInput'
|
||||
export { WeightingEditor } from './WeightingEditor'
|
||||
export { PropertyMatchRow } from './PropertyMatchRow'
|
||||
export { SavedNeedCard } from './SavedNeedCard'
|
||||
export { SavedNeedDetail } from './SavedNeedDetail'
|
||||
export { SavedProfilesTab } from './SavedProfilesTab'
|
||||
|
||||
@@ -21,16 +21,21 @@ export function AppShell() {
|
||||
const sidebarCollapsed = useLayoutStore(s => s.sidebarCollapsed)
|
||||
const setActiveWorkspace = useLayoutStore(s => s.setActiveWorkspace)
|
||||
const toggleSidebar = useLayoutStore(s => s.toggleSidebar)
|
||||
const setSidebarCollapsed = useLayoutStore(s => s.setSidebarCollapsed)
|
||||
const { currentUser } = useSessionStore()
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const theme = useTheme()
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down('md'))
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down('sm'))
|
||||
const isCompact = useMediaQuery(theme.breakpoints.down('xl'))
|
||||
const [mobileOpen, setMobileOpen] = useState(false)
|
||||
|
||||
// Close mobile menu on route change
|
||||
useEffect(() => { setMobileOpen(false) }, [location.pathname])
|
||||
|
||||
// Auto-collapse sidebar on compact screens (laptops < 1536px)
|
||||
useEffect(() => { setSidebarCollapsed(isCompact) }, [isCompact, setSidebarCollapsed])
|
||||
|
||||
// Sync active workspace with URL
|
||||
useEffect(() => {
|
||||
const detected = getWorkspaceFromPath(location.pathname)
|
||||
|
||||
@@ -1,22 +1,74 @@
|
||||
import { useState } from 'react'
|
||||
import { Badge, IconButton, Popover, Typography } from '@mui/material'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Badge, Box, Divider, IconButton, Popover, Typography } from '@mui/material'
|
||||
import { Bell } from 'lucide-react'
|
||||
import { useNavigate } from 'react-router'
|
||||
import { useNeedProfiles } from '../../hooks/useNeeds'
|
||||
import { useMatches } from '../../hooks/useMatches'
|
||||
import { ROUTES } from '../../lib/constants'
|
||||
|
||||
function loadSeenCounts(): Record<string, number> {
|
||||
try { return JSON.parse(localStorage.getItem('notif-seen-counts') ?? '{}') }
|
||||
catch { return {} }
|
||||
}
|
||||
|
||||
function saveSeenCounts(counts: Record<string, number>) {
|
||||
localStorage.setItem('notif-seen-counts', JSON.stringify(counts))
|
||||
}
|
||||
|
||||
export function NotificationButton() {
|
||||
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null)
|
||||
const [seenCounts, setSeenCounts] = useState<Record<string, number>>(loadSeenCounts)
|
||||
const navigate = useNavigate()
|
||||
|
||||
const { data: needs = [] } = useNeedProfiles()
|
||||
const { data: allMatches = [] } = useMatches()
|
||||
|
||||
const profilesWithMatches = useMemo(() => {
|
||||
const active = needs.filter(n =>
|
||||
(!n.status || n.status === 'ACTIVE' || n.status === 'DRAFT') &&
|
||||
(n.notificationConfig?.enabled !== false),
|
||||
)
|
||||
return active
|
||||
.map(n => {
|
||||
const minScore = n.notificationConfig?.minScore ?? 80
|
||||
return {
|
||||
need: n,
|
||||
count: allMatches.filter(m => m.needId === n.id && m.matchScore >= minScore).length,
|
||||
minScore,
|
||||
}
|
||||
})
|
||||
.filter(x => x.count > 0)
|
||||
}, [needs, allMatches])
|
||||
|
||||
// Badge only lights up for profiles with matches the user hasn't seen yet
|
||||
const newProfiles = useMemo(
|
||||
() => profilesWithMatches.filter(x => x.count > (seenCounts[x.need.id] ?? 0)),
|
||||
[profilesWithMatches, seenCounts],
|
||||
)
|
||||
const badgeCount = newProfiles.length
|
||||
|
||||
function handleOpen(e: React.MouseEvent<HTMLElement>) {
|
||||
setAnchorEl(e.currentTarget)
|
||||
// Mark all current matches as seen
|
||||
const updated = { ...seenCounts }
|
||||
for (const { need, count } of profilesWithMatches) {
|
||||
updated[need.id] = count
|
||||
}
|
||||
setSeenCounts(updated)
|
||||
saveSeenCounts(updated)
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
setAnchorEl(null)
|
||||
function handleClose() { setAnchorEl(null) }
|
||||
|
||||
function goToResults(needId: string) {
|
||||
navigate(ROUTES.DEMAND.RESULTS, { state: { fromNeedBuilder: true, activeNeedId: needId } })
|
||||
handleClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<IconButton size="small" sx={{ color: '#64748b' }} onClick={handleOpen}>
|
||||
<Badge badgeContent={0} color="error">
|
||||
<Badge badgeContent={badgeCount || null} color="error">
|
||||
<Bell size={20} />
|
||||
</Badge>
|
||||
</IconButton>
|
||||
@@ -27,12 +79,50 @@ export function NotificationButton() {
|
||||
onClose={handleClose}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
|
||||
transformOrigin={{ vertical: 'top', horizontal: 'right' }}
|
||||
slotProps={{ paper: { sx: { width: 280, p: 2 } } }}
|
||||
slotProps={{ paper: { sx: { width: 300, p: 2 } } }}
|
||||
>
|
||||
<Typography variant="subtitle2" sx={{ mb: 1 }}>Benachrichtigungen</Typography>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
Keine neuen Benachrichtigungen
|
||||
<Typography variant="subtitle2" sx={{ mb: 1.25, fontWeight: 700, color: '#0f172a' }}>
|
||||
Neue Treffer
|
||||
</Typography>
|
||||
|
||||
{profilesWithMatches.length === 0 ? (
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
Keine aktiven Benachrichtigungen
|
||||
</Typography>
|
||||
) : (
|
||||
<>
|
||||
{profilesWithMatches.map(({ need, count, minScore }) => (
|
||||
<Box
|
||||
key={need.id}
|
||||
onClick={() => goToResults(need.id)}
|
||||
sx={{
|
||||
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
|
||||
px: 1, py: 0.875, borderRadius: 1, cursor: 'pointer',
|
||||
'&:hover': { bgcolor: '#f8fafc' }, transition: 'background 0.1s',
|
||||
}}
|
||||
>
|
||||
<Box sx={{ minWidth: 0 }}>
|
||||
<Typography variant="body2" sx={{ fontSize: '0.825rem', color: '#1e293b' }} noWrap>
|
||||
{need.companyName}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: '#94a3b8', fontSize: '0.7rem' }}>
|
||||
ab {minScore}%
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography variant="caption" sx={{ color: '#15803d', fontWeight: 700, flexShrink: 0, ml: 1.5, fontSize: '0.78rem' }}>
|
||||
{count} Treffer →
|
||||
</Typography>
|
||||
</Box>
|
||||
))}
|
||||
<Divider sx={{ my: 1.25 }} />
|
||||
<Box
|
||||
onClick={() => { navigate(ROUTES.DEMAND.AI_SEARCH); handleClose() }}
|
||||
sx={{ textAlign: 'center', cursor: 'pointer', color: '#152642', fontSize: '0.8rem', fontWeight: 600, py: 0.25, '&:hover': { color: '#16304d' } }}
|
||||
>
|
||||
Alle Suchprofile anzeigen →
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</Popover>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -32,8 +32,8 @@ export function RightContextPanel() {
|
||||
right: 0,
|
||||
top: 56,
|
||||
height: 'calc(100vh - 56px)',
|
||||
width: 320,
|
||||
transform: isRightPanelOpen ? 'translateX(0)' : 'translateX(320px)',
|
||||
width: { xs: '85vw', sm: 300, lg: 320 },
|
||||
transform: isRightPanelOpen ? 'translateX(0)' : 'translateX(110%)',
|
||||
transition: 'transform 0.25s ease',
|
||||
bgcolor: '#fff',
|
||||
borderLeft: '1px solid #e2e8f0',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Box, Button, Chip, Tooltip, Typography } from '@mui/material'
|
||||
import { Building2, CheckCircle2 } from 'lucide-react'
|
||||
import { Box, Button, Chip, Typography } from '@mui/material'
|
||||
import { Building2, CheckCircle2, MapPin } from 'lucide-react'
|
||||
import { useSessionStore } from '../../stores/sessionStore'
|
||||
import { useInquiryStore } from '../../stores/inquiryStore'
|
||||
import { LocationPreview } from '../shared/LocationPreview'
|
||||
@@ -34,13 +34,8 @@ export function IntelligenceMatchCard({ vm, imageUrl, lat, lng, cityLabel, overl
|
||||
return <FutureAvailabilityCard vm={vm} />
|
||||
}
|
||||
|
||||
// secondary chips — max 2 visible
|
||||
const chips: Array<{ label: string; color?: string }> = []
|
||||
if (vm.availabilityLabel) chips.push({ label: vm.availabilityLabel })
|
||||
if (vm.fitOutLabel) chips.push({ label: vm.fitOutLabel })
|
||||
if (vm.isDivisible && vm.minDivisibleUnitSqm) chips.push({ label: `Teilbar ab ${vm.minDivisibleUnitSqm} m²` })
|
||||
const visibleChips = chips.slice(0, 2)
|
||||
const hiddenChips = chips.slice(2)
|
||||
// secondary chips — max 1 visible (availability only), no overflow chip
|
||||
const secondaryChip = vm.availabilityLabel ?? vm.fitOutLabel ?? null
|
||||
|
||||
return (
|
||||
<Box sx={{
|
||||
@@ -51,8 +46,15 @@ export function IntelligenceMatchCard({ vm, imageUrl, lat, lng, cityLabel, overl
|
||||
boxShadow: '0 1px 3px rgba(0,0,0,0.06)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
transition: 'border-color 0.15s',
|
||||
'&:hover': { borderColor: '#c8c7c4' },
|
||||
position: 'relative',
|
||||
zIndex: 0,
|
||||
transition: 'transform 0.18s ease, box-shadow 0.18s ease, border-color 0.15s',
|
||||
'&:hover': {
|
||||
borderColor: '#b0aead',
|
||||
transform: 'scale(1.025)',
|
||||
boxShadow: '0 10px 32px rgba(0,0,0,0.13)',
|
||||
zIndex: 1,
|
||||
},
|
||||
}}>
|
||||
|
||||
{/* ── Hero zone ── */}
|
||||
@@ -67,15 +69,29 @@ export function IntelligenceMatchCard({ vm, imageUrl, lat, lng, cityLabel, overl
|
||||
border: `1px solid ${theme.border}`,
|
||||
}}>
|
||||
<Typography sx={{
|
||||
fontFamily: '"DM Serif Display", serif',
|
||||
fontWeight: 400, fontSize: '1.625rem',
|
||||
color: '#ffffff', lineHeight: 1,
|
||||
fontWeight: 700, fontSize: '1.625rem',
|
||||
color: '#ffffff', lineHeight: 1, letterSpacing: '-0.02em',
|
||||
}}>
|
||||
{vm.matchScore}%
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Top-right badge strip */}
|
||||
{/* Location pill — Ginesta-style, top-left */}
|
||||
<Box sx={{
|
||||
position: 'absolute', top: 10, left: 10,
|
||||
bgcolor: 'rgba(255,255,255,0.92)',
|
||||
borderRadius: '20px',
|
||||
px: 1, py: 0.35,
|
||||
display: 'flex', alignItems: 'center', gap: 0.4,
|
||||
backdropFilter: 'blur(4px)',
|
||||
}}>
|
||||
<MapPin size={9} color="#64748b" />
|
||||
<Typography sx={{ fontSize: '0.65rem', fontWeight: 500, color: '#374151', lineHeight: 1 }}>
|
||||
{vm.locationLabel}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Top-right badge strip — result type + owner + heat */}
|
||||
<Box sx={{ position: 'absolute', top: 10, right: 10, display: 'flex', flexDirection: 'column', gap: 0.5, alignItems: 'flex-end' }}>
|
||||
<Chip label={rt.label} size="small" sx={{ bgcolor: rt.color, color: 'white', fontWeight: 600, fontSize: 10, height: 20 }} />
|
||||
{vm.resultType === 'VERIFIED_PORTFOLIO' && isPortfolioOwner && (
|
||||
@@ -116,19 +132,13 @@ export function IntelligenceMatchCard({ vm, imageUrl, lat, lng, cityLabel, overl
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{/* Confidence + secondary chips */}
|
||||
{/* Confidence + one secondary chip max */}
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5, mt: 1, alignItems: 'center' }}>
|
||||
<Chip label={`${confPct}% Konfidenz`} size="small"
|
||||
sx={{ bgcolor: confidenceHex(vm.confidenceScore), color: 'white', fontSize: 10, height: 20 }} />
|
||||
{visibleChips.map((c, i) => (
|
||||
<Chip key={i} label={c.label} size="small" variant="outlined"
|
||||
{secondaryChip && (
|
||||
<Chip label={secondaryChip} size="small" variant="outlined"
|
||||
sx={{ fontSize: 10, height: 20, borderColor: DS_BORDER.default, color: DS_TEXT.secondary }} />
|
||||
))}
|
||||
{hiddenChips.length > 0 && (
|
||||
<Tooltip title={hiddenChips.map(c => c.label).join(' · ')} arrow>
|
||||
<Chip label={`+${hiddenChips.length}`} size="small" variant="outlined"
|
||||
sx={{ fontSize: 10, height: 20, borderColor: DS_BORDER.default, color: DS_TEXT.muted }} />
|
||||
</Tooltip>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
@@ -145,7 +155,7 @@ export function IntelligenceMatchCard({ vm, imageUrl, lat, lng, cityLabel, overl
|
||||
|
||||
{vm.reasons.length > 0 && (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5, mt: 1 }}>
|
||||
{vm.reasons.slice(0, 3).map((r, i) => (
|
||||
{vm.reasons.slice(0, 2).map((r, i) => (
|
||||
<Box key={i} sx={{ display: 'flex', alignItems: 'flex-start', gap: 0.75 }}>
|
||||
<CheckCircle2 size={13} color="#1d6342" style={{ marginTop: 2, flexShrink: 0 }} />
|
||||
<Box>
|
||||
|
||||
@@ -1,23 +1,21 @@
|
||||
import { memo } from 'react'
|
||||
import { Alert, Box, Button, Chip, Divider, Tooltip, Typography } from '@mui/material'
|
||||
import { MapPin, Building2, CheckCircle2, AlertTriangle } from 'lucide-react'
|
||||
import { Alert, Box, Button, Chip, Typography } from '@mui/material'
|
||||
import { MapPin, AlertTriangle } from 'lucide-react'
|
||||
import { useSessionStore } from '../../stores/sessionStore'
|
||||
import { useInquiryStore } from '../../stores/inquiryStore'
|
||||
import { HeatBadge } from '../shared/HeatBadge'
|
||||
import { getScoreTier, SCORE_THEME } from '../shared/scoreTheme'
|
||||
import { RESULT_TYPE_META, DS_TEXT, DS_BORDER } from '../../lib/ds'
|
||||
import { confidenceHex } from '../../lib/utils'
|
||||
import { ScoreInlineBreakdown } from './ScoreInlineBreakdown'
|
||||
import { MatchCardRestrictedState } from './MatchCardRestrictedState'
|
||||
import type { MatchCardViewModel } from './MatchCardViewModel'
|
||||
|
||||
interface Props {
|
||||
vm: MatchCardViewModel
|
||||
imageUrl?: string
|
||||
overlayTypeLabel?: string
|
||||
}
|
||||
|
||||
export const MatchCardCompact = memo(function MatchCardCompact({ vm, imageUrl, overlayTypeLabel }: Props) {
|
||||
export const MatchCardCompact = memo(function MatchCardCompact({ vm, imageUrl }: Props) {
|
||||
if (vm.isRestricted) return <MatchCardRestrictedState />
|
||||
|
||||
const { currentUser } = useSessionStore()
|
||||
@@ -32,18 +30,20 @@ export const MatchCardCompact = memo(function MatchCardCompact({ vm, imageUrl, o
|
||||
|
||||
const leftAccent = vm.isCompareSelected ? '#5b3f8a' : vm.isSelected ? '#152642' : 'transparent'
|
||||
|
||||
const extraChips: string[] = []
|
||||
if (vm.availabilityLabel) extraChips.push(vm.availabilityLabel)
|
||||
if (vm.fitOutLabel) extraChips.push(vm.fitOutLabel)
|
||||
if (vm.isDivisible && vm.minDivisibleUnitSqm) extraChips.push(`Teilbar ab ${vm.minDivisibleUnitSqm} m²`)
|
||||
if (topRisk) extraChips.push(topRisk.level === 'CRITICAL' ? 'Kritisch' : 'Hohes Risiko')
|
||||
const visibleExtra = extraChips.slice(0, 2)
|
||||
const hiddenExtra = extraChips.slice(2)
|
||||
// Compact metric line — area · rent · availability
|
||||
const metricParts: string[] = []
|
||||
if (vm.areaSqm) metricParts.push(`${vm.areaSqm.toLocaleString('de-CH')} m²`)
|
||||
if (vm.rentPerSqm) metricParts.push(`CHF ${vm.rentPerSqm}/m²/J`)
|
||||
if (vm.availabilityLabel) metricParts.push(vm.availabilityLabel)
|
||||
const metricLine = metricParts.join(' · ')
|
||||
|
||||
// Detail action — prefer the "Details →" action if available, fallback to first action
|
||||
const detailAction = vm.actions.find(a => a.actionType === 'OPEN_DETAIL' && a.label.includes('Detail'))
|
||||
?? vm.actions.find(a => a.actionType === 'OPEN_DETAIL')
|
||||
|
||||
return (
|
||||
<Box sx={{
|
||||
display: 'flex',
|
||||
mb: 1.5,
|
||||
borderRadius: 2,
|
||||
overflow: 'hidden',
|
||||
border: `1px solid ${DS_BORDER.default}`,
|
||||
@@ -51,12 +51,19 @@ export const MatchCardCompact = memo(function MatchCardCompact({ vm, imageUrl, o
|
||||
bgcolor: '#ffffff',
|
||||
boxShadow: '0 1px 3px rgba(0,0,0,0.06)',
|
||||
opacity: vm.isStaleData ? 0.75 : 1,
|
||||
transition: 'border-color 0.15s',
|
||||
'&:hover': { borderColor: '#c8c7c4' },
|
||||
transition: 'border-color 0.15s, transform 0.18s ease, box-shadow 0.18s ease',
|
||||
position: 'relative',
|
||||
zIndex: 0,
|
||||
'&:hover': {
|
||||
borderColor: '#b0aead',
|
||||
transform: 'scale(1.012)',
|
||||
boxShadow: '0 6px 20px rgba(0,0,0,0.10)',
|
||||
zIndex: 1,
|
||||
},
|
||||
}}>
|
||||
|
||||
{/* ── Image strip ── */}
|
||||
<Box sx={{ width: 120, flexShrink: 0, position: 'relative', bgcolor: '#f4f3f0' }}>
|
||||
{/* ── Image strip — 180px, Ginesta proportion ── */}
|
||||
<Box sx={{ width: 180, flexShrink: 0, position: 'relative', bgcolor: '#f4f3f0' }}>
|
||||
{imageUrl ? (
|
||||
<img
|
||||
src={imageUrl}
|
||||
@@ -73,7 +80,7 @@ export const MatchCardCompact = memo(function MatchCardCompact({ vm, imageUrl, o
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Score badge */}
|
||||
{/* Score badge — top left */}
|
||||
<Box sx={{
|
||||
position: 'absolute', top: 8, left: 8,
|
||||
bgcolor: scoreBadgeBg, borderRadius: '6px',
|
||||
@@ -81,58 +88,50 @@ export const MatchCardCompact = memo(function MatchCardCompact({ vm, imageUrl, o
|
||||
border: `1px solid ${SCORE_THEME[tier].border}`,
|
||||
}}>
|
||||
<Typography sx={{
|
||||
fontFamily: '"DM Serif Display", serif',
|
||||
fontWeight: 400, fontSize: '1.125rem',
|
||||
color: '#ffffff', lineHeight: 1,
|
||||
fontWeight: 700, fontSize: '1.125rem',
|
||||
color: '#ffffff', lineHeight: 1, letterSpacing: '-0.01em',
|
||||
}}>
|
||||
{vm.matchScore}%
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{overlayTypeLabel && (
|
||||
{/* Location pill — Ginesta-style, top right */}
|
||||
<Box sx={{
|
||||
position: 'absolute', bottom: 0, left: 0, right: 0,
|
||||
px: 0.75, py: 0.5,
|
||||
background: 'linear-gradient(to top, rgba(0,0,0,0.55) 0%, transparent 100%)',
|
||||
position: 'absolute', top: 8, right: 8,
|
||||
bgcolor: 'rgba(255,255,255,0.92)',
|
||||
borderRadius: '20px',
|
||||
px: 1, py: 0.35,
|
||||
display: 'flex', alignItems: 'center', gap: 0.4,
|
||||
backdropFilter: 'blur(4px)',
|
||||
}}>
|
||||
<Typography sx={{ fontSize: '0.6rem', fontWeight: 600, color: 'white', lineHeight: 1.3 }}>
|
||||
{overlayTypeLabel}
|
||||
<MapPin size={9} color="#64748b" />
|
||||
<Typography sx={{ fontSize: '0.65rem', fontWeight: 500, color: '#374151', lineHeight: 1 }}>
|
||||
{vm.locationLabel}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* ── Content ── */}
|
||||
<Box sx={{ flex: 1, p: 2, display: 'flex', flexDirection: 'column', minWidth: 0 }}>
|
||||
<Box sx={{ flex: 1, px: 2, py: 2.5, display: 'flex', flexDirection: 'column', minWidth: 0 }}>
|
||||
|
||||
{vm.disclaimer && (
|
||||
<Alert severity="warning" sx={{ mb: 1, py: 0.25, fontSize: '0.72rem' }}>{vm.disclaimer}</Alert>
|
||||
)}
|
||||
|
||||
{/* Badges row */}
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5, alignItems: 'center', mb: 0.75 }}>
|
||||
<HeatBadge propertyId={vm.propertyId} size="sm" />
|
||||
{/* Chips — result type + heat + confidence */}
|
||||
<Box sx={{ display: 'flex', gap: 0.5, alignItems: 'center', mb: 0.875 }}>
|
||||
<Chip label={rt.label} size="small"
|
||||
sx={{ bgcolor: rt.color, color: 'white', fontWeight: 600, fontSize: 10, height: 20 }} />
|
||||
{vm.resultType === 'VERIFIED_PORTFOLIO' && isPortfolioOwner && (
|
||||
<Chip icon={<Building2 size={9} color="#152642" />} label="Ihr Objekt" size="small"
|
||||
sx={{ bgcolor: 'rgba(21,38,66,0.10)', color: '#152642', fontWeight: 700, fontSize: 9, height: 18, '& .MuiChip-icon': { ml: 0.5 } }} />
|
||||
)}
|
||||
<HeatBadge propertyId={vm.propertyId} size="sm" />
|
||||
<Chip label={`${confPct}% Konfidenz`} size="small"
|
||||
sx={{ bgcolor: confidenceHex(vm.confidenceScore), color: 'white', fontSize: 10, height: 20 }} />
|
||||
{visibleExtra.map((label, i) => (
|
||||
<Chip key={i} label={label} size="small" variant="outlined"
|
||||
sx={{ fontSize: 10, height: 20, borderColor: DS_BORDER.default, color: DS_TEXT.secondary }} />
|
||||
))}
|
||||
{hiddenExtra.length > 0 && (
|
||||
<Tooltip title={hiddenExtra.join(' · ')} arrow>
|
||||
<Chip label={`+${hiddenExtra.length}`} size="small" variant="outlined"
|
||||
sx={{ fontSize: 10, height: 20, borderColor: DS_BORDER.default, color: DS_TEXT.muted }} />
|
||||
</Tooltip>
|
||||
{vm.resultType === 'VERIFIED_PORTFOLIO' && isPortfolioOwner && (
|
||||
<Chip label="Ihr Objekt" size="small"
|
||||
sx={{ bgcolor: 'rgba(21,38,66,0.10)', color: '#152642', fontWeight: 700, fontSize: 9, height: 18 }} />
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Title + location */}
|
||||
{/* Title */}
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, lineHeight: 1.3, color: DS_TEXT.primary }} noWrap>
|
||||
{vm.title}
|
||||
</Typography>
|
||||
@@ -141,40 +140,25 @@ export const MatchCardCompact = memo(function MatchCardCompact({ vm, imageUrl, o
|
||||
{vm.propertySubtitle}
|
||||
</Typography>
|
||||
)}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.4, mt: 0.25 }}>
|
||||
<MapPin size={11} color="#9a9a9a" />
|
||||
<Typography variant="caption" sx={{ color: DS_TEXT.muted }}>
|
||||
{vm.locationLabel}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Metric line — Ginesta-style */}
|
||||
{metricLine && (
|
||||
<Typography sx={{ fontSize: '0.75rem', color: '#94a3b8', mt: 0.5, letterSpacing: 0.1 }}>
|
||||
{metricLine}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{/* Explainability summary — 1 line */}
|
||||
{vm.explainabilitySummary && (
|
||||
<Typography variant="body2" sx={{ color: DS_TEXT.secondary, fontWeight: 500, mt: 0.75, lineHeight: 1.5,
|
||||
overflow: 'hidden', display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical' }}>
|
||||
<Typography variant="body2" sx={{
|
||||
color: DS_TEXT.secondary, fontWeight: 500, mt: 0.875, lineHeight: 1.5,
|
||||
overflow: 'hidden', display: '-webkit-box', WebkitLineClamp: 1, WebkitBoxOrient: 'vertical',
|
||||
}}>
|
||||
{vm.explainabilitySummary}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{vm.reasons.length > 0 && (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.35, mt: 0.75 }}>
|
||||
{vm.reasons.slice(0, 2).map((r, i) => (
|
||||
<Box key={i} sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||
<CheckCircle2 size={11} color="#1d6342" style={{ flexShrink: 0 }} />
|
||||
<Typography sx={{ fontSize: '0.72rem', color: DS_TEXT.secondary, lineHeight: 1.3 }}>
|
||||
{r.label}
|
||||
</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{vm.scoreBreakdown && vm.allFactors && vm.allFactors.length > 0 && (
|
||||
<>
|
||||
<Divider sx={{ my: 0.75 }} />
|
||||
<ScoreInlineBreakdown scoreBreakdown={vm.scoreBreakdown} allFactors={vm.allFactors} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Critical risk only */}
|
||||
{topRisk && topRisk.level === 'CRITICAL' && (
|
||||
<Alert icon={<AlertTriangle size={13} />} severity="error"
|
||||
sx={{ mt: 0.75, py: 0.25, fontSize: '0.72rem' }}>
|
||||
@@ -182,8 +166,8 @@ export const MatchCardCompact = memo(function MatchCardCompact({ vm, imageUrl, o
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<Box sx={{ display: 'flex', gap: 0.625, mt: 'auto', pt: 1, flexWrap: 'wrap' }}>
|
||||
{/* 2 Buttons — Anfrage + Details */}
|
||||
<Box sx={{ display: 'flex', gap: 0.625, mt: 'auto', pt: 1.25 }}>
|
||||
<Button
|
||||
size="small"
|
||||
variant="contained"
|
||||
@@ -201,18 +185,16 @@ export const MatchCardCompact = memo(function MatchCardCompact({ vm, imageUrl, o
|
||||
>
|
||||
Anfrage
|
||||
</Button>
|
||||
{vm.actions.map(a => (
|
||||
{detailAction && (
|
||||
<Button
|
||||
key={a.id}
|
||||
size="small"
|
||||
variant={a.variant === 'primary' ? 'contained' : 'outlined'}
|
||||
onClick={a.onClick}
|
||||
disabled={a.disabled}
|
||||
variant="outlined"
|
||||
onClick={detailAction.onClick}
|
||||
sx={{ textTransform: 'none', fontSize: '0.7rem', py: 0.375, px: 1 }}
|
||||
>
|
||||
{a.label}
|
||||
Details →
|
||||
</Button>
|
||||
))}
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
@@ -86,6 +86,10 @@ export interface MatchCardViewModel {
|
||||
preMarketUnit?: PropertyUnit // specific unit being released pre-market
|
||||
preMarketAllUnits?: PropertyUnit[] // all units of the backing property
|
||||
|
||||
// Key metrics for compact display (Ginesta-style metric line)
|
||||
areaSqm?: number
|
||||
rentPerSqm?: number
|
||||
|
||||
// Score transparency
|
||||
scoreBreakdown?: {
|
||||
hardMatchScore: number
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { Box, Chip, CircularProgress, Paper, Typography } from '@mui/material'
|
||||
import { Lightbulb, MessageSquareQuote } from 'lucide-react'
|
||||
import { useFitOutAdvice } from '../../hooks/useAI'
|
||||
import { DS_TEXT, DS_SURFACE, DS_BORDER } from '../../lib/ds'
|
||||
|
||||
const RECOMMENDATION_META: Record<string, { label: string; bg: string; border: string; fg: string }> = {
|
||||
MIETERAUSBAU: { label: 'Mieterausbau', bg: DS_SURFACE.blue.bg, border: DS_SURFACE.blue.border, fg: DS_TEXT.signalDark },
|
||||
BKZ: { label: 'Baukostenzuschuss (BKZ)', bg: DS_SURFACE.success.bg, border: DS_SURFACE.success.border, fg: DS_TEXT.success },
|
||||
MAB_AMORTISATION: { label: 'MAB-Amortisation', bg: DS_SURFACE.success.bg, border: DS_SURFACE.success.border, fg: DS_TEXT.success },
|
||||
}
|
||||
|
||||
interface Props {
|
||||
fitOut: string
|
||||
areaSqm: number
|
||||
mabPerSqm: number
|
||||
rentPricePerSqm: number
|
||||
requiredFitOut?: string
|
||||
tenantBudgetPerSqm?: number
|
||||
}
|
||||
|
||||
export function FitOutAdvicePanel({ fitOut, areaSqm, mabPerSqm, rentPricePerSqm, requiredFitOut, tenantBudgetPerSqm }: Props) {
|
||||
const { data, isLoading, isError } = useFitOutAdvice({
|
||||
fitOut,
|
||||
areaSqm,
|
||||
mabPerSqm,
|
||||
requiredFitOut,
|
||||
tenantBudgetPerSqm,
|
||||
monthlyRentPerSqm: Math.round(rentPricePerSqm / 12),
|
||||
})
|
||||
|
||||
// Fallback: KI-Ausfall blockiert nie — Panel wird einfach nicht gezeigt
|
||||
if (isError) return null
|
||||
|
||||
if (isLoading || !data) {
|
||||
return (
|
||||
<Paper sx={{ p: 2.5, display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
||||
<CircularProgress size={16} />
|
||||
<Typography variant="body2" sx={{ color: DS_TEXT.muted }}>KI-Ausbauempfehlung wird erstellt…</Typography>
|
||||
</Paper>
|
||||
)
|
||||
}
|
||||
|
||||
const advice = data.data
|
||||
const meta = RECOMMENDATION_META[advice.recommendation] ?? RECOMMENDATION_META.MIETERAUSBAU
|
||||
|
||||
return (
|
||||
<Paper sx={{ p: 2.5 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
|
||||
<Lightbulb size={15} color={DS_TEXT.secondary} />
|
||||
<Typography variant="h6" sx={{ fontWeight: 700 }}>KI-Ausbauempfehlung</Typography>
|
||||
<Chip
|
||||
label={meta.label}
|
||||
size="small"
|
||||
sx={{ ml: 'auto', bgcolor: meta.bg, color: meta.fg, fontWeight: 700, fontSize: 11, height: 22, border: `1px solid ${meta.border}` }}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Typography variant="body2" sx={{ fontWeight: 700, mb: 0.75 }}>{advice.headline}</Typography>
|
||||
<Typography variant="body2" sx={{ color: DS_TEXT.secondary, mb: 1.5, lineHeight: 1.55 }}>
|
||||
{advice.explanation}
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', py: 1, px: 1.5, mb: 1.5, bgcolor: DS_SURFACE.neutral.bg, border: `1px solid ${DS_BORDER.default}`, borderRadius: 1 }}>
|
||||
<Typography variant="caption" sx={{ color: DS_TEXT.muted, fontWeight: 600 }}>Geschätzte Nettoinvestition</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 700 }}>{advice.estimatedNetInvestment}</Typography>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 1, alignItems: 'flex-start' }}>
|
||||
<MessageSquareQuote size={14} color={DS_TEXT.muted} style={{ marginTop: 3, flexShrink: 0 }} />
|
||||
<Typography variant="caption" sx={{ color: DS_TEXT.secondary, lineHeight: 1.5 }}>
|
||||
<strong>Verhandlungstipp:</strong> {advice.negotiationTip}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{data.provenance?.fallbackUsed && (
|
||||
<Typography variant="caption" sx={{ color: DS_TEXT.muted, display: 'block', mt: 1.25 }}>
|
||||
Hinweis: KI nicht verfügbar — Richtwert-basierte Empfehlung.
|
||||
</Typography>
|
||||
)}
|
||||
</Paper>
|
||||
)
|
||||
}
|
||||
@@ -1,33 +1,23 @@
|
||||
import { Box, Chip, Paper, Typography } from '@mui/material'
|
||||
import { HardHat } from 'lucide-react'
|
||||
import { calcFitOutInvestment } from '../../lib/fitOutUtils'
|
||||
import { effectiveAnnualBurdenPerSqm } from '../../lib/fitOutUtils'
|
||||
import { DS_TEXT, DS_SURFACE, DS_BORDER } from '../../lib/ds'
|
||||
import { FITOUT_AMORTIZATION_YEARS } from '../../lib/constants'
|
||||
import { FITOUT_AMORTIZATION_YEARS, FITOUT_ANNUITY_RATE, FIT_OUT_LABELS } from '../../lib/constants'
|
||||
|
||||
const FIT_OUT_LABELS: Record<string, string> = {
|
||||
SHELL: 'Rohbau', BASIC: 'Basisausbau', FULL: 'Vollausbau', PREMIUM: 'Premiumausbau',
|
||||
}
|
||||
|
||||
const AMORTIZATION_YEARS = FITOUT_AMORTIZATION_YEARS
|
||||
const READY_TO_MOVE_IN = new Set(['FULL', 'PREMIUM'])
|
||||
const RATE_PCT = Math.round(FITOUT_ANNUITY_RATE * 100)
|
||||
|
||||
interface Props {
|
||||
fitOut: string
|
||||
areaSqm: number
|
||||
mabPerSqm: number
|
||||
rentPricePerSqm: number
|
||||
tenantBudgetPerSqm?: number
|
||||
fitOutByLandlord?: boolean
|
||||
}
|
||||
|
||||
function chf(value: number): string {
|
||||
return `CHF ${Math.round(value).toLocaleString('de-CH')}.–`
|
||||
}
|
||||
|
||||
function chfRange(min: number, max: number): string {
|
||||
if (Math.round(min) === Math.round(max)) return chf(min)
|
||||
return `${chf(min)} – ${chf(max)}`
|
||||
}
|
||||
|
||||
interface RowProps { label: string; value: string; sub?: string; isTotal?: boolean; isWarning?: boolean }
|
||||
|
||||
function Row({ label, value, sub, isTotal, isWarning }: RowProps) {
|
||||
@@ -49,35 +39,26 @@ function Row({ label, value, sub, isTotal, isWarning }: RowProps) {
|
||||
)
|
||||
}
|
||||
|
||||
export function FitOutCostPanel({ fitOut, areaSqm, mabPerSqm, rentPricePerSqm, tenantBudgetPerSqm = 0 }: Props) {
|
||||
export function FitOutCostPanel({ fitOut, areaSqm, mabPerSqm, rentPricePerSqm, fitOutByLandlord }: Props) {
|
||||
const { fitOutPerSqm } = effectiveAnnualBurdenPerSqm({ fitOut, rentPricePerSqm, mabPerSqm, fitOutByLandlord })
|
||||
const rentPerYear = rentPricePerSqm * areaSqm
|
||||
const isReadyToMoveIn = READY_TO_MOVE_IN.has(fitOut)
|
||||
const fitOutPerYear = fitOutPerSqm * areaSqm
|
||||
const totalPerYear = rentPerYear + fitOutPerYear
|
||||
const fitOutLabel = FIT_OUT_LABELS[fitOut] ?? fitOut
|
||||
|
||||
const investment = isReadyToMoveIn
|
||||
? null
|
||||
: calcFitOutInvestment(fitOut, areaSqm, mabPerSqm, tenantBudgetPerSqm)
|
||||
|
||||
const fitOutPerYear = investment && !investment.isFullyCovered ? {
|
||||
min: Math.round(investment.netTotal.min / AMORTIZATION_YEARS),
|
||||
max: Math.round(investment.netTotal.max / AMORTIZATION_YEARS),
|
||||
} : { min: 0, max: 0 }
|
||||
|
||||
const totalPerYear = {
|
||||
min: rentPerYear + fitOutPerYear.min,
|
||||
max: rentPerYear + fitOutPerYear.max,
|
||||
}
|
||||
const isWarning = !isReadyToMoveIn && totalPerYear.max > rentPerYear * 1.3
|
||||
const disclaimer = isReadyToMoveIn
|
||||
? null
|
||||
: `Ausbaukosten nach CRB/BKP-Normen, amortisiert über ${AMORTIZATION_YEARS} Jahre. Tatsächliche Kosten je nach Ausbauumfang.`
|
||||
// Kein Aufschlag → entweder Vermieter übernimmt oder Fläche bereits bezugsfertig
|
||||
const hasSurcharge = fitOutPerYear > 0
|
||||
const isWarning = totalPerYear > rentPerYear * 1.3
|
||||
const disclaimer = hasSurcharge
|
||||
? `Ausbaukosten nach CRB/BKP-Normen, annuitätisch über ${FITOUT_AMORTIZATION_YEARS} Jahre zu ${RATE_PCT}% p.a. Tatsächliche Kosten je nach Ausbauumfang.`
|
||||
: null
|
||||
|
||||
return (
|
||||
<Paper sx={{ p: 2.5 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
|
||||
<HardHat size={15} color={DS_TEXT.secondary} />
|
||||
<Typography variant="h6" sx={{ fontWeight: 700 }}>Reale Jahresbelastung</Typography>
|
||||
{!isReadyToMoveIn && (
|
||||
{hasSurcharge && (
|
||||
<Chip
|
||||
label="CRB/BKP Richtwerte"
|
||||
size="small"
|
||||
@@ -92,32 +73,32 @@ export function FitOutCostPanel({ fitOut, areaSqm, mabPerSqm, rentPricePerSqm, t
|
||||
value={chf(rentPerYear)}
|
||||
sub={`CHF ${rentPricePerSqm}/m²/Jahr × ${areaSqm.toLocaleString('de-CH')} m²`}
|
||||
/>
|
||||
{isReadyToMoveIn ? (
|
||||
{fitOutByLandlord ? (
|
||||
<Row
|
||||
label="Ausbau"
|
||||
value="CHF 0.–"
|
||||
sub={`${fitOutLabel} — Ausbau im Mietzins enthalten`}
|
||||
/>
|
||||
) : !hasSurcharge ? (
|
||||
<Row
|
||||
label="Ausbau"
|
||||
value="CHF 0.–"
|
||||
sub={`${fitOutLabel} — bezugsfertig`}
|
||||
/>
|
||||
) : investment?.isFullyCovered ? (
|
||||
<Row
|
||||
label="Ausbau (amort.)"
|
||||
value="CHF 0.–"
|
||||
sub={`CHF ${mabPerSqm}/m² MAB deckt Ausbaukosten vollständig`}
|
||||
/>
|
||||
) : (
|
||||
<Row
|
||||
label={`Ausbau (amort. ${AMORTIZATION_YEARS} J.)`}
|
||||
value={chfRange(fitOutPerYear.min, fitOutPerYear.max)}
|
||||
label={`Ausbau (annuit. ${FITOUT_AMORTIZATION_YEARS} J. / ${RATE_PCT}%)`}
|
||||
value={chf(fitOutPerYear)}
|
||||
sub={
|
||||
`CHF ${investment?.grossPerSqm.min}–${investment?.grossPerSqm.max}/m² (${fitOutLabel})` +
|
||||
`${fitOutLabel}` +
|
||||
(mabPerSqm > 0 ? ` abzgl. CHF ${mabPerSqm} MAB` : '') +
|
||||
` × ${areaSqm.toLocaleString('de-CH')} m² ÷ ${AMORTIZATION_YEARS} J.`
|
||||
` — CHF ${fitOutPerSqm}/m²/Jahr × ${areaSqm.toLocaleString('de-CH')} m²`
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<Row
|
||||
label="Total/Jahr"
|
||||
value={chfRange(totalPerYear.min, totalPerYear.max)}
|
||||
value={chf(totalPerYear)}
|
||||
isTotal
|
||||
isWarning={isWarning}
|
||||
/>
|
||||
|
||||
@@ -6,7 +6,9 @@ import {
|
||||
import { Send } from 'lucide-react'
|
||||
import { useToastStore } from '../../stores/toastStore'
|
||||
import { useInquiryStore } from '../../stores/inquiryStore'
|
||||
import { useSessionStore } from '../../stores/sessionStore'
|
||||
import { useMoveStage } from '../../hooks/usePipeline'
|
||||
import { useCreateInquiry } from '../../hooks/useInquiries'
|
||||
import { DS_BG, DS_BORDER, DS_TEXT } from '../../lib/ds'
|
||||
|
||||
function buildTemplate(propertyTitle: string, location: string, areaLabel?: string): string {
|
||||
@@ -19,7 +21,8 @@ export function InquiryQuickDialog() {
|
||||
const dialogOpen = useInquiryStore(s => s.dialogOpen)
|
||||
const pendingInquiry = useInquiryStore(s => s.pendingInquiry)
|
||||
const closeInquiryDialog = useInquiryStore(s => s.closeInquiryDialog)
|
||||
const addInquiry = useInquiryStore(s => s.addInquiry)
|
||||
const currentUser = useSessionStore(s => s.currentUser)
|
||||
const createInquiry = useCreateInquiry()
|
||||
const { mutate: moveStage } = useMoveStage()
|
||||
|
||||
const [message, setMessage] = useState('')
|
||||
@@ -32,7 +35,18 @@ export function InquiryQuickDialog() {
|
||||
|
||||
function handleSend() {
|
||||
if (!pendingInquiry) return
|
||||
addInquiry(pendingInquiry, message)
|
||||
createInquiry.mutate({
|
||||
organizationId: 'org-wincasa', // Supply-Org des Objekts (Mock: Wincasa)
|
||||
tenantOrgId: currentUser?.organizationId ?? 'org-mobimo',
|
||||
propertyId: pendingInquiry.propertyId ?? 'unknown',
|
||||
tenantName: currentUser?.name ?? 'Demand User',
|
||||
tenantCompany: currentUser?.organizationName,
|
||||
tenantEmail: currentUser?.email,
|
||||
propertyAddress: pendingInquiry.propertyTitle,
|
||||
subject: `Anfrage: ${pendingInquiry.propertyTitle}`,
|
||||
message,
|
||||
matchScore: pendingInquiry.matchScore,
|
||||
})
|
||||
if (pendingInquiry.pipelineItemId) {
|
||||
moveStage({ id: pendingInquiry.pipelineItemId, stage: 'CONTACTED' })
|
||||
}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { Box, Chip, Paper, Typography } from '@mui/material'
|
||||
import { TrendingUp } from 'lucide-react'
|
||||
import { estimateMarketRent, type RentVerdict } from '../../lib/rentEstimate'
|
||||
import { DS_TEXT, DS_SURFACE, DS_BORDER } from '../../lib/ds'
|
||||
|
||||
const VERDICT_META: Record<RentVerdict, { label: string; bg: string; border: string; fg: string }> = {
|
||||
BELOW: { label: 'Unter Markt', bg: DS_SURFACE.success.bg, border: DS_SURFACE.success.border, fg: DS_TEXT.success },
|
||||
AT: { label: 'Marktkonform', bg: DS_SURFACE.blue.bg, border: DS_SURFACE.blue.border, fg: DS_TEXT.signalDark },
|
||||
ABOVE: { label: 'Über Markt', bg: DS_SURFACE.warning.bg, border: DS_SURFACE.warning.border, fg: DS_TEXT.warning },
|
||||
}
|
||||
|
||||
interface Props {
|
||||
city: string
|
||||
assetType: string
|
||||
askingRentPerSqm: number
|
||||
futureRentPerSqm?: number // Pre-Market: erwarteter künftiger Preis
|
||||
}
|
||||
|
||||
function chf(v: number): string {
|
||||
return `CHF ${Math.round(v).toLocaleString('de-CH')}/m²`
|
||||
}
|
||||
|
||||
export function MarketPricePanel({ city, assetType, askingRentPerSqm, futureRentPerSqm }: Props) {
|
||||
const est = estimateMarketRent(city, assetType, askingRentPerSqm)
|
||||
if (!est) return null
|
||||
const meta = VERDICT_META[est.verdict]
|
||||
|
||||
return (
|
||||
<Paper sx={{ p: 2.5 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
|
||||
<TrendingUp size={15} color={DS_TEXT.secondary} />
|
||||
<Typography variant="h6" sx={{ fontWeight: 700 }}>Marktpreis-Einschätzung</Typography>
|
||||
<Chip
|
||||
label={meta.label}
|
||||
size="small"
|
||||
sx={{ ml: 'auto', bgcolor: meta.bg, color: meta.fg, fontWeight: 700, fontSize: 11, height: 22, border: `1px solid ${meta.border}` }}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 2, mb: 1.25 }}>
|
||||
<Box sx={{ flex: 1, p: 1.25, border: `1px solid ${DS_BORDER.default}`, borderRadius: 1 }}>
|
||||
<Typography variant="caption" sx={{ color: DS_TEXT.muted }}>Angebotsmiete</Typography>
|
||||
<Typography variant="body1" sx={{ fontWeight: 700 }}>{chf(est.askingRentPerSqm)}</Typography>
|
||||
</Box>
|
||||
<Box sx={{ flex: 1, p: 1.25, border: `1px solid ${DS_BORDER.default}`, borderRadius: 1 }}>
|
||||
<Typography variant="caption" sx={{ color: DS_TEXT.muted }}>Faire Marktmiete</Typography>
|
||||
<Typography variant="body1" sx={{ fontWeight: 700 }}>
|
||||
{chf(est.fairRentPerSqm)}
|
||||
<Box component="span" sx={{ ml: 0.75, fontSize: 12, fontWeight: 600, color: meta.fg }}>
|
||||
{est.deltaPct > 0 ? `+${est.deltaPct}%` : `${est.deltaPct}%`}
|
||||
</Box>
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{futureRentPerSqm != null && futureRentPerSqm > 0 && (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', py: 1, px: 1.5, mb: 1.25, bgcolor: DS_SURFACE.purple.bg, border: `1px solid ${DS_SURFACE.purple.border}`, borderRadius: 1 }}>
|
||||
<Typography variant="caption" sx={{ color: DS_TEXT.muted, fontWeight: 600 }}>Erwartet (Pre-Market)</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 700 }}>
|
||||
{chf(est.askingRentPerSqm)} <Box component="span" sx={{ color: DS_TEXT.muted }}>→</Box> {chf(futureRentPerSqm)}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Typography variant="caption" sx={{ color: DS_TEXT.muted, display: 'block' }}>
|
||||
{est.rationale}
|
||||
</Typography>
|
||||
</Paper>
|
||||
)
|
||||
}
|
||||
@@ -13,4 +13,6 @@ export { SourceProvenancePanel } from './SourceProvenancePanel'
|
||||
export { FutureAvailabilityContextPanel } from './FutureAvailabilityContextPanel'
|
||||
export { NextActionsPanel } from './NextActionsPanel'
|
||||
export { FitOutCostPanel } from './FitOutCostPanel'
|
||||
export { FitOutAdvicePanel } from './FitOutAdvicePanel'
|
||||
export { MarketPricePanel } from './MarketPricePanel'
|
||||
export { FLOOR_LABEL, ASSET_LABELS, RISK_LABELS, SOURCE_LABELS, PASSERBY_LABELS, KeyFactRow, UnitStatusChip, UnitRow } from './MatchDetailPropertyDetails'
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
import { Box, Card, FormControlLabel, MenuItem, Switch, TextField, Typography } from '@mui/material'
|
||||
import { Box, Card, FormControlLabel, MenuItem, Switch, TextField, ToggleButton, ToggleButtonGroup, Typography } from '@mui/material'
|
||||
import { FIT_OUT_OPTIONS } from '../../pages/supply/newListingConstants'
|
||||
|
||||
// Stufen, die noch Mieterausbau benötigen — nur dann ist die Träger-Frage relevant
|
||||
const NEEDS_FIT_OUT = new Set(['SHELL', 'BASIC'])
|
||||
|
||||
interface Props {
|
||||
floor: string
|
||||
onFloorChange: (v: string) => void
|
||||
fitOut: string
|
||||
onFitOutChange: (v: string) => void
|
||||
fitOutByLandlord: boolean | undefined
|
||||
onFitOutByLandlordChange: (v: boolean) => void
|
||||
parking: string
|
||||
onParkingChange: (v: string) => void
|
||||
ceilingHeight: string
|
||||
@@ -21,12 +26,14 @@ interface Props {
|
||||
export function TechnicalDetailsSection({
|
||||
floor, onFloorChange,
|
||||
fitOut, onFitOutChange,
|
||||
fitOutByLandlord, onFitOutByLandlordChange,
|
||||
parking, onParkingChange,
|
||||
ceilingHeight, onCeilingHeightChange,
|
||||
mieterausbaubeitrag, onMieterausbaubeitragChange,
|
||||
isFlexible, onIsFlexibleChange,
|
||||
minLettableSqm, onMinLettableSqmChange,
|
||||
}: Props) {
|
||||
const showFitOutResponsibility = NEEDS_FIT_OUT.has(fitOut)
|
||||
return (
|
||||
<Card sx={{ p: 3, mb: 3 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 2 }}>Technische Details (optional)</Typography>
|
||||
@@ -44,14 +51,6 @@ export function TechnicalDetailsSection({
|
||||
<MenuItem key={o.value} value={o.value}>{o.label}</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
<TextField
|
||||
label="Mieterausbaubeitrag (CHF/m²)"
|
||||
value={mieterausbaubeitrag}
|
||||
onChange={e => onMieterausbaubeitragChange(e.target.value)}
|
||||
size="small" type="number" fullWidth
|
||||
slotProps={{ htmlInput: { min: 0, max: 1000, step: 25 } }}
|
||||
helperText="Beitrag des Vermieters"
|
||||
/>
|
||||
<TextField
|
||||
label="Parkplätze" value={parking}
|
||||
onChange={e => onParkingChange(e.target.value)}
|
||||
@@ -64,6 +63,46 @@ export function TechnicalDetailsSection({
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Ausbau-Träger — nur relevant, wenn die Fläche noch Ausbau benötigt (Rohbau/Edelrohbau) */}
|
||||
{showFitOutResponsibility && (
|
||||
<Box sx={{ mt: 2.5, pt: 2, borderTop: '1px solid #f1f5f9' }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, mb: 1 }}>
|
||||
Wer baut aus? <Box component="span" sx={{ color: '#dc2626' }}>*</Box>
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 2, flexWrap: 'wrap' }}>
|
||||
<ToggleButtonGroup
|
||||
exclusive
|
||||
size="small"
|
||||
value={fitOutByLandlord === undefined ? null : fitOutByLandlord ? 'landlord' : 'tenant'}
|
||||
onChange={(_, v) => { if (v) onFitOutByLandlordChange(v === 'landlord') }}
|
||||
>
|
||||
<ToggleButton value="landlord" sx={{ textTransform: 'none' }}>Vermieter übernimmt</ToggleButton>
|
||||
<ToggleButton value="tenant" sx={{ textTransform: 'none' }}>Mieter baut aus</ToggleButton>
|
||||
</ToggleButtonGroup>
|
||||
|
||||
{fitOutByLandlord === undefined ? (
|
||||
<Typography variant="caption" sx={{ flex: 1, minWidth: 220, mt: 0.5, color: '#b45309' }}>
|
||||
Pflichtangabe bei Rohbau/Edelrohbau — bitte wählen, wer den Ausbau trägt.
|
||||
</Typography>
|
||||
) : fitOutByLandlord ? (
|
||||
<Typography variant="caption" color="text.secondary" sx={{ flex: 1, minWidth: 220, mt: 0.5 }}>
|
||||
Ausbau im Mietzins enthalten — bitte die <strong>bezugsfertige</strong> Miete eintragen.
|
||||
Es wird kein Aufschlag berechnet.
|
||||
</Typography>
|
||||
) : (
|
||||
<TextField
|
||||
label="Mieterausbaubeitrag (CHF/m²)"
|
||||
value={mieterausbaubeitrag}
|
||||
onChange={e => onMieterausbaubeitragChange(e.target.value)}
|
||||
size="small" type="number" sx={{ width: 240 }}
|
||||
slotProps={{ htmlInput: { min: 0, max: 1000, step: 25 } }}
|
||||
helperText="Beitrag des Vermieters — optional, leer = kein Beitrag"
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Divisibility */}
|
||||
<Box sx={{ mt: 2.5, pt: 2, borderTop: '1px solid #f1f5f9', display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<FormControlLabel
|
||||
|
||||
@@ -32,7 +32,7 @@ export function DetailPanel({ item, onClose }: { item: PipelineItem; onClose: ()
|
||||
|
||||
return (
|
||||
<Box sx={{
|
||||
width: { xs: '100%', md: 340 }, flexShrink: 0,
|
||||
width: { xs: '100%', md: 300, lg: 320, xl: 340 }, flexShrink: 0,
|
||||
display: 'flex', flexDirection: 'column',
|
||||
bgcolor: 'white', borderLeft: `1px solid ${DS_BORDER.default}`, overflow: 'hidden',
|
||||
}}>
|
||||
|
||||
@@ -125,16 +125,6 @@ export function UnifiedResultCard({ result, view = 'list' }: Props) {
|
||||
const listImageUrl = result.resultType !== 'FUTURE_AVAILABILITY'
|
||||
? result.property.images?.[0]
|
||||
: undefined
|
||||
const listOverlay = result.resultType !== 'FUTURE_AVAILABILITY'
|
||||
? resolveImageLabel({
|
||||
assetType: result.property.assetType,
|
||||
city: result.property.location.city,
|
||||
district: result.property.location.district,
|
||||
prestige: result.property.softFactors?.prestige,
|
||||
floorLevel: result.property.floorLevel,
|
||||
propertyId: result.property.id,
|
||||
})
|
||||
: null
|
||||
|
||||
return <MatchCardCompact vm={vm} imageUrl={listImageUrl} overlayTypeLabel={listOverlay?.type} />
|
||||
return <MatchCardCompact vm={vm} imageUrl={listImageUrl} />
|
||||
}
|
||||
|
||||
@@ -86,7 +86,7 @@ export function UnifiedResultFeed({ results, view = 'list' }: Props) {
|
||||
))}
|
||||
</Box>
|
||||
) : (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
{items.map(result => (
|
||||
<UnifiedResultCard key={result.matchId} result={result} view="list" />
|
||||
))}
|
||||
|
||||
@@ -20,13 +20,13 @@ export function DashboardHeader({ orgName = 'Demo Organisation', lastUpdated }:
|
||||
return (
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.5 }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 600 }}>
|
||||
<Typography sx={{ fontWeight: 700, fontSize: '1.125rem', lineHeight: 1.3, color: '#0f172a' }}>
|
||||
{orgName}
|
||||
</Typography>
|
||||
<Chip label="Demo" size="small" color="info" variant="outlined" />
|
||||
</Box>
|
||||
{lastUpdated && (
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Zuletzt aktualisiert: {formatLastUpdated(lastUpdated)}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
@@ -188,6 +188,7 @@ export function PreMarketPanel({ p }: { p: Property }) {
|
||||
|
||||
{(p.units?.length ?? 0) > 0 && (
|
||||
<PreMarketUnitGrid
|
||||
property={p}
|
||||
units={p.units!}
|
||||
unitStates={unitStates}
|
||||
unitSaving={unitSaving}
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
import { useState } from 'react'
|
||||
import { Box, Button, Chip, CircularProgress, Divider, MenuItem, TextField, ToggleButton, ToggleButtonGroup, Typography } from '@mui/material'
|
||||
import { Sparkles } from 'lucide-react'
|
||||
import type { Property, PropertyUnit } from '../../domain/property'
|
||||
import { usePreMarketRentRecommendation } from '../../hooks/useAI'
|
||||
import { useUpdateUnit } from '../../hooks/useProperties'
|
||||
import { resolveUnitFacts } from '../../lib/unitFacts'
|
||||
import { FIT_OUT_LABELS } from '../../lib/constants'
|
||||
import { DS_BORDER, DS_PRE_MARKET, DS_SURFACE, DS_TEXT } from '../../lib/ds'
|
||||
|
||||
const NEEDS_BUILD = new Set(['SHELL', 'BASIC'])
|
||||
const VERDICT_META: Record<string, { label: string; bg: string; fg: string }> = {
|
||||
UNDERPRICED: { label: 'zu günstig', bg: '#fef3c7', fg: '#92400e' },
|
||||
FAIR: { label: 'marktgerecht', bg: '#dcfce7', fg: '#166534' },
|
||||
AMBITIOUS: { label: 'ambitioniert', bg: '#fee2e2', fg: '#991b1b' },
|
||||
}
|
||||
|
||||
interface Draft {
|
||||
fitOut?: string
|
||||
fitOutByLandlord?: boolean
|
||||
mieterausbaubeitragPerSqm?: number
|
||||
parkingSpots?: number
|
||||
expectedRentPerSqm?: number
|
||||
}
|
||||
|
||||
interface Props {
|
||||
property: Property
|
||||
unit: PropertyUnit
|
||||
}
|
||||
|
||||
/** Pre-Market-Konfiguration einer Einheit: erst Ausbau/Parkplätze, dann KI-Preisempfehlung. */
|
||||
export function PreMarketPriceAdvisor({ property, unit }: Props) {
|
||||
const updateUnit = useUpdateUnit(property.id)
|
||||
const [d, setD] = useState<Draft>(() => ({
|
||||
fitOut: unit.fitOut ?? '',
|
||||
fitOutByLandlord: unit.fitOutByLandlord,
|
||||
mieterausbaubeitragPerSqm: unit.mieterausbaubeitragPerSqm,
|
||||
parkingSpots: unit.parkingSpots,
|
||||
expectedRentPerSqm: unit.expectedRentPerSqm,
|
||||
}))
|
||||
|
||||
const facts = resolveUnitFacts(property, unit)
|
||||
const current = facts.rentPricePerSqm
|
||||
const effFit = d.fitOut || property.hardFacts?.fitOut
|
||||
const needsBuild = NEEDS_BUILD.has(effFit ?? '')
|
||||
|
||||
const { data, isLoading, isError } = usePreMarketRentRecommendation({
|
||||
city: property.location?.city ?? '',
|
||||
assetType: property.assetType,
|
||||
areaSqm: unit.areaSqm,
|
||||
currentRentPerSqm: current,
|
||||
availableFrom: unit.schattenmarktRelease?.availableFrom,
|
||||
})
|
||||
const rec = data?.data
|
||||
const verdict = rec ? VERDICT_META[rec.verdict] : null
|
||||
|
||||
const save = (patch: Partial<PropertyUnit>) => updateUnit.mutate({ unitId: unit.id, data: patch })
|
||||
|
||||
return (
|
||||
<Box sx={{ mt: 0.75, p: 1.25, borderRadius: 1, bgcolor: 'white', border: `1px solid ${DS_SURFACE.purple.border}` }}>
|
||||
{/* 1) Ausbau & Ausstattung */}
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 130px', gap: 1 }}>
|
||||
<TextField
|
||||
select size="small" label="Ausbaustandard"
|
||||
value={d.fitOut ?? ''}
|
||||
onChange={e => {
|
||||
const v = e.target.value
|
||||
const nb = v === 'SHELL' || v === 'BASIC'
|
||||
setD(p => ({ ...p, fitOut: v }))
|
||||
save({ fitOut: (v || undefined) as PropertyUnit['fitOut'], ...(nb ? {} : { fitOutByLandlord: undefined, mieterausbaubeitragPerSqm: undefined }) })
|
||||
}}
|
||||
>
|
||||
<MenuItem value="">Wie Objekt{property.hardFacts?.fitOut ? ` (${FIT_OUT_LABELS[property.hardFacts.fitOut] ?? property.hardFacts.fitOut})` : ''}</MenuItem>
|
||||
<MenuItem value="SHELL">{FIT_OUT_LABELS.SHELL}</MenuItem>
|
||||
<MenuItem value="BASIC">{FIT_OUT_LABELS.BASIC}</MenuItem>
|
||||
<MenuItem value="FULL">{FIT_OUT_LABELS.FULL}</MenuItem>
|
||||
<MenuItem value="PREMIUM">{FIT_OUT_LABELS.PREMIUM}</MenuItem>
|
||||
</TextField>
|
||||
<TextField
|
||||
label="Parkplätze" type="number" size="small"
|
||||
value={d.parkingSpots ?? ''}
|
||||
onChange={e => setD(p => ({ ...p, parkingSpots: parseInt(e.target.value) || undefined }))}
|
||||
onBlur={e => save({ parkingSpots: parseInt(e.target.value) || undefined })}
|
||||
slotProps={{ htmlInput: { min: 0 } }}
|
||||
helperText={property.hardFacts?.parking != null ? `Pool: ${property.hardFacts.parking}` : undefined}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{needsBuild && (
|
||||
<Box sx={{ display: 'flex', gap: 1.5, alignItems: 'center', flexWrap: 'wrap', mt: 1 }}>
|
||||
<Typography variant="caption" color="text.secondary">Wer baut aus?</Typography>
|
||||
<ToggleButtonGroup
|
||||
exclusive size="small"
|
||||
value={d.fitOutByLandlord === undefined ? null : d.fitOutByLandlord ? 'landlord' : 'tenant'}
|
||||
onChange={(_, v) => {
|
||||
if (!v) return
|
||||
const landlord = v === 'landlord'
|
||||
setD(p => ({ ...p, fitOutByLandlord: landlord, ...(landlord ? { mieterausbaubeitragPerSqm: undefined } : {}) }))
|
||||
save({ fitOutByLandlord: landlord, ...(landlord ? { mieterausbaubeitragPerSqm: undefined } : {}) })
|
||||
}}
|
||||
>
|
||||
<ToggleButton value="landlord" sx={{ textTransform: 'none' }}>Vermieter</ToggleButton>
|
||||
<ToggleButton value="tenant" sx={{ textTransform: 'none' }}>Mieter</ToggleButton>
|
||||
</ToggleButtonGroup>
|
||||
{d.fitOutByLandlord === false && (
|
||||
<TextField
|
||||
label="MAB (CHF/m²)" type="number" size="small" sx={{ width: 140 }}
|
||||
value={d.mieterausbaubeitragPerSqm ?? ''}
|
||||
onChange={e => setD(p => ({ ...p, mieterausbaubeitragPerSqm: parseInt(e.target.value) || undefined }))}
|
||||
onBlur={e => save({ mieterausbaubeitragPerSqm: parseInt(e.target.value) || undefined })}
|
||||
slotProps={{ htmlInput: { min: 0, max: 1000, step: 25 } }}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Divider sx={{ my: 1.25 }} />
|
||||
|
||||
{/* 2) KI-Preisempfehlung */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mb: 0.5 }}>
|
||||
<Sparkles size={12} color={DS_PRE_MARKET.accent} />
|
||||
<Typography sx={{ fontSize: '0.65rem', fontWeight: 700, color: '#5b21b6' }}>KI-Preisempfehlung Pre-Market</Typography>
|
||||
{verdict && <Chip label={verdict.label} size="small" sx={{ height: 16, fontSize: '0.6rem', fontWeight: 700, bgcolor: verdict.bg, color: verdict.fg, ml: 'auto' }} />}
|
||||
</Box>
|
||||
|
||||
{isLoading ? (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, py: 0.5 }}>
|
||||
<CircularProgress size={12} sx={{ color: DS_PRE_MARKET.accent }} />
|
||||
<Typography sx={{ fontSize: '0.65rem', color: '#6d28d9' }}>Marktanalyse läuft…</Typography>
|
||||
</Box>
|
||||
) : isError || !rec ? (
|
||||
<Typography sx={{ fontSize: '0.65rem', color: DS_TEXT.muted }}>Keine Empfehlung verfügbar.</Typography>
|
||||
) : (
|
||||
<>
|
||||
<Typography sx={{ fontSize: '0.72rem', fontWeight: 700, color: '#5b21b6' }}>
|
||||
Empfehlung: CHF {rec.recommendedPerSqm}/m²
|
||||
<Box component="span" sx={{ fontWeight: 400, color: '#6d28d9' }}> (CHF {rec.rangeMinPerSqm}–{rec.rangeMaxPerSqm})</Box>
|
||||
</Typography>
|
||||
<Typography sx={{ fontSize: '0.65rem', color: '#6d28d9', mb: 0.25 }}>
|
||||
Heute CHF {current}/m²
|
||||
<Box component="span" sx={{ fontWeight: 700, ml: 0.5 }}>
|
||||
{rec.deltaVsCurrentPct > 0 ? `+${rec.deltaVsCurrentPct}%` : `${rec.deltaVsCurrentPct}%`} vs. heute
|
||||
</Box>
|
||||
</Typography>
|
||||
<Typography sx={{ fontSize: '0.6rem', color: '#7c3aed', mb: 0.625 }}>
|
||||
{rec.drivers.join(' · ')}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
|
||||
<TextField
|
||||
type="number" size="small" label="Pre-Market-Preis"
|
||||
placeholder={`z.B. ${rec.recommendedPerSqm}`}
|
||||
value={d.expectedRentPerSqm ?? ''}
|
||||
onChange={e => setD(p => ({ ...p, expectedRentPerSqm: parseInt(e.target.value) || undefined }))}
|
||||
onBlur={e => save({ expectedRentPerSqm: parseInt(e.target.value) || undefined })}
|
||||
slotProps={{ inputLabel: { shrink: true }, htmlInput: { min: 0, step: 10 } }}
|
||||
sx={{ width: 150, '& .MuiInputBase-input': { fontSize: '0.72rem', py: 0.5 } }}
|
||||
/>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => { setD(p => ({ ...p, expectedRentPerSqm: rec.recommendedPerSqm })); save({ expectedRentPerSqm: rec.recommendedPerSqm }) }}
|
||||
sx={{ textTransform: 'none', fontSize: '0.68rem', color: DS_PRE_MARKET.accent }}
|
||||
>
|
||||
Empfehlung übernehmen
|
||||
</Button>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -1,8 +1,13 @@
|
||||
import { Box, CircularProgress, Switch, TextField, Tooltip, Typography } from '@mui/material'
|
||||
import { EyeOff } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { Box, CircularProgress, Collapse, IconButton, Switch, TextField, Tooltip, Typography } from '@mui/material'
|
||||
import { EyeOff, Pencil } from 'lucide-react'
|
||||
import type { Property } from '../../domain/property'
|
||||
import { DS_PRE_MARKET, DS_TEXT } from '../../lib/ds'
|
||||
import { DS_BORDER, DS_PRE_MARKET, DS_TEXT } from '../../lib/ds'
|
||||
import { FIT_OUT_LABELS } from '../../lib/constants'
|
||||
import { resolveUnitFacts } from '../../lib/unitFacts'
|
||||
import { floorLabel } from './PropertyDetailHelpers'
|
||||
import { UnitFieldsEditor } from './UnitFieldsEditor'
|
||||
import { PreMarketPriceAdvisor } from './PreMarketPriceAdvisor'
|
||||
|
||||
type PropertyUnit = NonNullable<Property['units']>[number]
|
||||
|
||||
@@ -13,6 +18,7 @@ export interface UnitReleaseState {
|
||||
}
|
||||
|
||||
interface Props {
|
||||
property: Property
|
||||
units: PropertyUnit[]
|
||||
unitStates: Record<string, UnitReleaseState>
|
||||
unitSaving: Record<string, boolean>
|
||||
@@ -20,7 +26,9 @@ interface Props {
|
||||
saveUnit: (unitId: string, enabled: boolean, availableFrom: string, anonymous: boolean) => Promise<void>
|
||||
}
|
||||
|
||||
export function PreMarketUnitGrid({ units, unitStates, unitSaving, setUnitStates, saveUnit }: Props) {
|
||||
export function PreMarketUnitGrid({ property, units, unitStates, unitSaving, setUnitStates, saveUnit }: Props) {
|
||||
const [editing, setEditing] = useState<string | null>(null)
|
||||
|
||||
return (
|
||||
<Box sx={{ mt: 1.25, pt: 1.25, borderTop: `1px solid ${DS_PRE_MARKET.border}` }}>
|
||||
<Typography variant="caption" sx={{ fontWeight: 700, color: '#4c1d95', textTransform: 'uppercase', letterSpacing: 0.5, fontSize: '0.63rem', display: 'block', mb: 0.875 }}>
|
||||
@@ -28,16 +36,18 @@ export function PreMarketUnitGrid({ units, unitStates, unitSaving, setUnitStates
|
||||
</Typography>
|
||||
{units.map(u => {
|
||||
const us = unitStates[u.id] ?? { enabled: false, availableFrom: '', anonymous: false }
|
||||
const f = resolveUnitFacts(property, u)
|
||||
const fitLabel = f.fitOut ? (FIT_OUT_LABELS[f.fitOut] ?? f.fitOut) : null
|
||||
const needsBuild = f.fitOut === 'SHELL' || f.fitOut === 'BASIC'
|
||||
const detailParts: string[] = []
|
||||
if (fitLabel) detailParts.push(fitLabel)
|
||||
if (needsBuild) detailParts.push(`Träger: ${f.fitOutByLandlord ? 'Vermieter' : 'Mieter'}`)
|
||||
if (f.parkingSpots != null) detailParts.push(`${f.parkingSpots} PP`)
|
||||
detailParts.push(u.expectedRentPerSqm ? `erwartet CHF ${u.expectedRentPerSqm}/m²` : `CHF ${f.rentPricePerSqm}/m²`)
|
||||
|
||||
return (
|
||||
<Box
|
||||
key={u.id}
|
||||
sx={{
|
||||
display: 'grid', gridTemplateColumns: '1fr 140px auto',
|
||||
gap: 1, alignItems: 'start', py: 0.875,
|
||||
borderBottom: '1px solid #f3e8ff',
|
||||
'&:last-child': { borderBottom: 'none' },
|
||||
}}
|
||||
>
|
||||
<Box key={u.id} sx={{ borderBottom: '1px solid #f3e8ff', '&:last-child': { borderBottom: 'none' }, py: 0.875 }}>
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 140px auto', gap: 1, alignItems: 'start' }}>
|
||||
{/* Unit info + anonym toggle (shown when enabled) */}
|
||||
<Box>
|
||||
<Typography sx={{ fontSize: '0.72rem', fontWeight: 600, color: DS_TEXT.primary }}>
|
||||
@@ -105,9 +115,16 @@ export function PreMarketUnitGrid({ units, unitStates, unitSaving, setUnitStates
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Enabled toggle */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mt: 0.25 }}>
|
||||
{/* Edit (nur vor Freigabe — danach inline) + enabled toggle */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.25, mt: 0.25 }}>
|
||||
{unitSaving[u.id] && <CircularProgress size={10} sx={{ color: DS_PRE_MARKET.accent }} />}
|
||||
{!us.enabled && (
|
||||
<Tooltip title="Ausbau, Preis & Parkplätze dieser Einheit bearbeiten">
|
||||
<IconButton size="small" onClick={() => setEditing(editing === u.id ? null : u.id)} sx={{ p: 0.25, color: editing === u.id ? DS_PRE_MARKET.accent : DS_TEXT.disabled }}>
|
||||
<Pencil size={12} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Switch
|
||||
size="small"
|
||||
checked={us.enabled}
|
||||
@@ -123,6 +140,23 @@ export function PreMarketUnitGrid({ units, unitStates, unitSaving, setUnitStates
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Vor Freigabe: Lese-Details + Stift-Editor. Nach Freigabe: volle Inline-Konfiguration. */}
|
||||
{us.enabled ? (
|
||||
<PreMarketPriceAdvisor property={property} unit={u} />
|
||||
) : (
|
||||
<>
|
||||
<Typography variant="caption" sx={{ color: DS_TEXT.muted, fontSize: '0.62rem', display: 'block', mt: 0.25 }}>
|
||||
{detailParts.join(' · ')}
|
||||
</Typography>
|
||||
<Collapse in={editing === u.id}>
|
||||
<Box sx={{ border: `1px solid ${DS_BORDER.default}`, borderRadius: 1, mt: 0.75, overflow: 'hidden' }}>
|
||||
<UnitFieldsEditor property={property} unit={u} onClose={() => setEditing(null)} />
|
||||
</Box>
|
||||
</Collapse>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
})}
|
||||
</Box>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Box, Button, Divider, LinearProgress, MenuItem, TextField, Typography } from '@mui/material'
|
||||
import { Box, Button, Divider, LinearProgress, MenuItem, TextField, ToggleButton, ToggleButtonGroup, Typography } from '@mui/material'
|
||||
import { ExternalLink, FileText } from 'lucide-react'
|
||||
import type { Property, UpdatePropertyInput } from '../../domain/property'
|
||||
import { PropertyMap } from '../shared'
|
||||
@@ -6,6 +6,11 @@ import { getAssetTypeLabel, qualityColor } from './propertyHelpers'
|
||||
import { Field, FieldGrid, SectionTitle } from './PropertyDetailHelpers'
|
||||
import { UnitStructurePanel } from './UnitStructurePanel'
|
||||
import { PreMarketPanel } from './PreMarketPanel'
|
||||
import { FIT_OUT_LABELS } from '../../lib/constants'
|
||||
import { FIT_OUT_OPTIONS } from '../../pages/supply/newListingConstants'
|
||||
|
||||
// Stufen, die noch Mieterausbau benötigen — nur dann ist die Träger-Frage relevant
|
||||
const FIT_OUT_NEEDS_BUILD = new Set(['SHELL', 'BASIC'])
|
||||
|
||||
interface PropertyDetailOverviewProps {
|
||||
p: Property
|
||||
@@ -15,16 +20,38 @@ interface PropertyDetailOverviewProps {
|
||||
}
|
||||
|
||||
export function PropertyDetailOverview({ p, editing, draft, onDraftChange }: PropertyDetailOverviewProps) {
|
||||
const rentLabel = p.rentPricePerSqm > 0 ? `CHF ${p.rentPricePerSqm.toLocaleString('de-CH')}` : undefined
|
||||
// Mehr-Einheiten-Objekt: Preis als Spanne, Fläche mit Anzahl, Verfügbarkeit pro Einheit
|
||||
const units = p.units ?? []
|
||||
const isMultiUnit = units.length > 1
|
||||
const unitPrices = units.map(u => u.rentPricePerSqm ?? p.rentPricePerSqm).filter(v => v > 0)
|
||||
const priceMin = unitPrices.length ? Math.min(...unitPrices) : p.rentPricePerSqm
|
||||
const priceMax = unitPrices.length ? Math.max(...unitPrices) : p.rentPricePerSqm
|
||||
const rentLabel = isMultiUnit && priceMin !== priceMax
|
||||
? `CHF ${priceMin.toLocaleString('de-CH')}–${priceMax.toLocaleString('de-CH')}`
|
||||
: p.rentPricePerSqm > 0 ? `CHF ${p.rentPricePerSqm.toLocaleString('de-CH')}` : undefined
|
||||
const areaLabel = isMultiUnit
|
||||
? `${p.areaSqm.toLocaleString('de-CH')} m² · ${units.length} Einheiten`
|
||||
: `${p.areaSqm.toLocaleString('de-CH')} m²`
|
||||
const availLabel = isMultiUnit
|
||||
? 'pro Einheit ↓'
|
||||
: p.availabilityDate ? new Date(p.availabilityDate).toLocaleDateString('de-CH') : undefined
|
||||
|
||||
// Effektive (Draft-überlagerte) hardFacts für den Bearbeiten-Modus
|
||||
const hf = draft.hardFacts ?? p.hardFacts ?? {}
|
||||
const editFitOut = hf.fitOut ?? ''
|
||||
const editByLandlord = hf.fitOutByLandlord
|
||||
const showBuildResponsibility = FIT_OUT_NEEDS_BUILD.has(editFitOut)
|
||||
const patchHF = (patch: Partial<NonNullable<Property['hardFacts']>>) =>
|
||||
onDraftChange({ ...draft, hardFacts: { ...hf, ...patch } })
|
||||
|
||||
return (
|
||||
<Box>
|
||||
{/* Key metrics */}
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 1.5, mb: 0.75 }}>
|
||||
{[
|
||||
{ label: 'Fläche', value: `${p.areaSqm.toLocaleString('de-CH')} m²` },
|
||||
{ label: 'CHF/m²/Jahr', value: rentLabel },
|
||||
{ label: 'Verfügbar ab', value: p.availabilityDate ? new Date(p.availabilityDate).toLocaleDateString('de-CH') : undefined },
|
||||
{ label: 'Fläche', value: areaLabel },
|
||||
{ label: isMultiUnit ? 'CHF/m²/Jahr (Spanne)' : 'CHF/m²/Jahr', value: rentLabel },
|
||||
{ label: 'Verfügbar ab', value: availLabel },
|
||||
].map(({ label, value }) => (
|
||||
<Box key={label} sx={{ p: 1.5, border: '1px solid', borderColor: 'divider', borderRadius: 1 }}>
|
||||
<Typography variant="caption" color="text.secondary">{label}</Typography>
|
||||
@@ -132,54 +159,78 @@ export function PropertyDetailOverview({ p, editing, draft, onDraftChange }: Pro
|
||||
{/* Object details */}
|
||||
<SectionTitle title="Objekt & Lage" />
|
||||
{editing ? (
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 1.5, mb: 2 }}>
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 1.5 }}>
|
||||
<TextField
|
||||
select label="Ausbaustandard"
|
||||
size="small" fullWidth
|
||||
value={draft.hardFacts?.fitOut ?? p.hardFacts?.fitOut ?? ''}
|
||||
onChange={e => onDraftChange({ ...draft, hardFacts: { ...(draft.hardFacts ?? p.hardFacts ?? {}), fitOut: (e.target.value || undefined) as 'SHELL' | 'BASIC' | 'FULL' | 'PREMIUM' | undefined } })}
|
||||
value={editFitOut}
|
||||
onChange={e => patchHF({ fitOut: (e.target.value || undefined) as 'SHELL' | 'BASIC' | 'FULL' | 'PREMIUM' | undefined })}
|
||||
>
|
||||
{[
|
||||
{ value: '', label: 'Keine Angabe' },
|
||||
{ value: 'SHELL', label: 'Rohbau' },
|
||||
{ value: 'BASIC', label: 'Basisausbau' },
|
||||
{ value: 'FULL', label: 'Vollausbau' },
|
||||
{ value: 'PREMIUM', label: 'Premiumausbau' },
|
||||
].map(o => <MenuItem key={o.value} value={o.value}>{o.label}</MenuItem>)}
|
||||
{FIT_OUT_OPTIONS.map(o => <MenuItem key={o.value} value={o.value}>{o.label}</MenuItem>)}
|
||||
</TextField>
|
||||
<TextField
|
||||
label="Mieterausbaubeitrag (CHF/m²)"
|
||||
size="small" fullWidth type="number"
|
||||
slotProps={{ htmlInput: { min: 0, max: 1000, step: 25 } }}
|
||||
helperText="Beitrag des Vermieters zum Ausbau"
|
||||
value={draft.hardFacts?.mieterausbaubeitragPerSqm ?? p.hardFacts?.mieterausbaubeitragPerSqm ?? ''}
|
||||
onChange={e => onDraftChange({ ...draft, hardFacts: { ...(draft.hardFacts ?? p.hardFacts ?? {}), mieterausbaubeitragPerSqm: parseInt(e.target.value) || undefined } })}
|
||||
/>
|
||||
<TextField
|
||||
label="Parkplätze"
|
||||
size="small" fullWidth type="number"
|
||||
slotProps={{ htmlInput: { min: 0 } }}
|
||||
value={draft.hardFacts?.parking ?? p.hardFacts?.parking ?? ''}
|
||||
onChange={e => onDraftChange({ ...draft, hardFacts: { ...(draft.hardFacts ?? p.hardFacts ?? {}), parking: parseInt(e.target.value) || undefined } })}
|
||||
value={hf.parking ?? ''}
|
||||
onChange={e => patchHF({ parking: parseInt(e.target.value) || undefined })}
|
||||
/>
|
||||
<TextField
|
||||
label="Deckenhöhe (m)"
|
||||
size="small" fullWidth type="number"
|
||||
slotProps={{ htmlInput: { step: 0.1, min: 2 } }}
|
||||
value={draft.hardFacts?.ceilingHeightM ?? p.hardFacts?.ceilingHeightM ?? ''}
|
||||
onChange={e => onDraftChange({ ...draft, hardFacts: { ...(draft.hardFacts ?? p.hardFacts ?? {}), ceilingHeightM: parseFloat(e.target.value) || undefined } })}
|
||||
value={hf.ceilingHeightM ?? ''}
|
||||
onChange={e => patchHF({ ceilingHeightM: parseFloat(e.target.value) || undefined })}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{showBuildResponsibility && (
|
||||
<Box sx={{ mt: 1.5 }}>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.5 }}>
|
||||
Wer baut aus? <Box component="span" sx={{ color: '#dc2626' }}>*</Box>
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 1.5, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<ToggleButtonGroup
|
||||
exclusive size="small"
|
||||
value={editByLandlord === undefined ? null : editByLandlord ? 'landlord' : 'tenant'}
|
||||
onChange={(_, v) => { if (v) patchHF({ fitOutByLandlord: v === 'landlord', ...(v === 'landlord' ? { mieterausbaubeitragPerSqm: undefined } : {}) }) }}
|
||||
>
|
||||
<ToggleButton value="landlord" sx={{ textTransform: 'none' }}>Vermieter übernimmt</ToggleButton>
|
||||
<ToggleButton value="tenant" sx={{ textTransform: 'none' }}>Mieter baut aus</ToggleButton>
|
||||
</ToggleButtonGroup>
|
||||
{editByLandlord === undefined && (
|
||||
<Typography variant="caption" sx={{ color: '#b45309' }}>
|
||||
Pflichtangabe bei Rohbau/Edelrohbau
|
||||
</Typography>
|
||||
)}
|
||||
{editByLandlord === false && (
|
||||
<TextField
|
||||
label="Mieterausbaubeitrag (CHF/m²)"
|
||||
size="small" type="number" sx={{ width: 240 }}
|
||||
slotProps={{ htmlInput: { min: 0, max: 1000, step: 25 } }}
|
||||
helperText="Beitrag des Vermieters — optional"
|
||||
value={hf.mieterausbaubeitragPerSqm ?? ''}
|
||||
onChange={e => patchHF({ mieterausbaubeitragPerSqm: parseInt(e.target.value) || undefined })}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
) : (
|
||||
<FieldGrid>
|
||||
{p.propertyNumber && <Field label="Objekt-Nr." value={p.propertyNumber} />}
|
||||
<Field label="Objekttyp" value={getAssetTypeLabel(p.assetType)} />
|
||||
<Field label="Etage" value={p.hardFacts?.floor ?? p.floorLevel} />
|
||||
<Field label="Ausbaustandard" value={p.hardFacts?.fitOut} />
|
||||
{p.hardFacts?.mieterausbaubeitragPerSqm && (
|
||||
<Field label={isMultiUnit ? 'Ausbaustandard (Objekt-Standard)' : 'Ausbaustandard'} value={p.hardFacts?.fitOut ? (FIT_OUT_LABELS[p.hardFacts.fitOut] ?? p.hardFacts.fitOut) : undefined} />
|
||||
{p.hardFacts?.fitOut && FIT_OUT_NEEDS_BUILD.has(p.hardFacts.fitOut) && (
|
||||
<Field label="Ausbau-Träger" value={p.hardFacts.fitOutByLandlord ? 'Vermieter (im Mietzins)' : 'Mieter'} />
|
||||
)}
|
||||
{p.hardFacts?.mieterausbaubeitragPerSqm && !p.hardFacts.fitOutByLandlord && (
|
||||
<Field label="Mieterausbaubeitrag" value={`CHF ${p.hardFacts.mieterausbaubeitragPerSqm}/m²`} />
|
||||
)}
|
||||
<Field label="Parkplätze" value={p.hardFacts?.parking ?? p.softFactors?.parkingSpots} />
|
||||
<Field label={isMultiUnit ? 'Parkplätze (Objekt-Pool)' : 'Parkplätze'} value={p.hardFacts?.parking ?? p.softFactors?.parkingSpots} />
|
||||
<Field label="ÖV (Min.)" value={p.softFactors?.publicTransportMinutes} />
|
||||
<Field label="Nebenkosten/m²" value={p.ancillaryCosts ? `CHF ${p.ancillaryCosts}` : undefined} />
|
||||
<Field label="Importiert aus" value={p.importedFrom} />
|
||||
|
||||
@@ -51,6 +51,13 @@ export function PropertyDetailView({ propertyId, onClose, hideTabs = [] }: Prope
|
||||
|
||||
function saveEdit() {
|
||||
if (!property) return
|
||||
// Träger-Pflicht bei Rohbau/Edelrohbau (SHELL/BASIC)
|
||||
const effFitOut = draft.hardFacts?.fitOut ?? property.hardFacts?.fitOut
|
||||
const effByLandlord = draft.hardFacts?.fitOutByLandlord ?? property.hardFacts?.fitOutByLandlord
|
||||
if ((effFitOut === 'SHELL' || effFitOut === 'BASIC') && effByLandlord === undefined) {
|
||||
showToast('Bitte wählen, wer den Ausbau trägt (Vermieter oder Mieter).', 'error')
|
||||
return
|
||||
}
|
||||
updateProperty.mutate(
|
||||
{ id: property.id, input: draft },
|
||||
{
|
||||
|
||||
@@ -43,19 +43,26 @@ export const PropertyIntelligenceCard = memo(function PropertyIntelligenceCard({
|
||||
<Box
|
||||
onClick={() => onSelect(p.id)}
|
||||
sx={{
|
||||
borderRadius: 1,
|
||||
borderRadius: 2,
|
||||
overflow: 'hidden',
|
||||
border: `1px solid ${DS_BORDER.default}`,
|
||||
bgcolor: '#ffffff',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
cursor: 'pointer',
|
||||
transition: 'border-color 0.15s',
|
||||
'&:hover': { borderColor: '#c8c7c4' },
|
||||
position: 'relative',
|
||||
zIndex: 0,
|
||||
transition: 'transform 0.18s ease, box-shadow 0.18s ease, border-color 0.15s',
|
||||
'&:hover': {
|
||||
borderColor: '#b0aead',
|
||||
transform: 'scale(1.025)',
|
||||
boxShadow: '0 10px 32px rgba(0,0,0,0.13)',
|
||||
zIndex: 1,
|
||||
},
|
||||
}}
|
||||
>
|
||||
{/* ── Image — clean, no text overlay ── */}
|
||||
<Box sx={{ position: 'relative', height: 150, flexShrink: 0, bgcolor: '#f4f3f0', overflow: 'hidden' }}>
|
||||
<Box sx={{ position: 'relative', aspectRatio: '15/8', flexShrink: 0, bgcolor: '#f4f3f0', overflow: 'hidden' }}>
|
||||
{hasImage ? (
|
||||
<Box component="img" src={p.images![0]} alt={p.title}
|
||||
sx={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }} />
|
||||
@@ -78,24 +85,24 @@ export const PropertyIntelligenceCard = memo(function PropertyIntelligenceCard({
|
||||
</Box>
|
||||
|
||||
{/* ── Body ── */}
|
||||
<Box sx={{ px: 1.75, pt: 1.5, pb: 1.75, display: 'flex', flexDirection: 'column', gap: 1.125, flex: 1 }}>
|
||||
<Box sx={{ px: 2.5, pt: 2.25, pb: 2.5, display: 'flex', flexDirection: 'column', gap: 2, flex: 1 }}>
|
||||
|
||||
{/* Title + location + status inline */}
|
||||
<Box>
|
||||
<Typography sx={{ fontWeight: 600, fontSize: '0.9375rem', color: DS_TEXT.primary, lineHeight: 1.3 }} noWrap>
|
||||
<Typography sx={{ fontWeight: 600, fontSize: '1.0625rem', color: DS_TEXT.primary, lineHeight: 1.3 }} noWrap>
|
||||
{p.title}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mt: 0.4 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mt: 0.6 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.4 }}>
|
||||
<MapPin size={11} color="#9a9a9a" />
|
||||
<Typography sx={{ fontSize: '0.75rem', color: DS_TEXT.muted }}>
|
||||
<MapPin size={12} color="#9a9a9a" />
|
||||
<Typography sx={{ fontSize: '0.8125rem', color: DS_TEXT.muted }}>
|
||||
{p.location.city}{p.location.district ? ` · ${p.location.district}` : ''}
|
||||
</Typography>
|
||||
</Box>
|
||||
{/* Status — plain dot + text, no pill container */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||
<Box sx={{ width: 6, height: 6, borderRadius: '50%', bgcolor: status.dot, flexShrink: 0 }} />
|
||||
<Typography sx={{ fontSize: '0.72rem', fontWeight: 500, color: status.text, lineHeight: 1 }}>
|
||||
<Box sx={{ width: 7, height: 7, borderRadius: '50%', bgcolor: status.dot, flexShrink: 0 }} />
|
||||
<Typography sx={{ fontSize: '0.8rem', fontWeight: 500, color: status.text, lineHeight: 1 }}>
|
||||
{status.label}
|
||||
{p.availabilityStatus === 'AVAILABLE_SOON' && availDate ? ` · ab ${availDate}` : ''}
|
||||
</Typography>
|
||||
@@ -104,62 +111,58 @@ export const PropertyIntelligenceCard = memo(function PropertyIntelligenceCard({
|
||||
</Box>
|
||||
|
||||
{/* 3 key metrics — no boxes, whitespace only */}
|
||||
<Box sx={{ display: 'flex', gap: 2 }}>
|
||||
<Box sx={{ display: 'flex', gap: 2.5 }}>
|
||||
<Box>
|
||||
<Typography sx={{ fontSize: '0.68rem', color: DS_TEXT.muted, mb: 0.25 }}>Fläche</Typography>
|
||||
<Typography sx={{ fontWeight: 600, fontSize: '0.875rem', color: DS_TEXT.primary }}>
|
||||
<Typography sx={{ fontSize: '0.75rem', color: DS_TEXT.muted, mb: 0.35 }}>Fläche</Typography>
|
||||
<Typography sx={{ fontWeight: 600, fontSize: '1rem', color: DS_TEXT.primary }}>
|
||||
{p.areaSqm.toLocaleString('de-CH')} m²
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Typography sx={{ fontSize: '0.68rem', color: DS_TEXT.muted, mb: 0.25 }}>Miete</Typography>
|
||||
<Typography sx={{ fontWeight: 600, fontSize: '0.875rem', color: DS_TEXT.primary }}>
|
||||
<Typography sx={{ fontSize: '0.75rem', color: DS_TEXT.muted, mb: 0.35 }}>Miete</Typography>
|
||||
<Typography sx={{ fontWeight: 600, fontSize: '1rem', color: DS_TEXT.primary }}>
|
||||
{p.rentPricePerSqm}
|
||||
<Typography component="span" sx={{ fontSize: '0.62rem', fontWeight: 400, color: DS_TEXT.muted }}> CHF/m²/J</Typography>
|
||||
<Typography component="span" sx={{ fontSize: '0.7rem', fontWeight: 400, color: DS_TEXT.muted }}> CHF/m²/J</Typography>
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Tooltip title={hasInquiries ? `${inquiryCount} aktive Anfragen` : 'Noch keine Anfragen'} placement="top" arrow>
|
||||
<Box>
|
||||
<Typography sx={{ fontSize: '0.68rem', color: DS_TEXT.muted, mb: 0.25 }}>Anfragen</Typography>
|
||||
<Typography sx={{ fontWeight: 600, fontSize: '0.875rem', color: hasInquiries ? '#a16207' : DS_TEXT.muted }}>
|
||||
<Typography sx={{ fontSize: '0.75rem', color: DS_TEXT.muted, mb: 0.35 }}>Anfragen</Typography>
|
||||
<Typography sx={{ fontWeight: 600, fontSize: '1rem', color: hasInquiries ? '#a16207' : DS_TEXT.muted }}>
|
||||
{inquiryCount ?? 0}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
|
||||
{/* Property spec chips */}
|
||||
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap' }}>
|
||||
{/* Chips + Datenvollständigkeit in einer Zeile */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1, mt: 'auto' }}>
|
||||
<Box sx={{ display: 'flex', gap: 0.75, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
{p.hardFacts?.fitOut && (() => {
|
||||
const meta = FIT_OUT_META[p.hardFacts!.fitOut!] ?? { label: p.hardFacts!.fitOut!, tip: '' }
|
||||
return (
|
||||
<Tooltip title={meta.tip} placement="top" arrow>
|
||||
<Chip label={meta.label} size="small"
|
||||
sx={{ height: 20, fontSize: '0.68rem', bgcolor: '#f4f3f0', color: DS_TEXT.secondary, border: `1px solid ${DS_BORDER.default}`, cursor: 'help' }} />
|
||||
sx={{ height: 22, fontSize: '0.73rem', bgcolor: '#f4f3f0', color: DS_TEXT.secondary, border: `1px solid ${DS_BORDER.default}`, cursor: 'help' }} />
|
||||
</Tooltip>
|
||||
)
|
||||
})()}
|
||||
{p.contractDurationMonths && (
|
||||
<Chip label={`${p.contractDurationMonths}M Vertrag`} size="small"
|
||||
sx={{ height: 20, fontSize: '0.68rem', bgcolor: '#f4f3f0', color: DS_TEXT.secondary, border: `1px solid ${DS_BORDER.default}` }} />
|
||||
sx={{ height: 22, fontSize: '0.73rem', bgcolor: '#f4f3f0', color: DS_TEXT.secondary, border: `1px solid ${DS_BORDER.default}` }} />
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Data completeness */}
|
||||
<Box sx={{ mt: 'auto' }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 0.5 }}>
|
||||
<Typography sx={{ fontSize: '0.62rem', color: DS_TEXT.disabled, letterSpacing: '0.03em' }}>
|
||||
Datenvollständigkeit
|
||||
</Typography>
|
||||
<Typography sx={{ fontSize: '0.75rem', fontWeight: 700, color: p.confidenceScore >= 0.75 ? '#b8975a' : DS_TEXT.muted }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, flexShrink: 0 }}>
|
||||
<Typography sx={{ fontSize: '0.68rem', color: DS_TEXT.disabled }}>Daten</Typography>
|
||||
<LinearProgress variant="determinate" value={confPct}
|
||||
sx={{ width: 44, height: 3, borderRadius: 1, bgcolor: '#f4f3f0',
|
||||
'& .MuiLinearProgress-bar': { bgcolor: '#152642', borderRadius: 1 } }} />
|
||||
<Typography sx={{ fontSize: '0.78rem', fontWeight: 700, color: p.confidenceScore >= 0.75 ? '#b8975a' : DS_TEXT.muted }}>
|
||||
{confPct}%
|
||||
</Typography>
|
||||
</Box>
|
||||
<LinearProgress variant="determinate" value={confPct}
|
||||
sx={{ height: 3, borderRadius: 1, bgcolor: '#f4f3f0',
|
||||
'& .MuiLinearProgress-bar': { bgcolor: '#152642', borderRadius: 1 } }} />
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
@@ -1,26 +1,185 @@
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
Drawer,
|
||||
Box,
|
||||
Typography,
|
||||
IconButton,
|
||||
Chip,
|
||||
Divider,
|
||||
TextField,
|
||||
Button,
|
||||
Switch,
|
||||
FormControlLabel,
|
||||
Tooltip,
|
||||
Drawer, Box, Typography, IconButton, Chip, Divider,
|
||||
TextField, Button, Select, MenuItem, FormControl, InputLabel, Autocomplete,
|
||||
} from '@mui/material'
|
||||
import { X, MapPin, Calendar, DollarSign, Eye, Activity, FileText, ExternalLink } from 'lucide-react'
|
||||
import { X, Calendar, Activity, FileText, ExternalLink, Eye } from 'lucide-react'
|
||||
import { useReminderStore } from '../../stores/reminderStore'
|
||||
import { useReminder, useCompleteReminder, useDismissReminder, useSnoozeReminder } from '../../hooks/useReminders'
|
||||
import { usePropertyById } from '../../hooks/useProperties'
|
||||
import {
|
||||
useReminder, useCompleteReminder, useDismissReminder,
|
||||
useSnoozeReminder, useCreateReminder,
|
||||
} from '../../hooks/useReminders'
|
||||
import { usePropertyById, useProperties } from '../../hooks/useProperties'
|
||||
import { ReminderPriorityBadge } from './ReminderPriorityBadge'
|
||||
import { ReminderTypeBadge } from './ReminderTypeBadge'
|
||||
import { ReminderDaysIndicator } from './ReminderDaysIndicator'
|
||||
import { ReminderStatus } from '../../domain/reminder'
|
||||
import { SHADOW_RISK_COLOR, SHADOW_RISK_LABEL, STATUS_CHIP_COLOR, STATUS_LABEL, SectionTitle, DateRow, ActivityEntry } from './reminderDetailHelpers'
|
||||
import { ReminderStatus, ReminderType, ReminderPriority } from '../../domain/reminder'
|
||||
import type { Property } from '../../domain/property'
|
||||
import {
|
||||
SHADOW_RISK_COLOR, SHADOW_RISK_LABEL,
|
||||
STATUS_CHIP_COLOR, STATUS_LABEL,
|
||||
SectionTitle, DateRow, ActivityEntry,
|
||||
} from './reminderDetailHelpers'
|
||||
|
||||
// ── Constants ────────────────────────────────────────────────────────────────
|
||||
|
||||
const TYPE_LABELS: Record<ReminderType, string> = {
|
||||
LEASE_EXPIRY: 'Mietablauf',
|
||||
BREAK_OPTION: 'Break-Option',
|
||||
RENT_REVIEW: 'Mietanpassung',
|
||||
INSPECTION: 'Inspektion',
|
||||
INSURANCE_RENEWAL: 'Versicherung',
|
||||
MAINTENANCE: 'Unterhalt',
|
||||
SCHATTENMARKT_RELEASE: 'Pre-Market',
|
||||
CUSTOM: 'Individuell',
|
||||
}
|
||||
|
||||
const TYPE_SECOND_DATE: Partial<Record<ReminderType, 'contractEndDate' | 'breakOptionDate' | 'eventDate'>> = {
|
||||
[ReminderType.LEASE_EXPIRY]: 'contractEndDate',
|
||||
[ReminderType.BREAK_OPTION]: 'breakOptionDate',
|
||||
[ReminderType.RENT_REVIEW]: 'eventDate',
|
||||
[ReminderType.SCHATTENMARKT_RELEASE]: 'eventDate',
|
||||
}
|
||||
|
||||
const TYPE_SECOND_LABEL: Partial<Record<ReminderType, string>> = {
|
||||
[ReminderType.LEASE_EXPIRY]: 'Vertragsende',
|
||||
[ReminderType.BREAK_OPTION]: 'Break-Option',
|
||||
[ReminderType.RENT_REVIEW]: 'Ereignisdatum',
|
||||
[ReminderType.SCHATTENMARKT_RELEASE]: 'Ereignisdatum',
|
||||
}
|
||||
|
||||
const MOCK_TODAY = new Date('2026-05-20')
|
||||
|
||||
function calcPriority(dueDateStr: string): ReminderPriority {
|
||||
const days = Math.ceil((new Date(dueDateStr).getTime() - MOCK_TODAY.getTime()) / (1000 * 60 * 60 * 24))
|
||||
if (days <= 14) return ReminderPriority.URGENT
|
||||
if (days <= 30) return ReminderPriority.HIGH
|
||||
if (days <= 60) return ReminderPriority.MEDIUM
|
||||
return ReminderPriority.LOW
|
||||
}
|
||||
|
||||
// ── Create Form ───────────────────────────────────────────────────────────────
|
||||
|
||||
function CreateForm({ onClose }: { onClose: () => void }) {
|
||||
const { data: properties = [] } = useProperties()
|
||||
const create = useCreateReminder()
|
||||
|
||||
const [type, setType] = useState<ReminderType>(ReminderType.LEASE_EXPIRY)
|
||||
const [selectedProperty, setSelectedProperty] = useState<Property | null>(null)
|
||||
const [tenantName, setTenantName] = useState('')
|
||||
const [dueDate, setDueDate] = useState('')
|
||||
const [note, setNote] = useState('')
|
||||
|
||||
function handlePropertyChange(_: unknown, prop: Property | null) {
|
||||
setSelectedProperty(prop)
|
||||
setTenantName(prop?.currentTenant ?? '')
|
||||
}
|
||||
|
||||
function handleSubmit() {
|
||||
if (!selectedProperty || !dueDate) return
|
||||
create.mutate(
|
||||
{
|
||||
type,
|
||||
priority: calcPriority(dueDate),
|
||||
status: ReminderStatus.ACTIVE,
|
||||
propertyId: selectedProperty.id,
|
||||
propertyTitle: selectedProperty.title,
|
||||
propertyCity: selectedProperty.location.city,
|
||||
propertyDistrict: selectedProperty.location.district,
|
||||
tenantName: tenantName || '—',
|
||||
dueDate,
|
||||
eventDate: dueDate,
|
||||
areaSqm: selectedProperty.areaSqm,
|
||||
currentRentPerSqm: selectedProperty.rentPricePerSqm,
|
||||
currency: 'CHF',
|
||||
shadowMarketRisk: 'NONE',
|
||||
schattenmarktEnabled: false,
|
||||
note: note || undefined,
|
||||
organizationId: selectedProperty.organizationId ?? 'org-1',
|
||||
},
|
||||
{ onSuccess: onClose },
|
||||
)
|
||||
}
|
||||
|
||||
const canSubmit = !!selectedProperty && !!dueDate && !create.isPending
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2.5 }}>
|
||||
{/* Typ */}
|
||||
<FormControl size="small" fullWidth>
|
||||
<InputLabel>Typ</InputLabel>
|
||||
<Select
|
||||
value={type}
|
||||
label="Typ"
|
||||
onChange={e => setType(e.target.value as ReminderType)}
|
||||
>
|
||||
{Object.values(ReminderType).map(t => (
|
||||
<MenuItem key={t} value={t}>{TYPE_LABELS[t]}</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
{/* Objekt */}
|
||||
<Autocomplete
|
||||
options={properties}
|
||||
getOptionLabel={p => p.title}
|
||||
value={selectedProperty}
|
||||
onChange={handlePropertyChange}
|
||||
size="small"
|
||||
renderInput={params => (
|
||||
<TextField {...params} label="Objekt" placeholder="Objekt auswählen…" />
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Mieter — nur wenn Objekt ausgewählt */}
|
||||
{selectedProperty && (
|
||||
<TextField
|
||||
label="Mieter"
|
||||
size="small"
|
||||
fullWidth
|
||||
value={tenantName}
|
||||
onChange={e => setTenantName(e.target.value)}
|
||||
placeholder="Mietername eingeben…"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Fälligkeitsdatum */}
|
||||
<TextField
|
||||
label="Fälligkeitsdatum"
|
||||
type="date"
|
||||
size="small"
|
||||
fullWidth
|
||||
value={dueDate}
|
||||
onChange={e => setDueDate(e.target.value)}
|
||||
slotProps={{ inputLabel: { shrink: true } }}
|
||||
/>
|
||||
|
||||
{/* Notiz */}
|
||||
<TextField
|
||||
label="Notiz (optional)"
|
||||
multiline
|
||||
rows={3}
|
||||
size="small"
|
||||
fullWidth
|
||||
value={note}
|
||||
onChange={e => setNote(e.target.value)}
|
||||
placeholder="Kontext oder Hinweise…"
|
||||
/>
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
fullWidth
|
||||
disabled={!canSubmit}
|
||||
onClick={handleSubmit}
|
||||
sx={{ textTransform: 'none', fontWeight: 600 }}
|
||||
>
|
||||
{create.isPending ? 'Wird erstellt…' : 'Reminder erstellen'}
|
||||
</Button>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Main Drawer ───────────────────────────────────────────────────────────────
|
||||
|
||||
export function ReminderDetailDrawer() {
|
||||
const { selectedId, drawerOpen, setDrawerOpen, setSelectedId } = useReminderStore()
|
||||
@@ -38,30 +197,35 @@ export function ReminderDetailDrawer() {
|
||||
setSelectedId(null)
|
||||
}
|
||||
|
||||
const isNew = !selectedId
|
||||
const isCreateMode = !selectedId
|
||||
|
||||
const secondDateKey = reminder ? TYPE_SECOND_DATE[reminder.type] : undefined
|
||||
const secondDateLabel = reminder ? TYPE_SECOND_LABEL[reminder.type] : undefined
|
||||
const secondDateValue = secondDateKey && reminder ? reminder[secondDateKey] : undefined
|
||||
const showSecondDate = secondDateValue && reminder && secondDateValue !== reminder.dueDate
|
||||
|
||||
const showPreMarket = reminder &&
|
||||
reminder.shadowMarketRisk !== 'NONE' &&
|
||||
reminder.shadowMarketRisk !== 'LOW'
|
||||
|
||||
const isActionable = reminder &&
|
||||
(reminder.status === ReminderStatus.ACTIVE || reminder.status === ReminderStatus.SNOOZED)
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
anchor="right"
|
||||
open={drawerOpen}
|
||||
onClose={handleClose}
|
||||
slotProps={{ paper: { sx: { width: 480, display: 'flex', flexDirection: 'column' } } }}
|
||||
>
|
||||
{/* Header */}
|
||||
<Box
|
||||
sx={{
|
||||
px: 2.5,
|
||||
py: 2,
|
||||
borderBottom: '1px solid #e2e8f0',
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
gap: 1,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
slotProps={{ paper: { sx: { width: 460, display: 'flex', flexDirection: 'column' } } }}
|
||||
>
|
||||
{/* ── Header ── */}
|
||||
<Box sx={{ px: 2.5, py: 2, borderBottom: '1px solid #e2e8f0', flexShrink: 0 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1 }}>
|
||||
<Box sx={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 1, minWidth: 0 }}>
|
||||
{isNew ? (
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>Neuer Reminder</Typography>
|
||||
{isCreateMode ? (
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700, color: '#0f172a' }}>
|
||||
Neuer Reminder
|
||||
</Typography>
|
||||
) : reminder ? (
|
||||
<>
|
||||
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
@@ -78,34 +242,34 @@ export function ReminderDetailDrawer() {
|
||||
</>
|
||||
) : null}
|
||||
</Box>
|
||||
<IconButton size="small" onClick={handleClose} sx={{ flexShrink: 0 }}>
|
||||
<IconButton size="small" onClick={handleClose} sx={{ flexShrink: 0, color: '#94a3b8' }}>
|
||||
<X size={18} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Scrollable body */}
|
||||
<Box sx={{ flex: 1, overflowY: 'auto', p: 2.5 }} className="flex flex-col gap-5">
|
||||
{isNew && (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Neue Reminder-Erstellung noch nicht implementiert.
|
||||
</Typography>
|
||||
)}
|
||||
{/* ── Body ── */}
|
||||
<Box sx={{ flex: 1, overflowY: 'auto', p: 2.5, display: 'flex', flexDirection: 'column' }}>
|
||||
|
||||
{/* Create mode */}
|
||||
{isCreateMode && <CreateForm onClose={handleClose} />}
|
||||
|
||||
{/* Detail mode */}
|
||||
{reminder && (
|
||||
<>
|
||||
{/* 2. Property */}
|
||||
<Box>
|
||||
<SectionTitle icon={<MapPin size={15} color="#64748b" />}>Objekt</SectionTitle>
|
||||
<Box className="flex flex-col gap-1">
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>{reminder.propertyTitle}</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{reminder.propertyCity}{reminder.propertyDistrict ? ` · ${reminder.propertyDistrict}` : ''}
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0 }}>
|
||||
{/* 1. Objekt */}
|
||||
<Box sx={{ mb: 2.5 }}>
|
||||
<Typography variant="body1" sx={{ fontWeight: 700, lineHeight: 1.3, mb: 0.25 }}>
|
||||
{reminder.propertyTitle}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">Mieter: {reminder.tenantName}</Typography>
|
||||
<Typography variant="caption" color="text.secondary">Fläche: {reminder.areaSqm.toLocaleString('de-CH')} m²</Typography>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 1 }}>
|
||||
{reminder.propertyCity}{reminder.propertyDistrict ? ` · ${reminder.propertyDistrict}` : ''} · {reminder.tenantName}
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, flexWrap: 'wrap' }}>
|
||||
{reminderProperty?.leaseContractUrl ? (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mt: 0.5 }}>
|
||||
<FileText size={12} color="#152642" />
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||
<FileText size={12} color="#64748b" />
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
@@ -113,94 +277,58 @@ export function ReminderDetailDrawer() {
|
||||
href={reminderProperty.leaseContractUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
sx={{ textTransform: 'none', fontSize: '0.68rem', py: 0.125, px: 0.75, borderColor: '#cbd5e1', color: '#152642' }}
|
||||
sx={{
|
||||
textTransform: 'none', fontSize: '0.68rem',
|
||||
py: 0.125, px: 0.75, borderColor: '#cbd5e1', color: '#334155',
|
||||
}}
|
||||
>
|
||||
{reminderProperty.leaseContractName ?? 'Mietvertrag'}
|
||||
</Button>
|
||||
</Box>
|
||||
) : (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mt: 0.5 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||
<FileText size={12} color="#cbd5e1" />
|
||||
<Typography variant="caption" sx={{ color: '#cbd5e1' }}>Kein Mietvertrag hinterlegt</Typography>
|
||||
<Typography variant="caption" sx={{ color: '#cbd5e1' }}>Kein Vertrag</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{showPreMarket && (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.4 }}>
|
||||
<Eye size={11} color={SHADOW_RISK_COLOR[reminder.shadowMarketRisk]} />
|
||||
<Typography variant="caption" sx={{
|
||||
color: SHADOW_RISK_COLOR[reminder.shadowMarketRisk],
|
||||
fontWeight: 600, fontSize: '0.68rem',
|
||||
}}>
|
||||
Pre-Mkt: {SHADOW_RISK_LABEL[reminder.shadowMarketRisk]}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Divider />
|
||||
<Divider sx={{ mb: 2.5 }} />
|
||||
|
||||
{/* 3. Dates */}
|
||||
<Box>
|
||||
<SectionTitle icon={<Calendar size={15} color="#64748b" />}>Daten & Fristen</SectionTitle>
|
||||
<Box className="flex flex-col gap-1.5">
|
||||
<DateRow label="Fälligkeitsdatum" value={reminder.dueDate} />
|
||||
<DateRow label="Ereignisdatum" value={reminder.eventDate} />
|
||||
<DateRow label="Vertragsende" value={reminder.contractEndDate} />
|
||||
<DateRow label="Break-Option" value={reminder.breakOptionDate} />
|
||||
{/* 2. Fristen */}
|
||||
<Box sx={{ mb: 2.5 }}>
|
||||
<SectionTitle icon={<Calendar size={14} color="#64748b" />}>Fristen</SectionTitle>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
<DateRow label="Fälligkeit" value={reminder.dueDate} />
|
||||
{showSecondDate && (
|
||||
<DateRow label={secondDateLabel!} value={secondDateValue} />
|
||||
)}
|
||||
{reminder.snoozedUntil && (
|
||||
<DateRow label="Schlummern bis" value={reminder.snoozedUntil} />
|
||||
<DateRow label="Schlummert bis" value={reminder.snoozedUntil} />
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Divider />
|
||||
<Divider sx={{ mb: 2.5 }} />
|
||||
|
||||
{/* 4. Financials */}
|
||||
<Box>
|
||||
<SectionTitle icon={<DollarSign size={15} color="#64748b" />}>Finanzen</SectionTitle>
|
||||
<Box className="flex flex-col gap-1.5">
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: '120px 1fr', gap: 1 }}>
|
||||
<Typography variant="caption" color="text.secondary">Miete/m²</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 500 }}>
|
||||
{reminder.currency} {reminder.currentRentPerSqm.toLocaleString('de-CH')} / Monat
|
||||
{/* 3. Notiz */}
|
||||
<Box sx={{ mb: isActionable ? 0 : 2.5 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 1, fontSize: '0.8125rem' }}>
|
||||
Notiz
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: '120px 1fr', gap: 1 }}>
|
||||
<Typography variant="caption" color="text.secondary">Total / Monat</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 500 }}>
|
||||
{reminder.currency} {(reminder.currentRentPerSqm * reminder.areaSqm).toLocaleString('de-CH')}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* 5. Pre-Market */}
|
||||
<Box>
|
||||
<SectionTitle icon={<Eye size={15} color="#64748b" />}>Pre-Market Risiko</SectionTitle>
|
||||
<Box className="flex flex-col gap-2">
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Box
|
||||
sx={{
|
||||
width: 10,
|
||||
height: 10,
|
||||
borderRadius: '50%',
|
||||
bgcolor: SHADOW_RISK_COLOR[reminder.shadowMarketRisk],
|
||||
}}
|
||||
/>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, color: SHADOW_RISK_COLOR[reminder.shadowMarketRisk] }}>
|
||||
{SHADOW_RISK_LABEL[reminder.shadowMarketRisk]}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Tooltip title="Nur Anzeige — Änderung über Objektverwaltung">
|
||||
<FormControlLabel
|
||||
control={<Switch checked={reminder.schattenmarktEnabled} size="small" readOnly />}
|
||||
label={
|
||||
<Typography variant="caption">
|
||||
Pre-Market aktiviert
|
||||
</Typography>
|
||||
}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* 6. Note */}
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 1, fontSize: '0.8125rem' }}>Notiz</Typography>
|
||||
<TextField
|
||||
multiline
|
||||
rows={3}
|
||||
@@ -213,24 +341,24 @@ export function ReminderDetailDrawer() {
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Actions */}
|
||||
{(reminder.status === ReminderStatus.ACTIVE || reminder.status === ReminderStatus.SNOOZED) && (
|
||||
{/* 4. Aktionen */}
|
||||
{isActionable && (
|
||||
<>
|
||||
<Divider />
|
||||
<Box className="flex flex-col gap-2">
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, fontSize: '0.8125rem' }}>Aktionen</Typography>
|
||||
<Box className="flex gap-2">
|
||||
<Divider sx={{ my: 2.5 }} />
|
||||
<Box sx={{ mb: 2.5, display: 'flex', flexDirection: 'column', gap: 1.5 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, fontSize: '0.8125rem' }}>
|
||||
Aktionen
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 1.5 }}>
|
||||
<Button
|
||||
size="small"
|
||||
variant="contained"
|
||||
color="success"
|
||||
sx={{ textTransform: 'none', flex: 1 }}
|
||||
sx={{ textTransform: 'none', flex: 1, fontWeight: 600 }}
|
||||
onClick={() => complete.mutate({ id: reminder.id, note: noteValue || undefined })}
|
||||
>
|
||||
Erledigt
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
color="error"
|
||||
sx={{ textTransform: 'none', flex: 1 }}
|
||||
@@ -239,7 +367,7 @@ export function ReminderDetailDrawer() {
|
||||
Verwerfen
|
||||
</Button>
|
||||
</Box>
|
||||
<Box className="flex gap-2 items-center">
|
||||
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
|
||||
<TextField
|
||||
type="date"
|
||||
size="small"
|
||||
@@ -248,10 +376,10 @@ export function ReminderDetailDrawer() {
|
||||
sx={{ flex: 1, '& .MuiInputBase-root': { fontSize: '0.8125rem' } }}
|
||||
/>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
size="small"
|
||||
disabled={!snoozeDate}
|
||||
sx={{ textTransform: 'none', whiteSpace: 'nowrap' }}
|
||||
sx={{ textTransform: 'none', whiteSpace: 'nowrap', color: '#64748b', borderColor: '#e2e8f0' }}
|
||||
onClick={() => snoozeDate && snooze.mutate({ id: reminder.id, until: snoozeDate })}
|
||||
>
|
||||
Schlummern bis
|
||||
@@ -261,18 +389,18 @@ export function ReminderDetailDrawer() {
|
||||
</>
|
||||
)}
|
||||
|
||||
<Divider />
|
||||
<Divider sx={{ mb: 2.5 }} />
|
||||
|
||||
{/* 7. Activity */}
|
||||
{/* 5. Verlauf */}
|
||||
<Box>
|
||||
<SectionTitle icon={<Activity size={15} color="#64748b" />}>Aktivitätslog</SectionTitle>
|
||||
<SectionTitle icon={<Activity size={14} color="#64748b" />}>Verlauf</SectionTitle>
|
||||
<Box>
|
||||
{[...reminder.activity].reverse().map((entry, i) => (
|
||||
<ActivityEntry key={i} entry={entry} />
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
</>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Drawer>
|
||||
|
||||
@@ -28,7 +28,7 @@ const HORIZON_CONFIG: { key: FilterHorizon; label: string; color: string; bg: st
|
||||
{ key: 'LATER', label: 'Später', color: '#64748b', bg: '#f8fafc', border: '#e8e7e4' },
|
||||
]
|
||||
|
||||
const LIST_COLS = '100px 130px 1fr 140px 90px 100px 90px'
|
||||
const LIST_COLS = '90px 130px 1fr 140px 80px 90px'
|
||||
|
||||
function applyFilters(
|
||||
reminders: Reminder[],
|
||||
@@ -141,7 +141,7 @@ export function ReminderFeed() {
|
||||
borderBottom: '1px solid #e2e8f0',
|
||||
}}
|
||||
>
|
||||
{['Priorität', 'Typ', 'Objekt / Mieter', 'Fälligkeit', 'Fläche', 'Status', 'Aktionen'].map(h => (
|
||||
{['Priorität', 'Typ', 'Objekt / Mieter', 'Fälligkeit', 'Status', 'Aktionen'].map(h => (
|
||||
<Typography key={h} variant="caption" sx={{ fontWeight: 600, color: '#64748b', fontSize: '0.7rem' }}>
|
||||
{h}
|
||||
</Typography>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useReminderStore } from '../../stores/reminderStore'
|
||||
import type { FilterHorizon } from '../../stores/reminderStore'
|
||||
import { ReminderType } from '../../domain/reminder'
|
||||
|
||||
interface KpiCardProps {
|
||||
interface SecondaryKpiProps {
|
||||
icon: React.ReactNode
|
||||
label: string
|
||||
value: number | string
|
||||
@@ -15,36 +15,35 @@ interface KpiCardProps {
|
||||
onClick: () => void
|
||||
}
|
||||
|
||||
function KpiCard({ icon, label, value, color, active, onClick }: KpiCardProps) {
|
||||
function SecondaryKpi({ icon, label, value, color, active, onClick }: SecondaryKpiProps) {
|
||||
return (
|
||||
<Box
|
||||
onClick={onClick}
|
||||
sx={{
|
||||
flex: 1,
|
||||
px: 1.5,
|
||||
py: 1.125,
|
||||
px: 2,
|
||||
py: 1.5,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
border: active ? `1.5px solid ${color}` : '1px solid #e8e7e4',
|
||||
borderRadius: 1.5,
|
||||
bgcolor: active ? `${color}08` : 'white',
|
||||
flexDirection: 'column',
|
||||
gap: 0.25,
|
||||
cursor: 'pointer',
|
||||
borderRadius: 1.5,
|
||||
border: active ? `1.5px solid ${color}` : '1px solid #e8e7e4',
|
||||
bgcolor: active ? `${color}08` : 'white',
|
||||
transition: 'border-color 0.15s, background 0.15s',
|
||||
minWidth: 0,
|
||||
'&:hover': { bgcolor: `${color}06`, borderColor: color },
|
||||
}}
|
||||
>
|
||||
<Box sx={{ opacity: active ? 1 : 0.55, flexShrink: 0 }}>{icon}</Box>
|
||||
<Box sx={{ minWidth: 0 }}>
|
||||
<Typography sx={{ fontWeight: 700, color, lineHeight: 1.1, fontSize: '1.1rem' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
|
||||
<Box sx={{ opacity: active ? 1 : 0.5, flexShrink: 0 }}>{icon}</Box>
|
||||
<Typography sx={{ fontWeight: 700, color, fontSize: '1.25rem', lineHeight: 1 }}>
|
||||
{value}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: '#64748b', whiteSpace: 'nowrap', fontSize: '0.68rem' }}>
|
||||
</Box>
|
||||
<Typography variant="caption" sx={{ color: '#94a3b8', fontSize: '0.7rem', whiteSpace: 'nowrap' }}>
|
||||
{label}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -60,63 +59,105 @@ export function ReminderKpiBar() {
|
||||
)
|
||||
|
||||
function toggleHorizon(h: FilterHorizon) {
|
||||
if (filterHorizon === h) {
|
||||
setFilterHorizon('ALL')
|
||||
} else {
|
||||
setFilterHorizon(h)
|
||||
setFilterType('ALL') // clear type filter so count matches
|
||||
}
|
||||
if (filterHorizon === h) setFilterHorizon('ALL')
|
||||
else { setFilterHorizon(h); setFilterType('ALL') }
|
||||
}
|
||||
|
||||
function togglePreMarket() {
|
||||
if (filterType === ReminderType.SCHATTENMARKT_RELEASE) {
|
||||
setFilterType('ALL')
|
||||
} else {
|
||||
setFilterType(ReminderType.SCHATTENMARKT_RELEASE)
|
||||
setFilterHorizon('ALL') // clear horizon filter so count matches
|
||||
}
|
||||
if (filterType === ReminderType.SCHATTENMARKT_RELEASE) setFilterType('ALL')
|
||||
else { setFilterType(ReminderType.SCHATTENMARKT_RELEASE); setFilterHorizon('ALL') }
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Box className="flex gap-3">
|
||||
{[1, 2, 3, 4].map(i => (
|
||||
<Skeleton key={i} variant="rounded" height={68} sx={{ flex: 1 }} />
|
||||
))}
|
||||
<Box sx={{ display: 'flex', gap: 2 }}>
|
||||
<Skeleton variant="rounded" height={76} sx={{ flex: '0 0 220px' }} />
|
||||
<Box sx={{ flex: 1, display: 'flex', gap: 2 }}>
|
||||
{[1, 2, 3].map(i => <Skeleton key={i} variant="rounded" height={76} sx={{ flex: 1 }} />)}
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
const ins = data ?? { overdueCount: 0, dueThisWeek: 0, dueThisMonth: 0, schattenmarktReadyCount: 0 }
|
||||
const isOverdue = ins.overdueCount > 0
|
||||
const overdueActive = filterHorizon === 'OVERDUE'
|
||||
|
||||
return (
|
||||
<Box className="flex gap-3">
|
||||
<KpiCard
|
||||
icon={<AlertOctagon size={18} color="#dc2626" />}
|
||||
label="Überfällig"
|
||||
value={ins.overdueCount}
|
||||
color="#dc2626"
|
||||
active={filterHorizon === 'OVERDUE'}
|
||||
<Box sx={{ display: 'flex', gap: 2, alignItems: 'stretch' }}>
|
||||
{/* Focal card — Überfällig */}
|
||||
<Box
|
||||
onClick={() => toggleHorizon('OVERDUE')}
|
||||
/>
|
||||
<KpiCard
|
||||
icon={<Calendar size={18} color="#ea580c" />}
|
||||
sx={{
|
||||
flex: '0 0 200px',
|
||||
px: 2.5,
|
||||
py: 2,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
gap: 0.5,
|
||||
borderRadius: 1.5,
|
||||
cursor: 'pointer',
|
||||
border: overdueActive
|
||||
? '1.5px solid #dc2626'
|
||||
: isOverdue
|
||||
? '1.5px solid #fca5a5'
|
||||
: '1px solid #e8e7e4',
|
||||
bgcolor: overdueActive
|
||||
? '#fef2f2'
|
||||
: isOverdue
|
||||
? '#fff5f5'
|
||||
: 'white',
|
||||
transition: 'border-color 0.15s, background 0.15s',
|
||||
'&:hover': { borderColor: '#dc2626', bgcolor: '#fef2f2' },
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<AlertOctagon size={16} color={isOverdue ? '#dc2626' : '#cbd5e1'} />
|
||||
<Typography
|
||||
sx={{
|
||||
fontWeight: 800,
|
||||
fontSize: '2rem',
|
||||
lineHeight: 1,
|
||||
color: isOverdue ? '#dc2626' : '#94a3b8',
|
||||
letterSpacing: '-0.02em',
|
||||
}}
|
||||
>
|
||||
{ins.overdueCount}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
color: isOverdue ? '#dc2626' : '#94a3b8',
|
||||
fontWeight: isOverdue ? 600 : 400,
|
||||
fontSize: '0.75rem',
|
||||
}}
|
||||
>
|
||||
Überfällig
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Secondary KPIs */}
|
||||
<Box sx={{ flex: 1, display: 'flex', gap: 2 }}>
|
||||
<SecondaryKpi
|
||||
icon={<Calendar size={14} color="#ea580c" />}
|
||||
label="Diese Woche"
|
||||
value={ins.dueThisWeek}
|
||||
color="#ea580c"
|
||||
active={filterHorizon === 'THIS_WEEK'}
|
||||
onClick={() => toggleHorizon('THIS_WEEK')}
|
||||
/>
|
||||
<KpiCard
|
||||
icon={<CalendarDays size={18} color="#0369a1" />}
|
||||
<SecondaryKpi
|
||||
icon={<CalendarDays size={14} color="#0369a1" />}
|
||||
label="Dieser Monat"
|
||||
value={ins.dueThisMonth}
|
||||
color="#0369a1"
|
||||
active={filterHorizon === 'THIS_MONTH'}
|
||||
onClick={() => toggleHorizon('THIS_MONTH')}
|
||||
/>
|
||||
<KpiCard
|
||||
icon={<Eye size={18} color="#be185d" />}
|
||||
<SecondaryKpi
|
||||
icon={<Eye size={14} color="#be185d" />}
|
||||
label="Pre-Market Risiko"
|
||||
value={ins.schattenmarktReadyCount}
|
||||
color="#be185d"
|
||||
@@ -124,5 +165,6 @@ export function ReminderKpiBar() {
|
||||
onClick={togglePreMarket}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -70,10 +70,10 @@ export const ReminderListRow = memo(function ReminderListRow({ reminder }: Props
|
||||
onClick={handleRowClick}
|
||||
sx={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '100px 130px 1fr 140px 90px 100px 90px',
|
||||
gridTemplateColumns: '90px 130px 1fr 140px 80px 90px',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
pl: 1.5,
|
||||
pl: 2,
|
||||
pr: 2,
|
||||
py: 1.25,
|
||||
borderBottom: '1px solid #f1f5f9',
|
||||
@@ -84,8 +84,8 @@ export const ReminderListRow = memo(function ReminderListRow({ reminder }: Props
|
||||
transition: 'background-color 0.1s',
|
||||
}}
|
||||
>
|
||||
{/* Priority badge */}
|
||||
<Box>
|
||||
{/* Priority dot */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
||||
<ReminderPriorityBadge priority={reminder.priority} />
|
||||
</Box>
|
||||
|
||||
@@ -112,13 +112,9 @@ export const ReminderListRow = memo(function ReminderListRow({ reminder }: Props
|
||||
<ReminderDaysIndicator dueDate={reminder.dueDate} />
|
||||
</Box>
|
||||
|
||||
{/* Area */}
|
||||
<Typography variant="caption" color="text.secondary" sx={{ whiteSpace: 'nowrap' }}>
|
||||
{reminder.areaSqm.toLocaleString('de-CH')} m²
|
||||
</Typography>
|
||||
|
||||
{/* Status chip */}
|
||||
{/* Status — only shown for non-default states */}
|
||||
<Box>
|
||||
{reminder.status !== ReminderStatus.ACTIVE && (
|
||||
<Chip
|
||||
label={STATUS_LABEL[reminder.status]}
|
||||
size="small"
|
||||
@@ -131,6 +127,7 @@ export const ReminderListRow = memo(function ReminderListRow({ reminder }: Props
|
||||
border: `1px solid ${STATUS_STYLE[reminder.status]?.border ?? '#e2e8f0'}`,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Actions */}
|
||||
|
||||
@@ -5,7 +5,7 @@ const CONFIG: Record<ReminderPriority, { color: string; label: string }> = {
|
||||
URGENT: { color: '#dc2626', label: 'Dringend' },
|
||||
HIGH: { color: '#ea580c', label: 'Hoch' },
|
||||
MEDIUM: { color: '#ca8a04', label: 'Mittel' },
|
||||
LOW: { color: '#64748b', label: 'Niedrig' },
|
||||
LOW: { color: '#94a3b8', label: 'Niedrig' },
|
||||
}
|
||||
|
||||
interface Props {
|
||||
@@ -15,9 +15,9 @@ interface Props {
|
||||
export function ReminderPriorityBadge({ priority }: Props) {
|
||||
const { color, label } = CONFIG[priority]
|
||||
return (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
|
||||
<Box sx={{ width: 8, height: 8, borderRadius: '50%', bgcolor: color, flexShrink: 0 }} />
|
||||
<Typography variant="caption" sx={{ color, fontWeight: 600, fontSize: '0.7rem' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.6 }}>
|
||||
<Box sx={{ width: 7, height: 7, borderRadius: '50%', bgcolor: color, flexShrink: 0 }} />
|
||||
<Typography sx={{ color, fontWeight: 600, fontSize: '0.7rem', lineHeight: 1, whiteSpace: 'nowrap' }}>
|
||||
{label}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import { useState } from 'react'
|
||||
import { Box, Button, MenuItem, TextField, ToggleButton, ToggleButtonGroup, Typography } from '@mui/material'
|
||||
import type { Property, PropertyUnit } from '../../domain/property'
|
||||
import { useUpdateUnit } from '../../hooks/useProperties'
|
||||
import { FIT_OUT_LABELS } from '../../lib/constants'
|
||||
import { DS_BORDER, DS_TEXT } from '../../lib/ds'
|
||||
|
||||
const NEEDS_BUILD = new Set(['SHELL', 'BASIC'])
|
||||
|
||||
interface UnitDraft {
|
||||
rentPricePerSqm?: number
|
||||
availableFrom?: string
|
||||
fitOut?: string
|
||||
fitOutByLandlord?: boolean
|
||||
mieterausbaubeitragPerSqm?: number
|
||||
parkingSpots?: number
|
||||
}
|
||||
|
||||
interface Props {
|
||||
property: Property
|
||||
unit: PropertyUnit
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
/** Shared per-unit editor: conditions (price, availability), fit-out group, parking allocation. */
|
||||
export function UnitFieldsEditor({ property, unit, onClose }: Props) {
|
||||
const updateUnit = useUpdateUnit(property.id)
|
||||
const [d, setD] = useState<UnitDraft>(() => ({
|
||||
rentPricePerSqm: unit.rentPricePerSqm ?? property.rentPricePerSqm,
|
||||
availableFrom: unit.availableFrom,
|
||||
fitOut: unit.fitOut ?? '',
|
||||
fitOutByLandlord: unit.fitOutByLandlord,
|
||||
mieterausbaubeitragPerSqm: unit.mieterausbaubeitragPerSqm,
|
||||
parkingSpots: unit.parkingSpots,
|
||||
}))
|
||||
|
||||
const effFit = d.fitOut || property.hardFacts?.fitOut
|
||||
const needsBuild = NEEDS_BUILD.has(effFit ?? '')
|
||||
|
||||
function save() {
|
||||
const fit = (d.fitOut || undefined) as 'SHELL' | 'BASIC' | 'FULL' | 'PREMIUM' | undefined
|
||||
const nb = fit === 'SHELL' || fit === 'BASIC'
|
||||
updateUnit.mutate({
|
||||
unitId: unit.id,
|
||||
data: {
|
||||
rentPricePerSqm: d.rentPricePerSqm,
|
||||
availableFrom: d.availableFrom || undefined,
|
||||
fitOut: fit,
|
||||
fitOutByLandlord: nb ? d.fitOutByLandlord : undefined,
|
||||
mieterausbaubeitragPerSqm: nb && d.fitOutByLandlord === false ? d.mieterausbaubeitragPerSqm : undefined,
|
||||
parkingSpots: d.parkingSpots,
|
||||
},
|
||||
})
|
||||
onClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ px: 2, py: 1.5, bgcolor: '#f8fafc', borderTop: `1px solid ${DS_BORDER.default}` }}>
|
||||
{/* Konditionen */}
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: unit.available ? '1fr 1fr 1fr' : '1fr 1fr', gap: 1.25 }}>
|
||||
<TextField
|
||||
label="Preis (CHF/m²/Jahr)" type="number" size="small"
|
||||
value={d.rentPricePerSqm ?? ''}
|
||||
onChange={e => setD(p => ({ ...p, rentPricePerSqm: parseInt(e.target.value) || undefined }))}
|
||||
slotProps={{ htmlInput: { min: 0, step: 10 } }}
|
||||
helperText="Leer = Objekt-Preis"
|
||||
/>
|
||||
{unit.available && (
|
||||
<TextField
|
||||
label="Verfügbar ab" type="date" size="small"
|
||||
slotProps={{ inputLabel: { shrink: true } }}
|
||||
value={d.availableFrom ?? ''}
|
||||
onChange={e => setD(p => ({ ...p, availableFrom: e.target.value }))}
|
||||
helperText="Leer = sofort"
|
||||
/>
|
||||
)}
|
||||
<TextField
|
||||
label="Parkplätze (Zuteilung)" type="number" size="small"
|
||||
value={d.parkingSpots ?? ''}
|
||||
onChange={e => setD(p => ({ ...p, parkingSpots: parseInt(e.target.value) || undefined }))}
|
||||
slotProps={{ htmlInput: { min: 0 } }}
|
||||
helperText={property.hardFacts?.parking != null ? `Objekt-Pool: ${property.hardFacts.parking}` : 'Leer = Objekt-Wert'}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Ausbau — Standard + Träger + MAB als Gruppe */}
|
||||
<Box sx={{ mt: 1.5, p: 1.5, border: `1px solid ${DS_BORDER.default}`, borderRadius: 1, bgcolor: 'white' }}>
|
||||
<Typography variant="caption" sx={{ fontWeight: 700, color: DS_TEXT.secondary, textTransform: 'uppercase', letterSpacing: 0.4, display: 'block', mb: 1 }}>
|
||||
Ausbau
|
||||
</Typography>
|
||||
<TextField
|
||||
select size="small" label="Ausbaustandard" fullWidth
|
||||
value={d.fitOut ?? ''}
|
||||
onChange={e => setD(p => ({ ...p, fitOut: e.target.value }))}
|
||||
>
|
||||
<MenuItem value="">Wie Objekt{property.hardFacts?.fitOut ? ` (${FIT_OUT_LABELS[property.hardFacts.fitOut] ?? property.hardFacts.fitOut})` : ''}</MenuItem>
|
||||
<MenuItem value="SHELL">{FIT_OUT_LABELS.SHELL}</MenuItem>
|
||||
<MenuItem value="BASIC">{FIT_OUT_LABELS.BASIC}</MenuItem>
|
||||
<MenuItem value="FULL">{FIT_OUT_LABELS.FULL}</MenuItem>
|
||||
<MenuItem value="PREMIUM">{FIT_OUT_LABELS.PREMIUM}</MenuItem>
|
||||
</TextField>
|
||||
|
||||
{needsBuild && (
|
||||
<Box sx={{ display: 'flex', gap: 1.5, alignItems: 'center', flexWrap: 'wrap', mt: 1.25 }}>
|
||||
<Typography variant="caption" color="text.secondary">Wer baut aus?</Typography>
|
||||
<ToggleButtonGroup
|
||||
exclusive size="small"
|
||||
value={d.fitOutByLandlord === undefined ? null : d.fitOutByLandlord ? 'landlord' : 'tenant'}
|
||||
onChange={(_, v) => { if (v) setD(p => ({ ...p, fitOutByLandlord: v === 'landlord', ...(v === 'landlord' ? { mieterausbaubeitragPerSqm: undefined } : {}) })) }}
|
||||
>
|
||||
<ToggleButton value="landlord" sx={{ textTransform: 'none' }}>Vermieter</ToggleButton>
|
||||
<ToggleButton value="tenant" sx={{ textTransform: 'none' }}>Mieter</ToggleButton>
|
||||
</ToggleButtonGroup>
|
||||
{d.fitOutByLandlord === false && (
|
||||
<TextField
|
||||
label="MAB (CHF/m²)" type="number" size="small" sx={{ width: 150 }}
|
||||
value={d.mieterausbaubeitragPerSqm ?? ''}
|
||||
onChange={e => setD(p => ({ ...p, mieterausbaubeitragPerSqm: parseInt(e.target.value) || undefined }))}
|
||||
slotProps={{ htmlInput: { min: 0, max: 1000, step: 25 } }}
|
||||
/>
|
||||
)}
|
||||
<Typography variant="caption" sx={{ color: DS_TEXT.disabled }}>leer = wie Objekt</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 1, mt: 1.5, justifyContent: 'flex-end' }}>
|
||||
<Button size="small" onClick={onClose} sx={{ textTransform: 'none', color: DS_TEXT.muted }}>Abbrechen</Button>
|
||||
<Button size="small" variant="contained" onClick={save} sx={{ textTransform: 'none', bgcolor: '#152642', '&:hover': { bgcolor: '#16304d' } }}>Speichern</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -1,13 +1,16 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Box, Button, Checkbox, Chip, Collapse, Divider, IconButton, Switch, TextField, Tooltip, Typography } from '@mui/material'
|
||||
import { ChevronDown, ChevronUp, ExternalLink, FileText, Layers, Users } from 'lucide-react'
|
||||
import { ChevronDown, ChevronUp, ExternalLink, FileText, Layers, Pencil, Users } from 'lucide-react'
|
||||
import { useNavigate } from 'react-router'
|
||||
import type { Property, PropertyUnit, UnitNeedMatch } from '../../domain/property'
|
||||
import type { Lease } from '../../domain/lease'
|
||||
import { useUnitMatchesMap, useUnitBundle, useBundleMatches } from '../../hooks/useUnitMatches'
|
||||
import { useUpdateUnit } from '../../hooks/useProperties'
|
||||
import { DS_BORDER, DS_PRE_MARKET, DS_SURFACE, DS_TEXT } from '../../lib/ds'
|
||||
import { FIT_OUT_LABELS } from '../../lib/constants'
|
||||
import { resolveUnitFacts } from '../../lib/unitFacts'
|
||||
import { floorLabel } from './PropertyDetailHelpers'
|
||||
import { UnitFieldsEditor } from './UnitFieldsEditor'
|
||||
|
||||
function MatchPill({ m }: { m: UnitNeedMatch }) {
|
||||
const bg = m.matchScore >= 85 ? '#fef3c7' : '#e0e7ff'
|
||||
@@ -50,6 +53,7 @@ export function UnitStructurePanel({ p }: { p: Property }) {
|
||||
const [expandedUnit, setExpandedUnit] = useState<string | null>(null)
|
||||
const [editingFlexUnit, setEditingFlexUnit] = useState<string | null>(null)
|
||||
const [flexDraft, setFlexDraft] = useState<Record<string, number | undefined>>({})
|
||||
const [editingUnit, setEditingUnit] = useState<string | null>(null)
|
||||
const updateUnit = useUpdateUnit(p.id)
|
||||
|
||||
const freeUnits = useMemo(() => (p.units ?? []).filter(u => u.available), [p.units])
|
||||
@@ -229,6 +233,7 @@ export function UnitStructurePanel({ p }: { p: Property }) {
|
||||
propertyId: p.id,
|
||||
floor: floorLabel(u),
|
||||
fitOut: p.hardFacts?.fitOut,
|
||||
fitOutByLandlord: p.hardFacts?.fitOutByLandlord,
|
||||
parking: p.hardFacts?.parking,
|
||||
ceilingHeight: p.hardFacts?.ceilingHeightM,
|
||||
mieterausbaubeitragPerSqm: p.hardFacts?.mieterausbaubeitragPerSqm,
|
||||
@@ -285,9 +290,19 @@ export function UnitStructurePanel({ p }: { p: Property }) {
|
||||
{isExpanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
|
||||
</IconButton>
|
||||
)}
|
||||
{/* Edit price / availability per unit */}
|
||||
<Tooltip title="Preis & Verfügbarkeit bearbeiten">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => setEditingUnit(editingUnit === u.id ? null : u.id)}
|
||||
sx={{ p: 0.25, color: editingUnit === u.id ? '#2563eb' : DS_TEXT.disabled }}
|
||||
>
|
||||
<Pencil size={11} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
|
||||
{/* Area */}
|
||||
{/* Area + Preis + Verfügbarkeit pro Einheit */}
|
||||
<Box sx={{ textAlign: 'right' }}>
|
||||
<Typography variant="caption" sx={{ color: '#374151', fontWeight: u.available ? 600 : 400 }}>
|
||||
{(u.offeredSqm ?? u.areaSqm).toLocaleString('de-CH')} m²
|
||||
@@ -297,9 +312,46 @@ export function UnitStructurePanel({ p }: { p: Property }) {
|
||||
ab {u.minLettableSqm} m²
|
||||
</Typography>
|
||||
)}
|
||||
<Typography variant="caption" sx={{ color: DS_TEXT.muted, fontSize: '0.6rem', display: 'block' }}>
|
||||
CHF {(u.rentPricePerSqm ?? p.rentPricePerSqm).toLocaleString('de-CH')}/m²
|
||||
</Typography>
|
||||
{u.available && (() => {
|
||||
const av = u.availableFrom ?? u.schattenmarktRelease?.availableFrom
|
||||
return (
|
||||
<Typography variant="caption" sx={{ color: DS_TEXT.disabled, fontSize: '0.6rem', display: 'block' }}>
|
||||
{av ? `ab ${new Date(av).toLocaleDateString('de-CH', { month: 'short', year: '2-digit' })}` : 'sofort'}
|
||||
</Typography>
|
||||
)
|
||||
})()}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Always-visible per-unit details (read) */}
|
||||
{(() => {
|
||||
const f = resolveUnitFacts(p, u)
|
||||
const fitLabel = f.fitOut ? (FIT_OUT_LABELS[f.fitOut] ?? f.fitOut) : null
|
||||
const needsBuild = f.fitOut === 'SHELL' || f.fitOut === 'BASIC'
|
||||
const parts: string[] = []
|
||||
if (fitLabel) parts.push(`Ausbau: ${fitLabel}`)
|
||||
if (needsBuild) parts.push(`Träger: ${f.fitOutByLandlord ? 'Vermieter' : 'Mieter'}`)
|
||||
if (needsBuild && !f.fitOutByLandlord && f.mabPerSqm > 0) parts.push(`MAB CHF ${f.mabPerSqm}/m²`)
|
||||
if (f.parkingSpots != null) parts.push(`${f.parkingSpots} PP`)
|
||||
if (u.expectedRentPerSqm) parts.push(`erwartet CHF ${u.expectedRentPerSqm}/m²`)
|
||||
if (parts.length === 0) return null
|
||||
return (
|
||||
<Box sx={{ px: 1.5, pb: 0.625, mt: -0.25, borderBottom: isLastRow ? 'none' : `1px solid ${DS_BORDER.muted}` }}>
|
||||
<Typography variant="caption" sx={{ color: DS_TEXT.muted, fontSize: '0.63rem' }}>
|
||||
{parts.join(' · ')}
|
||||
</Typography>
|
||||
</Box>
|
||||
)
|
||||
})()}
|
||||
|
||||
{/* Inline editor: full per-unit fields */}
|
||||
<Collapse in={editingUnit === u.id}>
|
||||
<UnitFieldsEditor property={p} unit={u} onClose={() => setEditingUnit(null)} />
|
||||
</Collapse>
|
||||
|
||||
{/* Expanded: matches for free units */}
|
||||
<Collapse in={isExpanded && u.available}>
|
||||
<Box sx={{ px: 2, py: 1, bgcolor: DS_SURFACE.neutral.bg, borderBottom: !isLastUnit ? `1px solid ${DS_BORDER.default}` : 'none' }}>
|
||||
|
||||
@@ -20,10 +20,20 @@ export interface InquiryMessage {
|
||||
|
||||
export type InquiryStatus = 'new' | 'in_progress' | 'answered' | 'archived'
|
||||
|
||||
/** Konversationstyp: Nachfrager-initiierte Anfrage vs. Verwaltung-initiiertes Angebot. */
|
||||
export type InquiryKind = 'INQUIRY' | 'OFFER'
|
||||
|
||||
export interface Inquiry {
|
||||
id: string
|
||||
/** Supply-Organisation (Eigentümer/Verwaltung des Objekts). */
|
||||
organizationId: string
|
||||
/** Nachfrager-Organisation — für das Demand-Postfach. */
|
||||
tenantOrgId?: string
|
||||
/** INQUIRY (Demand→Supply) oder OFFER (Supply→Demand). Default INQUIRY. */
|
||||
kind?: InquiryKind
|
||||
propertyId: string
|
||||
/** Bei OFFER: alle im Angebot enthaltenen Objekte. */
|
||||
offeredPropertyIds?: string[]
|
||||
needId?: string
|
||||
tenantName: string
|
||||
tenantCompany?: string
|
||||
|
||||
@@ -5,6 +5,8 @@ export interface LatentNeed {
|
||||
id: string
|
||||
title: string
|
||||
tenantCompany?: string
|
||||
/** Nachfrager-Organisation — Empfänger eines Angebots (Demo-Default-Routing: org-mobimo). */
|
||||
tenantOrgId?: string
|
||||
assetType: AssetType
|
||||
desiredLocation: string
|
||||
sizeRange: { min: number; max: number }
|
||||
|
||||
@@ -63,6 +63,12 @@ export interface WeightedPreference {
|
||||
description?: string
|
||||
}
|
||||
|
||||
export interface NotificationConfig {
|
||||
enabled: boolean
|
||||
minScore: number // 0–100, default 80
|
||||
emailAddress?: string
|
||||
}
|
||||
|
||||
export interface Need {
|
||||
id: string
|
||||
companyName: string
|
||||
@@ -103,6 +109,7 @@ export interface Need {
|
||||
confidenceInCriteria: number
|
||||
extractedFromText?: string
|
||||
notes?: string
|
||||
notificationConfig?: NotificationConfig
|
||||
organizationId?: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
|
||||
@@ -56,6 +56,8 @@ export interface PropertyHardFacts {
|
||||
floor?: number
|
||||
fitOut?: 'SHELL' | 'BASIC' | 'FULL' | 'PREMIUM'
|
||||
mieterausbaubeitragPerSqm?: number
|
||||
/** true = Vermieter übernimmt den Ausbau & preist ihn in die Miete ein (kein Aufschlag, kein MAB). */
|
||||
fitOutByLandlord?: boolean
|
||||
parking?: number
|
||||
publicTransportScore?: number
|
||||
usageType?: string
|
||||
|
||||
@@ -17,6 +17,14 @@ export interface PropertyUnit {
|
||||
areaSqm: number
|
||||
available: boolean
|
||||
rentPricePerSqm?: number // annual CHF/m²; falls back to property.rentPricePerSqm
|
||||
availableFrom?: string // explicit availability date for a free unit (ISO); else derived from leases
|
||||
// Unit-level overrides — fall back to property.hardFacts.* when unset.
|
||||
// Relevant where units of one property are matched individually (pre-market, multi-unit).
|
||||
fitOut?: 'SHELL' | 'BASIC' | 'FULL' | 'PREMIUM'
|
||||
fitOutByLandlord?: boolean // wer trägt den Ausbau (Default: Objekt-Wert)
|
||||
mieterausbaubeitragPerSqm?: number // MAB der Einheit (Default: Objekt-Wert)
|
||||
parkingSpots?: number // aus dem Objekt-Pool zugeteilte Parkplätze
|
||||
expectedRentPerSqm?: number // erwarteter künftiger Preis (Pre-Market), ≠ heutige Sollmiete
|
||||
leases?: Lease[] // Mietverträge — current, historical, future
|
||||
/** @deprecated Use leases[].tenant.companyName */
|
||||
currentTenant?: string
|
||||
|
||||
@@ -181,6 +181,27 @@ describe('calculateScore', () => {
|
||||
expect(output.excludedReason).toBeTruthy()
|
||||
})
|
||||
|
||||
// ── Ausbau-Annuität im Budget-Faktor ────────────────────────────────────────
|
||||
function budgetFactor(need: ReturnType<typeof makeNeed>, prop: ReturnType<typeof makeProperty>) {
|
||||
return calculateScore(need, prop).allHardFactors.find(f => f.criterion === 'budget')!
|
||||
}
|
||||
|
||||
it('tenant-borne SHELL lowers the budget factor vs a fitted-out (FULL) property', () => {
|
||||
const need = makeNeed({ budgetRange: { maxPerSqm: 50, currency: 'CHF' } })
|
||||
const base = { rentPricePerSqm: 48, areaSqm: 300 }
|
||||
const shell = makeProperty({ ...base, hardFacts: { fitOut: 'SHELL' } })
|
||||
const full = makeProperty({ ...base, hardFacts: { fitOut: 'FULL' } })
|
||||
expect(budgetFactor(need, shell).score).toBeLessThan(budgetFactor(need, full).score)
|
||||
})
|
||||
|
||||
it('landlord-borne SHELL keeps the budget factor (no fit-out surcharge)', () => {
|
||||
const need = makeNeed({ budgetRange: { maxPerSqm: 50, currency: 'CHF' } })
|
||||
const base = { rentPricePerSqm: 48, areaSqm: 300 }
|
||||
const tenant = makeProperty({ ...base, hardFacts: { fitOut: 'SHELL' } })
|
||||
const landlord = makeProperty({ ...base, hardFacts: { fitOut: 'SHELL', fitOutByLandlord: true } })
|
||||
expect(budgetFactor(need, landlord).score).toBeGreaterThan(budgetFactor(need, tenant).score)
|
||||
})
|
||||
|
||||
it('produces identical scores on repeated calls with the same inputs (determinism)', () => {
|
||||
const need = makeNeed()
|
||||
const prop = makeProperty()
|
||||
|
||||
@@ -13,6 +13,7 @@ import type {
|
||||
import { getCityIntelligence } from '../../lib/locationIntelligence'
|
||||
import { formatUnitTitle, formatMultiUnitFloors, getUnitAvailability } from '../../domain/unit'
|
||||
import { calcFitOutInvestment } from '../../lib/fitOutUtils'
|
||||
import { resolveUnitFacts } from '../../lib/unitFacts'
|
||||
import { AvailabilityStatus } from '../../domain/enums'
|
||||
|
||||
const HARD_CRITERIA = new Set(['area', 'location', 'budget', 'timing'])
|
||||
@@ -152,29 +153,30 @@ export function buildMatchCardViewModel(
|
||||
preMarketUnit: unit,
|
||||
preMarketAllUnits: property?.units,
|
||||
fitOutLabel: (() => {
|
||||
const fitOut = property?.hardFacts?.fitOut
|
||||
// Einheit-Werte haben Vorrang vor Objekt-Werten (Pre-Market / Mehr-Einheiten)
|
||||
if (!property) return undefined
|
||||
const { fitOut, mabPerSqm } = resolveUnitFacts(property, unit)
|
||||
if (!fitOut) return undefined
|
||||
const LABELS: Record<string, string> = { SHELL: 'Rohbau', BASIC: 'Basisausbau', FULL: 'Vollausbau', PREMIUM: 'Premiumausbau' }
|
||||
const mab = property.hardFacts?.mieterausbaubeitragPerSqm
|
||||
if (fitOut === 'FULL' || fitOut === 'PREMIUM') return `${LABELS[fitOut]} — bezugsfertig`
|
||||
if (mab) return `${LABELS[fitOut]} + CHF ${mab} MAB`
|
||||
if (mabPerSqm) return `${LABELS[fitOut]} + CHF ${mabPerSqm} MAB`
|
||||
return LABELS[fitOut] ?? fitOut
|
||||
})(),
|
||||
fitOutViable: (() => {
|
||||
const fitOut = property?.hardFacts?.fitOut
|
||||
if (!property) return undefined
|
||||
const { fitOut, mabPerSqm } = resolveUnitFacts(property, unit)
|
||||
if (!fitOut) return undefined
|
||||
if (fitOut === 'FULL' || fitOut === 'PREMIUM') return true
|
||||
const mab = property.hardFacts?.mieterausbaubeitragPerSqm ?? 0
|
||||
if (fitOut === 'BASIC' && mab >= 150) return true
|
||||
if (fitOut === 'SHELL' && mab >= 350) return true
|
||||
if (fitOut === 'BASIC' && mabPerSqm >= 150) return true
|
||||
if (fitOut === 'SHELL' && mabPerSqm >= 350) return true
|
||||
return undefined
|
||||
})(),
|
||||
fitOutInvestment: (() => {
|
||||
const fitOut = property?.hardFacts?.fitOut
|
||||
if (!property) return undefined
|
||||
const { fitOut, mabPerSqm } = resolveUnitFacts(property, unit)
|
||||
if (!fitOut || fitOut === 'FULL' || fitOut === 'PREMIUM') return undefined
|
||||
const mab = property.hardFacts?.mieterausbaubeitragPerSqm ?? 0
|
||||
const area = property.areaSqm ?? 0
|
||||
return calcFitOutInvestment(fitOut, area, mab, 0) ?? undefined
|
||||
const area = unit?.areaSqm ?? property.areaSqm ?? 0
|
||||
return calcFitOutInvestment(fitOut, area, mabPerSqm, 0) ?? undefined
|
||||
})(),
|
||||
isDivisible: property?.units ? property.units.length > 1 : false,
|
||||
minDivisibleUnitSqm: (() => {
|
||||
|
||||
@@ -15,6 +15,7 @@ import { analyzeTradeOffs, analyzeRisks, identifyMissingData } from './tradeOffA
|
||||
import { generateNextBestActions } from './rankingEngine'
|
||||
import { softFactorEnrichmentService } from '../../services/softFactorEnrichmentService'
|
||||
import { scoreMustHaves } from './mustHaveScorer'
|
||||
import { effectiveAnnualBurdenPerSqm } from '../../lib/fitOutUtils'
|
||||
|
||||
// ── Profile resolution ────────────────────────────────────────────────────────
|
||||
|
||||
@@ -159,7 +160,16 @@ function scoreLocation(need: Need, property: Property, weight: number): ScoreFac
|
||||
|
||||
function scoreBudget(need: Need, property: Property, weight: number): ScoreFactor {
|
||||
const maxBudget = need.budgetRange?.maxPerSqm ?? 0
|
||||
const rent = property.rentPricePerSqm
|
||||
|
||||
// Effektive Jahresbelastung: Kaltmiete + annuitätischer Ausbau-Aufschlag (nur wenn Mieter ausbaut)
|
||||
const { effectivePerSqm: rent, fitOutPerSqm } = effectiveAnnualBurdenPerSqm({
|
||||
fitOut: property.hardFacts?.fitOut,
|
||||
rentPricePerSqm: property.rentPricePerSqm,
|
||||
mabPerSqm: property.hardFacts?.mieterausbaubeitragPerSqm ?? 0,
|
||||
fitOutByLandlord: property.hardFacts?.fitOutByLandlord,
|
||||
})
|
||||
// Erklärt den Ausbau-Anteil transparent, wenn er den Score beeinflusst
|
||||
const fitOutNote = fitOutPerSqm > 0 ? ` (inkl. CHF ${fitOutPerSqm}/m² Ausbau-Annuität)` : ''
|
||||
|
||||
let score: number
|
||||
let explanation: string
|
||||
@@ -171,18 +181,18 @@ function scoreBudget(need: Need, property: Property, weight: number): ScoreFacto
|
||||
const ratio = rent / maxBudget
|
||||
// Very cheap can indicate quality issues — slight penalty below 50% of budget
|
||||
score = ratio >= 0.50 ? 100 : 88
|
||||
explanation = `Miete CHF ${rent}/m² liegt ${Math.round((1 - ratio) * 100)}% unter Budget CHF ${maxBudget}/m²`
|
||||
explanation = `Effektive Belastung CHF ${rent}/m² liegt ${Math.round((1 - ratio) * 100)}% unter Budget CHF ${maxBudget}/m²${fitOutNote}`
|
||||
} else {
|
||||
const overRatio = rent / maxBudget
|
||||
if (overRatio <= HARD_FILTER.BUDGET_MODERATE_RATIO) {
|
||||
score = 75
|
||||
explanation = `Miete CHF ${rent}/m² leicht über Budget (+${Math.round((overRatio - 1) * 100)}%)`
|
||||
explanation = `Effektive Belastung CHF ${rent}/m² leicht über Budget (+${Math.round((overRatio - 1) * 100)}%)${fitOutNote}`
|
||||
} else if (overRatio <= HARD_FILTER.BUDGET_SEVERE_RATIO) {
|
||||
score = 45
|
||||
explanation = `Miete CHF ${rent}/m² merklich über Budget (+${Math.round((overRatio - 1) * 100)}%)`
|
||||
explanation = `Effektive Belastung CHF ${rent}/m² merklich über Budget (+${Math.round((overRatio - 1) * 100)}%)${fitOutNote}`
|
||||
} else {
|
||||
score = 20
|
||||
explanation = `Miete CHF ${rent}/m² stark über Budget (+${Math.round((overRatio - 1) * 100)}%)`
|
||||
explanation = `Effektive Belastung CHF ${rent}/m² stark über Budget (+${Math.round((overRatio - 1) * 100)}%)${fitOutNote}`
|
||||
}
|
||||
}
|
||||
|
||||
@@ -610,8 +620,12 @@ export function calculateScore(need: Need, property: Property): MatchEngineOutpu
|
||||
]
|
||||
const mustHaveEval = scoreMustHaves(allMustHaveText, property)
|
||||
|
||||
// ── Final score: hard/soft weighted sum, clamped 0–100 ───────────────────
|
||||
const rawFinal = hardMatchScore * 0.60 + softFactorScore * 0.40 - hardFilter.severePenalty
|
||||
// ── Final score: hard/soft weighted sum + Datenqualität/Konfidenz-Modifikatoren,
|
||||
// abzgl. severePenalty, clamped 0–100 (Trust-first: schwache Datenlage senkt den Score) ──
|
||||
const dataQualityModifier = calcDataQualityModifier(property)
|
||||
const confidenceModifier = calcConfidenceModifier(property)
|
||||
const rawFinal = hardMatchScore * 0.60 + softFactorScore * 0.40
|
||||
+ dataQualityModifier + confidenceModifier - hardFilter.severePenalty
|
||||
const finalScore = Math.round(Math.min(100, Math.max(0, rawFinal)))
|
||||
|
||||
// ── Factor classification — only use weighted soft factors for positive/negative ──
|
||||
@@ -639,8 +653,8 @@ export function calculateScore(need: Need, property: Property): MatchEngineOutpu
|
||||
finalScore,
|
||||
hardMatchScore,
|
||||
softFactorScore,
|
||||
dataQualityModifier: 0,
|
||||
confidenceModifier: 0,
|
||||
dataQualityModifier,
|
||||
confidenceModifier,
|
||||
positiveFactors,
|
||||
negativeFactors,
|
||||
allHardFactors: hardFactors,
|
||||
|
||||
+20
-2
@@ -1,6 +1,6 @@
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { useMutation, useQuery } from '@tanstack/react-query'
|
||||
import { aiService, parseListingText } from '../services/aiService'
|
||||
import type { OfferEmailPayload } from '../services/aiService'
|
||||
import type { OfferEmailPayload, FitOutAdviceInput, PreMarketRentInput } from '../services/aiService'
|
||||
|
||||
export function useParseNeed() {
|
||||
return useMutation({
|
||||
@@ -25,3 +25,21 @@ export function useParseListingText() {
|
||||
mutationFn: (text: string) => parseListingText(text),
|
||||
})
|
||||
}
|
||||
|
||||
export function useFitOutAdvice(input: FitOutAdviceInput | null) {
|
||||
return useQuery({
|
||||
queryKey: ['fitOutAdvice', input],
|
||||
queryFn: () => aiService.generateFitOutAdvice(input!),
|
||||
enabled: !!input,
|
||||
staleTime: Infinity,
|
||||
})
|
||||
}
|
||||
|
||||
export function usePreMarketRentRecommendation(input: PreMarketRentInput | null) {
|
||||
return useQuery({
|
||||
queryKey: ['preMarketRent', input],
|
||||
queryFn: () => aiService.recommendPreMarketRent(input!),
|
||||
enabled: !!input && !!input.city,
|
||||
staleTime: Infinity,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { inquiryService } from '../services/inquiryService'
|
||||
import type { InquiryFilters } from '../provider/IInquiryProvider'
|
||||
import type { Attachment } from '../domain/inquiry'
|
||||
import type { InquiryFilters, CreateInquiryInput, CreateOfferInput } from '../provider/IInquiryProvider'
|
||||
import type { Attachment, InquiryMessage } from '../domain/inquiry'
|
||||
import { useToastStore } from '../stores/toastStore'
|
||||
|
||||
export function useActiveInquiries(filters?: InquiryFilters) {
|
||||
@@ -29,7 +29,7 @@ export function useSendInquiryReply() {
|
||||
payload,
|
||||
}: {
|
||||
inquiryId: string
|
||||
payload: { subject?: string; body: string; attachments?: Attachment[] }
|
||||
payload: { subject?: string; body: string; attachments?: Attachment[]; senderType?: InquiryMessage['senderType']; senderName?: string }
|
||||
}) => inquiryService.sendInquiryReply(inquiryId, payload),
|
||||
onSuccess: (_data, { inquiryId }) => {
|
||||
qc.invalidateQueries({ queryKey: ['inquiry', inquiryId] })
|
||||
@@ -41,6 +41,31 @@ export function useSendInquiryReply() {
|
||||
})
|
||||
}
|
||||
|
||||
/** Demand→Supply: neue Anfrage erstellen (ersetzt die alte Zustand-Insel). */
|
||||
export function useCreateInquiry() {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (input: CreateInquiryInput) => inquiryService.createInquiry(input),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['inquiries'] })
|
||||
},
|
||||
onError: () => {
|
||||
useToastStore.getState().showToast('Anfrage konnte nicht gesendet werden.', 'error')
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/** Supply→Demand: Angebot als Konversation im Nachfrager-Postfach materialisieren. */
|
||||
export function useCreateOffer() {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (input: CreateOfferInput) => inquiryService.createOffer(input),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['inquiries'] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useMarkThreadAsRead() {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
|
||||
+25
-3
@@ -1,6 +1,7 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { needService } from '../services/needService'
|
||||
import type { CreateNeedInput } from '../domain/need'
|
||||
import { useSessionStore } from '../stores/sessionStore'
|
||||
import type { CreateNeedInput, UpdateNeedInput } from '../domain/need'
|
||||
|
||||
interface UseNeedsOptions {
|
||||
refetchOnMount?: boolean | 'always'
|
||||
@@ -8,9 +9,10 @@ interface UseNeedsOptions {
|
||||
}
|
||||
|
||||
export function useNeeds(options?: UseNeedsOptions) {
|
||||
const orgId = useSessionStore(s => s.currentUser?.organizationId)
|
||||
return useQuery({
|
||||
queryKey: ['needs'],
|
||||
queryFn: () => needService.getAll(),
|
||||
queryKey: ['needs', orgId],
|
||||
queryFn: () => needService.getAll({ organizationId: orgId }),
|
||||
select: (res) => res.data ?? [],
|
||||
...options,
|
||||
})
|
||||
@@ -36,3 +38,23 @@ export function useCreateNeed() {
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useUpdateNeed() {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: ({ id, ...data }: { id: string } & UpdateNeedInput) => needService.update(id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['needs'] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useArchiveNeed() {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => needService.update(id, { status: 'INACTIVE' }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['needs'] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -15,6 +15,8 @@ function validate(fields: {
|
||||
city: string
|
||||
areaSqm: string
|
||||
rentPerSqm: string
|
||||
fitOut: string
|
||||
fitOutByLandlord: boolean | undefined
|
||||
}): string | null {
|
||||
if (!fields.street.trim()) return 'Strasse erforderlich'
|
||||
if (!fields.postalCode.trim()) return 'PLZ erforderlich'
|
||||
@@ -23,6 +25,9 @@ function validate(fields: {
|
||||
|| Number(fields.areaSqm) <= 0) return 'Gültige Fläche eingeben'
|
||||
if (!fields.rentPerSqm || isNaN(Number(fields.rentPerSqm))
|
||||
|| Number(fields.rentPerSqm) <= 0) return 'Gültigen Mietpreis eingeben'
|
||||
// Bei Rohbau/Edelrohbau muss der Ausbau-Träger aktiv gewählt werden
|
||||
if ((fields.fitOut === 'SHELL' || fields.fitOut === 'BASIC')
|
||||
&& fields.fitOutByLandlord === undefined) return 'Bitte wählen, wer den Ausbau trägt (Vermieter oder Mieter)'
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -43,6 +48,7 @@ export interface NewListingFormState {
|
||||
// Technical
|
||||
floor: string
|
||||
fitOut: string
|
||||
fitOutByLandlord: boolean | undefined
|
||||
parking: string
|
||||
ceilingHeight: string
|
||||
mieterausbaubeitrag: string
|
||||
@@ -80,6 +86,7 @@ export interface NewListingFormHandlers {
|
||||
setSoftLevel: (key: string, value: string) => void
|
||||
setFloor: (v: string) => void
|
||||
setFitOut: (v: string) => void
|
||||
setFitOutByLandlord: (v: boolean | undefined) => void
|
||||
setParking: (v: string) => void
|
||||
setCeilingHeight: (v: string) => void
|
||||
setMieterausbaubeitrag: (v: string) => void
|
||||
@@ -117,6 +124,7 @@ export function useNewListingForm(pre: Prefill): NewListingFormState & NewListin
|
||||
const [softLevels, setSoftLevels] = useState<Record<string, string>>(() => ({ ...emptySoftLevels(), ...pre.softLevels }))
|
||||
const [floor, setFloor] = useState(pre.floor ?? '')
|
||||
const [fitOut, setFitOut] = useState(pre.fitOut ?? '')
|
||||
const [fitOutByLandlord, setFitOutByLandlord] = useState<boolean | undefined>(pre.fitOutByLandlord)
|
||||
const [parking, setParking] = useState(pre.parking != null ? String(pre.parking) : '')
|
||||
const [ceilingHeight, setCeilingHeight]= useState(pre.ceilingHeight != null ? String(pre.ceilingHeight) : '')
|
||||
const [mieterausbaubeitrag, setMieterausbaubeitrag] = useState(pre.mieterausbaubeitragPerSqm != null ? String(pre.mieterausbaubeitragPerSqm) : '')
|
||||
@@ -161,7 +169,7 @@ export function useNewListingForm(pre: Prefill): NewListingFormState & NewListin
|
||||
}
|
||||
|
||||
function handleSubmit() {
|
||||
const err = validate({ street, postalCode, city, areaSqm, rentPerSqm })
|
||||
const err = validate({ street, postalCode, city, areaSqm, rentPerSqm, fitOut, fitOutByLandlord })
|
||||
if (err) { setError(err); return }
|
||||
setError(null)
|
||||
createProperty.mutate(
|
||||
@@ -169,7 +177,7 @@ export function useNewListingForm(pre: Prefill): NewListingFormState & NewListin
|
||||
assetType, street, houseNumber, postalCode, city,
|
||||
areaSqm: Number(areaSqm), rentPerSqm: Number(rentPerSqm),
|
||||
availableFrom, description, softLevels,
|
||||
floor, fitOut, parking, ceilingHeight, images, floorPlanUrl,
|
||||
floor, fitOut, fitOutByLandlord, parking, ceilingHeight, images, floorPlanUrl,
|
||||
mieterausbaubeitrag, isFlexible, minLettableSqm,
|
||||
}),
|
||||
{
|
||||
@@ -185,7 +193,7 @@ export function useNewListingForm(pre: Prefill): NewListingFormState & NewListin
|
||||
setAreaSqm(''); setRentPerSqm(''); setAvailableFrom(''); setDescription('')
|
||||
setContactName(''); setContactEmail(''); setContactPhone('')
|
||||
setSoftLevels(emptySoftLevels())
|
||||
setFloor(''); setFitOut(''); setParking(''); setCeilingHeight('')
|
||||
setFloor(''); setFitOut(''); setFitOutByLandlord(undefined); setParking(''); setCeilingHeight('')
|
||||
setMieterausbaubeitrag(''); setIsFlexible(false); setMinLettableSqm('')
|
||||
setImages([]); setImageInput(''); setFloorPlanUrl('')
|
||||
setAiText(''); setAiApplied(false)
|
||||
@@ -195,7 +203,7 @@ export function useNewListingForm(pre: Prefill): NewListingFormState & NewListin
|
||||
return {
|
||||
assetType, areaSqm, rentPerSqm, availableFrom, description,
|
||||
street, houseNumber, postalCode, city,
|
||||
softLevels, floor, fitOut, parking, ceilingHeight, mieterausbaubeitrag, isFlexible, minLettableSqm,
|
||||
softLevels, floor, fitOut, fitOutByLandlord, parking, ceilingHeight, mieterausbaubeitrag, isFlexible, minLettableSqm,
|
||||
contactName, contactEmail, contactPhone,
|
||||
images, imageInput, floorPlanUrl, aiText, aiApplied,
|
||||
error, created,
|
||||
@@ -205,7 +213,7 @@ export function useNewListingForm(pre: Prefill): NewListingFormState & NewListin
|
||||
setAssetType, setAreaSqm, setRentPerSqm, setAvailableFrom, setDescription,
|
||||
setStreet, setHouseNumber, setPostalCode, setCity,
|
||||
setSoftLevel,
|
||||
setFloor, setFitOut, setParking, setCeilingHeight, setMieterausbaubeitrag, setIsFlexible, setMinLettableSqm,
|
||||
setFloor, setFitOut, setFitOutByLandlord, setParking, setCeilingHeight, setMieterausbaubeitrag, setIsFlexible, setMinLettableSqm,
|
||||
setContactName, setContactEmail, setContactPhone,
|
||||
setImageInput, setFloorPlanUrl, setAiText,
|
||||
addImage, removeImage,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { reminderService } from '../services/reminderService'
|
||||
import type { Reminder } from '../domain/reminder'
|
||||
import { useToastStore } from '../stores/toastStore'
|
||||
|
||||
export function useReminders() {
|
||||
@@ -69,6 +70,21 @@ export function useSnoozeReminder() {
|
||||
})
|
||||
}
|
||||
|
||||
export function useCreateReminder() {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (data: Omit<Reminder, 'id' | 'activity' | 'createdAt' | 'updatedAt'>) =>
|
||||
reminderService.create(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['reminders'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['reminder-insights'] })
|
||||
},
|
||||
onError: () => {
|
||||
useToastStore.getState().showToast('Reminder konnte nicht erstellt werden.', 'error')
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useUpdateReminder() {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { estimateMarketRent, suggestFutureRent } from '../rentEstimate'
|
||||
|
||||
describe('estimateMarketRent', () => {
|
||||
// Zürich OFFICE median = 42/Monat → 504/Jahr (getMarketRent rechnet ×12)
|
||||
it('flags asking rent clearly above the city median as ABOVE', () => {
|
||||
const est = estimateMarketRent('Zürich', 'OFFICE', 600)
|
||||
expect(est).not.toBeNull()
|
||||
expect(est!.verdict).toBe('ABOVE')
|
||||
expect(est!.deltaPct).toBeGreaterThan(5)
|
||||
expect(est!.fairRentPerSqm).toBe(504)
|
||||
})
|
||||
|
||||
it('flags asking rent clearly below median as BELOW', () => {
|
||||
const est = estimateMarketRent('Zürich', 'OFFICE', 400)
|
||||
expect(est!.verdict).toBe('BELOW')
|
||||
expect(est!.deltaPct).toBeLessThan(-5)
|
||||
})
|
||||
|
||||
it('treats near-median asking rent as AT (within ±5%)', () => {
|
||||
const est = estimateMarketRent('Zürich', 'OFFICE', 510)
|
||||
expect(est!.verdict).toBe('AT')
|
||||
})
|
||||
|
||||
it('returns null for unknown city', () => {
|
||||
expect(estimateMarketRent('Atlantis', 'OFFICE', 480)).toBeNull()
|
||||
})
|
||||
|
||||
it('suggestFutureRent indexes by the city rent trend', () => {
|
||||
// Zürich rentTrend12m = +4.2% → 100 * 1.042 = 104 (rounded)
|
||||
expect(suggestFutureRent('Zürich', 100)).toBe(104)
|
||||
})
|
||||
})
|
||||
@@ -142,3 +142,15 @@ export const FIT_OUT_COST_CHF_PER_SQM: Record<string, { min: number; max: number
|
||||
|
||||
// Assumed amortization period for fit-out investment — change here to affect all cost calculations
|
||||
export const FITOUT_AMORTIZATION_YEARS = 5
|
||||
|
||||
// Kalkulationszins p.a. für die annuitätische Amortisation der Ausbauinvestition (Schweizer Richtwert)
|
||||
export const FITOUT_ANNUITY_RATE = 0.05
|
||||
|
||||
// Zentrale Ausbaustandard-Labels — branchenüblich + internationale Synonyme (CAT A/B, Core & Shell).
|
||||
// Single Source of Truth für Dropdown, Panel, Score, Vergleich.
|
||||
export const FIT_OUT_LABELS: Record<string, string> = {
|
||||
SHELL: 'Rohbau · Core & Shell',
|
||||
BASIC: 'Edelrohbau · CAT A',
|
||||
FULL: 'Ausgebaut · CAT B',
|
||||
PREMIUM: 'Vollausgebaut · Plug & Play',
|
||||
}
|
||||
|
||||
+42
-1
@@ -1,15 +1,49 @@
|
||||
import { FIT_OUT_COST_CHF_PER_SQM } from './constants'
|
||||
import { FIT_OUT_COST_CHF_PER_SQM, FITOUT_AMORTIZATION_YEARS, FITOUT_ANNUITY_RATE } from './constants'
|
||||
|
||||
export interface FitOutInvestment {
|
||||
grossPerSqm: { min: number; max: number }
|
||||
mabOffset: number
|
||||
netPerSqm: { min: number; max: number }
|
||||
netTotal: { min: number; max: number }
|
||||
annualBurden: { min: number; max: number }
|
||||
isFullyCovered: boolean
|
||||
shortLabel: string
|
||||
detailLabel: string
|
||||
}
|
||||
|
||||
// Annuitätenfaktor: a = i(1+i)^n / ((1+i)^n − 1); bei i=0 → 1/n (lineare Amortisation als Fallback)
|
||||
export function annuityFactor(rate: number, years: number): number {
|
||||
if (years <= 0) return 0
|
||||
if (rate === 0) return 1 / years
|
||||
const q = Math.pow(1 + rate, years)
|
||||
return (rate * q) / (q - 1)
|
||||
}
|
||||
|
||||
// Bezugsfertige Stufen — kein Ausbau-Aufschlag (Mieter muss nicht mehr investieren)
|
||||
const FITOUT_READY = new Set(['FULL', 'PREMIUM'])
|
||||
|
||||
/**
|
||||
* Effektive Jahresbelastung pro m². Grundsatz: die quotierte Miete gilt IMMER als realer Preis —
|
||||
* die Plattform erfindet keinen Aufschlag. Nur wenn der Mieter ausbaut (SHELL/BASIC), wird die
|
||||
* versteckte Ausbaukost annuitätisch sichtbar gemacht (abzgl. MAB). Übernimmt der Vermieter, ist der
|
||||
* Ausbau bereits in der Miete enthalten → kein Aufschlag, keine Mieter-Capex.
|
||||
*/
|
||||
export function effectiveAnnualBurdenPerSqm(p: {
|
||||
fitOut?: string
|
||||
rentPricePerSqm: number
|
||||
mabPerSqm: number
|
||||
fitOutByLandlord?: boolean
|
||||
}): { rentPerSqm: number; fitOutPerSqm: number; effectivePerSqm: number } {
|
||||
if (p.fitOutByLandlord || FITOUT_READY.has(p.fitOut ?? '')) {
|
||||
return { rentPerSqm: p.rentPricePerSqm, fitOutPerSqm: 0, effectivePerSqm: p.rentPricePerSqm }
|
||||
}
|
||||
const bm = FIT_OUT_COST_CHF_PER_SQM[p.fitOut ?? '']
|
||||
const grossMid = bm ? (bm.min + bm.max) / 2 : 0
|
||||
const net = Math.max(0, grossMid - p.mabPerSqm)
|
||||
const fitOutPerSqm = Math.round(net * annuityFactor(FITOUT_ANNUITY_RATE, FITOUT_AMORTIZATION_YEARS))
|
||||
return { rentPerSqm: p.rentPricePerSqm, fitOutPerSqm, effectivePerSqm: p.rentPricePerSqm + fitOutPerSqm }
|
||||
}
|
||||
|
||||
function formatChfK(value: number): string {
|
||||
if (value >= 1000) return `CHF ${(value / 1000).toLocaleString('de-CH', { minimumFractionDigits: 0, maximumFractionDigits: 1 })} Mio.`
|
||||
return `CHF ${Math.round(value / 1000)}k`
|
||||
@@ -34,6 +68,12 @@ export function calcFitOutInvestment(
|
||||
max: Math.round(netMax * areaSqm),
|
||||
}
|
||||
|
||||
const factor = annuityFactor(FITOUT_ANNUITY_RATE, FITOUT_AMORTIZATION_YEARS)
|
||||
const annualBurden = {
|
||||
min: Math.round(netTotal.min * factor),
|
||||
max: Math.round(netTotal.max * factor),
|
||||
}
|
||||
|
||||
const isFullyCovered = netTotal.max <= 0
|
||||
|
||||
const shortLabel = isFullyCovered
|
||||
@@ -51,6 +91,7 @@ export function calcFitOutInvestment(
|
||||
mabOffset: mabPerSqm,
|
||||
netPerSqm: { min: netMin, max: netMax },
|
||||
netTotal,
|
||||
annualBurden,
|
||||
isFullyCovered,
|
||||
shortLabel,
|
||||
detailLabel,
|
||||
|
||||
@@ -6,9 +6,9 @@ export interface CityIntelligence {
|
||||
purchasingPowerIndex: number // Kaufkraft-Index (CH = 100)
|
||||
dominantIndustryClusters: string[]
|
||||
plannedInfrastructure: { project: string; timeline: string; impact: string }[]
|
||||
medianRentOffice: number // CHF/m² für Bürofläche
|
||||
medianRentLogistics: number
|
||||
medianRentRetail: number
|
||||
medianRentOffice: number // CHF/m²/Monat (Bürofläche) — getMarketRent rechnet auf Jahr um
|
||||
medianRentLogistics: number // CHF/m²/Monat
|
||||
medianRentRetail: number // CHF/m²/Monat
|
||||
avgDaysOnMarket: number // Durchschnittliche Tage bis Vermietung
|
||||
demandStrength: 'LOW' | 'MEDIUM' | 'HIGH' | 'VERY_HIGH'
|
||||
taxIndexCanton: number // Steuerindex 100 = CH-Mittel
|
||||
@@ -137,13 +137,15 @@ export function getCityIntelligence(city: string): CityIntelligence | null {
|
||||
return key ? CITY_INTELLIGENCE[key] : null
|
||||
}
|
||||
|
||||
/** Median-Marktmiete in CHF/m²/**Jahr** (Daten sind monatlich gespeichert → ×12), passend zu property.rentPricePerSqm. */
|
||||
export function getMarketRent(city: string, assetType: string): number | null {
|
||||
const intel = getCityIntelligence(city)
|
||||
if (!intel) return null
|
||||
if (assetType === 'OFFICE') return intel.medianRentOffice
|
||||
if (assetType === 'LOGISTICS' || assetType === 'LIGHT_INDUSTRIAL') return intel.medianRentLogistics
|
||||
if (assetType === 'RETAIL') return intel.medianRentRetail
|
||||
return intel.medianRentOffice
|
||||
const monthly =
|
||||
assetType === 'LOGISTICS' || assetType === 'LIGHT_INDUSTRIAL' ? intel.medianRentLogistics
|
||||
: assetType === 'RETAIL' ? intel.medianRentRetail
|
||||
: intel.medianRentOffice
|
||||
return monthly * 12
|
||||
}
|
||||
|
||||
// ── City coordinates (WGS84) ─────────────────────────────────────────────────
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { getMarketRent, getCityIntelligence } from './locationIntelligence'
|
||||
|
||||
export type RentVerdict = 'BELOW' | 'AT' | 'ABOVE'
|
||||
|
||||
export interface RentEstimate {
|
||||
fairRentPerSqm: number // Median-Marktmiete als faire Benchmark
|
||||
askingRentPerSqm: number
|
||||
verdict: RentVerdict // Angebot vs. Markt
|
||||
deltaPct: number // +über / −unter Markt (gerundet)
|
||||
rationale: string
|
||||
confidence: 'LOW' | 'MEDIUM' | 'HIGH'
|
||||
}
|
||||
|
||||
/**
|
||||
* Markt-Einschätzung der Angebotsmiete: Vergleich gegen die Median-Marktmiete
|
||||
* (aus Standort-Intelligence) plus Leerstand/Miettrend als Kontext. Deterministisch.
|
||||
*/
|
||||
export function estimateMarketRent(city: string, assetType: string, askingRentPerSqm: number): RentEstimate | null {
|
||||
const market = getMarketRent(city, assetType)
|
||||
const intel = getCityIntelligence(city)
|
||||
if (market == null || !intel || askingRentPerSqm <= 0) return null
|
||||
|
||||
const fair = market
|
||||
const deltaPct = Math.round(((askingRentPerSqm - fair) / fair) * 100)
|
||||
const verdict: RentVerdict = deltaPct > 5 ? 'ABOVE' : deltaPct < -5 ? 'BELOW' : 'AT'
|
||||
const trendNote = `Miettrend ${intel.rentTrend12m >= 0 ? '+' : ''}${intel.rentTrend12m}% (12 M)`
|
||||
const verdictText =
|
||||
verdict === 'ABOVE' ? `Angebot ${deltaPct}% über Marktmedian.`
|
||||
: verdict === 'BELOW' ? `Angebot ${Math.abs(deltaPct)}% unter Marktmedian.`
|
||||
: 'Angebot marktkonform.'
|
||||
const rationale = `Median ${city}: CHF ${fair}/m² · Leerstand ${intel.vacancyRatePct}% · ${trendNote}. ${verdictText}`
|
||||
const confidence = intel.demandStrength === 'LOW' ? 'LOW' : intel.avgDaysOnMarket > 70 ? 'MEDIUM' : 'HIGH'
|
||||
|
||||
return { fairRentPerSqm: fair, askingRentPerSqm, verdict, deltaPct, rationale, confidence }
|
||||
}
|
||||
|
||||
/** Indexierter Vorschlag für den künftigen Preis (Pre-Market): Heutepreis × (1 + Miettrend). */
|
||||
export function suggestFutureRent(city: string, currentRentPerSqm: number): number | null {
|
||||
const intel = getCityIntelligence(city)
|
||||
if (!intel || currentRentPerSqm <= 0) return null
|
||||
return Math.round(currentRentPerSqm * (1 + intel.rentTrend12m / 100))
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { Property, PropertyUnit } from '../domain/property'
|
||||
|
||||
export interface ResolvedUnitFacts {
|
||||
fitOut?: 'SHELL' | 'BASIC' | 'FULL' | 'PREMIUM'
|
||||
mabPerSqm: number
|
||||
fitOutByLandlord?: boolean
|
||||
parkingSpots?: number
|
||||
rentPricePerSqm: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Effektive Ausbau-/Preis-/Parkplatz-Werte für eine Einheit: Einheit-Wert ?? Objekt-Wert.
|
||||
* Parkplätze: Einheit-Zuteilung; bei Einzel-Einheit-Objekten der ganze Objekt-Pool als Fallback.
|
||||
*/
|
||||
export function resolveUnitFacts(property: Property, unit?: PropertyUnit | null): ResolvedUnitFacts {
|
||||
const hf = property.hardFacts
|
||||
const singleUnit = (property.units?.length ?? 0) <= 1
|
||||
return {
|
||||
fitOut: unit?.fitOut ?? hf?.fitOut,
|
||||
mabPerSqm: unit?.mieterausbaubeitragPerSqm ?? hf?.mieterausbaubeitragPerSqm ?? 0,
|
||||
fitOutByLandlord: unit?.fitOutByLandlord ?? hf?.fitOutByLandlord,
|
||||
parkingSpots: unit?.parkingSpots ?? (singleUnit ? hf?.parking : undefined),
|
||||
rentPricePerSqm: unit?.rentPricePerSqm ?? property.rentPricePerSqm,
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,8 @@ export const mockDemandInquiries: Inquiry[] = [
|
||||
{
|
||||
id: 'dinq-001',
|
||||
organizationId: 'org-wincasa',
|
||||
tenantOrgId: 'org-mobimo',
|
||||
kind: 'INQUIRY',
|
||||
propertyId: 'prop-technopark',
|
||||
tenantName: 'Admin User',
|
||||
tenantCompany: 'Mobimo Management AG',
|
||||
@@ -39,6 +41,8 @@ export const mockDemandInquiries: Inquiry[] = [
|
||||
{
|
||||
id: 'dinq-002',
|
||||
organizationId: 'org-wincasa',
|
||||
tenantOrgId: 'org-mobimo',
|
||||
kind: 'INQUIRY',
|
||||
propertyId: 'prop-hardturmpark',
|
||||
tenantName: 'Admin User',
|
||||
tenantCompany: 'Mobimo Management AG',
|
||||
@@ -73,6 +77,8 @@ export const mockDemandInquiries: Inquiry[] = [
|
||||
{
|
||||
id: 'dinq-003',
|
||||
organizationId: 'org-wincasa',
|
||||
tenantOrgId: 'org-mobimo',
|
||||
kind: 'INQUIRY',
|
||||
propertyId: 'prop-sihlcity',
|
||||
tenantName: 'Admin User',
|
||||
tenantCompany: 'Mobimo Management AG',
|
||||
@@ -130,6 +136,8 @@ export const mockDemandInquiries: Inquiry[] = [
|
||||
{
|
||||
id: 'dinq-004',
|
||||
organizationId: 'org-wincasa',
|
||||
tenantOrgId: 'org-mobimo',
|
||||
kind: 'INQUIRY',
|
||||
propertyId: 'prop-bahnhofzug',
|
||||
tenantName: 'Admin User',
|
||||
tenantCompany: 'Mobimo Management AG',
|
||||
@@ -186,6 +194,8 @@ export const mockDemandInquiries: Inquiry[] = [
|
||||
{
|
||||
id: 'dinq-005',
|
||||
organizationId: 'org-wincasa',
|
||||
tenantOrgId: 'org-mobimo',
|
||||
kind: 'INQUIRY',
|
||||
propertyId: 'prop-dreispitz',
|
||||
tenantName: 'Admin User',
|
||||
tenantCompany: 'Mobimo Management AG',
|
||||
@@ -238,4 +248,40 @@ export const mockDemandInquiries: Inquiry[] = [
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// 6 — OFFER (Verwaltung → Nachfrager, proaktiv aus Latente Anfragen)
|
||||
{
|
||||
id: 'off-seed-001',
|
||||
organizationId: 'org-wincasa',
|
||||
tenantOrgId: 'org-mobimo',
|
||||
kind: 'OFFER',
|
||||
propertyId: 'prop-001',
|
||||
offeredPropertyIds: ['prop-001', 'prop-093'],
|
||||
tenantName: 'Mobimo Management AG',
|
||||
propertyManagerCompany: 'Wincasa AG',
|
||||
propertyAddress: '2 Objekte im Angebot',
|
||||
subject: 'Passende Büroflächen zu Ihrem Bedarf',
|
||||
message:
|
||||
'Sehr geehrte Damen und Herren,\n\nbasierend auf Ihrem Suchprofil haben wir zwei passende Büroflächen für Sie zusammengestellt. Details finden Sie im beigefügten Angebot.\n\nFreundliche Grüsse\nWincasa AG',
|
||||
status: 'new',
|
||||
unreadCount: 1,
|
||||
isRead: false,
|
||||
createdAt: '2026-05-24T10:00:00Z',
|
||||
updatedAt: '2026-05-24T10:00:00Z',
|
||||
thread: [
|
||||
{
|
||||
id: 'off-seed-001-1',
|
||||
inquiryId: 'off-seed-001',
|
||||
senderType: 'supply_user',
|
||||
senderName: 'Wincasa AG',
|
||||
subject: 'Passende Büroflächen zu Ihrem Bedarf',
|
||||
body:
|
||||
'Sehr geehrte Damen und Herren,\n\nbasierend auf Ihrem Suchprofil haben wir zwei passende Büroflächen für Sie zusammengestellt. Details finden Sie im beigefügten Angebot.\n\nFreundliche Grüsse\nWincasa AG',
|
||||
attachments: [
|
||||
{ id: 'off-att-001', fileName: 'Angebot_Bueroflaechen_Zuerich.pdf', fileType: 'application/pdf', fileSize: 312500, generated: true },
|
||||
],
|
||||
createdAt: '2026-05-24T10:00:00Z',
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
+170
-36
@@ -2,10 +2,10 @@ import { AssetType } from '../domain/enums'
|
||||
import type { Need } from '../domain/need'
|
||||
|
||||
export const mockNeeds: Need[] = [
|
||||
// --- need-001: Innovatech AG — OFFICE Zürich ---
|
||||
// --- need-001: OFFICE Zürich-West ---
|
||||
{
|
||||
id: 'need-001',
|
||||
companyName: 'Innovatech AG',
|
||||
companyName: 'Bürofläche Zürich-West',
|
||||
contactName: 'Sandra Meier',
|
||||
assetType: AssetType.OFFICE,
|
||||
requiredArea: { min: 600, max: 1000 },
|
||||
@@ -38,10 +38,10 @@ export const mockNeeds: Need[] = [
|
||||
updatedAt: '2025-05-01T09:00:00Z',
|
||||
},
|
||||
|
||||
// --- need-002: Schweizer Logistik GmbH — LOGISTICS Basel ---
|
||||
// --- need-002: LOGISTICS Basel ---
|
||||
{
|
||||
id: 'need-002',
|
||||
companyName: 'Schweizer Logistik GmbH',
|
||||
companyName: 'Logistikfläche Basel',
|
||||
contactName: 'Thomas Brun',
|
||||
assetType: AssetType.LOGISTICS,
|
||||
requiredArea: { min: 1500, max: 4000 },
|
||||
@@ -72,10 +72,10 @@ export const mockNeeds: Need[] = [
|
||||
updatedAt: '2025-04-10T11:00:00Z',
|
||||
},
|
||||
|
||||
// --- need-003: Pharma Holding AG — OFFICE Basel ---
|
||||
// --- need-003: OFFICE Basel ---
|
||||
{
|
||||
id: 'need-003',
|
||||
companyName: 'Pharma Holding AG',
|
||||
companyName: 'Bürofläche Basel Repräsentanz',
|
||||
contactName: 'Ursula Schmid',
|
||||
assetType: AssetType.OFFICE,
|
||||
requiredArea: { min: 500, max: 800 },
|
||||
@@ -110,10 +110,10 @@ export const mockNeeds: Need[] = [
|
||||
updatedAt: '2025-05-05T14:00:00Z',
|
||||
},
|
||||
|
||||
// --- need-004: Retailer Zürich AG — RETAIL Zürich ---
|
||||
// --- need-004: RETAIL Zürich ---
|
||||
{
|
||||
id: 'need-004',
|
||||
companyName: 'Retailer Zürich AG',
|
||||
companyName: 'Ladenlokal Zürich Innenstadt',
|
||||
contactName: 'Marco Colombo',
|
||||
assetType: AssetType.RETAIL,
|
||||
requiredArea: { min: 200, max: 500 },
|
||||
@@ -144,10 +144,10 @@ export const mockNeeds: Need[] = [
|
||||
updatedAt: '2025-04-22T08:00:00Z',
|
||||
},
|
||||
|
||||
// --- need-005: TechStart GmbH — OFFICE Zug/Zürich ---
|
||||
// --- need-005: OFFICE Zug/Zürich ---
|
||||
{
|
||||
id: 'need-005',
|
||||
companyName: 'TechStart GmbH',
|
||||
companyName: 'Bürofläche Zug / Zürich',
|
||||
contactName: 'Florian Keller',
|
||||
assetType: AssetType.OFFICE,
|
||||
requiredArea: { min: 300, max: 700 },
|
||||
@@ -179,10 +179,10 @@ export const mockNeeds: Need[] = [
|
||||
updatedAt: '2025-05-08T10:00:00Z',
|
||||
},
|
||||
|
||||
// --- need-006: Lager & Spedition AG — LOGISTICS Winterthur ---
|
||||
// --- need-006: LOGISTICS Winterthur ---
|
||||
{
|
||||
id: 'need-006',
|
||||
companyName: 'Lager & Spedition AG',
|
||||
companyName: 'Lagerfläche Winterthur',
|
||||
contactName: 'Beat Zimmermann',
|
||||
assetType: AssetType.LOGISTICS,
|
||||
requiredArea: { min: 1200, max: 3000 },
|
||||
@@ -209,10 +209,10 @@ export const mockNeeds: Need[] = [
|
||||
updatedAt: '2025-05-09T16:00:00Z',
|
||||
},
|
||||
|
||||
// --- need-007: Creative Studios AG — MIXED Zürich/Bern ---
|
||||
// --- need-007: MIXED Zürich-West ---
|
||||
{
|
||||
id: 'need-007',
|
||||
companyName: 'Creative Studios AG',
|
||||
companyName: 'Gewerbefläche Zürich-West',
|
||||
contactName: 'Nora Hauser',
|
||||
assetType: AssetType.MIXED,
|
||||
requiredArea: { min: 800, max: 1500 },
|
||||
@@ -244,10 +244,10 @@ export const mockNeeds: Need[] = [
|
||||
updatedAt: '2025-05-03T11:00:00Z',
|
||||
},
|
||||
|
||||
// --- need-008: Berner Produzenten GmbH — PRODUCTION Bern ---
|
||||
// --- need-008: PRODUCTION Bern ---
|
||||
{
|
||||
id: 'need-008',
|
||||
companyName: 'Berner Produzenten GmbH',
|
||||
companyName: 'Produktionshalle Bern',
|
||||
contactName: 'Hans Lüthi',
|
||||
assetType: AssetType.PRODUCTION,
|
||||
requiredArea: { min: 2000, max: 4000 },
|
||||
@@ -274,10 +274,10 @@ export const mockNeeds: Need[] = [
|
||||
updatedAt: '2025-04-20T12:00:00Z',
|
||||
},
|
||||
|
||||
// --- need-009: Geneva Commerce SA — RETAIL Genf ---
|
||||
// --- need-009: RETAIL Genf ---
|
||||
{
|
||||
id: 'need-009',
|
||||
companyName: 'Geneva Commerce SA',
|
||||
companyName: 'Commerce Genève Centre',
|
||||
contactName: 'Pierre Dupont',
|
||||
assetType: AssetType.RETAIL,
|
||||
requiredArea: { min: 150, max: 400 },
|
||||
@@ -308,10 +308,10 @@ export const mockNeeds: Need[] = [
|
||||
updatedAt: '2025-04-18T09:00:00Z',
|
||||
},
|
||||
|
||||
// --- need-011: Stadtladen Bern GmbH — RETAIL Bern Innenstadt ---
|
||||
// --- need-011: RETAIL Bern Innenstadt ---
|
||||
{
|
||||
id: 'need-011',
|
||||
companyName: 'Stadtladen Bern GmbH',
|
||||
companyName: 'Ladenlokal Bern Altstadt',
|
||||
contactName: 'Katrin Müller',
|
||||
assetType: AssetType.RETAIL,
|
||||
requiredArea: { min: 200, max: 400 },
|
||||
@@ -342,10 +342,10 @@ export const mockNeeds: Need[] = [
|
||||
updatedAt: '2025-05-18T10:00:00Z',
|
||||
},
|
||||
|
||||
// --- need-010: St.Galler Büros AG — OFFICE St.Gallen ---
|
||||
// --- need-010: OFFICE St.Gallen ---
|
||||
{
|
||||
id: 'need-010',
|
||||
companyName: 'St.Galler Büros AG',
|
||||
companyName: 'Bürofläche St. Gallen',
|
||||
contactName: 'Brigitte Fässler',
|
||||
assetType: AssetType.OFFICE,
|
||||
requiredArea: { min: 400, max: 800 },
|
||||
@@ -377,10 +377,10 @@ export const mockNeeds: Need[] = [
|
||||
updatedAt: '2025-05-06T13:00:00Z',
|
||||
},
|
||||
|
||||
// --- need-uc-a: Renato's Fahrradhändler — Use Case A ---
|
||||
// --- need-uc-a: RETAIL Zürich EG ---
|
||||
{
|
||||
id: 'need-uc-a',
|
||||
companyName: 'Velo City GmbH',
|
||||
companyName: 'Ladenlokal Zürich EG Schaufenster',
|
||||
contactName: 'Renato Marchetti',
|
||||
assetType: AssetType.RETAIL,
|
||||
requiredArea: { min: 120, max: 160 },
|
||||
@@ -411,10 +411,10 @@ export const mockNeeds: Need[] = [
|
||||
updatedAt: '2025-05-22T10:00:00Z',
|
||||
},
|
||||
|
||||
// --- need-uc-b: Renato's Umzugsfirma — Use Case B ---
|
||||
// --- need-uc-b: OFFICE Zürich-West klein ---
|
||||
{
|
||||
id: 'need-uc-b',
|
||||
companyName: 'Alp Transit Umzüge GmbH',
|
||||
companyName: 'Bürofläche Zürich-West klein',
|
||||
contactName: 'Renato Marchetti',
|
||||
assetType: AssetType.OFFICE,
|
||||
requiredArea: { min: 140, max: 200 },
|
||||
@@ -445,10 +445,10 @@ export const mockNeeds: Need[] = [
|
||||
updatedAt: '2025-05-22T10:00:00Z',
|
||||
},
|
||||
|
||||
// --- need-012: Gewerbe Solutions AG — LIGHT_INDUSTRIAL Pratteln ---
|
||||
// --- need-012: LIGHT_INDUSTRIAL Pratteln ---
|
||||
{
|
||||
id: 'need-012',
|
||||
companyName: 'Gewerbe Solutions AG',
|
||||
companyName: 'Gewerbefläche Pratteln',
|
||||
contactName: 'Andreas Weber',
|
||||
assetType: AssetType.LIGHT_INDUSTRIAL,
|
||||
requiredArea: { min: 400, max: 750 },
|
||||
@@ -477,10 +477,10 @@ export const mockNeeds: Need[] = [
|
||||
updatedAt: '2026-03-01T09:00:00Z',
|
||||
},
|
||||
|
||||
// --- need-013: Bern Consulting AG — OFFICE Bern ---
|
||||
// --- need-013: OFFICE Bern ---
|
||||
{
|
||||
id: 'need-013',
|
||||
companyName: 'Bern Consulting AG',
|
||||
companyName: 'Bürofläche Bern Bahnhof',
|
||||
contactName: 'Sabine Gerber',
|
||||
assetType: AssetType.OFFICE,
|
||||
requiredArea: { min: 180, max: 320 },
|
||||
@@ -512,10 +512,10 @@ export const mockNeeds: Need[] = [
|
||||
updatedAt: '2026-04-05T10:00:00Z',
|
||||
},
|
||||
|
||||
// --- need-014: Winterthur Handel GmbH — RETAIL Winterthur ---
|
||||
// --- need-014: RETAIL Winterthur ---
|
||||
{
|
||||
id: 'need-014',
|
||||
companyName: 'Winterthur Handel GmbH',
|
||||
companyName: 'Retailfläche Winterthur',
|
||||
contactName: 'Pascal Brunner',
|
||||
assetType: AssetType.RETAIL,
|
||||
requiredArea: { min: 150, max: 250 },
|
||||
@@ -546,10 +546,10 @@ export const mockNeeds: Need[] = [
|
||||
updatedAt: '2026-03-20T11:00:00Z',
|
||||
},
|
||||
|
||||
// --- need-015: Luzern Advisory GmbH — OFFICE Luzern ---
|
||||
// --- need-015: OFFICE Luzern ---
|
||||
{
|
||||
id: 'need-015',
|
||||
companyName: 'Luzern Advisory GmbH',
|
||||
companyName: 'Bürofläche Luzern',
|
||||
contactName: 'Markus Bucher',
|
||||
assetType: AssetType.OFFICE,
|
||||
requiredArea: { min: 350, max: 600 },
|
||||
@@ -581,10 +581,10 @@ export const mockNeeds: Need[] = [
|
||||
updatedAt: '2026-04-10T14:00:00Z',
|
||||
},
|
||||
|
||||
// --- need-uc-c: Renato's Vermögensverwalter — Use Case C ---
|
||||
// --- need-uc-c: OFFICE Zürich Premium ---
|
||||
{
|
||||
id: 'need-uc-c',
|
||||
companyName: 'Wealth Advisory Partners AG',
|
||||
companyName: 'Repräsentanzbüro Zürich Premium',
|
||||
contactName: 'Renato Marchetti',
|
||||
assetType: AssetType.OFFICE,
|
||||
requiredArea: { min: 450, max: 560 },
|
||||
@@ -616,4 +616,138 @@ export const mockNeeds: Need[] = [
|
||||
createdAt: '2025-05-20T10:00:00Z',
|
||||
updatedAt: '2025-05-22T10:00:00Z',
|
||||
},
|
||||
|
||||
// --- org-mobimo: eigene Suchprofile der Mobimo Management AG ---
|
||||
|
||||
{
|
||||
id: 'need-mob-001',
|
||||
companyName: 'Bürofläche Zürich City',
|
||||
contactName: 'Sandra Koch',
|
||||
assetType: AssetType.OFFICE,
|
||||
requiredArea: { min: 300, max: 600 },
|
||||
preferredLocations: ['Zürich', 'Zürich City', 'Zürich Kreis 1', 'Zürich Kreis 4'],
|
||||
excludedLocations: [],
|
||||
budgetRange: { maxPerSqm: 480, maxMonthlyTotal: 20000, currency: 'CHF' },
|
||||
timing: {
|
||||
earliestMoveIn: '2025-10-01',
|
||||
latestMoveIn: '2026-03-01',
|
||||
contractDurationMonths: 60,
|
||||
flexibleTiming: true,
|
||||
},
|
||||
mustCriteriaText: ['Gute ÖV-Anbindung', 'Repräsentative Lage', 'Klimaanlage'],
|
||||
softFactors: {
|
||||
minPrestige: 70,
|
||||
minAccessibility: 80,
|
||||
requireParking: false,
|
||||
maxPublicTransportMinutes: 8,
|
||||
},
|
||||
weightingProfile: {
|
||||
area: 0.18, location: 0.24, budget: 0.16, timing: 0.12,
|
||||
prestige: 0.10, accessibility: 0.08, expansionPotential: 0.03, flexibility: 0.04,
|
||||
visibility: 0.02, footfall: 0.01, talentAccess: 0.01, esg: 0.01, taxEnvironment: 0.00,
|
||||
},
|
||||
confidenceInCriteria: 0.91,
|
||||
notificationConfig: { enabled: true, minScore: 80, emailAddress: 'sandra.koch@mobimo.ch' },
|
||||
organizationId: 'org-mobimo',
|
||||
createdAt: '2025-05-10T09:00:00Z',
|
||||
updatedAt: '2025-06-01T10:00:00Z',
|
||||
},
|
||||
|
||||
{
|
||||
id: 'need-mob-002',
|
||||
companyName: 'Showroom Bern Innenstadt',
|
||||
contactName: 'Sandra Koch',
|
||||
assetType: AssetType.RETAIL,
|
||||
requiredArea: { min: 150, max: 300 },
|
||||
preferredLocations: ['Bern', 'Bern Innenstadt', 'Bern Gurtengasse', 'Bern Marktgasse'],
|
||||
excludedLocations: [],
|
||||
budgetRange: { maxPerSqm: 380, maxMonthlyTotal: 8000, currency: 'CHF' },
|
||||
timing: {
|
||||
earliestMoveIn: '2025-11-01',
|
||||
latestMoveIn: '2026-06-01',
|
||||
contractDurationMonths: 36,
|
||||
flexibleTiming: false,
|
||||
},
|
||||
mustCriteriaText: ['Schaufenster Erdgeschoss', 'Hoher Publikumsverkehr', 'Liefermöglichkeit'],
|
||||
softFactors: {
|
||||
minPrestige: 65,
|
||||
minAccessibility: 75,
|
||||
requireParking: false,
|
||||
maxPublicTransportMinutes: 5,
|
||||
},
|
||||
weightingProfile: {
|
||||
area: 0.14, location: 0.28, budget: 0.15, timing: 0.10,
|
||||
prestige: 0.08, accessibility: 0.07, expansionPotential: 0.02, flexibility: 0.03,
|
||||
visibility: 0.08, footfall: 0.04, talentAccess: 0.00, esg: 0.01, taxEnvironment: 0.00,
|
||||
},
|
||||
confidenceInCriteria: 0.87,
|
||||
notificationConfig: { enabled: false, minScore: 75 },
|
||||
organizationId: 'org-mobimo',
|
||||
createdAt: '2025-04-20T11:00:00Z',
|
||||
updatedAt: '2025-05-15T09:00:00Z',
|
||||
},
|
||||
|
||||
{
|
||||
id: 'need-mob-003',
|
||||
companyName: 'Lager & Service Schaffhausen',
|
||||
contactName: 'Sandra Koch',
|
||||
assetType: AssetType.LOGISTICS,
|
||||
requiredArea: { min: 800, max: 1500 },
|
||||
preferredLocations: ['Schaffhausen', 'Neuhausen am Rheinfall', 'Thayngen'],
|
||||
excludedLocations: [],
|
||||
budgetRange: { maxPerSqm: 160, currency: 'CHF' },
|
||||
timing: {
|
||||
earliestMoveIn: '2026-01-01',
|
||||
latestMoveIn: '2026-09-01',
|
||||
contractDurationMonths: 48,
|
||||
flexibleTiming: true,
|
||||
},
|
||||
mustCriteriaText: ['Autobahnanschluss < 10 Min', 'Rampe / Ladetor', 'Büroanteil mind. 80 m²'],
|
||||
softFactors: {
|
||||
requireParking: true,
|
||||
},
|
||||
weightingProfile: {
|
||||
area: 0.22, location: 0.20, budget: 0.20, timing: 0.12,
|
||||
prestige: 0.01, accessibility: 0.12, expansionPotential: 0.05, flexibility: 0.03,
|
||||
visibility: 0.01, footfall: 0.00, talentAccess: 0.01, esg: 0.02, taxEnvironment: 0.01,
|
||||
},
|
||||
confidenceInCriteria: 0.83,
|
||||
notificationConfig: { enabled: true, minScore: 85 },
|
||||
organizationId: 'org-mobimo',
|
||||
createdAt: '2025-06-01T08:00:00Z',
|
||||
updatedAt: '2025-06-10T14:00:00Z',
|
||||
},
|
||||
|
||||
{
|
||||
id: 'need-mob-004',
|
||||
companyName: 'Repräsentanzfläche Zürich-West',
|
||||
contactName: 'Sandra Koch',
|
||||
assetType: AssetType.OFFICE,
|
||||
requiredArea: { min: 500, max: 900 },
|
||||
preferredLocations: ['Zürich-West', 'Zürich Kreis 5', 'Zürich Kreis 4', 'Zürich Hardbrücke'],
|
||||
excludedLocations: [],
|
||||
budgetRange: { maxPerSqm: 520, maxMonthlyTotal: 35000, currency: 'CHF' },
|
||||
timing: {
|
||||
earliestMoveIn: '2026-03-01',
|
||||
latestMoveIn: '2026-12-01',
|
||||
contractDurationMonths: 84,
|
||||
flexibleTiming: false,
|
||||
},
|
||||
mustCriteriaText: ['Moderner Neubau oder Kernsanierung', 'Kollaborative Fläche', 'Fahrradabstellplätze', 'Minergie oder LEED-zertifiziert'],
|
||||
softFactors: {
|
||||
minPrestige: 75,
|
||||
minAccessibility: 85,
|
||||
requireParking: false,
|
||||
maxPublicTransportMinutes: 6,
|
||||
},
|
||||
weightingProfile: {
|
||||
area: 0.16, location: 0.22, budget: 0.14, timing: 0.10,
|
||||
prestige: 0.12, accessibility: 0.09, expansionPotential: 0.05, flexibility: 0.04,
|
||||
visibility: 0.02, footfall: 0.01, talentAccess: 0.03, esg: 0.02, taxEnvironment: 0.00,
|
||||
},
|
||||
confidenceInCriteria: 0.93,
|
||||
organizationId: 'org-mobimo',
|
||||
createdAt: '2025-03-15T13:00:00Z',
|
||||
updatedAt: '2025-06-05T11:00:00Z',
|
||||
},
|
||||
]
|
||||
|
||||
@@ -153,7 +153,7 @@ export const mockProperties: Property[] = [
|
||||
parkingSpots: 8,
|
||||
publicTransportMinutes: 5,
|
||||
},
|
||||
hardFacts: { fitOut: 'BASIC' },
|
||||
hardFacts: { fitOut: 'BASIC', fitOutByLandlord: true },
|
||||
floorLevel: 2,
|
||||
expansionPotentialSqm: 200,
|
||||
contractDurationMonths: 48,
|
||||
|
||||
+13
-13
@@ -27,7 +27,7 @@ export const mockReminders: Reminder[] = [
|
||||
schattenmarktEnabled: false,
|
||||
note: 'Mieter hat bisher keine Verlängerungsabsicht signalisiert. Erstkontakt dringend.',
|
||||
activity: [
|
||||
{ at: '2026-04-01T08:00:00Z', by: 'Anna Meier', action: 'CREATED', note: 'Automatisch aus Vertragsdaten erstellt' },
|
||||
{ at: '2026-04-01T08:00:00Z', by: 'System', action: 'CREATED', note: 'Automatisch aus Vertragsdaten erstellt' },
|
||||
{ at: '2026-05-10T14:30:00Z', by: 'Thomas Huber', action: 'NOTED', note: 'Mieter angerufen, kein Rückruf erhalten' },
|
||||
],
|
||||
organizationId: 'org-wincasa',
|
||||
@@ -56,7 +56,7 @@ export const mockReminders: Reminder[] = [
|
||||
schattenmarktEnabled: true,
|
||||
note: 'Break-Option läuft am 01.09 ab — Frist zur Ausübung ist 90 Tage vorher, also bis 03.06.',
|
||||
activity: [
|
||||
{ at: '2026-03-15T09:00:00Z', by: 'Anna Meier', action: 'CREATED' },
|
||||
{ at: '2026-03-15T09:00:00Z', by: 'System', action: 'CREATED', note: 'Automatisch aus Vertragsdaten erstellt' },
|
||||
{ at: '2026-05-05T11:00:00Z', by: 'Anna Meier', action: 'NOTED', note: 'Mieterdossier vorbereitet' },
|
||||
],
|
||||
organizationId: 'org-wincasa',
|
||||
@@ -83,7 +83,7 @@ export const mockReminders: Reminder[] = [
|
||||
schattenmarktEnabled: false,
|
||||
note: 'Gebäudeversicherung läuft am 30.06 ab. Police-Nummer ZG-2022-9912.',
|
||||
activity: [
|
||||
{ at: '2026-04-20T10:00:00Z', by: 'Sandra Wyss', action: 'CREATED' },
|
||||
{ at: '2026-04-20T10:00:00Z', by: 'System', action: 'CREATED', note: 'Automatisch aus Vertragsdaten erstellt' },
|
||||
{ at: '2026-05-12T15:00:00Z', by: 'Sandra Wyss', action: 'NOTED', note: 'Offerte von Mobiliar angefordert' },
|
||||
],
|
||||
organizationId: 'org-wincasa',
|
||||
@@ -140,7 +140,7 @@ export const mockReminders: Reminder[] = [
|
||||
schattenmarktEnabled: true,
|
||||
note: 'Indexierte Mietanpassung per 01.10 möglich. LIK-Index prüfen.',
|
||||
activity: [
|
||||
{ at: '2026-04-05T08:00:00Z', by: 'Anna Meier', action: 'CREATED' },
|
||||
{ at: '2026-04-05T08:00:00Z', by: 'System', action: 'CREATED', note: 'Automatisch aus Vertragsdaten erstellt' },
|
||||
{ at: '2026-05-18T09:00:00Z', by: 'Anna Meier', action: 'NOTED', note: 'LIK-Daten für Q1 2026 abrufbar' },
|
||||
],
|
||||
organizationId: 'org-wincasa',
|
||||
@@ -168,7 +168,7 @@ export const mockReminders: Reminder[] = [
|
||||
schattenmarktEnabled: false,
|
||||
note: 'Frist für Vertragsverhandlung: 9 Monate vor Ablauf. Markt Bern Industrie angespannt.',
|
||||
activity: [
|
||||
{ at: '2026-03-01T08:00:00Z', by: 'Sandra Wyss', action: 'CREATED' },
|
||||
{ at: '2026-03-01T08:00:00Z', by: 'System', action: 'CREATED', note: 'Automatisch aus Vertragsdaten erstellt' },
|
||||
],
|
||||
organizationId: 'org-wincasa',
|
||||
createdAt: '2026-03-01T08:00:00Z',
|
||||
@@ -195,7 +195,7 @@ export const mockReminders: Reminder[] = [
|
||||
schattenmarktEnabled: false,
|
||||
note: 'Einheit noch nicht für Pre-Market freigegeben. 14 Monate Lead Time empfohlen.',
|
||||
activity: [
|
||||
{ at: '2026-04-18T10:00:00Z', by: 'Thomas Huber', action: 'CREATED', note: 'Pre-Market-Freigabe ausstehend' },
|
||||
{ at: '2026-04-18T10:00:00Z', by: 'System', action: 'CREATED', note: 'Automatisch aus Vertragsdaten & Marktdaten erstellt' },
|
||||
{ at: '2026-05-02T11:00:00Z', by: 'Thomas Huber', action: 'NOTED', note: 'Eigentümer informiert, Freigabe ausstehend' },
|
||||
],
|
||||
organizationId: 'org-wincasa',
|
||||
@@ -250,7 +250,7 @@ export const mockReminders: Reminder[] = [
|
||||
snoozedUntil: '2026-05-27',
|
||||
note: 'Mieter erwägt Flächenreduktion. Gespräch vereinbart für 27.05.',
|
||||
activity: [
|
||||
{ at: '2026-04-02T09:00:00Z', by: 'Anna Meier', action: 'CREATED' },
|
||||
{ at: '2026-04-02T09:00:00Z', by: 'System', action: 'CREATED', note: 'Automatisch aus Vertragsdaten erstellt' },
|
||||
{ at: '2026-05-14T16:00:00Z', by: 'Anna Meier', action: 'SNOOZED', note: 'Bis nach Gespräch mit Mieter zurückgestellt' },
|
||||
],
|
||||
organizationId: 'org-wincasa',
|
||||
@@ -279,7 +279,7 @@ export const mockReminders: Reminder[] = [
|
||||
shadowMarketRisk: ShadowMarketRisk.LOW,
|
||||
schattenmarktEnabled: true,
|
||||
activity: [
|
||||
{ at: '2026-03-20T10:00:00Z', by: 'Sandra Wyss', action: 'CREATED' },
|
||||
{ at: '2026-03-20T10:00:00Z', by: 'System', action: 'CREATED', note: 'Automatisch aus Vertragsdaten erstellt' },
|
||||
],
|
||||
organizationId: 'org-wincasa',
|
||||
createdAt: '2026-03-20T10:00:00Z',
|
||||
@@ -306,7 +306,7 @@ export const mockReminders: Reminder[] = [
|
||||
schattenmarktEnabled: false,
|
||||
note: 'Erstgespräch über Verlängerung bis 01.07 einleiten.',
|
||||
activity: [
|
||||
{ at: '2026-03-10T08:00:00Z', by: 'Thomas Huber', action: 'CREATED' },
|
||||
{ at: '2026-03-10T08:00:00Z', by: 'System', action: 'CREATED', note: 'Automatisch aus Vertragsdaten erstellt' },
|
||||
{ at: '2026-05-02T09:00:00Z', by: 'Thomas Huber', action: 'NOTED', note: 'Vermieterseite wünscht Mietpreiserhöhung +5%' },
|
||||
],
|
||||
organizationId: 'org-wincasa',
|
||||
@@ -359,7 +359,7 @@ export const mockReminders: Reminder[] = [
|
||||
schattenmarktEnabled: false,
|
||||
note: 'Pre-Market Freigabe 9 Monate vor Vertragsende. Eigentümer-Freigabe einholen.',
|
||||
activity: [
|
||||
{ at: '2026-04-25T11:00:00Z', by: 'Anna Meier', action: 'CREATED' },
|
||||
{ at: '2026-04-25T11:00:00Z', by: 'System', action: 'CREATED', note: 'Automatisch aus Vertragsdaten & Marktdaten erstellt' },
|
||||
],
|
||||
organizationId: 'org-wincasa',
|
||||
createdAt: '2026-04-25T11:00:00Z',
|
||||
@@ -415,7 +415,7 @@ export const mockReminders: Reminder[] = [
|
||||
shadowMarketRisk: ShadowMarketRisk.MEDIUM,
|
||||
schattenmarktEnabled: true,
|
||||
activity: [
|
||||
{ at: '2026-02-01T08:00:00Z', by: 'Anna Meier', action: 'CREATED', note: 'Vertragsablauf-Erinnerung (4 Monate)' },
|
||||
{ at: '2026-02-01T08:00:00Z', by: 'System', action: 'CREATED', note: 'Automatisch aus Vertragsdaten erstellt' },
|
||||
],
|
||||
organizationId: 'org-wincasa',
|
||||
createdAt: '2026-02-01T08:00:00Z',
|
||||
@@ -440,7 +440,7 @@ export const mockReminders: Reminder[] = [
|
||||
shadowMarketRisk: ShadowMarketRisk.NONE,
|
||||
schattenmarktEnabled: false,
|
||||
activity: [
|
||||
{ at: '2026-04-20T08:00:00Z', by: 'Sandra Wyss', action: 'CREATED' },
|
||||
{ at: '2026-04-20T08:00:00Z', by: 'System', action: 'CREATED', note: 'Automatisch aus Vertragsdaten erstellt' },
|
||||
],
|
||||
organizationId: 'org-wincasa',
|
||||
createdAt: '2026-04-20T08:00:00Z',
|
||||
@@ -522,7 +522,7 @@ export const mockReminders: Reminder[] = [
|
||||
schattenmarktEnabled: false,
|
||||
note: 'Mietpreisanpassung +2.8% vereinbart, ab 01.09 gültig.',
|
||||
activity: [
|
||||
{ at: '2026-02-15T08:00:00Z', by: 'Anna Meier', action: 'CREATED' },
|
||||
{ at: '2026-02-15T08:00:00Z', by: 'System', action: 'CREATED', note: 'Automatisch aus Vertragsdaten erstellt' },
|
||||
{ at: '2026-04-14T11:00:00Z', by: 'Anna Meier', action: 'NOTED', note: 'Mieter hat Anpassung akzeptiert' },
|
||||
{ at: '2026-04-15T14:00:00Z', by: 'Anna Meier', action: 'COMPLETED', note: 'Nachtrag unterzeichnet' },
|
||||
],
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useRef, useState } from 'react'
|
||||
import { Box, Typography } from '@mui/material'
|
||||
import { useNavigate } from 'react-router'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Box, Tab, Tabs, Typography } from '@mui/material'
|
||||
import { useLocation, useNavigate } from 'react-router'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
NeedBuilderProgress,
|
||||
@@ -11,12 +11,15 @@ import {
|
||||
} from '../../components/demand'
|
||||
import { AISearchActionBar } from '../../components/demand/AISearchActionBar'
|
||||
import { AISearchSavePreview } from '../../components/demand/AISearchSavePreview'
|
||||
import { SavedProfilesTab } from '../../components/demand/SavedProfilesTab'
|
||||
import { AddToPipelineDialog } from '../../components/shortlist'
|
||||
import { InquiryQuickDialog } from '../../components/match-detail/InquiryQuickDialog'
|
||||
import { useParseNeed } from '../../hooks/useAI'
|
||||
import { useCreateNeed } from '../../hooks/useNeeds'
|
||||
import { useCreateNeed, useNeed, useNeedProfiles } from '../../hooks/useNeeds'
|
||||
import { useDefaultWeights } from '../../hooks/useWeighting'
|
||||
import { NeedBuilderStep } from '../../domain/needBuilder'
|
||||
import type { ParseNeedResult, ParsedNeedCriteria, WeightingKey } from '../../domain/needBuilder'
|
||||
import { generateSummary, buildNeedInput } from '../../services/aiSearch/needSearchMapper'
|
||||
import { generateSummary, buildNeedInput, needToParsedCriteria } from '../../services/aiSearch/needSearchMapper'
|
||||
|
||||
type ActionIntent = 'search' | 'save-profile'
|
||||
|
||||
@@ -41,8 +44,25 @@ export default function AISearch() {
|
||||
const [isAnonymous, setIsAnonymous] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const [tab, setTab] = useState(0)
|
||||
const { data: savedProfiles = [] } = useNeedProfiles()
|
||||
|
||||
const locationPrefillId = (useLocation().state as { prefillNeedId?: string } | null)?.prefillNeedId
|
||||
const [internalPrefillId, setInternalPrefillId] = useState<string | undefined>()
|
||||
const prefillNeedId = internalPrefillId ?? locationPrefillId
|
||||
const { data: prefillNeed } = useNeed(prefillNeedId ?? '')
|
||||
|
||||
const isManualTextRef = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!prefillNeed) return
|
||||
setCriteria(needToParsedCriteria(prefillNeed))
|
||||
setWeights(prefillNeed.weightingProfile as Record<WeightingKey, number>)
|
||||
setNeedTitle(prefillNeed.companyName)
|
||||
setWeightingKey(k => k + 1)
|
||||
setTab(0)
|
||||
}, [prefillNeed])
|
||||
|
||||
function handleCriteriaChange(next: ParsedNeedCriteria) {
|
||||
setCriteria(next)
|
||||
if (!isManualTextRef.current) {
|
||||
@@ -56,7 +76,6 @@ export default function AISearch() {
|
||||
isManualTextRef.current = text !== ''
|
||||
setIsAutoGen(false)
|
||||
setInputText(text)
|
||||
// Clear stale parse results when user edits the text manually
|
||||
setCriteria({})
|
||||
setParseResult(null)
|
||||
}
|
||||
@@ -198,30 +217,38 @@ export default function AISearch() {
|
||||
const isProcessing = step === NeedBuilderStep.PARSING || step === NeedBuilderStep.SAVING
|
||||
const isSaveStep = step === NeedBuilderStep.READY_TO_SAVE || step === NeedBuilderStep.SAVING
|
||||
|
||||
const overallConfidence = parseResult
|
||||
? (() => {
|
||||
const entries = Object.entries(parseResult.confidenceByField)
|
||||
return entries.length > 0 ? entries.reduce((s, [, v]) => s + v, 0) / entries.length : 0
|
||||
})()
|
||||
: 0
|
||||
const vals = parseResult ? Object.values(parseResult.confidenceByField) : []
|
||||
const overallConfidence = vals.length > 0 ? vals.reduce((s, v) => s + v, 0) / vals.length : 0
|
||||
|
||||
const savedCount = savedProfiles.filter(n => !n.status || n.status === 'ACTIVE' || n.status === 'DRAFT').length
|
||||
|
||||
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>
|
||||
<AddToPipelineDialog />
|
||||
<InquiryQuickDialog />
|
||||
<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>
|
||||
</Box>
|
||||
|
||||
<NeedBuilderProgress step={step} />
|
||||
<Tabs
|
||||
value={tab}
|
||||
onChange={(_, v: number) => setTab(v)}
|
||||
sx={{ borderBottom: '1px solid #e2e8f0', px: 3, bgcolor: 'white', flexShrink: 0, minHeight: 44 }}
|
||||
>
|
||||
<Tab label="Neue Suche" sx={{ textTransform: 'none', fontWeight: 600, fontSize: '0.875rem', minHeight: 44, py: 0 }} />
|
||||
<Tab label={`Gespeicherte Profile (${savedCount})`} sx={{ textTransform: 'none', fontWeight: 600, fontSize: '0.875rem', minHeight: 44, py: 0 }} />
|
||||
</Tabs>
|
||||
|
||||
{tab === 0 && (
|
||||
<>
|
||||
<NeedBuilderProgress step={step} />
|
||||
<Box sx={{ flex: 1, overflowY: 'auto', px: 3, py: 3 }}>
|
||||
|
||||
{/* IDLE: full form */}
|
||||
{(step === NeedBuilderStep.IDLE || step === NeedBuilderStep.PARSING) && (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, maxWidth: 1200, mx: 'auto' }}>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, maxWidth: { md: 900, xl: 1200 }, mx: 'auto' }}>
|
||||
<VoiceNeedInput
|
||||
text={inputText}
|
||||
onTextChange={handleTextChange}
|
||||
@@ -248,7 +275,6 @@ export default function AISearch() {
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Save preview step */}
|
||||
{isSaveStep && parseResult && editedCriteria && (
|
||||
<AISearchSavePreview
|
||||
criteria={editedCriteria}
|
||||
@@ -265,11 +291,18 @@ export default function AISearch() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{step === NeedBuilderStep.ERROR && (
|
||||
<NeedBuilderErrorState message={error ?? 'Unbekannter Fehler'} onRetry={handleRetry} />
|
||||
)}
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
|
||||
{tab === 1 && (
|
||||
<Box sx={{ flex: 1, overflow: 'hidden' }}>
|
||||
<SavedProfilesTab onNewSearch={(id) => { setInternalPrefillId(id); setTab(0) }} />
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5,10 +5,9 @@ import {
|
||||
InputAdornment, Alert,
|
||||
} from '@mui/material'
|
||||
import { Search, Send, Paperclip, ArrowLeft, Bot, Building2, Kanban } from 'lucide-react'
|
||||
import { mockDemandInquiries } from '../../mock-data/demandInquiries'
|
||||
import { useInquiryStore } from '../../stores/inquiryStore'
|
||||
import { useActiveInquiries, useSendInquiryReply, useMarkThreadAsRead } from '../../hooks/useInquiries'
|
||||
import { usePipelineItems, useMoveStage } from '../../hooks/usePipeline'
|
||||
import type { InquiryMessage } from '../../domain/inquiry'
|
||||
import { mockProperties } from '../../mock-data/properties'
|
||||
import { STAGE_ORDER, STAGE_LABELS, detectKiStage } from './anfragenKiDetection'
|
||||
import { AnfragenMessageBubble } from '../../components/demand/AnfragenMessageBubble'
|
||||
import { AnfragenInquiryItem } from '../../components/demand/AnfragenInquiryItem'
|
||||
@@ -19,9 +18,8 @@ import { useSessionStore } from '../../stores/sessionStore'
|
||||
|
||||
const FILTER_TABS = [
|
||||
{ key: 'all', label: 'Alle' },
|
||||
{ key: 'new', label: 'Neu' },
|
||||
{ key: 'in_progress', label: 'Aktiv' },
|
||||
{ key: 'answered', label: 'Beantwortet' },
|
||||
{ key: 'INQUIRY', label: 'Gesendet' },
|
||||
{ key: 'OFFER', label: 'Erhalten' },
|
||||
]
|
||||
|
||||
// ── Anfragen page ─────────────────────────────────────────────────────────────
|
||||
@@ -36,20 +34,23 @@ export default function Anfragen() {
|
||||
const preselectedId = searchParams.get('inquiry')
|
||||
|
||||
const { currentUser } = useSessionStore()
|
||||
const storeInquiries = useInquiryStore(s => s.sentInquiries)
|
||||
const [inquiries, setInquiries] = useState(mockDemandInquiries)
|
||||
const allInquiries = [...storeInquiries, ...inquiries]
|
||||
const { data: allInquiries = [] } = useActiveInquiries({ tenantOrgId: currentUser?.organizationId })
|
||||
const sendReply = useSendInquiryReply()
|
||||
const markRead = useMarkThreadAsRead()
|
||||
|
||||
const [selectedId, setSelectedId] = useState<string | null>(
|
||||
preselectedId ?? mockDemandInquiries[0]?.id ?? null
|
||||
)
|
||||
const [selectedId, setSelectedId] = useState<string | null>(preselectedId ?? null)
|
||||
const [search, setSearch] = useState('')
|
||||
const [statusFilter, setStatusFilter] = useState('all')
|
||||
const [kindFilter, setKindFilter] = useState('all')
|
||||
const [replyText, setReplyText] = useState('')
|
||||
const [mobileShowChat, setMobileShowChat] = useState(!!preselectedId)
|
||||
const [kiAlert, setKiAlert] = useState<{ title: string; stage: string } | null>(null)
|
||||
const threadRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Default-Auswahl: erste Konversation, sobald geladen
|
||||
useEffect(() => {
|
||||
if (!selectedId && allInquiries.length > 0) setSelectedId(allInquiries[0].id)
|
||||
}, [allInquiries, selectedId])
|
||||
|
||||
const filtered = allInquiries.filter(inq => {
|
||||
const q = search.toLowerCase()
|
||||
const matchesSearch = !q ||
|
||||
@@ -59,12 +60,14 @@ export default function Anfragen() {
|
||||
(inq.propertyAddress?.toLowerCase().includes(q) ?? false) ||
|
||||
(inq.propertyManagerName?.toLowerCase().includes(q) ?? false) ||
|
||||
(inq.propertyManagerCompany?.toLowerCase().includes(q) ?? false)
|
||||
const matchesStatus = statusFilter === 'all' || inq.status === statusFilter
|
||||
return matchesSearch && matchesStatus
|
||||
const matchesKind = kindFilter === 'all' || (inq.kind ?? 'INQUIRY') === kindFilter
|
||||
return matchesSearch && matchesKind
|
||||
})
|
||||
|
||||
const selected = allInquiries.find(i => i.id === selectedId) ?? null
|
||||
const totalUnread = allInquiries.reduce((sum, i) => sum + i.unreadCount, 0)
|
||||
const offeredProperties = (selected?.kind === 'OFFER' ? selected.offeredPropertyIds ?? [] : [])
|
||||
.map(id => mockProperties.find(p => p.id === id)).filter(Boolean)
|
||||
|
||||
// Pipeline link for currently selected inquiry
|
||||
const linkedPipelineItem = selected?.propertyId
|
||||
@@ -86,7 +89,7 @@ export default function Anfragen() {
|
||||
|
||||
function handleSelect(id: string) {
|
||||
setSelectedId(id)
|
||||
setInquiries(prev => prev.map(i => i.id === id ? { ...i, isRead: true, unreadCount: 0 } : i))
|
||||
markRead.mutate(id)
|
||||
setMobileShowChat(true)
|
||||
setKiAlert(null)
|
||||
}
|
||||
@@ -95,20 +98,10 @@ export default function Anfragen() {
|
||||
if (!replyText.trim() || !selectedId) return
|
||||
const text = replyText.trim()
|
||||
|
||||
const msg: InquiryMessage = {
|
||||
id: `msg-${Date.now()}`,
|
||||
sendReply.mutate({
|
||||
inquiryId: selectedId,
|
||||
senderType: 'tenant',
|
||||
senderName: 'Sie',
|
||||
body: text,
|
||||
attachments: [],
|
||||
createdAt: new Date().toISOString(),
|
||||
}
|
||||
setInquiries(prev => prev.map(i =>
|
||||
i.id === selectedId
|
||||
? { ...i, thread: [...i.thread, msg], status: 'in_progress', updatedAt: new Date().toISOString() }
|
||||
: i
|
||||
))
|
||||
payload: { body: text, senderType: 'tenant', senderName: currentUser?.name ?? 'Sie' },
|
||||
})
|
||||
setReplyText('')
|
||||
|
||||
// KI: detect stage transition from message content
|
||||
@@ -134,8 +127,8 @@ export default function Anfragen() {
|
||||
{/* ── Left panel ── */}
|
||||
<Box
|
||||
sx={{
|
||||
width: { xs: mobileShowChat ? 0 : '100%', md: 320 },
|
||||
minWidth: { md: 320 },
|
||||
width: { xs: mobileShowChat ? 0 : '100%', md: 280, lg: 300, xl: 320 },
|
||||
minWidth: { md: 280, lg: 300, xl: 320 },
|
||||
flexShrink: 0,
|
||||
display: 'flex', flexDirection: 'column',
|
||||
borderRight: `1px solid ${DS_BORDER.default}`,
|
||||
@@ -160,13 +153,13 @@ export default function Anfragen() {
|
||||
/>
|
||||
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap' }}>
|
||||
{FILTER_TABS.map(tab => (
|
||||
<Chip key={tab.key} label={tab.label} size="small" onClick={() => setStatusFilter(tab.key)}
|
||||
<Chip key={tab.key} label={tab.label} size="small" onClick={() => setKindFilter(tab.key)}
|
||||
sx={{
|
||||
height: 22, fontSize: '0.7rem', cursor: 'pointer',
|
||||
bgcolor: statusFilter === tab.key ? 'primary.main' : DS_BG.subtle,
|
||||
color: statusFilter === tab.key ? 'white' : DS_TEXT.secondary,
|
||||
fontWeight: statusFilter === tab.key ? 700 : 400,
|
||||
'&:hover': { bgcolor: statusFilter === tab.key ? 'primary.dark' : DS_BG.muted },
|
||||
bgcolor: kindFilter === tab.key ? 'primary.main' : DS_BG.subtle,
|
||||
color: kindFilter === tab.key ? 'white' : DS_TEXT.secondary,
|
||||
fontWeight: kindFilter === tab.key ? 700 : 400,
|
||||
'&:hover': { bgcolor: kindFilter === tab.key ? 'primary.dark' : DS_BG.muted },
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
@@ -291,6 +284,27 @@ export default function Anfragen() {
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Angebot: enthaltene Objekte */}
|
||||
{offeredProperties.length > 0 && (
|
||||
<Box sx={{ px: { xs: 2, md: 3 }, pt: 1.5, flexShrink: 0 }}>
|
||||
<Typography variant="caption" sx={{ color: DS_TEXT.muted, fontWeight: 700, textTransform: 'uppercase', letterSpacing: 0.4, display: 'block', mb: 0.75 }}>
|
||||
Enthaltene Objekte ({offeredProperties.length})
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
|
||||
{offeredProperties.map(p => p && (
|
||||
<Chip
|
||||
key={p.id}
|
||||
icon={<Building2 size={12} />}
|
||||
label={`${p.title} · CHF ${p.rentPricePerSqm}/m²`}
|
||||
size="small"
|
||||
onClick={() => navigate(`/demand/property/${p.id}`)}
|
||||
sx={{ cursor: 'pointer', bgcolor: DS_SURFACE.blue.bg, color: DS_TEXT.signalDark, border: `1px solid ${DS_SURFACE.blue.border}`, fontSize: '0.72rem', '& .MuiChip-icon': { color: DS_TEXT.signalDark } }}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Thread */}
|
||||
<Box ref={threadRef} sx={{ flex: 1, overflowY: 'auto', px: { xs: 2, md: 3 }, py: 2.5, display: 'flex', flexDirection: 'column', gap: 0.5 }}>
|
||||
{selected.thread.map(msg => (
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -8,7 +8,7 @@ import { useInquiryStore } from '../../stores/inquiryStore'
|
||||
import { AddToPipelineDialog } from '../../components/shortlist'
|
||||
import { MatchReasonList } from '../../components/match-card/MatchReasonList'
|
||||
import { getCityIntelligence, lookupCityCoords } from '../../lib/locationIntelligence'
|
||||
import { NextActionsPanel, FitOutCostPanel } from '../../components/match-detail'
|
||||
import { NextActionsPanel, FitOutCostPanel, FitOutAdvicePanel, MarketPricePanel } from '../../components/match-detail'
|
||||
import type { MatchCardReason } from '../../components/match-card/MatchCardViewModel'
|
||||
import { useMatchDetailData } from '../../hooks/useMatchDetailData'
|
||||
import { useMatchDetail } from '../../hooks/useMatches'
|
||||
@@ -39,6 +39,7 @@ export default function MatchDetail() {
|
||||
const { openInquiryDialog } = useInquiryStore()
|
||||
|
||||
const { match, property, need, signal, isLoading, isFuture } = useMatchDetailData(matchId ?? '')
|
||||
const detailUnit = property?.units?.find(u => u.id === match?.unitId)
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
@@ -171,7 +172,7 @@ export default function MatchDetail() {
|
||||
/>
|
||||
|
||||
{/* ── Main content ── */}
|
||||
<Box sx={{ maxWidth: { sm: 780, md: 960, lg: 1100 }, mx: 'auto', px: { xs: 1.5, sm: 3 }, py: { xs: 2, sm: 3 }, display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<Box sx={{ maxWidth: { sm: 700, md: 860, lg: 1000, xl: 1100 }, mx: 'auto', px: { xs: 1.5, sm: 3 }, py: { xs: 2, sm: 3 }, display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
|
||||
{/* ── Section A: Warum dieser Match? ── */}
|
||||
<Paper sx={{ p: 2.5 }}>
|
||||
@@ -220,7 +221,31 @@ export default function MatchDetail() {
|
||||
areaSqm={property.areaSqm}
|
||||
mabPerSqm={property.hardFacts.mieterausbaubeitragPerSqm ?? 0}
|
||||
rentPricePerSqm={property.rentPricePerSqm}
|
||||
tenantBudgetPerSqm={need?.fitOutBudgetMaxPerSqm ?? 0}
|
||||
fitOutByLandlord={property.hardFacts.fitOutByLandlord}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ── KI-Ausbauempfehlung: nur wenn Mieter ausbaut (SHELL/BASIC, kein Vermieter-Übernahme) ── */}
|
||||
{!isFuture && property?.hardFacts?.fitOut
|
||||
&& !property.hardFacts.fitOutByLandlord
|
||||
&& (property.hardFacts.fitOut === 'SHELL' || property.hardFacts.fitOut === 'BASIC') && (
|
||||
<FitOutAdvicePanel
|
||||
fitOut={property.hardFacts.fitOut}
|
||||
areaSqm={property.areaSqm}
|
||||
mabPerSqm={property.hardFacts.mieterausbaubeitragPerSqm ?? 0}
|
||||
rentPricePerSqm={property.rentPricePerSqm}
|
||||
requiredFitOut={need?.requiredFitOut}
|
||||
tenantBudgetPerSqm={need?.fitOutBudgetMaxPerSqm}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ── Marktpreis-Einschätzung (Angebot vs. Markt; bei Pre-Market inkl. Zukunftspreis) ── */}
|
||||
{property?.location?.city && property.rentPricePerSqm > 0 && (
|
||||
<MarketPricePanel
|
||||
city={property.location.city}
|
||||
assetType={property.assetType}
|
||||
askingRentPerSqm={detailUnit?.rentPricePerSqm ?? property.rentPricePerSqm}
|
||||
futureRentPerSqm={isFuture ? detailUnit?.expectedRentPerSqm : undefined}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -73,11 +73,11 @@ export default function Pipeline() {
|
||||
|
||||
{/* Header */}
|
||||
<Box sx={{
|
||||
bgcolor: 'white', borderBottom: '1px solid #e2e8f0',
|
||||
px: 3, py: 2, display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexShrink: 0,
|
||||
bgcolor: 'white', borderBottom: '1px solid #e8e7e4',
|
||||
px: 3, py: 2.5, display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexShrink: 0,
|
||||
}}>
|
||||
<Box>
|
||||
<Typography variant="h5" sx={{ fontWeight: 700 }}>Deal Pipeline</Typography>
|
||||
<Typography sx={{ fontWeight: 700, fontSize: '1.125rem', lineHeight: 1.3, color: '#0f172a' }}>Deal Pipeline</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Von der ersten Idee bis zum Abschluss — per Drag & Drop verschieben
|
||||
</Typography>
|
||||
@@ -109,7 +109,7 @@ export default function Pipeline() {
|
||||
return (
|
||||
<Box
|
||||
key={stage.key}
|
||||
sx={{ minWidth: syncedSelected ? 190 : 230, maxWidth: syncedSelected ? 230 : 270, flexShrink: 0, display: 'flex', flexDirection: 'column' }}
|
||||
sx={{ minWidth: syncedSelected ? 170 : 200, maxWidth: syncedSelected ? 210 : 260, flexShrink: 0, display: 'flex', flexDirection: 'column' }}
|
||||
>
|
||||
<Box sx={{ py: 1, display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Typography sx={{ fontWeight: 700, color: stage.color, fontSize: '0.8125rem' }}>
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { useState, useMemo, useEffect } from 'react'
|
||||
import { Box, Button, Card, Chip, Typography } from '@mui/material'
|
||||
import { Box, Button, Typography } from '@mui/material'
|
||||
import { Zap } from 'lucide-react'
|
||||
import { useNavigate, useLocation } from 'react-router'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { useUnifiedResults } from '../../hooks/useUnifiedResults'
|
||||
import { useNeeds } from '../../hooks/useNeeds'
|
||||
import { DecisionContextPanel } from '../../components/ui'
|
||||
import {
|
||||
FeedEmptyState,
|
||||
FeedSkeleton,
|
||||
@@ -72,16 +71,14 @@ export default function Results() {
|
||||
const isStaff = currentUser?.role === 'PROPERTY_MANAGER' || currentUser?.role === 'ORGANIZATION_ADMIN'
|
||||
|
||||
// Counts over the full result set — single pass, stable across filter changes
|
||||
const { platformCount, maisonWorkCount, futureCount, missingDataCount } = useMemo(() => {
|
||||
let platform = 0, maison = 0, future = 0, missing = 0
|
||||
const { platformCount, maisonWorkCount, futureCount } = useMemo(() => {
|
||||
let platform = 0, maison = 0, future = 0
|
||||
for (const r of results) {
|
||||
if (r.resultType === 'VERIFIED_PORTFOLIO') platform++
|
||||
else if (r.resultType === 'MAISON_WORK') maison++
|
||||
else if (r.resultType === 'FUTURE_AVAILABILITY') future++
|
||||
if ('match' in r && Array.isArray((r as { match?: { missingData?: unknown[] } }).match?.missingData) &&
|
||||
((r as { match?: { missingData?: unknown[] } }).match?.missingData?.length ?? 0) > 0) missing++
|
||||
}
|
||||
return { platformCount: platform, maisonWorkCount: maison, futureCount: future, missingDataCount: missing }
|
||||
return { platformCount: platform, maisonWorkCount: maison, futureCount: future }
|
||||
}, [results])
|
||||
|
||||
// Filter + sort in one memo — only reruns when inputs change
|
||||
@@ -98,7 +95,6 @@ export default function Results() {
|
||||
return sortResults(filtered, sortBy)
|
||||
}, [results, filterSource, sortBy, showFutureAvailability, showOwnProperties])
|
||||
|
||||
const strongCount = useMemo(() => sorted.filter(r => r.matchScore >= 80).length, [sorted])
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
||||
@@ -113,103 +109,27 @@ export default function Results() {
|
||||
onViewChange={v => { setView(v); localStorage.setItem('view-results', v) }}
|
||||
/>
|
||||
|
||||
{!isLoading && results.length > 0 && (
|
||||
<DecisionContextPanel
|
||||
decision="Welche Treffer lohnen sich für die Shortlist — und welche tragen Risiken, die zuerst geprüft werden müssen?"
|
||||
context={activeNeed ? `Suche: ${activeNeed.assetType} · ${activeNeed.requiredArea.min}–${activeNeed.requiredArea.max} m² · ${activeNeed.preferredLocations.join(', ')}` : undefined}
|
||||
metrics={[
|
||||
...(strongCount > 0 ? [{ label: 'starke Treffer (≥80)', value: strongCount, severity: 'positive' as const }] : []),
|
||||
...(maisonWorkCount > 0 ? [{ label: 'Maison Work', value: maisonWorkCount, severity: 'neutral' as const }] : []),
|
||||
...(futureCount > 0 ? [{ label: 'Zukunftssignale', value: futureCount, severity: 'neutral' as const }] : []),
|
||||
...(missingDataCount > 0 ? [{ label: 'mit Datenlücken', value: missingDataCount, severity: 'warning' as const }] : []),
|
||||
]}
|
||||
risks={[
|
||||
...(missingDataCount > 0 ? [`${missingDataCount} Treffer mit fehlenden Daten — Einschätzung eingeschränkt`] : []),
|
||||
]}
|
||||
actions={[
|
||||
{ label: 'Vergleich öffnen', onClick: () => navigate('/demand/compare') },
|
||||
{ label: 'Suche anpassen', onClick: () => navigate('/demand/ai-search') },
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Box sx={{ flex: 1, overflowY: 'auto', px: 3, py: 3, pb: 10 }}>
|
||||
|
||||
{/* Aktive Suche */}
|
||||
{activeNeed && (
|
||||
<Card sx={{ bgcolor: '#eff6ff', p: 2, mb: 2, border: '1px solid #bfdbfe' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 2 }}>
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 0.5 }} color="#152642">
|
||||
Aktive Suche: {activeNeed.companyName}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: '4px 16px' }}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
<strong>Typ:</strong> {activeNeed.assetType}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
<strong>Fläche:</strong> {activeNeed.requiredArea.min}–{activeNeed.requiredArea.max} m²
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
<strong>Standort:</strong> {activeNeed.preferredLocations.join(', ')}
|
||||
</Typography>
|
||||
{activeNeed.budgetRange && activeNeed.budgetRange.maxPerSqm > 0 && (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
<strong>Budget:</strong> max. CHF {activeNeed.budgetRange.maxPerSqm}/m²/Jahr
|
||||
</Typography>
|
||||
)}
|
||||
{activeNeed.timing?.earliestMoveIn && !isNaN(new Date(activeNeed.timing.earliestMoveIn).getTime()) && (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
<strong>Bezug ab:</strong> {new Date(activeNeed.timing.earliestMoveIn).toLocaleDateString('de-CH', { month: 'long', year: 'numeric' })}
|
||||
</Typography>
|
||||
)}
|
||||
{activeNeed.searchRadius && (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
<strong>Radius:</strong> {activeNeed.searchRadius} km
|
||||
</Typography>
|
||||
)}
|
||||
{activeNeed.requiresDivisibility && activeNeed.minDivisibleUnit && (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
<strong>Teilbar ab:</strong> {activeNeed.minDivisibleUnit} m²
|
||||
</Typography>
|
||||
)}
|
||||
{activeNeed.requiredFitOut && (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
<strong>Ausbaustandard:</strong> min. {
|
||||
activeNeed.requiredFitOut === 'BASIC' ? 'Basisausbau' :
|
||||
activeNeed.requiredFitOut === 'FULL' ? 'Vollausbau' : 'Premiumausbau'
|
||||
}
|
||||
</Typography>
|
||||
)}
|
||||
{activeNeed.fitOutBudgetMaxPerSqm && (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
<strong>Ausbaubudget:</strong> max. CHF {activeNeed.fitOutBudgetMaxPerSqm}/m²
|
||||
</Typography>
|
||||
)}
|
||||
{activeNeed.mustCriteriaText && activeNeed.mustCriteriaText.length > 0 && (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
<strong>Must-haves:</strong> {activeNeed.mustCriteriaText.slice(0, 3).join(' · ')}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 2, bgcolor: '#eff6ff', border: '1px solid #bfdbfe', borderRadius: 1.5, px: 2, py: 1.25, mb: 2 }}>
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: '4px 16px', alignItems: 'center' }}>
|
||||
<Typography variant="caption" sx={{ fontWeight: 700, color: '#152642' }}>
|
||||
{activeNeed.companyName}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">{activeNeed.assetType}</Typography>
|
||||
<Typography variant="caption" color="text.secondary">{activeNeed.requiredArea.min}–{activeNeed.requiredArea.max} m²</Typography>
|
||||
<Typography variant="caption" color="text.secondary">{activeNeed.preferredLocations.join(', ')}</Typography>
|
||||
{activeNeed.budgetRange?.maxPerSqm > 0 && (
|
||||
<Typography variant="caption" color="text.secondary">max. CHF {activeNeed.budgetRange.maxPerSqm}/m²/J</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexShrink: 0 }}>
|
||||
{activeNeed.isAnonymous && (
|
||||
<Chip
|
||||
label="Anonyme Suche"
|
||||
size="small"
|
||||
sx={{ bgcolor: '#7c3aed', color: 'white', fontWeight: 700, fontSize: 10, height: 20 }}
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
size="small"
|
||||
variant="text"
|
||||
onClick={() => navigate('/demand/ai-search')}
|
||||
sx={{ color: '#152642', flexShrink: 0 }}
|
||||
>
|
||||
<Button size="small" variant="text" onClick={() => navigate('/demand/ai-search')}
|
||||
sx={{ color: '#152642', flexShrink: 0, textTransform: 'none', fontSize: '0.75rem' }}>
|
||||
Suche ändern
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<ResultFilterBar
|
||||
|
||||
@@ -2,15 +2,17 @@ import { useState } from 'react'
|
||||
import { useLocation } from 'react-router'
|
||||
import { Box, Tab, Tabs, Typography } from '@mui/material'
|
||||
import { ActiveInquiriesTab, LatentInquiriesTab, OfferWizard } from '../../components/anfragencenter'
|
||||
import { useSessionStore } from '../../stores/sessionStore'
|
||||
|
||||
export default function Anfragencenter() {
|
||||
const location = useLocation()
|
||||
const initialTab = (location.state as { tab?: number } | null)?.tab ?? 0
|
||||
const [tab, setTab] = useState(initialTab)
|
||||
const orgId = useSessionStore(s => s.currentUser?.organizationId)
|
||||
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
|
||||
@@ -21,12 +23,20 @@ export default function Anfragencenter() {
|
||||
}}
|
||||
>
|
||||
<Tab label="Aktive Anfragen" />
|
||||
<Tab label="Gesendet" />
|
||||
<Tab label="Latente Anfragen" />
|
||||
</Tabs>
|
||||
</Box>
|
||||
<Box sx={{ flex: 1, overflow: 'hidden' }}>
|
||||
{tab === 0 && <ActiveInquiriesTab />}
|
||||
{tab === 1 && <LatentInquiriesTab />}
|
||||
{tab === 0 && <ActiveInquiriesTab filters={{ organizationId: orgId, kind: 'INQUIRY' }} />}
|
||||
{tab === 1 && (
|
||||
<ActiveInquiriesTab
|
||||
filters={{ organizationId: orgId, kind: 'OFFER' }}
|
||||
emptyTitle="Keine gesendeten Angebote"
|
||||
emptyDescription="Angebote aus den latenten Anfragen erscheinen hier, sobald Sie sie versenden."
|
||||
/>
|
||||
)}
|
||||
{tab === 2 && <LatentInquiriesTab />}
|
||||
</Box>
|
||||
<OfferWizard />
|
||||
</Box>
|
||||
|
||||
@@ -263,9 +263,9 @@ export default function DataQuality() {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
||||
{/* Header */}
|
||||
<Box sx={{ px: 3, py: 2, bgcolor: 'white', borderBottom: '1px solid #e2e8f0', flexShrink: 0 }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 700 }}>Datenpflege</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
<Box sx={{ px: 3, py: 2.5, bgcolor: 'white', borderBottom: '1px solid #e8e7e4', flexShrink: 0 }}>
|
||||
<Typography sx={{ fontWeight: 700, fontSize: '1.125rem', lineHeight: 1.3, color: '#0f172a' }}>Datenpflege</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Vollständigkeit, Aktualität und Vertrauen der Objektdaten · Klick auf KPI-Karte filtert die Liste
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
@@ -49,10 +49,10 @@ export default function FutureAvailability() {
|
||||
<AddToShortlistDialog />
|
||||
|
||||
{/* Header */}
|
||||
<Box sx={{ bgcolor: 'white', borderBottom: '1px solid #e2e8f0', px: 3, py: 2, flexShrink: 0 }}>
|
||||
<Box sx={{ bgcolor: 'white', borderBottom: '1px solid #e8e7e4', px: 3, py: 2.5, flexShrink: 0 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 1 }}>
|
||||
<Box>
|
||||
<Typography variant="h5" sx={{ fontWeight: 700 }}>Zukunftssignale</Typography>
|
||||
<Typography sx={{ fontWeight: 700, fontSize: '1.125rem', lineHeight: 1.3, color: '#0f172a' }}>Zukunftssignale</Typography>
|
||||
<Typography variant="body2" color="text.secondary">Probabilistische Markt- und Verfügbarkeitssignale</Typography>
|
||||
</Box>
|
||||
<Chip
|
||||
|
||||
@@ -1046,9 +1046,9 @@ export default function MarketIntelligence() {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
||||
{/* Page header */}
|
||||
<Box sx={{ px: 3, py: 2, bgcolor: 'white', borderBottom: '1px solid #e2e8f0', flexShrink: 0 }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 700, lineHeight: 1.2 }}>Marktchancen</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
<Box sx={{ px: 3, py: 2.5, bgcolor: 'white', borderBottom: '1px solid #e8e7e4', flexShrink: 0 }}>
|
||||
<Typography sx={{ fontWeight: 700, fontSize: '1.125rem', lineHeight: 1.3, color: '#0f172a' }}>Marktchancen</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
KI-Signale aus Web-Quellen und menschliche Netzwerk-Hinweise — mit Portfolio-Match
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
@@ -76,9 +76,9 @@ export default function MatchCenter() {
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', height: 'calc(100vh - 64px)', overflow: 'hidden' }}>
|
||||
|
||||
{/* Header */}
|
||||
<Box sx={{ bgcolor: 'white', borderBottom: '1px solid #e2e8f0', px: 3, py: 2, flexShrink: 0 }}>
|
||||
<Box sx={{ bgcolor: 'white', borderBottom: '1px solid #e8e7e4', px: 3, py: 2.5, flexShrink: 0 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'baseline', gap: 2, mb: 1 }}>
|
||||
<Typography variant="h5" sx={{ fontWeight: 700 }}>Match Center</Typography>
|
||||
<Typography sx={{ fontWeight: 700, fontSize: '1.125rem', lineHeight: 1.3, color: '#0f172a' }}>Match Center</Typography>
|
||||
<Typography variant="body2" color="text.secondary">Automatisch berechnete Matches</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
|
||||
|
||||
@@ -339,10 +339,11 @@ export default function MyListings() {
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ maxWidth: 1040, mx: 'auto', px: 3, py: 4 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 3 }}>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
||||
{/* Header */}
|
||||
<Box sx={{ bgcolor: 'white', borderBottom: '1px solid #e8e7e4', px: 3, py: 2.5, flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Box>
|
||||
<Typography variant="h5" sx={{ fontWeight: 700 }}>Inserate</Typography>
|
||||
<Typography sx={{ fontWeight: 700, fontSize: '1.125rem', lineHeight: 1.3, color: '#0f172a' }}>Inserate</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Direkt erstellte Inserate — unabhängig vom Portfolio
|
||||
</Typography>
|
||||
@@ -357,6 +358,8 @@ export default function MyListings() {
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ flex: 1, overflowY: 'auto', px: 3, py: 3, maxWidth: 1040, width: '100%', mx: 'auto', boxSizing: 'border-box' }}>
|
||||
|
||||
{actionError && (
|
||||
<Alert severity="error" sx={{ mb: 2 }} onClose={() => setActionError(null)}>
|
||||
{actionError}
|
||||
@@ -415,12 +418,14 @@ export default function MyListings() {
|
||||
</Box>
|
||||
)}
|
||||
|
||||
</Box>{/* end scroll container */}
|
||||
|
||||
{/* Detail / edit drawer */}
|
||||
<Drawer
|
||||
anchor="right"
|
||||
open={!!detailId}
|
||||
onClose={() => setDetailId(null)}
|
||||
slotProps={{ paper: { sx: { width: { xs: '100%', sm: 560 } } } }}
|
||||
slotProps={{ paper: { sx: { width: { xs: '100%', sm: '90vw', md: 480, lg: 540, xl: 560 } } } }}
|
||||
>
|
||||
{detailId && (
|
||||
<PropertyDetailView propertyId={detailId} onClose={() => setDetailId(null)} hideTabs={['Matchability', 'Marktsignale']} />
|
||||
|
||||
@@ -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.`
|
||||
@@ -81,6 +81,7 @@ export default function NewListing() {
|
||||
<TechnicalDetailsSection
|
||||
floor={form.floor} onFloorChange={form.setFloor}
|
||||
fitOut={form.fitOut} onFitOutChange={form.setFitOut}
|
||||
fitOutByLandlord={form.fitOutByLandlord} onFitOutByLandlordChange={form.setFitOutByLandlord}
|
||||
parking={form.parking} onParkingChange={form.setParking}
|
||||
ceilingHeight={form.ceilingHeight} onCeilingHeightChange={form.setCeilingHeight}
|
||||
mieterausbaubeitrag={form.mieterausbaubeitrag} onMieterausbaubeitragChange={form.setMieterausbaubeitrag}
|
||||
|
||||
@@ -57,6 +57,9 @@ export default function Properties() {
|
||||
const [view, setView] = useState<'list' | 'grid'>(() =>
|
||||
(localStorage.getItem('view-properties') as 'list' | 'grid') ?? 'list'
|
||||
)
|
||||
const [columns, setColumns] = useState<3 | 5 | 10>(() =>
|
||||
(Number(localStorage.getItem('view-properties-cols')) as 3 | 5 | 10) || 3
|
||||
)
|
||||
const [headerOpen, setHeaderOpen] = useState(() =>
|
||||
localStorage.getItem('props-header-open') !== 'false'
|
||||
)
|
||||
@@ -109,10 +112,31 @@ export default function Properties() {
|
||||
title="Objektverwaltung"
|
||||
subtitle={`${filtered.length} von ${properties.length} Objekten`}
|
||||
secondaryActions={
|
||||
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
|
||||
{view === 'grid' && (
|
||||
<Box sx={{ display: 'flex', border: '1px solid #e2e8f0', borderRadius: 1, overflow: 'hidden' }}>
|
||||
{([3, 5, 10] as const).map(n => (
|
||||
<Box
|
||||
key={n}
|
||||
onClick={() => { setColumns(n); localStorage.setItem('view-properties-cols', String(n)) }}
|
||||
sx={{
|
||||
width: 32, height: 32, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
cursor: 'pointer', fontSize: '0.72rem', fontWeight: 600,
|
||||
bgcolor: columns === n ? '#152642' : 'transparent',
|
||||
color: columns === n ? 'white' : '#64748b',
|
||||
'&:hover': { bgcolor: columns === n ? '#162d4a' : '#f1f5f9' },
|
||||
}}
|
||||
>
|
||||
{n}
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
<ViewToggle
|
||||
view={view}
|
||||
onChange={v => { setView(v); localStorage.setItem('view-properties', v) }}
|
||||
/>
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -201,7 +225,7 @@ export default function Properties() {
|
||||
|
||||
<Box sx={{ flex: 1, overflowY: 'auto' }}>
|
||||
{view === 'grid' ? (
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(320px, 1fr))', gap: 2, p: 2.5 }}>
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: `repeat(${columns}, 1fr)`, gap: 3, p: 3 }}>
|
||||
{filtered.map(p => (
|
||||
<PropertyIntelligenceCard key={p.id} property={p} onSelect={setSelectedId} inquiryCount={inquiryCountByProperty.get(p.id) ?? 0} />
|
||||
))}
|
||||
@@ -223,7 +247,7 @@ export default function Properties() {
|
||||
anchor="right"
|
||||
open={!!selectedId}
|
||||
onClose={() => setSelectedId(null)}
|
||||
slotProps={{ paper: { sx: { width: isMobile ? '100vw' : 650, boxShadow: '-4px 0 24px rgba(0,0,0,0.10)' } } }}
|
||||
slotProps={{ paper: { sx: { width: isMobile ? '100vw' : { md: 520, lg: 580, xl: 640 }, boxShadow: '-4px 0 24px rgba(0,0,0,0.10)' } } }}
|
||||
>
|
||||
{selectedId && (
|
||||
<PropertyDetailView propertyId={selectedId} onClose={() => setSelectedId(null)} />
|
||||
|
||||
@@ -30,12 +30,14 @@ export const LEVEL_TO_SCORE: Record<string, number | undefined> = {
|
||||
LOW: 0.30, MEDIUM: 0.55, HIGH: 0.85, '': undefined,
|
||||
}
|
||||
|
||||
import { FIT_OUT_LABELS } from '../../lib/constants'
|
||||
|
||||
export const FIT_OUT_OPTIONS = [
|
||||
{ value: '', label: 'Keine Angabe' },
|
||||
{ value: 'SHELL', label: 'Rohbau' },
|
||||
{ value: 'BASIC', label: 'Basis-Ausbau' },
|
||||
{ value: 'FULL', label: 'Vollausbau' },
|
||||
{ value: 'PREMIUM', label: 'Premiumausbau' },
|
||||
{ value: 'SHELL', label: FIT_OUT_LABELS.SHELL },
|
||||
{ value: 'BASIC', label: FIT_OUT_LABELS.BASIC },
|
||||
{ value: 'FULL', label: FIT_OUT_LABELS.FULL },
|
||||
{ value: 'PREMIUM', label: FIT_OUT_LABELS.PREMIUM },
|
||||
]
|
||||
|
||||
export interface LocationState {
|
||||
@@ -51,6 +53,7 @@ export interface LocationState {
|
||||
propertyId?: string
|
||||
floor?: string
|
||||
fitOut?: string
|
||||
fitOutByLandlord?: boolean
|
||||
parking?: number
|
||||
ceilingHeight?: number
|
||||
mieterausbaubeitragPerSqm?: number
|
||||
|
||||
@@ -15,6 +15,7 @@ export function buildCreatePropertyInput(fields: {
|
||||
softLevels: Record<string, string>
|
||||
floor: string
|
||||
fitOut: string
|
||||
fitOutByLandlord?: boolean
|
||||
parking: string
|
||||
ceilingHeight: string
|
||||
images: string[]
|
||||
@@ -26,7 +27,7 @@ export function buildCreatePropertyInput(fields: {
|
||||
const {
|
||||
assetType, street, houseNumber, postalCode, city,
|
||||
areaSqm, rentPerSqm, availableFrom, description,
|
||||
softLevels, floor, fitOut, parking, ceilingHeight, images, floorPlanUrl,
|
||||
softLevels, floor, fitOut, fitOutByLandlord, parking, ceilingHeight, images, floorPlanUrl,
|
||||
mieterausbaubeitrag, isFlexible, minLettableSqm,
|
||||
} = fields
|
||||
|
||||
@@ -45,7 +46,9 @@ export function buildCreatePropertyInput(fields: {
|
||||
const hf = {
|
||||
floor: floor ? parseInt(floor) : undefined,
|
||||
fitOut: (fitOut || undefined) as 'SHELL' | 'BASIC' | 'FULL' | 'PREMIUM' | undefined,
|
||||
mieterausbaubeitragPerSqm: mieterausbaubeitrag ? parseInt(mieterausbaubeitrag) : undefined,
|
||||
fitOutByLandlord,
|
||||
// MAB nur relevant, wenn der Mieter ausbaut — sonst nicht speichern
|
||||
mieterausbaubeitragPerSqm: !fitOutByLandlord && mieterausbaubeitrag ? parseInt(mieterausbaubeitrag) : undefined,
|
||||
parking: parking ? parseInt(parking) : undefined,
|
||||
ceilingHeightM: ceilingHeight ? parseFloat(ceilingHeight) : undefined,
|
||||
}
|
||||
|
||||
@@ -1,17 +1,50 @@
|
||||
import type { Inquiry, InquiryStatus, InquiryMessage } from '../domain/inquiry'
|
||||
import type { Inquiry, InquiryStatus, InquiryKind, InquiryMessage, Attachment } from '../domain/inquiry'
|
||||
|
||||
export interface InquiryFilters {
|
||||
status?: InquiryStatus
|
||||
propertyId?: string
|
||||
organizationId?: string
|
||||
organizationId?: string // Supply-Perspektive
|
||||
tenantOrgId?: string // Demand-Perspektive
|
||||
kind?: InquiryKind
|
||||
}
|
||||
|
||||
/** Demand→Supply: neue Anfrage zu einem Objekt. */
|
||||
export interface CreateInquiryInput {
|
||||
organizationId: string // Supply-Org (Objekt-Eigentümer)
|
||||
tenantOrgId: string // Demand-Org (Absender)
|
||||
propertyId: string
|
||||
needId?: string
|
||||
tenantName: string
|
||||
tenantCompany?: string
|
||||
tenantEmail?: string
|
||||
propertyAddress?: string
|
||||
subject: string
|
||||
message: string
|
||||
matchScore?: number
|
||||
}
|
||||
|
||||
/** Supply→Demand: Angebot zu einem latenten Bedarf. */
|
||||
export interface CreateOfferInput {
|
||||
organizationId: string // Supply-Org (Absender)
|
||||
tenantOrgId: string // Demand-Org (Empfänger, aus need.organizationId)
|
||||
needId: string
|
||||
propertyId: string // primäres Objekt (Kompatibilität)
|
||||
offeredPropertyIds: string[]
|
||||
subject: string
|
||||
message: string
|
||||
senderName: string // Verwaltungs-/Org-Name
|
||||
recipientName?: string
|
||||
attachments?: Attachment[]
|
||||
}
|
||||
|
||||
export interface IInquiryProvider {
|
||||
getAll(filters?: InquiryFilters): Promise<Inquiry[]>
|
||||
getById(id: string): Promise<Inquiry | null>
|
||||
createInquiry(input: CreateInquiryInput): Promise<Inquiry>
|
||||
createOffer(input: CreateOfferInput): Promise<Inquiry>
|
||||
updateStatus(id: string, status: InquiryStatus): Promise<Inquiry>
|
||||
markThreadAsRead(id: string): Promise<Inquiry>
|
||||
getUnreadCount(): Promise<number>
|
||||
getUnreadCount(filters?: InquiryFilters): Promise<number>
|
||||
addMessage(
|
||||
inquiryId: string,
|
||||
msg: Omit<InquiryMessage, 'id' | 'inquiryId' | 'createdAt'>,
|
||||
|
||||
@@ -1,20 +1,99 @@
|
||||
import type { IInquiryProvider, InquiryFilters } from './IInquiryProvider'
|
||||
import type { IInquiryProvider, InquiryFilters, CreateInquiryInput, CreateOfferInput } from './IInquiryProvider'
|
||||
import type { Inquiry, InquiryStatus, InquiryMessage } from '../domain/inquiry'
|
||||
import { mockInquiries } from '../mock-data/inquiries'
|
||||
import { mockDemandInquiries } from '../mock-data/demandInquiries'
|
||||
|
||||
const store: Inquiry[] = mockInquiries.map(i => ({ ...i, thread: [...i.thread] }))
|
||||
// Eine gemeinsame Konversationsmenge: Supply-Eingang (mockInquiries) + Demand-Perspektive
|
||||
// (mockDemandInquiries, inkl. erhaltener Angebote). Beide Seiten lesen perspektivisch gefiltert.
|
||||
const store: Inquiry[] = [...mockInquiries, ...mockDemandInquiries].map(i => ({ ...i, thread: [...i.thread] }))
|
||||
|
||||
function applyFilters(results: Inquiry[], filters?: InquiryFilters): Inquiry[] {
|
||||
let out = results
|
||||
if (filters?.status) out = out.filter(i => i.status === filters.status)
|
||||
if (filters?.propertyId) out = out.filter(i => i.propertyId === filters.propertyId)
|
||||
if (filters?.organizationId) out = out.filter(i => i.organizationId === filters.organizationId)
|
||||
if (filters?.tenantOrgId) out = out.filter(i => i.tenantOrgId === filters.tenantOrgId)
|
||||
if (filters?.kind) out = out.filter(i => (i.kind ?? 'INQUIRY') === filters.kind)
|
||||
return out
|
||||
}
|
||||
|
||||
export const MockupInquiryProvider: IInquiryProvider = {
|
||||
async getAll(filters?: InquiryFilters): Promise<Inquiry[]> {
|
||||
let results = [...store]
|
||||
if (filters?.status) results = results.filter(i => i.status === filters.status)
|
||||
if (filters?.propertyId) results = results.filter(i => i.propertyId === filters.propertyId)
|
||||
if (filters?.organizationId) results = results.filter(i => i.organizationId === filters.organizationId)
|
||||
return results.sort((a, b) => (a.createdAt < b.createdAt ? 1 : -1))
|
||||
return applyFilters([...store], filters).sort((a, b) => (a.updatedAt < b.updatedAt ? 1 : -1))
|
||||
},
|
||||
async getById(id: string): Promise<Inquiry | null> {
|
||||
return store.find(i => i.id === id) ?? null
|
||||
},
|
||||
async createInquiry(input: CreateInquiryInput): Promise<Inquiry> {
|
||||
const now = new Date().toISOString()
|
||||
const id = `inq-${crypto.randomUUID().slice(0, 8)}`
|
||||
const inquiry: Inquiry = {
|
||||
id,
|
||||
kind: 'INQUIRY',
|
||||
organizationId: input.organizationId,
|
||||
tenantOrgId: input.tenantOrgId,
|
||||
propertyId: input.propertyId,
|
||||
needId: input.needId,
|
||||
tenantName: input.tenantName,
|
||||
tenantCompany: input.tenantCompany,
|
||||
tenantEmail: input.tenantEmail,
|
||||
propertyAddress: input.propertyAddress,
|
||||
subject: input.subject,
|
||||
message: input.message,
|
||||
status: 'new',
|
||||
unreadCount: 1, // neu für die Verwaltung (Empfänger)
|
||||
isRead: false,
|
||||
matchScore: input.matchScore,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
thread: [{
|
||||
id: crypto.randomUUID(),
|
||||
inquiryId: id,
|
||||
senderType: 'tenant',
|
||||
senderName: input.tenantCompany ?? input.tenantName,
|
||||
subject: input.subject,
|
||||
body: input.message,
|
||||
attachments: [],
|
||||
createdAt: now,
|
||||
}],
|
||||
}
|
||||
store.unshift(inquiry)
|
||||
return inquiry
|
||||
},
|
||||
async createOffer(input: CreateOfferInput): Promise<Inquiry> {
|
||||
const now = new Date().toISOString()
|
||||
const id = `off-${crypto.randomUUID().slice(0, 8)}`
|
||||
const inquiry: Inquiry = {
|
||||
id,
|
||||
kind: 'OFFER',
|
||||
organizationId: input.organizationId,
|
||||
tenantOrgId: input.tenantOrgId,
|
||||
propertyId: input.propertyId,
|
||||
offeredPropertyIds: input.offeredPropertyIds,
|
||||
needId: input.needId,
|
||||
tenantName: input.recipientName ?? '',
|
||||
propertyManagerCompany: input.senderName,
|
||||
subject: input.subject,
|
||||
message: input.message,
|
||||
status: 'new',
|
||||
unreadCount: 1, // neu für den Nachfrager (Empfänger)
|
||||
isRead: false,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
thread: [{
|
||||
id: crypto.randomUUID(),
|
||||
inquiryId: id,
|
||||
senderType: 'supply_user',
|
||||
senderName: input.senderName,
|
||||
subject: input.subject,
|
||||
body: input.message,
|
||||
attachments: input.attachments ?? [],
|
||||
createdAt: now,
|
||||
}],
|
||||
}
|
||||
store.unshift(inquiry)
|
||||
return inquiry
|
||||
},
|
||||
async updateStatus(id: string, status: InquiryStatus): Promise<Inquiry> {
|
||||
const idx = store.findIndex(i => i.id === id)
|
||||
if (idx === -1) throw new Error(`Inquiry ${id} not found`)
|
||||
@@ -33,8 +112,8 @@ export const MockupInquiryProvider: IInquiryProvider = {
|
||||
}
|
||||
return store[idx]
|
||||
},
|
||||
async getUnreadCount(): Promise<number> {
|
||||
return store.reduce((sum, i) => sum + (i.unreadCount ?? 0), 0)
|
||||
async getUnreadCount(filters?: InquiryFilters): Promise<number> {
|
||||
return applyFilters([...store], filters).reduce((sum, i) => sum + (i.unreadCount ?? 0), 0)
|
||||
},
|
||||
async addMessage(
|
||||
inquiryId: string,
|
||||
@@ -51,6 +130,10 @@ export const MockupInquiryProvider: IInquiryProvider = {
|
||||
store[idx] = {
|
||||
...store[idx],
|
||||
thread: [...store[idx].thread, message],
|
||||
// Neue Nachricht der Gegenseite → für den Empfänger ungelesen
|
||||
unreadCount: (store[idx].unreadCount ?? 0) + 1,
|
||||
isRead: false,
|
||||
status: store[idx].status === 'new' ? 'in_progress' : store[idx].status,
|
||||
updatedAt: message.createdAt,
|
||||
}
|
||||
return store[idx]
|
||||
|
||||
@@ -17,11 +17,16 @@ export const MockupUnitProvider: IUnitProvider = {
|
||||
return getAllUnits().find(u => u.id === unitId) ?? null
|
||||
},
|
||||
async update(unitId: string, data: Partial<PropertyUnit>) {
|
||||
for (const prop of propertyStore) {
|
||||
for (let i = 0; i < propertyStore.length; i++) {
|
||||
const prop = propertyStore[i]
|
||||
const idx = (prop.units ?? []).findIndex(u => u.id === unitId)
|
||||
if (idx === -1) continue
|
||||
prop.units![idx] = { ...prop.units![idx], ...data }
|
||||
return prop.units![idx]
|
||||
// Immutable update: neue Referenzen (Unit, units-Array, Property), damit React Query
|
||||
// die Änderung via Structural-Sharing erkennt und neu rendert.
|
||||
const nextUnits = [...prop.units!]
|
||||
nextUnits[idx] = { ...nextUnits[idx], ...data }
|
||||
propertyStore[i] = { ...prop, units: nextUnits }
|
||||
return nextUnits[idx]
|
||||
}
|
||||
throw new Error(`Unit ${unitId} not found`)
|
||||
},
|
||||
|
||||
@@ -176,6 +176,27 @@ export interface FitOutAdvice {
|
||||
estimatedNetInvestment: string
|
||||
}
|
||||
|
||||
// ── Pre-market rent recommendation ─────────────────────────────────────────────
|
||||
|
||||
export interface PreMarketRentInput {
|
||||
city: string
|
||||
assetType: string
|
||||
areaSqm: number
|
||||
currentRentPerSqm: number
|
||||
availableFrom?: string // ISO — Pre-Market liegt in der Zukunft
|
||||
}
|
||||
|
||||
export interface PreMarketRentRecommendation {
|
||||
recommendedPerSqm: number
|
||||
rangeMinPerSqm: number
|
||||
rangeMaxPerSqm: number
|
||||
verdict: 'UNDERPRICED' | 'FAIR' | 'AMBITIOUS' // Bewertung des heutigen Preises
|
||||
deltaVsCurrentPct: number // Empfehlung vs. heutiger Preis
|
||||
drivers: string[] // Vergleichsmiete, Angebot, Nachfrage, Trend …
|
||||
rationale: string
|
||||
confidence: 'LOW' | 'MEDIUM' | 'HIGH'
|
||||
}
|
||||
|
||||
// ── Legacy types (kept for backward compatibility) ────────────────────────────
|
||||
|
||||
export interface CriteriaExtractionResult {
|
||||
@@ -220,6 +241,9 @@ export interface IAIService {
|
||||
// Fit-out investment advice (demand side)
|
||||
generateFitOutAdvice(input: FitOutAdviceInput): Promise<AIResponse<FitOutAdvice>>
|
||||
|
||||
// Pre-market rent recommendation (supply side) — based on regional comparables, supply & demand
|
||||
recommendPreMarketRent(input: PreMarketRentInput): Promise<AIResponse<PreMarketRentRecommendation>>
|
||||
|
||||
// Legacy methods
|
||||
extractCriteria(input: string): Promise<AIResponse<CriteriaExtractionResult>>
|
||||
generateFollowUp(partialNeed: Partial<CreateNeedInput>): Promise<AIResponse<string[]>>
|
||||
|
||||
@@ -57,6 +57,8 @@ import type {
|
||||
MarketSignalClassification,
|
||||
FitOutAdviceInput,
|
||||
FitOutAdvice,
|
||||
PreMarketRentInput,
|
||||
PreMarketRentRecommendation,
|
||||
} from '../IAIService'
|
||||
import { ServiceErrorCode } from '../../types'
|
||||
import { AppError } from '../../errors'
|
||||
@@ -82,6 +84,7 @@ import { buildDecisionBriefPrompt } from '../prompts/decisionBriefPrompt'
|
||||
import { buildDataQualityPrompt } from '../prompts/dataQualityPrompt'
|
||||
import { buildMarketSignalPrompt } from '../prompts/marketSignalPrompt'
|
||||
import { MockAIService } from '../mock/MockAIService'
|
||||
import { getCityIntelligence, getMarketRent } from '../../../lib/locationIntelligence'
|
||||
|
||||
// ── Config ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -606,6 +609,41 @@ Bitte analysiere die Situation und empfiehl die beste Option für den Mieter.`
|
||||
}, () => MockAIService.generateFitOutAdvice(input))
|
||||
},
|
||||
|
||||
// ── recommendPreMarketRent ──────────────────────────────────────────────────
|
||||
recommendPreMarketRent(input: PreMarketRentInput): Promise<AIResponse<PreMarketRentRecommendation>> {
|
||||
return withFallback('recommendPreMarketRent', async () => {
|
||||
const intel = getCityIntelligence(input.city)
|
||||
const comp = getMarketRent(input.city, input.assetType)
|
||||
const system = `Du bist Schweizer Gewerbeimmobilien-Marktanalyst. Empfiehl einen Pre-Market-Mietpreis (CHF/m²/Jahr) auf Basis regionaler Vergleichsmieten, Angebot (Leerstand) und Nachfrage. Antworte als JSON:
|
||||
{
|
||||
"recommendedPerSqm": number,
|
||||
"rangeMinPerSqm": number,
|
||||
"rangeMaxPerSqm": number,
|
||||
"verdict": "UNDERPRICED" | "FAIR" | "AMBITIOUS",
|
||||
"deltaVsCurrentPct": number,
|
||||
"drivers": ["kurze Treiber auf Deutsch"],
|
||||
"rationale": "2-3 Sätze Begründung auf Deutsch",
|
||||
"confidence": "LOW" | "MEDIUM" | "HIGH"
|
||||
}`
|
||||
const user = `Stadt: ${input.city}
|
||||
Nutzung: ${input.assetType}
|
||||
Fläche: ${input.areaSqm} m²
|
||||
Heutiger Preis: CHF ${input.currentRentPerSqm}/m²
|
||||
Vergleichsmiete (Median): ${comp ?? 'unbekannt'}
|
||||
Leerstand: ${intel?.vacancyRatePct ?? '?'}%
|
||||
Nachfrage: ${intel?.demandStrength ?? '?'}
|
||||
Miettrend 12M: ${intel?.rentTrend12m ?? '?'}%
|
||||
Ø Vermietungsdauer: ${intel?.avgDaysOnMarket ?? '?'} Tage`
|
||||
const raw = await chat(system, user)
|
||||
const json = extractJSON<PreMarketRentRecommendation>(raw)
|
||||
if (!json || typeof json.recommendedPerSqm !== 'number') {
|
||||
const fb = await MockAIService.recommendPreMarketRent(input)
|
||||
return { ...fb, provenance: makeProvenance('mock', true, false, { fallbackReason: json ? 'schema_validation' : 'json_parse' }) }
|
||||
}
|
||||
return { data: json, provenance: makeProvenance('ai', false, true) }
|
||||
}, () => MockAIService.recommendPreMarketRent(input))
|
||||
},
|
||||
|
||||
// ── Legacy: extractCriteria ─────────────────────────────────────────────────
|
||||
extractCriteria(input: string): Promise<AIResponse<CriteriaExtractionResult>> {
|
||||
return withFallback('extractCriteria', async () => {
|
||||
|
||||
@@ -12,12 +12,15 @@ import type {
|
||||
MarketSignalClassification,
|
||||
FitOutAdviceInput,
|
||||
FitOutAdvice,
|
||||
PreMarketRentInput,
|
||||
PreMarketRentRecommendation,
|
||||
} from '../IAIService'
|
||||
import { mockProvenance } from '../IAIService'
|
||||
import { aiTraceStore } from '../tracing'
|
||||
import { mockParseNeed } from './needParser'
|
||||
import { buildComparisonSummary } from './compareBuilder'
|
||||
import { buildMockDecisionBrief } from './decisionBrief'
|
||||
import { getCityIntelligence, getMarketRent } from '../../../lib/locationIntelligence'
|
||||
|
||||
const SIMULATED_DELAY = { fast: 300, medium: 600, slow: 1800 }
|
||||
const delay = (ms: number) => new Promise(r => setTimeout(r, ms))
|
||||
@@ -338,6 +341,74 @@ export const MockAIService: IAIService = {
|
||||
return { data, provenance: mockProvenance() }
|
||||
}),
|
||||
|
||||
// ── recommendPreMarketRent ──────────────────────────────────────────────────
|
||||
recommendPreMarketRent: (input: PreMarketRentInput) =>
|
||||
traceMock('recommendPreMarketRent', async () => {
|
||||
await delay(SIMULATED_DELAY.fast)
|
||||
const intel = getCityIntelligence(input.city)
|
||||
const comp = getMarketRent(input.city, input.assetType)
|
||||
const current = input.currentRentPerSqm
|
||||
const ASSET_LABELS: Record<string, string> = { OFFICE: 'Bürofläche', LOGISTICS: 'Logistikfläche', LIGHT_INDUSTRIAL: 'Gewerbefläche', RETAIL: 'Retailfläche', PRODUCTION: 'Produktionsfläche' }
|
||||
const assetLabel = ASSET_LABELS[input.assetType] ?? 'Fläche'
|
||||
|
||||
// Ohne regionale Vergleichsdaten: nur grobe Schätzung, niedrige Konfidenz
|
||||
if (!intel || comp == null) {
|
||||
const rec = Math.round(current * 1.02)
|
||||
const data: PreMarketRentRecommendation = {
|
||||
recommendedPerSqm: rec, rangeMinPerSqm: Math.round(rec * 0.93), rangeMaxPerSqm: Math.round(rec * 1.07),
|
||||
verdict: 'FAIR', deltaVsCurrentPct: 0,
|
||||
drivers: ['Keine regionalen Vergleichsdaten verfügbar'],
|
||||
rationale: 'Keine ausreichenden Marktdaten für diese Region — Empfehlung beruht auf dem heutigen Preis.',
|
||||
confidence: 'LOW',
|
||||
}
|
||||
return { data, provenance: mockProvenance() }
|
||||
}
|
||||
|
||||
// Empfehlung am HEUTIGEN Preis des Objekts verankert (realistisch) und nur begrenzt
|
||||
// nach Marktmomentum (Angebot/Nachfrage/Trend) angepasst — keine Sprünge auf den
|
||||
// stadtweiten Median (segment-grob). Vergleichsmiete dient nur als Spielraum-Check.
|
||||
let adj = intel.rentTrend12m / 100 // Trend vorwärts (Pre-Market liegt in der Zukunft)
|
||||
if (intel.vacancyRatePct < 2.5) adj += 0.04
|
||||
else if (intel.vacancyRatePct < 4) adj += 0.02
|
||||
else if (intel.vacancyRatePct > 5.5) adj -= 0.05
|
||||
else if (intel.vacancyRatePct > 4.5) adj -= 0.02
|
||||
adj += { VERY_HIGH: 0.05, HIGH: 0.025, MEDIUM: 0, LOW: -0.04 }[intel.demandStrength]
|
||||
if (intel.avgDaysOnMarket < 35) adj += 0.015
|
||||
else if (intel.avgDaysOnMarket > 75) adj -= 0.025
|
||||
// Spielraum-Check: liegt der heutige Preis bereits über dem regionalen Marktband → kaum Luft nach oben
|
||||
if (comp != null && current >= comp) adj = Math.min(adj, 0.02)
|
||||
adj = Math.max(-0.10, Math.min(0.15, adj)) // realistischer Rahmen: −10 % … +15 %
|
||||
|
||||
const recommended = Math.round(current * (1 + adj))
|
||||
const rangeMin = Math.round(recommended * 0.95)
|
||||
const rangeMax = Math.round(recommended * 1.05)
|
||||
const deltaVsCurrentPct = Math.round(adj * 100)
|
||||
const verdict: PreMarketRentRecommendation['verdict'] =
|
||||
deltaVsCurrentPct >= 5 ? 'UNDERPRICED' : deltaVsCurrentPct <= -4 ? 'AMBITIOUS' : 'FAIR'
|
||||
|
||||
const supplyLabel = intel.vacancyRatePct < 3 ? 'sehr knappes Angebot' : intel.vacancyRatePct > 5 ? 'entspanntes Angebot' : 'ausgeglichenes Angebot'
|
||||
const demandLabel = { VERY_HIGH: 'sehr hohe Nachfrage', HIGH: 'hohe Nachfrage', MEDIUM: 'mittlere Nachfrage', LOW: 'schwache Nachfrage' }[intel.demandStrength]
|
||||
const drivers = [
|
||||
`Leerstand ${intel.vacancyRatePct}% (${supplyLabel})`,
|
||||
demandLabel,
|
||||
`Miettrend ${intel.rentTrend12m >= 0 ? '+' : ''}${intel.rentTrend12m}% (12 M)`,
|
||||
`Ø Vermietungsdauer ${intel.avgDaysOnMarket} Tage`,
|
||||
]
|
||||
const verdictText =
|
||||
verdict === 'UNDERPRICED' ? `Marktumfeld lässt Spielraum nach oben (+${deltaVsCurrentPct}% ggü. heute).`
|
||||
: verdict === 'AMBITIOUS' ? `Marktumfeld eher schwächer (${deltaVsCurrentPct}% ggü. heute) — vorsichtig ansetzen.`
|
||||
: 'Heutiger Preis ist marktgerecht.'
|
||||
const rationale = `${assetLabel} in ${input.city}: ${supplyLabel}, ${demandLabel}, Miettrend ${intel.rentTrend12m >= 0 ? '+' : ''}${intel.rentTrend12m}%. Empfehlung für Pre-Market: CHF ${recommended}/m² (CHF ${rangeMin}–${rangeMax}) — verankert am heutigen Preis CHF ${current}/m². ${verdictText}`
|
||||
const confidence: PreMarketRentRecommendation['confidence'] =
|
||||
intel.demandStrength === 'LOW' || intel.avgDaysOnMarket > 75 ? 'MEDIUM' : 'HIGH'
|
||||
|
||||
const data: PreMarketRentRecommendation = {
|
||||
recommendedPerSqm: recommended, rangeMinPerSqm: rangeMin, rangeMaxPerSqm: rangeMax,
|
||||
verdict, deltaVsCurrentPct, drivers, rationale, confidence,
|
||||
}
|
||||
return { data, provenance: mockProvenance() }
|
||||
}),
|
||||
|
||||
// Legacy methods
|
||||
extractCriteria: (_input: string) =>
|
||||
traceMock('extractCriteria', async () => ({
|
||||
|
||||
@@ -22,5 +22,9 @@ export type {
|
||||
DataQualityInput,
|
||||
DataQualitySummary,
|
||||
MarketSignalClassification,
|
||||
FitOutAdvice,
|
||||
FitOutAdviceInput,
|
||||
PreMarketRentInput,
|
||||
PreMarketRentRecommendation,
|
||||
} from './ai/IAIService'
|
||||
export { parseListingText } from './ai/mock/listingParser'
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { MockupInquiryProvider } from '../provider/MockupInquiryProvider'
|
||||
import type { InquiryFilters } from '../provider/IInquiryProvider'
|
||||
import type { Inquiry, Attachment } from '../domain/inquiry'
|
||||
import type { InquiryFilters, CreateInquiryInput, CreateOfferInput } from '../provider/IInquiryProvider'
|
||||
import type { Inquiry, Attachment, InquiryMessage } from '../domain/inquiry'
|
||||
import type { ListResponse, ItemResponse } from './types'
|
||||
import { throwServiceError } from './errors'
|
||||
|
||||
@@ -10,6 +10,8 @@ export interface InquiryReplyPayload {
|
||||
subject?: string
|
||||
body: string
|
||||
attachments?: Attachment[]
|
||||
senderType?: InquiryMessage['senderType'] // default 'supply_user'
|
||||
senderName?: string
|
||||
}
|
||||
|
||||
export const inquiryService = {
|
||||
@@ -31,14 +33,32 @@ export const inquiryService = {
|
||||
}
|
||||
},
|
||||
|
||||
async createInquiry(input: CreateInquiryInput): Promise<ItemResponse<Inquiry>> {
|
||||
try {
|
||||
const data = await provider.createInquiry(input)
|
||||
return { data }
|
||||
} catch (err) {
|
||||
throwServiceError(err)
|
||||
}
|
||||
},
|
||||
|
||||
async createOffer(input: CreateOfferInput): Promise<ItemResponse<Inquiry>> {
|
||||
try {
|
||||
const data = await provider.createOffer(input)
|
||||
return { data }
|
||||
} catch (err) {
|
||||
throwServiceError(err)
|
||||
}
|
||||
},
|
||||
|
||||
async sendInquiryReply(
|
||||
inquiryId: string,
|
||||
payload: InquiryReplyPayload,
|
||||
): Promise<ItemResponse<Inquiry>> {
|
||||
try {
|
||||
const data = await provider.addMessage(inquiryId, {
|
||||
senderType: 'supply_user',
|
||||
senderName: 'Wincasa AG',
|
||||
senderType: payload.senderType ?? 'supply_user',
|
||||
senderName: payload.senderName ?? 'Wincasa AG',
|
||||
subject: payload.subject,
|
||||
body: payload.body,
|
||||
attachments: payload.attachments ?? [],
|
||||
@@ -58,9 +78,9 @@ export const inquiryService = {
|
||||
}
|
||||
},
|
||||
|
||||
async getUnreadCount(): Promise<ItemResponse<number>> {
|
||||
async getUnreadCount(filters?: InquiryFilters): Promise<ItemResponse<number>> {
|
||||
try {
|
||||
const data = await provider.getUnreadCount()
|
||||
const data = await provider.getUnreadCount(filters)
|
||||
return { data }
|
||||
} catch (err) {
|
||||
throwServiceError(err)
|
||||
|
||||
@@ -12,6 +12,7 @@ import type { Need } from '../domain/need'
|
||||
import type { Property } from '../domain/property'
|
||||
import { getEffectiveUnits } from '../domain/property'
|
||||
import { calculateScore } from '../features/matching/scoreCalculator'
|
||||
import { resolveUnitFacts } from '../lib/unitFacts'
|
||||
|
||||
function strengthFromScore(s: number): MatchStrength {
|
||||
if (s >= 75) return MatchStrength.STRONG
|
||||
@@ -71,24 +72,38 @@ function scoreProperty(
|
||||
overrideArea?: number,
|
||||
overridePrice?: number,
|
||||
overrideResultType?: string,
|
||||
overrideHardFacts?: Partial<NonNullable<Property['hardFacts']>>,
|
||||
): MatchEngineOutput {
|
||||
if (overrideArea !== undefined || overridePrice !== undefined || overrideResultType !== undefined) {
|
||||
if (overrideArea !== undefined || overridePrice !== undefined || overrideResultType !== undefined || overrideHardFacts !== undefined) {
|
||||
return calculateScore(need, {
|
||||
...prop,
|
||||
areaSqm: overrideArea ?? prop.areaSqm,
|
||||
rentPricePerSqm: overridePrice ?? prop.rentPricePerSqm,
|
||||
resultType: (overrideResultType ?? prop.resultType) as ResultType,
|
||||
// Einheit-Werte überschreiben Objekt-Werte (Fallback bleibt prop.hardFacts)
|
||||
hardFacts: overrideHardFacts ? { ...prop.hardFacts, ...overrideHardFacts } : prop.hardFacts,
|
||||
})
|
||||
}
|
||||
return calculateScore(need, prop)
|
||||
}
|
||||
|
||||
// Externe Inserate aus Drittquellen (Scrapes) sind keine Plattform-Inventar-Objekte und
|
||||
// gehören nicht in die Treffer (nur eigene Plattform-Objekte, Maison Work, Future Availability).
|
||||
const EXTERNAL_SCRAPE_SOURCES = new Set(['HOMEGATE_SCRAPE', 'IMMOSCOUT_SCRAPE', 'NEWHOME_SCRAPE', 'MATCHOFFICE_SCRAPE'])
|
||||
|
||||
function isExternalListing(prop: Property): boolean {
|
||||
return prop.resultType === ResultType.VERIFIED_PORTFOLIO
|
||||
&& !!prop.sourceType && EXTERNAL_SCRAPE_SOURCES.has(prop.sourceType)
|
||||
}
|
||||
|
||||
/** Generate matches for a single need against all properties and push them into matchStore. */
|
||||
export function generateMatchesForNeed(need: Need): void {
|
||||
const now = new Date().toISOString()
|
||||
const MIN_SCORE = 22
|
||||
|
||||
for (const prop of propertyStore) {
|
||||
// Externe Scrape-Inserate, die fälschlich als Plattform-Objekt getaggt sind → nicht matchen
|
||||
if (isExternalListing(prop)) continue
|
||||
const hasExplicitUnits = (prop.units ?? []).length > 0
|
||||
|
||||
if (hasExplicitUnits) {
|
||||
@@ -101,7 +116,15 @@ export function generateMatchesForNeed(need: Need): void {
|
||||
}
|
||||
for (const unit of prop.units!) {
|
||||
if (!unit.schattenmarktRelease?.enabled) continue
|
||||
const unitOutput = scoreProperty(need, prop, unit.areaSqm, unit.rentPricePerSqm ?? prop.rentPricePerSqm, ResultType.FUTURE_AVAILABILITY)
|
||||
const facts = resolveUnitFacts(prop, unit)
|
||||
// Pre-Market: erwarteter künftiger Preis hat Vorrang vor der heutigen Sollmiete
|
||||
const unitPrice = unit.expectedRentPerSqm ?? unit.rentPricePerSqm ?? prop.rentPricePerSqm
|
||||
const unitOutput = scoreProperty(need, prop, unit.areaSqm, unitPrice, ResultType.FUTURE_AVAILABILITY, {
|
||||
fitOut: facts.fitOut,
|
||||
mieterausbaubeitragPerSqm: facts.mabPerSqm,
|
||||
fitOutByLandlord: facts.fitOutByLandlord,
|
||||
parking: facts.parkingSpots,
|
||||
})
|
||||
if (unitOutput.excluded || unitOutput.finalScore < MIN_SCORE) continue
|
||||
const resultId = `schattenmarkt-${prop.id}-${unit.id}`
|
||||
matchStore.push(buildMatch(prop, unit.id, need, unitOutput, ResultType.FUTURE_AVAILABILITY, resultId, now))
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { NeedFilters } from '../provider/INeedProvider'
|
||||
import type { Need, CreateNeedInput, UpdateNeedInput } from '../domain/need'
|
||||
import type { ListResponse, ItemResponse } from './types'
|
||||
import { throwServiceError } from './errors'
|
||||
import { useSessionStore } from '../stores/sessionStore'
|
||||
|
||||
const provider = MockupNeedProvider
|
||||
|
||||
@@ -25,7 +26,10 @@ export const needService = {
|
||||
},
|
||||
async create(input: CreateNeedInput): Promise<ItemResponse<Need>> {
|
||||
try {
|
||||
const data = await provider.create(input)
|
||||
// Neues Suchprofil der Organisation des Erstellers zuordnen (sonst fällt es aus der
|
||||
// org-gefilterten Liste — Aktive-Suche-Leiste zeigt sonst ein fremdes Profil)
|
||||
const orgId = useSessionStore.getState().currentUser?.organizationId
|
||||
const data = await provider.create({ ...input, organizationId: input.organizationId ?? orgId })
|
||||
return { data }
|
||||
} catch (err) {
|
||||
throwServiceError(err)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { create } from 'zustand'
|
||||
import type { Inquiry, InquiryMessage } from '../domain/inquiry'
|
||||
|
||||
// Reine UI-State: der Anfrage-Dialog. Versendete Anfragen leben jetzt im Provider
|
||||
// (MockupInquiryProvider) und werden via React Query gelesen — nicht mehr hier.
|
||||
export interface PendingInquiry {
|
||||
propertyTitle: string
|
||||
location: string
|
||||
@@ -13,15 +14,10 @@ export interface PendingInquiry {
|
||||
}
|
||||
|
||||
interface InquiryStore {
|
||||
// Dialog state
|
||||
dialogOpen: boolean
|
||||
pendingInquiry: PendingInquiry | null
|
||||
openInquiryDialog: (item: PendingInquiry) => void
|
||||
closeInquiryDialog: () => void
|
||||
|
||||
// Sent inquiries (in-memory, persists for the session)
|
||||
sentInquiries: Inquiry[]
|
||||
addInquiry: (item: PendingInquiry, message: string) => void
|
||||
}
|
||||
|
||||
export const useInquiryStore = create<InquiryStore>((set) => ({
|
||||
@@ -29,44 +25,4 @@ export const useInquiryStore = create<InquiryStore>((set) => ({
|
||||
pendingInquiry: null,
|
||||
openInquiryDialog: (item) => set({ dialogOpen: true, pendingInquiry: item }),
|
||||
closeInquiryDialog: () => set({ dialogOpen: false, pendingInquiry: null }),
|
||||
|
||||
sentInquiries: [],
|
||||
addInquiry: (item, message) => {
|
||||
const now = new Date().toISOString()
|
||||
const msgId = `msg-sent-${Date.now()}`
|
||||
const id = `sent-${Date.now()}`
|
||||
|
||||
const thread: InquiryMessage = {
|
||||
id: msgId,
|
||||
inquiryId: id,
|
||||
senderType: 'tenant',
|
||||
senderName: 'Admin User',
|
||||
body: message,
|
||||
attachments: [],
|
||||
createdAt: now,
|
||||
}
|
||||
|
||||
const inquiry: Inquiry = {
|
||||
id,
|
||||
organizationId: 'org-wincasa',
|
||||
propertyId: item.propertyId ?? 'unknown',
|
||||
tenantName: 'Admin User',
|
||||
tenantCompany: 'Mobimo Management AG',
|
||||
tenantEmail: 'admin@ideal-sharing.ch',
|
||||
propertyAddress: item.propertyTitle,
|
||||
propertyManagerName: 'Verwalter',
|
||||
propertyManagerCompany: '',
|
||||
subject: 'Anfrage: ' + item.propertyTitle,
|
||||
message,
|
||||
status: 'new',
|
||||
unreadCount: 0,
|
||||
isRead: true,
|
||||
matchScore: item.matchScore,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
thread: [thread],
|
||||
}
|
||||
|
||||
set(state => ({ sentInquiries: [inquiry, ...state.sentInquiries] }))
|
||||
},
|
||||
}))
|
||||
|
||||
@@ -17,6 +17,7 @@ interface LayoutState {
|
||||
// Actions
|
||||
setActiveWorkspace: (workspace: WorkspaceType) => void
|
||||
toggleSidebar: () => void
|
||||
setSidebarCollapsed: (collapsed: boolean) => void
|
||||
openRightPanel: (type: RightPanelContentType) => void
|
||||
closeRightPanel: () => void
|
||||
toggleRightPanel: (type: RightPanelContentType) => void
|
||||
@@ -30,6 +31,7 @@ export const useLayoutStore = create<LayoutState>((set, get) => ({
|
||||
|
||||
setActiveWorkspace: (workspace) => set({ activeWorkspace: workspace }),
|
||||
toggleSidebar: () => set((s) => ({ sidebarCollapsed: !s.sidebarCollapsed })),
|
||||
setSidebarCollapsed: (collapsed) => set({ sidebarCollapsed: collapsed }),
|
||||
openRightPanel: (type) => set({ isRightPanelOpen: true, rightPanelContentType: type }),
|
||||
closeRightPanel: () => set({ isRightPanelOpen: false, rightPanelContentType: null }),
|
||||
toggleRightPanel: (type) => {
|
||||
|
||||
Reference in New Issue
Block a user