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": [
|
||||
"Bash(git commit -m ' *)",
|
||||
"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',
|
||||
alignItems: 'center',
|
||||
gap: 0.75,
|
||||
bgcolor: 'white',
|
||||
border: '1px solid',
|
||||
borderColor: a.generated ? '#bfdbfe' : '#cbd5e1',
|
||||
borderRadius: 1,
|
||||
|
||||
@@ -104,7 +104,7 @@ export function OfferCreationWizard({ inquiryId, propertyId, tenantName, onClose
|
||||
}
|
||||
|
||||
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 }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 700 }}>Angebot erstellen</Typography>
|
||||
<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"
|
||||
value={a.date}
|
||||
onChange={e => updateAppointment(a.id, 'date', e.target.value)}
|
||||
InputLabelProps={{ shrink: true }}
|
||||
slotProps={{ inputLabel: { shrink: true } }}
|
||||
sx={{ width: 160 }}
|
||||
/>
|
||||
<TextField
|
||||
@@ -228,7 +228,7 @@ export function OfferCreationWizard({ inquiryId, propertyId, tenantName, onClose
|
||||
progress={progress}
|
||||
ready={ready}
|
||||
draft={draft}
|
||||
property={property}
|
||||
property={property ?? undefined}
|
||||
onDownload={() => showToast('PDF wird heruntergeladen…', 'info')}
|
||||
onAttach={() => {
|
||||
const label = `Angebot_${property?.title ?? 'Objekt'}.pdf`
|
||||
|
||||
@@ -48,13 +48,15 @@ export function OfferWizard() {
|
||||
fullScreen={fullScreen}
|
||||
maxWidth="lg"
|
||||
fullWidth
|
||||
PaperProps={{
|
||||
slotProps={{
|
||||
paper: {
|
||||
sx: {
|
||||
height: fullScreen ? '100vh' : '85vh',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
} from '@mui/material'
|
||||
import { Download, Send, X } from 'lucide-react'
|
||||
import type { Inquiry } from '../../domain/inquiry'
|
||||
import type { Property } from '../../domain/property'
|
||||
import type { InquiryPreparationReportDraft, ReportObjectFieldSelection } from '../../domain/inquiryReport'
|
||||
import { useCreateInquiryReport, useUpdateInquiryReport, useFinalizeInquiryReport } from '../../hooks/useInquiryReport'
|
||||
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 selectedProperties = draft?.selectedPropertyIds
|
||||
.map(id => allProperties.find(p => p.id === id))
|
||||
.filter((p): p is Property => !!p) ?? []
|
||||
|
||||
const toggleProperty = (id: string) => {
|
||||
setSelectedIds(prev =>
|
||||
prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id],
|
||||
@@ -112,7 +107,7 @@ export function PreparationWizard({ inquiryId, inquiry, onClose }: Props) {
|
||||
}
|
||||
|
||||
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 }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 700 }}>Vorbereitung starten</Typography>
|
||||
<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) {
|
||||
const isSelected = (key: ReportObjectFieldKey) =>
|
||||
value.selectedOptionalFields.includes(key)
|
||||
@@ -115,7 +113,6 @@ export function ReportObjectFieldSelector({ propertyTitle, value, onChange }: Pr
|
||||
|
||||
{FIELD_GROUPS.map(group => {
|
||||
const groupKeys = group.fields.map(f => f.key)
|
||||
const allSelected = groupKeys.every(k => value.selectedOptionalFields.includes(k))
|
||||
return (
|
||||
<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 } }}>
|
||||
|
||||
@@ -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,
|
||||
Kanban,
|
||||
BellRing,
|
||||
Settings,
|
||||
} from 'lucide-react'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -68,6 +69,17 @@ export const WORKSPACE_CONFIG: Record<WorkspaceType, WorkspaceConfig> = {
|
||||
{ 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
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { memo } from 'react'
|
||||
import { Box, Button, Divider, Typography } from '@mui/material'
|
||||
import { AlertCircle, CheckCircle2, ShieldCheck } from 'lucide-react'
|
||||
import { useNavigate } from 'react-router'
|
||||
@@ -9,7 +10,7 @@ import type { MatchCardViewModel } from './MatchCardViewModel'
|
||||
|
||||
// ── Future Availability Card ──────────────────────────────────────────────────
|
||||
|
||||
export function FutureAvailabilityCard({ vm }: { vm: MatchCardViewModel }) {
|
||||
export const FutureAvailabilityCard = memo(function FutureAvailabilityCard({ vm }: { vm: MatchCardViewModel }) {
|
||||
const isControlled = vm.signalIsControlled ?? false
|
||||
const navigate = useNavigate()
|
||||
|
||||
@@ -243,4 +244,4 @@ export function FutureAvailabilityCard({ vm }: { vm: MatchCardViewModel }) {
|
||||
</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 }}>
|
||||
<AlertTriangle size={12} color="#d97706" style={{ marginTop: 2, flexShrink: 0 }} />
|
||||
<Typography sx={{ fontSize: '0.69rem', color: '#92400e', lineHeight: 1.3 }}>
|
||||
{vm.tradeoffs[0].description}
|
||||
{vm.tradeoffs[0].concern}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
@@ -11,6 +11,7 @@ export { MatchDataQualitySummary } from './MatchDataQualitySummary'
|
||||
export { MatchActionToolbar } from './MatchActionToolbar'
|
||||
export { MatchCardSkeleton } from './MatchCardSkeleton'
|
||||
export { MatchCardRestrictedState } from './MatchCardRestrictedState'
|
||||
export { FutureAvailabilityCard } from './FutureAvailabilityCard'
|
||||
export type {
|
||||
MatchCardViewModel,
|
||||
MatchCardVariant,
|
||||
|
||||
@@ -5,7 +5,7 @@ import { ResultType } from '../../domain/enums'
|
||||
import type { Property } from '../../domain/property'
|
||||
import { FLOOR_LABEL, ASSET_LABELS, RISK_LABELS, SOURCE_LABELS, PASSERBY_LABELS, KeyFactRow, UnitRow } from './MatchDetailPropertyDetails'
|
||||
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']>
|
||||
|
||||
|
||||
@@ -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,
|
||||
Info,
|
||||
Layers,
|
||||
ShieldCheck,
|
||||
Tag,
|
||||
Train,
|
||||
TrendingUp,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
export { LocationIntelligencePanel } from './LocationIntelligencePanel'
|
||||
export { MatchDetailHeader } from './MatchDetailHeader'
|
||||
export { MatchDetailHero } from './MatchDetailHero'
|
||||
export { MatchDetailScoreBreakdown } from './MatchDetailScoreBreakdown'
|
||||
export { ExecutiveSummaryPanel } from './ExecutiveSummaryPanel'
|
||||
export { PropertyOverviewPanel } from './PropertyOverviewPanel'
|
||||
export { NeedAlignmentPanel } from './NeedAlignmentPanel'
|
||||
|
||||
@@ -37,13 +37,13 @@ export function AreaDetailsSection({
|
||||
<TextField
|
||||
label="Fläche (m²)" value={areaSqm}
|
||||
onChange={e => onAreaSqmChange(e.target.value)}
|
||||
size="small" type="number" inputProps={{ min: 1 }} fullWidth
|
||||
size="small" type="number" slotProps={{ htmlInput: { min: 1 } }} fullWidth
|
||||
/>
|
||||
|
||||
<TextField
|
||||
label="Mietpreis (CHF/m²/Jahr)" value={rentPerSqm}
|
||||
onChange={e => onRentPerSqmChange(e.target.value)}
|
||||
size="small" type="number" inputProps={{ min: 1 }} fullWidth
|
||||
size="small" type="number" slotProps={{ htmlInput: { min: 1 } }} fullWidth
|
||||
/>
|
||||
|
||||
<TextField
|
||||
|
||||
@@ -38,12 +38,12 @@ export function TechnicalDetailsSection({
|
||||
<TextField
|
||||
label="Parkplätze" value={parking}
|
||||
onChange={e => onParkingChange(e.target.value)}
|
||||
size="small" type="number" inputProps={{ min: 0 }} fullWidth
|
||||
size="small" type="number" slotProps={{ htmlInput: { min: 0 } }} fullWidth
|
||||
/>
|
||||
<TextField
|
||||
label="Deckenhöhe (m)" value={ceilingHeight}
|
||||
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>
|
||||
</Card>
|
||||
|
||||
@@ -146,7 +146,6 @@ export function WelcomeDialog() {
|
||||
open={open}
|
||||
maxWidth="sm"
|
||||
fullWidth
|
||||
disableEscapeKeyDown
|
||||
>
|
||||
<DialogContent sx={{ pt: 4, pb: 2 }}>
|
||||
{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}
|
||||
maxWidth="md"
|
||||
fullWidth
|
||||
PaperProps={{ sx: { height: '88vh', display: 'flex', flexDirection: 'column', overflow: 'hidden' } }}
|
||||
slotProps={{ paper: { sx: { height: '88vh', display: 'flex', flexDirection: 'column', overflow: 'hidden' } } }}
|
||||
>
|
||||
{/* Header */}
|
||||
<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 { ExternalLink, FileText, Plus } from 'lucide-react'
|
||||
import { ExternalLink, FileText } from 'lucide-react'
|
||||
import type { Property, UpdatePropertyInput } from '../../domain/property'
|
||||
import { PropertyMap } from '../shared'
|
||||
import { getAssetTypeLabel, qualityColor } from './propertyHelpers'
|
||||
|
||||
@@ -20,7 +20,7 @@ import { ReminderPriorityBadge } from './ReminderPriorityBadge'
|
||||
import { ReminderTypeBadge } from './ReminderTypeBadge'
|
||||
import { ReminderDaysIndicator } from './ReminderDaysIndicator'
|
||||
import { ReminderStatus } from '../../domain/reminder'
|
||||
import { SHADOW_RISK_COLOR, SHADOW_RISK_LABEL, STATUS_CHIP_COLOR, STATUS_LABEL, 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() {
|
||||
const { selectedId, drawerOpen, setDrawerOpen, setSelectedId } = useReminderStore()
|
||||
|
||||
@@ -95,7 +95,7 @@ export function ReminderFeed() {
|
||||
if (viewMode === 'card') {
|
||||
return (
|
||||
<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]
|
||||
if (!group || group.length === 0) return null
|
||||
return (
|
||||
|
||||
@@ -3,6 +3,7 @@ import type {
|
||||
FreshnessStatus, RiskLevel, SourceType, DataQualityLevel,
|
||||
} 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
|
||||
export type { PropertyUnit, UnitBundle, UnitNeedMatch } from './unit'
|
||||
@@ -152,7 +153,7 @@ export interface Property {
|
||||
importedAt?: string
|
||||
lastUpdatedAt?: string
|
||||
|
||||
schattenmarktRelease?: { enabled: boolean; leadTimeMonths: number }
|
||||
schattenmarktRelease?: { enabled: boolean; leadTimeMonths?: number }
|
||||
|
||||
status?: 'ACTIVE' | 'INACTIVE' | 'DRAFT' | 'ARCHIVED'
|
||||
lastReviewedAt?: string
|
||||
|
||||
@@ -29,6 +29,9 @@ export interface FutureAvailabilityResult extends UnifiedResultBase {
|
||||
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 =
|
||||
| VerifiedPortfolioResult
|
||||
| FutureAvailabilityResult
|
||||
|
||||
@@ -44,9 +44,9 @@ describe('buildMatchCardViewModel — VERIFIED_PORTFOLIO', () => {
|
||||
|
||||
it('scoreBreakdown reflects hard and soft components from the engine', () => {
|
||||
const vm = buildMatchCardViewModel(result, [])
|
||||
expect(vm.scoreBreakdown.hardMatchScore).toBe(match.scoreBreakdown.hardMatchScore)
|
||||
expect(vm.scoreBreakdown.softFactorScore).toBe(match.scoreBreakdown.softFactorScore)
|
||||
expect(vm.scoreBreakdown.totalScore).toBe(match.scoreBreakdown.totalScore)
|
||||
expect(vm.scoreBreakdown!.hardMatchScore).toBe(match.scoreBreakdown.hardMatchScore)
|
||||
expect(vm.scoreBreakdown!.softFactorScore).toBe(match.scoreBreakdown.softFactorScore)
|
||||
expect(vm.scoreBreakdown!.totalScore).toBe(match.scoreBreakdown.totalScore)
|
||||
})
|
||||
|
||||
it('does NOT set a disclaimer for verified portfolio results', () => {
|
||||
@@ -65,7 +65,7 @@ describe('buildMatchCardViewModel — VERIFIED_PORTFOLIO', () => {
|
||||
})
|
||||
|
||||
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)
|
||||
expect(vm.actions).toBe(actions)
|
||||
})
|
||||
|
||||
@@ -61,6 +61,9 @@ describe('useCreateNeed', () => {
|
||||
preferredLocations: ['Zürich'],
|
||||
budgetRange: { maxPerSqm: 400, maxMonthlyTotal: 10000, currency: 'CHF' },
|
||||
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))
|
||||
expect(result.current.data?.data.companyName).toBe('Hook Test GmbH')
|
||||
|
||||
@@ -71,9 +71,14 @@ describe('useCreateProperty', () => {
|
||||
assetType: 'LOGISTICS',
|
||||
resultType: 'VERIFIED_PORTFOLIO',
|
||||
location: { city: 'Bern', country: 'CH' },
|
||||
address: { street: 'Teststrasse', houseNumber: '1', postalCode: '3000', city: 'Bern', country: 'CH' },
|
||||
areaSqm: 300,
|
||||
rentPricePerSqm: 100,
|
||||
availabilityDate: '2025-01-01',
|
||||
availabilityStatus: 'AVAILABLE_NOW',
|
||||
sourceType: 'DIRECT',
|
||||
confidenceScore: 0.8,
|
||||
dataQuality: { score: 0.8, missingCriticalFields: [], missingOptionalFields: [], freshness: 'FRESH', warnings: [] },
|
||||
organizationId: 'org-test',
|
||||
})
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useMatchDetail } from './useMatches'
|
||||
import { propertyService } from '../services/propertyService'
|
||||
import { needService } from '../services/needService'
|
||||
import { futureSignalService } from '../services/futureSignalService'
|
||||
import type { ItemResponse } from '../services/types'
|
||||
import type { Property } from '../domain/property'
|
||||
import type { Need } from '../domain/need'
|
||||
import type { FutureSignal } from '../domain/futureSignal'
|
||||
@@ -13,14 +14,14 @@ export function useMatchDetailData(matchId: string) {
|
||||
const { data: match, isLoading } = useMatchDetail(matchId)
|
||||
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],
|
||||
queryFn: () => propertyService.getById(match!.propertyId),
|
||||
enabled: !!match && !isFuture,
|
||||
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],
|
||||
queryFn: () => needService.getById(match!.needId),
|
||||
enabled: !!match?.needId,
|
||||
|
||||
@@ -13,6 +13,7 @@ interface PropertyFilter {
|
||||
minAreaSqm?: number
|
||||
maxRentPerSqm?: number
|
||||
organizationId?: string
|
||||
sourceType?: string
|
||||
}
|
||||
|
||||
export function useProperties(filter?: PropertyFilter) {
|
||||
|
||||
@@ -22,13 +22,13 @@ export function useSchattenmarktSignals(properties: Property[]): FutureSignal[]
|
||||
for (const unit of releasedUnits) {
|
||||
const availableFrom = unit.schattenmarktRelease?.availableFrom ?? unit.leaseEndDate ?? p.leaseEndDate
|
||||
if (!availableFrom) continue
|
||||
const triggerDate = getTriggerDate(availableFrom, rel.leadTimeMonths)
|
||||
const triggerDate = getTriggerDate(availableFrom, rel.leadTimeMonths ?? 0)
|
||||
if (MOCK_TODAY < triggerDate) continue
|
||||
signals.push(buildUnitSignal(p, unit, availableFrom))
|
||||
}
|
||||
} else {
|
||||
// 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
|
||||
signals.push(buildPropertySignal(p))
|
||||
}
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import { useMemo } from 'react'
|
||||
import { useMatches, useMatchesByNeed } from './useMatches'
|
||||
import { useMatchesByNeed } from './useMatches'
|
||||
import { useProperties } from './useProperties'
|
||||
import { useFutureSignals } from './useFutureSignals'
|
||||
import { useSchattenmarktSignals } from './useSchattenmarktSignals'
|
||||
import type {
|
||||
UnifiedMatchResult,
|
||||
VerifiedPortfolioResult,
|
||||
FutureAvailabilityResult,
|
||||
} from '../domain/unifiedResult'
|
||||
|
||||
export function useUnifiedResults(needId?: string) {
|
||||
|
||||
@@ -67,6 +67,10 @@ const WORKSPACE_ROLES: Record<WorkspaceType, UserRole[]> = {
|
||||
UserRole.PROPERTY_MANAGER,
|
||||
UserRole.DEMAND_USER,
|
||||
],
|
||||
[WorkspaceType.OPERATIONS]: [
|
||||
UserRole.SUPER_ADMIN,
|
||||
UserRole.ORGANIZATION_ADMIN,
|
||||
],
|
||||
}
|
||||
|
||||
// ── Core Functions ────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -608,7 +608,7 @@ export const mockProperties: Property[] = [
|
||||
prestige: 56,
|
||||
accessibility: 88,
|
||||
visibilityScore: 72,
|
||||
passerbyFrequency: 'MEDIUM_HIGH',
|
||||
passerbyFrequency: 'HIGH',
|
||||
talentAccess: 65,
|
||||
parkingSpots: 1,
|
||||
publicTransportMinutes: 5,
|
||||
@@ -830,7 +830,7 @@ export const mockProperties: Property[] = [
|
||||
prestige: 60,
|
||||
accessibility: 88,
|
||||
visibilityScore: 75,
|
||||
passerbyFrequency: 'MEDIUM_HIGH',
|
||||
passerbyFrequency: 'HIGH',
|
||||
talentAccess: 70,
|
||||
parkingSpots: 2,
|
||||
publicTransportMinutes: 5,
|
||||
@@ -2017,7 +2017,7 @@ export const mockProperties: Property[] = [
|
||||
prestige: 48,
|
||||
accessibility: 78,
|
||||
visibilityScore: 68,
|
||||
passerbyFrequency: 'MEDIUM_HIGH',
|
||||
passerbyFrequency: 'HIGH',
|
||||
talentAccess: 60,
|
||||
parkingSpots: 2,
|
||||
publicTransportMinutes: 5,
|
||||
@@ -2230,7 +2230,7 @@ export const mockProperties: Property[] = [
|
||||
prestige: 58,
|
||||
accessibility: 85,
|
||||
visibilityScore: 70,
|
||||
passerbyFrequency: 'MEDIUM_HIGH',
|
||||
passerbyFrequency: 'HIGH',
|
||||
talentAccess: 65,
|
||||
parkingSpots: 2,
|
||||
publicTransportMinutes: 5,
|
||||
@@ -2373,7 +2373,7 @@ export const mockProperties: Property[] = [
|
||||
prestige: 55,
|
||||
accessibility: 82,
|
||||
visibilityScore: 70,
|
||||
passerbyFrequency: 'MEDIUM_HIGH',
|
||||
passerbyFrequency: 'HIGH',
|
||||
talentAccess: 65,
|
||||
parkingSpots: 0,
|
||||
publicTransportMinutes: 5,
|
||||
@@ -3453,7 +3453,7 @@ export const mockProperties: Property[] = [
|
||||
prestige: 85,
|
||||
accessibility: 95,
|
||||
visibilityScore: 88,
|
||||
passerbyFrequency: 'MEDIUM_HIGH',
|
||||
passerbyFrequency: 'HIGH',
|
||||
talentAccess: 76,
|
||||
parkingSpots: 0,
|
||||
publicTransportMinutes: 3,
|
||||
@@ -4586,7 +4586,7 @@ export const mockProperties: Property[] = [
|
||||
availabilityStatus: AvailabilityStatus.AVAILABLE_SOON,
|
||||
sourceType: 'DIRECT',
|
||||
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 },
|
||||
hardFacts: { floor: 0, fitOut: 'BASIC', hasStorefront: true },
|
||||
floorLevel: 0,
|
||||
@@ -4640,7 +4640,7 @@ export const mockProperties: Property[] = [
|
||||
availabilityStatus: AvailabilityStatus.AVAILABLE_SOON,
|
||||
sourceType: 'DIRECT',
|
||||
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 },
|
||||
hardFacts: { floor: 1, fitOut: 'BASIC', parking: 8 },
|
||||
floorLevel: 1,
|
||||
|
||||
@@ -8,7 +8,6 @@ import { Search, Send, Paperclip, ArrowLeft, Bot, Building2, Kanban } from 'luci
|
||||
import { mockDemandInquiries } from '../../mock-data/demandInquiries'
|
||||
import { useInquiryStore } from '../../stores/inquiryStore'
|
||||
import { usePipelineItems, useMoveStage } from '../../hooks/usePipeline'
|
||||
import { useToastStore } from '../../stores/toastStore'
|
||||
import type { InquiryMessage } from '../../domain/inquiry'
|
||||
import { STAGE_ORDER, STAGE_LABELS, detectKiStage } from './anfragenKiDetection'
|
||||
import { AnfragenMessageBubble } from '../../components/demand/AnfragenMessageBubble'
|
||||
@@ -32,7 +31,7 @@ export default function Anfragen() {
|
||||
const [searchParams] = useSearchParams()
|
||||
const { data: pipelineItems = [] } = usePipelineItems()
|
||||
const { mutate: moveStage } = useMoveStage()
|
||||
const showToast = useToastStore(s => s.showToast)
|
||||
|
||||
|
||||
const preselectedId = searchParams.get('inquiry')
|
||||
|
||||
@@ -156,7 +155,7 @@ export default function Anfragen() {
|
||||
<TextField
|
||||
size="small" placeholder="Suchen..." fullWidth
|
||||
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 }}
|
||||
/>
|
||||
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap' }}>
|
||||
|
||||
@@ -41,7 +41,6 @@ export default function Compare() {
|
||||
overallWinnerIdx,
|
||||
bestScoreIdx,
|
||||
worstConfIdx,
|
||||
dqScores,
|
||||
worstDQIdx,
|
||||
missingCriticalCounts,
|
||||
maxMissingCritical,
|
||||
|
||||
@@ -1,31 +1,19 @@
|
||||
import { useState } from 'react'
|
||||
import { InquiryQuickDialog } from '../../components/match-detail/InquiryQuickDialog'
|
||||
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 { useCompareStore } from '../../stores/compareStore'
|
||||
import { usePipelineStore } from '../../stores/pipelineStore'
|
||||
import { useInquiryStore } from '../../stores/inquiryStore'
|
||||
import { AddToPipelineDialog } from '../../components/shortlist'
|
||||
import { MatchReasonList } from '../../components/match-card/MatchReasonList'
|
||||
import { PropertyMap } from '../../components/shared'
|
||||
import { getCityIntelligence } from '../../lib/locationIntelligence'
|
||||
import {
|
||||
LocationIntelligencePanel,
|
||||
ExecutiveSummaryPanel,
|
||||
NeedAlignmentPanel,
|
||||
ScoreBreakdownPanel,
|
||||
TradeoffPanel,
|
||||
RiskPanel,
|
||||
MissingInformationPanel,
|
||||
FutureAvailabilityContextPanel,
|
||||
NextActionsPanel,
|
||||
} from '../../components/match-detail'
|
||||
import { NextActionsPanel } from '../../components/match-detail'
|
||||
import type { MatchCardReason } from '../../components/match-card/MatchCardViewModel'
|
||||
import { useMatchDetailData } from '../../hooks/useMatchDetailData'
|
||||
import { useMatchDetail } from '../../hooks/useMatches'
|
||||
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'
|
||||
|
||||
// ── helpers ───────────────────────────────────────────────────────────────────
|
||||
@@ -49,7 +37,6 @@ export default function MatchDetail() {
|
||||
const { addToCompare } = useCompareStore()
|
||||
const { openSavedDialog } = usePipelineStore()
|
||||
const { openInquiryDialog } = useInquiryStore()
|
||||
const [showFullAnalysis, setShowFullAnalysis] = useState(false)
|
||||
|
||||
const { match, property, need, signal, isLoading, isFuture } = useMatchDetailData(matchId ?? '')
|
||||
|
||||
@@ -91,7 +78,7 @@ export default function MatchDetail() {
|
||||
const handleCompare = () => {
|
||||
if (match && !isFuture && property) {
|
||||
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,
|
||||
})
|
||||
} else if (match && isFuture && signal) {
|
||||
@@ -210,51 +197,15 @@ export default function MatchDetail() {
|
||||
})}
|
||||
/>
|
||||
|
||||
{/* ── 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}
|
||||
{/* ── Full analysis toggle + expanded panels ── */}
|
||||
<MatchDetailScoreBreakdown
|
||||
match={match}
|
||||
property={property}
|
||||
need={need}
|
||||
signal={signal}
|
||||
isFuture={isFuture}
|
||||
taxCalculatorUrl={taxCalculatorUrl}
|
||||
/>
|
||||
</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>
|
||||
)
|
||||
|
||||
@@ -2,7 +2,7 @@ import { memo, useState, useEffect, useCallback, useMemo } from 'react'
|
||||
import { useNavigate } from 'react-router'
|
||||
import {
|
||||
Alert, Box, Button, Chip, Dialog, DialogActions, DialogContent, DialogTitle,
|
||||
Divider, FormControl, FormControlLabel, IconButton,
|
||||
Divider, FormControl, FormControlLabel,
|
||||
InputLabel, MenuItem, Select as MuiSelect,
|
||||
Skeleton, Switch, Tab, Tabs, TextField, ToggleButton, ToggleButtonGroup,
|
||||
Tooltip, Typography,
|
||||
|
||||
@@ -53,7 +53,7 @@ export default function SupplyDashboard() {
|
||||
<Paper sx={{ p: 0, overflow: 'hidden', border: `1px solid ${DS_BORDER.default}` }}>
|
||||
|
||||
{/* 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 */}
|
||||
<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 }}>
|
||||
|
||||
@@ -7,7 +7,7 @@ describe('futureSignalService', () => {
|
||||
const result = await futureSignalService.getAll()
|
||||
expect(Array.isArray(result.data)).toBe(true)
|
||||
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 () => {
|
||||
|
||||
@@ -7,7 +7,7 @@ describe('matchService', () => {
|
||||
const result = await matchService.getAll()
|
||||
expect(Array.isArray(result.data)).toBe(true)
|
||||
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 () => {
|
||||
|
||||
@@ -7,9 +7,9 @@ describe('needService', () => {
|
||||
const result = await needService.getAll()
|
||||
expect(Array.isArray(result.data)).toBe(true)
|
||||
expect(result.data.length).toBeGreaterThan(0)
|
||||
expect(result.meta.total).toBe(result.data.length)
|
||||
expect(result.meta.page).toBe(1)
|
||||
expect(result.meta.hasMore).toBe(false)
|
||||
expect(result.meta!.total).toBe(result.data.length)
|
||||
expect(result.meta!.page).toBe(1)
|
||||
expect(result.meta!.hasMore).toBe(false)
|
||||
})
|
||||
|
||||
it('each need has required fields', async () => {
|
||||
@@ -49,6 +49,9 @@ describe('needService', () => {
|
||||
preferredLocations: ['Zürich'],
|
||||
budgetRange: { maxPerSqm: 400, maxMonthlyTotal: 10000, currency: 'CHF' as const },
|
||||
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)
|
||||
expect(result.data.companyName).toBe('Test GmbH')
|
||||
@@ -79,6 +82,9 @@ describe('needService', () => {
|
||||
preferredLocations: ['Bern'],
|
||||
budgetRange: { maxPerSqm: 300, maxMonthlyTotal: 5000, currency: 'CHF' as const },
|
||||
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
|
||||
await needService.remove(id)
|
||||
|
||||
@@ -7,8 +7,8 @@ describe('propertyService', () => {
|
||||
const result = await propertyService.getAll()
|
||||
expect(Array.isArray(result.data)).toBe(true)
|
||||
expect(result.data.length).toBeGreaterThan(0)
|
||||
expect(result.meta.total).toBe(result.data.length)
|
||||
expect(result.meta.hasMore).toBe(false)
|
||||
expect(result.meta!.total).toBe(result.data.length)
|
||||
expect(result.meta!.hasMore).toBe(false)
|
||||
})
|
||||
|
||||
it('each property has required fields', async () => {
|
||||
@@ -62,9 +62,14 @@ describe('propertyService', () => {
|
||||
assetType: 'LOGISTICS' as const,
|
||||
resultType: 'VERIFIED_PORTFOLIO' as const,
|
||||
location: { city: 'Basel', country: 'CH' as const },
|
||||
address: { street: 'Teststr.', houseNumber: '1', postalCode: '4000', city: 'Basel', country: 'CH' },
|
||||
areaSqm: 500,
|
||||
rentPricePerSqm: 120,
|
||||
availabilityDate: '2025-01-01',
|
||||
availabilityStatus: 'AVAILABLE_NOW' as const,
|
||||
sourceType: 'DIRECT',
|
||||
confidenceScore: 0.8,
|
||||
dataQuality: { score: 0.8, missingCriticalFields: [], missingOptionalFields: [], freshness: 'FRESH' as const, warnings: [] },
|
||||
organizationId: 'org-test',
|
||||
}
|
||||
const result = await propertyService.create(input)
|
||||
|
||||
@@ -73,8 +73,8 @@ describe('MockAIService.generateFollowUpQuestions', () => {
|
||||
assetType: 'OFFICE',
|
||||
areaRange: { min: 300, max: 600 },
|
||||
preferredLocations: ['Zürich'],
|
||||
budgetRange: { maxPerSqm: 400 },
|
||||
timing: { earliestMoveIn: '2025-09-01' },
|
||||
budgetRange: { maxPerSqm: 400, currency: 'CHF' },
|
||||
timing: { earliestMoveIn: '2025-09-01', latestMoveIn: '2026-09-01', flexibleTiming: false },
|
||||
mustHaveCriteria: ['ÖV-Anbindung'],
|
||||
}
|
||||
const result = await run(MockAIService.generateFollowUpQuestions(criteria))
|
||||
@@ -116,7 +116,7 @@ describe('MockAIService provenance', () => {
|
||||
describe('MockAIService.summarizeTradeOffs', () => {
|
||||
it('returns LOW risk when no HIGH severity trade-offs', async () => {
|
||||
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))
|
||||
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 () => {
|
||||
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))
|
||||
expect(result.data.overallRisk).toBe('MEDIUM')
|
||||
@@ -132,8 +132,8 @@ describe('MockAIService.summarizeTradeOffs', () => {
|
||||
|
||||
it('returns HIGH risk when 2+ HIGH severity trade-offs', async () => {
|
||||
const tradeoffs: TradeOffInput[] = [
|
||||
{ concern: 'Fläche zu klein', severity: 'HIGH', mitigation: '' },
|
||||
{ concern: 'Budget weit überschritten', severity: 'HIGH', mitigation: '' },
|
||||
{ criterion: 'Fläche', concern: 'Fläche zu klein', severity: 'HIGH', mitigation: '' },
|
||||
{ criterion: 'Budget', concern: 'Budget weit überschritten', severity: 'HIGH', mitigation: '' },
|
||||
]
|
||||
const result = await run(MockAIService.summarizeTradeOffs(tradeoffs))
|
||||
expect(result.data.overallRisk).toBe('HIGH')
|
||||
@@ -155,6 +155,7 @@ describe('MockAIService.generateMatchExplanation', () => {
|
||||
propertyCity: 'Zürich',
|
||||
positiveFactors: [{ criterion: 'Lage', explanation: 'Zentrale Lage', weight: 0.3 }],
|
||||
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 () => {
|
||||
|
||||
@@ -4,16 +4,11 @@ import type { UnifiedMatchResult } from '../../../domain/unifiedResult'
|
||||
import type {
|
||||
IAIService,
|
||||
AIResponse,
|
||||
DecisionBrief,
|
||||
ComparisonSummary,
|
||||
CriteriaExtractionResult,
|
||||
OfferEmailPayload,
|
||||
MatchExplanationInput,
|
||||
MatchExplanation,
|
||||
TradeOffInput,
|
||||
TradeOffSummary,
|
||||
DataQualityInput,
|
||||
DataQualitySummary,
|
||||
MarketSignalClassification,
|
||||
} from '../IAIService'
|
||||
import { mockProvenance } from '../IAIService'
|
||||
|
||||
@@ -316,7 +316,7 @@ export const OpenRouterAIService: IAIService = {
|
||||
preferredLocations: ai.preferredLocations,
|
||||
budgetRange: ai.budgetRange ?? undefined,
|
||||
timing: ai.timing
|
||||
? { ...ai.timing, latestMoveIn: ai.timing.latestMoveIn ?? undefined }
|
||||
? { ...ai.timing, earliestMoveIn: ai.timing.earliestMoveIn ?? '', flexibleTiming: ai.timing.flexibleTiming ?? false }
|
||||
: undefined,
|
||||
mustHaveCriteria: ai.mustHaveCriteria,
|
||||
}
|
||||
|
||||
@@ -219,7 +219,7 @@ export const aiTraceStore = new AITraceStore()
|
||||
// window.__aiTraces.stats() → summary statistics
|
||||
// window.__aiTraces.clear() → clear all
|
||||
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 ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -175,8 +175,8 @@ export const matchService = {
|
||||
rentPricePerSqm: p.rentPricePerSqm,
|
||||
matchScore: score,
|
||||
imageUrl: p.images?.[0],
|
||||
reasons: (match?.positiveFactors ?? []).slice(0, 3).map(f => f.explanation ?? f.label ?? '').filter(Boolean),
|
||||
topUncertainty: (match?.tradeoffs ?? match?.tradeOffs ?? [])[0]?.description
|
||||
reasons: (match?.positiveFactors ?? []).slice(0, 3).map(f => f.explanation ?? f.criterion ?? '').filter(Boolean),
|
||||
topUncertainty: (match?.tradeoffs ?? match?.tradeOffs ?? [])[0]?.concern
|
||||
?? (match?.negativeFactors ?? [])[0]?.explanation
|
||||
?? undefined,
|
||||
}))
|
||||
|
||||
@@ -6,22 +6,27 @@ const PROFILES: Record<string, WeightProfile> = {
|
||||
OFFICE: {
|
||||
area: 0.20, location: 0.25, budget: 0.20, timing: 0.15,
|
||||
prestige: 0.10, accessibility: 0.05, expansionPotential: 0.03, flexibility: 0.02,
|
||||
visibility: 0, talentAccess: 0, footfall: 0, esg: 0, taxEnvironment: 0,
|
||||
},
|
||||
LOGISTICS: {
|
||||
area: 0.30, location: 0.20, budget: 0.20, timing: 0.15,
|
||||
prestige: 0.02, accessibility: 0.08, expansionPotential: 0.03, flexibility: 0.02,
|
||||
visibility: 0, talentAccess: 0, footfall: 0, esg: 0, taxEnvironment: 0,
|
||||
},
|
||||
RETAIL: {
|
||||
area: 0.15, location: 0.30, budget: 0.20, timing: 0.10,
|
||||
prestige: 0.12, accessibility: 0.08, expansionPotential: 0.03, flexibility: 0.02,
|
||||
visibility: 0, talentAccess: 0, footfall: 0, esg: 0, taxEnvironment: 0,
|
||||
},
|
||||
PRODUCTION: {
|
||||
area: 0.30, location: 0.20, budget: 0.20, timing: 0.15,
|
||||
prestige: 0.02, accessibility: 0.07, expansionPotential: 0.04, flexibility: 0.02,
|
||||
visibility: 0, talentAccess: 0, footfall: 0, esg: 0, taxEnvironment: 0,
|
||||
},
|
||||
DEFAULT: {
|
||||
area: 0.25, location: 0.25, budget: 0.20, timing: 0.15,
|
||||
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