Initial commit
This commit is contained in:
+28
@@ -0,0 +1,28 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Windows system files
|
||||||
|
desktop.ini
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
# property-match — Development Guidelines
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
|
||||||
|
- **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
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
## Styling
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
|
||||||
|
## Providers
|
||||||
|
|
||||||
|
All data access and data actions live in `src/provider/`.
|
||||||
|
|
||||||
|
### Naming
|
||||||
|
|
||||||
|
| 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
|
||||||
|
|
||||||
|
Define a TypeScript interface for each provider so the mockup and the real implementation are interchangeable:
|
||||||
|
Every Interface should be prefixed with a capitalized I.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// src/provider/IPropertyProvider.ts
|
||||||
|
export interface PropertyProvider {
|
||||||
|
getAll(): Promise<Property[]>
|
||||||
|
getById(id: string): Promise<Property | null>
|
||||||
|
create(data: CreatePropertyInput): Promise<Property>
|
||||||
|
update(id: string, data: UpdatePropertyInput): Promise<Property>
|
||||||
|
remove(id: string): Promise<void>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 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
|
||||||
|
// src/provider/MockupPropertyProvider.ts
|
||||||
|
import type { PropertyProvider } from './PropertyProvider'
|
||||||
|
|
||||||
|
const properties: Property[] = [ /* seed data */ ]
|
||||||
|
|
||||||
|
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.
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
# React + TypeScript + Vite
|
||||||
|
|
||||||
|
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||||
|
|
||||||
|
Currently, two official plugins are available:
|
||||||
|
|
||||||
|
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
|
||||||
|
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
|
||||||
|
|
||||||
|
## React Compiler
|
||||||
|
|
||||||
|
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).
|
||||||
|
|
||||||
|
## 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:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// eslint.config.js
|
||||||
|
import reactX from 'eslint-plugin-react-x'
|
||||||
|
import reactDom from 'eslint-plugin-react-dom'
|
||||||
|
|
||||||
|
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...
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
```
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import js from '@eslint/js'
|
||||||
|
import globals from 'globals'
|
||||||
|
import reactHooks from 'eslint-plugin-react-hooks'
|
||||||
|
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||||
|
import tseslint from 'typescript-eslint'
|
||||||
|
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||||
|
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores(['dist']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
extends: [
|
||||||
|
js.configs.recommended,
|
||||||
|
tseslint.configs.recommended,
|
||||||
|
reactHooks.configs.flat.recommended,
|
||||||
|
reactRefresh.configs.vite,
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
globals: globals.browser,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
+13
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Property Match</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Generated
+3942
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,41 @@
|
|||||||
|
{
|
||||||
|
"name": "property-match",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@emotion/react": "^11.14.0",
|
||||||
|
"@emotion/styled": "^11.14.1",
|
||||||
|
"@mui/icons-material": "^9.0.1",
|
||||||
|
"@mui/material": "^9.0.1",
|
||||||
|
"@tanstack/react-query": "^5.75.2",
|
||||||
|
"lucide-react": "^0.511.0",
|
||||||
|
"react": "^19.2.6",
|
||||||
|
"react-dom": "^19.2.6",
|
||||||
|
"react-router": "^7.15.0",
|
||||||
|
"zod": "^3.25.17",
|
||||||
|
"zustand": "^5.0.5"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^10.0.1",
|
||||||
|
"@tailwindcss/vite": "^4.3.0",
|
||||||
|
"@types/node": "^24.12.3",
|
||||||
|
"@types/react": "^19.2.14",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@vitejs/plugin-react": "^6.0.1",
|
||||||
|
"eslint": "^10.3.0",
|
||||||
|
"eslint-plugin-react-hooks": "^7.1.1",
|
||||||
|
"eslint-plugin-react-refresh": "^0.5.2",
|
||||||
|
"globals": "^17.6.0",
|
||||||
|
"tailwindcss": "^4.3.0",
|
||||||
|
"typescript": "~6.0.2",
|
||||||
|
"typescript-eslint": "^8.59.2",
|
||||||
|
"vite": "^8.0.12"
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 9.3 KiB |
@@ -0,0 +1,24 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||||
|
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||||
|
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||||
|
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||||
|
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||||
|
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||||
|
</symbol>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 4.9 KiB |
+184
@@ -0,0 +1,184 @@
|
|||||||
|
.counter {
|
||||||
|
font-size: 16px;
|
||||||
|
padding: 5px 10px;
|
||||||
|
border-radius: 5px;
|
||||||
|
color: var(--accent);
|
||||||
|
background: var(--accent-bg);
|
||||||
|
border: 2px solid transparent;
|
||||||
|
transition: border-color 0.3s;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: var(--accent-border);
|
||||||
|
}
|
||||||
|
&:focus-visible {
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero {
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
.base,
|
||||||
|
.framework,
|
||||||
|
.vite {
|
||||||
|
inset-inline: 0;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.base {
|
||||||
|
width: 170px;
|
||||||
|
position: relative;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.framework,
|
||||||
|
.vite {
|
||||||
|
position: absolute;
|
||||||
|
}
|
||||||
|
|
||||||
|
.framework {
|
||||||
|
z-index: 1;
|
||||||
|
top: 34px;
|
||||||
|
height: 28px;
|
||||||
|
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
|
||||||
|
scale(1.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vite {
|
||||||
|
z-index: 0;
|
||||||
|
top: 107px;
|
||||||
|
height: 26px;
|
||||||
|
width: auto;
|
||||||
|
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
|
||||||
|
scale(0.8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#center {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 25px;
|
||||||
|
place-content: center;
|
||||||
|
place-items: center;
|
||||||
|
flex-grow: 1;
|
||||||
|
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
padding: 32px 20px 24px;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#next-steps {
|
||||||
|
display: flex;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
text-align: left;
|
||||||
|
|
||||||
|
& > div {
|
||||||
|
flex: 1 1 0;
|
||||||
|
padding: 32px;
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
padding: 24px 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
flex-direction: column;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#docs {
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
border-right: none;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#next-steps ul {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin: 32px 0 0;
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: var(--text-h);
|
||||||
|
font-size: 16px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--social-bg);
|
||||||
|
display: flex;
|
||||||
|
padding: 6px 12px;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: box-shadow 0.3s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
.button-icon {
|
||||||
|
height: 18px;
|
||||||
|
width: 18px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
margin-top: 20px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
li {
|
||||||
|
flex: 1 1 calc(50% - 8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#spacer {
|
||||||
|
height: 88px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
height: 48px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticks {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
&::before,
|
||||||
|
&::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: -4.5px;
|
||||||
|
border: 5px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
left: 0;
|
||||||
|
border-left-color: var(--border);
|
||||||
|
}
|
||||||
|
&::after {
|
||||||
|
right: 0;
|
||||||
|
border-right-color: var(--border);
|
||||||
|
}
|
||||||
|
}
|
||||||
+55
@@ -0,0 +1,55 @@
|
|||||||
|
import { lazy, Suspense } from 'react'
|
||||||
|
import { Routes, Route, Navigate } from 'react-router'
|
||||||
|
import { LoadingPage, AppErrorBoundary } from './components/ui'
|
||||||
|
import { AppShell } from './components/layout'
|
||||||
|
|
||||||
|
const SupplyDashboard = lazy(() => import('./pages/supply/SupplyDashboard'))
|
||||||
|
const Properties = lazy(() => import('./pages/supply/Properties'))
|
||||||
|
const MatchCenter = lazy(() => import('./pages/supply/MatchCenter'))
|
||||||
|
const FutureAvailability = lazy(() => import('./pages/supply/FutureAvailability'))
|
||||||
|
const DataQuality = lazy(() => import('./pages/supply/DataQuality'))
|
||||||
|
|
||||||
|
const AISearch = lazy(() => import('./pages/demand/AISearch'))
|
||||||
|
const Results = lazy(() => import('./pages/demand/Results'))
|
||||||
|
const Compare = lazy(() => import('./pages/demand/Compare'))
|
||||||
|
const Shortlists = lazy(() => import('./pages/demand/Shortlists'))
|
||||||
|
|
||||||
|
const ReviewQueue = lazy(() => import('./pages/ops/ReviewQueue'))
|
||||||
|
const AIMonitoring = lazy(() => import('./pages/ops/AIMonitoring'))
|
||||||
|
const Governance = lazy(() => import('./pages/ops/Governance'))
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
return (
|
||||||
|
<AppErrorBoundary>
|
||||||
|
<Suspense fallback={<LoadingPage />}>
|
||||||
|
<Routes>
|
||||||
|
<Route element={<AppShell />}>
|
||||||
|
<Route path="/" element={<Navigate to="/supply/dashboard" replace />} />
|
||||||
|
|
||||||
|
{/* Supply Workspace */}
|
||||||
|
<Route path="/supply/dashboard" element={<SupplyDashboard />} />
|
||||||
|
<Route path="/supply/properties" element={<Properties />} />
|
||||||
|
<Route path="/supply/match-center" element={<MatchCenter />} />
|
||||||
|
<Route path="/supply/future-availability" element={<FutureAvailability />} />
|
||||||
|
<Route path="/supply/data-quality" element={<DataQuality />} />
|
||||||
|
|
||||||
|
{/* Demand Workspace */}
|
||||||
|
<Route path="/demand/ai-search" element={<AISearch />} />
|
||||||
|
<Route path="/demand/results" element={<Results />} />
|
||||||
|
<Route path="/demand/compare" element={<Compare />} />
|
||||||
|
<Route path="/demand/shortlists" element={<Shortlists />} />
|
||||||
|
|
||||||
|
{/* Operations Workspace */}
|
||||||
|
<Route path="/ops/review-queue" element={<ReviewQueue />} />
|
||||||
|
<Route path="/ops/ai-monitoring" element={<AIMonitoring />} />
|
||||||
|
<Route path="/ops/governance" element={<Governance />} />
|
||||||
|
</Route>
|
||||||
|
|
||||||
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
|
</Routes>
|
||||||
|
</Suspense>
|
||||||
|
</AppErrorBoundary>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 4.0 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 8.5 KiB |
@@ -0,0 +1,527 @@
|
|||||||
|
import { useEffect } from 'react'
|
||||||
|
import { Outlet, NavLink, useNavigate, useLocation } from 'react-router'
|
||||||
|
import { useLayoutStore } from '../../stores/layoutStore'
|
||||||
|
import { useSessionStore } from '../../stores/sessionStore'
|
||||||
|
import { WorkspaceType } from '../../domain/enums'
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Typography,
|
||||||
|
Avatar,
|
||||||
|
IconButton,
|
||||||
|
Tooltip,
|
||||||
|
Chip,
|
||||||
|
Button,
|
||||||
|
} from '@mui/material'
|
||||||
|
import {
|
||||||
|
LayoutDashboard,
|
||||||
|
Building2,
|
||||||
|
Target,
|
||||||
|
TrendingUp,
|
||||||
|
CheckSquare,
|
||||||
|
Search,
|
||||||
|
List,
|
||||||
|
Columns2,
|
||||||
|
Bookmark,
|
||||||
|
ClipboardList,
|
||||||
|
Activity,
|
||||||
|
Shield,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
Sparkles,
|
||||||
|
Bell,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import type { LucideIcon } from 'lucide-react'
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface NavItem {
|
||||||
|
path: string
|
||||||
|
label: string
|
||||||
|
icon: LucideIcon
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WorkspaceConfig {
|
||||||
|
label: string
|
||||||
|
abbreviation: string
|
||||||
|
icon: LucideIcon
|
||||||
|
firstPath: string
|
||||||
|
navItems: NavItem[]
|
||||||
|
chipColor: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Workspace configuration
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const WORKSPACE_CONFIG: Record<WorkspaceType, WorkspaceConfig> = {
|
||||||
|
[WorkspaceType.SUPPLY]: {
|
||||||
|
label: 'Supply',
|
||||||
|
abbreviation: 'SUP',
|
||||||
|
icon: Building2,
|
||||||
|
firstPath: '/supply/dashboard',
|
||||||
|
chipColor: '#1e3a5f',
|
||||||
|
navItems: [
|
||||||
|
{ path: '/supply/dashboard', label: 'Dashboard', icon: LayoutDashboard },
|
||||||
|
{ path: '/supply/properties', label: 'Objekte', icon: Building2 },
|
||||||
|
{ path: '/supply/match-center', label: 'Match Center', icon: Target },
|
||||||
|
{ path: '/supply/future-availability', label: 'Marktchancen', icon: TrendingUp },
|
||||||
|
{ path: '/supply/data-quality', label: 'Datenqualität', icon: CheckSquare },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
[WorkspaceType.DEMAND]: {
|
||||||
|
label: 'Demand',
|
||||||
|
abbreviation: 'DEM',
|
||||||
|
icon: Search,
|
||||||
|
firstPath: '/demand/ai-search',
|
||||||
|
chipColor: '#1a7a4a',
|
||||||
|
navItems: [
|
||||||
|
{ path: '/demand/ai-search', label: 'AI Suche', icon: Search },
|
||||||
|
{ path: '/demand/results', label: 'Ergebnisse', icon: List },
|
||||||
|
{ path: '/demand/compare', label: 'Vergleich', icon: Columns2 },
|
||||||
|
{ path: '/demand/shortlists', label: 'Shortlists', icon: Bookmark },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
[WorkspaceType.OPERATIONS]: {
|
||||||
|
label: 'Operations',
|
||||||
|
abbreviation: 'OPS',
|
||||||
|
icon: Shield,
|
||||||
|
firstPath: '/ops/review-queue',
|
||||||
|
chipColor: '#7c3aed',
|
||||||
|
navItems: [
|
||||||
|
{ path: '/ops/review-queue', label: 'Review Queue', icon: ClipboardList },
|
||||||
|
{ path: '/ops/ai-monitoring', label: 'AI Monitoring', icon: Activity },
|
||||||
|
{ path: '/ops/governance', label: 'Governance', icon: Shield },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ordered list for rendering workspace tabs
|
||||||
|
const WORKSPACE_ORDER: WorkspaceType[] = [
|
||||||
|
WorkspaceType.SUPPLY,
|
||||||
|
WorkspaceType.DEMAND,
|
||||||
|
WorkspaceType.OPERATIONS,
|
||||||
|
]
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function getWorkspaceFromPath(pathname: string): WorkspaceType | null {
|
||||||
|
if (pathname.startsWith('/supply')) return WorkspaceType.SUPPLY
|
||||||
|
if (pathname.startsWith('/demand')) return WorkspaceType.DEMAND
|
||||||
|
if (pathname.startsWith('/ops')) return WorkspaceType.OPERATIONS
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPageNameFromPath(pathname: string): string {
|
||||||
|
for (const ws of Object.values(WORKSPACE_CONFIG)) {
|
||||||
|
for (const item of ws.navItems) {
|
||||||
|
if (item.path === pathname) return item.label
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Fallback: last segment, capitalised
|
||||||
|
const segment = pathname.split('/').filter(Boolean).pop() ?? ''
|
||||||
|
return segment.charAt(0).toUpperCase() + segment.slice(1).replace(/-/g, ' ')
|
||||||
|
}
|
||||||
|
|
||||||
|
function getUserInitials(name: string): string {
|
||||||
|
return name
|
||||||
|
.split(' ')
|
||||||
|
.map((n) => n[0])
|
||||||
|
.join('')
|
||||||
|
.toUpperCase()
|
||||||
|
.slice(0, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Sub-components
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const SIDEBAR_BG = '#0f1923'
|
||||||
|
const DIVIDER_COLOR = 'rgba(255,255,255,0.08)'
|
||||||
|
const TEXT_MUTED = '#94a3b8'
|
||||||
|
const TEXT_WHITE = '#ffffff'
|
||||||
|
const ACTIVE_BG = 'rgba(255,255,255,0.1)'
|
||||||
|
const NAV_ACTIVE_BG = 'rgba(255,255,255,0.12)'
|
||||||
|
const NAV_HOVER_BG = 'rgba(255,255,255,0.06)'
|
||||||
|
|
||||||
|
interface SidebarProps {
|
||||||
|
collapsed: boolean
|
||||||
|
activeWorkspace: WorkspaceType
|
||||||
|
onWorkspaceClick: (workspace: WorkspaceType) => void
|
||||||
|
onToggle: () => void
|
||||||
|
userName: string
|
||||||
|
orgName: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function Sidebar({
|
||||||
|
collapsed,
|
||||||
|
activeWorkspace,
|
||||||
|
onWorkspaceClick,
|
||||||
|
onToggle,
|
||||||
|
userName,
|
||||||
|
orgName,
|
||||||
|
}: SidebarProps) {
|
||||||
|
const config = WORKSPACE_CONFIG[activeWorkspace]
|
||||||
|
const width = collapsed ? 60 : 240
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
component="nav"
|
||||||
|
sx={{
|
||||||
|
width,
|
||||||
|
minWidth: width,
|
||||||
|
flexShrink: 0,
|
||||||
|
height: '100vh',
|
||||||
|
backgroundColor: SIDEBAR_BG,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
transition: 'width 0.2s ease',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Logo area */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
height: 56,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: collapsed ? 'center' : 'flex-start',
|
||||||
|
px: collapsed ? 0 : 2.5,
|
||||||
|
borderBottom: `1px solid ${DIVIDER_COLOR}`,
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{collapsed ? (
|
||||||
|
<Typography
|
||||||
|
variant="subtitle1"
|
||||||
|
sx={{ color: TEXT_WHITE, fontWeight: 700, letterSpacing: 0.5 }}
|
||||||
|
>
|
||||||
|
PM
|
||||||
|
</Typography>
|
||||||
|
) : (
|
||||||
|
<Box>
|
||||||
|
<Typography
|
||||||
|
variant="subtitle1"
|
||||||
|
sx={{ color: TEXT_WHITE, fontWeight: 700, lineHeight: 1.2 }}
|
||||||
|
>
|
||||||
|
Property
|
||||||
|
</Typography>
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
sx={{
|
||||||
|
color: '#64b5f6',
|
||||||
|
fontWeight: 600,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: 1,
|
||||||
|
fontSize: '0.6rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Match
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Workspace tabs */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
borderBottom: `1px solid ${DIVIDER_COLOR}`,
|
||||||
|
py: 0.5,
|
||||||
|
px: 0.5,
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{WORKSPACE_ORDER.map((ws) => {
|
||||||
|
const wsConfig = WORKSPACE_CONFIG[ws]
|
||||||
|
const Icon = wsConfig.icon
|
||||||
|
const isActive = ws === activeWorkspace
|
||||||
|
|
||||||
|
const tabContent = (
|
||||||
|
<Box
|
||||||
|
onClick={() => onWorkspaceClick(ws)}
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 1.25,
|
||||||
|
px: collapsed ? 0 : 1.5,
|
||||||
|
py: 0.75,
|
||||||
|
borderRadius: 1,
|
||||||
|
cursor: 'pointer',
|
||||||
|
justifyContent: collapsed ? 'center' : 'flex-start',
|
||||||
|
backgroundColor: isActive ? ACTIVE_BG : 'transparent',
|
||||||
|
'&:hover': {
|
||||||
|
backgroundColor: isActive ? ACTIVE_BG : NAV_HOVER_BG,
|
||||||
|
},
|
||||||
|
transition: 'background-color 0.15s ease',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon size={16} color={isActive ? TEXT_WHITE : TEXT_MUTED} />
|
||||||
|
{!collapsed && (
|
||||||
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
sx={{
|
||||||
|
color: isActive ? TEXT_WHITE : TEXT_MUTED,
|
||||||
|
fontWeight: isActive ? 600 : 400,
|
||||||
|
fontSize: '0.8125rem',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{wsConfig.label}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
|
||||||
|
return collapsed ? (
|
||||||
|
<Tooltip key={ws} title={wsConfig.label} placement="right">
|
||||||
|
{tabContent}
|
||||||
|
</Tooltip>
|
||||||
|
) : (
|
||||||
|
<Box key={ws}>{tabContent}</Box>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Nav items */}
|
||||||
|
<Box sx={{ flex: 1, overflowY: 'auto', py: 0.5 }}>
|
||||||
|
{config.navItems.map((item) => {
|
||||||
|
const Icon = item.icon
|
||||||
|
|
||||||
|
const navContent = (
|
||||||
|
<NavLink
|
||||||
|
to={item.path}
|
||||||
|
style={{ textDecoration: 'none', display: 'block' }}
|
||||||
|
>
|
||||||
|
{({ isActive }) => (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 1.25,
|
||||||
|
px: collapsed ? 0 : 1.5,
|
||||||
|
py: 0.75,
|
||||||
|
mx: 0.5,
|
||||||
|
borderRadius: 1,
|
||||||
|
justifyContent: collapsed ? 'center' : 'flex-start',
|
||||||
|
backgroundColor: isActive ? NAV_ACTIVE_BG : 'transparent',
|
||||||
|
'&:hover': {
|
||||||
|
backgroundColor: isActive ? NAV_ACTIVE_BG : NAV_HOVER_BG,
|
||||||
|
},
|
||||||
|
transition: 'background-color 0.15s ease',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon size={16} color={isActive ? TEXT_WHITE : TEXT_MUTED} />
|
||||||
|
{!collapsed && (
|
||||||
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
sx={{
|
||||||
|
color: isActive ? TEXT_WHITE : TEXT_MUTED,
|
||||||
|
fontWeight: isActive ? 500 : 400,
|
||||||
|
fontSize: '0.8125rem',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</NavLink>
|
||||||
|
)
|
||||||
|
|
||||||
|
return collapsed ? (
|
||||||
|
<Tooltip key={item.path} title={item.label} placement="right">
|
||||||
|
<Box>{navContent}</Box>
|
||||||
|
</Tooltip>
|
||||||
|
) : (
|
||||||
|
<Box key={item.path}>{navContent}</Box>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Bottom section */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
borderTop: `1px solid ${DIVIDER_COLOR}`,
|
||||||
|
px: collapsed ? 0 : 1.5,
|
||||||
|
py: 1,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 1,
|
||||||
|
justifyContent: collapsed ? 'center' : 'space-between',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{!collapsed && (
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, minWidth: 0 }}>
|
||||||
|
<Avatar
|
||||||
|
sx={{
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
fontSize: '0.75rem',
|
||||||
|
bgcolor: '#1e3a5f',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{getUserInitials(userName)}
|
||||||
|
</Avatar>
|
||||||
|
<Box sx={{ minWidth: 0 }}>
|
||||||
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
sx={{
|
||||||
|
color: TEXT_WHITE,
|
||||||
|
fontWeight: 500,
|
||||||
|
fontSize: '0.8125rem',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{userName}
|
||||||
|
</Typography>
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
sx={{
|
||||||
|
color: '#64748b',
|
||||||
|
fontSize: '0.7rem',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
display: 'block',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{orgName}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<IconButton
|
||||||
|
onClick={onToggle}
|
||||||
|
size="small"
|
||||||
|
sx={{ color: '#64748b', flexShrink: 0 }}
|
||||||
|
>
|
||||||
|
{collapsed ? <ChevronRight size={16} /> : <ChevronLeft size={16} />}
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TopBarProps {
|
||||||
|
activeWorkspace: WorkspaceType
|
||||||
|
pathname: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function TopBar({ activeWorkspace, pathname }: TopBarProps) {
|
||||||
|
const config = WORKSPACE_CONFIG[activeWorkspace]
|
||||||
|
const pageName = getPageNameFromPath(pathname)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
component="header"
|
||||||
|
sx={{
|
||||||
|
height: 56,
|
||||||
|
flexShrink: 0,
|
||||||
|
backgroundColor: '#ffffff',
|
||||||
|
borderBottom: '1px solid #e2e8f0',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
px: 3,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Left side */}
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
||||||
|
<Chip
|
||||||
|
label={config.label}
|
||||||
|
size="small"
|
||||||
|
sx={{
|
||||||
|
backgroundColor: config.chipColor,
|
||||||
|
color: '#ffffff',
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: '0.7rem',
|
||||||
|
height: 22,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Typography
|
||||||
|
variant="body1"
|
||||||
|
sx={{ fontWeight: 500, color: '#1e293b', fontSize: '0.9375rem' }}
|
||||||
|
>
|
||||||
|
{pageName}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Right side */}
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
size="small"
|
||||||
|
startIcon={<Sparkles size={14} />}
|
||||||
|
sx={{ textTransform: 'none', fontSize: '0.8125rem' }}
|
||||||
|
>
|
||||||
|
AI Assistent
|
||||||
|
</Button>
|
||||||
|
<IconButton size="small" sx={{ color: '#64748b' }}>
|
||||||
|
<Bell size={20} />
|
||||||
|
</IconButton>
|
||||||
|
<Avatar sx={{ width: 32, height: 32, fontSize: '0.75rem', bgcolor: '#1e3a5f' }}>
|
||||||
|
AU
|
||||||
|
</Avatar>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// AppShell
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function AppShell() {
|
||||||
|
const { activeWorkspace, sidebarCollapsed, setActiveWorkspace, toggleSidebar } =
|
||||||
|
useLayoutStore()
|
||||||
|
const { currentUser } = useSessionStore()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const location = useLocation()
|
||||||
|
|
||||||
|
// Sync active workspace with URL
|
||||||
|
useEffect(() => {
|
||||||
|
const detected = getWorkspaceFromPath(location.pathname)
|
||||||
|
if (detected && detected !== activeWorkspace) {
|
||||||
|
setActiveWorkspace(detected)
|
||||||
|
}
|
||||||
|
}, [location.pathname, activeWorkspace, setActiveWorkspace])
|
||||||
|
|
||||||
|
const handleWorkspaceClick = (workspace: WorkspaceType) => {
|
||||||
|
setActiveWorkspace(workspace)
|
||||||
|
navigate(WORKSPACE_CONFIG[workspace].firstPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
const userName = currentUser?.name ?? 'User'
|
||||||
|
const orgName = currentUser?.organizationName ?? ''
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: 'flex', height: '100vh', overflow: 'hidden' }}>
|
||||||
|
<Sidebar
|
||||||
|
collapsed={sidebarCollapsed}
|
||||||
|
activeWorkspace={activeWorkspace}
|
||||||
|
onWorkspaceClick={handleWorkspaceClick}
|
||||||
|
onToggle={toggleSidebar}
|
||||||
|
userName={userName}
|
||||||
|
orgName={orgName}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Box sx={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
||||||
|
<TopBar activeWorkspace={activeWorkspace} pathname={location.pathname} />
|
||||||
|
|
||||||
|
<Box component="main" sx={{ flex: 1, overflowY: 'auto' }}>
|
||||||
|
<Outlet />
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { AppShell } from './AppShell'
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { Component, type ReactNode } from 'react'
|
||||||
|
import { Box, Button, Typography } from '@mui/material'
|
||||||
|
import { AlertTriangle } from 'lucide-react'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
children: ReactNode
|
||||||
|
fallback?: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
hasError: boolean
|
||||||
|
error?: Error
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AppErrorBoundary extends Component<Props, State> {
|
||||||
|
constructor(props: Props) {
|
||||||
|
super(props)
|
||||||
|
this.state = { hasError: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
static getDerivedStateFromError(error: Error): State {
|
||||||
|
return { hasError: true, error }
|
||||||
|
}
|
||||||
|
|
||||||
|
handleReset = () => {
|
||||||
|
this.setState({ hasError: false, error: undefined })
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (this.state.hasError) {
|
||||||
|
if (this.props.fallback) return this.props.fallback
|
||||||
|
return (
|
||||||
|
<Box className="flex flex-col items-center justify-center min-h-64 gap-4 p-8 text-center">
|
||||||
|
<AlertTriangle size={40} className="text-red-500" />
|
||||||
|
<Typography variant="h6" color="error">Unerwarteter Fehler</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary" className="max-w-md">
|
||||||
|
{this.state.error?.message ?? 'Ein unbekannter Fehler ist aufgetreten.'}
|
||||||
|
</Typography>
|
||||||
|
<Button variant="outlined" onClick={this.handleReset}>Neu laden</Button>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return this.props.children
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { Box, Button, Typography } from '@mui/material'
|
||||||
|
import type { ReactNode } from 'react'
|
||||||
|
|
||||||
|
interface EmptyStateProps {
|
||||||
|
icon?: ReactNode
|
||||||
|
title: string
|
||||||
|
description?: string
|
||||||
|
action?: {
|
||||||
|
label: string
|
||||||
|
onClick: () => void
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EmptyState({ icon, title, description, action }: EmptyStateProps) {
|
||||||
|
return (
|
||||||
|
<Box className="flex flex-col items-center justify-center gap-3 py-16 px-8 text-center">
|
||||||
|
{icon && <Box className="text-slate-400 mb-2">{icon}</Box>}
|
||||||
|
<Typography variant="h6" color="text.primary" fontWeight={500}>{title}</Typography>
|
||||||
|
{description && (
|
||||||
|
<Typography variant="body2" color="text.secondary" className="max-w-sm">{description}</Typography>
|
||||||
|
)}
|
||||||
|
{action && (
|
||||||
|
<Button variant="outlined" size="small" onClick={action.onClick} className="mt-2">
|
||||||
|
{action.label}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { Box, Button, Typography } from '@mui/material'
|
||||||
|
import { AlertCircle } from 'lucide-react'
|
||||||
|
|
||||||
|
interface ErrorStateProps {
|
||||||
|
message?: string
|
||||||
|
onRetry?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ErrorState({ message = 'Daten konnten nicht geladen werden.', onRetry }: ErrorStateProps) {
|
||||||
|
return (
|
||||||
|
<Box className="flex flex-col items-center justify-center gap-3 py-16 px-8 text-center">
|
||||||
|
<AlertCircle size={36} className="text-red-400" />
|
||||||
|
<Typography variant="body1" color="text.secondary">{message}</Typography>
|
||||||
|
{onRetry && (
|
||||||
|
<Button variant="outlined" size="small" onClick={onRetry}>Erneut versuchen</Button>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { Box, Skeleton } from '@mui/material'
|
||||||
|
|
||||||
|
interface LoadingPageProps {
|
||||||
|
rows?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LoadingPage({ rows = 5 }: LoadingPageProps) {
|
||||||
|
return (
|
||||||
|
<Box className="flex flex-col gap-4 p-6 w-full">
|
||||||
|
<Skeleton variant="rectangular" height={48} className="rounded" />
|
||||||
|
<Box className="flex gap-4">
|
||||||
|
{[1, 2, 3, 4].map(i => (
|
||||||
|
<Skeleton key={i} variant="rectangular" height={80} className="flex-1 rounded" />
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
{Array.from({ length: rows }).map((_, i) => (
|
||||||
|
<Skeleton key={i} variant="rectangular" height={64} className="rounded" />
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { Box } from '@mui/material'
|
||||||
|
import type { ReactNode } from 'react'
|
||||||
|
|
||||||
|
interface PageContainerProps {
|
||||||
|
children: ReactNode
|
||||||
|
maxWidth?: string | number
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PageContainer({ children, maxWidth = 1440, className = '' }: PageContainerProps) {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
className={`w-full mx-auto px-6 py-6 ${className}`}
|
||||||
|
sx={{ maxWidth }}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { Box, Divider, Typography } from '@mui/material'
|
||||||
|
import type { ReactNode } from 'react'
|
||||||
|
|
||||||
|
interface SectionContainerProps {
|
||||||
|
title?: string
|
||||||
|
subtitle?: string
|
||||||
|
action?: ReactNode
|
||||||
|
children: ReactNode
|
||||||
|
className?: string
|
||||||
|
divider?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SectionContainer({ title, subtitle, action, children, className = '', divider = false }: SectionContainerProps) {
|
||||||
|
return (
|
||||||
|
<Box className={`flex flex-col gap-3 ${className}`}>
|
||||||
|
{(title || action) && (
|
||||||
|
<Box className="flex items-center justify-between gap-2">
|
||||||
|
<Box>
|
||||||
|
{title && <Typography variant="subtitle1" fontWeight={600} color="text.primary">{title}</Typography>}
|
||||||
|
{subtitle && <Typography variant="caption" color="text.secondary">{subtitle}</Typography>}
|
||||||
|
</Box>
|
||||||
|
{action}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
{divider && <Divider />}
|
||||||
|
{children}
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export { AppErrorBoundary } from './AppErrorBoundary'
|
||||||
|
export { LoadingPage } from './LoadingPage'
|
||||||
|
export { EmptyState } from './EmptyState'
|
||||||
|
export { ErrorState } from './ErrorState'
|
||||||
|
export { PageContainer } from './PageContainer'
|
||||||
|
export { SectionContainer } from './SectionContainer'
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
export enum AssetType {
|
||||||
|
OFFICE = 'OFFICE',
|
||||||
|
RETAIL = 'RETAIL',
|
||||||
|
GASTRO = 'GASTRO',
|
||||||
|
LOGISTICS = 'LOGISTICS',
|
||||||
|
PRODUCTION = 'PRODUCTION',
|
||||||
|
MIXED = 'MIXED',
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum ResultType {
|
||||||
|
VERIFIED_PORTFOLIO = 'VERIFIED_PORTFOLIO',
|
||||||
|
EXTERNAL_MARKET = 'EXTERNAL_MARKET',
|
||||||
|
FUTURE_AVAILABILITY = 'FUTURE_AVAILABILITY',
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum MatchStrength {
|
||||||
|
STRONG = 'STRONG',
|
||||||
|
MODERATE = 'MODERATE',
|
||||||
|
WEAK = 'WEAK',
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum RiskLevel {
|
||||||
|
LOW = 'LOW',
|
||||||
|
MEDIUM = 'MEDIUM',
|
||||||
|
HIGH = 'HIGH',
|
||||||
|
CRITICAL = 'CRITICAL',
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum AvailabilityStatus {
|
||||||
|
AVAILABLE_NOW = 'AVAILABLE_NOW',
|
||||||
|
AVAILABLE_SOON = 'AVAILABLE_SOON',
|
||||||
|
FUTURE_SIGNAL = 'FUTURE_SIGNAL',
|
||||||
|
OCCUPIED = 'OCCUPIED',
|
||||||
|
UNKNOWN = 'UNKNOWN',
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum DataFreshness {
|
||||||
|
FRESH = 'FRESH',
|
||||||
|
STALE = 'STALE',
|
||||||
|
OUTDATED = 'OUTDATED',
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum UserRole {
|
||||||
|
SUPER_ADMIN = 'SUPER_ADMIN',
|
||||||
|
ORGANIZATION_ADMIN = 'ORGANIZATION_ADMIN',
|
||||||
|
PROPERTY_MANAGER = 'PROPERTY_MANAGER',
|
||||||
|
REVIEWER = 'REVIEWER',
|
||||||
|
OWNER_VIEWER = 'OWNER_VIEWER',
|
||||||
|
DEMAND_USER = 'DEMAND_USER',
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum SignalType {
|
||||||
|
EXPANSION = 'EXPANSION',
|
||||||
|
POSSIBLE_MOVE_OUT = 'POSSIBLE_MOVE_OUT',
|
||||||
|
CONSTRUCTION_PROJECT = 'CONSTRUCTION_PROJECT',
|
||||||
|
RESTRUCTURING = 'RESTRUCTURING',
|
||||||
|
PROJECT_DEVELOPMENT = 'PROJECT_DEVELOPMENT',
|
||||||
|
SPACE_CONSOLIDATION = 'SPACE_CONSOLIDATION',
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum WorkspaceType {
|
||||||
|
SUPPLY = 'SUPPLY',
|
||||||
|
DEMAND = 'DEMAND',
|
||||||
|
OPERATIONS = 'OPERATIONS',
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import type { SignalType, RiskLevel } from './enums'
|
||||||
|
|
||||||
|
export interface SignalSource {
|
||||||
|
type: 'PRESS' | 'CONSTRUCTION_PERMIT' | 'JOB_POSTING' | 'COMPANY_REPORT' | 'MARKET_DATA' | 'MANUAL'
|
||||||
|
url?: string
|
||||||
|
publishedAt?: string
|
||||||
|
credibility: 'LOW' | 'MEDIUM' | 'HIGH'
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FutureSignal {
|
||||||
|
id: string
|
||||||
|
signalType: SignalType
|
||||||
|
companyName?: string
|
||||||
|
propertyId?: string
|
||||||
|
locationHint: string
|
||||||
|
areaSqmEstimate?: number
|
||||||
|
probability: number
|
||||||
|
confidenceScore: number
|
||||||
|
timeHorizonMonths: number
|
||||||
|
source: SignalSource
|
||||||
|
sensitivityLevel: 'PUBLIC' | 'INTERNAL' | 'CONFIDENTIAL'
|
||||||
|
disclaimer: string
|
||||||
|
riskLevel: RiskLevel
|
||||||
|
marketIndicator?: string
|
||||||
|
relevanceScore?: number
|
||||||
|
isVerified: boolean
|
||||||
|
verifiedBy?: string
|
||||||
|
verifiedAt?: string
|
||||||
|
expiresAt?: string
|
||||||
|
organizationId?: string
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export * from './enums'
|
||||||
|
export * from './property'
|
||||||
|
export * from './need'
|
||||||
|
export * from './match'
|
||||||
|
export * from './futureSignal'
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import type { MatchStrength, RiskLevel } from './enums'
|
||||||
|
|
||||||
|
export interface ScoreBreakdown {
|
||||||
|
hardMatchScore: number
|
||||||
|
softFactorScore: number
|
||||||
|
confidenceModifier: number
|
||||||
|
dataQualityModifier: number
|
||||||
|
totalScore: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScoreFactor {
|
||||||
|
criterion: string
|
||||||
|
weight: number
|
||||||
|
score: number
|
||||||
|
contribution: number
|
||||||
|
explanation: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Tradeoff {
|
||||||
|
criterion: string
|
||||||
|
concern: string
|
||||||
|
severity: 'LOW' | 'MEDIUM' | 'HIGH'
|
||||||
|
mitigation?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AlternativeStrategy {
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
expectedScore?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Match {
|
||||||
|
id: string
|
||||||
|
propertyId: string
|
||||||
|
needId: string
|
||||||
|
matchScore: number
|
||||||
|
matchStrength: MatchStrength
|
||||||
|
scoreBreakdown: ScoreBreakdown
|
||||||
|
positiveFactors: ScoreFactor[]
|
||||||
|
negativeFactors: ScoreFactor[]
|
||||||
|
tradeoffs: Tradeoff[]
|
||||||
|
explainabilitySummary: string
|
||||||
|
confidenceLevel: number
|
||||||
|
riskLevel: RiskLevel
|
||||||
|
uncertaintyIndicators: string[]
|
||||||
|
alternativeStrategies?: AlternativeStrategy[]
|
||||||
|
reviewedBy?: string
|
||||||
|
reviewedAt?: string
|
||||||
|
isApproved?: boolean
|
||||||
|
organizationId?: string
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import type { AssetType } from './enums'
|
||||||
|
|
||||||
|
export interface AreaRange {
|
||||||
|
min: number
|
||||||
|
max: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BudgetRange {
|
||||||
|
minPerSqm?: number
|
||||||
|
maxPerSqm: number
|
||||||
|
maxMonthlyTotal?: number
|
||||||
|
currency: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Timing {
|
||||||
|
earliestMoveIn: string
|
||||||
|
latestMoveIn: string
|
||||||
|
contractDurationMonths?: number
|
||||||
|
flexibleTiming: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WeightingProfile {
|
||||||
|
area: number
|
||||||
|
location: number
|
||||||
|
budget: number
|
||||||
|
timing: number
|
||||||
|
prestige: number
|
||||||
|
accessibility: number
|
||||||
|
expansionPotential: number
|
||||||
|
flexibility: number
|
||||||
|
[key: string]: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SoftFactorPreferences {
|
||||||
|
minPrestige?: number
|
||||||
|
minAccessibility?: number
|
||||||
|
requireParking?: boolean
|
||||||
|
maxPublicTransportMinutes?: number
|
||||||
|
preferredEsgRating?: string
|
||||||
|
requireHighVisibility?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Need {
|
||||||
|
id: string
|
||||||
|
companyName: string
|
||||||
|
contactName?: string
|
||||||
|
assetType: AssetType
|
||||||
|
requiredArea: AreaRange
|
||||||
|
preferredLocations: string[]
|
||||||
|
excludedLocations?: string[]
|
||||||
|
budgetRange: BudgetRange
|
||||||
|
timing: Timing
|
||||||
|
mustCriteriaText?: string[]
|
||||||
|
softFactors?: SoftFactorPreferences
|
||||||
|
weightingProfile: WeightingProfile
|
||||||
|
confidenceInCriteria: number
|
||||||
|
extractedFromText?: string
|
||||||
|
notes?: string
|
||||||
|
organizationId?: string
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CreateNeedInput = Omit<Need, 'id' | 'createdAt' | 'updatedAt'>
|
||||||
|
export type UpdateNeedInput = Partial<CreateNeedInput>
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import type { AssetType, ResultType, AvailabilityStatus, DataFreshness, RiskLevel } from './enums'
|
||||||
|
|
||||||
|
export interface Location {
|
||||||
|
city: string
|
||||||
|
district?: string
|
||||||
|
canton?: string
|
||||||
|
country: string
|
||||||
|
coordinates?: {
|
||||||
|
lat: number
|
||||||
|
lng: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Address {
|
||||||
|
street: string
|
||||||
|
houseNumber: string
|
||||||
|
postalCode: string
|
||||||
|
city: string
|
||||||
|
country: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SoftFactors {
|
||||||
|
prestige?: number
|
||||||
|
accessibility?: number
|
||||||
|
visibilityScore?: number
|
||||||
|
talentAccess?: number
|
||||||
|
esgRating?: string
|
||||||
|
passerbyFrequency?: 'LOW' | 'MEDIUM' | 'HIGH' | 'VERY_HIGH'
|
||||||
|
parkingSpots?: number
|
||||||
|
publicTransportMinutes?: number
|
||||||
|
infrastructureNotes?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DataQuality {
|
||||||
|
score: number
|
||||||
|
missingCriticalFields: string[]
|
||||||
|
missingOptionalFields: string[]
|
||||||
|
lastVerifiedAt?: string
|
||||||
|
freshness: DataFreshness
|
||||||
|
warnings: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Property {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
assetType: AssetType
|
||||||
|
resultType: ResultType
|
||||||
|
location: Location
|
||||||
|
address: Address
|
||||||
|
areaSqm: number
|
||||||
|
rentPricePerSqm: number
|
||||||
|
totalRentMonthly?: number
|
||||||
|
availabilityDate: string
|
||||||
|
availabilityStatus: AvailabilityStatus
|
||||||
|
sourceType: string
|
||||||
|
sourceUrl?: string
|
||||||
|
confidenceScore: number
|
||||||
|
dataQuality: DataQuality
|
||||||
|
softFactors?: SoftFactors
|
||||||
|
floorLevel?: number
|
||||||
|
expansionPotentialSqm?: number
|
||||||
|
contractDurationMonths?: number
|
||||||
|
ancillaryCosts?: number
|
||||||
|
riskLevel?: RiskLevel
|
||||||
|
description?: string
|
||||||
|
images?: string[]
|
||||||
|
organizationId?: string
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CreatePropertyInput = Omit<Property, 'id' | 'createdAt' | 'updatedAt'>
|
||||||
|
export type UpdatePropertyInput = Partial<CreatePropertyInput>
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
@layer theme, base, components, utilities;
|
||||||
|
|
||||||
|
@import "tailwindcss/theme.css" layer(theme);
|
||||||
|
@import "tailwindcss/utilities.css" layer(utilities);
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import { createTheme } from '@mui/material/styles'
|
||||||
|
|
||||||
|
export const theme = createTheme({
|
||||||
|
palette: {
|
||||||
|
mode: 'light',
|
||||||
|
primary: {
|
||||||
|
main: '#1e3a5f',
|
||||||
|
light: '#2d5290',
|
||||||
|
dark: '#142a45',
|
||||||
|
contrastText: '#ffffff',
|
||||||
|
},
|
||||||
|
secondary: {
|
||||||
|
main: '#2d6a8f',
|
||||||
|
light: '#4a8ab0',
|
||||||
|
dark: '#1e4d68',
|
||||||
|
contrastText: '#ffffff',
|
||||||
|
},
|
||||||
|
background: {
|
||||||
|
default: '#f4f6f9',
|
||||||
|
paper: '#ffffff',
|
||||||
|
},
|
||||||
|
text: {
|
||||||
|
primary: '#1a2332',
|
||||||
|
secondary: '#4a5568',
|
||||||
|
},
|
||||||
|
error: { main: '#c0392b' },
|
||||||
|
warning: { main: '#d97706' },
|
||||||
|
success: { main: '#1a7a4a' },
|
||||||
|
info: { main: '#2563eb' },
|
||||||
|
divider: '#e2e8f0',
|
||||||
|
},
|
||||||
|
typography: {
|
||||||
|
fontFamily: '"Inter", "Segoe UI", system-ui, -apple-system, sans-serif',
|
||||||
|
h1: { fontSize: '1.75rem', fontWeight: 700, letterSpacing: '-0.02em' },
|
||||||
|
h2: { fontSize: '1.5rem', fontWeight: 600, letterSpacing: '-0.01em' },
|
||||||
|
h3: { fontSize: '1.25rem', fontWeight: 600 },
|
||||||
|
h4: { fontSize: '1.125rem', fontWeight: 600 },
|
||||||
|
h5: { fontSize: '1rem', fontWeight: 600 },
|
||||||
|
h6: { fontSize: '0.9375rem', fontWeight: 600 },
|
||||||
|
subtitle1: { fontSize: '0.875rem', fontWeight: 600, lineHeight: 1.5 },
|
||||||
|
subtitle2: { fontSize: '0.8125rem', fontWeight: 600, lineHeight: 1.4 },
|
||||||
|
body1: { fontSize: '0.875rem', lineHeight: 1.6 },
|
||||||
|
body2: { fontSize: '0.8125rem', lineHeight: 1.5 },
|
||||||
|
caption: { fontSize: '0.75rem', lineHeight: 1.4, color: '#64748b' },
|
||||||
|
overline: { fontSize: '0.6875rem', fontWeight: 600, letterSpacing: '0.08em', textTransform: 'uppercase' },
|
||||||
|
button: { fontSize: '0.8125rem', fontWeight: 600, textTransform: 'none' },
|
||||||
|
},
|
||||||
|
shape: { borderRadius: 6 },
|
||||||
|
shadows: [
|
||||||
|
'none',
|
||||||
|
'0 1px 2px rgba(0,0,0,0.06), 0 1px 3px rgba(0,0,0,0.08)',
|
||||||
|
'0 1px 4px rgba(0,0,0,0.06), 0 2px 6px rgba(0,0,0,0.08)',
|
||||||
|
'0 2px 6px rgba(0,0,0,0.07), 0 4px 10px rgba(0,0,0,0.08)',
|
||||||
|
'0 3px 8px rgba(0,0,0,0.08), 0 6px 14px rgba(0,0,0,0.08)',
|
||||||
|
'0 4px 10px rgba(0,0,0,0.08), 0 8px 18px rgba(0,0,0,0.08)',
|
||||||
|
'0 5px 12px rgba(0,0,0,0.09)', '0 6px 14px rgba(0,0,0,0.09)',
|
||||||
|
'0 7px 16px rgba(0,0,0,0.09)', '0 8px 18px rgba(0,0,0,0.09)',
|
||||||
|
'0 9px 20px rgba(0,0,0,0.09)', '0 10px 22px rgba(0,0,0,0.10)',
|
||||||
|
'0 11px 24px rgba(0,0,0,0.10)', '0 12px 26px rgba(0,0,0,0.10)',
|
||||||
|
'0 13px 28px rgba(0,0,0,0.10)', '0 14px 30px rgba(0,0,0,0.10)',
|
||||||
|
'0 15px 32px rgba(0,0,0,0.10)', '0 16px 34px rgba(0,0,0,0.10)',
|
||||||
|
'0 17px 36px rgba(0,0,0,0.10)', '0 18px 38px rgba(0,0,0,0.10)',
|
||||||
|
'0 19px 40px rgba(0,0,0,0.10)', '0 20px 42px rgba(0,0,0,0.10)',
|
||||||
|
'0 21px 44px rgba(0,0,0,0.10)', '0 22px 46px rgba(0,0,0,0.10)',
|
||||||
|
'0 24px 50px rgba(0,0,0,0.12)',
|
||||||
|
],
|
||||||
|
components: {
|
||||||
|
MuiCard: {
|
||||||
|
defaultProps: { elevation: 1 },
|
||||||
|
styleOverrides: {
|
||||||
|
root: {
|
||||||
|
border: '1px solid #e2e8f0',
|
||||||
|
'&:hover': { boxShadow: '0 2px 8px rgba(0,0,0,0.10)' },
|
||||||
|
transition: 'box-shadow 0.15s ease',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
MuiButton: {
|
||||||
|
defaultProps: { disableElevation: true },
|
||||||
|
styleOverrides: {
|
||||||
|
root: { borderRadius: 6, padding: '6px 16px' },
|
||||||
|
containedPrimary: {
|
||||||
|
background: '#1e3a5f',
|
||||||
|
'&:hover': { background: '#142a45' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
MuiChip: {
|
||||||
|
styleOverrides: {
|
||||||
|
root: { borderRadius: 4, fontSize: '0.75rem', fontWeight: 600, height: 22 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
MuiTableCell: {
|
||||||
|
styleOverrides: {
|
||||||
|
head: { fontWeight: 600, fontSize: '0.75rem', color: '#64748b', textTransform: 'uppercase', letterSpacing: '0.05em', padding: '8px 12px' },
|
||||||
|
body: { fontSize: '0.8125rem', padding: '10px 12px' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
MuiTooltip: {
|
||||||
|
defaultProps: { arrow: true },
|
||||||
|
},
|
||||||
|
MuiPaper: {
|
||||||
|
styleOverrides: {
|
||||||
|
root: { backgroundImage: 'none' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { StrictMode } from 'react'
|
||||||
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import { BrowserRouter } from 'react-router'
|
||||||
|
import { StyledEngineProvider, ThemeProvider, CssBaseline } from '@mui/material'
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
|
import { theme } from './lib/theme'
|
||||||
|
import './index.css'
|
||||||
|
import App from './App.tsx'
|
||||||
|
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
|
retry: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<BrowserRouter>
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<StyledEngineProvider injectFirst>
|
||||||
|
<ThemeProvider theme={theme}>
|
||||||
|
<CssBaseline />
|
||||||
|
<App />
|
||||||
|
</ThemeProvider>
|
||||||
|
</StyledEngineProvider>
|
||||||
|
</QueryClientProvider>
|
||||||
|
</BrowserRouter>
|
||||||
|
</StrictMode>,
|
||||||
|
)
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { SignalType, RiskLevel } from '../domain/enums'
|
||||||
|
import type { FutureSignal } from '../domain/futureSignal'
|
||||||
|
|
||||||
|
export const mockFutureSignals: FutureSignal[] = [
|
||||||
|
{
|
||||||
|
id: 'signal-001',
|
||||||
|
signalType: SignalType.EXPANSION,
|
||||||
|
companyName: 'DataCloud Systems AG',
|
||||||
|
locationHint: 'Zürich-West / Technopark',
|
||||||
|
areaSqmEstimate: 600,
|
||||||
|
probability: 0.72,
|
||||||
|
confidenceScore: 0.68,
|
||||||
|
timeHorizonMonths: 10,
|
||||||
|
source: {
|
||||||
|
type: 'JOB_POSTING',
|
||||||
|
url: 'https://example.com/jobs/datacloud',
|
||||||
|
publishedAt: '2025-04-20',
|
||||||
|
credibility: 'MEDIUM',
|
||||||
|
},
|
||||||
|
sensitivityLevel: 'INTERNAL',
|
||||||
|
disclaimer: 'Dieses Signal basiert auf AI-Analyse öffentlicher Daten (Stelleninserate, Presseberichte). Es handelt sich um ein probabilistisches Signal, kein bestätigtes Objekt.',
|
||||||
|
riskLevel: RiskLevel.MEDIUM,
|
||||||
|
marketIndicator: 'Tech-Sektor Stellenwachstum +38% YoY',
|
||||||
|
relevanceScore: 0.76,
|
||||||
|
isVerified: false,
|
||||||
|
expiresAt: '2026-03-01',
|
||||||
|
organizationId: 'org-wincasa',
|
||||||
|
createdAt: '2025-05-01T07:00:00Z',
|
||||||
|
updatedAt: '2025-05-10T07:00:00Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'signal-002',
|
||||||
|
signalType: SignalType.POSSIBLE_MOVE_OUT,
|
||||||
|
companyName: 'Helvetia Produktion GmbH',
|
||||||
|
propertyId: 'prop-006',
|
||||||
|
locationHint: 'Reinach BL, Industriezone Nord',
|
||||||
|
areaSqmEstimate: 3200,
|
||||||
|
probability: 0.48,
|
||||||
|
confidenceScore: 0.44,
|
||||||
|
timeHorizonMonths: 13,
|
||||||
|
source: {
|
||||||
|
type: 'PRESS',
|
||||||
|
url: 'https://example.com/news/helvetia-restrukturierung',
|
||||||
|
publishedAt: '2025-03-15',
|
||||||
|
credibility: 'HIGH',
|
||||||
|
},
|
||||||
|
sensitivityLevel: 'CONFIDENTIAL',
|
||||||
|
disclaimer: 'Dieses Signal basiert auf Pressemeldungen zu Restrukturierungsplänen. Kein bestätigter Auszug. Vertraulich behandeln.',
|
||||||
|
riskLevel: RiskLevel.HIGH,
|
||||||
|
marketIndicator: 'Restrukturierungsankündigung Mutterkonzern',
|
||||||
|
relevanceScore: 0.61,
|
||||||
|
isVerified: false,
|
||||||
|
expiresAt: '2026-06-01',
|
||||||
|
organizationId: 'org-wincasa',
|
||||||
|
createdAt: '2025-05-03T08:00:00Z',
|
||||||
|
updatedAt: '2025-05-10T08:00:00Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'signal-003',
|
||||||
|
signalType: SignalType.CONSTRUCTION_PROJECT,
|
||||||
|
locationHint: 'Bern, Wankdorf',
|
||||||
|
areaSqmEstimate: 4500,
|
||||||
|
probability: 0.85,
|
||||||
|
confidenceScore: 0.80,
|
||||||
|
timeHorizonMonths: 24,
|
||||||
|
source: {
|
||||||
|
type: 'CONSTRUCTION_PERMIT',
|
||||||
|
publishedAt: '2025-02-10',
|
||||||
|
credibility: 'HIGH',
|
||||||
|
},
|
||||||
|
sensitivityLevel: 'PUBLIC',
|
||||||
|
disclaimer: 'Baubewilligung öffentlich eingesehen. Fertigstellung gemäss Baugesuch Q1 2027. Vermietungsstart noch offen.',
|
||||||
|
riskLevel: RiskLevel.LOW,
|
||||||
|
marketIndicator: 'Neubau Büro/Gewerbeflächen Wankdorf West',
|
||||||
|
relevanceScore: 0.82,
|
||||||
|
isVerified: true,
|
||||||
|
verifiedBy: 'admin@ideal-sharing.ch',
|
||||||
|
verifiedAt: '2025-05-05T09:00:00Z',
|
||||||
|
expiresAt: '2027-03-01',
|
||||||
|
organizationId: 'org-wincasa',
|
||||||
|
createdAt: '2025-02-12T10:00:00Z',
|
||||||
|
updatedAt: '2025-05-05T09:00:00Z',
|
||||||
|
},
|
||||||
|
]
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export { mockProperties } from './properties'
|
||||||
|
export { mockNeeds } from './needs'
|
||||||
|
export { mockMatches } from './matches'
|
||||||
|
export { mockFutureSignals } from './futureSignals'
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
import { MatchStrength, RiskLevel } from '../domain/enums'
|
||||||
|
import type { Match } from '../domain/match'
|
||||||
|
|
||||||
|
export const mockMatches: Match[] = [
|
||||||
|
{
|
||||||
|
id: 'match-001',
|
||||||
|
propertyId: 'prop-001',
|
||||||
|
needId: 'need-001',
|
||||||
|
matchScore: 88,
|
||||||
|
matchStrength: MatchStrength.STRONG,
|
||||||
|
scoreBreakdown: {
|
||||||
|
hardMatchScore: 92,
|
||||||
|
softFactorScore: 85,
|
||||||
|
confidenceModifier: 0.97,
|
||||||
|
dataQualityModifier: 0.92,
|
||||||
|
totalScore: 88,
|
||||||
|
},
|
||||||
|
positiveFactors: [
|
||||||
|
{ criterion: 'Fläche', weight: 0.20, score: 95, contribution: 19, explanation: '850m² liegt im Zielkorridor (600–1000m²)' },
|
||||||
|
{ criterion: 'ÖV-Anbindung', weight: 0.20, score: 90, contribution: 18, explanation: '4 Min. zur S-Bahn, Kriterium erfüllt' },
|
||||||
|
{ criterion: 'Standort', weight: 0.15, score: 88, contribution: 13.2, explanation: 'Zürich-West trifft bevorzugte Lage' },
|
||||||
|
],
|
||||||
|
negativeFactors: [
|
||||||
|
{ criterion: 'Mietpreis', weight: 0.15, score: 76, contribution: 11.4, explanation: 'CHF 38/m² liegt über Wunschbudget von CHF 35/m²' },
|
||||||
|
],
|
||||||
|
tradeoffs: [
|
||||||
|
{ criterion: 'Budget', concern: 'Mietpreis 8% über Budget-Maximum', severity: 'MEDIUM', mitigation: 'Verhandlungspotenzial vorhanden – Objekt leer seit 3 Monaten' },
|
||||||
|
],
|
||||||
|
explainabilitySummary: 'Starker Match aufgrund idealer Lage und Fläche. Einziger Vorbehalt ist der Mietpreis, der knapp über dem Budget liegt, aber verhandelbar erscheint.',
|
||||||
|
confidenceLevel: 0.92,
|
||||||
|
riskLevel: RiskLevel.LOW,
|
||||||
|
uncertaintyIndicators: [],
|
||||||
|
alternativeStrategies: [
|
||||||
|
{ title: 'Kleinere Einheit im gleichen Gebäude', description: '650m² verfügbar ab Q3 2025, CHF 35/m²', expectedScore: 82 },
|
||||||
|
],
|
||||||
|
organizationId: 'org-wincasa',
|
||||||
|
createdAt: '2025-05-10T08:00:00Z',
|
||||||
|
updatedAt: '2025-05-10T08:00:00Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'match-002',
|
||||||
|
propertyId: 'prop-004',
|
||||||
|
needId: 'need-001',
|
||||||
|
matchScore: 64,
|
||||||
|
matchStrength: MatchStrength.MODERATE,
|
||||||
|
scoreBreakdown: {
|
||||||
|
hardMatchScore: 78,
|
||||||
|
softFactorScore: 62,
|
||||||
|
confidenceModifier: 0.68,
|
||||||
|
dataQualityModifier: 0.55,
|
||||||
|
totalScore: 64,
|
||||||
|
},
|
||||||
|
positiveFactors: [
|
||||||
|
{ criterion: 'Fläche', weight: 0.20, score: 88, contribution: 17.6, explanation: '1150m² liegt im erweiterten Korridor' },
|
||||||
|
{ criterion: 'Standort', weight: 0.20, score: 80, contribution: 16, explanation: 'Zürich Kreis 4 nahe bevorzugten Lagen' },
|
||||||
|
],
|
||||||
|
negativeFactors: [
|
||||||
|
{ criterion: 'Datenqualität', weight: 0.10, score: 40, contribution: 4, explanation: 'Mehrere kritische Felder fehlen – Verlässlichkeit eingeschränkt' },
|
||||||
|
{ criterion: 'Mietpreis', weight: 0.15, score: 55, contribution: 8.25, explanation: 'CHF 52/m² deutlich über Budget' },
|
||||||
|
],
|
||||||
|
tradeoffs: [
|
||||||
|
{ criterion: 'Datenqualität', concern: 'Externe Quelle – Mietpreis und Verfügbarkeit nicht bestätigt', severity: 'HIGH', mitigation: 'Direkte Anfrage beim Anbieter empfohlen' },
|
||||||
|
{ criterion: 'Budget', concern: 'Mietpreis 30% über Budget-Maximum', severity: 'HIGH' },
|
||||||
|
],
|
||||||
|
explainabilitySummary: 'Moderater Match – Fläche und Lage passen, aber Mietpreis und Datenqualität sind kritische Vorbehalte. Nur mit Preisverhandlung und Verifikation sinnvoll.',
|
||||||
|
confidenceLevel: 0.58,
|
||||||
|
riskLevel: RiskLevel.MEDIUM,
|
||||||
|
uncertaintyIndicators: ['Daten aus Drittquelle unvollständig', 'Mietpreis nicht verifiziert'],
|
||||||
|
organizationId: 'org-wincasa',
|
||||||
|
createdAt: '2025-05-10T08:05:00Z',
|
||||||
|
updatedAt: '2025-05-10T08:05:00Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'match-003',
|
||||||
|
propertyId: 'prop-002',
|
||||||
|
needId: 'need-002',
|
||||||
|
matchScore: 91,
|
||||||
|
matchStrength: MatchStrength.STRONG,
|
||||||
|
scoreBreakdown: {
|
||||||
|
hardMatchScore: 94,
|
||||||
|
softFactorScore: 89,
|
||||||
|
confidenceModifier: 0.99,
|
||||||
|
dataQualityModifier: 0.96,
|
||||||
|
totalScore: 91,
|
||||||
|
},
|
||||||
|
positiveFactors: [
|
||||||
|
{ criterion: 'Fläche', weight: 0.25, score: 96, contribution: 24, explanation: '2400m² im Zielkorridor (1500–4000m²)' },
|
||||||
|
{ criterion: 'Autobahnanschluss', weight: 0.20, score: 92, contribution: 18.4, explanation: 'A2-Anschluss ca. 4 Min., Kriterium erfüllt' },
|
||||||
|
{ criterion: 'Budget', weight: 0.20, score: 88, contribution: 17.6, explanation: 'CHF 14/m² liegt unter Maximum von CHF 18/m²' },
|
||||||
|
{ criterion: 'Datenqualität', weight: 0.10, score: 96, contribution: 9.6, explanation: 'Verifiziertes Portfolioobjekt, alle Felder vollständig' },
|
||||||
|
],
|
||||||
|
negativeFactors: [
|
||||||
|
{ criterion: 'Erweiterungspotenzial', weight: 0.05, score: 70, contribution: 3.5, explanation: '800m² Erweiterung möglich, aber begrenzt' },
|
||||||
|
],
|
||||||
|
tradeoffs: [],
|
||||||
|
explainabilitySummary: 'Sehr starker Match. Fläche, Anbindung und Budget passen exzellent. Hohe Datenqualität aus internem Portfolio bestätigt Verlässlichkeit.',
|
||||||
|
confidenceLevel: 0.96,
|
||||||
|
riskLevel: RiskLevel.LOW,
|
||||||
|
uncertaintyIndicators: [],
|
||||||
|
organizationId: 'org-wincasa',
|
||||||
|
createdAt: '2025-05-10T08:10:00Z',
|
||||||
|
updatedAt: '2025-05-10T08:10:00Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'match-004',
|
||||||
|
propertyId: 'prop-006',
|
||||||
|
needId: 'need-002',
|
||||||
|
matchScore: 47,
|
||||||
|
matchStrength: MatchStrength.WEAK,
|
||||||
|
scoreBreakdown: {
|
||||||
|
hardMatchScore: 70,
|
||||||
|
softFactorScore: 40,
|
||||||
|
confidenceModifier: 0.48,
|
||||||
|
dataQualityModifier: 0.30,
|
||||||
|
totalScore: 47,
|
||||||
|
},
|
||||||
|
positiveFactors: [
|
||||||
|
{ criterion: 'Fläche', weight: 0.25, score: 85, contribution: 21.25, explanation: '3200m² im erweiterten Zielkorridor' },
|
||||||
|
{ criterion: 'Standort', weight: 0.20, score: 72, contribution: 14.4, explanation: 'Reinach BL ist eine bevorzugte Region' },
|
||||||
|
],
|
||||||
|
negativeFactors: [
|
||||||
|
{ criterion: 'Confidence', weight: 0.15, score: 20, contribution: 3, explanation: 'Nur 48% Signalwahrscheinlichkeit – kein bestätigtes Objekt' },
|
||||||
|
{ criterion: 'Datenqualität', weight: 0.10, score: 15, contribution: 1.5, explanation: 'Mehrere kritische Felder fehlen, Schätzwerte' },
|
||||||
|
{ criterion: 'Timing', weight: 0.15, score: 50, contribution: 7.5, explanation: 'Verfügbarkeit erst Q2 2026, spät für Anforderung' },
|
||||||
|
],
|
||||||
|
tradeoffs: [
|
||||||
|
{ criterion: 'Verfügbarkeit', concern: 'Probabilistisches Future-Signal – keine Garantie auf Verfügbarkeit', severity: 'HIGH', mitigation: 'Für Monitoring-Watchlist geeignet' },
|
||||||
|
{ criterion: 'Daten', concern: 'Mietpreis geschätzt, Andienung nicht bestätigt', severity: 'HIGH' },
|
||||||
|
],
|
||||||
|
explainabilitySummary: 'Schwacher Match aufgrund hoher Unsicherheit. Das Signal ist interessant als Frühindikator, aber nicht als aktive Option geeignet. Empfehlung: Watchlist.',
|
||||||
|
confidenceLevel: 0.38,
|
||||||
|
riskLevel: RiskLevel.HIGH,
|
||||||
|
uncertaintyIndicators: ['Probabilistisches Signal ohne Bestätigung', 'Mietpreis geschätzt', 'Verfügbarkeitsdatum unsicher'],
|
||||||
|
alternativeStrategies: [
|
||||||
|
{ title: 'Signal beobachten', description: 'Als Future-Availability-Signal auf Watchlist setzen und in 3 Monaten neu evaluieren' },
|
||||||
|
],
|
||||||
|
organizationId: 'org-wincasa',
|
||||||
|
createdAt: '2025-05-10T08:15:00Z',
|
||||||
|
updatedAt: '2025-05-10T08:15:00Z',
|
||||||
|
},
|
||||||
|
]
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { AssetType } from '../domain/enums'
|
||||||
|
import type { Need } from '../domain/need'
|
||||||
|
|
||||||
|
export const mockNeeds: Need[] = [
|
||||||
|
{
|
||||||
|
id: 'need-001',
|
||||||
|
companyName: 'Innovatech AG',
|
||||||
|
contactName: 'Sandra Meier',
|
||||||
|
assetType: AssetType.OFFICE,
|
||||||
|
requiredArea: { min: 600, max: 1000 },
|
||||||
|
preferredLocations: ['Zürich', 'Zürich-West', 'Zürich Kreis 5'],
|
||||||
|
excludedLocations: [],
|
||||||
|
budgetRange: { maxPerSqm: 45, maxMonthlyTotal: 40000, currency: 'CHF' },
|
||||||
|
timing: {
|
||||||
|
earliestMoveIn: '2025-08-01',
|
||||||
|
latestMoveIn: '2026-01-01',
|
||||||
|
contractDurationMonths: 60,
|
||||||
|
flexibleTiming: true,
|
||||||
|
},
|
||||||
|
mustCriteriaText: ['ÖV-Anbindung < 5 Min', 'Mindestfläche 600m²', 'Ausbaugrad modern'],
|
||||||
|
softFactors: {
|
||||||
|
minPrestige: 70,
|
||||||
|
minAccessibility: 80,
|
||||||
|
requireParking: false,
|
||||||
|
maxPublicTransportMinutes: 6,
|
||||||
|
},
|
||||||
|
weightingProfile: {
|
||||||
|
area: 0.20,
|
||||||
|
location: 0.20,
|
||||||
|
budget: 0.15,
|
||||||
|
timing: 0.15,
|
||||||
|
prestige: 0.10,
|
||||||
|
accessibility: 0.10,
|
||||||
|
expansionPotential: 0.05,
|
||||||
|
flexibility: 0.05,
|
||||||
|
},
|
||||||
|
confidenceInCriteria: 0.88,
|
||||||
|
extractedFromText: 'Wir suchen moderne Büroflächen in Zürich-West, ca. 700–900m², Budget max CHF 42/m², Bezug Herbst 2025.',
|
||||||
|
organizationId: 'org-wincasa',
|
||||||
|
createdAt: '2025-04-15T10:00:00Z',
|
||||||
|
updatedAt: '2025-05-01T09:00:00Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'need-002',
|
||||||
|
companyName: 'Schweizer Logistik GmbH',
|
||||||
|
contactName: 'Thomas Brun',
|
||||||
|
assetType: AssetType.LOGISTICS,
|
||||||
|
requiredArea: { min: 1500, max: 4000 },
|
||||||
|
preferredLocations: ['Basel', 'Muttenz', 'Pratteln', 'Reinach BL'],
|
||||||
|
budgetRange: { maxPerSqm: 18, currency: 'CHF' },
|
||||||
|
timing: {
|
||||||
|
earliestMoveIn: '2025-09-01',
|
||||||
|
latestMoveIn: '2026-06-01',
|
||||||
|
contractDurationMonths: 36,
|
||||||
|
flexibleTiming: false,
|
||||||
|
},
|
||||||
|
mustCriteriaText: ['Autobahnanschluss < 5 Min', 'Rampe / Andienung', 'Hallenhöhe min 6m'],
|
||||||
|
softFactors: {
|
||||||
|
requireParking: true,
|
||||||
|
},
|
||||||
|
weightingProfile: {
|
||||||
|
area: 0.25,
|
||||||
|
location: 0.20,
|
||||||
|
budget: 0.20,
|
||||||
|
timing: 0.15,
|
||||||
|
prestige: 0.02,
|
||||||
|
accessibility: 0.10,
|
||||||
|
expansionPotential: 0.05,
|
||||||
|
flexibility: 0.03,
|
||||||
|
},
|
||||||
|
confidenceInCriteria: 0.94,
|
||||||
|
organizationId: 'org-wincasa',
|
||||||
|
createdAt: '2025-03-20T14:00:00Z',
|
||||||
|
updatedAt: '2025-04-10T11:00:00Z',
|
||||||
|
},
|
||||||
|
]
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
import { AssetType, ResultType, AvailabilityStatus, DataFreshness, RiskLevel } from '../domain/enums'
|
||||||
|
import type { Property } from '../domain/property'
|
||||||
|
|
||||||
|
export const mockProperties: Property[] = [
|
||||||
|
// --- VERIFIED_PORTFOLIO ---
|
||||||
|
{
|
||||||
|
id: 'prop-001',
|
||||||
|
title: 'Bürofläche Zollstrasse 12',
|
||||||
|
assetType: AssetType.OFFICE,
|
||||||
|
resultType: ResultType.VERIFIED_PORTFOLIO,
|
||||||
|
location: { city: 'Zürich', district: 'Zürich-West', canton: 'ZH', country: 'CH', coordinates: { lat: 47.3882, lng: 8.5132 } },
|
||||||
|
address: { street: 'Zollstrasse', houseNumber: '12', postalCode: '8005', city: 'Zürich', country: 'CH' },
|
||||||
|
areaSqm: 850,
|
||||||
|
rentPricePerSqm: 38,
|
||||||
|
totalRentMonthly: 32300,
|
||||||
|
availabilityDate: '2025-09-01',
|
||||||
|
availabilityStatus: AvailabilityStatus.AVAILABLE_SOON,
|
||||||
|
sourceType: 'ERP_IMPORT',
|
||||||
|
confidenceScore: 0.97,
|
||||||
|
dataQuality: {
|
||||||
|
score: 0.92,
|
||||||
|
missingCriticalFields: [],
|
||||||
|
missingOptionalFields: ['expansionPotentialSqm'],
|
||||||
|
lastVerifiedAt: '2025-04-28',
|
||||||
|
freshness: DataFreshness.FRESH,
|
||||||
|
warnings: [],
|
||||||
|
},
|
||||||
|
softFactors: {
|
||||||
|
prestige: 78,
|
||||||
|
accessibility: 90,
|
||||||
|
visibilityScore: 65,
|
||||||
|
talentAccess: 85,
|
||||||
|
parkingSpots: 12,
|
||||||
|
publicTransportMinutes: 4,
|
||||||
|
},
|
||||||
|
floorLevel: 3,
|
||||||
|
contractDurationMonths: 60,
|
||||||
|
ancillaryCosts: 5.5,
|
||||||
|
riskLevel: RiskLevel.LOW,
|
||||||
|
organizationId: 'org-wincasa',
|
||||||
|
createdAt: '2025-01-10T08:00:00Z',
|
||||||
|
updatedAt: '2025-04-28T10:30:00Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'prop-002',
|
||||||
|
title: 'Lagerfläche Hardstrasse 44',
|
||||||
|
assetType: AssetType.LOGISTICS,
|
||||||
|
resultType: ResultType.VERIFIED_PORTFOLIO,
|
||||||
|
location: { city: 'Basel', district: 'Kleinhüningen', canton: 'BS', country: 'CH', coordinates: { lat: 47.5736, lng: 7.5946 } },
|
||||||
|
address: { street: 'Hardstrasse', houseNumber: '44', postalCode: '4057', city: 'Basel', country: 'CH' },
|
||||||
|
areaSqm: 2400,
|
||||||
|
rentPricePerSqm: 14,
|
||||||
|
totalRentMonthly: 33600,
|
||||||
|
availabilityDate: '2025-07-01',
|
||||||
|
availabilityStatus: AvailabilityStatus.AVAILABLE_NOW,
|
||||||
|
sourceType: 'ERP_IMPORT',
|
||||||
|
confidenceScore: 0.99,
|
||||||
|
dataQuality: {
|
||||||
|
score: 0.96,
|
||||||
|
missingCriticalFields: [],
|
||||||
|
missingOptionalFields: [],
|
||||||
|
lastVerifiedAt: '2025-05-05',
|
||||||
|
freshness: DataFreshness.FRESH,
|
||||||
|
warnings: [],
|
||||||
|
},
|
||||||
|
softFactors: {
|
||||||
|
prestige: 40,
|
||||||
|
accessibility: 88,
|
||||||
|
parkingSpots: 30,
|
||||||
|
publicTransportMinutes: 12,
|
||||||
|
},
|
||||||
|
floorLevel: 0,
|
||||||
|
expansionPotentialSqm: 800,
|
||||||
|
contractDurationMonths: 36,
|
||||||
|
ancillaryCosts: 3.0,
|
||||||
|
riskLevel: RiskLevel.LOW,
|
||||||
|
organizationId: 'org-wincasa',
|
||||||
|
createdAt: '2024-11-20T09:00:00Z',
|
||||||
|
updatedAt: '2025-05-05T11:00:00Z',
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- EXTERNAL_MARKET ---
|
||||||
|
{
|
||||||
|
id: 'prop-003',
|
||||||
|
title: 'Retail-Fläche Bahnhofstrasse 88',
|
||||||
|
assetType: AssetType.RETAIL,
|
||||||
|
resultType: ResultType.EXTERNAL_MARKET,
|
||||||
|
location: { city: 'Bern', district: 'Innenstadt', canton: 'BE', country: 'CH', coordinates: { lat: 46.9483, lng: 7.4474 } },
|
||||||
|
address: { street: 'Bahnhofstrasse', houseNumber: '88', postalCode: '3011', city: 'Bern', country: 'CH' },
|
||||||
|
areaSqm: 320,
|
||||||
|
rentPricePerSqm: 95,
|
||||||
|
totalRentMonthly: 30400,
|
||||||
|
availabilityDate: '2025-08-15',
|
||||||
|
availabilityStatus: AvailabilityStatus.AVAILABLE_SOON,
|
||||||
|
sourceType: 'MATCHOFFICE_SCRAPE',
|
||||||
|
sourceUrl: 'https://example.com/listing/prop-003',
|
||||||
|
confidenceScore: 0.71,
|
||||||
|
dataQuality: {
|
||||||
|
score: 0.62,
|
||||||
|
missingCriticalFields: ['contractDurationMonths'],
|
||||||
|
missingOptionalFields: ['ancillaryCosts', 'floorLevel'],
|
||||||
|
lastVerifiedAt: '2025-04-10',
|
||||||
|
freshness: DataFreshness.STALE,
|
||||||
|
warnings: ['Mietpreis nicht bestätigt', 'Verfügbarkeit nicht verifiziert'],
|
||||||
|
},
|
||||||
|
softFactors: {
|
||||||
|
prestige: 92,
|
||||||
|
visibilityScore: 98,
|
||||||
|
passerbyFrequency: 'VERY_HIGH',
|
||||||
|
publicTransportMinutes: 2,
|
||||||
|
},
|
||||||
|
riskLevel: RiskLevel.MEDIUM,
|
||||||
|
createdAt: '2025-02-15T14:00:00Z',
|
||||||
|
updatedAt: '2025-04-10T09:00:00Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'prop-004',
|
||||||
|
title: 'Gemischte Gewerbeeinheit Europaallee',
|
||||||
|
assetType: AssetType.MIXED,
|
||||||
|
resultType: ResultType.EXTERNAL_MARKET,
|
||||||
|
location: { city: 'Zürich', district: 'Kreis 4', canton: 'ZH', country: 'CH', coordinates: { lat: 47.3775, lng: 8.5398 } },
|
||||||
|
address: { street: 'Europaallee', houseNumber: '21', postalCode: '8004', city: 'Zürich', country: 'CH' },
|
||||||
|
areaSqm: 1150,
|
||||||
|
rentPricePerSqm: 52,
|
||||||
|
totalRentMonthly: 59800,
|
||||||
|
availabilityDate: '2025-10-01',
|
||||||
|
availabilityStatus: AvailabilityStatus.AVAILABLE_SOON,
|
||||||
|
sourceType: 'IMMOSCOUT_SCRAPE',
|
||||||
|
confidenceScore: 0.68,
|
||||||
|
dataQuality: {
|
||||||
|
score: 0.55,
|
||||||
|
missingCriticalFields: ['contractDurationMonths', 'ancillaryCosts'],
|
||||||
|
missingOptionalFields: ['softFactors'],
|
||||||
|
lastVerifiedAt: '2025-03-20',
|
||||||
|
freshness: DataFreshness.STALE,
|
||||||
|
warnings: ['Daten aus Drittquelle – nicht verifiziert'],
|
||||||
|
},
|
||||||
|
riskLevel: RiskLevel.MEDIUM,
|
||||||
|
createdAt: '2025-03-01T10:00:00Z',
|
||||||
|
updatedAt: '2025-03-20T15:00:00Z',
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- FUTURE_AVAILABILITY ---
|
||||||
|
{
|
||||||
|
id: 'prop-005',
|
||||||
|
title: 'Bürofläche Technoparkstrasse (Signal: Expansion)',
|
||||||
|
assetType: AssetType.OFFICE,
|
||||||
|
resultType: ResultType.FUTURE_AVAILABILITY,
|
||||||
|
location: { city: 'Zürich', district: 'Technopark', canton: 'ZH', country: 'CH', coordinates: { lat: 47.3923, lng: 8.5142 } },
|
||||||
|
address: { street: 'Technoparkstrasse', houseNumber: '1', postalCode: '8005', city: 'Zürich', country: 'CH' },
|
||||||
|
areaSqm: 600,
|
||||||
|
rentPricePerSqm: 42,
|
||||||
|
availabilityDate: '2026-03-01',
|
||||||
|
availabilityStatus: AvailabilityStatus.FUTURE_SIGNAL,
|
||||||
|
sourceType: 'AI_SIGNAL',
|
||||||
|
confidenceScore: 0.55,
|
||||||
|
dataQuality: {
|
||||||
|
score: 0.38,
|
||||||
|
missingCriticalFields: ['totalRentMonthly', 'contractDurationMonths'],
|
||||||
|
missingOptionalFields: ['ancillaryCosts', 'floorLevel', 'parkingSpots'],
|
||||||
|
freshness: DataFreshness.FRESH,
|
||||||
|
warnings: ['Probabilistisches Signal – kein bestätigtes Objekt', 'Daten nicht verifiziert'],
|
||||||
|
},
|
||||||
|
riskLevel: RiskLevel.HIGH,
|
||||||
|
createdAt: '2025-05-01T07:00:00Z',
|
||||||
|
updatedAt: '2025-05-10T07:00:00Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'prop-006',
|
||||||
|
title: 'Produktionsfläche Reinach (Signal: möglicher Auszug)',
|
||||||
|
assetType: AssetType.PRODUCTION,
|
||||||
|
resultType: ResultType.FUTURE_AVAILABILITY,
|
||||||
|
location: { city: 'Reinach', district: 'Industriezone Nord', canton: 'BL', country: 'CH', coordinates: { lat: 47.4978, lng: 7.5933 } },
|
||||||
|
address: { street: 'Industriestrasse', houseNumber: '18', postalCode: '4153', city: 'Reinach', country: 'CH' },
|
||||||
|
areaSqm: 3200,
|
||||||
|
rentPricePerSqm: 11,
|
||||||
|
availabilityDate: '2026-06-01',
|
||||||
|
availabilityStatus: AvailabilityStatus.FUTURE_SIGNAL,
|
||||||
|
sourceType: 'AI_SIGNAL',
|
||||||
|
confidenceScore: 0.48,
|
||||||
|
dataQuality: {
|
||||||
|
score: 0.30,
|
||||||
|
missingCriticalFields: ['totalRentMonthly', 'rentPricePerSqm', 'contractDurationMonths'],
|
||||||
|
missingOptionalFields: ['ancillaryCosts', 'softFactors'],
|
||||||
|
freshness: DataFreshness.FRESH,
|
||||||
|
warnings: ['Probabilistisches Signal – kein bestätigtes Objekt', 'Mietpreis geschätzt'],
|
||||||
|
},
|
||||||
|
riskLevel: RiskLevel.HIGH,
|
||||||
|
createdAt: '2025-05-03T08:00:00Z',
|
||||||
|
updatedAt: '2025-05-10T08:00:00Z',
|
||||||
|
},
|
||||||
|
]
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { Container, Typography, Button, Card, CardContent, Chip } from '@mui/material'
|
||||||
|
import HomeIcon from '@mui/icons-material/Home'
|
||||||
|
import SearchIcon from '@mui/icons-material/Search'
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
return (
|
||||||
|
<Container maxWidth="lg" className="py-12">
|
||||||
|
<div className="mb-8 text-center">
|
||||||
|
<Typography variant="h3" component="h1" className="font-bold text-gray-800">
|
||||||
|
Property Match
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="subtitle1" className="mt-2 text-gray-500">
|
||||||
|
Find your ideal property
|
||||||
|
</Typography>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4 mb-10 max-w-2xl mx-auto">
|
||||||
|
<Button variant="contained" size="large" startIcon={<SearchIcon />}>
|
||||||
|
Search
|
||||||
|
</Button>
|
||||||
|
<Button variant="outlined" size="large" startIcon={<HomeIcon />}>
|
||||||
|
Browse
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
|
{(['Buy', 'Rent', 'Invest'] as const).map((category) => (
|
||||||
|
<Card key={category} elevation={2} className="hover:shadow-lg transition-shadow duration-200">
|
||||||
|
<CardContent className="p-6">
|
||||||
|
<Chip label={category} color="primary" size="small" className="mb-3" />
|
||||||
|
<Typography variant="h6" className="font-semibold mb-2">
|
||||||
|
{category} a Property
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" className="text-gray-500">
|
||||||
|
Browse listings available for {category.toLowerCase()} in your area.
|
||||||
|
</Typography>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Container>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,338 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Chip,
|
||||||
|
TextField,
|
||||||
|
Typography,
|
||||||
|
CircularProgress,
|
||||||
|
Alert,
|
||||||
|
Stack,
|
||||||
|
Divider,
|
||||||
|
} from '@mui/material'
|
||||||
|
import { Sparkles, ArrowRight } from 'lucide-react'
|
||||||
|
import { useNavigate } from 'react-router'
|
||||||
|
import { MockupAIServiceProvider, type CriteriaExtractionResult } from '../../services/aiService'
|
||||||
|
|
||||||
|
type Step = 'input' | 'extracting' | 'review' | 'done'
|
||||||
|
|
||||||
|
const EXAMPLE_QUERIES = [
|
||||||
|
'Büro Zürich 500-800m²',
|
||||||
|
'Logistik Basel 2000m²',
|
||||||
|
'Retail Bern Innenstadt',
|
||||||
|
]
|
||||||
|
|
||||||
|
function ConfidenceColor(score: number): string {
|
||||||
|
if (score >= 0.8) return '#1a7a4a'
|
||||||
|
if (score >= 0.6) return '#d97706'
|
||||||
|
return '#c0392b'
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AISearch() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const [step, setStep] = useState<Step>('input')
|
||||||
|
const [inputText, setInputText] = useState('')
|
||||||
|
const [extractedCriteria, setExtractedCriteria] = useState<CriteriaExtractionResult | null>(null)
|
||||||
|
const [followUpAnswers, setFollowUpAnswers] = useState<Record<number, string>>({})
|
||||||
|
|
||||||
|
const handleAnalyze = async () => {
|
||||||
|
setStep('extracting')
|
||||||
|
await new Promise(r => setTimeout(r, 1500))
|
||||||
|
const result = await MockupAIServiceProvider.extractCriteria(inputText)
|
||||||
|
setExtractedCriteria(result)
|
||||||
|
setStep('review')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleStartSearch = () => {
|
||||||
|
navigate('/demand/results', { state: { needId: 'need-001' } })
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
{/* Page Header */}
|
||||||
|
<Box sx={{ bgcolor: 'white', borderBottom: '1px solid #e2e8f0', px: 3, py: 2 }}>
|
||||||
|
<Typography variant="h5" fontWeight={700} color="text.primary">
|
||||||
|
AI Bedarfsanalyse
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
Beschreiben Sie Ihren Flächenbedarf in natürlicher Sprache
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ px: 3, py: 3 }}>
|
||||||
|
{/* Step 1: Input */}
|
||||||
|
{step === 'input' && (
|
||||||
|
<Box>
|
||||||
|
<Card sx={{ maxWidth: 680, mx: 'auto', p: 3 }}>
|
||||||
|
<Typography variant="subtitle1" fontWeight={600} mb={2}>
|
||||||
|
Flächenbedarf beschreiben
|
||||||
|
</Typography>
|
||||||
|
<TextField
|
||||||
|
multiline
|
||||||
|
rows={5}
|
||||||
|
fullWidth
|
||||||
|
placeholder="Beispiel: Wir suchen 800–1.000 m² Bürofläche in Zürich-West, verfügbar ab September 2025, Budget max. 45 CHF/m². Wichtig: gute ÖV-Anbindung, moderne Infrastruktur."
|
||||||
|
value={inputText}
|
||||||
|
onChange={e => setInputText(e.target.value)}
|
||||||
|
inputProps={{ maxLength: 2000 }}
|
||||||
|
sx={{ mb: 1 }}
|
||||||
|
/>
|
||||||
|
<Typography variant="caption" color="text.secondary" display="block" textAlign="right" mb={2}>
|
||||||
|
{inputText.length}/2000
|
||||||
|
</Typography>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
fullWidth
|
||||||
|
disabled={inputText.length < 20}
|
||||||
|
onClick={handleAnalyze}
|
||||||
|
endIcon={<Sparkles size={16} />}
|
||||||
|
sx={{ bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#162d4a' }, mb: 2 }}
|
||||||
|
>
|
||||||
|
Analysieren
|
||||||
|
</Button>
|
||||||
|
<Typography variant="caption" color="text.secondary" display="block" textAlign="center">
|
||||||
|
Die KI extrahiert automatisch Kriterien, Standortpräferenzen und Budget aus Ihrer Beschreibung.
|
||||||
|
</Typography>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Example Queries */}
|
||||||
|
<Box sx={{ maxWidth: 680, mx: 'auto', mt: 2 }}>
|
||||||
|
<Typography variant="caption" color="text.secondary" display="block" mb={1}>
|
||||||
|
Beispiele:
|
||||||
|
</Typography>
|
||||||
|
<Stack direction="row" spacing={1} flexWrap="wrap" gap={1}>
|
||||||
|
{EXAMPLE_QUERIES.map(q => (
|
||||||
|
<Chip
|
||||||
|
key={q}
|
||||||
|
label={q}
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
clickable
|
||||||
|
onClick={() => setInputText(q)}
|
||||||
|
sx={{ cursor: 'pointer' }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step 2: Extracting */}
|
||||||
|
{step === 'extracting' && (
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', py: 12, gap: 3 }}>
|
||||||
|
<CircularProgress size={48} sx={{ color: '#1e3a5f' }} />
|
||||||
|
<Typography variant="h6" color="text.secondary">
|
||||||
|
KI analysiert Ihren Bedarf...
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step 3: Review */}
|
||||||
|
{step === 'review' && extractedCriteria && (
|
||||||
|
<Box>
|
||||||
|
<Box className="grid grid-cols-2 gap-4" sx={{ mb: 3 }}>
|
||||||
|
{/* Left: Extracted Criteria */}
|
||||||
|
<Card sx={{ p: 3 }}>
|
||||||
|
<Typography variant="subtitle1" fontWeight={600} mb={2}>
|
||||||
|
Extrahierte Kriterien
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Stack spacing={2}>
|
||||||
|
{/* Confidence badge */}
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
|
||||||
|
<Typography variant="caption" color="text.secondary">Gesamtkonfidenz:</Typography>
|
||||||
|
<Chip
|
||||||
|
label={`${Math.round(extractedCriteria.confidence * 100)}%`}
|
||||||
|
size="small"
|
||||||
|
sx={{
|
||||||
|
bgcolor: ConfidenceColor(extractedCriteria.confidence),
|
||||||
|
color: 'white',
|
||||||
|
fontWeight: 700,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
{/* Criteria items */}
|
||||||
|
{extractedCriteria.extractedCriteria.requiredArea && (
|
||||||
|
<Box>
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
fontWeight={600}
|
||||||
|
sx={{ color: ConfidenceColor(extractedCriteria.confidence) }}
|
||||||
|
>
|
||||||
|
Flächenbedarf
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2">
|
||||||
|
{extractedCriteria.extractedCriteria.requiredArea.min}–
|
||||||
|
{extractedCriteria.extractedCriteria.requiredArea.max} m²
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{extractedCriteria.extractedCriteria.budgetRange && (
|
||||||
|
<Box>
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
fontWeight={600}
|
||||||
|
sx={{ color: ConfidenceColor(extractedCriteria.confidence) }}
|
||||||
|
>
|
||||||
|
Budget
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2">
|
||||||
|
max. {extractedCriteria.extractedCriteria.budgetRange.maxPerSqm}{' '}
|
||||||
|
{extractedCriteria.extractedCriteria.budgetRange.currency}/m²
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{extractedCriteria.extractedCriteria.companyName && (
|
||||||
|
<Box>
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
fontWeight={600}
|
||||||
|
sx={{ color: ConfidenceColor(0.5) }}
|
||||||
|
>
|
||||||
|
Unternehmen
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2">
|
||||||
|
{extractedCriteria.extractedCriteria.companyName}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{extractedCriteria.extractedCriteria.preferredLocations && extractedCriteria.extractedCriteria.preferredLocations.length > 0 && (
|
||||||
|
<Box>
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
fontWeight={600}
|
||||||
|
sx={{ color: ConfidenceColor(0.85) }}
|
||||||
|
>
|
||||||
|
Standort
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2">
|
||||||
|
{extractedCriteria.extractedCriteria.preferredLocations.join(', ')}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{extractedCriteria.extractedCriteria.timing && (
|
||||||
|
<Box>
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
fontWeight={600}
|
||||||
|
sx={{ color: ConfidenceColor(0.85) }}
|
||||||
|
>
|
||||||
|
Verfügbarkeit
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2">
|
||||||
|
ab {extractedCriteria.extractedCriteria.timing.earliestMoveIn}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{extractedCriteria.extractedCriteria.assetType && (
|
||||||
|
<Box>
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
fontWeight={600}
|
||||||
|
sx={{ color: ConfidenceColor(0.85) }}
|
||||||
|
>
|
||||||
|
Objekttyp
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2">
|
||||||
|
{extractedCriteria.extractedCriteria.assetType}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{/* Assumptions */}
|
||||||
|
{extractedCriteria.assumptions.length > 0 && (
|
||||||
|
<Box mt={3}>
|
||||||
|
<Typography variant="caption" fontWeight={600} color="text.secondary" display="block" mb={1}>
|
||||||
|
Annahmen der KI
|
||||||
|
</Typography>
|
||||||
|
<Stack spacing={0.5}>
|
||||||
|
{extractedCriteria.assumptions.map((a, i) => (
|
||||||
|
<Alert key={i} severity="warning" sx={{ py: 0, px: 1, '& .MuiAlert-message': { fontSize: 12 } }}>
|
||||||
|
{a}
|
||||||
|
</Alert>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Missing Fields */}
|
||||||
|
{extractedCriteria.missingFields.length > 0 && (
|
||||||
|
<Box mt={2}>
|
||||||
|
<Typography variant="caption" fontWeight={600} color="text.secondary" display="block" mb={1}>
|
||||||
|
Fehlende Informationen
|
||||||
|
</Typography>
|
||||||
|
<Stack direction="row" spacing={0.5} flexWrap="wrap" gap={0.5}>
|
||||||
|
{extractedCriteria.missingFields.map(f => (
|
||||||
|
<Chip
|
||||||
|
key={f}
|
||||||
|
label={f}
|
||||||
|
size="small"
|
||||||
|
color="warning"
|
||||||
|
variant="outlined"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Right: Follow-up Questions */}
|
||||||
|
<Card sx={{ p: 3 }}>
|
||||||
|
<Typography variant="subtitle1" fontWeight={600} mb={0.5}>
|
||||||
|
Rückfragen der KI
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" color="text.secondary" display="block" mb={2}>
|
||||||
|
Diese Fragen sind optional — verbessern jedoch die Trefferqualität.
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Stack spacing={3}>
|
||||||
|
{extractedCriteria.followUpQuestions.map((q, i) => (
|
||||||
|
<Box key={i}>
|
||||||
|
<Typography variant="body2" fontWeight={500} mb={1}>
|
||||||
|
{i + 1}. {q}
|
||||||
|
</Typography>
|
||||||
|
<TextField
|
||||||
|
size="small"
|
||||||
|
fullWidth
|
||||||
|
placeholder="Ihre Antwort (optional)"
|
||||||
|
value={followUpAnswers[i] ?? ''}
|
||||||
|
onChange={e =>
|
||||||
|
setFollowUpAnswers(prev => ({ ...prev, [i]: e.target.value }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Footer Actions */}
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 2 }}>
|
||||||
|
<Button variant="outlined" onClick={() => setStep('input')}>
|
||||||
|
Zurück
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
onClick={handleStartSearch}
|
||||||
|
endIcon={<ArrowRight size={16} />}
|
||||||
|
sx={{ bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#162d4a' } }}
|
||||||
|
>
|
||||||
|
Suche starten
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,320 @@
|
|||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Chip,
|
||||||
|
Typography,
|
||||||
|
LinearProgress,
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableRow,
|
||||||
|
CircularProgress,
|
||||||
|
Stack,
|
||||||
|
} from '@mui/material'
|
||||||
|
import { X } from 'lucide-react'
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { useNavigate } from 'react-router'
|
||||||
|
import { propertyService } from '../../services/propertyService'
|
||||||
|
import { useCompareStore } from '../../stores/compareStore'
|
||||||
|
import { EmptyState } from '../../components/ui'
|
||||||
|
import { ResultType, RiskLevel } from '../../domain/enums'
|
||||||
|
import type { Property } from '../../domain/property'
|
||||||
|
|
||||||
|
function getResultTypeLabel(type: ResultType): string {
|
||||||
|
switch (type) {
|
||||||
|
case ResultType.VERIFIED_PORTFOLIO: return 'Verified Portfolio'
|
||||||
|
case ResultType.EXTERNAL_MARKET: return 'Marktinserat'
|
||||||
|
case ResultType.FUTURE_AVAILABILITY: return 'Zukunftssignal'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getResultTypeColor(type: ResultType): string {
|
||||||
|
switch (type) {
|
||||||
|
case ResultType.VERIFIED_PORTFOLIO: return '#1e3a5f'
|
||||||
|
case ResultType.EXTERNAL_MARKET: return '#d97706'
|
||||||
|
case ResultType.FUTURE_AVAILABILITY: return '#7c3aed'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRiskColor(risk?: RiskLevel): 'success' | 'warning' | 'error' | 'default' {
|
||||||
|
if (!risk) return 'default'
|
||||||
|
if (risk === RiskLevel.LOW) return 'success'
|
||||||
|
if (risk === RiskLevel.MEDIUM) return 'warning'
|
||||||
|
return 'error'
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRiskLabel(risk?: RiskLevel): string {
|
||||||
|
if (!risk) return '–'
|
||||||
|
switch (risk) {
|
||||||
|
case RiskLevel.LOW: return 'Niedrig'
|
||||||
|
case RiskLevel.MEDIUM: return 'Mittel'
|
||||||
|
case RiskLevel.HIGH: return 'Hoch'
|
||||||
|
case RiskLevel.CRITICAL: return 'Kritisch'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CompareRow {
|
||||||
|
label: string
|
||||||
|
getValue: (p: Property) => string | number | null
|
||||||
|
format?: (v: string | number | null, p: Property) => React.ReactNode
|
||||||
|
isHigherBetter?: boolean
|
||||||
|
isLowerBetter?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function NumericCell({ value, isBest }: { value: React.ReactNode; isBest: boolean }) {
|
||||||
|
return (
|
||||||
|
<TableCell
|
||||||
|
sx={{
|
||||||
|
bgcolor: isBest ? '#f0fdf4' : 'transparent',
|
||||||
|
fontWeight: isBest ? 700 : 400,
|
||||||
|
color: isBest ? '#1a7a4a' : 'inherit',
|
||||||
|
borderLeft: '1px solid #f1f5f9',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</TableCell>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Compare() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const { compareTray, removeFromCompare, clearCompare } = useCompareStore()
|
||||||
|
|
||||||
|
const { data: propResp, isLoading } = useQuery({
|
||||||
|
queryKey: ['properties'],
|
||||||
|
queryFn: () => propertyService.getAll(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const properties = propResp?.data ?? []
|
||||||
|
const compareProperties = properties.filter(p => compareTray.includes(p.id))
|
||||||
|
// Keep ordering same as tray
|
||||||
|
const orderedProperties = compareTray
|
||||||
|
.map(id => compareProperties.find(p => p.id === id))
|
||||||
|
.filter((p): p is Property => p !== null)
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', py: 12 }}>
|
||||||
|
<CircularProgress />
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (compareTray.length === 0) {
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Box sx={{ bgcolor: 'white', borderBottom: '1px solid #e2e8f0', px: 3, py: 2 }}>
|
||||||
|
<Typography variant="h5" fontWeight={700}>Vergleich</Typography>
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ px: 3, py: 3 }}>
|
||||||
|
<EmptyState
|
||||||
|
title="Keine Objekte zum Vergleich"
|
||||||
|
description="Fügen Sie Objekte aus den Suchergebnissen zum Vergleich hinzu."
|
||||||
|
action={{ label: 'Zur Suche', onClick: () => navigate('/demand/results') }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows: CompareRow[] = [
|
||||||
|
{
|
||||||
|
label: 'Fläche (m²)',
|
||||||
|
getValue: p => p.areaSqm,
|
||||||
|
isHigherBetter: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Miete/m² (CHF)',
|
||||||
|
getValue: p => p.rentPricePerSqm,
|
||||||
|
isLowerBetter: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Gesamtmiete/Monat (CHF)',
|
||||||
|
getValue: p => p.totalRentMonthly ?? null,
|
||||||
|
format: (v) => v != null ? `${Number(v).toLocaleString('de-CH')} CHF` : <em style={{ color: '#94a3b8' }}>–</em>,
|
||||||
|
isLowerBetter: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Verfügbarkeit',
|
||||||
|
getValue: p => p.availabilityDate,
|
||||||
|
format: (v) => v ?? <em style={{ color: '#94a3b8' }}>–</em>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Standort',
|
||||||
|
getValue: p => `${p.location.city}${p.location.district ? ', ' + p.location.district : ''}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Datenqualität',
|
||||||
|
getValue: p => p.dataQuality.score,
|
||||||
|
format: (v, p) => (
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
|
<Box sx={{ width: 80 }}>
|
||||||
|
<LinearProgress
|
||||||
|
variant="determinate"
|
||||||
|
value={p.dataQuality.score * 100}
|
||||||
|
sx={{ height: 6, borderRadius: 3 }}
|
||||||
|
color={p.dataQuality.score >= 0.8 ? 'success' : p.dataQuality.score >= 0.6 ? 'warning' : 'error'}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
<Typography variant="caption">{Math.round(p.dataQuality.score * 100)}%</Typography>
|
||||||
|
</Box>
|
||||||
|
),
|
||||||
|
isHigherBetter: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Konfidenz',
|
||||||
|
getValue: p => p.confidenceScore,
|
||||||
|
format: (v) => v != null ? `${Math.round(Number(v) * 100)}%` : <em style={{ color: '#94a3b8' }}>–</em>,
|
||||||
|
isHigherBetter: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Risiko',
|
||||||
|
getValue: p => p.riskLevel ?? null,
|
||||||
|
format: (v, p) => (
|
||||||
|
<Chip
|
||||||
|
label={getRiskLabel(p.riskLevel)}
|
||||||
|
size="small"
|
||||||
|
color={getRiskColor(p.riskLevel)}
|
||||||
|
variant="outlined"
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Prestige',
|
||||||
|
getValue: p => p.softFactors?.prestige ?? null,
|
||||||
|
format: (v) => v != null ? String(v) : <em style={{ color: '#94a3b8' }}>–</em>,
|
||||||
|
isHigherBetter: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Erreichbarkeit',
|
||||||
|
getValue: p => p.softFactors?.accessibility ?? null,
|
||||||
|
format: (v) => v != null ? String(v) : <em style={{ color: '#94a3b8' }}>–</em>,
|
||||||
|
isHigherBetter: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'ÖV-Minuten',
|
||||||
|
getValue: p => p.softFactors?.publicTransportMinutes ?? null,
|
||||||
|
format: (v) => v != null ? `${v} Min.` : <em style={{ color: '#94a3b8' }}>–</em>,
|
||||||
|
isLowerBetter: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Fehlende Pflichtfelder',
|
||||||
|
getValue: p => p.dataQuality.missingCriticalFields.length,
|
||||||
|
format: (v) => v != null ? String(v) : <em style={{ color: '#94a3b8' }}>–</em>,
|
||||||
|
isLowerBetter: true,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
{/* Page Header */}
|
||||||
|
<Box sx={{ bgcolor: 'white', borderBottom: '1px solid #e2e8f0', px: 3, py: 2, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||||
|
<Typography variant="h5" fontWeight={700}>Vergleich</Typography>
|
||||||
|
<Chip label={`${orderedProperties.length} Objekte`} size="small" />
|
||||||
|
</Box>
|
||||||
|
<Button variant="outlined" size="small" color="error" onClick={clearCompare}>
|
||||||
|
Leeren
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ px: 3, py: 3 }}>
|
||||||
|
<Card sx={{ overflowX: 'auto' }}>
|
||||||
|
<Table>
|
||||||
|
<TableHead>
|
||||||
|
<TableRow sx={{ bgcolor: '#f8fafc' }}>
|
||||||
|
<TableCell sx={{ width: 180, fontWeight: 600, color: '#64748b', fontSize: 12 }}>
|
||||||
|
Kriterium
|
||||||
|
</TableCell>
|
||||||
|
{orderedProperties.map(p => (
|
||||||
|
<TableCell key={p.id} sx={{ borderLeft: '1px solid #f1f5f9', minWidth: 220 }}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 1 }}>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="body2" fontWeight={600}>{p.title}</Typography>
|
||||||
|
<Chip
|
||||||
|
label={getResultTypeLabel(p.resultType)}
|
||||||
|
size="small"
|
||||||
|
sx={{
|
||||||
|
bgcolor: getResultTypeColor(p.resultType),
|
||||||
|
color: 'white',
|
||||||
|
fontSize: 10,
|
||||||
|
mt: 0.5,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
sx={{ minWidth: 'auto', p: 0.5 }}
|
||||||
|
onClick={() => removeFromCompare(p.id)}
|
||||||
|
>
|
||||||
|
<X size={14} />
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
|
||||||
|
<TableBody>
|
||||||
|
{rows.map(row => {
|
||||||
|
const values = orderedProperties.map(p => row.getValue(p))
|
||||||
|
const numericValues = values
|
||||||
|
.map((v, i) => ({ v, i }))
|
||||||
|
.filter(x => x.v != null && typeof x.v === 'number') as { v: number; i: number }[]
|
||||||
|
|
||||||
|
let bestIdx = -1
|
||||||
|
if (numericValues.length > 1) {
|
||||||
|
if (row.isHigherBetter) {
|
||||||
|
bestIdx = numericValues.reduce((best, cur) => cur.v > best.v ? cur : best).i
|
||||||
|
} else if (row.isLowerBetter) {
|
||||||
|
bestIdx = numericValues.reduce((best, cur) => cur.v < best.v ? cur : best).i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TableRow key={row.label} hover>
|
||||||
|
<TableCell sx={{ color: '#64748b', fontSize: 13, fontWeight: 500 }}>
|
||||||
|
{row.label}
|
||||||
|
</TableCell>
|
||||||
|
{orderedProperties.map((p, idx) => {
|
||||||
|
const raw = row.getValue(p)
|
||||||
|
const displayValue = row.format
|
||||||
|
? row.format(raw, p)
|
||||||
|
: raw != null
|
||||||
|
? String(raw)
|
||||||
|
: <em style={{ color: '#94a3b8' }}>–</em>
|
||||||
|
|
||||||
|
const isBest = bestIdx === idx
|
||||||
|
return (
|
||||||
|
<NumericCell key={p.id} value={displayValue} isBest={isBest} />
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</TableRow>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Add more prompt */}
|
||||||
|
{orderedProperties.length < 3 && (
|
||||||
|
<Card sx={{ p: 2.5, mt: 2, border: '2px dashed #e2e8f0', boxShadow: 'none' }}>
|
||||||
|
<Stack direction="row" alignItems="center" justifyContent="space-between">
|
||||||
|
<Box>
|
||||||
|
<Typography variant="subtitle2" fontWeight={600}>Weiteres Objekt hinzufügen</Typography>
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
Bis zu {3 - orderedProperties.length} weitere{orderedProperties.length < 2 ? 's' : ''} Objekt{orderedProperties.length < 2 ? '' : 'e'} möglich
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Button variant="outlined" size="small" onClick={() => navigate('/demand/results')}>
|
||||||
|
Zur Suche
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,476 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Chip,
|
||||||
|
Typography,
|
||||||
|
LinearProgress,
|
||||||
|
Stack,
|
||||||
|
Alert,
|
||||||
|
CircularProgress,
|
||||||
|
Divider,
|
||||||
|
} from '@mui/material'
|
||||||
|
import {
|
||||||
|
MapPin,
|
||||||
|
Maximize2,
|
||||||
|
Banknote,
|
||||||
|
Calendar,
|
||||||
|
Bookmark,
|
||||||
|
Columns2,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { useNavigate, useLocation } from 'react-router'
|
||||||
|
import { propertyService } from '../../services/propertyService'
|
||||||
|
import { matchService } from '../../services/matchService'
|
||||||
|
import { needService } from '../../services/needService'
|
||||||
|
import { ResultType, MatchStrength, RiskLevel } from '../../domain/enums'
|
||||||
|
import type { Property } from '../../domain/property'
|
||||||
|
import type { Match } from '../../domain/match'
|
||||||
|
import { useCompareStore } from '../../stores/compareStore'
|
||||||
|
import { EmptyState } from '../../components/ui'
|
||||||
|
|
||||||
|
type FilterSource = ResultType | 'ALL'
|
||||||
|
type SortBy = 'score' | 'rent' | 'area'
|
||||||
|
|
||||||
|
function getResultTypeLabel(type: ResultType): string {
|
||||||
|
switch (type) {
|
||||||
|
case ResultType.VERIFIED_PORTFOLIO: return 'Verified Portfolio'
|
||||||
|
case ResultType.EXTERNAL_MARKET: return 'Marktinserat'
|
||||||
|
case ResultType.FUTURE_AVAILABILITY: return 'Zukunftssignal'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getResultTypeColor(type: ResultType): string {
|
||||||
|
switch (type) {
|
||||||
|
case ResultType.VERIFIED_PORTFOLIO: return '#1e3a5f'
|
||||||
|
case ResultType.EXTERNAL_MARKET: return '#d97706'
|
||||||
|
case ResultType.FUTURE_AVAILABILITY: return '#7c3aed'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMatchStrengthColor(strength: MatchStrength): string {
|
||||||
|
switch (strength) {
|
||||||
|
case MatchStrength.STRONG: return '#1a7a4a'
|
||||||
|
case MatchStrength.MODERATE: return '#d97706'
|
||||||
|
case MatchStrength.WEAK: return '#c0392b'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRiskChipColor(risk: RiskLevel): 'success' | 'warning' | 'error' {
|
||||||
|
if (risk === RiskLevel.LOW) return 'success'
|
||||||
|
if (risk === RiskLevel.MEDIUM) return 'warning'
|
||||||
|
return 'error'
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRiskLabel(risk: RiskLevel): string {
|
||||||
|
switch (risk) {
|
||||||
|
case RiskLevel.LOW: return 'Niedriges Risiko'
|
||||||
|
case RiskLevel.MEDIUM: return 'Mittleres Risiko'
|
||||||
|
case RiskLevel.HIGH: return 'Hohes Risiko'
|
||||||
|
case RiskLevel.CRITICAL: return 'Kritisches Risiko'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ResultCardProps {
|
||||||
|
property: Property
|
||||||
|
match: Match
|
||||||
|
onCompare: (id: string) => void
|
||||||
|
isInCompare: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function ResultCard({ property, match, onCompare, isInCompare }: ResultCardProps) {
|
||||||
|
const scoreColor = getMatchStrengthColor(match.matchStrength)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card sx={{ p: 2.5, mb: 1.5 }}>
|
||||||
|
{/* Future signal warning */}
|
||||||
|
{property.resultType === ResultType.FUTURE_AVAILABILITY && (
|
||||||
|
<Alert severity="warning" sx={{ mb: 1.5, py: 0.5 }}>
|
||||||
|
Probabilistisches Signal – kein bestätigtes Objekt
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Header row */}
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', mb: 1.5 }}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
||||||
|
<Chip
|
||||||
|
label={getResultTypeLabel(property.resultType)}
|
||||||
|
size="small"
|
||||||
|
sx={{
|
||||||
|
bgcolor: getResultTypeColor(property.resultType),
|
||||||
|
color: 'white',
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: 11,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Typography variant="h6" fontWeight={600}>
|
||||||
|
{property.title}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'baseline', gap: 0.5 }}>
|
||||||
|
<Typography variant="h4" fontWeight={800} sx={{ color: scoreColor }}>
|
||||||
|
{match.matchScore}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary">/100</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Property details row */}
|
||||||
|
<Stack direction="row" spacing={2.5} sx={{ mb: 1.5 }} flexWrap="wrap">
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||||
|
<MapPin size={14} color="#64748b" />
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
{property.location.city}
|
||||||
|
{property.location.district ? `, ${property.location.district}` : ''}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||||
|
<Maximize2 size={14} color="#64748b" />
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
{property.areaSqm} m²
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||||
|
<Banknote size={14} color="#64748b" />
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
CHF {property.rentPricePerSqm}/m²
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||||
|
<Calendar size={14} color="#64748b" />
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
{property.availabilityDate}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{/* Match factors section */}
|
||||||
|
<Divider sx={{ mb: 1.5 }} />
|
||||||
|
|
||||||
|
{match.positiveFactors.length > 0 && (
|
||||||
|
<Box sx={{ mb: 1 }}>
|
||||||
|
<Typography variant="caption" fontWeight={600} color="text.secondary" display="block" mb={0.5}>
|
||||||
|
Positive Faktoren
|
||||||
|
</Typography>
|
||||||
|
<Stack direction="row" spacing={0.5} flexWrap="wrap" gap={0.5}>
|
||||||
|
{match.positiveFactors.slice(0, 3).map((f, i) => (
|
||||||
|
<Chip
|
||||||
|
key={i}
|
||||||
|
label={f.criterion}
|
||||||
|
size="small"
|
||||||
|
sx={{ bgcolor: '#f0fdf4', color: '#1a7a4a', border: '1px solid #bbf7d0', fontSize: 11 }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{match.tradeoffs.length > 0 && (
|
||||||
|
<Alert severity="warning" sx={{ py: 0.5, px: 1.5, mb: 1, '& .MuiAlert-message': { fontSize: 12 } }}>
|
||||||
|
<Typography variant="caption" fontWeight={600} display="block" mb={0.5}>Abwägungen</Typography>
|
||||||
|
{match.tradeoffs.slice(0, 2).map((t, i) => (
|
||||||
|
<Typography key={i} variant="caption" display="block">
|
||||||
|
{t.criterion}: {t.concern}
|
||||||
|
</Typography>
|
||||||
|
))}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Confidence + Quality row */}
|
||||||
|
<Stack direction="row" spacing={2.5} alignItems="center" sx={{ mb: 1.5 }} flexWrap="wrap">
|
||||||
|
<Typography variant="caption">
|
||||||
|
<span style={{ color: '#64748b' }}>Konfidenz </span>
|
||||||
|
<strong style={{ color: match.confidenceLevel >= 0.8 ? '#1a7a4a' : match.confidenceLevel >= 0.6 ? '#d97706' : '#c0392b' }}>
|
||||||
|
{Math.round(match.confidenceLevel * 100)}%
|
||||||
|
</strong>
|
||||||
|
</Typography>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
|
<Typography variant="caption" color="text.secondary">Datenqualität</Typography>
|
||||||
|
<Box sx={{ width: 80 }}>
|
||||||
|
<LinearProgress
|
||||||
|
variant="determinate"
|
||||||
|
value={property.dataQuality.score * 100}
|
||||||
|
sx={{ height: 6, borderRadius: 3 }}
|
||||||
|
color={
|
||||||
|
property.dataQuality.score >= 0.8 ? 'success' :
|
||||||
|
property.dataQuality.score >= 0.6 ? 'warning' : 'error'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
{Math.round(property.dataQuality.score * 100)}%
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
{property.riskLevel && (
|
||||||
|
<Chip
|
||||||
|
label={getRiskLabel(property.riskLevel)}
|
||||||
|
size="small"
|
||||||
|
color={getRiskChipColor(property.riskLevel)}
|
||||||
|
variant="outlined"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{/* Actions row */}
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 1 }}>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
size="small"
|
||||||
|
startIcon={<Bookmark size={14} />}
|
||||||
|
disabled
|
||||||
|
>
|
||||||
|
Shortlist
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={isInCompare ? 'contained' : 'outlined'}
|
||||||
|
size="small"
|
||||||
|
startIcon={<Columns2 size={14} />}
|
||||||
|
onClick={() => onCompare(property.id)}
|
||||||
|
sx={isInCompare ? { bgcolor: '#1e3a5f' } : {}}
|
||||||
|
>
|
||||||
|
Vergleichen
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
size="small"
|
||||||
|
disabled
|
||||||
|
sx={{ bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#162d4a' } }}
|
||||||
|
>
|
||||||
|
Details
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Results() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const location = useLocation()
|
||||||
|
const _needId = (location.state as { needId?: string } | null)?.needId ?? 'need-001'
|
||||||
|
|
||||||
|
const [filterSource, setFilterSource] = useState<FilterSource>('ALL')
|
||||||
|
const [sortBy, setSortBy] = useState<SortBy>('score')
|
||||||
|
|
||||||
|
const { data: propResp, isLoading: propLoading } = useQuery({
|
||||||
|
queryKey: ['properties'],
|
||||||
|
queryFn: () => propertyService.getAll(),
|
||||||
|
})
|
||||||
|
const { data: matchResp, isLoading: matchLoading } = useQuery({
|
||||||
|
queryKey: ['matches'],
|
||||||
|
queryFn: () => matchService.getAll(),
|
||||||
|
})
|
||||||
|
const { data: needResp, isLoading: needLoading } = useQuery({
|
||||||
|
queryKey: ['needs'],
|
||||||
|
queryFn: () => needService.getAll(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const { addToCompare, removeFromCompare, clearCompare, isInCompare, compareTray } = useCompareStore()
|
||||||
|
|
||||||
|
const properties = propResp?.data ?? []
|
||||||
|
const matches = matchResp?.data ?? []
|
||||||
|
const needs = needResp?.data ?? []
|
||||||
|
const activeNeed = needs[0]
|
||||||
|
|
||||||
|
const isLoading = propLoading || matchLoading || needLoading
|
||||||
|
|
||||||
|
// Join matches with properties
|
||||||
|
const resultItems = matches
|
||||||
|
.map(match => {
|
||||||
|
const property = properties.find(p => p.id === match.propertyId)
|
||||||
|
return property ? { match, property } : null
|
||||||
|
})
|
||||||
|
.filter((item): item is { match: Match; property: Property } => item !== null)
|
||||||
|
|
||||||
|
// Filter by source
|
||||||
|
const filtered = filterSource === 'ALL'
|
||||||
|
? resultItems
|
||||||
|
: resultItems.filter(item => item.property.resultType === filterSource)
|
||||||
|
|
||||||
|
// Sort
|
||||||
|
const sorted = [...filtered].sort((a, b) => {
|
||||||
|
if (sortBy === 'score') return b.match.matchScore - a.match.matchScore
|
||||||
|
if (sortBy === 'rent') return a.property.rentPricePerSqm - b.property.rentPricePerSqm
|
||||||
|
if (sortBy === 'area') return b.property.areaSqm - a.property.areaSqm
|
||||||
|
return 0
|
||||||
|
})
|
||||||
|
|
||||||
|
const verifiedCount = resultItems.filter(i => i.property.resultType === ResultType.VERIFIED_PORTFOLIO).length
|
||||||
|
const externalCount = resultItems.filter(i => i.property.resultType === ResultType.EXTERNAL_MARKET).length
|
||||||
|
const futureCount = resultItems.filter(i => i.property.resultType === ResultType.FUTURE_AVAILABILITY).length
|
||||||
|
|
||||||
|
const handleToggleCompare = (id: string) => {
|
||||||
|
if (isInCompare(id)) {
|
||||||
|
removeFromCompare(id)
|
||||||
|
} else {
|
||||||
|
addToCompare(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', py: 12 }}>
|
||||||
|
<CircularProgress />
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
{/* Page Header */}
|
||||||
|
<Box sx={{ bgcolor: 'white', borderBottom: '1px solid #e2e8f0', px: 3, py: 2, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="h5" fontWeight={700} color="text.primary">
|
||||||
|
{sorted.length} Treffer gefunden
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
{verifiedCount} Verified · {externalCount} Extern · {futureCount} Signale
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ px: 3, py: 3 }}>
|
||||||
|
{/* Active Need Banner */}
|
||||||
|
{activeNeed && (
|
||||||
|
<Card sx={{ bgcolor: '#eff6ff', p: 2, mb: 2, border: '1px solid #bfdbfe' }}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="subtitle2" fontWeight={600} color="#1e3a5f">
|
||||||
|
Aktive Suche: {activeNeed.companyName}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
{activeNeed.assetType} · {activeNeed.requiredArea.min}–{activeNeed.requiredArea.max} m² ·{' '}
|
||||||
|
{activeNeed.preferredLocations.join(', ')}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="text"
|
||||||
|
onClick={() => navigate('/demand/ai-search')}
|
||||||
|
sx={{ color: '#1e3a5f' }}
|
||||||
|
>
|
||||||
|
Suche ändern
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Filter/Sort bar */}
|
||||||
|
<Card sx={{ p: 1.5, mb: 2 }}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: 1 }}>
|
||||||
|
<Stack direction="row" spacing={0.5} flexWrap="wrap" gap={0.5}>
|
||||||
|
{(['ALL', ResultType.VERIFIED_PORTFOLIO, ResultType.EXTERNAL_MARKET, ResultType.FUTURE_AVAILABILITY] as FilterSource[]).map(source => {
|
||||||
|
const labels: Record<FilterSource, string> = {
|
||||||
|
ALL: 'Alle',
|
||||||
|
[ResultType.VERIFIED_PORTFOLIO]: 'Verified Portfolio',
|
||||||
|
[ResultType.EXTERNAL_MARKET]: 'Marktinserate',
|
||||||
|
[ResultType.FUTURE_AVAILABILITY]: 'Zukunftssignale',
|
||||||
|
}
|
||||||
|
const colors: Partial<Record<FilterSource, string>> = {
|
||||||
|
[ResultType.VERIFIED_PORTFOLIO]: '#1e3a5f',
|
||||||
|
[ResultType.EXTERNAL_MARKET]: '#d97706',
|
||||||
|
[ResultType.FUTURE_AVAILABILITY]: '#7c3aed',
|
||||||
|
}
|
||||||
|
const isActive = filterSource === source
|
||||||
|
return (
|
||||||
|
<Chip
|
||||||
|
key={source}
|
||||||
|
label={labels[source]}
|
||||||
|
size="small"
|
||||||
|
clickable
|
||||||
|
onClick={() => setFilterSource(source)}
|
||||||
|
sx={{
|
||||||
|
bgcolor: isActive ? (colors[source] ?? '#1e3a5f') : 'transparent',
|
||||||
|
color: isActive ? 'white' : 'text.secondary',
|
||||||
|
border: `1px solid ${isActive ? (colors[source] ?? '#1e3a5f') : '#e2e8f0'}`,
|
||||||
|
fontWeight: isActive ? 600 : 400,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Stack direction="row" spacing={0.5} alignItems="center">
|
||||||
|
<Typography variant="caption" color="text.secondary">Sortierung:</Typography>
|
||||||
|
{([['score', 'Relevanz'], ['area', 'Fläche'], ['rent', 'Mietpreis']] as [SortBy, string][]).map(([val, label]) => (
|
||||||
|
<Chip
|
||||||
|
key={val}
|
||||||
|
label={label}
|
||||||
|
size="small"
|
||||||
|
clickable
|
||||||
|
onClick={() => setSortBy(val)}
|
||||||
|
sx={{
|
||||||
|
bgcolor: sortBy === val ? '#1e3a5f' : 'transparent',
|
||||||
|
color: sortBy === val ? 'white' : 'text.secondary',
|
||||||
|
border: `1px solid ${sortBy === val ? '#1e3a5f' : '#e2e8f0'}`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Results */}
|
||||||
|
{sorted.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
title="Keine Treffer gefunden"
|
||||||
|
description="Passen Sie die Suchkriterien an oder wechseln Sie den Filter."
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
sorted.map(({ match, property }) => (
|
||||||
|
<ResultCard
|
||||||
|
key={match.id}
|
||||||
|
property={property}
|
||||||
|
match={match}
|
||||||
|
onCompare={handleToggleCompare}
|
||||||
|
isInCompare={isInCompare(property.id)}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Compare Tray */}
|
||||||
|
{compareTray.length > 0 && (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
position: 'fixed',
|
||||||
|
bottom: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bgcolor: '#1e3a5f',
|
||||||
|
color: 'white',
|
||||||
|
py: 1.5,
|
||||||
|
px: 3,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
zIndex: 1200,
|
||||||
|
boxShadow: '0 -4px 12px rgba(0,0,0,0.15)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="body2" fontWeight={600}>
|
||||||
|
{compareTray.length} Objekte zum Vergleich ausgewählt
|
||||||
|
</Typography>
|
||||||
|
<Stack direction="row" spacing={1}>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
sx={{ color: 'white', borderColor: 'rgba(255,255,255,0.5)' }}
|
||||||
|
onClick={clearCompare}
|
||||||
|
>
|
||||||
|
Leeren
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="contained"
|
||||||
|
sx={{ bgcolor: 'white', color: '#1e3a5f', '&:hover': { bgcolor: '#f1f5f9' } }}
|
||||||
|
onClick={() => navigate('/demand/compare')}
|
||||||
|
>
|
||||||
|
Vergleich starten
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Chip,
|
||||||
|
Typography,
|
||||||
|
Stack,
|
||||||
|
Divider,
|
||||||
|
List,
|
||||||
|
ListItem,
|
||||||
|
ListItemText,
|
||||||
|
} from '@mui/material'
|
||||||
|
import { Bookmark } from 'lucide-react'
|
||||||
|
|
||||||
|
interface MockShortlist {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
company: string
|
||||||
|
assetType: string
|
||||||
|
objectCount: number
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
properties: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const MOCK_SHORTLISTS: MockShortlist[] = [
|
||||||
|
{
|
||||||
|
id: 'sl-001',
|
||||||
|
title: 'Bürosuche Innovatech AG',
|
||||||
|
company: 'Innovatech AG',
|
||||||
|
assetType: 'Büro',
|
||||||
|
objectCount: 2,
|
||||||
|
createdAt: '01.05.2025',
|
||||||
|
updatedAt: '08.05.2025',
|
||||||
|
properties: ['Bürofläche Zollstrasse 12', 'Gemischte Gewerbeeinheit Europaallee'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'sl-002',
|
||||||
|
title: 'Logistik Basel — Schweizer Logistik',
|
||||||
|
company: 'Schweizer Logistik GmbH',
|
||||||
|
assetType: 'Logistik',
|
||||||
|
objectCount: 1,
|
||||||
|
createdAt: '03.05.2025',
|
||||||
|
updatedAt: '03.05.2025',
|
||||||
|
properties: ['Lagerfläche Hardstrasse 44'],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export default function Shortlists() {
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
{/* Page Header */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
bgcolor: 'white',
|
||||||
|
borderBottom: '1px solid #e2e8f0',
|
||||||
|
px: 3,
|
||||||
|
py: 2,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="h5" fontWeight={700} color="text.primary">
|
||||||
|
Shortlists
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
Gespeicherte Objektlisten und Entscheidungsvorlagen
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
size="small"
|
||||||
|
startIcon={<Bookmark size={14} />}
|
||||||
|
disabled
|
||||||
|
sx={{ bgcolor: '#1e3a5f' }}
|
||||||
|
>
|
||||||
|
Neue Shortlist
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ px: 3, py: 3 }}>
|
||||||
|
<Stack spacing={2}>
|
||||||
|
{MOCK_SHORTLISTS.map(sl => (
|
||||||
|
<Card key={sl.id} sx={{ p: 2.5 }}>
|
||||||
|
{/* Card header */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
mb: 1.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="subtitle1" fontWeight={700}>
|
||||||
|
{sl.title}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
{sl.company} · {sl.assetType}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Chip
|
||||||
|
label={`${sl.objectCount} Objekte`}
|
||||||
|
size="small"
|
||||||
|
sx={{ bgcolor: '#eff6ff', color: '#1e3a5f', fontWeight: 600 }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Dates */}
|
||||||
|
<Stack direction="row" spacing={2} sx={{ mb: 1.5 }}>
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
Erstellt: {sl.createdAt}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
Zuletzt aktualisiert: {sl.updatedAt}
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Divider sx={{ mb: 1.5 }} />
|
||||||
|
|
||||||
|
{/* Property list */}
|
||||||
|
<List dense disablePadding sx={{ mb: 1.5 }}>
|
||||||
|
{sl.properties.map((prop, i) => (
|
||||||
|
<ListItem key={i} disableGutters sx={{ py: 0.25 }}>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: 6,
|
||||||
|
height: 6,
|
||||||
|
borderRadius: '50%',
|
||||||
|
bgcolor: '#1e3a5f',
|
||||||
|
mr: 1.5,
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<ListItemText
|
||||||
|
primary={prop}
|
||||||
|
primaryTypographyProps={{ variant: 'body2' }}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
))}
|
||||||
|
</List>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 1 }}>
|
||||||
|
<Button variant="outlined" size="small" disabled>
|
||||||
|
Teilen
|
||||||
|
</Button>
|
||||||
|
<Button variant="outlined" size="small" disabled>
|
||||||
|
Öffnen
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Empty shortlist prompt */}
|
||||||
|
<Card
|
||||||
|
sx={{
|
||||||
|
p: 2,
|
||||||
|
border: '2px dashed #e2e8f0',
|
||||||
|
boxShadow: 'none',
|
||||||
|
bgcolor: '#fafafa',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="body2" color="text.secondary" textAlign="center">
|
||||||
|
Objekte aus den Suchergebnissen zur Shortlist hinzufügen
|
||||||
|
</Typography>
|
||||||
|
</Card>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,280 @@
|
|||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Card,
|
||||||
|
Chip,
|
||||||
|
Typography,
|
||||||
|
LinearProgress,
|
||||||
|
Stack,
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableRow,
|
||||||
|
Alert,
|
||||||
|
CircularProgress,
|
||||||
|
} from '@mui/material'
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { propertyService } from '../../services/propertyService'
|
||||||
|
|
||||||
|
interface MetricCard {
|
||||||
|
label: string
|
||||||
|
value: string
|
||||||
|
color: string
|
||||||
|
note: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const HEALTH_METRICS: MetricCard[] = [
|
||||||
|
{ label: 'Extraktionsgenauigkeit', value: '94%', color: '#1a7a4a', note: 'Ø letzte 30 Tage' },
|
||||||
|
{ label: 'Konfidenz-Ø', value: '73%', color: '#d97706', note: 'Alle Objekte' },
|
||||||
|
{ label: 'Validierungsrate', value: '88%', color: '#1a7a4a', note: 'Menschliche Bestätigung' },
|
||||||
|
{ label: 'Fehlerrate', value: '2.1%', color: '#1a7a4a', note: 'Kritische Fehler' },
|
||||||
|
]
|
||||||
|
|
||||||
|
interface AIDecision {
|
||||||
|
timestamp: string
|
||||||
|
type: string
|
||||||
|
confidence: string
|
||||||
|
result: string
|
||||||
|
impact: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const RECENT_DECISIONS: AIDecision[] = [
|
||||||
|
{
|
||||||
|
timestamp: '15.05.2025 14:32',
|
||||||
|
type: 'Bedarfsextraktion',
|
||||||
|
confidence: '91%',
|
||||||
|
result: 'Kriterien extrahiert',
|
||||||
|
impact: 'Suche ausgelöst',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
timestamp: '15.05.2025 11:15',
|
||||||
|
type: 'Match-Scoring',
|
||||||
|
confidence: '88%',
|
||||||
|
result: '3 Matches berechnet',
|
||||||
|
impact: 'Review ausgelöst',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
timestamp: '14.05.2025 16:40',
|
||||||
|
type: 'Signal-Erkennung',
|
||||||
|
confidence: '72%',
|
||||||
|
result: 'Expansion erkannt',
|
||||||
|
impact: 'Signal erstellt',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
timestamp: '14.05.2025 09:00',
|
||||||
|
type: 'Datenqualitätsprüfung',
|
||||||
|
confidence: '95%',
|
||||||
|
result: '2 Warnungen erkannt',
|
||||||
|
impact: 'Meldung erstellt',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
timestamp: '13.05.2025 15:22',
|
||||||
|
type: 'Match-Scoring',
|
||||||
|
confidence: '84%',
|
||||||
|
result: '2 Matches berechnet',
|
||||||
|
impact: 'Review ausgelöst',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
timestamp: '12.05.2025 10:11',
|
||||||
|
type: 'Signal-Erkennung',
|
||||||
|
confidence: '65%',
|
||||||
|
result: 'Möglicher Auszug erkannt',
|
||||||
|
impact: 'Signal erstellt',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
function getConfidenceBadge(pct: string) {
|
||||||
|
const n = parseInt(pct)
|
||||||
|
const color = n >= 85 ? '#1a7a4a' : n >= 70 ? '#d97706' : '#c0392b'
|
||||||
|
return (
|
||||||
|
<Chip
|
||||||
|
label={pct}
|
||||||
|
size="small"
|
||||||
|
sx={{ bgcolor: color, color: 'white', fontWeight: 700, fontSize: 11 }}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AIMonitoring() {
|
||||||
|
const { data: propResp, isLoading } = useQuery({
|
||||||
|
queryKey: ['properties'],
|
||||||
|
queryFn: () => propertyService.getAll(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const properties = propResp?.data ?? []
|
||||||
|
|
||||||
|
const highConf = properties.filter(p => p.confidenceScore > 0.85).length
|
||||||
|
const midConf = properties.filter(p => p.confidenceScore >= 0.65 && p.confidenceScore <= 0.85).length
|
||||||
|
const lowConf = properties.filter(p => p.confidenceScore < 0.65).length
|
||||||
|
const total = properties.length || 1
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
{/* Page Header */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
bgcolor: 'white',
|
||||||
|
borderBottom: '1px solid #e2e8f0',
|
||||||
|
px: 3,
|
||||||
|
py: 2,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box sx={{ flex: 1 }}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
||||||
|
<Typography variant="h5" fontWeight={700} color="text.primary">
|
||||||
|
AI Monitoring
|
||||||
|
</Typography>
|
||||||
|
<Chip
|
||||||
|
label="Live"
|
||||||
|
size="small"
|
||||||
|
sx={{
|
||||||
|
bgcolor: '#1a7a4a',
|
||||||
|
color: 'white',
|
||||||
|
fontWeight: 700,
|
||||||
|
fontSize: 11,
|
||||||
|
animation: 'pulse 2s ease-in-out infinite',
|
||||||
|
'@keyframes pulse': {
|
||||||
|
'0%, 100%': { opacity: 1 },
|
||||||
|
'50%': { opacity: 0.6 },
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
AI-Layer Gesundheit und Entscheidungsqualität
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ px: 3, py: 3 }}>
|
||||||
|
{/* Health Metrics */}
|
||||||
|
<Box className="grid grid-cols-4 gap-4" sx={{ mb: 3 }}>
|
||||||
|
{HEALTH_METRICS.map(m => (
|
||||||
|
<Card key={m.label} sx={{ p: 2.5 }}>
|
||||||
|
<Typography variant="overline" color="text.secondary" lineHeight={1.4} display="block">
|
||||||
|
{m.label}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="h3" fontWeight={800} sx={{ color: m.color }}>
|
||||||
|
{m.value}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
{m.note}
|
||||||
|
</Typography>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box className="grid grid-cols-2 gap-4" sx={{ mb: 3 }}>
|
||||||
|
{/* Confidence Distribution */}
|
||||||
|
<Card sx={{ p: 2.5 }}>
|
||||||
|
<Typography variant="subtitle1" fontWeight={600} mb={2}>
|
||||||
|
Konfidenzverteilung
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
|
||||||
|
<CircularProgress size={32} />
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<Stack spacing={2}>
|
||||||
|
<Box>
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.5 }}>
|
||||||
|
<Typography variant="body2" fontWeight={500}>Hoch (>85%)</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary">{highConf} Objekte</Typography>
|
||||||
|
</Box>
|
||||||
|
<LinearProgress
|
||||||
|
variant="determinate"
|
||||||
|
value={(highConf / total) * 100}
|
||||||
|
color="success"
|
||||||
|
sx={{ height: 10, borderRadius: 5 }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
<Box>
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.5 }}>
|
||||||
|
<Typography variant="body2" fontWeight={500}>Mittel (65–85%)</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary">{midConf} Objekte</Typography>
|
||||||
|
</Box>
|
||||||
|
<LinearProgress
|
||||||
|
variant="determinate"
|
||||||
|
value={(midConf / total) * 100}
|
||||||
|
color="warning"
|
||||||
|
sx={{ height: 10, borderRadius: 5 }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
<Box>
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.5 }}>
|
||||||
|
<Typography variant="body2" fontWeight={500}>Niedrig (<65%)</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary">{lowConf} Objekte</Typography>
|
||||||
|
</Box>
|
||||||
|
<LinearProgress
|
||||||
|
variant="determinate"
|
||||||
|
value={(lowConf / total) * 100}
|
||||||
|
color="error"
|
||||||
|
sx={{ height: 10, borderRadius: 5 }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Anomaly Alerts */}
|
||||||
|
<Card sx={{ p: 2.5 }}>
|
||||||
|
<Typography variant="subtitle1" fontWeight={600} mb={2}>
|
||||||
|
Anomalien
|
||||||
|
</Typography>
|
||||||
|
<Stack spacing={1.5}>
|
||||||
|
<Alert severity="warning">
|
||||||
|
Mietpreisangaben für prop-004 weichen von Marktdurchschnitt ab (±31%). Manuelle Prüfung empfohlen.
|
||||||
|
</Alert>
|
||||||
|
<Alert severity="success">
|
||||||
|
Keine kritischen Anomalien erkannt. System läuft stabil.
|
||||||
|
</Alert>
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Recent AI Decisions */}
|
||||||
|
<Card>
|
||||||
|
<Box sx={{ px: 2.5, py: 2, borderBottom: '1px solid #f1f5f9' }}>
|
||||||
|
<Typography variant="subtitle1" fontWeight={600}>
|
||||||
|
Letzte KI-Entscheidungen
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Table>
|
||||||
|
<TableHead>
|
||||||
|
<TableRow sx={{ bgcolor: '#f8fafc' }}>
|
||||||
|
<TableCell sx={{ fontSize: 12, fontWeight: 600, color: '#64748b' }}>Zeitpunkt</TableCell>
|
||||||
|
<TableCell sx={{ fontSize: 12, fontWeight: 600, color: '#64748b' }}>Entscheidungstyp</TableCell>
|
||||||
|
<TableCell sx={{ fontSize: 12, fontWeight: 600, color: '#64748b' }}>Konfidenz</TableCell>
|
||||||
|
<TableCell sx={{ fontSize: 12, fontWeight: 600, color: '#64748b' }}>Ergebnis</TableCell>
|
||||||
|
<TableCell sx={{ fontSize: 12, fontWeight: 600, color: '#64748b' }}>Einfluss</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{RECENT_DECISIONS.map((d, i) => (
|
||||||
|
<TableRow key={i} hover>
|
||||||
|
<TableCell>
|
||||||
|
<Typography variant="caption" color="text.secondary">{d.timestamp}</Typography>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Typography variant="body2" fontWeight={500}>{d.type}</Typography>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{getConfidenceBadge(d.confidence)}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Typography variant="body2">{d.result}</Typography>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Typography variant="body2" color="text.secondary">{d.impact}</Typography>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</Card>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,311 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Chip,
|
||||||
|
Typography,
|
||||||
|
Stack,
|
||||||
|
CircularProgress,
|
||||||
|
} from '@mui/material'
|
||||||
|
import {
|
||||||
|
Building2,
|
||||||
|
Edit,
|
||||||
|
CheckCircle,
|
||||||
|
XCircle,
|
||||||
|
TrendingUp,
|
||||||
|
Search,
|
||||||
|
ClipboardList,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { governanceService, type ActivityEventType, type ActivityEvent } from '../../services/governanceService'
|
||||||
|
import { EmptyState } from '../../components/ui'
|
||||||
|
|
||||||
|
function getEventLabel(type: ActivityEventType): string {
|
||||||
|
switch (type) {
|
||||||
|
case 'PROPERTY_CREATED': return 'Objekt erstellt'
|
||||||
|
case 'PROPERTY_UPDATED': return 'Objekt aktualisiert'
|
||||||
|
case 'MATCH_APPROVED': return 'Match genehmigt'
|
||||||
|
case 'MATCH_REJECTED': return 'Match abgelehnt'
|
||||||
|
case 'SIGNAL_VERIFIED': return 'Signal verifiziert'
|
||||||
|
case 'NEED_CREATED': return 'Bedarf erstellt'
|
||||||
|
case 'REVIEW_REQUESTED': return 'Überprüfung angefordert'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getEventDescription(event: ActivityEvent): string {
|
||||||
|
const actor = event.performedBy
|
||||||
|
const action = getEventLabel(event.type)
|
||||||
|
const entity = `${event.entityType} ${event.entityId}`
|
||||||
|
return `${actor} hat ${entity} — ${action}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function getEventColor(type: ActivityEventType): string {
|
||||||
|
switch (type) {
|
||||||
|
case 'PROPERTY_CREATED': return '#1e3a5f'
|
||||||
|
case 'PROPERTY_UPDATED': return '#1e3a5f'
|
||||||
|
case 'MATCH_APPROVED': return '#1a7a4a'
|
||||||
|
case 'MATCH_REJECTED': return '#c0392b'
|
||||||
|
case 'SIGNAL_VERIFIED': return '#7c3aed'
|
||||||
|
case 'NEED_CREATED': return '#0891b2'
|
||||||
|
case 'REVIEW_REQUESTED': return '#d97706'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getEventIcon(type: ActivityEventType) {
|
||||||
|
const size = 14
|
||||||
|
switch (type) {
|
||||||
|
case 'PROPERTY_CREATED': return <Building2 size={size} color="white" />
|
||||||
|
case 'PROPERTY_UPDATED': return <Edit size={size} color="white" />
|
||||||
|
case 'MATCH_APPROVED': return <CheckCircle size={size} color="white" />
|
||||||
|
case 'MATCH_REJECTED': return <XCircle size={size} color="white" />
|
||||||
|
case 'SIGNAL_VERIFIED': return <TrendingUp size={size} color="white" />
|
||||||
|
case 'NEED_CREATED': return <Search size={size} color="white" />
|
||||||
|
case 'REVIEW_REQUESTED': return <ClipboardList size={size} color="white" />
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const ALL_EVENT_TYPES: ActivityEventType[] = [
|
||||||
|
'PROPERTY_CREATED',
|
||||||
|
'PROPERTY_UPDATED',
|
||||||
|
'MATCH_APPROVED',
|
||||||
|
'MATCH_REJECTED',
|
||||||
|
'SIGNAL_VERIFIED',
|
||||||
|
'NEED_CREATED',
|
||||||
|
'REVIEW_REQUESTED',
|
||||||
|
]
|
||||||
|
|
||||||
|
function formatDateTime(dateStr: string): string {
|
||||||
|
const d = new Date(dateStr)
|
||||||
|
return d.toLocaleDateString('de-CH', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric',
|
||||||
|
}) + ', ' + d.toLocaleTimeString('de-CH', { hour: '2-digit', minute: '2-digit' })
|
||||||
|
}
|
||||||
|
|
||||||
|
function isToday(dateStr: string): boolean {
|
||||||
|
const d = new Date(dateStr)
|
||||||
|
const now = new Date()
|
||||||
|
return d.getFullYear() === now.getFullYear() &&
|
||||||
|
d.getMonth() === now.getMonth() &&
|
||||||
|
d.getDate() === now.getDate()
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Governance() {
|
||||||
|
const [filterType, setFilterType] = useState<ActivityEventType | 'ALL'>('ALL')
|
||||||
|
|
||||||
|
const { data: activityResp, isLoading, error } = useQuery({
|
||||||
|
queryKey: ['activity', 'org-wincasa'],
|
||||||
|
queryFn: () => governanceService.getActivityLog('org-wincasa'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const events = activityResp?.data ?? []
|
||||||
|
|
||||||
|
const presentTypes = [...new Set(events.map(e => e.type))]
|
||||||
|
const todayCount = events.filter(e => isToday(e.createdAt)).length
|
||||||
|
const uniqueUsers = new Set(events.map(e => e.performedBy)).size
|
||||||
|
|
||||||
|
const filtered = filterType === 'ALL'
|
||||||
|
? events
|
||||||
|
: events.filter(e => e.type === filterType)
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', py: 12 }}>
|
||||||
|
<CircularProgress />
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<Box sx={{ px: 3, py: 4 }}>
|
||||||
|
<Typography color="error">Fehler beim Laden des Aktivitätslogs.</Typography>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
{/* Page Header */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
bgcolor: 'white',
|
||||||
|
borderBottom: '1px solid #e2e8f0',
|
||||||
|
px: 3,
|
||||||
|
py: 2,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="h5" fontWeight={700} color="text.primary">
|
||||||
|
Governance & Aktivitätslog
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
Vollständiger Audit-Trail aller Plattformaktionen
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Button variant="outlined" size="small" disabled>
|
||||||
|
Exportieren
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ px: 3, py: 3 }}>
|
||||||
|
{/* Stats row */}
|
||||||
|
<Box className="grid grid-cols-3 gap-4" sx={{ mb: 3 }}>
|
||||||
|
<Card sx={{ p: 2.5 }}>
|
||||||
|
<Typography variant="overline" color="text.secondary" lineHeight={1.4} display="block">
|
||||||
|
Ereignisse gesamt
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="h3" fontWeight={700}>
|
||||||
|
{events.length}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" color="text.secondary">Alle Aktivitäten</Typography>
|
||||||
|
</Card>
|
||||||
|
<Card sx={{ p: 2.5 }}>
|
||||||
|
<Typography variant="overline" color="text.secondary" lineHeight={1.4} display="block">
|
||||||
|
Ereignisse heute
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="h3" fontWeight={700}>
|
||||||
|
{todayCount}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" color="text.secondary">Heutige Aktivitäten</Typography>
|
||||||
|
</Card>
|
||||||
|
<Card sx={{ p: 2.5 }}>
|
||||||
|
<Typography variant="overline" color="text.secondary" lineHeight={1.4} display="block">
|
||||||
|
Aktive Benutzer
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="h3" fontWeight={700}>
|
||||||
|
{uniqueUsers}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" color="text.secondary">Unterschiedliche Nutzer</Typography>
|
||||||
|
</Card>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Filter chips */}
|
||||||
|
<Stack direction="row" spacing={0.5} flexWrap="wrap" gap={0.5} sx={{ mb: 2 }}>
|
||||||
|
<Chip
|
||||||
|
label="Alle"
|
||||||
|
size="small"
|
||||||
|
clickable
|
||||||
|
onClick={() => setFilterType('ALL')}
|
||||||
|
sx={{
|
||||||
|
bgcolor: filterType === 'ALL' ? '#1e3a5f' : 'transparent',
|
||||||
|
color: filterType === 'ALL' ? 'white' : 'text.secondary',
|
||||||
|
border: `1px solid ${filterType === 'ALL' ? '#1e3a5f' : '#e2e8f0'}`,
|
||||||
|
fontWeight: filterType === 'ALL' ? 600 : 400,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{ALL_EVENT_TYPES.filter(t => presentTypes.includes(t)).map(t => (
|
||||||
|
<Chip
|
||||||
|
key={t}
|
||||||
|
label={getEventLabel(t)}
|
||||||
|
size="small"
|
||||||
|
clickable
|
||||||
|
onClick={() => setFilterType(t)}
|
||||||
|
sx={{
|
||||||
|
bgcolor: filterType === t ? getEventColor(t) : 'transparent',
|
||||||
|
color: filterType === t ? 'white' : 'text.secondary',
|
||||||
|
border: `1px solid ${filterType === t ? getEventColor(t) : '#e2e8f0'}`,
|
||||||
|
fontWeight: filterType === t ? 600 : 400,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{/* Activity Timeline */}
|
||||||
|
<Card sx={{ p: 2.5 }}>
|
||||||
|
<Typography variant="subtitle1" fontWeight={600} mb={2}>
|
||||||
|
Aktivitätslog
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{filtered.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
title="Keine Ereignisse"
|
||||||
|
description="Für diesen Filter wurden keine Aktivitäten gefunden."
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Box sx={{ position: 'relative' }}>
|
||||||
|
{/* Vertical line */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
position: 'absolute',
|
||||||
|
left: 15,
|
||||||
|
top: 16,
|
||||||
|
bottom: 16,
|
||||||
|
width: 2,
|
||||||
|
bgcolor: '#e2e8f0',
|
||||||
|
zIndex: 0,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Stack spacing={0}>
|
||||||
|
{filtered.map((event, idx) => (
|
||||||
|
<Box
|
||||||
|
key={event.id}
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
gap: 2,
|
||||||
|
py: 1.5,
|
||||||
|
borderBottom: idx < filtered.length - 1 ? '1px solid #f8fafc' : 'none',
|
||||||
|
position: 'relative',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Icon dot */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
borderRadius: '50%',
|
||||||
|
bgcolor: getEventColor(event.type),
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
flexShrink: 0,
|
||||||
|
zIndex: 1,
|
||||||
|
boxShadow: '0 0 0 3px white',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{getEventIcon(event.type)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<Box sx={{ flex: 1, minWidth: 0, pt: 0.5 }}>
|
||||||
|
<Typography variant="body2">
|
||||||
|
{getEventDescription(event)}
|
||||||
|
</Typography>
|
||||||
|
{event.notes && (
|
||||||
|
<Typography variant="caption" color="text.secondary" display="block" mt={0.25}>
|
||||||
|
{event.notes}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mt: 0.5 }}>
|
||||||
|
<Chip
|
||||||
|
label={event.organizationId}
|
||||||
|
size="small"
|
||||||
|
sx={{ fontSize: 10, height: 18, bgcolor: '#f1f5f9', color: '#475569' }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Timestamp */}
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
color="text.secondary"
|
||||||
|
sx={{ flexShrink: 0, pt: 0.5, textAlign: 'right', minWidth: 110 }}
|
||||||
|
>
|
||||||
|
{formatDateTime(event.createdAt)}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,380 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Chip,
|
||||||
|
Typography,
|
||||||
|
TextField,
|
||||||
|
Stack,
|
||||||
|
CircularProgress,
|
||||||
|
Divider,
|
||||||
|
Alert,
|
||||||
|
} from '@mui/material'
|
||||||
|
import { Target, TrendingUp } from 'lucide-react'
|
||||||
|
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { matchService } from '../../services/matchService'
|
||||||
|
import { futureSignalService } from '../../services/futureSignalService'
|
||||||
|
import { RiskLevel } from '../../domain/enums'
|
||||||
|
import { EmptyState } from '../../components/ui'
|
||||||
|
|
||||||
|
type ReviewItemType = 'MATCH' | 'SIGNAL'
|
||||||
|
|
||||||
|
interface ReviewItem {
|
||||||
|
id: string
|
||||||
|
type: ReviewItemType
|
||||||
|
title: string
|
||||||
|
confidence: number
|
||||||
|
risk?: RiskLevel
|
||||||
|
summary?: string
|
||||||
|
probability?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPriorityLabel(confidence: number): string {
|
||||||
|
return confidence > 0.8 ? 'Kritisch' : 'Normal'
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPriorityColor(confidence: number): 'error' | 'primary' {
|
||||||
|
return confidence > 0.8 ? 'error' : 'primary'
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRiskLabel(risk?: RiskLevel): string {
|
||||||
|
if (!risk) return '–'
|
||||||
|
switch (risk) {
|
||||||
|
case RiskLevel.LOW: return 'Niedrig'
|
||||||
|
case RiskLevel.MEDIUM: return 'Mittel'
|
||||||
|
case RiskLevel.HIGH: return 'Hoch'
|
||||||
|
case RiskLevel.CRITICAL: return 'Kritisch'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRiskColor(risk?: RiskLevel): 'success' | 'warning' | 'error' | 'default' {
|
||||||
|
if (!risk) return 'default'
|
||||||
|
if (risk === RiskLevel.LOW) return 'success'
|
||||||
|
if (risk === RiskLevel.MEDIUM) return 'warning'
|
||||||
|
return 'error'
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ReviewQueue() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const [activeItem, setActiveItem] = useState<string | null>(null)
|
||||||
|
const [reviewNotes, setReviewNotes] = useState('')
|
||||||
|
const [approvedIds, setApprovedIds] = useState<Set<string>>(new Set())
|
||||||
|
const [rejectedIds, setRejectedIds] = useState<Set<string>>(new Set())
|
||||||
|
|
||||||
|
const { data: matchResp, isLoading: matchLoading } = useQuery({
|
||||||
|
queryKey: ['matches'],
|
||||||
|
queryFn: () => matchService.getAll(),
|
||||||
|
})
|
||||||
|
const { data: signalResp, isLoading: signalLoading } = useQuery({
|
||||||
|
queryKey: ['futureSignals'],
|
||||||
|
queryFn: () => futureSignalService.getAll(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const matches = matchResp?.data ?? []
|
||||||
|
const signals = signalResp?.data ?? []
|
||||||
|
|
||||||
|
// Review items = matches NOT approved + future signals NOT verified
|
||||||
|
const matchItems: ReviewItem[] = matches
|
||||||
|
.filter(m => !m.isApproved && !approvedIds.has(m.id) && !rejectedIds.has(m.id))
|
||||||
|
.map(m => ({
|
||||||
|
id: m.id,
|
||||||
|
type: 'MATCH' as ReviewItemType,
|
||||||
|
title: `Match: ${m.propertyId} / ${m.needId}`,
|
||||||
|
confidence: m.confidenceLevel,
|
||||||
|
risk: m.riskLevel,
|
||||||
|
summary: m.explainabilitySummary,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const signalItems: ReviewItem[] = signals
|
||||||
|
.filter(s => !s.isVerified && !approvedIds.has(s.id) && !rejectedIds.has(s.id))
|
||||||
|
.map(s => ({
|
||||||
|
id: s.id,
|
||||||
|
type: 'SIGNAL' as ReviewItemType,
|
||||||
|
title: `${s.signalType}: ${s.locationHint}`,
|
||||||
|
confidence: s.confidenceScore,
|
||||||
|
risk: s.riskLevel,
|
||||||
|
probability: s.probability,
|
||||||
|
summary: s.disclaimer,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const allItems = [...matchItems, ...signalItems]
|
||||||
|
const selectedItem = allItems.find(i => i.id === activeItem)
|
||||||
|
|
||||||
|
const isLoading = matchLoading || signalLoading
|
||||||
|
|
||||||
|
const handleApprove = async () => {
|
||||||
|
if (!activeItem) return
|
||||||
|
const item = allItems.find(i => i.id === activeItem)
|
||||||
|
if (item?.type === 'MATCH') {
|
||||||
|
await matchService.approve(activeItem, 'admin@ideal-sharing.ch')
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ['matches'] })
|
||||||
|
} else if (item?.type === 'SIGNAL') {
|
||||||
|
await futureSignalService.verify(activeItem, 'admin@ideal-sharing.ch')
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ['futureSignals'] })
|
||||||
|
}
|
||||||
|
setApprovedIds(prev => new Set([...prev, activeItem]))
|
||||||
|
setActiveItem(null)
|
||||||
|
setReviewNotes('')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleReject = () => {
|
||||||
|
if (!activeItem) return
|
||||||
|
setRejectedIds(prev => new Set([...prev, activeItem]))
|
||||||
|
setActiveItem(null)
|
||||||
|
setReviewNotes('')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', py: 12 }}>
|
||||||
|
<CircularProgress />
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalPending = matchItems.length + signalItems.length
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
{/* Page Header */}
|
||||||
|
<Box sx={{ bgcolor: 'white', borderBottom: '1px solid #e2e8f0', px: 3, py: 2, display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||||
|
<Box>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
||||||
|
<Typography variant="h5" fontWeight={700} color="text.primary">
|
||||||
|
Review Queue
|
||||||
|
</Typography>
|
||||||
|
{totalPending > 0 && (
|
||||||
|
<Chip
|
||||||
|
label={totalPending}
|
||||||
|
size="small"
|
||||||
|
sx={{ bgcolor: '#d97706', color: 'white', fontWeight: 700 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
Human-in-the-loop Prüfung
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ px: 3, py: 3 }}>
|
||||||
|
{/* Stats row */}
|
||||||
|
<Box className="grid grid-cols-2 gap-4" sx={{ mb: 3 }}>
|
||||||
|
<Card sx={{ p: 2.5 }}>
|
||||||
|
<Typography variant="overline" color="text.secondary" lineHeight={1.4} display="block">
|
||||||
|
Offene Reviews
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="h3" fontWeight={700} sx={{ color: totalPending > 0 ? 'warning.main' : 'text.primary' }}>
|
||||||
|
{totalPending}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
Ausstehende Prüfungen
|
||||||
|
</Typography>
|
||||||
|
</Card>
|
||||||
|
<Card sx={{ p: 2.5 }}>
|
||||||
|
<Typography variant="overline" color="text.secondary" lineHeight={1.4} display="block">
|
||||||
|
Signale zur Prüfung
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="h3" fontWeight={700} sx={{ color: signalItems.length > 0 ? 'warning.main' : 'text.primary' }}>
|
||||||
|
{signalItems.length}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
Unverifizierte Signale
|
||||||
|
</Typography>
|
||||||
|
</Card>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Two-column layout */}
|
||||||
|
<Box className="grid grid-cols-2 gap-4" sx={{ alignItems: 'start' }}>
|
||||||
|
{/* Left: Item List */}
|
||||||
|
<Card sx={{ p: 0, overflow: 'hidden' }}>
|
||||||
|
<Box sx={{ px: 2, py: 1.5, borderBottom: '1px solid #f1f5f9' }}>
|
||||||
|
<Typography variant="subtitle2" fontWeight={600}>
|
||||||
|
Ausstehende Elemente
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{allItems.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
title="Keine ausstehenden Reviews"
|
||||||
|
description="Alle Elemente wurden geprüft."
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Box>
|
||||||
|
{allItems.map(item => (
|
||||||
|
<Box
|
||||||
|
key={item.id}
|
||||||
|
onClick={() => {
|
||||||
|
setActiveItem(item.id)
|
||||||
|
setReviewNotes('')
|
||||||
|
}}
|
||||||
|
sx={{
|
||||||
|
px: 2,
|
||||||
|
py: 1.5,
|
||||||
|
cursor: 'pointer',
|
||||||
|
borderBottom: '1px solid #f8fafc',
|
||||||
|
bgcolor: activeItem === item.id ? '#eff6ff' : 'white',
|
||||||
|
'&:hover': { bgcolor: activeItem === item.id ? '#eff6ff' : '#fafafa' },
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
gap: 1.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
borderRadius: '50%',
|
||||||
|
bgcolor: item.type === 'MATCH' ? '#eff6ff' : '#faf5ff',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
flexShrink: 0,
|
||||||
|
mt: 0.25,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.type === 'MATCH'
|
||||||
|
? <Target size={16} color="#1e3a5f" />
|
||||||
|
: <TrendingUp size={16} color="#7c3aed" />
|
||||||
|
}
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<Typography variant="body2" fontWeight={500} noWrap>
|
||||||
|
{item.title}
|
||||||
|
</Typography>
|
||||||
|
<Stack direction="row" spacing={0.5} mt={0.5} flexWrap="wrap">
|
||||||
|
<Chip
|
||||||
|
label={getPriorityLabel(item.confidence)}
|
||||||
|
size="small"
|
||||||
|
color={getPriorityColor(item.confidence)}
|
||||||
|
variant="outlined"
|
||||||
|
sx={{ fontSize: 10 }}
|
||||||
|
/>
|
||||||
|
<Chip
|
||||||
|
label="Ausstehend"
|
||||||
|
size="small"
|
||||||
|
sx={{ bgcolor: '#fef3c7', color: '#92400e', fontSize: 10 }}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Right: Review Panel */}
|
||||||
|
<Card sx={{ p: 0, overflow: 'hidden' }}>
|
||||||
|
{!selectedItem ? (
|
||||||
|
<EmptyState
|
||||||
|
title="Wählen Sie ein Element zur Prüfung"
|
||||||
|
description="Klicken Sie auf ein Element in der Liste, um es zu prüfen."
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Box>
|
||||||
|
<Box sx={{ px: 2.5, py: 2, borderBottom: '1px solid #f1f5f9' }}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.5 }}>
|
||||||
|
<Chip
|
||||||
|
label={selectedItem.type === 'MATCH' ? 'Match' : 'Signal'}
|
||||||
|
size="small"
|
||||||
|
sx={{
|
||||||
|
bgcolor: selectedItem.type === 'MATCH' ? '#eff6ff' : '#faf5ff',
|
||||||
|
color: selectedItem.type === 'MATCH' ? '#1e3a5f' : '#7c3aed',
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Typography variant="subtitle2" fontWeight={600}>
|
||||||
|
{selectedItem.title}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ px: 2.5, py: 2 }}>
|
||||||
|
{/* Key facts */}
|
||||||
|
<Stack spacing={1} sx={{ mb: 2 }}>
|
||||||
|
<Box sx={{ display: 'flex', gap: 3 }}>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="caption" color="text.secondary" display="block">Konfidenz</Typography>
|
||||||
|
<Typography variant="body2" fontWeight={600}>
|
||||||
|
{Math.round(selectedItem.confidence * 100)}%
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
{selectedItem.probability != null && (
|
||||||
|
<Box>
|
||||||
|
<Typography variant="caption" color="text.secondary" display="block">Wahrscheinlichkeit</Typography>
|
||||||
|
<Typography variant="body2" fontWeight={600}>
|
||||||
|
{Math.round(selectedItem.probability * 100)}%
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
<Box>
|
||||||
|
<Typography variant="caption" color="text.secondary" display="block">Risiko</Typography>
|
||||||
|
<Chip
|
||||||
|
label={getRiskLabel(selectedItem.risk)}
|
||||||
|
size="small"
|
||||||
|
color={getRiskColor(selectedItem.risk)}
|
||||||
|
variant="outlined"
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{selectedItem.summary && (
|
||||||
|
<Alert severity="info" sx={{ mb: 2, '& .MuiAlert-message': { fontSize: 13 } }}>
|
||||||
|
{selectedItem.summary}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Divider sx={{ mb: 2 }} />
|
||||||
|
|
||||||
|
{/* Notes */}
|
||||||
|
<Typography variant="caption" fontWeight={600} color="text.secondary" display="block" mb={1}>
|
||||||
|
Notizen
|
||||||
|
</Typography>
|
||||||
|
<TextField
|
||||||
|
multiline
|
||||||
|
rows={3}
|
||||||
|
fullWidth
|
||||||
|
placeholder="Optionale Anmerkungen zur Entscheidung..."
|
||||||
|
value={reviewNotes}
|
||||||
|
onChange={e => setReviewNotes(e.target.value)}
|
||||||
|
size="small"
|
||||||
|
sx={{ mb: 2 }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Decision buttons */}
|
||||||
|
<Stack spacing={1}>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
fullWidth
|
||||||
|
onClick={handleApprove}
|
||||||
|
sx={{ bgcolor: '#1a7a4a', '&:hover': { bgcolor: '#155f3a' } }}
|
||||||
|
>
|
||||||
|
Genehmigen
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
fullWidth
|
||||||
|
color="error"
|
||||||
|
onClick={handleReject}
|
||||||
|
>
|
||||||
|
Ablehnen
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
fullWidth
|
||||||
|
sx={{ color: '#64748b', borderColor: '#e2e8f0' }}
|
||||||
|
>
|
||||||
|
Weiterleiten
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,309 @@
|
|||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Box,
|
||||||
|
Card,
|
||||||
|
Chip,
|
||||||
|
LinearProgress,
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableRow,
|
||||||
|
Typography,
|
||||||
|
} from '@mui/material'
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { ErrorState, LoadingPage, SectionContainer } from '../../components/ui'
|
||||||
|
import { propertyService } from '../../services/propertyService'
|
||||||
|
import { DataFreshness, ResultType } from '../../domain/enums'
|
||||||
|
|
||||||
|
function getResultTypeLabel(type: ResultType): string {
|
||||||
|
switch (type) {
|
||||||
|
case ResultType.VERIFIED_PORTFOLIO: return 'Verified Portfolio'
|
||||||
|
case ResultType.EXTERNAL_MARKET: return 'Marktinserat'
|
||||||
|
case ResultType.FUTURE_AVAILABILITY: return 'Zukunftssignal'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getQualityColor(score: number): 'success' | 'warning' | 'error' {
|
||||||
|
if (score >= 0.8) return 'success'
|
||||||
|
if (score >= 0.6) return 'warning'
|
||||||
|
return 'error'
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFreshnessLabel(freshness: DataFreshness): string {
|
||||||
|
switch (freshness) {
|
||||||
|
case DataFreshness.FRESH: return 'Aktuell'
|
||||||
|
case DataFreshness.STALE: return 'Veraltet'
|
||||||
|
case DataFreshness.OUTDATED: return 'Abgelaufen'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFreshnessColor(freshness: DataFreshness): 'success' | 'warning' | 'error' {
|
||||||
|
switch (freshness) {
|
||||||
|
case DataFreshness.FRESH: return 'success'
|
||||||
|
case DataFreshness.STALE: return 'warning'
|
||||||
|
case DataFreshness.OUTDATED: return 'error'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DataQuality() {
|
||||||
|
const { data: resp, isLoading, error } = useQuery({
|
||||||
|
queryKey: ['properties'],
|
||||||
|
queryFn: () => propertyService.getAll(),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (isLoading) return <LoadingPage />
|
||||||
|
if (error) return <ErrorState />
|
||||||
|
|
||||||
|
const properties = resp?.data ?? []
|
||||||
|
|
||||||
|
const avgScore = properties.length
|
||||||
|
? properties.reduce((sum, p) => sum + p.dataQuality.score, 0) / properties.length
|
||||||
|
: 0
|
||||||
|
|
||||||
|
const criticalIssues = properties.filter(p => p.dataQuality.missingCriticalFields.length > 0)
|
||||||
|
const staleData = properties.filter(
|
||||||
|
p => p.dataQuality.freshness === DataFreshness.STALE || p.dataQuality.freshness === DataFreshness.OUTDATED
|
||||||
|
)
|
||||||
|
const highQuality = properties.filter(p => p.dataQuality.score >= 0.8)
|
||||||
|
const medQuality = properties.filter(p => p.dataQuality.score >= 0.6 && p.dataQuality.score < 0.8)
|
||||||
|
const lowQuality = properties.filter(p => p.dataQuality.score < 0.6)
|
||||||
|
|
||||||
|
// Sort by score ascending (worst first)
|
||||||
|
const sortedProperties = [...properties].sort((a, b) => a.dataQuality.score - b.dataQuality.score)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
{/* Page Header */}
|
||||||
|
<Box sx={{ bgcolor: 'white', borderBottom: '1px solid', borderColor: 'divider', px: 3, py: 2 }}>
|
||||||
|
<Typography variant="h5" fontWeight={700} color="text.primary">Datenqualität</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary">Vollständigkeit und Aktualität der Objektdaten</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<Box sx={{ px: 3, py: 3 }} className="flex flex-col gap-4">
|
||||||
|
|
||||||
|
{/* Summary Stats Row */}
|
||||||
|
<Box className="grid grid-cols-3 gap-4">
|
||||||
|
{/* Avg Quality Score */}
|
||||||
|
<Card sx={{ elevation: 1, p: 2.5 }}>
|
||||||
|
<Typography variant="overline" color="text.secondary" display="block">Ø Qualitätsscore</Typography>
|
||||||
|
<Typography variant="h4" fontWeight={700} sx={{ color: avgScore >= 0.8 ? '#1a7a4a' : avgScore >= 0.6 ? '#d97706' : '#c0392b' }}>
|
||||||
|
{Math.round(avgScore * 100)}%
|
||||||
|
</Typography>
|
||||||
|
<Box sx={{ mt: 1 }}>
|
||||||
|
<LinearProgress
|
||||||
|
variant="determinate"
|
||||||
|
value={avgScore * 100}
|
||||||
|
color={getQualityColor(avgScore)}
|
||||||
|
sx={{ height: 8, borderRadius: 4 }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Critical Issues */}
|
||||||
|
<Card sx={{ elevation: 1, p: 2.5 }}>
|
||||||
|
<Typography variant="overline" color="text.secondary" display="block">Kritische Felder fehlen</Typography>
|
||||||
|
<Typography
|
||||||
|
variant="h4"
|
||||||
|
fontWeight={700}
|
||||||
|
sx={{ color: criticalIssues.length > 2 ? '#c0392b' : criticalIssues.length > 0 ? '#d97706' : '#1a7a4a' }}
|
||||||
|
>
|
||||||
|
{criticalIssues.length}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
von {properties.length} Objekten
|
||||||
|
</Typography>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Stale Data */}
|
||||||
|
<Card sx={{ elevation: 1, p: 2.5 }}>
|
||||||
|
<Typography variant="overline" color="text.secondary" display="block">Veraltete Daten</Typography>
|
||||||
|
<Typography
|
||||||
|
variant="h4"
|
||||||
|
fontWeight={700}
|
||||||
|
sx={{ color: staleData.length > 2 ? '#c0392b' : staleData.length > 0 ? '#d97706' : '#1a7a4a' }}
|
||||||
|
>
|
||||||
|
{staleData.length}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
von {properties.length} Objekten
|
||||||
|
</Typography>
|
||||||
|
</Card>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Quality Distribution */}
|
||||||
|
<SectionContainer title="Qualitätsverteilung">
|
||||||
|
<Card sx={{ elevation: 1, p: 2.5 }}>
|
||||||
|
<Box className="flex flex-col gap-3">
|
||||||
|
{/* High */}
|
||||||
|
<Box className="flex items-center gap-3">
|
||||||
|
<Typography variant="body2" sx={{ width: 120, flexShrink: 0 }}>Hoch (≥80%)</Typography>
|
||||||
|
<Chip label={highQuality.length} size="small" color="success" />
|
||||||
|
<Box className="flex-1">
|
||||||
|
<LinearProgress
|
||||||
|
variant="determinate"
|
||||||
|
value={properties.length > 0 ? (highQuality.length / properties.length) * 100 : 0}
|
||||||
|
color="success"
|
||||||
|
sx={{ height: 10, borderRadius: 5 }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
<Typography variant="caption" color="text.secondary" sx={{ width: 40, textAlign: 'right' }}>
|
||||||
|
{properties.length > 0 ? Math.round((highQuality.length / properties.length) * 100) : 0}%
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Medium */}
|
||||||
|
<Box className="flex items-center gap-3">
|
||||||
|
<Typography variant="body2" sx={{ width: 120, flexShrink: 0 }}>Mittel (60–79%)</Typography>
|
||||||
|
<Chip label={medQuality.length} size="small" color="warning" />
|
||||||
|
<Box className="flex-1">
|
||||||
|
<LinearProgress
|
||||||
|
variant="determinate"
|
||||||
|
value={properties.length > 0 ? (medQuality.length / properties.length) * 100 : 0}
|
||||||
|
color="warning"
|
||||||
|
sx={{ height: 10, borderRadius: 5 }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
<Typography variant="caption" color="text.secondary" sx={{ width: 40, textAlign: 'right' }}>
|
||||||
|
{properties.length > 0 ? Math.round((medQuality.length / properties.length) * 100) : 0}%
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Low */}
|
||||||
|
<Box className="flex items-center gap-3">
|
||||||
|
<Typography variant="body2" sx={{ width: 120, flexShrink: 0 }}>Niedrig ({'<'}60%)</Typography>
|
||||||
|
<Chip label={lowQuality.length} size="small" color="error" />
|
||||||
|
<Box className="flex-1">
|
||||||
|
<LinearProgress
|
||||||
|
variant="determinate"
|
||||||
|
value={properties.length > 0 ? (lowQuality.length / properties.length) * 100 : 0}
|
||||||
|
color="error"
|
||||||
|
sx={{ height: 10, borderRadius: 5 }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
<Typography variant="caption" color="text.secondary" sx={{ width: 40, textAlign: 'right' }}>
|
||||||
|
{properties.length > 0 ? Math.round((lowQuality.length / properties.length) * 100) : 0}%
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Card>
|
||||||
|
</SectionContainer>
|
||||||
|
|
||||||
|
{/* Properties Quality Table */}
|
||||||
|
<SectionContainer title="Objektübersicht Datenqualität">
|
||||||
|
<Card sx={{ elevation: 1 }}>
|
||||||
|
<Table size="small">
|
||||||
|
<TableHead>
|
||||||
|
<TableRow sx={{ bgcolor: 'grey.50' }}>
|
||||||
|
<TableCell><Typography variant="caption" fontWeight={600}>Objekt</Typography></TableCell>
|
||||||
|
<TableCell><Typography variant="caption" fontWeight={600}>Quelle</Typography></TableCell>
|
||||||
|
<TableCell><Typography variant="caption" fontWeight={600}>Score</Typography></TableCell>
|
||||||
|
<TableCell><Typography variant="caption" fontWeight={600}>Kritische Felder</Typography></TableCell>
|
||||||
|
<TableCell><Typography variant="caption" fontWeight={600}>Optionale Felder</Typography></TableCell>
|
||||||
|
<TableCell><Typography variant="caption" fontWeight={600}>Aktualität</Typography></TableCell>
|
||||||
|
<TableCell><Typography variant="caption" fontWeight={600}>Warnungen</Typography></TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{sortedProperties.map(property => {
|
||||||
|
const hasCritical = property.dataQuality.missingCriticalFields.length > 0
|
||||||
|
const missingCritical = property.dataQuality.missingCriticalFields
|
||||||
|
const missingOptional = property.dataQuality.missingOptionalFields
|
||||||
|
const score = property.dataQuality.score
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TableRow
|
||||||
|
key={property.id}
|
||||||
|
hover
|
||||||
|
sx={hasCritical ? { bgcolor: 'rgba(192,57,43,0.04)' } : {}}
|
||||||
|
>
|
||||||
|
{/* Objekt */}
|
||||||
|
<TableCell>
|
||||||
|
<Typography variant="body2" fontWeight={500}>{property.title}</Typography>
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
{/* Quelle */}
|
||||||
|
<TableCell>
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
{getResultTypeLabel(property.resultType)}
|
||||||
|
</Typography>
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
{/* Score */}
|
||||||
|
<TableCell>
|
||||||
|
<Box sx={{ width: 100 }}>
|
||||||
|
<Box className="flex items-center justify-between mb-1">
|
||||||
|
<Typography variant="caption" fontWeight={700} sx={{ color: score >= 0.8 ? '#1a7a4a' : score >= 0.6 ? '#d97706' : '#c0392b' }}>
|
||||||
|
{Math.round(score * 100)}%
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<LinearProgress
|
||||||
|
variant="determinate"
|
||||||
|
value={score * 100}
|
||||||
|
color={getQualityColor(score)}
|
||||||
|
sx={{ height: 5, borderRadius: 2 }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
{/* Kritische Felder */}
|
||||||
|
<TableCell>
|
||||||
|
{missingCritical.length === 0 ? (
|
||||||
|
<Chip label="Vollständig" color="success" size="small" />
|
||||||
|
) : (
|
||||||
|
<Box className="flex flex-wrap gap-1 items-center">
|
||||||
|
{missingCritical.slice(0, 2).map(f => (
|
||||||
|
<Chip key={f} label={f} color="error" size="small" variant="outlined" />
|
||||||
|
))}
|
||||||
|
{missingCritical.length > 2 && (
|
||||||
|
<Typography variant="caption" color="error.main" fontWeight={600}>
|
||||||
|
+{missingCritical.length - 2} weitere
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
{/* Optionale Felder */}
|
||||||
|
<TableCell>
|
||||||
|
{missingOptional.length === 0 ? (
|
||||||
|
<Typography variant="caption" color="text.secondary">–</Typography>
|
||||||
|
) : (
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
{missingOptional.length} fehlen
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
{/* Aktualität */}
|
||||||
|
<TableCell>
|
||||||
|
<Chip
|
||||||
|
label={getFreshnessLabel(property.dataQuality.freshness)}
|
||||||
|
color={getFreshnessColor(property.dataQuality.freshness)}
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
{/* Warnungen */}
|
||||||
|
<TableCell>
|
||||||
|
{property.dataQuality.warnings.length > 0 ? (
|
||||||
|
<Alert severity="warning" sx={{ py: 0, px: 1, fontSize: 11 }}>
|
||||||
|
{property.dataQuality.warnings[0]}
|
||||||
|
</Alert>
|
||||||
|
) : (
|
||||||
|
<Typography variant="caption" color="text.secondary">–</Typography>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</Card>
|
||||||
|
</SectionContainer>
|
||||||
|
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Chip,
|
||||||
|
Divider,
|
||||||
|
LinearProgress,
|
||||||
|
Typography,
|
||||||
|
} from '@mui/material'
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { EmptyState, ErrorState, LoadingPage } from '../../components/ui'
|
||||||
|
import { futureSignalService } from '../../services/futureSignalService'
|
||||||
|
import { SignalType } from '../../domain/enums'
|
||||||
|
|
||||||
|
function getSignalTypeLabel(type: SignalType): string {
|
||||||
|
switch (type) {
|
||||||
|
case SignalType.EXPANSION: return 'Expansion'
|
||||||
|
case SignalType.POSSIBLE_MOVE_OUT: return 'Möglicher Auszug'
|
||||||
|
case SignalType.CONSTRUCTION_PROJECT: return 'Bauprojekt'
|
||||||
|
case SignalType.RESTRUCTURING: return 'Restrukturierung'
|
||||||
|
case SignalType.PROJECT_DEVELOPMENT: return 'Projektentwicklung'
|
||||||
|
case SignalType.SPACE_CONSOLIDATION: return 'Flächenkonsolidierung'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSignalTypeColor(type: SignalType): string {
|
||||||
|
switch (type) {
|
||||||
|
case SignalType.EXPANSION: return '#1a7a4a'
|
||||||
|
case SignalType.POSSIBLE_MOVE_OUT: return '#d97706'
|
||||||
|
case SignalType.CONSTRUCTION_PROJECT: return '#1e3a5f'
|
||||||
|
case SignalType.RESTRUCTURING: return '#ea580c'
|
||||||
|
case SignalType.PROJECT_DEVELOPMENT: return '#7c3aed'
|
||||||
|
case SignalType.SPACE_CONSOLIDATION: return '#6b7280'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getProbabilityColor(prob: number): 'success' | 'warning' | 'error' {
|
||||||
|
if (prob > 0.7) return 'success'
|
||||||
|
if (prob >= 0.5) return 'warning'
|
||||||
|
return 'error'
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(dateStr?: string): string {
|
||||||
|
if (!dateStr) return '–'
|
||||||
|
return new Date(dateStr).toLocaleDateString('de-CH', { day: '2-digit', month: '2-digit', year: 'numeric' })
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSourceTypeLabel(type: string): string {
|
||||||
|
switch (type) {
|
||||||
|
case 'PRESS': return 'Pressebericht'
|
||||||
|
case 'CONSTRUCTION_PERMIT': return 'Baubewilligung'
|
||||||
|
case 'JOB_POSTING': return 'Stelleninserat'
|
||||||
|
case 'COMPANY_REPORT': return 'Geschäftsbericht'
|
||||||
|
case 'MARKET_DATA': return 'Marktdaten'
|
||||||
|
case 'MANUAL': return 'Manuell'
|
||||||
|
default: return type
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FutureAvailability() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
const { data: resp, isLoading, error } = useQuery({
|
||||||
|
queryKey: ['futureSignals'],
|
||||||
|
queryFn: () => futureSignalService.getAll(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const verifyMutation = useMutation({
|
||||||
|
mutationFn: (signalId: string) => futureSignalService.verify(signalId, 'admin@ideal-sharing.ch'),
|
||||||
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['futureSignals'] }),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (isLoading) return <LoadingPage />
|
||||||
|
if (error) return <ErrorState />
|
||||||
|
|
||||||
|
const signals = resp?.data ?? []
|
||||||
|
|
||||||
|
const totalCount = signals.length
|
||||||
|
const verifiedCount = signals.filter(s => s.isVerified).length
|
||||||
|
const highProbCount = signals.filter(s => s.probability > 0.7).length
|
||||||
|
const avgProbability = signals.length
|
||||||
|
? signals.reduce((sum, s) => sum + s.probability, 0) / signals.length
|
||||||
|
: 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
{/* Page Header */}
|
||||||
|
<Box sx={{ bgcolor: 'white', borderBottom: '1px solid', borderColor: 'divider', px: 3, py: 2 }} className="flex items-center gap-3">
|
||||||
|
<Box className="flex-1">
|
||||||
|
<Box className="flex items-center gap-2">
|
||||||
|
<Typography variant="h5" fontWeight={700} color="text.primary">Marktchancen</Typography>
|
||||||
|
<Chip
|
||||||
|
label="Shadow Intelligence Layer"
|
||||||
|
size="small"
|
||||||
|
sx={{ bgcolor: '#7c3aed', color: 'white', fontWeight: 600 }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
<Typography variant="body2" color="text.secondary">KI-generierte Verfügbarkeitssignale</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<Box sx={{ px: 3, py: 3 }} className="flex flex-col gap-4">
|
||||||
|
|
||||||
|
{/* Stats Row */}
|
||||||
|
<Box className="grid grid-cols-4 gap-4">
|
||||||
|
<Card sx={{ elevation: 1, p: 2.5 }}>
|
||||||
|
<Typography variant="overline" color="text.secondary" display="block">Signale gesamt</Typography>
|
||||||
|
<Typography variant="h4" fontWeight={700}>{totalCount}</Typography>
|
||||||
|
</Card>
|
||||||
|
<Card sx={{ elevation: 1, p: 2.5 }}>
|
||||||
|
<Typography variant="overline" color="text.secondary" display="block">Verifiziert</Typography>
|
||||||
|
<Typography variant="h4" fontWeight={700} sx={{ color: '#1a7a4a' }}>{verifiedCount}</Typography>
|
||||||
|
</Card>
|
||||||
|
<Card sx={{ elevation: 1, p: 2.5 }}>
|
||||||
|
<Typography variant="overline" color="text.secondary" display="block">Hohe Wahrscheinlichkeit</Typography>
|
||||||
|
<Typography variant="h4" fontWeight={700} sx={{ color: '#1e3a5f' }}>{highProbCount}</Typography>
|
||||||
|
</Card>
|
||||||
|
<Card sx={{ elevation: 1, p: 2.5 }}>
|
||||||
|
<Typography variant="overline" color="text.secondary" display="block">Ø Wahrscheinlichkeit</Typography>
|
||||||
|
<Typography variant="h4" fontWeight={700} sx={{ color: getProbabilityColor(avgProbability) === 'success' ? '#1a7a4a' : getProbabilityColor(avgProbability) === 'warning' ? '#d97706' : '#c0392b' }}>
|
||||||
|
{Math.round(avgProbability * 100)}%
|
||||||
|
</Typography>
|
||||||
|
</Card>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Signal Cards Grid */}
|
||||||
|
{signals.length === 0 ? (
|
||||||
|
<EmptyState title="Keine Signale gefunden" description="Es sind noch keine Zukunftssignale vorhanden." />
|
||||||
|
) : (
|
||||||
|
<Box className="flex flex-wrap gap-4">
|
||||||
|
{signals.map(signal => (
|
||||||
|
<Card key={signal.id} sx={{ elevation: 1, p: 2, flex: '1 1 calc(50% - 16px)', minWidth: 320 }}>
|
||||||
|
{/* Card Header */}
|
||||||
|
<Box className="flex items-center justify-between mb-2">
|
||||||
|
<Chip
|
||||||
|
label={getSignalTypeLabel(signal.signalType)}
|
||||||
|
size="small"
|
||||||
|
sx={{ bgcolor: getSignalTypeColor(signal.signalType), color: 'white', fontWeight: 600 }}
|
||||||
|
/>
|
||||||
|
<Chip
|
||||||
|
label={signal.sensitivityLevel === 'PUBLIC' ? 'Öffentlich' : signal.sensitivityLevel === 'CONFIDENTIAL' ? 'Vertraulich' : 'Intern'}
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
color={signal.sensitivityLevel === 'CONFIDENTIAL' ? 'error' : 'default'}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Location */}
|
||||||
|
<Box className="mb-2">
|
||||||
|
<Typography variant="body1" fontWeight={500}>{signal.locationHint}</Typography>
|
||||||
|
{signal.companyName && (
|
||||||
|
<Typography variant="body2" color="text.secondary">{signal.companyName}</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Probability */}
|
||||||
|
<Box className="mb-2">
|
||||||
|
<Box className="flex items-center justify-between mb-1">
|
||||||
|
<Typography variant="caption" color="text.secondary">Wahrscheinlichkeit</Typography>
|
||||||
|
<Typography variant="body2" fontWeight={700} sx={{ color: signal.probability > 0.7 ? '#1a7a4a' : signal.probability >= 0.5 ? '#d97706' : '#c0392b' }}>
|
||||||
|
{Math.round(signal.probability * 100)}%
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<LinearProgress
|
||||||
|
variant="determinate"
|
||||||
|
value={signal.probability * 100}
|
||||||
|
color={getProbabilityColor(signal.probability)}
|
||||||
|
sx={{ height: 6, borderRadius: 3 }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Details */}
|
||||||
|
<Box className="flex flex-wrap gap-2 mb-2">
|
||||||
|
{signal.areaSqmEstimate && (
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
Fläche: ca. {signal.areaSqmEstimate.toLocaleString('de-CH')} m²
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
Zeithorizont: {signal.timeHorizonMonths} Monate
|
||||||
|
</Typography>
|
||||||
|
{signal.expiresAt && (
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
Verfügbar ab: {formatDate(signal.expiresAt)}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Source */}
|
||||||
|
<Box className="mb-2">
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
Quelle: {getSourceTypeLabel(signal.source.type)} — Glaubwürdigkeit:{' '}
|
||||||
|
<span style={{ color: signal.source.credibility === 'HIGH' ? '#1a7a4a' : signal.source.credibility === 'MEDIUM' ? '#d97706' : '#c0392b', fontWeight: 600 }}>
|
||||||
|
{signal.source.credibility === 'HIGH' ? 'Hoch' : signal.source.credibility === 'MEDIUM' ? 'Mittel' : 'Niedrig'}
|
||||||
|
</span>
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Verification Status */}
|
||||||
|
<Box className="mb-2">
|
||||||
|
{signal.isVerified ? (
|
||||||
|
<Box className="flex items-center gap-2">
|
||||||
|
<Chip label="Verifiziert" color="success" size="small" />
|
||||||
|
<Typography variant="caption" color="text.secondary">{formatDate(signal.verifiedAt)}</Typography>
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<Chip label="Nicht verifiziert" color="warning" size="small" variant="outlined" />
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Divider sx={{ my: 1 }} />
|
||||||
|
|
||||||
|
{/* Footer buttons */}
|
||||||
|
<Box className="flex items-center gap-2">
|
||||||
|
{!signal.isVerified && (
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
size="small"
|
||||||
|
onClick={() => verifyMutation.mutate(signal.id)}
|
||||||
|
disabled={verifyMutation.isPending}
|
||||||
|
>
|
||||||
|
Verifizieren
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button variant="outlined" size="small" disabled>
|
||||||
|
Zu Shortlist
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,283 @@
|
|||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Chip,
|
||||||
|
Divider,
|
||||||
|
LinearProgress,
|
||||||
|
Typography,
|
||||||
|
} from '@mui/material'
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { Building2 } from 'lucide-react'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { EmptyState, ErrorState, LoadingPage } from '../../components/ui'
|
||||||
|
import { matchService } from '../../services/matchService'
|
||||||
|
import { propertyService } from '../../services/propertyService'
|
||||||
|
import { needService } from '../../services/needService'
|
||||||
|
import { MatchStrength, RiskLevel } from '../../domain/enums'
|
||||||
|
|
||||||
|
function getMatchStrengthLabel(strength: MatchStrength): string {
|
||||||
|
switch (strength) {
|
||||||
|
case MatchStrength.STRONG: return 'Stark'
|
||||||
|
case MatchStrength.MODERATE: return 'Mittel'
|
||||||
|
case MatchStrength.WEAK: return 'Schwach'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMatchStrengthColor(strength: MatchStrength): 'success' | 'warning' | 'error' {
|
||||||
|
switch (strength) {
|
||||||
|
case MatchStrength.STRONG: return 'success'
|
||||||
|
case MatchStrength.MODERATE: return 'warning'
|
||||||
|
case MatchStrength.WEAK: return 'error'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getScoreColor(strength: MatchStrength): string {
|
||||||
|
switch (strength) {
|
||||||
|
case MatchStrength.STRONG: return '#1a7a4a'
|
||||||
|
case MatchStrength.MODERATE: return '#d97706'
|
||||||
|
case MatchStrength.WEAK: return '#c0392b'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getConfidenceColor(score: number): 'success' | 'primary' | 'warning' {
|
||||||
|
if (score >= 0.85) return 'success'
|
||||||
|
if (score >= 0.65) return 'primary'
|
||||||
|
return 'warning'
|
||||||
|
}
|
||||||
|
|
||||||
|
function getConfidenceLabel(score: number): string {
|
||||||
|
if (score >= 0.85) return 'Hoch'
|
||||||
|
if (score >= 0.65) return 'Mittel'
|
||||||
|
return 'Niedrig'
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRiskLabel(level: RiskLevel): string {
|
||||||
|
switch (level) {
|
||||||
|
case RiskLevel.LOW: return 'Niedriges Risiko'
|
||||||
|
case RiskLevel.MEDIUM: return 'Mittleres Risiko'
|
||||||
|
case RiskLevel.HIGH: return 'Hohes Risiko'
|
||||||
|
case RiskLevel.CRITICAL: return 'Kritisches Risiko'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRiskColor(level: RiskLevel): 'success' | 'warning' | 'error' {
|
||||||
|
switch (level) {
|
||||||
|
case RiskLevel.LOW: return 'success'
|
||||||
|
case RiskLevel.MEDIUM: return 'warning'
|
||||||
|
case RiskLevel.HIGH: return 'error'
|
||||||
|
case RiskLevel.CRITICAL: return 'error'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MatchCenter() {
|
||||||
|
const [filterStrength, setFilterStrength] = useState<MatchStrength | 'ALL'>('ALL')
|
||||||
|
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
const { data: matchResp, isLoading: matchLoading, error: matchError } = useQuery({
|
||||||
|
queryKey: ['matches'],
|
||||||
|
queryFn: () => matchService.getAll(),
|
||||||
|
})
|
||||||
|
const { data: propResp, isLoading: propLoading, error: propError } = useQuery({
|
||||||
|
queryKey: ['properties'],
|
||||||
|
queryFn: () => propertyService.getAll(),
|
||||||
|
})
|
||||||
|
const { data: needResp, isLoading: needLoading, error: needError } = useQuery({
|
||||||
|
queryKey: ['needs'],
|
||||||
|
queryFn: () => needService.getAll(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const approveMutation = useMutation({
|
||||||
|
mutationFn: (matchId: string) => matchService.approve(matchId, 'admin@ideal-sharing.ch'),
|
||||||
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['matches'] }),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (matchLoading || propLoading || needLoading) return <LoadingPage />
|
||||||
|
if (matchError || propError || needError) return <ErrorState />
|
||||||
|
|
||||||
|
const matches = matchResp?.data ?? []
|
||||||
|
const properties = propResp?.data ?? []
|
||||||
|
const needs = needResp?.data ?? []
|
||||||
|
|
||||||
|
const filtered = filterStrength === 'ALL'
|
||||||
|
? matches
|
||||||
|
: matches.filter(m => m.matchStrength === filterStrength)
|
||||||
|
|
||||||
|
const sortedFiltered = [...filtered].sort((a, b) => b.matchScore - a.matchScore)
|
||||||
|
|
||||||
|
const strengthFilters: { value: MatchStrength | 'ALL'; label: string }[] = [
|
||||||
|
{ value: 'ALL', label: 'Alle' },
|
||||||
|
{ value: MatchStrength.STRONG, label: 'Stark' },
|
||||||
|
{ value: MatchStrength.MODERATE, label: 'Mittel' },
|
||||||
|
{ value: MatchStrength.WEAK, label: 'Schwach' },
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
{/* Page Header */}
|
||||||
|
<Box sx={{ bgcolor: 'white', borderBottom: '1px solid', borderColor: 'divider', px: 3, py: 2 }} className="flex items-center justify-between">
|
||||||
|
<Box>
|
||||||
|
<Box className="flex items-center gap-2">
|
||||||
|
<Typography variant="h5" fontWeight={700} color="text.primary">Match Center</Typography>
|
||||||
|
<Chip label={`${matches.length} Matches`} size="small" sx={{ bgcolor: '#1e3a5f', color: 'white', fontWeight: 700 }} />
|
||||||
|
</Box>
|
||||||
|
<Typography variant="body2" color="text.secondary">KI-gestützte Objekt-Bedarfs-Analyse</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<Box sx={{ px: 3, py: 3 }} className="flex flex-col gap-3">
|
||||||
|
|
||||||
|
{/* Filter Chips */}
|
||||||
|
<Box className="flex items-center gap-2">
|
||||||
|
{strengthFilters.map(f => (
|
||||||
|
<Chip
|
||||||
|
key={f.value}
|
||||||
|
label={f.label}
|
||||||
|
variant={filterStrength === f.value ? 'filled' : 'outlined'}
|
||||||
|
size="small"
|
||||||
|
onClick={() => setFilterStrength(f.value)}
|
||||||
|
color={
|
||||||
|
f.value === MatchStrength.STRONG ? 'success'
|
||||||
|
: f.value === MatchStrength.MODERATE ? 'warning'
|
||||||
|
: f.value === MatchStrength.WEAK ? 'error'
|
||||||
|
: 'default'
|
||||||
|
}
|
||||||
|
sx={{ cursor: 'pointer', fontWeight: filterStrength === f.value ? 700 : 400 }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Match Cards */}
|
||||||
|
{sortedFiltered.length === 0 ? (
|
||||||
|
<EmptyState title="Keine Matches gefunden" description="Passen Sie den Filter an." />
|
||||||
|
) : (
|
||||||
|
<Box className="flex flex-col gap-4">
|
||||||
|
{sortedFiltered.map(match => {
|
||||||
|
const property = properties.find(p => p.id === match.propertyId)
|
||||||
|
const need = needs.find(n => n.id === match.needId)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card key={match.id} sx={{ elevation: 1, p: 2.5 }}>
|
||||||
|
<Box className="flex gap-4">
|
||||||
|
{/* Left column: score */}
|
||||||
|
<Box sx={{ width: 80, flexShrink: 0, textAlign: 'center' }} className="flex flex-col items-center gap-1">
|
||||||
|
<Typography variant="h3" fontWeight={700} sx={{ color: getScoreColor(match.matchStrength) }}>
|
||||||
|
{match.matchScore}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" color="text.secondary">/ 100</Typography>
|
||||||
|
<Chip
|
||||||
|
label={getMatchStrengthLabel(match.matchStrength)}
|
||||||
|
color={getMatchStrengthColor(match.matchStrength)}
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Center column: details */}
|
||||||
|
<Box className="flex-1 flex flex-col gap-2">
|
||||||
|
{/* Property info */}
|
||||||
|
<Box className="flex items-center gap-1">
|
||||||
|
<Building2 size={16} color="#1e3a5f" />
|
||||||
|
<Typography variant="h6" fontWeight={600}>
|
||||||
|
{property?.title ?? match.propertyId}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
{need && (
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
{need.companyName} — {need.assetType}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
{/* Positive factors */}
|
||||||
|
<Box className="flex flex-col gap-1">
|
||||||
|
{match.positiveFactors.slice(0, 3).map((f, i) => (
|
||||||
|
<Box key={i} className="flex items-center gap-2">
|
||||||
|
<Typography variant="caption" sx={{ color: '#1a7a4a', fontWeight: 600 }}>
|
||||||
|
✓ {f.criterion}: {Math.round(f.score)}%
|
||||||
|
</Typography>
|
||||||
|
<Box sx={{ width: 60 }}>
|
||||||
|
<LinearProgress
|
||||||
|
variant="determinate"
|
||||||
|
value={f.score}
|
||||||
|
color="success"
|
||||||
|
sx={{ height: 4, borderRadius: 2 }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Negative factors */}
|
||||||
|
{match.negativeFactors.slice(0, 2).map((f, i) => (
|
||||||
|
<Typography key={i} variant="caption" sx={{ color: '#c0392b' }}>
|
||||||
|
✗ {f.criterion}
|
||||||
|
</Typography>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Tradeoffs */}
|
||||||
|
{match.tradeoffs.length > 0 && (
|
||||||
|
<Box>
|
||||||
|
{match.tradeoffs.slice(0, 2).map((t, i) => (
|
||||||
|
<Typography key={i} variant="caption" sx={{ color: '#d97706' }}>
|
||||||
|
⚠ {t.concern}
|
||||||
|
</Typography>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Right column: actions */}
|
||||||
|
<Box sx={{ width: 160, flexShrink: 0, textAlign: 'right' }} className="flex flex-col gap-2 items-end">
|
||||||
|
<Chip
|
||||||
|
label={`Konfidenz: ${getConfidenceLabel(match.confidenceLevel)}`}
|
||||||
|
color={getConfidenceColor(match.confidenceLevel)}
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
<Chip
|
||||||
|
label={getRiskLabel(match.riskLevel)}
|
||||||
|
color={getRiskColor(match.riskLevel)}
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
size="small"
|
||||||
|
fullWidth
|
||||||
|
disabled
|
||||||
|
>
|
||||||
|
Details
|
||||||
|
</Button>
|
||||||
|
{match.isApproved ? (
|
||||||
|
<Chip
|
||||||
|
label="✓ Genehmigt"
|
||||||
|
color="success"
|
||||||
|
size="small"
|
||||||
|
sx={{ width: '100%', justifyContent: 'center' }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
size="small"
|
||||||
|
fullWidth
|
||||||
|
color="primary"
|
||||||
|
sx={{ bgcolor: '#1e3a5f' }}
|
||||||
|
onClick={() => approveMutation.mutate(match.id)}
|
||||||
|
disabled={approveMutation.isPending}
|
||||||
|
>
|
||||||
|
Genehmigen
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,374 @@
|
|||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Card,
|
||||||
|
Chip,
|
||||||
|
IconButton,
|
||||||
|
LinearProgress,
|
||||||
|
MenuItem,
|
||||||
|
Select,
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableRow,
|
||||||
|
TextField,
|
||||||
|
Tooltip,
|
||||||
|
Typography,
|
||||||
|
} from '@mui/material'
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { Eye, MoreHorizontal, Plus } from 'lucide-react'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { EmptyState, ErrorState, LoadingPage } from '../../components/ui'
|
||||||
|
import { propertyService } from '../../services/propertyService'
|
||||||
|
import { AssetType, AvailabilityStatus, ResultType } from '../../domain/enums'
|
||||||
|
|
||||||
|
function getAssetTypeLabel(type: AssetType): string {
|
||||||
|
switch (type) {
|
||||||
|
case AssetType.OFFICE: return 'Büro'
|
||||||
|
case AssetType.LOGISTICS: return 'Logistik'
|
||||||
|
case AssetType.RETAIL: return 'Retail'
|
||||||
|
case AssetType.GASTRO: return 'Gastro'
|
||||||
|
case AssetType.PRODUCTION: return 'Produktion'
|
||||||
|
case AssetType.MIXED: return 'Gemischt'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAssetTypeColor(type: AssetType): string {
|
||||||
|
switch (type) {
|
||||||
|
case AssetType.OFFICE: return '#1e3a5f'
|
||||||
|
case AssetType.LOGISTICS: return '#d97706'
|
||||||
|
case AssetType.RETAIL: return '#7c3aed'
|
||||||
|
case AssetType.GASTRO: return '#0d9488'
|
||||||
|
case AssetType.PRODUCTION: return '#92400e'
|
||||||
|
case AssetType.MIXED: return '#6b7280'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAvailabilityLabel(status: AvailabilityStatus): string {
|
||||||
|
switch (status) {
|
||||||
|
case AvailabilityStatus.AVAILABLE_NOW: return 'Verfügbar'
|
||||||
|
case AvailabilityStatus.AVAILABLE_SOON: return 'Bald verfügbar'
|
||||||
|
case AvailabilityStatus.FUTURE_SIGNAL: return 'Zukunftssignal'
|
||||||
|
case AvailabilityStatus.OCCUPIED: return 'Belegt'
|
||||||
|
case AvailabilityStatus.UNKNOWN: return 'Unbekannt'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAvailabilityColor(status: AvailabilityStatus): 'success' | 'warning' | 'secondary' | 'error' | 'default' {
|
||||||
|
switch (status) {
|
||||||
|
case AvailabilityStatus.AVAILABLE_NOW: return 'success'
|
||||||
|
case AvailabilityStatus.AVAILABLE_SOON: return 'warning'
|
||||||
|
case AvailabilityStatus.FUTURE_SIGNAL: return 'secondary'
|
||||||
|
case AvailabilityStatus.OCCUPIED: return 'error'
|
||||||
|
case AvailabilityStatus.UNKNOWN: return 'default'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getResultTypeLabel(type: ResultType): string {
|
||||||
|
switch (type) {
|
||||||
|
case ResultType.VERIFIED_PORTFOLIO: return 'Verified Portfolio'
|
||||||
|
case ResultType.EXTERNAL_MARKET: return 'Marktinserat'
|
||||||
|
case ResultType.FUTURE_AVAILABILITY: return 'Zukunftssignal'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getResultTypeColor(type: ResultType): string {
|
||||||
|
switch (type) {
|
||||||
|
case ResultType.VERIFIED_PORTFOLIO: return '#1e3a5f'
|
||||||
|
case ResultType.EXTERNAL_MARKET: return '#d97706'
|
||||||
|
case ResultType.FUTURE_AVAILABILITY: return '#7c3aed'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getConfidenceColor(score: number): string {
|
||||||
|
if (score >= 0.85) return '#1a7a4a'
|
||||||
|
if (score >= 0.65) return '#1e3a5f'
|
||||||
|
return '#d97706'
|
||||||
|
}
|
||||||
|
|
||||||
|
function getQualityColor(score: number): 'success' | 'warning' | 'error' {
|
||||||
|
if (score >= 0.8) return 'success'
|
||||||
|
if (score >= 0.6) return 'warning'
|
||||||
|
return 'error'
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Properties() {
|
||||||
|
const [selectedResultType, setSelectedResultType] = useState<ResultType | 'ALL'>('ALL')
|
||||||
|
const [selectedAssetType, setSelectedAssetType] = useState<AssetType | 'ALL'>('ALL')
|
||||||
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
|
|
||||||
|
const { data: resp, isLoading, error } = useQuery({
|
||||||
|
queryKey: ['properties'],
|
||||||
|
queryFn: () => propertyService.getAll(),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (isLoading) return <LoadingPage />
|
||||||
|
if (error) return <ErrorState />
|
||||||
|
|
||||||
|
const properties = resp?.data ?? []
|
||||||
|
|
||||||
|
const filtered = properties.filter(p => {
|
||||||
|
if (selectedResultType !== 'ALL' && p.resultType !== selectedResultType) return false
|
||||||
|
if (selectedAssetType !== 'ALL' && p.assetType !== selectedAssetType) return false
|
||||||
|
if (searchQuery) {
|
||||||
|
const q = searchQuery.toLowerCase()
|
||||||
|
const matchesTitle = p.title.toLowerCase().includes(q)
|
||||||
|
const matchesCity = p.location.city.toLowerCase().includes(q)
|
||||||
|
const matchesStreet = p.address.street.toLowerCase().includes(q)
|
||||||
|
if (!matchesTitle && !matchesCity && !matchesStreet) return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
const sourceTypeFilters: { value: ResultType | 'ALL'; label: string; color: string }[] = [
|
||||||
|
{ value: 'ALL', label: 'Alle', color: '#6b7280' },
|
||||||
|
{ value: ResultType.VERIFIED_PORTFOLIO, label: 'Verified Portfolio', color: '#1e3a5f' },
|
||||||
|
{ value: ResultType.EXTERNAL_MARKET, label: 'Marktinserate', color: '#d97706' },
|
||||||
|
{ value: ResultType.FUTURE_AVAILABILITY, label: 'Zukunftssignale', color: '#7c3aed' },
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
{/* Page Header */}
|
||||||
|
<Box sx={{ bgcolor: 'white', borderBottom: '1px solid', borderColor: 'divider', px: 3, py: 2 }} className="flex items-center justify-between">
|
||||||
|
<Box className="flex items-center gap-2">
|
||||||
|
<Typography variant="h5" fontWeight={700} color="text.primary">Objekte</Typography>
|
||||||
|
<Chip label={properties.length} size="small" sx={{ bgcolor: '#1e3a5f', color: 'white', fontWeight: 700 }} />
|
||||||
|
</Box>
|
||||||
|
<Tooltip title="In Entwicklung">
|
||||||
|
<span>
|
||||||
|
<button
|
||||||
|
disabled
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 6,
|
||||||
|
padding: '6px 16px',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: 4,
|
||||||
|
background: '#1e3a5f',
|
||||||
|
color: 'white',
|
||||||
|
cursor: 'not-allowed',
|
||||||
|
opacity: 0.5,
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: 500,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Plus size={16} />
|
||||||
|
Neues Objekt
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<Box sx={{ px: 3, py: 3 }} className="flex flex-col gap-3">
|
||||||
|
|
||||||
|
{/* Filter Bar */}
|
||||||
|
<Card sx={{ elevation: 1, p: 1.5 }}>
|
||||||
|
<Box className="flex flex-col gap-2">
|
||||||
|
{/* Row 1: Source type chips */}
|
||||||
|
<Box className="flex items-center gap-2 flex-wrap">
|
||||||
|
{sourceTypeFilters.map(f => (
|
||||||
|
<Chip
|
||||||
|
key={f.value}
|
||||||
|
label={f.label}
|
||||||
|
variant={selectedResultType === f.value ? 'filled' : 'outlined'}
|
||||||
|
size="small"
|
||||||
|
onClick={() => setSelectedResultType(f.value)}
|
||||||
|
sx={
|
||||||
|
selectedResultType === f.value
|
||||||
|
? { bgcolor: f.color, color: 'white', borderColor: f.color, fontWeight: 600, cursor: 'pointer' }
|
||||||
|
: { borderColor: f.color, color: f.color, cursor: 'pointer' }
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
{/* Row 2: Asset type select + search */}
|
||||||
|
<Box className="flex items-center gap-2">
|
||||||
|
<Select
|
||||||
|
value={selectedAssetType}
|
||||||
|
onChange={e => setSelectedAssetType(e.target.value as AssetType | 'ALL')}
|
||||||
|
size="small"
|
||||||
|
sx={{ minWidth: 160 }}
|
||||||
|
>
|
||||||
|
<MenuItem value="ALL">Alle Typen</MenuItem>
|
||||||
|
{Object.values(AssetType).map(t => (
|
||||||
|
<MenuItem key={t} value={t}>{getAssetTypeLabel(t)}</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
<TextField
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={e => setSearchQuery(e.target.value)}
|
||||||
|
placeholder="Suche nach Titel, Stadt, Strasse…"
|
||||||
|
size="small"
|
||||||
|
sx={{ ml: 'auto', minWidth: 260 }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Properties Table */}
|
||||||
|
{filtered.length === 0 ? (
|
||||||
|
<EmptyState title="Keine Objekte gefunden" description="Passen Sie die Filter an, um Ergebnisse anzuzeigen." />
|
||||||
|
) : (
|
||||||
|
<Card sx={{ elevation: 1 }}>
|
||||||
|
<Table stickyHeader size="small">
|
||||||
|
<TableHead>
|
||||||
|
<TableRow sx={{ bgcolor: 'grey.50' }}>
|
||||||
|
<TableCell><Typography variant="caption" fontWeight={600}>Objekt</Typography></TableCell>
|
||||||
|
<TableCell><Typography variant="caption" fontWeight={600}>Typ</Typography></TableCell>
|
||||||
|
<TableCell><Typography variant="caption" fontWeight={600}>Standort</Typography></TableCell>
|
||||||
|
<TableCell><Typography variant="caption" fontWeight={600}>Fläche</Typography></TableCell>
|
||||||
|
<TableCell><Typography variant="caption" fontWeight={600}>Miete/m²</Typography></TableCell>
|
||||||
|
<TableCell><Typography variant="caption" fontWeight={600}>Quelle</Typography></TableCell>
|
||||||
|
<TableCell><Typography variant="caption" fontWeight={600}>Konfidenz</Typography></TableCell>
|
||||||
|
<TableCell><Typography variant="caption" fontWeight={600}>Datenqualität</Typography></TableCell>
|
||||||
|
<TableCell><Typography variant="caption" fontWeight={600}>Verfügbarkeit</Typography></TableCell>
|
||||||
|
<TableCell><Typography variant="caption" fontWeight={600}>Aktionen</Typography></TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{filtered.map(property => {
|
||||||
|
const hasCritical = property.dataQuality.missingCriticalFields.length > 0
|
||||||
|
return (
|
||||||
|
<TableRow
|
||||||
|
key={property.id}
|
||||||
|
hover
|
||||||
|
sx={hasCritical ? { bgcolor: 'rgba(192,57,43,0.04)' } : {}}
|
||||||
|
>
|
||||||
|
{/* Objekt */}
|
||||||
|
<TableCell>
|
||||||
|
<Typography variant="body2" fontWeight={500}>{property.title}</Typography>
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
{property.address.street} {property.address.houseNumber}, {property.address.city}
|
||||||
|
</Typography>
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
{/* Typ */}
|
||||||
|
<TableCell>
|
||||||
|
<Chip
|
||||||
|
label={getAssetTypeLabel(property.assetType)}
|
||||||
|
size="small"
|
||||||
|
sx={{ bgcolor: getAssetTypeColor(property.assetType), color: 'white', fontSize: 11 }}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
{/* Standort */}
|
||||||
|
<TableCell>
|
||||||
|
<Typography variant="body2">{property.location.city}</Typography>
|
||||||
|
{property.location.canton && (
|
||||||
|
<Typography variant="caption" color="text.secondary">{property.location.canton}</Typography>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
{/* Fläche */}
|
||||||
|
<TableCell>
|
||||||
|
<Typography variant="body2">{property.areaSqm.toLocaleString('de-CH')} m²</Typography>
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
{/* Miete/m² */}
|
||||||
|
<TableCell>
|
||||||
|
<Typography variant="body2">CHF {property.rentPricePerSqm}</Typography>
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
{/* Quelle */}
|
||||||
|
<TableCell>
|
||||||
|
<Chip
|
||||||
|
label={getResultTypeLabel(property.resultType)}
|
||||||
|
size="small"
|
||||||
|
sx={{ bgcolor: getResultTypeColor(property.resultType), color: 'white', fontSize: 11 }}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
{/* Konfidenz */}
|
||||||
|
<TableCell>
|
||||||
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
fontWeight={600}
|
||||||
|
sx={{ color: getConfidenceColor(property.confidenceScore) }}
|
||||||
|
>
|
||||||
|
{Math.round(property.confidenceScore * 100)}%
|
||||||
|
</Typography>
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
{/* Datenqualität */}
|
||||||
|
<TableCell>
|
||||||
|
<Tooltip
|
||||||
|
title={
|
||||||
|
<Box>
|
||||||
|
{property.dataQuality.missingCriticalFields.length > 0 && (
|
||||||
|
<Box>
|
||||||
|
<Typography variant="caption" fontWeight={600}>Kritische Felder fehlen:</Typography>
|
||||||
|
{property.dataQuality.missingCriticalFields.map(f => (
|
||||||
|
<Typography key={f} variant="caption" display="block">• {f}</Typography>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
{property.dataQuality.warnings.length > 0 && (
|
||||||
|
<Box mt={0.5}>
|
||||||
|
<Typography variant="caption" fontWeight={600}>Warnungen:</Typography>
|
||||||
|
{property.dataQuality.warnings.map((w, i) => (
|
||||||
|
<Typography key={i} variant="caption" display="block">• {w}</Typography>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
{property.dataQuality.missingCriticalFields.length === 0 && property.dataQuality.warnings.length === 0 && (
|
||||||
|
<Typography variant="caption">Keine Probleme</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Box sx={{ width: 80 }}>
|
||||||
|
<LinearProgress
|
||||||
|
variant="determinate"
|
||||||
|
value={property.dataQuality.score * 100}
|
||||||
|
color={getQualityColor(property.dataQuality.score)}
|
||||||
|
sx={{ height: 6, borderRadius: 3 }}
|
||||||
|
/>
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
{Math.round(property.dataQuality.score * 100)}%
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Tooltip>
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
{/* Verfügbarkeit */}
|
||||||
|
<TableCell>
|
||||||
|
<Chip
|
||||||
|
label={getAvailabilityLabel(property.availabilityStatus)}
|
||||||
|
color={getAvailabilityColor(property.availabilityStatus)}
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
{/* Aktionen */}
|
||||||
|
<TableCell>
|
||||||
|
<Box className="flex items-center gap-1">
|
||||||
|
<Tooltip title="Details (in Entwicklung)">
|
||||||
|
<span>
|
||||||
|
<IconButton size="small" disabled>
|
||||||
|
<Eye size={16} />
|
||||||
|
</IconButton>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title="Mehr Aktionen (in Entwicklung)">
|
||||||
|
<span>
|
||||||
|
<IconButton size="small" disabled>
|
||||||
|
<MoreHorizontal size={16} />
|
||||||
|
</IconButton>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
</Box>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,283 @@
|
|||||||
|
import { Box, Card, Chip, LinearProgress, Table, TableBody, TableCell, TableHead, TableRow, Tooltip, Typography } from '@mui/material'
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { AlertTriangle, BarChart2, Building2, Target } from 'lucide-react'
|
||||||
|
import { SectionContainer, LoadingPage, ErrorState } from '../../components/ui'
|
||||||
|
import { propertyService } from '../../services/propertyService'
|
||||||
|
import { matchService } from '../../services/matchService'
|
||||||
|
import { governanceService } from '../../services/governanceService'
|
||||||
|
import { ResultType, MatchStrength } from '../../domain/enums'
|
||||||
|
import type { ActivityEventType } from '../../services/governanceService'
|
||||||
|
|
||||||
|
function getMatchStrengthColor(strength: MatchStrength): 'success' | 'warning' | 'error' {
|
||||||
|
if (strength === MatchStrength.STRONG) return 'success'
|
||||||
|
if (strength === MatchStrength.MODERATE) return 'warning'
|
||||||
|
return 'error'
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMatchStrengthLabel(strength: MatchStrength): string {
|
||||||
|
if (strength === MatchStrength.STRONG) return 'Stark'
|
||||||
|
if (strength === MatchStrength.MODERATE) return 'Mittel'
|
||||||
|
return 'Schwach'
|
||||||
|
}
|
||||||
|
|
||||||
|
function getEventDescription(type: ActivityEventType): string {
|
||||||
|
switch (type) {
|
||||||
|
case 'PROPERTY_CREATED': return 'hat ein Objekt erstellt'
|
||||||
|
case 'PROPERTY_UPDATED': return 'hat ein Objekt aktualisiert'
|
||||||
|
case 'MATCH_APPROVED': return 'hat einen Match genehmigt'
|
||||||
|
case 'MATCH_REJECTED': return 'hat einen Match abgelehnt'
|
||||||
|
case 'SIGNAL_VERIFIED': return 'hat ein Signal verifiziert'
|
||||||
|
case 'NEED_CREATED': return 'hat einen Bedarf erstellt'
|
||||||
|
case 'REVIEW_REQUESTED': return 'hat eine Überprüfung angefordert'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getEventColor(type: ActivityEventType): string {
|
||||||
|
switch (type) {
|
||||||
|
case 'PROPERTY_CREATED': return '#1e3a5f'
|
||||||
|
case 'PROPERTY_UPDATED': return '#1e3a5f'
|
||||||
|
case 'MATCH_APPROVED': return '#1a7a4a'
|
||||||
|
case 'MATCH_REJECTED': return '#c0392b'
|
||||||
|
case 'SIGNAL_VERIFIED': return '#7c3aed'
|
||||||
|
case 'NEED_CREATED': return '#d97706'
|
||||||
|
case 'REVIEW_REQUESTED': return '#d97706'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTimeAgo(dateStr: string): string {
|
||||||
|
const diff = Date.now() - new Date(dateStr).getTime()
|
||||||
|
const hours = Math.floor(diff / 3600000)
|
||||||
|
const days = Math.floor(hours / 24)
|
||||||
|
if (hours < 1) return 'vor weniger als 1 Stunde'
|
||||||
|
if (hours < 24) return `vor ${hours} Stunde${hours > 1 ? 'n' : ''}`
|
||||||
|
return `vor ${days} Tag${days > 1 ? 'en' : ''}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SupplyDashboard() {
|
||||||
|
const { data: propResp, isLoading: propLoading, error: propError } = useQuery({
|
||||||
|
queryKey: ['properties'],
|
||||||
|
queryFn: () => propertyService.getAll(),
|
||||||
|
})
|
||||||
|
const { data: matchResp, isLoading: matchLoading, error: matchError } = useQuery({
|
||||||
|
queryKey: ['matches'],
|
||||||
|
queryFn: () => matchService.getAll(),
|
||||||
|
})
|
||||||
|
const { data: activityResp, isLoading: activityLoading, error: activityError } = useQuery({
|
||||||
|
queryKey: ['activity', 'org-wincasa'],
|
||||||
|
queryFn: () => governanceService.getActivityLog('org-wincasa'),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (propLoading || matchLoading || activityLoading) return <LoadingPage />
|
||||||
|
if (propError || matchError || activityError) return <ErrorState />
|
||||||
|
|
||||||
|
const properties = propResp?.data ?? []
|
||||||
|
const matches = matchResp?.data ?? []
|
||||||
|
const activities = activityResp?.data ?? []
|
||||||
|
|
||||||
|
const avgQuality = properties.length
|
||||||
|
? properties.reduce((sum, p) => sum + p.dataQuality.score, 0) / properties.length
|
||||||
|
: 0
|
||||||
|
const pendingReview = properties.filter(p => p.dataQuality.missingCriticalFields.length > 0).length
|
||||||
|
|
||||||
|
const avgQualityColor = avgQuality >= 0.8 ? 'success.main' : avgQuality >= 0.6 ? 'warning.main' : 'error.main'
|
||||||
|
|
||||||
|
const verifiedCount = properties.filter(p => p.resultType === ResultType.VERIFIED_PORTFOLIO).length
|
||||||
|
const marketCount = properties.filter(p => p.resultType === ResultType.EXTERNAL_MARKET).length
|
||||||
|
const futureCount = properties.filter(p => p.resultType === ResultType.FUTURE_AVAILABILITY).length
|
||||||
|
|
||||||
|
const topMatches = [...matches].sort((a, b) => b.matchScore - a.matchScore).slice(0, 3)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
{/* Page Header */}
|
||||||
|
<Box sx={{ bgcolor: 'white', borderBottom: '1px solid', borderColor: 'divider', px: 3, py: 2 }}>
|
||||||
|
<Typography variant="h5" fontWeight={700} color="text.primary">Supply Dashboard</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary">Portfolioübersicht und aktuelle Kennzahlen</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<Box sx={{ px: 3, py: 3 }} className="flex flex-col gap-4">
|
||||||
|
|
||||||
|
{/* Section 1 - KPI Cards */}
|
||||||
|
<Box className="grid grid-cols-4 gap-4">
|
||||||
|
{/* Objekte */}
|
||||||
|
<Card sx={{ elevation: 1, p: 2.5 }}>
|
||||||
|
<Box className="flex flex-col gap-1">
|
||||||
|
<Box className="flex items-center justify-between">
|
||||||
|
<Typography variant="overline" color="text.secondary" lineHeight={1.4}>Objekte</Typography>
|
||||||
|
<Building2 size={20} color="#1e3a5f" />
|
||||||
|
</Box>
|
||||||
|
<Typography variant="h3" fontWeight={700} color="text.primary">{properties.length}</Typography>
|
||||||
|
<Typography variant="caption" color="text.secondary">Gesamtportfolio</Typography>
|
||||||
|
</Box>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Aktive Matches */}
|
||||||
|
<Card sx={{ elevation: 1, p: 2.5 }}>
|
||||||
|
<Box className="flex flex-col gap-1">
|
||||||
|
<Box className="flex items-center justify-between">
|
||||||
|
<Typography variant="overline" color="text.secondary" lineHeight={1.4}>Aktive Matches</Typography>
|
||||||
|
<Target size={20} color="#1a7a4a" />
|
||||||
|
</Box>
|
||||||
|
<Typography variant="h3" fontWeight={700} color="text.primary">{matches.length}</Typography>
|
||||||
|
<Typography variant="caption" color="text.secondary">KI-generierte Matches</Typography>
|
||||||
|
</Box>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Ø Datenqualität */}
|
||||||
|
<Card sx={{ elevation: 1, p: 2.5 }}>
|
||||||
|
<Box className="flex flex-col gap-1">
|
||||||
|
<Box className="flex items-center justify-between">
|
||||||
|
<Typography variant="overline" color="text.secondary" lineHeight={1.4}>Ø Datenqualität</Typography>
|
||||||
|
<BarChart2 size={20} color={avgQuality >= 0.8 ? '#1a7a4a' : avgQuality >= 0.6 ? '#d97706' : '#c0392b'} />
|
||||||
|
</Box>
|
||||||
|
<Typography variant="h3" fontWeight={700} sx={{ color: avgQualityColor }}>
|
||||||
|
{Math.round(avgQuality * 100)}%
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" color="text.secondary">Durchschnittlicher Score</Typography>
|
||||||
|
</Box>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Prüfungen ausstehend */}
|
||||||
|
<Card sx={{ elevation: 1, p: 2.5 }}>
|
||||||
|
<Box className="flex flex-col gap-1">
|
||||||
|
<Box className="flex items-center justify-between">
|
||||||
|
<Typography variant="overline" color="text.secondary" lineHeight={1.4}>Prüfungen ausstehend</Typography>
|
||||||
|
<AlertTriangle size={20} color="#d97706" />
|
||||||
|
</Box>
|
||||||
|
<Typography variant="h3" fontWeight={700} sx={{ color: pendingReview > 0 ? 'warning.main' : 'text.primary' }}>
|
||||||
|
{pendingReview}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" color="text.secondary">Kritische Felder fehlen</Typography>
|
||||||
|
</Box>
|
||||||
|
</Card>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Section 2 - Portfolio Overview */}
|
||||||
|
<Box className="grid grid-cols-3 gap-4">
|
||||||
|
<Card sx={{ elevation: 1, borderTop: '4px solid #1e3a5f', p: 2.5 }}>
|
||||||
|
<Typography variant="h4" fontWeight={700} color="text.primary">{verifiedCount}</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary">Verified Portfolio</Typography>
|
||||||
|
</Card>
|
||||||
|
<Card sx={{ elevation: 1, borderTop: '4px solid #d97706', p: 2.5 }}>
|
||||||
|
<Typography variant="h4" fontWeight={700} color="text.primary">{marketCount}</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary">Marktinserate</Typography>
|
||||||
|
</Card>
|
||||||
|
<Card sx={{ elevation: 1, borderTop: '4px solid #7c3aed', p: 2.5 }}>
|
||||||
|
<Typography variant="h4" fontWeight={700} color="text.primary">{futureCount}</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary">Zukunftssignale</Typography>
|
||||||
|
</Card>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Section 3 - Recent Matches */}
|
||||||
|
<SectionContainer title="Aktuelle Matches">
|
||||||
|
<Card sx={{ elevation: 1 }}>
|
||||||
|
<Table>
|
||||||
|
<TableHead>
|
||||||
|
<TableRow sx={{ bgcolor: 'grey.50' }}>
|
||||||
|
<TableCell><Typography variant="caption" fontWeight={600}>Objekt</Typography></TableCell>
|
||||||
|
<TableCell><Typography variant="caption" fontWeight={600}>Unternehmen</Typography></TableCell>
|
||||||
|
<TableCell><Typography variant="caption" fontWeight={600}>Score</Typography></TableCell>
|
||||||
|
<TableCell><Typography variant="caption" fontWeight={600}>Stärke</Typography></TableCell>
|
||||||
|
<TableCell><Typography variant="caption" fontWeight={600}>Aktion</Typography></TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{topMatches.map(match => {
|
||||||
|
const property = properties.find(p => p.id === match.propertyId)
|
||||||
|
return (
|
||||||
|
<TableRow key={match.id} hover>
|
||||||
|
<TableCell>
|
||||||
|
<Typography variant="body2" fontWeight={500}>{property?.title ?? match.propertyId}</Typography>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Typography variant="body2" color="text.secondary">{match.needId}</Typography>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Box className="flex items-center gap-2">
|
||||||
|
<Typography variant="body2" fontWeight={700}>{match.matchScore}</Typography>
|
||||||
|
<Box sx={{ width: 80 }}>
|
||||||
|
<LinearProgress
|
||||||
|
variant="determinate"
|
||||||
|
value={match.matchScore}
|
||||||
|
color={match.matchStrength === MatchStrength.STRONG ? 'success' : match.matchStrength === MatchStrength.MODERATE ? 'warning' : 'error'}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Chip
|
||||||
|
label={getMatchStrengthLabel(match.matchStrength)}
|
||||||
|
color={getMatchStrengthColor(match.matchStrength)}
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Tooltip title="Details-Seite in Entwicklung">
|
||||||
|
<span>
|
||||||
|
<button
|
||||||
|
disabled
|
||||||
|
style={{
|
||||||
|
padding: '4px 12px',
|
||||||
|
border: '1px solid rgba(0,0,0,0.23)',
|
||||||
|
borderRadius: 4,
|
||||||
|
background: 'transparent',
|
||||||
|
cursor: 'not-allowed',
|
||||||
|
opacity: 0.5,
|
||||||
|
fontSize: 13,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Details
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</Card>
|
||||||
|
</SectionContainer>
|
||||||
|
|
||||||
|
{/* Section 4 - Activity Log */}
|
||||||
|
<SectionContainer title="Aktivitäten">
|
||||||
|
<Card sx={{ elevation: 1, p: 2 }}>
|
||||||
|
<Box className="flex flex-col gap-3">
|
||||||
|
{activities.slice(0, 5).map(event => (
|
||||||
|
<Box key={event.id} className="flex items-center gap-3">
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
borderRadius: '50%',
|
||||||
|
bgcolor: getEventColor(event.type),
|
||||||
|
flexShrink: 0,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Building2 size={14} color="white" />
|
||||||
|
</Box>
|
||||||
|
<Box className="flex-1">
|
||||||
|
<Typography variant="body2">
|
||||||
|
<strong>{event.performedBy}</strong> {getEventDescription(event.type)}
|
||||||
|
</Typography>
|
||||||
|
{event.notes && (
|
||||||
|
<Typography variant="caption" color="text.secondary">{event.notes}</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
<Typography variant="caption" color="text.secondary" sx={{ flexShrink: 0 }}>
|
||||||
|
{formatTimeAgo(event.createdAt)}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</Card>
|
||||||
|
</SectionContainer>
|
||||||
|
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import type { FutureSignal } from '../domain/futureSignal'
|
||||||
|
import type { SignalType } from '../domain/enums'
|
||||||
|
|
||||||
|
export interface FutureSignalFilters {
|
||||||
|
signalType?: SignalType
|
||||||
|
minProbability?: number
|
||||||
|
organizationId?: string
|
||||||
|
isVerified?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IFutureSignalProvider {
|
||||||
|
getAll(filters?: FutureSignalFilters): Promise<FutureSignal[]>
|
||||||
|
getById(id: string): Promise<FutureSignal | null>
|
||||||
|
getByProperty(propertyId: string): Promise<FutureSignal[]>
|
||||||
|
verify(id: string, verifiedBy: string): Promise<FutureSignal>
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import type { Match } from '../domain/match'
|
||||||
|
import type { MatchStrength } from '../domain/enums'
|
||||||
|
|
||||||
|
export interface MatchFilters {
|
||||||
|
needId?: string
|
||||||
|
propertyId?: string
|
||||||
|
minScore?: number
|
||||||
|
matchStrength?: MatchStrength
|
||||||
|
organizationId?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IMatchProvider {
|
||||||
|
getAll(filters?: MatchFilters): Promise<Match[]>
|
||||||
|
getById(id: string): Promise<Match | null>
|
||||||
|
getByNeed(needId: string): Promise<Match[]>
|
||||||
|
getByProperty(propertyId: string): Promise<Match[]>
|
||||||
|
approve(id: string, reviewedBy: string): Promise<Match>
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import type { Need, CreateNeedInput, UpdateNeedInput } from '../domain/need'
|
||||||
|
import type { AssetType } from '../domain/enums'
|
||||||
|
|
||||||
|
export interface NeedFilters {
|
||||||
|
assetType?: AssetType
|
||||||
|
organizationId?: string
|
||||||
|
companyName?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface INeedProvider {
|
||||||
|
getAll(filters?: NeedFilters): Promise<Need[]>
|
||||||
|
getById(id: string): Promise<Need | null>
|
||||||
|
create(data: CreateNeedInput): Promise<Need>
|
||||||
|
update(id: string, data: UpdateNeedInput): Promise<Need>
|
||||||
|
remove(id: string): Promise<void>
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import type { Property, CreatePropertyInput, UpdatePropertyInput } from '../domain/property'
|
||||||
|
import type { AssetType, ResultType } from '../domain/enums'
|
||||||
|
|
||||||
|
export interface PropertyFilters {
|
||||||
|
assetType?: AssetType
|
||||||
|
resultType?: ResultType
|
||||||
|
city?: string
|
||||||
|
minAreaSqm?: number
|
||||||
|
maxRentPerSqm?: number
|
||||||
|
organizationId?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IPropertyProvider {
|
||||||
|
getAll(filters?: PropertyFilters): Promise<Property[]>
|
||||||
|
getById(id: string): Promise<Property | null>
|
||||||
|
create(data: CreatePropertyInput): Promise<Property>
|
||||||
|
update(id: string, data: UpdatePropertyInput): Promise<Property>
|
||||||
|
remove(id: string): Promise<void>
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import type { IFutureSignalProvider, FutureSignalFilters } from './IFutureSignalProvider'
|
||||||
|
import type { FutureSignal } from '../domain/futureSignal'
|
||||||
|
import { mockFutureSignals } from '../mock-data/futureSignals'
|
||||||
|
|
||||||
|
const store: FutureSignal[] = [...mockFutureSignals]
|
||||||
|
|
||||||
|
export const MockupFutureSignalProvider: IFutureSignalProvider = {
|
||||||
|
async getAll(filters?: FutureSignalFilters) {
|
||||||
|
let results = [...store]
|
||||||
|
if (filters?.signalType) results = results.filter(s => s.signalType === filters.signalType)
|
||||||
|
if (filters?.minProbability !== undefined) results = results.filter(s => s.probability >= filters.minProbability!)
|
||||||
|
if (filters?.organizationId) results = results.filter(s => s.organizationId === filters.organizationId)
|
||||||
|
if (filters?.isVerified !== undefined) results = results.filter(s => s.isVerified === filters.isVerified)
|
||||||
|
return results.sort((a, b) => b.probability - a.probability)
|
||||||
|
},
|
||||||
|
async getById(id) {
|
||||||
|
return store.find(s => s.id === id) ?? null
|
||||||
|
},
|
||||||
|
async getByProperty(propertyId) {
|
||||||
|
return store.filter(s => s.propertyId === propertyId)
|
||||||
|
},
|
||||||
|
async verify(id, verifiedBy) {
|
||||||
|
const idx = store.findIndex(s => s.id === id)
|
||||||
|
store[idx] = { ...store[idx], isVerified: true, verifiedBy, verifiedAt: new Date().toISOString(), updatedAt: new Date().toISOString() }
|
||||||
|
return store[idx]
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import type { IMatchProvider, MatchFilters } from './IMatchProvider'
|
||||||
|
import type { Match } from '../domain/match'
|
||||||
|
import { mockMatches } from '../mock-data/matches'
|
||||||
|
|
||||||
|
const store: Match[] = [...mockMatches]
|
||||||
|
|
||||||
|
export const MockupMatchProvider: IMatchProvider = {
|
||||||
|
async getAll(filters?: MatchFilters) {
|
||||||
|
let results = [...store]
|
||||||
|
if (filters?.needId) results = results.filter(m => m.needId === filters.needId)
|
||||||
|
if (filters?.propertyId) results = results.filter(m => m.propertyId === filters.propertyId)
|
||||||
|
if (filters?.minScore) results = results.filter(m => m.matchScore >= filters.minScore!)
|
||||||
|
if (filters?.matchStrength) results = results.filter(m => m.matchStrength === filters.matchStrength)
|
||||||
|
if (filters?.organizationId) results = results.filter(m => m.organizationId === filters.organizationId)
|
||||||
|
return results.sort((a, b) => b.matchScore - a.matchScore)
|
||||||
|
},
|
||||||
|
async getById(id) {
|
||||||
|
return store.find(m => m.id === id) ?? null
|
||||||
|
},
|
||||||
|
async getByNeed(needId) {
|
||||||
|
return store.filter(m => m.needId === needId).sort((a, b) => b.matchScore - a.matchScore)
|
||||||
|
},
|
||||||
|
async getByProperty(propertyId) {
|
||||||
|
return store.filter(m => m.propertyId === propertyId).sort((a, b) => b.matchScore - a.matchScore)
|
||||||
|
},
|
||||||
|
async approve(id, reviewedBy) {
|
||||||
|
const idx = store.findIndex(m => m.id === id)
|
||||||
|
store[idx] = { ...store[idx], isApproved: true, reviewedBy, reviewedAt: new Date().toISOString(), updatedAt: new Date().toISOString() }
|
||||||
|
return store[idx]
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import type { INeedProvider, NeedFilters } from './INeedProvider'
|
||||||
|
import type { Need, CreateNeedInput, UpdateNeedInput } from '../domain/need'
|
||||||
|
import { mockNeeds } from '../mock-data/needs'
|
||||||
|
|
||||||
|
const store: Need[] = [...mockNeeds]
|
||||||
|
|
||||||
|
export const MockupNeedProvider: INeedProvider = {
|
||||||
|
async getAll(filters?: NeedFilters) {
|
||||||
|
let results = [...store]
|
||||||
|
if (filters?.assetType) results = results.filter(n => n.assetType === filters.assetType)
|
||||||
|
if (filters?.organizationId) results = results.filter(n => n.organizationId === filters.organizationId)
|
||||||
|
if (filters?.companyName) results = results.filter(n => n.companyName.toLowerCase().includes(filters.companyName!.toLowerCase()))
|
||||||
|
return results
|
||||||
|
},
|
||||||
|
async getById(id) {
|
||||||
|
return store.find(n => n.id === id) ?? null
|
||||||
|
},
|
||||||
|
async create(data: CreateNeedInput) {
|
||||||
|
const next: Need = { id: crypto.randomUUID(), ...data, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() }
|
||||||
|
store.push(next)
|
||||||
|
return next
|
||||||
|
},
|
||||||
|
async update(id, data: UpdateNeedInput) {
|
||||||
|
const idx = store.findIndex(n => n.id === id)
|
||||||
|
store[idx] = { ...store[idx], ...data, updatedAt: new Date().toISOString() }
|
||||||
|
return store[idx]
|
||||||
|
},
|
||||||
|
async remove(id) {
|
||||||
|
const idx = store.findIndex(n => n.id === id)
|
||||||
|
store.splice(idx, 1)
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import type { IPropertyProvider, PropertyFilters } from './IPropertyProvider'
|
||||||
|
import type { Property, CreatePropertyInput, UpdatePropertyInput } from '../domain/property'
|
||||||
|
import { mockProperties } from '../mock-data/properties'
|
||||||
|
|
||||||
|
const store: Property[] = [...mockProperties]
|
||||||
|
|
||||||
|
export const MockupPropertyProvider: IPropertyProvider = {
|
||||||
|
async getAll(filters?: PropertyFilters) {
|
||||||
|
let results = [...store]
|
||||||
|
if (filters?.assetType) results = results.filter(p => p.assetType === filters.assetType)
|
||||||
|
if (filters?.resultType) results = results.filter(p => p.resultType === filters.resultType)
|
||||||
|
if (filters?.city) results = results.filter(p => p.location.city.toLowerCase().includes(filters.city!.toLowerCase()))
|
||||||
|
if (filters?.minAreaSqm) results = results.filter(p => p.areaSqm >= filters.minAreaSqm!)
|
||||||
|
if (filters?.maxRentPerSqm) results = results.filter(p => p.rentPricePerSqm <= filters.maxRentPerSqm!)
|
||||||
|
if (filters?.organizationId) results = results.filter(p => p.organizationId === filters.organizationId)
|
||||||
|
return results
|
||||||
|
},
|
||||||
|
async getById(id) {
|
||||||
|
return store.find(p => p.id === id) ?? null
|
||||||
|
},
|
||||||
|
async create(data: CreatePropertyInput) {
|
||||||
|
const next: Property = { id: crypto.randomUUID(), ...data, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() }
|
||||||
|
store.push(next)
|
||||||
|
return next
|
||||||
|
},
|
||||||
|
async update(id, data: UpdatePropertyInput) {
|
||||||
|
const idx = store.findIndex(p => p.id === id)
|
||||||
|
store[idx] = { ...store[idx], ...data, updatedAt: new Date().toISOString() }
|
||||||
|
return store[idx]
|
||||||
|
},
|
||||||
|
async remove(id) {
|
||||||
|
const idx = store.findIndex(p => p.id === id)
|
||||||
|
store.splice(idx, 1)
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import type { ItemResponse } from './types'
|
||||||
|
import type { CreateNeedInput } from '../domain/need'
|
||||||
|
|
||||||
|
export interface CriteriaExtractionResult {
|
||||||
|
extractedCriteria: Partial<CreateNeedInput>
|
||||||
|
confidence: number
|
||||||
|
missingFields: string[]
|
||||||
|
assumptions: string[]
|
||||||
|
followUpQuestions: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AIServiceProvider {
|
||||||
|
extractCriteria(naturalLanguageInput: string): Promise<CriteriaExtractionResult>
|
||||||
|
generateFollowUp(partialNeed: Partial<CreateNeedInput>): Promise<string[]>
|
||||||
|
}
|
||||||
|
|
||||||
|
const MockupAIServiceProvider: AIServiceProvider = {
|
||||||
|
async extractCriteria(input: string): Promise<CriteriaExtractionResult> {
|
||||||
|
// Deterministic mock extraction — simulates AI parsing
|
||||||
|
return {
|
||||||
|
extractedCriteria: {
|
||||||
|
companyName: 'Unbekannt (bitte bestätigen)',
|
||||||
|
requiredArea: { min: 400, max: 900 },
|
||||||
|
budgetRange: { maxPerSqm: 40, currency: 'CHF' },
|
||||||
|
},
|
||||||
|
confidence: 0.72,
|
||||||
|
missingFields: ['assetType', 'timing', 'preferredLocations'],
|
||||||
|
assumptions: ['Fläche aus Zahlenangabe geschätzt', 'Budget aus Kostennennung abgeleitet'],
|
||||||
|
followUpQuestions: [
|
||||||
|
'Welchen Nutzungstyp suchen Sie (Büro, Retail, Logistik)?',
|
||||||
|
'In welchen Städten oder Regionen suchen Sie?',
|
||||||
|
'Wann möchten Sie spätestens einziehen?',
|
||||||
|
],
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async generateFollowUp(partialNeed: Partial<CreateNeedInput>): Promise<string[]> {
|
||||||
|
const questions: string[] = []
|
||||||
|
if (!partialNeed.assetType) questions.push('Welchen Nutzungstyp suchen Sie?')
|
||||||
|
if (!partialNeed.preferredLocations?.length) questions.push('In welchen Regionen suchen Sie?')
|
||||||
|
if (!partialNeed.timing) questions.push('Was ist Ihr gewünschter Einzugstermin?')
|
||||||
|
if (!partialNeed.budgetRange) questions.push('Was ist Ihr maximales monatliches Budget?')
|
||||||
|
return questions
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const provider = MockupAIServiceProvider
|
||||||
|
|
||||||
|
export const aiService = {
|
||||||
|
async extractCriteria(input: string): Promise<ItemResponse<CriteriaExtractionResult>> {
|
||||||
|
const data = await provider.extractCriteria(input)
|
||||||
|
return { data }
|
||||||
|
},
|
||||||
|
async generateFollowUp(partialNeed: Partial<CreateNeedInput>): Promise<ItemResponse<string[]>> {
|
||||||
|
const data = await provider.generateFollowUp(partialNeed)
|
||||||
|
return { data }
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { MockupFutureSignalProvider } from '../provider/MockupFutureSignalProvider'
|
||||||
|
import type { FutureSignalFilters } from '../provider/IFutureSignalProvider'
|
||||||
|
import type { FutureSignal } from '../domain/futureSignal'
|
||||||
|
import type { ListResponse, ItemResponse } from './types'
|
||||||
|
|
||||||
|
const provider = MockupFutureSignalProvider
|
||||||
|
|
||||||
|
export const futureSignalService = {
|
||||||
|
async getAll(filters?: FutureSignalFilters): Promise<ListResponse<FutureSignal>> {
|
||||||
|
const data = await provider.getAll(filters)
|
||||||
|
return { data, meta: { total: data.length, page: 1, pageSize: data.length, hasMore: false } }
|
||||||
|
},
|
||||||
|
async getById(id: string): Promise<ItemResponse<FutureSignal | null>> {
|
||||||
|
const data = await provider.getById(id)
|
||||||
|
return { data }
|
||||||
|
},
|
||||||
|
async getByProperty(propertyId: string): Promise<ListResponse<FutureSignal>> {
|
||||||
|
const data = await provider.getByProperty(propertyId)
|
||||||
|
return { data, meta: { total: data.length, page: 1, pageSize: data.length, hasMore: false } }
|
||||||
|
},
|
||||||
|
async verify(id: string, verifiedBy: string): Promise<ItemResponse<FutureSignal>> {
|
||||||
|
const data = await provider.verify(id, verifiedBy)
|
||||||
|
return { data }
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import type { ListResponse, ItemResponse } from './types'
|
||||||
|
|
||||||
|
export type ActivityEventType =
|
||||||
|
| 'PROPERTY_CREATED'
|
||||||
|
| 'PROPERTY_UPDATED'
|
||||||
|
| 'MATCH_APPROVED'
|
||||||
|
| 'MATCH_REJECTED'
|
||||||
|
| 'SIGNAL_VERIFIED'
|
||||||
|
| 'NEED_CREATED'
|
||||||
|
| 'REVIEW_REQUESTED'
|
||||||
|
|
||||||
|
export interface ActivityEvent {
|
||||||
|
id: string
|
||||||
|
type: ActivityEventType
|
||||||
|
entityId: string
|
||||||
|
entityType: 'PROPERTY' | 'MATCH' | 'NEED' | 'SIGNAL'
|
||||||
|
performedBy: string
|
||||||
|
organizationId: string
|
||||||
|
notes?: string
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const mockActivityLog: ActivityEvent[] = [
|
||||||
|
{ id: 'evt-001', type: 'PROPERTY_CREATED', entityId: 'prop-001', entityType: 'PROPERTY', performedBy: 'admin@ideal-sharing.ch', organizationId: 'org-wincasa', createdAt: '2025-01-10T08:00:00Z' },
|
||||||
|
{ id: 'evt-002', type: 'MATCH_APPROVED', entityId: 'match-003', entityType: 'MATCH', performedBy: 'admin@ideal-sharing.ch', organizationId: 'org-wincasa', notes: 'Starker Match bestätigt', createdAt: '2025-05-10T09:00:00Z' },
|
||||||
|
{ id: 'evt-003', type: 'SIGNAL_VERIFIED', entityId: 'signal-003', entityType: 'SIGNAL', performedBy: 'admin@ideal-sharing.ch', organizationId: 'org-wincasa', createdAt: '2025-05-05T09:00:00Z' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const store = [...mockActivityLog]
|
||||||
|
|
||||||
|
export const governanceService = {
|
||||||
|
async getActivityLog(organizationId?: string): Promise<ListResponse<ActivityEvent>> {
|
||||||
|
const data = organizationId ? store.filter(e => e.organizationId === organizationId) : [...store]
|
||||||
|
return { data: data.sort((a, b) => b.createdAt.localeCompare(a.createdAt)), meta: { total: data.length, page: 1, pageSize: data.length, hasMore: false } }
|
||||||
|
},
|
||||||
|
async logEvent(event: Omit<ActivityEvent, 'id' | 'createdAt'>): Promise<ItemResponse<ActivityEvent>> {
|
||||||
|
const data: ActivityEvent = { id: crypto.randomUUID(), ...event, createdAt: new Date().toISOString() }
|
||||||
|
store.push(data)
|
||||||
|
return { data }
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { MockupMatchProvider } from '../provider/MockupMatchProvider'
|
||||||
|
import type { MatchFilters } from '../provider/IMatchProvider'
|
||||||
|
import type { Match } from '../domain/match'
|
||||||
|
import type { ListResponse, ItemResponse } from './types'
|
||||||
|
|
||||||
|
const provider = MockupMatchProvider
|
||||||
|
|
||||||
|
export const matchService = {
|
||||||
|
async getAll(filters?: MatchFilters): Promise<ListResponse<Match>> {
|
||||||
|
const data = await provider.getAll(filters)
|
||||||
|
return { data, meta: { total: data.length, page: 1, pageSize: data.length, hasMore: false } }
|
||||||
|
},
|
||||||
|
async getById(id: string): Promise<ItemResponse<Match | null>> {
|
||||||
|
const data = await provider.getById(id)
|
||||||
|
return { data }
|
||||||
|
},
|
||||||
|
async getByNeed(needId: string): Promise<ListResponse<Match>> {
|
||||||
|
const data = await provider.getByNeed(needId)
|
||||||
|
return { data, meta: { total: data.length, page: 1, pageSize: data.length, hasMore: false } }
|
||||||
|
},
|
||||||
|
async getByProperty(propertyId: string): Promise<ListResponse<Match>> {
|
||||||
|
const data = await provider.getByProperty(propertyId)
|
||||||
|
return { data, meta: { total: data.length, page: 1, pageSize: data.length, hasMore: false } }
|
||||||
|
},
|
||||||
|
async approve(id: string, reviewedBy: string): Promise<ItemResponse<Match>> {
|
||||||
|
const data = await provider.approve(id, reviewedBy)
|
||||||
|
return { data }
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { MockupNeedProvider } from '../provider/MockupNeedProvider'
|
||||||
|
import type { NeedFilters } from '../provider/INeedProvider'
|
||||||
|
import type { Need, CreateNeedInput, UpdateNeedInput } from '../domain/need'
|
||||||
|
import type { ListResponse, ItemResponse } from './types'
|
||||||
|
|
||||||
|
const provider = MockupNeedProvider
|
||||||
|
|
||||||
|
export const needService = {
|
||||||
|
async getAll(filters?: NeedFilters): Promise<ListResponse<Need>> {
|
||||||
|
const data = await provider.getAll(filters)
|
||||||
|
return { data, meta: { total: data.length, page: 1, pageSize: data.length, hasMore: false } }
|
||||||
|
},
|
||||||
|
async getById(id: string): Promise<ItemResponse<Need | null>> {
|
||||||
|
const data = await provider.getById(id)
|
||||||
|
return { data }
|
||||||
|
},
|
||||||
|
async create(input: CreateNeedInput): Promise<ItemResponse<Need>> {
|
||||||
|
const data = await provider.create(input)
|
||||||
|
return { data }
|
||||||
|
},
|
||||||
|
async update(id: string, input: UpdateNeedInput): Promise<ItemResponse<Need>> {
|
||||||
|
const data = await provider.update(id, input)
|
||||||
|
return { data }
|
||||||
|
},
|
||||||
|
async remove(id: string): Promise<ItemResponse<void>> {
|
||||||
|
await provider.remove(id)
|
||||||
|
return { data: undefined }
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { MockupPropertyProvider } from '../provider/MockupPropertyProvider'
|
||||||
|
import type { PropertyFilters } from '../provider/IPropertyProvider'
|
||||||
|
import type { CreatePropertyInput, UpdatePropertyInput } from '../domain/property'
|
||||||
|
import type { ListResponse, ItemResponse } from './types'
|
||||||
|
import type { Property } from '../domain/property'
|
||||||
|
|
||||||
|
const provider = MockupPropertyProvider
|
||||||
|
|
||||||
|
export const propertyService = {
|
||||||
|
async getAll(filters?: PropertyFilters): Promise<ListResponse<Property>> {
|
||||||
|
const data = await provider.getAll(filters)
|
||||||
|
return { data, meta: { total: data.length, page: 1, pageSize: data.length, hasMore: false } }
|
||||||
|
},
|
||||||
|
async getById(id: string): Promise<ItemResponse<Property | null>> {
|
||||||
|
const data = await provider.getById(id)
|
||||||
|
return { data }
|
||||||
|
},
|
||||||
|
async create(input: CreatePropertyInput): Promise<ItemResponse<Property>> {
|
||||||
|
const data = await provider.create(input)
|
||||||
|
return { data }
|
||||||
|
},
|
||||||
|
async update(id: string, input: UpdatePropertyInput): Promise<ItemResponse<Property>> {
|
||||||
|
const data = await provider.update(id, input)
|
||||||
|
return { data }
|
||||||
|
},
|
||||||
|
async remove(id: string): Promise<ItemResponse<void>> {
|
||||||
|
await provider.remove(id)
|
||||||
|
return { data: undefined }
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
export interface ServiceMeta {
|
||||||
|
total: number
|
||||||
|
page: number
|
||||||
|
pageSize: number
|
||||||
|
hasMore: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServiceResponse<T> {
|
||||||
|
data: T
|
||||||
|
meta?: ServiceMeta
|
||||||
|
error?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ListResponse<T> = ServiceResponse<T[]>
|
||||||
|
export type ItemResponse<T> = ServiceResponse<T>
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { create } from 'zustand'
|
||||||
|
|
||||||
|
const MAX_COMPARE_ITEMS = 3
|
||||||
|
|
||||||
|
interface CompareState {
|
||||||
|
compareTray: string[]
|
||||||
|
addToCompare: (propertyId: string) => void
|
||||||
|
removeFromCompare: (propertyId: string) => void
|
||||||
|
clearCompare: () => void
|
||||||
|
isInCompare: (propertyId: string) => boolean
|
||||||
|
isFull: () => boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useCompareStore = create<CompareState>((set, get) => ({
|
||||||
|
compareTray: [],
|
||||||
|
addToCompare: (propertyId) =>
|
||||||
|
set((state) => {
|
||||||
|
if (state.compareTray.length >= MAX_COMPARE_ITEMS) return state
|
||||||
|
if (state.compareTray.includes(propertyId)) return state
|
||||||
|
return { compareTray: [...state.compareTray, propertyId] }
|
||||||
|
}),
|
||||||
|
removeFromCompare: (propertyId) =>
|
||||||
|
set((state) => ({ compareTray: state.compareTray.filter(id => id !== propertyId) })),
|
||||||
|
clearCompare: () => set({ compareTray: [] }),
|
||||||
|
isInCompare: (propertyId) => get().compareTray.includes(propertyId),
|
||||||
|
isFull: () => get().compareTray.length >= MAX_COMPARE_ITEMS,
|
||||||
|
}))
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { create } from 'zustand'
|
||||||
|
import { WorkspaceType } from '../domain/enums'
|
||||||
|
|
||||||
|
interface LayoutState {
|
||||||
|
activeWorkspace: WorkspaceType
|
||||||
|
sidebarCollapsed: boolean
|
||||||
|
pinnedPanels: string[]
|
||||||
|
setActiveWorkspace: (workspace: WorkspaceType) => void
|
||||||
|
toggleSidebar: () => void
|
||||||
|
pinPanel: (panelId: string) => void
|
||||||
|
unpinPanel: (panelId: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useLayoutStore = create<LayoutState>((set) => ({
|
||||||
|
activeWorkspace: WorkspaceType.SUPPLY,
|
||||||
|
sidebarCollapsed: false,
|
||||||
|
pinnedPanels: [],
|
||||||
|
setActiveWorkspace: (workspace) => set({ activeWorkspace: workspace }),
|
||||||
|
toggleSidebar: () => set((state) => ({ sidebarCollapsed: !state.sidebarCollapsed })),
|
||||||
|
pinPanel: (panelId) => set((state) => ({ pinnedPanels: [...state.pinnedPanels, panelId] })),
|
||||||
|
unpinPanel: (panelId) => set((state) => ({ pinnedPanels: state.pinnedPanels.filter(id => id !== panelId) })),
|
||||||
|
}))
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { create } from 'zustand'
|
||||||
|
import { UserRole } from '../domain/enums'
|
||||||
|
|
||||||
|
export interface MockUser {
|
||||||
|
id: string
|
||||||
|
email: string
|
||||||
|
name: string
|
||||||
|
role: UserRole
|
||||||
|
organizationId: string
|
||||||
|
organizationName: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SessionState {
|
||||||
|
currentUser: MockUser | null
|
||||||
|
activeOrganizationId: string | null
|
||||||
|
isAuthenticated: boolean
|
||||||
|
login: (user: MockUser) => void
|
||||||
|
logout: () => void
|
||||||
|
switchOrganization: (organizationId: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const mockUser: MockUser = {
|
||||||
|
id: 'user-001',
|
||||||
|
email: 'admin@ideal-sharing.ch',
|
||||||
|
name: 'Admin User',
|
||||||
|
role: UserRole.ORGANIZATION_ADMIN,
|
||||||
|
organizationId: 'org-wincasa',
|
||||||
|
organizationName: 'Wincasa AG',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useSessionStore = create<SessionState>((set) => ({
|
||||||
|
currentUser: mockUser,
|
||||||
|
activeOrganizationId: mockUser.organizationId,
|
||||||
|
isAuthenticated: true,
|
||||||
|
login: (user) => set({ currentUser: user, activeOrganizationId: user.organizationId, isAuthenticated: true }),
|
||||||
|
logout: () => set({ currentUser: null, activeOrganizationId: null, isAuthenticated: false }),
|
||||||
|
switchOrganization: (organizationId) => set({ activeOrganizationId: organizationId }),
|
||||||
|
}))
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
|
"target": "es2023",
|
||||||
|
"lib": ["ES2023", "DOM"],
|
||||||
|
"module": "esnext",
|
||||||
|
"types": ["vite/client"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||||
|
"target": "es2023",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"module": "esnext",
|
||||||
|
"types": ["node"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
import tailwindcss from '@tailwindcss/vite'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [
|
||||||
|
react(),
|
||||||
|
tailwindcss(),
|
||||||
|
],
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user