Compare commits
182 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 177641386a | |||
| 8377ce03b5 | |||
| 576733d7da | |||
| a7e05fb7f0 | |||
| 78e2dab655 | |||
| 52dc7de25e | |||
| 36ce548169 | |||
| 3849b78f83 | |||
| 362514681a | |||
| 07cea21143 | |||
| be5523bcee | |||
| a8b54af0b8 | |||
| 6206447dae | |||
| 8cb6f21581 | |||
| 29be4f6e54 | |||
| 09095a1a32 | |||
| fb029cf0bc | |||
| 3e1e945661 | |||
| 74f9660581 | |||
| fca113e7ab | |||
| fd6ed105bb | |||
| 647493bf3c | |||
| 5d26dd4a6c | |||
| e169f8e310 | |||
| 7a0909e36a | |||
| 6aa4f96bd2 | |||
| 5259579f85 | |||
| beecf754d5 | |||
| 6e67c698ce | |||
| 3dcac2dcb6 | |||
| 5035445d2e | |||
| 017835b083 | |||
| d945fb1ab6 | |||
| f783cf6881 | |||
| cf9089b5a2 | |||
| b0f675c319 | |||
| da3b5b55cc | |||
| 76e52dae1d | |||
| 4c79d9f969 | |||
| ec5d567dae | |||
| e64ae15d17 | |||
| ee229f8f4f | |||
| d771029633 | |||
| 20dcc6b245 | |||
| bd9fd98d78 | |||
| bae0cd31d5 | |||
| 5c886193d5 | |||
| e6c20958a5 | |||
| 884bb468b8 | |||
| 29ee98a300 | |||
| 161b2d609a | |||
| da50f3b5ea | |||
| 96a2507979 | |||
| 87ea4c4dfc | |||
| c6d3071010 | |||
| 5a1f412ce3 | |||
| b2530f9e20 | |||
| a825672197 | |||
| 36570c5bdc | |||
| 98d2770054 | |||
| 6fb2fb027c | |||
| 7f6b7766e6 | |||
| 63909c8caf | |||
| d8ff380d6e | |||
| 81d66d24a9 | |||
| e597e81ff5 | |||
| d2af8664f4 | |||
| cd220ed282 | |||
| a9ee53f185 | |||
| fe8444c1d3 | |||
| 453f2b1982 | |||
| d804d1c923 | |||
| a9e038a64f | |||
| 61a26d00a7 | |||
| f2edc3b2ef | |||
| e95490eb72 | |||
| 3ab6eeccf2 | |||
| 128d28af8d | |||
| 9df5d285f5 | |||
| 609a3634bd | |||
| 11de05025c | |||
| 99c26b5464 | |||
| 58a0a05eff | |||
| e1f4beb898 | |||
| e36c5bc979 | |||
| f487435a94 | |||
| d4171fe9b5 | |||
| 7934da7669 | |||
| e62391af66 | |||
| 8f1db31683 | |||
| 7f090a48ff | |||
| c972392b78 | |||
| 723f553939 | |||
| 0582031930 | |||
| 713ef3ec08 | |||
| ad84fe8289 | |||
| ce67da73b3 | |||
| 86bdf142b4 | |||
| 5d53f35dea | |||
| eedee83b49 | |||
| 01c0f8b06f | |||
| c6055f0611 | |||
| a3fc213916 | |||
| ee71ecc881 | |||
| f0988b7250 | |||
| 0bacd188d6 | |||
| ae82d0e6a0 | |||
| 4d4ea6d2ff | |||
| 6515acb7f0 | |||
| efc72b720e | |||
| 22c195b4a5 | |||
| 72e4f08900 | |||
| 6a6ff7f2e2 | |||
| a002597f5b | |||
| de00f758e9 | |||
| 76f9927c7c | |||
| 9f7062d137 | |||
| 98b4a1146b | |||
| f82194ea7f | |||
| 030d861ed5 | |||
| 0de48a14d7 | |||
| 9f877614c1 | |||
| 955da564a0 | |||
| c9324e9cc8 | |||
| 0dac528f98 | |||
| 67d3d06a53 | |||
| 531c88cfb6 | |||
| ba910806d6 | |||
| 113274cfc7 | |||
| d8cce96bd8 | |||
| 11ecd5d900 | |||
| 27c53f3af1 | |||
| 34a4dcfb29 | |||
| a39c6ed200 | |||
| e2ec1b1dc9 | |||
| 69b96f293a | |||
| 99e99d66c5 | |||
| d902feb6c0 | |||
| 66aabde1dc | |||
| dd2eb9be3f | |||
| d2d2f5f1bb | |||
| 8f7372157c | |||
| 67c0701bfe | |||
| 0dc04a14c5 | |||
| b0ad6643ff | |||
| 5fe15bac62 | |||
| d5a4862a28 | |||
| 9f391d17cb | |||
| 0663757cde | |||
| 3756675b6f | |||
| a521155bb2 | |||
| b6f65f4763 | |||
| 86051c6f01 | |||
| 315733a423 | |||
| a36a827564 | |||
| d15a13e485 | |||
| d22e72f945 | |||
| f0e58f5f7a | |||
| 7cda66f98a | |||
| 18a7f146b0 | |||
| 13553f60a0 | |||
| b4d398270f | |||
| 279b55d70e | |||
| 7800d127fe | |||
| 0651333030 | |||
| 18f4b2b37a | |||
| c0f50344e0 | |||
| 920ce8eb09 | |||
| 52a2693cad | |||
| 73ff758f03 | |||
| 14badd1ca8 | |||
| 9cf3e72033 | |||
| b5cdb0353b | |||
| 5391ad0ae5 | |||
| a75818b983 | |||
| 259b4e3c68 | |||
| 732231ce13 | |||
| fdf4de37db | |||
| c4c29ef7d3 | |||
| 05ac0083b2 | |||
| 5b917bd619 | |||
| c75d3c8121 |
@@ -0,0 +1,35 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(npm run *)",
|
||||||
|
"Bash(git add *)",
|
||||||
|
"Bash(git push *)",
|
||||||
|
"Bash(npx tsc *)",
|
||||||
|
"Bash(grep -E \"\\\\.\\(ts|tsx\\)$\")",
|
||||||
|
"Bash(find c:\\\\\\\\Users\\\\\\\\beni_\\\\\\\\OneDrive\\\\\\\\Desktop\\\\\\\\property-match -name \"Dashboard.tsx\" -type f)",
|
||||||
|
"Bash(awk '{print $NF}')",
|
||||||
|
"Bash(start http://localhost:5173)",
|
||||||
|
"Bash(npm install *)",
|
||||||
|
"Bash(git pull *)",
|
||||||
|
"Bash(node scripts/check-tokens.js)",
|
||||||
|
"Bash(echo \"EXIT:$?\")",
|
||||||
|
"Bash(echo \"EXIT_CODE:$?\")",
|
||||||
|
"Bash(Get-ChildItem \"c:\\\\Users\\\\beni_\\\\AppData\\\\Local\\\\Temp\\\\docx-gen\\\\node_modules\\\\docx\" -Recurse -Name)",
|
||||||
|
"Bash(start \"C:\\\\Users\\\\beni_\\\\OneDrive\\\\Desktop\\\\property-match\\\\Aenderungsprotokoll_Meeting_26052026.docx\")",
|
||||||
|
"Bash(start http://localhost:5176)",
|
||||||
|
"Bash(start http://localhost:5176/demand/ai-search)",
|
||||||
|
"Bash(node -e ' *)",
|
||||||
|
"Skill(run)",
|
||||||
|
"Skill(run:*)",
|
||||||
|
"Bash(curl -s -o /dev/null -w \"%{http_code}\" http://localhost:5173)",
|
||||||
|
"Bash(powershell.exe -Command \"Start-Process 'http://localhost:5173'\")",
|
||||||
|
"Bash(findstr \"LISTENING\")",
|
||||||
|
"Bash(findstr \":517\")",
|
||||||
|
"Bash(findstr \"LISTEN\")",
|
||||||
|
"Bash(findstr \":5\")",
|
||||||
|
"Bash(echo \"Exit: $?\")",
|
||||||
|
"Bash(npx vercel *)",
|
||||||
|
"Bash(Start-Process \"http://localhost:5180\")"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(git commit -m ' *)",
|
||||||
|
"Bash(git commit *)",
|
||||||
|
"Bash(node -e ' *)",
|
||||||
|
"Bash(git stash *)",
|
||||||
|
"Read(//c/Users/beni_/.claude/projects/c--Users-beni--OneDrive-Desktop-property-match/8e388ddd-e02e-47fe-8bb9-cdb8164c9fc3/tool-results/**)",
|
||||||
|
"Bash(pandoc --version)",
|
||||||
|
"Bash(npm list *)",
|
||||||
|
"Read(//c/Program Files/LibreOffice/program/**)",
|
||||||
|
"Read(//c/Program Files/Microsoft Office/**)",
|
||||||
|
"Read(//c/Program Files \\(x86\\)/**)",
|
||||||
|
"Bash(mkdir -p /tmp/docx-gen)",
|
||||||
|
"Read(//tmp/**)",
|
||||||
|
"Bash(npm init *)",
|
||||||
|
"Bash(node generate.js)",
|
||||||
|
"Bash(timeout 8 bash -c \"until curl -s http://localhost:5173 > /dev/null; do sleep 1; done\")",
|
||||||
|
"Bash(dir \"c:\\\\Users\\\\beni_\\\\OneDrive\\\\Desktop\\\\property-match\\\\src\" -Depth 2)",
|
||||||
|
"Bash(start \"\" \"http://localhost:5178/demand/results\")",
|
||||||
|
"Bash(start \"\" \"http://localhost:5178/supply/properties\")"
|
||||||
|
],
|
||||||
|
"additionalDirectories": [
|
||||||
|
"c:\\Users\\beni_\\AppData\\Local\\Temp\\docx-gen"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,6 +16,9 @@ dist-ssr
|
|||||||
desktop.ini
|
desktop.ini
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|
||||||
|
# Claude Code agent worktrees (transient copies of src/ — never commit)
|
||||||
|
.claude/worktrees/
|
||||||
|
|
||||||
# Editor directories and files
|
# Editor directories and files
|
||||||
.vscode/*
|
.vscode/*
|
||||||
!.vscode/extensions.json
|
!.vscode/extensions.json
|
||||||
|
|||||||
@@ -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"}
|
||||||
@@ -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
|
||||||
|
```
|
||||||
@@ -1,73 +1,129 @@
|
|||||||
# React + TypeScript + Vite
|
# Property Match
|
||||||
|
|
||||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
Decision-Intelligence-Plattform für kommerziell genutzte Immobilien — kein Portal, sondern
|
||||||
|
eine Oberfläche, die Treffer bewertet, Trade-offs erklärt und eine nächste Handlung empfiehlt.
|
||||||
|
|
||||||
Currently, two official plugins are available:
|
Verbindliche Entwicklungsregeln stehen in [CLAUDE.md](./CLAUDE.md), Architekturdiagramme in
|
||||||
|
[ARCHITECTURE.md](./ARCHITECTURE.md), Zustandsregeln in [STATE_MANAGEMENT.md](./STATE_MANAGEMENT.md).
|
||||||
|
|
||||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
|
## Starten
|
||||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
|
|
||||||
|
|
||||||
## React Compiler
|
```bash
|
||||||
|
npm install
|
||||||
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
npm run dev # Entwicklungsserver auf http://localhost:5173
|
||||||
|
|
||||||
## Expanding the ESLint configuration
|
|
||||||
|
|
||||||
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
|
||||||
|
|
||||||
```js
|
|
||||||
export default defineConfig([
|
|
||||||
globalIgnores(['dist']),
|
|
||||||
{
|
|
||||||
files: ['**/*.{ts,tsx}'],
|
|
||||||
extends: [
|
|
||||||
// Other configs...
|
|
||||||
|
|
||||||
// Remove tseslint.configs.recommended and replace with this
|
|
||||||
tseslint.configs.recommendedTypeChecked,
|
|
||||||
// Alternatively, use this for stricter rules
|
|
||||||
tseslint.configs.strictTypeChecked,
|
|
||||||
// Optionally, add this for stylistic rules
|
|
||||||
tseslint.configs.stylisticTypeChecked,
|
|
||||||
|
|
||||||
// Other configs...
|
|
||||||
],
|
|
||||||
languageOptions: {
|
|
||||||
parserOptions: {
|
|
||||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
|
||||||
tsconfigRootDir: import.meta.dirname,
|
|
||||||
},
|
|
||||||
// other options...
|
|
||||||
},
|
|
||||||
},
|
|
||||||
])
|
|
||||||
```
|
```
|
||||||
|
|
||||||
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
Es gibt kein Backend und keine Datenbank. Sämtliche Daten stammen aus TypeScript-Mockdaten
|
||||||
|
unter `src/mock-data/` und werden über Mockup-Provider bereitgestellt.
|
||||||
|
|
||||||
```js
|
| Skript | Zweck |
|
||||||
// eslint.config.js
|
|---|---|
|
||||||
import reactX from 'eslint-plugin-react-x'
|
| `npm run dev` | Entwicklungsserver mit HMR |
|
||||||
import reactDom from 'eslint-plugin-react-dom'
|
| `npm run build` | Typecheck (`tsc -b`) und Produktionsbuild |
|
||||||
|
| `npm run lint` | ESLint über das gesamte Projekt |
|
||||||
|
| `npm test` | Vitest einmalig ausführen |
|
||||||
|
| `npm run test:watch` | Vitest im Watch-Modus |
|
||||||
|
| `npm run check:tokens` | Zählt rohe Hex-Farbwerte gegen einen Schwellwert |
|
||||||
|
|
||||||
|
## Architektur in vier Schichten
|
||||||
|
|
||||||
export default defineConfig([
|
|
||||||
globalIgnores(['dist']),
|
|
||||||
{
|
|
||||||
files: ['**/*.{ts,tsx}'],
|
|
||||||
extends: [
|
|
||||||
// Other configs...
|
|
||||||
// Enable lint rules for React
|
|
||||||
reactX.configs['recommended-typescript'],
|
|
||||||
// Enable lint rules for React DOM
|
|
||||||
reactDom.configs.recommended,
|
|
||||||
],
|
|
||||||
languageOptions: {
|
|
||||||
parserOptions: {
|
|
||||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
|
||||||
tsconfigRootDir: import.meta.dirname,
|
|
||||||
},
|
|
||||||
// other options...
|
|
||||||
},
|
|
||||||
},
|
|
||||||
])
|
|
||||||
```
|
```
|
||||||
|
Provider → Service → Hook (React Query) → Component
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Provider** (`src/provider/`) sind der einzige Ort, der Datenhaltung berührt.
|
||||||
|
- **Services** (`src/services/`) tragen die Fachlogik und liefern `ListResponse<T>` / `ItemResponse<T>`.
|
||||||
|
- **Hooks** (`src/hooks/`) kapseln React Query; Stale-Zeiten kommen aus `src/lib/constants.ts`.
|
||||||
|
- **Komponenten** (`src/components/`, `src/pages/`) sind reine Darstellung.
|
||||||
|
|
||||||
|
Drei geschützte Arbeitsbereiche: `/supply/*` (Bewirtschaftung), `/demand/*` (Suche),
|
||||||
|
`/ops/*` (interner Betrieb).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Property On — «Teamübersicht»
|
||||||
|
|
||||||
|
Property On ist als Funktionsbereich in Property Match eingebettet, nicht als eigene
|
||||||
|
Anwendung. Es gibt keine zweite App-Shell, keine zweite Sidebar und kein eigenes Branding.
|
||||||
|
Erreichbar über den Hauptreiter **Teamübersicht** in der Bewirtschaftungs-Navigation, mit den
|
||||||
|
Subreitern **Personalverwaltung**, **Bearbeitungsverlauf** und **Kanäle & Systeme**.
|
||||||
|
|
||||||
|
Bedienprinzip: **human-led, agent-operated.** Sieben digitale Mitarbeiter führen Arbeiten aus,
|
||||||
|
der Mensch gibt frei, passt an oder weist zurück. Jede Entscheidung erzeugt einen
|
||||||
|
Protokolleintrag, aktualisiert die Kennzahlen und meldet sich mit einer Rückmeldung.
|
||||||
|
|
||||||
|
### Routen
|
||||||
|
|
||||||
|
```
|
||||||
|
/supply/team Teamübersicht (Startseite)
|
||||||
|
/supply/team/personalverwaltung Agentenliste + Dossier
|
||||||
|
/supply/team/personalverwaltung/:agentId Personalblatt
|
||||||
|
/supply/team/personalverwaltung/:agentId/:tab Dossier-Reiter, deep-linkbar
|
||||||
|
tab: aufgaben | kanaele | systeme
|
||||||
|
einstellungen | protokoll
|
||||||
|
/supply/team/bearbeitungsverlauf Vorgänge
|
||||||
|
/supply/team/bearbeitungsverlauf/:tab tab: pendente-anfragen | erledigte-auftraege
|
||||||
|
/supply/team/kanaele-systeme Verbindungen der Organisation
|
||||||
|
```
|
||||||
|
|
||||||
|
### Architekturentscheidungen
|
||||||
|
|
||||||
|
**Der Entitätstyp heisst `TeamAgent`, nicht `Agent`.** Im Repo existiert bereits `PowerOn` als
|
||||||
|
Name des KI-Backend-Proxys, und Claude Code legt Arbeitskopien unter `agent-*` ab. Ein nackter
|
||||||
|
Typ `Agent` wäre in Suchen praktisch nicht auffindbar. Alle Satellitentypen tragen das Präfix
|
||||||
|
`Agent*` — `AgentStatus`, `AgentChannelType`, `AgentProtocolEntry` —, weil `src/domain/index.ts`
|
||||||
|
per `export *` bündelt und generische Namen dort kollidieren würden.
|
||||||
|
|
||||||
|
**Eigener Protokolltyp statt `ActivityEvent`.** Den Namen gibt es im Repo dreifach und
|
||||||
|
gegenseitig inkompatibel: in `domain/activityEvent.ts`, lokal in `services/governanceService.ts`
|
||||||
|
und noch einmal in `components/supply/PropertyActivityLogPanel.tsx`. Ein Anschluss an einen
|
||||||
|
dieser Typen hätte den Konflikt zementiert; `AgentProtocolEntry` steht bewusst daneben.
|
||||||
|
|
||||||
|
**Verbindungen sind von `DataSource` getrennt.** `DataSource`/`ConnectorRun` modelliert
|
||||||
|
Crawler-Quellen mit AGB-Status und Crawl-Läufen. «Kanäle & Systeme» beschreibt angebundene
|
||||||
|
Kommunikationskanäle und Fachsysteme — fachlich etwas anderes. Die Ops-Komponenten dienten als
|
||||||
|
visuelles Vorbild, das Datenmodell ist eigenständig.
|
||||||
|
|
||||||
|
**Eine Demo-Uhr statt der Systemzeit** (`src/lib/teamClock.ts`). Die Mockdaten sind fachlich
|
||||||
|
auf den 20.05.2026 verankert. Gegen die echte Systemzeit gerechnet wären die Zeitraumfilter
|
||||||
|
«heute / diese Woche / dieser Monat» an jedem anderen Kalendertag leer, und die Auswertung
|
||||||
|
sähe kaputt aus, obwohl sie korrekt arbeitet. Die Demo-Uhr startet am Anker und läuft ab
|
||||||
|
Anwendungsstart in Echtzeit weiter — jede Freigabe während einer Vorführung landet damit
|
||||||
|
verlässlich im Bucket «heute». Sie gilt ausschliesslich für Property On.
|
||||||
|
|
||||||
|
**Persistenz liegt in der Providerschicht.** Geänderte Einstellungen, Aufgabenschalter,
|
||||||
|
erledigte Vorgänge, Verbindungsstatus und pausierte Mitarbeiter überleben einen Reload über
|
||||||
|
den Local Storage. Die Bestände tragen eine Schemaversion (`TEAM_SCHEMA_VERSION`): passt sie
|
||||||
|
nicht, greift wieder der Seed aus `src/mock-data/` — sonst überlagerte ein alter Eintrag
|
||||||
|
stillschweigend geänderte Mockdaten. «Demo zurücksetzen» löscht genau diese Schlüssel.
|
||||||
|
|
||||||
|
**Subreiter sind echte Routen, keine lokalen Tabs.** Die Anwendung kannte bis dahin keine
|
||||||
|
Feature-Route mit eigenem `<Outlet/>`, und Tabs werden sonst über lokalen `useState` gebaut.
|
||||||
|
Hier war das nicht tragfähig: die drei Subreiter müssen in der Sidebar sichtbar sein und
|
||||||
|
deep-linkbar bleiben. Das `NavItem`-Modell in `appShellConfig.ts` wurde deshalb um `children`
|
||||||
|
erweitert; die Routen sind flache Geschwister, kein Outlet-Baum.
|
||||||
|
|
||||||
|
**Kein neuer Stack.** Der ursprüngliche Auftrag nannte shadcn/ui, React Hook Form, date-fns und
|
||||||
|
Playwright. Nichts davon ist installiert, und die Vorgabe, die bestehenden Property-Match-Muster
|
||||||
|
zu übernehmen, wiegt schwerer. Umgesetzt mit MUI v9, TanStack Query, Zustand und Zod;
|
||||||
|
Datumsformatierung über `Intl` in `src/lib/utils.ts` und `src/lib/teamClock.ts`. Besonders
|
||||||
|
relevant: Tailwind-Preflight ist bewusst nicht importiert — shadcn erwartet es und würde den
|
||||||
|
`CssBaseline`-Reset von MUI überschreiben.
|
||||||
|
|
||||||
|
### Simulation
|
||||||
|
|
||||||
|
Alles ist Frontend-Simulation. Es gibt keine echten Systemverbindungen, keine echten
|
||||||
|
Zugangsdaten und keine echten Modellaufrufe. Simulierte Ladezeiten liegen zwischen 300 und
|
||||||
|
800 ms. Der Test einer Verbindung ist **deterministisch** und nicht zufällig: verbundene
|
||||||
|
Kanäle gelingen, nicht verbundene und geplante scheitern reproduzierbar — ein Zufallsfehler
|
||||||
|
würde eine Kundendemo unvorhersehbar machen.
|
||||||
|
|
||||||
|
### Bekannte Altlasten
|
||||||
|
|
||||||
|
- `npm run check:tokens` liegt bei 2249 rohen Hex-Werten über dem Schwellwert von 1958. Der
|
||||||
|
Wert stammt vollständig aus dem Bestand; Property On hat null hinzugefügt. Der Schwellwert
|
||||||
|
darf laut Skript nicht angehoben werden.
|
||||||
|
- `npm run lint` meldet im Bestand weiterhin Fehler und Warnungen. Der Property-On-Code ist
|
||||||
|
frei davon: `npx eslint src/components/team src/pages/supply/Teamuebersicht.tsx …` ist grün.
|
||||||
|
- In `tsconfig` ist `strict` entgegen CLAUDE.md §14 nicht gesetzt; `strictNullChecks` fehlt
|
||||||
|
damit projektweit.
|
||||||
|
|||||||
@@ -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` |
|
||||||
@@ -6,7 +6,9 @@ import tseslint from 'typescript-eslint'
|
|||||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||||
|
|
||||||
export default defineConfig([
|
export default defineConfig([
|
||||||
globalIgnores(['dist']),
|
// `.claude/worktrees/` holds transient agent copies of src/ — linting them
|
||||||
|
// produces thousands of duplicate parse errors and drowns out real findings.
|
||||||
|
globalIgnores(['dist', '.claude/**']),
|
||||||
{
|
{
|
||||||
files: ['**/*.{ts,tsx}'],
|
files: ['**/*.{ts,tsx}'],
|
||||||
extends: [
|
extends: [
|
||||||
@@ -19,4 +21,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.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
])
|
])
|
||||||
|
|||||||
@@ -7,17 +7,26 @@
|
|||||||
"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/sortable": "^10.0.0",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@emotion/react": "^11.14.0",
|
"@emotion/react": "^11.14.0",
|
||||||
"@emotion/styled": "^11.14.1",
|
"@emotion/styled": "^11.14.1",
|
||||||
"@mui/icons-material": "^9.0.1",
|
"@mui/icons-material": "^9.0.1",
|
||||||
"@mui/material": "^9.0.1",
|
"@mui/material": "^9.0.1",
|
||||||
"@tanstack/react-query": "^5.75.2",
|
"@tanstack/react-query": "^5.75.2",
|
||||||
|
"leaflet": "^1.9.4",
|
||||||
"lucide-react": "^0.511.0",
|
"lucide-react": "^0.511.0",
|
||||||
"react": "^19.2.6",
|
"react": "^19.2.6",
|
||||||
"react-dom": "^19.2.6",
|
"react-dom": "^19.2.6",
|
||||||
|
"react-leaflet": "^5.0.0",
|
||||||
"react-router": "^7.15.0",
|
"react-router": "^7.15.0",
|
||||||
"zod": "^3.25.17",
|
"zod": "^3.25.17",
|
||||||
"zustand": "^5.0.5"
|
"zustand": "^5.0.5"
|
||||||
@@ -25,17 +34,24 @@
|
|||||||
"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/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",
|
||||||
|
"playwright": "^1.61.0",
|
||||||
"tailwindcss": "^4.3.0",
|
"tailwindcss": "^4.3.0",
|
||||||
"typescript": "~6.0.2",
|
"typescript": "~6.0.2",
|
||||||
"typescript-eslint": "^8.59.2",
|
"typescript-eslint": "^8.59.2",
|
||||||
"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)
|
||||||
|
}
|
||||||
@@ -9,11 +9,10 @@ import { useSessionStore } from './stores/sessionStore'
|
|||||||
const WORKSPACE_HOME: Record<string, string> = {
|
const WORKSPACE_HOME: Record<string, string> = {
|
||||||
[WorkspaceType.SUPPLY]: '/supply/dashboard',
|
[WorkspaceType.SUPPLY]: '/supply/dashboard',
|
||||||
[WorkspaceType.DEMAND]: '/demand/ai-search',
|
[WorkspaceType.DEMAND]: '/demand/ai-search',
|
||||||
[WorkspaceType.OPERATIONS]: '/ops/review-queue',
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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 />
|
||||||
}
|
}
|
||||||
@@ -23,22 +22,28 @@ const LoginScreen = lazy(() => import('./pages/auth/LoginScreen'))
|
|||||||
const SupplyDashboard = lazy(() => import('./pages/supply/SupplyDashboard'))
|
const SupplyDashboard = lazy(() => import('./pages/supply/SupplyDashboard'))
|
||||||
const Properties = lazy(() => import('./pages/supply/Properties'))
|
const Properties = lazy(() => import('./pages/supply/Properties'))
|
||||||
const MatchCenter = lazy(() => import('./pages/supply/MatchCenter'))
|
const MatchCenter = lazy(() => import('./pages/supply/MatchCenter'))
|
||||||
|
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 MarketIntelligence = lazy(() => import('./pages/supply/MarketIntelligence'))
|
||||||
|
const NewListing = lazy(() => import('./pages/supply/NewListing'))
|
||||||
|
const MyListings = lazy(() => import('./pages/supply/MyListings'))
|
||||||
|
|
||||||
|
// Property On — «Teamübersicht»
|
||||||
|
const Teamuebersicht = lazy(() => import('./pages/supply/Teamuebersicht'))
|
||||||
|
const Personalverwaltung = lazy(() => import('./pages/supply/Personalverwaltung'))
|
||||||
|
const Bearbeitungsverlauf = lazy(() => import('./pages/supply/Bearbeitungsverlauf'))
|
||||||
|
const KanaeleSysteme = lazy(() => import('./pages/supply/KanaeleSysteme'))
|
||||||
|
|
||||||
const AISearch = lazy(() => import('./pages/demand/AISearch'))
|
const AISearch = lazy(() => import('./pages/demand/AISearch'))
|
||||||
const Results = lazy(() => import('./pages/demand/Results'))
|
const Results = lazy(() => import('./pages/demand/Results'))
|
||||||
const MatchDetail = lazy(() => import('./pages/demand/MatchDetail'))
|
const MatchDetail = lazy(() => import('./pages/demand/MatchDetail'))
|
||||||
const Compare = lazy(() => import('./pages/demand/Compare'))
|
const Compare = lazy(() => import('./pages/demand/Compare'))
|
||||||
const Shortlists = lazy(() => import('./pages/demand/Shortlists'))
|
const Anfragen = lazy(() => import('./pages/demand/Anfragen'))
|
||||||
|
const Pipeline = lazy(() => import('./pages/demand/Pipeline'))
|
||||||
|
const PropertyDetail = lazy(() => import('./pages/demand/PropertyDetail'))
|
||||||
|
|
||||||
const ReviewQueue = lazy(() => import('./pages/ops/ReviewQueue'))
|
|
||||||
const AIMonitoring = lazy(() => import('./pages/ops/AIMonitoring'))
|
|
||||||
const Governance = lazy(() => import('./pages/ops/Governance'))
|
|
||||||
const MarketIntelligence = lazy(() => import('./pages/ops/MarketIntelligence'))
|
|
||||||
const SourceMonitoring = lazy(() => import('./pages/ops/SourceMonitoring'))
|
|
||||||
const ActivityTimeline = lazy(() => import('./pages/ops/ActivityTimeline'))
|
|
||||||
const SignalPipeline = lazy(() => import('./pages/ops/SignalPipeline'))
|
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
@@ -58,8 +63,25 @@ function App() {
|
|||||||
<Route path="/supply/dashboard" element={<SupplyDashboard />} />
|
<Route path="/supply/dashboard" element={<SupplyDashboard />} />
|
||||||
<Route path="/supply/properties" element={<Properties />} />
|
<Route path="/supply/properties" element={<Properties />} />
|
||||||
<Route path="/supply/match-center" element={<MatchCenter />} />
|
<Route path="/supply/match-center" element={<MatchCenter />} />
|
||||||
|
<Route path="/supply/anfragen" element={<Anfragencenter />} />
|
||||||
<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/market-intelligence" element={<MarketIntelligence />} />
|
||||||
|
<Route path="/supply/new-listing" element={<NewListing />} />
|
||||||
|
<Route path="/supply/my-listings" element={<MyListings />} />
|
||||||
|
|
||||||
|
{/* Property On — Hierarchie und Deep-Links bleiben erhalten (§3.4).
|
||||||
|
Bewusst flache Geschwisterrouten statt <Outlet/>: die App kennt
|
||||||
|
keine einzige Feature-Route mit eigenem Outlet, ein Novum hier
|
||||||
|
würde Sidebar, Seitentitel und Guards gleichzeitig betreffen. */}
|
||||||
|
<Route path="/supply/team" element={<Teamuebersicht />} />
|
||||||
|
<Route path="/supply/team/personalverwaltung" element={<Personalverwaltung />} />
|
||||||
|
<Route path="/supply/team/personalverwaltung/:agentId" element={<Personalverwaltung />} />
|
||||||
|
<Route path="/supply/team/personalverwaltung/:agentId/:tab" element={<Personalverwaltung />} />
|
||||||
|
<Route path="/supply/team/bearbeitungsverlauf" element={<Bearbeitungsverlauf />} />
|
||||||
|
<Route path="/supply/team/bearbeitungsverlauf/:tab" element={<Bearbeitungsverlauf />} />
|
||||||
|
<Route path="/supply/team/kanaele-systeme" element={<KanaeleSysteme />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
{/* Demand Workspace */}
|
{/* Demand Workspace */}
|
||||||
@@ -68,19 +90,12 @@ function App() {
|
|||||||
<Route path="/demand/results" element={<Results />} />
|
<Route path="/demand/results" element={<Results />} />
|
||||||
<Route path="/demand/results/:matchId" element={<MatchDetail />} />
|
<Route path="/demand/results/:matchId" element={<MatchDetail />} />
|
||||||
<Route path="/demand/compare" element={<Compare />} />
|
<Route path="/demand/compare" element={<Compare />} />
|
||||||
<Route path="/demand/shortlists" element={<Shortlists />} />
|
<Route path="/demand/anfragen" element={<Anfragen />} />
|
||||||
|
<Route path="/demand/pipeline" element={<Pipeline />} />
|
||||||
|
<Route path="/demand/property/:propertyId" element={<PropertyDetail />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
{/* Operations Workspace */}
|
|
||||||
<Route element={<ProtectedRoute workspace={WorkspaceType.OPERATIONS} />}>
|
|
||||||
<Route path="/ops/review-queue" element={<ReviewQueue />} />
|
|
||||||
<Route path="/ops/ai-monitoring" element={<AIMonitoring />} />
|
|
||||||
<Route path="/ops/governance" element={<Governance />} />
|
|
||||||
<Route path="/ops/market-intelligence" element={<MarketIntelligence />} />
|
|
||||||
<Route path="/ops/source-monitoring" element={<SourceMonitoring />} />
|
|
||||||
<Route path="/ops/activity-timeline" element={<ActivityTimeline />} />
|
|
||||||
<Route path="/ops/signal-pipeline" element={<SignalPipeline />} />
|
|
||||||
</Route>
|
|
||||||
</Route>
|
</Route>
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
|
|||||||
|
After Width: | Height: | Size: 5.8 KiB |
|
After Width: | Height: | Size: 6.1 KiB |
|
After Width: | Height: | Size: 5.8 KiB |
|
After Width: | Height: | Size: 5.9 KiB |
|
After Width: | Height: | Size: 7.0 KiB |
|
After Width: | Height: | Size: 6.2 KiB |
|
After Width: | Height: | Size: 5.6 KiB |
|
After Width: | Height: | Size: 7.4 KiB |
|
After Width: | Height: | Size: 6.2 KiB |
|
After Width: | Height: | Size: 6.5 KiB |
|
After Width: | Height: | Size: 6.3 KiB |
|
After Width: | Height: | Size: 5.6 KiB |
|
After Width: | Height: | Size: 5.6 KiB |
|
After Width: | Height: | Size: 7.4 KiB |
|
After Width: | Height: | Size: 6.2 KiB |
|
After Width: | Height: | Size: 5.4 KiB |
|
After Width: | Height: | Size: 5.9 KiB |
|
After Width: | Height: | Size: 6.3 KiB |
|
After Width: | Height: | Size: 7.2 KiB |
|
After Width: | Height: | Size: 6.5 KiB |
|
After Width: | Height: | Size: 5.7 KiB |
|
After Width: | Height: | Size: 6.8 KiB |
|
After Width: | Height: | Size: 6.1 KiB |
|
After Width: | Height: | Size: 6.5 KiB |
|
After Width: | Height: | Size: 6.5 KiB |
|
After Width: | Height: | Size: 5.5 KiB |
|
After Width: | Height: | Size: 6.4 KiB |
|
After Width: | Height: | Size: 6.7 KiB |
|
After Width: | Height: | Size: 6.2 KiB |
|
After Width: | Height: | Size: 6.4 KiB |
|
After Width: | Height: | Size: 5.7 KiB |
|
After Width: | Height: | Size: 6.1 KiB |
|
After Width: | Height: | Size: 6.8 KiB |
|
After Width: | Height: | Size: 6.4 KiB |
|
After Width: | Height: | Size: 6.3 KiB |
|
After Width: | Height: | Size: 5.7 KiB |
@@ -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,4 +1,4 @@
|
|||||||
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 }> = {
|
||||||
@@ -11,11 +11,5 @@ const CONFIG: Record<ReviewStatus, { label: string; color: string }> = {
|
|||||||
|
|
||||||
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' }}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ const TYPE_LABELS: Record<AIOutputType, string> = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const TYPE_COLORS: Record<AIOutputType, string> = {
|
const TYPE_COLORS: Record<AIOutputType, string> = {
|
||||||
NEED_PARSE: '#1e3a5f',
|
NEED_PARSE: '#152642',
|
||||||
FOLLOW_UP_QUESTIONS: '#0891b2',
|
FOLLOW_UP_QUESTIONS: '#0891b2',
|
||||||
MATCH_EXPLANATION: '#4f46e5',
|
MATCH_EXPLANATION: '#4f46e5',
|
||||||
COMPARE_SUMMARY: '#1a7a4a',
|
COMPARE_SUMMARY: '#1a7a4a',
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ export function AIReviewActionToolbar({ output, onUpdateStatus, onCopyJson, isSu
|
|||||||
variant="outlined"
|
variant="outlined"
|
||||||
onClick={onCopyJson}
|
onClick={onCopyJson}
|
||||||
startIcon={<Copy size={13} />}
|
startIcon={<Copy size={13} />}
|
||||||
sx={{ textTransform: 'none', fontSize: '0.75rem', color: '#64748b', borderColor: '#e2e8f0', '&:hover': { bgcolor: '#f8fafc' } }}
|
sx={{ textTransform: 'none', fontSize: '0.75rem', color: '#64748b', borderColor: '#e8e7e4', '&:hover': { bgcolor: '#f8fafc' } }}
|
||||||
>
|
>
|
||||||
JSON
|
JSON
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -0,0 +1,144 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { Box, IconButton, Tooltip, Typography, useMediaQuery, useTheme } from '@mui/material'
|
||||||
|
import { ArrowLeft, LayoutGrid, List as ListIcon, Inbox } from 'lucide-react'
|
||||||
|
import { useActiveInquiries } from '../../hooks/useInquiries'
|
||||||
|
import type { InquiryFilters } from '../../provider/IInquiryProvider'
|
||||||
|
import { EmptyState } from '../ui'
|
||||||
|
import { InquiryList } from './InquiryList'
|
||||||
|
import { InquiryCardGrid } from './InquiryCardGrid'
|
||||||
|
import { InquiryDetailPanel } from './InquiryDetailPanel'
|
||||||
|
|
||||||
|
type ViewMode = 'list' | 'grid'
|
||||||
|
|
||||||
|
const VIEW_STORAGE_KEY = 'view-inquiries'
|
||||||
|
|
||||||
|
function loadViewMode(): ViewMode {
|
||||||
|
if (typeof window === 'undefined') return 'list'
|
||||||
|
const stored = window.localStorage.getItem(VIEW_STORAGE_KEY)
|
||||||
|
return stored === 'grid' ? 'grid' : 'list'
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
filters?: InquiryFilters
|
||||||
|
emptyTitle?: string
|
||||||
|
emptyDescription?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ActiveInquiriesTab({ filters, emptyTitle, emptyDescription }: Props = {}) {
|
||||||
|
const { data: inquiries = [], isLoading } = useActiveInquiries(filters)
|
||||||
|
const [selectedId, setSelectedId] = useState<string | null>(null)
|
||||||
|
const [view, setView] = useState<ViewMode>(loadViewMode())
|
||||||
|
const theme = useTheme()
|
||||||
|
const isMobile = useMediaQuery(theme.breakpoints.down('md'))
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.localStorage.setItem(VIEW_STORAGE_KEY, view)
|
||||||
|
}
|
||||||
|
}, [view])
|
||||||
|
|
||||||
|
// Auto-select first inquiry on desktop only
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isMobile && !selectedId && inquiries.length > 0) {
|
||||||
|
setSelectedId(inquiries[0].id)
|
||||||
|
}
|
||||||
|
}, [inquiries, selectedId, isMobile])
|
||||||
|
|
||||||
|
if (!isLoading && inquiries.length === 0) {
|
||||||
|
return (
|
||||||
|
<EmptyState
|
||||||
|
icon={<Inbox size={40} />}
|
||||||
|
title={emptyTitle ?? 'Keine aktiven Anfragen'}
|
||||||
|
description={emptyDescription ?? 'Sobald Interessenten Anfragen zu Ihren Objekten stellen, erscheinen diese hier.'}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mobile: show detail panel when an inquiry is selected
|
||||||
|
if (isMobile && selectedId) {
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
||||||
|
<Box sx={{ px: 1.5, py: 1, borderBottom: '1px solid #e2e8f0', bgcolor: 'white', flexShrink: 0 }}>
|
||||||
|
<IconButton size="small" onClick={() => setSelectedId(null)}>
|
||||||
|
<ArrowLeft size={18} />
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ flex: 1, overflow: 'hidden' }}>
|
||||||
|
<InquiryDetailPanel inquiryId={selectedId} />
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: 'flex', height: '100%', overflow: 'hidden' }}>
|
||||||
|
{/* Left column: list/grid */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: isMobile ? '100%' : (view === 'list' ? 380 : 720),
|
||||||
|
minWidth: isMobile ? 'unset' : (view === 'list' ? 380 : 480),
|
||||||
|
flexShrink: 0,
|
||||||
|
borderRight: isMobile ? 'none' : '1px solid #e2e8f0',
|
||||||
|
bgcolor: 'white',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
px: 2,
|
||||||
|
py: 1.25,
|
||||||
|
borderBottom: '1px solid #e2e8f0',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 600, color: '#0f172a' }}>
|
||||||
|
Anfragen
|
||||||
|
</Typography>
|
||||||
|
<Box sx={{ px: 1, py: 0.125, borderRadius: 1, bgcolor: '#152642', color: 'white', fontWeight: 600, fontSize: '0.7rem' }}>
|
||||||
|
{inquiries.length}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ display: 'flex', gap: 0.5 }}>
|
||||||
|
<Tooltip title="Listenansicht">
|
||||||
|
<IconButton size="small" onClick={() => setView('list')} sx={{ bgcolor: view === 'list' ? '#e0e7ff' : 'transparent', color: view === 'list' ? '#152642' : '#64748b' }}>
|
||||||
|
<ListIcon size={16} />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title="Kartenansicht">
|
||||||
|
<IconButton size="small" onClick={() => setView('grid')} sx={{ bgcolor: view === 'grid' ? '#e0e7ff' : 'transparent', color: view === 'grid' ? '#152642' : '#64748b' }}>
|
||||||
|
<LayoutGrid size={16} />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{view === 'list' ? (
|
||||||
|
<InquiryList inquiries={inquiries} selectedId={selectedId} onSelect={setSelectedId} />
|
||||||
|
) : (
|
||||||
|
<InquiryCardGrid inquiries={inquiries} selectedId={selectedId} onSelect={setSelectedId} />
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Right column: detail (desktop only) */}
|
||||||
|
{!isMobile && (
|
||||||
|
<Box sx={{ flex: 1, overflow: 'hidden', bgcolor: '#f8fafc' }}>
|
||||||
|
{selectedId ? (
|
||||||
|
<InquiryDetailPanel inquiryId={selectedId} />
|
||||||
|
) : (
|
||||||
|
<EmptyState
|
||||||
|
icon={<Inbox size={40} />}
|
||||||
|
title="Anfrage auswählen"
|
||||||
|
description="Wählen Sie eine Anfrage aus der Liste, um Details anzuzeigen und zu antworten."
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { Box, Button, CircularProgress, Typography } from '@mui/material'
|
||||||
|
import { Sparkles } from 'lucide-react'
|
||||||
|
import { useGenerateOfferEmail } from '../../hooks/useAI'
|
||||||
|
|
||||||
|
interface AiOfferEmailButtonProps {
|
||||||
|
needTitle: string
|
||||||
|
selectedProperties: string[]
|
||||||
|
matchScores: number[]
|
||||||
|
onGenerated: (subject: string, body: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AiOfferEmailButton({
|
||||||
|
needTitle,
|
||||||
|
selectedProperties,
|
||||||
|
matchScores,
|
||||||
|
onGenerated,
|
||||||
|
}: AiOfferEmailButtonProps) {
|
||||||
|
const generateOfferEmail = useGenerateOfferEmail()
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
generateOfferEmail.mutate(
|
||||||
|
{ needTitle, properties: selectedProperties, matchScores },
|
||||||
|
{
|
||||||
|
onSuccess: (res) => {
|
||||||
|
onGenerated(res.data.subject, res.data.body)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
startIcon={generateOfferEmail.isPending ? <CircularProgress size={14} /> : <Sparkles size={14} />}
|
||||||
|
onClick={handleClick}
|
||||||
|
disabled={generateOfferEmail.isPending}
|
||||||
|
sx={{
|
||||||
|
textTransform: 'none',
|
||||||
|
color: '#7c3aed',
|
||||||
|
borderColor: '#c4b5fd',
|
||||||
|
'&:hover': { bgcolor: '#f5f3ff', borderColor: '#7c3aed' },
|
||||||
|
}}
|
||||||
|
variant="outlined"
|
||||||
|
>
|
||||||
|
KI-Mail generieren
|
||||||
|
</Button>
|
||||||
|
{generateOfferEmail.isError && (
|
||||||
|
<Typography variant="caption" sx={{ color: 'error.main', display: 'block', mt: 0.5 }}>
|
||||||
|
{generateOfferEmail.error instanceof Error
|
||||||
|
? generateOfferEmail.error.message
|
||||||
|
: 'KI-Generierung fehlgeschlagen'}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { Box, TextField, Typography } from '@mui/material'
|
||||||
|
import type { OfferEditableField } from '../../domain/offer'
|
||||||
|
|
||||||
|
interface EditableOfferFieldListProps {
|
||||||
|
fields: OfferEditableField[]
|
||||||
|
values: Record<string, string>
|
||||||
|
onChange: (id: string, value: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EditableOfferFieldList({ fields, values, onChange }: EditableOfferFieldListProps) {
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
|
||||||
|
{fields.map(f => {
|
||||||
|
const value = values[f.id] !== undefined ? values[f.id] : f.value
|
||||||
|
return (
|
||||||
|
<Box key={f.id}>
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
sx={{
|
||||||
|
color: '#64748b',
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: '0.7rem',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: 0.5,
|
||||||
|
mb: 0.5,
|
||||||
|
display: 'block',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{f.label}
|
||||||
|
</Typography>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
size="small"
|
||||||
|
value={value}
|
||||||
|
onChange={e => onChange(f.id, e.target.value)}
|
||||||
|
multiline={f.fieldType === 'textarea'}
|
||||||
|
rows={f.fieldType === 'textarea' ? 3 : undefined}
|
||||||
|
sx={{
|
||||||
|
'& .MuiInputBase-input': { fontSize: '0.85rem' },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
import { Box, Button, Checkbox, Paper, Typography } from '@mui/material'
|
||||||
|
import { Building2, MapPin } from 'lucide-react'
|
||||||
|
import type { Property } from '../../domain/property'
|
||||||
|
import { getScoreTier, SCORE_THEME } from '../shared/scoreTheme'
|
||||||
|
import { assetTypeLabel } from './latentNeedUtils'
|
||||||
|
|
||||||
|
interface EmbeddedPropertyCardProps {
|
||||||
|
property: Property
|
||||||
|
matchScore: number
|
||||||
|
selected: boolean
|
||||||
|
onToggle: () => void
|
||||||
|
reason: string
|
||||||
|
onQuickOffer: () => void
|
||||||
|
disabled?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EmbeddedPropertyCard({
|
||||||
|
property,
|
||||||
|
matchScore,
|
||||||
|
selected,
|
||||||
|
onToggle,
|
||||||
|
reason,
|
||||||
|
onQuickOffer,
|
||||||
|
disabled,
|
||||||
|
}: EmbeddedPropertyCardProps) {
|
||||||
|
const tier = getScoreTier(matchScore)
|
||||||
|
const theme = SCORE_THEME[tier]
|
||||||
|
const image = property.images?.[0]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper
|
||||||
|
elevation={0}
|
||||||
|
sx={{
|
||||||
|
borderRadius: 2,
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: selected ? '#152642' : '#e2e8f0',
|
||||||
|
bgcolor: selected ? '#f8fafc' : 'white',
|
||||||
|
overflow: 'hidden',
|
||||||
|
transition: 'all 0.15s',
|
||||||
|
'&:hover': { borderColor: selected ? '#152642' : '#94a3b8', boxShadow: '0 2px 8px rgba(15,23,42,0.07)' },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Info row */}
|
||||||
|
<Box sx={{ display: 'flex', gap: 2, p: 2 }}>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: 72,
|
||||||
|
height: 72,
|
||||||
|
flexShrink: 0,
|
||||||
|
borderRadius: 1.5,
|
||||||
|
bgcolor: '#e8e7e4',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
backgroundImage: image ? `url(${image})` : 'none',
|
||||||
|
backgroundSize: 'cover',
|
||||||
|
backgroundPosition: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{!image && <Building2 size={24} color="#94a3b8" />}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 1 }}>
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 700, color: '#0f172a', fontSize: '0.9rem', lineHeight: 1.3 }}>
|
||||||
|
{property.title}
|
||||||
|
</Typography>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
background: theme.gradient,
|
||||||
|
color: theme.text,
|
||||||
|
px: 1.25,
|
||||||
|
py: 0.375,
|
||||||
|
borderRadius: 1,
|
||||||
|
fontSize: '0.9rem',
|
||||||
|
fontWeight: 700,
|
||||||
|
flexShrink: 0,
|
||||||
|
border: `1px solid ${theme.border}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{matchScore}%
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mt: 0.5, color: '#64748b' }}>
|
||||||
|
<MapPin size={12} />
|
||||||
|
<Typography variant="caption" sx={{ fontSize: '0.75rem' }}>
|
||||||
|
{property.location.city} · {assetTypeLabel(property.assetType)} · {property.areaSqm.toLocaleString('de-CH')} m²
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Typography variant="caption" sx={{ fontSize: '0.775rem', color: '#15803d', fontWeight: 600, display: 'block', mt: 0.5 }}>
|
||||||
|
{reason}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Action row */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
borderTop: '1px solid #f1f5f9',
|
||||||
|
px: 2,
|
||||||
|
py: 1,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
bgcolor: '#fafafa',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
onClick={!disabled ? onToggle : undefined}
|
||||||
|
sx={{ display: 'flex', alignItems: 'center', gap: 0.5, cursor: disabled ? 'default' : 'pointer' }}
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
checked={selected}
|
||||||
|
onChange={onToggle}
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
size="small"
|
||||||
|
disabled={disabled}
|
||||||
|
sx={{ p: 0.5 }}
|
||||||
|
/>
|
||||||
|
<Typography variant="caption" sx={{ fontSize: '0.775rem', color: '#475569', fontWeight: 500 }}>
|
||||||
|
Für Angebot auswählen
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
onClick={onQuickOffer}
|
||||||
|
disabled={disabled}
|
||||||
|
sx={{
|
||||||
|
textTransform: 'none',
|
||||||
|
fontSize: '0.775rem',
|
||||||
|
fontWeight: 600,
|
||||||
|
borderColor: '#152642',
|
||||||
|
color: '#152642',
|
||||||
|
py: 0.5,
|
||||||
|
px: 1.5,
|
||||||
|
minWidth: 0,
|
||||||
|
'&:hover': { bgcolor: '#152642', color: 'white', borderColor: '#152642' },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Angebot →
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import { Box, Paper, Typography } from '@mui/material'
|
||||||
|
import { Building2 } from 'lucide-react'
|
||||||
|
import type { Inquiry } from '../../domain/inquiry'
|
||||||
|
import { formatInquiryDate, propertyLabelFromId } from './inquiryUtils'
|
||||||
|
|
||||||
|
interface InquiryCardProps {
|
||||||
|
inquiry: Inquiry
|
||||||
|
selected: boolean
|
||||||
|
onClick: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InquiryCard({ inquiry, selected, onClick }: InquiryCardProps) {
|
||||||
|
const hasUnread = !inquiry.isRead && inquiry.unreadCount > 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper
|
||||||
|
onClick={onClick}
|
||||||
|
elevation={0}
|
||||||
|
sx={{
|
||||||
|
p: 2,
|
||||||
|
borderRadius: 1.5,
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: selected ? '#152642' : '#e8e7e4',
|
||||||
|
bgcolor: selected ? '#f1f5f9' : 'white',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'all 0.15s',
|
||||||
|
'&:hover': {
|
||||||
|
borderColor: selected ? '#152642' : '#94a3b8',
|
||||||
|
boxShadow: '0 2px 6px rgba(15,23,42,0.06)',
|
||||||
|
},
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 0.75,
|
||||||
|
minHeight: 160,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 1 }}>
|
||||||
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
sx={{
|
||||||
|
fontWeight: hasUnread ? 700 : 600,
|
||||||
|
color: '#0f172a',
|
||||||
|
fontSize: '0.85rem',
|
||||||
|
lineHeight: 1.3,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{inquiry.tenantName}
|
||||||
|
{inquiry.tenantCompany ? ` · ${inquiry.tenantCompany}` : ''}
|
||||||
|
</Typography>
|
||||||
|
{hasUnread && (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
minWidth: 20,
|
||||||
|
height: 20,
|
||||||
|
borderRadius: '50%',
|
||||||
|
bgcolor: '#2563eb',
|
||||||
|
color: 'white',
|
||||||
|
fontSize: '0.65rem',
|
||||||
|
fontWeight: 700,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
flexShrink: 0,
|
||||||
|
px: inquiry.unreadCount > 9 ? 0.75 : 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{inquiry.unreadCount}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
sx={{
|
||||||
|
fontWeight: hasUnread ? 600 : 500,
|
||||||
|
color: '#1e293b',
|
||||||
|
fontSize: '0.85rem',
|
||||||
|
display: '-webkit-box',
|
||||||
|
WebkitLineClamp: 2,
|
||||||
|
WebkitBoxOrient: 'vertical',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{inquiry.subject}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, color: '#64748b' }}>
|
||||||
|
<Building2 size={13} />
|
||||||
|
<Typography variant="caption" sx={{ fontSize: '0.75rem' }}>
|
||||||
|
{propertyLabelFromId(inquiry.propertyId)}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ mt: 'auto', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
<Typography variant="caption" sx={{ color: '#94a3b8', fontSize: '0.7rem' }}>
|
||||||
|
{formatInquiryDate(inquiry.createdAt)}
|
||||||
|
</Typography>
|
||||||
|
{inquiry.matchScore !== undefined && (
|
||||||
|
<Typography sx={{ fontSize: '0.75rem', fontWeight: 700, color: '#152642' }}>
|
||||||
|
{inquiry.matchScore}%
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { Box } from '@mui/material'
|
||||||
|
import type { Inquiry } from '../../domain/inquiry'
|
||||||
|
import { InquiryCard } from './InquiryCard'
|
||||||
|
|
||||||
|
interface InquiryCardGridProps {
|
||||||
|
inquiries: Inquiry[]
|
||||||
|
selectedId: string | null
|
||||||
|
onSelect: (id: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InquiryCardGrid({ inquiries, selectedId, onSelect }: InquiryCardGridProps) {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
overflowY: 'auto',
|
||||||
|
flex: 1,
|
||||||
|
p: 1.5,
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(auto-fill, minmax(260px, 1fr))',
|
||||||
|
gap: 1.5,
|
||||||
|
alignContent: 'flex-start',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{inquiries.map(inq => (
|
||||||
|
<InquiryCard
|
||||||
|
key={inq.id}
|
||||||
|
inquiry={inq}
|
||||||
|
selected={inq.id === selectedId}
|
||||||
|
onClick={() => onSelect(inq.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { useEffect, useRef } from 'react'
|
||||||
|
import { Box, Button } from '@mui/material'
|
||||||
|
import { FileText } from 'lucide-react'
|
||||||
|
import type { Inquiry, Attachment } from '../../domain/inquiry'
|
||||||
|
import { InquiryMessageBubble } from './InquiryMessageBubble'
|
||||||
|
import { InquiryReplyComposer } from './InquiryReplyComposer'
|
||||||
|
|
||||||
|
interface InquiryChatProps {
|
||||||
|
inquiry: Inquiry
|
||||||
|
onCreateOffer?: () => void
|
||||||
|
pendingAttachment?: Attachment | null
|
||||||
|
onPendingAttachmentConsumed?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InquiryChat({
|
||||||
|
inquiry,
|
||||||
|
onCreateOffer,
|
||||||
|
pendingAttachment,
|
||||||
|
onPendingAttachmentConsumed,
|
||||||
|
}: InquiryChatProps) {
|
||||||
|
const endRef = useRef<HTMLDivElement | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
endRef.current?.scrollIntoView({ behavior: 'smooth', block: 'end' })
|
||||||
|
}, [inquiry.thread.length])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', flex: 1, overflow: 'hidden' }}>
|
||||||
|
<Box sx={{ overflowY: 'auto', flex: 1, p: 2, bgcolor: 'white' }}>
|
||||||
|
{inquiry.thread.map((m, idx) => (
|
||||||
|
<Box key={m.id}>
|
||||||
|
<InquiryMessageBubble message={m} />
|
||||||
|
{idx === 0 && m.senderType === 'tenant' && onCreateOffer && (
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'flex-start', mb: 1.5, ml: 0.5 }}>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
startIcon={<FileText size={13} />}
|
||||||
|
onClick={onCreateOffer}
|
||||||
|
sx={{
|
||||||
|
textTransform: 'none',
|
||||||
|
fontSize: '0.75rem',
|
||||||
|
borderColor: '#cbd5e1',
|
||||||
|
color: '#475569',
|
||||||
|
'&:hover': { borderColor: '#152642', color: '#152642', bgcolor: '#f0f4f8' },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Angebot erstellen
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
<div ref={endRef} />
|
||||||
|
</Box>
|
||||||
|
<InquiryReplyComposer
|
||||||
|
inquiryId={inquiry.id}
|
||||||
|
defaultSubject={inquiry.subject}
|
||||||
|
tenantName={inquiry.tenantName}
|
||||||
|
pendingAttachment={pendingAttachment}
|
||||||
|
onPendingAttachmentConsumed={onPendingAttachmentConsumed}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { Box, Button, CircularProgress, Typography } from '@mui/material'
|
||||||
|
import { ArrowRight, Building2, ClipboardList, MapPin } from 'lucide-react'
|
||||||
|
import { useNavigate } from 'react-router'
|
||||||
|
import { useInquiryById, useMarkThreadAsRead } from '../../hooks/useInquiries'
|
||||||
|
import { usePropertyById } from '../../hooks/useProperties'
|
||||||
|
import { InquiryChat } from './InquiryChat'
|
||||||
|
import { RelatedPropertyCardPanel } from './RelatedPropertyCardPanel'
|
||||||
|
import type { Attachment } from '../../domain/inquiry'
|
||||||
|
|
||||||
|
interface InquiryDetailPanelProps {
|
||||||
|
inquiryId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InquiryDetailPanel({ inquiryId }: InquiryDetailPanelProps) {
|
||||||
|
const { data: inquiry, isLoading } = useInquiryById(inquiryId)
|
||||||
|
const { data: property } = usePropertyById(inquiry?.propertyId ?? '')
|
||||||
|
const markRead = useMarkThreadAsRead()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const [preparationOpen, setPreparationOpen] = useState(false)
|
||||||
|
const [offerWizardOpen, setOfferWizardOpen] = useState(false)
|
||||||
|
const [pendingAttachment, setPendingAttachment] = useState<Attachment | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (inquiry && !inquiry.isRead) {
|
||||||
|
markRead.mutate(inquiry.id)
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [inquiry?.id])
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100%' }}>
|
||||||
|
<CircularProgress size={24} />
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!inquiry) {
|
||||||
|
return (
|
||||||
|
<Box sx={{ p: 3 }}>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
Anfrage nicht gefunden
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const isLatentInquiry = !!inquiry.needId
|
||||||
|
const propertyImage = property?.images?.[0]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
||||||
|
|
||||||
|
{/* Header: tenant + subject + action */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
px: 2.5,
|
||||||
|
py: 1.5,
|
||||||
|
borderBottom: '1px solid #e2e8f0',
|
||||||
|
bgcolor: 'white',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 2,
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<Typography variant="caption" sx={{ color: '#64748b', fontSize: '0.75rem' }}>
|
||||||
|
{inquiry.tenantName}
|
||||||
|
{inquiry.tenantCompany ? ` · ${inquiry.tenantCompany}` : ''}
|
||||||
|
{inquiry.tenantEmail ? ` · ${inquiry.tenantEmail}` : ''}
|
||||||
|
</Typography>
|
||||||
|
<Typography
|
||||||
|
variant="body1"
|
||||||
|
sx={{
|
||||||
|
fontWeight: 600,
|
||||||
|
color: '#0f172a',
|
||||||
|
fontSize: '0.95rem',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{inquiry.subject}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
{isLatentInquiry && (
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
startIcon={<ClipboardList size={14} />}
|
||||||
|
onClick={() => setPreparationOpen(true)}
|
||||||
|
sx={{ textTransform: 'none', whiteSpace: 'nowrap', borderColor: '#152642', color: '#152642', '&:hover': { bgcolor: '#f0f4f8' } }}
|
||||||
|
>
|
||||||
|
Vorbereitung starten
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Property context bar */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
px: 2,
|
||||||
|
py: 1,
|
||||||
|
borderBottom: '1px solid #e2e8f0',
|
||||||
|
bgcolor: '#f8fafc',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 1.5,
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: 44,
|
||||||
|
height: 44,
|
||||||
|
borderRadius: 1,
|
||||||
|
overflow: 'hidden',
|
||||||
|
flexShrink: 0,
|
||||||
|
bgcolor: '#e8e7e4',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{propertyImage ? (
|
||||||
|
<Box
|
||||||
|
component="img"
|
||||||
|
src={propertyImage}
|
||||||
|
alt={property?.title}
|
||||||
|
sx={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Building2 size={18} color="#94a3b8" />
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<Typography
|
||||||
|
sx={{ fontWeight: 600, color: '#0f172a', fontSize: '0.85rem', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', lineHeight: 1.3 }}
|
||||||
|
>
|
||||||
|
{property?.title ?? '—'}
|
||||||
|
</Typography>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, color: '#64748b' }}>
|
||||||
|
<MapPin size={11} style={{ flexShrink: 0 }} />
|
||||||
|
<Typography variant="caption" sx={{ fontSize: '0.72rem', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||||
|
{property?.location.city}
|
||||||
|
{property?.areaSqm ? ` · ${property.areaSqm.toLocaleString('de-CH')} m²` : ''}
|
||||||
|
{property?.rentPricePerSqm ? ` · CHF ${property.rentPricePerSqm}/m²/J.` : ''}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
endIcon={<ArrowRight size={12} />}
|
||||||
|
onClick={() => navigate('/supply/properties')}
|
||||||
|
sx={{ textTransform: 'none', fontSize: '0.75rem', color: '#152642', whiteSpace: 'nowrap', flexShrink: 0 }}
|
||||||
|
>
|
||||||
|
Objekt ansehen
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Chat (flex: 1) */}
|
||||||
|
<Box sx={{ flex: 1, overflow: 'hidden' }}>
|
||||||
|
<InquiryChat
|
||||||
|
inquiry={inquiry}
|
||||||
|
onCreateOffer={() => setOfferWizardOpen(true)}
|
||||||
|
pendingAttachment={pendingAttachment}
|
||||||
|
onPendingAttachmentConsumed={() => setPendingAttachment(null)}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Bottom: weitere passende Objekte */}
|
||||||
|
<Box sx={{ flexShrink: 0, borderTop: '1px solid #e2e8f0' }}>
|
||||||
|
<RelatedPropertyCardPanel
|
||||||
|
propertyId={inquiry.propertyId}
|
||||||
|
inquiryId={inquiry.id}
|
||||||
|
compact
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{preparationOpen && (
|
||||||
|
<PreparationWizardLazy
|
||||||
|
inquiryId={inquiry.id}
|
||||||
|
inquiry={inquiry}
|
||||||
|
onClose={() => setPreparationOpen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{offerWizardOpen && (
|
||||||
|
<OfferCreationWizardLazy
|
||||||
|
inquiryId={inquiry.id}
|
||||||
|
propertyId={inquiry.propertyId}
|
||||||
|
tenantName={inquiry.tenantName}
|
||||||
|
onClose={() => setOfferWizardOpen(false)}
|
||||||
|
onAttach={(label) => {
|
||||||
|
setPendingAttachment({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
fileName: label,
|
||||||
|
fileType: 'application/pdf',
|
||||||
|
generated: true,
|
||||||
|
})
|
||||||
|
setOfferWizardOpen(false)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lazy-loaded wizard wrappers to avoid circular imports at module load time
|
||||||
|
import { lazy, Suspense } from 'react'
|
||||||
|
const PreparationWizardComponent = lazy(() =>
|
||||||
|
import('./PreparationWizard').then(m => ({ default: m.PreparationWizard }))
|
||||||
|
)
|
||||||
|
const OfferCreationWizardComponent = lazy(() =>
|
||||||
|
import('./OfferCreationWizard').then(m => ({ default: m.OfferCreationWizard }))
|
||||||
|
)
|
||||||
|
|
||||||
|
import type { Inquiry } from '../../domain/inquiry'
|
||||||
|
|
||||||
|
function PreparationWizardLazy(props: { inquiryId: string; inquiry: Inquiry; onClose: () => void }) {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<PreparationWizardComponent {...props} />
|
||||||
|
</Suspense>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function OfferCreationWizardLazy(props: {
|
||||||
|
inquiryId: string
|
||||||
|
propertyId: string
|
||||||
|
tenantName: string
|
||||||
|
onClose: () => void
|
||||||
|
onAttach: (label: string) => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<OfferCreationWizardComponent {...props} />
|
||||||
|
</Suspense>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { Box } from '@mui/material'
|
||||||
|
import type { Inquiry } from '../../domain/inquiry'
|
||||||
|
import { InquiryListRow } from './InquiryListRow'
|
||||||
|
|
||||||
|
interface InquiryListProps {
|
||||||
|
inquiries: Inquiry[]
|
||||||
|
selectedId: string | null
|
||||||
|
onSelect: (id: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InquiryList({ inquiries, selectedId, onSelect }: InquiryListProps) {
|
||||||
|
return (
|
||||||
|
<Box sx={{ overflowY: 'auto', flex: 1 }}>
|
||||||
|
{inquiries.map(inq => (
|
||||||
|
<InquiryListRow
|
||||||
|
key={inq.id}
|
||||||
|
inquiry={inq}
|
||||||
|
selected={inq.id === selectedId}
|
||||||
|
onClick={() => onSelect(inq.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import { Box, Typography } from '@mui/material'
|
||||||
|
import { Building2 } from 'lucide-react'
|
||||||
|
import type { Inquiry } from '../../domain/inquiry'
|
||||||
|
import { formatInquiryDate, propertyLabelFromId } from './inquiryUtils'
|
||||||
|
|
||||||
|
interface InquiryListRowProps {
|
||||||
|
inquiry: Inquiry
|
||||||
|
selected: boolean
|
||||||
|
onClick: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InquiryListRow({ inquiry, selected, onClick }: InquiryListRowProps) {
|
||||||
|
const hasUnread = !inquiry.isRead && inquiry.unreadCount > 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
onClick={onClick}
|
||||||
|
sx={{
|
||||||
|
px: 2,
|
||||||
|
py: 1,
|
||||||
|
borderBottom: '1px solid #e2e8f0',
|
||||||
|
borderLeft: selected ? '4px solid #2563eb' : `3px solid ${hasUnread ? '#2563eb' : 'transparent'}`,
|
||||||
|
bgcolor: selected ? '#eff6ff' : 'white',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'background-color 0.15s, border-color 0.15s',
|
||||||
|
'&:hover': { bgcolor: selected ? '#f1f5f9' : '#f8fafc' },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 0.5 }}>
|
||||||
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
sx={{ fontWeight: selected || hasUnread ? 700 : 600, color: selected ? '#1d4ed8' : '#0f172a', fontSize: '0.85rem' }}
|
||||||
|
>
|
||||||
|
{inquiry.tenantName}
|
||||||
|
{inquiry.tenantCompany ? ` · ${inquiry.tenantCompany}` : ''}
|
||||||
|
</Typography>
|
||||||
|
{hasUnread && (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
minWidth: 18,
|
||||||
|
height: 18,
|
||||||
|
borderRadius: '50%',
|
||||||
|
bgcolor: '#2563eb',
|
||||||
|
color: 'white',
|
||||||
|
fontSize: '0.6rem',
|
||||||
|
fontWeight: 700,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
flexShrink: 0,
|
||||||
|
px: inquiry.unreadCount > 9 ? 0.5 : 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{inquiry.unreadCount}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
sx={{
|
||||||
|
fontWeight: hasUnread ? 600 : 500,
|
||||||
|
color: '#1e293b',
|
||||||
|
fontSize: '0.8125rem',
|
||||||
|
mb: 0.5,
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{inquiry.subject}
|
||||||
|
</Typography>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, color: '#64748b', fontSize: '0.75rem' }}>
|
||||||
|
<Building2 size={12} />
|
||||||
|
<Typography variant="caption" sx={{ fontSize: '0.75rem' }}>
|
||||||
|
{propertyLabelFromId(inquiry.propertyId)}
|
||||||
|
</Typography>
|
||||||
|
{inquiry.matchScore !== undefined && (
|
||||||
|
<Typography sx={{ ml: 'auto', fontSize: '0.75rem', fontWeight: 700, color: '#152642' }}>
|
||||||
|
{inquiry.matchScore}%
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
<Typography variant="caption" sx={{ color: '#94a3b8', fontSize: '0.7rem', mt: 0.5, display: 'block' }}>
|
||||||
|
{formatInquiryDate(inquiry.createdAt)}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
import { Box, Typography } from '@mui/material'
|
||||||
|
import { Paperclip } from 'lucide-react'
|
||||||
|
import type { InquiryMessage } from '../../domain/inquiry'
|
||||||
|
import { formatInquiryDate, formatFileSize } from './inquiryUtils'
|
||||||
|
|
||||||
|
interface InquiryMessageBubbleProps {
|
||||||
|
message: InquiryMessage
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InquiryMessageBubble({ message }: InquiryMessageBubbleProps) {
|
||||||
|
const isTenant = message.senderType === 'tenant'
|
||||||
|
const isSupply = message.senderType === 'supply_user'
|
||||||
|
const isSystemOrAi = message.senderType === 'system' || message.senderType === 'ai'
|
||||||
|
|
||||||
|
if (isSystemOrAi) {
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'center', my: 1 }}>
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
sx={{
|
||||||
|
color: '#64748b',
|
||||||
|
fontStyle: 'italic',
|
||||||
|
bgcolor: '#f1f5f9',
|
||||||
|
px: 2,
|
||||||
|
py: 0.5,
|
||||||
|
borderRadius: 4,
|
||||||
|
fontSize: '0.75rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{message.senderName}: {message.body}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: isTenant ? 'flex-start' : 'flex-end', mb: 1.5 }}>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
maxWidth: '75%',
|
||||||
|
bgcolor: isTenant ? '#f1f5f9' : '#152642',
|
||||||
|
color: isTenant ? '#0f172a' : 'white',
|
||||||
|
px: 1.75,
|
||||||
|
py: 1.25,
|
||||||
|
borderRadius: 2,
|
||||||
|
boxShadow: '0 1px 2px rgba(15,23,42,0.06)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 1.5, mb: 0.5 }}>
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
sx={{
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: '0.75rem',
|
||||||
|
color: isTenant ? '#0f172a' : 'white',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{message.senderName}
|
||||||
|
</Typography>
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
sx={{
|
||||||
|
fontSize: '0.7rem',
|
||||||
|
color: isTenant ? '#64748b' : 'rgba(255,255,255,0.7)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{formatInquiryDate(message.createdAt)}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
{message.subject && (
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
sx={{
|
||||||
|
display: 'block',
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: '0.75rem',
|
||||||
|
color: isTenant ? '#334155' : 'rgba(255,255,255,0.85)',
|
||||||
|
mb: 0.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{message.subject}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
sx={{
|
||||||
|
whiteSpace: 'pre-wrap',
|
||||||
|
fontSize: '0.85rem',
|
||||||
|
lineHeight: 1.5,
|
||||||
|
color: isTenant ? '#0f172a' : 'white',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{message.body}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{message.attachments.length > 0 && (
|
||||||
|
<Box sx={{ mt: 1, display: 'flex', flexDirection: 'column', gap: 0.5 }}>
|
||||||
|
{message.attachments.map(att => (
|
||||||
|
<Box
|
||||||
|
key={att.id}
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 0.75,
|
||||||
|
bgcolor: isTenant ? 'white' : 'rgba(255,255,255,0.12)',
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: isTenant ? '#cbd5e1' : 'rgba(255,255,255,0.25)',
|
||||||
|
px: 1,
|
||||||
|
py: 0.5,
|
||||||
|
borderRadius: 1,
|
||||||
|
fontSize: '0.75rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Paperclip size={12} />
|
||||||
|
<Typography variant="caption" sx={{ fontSize: '0.75rem', fontWeight: 500 }}>
|
||||||
|
{att.fileName}
|
||||||
|
</Typography>
|
||||||
|
{att.fileSize && (
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
sx={{
|
||||||
|
fontSize: '0.7rem',
|
||||||
|
color: isTenant ? '#64748b' : 'rgba(255,255,255,0.65)',
|
||||||
|
ml: 'auto',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{formatFileSize(att.fileSize)}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isSupply && (
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
sx={{
|
||||||
|
display: 'block',
|
||||||
|
fontSize: '0.65rem',
|
||||||
|
color: 'rgba(255,255,255,0.6)',
|
||||||
|
mt: 0.75,
|
||||||
|
textAlign: 'right',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Wincasa AG
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
CircularProgress,
|
||||||
|
IconButton,
|
||||||
|
TextField,
|
||||||
|
Typography,
|
||||||
|
} from '@mui/material'
|
||||||
|
import { Paperclip, Send, Sparkles, X } from 'lucide-react'
|
||||||
|
import { useSendInquiryReply } from '../../hooks/useInquiries'
|
||||||
|
import { useToastStore } from '../../stores/toastStore'
|
||||||
|
import type { Attachment } from '../../domain/inquiry'
|
||||||
|
import { formatFileSize } from './inquiryUtils'
|
||||||
|
|
||||||
|
interface InquiryReplyComposerProps {
|
||||||
|
inquiryId: string
|
||||||
|
defaultSubject: string
|
||||||
|
tenantName?: string
|
||||||
|
onSent?: () => void
|
||||||
|
pendingAttachment?: Attachment | null
|
||||||
|
onPendingAttachmentConsumed?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InquiryReplyComposer({
|
||||||
|
inquiryId,
|
||||||
|
defaultSubject,
|
||||||
|
tenantName,
|
||||||
|
onSent,
|
||||||
|
pendingAttachment,
|
||||||
|
onPendingAttachmentConsumed,
|
||||||
|
}: InquiryReplyComposerProps) {
|
||||||
|
const [subject, setSubject] = useState(
|
||||||
|
defaultSubject.startsWith('Re:') ? defaultSubject : `Re: ${defaultSubject}`,
|
||||||
|
)
|
||||||
|
const [body, setBody] = useState('')
|
||||||
|
const [attachments, setAttachments] = useState<Attachment[]>([])
|
||||||
|
const [isAIDraft, setIsAIDraft] = useState(false)
|
||||||
|
|
||||||
|
const sendReply = useSendInquiryReply()
|
||||||
|
const showToast = useToastStore(s => s.showToast)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (pendingAttachment) {
|
||||||
|
setAttachments(prev => {
|
||||||
|
if (prev.find(a => a.id === pendingAttachment.id)) return prev
|
||||||
|
return [...prev, pendingAttachment]
|
||||||
|
})
|
||||||
|
onPendingAttachmentConsumed?.()
|
||||||
|
}
|
||||||
|
}, [pendingAttachment, onPendingAttachmentConsumed])
|
||||||
|
|
||||||
|
const sending = sendReply.isPending
|
||||||
|
|
||||||
|
function handleAIDraft() {
|
||||||
|
const salutation = tenantName ? `Sehr geehrte/r ${tenantName}` : 'Sehr geehrte Damen und Herren'
|
||||||
|
setBody(
|
||||||
|
`${salutation},\n\nVielen Dank für Ihre Anfrage. Gerne bestätigen wir, dass das Objekt zum gewünschten Zeitpunkt verfügbar ist.\n\nWir würden Ihnen gerne einen Besichtigungstermin vorschlagen — bitte teilen Sie uns Ihre Verfügbarkeit mit, damit wir einen passenden Termin finden können.\n\nFür Rückfragen stehen wir Ihnen jederzeit gerne zur Verfügung.\n\nMit freundlichen Grüssen`
|
||||||
|
)
|
||||||
|
setIsAIDraft(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAddMockAttachment = () => {
|
||||||
|
const name = `Anhang_${attachments.length + 1}.pdf`
|
||||||
|
setAttachments(prev => [
|
||||||
|
...prev,
|
||||||
|
{
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
fileName: name,
|
||||||
|
fileType: 'application/pdf',
|
||||||
|
fileSize: 240_000 + Math.floor(Math.random() * 800_000),
|
||||||
|
},
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRemoveAttachment = (id: string) => {
|
||||||
|
setAttachments(prev => prev.filter(a => a.id !== id))
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSend = async () => {
|
||||||
|
if (!body.trim()) {
|
||||||
|
showToast('Bitte geben Sie eine Nachricht ein', 'warning')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const result = await sendReply.mutateAsync({
|
||||||
|
inquiryId,
|
||||||
|
payload: { subject, body, attachments },
|
||||||
|
})
|
||||||
|
if (result.error) {
|
||||||
|
showToast(`Fehler: ${result.error}`, 'error')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
showToast('Antwort gesendet', 'success')
|
||||||
|
setBody('')
|
||||||
|
setAttachments([])
|
||||||
|
setIsAIDraft(false)
|
||||||
|
onSent?.()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
borderTop: '1px solid #e2e8f0',
|
||||||
|
bgcolor: '#f8fafc',
|
||||||
|
p: 2,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 1.25,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TextField
|
||||||
|
size="small"
|
||||||
|
label="Betreff"
|
||||||
|
value={subject}
|
||||||
|
onChange={e => setSubject(e.target.value)}
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
|
||||||
|
{isAIDraft && (
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
|
||||||
|
<Sparkles size={12} color="#7c3aed" />
|
||||||
|
<Typography variant="caption" sx={{ color: '#7c3aed', fontSize: '0.7rem', fontWeight: 500 }}>
|
||||||
|
KI-Entwurf — bitte prüfen und anpassen
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
size="small"
|
||||||
|
label="Nachricht"
|
||||||
|
value={body}
|
||||||
|
onChange={e => { setBody(e.target.value); setIsAIDraft(false) }}
|
||||||
|
multiline
|
||||||
|
rows={7}
|
||||||
|
placeholder="Antwort verfassen..."
|
||||||
|
fullWidth
|
||||||
|
sx={isAIDraft ? { '& .MuiOutlinedInput-root': { borderColor: '#ddd6fe' }, '& fieldset': { borderColor: '#ddd6fe !important' } } : {}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{attachments.length > 0 && (
|
||||||
|
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.75 }}>
|
||||||
|
{attachments.map(a => (
|
||||||
|
<Box
|
||||||
|
key={a.id}
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 0.75,
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: a.generated ? '#bfdbfe' : '#cbd5e1',
|
||||||
|
borderRadius: 1,
|
||||||
|
px: 1,
|
||||||
|
py: 0.5,
|
||||||
|
fontSize: '0.75rem',
|
||||||
|
bgcolor: a.generated ? '#eff6ff' : 'white',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Paperclip size={12} color={a.generated ? '#2563eb' : undefined} />
|
||||||
|
<Typography variant="caption" sx={{ fontSize: '0.75rem', color: a.generated ? '#1d4ed8' : undefined }}>
|
||||||
|
{a.fileName}
|
||||||
|
</Typography>
|
||||||
|
{a.fileSize && (
|
||||||
|
<Typography variant="caption" sx={{ fontSize: '0.7rem', color: '#64748b' }}>
|
||||||
|
{formatFileSize(a.fileSize)}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
<IconButton size="small" onClick={() => handleRemoveAttachment(a.id)} sx={{ p: 0.25 }}>
|
||||||
|
<X size={12} />
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, justifyContent: 'space-between' }}>
|
||||||
|
<Box sx={{ display: 'flex', gap: 0.75 }}>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
startIcon={<Sparkles size={13} />}
|
||||||
|
onClick={handleAIDraft}
|
||||||
|
sx={{
|
||||||
|
textTransform: 'none',
|
||||||
|
fontSize: '0.75rem',
|
||||||
|
color: '#7c3aed',
|
||||||
|
borderColor: '#ddd6fe',
|
||||||
|
'&:hover': { bgcolor: '#f5f3ff', borderColor: '#c4b5fd' },
|
||||||
|
}}
|
||||||
|
variant="outlined"
|
||||||
|
>
|
||||||
|
KI-Entwurf
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
startIcon={<Paperclip size={13} />}
|
||||||
|
onClick={handleAddMockAttachment}
|
||||||
|
sx={{ textTransform: 'none', fontSize: '0.75rem' }}
|
||||||
|
>
|
||||||
|
Anhang
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
size="small"
|
||||||
|
startIcon={
|
||||||
|
sending ? <CircularProgress size={14} sx={{ color: 'white' }} /> : <Send size={14} />
|
||||||
|
}
|
||||||
|
onClick={handleSend}
|
||||||
|
disabled={sending || !body.trim()}
|
||||||
|
sx={{ textTransform: 'none', bgcolor: '#152642', '&:hover': { bgcolor: '#16304d' } }}
|
||||||
|
>
|
||||||
|
Antwort senden
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { Box, IconButton, Typography, useMediaQuery, useTheme } from '@mui/material'
|
||||||
|
import { ArrowLeft, Sparkles } from 'lucide-react'
|
||||||
|
import { usePublicNeeds, useLatentNeedById } from '../../hooks/useLatentNeeds'
|
||||||
|
import { EmptyState } from '../ui'
|
||||||
|
import { PublicNeedList } from './PublicNeedList'
|
||||||
|
import { PublicNeedDetail } from './PublicNeedDetail'
|
||||||
|
import { OwnPropertyMatchList } from './OwnPropertyMatchList'
|
||||||
|
|
||||||
|
type MobileView = 'list' | 'detail' | 'properties'
|
||||||
|
|
||||||
|
export function LatentInquiriesTab() {
|
||||||
|
const { data: needs = [] } = usePublicNeeds()
|
||||||
|
const [selectedNeedId, setSelectedNeedId] = useState<string | null>(null)
|
||||||
|
const { data: selectedNeed } = useLatentNeedById(selectedNeedId)
|
||||||
|
const theme = useTheme()
|
||||||
|
const isMobile = useMediaQuery(theme.breakpoints.down('md'))
|
||||||
|
const isTablet = useMediaQuery(theme.breakpoints.between('md', 'lg'))
|
||||||
|
const [mobileView, setMobileView] = useState<MobileView>('list')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isMobile && !selectedNeedId && needs.length > 0) {
|
||||||
|
setSelectedNeedId(needs[0].id)
|
||||||
|
}
|
||||||
|
}, [needs, selectedNeedId, isMobile])
|
||||||
|
|
||||||
|
const handleSelectNeed = (id: string) => {
|
||||||
|
setSelectedNeedId(id)
|
||||||
|
if (isMobile) setMobileView('detail')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mobile: sequential single-panel navigation
|
||||||
|
if (isMobile) {
|
||||||
|
if (mobileView === 'detail' && selectedNeed) {
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
||||||
|
<Box sx={{ px: 1.5, py: 1, borderBottom: '1px solid #e2e8f0', bgcolor: 'white', flexShrink: 0, display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
|
<IconButton size="small" onClick={() => setMobileView('list')}>
|
||||||
|
<ArrowLeft size={18} />
|
||||||
|
</IconButton>
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 600, fontSize: '0.85rem' }} noWrap>
|
||||||
|
{selectedNeed.title}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
|
||||||
|
<PublicNeedDetail need={selectedNeed} />
|
||||||
|
<Box sx={{ px: 2, py: 1.5, borderTop: '1px solid #e2e8f0', flexShrink: 0 }}>
|
||||||
|
<Box
|
||||||
|
onClick={() => setMobileView('properties')}
|
||||||
|
sx={{ p: 1.25, bgcolor: '#152642', color: 'white', borderRadius: 1, textAlign: 'center', cursor: 'pointer', fontSize: '0.85rem', fontWeight: 600 }}
|
||||||
|
>
|
||||||
|
Eigene Objekte anzeigen →
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (mobileView === 'properties' && selectedNeed) {
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
||||||
|
<Box sx={{ px: 1.5, py: 1, borderBottom: '1px solid #e2e8f0', bgcolor: 'white', flexShrink: 0, display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
|
<IconButton size="small" onClick={() => setMobileView('detail')}>
|
||||||
|
<ArrowLeft size={18} />
|
||||||
|
</IconButton>
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 600, fontSize: '0.85rem' }}>
|
||||||
|
Eigene Objekte
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ flex: 1, overflow: 'hidden' }}>
|
||||||
|
<OwnPropertyMatchList need={selectedNeed} />
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// list view
|
||||||
|
return (
|
||||||
|
<Box sx={{ height: '100%', overflow: 'hidden' }}>
|
||||||
|
<PublicNeedList selectedNeedId={selectedNeedId} onSelect={handleSelectNeed} fullWidth />
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tablet: hide own-property column, show it as button in detail
|
||||||
|
if (isTablet) {
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: 'flex', height: '100%', overflow: 'hidden' }}>
|
||||||
|
<PublicNeedList selectedNeedId={selectedNeedId} onSelect={setSelectedNeedId} />
|
||||||
|
<Box sx={{ flex: 1, display: 'flex', overflow: 'hidden' }}>
|
||||||
|
{selectedNeed ? (
|
||||||
|
<PublicNeedDetail need={selectedNeed} />
|
||||||
|
) : (
|
||||||
|
<Box sx={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
|
<EmptyState icon={<Sparkles size={40} />} title="Bedarf auswählen" description="Wählen Sie einen latenten Bedarf, um Details zu sehen." />
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Desktop: full 3-column layout
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: 'flex', height: '100%', overflow: 'hidden' }}>
|
||||||
|
<PublicNeedList selectedNeedId={selectedNeedId} onSelect={setSelectedNeedId} />
|
||||||
|
<Box sx={{ flex: 1, display: 'flex', overflow: 'hidden' }}>
|
||||||
|
{selectedNeed ? (
|
||||||
|
<PublicNeedDetail need={selectedNeed} />
|
||||||
|
) : (
|
||||||
|
<Box sx={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
|
<EmptyState icon={<Sparkles size={40} />} title="Bedarf auswählen" description="Wählen Sie einen latenten Bedarf, um Details und passende Objekte zu sehen." />
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,282 @@
|
|||||||
|
import { Box, Chip, Divider, Typography } from '@mui/material'
|
||||||
|
import { Building2, MapPin, Ruler, Calendar } from 'lucide-react'
|
||||||
|
import type { InquiryPreparationReportDraft, ReportObjectFieldKey } from '../../domain/inquiryReport'
|
||||||
|
import type { Property } from '../../domain/property'
|
||||||
|
import type { Inquiry } from '../../domain/inquiry'
|
||||||
|
import { DS_TEXT, DS_BG, DS_SURFACE, DS_BORDER } from '../../lib/ds'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
draft: InquiryPreparationReportDraft
|
||||||
|
inquiry: Inquiry
|
||||||
|
properties: Property[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const FIELD_LABELS: Partial<Record<ReportObjectFieldKey, string>> = {
|
||||||
|
areaSqm: 'Fläche',
|
||||||
|
rentPricePerSqm: 'Mietpreis',
|
||||||
|
availabilityDate: 'Verfügbar ab',
|
||||||
|
leaseTerm: 'Mietlaufzeit',
|
||||||
|
breakoutOption: 'Breakout-Option',
|
||||||
|
breakoutOptionDate: 'Breakout Zeitpunkt',
|
||||||
|
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',
|
||||||
|
marketSignals: 'Marktsignale',
|
||||||
|
negotiationHints: 'Verhandlungshinweise',
|
||||||
|
missingData: 'Datenlücken',
|
||||||
|
dataQuality: 'Datenqualität',
|
||||||
|
description: 'Beschreibung',
|
||||||
|
units: 'Stockwerkstruktur',
|
||||||
|
}
|
||||||
|
|
||||||
|
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²/Jahr` : 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ätze` : 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 LatentInquiryReportPreview({ draft, inquiry, properties }: Props) {
|
||||||
|
const editableByField = Object.fromEntries(draft.editableFields.map(f => [f.id, f.value]))
|
||||||
|
const selectedProps = draft.selectedPropertyIds
|
||||||
|
.map(id => properties.find(p => p.id === id))
|
||||||
|
.filter((p): p is Property => !!p)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0, fontFamily: 'Georgia, serif' }}>
|
||||||
|
|
||||||
|
{/* Page 1 — Need Summary */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
bgcolor: 'white',
|
||||||
|
p: 4,
|
||||||
|
minHeight: 600,
|
||||||
|
boxShadow: '0 2px 12px rgba(0,0,0,0.1)',
|
||||||
|
borderRadius: 1,
|
||||||
|
mb: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box sx={{ borderBottom: `3px solid ${DS_TEXT.brand}`, pb: 2, mb: 3 }}>
|
||||||
|
<Typography variant="h5" sx={{ fontWeight: 700, color: DS_TEXT.brand, fontFamily: 'inherit' }}>
|
||||||
|
Objektvorschlag
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" sx={{ color: DS_TEXT.muted, mt: 0.5 }}>
|
||||||
|
Wincasa AG · {new Date().toLocaleDateString('de-CH')}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{editableByField['intro'] && (
|
||||||
|
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap', lineHeight: 1.8, mb: 3, color: DS_TEXT.primary }}>
|
||||||
|
{editableByField['intro']}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<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: DS_TEXT.primary, mb: 1 }}>
|
||||||
|
Ihre Anfrage
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" sx={{ color: DS_TEXT.primary, mb: 0.5 }}>
|
||||||
|
<strong>Kontakt:</strong> {inquiry.tenantName}{inquiry.tenantCompany ? ` · ${inquiry.tenantCompany}` : ''}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" sx={{ color: DS_TEXT.primary }}>
|
||||||
|
<strong>Betreff:</strong> {inquiry.subject}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{editableByField['highlights'] && (
|
||||||
|
<>
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 700, color: DS_TEXT.primary, mb: 1 }}>
|
||||||
|
Warum diese Objekte passen
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap', lineHeight: 1.8, color: DS_TEXT.primary, mb: 3 }}>
|
||||||
|
{editableByField['highlights']}
|
||||||
|
</Typography>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
|
||||||
|
{selectedProps.map(p => (
|
||||||
|
<Chip
|
||||||
|
key={p.id}
|
||||||
|
label={p.title}
|
||||||
|
size="small"
|
||||||
|
icon={<Building2 size={12} />}
|
||||||
|
sx={{ bgcolor: DS_SURFACE.indigo.bg, color: DS_TEXT.brand, fontWeight: 600 }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Page 2+ — One section per property */}
|
||||||
|
{selectedProps.map((property, idx) => {
|
||||||
|
const selection = draft.fieldSelections.find(fs => fs.propertyId === property.id)
|
||||||
|
const optionalFields = selection?.selectedOptionalFields ?? []
|
||||||
|
const image = property.images?.[0]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box key={property.id}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
|
||||||
|
<Divider sx={{ flex: 1 }} />
|
||||||
|
<Typography variant="caption" sx={{ color: DS_TEXT.disabled, fontSize: '0.7rem', whiteSpace: 'nowrap' }}>
|
||||||
|
Objekt {idx + 1} von {selectedProps.length}
|
||||||
|
</Typography>
|
||||||
|
<Divider sx={{ flex: 1 }} />
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
bgcolor: 'white',
|
||||||
|
p: 3,
|
||||||
|
boxShadow: '0 2px 12px rgba(0,0,0,0.1)',
|
||||||
|
borderRadius: 1,
|
||||||
|
mb: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box sx={{ display: 'flex', gap: 2, mb: 2.5 }}>
|
||||||
|
{/* Map placeholder */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: 160,
|
||||||
|
height: 120,
|
||||||
|
borderRadius: 1,
|
||||||
|
overflow: 'hidden',
|
||||||
|
bgcolor: DS_BORDER.default,
|
||||||
|
backgroundImage: property.mapImageUrl ? `url(${property.mapImageUrl})` : 'none',
|
||||||
|
backgroundSize: 'cover',
|
||||||
|
backgroundPosition: 'center',
|
||||||
|
flexShrink: 0,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{!property.mapImageUrl && <MapPin size={28} color={DS_TEXT.disabled} />}
|
||||||
|
</Box>
|
||||||
|
{/* Property photo */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
flex: 1,
|
||||||
|
height: 120,
|
||||||
|
borderRadius: 1,
|
||||||
|
overflow: 'hidden',
|
||||||
|
bgcolor: DS_BORDER.default,
|
||||||
|
backgroundImage: image ? `url(${image})` : 'none',
|
||||||
|
backgroundSize: 'cover',
|
||||||
|
backgroundPosition: 'center',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{!image && <Building2 size={28} color={DS_TEXT.disabled} />}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Typography variant="h6" sx={{ fontWeight: 700, color: DS_TEXT.brand, mb: 0.5, fontFamily: 'inherit' }}>
|
||||||
|
{property.title}
|
||||||
|
</Typography>
|
||||||
|
<Box sx={{ display: 'flex', gap: 2, mb: 2 }}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, color: DS_TEXT.muted }}>
|
||||||
|
<MapPin size={13} />
|
||||||
|
<Typography variant="caption">{property.location.city}{property.location.district ? `, ${property.location.district}` : ''}</Typography>
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, color: DS_TEXT.muted }}>
|
||||||
|
<Ruler size={13} />
|
||||||
|
<Typography variant="caption">{property.areaSqm.toLocaleString('de-CH')} m²</Typography>
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, color: DS_TEXT.muted }}>
|
||||||
|
<Calendar size={13} />
|
||||||
|
<Typography variant="caption">{new Date(property.availabilityDate).toLocaleDateString('de-CH')}</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{optionalFields.length > 0 && (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: '1fr 1fr',
|
||||||
|
gap: 0.75,
|
||||||
|
bgcolor: DS_BG.page,
|
||||||
|
borderRadius: 1,
|
||||||
|
p: 1.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{optionalFields.map(key => {
|
||||||
|
const val = getFieldValue(property, key)
|
||||||
|
if (!val) return null
|
||||||
|
return (
|
||||||
|
<Box key={key}>
|
||||||
|
<Typography variant="caption" sx={{ color: DS_TEXT.muted, fontSize: '0.68rem', display: 'block' }}>
|
||||||
|
{FIELD_LABELS[key] ?? key}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" sx={{ fontWeight: 600, color: DS_TEXT.primary, fontSize: '0.78rem' }}>
|
||||||
|
{val}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
|
{editableByField['next_steps'] && (
|
||||||
|
<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: DS_TEXT.primary, mb: 1 }}>
|
||||||
|
Nächste Schritte
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap', lineHeight: 1.8, color: DS_TEXT.primary }}>
|
||||||
|
{editableByField['next_steps']}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,251 @@
|
|||||||
|
import { Box, Divider, Typography } from '@mui/material'
|
||||||
|
import { Building2, FileText, MapPin } from 'lucide-react'
|
||||||
|
import { useOfferWizardStore } from '../../stores/offerWizardStore'
|
||||||
|
import { mockProperties } from '../../mock-data/properties'
|
||||||
|
import type { ReportObjectFieldKey, ReportObjectFieldSelection } from '../../domain/inquiryReport'
|
||||||
|
import type { Property } from '../../domain/property'
|
||||||
|
|
||||||
|
interface MockPdfPreviewProps {
|
||||||
|
fields: Record<string, string>
|
||||||
|
fallbackFields: Record<string, string>
|
||||||
|
fieldSelections: ReportObjectFieldSelection[]
|
||||||
|
}
|
||||||
|
|
||||||
|
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 propertyIds = useOfferWizardStore(s => s.selectedPropertyIds)
|
||||||
|
|
||||||
|
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] ?? ''
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
bgcolor: 'white',
|
||||||
|
borderRadius: 1.5,
|
||||||
|
border: '1px solid #cbd5e1',
|
||||||
|
boxShadow: '0 4px 16px rgba(15,23,42,0.08)',
|
||||||
|
p: 4,
|
||||||
|
height: '100%',
|
||||||
|
overflowY: 'auto',
|
||||||
|
fontFamily: '"Georgia", "Times New Roman", serif',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* ── Page 1 — Offer letter ── */}
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3, color: '#64748b' }}>
|
||||||
|
<FileText size={16} />
|
||||||
|
<Typography variant="caption" sx={{ textTransform: 'uppercase', letterSpacing: 1, fontSize: '0.7rem' }}>
|
||||||
|
Angebotsvorschau (PDF)
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Typography variant="caption" sx={{ display: 'block', color: '#94a3b8', textAlign: 'right' }}>
|
||||||
|
Wincasa AG · Zürich
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" sx={{ display: 'block', color: '#94a3b8', textAlign: 'right', mb: 4 }}>
|
||||||
|
{new Date().toLocaleDateString('de-CH')}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Typography variant="h6" sx={{ fontWeight: 700, color: '#0f172a', mb: 2, fontSize: '1.05rem' }}>
|
||||||
|
Angebot: {needTitle}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Typography variant="body2" sx={{ mb: 1.5, fontSize: '0.875rem' }}>
|
||||||
|
{v('recipient_salutation')},
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Typography variant="body2" sx={{ mb: 2, fontSize: '0.875rem', lineHeight: 1.6, whiteSpace: 'pre-wrap' }}>
|
||||||
|
{v('offer_intro')}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Typography variant="body2" sx={{ mb: 1, fontWeight: 700, fontSize: '0.875rem' }}>
|
||||||
|
Hervorgehobene Kriterien
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" sx={{ mb: 2.5, fontSize: '0.875rem', lineHeight: 1.6, whiteSpace: 'pre-wrap' }}>
|
||||||
|
{v('highlighted_criteria')}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Typography variant="body2" sx={{ mb: 1, fontWeight: 700, fontSize: '0.875rem' }}>
|
||||||
|
Vorgeschlagene Objekte
|
||||||
|
</Typography>
|
||||||
|
<Box component="ul" sx={{ pl: 2, mb: 2.5 }}>
|
||||||
|
{properties.map(p => (
|
||||||
|
<Typography
|
||||||
|
key={p.id}
|
||||||
|
component="li"
|
||||||
|
variant="body2"
|
||||||
|
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²/Jahr
|
||||||
|
</Typography>
|
||||||
|
))}
|
||||||
|
{properties.length === 0 && (
|
||||||
|
<Typography variant="body2" sx={{ color: '#94a3b8', fontStyle: 'italic' }}>
|
||||||
|
Keine Objekte ausgewählt.
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Typography variant="body2" sx={{ mb: 2.5, fontSize: '0.875rem', lineHeight: 1.6, whiteSpace: 'pre-wrap' }}>
|
||||||
|
{v('next_steps')}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Typography variant="body2" sx={{ fontSize: '0.875rem', whiteSpace: 'pre-wrap', lineHeight: 1.6 }}>
|
||||||
|
{v('closing')}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{/* ── Page 2 — Property details side by side ── */}
|
||||||
|
{hasPropertyPage && (
|
||||||
|
<>
|
||||||
|
<Box sx={{ my: 3, display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
||||||
|
<Divider sx={{ flex: 1, borderColor: '#152642', borderWidth: 1 }} />
|
||||||
|
<Typography variant="caption" sx={{ color: '#94a3b8', fontSize: '0.68rem', whiteSpace: 'nowrap', letterSpacing: 0.5, textTransform: 'uppercase' }}>
|
||||||
|
Seite 2 — Objektdetails
|
||||||
|
</Typography>
|
||||||
|
<Divider sx={{ flex: 1, borderColor: '#152642', borderWidth: 1 }} />
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: `repeat(${Math.min(properties.length, 3)}, 1fr)`,
|
||||||
|
gap: 1.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{properties.map(p => {
|
||||||
|
const selection = fieldSelections.find(fs => fs.propertyId === p.id)
|
||||||
|
const optionalFields = selection?.selectedOptionalFields ?? []
|
||||||
|
const image = p.images?.[0]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
key={p.id}
|
||||||
|
sx={{
|
||||||
|
border: '1px solid #e2e8f0',
|
||||||
|
borderRadius: 1,
|
||||||
|
overflow: 'hidden',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Photo */}
|
||||||
|
{image ? (
|
||||||
|
<Box
|
||||||
|
component="img"
|
||||||
|
src={image}
|
||||||
|
alt={p.title}
|
||||||
|
sx={{ width: '100%', height: 90, objectFit: 'cover', display: 'block' }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Box sx={{ height: 90, bgcolor: '#f1f5f9', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
|
<Building2 size={22} color="#94a3b8" />
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Info */}
|
||||||
|
<Box sx={{ p: 1.25, flex: 1 }}>
|
||||||
|
<Typography sx={{ fontWeight: 700, fontSize: '0.78rem', color: '#0f172a', lineHeight: 1.3, mb: 0.4 }}>
|
||||||
|
{p.title}
|
||||||
|
</Typography>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.3, mb: 1 }}>
|
||||||
|
<MapPin size={10} color="#94a3b8" />
|
||||||
|
<Typography sx={{ fontSize: '0.68rem', color: '#64748b' }}>
|
||||||
|
{p.location.city}{p.location.district ? ` · ${p.location.district}` : ''}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Selected fields */}
|
||||||
|
{optionalFields.length > 0 && (
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.4 }}>
|
||||||
|
{optionalFields.map(key => {
|
||||||
|
const val = getFieldValue(p, key)
|
||||||
|
if (!val) return null
|
||||||
|
return (
|
||||||
|
<Box key={key} sx={{ display: 'flex', justifyContent: 'space-between', gap: 0.5 }}>
|
||||||
|
<Typography sx={{ fontSize: '0.62rem', color: '#94a3b8' }}>
|
||||||
|
{FIELD_LABELS[key] ?? key}
|
||||||
|
</Typography>
|
||||||
|
<Typography sx={{ fontSize: '0.68rem', fontWeight: 600, color: '#0f172a', textAlign: 'right' }}>
|
||||||
|
{val}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</Box>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
import { Box, Button, CircularProgress, IconButton, TextField, Typography } from '@mui/material'
|
||||||
|
import { ArrowLeft, FileText, Paperclip, Send, X } from 'lucide-react'
|
||||||
|
import { useOfferWizardStore } from '../../stores/offerWizardStore'
|
||||||
|
import { useSendOffer } from '../../hooks/useOffers'
|
||||||
|
import { useCreateOffer } from '../../hooks/useInquiries'
|
||||||
|
import { useLatentNeedById } from '../../hooks/useLatentNeeds'
|
||||||
|
import { useSessionStore } from '../../stores/sessionStore'
|
||||||
|
import { useToastStore } from '../../stores/toastStore'
|
||||||
|
import { AiOfferEmailButton } from './AiOfferEmailButton'
|
||||||
|
import { mockProperties } from '../../mock-data/properties'
|
||||||
|
import { deterministicMatchScore } from './latentNeedUtils'
|
||||||
|
import { formatFileSize } from './inquiryUtils'
|
||||||
|
|
||||||
|
export function OfferChatComposer() {
|
||||||
|
const subject = useOfferWizardStore(s => s.messageSubject)
|
||||||
|
const body = useOfferWizardStore(s => s.messageDraft)
|
||||||
|
const setSubject = useOfferWizardStore(s => s.setMessageSubject)
|
||||||
|
const setBody = useOfferWizardStore(s => s.setMessageDraft)
|
||||||
|
const attachments = useOfferWizardStore(s => s.attachments)
|
||||||
|
const addAttachment = useOfferWizardStore(s => s.addAttachment)
|
||||||
|
const removeAttachment = useOfferWizardStore(s => s.removeAttachment)
|
||||||
|
const offerDraftId = useOfferWizardStore(s => s.offerDraftId)
|
||||||
|
const selectedPropertyIds = useOfferWizardStore(s => s.selectedPropertyIds)
|
||||||
|
const needId = useOfferWizardStore(s => s.selectedNeedId)
|
||||||
|
const needTitle = useOfferWizardStore(s => s.needTitle)
|
||||||
|
const setStep = useOfferWizardStore(s => s.setStep)
|
||||||
|
const reset = useOfferWizardStore(s => s.reset)
|
||||||
|
|
||||||
|
const sendOffer = useSendOffer()
|
||||||
|
const createOffer = useCreateOffer()
|
||||||
|
const { data: latentNeed } = useLatentNeedById(needId)
|
||||||
|
const currentUser = useSessionStore(s => s.currentUser)
|
||||||
|
const showToast = useToastStore(s => s.showToast)
|
||||||
|
|
||||||
|
const propertyTitles = selectedPropertyIds.map(id => {
|
||||||
|
const p = mockProperties.find(pp => pp.id === id)
|
||||||
|
return p?.title ?? id
|
||||||
|
})
|
||||||
|
const scores = needId
|
||||||
|
? selectedPropertyIds.map(id => deterministicMatchScore(id, needId))
|
||||||
|
: []
|
||||||
|
|
||||||
|
const handleAiGenerated = (newSubject: string, newBody: string) => {
|
||||||
|
setSubject(newSubject)
|
||||||
|
setBody(newBody)
|
||||||
|
showToast('KI-Vorschlag eingefügt', 'success')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAddAttachment = () => {
|
||||||
|
addAttachment({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
fileName: `Anhang_${attachments.length + 1}.pdf`,
|
||||||
|
fileType: 'application/pdf',
|
||||||
|
fileSize: 200_000 + Math.floor(Math.random() * 600_000),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSend = async () => {
|
||||||
|
if (!offerDraftId) return
|
||||||
|
if (!body.trim()) {
|
||||||
|
showToast('Bitte Nachricht eingeben', 'warning')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const res = await sendOffer.mutateAsync(offerDraftId)
|
||||||
|
if (res.error) {
|
||||||
|
showToast(`Fehler: ${res.error}`, 'error')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Angebot als Konversation im Nachfrager-Postfach materialisieren
|
||||||
|
await createOffer.mutateAsync({
|
||||||
|
organizationId: currentUser?.organizationId ?? 'org-wincasa',
|
||||||
|
tenantOrgId: latentNeed?.tenantOrgId ?? 'org-mobimo',
|
||||||
|
needId: needId ?? '',
|
||||||
|
propertyId: selectedPropertyIds[0] ?? '',
|
||||||
|
offeredPropertyIds: selectedPropertyIds,
|
||||||
|
subject,
|
||||||
|
message: body,
|
||||||
|
senderName: currentUser?.organizationName ?? 'Wincasa AG',
|
||||||
|
recipientName: latentNeed?.tenantCompany,
|
||||||
|
attachments,
|
||||||
|
})
|
||||||
|
showToast('Angebot erfolgreich gesendet', 'success')
|
||||||
|
reset()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
||||||
|
<Box sx={{ flex: 1, overflowY: 'auto', p: 3, bgcolor: '#f8fafc' }}>
|
||||||
|
<Box sx={{ maxWidth: 720, mx: 'auto', display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="caption" sx={{ color: '#64748b', fontWeight: 600, textTransform: 'uppercase', letterSpacing: 0.5 }}>
|
||||||
|
Empfänger-Kontext
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" sx={{ fontSize: '0.85rem', color: '#1e293b', mt: 0.5 }}>
|
||||||
|
Bedarf: <strong>{needTitle}</strong> · {selectedPropertyIds.length} Objekt
|
||||||
|
{selectedPropertyIds.length === 1 ? '' : 'e'} im Angebot
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
size="small"
|
||||||
|
label="Betreff"
|
||||||
|
fullWidth
|
||||||
|
value={subject}
|
||||||
|
onChange={e => setSubject(e.target.value)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
size="small"
|
||||||
|
label="Nachricht"
|
||||||
|
fullWidth
|
||||||
|
value={body}
|
||||||
|
onChange={e => setBody(e.target.value)}
|
||||||
|
multiline
|
||||||
|
rows={8}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{attachments.length > 0 && (
|
||||||
|
<Box>
|
||||||
|
<Typography variant="caption" sx={{ color: '#64748b', fontWeight: 600, mb: 0.75, display: 'block' }}>
|
||||||
|
Anhänge
|
||||||
|
</Typography>
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75 }}>
|
||||||
|
{attachments.map(a => (
|
||||||
|
<Box
|
||||||
|
key={a.id}
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 1,
|
||||||
|
bgcolor: 'white',
|
||||||
|
border: '1px solid #cbd5e1',
|
||||||
|
borderRadius: 1,
|
||||||
|
px: 1,
|
||||||
|
py: 0.75,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FileText size={14} color="#152642" />
|
||||||
|
<Typography variant="caption" sx={{ fontSize: '0.8rem', fontWeight: 500 }}>
|
||||||
|
{a.fileName}
|
||||||
|
</Typography>
|
||||||
|
{a.generated && (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
px: 0.75,
|
||||||
|
py: 0.125,
|
||||||
|
bgcolor: '#e0e7ff',
|
||||||
|
color: '#3730a3',
|
||||||
|
fontWeight: 700,
|
||||||
|
fontSize: '0.65rem',
|
||||||
|
borderRadius: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
PDF
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
<Typography variant="caption" sx={{ fontSize: '0.75rem', color: '#64748b', ml: 'auto' }}>
|
||||||
|
{formatFileSize(a.fileSize)}
|
||||||
|
</Typography>
|
||||||
|
<IconButton size="small" onClick={() => removeAttachment(a.id)} sx={{ p: 0.25 }}>
|
||||||
|
<X size={12} />
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
startIcon={<Paperclip size={14} />}
|
||||||
|
onClick={handleAddAttachment}
|
||||||
|
sx={{ textTransform: 'none' }}
|
||||||
|
>
|
||||||
|
Anhang
|
||||||
|
</Button>
|
||||||
|
<AiOfferEmailButton
|
||||||
|
needTitle={needTitle}
|
||||||
|
selectedProperties={propertyTitles}
|
||||||
|
matchScores={scores}
|
||||||
|
onGenerated={handleAiGenerated}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
p: 2,
|
||||||
|
borderTop: '1px solid #e2e8f0',
|
||||||
|
bgcolor: 'white',
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button startIcon={<ArrowLeft size={14} />} onClick={() => setStep('checked')} sx={{ textTransform: 'none' }}>
|
||||||
|
Zurück
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
startIcon={sendOffer.isPending ? <CircularProgress size={14} sx={{ color: 'white' }} /> : <Send size={14} />}
|
||||||
|
onClick={handleSend}
|
||||||
|
disabled={sendOffer.isPending || !body.trim()}
|
||||||
|
sx={{
|
||||||
|
textTransform: 'none',
|
||||||
|
bgcolor: '#16a34a',
|
||||||
|
fontWeight: 600,
|
||||||
|
'&:hover': { bgcolor: '#15803d' },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Absenden
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import { Box, Button, CircularProgress, Typography } from '@mui/material'
|
||||||
|
import { CheckCircle2 } from 'lucide-react'
|
||||||
|
import { useOfferWizardStore } from '../../stores/offerWizardStore'
|
||||||
|
import { useMarkOfferChecked } from '../../hooks/useOffers'
|
||||||
|
import { useToastStore } from '../../stores/toastStore'
|
||||||
|
|
||||||
|
export function OfferCheckedAction() {
|
||||||
|
const offerDraftId = useOfferWizardStore(s => s.offerDraftId)
|
||||||
|
const needTitle = useOfferWizardStore(s => s.needTitle)
|
||||||
|
const setStep = useOfferWizardStore(s => s.setStep)
|
||||||
|
const addAttachment = useOfferWizardStore(s => s.addAttachment)
|
||||||
|
const setMessageDraft = useOfferWizardStore(s => s.setMessageDraft)
|
||||||
|
const setMessageSubject = useOfferWizardStore(s => s.setMessageSubject)
|
||||||
|
|
||||||
|
const markChecked = useMarkOfferChecked()
|
||||||
|
const showToast = useToastStore(s => s.showToast)
|
||||||
|
|
||||||
|
const handleConfirm = async () => {
|
||||||
|
if (!offerDraftId) return
|
||||||
|
const res = await markChecked.mutateAsync(offerDraftId)
|
||||||
|
if (res.error) {
|
||||||
|
showToast(`Fehler: ${res.error}`, 'error')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Add generated PDF as attachment to message
|
||||||
|
addAttachment({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
fileName: `Angebot_${needTitle.replace(/\s+/g, '_')}.pdf`,
|
||||||
|
fileType: 'application/pdf',
|
||||||
|
fileSize: 320_000,
|
||||||
|
generated: true,
|
||||||
|
})
|
||||||
|
setMessageSubject(`Passende Gewerbeflächen zu Ihrer Anfrage: ${needTitle}`)
|
||||||
|
setMessageDraft(
|
||||||
|
`Sehr geehrte Damen und Herren,\n\nbitte finden Sie anbei unser Angebot zu Ihrem Bedarf "${needTitle}". Gerne stehen wir für Rückfragen und Besichtigungstermine zur Verfügung.\n\nFreundliche Grüsse\nWincasa AG`,
|
||||||
|
)
|
||||||
|
setStep('send')
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
flex: 1,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
bgcolor: '#f8fafc',
|
||||||
|
p: 4,
|
||||||
|
textAlign: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: 96,
|
||||||
|
height: 96,
|
||||||
|
borderRadius: '50%',
|
||||||
|
bgcolor: '#dcfce7',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
mb: 3,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CheckCircle2 size={48} color="#16a34a" />
|
||||||
|
</Box>
|
||||||
|
<Typography variant="h5" sx={{ fontWeight: 700, color: '#0f172a', mb: 1 }}>
|
||||||
|
Bereit zur Prüfung
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" sx={{ color: '#475569', maxWidth: 480, mb: 4 }}>
|
||||||
|
Bestätigen Sie das geprüfte Angebot. Das PDF wird automatisch als Anhang für den Chat
|
||||||
|
vorbereitet, damit Sie es direkt versenden können.
|
||||||
|
</Typography>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
size="large"
|
||||||
|
startIcon={
|
||||||
|
markChecked.isPending ? <CircularProgress size={18} sx={{ color: 'white' }} /> : <CheckCircle2 size={18} />
|
||||||
|
}
|
||||||
|
onClick={handleConfirm}
|
||||||
|
disabled={markChecked.isPending}
|
||||||
|
sx={{
|
||||||
|
textTransform: 'none',
|
||||||
|
bgcolor: '#16a34a',
|
||||||
|
fontSize: '1rem',
|
||||||
|
fontWeight: 700,
|
||||||
|
px: 4,
|
||||||
|
py: 1.5,
|
||||||
|
'&:hover': { bgcolor: '#15803d' },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
ANGEBOT GEPRÜFT
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { Box, Button, Typography } from '@mui/material'
|
||||||
|
import { FileText } from 'lucide-react'
|
||||||
|
import { useOfferWizardStore } from '../../stores/offerWizardStore'
|
||||||
|
|
||||||
|
interface OfferCreationPanelProps {
|
||||||
|
selectedCount: number
|
||||||
|
needId: string
|
||||||
|
needTitle: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OfferCreationPanel({ selectedCount, needId, needTitle }: OfferCreationPanelProps) {
|
||||||
|
const open = useOfferWizardStore(s => s.open)
|
||||||
|
if (selectedCount === 0) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
borderTop: '1px solid #e2e8f0',
|
||||||
|
bgcolor: '#f8fafc',
|
||||||
|
p: 1.5,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 1,
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 600, fontSize: '0.85rem', color: '#0f172a' }}>
|
||||||
|
{selectedCount} Objekt{selectedCount === 1 ? '' : 'e'} ausgewählt
|
||||||
|
</Typography>
|
||||||
|
<Button
|
||||||
|
fullWidth
|
||||||
|
variant="contained"
|
||||||
|
size="small"
|
||||||
|
startIcon={<FileText size={14} />}
|
||||||
|
onClick={() => open(needId, needTitle)}
|
||||||
|
sx={{
|
||||||
|
textTransform: 'none',
|
||||||
|
bgcolor: '#152642',
|
||||||
|
fontWeight: 600,
|
||||||
|
'&:hover': { bgcolor: '#16304d' },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Angebot erstellen
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,285 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import {
|
||||||
|
Box, Button, CircularProgress, Dialog, DialogContent,
|
||||||
|
IconButton, Stack, Step, StepLabel, Stepper,
|
||||||
|
TextField, Typography,
|
||||||
|
} from '@mui/material'
|
||||||
|
import { Plus, Trash2, X } from 'lucide-react'
|
||||||
|
import type { OfferReportDraft, ViewingAppointmentOption } from '../../domain/offerReport'
|
||||||
|
import { useCreateOfferReport, useUpdateOfferReport, useGenerateOfferReportPdf } from '../../hooks/useOfferReport'
|
||||||
|
import { usePropertyById } from '../../hooks/useProperties'
|
||||||
|
import { useToastStore } from '../../stores/toastStore'
|
||||||
|
import { OfferWizardPdfStep } from './OfferWizardPdfStep'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
inquiryId: string
|
||||||
|
propertyId: string
|
||||||
|
tenantName: string
|
||||||
|
onClose: () => void
|
||||||
|
onAttach: (label: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const STEPS = ['Daten erfassen', 'Bericht prüfen', 'Besichtigungstermine', 'PDF erstellen']
|
||||||
|
|
||||||
|
export function OfferCreationWizard({ inquiryId, propertyId, tenantName, onClose, onAttach }: Props) {
|
||||||
|
const [step, setStep] = useState(0)
|
||||||
|
const [draft, setDraft] = useState<OfferReportDraft | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [generating, setGenerating] = useState(false)
|
||||||
|
const [progress, setProgress] = useState(0)
|
||||||
|
const [ready, setReady] = useState(false)
|
||||||
|
const { data: property } = usePropertyById(propertyId)
|
||||||
|
const showToast = useToastStore(s => s.showToast)
|
||||||
|
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||||
|
|
||||||
|
const createOfferReport = useCreateOfferReport()
|
||||||
|
const updateOfferReport = useUpdateOfferReport()
|
||||||
|
const generatePdf = useGenerateOfferReportPdf()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
createOfferReport.mutate(
|
||||||
|
{ inquiryId, propertyId, tenantName, propertyTitle: property?.title ?? '' },
|
||||||
|
{ onSuccess: (d) => { setDraft(d); setLoading(false) } },
|
||||||
|
)
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [inquiryId, propertyId, tenantName, property?.title])
|
||||||
|
|
||||||
|
useEffect(() => () => { if (timerRef.current) clearInterval(timerRef.current) }, [])
|
||||||
|
|
||||||
|
const handleUpdateField = (fieldId: string, value: string) => {
|
||||||
|
if (!draft) return
|
||||||
|
setDraft({ ...draft, editableFields: draft.editableFields.map(f => f.id === fieldId ? { ...f, value } : f) })
|
||||||
|
}
|
||||||
|
|
||||||
|
const addAppointment = () => {
|
||||||
|
if (!draft) return
|
||||||
|
const slot: ViewingAppointmentOption = {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
date: new Date(Date.now() + 7 * 86_400_000).toISOString().slice(0, 10),
|
||||||
|
timeSlot: '10:00–11:00',
|
||||||
|
}
|
||||||
|
setDraft({ ...draft, viewingAppointments: [...draft.viewingAppointments, slot] })
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeAppointment = (id: string) => {
|
||||||
|
if (!draft) return
|
||||||
|
setDraft({ ...draft, viewingAppointments: draft.viewingAppointments.filter(a => a.id !== id) })
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateAppointment = (id: string, field: keyof ViewingAppointmentOption, value: string) => {
|
||||||
|
if (!draft) return
|
||||||
|
setDraft({
|
||||||
|
...draft,
|
||||||
|
viewingAppointments: draft.viewingAppointments.map(a =>
|
||||||
|
a.id === id ? { ...a, [field]: value } : a,
|
||||||
|
),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleGeneratePdf = () => {
|
||||||
|
if (!draft) return
|
||||||
|
updateOfferReport.mutate(
|
||||||
|
{ draftId: draft.id, data: { editableFields: draft.editableFields, viewingAppointments: draft.viewingAppointments } },
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
setStep(3)
|
||||||
|
setGenerating(true)
|
||||||
|
setProgress(0)
|
||||||
|
let p = 0
|
||||||
|
timerRef.current = setInterval(() => {
|
||||||
|
p += Math.random() * 18 + 8
|
||||||
|
if (p >= 100) {
|
||||||
|
clearInterval(timerRef.current!)
|
||||||
|
setProgress(100)
|
||||||
|
setGenerating(false)
|
||||||
|
setReady(true)
|
||||||
|
generatePdf.mutate(draft.id, { onSuccess: setDraft })
|
||||||
|
} else {
|
||||||
|
setProgress(Math.min(100, p))
|
||||||
|
}
|
||||||
|
}, 200)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open fullWidth maxWidth="lg" slotProps={{ paper: { sx: { height: '90vh', display: 'flex', flexDirection: 'column' } } }}>
|
||||||
|
<Box sx={{ px: 3, pt: 2.5, pb: 1.5, borderBottom: '1px solid #e2e8f0', display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexShrink: 0 }}>
|
||||||
|
<Typography variant="h6" sx={{ fontWeight: 700 }}>Angebot erstellen</Typography>
|
||||||
|
<Button size="small" onClick={onClose} sx={{ minWidth: 0, p: 0.5 }}><X size={18} /></Button>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ px: 3, py: 2, flexShrink: 0 }}>
|
||||||
|
<Stepper activeStep={step} alternativeLabel>
|
||||||
|
{STEPS.map(label => (
|
||||||
|
<Step key={label}><StepLabel>{label}</StepLabel></Step>
|
||||||
|
))}
|
||||||
|
</Stepper>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<DialogContent sx={{ flex: 1, overflow: 'auto', px: 3 }}>
|
||||||
|
{loading && (
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'center', pt: 6 }}>
|
||||||
|
<CircularProgress />
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step 0: Data summary */}
|
||||||
|
{!loading && step === 0 && property && (
|
||||||
|
<Box sx={{ display: 'flex', gap: 3 }}>
|
||||||
|
<Box sx={{ flex: 1 }}>
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 600, mb: 1 }}>Objekt</Typography>
|
||||||
|
<Box sx={{ border: '1px solid #e2e8f0', borderRadius: 1, p: 2, mb: 2 }}>
|
||||||
|
{property.images?.[0] && (
|
||||||
|
<Box sx={{ height: 120, backgroundImage: `url(${property.images[0]})`, backgroundSize: 'cover', backgroundPosition: 'center', borderRadius: 1, mb: 1.5 }} />
|
||||||
|
)}
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 700 }}>{property.title}</Typography>
|
||||||
|
<Typography variant="caption" sx={{ color: '#64748b' }}>
|
||||||
|
{property.location.city} · {property.areaSqm.toLocaleString('de-CH')} m² · CHF {property.rentPricePerSqm}/m²/Jahr
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 600, mb: 1 }}>Interessent</Typography>
|
||||||
|
<Box sx={{ border: '1px solid #e2e8f0', borderRadius: 1, p: 2 }}>
|
||||||
|
<Typography variant="body2">{tenantName}</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ flex: 1 }}>
|
||||||
|
<Typography variant="body2" sx={{ color: '#64748b', lineHeight: 1.7 }}>
|
||||||
|
Das Angebot wird auf Basis der Objekt- und Anfragedaten vorausgefüllt.
|
||||||
|
Im nächsten Schritt können Sie alle Felder anpassen.
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step 1: Edit fields */}
|
||||||
|
{!loading && step === 1 && draft && (
|
||||||
|
<Stack spacing={2}>
|
||||||
|
{draft.editableFields.map(f => (
|
||||||
|
<TextField
|
||||||
|
key={f.id}
|
||||||
|
label={f.label}
|
||||||
|
value={f.value}
|
||||||
|
onChange={e => handleUpdateField(f.id, e.target.value)}
|
||||||
|
multiline={f.fieldType === 'textarea'}
|
||||||
|
rows={f.fieldType === 'textarea' ? 3 : 1}
|
||||||
|
size="small"
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step 2: Viewing appointments */}
|
||||||
|
{!loading && step === 2 && draft && (
|
||||||
|
<Box>
|
||||||
|
<Typography variant="body2" sx={{ color: '#64748b', mb: 2 }}>
|
||||||
|
Fügen Sie Besichtigungstermine hinzu, die dem Interessenten angeboten werden sollen:
|
||||||
|
</Typography>
|
||||||
|
<Stack spacing={1.5} sx={{ mb: 2 }}>
|
||||||
|
{draft.viewingAppointments.map(a => (
|
||||||
|
<Box key={a.id} sx={{ display: 'flex', gap: 1.5, alignItems: 'center', border: '1px solid #e2e8f0', borderRadius: 1, p: 1.5 }}>
|
||||||
|
<TextField
|
||||||
|
label="Datum"
|
||||||
|
type="date"
|
||||||
|
size="small"
|
||||||
|
value={a.date}
|
||||||
|
onChange={e => updateAppointment(a.id, 'date', e.target.value)}
|
||||||
|
slotProps={{ inputLabel: { shrink: true } }}
|
||||||
|
sx={{ width: 160 }}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Zeitfenster"
|
||||||
|
size="small"
|
||||||
|
value={a.timeSlot}
|
||||||
|
onChange={e => updateAppointment(a.id, 'timeSlot', e.target.value)}
|
||||||
|
placeholder="10:00–11:00"
|
||||||
|
sx={{ width: 140 }}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Kontaktperson"
|
||||||
|
size="small"
|
||||||
|
value={a.contactPerson ?? ''}
|
||||||
|
onChange={e => updateAppointment(a.id, 'contactPerson', e.target.value)}
|
||||||
|
sx={{ flex: 1 }}
|
||||||
|
/>
|
||||||
|
<IconButton size="small" onClick={() => removeAppointment(a.id)} sx={{ color: '#ef4444' }}>
|
||||||
|
<Trash2 size={16} />
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
<Button
|
||||||
|
startIcon={<Plus size={14} />}
|
||||||
|
onClick={addAppointment}
|
||||||
|
sx={{ textTransform: 'none' }}
|
||||||
|
>
|
||||||
|
Termin hinzufügen
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step 3: PDF */}
|
||||||
|
{step === 3 && (
|
||||||
|
<OfferWizardPdfStep
|
||||||
|
generating={generating}
|
||||||
|
progress={progress}
|
||||||
|
ready={ready}
|
||||||
|
draft={draft}
|
||||||
|
property={property ?? undefined}
|
||||||
|
onDownload={() => showToast('PDF wird heruntergeladen…', 'info')}
|
||||||
|
onAttach={() => {
|
||||||
|
const label = `Angebot_${property?.title ?? 'Objekt'}.pdf`
|
||||||
|
onAttach(label)
|
||||||
|
showToast('Angebot als Anhang hinzugefügt', 'success')
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<Box sx={{ px: 3, py: 2, borderTop: '1px solid #e2e8f0', display: 'flex', justifyContent: 'space-between', flexShrink: 0 }}>
|
||||||
|
<Button onClick={onClose} sx={{ textTransform: 'none', color: '#64748b' }}>
|
||||||
|
Abbrechen
|
||||||
|
</Button>
|
||||||
|
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||||
|
{step > 0 && step < 3 && (
|
||||||
|
<Button variant="outlined" onClick={() => setStep(s => s - 1)} sx={{ textTransform: 'none' }}>
|
||||||
|
Zurück
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{step === 0 && (
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
disabled={loading}
|
||||||
|
onClick={() => setStep(1)}
|
||||||
|
sx={{ textTransform: 'none', bgcolor: '#152642', '&:hover': { bgcolor: '#16304d' } }}
|
||||||
|
>
|
||||||
|
Weiter
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{step === 1 && (
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
onClick={() => setStep(2)}
|
||||||
|
sx={{ textTransform: 'none', bgcolor: '#152642', '&:hover': { bgcolor: '#16304d' } }}
|
||||||
|
>
|
||||||
|
Weiter
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{step === 2 && (
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
onClick={handleGeneratePdf}
|
||||||
|
sx={{ textTransform: 'none', bgcolor: '#152642', '&:hover': { bgcolor: '#16304d' } }}
|
||||||
|
>
|
||||||
|
PDF erstellen
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { useEffect } from 'react'
|
||||||
|
import { Box, Button, Stack, Typography } from '@mui/material'
|
||||||
|
import { ArrowLeft } from 'lucide-react'
|
||||||
|
import { useOfferWizardStore } from '../../stores/offerWizardStore'
|
||||||
|
import { useProperties } from '../../hooks/useProperties'
|
||||||
|
import { ReportObjectFieldSelector } from './ReportObjectFieldSelector'
|
||||||
|
import type { ReportObjectFieldKey, ReportObjectFieldSelection } from '../../domain/inquiryReport'
|
||||||
|
|
||||||
|
const DEFAULT_FIELDS: ReportObjectFieldKey[] = ['areaSqm', 'rentPricePerSqm', 'availabilityDate']
|
||||||
|
const MANDATORY_FIELDS: ReportObjectFieldKey[] = ['title', 'location', 'mapImageUrl', 'images']
|
||||||
|
|
||||||
|
export function OfferFieldSelectionStep() {
|
||||||
|
const propertyIds = useOfferWizardStore(s => s.selectedPropertyIds)
|
||||||
|
const fieldSelections = useOfferWizardStore(s => s.fieldSelections)
|
||||||
|
const setFieldSelections = useOfferWizardStore(s => s.setFieldSelections)
|
||||||
|
const updateFieldSelection = useOfferWizardStore(s => s.updateFieldSelection)
|
||||||
|
const setStep = useOfferWizardStore(s => s.setStep)
|
||||||
|
|
||||||
|
const { data: allProperties = [] } = useProperties()
|
||||||
|
const selectedProperties = allProperties.filter(p => propertyIds.includes(p.id))
|
||||||
|
|
||||||
|
// Initialise selections when properties are loaded or change
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedProperties.length === 0) return
|
||||||
|
const existing = new Set(fieldSelections.map(fs => fs.propertyId))
|
||||||
|
const missing = selectedProperties.filter(p => !existing.has(p.id))
|
||||||
|
if (missing.length > 0) {
|
||||||
|
setFieldSelections([
|
||||||
|
...fieldSelections,
|
||||||
|
...missing.map(p => ({
|
||||||
|
propertyId: p.id,
|
||||||
|
mandatoryFields: MANDATORY_FIELDS,
|
||||||
|
selectedOptionalFields: DEFAULT_FIELDS,
|
||||||
|
})),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
// run only when property list or selections change length
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [selectedProperties.length, fieldSelections.length])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
||||||
|
<Box sx={{ flex: 1, overflowY: 'auto', p: 3 }}>
|
||||||
|
<Typography variant="body2" sx={{ color: '#64748b', mb: 2.5 }}>
|
||||||
|
Wählen Sie die Datenfelder, die auf der Objektseite des Angebots angezeigt werden sollen.
|
||||||
|
Pflichtfelder (Titel, Standort, Foto) sind immer enthalten.
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Stack spacing={2}>
|
||||||
|
{selectedProperties.map(p => {
|
||||||
|
const selection: ReportObjectFieldSelection =
|
||||||
|
fieldSelections.find(fs => fs.propertyId === p.id) ?? {
|
||||||
|
propertyId: p.id,
|
||||||
|
mandatoryFields: MANDATORY_FIELDS,
|
||||||
|
selectedOptionalFields: DEFAULT_FIELDS,
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<ReportObjectFieldSelector
|
||||||
|
key={p.id}
|
||||||
|
propertyTitle={p.title}
|
||||||
|
value={selection}
|
||||||
|
onChange={v => updateFieldSelection(p.id, v)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ px: 3, py: 2, borderTop: '1px solid #e2e8f0', bgcolor: 'white', display: 'flex', justifyContent: 'space-between', flexShrink: 0 }}>
|
||||||
|
<Button
|
||||||
|
startIcon={<ArrowLeft size={14} />}
|
||||||
|
onClick={() => setStep('select_properties')}
|
||||||
|
sx={{ textTransform: 'none' }}
|
||||||
|
>
|
||||||
|
Zurück
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
onClick={() => setStep('pdf_review')}
|
||||||
|
sx={{ textTransform: 'none', bgcolor: '#152642', '&:hover': { bgcolor: '#16304d' } }}
|
||||||
|
>
|
||||||
|
Weiter
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
import { Box, Button, CircularProgress, Typography } from '@mui/material'
|
||||||
|
import { ArrowLeft, Send } from 'lucide-react'
|
||||||
|
import { useOfferWizardStore } from '../../stores/offerWizardStore'
|
||||||
|
import { useUpdateOfferField, useGeneratePdfPreview, useMarkOfferChecked } from '../../hooks/useOffers'
|
||||||
|
import { useToastStore } from '../../stores/toastStore'
|
||||||
|
import { MockPdfPreview } from './MockPdfPreview'
|
||||||
|
import { EditableOfferFieldList } from './EditableOfferFieldList'
|
||||||
|
import type { OfferDraft, OfferEditableField } from '../../domain/offer'
|
||||||
|
|
||||||
|
export function OfferPdfReviewStep() {
|
||||||
|
const offerDraftId = useOfferWizardStore(s => s.offerDraftId)
|
||||||
|
const editableFieldsStore = useOfferWizardStore(s => s.editableFields)
|
||||||
|
const updateField = useOfferWizardStore(s => s.updateField)
|
||||||
|
const setStep = useOfferWizardStore(s => s.setStep)
|
||||||
|
const setPdfReady = useOfferWizardStore(s => s.setPdfReady)
|
||||||
|
const pdfReady = useOfferWizardStore(s => s.pdfPreviewReady)
|
||||||
|
|
||||||
|
const [draft, setDraft] = useState<OfferDraft | null>(null)
|
||||||
|
const [loadingDraft, setLoadingDraft] = useState(true)
|
||||||
|
|
||||||
|
const updateFieldMut = useUpdateOfferField()
|
||||||
|
const genPreview = useGeneratePdfPreview()
|
||||||
|
const markChecked = useMarkOfferChecked()
|
||||||
|
const showToast = useToastStore(s => s.showToast)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
async function load() {
|
||||||
|
if (!offerDraftId) return
|
||||||
|
setLoadingDraft(true)
|
||||||
|
const draftRes = await import('../../provider/MockupOfferProvider').then(m =>
|
||||||
|
m.MockupOfferProvider.getById(offerDraftId),
|
||||||
|
)
|
||||||
|
if (cancelled) return
|
||||||
|
setDraft(draftRes)
|
||||||
|
setLoadingDraft(false)
|
||||||
|
|
||||||
|
// Trigger PDF generation once
|
||||||
|
if (draftRes && !pdfReady) {
|
||||||
|
const res = await genPreview.mutateAsync(offerDraftId)
|
||||||
|
if (!cancelled && res.data) setPdfReady()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
void load()
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [offerDraftId])
|
||||||
|
|
||||||
|
const fallback: Record<string, string> = useMemo(() => {
|
||||||
|
const out: Record<string, string> = {}
|
||||||
|
if (draft) {
|
||||||
|
for (const f of draft.editableFields) out[f.id] = f.value
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}, [draft])
|
||||||
|
|
||||||
|
const fields: OfferEditableField[] = draft?.editableFields ?? []
|
||||||
|
|
||||||
|
const handleFieldChange = async (id: string, value: string) => {
|
||||||
|
updateField(id, value)
|
||||||
|
if (offerDraftId) {
|
||||||
|
await updateFieldMut.mutateAsync({ offerDraftId, fieldId: id, value })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fieldSelections = useOfferWizardStore(s => s.fieldSelections)
|
||||||
|
|
||||||
|
const needTitle = useOfferWizardStore(s => s.needTitle)
|
||||||
|
const addAttachment = useOfferWizardStore(s => s.addAttachment)
|
||||||
|
const setMessageDraft = useOfferWizardStore(s => s.setMessageDraft)
|
||||||
|
const setMessageSubject = useOfferWizardStore(s => s.setMessageSubject)
|
||||||
|
|
||||||
|
const handleNext = async () => {
|
||||||
|
if (!offerDraftId) return
|
||||||
|
if (!pdfReady) {
|
||||||
|
showToast('PDF-Vorschau wird noch generiert...', 'info')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const res = await markChecked.mutateAsync(offerDraftId)
|
||||||
|
if (res.error) {
|
||||||
|
showToast(`Fehler: ${res.error}`, 'error')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
addAttachment({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
fileName: `Angebot_${needTitle.replace(/\s+/g, '_')}.pdf`,
|
||||||
|
fileType: 'application/pdf',
|
||||||
|
fileSize: 320_000,
|
||||||
|
generated: true,
|
||||||
|
})
|
||||||
|
setMessageSubject(`Passende Gewerbeflächen zu Ihrer Anfrage: ${needTitle}`)
|
||||||
|
setMessageDraft(
|
||||||
|
`Sehr geehrte Damen und Herren,\n\nbitte finden Sie anbei unser Angebot zu Ihrem Bedarf "${needTitle}". Gerne stehen wir für Rückfragen und Besichtigungstermine zur Verfügung.\n\nFreundliche Grüsse\nWincasa AG`,
|
||||||
|
)
|
||||||
|
setStep('send')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loadingDraft || !draft) {
|
||||||
|
return (
|
||||||
|
<Box sx={{ p: 4, display: 'flex', justifyContent: 'center' }}>
|
||||||
|
<CircularProgress size={24} />
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
||||||
|
<Box sx={{ flex: 1, display: 'flex', overflow: 'hidden', bgcolor: '#f1f5f9' }}>
|
||||||
|
<Box sx={{ flex: 1.4, p: 2, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
|
||||||
|
{!pdfReady ? (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
flex: 1,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: 2,
|
||||||
|
bgcolor: 'white',
|
||||||
|
borderRadius: 1.5,
|
||||||
|
border: '1px dashed #cbd5e1',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CircularProgress size={24} />
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
PDF-Vorschau wird generiert...
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<MockPdfPreview fields={editableFieldsStore} fallbackFields={fallback} fieldSelections={fieldSelections} />
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: 360,
|
||||||
|
minWidth: 360,
|
||||||
|
flexShrink: 0,
|
||||||
|
p: 2,
|
||||||
|
bgcolor: 'white',
|
||||||
|
borderLeft: '1px solid #e2e8f0',
|
||||||
|
overflowY: 'auto',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 700, color: '#0f172a', mb: 1.5, fontSize: '0.9rem' }}>
|
||||||
|
Inhalte bearbeiten
|
||||||
|
</Typography>
|
||||||
|
<EditableOfferFieldList
|
||||||
|
fields={fields}
|
||||||
|
values={editableFieldsStore}
|
||||||
|
onChange={handleFieldChange}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
p: 2,
|
||||||
|
borderTop: '1px solid #e2e8f0',
|
||||||
|
bgcolor: 'white',
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
startIcon={<ArrowLeft size={14} />}
|
||||||
|
onClick={() => setStep('select_properties')}
|
||||||
|
sx={{ textTransform: 'none' }}
|
||||||
|
>
|
||||||
|
Zurück
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
endIcon={markChecked.isPending ? <CircularProgress size={14} sx={{ color: 'white' }} /> : <Send size={14} />}
|
||||||
|
onClick={handleNext}
|
||||||
|
disabled={!pdfReady || markChecked.isPending}
|
||||||
|
sx={{ textTransform: 'none', bgcolor: '#152642', '&:hover': { bgcolor: '#16304d' } }}
|
||||||
|
>
|
||||||
|
{markChecked.isPending ? 'Wird geprüft…' : 'Angebot senden →'}
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
import { useEffect, useMemo } from 'react'
|
||||||
|
import { Box, Button, CircularProgress, Typography } from '@mui/material'
|
||||||
|
import { ArrowRight } from 'lucide-react'
|
||||||
|
import { useProperties } from '../../hooks/useProperties'
|
||||||
|
import { ResultType } from '../../domain/enums'
|
||||||
|
import { useOfferWizardStore } from '../../stores/offerWizardStore'
|
||||||
|
import { useLatentNeedById } from '../../hooks/useLatentNeeds'
|
||||||
|
import { useCreateOfferDraft } from '../../hooks/useOffers'
|
||||||
|
import { useToastStore } from '../../stores/toastStore'
|
||||||
|
import { SelectablePropertyMatchCard } from './SelectablePropertyMatchCard'
|
||||||
|
import { deterministicMatchScore, assetTypeLabel } from './latentNeedUtils'
|
||||||
|
|
||||||
|
function buildReason(propertyAssetType: string, needAssetType: string, city: string, location: string): string {
|
||||||
|
if (propertyAssetType === needAssetType) {
|
||||||
|
const cityMatch = location.toLowerCase().includes(city.toLowerCase()) || city.toLowerCase().includes(location.toLowerCase())
|
||||||
|
if (cityMatch) return 'Nutzungstyp und Standort passen sehr gut'
|
||||||
|
return 'Passender Nutzungstyp, alternative Lage'
|
||||||
|
}
|
||||||
|
return 'Alternatives Profil — Detailprüfung empfohlen'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OfferPropertySelectionStep() {
|
||||||
|
const needId = useOfferWizardStore(s => s.selectedNeedId)
|
||||||
|
const selectedIds = useOfferWizardStore(s => s.selectedPropertyIds)
|
||||||
|
const toggle = useOfferWizardStore(s => s.toggleProperty)
|
||||||
|
const setSelected = useOfferWizardStore(s => s.setSelectedProperties)
|
||||||
|
const setOfferDraftId = useOfferWizardStore(s => s.setOfferDraftId)
|
||||||
|
const setStep = useOfferWizardStore(s => s.setStep)
|
||||||
|
|
||||||
|
const { data: need } = useLatentNeedById(needId)
|
||||||
|
const { data: properties = [], isLoading } = useProperties({ resultType: ResultType.VERIFIED_PORTFOLIO })
|
||||||
|
|
||||||
|
const createDraft = useCreateOfferDraft()
|
||||||
|
const showToast = useToastStore(s => s.showToast)
|
||||||
|
|
||||||
|
const scored = useMemo(() => {
|
||||||
|
if (!need) return []
|
||||||
|
return properties
|
||||||
|
.map(p => ({
|
||||||
|
property: p,
|
||||||
|
score: deterministicMatchScore(p.id, need.id),
|
||||||
|
reason: buildReason(p.assetType, need.assetType, p.location.city, need.desiredLocation),
|
||||||
|
}))
|
||||||
|
.sort((a, b) => b.score - a.score)
|
||||||
|
}, [properties, need])
|
||||||
|
|
||||||
|
// Pre-select top 2 if nothing selected
|
||||||
|
useEffect(() => {
|
||||||
|
if (scored.length > 0 && selectedIds.length === 0) {
|
||||||
|
setSelected(scored.slice(0, 2).map(s => s.property.id))
|
||||||
|
}
|
||||||
|
}, [scored, selectedIds.length, setSelected])
|
||||||
|
|
||||||
|
const handleNext = async () => {
|
||||||
|
if (!need) return
|
||||||
|
if (selectedIds.length === 0) {
|
||||||
|
showToast('Bitte mindestens ein Objekt auswählen', 'warning')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const res = await createDraft.mutateAsync({
|
||||||
|
needId: need.id,
|
||||||
|
selectedPropertyIds: selectedIds,
|
||||||
|
needTitle: need.title,
|
||||||
|
location: need.desiredLocation,
|
||||||
|
assetType: need.assetType,
|
||||||
|
sizeRange: need.sizeRange,
|
||||||
|
})
|
||||||
|
if (res.error || !res.data) {
|
||||||
|
showToast(`Fehler: ${res.error}`, 'error')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setOfferDraftId(res.data.id)
|
||||||
|
setStep('select_fields')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!need) {
|
||||||
|
return (
|
||||||
|
<Box sx={{ p: 4, display: 'flex', justifyContent: 'center' }}>
|
||||||
|
<CircularProgress size={24} />
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
||||||
|
{/* Need summary header */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
p: 2.5,
|
||||||
|
bgcolor: '#f8fafc',
|
||||||
|
borderBottom: '1px solid #e2e8f0',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="caption" sx={{ color: '#64748b', fontWeight: 600, textTransform: 'uppercase', letterSpacing: 0.5 }}>
|
||||||
|
Bedarf
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="h6" sx={{ fontWeight: 700, color: '#0f172a', fontSize: '1.1rem', mt: 0.25 }}>
|
||||||
|
{need.title}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" sx={{ color: '#475569', fontSize: '0.8rem', mt: 0.5, display: 'block' }}>
|
||||||
|
{assetTypeLabel(need.assetType)} · {need.desiredLocation} · {need.sizeRange.min}–{need.sizeRange.max} m²
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ flex: 1, overflowY: 'auto', p: 2 }}>
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 600, color: '#0f172a', mb: 1.5 }}>
|
||||||
|
Wählen Sie passende Objekte aus Ihrem Portfolio
|
||||||
|
</Typography>
|
||||||
|
{isLoading && <CircularProgress size={20} />}
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||||
|
{scored.map(({ property, score, reason }) => (
|
||||||
|
<SelectablePropertyMatchCard
|
||||||
|
key={property.id}
|
||||||
|
property={property}
|
||||||
|
matchScore={score}
|
||||||
|
selected={selectedIds.includes(property.id)}
|
||||||
|
onToggle={() => toggle(property.id)}
|
||||||
|
reason={reason}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
p: 2,
|
||||||
|
borderTop: '1px solid #e2e8f0',
|
||||||
|
bgcolor: 'white',
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="body2" sx={{ color: '#64748b' }}>
|
||||||
|
{selectedIds.length} Objekt{selectedIds.length === 1 ? '' : 'e'} ausgewählt
|
||||||
|
</Typography>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
endIcon={createDraft.isPending ? <CircularProgress size={14} sx={{ color: 'white' }} /> : <ArrowRight size={14} />}
|
||||||
|
onClick={handleNext}
|
||||||
|
disabled={selectedIds.length === 0 || createDraft.isPending}
|
||||||
|
sx={{ textTransform: 'none', bgcolor: '#152642', '&:hover': { bgcolor: '#16304d' } }}
|
||||||
|
>
|
||||||
|
Weiter
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Dialog,
|
||||||
|
IconButton,
|
||||||
|
Step,
|
||||||
|
StepLabel,
|
||||||
|
Stepper,
|
||||||
|
Typography,
|
||||||
|
useMediaQuery,
|
||||||
|
useTheme,
|
||||||
|
} from '@mui/material'
|
||||||
|
import { X } from 'lucide-react'
|
||||||
|
import { useOfferWizardStore, type OfferStep } from '../../stores/offerWizardStore'
|
||||||
|
import { OfferPropertySelectionStep } from './OfferPropertySelectionStep'
|
||||||
|
import { OfferFieldSelectionStep } from './OfferFieldSelectionStep'
|
||||||
|
import { OfferPdfReviewStep } from './OfferPdfReviewStep'
|
||||||
|
import { OfferCheckedAction } from './OfferCheckedAction'
|
||||||
|
import { OfferChatComposer } from './OfferChatComposer'
|
||||||
|
|
||||||
|
// 'checked' is internal — merged into pdf_review step; 4 visible steps
|
||||||
|
const STEPS: { key: OfferStep; label: string }[] = [
|
||||||
|
{ key: 'select_properties', label: 'Objekte wählen' },
|
||||||
|
{ key: 'select_fields', label: 'Felder wählen' },
|
||||||
|
{ key: 'pdf_review', label: 'Vorschau & Prüfen' },
|
||||||
|
{ key: 'send', label: 'Senden' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const STEP_DISPLAY_INDEX: Record<OfferStep, number> = {
|
||||||
|
select_properties: 0,
|
||||||
|
select_fields: 1,
|
||||||
|
pdf_review: 2,
|
||||||
|
checked: 2, // same visual step as pdf_review
|
||||||
|
send: 3,
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OfferWizard() {
|
||||||
|
const theme = useTheme()
|
||||||
|
const fullScreen = useMediaQuery(theme.breakpoints.down('md'))
|
||||||
|
|
||||||
|
const isOpen = useOfferWizardStore(s => s.isOpen)
|
||||||
|
const close = useOfferWizardStore(s => s.close)
|
||||||
|
const currentStep = useOfferWizardStore(s => s.currentStep)
|
||||||
|
const needTitle = useOfferWizardStore(s => s.needTitle)
|
||||||
|
|
||||||
|
const activeIndex = STEP_DISPLAY_INDEX[currentStep] ?? 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
open={isOpen}
|
||||||
|
onClose={close}
|
||||||
|
fullScreen={fullScreen}
|
||||||
|
maxWidth="lg"
|
||||||
|
fullWidth
|
||||||
|
slotProps={{
|
||||||
|
paper: {
|
||||||
|
sx: {
|
||||||
|
height: fullScreen ? '100vh' : '85vh',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
px: 3,
|
||||||
|
py: 1.5,
|
||||||
|
borderBottom: '1px solid #e2e8f0',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box sx={{ minWidth: 0 }}>
|
||||||
|
<Typography variant="caption" sx={{ color: '#64748b', fontSize: '0.7rem', textTransform: 'uppercase', fontWeight: 600, letterSpacing: 0.5 }}>
|
||||||
|
Angebot erstellen
|
||||||
|
</Typography>
|
||||||
|
<Typography
|
||||||
|
variant="body1"
|
||||||
|
sx={{
|
||||||
|
fontWeight: 700,
|
||||||
|
color: '#0f172a',
|
||||||
|
fontSize: '0.95rem',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{needTitle}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<IconButton onClick={close} size="small">
|
||||||
|
<X size={18} />
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Stepper */}
|
||||||
|
<Box sx={{ px: 3, py: 1.5, borderBottom: '1px solid #e2e8f0', bgcolor: '#f8fafc', flexShrink: 0 }}>
|
||||||
|
<Stepper activeStep={activeIndex} alternativeLabel>
|
||||||
|
{STEPS.map(s => (
|
||||||
|
<Step key={s.key}>
|
||||||
|
<StepLabel
|
||||||
|
slotProps={{
|
||||||
|
label: { sx: { fontSize: '0.8rem', fontWeight: 500 } },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{s.label}
|
||||||
|
</StepLabel>
|
||||||
|
</Step>
|
||||||
|
))}
|
||||||
|
</Stepper>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Step content */}
|
||||||
|
<Box sx={{ flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
|
||||||
|
{currentStep === 'select_properties' && <OfferPropertySelectionStep />}
|
||||||
|
{currentStep === 'select_fields' && <OfferFieldSelectionStep />}
|
||||||
|
{currentStep === 'pdf_review' && <OfferPdfReviewStep />}
|
||||||
|
{currentStep === 'checked' && <OfferCheckedAction />}
|
||||||
|
{currentStep === 'send' && <OfferChatComposer />}
|
||||||
|
</Box>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import { Box, Button, CircularProgress, LinearProgress, Typography } from '@mui/material'
|
||||||
|
import { Download, Send } from 'lucide-react'
|
||||||
|
import type { OfferReportDraft } from '../../domain/offerReport'
|
||||||
|
import type { Property } from '../../domain/property'
|
||||||
|
|
||||||
|
interface OfferWizardPdfStepProps {
|
||||||
|
generating: boolean
|
||||||
|
progress: number
|
||||||
|
ready: boolean
|
||||||
|
draft: OfferReportDraft | null
|
||||||
|
property: Property | undefined
|
||||||
|
onDownload: () => void
|
||||||
|
onAttach: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OfferWizardPdfStep({ generating, progress, ready, draft, property, onDownload, onAttach }: OfferWizardPdfStepProps) {
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '100%', gap: 3 }}>
|
||||||
|
{generating ? (
|
||||||
|
<>
|
||||||
|
<CircularProgress size={48} sx={{ color: '#152642' }} />
|
||||||
|
<Typography variant="body1" sx={{ fontWeight: 600, color: '#152642' }}>
|
||||||
|
PDF wird generiert…
|
||||||
|
</Typography>
|
||||||
|
<Box sx={{ width: '100%', maxWidth: 400 }}>
|
||||||
|
<LinearProgress variant="determinate" value={progress} sx={{ height: 6, borderRadius: 3 }} />
|
||||||
|
<Typography variant="caption" sx={{ color: '#64748b', display: 'block', textAlign: 'center', mt: 1 }}>
|
||||||
|
{Math.round(progress)}%
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</>
|
||||||
|
) : ready ? (
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 2, width: '100%' }}>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: '100%',
|
||||||
|
maxWidth: 520,
|
||||||
|
height: 380,
|
||||||
|
bgcolor: '#f8fafc',
|
||||||
|
border: '1px solid #e2e8f0',
|
||||||
|
borderRadius: 1.5,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box sx={{ bgcolor: '#152642', px: 2, py: 1, display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
|
<Box sx={{ width: 10, height: 10, borderRadius: '50%', bgcolor: '#ef4444' }} />
|
||||||
|
<Box sx={{ width: 10, height: 10, borderRadius: '50%', bgcolor: '#f59e0b' }} />
|
||||||
|
<Box sx={{ width: 10, height: 10, borderRadius: '50%', bgcolor: '#10b981' }} />
|
||||||
|
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.7)', ml: 1, fontSize: '0.7rem' }}>
|
||||||
|
Angebot_{property?.title ?? 'Objekt'}.pdf
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ flex: 1, p: 3 }}>
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 700, color: '#152642', mb: 1 }}>
|
||||||
|
Angebotsschreiben
|
||||||
|
</Typography>
|
||||||
|
{draft?.editableFields.slice(0, 3).map(f => (
|
||||||
|
<Box key={f.id} sx={{ mb: 1 }}>
|
||||||
|
<Typography variant="caption" sx={{ color: '#94a3b8', fontSize: '0.65rem', display: 'block' }}>{f.label}</Typography>
|
||||||
|
<Typography variant="caption" sx={{ color: '#374151', fontSize: '0.75rem', display: 'block' }}>
|
||||||
|
{f.value.slice(0, 80)}{f.value.length > 80 ? '…' : ''}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
{(draft?.viewingAppointments.length ?? 0) > 0 && (
|
||||||
|
<Box sx={{ mt: 1.5, p: 1, bgcolor: '#f1f5f9', borderRadius: 1 }}>
|
||||||
|
<Typography variant="caption" sx={{ fontWeight: 600, fontSize: '0.72rem', display: 'block', mb: 0.5 }}>
|
||||||
|
Besichtigungstermine
|
||||||
|
</Typography>
|
||||||
|
{draft!.viewingAppointments.map(a => (
|
||||||
|
<Typography key={a.id} variant="caption" sx={{ fontSize: '0.7rem', display: 'block', color: '#475569' }}>
|
||||||
|
{new Date(a.date).toLocaleDateString('de-CH')} · {a.timeSlot}
|
||||||
|
</Typography>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ display: 'flex', gap: 1.5 }}>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
startIcon={<Download size={16} />}
|
||||||
|
onClick={onDownload}
|
||||||
|
sx={{ textTransform: 'none', bgcolor: '#152642', '&:hover': { bgcolor: '#16304d' } }}
|
||||||
|
>
|
||||||
|
Herunterladen
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
startIcon={<Send size={16} />}
|
||||||
|
onClick={onAttach}
|
||||||
|
sx={{ textTransform: 'none' }}
|
||||||
|
>
|
||||||
|
An Nachricht anhängen
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
) : null}
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import { useMemo } from 'react'
|
||||||
|
import { Box, CircularProgress, Typography } from '@mui/material'
|
||||||
|
import { Target } from 'lucide-react'
|
||||||
|
import { useProperties } from '../../hooks/useProperties'
|
||||||
|
import { ResultType } from '../../domain/enums'
|
||||||
|
import { useOfferWizardStore } from '../../stores/offerWizardStore'
|
||||||
|
import { deterministicMatchScore } from './latentNeedUtils'
|
||||||
|
import { SelectablePropertyMatchCard } from './SelectablePropertyMatchCard'
|
||||||
|
import { OfferCreationPanel } from './OfferCreationPanel'
|
||||||
|
import type { LatentNeed } from '../../domain/latentNeed'
|
||||||
|
|
||||||
|
interface OwnPropertyMatchListProps {
|
||||||
|
need: LatentNeed
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildReason(propertyAssetType: string, needAssetType: string, city: string, location: string): string {
|
||||||
|
if (propertyAssetType === needAssetType) {
|
||||||
|
const cityMatch = location.toLowerCase().includes(city.toLowerCase()) || city.toLowerCase().includes(location.toLowerCase())
|
||||||
|
if (cityMatch) return 'Nutzungstyp und Standort passen sehr gut'
|
||||||
|
return 'Passender Nutzungstyp, alternative Lage'
|
||||||
|
}
|
||||||
|
return 'Alternatives Profil — Detailprüfung empfohlen'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OwnPropertyMatchList({ need }: OwnPropertyMatchListProps) {
|
||||||
|
const { data: properties = [], isLoading } = useProperties({ resultType: ResultType.VERIFIED_PORTFOLIO })
|
||||||
|
|
||||||
|
const selectedIds = useOfferWizardStore(s => s.selectedPropertyIds)
|
||||||
|
const toggle = useOfferWizardStore(s => s.toggleProperty)
|
||||||
|
|
||||||
|
const scored = useMemo(() => {
|
||||||
|
return properties
|
||||||
|
.map(p => ({
|
||||||
|
property: p,
|
||||||
|
score: deterministicMatchScore(p.id, need.id),
|
||||||
|
reason: buildReason(p.assetType, need.assetType, p.location.city, need.desiredLocation),
|
||||||
|
}))
|
||||||
|
.sort((a, b) => b.score - a.score)
|
||||||
|
}, [properties, need])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: 360,
|
||||||
|
minWidth: 360,
|
||||||
|
flexShrink: 0,
|
||||||
|
borderLeft: '1px solid #e2e8f0',
|
||||||
|
bgcolor: 'white',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
px: 2,
|
||||||
|
py: 1.25,
|
||||||
|
borderBottom: '1px solid #e2e8f0',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 1,
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Target size={14} color="#152642" />
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 600, color: '#0f172a' }}>
|
||||||
|
Eigene Objekte
|
||||||
|
</Typography>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
ml: 'auto',
|
||||||
|
px: 1,
|
||||||
|
py: 0.125,
|
||||||
|
borderRadius: 1,
|
||||||
|
bgcolor: '#152642',
|
||||||
|
color: 'white',
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: '0.7rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{scored.length}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ flex: 1, overflowY: 'auto', p: 1.25, display: 'flex', flexDirection: 'column', gap: 0.75 }}>
|
||||||
|
{isLoading && (
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'center', p: 3 }}>
|
||||||
|
<CircularProgress size={20} />
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
{!isLoading && scored.length === 0 && (
|
||||||
|
<Typography variant="caption" sx={{ color: '#64748b', fontSize: '0.8rem', textAlign: 'center', p: 2 }}>
|
||||||
|
Keine Portfolio-Objekte vorhanden
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
{scored.map(({ property, score, reason }) => (
|
||||||
|
<SelectablePropertyMatchCard
|
||||||
|
key={property.id}
|
||||||
|
property={property}
|
||||||
|
matchScore={score}
|
||||||
|
selected={selectedIds.includes(property.id)}
|
||||||
|
onToggle={() => toggle(property.id)}
|
||||||
|
reason={reason}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<OfferCreationPanel
|
||||||
|
selectedCount={selectedIds.length}
|
||||||
|
needId={need.id}
|
||||||
|
needTitle={need.title}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,294 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import {
|
||||||
|
Box, Button, Checkbox, CircularProgress, Dialog, DialogContent,
|
||||||
|
FormControlLabel, LinearProgress, Stack, Step, StepLabel, Stepper,
|
||||||
|
TextField, Typography,
|
||||||
|
} from '@mui/material'
|
||||||
|
import { Download, Send, X } from 'lucide-react'
|
||||||
|
import type { Inquiry } from '../../domain/inquiry'
|
||||||
|
import type { InquiryPreparationReportDraft, ReportObjectFieldSelection } from '../../domain/inquiryReport'
|
||||||
|
import { useCreateInquiryReport, useUpdateInquiryReport, useFinalizeInquiryReport } from '../../hooks/useInquiryReport'
|
||||||
|
import { useToastStore } from '../../stores/toastStore'
|
||||||
|
import { ReportObjectFieldSelector } from './ReportObjectFieldSelector'
|
||||||
|
import { LatentInquiryReportPreview } from './LatentInquiryReportPreview'
|
||||||
|
import { useProperties } from '../../hooks/useProperties'
|
||||||
|
import { ResultType } from '../../domain/enums'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
inquiryId: string
|
||||||
|
inquiry: Inquiry
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const STEPS = ['Objekte wählen', 'Bericht erstellen', 'Prüfen & bearbeiten', 'Finalisieren']
|
||||||
|
|
||||||
|
export function PreparationWizard({ inquiryId, inquiry, onClose }: Props) {
|
||||||
|
const [step, setStep] = useState(0)
|
||||||
|
const [selectedIds, setSelectedIds] = useState<string[]>([])
|
||||||
|
const [draft, setDraft] = useState<InquiryPreparationReportDraft | null>(null)
|
||||||
|
const [generating, setGenerating] = useState(false)
|
||||||
|
const [progress, setProgress] = useState(0)
|
||||||
|
const { data: allProperties = [] } = useProperties()
|
||||||
|
const showToast = useToastStore(s => s.showToast)
|
||||||
|
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||||
|
|
||||||
|
const createInquiryReport = useCreateInquiryReport()
|
||||||
|
const updateInquiryReport = useUpdateInquiryReport()
|
||||||
|
const finalizeInquiryReport = useFinalizeInquiryReport()
|
||||||
|
const finalizing = finalizeInquiryReport.isPending
|
||||||
|
|
||||||
|
const portfolioProps = allProperties.filter(p => p.resultType === ResultType.VERIFIED_PORTFOLIO)
|
||||||
|
|
||||||
|
const toggleProperty = (id: string) => {
|
||||||
|
setSelectedIds(prev =>
|
||||||
|
prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleGenerate = () => {
|
||||||
|
setStep(1)
|
||||||
|
setGenerating(true)
|
||||||
|
setProgress(0)
|
||||||
|
let p = 0
|
||||||
|
timerRef.current = setInterval(() => {
|
||||||
|
p += Math.random() * 20 + 10
|
||||||
|
if (p >= 100) {
|
||||||
|
clearInterval(timerRef.current!)
|
||||||
|
setProgress(100)
|
||||||
|
setGenerating(false)
|
||||||
|
createInquiryReport.mutate(
|
||||||
|
{ inquiryId, selectedPropertyIds: selectedIds },
|
||||||
|
{
|
||||||
|
onSuccess: (d) => {
|
||||||
|
setDraft(d)
|
||||||
|
setStep(2)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
setProgress(Math.min(100, p))
|
||||||
|
}
|
||||||
|
}, 200)
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => () => { if (timerRef.current) clearInterval(timerRef.current) }, [])
|
||||||
|
|
||||||
|
const handleUpdateField = (fieldId: string, value: string) => {
|
||||||
|
if (!draft) return
|
||||||
|
setDraft({
|
||||||
|
...draft,
|
||||||
|
editableFields: draft.editableFields.map(f => f.id === fieldId ? { ...f, value } : f),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleUpdateFieldSelection = (propertyId: string, sel: ReportObjectFieldSelection) => {
|
||||||
|
if (!draft) return
|
||||||
|
setDraft({
|
||||||
|
...draft,
|
||||||
|
fieldSelections: draft.fieldSelections.map(fs => fs.propertyId === propertyId ? sel : fs),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleFinalize = () => {
|
||||||
|
if (!draft) return
|
||||||
|
updateInquiryReport.mutate(
|
||||||
|
{ draftId: draft.id, data: { editableFields: draft.editableFields, fieldSelections: draft.fieldSelections } },
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
finalizeInquiryReport.mutate(draft.id, {
|
||||||
|
onSuccess: (finalized) => {
|
||||||
|
setDraft(finalized)
|
||||||
|
setStep(3)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open fullWidth maxWidth="lg" slotProps={{ paper: { sx: { height: '90vh', display: 'flex', flexDirection: 'column' } } }}>
|
||||||
|
<Box sx={{ px: 3, pt: 2.5, pb: 1.5, borderBottom: '1px solid #e2e8f0', display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexShrink: 0 }}>
|
||||||
|
<Typography variant="h6" sx={{ fontWeight: 700 }}>Vorbereitung starten</Typography>
|
||||||
|
<Button size="small" onClick={onClose} sx={{ minWidth: 0, p: 0.5 }}><X size={18} /></Button>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ px: 3, py: 2, flexShrink: 0 }}>
|
||||||
|
<Stepper activeStep={step} alternativeLabel>
|
||||||
|
{STEPS.map(label => (
|
||||||
|
<Step key={label}><StepLabel>{label}</StepLabel></Step>
|
||||||
|
))}
|
||||||
|
</Stepper>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<DialogContent sx={{ flex: 1, overflow: 'auto', px: 3 }}>
|
||||||
|
|
||||||
|
{/* Step 0: Select properties */}
|
||||||
|
{step === 0 && (
|
||||||
|
<Box>
|
||||||
|
<Typography variant="body2" sx={{ color: '#64748b', mb: 2 }}>
|
||||||
|
Wählen Sie die Objekte aus Ihrem Portfolio, die Sie dem Interessenten vorstellen möchten:
|
||||||
|
</Typography>
|
||||||
|
<Stack spacing={1}>
|
||||||
|
{portfolioProps.map(p => (
|
||||||
|
<FormControlLabel
|
||||||
|
key={p.id}
|
||||||
|
control={
|
||||||
|
<Checkbox
|
||||||
|
checked={selectedIds.includes(p.id)}
|
||||||
|
onChange={() => toggleProperty(p.id)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label={
|
||||||
|
<Box>
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 600 }}>{p.title}</Typography>
|
||||||
|
<Typography variant="caption" sx={{ color: '#64748b' }}>
|
||||||
|
{p.location.city} · {p.areaSqm.toLocaleString('de-CH')} m² · CHF {p.rentPricePerSqm}/m²/Jahr
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
}
|
||||||
|
sx={{ border: '1px solid #e2e8f0', borderRadius: 1, p: 1, m: 0, alignItems: 'flex-start', '& .MuiCheckbox-root': { pt: 0 } }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step 1: Generating */}
|
||||||
|
{step === 1 && (
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '100%', gap: 3 }}>
|
||||||
|
{generating ? (
|
||||||
|
<>
|
||||||
|
<CircularProgress size={48} sx={{ color: '#152642' }} />
|
||||||
|
<Typography variant="body1" sx={{ fontWeight: 600, color: '#152642' }}>
|
||||||
|
Bericht wird erstellt…
|
||||||
|
</Typography>
|
||||||
|
<Box sx={{ width: '100%', maxWidth: 400 }}>
|
||||||
|
<LinearProgress variant="determinate" value={progress} sx={{ height: 6, borderRadius: 3 }} />
|
||||||
|
<Typography variant="caption" sx={{ color: '#64748b', display: 'block', textAlign: 'center', mt: 1 }}>
|
||||||
|
{Math.round(progress)}%
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Typography variant="body1">Bericht erstellt. Weiterleitung…</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step 2: Review & Edit */}
|
||||||
|
{step === 2 && draft && (
|
||||||
|
<Box sx={{ display: 'flex', gap: 3 }}>
|
||||||
|
<Box sx={{ flex: 1 }}>
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 600, mb: 2 }}>Berichtsfelder bearbeiten</Typography>
|
||||||
|
<Stack spacing={2} sx={{ mb: 3 }}>
|
||||||
|
{draft.editableFields.map(f => (
|
||||||
|
<TextField
|
||||||
|
key={f.id}
|
||||||
|
label={f.label}
|
||||||
|
value={f.value}
|
||||||
|
onChange={e => handleUpdateField(f.id, e.target.value)}
|
||||||
|
multiline={f.fieldType === 'textarea'}
|
||||||
|
rows={f.fieldType === 'textarea' ? 3 : 1}
|
||||||
|
size="small"
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 600, mb: 1.5 }}>Felder pro Objekt</Typography>
|
||||||
|
<Stack spacing={2}>
|
||||||
|
{draft.fieldSelections.map(fs => {
|
||||||
|
const prop = allProperties.find(p => p.id === fs.propertyId)
|
||||||
|
if (!prop) return null
|
||||||
|
return (
|
||||||
|
<ReportObjectFieldSelector
|
||||||
|
key={fs.propertyId}
|
||||||
|
propertyTitle={prop.title}
|
||||||
|
value={fs}
|
||||||
|
onChange={sel => handleUpdateFieldSelection(fs.propertyId, sel)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ width: 380, flexShrink: 0 }}>
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 600, mb: 2 }}>Vorschau</Typography>
|
||||||
|
<Box sx={{ overflow: 'auto', maxHeight: 600 }}>
|
||||||
|
<LatentInquiryReportPreview draft={draft} inquiry={inquiry} properties={allProperties} />
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step 3: Finalized */}
|
||||||
|
{step === 3 && draft && (
|
||||||
|
<Box sx={{ display: 'flex', gap: 3 }}>
|
||||||
|
<Box sx={{ flex: 1, overflow: 'auto' }}>
|
||||||
|
<LatentInquiryReportPreview draft={draft} inquiry={inquiry} properties={allProperties} />
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ width: 200, flexShrink: 0, display: 'flex', flexDirection: 'column', gap: 1.5, pt: 1 }}>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
fullWidth
|
||||||
|
startIcon={<Download size={16} />}
|
||||||
|
onClick={() => showToast('PDF wird heruntergeladen…', 'info')}
|
||||||
|
sx={{ textTransform: 'none', bgcolor: '#152642', '&:hover': { bgcolor: '#16304d' } }}
|
||||||
|
>
|
||||||
|
Herunterladen
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
fullWidth
|
||||||
|
startIcon={<Send size={16} />}
|
||||||
|
onClick={() => {
|
||||||
|
showToast('Bericht als Anhang hinzugefügt', 'success')
|
||||||
|
onClose()
|
||||||
|
}}
|
||||||
|
sx={{ textTransform: 'none' }}
|
||||||
|
>
|
||||||
|
Als Anhang senden
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
|
||||||
|
{/* Footer navigation */}
|
||||||
|
<Box sx={{ px: 3, py: 2, borderTop: '1px solid #e2e8f0', display: 'flex', justifyContent: 'space-between', flexShrink: 0 }}>
|
||||||
|
<Button onClick={onClose} sx={{ textTransform: 'none', color: '#64748b' }}>
|
||||||
|
Abbrechen
|
||||||
|
</Button>
|
||||||
|
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||||
|
{step === 2 && (
|
||||||
|
<Button variant="outlined" onClick={() => setStep(0)} sx={{ textTransform: 'none' }}>
|
||||||
|
Zurück
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{step === 0 && (
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
disabled={selectedIds.length === 0}
|
||||||
|
onClick={handleGenerate}
|
||||||
|
sx={{ textTransform: 'none', bgcolor: '#152642', '&:hover': { bgcolor: '#16304d' } }}
|
||||||
|
>
|
||||||
|
Weiter
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{step === 2 && (
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
disabled={finalizing}
|
||||||
|
onClick={handleFinalize}
|
||||||
|
startIcon={finalizing ? <CircularProgress size={14} sx={{ color: 'white' }} /> : undefined}
|
||||||
|
sx={{ textTransform: 'none', bgcolor: '#152642', '&:hover': { bgcolor: '#16304d' } }}
|
||||||
|
>
|
||||||
|
Finalisieren
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import { Box, Chip, Paper, Typography } from '@mui/material'
|
||||||
|
import { MapPin } from 'lucide-react'
|
||||||
|
import type { LatentNeed } from '../../domain/latentNeed'
|
||||||
|
import { assetTypeLabel, latentStatusBadge } from './latentNeedUtils'
|
||||||
|
|
||||||
|
interface PublicNeedCardProps {
|
||||||
|
need: LatentNeed
|
||||||
|
selected: boolean
|
||||||
|
onClick: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function opportunityColor(status: 'public' | 'paused' | 'expired'): string {
|
||||||
|
if (status === 'public') return '#16a34a'
|
||||||
|
if (status === 'paused') return '#d97706'
|
||||||
|
return '#94a3b8'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PublicNeedCard({ need, selected, onClick }: PublicNeedCardProps) {
|
||||||
|
const statusCfg = latentStatusBadge(need.status)
|
||||||
|
return (
|
||||||
|
<Paper
|
||||||
|
onClick={onClick}
|
||||||
|
elevation={0}
|
||||||
|
sx={{
|
||||||
|
p: 1.5,
|
||||||
|
borderRadius: 1.5,
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: selected ? '#152642' : '#e8e7e4',
|
||||||
|
bgcolor: selected ? '#f1f5f9' : 'white',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'all 0.15s',
|
||||||
|
'&:hover': {
|
||||||
|
borderColor: selected ? '#152642' : '#94a3b8',
|
||||||
|
boxShadow: '0 2px 6px rgba(15,23,42,0.06)',
|
||||||
|
},
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 0.75,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', gap: 1, alignItems: 'flex-start' }}>
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 600, color: '#0f172a', fontSize: '0.85rem', lineHeight: 1.3 }}>
|
||||||
|
{need.title}
|
||||||
|
</Typography>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, flexShrink: 0 }}>
|
||||||
|
<Box sx={{ width: 8, height: 8, borderRadius: '50%', bgcolor: opportunityColor(need.status) }} />
|
||||||
|
<Chip
|
||||||
|
label={statusCfg.label}
|
||||||
|
size="small"
|
||||||
|
sx={{ bgcolor: statusCfg.bg, color: statusCfg.fg, fontWeight: 600, fontSize: '0.65rem', height: 20 }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{need.tenantCompany && (
|
||||||
|
<Typography variant="caption" sx={{ color: '#64748b', fontSize: '0.75rem' }}>
|
||||||
|
{need.tenantCompany}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Chip
|
||||||
|
label={assetTypeLabel(need.assetType)}
|
||||||
|
size="small"
|
||||||
|
sx={{
|
||||||
|
alignSelf: 'flex-start',
|
||||||
|
bgcolor: '#e0e7ff',
|
||||||
|
color: '#3730a3',
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: '0.7rem',
|
||||||
|
height: 20,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, color: '#475569', fontSize: '0.75rem' }}>
|
||||||
|
<MapPin size={12} />
|
||||||
|
<Typography variant="caption" sx={{ fontSize: '0.75rem' }}>
|
||||||
|
{need.desiredLocation}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,263 @@
|
|||||||
|
import { useMemo, useState } from 'react'
|
||||||
|
import {
|
||||||
|
Accordion, AccordionDetails, AccordionSummary,
|
||||||
|
Alert, Box, Button, Chip, CircularProgress, Divider, Paper, Typography,
|
||||||
|
} from '@mui/material'
|
||||||
|
import { Calendar, CheckCircle2, ChevronDown, Info, MapPin, Ruler, Sparkles, Target, Wallet } from 'lucide-react'
|
||||||
|
import type { LatentNeed } from '../../domain/latentNeed'
|
||||||
|
import { ResultType, UserRole } from '../../domain/enums'
|
||||||
|
import { useOfferWizardStore } from '../../stores/offerWizardStore'
|
||||||
|
import { useSessionStore } from '../../stores/sessionStore'
|
||||||
|
import { useProperties } from '../../hooks/useProperties'
|
||||||
|
import { assetTypeLabel, deterministicMatchScore, latentStatusBadge } from './latentNeedUtils'
|
||||||
|
import { EmbeddedPropertyCard } from './EmbeddedPropertyCard'
|
||||||
|
|
||||||
|
interface PublicNeedDetailProps {
|
||||||
|
need: LatentNeed
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildReason(propType: string, needType: string, city: string, location: string): string {
|
||||||
|
if (propType === needType) {
|
||||||
|
const match = location.toLowerCase().includes(city.toLowerCase()) || city.toLowerCase().includes(location.toLowerCase())
|
||||||
|
return match ? 'Nutzungstyp und Standort passen sehr gut' : 'Passender Nutzungstyp, alternative Lage'
|
||||||
|
}
|
||||||
|
return 'Alternatives Profil — Detailprüfung empfohlen'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PublicNeedDetail({ need }: PublicNeedDetailProps) {
|
||||||
|
const openWizard = useOfferWizardStore(s => s.open)
|
||||||
|
const setSelectedProperties = useOfferWizardStore(s => s.setSelectedProperties)
|
||||||
|
const selectedIds = useOfferWizardStore(s => s.selectedPropertyIds)
|
||||||
|
const toggleProperty = useOfferWizardStore(s => s.toggleProperty)
|
||||||
|
const currentUser = useSessionStore(s => s.currentUser)
|
||||||
|
const statusCfg = latentStatusBadge(need.status)
|
||||||
|
const [showAll, setShowAll] = useState(false)
|
||||||
|
|
||||||
|
const { data: properties = [], isLoading: propertiesLoading } = useProperties({ resultType: ResultType.VERIFIED_PORTFOLIO })
|
||||||
|
const disabled = currentUser?.role === UserRole.OWNER_VIEWER || need.status !== 'public'
|
||||||
|
|
||||||
|
const scored = useMemo(
|
||||||
|
() =>
|
||||||
|
properties
|
||||||
|
.map(p => ({
|
||||||
|
property: p,
|
||||||
|
score: deterministicMatchScore(p.id, need.id),
|
||||||
|
reason: buildReason(p.assetType, need.assetType, p.location.city, need.desiredLocation),
|
||||||
|
}))
|
||||||
|
.sort((a, b) => b.score - a.score),
|
||||||
|
[properties, need],
|
||||||
|
)
|
||||||
|
|
||||||
|
const visible = showAll ? scored : scored.slice(0, 3)
|
||||||
|
const remaining = scored.length - 3
|
||||||
|
|
||||||
|
function handleQuickOffer(propertyId: string) {
|
||||||
|
setSelectedProperties([propertyId])
|
||||||
|
openWizard(need.id, need.title)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden', bgcolor: '#f8fafc' }}>
|
||||||
|
{/* Scrollable content */}
|
||||||
|
<Box sx={{ flex: 1, overflowY: 'auto', p: 3, display: 'flex', flexDirection: 'column', gap: 2.5 }}>
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<Box>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.5 }}>
|
||||||
|
<Chip label={assetTypeLabel(need.assetType)} size="small"
|
||||||
|
sx={{ bgcolor: '#e0e7ff', color: '#3730a3', fontWeight: 600, fontSize: '0.7rem', height: 22 }} />
|
||||||
|
<Chip label={statusCfg.label} size="small"
|
||||||
|
sx={{ bgcolor: statusCfg.bg, color: statusCfg.fg, fontWeight: 600, fontSize: '0.7rem', height: 22 }} />
|
||||||
|
</Box>
|
||||||
|
<Typography variant="h5" sx={{ fontWeight: 700, color: '#0f172a', fontSize: '1.35rem', mb: 0.5 }}>
|
||||||
|
{need.title}
|
||||||
|
</Typography>
|
||||||
|
{need.tenantCompany && (
|
||||||
|
<Typography variant="body2" sx={{ color: '#64748b' }}>{need.tenantCompany}</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* KI-Entscheidungsbrief */}
|
||||||
|
{need.aiSummary && (
|
||||||
|
<Paper variant="outlined" sx={{ p: 2.5, borderRadius: 2, borderColor: '#bfdbfe', bgcolor: '#eff6ff' }}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.25 }}>
|
||||||
|
<Sparkles size={16} color="#1d4ed8" />
|
||||||
|
<Typography variant="caption"
|
||||||
|
sx={{ fontWeight: 700, color: '#1d4ed8', textTransform: 'uppercase', letterSpacing: 0.8, fontSize: '0.7rem' }}>
|
||||||
|
KI-Entscheidungsbrief
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Typography variant="body1" sx={{ color: '#1e3a8a', lineHeight: 1.7, fontSize: '0.9375rem' }}>
|
||||||
|
{need.aiSummary}
|
||||||
|
</Typography>
|
||||||
|
</Paper>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Suchprofil */}
|
||||||
|
<Box>
|
||||||
|
<Typography variant="caption" sx={{ color: '#64748b', fontWeight: 700, textTransform: 'uppercase', letterSpacing: 0.5 }}>
|
||||||
|
Suchprofil
|
||||||
|
</Typography>
|
||||||
|
<Box sx={{ mt: 1, display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', gap: 1.5 }}>
|
||||||
|
<ProfileBox icon={<MapPin size={14} />} label="Standort" value={need.desiredLocation} />
|
||||||
|
<ProfileBox icon={<Ruler size={14} />} label="Fläche" value={`${need.sizeRange.min}–${need.sizeRange.max} m²`} />
|
||||||
|
<ProfileBox
|
||||||
|
icon={<Wallet size={14} />}
|
||||||
|
label="Budget"
|
||||||
|
value={need.budgetRange
|
||||||
|
? `${need.budgetRange.min ? `CHF ${need.budgetRange.min}` : 'flex'}–${need.budgetRange.max ? `CHF ${need.budgetRange.max}` : 'flex'} /m²`
|
||||||
|
: 'Flexibel'}
|
||||||
|
/>
|
||||||
|
<ProfileBox icon={<Calendar size={14} />} label="Timing" value={need.timing} />
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Must-haves */}
|
||||||
|
{need.mustHaveCriteria.length > 0 && (
|
||||||
|
<Box>
|
||||||
|
<Typography variant="caption" sx={{ color: '#64748b', fontWeight: 700, textTransform: 'uppercase', letterSpacing: 0.5 }}>
|
||||||
|
Must-have Kriterien
|
||||||
|
</Typography>
|
||||||
|
<Box sx={{ mt: 1, display: 'flex', flexDirection: 'column', gap: 0.5 }}>
|
||||||
|
{need.mustHaveCriteria.map((c, idx) => (
|
||||||
|
<Box key={idx} sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
|
<CheckCircle2 size={14} color="#16a34a" />
|
||||||
|
<Typography variant="body2" sx={{ fontSize: '0.875rem', color: '#1e293b' }}>{c}</Typography>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Gewichtete Präferenzen — Accordion */}
|
||||||
|
{need.weightedPreferences.length > 0 && (
|
||||||
|
<Accordion elevation={0} disableGutters
|
||||||
|
sx={{ border: '1px solid #e2e8f0', borderRadius: '8px !important', overflow: 'hidden', '&:before': { display: 'none' }, bgcolor: 'white' }}>
|
||||||
|
<AccordionSummary expandIcon={<ChevronDown size={16} color="#64748b" />}
|
||||||
|
sx={{ px: 2, minHeight: 44, '& .MuiAccordionSummary-content': { my: 0 } }}>
|
||||||
|
<Typography variant="caption" sx={{ color: '#64748b', fontWeight: 700, textTransform: 'uppercase', letterSpacing: 0.5 }}>
|
||||||
|
Gewichtete Präferenzen
|
||||||
|
</Typography>
|
||||||
|
</AccordionSummary>
|
||||||
|
<AccordionDetails sx={{ px: 2, pt: 0, pb: 2 }}>
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||||
|
{need.weightedPreferences.map((p, idx) => {
|
||||||
|
const pct = Math.round(p.weight * 100)
|
||||||
|
return (
|
||||||
|
<Box key={idx} sx={{ bgcolor: '#f8fafc', border: '1px solid #e2e8f0', borderRadius: 1.5, px: 1.5, py: 1 }}>
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 0.5 }}>
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 600, fontSize: '0.85rem' }}>{p.criterion}</Typography>
|
||||||
|
<Typography variant="caption"
|
||||||
|
sx={{ fontWeight: 700, color: '#152642', fontSize: '0.75rem', bgcolor: '#e0e7ff', px: 1, py: 0.125, borderRadius: 1 }}>
|
||||||
|
{pct}%
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ height: 6, borderRadius: 3, bgcolor: '#e8e7e4', overflow: 'hidden' }}>
|
||||||
|
<Box sx={{ width: `${pct}%`, height: '100%', bgcolor: '#152642', transition: 'width 0.3s' }} />
|
||||||
|
</Box>
|
||||||
|
{p.description && (
|
||||||
|
<Typography variant="caption" sx={{ color: '#64748b', fontSize: '0.75rem', mt: 0.5, display: 'block' }}>
|
||||||
|
{p.description}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</Box>
|
||||||
|
</AccordionDetails>
|
||||||
|
</Accordion>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Top Empfehlungen */}
|
||||||
|
<Box>
|
||||||
|
<Divider sx={{ mb: 2.5 }} />
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
|
||||||
|
<Target size={14} color="#152642" />
|
||||||
|
<Typography variant="caption" sx={{ color: '#64748b', fontWeight: 700, textTransform: 'uppercase', letterSpacing: 0.5 }}>
|
||||||
|
Ihre Top Empfehlungen
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
{propertiesLoading ? (
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'center', p: 3 }}><CircularProgress size={20} /></Box>
|
||||||
|
) : scored.length === 0 ? (
|
||||||
|
<Typography variant="caption" sx={{ color: '#64748b', fontSize: '0.8rem' }}>
|
||||||
|
Keine Portfolio-Objekte vorhanden
|
||||||
|
</Typography>
|
||||||
|
) : (
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.25 }}>
|
||||||
|
{visible.map(({ property, score, reason }) => (
|
||||||
|
<EmbeddedPropertyCard
|
||||||
|
key={property.id}
|
||||||
|
property={property}
|
||||||
|
matchScore={score}
|
||||||
|
selected={selectedIds.includes(property.id)}
|
||||||
|
onToggle={() => toggleProperty(property.id)}
|
||||||
|
reason={reason}
|
||||||
|
onQuickOffer={() => handleQuickOffer(property.id)}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{remaining > 0 && (
|
||||||
|
<Box
|
||||||
|
onClick={() => setShowAll(s => !s)}
|
||||||
|
sx={{
|
||||||
|
py: 1.25,
|
||||||
|
textAlign: 'center',
|
||||||
|
cursor: 'pointer',
|
||||||
|
color: '#64748b',
|
||||||
|
fontSize: '0.8rem',
|
||||||
|
fontWeight: 500,
|
||||||
|
border: '1px dashed #e2e8f0',
|
||||||
|
borderRadius: 1.5,
|
||||||
|
'&:hover': { color: '#152642', borderColor: '#152642', bgcolor: '#f8fafc' },
|
||||||
|
transition: 'all 0.15s',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{showAll ? '▲ Weniger anzeigen' : `▼ Alle ${scored.length} Objekte anzeigen (+${remaining} weitere)`}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Sticky footer CTA */}
|
||||||
|
<Box sx={{ flexShrink: 0, borderTop: '1px solid #e2e8f0', px: 3, py: 2, bgcolor: 'white' }}>
|
||||||
|
{disabled && need.status !== 'public' && (
|
||||||
|
<Alert severity="warning" icon={<Info size={16} />} sx={{ fontSize: '0.8125rem', mb: 1.5 }}>
|
||||||
|
Dieser Bedarf ist aktuell {statusCfg.label.toLowerCase()} — kein Angebot möglich.
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
{selectedIds.length > 0 && !disabled && (
|
||||||
|
<Typography variant="caption" sx={{ color: '#475569', display: 'block', mb: 1, textAlign: 'center' }}>
|
||||||
|
{selectedIds.length} Objekt{selectedIds.length !== 1 ? 'e' : ''} ausgewählt
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
size="large"
|
||||||
|
fullWidth
|
||||||
|
onClick={() => openWizard(need.id, need.title)}
|
||||||
|
disabled={disabled}
|
||||||
|
sx={{ textTransform: 'none', bgcolor: '#152642', fontWeight: 600, '&:hover': { bgcolor: '#16304d' } }}
|
||||||
|
>
|
||||||
|
Angebot erstellen
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProfileBox({ icon, label, value }: { icon: React.ReactNode; label: string; value: string }) {
|
||||||
|
return (
|
||||||
|
<Box sx={{ bgcolor: 'white', border: '1px solid #e2e8f0', borderRadius: 1.5, px: 1.5, py: 1 }}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, color: '#64748b', mb: 0.25 }}>
|
||||||
|
{icon}
|
||||||
|
<Typography variant="caption" sx={{ fontSize: '0.7rem', fontWeight: 600, textTransform: 'uppercase', letterSpacing: 0.5 }}>
|
||||||
|
{label}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Typography variant="body2" sx={{ fontSize: '0.85rem', color: '#0f172a', fontWeight: 500 }}>{value}</Typography>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import { Box, CircularProgress, Typography } from '@mui/material'
|
||||||
|
import { Sparkles } from 'lucide-react'
|
||||||
|
import { usePublicNeeds } from '../../hooks/useLatentNeeds'
|
||||||
|
import { PublicNeedCard } from './PublicNeedCard'
|
||||||
|
import { EmptyState } from '../ui'
|
||||||
|
|
||||||
|
interface PublicNeedListProps {
|
||||||
|
selectedNeedId: string | null
|
||||||
|
onSelect: (id: string) => void
|
||||||
|
fullWidth?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PublicNeedList({ selectedNeedId, onSelect, fullWidth }: PublicNeedListProps) {
|
||||||
|
const { data: needs = [], isLoading, error } = usePublicNeeds()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: fullWidth ? '100%' : 260,
|
||||||
|
minWidth: fullWidth ? 'unset' : 260,
|
||||||
|
flexShrink: 0,
|
||||||
|
borderRight: fullWidth ? 'none' : '1px solid #e2e8f0',
|
||||||
|
bgcolor: 'white',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
px: 2,
|
||||||
|
py: 1.25,
|
||||||
|
borderBottom: '1px solid #e2e8f0',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 1,
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Sparkles size={14} color="#7c3aed" />
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 600, color: '#0f172a' }}>
|
||||||
|
Latente Bedarfe
|
||||||
|
</Typography>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
ml: 'auto',
|
||||||
|
px: 1,
|
||||||
|
py: 0.125,
|
||||||
|
borderRadius: 1,
|
||||||
|
bgcolor: '#152642',
|
||||||
|
color: 'white',
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: '0.7rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{needs.length}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ flex: 1, overflowY: 'auto', p: 1.25, display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||||
|
{isLoading && (
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}>
|
||||||
|
<CircularProgress size={20} />
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
{error && (
|
||||||
|
<Typography variant="body2" color="error">
|
||||||
|
Fehler beim Laden
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
{!isLoading && needs.length === 0 && (
|
||||||
|
<EmptyState
|
||||||
|
title="Keine Bedarfe"
|
||||||
|
description="Aktuell sind keine öffentlichen Bedarfe verfügbar."
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{needs.map(n => (
|
||||||
|
<PublicNeedCard
|
||||||
|
key={n.id}
|
||||||
|
need={n}
|
||||||
|
selected={n.id === selectedNeedId}
|
||||||
|
onClick={() => onSelect(n.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,288 @@
|
|||||||
|
import { Box, Button, CircularProgress, Divider, Typography } from '@mui/material'
|
||||||
|
import { ArrowRight, Building2, Calendar, MapPin, Ruler } from 'lucide-react'
|
||||||
|
import { useNavigate } from 'react-router'
|
||||||
|
import { usePropertyById } from '../../hooks/useProperties'
|
||||||
|
import { useAdditionalMatchesForInquiry } from '../../hooks/useMatches'
|
||||||
|
import type { AdditionalPropertyMatch } from '../../domain/additionalMatch'
|
||||||
|
|
||||||
|
interface RelatedPropertyCardPanelProps {
|
||||||
|
propertyId: string
|
||||||
|
inquiryId: string
|
||||||
|
compact?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RelatedPropertyCardPanel({ propertyId, inquiryId, compact }: RelatedPropertyCardPanelProps) {
|
||||||
|
const { data: property, isLoading } = usePropertyById(propertyId)
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const {
|
||||||
|
data: additionalMatches = [],
|
||||||
|
isLoading: loadingMatches,
|
||||||
|
} = useAdditionalMatchesForInquiry(inquiryId, { minScore: 80, excludePropertyId: propertyId })
|
||||||
|
|
||||||
|
if (isLoading && !compact) {
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', p: 4 }}>
|
||||||
|
<CircularProgress size={20} />
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (compact) {
|
||||||
|
return (
|
||||||
|
<Box sx={{ px: 1.5, pt: 1.25, pb: 1.5, bgcolor: 'white' }}>
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
sx={{ color: '#64748b', fontWeight: 600, textTransform: 'uppercase', letterSpacing: 0.5, fontSize: '0.65rem', display: 'block', mb: 1 }}
|
||||||
|
>
|
||||||
|
Weitere passende Objekte
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{loadingMatches ? (
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'center', py: 1.5 }}>
|
||||||
|
<CircularProgress size={16} />
|
||||||
|
</Box>
|
||||||
|
) : additionalMatches.length === 0 ? (
|
||||||
|
<Typography variant="caption" sx={{ color: '#94a3b8', fontSize: '0.72rem' }}>
|
||||||
|
Keine weiteren Matches
|
||||||
|
</Typography>
|
||||||
|
) : (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
gap: 1.25,
|
||||||
|
overflowX: 'auto',
|
||||||
|
pb: 0.5,
|
||||||
|
'&::-webkit-scrollbar': { height: 3 },
|
||||||
|
'&::-webkit-scrollbar-thumb': { bgcolor: '#cbd5e1', borderRadius: 2 },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{additionalMatches.map(m => (
|
||||||
|
<MatchSliderCard key={m.propertyId} match={m} />
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!property) {
|
||||||
|
return (
|
||||||
|
<Box sx={{ p: 2 }}>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
Objekt nicht gefunden
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const image = property.images?.[0]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 1.5,
|
||||||
|
p: 2,
|
||||||
|
borderLeft: '1px solid #e2e8f0',
|
||||||
|
bgcolor: '#f8fafc',
|
||||||
|
height: '100%',
|
||||||
|
overflowY: 'auto',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
aspectRatio: '3/2',
|
||||||
|
borderRadius: 1.5,
|
||||||
|
overflow: 'hidden',
|
||||||
|
bgcolor: '#f4f3f0',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{image ? (
|
||||||
|
<Box component="img" src={image} alt={property.title}
|
||||||
|
sx={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }} />
|
||||||
|
) : (
|
||||||
|
<Box sx={{ width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
|
<Building2 size={32} color="#94a3b8" />
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Typography variant="body1" sx={{ fontWeight: 600, color: '#0f172a', fontSize: '0.95rem' }}>
|
||||||
|
{property.title}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75 }}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, color: '#475569' }}>
|
||||||
|
<MapPin size={14} />
|
||||||
|
<Typography variant="body2" sx={{ fontSize: '0.8125rem' }}>
|
||||||
|
{property.location.city}
|
||||||
|
{property.location.district ? `, ${property.location.district}` : ''}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, color: '#475569' }}>
|
||||||
|
<Ruler size={14} />
|
||||||
|
<Typography variant="body2" sx={{ fontSize: '0.8125rem' }}>
|
||||||
|
{property.areaSqm.toLocaleString('de-CH')} m² · CHF {property.rentPricePerSqm}/m²/Jahr
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, color: '#475569' }}>
|
||||||
|
<Calendar size={14} />
|
||||||
|
<Typography variant="body2" sx={{ fontSize: '0.8125rem' }}>
|
||||||
|
Verfügbar ab {new Date(property.availabilityDate).toLocaleDateString('de-CH')}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{property.description && (
|
||||||
|
<Typography variant="body2" sx={{ color: '#64748b', fontSize: '0.8125rem', lineHeight: 1.5 }}>
|
||||||
|
{property.description}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
size="small"
|
||||||
|
endIcon={<ArrowRight size={14} />}
|
||||||
|
onClick={() => navigate('/supply/properties')}
|
||||||
|
sx={{ textTransform: 'none' }}
|
||||||
|
>
|
||||||
|
Objekt ansehen
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Divider sx={{ my: 0.5 }} />
|
||||||
|
<Typography variant="caption" sx={{ color: '#64748b', fontWeight: 600, textTransform: 'uppercase', letterSpacing: 0.5, fontSize: '0.68rem' }}>
|
||||||
|
Weitere passende Objekte
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{loadingMatches ? (
|
||||||
|
<CircularProgress size={16} sx={{ alignSelf: 'center' }} />
|
||||||
|
) : additionalMatches.length === 0 ? (
|
||||||
|
<Typography variant="caption" sx={{ color: '#94a3b8', fontSize: '0.75rem' }}>
|
||||||
|
Keine weiteren Matches
|
||||||
|
</Typography>
|
||||||
|
) : (
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||||
|
{additionalMatches.map(m => (
|
||||||
|
<AdditionalMatchCard key={m.propertyId} match={m} />
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MatchSliderCard({ match }: { match: AdditionalPropertyMatch }) {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
onClick={() => navigate('/supply/properties')}
|
||||||
|
sx={{
|
||||||
|
flexShrink: 0,
|
||||||
|
width: 168,
|
||||||
|
border: '1px solid #e8e7e4',
|
||||||
|
borderRadius: 1.5,
|
||||||
|
overflow: 'hidden',
|
||||||
|
cursor: 'pointer',
|
||||||
|
bgcolor: 'white',
|
||||||
|
transition: 'border-color 0.15s, box-shadow 0.15s',
|
||||||
|
'&:hover': {
|
||||||
|
borderColor: '#b0aead',
|
||||||
|
boxShadow: '0 2px 8px rgba(0,0,0,0.08)',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Image — 16:9 crop, compact supplementary card */}
|
||||||
|
<Box sx={{ position: 'relative', height: 60, bgcolor: '#f4f3f0', overflow: 'hidden' }}>
|
||||||
|
{match.imageUrl ? (
|
||||||
|
<Box
|
||||||
|
component="img"
|
||||||
|
src={match.imageUrl}
|
||||||
|
alt={match.title}
|
||||||
|
sx={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Box sx={{ width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
|
<Building2 size={16} color="#94a3b8" />
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
{/* Score badge overlay */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 6,
|
||||||
|
right: 6,
|
||||||
|
bgcolor: 'rgba(15,23,42,0.72)',
|
||||||
|
color: 'white',
|
||||||
|
fontSize: '0.7rem',
|
||||||
|
fontWeight: 700,
|
||||||
|
px: 0.75,
|
||||||
|
py: 0.25,
|
||||||
|
borderRadius: '4px',
|
||||||
|
lineHeight: 1.4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{match.matchScore}%
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ p: 0.75 }}>
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', mb: 0.2 }}>
|
||||||
|
<Typography
|
||||||
|
sx={{ fontWeight: 600, color: '#0f172a', fontSize: '0.75rem', lineHeight: 1.3, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1, mr: 0.5 }}
|
||||||
|
>
|
||||||
|
{match.title}
|
||||||
|
</Typography>
|
||||||
|
<Typography sx={{ fontSize: '0.7rem', fontWeight: 700, color: '#152642', flexShrink: 0 }}>
|
||||||
|
{match.matchScore}%
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Typography variant="caption" sx={{ color: '#64748b', fontSize: '0.68rem', display: 'block', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||||
|
{match.location}
|
||||||
|
</Typography>
|
||||||
|
{match.reasons[0] && (
|
||||||
|
<Typography variant="caption" sx={{ color: '#15803d', fontSize: '0.67rem', display: 'block', lineHeight: 1.4, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', mt: 0.2 }}>
|
||||||
|
+ {match.reasons[0]}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AdditionalMatchCard({ match }: { match: AdditionalPropertyMatch }) {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
borderTop: '1px solid #f1f5f9',
|
||||||
|
pt: 1,
|
||||||
|
'&:first-of-type': { borderTop: 'none', pt: 0 },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', mb: 0.25 }}>
|
||||||
|
<Typography sx={{ fontWeight: 600, color: '#0f172a', fontSize: '0.8rem', lineHeight: 1.3, mr: 0.5 }}>
|
||||||
|
{match.title}
|
||||||
|
</Typography>
|
||||||
|
<Typography sx={{ fontSize: '0.75rem', fontWeight: 700, color: '#152642', flexShrink: 0 }}>
|
||||||
|
{match.matchScore}%
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Typography variant="caption" sx={{ color: '#64748b', fontSize: '0.72rem', display: 'block', mb: 0.375 }}>
|
||||||
|
{match.location} · {match.areaSqm.toLocaleString('de-CH')} m²
|
||||||
|
</Typography>
|
||||||
|
{match.reasons[0] && (
|
||||||
|
<Typography variant="caption" sx={{ color: '#15803d', fontSize: '0.7rem', display: 'block', mb: 0.375, lineHeight: 1.4 }}>
|
||||||
|
+ {match.reasons[0]}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
endIcon={<ArrowRight size={10} />}
|
||||||
|
onClick={() => navigate('/supply/properties')}
|
||||||
|
sx={{ textTransform: 'none', fontSize: '0.7rem', p: 0, minWidth: 0, color: '#152642' }}
|
||||||
|
>
|
||||||
|
Ansehen
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
import { Accordion, AccordionDetails, AccordionSummary, Box, Checkbox, FormControlLabel, Typography, Button } from '@mui/material'
|
||||||
|
import { ChevronDown } from 'lucide-react'
|
||||||
|
import type { ReportObjectFieldKey, ReportObjectFieldSelection } from '../../domain/inquiryReport'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
propertyTitle: string
|
||||||
|
value: ReportObjectFieldSelection
|
||||||
|
onChange: (v: ReportObjectFieldSelection) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
type FieldGroup = {
|
||||||
|
label: string
|
||||||
|
fields: { key: ReportObjectFieldKey; label: string }[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const FIELD_GROUPS: FieldGroup[] = [
|
||||||
|
{
|
||||||
|
label: 'Basisdaten',
|
||||||
|
fields: [
|
||||||
|
{ key: 'areaSqm', label: 'Fläche' },
|
||||||
|
{ key: 'rentPricePerSqm', label: 'Mietpreis' },
|
||||||
|
{ key: 'availabilityDate', label: 'Verfügbar ab' },
|
||||||
|
{ key: 'propertyNumber', label: 'Objektnummer' },
|
||||||
|
{ key: 'assetType', label: 'Asset Type' },
|
||||||
|
{ key: 'description', label: 'Beschreibung' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Miet-/Vertragsdaten',
|
||||||
|
fields: [
|
||||||
|
{ key: 'leaseTerm', label: 'Mietlaufzeit' },
|
||||||
|
{ key: 'breakoutOption', label: 'Breakout-Option' },
|
||||||
|
{ key: 'breakoutOptionDate', label: 'Breakout-Option Zeitpunkt' },
|
||||||
|
{ key: 'currentTenant', label: 'Aktueller Mieter' },
|
||||||
|
{ key: 'floor', label: 'Stockwerk' },
|
||||||
|
{ key: 'parking', label: 'Parkplätze' },
|
||||||
|
{ key: 'fitOut', label: 'Ausbaugrad' },
|
||||||
|
{ key: 'isBarrierFree', label: 'Barrierefrei' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Technische Hard Facts',
|
||||||
|
fields: [
|
||||||
|
{ key: 'ceilingHeightM', label: 'Raumhöhe' },
|
||||||
|
{ key: 'floorLoad', label: 'Bodenlast' },
|
||||||
|
{ key: 'loadingDocksCount', label: 'Anlieferung' },
|
||||||
|
{ key: 'goodsLift', label: 'Warenaufzug' },
|
||||||
|
{ key: 'passengerLift', label: 'Personenaufzug' },
|
||||||
|
{ key: 'powerSupplyKva', label: 'Stromanschluss (kVA)' },
|
||||||
|
{ key: 'hasServerRoom', label: 'Serverraum' },
|
||||||
|
{ key: 'internet', label: 'Internet / Konnektivität' },
|
||||||
|
{ key: 'deliveryAccess', label: 'Zufahrt / Logistik' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Soft Factors',
|
||||||
|
fields: [
|
||||||
|
{ key: 'prestigeScore', label: 'Prestige' },
|
||||||
|
{ key: 'visibilityScore', label: 'Sichtbarkeit' },
|
||||||
|
{ key: 'footfallScore', label: 'Passantenfrequenz' },
|
||||||
|
{ key: 'commuterAccessScore', label: 'Pendlererreichbarkeit' },
|
||||||
|
{ key: 'talentAccessScore', label: 'Talent Access' },
|
||||||
|
{ key: 'esgScore', label: 'ESG / Nachhaltigkeit' },
|
||||||
|
{ key: 'flexibilityScore', label: 'Flexibilität' },
|
||||||
|
{ key: 'expansionPotentialScore', label: 'Expansionsmöglichkeit' },
|
||||||
|
{ key: 'taxEnvironmentScore', label: 'Steuerumfeld' },
|
||||||
|
{ key: 'publicTransportScore', label: 'ÖV-Anbindung' },
|
||||||
|
{ key: 'microLocation', label: 'Mikrostandort' },
|
||||||
|
{ key: 'competitionEnvironment', label: 'Konkurrenzumfeld' },
|
||||||
|
{ key: 'infrastructure', label: 'Infrastruktur' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Marktinformationen',
|
||||||
|
fields: [
|
||||||
|
{ key: 'marketSignals', label: 'Marktsignale' },
|
||||||
|
{ key: 'negotiationHints', label: 'Verhandlungshinweise' },
|
||||||
|
{ key: 'missingData', label: 'Datenlücken / offene Punkte' },
|
||||||
|
{ key: 'dataQuality', label: 'Datenqualität' },
|
||||||
|
{ key: 'units', label: 'Stockwerkstruktur' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export function ReportObjectFieldSelector({ propertyTitle, value, onChange }: Props) {
|
||||||
|
const isSelected = (key: ReportObjectFieldKey) =>
|
||||||
|
value.selectedOptionalFields.includes(key)
|
||||||
|
|
||||||
|
const toggle = (key: ReportObjectFieldKey) => {
|
||||||
|
const next = isSelected(key)
|
||||||
|
? value.selectedOptionalFields.filter(k => k !== key)
|
||||||
|
: [...value.selectedOptionalFields, key]
|
||||||
|
onChange({ ...value, selectedOptionalFields: next })
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectAll = (keys: ReportObjectFieldKey[]) => {
|
||||||
|
const next = Array.from(new Set([...value.selectedOptionalFields, ...keys]))
|
||||||
|
onChange({ ...value, selectedOptionalFields: next })
|
||||||
|
}
|
||||||
|
|
||||||
|
const deselectAll = (keys: ReportObjectFieldKey[]) => {
|
||||||
|
onChange({ ...value, selectedOptionalFields: value.selectedOptionalFields.filter(k => !keys.includes(k)) })
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 600, color: '#0f172a', mb: 1 }}>
|
||||||
|
Felder für: {propertyTitle}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" sx={{ color: '#64748b', display: 'block', mb: 1.5 }}>
|
||||||
|
Pflichtfelder (immer enthalten): Titel, Standort, Karte, Fotos
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{FIELD_GROUPS.map(group => {
|
||||||
|
const groupKeys = group.fields.map(f => f.key)
|
||||||
|
return (
|
||||||
|
<Accordion key={group.label} disableGutters elevation={0} sx={{ border: '1px solid #e2e8f0', mb: 0.5, '&:before': { display: 'none' } }}>
|
||||||
|
<AccordionSummary expandIcon={<ChevronDown size={16} />} sx={{ minHeight: 40, '& .MuiAccordionSummary-content': { my: 0.5 } }}>
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 600, fontSize: '0.8125rem' }}>
|
||||||
|
{group.label}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" sx={{ ml: 1, color: '#64748b', alignSelf: 'center' }}>
|
||||||
|
({groupKeys.filter(k => value.selectedOptionalFields.includes(k)).length}/{groupKeys.length})
|
||||||
|
</Typography>
|
||||||
|
</AccordionSummary>
|
||||||
|
<AccordionDetails sx={{ pt: 0, pb: 1 }}>
|
||||||
|
<Box sx={{ display: 'flex', gap: 1, mb: 1 }}>
|
||||||
|
<Button size="small" onClick={() => selectAll(groupKeys)} sx={{ textTransform: 'none', fontSize: '0.7rem', p: '2px 8px', minWidth: 0 }}>
|
||||||
|
Alle
|
||||||
|
</Button>
|
||||||
|
<Button size="small" onClick={() => deselectAll(groupKeys)} sx={{ textTransform: 'none', fontSize: '0.7rem', p: '2px 8px', minWidth: 0 }}>
|
||||||
|
Keine
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 0 }}>
|
||||||
|
{group.fields.map(f => (
|
||||||
|
<FormControlLabel
|
||||||
|
key={f.key}
|
||||||
|
control={
|
||||||
|
<Checkbox
|
||||||
|
size="small"
|
||||||
|
checked={isSelected(f.key)}
|
||||||
|
onChange={() => toggle(f.key)}
|
||||||
|
sx={{ py: 0.25 }}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label={<Typography variant="caption" sx={{ fontSize: '0.75rem' }}>{f.label}</Typography>}
|
||||||
|
sx={{ m: 0 }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</AccordionDetails>
|
||||||
|
</Accordion>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import { Box, Checkbox, Paper, Typography } from '@mui/material'
|
||||||
|
import { Building2, MapPin } from 'lucide-react'
|
||||||
|
import type { Property } from '../../domain/property'
|
||||||
|
import { getScoreTier, SCORE_THEME } from '../shared/scoreTheme'
|
||||||
|
import { assetTypeLabel } from './latentNeedUtils'
|
||||||
|
|
||||||
|
interface SelectablePropertyMatchCardProps {
|
||||||
|
property: Property
|
||||||
|
matchScore: number
|
||||||
|
selected: boolean
|
||||||
|
onToggle: () => void
|
||||||
|
reason: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SelectablePropertyMatchCard({
|
||||||
|
property,
|
||||||
|
matchScore,
|
||||||
|
selected,
|
||||||
|
onToggle,
|
||||||
|
reason,
|
||||||
|
}: SelectablePropertyMatchCardProps) {
|
||||||
|
const tier = getScoreTier(matchScore)
|
||||||
|
const theme = SCORE_THEME[tier]
|
||||||
|
const image = property.images?.[0]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper
|
||||||
|
elevation={0}
|
||||||
|
onClick={onToggle}
|
||||||
|
sx={{
|
||||||
|
p: 1.25,
|
||||||
|
borderRadius: 1.5,
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: selected ? '#152642' : '#e8e7e4',
|
||||||
|
bgcolor: selected ? '#eff6ff' : 'white',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'all 0.15s',
|
||||||
|
'&:hover': {
|
||||||
|
borderColor: selected ? '#152642' : '#94a3b8',
|
||||||
|
},
|
||||||
|
display: 'flex',
|
||||||
|
gap: 1.25,
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
checked={selected}
|
||||||
|
onChange={onToggle}
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
size="small"
|
||||||
|
sx={{ p: 0.5, mt: -0.5, ml: -0.5 }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
flexShrink: 0,
|
||||||
|
borderRadius: 1,
|
||||||
|
bgcolor: '#e8e7e4',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
backgroundImage: image ? `url(${image})` : 'none',
|
||||||
|
backgroundSize: 'cover',
|
||||||
|
backgroundPosition: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{!image && <Building2 size={18} color="#94a3b8" />}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 1 }}>
|
||||||
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
sx={{
|
||||||
|
fontWeight: 600,
|
||||||
|
color: '#0f172a',
|
||||||
|
fontSize: '0.8125rem',
|
||||||
|
lineHeight: 1.3,
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
display: '-webkit-box',
|
||||||
|
WebkitLineClamp: 1,
|
||||||
|
WebkitBoxOrient: 'vertical',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{property.title}
|
||||||
|
</Typography>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
background: theme.gradient,
|
||||||
|
color: theme.text,
|
||||||
|
px: 0.75,
|
||||||
|
py: 0.125,
|
||||||
|
borderRadius: 1,
|
||||||
|
fontSize: '0.7rem',
|
||||||
|
fontWeight: 700,
|
||||||
|
flexShrink: 0,
|
||||||
|
border: `1px solid ${theme.border}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{matchScore}%
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mt: 0.25, color: '#64748b' }}>
|
||||||
|
<MapPin size={11} />
|
||||||
|
<Typography variant="caption" sx={{ fontSize: '0.7rem' }}>
|
||||||
|
{property.location.city} · {assetTypeLabel(property.assetType)} · {property.areaSqm.toLocaleString('de-CH')} m²
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Typography variant="caption" sx={{ fontSize: '0.7rem', color: '#475569', display: 'block', mt: 0.25 }}>
|
||||||
|
{reason}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
export { ActiveInquiriesTab } from './ActiveInquiriesTab'
|
||||||
|
export { LatentInquiriesTab } from './LatentInquiriesTab'
|
||||||
|
export { OfferWizard } from './OfferWizard'
|
||||||
|
export { InquiryList } from './InquiryList'
|
||||||
|
export { InquiryListRow } from './InquiryListRow'
|
||||||
|
export { InquiryCard } from './InquiryCard'
|
||||||
|
export { InquiryCardGrid } from './InquiryCardGrid'
|
||||||
|
export { InquiryChat } from './InquiryChat'
|
||||||
|
export { InquiryMessageBubble } from './InquiryMessageBubble'
|
||||||
|
export { InquiryReplyComposer } from './InquiryReplyComposer'
|
||||||
|
export { InquiryDetailPanel } from './InquiryDetailPanel'
|
||||||
|
export { RelatedPropertyCardPanel } from './RelatedPropertyCardPanel'
|
||||||
|
export { PublicNeedList } from './PublicNeedList'
|
||||||
|
export { PublicNeedCard } from './PublicNeedCard'
|
||||||
|
export { PublicNeedDetail } from './PublicNeedDetail'
|
||||||
|
export { OwnPropertyMatchList } from './OwnPropertyMatchList'
|
||||||
|
export { SelectablePropertyMatchCard } from './SelectablePropertyMatchCard'
|
||||||
|
export { EmbeddedPropertyCard } from './EmbeddedPropertyCard'
|
||||||
|
export { OfferCreationPanel } from './OfferCreationPanel'
|
||||||
|
export { OfferPropertySelectionStep } from './OfferPropertySelectionStep'
|
||||||
|
export { OfferPdfReviewStep } from './OfferPdfReviewStep'
|
||||||
|
export { OfferCheckedAction } from './OfferCheckedAction'
|
||||||
|
export { OfferChatComposer } from './OfferChatComposer'
|
||||||
|
export { AiOfferEmailButton } from './AiOfferEmailButton'
|
||||||
|
export { EditableOfferFieldList } from './EditableOfferFieldList'
|
||||||
|
export { MockPdfPreview } from './MockPdfPreview'
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { mockProperties } from '../../mock-data/properties'
|
||||||
|
|
||||||
|
const propertyMap: Record<string, string> = Object.fromEntries(
|
||||||
|
mockProperties.map(p => [p.id, p.title]),
|
||||||
|
)
|
||||||
|
|
||||||
|
export function propertyLabelFromId(id: string): string {
|
||||||
|
return propertyMap[id] ?? id
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatInquiryDate(iso: string): string {
|
||||||
|
const d = new Date(iso)
|
||||||
|
const date = d.toLocaleDateString('de-CH', { day: '2-digit', month: '2-digit', year: 'numeric' })
|
||||||
|
const time = d.toLocaleTimeString('de-CH', { hour: '2-digit', minute: '2-digit' })
|
||||||
|
return `${date} · ${time}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatFileSize(bytes?: number): string {
|
||||||
|
if (!bytes) return ''
|
||||||
|
if (bytes < 1024) return `${bytes} B`
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||||||
|
return `${(bytes / 1024 / 1024).toFixed(1)} MB`
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import type { AssetType } from '../../domain/enums'
|
||||||
|
|
||||||
|
export function assetTypeLabel(at: AssetType): string {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
OFFICE: 'Büro',
|
||||||
|
RETAIL: 'Retail',
|
||||||
|
LOGISTICS: 'Logistik',
|
||||||
|
LIGHT_INDUSTRIAL: 'Gewerbe',
|
||||||
|
PRODUCTION: 'Produktion',
|
||||||
|
GASTRO: 'Gastro',
|
||||||
|
MIXED: 'Mischnutzung',
|
||||||
|
UNKNOWN: 'Unbekannt',
|
||||||
|
}
|
||||||
|
return map[at] ?? 'Fläche'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function latentStatusBadge(status: 'public' | 'paused' | 'expired'): {
|
||||||
|
label: string
|
||||||
|
bg: string
|
||||||
|
fg: string
|
||||||
|
} {
|
||||||
|
if (status === 'public') return { label: 'Öffentlich', bg: '#dcfce7', fg: '#166534' }
|
||||||
|
if (status === 'paused') return { label: 'Pausiert', bg: '#fed7aa', fg: '#9a3412' }
|
||||||
|
return { label: 'Abgelaufen', bg: '#e8e7e4', fg: '#475569' }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deterministic pseudo-random match score from string IDs (range 60–96)
|
||||||
|
export function deterministicMatchScore(propertyId: string, needId: string): number {
|
||||||
|
const seed = `${propertyId}::${needId}`
|
||||||
|
let h = 0
|
||||||
|
for (let i = 0; i < seed.length; i++) {
|
||||||
|
h = (h * 31 + seed.charCodeAt(i)) >>> 0
|
||||||
|
}
|
||||||
|
return 60 + (h % 37)
|
||||||
|
}
|
||||||
@@ -9,7 +9,7 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ACTION_COLORS: Record<string, string> = {
|
const ACTION_COLORS: Record<string, string> = {
|
||||||
NAVIGATE: '#1e3a5f',
|
NAVIGATE: '#152642',
|
||||||
OPEN_REVIEW: '#7c3aed',
|
OPEN_REVIEW: '#7c3aed',
|
||||||
ADD_TO_SHORTLIST: '#1a7a4a',
|
ADD_TO_SHORTLIST: '#1a7a4a',
|
||||||
REQUEST_DATA: '#d97706',
|
REQUEST_DATA: '#d97706',
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ function MessageBubble({ message }: { message: AssistantMessage }) {
|
|||||||
px: 1.5,
|
px: 1.5,
|
||||||
py: 1,
|
py: 1,
|
||||||
borderRadius: isUser ? '8px 8px 2px 8px' : '2px 8px 8px 8px',
|
borderRadius: isUser ? '8px 8px 2px 8px' : '2px 8px 8px 8px',
|
||||||
bgcolor: isUser ? '#1e3a5f' : '#f1f5f9',
|
bgcolor: isUser ? '#152642' : '#f1f5f9',
|
||||||
color: isUser ? 'white' : '#1e293b',
|
color: isUser ? 'white' : '#1e293b',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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,49 +2,38 @@ 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 {
|
||||||
if (pathname.startsWith('/supply')) return 'SUPPLY' as WorkspaceType
|
if (pathname.startsWith('/supply')) return 'SUPPLY' as WorkspaceType
|
||||||
if (pathname.startsWith('/demand')) return 'DEMAND' as WorkspaceType
|
if (pathname.startsWith('/demand')) return 'DEMAND' as WorkspaceType
|
||||||
if (pathname.startsWith('/ops')) return 'OPERATIONS' as WorkspaceType
|
|
||||||
return null
|
return 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 = {
|
||||||
@@ -54,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(() => {
|
||||||
@@ -64,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)
|
||||||
@@ -78,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)
|
|
||||||
|
answerQuestion.mutate(
|
||||||
|
{ context: ctx, question },
|
||||||
|
{
|
||||||
|
onSuccess: (answer) => {
|
||||||
addMessage({
|
addMessage({
|
||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
role: 'assistant',
|
role: 'assistant',
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
...answer,
|
...answer,
|
||||||
})
|
})
|
||||||
} catch {
|
|
||||||
setError('Antwort konnte nicht generiert werden. Bitte erneut versuchen.')
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
},
|
||||||
}, [context, isLoading, location.pathname, currentUser, addMessage, setLoading, setError])
|
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) {
|
||||||
@@ -108,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
|
||||||
@@ -247,7 +237,7 @@ export function GlobalAIAssistantDrawer() {
|
|||||||
color: 'white',
|
color: 'white',
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
'&:hover': { bgcolor: '#4338ca' },
|
'&:hover': { bgcolor: '#4338ca' },
|
||||||
'&:disabled': { bgcolor: '#e2e8f0', color: '#94a3b8' },
|
'&:disabled': { bgcolor: '#e8e7e4', color: '#94a3b8' },
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Send size={16} />
|
<Send size={16} />
|
||||||
|
|||||||
@@ -1,22 +1,19 @@
|
|||||||
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 ROLE_LABELS: Record<UserRole, string> = {
|
const DEMO_ROLES: { role: UserRole; label: string }[] = [
|
||||||
[UserRole.SUPER_ADMIN]: 'Super Admin',
|
{ role: UserRole.PROPERTY_MANAGER, label: 'Verwaltung' },
|
||||||
[UserRole.ORGANIZATION_ADMIN]: 'Org Admin',
|
{ role: UserRole.DEMAND_USER, label: 'Bürosuche' },
|
||||||
[UserRole.PROPERTY_MANAGER]: 'Prop. Manager',
|
]
|
||||||
[UserRole.REVIEWER]: 'Reviewer',
|
|
||||||
[UserRole.OWNER_VIEWER]: 'Owner Viewer',
|
|
||||||
[UserRole.DEMAND_USER]: 'Demand User',
|
|
||||||
}
|
|
||||||
|
|
||||||
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 (
|
||||||
@@ -28,22 +25,22 @@ export function DemoRoleSwitcher() {
|
|||||||
Demo-Modus
|
Demo-Modus
|
||||||
</Typography>
|
</Typography>
|
||||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
|
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
|
||||||
{Object.values(UserRole).map((role) => {
|
{DEMO_ROLES.map(({ role, label }) => {
|
||||||
const active = currentUser?.role === role
|
const active = currentUser?.role === role
|
||||||
return (
|
return (
|
||||||
<Chip
|
<Chip
|
||||||
key={role}
|
key={role}
|
||||||
label={ROLE_LABELS[role]}
|
label={label}
|
||||||
size="small"
|
size="small"
|
||||||
clickable
|
clickable
|
||||||
onClick={() => handleSwitch(role)}
|
onClick={() => handleSwitch(role)}
|
||||||
sx={{
|
sx={{
|
||||||
fontSize: '0.7rem',
|
fontSize: '0.7rem',
|
||||||
height: 22,
|
height: 22,
|
||||||
bgcolor: active ? '#1e3a5f' : 'transparent',
|
bgcolor: active ? '#152642' : 'transparent',
|
||||||
color: active ? '#fff' : 'text.secondary',
|
color: active ? '#fff' : 'text.secondary',
|
||||||
border: '1px solid',
|
border: '1px solid',
|
||||||
borderColor: active ? '#1e3a5f' : 'divider',
|
borderColor: active ? '#152642' : 'divider',
|
||||||
'&:hover': { bgcolor: active ? '#162d4a' : 'rgba(0,0,0,0.04)' },
|
'&:hover': { bgcolor: active ? '#162d4a' : 'rgba(0,0,0,0.04)' },
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ export function ProtectedRoute({ workspace }: ProtectedRouteProps) {
|
|||||||
const workspaceLabel: Record<string, string> = {
|
const workspaceLabel: Record<string, string> = {
|
||||||
SUPPLY: 'Supply',
|
SUPPLY: 'Supply',
|
||||||
DEMAND: 'Demand',
|
DEMAND: 'Demand',
|
||||||
OPERATIONS: 'Operations',
|
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<AccessDenied
|
<AccessDenied
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ export function SessionExpired() {
|
|||||||
<Button
|
<Button
|
||||||
variant="contained"
|
variant="contained"
|
||||||
onClick={handleRelogin}
|
onClick={handleRelogin}
|
||||||
sx={{ mt: 1, bgcolor: '#1e3a5f', textTransform: 'none' }}
|
sx={{ mt: 1, bgcolor: '#152642', textTransform: 'none' }}
|
||||||
>
|
>
|
||||||
Erneut anmelden
|
Erneut anmelden
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||