Compare commits
72 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 362514681a | |||
| 07cea21143 | |||
| 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 | |||
| ec5d567dae | |||
| e64ae15d17 | |||
| ee229f8f4f | |||
| d771029633 | |||
| 20dcc6b245 | |||
| bd9fd98d78 | |||
| bae0cd31d5 | |||
| 5c886193d5 | |||
| e6c20958a5 | |||
| 884bb468b8 | |||
| 29ee98a300 | |||
| 161b2d609a | |||
| da50f3b5ea | |||
| 96a2507979 | |||
| 87ea4c4dfc | |||
| c6d3071010 | |||
| 5a1f412ce3 | |||
| b2530f9e20 | |||
| a825672197 | |||
| 36570c5bdc | |||
| 98d2770054 | |||
| 6fb2fb027c | |||
| 7f6b7766e6 | |||
| 63909c8caf | |||
| d8ff380d6e | |||
| 81d66d24a9 | |||
| e597e81ff5 | |||
| d2af8664f4 | |||
| cd220ed282 | |||
| a9ee53f185 | |||
| fe8444c1d3 | |||
| 453f2b1982 | |||
| d804d1c923 | |||
| a9e038a64f | |||
| 61a26d00a7 | |||
| f2edc3b2ef | |||
| e95490eb72 | |||
| 3ab6eeccf2 | |||
| 128d28af8d | |||
| 9df5d285f5 | |||
| 609a3634bd |
+19
-1
@@ -11,7 +11,25 @@
|
|||||||
"Bash(start http://localhost:5173)",
|
"Bash(start http://localhost:5173)",
|
||||||
"Bash(npm install *)",
|
"Bash(npm install *)",
|
||||||
"Bash(git pull *)",
|
"Bash(git pull *)",
|
||||||
"Bash(node scripts/check-tokens.js)"
|
"Bash(node scripts/check-tokens.js)",
|
||||||
|
"Bash(echo \"EXIT:$?\")",
|
||||||
|
"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\")"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,26 @@
|
|||||||
"permissions": {
|
"permissions": {
|
||||||
"allow": [
|
"allow": [
|
||||||
"Bash(git commit -m ' *)",
|
"Bash(git commit -m ' *)",
|
||||||
"Bash(git commit *)"
|
"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/**)",
|
||||||
|
"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"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
> Why do I have a folder named ".vercel" in my project?
|
||||||
|
The ".vercel" folder is created when you link a directory to a Vercel project.
|
||||||
|
|
||||||
|
> What does the "project.json" file contain?
|
||||||
|
The "project.json" file contains:
|
||||||
|
- The ID of the Vercel project that you linked ("projectId")
|
||||||
|
- The ID of the user or team your Vercel project is owned by ("orgId")
|
||||||
|
|
||||||
|
> Should I commit the ".vercel" folder?
|
||||||
|
No, you should not share the ".vercel" folder with anyone.
|
||||||
|
Upon creation, it will be automatically added to your ".gitignore" file.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"projectId":"prj_0UhtBM4Jg83OXPDkzhOM6x0gYfEY","orgId":"team_Quh2pScdkVSiGHkdwh6ntKZ5","projectName":"property-match"}
|
||||||
Binary file not shown.
Generated
+48
@@ -41,6 +41,7 @@
|
|||||||
"eslint-plugin-react-refresh": "^0.5.2",
|
"eslint-plugin-react-refresh": "^0.5.2",
|
||||||
"globals": "^17.6.0",
|
"globals": "^17.6.0",
|
||||||
"jsdom": "^29.1.1",
|
"jsdom": "^29.1.1",
|
||||||
|
"playwright": "^1.61.0",
|
||||||
"tailwindcss": "^4.3.0",
|
"tailwindcss": "^4.3.0",
|
||||||
"typescript": "~6.0.2",
|
"typescript": "~6.0.2",
|
||||||
"typescript-eslint": "^8.59.2",
|
"typescript-eslint": "^8.59.2",
|
||||||
@@ -4330,6 +4331,53 @@
|
|||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
"url": "https://github.com/sponsors/jonschlinkert"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/playwright": {
|
||||||
|
"version": "1.61.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.0.tgz",
|
||||||
|
"integrity": "sha512-Z+7BeeqQPRRzklHsVFP4KTGIyMxKUmfeRA4WisM6G3/XW6nwGeX6fX9qYaDa+CiUqpOkb2f6X3nar05R3kSuJQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"playwright-core": "1.61.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"fsevents": "2.3.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright-core": {
|
||||||
|
"version": "1.61.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.0.tgz",
|
||||||
|
"integrity": "sha512-caX7TrY3Ml6egyDX0WUcTHDxodl/b51y5wJOdCEA36QviK/s2g081hvmGs8eaE3DWb6NYZQ6BjO/QkNRPenoPA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"playwright-core": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright/node_modules/fsevents": {
|
||||||
|
"version": "2.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||||
|
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||||
|
"dev": true,
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/postcss": {
|
"node_modules/postcss": {
|
||||||
"version": "8.5.14",
|
"version": "8.5.14",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz",
|
||||||
|
|||||||
@@ -47,6 +47,7 @@
|
|||||||
"eslint-plugin-react-refresh": "^0.5.2",
|
"eslint-plugin-react-refresh": "^0.5.2",
|
||||||
"globals": "^17.6.0",
|
"globals": "^17.6.0",
|
||||||
"jsdom": "^29.1.1",
|
"jsdom": "^29.1.1",
|
||||||
|
"playwright": "^1.61.0",
|
||||||
"tailwindcss": "^4.3.0",
|
"tailwindcss": "^4.3.0",
|
||||||
"typescript": "~6.0.2",
|
"typescript": "~6.0.2",
|
||||||
"typescript-eslint": "^8.59.2",
|
"typescript-eslint": "^8.59.2",
|
||||||
|
|||||||
+1
-3
@@ -26,7 +26,7 @@ const Anfragencenter = lazy(() => import('./pages/supply/Anfragencenter'))
|
|||||||
const FutureAvailability = lazy(() => import('./pages/supply/FutureAvailability'))
|
const FutureAvailability = lazy(() => import('./pages/supply/FutureAvailability'))
|
||||||
const DataQuality = lazy(() => import('./pages/supply/DataQuality'))
|
const DataQuality = lazy(() => import('./pages/supply/DataQuality'))
|
||||||
const ReminderManager = lazy(() => import('./pages/supply/ReminderManager'))
|
const ReminderManager = lazy(() => import('./pages/supply/ReminderManager'))
|
||||||
const MarketLeads = lazy(() => import('./pages/supply/MarketLeads'))
|
const MarketIntelligence = lazy(() => import('./pages/supply/MarketIntelligence'))
|
||||||
const NewListing = lazy(() => import('./pages/supply/NewListing'))
|
const NewListing = lazy(() => import('./pages/supply/NewListing'))
|
||||||
const MyListings = lazy(() => import('./pages/supply/MyListings'))
|
const MyListings = lazy(() => import('./pages/supply/MyListings'))
|
||||||
|
|
||||||
@@ -38,7 +38,6 @@ const Anfragen = lazy(() => import('./pages/demand/Anfragen'))
|
|||||||
const Pipeline = lazy(() => import('./pages/demand/Pipeline'))
|
const Pipeline = lazy(() => import('./pages/demand/Pipeline'))
|
||||||
const PropertyDetail = lazy(() => import('./pages/demand/PropertyDetail'))
|
const PropertyDetail = lazy(() => import('./pages/demand/PropertyDetail'))
|
||||||
|
|
||||||
const MarketIntelligence = lazy(() => import('./pages/ops/MarketIntelligence'))
|
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
@@ -62,7 +61,6 @@ function App() {
|
|||||||
<Route path="/supply/future-availability" element={<FutureAvailability />} />
|
<Route path="/supply/future-availability" element={<FutureAvailability />} />
|
||||||
<Route path="/supply/data-quality" element={<DataQuality />} />
|
<Route path="/supply/data-quality" element={<DataQuality />} />
|
||||||
<Route path="/supply/reminder-manager" element={<ReminderManager />} />
|
<Route path="/supply/reminder-manager" element={<ReminderManager />} />
|
||||||
<Route path="/supply/market-leads" element={<MarketLeads />} />
|
|
||||||
<Route path="/supply/market-intelligence" element={<MarketIntelligence />} />
|
<Route path="/supply/market-intelligence" element={<MarketIntelligence />} />
|
||||||
<Route path="/supply/new-listing" element={<NewListing />} />
|
<Route path="/supply/new-listing" element={<NewListing />} />
|
||||||
<Route path="/supply/my-listings" element={<MyListings />} />
|
<Route path="/supply/my-listings" element={<MyListings />} />
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ const TYPE_LABELS: Record<AIOutputType, string> = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const TYPE_COLORS: Record<AIOutputType, string> = {
|
const TYPE_COLORS: Record<AIOutputType, string> = {
|
||||||
NEED_PARSE: '#1e3a5f',
|
NEED_PARSE: '#152642',
|
||||||
FOLLOW_UP_QUESTIONS: '#0891b2',
|
FOLLOW_UP_QUESTIONS: '#0891b2',
|
||||||
MATCH_EXPLANATION: '#4f46e5',
|
MATCH_EXPLANATION: '#4f46e5',
|
||||||
COMPARE_SUMMARY: '#1a7a4a',
|
COMPARE_SUMMARY: '#1a7a4a',
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ export function AIReviewActionToolbar({ output, onUpdateStatus, onCopyJson, isSu
|
|||||||
variant="outlined"
|
variant="outlined"
|
||||||
onClick={onCopyJson}
|
onClick={onCopyJson}
|
||||||
startIcon={<Copy size={13} />}
|
startIcon={<Copy size={13} />}
|
||||||
sx={{ textTransform: 'none', fontSize: '0.75rem', color: '#64748b', borderColor: '#e2e8f0', '&:hover': { bgcolor: '#f8fafc' } }}
|
sx={{ textTransform: 'none', fontSize: '0.75rem', color: '#64748b', borderColor: '#e8e7e4', '&:hover': { bgcolor: '#f8fafc' } }}
|
||||||
>
|
>
|
||||||
JSON
|
JSON
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'
|
|||||||
import { Box, IconButton, Tooltip, Typography, useMediaQuery, useTheme } from '@mui/material'
|
import { Box, IconButton, Tooltip, Typography, useMediaQuery, useTheme } from '@mui/material'
|
||||||
import { ArrowLeft, LayoutGrid, List as ListIcon, Inbox } from 'lucide-react'
|
import { ArrowLeft, LayoutGrid, List as ListIcon, Inbox } from 'lucide-react'
|
||||||
import { useActiveInquiries } from '../../hooks/useInquiries'
|
import { useActiveInquiries } from '../../hooks/useInquiries'
|
||||||
|
import type { InquiryFilters } from '../../provider/IInquiryProvider'
|
||||||
import { EmptyState } from '../ui'
|
import { EmptyState } from '../ui'
|
||||||
import { InquiryList } from './InquiryList'
|
import { InquiryList } from './InquiryList'
|
||||||
import { InquiryCardGrid } from './InquiryCardGrid'
|
import { InquiryCardGrid } from './InquiryCardGrid'
|
||||||
@@ -17,8 +18,14 @@ function loadViewMode(): ViewMode {
|
|||||||
return stored === 'grid' ? 'grid' : 'list'
|
return stored === 'grid' ? 'grid' : 'list'
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ActiveInquiriesTab() {
|
interface Props {
|
||||||
const { data: inquiries = [], isLoading } = useActiveInquiries()
|
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 [selectedId, setSelectedId] = useState<string | null>(null)
|
||||||
const [view, setView] = useState<ViewMode>(loadViewMode())
|
const [view, setView] = useState<ViewMode>(loadViewMode())
|
||||||
const theme = useTheme()
|
const theme = useTheme()
|
||||||
@@ -41,8 +48,8 @@ export function ActiveInquiriesTab() {
|
|||||||
return (
|
return (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon={<Inbox size={40} />}
|
icon={<Inbox size={40} />}
|
||||||
title="Keine aktiven Anfragen"
|
title={emptyTitle ?? 'Keine aktiven Anfragen'}
|
||||||
description="Sobald Interessenten Anfragen zu Ihren Objekten stellen, erscheinen diese hier."
|
description={emptyDescription ?? 'Sobald Interessenten Anfragen zu Ihren Objekten stellen, erscheinen diese hier.'}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -93,18 +100,18 @@ export function ActiveInquiriesTab() {
|
|||||||
<Typography variant="body2" sx={{ fontWeight: 600, color: '#0f172a' }}>
|
<Typography variant="body2" sx={{ fontWeight: 600, color: '#0f172a' }}>
|
||||||
Anfragen
|
Anfragen
|
||||||
</Typography>
|
</Typography>
|
||||||
<Box sx={{ px: 1, py: 0.125, borderRadius: 1, bgcolor: '#1e3a5f', color: 'white', fontWeight: 600, fontSize: '0.7rem' }}>
|
<Box sx={{ px: 1, py: 0.125, borderRadius: 1, bgcolor: '#152642', color: 'white', fontWeight: 600, fontSize: '0.7rem' }}>
|
||||||
{inquiries.length}
|
{inquiries.length}
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
<Box sx={{ display: 'flex', gap: 0.5 }}>
|
<Box sx={{ display: 'flex', gap: 0.5 }}>
|
||||||
<Tooltip title="Listenansicht">
|
<Tooltip title="Listenansicht">
|
||||||
<IconButton size="small" onClick={() => setView('list')} sx={{ bgcolor: view === 'list' ? '#e0e7ff' : 'transparent', color: view === 'list' ? '#1e3a5f' : '#64748b' }}>
|
<IconButton size="small" onClick={() => setView('list')} sx={{ bgcolor: view === 'list' ? '#e0e7ff' : 'transparent', color: view === 'list' ? '#152642' : '#64748b' }}>
|
||||||
<ListIcon size={16} />
|
<ListIcon size={16} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip title="Kartenansicht">
|
<Tooltip title="Kartenansicht">
|
||||||
<IconButton size="small" onClick={() => setView('grid')} sx={{ bgcolor: view === 'grid' ? '#e0e7ff' : 'transparent', color: view === 'grid' ? '#1e3a5f' : '#64748b' }}>
|
<IconButton size="small" onClick={() => setView('grid')} sx={{ bgcolor: view === 'grid' ? '#e0e7ff' : 'transparent', color: view === 'grid' ? '#152642' : '#64748b' }}>
|
||||||
<LayoutGrid size={16} />
|
<LayoutGrid size={16} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|||||||
@@ -0,0 +1,146 @@
|
|||||||
|
import { Box, Button, Checkbox, Paper, Typography } from '@mui/material'
|
||||||
|
import { Building2, MapPin } from 'lucide-react'
|
||||||
|
import type { Property } from '../../domain/property'
|
||||||
|
import { getScoreTier, SCORE_THEME } from '../shared/scoreTheme'
|
||||||
|
import { assetTypeLabel } from './latentNeedUtils'
|
||||||
|
|
||||||
|
interface EmbeddedPropertyCardProps {
|
||||||
|
property: Property
|
||||||
|
matchScore: number
|
||||||
|
selected: boolean
|
||||||
|
onToggle: () => void
|
||||||
|
reason: string
|
||||||
|
onQuickOffer: () => void
|
||||||
|
disabled?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EmbeddedPropertyCard({
|
||||||
|
property,
|
||||||
|
matchScore,
|
||||||
|
selected,
|
||||||
|
onToggle,
|
||||||
|
reason,
|
||||||
|
onQuickOffer,
|
||||||
|
disabled,
|
||||||
|
}: EmbeddedPropertyCardProps) {
|
||||||
|
const tier = getScoreTier(matchScore)
|
||||||
|
const theme = SCORE_THEME[tier]
|
||||||
|
const image = property.images?.[0]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper
|
||||||
|
elevation={0}
|
||||||
|
sx={{
|
||||||
|
borderRadius: 2,
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: selected ? '#152642' : '#e2e8f0',
|
||||||
|
bgcolor: selected ? '#f8fafc' : 'white',
|
||||||
|
overflow: 'hidden',
|
||||||
|
transition: 'all 0.15s',
|
||||||
|
'&:hover': { borderColor: selected ? '#152642' : '#94a3b8', boxShadow: '0 2px 8px rgba(15,23,42,0.07)' },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Info row */}
|
||||||
|
<Box sx={{ display: 'flex', gap: 2, p: 2 }}>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: 72,
|
||||||
|
height: 72,
|
||||||
|
flexShrink: 0,
|
||||||
|
borderRadius: 1.5,
|
||||||
|
bgcolor: '#e8e7e4',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
backgroundImage: image ? `url(${image})` : 'none',
|
||||||
|
backgroundSize: 'cover',
|
||||||
|
backgroundPosition: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{!image && <Building2 size={24} color="#94a3b8" />}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 1 }}>
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 700, color: '#0f172a', fontSize: '0.9rem', lineHeight: 1.3 }}>
|
||||||
|
{property.title}
|
||||||
|
</Typography>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
background: theme.gradient,
|
||||||
|
color: theme.text,
|
||||||
|
px: 1.25,
|
||||||
|
py: 0.375,
|
||||||
|
borderRadius: 1,
|
||||||
|
fontSize: '0.9rem',
|
||||||
|
fontWeight: 700,
|
||||||
|
flexShrink: 0,
|
||||||
|
border: `1px solid ${theme.border}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{matchScore}%
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mt: 0.5, color: '#64748b' }}>
|
||||||
|
<MapPin size={12} />
|
||||||
|
<Typography variant="caption" sx={{ fontSize: '0.75rem' }}>
|
||||||
|
{property.location.city} · {assetTypeLabel(property.assetType)} · {property.areaSqm.toLocaleString('de-CH')} m²
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Typography variant="caption" sx={{ fontSize: '0.775rem', color: '#15803d', fontWeight: 600, display: 'block', mt: 0.5 }}>
|
||||||
|
{reason}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Action row */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
borderTop: '1px solid #f1f5f9',
|
||||||
|
px: 2,
|
||||||
|
py: 1,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
bgcolor: '#fafafa',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
onClick={!disabled ? onToggle : undefined}
|
||||||
|
sx={{ display: 'flex', alignItems: 'center', gap: 0.5, cursor: disabled ? 'default' : 'pointer' }}
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
checked={selected}
|
||||||
|
onChange={onToggle}
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
size="small"
|
||||||
|
disabled={disabled}
|
||||||
|
sx={{ p: 0.5 }}
|
||||||
|
/>
|
||||||
|
<Typography variant="caption" sx={{ fontSize: '0.775rem', color: '#475569', fontWeight: 500 }}>
|
||||||
|
Für Angebot auswählen
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
onClick={onQuickOffer}
|
||||||
|
disabled={disabled}
|
||||||
|
sx={{
|
||||||
|
textTransform: 'none',
|
||||||
|
fontSize: '0.775rem',
|
||||||
|
fontWeight: 600,
|
||||||
|
borderColor: '#152642',
|
||||||
|
color: '#152642',
|
||||||
|
py: 0.5,
|
||||||
|
px: 1.5,
|
||||||
|
minWidth: 0,
|
||||||
|
'&:hover': { bgcolor: '#152642', color: 'white', borderColor: '#152642' },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Angebot →
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -20,12 +20,12 @@ export function InquiryCard({ inquiry, selected, onClick }: InquiryCardProps) {
|
|||||||
p: 2,
|
p: 2,
|
||||||
borderRadius: 1.5,
|
borderRadius: 1.5,
|
||||||
border: '1px solid',
|
border: '1px solid',
|
||||||
borderColor: selected ? '#1e3a5f' : '#e2e8f0',
|
borderColor: selected ? '#152642' : '#e8e7e4',
|
||||||
bgcolor: selected ? '#f1f5f9' : 'white',
|
bgcolor: selected ? '#f1f5f9' : 'white',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
transition: 'all 0.15s',
|
transition: 'all 0.15s',
|
||||||
'&:hover': {
|
'&:hover': {
|
||||||
borderColor: selected ? '#1e3a5f' : '#94a3b8',
|
borderColor: selected ? '#152642' : '#94a3b8',
|
||||||
boxShadow: '0 2px 6px rgba(15,23,42,0.06)',
|
boxShadow: '0 2px 6px rgba(15,23,42,0.06)',
|
||||||
},
|
},
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
@@ -96,19 +96,9 @@ export function InquiryCard({ inquiry, selected, onClick }: InquiryCardProps) {
|
|||||||
{formatInquiryDate(inquiry.createdAt)}
|
{formatInquiryDate(inquiry.createdAt)}
|
||||||
</Typography>
|
</Typography>
|
||||||
{inquiry.matchScore !== undefined && (
|
{inquiry.matchScore !== undefined && (
|
||||||
<Box
|
<Typography sx={{ fontSize: '0.75rem', fontWeight: 700, color: '#152642' }}>
|
||||||
sx={{
|
{inquiry.matchScore}%
|
||||||
px: 1,
|
</Typography>
|
||||||
py: 0.25,
|
|
||||||
borderRadius: 1,
|
|
||||||
bgcolor: '#e0e7ff',
|
|
||||||
color: '#3730a3',
|
|
||||||
fontWeight: 600,
|
|
||||||
fontSize: '0.7rem',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Match {inquiry.matchScore}%
|
|
||||||
</Box>
|
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ export function InquiryChat({
|
|||||||
fontSize: '0.75rem',
|
fontSize: '0.75rem',
|
||||||
borderColor: '#cbd5e1',
|
borderColor: '#cbd5e1',
|
||||||
color: '#475569',
|
color: '#475569',
|
||||||
'&:hover': { borderColor: '#1e3a5f', color: '#1e3a5f', bgcolor: '#f0f4f8' },
|
'&:hover': { borderColor: '#152642', color: '#152642', bgcolor: '#f0f4f8' },
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Angebot erstellen
|
Angebot erstellen
|
||||||
@@ -56,6 +56,7 @@ export function InquiryChat({
|
|||||||
<InquiryReplyComposer
|
<InquiryReplyComposer
|
||||||
inquiryId={inquiry.id}
|
inquiryId={inquiry.id}
|
||||||
defaultSubject={inquiry.subject}
|
defaultSubject={inquiry.subject}
|
||||||
|
tenantName={inquiry.tenantName}
|
||||||
pendingAttachment={pendingAttachment}
|
pendingAttachment={pendingAttachment}
|
||||||
onPendingAttachmentConsumed={onPendingAttachmentConsumed}
|
onPendingAttachmentConsumed={onPendingAttachmentConsumed}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { Box, Button, CircularProgress, Typography } from '@mui/material'
|
import { Box, Button, CircularProgress, Typography } from '@mui/material'
|
||||||
import { ClipboardList } from 'lucide-react'
|
import { ArrowRight, Building2, ClipboardList, MapPin, Ruler } from 'lucide-react'
|
||||||
|
import { useNavigate } from 'react-router'
|
||||||
import { useInquiryById, useMarkThreadAsRead } from '../../hooks/useInquiries'
|
import { useInquiryById, useMarkThreadAsRead } from '../../hooks/useInquiries'
|
||||||
|
import { usePropertyById } from '../../hooks/useProperties'
|
||||||
import { InquiryChat } from './InquiryChat'
|
import { InquiryChat } from './InquiryChat'
|
||||||
import { RelatedPropertyCardPanel } from './RelatedPropertyCardPanel'
|
import { RelatedPropertyCardPanel } from './RelatedPropertyCardPanel'
|
||||||
import type { Attachment } from '../../domain/inquiry'
|
import type { Attachment } from '../../domain/inquiry'
|
||||||
@@ -12,7 +14,9 @@ interface InquiryDetailPanelProps {
|
|||||||
|
|
||||||
export function InquiryDetailPanel({ inquiryId }: InquiryDetailPanelProps) {
|
export function InquiryDetailPanel({ inquiryId }: InquiryDetailPanelProps) {
|
||||||
const { data: inquiry, isLoading } = useInquiryById(inquiryId)
|
const { data: inquiry, isLoading } = useInquiryById(inquiryId)
|
||||||
|
const { data: property } = usePropertyById(inquiry?.propertyId ?? '')
|
||||||
const markRead = useMarkThreadAsRead()
|
const markRead = useMarkThreadAsRead()
|
||||||
|
const navigate = useNavigate()
|
||||||
const [preparationOpen, setPreparationOpen] = useState(false)
|
const [preparationOpen, setPreparationOpen] = useState(false)
|
||||||
const [offerWizardOpen, setOfferWizardOpen] = useState(false)
|
const [offerWizardOpen, setOfferWizardOpen] = useState(false)
|
||||||
const [pendingAttachment, setPendingAttachment] = useState<Attachment | null>(null)
|
const [pendingAttachment, setPendingAttachment] = useState<Attachment | null>(null)
|
||||||
@@ -43,10 +47,13 @@ export function InquiryDetailPanel({ inquiryId }: InquiryDetailPanelProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const isLatentInquiry = !!inquiry.needId
|
const isLatentInquiry = !!inquiry.needId
|
||||||
|
const propertyImage = property?.images?.[0]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
||||||
|
|
||||||
|
{/* Header: tenant + subject + action */}
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
px: 2.5,
|
px: 2.5,
|
||||||
@@ -85,28 +92,94 @@ export function InquiryDetailPanel({ inquiryId }: InquiryDetailPanelProps) {
|
|||||||
variant="outlined"
|
variant="outlined"
|
||||||
startIcon={<ClipboardList size={14} />}
|
startIcon={<ClipboardList size={14} />}
|
||||||
onClick={() => setPreparationOpen(true)}
|
onClick={() => setPreparationOpen(true)}
|
||||||
sx={{ textTransform: 'none', whiteSpace: 'nowrap', borderColor: '#1e3a5f', color: '#1e3a5f', '&:hover': { bgcolor: '#f0f4f8' } }}
|
sx={{ textTransform: 'none', whiteSpace: 'nowrap', borderColor: '#152642', color: '#152642', '&:hover': { bgcolor: '#f0f4f8' } }}
|
||||||
>
|
>
|
||||||
Vorbereitung starten
|
Vorbereitung starten
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Box sx={{ flex: 1, display: 'flex', flexDirection: { xs: 'column', md: 'row' }, overflow: 'hidden' }}>
|
{/* Property context bar */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
px: 2,
|
||||||
|
py: 1,
|
||||||
|
borderBottom: '1px solid #e2e8f0',
|
||||||
|
bgcolor: '#f8fafc',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 1.5,
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: 44,
|
||||||
|
height: 44,
|
||||||
|
borderRadius: 1,
|
||||||
|
overflow: 'hidden',
|
||||||
|
flexShrink: 0,
|
||||||
|
bgcolor: '#e8e7e4',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{propertyImage ? (
|
||||||
|
<Box
|
||||||
|
component="img"
|
||||||
|
src={propertyImage}
|
||||||
|
alt={property?.title}
|
||||||
|
sx={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Building2 size={18} color="#94a3b8" />
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<Typography
|
||||||
|
sx={{ fontWeight: 600, color: '#0f172a', fontSize: '0.85rem', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', lineHeight: 1.3 }}
|
||||||
|
>
|
||||||
|
{property?.title ?? '—'}
|
||||||
|
</Typography>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, color: '#64748b' }}>
|
||||||
|
<MapPin size={11} style={{ flexShrink: 0 }} />
|
||||||
|
<Typography variant="caption" sx={{ fontSize: '0.72rem', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||||
|
{property?.location.city}
|
||||||
|
{property?.areaSqm ? ` · ${property.areaSqm.toLocaleString('de-CH')} m²` : ''}
|
||||||
|
{property?.rentPricePerSqm ? ` · CHF ${property.rentPricePerSqm}/m²/J.` : ''}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
endIcon={<ArrowRight size={12} />}
|
||||||
|
onClick={() => navigate('/supply/properties')}
|
||||||
|
sx={{ textTransform: 'none', fontSize: '0.75rem', color: '#152642', whiteSpace: 'nowrap', flexShrink: 0 }}
|
||||||
|
>
|
||||||
|
Objekt ansehen
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Chat (flex: 1) */}
|
||||||
|
<Box sx={{ flex: 1, overflow: 'hidden' }}>
|
||||||
<InquiryChat
|
<InquiryChat
|
||||||
inquiry={inquiry}
|
inquiry={inquiry}
|
||||||
onCreateOffer={() => setOfferWizardOpen(true)}
|
onCreateOffer={() => setOfferWizardOpen(true)}
|
||||||
pendingAttachment={pendingAttachment}
|
pendingAttachment={pendingAttachment}
|
||||||
onPendingAttachmentConsumed={() => setPendingAttachment(null)}
|
onPendingAttachmentConsumed={() => setPendingAttachment(null)}
|
||||||
/>
|
/>
|
||||||
<Box sx={{ width: { xs: '100%', md: 300 }, flexShrink: 0, borderTop: { xs: '1px solid #e2e8f0', md: 'none' }, maxHeight: { xs: 280, md: 'none' }, overflowY: 'auto' }}>
|
</Box>
|
||||||
|
|
||||||
|
{/* Bottom: weitere passende Objekte */}
|
||||||
|
<Box sx={{ flexShrink: 0, borderTop: '1px solid #e2e8f0' }}>
|
||||||
<RelatedPropertyCardPanel
|
<RelatedPropertyCardPanel
|
||||||
propertyId={inquiry.propertyId}
|
propertyId={inquiry.propertyId}
|
||||||
inquiryId={inquiry.id}
|
inquiryId={inquiry.id}
|
||||||
|
compact
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
|
||||||
|
|
||||||
{preparationOpen && (
|
{preparationOpen && (
|
||||||
<PreparationWizardLazy
|
<PreparationWizardLazy
|
||||||
|
|||||||
@@ -17,11 +17,10 @@ export function InquiryListRow({ inquiry, selected, onClick }: InquiryListRowPro
|
|||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
sx={{
|
sx={{
|
||||||
px: 2,
|
px: 2,
|
||||||
py: 1.5,
|
py: 1,
|
||||||
borderBottom: '1px solid #e2e8f0',
|
borderBottom: '1px solid #e2e8f0',
|
||||||
borderLeft: '3px solid',
|
borderLeft: selected ? '4px solid #2563eb' : `3px solid ${hasUnread ? '#2563eb' : 'transparent'}`,
|
||||||
borderLeftColor: selected ? '#1e3a5f' : hasUnread ? '#2563eb' : 'transparent',
|
bgcolor: selected ? '#eff6ff' : 'white',
|
||||||
bgcolor: selected ? '#f1f5f9' : 'white',
|
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
transition: 'background-color 0.15s, border-color 0.15s',
|
transition: 'background-color 0.15s, border-color 0.15s',
|
||||||
'&:hover': { bgcolor: selected ? '#f1f5f9' : '#f8fafc' },
|
'&:hover': { bgcolor: selected ? '#f1f5f9' : '#f8fafc' },
|
||||||
@@ -30,7 +29,7 @@ export function InquiryListRow({ inquiry, selected, onClick }: InquiryListRowPro
|
|||||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 0.5 }}>
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 0.5 }}>
|
||||||
<Typography
|
<Typography
|
||||||
variant="body2"
|
variant="body2"
|
||||||
sx={{ fontWeight: hasUnread ? 700 : 600, color: '#0f172a', fontSize: '0.85rem' }}
|
sx={{ fontWeight: selected || hasUnread ? 700 : 600, color: selected ? '#1d4ed8' : '#0f172a', fontSize: '0.85rem' }}
|
||||||
>
|
>
|
||||||
{inquiry.tenantName}
|
{inquiry.tenantName}
|
||||||
{inquiry.tenantCompany ? ` · ${inquiry.tenantCompany}` : ''}
|
{inquiry.tenantCompany ? ` · ${inquiry.tenantCompany}` : ''}
|
||||||
@@ -76,20 +75,9 @@ export function InquiryListRow({ inquiry, selected, onClick }: InquiryListRowPro
|
|||||||
{propertyLabelFromId(inquiry.propertyId)}
|
{propertyLabelFromId(inquiry.propertyId)}
|
||||||
</Typography>
|
</Typography>
|
||||||
{inquiry.matchScore !== undefined && (
|
{inquiry.matchScore !== undefined && (
|
||||||
<Box
|
<Typography sx={{ ml: 'auto', fontSize: '0.75rem', fontWeight: 700, color: '#152642' }}>
|
||||||
sx={{
|
|
||||||
ml: 'auto',
|
|
||||||
px: 0.75,
|
|
||||||
py: 0.125,
|
|
||||||
borderRadius: 1,
|
|
||||||
bgcolor: '#e0e7ff',
|
|
||||||
color: '#3730a3',
|
|
||||||
fontWeight: 600,
|
|
||||||
fontSize: '0.7rem',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{inquiry.matchScore}%
|
{inquiry.matchScore}%
|
||||||
</Box>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
<Typography variant="caption" sx={{ color: '#94a3b8', fontSize: '0.7rem', mt: 0.5, display: 'block' }}>
|
<Typography variant="caption" sx={{ color: '#94a3b8', fontSize: '0.7rem', mt: 0.5, display: 'block' }}>
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ export function InquiryMessageBubble({ message }: InquiryMessageBubbleProps) {
|
|||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
maxWidth: '75%',
|
maxWidth: '75%',
|
||||||
bgcolor: isTenant ? '#f1f5f9' : '#1e3a5f',
|
bgcolor: isTenant ? '#f1f5f9' : '#152642',
|
||||||
color: isTenant ? '#0f172a' : 'white',
|
color: isTenant ? '#0f172a' : 'white',
|
||||||
px: 1.75,
|
px: 1.75,
|
||||||
py: 1.25,
|
py: 1.25,
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
TextField,
|
TextField,
|
||||||
Typography,
|
Typography,
|
||||||
} from '@mui/material'
|
} from '@mui/material'
|
||||||
import { Send, Paperclip, X } from 'lucide-react'
|
import { Paperclip, Send, Sparkles, X } from 'lucide-react'
|
||||||
import { useSendInquiryReply } from '../../hooks/useInquiries'
|
import { useSendInquiryReply } from '../../hooks/useInquiries'
|
||||||
import { useToastStore } from '../../stores/toastStore'
|
import { useToastStore } from '../../stores/toastStore'
|
||||||
import type { Attachment } from '../../domain/inquiry'
|
import type { Attachment } from '../../domain/inquiry'
|
||||||
@@ -16,6 +16,7 @@ import { formatFileSize } from './inquiryUtils'
|
|||||||
interface InquiryReplyComposerProps {
|
interface InquiryReplyComposerProps {
|
||||||
inquiryId: string
|
inquiryId: string
|
||||||
defaultSubject: string
|
defaultSubject: string
|
||||||
|
tenantName?: string
|
||||||
onSent?: () => void
|
onSent?: () => void
|
||||||
pendingAttachment?: Attachment | null
|
pendingAttachment?: Attachment | null
|
||||||
onPendingAttachmentConsumed?: () => void
|
onPendingAttachmentConsumed?: () => void
|
||||||
@@ -24,6 +25,7 @@ interface InquiryReplyComposerProps {
|
|||||||
export function InquiryReplyComposer({
|
export function InquiryReplyComposer({
|
||||||
inquiryId,
|
inquiryId,
|
||||||
defaultSubject,
|
defaultSubject,
|
||||||
|
tenantName,
|
||||||
onSent,
|
onSent,
|
||||||
pendingAttachment,
|
pendingAttachment,
|
||||||
onPendingAttachmentConsumed,
|
onPendingAttachmentConsumed,
|
||||||
@@ -33,6 +35,7 @@ export function InquiryReplyComposer({
|
|||||||
)
|
)
|
||||||
const [body, setBody] = useState('')
|
const [body, setBody] = useState('')
|
||||||
const [attachments, setAttachments] = useState<Attachment[]>([])
|
const [attachments, setAttachments] = useState<Attachment[]>([])
|
||||||
|
const [isAIDraft, setIsAIDraft] = useState(false)
|
||||||
|
|
||||||
const sendReply = useSendInquiryReply()
|
const sendReply = useSendInquiryReply()
|
||||||
const showToast = useToastStore(s => s.showToast)
|
const showToast = useToastStore(s => s.showToast)
|
||||||
@@ -49,6 +52,14 @@ export function InquiryReplyComposer({
|
|||||||
|
|
||||||
const sending = sendReply.isPending
|
const sending = sendReply.isPending
|
||||||
|
|
||||||
|
function handleAIDraft() {
|
||||||
|
const salutation = tenantName ? `Sehr geehrte/r ${tenantName}` : 'Sehr geehrte Damen und Herren'
|
||||||
|
setBody(
|
||||||
|
`${salutation},\n\nVielen Dank für Ihre Anfrage. Gerne bestätigen wir, dass das Objekt zum gewünschten Zeitpunkt verfügbar ist.\n\nWir würden Ihnen gerne einen Besichtigungstermin vorschlagen — bitte teilen Sie uns Ihre Verfügbarkeit mit, damit wir einen passenden Termin finden können.\n\nFür Rückfragen stehen wir Ihnen jederzeit gerne zur Verfügung.\n\nMit freundlichen Grüssen`
|
||||||
|
)
|
||||||
|
setIsAIDraft(true)
|
||||||
|
}
|
||||||
|
|
||||||
const handleAddMockAttachment = () => {
|
const handleAddMockAttachment = () => {
|
||||||
const name = `Anhang_${attachments.length + 1}.pdf`
|
const name = `Anhang_${attachments.length + 1}.pdf`
|
||||||
setAttachments(prev => [
|
setAttachments(prev => [
|
||||||
@@ -82,6 +93,7 @@ export function InquiryReplyComposer({
|
|||||||
showToast('Antwort gesendet', 'success')
|
showToast('Antwort gesendet', 'success')
|
||||||
setBody('')
|
setBody('')
|
||||||
setAttachments([])
|
setAttachments([])
|
||||||
|
setIsAIDraft(false)
|
||||||
onSent?.()
|
onSent?.()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,15 +115,26 @@ export function InquiryReplyComposer({
|
|||||||
onChange={e => setSubject(e.target.value)}
|
onChange={e => setSubject(e.target.value)}
|
||||||
fullWidth
|
fullWidth
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{isAIDraft && (
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
|
||||||
|
<Sparkles size={12} color="#7c3aed" />
|
||||||
|
<Typography variant="caption" sx={{ color: '#7c3aed', fontSize: '0.7rem', fontWeight: 500 }}>
|
||||||
|
KI-Entwurf — bitte prüfen und anpassen
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
<TextField
|
<TextField
|
||||||
size="small"
|
size="small"
|
||||||
label="Nachricht"
|
label="Nachricht"
|
||||||
value={body}
|
value={body}
|
||||||
onChange={e => setBody(e.target.value)}
|
onChange={e => { setBody(e.target.value); setIsAIDraft(false) }}
|
||||||
multiline
|
multiline
|
||||||
rows={4}
|
rows={7}
|
||||||
placeholder="Antwort verfassen..."
|
placeholder="Antwort verfassen..."
|
||||||
fullWidth
|
fullWidth
|
||||||
|
sx={isAIDraft ? { '& .MuiOutlinedInput-root': { borderColor: '#ddd6fe' }, '& fieldset': { borderColor: '#ddd6fe !important' } } : {}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{attachments.length > 0 && (
|
{attachments.length > 0 && (
|
||||||
@@ -123,7 +146,6 @@ export function InquiryReplyComposer({
|
|||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: 0.75,
|
gap: 0.75,
|
||||||
bgcolor: 'white',
|
|
||||||
border: '1px solid',
|
border: '1px solid',
|
||||||
borderColor: a.generated ? '#bfdbfe' : '#cbd5e1',
|
borderColor: a.generated ? '#bfdbfe' : '#cbd5e1',
|
||||||
borderRadius: 1,
|
borderRadius: 1,
|
||||||
@@ -150,15 +172,32 @@ export function InquiryReplyComposer({
|
|||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, justifyContent: 'space-between' }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, justifyContent: 'space-between' }}>
|
||||||
|
<Box sx={{ display: 'flex', gap: 0.75 }}>
|
||||||
<Button
|
<Button
|
||||||
size="small"
|
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}
|
onClick={handleAddMockAttachment}
|
||||||
sx={{ textTransform: 'none' }}
|
sx={{ textTransform: 'none', fontSize: '0.75rem' }}
|
||||||
>
|
>
|
||||||
Anhang
|
Anhang
|
||||||
</Button>
|
</Button>
|
||||||
|
</Box>
|
||||||
<Button
|
<Button
|
||||||
variant="contained"
|
variant="contained"
|
||||||
size="small"
|
size="small"
|
||||||
@@ -167,7 +206,7 @@ export function InquiryReplyComposer({
|
|||||||
}
|
}
|
||||||
onClick={handleSend}
|
onClick={handleSend}
|
||||||
disabled={sending || !body.trim()}
|
disabled={sending || !body.trim()}
|
||||||
sx={{ textTransform: 'none', bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#16304d' } }}
|
sx={{ textTransform: 'none', bgcolor: '#152642', '&:hover': { bgcolor: '#16304d' } }}
|
||||||
>
|
>
|
||||||
Antwort senden
|
Antwort senden
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { usePublicNeeds, useLatentNeedById } from '../../hooks/useLatentNeeds'
|
|||||||
import { EmptyState } from '../ui'
|
import { EmptyState } from '../ui'
|
||||||
import { PublicNeedList } from './PublicNeedList'
|
import { PublicNeedList } from './PublicNeedList'
|
||||||
import { PublicNeedDetail } from './PublicNeedDetail'
|
import { PublicNeedDetail } from './PublicNeedDetail'
|
||||||
import { OwnPropertyMatchList } from './OwnPropertyMatchList'
|
|
||||||
|
|
||||||
type MobileView = 'list' | 'detail' | 'properties'
|
type MobileView = 'list' | 'detail' | 'properties'
|
||||||
|
|
||||||
@@ -47,7 +46,7 @@ export function LatentInquiriesTab() {
|
|||||||
<Box sx={{ px: 2, py: 1.5, borderTop: '1px solid #e2e8f0', flexShrink: 0 }}>
|
<Box sx={{ px: 2, py: 1.5, borderTop: '1px solid #e2e8f0', flexShrink: 0 }}>
|
||||||
<Box
|
<Box
|
||||||
onClick={() => setMobileView('properties')}
|
onClick={() => setMobileView('properties')}
|
||||||
sx={{ p: 1.25, bgcolor: '#1e3a5f', color: 'white', borderRadius: 1, textAlign: 'center', cursor: 'pointer', fontSize: '0.85rem', fontWeight: 600 }}
|
sx={{ p: 1.25, bgcolor: '#152642', color: 'white', borderRadius: 1, textAlign: 'center', cursor: 'pointer', fontSize: '0.85rem', fontWeight: 600 }}
|
||||||
>
|
>
|
||||||
Eigene Objekte anzeigen →
|
Eigene Objekte anzeigen →
|
||||||
</Box>
|
</Box>
|
||||||
@@ -112,7 +111,6 @@ export function LatentInquiriesTab() {
|
|||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
{selectedNeed && <OwnPropertyMatchList need={selectedNeed} />}
|
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,86 @@
|
|||||||
import { Box, Typography } from '@mui/material'
|
import { Box, Divider, Typography } from '@mui/material'
|
||||||
import { FileText } from 'lucide-react'
|
import { Building2, FileText, MapPin } from 'lucide-react'
|
||||||
import { useOfferWizardStore } from '../../stores/offerWizardStore'
|
import { useOfferWizardStore } from '../../stores/offerWizardStore'
|
||||||
import { mockProperties } from '../../mock-data/properties'
|
import { mockProperties } from '../../mock-data/properties'
|
||||||
|
import type { ReportObjectFieldKey, ReportObjectFieldSelection } from '../../domain/inquiryReport'
|
||||||
|
import type { Property } from '../../domain/property'
|
||||||
|
|
||||||
interface MockPdfPreviewProps {
|
interface MockPdfPreviewProps {
|
||||||
fields: Record<string, string>
|
fields: Record<string, string>
|
||||||
fallbackFields: Record<string, string>
|
fallbackFields: Record<string, string>
|
||||||
|
fieldSelections: ReportObjectFieldSelection[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MockPdfPreview({ fields, fallbackFields }: MockPdfPreviewProps) {
|
const FIELD_LABELS: Partial<Record<ReportObjectFieldKey, string>> = {
|
||||||
|
areaSqm: 'Fläche',
|
||||||
|
rentPricePerSqm: 'Mietpreis',
|
||||||
|
availabilityDate: 'Verfügbar ab',
|
||||||
|
leaseTerm: 'Mietlaufzeit',
|
||||||
|
breakoutOption: 'Breakout-Option',
|
||||||
|
currentTenant: 'Aktueller Mieter',
|
||||||
|
propertyNumber: 'Objektnummer',
|
||||||
|
assetType: 'Asset Type',
|
||||||
|
floor: 'Stockwerk',
|
||||||
|
parking: 'Parkplätze',
|
||||||
|
fitOut: 'Ausbaugrad',
|
||||||
|
isBarrierFree: 'Barrierefrei',
|
||||||
|
ceilingHeightM: 'Raumhöhe',
|
||||||
|
floorLoad: 'Bodenlast',
|
||||||
|
loadingDocksCount: 'Anlieferung',
|
||||||
|
goodsLift: 'Warenaufzug',
|
||||||
|
passengerLift: 'Personenaufzug',
|
||||||
|
powerSupplyKva: 'Stromanschluss',
|
||||||
|
hasServerRoom: 'Serverraum',
|
||||||
|
internet: 'Internet',
|
||||||
|
deliveryAccess: 'Zufahrt',
|
||||||
|
publicTransportScore: 'ÖV-Anbindung',
|
||||||
|
prestigeScore: 'Prestige',
|
||||||
|
visibilityScore: 'Sichtbarkeit',
|
||||||
|
footfallScore: 'Passantenfrequenz',
|
||||||
|
commuterAccessScore: 'Pendlererreichbarkeit',
|
||||||
|
talentAccessScore: 'Talent Access',
|
||||||
|
esgScore: 'ESG',
|
||||||
|
flexibilityScore: 'Flexibilität',
|
||||||
|
expansionPotentialScore: 'Expansionspotenzial',
|
||||||
|
taxEnvironmentScore: 'Steuerumfeld',
|
||||||
|
microLocation: 'Mikrostandort',
|
||||||
|
competitionEnvironment: 'Konkurrenzumfeld',
|
||||||
|
infrastructure: 'Infrastruktur',
|
||||||
|
description: 'Beschreibung',
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFieldValue(property: Property, key: ReportObjectFieldKey): string | null {
|
||||||
|
switch (key) {
|
||||||
|
case 'areaSqm': return property.areaSqm ? `${property.areaSqm.toLocaleString('de-CH')} m²` : null
|
||||||
|
case 'rentPricePerSqm': return property.rentPricePerSqm ? `CHF ${property.rentPricePerSqm}/m²/J` : null
|
||||||
|
case 'availabilityDate': return property.availabilityDate ? new Date(property.availabilityDate).toLocaleDateString('de-CH') : null
|
||||||
|
case 'leaseTerm': return property.leaseTerm ?? null
|
||||||
|
case 'breakoutOption': return property.breakoutOption != null ? (property.breakoutOption ? 'Ja' : 'Nein') : null
|
||||||
|
case 'currentTenant': return property.currentTenant ?? null
|
||||||
|
case 'propertyNumber': return property.propertyNumber ?? null
|
||||||
|
case 'assetType': return property.assetType ?? null
|
||||||
|
case 'floor': return property.floorLevel != null ? String(property.floorLevel) : null
|
||||||
|
case 'parking': return property.softFactors?.parkingSpots != null ? `${property.softFactors.parkingSpots} Pl.` : null
|
||||||
|
case 'ceilingHeightM': return property.hardFacts?.ceilingHeightM != null ? `${property.hardFacts.ceilingHeightM} m` : null
|
||||||
|
case 'loadingDocksCount': return property.hardFacts?.loadingDocksCount != null ? String(property.hardFacts.loadingDocksCount) : null
|
||||||
|
case 'powerSupplyKva': return property.hardFacts?.powerSupplyKva != null ? `${property.hardFacts.powerSupplyKva} kVA` : null
|
||||||
|
case 'hasServerRoom': return property.hardFacts?.hasServerRoom != null ? (property.hardFacts.hasServerRoom ? 'Ja' : 'Nein') : null
|
||||||
|
case 'isBarrierFree': return property.hardFacts?.isBarrierFree != null ? (property.hardFacts.isBarrierFree ? 'Ja' : 'Nein') : null
|
||||||
|
case 'fitOut': return property.hardFacts?.fitOut ?? null
|
||||||
|
case 'publicTransportScore': return property.softFactors?.publicTransportMinutes != null ? `${property.softFactors.publicTransportMinutes} Min.` : null
|
||||||
|
case 'prestigeScore': return property.softFactors?.prestige != null ? `${property.softFactors.prestige}/100` : null
|
||||||
|
case 'visibilityScore': return property.softFactors?.visibilityScore != null ? `${property.softFactors.visibilityScore}/100` : null
|
||||||
|
case 'description': return property.description ?? null
|
||||||
|
default: return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MockPdfPreview({ fields, fallbackFields, fieldSelections }: MockPdfPreviewProps) {
|
||||||
const needTitle = useOfferWizardStore(s => s.needTitle)
|
const needTitle = useOfferWizardStore(s => s.needTitle)
|
||||||
const propertyIds = useOfferWizardStore(s => s.selectedPropertyIds)
|
const propertyIds = useOfferWizardStore(s => s.selectedPropertyIds)
|
||||||
|
|
||||||
const properties = mockProperties.filter(p => propertyIds.includes(p.id))
|
const properties = mockProperties.filter(p => propertyIds.includes(p.id))
|
||||||
|
const hasPropertyPage = fieldSelections.length > 0 && properties.length > 0
|
||||||
|
|
||||||
const v = (id: string) => fields[id] ?? fallbackFields[id] ?? ''
|
const v = (id: string) => fields[id] ?? fallbackFields[id] ?? ''
|
||||||
|
|
||||||
@@ -29,6 +97,7 @@ export function MockPdfPreview({ fields, fallbackFields }: MockPdfPreviewProps)
|
|||||||
fontFamily: '"Georgia", "Times New Roman", serif',
|
fontFamily: '"Georgia", "Times New Roman", serif',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
{/* ── Page 1 — Offer letter ── */}
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3, color: '#64748b' }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3, color: '#64748b' }}>
|
||||||
<FileText size={16} />
|
<FileText size={16} />
|
||||||
<Typography variant="caption" sx={{ textTransform: 'uppercase', letterSpacing: 1, fontSize: '0.7rem' }}>
|
<Typography variant="caption" sx={{ textTransform: 'uppercase', letterSpacing: 1, fontSize: '0.7rem' }}>
|
||||||
@@ -90,6 +159,93 @@ export function MockPdfPreview({ fields, fallbackFields }: MockPdfPreviewProps)
|
|||||||
<Typography variant="body2" sx={{ fontSize: '0.875rem', whiteSpace: 'pre-wrap', lineHeight: 1.6 }}>
|
<Typography variant="body2" sx={{ fontSize: '0.875rem', whiteSpace: 'pre-wrap', lineHeight: 1.6 }}>
|
||||||
{v('closing')}
|
{v('closing')}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
|
{/* ── Page 2 — Property details side by side ── */}
|
||||||
|
{hasPropertyPage && (
|
||||||
|
<>
|
||||||
|
<Box sx={{ my: 3, display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
||||||
|
<Divider sx={{ flex: 1, borderColor: '#152642', borderWidth: 1 }} />
|
||||||
|
<Typography variant="caption" sx={{ color: '#94a3b8', fontSize: '0.68rem', whiteSpace: 'nowrap', letterSpacing: 0.5, textTransform: 'uppercase' }}>
|
||||||
|
Seite 2 — Objektdetails
|
||||||
|
</Typography>
|
||||||
|
<Divider sx={{ flex: 1, borderColor: '#152642', borderWidth: 1 }} />
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: `repeat(${Math.min(properties.length, 3)}, 1fr)`,
|
||||||
|
gap: 1.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{properties.map(p => {
|
||||||
|
const selection = fieldSelections.find(fs => fs.propertyId === p.id)
|
||||||
|
const optionalFields = selection?.selectedOptionalFields ?? []
|
||||||
|
const image = p.images?.[0]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
key={p.id}
|
||||||
|
sx={{
|
||||||
|
border: '1px solid #e2e8f0',
|
||||||
|
borderRadius: 1,
|
||||||
|
overflow: 'hidden',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Photo */}
|
||||||
|
{image ? (
|
||||||
|
<Box
|
||||||
|
component="img"
|
||||||
|
src={image}
|
||||||
|
alt={p.title}
|
||||||
|
sx={{ width: '100%', height: 90, objectFit: 'cover', display: 'block' }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Box sx={{ height: 90, bgcolor: '#f1f5f9', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
|
<Building2 size={22} color="#94a3b8" />
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Info */}
|
||||||
|
<Box sx={{ p: 1.25, flex: 1 }}>
|
||||||
|
<Typography sx={{ fontWeight: 700, fontSize: '0.78rem', color: '#0f172a', lineHeight: 1.3, mb: 0.4 }}>
|
||||||
|
{p.title}
|
||||||
|
</Typography>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.3, mb: 1 }}>
|
||||||
|
<MapPin size={10} color="#94a3b8" />
|
||||||
|
<Typography sx={{ fontSize: '0.68rem', color: '#64748b' }}>
|
||||||
|
{p.location.city}{p.location.district ? ` · ${p.location.district}` : ''}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Selected fields */}
|
||||||
|
{optionalFields.length > 0 && (
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.4 }}>
|
||||||
|
{optionalFields.map(key => {
|
||||||
|
const val = getFieldValue(p, key)
|
||||||
|
if (!val) return null
|
||||||
|
return (
|
||||||
|
<Box key={key} sx={{ display: 'flex', justifyContent: 'space-between', gap: 0.5 }}>
|
||||||
|
<Typography sx={{ fontSize: '0.62rem', color: '#94a3b8' }}>
|
||||||
|
{FIELD_LABELS[key] ?? key}
|
||||||
|
</Typography>
|
||||||
|
<Typography sx={{ fontSize: '0.68rem', fontWeight: 600, color: '#0f172a', textAlign: 'right' }}>
|
||||||
|
{val}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</Box>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ import { Box, Button, CircularProgress, IconButton, TextField, Typography } from
|
|||||||
import { ArrowLeft, FileText, Paperclip, Send, X } from 'lucide-react'
|
import { ArrowLeft, FileText, Paperclip, Send, X } from 'lucide-react'
|
||||||
import { useOfferWizardStore } from '../../stores/offerWizardStore'
|
import { useOfferWizardStore } from '../../stores/offerWizardStore'
|
||||||
import { useSendOffer } from '../../hooks/useOffers'
|
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 { useToastStore } from '../../stores/toastStore'
|
||||||
import { AiOfferEmailButton } from './AiOfferEmailButton'
|
import { AiOfferEmailButton } from './AiOfferEmailButton'
|
||||||
import { mockProperties } from '../../mock-data/properties'
|
import { mockProperties } from '../../mock-data/properties'
|
||||||
@@ -24,6 +27,9 @@ export function OfferChatComposer() {
|
|||||||
const reset = useOfferWizardStore(s => s.reset)
|
const reset = useOfferWizardStore(s => s.reset)
|
||||||
|
|
||||||
const sendOffer = useSendOffer()
|
const sendOffer = useSendOffer()
|
||||||
|
const createOffer = useCreateOffer()
|
||||||
|
const { data: latentNeed } = useLatentNeedById(needId)
|
||||||
|
const currentUser = useSessionStore(s => s.currentUser)
|
||||||
const showToast = useToastStore(s => s.showToast)
|
const showToast = useToastStore(s => s.showToast)
|
||||||
|
|
||||||
const propertyTitles = selectedPropertyIds.map(id => {
|
const propertyTitles = selectedPropertyIds.map(id => {
|
||||||
@@ -60,6 +66,19 @@ export function OfferChatComposer() {
|
|||||||
showToast(`Fehler: ${res.error}`, 'error')
|
showToast(`Fehler: ${res.error}`, 'error')
|
||||||
return
|
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')
|
showToast('Angebot erfolgreich gesendet', 'success')
|
||||||
reset()
|
reset()
|
||||||
}
|
}
|
||||||
@@ -116,7 +135,7 @@ export function OfferChatComposer() {
|
|||||||
py: 0.75,
|
py: 0.75,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<FileText size={14} color="#1e3a5f" />
|
<FileText size={14} color="#152642" />
|
||||||
<Typography variant="caption" sx={{ fontSize: '0.8rem', fontWeight: 500 }}>
|
<Typography variant="caption" sx={{ fontSize: '0.8rem', fontWeight: 500 }}>
|
||||||
{a.fileName}
|
{a.fileName}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ export function OfferCreationPanel({ selectedCount, needId, needTitle }: OfferCr
|
|||||||
onClick={() => open(needId, needTitle)}
|
onClick={() => open(needId, needTitle)}
|
||||||
sx={{
|
sx={{
|
||||||
textTransform: 'none',
|
textTransform: 'none',
|
||||||
bgcolor: '#1e3a5f',
|
bgcolor: '#152642',
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
'&:hover': { bgcolor: '#16304d' },
|
'&:hover': { bgcolor: '#16304d' },
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ export function OfferCreationWizard({ inquiryId, propertyId, tenantName, onClose
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open fullWidth maxWidth="lg" PaperProps={{ sx: { height: '90vh', display: 'flex', flexDirection: 'column' } }}>
|
<Dialog open fullWidth maxWidth="lg" slotProps={{ paper: { sx: { height: '90vh', display: 'flex', flexDirection: 'column' } } }}>
|
||||||
<Box sx={{ px: 3, pt: 2.5, pb: 1.5, borderBottom: '1px solid #e2e8f0', display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexShrink: 0 }}>
|
<Box sx={{ px: 3, pt: 2.5, pb: 1.5, borderBottom: '1px solid #e2e8f0', display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexShrink: 0 }}>
|
||||||
<Typography variant="h6" sx={{ fontWeight: 700 }}>Angebot erstellen</Typography>
|
<Typography variant="h6" sx={{ fontWeight: 700 }}>Angebot erstellen</Typography>
|
||||||
<Button size="small" onClick={onClose} sx={{ minWidth: 0, p: 0.5 }}><X size={18} /></Button>
|
<Button size="small" onClick={onClose} sx={{ minWidth: 0, p: 0.5 }}><X size={18} /></Button>
|
||||||
@@ -187,7 +187,7 @@ export function OfferCreationWizard({ inquiryId, propertyId, tenantName, onClose
|
|||||||
size="small"
|
size="small"
|
||||||
value={a.date}
|
value={a.date}
|
||||||
onChange={e => updateAppointment(a.id, 'date', e.target.value)}
|
onChange={e => updateAppointment(a.id, 'date', e.target.value)}
|
||||||
InputLabelProps={{ shrink: true }}
|
slotProps={{ inputLabel: { shrink: true } }}
|
||||||
sx={{ width: 160 }}
|
sx={{ width: 160 }}
|
||||||
/>
|
/>
|
||||||
<TextField
|
<TextField
|
||||||
@@ -228,7 +228,7 @@ export function OfferCreationWizard({ inquiryId, propertyId, tenantName, onClose
|
|||||||
progress={progress}
|
progress={progress}
|
||||||
ready={ready}
|
ready={ready}
|
||||||
draft={draft}
|
draft={draft}
|
||||||
property={property}
|
property={property ?? undefined}
|
||||||
onDownload={() => showToast('PDF wird heruntergeladen…', 'info')}
|
onDownload={() => showToast('PDF wird heruntergeladen…', 'info')}
|
||||||
onAttach={() => {
|
onAttach={() => {
|
||||||
const label = `Angebot_${property?.title ?? 'Objekt'}.pdf`
|
const label = `Angebot_${property?.title ?? 'Objekt'}.pdf`
|
||||||
@@ -255,7 +255,7 @@ export function OfferCreationWizard({ inquiryId, propertyId, tenantName, onClose
|
|||||||
variant="contained"
|
variant="contained"
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
onClick={() => setStep(1)}
|
onClick={() => setStep(1)}
|
||||||
sx={{ textTransform: 'none', bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#16304d' } }}
|
sx={{ textTransform: 'none', bgcolor: '#152642', '&:hover': { bgcolor: '#16304d' } }}
|
||||||
>
|
>
|
||||||
Weiter
|
Weiter
|
||||||
</Button>
|
</Button>
|
||||||
@@ -264,7 +264,7 @@ export function OfferCreationWizard({ inquiryId, propertyId, tenantName, onClose
|
|||||||
<Button
|
<Button
|
||||||
variant="contained"
|
variant="contained"
|
||||||
onClick={() => setStep(2)}
|
onClick={() => setStep(2)}
|
||||||
sx={{ textTransform: 'none', bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#16304d' } }}
|
sx={{ textTransform: 'none', bgcolor: '#152642', '&:hover': { bgcolor: '#16304d' } }}
|
||||||
>
|
>
|
||||||
Weiter
|
Weiter
|
||||||
</Button>
|
</Button>
|
||||||
@@ -273,7 +273,7 @@ export function OfferCreationWizard({ inquiryId, propertyId, tenantName, onClose
|
|||||||
<Button
|
<Button
|
||||||
variant="contained"
|
variant="contained"
|
||||||
onClick={handleGeneratePdf}
|
onClick={handleGeneratePdf}
|
||||||
sx={{ textTransform: 'none', bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#16304d' } }}
|
sx={{ textTransform: 'none', bgcolor: '#152642', '&:hover': { bgcolor: '#16304d' } }}
|
||||||
>
|
>
|
||||||
PDF erstellen
|
PDF erstellen
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { useEffect } from 'react'
|
||||||
|
import { Box, Button, Stack, Typography } from '@mui/material'
|
||||||
|
import { ArrowLeft } from 'lucide-react'
|
||||||
|
import { useOfferWizardStore } from '../../stores/offerWizardStore'
|
||||||
|
import { useProperties } from '../../hooks/useProperties'
|
||||||
|
import { ReportObjectFieldSelector } from './ReportObjectFieldSelector'
|
||||||
|
import type { ReportObjectFieldKey, ReportObjectFieldSelection } from '../../domain/inquiryReport'
|
||||||
|
|
||||||
|
const DEFAULT_FIELDS: ReportObjectFieldKey[] = ['areaSqm', 'rentPricePerSqm', 'availabilityDate']
|
||||||
|
const MANDATORY_FIELDS: ReportObjectFieldKey[] = ['title', 'location', 'mapImageUrl', 'images']
|
||||||
|
|
||||||
|
export function OfferFieldSelectionStep() {
|
||||||
|
const propertyIds = useOfferWizardStore(s => s.selectedPropertyIds)
|
||||||
|
const fieldSelections = useOfferWizardStore(s => s.fieldSelections)
|
||||||
|
const setFieldSelections = useOfferWizardStore(s => s.setFieldSelections)
|
||||||
|
const updateFieldSelection = useOfferWizardStore(s => s.updateFieldSelection)
|
||||||
|
const setStep = useOfferWizardStore(s => s.setStep)
|
||||||
|
|
||||||
|
const { data: allProperties = [] } = useProperties()
|
||||||
|
const selectedProperties = allProperties.filter(p => propertyIds.includes(p.id))
|
||||||
|
|
||||||
|
// Initialise selections when properties are loaded or change
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedProperties.length === 0) return
|
||||||
|
const existing = new Set(fieldSelections.map(fs => fs.propertyId))
|
||||||
|
const missing = selectedProperties.filter(p => !existing.has(p.id))
|
||||||
|
if (missing.length > 0) {
|
||||||
|
setFieldSelections([
|
||||||
|
...fieldSelections,
|
||||||
|
...missing.map(p => ({
|
||||||
|
propertyId: p.id,
|
||||||
|
mandatoryFields: MANDATORY_FIELDS,
|
||||||
|
selectedOptionalFields: DEFAULT_FIELDS,
|
||||||
|
})),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
// run only when property list or selections change length
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [selectedProperties.length, fieldSelections.length])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
||||||
|
<Box sx={{ flex: 1, overflowY: 'auto', p: 3 }}>
|
||||||
|
<Typography variant="body2" sx={{ color: '#64748b', mb: 2.5 }}>
|
||||||
|
Wählen Sie die Datenfelder, die auf der Objektseite des Angebots angezeigt werden sollen.
|
||||||
|
Pflichtfelder (Titel, Standort, Foto) sind immer enthalten.
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Stack spacing={2}>
|
||||||
|
{selectedProperties.map(p => {
|
||||||
|
const selection: ReportObjectFieldSelection =
|
||||||
|
fieldSelections.find(fs => fs.propertyId === p.id) ?? {
|
||||||
|
propertyId: p.id,
|
||||||
|
mandatoryFields: MANDATORY_FIELDS,
|
||||||
|
selectedOptionalFields: DEFAULT_FIELDS,
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<ReportObjectFieldSelector
|
||||||
|
key={p.id}
|
||||||
|
propertyTitle={p.title}
|
||||||
|
value={selection}
|
||||||
|
onChange={v => updateFieldSelection(p.id, v)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ px: 3, py: 2, borderTop: '1px solid #e2e8f0', bgcolor: 'white', display: 'flex', justifyContent: 'space-between', flexShrink: 0 }}>
|
||||||
|
<Button
|
||||||
|
startIcon={<ArrowLeft size={14} />}
|
||||||
|
onClick={() => setStep('select_properties')}
|
||||||
|
sx={{ textTransform: 'none' }}
|
||||||
|
>
|
||||||
|
Zurück
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
onClick={() => setStep('pdf_review')}
|
||||||
|
sx={{ textTransform: 'none', bgcolor: '#152642', '&:hover': { bgcolor: '#16304d' } }}
|
||||||
|
>
|
||||||
|
Weiter
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -66,6 +66,8 @@ export function OfferPdfReviewStep() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const fieldSelections = useOfferWizardStore(s => s.fieldSelections)
|
||||||
|
|
||||||
const needTitle = useOfferWizardStore(s => s.needTitle)
|
const needTitle = useOfferWizardStore(s => s.needTitle)
|
||||||
const addAttachment = useOfferWizardStore(s => s.addAttachment)
|
const addAttachment = useOfferWizardStore(s => s.addAttachment)
|
||||||
const setMessageDraft = useOfferWizardStore(s => s.setMessageDraft)
|
const setMessageDraft = useOfferWizardStore(s => s.setMessageDraft)
|
||||||
@@ -128,7 +130,7 @@ export function OfferPdfReviewStep() {
|
|||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
) : (
|
) : (
|
||||||
<MockPdfPreview fields={editableFieldsStore} fallbackFields={fallback} />
|
<MockPdfPreview fields={editableFieldsStore} fallbackFields={fallback} fieldSelections={fieldSelections} />
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
@@ -177,7 +179,7 @@ export function OfferPdfReviewStep() {
|
|||||||
endIcon={markChecked.isPending ? <CircularProgress size={14} sx={{ color: 'white' }} /> : <Send size={14} />}
|
endIcon={markChecked.isPending ? <CircularProgress size={14} sx={{ color: 'white' }} /> : <Send size={14} />}
|
||||||
onClick={handleNext}
|
onClick={handleNext}
|
||||||
disabled={!pdfReady || markChecked.isPending}
|
disabled={!pdfReady || markChecked.isPending}
|
||||||
sx={{ textTransform: 'none', bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#16304d' } }}
|
sx={{ textTransform: 'none', bgcolor: '#152642', '&:hover': { bgcolor: '#16304d' } }}
|
||||||
>
|
>
|
||||||
{markChecked.isPending ? 'Wird geprüft…' : 'Angebot senden →'}
|
{markChecked.isPending ? 'Wird geprüft…' : 'Angebot senden →'}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ export function OfferPropertySelectionStep() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
setOfferDraftId(res.data.id)
|
setOfferDraftId(res.data.id)
|
||||||
setStep('pdf_review')
|
setStep('select_fields')
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!need) {
|
if (!need) {
|
||||||
@@ -141,7 +141,7 @@ export function OfferPropertySelectionStep() {
|
|||||||
endIcon={createDraft.isPending ? <CircularProgress size={14} sx={{ color: 'white' }} /> : <ArrowRight size={14} />}
|
endIcon={createDraft.isPending ? <CircularProgress size={14} sx={{ color: 'white' }} /> : <ArrowRight size={14} />}
|
||||||
onClick={handleNext}
|
onClick={handleNext}
|
||||||
disabled={selectedIds.length === 0 || createDraft.isPending}
|
disabled={selectedIds.length === 0 || createDraft.isPending}
|
||||||
sx={{ textTransform: 'none', bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#16304d' } }}
|
sx={{ textTransform: 'none', bgcolor: '#152642', '&:hover': { bgcolor: '#16304d' } }}
|
||||||
>
|
>
|
||||||
Weiter
|
Weiter
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -12,22 +12,25 @@ import {
|
|||||||
import { X } from 'lucide-react'
|
import { X } from 'lucide-react'
|
||||||
import { useOfferWizardStore, type OfferStep } from '../../stores/offerWizardStore'
|
import { useOfferWizardStore, type OfferStep } from '../../stores/offerWizardStore'
|
||||||
import { OfferPropertySelectionStep } from './OfferPropertySelectionStep'
|
import { OfferPropertySelectionStep } from './OfferPropertySelectionStep'
|
||||||
|
import { OfferFieldSelectionStep } from './OfferFieldSelectionStep'
|
||||||
import { OfferPdfReviewStep } from './OfferPdfReviewStep'
|
import { OfferPdfReviewStep } from './OfferPdfReviewStep'
|
||||||
import { OfferCheckedAction } from './OfferCheckedAction'
|
import { OfferCheckedAction } from './OfferCheckedAction'
|
||||||
import { OfferChatComposer } from './OfferChatComposer'
|
import { OfferChatComposer } from './OfferChatComposer'
|
||||||
|
|
||||||
// 'checked' is internal — merged into pdf_review step; only 3 steps shown
|
// 'checked' is internal — merged into pdf_review step; 4 visible steps
|
||||||
const STEPS: { key: OfferStep; label: string }[] = [
|
const STEPS: { key: OfferStep; label: string }[] = [
|
||||||
{ key: 'select_properties', label: 'Objekte wählen' },
|
{ key: 'select_properties', label: 'Objekte wählen' },
|
||||||
|
{ key: 'select_fields', label: 'Felder wählen' },
|
||||||
{ key: 'pdf_review', label: 'Vorschau & Prüfen' },
|
{ key: 'pdf_review', label: 'Vorschau & Prüfen' },
|
||||||
{ key: 'send', label: 'Senden' },
|
{ key: 'send', label: 'Senden' },
|
||||||
]
|
]
|
||||||
|
|
||||||
const STEP_DISPLAY_INDEX: Record<OfferStep, number> = {
|
const STEP_DISPLAY_INDEX: Record<OfferStep, number> = {
|
||||||
select_properties: 0,
|
select_properties: 0,
|
||||||
pdf_review: 1,
|
select_fields: 1,
|
||||||
checked: 1, // same visual step as pdf_review
|
pdf_review: 2,
|
||||||
send: 2,
|
checked: 2, // same visual step as pdf_review
|
||||||
|
send: 3,
|
||||||
}
|
}
|
||||||
|
|
||||||
export function OfferWizard() {
|
export function OfferWizard() {
|
||||||
@@ -48,13 +51,15 @@ export function OfferWizard() {
|
|||||||
fullScreen={fullScreen}
|
fullScreen={fullScreen}
|
||||||
maxWidth="lg"
|
maxWidth="lg"
|
||||||
fullWidth
|
fullWidth
|
||||||
PaperProps={{
|
slotProps={{
|
||||||
|
paper: {
|
||||||
sx: {
|
sx: {
|
||||||
height: fullScreen ? '100vh' : '85vh',
|
height: fullScreen ? '100vh' : '85vh',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
},
|
},
|
||||||
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
@@ -112,6 +117,7 @@ export function OfferWizard() {
|
|||||||
{/* Step content */}
|
{/* Step content */}
|
||||||
<Box sx={{ flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
|
<Box sx={{ flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
|
||||||
{currentStep === 'select_properties' && <OfferPropertySelectionStep />}
|
{currentStep === 'select_properties' && <OfferPropertySelectionStep />}
|
||||||
|
{currentStep === 'select_fields' && <OfferFieldSelectionStep />}
|
||||||
{currentStep === 'pdf_review' && <OfferPdfReviewStep />}
|
{currentStep === 'pdf_review' && <OfferPdfReviewStep />}
|
||||||
{currentStep === 'checked' && <OfferCheckedAction />}
|
{currentStep === 'checked' && <OfferCheckedAction />}
|
||||||
{currentStep === 'send' && <OfferChatComposer />}
|
{currentStep === 'send' && <OfferChatComposer />}
|
||||||
|
|||||||
@@ -18,8 +18,8 @@ export function OfferWizardPdfStep({ generating, progress, ready, draft, propert
|
|||||||
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '100%', gap: 3 }}>
|
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '100%', gap: 3 }}>
|
||||||
{generating ? (
|
{generating ? (
|
||||||
<>
|
<>
|
||||||
<CircularProgress size={48} sx={{ color: '#1e3a5f' }} />
|
<CircularProgress size={48} sx={{ color: '#152642' }} />
|
||||||
<Typography variant="body1" sx={{ fontWeight: 600, color: '#1e3a5f' }}>
|
<Typography variant="body1" sx={{ fontWeight: 600, color: '#152642' }}>
|
||||||
PDF wird generiert…
|
PDF wird generiert…
|
||||||
</Typography>
|
</Typography>
|
||||||
<Box sx={{ width: '100%', maxWidth: 400 }}>
|
<Box sx={{ width: '100%', maxWidth: 400 }}>
|
||||||
@@ -44,7 +44,7 @@ export function OfferWizardPdfStep({ generating, progress, ready, draft, propert
|
|||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Box sx={{ bgcolor: '#1e3a5f', px: 2, py: 1, display: 'flex', alignItems: 'center', gap: 1 }}>
|
<Box sx={{ bgcolor: '#152642', px: 2, py: 1, display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
<Box sx={{ width: 10, height: 10, borderRadius: '50%', bgcolor: '#ef4444' }} />
|
<Box sx={{ width: 10, height: 10, borderRadius: '50%', bgcolor: '#ef4444' }} />
|
||||||
<Box sx={{ width: 10, height: 10, borderRadius: '50%', bgcolor: '#f59e0b' }} />
|
<Box sx={{ width: 10, height: 10, borderRadius: '50%', bgcolor: '#f59e0b' }} />
|
||||||
<Box sx={{ width: 10, height: 10, borderRadius: '50%', bgcolor: '#10b981' }} />
|
<Box sx={{ width: 10, height: 10, borderRadius: '50%', bgcolor: '#10b981' }} />
|
||||||
@@ -53,7 +53,7 @@ export function OfferWizardPdfStep({ generating, progress, ready, draft, propert
|
|||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
<Box sx={{ flex: 1, p: 3 }}>
|
<Box sx={{ flex: 1, p: 3 }}>
|
||||||
<Typography variant="body2" sx={{ fontWeight: 700, color: '#1e3a5f', mb: 1 }}>
|
<Typography variant="body2" sx={{ fontWeight: 700, color: '#152642', mb: 1 }}>
|
||||||
Angebotsschreiben
|
Angebotsschreiben
|
||||||
</Typography>
|
</Typography>
|
||||||
{draft?.editableFields.slice(0, 3).map(f => (
|
{draft?.editableFields.slice(0, 3).map(f => (
|
||||||
@@ -83,7 +83,7 @@ export function OfferWizardPdfStep({ generating, progress, ready, draft, propert
|
|||||||
variant="contained"
|
variant="contained"
|
||||||
startIcon={<Download size={16} />}
|
startIcon={<Download size={16} />}
|
||||||
onClick={onDownload}
|
onClick={onDownload}
|
||||||
sx={{ textTransform: 'none', bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#16304d' } }}
|
sx={{ textTransform: 'none', bgcolor: '#152642', '&:hover': { bgcolor: '#16304d' } }}
|
||||||
>
|
>
|
||||||
Herunterladen
|
Herunterladen
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ export function OwnPropertyMatchList({ need }: OwnPropertyMatchListProps) {
|
|||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Target size={14} color="#1e3a5f" />
|
<Target size={14} color="#152642" />
|
||||||
<Typography variant="body2" sx={{ fontWeight: 600, color: '#0f172a' }}>
|
<Typography variant="body2" sx={{ fontWeight: 600, color: '#0f172a' }}>
|
||||||
Eigene Objekte
|
Eigene Objekte
|
||||||
</Typography>
|
</Typography>
|
||||||
@@ -72,7 +72,7 @@ export function OwnPropertyMatchList({ need }: OwnPropertyMatchListProps) {
|
|||||||
px: 1,
|
px: 1,
|
||||||
py: 0.125,
|
py: 0.125,
|
||||||
borderRadius: 1,
|
borderRadius: 1,
|
||||||
bgcolor: '#1e3a5f',
|
bgcolor: '#152642',
|
||||||
color: 'white',
|
color: 'white',
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
fontSize: '0.7rem',
|
fontSize: '0.7rem',
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import {
|
|||||||
} from '@mui/material'
|
} from '@mui/material'
|
||||||
import { Download, Send, X } from 'lucide-react'
|
import { Download, Send, X } from 'lucide-react'
|
||||||
import type { Inquiry } from '../../domain/inquiry'
|
import type { Inquiry } from '../../domain/inquiry'
|
||||||
import type { Property } from '../../domain/property'
|
|
||||||
import type { InquiryPreparationReportDraft, ReportObjectFieldSelection } from '../../domain/inquiryReport'
|
import type { InquiryPreparationReportDraft, ReportObjectFieldSelection } from '../../domain/inquiryReport'
|
||||||
import { useCreateInquiryReport, useUpdateInquiryReport, useFinalizeInquiryReport } from '../../hooks/useInquiryReport'
|
import { useCreateInquiryReport, useUpdateInquiryReport, useFinalizeInquiryReport } from '../../hooks/useInquiryReport'
|
||||||
import { useToastStore } from '../../stores/toastStore'
|
import { useToastStore } from '../../stores/toastStore'
|
||||||
@@ -40,10 +39,6 @@ export function PreparationWizard({ inquiryId, inquiry, onClose }: Props) {
|
|||||||
|
|
||||||
const portfolioProps = allProperties.filter(p => p.resultType === ResultType.VERIFIED_PORTFOLIO)
|
const portfolioProps = allProperties.filter(p => p.resultType === ResultType.VERIFIED_PORTFOLIO)
|
||||||
|
|
||||||
const selectedProperties = draft?.selectedPropertyIds
|
|
||||||
.map(id => allProperties.find(p => p.id === id))
|
|
||||||
.filter((p): p is Property => !!p) ?? []
|
|
||||||
|
|
||||||
const toggleProperty = (id: string) => {
|
const toggleProperty = (id: string) => {
|
||||||
setSelectedIds(prev =>
|
setSelectedIds(prev =>
|
||||||
prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id],
|
prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id],
|
||||||
@@ -112,7 +107,7 @@ export function PreparationWizard({ inquiryId, inquiry, onClose }: Props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open fullWidth maxWidth="lg" PaperProps={{ sx: { height: '90vh', display: 'flex', flexDirection: 'column' } }}>
|
<Dialog open fullWidth maxWidth="lg" slotProps={{ paper: { sx: { height: '90vh', display: 'flex', flexDirection: 'column' } } }}>
|
||||||
<Box sx={{ px: 3, pt: 2.5, pb: 1.5, borderBottom: '1px solid #e2e8f0', display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexShrink: 0 }}>
|
<Box sx={{ px: 3, pt: 2.5, pb: 1.5, borderBottom: '1px solid #e2e8f0', display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexShrink: 0 }}>
|
||||||
<Typography variant="h6" sx={{ fontWeight: 700 }}>Vorbereitung starten</Typography>
|
<Typography variant="h6" sx={{ fontWeight: 700 }}>Vorbereitung starten</Typography>
|
||||||
<Button size="small" onClick={onClose} sx={{ minWidth: 0, p: 0.5 }}><X size={18} /></Button>
|
<Button size="small" onClick={onClose} sx={{ minWidth: 0, p: 0.5 }}><X size={18} /></Button>
|
||||||
@@ -164,8 +159,8 @@ export function PreparationWizard({ inquiryId, inquiry, onClose }: Props) {
|
|||||||
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '100%', gap: 3 }}>
|
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '100%', gap: 3 }}>
|
||||||
{generating ? (
|
{generating ? (
|
||||||
<>
|
<>
|
||||||
<CircularProgress size={48} sx={{ color: '#1e3a5f' }} />
|
<CircularProgress size={48} sx={{ color: '#152642' }} />
|
||||||
<Typography variant="body1" sx={{ fontWeight: 600, color: '#1e3a5f' }}>
|
<Typography variant="body1" sx={{ fontWeight: 600, color: '#152642' }}>
|
||||||
Bericht wird erstellt…
|
Bericht wird erstellt…
|
||||||
</Typography>
|
</Typography>
|
||||||
<Box sx={{ width: '100%', maxWidth: 400 }}>
|
<Box sx={{ width: '100%', maxWidth: 400 }}>
|
||||||
@@ -239,7 +234,7 @@ export function PreparationWizard({ inquiryId, inquiry, onClose }: Props) {
|
|||||||
fullWidth
|
fullWidth
|
||||||
startIcon={<Download size={16} />}
|
startIcon={<Download size={16} />}
|
||||||
onClick={() => showToast('PDF wird heruntergeladen…', 'info')}
|
onClick={() => showToast('PDF wird heruntergeladen…', 'info')}
|
||||||
sx={{ textTransform: 'none', bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#16304d' } }}
|
sx={{ textTransform: 'none', bgcolor: '#152642', '&:hover': { bgcolor: '#16304d' } }}
|
||||||
>
|
>
|
||||||
Herunterladen
|
Herunterladen
|
||||||
</Button>
|
</Button>
|
||||||
@@ -276,7 +271,7 @@ export function PreparationWizard({ inquiryId, inquiry, onClose }: Props) {
|
|||||||
variant="contained"
|
variant="contained"
|
||||||
disabled={selectedIds.length === 0}
|
disabled={selectedIds.length === 0}
|
||||||
onClick={handleGenerate}
|
onClick={handleGenerate}
|
||||||
sx={{ textTransform: 'none', bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#16304d' } }}
|
sx={{ textTransform: 'none', bgcolor: '#152642', '&:hover': { bgcolor: '#16304d' } }}
|
||||||
>
|
>
|
||||||
Weiter
|
Weiter
|
||||||
</Button>
|
</Button>
|
||||||
@@ -287,7 +282,7 @@ export function PreparationWizard({ inquiryId, inquiry, onClose }: Props) {
|
|||||||
disabled={finalizing}
|
disabled={finalizing}
|
||||||
onClick={handleFinalize}
|
onClick={handleFinalize}
|
||||||
startIcon={finalizing ? <CircularProgress size={14} sx={{ color: 'white' }} /> : undefined}
|
startIcon={finalizing ? <CircularProgress size={14} sx={{ color: 'white' }} /> : undefined}
|
||||||
sx={{ textTransform: 'none', bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#16304d' } }}
|
sx={{ textTransform: 'none', bgcolor: '#152642', '&:hover': { bgcolor: '#16304d' } }}
|
||||||
>
|
>
|
||||||
Finalisieren
|
Finalisieren
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Box, Chip, Paper, Typography } from '@mui/material'
|
import { Box, Chip, Paper, Typography } from '@mui/material'
|
||||||
import { MapPin, Ruler } from 'lucide-react'
|
import { MapPin } from 'lucide-react'
|
||||||
import type { LatentNeed } from '../../domain/latentNeed'
|
import type { LatentNeed } from '../../domain/latentNeed'
|
||||||
import { assetTypeLabel, latentStatusBadge } from './latentNeedUtils'
|
import { assetTypeLabel, latentStatusBadge } from './latentNeedUtils'
|
||||||
|
|
||||||
@@ -9,6 +9,12 @@ interface PublicNeedCardProps {
|
|||||||
onClick: () => void
|
onClick: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function opportunityColor(status: 'public' | 'paused' | 'expired'): string {
|
||||||
|
if (status === 'public') return '#16a34a'
|
||||||
|
if (status === 'paused') return '#d97706'
|
||||||
|
return '#94a3b8'
|
||||||
|
}
|
||||||
|
|
||||||
export function PublicNeedCard({ need, selected, onClick }: PublicNeedCardProps) {
|
export function PublicNeedCard({ need, selected, onClick }: PublicNeedCardProps) {
|
||||||
const statusCfg = latentStatusBadge(need.status)
|
const statusCfg = latentStatusBadge(need.status)
|
||||||
return (
|
return (
|
||||||
@@ -19,12 +25,12 @@ export function PublicNeedCard({ need, selected, onClick }: PublicNeedCardProps)
|
|||||||
p: 1.5,
|
p: 1.5,
|
||||||
borderRadius: 1.5,
|
borderRadius: 1.5,
|
||||||
border: '1px solid',
|
border: '1px solid',
|
||||||
borderColor: selected ? '#1e3a5f' : '#e2e8f0',
|
borderColor: selected ? '#152642' : '#e8e7e4',
|
||||||
bgcolor: selected ? '#f1f5f9' : 'white',
|
bgcolor: selected ? '#f1f5f9' : 'white',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
transition: 'all 0.15s',
|
transition: 'all 0.15s',
|
||||||
'&:hover': {
|
'&:hover': {
|
||||||
borderColor: selected ? '#1e3a5f' : '#94a3b8',
|
borderColor: selected ? '#152642' : '#94a3b8',
|
||||||
boxShadow: '0 2px 6px rgba(15,23,42,0.06)',
|
boxShadow: '0 2px 6px rgba(15,23,42,0.06)',
|
||||||
},
|
},
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
@@ -36,18 +42,15 @@ export function PublicNeedCard({ need, selected, onClick }: PublicNeedCardProps)
|
|||||||
<Typography variant="body2" sx={{ fontWeight: 600, color: '#0f172a', fontSize: '0.85rem', lineHeight: 1.3 }}>
|
<Typography variant="body2" sx={{ fontWeight: 600, color: '#0f172a', fontSize: '0.85rem', lineHeight: 1.3 }}>
|
||||||
{need.title}
|
{need.title}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, flexShrink: 0 }}>
|
||||||
|
<Box sx={{ width: 8, height: 8, borderRadius: '50%', bgcolor: opportunityColor(need.status) }} />
|
||||||
<Chip
|
<Chip
|
||||||
label={statusCfg.label}
|
label={statusCfg.label}
|
||||||
size="small"
|
size="small"
|
||||||
sx={{
|
sx={{ bgcolor: statusCfg.bg, color: statusCfg.fg, fontWeight: 600, fontSize: '0.65rem', height: 20 }}
|
||||||
bgcolor: statusCfg.bg,
|
|
||||||
color: statusCfg.fg,
|
|
||||||
fontWeight: 600,
|
|
||||||
fontSize: '0.65rem',
|
|
||||||
height: 20,
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
{need.tenantCompany && (
|
{need.tenantCompany && (
|
||||||
<Typography variant="caption" sx={{ color: '#64748b', fontSize: '0.75rem' }}>
|
<Typography variant="caption" sx={{ color: '#64748b', fontSize: '0.75rem' }}>
|
||||||
@@ -74,12 +77,6 @@ export function PublicNeedCard({ need, selected, onClick }: PublicNeedCardProps)
|
|||||||
{need.desiredLocation}
|
{need.desiredLocation}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, color: '#475569', fontSize: '0.75rem' }}>
|
|
||||||
<Ruler size={12} />
|
|
||||||
<Typography variant="caption" sx={{ fontSize: '0.75rem' }}>
|
|
||||||
{need.sizeRange.min}–{need.sizeRange.max} m²
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
</Paper>
|
</Paper>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,206 +1,159 @@
|
|||||||
import { Alert, Box, Button, Chip, Typography } from '@mui/material'
|
import { useMemo, useState } from 'react'
|
||||||
import { CheckCircle2, Calendar, MapPin, Wallet, Info, Ruler, Sparkles } from 'lucide-react'
|
import {
|
||||||
|
Accordion, AccordionDetails, AccordionSummary,
|
||||||
|
Alert, Box, Button, Chip, CircularProgress, Divider, Paper, Typography,
|
||||||
|
} from '@mui/material'
|
||||||
|
import { Calendar, CheckCircle2, ChevronDown, Info, MapPin, Ruler, Sparkles, Target, Wallet } from 'lucide-react'
|
||||||
import type { LatentNeed } from '../../domain/latentNeed'
|
import type { LatentNeed } from '../../domain/latentNeed'
|
||||||
|
import { ResultType, UserRole } from '../../domain/enums'
|
||||||
import { useOfferWizardStore } from '../../stores/offerWizardStore'
|
import { useOfferWizardStore } from '../../stores/offerWizardStore'
|
||||||
import { useSessionStore } from '../../stores/sessionStore'
|
import { useSessionStore } from '../../stores/sessionStore'
|
||||||
import { assetTypeLabel, latentStatusBadge } from './latentNeedUtils'
|
import { useProperties } from '../../hooks/useProperties'
|
||||||
import { UserRole } from '../../domain/enums'
|
import { assetTypeLabel, deterministicMatchScore, latentStatusBadge } from './latentNeedUtils'
|
||||||
|
import { EmbeddedPropertyCard } from './EmbeddedPropertyCard'
|
||||||
|
|
||||||
interface PublicNeedDetailProps {
|
interface PublicNeedDetailProps {
|
||||||
need: LatentNeed
|
need: LatentNeed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildReason(propType: string, needType: string, city: string, location: string): string {
|
||||||
|
if (propType === needType) {
|
||||||
|
const match = location.toLowerCase().includes(city.toLowerCase()) || city.toLowerCase().includes(location.toLowerCase())
|
||||||
|
return match ? 'Nutzungstyp und Standort passen sehr gut' : 'Passender Nutzungstyp, alternative Lage'
|
||||||
|
}
|
||||||
|
return 'Alternatives Profil — Detailprüfung empfohlen'
|
||||||
|
}
|
||||||
|
|
||||||
export function PublicNeedDetail({ need }: PublicNeedDetailProps) {
|
export function PublicNeedDetail({ need }: PublicNeedDetailProps) {
|
||||||
const openWizard = useOfferWizardStore(s => s.open)
|
const openWizard = useOfferWizardStore(s => s.open)
|
||||||
|
const setSelectedProperties = useOfferWizardStore(s => s.setSelectedProperties)
|
||||||
|
const selectedIds = useOfferWizardStore(s => s.selectedPropertyIds)
|
||||||
|
const toggleProperty = useOfferWizardStore(s => s.toggleProperty)
|
||||||
const currentUser = useSessionStore(s => s.currentUser)
|
const currentUser = useSessionStore(s => s.currentUser)
|
||||||
const statusCfg = latentStatusBadge(need.status)
|
const statusCfg = latentStatusBadge(need.status)
|
||||||
|
const [showAll, setShowAll] = useState(false)
|
||||||
|
|
||||||
|
const { data: properties = [], isLoading: propertiesLoading } = useProperties({ resultType: ResultType.VERIFIED_PORTFOLIO })
|
||||||
const disabled = currentUser?.role === UserRole.OWNER_VIEWER || need.status !== 'public'
|
const disabled = currentUser?.role === UserRole.OWNER_VIEWER || need.status !== 'public'
|
||||||
|
|
||||||
|
const scored = useMemo(
|
||||||
|
() =>
|
||||||
|
properties
|
||||||
|
.map(p => ({
|
||||||
|
property: p,
|
||||||
|
score: deterministicMatchScore(p.id, need.id),
|
||||||
|
reason: buildReason(p.assetType, need.assetType, p.location.city, need.desiredLocation),
|
||||||
|
}))
|
||||||
|
.sort((a, b) => b.score - a.score),
|
||||||
|
[properties, need],
|
||||||
|
)
|
||||||
|
|
||||||
|
const visible = showAll ? scored : scored.slice(0, 3)
|
||||||
|
const remaining = scored.length - 3
|
||||||
|
|
||||||
|
function handleQuickOffer(propertyId: string) {
|
||||||
|
setSelectedProperties([propertyId])
|
||||||
|
openWizard(need.id, need.title)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box sx={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden', bgcolor: '#f8fafc' }}>
|
||||||
sx={{
|
{/* Scrollable content */}
|
||||||
flex: 1,
|
<Box sx={{ flex: 1, overflowY: 'auto', p: 3, display: 'flex', flexDirection: 'column', gap: 2.5 }}>
|
||||||
overflowY: 'auto',
|
|
||||||
bgcolor: '#f8fafc',
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Box sx={{ p: 3, display: 'flex', flexDirection: 'column', gap: 2.5 }}>
|
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<Box>
|
<Box>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.5 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.5 }}>
|
||||||
<Chip
|
<Chip label={assetTypeLabel(need.assetType)} size="small"
|
||||||
label={assetTypeLabel(need.assetType)}
|
sx={{ bgcolor: '#e0e7ff', color: '#3730a3', fontWeight: 600, fontSize: '0.7rem', height: 22 }} />
|
||||||
size="small"
|
<Chip label={statusCfg.label} size="small"
|
||||||
sx={{
|
sx={{ bgcolor: statusCfg.bg, color: statusCfg.fg, fontWeight: 600, fontSize: '0.7rem', height: 22 }} />
|
||||||
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>
|
</Box>
|
||||||
<Typography variant="h5" sx={{ fontWeight: 700, color: '#0f172a', fontSize: '1.35rem', mb: 0.5 }}>
|
<Typography variant="h5" sx={{ fontWeight: 700, color: '#0f172a', fontSize: '1.35rem', mb: 0.5 }}>
|
||||||
{need.title}
|
{need.title}
|
||||||
</Typography>
|
</Typography>
|
||||||
{need.tenantCompany && (
|
{need.tenantCompany && (
|
||||||
<Typography variant="body2" sx={{ color: '#64748b' }}>
|
<Typography variant="body2" sx={{ color: '#64748b' }}>{need.tenantCompany}</Typography>
|
||||||
{need.tenantCompany}
|
|
||||||
</Typography>
|
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* AI Summary */}
|
{/* KI-Entscheidungsbrief */}
|
||||||
{need.aiSummary && (
|
{need.aiSummary && (
|
||||||
<Alert
|
<Paper variant="outlined" sx={{ p: 2.5, borderRadius: 2, borderColor: '#bfdbfe', bgcolor: '#eff6ff' }}>
|
||||||
icon={<Sparkles size={16} />}
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.25 }}>
|
||||||
severity="info"
|
<Sparkles size={16} color="#1d4ed8" />
|
||||||
sx={{
|
<Typography variant="caption"
|
||||||
bgcolor: '#eff6ff',
|
sx={{ fontWeight: 700, color: '#1d4ed8', textTransform: 'uppercase', letterSpacing: 0.8, fontSize: '0.7rem' }}>
|
||||||
borderRadius: 1.5,
|
KI-Entscheidungsbrief
|
||||||
border: '1px solid #bfdbfe',
|
|
||||||
'& .MuiAlert-icon': { color: '#1d4ed8' },
|
|
||||||
'& .MuiAlert-message': { color: '#1e3a8a', fontSize: '0.875rem' },
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Typography variant="caption" sx={{ fontWeight: 700, color: '#1d4ed8', display: 'block', mb: 0.5 }}>
|
|
||||||
KI-Analyse
|
|
||||||
</Typography>
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Typography variant="body1" sx={{ color: '#1e3a8a', lineHeight: 1.7, fontSize: '0.9375rem' }}>
|
||||||
{need.aiSummary}
|
{need.aiSummary}
|
||||||
</Alert>
|
</Typography>
|
||||||
|
</Paper>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Suchprofil grid */}
|
{/* Suchprofil */}
|
||||||
<Box>
|
<Box>
|
||||||
<Typography
|
<Typography variant="caption" sx={{ color: '#64748b', fontWeight: 700, textTransform: 'uppercase', letterSpacing: 0.5 }}>
|
||||||
variant="caption"
|
|
||||||
sx={{ color: '#64748b', fontWeight: 700, textTransform: 'uppercase', letterSpacing: 0.5 }}
|
|
||||||
>
|
|
||||||
Suchprofil
|
Suchprofil
|
||||||
</Typography>
|
</Typography>
|
||||||
<Box
|
<Box sx={{ mt: 1, display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', gap: 1.5 }}>
|
||||||
sx={{
|
|
||||||
mt: 1,
|
|
||||||
display: 'grid',
|
|
||||||
gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))',
|
|
||||||
gap: 1.5,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ProfileBox icon={<MapPin size={14} />} label="Standort" value={need.desiredLocation} />
|
<ProfileBox icon={<MapPin size={14} />} label="Standort" value={need.desiredLocation} />
|
||||||
<ProfileBox
|
<ProfileBox icon={<Ruler size={14} />} label="Fläche" value={`${need.sizeRange.min}–${need.sizeRange.max} m²`} />
|
||||||
icon={<Ruler size={14} />}
|
|
||||||
label="Fläche"
|
|
||||||
value={`${need.sizeRange.min}–${need.sizeRange.max} m²`}
|
|
||||||
/>
|
|
||||||
<ProfileBox
|
<ProfileBox
|
||||||
icon={<Wallet size={14} />}
|
icon={<Wallet size={14} />}
|
||||||
label="Budget"
|
label="Budget"
|
||||||
value={
|
value={need.budgetRange
|
||||||
need.budgetRange
|
? `${need.budgetRange.min ? `CHF ${need.budgetRange.min}` : 'flex'}–${need.budgetRange.max ? `CHF ${need.budgetRange.max}` : 'flex'} /m²`
|
||||||
? `${need.budgetRange.min ? `CHF ${need.budgetRange.min}` : 'flex'}–${
|
: 'Flexibel'}
|
||||||
need.budgetRange.max ? `CHF ${need.budgetRange.max}` : 'flex'
|
|
||||||
} /m²`
|
|
||||||
: 'Flexibel'
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
<ProfileBox icon={<Calendar size={14} />} label="Timing" value={need.timing} />
|
<ProfileBox icon={<Calendar size={14} />} label="Timing" value={need.timing} />
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Must-have Kriterien */}
|
{/* Must-haves */}
|
||||||
{need.mustHaveCriteria.length > 0 && (
|
{need.mustHaveCriteria.length > 0 && (
|
||||||
<Box>
|
<Box>
|
||||||
<Typography
|
<Typography variant="caption" sx={{ color: '#64748b', fontWeight: 700, textTransform: 'uppercase', letterSpacing: 0.5 }}>
|
||||||
variant="caption"
|
|
||||||
sx={{ color: '#64748b', fontWeight: 700, textTransform: 'uppercase', letterSpacing: 0.5 }}
|
|
||||||
>
|
|
||||||
Must-have Kriterien
|
Must-have Kriterien
|
||||||
</Typography>
|
</Typography>
|
||||||
<Box sx={{ mt: 1, display: 'flex', flexDirection: 'column', gap: 0.5 }}>
|
<Box sx={{ mt: 1, display: 'flex', flexDirection: 'column', gap: 0.5 }}>
|
||||||
{need.mustHaveCriteria.map((c, idx) => (
|
{need.mustHaveCriteria.map((c, idx) => (
|
||||||
<Box key={idx} sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
<Box key={idx} sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
<CheckCircle2 size={14} color="#16a34a" />
|
<CheckCircle2 size={14} color="#16a34a" />
|
||||||
<Typography variant="body2" sx={{ fontSize: '0.875rem', color: '#1e293b' }}>
|
<Typography variant="body2" sx={{ fontSize: '0.875rem', color: '#1e293b' }}>{c}</Typography>
|
||||||
{c}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
</Box>
|
||||||
))}
|
))}
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Gewichtete Präferenzen */}
|
{/* Gewichtete Präferenzen — Accordion */}
|
||||||
{need.weightedPreferences.length > 0 && (
|
{need.weightedPreferences.length > 0 && (
|
||||||
<Box>
|
<Accordion elevation={0} disableGutters
|
||||||
<Typography
|
sx={{ border: '1px solid #e2e8f0', borderRadius: '8px !important', overflow: 'hidden', '&:before': { display: 'none' }, bgcolor: 'white' }}>
|
||||||
variant="caption"
|
<AccordionSummary expandIcon={<ChevronDown size={16} color="#64748b" />}
|
||||||
sx={{ color: '#64748b', fontWeight: 700, textTransform: 'uppercase', letterSpacing: 0.5 }}
|
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
|
Gewichtete Präferenzen
|
||||||
</Typography>
|
</Typography>
|
||||||
<Box sx={{ mt: 1, display: 'flex', flexDirection: 'column', gap: 1 }}>
|
</AccordionSummary>
|
||||||
|
<AccordionDetails sx={{ px: 2, pt: 0, pb: 2 }}>
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||||
{need.weightedPreferences.map((p, idx) => {
|
{need.weightedPreferences.map((p, idx) => {
|
||||||
const pct = Math.round(p.weight * 100)
|
const pct = Math.round(p.weight * 100)
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box key={idx} sx={{ bgcolor: '#f8fafc', border: '1px solid #e2e8f0', borderRadius: 1.5, px: 1.5, py: 1 }}>
|
||||||
key={idx}
|
|
||||||
sx={{
|
|
||||||
bgcolor: 'white',
|
|
||||||
border: '1px solid #e2e8f0',
|
|
||||||
borderRadius: 1.5,
|
|
||||||
px: 1.5,
|
|
||||||
py: 1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 0.5 }}>
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 0.5 }}>
|
||||||
<Typography variant="body2" sx={{ fontWeight: 600, fontSize: '0.85rem' }}>
|
<Typography variant="body2" sx={{ fontWeight: 600, fontSize: '0.85rem' }}>{p.criterion}</Typography>
|
||||||
{p.criterion}
|
<Typography variant="caption"
|
||||||
</Typography>
|
sx={{ fontWeight: 700, color: '#152642', fontSize: '0.75rem', bgcolor: '#e0e7ff', px: 1, py: 0.125, borderRadius: 1 }}>
|
||||||
<Typography
|
|
||||||
variant="caption"
|
|
||||||
sx={{
|
|
||||||
fontWeight: 700,
|
|
||||||
color: '#1e3a5f',
|
|
||||||
fontSize: '0.75rem',
|
|
||||||
bgcolor: '#e0e7ff',
|
|
||||||
px: 1,
|
|
||||||
py: 0.125,
|
|
||||||
borderRadius: 1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{pct}%
|
{pct}%
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
<Box
|
<Box sx={{ height: 6, borderRadius: 3, bgcolor: '#e8e7e4', overflow: 'hidden' }}>
|
||||||
sx={{
|
<Box sx={{ width: `${pct}%`, height: '100%', bgcolor: '#152642', transition: 'width 0.3s' }} />
|
||||||
height: 6,
|
|
||||||
borderRadius: 3,
|
|
||||||
bgcolor: '#e2e8f0',
|
|
||||||
overflow: 'hidden',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
width: `${pct}%`,
|
|
||||||
height: '100%',
|
|
||||||
bgcolor: '#1e3a5f',
|
|
||||||
transition: 'width 0.3s',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Box>
|
</Box>
|
||||||
{p.description && (
|
{p.description && (
|
||||||
<Typography variant="caption" sx={{ color: '#64748b', fontSize: '0.75rem', mt: 0.5, display: 'block' }}>
|
<Typography variant="caption" sx={{ color: '#64748b', fontSize: '0.75rem', mt: 0.5, display: 'block' }}>
|
||||||
@@ -211,57 +164,100 @@ export function PublicNeedDetail({ need }: PublicNeedDetailProps) {
|
|||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</AccordionDetails>
|
||||||
|
</Accordion>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Top Empfehlungen */}
|
||||||
|
<Box>
|
||||||
|
<Divider sx={{ mb: 2.5 }} />
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
|
||||||
|
<Target size={14} color="#152642" />
|
||||||
|
<Typography variant="caption" sx={{ color: '#64748b', fontWeight: 700, textTransform: 'uppercase', letterSpacing: 0.5 }}>
|
||||||
|
Ihre Top Empfehlungen
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
{propertiesLoading ? (
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'center', p: 3 }}><CircularProgress size={20} /></Box>
|
||||||
|
) : scored.length === 0 ? (
|
||||||
|
<Typography variant="caption" sx={{ color: '#64748b', fontSize: '0.8rem' }}>
|
||||||
|
Keine Portfolio-Objekte vorhanden
|
||||||
|
</Typography>
|
||||||
|
) : (
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.25 }}>
|
||||||
|
{visible.map(({ property, score, reason }) => (
|
||||||
|
<EmbeddedPropertyCard
|
||||||
|
key={property.id}
|
||||||
|
property={property}
|
||||||
|
matchScore={score}
|
||||||
|
selected={selectedIds.includes(property.id)}
|
||||||
|
onToggle={() => toggleProperty(property.id)}
|
||||||
|
reason={reason}
|
||||||
|
onQuickOffer={() => handleQuickOffer(property.id)}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{remaining > 0 && (
|
||||||
|
<Box
|
||||||
|
onClick={() => setShowAll(s => !s)}
|
||||||
|
sx={{
|
||||||
|
py: 1.25,
|
||||||
|
textAlign: 'center',
|
||||||
|
cursor: 'pointer',
|
||||||
|
color: '#64748b',
|
||||||
|
fontSize: '0.8rem',
|
||||||
|
fontWeight: 500,
|
||||||
|
border: '1px dashed #e2e8f0',
|
||||||
|
borderRadius: 1.5,
|
||||||
|
'&:hover': { color: '#152642', borderColor: '#152642', bgcolor: '#f8fafc' },
|
||||||
|
transition: 'all 0.15s',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{showAll ? '▲ Weniger anzeigen' : `▼ Alle ${scored.length} Objekte anzeigen (+${remaining} weitere)`}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Sticky footer CTA */}
|
||||||
|
<Box sx={{ flexShrink: 0, borderTop: '1px solid #e2e8f0', px: 3, py: 2, bgcolor: 'white' }}>
|
||||||
{disabled && need.status !== 'public' && (
|
{disabled && need.status !== 'public' && (
|
||||||
<Alert severity="warning" icon={<Info size={16} />} sx={{ fontSize: '0.8125rem' }}>
|
<Alert severity="warning" icon={<Info size={16} />} sx={{ fontSize: '0.8125rem', mb: 1.5 }}>
|
||||||
Dieser Bedarf ist aktuell {statusCfg.label.toLowerCase()} — kein Angebot möglich.
|
Dieser Bedarf ist aktuell {statusCfg.label.toLowerCase()} — kein Angebot möglich.
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
{selectedIds.length > 0 && !disabled && (
|
||||||
<Box sx={{ mt: 'auto', pt: 1 }}>
|
<Typography variant="caption" sx={{ color: '#475569', display: 'block', mb: 1, textAlign: 'center' }}>
|
||||||
|
{selectedIds.length} Objekt{selectedIds.length !== 1 ? 'e' : ''} ausgewählt
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
<Button
|
<Button
|
||||||
variant="contained"
|
variant="contained"
|
||||||
size="large"
|
size="large"
|
||||||
fullWidth
|
fullWidth
|
||||||
onClick={() => openWizard(need.id, need.title)}
|
onClick={() => openWizard(need.id, need.title)}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
sx={{
|
sx={{ textTransform: 'none', bgcolor: '#152642', fontWeight: 600, '&:hover': { bgcolor: '#16304d' } }}
|
||||||
textTransform: 'none',
|
|
||||||
bgcolor: '#1e3a5f',
|
|
||||||
fontWeight: 600,
|
|
||||||
'&:hover': { bgcolor: '#16304d' },
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
Angebot erstellen
|
Angebot erstellen
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function ProfileBox({ icon, label, value }: { icon: React.ReactNode; label: string; value: string }) {
|
function ProfileBox({ icon, label, value }: { icon: React.ReactNode; label: string; value: string }) {
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box sx={{ bgcolor: 'white', border: '1px solid #e2e8f0', borderRadius: 1.5, px: 1.5, py: 1 }}>
|
||||||
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 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, color: '#64748b', mb: 0.25 }}>
|
||||||
{icon}
|
{icon}
|
||||||
<Typography variant="caption" sx={{ fontSize: '0.7rem', fontWeight: 600, textTransform: 'uppercase', letterSpacing: 0.5 }}>
|
<Typography variant="caption" sx={{ fontSize: '0.7rem', fontWeight: 600, textTransform: 'uppercase', letterSpacing: 0.5 }}>
|
||||||
{label}
|
{label}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
<Typography variant="body2" sx={{ fontSize: '0.85rem', color: '#0f172a', fontWeight: 500 }}>
|
<Typography variant="body2" sx={{ fontSize: '0.85rem', color: '#0f172a', fontWeight: 500 }}>{value}</Typography>
|
||||||
{value}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,8 +16,8 @@ export function PublicNeedList({ selectedNeedId, onSelect, fullWidth }: PublicNe
|
|||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
width: fullWidth ? '100%' : 280,
|
width: fullWidth ? '100%' : 260,
|
||||||
minWidth: fullWidth ? 'unset' : 280,
|
minWidth: fullWidth ? 'unset' : 260,
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
borderRight: fullWidth ? 'none' : '1px solid #e2e8f0',
|
borderRight: fullWidth ? 'none' : '1px solid #e2e8f0',
|
||||||
bgcolor: 'white',
|
bgcolor: 'white',
|
||||||
@@ -47,7 +47,7 @@ export function PublicNeedList({ selectedNeedId, onSelect, fullWidth }: PublicNe
|
|||||||
px: 1,
|
px: 1,
|
||||||
py: 0.125,
|
py: 0.125,
|
||||||
borderRadius: 1,
|
borderRadius: 1,
|
||||||
bgcolor: '#1e3a5f',
|
bgcolor: '#152642',
|
||||||
color: 'white',
|
color: 'white',
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
fontSize: '0.7rem',
|
fontSize: '0.7rem',
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Box, Button, CircularProgress, Divider, Typography } from '@mui/material'
|
import { Box, Button, CircularProgress, Divider, Typography } from '@mui/material'
|
||||||
import { ArrowRight, Building2, Calendar, MapPin, Ruler, Trophy } from 'lucide-react'
|
import { ArrowRight, Building2, Calendar, MapPin, Ruler } from 'lucide-react'
|
||||||
import { useNavigate } from 'react-router'
|
import { useNavigate } from 'react-router'
|
||||||
import { usePropertyById } from '../../hooks/useProperties'
|
import { usePropertyById } from '../../hooks/useProperties'
|
||||||
import { useAdditionalMatchesForInquiry } from '../../hooks/useMatches'
|
import { useAdditionalMatchesForInquiry } from '../../hooks/useMatches'
|
||||||
@@ -8,9 +8,10 @@ import type { AdditionalPropertyMatch } from '../../domain/additionalMatch'
|
|||||||
interface RelatedPropertyCardPanelProps {
|
interface RelatedPropertyCardPanelProps {
|
||||||
propertyId: string
|
propertyId: string
|
||||||
inquiryId: string
|
inquiryId: string
|
||||||
|
compact?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RelatedPropertyCardPanel({ propertyId, inquiryId }: RelatedPropertyCardPanelProps) {
|
export function RelatedPropertyCardPanel({ propertyId, inquiryId, compact }: RelatedPropertyCardPanelProps) {
|
||||||
const { data: property, isLoading } = usePropertyById(propertyId)
|
const { data: property, isLoading } = usePropertyById(propertyId)
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const {
|
const {
|
||||||
@@ -18,7 +19,7 @@ export function RelatedPropertyCardPanel({ propertyId, inquiryId }: RelatedPrope
|
|||||||
isLoading: loadingMatches,
|
isLoading: loadingMatches,
|
||||||
} = useAdditionalMatchesForInquiry(inquiryId, { minScore: 80, excludePropertyId: propertyId })
|
} = useAdditionalMatchesForInquiry(inquiryId, { minScore: 80, excludePropertyId: propertyId })
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading && !compact) {
|
||||||
return (
|
return (
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', p: 4 }}>
|
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', p: 4 }}>
|
||||||
<CircularProgress size={20} />
|
<CircularProgress size={20} />
|
||||||
@@ -26,6 +27,44 @@ export function RelatedPropertyCardPanel({ propertyId, inquiryId }: RelatedPrope
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (compact) {
|
||||||
|
return (
|
||||||
|
<Box sx={{ px: 1.5, pt: 1.25, pb: 1.5, bgcolor: 'white' }}>
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
sx={{ color: '#64748b', fontWeight: 600, textTransform: 'uppercase', letterSpacing: 0.5, fontSize: '0.65rem', display: 'block', mb: 1 }}
|
||||||
|
>
|
||||||
|
Weitere passende Objekte
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{loadingMatches ? (
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'center', py: 1.5 }}>
|
||||||
|
<CircularProgress size={16} />
|
||||||
|
</Box>
|
||||||
|
) : additionalMatches.length === 0 ? (
|
||||||
|
<Typography variant="caption" sx={{ color: '#94a3b8', fontSize: '0.72rem' }}>
|
||||||
|
Keine weiteren Matches
|
||||||
|
</Typography>
|
||||||
|
) : (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
gap: 1.25,
|
||||||
|
overflowX: 'auto',
|
||||||
|
pb: 0.5,
|
||||||
|
'&::-webkit-scrollbar': { height: 3 },
|
||||||
|
'&::-webkit-scrollbar-thumb': { bgcolor: '#cbd5e1', borderRadius: 2 },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{additionalMatches.map(m => (
|
||||||
|
<MatchSliderCard key={m.propertyId} match={m} />
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (!property) {
|
if (!property) {
|
||||||
return (
|
return (
|
||||||
<Box sx={{ p: 2 }}>
|
<Box sx={{ p: 2 }}>
|
||||||
@@ -51,25 +90,22 @@ export function RelatedPropertyCardPanel({ propertyId, inquiryId }: RelatedPrope
|
|||||||
overflowY: 'auto',
|
overflowY: 'auto',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Typography variant="caption" sx={{ color: '#64748b', fontWeight: 600, textTransform: 'uppercase', letterSpacing: 0.5 }}>
|
|
||||||
Bezogenes Objekt
|
|
||||||
</Typography>
|
|
||||||
|
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
height: 140,
|
aspectRatio: '3/2',
|
||||||
borderRadius: 1.5,
|
borderRadius: 1.5,
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
bgcolor: '#e2e8f0',
|
bgcolor: '#f4f3f0',
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
backgroundImage: image ? `url(${image})` : 'none',
|
|
||||||
backgroundSize: 'cover',
|
|
||||||
backgroundPosition: 'center',
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{!image && <Building2 size={32} color="#94a3b8" />}
|
{image ? (
|
||||||
|
<Box component="img" src={image} alt={property.title}
|
||||||
|
sx={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }} />
|
||||||
|
) : (
|
||||||
|
<Box sx={{ width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
|
<Building2 size={32} color="#94a3b8" />
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Typography variant="body1" sx={{ fontWeight: 600, color: '#0f172a', fontSize: '0.95rem' }}>
|
<Typography variant="body1" sx={{ fontWeight: 600, color: '#0f172a', fontSize: '0.95rem' }}>
|
||||||
@@ -77,20 +113,20 @@ export function RelatedPropertyCardPanel({ propertyId, inquiryId }: RelatedPrope
|
|||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75 }}>
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75 }}>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, color: '#475569', fontSize: '0.8125rem' }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, color: '#475569' }}>
|
||||||
<MapPin size={14} />
|
<MapPin size={14} />
|
||||||
<Typography variant="body2" sx={{ fontSize: '0.8125rem' }}>
|
<Typography variant="body2" sx={{ fontSize: '0.8125rem' }}>
|
||||||
{property.location.city}
|
{property.location.city}
|
||||||
{property.location.district ? `, ${property.location.district}` : ''}
|
{property.location.district ? `, ${property.location.district}` : ''}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, color: '#475569', fontSize: '0.8125rem' }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, color: '#475569' }}>
|
||||||
<Ruler size={14} />
|
<Ruler size={14} />
|
||||||
<Typography variant="body2" sx={{ fontSize: '0.8125rem' }}>
|
<Typography variant="body2" sx={{ fontSize: '0.8125rem' }}>
|
||||||
{property.areaSqm.toLocaleString('de-CH')} m² · CHF {property.rentPricePerSqm}/m²/Jahr
|
{property.areaSqm.toLocaleString('de-CH')} m² · CHF {property.rentPricePerSqm}/m²/Jahr
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, color: '#475569', fontSize: '0.8125rem' }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, color: '#475569' }}>
|
||||||
<Calendar size={14} />
|
<Calendar size={14} />
|
||||||
<Typography variant="body2" sx={{ fontSize: '0.8125rem' }}>
|
<Typography variant="body2" sx={{ fontSize: '0.8125rem' }}>
|
||||||
Verfügbar ab {new Date(property.availabilityDate).toLocaleDateString('de-CH')}
|
Verfügbar ab {new Date(property.availabilityDate).toLocaleDateString('de-CH')}
|
||||||
@@ -114,14 +150,10 @@ export function RelatedPropertyCardPanel({ propertyId, inquiryId }: RelatedPrope
|
|||||||
Objekt ansehen
|
Objekt ansehen
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{/* Weitere Matches */}
|
|
||||||
<Divider sx={{ my: 0.5 }} />
|
<Divider sx={{ my: 0.5 }} />
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
|
|
||||||
<Trophy size={14} color="#d97706" />
|
|
||||||
<Typography variant="caption" sx={{ color: '#64748b', fontWeight: 600, textTransform: 'uppercase', letterSpacing: 0.5, fontSize: '0.68rem' }}>
|
<Typography variant="caption" sx={{ color: '#64748b', fontWeight: 600, textTransform: 'uppercase', letterSpacing: 0.5, fontSize: '0.68rem' }}>
|
||||||
Weitere passende Objekte
|
Weitere passende Objekte
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
|
||||||
|
|
||||||
{loadingMatches ? (
|
{loadingMatches ? (
|
||||||
<CircularProgress size={16} sx={{ alignSelf: 'center' }} />
|
<CircularProgress size={16} sx={{ alignSelf: 'center' }} />
|
||||||
@@ -140,74 +172,117 @@ export function RelatedPropertyCardPanel({ propertyId, inquiryId }: RelatedPrope
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function AdditionalMatchCard({ match }: { match: AdditionalPropertyMatch }) {
|
function MatchSliderCard({ match }: { match: AdditionalPropertyMatch }) {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
|
onClick={() => navigate('/supply/properties')}
|
||||||
sx={{
|
sx={{
|
||||||
border: '1px solid #e2e8f0',
|
flexShrink: 0,
|
||||||
|
width: 168,
|
||||||
|
border: '1px solid #e8e7e4',
|
||||||
borderRadius: 1.5,
|
borderRadius: 1.5,
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
|
cursor: 'pointer',
|
||||||
bgcolor: 'white',
|
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
|
<Box
|
||||||
sx={{
|
component="img"
|
||||||
height: 70,
|
src={match.imageUrl}
|
||||||
backgroundImage: `url(${match.imageUrl})`,
|
alt={match.title}
|
||||||
backgroundSize: 'cover',
|
sx={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
|
||||||
backgroundPosition: 'center',
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
|
) : (
|
||||||
|
<Box sx={{ width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
|
<Building2 size={16} color="#94a3b8" />
|
||||||
|
</Box>
|
||||||
)}
|
)}
|
||||||
<Box sx={{ p: 1 }}>
|
{/* Score badge overlay */}
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', mb: 0.5 }}>
|
|
||||||
<Typography variant="caption" sx={{ fontWeight: 600, color: '#0f172a', fontSize: '0.75rem', lineHeight: 1.3 }}>
|
|
||||||
{match.title}
|
|
||||||
</Typography>
|
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
px: 0.75,
|
position: 'absolute',
|
||||||
py: 0.125,
|
top: 6,
|
||||||
borderRadius: 1,
|
right: 6,
|
||||||
bgcolor: match.matchScore >= 90 ? '#fef3c7' : '#e0e7ff',
|
bgcolor: 'rgba(15,23,42,0.72)',
|
||||||
color: match.matchScore >= 90 ? '#92400e' : '#3730a3',
|
color: 'white',
|
||||||
|
fontSize: '0.7rem',
|
||||||
fontWeight: 700,
|
fontWeight: 700,
|
||||||
fontSize: '0.65rem',
|
px: 0.75,
|
||||||
flexShrink: 0,
|
py: 0.25,
|
||||||
ml: 0.5,
|
borderRadius: '4px',
|
||||||
|
lineHeight: 1.4,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{match.matchScore}%
|
{match.matchScore}%
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
<Typography variant="caption" sx={{ color: '#64748b', fontSize: '0.7rem', display: 'block', mb: 0.5 }}>
|
<Box sx={{ p: 0.75 }}>
|
||||||
{match.location} · {match.areaSqm.toLocaleString('de-CH')} m² · CHF {match.rentPricePerSqm}/m²/Jahr
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', mb: 0.2 }}>
|
||||||
</Typography>
|
<Typography
|
||||||
{match.reasons.length > 0 && (
|
sx={{ fontWeight: 600, color: '#0f172a', fontSize: '0.75rem', lineHeight: 1.3, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1, mr: 0.5 }}
|
||||||
<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: '#1e3a5f' }}
|
|
||||||
>
|
>
|
||||||
Ansehen
|
{match.title}
|
||||||
</Button>
|
</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>
|
||||||
</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -82,8 +82,6 @@ const FIELD_GROUPS: FieldGroup[] = [
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
const MANDATORY: ReportObjectFieldKey[] = ['title', 'location', 'mapImageUrl', 'images']
|
|
||||||
|
|
||||||
export function ReportObjectFieldSelector({ propertyTitle, value, onChange }: Props) {
|
export function ReportObjectFieldSelector({ propertyTitle, value, onChange }: Props) {
|
||||||
const isSelected = (key: ReportObjectFieldKey) =>
|
const isSelected = (key: ReportObjectFieldKey) =>
|
||||||
value.selectedOptionalFields.includes(key)
|
value.selectedOptionalFields.includes(key)
|
||||||
@@ -115,7 +113,6 @@ export function ReportObjectFieldSelector({ propertyTitle, value, onChange }: Pr
|
|||||||
|
|
||||||
{FIELD_GROUPS.map(group => {
|
{FIELD_GROUPS.map(group => {
|
||||||
const groupKeys = group.fields.map(f => f.key)
|
const groupKeys = group.fields.map(f => f.key)
|
||||||
const allSelected = groupKeys.every(k => value.selectedOptionalFields.includes(k))
|
|
||||||
return (
|
return (
|
||||||
<Accordion key={group.label} disableGutters elevation={0} sx={{ border: '1px solid #e2e8f0', mb: 0.5, '&:before': { display: 'none' } }}>
|
<Accordion key={group.label} disableGutters elevation={0} sx={{ border: '1px solid #e2e8f0', mb: 0.5, '&:before': { display: 'none' } }}>
|
||||||
<AccordionSummary expandIcon={<ChevronDown size={16} />} sx={{ minHeight: 40, '& .MuiAccordionSummary-content': { my: 0.5 } }}>
|
<AccordionSummary expandIcon={<ChevronDown size={16} />} sx={{ minHeight: 40, '& .MuiAccordionSummary-content': { my: 0.5 } }}>
|
||||||
|
|||||||
@@ -31,12 +31,12 @@ export function SelectablePropertyMatchCard({
|
|||||||
p: 1.25,
|
p: 1.25,
|
||||||
borderRadius: 1.5,
|
borderRadius: 1.5,
|
||||||
border: '1px solid',
|
border: '1px solid',
|
||||||
borderColor: selected ? '#1e3a5f' : '#e2e8f0',
|
borderColor: selected ? '#152642' : '#e8e7e4',
|
||||||
bgcolor: selected ? '#eff6ff' : 'white',
|
bgcolor: selected ? '#eff6ff' : 'white',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
transition: 'all 0.15s',
|
transition: 'all 0.15s',
|
||||||
'&:hover': {
|
'&:hover': {
|
||||||
borderColor: selected ? '#1e3a5f' : '#94a3b8',
|
borderColor: selected ? '#152642' : '#94a3b8',
|
||||||
},
|
},
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
gap: 1.25,
|
gap: 1.25,
|
||||||
@@ -57,7 +57,7 @@ export function SelectablePropertyMatchCard({
|
|||||||
height: 40,
|
height: 40,
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
borderRadius: 1,
|
borderRadius: 1,
|
||||||
bgcolor: '#e2e8f0',
|
bgcolor: '#e8e7e4',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export { PublicNeedCard } from './PublicNeedCard'
|
|||||||
export { PublicNeedDetail } from './PublicNeedDetail'
|
export { PublicNeedDetail } from './PublicNeedDetail'
|
||||||
export { OwnPropertyMatchList } from './OwnPropertyMatchList'
|
export { OwnPropertyMatchList } from './OwnPropertyMatchList'
|
||||||
export { SelectablePropertyMatchCard } from './SelectablePropertyMatchCard'
|
export { SelectablePropertyMatchCard } from './SelectablePropertyMatchCard'
|
||||||
|
export { EmbeddedPropertyCard } from './EmbeddedPropertyCard'
|
||||||
export { OfferCreationPanel } from './OfferCreationPanel'
|
export { OfferCreationPanel } from './OfferCreationPanel'
|
||||||
export { OfferPropertySelectionStep } from './OfferPropertySelectionStep'
|
export { OfferPropertySelectionStep } from './OfferPropertySelectionStep'
|
||||||
export { OfferPdfReviewStep } from './OfferPdfReviewStep'
|
export { OfferPdfReviewStep } from './OfferPdfReviewStep'
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export function latentStatusBadge(status: 'public' | 'paused' | 'expired'): {
|
|||||||
} {
|
} {
|
||||||
if (status === 'public') return { label: 'Öffentlich', bg: '#dcfce7', fg: '#166534' }
|
if (status === 'public') return { label: 'Öffentlich', bg: '#dcfce7', fg: '#166534' }
|
||||||
if (status === 'paused') return { label: 'Pausiert', bg: '#fed7aa', fg: '#9a3412' }
|
if (status === 'paused') return { label: 'Pausiert', bg: '#fed7aa', fg: '#9a3412' }
|
||||||
return { label: 'Abgelaufen', bg: '#e2e8f0', fg: '#475569' }
|
return { label: 'Abgelaufen', bg: '#e8e7e4', fg: '#475569' }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Deterministic pseudo-random match score from string IDs (range 60–96)
|
// Deterministic pseudo-random match score from string IDs (range 60–96)
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ACTION_COLORS: Record<string, string> = {
|
const ACTION_COLORS: Record<string, string> = {
|
||||||
NAVIGATE: '#1e3a5f',
|
NAVIGATE: '#152642',
|
||||||
OPEN_REVIEW: '#7c3aed',
|
OPEN_REVIEW: '#7c3aed',
|
||||||
ADD_TO_SHORTLIST: '#1a7a4a',
|
ADD_TO_SHORTLIST: '#1a7a4a',
|
||||||
REQUEST_DATA: '#d97706',
|
REQUEST_DATA: '#d97706',
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ function MessageBubble({ message }: { message: AssistantMessage }) {
|
|||||||
px: 1.5,
|
px: 1.5,
|
||||||
py: 1,
|
py: 1,
|
||||||
borderRadius: isUser ? '8px 8px 2px 8px' : '2px 8px 8px 8px',
|
borderRadius: isUser ? '8px 8px 2px 8px' : '2px 8px 8px 8px',
|
||||||
bgcolor: isUser ? '#1e3a5f' : '#f1f5f9',
|
bgcolor: isUser ? '#152642' : '#f1f5f9',
|
||||||
color: isUser ? 'white' : '#1e293b',
|
color: isUser ? 'white' : '#1e293b',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -237,7 +237,7 @@ export function GlobalAIAssistantDrawer() {
|
|||||||
color: 'white',
|
color: 'white',
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
'&:hover': { bgcolor: '#4338ca' },
|
'&:hover': { bgcolor: '#4338ca' },
|
||||||
'&:disabled': { bgcolor: '#e2e8f0', color: '#94a3b8' },
|
'&:disabled': { bgcolor: '#e8e7e4', color: '#94a3b8' },
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Send size={16} />
|
<Send size={16} />
|
||||||
|
|||||||
@@ -37,10 +37,10 @@ export function DemoRoleSwitcher() {
|
|||||||
sx={{
|
sx={{
|
||||||
fontSize: '0.7rem',
|
fontSize: '0.7rem',
|
||||||
height: 22,
|
height: 22,
|
||||||
bgcolor: active ? '#1e3a5f' : 'transparent',
|
bgcolor: active ? '#152642' : 'transparent',
|
||||||
color: active ? '#fff' : 'text.secondary',
|
color: active ? '#fff' : 'text.secondary',
|
||||||
border: '1px solid',
|
border: '1px solid',
|
||||||
borderColor: active ? '#1e3a5f' : 'divider',
|
borderColor: active ? '#152642' : 'divider',
|
||||||
'&:hover': { bgcolor: active ? '#162d4a' : 'rgba(0,0,0,0.04)' },
|
'&:hover': { bgcolor: active ? '#162d4a' : 'rgba(0,0,0,0.04)' },
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ export function SessionExpired() {
|
|||||||
<Button
|
<Button
|
||||||
variant="contained"
|
variant="contained"
|
||||||
onClick={handleRelogin}
|
onClick={handleRelogin}
|
||||||
sx={{ mt: 1, bgcolor: '#1e3a5f', textTransform: 'none' }}
|
sx={{ mt: 1, bgcolor: '#152642', textTransform: 'none' }}
|
||||||
>
|
>
|
||||||
Erneut anmelden
|
Erneut anmelden
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Chip } from '@mui/material'
|
import { Chip } from '@mui/material'
|
||||||
import { ShieldCheck, Globe, Building2, Sparkles } from 'lucide-react'
|
import { ShieldCheck, Building2, Sparkles } from 'lucide-react'
|
||||||
import type { ResultType } from '../../domain/enums'
|
import type { ResultType } from '../../domain/enums'
|
||||||
import { DS_COLORS } from '../../lib/ds'
|
import { DS_COLORS } from '../../lib/ds'
|
||||||
import { RESULT_TYPE_LABELS } from '../../lib/constants'
|
import { RESULT_TYPE_LABELS } from '../../lib/constants'
|
||||||
@@ -11,7 +11,6 @@ interface ResultTypeBadgeProps {
|
|||||||
|
|
||||||
const ICONS: Record<ResultType, React.ElementType> = {
|
const ICONS: Record<ResultType, React.ElementType> = {
|
||||||
VERIFIED_PORTFOLIO: ShieldCheck,
|
VERIFIED_PORTFOLIO: ShieldCheck,
|
||||||
EXTERNAL_MARKET: Globe,
|
|
||||||
MAISON_WORK: Building2,
|
MAISON_WORK: Building2,
|
||||||
FUTURE_AVAILABILITY: Sparkles,
|
FUTURE_AVAILABILITY: Sparkles,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ interface MetricCardProps {
|
|||||||
sx?: SxProps<Theme>
|
sx?: SxProps<Theme>
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MetricCard({ label, value, delta, icon, color = '#1e3a5f', sx }: MetricCardProps) {
|
export function MetricCard({ label, value, delta, icon, color = '#152642', sx }: MetricCardProps) {
|
||||||
return (
|
return (
|
||||||
<Card sx={{ p: 2.5, ...sx }}>
|
<Card sx={{ p: 2.5, ...sx }}>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between' }}>
|
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between' }}>
|
||||||
|
|||||||
@@ -9,8 +9,7 @@ interface SourceTypeBadgeProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const CONFIG: Record<string, { bg: string; color: string; Icon: React.ElementType }> = {
|
const CONFIG: Record<string, { bg: string; color: string; Icon: React.ElementType }> = {
|
||||||
VERIFIED_PORTFOLIO: { bg: 'rgba(30,58,95,0.1)', color: '#1e3a5f', Icon: ShieldCheck },
|
VERIFIED_PORTFOLIO: { bg: 'rgba(30,58,95,0.1)', color: '#152642', Icon: ShieldCheck },
|
||||||
EXTERNAL_MARKET: { bg: 'rgba(217,119,6,0.1)', color: '#b45309', Icon: Globe },
|
|
||||||
MAISON_WORK: { bg: 'rgba(3,105,161,0.1)', color: '#0369a1', Icon: Building2 },
|
MAISON_WORK: { bg: 'rgba(3,105,161,0.1)', color: '#0369a1', Icon: Building2 },
|
||||||
FUTURE_AVAILABILITY: { bg: 'rgba(124,58,237,0.1)', color: '#6d28d9', Icon: Sparkles },
|
FUTURE_AVAILABILITY: { bg: 'rgba(124,58,237,0.1)', color: '#6d28d9', Icon: Sparkles },
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ export function CompareColumnHeader({ item, onRemove }: Props) {
|
|||||||
sx={{
|
sx={{
|
||||||
fontWeight: 700, lineHeight: 1.3, mb: 0.25,
|
fontWeight: 700, lineHeight: 1.3, mb: 0.25,
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
'&:hover': { color: '#1e3a5f' },
|
'&:hover': { color: '#152642' },
|
||||||
}}
|
}}
|
||||||
onClick={() => navigate(`/demand/results/${item.matchId}`)}
|
onClick={() => navigate(`/demand/results/${item.matchId}`)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ export function CompareCriteriaCard({ compareItems, relevantCriteria, overallWin
|
|||||||
<TableCell sx={{ ...LABEL_SX, bgcolor: '#f8fafc', fontSize: 11 }}>Kriterium</TableCell>
|
<TableCell sx={{ ...LABEL_SX, bgcolor: '#f8fafc', fontSize: 11 }}>Kriterium</TableCell>
|
||||||
{compareItems.map(item => (
|
{compareItems.map(item => (
|
||||||
<TableCell key={item.matchId} sx={{ ...DATA_SX, bgcolor: '#f8fafc' }}>
|
<TableCell key={item.matchId} sx={{ ...DATA_SX, bgcolor: '#f8fafc' }}>
|
||||||
<Typography variant="caption" sx={{ fontWeight: 700, color: '#1e3a5f' }} noWrap>
|
<Typography variant="caption" sx={{ fontWeight: 700, color: '#152642' }} noWrap>
|
||||||
{getTitle(item)}
|
{getTitle(item)}
|
||||||
</Typography>
|
</Typography>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ export function CompareEmptyState() {
|
|||||||
Fügen Sie 2–4 Ergebnisse aus dem Feed, Match Detail oder Match Center zum Vergleich hinzu.
|
Fügen Sie 2–4 Ergebnisse aus dem Feed, Match Detail oder Match Center zum Vergleich hinzu.
|
||||||
</Typography>
|
</Typography>
|
||||||
<Button variant="contained" size="small" onClick={() => navigate('/demand/results')}
|
<Button variant="contained" size="small" onClick={() => navigate('/demand/results')}
|
||||||
sx={{ bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#162d4a' } }}>
|
sx={{ bgcolor: '#152642', '&:hover': { bgcolor: '#162d4a' } }}>
|
||||||
Zu den Suchergebnissen
|
Zu den Suchergebnissen
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { memo } from 'react'
|
||||||
|
import type { ReactNode } from 'react'
|
||||||
|
import { TableCell, TableRow } from '@mui/material'
|
||||||
|
import { LABEL_SX, DATA_SX } from './compareUtils'
|
||||||
|
|
||||||
|
// ── Props ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface CompareMetricRowProps {
|
||||||
|
label: string
|
||||||
|
cells: ReactNode[]
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Component ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const CompareMetricRow = memo(function CompareMetricRow({ label, cells }: CompareMetricRowProps) {
|
||||||
|
return (
|
||||||
|
<TableRow hover>
|
||||||
|
<TableCell sx={LABEL_SX}>{label}</TableCell>
|
||||||
|
{cells.map((cell, i) => (
|
||||||
|
<TableCell key={i} sx={DATA_SX}>{cell}</TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -20,6 +20,8 @@ import {
|
|||||||
import { CompareCell, MissingDataCell } from './index'
|
import { CompareCell, MissingDataCell } from './index'
|
||||||
import { RESULT_TYPE_META, DS_COLORS } from '../../lib/ds'
|
import { RESULT_TYPE_META, DS_COLORS } from '../../lib/ds'
|
||||||
import { matchScoreHex } from '../../lib/utils'
|
import { matchScoreHex } from '../../lib/utils'
|
||||||
|
import { effectiveAnnualBurdenPerSqm } from '../../lib/fitOutUtils'
|
||||||
|
import { FITOUT_AMORTIZATION_YEARS, FITOUT_ANNUITY_RATE, FIT_OUT_LABELS } from '../../lib/constants'
|
||||||
import type { UnifiedMatchResult } from '../../domain/unifiedResult'
|
import type { UnifiedMatchResult } from '../../domain/unifiedResult'
|
||||||
|
|
||||||
// ── Local helper ──────────────────────────────────────────────────────────────
|
// ── Local helper ──────────────────────────────────────────────────────────────
|
||||||
@@ -148,18 +150,57 @@ export function CompareTableBody({
|
|||||||
: <MissingDataCell />
|
: <MissingDataCell />
|
||||||
}))}
|
}))}
|
||||||
|
|
||||||
{/* 9. Rent / Budget Fit */}
|
{/* 9. Rent / Budget Fit — full cost breakdown incl. amortised fit-out */}
|
||||||
{row('9. Miete / Budget', compareItems.map(item => {
|
{row('9. Kosten / Budget', compareItems.map(item => {
|
||||||
const prop = getProp(item)
|
const prop = getProp(item)
|
||||||
if (!prop) return <MissingDataCell reason="Mietpreis nur für bestätigte Objekte verfügbar" />
|
if (!prop) return <MissingDataCell reason="Mietpreis nur für bestätigte Objekte verfügbar" />
|
||||||
|
|
||||||
|
const fitOutByLandlord = prop.hardFacts?.fitOutByLandlord
|
||||||
|
const monthlyRent = prop.totalRentMonthly
|
||||||
|
?? Math.round(prop.rentPricePerSqm * prop.areaSqm / 12)
|
||||||
|
// ancillaryCosts stored as CHF/m²/Monat
|
||||||
|
const monthlyNebenkosten = prop.ancillaryCosts != null
|
||||||
|
? Math.round(prop.ancillaryCosts * prop.areaSqm)
|
||||||
|
: null
|
||||||
|
const fitOut = prop.hardFacts?.fitOut
|
||||||
|
const fitOutLabel = fitOut ? (FIT_OUT_LABELS[fitOut] ?? fitOut) : null
|
||||||
|
const mabPerSqm = prop.hardFacts?.mieterausbaubeitragPerSqm ?? 0
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
+ fitOutMonthly
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box>
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.4 }}>
|
||||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>
|
<Typography variant="body2" sx={{ fontWeight: 700 }}>
|
||||||
CHF {prop.rentPricePerSqm}/m²/Jahr
|
CHF {prop.rentPricePerSqm}/m²/Jahr
|
||||||
</Typography>
|
</Typography>
|
||||||
{prop.totalRentMonthly && (
|
|
||||||
<Typography variant="caption" color="text.secondary">
|
<Typography variant="caption" color="text.secondary">
|
||||||
{prop.totalRentMonthly.toLocaleString('de-CH')} CHF/Monat
|
{monthlyRent.toLocaleString('de-CH')} CHF/Monat (Miete)
|
||||||
|
</Typography>
|
||||||
|
{monthlyNebenkosten != null && (
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
+ {monthlyNebenkosten.toLocaleString('de-CH')} CHF/Monat (NK)
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
{fitOutMonthlyLabel && (
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
+ {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 }}>
|
||||||
|
= {totalMonthly.toLocaleString('de-CH')} CHF/Monat
|
||||||
|
</Typography>
|
||||||
|
{fitOutLabel && (
|
||||||
|
<Typography variant="caption" sx={{ color: '#64748b', mt: 0.25 }}>
|
||||||
|
Ausbau: {fitOutLabel}{fitOutByLandlord ? ' (im Mietzins)' : fitOutMonthly === 0 ? ' (bezugsfertig)' : ''}
|
||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ export function DataQualityPanel({ property }: DataQualityPanelProps) {
|
|||||||
Datenaktualisierung anfragen
|
Datenaktualisierung anfragen
|
||||||
</Button>
|
</Button>
|
||||||
{q.missingCriticalFields.length > 0 && (
|
{q.missingCriticalFields.length > 0 && (
|
||||||
<Button variant="contained" size="small" sx={{ textTransform: 'none', bgcolor: '#1e3a5f' }}>
|
<Button variant="contained" size="small" sx={{ textTransform: 'none', bgcolor: '#152642' }}>
|
||||||
Fehlende Felder ergänzen
|
Fehlende Felder ergänzen
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ export function DataQualityProgress({ property, compact = false }: DataQualityPr
|
|||||||
sx={{
|
sx={{
|
||||||
height: 4,
|
height: 4,
|
||||||
borderRadius: 2,
|
borderRadius: 2,
|
||||||
bgcolor: '#e2e8f0',
|
bgcolor: '#e8e7e4',
|
||||||
'& .MuiLinearProgress-bar': { bgcolor: d.color },
|
'& .MuiLinearProgress-bar': { bgcolor: d.color },
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -110,7 +110,7 @@ export function DataQualityProgress({ property, compact = false }: DataQualityPr
|
|||||||
sx={{
|
sx={{
|
||||||
height: 5,
|
height: 5,
|
||||||
borderRadius: 3,
|
borderRadius: 3,
|
||||||
bgcolor: '#e2e8f0',
|
bgcolor: '#e8e7e4',
|
||||||
'& .MuiLinearProgress-bar': { bgcolor: d.color },
|
'& .MuiLinearProgress-bar': { bgcolor: d.color },
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ export function ProvenancePanel({ property: p }: ProvenancePanelProps) {
|
|||||||
sx={{
|
sx={{
|
||||||
height: 5,
|
height: 5,
|
||||||
borderRadius: 3,
|
borderRadius: 3,
|
||||||
bgcolor: '#e2e8f0',
|
bgcolor: '#e8e7e4',
|
||||||
'& .MuiLinearProgress-bar': { bgcolor: color },
|
'& .MuiLinearProgress-bar': { bgcolor: color },
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export function AISearchActionBar({ canProceed, isSearching, isSavingProfile, on
|
|||||||
sx={{
|
sx={{
|
||||||
flex: 1,
|
flex: 1,
|
||||||
py: 1.5,
|
py: 1.5,
|
||||||
bgcolor: '#1e3a5f',
|
bgcolor: '#152642',
|
||||||
'&:hover': { bgcolor: '#162d4a' },
|
'&:hover': { bgcolor: '#162d4a' },
|
||||||
fontSize: 15,
|
fontSize: 15,
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Alert, Box, Button, CircularProgress } from '@mui/material'
|
import { Alert, Box, Button, CircularProgress, FormControlLabel, Switch, Typography } from '@mui/material'
|
||||||
import { Save } from 'lucide-react'
|
import { Save } from 'lucide-react'
|
||||||
import { NeedCardPreview } from './NeedCardPreview'
|
import { NeedCardPreview } from './NeedCardPreview'
|
||||||
import type { ParseNeedResult, ParsedNeedCriteria, WeightingKey } from '../../domain/needBuilder'
|
import type { ParseNeedResult, ParsedNeedCriteria, WeightingKey } from '../../domain/needBuilder'
|
||||||
@@ -10,7 +10,9 @@ interface Props {
|
|||||||
needTitle: string
|
needTitle: string
|
||||||
overallConfidence: number
|
overallConfidence: number
|
||||||
isSaving: boolean
|
isSaving: boolean
|
||||||
|
isAnonymous: boolean
|
||||||
onNeedTitleChange: (t: string) => void
|
onNeedTitleChange: (t: string) => void
|
||||||
|
onAnonymousChange: (v: boolean) => void
|
||||||
onBack: () => void
|
onBack: () => void
|
||||||
onSave: () => void
|
onSave: () => void
|
||||||
}
|
}
|
||||||
@@ -22,7 +24,9 @@ export function AISearchSavePreview({
|
|||||||
needTitle,
|
needTitle,
|
||||||
overallConfidence,
|
overallConfidence,
|
||||||
isSaving,
|
isSaving,
|
||||||
|
isAnonymous,
|
||||||
onNeedTitleChange,
|
onNeedTitleChange,
|
||||||
|
onAnonymousChange,
|
||||||
onBack,
|
onBack,
|
||||||
onSave,
|
onSave,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
@@ -39,7 +43,41 @@ export function AISearchSavePreview({
|
|||||||
needTitle={needTitle}
|
needTitle={needTitle}
|
||||||
onNeedTitleChange={onNeedTitleChange}
|
onNeedTitleChange={onNeedTitleChange}
|
||||||
/>
|
/>
|
||||||
<Box sx={{ maxWidth: 720, mx: 'auto', display: 'flex', justifyContent: 'space-between', mt: 3 }}>
|
|
||||||
|
{/* Anonymity option */}
|
||||||
|
<Box sx={{
|
||||||
|
maxWidth: 720, mx: 'auto', mt: 2,
|
||||||
|
p: 1.5, borderRadius: 1.5, border: '1px solid',
|
||||||
|
borderColor: isAnonymous ? '#7c3aed' : '#e8e7e4',
|
||||||
|
bgcolor: isAnonymous ? '#f5f3ff' : '#f8fafc',
|
||||||
|
transition: 'all 0.15s',
|
||||||
|
}}>
|
||||||
|
<FormControlLabel
|
||||||
|
control={
|
||||||
|
<Switch
|
||||||
|
size="small"
|
||||||
|
checked={isAnonymous}
|
||||||
|
onChange={e => onAnonymousChange(e.target.checked)}
|
||||||
|
sx={{
|
||||||
|
'& .MuiSwitch-switchBase.Mui-checked': { color: '#7c3aed' },
|
||||||
|
'& .MuiSwitch-switchBase.Mui-checked + .MuiSwitch-track': { bgcolor: '#7c3aed' },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label={
|
||||||
|
<Box>
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 700, color: isAnonymous ? '#6d28d9' : 'text.primary' }}>
|
||||||
|
Anonymes Suchprofil
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" sx={{ color: isAnonymous ? '#7c3aed' : 'text.secondary' }}>
|
||||||
|
Firmenname wird nicht an Vermieter übermittelt — Verwalter sehen nur Branche und Flächenbedarf
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ maxWidth: 720, mx: 'auto', display: 'flex', justifyContent: 'space-between', mt: 2 }}>
|
||||||
<Button variant="outlined" onClick={onBack} disabled={isSaving}>
|
<Button variant="outlined" onClick={onBack} disabled={isSaving}>
|
||||||
← Zurück
|
← Zurück
|
||||||
</Button>
|
</Button>
|
||||||
@@ -48,7 +86,7 @@ export function AISearchSavePreview({
|
|||||||
onClick={onSave}
|
onClick={onSave}
|
||||||
disabled={isSaving}
|
disabled={isSaving}
|
||||||
endIcon={isSaving ? <CircularProgress size={16} color="inherit" /> : <Save size={16} />}
|
endIcon={isSaving ? <CircularProgress size={16} color="inherit" /> : <Save size={16} />}
|
||||||
sx={{ bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#162d4a' }, textTransform: 'none' }}
|
sx={{ bgcolor: '#152642', '&:hover': { bgcolor: '#162d4a' }, textTransform: 'none' }}
|
||||||
>
|
>
|
||||||
{isSaving
|
{isSaving
|
||||||
? 'Wird gespeichert…'
|
? 'Wird gespeichert…'
|
||||||
|
|||||||
@@ -7,15 +7,34 @@ interface AnfragenInquiryItemProps {
|
|||||||
inq: Inquiry
|
inq: Inquiry
|
||||||
isSelected: boolean
|
isSelected: boolean
|
||||||
hasPipeline: boolean
|
hasPipeline: boolean
|
||||||
|
perspective?: 'demand' | 'supply'
|
||||||
onSelect: (id: string) => void
|
onSelect: (id: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AnfragenInquiryItem({ inq, isSelected, hasPipeline, onSelect }: AnfragenInquiryItemProps) {
|
export function AnfragenInquiryItem({ inq, isSelected, hasPipeline, perspective = 'supply', onSelect }: AnfragenInquiryItemProps) {
|
||||||
const cfg = INQUIRY_STATUS_META[inq.status ?? 'new']
|
const cfg = INQUIRY_STATUS_META[inq.status ?? 'new']
|
||||||
const displayDate = new Date(inq.updatedAt).toLocaleDateString('de-CH', { day: '2-digit', month: '2-digit' })
|
const displayDate = new Date(inq.updatedAt).toLocaleDateString('de-CH', { day: '2-digit', month: '2-digit' })
|
||||||
const lastMsg = inq.thread[inq.thread.length - 1]
|
const lastMsg = inq.thread[inq.thread.length - 1]
|
||||||
const unreadColor = cfg?.fg ?? DS_TEXT.danger
|
const unreadColor = cfg?.fg ?? DS_TEXT.danger
|
||||||
|
|
||||||
|
const primaryTitle = perspective === 'demand'
|
||||||
|
? (inq.propertyAddress ?? inq.subject)
|
||||||
|
: inq.tenantName
|
||||||
|
|
||||||
|
const secondaryLine = perspective === 'demand'
|
||||||
|
? (inq.propertyManagerCompany ?? inq.propertyManagerName ?? '')
|
||||||
|
: (inq.tenantCompany ?? '')
|
||||||
|
|
||||||
|
const lastMsgPrefix = (() => {
|
||||||
|
if (!lastMsg) return ''
|
||||||
|
if (perspective === 'demand') {
|
||||||
|
return lastMsg.senderType === 'tenant'
|
||||||
|
? 'Sie: '
|
||||||
|
: `${inq.propertyManagerName ?? 'Verwalter'}: `
|
||||||
|
}
|
||||||
|
return lastMsg.senderType === 'tenant' ? 'Sie: ' : `${lastMsg.senderName}: `
|
||||||
|
})()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box onClick={() => onSelect(inq.id)} sx={{
|
<Box onClick={() => onSelect(inq.id)} sx={{
|
||||||
px: 2, py: 1.5,
|
px: 2, py: 1.5,
|
||||||
@@ -31,7 +50,7 @@ export function AnfragenInquiryItem({ inq, isSelected, hasPipeline, onSelect }:
|
|||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, minWidth: 0 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, minWidth: 0 }}>
|
||||||
{!inq.isRead && <Box sx={{ width: 7, height: 7, borderRadius: '50%', bgcolor: unreadColor, flexShrink: 0 }} />}
|
{!inq.isRead && <Box sx={{ width: 7, height: 7, borderRadius: '50%', bgcolor: unreadColor, flexShrink: 0 }} />}
|
||||||
<Typography variant="body2" sx={{ fontWeight: !inq.isRead ? 700 : 500, fontSize: '0.8125rem' }} noWrap>
|
<Typography variant="body2" sx={{ fontWeight: !inq.isRead ? 700 : 500, fontSize: '0.8125rem' }} noWrap>
|
||||||
{inq.tenantName}
|
{primaryTitle}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, flexShrink: 0 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, flexShrink: 0 }}>
|
||||||
@@ -43,9 +62,9 @@ export function AnfragenInquiryItem({ inq, isSelected, hasPipeline, onSelect }:
|
|||||||
<Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.7rem' }}>{displayDate}</Typography>
|
<Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.7rem' }}>{displayDate}</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
{inq.tenantCompany && (
|
{secondaryLine && (
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', fontSize: '0.7rem', mb: 0.25 }} noWrap>
|
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', fontSize: '0.7rem', mb: 0.25 }} noWrap>
|
||||||
{inq.tenantCompany}
|
{secondaryLine}
|
||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
<Typography variant="caption" sx={{ color: DS_TEXT.secondary, display: 'block', mb: 0.5, fontWeight: !inq.isRead ? 600 : 400, fontSize: '0.75rem' }} noWrap>
|
<Typography variant="caption" sx={{ color: DS_TEXT.secondary, display: 'block', mb: 0.5, fontWeight: !inq.isRead ? 600 : 400, fontSize: '0.75rem' }} noWrap>
|
||||||
@@ -53,7 +72,7 @@ export function AnfragenInquiryItem({ inq, isSelected, hasPipeline, onSelect }:
|
|||||||
</Typography>
|
</Typography>
|
||||||
{lastMsg && (
|
{lastMsg && (
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.5, fontSize: '0.7rem' }} noWrap>
|
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.5, fontSize: '0.7rem' }} noWrap>
|
||||||
{lastMsg.senderType === 'tenant' ? 'Sie: ' : `${lastMsg.senderName}: `}
|
{lastMsgPrefix}
|
||||||
{lastMsg.body.split('\n')[0]}
|
{lastMsg.body.split('\n')[0]}
|
||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { Bot, Building2, FileText } from 'lucide-react'
|
|||||||
import { DS_COLORS, DS_TEXT, DS_BG, DS_BORDER } from '../../lib/ds'
|
import { DS_COLORS, DS_TEXT, DS_BG, DS_BORDER } from '../../lib/ds'
|
||||||
import type { InquiryMessage } from '../../domain/inquiry'
|
import type { InquiryMessage } from '../../domain/inquiry'
|
||||||
|
|
||||||
export function AnfragenMessageBubble({ msg }: { msg: InquiryMessage }) {
|
export function AnfragenMessageBubble({ msg, currentUserName }: { msg: InquiryMessage; currentUserName?: string }) {
|
||||||
const isOwnMessage = msg.senderType === 'tenant'
|
const isOwnMessage = msg.senderType === 'tenant'
|
||||||
const isAI = msg.senderType === 'ai'
|
const isAI = msg.senderType === 'ai'
|
||||||
const time = new Date(msg.createdAt).toLocaleTimeString('de-CH', { hour: '2-digit', minute: '2-digit' })
|
const time = new Date(msg.createdAt).toLocaleTimeString('de-CH', { hour: '2-digit', minute: '2-digit' })
|
||||||
@@ -15,7 +15,7 @@ export function AnfragenMessageBubble({ msg }: { msg: InquiryMessage }) {
|
|||||||
{!isOwnMessage && isAI && <Bot size={11} color={DS_TEXT.signal} />}
|
{!isOwnMessage && isAI && <Bot size={11} color={DS_TEXT.signal} />}
|
||||||
{!isOwnMessage && msg.senderType === 'supply_user' && <Building2 size={11} color={DS_TEXT.muted} />}
|
{!isOwnMessage && msg.senderType === 'supply_user' && <Building2 size={11} color={DS_TEXT.muted} />}
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.7rem' }}>
|
<Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.7rem' }}>
|
||||||
{msg.senderName} · {date} {time}
|
{isOwnMessage && currentUserName ? currentUserName : msg.senderName} · {date} {time}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
<Box
|
<Box
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ interface Props {
|
|||||||
|
|
||||||
const ASSET_LABELS: Record<string, string> = {
|
const ASSET_LABELS: Record<string, string> = {
|
||||||
OFFICE: 'Büro', LOGISTICS: 'Logistik / Lager', RETAIL: 'Retail',
|
OFFICE: 'Büro', LOGISTICS: 'Logistik / Lager', RETAIL: 'Retail',
|
||||||
PRODUCTION: 'Produktion', GASTRO: 'Gastro / F&B', LIGHT_INDUSTRIAL: 'Light Industrial',
|
PRODUCTION: 'Produktion', GASTRO: 'Gastro / F&B', LIGHT_INDUSTRIAL: 'Gewerbe',
|
||||||
MIXED: 'Gemischt', UNKNOWN: 'Unbekannt',
|
MIXED: 'Gemischt', UNKNOWN: 'Unbekannt',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ export function FollowUpPanel({ questions, answers, onAnswer, onContinue, onRepa
|
|||||||
disabled={requiredUnanswered.length > 0}
|
disabled={requiredUnanswered.length > 0}
|
||||||
onClick={onContinue}
|
onClick={onContinue}
|
||||||
endIcon={<ArrowRight size={16} />}
|
endIcon={<ArrowRight size={16} />}
|
||||||
sx={{ bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#162d4a' } }}
|
sx={{ bgcolor: '#152642', '&:hover': { bgcolor: '#162d4a' } }}
|
||||||
>
|
>
|
||||||
Weiter zur Gewichtung
|
Weiter zur Gewichtung
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export function NeedBuilderErrorState({ message, onRetry }: Props) {
|
|||||||
variant="contained"
|
variant="contained"
|
||||||
startIcon={<RotateCcw size={16} />}
|
startIcon={<RotateCcw size={16} />}
|
||||||
onClick={onRetry}
|
onClick={onRetry}
|
||||||
sx={{ bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#162d4a' } }}
|
sx={{ bgcolor: '#152642', '&:hover': { bgcolor: '#162d4a' } }}
|
||||||
>
|
>
|
||||||
Erneut versuchen
|
Erneut versuchen
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ interface Props {
|
|||||||
|
|
||||||
const ASSET_LABELS: Record<string, string> = {
|
const ASSET_LABELS: Record<string, string> = {
|
||||||
OFFICE: 'Büro', LOGISTICS: 'Logistik / Lager', RETAIL: 'Retail',
|
OFFICE: 'Büro', LOGISTICS: 'Logistik / Lager', RETAIL: 'Retail',
|
||||||
PRODUCTION: 'Produktion', GASTRO: 'Gastro / F&B', LIGHT_INDUSTRIAL: 'Light Industrial',
|
PRODUCTION: 'Produktion', GASTRO: 'Gastro / F&B', LIGHT_INDUSTRIAL: 'Gewerbe',
|
||||||
}
|
}
|
||||||
|
|
||||||
const CRITICAL_FIELDS = ['assetType', 'areaRange', 'preferredLocations', 'budgetRange', 'timing']
|
const CRITICAL_FIELDS = ['assetType', 'areaRange', 'preferredLocations', 'budgetRange', 'timing']
|
||||||
@@ -66,7 +66,7 @@ export function NeedCardPreview({ criteria: c, weights, confidenceByField, missi
|
|||||||
{/* Header */}
|
{/* Header */}
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
|
||||||
{c.assetType && (
|
{c.assetType && (
|
||||||
<Chip label={ASSET_LABELS[c.assetType] ?? c.assetType} size="small" sx={{ bgcolor: '#1e3a5f', color: 'white' }} />
|
<Chip label={ASSET_LABELS[c.assetType] ?? c.assetType} size="small" sx={{ bgcolor: '#152642', color: 'white' }} />
|
||||||
)}
|
)}
|
||||||
<Chip label={isLowConfidence ? 'ENTWURF (needs_review)' : 'ENTWURF'} size="small" variant="outlined" />
|
<Chip label={isLowConfidence ? 'ENTWURF (needs_review)' : 'ENTWURF'} size="small" variant="outlined" />
|
||||||
</Box>
|
</Box>
|
||||||
@@ -143,7 +143,7 @@ export function NeedCardPreview({ criteria: c, weights, confidenceByField, missi
|
|||||||
variant="determinate"
|
variant="determinate"
|
||||||
value={overallConfidence * 100}
|
value={overallConfidence * 100}
|
||||||
sx={{
|
sx={{
|
||||||
height: 6, borderRadius: 3, bgcolor: '#e2e8f0',
|
height: 6, borderRadius: 3, bgcolor: '#e8e7e4',
|
||||||
'& .MuiLinearProgress-bar': {
|
'& .MuiLinearProgress-bar': {
|
||||||
bgcolor: overallConfidence >= 0.7 ? '#1a7a4a' : overallConfidence >= 0.5 ? '#d97706' : '#c0392b',
|
bgcolor: overallConfidence >= 0.7 ? '#1a7a4a' : overallConfidence >= 0.5 ? '#d97706' : '#c0392b',
|
||||||
},
|
},
|
||||||
@@ -171,7 +171,7 @@ export function NeedCardPreview({ criteria: c, weights, confidenceByField, missi
|
|||||||
<LinearProgress
|
<LinearProgress
|
||||||
variant="determinate"
|
variant="determinate"
|
||||||
value={((weights[k] ?? 0) / maxWeight) * 100}
|
value={((weights[k] ?? 0) / maxWeight) * 100}
|
||||||
sx={{ flex: 1, height: 6, borderRadius: 3, bgcolor: '#e2e8f0', '& .MuiLinearProgress-bar': { bgcolor: '#1e3a5f' } }}
|
sx={{ flex: 1, height: 6, borderRadius: 3, bgcolor: '#e8e7e4', '& .MuiLinearProgress-bar': { bgcolor: '#152642' } }}
|
||||||
/>
|
/>
|
||||||
<Typography variant="caption" sx={{ width: 32, textAlign: 'right' }}>{pct}%</Typography>
|
<Typography variant="caption" sx={{ width: 32, textAlign: 'right' }}>{pct}%</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Accordion, AccordionDetails, AccordionSummary, Box, Checkbox, FormControlLabel, MenuItem, TextField, Typography } from '@mui/material'
|
import { Accordion, AccordionDetails, AccordionSummary, Box, Checkbox, FormControlLabel, MenuItem, Switch, TextField, Typography } from '@mui/material'
|
||||||
import { ChevronDown } from 'lucide-react'
|
import { ChevronDown } from 'lucide-react'
|
||||||
import type { ParsedNeedCriteria } from '../../domain/needBuilder'
|
import type { ParsedNeedCriteria } from '../../domain/needBuilder'
|
||||||
|
|
||||||
@@ -73,7 +73,48 @@ export function NeedExtendedRequirements({ criteria: c, onChange: set }: Props)
|
|||||||
slotProps={{ htmlInput: { min: 0, max: 360, step: 12 } }}
|
slotProps={{ htmlInput: { min: 0, max: 360, step: 12 } }}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
{c.requiredFitOut && (
|
||||||
|
<Box>
|
||||||
|
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.5 }}>Eigenes Ausbaubudget (max. CHF/m²)</Typography>
|
||||||
|
<TextField
|
||||||
|
size="small" type="number" fullWidth placeholder="z.B. 200"
|
||||||
|
value={c.fitOutBudgetMaxPerSqm ?? ''}
|
||||||
|
onChange={e => set({ ...c, fitOutBudgetMaxPerSqm: parseInt(e.target.value) || undefined })}
|
||||||
|
slotProps={{ htmlInput: { min: 0, max: 5000, step: 50 } }}
|
||||||
|
helperText="Überbrückt Flächen unter Ihrem Mindest-Ausbaustandard — Sie tragen die Differenz selbst (exkl. MAB des Vermieters)."
|
||||||
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Divisibility */}
|
||||||
|
<Box sx={{ mt: 1.5, pt: 1.5, borderTop: '1px solid #f1f5f9' }}>
|
||||||
|
<FormControlLabel
|
||||||
|
control={
|
||||||
|
<Switch
|
||||||
|
size="small"
|
||||||
|
checked={c.requiresDivisibility ?? false}
|
||||||
|
onChange={e => set({ ...c, requiresDivisibility: e.target.checked || undefined })}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label={<Typography variant="caption" sx={{ fontWeight: 600 }}>Fläche muss teilbar sein</Typography>}
|
||||||
|
sx={{ mb: c.requiresDivisibility ? 1 : 0 }}
|
||||||
|
/>
|
||||||
|
{c.requiresDivisibility && (
|
||||||
|
<Box>
|
||||||
|
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.5 }}>Mindesteinheit (m²)</Typography>
|
||||||
|
<TextField
|
||||||
|
size="small" type="number"
|
||||||
|
placeholder="z.B. 150"
|
||||||
|
value={c.minDivisibleUnit ?? ''}
|
||||||
|
onChange={e => set({ ...c, minDivisibleUnit: parseInt(e.target.value) || undefined })}
|
||||||
|
sx={{ width: 160 }}
|
||||||
|
slotProps={{ htmlInput: { min: 10, max: 5000, step: 10 } }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
</AccordionDetails>
|
</AccordionDetails>
|
||||||
</Accordion>
|
</Accordion>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { Box, Card, Chip, Stack, TextField, Typography } from '@mui/material'
|
import { Box, Card, Chip, Slider, Stack, TextField, Typography } from '@mui/material'
|
||||||
import type { ParsedNeedCriteria } from '../../domain/needBuilder'
|
import type { ParsedNeedCriteria } from '../../domain/needBuilder'
|
||||||
import { AssetType } from '../../domain/enums'
|
import { AssetType } from '../../domain/enums'
|
||||||
import { NeedExtendedRequirements } from './NeedExtendedRequirements'
|
import { NeedExtendedRequirements } from './NeedExtendedRequirements'
|
||||||
@@ -14,7 +14,7 @@ const ASSET_OPTIONS = [
|
|||||||
{ label: 'Retail', value: AssetType.RETAIL },
|
{ label: 'Retail', value: AssetType.RETAIL },
|
||||||
{ label: 'Logistik', value: AssetType.LOGISTICS },
|
{ label: 'Logistik', value: AssetType.LOGISTICS },
|
||||||
{ label: 'Produktion', value: AssetType.PRODUCTION },
|
{ label: 'Produktion', value: AssetType.PRODUCTION },
|
||||||
{ label: 'Light Industrial', value: AssetType.LIGHT_INDUSTRIAL },
|
{ label: 'Gewerbe', value: AssetType.LIGHT_INDUSTRIAL },
|
||||||
{ label: 'Gemischt', value: AssetType.MIXED },
|
{ label: 'Gemischt', value: AssetType.MIXED },
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -34,6 +34,8 @@ const BUDGET_PRESETS = [
|
|||||||
|
|
||||||
const LOCATION_PRESETS = ['Zürich', 'Zürich-West', 'Zürich-Nord', 'Bern', 'Basel', 'Luzern']
|
const LOCATION_PRESETS = ['Zürich', 'Zürich-West', 'Zürich-Nord', 'Bern', 'Basel', 'Luzern']
|
||||||
|
|
||||||
|
const RADIUS_MARKS = [5, 10, 20, 30, 50, 100].map(v => ({ value: v, label: v === 5 || v === 100 ? `${v} km` : `${v}` }))
|
||||||
|
|
||||||
function FieldLabel({ children }: { children: React.ReactNode }) {
|
function FieldLabel({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ fontWeight: 600, display: 'block', mb: 0.75 }}>
|
<Typography variant="caption" color="text.secondary" sx={{ fontWeight: 600, display: 'block', mb: 0.75 }}>
|
||||||
@@ -79,7 +81,7 @@ export function NeedInput({ criteria: c, onCriteriaChange: set }: Props) {
|
|||||||
clickable
|
clickable
|
||||||
onClick={() => set({ ...c, assetType: c.assetType === opt.value ? undefined : opt.value })}
|
onClick={() => set({ ...c, assetType: c.assetType === opt.value ? undefined : opt.value })}
|
||||||
sx={c.assetType === opt.value
|
sx={c.assetType === opt.value
|
||||||
? { bgcolor: '#1e3a5f', color: 'white', '& .MuiChip-label': { color: 'white' } }
|
? { bgcolor: '#152642', color: 'white', '& .MuiChip-label': { color: 'white' } }
|
||||||
: {}}
|
: {}}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -137,7 +139,7 @@ export function NeedInput({ criteria: c, onCriteriaChange: set }: Props) {
|
|||||||
})
|
})
|
||||||
}}
|
}}
|
||||||
sx={c.preferredLocations?.includes(loc)
|
sx={c.preferredLocations?.includes(loc)
|
||||||
? { fontSize: '0.68rem', height: 22, bgcolor: '#1e3a5f', color: 'white' }
|
? { fontSize: '0.68rem', height: 22, bgcolor: '#152642', color: 'white' }
|
||||||
: { fontSize: '0.68rem', height: 22, color: '#64748b', borderColor: '#cbd5e1' }
|
: { fontSize: '0.68rem', height: 22, color: '#64748b', borderColor: '#cbd5e1' }
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -161,7 +163,27 @@ export function NeedInput({ criteria: c, onCriteriaChange: set }: Props) {
|
|||||||
))}
|
))}
|
||||||
</Stack>
|
</Stack>
|
||||||
)}
|
)}
|
||||||
<Box sx={{ mb: (c.preferredLocations?.filter(l => !LOCATION_PRESETS.includes(l)).length ?? 0) > 0 ? 0 : 2.5 }} />
|
{(c.preferredLocations?.filter(l => !LOCATION_PRESETS.includes(l)).length ?? 0) === 0 && <Box sx={{ mb: 1.5 }} />}
|
||||||
|
|
||||||
|
{/* Radius */}
|
||||||
|
<Box sx={{ mb: 2.5, px: 0.5 }}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 0.5 }}>
|
||||||
|
<Typography variant="caption" color="text.secondary" sx={{ fontWeight: 600 }}>Suchradius</Typography>
|
||||||
|
<Typography variant="caption" sx={{ fontWeight: 700, color: '#152642' }}>
|
||||||
|
{c.searchRadius ?? 30} km
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Slider
|
||||||
|
value={c.searchRadius ?? 30}
|
||||||
|
min={1}
|
||||||
|
max={100}
|
||||||
|
marks={RADIUS_MARKS}
|
||||||
|
step={1}
|
||||||
|
onChange={(_, v) => set({ ...c, searchRadius: v as number })}
|
||||||
|
size="small"
|
||||||
|
sx={{ color: '#152642', '& .MuiSlider-markLabel': { fontSize: '0.62rem' } }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
{/* Budget */}
|
{/* Budget */}
|
||||||
<FieldLabel>Budget (max CHF/m²/Jahr)</FieldLabel>
|
<FieldLabel>Budget (max CHF/m²/Jahr)</FieldLabel>
|
||||||
@@ -175,7 +197,7 @@ export function NeedInput({ criteria: c, onCriteriaChange: set }: Props) {
|
|||||||
clickable
|
clickable
|
||||||
onClick={() => set({ ...c, budgetRange: { maxPerSqm: p.value, currency: 'CHF' } })}
|
onClick={() => set({ ...c, budgetRange: { maxPerSqm: p.value, currency: 'CHF' } })}
|
||||||
sx={c.budgetRange?.maxPerSqm === p.value
|
sx={c.budgetRange?.maxPerSqm === p.value
|
||||||
? { fontSize: '0.68rem', height: 22, bgcolor: '#1e3a5f', color: 'white' }
|
? { fontSize: '0.68rem', height: 22, bgcolor: '#152642', color: 'white' }
|
||||||
: { fontSize: '0.68rem', height: 22, color: '#64748b', borderColor: '#cbd5e1' }
|
: { fontSize: '0.68rem', height: 22, color: '#64748b', borderColor: '#cbd5e1' }
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -77,7 +77,7 @@ export function VoiceNeedInput({ text, onTextChange, onAiSubmit, isAnalyzing, is
|
|||||||
sx={{
|
sx={{
|
||||||
p: 3,
|
p: 3,
|
||||||
border: '1px solid',
|
border: '1px solid',
|
||||||
borderColor: isRecording ? '#dc2626' : '#e2e8f0',
|
borderColor: isRecording ? '#dc2626' : '#e8e7e4',
|
||||||
transition: 'border-color 0.2s',
|
transition: 'border-color 0.2s',
|
||||||
bgcolor: isRecording ? '#fff5f5' : 'white',
|
bgcolor: isRecording ? '#fff5f5' : 'white',
|
||||||
}}
|
}}
|
||||||
@@ -101,11 +101,11 @@ export function VoiceNeedInput({ text, onTextChange, onAiSubmit, isAnalyzing, is
|
|||||||
/>
|
/>
|
||||||
) : isAnalyzing ? (
|
) : isAnalyzing ? (
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
<CircularProgress size={14} sx={{ color: '#1e3a5f' }} />
|
<CircularProgress size={14} sx={{ color: '#152642' }} />
|
||||||
<Typography variant="caption" sx={{ color: '#1e3a5f', fontWeight: 600 }}>Analysiert…</Typography>
|
<Typography variant="caption" sx={{ color: '#152642', fontWeight: 600 }}>Analysiert…</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
) : isAutoGen && text ? (
|
) : isAutoGen && text ? (
|
||||||
<Typography variant="caption" sx={{ color: '#1e3a5f', fontSize: 11 }}>⚡ auto-synchronisiert</Typography>
|
<Typography variant="caption" sx={{ color: '#152642', fontSize: 11 }}>⚡ auto-synchronisiert</Typography>
|
||||||
) : null}
|
) : null}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
@@ -154,7 +154,7 @@ export function VoiceNeedInput({ text, onTextChange, onAiSubmit, isAnalyzing, is
|
|||||||
size="small"
|
size="small"
|
||||||
disabled={isAnalyzing || !isSpeechSupported}
|
disabled={isAnalyzing || !isSpeechSupported}
|
||||||
title={isSpeechSupported ? 'Spracheingabe starten' : 'Spracheingabe nicht verfügbar'}
|
title={isSpeechSupported ? 'Spracheingabe starten' : 'Spracheingabe nicht verfügbar'}
|
||||||
sx={{ bgcolor: '#f1f5f9', color: '#475569', '&:hover': { bgcolor: '#e2e8f0' }, '&:disabled': { opacity: 0.35 } }}
|
sx={{ bgcolor: '#f1f5f9', color: '#475569', '&:hover': { bgcolor: '#e8e7e4' }, '&:disabled': { opacity: 0.35 } }}
|
||||||
>
|
>
|
||||||
<Mic size={16} />
|
<Mic size={16} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
|
|||||||
@@ -109,7 +109,7 @@ export function WeightingEditor({ weights, onChange, assetType }: Props) {
|
|||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Card sx={{ p: 3 }}>
|
<Card sx={{ p: 3 }}>
|
||||||
<SliderGroup label="Harte Kriterien" keys={HARD_KEYS} raw={raw} onSlider={handleSlider} color="#1e3a5f" />
|
<SliderGroup label="Harte Kriterien" keys={HARD_KEYS} raw={raw} onSlider={handleSlider} color="#152642" />
|
||||||
<Divider sx={{ my: 2.5 }} />
|
<Divider sx={{ my: 2.5 }} />
|
||||||
<SliderGroup label="Weiche Kriterien" keys={SOFT_KEYS} raw={raw} onSlider={handleSlider} color="#7c3aed" />
|
<SliderGroup label="Weiche Kriterien" keys={SOFT_KEYS} raw={raw} onSlider={handleSlider} color="#7c3aed" />
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -9,3 +9,7 @@ export { NeedCardPreview } from './NeedCardPreview'
|
|||||||
export { NeedInput } from './NeedInput'
|
export { NeedInput } from './NeedInput'
|
||||||
export { VoiceNeedInput } from './VoiceNeedInput'
|
export { VoiceNeedInput } from './VoiceNeedInput'
|
||||||
export { WeightingEditor } from './WeightingEditor'
|
export { WeightingEditor } from './WeightingEditor'
|
||||||
|
export { PropertyMatchRow } from './PropertyMatchRow'
|
||||||
|
export { SavedNeedCard } from './SavedNeedCard'
|
||||||
|
export { SavedNeedDetail } from './SavedNeedDetail'
|
||||||
|
export { SavedProfilesTab } from './SavedProfilesTab'
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ export function PriorityChipGroup({ label, options, value, onChange, exclusive =
|
|||||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
|
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
|
||||||
{options.map(opt => {
|
{options.map(opt => {
|
||||||
const selected = value.includes(opt.value)
|
const selected = value.includes(opt.value)
|
||||||
const accent = opt.color ?? '#1e3a5f'
|
const accent = opt.color ?? '#152642'
|
||||||
return (
|
return (
|
||||||
<Chip
|
<Chip
|
||||||
key={opt.value}
|
key={opt.value}
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ export function FutureSignalFilterBar({ filters, onChange, totalCount, filteredC
|
|||||||
onClick={() => set({ signalType: filters.signalType === t ? '' : t })}
|
onClick={() => set({ signalType: filters.signalType === t ? '' : t })}
|
||||||
sx={{
|
sx={{
|
||||||
fontWeight: filters.signalType === t ? 700 : 400,
|
fontWeight: filters.signalType === t ? 700 : 400,
|
||||||
bgcolor: filters.signalType === t ? '#1e3a5f' : undefined,
|
bgcolor: filters.signalType === t ? '#152642' : undefined,
|
||||||
color: filters.signalType === t ? 'white' : undefined,
|
color: filters.signalType === t ? 'white' : undefined,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -131,7 +131,7 @@ export function FutureSignalFilterBar({ filters, onChange, totalCount, filteredC
|
|||||||
label={`${activeCount} Filter aktiv`}
|
label={`${activeCount} Filter aktiv`}
|
||||||
size="small"
|
size="small"
|
||||||
onDelete={() => onChange(DEFAULT_SIGNAL_FILTERS)}
|
onDelete={() => onChange(DEFAULT_SIGNAL_FILTERS)}
|
||||||
sx={{ bgcolor: '#eff6ff', color: '#1e3a5f' }}
|
sx={{ bgcolor: '#eff6ff', color: '#152642' }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<Typography variant="caption" color="text.secondary">
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
|||||||
@@ -21,16 +21,21 @@ export function AppShell() {
|
|||||||
const sidebarCollapsed = useLayoutStore(s => s.sidebarCollapsed)
|
const sidebarCollapsed = useLayoutStore(s => s.sidebarCollapsed)
|
||||||
const setActiveWorkspace = useLayoutStore(s => s.setActiveWorkspace)
|
const setActiveWorkspace = useLayoutStore(s => s.setActiveWorkspace)
|
||||||
const toggleSidebar = useLayoutStore(s => s.toggleSidebar)
|
const toggleSidebar = useLayoutStore(s => s.toggleSidebar)
|
||||||
|
const setSidebarCollapsed = useLayoutStore(s => s.setSidebarCollapsed)
|
||||||
const { currentUser } = useSessionStore()
|
const { currentUser } = useSessionStore()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const theme = useTheme()
|
const theme = useTheme()
|
||||||
const isMobile = useMediaQuery(theme.breakpoints.down('md'))
|
const isMobile = useMediaQuery(theme.breakpoints.down('sm'))
|
||||||
|
const isCompact = useMediaQuery(theme.breakpoints.down('lg'))
|
||||||
const [mobileOpen, setMobileOpen] = useState(false)
|
const [mobileOpen, setMobileOpen] = useState(false)
|
||||||
|
|
||||||
// Close mobile menu on route change
|
// Close mobile menu on route change
|
||||||
useEffect(() => { setMobileOpen(false) }, [location.pathname])
|
useEffect(() => { setMobileOpen(false) }, [location.pathname])
|
||||||
|
|
||||||
|
// Auto-collapse sidebar only on smaller screens (< 1200px); laptops keep the full nav + account
|
||||||
|
useEffect(() => { setSidebarCollapsed(isCompact) }, [isCompact, setSidebarCollapsed])
|
||||||
|
|
||||||
// Sync active workspace with URL
|
// Sync active workspace with URL
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const detected = getWorkspaceFromPath(location.pathname)
|
const detected = getWorkspaceFromPath(location.pathname)
|
||||||
|
|||||||
@@ -13,7 +13,8 @@ export const SIDEBAR_BG = '#0f1923'
|
|||||||
export const DIVIDER_COLOR = 'rgba(255,255,255,0.08)'
|
export const DIVIDER_COLOR = 'rgba(255,255,255,0.08)'
|
||||||
export const TEXT_MUTED = '#94a3b8'
|
export const TEXT_MUTED = '#94a3b8'
|
||||||
export const TEXT_WHITE = '#ffffff'
|
export const TEXT_WHITE = '#ffffff'
|
||||||
export const NAV_ACTIVE_BG = 'rgba(255,255,255,0.12)'
|
export const NAV_ACTIVE_BG = 'transparent'
|
||||||
|
export const NAV_ACTIVE_ACCENT = '#b8975a'
|
||||||
export const NAV_HOVER_BG = 'rgba(255,255,255,0.06)'
|
export const NAV_HOVER_BG = 'rgba(255,255,255,0.06)'
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -188,16 +189,16 @@ export function Sidebar({
|
|||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: 1.25,
|
gap: 1.25,
|
||||||
px: collapsed ? 0 : 1.5,
|
pl: collapsed ? 0 : isActive ? 1.25 : 1.5,
|
||||||
|
pr: collapsed ? 0 : 1.5,
|
||||||
py: 0.75,
|
py: 0.75,
|
||||||
mx: 0.5,
|
mx: 0.5,
|
||||||
borderRadius: 1,
|
borderRadius: 1,
|
||||||
justifyContent: collapsed ? 'center' : 'flex-start',
|
justifyContent: collapsed ? 'center' : 'flex-start',
|
||||||
backgroundColor: isActive ? NAV_ACTIVE_BG : 'transparent',
|
borderLeft: collapsed ? 'none' : `3px solid ${isActive ? NAV_ACTIVE_ACCENT : 'transparent'}`,
|
||||||
'&:hover': {
|
backgroundColor: 'transparent',
|
||||||
backgroundColor: isActive ? NAV_ACTIVE_BG : NAV_HOVER_BG,
|
'&:hover': { backgroundColor: NAV_HOVER_BG },
|
||||||
},
|
transition: 'border-color 0.15s ease',
|
||||||
transition: 'background-color 0.15s ease',
|
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -208,8 +209,9 @@ export function Sidebar({
|
|||||||
variant="body2"
|
variant="body2"
|
||||||
sx={{
|
sx={{
|
||||||
color: isActive ? TEXT_WHITE : TEXT_MUTED,
|
color: isActive ? TEXT_WHITE : TEXT_MUTED,
|
||||||
fontWeight: isActive ? 500 : 400,
|
fontWeight: isActive ? 600 : 400,
|
||||||
fontSize: '0.8125rem',
|
fontSize: '0.875rem',
|
||||||
|
letterSpacing: '-0.01em',
|
||||||
whiteSpace: 'nowrap',
|
whiteSpace: 'nowrap',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -263,7 +265,7 @@ export function Sidebar({
|
|||||||
width: 32,
|
width: 32,
|
||||||
height: 32,
|
height: 32,
|
||||||
fontSize: '0.75rem',
|
fontSize: '0.75rem',
|
||||||
bgcolor: '#1e3a5f',
|
bgcolor: '#b8975a',
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -31,10 +31,10 @@ export function TopBar({ activeWorkspace, pathname, onMenuClick, isMobile }: Top
|
|||||||
<Box
|
<Box
|
||||||
component="header"
|
component="header"
|
||||||
sx={{
|
sx={{
|
||||||
height: 56,
|
height: 64,
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
backgroundColor: '#ffffff',
|
backgroundColor: '#ffffff',
|
||||||
borderBottom: '1px solid #e2e8f0',
|
borderBottom: '1px solid #e8e7e4',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
@@ -51,12 +51,13 @@ export function TopBar({ activeWorkspace, pathname, onMenuClick, isMobile }: Top
|
|||||||
<Chip
|
<Chip
|
||||||
label={config.label}
|
label={config.label}
|
||||||
size="small"
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
sx={{
|
sx={{
|
||||||
backgroundColor: config.chipColor,
|
color: '#152642',
|
||||||
color: '#ffffff',
|
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
fontSize: '0.7rem',
|
fontSize: '0.7rem',
|
||||||
height: 22,
|
height: 22,
|
||||||
|
border: '1px solid #e8e7e4',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Typography
|
<Typography
|
||||||
|
|||||||
@@ -4,8 +4,7 @@ import { X } from 'lucide-react'
|
|||||||
import { useCompareStore } from '../../stores/compareStore'
|
import { useCompareStore } from '../../stores/compareStore'
|
||||||
|
|
||||||
const TYPE_DOT: Record<string, string> = {
|
const TYPE_DOT: Record<string, string> = {
|
||||||
VERIFIED_PORTFOLIO: '#1e3a5f',
|
VERIFIED_PORTFOLIO: '#152642',
|
||||||
EXTERNAL_MARKET: '#d97706',
|
|
||||||
MAISON_WORK: '#0369a1',
|
MAISON_WORK: '#0369a1',
|
||||||
FUTURE_AVAILABILITY: '#7c3aed',
|
FUTURE_AVAILABILITY: '#7c3aed',
|
||||||
}
|
}
|
||||||
@@ -93,7 +92,7 @@ export function CompareTray() {
|
|||||||
variant="contained"
|
variant="contained"
|
||||||
size="small"
|
size="small"
|
||||||
onClick={() => navigate('/demand/compare')}
|
onClick={() => navigate('/demand/compare')}
|
||||||
sx={{ bgcolor: '#1e3a5f', textTransform: 'none', flexShrink: 0, '&:hover': { bgcolor: '#162d4a' } }}
|
sx={{ bgcolor: '#152642', textTransform: 'none', flexShrink: 0, '&:hover': { bgcolor: '#162d4a' } }}
|
||||||
>
|
>
|
||||||
Vergleich starten
|
Vergleich starten
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -1,22 +1,74 @@
|
|||||||
import { useState } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
import { Badge, IconButton, Popover, Typography } from '@mui/material'
|
import { Badge, Box, Divider, IconButton, Popover, Typography } from '@mui/material'
|
||||||
import { Bell } from 'lucide-react'
|
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() {
|
export function NotificationButton() {
|
||||||
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null)
|
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>) {
|
function handleOpen(e: React.MouseEvent<HTMLElement>) {
|
||||||
setAnchorEl(e.currentTarget)
|
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() {
|
function handleClose() { setAnchorEl(null) }
|
||||||
setAnchorEl(null)
|
|
||||||
|
function goToResults(needId: string) {
|
||||||
|
navigate(ROUTES.DEMAND.RESULTS, { state: { fromNeedBuilder: true, activeNeedId: needId } })
|
||||||
|
handleClose()
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<IconButton size="small" sx={{ color: '#64748b' }} onClick={handleOpen}>
|
<IconButton size="small" sx={{ color: '#64748b' }} onClick={handleOpen}>
|
||||||
<Badge badgeContent={0} color="error">
|
<Badge badgeContent={badgeCount || null} color="error">
|
||||||
<Bell size={20} />
|
<Bell size={20} />
|
||||||
</Badge>
|
</Badge>
|
||||||
</IconButton>
|
</IconButton>
|
||||||
@@ -27,12 +79,50 @@ export function NotificationButton() {
|
|||||||
onClose={handleClose}
|
onClose={handleClose}
|
||||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
|
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
|
||||||
transformOrigin={{ vertical: 'top', 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="subtitle2" sx={{ mb: 1.25, fontWeight: 700, color: '#0f172a' }}>
|
||||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
Neue Treffer
|
||||||
Keine neuen Benachrichtigungen
|
|
||||||
</Typography>
|
</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>
|
</Popover>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export function OrganizationContextBadge() {
|
|||||||
variant="outlined"
|
variant="outlined"
|
||||||
icon={<Building2 size={12} color="#64748b" />}
|
icon={<Building2 size={12} color="#64748b" />}
|
||||||
label={currentUser.organizationName}
|
label={currentUser.organizationName}
|
||||||
sx={{ fontSize: '0.7rem', height: 22, color: '#64748b', borderColor: '#e2e8f0' }}
|
sx={{ fontSize: '0.7rem', height: 22, color: '#64748b', borderColor: '#e8e7e4' }}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ export function PageHeader({
|
|||||||
sx,
|
sx,
|
||||||
}: PageHeaderProps) {
|
}: PageHeaderProps) {
|
||||||
return (
|
return (
|
||||||
<Box sx={{ px: 3, py: 2.5, borderBottom: '1px solid #e2e8f0', ...sx }}>
|
<Box sx={{ px: 3, py: 2.5, borderBottom: '1px solid #e8e7e4', bgcolor: 'white', ...sx }}>
|
||||||
{breadcrumbs && breadcrumbs.length > 0 && (
|
{breadcrumbs && breadcrumbs.length > 0 && (
|
||||||
<Breadcrumbs separator="/" sx={{ mb: 1 }}>
|
<Breadcrumbs separator="/" sx={{ mb: 1 }}>
|
||||||
{breadcrumbs.map((crumb) =>
|
{breadcrumbs.map((crumb) =>
|
||||||
@@ -52,7 +52,7 @@ export function PageHeader({
|
|||||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
||||||
<Box>
|
<Box>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
<Typography variant="h6" sx={{ fontWeight: 600, lineHeight: 1.3 }}>
|
<Typography sx={{ fontWeight: 700, fontSize: '1.125rem', lineHeight: 1.3, color: '#0f172a' }}>
|
||||||
{title}
|
{title}
|
||||||
</Typography>
|
</Typography>
|
||||||
{badge}
|
{badge}
|
||||||
|
|||||||
@@ -32,8 +32,8 @@ export function RightContextPanel() {
|
|||||||
right: 0,
|
right: 0,
|
||||||
top: 56,
|
top: 56,
|
||||||
height: 'calc(100vh - 56px)',
|
height: 'calc(100vh - 56px)',
|
||||||
width: 320,
|
width: { xs: '85vw', sm: 300, lg: 320 },
|
||||||
transform: isRightPanelOpen ? 'translateX(0)' : 'translateX(320px)',
|
transform: isRightPanelOpen ? 'translateX(0)' : 'translateX(110%)',
|
||||||
transition: 'transform 0.25s ease',
|
transition: 'transform 0.25s ease',
|
||||||
bgcolor: '#fff',
|
bgcolor: '#fff',
|
||||||
borderLeft: '1px solid #e2e8f0',
|
borderLeft: '1px solid #e2e8f0',
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ export function UserMenu() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<IconButton onClick={handleOpen} size="small" sx={{ p: 0 }}>
|
<IconButton onClick={handleOpen} size="small" sx={{ p: 0 }}>
|
||||||
<Avatar sx={{ width: 32, height: 32, fontSize: '0.75rem', bgcolor: '#1e3a5f' }}>
|
<Avatar sx={{ width: 32, height: 32, fontSize: '0.75rem', bgcolor: '#152642' }}>
|
||||||
{initials}
|
{initials}
|
||||||
</Avatar>
|
</Avatar>
|
||||||
</IconButton>
|
</IconButton>
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import type { LucideIcon } from 'lucide-react'
|
|||||||
import {
|
import {
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
Building2,
|
Building2,
|
||||||
Target,
|
|
||||||
CheckSquare,
|
CheckSquare,
|
||||||
Search,
|
Search,
|
||||||
List,
|
List,
|
||||||
@@ -13,7 +12,7 @@ import {
|
|||||||
MessageSquare,
|
MessageSquare,
|
||||||
Kanban,
|
Kanban,
|
||||||
BellRing,
|
BellRing,
|
||||||
Plus,
|
Settings,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -45,17 +44,15 @@ export const WORKSPACE_CONFIG: Record<WorkspaceType, WorkspaceConfig> = {
|
|||||||
abbreviation: 'VW',
|
abbreviation: 'VW',
|
||||||
icon: Building2,
|
icon: Building2,
|
||||||
firstPath: '/supply/dashboard',
|
firstPath: '/supply/dashboard',
|
||||||
chipColor: '#1e3a5f',
|
chipColor: '#152642',
|
||||||
navItems: [
|
navItems: [
|
||||||
{ path: '/supply/dashboard', label: 'Übersicht', icon: LayoutDashboard },
|
{ path: '/supply/dashboard', label: 'Übersicht', icon: LayoutDashboard },
|
||||||
{ path: '/supply/properties', label: 'Meine Objekte', icon: Building2 },
|
{ path: '/supply/properties', label: 'Meine Objekte', icon: Building2 },
|
||||||
{ path: '/supply/reminder-manager', label: 'Reminder Manager', icon: BellRing },
|
{ path: '/supply/reminder-manager', label: 'Reminder Manager', icon: BellRing },
|
||||||
{ path: '/supply/market-leads', label: 'Markt-Leads', icon: Target },
|
|
||||||
{ path: '/supply/anfragen', label: 'Anfragencenter', icon: MessageSquare },
|
{ path: '/supply/anfragen', label: 'Anfragencenter', icon: MessageSquare },
|
||||||
{ path: '/supply/data-quality', label: 'Datenpflege', icon: CheckSquare },
|
{ path: '/supply/data-quality', label: 'Datenpflege', icon: CheckSquare },
|
||||||
{ path: '/supply/market-intelligence', label: 'Markt Intelligence', icon: Radar },
|
{ path: '/supply/market-intelligence', label: 'Marktchancen', icon: Radar },
|
||||||
{ path: '/supply/my-listings', label: 'Meine Inserate', icon: ClipboardList },
|
{ path: '/supply/my-listings', label: 'Inserate', icon: ClipboardList },
|
||||||
{ path: '/supply/new-listing', label: 'Neues Inserat', icon: Plus },
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
[WorkspaceType.DEMAND]: {
|
[WorkspaceType.DEMAND]: {
|
||||||
@@ -72,6 +69,17 @@ export const WORKSPACE_CONFIG: Record<WorkspaceType, WorkspaceConfig> = {
|
|||||||
{ path: '/demand/anfragen', label: 'Anfragen', icon: MessageSquare },
|
{ path: '/demand/anfragen', label: 'Anfragen', icon: MessageSquare },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
[WorkspaceType.OPERATIONS]: {
|
||||||
|
label: 'Operations',
|
||||||
|
abbreviation: 'OP',
|
||||||
|
icon: Settings,
|
||||||
|
firstPath: '/ops/review-queue',
|
||||||
|
chipColor: '#6b21a8',
|
||||||
|
navItems: [
|
||||||
|
{ path: '/ops/review-queue', label: 'Review Queue', icon: CheckSquare },
|
||||||
|
{ path: '/ops/audit', label: 'Audit Log', icon: ClipboardList },
|
||||||
|
],
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ordered list for rendering workspace tabs
|
// Ordered list for rendering workspace tabs
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { memo } from 'react'
|
||||||
import { Box, Button, Divider, Typography } from '@mui/material'
|
import { Box, Button, Divider, Typography } from '@mui/material'
|
||||||
import { AlertCircle, CheckCircle2, ShieldCheck } from 'lucide-react'
|
import { AlertCircle, CheckCircle2, ShieldCheck } from 'lucide-react'
|
||||||
import { useNavigate } from 'react-router'
|
import { useNavigate } from 'react-router'
|
||||||
@@ -9,7 +10,7 @@ import type { MatchCardViewModel } from './MatchCardViewModel'
|
|||||||
|
|
||||||
// ── Future Availability Card ──────────────────────────────────────────────────
|
// ── Future Availability Card ──────────────────────────────────────────────────
|
||||||
|
|
||||||
export function FutureAvailabilityCard({ vm }: { vm: MatchCardViewModel }) {
|
export const FutureAvailabilityCard = memo(function FutureAvailabilityCard({ vm }: { vm: MatchCardViewModel }) {
|
||||||
const isControlled = vm.signalIsControlled ?? false
|
const isControlled = vm.signalIsControlled ?? false
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
|
||||||
@@ -31,7 +32,6 @@ export function FutureAvailabilityCard({ vm }: { vm: MatchCardViewModel }) {
|
|||||||
borderRadius: 2,
|
borderRadius: 2,
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
border: `1px solid ${borderColor}`,
|
border: `1px solid ${borderColor}`,
|
||||||
borderLeft: `3px solid ${accentColor}`,
|
|
||||||
background: 'white',
|
background: 'white',
|
||||||
display: 'flex', flexDirection: 'column',
|
display: 'flex', flexDirection: 'column',
|
||||||
boxShadow: '0 1px 8px rgba(0,0,0,0.06)',
|
boxShadow: '0 1px 8px rgba(0,0,0,0.06)',
|
||||||
@@ -243,4 +243,4 @@ export function FutureAvailabilityCard({ vm }: { vm: MatchCardViewModel }) {
|
|||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
}
|
})
|
||||||
|
|||||||
@@ -1,18 +1,15 @@
|
|||||||
import { Box, Button, Chip, Divider, Typography } from '@mui/material'
|
import { Box, Button, Chip, Typography } from '@mui/material'
|
||||||
import {
|
import { Building2, CheckCircle2, MapPin } from 'lucide-react'
|
||||||
Building2,
|
|
||||||
CheckCircle2,
|
|
||||||
} from 'lucide-react'
|
|
||||||
import { useSessionStore } from '../../stores/sessionStore'
|
import { useSessionStore } from '../../stores/sessionStore'
|
||||||
|
import { useInquiryStore } from '../../stores/inquiryStore'
|
||||||
import { LocationPreview } from '../shared/LocationPreview'
|
import { LocationPreview } from '../shared/LocationPreview'
|
||||||
import { HeatBadge } from '../shared/HeatBadge'
|
import { HeatBadge } from '../shared/HeatBadge'
|
||||||
import { getScoreTier, SCORE_THEME } from '../shared/scoreTheme'
|
import { getScoreTier, SCORE_THEME } from '../shared/scoreTheme'
|
||||||
import { RESULT_TYPE_META } from '../../lib/ds'
|
import { RESULT_TYPE_META, DS_TEXT, DS_BORDER } from '../../lib/ds'
|
||||||
|
import { confidenceHex } from '../../lib/utils'
|
||||||
import type { MatchCardViewModel } from './MatchCardViewModel'
|
import type { MatchCardViewModel } from './MatchCardViewModel'
|
||||||
import { FutureAvailabilityCard } from './FutureAvailabilityCard'
|
import { FutureAvailabilityCard } from './FutureAvailabilityCard'
|
||||||
|
|
||||||
// ── Main component ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
vm: MatchCardViewModel
|
vm: MatchCardViewModel
|
||||||
imageUrl?: string
|
imageUrl?: string
|
||||||
@@ -25,53 +22,82 @@ export function IntelligenceMatchCard({ vm, imageUrl, lat, lng, cityLabel }: Pro
|
|||||||
const tier = getScoreTier(vm.matchScore)
|
const tier = getScoreTier(vm.matchScore)
|
||||||
const theme = SCORE_THEME[tier]
|
const theme = SCORE_THEME[tier]
|
||||||
const { currentUser } = useSessionStore()
|
const { currentUser } = useSessionStore()
|
||||||
|
const openInquiryDialog = useInquiryStore(s => s.openInquiryDialog)
|
||||||
const isPortfolioOwner = currentUser?.role === 'PROPERTY_MANAGER' || currentUser?.role === 'ORGANIZATION_ADMIN'
|
const isPortfolioOwner = currentUser?.role === 'PROPERTY_MANAGER' || currentUser?.role === 'ORGANIZATION_ADMIN'
|
||||||
const rt = RESULT_TYPE_META[vm.resultType] ?? { label: vm.resultType, color: '#64748b' }
|
const rt = RESULT_TYPE_META[vm.resultType] ?? { label: vm.resultType, color: '#9a9a9a' }
|
||||||
const isFuture = vm.resultType === 'FUTURE_AVAILABILITY'
|
const isFuture = vm.resultType === 'FUTURE_AVAILABILITY'
|
||||||
|
const confPct = Math.round(vm.confidenceScore * 100)
|
||||||
|
|
||||||
// Future availability cards use the new design
|
|
||||||
if (isFuture) {
|
if (isFuture) {
|
||||||
return <FutureAvailabilityCard vm={vm} />
|
return <FutureAvailabilityCard vm={vm} />
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// secondary chips — max 1 visible (availability only), no overflow chip
|
||||||
|
const secondaryChip = vm.availabilityLabel ?? vm.fitOutLabel ?? null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{
|
<Box sx={{
|
||||||
borderRadius: 2,
|
borderRadius: 2,
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
boxShadow: `0 2px 16px rgba(0,0,0,0.07), 0 0 0 1px ${theme.cardBorder}`,
|
border: `1px solid ${DS_BORDER.default}`,
|
||||||
background: theme.cardBg,
|
bgcolor: '#ffffff',
|
||||||
|
boxShadow: '0 1px 3px rgba(0,0,0,0.06)',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
transition: 'box-shadow 0.15s, transform 0.1s',
|
position: 'relative',
|
||||||
|
zIndex: 0,
|
||||||
|
transition: 'transform 0.18s ease, box-shadow 0.18s ease, border-color 0.15s',
|
||||||
'&:hover': {
|
'&:hover': {
|
||||||
boxShadow: `0 6px 28px rgba(0,0,0,0.12), 0 0 0 1px ${theme.cardBorder}`,
|
borderColor: '#b0aead',
|
||||||
transform: 'translateY(-1px)',
|
transform: 'scale(1.025)',
|
||||||
|
boxShadow: '0 10px 32px rgba(0,0,0,0.13)',
|
||||||
|
zIndex: 1,
|
||||||
},
|
},
|
||||||
}}>
|
}}>
|
||||||
|
|
||||||
{/* ── Hero zone ── */}
|
{/* ── Hero zone ── */}
|
||||||
<Box sx={{ position: 'relative' }}>
|
<Box sx={{ position: 'relative' }}>
|
||||||
<LocationPreview imageUrl={imageUrl} lat={lat} lng={lng} cityLabel={cityLabel} height={175} />
|
<LocationPreview imageUrl={imageUrl} lat={lat} lng={lng} cityLabel={cityLabel} aspectRatio="15/8" />
|
||||||
|
|
||||||
|
{/* Score badge — bottom-left editorial */}
|
||||||
<Box sx={{
|
<Box sx={{
|
||||||
position: 'absolute', top: 10, left: 10,
|
position: 'absolute', bottom: 10, left: 12,
|
||||||
background: theme.gradient, borderRadius: '10px',
|
bgcolor: theme.gradient, borderRadius: '8px',
|
||||||
px: 1.5, py: 0.5,
|
px: 1.25, py: 0.375,
|
||||||
boxShadow: `0 4px 16px ${theme.glow}`,
|
|
||||||
border: `1px solid ${theme.border}`,
|
border: `1px solid ${theme.border}`,
|
||||||
minWidth: 52, textAlign: 'center',
|
|
||||||
}}>
|
}}>
|
||||||
<Typography sx={{ fontWeight: 900, fontSize: '1.5rem', color: theme.text, lineHeight: 1 }}>
|
<Typography sx={{
|
||||||
|
fontWeight: 700, fontSize: '1.625rem',
|
||||||
|
color: '#ffffff', lineHeight: 1, letterSpacing: '-0.02em',
|
||||||
|
}}>
|
||||||
{vm.matchScore}%
|
{vm.matchScore}%
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
<Box sx={{ position: 'absolute', top: 10, right: 44, display: 'flex', flexDirection: 'column', gap: 0.5, alignItems: 'flex-end' }}>
|
|
||||||
|
{/* 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 }} />
|
<Chip label={rt.label} size="small" sx={{ bgcolor: rt.color, color: 'white', fontWeight: 600, fontSize: 10, height: 20 }} />
|
||||||
{vm.resultType === 'VERIFIED_PORTFOLIO' && isPortfolioOwner && (
|
{vm.resultType === 'VERIFIED_PORTFOLIO' && isPortfolioOwner && (
|
||||||
<Chip
|
<Chip
|
||||||
icon={<Building2 size={9} color="#1e3a5f" />}
|
icon={<Building2 size={9} color="#152642" />}
|
||||||
label="Ihr Objekt"
|
label="Ihr Objekt"
|
||||||
size="small"
|
size="small"
|
||||||
sx={{ bgcolor: 'rgba(30,58,95,0.85)', color: 'white', fontWeight: 700, fontSize: 9, height: 18, '& .MuiChip-icon': { ml: 0.5 } }}
|
sx={{ bgcolor: 'rgba(21,38,66,0.85)', color: 'white', fontWeight: 700, fontSize: 9, height: 18, '& .MuiChip-icon': { ml: 0.5 } }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<HeatBadge propertyId={vm.propertyId} size="sm" />
|
<HeatBadge propertyId={vm.propertyId} size="sm" />
|
||||||
@@ -79,24 +105,24 @@ export function IntelligenceMatchCard({ vm, imageUrl, lat, lng, cityLabel }: Pro
|
|||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* ── Card body ── */}
|
{/* ── Card body ── */}
|
||||||
<Box sx={{ p: 2, flex: 1, display: 'flex', flexDirection: 'column', gap: 0 }}>
|
<Box sx={{ p: 2.25, flex: 1, display: 'flex', flexDirection: 'column', gap: 0 }}>
|
||||||
{/* Unit-first title (floor + street) */}
|
|
||||||
<Typography variant="subtitle1" sx={{ fontWeight: 700, lineHeight: 1.3 }} noWrap>
|
{/* Title */}
|
||||||
|
<Typography variant="subtitle1" sx={{ fontWeight: 700, lineHeight: 1.3, color: DS_TEXT.primary }} noWrap>
|
||||||
{vm.title}
|
{vm.title}
|
||||||
</Typography>
|
</Typography>
|
||||||
{/* Building name as subtitle when a unit or floor range is in the title */}
|
|
||||||
{vm.propertySubtitle && (
|
{vm.propertySubtitle && (
|
||||||
<Typography variant="caption" sx={{ display: 'block', mt: 0.125, color: '#475569', fontWeight: 500 }} noWrap>
|
<Typography variant="caption" sx={{ display: 'block', mt: 0.125, color: DS_TEXT.secondary, fontWeight: 500 }} noWrap>
|
||||||
{vm.propertySubtitle}
|
{vm.propertySubtitle}
|
||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 0.25 }}>
|
<Typography variant="caption" sx={{ color: DS_TEXT.muted, display: 'block', mt: 0.25 }}>
|
||||||
{[vm.locationLabel, vm.availabilityLabel].filter(Boolean).join(' · ')}
|
{[vm.locationLabel, vm.availabilityLabel].filter(Boolean).join(' · ')}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
{/* Unit area + rent when a specific unit is matched */}
|
{/* Unit details */}
|
||||||
{vm.preMarketUnit && (
|
{vm.preMarketUnit && (
|
||||||
<Typography variant="caption" sx={{ color: '#64748b', fontSize: '0.68rem', mt: 0.25 }}>
|
<Typography variant="caption" sx={{ color: DS_TEXT.muted, fontSize: '0.68rem', mt: 0.25 }}>
|
||||||
{vm.preMarketUnit.areaSqm.toLocaleString('de-CH')} m²
|
{vm.preMarketUnit.areaSqm.toLocaleString('de-CH')} m²
|
||||||
{vm.preMarketUnit.rentPricePerSqm
|
{vm.preMarketUnit.rentPricePerSqm
|
||||||
? ` · CHF ${Math.round(vm.preMarketUnit.rentPricePerSqm / 12).toLocaleString('de-CH')}/m²/Mt.`
|
? ` · CHF ${Math.round(vm.preMarketUnit.rentPricePerSqm / 12).toLocaleString('de-CH')}/m²/Mt.`
|
||||||
@@ -104,43 +130,64 @@ export function IntelligenceMatchCard({ vm, imageUrl, lat, lng, cityLabel }: Pro
|
|||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Regular explainability summary */}
|
{/* 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 }} />
|
||||||
|
{secondaryChip && (
|
||||||
|
<Chip label={secondaryChip} size="small" variant="outlined"
|
||||||
|
sx={{ fontSize: 10, height: 20, borderColor: DS_BORDER.default, color: DS_TEXT.secondary }} />
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Explainability */}
|
||||||
{vm.explainabilitySummary && (
|
{vm.explainabilitySummary && (
|
||||||
<>
|
|
||||||
<Divider sx={{ my: 1.25 }} />
|
|
||||||
<Typography variant="body2" sx={{
|
<Typography variant="body2" sx={{
|
||||||
fontWeight: 500, color: '#1e293b', lineHeight: 1.5, flex: 1,
|
fontWeight: 500, color: DS_TEXT.secondary, lineHeight: 1.5, flex: 1, mt: 1,
|
||||||
overflow: 'hidden', display: '-webkit-box',
|
overflow: 'hidden', display: '-webkit-box',
|
||||||
WebkitLineClamp: 3, WebkitBoxOrient: 'vertical',
|
WebkitLineClamp: 3, WebkitBoxOrient: 'vertical',
|
||||||
}}>
|
}}>
|
||||||
{vm.explainabilitySummary}
|
{vm.explainabilitySummary}
|
||||||
</Typography>
|
</Typography>
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{vm.reasons.length > 0 && (
|
{vm.reasons.length > 0 && (
|
||||||
<>
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5, mt: 1 }}>
|
||||||
<Divider sx={{ my: 1.25 }} />
|
{vm.reasons.slice(0, 2).map((r, i) => (
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.625 }}>
|
|
||||||
{vm.reasons.slice(0, 3).map((r, i) => (
|
|
||||||
<Box key={i} sx={{ display: 'flex', alignItems: 'flex-start', gap: 0.75 }}>
|
<Box key={i} sx={{ display: 'flex', alignItems: 'flex-start', gap: 0.75 }}>
|
||||||
<CheckCircle2 size={13} color="#1a7a4a" style={{ marginTop: 2, flexShrink: 0 }} />
|
<CheckCircle2 size={13} color="#1d6342" style={{ marginTop: 2, flexShrink: 0 }} />
|
||||||
<Box>
|
<Box>
|
||||||
<Typography sx={{ fontSize: '0.75rem', fontWeight: 600, color: '#1e293b', lineHeight: 1.3 }}>
|
<Typography sx={{ fontSize: '0.75rem', fontWeight: 600, color: DS_TEXT.primary, lineHeight: 1.3 }}>
|
||||||
{r.label}
|
{r.label}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography sx={{ fontSize: '0.72rem', color: '#64748b', lineHeight: 1.3 }}>
|
<Typography sx={{ fontSize: '0.72rem', color: DS_TEXT.muted, lineHeight: 1.3 }}>
|
||||||
{r.explanation}
|
{r.explanation}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
))}
|
))}
|
||||||
</Box>
|
</Box>
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<Box sx={{ display: 'flex', gap: 0.75, mt: 1.5, pt: 1.25, borderTop: '1px solid rgba(0,0,0,0.06)' }}>
|
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.75, mt: 1.5, pt: 1.25, borderTop: `1px solid ${DS_BORDER.muted}` }}>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="contained"
|
||||||
|
onClick={e => {
|
||||||
|
e.stopPropagation()
|
||||||
|
openInquiryDialog({
|
||||||
|
propertyTitle: vm.title,
|
||||||
|
location: vm.locationLabel ?? '',
|
||||||
|
matchScore: vm.matchScore,
|
||||||
|
matchId: vm.id,
|
||||||
|
propertyId: vm.propertyId,
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
sx={{ textTransform: 'none', fontSize: '0.7rem', py: 0.375, px: 1, bgcolor: '#152642', '&:hover': { bgcolor: '#0e1c30' } }}
|
||||||
|
>
|
||||||
|
Anfrage
|
||||||
|
</Button>
|
||||||
{vm.actions.map(a => (
|
{vm.actions.map(a => (
|
||||||
<Button
|
<Button
|
||||||
key={a.id}
|
key={a.id}
|
||||||
@@ -148,9 +195,7 @@ export function IntelligenceMatchCard({ vm, imageUrl, lat, lng, cityLabel }: Pro
|
|||||||
variant={a.variant === 'primary' ? 'contained' : 'outlined'}
|
variant={a.variant === 'primary' ? 'contained' : 'outlined'}
|
||||||
onClick={a.onClick}
|
onClick={a.onClick}
|
||||||
disabled={a.disabled}
|
disabled={a.disabled}
|
||||||
sx={{
|
sx={{ textTransform: 'none', fontSize: '0.7rem', py: 0.375, px: 1 }}
|
||||||
textTransform: 'none', fontSize: '0.7rem', py: 0.375, px: 1,
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{a.label}
|
{a.label}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export function MatchActionToolbar({ actions }: Props) {
|
|||||||
onClick={action.onClick}
|
onClick={action.onClick}
|
||||||
sx={
|
sx={
|
||||||
action.variant === 'primary'
|
action.variant === 'primary'
|
||||||
? { bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#162d4a' } }
|
? { bgcolor: '#152642', '&:hover': { bgcolor: '#162d4a' } }
|
||||||
: action.variant === 'danger'
|
: action.variant === 'danger'
|
||||||
? { color: '#c0392b', borderColor: '#c0392b', '&:hover': { borderColor: '#c0392b' } }
|
? { color: '#c0392b', borderColor: '#c0392b', '&:hover': { borderColor: '#c0392b' } }
|
||||||
: {}
|
: {}
|
||||||
|
|||||||
@@ -1,119 +1,202 @@
|
|||||||
import { Alert, Box, Card, Divider, Typography } from '@mui/material'
|
import { memo } from 'react'
|
||||||
import { MapPin } from 'lucide-react'
|
import { Alert, Box, Button, Chip, Typography } from '@mui/material'
|
||||||
import { MatchCardHeader } from './MatchCardHeader'
|
import { MapPin, AlertTriangle } from 'lucide-react'
|
||||||
import { MatchReasonList } from './MatchReasonList'
|
import { useSessionStore } from '../../stores/sessionStore'
|
||||||
import { TradeoffList } from './TradeoffList'
|
import { useInquiryStore } from '../../stores/inquiryStore'
|
||||||
import { MatchDataQualitySummary } from './MatchDataQualitySummary'
|
import { HeatBadge } from '../shared/HeatBadge'
|
||||||
import { MatchActionToolbar } from './MatchActionToolbar'
|
import { getScoreTier, SCORE_THEME } from '../shared/scoreTheme'
|
||||||
|
import { RESULT_TYPE_META, DS_TEXT, DS_BORDER } from '../../lib/ds'
|
||||||
|
import { confidenceHex } from '../../lib/utils'
|
||||||
import { MatchCardRestrictedState } from './MatchCardRestrictedState'
|
import { MatchCardRestrictedState } from './MatchCardRestrictedState'
|
||||||
import { ScoreInlineBreakdown } from './ScoreInlineBreakdown'
|
|
||||||
import type { MatchCardViewModel } from './MatchCardViewModel'
|
import type { MatchCardViewModel } from './MatchCardViewModel'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
vm: MatchCardViewModel
|
vm: MatchCardViewModel
|
||||||
|
imageUrl?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MatchCardCompact({ vm }: Props) {
|
export const MatchCardCompact = memo(function MatchCardCompact({ vm, imageUrl }: Props) {
|
||||||
if (vm.isRestricted) return <MatchCardRestrictedState />
|
if (vm.isRestricted) return <MatchCardRestrictedState />
|
||||||
|
|
||||||
const borderColor = vm.isCompareSelected
|
const { currentUser } = useSessionStore()
|
||||||
? '#7c3aed'
|
const openInquiryDialog = useInquiryStore(s => s.openInquiryDialog)
|
||||||
: vm.isSelected
|
const isPortfolioOwner = currentUser?.role === 'PROPERTY_MANAGER' || currentUser?.role === 'ORGANIZATION_ADMIN'
|
||||||
? '#1e3a5f'
|
|
||||||
: 'transparent'
|
const tier = getScoreTier(vm.matchScore)
|
||||||
|
const scoreBadgeBg = SCORE_THEME[tier].gradient
|
||||||
|
const rt = RESULT_TYPE_META[vm.resultType] ?? { label: vm.resultType, color: '#9a9a9a' }
|
||||||
|
const confPct = Math.round(vm.confidenceScore * 100)
|
||||||
|
const topRisk = vm.risks.find(r => r.level === 'CRITICAL' || r.level === 'HIGH')
|
||||||
|
|
||||||
|
const leftAccent = vm.isCompareSelected ? '#5b3f8a' : vm.isSelected ? '#152642' : 'transparent'
|
||||||
|
|
||||||
|
// 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 (
|
return (
|
||||||
<Card
|
<Box sx={{
|
||||||
sx={{
|
display: 'flex',
|
||||||
p: 2.5,
|
borderRadius: 2,
|
||||||
mb: 1.5,
|
overflow: 'hidden',
|
||||||
border: `2px solid ${borderColor}`,
|
border: `1px solid ${DS_BORDER.default}`,
|
||||||
|
borderLeft: `3px solid ${leftAccent}`,
|
||||||
|
bgcolor: '#ffffff',
|
||||||
|
boxShadow: '0 1px 3px rgba(0,0,0,0.06)',
|
||||||
opacity: vm.isStaleData ? 0.75 : 1,
|
opacity: vm.isStaleData ? 0.75 : 1,
|
||||||
transition: 'border-color 0.15s',
|
transition: 'border-color 0.15s, transform 0.18s ease, box-shadow 0.18s ease',
|
||||||
}}
|
position: 'relative',
|
||||||
>
|
zIndex: 0,
|
||||||
{/* FUTURE_AVAILABILITY disclaimer — mandatory, non-dismissable */}
|
'&:hover': {
|
||||||
{vm.disclaimer && (
|
borderColor: '#b0aead',
|
||||||
<Alert severity="warning" sx={{ mb: 1.5, py: 0.5 }}>
|
transform: 'scale(1.012)',
|
||||||
{vm.disclaimer}
|
boxShadow: '0 6px 20px rgba(0,0,0,0.10)',
|
||||||
</Alert>
|
zIndex: 1,
|
||||||
|
},
|
||||||
|
}}>
|
||||||
|
|
||||||
|
{/* ── Image strip — 180px, Ginesta proportion ── */}
|
||||||
|
<Box sx={{ width: 180, flexShrink: 0, position: 'relative', bgcolor: '#f4f3f0' }}>
|
||||||
|
{imageUrl ? (
|
||||||
|
<img
|
||||||
|
src={imageUrl}
|
||||||
|
alt=""
|
||||||
|
style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Box sx={{
|
||||||
|
width: '100%', height: '100%', minHeight: 140,
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
bgcolor: '#f4f3f0',
|
||||||
|
}}>
|
||||||
|
<MapPin size={24} color="#c4c4c4" />
|
||||||
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{vm.isReviewRequired && (
|
{/* Score badge — top left */}
|
||||||
<Alert severity="info" sx={{ mb: 1.5, py: 0.5 }}>
|
<Box sx={{
|
||||||
Manuelle Überprüfung erforderlich
|
position: 'absolute', top: 8, left: 8,
|
||||||
</Alert>
|
bgcolor: scoreBadgeBg, borderRadius: '6px',
|
||||||
)}
|
px: 0.875, py: 0.25,
|
||||||
|
border: `1px solid ${SCORE_THEME[tier].border}`,
|
||||||
{/* Header: score → type → confidence → availability → risk */}
|
}}>
|
||||||
<MatchCardHeader vm={vm} compact />
|
<Typography sx={{
|
||||||
|
fontWeight: 700, fontSize: '1.125rem',
|
||||||
{/* Title + location (max 2 lines) */}
|
color: '#ffffff', lineHeight: 1, letterSpacing: '-0.01em',
|
||||||
<Box sx={{ mt: 1.25, mb: 1.25 }}>
|
}}>
|
||||||
<Typography variant="subtitle1" sx={{ fontWeight: 600 }} noWrap>
|
{vm.matchScore}%
|
||||||
{vm.title}
|
|
||||||
</Typography>
|
</Typography>
|
||||||
{vm.propertySubtitle && (
|
</Box>
|
||||||
<Typography variant="caption" sx={{ display: 'block', color: '#64748b', fontWeight: 500, mt: 0.125 }} noWrap>
|
|
||||||
{vm.propertySubtitle}
|
{/* Location pill — Ginesta-style, top right */}
|
||||||
</Typography>
|
<Box sx={{
|
||||||
)}
|
position: 'absolute', top: 8, right: 8,
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mt: 0.25 }}>
|
bgcolor: 'rgba(255,255,255,0.92)',
|
||||||
<MapPin size={13} color="#64748b" />
|
borderRadius: '20px',
|
||||||
<Typography variant="body2" color="text.secondary">
|
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}
|
{vm.locationLabel}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* ── Content ── */}
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 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 }} />
|
||||||
|
<HeatBadge propertyId={vm.propertyId} size="sm" />
|
||||||
|
<Chip label={`${confPct}% Konfidenz`} size="small"
|
||||||
|
sx={{ bgcolor: confidenceHex(vm.confidenceScore), color: 'white', fontSize: 10, height: 20 }} />
|
||||||
|
{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 */}
|
||||||
|
<Typography variant="subtitle2" sx={{ fontWeight: 700, lineHeight: 1.3, color: DS_TEXT.primary }} noWrap>
|
||||||
|
{vm.title}
|
||||||
|
</Typography>
|
||||||
|
{vm.propertySubtitle && (
|
||||||
|
<Typography variant="caption" sx={{ color: DS_TEXT.secondary, fontWeight: 500, display: 'block', mt: 0.1 }} noWrap>
|
||||||
|
{vm.propertySubtitle}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Metric line — Ginesta-style */}
|
||||||
|
{metricLine && (
|
||||||
|
<Typography sx={{ fontSize: '0.75rem', color: '#94a3b8', mt: 0.5, letterSpacing: 0.1 }}>
|
||||||
|
{metricLine}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Explainability summary — 1 line */}
|
||||||
{vm.explainabilitySummary && (
|
{vm.explainabilitySummary && (
|
||||||
<Typography
|
<Typography variant="body2" sx={{
|
||||||
variant="body2"
|
color: DS_TEXT.secondary, fontWeight: 500, mt: 0.875, lineHeight: 1.5,
|
||||||
color="text.secondary"
|
overflow: 'hidden', display: '-webkit-box', WebkitLineClamp: 1, WebkitBoxOrient: 'vertical',
|
||||||
sx={{
|
}}>
|
||||||
mt: 0.5,
|
|
||||||
overflow: 'hidden',
|
|
||||||
display: '-webkit-box',
|
|
||||||
WebkitLineClamp: 2,
|
|
||||||
WebkitBoxOrient: 'vertical',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{vm.explainabilitySummary}
|
{vm.explainabilitySummary}
|
||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Divider sx={{ mb: 1.25 }} />
|
{/* Critical risk only */}
|
||||||
|
{topRisk && topRisk.level === 'CRITICAL' && (
|
||||||
{/* Score formula — always visible */}
|
<Alert icon={<AlertTriangle size={13} />} severity="error"
|
||||||
{vm.scoreBreakdown && (
|
sx={{ mt: 0.75, py: 0.25, fontSize: '0.72rem' }}>
|
||||||
<Box sx={{ mb: 1.25 }}>
|
{topRisk.description}
|
||||||
<ScoreInlineBreakdown scoreBreakdown={vm.scoreBreakdown} allFactors={vm.allFactors} compact />
|
</Alert>
|
||||||
</Box>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Top reason only */}
|
{/* 2 Buttons — Anfrage + Details */}
|
||||||
{vm.reasons.length > 0 && (
|
<Box sx={{ display: 'flex', gap: 0.625, mt: 'auto', pt: 1.25 }}>
|
||||||
<Box sx={{ mb: 1 }}>
|
<Button
|
||||||
<MatchReasonList reasons={vm.reasons.slice(0, 1)} />
|
size="small"
|
||||||
</Box>
|
variant="contained"
|
||||||
|
onClick={e => {
|
||||||
|
e.stopPropagation()
|
||||||
|
openInquiryDialog({
|
||||||
|
propertyTitle: vm.title,
|
||||||
|
location: vm.locationLabel ?? '',
|
||||||
|
matchScore: vm.matchScore,
|
||||||
|
matchId: vm.id,
|
||||||
|
propertyId: vm.propertyId,
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
sx={{ textTransform: 'none', fontSize: '0.7rem', py: 0.375, px: 1, bgcolor: '#152642', '&:hover': { bgcolor: '#0e1c30' } }}
|
||||||
|
>
|
||||||
|
Anfrage
|
||||||
|
</Button>
|
||||||
|
{detailAction && (
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
onClick={detailAction.onClick}
|
||||||
|
sx={{ textTransform: 'none', fontSize: '0.7rem', py: 0.375, px: 1 }}
|
||||||
|
>
|
||||||
|
Details →
|
||||||
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Top tradeoff only (compact) */}
|
|
||||||
{vm.tradeoffs.length > 0 && (
|
|
||||||
<Box sx={{ mb: 1 }}>
|
|
||||||
<TradeoffList tradeoffs={vm.tradeoffs.slice(0, 1)} compact />
|
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Data quality — only critical warning in compact mode */}
|
|
||||||
<MatchDataQualitySummary
|
|
||||||
dataQualityScore={vm.dataQualityScore}
|
|
||||||
missingData={vm.missingData}
|
|
||||||
compact
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Box sx={{ mt: 1.25 }}>
|
|
||||||
<MatchActionToolbar actions={vm.actions} compact />
|
|
||||||
</Box>
|
</Box>
|
||||||
</Card>
|
</Box>
|
||||||
)
|
)
|
||||||
}
|
})
|
||||||
|
|||||||
@@ -4,8 +4,7 @@ import { MatchScoreDisplay } from './MatchScoreDisplay'
|
|||||||
import type { MatchCardViewModel } from './MatchCardViewModel'
|
import type { MatchCardViewModel } from './MatchCardViewModel'
|
||||||
|
|
||||||
const RESULT_TYPE_META: Record<string, { label: string; color: string }> = {
|
const RESULT_TYPE_META: Record<string, { label: string; color: string }> = {
|
||||||
VERIFIED_PORTFOLIO: { label: 'Verified', color: '#1e3a5f' },
|
VERIFIED_PORTFOLIO: { label: 'Verified', color: '#152642' },
|
||||||
EXTERNAL_MARKET: { label: 'Direkt', color: '#d97706' },
|
|
||||||
MAISON_WORK: { label: 'Maison Work', color: '#0369a1' },
|
MAISON_WORK: { label: 'Maison Work', color: '#0369a1' },
|
||||||
FUTURE_AVAILABILITY: { label: 'Signal', color: '#7c3aed' },
|
FUTURE_AVAILABILITY: { label: 'Signal', color: '#7c3aed' },
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,10 +38,10 @@ export function MatchCardHeader({ vm, compact }: Props) {
|
|||||||
/>
|
/>
|
||||||
{vm.resultType === 'VERIFIED_PORTFOLIO' && isPortfolioOwner && (
|
{vm.resultType === 'VERIFIED_PORTFOLIO' && isPortfolioOwner && (
|
||||||
<Chip
|
<Chip
|
||||||
icon={<Building2 size={10} color="#1e3a5f" />}
|
icon={<Building2 size={10} color="#152642" />}
|
||||||
label="Ihr Objekt"
|
label="Ihr Objekt"
|
||||||
size="small"
|
size="small"
|
||||||
sx={{ bgcolor: 'rgba(30,58,95,0.12)', color: '#1e3a5f', fontWeight: 700, fontSize: 11, '& .MuiChip-icon': { ml: 0.5 } }}
|
sx={{ bgcolor: 'rgba(30,58,95,0.12)', color: '#152642', fontWeight: 700, fontSize: 11, '& .MuiChip-icon': { ml: 0.5 } }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{vm.assetType && (
|
{vm.assetType && (
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { ResultType } from '../../domain/enums'
|
import type { ResultType } from '../../domain/enums'
|
||||||
import type { TradeOff, Risk, MissingDataItem, ScoreFactor } from '../../domain/match'
|
import type { TradeOff, Risk, MissingDataItem, ScoreFactor } from '../../domain/match'
|
||||||
import type { PropertyUnit } from '../../domain/property'
|
import type { PropertyUnit } from '../../domain/property'
|
||||||
|
import type { FitOutInvestment } from '../../lib/fitOutUtils'
|
||||||
|
|
||||||
export type MatchCardVariant = 'compact' | 'expanded' | 'review' | 'compare-mini'
|
export type MatchCardVariant = 'compact' | 'expanded' | 'review' | 'compare-mini'
|
||||||
|
|
||||||
@@ -67,6 +68,15 @@ export interface MatchCardViewModel {
|
|||||||
signalIsControlled?: boolean
|
signalIsControlled?: boolean
|
||||||
signalAreaSqmEstimate?: number
|
signalAreaSqmEstimate?: number
|
||||||
|
|
||||||
|
// Divisibility (Teilbarkeit)
|
||||||
|
isDivisible?: boolean
|
||||||
|
minDivisibleUnitSqm?: number
|
||||||
|
|
||||||
|
// Fit-out signal
|
||||||
|
fitOutLabel?: string // e.g. "FULL — bezugsfertig", "BASIC + CHF 200 MAB"
|
||||||
|
fitOutViable?: boolean // true when budget bridges the gap
|
||||||
|
fitOutInvestment?: FitOutInvestment
|
||||||
|
|
||||||
// Unit-first display (primary)
|
// Unit-first display (primary)
|
||||||
propertySubtitle?: string // building name shown below the unit title
|
propertySubtitle?: string // building name shown below the unit title
|
||||||
|
|
||||||
@@ -76,6 +86,10 @@ export interface MatchCardViewModel {
|
|||||||
preMarketUnit?: PropertyUnit // specific unit being released pre-market
|
preMarketUnit?: PropertyUnit // specific unit being released pre-market
|
||||||
preMarketAllUnits?: PropertyUnit[] // all units of the backing property
|
preMarketAllUnits?: PropertyUnit[] // all units of the backing property
|
||||||
|
|
||||||
|
// Key metrics for compact display (Ginesta-style metric line)
|
||||||
|
areaSqm?: number
|
||||||
|
rentPerSqm?: number
|
||||||
|
|
||||||
// Score transparency
|
// Score transparency
|
||||||
scoreBreakdown?: {
|
scoreBreakdown?: {
|
||||||
hardMatchScore: number
|
hardMatchScore: number
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ const HARD_KEYS = new Set(['area', 'location', 'budget', 'timing'])
|
|||||||
|
|
||||||
function importanceLabel(weight: number, maxWeight: number): { label: string; color: string } {
|
function importanceLabel(weight: number, maxWeight: number): { label: string; color: string } {
|
||||||
const ratio = maxWeight > 0 ? weight / maxWeight : 0
|
const ratio = maxWeight > 0 ? weight / maxWeight : 0
|
||||||
if (ratio >= 0.85) return { label: 'Entscheidend', color: '#1e3a5f' }
|
if (ratio >= 0.85) return { label: 'Entscheidend', color: '#152642' }
|
||||||
if (ratio >= 0.65) return { label: 'Sehr wichtig', color: '#1d4ed8' }
|
if (ratio >= 0.65) return { label: 'Sehr wichtig', color: '#1d4ed8' }
|
||||||
if (ratio >= 0.40) return { label: 'Wichtig', color: '#475569' }
|
if (ratio >= 0.40) return { label: 'Wichtig', color: '#475569' }
|
||||||
if (ratio >= 0.20) return { label: 'Wenig wichtig',color: '#94a3b8' }
|
if (ratio >= 0.20) return { label: 'Wenig wichtig',color: '#94a3b8' }
|
||||||
@@ -93,15 +93,15 @@ export function ScoreInlineBreakdown({ scoreBreakdown: sb, allFactors, compact =
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Box>
|
<Box>
|
||||||
<Typography variant="caption" sx={{ fontWeight: 700, color: '#1e3a5f', textTransform: 'uppercase', letterSpacing: 0.5, display: 'block', mb: 1 }}>
|
<Typography variant="caption" sx={{ fontWeight: 700, color: '#152642', textTransform: 'uppercase', letterSpacing: 0.5, display: 'block', mb: 1 }}>
|
||||||
Hart-Kriterien
|
Hart-Kriterien
|
||||||
</Typography>
|
</Typography>
|
||||||
{hardFactors.map((f, i) => <FactorRow key={i} factor={f} maxWeight={maxHardWeight} />)}
|
{hardFactors.map((f, i) => <FactorRow key={i} factor={f} maxWeight={maxHardWeight} />)}
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', bgcolor: '#eef2ff', px: 1.25, py: 0.6, borderRadius: 1, mb: softFactors.length > 0 ? 2 : 1.5 }}>
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', bgcolor: '#eef2ff', px: 1.25, py: 0.6, borderRadius: 1, mb: softFactors.length > 0 ? 2 : 1.5 }}>
|
||||||
<Typography variant="caption" sx={{ fontWeight: 600, color: '#1e3a5f' }}>
|
<Typography variant="caption" sx={{ fontWeight: 600, color: '#152642' }}>
|
||||||
{sb.hardMatchScore}/100 × 60%
|
{sb.hardMatchScore}/100 × 60%
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="caption" sx={{ fontWeight: 800, color: '#1e3a5f' }}>{hardContrib} Pkt</Typography>
|
<Typography variant="caption" sx={{ fontWeight: 800, color: '#152642' }}>{hardContrib} Pkt</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{softFactors.length > 0 && (
|
{softFactors.length > 0 && (
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export function SignalQualityDots({ quality }: { quality: 'HIGH' | 'MEDIUM' | 'L
|
|||||||
return (
|
return (
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||||
{c.dots.map((filled, i) => (
|
{c.dots.map((filled, i) => (
|
||||||
<Box key={i} sx={{ width: 7, height: 7, borderRadius: '50%', bgcolor: filled ? c.color : '#e2e8f0' }} />
|
<Box key={i} sx={{ width: 7, height: 7, borderRadius: '50%', bgcolor: filled ? c.color : '#e8e7e4' }} />
|
||||||
))}
|
))}
|
||||||
<Typography sx={{ fontSize: '0.68rem', color: c.color, fontWeight: 600, ml: 0.25 }}>
|
<Typography sx={{ fontSize: '0.68rem', color: c.color, fontWeight: 600, ml: 0.25 }}>
|
||||||
{c.label}
|
{c.label}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export { MatchDataQualitySummary } from './MatchDataQualitySummary'
|
|||||||
export { MatchActionToolbar } from './MatchActionToolbar'
|
export { MatchActionToolbar } from './MatchActionToolbar'
|
||||||
export { MatchCardSkeleton } from './MatchCardSkeleton'
|
export { MatchCardSkeleton } from './MatchCardSkeleton'
|
||||||
export { MatchCardRestrictedState } from './MatchCardRestrictedState'
|
export { MatchCardRestrictedState } from './MatchCardRestrictedState'
|
||||||
|
export { FutureAvailabilityCard } from './FutureAvailabilityCard'
|
||||||
export type {
|
export type {
|
||||||
MatchCardViewModel,
|
MatchCardViewModel,
|
||||||
MatchCardVariant,
|
MatchCardVariant,
|
||||||
|
|||||||
@@ -70,9 +70,12 @@ export function MatchListCard({ match, property, need, onSelect, onApprove }: Pr
|
|||||||
<Box sx={{ display: 'flex', gap: 0.75, mt: 0.5, flexWrap: 'wrap' }}>
|
<Box sx={{ display: 'flex', gap: 0.75, mt: 0.5, flexWrap: 'wrap' }}>
|
||||||
{need && (
|
{need && (
|
||||||
<Chip
|
<Chip
|
||||||
label={need.companyName}
|
label={need.isAnonymous ? 'Anonyme Anfrage' : need.companyName}
|
||||||
size="small"
|
size="small"
|
||||||
sx={{ bgcolor: '#eff6ff', color: '#1e3a5f', fontSize: 11, height: 20, fontWeight: 500 }}
|
sx={need.isAnonymous
|
||||||
|
? { bgcolor: '#f5f3ff', color: '#6d28d9', fontSize: 11, height: 20, fontWeight: 600, border: '1px solid #ddd6fe' }
|
||||||
|
: { bgcolor: '#eff6ff', color: '#152642', fontSize: 11, height: 20, fontWeight: 500 }
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<Chip
|
<Chip
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ const STATUS_META: Record<string, { label: string; color: string }> = {
|
|||||||
PENDING_REVIEW: { label: 'Ausstehend', color: '#d97706' },
|
PENDING_REVIEW: { label: 'Ausstehend', color: '#d97706' },
|
||||||
APPROVED: { label: 'Genehmigt', color: '#1a7a4a' },
|
APPROVED: { label: 'Genehmigt', color: '#1a7a4a' },
|
||||||
REJECTED: { label: 'Abgelehnt', color: '#c0392b' },
|
REJECTED: { label: 'Abgelehnt', color: '#c0392b' },
|
||||||
SHORTLISTED: { label: 'Shortlist', color: '#1e3a5f' },
|
SHORTLISTED: { label: 'Shortlist', color: '#152642' },
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MatchStatusBadge({ status }: { status?: MatchStatus }) {
|
export function MatchStatusBadge({ status }: { status?: MatchStatus }) {
|
||||||
|
|||||||
@@ -32,9 +32,14 @@ export function NeedSelectionPanel({ matches }: Props) {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', mb: 0.5 }}>
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', mb: 0.5 }}>
|
||||||
<Typography variant="body2" sx={{ fontWeight: 600, lineHeight: 1.3, flex: 1, mr: 0.5 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, flex: 1, mr: 0.5, minWidth: 0 }}>
|
||||||
{need.companyName}
|
<Typography variant="body2" sx={{ fontWeight: 600, lineHeight: 1.3 }} noWrap>
|
||||||
|
{need.isAnonymous ? 'Anonyme Anfrage' : need.companyName}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
{need.isAnonymous && (
|
||||||
|
<Chip label="Anonym" size="small" sx={{ bgcolor: '#f5f3ff', color: '#6d28d9', fontSize: 9, height: 16, fontWeight: 700, flexShrink: 0, border: '1px solid #ddd6fe' }} />
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
{pendingCount > 0 && (
|
{pendingCount > 0 && (
|
||||||
<Chip label={pendingCount} size="small"
|
<Chip label={pendingCount} size="small"
|
||||||
sx={{ bgcolor: '#d97706', color: 'white', fontSize: 10, height: 18, minWidth: 22 }} />
|
sx={{ bgcolor: '#d97706', color: 'white', fontSize: 10, height: 18, minWidth: 22 }} />
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export function ExecutiveSummaryPanel({ match }: Props) {
|
|||||||
|
|
||||||
const rows: { icon: React.ReactNode; label: string; text: string }[] = [
|
const rows: { icon: React.ReactNode; label: string; text: string }[] = [
|
||||||
{
|
{
|
||||||
icon: <Target size={15} color="#1e3a5f" />,
|
icon: <Target size={15} color="#152642" />,
|
||||||
label: 'Gesamteignung',
|
label: 'Gesamteignung',
|
||||||
text: match.explainabilitySummary || `${match.matchStrength}-Match mit ${match.matchScore} Punkten`,
|
text: match.explainabilitySummary || `${match.matchStrength}-Match mit ${match.matchScore} Punkten`,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import { Box, Chip, Paper, Typography } from '@mui/material'
|
||||||
|
import { HardHat } from 'lucide-react'
|
||||||
|
import { effectiveAnnualBurdenPerSqm } from '../../lib/fitOutUtils'
|
||||||
|
import { DS_TEXT, DS_SURFACE, DS_BORDER } from '../../lib/ds'
|
||||||
|
import { FITOUT_AMORTIZATION_YEARS, FITOUT_ANNUITY_RATE, FIT_OUT_LABELS } from '../../lib/constants'
|
||||||
|
|
||||||
|
const RATE_PCT = Math.round(FITOUT_ANNUITY_RATE * 100)
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
fitOut: string
|
||||||
|
areaSqm: number
|
||||||
|
mabPerSqm: number
|
||||||
|
rentPricePerSqm: number
|
||||||
|
fitOutByLandlord?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function chf(value: number): string {
|
||||||
|
return `CHF ${Math.round(value).toLocaleString('de-CH')}.–`
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RowProps { label: string; value: string; sub?: string; isTotal?: boolean; isWarning?: boolean }
|
||||||
|
|
||||||
|
function Row({ label, value, sub, isTotal, isWarning }: RowProps) {
|
||||||
|
return (
|
||||||
|
<Box sx={{
|
||||||
|
display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start',
|
||||||
|
py: 0.875, borderBottom: isTotal ? 'none' : `1px solid ${DS_BORDER.default}`,
|
||||||
|
bgcolor: isTotal ? (isWarning ? DS_SURFACE.warning.bg : DS_SURFACE.success.bg) : 'transparent',
|
||||||
|
px: isTotal ? 1.5 : 0, mx: isTotal ? -1.5 : 0, borderRadius: isTotal ? 1 : 0,
|
||||||
|
}}>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: isTotal ? 700 : 400 }}>{label}</Typography>
|
||||||
|
{sub && <Typography variant="caption" sx={{ color: DS_TEXT.muted }}>{sub}</Typography>}
|
||||||
|
</Box>
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: isTotal ? 700 : 500, color: isTotal ? DS_TEXT.primary : DS_TEXT.secondary, flexShrink: 0, ml: 2 }}>
|
||||||
|
{value}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FitOutCostPanel({ fitOut, areaSqm, mabPerSqm, rentPricePerSqm, fitOutByLandlord }: Props) {
|
||||||
|
const { fitOutPerSqm } = effectiveAnnualBurdenPerSqm({ fitOut, rentPricePerSqm, mabPerSqm, fitOutByLandlord })
|
||||||
|
const rentPerYear = rentPricePerSqm * areaSqm
|
||||||
|
const fitOutPerYear = fitOutPerSqm * areaSqm
|
||||||
|
const totalPerYear = rentPerYear + fitOutPerYear
|
||||||
|
const fitOutLabel = FIT_OUT_LABELS[fitOut] ?? fitOut
|
||||||
|
|
||||||
|
// 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>
|
||||||
|
{hasSurcharge && (
|
||||||
|
<Chip
|
||||||
|
label="CRB/BKP Richtwerte"
|
||||||
|
size="small"
|
||||||
|
sx={{ ml: 'auto', bgcolor: DS_SURFACE.blue.bg, color: DS_TEXT.signalDark, fontSize: 10, height: 20, border: `1px solid ${DS_SURFACE.blue.border}` }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ px: 1.5, border: `1px solid ${DS_BORDER.default}`, borderRadius: 1.5, overflow: 'hidden' }}>
|
||||||
|
<Row
|
||||||
|
label="Jahresmiete"
|
||||||
|
value={chf(rentPerYear)}
|
||||||
|
sub={`CHF ${rentPricePerSqm}/m²/Jahr × ${areaSqm.toLocaleString('de-CH')} m²`}
|
||||||
|
/>
|
||||||
|
{fitOutByLandlord ? (
|
||||||
|
<Row
|
||||||
|
label="Ausbau"
|
||||||
|
value="CHF 0.–"
|
||||||
|
sub={`${fitOutLabel} — Ausbau im Mietzins enthalten`}
|
||||||
|
/>
|
||||||
|
) : !hasSurcharge ? (
|
||||||
|
<Row
|
||||||
|
label="Ausbau"
|
||||||
|
value="CHF 0.–"
|
||||||
|
sub={`${fitOutLabel} — bezugsfertig`}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Row
|
||||||
|
label={`Ausbau (annuit. ${FITOUT_AMORTIZATION_YEARS} J. / ${RATE_PCT}%)`}
|
||||||
|
value={chf(fitOutPerYear)}
|
||||||
|
sub={
|
||||||
|
`${fitOutLabel}` +
|
||||||
|
(mabPerSqm > 0 ? ` abzgl. CHF ${mabPerSqm} MAB` : '') +
|
||||||
|
` — CHF ${fitOutPerSqm}/m²/Jahr × ${areaSqm.toLocaleString('de-CH')} m²`
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Row
|
||||||
|
label="Total/Jahr"
|
||||||
|
value={chf(totalPerYear)}
|
||||||
|
isTotal
|
||||||
|
isWarning={isWarning}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{disclaimer && (
|
||||||
|
<Typography variant="caption" sx={{ color: DS_TEXT.muted, display: 'block', mt: 1.25 }}>
|
||||||
|
{disclaimer}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Paper>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { Box, Paper, Typography } from '@mui/material'
|
||||||
|
import { FileImage } from 'lucide-react'
|
||||||
|
import { DS_TEXT } from '../../lib/ds'
|
||||||
|
|
||||||
|
interface FloorPlan {
|
||||||
|
url: string
|
||||||
|
label?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
plans: FloorPlan[]
|
||||||
|
mb?: number | string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FloorPlanSection({ plans, mb }: Props) {
|
||||||
|
if (plans.length === 0) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper sx={{ p: 2.5, ...(mb !== undefined ? { mb } : {}) }}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
|
||||||
|
<FileImage size={15} color={DS_TEXT.secondary} />
|
||||||
|
<Typography variant="h6" sx={{ fontWeight: 700 }}>Grundriss</Typography>
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||||
|
{plans.map((p, i) => (
|
||||||
|
<Box key={i}>
|
||||||
|
{p.label && (
|
||||||
|
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.75, fontWeight: 600 }}>
|
||||||
|
{p.label}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
<Box
|
||||||
|
component="img"
|
||||||
|
src={p.url}
|
||||||
|
alt={p.label ?? 'Grundriss'}
|
||||||
|
sx={{
|
||||||
|
width: '100%',
|
||||||
|
borderRadius: 1,
|
||||||
|
border: '1px solid rgba(0,0,0,0.08)',
|
||||||
|
display: 'block',
|
||||||
|
objectFit: 'contain',
|
||||||
|
maxHeight: 480,
|
||||||
|
bgcolor: '#f8fafc',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
)
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user