feat: role-aware UX, market intelligence, score fix & layout scroll
- Role-based workspace access: Property Manager gets Supply+Demand, Operations restricted to Super Admin + Reviewer only - Root redirect routes each persona to their first workspace - Match Center: replaced 3-panel with auto-sorted flat list + drawer - AI Search: unified form with dual-action (Jetzt suchen / Als Suchprofil speichern) - CompareTray hidden on non-Demand routes; Vergleichen removed from Supply - LocationIntelligencePanel: city KPIs, rent trends, soft factors, comparables - NegotiationInsightsPanel: price positioning, active demand, selling arguments - scoreCalculator: cap hardMatchScore and softFactorScore to max 100 - Layout: add display:flex to overflow:hidden wrappers so inner scroll works (MatchCenter drawer, MarketIntelligence, SourceMonitoring, SignalPipeline) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,9 +1,127 @@
|
||||
import type { INeedProvider, NeedFilters } from './INeedProvider'
|
||||
import type { Need, CreateNeedInput, UpdateNeedInput } from '../domain/need'
|
||||
import { mockNeeds } from '../mock-data/needs'
|
||||
import { matchStore } from './MockupMatchProvider'
|
||||
import { propertyStore } from './MockupPropertyProvider'
|
||||
import { MatchStrength, MatchStatus, RiskLevel } from '../domain/enums'
|
||||
import type { Match } from '../domain/match'
|
||||
|
||||
const store: Need[] = [...mockNeeds]
|
||||
|
||||
// ── Location scoring ───────────────────────────────────────────────────────────
|
||||
|
||||
const CANTON_MAP: Record<string, string> = {
|
||||
zürich: 'zh', zug: 'zg', winterthur: 'zh', uster: 'zh', bülach: 'zh', oerlikon: 'zh',
|
||||
bern: 'be', biel: 'be', thun: 'be', köniz: 'be',
|
||||
basel: 'bs', muttenz: 'bl', pratteln: 'bl', reinach: 'bl', allschwil: 'bl', binningen: 'bl',
|
||||
genf: 'ge', genève: 'ge', carouge: 'ge', lancy: 'ge',
|
||||
'st. gallen': 'sg', 'st.gallen': 'sg', rapperswil: 'sg',
|
||||
}
|
||||
|
||||
function locationScore(propCity: string, preferredLocations: string[]): number {
|
||||
const pc = propCity.toLowerCase()
|
||||
for (const pref of preferredLocations) {
|
||||
const p = pref.toLowerCase()
|
||||
if (pc.includes(p) || p.includes(pc)) return 1.0
|
||||
}
|
||||
// Same canton check
|
||||
const propCanton = CANTON_MAP[pc]
|
||||
if (propCanton) {
|
||||
for (const pref of preferredLocations) {
|
||||
const prefCanton = CANTON_MAP[pref.toLowerCase()]
|
||||
if (prefCanton && prefCanton === propCanton) return 0.55
|
||||
}
|
||||
}
|
||||
return 0.30
|
||||
}
|
||||
|
||||
function computeScore(prop: { assetType: string; areaSqm: number; rentPricePerSqm: number; location: { city: string } }, need: Need): number | null {
|
||||
if (need.assetType && prop.assetType !== need.assetType) return null
|
||||
|
||||
const locScore = locationScore(prop.location.city, need.preferredLocations ?? [])
|
||||
|
||||
// Location dominates: same city → 50-90 base, different → 25-45
|
||||
let score = locScore >= 0.9 ? 65 : locScore >= 0.5 ? 42 : 28
|
||||
|
||||
// Area overlap (+0-20)
|
||||
if (need.requiredArea && prop.areaSqm) {
|
||||
const { min, max } = need.requiredArea
|
||||
if (prop.areaSqm >= min && prop.areaSqm <= max) score += 20
|
||||
else if (prop.areaSqm >= min * 0.7 && prop.areaSqm <= max * 1.5) score += 10
|
||||
else if (prop.areaSqm < min * 0.5 || prop.areaSqm > max * 2) score -= 10
|
||||
}
|
||||
|
||||
// Budget fit (+0-10)
|
||||
if (need.budgetRange?.maxPerSqm && prop.rentPricePerSqm) {
|
||||
if (prop.rentPricePerSqm <= need.budgetRange.maxPerSqm) score += 10
|
||||
else if (prop.rentPricePerSqm <= need.budgetRange.maxPerSqm * 1.2) score += 3
|
||||
else score -= 8
|
||||
}
|
||||
|
||||
// Small jitter so results look natural
|
||||
score += Math.floor(Math.random() * 6) - 2
|
||||
|
||||
return Math.min(97, Math.max(22, score))
|
||||
}
|
||||
|
||||
function strengthFromScore(s: number): string {
|
||||
if (s >= 75) return MatchStrength.STRONG
|
||||
if (s >= 55) return MatchStrength.MODERATE
|
||||
return MatchStrength.WEAK
|
||||
}
|
||||
|
||||
function generateSyntheticMatches(need: Need) {
|
||||
const now = new Date().toISOString()
|
||||
|
||||
for (const prop of propertyStore) {
|
||||
const score = computeScore(prop, need)
|
||||
if (score === null || score < 25) continue
|
||||
|
||||
const locS = locationScore(prop.location.city, need.preferredLocations ?? [])
|
||||
const isGoodLoc = locS >= 0.9
|
||||
|
||||
const match: Match = {
|
||||
id: crypto.randomUUID(),
|
||||
propertyId: prop.id,
|
||||
needId: need.id,
|
||||
resultId: prop.id,
|
||||
resultType: prop.resultType ?? 'VERIFIED_PORTFOLIO',
|
||||
matchScore: score,
|
||||
matchStrength: strengthFromScore(score) as typeof MatchStrength[keyof typeof MatchStrength],
|
||||
status: score >= 75 ? MatchStatus.PENDING_REVIEW : MatchStatus.PENDING_REVIEW,
|
||||
scoreBreakdown: {
|
||||
hardMatchScore: score + 5,
|
||||
softFactorScore: score - 5,
|
||||
confidenceModifier: isGoodLoc ? 0.96 : 0.82,
|
||||
dataQualityModifier: 0.92,
|
||||
totalScore: score,
|
||||
},
|
||||
positiveFactors: isGoodLoc
|
||||
? [{ criterion: 'Standort', weight: 0.25, score: 92, contribution: 23, explanation: `${prop.location.city} – bevorzugter Standort` }]
|
||||
: [{ criterion: 'Fläche', weight: 0.25, score: 70, contribution: 17.5, explanation: `${prop.areaSqm} m² verfügbar` }],
|
||||
negativeFactors: !isGoodLoc
|
||||
? [{ criterion: 'Standort', weight: 0.25, score: 35, contribution: 8.75, explanation: `${prop.location.city} liegt außerhalb der bevorzugten Region` }]
|
||||
: [],
|
||||
tradeoffs: !isGoodLoc
|
||||
? [{ criterion: 'Standort', concern: `${prop.location.city} ist nicht im Präferenzgebiet`, severity: 'MEDIUM' as const }]
|
||||
: [],
|
||||
explainabilitySummary: isGoodLoc
|
||||
? `${prop.location.city} trifft den Standortwunsch. Objekt entspricht den Kernkriterien.`
|
||||
: `Abweichender Standort (${prop.location.city}). Nur bei Engpass im Zielgebiet empfohlen.`,
|
||||
confidenceLevel: isGoodLoc ? 0.88 : 0.60,
|
||||
riskLevel: isGoodLoc ? RiskLevel.LOW : RiskLevel.MEDIUM,
|
||||
uncertaintyIndicators: isGoodLoc ? [] : ['Standort außerhalb Präferenz'],
|
||||
organizationId: 'org-wincasa',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}
|
||||
|
||||
matchStore.push(match)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Provider ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export const MockupNeedProvider: INeedProvider = {
|
||||
async getAll(filters?: NeedFilters) {
|
||||
let results = [...store]
|
||||
@@ -18,6 +136,7 @@ export const MockupNeedProvider: INeedProvider = {
|
||||
async create(data: CreateNeedInput) {
|
||||
const next: Need = { id: crypto.randomUUID(), ...data, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() }
|
||||
store.push(next)
|
||||
generateSyntheticMatches(next)
|
||||
return next
|
||||
},
|
||||
async update(id, data: UpdateNeedInput) {
|
||||
|
||||
Reference in New Issue
Block a user