Compare commits
45 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a825672197 | |||
| 36570c5bdc | |||
| 98d2770054 | |||
| 6fb2fb027c | |||
| 7f6b7766e6 | |||
| 63909c8caf | |||
| d8ff380d6e | |||
| 81d66d24a9 | |||
| e597e81ff5 | |||
| d2af8664f4 | |||
| cd220ed282 | |||
| a9ee53f185 | |||
| fe8444c1d3 | |||
| 453f2b1982 | |||
| d804d1c923 | |||
| a9e038a64f | |||
| 61a26d00a7 | |||
| f2edc3b2ef | |||
| e95490eb72 | |||
| 3ab6eeccf2 | |||
| 128d28af8d | |||
| 9df5d285f5 | |||
| 609a3634bd | |||
| 11de05025c | |||
| 99c26b5464 | |||
| 58a0a05eff | |||
| e1f4beb898 | |||
| e36c5bc979 | |||
| f487435a94 | |||
| d4171fe9b5 | |||
| 7934da7669 | |||
| e62391af66 | |||
| 8f1db31683 | |||
| 7f090a48ff | |||
| c972392b78 | |||
| 723f553939 | |||
| 0582031930 | |||
| 713ef3ec08 | |||
| ad84fe8289 | |||
| ce67da73b3 | |||
| 86bdf142b4 | |||
| 5d53f35dea | |||
| eedee83b49 | |||
| 01c0f8b06f | |||
| c6055f0611 |
@@ -10,7 +10,10 @@
|
|||||||
"Bash(awk '{print $NF}')",
|
"Bash(awk '{print $NF}')",
|
||||||
"Bash(start http://localhost:5173)",
|
"Bash(start http://localhost:5173)",
|
||||||
"Bash(npm install *)",
|
"Bash(npm install *)",
|
||||||
"Bash(git pull *)"
|
"Bash(git pull *)",
|
||||||
|
"Bash(node scripts/check-tokens.js)",
|
||||||
|
"Bash(echo \"EXIT:$?\")",
|
||||||
|
"Bash(echo \"EXIT_CODE:$?\")"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,10 @@
|
|||||||
"permissions": {
|
"permissions": {
|
||||||
"allow": [
|
"allow": [
|
||||||
"Bash(git commit -m ' *)",
|
"Bash(git commit -m ' *)",
|
||||||
"Bash(git commit *)"
|
"Bash(git commit *)",
|
||||||
|
"Bash(node -e ' *)",
|
||||||
|
"Bash(git stash *)",
|
||||||
|
"Read(//c/Users/beni_/.claude/projects/c--Users-beni--OneDrive-Desktop-property-match/8e388ddd-e02e-47fe-8bb9-cdb8164c9fc3/tool-results/**)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"}
|
||||||
+476
@@ -0,0 +1,476 @@
|
|||||||
|
# Property Match — Architecture Reference
|
||||||
|
|
||||||
|
> This document describes the architecture of the Property Match platform.
|
||||||
|
> For coding rules see [CLAUDE.md](./CLAUDE.md).
|
||||||
|
> For state management patterns see [STATE_MANAGEMENT.md](./STATE_MANAGEMENT.md).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Folder Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── assets/ Static images, icons
|
||||||
|
│
|
||||||
|
├── domain/ TypeScript types & enums (single source of truth)
|
||||||
|
│ ├── enums.ts AssetType, ResultType, MatchStatus, UserRole, WorkspaceType
|
||||||
|
│ ├── match.ts Match, ScoreBreakdown, ScoreFactor, TradeOff, Risk
|
||||||
|
│ ├── property.ts Property, Location, ContactPerson
|
||||||
|
│ ├── need.ts Need, AreaRange, BudgetRange, WeightingProfile
|
||||||
|
│ ├── unifiedResult.ts UnifiedMatchResult (discriminated union)
|
||||||
|
│ ├── scoring.ts ScoringWeightProfile, MatchEngineOutput
|
||||||
|
│ ├── aiOutput.ts AI scoring, extraction, monitoring types
|
||||||
|
│ ├── futureSignal.ts Future availability signals
|
||||||
|
│ ├── pipeline.ts Pipeline stages & items
|
||||||
|
│ ├── inquiry.ts Demand-side inquiry records
|
||||||
|
│ ├── reminder.ts Reminder & DueDate
|
||||||
|
│ └── index.ts Barrel re-exports
|
||||||
|
│
|
||||||
|
├── features/ Business logic algorithms (pure TypeScript)
|
||||||
|
│ └── matching/
|
||||||
|
│ ├── scoreCalculator.ts Hard/soft scoring, confidence & DQ modifiers
|
||||||
|
│ ├── rankingEngine.ts Match ranking, unified result assembly
|
||||||
|
│ ├── mustHaveScorer.ts Must-have criteria evaluation
|
||||||
|
│ ├── tradeOffAnalyzer.ts Tradeoff, risk, and missing-data analysis
|
||||||
|
│ └── matchCardAdapter.ts Match → MatchCardViewModel adapter
|
||||||
|
│
|
||||||
|
├── provider/ Data access layer (interface + mock per entity)
|
||||||
|
│ ├── IPropertyProvider.ts
|
||||||
|
│ ├── MockupPropertyProvider.ts
|
||||||
|
│ ├── INeedProvider.ts
|
||||||
|
│ ├── MockupNeedProvider.ts
|
||||||
|
│ └── ... (one pair per entity)
|
||||||
|
│
|
||||||
|
├── services/ Business logic services
|
||||||
|
│ ├── ai/
|
||||||
|
│ │ ├── IAIService.ts AI capability interface
|
||||||
|
│ │ ├── MockAIService.ts Deterministic mock (dev/CI)
|
||||||
|
│ │ ├── OpenRouterAIService.ts Real LLM calls (production)
|
||||||
|
│ │ └── prompts/ Structured prompt templates
|
||||||
|
│ ├── matchService.ts
|
||||||
|
│ ├── needService.ts
|
||||||
|
│ ├── propertyService.ts
|
||||||
|
│ ├── aiMonitoringService.ts
|
||||||
|
│ ├── governanceService.ts
|
||||||
|
│ ├── types.ts ListResponse<T>, ItemResponse<T>
|
||||||
|
│ └── errors.ts throwServiceError()
|
||||||
|
│
|
||||||
|
├── hooks/ React Query wrappers
|
||||||
|
│ ├── useProperties.ts
|
||||||
|
│ ├── useMatches.ts
|
||||||
|
│ ├── useUnifiedResults.ts Aggregates all result types for demand feed
|
||||||
|
│ └── ... (one file per entity)
|
||||||
|
│
|
||||||
|
├── stores/ Zustand UI stores (global UI state only)
|
||||||
|
│ ├── sessionStore.ts Auth, currentUser, workspace
|
||||||
|
│ ├── compareStore.ts Compare tray items (max 4)
|
||||||
|
│ ├── pipelineStore.ts Pipeline items + add-dialog
|
||||||
|
│ ├── assistantStore.ts AI assistant drawer conversation
|
||||||
|
│ ├── offerWizardStore.ts Multi-step offer wizard
|
||||||
|
│ ├── layoutStore.ts Sidebar, right panel, active workspace
|
||||||
|
│ ├── matchCenterStore.ts Supply match center selection
|
||||||
|
│ ├── reminderStore.ts Reminder filters + drawer
|
||||||
|
│ ├── shortlistStore.ts Shortlist selection + add-dialog
|
||||||
|
│ └── toastStore.ts Toast notification queue
|
||||||
|
│
|
||||||
|
├── lib/ Shared utilities & design system
|
||||||
|
│ ├── constants.ts All thresholds, labels, stale times, routes
|
||||||
|
│ ├── ds.ts DS_COLORS, RESULT_TYPE_META, score → level converters
|
||||||
|
│ ├── utils.ts matchScoreHex, confidenceHex, dataQualityColor, formatters
|
||||||
|
│ ├── theme.ts MUI theme
|
||||||
|
│ ├── queryClient.ts TanStack Query client config
|
||||||
|
│ ├── permissions.ts Role-based access helpers
|
||||||
|
│ ├── locationIntelligence.ts Location scoring
|
||||||
|
│ └── propertyHeat.ts Market heat analysis
|
||||||
|
│
|
||||||
|
├── components/ UI components (organized by feature)
|
||||||
|
│ ├── layout/ AppShell, PageHeader, RightContextPanel
|
||||||
|
│ ├── ui/ ErrorBoundary, EmptyState, Toast, DecisionContextPanel
|
||||||
|
│ ├── shared/ LocationPreview, ViewToggle (cross-feature)
|
||||||
|
│ ├── match-card/ MatchCard variants (compact/expanded/review/compare-mini)
|
||||||
|
│ ├── match-detail/ MatchDetailHero, ScoreBreakdownPanel, CriterionRow
|
||||||
|
│ ├── compare/ CompareMetricRow, CompareColumn, compareUtils
|
||||||
|
│ ├── pipeline/ PipelineCard, PipelineColumn, pipelineConstants, pipelineUtils
|
||||||
|
│ ├── results/ UnifiedResultFeed, ResultFilterBar, ResultTypeBadge
|
||||||
|
│ ├── supply/ PropertyIntelligenceCard, ReminderFeed, ReminderListRow, ...
|
||||||
|
│ ├── demand/ NeedInput, WeightingEditor, CriteriaReviewPanel, ...
|
||||||
|
│ ├── future-signals/ SignalCard, SignalFilterBar, FutureAvailabilityPanel, ...
|
||||||
|
│ ├── data-quality/ DataQualityPanel, FreshnessIndicator, ProvenancePanel, ...
|
||||||
|
│ ├── badges/ ConfidenceBadge, RiskBadge, FreshnessBadge, ...
|
||||||
|
│ ├── ai-monitoring/ AI audit log UI
|
||||||
|
│ └── ...
|
||||||
|
│
|
||||||
|
├── pages/ Route-level page components
|
||||||
|
│ ├── auth/LoginScreen.tsx
|
||||||
|
│ ├── supply/ SupplyDashboard, Properties, MatchCenter, ...
|
||||||
|
│ ├── demand/ AISearch, Results, MatchDetail, Compare, Pipeline, ...
|
||||||
|
│ └── ops/ MarketIntelligence
|
||||||
|
│
|
||||||
|
├── mock-data/ Seed data for mock providers
|
||||||
|
│
|
||||||
|
├── App.tsx React Router setup (all routes, lazy-loaded)
|
||||||
|
└── index.css Tailwind layer imports (no project styles here)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Data Flow Overview
|
||||||
|
|
||||||
|
The core data flow is **unidirectional** across four layers.
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ Component / Page │
|
||||||
|
│ │
|
||||||
|
│ Reads: useQuery hook Writes: useMutation hook │
|
||||||
|
│ UI state: Zustand selector Side effects: useEffect │
|
||||||
|
└────────────────┬──────────────────────────┬────────────────────────────┘
|
||||||
|
│ calls │ calls
|
||||||
|
▼ ▼
|
||||||
|
┌────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ Hook Layer │
|
||||||
|
│ src/hooks/*.ts │
|
||||||
|
│ │
|
||||||
|
│ useQuery({ queryFn: () => service.getAll() }) │
|
||||||
|
│ useMutation({ mutationFn: (data) => service.create(data) }) │
|
||||||
|
└────────────────────────────────┬───────────────────────────────────────┘
|
||||||
|
│ calls
|
||||||
|
▼
|
||||||
|
┌────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ Service Layer │
|
||||||
|
│ src/services/*.ts │
|
||||||
|
│ │
|
||||||
|
│ Business logic, error handling, response standardization │
|
||||||
|
│ Reads session via sessionStore.getState() │
|
||||||
|
│ Calls scoring engine (features/matching/) as needed │
|
||||||
|
└────────────────────────────────┬───────────────────────────────────────┘
|
||||||
|
│ calls
|
||||||
|
▼
|
||||||
|
┌────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ Provider Layer │
|
||||||
|
│ src/provider/*.ts │
|
||||||
|
│ │
|
||||||
|
│ IPropertyProvider (interface) │
|
||||||
|
│ MockupPropertyProvider (in-memory, dev) ← swap → RestPropertyProvider (prod)
|
||||||
|
└────────────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Provider Swap Flow
|
||||||
|
|
||||||
|
Swapping from mock to real requires **one file change** — the service import. No hooks, components, or tests change.
|
||||||
|
|
||||||
|
```
|
||||||
|
Dev / CI Production
|
||||||
|
───────────────────────────────────── ─────────────────────────────────────
|
||||||
|
propertyService.ts propertyService.ts
|
||||||
|
import provider from import provider from
|
||||||
|
'./MockupPropertyProvider' './RestPropertyProvider'
|
||||||
|
↑
|
||||||
|
One line change — nothing else
|
||||||
|
```
|
||||||
|
|
||||||
|
Provider interfaces enforce the contract. All methods are `async`. Mock providers hold in-memory seed data from `src/mock-data/`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. State Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────┐
|
||||||
|
│ React Query Cache │
|
||||||
|
│ │
|
||||||
|
│ ['properties'] ── 5 min stale │
|
||||||
|
│ ['matches', needId] ── 2 min stale │
|
||||||
|
│ ['signals'] ── 5 min stale │
|
||||||
|
│ ['review-queue'] ── 30 s stale │
|
||||||
|
│ │
|
||||||
|
│ ← server data, cached, invalidated │
|
||||||
|
│ on mutation success → │
|
||||||
|
└──────────────────────────────────────────┘
|
||||||
|
|
||||||
|
┌──────────────────────────────────────────┐
|
||||||
|
│ Zustand Stores │
|
||||||
|
│ │
|
||||||
|
│ sessionStore ← auth, currentUser │
|
||||||
|
│ layoutStore ← sidebar, workspace │
|
||||||
|
│ compareStore ← compare tray items │
|
||||||
|
│ pipelineStore ← pipeline + dialog │
|
||||||
|
│ assistantStore ← AI chat conversation │
|
||||||
|
│ offerWizardStore← multi-step wizard │
|
||||||
|
│ reminderStore ← filter + drawer │
|
||||||
|
│ toastStore ← notification queue │
|
||||||
|
│ │
|
||||||
|
│ ← UI state only, no server data → │
|
||||||
|
└──────────────────────────────────────────┘
|
||||||
|
|
||||||
|
┌──────────────────────────────────────────┐
|
||||||
|
│ Component Local State │
|
||||||
|
│ │
|
||||||
|
│ useState ← form inputs, toggles │
|
||||||
|
│ useMemo ← derived / filtered lists │
|
||||||
|
│ useCallback ← stable callbacks │
|
||||||
|
└──────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Matching & Scoring Flow
|
||||||
|
|
||||||
|
The matching engine (`src/features/matching/`) produces all scores. Components never compute scores — they display them.
|
||||||
|
|
||||||
|
```
|
||||||
|
Need (criteria) + Property (attributes)
|
||||||
|
│ │
|
||||||
|
└───────────┬────────────┘
|
||||||
|
▼
|
||||||
|
┌─────────────────────┐
|
||||||
|
│ scoreCalculator │
|
||||||
|
│ │
|
||||||
|
│ 1. Hard filters │ ← Exclusions (wrong type, out of area, etc.)
|
||||||
|
│ 2. Hard scoring │ ← Area, location, budget, timing, asset type (0–100 each)
|
||||||
|
│ 3. Soft scoring │ ← Prestige, accessibility, ESG, footfall, etc. (0–100 each)
|
||||||
|
│ 4. Modifiers │ ← confidenceModifier (-20→+5) + dataQualityModifier (-15→0)
|
||||||
|
│ 5. totalScore │ ← Weighted average + modifiers (0–100)
|
||||||
|
└──────────┬──────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────────┐
|
||||||
|
│ tradeOffAnalyzer │ ← TradeOff[], Risk[], MissingDataItem[]
|
||||||
|
└──────────┬──────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────────┐
|
||||||
|
│ rankingEngine │ ← Builds Match[], ranks, adds NextBestAction[]
|
||||||
|
└──────────┬──────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────────┐
|
||||||
|
│ matchCardAdapter │ ← Match → MatchCardViewModel (UI props)
|
||||||
|
└──────────┬──────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
MatchCard / MatchDetail / Compare
|
||||||
|
```
|
||||||
|
|
||||||
|
**ScoreBreakdown shape:**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
{
|
||||||
|
hardMatchScore: number // 0–100: area, location, budget, timing, usage
|
||||||
|
softFactorScore: number // 0–100: prestige, accessibility, ESG, etc.
|
||||||
|
confidenceModifier: number // –20 → +5
|
||||||
|
dataQualityModifier: number // –15 → 0
|
||||||
|
totalScore: number // 0–100 final score
|
||||||
|
positiveFactors: ScoreFactor[] // top reasons this is a good match
|
||||||
|
negativeFactors: ScoreFactor[] // top concerns
|
||||||
|
tradeOffs: TradeOff[] // explicit trade-off explanations
|
||||||
|
risks: Risk[] // risk categories
|
||||||
|
missingData: MissingDataItem[] // fields that would improve the score
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. AI Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
User input (text / voice)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
NeedInput component
|
||||||
|
│ calls
|
||||||
|
▼
|
||||||
|
aiService.parseNeed(text)
|
||||||
|
│ routes to
|
||||||
|
├── MockAIService (dev) ──────────────► deterministic ParsedNeed
|
||||||
|
└── OpenRouterAIService (prod) ────────► LLM API call
|
||||||
|
│
|
||||||
|
│ validates with Zod
|
||||||
|
▼
|
||||||
|
ParseNeedResult
|
||||||
|
{ need: Partial<Need>, confidence: number, followUpQuestions: string[] }
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
CriteriaReviewPanel
|
||||||
|
(user confirms / overrides AI-extracted criteria)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Need saved → Results feed runs
|
||||||
|
```
|
||||||
|
|
||||||
|
**AI Governance loop:**
|
||||||
|
|
||||||
|
```
|
||||||
|
Every AI call
|
||||||
|
│
|
||||||
|
├─► aiMonitoringService.log({ modelId, promptHash, responseDigest, userId, ... })
|
||||||
|
│
|
||||||
|
└─► Visible in /ops/ai-monitoring (review queue for ops team)
|
||||||
|
```
|
||||||
|
|
||||||
|
**AI never speaks directly to the UI.** All AI outputs go through:
|
||||||
|
1. `IAIService` interface method
|
||||||
|
2. Zod validation
|
||||||
|
3. Typed domain object
|
||||||
|
4. Component that renders it with explainability metadata
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Unified Result Feed Flow (Demand)
|
||||||
|
|
||||||
|
The demand workspace shows a unified feed that merges three result types.
|
||||||
|
|
||||||
|
```
|
||||||
|
useUnifiedResults(needId)
|
||||||
|
│
|
||||||
|
├─► matchService.getForNeed(needId)
|
||||||
|
│ └─► VERIFIED_PORTFOLIO matches (own properties)
|
||||||
|
│ └─► EXTERNAL_MARKET matches (partner/scraped)
|
||||||
|
│
|
||||||
|
├─► aiService.getMaisonWorkResults(needId)
|
||||||
|
│ └─► MAISON_WORK matches (AI-suggested)
|
||||||
|
│
|
||||||
|
└─► futureSignalService.getForNeed(needId)
|
||||||
|
└─► FUTURE_AVAILABILITY signals (AI-predicted)
|
||||||
|
├── probabilityScore (0–1)
|
||||||
|
├── signalBasis (string)
|
||||||
|
└── expectedAvailabilityDate
|
||||||
|
|
||||||
|
All merged into UnifiedMatchResult[]
|
||||||
|
Sorted by matchScore desc (default)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
UnifiedResultFeed
|
||||||
|
├── MatchCard (VERIFIED_PORTFOLIO, EXTERNAL_MARKET, MAISON_WORK)
|
||||||
|
└── FutureAvailabilityCard (FUTURE_AVAILABILITY — different visual treatment)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Future Availability signals are probabilistic** — they must always show `probabilityScore` and a basis explanation. They are never presented as confirmed availability.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Three-Workspace Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
App.tsx
|
||||||
|
│
|
||||||
|
├── /auth/login → LoginScreen
|
||||||
|
│
|
||||||
|
├── /supply/* ──────────────────────────────────► SUPPLY workspace
|
||||||
|
│ Protected: WorkspaceType.SUPPLY (PropertyManager, OrgAdmin)
|
||||||
|
│ Pages: SupplyDashboard, Properties,
|
||||||
|
│ MatchCenter, Anfragencenter,
|
||||||
|
│ FutureAvailability, DataQuality,
|
||||||
|
│ ReminderManager, MarketLeads,
|
||||||
|
│ NewListing, MyListings
|
||||||
|
│
|
||||||
|
├── /demand/* ──────────────────────────────────► DEMAND workspace
|
||||||
|
│ Protected: WorkspaceType.DEMAND (Tenant, CompanyAdmin)
|
||||||
|
│ Pages: AISearch, Results, MatchDetail,
|
||||||
|
│ Compare, Pipeline, Anfragen,
|
||||||
|
│ PropertyDetail, Shortlists
|
||||||
|
│
|
||||||
|
└── /ops/* ────────────────────────────────────► OPERATIONS workspace
|
||||||
|
Protected: WorkspaceType.OPERATIONS (Internal staff)
|
||||||
|
Pages: MarketIntelligence
|
||||||
|
```
|
||||||
|
|
||||||
|
Route protection enforced by `<ProtectedRoute workspace={...} />`. Each workspace has distinct data flows and does not share components with other workspaces unless through `src/components/shared/` or `src/components/ui/`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Decision Screen Design Pattern
|
||||||
|
|
||||||
|
Every page is built around **one user decision**. The `DecisionContextPanel` component surfaces this explicitly at the top of each page.
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ PageHeader — title, subtitle, secondary actions (ViewToggle) │
|
||||||
|
├─────────────────────────────────────────────────────────────┤
|
||||||
|
│ DecisionContextPanel │
|
||||||
|
│ decision: "Which matches are ready to shortlist?" │
|
||||||
|
│ context: "Search: OFFICE · 200–500 m² · Zürich" │
|
||||||
|
│ metrics: [strongMatches: 3 ✓] [withGaps: 2 ⚠] │
|
||||||
|
│ risks: ["2 results have missing floor plan data"] │
|
||||||
|
│ actions: [Open Compare] [Refine Search] │
|
||||||
|
├─────────────────────────────────────────────────────────────┤
|
||||||
|
│ FilterBar / Controls │
|
||||||
|
├─────────────────────────────────────────────────────────────┤
|
||||||
|
│ Content (list / grid / table / kanban) │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
If a screen cannot answer "what decision does this help the user make?", the design is wrong.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Error Boundary Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
App.tsx
|
||||||
|
└── AppErrorBoundary (top-level catch-all)
|
||||||
|
└── Route components
|
||||||
|
└── Feature-level try/catch in services
|
||||||
|
└── Error state passed via useQuery error prop
|
||||||
|
└── <ErrorState message={...} /> or <UnauthorizedState />
|
||||||
|
```
|
||||||
|
|
||||||
|
Every page handles three states: loading (`<FeedSkeleton />`), error (`<ErrorState />`), and empty (`<FeedEmptyState />`). These are never omitted.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Component Dependency Rules
|
||||||
|
|
||||||
|
```
|
||||||
|
pages/ can import from components/, hooks/, stores/, lib/, domain/
|
||||||
|
components/ can import from components/, hooks/, stores/, lib/, domain/
|
||||||
|
hooks/ can import from services/, lib/, domain/
|
||||||
|
services/ can import from provider/, features/, lib/, domain/
|
||||||
|
features/ can import from lib/, domain/
|
||||||
|
provider/ can import from domain/, mock-data/
|
||||||
|
lib/ can import from (nothing in src — pure utilities)
|
||||||
|
domain/ can import from (nothing — types only)
|
||||||
|
stores/ can import from domain/, lib/
|
||||||
|
```
|
||||||
|
|
||||||
|
**Forbidden imports:**
|
||||||
|
- `services/` must not import React hooks
|
||||||
|
- `domain/` must not import anything from `src/`
|
||||||
|
- `lib/constants.ts` must not import from components or services
|
||||||
|
- Circular imports of any kind
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Key Design System Tokens
|
||||||
|
|
||||||
|
```
|
||||||
|
Scores (0–100 scale)
|
||||||
|
≥ 80 → STRONG → DS_COLORS.confidence.high (#1a7a4a)
|
||||||
|
≥ 60 → MODERATE → DS_COLORS.confidence.medium (#d97706)
|
||||||
|
< 60 → WEAK → DS_COLORS.confidence.low (#c0392b)
|
||||||
|
|
||||||
|
Confidence (0–1 scale)
|
||||||
|
≥ 0.85 → HIGH → DS_COLORS.confidence.high
|
||||||
|
≥ 0.65 → MEDIUM → DS_COLORS.confidence.medium
|
||||||
|
< 0.65 → LOW → DS_COLORS.confidence.low
|
||||||
|
|
||||||
|
Data Quality (0–1 scale)
|
||||||
|
≥ 0.80 → HIGH
|
||||||
|
≥ 0.60 → MEDIUM
|
||||||
|
< 0.60 → LOW
|
||||||
|
|
||||||
|
Result Types
|
||||||
|
VERIFIED_PORTFOLIO → label 'Plattform' color #1e3a5f
|
||||||
|
EXTERNAL_MARKET → label 'Plattform' color #1e3a5f
|
||||||
|
MAISON_WORK → label 'Maison Work' color #0369a1
|
||||||
|
FUTURE_AVAILABILITY → label 'Zukunftssignal' color #7c3aed
|
||||||
|
|
||||||
|
Freshness
|
||||||
|
FRESH → DS_COLORS.freshness.fresh (#1a7a4a)
|
||||||
|
STALE → DS_COLORS.freshness.stale (#d97706)
|
||||||
|
OUTDATED → DS_COLORS.freshness.outdated (#dc2626)
|
||||||
|
```
|
||||||
|
|
||||||
|
All tokens from `src/lib/ds.ts`. All thresholds from `src/lib/constants.ts`.
|
||||||
@@ -1,40 +1,99 @@
|
|||||||
# property-match — Development Guidelines
|
# property-match — Engineering Guidelines
|
||||||
|
|
||||||
## Stack
|
> **This file is read by Claude Code on every session.** All rules here are binding.
|
||||||
|
> For architecture diagrams and data flows see [ARCHITECTURE.md](./ARCHITECTURE.md).
|
||||||
|
> For the pre-commit review checklist see [CODE_REVIEW_CHECKLIST.md](./CODE_REVIEW_CHECKLIST.md).
|
||||||
|
> For state management patterns see [STATE_MANAGEMENT.md](./STATE_MANAGEMENT.md).
|
||||||
|
|
||||||
- **Vite 8** + **React 19** + **TypeScript 6**
|
---
|
||||||
- **MUI v9** (`@mui/material`) — primary component library
|
|
||||||
- **Tailwind CSS v4** — utility classes via `@tailwindcss/vite` (no `tailwind.config.js`)
|
|
||||||
- **React Router v7** — import from `react-router`, not `react-router-dom`
|
|
||||||
|
|
||||||
## Components
|
## 1. Project Vision
|
||||||
|
|
||||||
Always reach for an existing MUI component before writing a custom one. Check the [MUI component list](https://mui.com/material-ui/all-components/) first. Only build a custom component when MUI has no equivalent or the required behavior diverges significantly from what MUI provides.
|
Property Match is a **Decision Intelligence Platform** — not a real estate portal.
|
||||||
|
|
||||||
## Styling
|
The distinction matters for every design decision:
|
||||||
|
|
||||||
Use Tailwind utility classes for all layout and styling. Do not write plain CSS rules or add styles to `.css` files. The only CSS file is `src/index.css`, which holds the Tailwind layer imports — do not add project styles there.
|
| Classical Portal | Decision Intelligence |
|
||||||
|
|---|---|
|
||||||
|
| Show listings, let the user decide | Surface scored matches with explainability |
|
||||||
|
| Generic SaaS listing UI | Every screen is designed around one user decision |
|
||||||
|
| More filters = better | Fewer, smarter signals = better |
|
||||||
|
| Trust by volume | Trust by transparency |
|
||||||
|
|
||||||
|
**Three design principles that override everything else:**
|
||||||
|
|
||||||
## Providers
|
1. **Explainability-first.** Every score, badge, and recommendation must be traceable. If the user cannot understand *why* a match is strong, the feature is not done. Score breakdowns, trade-off panels, risk indicators, and data provenance are first-class UI elements — not tooltips added at the end.
|
||||||
|
|
||||||
All data access and data actions live in `src/provider/`.
|
2. **Trust-first.** Confidence levels, data freshness, and missing-data warnings are shown proactively. We never surface a result that looks more certain than the underlying data supports. When data is stale or incomplete, we say so.
|
||||||
|
|
||||||
### Naming
|
3. **Better-than-Google.** A property search result that just shows "here are things that match your query" fails the user. Property Match should rank, explain trade-offs, surface future signals, and give the user a clear recommended next action — like a trusted advisor, not a search engine.
|
||||||
|
|
||||||
| Rule | Example |
|
---
|
||||||
|------|---------|
|
|
||||||
| Every provider file/class is suffixed `Provider` | `PropertyProvider`, `UserProvider` |
|
|
||||||
| Every provider backed by mock data is also prefixed `Mockup` | `MockupPropertyProvider`, `MockupUserProvider` |
|
|
||||||
|
|
||||||
### Interface pattern
|
## 2. Stack
|
||||||
|
|
||||||
Define a TypeScript interface for each provider so the mockup and the real implementation are interchangeable:
|
| Technology | Version | Role |
|
||||||
Every Interface should be prefixed with a capitalized I.
|
|---|---|---|
|
||||||
|
| Vite | 8 | Build tool & dev server |
|
||||||
|
| React | 19 | UI framework |
|
||||||
|
| TypeScript | 6 | Type safety |
|
||||||
|
| MUI | v9 (`@mui/material`) | Component library |
|
||||||
|
| Tailwind CSS | v4 | Utility classes (via `@tailwindcss/vite`) |
|
||||||
|
| React Router | v7 | Routing — import from `react-router`, not `react-router-dom` |
|
||||||
|
| TanStack Query | v5 | Server state & caching |
|
||||||
|
| Zustand | v5 | Global UI state |
|
||||||
|
| @dnd-kit | current | Drag-and-drop (Pipeline board) |
|
||||||
|
| Zod | current | Schema validation |
|
||||||
|
| Lucide React | current | Icon set (supplementary to MUI icons) |
|
||||||
|
|
||||||
|
**There is no `tailwind.config.js`.** Tailwind v4 uses `@tailwindcss/vite` — config is inline.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Three-Workspace Architecture
|
||||||
|
|
||||||
|
The app is organized into three protected workspaces. Every route lives in exactly one workspace.
|
||||||
|
|
||||||
|
| Workspace | Route prefix | Primary user | Core job-to-be-done |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **SUPPLY** | `/supply/*` | Property manager / owner | Manage inventory, respond to demand, monitor market |
|
||||||
|
| **DEMAND** | `/demand/*` | Tenant / company | Find, compare, and pipeline commercial space |
|
||||||
|
| **OPERATIONS** | `/ops/*` | Internal staff | AI governance, market intelligence, audit logs |
|
||||||
|
|
||||||
|
Route protection is enforced at the router level via `<ProtectedRoute workspace={WorkspaceType.X} />`. Never add workspace-specific logic inside shared components — pass `isStaff`, `isOwner`, etc. as props.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Layered Architecture
|
||||||
|
|
||||||
|
Data and logic flow through four explicit layers. **Never skip a layer.**
|
||||||
|
|
||||||
|
```
|
||||||
|
Provider → Service → Hook (React Query) → Component
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.1 Provider Layer (`src/provider/`)
|
||||||
|
|
||||||
|
Providers are the **only** code that touches data storage. Everything else goes through them.
|
||||||
|
|
||||||
|
**Rules:**
|
||||||
|
- Every entity has an interface: `I[Entity]Provider` (file: `I[Entity]Provider.ts`)
|
||||||
|
- Every interface is implemented by a mockup: `Mockup[Entity]Provider` (file: `Mockup[Entity]Provider.ts`)
|
||||||
|
- Every method is `async` and returns a `Promise` — even in the mockup
|
||||||
|
- Mockups hold in-memory seed data from `src/mock-data/`
|
||||||
|
- Real implementations swap in without changing any call sites
|
||||||
|
|
||||||
|
**Naming:**
|
||||||
|
|
||||||
|
| Pattern | Example |
|
||||||
|
|---|---|
|
||||||
|
| Interface | `IPropertyProvider` |
|
||||||
|
| Mock implementation | `MockupPropertyProvider` |
|
||||||
|
| Real implementation (future) | `RestPropertyProvider`, `SupabasePropertyProvider` |
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
// src/provider/IPropertyProvider.ts
|
// src/provider/IPropertyProvider.ts
|
||||||
export interface PropertyProvider {
|
export interface IPropertyProvider {
|
||||||
getAll(): Promise<Property[]>
|
getAll(): Promise<Property[]>
|
||||||
getById(id: string): Promise<Property | null>
|
getById(id: string): Promise<Property | null>
|
||||||
create(data: CreatePropertyInput): Promise<Property>
|
create(data: CreatePropertyInput): Promise<Property>
|
||||||
@@ -43,38 +102,482 @@ export interface PropertyProvider {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### Async methods
|
|
||||||
|
|
||||||
Every method in a provider must be `async` and return a `Promise`, even in the mockup. This ensures the real provider can be swapped in without changing any call sites.
|
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
// src/provider/MockupPropertyProvider.ts
|
// src/provider/MockupPropertyProvider.ts
|
||||||
import type { PropertyProvider } from './PropertyProvider'
|
export const MockupPropertyProvider: IPropertyProvider = {
|
||||||
|
async getAll() { return [...properties] },
|
||||||
const properties: Property[] = [ /* seed data */ ]
|
async getById(id) { return properties.find(p => p.id === id) ?? null },
|
||||||
|
// ...
|
||||||
export const MockupPropertyProvider: PropertyProvider = {
|
|
||||||
async getAll() {
|
|
||||||
return [...properties]
|
|
||||||
},
|
|
||||||
async getById(id) {
|
|
||||||
return properties.find(p => p.id === id) ?? null
|
|
||||||
},
|
|
||||||
async create(data) {
|
|
||||||
const next: Property = { id: crypto.randomUUID(), ...data }
|
|
||||||
properties.push(next)
|
|
||||||
return next
|
|
||||||
},
|
|
||||||
async update(id, data) {
|
|
||||||
const idx = properties.findIndex(p => p.id === id)
|
|
||||||
properties[idx] = { ...properties[idx], ...data }
|
|
||||||
return properties[idx]
|
|
||||||
},
|
|
||||||
async remove(id) {
|
|
||||||
const idx = properties.findIndex(p => p.id === id)
|
|
||||||
properties.splice(idx, 1)
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Swap to a real implementation by replacing `MockupPropertyProvider` with a provider that calls an API — no other code changes required.
|
**Anti-patterns:**
|
||||||
|
- Never call a provider directly from a component — use a service
|
||||||
|
- Never mutate provider state from outside the provider
|
||||||
|
- Never share state across providers (cross-provider coupling creates hidden dependencies)
|
||||||
|
|
||||||
|
### 4.2 Service Layer (`src/services/`)
|
||||||
|
|
||||||
|
Services wrap providers with business logic, error handling, and response standardization.
|
||||||
|
|
||||||
|
**Rules:**
|
||||||
|
- Named `[entity]Service` (e.g., `matchService`, `needService`)
|
||||||
|
- Return standardized types: `ListResponse<T>` or `ItemResponse<T>` from `services/types.ts`
|
||||||
|
- Errors are thrown via `throwServiceError()` from `services/errors.ts`
|
||||||
|
- Services never import React hooks — they are plain TypeScript
|
||||||
|
- Services access Zustand stores via `getState()`, never via `useStore()` hooks
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// ✅ Service accessing auth — no hook
|
||||||
|
const user = useSessionStore.getState().currentUser
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.3 Hook Layer (`src/hooks/`)
|
||||||
|
|
||||||
|
Hooks wrap React Query around services.
|
||||||
|
|
||||||
|
**Rules:**
|
||||||
|
- One hook file per entity: `useProperties.ts`, `useMatches.ts`, etc.
|
||||||
|
- `useQuery` for reads, `useMutation` for writes
|
||||||
|
- Query keys follow the convention in STATE_MANAGEMENT.md
|
||||||
|
- Stale times are imported from `src/lib/constants.ts` — never defined locally in hooks
|
||||||
|
- Mutations call `queryClient.invalidateQueries` on success
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// ✅ Stale time from constants — never local
|
||||||
|
import { STALE_PROPERTIES } from '../lib/constants'
|
||||||
|
export function useProperties() {
|
||||||
|
return useQuery({ queryKey: ['properties'], queryFn: () => propertyService.getAll(), staleTime: STALE_PROPERTIES })
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.4 Component Layer (`src/components/` + `src/pages/`)
|
||||||
|
|
||||||
|
Components are **pure UI**. They render data, dispatch mutations, and show state. They do not contain business logic.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Component Rules
|
||||||
|
|
||||||
|
### 5.1 Size Limits
|
||||||
|
|
||||||
|
| File type | Hard limit | Soft target |
|
||||||
|
|---|---|---|
|
||||||
|
| Page component (`src/pages/`) | 300 lines | 150–200 lines |
|
||||||
|
| Feature component | 250 lines | 100–150 lines |
|
||||||
|
| Atom/shared component | 150 lines | 50–100 lines |
|
||||||
|
|
||||||
|
When a component exceeds its soft target, split it. Extract sub-components, move constants to a `*Constants.ts` file, move pure helpers to a `*Utils.ts` file (`.tsx` if it returns JSX).
|
||||||
|
|
||||||
|
### 5.2 No Business Logic in JSX
|
||||||
|
|
||||||
|
The render body is for presentation only. All logic belongs upstream.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// ❌ Business logic in JSX
|
||||||
|
{matches.filter(m => m.score > 70 && m.status !== 'DISMISSED').map(...)}
|
||||||
|
|
||||||
|
// ✅ Logic in useMemo, computed before the return
|
||||||
|
const visibleMatches = useMemo(
|
||||||
|
() => matches.filter(m => m.score > 70 && m.status !== 'DISMISSED'),
|
||||||
|
[matches]
|
||||||
|
)
|
||||||
|
// then:
|
||||||
|
{visibleMatches.map(...)}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.3 No Magic Strings
|
||||||
|
|
||||||
|
Every label, status, color, and route string has a named constant.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// ❌
|
||||||
|
if (user.role === 'PROPERTY_MANAGER') { ... }
|
||||||
|
navigate('/supply/properties')
|
||||||
|
|
||||||
|
// ✅
|
||||||
|
import { UserRole } from '../domain/enums'
|
||||||
|
import { ROUTES } from '../lib/constants'
|
||||||
|
if (user.role === UserRole.PROPERTY_MANAGER) { ... }
|
||||||
|
navigate(ROUTES.SUPPLY.PROPERTIES)
|
||||||
|
```
|
||||||
|
|
||||||
|
All German UI labels for enums live in `src/lib/constants.ts` (e.g., `ASSET_TYPE_LABELS`, `AVAILABILITY_LABELS`).
|
||||||
|
|
||||||
|
### 5.4 No `any` Casts
|
||||||
|
|
||||||
|
`any` disables TypeScript. Use `unknown` with type guards, or fix the type properly.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// ❌
|
||||||
|
const result = response as any
|
||||||
|
const { data } = result
|
||||||
|
|
||||||
|
// ✅
|
||||||
|
const result: ItemResponse<Property> = response
|
||||||
|
const { data } = result
|
||||||
|
```
|
||||||
|
|
||||||
|
The one allowed exception: third-party library types that don't ship proper types. Wrap the cast in a type-safe adapter so the `any` is isolated.
|
||||||
|
|
||||||
|
### 5.5 No Duplicated State
|
||||||
|
|
||||||
|
If a value can be computed from existing state, compute it. Do not store it.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// ❌ Duplicated — sync bugs guaranteed
|
||||||
|
const [overdueCount, setOverdueCount] = useState(0)
|
||||||
|
useEffect(() => setOverdueCount(reminders.filter(r => isPast(r.dueDate)).length), [reminders])
|
||||||
|
|
||||||
|
// ✅ Derived inline
|
||||||
|
const overdueCount = reminders.filter(r => isPast(r.dueDate)).length
|
||||||
|
```
|
||||||
|
|
||||||
|
See STATE_MANAGEMENT.md for the full decision tree.
|
||||||
|
|
||||||
|
### 5.6 Component Library First
|
||||||
|
|
||||||
|
Before writing a custom component, check the [MUI component list](https://mui.com/material-ui/all-components/). Only build custom when:
|
||||||
|
- MUI has no equivalent, or
|
||||||
|
- The required behavior diverges significantly from what MUI provides
|
||||||
|
|
||||||
|
### 5.7 Styling
|
||||||
|
|
||||||
|
Use Tailwind utility classes for all layout and spacing. Use MUI's `sx` prop for component-specific overrides and theme values. **Never add styles to `.css` files.** The only CSS file is `src/index.css` (Tailwind layer imports) — do not add project styles there.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. State Management
|
||||||
|
|
||||||
|
Full rules in [STATE_MANAGEMENT.md](./STATE_MANAGEMENT.md). Summary:
|
||||||
|
|
||||||
|
| Data type | Where it lives |
|
||||||
|
|---|---|
|
||||||
|
| Server data (fetched from provider) | React Query (`useQuery`) |
|
||||||
|
| Write operations | React Query (`useMutation`) |
|
||||||
|
| Global UI state (dialog open/close, compare tray, wizard steps) | Zustand store |
|
||||||
|
| Auth session | `sessionStore` (Zustand, also read by services via `getState()`) |
|
||||||
|
| Component-local state (form inputs, hover, toggle) | `useState` |
|
||||||
|
| Values derived from existing state | Computed inline — never stored |
|
||||||
|
|
||||||
|
**Critical rules:**
|
||||||
|
- Never copy React Query data into a Zustand store
|
||||||
|
- Never call `useStore()` without a selector (use `s => s.field`)
|
||||||
|
- Services read Zustand via `getState()`, never via hooks
|
||||||
|
- Side effects (cache invalidation, analytics) go in `useEffect`, never in the render body
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Domain & Types
|
||||||
|
|
||||||
|
### 7.1 Single Source of Truth
|
||||||
|
|
||||||
|
All domain types live in `src/domain/`. Never duplicate a type or interface.
|
||||||
|
|
||||||
|
| File | Contents |
|
||||||
|
|---|---|
|
||||||
|
| `enums.ts` | All enums: `AssetType`, `ResultType`, `MatchStatus`, `UserRole`, `WorkspaceType`, etc. |
|
||||||
|
| `property.ts` | `Property`, `Location`, `ContactPerson` |
|
||||||
|
| `match.ts` | `Match`, `ScoreBreakdown`, `ScoreFactor`, `TradeOff`, `Risk` |
|
||||||
|
| `need.ts` | `Need`, `AreaRange`, `BudgetRange`, `WeightingProfile` |
|
||||||
|
| `unifiedResult.ts` | `UnifiedMatchResult` (the discriminated union for result feeds) |
|
||||||
|
| `scoring.ts` | `ScoringWeightProfile`, `HardFilterResult`, `MatchEngineOutput` |
|
||||||
|
| `aiOutput.ts` | AI scoring, extraction, monitoring types |
|
||||||
|
| ... | See `/domain/index.ts` for full list |
|
||||||
|
|
||||||
|
### 7.2 No Inline Type Duplication
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// ❌ Redefining a type locally
|
||||||
|
interface LocalProperty { id: string; title: string; ... }
|
||||||
|
|
||||||
|
// ✅ Import from domain
|
||||||
|
import type { Property } from '../../domain/property'
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.3 Response Types
|
||||||
|
|
||||||
|
All service responses use the standardized wrapper from `src/services/types.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
type ListResponse<T> = { data: T[]; total: number }
|
||||||
|
type ItemResponse<T> = { data: T }
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Design System
|
||||||
|
|
||||||
|
All visual constants are centralized. **Never hardcode a color, spacing multiplier, or border-radius in a component.**
|
||||||
|
|
||||||
|
### 8.1 Color Tokens (`src/lib/ds.ts`)
|
||||||
|
|
||||||
|
```ts
|
||||||
|
DS_COLORS.confidence.high // '#1a7a4a'
|
||||||
|
DS_COLORS.confidence.medium // '#d97706'
|
||||||
|
DS_COLORS.confidence.low // '#c0392b'
|
||||||
|
DS_COLORS.risk.critical // '#dc2626'
|
||||||
|
DS_COLORS.risk.warning // '#d97706'
|
||||||
|
DS_COLORS.freshness.stale // '#d97706'
|
||||||
|
DS_COLORS.freshness.outdated// '#dc2626'
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.2 Score → Color Helpers (`src/lib/utils.ts`)
|
||||||
|
|
||||||
|
| Function | Input | Output | Use for |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `matchScoreHex(score)` | 0–100 | hex string | Overall match score color |
|
||||||
|
| `confidenceHex(score)` | 0–1 | hex string | Confidence score color |
|
||||||
|
| `dataQualityColor(score)` | 0–1 | hex string | Data quality color |
|
||||||
|
| `criterionScoreColor(score)` | 0–100 | MUI color token | Individual criterion badge |
|
||||||
|
| `criterionScoreTextColor(score)` | 0–100 | hex string | Criterion text color |
|
||||||
|
|
||||||
|
### 8.3 Result Type Metadata (`src/lib/ds.ts`)
|
||||||
|
|
||||||
|
Use `RESULT_TYPE_META[resultType]` for label, color, and background. Never define these inline.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// ❌
|
||||||
|
label="Verified Portfolio"
|
||||||
|
bgcolor="#1e3a5f"
|
||||||
|
|
||||||
|
// ✅
|
||||||
|
import { RESULT_TYPE_META } from '../../lib/ds'
|
||||||
|
label={RESULT_TYPE_META[result.resultType]?.label}
|
||||||
|
bgcolor={RESULT_TYPE_META[result.resultType]?.color}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.4 Thresholds (`src/lib/constants.ts`)
|
||||||
|
|
||||||
|
```ts
|
||||||
|
SCORE_STRONG = 80 // match score considered "strong"
|
||||||
|
SCORE_MODERATE = 60 // match score considered "moderate"
|
||||||
|
CONF_HIGH = 0.85 // confidence considered "high"
|
||||||
|
CONF_MEDIUM = 0.65 // confidence considered "medium"
|
||||||
|
DQ_HIGH = 0.8 // data quality considered "high"
|
||||||
|
DQ_MEDIUM = 0.6 // data quality considered "medium"
|
||||||
|
```
|
||||||
|
|
||||||
|
These thresholds are used by `scoreToConfidenceLevel()`, `scoreToDataQualityLevel()`, and all badge logic. Never re-define them locally.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. AI Integration Rules
|
||||||
|
|
||||||
|
Property Match integrates AI as a **service**, not as a feature bolted onto the UI.
|
||||||
|
|
||||||
|
### 9.1 Model-Agnostic Interface
|
||||||
|
|
||||||
|
AI is accessed exclusively through `IAIService` (`src/services/ai/IAIService.ts`). The interface defines capabilities (parseNeed, compareProperties, generateDecisionBrief, etc.). No component imports an LLM client directly.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// ❌ Never in a component or hook
|
||||||
|
import OpenAI from 'openai'
|
||||||
|
const client = new OpenAI(...)
|
||||||
|
|
||||||
|
// ✅ Always via service
|
||||||
|
import { aiService } from '../services/aiService'
|
||||||
|
const result = await aiService.parseNeed(text)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 9.2 Implementations
|
||||||
|
|
||||||
|
| Implementation | File | Used when |
|
||||||
|
|---|---|---|
|
||||||
|
| `MockAIService` | `src/services/ai/MockAIService.ts` | Dev / CI — deterministic, no API calls |
|
||||||
|
| `OpenRouterAIService` | `src/services/ai/OpenRouterAIService.ts` | Production — OpenRouter API |
|
||||||
|
|
||||||
|
Swap implementations by changing the export in `src/services/aiService.ts`. No other file changes required.
|
||||||
|
|
||||||
|
### 9.3 Structured Responses
|
||||||
|
|
||||||
|
AI calls must return typed, structured objects — never raw strings passed into JSX.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// ❌ Raw LLM output in JSX
|
||||||
|
<Typography>{llmResponse}</Typography>
|
||||||
|
|
||||||
|
// ✅ Parsed, validated, typed output
|
||||||
|
const result: ParsedNeed = await aiService.parseNeed(text)
|
||||||
|
<NeedCardPreview need={result.need} confidence={result.confidence} />
|
||||||
|
```
|
||||||
|
|
||||||
|
All AI response schemas are validated with Zod before use.
|
||||||
|
|
||||||
|
### 9.4 Explainable Outputs
|
||||||
|
|
||||||
|
Every AI output that influences a user decision must be accompanied by an explanation.
|
||||||
|
|
||||||
|
- Match scores include `ScoreFactor[]` (positive & negative)
|
||||||
|
- Trade-off analysis includes `TradeOff[]` with severity
|
||||||
|
- Future availability signals include `probabilityScore` + `signalBasis`
|
||||||
|
- AI-parsed needs include `confidence` + which fields were inferred vs. stated
|
||||||
|
|
||||||
|
### 9.5 Human-in-the-Loop
|
||||||
|
|
||||||
|
AI suggestions are always proposals — the user confirms or overrides.
|
||||||
|
|
||||||
|
- Need parsing: user reviews extracted criteria before search runs
|
||||||
|
- AI recommendations: presented with confidence, not as commands
|
||||||
|
- Review queue (`/ops`): all AI-generated content can be reviewed and corrected
|
||||||
|
- Governance: `aiMonitoringService` logs every AI output with model version, prompt hash, and response
|
||||||
|
|
||||||
|
### 9.6 Fallback Handling
|
||||||
|
|
||||||
|
Every AI call has a defined fallback. If the AI service fails or returns a low-confidence result:
|
||||||
|
- The UI shows a manual entry path
|
||||||
|
- Confidence badges reflect uncertainty
|
||||||
|
- No AI failure should block a user workflow
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Performance Rules
|
||||||
|
|
||||||
|
### 10.1 No Expensive Calculations in Render
|
||||||
|
|
||||||
|
Any computation over a list, aggregation, or filter runs in `useMemo`, not in the render body.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// ❌ Runs on every render — expensive if list is large
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{properties
|
||||||
|
.filter(p => p.confidenceScore >= 0.7)
|
||||||
|
.sort((a, b) => b.areaSqm - a.areaSqm)
|
||||||
|
.map(p => <PropertyCard key={p.id} property={p} />)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
// ✅ Runs only when dependencies change
|
||||||
|
const visible = useMemo(
|
||||||
|
() => properties.filter(p => p.confidenceScore >= 0.7).sort((a, b) => b.areaSqm - a.areaSqm),
|
||||||
|
[properties]
|
||||||
|
)
|
||||||
|
return <div>{visible.map(p => <PropertyCard key={p.id} property={p} />)}</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 10.2 Memoize List Items
|
||||||
|
|
||||||
|
Components rendered in lists (feeds, tables, grids) must be wrapped in `React.memo` to prevent cascade re-renders when parent state changes (e.g., a filter toggle or `selectedId` update).
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// ✅ List item — always memo
|
||||||
|
export const PropertyCard = memo(function PropertyCard({ property, onSelect }: Props) { ... })
|
||||||
|
export const ReminderListRow = memo(function ReminderListRow({ reminder }: Props) { ... })
|
||||||
|
```
|
||||||
|
|
||||||
|
### 10.3 Stable Callbacks for Memoized Children
|
||||||
|
|
||||||
|
When passing callbacks as props to memoized children, wrap them in `useCallback`. An unstable function reference breaks `React.memo`.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// ✅ Stable reference — memo children won't re-render
|
||||||
|
const handleSelect = useCallback((id: string) => setSelectedId(id), [])
|
||||||
|
```
|
||||||
|
|
||||||
|
### 10.4 Single-Pass Aggregations
|
||||||
|
|
||||||
|
When computing multiple aggregates over the same list (e.g., count of matchReady + criticalGaps + lowConfidence), use a single `for` loop in one `useMemo` — not three separate `.filter()` calls.
|
||||||
|
|
||||||
|
### 10.5 Virtualization
|
||||||
|
|
||||||
|
The current page size (`DEFAULT_PAGE_SIZE = 25`) makes React virtualization unnecessary today. If paginated lists are removed or page size exceeds ~200 items, evaluate `@tanstack/virtual` for the result feed and property table.
|
||||||
|
|
||||||
|
### 10.6 Bundle Splitting
|
||||||
|
|
||||||
|
All routes are already lazy-loaded via `React.lazy()` in `App.tsx`. Keep it that way — do not import page components directly.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Security & Governance
|
||||||
|
|
||||||
|
### 11.1 Auth & Session
|
||||||
|
|
||||||
|
- Auth state lives in `sessionStore` (Zustand)
|
||||||
|
- On logout, **clear the React Query cache**: `queryClient.clear()`
|
||||||
|
- Role checks use `src/lib/permissions.ts` — never inline `role === 'PROPERTY_MANAGER'`
|
||||||
|
- Workspace access is enforced by `<ProtectedRoute workspace={...} />` — never guard with `if` inside a component
|
||||||
|
|
||||||
|
### 11.2 Role-Based Rendering
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// ❌ Magic string role check
|
||||||
|
if (user.role === 'PROPERTY_MANAGER') { ... }
|
||||||
|
|
||||||
|
// ✅ Permission helper
|
||||||
|
import { canViewMatchCenter } from '../../lib/permissions'
|
||||||
|
if (canViewMatchCenter(user)) { ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
### 11.3 No Sensitive Data in Client State
|
||||||
|
|
||||||
|
- Do not store tokens, passwords, or PII in Zustand stores beyond what is strictly needed for session identity
|
||||||
|
- Do not log sensitive fields in console statements
|
||||||
|
- Do not pass sensitive data as URL params (use navigation state instead)
|
||||||
|
|
||||||
|
### 11.4 AI Audit Logging
|
||||||
|
|
||||||
|
Every AI output that influences a business decision is logged via `aiMonitoringService`. Logs include:
|
||||||
|
- Timestamp
|
||||||
|
- Model ID and version
|
||||||
|
- Prompt hash (not the full prompt — for privacy)
|
||||||
|
- Response summary
|
||||||
|
- User ID and workspace
|
||||||
|
|
||||||
|
### 11.5 Data Lineage
|
||||||
|
|
||||||
|
Every property record carries `DataSource` metadata (origin, freshness, last verified). Surface this data proactively — especially when `freshness === 'STALE'` or `freshness === 'OUTDATED'`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Adding a New Feature — Checklist
|
||||||
|
|
||||||
|
Before writing any code, answer these questions:
|
||||||
|
|
||||||
|
1. **Which workspace does this belong to?** (SUPPLY / DEMAND / OPS)
|
||||||
|
2. **What decision does this help the user make?** (If you can't answer this, reconsider the feature)
|
||||||
|
3. **Does a provider already exist for this entity?** If not, create `I[Entity]Provider` + `Mockup[Entity]Provider` first
|
||||||
|
4. **Does a service already exist?** If not, create `[entity]Service` that wraps the provider
|
||||||
|
5. **Does a hook already exist?** If not, create `use[Entity]` in `src/hooks/`
|
||||||
|
6. **Is this server state or UI state?** (see STATE_MANAGEMENT.md)
|
||||||
|
7. **Does this show a score or confidence value?** Use `DS_COLORS` + helper functions from `lib/utils.ts`
|
||||||
|
8. **Does this involve AI?** Go through `IAIService` — never call LLM APIs directly
|
||||||
|
9. **Does this need explainability?** Add score factors, provenance, or reasoning before calling it done
|
||||||
|
|
||||||
|
**Do not** add business logic to components. **Do not** duplicate types from `/domain/`. **Do not** hardcode colors, labels, or thresholds.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. File Naming & Organization
|
||||||
|
|
||||||
|
| Location | What goes there |
|
||||||
|
|---|---|
|
||||||
|
| `src/pages/[workspace]/` | Route-level page components |
|
||||||
|
| `src/components/[feature]/` | Feature-specific components |
|
||||||
|
| `src/components/shared/` | Components used across features |
|
||||||
|
| `src/components/ui/` | Atomic UI: ErrorBoundary, EmptyState, Toast, DecisionContextPanel |
|
||||||
|
| `src/components/layout/` | AppShell, PageHeader, RightContextPanel |
|
||||||
|
| `src/hooks/` | React Query hooks |
|
||||||
|
| `src/services/` | Business logic services |
|
||||||
|
| `src/services/ai/` | AI service interface + implementations + prompts |
|
||||||
|
| `src/provider/` | Data access interfaces + mock implementations |
|
||||||
|
| `src/domain/` | TypeScript types and enums |
|
||||||
|
| `src/features/matching/` | Scoring and ranking algorithms |
|
||||||
|
| `src/stores/` | Zustand stores |
|
||||||
|
| `src/lib/` | Shared utilities, theme, constants, design system |
|
||||||
|
| `src/mock-data/` | Seed data for mock providers |
|
||||||
|
|
||||||
|
Each component folder has an `index.ts` barrel export. New components must be added to the barrel before use.
|
||||||
|
|
||||||
|
**File extension rule:** Any file that contains JSX must use `.tsx`. Plain TypeScript (no JSX) uses `.ts`. Violating this causes Vite parse errors.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. TypeScript Rules
|
||||||
|
|
||||||
|
- `strict: true` is enforced — do not disable strict mode or individual checks
|
||||||
|
- Prefer `interface` for object shapes (can be extended); use `type` for unions and aliases
|
||||||
|
- Props interfaces are named `Props` (local) or `[ComponentName]Props` (exported)
|
||||||
|
- Never use `@ts-ignore` — fix the type instead
|
||||||
|
- `as unknown as T` is acceptable only when bridging genuinely untyped third-party code; wrap it in a named adapter function
|
||||||
|
- Enums from `src/domain/enums.ts` are the source of truth — never redefine values locally
|
||||||
|
|||||||
@@ -0,0 +1,226 @@
|
|||||||
|
# Code Review Checklist — Property Match
|
||||||
|
|
||||||
|
> Use this checklist before every PR review and before considering any implementation "done".
|
||||||
|
> For architecture context see [ARCHITECTURE.md](./ARCHITECTURE.md).
|
||||||
|
> For coding rules see [CLAUDE.md](./CLAUDE.md).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## A. Component Size & Structure
|
||||||
|
|
||||||
|
- [ ] **Is the component too large?**
|
||||||
|
- Page component: hard limit 300 lines, target 150–200
|
||||||
|
- Feature component: hard limit 250 lines, target 100–150
|
||||||
|
- Atom/shared component: hard limit 150 lines
|
||||||
|
- If over limit: extract sub-components, move constants to `*Constants.ts`, move helpers to `*Utils.ts` (`.tsx` if JSX)
|
||||||
|
|
||||||
|
- [ ] **Is business logic inside JSX?**
|
||||||
|
- `.filter()`, `.sort()`, `.reduce()`, conditional rendering based on computed values — these belong in `useMemo`, not inline in the return
|
||||||
|
- The render body should read computed values, not compute them
|
||||||
|
|
||||||
|
- [ ] **Are hardcoded strings present?**
|
||||||
|
- Status labels (`'ACTIVE'`, `'Aktiv'`, `'PROPERTY_MANAGER'`) must come from `enums.ts` or `constants.ts`
|
||||||
|
- Route paths must come from `ROUTES` constants, not string literals
|
||||||
|
- German UI labels for enums live in `constants.ts` (e.g., `ASSET_TYPE_LABELS`)
|
||||||
|
|
||||||
|
- [ ] **Are hardcoded colors or thresholds present?**
|
||||||
|
- Colors like `'#1a7a4a'`, `'#dc2626'` directly in `sx` props → use `DS_COLORS` from `lib/ds.ts`
|
||||||
|
- Score thresholds like `score >= 80` → use `SCORE_STRONG` from `constants.ts`
|
||||||
|
- Confidence thresholds like `conf >= 0.75` → use `CONF_HIGH` from `constants.ts`
|
||||||
|
|
||||||
|
- [ ] **Are duplicate result type labels/colors defined locally?**
|
||||||
|
- `RESULT_TYPE_META` in `lib/ds.ts` is the single source of truth
|
||||||
|
- `RESULT_TYPE_LABEL` / `RESULT_TYPE_COLOR` inline objects anywhere else → delete them
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## B. State & Data
|
||||||
|
|
||||||
|
- [ ] **Is server data stored in Zustand?**
|
||||||
|
- Any `useEffect(() => setFoo(data), [data])` pattern that mirrors React Query data into local/Zustand state → remove it, read directly from the query
|
||||||
|
- Server data lives exclusively in the React Query cache
|
||||||
|
|
||||||
|
- [ ] **Is derived state stored in state?**
|
||||||
|
- `const [count, setCount] = useState(0)` + `useEffect` to keep it in sync → compute inline or use `useMemo`
|
||||||
|
- Exception: expensive computations may use `useMemo`
|
||||||
|
|
||||||
|
- [ ] **Is the full Zustand store subscribed to without a selector?**
|
||||||
|
- `const { a, b, c } = useMyStore()` — re-renders on every store mutation
|
||||||
|
- Use `useMyStore(s => s.a)` for single fields, `useShallow` for multiple fields
|
||||||
|
|
||||||
|
- [ ] **Is there a side effect in the render body?**
|
||||||
|
- `queryClient.invalidateQueries(...)` called during render → must be in `useEffect`
|
||||||
|
- Any `console.log`, analytics call, or mutation triggered directly in render body → `useEffect`
|
||||||
|
|
||||||
|
- [ ] **Are all hooks called before conditional early returns?**
|
||||||
|
- React rules of hooks: all `useState`, `useMemo`, `useCallback`, `useQuery` calls must appear before any `if (isLoading) return ...`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## C. Type Safety
|
||||||
|
|
||||||
|
- [ ] **Are there `any` casts?**
|
||||||
|
- `as any`, `: any`, `// @ts-ignore` — fix the type or use `unknown` with a type guard
|
||||||
|
- One exception: bridging untyped third-party code — must be isolated in a named adapter
|
||||||
|
|
||||||
|
- [ ] **Are domain types re-defined locally?**
|
||||||
|
- Any interface or type that duplicates something in `src/domain/` → import from domain instead
|
||||||
|
|
||||||
|
- [ ] **Are response types used?**
|
||||||
|
- Service functions should return `ListResponse<T>` or `ItemResponse<T>` from `services/types.ts`
|
||||||
|
|
||||||
|
- [ ] **Do `.tsx` files contain JSX and `.ts` files not?**
|
||||||
|
- Any file with JSX (`<Component />`, `<Box>`, `<Typography>`) must have `.tsx` extension
|
||||||
|
- Plain TypeScript utilities use `.ts`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## D. Layering & Architecture
|
||||||
|
|
||||||
|
- [ ] **Is business logic in a component?**
|
||||||
|
- Scoring calculations, ranking, filtering rules, AI calls, data transformation → move to service or `features/` layer
|
||||||
|
- Components only render and dispatch
|
||||||
|
|
||||||
|
- [ ] **Is a provider called directly from a component or hook?**
|
||||||
|
- `MockupPropertyProvider.getAll()` called in a hook → must go through `propertyService`
|
||||||
|
- All provider access is mediated by a service
|
||||||
|
|
||||||
|
- [ ] **Is an LLM API called directly?**
|
||||||
|
- Any `fetch` to an AI API, or direct `OpenAI` / `openrouter` SDK calls outside `src/services/ai/` → move behind `IAIService`
|
||||||
|
|
||||||
|
- [ ] **Are there cross-workspace component imports?**
|
||||||
|
- A supply component importing from `components/demand/` or vice versa → use `components/shared/` or `components/ui/` instead
|
||||||
|
|
||||||
|
- [ ] **Are the import direction rules respected?**
|
||||||
|
- `services/` must not import React hooks
|
||||||
|
- `domain/` must not import anything from `src/`
|
||||||
|
- `lib/constants.ts` must not import from components or services
|
||||||
|
- No circular imports
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## E. Loading, Error & Empty States
|
||||||
|
|
||||||
|
- [ ] **Does every data-dependent view handle loading?**
|
||||||
|
- Every `useQuery` that drives a list or table must show a skeleton or spinner while `isLoading`
|
||||||
|
- Use the appropriate skeleton component (`FeedSkeleton`, `ReminderSkeleton`, etc.)
|
||||||
|
|
||||||
|
- [ ] **Does every data-dependent view handle error?**
|
||||||
|
- `isError` from `useQuery` must show `<ErrorState message={error.message} />`
|
||||||
|
- Never leave an error state blank or silently swallowed
|
||||||
|
|
||||||
|
- [ ] **Does every list handle the empty state?**
|
||||||
|
- Empty results after filtering → `<FeedEmptyState filtered={true} />` with a reset action
|
||||||
|
- Empty because no data exists → `<FeedEmptyState filtered={false} />`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## F. Performance
|
||||||
|
|
||||||
|
- [ ] **Are list items wrapped in `React.memo`?**
|
||||||
|
- Components rendered inside `.map()` in feeds, tables, or grids → should be `memo()`
|
||||||
|
- Without memo, every parent state change (e.g., filter toggle, `selectedId`) re-renders the entire list
|
||||||
|
|
||||||
|
- [ ] **Are callbacks to memoized children wrapped in `useCallback`?**
|
||||||
|
- Passing `() => setSelectedId(id)` inline as a prop to a `memo()`-wrapped child → the memo is broken
|
||||||
|
- Wrap with `useCallback` and explicit dependency array
|
||||||
|
|
||||||
|
- [ ] **Are multiple aggregations over the same list collapsed into one pass?**
|
||||||
|
- Three separate `useMemo` calls that each `.filter()` the same array → collapse into one `useMemo` with a `for` loop
|
||||||
|
|
||||||
|
- [ ] **Is `useMemo` used for expensive filters and sorts?**
|
||||||
|
- `.filter()` + `.sort()` over 25+ items in the render body → `useMemo`
|
||||||
|
- Decision aggregates (matchReady count, criticalGaps count, etc.) in `Properties.tsx` → already in `useMemo`
|
||||||
|
|
||||||
|
- [ ] **Are routes lazy-loaded?**
|
||||||
|
- All page imports in `App.tsx` must use `React.lazy()` — never static imports for route components
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## G. Explainability & Trust
|
||||||
|
|
||||||
|
- [ ] **Does every score shown have a breakdown path?**
|
||||||
|
- A match score badge with no way to see *why* it's that score → add `ScoreBreakdownPanel` or link to MatchDetail
|
||||||
|
|
||||||
|
- [ ] **Are confidence levels shown proactively?**
|
||||||
|
- Data with `confidenceScore < CONF_MEDIUM` or `freshness === 'STALE'` → must be visually flagged
|
||||||
|
- `ConfidenceBadge`, `FreshnessIndicator`, `DataQualityBar` exist for this purpose
|
||||||
|
|
||||||
|
- [ ] **Are Future Availability signals clearly labeled as probabilistic?**
|
||||||
|
- `FUTURE_AVAILABILITY` results must always show `probabilityScore` and `signalBasis`
|
||||||
|
- They must never look identical to confirmed availability listings
|
||||||
|
|
||||||
|
- [ ] **Does AI output show its confidence?**
|
||||||
|
- AI-extracted need criteria → shown with confidence indicator
|
||||||
|
- AI match suggestions → shown with `MAISON_WORK` badge and probability
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## H. Security
|
||||||
|
|
||||||
|
- [ ] **Is the React Query cache cleared on logout?**
|
||||||
|
- Logout flow must call `queryClient.clear()` — otherwise stale data from previous user persists
|
||||||
|
|
||||||
|
- [ ] **Are role checks using `permissions.ts` helpers?**
|
||||||
|
- `user.role === 'PROPERTY_MANAGER'` inline → use `canViewMatchCenter(user)` from `lib/permissions.ts`
|
||||||
|
|
||||||
|
- [ ] **Are workspace route guards in place?**
|
||||||
|
- Every supply/demand/ops route wrapped in `<ProtectedRoute workspace={...} />`
|
||||||
|
- Never guard with `if (user.workspace !== ...)` inside a component body
|
||||||
|
|
||||||
|
- [ ] **Is sensitive data stored in Zustand?**
|
||||||
|
- PII, tokens, and sensitive fields beyond current-user identity should not persist in client state
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## I. Backend-Readiness
|
||||||
|
|
||||||
|
- [ ] **Is the provider interface complete?**
|
||||||
|
- Any new entity that creates a mock shortcut (array mutation inline, no interface) → add `I[Entity]Provider` and `Mockup[Entity]Provider`
|
||||||
|
|
||||||
|
- [ ] **Are async patterns consistent?**
|
||||||
|
- All provider methods are `async` — if a new provider method is synchronous, fix it before it gets called from a service
|
||||||
|
|
||||||
|
- [ ] **Are query keys stable and specific?**
|
||||||
|
- Query key `['properties']` is fine for list; `['properties', id]` for single item
|
||||||
|
- Keys that include derived or computed objects (not primitives) will break cache lookup
|
||||||
|
|
||||||
|
- [ ] **Are stale times centralized?**
|
||||||
|
- No `staleTime: 60_000` or `staleTime: 5 * 60 * 1000` inline in hook files
|
||||||
|
- Use `STALE_PROPERTIES`, `STALE_MATCHES`, etc. from `src/lib/constants.ts`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## J. AI & Model Governance
|
||||||
|
|
||||||
|
- [ ] **Is the AI call going through `IAIService`?**
|
||||||
|
- Direct fetch to AI API anywhere outside `src/services/ai/` → move it
|
||||||
|
|
||||||
|
- [ ] **Is the AI response validated with Zod before use?**
|
||||||
|
- Raw LLM string parsed inline with `JSON.parse()` without a schema → add Zod validation
|
||||||
|
|
||||||
|
- [ ] **Is the AI output logged via `aiMonitoringService`?**
|
||||||
|
- Any AI call that influences a business decision (need parsing, match recommendation, market signal) → log it
|
||||||
|
|
||||||
|
- [ ] **Does the AI flow have a fallback?**
|
||||||
|
- If `aiService` throws or returns `confidence < threshold`, is there a manual entry path?
|
||||||
|
- AI failures must never block the user workflow
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick Checklist — Pre-Commit
|
||||||
|
|
||||||
|
Before every commit, verify:
|
||||||
|
|
||||||
|
```
|
||||||
|
[ ] npx tsc --noEmit → zero errors
|
||||||
|
[ ] No new hardcoded colors, routes, or role strings
|
||||||
|
[ ] No business logic added to a JSX return block
|
||||||
|
[ ] No React Query data mirrored into Zustand or useState
|
||||||
|
[ ] All hooks called before early returns
|
||||||
|
[ ] New list-item components wrapped in React.memo
|
||||||
|
[ ] New provider methods are async and match the interface
|
||||||
|
[ ] New feature goes through Provider → Service → Hook → Component
|
||||||
|
[ ] Loading, error, and empty states handled for any new data-fetching view
|
||||||
|
[ ] File with JSX has .tsx extension
|
||||||
|
```
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
# State Management — Property Match
|
||||||
|
|
||||||
|
## Decision Tree
|
||||||
|
|
||||||
|
```
|
||||||
|
Is it server data (fetched from an API or provider)?
|
||||||
|
→ React Query (useQuery / useMutation)
|
||||||
|
|
||||||
|
Is it global UI state shared across unrelated components?
|
||||||
|
→ Zustand store
|
||||||
|
|
||||||
|
Is it local to a single component or parent-child chain?
|
||||||
|
→ useState / useReducer (local state)
|
||||||
|
|
||||||
|
Can it be computed from existing state/data?
|
||||||
|
→ Derived state (compute inline — no separate store field)
|
||||||
|
|
||||||
|
Is it cross-cutting auth / session context accessed in non-React code?
|
||||||
|
→ Zustand store read via getState() (not useStore hook)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## React Query — Server State
|
||||||
|
|
||||||
|
**Rule:** React Query owns all data that comes from a provider (mock or real).
|
||||||
|
Never copy React Query data into a Zustand store.
|
||||||
|
|
||||||
|
### When to use
|
||||||
|
|
||||||
|
- Fetching lists or detail records (`useQuery`)
|
||||||
|
- Creating, updating, deleting records (`useMutation`)
|
||||||
|
- Anything that needs cache invalidation or background refetch
|
||||||
|
|
||||||
|
### Patterns
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// ✅ Correct — server data in React Query
|
||||||
|
const { data: properties } = useProperties()
|
||||||
|
|
||||||
|
// ✅ Correct — mutation with cache invalidation
|
||||||
|
const createProp = useCreateProperty()
|
||||||
|
createProp.mutate(input, {
|
||||||
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['properties'] })
|
||||||
|
})
|
||||||
|
|
||||||
|
// ❌ Wrong — copying server data into a Zustand store
|
||||||
|
const [properties, setProperties] = useState([])
|
||||||
|
useEffect(() => { fetchProperties().then(setProperties) }, [])
|
||||||
|
```
|
||||||
|
|
||||||
|
### staleTime constants
|
||||||
|
|
||||||
|
All stale times live in `src/lib/constants.ts` — never define them locally in hooks.
|
||||||
|
|
||||||
|
| Constant | Value | Used for |
|
||||||
|
|----------|-------|---------|
|
||||||
|
| `STALE_PROPERTIES` | 5 min | Property lists and details |
|
||||||
|
| `STALE_MATCHES` | 2 min | Match results (change with need edits) |
|
||||||
|
| `STALE_SIGNALS` | 5 min | Future availability signals |
|
||||||
|
| `STALE_MARKET_SIGNALS` | 30 s | Market intelligence (ops team, real-time) |
|
||||||
|
| `STALE_REVIEW_QUEUE` | 30 s | Review queue tasks (ops team, real-time) |
|
||||||
|
|
||||||
|
### Query key conventions
|
||||||
|
|
||||||
|
```ts
|
||||||
|
['entity'] // list: ['properties'], ['matches'], ['reminders']
|
||||||
|
['entity', id] // single: ['property', id], ['match', id]
|
||||||
|
['entity', 'scope', id] // scoped: ['matches', 'need', needId]
|
||||||
|
['entity', filters] // filtered: ['properties', { assetType: 'OFFICE' }]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Zustand — UI / Interaction State
|
||||||
|
|
||||||
|
**Rule:** Zustand owns UI state only. It must never hold data that belongs in React Query.
|
||||||
|
|
||||||
|
### When to use
|
||||||
|
|
||||||
|
- Multi-step wizard state (`offerWizardStore`)
|
||||||
|
- Dialog open/close + pending item (`shortlistStore`, `pipelineStore`)
|
||||||
|
- Sidebar / layout flags (`layoutStore`)
|
||||||
|
- Auth session (`sessionStore` — special case, also read by services via `getState()`)
|
||||||
|
- Toast queue (`toastStore`)
|
||||||
|
- Active selection in a panel (`matchCenterStore`, `reminderStore`)
|
||||||
|
|
||||||
|
### When NOT to use
|
||||||
|
|
||||||
|
- Data fetched from a provider → use React Query
|
||||||
|
- State used only inside one component → use `useState`
|
||||||
|
- Values derived from existing state → compute inline
|
||||||
|
|
||||||
|
### Selector rules
|
||||||
|
|
||||||
|
Always use a selector. Subscribing to the full store causes re-renders on every state mutation.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// ✅ Individual field selector — re-renders only when that field changes
|
||||||
|
const isOpen = useLayoutStore(s => s.isRightPanelOpen)
|
||||||
|
const close = useLayoutStore(s => s.closeRightPanel)
|
||||||
|
|
||||||
|
// ✅ useShallow for multiple fields from the same store
|
||||||
|
import { useShallow } from 'zustand/react/shallow'
|
||||||
|
const { filterType, filterStatus } = useReminderStore(
|
||||||
|
useShallow(s => ({ filterType: s.filterType, filterStatus: s.filterStatus }))
|
||||||
|
)
|
||||||
|
|
||||||
|
// ❌ No selector — subscribes to all store fields, re-renders on any change
|
||||||
|
const { isOpen, close } = useLayoutStore()
|
||||||
|
|
||||||
|
// ✅ Non-React code (services, mutations) reads store via getState — no subscription
|
||||||
|
const user = useSessionStore.getState().currentUser
|
||||||
|
```
|
||||||
|
|
||||||
|
### Store inventory
|
||||||
|
|
||||||
|
| Store | Responsibility | Key state |
|
||||||
|
|-------|---------------|-----------|
|
||||||
|
| `layoutStore` | App shell layout | `activeWorkspace`, `sidebarCollapsed`, `isRightPanelOpen` |
|
||||||
|
| `sessionStore` | Auth / current user | `currentUser`, `isAuthenticated`, `sessionStatus` |
|
||||||
|
| `assistantStore` | AI drawer conversation | `isOpen`, `messages`, `context`, `isLoading` |
|
||||||
|
| `compareStore` | Compare tray items | `compareItems[]` |
|
||||||
|
| `pipelineStore` | Pipeline items + add-dialog | `items[]`, `dialogOpen`, `pendingItem` |
|
||||||
|
| `shortlistStore` | Shortlist selection + add-dialog | `selectedShortlistId`, `dialogOpen`, `pendingItem` |
|
||||||
|
| `offerWizardStore` | Multi-step offer wizard | `isOpen`, `currentStep`, `selectedPropertyIds`, `editableFields` |
|
||||||
|
| `matchCenterStore` | Supply-side match center selection | `selectedPropertyId`, `selectedNeedId` |
|
||||||
|
| `reminderStore` | Reminder list filters + drawer | `filterType`, `filterStatus`, `selectedId`, `drawerOpen` |
|
||||||
|
| `toastStore` | Toast notification queue | `toasts[]` |
|
||||||
|
|
||||||
|
### Adding a new store field
|
||||||
|
|
||||||
|
Ask these questions first:
|
||||||
|
1. Is this server data? → React Query instead.
|
||||||
|
2. Is this only used in one component? → `useState` instead.
|
||||||
|
3. Is this derived from existing state? → compute it, don't store it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Local State — Component-Scoped
|
||||||
|
|
||||||
|
**Rule:** Default to `useState`. Only escalate to Zustand when state genuinely needs to be shared across unrelated components.
|
||||||
|
|
||||||
|
### When to use
|
||||||
|
|
||||||
|
- Form input values
|
||||||
|
- Toggle / accordion open state
|
||||||
|
- Hover / focus effects
|
||||||
|
- Step progress inside a self-contained wizard step
|
||||||
|
- Any state that resets when the component unmounts
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// ✅ Local — form input, no other component needs this
|
||||||
|
const [name, setName] = useState('')
|
||||||
|
|
||||||
|
// ✅ Local — dialog only opened from one place
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
|
||||||
|
// ❌ Should be local — extracted to store unnecessarily
|
||||||
|
// (e.g. a "confirmDialogOpen" only ever toggled from one parent)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Derived State — Compute, Don't Store
|
||||||
|
|
||||||
|
**Rule:** Never store a value that can be computed from existing state or query data. Compute it at render time.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// ✅ Derived — compute from store
|
||||||
|
const isFull = compareItems.length >= MAX_COMPARE_ITEMS // NOT stored
|
||||||
|
|
||||||
|
// ✅ Derived — compute from React Query data
|
||||||
|
const overdueReminders = reminders.filter(r => isPastDue(r.dueDate)) // NOT stored
|
||||||
|
|
||||||
|
// ❌ Stored derived state — causes sync bugs
|
||||||
|
const [overdueCount, setOverdueCount] = useState(0)
|
||||||
|
useEffect(() => setOverdueCount(reminders.filter(...).length), [reminders])
|
||||||
|
```
|
||||||
|
|
||||||
|
Exception: expensive computations (e.g. score calculation over thousands of items) may use `useMemo`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Context — When Neither React Query nor Zustand Fits
|
||||||
|
|
||||||
|
Use React Context for:
|
||||||
|
- Dependency injection (swap provider implementations)
|
||||||
|
- Tree-scoped state (e.g. a form context for nested inputs)
|
||||||
|
- Auth abstraction (`AuthProvider` wraps `sessionStore` so components don't import the store directly)
|
||||||
|
|
||||||
|
Do NOT use Context as a replacement for React Query or Zustand — it causes cascading re-renders without cache or subscription granularity.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Service / Non-React Code
|
||||||
|
|
||||||
|
Services must not import React hooks. They access Zustand state via `getState()`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// ✅ In a service — no hook, no subscription
|
||||||
|
const user = useSessionStore.getState().currentUser
|
||||||
|
|
||||||
|
// ✅ In a React Query mutation onError
|
||||||
|
onError: () => useToastStore.getState().showToast('Fehler aufgetreten', 'error')
|
||||||
|
|
||||||
|
// ❌ Services must never call useStore hooks
|
||||||
|
import { useSessionStore } from '../stores/sessionStore'
|
||||||
|
const { currentUser } = useSessionStore() // only valid inside a React component
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Anti-Patterns to Avoid
|
||||||
|
|
||||||
|
| Anti-pattern | Why bad | Fix |
|
||||||
|
|---|---|---|
|
||||||
|
| `useStore()` without selector | Re-renders on every store mutation | Use `s => s.field` selector or `useShallow` |
|
||||||
|
| Server data in Zustand | Duplicates cache, causes stale/sync bugs | React Query |
|
||||||
|
| Derived state stored in state | Sync bugs, extra renders | Compute inline |
|
||||||
|
| Local dialog state in global store | Bloats store, breaks encapsulation | `useState` |
|
||||||
|
| Cross-store imports | Tight coupling, circular risk | Keep stores independent |
|
||||||
|
| Hook-local `STALE_*` constants | Inconsistent cache behaviour | Use `src/lib/constants.ts` |
|
||||||
@@ -19,4 +19,29 @@ export default defineConfig([
|
|||||||
globals: globals.browser,
|
globals: globals.browser,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
// ── Design Token Enforcement ────────────────────────────────────────────────
|
||||||
|
// New hardcoded hex colors in components/pages are blocked (error).
|
||||||
|
// Existing violations are tracked by: npm run check:tokens
|
||||||
|
//
|
||||||
|
// Use instead:
|
||||||
|
// DS_TEXT.secondary, DS_SURFACE.success.bg (src/lib/ds.ts)
|
||||||
|
// matchScoreHex(score), criterionScoreTextColor(score) (src/lib/utils.ts)
|
||||||
|
// RESULT_TYPE_META[type].color (src/lib/ds.ts)
|
||||||
|
// MUI theme tokens: "primary.main", "text.secondary"
|
||||||
|
{
|
||||||
|
files: ['src/components/**/*.{ts,tsx}', 'src/pages/**/*.{ts,tsx}'],
|
||||||
|
rules: {
|
||||||
|
'no-restricted-syntax': [
|
||||||
|
'warn',
|
||||||
|
{
|
||||||
|
selector: [
|
||||||
|
'Property[key.name=/^(bgcolor|color|borderColor|background|fill|stroke)$/]',
|
||||||
|
' > Literal[value=/^#[0-9A-Fa-f]{3,8}$/]',
|
||||||
|
].join(''),
|
||||||
|
message:
|
||||||
|
'No hardcoded hex colors in sx/style props. Use DS_TEXT, DS_SURFACE, DS_BORDER, DS_BG, BADGE_COLORS from src/lib/ds.ts, or helper functions from src/lib/utils.ts.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
])
|
])
|
||||||
|
|||||||
Generated
+1296
-1
File diff suppressed because it is too large
Load Diff
+11
-2
@@ -7,7 +7,11 @@
|
|||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b && vite build",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest",
|
||||||
|
"test:coverage": "vitest run --coverage",
|
||||||
|
"check:tokens": "node scripts/check-tokens.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
@@ -30,18 +34,23 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
"@tailwindcss/vite": "^4.3.0",
|
"@tailwindcss/vite": "^4.3.0",
|
||||||
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
|
"@testing-library/react": "^16.3.2",
|
||||||
"@types/leaflet": "^1.9.21",
|
"@types/leaflet": "^1.9.21",
|
||||||
"@types/node": "^24.12.3",
|
"@types/node": "^24.12.3",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vitejs/plugin-react": "^6.0.1",
|
"@vitejs/plugin-react": "^6.0.1",
|
||||||
|
"@vitest/coverage-v8": "^4.1.7",
|
||||||
"eslint": "^10.3.0",
|
"eslint": "^10.3.0",
|
||||||
"eslint-plugin-react-hooks": "^7.1.1",
|
"eslint-plugin-react-hooks": "^7.1.1",
|
||||||
"eslint-plugin-react-refresh": "^0.5.2",
|
"eslint-plugin-react-refresh": "^0.5.2",
|
||||||
"globals": "^17.6.0",
|
"globals": "^17.6.0",
|
||||||
|
"jsdom": "^29.1.1",
|
||||||
"tailwindcss": "^4.3.0",
|
"tailwindcss": "^4.3.0",
|
||||||
"typescript": "~6.0.2",
|
"typescript": "~6.0.2",
|
||||||
"typescript-eslint": "^8.59.2",
|
"typescript-eslint": "^8.59.2",
|
||||||
"vite": "^8.0.12"
|
"vite": "^8.0.12",
|
||||||
|
"vitest": "^4.1.7"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* check-tokens.js
|
||||||
|
*
|
||||||
|
* Counts hardcoded hex color literals in sx props / color attributes
|
||||||
|
* across src/components and src/pages. Fails (exit 1) if count exceeds THRESHOLD.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* node scripts/check-tokens.js — report + fail if > threshold
|
||||||
|
* node scripts/check-tokens.js --report — report only, always exits 0
|
||||||
|
*
|
||||||
|
* Run via: npm run check:tokens
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { readdirSync, readFileSync, statSync } from 'node:fs'
|
||||||
|
import { join, extname } from 'node:path'
|
||||||
|
|
||||||
|
// Hex literals allowed before CI blocks the build.
|
||||||
|
// This is a ratchet — lower it as migration progresses. Never raise it.
|
||||||
|
// Baseline after initial token migration (2026-05-24): 1958
|
||||||
|
const THRESHOLD = 1958
|
||||||
|
|
||||||
|
const HEX_PATTERN = /#[0-9A-Fa-f]{3,8}\b/g
|
||||||
|
|
||||||
|
const SEARCH_DIRS = ['src/components', 'src/pages']
|
||||||
|
|
||||||
|
function walk(dir) {
|
||||||
|
const files = []
|
||||||
|
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||||
|
const full = join(dir, entry.name)
|
||||||
|
if (entry.isDirectory()) files.push(...walk(full))
|
||||||
|
else if (entry.isFile() && ['.ts', '.tsx'].includes(extname(entry.name))) files.push(full)
|
||||||
|
}
|
||||||
|
return files
|
||||||
|
}
|
||||||
|
|
||||||
|
const reportOnly = process.argv.includes('--report')
|
||||||
|
const cwd = process.cwd()
|
||||||
|
|
||||||
|
let totalViolations = 0
|
||||||
|
const fileViolations = []
|
||||||
|
|
||||||
|
for (const searchDir of SEARCH_DIRS) {
|
||||||
|
const dir = join(cwd, searchDir)
|
||||||
|
for (const file of walk(dir)) {
|
||||||
|
const content = readFileSync(file, 'utf8')
|
||||||
|
const matches = content.match(HEX_PATTERN)
|
||||||
|
if (matches && matches.length > 0) {
|
||||||
|
totalViolations += matches.length
|
||||||
|
fileViolations.push({ file: file.replace(cwd + '\\', '').replace(cwd + '/', ''), count: matches.length })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fileViolations.sort((a, b) => b.count - a.count)
|
||||||
|
|
||||||
|
console.log('\n── Token Compliance Check ───────────────────────────────────────────')
|
||||||
|
console.log(`Total hex violations: ${totalViolations} (threshold: ${THRESHOLD})`)
|
||||||
|
console.log(`Status: ${totalViolations <= THRESHOLD ? '✅ PASS' : '❌ FAIL'}`)
|
||||||
|
console.log('\nTop 15 offenders:')
|
||||||
|
fileViolations.slice(0, 15).forEach(({ file, count }) => {
|
||||||
|
console.log(` ${count.toString().padStart(3)} ${file}`)
|
||||||
|
})
|
||||||
|
console.log('─────────────────────────────────────────────────────────────────────\n')
|
||||||
|
|
||||||
|
if (!reportOnly && totalViolations > THRESHOLD) {
|
||||||
|
console.error(`Error: ${totalViolations} hex violations exceed threshold of ${THRESHOLD}.`)
|
||||||
|
console.error('Run `node scripts/check-tokens.js --report` to see the full list.')
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
+2
-4
@@ -12,7 +12,7 @@ const WORKSPACE_HOME: Record<string, string> = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function RoleRedirect() {
|
function RoleRedirect() {
|
||||||
const { currentUser } = useSessionStore()
|
const currentUser = useSessionStore(s => s.currentUser)
|
||||||
const first = currentUser?.allowedWorkspaces[0] ?? WorkspaceType.SUPPLY
|
const first = currentUser?.allowedWorkspaces[0] ?? WorkspaceType.SUPPLY
|
||||||
return <Navigate to={WORKSPACE_HOME[first] ?? '/supply/dashboard'} replace />
|
return <Navigate to={WORKSPACE_HOME[first] ?? '/supply/dashboard'} replace />
|
||||||
}
|
}
|
||||||
@@ -26,7 +26,7 @@ const Anfragencenter = lazy(() => import('./pages/supply/Anfragencenter'))
|
|||||||
const FutureAvailability = lazy(() => import('./pages/supply/FutureAvailability'))
|
const FutureAvailability = lazy(() => import('./pages/supply/FutureAvailability'))
|
||||||
const DataQuality = lazy(() => import('./pages/supply/DataQuality'))
|
const DataQuality = lazy(() => import('./pages/supply/DataQuality'))
|
||||||
const ReminderManager = lazy(() => import('./pages/supply/ReminderManager'))
|
const ReminderManager = lazy(() => import('./pages/supply/ReminderManager'))
|
||||||
const MarketLeads = lazy(() => import('./pages/supply/MarketLeads'))
|
const MarketIntelligence = lazy(() => import('./pages/supply/MarketIntelligence'))
|
||||||
const NewListing = lazy(() => import('./pages/supply/NewListing'))
|
const NewListing = lazy(() => import('./pages/supply/NewListing'))
|
||||||
const MyListings = lazy(() => import('./pages/supply/MyListings'))
|
const MyListings = lazy(() => import('./pages/supply/MyListings'))
|
||||||
|
|
||||||
@@ -38,7 +38,6 @@ const Anfragen = lazy(() => import('./pages/demand/Anfragen'))
|
|||||||
const Pipeline = lazy(() => import('./pages/demand/Pipeline'))
|
const Pipeline = lazy(() => import('./pages/demand/Pipeline'))
|
||||||
const PropertyDetail = lazy(() => import('./pages/demand/PropertyDetail'))
|
const PropertyDetail = lazy(() => import('./pages/demand/PropertyDetail'))
|
||||||
|
|
||||||
const MarketIntelligence = lazy(() => import('./pages/ops/MarketIntelligence'))
|
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
@@ -62,7 +61,6 @@ function App() {
|
|||||||
<Route path="/supply/future-availability" element={<FutureAvailability />} />
|
<Route path="/supply/future-availability" element={<FutureAvailability />} />
|
||||||
<Route path="/supply/data-quality" element={<DataQuality />} />
|
<Route path="/supply/data-quality" element={<DataQuality />} />
|
||||||
<Route path="/supply/reminder-manager" element={<ReminderManager />} />
|
<Route path="/supply/reminder-manager" element={<ReminderManager />} />
|
||||||
<Route path="/supply/market-leads" element={<MarketLeads />} />
|
|
||||||
<Route path="/supply/market-intelligence" element={<MarketIntelligence />} />
|
<Route path="/supply/market-intelligence" element={<MarketIntelligence />} />
|
||||||
<Route path="/supply/new-listing" element={<NewListing />} />
|
<Route path="/supply/new-listing" element={<NewListing />} />
|
||||||
<Route path="/supply/my-listings" element={<MyListings />} />
|
<Route path="/supply/my-listings" element={<MyListings />} />
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Chip, Tooltip } from '@mui/material'
|
import { GenericBadge } from '../shared/GenericBadge'
|
||||||
import type { AIOutputError } from '../../domain/aiOutput'
|
import type { AIOutputError } from '../../domain/aiOutput'
|
||||||
|
|
||||||
const ERROR_CONFIG: Record<string, { label: string; color: string }> = {
|
const ERROR_CONFIG: Record<string, { label: string; color: string }> = {
|
||||||
@@ -11,13 +11,5 @@ const ERROR_CONFIG: Record<string, { label: string; color: string }> = {
|
|||||||
|
|
||||||
export function AIErrorBadge({ error }: { error: AIOutputError }) {
|
export function AIErrorBadge({ error }: { error: AIOutputError }) {
|
||||||
const { label, color } = ERROR_CONFIG[error.type] ?? { label: error.type, color: '#c0392b' }
|
const { label, color } = ERROR_CONFIG[error.type] ?? { label: error.type, color: '#c0392b' }
|
||||||
return (
|
return <GenericBadge label={label} color={color} bold tooltip={error.message} />
|
||||||
<Tooltip title={error.message} arrow>
|
|
||||||
<Chip
|
|
||||||
size="small"
|
|
||||||
label={label}
|
|
||||||
sx={{ bgcolor: `${color}18`, color, fontWeight: 700, fontSize: '0.65rem', cursor: 'default' }}
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,15 @@
|
|||||||
import { Chip } from '@mui/material'
|
import { GenericBadge } from '../shared/GenericBadge'
|
||||||
import type { ReviewStatus } from '../../domain/enums'
|
import type { ReviewStatus } from '../../domain/enums'
|
||||||
|
|
||||||
const CONFIG: Record<ReviewStatus, { label: string; color: string }> = {
|
const CONFIG: Record<ReviewStatus, { label: string; color: string }> = {
|
||||||
UNREVIEWED: { label: 'Ungeprüft', color: '#94a3b8' },
|
UNREVIEWED: { label: 'Ungeprüft', color: '#94a3b8' },
|
||||||
IN_REVIEW: { label: 'In Prüfung', color: '#d97706' },
|
IN_REVIEW: { label: 'In Prüfung', color: '#d97706' },
|
||||||
APPROVED: { label: 'Genehmigt', color: '#1a7a4a' },
|
APPROVED: { label: 'Genehmigt', color: '#1a7a4a' },
|
||||||
REJECTED: { label: 'Abgelehnt', color: '#c0392b' },
|
REJECTED: { label: 'Abgelehnt', color: '#c0392b' },
|
||||||
FLAGGED: { label: 'Markiert', color: '#ea580c' },
|
FLAGGED: { label: 'Markiert', color: '#ea580c' },
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AIOutputStatusBadge({ status }: { status: ReviewStatus }) {
|
export function AIOutputStatusBadge({ status }: { status: ReviewStatus }) {
|
||||||
const { label, color } = CONFIG[status] ?? { label: status, color: '#64748b' }
|
const { label, color } = CONFIG[status] ?? { label: status, color: '#64748b' }
|
||||||
return (
|
return <GenericBadge label={label} color={color} />
|
||||||
<Chip
|
|
||||||
size="small"
|
|
||||||
label={label}
|
|
||||||
sx={{ bgcolor: `${color}18`, color, fontWeight: 600, fontSize: '0.7rem' }}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { useState } from 'react'
|
|
||||||
import { Box, Button, CircularProgress, Typography } from '@mui/material'
|
import { Box, Button, CircularProgress, Typography } from '@mui/material'
|
||||||
import { Sparkles } from 'lucide-react'
|
import { Sparkles } from 'lucide-react'
|
||||||
import { aiService } from '../../services/aiService'
|
import { useGenerateOfferEmail } from '../../hooks/useAI'
|
||||||
|
|
||||||
interface AiOfferEmailButtonProps {
|
interface AiOfferEmailButtonProps {
|
||||||
needTitle: string
|
needTitle: string
|
||||||
@@ -16,33 +15,26 @@ export function AiOfferEmailButton({
|
|||||||
matchScores,
|
matchScores,
|
||||||
onGenerated,
|
onGenerated,
|
||||||
}: AiOfferEmailButtonProps) {
|
}: AiOfferEmailButtonProps) {
|
||||||
const [loading, setLoading] = useState(false)
|
const generateOfferEmail = useGenerateOfferEmail()
|
||||||
const [error, setError] = useState<string | null>(null)
|
|
||||||
|
|
||||||
const handleClick = async () => {
|
const handleClick = () => {
|
||||||
setLoading(true)
|
generateOfferEmail.mutate(
|
||||||
setError(null)
|
{ needTitle, properties: selectedProperties, matchScores },
|
||||||
try {
|
{
|
||||||
const res = await aiService.generateOfferEmail({
|
onSuccess: (res) => {
|
||||||
needTitle,
|
onGenerated(res.data.subject, res.data.body)
|
||||||
properties: selectedProperties,
|
},
|
||||||
matchScores,
|
},
|
||||||
})
|
)
|
||||||
onGenerated(res.data.subject, res.data.body)
|
|
||||||
} catch (e) {
|
|
||||||
setError(e instanceof Error ? e.message : 'KI-Generierung fehlgeschlagen')
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box>
|
<Box>
|
||||||
<Button
|
<Button
|
||||||
size="small"
|
size="small"
|
||||||
startIcon={loading ? <CircularProgress size={14} /> : <Sparkles size={14} />}
|
startIcon={generateOfferEmail.isPending ? <CircularProgress size={14} /> : <Sparkles size={14} />}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
disabled={loading}
|
disabled={generateOfferEmail.isPending}
|
||||||
sx={{
|
sx={{
|
||||||
textTransform: 'none',
|
textTransform: 'none',
|
||||||
color: '#7c3aed',
|
color: '#7c3aed',
|
||||||
@@ -53,9 +45,11 @@ export function AiOfferEmailButton({
|
|||||||
>
|
>
|
||||||
KI-Mail generieren
|
KI-Mail generieren
|
||||||
</Button>
|
</Button>
|
||||||
{error && (
|
{generateOfferEmail.isError && (
|
||||||
<Typography variant="caption" sx={{ color: 'error.main', display: 'block', mt: 0.5 }}>
|
<Typography variant="caption" sx={{ color: 'error.main', display: 'block', mt: 0.5 }}>
|
||||||
{error}
|
{generateOfferEmail.error instanceof Error
|
||||||
|
? generateOfferEmail.error.message
|
||||||
|
: 'KI-Generierung fehlgeschlagen'}
|
||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Building2, MapPin, Ruler, Calendar } from 'lucide-react'
|
|||||||
import type { InquiryPreparationReportDraft, ReportObjectFieldKey } from '../../domain/inquiryReport'
|
import type { InquiryPreparationReportDraft, ReportObjectFieldKey } from '../../domain/inquiryReport'
|
||||||
import type { Property } from '../../domain/property'
|
import type { Property } from '../../domain/property'
|
||||||
import type { Inquiry } from '../../domain/inquiry'
|
import type { Inquiry } from '../../domain/inquiry'
|
||||||
|
import { DS_TEXT, DS_BG, DS_SURFACE, DS_BORDER } from '../../lib/ds'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
draft: InquiryPreparationReportDraft
|
draft: InquiryPreparationReportDraft
|
||||||
@@ -100,39 +101,39 @@ export function LatentInquiryReportPreview({ draft, inquiry, properties }: Props
|
|||||||
mb: 2,
|
mb: 2,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Box sx={{ borderBottom: '3px solid #1e3a5f', pb: 2, mb: 3 }}>
|
<Box sx={{ borderBottom: `3px solid ${DS_TEXT.brand}`, pb: 2, mb: 3 }}>
|
||||||
<Typography variant="h5" sx={{ fontWeight: 700, color: '#1e3a5f', fontFamily: 'inherit' }}>
|
<Typography variant="h5" sx={{ fontWeight: 700, color: DS_TEXT.brand, fontFamily: 'inherit' }}>
|
||||||
Objektvorschlag
|
Objektvorschlag
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="body2" sx={{ color: '#64748b', mt: 0.5 }}>
|
<Typography variant="body2" sx={{ color: DS_TEXT.muted, mt: 0.5 }}>
|
||||||
Wincasa AG · {new Date().toLocaleDateString('de-CH')}
|
Wincasa AG · {new Date().toLocaleDateString('de-CH')}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{editableByField['intro'] && (
|
{editableByField['intro'] && (
|
||||||
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap', lineHeight: 1.8, mb: 3, color: '#1e293b' }}>
|
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap', lineHeight: 1.8, mb: 3, color: DS_TEXT.primary }}>
|
||||||
{editableByField['intro']}
|
{editableByField['intro']}
|
||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Box sx={{ bgcolor: '#f8fafc', border: '1px solid #e2e8f0', borderRadius: 1, p: 2, mb: 3 }}>
|
<Box sx={{ bgcolor: DS_BG.page, border: `1px solid ${DS_BORDER.default}`, borderRadius: 1, p: 2, mb: 3 }}>
|
||||||
<Typography variant="body2" sx={{ fontWeight: 700, color: '#0f172a', mb: 1 }}>
|
<Typography variant="body2" sx={{ fontWeight: 700, color: DS_TEXT.primary, mb: 1 }}>
|
||||||
Ihre Anfrage
|
Ihre Anfrage
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="body2" sx={{ color: '#374151', mb: 0.5 }}>
|
<Typography variant="body2" sx={{ color: DS_TEXT.primary, mb: 0.5 }}>
|
||||||
<strong>Kontakt:</strong> {inquiry.tenantName}{inquiry.tenantCompany ? ` · ${inquiry.tenantCompany}` : ''}
|
<strong>Kontakt:</strong> {inquiry.tenantName}{inquiry.tenantCompany ? ` · ${inquiry.tenantCompany}` : ''}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="body2" sx={{ color: '#374151' }}>
|
<Typography variant="body2" sx={{ color: DS_TEXT.primary }}>
|
||||||
<strong>Betreff:</strong> {inquiry.subject}
|
<strong>Betreff:</strong> {inquiry.subject}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{editableByField['highlights'] && (
|
{editableByField['highlights'] && (
|
||||||
<>
|
<>
|
||||||
<Typography variant="body2" sx={{ fontWeight: 700, color: '#0f172a', mb: 1 }}>
|
<Typography variant="body2" sx={{ fontWeight: 700, color: DS_TEXT.primary, mb: 1 }}>
|
||||||
Warum diese Objekte passen
|
Warum diese Objekte passen
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap', lineHeight: 1.8, color: '#374151', mb: 3 }}>
|
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap', lineHeight: 1.8, color: DS_TEXT.primary, mb: 3 }}>
|
||||||
{editableByField['highlights']}
|
{editableByField['highlights']}
|
||||||
</Typography>
|
</Typography>
|
||||||
</>
|
</>
|
||||||
@@ -145,7 +146,7 @@ export function LatentInquiryReportPreview({ draft, inquiry, properties }: Props
|
|||||||
label={p.title}
|
label={p.title}
|
||||||
size="small"
|
size="small"
|
||||||
icon={<Building2 size={12} />}
|
icon={<Building2 size={12} />}
|
||||||
sx={{ bgcolor: '#e0e7ff', color: '#1e3a5f', fontWeight: 600 }}
|
sx={{ bgcolor: DS_SURFACE.indigo.bg, color: DS_TEXT.brand, fontWeight: 600 }}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</Box>
|
</Box>
|
||||||
@@ -161,7 +162,7 @@ export function LatentInquiryReportPreview({ draft, inquiry, properties }: Props
|
|||||||
<Box key={property.id}>
|
<Box key={property.id}>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
|
||||||
<Divider sx={{ flex: 1 }} />
|
<Divider sx={{ flex: 1 }} />
|
||||||
<Typography variant="caption" sx={{ color: '#94a3b8', fontSize: '0.7rem', whiteSpace: 'nowrap' }}>
|
<Typography variant="caption" sx={{ color: DS_TEXT.disabled, fontSize: '0.7rem', whiteSpace: 'nowrap' }}>
|
||||||
Objekt {idx + 1} von {selectedProps.length}
|
Objekt {idx + 1} von {selectedProps.length}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Divider sx={{ flex: 1 }} />
|
<Divider sx={{ flex: 1 }} />
|
||||||
@@ -184,7 +185,7 @@ export function LatentInquiryReportPreview({ draft, inquiry, properties }: Props
|
|||||||
height: 120,
|
height: 120,
|
||||||
borderRadius: 1,
|
borderRadius: 1,
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
bgcolor: '#e2e8f0',
|
bgcolor: DS_BORDER.default,
|
||||||
backgroundImage: property.mapImageUrl ? `url(${property.mapImageUrl})` : 'none',
|
backgroundImage: property.mapImageUrl ? `url(${property.mapImageUrl})` : 'none',
|
||||||
backgroundSize: 'cover',
|
backgroundSize: 'cover',
|
||||||
backgroundPosition: 'center',
|
backgroundPosition: 'center',
|
||||||
@@ -194,7 +195,7 @@ export function LatentInquiryReportPreview({ draft, inquiry, properties }: Props
|
|||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{!property.mapImageUrl && <MapPin size={28} color="#94a3b8" />}
|
{!property.mapImageUrl && <MapPin size={28} color={DS_TEXT.disabled} />}
|
||||||
</Box>
|
</Box>
|
||||||
{/* Property photo */}
|
{/* Property photo */}
|
||||||
<Box
|
<Box
|
||||||
@@ -203,7 +204,7 @@ export function LatentInquiryReportPreview({ draft, inquiry, properties }: Props
|
|||||||
height: 120,
|
height: 120,
|
||||||
borderRadius: 1,
|
borderRadius: 1,
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
bgcolor: '#e2e8f0',
|
bgcolor: DS_BORDER.default,
|
||||||
backgroundImage: image ? `url(${image})` : 'none',
|
backgroundImage: image ? `url(${image})` : 'none',
|
||||||
backgroundSize: 'cover',
|
backgroundSize: 'cover',
|
||||||
backgroundPosition: 'center',
|
backgroundPosition: 'center',
|
||||||
@@ -212,23 +213,23 @@ export function LatentInquiryReportPreview({ draft, inquiry, properties }: Props
|
|||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{!image && <Building2 size={28} color="#94a3b8" />}
|
{!image && <Building2 size={28} color={DS_TEXT.disabled} />}
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Typography variant="h6" sx={{ fontWeight: 700, color: '#1e3a5f', mb: 0.5, fontFamily: 'inherit' }}>
|
<Typography variant="h6" sx={{ fontWeight: 700, color: DS_TEXT.brand, mb: 0.5, fontFamily: 'inherit' }}>
|
||||||
{property.title}
|
{property.title}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Box sx={{ display: 'flex', gap: 2, mb: 2 }}>
|
<Box sx={{ display: 'flex', gap: 2, mb: 2 }}>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, color: '#64748b' }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, color: DS_TEXT.muted }}>
|
||||||
<MapPin size={13} />
|
<MapPin size={13} />
|
||||||
<Typography variant="caption">{property.location.city}{property.location.district ? `, ${property.location.district}` : ''}</Typography>
|
<Typography variant="caption">{property.location.city}{property.location.district ? `, ${property.location.district}` : ''}</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, color: '#64748b' }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, color: DS_TEXT.muted }}>
|
||||||
<Ruler size={13} />
|
<Ruler size={13} />
|
||||||
<Typography variant="caption">{property.areaSqm.toLocaleString('de-CH')} m²</Typography>
|
<Typography variant="caption">{property.areaSqm.toLocaleString('de-CH')} m²</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, color: '#64748b' }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, color: DS_TEXT.muted }}>
|
||||||
<Calendar size={13} />
|
<Calendar size={13} />
|
||||||
<Typography variant="caption">{new Date(property.availabilityDate).toLocaleDateString('de-CH')}</Typography>
|
<Typography variant="caption">{new Date(property.availabilityDate).toLocaleDateString('de-CH')}</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -240,7 +241,7 @@ export function LatentInquiryReportPreview({ draft, inquiry, properties }: Props
|
|||||||
display: 'grid',
|
display: 'grid',
|
||||||
gridTemplateColumns: '1fr 1fr',
|
gridTemplateColumns: '1fr 1fr',
|
||||||
gap: 0.75,
|
gap: 0.75,
|
||||||
bgcolor: '#f8fafc',
|
bgcolor: DS_BG.page,
|
||||||
borderRadius: 1,
|
borderRadius: 1,
|
||||||
p: 1.5,
|
p: 1.5,
|
||||||
}}
|
}}
|
||||||
@@ -250,10 +251,10 @@ export function LatentInquiryReportPreview({ draft, inquiry, properties }: Props
|
|||||||
if (!val) return null
|
if (!val) return null
|
||||||
return (
|
return (
|
||||||
<Box key={key}>
|
<Box key={key}>
|
||||||
<Typography variant="caption" sx={{ color: '#64748b', fontSize: '0.68rem', display: 'block' }}>
|
<Typography variant="caption" sx={{ color: DS_TEXT.muted, fontSize: '0.68rem', display: 'block' }}>
|
||||||
{FIELD_LABELS[key] ?? key}
|
{FIELD_LABELS[key] ?? key}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="caption" sx={{ fontWeight: 600, color: '#0f172a', fontSize: '0.78rem' }}>
|
<Typography variant="caption" sx={{ fontWeight: 600, color: DS_TEXT.primary, fontSize: '0.78rem' }}>
|
||||||
{val}
|
{val}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -268,10 +269,10 @@ export function LatentInquiryReportPreview({ draft, inquiry, properties }: Props
|
|||||||
|
|
||||||
{editableByField['next_steps'] && (
|
{editableByField['next_steps'] && (
|
||||||
<Box sx={{ bgcolor: 'white', p: 3, boxShadow: '0 2px 12px rgba(0,0,0,0.1)', borderRadius: 1 }}>
|
<Box sx={{ bgcolor: 'white', p: 3, boxShadow: '0 2px 12px rgba(0,0,0,0.1)', borderRadius: 1 }}>
|
||||||
<Typography variant="body2" sx={{ fontWeight: 700, color: '#0f172a', mb: 1 }}>
|
<Typography variant="body2" sx={{ fontWeight: 700, color: DS_TEXT.primary, mb: 1 }}>
|
||||||
Nächste Schritte
|
Nächste Schritte
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap', lineHeight: 1.8, color: '#374151' }}>
|
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap', lineHeight: 1.8, color: DS_TEXT.primary }}>
|
||||||
{editableByField['next_steps']}
|
{editableByField['next_steps']}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -1,18 +1,86 @@
|
|||||||
import { Box, Typography } from '@mui/material'
|
import { Box, Divider, Typography } from '@mui/material'
|
||||||
import { FileText } from 'lucide-react'
|
import { Building2, FileText, MapPin } from 'lucide-react'
|
||||||
import { useOfferWizardStore } from '../../stores/offerWizardStore'
|
import { useOfferWizardStore } from '../../stores/offerWizardStore'
|
||||||
import { mockProperties } from '../../mock-data/properties'
|
import { mockProperties } from '../../mock-data/properties'
|
||||||
|
import type { ReportObjectFieldKey, ReportObjectFieldSelection } from '../../domain/inquiryReport'
|
||||||
|
import type { Property } from '../../domain/property'
|
||||||
|
|
||||||
interface MockPdfPreviewProps {
|
interface MockPdfPreviewProps {
|
||||||
fields: Record<string, string>
|
fields: Record<string, string>
|
||||||
fallbackFields: Record<string, string>
|
fallbackFields: Record<string, string>
|
||||||
|
fieldSelections: ReportObjectFieldSelection[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MockPdfPreview({ fields, fallbackFields }: MockPdfPreviewProps) {
|
const FIELD_LABELS: Partial<Record<ReportObjectFieldKey, string>> = {
|
||||||
|
areaSqm: 'Fläche',
|
||||||
|
rentPricePerSqm: 'Mietpreis',
|
||||||
|
availabilityDate: 'Verfügbar ab',
|
||||||
|
leaseTerm: 'Mietlaufzeit',
|
||||||
|
breakoutOption: 'Breakout-Option',
|
||||||
|
currentTenant: 'Aktueller Mieter',
|
||||||
|
propertyNumber: 'Objektnummer',
|
||||||
|
assetType: 'Asset Type',
|
||||||
|
floor: 'Stockwerk',
|
||||||
|
parking: 'Parkplätze',
|
||||||
|
fitOut: 'Ausbaugrad',
|
||||||
|
isBarrierFree: 'Barrierefrei',
|
||||||
|
ceilingHeightM: 'Raumhöhe',
|
||||||
|
floorLoad: 'Bodenlast',
|
||||||
|
loadingDocksCount: 'Anlieferung',
|
||||||
|
goodsLift: 'Warenaufzug',
|
||||||
|
passengerLift: 'Personenaufzug',
|
||||||
|
powerSupplyKva: 'Stromanschluss',
|
||||||
|
hasServerRoom: 'Serverraum',
|
||||||
|
internet: 'Internet',
|
||||||
|
deliveryAccess: 'Zufahrt',
|
||||||
|
publicTransportScore: 'ÖV-Anbindung',
|
||||||
|
prestigeScore: 'Prestige',
|
||||||
|
visibilityScore: 'Sichtbarkeit',
|
||||||
|
footfallScore: 'Passantenfrequenz',
|
||||||
|
commuterAccessScore: 'Pendlererreichbarkeit',
|
||||||
|
talentAccessScore: 'Talent Access',
|
||||||
|
esgScore: 'ESG',
|
||||||
|
flexibilityScore: 'Flexibilität',
|
||||||
|
expansionPotentialScore: 'Expansionspotenzial',
|
||||||
|
taxEnvironmentScore: 'Steuerumfeld',
|
||||||
|
microLocation: 'Mikrostandort',
|
||||||
|
competitionEnvironment: 'Konkurrenzumfeld',
|
||||||
|
infrastructure: 'Infrastruktur',
|
||||||
|
description: 'Beschreibung',
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFieldValue(property: Property, key: ReportObjectFieldKey): string | null {
|
||||||
|
switch (key) {
|
||||||
|
case 'areaSqm': return property.areaSqm ? `${property.areaSqm.toLocaleString('de-CH')} m²` : null
|
||||||
|
case 'rentPricePerSqm': return property.rentPricePerSqm ? `CHF ${property.rentPricePerSqm}/m²/J` : null
|
||||||
|
case 'availabilityDate': return property.availabilityDate ? new Date(property.availabilityDate).toLocaleDateString('de-CH') : null
|
||||||
|
case 'leaseTerm': return property.leaseTerm ?? null
|
||||||
|
case 'breakoutOption': return property.breakoutOption != null ? (property.breakoutOption ? 'Ja' : 'Nein') : null
|
||||||
|
case 'currentTenant': return property.currentTenant ?? null
|
||||||
|
case 'propertyNumber': return property.propertyNumber ?? null
|
||||||
|
case 'assetType': return property.assetType ?? null
|
||||||
|
case 'floor': return property.floorLevel != null ? String(property.floorLevel) : null
|
||||||
|
case 'parking': return property.softFactors?.parkingSpots != null ? `${property.softFactors.parkingSpots} Pl.` : null
|
||||||
|
case 'ceilingHeightM': return property.hardFacts?.ceilingHeightM != null ? `${property.hardFacts.ceilingHeightM} m` : null
|
||||||
|
case 'loadingDocksCount': return property.hardFacts?.loadingDocksCount != null ? String(property.hardFacts.loadingDocksCount) : null
|
||||||
|
case 'powerSupplyKva': return property.hardFacts?.powerSupplyKva != null ? `${property.hardFacts.powerSupplyKva} kVA` : null
|
||||||
|
case 'hasServerRoom': return property.hardFacts?.hasServerRoom != null ? (property.hardFacts.hasServerRoom ? 'Ja' : 'Nein') : null
|
||||||
|
case 'isBarrierFree': return property.hardFacts?.isBarrierFree != null ? (property.hardFacts.isBarrierFree ? 'Ja' : 'Nein') : null
|
||||||
|
case 'fitOut': return property.hardFacts?.fitOut ?? null
|
||||||
|
case 'publicTransportScore': return property.softFactors?.publicTransportMinutes != null ? `${property.softFactors.publicTransportMinutes} Min.` : null
|
||||||
|
case 'prestigeScore': return property.softFactors?.prestige != null ? `${property.softFactors.prestige}/100` : null
|
||||||
|
case 'visibilityScore': return property.softFactors?.visibilityScore != null ? `${property.softFactors.visibilityScore}/100` : null
|
||||||
|
case 'description': return property.description ?? null
|
||||||
|
default: return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MockPdfPreview({ fields, fallbackFields, fieldSelections }: MockPdfPreviewProps) {
|
||||||
const needTitle = useOfferWizardStore(s => s.needTitle)
|
const needTitle = useOfferWizardStore(s => s.needTitle)
|
||||||
const propertyIds = useOfferWizardStore(s => s.selectedPropertyIds)
|
const propertyIds = useOfferWizardStore(s => s.selectedPropertyIds)
|
||||||
|
|
||||||
const properties = mockProperties.filter(p => propertyIds.includes(p.id))
|
const properties = mockProperties.filter(p => propertyIds.includes(p.id))
|
||||||
|
const hasPropertyPage = fieldSelections.length > 0 && properties.length > 0
|
||||||
|
|
||||||
const v = (id: string) => fields[id] ?? fallbackFields[id] ?? ''
|
const v = (id: string) => fields[id] ?? fallbackFields[id] ?? ''
|
||||||
|
|
||||||
@@ -29,6 +97,7 @@ export function MockPdfPreview({ fields, fallbackFields }: MockPdfPreviewProps)
|
|||||||
fontFamily: '"Georgia", "Times New Roman", serif',
|
fontFamily: '"Georgia", "Times New Roman", serif',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
{/* ── Page 1 — Offer letter ── */}
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3, color: '#64748b' }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3, color: '#64748b' }}>
|
||||||
<FileText size={16} />
|
<FileText size={16} />
|
||||||
<Typography variant="caption" sx={{ textTransform: 'uppercase', letterSpacing: 1, fontSize: '0.7rem' }}>
|
<Typography variant="caption" sx={{ textTransform: 'uppercase', letterSpacing: 1, fontSize: '0.7rem' }}>
|
||||||
@@ -73,7 +142,7 @@ export function MockPdfPreview({ fields, fallbackFields }: MockPdfPreviewProps)
|
|||||||
variant="body2"
|
variant="body2"
|
||||||
sx={{ fontSize: '0.875rem', mb: 0.5, lineHeight: 1.5 }}
|
sx={{ fontSize: '0.875rem', mb: 0.5, lineHeight: 1.5 }}
|
||||||
>
|
>
|
||||||
<strong>{p.title}</strong> — {p.location.city}, {p.areaSqm.toLocaleString('de-CH')} m², CHF {p.rentPricePerSqm}/m²
|
<strong>{p.title}</strong> — {p.location.city}, {p.areaSqm.toLocaleString('de-CH')} m², CHF {p.rentPricePerSqm}/m²/Jahr
|
||||||
</Typography>
|
</Typography>
|
||||||
))}
|
))}
|
||||||
{properties.length === 0 && (
|
{properties.length === 0 && (
|
||||||
@@ -90,6 +159,93 @@ export function MockPdfPreview({ fields, fallbackFields }: MockPdfPreviewProps)
|
|||||||
<Typography variant="body2" sx={{ fontSize: '0.875rem', whiteSpace: 'pre-wrap', lineHeight: 1.6 }}>
|
<Typography variant="body2" sx={{ fontSize: '0.875rem', whiteSpace: 'pre-wrap', lineHeight: 1.6 }}>
|
||||||
{v('closing')}
|
{v('closing')}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
|
{/* ── Page 2 — Property details side by side ── */}
|
||||||
|
{hasPropertyPage && (
|
||||||
|
<>
|
||||||
|
<Box sx={{ my: 3, display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
||||||
|
<Divider sx={{ flex: 1, borderColor: '#1e3a5f', borderWidth: 1 }} />
|
||||||
|
<Typography variant="caption" sx={{ color: '#94a3b8', fontSize: '0.68rem', whiteSpace: 'nowrap', letterSpacing: 0.5, textTransform: 'uppercase' }}>
|
||||||
|
Seite 2 — Objektdetails
|
||||||
|
</Typography>
|
||||||
|
<Divider sx={{ flex: 1, borderColor: '#1e3a5f', borderWidth: 1 }} />
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: `repeat(${Math.min(properties.length, 3)}, 1fr)`,
|
||||||
|
gap: 1.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{properties.map(p => {
|
||||||
|
const selection = fieldSelections.find(fs => fs.propertyId === p.id)
|
||||||
|
const optionalFields = selection?.selectedOptionalFields ?? []
|
||||||
|
const image = p.images?.[0]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
key={p.id}
|
||||||
|
sx={{
|
||||||
|
border: '1px solid #e2e8f0',
|
||||||
|
borderRadius: 1,
|
||||||
|
overflow: 'hidden',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Photo */}
|
||||||
|
{image ? (
|
||||||
|
<Box
|
||||||
|
component="img"
|
||||||
|
src={image}
|
||||||
|
alt={p.title}
|
||||||
|
sx={{ width: '100%', height: 90, objectFit: 'cover', display: 'block' }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Box sx={{ height: 90, bgcolor: '#f1f5f9', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
|
<Building2 size={22} color="#94a3b8" />
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Info */}
|
||||||
|
<Box sx={{ p: 1.25, flex: 1 }}>
|
||||||
|
<Typography sx={{ fontWeight: 700, fontSize: '0.78rem', color: '#0f172a', lineHeight: 1.3, mb: 0.4 }}>
|
||||||
|
{p.title}
|
||||||
|
</Typography>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.3, mb: 1 }}>
|
||||||
|
<MapPin size={10} color="#94a3b8" />
|
||||||
|
<Typography sx={{ fontSize: '0.68rem', color: '#64748b' }}>
|
||||||
|
{p.location.city}{p.location.district ? ` · ${p.location.district}` : ''}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Selected fields */}
|
||||||
|
{optionalFields.length > 0 && (
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.4 }}>
|
||||||
|
{optionalFields.map(key => {
|
||||||
|
const val = getFieldValue(p, key)
|
||||||
|
if (!val) return null
|
||||||
|
return (
|
||||||
|
<Box key={key} sx={{ display: 'flex', justifyContent: 'space-between', gap: 0.5 }}>
|
||||||
|
<Typography sx={{ fontSize: '0.62rem', color: '#94a3b8' }}>
|
||||||
|
{FIELD_LABELS[key] ?? key}
|
||||||
|
</Typography>
|
||||||
|
<Typography sx={{ fontSize: '0.68rem', fontWeight: 600, color: '#0f172a', textAlign: 'right' }}>
|
||||||
|
{val}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</Box>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
} from '@mui/material'
|
} from '@mui/material'
|
||||||
import { Plus, Trash2, X } from 'lucide-react'
|
import { Plus, Trash2, X } from 'lucide-react'
|
||||||
import type { OfferReportDraft, ViewingAppointmentOption } from '../../domain/offerReport'
|
import type { OfferReportDraft, ViewingAppointmentOption } from '../../domain/offerReport'
|
||||||
import { offerReportService } from '../../services/offerReportService'
|
import { useCreateOfferReport, useUpdateOfferReport, useGenerateOfferReportPdf } from '../../hooks/useOfferReport'
|
||||||
import { usePropertyById } from '../../hooks/useProperties'
|
import { usePropertyById } from '../../hooks/useProperties'
|
||||||
import { useToastStore } from '../../stores/toastStore'
|
import { useToastStore } from '../../stores/toastStore'
|
||||||
import { OfferWizardPdfStep } from './OfferWizardPdfStep'
|
import { OfferWizardPdfStep } from './OfferWizardPdfStep'
|
||||||
@@ -32,10 +32,16 @@ export function OfferCreationWizard({ inquiryId, propertyId, tenantName, onClose
|
|||||||
const showToast = useToastStore(s => s.showToast)
|
const showToast = useToastStore(s => s.showToast)
|
||||||
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||||
|
|
||||||
|
const createOfferReport = useCreateOfferReport()
|
||||||
|
const updateOfferReport = useUpdateOfferReport()
|
||||||
|
const generatePdf = useGenerateOfferReportPdf()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
offerReportService
|
createOfferReport.mutate(
|
||||||
.create(inquiryId, propertyId, tenantName, property?.title ?? '')
|
{ inquiryId, propertyId, tenantName, propertyTitle: property?.title ?? '' },
|
||||||
.then(d => { setDraft(d); setLoading(false) })
|
{ onSuccess: (d) => { setDraft(d); setLoading(false) } },
|
||||||
|
)
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [inquiryId, propertyId, tenantName, property?.title])
|
}, [inquiryId, propertyId, tenantName, property?.title])
|
||||||
|
|
||||||
useEffect(() => () => { if (timerRef.current) clearInterval(timerRef.current) }, [])
|
useEffect(() => () => { if (timerRef.current) clearInterval(timerRef.current) }, [])
|
||||||
@@ -70,32 +76,35 @@ export function OfferCreationWizard({ inquiryId, propertyId, tenantName, onClose
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleGeneratePdf = async () => {
|
const handleGeneratePdf = () => {
|
||||||
if (!draft) return
|
if (!draft) return
|
||||||
await offerReportService.update(draft.id, {
|
updateOfferReport.mutate(
|
||||||
editableFields: draft.editableFields,
|
{ draftId: draft.id, data: { editableFields: draft.editableFields, viewingAppointments: draft.viewingAppointments } },
|
||||||
viewingAppointments: draft.viewingAppointments,
|
{
|
||||||
})
|
onSuccess: () => {
|
||||||
setStep(3)
|
setStep(3)
|
||||||
setGenerating(true)
|
setGenerating(true)
|
||||||
setProgress(0)
|
setProgress(0)
|
||||||
let p = 0
|
let p = 0
|
||||||
timerRef.current = setInterval(() => {
|
timerRef.current = setInterval(() => {
|
||||||
p += Math.random() * 18 + 8
|
p += Math.random() * 18 + 8
|
||||||
if (p >= 100) {
|
if (p >= 100) {
|
||||||
clearInterval(timerRef.current!)
|
clearInterval(timerRef.current!)
|
||||||
setProgress(100)
|
setProgress(100)
|
||||||
setGenerating(false)
|
setGenerating(false)
|
||||||
setReady(true)
|
setReady(true)
|
||||||
offerReportService.generatePdf(draft.id).then(setDraft)
|
generatePdf.mutate(draft.id, { onSuccess: setDraft })
|
||||||
} else {
|
} else {
|
||||||
setProgress(Math.min(100, p))
|
setProgress(Math.min(100, p))
|
||||||
}
|
}
|
||||||
}, 200)
|
}, 200)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
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>
|
||||||
@@ -178,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
|
||||||
@@ -219,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`
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { useEffect } from 'react'
|
||||||
|
import { Box, Button, Stack, Typography } from '@mui/material'
|
||||||
|
import { ArrowLeft } from 'lucide-react'
|
||||||
|
import { useOfferWizardStore } from '../../stores/offerWizardStore'
|
||||||
|
import { useProperties } from '../../hooks/useProperties'
|
||||||
|
import { ReportObjectFieldSelector } from './ReportObjectFieldSelector'
|
||||||
|
import type { ReportObjectFieldKey, ReportObjectFieldSelection } from '../../domain/inquiryReport'
|
||||||
|
|
||||||
|
const DEFAULT_FIELDS: ReportObjectFieldKey[] = ['areaSqm', 'rentPricePerSqm', 'availabilityDate']
|
||||||
|
const MANDATORY_FIELDS: ReportObjectFieldKey[] = ['title', 'location', 'mapImageUrl', 'images']
|
||||||
|
|
||||||
|
export function OfferFieldSelectionStep() {
|
||||||
|
const propertyIds = useOfferWizardStore(s => s.selectedPropertyIds)
|
||||||
|
const fieldSelections = useOfferWizardStore(s => s.fieldSelections)
|
||||||
|
const setFieldSelections = useOfferWizardStore(s => s.setFieldSelections)
|
||||||
|
const updateFieldSelection = useOfferWizardStore(s => s.updateFieldSelection)
|
||||||
|
const setStep = useOfferWizardStore(s => s.setStep)
|
||||||
|
|
||||||
|
const { data: allProperties = [] } = useProperties()
|
||||||
|
const selectedProperties = allProperties.filter(p => propertyIds.includes(p.id))
|
||||||
|
|
||||||
|
// Initialise selections when properties are loaded or change
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedProperties.length === 0) return
|
||||||
|
const existing = new Set(fieldSelections.map(fs => fs.propertyId))
|
||||||
|
const missing = selectedProperties.filter(p => !existing.has(p.id))
|
||||||
|
if (missing.length > 0) {
|
||||||
|
setFieldSelections([
|
||||||
|
...fieldSelections,
|
||||||
|
...missing.map(p => ({
|
||||||
|
propertyId: p.id,
|
||||||
|
mandatoryFields: MANDATORY_FIELDS,
|
||||||
|
selectedOptionalFields: DEFAULT_FIELDS,
|
||||||
|
})),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
// run only when property list or selections change length
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [selectedProperties.length, fieldSelections.length])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
||||||
|
<Box sx={{ flex: 1, overflowY: 'auto', p: 3 }}>
|
||||||
|
<Typography variant="body2" sx={{ color: '#64748b', mb: 2.5 }}>
|
||||||
|
Wählen Sie die Datenfelder, die auf der Objektseite des Angebots angezeigt werden sollen.
|
||||||
|
Pflichtfelder (Titel, Standort, Foto) sind immer enthalten.
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Stack spacing={2}>
|
||||||
|
{selectedProperties.map(p => {
|
||||||
|
const selection: ReportObjectFieldSelection =
|
||||||
|
fieldSelections.find(fs => fs.propertyId === p.id) ?? {
|
||||||
|
propertyId: p.id,
|
||||||
|
mandatoryFields: MANDATORY_FIELDS,
|
||||||
|
selectedOptionalFields: DEFAULT_FIELDS,
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<ReportObjectFieldSelector
|
||||||
|
key={p.id}
|
||||||
|
propertyTitle={p.title}
|
||||||
|
value={selection}
|
||||||
|
onChange={v => updateFieldSelection(p.id, v)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ px: 3, py: 2, borderTop: '1px solid #e2e8f0', bgcolor: 'white', display: 'flex', justifyContent: 'space-between', flexShrink: 0 }}>
|
||||||
|
<Button
|
||||||
|
startIcon={<ArrowLeft size={14} />}
|
||||||
|
onClick={() => setStep('select_properties')}
|
||||||
|
sx={{ textTransform: 'none' }}
|
||||||
|
>
|
||||||
|
Zurück
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
onClick={() => setStep('pdf_review')}
|
||||||
|
sx={{ textTransform: 'none', bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#16304d' } }}
|
||||||
|
>
|
||||||
|
Weiter
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -66,6 +66,8 @@ export function OfferPdfReviewStep() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const fieldSelections = useOfferWizardStore(s => s.fieldSelections)
|
||||||
|
|
||||||
const needTitle = useOfferWizardStore(s => s.needTitle)
|
const needTitle = useOfferWizardStore(s => s.needTitle)
|
||||||
const addAttachment = useOfferWizardStore(s => s.addAttachment)
|
const addAttachment = useOfferWizardStore(s => s.addAttachment)
|
||||||
const setMessageDraft = useOfferWizardStore(s => s.setMessageDraft)
|
const setMessageDraft = useOfferWizardStore(s => s.setMessageDraft)
|
||||||
@@ -128,7 +130,7 @@ export function OfferPdfReviewStep() {
|
|||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
) : (
|
) : (
|
||||||
<MockPdfPreview fields={editableFieldsStore} fallbackFields={fallback} />
|
<MockPdfPreview fields={editableFieldsStore} fallbackFields={fallback} fieldSelections={fieldSelections} />
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ export function OfferPropertySelectionStep() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
setOfferDraftId(res.data.id)
|
setOfferDraftId(res.data.id)
|
||||||
setStep('pdf_review')
|
setStep('select_fields')
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!need) {
|
if (!need) {
|
||||||
|
|||||||
@@ -12,22 +12,25 @@ import {
|
|||||||
import { X } from 'lucide-react'
|
import { X } from 'lucide-react'
|
||||||
import { useOfferWizardStore, type OfferStep } from '../../stores/offerWizardStore'
|
import { useOfferWizardStore, type OfferStep } from '../../stores/offerWizardStore'
|
||||||
import { OfferPropertySelectionStep } from './OfferPropertySelectionStep'
|
import { OfferPropertySelectionStep } from './OfferPropertySelectionStep'
|
||||||
|
import { OfferFieldSelectionStep } from './OfferFieldSelectionStep'
|
||||||
import { OfferPdfReviewStep } from './OfferPdfReviewStep'
|
import { OfferPdfReviewStep } from './OfferPdfReviewStep'
|
||||||
import { OfferCheckedAction } from './OfferCheckedAction'
|
import { OfferCheckedAction } from './OfferCheckedAction'
|
||||||
import { OfferChatComposer } from './OfferChatComposer'
|
import { OfferChatComposer } from './OfferChatComposer'
|
||||||
|
|
||||||
// 'checked' is internal — merged into pdf_review step; only 3 steps shown
|
// 'checked' is internal — merged into pdf_review step; 4 visible steps
|
||||||
const STEPS: { key: OfferStep; label: string }[] = [
|
const STEPS: { key: OfferStep; label: string }[] = [
|
||||||
{ key: 'select_properties', label: 'Objekte wählen' },
|
{ key: 'select_properties', label: 'Objekte wählen' },
|
||||||
|
{ key: 'select_fields', label: 'Felder wählen' },
|
||||||
{ key: 'pdf_review', label: 'Vorschau & Prüfen' },
|
{ key: 'pdf_review', label: 'Vorschau & Prüfen' },
|
||||||
{ key: 'send', label: 'Senden' },
|
{ key: 'send', label: 'Senden' },
|
||||||
]
|
]
|
||||||
|
|
||||||
const STEP_DISPLAY_INDEX: Record<OfferStep, number> = {
|
const STEP_DISPLAY_INDEX: Record<OfferStep, number> = {
|
||||||
select_properties: 0,
|
select_properties: 0,
|
||||||
pdf_review: 1,
|
select_fields: 1,
|
||||||
checked: 1, // same visual step as pdf_review
|
pdf_review: 2,
|
||||||
send: 2,
|
checked: 2, // same visual step as pdf_review
|
||||||
|
send: 3,
|
||||||
}
|
}
|
||||||
|
|
||||||
export function OfferWizard() {
|
export function OfferWizard() {
|
||||||
@@ -48,12 +51,14 @@ export function OfferWizard() {
|
|||||||
fullScreen={fullScreen}
|
fullScreen={fullScreen}
|
||||||
maxWidth="lg"
|
maxWidth="lg"
|
||||||
fullWidth
|
fullWidth
|
||||||
PaperProps={{
|
slotProps={{
|
||||||
sx: {
|
paper: {
|
||||||
height: fullScreen ? '100vh' : '85vh',
|
sx: {
|
||||||
display: 'flex',
|
height: fullScreen ? '100vh' : '85vh',
|
||||||
flexDirection: 'column',
|
display: 'flex',
|
||||||
overflow: 'hidden',
|
flexDirection: 'column',
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -112,6 +117,7 @@ export function OfferWizard() {
|
|||||||
{/* Step content */}
|
{/* Step content */}
|
||||||
<Box sx={{ flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
|
<Box sx={{ flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
|
||||||
{currentStep === 'select_properties' && <OfferPropertySelectionStep />}
|
{currentStep === 'select_properties' && <OfferPropertySelectionStep />}
|
||||||
|
{currentStep === 'select_fields' && <OfferFieldSelectionStep />}
|
||||||
{currentStep === 'pdf_review' && <OfferPdfReviewStep />}
|
{currentStep === 'pdf_review' && <OfferPdfReviewStep />}
|
||||||
{currentStep === 'checked' && <OfferCheckedAction />}
|
{currentStep === 'checked' && <OfferCheckedAction />}
|
||||||
{currentStep === 'send' && <OfferChatComposer />}
|
{currentStep === 'send' && <OfferChatComposer />}
|
||||||
|
|||||||
@@ -6,9 +6,8 @@ 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 { inquiryReportService } from '../../services/inquiryReportService'
|
import { useCreateInquiryReport, useUpdateInquiryReport, useFinalizeInquiryReport } from '../../hooks/useInquiryReport'
|
||||||
import { useToastStore } from '../../stores/toastStore'
|
import { useToastStore } from '../../stores/toastStore'
|
||||||
import { ReportObjectFieldSelector } from './ReportObjectFieldSelector'
|
import { ReportObjectFieldSelector } from './ReportObjectFieldSelector'
|
||||||
import { LatentInquiryReportPreview } from './LatentInquiryReportPreview'
|
import { LatentInquiryReportPreview } from './LatentInquiryReportPreview'
|
||||||
@@ -29,16 +28,16 @@ export function PreparationWizard({ inquiryId, inquiry, onClose }: Props) {
|
|||||||
const [draft, setDraft] = useState<InquiryPreparationReportDraft | null>(null)
|
const [draft, setDraft] = useState<InquiryPreparationReportDraft | null>(null)
|
||||||
const [generating, setGenerating] = useState(false)
|
const [generating, setGenerating] = useState(false)
|
||||||
const [progress, setProgress] = useState(0)
|
const [progress, setProgress] = useState(0)
|
||||||
const [finalizing, setFinalizing] = useState(false)
|
|
||||||
const { data: allProperties = [] } = useProperties()
|
const { data: allProperties = [] } = useProperties()
|
||||||
const showToast = useToastStore(s => s.showToast)
|
const showToast = useToastStore(s => s.showToast)
|
||||||
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||||
|
|
||||||
const portfolioProps = allProperties.filter(p => p.resultType === ResultType.VERIFIED_PORTFOLIO)
|
const createInquiryReport = useCreateInquiryReport()
|
||||||
|
const updateInquiryReport = useUpdateInquiryReport()
|
||||||
|
const finalizeInquiryReport = useFinalizeInquiryReport()
|
||||||
|
const finalizing = finalizeInquiryReport.isPending
|
||||||
|
|
||||||
const selectedProperties = draft?.selectedPropertyIds
|
const portfolioProps = allProperties.filter(p => p.resultType === ResultType.VERIFIED_PORTFOLIO)
|
||||||
.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 =>
|
||||||
@@ -46,7 +45,7 @@ export function PreparationWizard({ inquiryId, inquiry, onClose }: Props) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleGenerate = async () => {
|
const handleGenerate = () => {
|
||||||
setStep(1)
|
setStep(1)
|
||||||
setGenerating(true)
|
setGenerating(true)
|
||||||
setProgress(0)
|
setProgress(0)
|
||||||
@@ -57,10 +56,15 @@ export function PreparationWizard({ inquiryId, inquiry, onClose }: Props) {
|
|||||||
clearInterval(timerRef.current!)
|
clearInterval(timerRef.current!)
|
||||||
setProgress(100)
|
setProgress(100)
|
||||||
setGenerating(false)
|
setGenerating(false)
|
||||||
inquiryReportService.create(inquiryId, selectedIds).then(d => {
|
createInquiryReport.mutate(
|
||||||
setDraft(d)
|
{ inquiryId, selectedPropertyIds: selectedIds },
|
||||||
setStep(2)
|
{
|
||||||
})
|
onSuccess: (d) => {
|
||||||
|
setDraft(d)
|
||||||
|
setStep(2)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
setProgress(Math.min(100, p))
|
setProgress(Math.min(100, p))
|
||||||
}
|
}
|
||||||
@@ -85,18 +89,25 @@ export function PreparationWizard({ inquiryId, inquiry, onClose }: Props) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleFinalize = async () => {
|
const handleFinalize = () => {
|
||||||
if (!draft) return
|
if (!draft) return
|
||||||
setFinalizing(true)
|
updateInquiryReport.mutate(
|
||||||
await inquiryReportService.update(draft.id, { editableFields: draft.editableFields, fieldSelections: draft.fieldSelections })
|
{ draftId: draft.id, data: { editableFields: draft.editableFields, fieldSelections: draft.fieldSelections } },
|
||||||
const finalized = await inquiryReportService.finalize(draft.id)
|
{
|
||||||
setDraft(finalized)
|
onSuccess: () => {
|
||||||
setFinalizing(false)
|
finalizeInquiryReport.mutate(draft.id, {
|
||||||
setStep(3)
|
onSuccess: (finalized) => {
|
||||||
|
setDraft(finalized)
|
||||||
|
setStep(3)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
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>
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import { useEffect, useState } from 'react'
|
|
||||||
import { Box, Button, CircularProgress, Divider, Typography } from '@mui/material'
|
import { Box, Button, CircularProgress, Divider, Typography } from '@mui/material'
|
||||||
import { ArrowRight, Building2, Calendar, MapPin, Ruler, Trophy } from 'lucide-react'
|
import { ArrowRight, Building2, Calendar, MapPin, Ruler, Trophy } from 'lucide-react'
|
||||||
import { useNavigate } from 'react-router'
|
import { useNavigate } from 'react-router'
|
||||||
import { usePropertyById } from '../../hooks/useProperties'
|
import { usePropertyById } from '../../hooks/useProperties'
|
||||||
import { matchService } from '../../services/matchService'
|
import { useAdditionalMatchesForInquiry } from '../../hooks/useMatches'
|
||||||
import type { AdditionalPropertyMatch } from '../../domain/additionalMatch'
|
import type { AdditionalPropertyMatch } from '../../domain/additionalMatch'
|
||||||
|
|
||||||
interface RelatedPropertyCardPanelProps {
|
interface RelatedPropertyCardPanelProps {
|
||||||
@@ -14,16 +13,10 @@ interface RelatedPropertyCardPanelProps {
|
|||||||
export function RelatedPropertyCardPanel({ propertyId, inquiryId }: RelatedPropertyCardPanelProps) {
|
export function RelatedPropertyCardPanel({ propertyId, inquiryId }: RelatedPropertyCardPanelProps) {
|
||||||
const { data: property, isLoading } = usePropertyById(propertyId)
|
const { data: property, isLoading } = usePropertyById(propertyId)
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const [additionalMatches, setAdditionalMatches] = useState<AdditionalPropertyMatch[]>([])
|
const {
|
||||||
const [loadingMatches, setLoadingMatches] = useState(false)
|
data: additionalMatches = [],
|
||||||
|
isLoading: loadingMatches,
|
||||||
useEffect(() => {
|
} = useAdditionalMatchesForInquiry(inquiryId, { minScore: 80, excludePropertyId: propertyId })
|
||||||
setLoadingMatches(true)
|
|
||||||
matchService
|
|
||||||
.getAdditionalMatchesForInquiry(inquiryId, { minScore: 80, excludePropertyId: propertyId })
|
|
||||||
.then(setAdditionalMatches)
|
|
||||||
.finally(() => setLoadingMatches(false))
|
|
||||||
}, [inquiryId, propertyId])
|
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
@@ -94,7 +87,7 @@ export function RelatedPropertyCardPanel({ propertyId, inquiryId }: RelatedPrope
|
|||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, color: '#475569', fontSize: '0.8125rem' }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, color: '#475569', fontSize: '0.8125rem' }}>
|
||||||
<Ruler size={14} />
|
<Ruler size={14} />
|
||||||
<Typography variant="body2" sx={{ fontSize: '0.8125rem' }}>
|
<Typography variant="body2" sx={{ fontSize: '0.8125rem' }}>
|
||||||
{property.areaSqm.toLocaleString('de-CH')} m² · CHF {property.rentPricePerSqm}/m²
|
{property.areaSqm.toLocaleString('de-CH')} m² · CHF {property.rentPricePerSqm}/m²/Jahr
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, color: '#475569', fontSize: '0.8125rem' }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, color: '#475569', fontSize: '0.8125rem' }}>
|
||||||
@@ -190,7 +183,7 @@ function AdditionalMatchCard({ match }: { match: AdditionalPropertyMatch }) {
|
|||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
<Typography variant="caption" sx={{ color: '#64748b', fontSize: '0.7rem', display: 'block', mb: 0.5 }}>
|
<Typography variant="caption" sx={{ color: '#64748b', fontSize: '0.7rem', display: 'block', mb: 0.5 }}>
|
||||||
{match.location} · {match.areaSqm.toLocaleString('de-CH')} m² · CHF {match.rentPricePerSqm}/m²
|
{match.location} · {match.areaSqm.toLocaleString('de-CH')} m² · CHF {match.rentPricePerSqm}/m²/Jahr
|
||||||
</Typography>
|
</Typography>
|
||||||
{match.reasons.length > 0 && (
|
{match.reasons.length > 0 && (
|
||||||
<Box sx={{ mb: 0.5 }}>
|
<Box sx={{ mb: 0.5 }}>
|
||||||
|
|||||||
@@ -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 } }}>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Box, Chip, Typography } from '@mui/material'
|
import { Box, Chip, Typography } from '@mui/material'
|
||||||
import type { SuggestedQuestion } from '../../domain/assistant'
|
import type { SuggestedQuestion } from '../../domain/assistant'
|
||||||
|
import { DS_TEXT, DS_BORDER, DS_SURFACE, BADGE_COLORS } from '../../lib/ds'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
suggestions: SuggestedQuestion[]
|
suggestions: SuggestedQuestion[]
|
||||||
@@ -9,26 +10,26 @@ interface Props {
|
|||||||
|
|
||||||
const CATEGORY_COLORS: Record<string, string> = {
|
const CATEGORY_COLORS: Record<string, string> = {
|
||||||
Match: '#4f46e5',
|
Match: '#4f46e5',
|
||||||
Datenqualität: '#d97706',
|
Datenqualität: DS_TEXT.warning,
|
||||||
Priorisierung: '#1e3a5f',
|
Priorisierung: DS_TEXT.brand,
|
||||||
Empfehlung: '#1a7a4a',
|
Empfehlung: DS_TEXT.success,
|
||||||
Risiko: '#c0392b',
|
Risiko: DS_TEXT.error,
|
||||||
Tradeoffs: '#ea580c',
|
Tradeoffs: '#ea580c',
|
||||||
Strategie: '#0891b2',
|
Strategie: '#0891b2',
|
||||||
Analyse: '#7c3aed',
|
Analyse: BADGE_COLORS.preMarket,
|
||||||
Erklärung: '#0891b2',
|
Erklärung: '#0891b2',
|
||||||
Evidenz: '#64748b',
|
Evidenz: DS_TEXT.muted,
|
||||||
Review: '#7c3aed',
|
Review: BADGE_COLORS.preMarket,
|
||||||
Konfidenz: '#d97706',
|
Konfidenz: DS_TEXT.warning,
|
||||||
Fehler: '#c0392b',
|
Fehler: DS_TEXT.error,
|
||||||
Fehleranalyse: '#ea580c',
|
Fehleranalyse: '#ea580c',
|
||||||
Eskalation: '#ea580c',
|
Eskalation: '#ea580c',
|
||||||
Prozess: '#64748b',
|
Prozess: DS_TEXT.muted,
|
||||||
Kosten: '#1a7a4a',
|
Kosten: DS_TEXT.success,
|
||||||
Impact: '#d97706',
|
Impact: DS_TEXT.warning,
|
||||||
Optimierung: '#1a7a4a',
|
Optimierung: DS_TEXT.success,
|
||||||
Aktion: '#1e3a5f',
|
Aktion: DS_TEXT.brand,
|
||||||
Überblick: '#64748b',
|
Überblick: DS_TEXT.muted,
|
||||||
Ranking: '#4f46e5',
|
Ranking: '#4f46e5',
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,12 +38,12 @@ export function AssistantPromptSuggestions({ suggestions, onSelect, disabled }:
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ px: 2, py: 1.25 }}>
|
<Box sx={{ px: 2, py: 1.25 }}>
|
||||||
<Typography variant="caption" sx={{ fontWeight: 700, textTransform: 'uppercase', letterSpacing: 0.5, color: '#94a3b8', display: 'block', mb: 0.75, fontSize: '0.65rem' }}>
|
<Typography variant="caption" sx={{ fontWeight: 700, textTransform: 'uppercase', letterSpacing: 0.5, color: DS_TEXT.disabled, display: 'block', mb: 0.75, fontSize: '0.65rem' }}>
|
||||||
Vorschläge
|
Vorschläge
|
||||||
</Typography>
|
</Typography>
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.625 }}>
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.625 }}>
|
||||||
{suggestions.map(s => {
|
{suggestions.map(s => {
|
||||||
const catColor = CATEGORY_COLORS[s.category] ?? '#64748b'
|
const catColor = CATEGORY_COLORS[s.category] ?? DS_TEXT.muted
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
key={s.id}
|
key={s.id}
|
||||||
@@ -51,11 +52,11 @@ export function AssistantPromptSuggestions({ suggestions, onSelect, disabled }:
|
|||||||
px: 1.25,
|
px: 1.25,
|
||||||
py: 0.875,
|
py: 0.875,
|
||||||
borderRadius: 1.5,
|
borderRadius: 1.5,
|
||||||
border: '1px solid #e2e8f0',
|
border: `1px solid ${DS_BORDER.default}`,
|
||||||
cursor: disabled ? 'default' : 'pointer',
|
cursor: disabled ? 'default' : 'pointer',
|
||||||
bgcolor: 'white',
|
bgcolor: 'white',
|
||||||
opacity: disabled ? 0.5 : 1,
|
opacity: disabled ? 0.5 : 1,
|
||||||
'&:hover': disabled ? {} : { bgcolor: '#f8fafc', borderColor: '#cbd5e1' },
|
'&:hover': disabled ? {} : { bgcolor: DS_SURFACE.neutral.bg, borderColor: DS_BORDER.strong },
|
||||||
transition: 'all 0.1s ease',
|
transition: 'all 0.1s ease',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ import { Sparkles } from 'lucide-react'
|
|||||||
import { useAssistantStore } from '../../stores/assistantStore'
|
import { useAssistantStore } from '../../stores/assistantStore'
|
||||||
|
|
||||||
export function GlobalAIAssistantButton() {
|
export function GlobalAIAssistantButton() {
|
||||||
const { isOpen, open } = useAssistantStore()
|
const isOpen = useAssistantStore(s => s.isOpen)
|
||||||
|
const open = useAssistantStore(s => s.open)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tooltip title="AI Assistent öffnen" placement="left">
|
<Tooltip title="AI Assistent öffnen" placement="left">
|
||||||
|
|||||||
@@ -2,15 +2,16 @@ import { useCallback, useEffect, useRef, useState } from 'react'
|
|||||||
import { Box, Divider, Drawer, IconButton, TextField, Tooltip, Typography } from '@mui/material'
|
import { Box, Divider, Drawer, IconButton, TextField, Tooltip, Typography } from '@mui/material'
|
||||||
import { RotateCcw, Send, Sparkles, X } from 'lucide-react'
|
import { RotateCcw, Send, Sparkles, X } from 'lucide-react'
|
||||||
import { useLocation } from 'react-router'
|
import { useLocation } from 'react-router'
|
||||||
|
import { useShallow } from 'zustand/react/shallow'
|
||||||
import { useAssistantStore } from '../../stores/assistantStore'
|
import { useAssistantStore } from '../../stores/assistantStore'
|
||||||
import { useSessionStore } from '../../stores/sessionStore'
|
import { useSessionStore } from '../../stores/sessionStore'
|
||||||
import { aiAssistantService } from '../../services/aiAssistantService'
|
import { useAssistantSuggestions, useAssistantAnswer } from '../../hooks/useAssistant'
|
||||||
import { AssistantContextSummary } from './AssistantContextSummary'
|
import { AssistantContextSummary } from './AssistantContextSummary'
|
||||||
import { AssistantMessageList } from './AssistantMessageList'
|
import { AssistantMessageList } from './AssistantMessageList'
|
||||||
import { AssistantPromptSuggestions } from './AssistantPromptSuggestions'
|
import { AssistantPromptSuggestions } from './AssistantPromptSuggestions'
|
||||||
import { AssistantLoadingState } from './AssistantLoadingState'
|
import { AssistantLoadingState } from './AssistantLoadingState'
|
||||||
import { AssistantErrorState } from './AssistantErrorState'
|
import { AssistantErrorState } from './AssistantErrorState'
|
||||||
import type { AssistantContext, SuggestedQuestion } from '../../domain/assistant'
|
import type { AssistantContext } from '../../domain/assistant'
|
||||||
import type { WorkspaceType } from '../../domain/enums'
|
import type { WorkspaceType } from '../../domain/enums'
|
||||||
|
|
||||||
function resolveWorkspace(pathname: string): WorkspaceType | null {
|
function resolveWorkspace(pathname: string): WorkspaceType | null {
|
||||||
@@ -21,29 +22,18 @@ function resolveWorkspace(pathname: string): WorkspaceType | null {
|
|||||||
|
|
||||||
export function GlobalAIAssistantDrawer() {
|
export function GlobalAIAssistantDrawer() {
|
||||||
const { isOpen, close, context, setContext, messages, isLoading, error, addMessage, setLoading, setError, clearConversation } =
|
const { isOpen, close, context, setContext, messages, isLoading, error, addMessage, setLoading, setError, clearConversation } =
|
||||||
useAssistantStore()
|
useAssistantStore(useShallow(s => s))
|
||||||
|
|
||||||
const { currentUser } = useSessionStore()
|
const { currentUser } = useSessionStore()
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
|
|
||||||
const [suggestions, setSuggestions] = useState<SuggestedQuestion[]>([])
|
|
||||||
const [inputText, setInputText] = useState('')
|
const [inputText, setInputText] = useState('')
|
||||||
const scrollRef = useRef<HTMLDivElement>(null)
|
const scrollRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
// Build context from route when drawer opens
|
const answerQuestion = useAssistantAnswer()
|
||||||
useEffect(() => {
|
const { data: suggestions = [] } = useAssistantSuggestions(isOpen ? context : null)
|
||||||
if (!isOpen) return
|
|
||||||
const ctx: AssistantContext = {
|
|
||||||
currentRoute: location.pathname,
|
|
||||||
workspace: resolveWorkspace(location.pathname),
|
|
||||||
userRole: currentUser?.role ?? 'VIEWER',
|
|
||||||
organizationId: currentUser?.organizationId ?? '',
|
|
||||||
}
|
|
||||||
setContext(ctx)
|
|
||||||
aiAssistantService.getSuggestions(ctx).then(setSuggestions)
|
|
||||||
}, [isOpen, location.pathname])
|
|
||||||
|
|
||||||
// Refresh suggestions when route changes while open
|
// Build context from route when drawer opens or route changes while open
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isOpen) return
|
if (!isOpen) return
|
||||||
const ctx: AssistantContext = {
|
const ctx: AssistantContext = {
|
||||||
@@ -53,8 +43,7 @@ export function GlobalAIAssistantDrawer() {
|
|||||||
organizationId: currentUser?.organizationId ?? '',
|
organizationId: currentUser?.organizationId ?? '',
|
||||||
}
|
}
|
||||||
setContext(ctx)
|
setContext(ctx)
|
||||||
aiAssistantService.getSuggestions(ctx).then(setSuggestions)
|
}, [isOpen, location.pathname])
|
||||||
}, [location.pathname])
|
|
||||||
|
|
||||||
// Auto-scroll on new messages
|
// Auto-scroll on new messages
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -63,7 +52,7 @@ export function GlobalAIAssistantDrawer() {
|
|||||||
}
|
}
|
||||||
}, [messages, isLoading])
|
}, [messages, isLoading])
|
||||||
|
|
||||||
const handleQuestion = useCallback(async (question: string) => {
|
const handleQuestion = useCallback((question: string) => {
|
||||||
if (!question.trim() || isLoading) return
|
if (!question.trim() || isLoading) return
|
||||||
setInputText('')
|
setInputText('')
|
||||||
setError(null)
|
setError(null)
|
||||||
@@ -77,26 +66,32 @@ export function GlobalAIAssistantDrawer() {
|
|||||||
addMessage(userMsg)
|
addMessage(userMsg)
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
|
|
||||||
try {
|
const ctx = context ?? {
|
||||||
const ctx = context ?? {
|
currentRoute: location.pathname,
|
||||||
currentRoute: location.pathname,
|
workspace: resolveWorkspace(location.pathname),
|
||||||
workspace: resolveWorkspace(location.pathname),
|
userRole: currentUser?.role ?? 'VIEWER',
|
||||||
userRole: currentUser?.role ?? 'VIEWER',
|
organizationId: currentUser?.organizationId ?? '',
|
||||||
organizationId: currentUser?.organizationId ?? '',
|
|
||||||
}
|
|
||||||
const answer = await aiAssistantService.answerQuestion(ctx, question)
|
|
||||||
addMessage({
|
|
||||||
id: crypto.randomUUID(),
|
|
||||||
role: 'assistant',
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
...answer,
|
|
||||||
})
|
|
||||||
} catch {
|
|
||||||
setError('Antwort konnte nicht generiert werden. Bitte erneut versuchen.')
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
}
|
||||||
}, [context, isLoading, location.pathname, currentUser, addMessage, setLoading, setError])
|
|
||||||
|
answerQuestion.mutate(
|
||||||
|
{ context: ctx, question },
|
||||||
|
{
|
||||||
|
onSuccess: (answer) => {
|
||||||
|
addMessage({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
role: 'assistant',
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
...answer,
|
||||||
|
})
|
||||||
|
setLoading(false)
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
setError('Antwort konnte nicht generiert werden. Bitte erneut versuchen.')
|
||||||
|
setLoading(false)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}, [context, isLoading, location.pathname, currentUser, addMessage, setLoading, setError, answerQuestion])
|
||||||
|
|
||||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
if (e.key === 'Enter' && !e.shiftKey) {
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
@@ -107,10 +102,6 @@ export function GlobalAIAssistantDrawer() {
|
|||||||
|
|
||||||
const handleClear = () => {
|
const handleClear = () => {
|
||||||
clearConversation()
|
clearConversation()
|
||||||
setSuggestions([])
|
|
||||||
if (context) {
|
|
||||||
aiAssistantService.getSuggestions(context).then(setSuggestions)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const showSuggestions = suggestions.length > 0 && messages.length === 0
|
const showSuggestions = suggestions.length > 0 && messages.length === 0
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Box, Chip, Typography } from '@mui/material'
|
import { Box, Chip, Typography } from '@mui/material'
|
||||||
import { UserRole } from '../../domain/enums'
|
import { UserRole } from '../../domain/enums'
|
||||||
import { authService } from '../../services/authService'
|
import { useSwitchDemoRole } from '../../hooks/useAuth'
|
||||||
import { useSessionStore } from '../../stores/sessionStore'
|
import { useSessionStore } from '../../stores/sessionStore'
|
||||||
|
|
||||||
const DEMO_ROLES: { role: UserRole; label: string }[] = [
|
const DEMO_ROLES: { role: UserRole; label: string }[] = [
|
||||||
@@ -10,9 +10,10 @@ const DEMO_ROLES: { role: UserRole; label: string }[] = [
|
|||||||
|
|
||||||
export function DemoRoleSwitcher() {
|
export function DemoRoleSwitcher() {
|
||||||
const { currentUser } = useSessionStore()
|
const { currentUser } = useSessionStore()
|
||||||
|
const switchDemoRole = useSwitchDemoRole()
|
||||||
|
|
||||||
async function handleSwitch(role: UserRole) {
|
function handleSwitch(role: UserRole) {
|
||||||
await authService.switchDemoRole(role)
|
switchDemoRole.mutate(role)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { FormControl, MenuItem, Select, Typography } from '@mui/material'
|
import { FormControl, MenuItem, Select, Typography } from '@mui/material'
|
||||||
import type { SelectChangeEvent } from '@mui/material'
|
import type { SelectChangeEvent } from '@mui/material'
|
||||||
import { authService } from '../../services/authService'
|
import { useSwitchOrganization } from '../../hooks/useAuth'
|
||||||
import { useSessionStore } from '../../stores/sessionStore'
|
import { useSessionStore } from '../../stores/sessionStore'
|
||||||
|
|
||||||
const MOCK_ORGANIZATIONS = [
|
const MOCK_ORGANIZATIONS = [
|
||||||
@@ -11,9 +11,10 @@ const MOCK_ORGANIZATIONS = [
|
|||||||
|
|
||||||
export function OrganizationSwitcher() {
|
export function OrganizationSwitcher() {
|
||||||
const { activeOrganizationId } = useSessionStore()
|
const { activeOrganizationId } = useSessionStore()
|
||||||
|
const switchOrganization = useSwitchOrganization()
|
||||||
|
|
||||||
async function handleChange(e: SelectChangeEvent<string>) {
|
function handleChange(e: SelectChangeEvent<string>) {
|
||||||
await authService.switchOrganization(e.target.value)
|
switchOrganization.mutate(e.target.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Chip } from '@mui/material'
|
import { Chip } from '@mui/material'
|
||||||
import { ShieldCheck, Globe, Building2, Sparkles } from 'lucide-react'
|
import { ShieldCheck, Building2, Sparkles } from 'lucide-react'
|
||||||
import type { ResultType } from '../../domain/enums'
|
import type { ResultType } from '../../domain/enums'
|
||||||
import { DS_COLORS } from '../../lib/ds'
|
import { DS_COLORS } from '../../lib/ds'
|
||||||
import { RESULT_TYPE_LABELS } from '../../lib/constants'
|
import { RESULT_TYPE_LABELS } from '../../lib/constants'
|
||||||
@@ -11,7 +11,6 @@ interface ResultTypeBadgeProps {
|
|||||||
|
|
||||||
const ICONS: Record<ResultType, React.ElementType> = {
|
const ICONS: Record<ResultType, React.ElementType> = {
|
||||||
VERIFIED_PORTFOLIO: ShieldCheck,
|
VERIFIED_PORTFOLIO: ShieldCheck,
|
||||||
EXTERNAL_MARKET: Globe,
|
|
||||||
MAISON_WORK: Building2,
|
MAISON_WORK: Building2,
|
||||||
FUTURE_AVAILABILITY: Sparkles,
|
FUTURE_AVAILABILITY: Sparkles,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ interface SourceTypeBadgeProps {
|
|||||||
|
|
||||||
const CONFIG: Record<string, { bg: string; color: string; Icon: React.ElementType }> = {
|
const CONFIG: Record<string, { bg: string; color: string; Icon: React.ElementType }> = {
|
||||||
VERIFIED_PORTFOLIO: { bg: 'rgba(30,58,95,0.1)', color: '#1e3a5f', Icon: ShieldCheck },
|
VERIFIED_PORTFOLIO: { bg: 'rgba(30,58,95,0.1)', color: '#1e3a5f', Icon: ShieldCheck },
|
||||||
EXTERNAL_MARKET: { bg: 'rgba(217,119,6,0.1)', color: '#b45309', Icon: Globe },
|
|
||||||
MAISON_WORK: { bg: 'rgba(3,105,161,0.1)', color: '#0369a1', Icon: Building2 },
|
MAISON_WORK: { bg: 'rgba(3,105,161,0.1)', color: '#0369a1', Icon: Building2 },
|
||||||
FUTURE_AVAILABILITY: { bg: 'rgba(124,58,237,0.1)', color: '#6d28d9', Icon: Sparkles },
|
FUTURE_AVAILABILITY: { bg: 'rgba(124,58,237,0.1)', color: '#6d28d9', Icon: Sparkles },
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useState } from 'react'
|
|||||||
import { Alert, Box, Chip, CircularProgress, Collapse, Typography } from '@mui/material'
|
import { Alert, Box, Chip, CircularProgress, Collapse, Typography } from '@mui/material'
|
||||||
import { ChevronDown, ChevronUp, Trophy, TrendingDown, ShieldCheck, AlertTriangle, Info, ArrowRight, CheckCircle2, XCircle, Star } from 'lucide-react'
|
import { ChevronDown, ChevronUp, Trophy, TrendingDown, ShieldCheck, AlertTriangle, Info, ArrowRight, CheckCircle2, XCircle, Star } from 'lucide-react'
|
||||||
import type { ComparisonSummary } from '../../services/aiService'
|
import type { ComparisonSummary } from '../../services/aiService'
|
||||||
|
import { DS_TEXT, DS_BG, DS_BORDER, DS_SURFACE } from '../../lib/ds'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
summary?: ComparisonSummary
|
summary?: ComparisonSummary
|
||||||
@@ -12,7 +13,7 @@ export function AICompareSummary({ summary, isLoading }: Props) {
|
|||||||
const [open, setOpen] = useState(true)
|
const [open, setOpen] = useState(true)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ mb: 2, border: '1px solid #e2e8f0', borderRadius: 1, overflow: 'hidden' }}>
|
<Box sx={{ mb: 2, border: `1px solid ${DS_BORDER.default}`, borderRadius: 1, overflow: 'hidden' }}>
|
||||||
<Box
|
<Box
|
||||||
onClick={() => setOpen(v => !v)}
|
onClick={() => setOpen(v => !v)}
|
||||||
sx={{
|
sx={{
|
||||||
@@ -21,14 +22,14 @@ export function AICompareSummary({ summary, isLoading }: Props) {
|
|||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
px: 2,
|
px: 2,
|
||||||
py: 1.25,
|
py: 1.25,
|
||||||
bgcolor: '#f8fafc',
|
bgcolor: DS_SURFACE.neutral.bg,
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
'&:hover': { bgcolor: '#f1f5f9' },
|
'&:hover': { bgcolor: DS_BG.subtle },
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>AI Vergleichs-Zusammenfassung</Typography>
|
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>AI Vergleichs-Zusammenfassung</Typography>
|
||||||
<Chip label="Beta" size="small" sx={{ fontSize: 10, bgcolor: '#ede9fe', color: '#6d28d9' }} />
|
<Chip label="Beta" size="small" sx={{ fontSize: 10, bgcolor: DS_SURFACE.purple.bg, color: DS_TEXT.signal }} />
|
||||||
</Box>
|
</Box>
|
||||||
{open ? <ChevronUp size={16} /> : <ChevronDown size={16} />}
|
{open ? <ChevronUp size={16} /> : <ChevronDown size={16} />}
|
||||||
</Box>
|
</Box>
|
||||||
@@ -45,15 +46,15 @@ export function AICompareSummary({ summary, isLoading }: Props) {
|
|||||||
{!isLoading && summary && (
|
{!isLoading && summary && (
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
|
||||||
{/* Overall assessment */}
|
{/* Overall assessment */}
|
||||||
<Box sx={{ bgcolor: '#f0f9ff', border: '1px solid #bae6fd', borderRadius: 1, p: 1.5 }}>
|
<Box sx={{ bgcolor: DS_SURFACE.info.bg, border: `1px solid ${DS_SURFACE.info.border}`, borderRadius: 1, p: 1.5 }}>
|
||||||
<Typography variant="body2" sx={{ color: '#0c4a6e', lineHeight: 1.5 }}>{summary.overallAssessment}</Typography>
|
<Typography variant="body2" sx={{ color: DS_TEXT.infoDark, lineHeight: 1.5 }}>{summary.overallAssessment}</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Strongest option */}
|
{/* Strongest option */}
|
||||||
<Box sx={{ display: 'flex', gap: 1, alignItems: 'flex-start' }}>
|
<Box sx={{ display: 'flex', gap: 1, alignItems: 'flex-start' }}>
|
||||||
<Trophy size={16} color="#1a7a4a" style={{ flexShrink: 0, marginTop: 2 }} />
|
<Trophy size={16} color={DS_TEXT.success} style={{ flexShrink: 0, marginTop: 2 }} />
|
||||||
<Box>
|
<Box>
|
||||||
<Typography variant="caption" sx={{ fontWeight: 600, color: '#64748b', display: 'block' }}>Stärkstes Match</Typography>
|
<Typography variant="caption" sx={{ fontWeight: 600, color: DS_TEXT.muted, display: 'block' }}>Stärkstes Match</Typography>
|
||||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>{summary.strongestOption.label}</Typography>
|
<Typography variant="body2" sx={{ fontWeight: 600 }}>{summary.strongestOption.label}</Typography>
|
||||||
<Typography variant="caption" color="text.secondary">{summary.strongestOption.reason}</Typography>
|
<Typography variant="caption" color="text.secondary">{summary.strongestOption.reason}</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -62,9 +63,9 @@ export function AICompareSummary({ summary, isLoading }: Props) {
|
|||||||
{/* Best value */}
|
{/* Best value */}
|
||||||
{summary.bestValue && (
|
{summary.bestValue && (
|
||||||
<Box sx={{ display: 'flex', gap: 1, alignItems: 'flex-start' }}>
|
<Box sx={{ display: 'flex', gap: 1, alignItems: 'flex-start' }}>
|
||||||
<TrendingDown size={16} color="#1e3a5f" style={{ flexShrink: 0, marginTop: 2 }} />
|
<TrendingDown size={16} color={DS_TEXT.brand} style={{ flexShrink: 0, marginTop: 2 }} />
|
||||||
<Box>
|
<Box>
|
||||||
<Typography variant="caption" sx={{ fontWeight: 600, color: '#64748b', display: 'block' }}>Bestes Preis-Leistungs-Verhältnis</Typography>
|
<Typography variant="caption" sx={{ fontWeight: 600, color: DS_TEXT.muted, display: 'block' }}>Bestes Preis-Leistungs-Verhältnis</Typography>
|
||||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>{summary.bestValue.label}</Typography>
|
<Typography variant="body2" sx={{ fontWeight: 600 }}>{summary.bestValue.label}</Typography>
|
||||||
<Typography variant="caption" color="text.secondary">{summary.bestValue.reason}</Typography>
|
<Typography variant="caption" color="text.secondary">{summary.bestValue.reason}</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -75,7 +76,7 @@ export function AICompareSummary({ summary, isLoading }: Props) {
|
|||||||
<Box sx={{ display: 'flex', gap: 1, alignItems: 'flex-start' }}>
|
<Box sx={{ display: 'flex', gap: 1, alignItems: 'flex-start' }}>
|
||||||
<ShieldCheck size={16} color="#0891b2" style={{ flexShrink: 0, marginTop: 2 }} />
|
<ShieldCheck size={16} color="#0891b2" style={{ flexShrink: 0, marginTop: 2 }} />
|
||||||
<Box>
|
<Box>
|
||||||
<Typography variant="caption" sx={{ fontWeight: 600, color: '#64748b', display: 'block' }}>Höchste Datenkonfidenz</Typography>
|
<Typography variant="caption" sx={{ fontWeight: 600, color: DS_TEXT.muted, display: 'block' }}>Höchste Datenkonfidenz</Typography>
|
||||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>
|
<Typography variant="body2" sx={{ fontWeight: 600 }}>
|
||||||
{summary.highestConfidence.label}
|
{summary.highestConfidence.label}
|
||||||
<Typography component="span" variant="caption" color="text.secondary" sx={{ ml: 0.5 }}>
|
<Typography component="span" variant="caption" color="text.secondary" sx={{ ml: 0.5 }}>
|
||||||
@@ -88,9 +89,9 @@ export function AICompareSummary({ summary, isLoading }: Props) {
|
|||||||
{/* Tradeoffs */}
|
{/* Tradeoffs */}
|
||||||
{summary.biggestTradeoffs.length > 0 && (
|
{summary.biggestTradeoffs.length > 0 && (
|
||||||
<Box sx={{ display: 'flex', gap: 1, alignItems: 'flex-start' }}>
|
<Box sx={{ display: 'flex', gap: 1, alignItems: 'flex-start' }}>
|
||||||
<AlertTriangle size={16} color="#d97706" style={{ flexShrink: 0, marginTop: 2 }} />
|
<AlertTriangle size={16} color={DS_TEXT.warning} style={{ flexShrink: 0, marginTop: 2 }} />
|
||||||
<Box>
|
<Box>
|
||||||
<Typography variant="caption" sx={{ fontWeight: 600, color: '#64748b', display: 'block' }}>Wichtigste Abwägungen</Typography>
|
<Typography variant="caption" sx={{ fontWeight: 600, color: DS_TEXT.muted, display: 'block' }}>Wichtigste Abwägungen</Typography>
|
||||||
{summary.biggestTradeoffs.map((t, i) => (
|
{summary.biggestTradeoffs.map((t, i) => (
|
||||||
<Typography key={i} variant="caption" color="text.secondary" sx={{ display: 'block' }}>· {t}</Typography>
|
<Typography key={i} variant="caption" color="text.secondary" sx={{ display: 'block' }}>· {t}</Typography>
|
||||||
))}
|
))}
|
||||||
@@ -101,9 +102,9 @@ export function AICompareSummary({ summary, isLoading }: Props) {
|
|||||||
{/* Missing data */}
|
{/* Missing data */}
|
||||||
{summary.missingDataWarnings.length > 0 && (
|
{summary.missingDataWarnings.length > 0 && (
|
||||||
<Box sx={{ display: 'flex', gap: 1, alignItems: 'flex-start' }}>
|
<Box sx={{ display: 'flex', gap: 1, alignItems: 'flex-start' }}>
|
||||||
<Info size={16} color="#c0392b" style={{ flexShrink: 0, marginTop: 2 }} />
|
<Info size={16} color={DS_TEXT.error} style={{ flexShrink: 0, marginTop: 2 }} />
|
||||||
<Box>
|
<Box>
|
||||||
<Typography variant="caption" sx={{ fontWeight: 600, color: '#64748b', display: 'block' }}>Fehlende Informationen</Typography>
|
<Typography variant="caption" sx={{ fontWeight: 600, color: DS_TEXT.muted, display: 'block' }}>Fehlende Informationen</Typography>
|
||||||
{summary.missingDataWarnings.map((w, i) => (
|
{summary.missingDataWarnings.map((w, i) => (
|
||||||
<Typography key={i} variant="caption" color="error" sx={{ display: 'block' }}>· {w}</Typography>
|
<Typography key={i} variant="caption" color="error" sx={{ display: 'block' }}>· {w}</Typography>
|
||||||
))}
|
))}
|
||||||
@@ -114,14 +115,14 @@ export function AICompareSummary({ summary, isLoading }: Props) {
|
|||||||
{/* Per-property assessment */}
|
{/* Per-property assessment */}
|
||||||
{summary.perPropertyAssessment.length > 0 && (
|
{summary.perPropertyAssessment.length > 0 && (
|
||||||
<Box>
|
<Box>
|
||||||
<Typography variant="caption" sx={{ fontWeight: 600, color: '#64748b', display: 'block', mb: 0.75 }}>Objektbewertung</Typography>
|
<Typography variant="caption" sx={{ fontWeight: 600, color: DS_TEXT.muted, display: 'block', mb: 0.75 }}>Objektbewertung</Typography>
|
||||||
<Box sx={{ display: 'flex', gap: 1.5, flexWrap: 'wrap' }}>
|
<Box sx={{ display: 'flex', gap: 1.5, flexWrap: 'wrap' }}>
|
||||||
{summary.perPropertyAssessment.map(prop => (
|
{summary.perPropertyAssessment.map(prop => (
|
||||||
<Box
|
<Box
|
||||||
key={prop.matchId}
|
key={prop.matchId}
|
||||||
sx={{
|
sx={{
|
||||||
flex: '1 1 200px',
|
flex: '1 1 200px',
|
||||||
border: '1px solid #e2e8f0',
|
border: `1px solid ${DS_BORDER.default}`,
|
||||||
borderRadius: 1,
|
borderRadius: 1,
|
||||||
p: 1.25,
|
p: 1.25,
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
@@ -129,13 +130,13 @@ export function AICompareSummary({ summary, isLoading }: Props) {
|
|||||||
gap: 0.75,
|
gap: 0.75,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Typography variant="body2" sx={{ fontWeight: 700, color: '#1e3a5f' }}>{prop.label}</Typography>
|
<Typography variant="body2" sx={{ fontWeight: 700, color: DS_TEXT.brand }}>{prop.label}</Typography>
|
||||||
|
|
||||||
{/* Strengths */}
|
{/* Strengths */}
|
||||||
<Box>
|
<Box>
|
||||||
{prop.strengths.map((s, i) => (
|
{prop.strengths.map((s, i) => (
|
||||||
<Box key={i} sx={{ display: 'flex', alignItems: 'flex-start', gap: 0.5, mb: 0.25 }}>
|
<Box key={i} sx={{ display: 'flex', alignItems: 'flex-start', gap: 0.5, mb: 0.25 }}>
|
||||||
<CheckCircle2 size={12} color="#1a7a4a" style={{ flexShrink: 0, marginTop: 2 }} />
|
<CheckCircle2 size={12} color={DS_TEXT.success} style={{ flexShrink: 0, marginTop: 2 }} />
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ lineHeight: 1.4 }}>{s}</Typography>
|
<Typography variant="caption" color="text.secondary" sx={{ lineHeight: 1.4 }}>{s}</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
))}
|
))}
|
||||||
@@ -145,7 +146,7 @@ export function AICompareSummary({ summary, isLoading }: Props) {
|
|||||||
<Box>
|
<Box>
|
||||||
{prop.weaknesses.map((w, i) => (
|
{prop.weaknesses.map((w, i) => (
|
||||||
<Box key={i} sx={{ display: 'flex', alignItems: 'flex-start', gap: 0.5, mb: 0.25 }}>
|
<Box key={i} sx={{ display: 'flex', alignItems: 'flex-start', gap: 0.5, mb: 0.25 }}>
|
||||||
<XCircle size={12} color="#dc2626" style={{ flexShrink: 0, marginTop: 2 }} />
|
<XCircle size={12} color={DS_TEXT.danger} style={{ flexShrink: 0, marginTop: 2 }} />
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ lineHeight: 1.4 }}>{w}</Typography>
|
<Typography variant="caption" color="text.secondary" sx={{ lineHeight: 1.4 }}>{w}</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
))}
|
))}
|
||||||
@@ -153,8 +154,8 @@ export function AICompareSummary({ summary, isLoading }: Props) {
|
|||||||
|
|
||||||
{/* Best for */}
|
{/* Best for */}
|
||||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 0.5 }}>
|
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 0.5 }}>
|
||||||
<Star size={12} color="#d97706" style={{ flexShrink: 0, marginTop: 2 }} />
|
<Star size={12} color={DS_TEXT.warning} style={{ flexShrink: 0, marginTop: 2 }} />
|
||||||
<Typography variant="caption" sx={{ color: '#92400e', lineHeight: 1.4 }}>
|
<Typography variant="caption" sx={{ color: DS_TEXT.warningDark, lineHeight: 1.4 }}>
|
||||||
<strong>Am besten für:</strong> {prop.bestFor}
|
<strong>Am besten für:</strong> {prop.bestFor}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -162,7 +163,7 @@ export function AICompareSummary({ summary, isLoading }: Props) {
|
|||||||
{/* Key risk */}
|
{/* Key risk */}
|
||||||
{prop.keyRisk && (
|
{prop.keyRisk && (
|
||||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 0.5 }}>
|
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 0.5 }}>
|
||||||
<AlertTriangle size={12} color="#d97706" style={{ flexShrink: 0, marginTop: 2 }} />
|
<AlertTriangle size={12} color={DS_TEXT.warning} style={{ flexShrink: 0, marginTop: 2 }} />
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ lineHeight: 1.4 }}>{prop.keyRisk}</Typography>
|
<Typography variant="caption" color="text.secondary" sx={{ lineHeight: 1.4 }}>{prop.keyRisk}</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
@@ -173,10 +174,10 @@ export function AICompareSummary({ summary, isLoading }: Props) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Recommendation */}
|
{/* Recommendation */}
|
||||||
<Box sx={{ display: 'flex', gap: 1, alignItems: 'flex-start', pt: 0.5, borderTop: '1px solid #f1f5f9', mt: 0.5 }}>
|
<Box sx={{ display: 'flex', gap: 1, alignItems: 'flex-start', pt: 0.5, borderTop: `1px solid ${DS_BORDER.muted}`, mt: 0.5 }}>
|
||||||
<ArrowRight size={16} color="#1e3a5f" style={{ flexShrink: 0, marginTop: 2 }} />
|
<ArrowRight size={16} color={DS_TEXT.brand} style={{ flexShrink: 0, marginTop: 2 }} />
|
||||||
<Box>
|
<Box>
|
||||||
<Typography variant="caption" sx={{ fontWeight: 600, color: '#64748b', display: 'block' }}>Empfehlung</Typography>
|
<Typography variant="caption" sx={{ fontWeight: 600, color: DS_TEXT.muted, display: 'block' }}>Empfehlung</Typography>
|
||||||
<Typography variant="body2">{summary.recommendation}</Typography>
|
<Typography variant="body2">{summary.recommendation}</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -1,18 +1,12 @@
|
|||||||
import { Box, Button, Chip, IconButton, Tooltip, Typography } from '@mui/material'
|
import { Box, Button, Chip, IconButton, Tooltip, Typography } from '@mui/material'
|
||||||
import { X, AlertTriangle, BookmarkCheck, ExternalLink, Kanban } from 'lucide-react'
|
import { X, AlertTriangle, BookmarkCheck, ExternalLink, Kanban } from 'lucide-react'
|
||||||
import { useNavigate } from 'react-router'
|
import { useNavigate } from 'react-router'
|
||||||
|
import { usePipelineItems } from '../../hooks/usePipeline'
|
||||||
import { usePipelineStore } from '../../stores/pipelineStore'
|
import { usePipelineStore } from '../../stores/pipelineStore'
|
||||||
|
import { RESULT_TYPE_META } from '../../lib/ds'
|
||||||
|
import { matchScoreHex } from '../../lib/utils'
|
||||||
import type { UnifiedMatchResult } from '../../domain/unifiedResult'
|
import type { UnifiedMatchResult } from '../../domain/unifiedResult'
|
||||||
|
|
||||||
const TYPE_META: Record<string, { label: string; color: string }> = {
|
|
||||||
VERIFIED_PORTFOLIO: { label: 'Plattform', color: '#1e3a5f' },
|
|
||||||
EXTERNAL_MARKET: { label: 'Plattform', color: '#1e3a5f' },
|
|
||||||
MAISON_WORK: { label: 'Maison Work', color: '#0369a1' },
|
|
||||||
FUTURE_AVAILABILITY: { label: 'Zukunftssignal', color: '#7c3aed' },
|
|
||||||
}
|
|
||||||
|
|
||||||
const SCORE_COLOR = (s: number) => s >= 78 ? '#1a7a4a' : s >= 52 ? '#d97706' : '#c0392b'
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
item: UnifiedMatchResult
|
item: UnifiedMatchResult
|
||||||
onRemove: () => void
|
onRemove: () => void
|
||||||
@@ -20,9 +14,10 @@ interface Props {
|
|||||||
|
|
||||||
export function CompareColumnHeader({ item, onRemove }: Props) {
|
export function CompareColumnHeader({ item, onRemove }: Props) {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const { items: pipelineItems, openSavedDialog } = usePipelineStore()
|
const { data: pipelineItems = [] } = usePipelineItems()
|
||||||
|
const { openSavedDialog } = usePipelineStore()
|
||||||
|
|
||||||
const meta = TYPE_META[item.resultType] ?? { label: item.resultType, color: '#64748b' }
|
const meta = RESULT_TYPE_META[item.resultType] ?? { label: item.resultType, color: '#64748b' }
|
||||||
const prop = item.resultType !== 'FUTURE_AVAILABILITY' ? (item as any).property : null
|
const prop = item.resultType !== 'FUTURE_AVAILABILITY' ? (item as any).property : null
|
||||||
const sig = item.resultType === 'FUTURE_AVAILABILITY' ? (item as any).signal : null
|
const sig = item.resultType === 'FUTURE_AVAILABILITY' ? (item as any).signal : null
|
||||||
|
|
||||||
@@ -48,7 +43,7 @@ export function CompareColumnHeader({ item, onRemove }: Props) {
|
|||||||
propertyId: prop?.id,
|
propertyId: prop?.id,
|
||||||
propertyAddress: prop ? `${title}, ${subtitle}${district ? `, ${district}` : ''}` : undefined,
|
propertyAddress: prop ? `${title}, ${subtitle}${district ? `, ${district}` : ''}` : undefined,
|
||||||
areaLabel: prop?.areaSqm ? `${prop.areaSqm.toLocaleString('de-CH')} m²` : undefined,
|
areaLabel: prop?.areaSqm ? `${prop.areaSqm.toLocaleString('de-CH')} m²` : undefined,
|
||||||
rentLabel: prop?.rentPricePerSqm ? `CHF ${prop.rentPricePerSqm}/m²` : undefined,
|
rentLabel: prop?.rentPricePerSqm ? `CHF ${prop.rentPricePerSqm}/m²/Jahr` : undefined,
|
||||||
availabilityLabel: prop?.availabilityDate ?? undefined,
|
availabilityLabel: prop?.availabilityDate ?? undefined,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -84,7 +79,7 @@ export function CompareColumnHeader({ item, onRemove }: Props) {
|
|||||||
|
|
||||||
{/* Row 3: score */}
|
{/* Row 3: score */}
|
||||||
<Box sx={{ display: 'flex', alignItems: 'baseline', gap: 0.5, mb: 1 }}>
|
<Box sx={{ display: 'flex', alignItems: 'baseline', gap: 0.5, mb: 1 }}>
|
||||||
<Typography variant="h4" sx={{ fontWeight: 800, color: SCORE_COLOR(item.matchScore), lineHeight: 1 }}>
|
<Typography variant="h4" sx={{ fontWeight: 800, color: matchScoreHex(item.matchScore), lineHeight: 1 }}>
|
||||||
{item.matchScore}
|
{item.matchScore}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="caption" color="text.secondary">/100</Typography>
|
<Typography variant="caption" color="text.secondary">/100</Typography>
|
||||||
@@ -150,7 +145,7 @@ export function CompareColumnHeader({ item, onRemove }: Props) {
|
|||||||
variant="contained"
|
variant="contained"
|
||||||
startIcon={<Kanban size={12} />}
|
startIcon={<Kanban size={12} />}
|
||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
sx={{ flex: 1, textTransform: 'none', fontSize: '0.72rem', py: 0.5, bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#162d4a' } }}
|
sx={{ flex: 1, textTransform: 'none', fontSize: '0.72rem', py: 0.5 }}
|
||||||
>
|
>
|
||||||
Merken
|
Merken
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -0,0 +1,340 @@
|
|||||||
|
import type { ReactNode } from 'react'
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Chip,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableRow,
|
||||||
|
Typography,
|
||||||
|
} from '@mui/material'
|
||||||
|
import { AlertOctagon, AlertTriangle, CheckCircle2, Trophy, XCircle, Zap } from 'lucide-react'
|
||||||
|
import {
|
||||||
|
HARD_CRITERIA,
|
||||||
|
RISK_LEVEL_ORDER,
|
||||||
|
getProp,
|
||||||
|
getSig,
|
||||||
|
LABEL_SX,
|
||||||
|
DATA_SX,
|
||||||
|
scoreBar,
|
||||||
|
} from './compareUtils'
|
||||||
|
import { CompareCell, MissingDataCell } from './index'
|
||||||
|
import { RESULT_TYPE_META, DS_COLORS } from '../../lib/ds'
|
||||||
|
import { matchScoreHex } from '../../lib/utils'
|
||||||
|
import type { UnifiedMatchResult } from '../../domain/unifiedResult'
|
||||||
|
|
||||||
|
// ── Local helper ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function row(label: string, cells: ReactNode[]) {
|
||||||
|
return (
|
||||||
|
<TableRow hover key={label}>
|
||||||
|
<TableCell sx={LABEL_SX}>{label}</TableCell>
|
||||||
|
{cells.map((cell, i) => (
|
||||||
|
<TableCell key={i} sx={DATA_SX}>{cell}</TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Props ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface CompareTableBodyProps {
|
||||||
|
compareItems: UnifiedMatchResult[]
|
||||||
|
bestScoreIdx: number
|
||||||
|
worstConfIdx: number
|
||||||
|
worstDQIdx: number
|
||||||
|
missingCriticalCounts: number[]
|
||||||
|
maxMissingCritical: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Component ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function CompareTableBody({
|
||||||
|
compareItems,
|
||||||
|
bestScoreIdx,
|
||||||
|
worstConfIdx,
|
||||||
|
worstDQIdx,
|
||||||
|
missingCriticalCounts,
|
||||||
|
maxMissingCritical,
|
||||||
|
}: CompareTableBodyProps) {
|
||||||
|
return (
|
||||||
|
<TableBody>
|
||||||
|
|
||||||
|
{/* 1. Result Type */}
|
||||||
|
{row('1. Result-Typ', compareItems.map(item => {
|
||||||
|
const m = RESULT_TYPE_META[item.resultType] ?? { label: item.resultType, color: '#64748b' }
|
||||||
|
return <Chip label={m.label} size="small" sx={{ bgcolor: m.color, color: 'white', fontWeight: 600, fontSize: 11 }} />
|
||||||
|
}))}
|
||||||
|
|
||||||
|
{/* 2. Source / Provenance */}
|
||||||
|
{row('2. Quelle / Provenienz', compareItems.map(item => {
|
||||||
|
const prop = getProp(item)
|
||||||
|
const sig = getSig(item)
|
||||||
|
const label = prop?.sourceLabel ?? sig?.source?.type ?? null
|
||||||
|
return label
|
||||||
|
? <Typography variant="body2">{label}</Typography>
|
||||||
|
: <MissingDataCell reason="Quellenangabe fehlt — Datenverlässlichkeit unklar" />
|
||||||
|
}))}
|
||||||
|
|
||||||
|
{/* 3. Match Score */}
|
||||||
|
{row('3. Match Score', compareItems.map((item, idx) => (
|
||||||
|
<CompareCell
|
||||||
|
highlight={idx === bestScoreIdx ? 'best' : 'none'}
|
||||||
|
icon={idx === bestScoreIdx ? <Trophy size={14} color="#1a7a4a" /> : undefined}
|
||||||
|
iconTooltip="Höchster Match Score"
|
||||||
|
>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'baseline', gap: 0.5 }}>
|
||||||
|
<Typography variant="h5" sx={{ fontWeight: 800, color: matchScoreHex(item.matchScore), lineHeight: 1 }}>
|
||||||
|
{item.matchScore}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" color="text.secondary">/100</Typography>
|
||||||
|
</Box>
|
||||||
|
</CompareCell>
|
||||||
|
)))}
|
||||||
|
|
||||||
|
{/* 4. Confidence Score */}
|
||||||
|
{row('4. Konfidenz', compareItems.map((item, idx) => (
|
||||||
|
<CompareCell
|
||||||
|
highlight={idx === worstConfIdx && item.match.confidenceLevel < 0.6 ? 'worst' : 'none'}
|
||||||
|
icon={idx === worstConfIdx && item.match.confidenceLevel < 0.6 ? <AlertTriangle size={14} color="#d97706" /> : undefined}
|
||||||
|
iconTooltip="Niedrigste Konfidenz"
|
||||||
|
>
|
||||||
|
{scoreBar(item.match.confidenceLevel)}
|
||||||
|
</CompareCell>
|
||||||
|
)))}
|
||||||
|
|
||||||
|
{/* 5. Data Quality Score */}
|
||||||
|
{row('5. Datenqualität', compareItems.map((item, idx) => {
|
||||||
|
const prop = getProp(item)
|
||||||
|
const dq = prop?.dataQuality.score ?? null
|
||||||
|
if (dq === null) return <MissingDataCell reason="Keine Datenqualitätsinformation verfügbar" />
|
||||||
|
return (
|
||||||
|
<CompareCell
|
||||||
|
highlight={idx === worstDQIdx && dq < 0.6 ? 'worst' : 'none'}
|
||||||
|
icon={idx === worstDQIdx && dq < 0.6 ? <AlertTriangle size={14} color="#d97706" /> : undefined}
|
||||||
|
iconTooltip="Niedrigste Datenqualität"
|
||||||
|
>
|
||||||
|
{scoreBar(dq)}
|
||||||
|
</CompareCell>
|
||||||
|
)
|
||||||
|
}))}
|
||||||
|
|
||||||
|
{/* 6. Asset Type */}
|
||||||
|
{row('6. Nutzungstyp', compareItems.map(item => {
|
||||||
|
const prop = getProp(item)
|
||||||
|
const label = prop?.assetType ?? null
|
||||||
|
return label
|
||||||
|
? <Chip label={label} size="small" variant="outlined" />
|
||||||
|
: <MissingDataCell />
|
||||||
|
}))}
|
||||||
|
|
||||||
|
{/* 7. Location */}
|
||||||
|
{row('7. Standort', compareItems.map(item => {
|
||||||
|
const prop = getProp(item)
|
||||||
|
const sig = getSig(item)
|
||||||
|
const city = prop?.location?.city ?? sig?.locationHint ?? null
|
||||||
|
const district = prop?.location?.district
|
||||||
|
return city
|
||||||
|
? <Typography variant="body2">{city}{district ? `, ${district}` : ''}</Typography>
|
||||||
|
: <MissingDataCell />
|
||||||
|
}))}
|
||||||
|
|
||||||
|
{/* 8. Area */}
|
||||||
|
{row('8. Fläche', compareItems.map(item => {
|
||||||
|
const prop = getProp(item)
|
||||||
|
const sig = getSig(item)
|
||||||
|
const area = prop?.areaSqm ?? sig?.areaSqmEstimate ?? null
|
||||||
|
return area !== null
|
||||||
|
? <Typography variant="body2">{area.toLocaleString('de-CH')} m²{sig ? ' (Schätzung)' : ''}</Typography>
|
||||||
|
: <MissingDataCell />
|
||||||
|
}))}
|
||||||
|
|
||||||
|
{/* 9. Rent / Budget Fit */}
|
||||||
|
{row('9. Miete / Budget', compareItems.map(item => {
|
||||||
|
const prop = getProp(item)
|
||||||
|
if (!prop) return <MissingDataCell reason="Mietpreis nur für bestätigte Objekte verfügbar" />
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 600 }}>
|
||||||
|
CHF {prop.rentPricePerSqm}/m²/Jahr
|
||||||
|
</Typography>
|
||||||
|
{prop.totalRentMonthly && (
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
{prop.totalRentMonthly.toLocaleString('de-CH')} CHF/Monat
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}))}
|
||||||
|
|
||||||
|
{/* 10. Availability / Time Horizon */}
|
||||||
|
{row('10. Verfügbarkeit', compareItems.map(item => {
|
||||||
|
const prop = getProp(item)
|
||||||
|
const sig = getSig(item)
|
||||||
|
if (prop) return <Typography variant="body2">{prop.availabilityDate}</Typography>
|
||||||
|
if (sig) return (
|
||||||
|
<CompareCell highlight="future" icon={<Zap size={14} color={DS_COLORS.futureCard.controlled.accent} />} iconTooltip="Probabilistisches Signal — keine bestätigte Verfügbarkeit">
|
||||||
|
<Typography variant="body2">~{sig.timeHorizonMonths} Monate</Typography>
|
||||||
|
<Typography variant="caption" sx={{ color: DS_COLORS.futureCard.controlled.accent }}>
|
||||||
|
{Math.round(sig.probability * 100)}% Wahrscheinlichkeit
|
||||||
|
</Typography>
|
||||||
|
</CompareCell>
|
||||||
|
)
|
||||||
|
return <MissingDataCell />
|
||||||
|
}))}
|
||||||
|
|
||||||
|
{/* 11. Hard Criteria Fit */}
|
||||||
|
{row('11. Hardkriterien', compareItems.map(item => {
|
||||||
|
const hardMatches = item.match.positiveFactors.filter(f => HARD_CRITERIA.has(f.criterion))
|
||||||
|
const total = 4
|
||||||
|
const count = hardMatches.length
|
||||||
|
const color = count >= 3 ? '#1a7a4a' : count >= 2 ? '#d97706' : '#c0392b'
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 700, color }}>
|
||||||
|
{count}/{total} erfüllt
|
||||||
|
</Typography>
|
||||||
|
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5, mt: 0.5 }}>
|
||||||
|
{hardMatches.map(f => (
|
||||||
|
<Chip key={f.criterion} label={f.criterion} size="small"
|
||||||
|
icon={<CheckCircle2 size={10} />}
|
||||||
|
sx={{ fontSize: 10, bgcolor: '#f0fdf4', color: '#166534', '& .MuiChip-icon': { color: '#1a7a4a' } }} />
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}))}
|
||||||
|
|
||||||
|
{/* 12. Top Soft Factors */}
|
||||||
|
{row('12. Soft Factors', compareItems.map(item => {
|
||||||
|
const softFactors = item.match.positiveFactors
|
||||||
|
.filter(f => !HARD_CRITERIA.has(f.criterion))
|
||||||
|
.slice(0, 3)
|
||||||
|
if (softFactors.length === 0) return <MissingDataCell />
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
|
||||||
|
{softFactors.map(f => (
|
||||||
|
<Chip key={f.criterion} label={f.criterion} size="small"
|
||||||
|
sx={{ fontSize: 10, bgcolor: '#eff6ff', color: '#1e40af' }} />
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}))}
|
||||||
|
|
||||||
|
{/* 13. Main Strengths */}
|
||||||
|
{row('13. Stärken', compareItems.map(item => {
|
||||||
|
const top = item.match.positiveFactors.slice(0, 2)
|
||||||
|
if (top.length === 0) return <MissingDataCell />
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
|
||||||
|
{top.map((f, i) => (
|
||||||
|
<Box key={i} sx={{ display: 'flex', alignItems: 'flex-start', gap: 0.5 }}>
|
||||||
|
<CheckCircle2 size={13} color="#1a7a4a" style={{ flexShrink: 0, marginTop: 2 }} />
|
||||||
|
<Typography variant="caption" sx={{ lineHeight: 1.4 }}>{f.explanation}</Typography>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}))}
|
||||||
|
|
||||||
|
{/* 14. Main Tradeoffs */}
|
||||||
|
{row('14. Abwägungen', compareItems.map(item => {
|
||||||
|
const tradeoffs = item.match.tradeoffs?.slice(0, 2) ?? []
|
||||||
|
if (tradeoffs.length === 0) return (
|
||||||
|
<Typography variant="body2" color="text.secondary">Keine signifikanten Abwägungen</Typography>
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
|
||||||
|
{tradeoffs.map((t, i) => (
|
||||||
|
<Box key={i} sx={{ display: 'flex', alignItems: 'flex-start', gap: 0.5 }}>
|
||||||
|
<AlertTriangle size={13} color="#d97706" style={{ flexShrink: 0, marginTop: 2 }} />
|
||||||
|
<Typography variant="caption" sx={{ lineHeight: 1.4 }}>{t.criterion}: {t.concern}</Typography>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}))}
|
||||||
|
|
||||||
|
{/* 15. Main Risks */}
|
||||||
|
{row('15. Risiken', compareItems.map(item => {
|
||||||
|
const risks = [...(item.match.risks ?? [])].sort(
|
||||||
|
(a, b) => (RISK_LEVEL_ORDER[a.level] ?? 4) - (RISK_LEVEL_ORDER[b.level] ?? 4)
|
||||||
|
).slice(0, 2)
|
||||||
|
if (risks.length === 0) return (
|
||||||
|
<Typography variant="body2" color="text.secondary">Keine identifizierten Risiken</Typography>
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
|
||||||
|
{risks.map((r, i) => (
|
||||||
|
<Box key={i} sx={{ display: 'flex', alignItems: 'flex-start', gap: 0.5 }}>
|
||||||
|
<XCircle size={13} color={r.level === 'CRITICAL' || r.level === 'HIGH' ? '#c0392b' : '#d97706'} style={{ flexShrink: 0, marginTop: 2 }} />
|
||||||
|
<Typography variant="caption" sx={{ lineHeight: 1.4 }}>{r.description}</Typography>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}))}
|
||||||
|
|
||||||
|
{/* 16. Missing Data */}
|
||||||
|
{row('16. Fehlende Daten', compareItems.map((item, idx) => {
|
||||||
|
const total = item.match.missingData?.length ?? 0
|
||||||
|
const critical = missingCriticalCounts[idx]
|
||||||
|
if (total === 0) return (
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||||
|
<CheckCircle2 size={14} color="#1a7a4a" />
|
||||||
|
<Typography variant="body2" sx={{ color: '#1a7a4a' }}>Vollständig</Typography>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
<CompareCell
|
||||||
|
highlight={critical > 0 && critical === maxMissingCritical ? 'critical' : critical > 0 ? 'worst' : 'none'}
|
||||||
|
icon={critical > 0 ? <AlertOctagon size={14} color="#c0392b" /> : <AlertTriangle size={14} color="#d97706" />}
|
||||||
|
iconTooltip={critical > 0 ? 'Kritische Pflichtfelder fehlen' : 'Optionale Felder fehlen'}
|
||||||
|
>
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 600 }}>{total} fehlend</Typography>
|
||||||
|
{critical > 0 && (
|
||||||
|
<Typography variant="caption" color="error">{critical} kritisch</Typography>
|
||||||
|
)}
|
||||||
|
</CompareCell>
|
||||||
|
)
|
||||||
|
}))}
|
||||||
|
|
||||||
|
{/* 17. Future Availability Context */}
|
||||||
|
{row('17. Zukunftskontext', compareItems.map(item => {
|
||||||
|
const sig = getSig(item)
|
||||||
|
if (!sig) return (
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ fontStyle: 'italic' }}>Nicht anwendbar</Typography>
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
<CompareCell highlight="future" icon={<Zap size={14} color={DS_COLORS.futureCard.controlled.accent} />} iconTooltip="Probabilistisches Zukunftssignal">
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.25 }}>
|
||||||
|
<Typography variant="caption" sx={{ fontWeight: 600, color: DS_COLORS.futureCard.controlled.accent }}>
|
||||||
|
{Math.round(sig.probability * 100)}% Wahrscheinlichkeit
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
Sensitivität: {sig.sensitivityLevel}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" color="text.secondary" sx={{ fontStyle: 'italic' }}>
|
||||||
|
{sig.disclaimer}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</CompareCell>
|
||||||
|
)
|
||||||
|
}))}
|
||||||
|
|
||||||
|
{/* 18. Recommended Next Action */}
|
||||||
|
{row('18. Nächste Aktion', compareItems.map(item => {
|
||||||
|
const action = item.match.nextBestActions?.[0]
|
||||||
|
if (!action) return <MissingDataCell />
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 600 }}>{action.label}</Typography>
|
||||||
|
{action.description && (
|
||||||
|
<Typography variant="caption" color="text.secondary">{action.description}</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}))}
|
||||||
|
|
||||||
|
</TableBody>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -2,11 +2,11 @@ import type { ReactNode } from 'react'
|
|||||||
import { Box, LinearProgress, Typography } from '@mui/material'
|
import { Box, LinearProgress, Typography } from '@mui/material'
|
||||||
import type { WeightingKey } from '../../domain/needBuilder'
|
import type { WeightingKey } from '../../domain/needBuilder'
|
||||||
import type { UnifiedMatchResult, VerifiedPortfolioResult, ExternalMarketResult, FutureAvailabilityResult } from '../../domain/unifiedResult'
|
import type { UnifiedMatchResult, VerifiedPortfolioResult, ExternalMarketResult, FutureAvailabilityResult } from '../../domain/unifiedResult'
|
||||||
|
import { dataQualityHex } from '../../lib/utils'
|
||||||
|
|
||||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export const HARD_CRITERIA = new Set(['area', 'location', 'budget', 'timing'])
|
export const HARD_CRITERIA = new Set(['area', 'location', 'budget', 'timing'])
|
||||||
export const SCORE_COLOR = (s: number) => s >= 78 ? '#1a7a4a' : s >= 52 ? '#d97706' : '#c0392b'
|
|
||||||
|
|
||||||
export function getProp(item: UnifiedMatchResult) {
|
export function getProp(item: UnifiedMatchResult) {
|
||||||
return item.resultType !== 'FUTURE_AVAILABILITY'
|
return item.resultType !== 'FUTURE_AVAILABILITY'
|
||||||
@@ -20,13 +20,6 @@ export function getSig(item: UnifiedMatchResult) {
|
|||||||
: null
|
: null
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TYPE_META: Record<string, { label: string; color: string }> = {
|
|
||||||
VERIFIED_PORTFOLIO: { label: 'Plattform', color: '#1e3a5f' },
|
|
||||||
EXTERNAL_MARKET: { label: 'Plattform', color: '#1e3a5f' },
|
|
||||||
MAISON_WORK: { label: 'Maison Work', color: '#0369a1' },
|
|
||||||
FUTURE_AVAILABILITY: { label: 'Zukunftssignal', color: '#7c3aed' },
|
|
||||||
}
|
|
||||||
|
|
||||||
export const RISK_LEVEL_ORDER: Record<string, number> = { CRITICAL: 0, HIGH: 1, MEDIUM: 2, LOW: 3 }
|
export const RISK_LEVEL_ORDER: Record<string, number> = { CRITICAL: 0, HIGH: 1, MEDIUM: 2, LOW: 3 }
|
||||||
|
|
||||||
export const CRITERION_ALIASES: Record<WeightingKey, string[]> = {
|
export const CRITERION_ALIASES: Record<WeightingKey, string[]> = {
|
||||||
@@ -78,7 +71,7 @@ export const DATA_SX = {
|
|||||||
// ── Score bar helper ──────────────────────────────────────────────────────────
|
// ── Score bar helper ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function scoreBar(value: number, label?: string): ReactNode {
|
export function scoreBar(value: number, label?: string): ReactNode {
|
||||||
const color = value >= 0.8 ? '#1a7a4a' : value >= 0.6 ? '#d97706' : '#c0392b'
|
const color = dataQualityHex(value)
|
||||||
return (
|
return (
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
<Box sx={{ width: 70 }}>
|
<Box sx={{ width: 70 }}>
|
||||||
@@ -3,3 +3,4 @@ export { CompareColumnHeader } from './CompareColumnHeader'
|
|||||||
export { CompareCell, MissingDataCell } from './CompareCell'
|
export { CompareCell, MissingDataCell } from './CompareCell'
|
||||||
export { AICompareSummary } from './AICompareSummary'
|
export { AICompareSummary } from './AICompareSummary'
|
||||||
export { CompareCriteriaCard } from './CompareCriteriaCard'
|
export { CompareCriteriaCard } from './CompareCriteriaCard'
|
||||||
|
export { CompareTableBody } from './CompareTableBody'
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { Alert, Box, Button, CircularProgress, Divider } from '@mui/material'
|
||||||
|
import { ArrowRight, Bookmark, Search } from 'lucide-react'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
canProceed: boolean
|
||||||
|
isSearching: boolean
|
||||||
|
isSavingProfile: boolean
|
||||||
|
onSearch: () => void
|
||||||
|
onSaveProfile: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AISearchActionBar({ canProceed, isSearching, isSavingProfile, onSearch, onSaveProfile }: Props) {
|
||||||
|
const isProcessing = isSearching || isSavingProfile
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Box sx={{ display: 'flex', gap: 2, alignItems: 'center' }}>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
size="large"
|
||||||
|
disabled={!canProceed || isProcessing}
|
||||||
|
onClick={onSearch}
|
||||||
|
endIcon={isSearching ? <CircularProgress size={18} color="inherit" /> : <Search size={18} />}
|
||||||
|
sx={{
|
||||||
|
flex: 1,
|
||||||
|
py: 1.5,
|
||||||
|
bgcolor: '#1e3a5f',
|
||||||
|
'&:hover': { bgcolor: '#162d4a' },
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: 600,
|
||||||
|
textTransform: 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isSearching ? 'Sucht…' : 'Jetzt suchen'}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Divider orientation="vertical" flexItem sx={{ mx: 0.5 }} />
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
size="large"
|
||||||
|
disabled={!canProceed || isProcessing}
|
||||||
|
onClick={onSaveProfile}
|
||||||
|
startIcon={<Bookmark size={16} />}
|
||||||
|
endIcon={isSavingProfile ? <CircularProgress size={18} color="inherit" /> : <ArrowRight size={18} />}
|
||||||
|
sx={{
|
||||||
|
py: 1.5,
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: 500,
|
||||||
|
textTransform: 'none',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isSavingProfile ? 'Analysiert…' : 'Als Suchprofil speichern'}
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Alert severity="info" sx={{ mt: -1 }}>
|
||||||
|
<strong>Jetzt suchen</strong> liefert sofortige Ergebnisse.{' '}
|
||||||
|
<strong>Als Suchprofil speichern</strong> legt einen dauerhaften Bedarf an, der automatisch mit neuen Angeboten abgeglichen wird — auch in Zukunft.
|
||||||
|
</Alert>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { Alert, Box, Button, CircularProgress } from '@mui/material'
|
||||||
|
import { Save } from 'lucide-react'
|
||||||
|
import { NeedCardPreview } from './NeedCardPreview'
|
||||||
|
import type { ParseNeedResult, ParsedNeedCriteria, WeightingKey } from '../../domain/needBuilder'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
criteria: ParsedNeedCriteria
|
||||||
|
weights: Record<WeightingKey, number>
|
||||||
|
parseResult: ParseNeedResult
|
||||||
|
needTitle: string
|
||||||
|
overallConfidence: number
|
||||||
|
isSaving: boolean
|
||||||
|
onNeedTitleChange: (t: string) => void
|
||||||
|
onBack: () => void
|
||||||
|
onSave: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AISearchSavePreview({
|
||||||
|
criteria,
|
||||||
|
weights,
|
||||||
|
parseResult,
|
||||||
|
needTitle,
|
||||||
|
overallConfidence,
|
||||||
|
isSaving,
|
||||||
|
onNeedTitleChange,
|
||||||
|
onBack,
|
||||||
|
onSave,
|
||||||
|
}: Props) {
|
||||||
|
return (
|
||||||
|
<Box sx={{ maxWidth: 1200, mx: 'auto' }}>
|
||||||
|
<Alert severity="success" sx={{ mb: 3 }}>
|
||||||
|
Dieses Suchprofil wird als aktiver Bedarf gespeichert und erscheint automatisch im Match Center der Verwaltung.
|
||||||
|
</Alert>
|
||||||
|
<NeedCardPreview
|
||||||
|
criteria={criteria}
|
||||||
|
weights={weights}
|
||||||
|
confidenceByField={parseResult.confidenceByField}
|
||||||
|
missingFields={parseResult.missingFields}
|
||||||
|
needTitle={needTitle}
|
||||||
|
onNeedTitleChange={onNeedTitleChange}
|
||||||
|
/>
|
||||||
|
<Box sx={{ maxWidth: 720, mx: 'auto', display: 'flex', justifyContent: 'space-between', mt: 3 }}>
|
||||||
|
<Button variant="outlined" onClick={onBack} disabled={isSaving}>
|
||||||
|
← Zurück
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
onClick={onSave}
|
||||||
|
disabled={isSaving}
|
||||||
|
endIcon={isSaving ? <CircularProgress size={16} color="inherit" /> : <Save size={16} />}
|
||||||
|
sx={{ bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#162d4a' }, textTransform: 'none' }}
|
||||||
|
>
|
||||||
|
{isSaving
|
||||||
|
? 'Wird gespeichert…'
|
||||||
|
: overallConfidence < 0.6
|
||||||
|
? 'Als Entwurf speichern'
|
||||||
|
: 'Suchprofil speichern & Matching starten'}
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,71 +1,86 @@
|
|||||||
import { Box, Chip, Typography } from '@mui/material'
|
import { Box, Chip, Typography } from '@mui/material'
|
||||||
import { Kanban } from 'lucide-react'
|
import { Kanban } from 'lucide-react'
|
||||||
|
import { INQUIRY_STATUS_META, DS_COLORS, DS_TEXT, DS_BG, DS_BORDER } from '../../lib/ds'
|
||||||
import type { Inquiry } from '../../domain/inquiry'
|
import type { Inquiry } from '../../domain/inquiry'
|
||||||
|
|
||||||
const STATUS_CONFIG: Record<string, { label: string; color: string; bgColor: string }> = {
|
|
||||||
new: { label: 'Neu', color: '#dc2626', bgColor: '#fef2f2' },
|
|
||||||
in_progress: { label: 'Aktiv', color: '#d97706', bgColor: '#fffbeb' },
|
|
||||||
answered: { label: 'Beantwortet', color: '#1a7a4a', bgColor: '#f0fdf4' },
|
|
||||||
archived: { label: 'Archiviert', color: '#64748b', bgColor: '#f8fafc' },
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AnfragenInquiryItemProps {
|
interface AnfragenInquiryItemProps {
|
||||||
inq: Inquiry
|
inq: Inquiry
|
||||||
isSelected: boolean
|
isSelected: boolean
|
||||||
hasPipeline: boolean
|
hasPipeline: boolean
|
||||||
|
perspective?: 'demand' | 'supply'
|
||||||
onSelect: (id: string) => void
|
onSelect: (id: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AnfragenInquiryItem({ inq, isSelected, hasPipeline, onSelect }: AnfragenInquiryItemProps) {
|
export function AnfragenInquiryItem({ inq, isSelected, hasPipeline, perspective = 'supply', onSelect }: AnfragenInquiryItemProps) {
|
||||||
const cfg = STATUS_CONFIG[inq.status ?? 'new']
|
const cfg = INQUIRY_STATUS_META[inq.status ?? 'new']
|
||||||
const displayDate = new Date(inq.updatedAt).toLocaleDateString('de-CH', { day: '2-digit', month: '2-digit' })
|
const displayDate = new Date(inq.updatedAt).toLocaleDateString('de-CH', { day: '2-digit', month: '2-digit' })
|
||||||
const lastMsg = inq.thread[inq.thread.length - 1]
|
const lastMsg = inq.thread[inq.thread.length - 1]
|
||||||
|
const unreadColor = cfg?.fg ?? DS_TEXT.danger
|
||||||
|
|
||||||
|
const primaryTitle = perspective === 'demand'
|
||||||
|
? (inq.propertyAddress ?? inq.subject)
|
||||||
|
: inq.tenantName
|
||||||
|
|
||||||
|
const secondaryLine = perspective === 'demand'
|
||||||
|
? (inq.propertyManagerCompany ?? inq.propertyManagerName ?? '')
|
||||||
|
: (inq.tenantCompany ?? '')
|
||||||
|
|
||||||
|
const lastMsgPrefix = (() => {
|
||||||
|
if (!lastMsg) return ''
|
||||||
|
if (perspective === 'demand') {
|
||||||
|
return lastMsg.senderType === 'tenant'
|
||||||
|
? 'Sie: '
|
||||||
|
: `${inq.propertyManagerName ?? 'Verwalter'}: `
|
||||||
|
}
|
||||||
|
return lastMsg.senderType === 'tenant' ? 'Sie: ' : `${lastMsg.senderName}: `
|
||||||
|
})()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box onClick={() => onSelect(inq.id)} sx={{
|
<Box onClick={() => onSelect(inq.id)} sx={{
|
||||||
px: 2, py: 1.5,
|
px: 2, py: 1.5,
|
||||||
borderBottom: '1px solid #f1f5f9',
|
borderBottom: `1px solid ${DS_BORDER.muted}`,
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
bgcolor: isSelected ? '#eff6ff' : !inq.isRead ? 'rgba(220,38,38,0.03)' : 'transparent',
|
bgcolor: isSelected ? DS_COLORS.futureCard.signal.headerBg : !inq.isRead ? 'rgba(220,38,38,0.03)' : 'transparent',
|
||||||
borderLeft: isSelected ? '3px solid #1e3a5f' : '3px solid transparent',
|
borderLeft: isSelected ? '3px solid' : '3px solid transparent',
|
||||||
'&:hover': { bgcolor: isSelected ? '#eff6ff' : '#f8fafc' },
|
borderLeftColor: isSelected ? 'primary.main' : 'transparent',
|
||||||
|
'&:hover': { bgcolor: isSelected ? DS_COLORS.futureCard.signal.headerBg : DS_BG.page },
|
||||||
transition: 'background-color 0.1s ease',
|
transition: 'background-color 0.1s ease',
|
||||||
}}>
|
}}>
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 0.375 }}>
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 0.375 }}>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, minWidth: 0 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, minWidth: 0 }}>
|
||||||
{!inq.isRead && <Box sx={{ width: 7, height: 7, borderRadius: '50%', bgcolor: '#dc2626', flexShrink: 0 }} />}
|
{!inq.isRead && <Box sx={{ width: 7, height: 7, borderRadius: '50%', bgcolor: unreadColor, flexShrink: 0 }} />}
|
||||||
<Typography variant="body2" sx={{ fontWeight: !inq.isRead ? 700 : 500, fontSize: '0.8125rem' }} noWrap>
|
<Typography variant="body2" sx={{ fontWeight: !inq.isRead ? 700 : 500, fontSize: '0.8125rem' }} noWrap>
|
||||||
{inq.tenantName}
|
{primaryTitle}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, flexShrink: 0 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, flexShrink: 0 }}>
|
||||||
{inq.unreadCount > 0 && (
|
{inq.unreadCount > 0 && (
|
||||||
<Box sx={{ width: 18, height: 18, borderRadius: '50%', bgcolor: '#dc2626', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
<Box sx={{ width: 18, height: 18, borderRadius: '50%', bgcolor: unreadColor, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
<Typography sx={{ color: 'white', fontSize: '0.6rem', fontWeight: 700 }}>{inq.unreadCount}</Typography>
|
<Typography sx={{ color: 'white', fontSize: '0.6rem', fontWeight: 700 }}>{inq.unreadCount}</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.7rem' }}>{displayDate}</Typography>
|
<Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.7rem' }}>{displayDate}</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
{inq.tenantCompany && (
|
{secondaryLine && (
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', fontSize: '0.7rem', mb: 0.25 }} noWrap>
|
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', fontSize: '0.7rem', mb: 0.25 }} noWrap>
|
||||||
{inq.tenantCompany}
|
{secondaryLine}
|
||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
<Typography variant="caption" sx={{ color: '#475569', display: 'block', mb: 0.5, fontWeight: !inq.isRead ? 600 : 400, fontSize: '0.75rem' }} noWrap>
|
<Typography variant="caption" sx={{ color: DS_TEXT.secondary, display: 'block', mb: 0.5, fontWeight: !inq.isRead ? 600 : 400, fontSize: '0.75rem' }} noWrap>
|
||||||
{inq.subject}
|
{inq.subject}
|
||||||
</Typography>
|
</Typography>
|
||||||
{lastMsg && (
|
{lastMsg && (
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.5, fontSize: '0.7rem' }} noWrap>
|
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.5, fontSize: '0.7rem' }} noWrap>
|
||||||
{lastMsg.senderType === 'tenant' ? 'Sie: ' : `${lastMsg.senderName}: `}
|
{lastMsgPrefix}
|
||||||
{lastMsg.body.split('\n')[0]}
|
{lastMsg.body.split('\n')[0]}
|
||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
<Chip label={cfg?.label ?? inq.status} size="small"
|
<Chip label={cfg?.label ?? inq.status} size="small"
|
||||||
sx={{ height: 16, fontSize: '0.6rem', bgcolor: cfg?.bgColor, color: cfg?.color, fontWeight: 600 }} />
|
sx={{ height: 16, fontSize: '0.6rem', bgcolor: cfg?.bg, color: cfg?.fg, fontWeight: 600 }} />
|
||||||
{inq.matchScore && (
|
{inq.matchScore && (
|
||||||
<Typography variant="caption" sx={{ color: inq.matchScore >= 80 ? '#1a7a4a' : '#d97706', fontWeight: 700, fontSize: '0.7rem' }}>
|
<Typography variant="caption" sx={{ color: inq.matchScore >= 80 ? DS_TEXT.success : DS_TEXT.warning, fontWeight: 700, fontSize: '0.7rem' }}>
|
||||||
{inq.matchScore}%
|
{inq.matchScore}%
|
||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
@@ -74,7 +89,12 @@ export function AnfragenInquiryItem({ inq, isSelected, hasPipeline, onSelect }:
|
|||||||
icon={<Kanban size={9} />}
|
icon={<Kanban size={9} />}
|
||||||
label="Pipeline"
|
label="Pipeline"
|
||||||
size="small"
|
size="small"
|
||||||
sx={{ height: 16, fontSize: '0.6rem', bgcolor: '#eff6ff', color: '#1e3a5f', fontWeight: 600, '& .MuiChip-icon': { color: '#1e3a5f' } }}
|
sx={{
|
||||||
|
height: 16, fontSize: '0.6rem', fontWeight: 600,
|
||||||
|
bgcolor: DS_COLORS.futureCard.signal.headerBg,
|
||||||
|
color: 'primary.main',
|
||||||
|
'& .MuiChip-icon': { color: 'primary.main' },
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { Box, Typography } from '@mui/material'
|
import { Box, Typography } from '@mui/material'
|
||||||
import { Bot, Building2, FileText } from 'lucide-react'
|
import { Bot, Building2, FileText } from 'lucide-react'
|
||||||
|
import { DS_COLORS, DS_TEXT, DS_BG, DS_BORDER } from '../../lib/ds'
|
||||||
import type { InquiryMessage } from '../../domain/inquiry'
|
import type { InquiryMessage } from '../../domain/inquiry'
|
||||||
|
|
||||||
export function AnfragenMessageBubble({ msg }: { msg: InquiryMessage }) {
|
export function AnfragenMessageBubble({ msg, currentUserName }: { msg: InquiryMessage; currentUserName?: string }) {
|
||||||
const isOwnMessage = msg.senderType === 'tenant'
|
const isOwnMessage = msg.senderType === 'tenant'
|
||||||
const isAI = msg.senderType === 'ai'
|
const isAI = msg.senderType === 'ai'
|
||||||
const time = new Date(msg.createdAt).toLocaleTimeString('de-CH', { hour: '2-digit', minute: '2-digit' })
|
const time = new Date(msg.createdAt).toLocaleTimeString('de-CH', { hour: '2-digit', minute: '2-digit' })
|
||||||
@@ -11,10 +12,10 @@ export function AnfragenMessageBubble({ msg }: { msg: InquiryMessage }) {
|
|||||||
return (
|
return (
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: isOwnMessage ? 'flex-end' : 'flex-start', mb: 0.5 }}>
|
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: isOwnMessage ? 'flex-end' : 'flex-start', mb: 0.5 }}>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mb: 0.375 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mb: 0.375 }}>
|
||||||
{!isOwnMessage && isAI && <Bot size={11} color="#7c3aed" />}
|
{!isOwnMessage && isAI && <Bot size={11} color={DS_TEXT.signal} />}
|
||||||
{!isOwnMessage && msg.senderType === 'supply_user' && <Building2 size={11} color="#64748b" />}
|
{!isOwnMessage && msg.senderType === 'supply_user' && <Building2 size={11} color={DS_TEXT.muted} />}
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.7rem' }}>
|
<Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.7rem' }}>
|
||||||
{msg.senderName} · {date} {time}
|
{isOwnMessage && currentUserName ? currentUserName : msg.senderName} · {date} {time}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
<Box
|
<Box
|
||||||
@@ -22,15 +23,15 @@ export function AnfragenMessageBubble({ msg }: { msg: InquiryMessage }) {
|
|||||||
maxWidth: { xs: '88%', md: '72%' },
|
maxWidth: { xs: '88%', md: '72%' },
|
||||||
px: 2, py: 1.25,
|
px: 2, py: 1.25,
|
||||||
borderRadius: isOwnMessage ? '18px 18px 4px 18px' : '18px 18px 18px 4px',
|
borderRadius: isOwnMessage ? '18px 18px 4px 18px' : '18px 18px 18px 4px',
|
||||||
bgcolor: isOwnMessage ? '#1e3a5f' : isAI ? '#f5f3ff' : 'white',
|
bgcolor: isOwnMessage ? 'primary.main' : isAI ? DS_COLORS.futureCard.controlled.headerBg : 'white',
|
||||||
border: isOwnMessage ? 'none' : isAI ? '1px solid #ddd6fe' : '1px solid #e2e8f0',
|
border: isOwnMessage ? 'none' : isAI ? `1px solid ${DS_COLORS.futureCard.controlled.border}` : `1px solid ${DS_BORDER.default}`,
|
||||||
boxShadow: isOwnMessage ? '0 2px 6px rgba(30,58,95,0.2)' : '0 1px 2px rgba(0,0,0,0.06)',
|
boxShadow: isOwnMessage ? '0 2px 6px rgba(30,58,95,0.2)' : '0 1px 2px rgba(0,0,0,0.06)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Typography
|
<Typography
|
||||||
variant="body2"
|
variant="body2"
|
||||||
sx={{
|
sx={{
|
||||||
color: isOwnMessage ? 'white' : isAI ? '#5b21b6' : '#1e293b',
|
color: isOwnMessage ? 'white' : isAI ? DS_COLORS.futureCard.controlled.badgeText : DS_TEXT.primary,
|
||||||
whiteSpace: 'pre-wrap', lineHeight: 1.65, fontSize: '0.875rem',
|
whiteSpace: 'pre-wrap', lineHeight: 1.65, fontSize: '0.875rem',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -44,17 +45,17 @@ export function AnfragenMessageBubble({ msg }: { msg: InquiryMessage }) {
|
|||||||
sx={{
|
sx={{
|
||||||
display: 'flex', alignItems: 'center', gap: 1,
|
display: 'flex', alignItems: 'center', gap: 1,
|
||||||
px: 1.5, py: 0.75,
|
px: 1.5, py: 0.75,
|
||||||
bgcolor: isOwnMessage ? 'rgba(255,255,255,0.12)' : '#f1f5f9',
|
bgcolor: isOwnMessage ? 'rgba(255,255,255,0.12)' : DS_BG.subtle,
|
||||||
borderRadius: 1.5, cursor: 'pointer',
|
borderRadius: 1.5, cursor: 'pointer',
|
||||||
'&:hover': { bgcolor: isOwnMessage ? 'rgba(255,255,255,0.2)' : '#e2e8f0' },
|
'&:hover': { bgcolor: isOwnMessage ? 'rgba(255,255,255,0.2)' : DS_BG.muted },
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<FileText size={13} color={isOwnMessage ? 'rgba(255,255,255,0.75)' : '#64748b'} />
|
<FileText size={13} color={isOwnMessage ? 'rgba(255,255,255,0.75)' : DS_TEXT.muted} />
|
||||||
<Typography variant="caption" sx={{ color: isOwnMessage ? 'rgba(255,255,255,0.9)' : '#475569', flex: 1 }} noWrap>
|
<Typography variant="caption" sx={{ color: isOwnMessage ? 'rgba(255,255,255,0.9)' : DS_TEXT.secondary, flex: 1 }} noWrap>
|
||||||
{att.fileName}
|
{att.fileName}
|
||||||
</Typography>
|
</Typography>
|
||||||
{att.fileSize && (
|
{att.fileSize && (
|
||||||
<Typography variant="caption" sx={{ color: isOwnMessage ? 'rgba(255,255,255,0.5)' : '#94a3b8', flexShrink: 0 }}>
|
<Typography variant="caption" sx={{ color: isOwnMessage ? 'rgba(255,255,255,0.5)' : DS_TEXT.disabled, flexShrink: 0 }}>
|
||||||
{att.fileSize < 1024 * 1024
|
{att.fileSize < 1024 * 1024
|
||||||
? `${Math.round(att.fileSize / 1024)} KB`
|
? `${Math.round(att.fileSize / 1024)} KB`
|
||||||
: `${(att.fileSize / 1024 / 1024).toFixed(1)} MB`}
|
: `${(att.fileSize / 1024 / 1024).toFixed(1)} MB`}
|
||||||
|
|||||||
@@ -1,22 +1,17 @@
|
|||||||
import { Chip } from '@mui/material'
|
import { Chip } from '@mui/material'
|
||||||
|
import { confidenceHex } from '../../lib/utils'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
confidence: number
|
confidence: number
|
||||||
size?: 'small' | 'medium'
|
size?: 'small' | 'medium'
|
||||||
}
|
}
|
||||||
|
|
||||||
function confidenceColor(c: number): string {
|
|
||||||
if (c >= 0.8) return '#1a7a4a'
|
|
||||||
if (c >= 0.6) return '#d97706'
|
|
||||||
return '#c0392b'
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ConfidenceFieldBadge({ confidence, size = 'small' }: Props) {
|
export function ConfidenceFieldBadge({ confidence, size = 'small' }: Props) {
|
||||||
return (
|
return (
|
||||||
<Chip
|
<Chip
|
||||||
label={`${Math.round(confidence * 100)}%`}
|
label={`${Math.round(confidence * 100)}%`}
|
||||||
size={size}
|
size={size}
|
||||||
sx={{ bgcolor: confidenceColor(confidence), color: 'white', fontWeight: 700, fontSize: 11 }}
|
sx={{ bgcolor: confidenceHex(confidence), color: 'white', fontWeight: 700, fontSize: 11 }}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ interface Props {
|
|||||||
|
|
||||||
const ASSET_LABELS: Record<string, string> = {
|
const ASSET_LABELS: Record<string, string> = {
|
||||||
OFFICE: 'Büro', LOGISTICS: 'Logistik / Lager', RETAIL: 'Retail',
|
OFFICE: 'Büro', LOGISTICS: 'Logistik / Lager', RETAIL: 'Retail',
|
||||||
PRODUCTION: 'Produktion', GASTRO: 'Gastro / F&B', LIGHT_INDUSTRIAL: 'Light Industrial',
|
PRODUCTION: 'Produktion', GASTRO: 'Gastro / F&B', LIGHT_INDUSTRIAL: 'Gewerbe',
|
||||||
MIXED: 'Gemischt', UNKNOWN: 'Unbekannt',
|
MIXED: 'Gemischt', UNKNOWN: 'Unbekannt',
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,7 +38,7 @@ function displayAreaRange(v: ParsedNeedCriteria['areaRange']): string {
|
|||||||
return v ? `${v.min}–${v.max} m²` : ''
|
return v ? `${v.min}–${v.max} m²` : ''
|
||||||
}
|
}
|
||||||
function displayBudget(v: ParsedNeedCriteria['budgetRange']): string {
|
function displayBudget(v: ParsedNeedCriteria['budgetRange']): string {
|
||||||
return v ? `CHF ${v.maxPerSqm}/m²` : ''
|
return v ? `CHF ${v.maxPerSqm}/m²/Jahr` : ''
|
||||||
}
|
}
|
||||||
function displayTiming(v: ParsedNeedCriteria['timing']): string {
|
function displayTiming(v: ParsedNeedCriteria['timing']): string {
|
||||||
if (!v) return ''
|
if (!v) return ''
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ interface Props {
|
|||||||
|
|
||||||
const ASSET_LABELS: Record<string, string> = {
|
const ASSET_LABELS: Record<string, string> = {
|
||||||
OFFICE: 'Büro', LOGISTICS: 'Logistik / Lager', RETAIL: 'Retail',
|
OFFICE: 'Büro', LOGISTICS: 'Logistik / Lager', RETAIL: 'Retail',
|
||||||
PRODUCTION: 'Produktion', GASTRO: 'Gastro / F&B', LIGHT_INDUSTRIAL: 'Light Industrial',
|
PRODUCTION: 'Produktion', GASTRO: 'Gastro / F&B', LIGHT_INDUSTRIAL: 'Gewerbe',
|
||||||
}
|
}
|
||||||
|
|
||||||
const CRITICAL_FIELDS = ['assetType', 'areaRange', 'preferredLocations', 'budgetRange', 'timing']
|
const CRITICAL_FIELDS = ['assetType', 'areaRange', 'preferredLocations', 'budgetRange', 'timing']
|
||||||
@@ -95,7 +95,7 @@ export function NeedCardPreview({ criteria: c, weights, confidenceByField, missi
|
|||||||
<Wallet size={16} color="#64748b" style={{ flexShrink: 0, marginTop: 2 }} />
|
<Wallet size={16} color="#64748b" style={{ flexShrink: 0, marginTop: 2 }} />
|
||||||
<Box>
|
<Box>
|
||||||
<Typography variant="caption" color="text.secondary">Budget</Typography>
|
<Typography variant="caption" color="text.secondary">Budget</Typography>
|
||||||
<Typography variant="body2">max. CHF {c.budgetRange.maxPerSqm}/m²</Typography>
|
<Typography variant="body2">max. CHF {c.budgetRange.maxPerSqm}/m²/Jahr</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { Accordion, AccordionDetails, AccordionSummary, Box, Checkbox, FormControlLabel, MenuItem, TextField, Typography } from '@mui/material'
|
||||||
|
import { ChevronDown } from 'lucide-react'
|
||||||
|
import type { ParsedNeedCriteria } from '../../domain/needBuilder'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
criteria: ParsedNeedCriteria
|
||||||
|
onChange: (c: ParsedNeedCriteria) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NeedExtendedRequirements({ criteria: c, onChange: set }: Props) {
|
||||||
|
return (
|
||||||
|
<Accordion disableGutters elevation={0} sx={{ border: '1px solid #e2e8f0', borderRadius: 1, mt: 1, '&:before': { display: 'none' } }}>
|
||||||
|
<AccordionSummary expandIcon={<ChevronDown size={16} />} sx={{ minHeight: 36, px: 1.5, '& .MuiAccordionSummary-content': { my: 0.5 } }}>
|
||||||
|
<Typography variant="caption" sx={{ fontWeight: 600, color: '#64748b' }}>Erweiterte Anforderungen</Typography>
|
||||||
|
</AccordionSummary>
|
||||||
|
<AccordionDetails sx={{ px: 1.5, pt: 0, pb: 1.5 }}>
|
||||||
|
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 0.5, mb: 1.5 }}>
|
||||||
|
<FormControlLabel
|
||||||
|
control={<Checkbox size="small" checked={c.requireGroundFloor ?? false} onChange={e => set({ ...c, requireGroundFloor: e.target.checked || undefined })} />}
|
||||||
|
label={<Typography variant="caption">Erdgeschoss erforderlich</Typography>}
|
||||||
|
/>
|
||||||
|
<FormControlLabel
|
||||||
|
control={<Checkbox size="small" checked={c.requireAirConditioning ?? false} onChange={e => set({ ...c, requireAirConditioning: e.target.checked || undefined })} />}
|
||||||
|
label={<Typography variant="caption">Klimaanlage erforderlich</Typography>}
|
||||||
|
/>
|
||||||
|
<FormControlLabel
|
||||||
|
control={<Checkbox size="small" checked={c.requireLoadingDock ?? false} onChange={e => set({ ...c, requireLoadingDock: e.target.checked || undefined })} />}
|
||||||
|
label={<Typography variant="caption">Laderampe erforderlich</Typography>}
|
||||||
|
/>
|
||||||
|
<FormControlLabel
|
||||||
|
control={<Checkbox size="small" checked={c.requireBarrierFree ?? false} onChange={e => set({ ...c, requireBarrierFree: e.target.checked || undefined })} />}
|
||||||
|
label={<Typography variant="caption">Barrierefrei erforderlich</Typography>}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 1.5 }}>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.5 }}>Mindest-Parkplätze</Typography>
|
||||||
|
<TextField
|
||||||
|
size="small" type="number" fullWidth placeholder="0"
|
||||||
|
value={c.requiredParkingMin ?? ''}
|
||||||
|
onChange={e => set({ ...c, requiredParkingMin: parseInt(e.target.value) || undefined })}
|
||||||
|
slotProps={{ htmlInput: { min: 0, max: 100 } }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.5 }}>Mindest-Ausbaustandard</Typography>
|
||||||
|
<TextField
|
||||||
|
select size="small" fullWidth
|
||||||
|
value={c.requiredFitOut ?? ''}
|
||||||
|
onChange={e => set({ ...c, requiredFitOut: (e.target.value as 'BASIC' | 'FULL' | 'PREMIUM') || undefined })}
|
||||||
|
>
|
||||||
|
<MenuItem value="">Kein Mindeststandard</MenuItem>
|
||||||
|
<MenuItem value="BASIC">Basisausbau</MenuItem>
|
||||||
|
<MenuItem value="FULL">Vollausbau</MenuItem>
|
||||||
|
<MenuItem value="PREMIUM">Premiumausbau</MenuItem>
|
||||||
|
</TextField>
|
||||||
|
</Box>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.5 }}>Mindest-Deckenhöhe (m)</Typography>
|
||||||
|
<TextField
|
||||||
|
size="small" type="number" fullWidth placeholder="z.B. 6"
|
||||||
|
value={c.minCeilingHeightM ?? ''}
|
||||||
|
onChange={e => set({ ...c, minCeilingHeightM: parseFloat(e.target.value) || undefined })}
|
||||||
|
slotProps={{ htmlInput: { min: 2, max: 20, step: 0.5 } }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.5 }}>Mindest-Vertragslaufzeit (Monate)</Typography>
|
||||||
|
<TextField
|
||||||
|
size="small" type="number" fullWidth placeholder="z.B. 36"
|
||||||
|
value={c.minContractDurationMonths ?? ''}
|
||||||
|
onChange={e => set({ ...c, minContractDurationMonths: parseInt(e.target.value) || undefined })}
|
||||||
|
slotProps={{ htmlInput: { min: 0, max: 360, step: 12 } }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</AccordionDetails>
|
||||||
|
</Accordion>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { Accordion, AccordionDetails, AccordionSummary, Box, Card, Checkbox, Chip, FormControlLabel, MenuItem, Stack, TextField, Typography } from '@mui/material'
|
import { Box, Card, Chip, Stack, TextField, Typography } from '@mui/material'
|
||||||
import { ChevronDown } from 'lucide-react'
|
|
||||||
import type { ParsedNeedCriteria } from '../../domain/needBuilder'
|
import type { ParsedNeedCriteria } from '../../domain/needBuilder'
|
||||||
import { AssetType } from '../../domain/enums'
|
import { AssetType } from '../../domain/enums'
|
||||||
|
import { NeedExtendedRequirements } from './NeedExtendedRequirements'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
criteria: ParsedNeedCriteria
|
criteria: ParsedNeedCriteria
|
||||||
@@ -14,7 +14,7 @@ const ASSET_OPTIONS = [
|
|||||||
{ label: 'Retail', value: AssetType.RETAIL },
|
{ label: 'Retail', value: AssetType.RETAIL },
|
||||||
{ label: 'Logistik', value: AssetType.LOGISTICS },
|
{ label: 'Logistik', value: AssetType.LOGISTICS },
|
||||||
{ label: 'Produktion', value: AssetType.PRODUCTION },
|
{ label: 'Produktion', value: AssetType.PRODUCTION },
|
||||||
{ label: 'Light Industrial', value: AssetType.LIGHT_INDUSTRIAL },
|
{ label: 'Gewerbe', value: AssetType.LIGHT_INDUSTRIAL },
|
||||||
{ label: 'Gemischt', value: AssetType.MIXED },
|
{ label: 'Gemischt', value: AssetType.MIXED },
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -220,77 +220,7 @@ export function NeedInput({ criteria: c, onCriteriaChange: set }: Props) {
|
|||||||
</Stack>
|
</Stack>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Extended requirements */}
|
<NeedExtendedRequirements criteria={c} onChange={set} />
|
||||||
<Accordion disableGutters elevation={0} sx={{ border: '1px solid #e2e8f0', borderRadius: 1, mt: 1, '&:before': { display: 'none' } }}>
|
|
||||||
<AccordionSummary expandIcon={<ChevronDown size={16} />} sx={{ minHeight: 36, px: 1.5, '& .MuiAccordionSummary-content': { my: 0.5 } }}>
|
|
||||||
<Typography variant="caption" sx={{ fontWeight: 600, color: '#64748b' }}>Erweiterte Anforderungen</Typography>
|
|
||||||
</AccordionSummary>
|
|
||||||
<AccordionDetails sx={{ px: 1.5, pt: 0, pb: 1.5 }}>
|
|
||||||
{/* Boolean requirement checkboxes — 2×2 grid */}
|
|
||||||
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 0.5, mb: 1.5 }}>
|
|
||||||
<FormControlLabel
|
|
||||||
control={<Checkbox size="small" checked={c.requireGroundFloor ?? false} onChange={e => set({ ...c, requireGroundFloor: e.target.checked || undefined })} />}
|
|
||||||
label={<Typography variant="caption">Erdgeschoss erforderlich</Typography>}
|
|
||||||
/>
|
|
||||||
<FormControlLabel
|
|
||||||
control={<Checkbox size="small" checked={c.requireAirConditioning ?? false} onChange={e => set({ ...c, requireAirConditioning: e.target.checked || undefined })} />}
|
|
||||||
label={<Typography variant="caption">Klimaanlage erforderlich</Typography>}
|
|
||||||
/>
|
|
||||||
<FormControlLabel
|
|
||||||
control={<Checkbox size="small" checked={c.requireLoadingDock ?? false} onChange={e => set({ ...c, requireLoadingDock: e.target.checked || undefined })} />}
|
|
||||||
label={<Typography variant="caption">Laderampe erforderlich</Typography>}
|
|
||||||
/>
|
|
||||||
<FormControlLabel
|
|
||||||
control={<Checkbox size="small" checked={c.requireBarrierFree ?? false} onChange={e => set({ ...c, requireBarrierFree: e.target.checked || undefined })} />}
|
|
||||||
label={<Typography variant="caption">Barrierefrei erforderlich</Typography>}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Numeric / select fields — 2×2 grid */}
|
|
||||||
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 1.5 }}>
|
|
||||||
<Box>
|
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.5 }}>Mindest-Parkplätze</Typography>
|
|
||||||
<TextField
|
|
||||||
size="small" type="number" fullWidth placeholder="0"
|
|
||||||
value={c.requiredParkingMin ?? ''}
|
|
||||||
onChange={e => set({ ...c, requiredParkingMin: parseInt(e.target.value) || undefined })}
|
|
||||||
slotProps={{ htmlInput: { min: 0, max: 100 } }}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
<Box>
|
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.5 }}>Mindest-Ausbaustandard</Typography>
|
|
||||||
<TextField
|
|
||||||
select size="small" fullWidth
|
|
||||||
value={c.requiredFitOut ?? ''}
|
|
||||||
onChange={e => set({ ...c, requiredFitOut: (e.target.value as 'BASIC' | 'FULL' | 'PREMIUM') || undefined })}
|
|
||||||
>
|
|
||||||
<MenuItem value="">Kein Mindeststandard</MenuItem>
|
|
||||||
<MenuItem value="BASIC">Basisausbau</MenuItem>
|
|
||||||
<MenuItem value="FULL">Vollausbau</MenuItem>
|
|
||||||
<MenuItem value="PREMIUM">Premiumausbau</MenuItem>
|
|
||||||
</TextField>
|
|
||||||
</Box>
|
|
||||||
<Box>
|
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.5 }}>Mindest-Deckenhöhe (m)</Typography>
|
|
||||||
<TextField
|
|
||||||
size="small" type="number" fullWidth placeholder="z.B. 6"
|
|
||||||
value={c.minCeilingHeightM ?? ''}
|
|
||||||
onChange={e => set({ ...c, minCeilingHeightM: parseFloat(e.target.value) || undefined })}
|
|
||||||
slotProps={{ htmlInput: { min: 2, max: 20, step: 0.5 } }}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
<Box>
|
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.5 }}>Mindest-Vertragslaufzeit (Monate)</Typography>
|
|
||||||
<TextField
|
|
||||||
size="small" type="number" fullWidth placeholder="z.B. 36"
|
|
||||||
value={c.minContractDurationMonths ?? ''}
|
|
||||||
onChange={e => set({ ...c, minContractDurationMonths: parseInt(e.target.value) || undefined })}
|
|
||||||
slotProps={{ htmlInput: { min: 0, max: 360, step: 12 } }}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
</AccordionDetails>
|
|
||||||
</Accordion>
|
|
||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { Box, Button, Card, Divider, Slider, Typography } from '@mui/material'
|
|||||||
import { RotateCcw } from 'lucide-react'
|
import { RotateCcw } from 'lucide-react'
|
||||||
import { WEIGHTING_KEYS, WEIGHTING_LABELS } from '../../domain/needBuilder'
|
import { WEIGHTING_KEYS, WEIGHTING_LABELS } from '../../domain/needBuilder'
|
||||||
import type { WeightingKey } from '../../domain/needBuilder'
|
import type { WeightingKey } from '../../domain/needBuilder'
|
||||||
import { weightingService } from '../../services/weightingService'
|
import { useDefaultWeights } from '../../hooks/useWeighting'
|
||||||
|
|
||||||
const HARD_KEYS: WeightingKey[] = ['area', 'location', 'budget', 'timing']
|
const HARD_KEYS: WeightingKey[] = ['area', 'location', 'budget', 'timing']
|
||||||
const SOFT_KEYS: WeightingKey[] = [
|
const SOFT_KEYS: WeightingKey[] = [
|
||||||
@@ -74,6 +74,7 @@ function SliderGroup({
|
|||||||
|
|
||||||
export function WeightingEditor({ weights, onChange, assetType }: Props) {
|
export function WeightingEditor({ weights, onChange, assetType }: Props) {
|
||||||
const [raw, setRaw] = useState<Record<WeightingKey, number>>(() => toRaw(weights))
|
const [raw, setRaw] = useState<Record<WeightingKey, number>>(() => toRaw(weights))
|
||||||
|
const defaultWeights = useDefaultWeights(assetType)
|
||||||
|
|
||||||
function handleSlider(key: WeightingKey, value: number) {
|
function handleSlider(key: WeightingKey, value: number) {
|
||||||
const updated = { ...raw, [key]: value }
|
const updated = { ...raw, [key]: value }
|
||||||
@@ -82,9 +83,8 @@ export function WeightingEditor({ weights, onChange, assetType }: Props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleReset() {
|
function handleReset() {
|
||||||
const defaults = weightingService.getDefaultWeights(assetType)
|
setRaw(toRaw(defaultWeights))
|
||||||
setRaw(toRaw(defaults))
|
onChange(defaultWeights)
|
||||||
onChange(defaults)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -6,9 +6,9 @@ import { SensitivityBadge } from './SensitivityBadge'
|
|||||||
import { SignalReviewStatusBadge } from './SignalReviewStatusBadge'
|
import { SignalReviewStatusBadge } from './SignalReviewStatusBadge'
|
||||||
import { FutureSignalDisclaimer } from './FutureSignalDisclaimer'
|
import { FutureSignalDisclaimer } from './FutureSignalDisclaimer'
|
||||||
import { useUpdateSignalReviewStatus } from '../../hooks/useFutureSignals'
|
import { useUpdateSignalReviewStatus } from '../../hooks/useFutureSignals'
|
||||||
|
import { useCreateReviewTask } from '../../hooks/useReviewQueue'
|
||||||
import { useShortlistStore } from '../../stores/shortlistStore'
|
import { useShortlistStore } from '../../stores/shortlistStore'
|
||||||
import { useToastStore } from '../../stores/toastStore'
|
import { useToastStore } from '../../stores/toastStore'
|
||||||
import { reviewService } from '../../services/reviewService'
|
|
||||||
import { ReviewStatus } from '../../domain/enums'
|
import { ReviewStatus } from '../../domain/enums'
|
||||||
import type { FutureSignal } from '../../domain/futureSignal'
|
import type { FutureSignal } from '../../domain/futureSignal'
|
||||||
|
|
||||||
@@ -51,6 +51,7 @@ interface Props {
|
|||||||
|
|
||||||
export function FutureSignalDetailPanel({ signal, onClose }: Props) {
|
export function FutureSignalDetailPanel({ signal, onClose }: Props) {
|
||||||
const updateStatus = useUpdateSignalReviewStatus()
|
const updateStatus = useUpdateSignalReviewStatus()
|
||||||
|
const createReviewTask = useCreateReviewTask()
|
||||||
const { openAddDialog } = useShortlistStore()
|
const { openAddDialog } = useShortlistStore()
|
||||||
const showToast = useToastStore((s) => s.showToast)
|
const showToast = useToastStore((s) => s.showToast)
|
||||||
const [reviewTaskSent, setReviewTaskSent] = useState(false)
|
const [reviewTaskSent, setReviewTaskSent] = useState(false)
|
||||||
@@ -75,14 +76,13 @@ export function FutureSignalDetailPanel({ signal, onClose }: Props) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSendReview() {
|
function handleSendReview() {
|
||||||
try {
|
createReviewTask.mutate(signal.id, {
|
||||||
await reviewService.createReviewTask(signal.id)
|
onSuccess: () => {
|
||||||
setReviewTaskSent(true)
|
setReviewTaskSent(true)
|
||||||
showToast('Prüfungsaufgabe erstellt.')
|
showToast('Prüfungsaufgabe erstellt.')
|
||||||
} catch {
|
},
|
||||||
showToast('Prüfungsaufgabe konnte nicht erstellt werden.', 'error')
|
})
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleShortlist() {
|
function handleShortlist() {
|
||||||
|
|||||||
@@ -17,8 +17,10 @@ import { WorkspaceType } from '../../domain/enums'
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export function AppShell() {
|
export function AppShell() {
|
||||||
const { activeWorkspace, sidebarCollapsed, setActiveWorkspace, toggleSidebar } =
|
const activeWorkspace = useLayoutStore(s => s.activeWorkspace)
|
||||||
useLayoutStore()
|
const sidebarCollapsed = useLayoutStore(s => s.sidebarCollapsed)
|
||||||
|
const setActiveWorkspace = useLayoutStore(s => s.setActiveWorkspace)
|
||||||
|
const toggleSidebar = useLayoutStore(s => s.toggleSidebar)
|
||||||
const { currentUser } = useSessionStore()
|
const { currentUser } = useSessionStore()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
|
|||||||
@@ -1,28 +1,20 @@
|
|||||||
import { useEffect } from 'react'
|
|
||||||
import { useNavigate, useLocation } from 'react-router'
|
import { useNavigate, useLocation } from 'react-router'
|
||||||
import { Box, Button, IconButton, Typography } from '@mui/material'
|
import { Box, Button, IconButton, Typography } from '@mui/material'
|
||||||
import { X } from 'lucide-react'
|
import { X } from 'lucide-react'
|
||||||
import { useCompareStore } from '../../stores/compareStore'
|
import { useCompareStore } from '../../stores/compareStore'
|
||||||
import { useLayoutStore } from '../../stores/layoutStore'
|
|
||||||
|
|
||||||
const TYPE_DOT: Record<string, string> = {
|
const TYPE_DOT: Record<string, string> = {
|
||||||
VERIFIED_PORTFOLIO: '#1e3a5f',
|
VERIFIED_PORTFOLIO: '#1e3a5f',
|
||||||
EXTERNAL_MARKET: '#d97706',
|
|
||||||
MAISON_WORK: '#0369a1',
|
MAISON_WORK: '#0369a1',
|
||||||
FUTURE_AVAILABILITY: '#7c3aed',
|
FUTURE_AVAILABILITY: '#7c3aed',
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CompareTray() {
|
export function CompareTray() {
|
||||||
const { compareItems, removeFromCompare, clearCompare } = useCompareStore()
|
const { compareItems, removeFromCompare, clearCompare } = useCompareStore()
|
||||||
const { setCompareTrayVisible } = useLayoutStore()
|
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const isDemand = location.pathname.startsWith('/demand')
|
const isDemand = location.pathname.startsWith('/demand')
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setCompareTrayVisible(compareItems.length > 0 && isDemand)
|
|
||||||
}, [compareItems.length, setCompareTrayVisible, isDemand])
|
|
||||||
|
|
||||||
if (!isDemand) return null
|
if (!isDemand) return null
|
||||||
|
|
||||||
const getTitle = (item: (typeof compareItems)[number]) => {
|
const getTitle = (item: (typeof compareItems)[number]) => {
|
||||||
|
|||||||
@@ -18,7 +18,9 @@ const PANEL_PLACEHOLDERS: Record<RightPanelContentType, string> = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function RightContextPanel() {
|
export function RightContextPanel() {
|
||||||
const { isRightPanelOpen, rightPanelContentType, closeRightPanel } = useLayoutStore()
|
const isRightPanelOpen = useLayoutStore(s => s.isRightPanelOpen)
|
||||||
|
const rightPanelContentType = useLayoutStore(s => s.rightPanelContentType)
|
||||||
|
const closeRightPanel = useLayoutStore(s => s.closeRightPanel)
|
||||||
|
|
||||||
const title = rightPanelContentType ? PANEL_TITLES[rightPanelContentType] : ''
|
const title = rightPanelContentType ? PANEL_TITLES[rightPanelContentType] : ''
|
||||||
const placeholder = rightPanelContentType ? PANEL_PLACEHOLDERS[rightPanelContentType] : ''
|
const placeholder = rightPanelContentType ? PANEL_PLACEHOLDERS[rightPanelContentType] : ''
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import type { LucideIcon } from 'lucide-react'
|
|||||||
import {
|
import {
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
Building2,
|
Building2,
|
||||||
Target,
|
|
||||||
CheckSquare,
|
CheckSquare,
|
||||||
Search,
|
Search,
|
||||||
List,
|
List,
|
||||||
@@ -13,7 +12,7 @@ import {
|
|||||||
MessageSquare,
|
MessageSquare,
|
||||||
Kanban,
|
Kanban,
|
||||||
BellRing,
|
BellRing,
|
||||||
Plus,
|
Settings,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -50,12 +49,10 @@ export const WORKSPACE_CONFIG: Record<WorkspaceType, WorkspaceConfig> = {
|
|||||||
{ path: '/supply/dashboard', label: 'Übersicht', icon: LayoutDashboard },
|
{ path: '/supply/dashboard', label: 'Übersicht', icon: LayoutDashboard },
|
||||||
{ path: '/supply/properties', label: 'Meine Objekte', icon: Building2 },
|
{ path: '/supply/properties', label: 'Meine Objekte', icon: Building2 },
|
||||||
{ path: '/supply/reminder-manager', label: 'Reminder Manager', icon: BellRing },
|
{ path: '/supply/reminder-manager', label: 'Reminder Manager', icon: BellRing },
|
||||||
{ path: '/supply/market-leads', label: 'Markt-Leads', icon: Target },
|
|
||||||
{ path: '/supply/anfragen', label: 'Anfragencenter', icon: MessageSquare },
|
{ path: '/supply/anfragen', label: 'Anfragencenter', icon: MessageSquare },
|
||||||
{ path: '/supply/data-quality', label: 'Datenpflege', icon: CheckSquare },
|
{ path: '/supply/data-quality', label: 'Datenpflege', icon: CheckSquare },
|
||||||
{ path: '/supply/market-intelligence', label: 'Markt Intelligence', icon: Radar },
|
{ path: '/supply/market-intelligence', label: 'Marktchancen', icon: Radar },
|
||||||
{ path: '/supply/my-listings', label: 'Meine Inserate', icon: ClipboardList },
|
{ path: '/supply/my-listings', label: 'Inserate', icon: ClipboardList },
|
||||||
{ path: '/supply/new-listing', label: 'Neues Inserat', icon: Plus },
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
[WorkspaceType.DEMAND]: {
|
[WorkspaceType.DEMAND]: {
|
||||||
@@ -72,6 +69,17 @@ export const WORKSPACE_CONFIG: Record<WorkspaceType, WorkspaceConfig> = {
|
|||||||
{ path: '/demand/anfragen', label: 'Anfragen', icon: MessageSquare },
|
{ path: '/demand/anfragen', label: 'Anfragen', icon: MessageSquare },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
[WorkspaceType.OPERATIONS]: {
|
||||||
|
label: 'Operations',
|
||||||
|
abbreviation: 'OP',
|
||||||
|
icon: Settings,
|
||||||
|
firstPath: '/ops/review-queue',
|
||||||
|
chipColor: '#6b21a8',
|
||||||
|
navItems: [
|
||||||
|
{ path: '/ops/review-queue', label: 'Review Queue', icon: CheckSquare },
|
||||||
|
{ path: '/ops/audit', label: 'Audit Log', icon: ClipboardList },
|
||||||
|
],
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ordered list for rendering workspace tabs
|
// Ordered list for rendering workspace tabs
|
||||||
|
|||||||
@@ -1,90 +1,25 @@
|
|||||||
|
import { memo } from 'react'
|
||||||
import { Box, Button, Divider, Typography } from '@mui/material'
|
import { Box, Button, Divider, Typography } from '@mui/material'
|
||||||
import {
|
import { AlertCircle, CheckCircle2, ShieldCheck } from 'lucide-react'
|
||||||
AlertCircle,
|
|
||||||
BarChart2,
|
|
||||||
Briefcase,
|
|
||||||
CheckCircle2,
|
|
||||||
FileCheck,
|
|
||||||
FileText,
|
|
||||||
Newspaper,
|
|
||||||
ShieldCheck,
|
|
||||||
User,
|
|
||||||
} from 'lucide-react'
|
|
||||||
import { useNavigate } from 'react-router'
|
import { useNavigate } from 'react-router'
|
||||||
import { getScoreTier, SCORE_THEME } from '../shared/scoreTheme'
|
import { getScoreTier, SCORE_THEME } from '../shared/scoreTheme'
|
||||||
|
import { floorLabel, SOURCE_META, ASSET_TYPE_LABELS, getOpportunityHeadline } from './futureAvailabilityCardHelpers'
|
||||||
|
import { SignalQualityDots } from './SignalQualityDots'
|
||||||
|
import { DS_COLORS, DS_TEXT, DS_SURFACE, DS_BORDER } from '../../lib/ds'
|
||||||
import type { MatchCardViewModel } from './MatchCardViewModel'
|
import type { MatchCardViewModel } from './MatchCardViewModel'
|
||||||
|
|
||||||
function floorLabel(level: number): string {
|
|
||||||
if (level === 0) return 'EG'
|
|
||||||
if (level < 0) return `UG ${Math.abs(level)}`
|
|
||||||
return `${level}.OG`
|
|
||||||
}
|
|
||||||
|
|
||||||
const SOURCE_META: Record<string, { label: string; icon: React.ReactNode }> = {
|
|
||||||
JOB_POSTING: { label: 'Stelleninserate', icon: <Briefcase size={11} /> },
|
|
||||||
PRESS: { label: 'Pressebericht', icon: <Newspaper size={11} /> },
|
|
||||||
CONSTRUCTION_PERMIT:{ label: 'Baubewilligung', icon: <FileCheck size={11} /> },
|
|
||||||
COMPANY_REPORT: { label: 'Geschäftsbericht', icon: <FileText size={11} /> },
|
|
||||||
MARKET_DATA: { label: 'Marktdaten', icon: <BarChart2 size={11} /> },
|
|
||||||
MANUAL: { label: 'Analyst', icon: <User size={11} /> },
|
|
||||||
LEASE_CONTRACT: { label: 'Vertrag verifiziert', icon: <ShieldCheck size={11} /> },
|
|
||||||
}
|
|
||||||
|
|
||||||
const ASSET_TYPE_LABELS: Record<string, string> = {
|
|
||||||
OFFICE: 'Bürofläche',
|
|
||||||
LOGISTICS: 'Lagerfläche',
|
|
||||||
RETAIL: 'Retailfläche',
|
|
||||||
PRODUCTION: 'Produktionsfläche',
|
|
||||||
MIXED: 'Gewerbefläche',
|
|
||||||
}
|
|
||||||
|
|
||||||
// Signal type → opportunity headline (with asset type)
|
|
||||||
function getOpportunityHeadline(signalType: string | undefined, assetTypeLabel: string): string {
|
|
||||||
switch (signalType) {
|
|
||||||
case 'LEASE_EXPIRY': return `${assetTypeLabel} wird verfügbar`
|
|
||||||
case 'POSSIBLE_MOVE_OUT': return `Mögliche ${assetTypeLabel} erkannt`
|
|
||||||
case 'EXPANSION': return `Unternehmen sucht ${assetTypeLabel}`
|
|
||||||
case 'CONSTRUCTION_PROJECT': return `Neubau: ${assetTypeLabel} in Planung`
|
|
||||||
case 'RESTRUCTURING': return `Mögliche Flächenfreigabe erkannt`
|
|
||||||
case 'SPACE_CONSOLIDATION': return `Mögliche Teilfläche erkannt`
|
|
||||||
case 'PROJECT_DEVELOPMENT': return `Neue Fläche in Projektentwicklung`
|
|
||||||
default: return `Potenzielle ${assetTypeLabel} erkannt`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Signal quality dots display
|
|
||||||
function SignalQualityDots({ quality }: { quality: 'HIGH' | 'MEDIUM' | 'LOW' | undefined }) {
|
|
||||||
const config = {
|
|
||||||
HIGH: { dots: [1, 1, 1, 1], color: '#1a7a4a', label: 'Hohe Signalqualität' },
|
|
||||||
MEDIUM: { dots: [1, 1, 1, 0], color: '#d97706', label: 'Mittlere Signalqualität' },
|
|
||||||
LOW: { dots: [1, 1, 0, 0], color: '#c0392b', label: 'Niedrige Signalqualität' },
|
|
||||||
}
|
|
||||||
const c = quality ? config[quality] : config.LOW
|
|
||||||
return (
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
|
||||||
{c.dots.map((filled, i) => (
|
|
||||||
<Box key={i} sx={{ width: 7, height: 7, borderRadius: '50%', bgcolor: filled ? c.color : '#e2e8f0' }} />
|
|
||||||
))}
|
|
||||||
<Typography sx={{ fontSize: '0.68rem', color: c.color, fontWeight: 600, ml: 0.25 }}>
|
|
||||||
{c.label}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 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()
|
||||||
|
|
||||||
// PRE-MARKET VERIFIED: soft purple / institutional premium
|
const cardColors = isControlled ? DS_COLORS.futureCard.controlled : DS_COLORS.futureCard.signal
|
||||||
// MARKET SIGNAL: slate blue / analytical
|
const accentColor = cardColors.accent
|
||||||
const accentColor = isControlled ? '#7c3aed' : '#1d4ed8'
|
const headerBg = cardColors.headerBg
|
||||||
const headerBg = isControlled ? '#faf5ff' : '#eff6ff'
|
const borderColor = cardColors.border
|
||||||
const borderColor = isControlled ? '#e9d5ff' : '#bfdbfe'
|
const badgeBg = cardColors.badgeBg
|
||||||
const badgeBg = isControlled ? '#ede9fe' : '#dbeafe'
|
const badgeColor = cardColors.badgeText
|
||||||
const badgeColor = isControlled ? '#5b21b6' : '#1e40af'
|
|
||||||
|
|
||||||
const assetLabel = vm.assetType ? (ASSET_TYPE_LABELS[vm.assetType] ?? vm.assetType) : 'Fläche'
|
const assetLabel = vm.assetType ? (ASSET_TYPE_LABELS[vm.assetType] ?? vm.assetType) : 'Fläche'
|
||||||
const headline = getOpportunityHeadline(vm.signalType, assetLabel)
|
const headline = getOpportunityHeadline(vm.signalType, assetLabel)
|
||||||
@@ -110,7 +45,7 @@ export function FutureAvailabilityCard({ vm }: { vm: MatchCardViewModel }) {
|
|||||||
|
|
||||||
{/* Asset type · location + badge */}
|
{/* Asset type · location + badge */}
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 0.5 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 0.5 }}>
|
||||||
<Typography sx={{ fontSize: '0.63rem', fontWeight: 700, color: '#64748b', textTransform: 'uppercase', letterSpacing: 0.8 }}>
|
<Typography sx={{ fontSize: '0.63rem', fontWeight: 700, color: DS_TEXT.muted, textTransform: 'uppercase', letterSpacing: 0.8 }}>
|
||||||
{assetLabel.toUpperCase()} · {vm.locationLabel?.split(',')[0]?.toUpperCase() ?? ''}
|
{assetLabel.toUpperCase()} · {vm.locationLabel?.split(',')[0]?.toUpperCase() ?? ''}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.4, bgcolor: badgeBg, color: badgeColor, px: 0.875, py: 0.25, borderRadius: 1, flexShrink: 0 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.4, bgcolor: badgeBg, color: badgeColor, px: 0.875, py: 0.25, borderRadius: 1, flexShrink: 0 }}>
|
||||||
@@ -122,19 +57,19 @@ export function FutureAvailabilityCard({ vm }: { vm: MatchCardViewModel }) {
|
|||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Opportunity headline */}
|
{/* Opportunity headline */}
|
||||||
<Typography variant="subtitle1" sx={{ fontWeight: 700, lineHeight: 1.3, color: '#1e293b', mb: 0.75 }}>
|
<Typography variant="subtitle1" sx={{ fontWeight: 700, lineHeight: 1.3, color: DS_TEXT.primary, mb: 0.75 }}>
|
||||||
{headline}
|
{headline}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
{/* Key facts */}
|
{/* Key facts */}
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, flexWrap: 'wrap', mb: 0.875 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, flexWrap: 'wrap', mb: 0.875 }}>
|
||||||
{vm.signalAreaSqmEstimate ? (
|
{vm.signalAreaSqmEstimate ? (
|
||||||
<Typography sx={{ fontSize: '0.75rem', fontWeight: 700, color: '#1e293b' }}>
|
<Typography sx={{ fontSize: '0.75rem', fontWeight: 700, color: DS_TEXT.primary }}>
|
||||||
{isControlled ? '' : '~'}{vm.signalAreaSqmEstimate.toLocaleString('de-CH')} m²
|
{isControlled ? '' : '~'}{vm.signalAreaSqmEstimate.toLocaleString('de-CH')} m²
|
||||||
</Typography>
|
</Typography>
|
||||||
) : null}
|
) : null}
|
||||||
{vm.availabilityLabel && (
|
{vm.availabilityLabel && (
|
||||||
<Typography sx={{ fontSize: '0.72rem', color: '#475569' }}>
|
<Typography sx={{ fontSize: '0.72rem', color: DS_TEXT.secondary }}>
|
||||||
{vm.availabilityLabel}
|
{vm.availabilityLabel}
|
||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
@@ -142,20 +77,20 @@ export function FutureAvailabilityCard({ vm }: { vm: MatchCardViewModel }) {
|
|||||||
|
|
||||||
{/* Source attribution */}
|
{/* Source attribution */}
|
||||||
{isControlled ? (
|
{isControlled ? (
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, bgcolor: '#f0fdf4', border: '1px solid #bbf7d0', borderRadius: 1, px: 0.875, py: 0.3, width: 'fit-content' }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, bgcolor: DS_SURFACE.success.bg, border: `1px solid ${DS_SURFACE.success.border}`, borderRadius: 1, px: 0.875, py: 0.3, width: 'fit-content' }}>
|
||||||
<ShieldCheck size={10} color="#1a7a4a" />
|
<ShieldCheck size={10} color={DS_TEXT.success} />
|
||||||
<Typography sx={{ fontSize: '0.65rem', fontWeight: 700, color: '#1a7a4a' }}>Direkte Verwaltungsquelle</Typography>
|
<Typography sx={{ fontSize: '0.65rem', fontWeight: 700, color: DS_TEXT.success }}>Direkte Verwaltungsquelle</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
) : sourceMeta ? (
|
) : sourceMeta ? (
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, width: 'fit-content' }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, width: 'fit-content' }}>
|
||||||
<Box sx={{ color: '#64748b', display: 'flex' }}>{sourceMeta.icon}</Box>
|
<Box sx={{ color: DS_TEXT.muted, display: 'flex' }}>{sourceMeta.icon}</Box>
|
||||||
<Typography sx={{ fontSize: '0.65rem', color: '#64748b', fontWeight: 500 }}>{sourceMeta.label}</Typography>
|
<Typography sx={{ fontSize: '0.65rem', color: DS_TEXT.muted, fontWeight: 500 }}>{sourceMeta.label}</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
) : null}
|
) : null}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* ── Score + signal quality strip ────────────────────────────────────── */}
|
{/* ── Score + signal quality strip ────────────────────────────────────── */}
|
||||||
<Box sx={{ px: 2, py: 1, display: 'flex', alignItems: 'center', gap: 1.5, borderBottom: '1px solid #f1f5f9', bgcolor: 'white' }}>
|
<Box sx={{ px: 2, py: 1, display: 'flex', alignItems: 'center', gap: 1.5, borderBottom: `1px solid ${DS_BORDER.muted}`, bgcolor: 'white' }}>
|
||||||
<Box sx={{
|
<Box sx={{
|
||||||
background: theme.gradient, borderRadius: '8px',
|
background: theme.gradient, borderRadius: '8px',
|
||||||
px: 1.25, py: 0.4, border: `1px solid ${theme.border}`,
|
px: 1.25, py: 0.4, border: `1px solid ${theme.border}`,
|
||||||
@@ -167,8 +102,8 @@ export function FutureAvailabilityCard({ vm }: { vm: MatchCardViewModel }) {
|
|||||||
</Box>
|
</Box>
|
||||||
<SignalQualityDots quality={vm.signalQuality} />
|
<SignalQualityDots quality={vm.signalQuality} />
|
||||||
{!isControlled && vm.signalProbability !== undefined && (
|
{!isControlled && vm.signalProbability !== undefined && (
|
||||||
<Box sx={{ ml: 'auto', bgcolor: '#f8fafc', border: '1px solid #e2e8f0', borderRadius: 1, px: 0.75, py: 0.2 }}>
|
<Box sx={{ ml: 'auto', bgcolor: DS_SURFACE.neutral.bg, border: `1px solid ${DS_BORDER.default}`, borderRadius: 1, px: 0.75, py: 0.2 }}>
|
||||||
<Typography sx={{ fontSize: '0.63rem', color: '#64748b', fontWeight: 600 }}>
|
<Typography sx={{ fontSize: '0.63rem', color: DS_TEXT.muted, fontWeight: 600 }}>
|
||||||
{Math.round(vm.signalProbability * 100)}% Signalw.
|
{Math.round(vm.signalProbability * 100)}% Signalw.
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -182,10 +117,10 @@ export function FutureAvailabilityCard({ vm }: { vm: MatchCardViewModel }) {
|
|||||||
{!isControlled && (
|
{!isControlled && (
|
||||||
<Box sx={{
|
<Box sx={{
|
||||||
display: 'flex', alignItems: 'flex-start', gap: 0.6, mb: 1.25,
|
display: 'flex', alignItems: 'flex-start', gap: 0.6, mb: 1.25,
|
||||||
bgcolor: '#fefce8', border: '1px solid #fde68a', borderRadius: 1, px: 1, py: 0.625,
|
bgcolor: DS_SURFACE.warning.bg, border: `1px solid ${DS_SURFACE.warning.border}`, borderRadius: 1, px: 1, py: 0.625,
|
||||||
}}>
|
}}>
|
||||||
<AlertCircle size={11} color="#92400e" style={{ marginTop: 2, flexShrink: 0 }} />
|
<AlertCircle size={11} color={DS_COLORS.heat.VERY_HOT.text} style={{ marginTop: 2, flexShrink: 0 }} />
|
||||||
<Typography sx={{ fontSize: '0.67rem', color: '#78350f', lineHeight: 1.45 }}>
|
<Typography sx={{ fontSize: '0.67rem', color: DS_COLORS.heat.VERY_HOT.text, lineHeight: 1.45 }}>
|
||||||
Probabilistischer Marktindikator — kein bestätigtes Objekt. Dient als strategischer Frühindikator.
|
Probabilistischer Marktindikator — kein bestätigtes Objekt. Dient als strategischer Frühindikator.
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -196,8 +131,8 @@ export function FutureAvailabilityCard({ vm }: { vm: MatchCardViewModel }) {
|
|||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.4, mb: 1.25 }}>
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.4, mb: 1.25 }}>
|
||||||
{(vm.signalConfirmedFacts ?? []).slice(0, 3).map((fact, i) => (
|
{(vm.signalConfirmedFacts ?? []).slice(0, 3).map((fact, i) => (
|
||||||
<Box key={i} sx={{ display: 'flex', alignItems: 'center', gap: 0.6 }}>
|
<Box key={i} sx={{ display: 'flex', alignItems: 'center', gap: 0.6 }}>
|
||||||
<CheckCircle2 size={12} color="#1a7a4a" style={{ flexShrink: 0 }} />
|
<CheckCircle2 size={12} color={DS_TEXT.success} style={{ flexShrink: 0 }} />
|
||||||
<Typography sx={{ fontSize: '0.72rem', color: '#1a7a4a', fontWeight: 500 }}>{fact}</Typography>
|
<Typography sx={{ fontSize: '0.72rem', color: DS_TEXT.success, fontWeight: 500 }}>{fact}</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
))}
|
))}
|
||||||
</Box>
|
</Box>
|
||||||
@@ -205,19 +140,19 @@ export function FutureAvailabilityCard({ vm }: { vm: MatchCardViewModel }) {
|
|||||||
|
|
||||||
{/* PRE-MARKET VERIFIED: specific unit info */}
|
{/* PRE-MARKET VERIFIED: specific unit info */}
|
||||||
{isControlled && vm.preMarketUnit && (
|
{isControlled && vm.preMarketUnit && (
|
||||||
<Box sx={{ bgcolor: '#f0fdf4', border: '1px solid #bbf7d0', borderRadius: 1, px: 1.25, py: 0.875, mb: 1.25 }}>
|
<Box sx={{ bgcolor: DS_SURFACE.success.bg, border: `1px solid ${DS_SURFACE.success.border}`, borderRadius: 1, px: 1.25, py: 0.875, mb: 1.25 }}>
|
||||||
<Typography sx={{ fontSize: '0.63rem', fontWeight: 700, color: '#1a7a4a', textTransform: 'uppercase', letterSpacing: 0.5, mb: 0.4 }}>
|
<Typography sx={{ fontSize: '0.63rem', fontWeight: 700, color: DS_TEXT.success, textTransform: 'uppercase', letterSpacing: 0.5, mb: 0.4 }}>
|
||||||
Freigegebene Einheit
|
Freigegebene Einheit
|
||||||
</Typography>
|
</Typography>
|
||||||
<Box sx={{ display: 'flex', gap: 1.5, flexWrap: 'wrap' }}>
|
<Box sx={{ display: 'flex', gap: 1.5, flexWrap: 'wrap' }}>
|
||||||
<Typography sx={{ fontSize: '0.72rem', color: '#1e293b', fontWeight: 600 }}>
|
<Typography sx={{ fontSize: '0.72rem', color: DS_TEXT.primary, fontWeight: 600 }}>
|
||||||
{floorLabel(vm.preMarketUnit.floorLevel)}{vm.preMarketUnit.unitLabel ? ` · ${vm.preMarketUnit.unitLabel}` : ''}
|
{floorLabel(vm.preMarketUnit.floorLevel)}{vm.preMarketUnit.unitLabel ? ` · ${vm.preMarketUnit.unitLabel}` : ''}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography sx={{ fontSize: '0.72rem', color: '#475569' }}>
|
<Typography sx={{ fontSize: '0.72rem', color: DS_TEXT.secondary }}>
|
||||||
{vm.preMarketUnit.areaSqm.toLocaleString('de-CH')} m²
|
{vm.preMarketUnit.areaSqm.toLocaleString('de-CH')} m²
|
||||||
</Typography>
|
</Typography>
|
||||||
{vm.preMarketUnit.schattenmarktRelease?.availableFrom && (
|
{vm.preMarketUnit.schattenmarktRelease?.availableFrom && (
|
||||||
<Typography sx={{ fontSize: '0.72rem', color: '#475569' }}>
|
<Typography sx={{ fontSize: '0.72rem', color: DS_TEXT.secondary }}>
|
||||||
ab {new Date(vm.preMarketUnit.schattenmarktRelease.availableFrom).toLocaleDateString('de-CH', { month: 'short', year: 'numeric' })}
|
ab {new Date(vm.preMarketUnit.schattenmarktRelease.availableFrom).toLocaleDateString('de-CH', { month: 'short', year: 'numeric' })}
|
||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
@@ -228,14 +163,14 @@ export function FutureAvailabilityCard({ vm }: { vm: MatchCardViewModel }) {
|
|||||||
{/* MARKET SIGNAL: market indicators */}
|
{/* MARKET SIGNAL: market indicators */}
|
||||||
{!isControlled && (vm.signalMarketIndicators?.length ?? 0) > 0 && (
|
{!isControlled && (vm.signalMarketIndicators?.length ?? 0) > 0 && (
|
||||||
<Box sx={{ mb: 1.25 }}>
|
<Box sx={{ mb: 1.25 }}>
|
||||||
<Typography sx={{ fontSize: '0.63rem', fontWeight: 700, color: '#64748b', textTransform: 'uppercase', letterSpacing: 0.6, mb: 0.5 }}>
|
<Typography sx={{ fontSize: '0.63rem', fontWeight: 700, color: DS_TEXT.muted, textTransform: 'uppercase', letterSpacing: 0.6, mb: 0.5 }}>
|
||||||
Erkannte Marktindikatoren
|
Erkannte Marktindikatoren
|
||||||
</Typography>
|
</Typography>
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.35 }}>
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.35 }}>
|
||||||
{(vm.signalMarketIndicators ?? []).slice(0, 3).map((ind, i) => (
|
{(vm.signalMarketIndicators ?? []).slice(0, 3).map((ind, i) => (
|
||||||
<Box key={i} sx={{ display: 'flex', alignItems: 'flex-start', gap: 0.6 }}>
|
<Box key={i} sx={{ display: 'flex', alignItems: 'flex-start', gap: 0.6 }}>
|
||||||
<Box sx={{ width: 4, height: 4, borderRadius: '50%', bgcolor: '#1d4ed8', mt: '5px', flexShrink: 0 }} />
|
<Box sx={{ width: 4, height: 4, borderRadius: '50%', bgcolor: DS_COLORS.futureCard.signal.indicator, mt: '5px', flexShrink: 0 }} />
|
||||||
<Typography sx={{ fontSize: '0.72rem', color: '#475569', lineHeight: 1.3 }}>{ind}</Typography>
|
<Typography sx={{ fontSize: '0.72rem', color: DS_TEXT.secondary, lineHeight: 1.3 }}>{ind}</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
))}
|
))}
|
||||||
</Box>
|
</Box>
|
||||||
@@ -246,16 +181,16 @@ export function FutureAvailabilityCard({ vm }: { vm: MatchCardViewModel }) {
|
|||||||
{vm.reasons.length > 0 && (
|
{vm.reasons.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<Divider sx={{ mb: 1 }} />
|
<Divider sx={{ mb: 1 }} />
|
||||||
<Typography sx={{ fontSize: '0.63rem', fontWeight: 700, color: '#64748b', textTransform: 'uppercase', letterSpacing: 0.6, mb: 0.6 }}>
|
<Typography sx={{ fontSize: '0.63rem', fontWeight: 700, color: DS_TEXT.muted, textTransform: 'uppercase', letterSpacing: 0.6, mb: 0.6 }}>
|
||||||
Warum relevant für Ihre Suche?
|
Warum relevant für Ihre Suche?
|
||||||
</Typography>
|
</Typography>
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5, mb: 1.25 }}>
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5, mb: 1.25 }}>
|
||||||
{vm.reasons.slice(0, 3).map((r, i) => (
|
{vm.reasons.slice(0, 3).map((r, i) => (
|
||||||
<Box key={i} sx={{ display: 'flex', alignItems: 'flex-start', gap: 0.6 }}>
|
<Box key={i} sx={{ display: 'flex', alignItems: 'flex-start', gap: 0.6 }}>
|
||||||
<CheckCircle2 size={12} color="#1a7a4a" style={{ marginTop: 2, flexShrink: 0 }} />
|
<CheckCircle2 size={12} color={DS_TEXT.success} style={{ marginTop: 2, flexShrink: 0 }} />
|
||||||
<Box>
|
<Box>
|
||||||
<Typography sx={{ fontSize: '0.72rem', fontWeight: 600, color: '#1e293b', lineHeight: 1.3 }}>{r.label}</Typography>
|
<Typography sx={{ fontSize: '0.72rem', fontWeight: 600, color: DS_TEXT.primary, lineHeight: 1.3 }}>{r.label}</Typography>
|
||||||
<Typography sx={{ fontSize: '0.69rem', color: '#64748b', lineHeight: 1.3 }}>{r.explanation}</Typography>
|
<Typography sx={{ fontSize: '0.69rem', color: DS_TEXT.muted, lineHeight: 1.3 }}>{r.explanation}</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
))}
|
))}
|
||||||
@@ -276,7 +211,7 @@ export function FutureAvailabilityCard({ vm }: { vm: MatchCardViewModel }) {
|
|||||||
textTransform: 'none', fontSize: '0.7rem', py: 0.375, px: 1,
|
textTransform: 'none', fontSize: '0.7rem', py: 0.375, px: 1,
|
||||||
...(a.variant === 'primary' && {
|
...(a.variant === 'primary' && {
|
||||||
bgcolor: accentColor,
|
bgcolor: accentColor,
|
||||||
'&:hover': { bgcolor: isControlled ? '#6d28d9' : '#1e40af' },
|
'&:hover': { bgcolor: cardColors.accentHover },
|
||||||
}),
|
}),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -290,7 +225,9 @@ export function FutureAvailabilityCard({ vm }: { vm: MatchCardViewModel }) {
|
|||||||
onClick={() => navigate(`/demand/property/${vm.propertyId}${vm.unitId ? `?unit=${vm.unitId}` : ''}`)}
|
onClick={() => navigate(`/demand/property/${vm.propertyId}${vm.unitId ? `?unit=${vm.unitId}` : ''}`)}
|
||||||
sx={{
|
sx={{
|
||||||
textTransform: 'none', fontSize: '0.7rem', py: 0.375, px: 1,
|
textTransform: 'none', fontSize: '0.7rem', py: 0.375, px: 1,
|
||||||
bgcolor: '#7c3aed', '&:hover': { bgcolor: '#6d28d9' }, ml: 'auto',
|
bgcolor: DS_COLORS.futureCard.controlled.accent,
|
||||||
|
'&:hover': { bgcolor: DS_COLORS.futureCard.controlled.accentHover },
|
||||||
|
ml: 'auto',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Zur Einheit →
|
Zur Einheit →
|
||||||
@@ -300,11 +237,11 @@ export function FutureAvailabilityCard({ vm }: { vm: MatchCardViewModel }) {
|
|||||||
|
|
||||||
{/* Disclaimer footnote */}
|
{/* Disclaimer footnote */}
|
||||||
{vm.disclaimer && (
|
{vm.disclaimer && (
|
||||||
<Typography variant="caption" color="text.disabled" sx={{ display: 'block', mt: 1, fontSize: '0.63rem', lineHeight: 1.4, borderTop: '1px solid #f1f5f9', pt: 0.75 }}>
|
<Typography variant="caption" color="text.disabled" sx={{ display: 'block', mt: 1, fontSize: '0.63rem', lineHeight: 1.4, borderTop: `1px solid ${DS_BORDER.muted}`, pt: 0.75 }}>
|
||||||
{vm.disclaimer}
|
{vm.disclaimer}
|
||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
}
|
})
|
||||||
|
|||||||
@@ -4,21 +4,14 @@ import {
|
|||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { useSessionStore } from '../../stores/sessionStore'
|
import { useSessionStore } from '../../stores/sessionStore'
|
||||||
|
import { useInquiryStore } from '../../stores/inquiryStore'
|
||||||
import { LocationPreview } from '../shared/LocationPreview'
|
import { LocationPreview } from '../shared/LocationPreview'
|
||||||
import { HeatBadge } from '../shared/HeatBadge'
|
import { HeatBadge } from '../shared/HeatBadge'
|
||||||
import { getScoreTier, SCORE_THEME } from '../shared/scoreTheme'
|
import { getScoreTier, SCORE_THEME } from '../shared/scoreTheme'
|
||||||
|
import { RESULT_TYPE_META } from '../../lib/ds'
|
||||||
import type { MatchCardViewModel } from './MatchCardViewModel'
|
import type { MatchCardViewModel } from './MatchCardViewModel'
|
||||||
import { FutureAvailabilityCard } from './FutureAvailabilityCard'
|
import { FutureAvailabilityCard } from './FutureAvailabilityCard'
|
||||||
|
|
||||||
// ── Constants ─────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const RESULT_TYPE_META: Record<string, { label: string; color: string }> = {
|
|
||||||
VERIFIED_PORTFOLIO: { label: 'Plattform', color: '#1e3a5f' },
|
|
||||||
EXTERNAL_MARKET: { label: 'Plattform', color: '#1e3a5f' },
|
|
||||||
MAISON_WORK: { label: 'Maison Work', color: '#0369a1' },
|
|
||||||
FUTURE_AVAILABILITY: { label: 'Future Availability', color: '#7c3aed' },
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Main component ────────────────────────────────────────────────────────────
|
// ── Main component ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -27,12 +20,15 @@ interface Props {
|
|||||||
lat?: number
|
lat?: number
|
||||||
lng?: number
|
lng?: number
|
||||||
cityLabel?: string
|
cityLabel?: string
|
||||||
|
overlayTypeLabel?: string
|
||||||
|
overlayLocationLabel?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function IntelligenceMatchCard({ vm, imageUrl, lat, lng, cityLabel }: Props) {
|
export function IntelligenceMatchCard({ vm, imageUrl, lat, lng, cityLabel, overlayTypeLabel, overlayLocationLabel }: Props) {
|
||||||
const tier = getScoreTier(vm.matchScore)
|
const tier = getScoreTier(vm.matchScore)
|
||||||
const theme = SCORE_THEME[tier]
|
const theme = SCORE_THEME[tier]
|
||||||
const { currentUser } = useSessionStore()
|
const { currentUser } = useSessionStore()
|
||||||
|
const openInquiryDialog = useInquiryStore(s => s.openInquiryDialog)
|
||||||
const isPortfolioOwner = currentUser?.role === 'PROPERTY_MANAGER' || currentUser?.role === 'ORGANIZATION_ADMIN'
|
const isPortfolioOwner = currentUser?.role === 'PROPERTY_MANAGER' || currentUser?.role === 'ORGANIZATION_ADMIN'
|
||||||
const rt = RESULT_TYPE_META[vm.resultType] ?? { label: vm.resultType, color: '#64748b' }
|
const rt = RESULT_TYPE_META[vm.resultType] ?? { label: vm.resultType, color: '#64748b' }
|
||||||
const isFuture = vm.resultType === 'FUTURE_AVAILABILITY'
|
const isFuture = vm.resultType === 'FUTURE_AVAILABILITY'
|
||||||
@@ -59,7 +55,7 @@ export function IntelligenceMatchCard({ vm, imageUrl, lat, lng, cityLabel }: Pro
|
|||||||
|
|
||||||
{/* ── Hero zone ── */}
|
{/* ── Hero zone ── */}
|
||||||
<Box sx={{ position: 'relative' }}>
|
<Box sx={{ position: 'relative' }}>
|
||||||
<LocationPreview imageUrl={imageUrl} lat={lat} lng={lng} cityLabel={cityLabel} height={175} />
|
<LocationPreview imageUrl={imageUrl} lat={lat} lng={lng} cityLabel={cityLabel} height={175} overlayTypeLabel={overlayTypeLabel} overlayLocationLabel={overlayLocationLabel} />
|
||||||
<Box sx={{
|
<Box sx={{
|
||||||
position: 'absolute', top: 10, left: 10,
|
position: 'absolute', top: 10, left: 10,
|
||||||
background: theme.gradient, borderRadius: '10px',
|
background: theme.gradient, borderRadius: '10px',
|
||||||
@@ -71,11 +67,6 @@ export function IntelligenceMatchCard({ vm, imageUrl, lat, lng, cityLabel }: Pro
|
|||||||
<Typography sx={{ fontWeight: 900, fontSize: '1.5rem', color: theme.text, lineHeight: 1 }}>
|
<Typography sx={{ fontWeight: 900, fontSize: '1.5rem', color: theme.text, lineHeight: 1 }}>
|
||||||
{vm.matchScore}%
|
{vm.matchScore}%
|
||||||
</Typography>
|
</Typography>
|
||||||
{tier !== 'bronze' && (
|
|
||||||
<Typography sx={{ fontSize: '0.575rem', color: theme.text, opacity: 0.8, textTransform: 'uppercase', letterSpacing: 0.8, lineHeight: 1.2 }}>
|
|
||||||
{theme.label}
|
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
</Box>
|
</Box>
|
||||||
<Box sx={{ position: 'absolute', top: 10, right: 44, display: 'flex', flexDirection: 'column', gap: 0.5, alignItems: 'flex-end' }}>
|
<Box sx={{ position: 'absolute', top: 10, right: 44, display: 'flex', flexDirection: 'column', gap: 0.5, alignItems: 'flex-end' }}>
|
||||||
<Chip label={rt.label} size="small" sx={{ bgcolor: rt.color, color: 'white', fontWeight: 600, fontSize: 10, height: 20 }} />
|
<Chip label={rt.label} size="small" sx={{ bgcolor: rt.color, color: 'white', fontWeight: 600, fontSize: 10, height: 20 }} />
|
||||||
@@ -153,7 +144,24 @@ export function IntelligenceMatchCard({ vm, imageUrl, lat, lng, cityLabel }: Pro
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<Box sx={{ display: 'flex', gap: 0.75, mt: 1.5, pt: 1.25, borderTop: '1px solid rgba(0,0,0,0.06)' }}>
|
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.75, mt: 1.5, pt: 1.25, borderTop: '1px solid rgba(0,0,0,0.06)' }}>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="contained"
|
||||||
|
onClick={e => {
|
||||||
|
e.stopPropagation()
|
||||||
|
openInquiryDialog({
|
||||||
|
propertyTitle: vm.title,
|
||||||
|
location: vm.locationLabel ?? '',
|
||||||
|
matchScore: vm.matchScore,
|
||||||
|
matchId: vm.id,
|
||||||
|
propertyId: vm.propertyId,
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
sx={{ textTransform: 'none', fontSize: '0.7rem', py: 0.375, px: 1, bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#162d4a' } }}
|
||||||
|
>
|
||||||
|
Anfrage
|
||||||
|
</Button>
|
||||||
{vm.actions.map(a => (
|
{vm.actions.map(a => (
|
||||||
<Button
|
<Button
|
||||||
key={a.id}
|
key={a.id}
|
||||||
@@ -163,10 +171,6 @@ export function IntelligenceMatchCard({ vm, imageUrl, lat, lng, cityLabel }: Pro
|
|||||||
disabled={a.disabled}
|
disabled={a.disabled}
|
||||||
sx={{
|
sx={{
|
||||||
textTransform: 'none', fontSize: '0.7rem', py: 0.375, px: 1,
|
textTransform: 'none', fontSize: '0.7rem', py: 0.375, px: 1,
|
||||||
...(a.variant === 'primary' && {
|
|
||||||
bgcolor: '#1e3a5f',
|
|
||||||
'&:hover': { bgcolor: '#162d4a' },
|
|
||||||
}),
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{a.label}
|
{a.label}
|
||||||
|
|||||||
@@ -1,119 +1,216 @@
|
|||||||
import { Alert, Box, Card, Divider, Typography } from '@mui/material'
|
import { memo } from 'react'
|
||||||
import { MapPin } from 'lucide-react'
|
import { Alert, Box, Button, Chip, Divider, Typography } from '@mui/material'
|
||||||
import { MatchCardHeader } from './MatchCardHeader'
|
import { MapPin, Building2, CheckCircle2, AlertTriangle } from 'lucide-react'
|
||||||
import { MatchReasonList } from './MatchReasonList'
|
import { useSessionStore } from '../../stores/sessionStore'
|
||||||
import { TradeoffList } from './TradeoffList'
|
import { useInquiryStore } from '../../stores/inquiryStore'
|
||||||
import { MatchDataQualitySummary } from './MatchDataQualitySummary'
|
import { HeatBadge } from '../shared/HeatBadge'
|
||||||
import { MatchActionToolbar } from './MatchActionToolbar'
|
import { getScoreTier, SCORE_THEME } from '../shared/scoreTheme'
|
||||||
import { MatchCardRestrictedState } from './MatchCardRestrictedState'
|
import { RESULT_TYPE_META } from '../../lib/ds'
|
||||||
|
import { confidenceHex } from '../../lib/utils'
|
||||||
import { ScoreInlineBreakdown } from './ScoreInlineBreakdown'
|
import { ScoreInlineBreakdown } from './ScoreInlineBreakdown'
|
||||||
|
import { MatchCardRestrictedState } from './MatchCardRestrictedState'
|
||||||
import type { MatchCardViewModel } from './MatchCardViewModel'
|
import type { MatchCardViewModel } from './MatchCardViewModel'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
vm: MatchCardViewModel
|
vm: MatchCardViewModel
|
||||||
|
imageUrl?: string
|
||||||
|
overlayTypeLabel?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MatchCardCompact({ vm }: Props) {
|
export const MatchCardCompact = memo(function MatchCardCompact({ vm, imageUrl, overlayTypeLabel }: Props) {
|
||||||
if (vm.isRestricted) return <MatchCardRestrictedState />
|
if (vm.isRestricted) return <MatchCardRestrictedState />
|
||||||
|
|
||||||
const borderColor = vm.isCompareSelected
|
const { currentUser } = useSessionStore()
|
||||||
? '#7c3aed'
|
const openInquiryDialog = useInquiryStore(s => s.openInquiryDialog)
|
||||||
: vm.isSelected
|
const isPortfolioOwner = currentUser?.role === 'PROPERTY_MANAGER' || currentUser?.role === 'ORGANIZATION_ADMIN'
|
||||||
? '#1e3a5f'
|
|
||||||
: 'transparent'
|
const tier = getScoreTier(vm.matchScore)
|
||||||
|
const theme = SCORE_THEME[tier]
|
||||||
|
const rt = RESULT_TYPE_META[vm.resultType] ?? { label: vm.resultType, color: '#64748b' }
|
||||||
|
const confPct = Math.round(vm.confidenceScore * 100)
|
||||||
|
const topRisk = vm.risks.find(r => r.level === 'CRITICAL' || r.level === 'HIGH')
|
||||||
|
|
||||||
|
const borderColor = vm.isCompareSelected ? '#7c3aed' : vm.isSelected ? '#1e3a5f' : 'transparent'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<Box sx={{
|
||||||
sx={{
|
display: 'flex',
|
||||||
p: 2.5,
|
mb: 1.5,
|
||||||
mb: 1.5,
|
borderRadius: 2,
|
||||||
border: `2px solid ${borderColor}`,
|
overflow: 'hidden',
|
||||||
opacity: vm.isStaleData ? 0.75 : 1,
|
border: `2px solid ${borderColor}`,
|
||||||
transition: 'border-color 0.15s',
|
boxShadow: '0 2px 12px rgba(0,0,0,0.07)',
|
||||||
}}
|
background: theme.cardBg,
|
||||||
>
|
opacity: vm.isStaleData ? 0.75 : 1,
|
||||||
{/* FUTURE_AVAILABILITY disclaimer — mandatory, non-dismissable */}
|
transition: 'box-shadow 0.15s',
|
||||||
{vm.disclaimer && (
|
'&:hover': { boxShadow: '0 4px 20px rgba(0,0,0,0.11)' },
|
||||||
<Alert severity="warning" sx={{ mb: 1.5, py: 0.5 }}>
|
}}>
|
||||||
{vm.disclaimer}
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{vm.isReviewRequired && (
|
{/* ── Image strip ── */}
|
||||||
<Alert severity="info" sx={{ mb: 1.5, py: 0.5 }}>
|
<Box sx={{ width: 120, flexShrink: 0, position: 'relative', bgcolor: '#dce8f2' }}>
|
||||||
Manuelle Überprüfung erforderlich
|
{imageUrl ? (
|
||||||
</Alert>
|
<img
|
||||||
)}
|
src={imageUrl}
|
||||||
|
alt=""
|
||||||
|
style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Box sx={{
|
||||||
|
width: '100%', height: '100%', minHeight: 120,
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
background: 'linear-gradient(160deg,#dce8f2 0%,#b8cfe0 100%)',
|
||||||
|
}}>
|
||||||
|
<MapPin size={24} color="#7ba3bf" />
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Header: score → type → confidence → availability → risk */}
|
{/* Score badge */}
|
||||||
<MatchCardHeader vm={vm} compact />
|
<Box sx={{
|
||||||
|
position: 'absolute', top: 8, left: 8,
|
||||||
|
background: theme.gradient, borderRadius: '8px',
|
||||||
|
px: 1, py: 0.25,
|
||||||
|
border: `1px solid ${theme.border}`,
|
||||||
|
boxShadow: `0 2px 8px ${theme.glow}`,
|
||||||
|
}}>
|
||||||
|
<Typography sx={{ fontWeight: 900, fontSize: '1rem', color: theme.text, lineHeight: 1 }}>
|
||||||
|
{vm.matchScore}%
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
{/* Title + location (max 2 lines) */}
|
{/* Asset type label */}
|
||||||
<Box sx={{ mt: 1.25, mb: 1.25 }}>
|
{overlayTypeLabel && (
|
||||||
<Typography variant="subtitle1" sx={{ fontWeight: 600 }} noWrap>
|
<Box sx={{
|
||||||
|
position: 'absolute', bottom: 0, left: 0, right: 0,
|
||||||
|
px: 0.75, py: 0.5,
|
||||||
|
background: 'linear-gradient(to top, rgba(0,0,0,0.65) 0%, transparent 100%)',
|
||||||
|
}}>
|
||||||
|
<Typography sx={{ fontSize: '0.6rem', fontWeight: 700, color: 'white', letterSpacing: 0.3, lineHeight: 1.3 }}>
|
||||||
|
{overlayTypeLabel}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* ── Content ── */}
|
||||||
|
<Box sx={{ flex: 1, p: 1.75, display: 'flex', flexDirection: 'column', minWidth: 0 }}>
|
||||||
|
|
||||||
|
{/* Alerts */}
|
||||||
|
{vm.disclaimer && (
|
||||||
|
<Alert severity="warning" sx={{ mb: 1, py: 0.25, fontSize: '0.72rem' }}>{vm.disclaimer}</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
|
||||||
|
{/* Badges row */}
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 0.625, flexWrap: 'wrap', gap: 0.5 }}>
|
||||||
|
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5, alignItems: 'center' }}>
|
||||||
|
<HeatBadge propertyId={vm.propertyId} size="sm" />
|
||||||
|
<Chip label={rt.label} size="small"
|
||||||
|
sx={{ bgcolor: rt.color, color: 'white', fontWeight: 600, fontSize: 10, height: 20 }} />
|
||||||
|
{vm.resultType === 'VERIFIED_PORTFOLIO' && isPortfolioOwner && (
|
||||||
|
<Chip icon={<Building2 size={9} color="#1e3a5f" />} label="Ihr Objekt" size="small"
|
||||||
|
sx={{ bgcolor: 'rgba(30,58,95,0.10)', color: '#1e3a5f', fontWeight: 700, fontSize: 9, height: 18, '& .MuiChip-icon': { ml: 0.5 } }} />
|
||||||
|
)}
|
||||||
|
<Chip label={`${confPct}% Konfidenz`} size="small"
|
||||||
|
sx={{ bgcolor: confidenceHex(vm.confidenceScore), color: 'white', fontSize: 10, height: 20 }} />
|
||||||
|
{vm.availabilityLabel && (
|
||||||
|
<Chip label={vm.availabilityLabel} size="small" variant="outlined" sx={{ fontSize: 10, height: 20 }} />
|
||||||
|
)}
|
||||||
|
{topRisk && (
|
||||||
|
<Chip
|
||||||
|
label={topRisk.level === 'CRITICAL' ? 'Kritisch' : 'Hohes Risiko'}
|
||||||
|
size="small" color="error" variant="outlined"
|
||||||
|
sx={{ fontSize: 10, height: 20 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Title + location */}
|
||||||
|
<Typography variant="subtitle2" sx={{ fontWeight: 700, lineHeight: 1.3 }} noWrap>
|
||||||
{vm.title}
|
{vm.title}
|
||||||
</Typography>
|
</Typography>
|
||||||
{vm.propertySubtitle && (
|
{vm.propertySubtitle && (
|
||||||
<Typography variant="caption" sx={{ display: 'block', color: '#64748b', fontWeight: 500, mt: 0.125 }} noWrap>
|
<Typography variant="caption" sx={{ color: '#475569', fontWeight: 500, display: 'block', mt: 0.1 }} noWrap>
|
||||||
{vm.propertySubtitle}
|
{vm.propertySubtitle}
|
||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mt: 0.25 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.4, mt: 0.25 }}>
|
||||||
<MapPin size={13} color="#64748b" />
|
<MapPin size={11} color="#64748b" />
|
||||||
<Typography variant="body2" color="text.secondary">
|
<Typography variant="caption" color="text.secondary">
|
||||||
{vm.locationLabel}
|
{vm.locationLabel}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
{/* Explainability summary */}
|
||||||
{vm.explainabilitySummary && (
|
{vm.explainabilitySummary && (
|
||||||
<Typography
|
<Typography variant="body2" sx={{ color: '#374151', fontWeight: 500, mt: 0.5, lineHeight: 1.45,
|
||||||
variant="body2"
|
overflow: 'hidden', display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical' }}>
|
||||||
color="text.secondary"
|
|
||||||
sx={{
|
|
||||||
mt: 0.5,
|
|
||||||
overflow: 'hidden',
|
|
||||||
display: '-webkit-box',
|
|
||||||
WebkitLineClamp: 2,
|
|
||||||
WebkitBoxOrient: 'vertical',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{vm.explainabilitySummary}
|
{vm.explainabilitySummary}
|
||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<Divider sx={{ my: 1 }} />
|
||||||
|
|
||||||
|
{/* Score breakdown */}
|
||||||
|
{vm.scoreBreakdown && (
|
||||||
|
<Box sx={{ mb: 1 }}>
|
||||||
|
<ScoreInlineBreakdown scoreBreakdown={vm.scoreBreakdown} allFactors={vm.allFactors} compact />
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Reasons — all 3, same style as grid card */}
|
||||||
|
{vm.reasons.length > 0 && (
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
|
||||||
|
{vm.reasons.slice(0, 3).map((r, i) => (
|
||||||
|
<Box key={i} sx={{ display: 'flex', alignItems: 'flex-start', gap: 0.75 }}>
|
||||||
|
<CheckCircle2 size={12} color="#1a7a4a" style={{ marginTop: 2, flexShrink: 0 }} />
|
||||||
|
<Box>
|
||||||
|
<Typography sx={{ fontSize: '0.72rem', fontWeight: 600, color: '#1e293b', lineHeight: 1.3 }}>
|
||||||
|
{r.label}
|
||||||
|
</Typography>
|
||||||
|
<Typography sx={{ fontSize: '0.69rem', color: '#64748b', lineHeight: 1.3 }}>
|
||||||
|
{r.explanation}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Top tradeoff */}
|
||||||
|
{vm.tradeoffs.length > 0 && (
|
||||||
|
<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].concern}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<Box sx={{ display: 'flex', gap: 0.75, mt: 1.25, pt: 1, borderTop: '1px solid rgba(0,0,0,0.06)' }}>
|
||||||
|
<Button size="small" variant="contained" onClick={e => {
|
||||||
|
e.stopPropagation()
|
||||||
|
openInquiryDialog({
|
||||||
|
propertyTitle: vm.title,
|
||||||
|
location: vm.locationLabel ?? '',
|
||||||
|
matchScore: vm.matchScore,
|
||||||
|
matchId: vm.id,
|
||||||
|
propertyId: vm.propertyId,
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
sx={{ textTransform: 'none', fontSize: '0.7rem', py: 0.375, px: 1, bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#162d4a' } }}>
|
||||||
|
Anfrage
|
||||||
|
</Button>
|
||||||
|
{vm.actions.map(a => (
|
||||||
|
<Button key={a.id} size="small" variant={a.variant === 'primary' ? 'contained' : 'outlined'}
|
||||||
|
onClick={a.onClick} disabled={a.disabled}
|
||||||
|
sx={{ textTransform: 'none', fontSize: '0.7rem', py: 0.375, px: 1 }}>
|
||||||
|
{a.label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
</Box>
|
||||||
<Divider sx={{ mb: 1.25 }} />
|
|
||||||
|
|
||||||
{/* Score formula — always visible */}
|
|
||||||
{vm.scoreBreakdown && (
|
|
||||||
<Box sx={{ mb: 1.25 }}>
|
|
||||||
<ScoreInlineBreakdown scoreBreakdown={vm.scoreBreakdown} allFactors={vm.allFactors} compact />
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Top reason only */}
|
|
||||||
{vm.reasons.length > 0 && (
|
|
||||||
<Box sx={{ mb: 1 }}>
|
|
||||||
<MatchReasonList reasons={vm.reasons.slice(0, 1)} />
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Top tradeoff only (compact) */}
|
|
||||||
{vm.tradeoffs.length > 0 && (
|
|
||||||
<Box sx={{ mb: 1 }}>
|
|
||||||
<TradeoffList tradeoffs={vm.tradeoffs.slice(0, 1)} compact />
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Data quality — only critical warning in compact mode */}
|
|
||||||
<MatchDataQualitySummary
|
|
||||||
dataQualityScore={vm.dataQualityScore}
|
|
||||||
missingData={vm.missingData}
|
|
||||||
compact
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Box sx={{ mt: 1.25 }}>
|
|
||||||
<MatchActionToolbar actions={vm.actions} compact />
|
|
||||||
</Box>
|
|
||||||
</Card>
|
|
||||||
)
|
)
|
||||||
}
|
})
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import type { MatchCardViewModel } from './MatchCardViewModel'
|
|||||||
|
|
||||||
const RESULT_TYPE_META: Record<string, { label: string; color: string }> = {
|
const RESULT_TYPE_META: Record<string, { label: string; color: string }> = {
|
||||||
VERIFIED_PORTFOLIO: { label: 'Verified', color: '#1e3a5f' },
|
VERIFIED_PORTFOLIO: { label: 'Verified', color: '#1e3a5f' },
|
||||||
EXTERNAL_MARKET: { label: 'Direkt', color: '#d97706' },
|
|
||||||
MAISON_WORK: { label: 'Maison Work', color: '#0369a1' },
|
MAISON_WORK: { label: 'Maison Work', color: '#0369a1' },
|
||||||
FUTURE_AVAILABILITY: { label: 'Signal', color: '#7c3aed' },
|
FUTURE_AVAILABILITY: { label: 'Signal', color: '#7c3aed' },
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,21 +3,10 @@ import { Building2 } from 'lucide-react'
|
|||||||
import { MatchScoreDisplay } from './MatchScoreDisplay'
|
import { MatchScoreDisplay } from './MatchScoreDisplay'
|
||||||
import { HeatBadge } from '../shared'
|
import { HeatBadge } from '../shared'
|
||||||
import { useSessionStore } from '../../stores/sessionStore'
|
import { useSessionStore } from '../../stores/sessionStore'
|
||||||
|
import { RESULT_TYPE_META } from '../../lib/ds'
|
||||||
|
import { confidenceHex } from '../../lib/utils'
|
||||||
import type { MatchCardViewModel } from './MatchCardViewModel'
|
import type { MatchCardViewModel } from './MatchCardViewModel'
|
||||||
|
|
||||||
const RESULT_TYPE_META: Record<string, { label: string; color: string }> = {
|
|
||||||
VERIFIED_PORTFOLIO: { label: 'Plattform', color: '#1e3a5f' },
|
|
||||||
EXTERNAL_MARKET: { label: 'Plattform', color: '#1e3a5f' },
|
|
||||||
MAISON_WORK: { label: 'Maison Work', color: '#0369a1' },
|
|
||||||
FUTURE_AVAILABILITY: { label: 'Zukunftssignal', color: '#7c3aed' },
|
|
||||||
}
|
|
||||||
|
|
||||||
function confidenceColor(score: number): string {
|
|
||||||
if (score >= 0.75) return '#1a7a4a'
|
|
||||||
if (score >= 0.55) return '#d97706'
|
|
||||||
return '#c0392b'
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
vm: MatchCardViewModel
|
vm: MatchCardViewModel
|
||||||
compact?: boolean
|
compact?: boolean
|
||||||
@@ -61,7 +50,7 @@ export function MatchCardHeader({ vm, compact }: Props) {
|
|||||||
<Chip
|
<Chip
|
||||||
label={`${confPct}% Konfidenz`}
|
label={`${confPct}% Konfidenz`}
|
||||||
size="small"
|
size="small"
|
||||||
sx={{ bgcolor: confidenceColor(vm.confidenceScore), color: 'white', fontSize: 11 }}
|
sx={{ bgcolor: confidenceHex(vm.confidenceScore), color: 'white', fontSize: 11 }}
|
||||||
/>
|
/>
|
||||||
{vm.availabilityLabel && (
|
{vm.availabilityLabel && (
|
||||||
<Chip label={vm.availabilityLabel} size="small" variant="outlined" sx={{ fontSize: 11 }} />
|
<Chip label={vm.availabilityLabel} size="small" variant="outlined" sx={{ fontSize: 11 }} />
|
||||||
|
|||||||
@@ -1,22 +1,17 @@
|
|||||||
import { Box, Typography } from '@mui/material'
|
import { Box, Typography } from '@mui/material'
|
||||||
|
import { matchScoreHex } from '../../lib/utils'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
score: number
|
score: number
|
||||||
size?: 'sm' | 'md' | 'lg'
|
size?: 'sm' | 'md' | 'lg'
|
||||||
}
|
}
|
||||||
|
|
||||||
function scoreColor(score: number): string {
|
|
||||||
if (score >= 78) return '#1a7a4a'
|
|
||||||
if (score >= 52) return '#d97706'
|
|
||||||
return '#c0392b'
|
|
||||||
}
|
|
||||||
|
|
||||||
const FONT_SIZES: Record<string, string> = { sm: '1.25rem', md: '1.75rem', lg: '2.5rem' }
|
const FONT_SIZES: Record<string, string> = { sm: '1.25rem', md: '1.75rem', lg: '2.5rem' }
|
||||||
|
|
||||||
export function MatchScoreDisplay({ score, size = 'md' }: Props) {
|
export function MatchScoreDisplay({ score, size = 'md' }: Props) {
|
||||||
return (
|
return (
|
||||||
<Box sx={{ display: 'flex', alignItems: 'baseline', gap: 0.25, flexShrink: 0 }}>
|
<Box sx={{ display: 'flex', alignItems: 'baseline', gap: 0.25, flexShrink: 0 }}>
|
||||||
<Typography sx={{ fontWeight: 800, fontSize: FONT_SIZES[size], color: scoreColor(score), lineHeight: 1 }}>
|
<Typography sx={{ fontWeight: 800, fontSize: FONT_SIZES[size], color: matchScoreHex(score), lineHeight: 1 }}>
|
||||||
{score}
|
{score}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="caption" color="text.secondary">/100</Typography>
|
<Typography variant="caption" color="text.secondary">/100</Typography>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Box, Divider, LinearProgress, Typography } from '@mui/material'
|
import { Box, Divider, LinearProgress, Typography } from '@mui/material'
|
||||||
import type { ScoreFactor } from '../../domain/match'
|
import type { ScoreFactor } from '../../domain/match'
|
||||||
|
import { criterionScoreColor, criterionScoreTextColor } from '../../lib/utils'
|
||||||
|
|
||||||
interface ScoreBreakdownData {
|
interface ScoreBreakdownData {
|
||||||
hardMatchScore: number
|
hardMatchScore: number
|
||||||
@@ -22,14 +23,6 @@ const CRITERION_LABEL: Record<string, string> = {
|
|||||||
|
|
||||||
const HARD_KEYS = new Set(['area', 'location', 'budget', 'timing'])
|
const HARD_KEYS = new Set(['area', 'location', 'budget', 'timing'])
|
||||||
|
|
||||||
function scoreColor(v: number): 'success' | 'warning' | 'error' {
|
|
||||||
return v >= 70 ? 'success' : v >= 50 ? 'warning' : 'error'
|
|
||||||
}
|
|
||||||
|
|
||||||
function scoreTextColor(v: number): string {
|
|
||||||
return v >= 70 ? '#1a7a4a' : v >= 50 ? '#d97706' : '#c0392b'
|
|
||||||
}
|
|
||||||
|
|
||||||
function importanceLabel(weight: number, maxWeight: number): { label: string; color: string } {
|
function importanceLabel(weight: number, maxWeight: number): { label: string; color: string } {
|
||||||
const ratio = maxWeight > 0 ? weight / maxWeight : 0
|
const ratio = maxWeight > 0 ? weight / maxWeight : 0
|
||||||
if (ratio >= 0.85) return { label: 'Entscheidend', color: '#1e3a5f' }
|
if (ratio >= 0.85) return { label: 'Entscheidend', color: '#1e3a5f' }
|
||||||
@@ -43,7 +36,7 @@ function FactorRow({ factor, maxWeight }: { factor: ScoreFactor; maxWeight: numb
|
|||||||
const label = CRITERION_LABEL[factor.criterion] ?? factor.criterion
|
const label = CRITERION_LABEL[factor.criterion] ?? factor.criterion
|
||||||
const pct = Math.round(factor.weight * 100)
|
const pct = Math.round(factor.weight * 100)
|
||||||
const imp = importanceLabel(factor.weight, maxWeight)
|
const imp = importanceLabel(factor.weight, maxWeight)
|
||||||
const color = scoreColor(factor.score)
|
const color = criterionScoreColor(factor.score)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ mb: 1.25 }}>
|
<Box sx={{ mb: 1.25 }}>
|
||||||
@@ -54,7 +47,7 @@ function FactorRow({ factor, maxWeight }: { factor: ScoreFactor; maxWeight: numb
|
|||||||
<Typography variant="caption" sx={{ color: '#cbd5e1', fontSize: '0.65rem' }}>{pct}%</Typography>
|
<Typography variant="caption" sx={{ color: '#cbd5e1', fontSize: '0.65rem' }}>{pct}%</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'baseline', gap: 0.75 }}>
|
<Box sx={{ display: 'flex', alignItems: 'baseline', gap: 0.75 }}>
|
||||||
<Typography variant="caption" sx={{ fontWeight: 700, color: scoreTextColor(factor.score) }}>
|
<Typography variant="caption" sx={{ fontWeight: 700, color: criterionScoreTextColor(factor.score) }}>
|
||||||
{factor.score}/100
|
{factor.score}/100
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="caption" sx={{ color: '#94a3b8', fontSize: '0.68rem', minWidth: 40, textAlign: 'right' }}>
|
<Typography variant="caption" sx={{ color: '#94a3b8', fontSize: '0.68rem', minWidth: 40, textAlign: 'right' }}>
|
||||||
@@ -82,7 +75,7 @@ export function ScoreInlineBreakdown({ scoreBreakdown: sb, allFactors, compact =
|
|||||||
<Typography variant="caption" sx={{ fontFamily: 'monospace', color: '#1e293b', display: 'block', lineHeight: 1.5 }}>
|
<Typography variant="caption" sx={{ fontFamily: 'monospace', color: '#1e293b', display: 'block', lineHeight: 1.5 }}>
|
||||||
Hart {sb.hardMatchScore} × 60% + Soft {sb.softFactorScore} × 40%
|
Hart {sb.hardMatchScore} × 60% + Soft {sb.softFactorScore} × 40%
|
||||||
{' = '}{hardContrib} + {softContrib}{' = '}
|
{' = '}{hardContrib} + {softContrib}{' = '}
|
||||||
<span style={{ fontWeight: 800, color: scoreTextColor(sb.totalScore) }}>{sb.totalScore}</span>
|
<span style={{ fontWeight: 800, color: criterionScoreTextColor(sb.totalScore) }}>{sb.totalScore}</span>
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { Box, Typography } from '@mui/material'
|
||||||
|
|
||||||
|
export function SignalQualityDots({ quality }: { quality: 'HIGH' | 'MEDIUM' | 'LOW' | undefined }) {
|
||||||
|
const config = {
|
||||||
|
HIGH: { dots: [1, 1, 1, 1], color: '#1a7a4a', label: 'Hohe Signalqualität' },
|
||||||
|
MEDIUM: { dots: [1, 1, 1, 0], color: '#d97706', label: 'Mittlere Signalqualität' },
|
||||||
|
LOW: { dots: [1, 1, 0, 0], color: '#c0392b', label: 'Niedrige Signalqualität' },
|
||||||
|
}
|
||||||
|
const c = quality ? config[quality] : config.LOW
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||||
|
{c.dots.map((filled, i) => (
|
||||||
|
<Box key={i} sx={{ width: 7, height: 7, borderRadius: '50%', bgcolor: filled ? c.color : '#e2e8f0' }} />
|
||||||
|
))}
|
||||||
|
<Typography sx={{ fontSize: '0.68rem', color: c.color, fontWeight: 600, ml: 0.25 }}>
|
||||||
|
{c.label}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { Briefcase, BarChart2, FileCheck, FileText, User, ShieldCheck, Newspaper } from 'lucide-react'
|
||||||
|
|
||||||
|
export function floorLabel(level: number): string {
|
||||||
|
if (level === 0) return 'EG'
|
||||||
|
if (level < 0) return `UG ${Math.abs(level)}`
|
||||||
|
return `${level}.OG`
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SOURCE_META: Record<string, { label: string; icon: React.ReactNode }> = {
|
||||||
|
JOB_POSTING: { label: 'Stelleninserate', icon: <Briefcase size={11} /> },
|
||||||
|
PRESS: { label: 'Pressebericht', icon: <Newspaper size={11} /> },
|
||||||
|
CONSTRUCTION_PERMIT:{ label: 'Baubewilligung', icon: <FileCheck size={11} /> },
|
||||||
|
COMPANY_REPORT: { label: 'Geschäftsbericht', icon: <FileText size={11} /> },
|
||||||
|
MARKET_DATA: { label: 'Marktdaten', icon: <BarChart2 size={11} /> },
|
||||||
|
MANUAL: { label: 'Analyst', icon: <User size={11} /> },
|
||||||
|
LEASE_CONTRACT: { label: 'Vertrag verifiziert', icon: <ShieldCheck size={11} /> },
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ASSET_TYPE_LABELS: Record<string, string> = {
|
||||||
|
OFFICE: 'Bürofläche',
|
||||||
|
LOGISTICS: 'Lagerfläche',
|
||||||
|
RETAIL: 'Retailfläche',
|
||||||
|
PRODUCTION: 'Produktionsfläche',
|
||||||
|
MIXED: 'Gewerbefläche',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getOpportunityHeadline(signalType: string | undefined, assetTypeLabel: string): string {
|
||||||
|
switch (signalType) {
|
||||||
|
case 'LEASE_EXPIRY': return `${assetTypeLabel} wird verfügbar`
|
||||||
|
case 'POSSIBLE_MOVE_OUT': return `Mögliche ${assetTypeLabel} erkannt`
|
||||||
|
case 'EXPANSION': return `Unternehmen sucht ${assetTypeLabel}`
|
||||||
|
case 'CONSTRUCTION_PROJECT': return `Neubau: ${assetTypeLabel} in Planung`
|
||||||
|
case 'RESTRUCTURING': return `Mögliche Flächenfreigabe erkannt`
|
||||||
|
case 'SPACE_CONSOLIDATION': return `Mögliche Teilfläche erkannt`
|
||||||
|
case 'PROJECT_DEVELOPMENT': return `Neue Fläche in Projektentwicklung`
|
||||||
|
default: return `Potenzielle ${assetTypeLabel} erkannt`
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -2,12 +2,12 @@ import { Box, CircularProgress, Typography } from '@mui/material'
|
|||||||
import { useNavigate } from 'react-router'
|
import { useNavigate } from 'react-router'
|
||||||
import { useMatchesByProperty, useApproveMatch } from '../../hooks/useMatches'
|
import { useMatchesByProperty, useApproveMatch } from '../../hooks/useMatches'
|
||||||
import { usePropertyById } from '../../hooks/useProperties'
|
import { usePropertyById } from '../../hooks/useProperties'
|
||||||
|
import { useCreateReviewTask } from '../../hooks/useReviewQueue'
|
||||||
import { useMatchCenterStore } from '../../stores/matchCenterStore'
|
import { useMatchCenterStore } from '../../stores/matchCenterStore'
|
||||||
import { useCompareStore } from '../../stores/compareStore'
|
import { useCompareStore } from '../../stores/compareStore'
|
||||||
import { useToastStore } from '../../stores/toastStore'
|
import { useToastStore } from '../../stores/toastStore'
|
||||||
import { MatchCardExpanded } from '../match-card/MatchCardExpanded'
|
import { MatchCardExpanded } from '../match-card/MatchCardExpanded'
|
||||||
import { buildMatchCardViewModel } from '../../features/matching/matchCardAdapter'
|
import { buildMatchCardViewModel } from '../../features/matching/matchCardAdapter'
|
||||||
import { reviewService } from '../../services/reviewService'
|
|
||||||
import { MatchCenterEmptyState } from './MatchCenterEmptyState'
|
import { MatchCenterEmptyState } from './MatchCenterEmptyState'
|
||||||
import { MatchStatusBadge } from './MatchStatusBadge'
|
import { MatchStatusBadge } from './MatchStatusBadge'
|
||||||
import type { MatchCardAction } from '../match-card/MatchCardViewModel'
|
import type { MatchCardAction } from '../match-card/MatchCardViewModel'
|
||||||
@@ -18,6 +18,7 @@ export function MatchBriefingPanel() {
|
|||||||
const { selectedPropertyId, selectedNeedId } = useMatchCenterStore()
|
const { selectedPropertyId, selectedNeedId } = useMatchCenterStore()
|
||||||
const { addToCompare } = useCompareStore()
|
const { addToCompare } = useCompareStore()
|
||||||
const approveMatch = useApproveMatch()
|
const approveMatch = useApproveMatch()
|
||||||
|
const createReviewTask = useCreateReviewTask()
|
||||||
const showToast = useToastStore((s) => s.showToast)
|
const showToast = useToastStore((s) => s.showToast)
|
||||||
|
|
||||||
const { data: matches = [], isLoading: matchesLoading } = useMatchesByProperty(selectedPropertyId ?? '')
|
const { data: matches = [], isLoading: matchesLoading } = useMatchesByProperty(selectedPropertyId ?? '')
|
||||||
@@ -64,7 +65,7 @@ export function MatchBriefingPanel() {
|
|||||||
label: 'Zur Prüfung',
|
label: 'Zur Prüfung',
|
||||||
actionType: 'SEND_REVIEW',
|
actionType: 'SEND_REVIEW',
|
||||||
variant: 'secondary',
|
variant: 'secondary',
|
||||||
onClick: () => { reviewService.createReviewTask(match.id) },
|
onClick: () => { createReviewTask.mutate(match.id) },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'compare',
|
id: 'compare',
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { MatchStatusBadge } from './MatchStatusBadge'
|
|||||||
import type { Match } from '../../domain/match'
|
import type { Match } from '../../domain/match'
|
||||||
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 { matchScoreHex } from '../../lib/utils'
|
||||||
|
|
||||||
const STRENGTH_META: Record<string, { label: string; bg: string; color: string }> = {
|
const STRENGTH_META: Record<string, { label: string; bg: string; color: string }> = {
|
||||||
STRONG: { label: 'Stark', bg: '#f0fdf4', color: '#1a7a4a' },
|
STRONG: { label: 'Stark', bg: '#f0fdf4', color: '#1a7a4a' },
|
||||||
@@ -10,12 +11,6 @@ const STRENGTH_META: Record<string, { label: string; bg: string; color: string }
|
|||||||
WEAK: { label: 'Schwach', bg: '#fff1f2', color: '#c0392b' },
|
WEAK: { label: 'Schwach', bg: '#fff1f2', color: '#c0392b' },
|
||||||
}
|
}
|
||||||
|
|
||||||
function scoreColor(score: number) {
|
|
||||||
if (score >= 80) return '#1a7a4a'
|
|
||||||
if (score >= 60) return '#d97706'
|
|
||||||
return '#c0392b'
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
match: Match
|
match: Match
|
||||||
property: Property | undefined
|
property: Property | undefined
|
||||||
@@ -49,7 +44,7 @@ export function MatchListCard({ match, property, need, onSelect, onApprove }: Pr
|
|||||||
width: 52,
|
width: 52,
|
||||||
height: 52,
|
height: 52,
|
||||||
borderRadius: 2,
|
borderRadius: 2,
|
||||||
bgcolor: scoreColor(match.matchScore),
|
bgcolor: matchScoreHex(match.matchScore),
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Chip } from '@mui/material'
|
import { GenericBadge } from '../shared/GenericBadge'
|
||||||
import type { MatchStatus } from '../../domain/enums'
|
import type { MatchStatus } from '../../domain/enums'
|
||||||
|
|
||||||
const STATUS_META: Record<string, { label: string; color: string }> = {
|
const STATUS_META: Record<string, { label: string; color: string }> = {
|
||||||
@@ -10,9 +10,6 @@ const STATUS_META: Record<string, { label: string; color: string }> = {
|
|||||||
|
|
||||||
export function MatchStatusBadge({ status }: { status?: MatchStatus }) {
|
export function MatchStatusBadge({ status }: { status?: MatchStatus }) {
|
||||||
if (!status) return null
|
if (!status) return null
|
||||||
const meta = STATUS_META[status] ?? { label: status, color: '#64748b' }
|
const { label, color } = STATUS_META[status] ?? { label: status, color: '#64748b' }
|
||||||
return (
|
return <GenericBadge label={label} color={color} variant="solid" />
|
||||||
<Chip label={meta.label} size="small"
|
|
||||||
sx={{ bgcolor: meta.color, color: 'white', fontWeight: 600, fontSize: 11 }} />
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,12 @@
|
|||||||
import { Box, LinearProgress, Typography } from '@mui/material'
|
import { Box, LinearProgress, Typography } from '@mui/material'
|
||||||
import type { ScoreFactor } from '../../domain/match'
|
import type { ScoreFactor } from '../../domain/match'
|
||||||
import { factorLabel, importanceLabel } from './scoreBreakdownConstants'
|
import { factorLabel, importanceLabel } from './scoreBreakdownConstants'
|
||||||
|
import { criterionScoreColor, criterionScoreTextColor } from '../../lib/utils'
|
||||||
function scoreColor(v: number): 'success' | 'warning' | 'error' {
|
|
||||||
return v >= 70 ? 'success' : v >= 50 ? 'warning' : 'error'
|
|
||||||
}
|
|
||||||
|
|
||||||
function scoreTextColor(v: number): string {
|
|
||||||
return v >= 70 ? '#1a7a4a' : v >= 50 ? '#d97706' : '#c0392b'
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CriterionRow({ factor, maxWeight }: { factor: ScoreFactor; maxWeight: number }) {
|
export function CriterionRow({ factor, maxWeight }: { factor: ScoreFactor; maxWeight: number }) {
|
||||||
const label = factorLabel(factor.criterion)
|
const label = factorLabel(factor.criterion)
|
||||||
const pct = Math.round(factor.weight * 100)
|
const pct = Math.round(factor.weight * 100)
|
||||||
const color = scoreColor(factor.score)
|
const color = criterionScoreColor(factor.score)
|
||||||
const imp = importanceLabel(factor.weight, maxWeight)
|
const imp = importanceLabel(factor.weight, maxWeight)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -34,7 +27,7 @@ export function CriterionRow({ factor, maxWeight }: { factor: ScoreFactor; maxWe
|
|||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'baseline', gap: 1 }}>
|
<Box sx={{ display: 'flex', alignItems: 'baseline', gap: 1 }}>
|
||||||
<Typography variant="caption" sx={{ fontWeight: 700, color: scoreTextColor(factor.score) }}>
|
<Typography variant="caption" sx={{ fontWeight: 700, color: criterionScoreTextColor(factor.score) }}>
|
||||||
{factor.score}/100
|
{factor.score}/100
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="caption" sx={{ color: '#64748b', fontSize: '0.68rem', minWidth: 44, textAlign: 'right' }}>
|
<Typography variant="caption" sx={{ color: '#64748b', fontSize: '0.68rem', minWidth: 44, textAlign: 'right' }}>
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { Box, Paper, Typography } from '@mui/material'
|
||||||
|
import { FileImage } from 'lucide-react'
|
||||||
|
import { DS_TEXT } from '../../lib/ds'
|
||||||
|
|
||||||
|
interface FloorPlan {
|
||||||
|
url: string
|
||||||
|
label?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
plans: FloorPlan[]
|
||||||
|
mb?: number | string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FloorPlanSection({ plans, mb }: Props) {
|
||||||
|
if (plans.length === 0) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper sx={{ p: 2.5, ...(mb !== undefined ? { mb } : {}) }}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
|
||||||
|
<FileImage size={15} color={DS_TEXT.secondary} />
|
||||||
|
<Typography variant="h6" sx={{ fontWeight: 700 }}>Grundriss</Typography>
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||||
|
{plans.map((p, i) => (
|
||||||
|
<Box key={i}>
|
||||||
|
{p.label && (
|
||||||
|
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.75, fontWeight: 600 }}>
|
||||||
|
{p.label}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
<Box
|
||||||
|
component="img"
|
||||||
|
src={p.url}
|
||||||
|
alt={p.label ?? 'Grundriss'}
|
||||||
|
sx={{
|
||||||
|
width: '100%',
|
||||||
|
borderRadius: 1,
|
||||||
|
border: '1px solid rgba(0,0,0,0.08)',
|
||||||
|
display: 'block',
|
||||||
|
objectFit: 'contain',
|
||||||
|
maxHeight: 480,
|
||||||
|
bgcolor: '#f8fafc',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,11 +1,9 @@
|
|||||||
import { Box, Button, Chip, Divider, LinearProgress, Paper, Typography } from '@mui/material'
|
import { Box, Chip, Divider, LinearProgress, Paper, Typography } from '@mui/material'
|
||||||
import {
|
import {
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
Calendar,
|
Calendar,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
Clock,
|
Clock,
|
||||||
ExternalLink,
|
|
||||||
Globe,
|
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
TrendingUp,
|
TrendingUp,
|
||||||
@@ -14,44 +12,9 @@ import {
|
|||||||
import type { Match } from '../../domain/match'
|
import type { Match } from '../../domain/match'
|
||||||
import type { FutureSignal } from '../../domain/futureSignal'
|
import type { FutureSignal } from '../../domain/futureSignal'
|
||||||
import { SIGNAL_TYPE_LABELS, SIGNAL_ACTION, SOURCE_META } from './futureAvailabilityConstants'
|
import { SIGNAL_TYPE_LABELS, SIGNAL_ACTION, SOURCE_META } from './futureAvailabilityConstants'
|
||||||
|
import { CREDIBILITY_META, SENSITIVITY_META, RISK_META, ageLabel } from './futureAvailabilityContextUtils'
|
||||||
const CREDIBILITY_META: Record<string, { label: string; color: string }> = {
|
import { SignalSourcesSection } from './SignalSourcesSection'
|
||||||
HIGH: { label: 'Hohe Quellenqualität', color: '#1a7a4a' },
|
import { DS_TEXT, DS_BG, DS_SURFACE, DS_PRE_MARKET, DS_MARKET_SIGNAL, DS_BORDER } from '../../lib/ds'
|
||||||
MEDIUM: { label: 'Mittlere Quellenqualität', color: '#d97706' },
|
|
||||||
LOW: { label: 'Niedrige Quellenqualität', color: '#c0392b' },
|
|
||||||
}
|
|
||||||
|
|
||||||
const SENSITIVITY_META: Record<string, { label: string; color: 'error' | 'warning' | 'default' }> = {
|
|
||||||
CONFIDENTIAL: { label: 'Vertraulich', color: 'error' },
|
|
||||||
INTERNAL: { label: 'Intern', color: 'warning' },
|
|
||||||
PUBLIC: { label: 'Öffentlich', color: 'default' },
|
|
||||||
}
|
|
||||||
|
|
||||||
const RISK_META: Record<string, { label: string; color: string }> = {
|
|
||||||
LOW: { label: 'Niedrig', color: '#1a7a4a' },
|
|
||||||
MEDIUM: { label: 'Mittel', color: '#d97706' },
|
|
||||||
HIGH: { label: 'Hoch', color: '#c0392b' },
|
|
||||||
CRITICAL: { label: 'Kritisch', color: '#7f1d1d' },
|
|
||||||
}
|
|
||||||
|
|
||||||
function ageLabel(isoDate: string): string {
|
|
||||||
const diffMs = Date.now() - new Date(isoDate).getTime()
|
|
||||||
const days = Math.floor(diffMs / 86400000)
|
|
||||||
if (days < 1) return 'Heute erkannt'
|
|
||||||
if (days < 7) return `Vor ${days} Tag${days === 1 ? '' : 'en'} erkannt`
|
|
||||||
if (days < 30) return `Vor ${Math.floor(days / 7)} Woche${Math.floor(days / 7) === 1 ? '' : 'n'} erkannt`
|
|
||||||
if (days < 365) return `Vor ${Math.floor(days / 30)} Monat${Math.floor(days / 30) === 1 ? '' : 'en'} erkannt`
|
|
||||||
return `Vor ${Math.floor(days / 365)} Jahr${Math.floor(days / 365) === 1 ? '' : 'en'} erkannt`
|
|
||||||
}
|
|
||||||
|
|
||||||
function domainLabel(url: string): string {
|
|
||||||
try {
|
|
||||||
const u = new URL(url)
|
|
||||||
return u.hostname.replace(/^www\./, '')
|
|
||||||
} catch {
|
|
||||||
return url.length > 60 ? url.slice(0, 57) + '…' : url
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
match: Match
|
match: Match
|
||||||
@@ -73,81 +36,85 @@ export function FutureAvailabilityContextPanel({ match: _match, signal }: Props)
|
|||||||
...(signal.evidence?.sourceUrls ?? []),
|
...(signal.evidence?.sourceUrls ?? []),
|
||||||
]
|
]
|
||||||
|
|
||||||
const probBarColor = probPct >= 70 ? '#1a7a4a' : probPct >= 50 ? '#d97706' : '#c0392b'
|
const probBarColor = probPct >= 70 ? DS_TEXT.success : probPct >= 50 ? DS_TEXT.warning : DS_TEXT.error
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Paper sx={{ p: 2.5, mb: 2 }}>
|
<Paper sx={{ p: 2.5, mb: 2 }}>
|
||||||
|
|
||||||
{/* ── Header ──────────────────────────────────────────────────────────── */}
|
{/* ── Header ── */}
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.75, flexWrap: 'wrap' }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.75, flexWrap: 'wrap' }}>
|
||||||
<Zap size={16} color="#7c3aed" />
|
<Zap size={16} color={DS_PRE_MARKET.accent} />
|
||||||
<Typography variant="h6" sx={{ fontWeight: 700 }}>Future Availability Signal</Typography>
|
<Typography variant="h6" sx={{ fontWeight: 700 }}>Future Availability Signal</Typography>
|
||||||
{signal.isVerified && (
|
{signal.isVerified && (
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.4, bgcolor: '#f0fdf4', border: '1px solid #86efac', borderRadius: 1, px: 0.75, py: 0.2 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.4, bgcolor: DS_SURFACE.success.bg, border: `1px solid ${DS_SURFACE.success.border}`, borderRadius: 1, px: 0.75, py: 0.2 }}>
|
||||||
<ShieldCheck size={11} color="#1a7a4a" />
|
<ShieldCheck size={11} color={DS_TEXT.success} />
|
||||||
<Typography sx={{ fontSize: '0.68rem', color: '#1a7a4a', fontWeight: 700 }}>Verifiziert</Typography>
|
<Typography sx={{ fontSize: '0.68rem', color: DS_TEXT.success, fontWeight: 700 }}>Verifiziert</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
<Box sx={{ ml: 'auto', display: 'flex', alignItems: 'center', gap: 0.5, color: '#94a3b8' }}>
|
<Box sx={{ ml: 'auto', display: 'flex', alignItems: 'center', gap: 0.5, color: DS_TEXT.disabled }}>
|
||||||
<Clock size={12} />
|
<Clock size={12} />
|
||||||
<Typography variant="caption" color="text.secondary">{ageLabel(signal.createdAt)}</Typography>
|
<Typography variant="caption" color="text.secondary">{ageLabel(signal.createdAt)}</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{signal.title && (
|
{signal.title && (
|
||||||
<Typography sx={{ fontSize: '0.95rem', fontWeight: 600, color: '#1e293b', mb: 2, lineHeight: 1.4 }}>
|
<Typography sx={{ fontSize: '0.95rem', fontWeight: 600, color: DS_TEXT.primary, mb: 2, lineHeight: 1.4 }}>
|
||||||
{signal.title}
|
{signal.title}
|
||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── 1. KI-Zusammenfassung ─────────────────────────────────────────── */}
|
{/* ── 1. KI-Zusammenfassung ── */}
|
||||||
{signal.aiSummary && (
|
{signal.aiSummary && (
|
||||||
<Box sx={{ mb: 2.5 }}>
|
<Box sx={{ mb: 2.5 }}>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mb: 0.875 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mb: 0.875 }}>
|
||||||
<Sparkles size={14} color="#7c3aed" />
|
<Sparkles size={14} color={DS_PRE_MARKET.accent} />
|
||||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: '#4c1d95', fontSize: '0.8125rem' }}>KI-Zusammenfassung</Typography>
|
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: DS_TEXT.signalDark, fontSize: '0.8125rem' }}>KI-Zusammenfassung</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
<Box sx={{ bgcolor: '#faf5ff', border: '1px solid #e9d5ff', borderRadius: 1.5, p: 1.75 }}>
|
<Box sx={{ bgcolor: DS_SURFACE.purple.bg, border: `1px solid ${DS_SURFACE.purple.border}`, borderRadius: 1.5, p: 1.75 }}>
|
||||||
<Typography variant="body2" sx={{ color: '#1e1b4b', lineHeight: 1.7 }}>
|
<Typography variant="body2" sx={{ color: DS_TEXT.primary, lineHeight: 1.7 }}>
|
||||||
{signal.aiSummary}
|
{signal.aiSummary}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── 2. Strategische Einschätzung ─────────────────────────────────── */}
|
{/* ── 2. Strategische Einschätzung ── */}
|
||||||
{signal.strategicInterpretation && (
|
{signal.strategicInterpretation && (
|
||||||
<Box sx={{ mb: 2.5 }}>
|
<Box sx={{ mb: 2.5 }}>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mb: 0.875 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mb: 0.875 }}>
|
||||||
<TrendingUp size={14} color="#0369a1" />
|
<TrendingUp size={14} color={DS_TEXT.info} />
|
||||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, fontSize: '0.8125rem' }}>Strategische Einschätzung</Typography>
|
<Typography variant="subtitle2" sx={{ fontWeight: 700, fontSize: '0.8125rem' }}>Strategische Einschätzung</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
<Box sx={{ bgcolor: '#f0f9ff', border: '1px solid #bae6fd', borderRadius: 1.5, p: 1.75 }}>
|
<Box sx={{ bgcolor: DS_SURFACE.info.bg, border: `1px solid ${DS_SURFACE.info.border}`, borderRadius: 1.5, p: 1.75 }}>
|
||||||
<Typography variant="body2" sx={{ color: '#0c4a6e', lineHeight: 1.7 }}>
|
<Typography variant="body2" sx={{ color: DS_TEXT.infoDark, lineHeight: 1.7 }}>
|
||||||
{signal.strategicInterpretation}
|
{signal.strategicInterpretation}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── 3. Handlungsempfehlung ───────────────────────────────────────── */}
|
{/* ── 3. Handlungsempfehlung ── */}
|
||||||
{action && (
|
{action && (
|
||||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1, bgcolor: action.urgency === 'high' ? '#fff7ed' : '#f8fafc', border: `1px solid ${action.urgency === 'high' ? '#fed7aa' : '#e2e8f0'}`, borderRadius: 1.5, px: 1.5, py: 1.25, mb: 2.5 }}>
|
<Box sx={{
|
||||||
<Zap size={14} color={action.urgency === 'high' ? '#c2410c' : '#64748b'} style={{ flexShrink: 0, marginTop: 2 }} />
|
display: 'flex', alignItems: 'flex-start', gap: 1,
|
||||||
|
bgcolor: action.urgency === 'high' ? DS_SURFACE.orange.bg : DS_BG.page,
|
||||||
|
border: `1px solid ${action.urgency === 'high' ? DS_SURFACE.orange.border : DS_BORDER.default}`,
|
||||||
|
borderRadius: 1.5, px: 1.5, py: 1.25, mb: 2.5,
|
||||||
|
}}>
|
||||||
|
<Zap size={14} color={action.urgency === 'high' ? DS_TEXT.error : DS_TEXT.muted} style={{ flexShrink: 0, marginTop: 2 }} />
|
||||||
<Box>
|
<Box>
|
||||||
<Typography sx={{ fontSize: '0.72rem', fontWeight: 700, color: action.urgency === 'high' ? '#9a3412' : '#475569', mb: 0.25 }}>Empfohlene Aktion</Typography>
|
<Typography sx={{ fontSize: '0.72rem', fontWeight: 700, color: action.urgency === 'high' ? DS_TEXT.warningDark : DS_TEXT.secondary, mb: 0.25 }}>Empfohlene Aktion</Typography>
|
||||||
<Typography sx={{ fontSize: '0.82rem', color: action.urgency === 'high' ? '#7c2d12' : '#334155', lineHeight: 1.5 }}>{action.label}</Typography>
|
<Typography sx={{ fontSize: '0.82rem', color: action.urgency === 'high' ? DS_TEXT.error : DS_TEXT.primary, lineHeight: 1.5 }}>{action.label}</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Divider sx={{ mb: 2 }} />
|
<Divider sx={{ mb: 2 }} />
|
||||||
|
|
||||||
{/* ── 4. Signal-Kenndaten ──────────────────────────────────────────── */}
|
{/* ── 4. Signal-Kenndaten ── */}
|
||||||
<Box sx={{ mb: 2.5 }}>
|
<Box sx={{ mb: 2.5 }}>
|
||||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 1.25, fontSize: '0.8125rem' }}>Signal-Kenndaten</Typography>
|
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 1.25, fontSize: '0.8125rem' }}>Signal-Kenndaten</Typography>
|
||||||
|
|
||||||
{/* Probability bar */}
|
|
||||||
<Box sx={{ mb: 1.5 }}>
|
<Box sx={{ mb: 1.5 }}>
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.4 }}>
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.4 }}>
|
||||||
<Typography variant="caption" color="text.secondary">Eintretenswahrscheinlichkeit</Typography>
|
<Typography variant="caption" color="text.secondary">Eintretenswahrscheinlichkeit</Typography>
|
||||||
@@ -157,7 +124,7 @@ export function FutureAvailabilityContextPanel({ match: _match, signal }: Props)
|
|||||||
variant="determinate"
|
variant="determinate"
|
||||||
value={probPct}
|
value={probPct}
|
||||||
sx={{
|
sx={{
|
||||||
height: 6, borderRadius: 3, bgcolor: '#f1f5f9',
|
height: 6, borderRadius: 3, bgcolor: DS_BG.subtle,
|
||||||
'& .MuiLinearProgress-bar': { bgcolor: probBarColor, borderRadius: 3 },
|
'& .MuiLinearProgress-bar': { bgcolor: probBarColor, borderRadius: 3 },
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -167,13 +134,13 @@ export function FutureAvailabilityContextPanel({ match: _match, signal }: Props)
|
|||||||
{signal.signalType && (
|
{signal.signalType && (
|
||||||
<Box>
|
<Box>
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.25 }}>Signaltyp</Typography>
|
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.25 }}>Signaltyp</Typography>
|
||||||
<Chip label={SIGNAL_TYPE_LABELS[signal.signalType] ?? signal.signalType} size="small" sx={{ bgcolor: '#ede9fe', color: '#6d28d9', fontWeight: 600 }} />
|
<Chip label={SIGNAL_TYPE_LABELS[signal.signalType] ?? signal.signalType} size="small" sx={{ bgcolor: DS_PRE_MARKET.badgeBg, color: DS_PRE_MARKET.accentHover, fontWeight: 600 }} />
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
<Box>
|
<Box>
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.25 }}>Zeithorizont</Typography>
|
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.25 }}>Zeithorizont</Typography>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||||
<Calendar size={13} color="#64748b" />
|
<Calendar size={13} color={DS_TEXT.muted} />
|
||||||
<Typography variant="body2" sx={{ fontWeight: 700 }}>~{signal.timeHorizonMonths} Monate</Typography>
|
<Typography variant="body2" sx={{ fontWeight: 700 }}>~{signal.timeHorizonMonths} Monate</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -194,22 +161,22 @@ export function FutureAvailabilityContextPanel({ match: _match, signal }: Props)
|
|||||||
|
|
||||||
<Divider sx={{ mb: 2 }} />
|
<Divider sx={{ mb: 2 }} />
|
||||||
|
|
||||||
{/* ── 5. Erkannte Marktindikatoren ──────────────────────────────────── */}
|
{/* ── 5. Erkannte Marktindikatoren ── */}
|
||||||
{signal.marketIndicators && signal.marketIndicators.length > 0 && (
|
{signal.marketIndicators && signal.marketIndicators.length > 0 && (
|
||||||
<Box sx={{ mb: 2.5 }}>
|
<Box sx={{ mb: 2.5 }}>
|
||||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 1, fontSize: '0.8125rem' }}>Erkannte Marktindikatoren</Typography>
|
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 1, fontSize: '0.8125rem' }}>Erkannte Marktindikatoren</Typography>
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75 }}>
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75 }}>
|
||||||
{signal.marketIndicators.map((indicator, i) => (
|
{signal.marketIndicators.map((indicator, i) => (
|
||||||
<Box key={i} sx={{ display: 'flex', alignItems: 'flex-start', gap: 0.875 }}>
|
<Box key={i} sx={{ display: 'flex', alignItems: 'flex-start', gap: 0.875 }}>
|
||||||
<Box sx={{ width: 6, height: 6, borderRadius: '50%', bgcolor: '#1d4ed8', mt: '6px', flexShrink: 0 }} />
|
<Box sx={{ width: 6, height: 6, borderRadius: '50%', bgcolor: DS_MARKET_SIGNAL.accent, mt: '6px', flexShrink: 0 }} />
|
||||||
<Typography variant="body2" sx={{ color: '#334155', lineHeight: 1.55 }}>{indicator}</Typography>
|
<Typography variant="body2" sx={{ color: DS_TEXT.primary, lineHeight: 1.55 }}>{indicator}</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
))}
|
))}
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── 6. Transparenz (Bestätigt / Nicht bestätigt) ─────────────────── */}
|
{/* ── 6. Transparenz ── */}
|
||||||
{((signal.confirmedFacts && signal.confirmedFacts.length > 0) ||
|
{((signal.confirmedFacts && signal.confirmedFacts.length > 0) ||
|
||||||
(signal.unconfirmedFacts && signal.unconfirmedFacts.length > 0)) && (
|
(signal.unconfirmedFacts && signal.unconfirmedFacts.length > 0)) && (
|
||||||
<Box sx={{ mb: 2.5 }}>
|
<Box sx={{ mb: 2.5 }}>
|
||||||
@@ -217,14 +184,14 @@ export function FutureAvailabilityContextPanel({ match: _match, signal }: Props)
|
|||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.625 }}>
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.625 }}>
|
||||||
{(signal.confirmedFacts ?? []).map((fact, i) => (
|
{(signal.confirmedFacts ?? []).map((fact, i) => (
|
||||||
<Box key={`c-${i}`} sx={{ display: 'flex', alignItems: 'flex-start', gap: 0.75 }}>
|
<Box key={`c-${i}`} sx={{ display: 'flex', alignItems: 'flex-start', gap: 0.75 }}>
|
||||||
<CheckCircle2 size={14} color="#1a7a4a" style={{ flexShrink: 0, marginTop: 1 }} />
|
<CheckCircle2 size={14} color={DS_TEXT.success} style={{ flexShrink: 0, marginTop: 1 }} />
|
||||||
<Typography variant="body2" sx={{ color: '#1a7a4a', fontWeight: 500, lineHeight: 1.45 }}>{fact}</Typography>
|
<Typography variant="body2" sx={{ color: DS_TEXT.success, fontWeight: 500, lineHeight: 1.45 }}>{fact}</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
))}
|
))}
|
||||||
{(signal.unconfirmedFacts ?? []).map((fact, i) => (
|
{(signal.unconfirmedFacts ?? []).map((fact, i) => (
|
||||||
<Box key={`u-${i}`} sx={{ display: 'flex', alignItems: 'flex-start', gap: 0.75 }}>
|
<Box key={`u-${i}`} sx={{ display: 'flex', alignItems: 'flex-start', gap: 0.75 }}>
|
||||||
<AlertTriangle size={14} color="#d97706" style={{ flexShrink: 0, marginTop: 1 }} />
|
<AlertTriangle size={14} color={DS_TEXT.warning} style={{ flexShrink: 0, marginTop: 1 }} />
|
||||||
<Typography variant="body2" sx={{ color: '#92400e', lineHeight: 1.45 }}>{fact}</Typography>
|
<Typography variant="body2" sx={{ color: DS_TEXT.warningDark, lineHeight: 1.45 }}>{fact}</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
))}
|
))}
|
||||||
</Box>
|
</Box>
|
||||||
@@ -233,103 +200,16 @@ export function FutureAvailabilityContextPanel({ match: _match, signal }: Props)
|
|||||||
|
|
||||||
<Divider sx={{ mb: 2 }} />
|
<Divider sx={{ mb: 2 }} />
|
||||||
|
|
||||||
{/* ── 7. Quellen & Belege ───────────────────────────────────────────── */}
|
{/* ── 7. Quellen & Belege ── */}
|
||||||
<Box sx={{ mb: 2.5 }}>
|
<SignalSourcesSection
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mb: 1.25 }}>
|
sourceMeta={sourceMeta}
|
||||||
<Globe size={14} color="#0369a1" />
|
credMeta={credMeta}
|
||||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, fontSize: '0.8125rem' }}>Quellen & Belege</Typography>
|
isVerifiedContract={isVerifiedContract}
|
||||||
</Box>
|
allSourceUrls={allSourceUrls}
|
||||||
|
signal={signal}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Primary source metadata — always shown */}
|
{/* ── Footer ── */}
|
||||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1, mb: 1.25 }}>
|
|
||||||
{sourceMeta && (
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, bgcolor: isVerifiedContract ? '#f0fdf4' : '#ede9fe', color: isVerifiedContract ? '#1a7a4a' : '#6d28d9', borderRadius: 1, px: 1, py: 0.5 }}>
|
|
||||||
{sourceMeta.icon}
|
|
||||||
<Typography sx={{ fontSize: '0.78rem', fontWeight: 600 }}>{sourceMeta.label}</Typography>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
{credMeta && (
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, bgcolor: '#f8fafc', border: '1px solid #e2e8f0', borderRadius: 1, px: 0.875, py: 0.4 }}>
|
|
||||||
<Box sx={{ width: 7, height: 7, borderRadius: '50%', bgcolor: credMeta.color }} />
|
|
||||||
<Typography sx={{ fontSize: '0.75rem', color: credMeta.color, fontWeight: 600 }}>{credMeta.label}</Typography>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
{signal.source.publishedAt && (
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, bgcolor: '#f8fafc', border: '1px solid #e2e8f0', borderRadius: 1, px: 0.875, py: 0.4 }}>
|
|
||||||
<Calendar size={11} color="#64748b" />
|
|
||||||
<Typography variant="caption" color="text.secondary">
|
|
||||||
{new Date(signal.source.publishedAt).toLocaleDateString('de-CH', { day: '2-digit', month: 'long', year: 'numeric' })}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Verified contract special box */}
|
|
||||||
{isVerifiedContract && (
|
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5, bgcolor: '#f0fdf4', border: '1px solid #bbf7d0', borderRadius: 1, px: 1.25, py: 1, mb: 1.25 }}>
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
|
|
||||||
<ShieldCheck size={13} color="#1a7a4a" />
|
|
||||||
<Typography variant="caption" sx={{ color: '#1a7a4a', fontWeight: 600 }}>Vertragsende aus internem ERP bestätigt</Typography>
|
|
||||||
</Box>
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
|
|
||||||
<ShieldCheck size={13} color="#1a7a4a" />
|
|
||||||
<Typography variant="caption" sx={{ color: '#1a7a4a', fontWeight: 600 }}>Verwaltung hat Marktfreigabe erteilt</Typography>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* All source URLs */}
|
|
||||||
{allSourceUrls.length > 0 ? (
|
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.625 }}>
|
|
||||||
{allSourceUrls.map((url, i) => (
|
|
||||||
<Button
|
|
||||||
key={i}
|
|
||||||
component="a"
|
|
||||||
href={url}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
size="small"
|
|
||||||
variant="outlined"
|
|
||||||
startIcon={<ExternalLink size={12} />}
|
|
||||||
sx={{
|
|
||||||
textTransform: 'none', justifyContent: 'flex-start',
|
|
||||||
color: '#0369a1', borderColor: '#bae6fd',
|
|
||||||
'&:hover': { borderColor: '#0369a1', bgcolor: '#f0f9ff' },
|
|
||||||
fontSize: '0.78rem', fontWeight: 500,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{domainLabel(url)}
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
</Box>
|
|
||||||
) : (
|
|
||||||
<Box sx={{ bgcolor: '#f8fafc', border: '1px solid #e2e8f0', borderRadius: 1, px: 1.25, py: 0.875 }}>
|
|
||||||
<Typography variant="caption" color="text.secondary">
|
|
||||||
Kein direkter Quellenlink verfügbar — Signal basiert auf aggregierten {sourceMeta?.label ?? 'Marktdaten'}.
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Evidence extraction date */}
|
|
||||||
{signal.evidence?.extractedAt && (
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mt: 0.875 }}>
|
|
||||||
<Clock size={11} color="#94a3b8" />
|
|
||||||
<Typography variant="caption" color="text.secondary">
|
|
||||||
Daten extrahiert: {new Date(signal.evidence.extractedAt).toLocaleDateString('de-CH', { day: '2-digit', month: 'short', year: 'numeric' })}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Evidence summary */}
|
|
||||||
{signal.evidence?.summary && (
|
|
||||||
<Box sx={{ bgcolor: '#f8fafc', border: '1px solid #e2e8f0', borderRadius: 1, p: 1.25, mt: 1 }}>
|
|
||||||
<Typography variant="caption" sx={{ fontWeight: 600, color: '#475569', display: 'block', mb: 0.3 }}>Evidenz-Zusammenfassung</Typography>
|
|
||||||
<Typography variant="body2" color="text.secondary" sx={{ lineHeight: 1.55 }}>{signal.evidence.summary}</Typography>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* ── Footer ──────────────────────────────────────────────────────────── */}
|
|
||||||
<Divider sx={{ mb: 1.5 }} />
|
<Divider sx={{ mb: 1.5 }} />
|
||||||
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.25, flexWrap: 'wrap' }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.25, flexWrap: 'wrap' }}>
|
||||||
@@ -338,12 +218,12 @@ export function FutureAvailabilityContextPanel({ match: _match, signal }: Props)
|
|||||||
{signal.verifiedBy && (
|
{signal.verifiedBy && (
|
||||||
<>
|
<>
|
||||||
<Typography variant="caption" color="text.secondary">Verifiziert von:</Typography>
|
<Typography variant="caption" color="text.secondary">Verifiziert von:</Typography>
|
||||||
<Typography variant="caption" sx={{ fontWeight: 600, color: '#1a7a4a' }}>{signal.verifiedBy}</Typography>
|
<Typography variant="caption" sx={{ fontWeight: 600, color: DS_TEXT.success }}>{signal.verifiedBy}</Typography>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', lineHeight: 1.55, bgcolor: '#f8fafc', borderRadius: 1, px: 1.25, py: 1, fontStyle: 'italic' }}>
|
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', lineHeight: 1.55, bgcolor: DS_BG.page, borderRadius: 1, px: 1.25, py: 1, fontStyle: 'italic' }}>
|
||||||
{signal.disclaimer}
|
{signal.disclaimer}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import {
|
||||||
|
Box, Button, Dialog, DialogActions, DialogContent, DialogTitle,
|
||||||
|
TextField, Typography,
|
||||||
|
} from '@mui/material'
|
||||||
|
import { Send } from 'lucide-react'
|
||||||
|
import { useToastStore } from '../../stores/toastStore'
|
||||||
|
import { useInquiryStore } from '../../stores/inquiryStore'
|
||||||
|
import { useMoveStage } from '../../hooks/usePipeline'
|
||||||
|
import { DS_BG, DS_BORDER, DS_TEXT } from '../../lib/ds'
|
||||||
|
|
||||||
|
function buildTemplate(propertyTitle: string, location: string, areaLabel?: string): string {
|
||||||
|
const areaLine = areaLabel ? `\nDie Fläche von ${areaLabel} entspricht unserem Bedarf.` : ''
|
||||||
|
return `Guten Tag\n\nWir interessieren uns für Ihre Fläche «${propertyTitle}» in ${location}.${areaLine} Gerne würden wir einen Besichtigungstermin vereinbaren und offene Fragen klären.\n\nFür Rückfragen stehen wir jederzeit zur Verfügung.\n\nFreundliche Grüsse`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InquiryQuickDialog() {
|
||||||
|
const showToast = useToastStore(s => s.showToast)
|
||||||
|
const dialogOpen = useInquiryStore(s => s.dialogOpen)
|
||||||
|
const pendingInquiry = useInquiryStore(s => s.pendingInquiry)
|
||||||
|
const closeInquiryDialog = useInquiryStore(s => s.closeInquiryDialog)
|
||||||
|
const addInquiry = useInquiryStore(s => s.addInquiry)
|
||||||
|
const { mutate: moveStage } = useMoveStage()
|
||||||
|
|
||||||
|
const [message, setMessage] = useState('')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (dialogOpen && pendingInquiry) {
|
||||||
|
setMessage(buildTemplate(pendingInquiry.propertyTitle, pendingInquiry.location, pendingInquiry.areaLabel))
|
||||||
|
}
|
||||||
|
}, [dialogOpen, pendingInquiry])
|
||||||
|
|
||||||
|
function handleSend() {
|
||||||
|
if (!pendingInquiry) return
|
||||||
|
addInquiry(pendingInquiry, message)
|
||||||
|
if (pendingInquiry.pipelineItemId) {
|
||||||
|
moveStage({ id: pendingInquiry.pipelineItemId, stage: 'CONTACTED' })
|
||||||
|
}
|
||||||
|
showToast('Anfrage gesendet — Sie erhalten eine Antwort per E-Mail.', 'success')
|
||||||
|
closeInquiryDialog()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!pendingInquiry) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={dialogOpen} onClose={closeInquiryDialog} maxWidth="sm" fullWidth>
|
||||||
|
<DialogTitle sx={{ fontWeight: 700, pb: 1, display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
|
<Send size={16} />
|
||||||
|
Anfrage senden
|
||||||
|
</DialogTitle>
|
||||||
|
|
||||||
|
<DialogContent sx={{ pt: 0 }}>
|
||||||
|
<Box sx={{ bgcolor: DS_BG.subtle, border: `1px solid ${DS_BORDER.default}`, borderRadius: 1.5, p: 1.5, mb: 2 }}>
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 600 }}>{pendingInquiry.propertyTitle}</Typography>
|
||||||
|
<Typography variant="caption" sx={{ color: DS_TEXT.secondary }}>
|
||||||
|
{pendingInquiry.location}
|
||||||
|
{pendingInquiry.areaLabel && ` · ${pendingInquiry.areaLabel}`}
|
||||||
|
{pendingInquiry.rentLabel && ` · ${pendingInquiry.rentLabel}`}
|
||||||
|
{' · '}Match {pendingInquiry.matchScore}%
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
multiline
|
||||||
|
rows={8}
|
||||||
|
value={message}
|
||||||
|
onChange={e => setMessage(e.target.value)}
|
||||||
|
sx={{ '& .MuiInputBase-root': { fontSize: '0.875rem' } }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Typography variant="caption" sx={{ color: DS_TEXT.muted, display: 'block', mt: 1 }}>
|
||||||
|
Der Text kann vor dem Absenden angepasst werden.
|
||||||
|
</Typography>
|
||||||
|
</DialogContent>
|
||||||
|
|
||||||
|
<DialogActions sx={{ px: 3, pb: 2, gap: 1 }}>
|
||||||
|
<Button onClick={closeInquiryDialog} size="small" sx={{ color: DS_TEXT.muted }}>Abbrechen</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleSend}
|
||||||
|
variant="contained"
|
||||||
|
size="small"
|
||||||
|
disabled={!message.trim()}
|
||||||
|
startIcon={<Send size={14} />}
|
||||||
|
sx={{ bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#162d4a' } }}
|
||||||
|
>
|
||||||
|
Anfrage senden
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { Box, Typography } from '@mui/material'
|
||||||
|
|
||||||
|
export function KpiTile({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
sub,
|
||||||
|
color,
|
||||||
|
}: {
|
||||||
|
label: string
|
||||||
|
value: string
|
||||||
|
sub?: string
|
||||||
|
color?: string
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
flex: 1,
|
||||||
|
p: 1.5,
|
||||||
|
bgcolor: '#f8fafc',
|
||||||
|
borderRadius: 1.5,
|
||||||
|
border: '1px solid #e2e8f0',
|
||||||
|
minWidth: 100,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.25 }}>
|
||||||
|
{label}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="subtitle1" sx={{ fontWeight: 700, color: color ?? '#0f1923', lineHeight: 1.2 }}>
|
||||||
|
{value}
|
||||||
|
</Typography>
|
||||||
|
{sub && (
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
{sub}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -7,66 +7,10 @@ import { useNavigate } from 'react-router'
|
|||||||
import { useProperties } from '../../hooks/useProperties'
|
import { useProperties } from '../../hooks/useProperties'
|
||||||
import { getCityIntelligence } from '../../lib/locationIntelligence'
|
import { getCityIntelligence } from '../../lib/locationIntelligence'
|
||||||
import type { Property } from '../../domain/property'
|
import type { Property } from '../../domain/property'
|
||||||
|
import { DS_TEXT, DS_SURFACE, DS_BORDER } from '../../lib/ds'
|
||||||
import { SoftFactorBar } from './SoftFactorBar'
|
import { SoftFactorBar } from './SoftFactorBar'
|
||||||
|
import { KpiTile } from './KpiTile'
|
||||||
// ── Sub-components ────────────────────────────────────────────────────────────
|
import { NEW_PROJECTS } from './locationIntelligenceConstants'
|
||||||
|
|
||||||
function KpiTile({
|
|
||||||
label,
|
|
||||||
value,
|
|
||||||
sub,
|
|
||||||
color,
|
|
||||||
}: {
|
|
||||||
label: string
|
|
||||||
value: string
|
|
||||||
sub?: string
|
|
||||||
color?: string
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
flex: 1,
|
|
||||||
p: 1.5,
|
|
||||||
bgcolor: '#f8fafc',
|
|
||||||
borderRadius: 1.5,
|
|
||||||
border: '1px solid #e2e8f0',
|
|
||||||
minWidth: 100,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.25 }}>
|
|
||||||
{label}
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="subtitle1" sx={{ fontWeight: 700, color: color ?? '#0f1923', lineHeight: 1.2 }}>
|
|
||||||
{value}
|
|
||||||
</Typography>
|
|
||||||
{sub && (
|
|
||||||
<Typography variant="caption" color="text.secondary">
|
|
||||||
{sub}
|
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const NEW_PROJECTS: Record<string, { title: string; area: string; completion: string; note: string }[]> = {
|
|
||||||
'Zürich': [
|
|
||||||
{ title: 'Ensemble Zürich-West', area: '500–2000 m²', completion: 'Q3 2026', note: 'Büroflächen im Neubauprojekt, Kreis 5' },
|
|
||||||
{ title: 'The Circle Phase II', area: '1000–5000 m²', completion: 'Q1 2027', note: 'Premium-Büros, Flughafen Zürich' },
|
|
||||||
],
|
|
||||||
'Basel': [
|
|
||||||
{ title: 'Basel SBB Tower', area: '300–1500 m²', completion: 'Q4 2026', note: 'Gemischte Nutzung, zentrale Lage' },
|
|
||||||
{ title: 'Erlenmatt Ost', area: '600–2500 m²', completion: 'Q2 2027', note: 'Modernes Stadtentwicklungsareal' },
|
|
||||||
],
|
|
||||||
'Zug': [
|
|
||||||
{ title: 'Zug Innovation Campus', area: '200–1000 m²', completion: 'Q1 2026', note: 'Steuerattraktiv, ÖV-optimal' },
|
|
||||||
],
|
|
||||||
'Bern': [
|
|
||||||
{ title: 'Bern West Business Park', area: '400–3000 m²', completion: 'Q2 2026', note: 'Modernes Gewerbeareal Ausserholligen' },
|
|
||||||
],
|
|
||||||
'Winterthur': [
|
|
||||||
{ title: 'Sulzerareal Phase 4', area: '800–4000 m²', completion: 'Q3 2027', note: 'Industrie-Loft-Flächen im Stadtentwicklungsgebiet' },
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Main component ────────────────────────────────────────────────────────────
|
// ── Main component ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -120,31 +64,31 @@ export function LocationIntelligencePanel({ property }: Props) {
|
|||||||
label="Leerstandsquote"
|
label="Leerstandsquote"
|
||||||
value={`${intel.vacancyRatePct}%`}
|
value={`${intel.vacancyRatePct}%`}
|
||||||
sub={intel.vacancyRatePct < 3 ? 'Angespannter Markt' : intel.vacancyRatePct < 5 ? 'Ausgeglichen' : 'Käufermarkt'}
|
sub={intel.vacancyRatePct < 3 ? 'Angespannter Markt' : intel.vacancyRatePct < 5 ? 'Ausgeglichen' : 'Käufermarkt'}
|
||||||
color={intel.vacancyRatePct < 3 ? '#c0392b' : intel.vacancyRatePct < 5 ? '#d97706' : '#1a7a4a'}
|
color={intel.vacancyRatePct < 3 ? DS_TEXT.error : intel.vacancyRatePct < 5 ? DS_TEXT.warning : DS_TEXT.success}
|
||||||
/>
|
/>
|
||||||
<KpiTile
|
<KpiTile
|
||||||
label="Mietpreis-Trend"
|
label="Mietpreis-Trend"
|
||||||
value={`${rentTrendPositive ? '+' : ''}${intel.rentTrend12m}%`}
|
value={`${rentTrendPositive ? '+' : ''}${intel.rentTrend12m}%`}
|
||||||
sub="letzte 12 Monate"
|
sub="letzte 12 Monate"
|
||||||
color={rentTrendPositive ? '#c0392b' : '#1a7a4a'}
|
color={rentTrendPositive ? DS_TEXT.error : DS_TEXT.success}
|
||||||
/>
|
/>
|
||||||
<KpiTile
|
<KpiTile
|
||||||
label="Kaufkraft-Index"
|
label="Kaufkraft-Index"
|
||||||
value={`${intel.purchasingPowerIndex}`}
|
value={`${intel.purchasingPowerIndex}`}
|
||||||
sub="CH-Mittel = 100"
|
sub="CH-Mittel = 100"
|
||||||
color={intel.purchasingPowerIndex >= 115 ? '#1a7a4a' : intel.purchasingPowerIndex >= 95 ? '#d97706' : '#c0392b'}
|
color={intel.purchasingPowerIndex >= 115 ? DS_TEXT.success : intel.purchasingPowerIndex >= 95 ? DS_TEXT.warning : DS_TEXT.error}
|
||||||
/>
|
/>
|
||||||
<KpiTile
|
<KpiTile
|
||||||
label="Ø Vermietungsdauer"
|
label="Ø Vermietungsdauer"
|
||||||
value={`${intel.avgDaysOnMarket}T`}
|
value={`${intel.avgDaysOnMarket}T`}
|
||||||
sub="Tage auf dem Markt"
|
sub="Tage auf dem Markt"
|
||||||
color={intel.avgDaysOnMarket < 40 ? '#c0392b' : intel.avgDaysOnMarket < 65 ? '#d97706' : '#1a7a4a'}
|
color={intel.avgDaysOnMarket < 40 ? DS_TEXT.error : intel.avgDaysOnMarket < 65 ? DS_TEXT.warning : DS_TEXT.success}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Rent trend interpretation */}
|
{/* Rent trend interpretation */}
|
||||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1, p: 1.5, bgcolor: rentTrendPositive ? '#fff8f0' : '#f0fdf4', borderRadius: 1.5, mb: 2 }}>
|
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1, p: 1.5, bgcolor: rentTrendPositive ? '#fff8f0' : DS_SURFACE.success.bg, borderRadius: 1.5, mb: 2 }}>
|
||||||
<Box sx={{ color: rentTrendPositive ? '#d97706' : '#1a7a4a', mt: 0.1 }}>
|
<Box sx={{ color: rentTrendPositive ? DS_TEXT.warning : DS_TEXT.success, mt: 0.1 }}>
|
||||||
{rentTrendPositive ? <TrendingUp size={16} /> : <TrendingDown size={16} />}
|
{rentTrendPositive ? <TrendingUp size={16} /> : <TrendingDown size={16} />}
|
||||||
</Box>
|
</Box>
|
||||||
<Typography variant="body2" sx={{ color: rentTrendPositive ? '#92400e' : '#14532d' }}>
|
<Typography variant="body2" sx={{ color: rentTrendPositive ? '#92400e' : '#14532d' }}>
|
||||||
@@ -156,29 +100,29 @@ export function LocationIntelligencePanel({ property }: Props) {
|
|||||||
|
|
||||||
{/* Tax + demand */}
|
{/* Tax + demand */}
|
||||||
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap', mb: 2 }}>
|
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap', mb: 2 }}>
|
||||||
<Box sx={{ flex: 1, p: 1.5, bgcolor: '#f8fafc', borderRadius: 1.5, border: '1px solid #e2e8f0', minWidth: 140 }}>
|
<Box sx={{ flex: 1, p: 1.5, bgcolor: DS_SURFACE.neutral.bg, borderRadius: 1.5, border: `1px solid ${DS_BORDER.default}`, minWidth: 140 }}>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mb: 0.5 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mb: 0.5 }}>
|
||||||
<Percent size={13} color="#64748b" />
|
<Percent size={13} color={DS_TEXT.muted} />
|
||||||
<Typography variant="caption" color="text.secondary">Steuerindex Kanton</Typography>
|
<Typography variant="caption" color="text.secondary">Steuerindex Kanton</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: intel.taxIndexCanton <= 80 ? '#1a7a4a' : intel.taxIndexCanton <= 105 ? '#d97706' : '#c0392b' }}>
|
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: intel.taxIndexCanton <= 80 ? DS_TEXT.success : intel.taxIndexCanton <= 105 ? DS_TEXT.warning : DS_TEXT.error }}>
|
||||||
{intel.taxIndexCanton} <Typography component="span" variant="caption" color="text.secondary">(CH = 100)</Typography>
|
{intel.taxIndexCanton} <Typography component="span" variant="caption" color="text.secondary">(CH = 100)</Typography>
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 0.25 }}>
|
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 0.25 }}>
|
||||||
{intel.taxIndexCanton <= 75 ? 'Sehr steuerattraktiv' : intel.taxIndexCanton <= 95 ? 'Günstige Steuerlast' : intel.taxIndexCanton <= 110 ? 'Durchschnittlich' : 'Hohe Steuerlast'}
|
{intel.taxIndexCanton <= 75 ? 'Sehr steuerattraktiv' : intel.taxIndexCanton <= 95 ? 'Günstige Steuerlast' : intel.taxIndexCanton <= 110 ? 'Durchschnittlich' : 'Hohe Steuerlast'}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
<Box sx={{ flex: 1, p: 1.5, bgcolor: '#f8fafc', borderRadius: 1.5, border: '1px solid #e2e8f0', minWidth: 140 }}>
|
<Box sx={{ flex: 1, p: 1.5, bgcolor: DS_SURFACE.neutral.bg, borderRadius: 1.5, border: `1px solid ${DS_BORDER.default}`, minWidth: 140 }}>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mb: 0.5 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mb: 0.5 }}>
|
||||||
<Activity size={13} color="#64748b" />
|
<Activity size={13} color={DS_TEXT.muted} />
|
||||||
<Typography variant="caption" color="text.secondary">Nachfragestärke</Typography>
|
<Typography variant="caption" color="text.secondary">Nachfragestärke</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
<Chip
|
<Chip
|
||||||
label={{ LOW: 'Gering', MEDIUM: 'Mittel', HIGH: 'Hoch', VERY_HIGH: 'Sehr hoch' }[intel.demandStrength]}
|
label={{ LOW: 'Gering', MEDIUM: 'Mittel', HIGH: 'Hoch', VERY_HIGH: 'Sehr hoch' }[intel.demandStrength]}
|
||||||
size="small"
|
size="small"
|
||||||
sx={{
|
sx={{
|
||||||
bgcolor: { LOW: '#f1f5f9', MEDIUM: '#fef3c7', HIGH: '#dcfce7', VERY_HIGH: '#f0fdf4' }[intel.demandStrength],
|
bgcolor: { LOW: '#f1f5f9', MEDIUM: '#fef3c7', HIGH: '#dcfce7', VERY_HIGH: DS_SURFACE.success.bg }[intel.demandStrength],
|
||||||
color: { LOW: '#64748b', MEDIUM: '#d97706', HIGH: '#16a34a', VERY_HIGH: '#1a7a4a' }[intel.demandStrength],
|
color: { LOW: DS_TEXT.muted, MEDIUM: DS_TEXT.warning, HIGH: '#16a34a', VERY_HIGH: DS_TEXT.success }[intel.demandStrength],
|
||||||
fontWeight: 600, fontSize: 11,
|
fontWeight: 600, fontSize: 11,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -190,12 +134,12 @@ export function LocationIntelligencePanel({ property }: Props) {
|
|||||||
|
|
||||||
{/* Industry clusters */}
|
{/* Industry clusters */}
|
||||||
<Box sx={{ mb: 2 }}>
|
<Box sx={{ mb: 2 }}>
|
||||||
<Typography variant="caption" sx={{ fontWeight: 600, color: '#475569', display: 'block', mb: 0.75 }}>
|
<Typography variant="caption" sx={{ fontWeight: 600, color: DS_TEXT.secondary, display: 'block', mb: 0.75 }}>
|
||||||
Dominante Branchen-Cluster
|
Dominante Branchen-Cluster
|
||||||
</Typography>
|
</Typography>
|
||||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
|
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
|
||||||
{intel.dominantIndustryClusters.map(c => (
|
{intel.dominantIndustryClusters.map(c => (
|
||||||
<Chip key={c} label={c} size="small" sx={{ bgcolor: '#eff6ff', color: '#1e3a5f', fontSize: 11, height: 22 }} />
|
<Chip key={c} label={c} size="small" sx={{ bgcolor: '#eff6ff', color: DS_TEXT.brand, fontSize: 11, height: 22 }} />
|
||||||
))}
|
))}
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -204,8 +148,8 @@ export function LocationIntelligencePanel({ property }: Props) {
|
|||||||
{intel.plannedInfrastructure.length > 0 && (
|
{intel.plannedInfrastructure.length > 0 && (
|
||||||
<Box sx={{ mb: 2 }}>
|
<Box sx={{ mb: 2 }}>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mb: 0.75 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mb: 0.75 }}>
|
||||||
<Train size={13} color="#64748b" />
|
<Train size={13} color={DS_TEXT.muted} />
|
||||||
<Typography variant="caption" sx={{ fontWeight: 600, color: '#475569' }}>
|
<Typography variant="caption" sx={{ fontWeight: 600, color: DS_TEXT.secondary }}>
|
||||||
Geplante Infrastruktur-Projekte
|
Geplante Infrastruktur-Projekte
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -230,7 +174,7 @@ export function LocationIntelligencePanel({ property }: Props) {
|
|||||||
{/* ── Soft Factors ── */}
|
{/* ── Soft Factors ── */}
|
||||||
{hasSoftFactors && (
|
{hasSoftFactors && (
|
||||||
<Box sx={{ mb: 1.5 }}>
|
<Box sx={{ mb: 1.5 }}>
|
||||||
<Typography variant="caption" sx={{ fontWeight: 600, color: '#475569', display: 'block', mb: 1 }}>
|
<Typography variant="caption" sx={{ fontWeight: 600, color: DS_TEXT.secondary, display: 'block', mb: 1 }}>
|
||||||
KI-berechnete Standortqualität
|
KI-berechnete Standortqualität
|
||||||
</Typography>
|
</Typography>
|
||||||
<SoftFactorBar
|
<SoftFactorBar
|
||||||
@@ -278,7 +222,7 @@ export function LocationIntelligencePanel({ property }: Props) {
|
|||||||
{comparables.length > 0 && (
|
{comparables.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<Divider sx={{ my: 1.5 }} />
|
<Divider sx={{ my: 1.5 }} />
|
||||||
<Typography variant="caption" sx={{ fontWeight: 600, color: '#475569', display: 'block', mb: 0.75 }}>
|
<Typography variant="caption" sx={{ fontWeight: 600, color: DS_TEXT.secondary, display: 'block', mb: 0.75 }}>
|
||||||
Vergleichbare Angebote in {city}
|
Vergleichbare Angebote in {city}
|
||||||
</Typography>
|
</Typography>
|
||||||
{comparables.map(p => (
|
{comparables.map(p => (
|
||||||
@@ -288,7 +232,7 @@ export function LocationIntelligencePanel({ property }: Props) {
|
|||||||
sx={{
|
sx={{
|
||||||
display: 'flex', alignItems: 'center', gap: 1.5, py: 0.75,
|
display: 'flex', alignItems: 'center', gap: 1.5, py: 0.75,
|
||||||
borderBottom: '1px solid #f1f5f9', cursor: 'pointer',
|
borderBottom: '1px solid #f1f5f9', cursor: 'pointer',
|
||||||
'&:hover': { bgcolor: '#f8fafc', borderRadius: 0.5 },
|
'&:hover': { bgcolor: DS_SURFACE.neutral.bg, borderRadius: 0.5 },
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Building2 size={13} color="#3b82f6" />
|
<Building2 size={13} color="#3b82f6" />
|
||||||
@@ -297,7 +241,7 @@ export function LocationIntelligencePanel({ property }: Props) {
|
|||||||
{p.title}
|
{p.title}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="caption" color="text.secondary">
|
<Typography variant="caption" color="text.secondary">
|
||||||
{p.areaSqm} m² · CHF {p.rentPricePerSqm}/m²
|
{p.areaSqm} m² · CHF {p.rentPricePerSqm}/m²/Jahr
|
||||||
{p.rentPricePerSqm < property.rentPricePerSqm && ' · günstiger'}
|
{p.rentPricePerSqm < property.rentPricePerSqm && ' · günstiger'}
|
||||||
{p.rentPricePerSqm > property.rentPricePerSqm && ' · teurer'}
|
{p.rentPricePerSqm > property.rentPricePerSqm && ' · teurer'}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import type { FutureSignal } from '../../domain/futureSignal'
|
|||||||
|
|
||||||
const RESULT_TYPE_META: Record<string, { label: string; color: string }> = {
|
const RESULT_TYPE_META: Record<string, { label: string; color: string }> = {
|
||||||
VERIFIED_PORTFOLIO: { label: 'Verified Portfolio', color: '#1e3a5f' },
|
VERIFIED_PORTFOLIO: { label: 'Verified Portfolio', color: '#1e3a5f' },
|
||||||
EXTERNAL_MARKET: { label: 'Direktinserat', color: '#d97706' },
|
|
||||||
MAISON_WORK: { label: 'Maison Work', color: '#0369a1' },
|
MAISON_WORK: { label: 'Maison Work', color: '#0369a1' },
|
||||||
FUTURE_AVAILABILITY: { label: 'Zukunftssignal', color: '#7c3aed' },
|
FUTURE_AVAILABILITY: { label: 'Zukunftssignal', color: '#7c3aed' },
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import { Box, Button, Chip, Paper, Typography } from '@mui/material'
|
import { Box, Button, Chip, Paper, Typography } from '@mui/material'
|
||||||
import { Building2, Clock, ExternalLink, Info, Layers, Tag, Train, TrendingUp } from 'lucide-react'
|
import { Building2, Clock, ExternalLink, Info, Layers, Tag, Train, TrendingUp } from 'lucide-react'
|
||||||
import { useMatchDetail } from '../../hooks/useMatches'
|
import { useMatchDetail } from '../../hooks/useMatches'
|
||||||
|
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 { 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']>
|
||||||
|
|
||||||
@@ -17,8 +20,11 @@ export function MatchDetailPropertySections({ property, match }: MatchDetailProp
|
|||||||
const flexibleUnits = units.filter(u => u.isFlexible && u.minLettableSqm != null)
|
const flexibleUnits = units.filter(u => u.isFlexible && u.minLettableSqm != null)
|
||||||
const preMarketUnits = units.filter(u => u.schattenmarktRelease?.enabled)
|
const preMarketUnits = units.filter(u => u.schattenmarktRelease?.enabled)
|
||||||
const otherUnits = units.filter(u => !u.schattenmarktRelease?.enabled)
|
const otherUnits = units.filter(u => !u.schattenmarktRelease?.enabled)
|
||||||
const monthlyPerSqm = Math.round(property.rentPricePerSqm / 12)
|
|
||||||
const totalMonthly = Math.round(property.areaSqm * property.rentPricePerSqm / 12)
|
const totalMonthly = Math.round(property.areaSqm * property.rentPricePerSqm / 12)
|
||||||
|
const rawMonthlyPerSqm = property.rentPricePerSqm / 12
|
||||||
|
const monthlyPerSqmLabel = Number.isInteger(rawMonthlyPerSqm)
|
||||||
|
? `CHF ${rawMonthlyPerSqm}.–`
|
||||||
|
: `CHF ${rawMonthlyPerSqm.toLocaleString('de-CH', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
|
||||||
const minLettable = property.areaSqmMin ?? (flexibleUnits.length > 0 ? Math.min(...flexibleUnits.map(u => u.minLettableSqm!)) : undefined)
|
const minLettable = property.areaSqmMin ?? (flexibleUnits.length > 0 ? Math.min(...flexibleUnits.map(u => u.minLettableSqm!)) : undefined)
|
||||||
const sourceLabel = property.sourceLabel ?? property.sourceMeta?.sourceLabel ?? SOURCE_LABELS[property.sourceType] ?? property.sourceType
|
const sourceLabel = property.sourceLabel ?? property.sourceMeta?.sourceLabel ?? SOURCE_LABELS[property.sourceType] ?? property.sourceType
|
||||||
|
|
||||||
@@ -27,11 +33,11 @@ export function MatchDetailPropertySections({ property, match }: MatchDetailProp
|
|||||||
{/* Preis */}
|
{/* Preis */}
|
||||||
<Paper sx={{ p: 2.5 }}>
|
<Paper sx={{ p: 2.5 }}>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
|
||||||
<Tag size={15} color="#374151" />
|
<Tag size={15} color={DS_TEXT.secondary} />
|
||||||
<Typography variant="h6" sx={{ fontWeight: 700 }}>Preis</Typography>
|
<Typography variant="h6" sx={{ fontWeight: 700 }}>Preis</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
<KeyFactRow label="Monatliche Miete" value={`CHF ${totalMonthly.toLocaleString('de-CH')}.–`} />
|
<KeyFactRow label="Monatliche Miete" value={`CHF ${totalMonthly.toLocaleString('de-CH')}.–`} />
|
||||||
<KeyFactRow label="Pro m²/Monat" value={`CHF ${monthlyPerSqm.toLocaleString('de-CH')}.–`} />
|
<KeyFactRow label="Pro m²/Monat" value={monthlyPerSqmLabel} />
|
||||||
<KeyFactRow label="Pro m²/Jahr" value={`CHF ${property.rentPricePerSqm.toLocaleString('de-CH')}.–`} />
|
<KeyFactRow label="Pro m²/Jahr" value={`CHF ${property.rentPricePerSqm.toLocaleString('de-CH')}.–`} />
|
||||||
{property.ancillaryCosts != null && (
|
{property.ancillaryCosts != null && (
|
||||||
<KeyFactRow label="Nebenkosten" value={`CHF ${Math.round(property.areaSqm * property.ancillaryCosts / 12).toLocaleString('de-CH')}/Mt. (CHF ${property.ancillaryCosts}/m²/a)`} />
|
<KeyFactRow label="Nebenkosten" value={`CHF ${Math.round(property.areaSqm * property.ancillaryCosts / 12).toLocaleString('de-CH')}/Mt. (CHF ${property.ancillaryCosts}/m²/a)`} />
|
||||||
@@ -41,7 +47,7 @@ export function MatchDetailPropertySections({ property, match }: MatchDetailProp
|
|||||||
{/* Hauptangaben */}
|
{/* Hauptangaben */}
|
||||||
<Paper sx={{ p: 2.5 }}>
|
<Paper sx={{ p: 2.5 }}>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
|
||||||
<Info size={15} color="#374151" />
|
<Info size={15} color={DS_TEXT.secondary} />
|
||||||
<Typography variant="h6" sx={{ fontWeight: 700 }}>Hauptangaben</Typography>
|
<Typography variant="h6" sx={{ fontWeight: 700 }}>Hauptangaben</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
<KeyFactRow label="Verfügbarkeit" value={property.availabilityDate ? new Date(property.availabilityDate).toLocaleDateString('de-CH', { day: 'numeric', month: 'long', year: 'numeric' }) : 'Auf Anfrage'} />
|
<KeyFactRow label="Verfügbarkeit" value={property.availabilityDate ? new Date(property.availabilityDate).toLocaleDateString('de-CH', { day: 'numeric', month: 'long', year: 'numeric' }) : 'Auf Anfrage'} />
|
||||||
@@ -63,27 +69,27 @@ export function MatchDetailPropertySections({ property, match }: MatchDetailProp
|
|||||||
{property.softFactors && (
|
{property.softFactors && (
|
||||||
<Paper sx={{ p: 2.5 }}>
|
<Paper sx={{ p: 2.5 }}>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
|
||||||
<TrendingUp size={15} color="#374151" />
|
<TrendingUp size={15} color={DS_TEXT.secondary} />
|
||||||
<Typography variant="h6" sx={{ fontWeight: 700 }}>Eigenschaften</Typography>
|
<Typography variant="h6" sx={{ fontWeight: 700 }}>Eigenschaften</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
|
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
|
||||||
{property.softFactors.publicTransportMinutes != null && (
|
{property.softFactors.publicTransportMinutes != null && (
|
||||||
<Chip size="small" icon={<Train size={11} />} label={`ÖV: ${property.softFactors.publicTransportMinutes} Min. zu Fuss`} sx={{ bgcolor: '#eff6ff', color: '#1d4ed8', border: '1px solid #bfdbfe', fontWeight: 500 }} />
|
<Chip size="small" icon={<Train size={11} />} label={`ÖV: ${property.softFactors.publicTransportMinutes} Min. zu Fuss`} sx={{ bgcolor: DS_SURFACE.blue.bg, color: DS_MARKET_SIGNAL.accent, border: `1px solid ${DS_SURFACE.blue.border}`, fontWeight: 500 }} />
|
||||||
)}
|
)}
|
||||||
{property.softFactors.parkingSpots != null && property.softFactors.parkingSpots > 0 && (
|
{property.softFactors.parkingSpots != null && property.softFactors.parkingSpots > 0 && (
|
||||||
<Chip size="small" label={`${property.softFactors.parkingSpots} Parkplätze`} sx={{ bgcolor: '#f8fafc', color: '#374151', border: '1px solid #e2e8f0', fontWeight: 500 }} />
|
<Chip size="small" label={`${property.softFactors.parkingSpots} Parkplätze`} sx={{ bgcolor: DS_BG.page, color: DS_TEXT.primary, border: `1px solid ${DS_BORDER.default}`, fontWeight: 500 }} />
|
||||||
)}
|
)}
|
||||||
{property.softFactors.prestige != null && property.softFactors.prestige >= 80 && (
|
{property.softFactors.prestige != null && property.softFactors.prestige >= 80 && (
|
||||||
<Chip size="small" label="Prestigestandort" sx={{ bgcolor: '#fef9c3', color: '#92400e', border: '1px solid #fde68a', fontWeight: 500 }} />
|
<Chip size="small" label="Prestigestandort" sx={{ bgcolor: DS_SURFACE.warning.bg, color: DS_TEXT.warningDark, border: `1px solid ${DS_SURFACE.warning.border}`, fontWeight: 500 }} />
|
||||||
)}
|
)}
|
||||||
{property.softFactors.visibilityScore != null && property.softFactors.visibilityScore >= 80 && (
|
{property.softFactors.visibilityScore != null && property.softFactors.visibilityScore >= 80 && (
|
||||||
<Chip size="small" label="Hohe Sichtbarkeit" sx={{ bgcolor: '#fef9c3', color: '#92400e', border: '1px solid #fde68a', fontWeight: 500 }} />
|
<Chip size="small" label="Hohe Sichtbarkeit" sx={{ bgcolor: DS_SURFACE.warning.bg, color: DS_TEXT.warningDark, border: `1px solid ${DS_SURFACE.warning.border}`, fontWeight: 500 }} />
|
||||||
)}
|
)}
|
||||||
{property.softFactors.passerbyFrequency && (
|
{property.softFactors.passerbyFrequency && (
|
||||||
<Chip size="small" label={`Laufkundschaft: ${PASSERBY_LABELS[property.softFactors.passerbyFrequency]}`} sx={{ bgcolor: '#f0fdf4', color: '#1a7a4a', border: '1px solid #bbf7d0', fontWeight: 500 }} />
|
<Chip size="small" label={`Laufkundschaft: ${PASSERBY_LABELS[property.softFactors.passerbyFrequency]}`} sx={{ bgcolor: DS_SURFACE.success.bg, color: DS_TEXT.success, border: `1px solid ${DS_SURFACE.success.border}`, fontWeight: 500 }} />
|
||||||
)}
|
)}
|
||||||
{property.softFactors.talentAccess != null && property.softFactors.talentAccess >= 80 && (
|
{property.softFactors.talentAccess != null && property.softFactors.talentAccess >= 80 && (
|
||||||
<Chip size="small" label={`Talentindex: ${property.softFactors.talentAccess}`} sx={{ bgcolor: '#faf5ff', color: '#5b21b6', border: '1px solid #e9d5ff', fontWeight: 500 }} />
|
<Chip size="small" label={`Talentindex: ${property.softFactors.talentAccess}`} sx={{ bgcolor: DS_SURFACE.purple.bg, color: DS_TEXT.signalDark, border: `1px solid ${DS_SURFACE.purple.border}`, fontWeight: 500 }} />
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
</Paper>
|
</Paper>
|
||||||
@@ -93,12 +99,12 @@ export function MatchDetailPropertySections({ property, match }: MatchDetailProp
|
|||||||
{property.softFactors?.publicTransportMinutes != null && (
|
{property.softFactors?.publicTransportMinutes != null && (
|
||||||
<Paper sx={{ p: 2.5 }}>
|
<Paper sx={{ p: 2.5 }}>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
|
||||||
<Clock size={15} color="#374151" />
|
<Clock size={15} color={DS_TEXT.secondary} />
|
||||||
<Typography variant="h6" sx={{ fontWeight: 700 }}>Wegzeit</Typography>
|
<Typography variant="h6" sx={{ fontWeight: 700 }}>Wegzeit</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: 40, height: 40, borderRadius: '50%', bgcolor: '#eff6ff', border: '1px solid #bfdbfe', flexShrink: 0 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: 40, height: 40, borderRadius: '50%', bgcolor: DS_SURFACE.blue.bg, border: `1px solid ${DS_SURFACE.blue.border}`, flexShrink: 0 }}>
|
||||||
<Train size={18} color="#1d4ed8" />
|
<Train size={18} color={DS_MARKET_SIGNAL.accent} />
|
||||||
</Box>
|
</Box>
|
||||||
<Box>
|
<Box>
|
||||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>{property.softFactors.publicTransportMinutes} Min. zu Fuss</Typography>
|
<Typography variant="body2" sx={{ fontWeight: 600 }}>{property.softFactors.publicTransportMinutes} Min. zu Fuss</Typography>
|
||||||
@@ -115,7 +121,7 @@ export function MatchDetailPropertySections({ property, match }: MatchDetailProp
|
|||||||
{units.length > 0 && (
|
{units.length > 0 && (
|
||||||
<Paper sx={{ p: 2.5 }}>
|
<Paper sx={{ p: 2.5 }}>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
|
||||||
<Layers size={15} color="#374151" />
|
<Layers size={15} color={DS_TEXT.secondary} />
|
||||||
<Typography variant="h6" sx={{ fontWeight: 700 }}>Einheiten</Typography>
|
<Typography variant="h6" sx={{ fontWeight: 700 }}>Einheiten</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 80px 110px 120px auto', gap: 1.5, px: 2, mb: 0.75 }}>
|
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 80px 110px 120px auto', gap: 1.5, px: 2, mb: 0.75 }}>
|
||||||
@@ -128,14 +134,32 @@ export function MatchDetailPropertySections({ property, match }: MatchDetailProp
|
|||||||
</Paper>
|
</Paper>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Grundriss */}
|
||||||
|
{(() => {
|
||||||
|
const plans: Array<{ url: string; label?: string }> = []
|
||||||
|
if (matchedUnit?.floorPlanUrl) {
|
||||||
|
const lbl = matchedUnit.unitLabel
|
||||||
|
? `${FLOOR_LABEL(matchedUnit.floorLevel)} ${matchedUnit.unitLabel}`
|
||||||
|
: FLOOR_LABEL(matchedUnit.floorLevel)
|
||||||
|
plans.push({ url: matchedUnit.floorPlanUrl, label: units.length > 1 ? lbl : undefined })
|
||||||
|
} else {
|
||||||
|
units.filter(u => u.floorPlanUrl).forEach(u => {
|
||||||
|
const lbl = u.unitLabel ? `${FLOOR_LABEL(u.floorLevel)} ${u.unitLabel}` : FLOOR_LABEL(u.floorLevel)
|
||||||
|
plans.push({ url: u.floorPlanUrl!, label: plans.length > 0 || units.filter(x => x.floorPlanUrl).length > 1 ? lbl : undefined })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (plans.length === 0 && property.floorPlanUrl) plans.push({ url: property.floorPlanUrl })
|
||||||
|
return <FloorPlanSection plans={plans} />
|
||||||
|
})()}
|
||||||
|
|
||||||
{/* Beschreibung */}
|
{/* Beschreibung */}
|
||||||
{property.description && (
|
{property.description && (
|
||||||
<Paper sx={{ p: 2.5 }}>
|
<Paper sx={{ p: 2.5 }}>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
|
||||||
<Building2 size={15} color="#374151" />
|
<Building2 size={15} color={DS_TEXT.secondary} />
|
||||||
<Typography variant="h6" sx={{ fontWeight: 700 }}>Beschreibung</Typography>
|
<Typography variant="h6" sx={{ fontWeight: 700 }}>Beschreibung</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
<Typography variant="body2" sx={{ lineHeight: 1.75, color: '#374151', whiteSpace: 'pre-wrap' }}>
|
<Typography variant="body2" sx={{ lineHeight: 1.75, color: DS_TEXT.primary, whiteSpace: 'pre-wrap' }}>
|
||||||
{property.description}
|
{property.description}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Paper>
|
</Paper>
|
||||||
@@ -144,7 +168,7 @@ export function MatchDetailPropertySections({ property, match }: MatchDetailProp
|
|||||||
{/* Quelle & Referenz */}
|
{/* Quelle & Referenz */}
|
||||||
<Paper sx={{ p: 2.5 }}>
|
<Paper sx={{ p: 2.5 }}>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
|
||||||
<Info size={15} color="#374151" />
|
<Info size={15} color={DS_TEXT.secondary} />
|
||||||
<Typography variant="h6" sx={{ fontWeight: 700 }}>Quelle & Referenz</Typography>
|
<Typography variant="h6" sx={{ fontWeight: 700 }}>Quelle & Referenz</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
<KeyFactRow label="Datenquelle" value={sourceLabel} />
|
<KeyFactRow label="Datenquelle" value={sourceLabel} />
|
||||||
@@ -153,9 +177,9 @@ export function MatchDetailPropertySections({ property, match }: MatchDetailProp
|
|||||||
{property.dataQuality.lastVerifiedAt && (
|
{property.dataQuality.lastVerifiedAt && (
|
||||||
<KeyFactRow label="Zuletzt verifiziert" value={new Date(property.dataQuality.lastVerifiedAt).toLocaleDateString('de-CH', { day: 'numeric', month: 'long', year: 'numeric' })} />
|
<KeyFactRow label="Zuletzt verifiziert" value={new Date(property.dataQuality.lastVerifiedAt).toLocaleDateString('de-CH', { day: 'numeric', month: 'long', year: 'numeric' })} />
|
||||||
)}
|
)}
|
||||||
{property.sourceUrl && (
|
{property.sourceUrl && property.resultType === ResultType.MAISON_WORK && (
|
||||||
<Box sx={{ mt: 1.25 }}>
|
<Box sx={{ mt: 1.25 }}>
|
||||||
<Button size="small" variant="outlined" endIcon={<ExternalLink size={12} />} href={property.sourceUrl} target="_blank" rel="noopener noreferrer" sx={{ textTransform: 'none', fontSize: '0.8rem', borderColor: '#cbd5e1', color: '#374151' }}>
|
<Button size="small" variant="outlined" endIcon={<ExternalLink size={12} />} href={property.sourceUrl} target="_blank" rel="noopener noreferrer" sx={{ textTransform: 'none', fontSize: '0.8rem', borderColor: DS_BORDER.strong, color: DS_TEXT.primary }}>
|
||||||
Zum Originalinserat
|
Zum Originalinserat
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -56,16 +56,22 @@ function buildRows(need: Need, property: Property): AlignmentRow[] {
|
|||||||
fit: locationFit,
|
fit: locationFit,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Budget
|
// Budget — both values are annual CHF/m²; display as monthly for consistency with Preis section
|
||||||
const budgetFit: FitStatus = property.rentPricePerSqm <= need.budgetRange.maxPerSqm
|
const budgetFit: FitStatus = property.rentPricePerSqm <= need.budgetRange.maxPerSqm
|
||||||
? 'MATCH'
|
? 'MATCH'
|
||||||
: property.rentPricePerSqm <= need.budgetRange.maxPerSqm * 1.1
|
: property.rentPricePerSqm <= need.budgetRange.maxPerSqm * 1.1
|
||||||
? 'PARTIAL'
|
? 'PARTIAL'
|
||||||
: 'NO_MATCH'
|
: 'NO_MATCH'
|
||||||
|
function fmtMt(annual: number): string {
|
||||||
|
const m = annual / 12
|
||||||
|
return Number.isInteger(m)
|
||||||
|
? `CHF ${m}.–/m²/Mt.`
|
||||||
|
: `CHF ${m.toLocaleString('de-CH', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}/m²/Mt.`
|
||||||
|
}
|
||||||
rows.push({
|
rows.push({
|
||||||
label: 'Budget',
|
label: 'Budget',
|
||||||
needValue: `max. CHF ${need.budgetRange.maxPerSqm}/m²`,
|
needValue: `max. ${fmtMt(need.budgetRange.maxPerSqm)}`,
|
||||||
resultValue: `CHF ${property.rentPricePerSqm}/m²`,
|
resultValue: fmtMt(property.rentPricePerSqm),
|
||||||
fit: budgetFit,
|
fit: budgetFit,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,23 +1,17 @@
|
|||||||
import { Box, Button, Paper, Typography } from '@mui/material'
|
import { Box, Button, Paper, Typography } from '@mui/material'
|
||||||
|
import { MessageSquare } from 'lucide-react'
|
||||||
import type { Match, NextBestAction } from '../../domain/match'
|
import type { Match, NextBestAction } from '../../domain/match'
|
||||||
|
|
||||||
const PRIORITY_ORDER: Record<string, number> = { HIGH: 0, MEDIUM: 1, LOW: 2 }
|
const PRIORITY_ORDER: Record<string, number> = { HIGH: 0, MEDIUM: 1, LOW: 2 }
|
||||||
|
|
||||||
const PRIORITY_COLOR: Record<string, 'contained' | 'outlined'> = {
|
|
||||||
HIGH: 'contained',
|
|
||||||
MEDIUM: 'outlined',
|
|
||||||
LOW: 'outlined',
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
match: Match
|
match: Match
|
||||||
onCompare?: () => void
|
onCompare?: () => void
|
||||||
onShortlist?: () => void
|
onPipeline?: () => void
|
||||||
onReject?: () => void
|
onInquire?: () => void
|
||||||
onReview?: () => void
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function NextActionsPanel({ match, onCompare, onShortlist, onReject, onReview }: Props) {
|
export function NextActionsPanel({ match, onCompare, onPipeline, onInquire }: Props) {
|
||||||
const engineActions: NextBestAction[] = [...(match.nextBestActions ?? [])].sort(
|
const engineActions: NextBestAction[] = [...(match.nextBestActions ?? [])].sort(
|
||||||
(a, b) => (PRIORITY_ORDER[a.priority] ?? 2) - (PRIORITY_ORDER[b.priority] ?? 2)
|
(a, b) => (PRIORITY_ORDER[a.priority] ?? 2) - (PRIORITY_ORDER[b.priority] ?? 2)
|
||||||
)
|
)
|
||||||
@@ -26,37 +20,41 @@ export function NextActionsPanel({ match, onCompare, onShortlist, onReject, onRe
|
|||||||
<Paper sx={{ p: 2.5, mb: 2 }}>
|
<Paper sx={{ p: 2.5, mb: 2 }}>
|
||||||
<Typography variant="h6" sx={{ fontWeight: 700, mb: 1.5 }}>Empfohlene Aktionen</Typography>
|
<Typography variant="h6" sx={{ fontWeight: 700, mb: 1.5 }}>Empfohlene Aktionen</Typography>
|
||||||
|
|
||||||
{engineActions.length > 0 && (
|
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75, mb: 1.5 }}>
|
|
||||||
{engineActions.map((action, i) => (
|
|
||||||
<Box key={i}>
|
|
||||||
<Button
|
|
||||||
fullWidth
|
|
||||||
size="small"
|
|
||||||
variant={PRIORITY_COLOR[action.priority]}
|
|
||||||
sx={
|
|
||||||
action.priority === 'HIGH'
|
|
||||||
? { bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#162d4a' }, justifyContent: 'flex-start' }
|
|
||||||
: { justifyContent: 'flex-start' }
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{action.label}
|
|
||||||
</Button>
|
|
||||||
{action.description && (
|
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 0.25, px: 1 }}>
|
|
||||||
{action.description}
|
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
))}
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Standard actions */}
|
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75 }}>
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75 }}>
|
||||||
{onShortlist && (
|
{onInquire && (
|
||||||
<Button fullWidth size="small" variant="outlined" onClick={onShortlist} sx={{ justifyContent: 'flex-start' }}>
|
<Button
|
||||||
Zur Shortlist hinzufügen
|
fullWidth
|
||||||
|
size="small"
|
||||||
|
variant="contained"
|
||||||
|
startIcon={<MessageSquare size={14} />}
|
||||||
|
onClick={onInquire}
|
||||||
|
sx={{ justifyContent: 'flex-start', bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#162d4a' } }}
|
||||||
|
>
|
||||||
|
Anfrage senden
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{engineActions.length > 0 && engineActions.map((action, i) => (
|
||||||
|
<Box key={i}>
|
||||||
|
<Button
|
||||||
|
fullWidth
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
sx={{ justifyContent: 'flex-start' }}
|
||||||
|
>
|
||||||
|
{action.label}
|
||||||
|
</Button>
|
||||||
|
{action.description && (
|
||||||
|
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 0.25, px: 1 }}>
|
||||||
|
{action.description}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{onPipeline && (
|
||||||
|
<Button fullWidth size="small" variant="outlined" onClick={onPipeline} sx={{ justifyContent: 'flex-start' }}>
|
||||||
|
Zur Pipeline hinzufügen
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{onCompare && (
|
{onCompare && (
|
||||||
@@ -64,16 +62,6 @@ export function NextActionsPanel({ match, onCompare, onShortlist, onReject, onRe
|
|||||||
Zum Vergleich hinzufügen
|
Zum Vergleich hinzufügen
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{onReview && (
|
|
||||||
<Button fullWidth size="small" variant="outlined" onClick={onReview} sx={{ justifyContent: 'flex-start', color: '#d97706', borderColor: '#d97706' }}>
|
|
||||||
Zur Überprüfung senden
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{onReject && (
|
|
||||||
<Button fullWidth size="small" variant="outlined" onClick={onReject} sx={{ justifyContent: 'flex-start', color: '#c0392b', borderColor: '#c0392b' }}>
|
|
||||||
Match ablehnen
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</Box>
|
</Box>
|
||||||
</Paper>
|
</Paper>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -11,11 +11,11 @@ import {
|
|||||||
ExternalLink,
|
ExternalLink,
|
||||||
Info,
|
Info,
|
||||||
Layers,
|
Layers,
|
||||||
ShieldCheck,
|
|
||||||
Tag,
|
Tag,
|
||||||
Train,
|
Train,
|
||||||
TrendingUp,
|
TrendingUp,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
import { ResultType } from '../../domain/enums'
|
||||||
import type { Property } from '../../domain/property'
|
import type { Property } from '../../domain/property'
|
||||||
import {
|
import {
|
||||||
ASSET_LABELS,
|
ASSET_LABELS,
|
||||||
@@ -26,6 +26,8 @@ import {
|
|||||||
SOURCE_LABELS,
|
SOURCE_LABELS,
|
||||||
UnitRow,
|
UnitRow,
|
||||||
} from './MatchDetailPropertyDetails'
|
} from './MatchDetailPropertyDetails'
|
||||||
|
import { FloorPlanSection } from './FloorPlanSection'
|
||||||
|
import { DS_TEXT, DS_BG, DS_SURFACE, DS_BORDER, DS_MARKET_SIGNAL } from '../../lib/ds'
|
||||||
|
|
||||||
interface PropertyDetailPublicSectionsProps {
|
interface PropertyDetailPublicSectionsProps {
|
||||||
property: Property
|
property: Property
|
||||||
@@ -37,8 +39,11 @@ export function PropertyDetailPublicSections({ property, highlightUnitId }: Prop
|
|||||||
const otherUnits = (property.units ?? []).filter(u => !u.schattenmarktRelease?.enabled)
|
const otherUnits = (property.units ?? []).filter(u => !u.schattenmarktRelease?.enabled)
|
||||||
const flexibleUnits = (property.units ?? []).filter(u => u.isFlexible && u.minLettableSqm !== undefined)
|
const flexibleUnits = (property.units ?? []).filter(u => u.isFlexible && u.minLettableSqm !== undefined)
|
||||||
|
|
||||||
const monthlyPerSqm = Math.round(property.rentPricePerSqm / 12)
|
|
||||||
const totalMonthly = Math.round(property.areaSqm * property.rentPricePerSqm / 12)
|
const totalMonthly = Math.round(property.areaSqm * property.rentPricePerSqm / 12)
|
||||||
|
const rawMonthlyPerSqm = property.rentPricePerSqm / 12
|
||||||
|
const monthlyPerSqmLabel = Number.isInteger(rawMonthlyPerSqm)
|
||||||
|
? `CHF ${rawMonthlyPerSqm}.–`
|
||||||
|
: `CHF ${rawMonthlyPerSqm.toLocaleString('de-CH', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
|
||||||
const minLettable = property.areaSqmMin
|
const minLettable = property.areaSqmMin
|
||||||
?? (flexibleUnits.length > 0
|
?? (flexibleUnits.length > 0
|
||||||
? Math.min(...flexibleUnits.map(u => u.minLettableSqm!))
|
? Math.min(...flexibleUnits.map(u => u.minLettableSqm!))
|
||||||
@@ -54,11 +59,11 @@ export function PropertyDetailPublicSections({ property, highlightUnitId }: Prop
|
|||||||
{/* ── Preis ── */}
|
{/* ── Preis ── */}
|
||||||
<Paper sx={{ mb: 2, p: 2.5 }}>
|
<Paper sx={{ mb: 2, p: 2.5 }}>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
|
||||||
<Tag size={15} color="#374151" />
|
<Tag size={15} color={DS_TEXT.secondary} />
|
||||||
<Typography variant="h6" sx={{ fontWeight: 700 }}>Preis</Typography>
|
<Typography variant="h6" sx={{ fontWeight: 700 }}>Preis</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
<KeyFactRow label="Monatliche Miete" value={`CHF ${totalMonthly.toLocaleString('de-CH')}.–`} />
|
<KeyFactRow label="Monatliche Miete" value={`CHF ${totalMonthly.toLocaleString('de-CH')}.–`} />
|
||||||
<KeyFactRow label="Pro m²/Monat" value={`CHF ${monthlyPerSqm.toLocaleString('de-CH')}.–`} />
|
<KeyFactRow label="Pro m²/Monat" value={monthlyPerSqmLabel} />
|
||||||
<KeyFactRow label="Pro m²/Jahr" value={`CHF ${property.rentPricePerSqm.toLocaleString('de-CH')}.–`} />
|
<KeyFactRow label="Pro m²/Jahr" value={`CHF ${property.rentPricePerSqm.toLocaleString('de-CH')}.–`} />
|
||||||
{property.ancillaryCosts != null && (
|
{property.ancillaryCosts != null && (
|
||||||
<KeyFactRow
|
<KeyFactRow
|
||||||
@@ -71,7 +76,7 @@ export function PropertyDetailPublicSections({ property, highlightUnitId }: Prop
|
|||||||
{/* ── Hauptangaben ── */}
|
{/* ── Hauptangaben ── */}
|
||||||
<Paper sx={{ mb: 2, p: 2.5 }}>
|
<Paper sx={{ mb: 2, p: 2.5 }}>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
|
||||||
<Info size={15} color="#374151" />
|
<Info size={15} color={DS_TEXT.secondary} />
|
||||||
<Typography variant="h6" sx={{ fontWeight: 700 }}>Hauptangaben</Typography>
|
<Typography variant="h6" sx={{ fontWeight: 700 }}>Hauptangaben</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
<KeyFactRow
|
<KeyFactRow
|
||||||
@@ -124,7 +129,7 @@ export function PropertyDetailPublicSections({ property, highlightUnitId }: Prop
|
|||||||
{property.softFactors && (
|
{property.softFactors && (
|
||||||
<Paper sx={{ mb: 2, p: 2.5 }}>
|
<Paper sx={{ mb: 2, p: 2.5 }}>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
|
||||||
<TrendingUp size={15} color="#374151" />
|
<TrendingUp size={15} color={DS_TEXT.secondary} />
|
||||||
<Typography variant="h6" sx={{ fontWeight: 700 }}>Eigenschaften</Typography>
|
<Typography variant="h6" sx={{ fontWeight: 700 }}>Eigenschaften</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
|
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
|
||||||
@@ -133,49 +138,49 @@ export function PropertyDetailPublicSections({ property, highlightUnitId }: Prop
|
|||||||
size="small"
|
size="small"
|
||||||
icon={<Train size={11} />}
|
icon={<Train size={11} />}
|
||||||
label={`ÖV: ${property.softFactors.publicTransportMinutes} Min. zu Fuss`}
|
label={`ÖV: ${property.softFactors.publicTransportMinutes} Min. zu Fuss`}
|
||||||
sx={{ bgcolor: '#eff6ff', color: '#1d4ed8', border: '1px solid #bfdbfe', fontWeight: 500 }}
|
sx={{ bgcolor: DS_SURFACE.blue.bg, color: DS_MARKET_SIGNAL.accent, border: `1px solid ${DS_SURFACE.blue.border}`, fontWeight: 500 }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{property.softFactors.parkingSpots != null && property.softFactors.parkingSpots > 0 && (
|
{property.softFactors.parkingSpots != null && property.softFactors.parkingSpots > 0 && (
|
||||||
<Chip
|
<Chip
|
||||||
size="small"
|
size="small"
|
||||||
label={`${property.softFactors.parkingSpots} Parkplätze`}
|
label={`${property.softFactors.parkingSpots} Parkplätze`}
|
||||||
sx={{ bgcolor: '#f8fafc', color: '#374151', border: '1px solid #e2e8f0', fontWeight: 500 }}
|
sx={{ bgcolor: DS_BG.page, color: DS_TEXT.primary, border: `1px solid ${DS_BORDER.default}`, fontWeight: 500 }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{property.softFactors.prestige != null && property.softFactors.prestige >= 80 && (
|
{property.softFactors.prestige != null && property.softFactors.prestige >= 80 && (
|
||||||
<Chip
|
<Chip
|
||||||
size="small"
|
size="small"
|
||||||
label="Prestigestandort"
|
label="Prestigestandort"
|
||||||
sx={{ bgcolor: '#fef9c3', color: '#92400e', border: '1px solid #fde68a', fontWeight: 500 }}
|
sx={{ bgcolor: DS_SURFACE.warning.bg, color: DS_TEXT.warningDark, border: `1px solid ${DS_SURFACE.warning.border}`, fontWeight: 500 }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{property.softFactors.visibilityScore != null && property.softFactors.visibilityScore >= 80 && (
|
{property.softFactors.visibilityScore != null && property.softFactors.visibilityScore >= 80 && (
|
||||||
<Chip
|
<Chip
|
||||||
size="small"
|
size="small"
|
||||||
label="Hohe Sichtbarkeit"
|
label="Hohe Sichtbarkeit"
|
||||||
sx={{ bgcolor: '#fef9c3', color: '#92400e', border: '1px solid #fde68a', fontWeight: 500 }}
|
sx={{ bgcolor: DS_SURFACE.warning.bg, color: DS_TEXT.warningDark, border: `1px solid ${DS_SURFACE.warning.border}`, fontWeight: 500 }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{property.softFactors.passerbyFrequency && (
|
{property.softFactors.passerbyFrequency && (
|
||||||
<Chip
|
<Chip
|
||||||
size="small"
|
size="small"
|
||||||
label={`Laufkundschaft: ${PASSERBY_LABELS[property.softFactors.passerbyFrequency]}`}
|
label={`Laufkundschaft: ${PASSERBY_LABELS[property.softFactors.passerbyFrequency]}`}
|
||||||
sx={{ bgcolor: '#f0fdf4', color: '#1a7a4a', border: '1px solid #bbf7d0', fontWeight: 500 }}
|
sx={{ bgcolor: DS_SURFACE.success.bg, color: DS_TEXT.success, border: `1px solid ${DS_SURFACE.success.border}`, fontWeight: 500 }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{property.softFactors.talentAccess != null && property.softFactors.talentAccess >= 80 && (
|
{property.softFactors.talentAccess != null && property.softFactors.talentAccess >= 80 && (
|
||||||
<Chip
|
<Chip
|
||||||
size="small"
|
size="small"
|
||||||
label="Hoher Talentzugang"
|
label="Hoher Talentzugang"
|
||||||
sx={{ bgcolor: '#faf5ff', color: '#5b21b6', border: '1px solid #e9d5ff', fontWeight: 500 }}
|
sx={{ bgcolor: DS_SURFACE.purple.bg, color: DS_TEXT.signalDark, border: `1px solid ${DS_SURFACE.purple.border}`, fontWeight: 500 }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{property.softFactors.talentAccess != null && property.softFactors.talentAccess >= 80 && (
|
{property.softFactors.talentAccess != null && property.softFactors.talentAccess >= 80 && (
|
||||||
<Chip
|
<Chip
|
||||||
size="small"
|
size="small"
|
||||||
label={`Talentindex: ${property.softFactors.talentAccess}`}
|
label={`Talentindex: ${property.softFactors.talentAccess}`}
|
||||||
sx={{ bgcolor: '#faf5ff', color: '#5b21b6', border: '1px solid #e9d5ff', fontWeight: 500 }}
|
sx={{ bgcolor: DS_SURFACE.purple.bg, color: DS_TEXT.signalDark, border: `1px solid ${DS_SURFACE.purple.border}`, fontWeight: 500 }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
@@ -186,12 +191,12 @@ export function PropertyDetailPublicSections({ property, highlightUnitId }: Prop
|
|||||||
{property.softFactors?.publicTransportMinutes != null && (
|
{property.softFactors?.publicTransportMinutes != null && (
|
||||||
<Paper sx={{ mb: 2, p: 2.5 }}>
|
<Paper sx={{ mb: 2, p: 2.5 }}>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
|
||||||
<Clock size={15} color="#374151" />
|
<Clock size={15} color={DS_TEXT.secondary} />
|
||||||
<Typography variant="h6" sx={{ fontWeight: 700 }}>Wegzeit</Typography>
|
<Typography variant="h6" sx={{ fontWeight: 700 }}>Wegzeit</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: 40, height: 40, borderRadius: '50%', bgcolor: '#eff6ff', border: '1px solid #bfdbfe', flexShrink: 0 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: 40, height: 40, borderRadius: '50%', bgcolor: DS_SURFACE.blue.bg, border: `1px solid ${DS_SURFACE.blue.border}`, flexShrink: 0 }}>
|
||||||
<Train size={18} color="#1d4ed8" />
|
<Train size={18} color={DS_MARKET_SIGNAL.accent} />
|
||||||
</Box>
|
</Box>
|
||||||
<Box>
|
<Box>
|
||||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>
|
<Typography variant="body2" sx={{ fontWeight: 600 }}>
|
||||||
@@ -217,7 +222,7 @@ export function PropertyDetailPublicSections({ property, highlightUnitId }: Prop
|
|||||||
{(property.units ?? []).length > 0 && (
|
{(property.units ?? []).length > 0 && (
|
||||||
<Paper sx={{ mb: 2, p: 2.5 }}>
|
<Paper sx={{ mb: 2, p: 2.5 }}>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
|
||||||
<Layers size={15} color="#374151" />
|
<Layers size={15} color={DS_TEXT.secondary} />
|
||||||
<Typography variant="h6" sx={{ fontWeight: 700 }}>Einheiten</Typography>
|
<Typography variant="h6" sx={{ fontWeight: 700 }}>Einheiten</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 80px 110px 120px auto', gap: 1.5, px: 2, mb: 0.75 }}>
|
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 80px 110px 120px auto', gap: 1.5, px: 2, mb: 0.75 }}>
|
||||||
@@ -234,14 +239,26 @@ export function PropertyDetailPublicSections({ property, highlightUnitId }: Prop
|
|||||||
</Paper>
|
</Paper>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* ── Grundriss ── */}
|
||||||
|
{(() => {
|
||||||
|
const allUnits = property.units ?? []
|
||||||
|
const plans: Array<{ url: string; label?: string }> = []
|
||||||
|
allUnits.filter(u => u.floorPlanUrl).forEach(u => {
|
||||||
|
const lbl = u.unitLabel ? `${FLOOR_LABEL(u.floorLevel)} ${u.unitLabel}` : FLOOR_LABEL(u.floorLevel)
|
||||||
|
plans.push({ url: u.floorPlanUrl!, label: allUnits.filter(x => x.floorPlanUrl).length > 1 ? lbl : undefined })
|
||||||
|
})
|
||||||
|
if (plans.length === 0 && property.floorPlanUrl) plans.push({ url: property.floorPlanUrl })
|
||||||
|
return <FloorPlanSection plans={plans} mb={2} />
|
||||||
|
})()}
|
||||||
|
|
||||||
{/* ── Beschreibung ── */}
|
{/* ── Beschreibung ── */}
|
||||||
{property.description && (
|
{property.description && (
|
||||||
<Paper sx={{ mb: 2, p: 2.5 }}>
|
<Paper sx={{ mb: 2, p: 2.5 }}>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
|
||||||
<Building2 size={15} color="#374151" />
|
<Building2 size={15} color={DS_TEXT.secondary} />
|
||||||
<Typography variant="h6" sx={{ fontWeight: 700 }}>Beschreibung</Typography>
|
<Typography variant="h6" sx={{ fontWeight: 700 }}>Beschreibung</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
<Typography variant="body2" sx={{ lineHeight: 1.75, color: '#374151', whiteSpace: 'pre-wrap' }}>
|
<Typography variant="body2" sx={{ lineHeight: 1.75, color: DS_TEXT.primary, whiteSpace: 'pre-wrap' }}>
|
||||||
{property.description}
|
{property.description}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Paper>
|
</Paper>
|
||||||
@@ -250,7 +267,7 @@ export function PropertyDetailPublicSections({ property, highlightUnitId }: Prop
|
|||||||
{/* ── Quelle & Referenz ── */}
|
{/* ── Quelle & Referenz ── */}
|
||||||
<Paper sx={{ mb: 2, p: 2.5 }}>
|
<Paper sx={{ mb: 2, p: 2.5 }}>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
|
||||||
<Info size={15} color="#374151" />
|
<Info size={15} color={DS_TEXT.secondary} />
|
||||||
<Typography variant="h6" sx={{ fontWeight: 700 }}>Quelle & Referenz</Typography>
|
<Typography variant="h6" sx={{ fontWeight: 700 }}>Quelle & Referenz</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
<KeyFactRow label="Datenquelle" value={sourceLabel} />
|
<KeyFactRow label="Datenquelle" value={sourceLabel} />
|
||||||
@@ -266,7 +283,7 @@ export function PropertyDetailPublicSections({ property, highlightUnitId }: Prop
|
|||||||
value={new Date(property.dataQuality.lastVerifiedAt).toLocaleDateString('de-CH', { day: 'numeric', month: 'long', year: 'numeric' })}
|
value={new Date(property.dataQuality.lastVerifiedAt).toLocaleDateString('de-CH', { day: 'numeric', month: 'long', year: 'numeric' })}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{property.sourceUrl && (
|
{property.sourceUrl && property.resultType === ResultType.MAISON_WORK && (
|
||||||
<Box sx={{ mt: 1.25 }}>
|
<Box sx={{ mt: 1.25 }}>
|
||||||
<Button
|
<Button
|
||||||
size="small"
|
size="small"
|
||||||
@@ -275,7 +292,7 @@ export function PropertyDetailPublicSections({ property, highlightUnitId }: Prop
|
|||||||
href={property.sourceUrl}
|
href={property.sourceUrl}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
sx={{ textTransform: 'none', fontSize: '0.8rem', borderColor: '#cbd5e1', color: '#374151' }}
|
sx={{ textTransform: 'none', fontSize: '0.8rem', borderColor: DS_BORDER.strong, color: DS_TEXT.primary }}
|
||||||
>
|
>
|
||||||
Zum Originalinserat
|
Zum Originalinserat
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ export function PropertyOverviewPanel({ match, property, signal }: Props) {
|
|||||||
] : [
|
] : [
|
||||||
{ icon: <MapPin size={15} />, label: 'Standort', value: property ? `${property.location.city}${property.location.district ? `, ${property.location.district}` : ''}` : '–' },
|
{ icon: <MapPin size={15} />, label: 'Standort', value: property ? `${property.location.city}${property.location.district ? `, ${property.location.district}` : ''}` : '–' },
|
||||||
{ icon: <Maximize2 size={15} />, label: 'Nutzfläche', value: property ? `${property.areaSqm} m²` : '–' },
|
{ icon: <Maximize2 size={15} />, label: 'Nutzfläche', value: property ? `${property.areaSqm} m²` : '–' },
|
||||||
{ icon: <Banknote size={15} />, label: 'Mietpreis', value: property?.rentPricePerSqm ? `CHF ${property.rentPricePerSqm}/m²` : '–' },
|
{ icon: <Banknote size={15} />, label: 'Mietpreis', value: property?.rentPricePerSqm ? `CHF ${property.rentPricePerSqm}/m²/Jahr` : '–' },
|
||||||
{ icon: <Calendar size={15} />, label: 'Verfügbar ab', value: property?.availabilityDate ?? '–' },
|
{ icon: <Calendar size={15} />, label: 'Verfügbar ab', value: property?.availabilityDate ?? '–' },
|
||||||
{ icon: <Tag size={15} />, label: 'Objekttyp', value: property?.assetType ?? '–' },
|
{ icon: <Tag size={15} />, label: 'Objekttyp', value: property?.assetType ?? '–' },
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -5,16 +5,9 @@ import type { Match } from '../../domain/match'
|
|||||||
import type { FutureSignal } from '../../domain/futureSignal'
|
import type { FutureSignal } from '../../domain/futureSignal'
|
||||||
import { CREDIBILITY_LABELS, HARD_KEYS, factorLabel } from './scoreBreakdownConstants'
|
import { CREDIBILITY_LABELS, HARD_KEYS, factorLabel } from './scoreBreakdownConstants'
|
||||||
import { CriterionRow } from './CriterionRow'
|
import { CriterionRow } from './CriterionRow'
|
||||||
|
import { criterionScoreColor, criterionScoreTextColor } from '../../lib/utils'
|
||||||
// ── Shared helpers ─────────────────────────────────────────────────────────────
|
import { DS_COLORS, DS_TEXT, DS_SURFACE } from '../../lib/ds'
|
||||||
|
import { SCORE_STRONG, SCORE_MODERATE } from '../../lib/constants'
|
||||||
function scoreColor(v: number): 'success' | 'warning' | 'error' {
|
|
||||||
return v >= 70 ? 'success' : v >= 50 ? 'warning' : 'error'
|
|
||||||
}
|
|
||||||
|
|
||||||
function scoreTextColor(v: number): string {
|
|
||||||
return v >= 70 ? '#1a7a4a' : v >= 50 ? '#d97706' : '#c0392b'
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Standard breakdown (VERIFIED_PORTFOLIO / EXTERNAL / MAISON) ──────────────
|
// ── Standard breakdown (VERIFIED_PORTFOLIO / EXTERNAL / MAISON) ──────────────
|
||||||
|
|
||||||
@@ -47,28 +40,28 @@ function StandardBreakdown({ match, taxCalculatorUrl }: StandardBreakdownProps)
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Hard criteria */}
|
{/* Hard criteria */}
|
||||||
<Typography variant="caption" sx={{ fontWeight: 700, color: '#1e3a5f', textTransform: 'uppercase', letterSpacing: 0.6, display: 'block', mb: 1.25 }}>
|
<Typography variant="caption" sx={{ fontWeight: 700, color: 'primary.main', textTransform: 'uppercase', letterSpacing: 0.6, display: 'block', mb: 1.25 }}>
|
||||||
{hasAllFactors ? 'Hart-Kriterien' : 'Bewertungsfaktoren'}
|
{hasAllFactors ? 'Hart-Kriterien' : 'Bewertungsfaktoren'}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
{hardFactors.map((f, i) => <CriterionRow key={i} factor={f} maxWeight={maxHardWeight} />)}
|
{hardFactors.map((f, i) => <CriterionRow key={i} factor={f} maxWeight={maxHardWeight} />)}
|
||||||
|
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', bgcolor: '#eef2ff', px: 1.25, py: 0.75, borderRadius: 1, mt: 0.25, mb: hasAllFactors && softFactors.length > 0 ? 2 : 1.5 }}>
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', bgcolor: DS_SURFACE.indigo.bg, px: 1.25, py: 0.75, borderRadius: 1, mt: 0.25, mb: hasAllFactors && softFactors.length > 0 ? 2 : 1.5 }}>
|
||||||
<Typography variant="caption" sx={{ fontWeight: 600, color: '#1e3a5f' }}>
|
<Typography variant="caption" sx={{ fontWeight: 600, color: 'primary.main' }}>
|
||||||
Hart-Kriterien {sb.hardMatchScore}/100 × 60%
|
Hart-Kriterien {sb.hardMatchScore}/100 × 60%
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="caption" sx={{ fontWeight: 800, color: '#1e3a5f' }}>
|
<Typography variant="caption" sx={{ fontWeight: 800, color: 'primary.main' }}>
|
||||||
{hardContrib} Pkt
|
{hardContrib} Pkt
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Soft factors: full list when allFactors present, otherwise summary row */}
|
{/* Soft factors: full list when allFactors present, otherwise summary row */}
|
||||||
{!hasAllFactors && (
|
{!hasAllFactors && (
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', bgcolor: '#f8fafc', px: 1.25, py: 0.75, borderRadius: 1, mb: 1.5 }}>
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', bgcolor: DS_SURFACE.neutral.bg, px: 1.25, py: 0.75, borderRadius: 1, mb: 1.5 }}>
|
||||||
<Typography variant="caption" sx={{ fontWeight: 600, color: '#475569' }}>
|
<Typography variant="caption" sx={{ fontWeight: 600, color: DS_TEXT.secondary }}>
|
||||||
Soft-Faktoren {sb.softFactorScore}/100 × 40%
|
Soft-Faktoren {sb.softFactorScore}/100 × 40%
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="caption" sx={{ fontWeight: 800, color: '#475569' }}>
|
<Typography variant="caption" sx={{ fontWeight: 800, color: DS_TEXT.secondary }}>
|
||||||
{softContrib} Pkt
|
{softContrib} Pkt
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -77,15 +70,15 @@ function StandardBreakdown({ match, taxCalculatorUrl }: StandardBreakdownProps)
|
|||||||
{hasAllFactors && softFactors.length > 0 && (
|
{hasAllFactors && softFactors.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<Divider sx={{ mb: 2 }} />
|
<Divider sx={{ mb: 2 }} />
|
||||||
<Typography variant="caption" sx={{ fontWeight: 700, color: '#475569', textTransform: 'uppercase', letterSpacing: 0.6, display: 'block', mb: 1.25 }}>
|
<Typography variant="caption" sx={{ fontWeight: 700, color: DS_TEXT.secondary, textTransform: 'uppercase', letterSpacing: 0.6, display: 'block', mb: 1.25 }}>
|
||||||
Soft-Faktoren
|
Soft-Faktoren
|
||||||
</Typography>
|
</Typography>
|
||||||
{softFactors.map((f, i) => <CriterionRow key={i} factor={f} maxWeight={maxSoftWeight} />)}
|
{softFactors.map((f, i) => <CriterionRow key={i} factor={f} maxWeight={maxSoftWeight} />)}
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', bgcolor: '#f8fafc', px: 1.25, py: 0.75, borderRadius: 1, mt: 0.25, mb: 1.5 }}>
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', bgcolor: DS_SURFACE.neutral.bg, px: 1.25, py: 0.75, borderRadius: 1, mt: 0.25, mb: 1.5 }}>
|
||||||
<Typography variant="caption" sx={{ fontWeight: 600, color: '#475569' }}>
|
<Typography variant="caption" sx={{ fontWeight: 600, color: DS_TEXT.secondary }}>
|
||||||
Soft-Score {sb.softFactorScore}/100 × 40%
|
Soft-Score {sb.softFactorScore}/100 × 40%
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="caption" sx={{ fontWeight: 800, color: '#475569' }}>
|
<Typography variant="caption" sx={{ fontWeight: 800, color: DS_TEXT.secondary }}>
|
||||||
{softContrib} Pkt
|
{softContrib} Pkt
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -94,10 +87,10 @@ function StandardBreakdown({ match, taxCalculatorUrl }: StandardBreakdownProps)
|
|||||||
|
|
||||||
{/* Tax link */}
|
{/* Tax link */}
|
||||||
{taxCalculatorUrl && (
|
{taxCalculatorUrl && (
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5, p: 1.25, bgcolor: '#f0f9ff', borderRadius: 1, border: '1px solid #bae6fd' }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5, p: 1.25, bgcolor: DS_SURFACE.info.bg, borderRadius: 1, border: `1px solid ${DS_SURFACE.info.border}` }}>
|
||||||
<ExternalLink size={13} color="#0369a1" style={{ flexShrink: 0 }} />
|
<ExternalLink size={13} color={DS_TEXT.info} style={{ flexShrink: 0 }} />
|
||||||
<Box>
|
<Box>
|
||||||
<Typography variant="caption" sx={{ color: '#0369a1', fontWeight: 600, display: 'block', lineHeight: 1.2 }}>
|
<Typography variant="caption" sx={{ color: DS_TEXT.info, fontWeight: 600, display: 'block', lineHeight: 1.2 }}>
|
||||||
Steuerlast in Bewertung eingeflossen
|
Steuerlast in Bewertung eingeflossen
|
||||||
</Typography>
|
</Typography>
|
||||||
<Link
|
<Link
|
||||||
@@ -105,7 +98,7 @@ function StandardBreakdown({ match, taxCalculatorUrl }: StandardBreakdownProps)
|
|||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
underline="hover"
|
underline="hover"
|
||||||
sx={{ fontSize: '0.72rem', color: '#0369a1' }}
|
sx={{ fontSize: '0.72rem', color: DS_TEXT.info }}
|
||||||
>
|
>
|
||||||
Steuerrechner Gemeinde öffnen →
|
Steuerrechner Gemeinde öffnen →
|
||||||
</Link>
|
</Link>
|
||||||
@@ -116,11 +109,11 @@ function StandardBreakdown({ match, taxCalculatorUrl }: StandardBreakdownProps)
|
|||||||
<Divider sx={{ mb: 1.5 }} />
|
<Divider sx={{ mb: 1.5 }} />
|
||||||
|
|
||||||
{/* Formula row — always visible */}
|
{/* Formula row — always visible */}
|
||||||
<Box sx={{ bgcolor: '#f8fafc', px: 1.25, py: 0.75, borderRadius: 1, mb: 1.25 }}>
|
<Box sx={{ bgcolor: DS_SURFACE.neutral.bg, px: 1.25, py: 0.75, borderRadius: 1, mb: 1.25 }}>
|
||||||
<Typography variant="caption" sx={{ color: '#64748b', display: 'block', mb: 0.4, fontWeight: 500 }}>
|
<Typography variant="caption" sx={{ color: DS_TEXT.muted, display: 'block', mb: 0.4, fontWeight: 500 }}>
|
||||||
Berechnung
|
Berechnung
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="caption" sx={{ fontFamily: 'monospace', color: '#1e293b', display: 'block', lineHeight: 1.6 }}>
|
<Typography variant="caption" sx={{ fontFamily: 'monospace', color: DS_TEXT.primary, display: 'block', lineHeight: 1.6 }}>
|
||||||
Hart {sb.hardMatchScore} × 60% + Soft {sb.softFactorScore} × 40%
|
Hart {sb.hardMatchScore} × 60% + Soft {sb.softFactorScore} × 40%
|
||||||
{' = '}{hardContrib} + {softContrib} = {baseSum}
|
{' = '}{hardContrib} + {softContrib} = {baseSum}
|
||||||
</Typography>
|
</Typography>
|
||||||
@@ -129,13 +122,13 @@ function StandardBreakdown({ match, taxCalculatorUrl }: StandardBreakdownProps)
|
|||||||
{/* Total */}
|
{/* Total */}
|
||||||
<Box sx={{
|
<Box sx={{
|
||||||
display: 'flex', alignItems: 'baseline', justifyContent: 'space-between',
|
display: 'flex', alignItems: 'baseline', justifyContent: 'space-between',
|
||||||
bgcolor: baseSum >= 78 ? '#f0fdf4' : baseSum >= 52 ? '#fffbeb' : '#fef2f2',
|
bgcolor: baseSum >= SCORE_STRONG ? DS_SURFACE.success.bg : baseSum >= SCORE_MODERATE ? DS_SURFACE.warning.bg : DS_SURFACE.error.bg,
|
||||||
p: 1.5, borderRadius: 1,
|
p: 1.5, borderRadius: 1,
|
||||||
}}>
|
}}>
|
||||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>Gesamt-Score</Typography>
|
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>Gesamt-Score</Typography>
|
||||||
<Typography
|
<Typography
|
||||||
variant="h4"
|
variant="h4"
|
||||||
sx={{ fontWeight: 800, color: scoreTextColor(Math.round(baseSum)) }}
|
sx={{ fontWeight: 800, color: criterionScoreTextColor(Math.round(baseSum)) }}
|
||||||
>
|
>
|
||||||
{baseSum}/100
|
{baseSum}/100
|
||||||
</Typography>
|
</Typography>
|
||||||
@@ -170,7 +163,7 @@ function SignalScoreDerivation({ match, signal }: SignalScoreDerivationProps) {
|
|||||||
<>
|
<>
|
||||||
{/* Schritt 1 — Kriterien-Match */}
|
{/* Schritt 1 — Kriterien-Match */}
|
||||||
<Box sx={{ mb: 2 }}>
|
<Box sx={{ mb: 2 }}>
|
||||||
<Typography variant="caption" sx={{ fontWeight: 700, color: '#6d28d9', textTransform: 'uppercase', letterSpacing: 0.6, display: 'block', mb: 0.75 }}>
|
<Typography variant="caption" sx={{ fontWeight: 700, color: DS_COLORS.futureCard.controlled.accent, textTransform: 'uppercase', letterSpacing: 0.6, display: 'block', mb: 0.75 }}>
|
||||||
Schritt 1 — Kriterien-Match
|
Schritt 1 — Kriterien-Match
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 1.25, lineHeight: 1.4 }}>
|
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 1.25, lineHeight: 1.4 }}>
|
||||||
@@ -183,18 +176,18 @@ function SignalScoreDerivation({ match, signal }: SignalScoreDerivationProps) {
|
|||||||
<Box key={i} sx={{ mb: 1.25 }}>
|
<Box key={i} sx={{ mb: 1.25 }}>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.6, mb: 0.4 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.6, mb: 0.4 }}>
|
||||||
{isPositive
|
{isPositive
|
||||||
? <CheckCircle2 size={12} color="#1a7a4a" style={{ flexShrink: 0 }} />
|
? <CheckCircle2 size={12} color={DS_TEXT.success} style={{ flexShrink: 0 }} />
|
||||||
: <X size={12} color="#c0392b" style={{ flexShrink: 0 }} />
|
: <X size={12} color={DS_TEXT.error} style={{ flexShrink: 0 }} />
|
||||||
}
|
}
|
||||||
<Box sx={{ flex: 1, display: 'flex', justifyContent: 'space-between', alignItems: 'baseline' }}>
|
<Box sx={{ flex: 1, display: 'flex', justifyContent: 'space-between', alignItems: 'baseline' }}>
|
||||||
<Typography variant="caption" sx={{ fontWeight: 600, color: '#1e293b' }}>{factorLabel(f.criterion)}</Typography>
|
<Typography variant="caption" sx={{ fontWeight: 600, color: DS_TEXT.primary }}>{factorLabel(f.criterion)}</Typography>
|
||||||
<Typography variant="caption" sx={{ fontWeight: 700, color: scoreTextColor(f.score), ml: 1 }}>{f.score}/100</Typography>
|
<Typography variant="caption" sx={{ fontWeight: 700, color: criterionScoreTextColor(f.score), ml: 1 }}>{f.score}/100</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
<LinearProgress
|
<LinearProgress
|
||||||
variant="determinate"
|
variant="determinate"
|
||||||
value={f.score}
|
value={f.score}
|
||||||
color={scoreColor(f.score)}
|
color={criterionScoreColor(f.score)}
|
||||||
sx={{ height: 5, borderRadius: 3, mb: 0.35, ml: 2.25 }}
|
sx={{ height: 5, borderRadius: 3, mb: 0.35, ml: 2.25 }}
|
||||||
/>
|
/>
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', ml: 2.25, lineHeight: 1.3 }}>
|
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', ml: 2.25, lineHeight: 1.3 }}>
|
||||||
@@ -204,9 +197,9 @@ function SignalScoreDerivation({ match, signal }: SignalScoreDerivationProps) {
|
|||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', bgcolor: '#f8fafc', px: 1.25, py: 0.75, borderRadius: 1, mt: 0.5 }}>
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', bgcolor: DS_SURFACE.neutral.bg, px: 1.25, py: 0.75, borderRadius: 1, mt: 0.5 }}>
|
||||||
<Typography variant="caption" sx={{ fontWeight: 600, color: '#475569' }}>Basis-Score</Typography>
|
<Typography variant="caption" sx={{ fontWeight: 600, color: DS_TEXT.secondary }}>Basis-Score</Typography>
|
||||||
<Typography variant="caption" sx={{ fontWeight: 800, color: '#1e293b' }}>{sb.hardMatchScore}/100</Typography>
|
<Typography variant="caption" sx={{ fontWeight: 800, color: DS_TEXT.primary }}>{sb.hardMatchScore}/100</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
@@ -214,27 +207,27 @@ function SignalScoreDerivation({ match, signal }: SignalScoreDerivationProps) {
|
|||||||
|
|
||||||
{/* Schritt 2 — Verlässlichkeit */}
|
{/* Schritt 2 — Verlässlichkeit */}
|
||||||
<Box sx={{ mb: 2 }}>
|
<Box sx={{ mb: 2 }}>
|
||||||
<Typography variant="caption" sx={{ fontWeight: 700, color: '#6d28d9', textTransform: 'uppercase', letterSpacing: 0.6, display: 'block', mb: 0.75 }}>
|
<Typography variant="caption" sx={{ fontWeight: 700, color: DS_COLORS.futureCard.controlled.accent, textTransform: 'uppercase', letterSpacing: 0.6, display: 'block', mb: 0.75 }}>
|
||||||
Schritt 2 — Verlässlichkeit des Signals
|
Schritt 2 — Verlässlichkeit des Signals
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
{isVerifiedContract ? (
|
{isVerifiedContract ? (
|
||||||
<>
|
<>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mb: 0.75 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mb: 0.75 }}>
|
||||||
<ShieldCheck size={13} color="#1a7a4a" style={{ flexShrink: 0 }} />
|
<ShieldCheck size={13} color={DS_TEXT.success} style={{ flexShrink: 0 }} />
|
||||||
<Typography variant="caption" sx={{ color: '#1a7a4a', lineHeight: 1.3 }}>
|
<Typography variant="caption" sx={{ color: DS_TEXT.success, lineHeight: 1.3 }}>
|
||||||
Vertragsende aus ERP verifiziert — keine Schätzung
|
Vertragsende aus ERP verifiziert — keine Schätzung
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mb: 1 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mb: 1 }}>
|
||||||
<ShieldCheck size={13} color="#1a7a4a" style={{ flexShrink: 0 }} />
|
<ShieldCheck size={13} color={DS_TEXT.success} style={{ flexShrink: 0 }} />
|
||||||
<Typography variant="caption" sx={{ color: '#1a7a4a', lineHeight: 1.3 }}>
|
<Typography variant="caption" sx={{ color: DS_TEXT.success, lineHeight: 1.3 }}>
|
||||||
Verwaltung hat Freigabe erteilt
|
Verwaltung hat Freigabe erteilt
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', bgcolor: '#f0fdf4', px: 1.25, py: 0.75, borderRadius: 1, border: '1px solid #bbf7d0' }}>
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', bgcolor: DS_SURFACE.success.bg, px: 1.25, py: 0.75, borderRadius: 1, border: `1px solid ${DS_SURFACE.success.border}` }}>
|
||||||
<Typography variant="caption" sx={{ fontWeight: 600, color: '#1a7a4a' }}>Signal-Abschlag</Typography>
|
<Typography variant="caption" sx={{ fontWeight: 600, color: DS_TEXT.success }}>Signal-Abschlag</Typography>
|
||||||
<Typography variant="caption" sx={{ fontWeight: 800, color: '#1a7a4a' }}>keiner</Typography>
|
<Typography variant="caption" sx={{ fontWeight: 800, color: DS_TEXT.success }}>keiner</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
@@ -242,18 +235,18 @@ function SignalScoreDerivation({ match, signal }: SignalScoreDerivationProps) {
|
|||||||
{probPct !== null && (
|
{probPct !== null && (
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', mb: 0.5 }}>
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', mb: 0.5 }}>
|
||||||
<Typography variant="caption" color="text.secondary">Eintretenswahrscheinlichkeit: {probPct}%</Typography>
|
<Typography variant="caption" color="text.secondary">Eintretenswahrscheinlichkeit: {probPct}%</Typography>
|
||||||
<Typography variant="caption" sx={{ fontWeight: 600, color: '#c0392b' }}>–{Math.round(signalDeduction * 0.7)} Pkt.</Typography>
|
<Typography variant="caption" sx={{ fontWeight: 600, color: DS_TEXT.error }}>–{Math.round(signalDeduction * 0.7)} Pkt.</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
{credLabel && (
|
{credLabel && (
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', mb: 1 }}>
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', mb: 1 }}>
|
||||||
<Typography variant="caption" color="text.secondary">Quellenqualität: {credLabel}</Typography>
|
<Typography variant="caption" color="text.secondary">Quellenqualität: {credLabel}</Typography>
|
||||||
<Typography variant="caption" sx={{ fontWeight: 600, color: '#d97706' }}>–{Math.round(signalDeduction * 0.3)} Pkt.</Typography>
|
<Typography variant="caption" sx={{ fontWeight: 600, color: DS_TEXT.warning }}>–{Math.round(signalDeduction * 0.3)} Pkt.</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', bgcolor: '#fff7ed', px: 1.25, py: 0.75, borderRadius: 1, border: '1px solid #fed7aa' }}>
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', bgcolor: DS_SURFACE.orange.bg, px: 1.25, py: 0.75, borderRadius: 1, border: `1px solid ${DS_SURFACE.orange.border}` }}>
|
||||||
<Typography variant="caption" sx={{ fontWeight: 600, color: '#d97706' }}>Signal-Abschlag</Typography>
|
<Typography variant="caption" sx={{ fontWeight: 600, color: DS_TEXT.warning }}>Signal-Abschlag</Typography>
|
||||||
<Typography variant="caption" sx={{ fontWeight: 800, color: '#d97706' }}>–{signalDeduction} Punkte</Typography>
|
<Typography variant="caption" sx={{ fontWeight: 800, color: DS_TEXT.warning }}>–{signalDeduction} Punkte</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -262,11 +255,11 @@ function SignalScoreDerivation({ match, signal }: SignalScoreDerivationProps) {
|
|||||||
<Divider sx={{ mb: 1.5 }} />
|
<Divider sx={{ mb: 1.5 }} />
|
||||||
|
|
||||||
{/* Gesamt */}
|
{/* Gesamt */}
|
||||||
<Box sx={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', bgcolor: '#f8fafc', p: 1.5, borderRadius: 1 }}>
|
<Box sx={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', bgcolor: DS_SURFACE.neutral.bg, p: 1.5, borderRadius: 1 }}>
|
||||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>Gesamt-Score</Typography>
|
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>Gesamt-Score</Typography>
|
||||||
<Typography
|
<Typography
|
||||||
variant="h4"
|
variant="h4"
|
||||||
sx={{ fontWeight: 800, color: scoreTextColor(sb.totalScore) }}
|
sx={{ fontWeight: 800, color: criterionScoreTextColor(sb.totalScore) }}
|
||||||
>
|
>
|
||||||
{sb.totalScore}/100
|
{sb.totalScore}/100
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import { Box, Button, Typography } from '@mui/material'
|
||||||
|
import { Calendar, Clock, ExternalLink, Globe, ShieldCheck } from 'lucide-react'
|
||||||
|
import type { FutureSignal } from '../../domain/futureSignal'
|
||||||
|
import { domainLabel } from './futureAvailabilityContextUtils'
|
||||||
|
|
||||||
|
interface SignalSourcesSectionProps {
|
||||||
|
sourceMeta: { label: string; icon: React.ReactNode; badge?: string; description?: string } | null
|
||||||
|
credMeta: { label: string; color: string } | null
|
||||||
|
isVerifiedContract: boolean
|
||||||
|
allSourceUrls: string[]
|
||||||
|
signal: FutureSignal
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SignalSourcesSection({
|
||||||
|
sourceMeta,
|
||||||
|
credMeta,
|
||||||
|
isVerifiedContract,
|
||||||
|
allSourceUrls,
|
||||||
|
signal,
|
||||||
|
}: SignalSourcesSectionProps) {
|
||||||
|
return (
|
||||||
|
<Box sx={{ mb: 2.5 }}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mb: 1.25 }}>
|
||||||
|
<Globe size={14} color="#0369a1" />
|
||||||
|
<Typography variant="subtitle2" sx={{ fontWeight: 700, fontSize: '0.8125rem' }}>Quellen & Belege</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Primary source metadata — always shown */}
|
||||||
|
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1, mb: 1.25 }}>
|
||||||
|
{sourceMeta && (
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, bgcolor: isVerifiedContract ? '#f0fdf4' : '#ede9fe', color: isVerifiedContract ? '#1a7a4a' : '#6d28d9', borderRadius: 1, px: 1, py: 0.5 }}>
|
||||||
|
{sourceMeta.icon}
|
||||||
|
<Typography sx={{ fontSize: '0.78rem', fontWeight: 600 }}>{sourceMeta.label}</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
{credMeta && (
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, bgcolor: '#f8fafc', border: '1px solid #e2e8f0', borderRadius: 1, px: 0.875, py: 0.4 }}>
|
||||||
|
<Box sx={{ width: 7, height: 7, borderRadius: '50%', bgcolor: credMeta.color }} />
|
||||||
|
<Typography sx={{ fontSize: '0.75rem', color: credMeta.color, fontWeight: 600 }}>{credMeta.label}</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
{signal.source.publishedAt && (
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, bgcolor: '#f8fafc', border: '1px solid #e2e8f0', borderRadius: 1, px: 0.875, py: 0.4 }}>
|
||||||
|
<Calendar size={11} color="#64748b" />
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
{new Date(signal.source.publishedAt).toLocaleDateString('de-CH', { day: '2-digit', month: 'long', year: 'numeric' })}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Verified contract special box */}
|
||||||
|
{isVerifiedContract && (
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5, bgcolor: '#f0fdf4', border: '1px solid #bbf7d0', borderRadius: 1, px: 1.25, py: 1, mb: 1.25 }}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
|
||||||
|
<ShieldCheck size={13} color="#1a7a4a" />
|
||||||
|
<Typography variant="caption" sx={{ color: '#1a7a4a', fontWeight: 600 }}>Vertragsende aus internem ERP bestätigt</Typography>
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
|
||||||
|
<ShieldCheck size={13} color="#1a7a4a" />
|
||||||
|
<Typography variant="caption" sx={{ color: '#1a7a4a', fontWeight: 600 }}>Verwaltung hat Marktfreigabe erteilt</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* All source URLs */}
|
||||||
|
{allSourceUrls.length > 0 ? (
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.625 }}>
|
||||||
|
{allSourceUrls.map((url, i) => (
|
||||||
|
<Button
|
||||||
|
key={i}
|
||||||
|
component="a"
|
||||||
|
href={url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
startIcon={<ExternalLink size={12} />}
|
||||||
|
sx={{
|
||||||
|
textTransform: 'none', justifyContent: 'flex-start',
|
||||||
|
color: '#0369a1', borderColor: '#bae6fd',
|
||||||
|
'&:hover': { borderColor: '#0369a1', bgcolor: '#f0f9ff' },
|
||||||
|
fontSize: '0.78rem', fontWeight: 500,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{domainLabel(url)}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<Box sx={{ bgcolor: '#f8fafc', border: '1px solid #e2e8f0', borderRadius: 1, px: 1.25, py: 0.875 }}>
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
Kein direkter Quellenlink verfügbar — Signal basiert auf aggregierten {sourceMeta?.label ?? 'Marktdaten'}.
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Evidence extraction date */}
|
||||||
|
{signal.evidence?.extractedAt && (
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mt: 0.875 }}>
|
||||||
|
<Clock size={11} color="#94a3b8" />
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
Daten extrahiert: {new Date(signal.evidence.extractedAt).toLocaleDateString('de-CH', { day: '2-digit', month: 'short', year: 'numeric' })}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Evidence summary */}
|
||||||
|
{signal.evidence?.summary && (
|
||||||
|
<Box sx={{ bgcolor: '#f8fafc', border: '1px solid #e2e8f0', borderRadius: 1, p: 1.25, mt: 1 }}>
|
||||||
|
<Typography variant="caption" sx={{ fontWeight: 600, color: '#475569', display: 'block', mb: 0.3 }}>Evidenz-Zusammenfassung</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ lineHeight: 1.55 }}>{signal.evidence.summary}</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
export const CREDIBILITY_META: Record<string, { label: string; color: string }> = {
|
||||||
|
HIGH: { label: 'Hohe Quellenqualität', color: '#1a7a4a' },
|
||||||
|
MEDIUM: { label: 'Mittlere Quellenqualität', color: '#d97706' },
|
||||||
|
LOW: { label: 'Niedrige Quellenqualität', color: '#c0392b' },
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SENSITIVITY_META: Record<string, { label: string; color: 'error' | 'warning' | 'default' }> = {
|
||||||
|
CONFIDENTIAL: { label: 'Vertraulich', color: 'error' },
|
||||||
|
INTERNAL: { label: 'Intern', color: 'warning' },
|
||||||
|
PUBLIC: { label: 'Öffentlich', color: 'default' },
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RISK_META: Record<string, { label: string; color: string }> = {
|
||||||
|
LOW: { label: 'Niedrig', color: '#1a7a4a' },
|
||||||
|
MEDIUM: { label: 'Mittel', color: '#d97706' },
|
||||||
|
HIGH: { label: 'Hoch', color: '#c0392b' },
|
||||||
|
CRITICAL: { label: 'Kritisch', color: '#7f1d1d' },
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ageLabel(isoDate: string): string {
|
||||||
|
const diffMs = Date.now() - new Date(isoDate).getTime()
|
||||||
|
const days = Math.floor(diffMs / 86400000)
|
||||||
|
if (days < 1) return 'Heute erkannt'
|
||||||
|
if (days < 7) return `Vor ${days} Tag${days === 1 ? '' : 'en'} erkannt`
|
||||||
|
if (days < 30) return `Vor ${Math.floor(days / 7)} Woche${Math.floor(days / 7) === 1 ? '' : 'n'} erkannt`
|
||||||
|
if (days < 365) return `Vor ${Math.floor(days / 30)} Monat${Math.floor(days / 30) === 1 ? '' : 'en'} erkannt`
|
||||||
|
return `Vor ${Math.floor(days / 365)} Jahr${Math.floor(days / 365) === 1 ? '' : 'en'} erkannt`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function domainLabel(url: string): string {
|
||||||
|
try {
|
||||||
|
const u = new URL(url)
|
||||||
|
return u.hostname.replace(/^www\./, '')
|
||||||
|
} catch {
|
||||||
|
return url.length > 60 ? url.slice(0, 57) + '…' : url
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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'
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
export const NEW_PROJECTS: Record<string, { title: string; area: string; completion: string; note: string }[]> = {
|
||||||
|
'Zürich': [
|
||||||
|
{ title: 'Ensemble Zürich-West', area: '500–2000 m²', completion: 'Q3 2026', note: 'Büroflächen im Neubauprojekt, Kreis 5' },
|
||||||
|
{ title: 'The Circle Phase II', area: '1000–5000 m²', completion: 'Q1 2027', note: 'Premium-Büros, Flughafen Zürich' },
|
||||||
|
],
|
||||||
|
'Basel': [
|
||||||
|
{ title: 'Basel SBB Tower', area: '300–1500 m²', completion: 'Q4 2026', note: 'Gemischte Nutzung, zentrale Lage' },
|
||||||
|
{ title: 'Erlenmatt Ost', area: '600–2500 m²', completion: 'Q2 2027', note: 'Modernes Stadtentwicklungsareal' },
|
||||||
|
],
|
||||||
|
'Zug': [
|
||||||
|
{ title: 'Zug Innovation Campus', area: '200–1000 m²', completion: 'Q1 2026', note: 'Steuerattraktiv, ÖV-optimal' },
|
||||||
|
],
|
||||||
|
'Bern': [
|
||||||
|
{ title: 'Bern West Business Park', area: '400–3000 m²', completion: 'Q2 2026', note: 'Modernes Gewerbeareal Ausserholligen' },
|
||||||
|
],
|
||||||
|
'Winterthur': [
|
||||||
|
{ title: 'Sulzerareal Phase 4', area: '800–4000 m²', completion: 'Q3 2027', note: 'Industrie-Loft-Flächen im Stadtentwicklungsgebiet' },
|
||||||
|
],
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { Box, Card, TextField, Typography } from '@mui/material'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
street: string
|
||||||
|
onStreetChange: (v: string) => void
|
||||||
|
houseNumber: string
|
||||||
|
onHouseNumberChange: (v: string) => void
|
||||||
|
postalCode: string
|
||||||
|
onPostalCodeChange: (v: string) => void
|
||||||
|
city: string
|
||||||
|
onCityChange: (v: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AddressSection({
|
||||||
|
street, onStreetChange,
|
||||||
|
houseNumber, onHouseNumberChange,
|
||||||
|
postalCode, onPostalCodeChange,
|
||||||
|
city, onCityChange,
|
||||||
|
}: Props) {
|
||||||
|
return (
|
||||||
|
<Card sx={{ p: 3, mb: 3 }}>
|
||||||
|
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 2 }}>Adresse</Typography>
|
||||||
|
<Box sx={{ display: 'grid', gridTemplateColumns: '3fr 1fr', gap: 2, mb: 2 }}>
|
||||||
|
<TextField
|
||||||
|
label="Strasse" value={street}
|
||||||
|
onChange={e => onStreetChange(e.target.value)} size="small" fullWidth
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Nr." value={houseNumber}
|
||||||
|
onChange={e => onHouseNumberChange(e.target.value)} size="small" fullWidth
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 2fr', gap: 2 }}>
|
||||||
|
<TextField
|
||||||
|
label="PLZ" value={postalCode}
|
||||||
|
onChange={e => onPostalCodeChange(e.target.value)} size="small" fullWidth
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Ort" value={city}
|
||||||
|
onChange={e => onCityChange(e.target.value)} size="small" fullWidth
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { Alert, Box, Button, Card, CircularProgress, TextField, Typography } from '@mui/material'
|
||||||
|
import { Sparkles } from 'lucide-react'
|
||||||
|
import { DS_TEXT, DS_SURFACE, DS_BORDER } from '../../lib/ds'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
text: string
|
||||||
|
onTextChange: (v: string) => void
|
||||||
|
onParse: () => void
|
||||||
|
parsing: boolean
|
||||||
|
applied: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AiAssistCard({ text, onTextChange, onParse, parsing, applied }: Props) {
|
||||||
|
return (
|
||||||
|
<Card sx={{ p: 3, mb: 3, border: `1px solid ${DS_SURFACE.indigo.border}`, bgcolor: DS_SURFACE.indigo.bg }}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
|
||||||
|
<Sparkles size={16} color={DS_TEXT.brand} />
|
||||||
|
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: DS_TEXT.brand }}>
|
||||||
|
KI-Hilfe — Formular automatisch ausfüllen
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 1.5 }}>
|
||||||
|
Beschreiben Sie die Fläche in eigenen Worten — die KI füllt die Felder automatisch aus.
|
||||||
|
</Typography>
|
||||||
|
<TextField
|
||||||
|
multiline
|
||||||
|
rows={3}
|
||||||
|
fullWidth
|
||||||
|
size="small"
|
||||||
|
placeholder="z.B.: Bürofläche 450 m² im Zentrum Zürich, 3. OG, CHF 280/m²/Jahr, sehr gute ÖV-Anbindung, 4 Parkplätze, Vollausbau, verfügbar ab Juli 2025"
|
||||||
|
value={text}
|
||||||
|
onChange={e => onTextChange(e.target.value)}
|
||||||
|
sx={{ mb: 1.5, bgcolor: 'white' }}
|
||||||
|
/>
|
||||||
|
{applied && (
|
||||||
|
<Alert severity="success" sx={{ mb: 1.5, py: 0.5 }}>
|
||||||
|
Felder wurden automatisch ausgefüllt — bitte überprüfen und ergänzen.
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
size="small"
|
||||||
|
startIcon={parsing ? <CircularProgress size={13} color="inherit" /> : <Sparkles size={13} />}
|
||||||
|
onClick={onParse}
|
||||||
|
disabled={!text.trim() || parsing}
|
||||||
|
sx={{ bgcolor: DS_TEXT.brand, '&:hover': { bgcolor: DS_BORDER.strong }, textTransform: 'none' }}
|
||||||
|
>
|
||||||
|
{parsing ? 'Analysiert…' : 'KI analysieren'}
|
||||||
|
</Button>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { Box, Card, MenuItem, TextField, Typography } from '@mui/material'
|
||||||
|
import { ASSET_TYPE_LABELS } from '../../pages/supply/newListingConstants'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
assetType: string
|
||||||
|
onAssetTypeChange: (v: string) => void
|
||||||
|
areaSqm: string
|
||||||
|
onAreaSqmChange: (v: string) => void
|
||||||
|
rentPerSqm: string
|
||||||
|
onRentPerSqmChange: (v: string) => void
|
||||||
|
availableFrom: string
|
||||||
|
onAvailableFromChange: (v: string) => void
|
||||||
|
description: string
|
||||||
|
onDescriptionChange: (v: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AreaDetailsSection({
|
||||||
|
assetType, onAssetTypeChange,
|
||||||
|
areaSqm, onAreaSqmChange,
|
||||||
|
rentPerSqm, onRentPerSqmChange,
|
||||||
|
availableFrom, onAvailableFromChange,
|
||||||
|
description, onDescriptionChange,
|
||||||
|
}: Props) {
|
||||||
|
return (
|
||||||
|
<Card sx={{ p: 3, mb: 3 }}>
|
||||||
|
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 2 }}>Flächendetails</Typography>
|
||||||
|
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 2 }}>
|
||||||
|
<TextField
|
||||||
|
select label="Flächentyp" value={assetType}
|
||||||
|
onChange={e => onAssetTypeChange(e.target.value)} size="small" fullWidth
|
||||||
|
>
|
||||||
|
{Object.entries(ASSET_TYPE_LABELS).map(([v, l]) => (
|
||||||
|
<MenuItem key={v} value={v}>{l}</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
label="Fläche (m²)" value={areaSqm}
|
||||||
|
onChange={e => onAreaSqmChange(e.target.value)}
|
||||||
|
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" slotProps={{ htmlInput: { min: 1 } }} fullWidth
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
label="Verfügbar ab" value={availableFrom}
|
||||||
|
onChange={e => onAvailableFromChange(e.target.value)}
|
||||||
|
size="small" type="date"
|
||||||
|
slotProps={{ inputLabel: { shrink: true } }} fullWidth
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
<TextField
|
||||||
|
label="Beschreibung (optional)" value={description}
|
||||||
|
onChange={e => onDescriptionChange(e.target.value)}
|
||||||
|
size="small" multiline rows={3} fullWidth sx={{ mt: 2 }}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { Box, Card, TextField, Typography } from '@mui/material'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
name: string
|
||||||
|
onNameChange: (v: string) => void
|
||||||
|
email: string
|
||||||
|
onEmailChange: (v: string) => void
|
||||||
|
phone: string
|
||||||
|
onPhoneChange: (v: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ContactSection({ name, onNameChange, email, onEmailChange, phone, onPhoneChange }: Props) {
|
||||||
|
return (
|
||||||
|
<Card sx={{ p: 3, mb: 3 }}>
|
||||||
|
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 2 }}>Kontakt (optional)</Typography>
|
||||||
|
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 2 }}>
|
||||||
|
<TextField
|
||||||
|
label="Name" value={name}
|
||||||
|
onChange={e => onNameChange(e.target.value)} size="small" fullWidth
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Telefon" value={phone}
|
||||||
|
onChange={e => onPhoneChange(e.target.value)} size="small" fullWidth
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="E-Mail" value={email}
|
||||||
|
onChange={e => onEmailChange(e.target.value)}
|
||||||
|
size="small" type="email" fullWidth sx={{ gridColumn: '1 / -1' }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { Box, Button, Typography } from '@mui/material'
|
||||||
|
import { CheckCircle } from 'lucide-react'
|
||||||
|
import { DS_TEXT } from '../../lib/ds'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onViewListings: () => void
|
||||||
|
onCreateAnother: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CreatedScreen({ onViewListings, onCreateAnother }: Props) {
|
||||||
|
return (
|
||||||
|
<Box sx={{ maxWidth: 520, mx: 'auto', mt: 8, px: 3, textAlign: 'center' }}>
|
||||||
|
<CheckCircle size={48} color={DS_TEXT.success} style={{ marginBottom: 16 }} />
|
||||||
|
<Typography variant="h5" sx={{ fontWeight: 700, mb: 1 }}>Inserat erstellt</Typography>
|
||||||
|
<Typography color="text.secondary" sx={{ mb: 3 }}>
|
||||||
|
Das Inserat wurde veröffentlicht und ist für passende Suchanfragen sichtbar.
|
||||||
|
</Typography>
|
||||||
|
<Box sx={{ display: 'flex', gap: 2, justifyContent: 'center' }}>
|
||||||
|
<Button variant="outlined" onClick={onViewListings}>
|
||||||
|
Meine Inserate
|
||||||
|
</Button>
|
||||||
|
<Button variant="contained" sx={{ bgcolor: DS_TEXT.brand, '&:hover': { bgcolor: DS_TEXT.brandDark } }} onClick={onCreateAnother}>
|
||||||
|
Weiteres Inserat
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { Box, Card, TextField, Typography } from '@mui/material'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
floorPlanUrl: string
|
||||||
|
onFloorPlanUrlChange: (v: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FloorPlanUrlSection({ floorPlanUrl, onFloorPlanUrlChange }: Props) {
|
||||||
|
return (
|
||||||
|
<Card sx={{ p: 3, mb: 3 }}>
|
||||||
|
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 0.5 }}>Grundriss (optional)</Typography>
|
||||||
|
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 2 }}>
|
||||||
|
URL zu einem Grundrissbild — hilft Interessenten, sich die Fläche vorzustellen.
|
||||||
|
</Typography>
|
||||||
|
<TextField
|
||||||
|
label="Grundriss-URL"
|
||||||
|
value={floorPlanUrl}
|
||||||
|
onChange={e => onFloorPlanUrlChange(e.target.value)}
|
||||||
|
size="small"
|
||||||
|
fullWidth
|
||||||
|
placeholder="https://…"
|
||||||
|
/>
|
||||||
|
{floorPlanUrl && (
|
||||||
|
<Box
|
||||||
|
component="img"
|
||||||
|
src={floorPlanUrl}
|
||||||
|
alt="Grundriss Vorschau"
|
||||||
|
sx={{ mt: 2, width: '100%', maxHeight: 300, objectFit: 'contain', borderRadius: 1, border: '1px solid rgba(0,0,0,0.08)', bgcolor: '#f8fafc' }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { Box, Button, Card, Chip, TextField, Typography } from '@mui/material'
|
||||||
|
import { ImagePlus } from 'lucide-react'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
images: string[]
|
||||||
|
imageInput: string
|
||||||
|
onImageInputChange: (v: string) => void
|
||||||
|
onAdd: () => void
|
||||||
|
onRemove: (index: number) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ImageUrlSection({ images, imageInput, onImageInputChange, onAdd, onRemove }: Props) {
|
||||||
|
return (
|
||||||
|
<Card sx={{ p: 3, mb: 3 }}>
|
||||||
|
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 2 }}>Bilder (optional)</Typography>
|
||||||
|
<Box sx={{ display: 'flex', gap: 1, mb: 1.5 }}>
|
||||||
|
<TextField
|
||||||
|
label="Bild-URL eingeben"
|
||||||
|
value={imageInput}
|
||||||
|
onChange={e => onImageInputChange(e.target.value)}
|
||||||
|
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); onAdd() } }}
|
||||||
|
size="small"
|
||||||
|
fullWidth
|
||||||
|
placeholder="https://…"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
size="small"
|
||||||
|
startIcon={<ImagePlus size={15} />}
|
||||||
|
onClick={onAdd}
|
||||||
|
disabled={!imageInput.trim()}
|
||||||
|
sx={{ whiteSpace: 'nowrap', textTransform: 'none' }}
|
||||||
|
>
|
||||||
|
Hinzufügen
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
{images.length > 0 && (
|
||||||
|
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
|
||||||
|
{images.map((url, i) => (
|
||||||
|
<Chip
|
||||||
|
key={i}
|
||||||
|
label={url.length > 40 ? url.slice(0, 40) + '…' : url}
|
||||||
|
size="small"
|
||||||
|
onDelete={() => onRemove(i)}
|
||||||
|
sx={{ maxWidth: 300 }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { Box, Card, MenuItem, TextField, Typography } from '@mui/material'
|
||||||
|
import { SOFT_FACTORS, LEVEL_OPTIONS } from '../../pages/supply/newListingConstants'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
softLevels: Record<string, string>
|
||||||
|
onChange: (key: string, value: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SoftFactorsSection({ softLevels, onChange }: Props) {
|
||||||
|
return (
|
||||||
|
<Card sx={{ p: 3, mb: 3 }}>
|
||||||
|
<Box sx={{ mb: 2 }}>
|
||||||
|
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>Lage & Ausstrahlung</Typography>
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
Diese Angaben verbessern die Match-Qualität erheblich.
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 2 }}>
|
||||||
|
{SOFT_FACTORS.map(({ key, label }) => (
|
||||||
|
<TextField
|
||||||
|
key={key}
|
||||||
|
select
|
||||||
|
label={label}
|
||||||
|
value={softLevels[key] ?? ''}
|
||||||
|
onChange={e => onChange(key, e.target.value)}
|
||||||
|
size="small"
|
||||||
|
fullWidth
|
||||||
|
>
|
||||||
|
{LEVEL_OPTIONS.map(o => (
|
||||||
|
<MenuItem key={o.value} value={o.value}>{o.label}</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { Box, Card, MenuItem, TextField, Typography } from '@mui/material'
|
||||||
|
import { FIT_OUT_OPTIONS } from '../../pages/supply/newListingConstants'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
floor: string
|
||||||
|
onFloorChange: (v: string) => void
|
||||||
|
fitOut: string
|
||||||
|
onFitOutChange: (v: string) => void
|
||||||
|
parking: string
|
||||||
|
onParkingChange: (v: string) => void
|
||||||
|
ceilingHeight: string
|
||||||
|
onCeilingHeightChange: (v: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TechnicalDetailsSection({
|
||||||
|
floor, onFloorChange,
|
||||||
|
fitOut, onFitOutChange,
|
||||||
|
parking, onParkingChange,
|
||||||
|
ceilingHeight, onCeilingHeightChange,
|
||||||
|
}: Props) {
|
||||||
|
return (
|
||||||
|
<Card sx={{ p: 3, mb: 3 }}>
|
||||||
|
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 2 }}>Technische Details (optional)</Typography>
|
||||||
|
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr 1fr', gap: 2 }}>
|
||||||
|
<TextField
|
||||||
|
label="Stockwerk" value={floor}
|
||||||
|
onChange={e => onFloorChange(e.target.value)}
|
||||||
|
size="small" type="number" fullWidth placeholder="0 = EG"
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
select label="Ausbaustandard" value={fitOut}
|
||||||
|
onChange={e => onFitOutChange(e.target.value)} size="small" fullWidth
|
||||||
|
>
|
||||||
|
{FIT_OUT_OPTIONS.map(o => (
|
||||||
|
<MenuItem key={o.value} value={o.value}>{o.label}</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
<TextField
|
||||||
|
label="Parkplätze" value={parking}
|
||||||
|
onChange={e => onParkingChange(e.target.value)}
|
||||||
|
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" slotProps={{ htmlInput: { step: 0.1, min: 2 } }} fullWidth
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
export { AiAssistCard } from './AiAssistCard'
|
||||||
|
export { AreaDetailsSection } from './AreaDetailsSection'
|
||||||
|
export { AddressSection } from './AddressSection'
|
||||||
|
export { SoftFactorsSection } from './SoftFactorsSection'
|
||||||
|
export { TechnicalDetailsSection } from './TechnicalDetailsSection'
|
||||||
|
export { ImageUrlSection } from './ImageUrlSection'
|
||||||
|
export { ContactSection } from './ContactSection'
|
||||||
|
export { CreatedScreen } from './CreatedScreen'
|
||||||
|
export { FloorPlanUrlSection } from './FloorPlanUrlSection'
|
||||||
@@ -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]}
|
||||||
|
|||||||
@@ -1,10 +1,5 @@
|
|||||||
import { Box, LinearProgress, Tooltip, Typography } from '@mui/material'
|
import { Box, LinearProgress, Tooltip, Typography } from '@mui/material'
|
||||||
|
import { confidenceHex } from '../../lib/utils'
|
||||||
function scoreColor(score: number): string {
|
|
||||||
if (score >= 0.85) return '#15803d'
|
|
||||||
if (score >= 0.65) return '#a16207'
|
|
||||||
return '#dc2626'
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ReliabilityScorePanelProps {
|
interface ReliabilityScorePanelProps {
|
||||||
score: number
|
score: number
|
||||||
@@ -13,7 +8,7 @@ interface ReliabilityScorePanelProps {
|
|||||||
|
|
||||||
export function ReliabilityScorePanel({ score, compact = false }: ReliabilityScorePanelProps) {
|
export function ReliabilityScorePanel({ score, compact = false }: ReliabilityScorePanelProps) {
|
||||||
const pct = Math.round(score * 100)
|
const pct = Math.round(score * 100)
|
||||||
const color = scoreColor(score)
|
const color = confidenceHex(score)
|
||||||
|
|
||||||
if (compact) {
|
if (compact) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import { useNavigate } from 'react-router'
|
import { useNavigate } from 'react-router'
|
||||||
import { Box, Card, Chip, IconButton, Tooltip, Typography } from '@mui/material'
|
import { Box, Button, Card, Chip, IconButton, Tooltip, Typography } from '@mui/material'
|
||||||
import { useDraggable } from '@dnd-kit/core'
|
import { useDraggable } from '@dnd-kit/core'
|
||||||
import { CSS } from '@dnd-kit/utilities'
|
import { CSS } from '@dnd-kit/utilities'
|
||||||
import { ExternalLink, MapPin, MessageSquare } from 'lucide-react'
|
import { ExternalLink, MapPin, MessageSquare, Send, MessagesSquare } from 'lucide-react'
|
||||||
import { MatchScoreDisplay } from '../match-card/MatchScoreDisplay'
|
import { MatchScoreDisplay } from '../match-card/MatchScoreDisplay'
|
||||||
import { HeatBadge } from '../shared'
|
import { HeatBadge } from '../shared'
|
||||||
import type { PipelineItem } from '../../domain/pipeline'
|
import type { PipelineItem } from '../../domain/pipeline'
|
||||||
import { STAGES, RESULT_TYPE_LABEL, RESULT_TYPE_COLOR } from './pipelineConstants'
|
import { STAGES, RESULT_TYPE_META } from './pipelineConstants'
|
||||||
import { detailPath } from './pipelineUtils'
|
import { detailPath } from './pipelineUtils'
|
||||||
|
import { useInquiryStore } from '../../stores/inquiryStore'
|
||||||
|
|
||||||
// ── DraggableCard ─────────────────────────────────────────────────────────────
|
// ── DraggableCard ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -25,8 +26,9 @@ export function DraggableCard({
|
|||||||
onChatClick?: (e: React.MouseEvent) => void
|
onChatClick?: (e: React.MouseEvent) => void
|
||||||
}) {
|
}) {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
const openInquiryDialog = useInquiryStore(s => s.openInquiryDialog)
|
||||||
const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({ id: item.id })
|
const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({ id: item.id })
|
||||||
const stageConfig = STAGES.find(s => s.key === item.stage)!
|
const stageConfig = STAGES.find(s => s.key === item.stage) ?? STAGES[0]
|
||||||
const path = detailPath(item)
|
const path = detailPath(item)
|
||||||
|
|
||||||
const style = !isDragOverlay ? {
|
const style = !isDragOverlay ? {
|
||||||
@@ -68,8 +70,8 @@ export function DraggableCard({
|
|||||||
<HeatBadge propertyId={item.propertyId} size="sm" />
|
<HeatBadge propertyId={item.propertyId} size="sm" />
|
||||||
<Chip
|
<Chip
|
||||||
size="small"
|
size="small"
|
||||||
label={RESULT_TYPE_LABEL[item.resultType] ?? item.resultType}
|
label={RESULT_TYPE_META[item.resultType]?.label ?? item.resultType}
|
||||||
sx={{ bgcolor: RESULT_TYPE_COLOR[item.resultType] ?? '#475569', color: 'white', fontSize: 10, height: 20, fontWeight: 600 }}
|
sx={{ bgcolor: RESULT_TYPE_META[item.resultType]?.color ?? '#475569', color: 'white', fontSize: 10, height: 20, fontWeight: 600 }}
|
||||||
/>
|
/>
|
||||||
<Chip
|
<Chip
|
||||||
size="small"
|
size="small"
|
||||||
@@ -87,7 +89,7 @@ export function DraggableCard({
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
{path && (
|
{path && (
|
||||||
<Tooltip title="Objekt öffnen">
|
<Tooltip title="Match öffnen">
|
||||||
<IconButton
|
<IconButton
|
||||||
size="small"
|
size="small"
|
||||||
onClick={(e) => { e.stopPropagation(); navigate(path) }}
|
onClick={(e) => { e.stopPropagation(); navigate(path) }}
|
||||||
@@ -135,6 +137,57 @@ export function DraggableCard({
|
|||||||
{item.notes}
|
{item.notes}
|
||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Row 6: Anfrage / Konversation */}
|
||||||
|
{!isDragOverlay && (
|
||||||
|
<Box sx={{ mt: 1, pt: 0.75, borderTop: '1px solid #f1f5f9' }}>
|
||||||
|
{item.stage !== 'SAVED' || item.inquiryId ? (
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
startIcon={<MessagesSquare size={11} />}
|
||||||
|
fullWidth
|
||||||
|
onMouseDown={e => e.stopPropagation()}
|
||||||
|
onClick={e => {
|
||||||
|
e.stopPropagation()
|
||||||
|
navigate(`/demand/anfragen?inquiry=${item.inquiryId}`)
|
||||||
|
}}
|
||||||
|
sx={{
|
||||||
|
fontSize: '0.7rem', py: 0.5, textTransform: 'none', fontWeight: 600,
|
||||||
|
borderColor: '#1e3a5f', color: '#1e3a5f',
|
||||||
|
'&:hover': { bgcolor: '#eff6ff', borderColor: '#1e3a5f' },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Zur Konversation
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="contained"
|
||||||
|
startIcon={<Send size={11} />}
|
||||||
|
fullWidth
|
||||||
|
onMouseDown={e => e.stopPropagation()}
|
||||||
|
onClick={e => {
|
||||||
|
e.stopPropagation()
|
||||||
|
openInquiryDialog({
|
||||||
|
propertyTitle: item.title,
|
||||||
|
location: item.propertyAddress ?? item.location,
|
||||||
|
areaLabel: item.areaLabel,
|
||||||
|
rentLabel: item.rentLabel,
|
||||||
|
matchScore: item.matchScore,
|
||||||
|
pipelineItemId: item.id,
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
sx={{
|
||||||
|
fontSize: '0.7rem', py: 0.5, textTransform: 'none', fontWeight: 600,
|
||||||
|
bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#162d4a' },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Anfrage senden
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export function DroppableColumn({
|
|||||||
selectedId,
|
selectedId,
|
||||||
onSelect,
|
onSelect,
|
||||||
onChatClick,
|
onChatClick,
|
||||||
|
onCardChatClick,
|
||||||
isOver,
|
isOver,
|
||||||
}: {
|
}: {
|
||||||
stage: { key: PipelineStage; label: string; color: string; bgColor: string }
|
stage: { key: PipelineStage; label: string; color: string; bgColor: string }
|
||||||
@@ -18,6 +19,7 @@ export function DroppableColumn({
|
|||||||
selectedId: string | null
|
selectedId: string | null
|
||||||
onSelect: (item: PipelineItem) => void
|
onSelect: (item: PipelineItem) => void
|
||||||
onChatClick: (inquiryId: string) => void
|
onChatClick: (inquiryId: string) => void
|
||||||
|
onCardChatClick?: (item: PipelineItem) => void
|
||||||
isOver: boolean
|
isOver: boolean
|
||||||
}) {
|
}) {
|
||||||
const { setNodeRef } = useDroppable({ id: stage.key })
|
const { setNodeRef } = useDroppable({ id: stage.key })
|
||||||
@@ -43,7 +45,13 @@ export function DroppableColumn({
|
|||||||
item={item}
|
item={item}
|
||||||
isSelected={item.id === selectedId}
|
isSelected={item.id === selectedId}
|
||||||
onSelect={onSelect}
|
onSelect={onSelect}
|
||||||
onChatClick={item.inquiryId ? (e) => { e.stopPropagation(); onChatClick(item.inquiryId!) } : undefined}
|
onChatClick={
|
||||||
|
item.inquiryId
|
||||||
|
? (e) => { e.stopPropagation(); onChatClick(item.inquiryId!) }
|
||||||
|
: onCardChatClick
|
||||||
|
? (e) => { e.stopPropagation(); onCardChatClick(item) }
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{items.length === 0 && (
|
{items.length === 0 && (
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user