fix: resolve all TypeScript errors and refactor oversized components
- Fix MUI v9 API: PaperProps/InputLabelProps/inputProps → slotProps in 6 components - Add ExternalMarketResult alias, PropertyUnit import, OPERATIONS workspace config - Fix TradeOff.description → .concern, ScoreFactor.label → .criterion - Make schattenmarktRelease.leadTimeMonths optional, fix mock-data enum values - Fix useMatchDetailData query typing, weightingService missing WeightProfile keys - Split Pipeline/Compare/MatchDetail/IntelligenceMatchCard into sub-components - Fix all test fixtures (CreateNeedInput, CreatePropertyInput, TradeOffInput, etc.) - Add vercel.json for deployment, zero tsc errors Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -3,7 +3,9 @@
|
|||||||
"allow": [
|
"allow": [
|
||||||
"Bash(git commit -m ' *)",
|
"Bash(git commit -m ' *)",
|
||||||
"Bash(git commit *)",
|
"Bash(git commit *)",
|
||||||
"Bash(node -e ' *)"
|
"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/**)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"}
|
||||||
@@ -123,7 +123,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,
|
||||||
|
|||||||
@@ -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`
|
||||||
|
|||||||
@@ -48,13 +48,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 */}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 } }}>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
MessageSquare,
|
MessageSquare,
|
||||||
Kanban,
|
Kanban,
|
||||||
BellRing,
|
BellRing,
|
||||||
|
Settings,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -68,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()
|
||||||
|
|
||||||
@@ -243,4 +244,4 @@ export function FutureAvailabilityCard({ vm }: { vm: MatchCardViewModel }) {
|
|||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
}
|
})
|
||||||
|
|||||||
@@ -182,7 +182,7 @@ export const MatchCardCompact = memo(function MatchCardCompact({ vm, imageUrl, o
|
|||||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 0.5, mt: 0.5 }}>
|
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 0.5, mt: 0.5 }}>
|
||||||
<AlertTriangle size={12} color="#d97706" style={{ marginTop: 2, flexShrink: 0 }} />
|
<AlertTriangle size={12} color="#d97706" style={{ marginTop: 2, flexShrink: 0 }} />
|
||||||
<Typography sx={{ fontSize: '0.69rem', color: '#92400e', lineHeight: 1.3 }}>
|
<Typography sx={{ fontSize: '0.69rem', color: '#92400e', lineHeight: 1.3 }}>
|
||||||
{vm.tradeoffs[0].description}
|
{vm.tradeoffs[0].concern}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { ResultType } from '../../domain/enums'
|
|||||||
import type { Property } from '../../domain/property'
|
import type { Property } from '../../domain/property'
|
||||||
import { FLOOR_LABEL, ASSET_LABELS, RISK_LABELS, SOURCE_LABELS, PASSERBY_LABELS, KeyFactRow, UnitRow } from './MatchDetailPropertyDetails'
|
import { FLOOR_LABEL, ASSET_LABELS, RISK_LABELS, SOURCE_LABELS, PASSERBY_LABELS, KeyFactRow, UnitRow } from './MatchDetailPropertyDetails'
|
||||||
import { FloorPlanSection } from './FloorPlanSection'
|
import { FloorPlanSection } from './FloorPlanSection'
|
||||||
import { DS_TEXT, DS_BG, DS_SURFACE, DS_BORDER, DS_MARKET_SIGNAL, DS_PRE_MARKET } from '../../lib/ds'
|
import { DS_TEXT, DS_BG, DS_SURFACE, DS_BORDER, DS_MARKET_SIGNAL } from '../../lib/ds'
|
||||||
|
|
||||||
type Match = NonNullable<ReturnType<typeof useMatchDetail>['data']>
|
type Match = NonNullable<ReturnType<typeof useMatchDetail>['data']>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { memo, useState } from 'react'
|
||||||
|
import { Box, Button, Paper, Typography } from '@mui/material'
|
||||||
|
import { ChevronDown, ChevronUp } from 'lucide-react'
|
||||||
|
import { PropertyMap } from '../shared'
|
||||||
|
import {
|
||||||
|
ExecutiveSummaryPanel,
|
||||||
|
NeedAlignmentPanel,
|
||||||
|
LocationIntelligencePanel,
|
||||||
|
TradeoffPanel,
|
||||||
|
RiskPanel,
|
||||||
|
MissingInformationPanel,
|
||||||
|
FutureAvailabilityContextPanel,
|
||||||
|
ScoreBreakdownPanel,
|
||||||
|
} from '.'
|
||||||
|
import { MatchDetailPropertySections } from './MatchDetailPropertySections'
|
||||||
|
import { useMatchDetail } from '../../hooks/useMatches'
|
||||||
|
import type { Property } from '../../domain/property'
|
||||||
|
import type { Need } from '../../domain/need'
|
||||||
|
import type { FutureSignal } from '../../domain/futureSignal'
|
||||||
|
import { DS_TEXT, DS_BORDER, DS_BG } from '../../lib/ds'
|
||||||
|
|
||||||
|
type Match = NonNullable<ReturnType<typeof useMatchDetail>['data']>
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
match: Match
|
||||||
|
property: Property | null
|
||||||
|
need: Need | null
|
||||||
|
signal: FutureSignal | null
|
||||||
|
isFuture: boolean
|
||||||
|
taxCalculatorUrl?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MatchDetailScoreBreakdown = memo(function MatchDetailScoreBreakdown({
|
||||||
|
match,
|
||||||
|
property,
|
||||||
|
need,
|
||||||
|
signal,
|
||||||
|
isFuture,
|
||||||
|
taxCalculatorUrl,
|
||||||
|
}: Props) {
|
||||||
|
const [showFullAnalysis, setShowFullAnalysis] = useState(false)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* ── Full analysis toggle ── */}
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
onClick={() => setShowFullAnalysis(v => !v)}
|
||||||
|
fullWidth
|
||||||
|
endIcon={showFullAnalysis ? <ChevronUp size={15} /> : <ChevronDown size={15} />}
|
||||||
|
sx={{
|
||||||
|
borderStyle: 'dashed', color: DS_TEXT.muted, borderColor: DS_BORDER.strong,
|
||||||
|
textTransform: 'none', fontWeight: 500,
|
||||||
|
'&:hover': { borderColor: DS_TEXT.muted, bgcolor: DS_BG.subtle },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{showFullAnalysis ? 'Weniger anzeigen' : 'Vollständige Analyse anzeigen'}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* ── Full analysis (hidden by default) ── */}
|
||||||
|
{showFullAnalysis && (
|
||||||
|
<>
|
||||||
|
<ExecutiveSummaryPanel match={match} />
|
||||||
|
<NeedAlignmentPanel match={match} need={need} property={property} />
|
||||||
|
{!isFuture && property && <MatchDetailPropertySections property={property} match={match} />}
|
||||||
|
<LocationIntelligencePanel property={property} />
|
||||||
|
{!isFuture && property?.images?.[0] && property?.location?.coordinates && (
|
||||||
|
<Paper sx={{ overflow: 'hidden', p: 0 }}>
|
||||||
|
<Box sx={{ px: 2.5, pt: 2, pb: 1 }}>
|
||||||
|
<Typography variant="h6" sx={{ fontWeight: 700 }}>Standort</Typography>
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
{property.address.street} {property.address.houseNumber}, {property.address.postalCode} {property.address.city}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<PropertyMap
|
||||||
|
lat={property.location.coordinates.lat}
|
||||||
|
lng={property.location.coordinates.lng}
|
||||||
|
label={property.title}
|
||||||
|
height={220}
|
||||||
|
/>
|
||||||
|
</Paper>
|
||||||
|
)}
|
||||||
|
<TradeoffPanel match={match} />
|
||||||
|
<RiskPanel match={match} />
|
||||||
|
<MissingInformationPanel match={match} />
|
||||||
|
{isFuture && <FutureAvailabilityContextPanel match={match} signal={signal} />}
|
||||||
|
<ScoreBreakdownPanel match={match} taxCalculatorUrl={taxCalculatorUrl} isFuture={isFuture} signal={signal} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -11,7 +11,6 @@ import {
|
|||||||
ExternalLink,
|
ExternalLink,
|
||||||
Info,
|
Info,
|
||||||
Layers,
|
Layers,
|
||||||
ShieldCheck,
|
|
||||||
Tag,
|
Tag,
|
||||||
Train,
|
Train,
|
||||||
TrendingUp,
|
TrendingUp,
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
export { LocationIntelligencePanel } from './LocationIntelligencePanel'
|
export { LocationIntelligencePanel } from './LocationIntelligencePanel'
|
||||||
export { MatchDetailHeader } from './MatchDetailHeader'
|
export { MatchDetailHeader } from './MatchDetailHeader'
|
||||||
|
export { MatchDetailHero } from './MatchDetailHero'
|
||||||
|
export { MatchDetailScoreBreakdown } from './MatchDetailScoreBreakdown'
|
||||||
export { ExecutiveSummaryPanel } from './ExecutiveSummaryPanel'
|
export { ExecutiveSummaryPanel } from './ExecutiveSummaryPanel'
|
||||||
export { PropertyOverviewPanel } from './PropertyOverviewPanel'
|
export { PropertyOverviewPanel } from './PropertyOverviewPanel'
|
||||||
export { NeedAlignmentPanel } from './NeedAlignmentPanel'
|
export { NeedAlignmentPanel } from './NeedAlignmentPanel'
|
||||||
|
|||||||
@@ -37,13 +37,13 @@ export function AreaDetailsSection({
|
|||||||
<TextField
|
<TextField
|
||||||
label="Fläche (m²)" value={areaSqm}
|
label="Fläche (m²)" value={areaSqm}
|
||||||
onChange={e => onAreaSqmChange(e.target.value)}
|
onChange={e => onAreaSqmChange(e.target.value)}
|
||||||
size="small" type="number" inputProps={{ min: 1 }} fullWidth
|
size="small" type="number" slotProps={{ htmlInput: { min: 1 } }} fullWidth
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<TextField
|
<TextField
|
||||||
label="Mietpreis (CHF/m²/Jahr)" value={rentPerSqm}
|
label="Mietpreis (CHF/m²/Jahr)" value={rentPerSqm}
|
||||||
onChange={e => onRentPerSqmChange(e.target.value)}
|
onChange={e => onRentPerSqmChange(e.target.value)}
|
||||||
size="small" type="number" inputProps={{ min: 1 }} fullWidth
|
size="small" type="number" slotProps={{ htmlInput: { min: 1 } }} fullWidth
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<TextField
|
<TextField
|
||||||
|
|||||||
@@ -38,12 +38,12 @@ export function TechnicalDetailsSection({
|
|||||||
<TextField
|
<TextField
|
||||||
label="Parkplätze" value={parking}
|
label="Parkplätze" value={parking}
|
||||||
onChange={e => onParkingChange(e.target.value)}
|
onChange={e => onParkingChange(e.target.value)}
|
||||||
size="small" type="number" inputProps={{ min: 0 }} fullWidth
|
size="small" type="number" slotProps={{ htmlInput: { min: 0 } }} fullWidth
|
||||||
/>
|
/>
|
||||||
<TextField
|
<TextField
|
||||||
label="Deckenhöhe (m)" value={ceilingHeight}
|
label="Deckenhöhe (m)" value={ceilingHeight}
|
||||||
onChange={e => onCeilingHeightChange(e.target.value)}
|
onChange={e => onCeilingHeightChange(e.target.value)}
|
||||||
size="small" type="number" inputProps={{ step: 0.1, min: 2 }} fullWidth
|
size="small" type="number" slotProps={{ htmlInput: { step: 0.1, min: 2 } }} fullWidth
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -146,7 +146,6 @@ export function WelcomeDialog() {
|
|||||||
open={open}
|
open={open}
|
||||||
maxWidth="sm"
|
maxWidth="sm"
|
||||||
fullWidth
|
fullWidth
|
||||||
disableEscapeKeyDown
|
|
||||||
>
|
>
|
||||||
<DialogContent sx={{ pt: 4, pb: 2 }}>
|
<DialogContent sx={{ pt: 4, pb: 2 }}>
|
||||||
{SLIDES[activeStep]}
|
{SLIDES[activeStep]}
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export { STAGES, NEXT_STAGE, MOCK_DOCS, RESULT_TYPE_META } from './pipelineConstants'
|
||||||
|
export { scoreColor, detailPath, getKiInsight } from './pipelineUtils'
|
||||||
|
export { DraggableCard } from './PipelineCard'
|
||||||
|
export { DroppableColumn } from './PipelineColumn'
|
||||||
|
export { DetailPanel } from './PipelineDetailPanel'
|
||||||
@@ -78,7 +78,7 @@ export function BerichtDialog({ onClose, report, propertyId }: BerichtDialogProp
|
|||||||
onClose={ready ? onClose : undefined}
|
onClose={ready ? onClose : undefined}
|
||||||
maxWidth="md"
|
maxWidth="md"
|
||||||
fullWidth
|
fullWidth
|
||||||
PaperProps={{ sx: { height: '88vh', display: 'flex', flexDirection: 'column', overflow: 'hidden' } }}
|
slotProps={{ paper: { sx: { height: '88vh', display: 'flex', flexDirection: 'column', overflow: 'hidden' } } }}
|
||||||
>
|
>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<Box sx={{ px: 3, py: 1.5, borderBottom: `1px solid ${DS_BORDER.default}`, display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexShrink: 0 }}>
|
<Box sx={{ px: 3, py: 1.5, borderBottom: `1px solid ${DS_BORDER.default}`, display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexShrink: 0 }}>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Box, Button, Divider, LinearProgress, TextField, Typography } from '@mui/material'
|
import { Box, Button, Divider, LinearProgress, TextField, Typography } from '@mui/material'
|
||||||
import { ExternalLink, FileText, Plus } from 'lucide-react'
|
import { ExternalLink, FileText } from 'lucide-react'
|
||||||
import type { Property, UpdatePropertyInput } from '../../domain/property'
|
import type { Property, UpdatePropertyInput } from '../../domain/property'
|
||||||
import { PropertyMap } from '../shared'
|
import { PropertyMap } from '../shared'
|
||||||
import { getAssetTypeLabel, qualityColor } from './propertyHelpers'
|
import { getAssetTypeLabel, qualityColor } from './propertyHelpers'
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import { ReminderPriorityBadge } from './ReminderPriorityBadge'
|
|||||||
import { ReminderTypeBadge } from './ReminderTypeBadge'
|
import { ReminderTypeBadge } from './ReminderTypeBadge'
|
||||||
import { ReminderDaysIndicator } from './ReminderDaysIndicator'
|
import { ReminderDaysIndicator } from './ReminderDaysIndicator'
|
||||||
import { ReminderStatus } from '../../domain/reminder'
|
import { ReminderStatus } from '../../domain/reminder'
|
||||||
import { SHADOW_RISK_COLOR, SHADOW_RISK_LABEL, STATUS_CHIP_COLOR, STATUS_LABEL, ACTION_LABEL, SectionTitle, DateRow, ActivityEntry } from './reminderDetailHelpers'
|
import { SHADOW_RISK_COLOR, SHADOW_RISK_LABEL, STATUS_CHIP_COLOR, STATUS_LABEL, SectionTitle, DateRow, ActivityEntry } from './reminderDetailHelpers'
|
||||||
|
|
||||||
export function ReminderDetailDrawer() {
|
export function ReminderDetailDrawer() {
|
||||||
const { selectedId, drawerOpen, setDrawerOpen, setSelectedId } = useReminderStore()
|
const { selectedId, drawerOpen, setDrawerOpen, setSelectedId } = useReminderStore()
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ export function ReminderFeed() {
|
|||||||
if (viewMode === 'card') {
|
if (viewMode === 'card') {
|
||||||
return (
|
return (
|
||||||
<Box className="flex flex-col gap-4">
|
<Box className="flex flex-col gap-4">
|
||||||
{HORIZON_CONFIG.map(({ key, label, color, bg, border }) => {
|
{HORIZON_CONFIG.map(({ key, label, color }) => {
|
||||||
const group = grouped[key]
|
const group = grouped[key]
|
||||||
if (!group || group.length === 0) return null
|
if (!group || group.length === 0) return null
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type {
|
|||||||
FreshnessStatus, RiskLevel, SourceType, DataQualityLevel,
|
FreshnessStatus, RiskLevel, SourceType, DataQualityLevel,
|
||||||
} from './enums'
|
} from './enums'
|
||||||
import { AvailabilityStatus as AS } from './enums'
|
import { AvailabilityStatus as AS } from './enums'
|
||||||
|
import type { PropertyUnit } from './unit'
|
||||||
|
|
||||||
// Re-export unit types so all existing imports from 'property' continue to work
|
// Re-export unit types so all existing imports from 'property' continue to work
|
||||||
export type { PropertyUnit, UnitBundle, UnitNeedMatch } from './unit'
|
export type { PropertyUnit, UnitBundle, UnitNeedMatch } from './unit'
|
||||||
@@ -152,7 +153,7 @@ export interface Property {
|
|||||||
importedAt?: string
|
importedAt?: string
|
||||||
lastUpdatedAt?: string
|
lastUpdatedAt?: string
|
||||||
|
|
||||||
schattenmarktRelease?: { enabled: boolean; leadTimeMonths: number }
|
schattenmarktRelease?: { enabled: boolean; leadTimeMonths?: number }
|
||||||
|
|
||||||
status?: 'ACTIVE' | 'INACTIVE' | 'DRAFT' | 'ARCHIVED'
|
status?: 'ACTIVE' | 'INACTIVE' | 'DRAFT' | 'ARCHIVED'
|
||||||
lastReviewedAt?: string
|
lastReviewedAt?: string
|
||||||
|
|||||||
@@ -29,6 +29,9 @@ export interface FutureAvailabilityResult extends UnifiedResultBase {
|
|||||||
unit?: PropertyUnit // specific rentable unit (when signal has a unitId)
|
unit?: PropertyUnit // specific rentable unit (when signal has a unitId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @deprecated Alias kept for backward compatibility — use VerifiedPortfolioResult */
|
||||||
|
export type ExternalMarketResult = VerifiedPortfolioResult
|
||||||
|
|
||||||
export type UnifiedMatchResult =
|
export type UnifiedMatchResult =
|
||||||
| VerifiedPortfolioResult
|
| VerifiedPortfolioResult
|
||||||
| FutureAvailabilityResult
|
| FutureAvailabilityResult
|
||||||
|
|||||||
@@ -44,9 +44,9 @@ describe('buildMatchCardViewModel — VERIFIED_PORTFOLIO', () => {
|
|||||||
|
|
||||||
it('scoreBreakdown reflects hard and soft components from the engine', () => {
|
it('scoreBreakdown reflects hard and soft components from the engine', () => {
|
||||||
const vm = buildMatchCardViewModel(result, [])
|
const vm = buildMatchCardViewModel(result, [])
|
||||||
expect(vm.scoreBreakdown.hardMatchScore).toBe(match.scoreBreakdown.hardMatchScore)
|
expect(vm.scoreBreakdown!.hardMatchScore).toBe(match.scoreBreakdown.hardMatchScore)
|
||||||
expect(vm.scoreBreakdown.softFactorScore).toBe(match.scoreBreakdown.softFactorScore)
|
expect(vm.scoreBreakdown!.softFactorScore).toBe(match.scoreBreakdown.softFactorScore)
|
||||||
expect(vm.scoreBreakdown.totalScore).toBe(match.scoreBreakdown.totalScore)
|
expect(vm.scoreBreakdown!.totalScore).toBe(match.scoreBreakdown.totalScore)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('does NOT set a disclaimer for verified portfolio results', () => {
|
it('does NOT set a disclaimer for verified portfolio results', () => {
|
||||||
@@ -65,7 +65,7 @@ describe('buildMatchCardViewModel — VERIFIED_PORTFOLIO', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('passes through supplied actions unchanged', () => {
|
it('passes through supplied actions unchanged', () => {
|
||||||
const actions = [{ label: 'Shortlist', actionType: 'SHORTLIST' as const, primary: true }]
|
const actions = [{ id: 'shortlist', label: 'Shortlist', actionType: 'SAVE_SHORTLIST' as const, primary: true, onClick: () => {} }]
|
||||||
const vm = buildMatchCardViewModel(result, actions)
|
const vm = buildMatchCardViewModel(result, actions)
|
||||||
expect(vm.actions).toBe(actions)
|
expect(vm.actions).toBe(actions)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -61,6 +61,9 @@ describe('useCreateNeed', () => {
|
|||||||
preferredLocations: ['Zürich'],
|
preferredLocations: ['Zürich'],
|
||||||
budgetRange: { maxPerSqm: 400, maxMonthlyTotal: 10000, currency: 'CHF' },
|
budgetRange: { maxPerSqm: 400, maxMonthlyTotal: 10000, currency: 'CHF' },
|
||||||
organizationId: 'org-test',
|
organizationId: 'org-test',
|
||||||
|
timing: { earliestMoveIn: '2025-01-01', latestMoveIn: '2026-01-01', flexibleTiming: true },
|
||||||
|
weightingProfile: { area: 0.3, location: 0.3, budget: 0.2, timing: 0.1, prestige: 0, accessibility: 0, expansionPotential: 0, flexibility: 0, visibility: 0, footfall: 0, talentAccess: 0, esg: 0, taxEnvironment: 0 },
|
||||||
|
confidenceInCriteria: 0.8,
|
||||||
})
|
})
|
||||||
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
||||||
expect(result.current.data?.data.companyName).toBe('Hook Test GmbH')
|
expect(result.current.data?.data.companyName).toBe('Hook Test GmbH')
|
||||||
|
|||||||
@@ -71,9 +71,14 @@ describe('useCreateProperty', () => {
|
|||||||
assetType: 'LOGISTICS',
|
assetType: 'LOGISTICS',
|
||||||
resultType: 'VERIFIED_PORTFOLIO',
|
resultType: 'VERIFIED_PORTFOLIO',
|
||||||
location: { city: 'Bern', country: 'CH' },
|
location: { city: 'Bern', country: 'CH' },
|
||||||
|
address: { street: 'Teststrasse', houseNumber: '1', postalCode: '3000', city: 'Bern', country: 'CH' },
|
||||||
areaSqm: 300,
|
areaSqm: 300,
|
||||||
rentPricePerSqm: 100,
|
rentPricePerSqm: 100,
|
||||||
|
availabilityDate: '2025-01-01',
|
||||||
availabilityStatus: 'AVAILABLE_NOW',
|
availabilityStatus: 'AVAILABLE_NOW',
|
||||||
|
sourceType: 'DIRECT',
|
||||||
|
confidenceScore: 0.8,
|
||||||
|
dataQuality: { score: 0.8, missingCriticalFields: [], missingOptionalFields: [], freshness: 'FRESH', warnings: [] },
|
||||||
organizationId: 'org-test',
|
organizationId: 'org-test',
|
||||||
})
|
})
|
||||||
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useMatchDetail } from './useMatches'
|
|||||||
import { propertyService } from '../services/propertyService'
|
import { propertyService } from '../services/propertyService'
|
||||||
import { needService } from '../services/needService'
|
import { needService } from '../services/needService'
|
||||||
import { futureSignalService } from '../services/futureSignalService'
|
import { futureSignalService } from '../services/futureSignalService'
|
||||||
|
import type { ItemResponse } from '../services/types'
|
||||||
import type { Property } from '../domain/property'
|
import type { Property } from '../domain/property'
|
||||||
import type { Need } from '../domain/need'
|
import type { Need } from '../domain/need'
|
||||||
import type { FutureSignal } from '../domain/futureSignal'
|
import type { FutureSignal } from '../domain/futureSignal'
|
||||||
@@ -13,14 +14,14 @@ export function useMatchDetailData(matchId: string) {
|
|||||||
const { data: match, isLoading } = useMatchDetail(matchId)
|
const { data: match, isLoading } = useMatchDetail(matchId)
|
||||||
const isFuture = match?.resultType === 'FUTURE_AVAILABILITY'
|
const isFuture = match?.resultType === 'FUTURE_AVAILABILITY'
|
||||||
|
|
||||||
const { data: property = null } = useQuery<Property | null>({
|
const { data: property = null } = useQuery<ItemResponse<Property | null>, Error, Property | null>({
|
||||||
queryKey: ['property', match?.propertyId],
|
queryKey: ['property', match?.propertyId],
|
||||||
queryFn: () => propertyService.getById(match!.propertyId),
|
queryFn: () => propertyService.getById(match!.propertyId),
|
||||||
enabled: !!match && !isFuture,
|
enabled: !!match && !isFuture,
|
||||||
select: r => r.data ?? null,
|
select: r => r.data ?? null,
|
||||||
})
|
})
|
||||||
|
|
||||||
const { data: need = null } = useQuery<Need | null>({
|
const { data: need = null } = useQuery<ItemResponse<Need | null>, Error, Need | null>({
|
||||||
queryKey: ['need', match?.needId],
|
queryKey: ['need', match?.needId],
|
||||||
queryFn: () => needService.getById(match!.needId),
|
queryFn: () => needService.getById(match!.needId),
|
||||||
enabled: !!match?.needId,
|
enabled: !!match?.needId,
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ interface PropertyFilter {
|
|||||||
minAreaSqm?: number
|
minAreaSqm?: number
|
||||||
maxRentPerSqm?: number
|
maxRentPerSqm?: number
|
||||||
organizationId?: string
|
organizationId?: string
|
||||||
|
sourceType?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useProperties(filter?: PropertyFilter) {
|
export function useProperties(filter?: PropertyFilter) {
|
||||||
|
|||||||
@@ -22,13 +22,13 @@ export function useSchattenmarktSignals(properties: Property[]): FutureSignal[]
|
|||||||
for (const unit of releasedUnits) {
|
for (const unit of releasedUnits) {
|
||||||
const availableFrom = unit.schattenmarktRelease?.availableFrom ?? unit.leaseEndDate ?? p.leaseEndDate
|
const availableFrom = unit.schattenmarktRelease?.availableFrom ?? unit.leaseEndDate ?? p.leaseEndDate
|
||||||
if (!availableFrom) continue
|
if (!availableFrom) continue
|
||||||
const triggerDate = getTriggerDate(availableFrom, rel.leadTimeMonths)
|
const triggerDate = getTriggerDate(availableFrom, rel.leadTimeMonths ?? 0)
|
||||||
if (MOCK_TODAY < triggerDate) continue
|
if (MOCK_TODAY < triggerDate) continue
|
||||||
signals.push(buildUnitSignal(p, unit, availableFrom))
|
signals.push(buildUnitSignal(p, unit, availableFrom))
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Backward compat: property-level signal (no explicit unit releases defined)
|
// Backward compat: property-level signal (no explicit unit releases defined)
|
||||||
const triggerDate = getEarliestTriggerDate(p, rel.leadTimeMonths)
|
const triggerDate = getEarliestTriggerDate(p, rel.leadTimeMonths ?? 0)
|
||||||
if (!triggerDate || MOCK_TODAY < triggerDate) continue
|
if (!triggerDate || MOCK_TODAY < triggerDate) continue
|
||||||
signals.push(buildPropertySignal(p))
|
signals.push(buildPropertySignal(p))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
import { useMatches, useMatchesByNeed } from './useMatches'
|
import { useMatchesByNeed } from './useMatches'
|
||||||
import { useProperties } from './useProperties'
|
import { useProperties } from './useProperties'
|
||||||
import { useFutureSignals } from './useFutureSignals'
|
import { useFutureSignals } from './useFutureSignals'
|
||||||
import { useSchattenmarktSignals } from './useSchattenmarktSignals'
|
import { useSchattenmarktSignals } from './useSchattenmarktSignals'
|
||||||
import type {
|
import type {
|
||||||
UnifiedMatchResult,
|
UnifiedMatchResult,
|
||||||
VerifiedPortfolioResult,
|
VerifiedPortfolioResult,
|
||||||
FutureAvailabilityResult,
|
|
||||||
} from '../domain/unifiedResult'
|
} from '../domain/unifiedResult'
|
||||||
|
|
||||||
export function useUnifiedResults(needId?: string) {
|
export function useUnifiedResults(needId?: string) {
|
||||||
|
|||||||
@@ -67,6 +67,10 @@ const WORKSPACE_ROLES: Record<WorkspaceType, UserRole[]> = {
|
|||||||
UserRole.PROPERTY_MANAGER,
|
UserRole.PROPERTY_MANAGER,
|
||||||
UserRole.DEMAND_USER,
|
UserRole.DEMAND_USER,
|
||||||
],
|
],
|
||||||
|
[WorkspaceType.OPERATIONS]: [
|
||||||
|
UserRole.SUPER_ADMIN,
|
||||||
|
UserRole.ORGANIZATION_ADMIN,
|
||||||
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Core Functions ────────────────────────────────────────────────────────────
|
// ── Core Functions ────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -608,7 +608,7 @@ export const mockProperties: Property[] = [
|
|||||||
prestige: 56,
|
prestige: 56,
|
||||||
accessibility: 88,
|
accessibility: 88,
|
||||||
visibilityScore: 72,
|
visibilityScore: 72,
|
||||||
passerbyFrequency: 'MEDIUM_HIGH',
|
passerbyFrequency: 'HIGH',
|
||||||
talentAccess: 65,
|
talentAccess: 65,
|
||||||
parkingSpots: 1,
|
parkingSpots: 1,
|
||||||
publicTransportMinutes: 5,
|
publicTransportMinutes: 5,
|
||||||
@@ -830,7 +830,7 @@ export const mockProperties: Property[] = [
|
|||||||
prestige: 60,
|
prestige: 60,
|
||||||
accessibility: 88,
|
accessibility: 88,
|
||||||
visibilityScore: 75,
|
visibilityScore: 75,
|
||||||
passerbyFrequency: 'MEDIUM_HIGH',
|
passerbyFrequency: 'HIGH',
|
||||||
talentAccess: 70,
|
talentAccess: 70,
|
||||||
parkingSpots: 2,
|
parkingSpots: 2,
|
||||||
publicTransportMinutes: 5,
|
publicTransportMinutes: 5,
|
||||||
@@ -2017,7 +2017,7 @@ export const mockProperties: Property[] = [
|
|||||||
prestige: 48,
|
prestige: 48,
|
||||||
accessibility: 78,
|
accessibility: 78,
|
||||||
visibilityScore: 68,
|
visibilityScore: 68,
|
||||||
passerbyFrequency: 'MEDIUM_HIGH',
|
passerbyFrequency: 'HIGH',
|
||||||
talentAccess: 60,
|
talentAccess: 60,
|
||||||
parkingSpots: 2,
|
parkingSpots: 2,
|
||||||
publicTransportMinutes: 5,
|
publicTransportMinutes: 5,
|
||||||
@@ -2230,7 +2230,7 @@ export const mockProperties: Property[] = [
|
|||||||
prestige: 58,
|
prestige: 58,
|
||||||
accessibility: 85,
|
accessibility: 85,
|
||||||
visibilityScore: 70,
|
visibilityScore: 70,
|
||||||
passerbyFrequency: 'MEDIUM_HIGH',
|
passerbyFrequency: 'HIGH',
|
||||||
talentAccess: 65,
|
talentAccess: 65,
|
||||||
parkingSpots: 2,
|
parkingSpots: 2,
|
||||||
publicTransportMinutes: 5,
|
publicTransportMinutes: 5,
|
||||||
@@ -2373,7 +2373,7 @@ export const mockProperties: Property[] = [
|
|||||||
prestige: 55,
|
prestige: 55,
|
||||||
accessibility: 82,
|
accessibility: 82,
|
||||||
visibilityScore: 70,
|
visibilityScore: 70,
|
||||||
passerbyFrequency: 'MEDIUM_HIGH',
|
passerbyFrequency: 'HIGH',
|
||||||
talentAccess: 65,
|
talentAccess: 65,
|
||||||
parkingSpots: 0,
|
parkingSpots: 0,
|
||||||
publicTransportMinutes: 5,
|
publicTransportMinutes: 5,
|
||||||
@@ -3453,7 +3453,7 @@ export const mockProperties: Property[] = [
|
|||||||
prestige: 85,
|
prestige: 85,
|
||||||
accessibility: 95,
|
accessibility: 95,
|
||||||
visibilityScore: 88,
|
visibilityScore: 88,
|
||||||
passerbyFrequency: 'MEDIUM_HIGH',
|
passerbyFrequency: 'HIGH',
|
||||||
talentAccess: 76,
|
talentAccess: 76,
|
||||||
parkingSpots: 0,
|
parkingSpots: 0,
|
||||||
publicTransportMinutes: 3,
|
publicTransportMinutes: 3,
|
||||||
@@ -4586,7 +4586,7 @@ export const mockProperties: Property[] = [
|
|||||||
availabilityStatus: AvailabilityStatus.AVAILABLE_SOON,
|
availabilityStatus: AvailabilityStatus.AVAILABLE_SOON,
|
||||||
sourceType: 'DIRECT',
|
sourceType: 'DIRECT',
|
||||||
confidenceScore: 0.72,
|
confidenceScore: 0.72,
|
||||||
dataQuality: { score: 0.68, missingCriticalFields: ['contactPerson'], missingOptionalFields: ['softFactors'], lastVerifiedAt: '2026-02-15', freshness: DataFreshness.AGING, warnings: ['Kein Ansprechpartner hinterlegt'] },
|
dataQuality: { score: 0.68, missingCriticalFields: ['contactPerson'], missingOptionalFields: ['softFactors'], lastVerifiedAt: '2026-02-15', freshness: DataFreshness.STALE, warnings: ['Kein Ansprechpartner hinterlegt'] },
|
||||||
softFactors: { prestige: 68, accessibility: 85, visibilityScore: 80, publicTransportMinutes: 5 },
|
softFactors: { prestige: 68, accessibility: 85, visibilityScore: 80, publicTransportMinutes: 5 },
|
||||||
hardFacts: { floor: 0, fitOut: 'BASIC', hasStorefront: true },
|
hardFacts: { floor: 0, fitOut: 'BASIC', hasStorefront: true },
|
||||||
floorLevel: 0,
|
floorLevel: 0,
|
||||||
@@ -4640,7 +4640,7 @@ export const mockProperties: Property[] = [
|
|||||||
availabilityStatus: AvailabilityStatus.AVAILABLE_SOON,
|
availabilityStatus: AvailabilityStatus.AVAILABLE_SOON,
|
||||||
sourceType: 'DIRECT',
|
sourceType: 'DIRECT',
|
||||||
confidenceScore: 0.70,
|
confidenceScore: 0.70,
|
||||||
dataQuality: { score: 0.65, missingCriticalFields: ['rentPricePerSqm'], missingOptionalFields: ['images', 'description'], lastVerifiedAt: '2026-03-01', freshness: DataFreshness.AGING, warnings: ['Mietpreis nicht verifiziert'] },
|
dataQuality: { score: 0.65, missingCriticalFields: ['rentPricePerSqm'], missingOptionalFields: ['images', 'description'], lastVerifiedAt: '2026-03-01', freshness: DataFreshness.STALE, warnings: ['Mietpreis nicht verifiziert'] },
|
||||||
softFactors: { prestige: 55, accessibility: 70, publicTransportMinutes: 12, parkingSpots: 8 },
|
softFactors: { prestige: 55, accessibility: 70, publicTransportMinutes: 12, parkingSpots: 8 },
|
||||||
hardFacts: { floor: 1, fitOut: 'BASIC', parking: 8 },
|
hardFacts: { floor: 1, fitOut: 'BASIC', parking: 8 },
|
||||||
floorLevel: 1,
|
floorLevel: 1,
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import { Search, Send, Paperclip, ArrowLeft, Bot, Building2, Kanban } from 'luci
|
|||||||
import { mockDemandInquiries } from '../../mock-data/demandInquiries'
|
import { mockDemandInquiries } from '../../mock-data/demandInquiries'
|
||||||
import { useInquiryStore } from '../../stores/inquiryStore'
|
import { useInquiryStore } from '../../stores/inquiryStore'
|
||||||
import { usePipelineItems, useMoveStage } from '../../hooks/usePipeline'
|
import { usePipelineItems, useMoveStage } from '../../hooks/usePipeline'
|
||||||
import { useToastStore } from '../../stores/toastStore'
|
|
||||||
import type { InquiryMessage } from '../../domain/inquiry'
|
import type { InquiryMessage } from '../../domain/inquiry'
|
||||||
import { STAGE_ORDER, STAGE_LABELS, detectKiStage } from './anfragenKiDetection'
|
import { STAGE_ORDER, STAGE_LABELS, detectKiStage } from './anfragenKiDetection'
|
||||||
import { AnfragenMessageBubble } from '../../components/demand/AnfragenMessageBubble'
|
import { AnfragenMessageBubble } from '../../components/demand/AnfragenMessageBubble'
|
||||||
@@ -32,7 +31,7 @@ export default function Anfragen() {
|
|||||||
const [searchParams] = useSearchParams()
|
const [searchParams] = useSearchParams()
|
||||||
const { data: pipelineItems = [] } = usePipelineItems()
|
const { data: pipelineItems = [] } = usePipelineItems()
|
||||||
const { mutate: moveStage } = useMoveStage()
|
const { mutate: moveStage } = useMoveStage()
|
||||||
const showToast = useToastStore(s => s.showToast)
|
|
||||||
|
|
||||||
const preselectedId = searchParams.get('inquiry')
|
const preselectedId = searchParams.get('inquiry')
|
||||||
|
|
||||||
@@ -156,7 +155,7 @@ export default function Anfragen() {
|
|||||||
<TextField
|
<TextField
|
||||||
size="small" placeholder="Suchen..." fullWidth
|
size="small" placeholder="Suchen..." fullWidth
|
||||||
value={search} onChange={e => setSearch(e.target.value)}
|
value={search} onChange={e => setSearch(e.target.value)}
|
||||||
InputProps={{ startAdornment: <InputAdornment position="start"><Search size={14} color={DS_TEXT.disabled} /></InputAdornment> }}
|
slotProps={{ input: { startAdornment: <InputAdornment position="start"><Search size={14} color={DS_TEXT.disabled} /></InputAdornment> } }}
|
||||||
sx={{ mb: 1.25 }}
|
sx={{ mb: 1.25 }}
|
||||||
/>
|
/>
|
||||||
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap' }}>
|
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap' }}>
|
||||||
|
|||||||
@@ -41,7 +41,6 @@ export default function Compare() {
|
|||||||
overallWinnerIdx,
|
overallWinnerIdx,
|
||||||
bestScoreIdx,
|
bestScoreIdx,
|
||||||
worstConfIdx,
|
worstConfIdx,
|
||||||
dqScores,
|
|
||||||
worstDQIdx,
|
worstDQIdx,
|
||||||
missingCriticalCounts,
|
missingCriticalCounts,
|
||||||
maxMissingCritical,
|
maxMissingCritical,
|
||||||
|
|||||||
@@ -1,31 +1,19 @@
|
|||||||
import { useState } from 'react'
|
|
||||||
import { InquiryQuickDialog } from '../../components/match-detail/InquiryQuickDialog'
|
import { InquiryQuickDialog } from '../../components/match-detail/InquiryQuickDialog'
|
||||||
import { Box, Button, Chip, CircularProgress, Paper, Typography } from '@mui/material'
|
import { Box, Button, Chip, CircularProgress, Paper, Typography } from '@mui/material'
|
||||||
import { AlertTriangle, ArrowLeft, ChevronDown, ChevronUp } from 'lucide-react'
|
import { AlertTriangle, ArrowLeft } from 'lucide-react'
|
||||||
import { useNavigate, useParams } from 'react-router'
|
import { useNavigate, useParams } from 'react-router'
|
||||||
import { useCompareStore } from '../../stores/compareStore'
|
import { useCompareStore } from '../../stores/compareStore'
|
||||||
import { usePipelineStore } from '../../stores/pipelineStore'
|
import { usePipelineStore } from '../../stores/pipelineStore'
|
||||||
import { useInquiryStore } from '../../stores/inquiryStore'
|
import { useInquiryStore } from '../../stores/inquiryStore'
|
||||||
import { AddToPipelineDialog } from '../../components/shortlist'
|
import { AddToPipelineDialog } from '../../components/shortlist'
|
||||||
import { MatchReasonList } from '../../components/match-card/MatchReasonList'
|
import { MatchReasonList } from '../../components/match-card/MatchReasonList'
|
||||||
import { PropertyMap } from '../../components/shared'
|
|
||||||
import { getCityIntelligence } from '../../lib/locationIntelligence'
|
import { getCityIntelligence } from '../../lib/locationIntelligence'
|
||||||
import {
|
import { NextActionsPanel } from '../../components/match-detail'
|
||||||
LocationIntelligencePanel,
|
|
||||||
ExecutiveSummaryPanel,
|
|
||||||
NeedAlignmentPanel,
|
|
||||||
ScoreBreakdownPanel,
|
|
||||||
TradeoffPanel,
|
|
||||||
RiskPanel,
|
|
||||||
MissingInformationPanel,
|
|
||||||
FutureAvailabilityContextPanel,
|
|
||||||
NextActionsPanel,
|
|
||||||
} from '../../components/match-detail'
|
|
||||||
import type { MatchCardReason } from '../../components/match-card/MatchCardViewModel'
|
import type { MatchCardReason } from '../../components/match-card/MatchCardViewModel'
|
||||||
import { useMatchDetailData } from '../../hooks/useMatchDetailData'
|
import { useMatchDetailData } from '../../hooks/useMatchDetailData'
|
||||||
import { useMatchDetail } from '../../hooks/useMatches'
|
import { useMatchDetail } from '../../hooks/useMatches'
|
||||||
import { MatchDetailHero } from '../../components/match-detail/MatchDetailHero'
|
import { MatchDetailHero } from '../../components/match-detail/MatchDetailHero'
|
||||||
import { MatchDetailPropertySections } from '../../components/match-detail/MatchDetailPropertySections'
|
import { MatchDetailScoreBreakdown } from '../../components/match-detail/MatchDetailScoreBreakdown'
|
||||||
import { RESULT_TYPE_META, DS_TEXT, DS_SURFACE, DS_BORDER, DS_BG } from '../../lib/ds'
|
import { RESULT_TYPE_META, DS_TEXT, DS_SURFACE, DS_BORDER, DS_BG } from '../../lib/ds'
|
||||||
|
|
||||||
// ── helpers ───────────────────────────────────────────────────────────────────
|
// ── helpers ───────────────────────────────────────────────────────────────────
|
||||||
@@ -49,7 +37,6 @@ export default function MatchDetail() {
|
|||||||
const { addToCompare } = useCompareStore()
|
const { addToCompare } = useCompareStore()
|
||||||
const { openSavedDialog } = usePipelineStore()
|
const { openSavedDialog } = usePipelineStore()
|
||||||
const { openInquiryDialog } = useInquiryStore()
|
const { openInquiryDialog } = useInquiryStore()
|
||||||
const [showFullAnalysis, setShowFullAnalysis] = useState(false)
|
|
||||||
|
|
||||||
const { match, property, need, signal, isLoading, isFuture } = useMatchDetailData(matchId ?? '')
|
const { match, property, need, signal, isLoading, isFuture } = useMatchDetailData(matchId ?? '')
|
||||||
|
|
||||||
@@ -91,7 +78,7 @@ export default function MatchDetail() {
|
|||||||
const handleCompare = () => {
|
const handleCompare = () => {
|
||||||
if (match && !isFuture && property) {
|
if (match && !isFuture && property) {
|
||||||
addToCompare({
|
addToCompare({
|
||||||
resultType: property.resultType ?? 'VERIFIED_PORTFOLIO',
|
resultType: (property.resultType ?? 'VERIFIED_PORTFOLIO') as 'VERIFIED_PORTFOLIO' | 'MAISON_WORK',
|
||||||
matchId: match.id, needId: match.needId, matchScore: match.matchScore, match, property,
|
matchId: match.id, needId: match.needId, matchScore: match.matchScore, match, property,
|
||||||
})
|
})
|
||||||
} else if (match && isFuture && signal) {
|
} else if (match && isFuture && signal) {
|
||||||
@@ -210,51 +197,15 @@ export default function MatchDetail() {
|
|||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* ── Full analysis toggle ── */}
|
{/* ── Full analysis toggle + expanded panels ── */}
|
||||||
<Button
|
<MatchDetailScoreBreakdown
|
||||||
variant="outlined"
|
match={match}
|
||||||
onClick={() => setShowFullAnalysis(v => !v)}
|
property={property}
|
||||||
fullWidth
|
need={need}
|
||||||
endIcon={showFullAnalysis ? <ChevronUp size={15} /> : <ChevronDown size={15} />}
|
signal={signal}
|
||||||
sx={{
|
isFuture={isFuture}
|
||||||
borderStyle: 'dashed', color: DS_TEXT.muted, borderColor: DS_BORDER.strong,
|
taxCalculatorUrl={taxCalculatorUrl}
|
||||||
textTransform: 'none', fontWeight: 500,
|
|
||||||
'&:hover': { borderColor: DS_TEXT.muted, bgcolor: DS_BG.subtle },
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{showFullAnalysis ? 'Weniger anzeigen' : 'Vollständige Analyse anzeigen'}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{/* ── Full analysis (hidden by default) ── */}
|
|
||||||
{showFullAnalysis && (
|
|
||||||
<>
|
|
||||||
<ExecutiveSummaryPanel match={match} />
|
|
||||||
<NeedAlignmentPanel match={match} need={need} property={property} />
|
|
||||||
{!isFuture && property && <MatchDetailPropertySections property={property} match={match} />}
|
|
||||||
<LocationIntelligencePanel property={property} />
|
|
||||||
{!isFuture && property?.images?.[0] && property?.location?.coordinates && (
|
|
||||||
<Paper sx={{ overflow: 'hidden', p: 0 }}>
|
|
||||||
<Box sx={{ px: 2.5, pt: 2, pb: 1 }}>
|
|
||||||
<Typography variant="h6" sx={{ fontWeight: 700 }}>Standort</Typography>
|
|
||||||
<Typography variant="caption" color="text.secondary">
|
|
||||||
{property.address.street} {property.address.houseNumber}, {property.address.postalCode} {property.address.city}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
<PropertyMap
|
|
||||||
lat={property.location.coordinates.lat}
|
|
||||||
lng={property.location.coordinates.lng}
|
|
||||||
label={property.title}
|
|
||||||
height={220}
|
|
||||||
/>
|
/>
|
||||||
</Paper>
|
|
||||||
)}
|
|
||||||
<TradeoffPanel match={match} />
|
|
||||||
<RiskPanel match={match} />
|
|
||||||
<MissingInformationPanel match={match} />
|
|
||||||
{isFuture && <FutureAvailabilityContextPanel match={match} signal={signal} />}
|
|
||||||
<ScoreBreakdownPanel match={match} taxCalculatorUrl={taxCalculatorUrl} isFuture={isFuture} signal={signal} />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { memo, useState, useEffect, useCallback, useMemo } from 'react'
|
|||||||
import { useNavigate } from 'react-router'
|
import { useNavigate } from 'react-router'
|
||||||
import {
|
import {
|
||||||
Alert, Box, Button, Chip, Dialog, DialogActions, DialogContent, DialogTitle,
|
Alert, Box, Button, Chip, Dialog, DialogActions, DialogContent, DialogTitle,
|
||||||
Divider, FormControl, FormControlLabel, IconButton,
|
Divider, FormControl, FormControlLabel,
|
||||||
InputLabel, MenuItem, Select as MuiSelect,
|
InputLabel, MenuItem, Select as MuiSelect,
|
||||||
Skeleton, Switch, Tab, Tabs, TextField, ToggleButton, ToggleButtonGroup,
|
Skeleton, Switch, Tab, Tabs, TextField, ToggleButton, ToggleButtonGroup,
|
||||||
Tooltip, Typography,
|
Tooltip, Typography,
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ export default function SupplyDashboard() {
|
|||||||
<Paper sx={{ p: 0, overflow: 'hidden', border: `1px solid ${DS_BORDER.default}` }}>
|
<Paper sx={{ p: 0, overflow: 'hidden', border: `1px solid ${DS_BORDER.default}` }}>
|
||||||
|
|
||||||
{/* KPI strip */}
|
{/* KPI strip */}
|
||||||
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr 1fr', md: 'repeat(3, 1fr)' }, divideX: true }}>
|
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr 1fr', md: 'repeat(3, 1fr)' } }}>
|
||||||
{/* Starke Matches */}
|
{/* Starke Matches */}
|
||||||
<Box sx={{ p: 2.5, borderRight: `1px solid ${DS_BORDER.default}`, bgcolor: strongMatchCount > 0 ? DS_SURFACE.success.bg : 'white' }}>
|
<Box sx={{ p: 2.5, borderRight: `1px solid ${DS_BORDER.default}`, bgcolor: strongMatchCount > 0 ? DS_SURFACE.success.bg : 'white' }}>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mb: 0.5 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mb: 0.5 }}>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ describe('futureSignalService', () => {
|
|||||||
const result = await futureSignalService.getAll()
|
const result = await futureSignalService.getAll()
|
||||||
expect(Array.isArray(result.data)).toBe(true)
|
expect(Array.isArray(result.data)).toBe(true)
|
||||||
expect(result.data.length).toBeGreaterThan(0)
|
expect(result.data.length).toBeGreaterThan(0)
|
||||||
expect(result.meta.total).toBe(result.data.length)
|
expect(result.meta!.total).toBe(result.data.length)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('each signal has required fields', async () => {
|
it('each signal has required fields', async () => {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ describe('matchService', () => {
|
|||||||
const result = await matchService.getAll()
|
const result = await matchService.getAll()
|
||||||
expect(Array.isArray(result.data)).toBe(true)
|
expect(Array.isArray(result.data)).toBe(true)
|
||||||
expect(result.data.length).toBeGreaterThan(0)
|
expect(result.data.length).toBeGreaterThan(0)
|
||||||
expect(result.meta.total).toBe(result.data.length)
|
expect(result.meta!.total).toBe(result.data.length)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('each match has required fields', async () => {
|
it('each match has required fields', async () => {
|
||||||
|
|||||||
@@ -7,9 +7,9 @@ describe('needService', () => {
|
|||||||
const result = await needService.getAll()
|
const result = await needService.getAll()
|
||||||
expect(Array.isArray(result.data)).toBe(true)
|
expect(Array.isArray(result.data)).toBe(true)
|
||||||
expect(result.data.length).toBeGreaterThan(0)
|
expect(result.data.length).toBeGreaterThan(0)
|
||||||
expect(result.meta.total).toBe(result.data.length)
|
expect(result.meta!.total).toBe(result.data.length)
|
||||||
expect(result.meta.page).toBe(1)
|
expect(result.meta!.page).toBe(1)
|
||||||
expect(result.meta.hasMore).toBe(false)
|
expect(result.meta!.hasMore).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('each need has required fields', async () => {
|
it('each need has required fields', async () => {
|
||||||
@@ -49,6 +49,9 @@ describe('needService', () => {
|
|||||||
preferredLocations: ['Zürich'],
|
preferredLocations: ['Zürich'],
|
||||||
budgetRange: { maxPerSqm: 400, maxMonthlyTotal: 10000, currency: 'CHF' as const },
|
budgetRange: { maxPerSqm: 400, maxMonthlyTotal: 10000, currency: 'CHF' as const },
|
||||||
organizationId: 'org-test',
|
organizationId: 'org-test',
|
||||||
|
timing: { earliestMoveIn: '2025-01-01', latestMoveIn: '2026-01-01', flexibleTiming: true },
|
||||||
|
weightingProfile: { area: 0.3, location: 0.3, budget: 0.2, timing: 0.1, prestige: 0, accessibility: 0, expansionPotential: 0, flexibility: 0, visibility: 0, footfall: 0, talentAccess: 0, esg: 0, taxEnvironment: 0 },
|
||||||
|
confidenceInCriteria: 0.8,
|
||||||
}
|
}
|
||||||
const result = await needService.create(input)
|
const result = await needService.create(input)
|
||||||
expect(result.data.companyName).toBe('Test GmbH')
|
expect(result.data.companyName).toBe('Test GmbH')
|
||||||
@@ -79,6 +82,9 @@ describe('needService', () => {
|
|||||||
preferredLocations: ['Bern'],
|
preferredLocations: ['Bern'],
|
||||||
budgetRange: { maxPerSqm: 300, maxMonthlyTotal: 5000, currency: 'CHF' as const },
|
budgetRange: { maxPerSqm: 300, maxMonthlyTotal: 5000, currency: 'CHF' as const },
|
||||||
organizationId: 'org-test',
|
organizationId: 'org-test',
|
||||||
|
timing: { earliestMoveIn: '2025-01-01', latestMoveIn: '2026-01-01', flexibleTiming: true },
|
||||||
|
weightingProfile: { area: 0.3, location: 0.3, budget: 0.2, timing: 0.1, prestige: 0, accessibility: 0, expansionPotential: 0, flexibility: 0, visibility: 0, footfall: 0, talentAccess: 0, esg: 0, taxEnvironment: 0 },
|
||||||
|
confidenceInCriteria: 0.8,
|
||||||
})
|
})
|
||||||
const id = created.data.id
|
const id = created.data.id
|
||||||
await needService.remove(id)
|
await needService.remove(id)
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ describe('propertyService', () => {
|
|||||||
const result = await propertyService.getAll()
|
const result = await propertyService.getAll()
|
||||||
expect(Array.isArray(result.data)).toBe(true)
|
expect(Array.isArray(result.data)).toBe(true)
|
||||||
expect(result.data.length).toBeGreaterThan(0)
|
expect(result.data.length).toBeGreaterThan(0)
|
||||||
expect(result.meta.total).toBe(result.data.length)
|
expect(result.meta!.total).toBe(result.data.length)
|
||||||
expect(result.meta.hasMore).toBe(false)
|
expect(result.meta!.hasMore).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('each property has required fields', async () => {
|
it('each property has required fields', async () => {
|
||||||
@@ -62,9 +62,14 @@ describe('propertyService', () => {
|
|||||||
assetType: 'LOGISTICS' as const,
|
assetType: 'LOGISTICS' as const,
|
||||||
resultType: 'VERIFIED_PORTFOLIO' as const,
|
resultType: 'VERIFIED_PORTFOLIO' as const,
|
||||||
location: { city: 'Basel', country: 'CH' as const },
|
location: { city: 'Basel', country: 'CH' as const },
|
||||||
|
address: { street: 'Teststr.', houseNumber: '1', postalCode: '4000', city: 'Basel', country: 'CH' },
|
||||||
areaSqm: 500,
|
areaSqm: 500,
|
||||||
rentPricePerSqm: 120,
|
rentPricePerSqm: 120,
|
||||||
|
availabilityDate: '2025-01-01',
|
||||||
availabilityStatus: 'AVAILABLE_NOW' as const,
|
availabilityStatus: 'AVAILABLE_NOW' as const,
|
||||||
|
sourceType: 'DIRECT',
|
||||||
|
confidenceScore: 0.8,
|
||||||
|
dataQuality: { score: 0.8, missingCriticalFields: [], missingOptionalFields: [], freshness: 'FRESH' as const, warnings: [] },
|
||||||
organizationId: 'org-test',
|
organizationId: 'org-test',
|
||||||
}
|
}
|
||||||
const result = await propertyService.create(input)
|
const result = await propertyService.create(input)
|
||||||
|
|||||||
@@ -73,8 +73,8 @@ describe('MockAIService.generateFollowUpQuestions', () => {
|
|||||||
assetType: 'OFFICE',
|
assetType: 'OFFICE',
|
||||||
areaRange: { min: 300, max: 600 },
|
areaRange: { min: 300, max: 600 },
|
||||||
preferredLocations: ['Zürich'],
|
preferredLocations: ['Zürich'],
|
||||||
budgetRange: { maxPerSqm: 400 },
|
budgetRange: { maxPerSqm: 400, currency: 'CHF' },
|
||||||
timing: { earliestMoveIn: '2025-09-01' },
|
timing: { earliestMoveIn: '2025-09-01', latestMoveIn: '2026-09-01', flexibleTiming: false },
|
||||||
mustHaveCriteria: ['ÖV-Anbindung'],
|
mustHaveCriteria: ['ÖV-Anbindung'],
|
||||||
}
|
}
|
||||||
const result = await run(MockAIService.generateFollowUpQuestions(criteria))
|
const result = await run(MockAIService.generateFollowUpQuestions(criteria))
|
||||||
@@ -116,7 +116,7 @@ describe('MockAIService provenance', () => {
|
|||||||
describe('MockAIService.summarizeTradeOffs', () => {
|
describe('MockAIService.summarizeTradeOffs', () => {
|
||||||
it('returns LOW risk when no HIGH severity trade-offs', async () => {
|
it('returns LOW risk when no HIGH severity trade-offs', async () => {
|
||||||
const tradeoffs: TradeOffInput[] = [
|
const tradeoffs: TradeOffInput[] = [
|
||||||
{ concern: 'Lage', severity: 'LOW', mitigation: 'Umgebung gut erschlossen' },
|
{ criterion: 'Lage', concern: 'Lage', severity: 'LOW', mitigation: 'Umgebung gut erschlossen' },
|
||||||
]
|
]
|
||||||
const result = await run(MockAIService.summarizeTradeOffs(tradeoffs))
|
const result = await run(MockAIService.summarizeTradeOffs(tradeoffs))
|
||||||
expect(result.data.overallRisk).toBe('LOW')
|
expect(result.data.overallRisk).toBe('LOW')
|
||||||
@@ -124,7 +124,7 @@ describe('MockAIService.summarizeTradeOffs', () => {
|
|||||||
|
|
||||||
it('returns MEDIUM risk when exactly 1 HIGH severity trade-off', async () => {
|
it('returns MEDIUM risk when exactly 1 HIGH severity trade-off', async () => {
|
||||||
const tradeoffs: TradeOffInput[] = [
|
const tradeoffs: TradeOffInput[] = [
|
||||||
{ concern: 'Fläche zu klein', severity: 'HIGH', mitigation: '' },
|
{ criterion: 'Fläche', concern: 'Fläche zu klein', severity: 'HIGH', mitigation: '' },
|
||||||
]
|
]
|
||||||
const result = await run(MockAIService.summarizeTradeOffs(tradeoffs))
|
const result = await run(MockAIService.summarizeTradeOffs(tradeoffs))
|
||||||
expect(result.data.overallRisk).toBe('MEDIUM')
|
expect(result.data.overallRisk).toBe('MEDIUM')
|
||||||
@@ -132,8 +132,8 @@ describe('MockAIService.summarizeTradeOffs', () => {
|
|||||||
|
|
||||||
it('returns HIGH risk when 2+ HIGH severity trade-offs', async () => {
|
it('returns HIGH risk when 2+ HIGH severity trade-offs', async () => {
|
||||||
const tradeoffs: TradeOffInput[] = [
|
const tradeoffs: TradeOffInput[] = [
|
||||||
{ concern: 'Fläche zu klein', severity: 'HIGH', mitigation: '' },
|
{ criterion: 'Fläche', concern: 'Fläche zu klein', severity: 'HIGH', mitigation: '' },
|
||||||
{ concern: 'Budget weit überschritten', severity: 'HIGH', mitigation: '' },
|
{ criterion: 'Budget', concern: 'Budget weit überschritten', severity: 'HIGH', mitigation: '' },
|
||||||
]
|
]
|
||||||
const result = await run(MockAIService.summarizeTradeOffs(tradeoffs))
|
const result = await run(MockAIService.summarizeTradeOffs(tradeoffs))
|
||||||
expect(result.data.overallRisk).toBe('HIGH')
|
expect(result.data.overallRisk).toBe('HIGH')
|
||||||
@@ -155,6 +155,7 @@ describe('MockAIService.generateMatchExplanation', () => {
|
|||||||
propertyCity: 'Zürich',
|
propertyCity: 'Zürich',
|
||||||
positiveFactors: [{ criterion: 'Lage', explanation: 'Zentrale Lage', weight: 0.3 }],
|
positiveFactors: [{ criterion: 'Lage', explanation: 'Zentrale Lage', weight: 0.3 }],
|
||||||
negativeFactors: [{ criterion: 'Preis', explanation: 'Etwas über Budget', weight: 0.2 }],
|
negativeFactors: [{ criterion: 'Preis', explanation: 'Etwas über Budget', weight: 0.2 }],
|
||||||
|
needSummary: 'Bürofläche 200–400 m² in Zürich',
|
||||||
}
|
}
|
||||||
|
|
||||||
it('generates STARK headline for score >= 78', async () => {
|
it('generates STARK headline for score >= 78', async () => {
|
||||||
|
|||||||
@@ -4,16 +4,11 @@ import type { UnifiedMatchResult } from '../../../domain/unifiedResult'
|
|||||||
import type {
|
import type {
|
||||||
IAIService,
|
IAIService,
|
||||||
AIResponse,
|
AIResponse,
|
||||||
DecisionBrief,
|
|
||||||
ComparisonSummary,
|
|
||||||
CriteriaExtractionResult,
|
|
||||||
OfferEmailPayload,
|
OfferEmailPayload,
|
||||||
MatchExplanationInput,
|
MatchExplanationInput,
|
||||||
MatchExplanation,
|
|
||||||
TradeOffInput,
|
TradeOffInput,
|
||||||
TradeOffSummary,
|
TradeOffSummary,
|
||||||
DataQualityInput,
|
DataQualityInput,
|
||||||
DataQualitySummary,
|
|
||||||
MarketSignalClassification,
|
MarketSignalClassification,
|
||||||
} from '../IAIService'
|
} from '../IAIService'
|
||||||
import { mockProvenance } from '../IAIService'
|
import { mockProvenance } from '../IAIService'
|
||||||
|
|||||||
@@ -316,7 +316,7 @@ export const OpenRouterAIService: IAIService = {
|
|||||||
preferredLocations: ai.preferredLocations,
|
preferredLocations: ai.preferredLocations,
|
||||||
budgetRange: ai.budgetRange ?? undefined,
|
budgetRange: ai.budgetRange ?? undefined,
|
||||||
timing: ai.timing
|
timing: ai.timing
|
||||||
? { ...ai.timing, latestMoveIn: ai.timing.latestMoveIn ?? undefined }
|
? { ...ai.timing, earliestMoveIn: ai.timing.earliestMoveIn ?? '', flexibleTiming: ai.timing.flexibleTiming ?? false }
|
||||||
: undefined,
|
: undefined,
|
||||||
mustHaveCriteria: ai.mustHaveCriteria,
|
mustHaveCriteria: ai.mustHaveCriteria,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -219,7 +219,7 @@ export const aiTraceStore = new AITraceStore()
|
|||||||
// window.__aiTraces.stats() → summary statistics
|
// window.__aiTraces.stats() → summary statistics
|
||||||
// window.__aiTraces.clear() → clear all
|
// window.__aiTraces.clear() → clear all
|
||||||
if (import.meta.env.DEV && typeof window !== 'undefined') {
|
if (import.meta.env.DEV && typeof window !== 'undefined') {
|
||||||
;(window as Record<string, unknown>).__aiTraces = aiTraceStore
|
;(window as unknown as Record<string, unknown>).__aiTraces = aiTraceStore
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -175,8 +175,8 @@ export const matchService = {
|
|||||||
rentPricePerSqm: p.rentPricePerSqm,
|
rentPricePerSqm: p.rentPricePerSqm,
|
||||||
matchScore: score,
|
matchScore: score,
|
||||||
imageUrl: p.images?.[0],
|
imageUrl: p.images?.[0],
|
||||||
reasons: (match?.positiveFactors ?? []).slice(0, 3).map(f => f.explanation ?? f.label ?? '').filter(Boolean),
|
reasons: (match?.positiveFactors ?? []).slice(0, 3).map(f => f.explanation ?? f.criterion ?? '').filter(Boolean),
|
||||||
topUncertainty: (match?.tradeoffs ?? match?.tradeOffs ?? [])[0]?.description
|
topUncertainty: (match?.tradeoffs ?? match?.tradeOffs ?? [])[0]?.concern
|
||||||
?? (match?.negativeFactors ?? [])[0]?.explanation
|
?? (match?.negativeFactors ?? [])[0]?.explanation
|
||||||
?? undefined,
|
?? undefined,
|
||||||
}))
|
}))
|
||||||
|
|||||||
@@ -6,22 +6,27 @@ const PROFILES: Record<string, WeightProfile> = {
|
|||||||
OFFICE: {
|
OFFICE: {
|
||||||
area: 0.20, location: 0.25, budget: 0.20, timing: 0.15,
|
area: 0.20, location: 0.25, budget: 0.20, timing: 0.15,
|
||||||
prestige: 0.10, accessibility: 0.05, expansionPotential: 0.03, flexibility: 0.02,
|
prestige: 0.10, accessibility: 0.05, expansionPotential: 0.03, flexibility: 0.02,
|
||||||
|
visibility: 0, talentAccess: 0, footfall: 0, esg: 0, taxEnvironment: 0,
|
||||||
},
|
},
|
||||||
LOGISTICS: {
|
LOGISTICS: {
|
||||||
area: 0.30, location: 0.20, budget: 0.20, timing: 0.15,
|
area: 0.30, location: 0.20, budget: 0.20, timing: 0.15,
|
||||||
prestige: 0.02, accessibility: 0.08, expansionPotential: 0.03, flexibility: 0.02,
|
prestige: 0.02, accessibility: 0.08, expansionPotential: 0.03, flexibility: 0.02,
|
||||||
|
visibility: 0, talentAccess: 0, footfall: 0, esg: 0, taxEnvironment: 0,
|
||||||
},
|
},
|
||||||
RETAIL: {
|
RETAIL: {
|
||||||
area: 0.15, location: 0.30, budget: 0.20, timing: 0.10,
|
area: 0.15, location: 0.30, budget: 0.20, timing: 0.10,
|
||||||
prestige: 0.12, accessibility: 0.08, expansionPotential: 0.03, flexibility: 0.02,
|
prestige: 0.12, accessibility: 0.08, expansionPotential: 0.03, flexibility: 0.02,
|
||||||
|
visibility: 0, talentAccess: 0, footfall: 0, esg: 0, taxEnvironment: 0,
|
||||||
},
|
},
|
||||||
PRODUCTION: {
|
PRODUCTION: {
|
||||||
area: 0.30, location: 0.20, budget: 0.20, timing: 0.15,
|
area: 0.30, location: 0.20, budget: 0.20, timing: 0.15,
|
||||||
prestige: 0.02, accessibility: 0.07, expansionPotential: 0.04, flexibility: 0.02,
|
prestige: 0.02, accessibility: 0.07, expansionPotential: 0.04, flexibility: 0.02,
|
||||||
|
visibility: 0, talentAccess: 0, footfall: 0, esg: 0, taxEnvironment: 0,
|
||||||
},
|
},
|
||||||
DEFAULT: {
|
DEFAULT: {
|
||||||
area: 0.25, location: 0.25, budget: 0.20, timing: 0.15,
|
area: 0.25, location: 0.25, budget: 0.20, timing: 0.15,
|
||||||
prestige: 0.07, accessibility: 0.05, expansionPotential: 0.02, flexibility: 0.01,
|
prestige: 0.07, accessibility: 0.05, expansionPotential: 0.02, flexibility: 0.01,
|
||||||
|
visibility: 0, talentAccess: 0, footfall: 0, esg: 0, taxEnvironment: 0,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"buildCommand": "npx vite build",
|
||||||
|
"outputDirectory": "dist",
|
||||||
|
"framework": null,
|
||||||
|
"rewrites": [{ "source": "/(.*)", "destination": "/index.html" }]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user