Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a7e05fb7f0 | |||
| 78e2dab655 | |||
| 52dc7de25e | |||
| 36ce548169 | |||
| 3849b78f83 | |||
| 362514681a | |||
| 07cea21143 | |||
| be5523bcee | |||
| a8b54af0b8 | |||
| 6206447dae | |||
| 8cb6f21581 | |||
| 29be4f6e54 | |||
| 09095a1a32 | |||
| fb029cf0bc | |||
| 3e1e945661 | |||
| 74f9660581 | |||
| fca113e7ab | |||
| fd6ed105bb | |||
| 647493bf3c | |||
| 5d26dd4a6c | |||
| e169f8e310 | |||
| 7a0909e36a | |||
| 6aa4f96bd2 |
@@ -1,28 +0,0 @@
|
|||||||
# 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?
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
# 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...
|
|
||||||
},
|
|
||||||
},
|
|
||||||
])
|
|
||||||
```
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
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,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
])
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
<!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>
|
|
||||||
-3942
File diff suppressed because it is too large
Load Diff
@@ -1,41 +0,0 @@
|
|||||||
{
|
|
||||||
"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
|
Before Width: | Height: | Size: 9.3 KiB |
@@ -1,24 +0,0 @@
|
|||||||
<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>
|
|
||||||
|
Before Width: | Height: | Size: 4.9 KiB |
@@ -1,184 +0,0 @@
|
|||||||
.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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
import { lazy, Suspense } from 'react'
|
|
||||||
import { Routes, Route, Navigate } from 'react-router'
|
|
||||||
import { LoadingPage, AppErrorBoundary } from './components/ui'
|
|
||||||
import { AppShell } from './components/layout'
|
|
||||||
import { ProtectedRoute } from './components/auth'
|
|
||||||
import { WorkspaceType } from './domain/enums'
|
|
||||||
import { useSessionStore } from './stores/sessionStore'
|
|
||||||
|
|
||||||
const WORKSPACE_HOME: Record<string, string> = {
|
|
||||||
[WorkspaceType.SUPPLY]: '/supply/dashboard',
|
|
||||||
[WorkspaceType.DEMAND]: '/demand/ai-search',
|
|
||||||
[WorkspaceType.OPERATIONS]: '/ops/review-queue',
|
|
||||||
}
|
|
||||||
|
|
||||||
function RoleRedirect() {
|
|
||||||
const { currentUser } = useSessionStore()
|
|
||||||
const first = currentUser?.allowedWorkspaces[0] ?? WorkspaceType.SUPPLY
|
|
||||||
return <Navigate to={WORKSPACE_HOME[first] ?? '/supply/dashboard'} replace />
|
|
||||||
}
|
|
||||||
|
|
||||||
const LoginScreen = lazy(() => import('./pages/auth/LoginScreen'))
|
|
||||||
|
|
||||||
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 MatchDetail = lazy(() => import('./pages/demand/MatchDetail'))
|
|
||||||
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'))
|
|
||||||
const MarketIntelligence = lazy(() => import('./pages/ops/MarketIntelligence'))
|
|
||||||
const SourceMonitoring = lazy(() => import('./pages/ops/SourceMonitoring'))
|
|
||||||
const ActivityTimeline = lazy(() => import('./pages/ops/ActivityTimeline'))
|
|
||||||
const SignalPipeline = lazy(() => import('./pages/ops/SignalPipeline'))
|
|
||||||
|
|
||||||
function App() {
|
|
||||||
return (
|
|
||||||
<AppErrorBoundary>
|
|
||||||
<Suspense fallback={<LoadingPage />}>
|
|
||||||
<Routes>
|
|
||||||
{/* Public */}
|
|
||||||
<Route path="/auth/login" element={<LoginScreen />} />
|
|
||||||
|
|
||||||
{/* Protected: auth check only */}
|
|
||||||
<Route element={<ProtectedRoute />}>
|
|
||||||
<Route element={<AppShell />}>
|
|
||||||
<Route path="/" element={<RoleRedirect />} />
|
|
||||||
|
|
||||||
{/* Supply Workspace */}
|
|
||||||
<Route element={<ProtectedRoute workspace={WorkspaceType.SUPPLY} />}>
|
|
||||||
<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 />} />
|
|
||||||
</Route>
|
|
||||||
|
|
||||||
{/* Demand Workspace */}
|
|
||||||
<Route element={<ProtectedRoute workspace={WorkspaceType.DEMAND} />}>
|
|
||||||
<Route path="/demand/ai-search" element={<AISearch />} />
|
|
||||||
<Route path="/demand/results" element={<Results />} />
|
|
||||||
<Route path="/demand/results/:matchId" element={<MatchDetail />} />
|
|
||||||
<Route path="/demand/compare" element={<Compare />} />
|
|
||||||
<Route path="/demand/shortlists" element={<Shortlists />} />
|
|
||||||
</Route>
|
|
||||||
|
|
||||||
{/* Operations Workspace */}
|
|
||||||
<Route element={<ProtectedRoute workspace={WorkspaceType.OPERATIONS} />}>
|
|
||||||
<Route path="/ops/review-queue" element={<ReviewQueue />} />
|
|
||||||
<Route path="/ops/ai-monitoring" element={<AIMonitoring />} />
|
|
||||||
<Route path="/ops/governance" element={<Governance />} />
|
|
||||||
<Route path="/ops/market-intelligence" element={<MarketIntelligence />} />
|
|
||||||
<Route path="/ops/source-monitoring" element={<SourceMonitoring />} />
|
|
||||||
<Route path="/ops/activity-timeline" element={<ActivityTimeline />} />
|
|
||||||
<Route path="/ops/signal-pipeline" element={<SignalPipeline />} />
|
|
||||||
</Route>
|
|
||||||
</Route>
|
|
||||||
</Route>
|
|
||||||
|
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
|
||||||
</Routes>
|
|
||||||
</Suspense>
|
|
||||||
</AppErrorBoundary>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default App
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 13 KiB |
@@ -1 +0,0 @@
|
|||||||
<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>
|
|
||||||
|
Before Width: | Height: | Size: 4.0 KiB |
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 8.5 KiB |
@@ -1,23 +0,0 @@
|
|||||||
import { Chip, Tooltip } from '@mui/material'
|
|
||||||
import type { AIOutputError } from '../../domain/aiOutput'
|
|
||||||
|
|
||||||
const ERROR_CONFIG: Record<string, { label: string; color: string }> = {
|
|
||||||
SCHEMA_VALIDATION: { label: 'Schema', color: '#ea580c' },
|
|
||||||
PROVIDER_TIMEOUT: { label: 'Timeout', color: '#c0392b' },
|
|
||||||
INVALID_JSON: { label: 'JSON', color: '#c0392b' },
|
|
||||||
EMPTY_RESPONSE: { label: 'Leer', color: '#d97706' },
|
|
||||||
RATE_LIMIT: { label: 'Rate Limit', color: '#7c3aed' },
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AIErrorBadge({ error }: { error: AIOutputError }) {
|
|
||||||
const { label, color } = ERROR_CONFIG[error.type] ?? { label: error.type, color: '#c0392b' }
|
|
||||||
return (
|
|
||||||
<Tooltip title={error.message} arrow>
|
|
||||||
<Chip
|
|
||||||
size="small"
|
|
||||||
label={label}
|
|
||||||
sx={{ bgcolor: `${color}18`, color, fontWeight: 700, fontSize: '0.65rem', cursor: 'default' }}
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
-38
@@ -1,38 +0,0 @@
|
|||||||
import { Box, Typography } from '@mui/material'
|
|
||||||
import { Bot, Filter, MousePointer } from 'lucide-react'
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
context: 'no-outputs' | 'filtered-empty' | 'no-selection'
|
|
||||||
}
|
|
||||||
|
|
||||||
const CONFIG = {
|
|
||||||
'no-outputs': {
|
|
||||||
icon: Bot,
|
|
||||||
color: '#94a3b8',
|
|
||||||
title: 'Keine AI-Outputs',
|
|
||||||
desc: 'Es wurden noch keine AI-Outputs generiert.',
|
|
||||||
},
|
|
||||||
'filtered-empty': {
|
|
||||||
icon: Filter,
|
|
||||||
color: '#94a3b8',
|
|
||||||
title: 'Keine Ergebnisse',
|
|
||||||
desc: 'Kein AI-Output entspricht den aktiven Filtern.',
|
|
||||||
},
|
|
||||||
'no-selection': {
|
|
||||||
icon: MousePointer,
|
|
||||||
color: '#94a3b8',
|
|
||||||
title: 'Output auswählen',
|
|
||||||
desc: 'Klicken Sie auf eine Zeile, um Details und Aktionen anzuzeigen.',
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AIMonitoringEmptyState({ context }: Props) {
|
|
||||||
const { icon: Icon, color, title, desc } = CONFIG[context]
|
|
||||||
return (
|
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '100%', minHeight: 200, p: 4, textAlign: 'center' }}>
|
|
||||||
<Icon size={36} color={color} style={{ marginBottom: 12, opacity: 0.6 }} />
|
|
||||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 0.5, color: '#1e293b' }}>{title}</Typography>
|
|
||||||
<Typography variant="body2" color="text.secondary" sx={{ maxWidth: 260 }}>{desc}</Typography>
|
|
||||||
</Box>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
import { Box, Typography } from '@mui/material'
|
|
||||||
import type { AIOutput } from '../../domain/aiOutput'
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
outputs: AIOutput[]
|
|
||||||
}
|
|
||||||
|
|
||||||
function MetricCell({ label, value, color }: { label: string; value: string | number; color?: string }) {
|
|
||||||
return (
|
|
||||||
<Box sx={{ px: 2, py: 1.25, borderRight: '1px solid #e2e8f0', '&:last-child': { borderRight: 'none' }, minWidth: 0, flex: 1 }}>
|
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', whiteSpace: 'nowrap', mb: 0.25 }}>
|
|
||||||
{label}
|
|
||||||
</Typography>
|
|
||||||
<Typography
|
|
||||||
variant="subtitle2"
|
|
||||||
sx={{ fontWeight: 700, fontSize: '0.9375rem', color: color ?? '#1e293b', lineHeight: 1.2 }}
|
|
||||||
>
|
|
||||||
{value}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function mostCommon(arr: string[]): string {
|
|
||||||
if (!arr.length) return '–'
|
|
||||||
const freq = arr.reduce<Record<string, number>>((acc, v) => ({ ...acc, [v]: (acc[v] ?? 0) + 1 }), {})
|
|
||||||
return Object.entries(freq).sort((a, b) => b[1] - a[1])[0][0]
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AIMonitoringMetrics({ outputs }: Props) {
|
|
||||||
const total = outputs.length
|
|
||||||
const failed = outputs.filter(o => !!o.error).length
|
|
||||||
const needsReview = outputs.filter(o => o.reviewStatus === 'UNREVIEWED' || o.reviewStatus === 'FLAGGED').length
|
|
||||||
const approved = outputs.filter(o => o.reviewStatus === 'APPROVED').length
|
|
||||||
const approvalRate = total > 0 ? Math.round((approved / total) * 100) : 0
|
|
||||||
const topPrompt = mostCommon(outputs.map(o => o.promptVersion))
|
|
||||||
const latestModel = outputs.length > 0
|
|
||||||
? outputs.sort((a, b) => b.createdAt.localeCompare(a.createdAt))[0].model
|
|
||||||
: '–'
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box sx={{ display: 'flex', bgcolor: 'white', borderBottom: '1px solid #e2e8f0', flexShrink: 0 }}>
|
|
||||||
<MetricCell label="AI-Outputs total" value={total} />
|
|
||||||
<MetricCell label="Fehlgeschlagen" value={failed} color={failed > 0 ? '#c0392b' : undefined} />
|
|
||||||
<MetricCell label="Prüfung ausstehend" value={needsReview} color={needsReview > 0 ? '#d97706' : undefined} />
|
|
||||||
<MetricCell label="Genehmigungsrate" value={`${approvalRate}%`} color={approvalRate >= 70 ? '#1a7a4a' : '#d97706'} />
|
|
||||||
<MetricCell label="Häufigste Version" value={topPrompt} />
|
|
||||||
<MetricCell label="Aktives Modell" value={latestModel.replace('claude-3-5-sonnet-20241022', 'sonnet-3.5').replace('claude-3-haiku-20240307', 'haiku-3').replace('claude-3-opus-20240229', 'opus-3')} />
|
|
||||||
</Box>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
-191
@@ -1,191 +0,0 @@
|
|||||||
import { Alert, Box, Divider, IconButton, Typography } from '@mui/material'
|
|
||||||
import { X } from 'lucide-react'
|
|
||||||
import { AIOutputStatusBadge } from './AIOutputStatusBadge'
|
|
||||||
import { PromptVersionBadge } from './PromptVersionBadge'
|
|
||||||
import { AIErrorBadge } from './AIErrorBadge'
|
|
||||||
import { AIReviewActionToolbar } from './AIReviewActionToolbar'
|
|
||||||
import type { AIOutput, AIOutputType } from '../../domain/aiOutput'
|
|
||||||
import type { ReviewStatus } from '../../domain/enums'
|
|
||||||
|
|
||||||
const TYPE_LABELS: Record<AIOutputType, string> = {
|
|
||||||
NEED_PARSE: 'Bedarf-Parsing',
|
|
||||||
FOLLOW_UP_QUESTIONS: 'Rückfragen',
|
|
||||||
MATCH_EXPLANATION: 'Match-Begründung',
|
|
||||||
COMPARE_SUMMARY: 'Vergleich',
|
|
||||||
DECISION_BRIEF: 'Entscheidungs-Brief',
|
|
||||||
DATA_QUALITY_SUMMARY: 'Datenqualität',
|
|
||||||
}
|
|
||||||
|
|
||||||
const ERROR_TYPE_LABELS: Record<string, string> = {
|
|
||||||
SCHEMA_VALIDATION: 'Schema-Validierungsfehler',
|
|
||||||
PROVIDER_TIMEOUT: 'Provider-Timeout',
|
|
||||||
INVALID_JSON: 'Ungültiges JSON',
|
|
||||||
EMPTY_RESPONSE: 'Leere Antwort',
|
|
||||||
RATE_LIMIT: 'Rate-Limit erreicht',
|
|
||||||
}
|
|
||||||
|
|
||||||
const MODEL_LABELS: Record<string, string> = {
|
|
||||||
'claude-3-5-sonnet-20241022': 'Claude 3.5 Sonnet',
|
|
||||||
'claude-3-haiku-20240307': 'Claude 3 Haiku',
|
|
||||||
'claude-3-opus-20240229': 'Claude 3 Opus',
|
|
||||||
}
|
|
||||||
|
|
||||||
const ENTITY_TYPE_LABELS: Record<string, string> = {
|
|
||||||
NEED: 'Gesuch', MATCH: 'Match', PROPERTY: 'Objekt', SIGNAL: 'Signal',
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
output: AIOutput
|
|
||||||
onClose: () => void
|
|
||||||
onUpdateStatus: (status: ReviewStatus) => void
|
|
||||||
isSubmitting?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
function MetaRow({ label, children }: { label: string; children: React.ReactNode }) {
|
|
||||||
return (
|
|
||||||
<Box sx={{ display: 'flex', gap: 1, mb: 0.625, alignItems: 'flex-start' }}>
|
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ minWidth: 116, flexShrink: 0, pt: 0.125 }}>
|
|
||||||
{label}
|
|
||||||
</Typography>
|
|
||||||
<Box sx={{ flex: 1, minWidth: 0 }}>{children}</Box>
|
|
||||||
</Box>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AIOutputDetailPanel({ output, onClose, onUpdateStatus, isSubmitting }: Props) {
|
|
||||||
const handleCopyJson = () => {
|
|
||||||
navigator.clipboard.writeText(output.outputPreview).catch(() => {})
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
|
||||||
{/* Header */}
|
|
||||||
<Box sx={{ px: 2, py: 1.5, borderBottom: '1px solid #e2e8f0', flexShrink: 0 }}>
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1 }}>
|
|
||||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
|
||||||
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap', mb: 0.5, alignItems: 'center' }}>
|
|
||||||
<AIOutputStatusBadge status={output.reviewStatus} />
|
|
||||||
{output.error && <AIErrorBadge error={output.error} />}
|
|
||||||
</Box>
|
|
||||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, fontSize: '0.875rem' }}>
|
|
||||||
{TYPE_LABELS[output.type] ?? output.type}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
<IconButton size="small" onClick={onClose} sx={{ flexShrink: 0, mt: -0.25 }}>
|
|
||||||
<X size={16} />
|
|
||||||
</IconButton>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Scrollable body */}
|
|
||||||
<Box sx={{ flex: 1, overflowY: 'auto', px: 2, py: 1.5 }}>
|
|
||||||
{/* Metadata */}
|
|
||||||
<MetaRow label="Output-ID">
|
|
||||||
<Typography variant="caption" sx={{ fontFamily: 'monospace', fontSize: '0.65rem', color: '#475569' }}>
|
|
||||||
{output.id}
|
|
||||||
</Typography>
|
|
||||||
</MetaRow>
|
|
||||||
<MetaRow label="Erstellt">
|
|
||||||
<Typography variant="caption" sx={{ color: '#334155' }}>
|
|
||||||
{new Date(output.createdAt).toLocaleString('de-CH', { dateStyle: 'medium', timeStyle: 'short' })}
|
|
||||||
</Typography>
|
|
||||||
</MetaRow>
|
|
||||||
<MetaRow label="Modell">
|
|
||||||
<Typography variant="caption" sx={{ fontWeight: 600, color: '#334155' }}>
|
|
||||||
{MODEL_LABELS[output.model] ?? output.model}
|
|
||||||
</Typography>
|
|
||||||
</MetaRow>
|
|
||||||
<MetaRow label="Provider">
|
|
||||||
<Typography variant="caption" sx={{ color: '#334155', textTransform: 'capitalize' }}>
|
|
||||||
{output.provider}
|
|
||||||
</Typography>
|
|
||||||
</MetaRow>
|
|
||||||
<MetaRow label="Prompt-Version">
|
|
||||||
<PromptVersionBadge promptVersion={output.promptVersion} schemaVersion={output.schemaVersion} />
|
|
||||||
</MetaRow>
|
|
||||||
<MetaRow label="Input-Hash">
|
|
||||||
<Typography variant="caption" sx={{ fontFamily: 'monospace', fontSize: '0.65rem', color: '#94a3b8' }}>
|
|
||||||
{output.inputHash}
|
|
||||||
</Typography>
|
|
||||||
</MetaRow>
|
|
||||||
<MetaRow label="Bezug">
|
|
||||||
<Typography variant="caption" sx={{ color: '#334155' }}>
|
|
||||||
{ENTITY_TYPE_LABELS[output.relatedEntityType] ?? output.relatedEntityType}{' '}
|
|
||||||
<span style={{ fontFamily: 'monospace', fontSize: '0.65rem', color: '#94a3b8' }}>
|
|
||||||
{output.relatedEntityId}
|
|
||||||
</span>
|
|
||||||
</Typography>
|
|
||||||
</MetaRow>
|
|
||||||
{output.latencyMs != null && (
|
|
||||||
<MetaRow label="Latenz">
|
|
||||||
<Typography variant="caption" sx={{ color: output.latencyMs > 5000 ? '#c0392b' : '#334155', fontWeight: output.latencyMs > 5000 ? 700 : 400 }}>
|
|
||||||
{(output.latencyMs / 1000).toFixed(2)}s
|
|
||||||
</Typography>
|
|
||||||
</MetaRow>
|
|
||||||
)}
|
|
||||||
{output.costEstimate != null && (
|
|
||||||
<MetaRow label="Kostenschätzung">
|
|
||||||
<Typography variant="caption" sx={{ color: '#334155' }}>
|
|
||||||
${output.costEstimate.toFixed(4)}
|
|
||||||
</Typography>
|
|
||||||
</MetaRow>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Divider sx={{ my: 1.5 }} />
|
|
||||||
|
|
||||||
{/* Error details */}
|
|
||||||
{output.error && (
|
|
||||||
<Box sx={{ mb: 1.5 }}>
|
|
||||||
<Alert
|
|
||||||
severity="error"
|
|
||||||
sx={{ '& .MuiAlert-message': { fontSize: '0.8rem' }, mb: 1 }}
|
|
||||||
>
|
|
||||||
<strong>{ERROR_TYPE_LABELS[output.error.type] ?? output.error.type}</strong>
|
|
||||||
<br />
|
|
||||||
{output.error.message}
|
|
||||||
{output.error.recoverable && (
|
|
||||||
<Typography variant="caption" sx={{ display: 'block', mt: 0.5, color: '#92400e' }}>
|
|
||||||
Wiederholbar — kann erneut ausgelöst werden.
|
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
</Alert>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Output preview */}
|
|
||||||
<Box sx={{ mb: 1.5 }}>
|
|
||||||
<Typography variant="caption" sx={{ fontWeight: 700, textTransform: 'uppercase', letterSpacing: 0.5, color: '#64748b', display: 'block', mb: 0.5 }}>
|
|
||||||
Output-Vorschau
|
|
||||||
</Typography>
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
p: 1.25,
|
|
||||||
bgcolor: '#f8fafc',
|
|
||||||
borderRadius: 1,
|
|
||||||
border: '1px solid #e2e8f0',
|
|
||||||
fontFamily: 'monospace',
|
|
||||||
fontSize: '0.75rem',
|
|
||||||
color: '#334155',
|
|
||||||
lineHeight: 1.6,
|
|
||||||
overflowX: 'auto',
|
|
||||||
whiteSpace: 'pre-wrap',
|
|
||||||
wordBreak: 'break-all',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{output.outputPreview || '(kein Output)'}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Divider sx={{ mb: 1.5 }} />
|
|
||||||
|
|
||||||
{/* Actions */}
|
|
||||||
<AIReviewActionToolbar
|
|
||||||
output={output}
|
|
||||||
onUpdateStatus={onUpdateStatus}
|
|
||||||
onCopyJson={handleCopyJson}
|
|
||||||
isSubmitting={isSubmitting}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
import { Chip } from '@mui/material'
|
|
||||||
import type { ReviewStatus } from '../../domain/enums'
|
|
||||||
|
|
||||||
const CONFIG: Record<ReviewStatus, { label: string; color: string }> = {
|
|
||||||
UNREVIEWED: { label: 'Ungeprüft', color: '#94a3b8' },
|
|
||||||
IN_REVIEW: { label: 'In Prüfung', color: '#d97706' },
|
|
||||||
APPROVED: { label: 'Genehmigt', color: '#1a7a4a' },
|
|
||||||
REJECTED: { label: 'Abgelehnt', color: '#c0392b' },
|
|
||||||
FLAGGED: { label: 'Markiert', color: '#ea580c' },
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AIOutputStatusBadge({ status }: { status: ReviewStatus }) {
|
|
||||||
const { label, color } = CONFIG[status] ?? { label: status, color: '#64748b' }
|
|
||||||
return (
|
|
||||||
<Chip
|
|
||||||
size="small"
|
|
||||||
label={label}
|
|
||||||
sx={{ bgcolor: `${color}18`, color, fontWeight: 600, fontSize: '0.7rem' }}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,137 +0,0 @@
|
|||||||
import {
|
|
||||||
Box,
|
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableHead,
|
|
||||||
TableRow,
|
|
||||||
Typography,
|
|
||||||
} from '@mui/material'
|
|
||||||
import { AIOutputStatusBadge } from './AIOutputStatusBadge'
|
|
||||||
import { PromptVersionBadge } from './PromptVersionBadge'
|
|
||||||
import { AIErrorBadge } from './AIErrorBadge'
|
|
||||||
import { AIMonitoringEmptyState } from './AIMonitoringEmptyState'
|
|
||||||
import type { AIOutput, AIOutputType } from '../../domain/aiOutput'
|
|
||||||
|
|
||||||
const TYPE_LABELS: Record<AIOutputType, string> = {
|
|
||||||
NEED_PARSE: 'Bedarf-Parsing',
|
|
||||||
FOLLOW_UP_QUESTIONS: 'Rückfragen',
|
|
||||||
MATCH_EXPLANATION: 'Match-Begründung',
|
|
||||||
COMPARE_SUMMARY: 'Vergleich',
|
|
||||||
DECISION_BRIEF: 'Entscheidungs-Brief',
|
|
||||||
DATA_QUALITY_SUMMARY: 'Datenqualität',
|
|
||||||
}
|
|
||||||
|
|
||||||
const TYPE_COLORS: Record<AIOutputType, string> = {
|
|
||||||
NEED_PARSE: '#1e3a5f',
|
|
||||||
FOLLOW_UP_QUESTIONS: '#0891b2',
|
|
||||||
MATCH_EXPLANATION: '#4f46e5',
|
|
||||||
COMPARE_SUMMARY: '#1a7a4a',
|
|
||||||
DECISION_BRIEF: '#7c3aed',
|
|
||||||
DATA_QUALITY_SUMMARY: '#d97706',
|
|
||||||
}
|
|
||||||
|
|
||||||
const MODEL_SHORT: Record<string, string> = {
|
|
||||||
'claude-3-5-sonnet-20241022': 'Sonnet 3.5',
|
|
||||||
'claude-3-haiku-20240307': 'Haiku 3',
|
|
||||||
'claude-3-opus-20240229': 'Opus 3',
|
|
||||||
}
|
|
||||||
|
|
||||||
function shortTime(iso: string) {
|
|
||||||
return new Date(iso).toLocaleString('de-CH', { dateStyle: 'short', timeStyle: 'short' })
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
outputs: AIOutput[]
|
|
||||||
selectedId: string | null
|
|
||||||
onSelect: (output: AIOutput) => void
|
|
||||||
isEmpty: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AIOutputTable({ outputs, selectedId, onSelect, isEmpty }: Props) {
|
|
||||||
if (isEmpty && outputs.length === 0) {
|
|
||||||
return <AIMonitoringEmptyState context="no-outputs" />
|
|
||||||
}
|
|
||||||
if (outputs.length === 0) {
|
|
||||||
return <AIMonitoringEmptyState context="filtered-empty" />
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Table size="small" stickyHeader>
|
|
||||||
<TableHead>
|
|
||||||
<TableRow sx={{ '& th': { bgcolor: '#f8fafc', fontSize: '0.7rem', fontWeight: 700, color: '#64748b', py: 0.75, textTransform: 'uppercase', letterSpacing: 0.4 } }}>
|
|
||||||
<TableCell sx={{ minWidth: 110 }}>Zeitpunkt</TableCell>
|
|
||||||
<TableCell sx={{ minWidth: 130 }}>Typ</TableCell>
|
|
||||||
<TableCell sx={{ minWidth: 100 }}>Modell</TableCell>
|
|
||||||
<TableCell sx={{ minWidth: 140 }}>Version</TableCell>
|
|
||||||
<TableCell sx={{ minWidth: 95 }}>Status</TableCell>
|
|
||||||
<TableCell sx={{ minWidth: 65 }}>Latenz</TableCell>
|
|
||||||
<TableCell sx={{ minWidth: 80 }}>Fehler</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
</TableHead>
|
|
||||||
<TableBody>
|
|
||||||
{outputs.map(output => {
|
|
||||||
const isSelected = selectedId === output.id
|
|
||||||
const color = TYPE_COLORS[output.type] ?? '#64748b'
|
|
||||||
return (
|
|
||||||
<TableRow
|
|
||||||
key={output.id}
|
|
||||||
hover
|
|
||||||
onClick={() => onSelect(output)}
|
|
||||||
sx={{
|
|
||||||
cursor: 'pointer',
|
|
||||||
bgcolor: isSelected ? '#eff6ff' : undefined,
|
|
||||||
borderLeft: isSelected ? '3px solid #1e3a5f' : '3px solid transparent',
|
|
||||||
'&:hover': { bgcolor: isSelected ? '#eff6ff' : '#f8fafc' },
|
|
||||||
'& td': { py: 0.75, borderBottom: '1px solid #f1f5f9' },
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<TableCell>
|
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.7rem', whiteSpace: 'nowrap' }}>
|
|
||||||
{shortTime(output.createdAt)}
|
|
||||||
</Typography>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
display: 'inline-flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
px: 0.75,
|
|
||||||
py: 0.2,
|
|
||||||
borderRadius: 1,
|
|
||||||
bgcolor: `${color}12`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Typography variant="caption" sx={{ color, fontWeight: 700, fontSize: '0.7rem', whiteSpace: 'nowrap' }}>
|
|
||||||
{TYPE_LABELS[output.type] ?? output.type}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Typography variant="caption" sx={{ fontWeight: 500, fontSize: '0.7rem', color: '#334155' }}>
|
|
||||||
{MODEL_SHORT[output.model] ?? output.model}
|
|
||||||
</Typography>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<PromptVersionBadge promptVersion={output.promptVersion} schemaVersion={output.schemaVersion} />
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<AIOutputStatusBadge status={output.reviewStatus} />
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Typography variant="caption" sx={{ fontSize: '0.7rem', color: output.latencyMs && output.latencyMs > 5000 ? '#c0392b' : '#64748b' }}>
|
|
||||||
{output.latencyMs != null ? `${(output.latencyMs / 1000).toFixed(1)}s` : '–'}
|
|
||||||
</Typography>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
{output.error ? <AIErrorBadge error={output.error} /> : (
|
|
||||||
<Typography variant="caption" color="text.disabled" sx={{ fontSize: '0.7rem' }}>–</Typography>
|
|
||||||
)}
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
-71
@@ -1,71 +0,0 @@
|
|||||||
import { Box, Button, Tooltip } from '@mui/material'
|
|
||||||
import { CheckCircle, XCircle, Send, Copy } from 'lucide-react'
|
|
||||||
import type { AIOutput } from '../../domain/aiOutput'
|
|
||||||
import type { ReviewStatus } from '../../domain/enums'
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
output: AIOutput
|
|
||||||
onUpdateStatus: (status: ReviewStatus) => void
|
|
||||||
onCopyJson: () => void
|
|
||||||
isSubmitting?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AIReviewActionToolbar({ output, onUpdateStatus, onCopyJson, isSubmitting }: Props) {
|
|
||||||
const { reviewStatus } = output
|
|
||||||
|
|
||||||
const canSendToReview = reviewStatus === 'UNREVIEWED' || reviewStatus === 'FLAGGED'
|
|
||||||
const canApprove = reviewStatus === 'IN_REVIEW' || reviewStatus === 'UNREVIEWED'
|
|
||||||
const canReject = reviewStatus !== 'REJECTED'
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box sx={{ display: 'flex', gap: 0.75, flexWrap: 'wrap' }}>
|
|
||||||
{canSendToReview && (
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
variant="outlined"
|
|
||||||
disabled={isSubmitting}
|
|
||||||
startIcon={<Send size={13} />}
|
|
||||||
onClick={() => onUpdateStatus('IN_REVIEW')}
|
|
||||||
sx={{ textTransform: 'none', fontSize: '0.75rem', color: '#d97706', borderColor: '#d97706', '&:hover': { bgcolor: '#fffbeb', borderColor: '#b45309' } }}
|
|
||||||
>
|
|
||||||
Zur Prüfung
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{canApprove && (
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
variant="contained"
|
|
||||||
disabled={isSubmitting}
|
|
||||||
startIcon={<CheckCircle size={13} />}
|
|
||||||
onClick={() => onUpdateStatus('APPROVED')}
|
|
||||||
sx={{ textTransform: 'none', fontSize: '0.75rem', bgcolor: '#1a7a4a', '&:hover': { bgcolor: '#155f3a' } }}
|
|
||||||
>
|
|
||||||
Genehmigen
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{canReject && (
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
variant="outlined"
|
|
||||||
disabled={isSubmitting}
|
|
||||||
startIcon={<XCircle size={13} />}
|
|
||||||
onClick={() => onUpdateStatus('REJECTED')}
|
|
||||||
sx={{ textTransform: 'none', fontSize: '0.75rem', color: '#c0392b', borderColor: '#c0392b', '&:hover': { bgcolor: '#fef2f2', borderColor: '#a93226' } }}
|
|
||||||
>
|
|
||||||
Ablehnen
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Tooltip title="Output-JSON kopieren">
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
variant="outlined"
|
|
||||||
onClick={onCopyJson}
|
|
||||||
startIcon={<Copy size={13} />}
|
|
||||||
sx={{ textTransform: 'none', fontSize: '0.75rem', color: '#64748b', borderColor: '#e2e8f0', '&:hover': { bgcolor: '#f8fafc' } }}
|
|
||||||
>
|
|
||||||
JSON
|
|
||||||
</Button>
|
|
||||||
</Tooltip>
|
|
||||||
</Box>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
import { Box, Tooltip, Typography } from '@mui/material'
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
promptVersion: string
|
|
||||||
schemaVersion?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export function PromptVersionBadge({ promptVersion, schemaVersion }: Props) {
|
|
||||||
const badge = (
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
display: 'inline-flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 0.5,
|
|
||||||
px: 0.75,
|
|
||||||
py: 0.2,
|
|
||||||
bgcolor: '#f1f5f9',
|
|
||||||
borderRadius: 1,
|
|
||||||
border: '1px solid #e2e8f0',
|
|
||||||
cursor: schemaVersion ? 'default' : undefined,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Typography
|
|
||||||
variant="caption"
|
|
||||||
sx={{ fontFamily: 'monospace', fontSize: '0.65rem', color: '#475569', fontWeight: 600, lineHeight: 1.4 }}
|
|
||||||
>
|
|
||||||
{promptVersion}
|
|
||||||
</Typography>
|
|
||||||
{schemaVersion && (
|
|
||||||
<>
|
|
||||||
<Box sx={{ width: '1px', height: 10, bgcolor: '#cbd5e1', flexShrink: 0 }} />
|
|
||||||
<Typography
|
|
||||||
variant="caption"
|
|
||||||
sx={{ fontFamily: 'monospace', fontSize: '0.6rem', color: '#94a3b8', lineHeight: 1.4 }}
|
|
||||||
>
|
|
||||||
{schemaVersion}
|
|
||||||
</Typography>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
)
|
|
||||||
|
|
||||||
return schemaVersion ? (
|
|
||||||
<Tooltip title={`Prompt: ${promptVersion} · Schema: ${schemaVersion}`}>{badge}</Tooltip>
|
|
||||||
) : badge
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
export { AIOutputStatusBadge } from './AIOutputStatusBadge'
|
|
||||||
export { PromptVersionBadge } from './PromptVersionBadge'
|
|
||||||
export { AIErrorBadge } from './AIErrorBadge'
|
|
||||||
export { AIMonitoringEmptyState } from './AIMonitoringEmptyState'
|
|
||||||
export { AIMonitoringMetrics } from './AIMonitoringMetrics'
|
|
||||||
export { AIOutputTable } from './AIOutputTable'
|
|
||||||
export { AIReviewActionToolbar } from './AIReviewActionToolbar'
|
|
||||||
export { AIOutputDetailPanel } from './AIOutputDetailPanel'
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
import { Box, Button, Typography } from '@mui/material'
|
|
||||||
import { ArrowRight } from 'lucide-react'
|
|
||||||
import { useNavigate } from 'react-router'
|
|
||||||
import type { AssistantAction } from '../../domain/assistant'
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
actions: AssistantAction[]
|
|
||||||
onExecute?: (action: AssistantAction) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
const ACTION_COLORS: Record<string, string> = {
|
|
||||||
NAVIGATE: '#1e3a5f',
|
|
||||||
OPEN_REVIEW: '#7c3aed',
|
|
||||||
ADD_TO_SHORTLIST: '#1a7a4a',
|
|
||||||
REQUEST_DATA: '#d97706',
|
|
||||||
SEND_TO_REVIEW: '#ea580c',
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AssistantActionCards({ actions, onExecute }: Props) {
|
|
||||||
const navigate = useNavigate()
|
|
||||||
|
|
||||||
const handleExecute = (action: AssistantAction) => {
|
|
||||||
if (action.actionType === 'NAVIGATE' && action.payload?.path) {
|
|
||||||
navigate(action.payload.path as string)
|
|
||||||
} else if (action.actionType === 'OPEN_REVIEW') {
|
|
||||||
navigate('/ops/review-queue')
|
|
||||||
}
|
|
||||||
onExecute?.(action)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!actions.length) return null
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box sx={{ px: 2, pt: 0.75, pb: 0.5 }}>
|
|
||||||
<Typography variant="caption" sx={{ fontWeight: 700, textTransform: 'uppercase', letterSpacing: 0.5, color: '#94a3b8', display: 'block', mb: 0.625, fontSize: '0.6rem' }}>
|
|
||||||
Vorgeschlagene Aktionen
|
|
||||||
</Typography>
|
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
|
|
||||||
{actions.map(action => {
|
|
||||||
const color = ACTION_COLORS[action.actionType] ?? '#64748b'
|
|
||||||
return (
|
|
||||||
<Box
|
|
||||||
key={action.id}
|
|
||||||
sx={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
px: 1.25,
|
|
||||||
py: 0.875,
|
|
||||||
borderRadius: 1.5,
|
|
||||||
border: `1px solid ${color}30`,
|
|
||||||
bgcolor: `${color}08`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Box>
|
|
||||||
<Typography variant="caption" sx={{ fontWeight: 700, color, fontSize: '0.75rem', display: 'block' }}>
|
|
||||||
{action.label}
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.7rem' }}>
|
|
||||||
{action.description}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
variant="outlined"
|
|
||||||
endIcon={<ArrowRight size={12} />}
|
|
||||||
onClick={() => handleExecute(action)}
|
|
||||||
sx={{
|
|
||||||
textTransform: 'none',
|
|
||||||
fontSize: '0.7rem',
|
|
||||||
color,
|
|
||||||
borderColor: `${color}60`,
|
|
||||||
flexShrink: 0,
|
|
||||||
ml: 1,
|
|
||||||
py: 0.4,
|
|
||||||
'&:hover': { borderColor: color, bgcolor: `${color}12` },
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Ausführen
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
import { Box, Chip, Typography } from '@mui/material'
|
|
||||||
import type { AssistantContext } from '../../domain/assistant'
|
|
||||||
|
|
||||||
const PAGE_LABELS: Record<string, string> = {
|
|
||||||
'/supply/dashboard': 'Übersicht',
|
|
||||||
'/supply/properties': 'Meine Objekte',
|
|
||||||
'/supply/match-center': 'Eingehende Bedarfe',
|
|
||||||
'/supply/data-quality': 'Datenpflege',
|
|
||||||
'/supply/future-availability':'Marktchancen',
|
|
||||||
'/demand/ai-search': 'Flächensuche',
|
|
||||||
'/demand/results': 'Ergebnisse',
|
|
||||||
'/demand/compare': 'Vergleich',
|
|
||||||
'/demand/shortlists': 'Shortlists',
|
|
||||||
'/ops/review-queue': 'Review Queue',
|
|
||||||
'/ops/ai-monitoring': 'AI Monitoring',
|
|
||||||
'/ops/governance': 'Governance',
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolvePageLabel(route: string): string {
|
|
||||||
for (const [path, label] of Object.entries(PAGE_LABELS)) {
|
|
||||||
if (route.startsWith(path)) return label
|
|
||||||
}
|
|
||||||
return route.split('/').filter(Boolean).pop()?.replace(/-/g, ' ') ?? 'Seite'
|
|
||||||
}
|
|
||||||
|
|
||||||
const ENTITY_LABELS: Record<string, string> = {
|
|
||||||
PROPERTY: 'Objekt', NEED: 'Gesuch', MATCH: 'Match',
|
|
||||||
SIGNAL: 'Signal', AI_OUTPUT: 'AI-Output',
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
context: AssistantContext
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AssistantContextSummary({ context }: Props) {
|
|
||||||
const pageLabel = resolvePageLabel(context.currentRoute)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box sx={{ px: 2, py: 1, bgcolor: '#f8fafc', borderBottom: '1px solid #e2e8f0' }}>
|
|
||||||
<Box sx={{ display: 'flex', gap: 0.75, flexWrap: 'wrap', alignItems: 'center' }}>
|
|
||||||
<Chip
|
|
||||||
label={pageLabel}
|
|
||||||
size="small"
|
|
||||||
sx={{ bgcolor: '#e0f2fe', color: '#075985', fontWeight: 600, fontSize: '0.7rem', height: 20 }}
|
|
||||||
/>
|
|
||||||
{context.selectedEntityType && context.selectedEntityId && (
|
|
||||||
<Chip
|
|
||||||
label={`${ENTITY_LABELS[context.selectedEntityType] ?? context.selectedEntityType}: ${context.selectedEntityId}`}
|
|
||||||
size="small"
|
|
||||||
sx={{ bgcolor: '#f1f5f9', color: '#475569', fontWeight: 500, fontSize: '0.65rem', height: 20, fontFamily: 'monospace' }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{context.visibleScores?.quality !== undefined && (
|
|
||||||
<Chip
|
|
||||||
label={`Qualität: ${context.visibleScores.quality}%`}
|
|
||||||
size="small"
|
|
||||||
sx={{
|
|
||||||
bgcolor: context.visibleScores.quality >= 70 ? '#dcfce7' : '#fef3c7',
|
|
||||||
color: context.visibleScores.quality >= 70 ? '#166534' : '#92400e',
|
|
||||||
fontWeight: 600, fontSize: '0.65rem', height: 20,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{context.visibleScores?.matchScore !== undefined && (
|
|
||||||
<Chip
|
|
||||||
label={`Match: ${context.visibleScores.matchScore}%`}
|
|
||||||
size="small"
|
|
||||||
sx={{ bgcolor: '#ede9fe', color: '#5b21b6', fontWeight: 600, fontSize: '0.65rem', height: 20 }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
{context.visibleMissingData && context.visibleMissingData.length > 0 && (
|
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 0.5, fontSize: '0.65rem' }}>
|
|
||||||
Fehlende Daten: {context.visibleMissingData.slice(0, 3).join(', ')}
|
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
import { Alert, Box, Button } from '@mui/material'
|
|
||||||
import { RefreshCw } from 'lucide-react'
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
error: string
|
|
||||||
onRetry?: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AssistantErrorState({ error, onRetry }: Props) {
|
|
||||||
return (
|
|
||||||
<Box sx={{ px: 2, py: 1 }}>
|
|
||||||
<Alert
|
|
||||||
severity="error"
|
|
||||||
sx={{ '& .MuiAlert-message': { fontSize: '0.8rem' } }}
|
|
||||||
action={
|
|
||||||
onRetry ? (
|
|
||||||
<Button size="small" onClick={onRetry} startIcon={<RefreshCw size={12} />} sx={{ textTransform: 'none', fontSize: '0.75rem' }}>
|
|
||||||
Erneut
|
|
||||||
</Button>
|
|
||||||
) : undefined
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{error}
|
|
||||||
</Alert>
|
|
||||||
</Box>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
import { Box, Typography } from '@mui/material'
|
|
||||||
|
|
||||||
export function AssistantLoadingState() {
|
|
||||||
return (
|
|
||||||
<Box sx={{ display: 'flex', gap: 1, px: 2, py: 1.5, alignItems: 'flex-start' }}>
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
width: 28,
|
|
||||||
height: 28,
|
|
||||||
borderRadius: '50%',
|
|
||||||
bgcolor: '#4f46e5',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
flexShrink: 0,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Typography variant="caption" sx={{ color: 'white', fontWeight: 700, fontSize: '0.625rem' }}>AI</Typography>
|
|
||||||
</Box>
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
bgcolor: '#f1f5f9',
|
|
||||||
borderRadius: '0 8px 8px 8px',
|
|
||||||
px: 1.5,
|
|
||||||
py: 1,
|
|
||||||
display: 'flex',
|
|
||||||
gap: 0.5,
|
|
||||||
alignItems: 'center',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{[0, 1, 2].map(i => (
|
|
||||||
<Box
|
|
||||||
key={i}
|
|
||||||
sx={{
|
|
||||||
width: 6,
|
|
||||||
height: 6,
|
|
||||||
borderRadius: '50%',
|
|
||||||
bgcolor: '#94a3b8',
|
|
||||||
animation: 'bounce 1.2s ease-in-out infinite',
|
|
||||||
animationDelay: `${i * 0.2}s`,
|
|
||||||
'@keyframes bounce': {
|
|
||||||
'0%, 80%, 100%': { transform: 'scale(0.8)', opacity: 0.5 },
|
|
||||||
'40%': { transform: 'scale(1.2)', opacity: 1 },
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
import { Box, Typography } from '@mui/material'
|
|
||||||
import { AssistantActionCards } from './AssistantActionCards'
|
|
||||||
import type { AssistantMessage } from '../../domain/assistant'
|
|
||||||
|
|
||||||
function MessageBubble({ message }: { message: AssistantMessage }) {
|
|
||||||
const isUser = message.role === 'user'
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box sx={{ display: 'flex', flexDirection: isUser ? 'row-reverse' : 'row', gap: 1, px: 2, py: 0.75, alignItems: 'flex-start' }}>
|
|
||||||
{!isUser && (
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
width: 28, height: 28, borderRadius: '50%', bgcolor: '#4f46e5',
|
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0, mt: 0.25,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Typography variant="caption" sx={{ color: 'white', fontWeight: 700, fontSize: '0.625rem' }}>AI</Typography>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Box sx={{ maxWidth: '80%', minWidth: 0 }}>
|
|
||||||
{/* Bubble */}
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
px: 1.5,
|
|
||||||
py: 1,
|
|
||||||
borderRadius: isUser ? '8px 8px 2px 8px' : '2px 8px 8px 8px',
|
|
||||||
bgcolor: isUser ? '#1e3a5f' : '#f1f5f9',
|
|
||||||
color: isUser ? 'white' : '#1e293b',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Typography
|
|
||||||
variant="body2"
|
|
||||||
sx={{
|
|
||||||
fontSize: '0.8125rem',
|
|
||||||
lineHeight: 1.6,
|
|
||||||
whiteSpace: 'pre-wrap',
|
|
||||||
color: 'inherit',
|
|
||||||
'& strong': { fontWeight: 700 },
|
|
||||||
}}
|
|
||||||
dangerouslySetInnerHTML={{
|
|
||||||
__html: message.content
|
|
||||||
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
|
||||||
.replace(/\n/g, '<br/>'),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Metadata */}
|
|
||||||
{!isUser && (message.confidence !== undefined || (message.sources && message.sources.length > 0)) && (
|
|
||||||
<Box sx={{ display: 'flex', gap: 0.75, mt: 0.5, flexWrap: 'wrap', alignItems: 'center' }}>
|
|
||||||
{message.confidence !== undefined && (
|
|
||||||
<Typography variant="caption" sx={{ fontSize: '0.65rem', color: '#94a3b8' }}>
|
|
||||||
Konfidenz: {Math.round(message.confidence * 100)}%
|
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
{message.sources?.map(s => (
|
|
||||||
<Typography key={s} variant="caption" sx={{ fontSize: '0.65rem', color: '#94a3b8', bgcolor: '#f8fafc', px: 0.5, py: 0.125, borderRadius: 0.5, border: '1px solid #e2e8f0' }}>
|
|
||||||
{s}
|
|
||||||
</Typography>
|
|
||||||
))}
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Timestamp */}
|
|
||||||
<Typography variant="caption" sx={{ display: 'block', fontSize: '0.6rem', color: '#cbd5e1', mt: 0.25, textAlign: isUser ? 'right' : 'left' }}>
|
|
||||||
{new Date(message.createdAt).toLocaleTimeString('de-CH', { timeStyle: 'short' })}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
messages: AssistantMessage[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AssistantMessageList({ messages }: Props) {
|
|
||||||
return (
|
|
||||||
<Box>
|
|
||||||
{messages.map((msg) => (
|
|
||||||
<Box key={msg.id}>
|
|
||||||
<MessageBubble message={msg} />
|
|
||||||
{msg.role === 'assistant' && msg.actions && msg.actions.length > 0 && (
|
|
||||||
<AssistantActionCards actions={msg.actions} />
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
))}
|
|
||||||
</Box>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
-91
@@ -1,91 +0,0 @@
|
|||||||
import { Box, Chip, Typography } from '@mui/material'
|
|
||||||
import type { SuggestedQuestion } from '../../domain/assistant'
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
suggestions: SuggestedQuestion[]
|
|
||||||
onSelect: (question: string) => void
|
|
||||||
disabled?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
const CATEGORY_COLORS: Record<string, string> = {
|
|
||||||
Match: '#4f46e5',
|
|
||||||
Datenqualität: '#d97706',
|
|
||||||
Priorisierung: '#1e3a5f',
|
|
||||||
Empfehlung: '#1a7a4a',
|
|
||||||
Risiko: '#c0392b',
|
|
||||||
Tradeoffs: '#ea580c',
|
|
||||||
Strategie: '#0891b2',
|
|
||||||
Analyse: '#7c3aed',
|
|
||||||
Erklärung: '#0891b2',
|
|
||||||
Evidenz: '#64748b',
|
|
||||||
Review: '#7c3aed',
|
|
||||||
Konfidenz: '#d97706',
|
|
||||||
Fehler: '#c0392b',
|
|
||||||
Fehleranalyse: '#ea580c',
|
|
||||||
Eskalation: '#ea580c',
|
|
||||||
Prozess: '#64748b',
|
|
||||||
Kosten: '#1a7a4a',
|
|
||||||
Impact: '#d97706',
|
|
||||||
Optimierung: '#1a7a4a',
|
|
||||||
Aktion: '#1e3a5f',
|
|
||||||
Überblick: '#64748b',
|
|
||||||
Ranking: '#4f46e5',
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AssistantPromptSuggestions({ suggestions, onSelect, disabled }: Props) {
|
|
||||||
if (suggestions.length === 0) return null
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box sx={{ px: 2, py: 1.25 }}>
|
|
||||||
<Typography variant="caption" sx={{ fontWeight: 700, textTransform: 'uppercase', letterSpacing: 0.5, color: '#94a3b8', display: 'block', mb: 0.75, fontSize: '0.65rem' }}>
|
|
||||||
Vorschläge
|
|
||||||
</Typography>
|
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.625 }}>
|
|
||||||
{suggestions.map(s => {
|
|
||||||
const catColor = CATEGORY_COLORS[s.category] ?? '#64748b'
|
|
||||||
return (
|
|
||||||
<Box
|
|
||||||
key={s.id}
|
|
||||||
onClick={() => !disabled && onSelect(s.question)}
|
|
||||||
sx={{
|
|
||||||
px: 1.25,
|
|
||||||
py: 0.875,
|
|
||||||
borderRadius: 1.5,
|
|
||||||
border: '1px solid #e2e8f0',
|
|
||||||
cursor: disabled ? 'default' : 'pointer',
|
|
||||||
bgcolor: 'white',
|
|
||||||
opacity: disabled ? 0.5 : 1,
|
|
||||||
'&:hover': disabled ? {} : { bgcolor: '#f8fafc', borderColor: '#cbd5e1' },
|
|
||||||
transition: 'all 0.1s ease',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
width: 3,
|
|
||||||
height: 28,
|
|
||||||
borderRadius: 2,
|
|
||||||
bgcolor: catColor,
|
|
||||||
flexShrink: 0,
|
|
||||||
opacity: 0.7,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
|
||||||
<Typography variant="caption" sx={{ fontSize: '0.8rem', color: '#334155', lineHeight: 1.4 }}>
|
|
||||||
{s.question}
|
|
||||||
</Typography>
|
|
||||||
<Chip
|
|
||||||
label={s.category}
|
|
||||||
size="small"
|
|
||||||
sx={{ height: 16, fontSize: '0.6rem', bgcolor: `${catColor}14`, color: catColor, fontWeight: 600, ml: 0.5, verticalAlign: 'middle' }}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
import { Box, IconButton, Tooltip } from '@mui/material'
|
|
||||||
import { Sparkles } from 'lucide-react'
|
|
||||||
import { useAssistantStore } from '../../stores/assistantStore'
|
|
||||||
|
|
||||||
export function GlobalAIAssistantButton() {
|
|
||||||
const { isOpen, open } = useAssistantStore()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Tooltip title="AI Assistent öffnen" placement="left">
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
position: 'fixed',
|
|
||||||
bottom: 24,
|
|
||||||
right: 24,
|
|
||||||
zIndex: 1250,
|
|
||||||
display: isOpen ? 'none' : 'flex',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<IconButton
|
|
||||||
onClick={open}
|
|
||||||
sx={{
|
|
||||||
width: 48,
|
|
||||||
height: 48,
|
|
||||||
bgcolor: '#4f46e5',
|
|
||||||
color: 'white',
|
|
||||||
boxShadow: '0 4px 16px rgba(79,70,229,0.4)',
|
|
||||||
'&:hover': { bgcolor: '#4338ca' },
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Sparkles size={20} />
|
|
||||||
</IconButton>
|
|
||||||
</Box>
|
|
||||||
</Tooltip>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
-264
@@ -1,264 +0,0 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
||||||
import { Box, Divider, Drawer, IconButton, TextField, Tooltip, Typography } from '@mui/material'
|
|
||||||
import { RotateCcw, Send, Sparkles, X } from 'lucide-react'
|
|
||||||
import { useLocation } from 'react-router'
|
|
||||||
import { useAssistantStore } from '../../stores/assistantStore'
|
|
||||||
import { useSessionStore } from '../../stores/sessionStore'
|
|
||||||
import { aiAssistantService } from '../../services/aiAssistantService'
|
|
||||||
import { AssistantContextSummary } from './AssistantContextSummary'
|
|
||||||
import { AssistantMessageList } from './AssistantMessageList'
|
|
||||||
import { AssistantPromptSuggestions } from './AssistantPromptSuggestions'
|
|
||||||
import { AssistantLoadingState } from './AssistantLoadingState'
|
|
||||||
import { AssistantErrorState } from './AssistantErrorState'
|
|
||||||
import type { AssistantContext, SuggestedQuestion } from '../../domain/assistant'
|
|
||||||
import type { WorkspaceType } from '../../domain/enums'
|
|
||||||
|
|
||||||
function resolveWorkspace(pathname: string): WorkspaceType | null {
|
|
||||||
if (pathname.startsWith('/supply')) return 'SUPPLY' as WorkspaceType
|
|
||||||
if (pathname.startsWith('/demand')) return 'DEMAND' as WorkspaceType
|
|
||||||
if (pathname.startsWith('/ops')) return 'OPERATIONS' as WorkspaceType
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
export function GlobalAIAssistantDrawer() {
|
|
||||||
const { isOpen, close, context, setContext, messages, isLoading, error, addMessage, setLoading, setError, clearConversation } =
|
|
||||||
useAssistantStore()
|
|
||||||
|
|
||||||
const { currentUser } = useSessionStore()
|
|
||||||
const location = useLocation()
|
|
||||||
|
|
||||||
const [suggestions, setSuggestions] = useState<SuggestedQuestion[]>([])
|
|
||||||
const [inputText, setInputText] = useState('')
|
|
||||||
const scrollRef = useRef<HTMLDivElement>(null)
|
|
||||||
|
|
||||||
// Build context from route when drawer opens
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isOpen) return
|
|
||||||
const ctx: AssistantContext = {
|
|
||||||
currentRoute: location.pathname,
|
|
||||||
workspace: resolveWorkspace(location.pathname),
|
|
||||||
userRole: currentUser?.role ?? 'VIEWER',
|
|
||||||
organizationId: currentUser?.organizationId ?? '',
|
|
||||||
}
|
|
||||||
setContext(ctx)
|
|
||||||
aiAssistantService.getSuggestions(ctx).then(setSuggestions)
|
|
||||||
}, [isOpen, location.pathname])
|
|
||||||
|
|
||||||
// Refresh suggestions when route changes while open
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isOpen) return
|
|
||||||
const ctx: AssistantContext = {
|
|
||||||
currentRoute: location.pathname,
|
|
||||||
workspace: resolveWorkspace(location.pathname),
|
|
||||||
userRole: currentUser?.role ?? 'VIEWER',
|
|
||||||
organizationId: currentUser?.organizationId ?? '',
|
|
||||||
}
|
|
||||||
setContext(ctx)
|
|
||||||
aiAssistantService.getSuggestions(ctx).then(setSuggestions)
|
|
||||||
}, [location.pathname])
|
|
||||||
|
|
||||||
// Auto-scroll on new messages
|
|
||||||
useEffect(() => {
|
|
||||||
if (scrollRef.current) {
|
|
||||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight
|
|
||||||
}
|
|
||||||
}, [messages, isLoading])
|
|
||||||
|
|
||||||
const handleQuestion = useCallback(async (question: string) => {
|
|
||||||
if (!question.trim() || isLoading) return
|
|
||||||
setInputText('')
|
|
||||||
setError(null)
|
|
||||||
|
|
||||||
const userMsg = {
|
|
||||||
id: crypto.randomUUID(),
|
|
||||||
role: 'user' as const,
|
|
||||||
content: question.trim(),
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
}
|
|
||||||
addMessage(userMsg)
|
|
||||||
setLoading(true)
|
|
||||||
|
|
||||||
try {
|
|
||||||
const ctx = context ?? {
|
|
||||||
currentRoute: location.pathname,
|
|
||||||
workspace: resolveWorkspace(location.pathname),
|
|
||||||
userRole: currentUser?.role ?? 'VIEWER',
|
|
||||||
organizationId: currentUser?.organizationId ?? '',
|
|
||||||
}
|
|
||||||
const answer = await aiAssistantService.answerQuestion(ctx, question)
|
|
||||||
addMessage({
|
|
||||||
id: crypto.randomUUID(),
|
|
||||||
role: 'assistant',
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
...answer,
|
|
||||||
})
|
|
||||||
} catch {
|
|
||||||
setError('Antwort konnte nicht generiert werden. Bitte erneut versuchen.')
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}, [context, isLoading, location.pathname, currentUser, addMessage, setLoading, setError])
|
|
||||||
|
|
||||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
|
||||||
if (e.key === 'Enter' && !e.shiftKey) {
|
|
||||||
e.preventDefault()
|
|
||||||
handleQuestion(inputText)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleClear = () => {
|
|
||||||
clearConversation()
|
|
||||||
setSuggestions([])
|
|
||||||
if (context) {
|
|
||||||
aiAssistantService.getSuggestions(context).then(setSuggestions)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const showSuggestions = suggestions.length > 0 && messages.length === 0
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Drawer
|
|
||||||
anchor="right"
|
|
||||||
open={isOpen}
|
|
||||||
onClose={close}
|
|
||||||
variant="temporary"
|
|
||||||
slotProps={{
|
|
||||||
paper: {
|
|
||||||
sx: {
|
|
||||||
width: 420,
|
|
||||||
top: '56px',
|
|
||||||
height: 'calc(100% - 56px)',
|
|
||||||
boxShadow: '-4px 0 24px rgba(0,0,0,0.12)',
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Header */}
|
|
||||||
<Box sx={{ px: 2, py: 1.5, borderBottom: '1px solid #e2e8f0', flexShrink: 0, display: 'flex', alignItems: 'center', gap: 1 }}>
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
width: 28, height: 28, borderRadius: '50%', bgcolor: '#4f46e5',
|
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Sparkles size={14} color="white" />
|
|
||||||
</Box>
|
|
||||||
<Box sx={{ flex: 1 }}>
|
|
||||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, fontSize: '0.875rem', lineHeight: 1.2 }}>
|
|
||||||
AI Assistent
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.7rem' }}>
|
|
||||||
Kontextbasierte Entscheidungsunterstützung
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
<Tooltip title="Gespräch zurücksetzen">
|
|
||||||
<IconButton size="small" onClick={handleClear} disabled={messages.length === 0} sx={{ color: '#94a3b8' }}>
|
|
||||||
<RotateCcw size={14} />
|
|
||||||
</IconButton>
|
|
||||||
</Tooltip>
|
|
||||||
<IconButton size="small" onClick={close} sx={{ color: '#94a3b8' }}>
|
|
||||||
<X size={16} />
|
|
||||||
</IconButton>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Context summary */}
|
|
||||||
{context && <AssistantContextSummary context={context} />}
|
|
||||||
|
|
||||||
{/* Scrollable body */}
|
|
||||||
<Box
|
|
||||||
ref={scrollRef}
|
|
||||||
sx={{ flex: 1, overflowY: 'auto', display: 'flex', flexDirection: 'column' }}
|
|
||||||
>
|
|
||||||
{/* Welcome message */}
|
|
||||||
{messages.length === 0 && !isLoading && (
|
|
||||||
<Box sx={{ px: 2, py: 1.5 }}>
|
|
||||||
<Box sx={{ bgcolor: '#f8fafc', borderRadius: 2, p: 1.5, border: '1px solid #e2e8f0' }}>
|
|
||||||
<Typography variant="body2" sx={{ fontSize: '0.8125rem', color: '#334155', lineHeight: 1.6 }}>
|
|
||||||
Ich helfe Ihnen mit kontextbezogenen Fragen zu dieser Seite. Meine Antworten basieren auf strukturierten Daten — keine erfundenen Fakten.
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Suggestions */}
|
|
||||||
{showSuggestions && (
|
|
||||||
<>
|
|
||||||
<AssistantPromptSuggestions
|
|
||||||
suggestions={suggestions}
|
|
||||||
onSelect={handleQuestion}
|
|
||||||
disabled={isLoading}
|
|
||||||
/>
|
|
||||||
<Divider sx={{ mx: 2, my: 0.5 }} />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Messages */}
|
|
||||||
{messages.length > 0 && (
|
|
||||||
<Box sx={{ py: 0.5 }}>
|
|
||||||
<AssistantMessageList messages={messages} />
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Inline suggestions after messages */}
|
|
||||||
{messages.length > 0 && suggestions.length > 0 && !isLoading && (
|
|
||||||
<>
|
|
||||||
<Divider sx={{ mx: 2, my: 0.5 }} />
|
|
||||||
<AssistantPromptSuggestions
|
|
||||||
suggestions={suggestions.slice(0, 2)}
|
|
||||||
onSelect={handleQuestion}
|
|
||||||
disabled={isLoading}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Loading */}
|
|
||||||
{isLoading && <AssistantLoadingState />}
|
|
||||||
|
|
||||||
{/* Error */}
|
|
||||||
{error && <AssistantErrorState error={error} onRetry={() => setError(null)} />}
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Input area */}
|
|
||||||
<Box sx={{ px: 2, py: 1.25, borderTop: '1px solid #e2e8f0', flexShrink: 0 }}>
|
|
||||||
<Box sx={{ display: 'flex', gap: 1, alignItems: 'flex-end' }}>
|
|
||||||
<TextField
|
|
||||||
multiline
|
|
||||||
maxRows={4}
|
|
||||||
fullWidth
|
|
||||||
size="small"
|
|
||||||
placeholder="Frage stellen…"
|
|
||||||
value={inputText}
|
|
||||||
onChange={e => setInputText(e.target.value)}
|
|
||||||
onKeyDown={handleKeyDown}
|
|
||||||
disabled={isLoading}
|
|
||||||
sx={{
|
|
||||||
'& .MuiOutlinedInput-root': { fontSize: '0.8125rem', borderRadius: 2 },
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Tooltip title="Senden (Enter)">
|
|
||||||
<span>
|
|
||||||
<IconButton
|
|
||||||
onClick={() => handleQuestion(inputText)}
|
|
||||||
disabled={!inputText.trim() || isLoading}
|
|
||||||
sx={{
|
|
||||||
bgcolor: '#4f46e5',
|
|
||||||
color: 'white',
|
|
||||||
flexShrink: 0,
|
|
||||||
'&:hover': { bgcolor: '#4338ca' },
|
|
||||||
'&:disabled': { bgcolor: '#e2e8f0', color: '#94a3b8' },
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Send size={16} />
|
|
||||||
</IconButton>
|
|
||||||
</span>
|
|
||||||
</Tooltip>
|
|
||||||
</Box>
|
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 0.5, fontSize: '0.65rem', textAlign: 'center' }}>
|
|
||||||
Antworten sind datenbasiert — Aktionen erfordern manuelle Bestätigung
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
</Drawer>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
export { GlobalAIAssistantButton } from './GlobalAIAssistantButton'
|
|
||||||
export { GlobalAIAssistantDrawer } from './GlobalAIAssistantDrawer'
|
|
||||||
export { AssistantMessageList } from './AssistantMessageList'
|
|
||||||
export { AssistantPromptSuggestions } from './AssistantPromptSuggestions'
|
|
||||||
export { AssistantContextSummary } from './AssistantContextSummary'
|
|
||||||
export { AssistantActionCards } from './AssistantActionCards'
|
|
||||||
export { AssistantLoadingState } from './AssistantLoadingState'
|
|
||||||
export { AssistantErrorState } from './AssistantErrorState'
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
import { Box, Button, Typography } from '@mui/material'
|
|
||||||
import type { SxProps, Theme } from '@mui/material'
|
|
||||||
import { ShieldOff } from 'lucide-react'
|
|
||||||
import { useNavigate } from 'react-router'
|
|
||||||
|
|
||||||
interface AccessDeniedProps {
|
|
||||||
title?: string
|
|
||||||
message?: string
|
|
||||||
onBack?: () => void
|
|
||||||
sx?: SxProps<Theme>
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AccessDenied({
|
|
||||||
title = 'Kein Zugriff',
|
|
||||||
message = 'Sie haben keine Berechtigung, diesen Bereich zu öffnen.',
|
|
||||||
onBack,
|
|
||||||
sx,
|
|
||||||
}: AccessDeniedProps) {
|
|
||||||
const navigate = useNavigate()
|
|
||||||
|
|
||||||
function handleBack() {
|
|
||||||
if (onBack) {
|
|
||||||
onBack()
|
|
||||||
} else {
|
|
||||||
navigate(-1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
minHeight: 400,
|
|
||||||
gap: 2,
|
|
||||||
p: 4,
|
|
||||||
...sx,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
width: 56,
|
|
||||||
height: 56,
|
|
||||||
borderRadius: '50%',
|
|
||||||
bgcolor: 'rgba(239,68,68,0.08)',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ShieldOff size={28} color="#ef4444" />
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Box sx={{ textAlign: 'center', maxWidth: 400 }}>
|
|
||||||
<Typography variant="h6" sx={{ fontWeight: 600, mb: 0.5 }}>
|
|
||||||
{title}
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
|
||||||
{message}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="outlined"
|
|
||||||
size="small"
|
|
||||||
onClick={handleBack}
|
|
||||||
sx={{ mt: 1, textTransform: 'none' }}
|
|
||||||
>
|
|
||||||
Zurück
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
import { Box, Chip, Typography } from '@mui/material'
|
|
||||||
import { UserRole } from '../../domain/enums'
|
|
||||||
import { authService } from '../../services/authService'
|
|
||||||
import { useSessionStore } from '../../stores/sessionStore'
|
|
||||||
|
|
||||||
const ROLE_LABELS: Record<UserRole, string> = {
|
|
||||||
[UserRole.SUPER_ADMIN]: 'Super Admin',
|
|
||||||
[UserRole.ORGANIZATION_ADMIN]: 'Org Admin',
|
|
||||||
[UserRole.PROPERTY_MANAGER]: 'Prop. Manager',
|
|
||||||
[UserRole.REVIEWER]: 'Reviewer',
|
|
||||||
[UserRole.OWNER_VIEWER]: 'Owner Viewer',
|
|
||||||
[UserRole.DEMAND_USER]: 'Demand User',
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DemoRoleSwitcher() {
|
|
||||||
const { currentUser } = useSessionStore()
|
|
||||||
|
|
||||||
async function handleSwitch(role: UserRole) {
|
|
||||||
await authService.switchDemoRole(role)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box sx={{ px: 1, py: 0.5 }}>
|
|
||||||
<Typography
|
|
||||||
variant="caption"
|
|
||||||
sx={{ color: 'text.secondary', fontWeight: 600, textTransform: 'uppercase', letterSpacing: 0.5, display: 'block', mb: 0.75 }}
|
|
||||||
>
|
|
||||||
Demo-Modus
|
|
||||||
</Typography>
|
|
||||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
|
|
||||||
{Object.values(UserRole).map((role) => {
|
|
||||||
const active = currentUser?.role === role
|
|
||||||
return (
|
|
||||||
<Chip
|
|
||||||
key={role}
|
|
||||||
label={ROLE_LABELS[role]}
|
|
||||||
size="small"
|
|
||||||
clickable
|
|
||||||
onClick={() => handleSwitch(role)}
|
|
||||||
sx={{
|
|
||||||
fontSize: '0.7rem',
|
|
||||||
height: 22,
|
|
||||||
bgcolor: active ? '#1e3a5f' : 'transparent',
|
|
||||||
color: active ? '#fff' : 'text.secondary',
|
|
||||||
border: '1px solid',
|
|
||||||
borderColor: active ? '#1e3a5f' : 'divider',
|
|
||||||
'&:hover': { bgcolor: active ? '#162d4a' : 'rgba(0,0,0,0.04)' },
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
import { FormControl, MenuItem, Select, Typography } from '@mui/material'
|
|
||||||
import type { SelectChangeEvent } from '@mui/material'
|
|
||||||
import { authService } from '../../services/authService'
|
|
||||||
import { useSessionStore } from '../../stores/sessionStore'
|
|
||||||
|
|
||||||
const MOCK_ORGANIZATIONS = [
|
|
||||||
{ id: 'org-wincasa', name: 'Wincasa AG' },
|
|
||||||
{ id: 'org-mobimo', name: 'Mobimo Management AG' },
|
|
||||||
{ id: 'org-ubs', name: 'UBS Asset Management RE' },
|
|
||||||
]
|
|
||||||
|
|
||||||
export function OrganizationSwitcher() {
|
|
||||||
const { activeOrganizationId } = useSessionStore()
|
|
||||||
|
|
||||||
async function handleChange(e: SelectChangeEvent<string>) {
|
|
||||||
await authService.switchOrganization(e.target.value)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<FormControl size="small" fullWidth sx={{ mt: 0.5 }}>
|
|
||||||
<Typography
|
|
||||||
variant="caption"
|
|
||||||
sx={{ color: 'text.secondary', fontWeight: 600, textTransform: 'uppercase', letterSpacing: 0.5, mb: 0.5, display: 'block' }}
|
|
||||||
>
|
|
||||||
Organisation
|
|
||||||
</Typography>
|
|
||||||
<Select
|
|
||||||
value={activeOrganizationId ?? ''}
|
|
||||||
onChange={handleChange}
|
|
||||||
sx={{ fontSize: '0.8rem' }}
|
|
||||||
>
|
|
||||||
{MOCK_ORGANIZATIONS.map((org) => (
|
|
||||||
<MenuItem key={org.id} value={org.id} sx={{ fontSize: '0.8rem' }}>
|
|
||||||
{org.name}
|
|
||||||
</MenuItem>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
</FormControl>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
import type { ReactNode } from 'react'
|
|
||||||
import type { MockUser } from '../../stores/sessionStore'
|
|
||||||
import { useSessionStore } from '../../stores/sessionStore'
|
|
||||||
|
|
||||||
interface PermissionGateProps {
|
|
||||||
check: (user: MockUser) => boolean
|
|
||||||
fallback?: ReactNode
|
|
||||||
children: ReactNode
|
|
||||||
}
|
|
||||||
|
|
||||||
export function PermissionGate({ check, fallback = null, children }: PermissionGateProps) {
|
|
||||||
const { currentUser } = useSessionStore()
|
|
||||||
|
|
||||||
if (!currentUser || !check(currentUser)) {
|
|
||||||
return <>{fallback}</>
|
|
||||||
}
|
|
||||||
|
|
||||||
return <>{children}</>
|
|
||||||
}
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
import { Navigate, Outlet } from 'react-router'
|
|
||||||
import type { WorkspaceType } from '../../domain/enums'
|
|
||||||
import { useSessionStore } from '../../stores/sessionStore'
|
|
||||||
import { SessionStatus } from '../../stores/sessionStore'
|
|
||||||
import { canAccessWorkspace } from '../../lib/permissions'
|
|
||||||
import { AccessDenied } from './AccessDenied'
|
|
||||||
import { SessionExpired } from './SessionExpired'
|
|
||||||
|
|
||||||
interface ProtectedRouteProps {
|
|
||||||
workspace?: WorkspaceType
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ProtectedRoute({ workspace }: ProtectedRouteProps) {
|
|
||||||
const { isAuthenticated, currentUser, sessionStatus } = useSessionStore()
|
|
||||||
|
|
||||||
if (!isAuthenticated || sessionStatus === SessionStatus.UNAUTHENTICATED) {
|
|
||||||
return <Navigate to="/auth/login" replace />
|
|
||||||
}
|
|
||||||
|
|
||||||
if (sessionStatus === SessionStatus.EXPIRED) {
|
|
||||||
return <SessionExpired />
|
|
||||||
}
|
|
||||||
|
|
||||||
if (workspace && currentUser && !canAccessWorkspace(currentUser, workspace)) {
|
|
||||||
const workspaceLabel: Record<string, string> = {
|
|
||||||
SUPPLY: 'Supply',
|
|
||||||
DEMAND: 'Demand',
|
|
||||||
OPERATIONS: 'Operations',
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<AccessDenied
|
|
||||||
message={`Sie haben keine Berechtigung für den ${workspaceLabel[workspace] ?? workspace}-Bereich.`}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return <Outlet />
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
import type { ReactNode } from 'react'
|
|
||||||
import type { UserRole } from '../../domain/enums'
|
|
||||||
import { useSessionStore } from '../../stores/sessionStore'
|
|
||||||
import { AccessDenied } from './AccessDenied'
|
|
||||||
|
|
||||||
interface RoleGuardProps {
|
|
||||||
roles: UserRole[]
|
|
||||||
fallback?: ReactNode
|
|
||||||
children: ReactNode
|
|
||||||
}
|
|
||||||
|
|
||||||
export function RoleGuard({ roles, fallback, children }: RoleGuardProps) {
|
|
||||||
const { currentUser } = useSessionStore()
|
|
||||||
|
|
||||||
if (!currentUser || !roles.includes(currentUser.role)) {
|
|
||||||
return <>{fallback ?? <AccessDenied />}</>
|
|
||||||
}
|
|
||||||
|
|
||||||
return <>{children}</>
|
|
||||||
}
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
import { Box, Button, Typography } from '@mui/material'
|
|
||||||
import { Clock } from 'lucide-react'
|
|
||||||
import { useNavigate } from 'react-router'
|
|
||||||
import { useSessionStore } from '../../stores/sessionStore'
|
|
||||||
|
|
||||||
export function SessionExpired() {
|
|
||||||
const { logout } = useSessionStore()
|
|
||||||
const navigate = useNavigate()
|
|
||||||
|
|
||||||
function handleRelogin() {
|
|
||||||
logout()
|
|
||||||
navigate('/auth/login')
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
minHeight: '100vh',
|
|
||||||
gap: 2,
|
|
||||||
bgcolor: '#f8fafc',
|
|
||||||
p: 4,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
width: 56,
|
|
||||||
height: 56,
|
|
||||||
borderRadius: '50%',
|
|
||||||
bgcolor: 'rgba(245,158,11,0.1)',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Clock size={28} color="#f59e0b" />
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Box sx={{ textAlign: 'center', maxWidth: 380 }}>
|
|
||||||
<Typography variant="h6" sx={{ fontWeight: 600, mb: 0.5 }}>
|
|
||||||
Sitzung abgelaufen
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
|
||||||
Ihre Sitzung ist abgelaufen. Bitte melden Sie sich erneut an.
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="contained"
|
|
||||||
onClick={handleRelogin}
|
|
||||||
sx={{ mt: 1, bgcolor: '#1e3a5f', textTransform: 'none' }}
|
|
||||||
>
|
|
||||||
Erneut anmelden
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
export { AccessDenied } from './AccessDenied'
|
|
||||||
export { SessionExpired } from './SessionExpired'
|
|
||||||
export { PermissionGate } from './PermissionGate'
|
|
||||||
export { RoleGuard } from './RoleGuard'
|
|
||||||
export { ProtectedRoute } from './ProtectedRoute'
|
|
||||||
export { DemoRoleSwitcher } from './DemoRoleSwitcher'
|
|
||||||
export { OrganizationSwitcher } from './OrganizationSwitcher'
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
import { Chip } from '@mui/material'
|
|
||||||
import { CheckCircle2, Clock, Sparkles, XCircle, HelpCircle } from 'lucide-react'
|
|
||||||
import type { AvailabilityStatus } from '../../domain/enums'
|
|
||||||
import { DS_COLORS } from '../../lib/ds'
|
|
||||||
import { AVAILABILITY_LABELS } from '../../lib/constants'
|
|
||||||
|
|
||||||
interface AvailabilityBadgeProps {
|
|
||||||
status: AvailabilityStatus
|
|
||||||
size?: 'small' | 'medium'
|
|
||||||
}
|
|
||||||
|
|
||||||
const ICONS: Record<AvailabilityStatus, React.ElementType> = {
|
|
||||||
AVAILABLE_NOW: CheckCircle2,
|
|
||||||
AVAILABLE_SOON: Clock,
|
|
||||||
FUTURE_SIGNAL: Sparkles,
|
|
||||||
OCCUPIED: XCircle,
|
|
||||||
UNKNOWN: HelpCircle,
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AvailabilityBadge({ status, size = 'small' }: AvailabilityBadgeProps) {
|
|
||||||
const { bg, fg } = DS_COLORS.availability[status]
|
|
||||||
const Icon = ICONS[status]
|
|
||||||
return (
|
|
||||||
<Chip
|
|
||||||
size={size}
|
|
||||||
label={AVAILABILITY_LABELS[status] ?? status}
|
|
||||||
icon={<Icon size={11} color={fg} />}
|
|
||||||
aria-label={`Verfügbarkeit: ${AVAILABILITY_LABELS[status] ?? status}`}
|
|
||||||
sx={{
|
|
||||||
bgcolor: bg,
|
|
||||||
color: fg,
|
|
||||||
border: 'none',
|
|
||||||
fontWeight: 600,
|
|
||||||
fontSize: '0.7rem',
|
|
||||||
'& .MuiChip-icon': { ml: 0.5 },
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
import { Chip } from '@mui/material'
|
|
||||||
import { ShieldCheck } from 'lucide-react'
|
|
||||||
import type { ConfidenceLevel } from '../../domain/enums'
|
|
||||||
import { DS_COLORS, scoreToConfidenceLevel } from '../../lib/ds'
|
|
||||||
import { CONFIDENCE_LABELS } from '../../lib/constants'
|
|
||||||
|
|
||||||
interface ConfidenceBadgeProps {
|
|
||||||
level?: ConfidenceLevel
|
|
||||||
score?: number
|
|
||||||
size?: 'small' | 'medium'
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ConfidenceBadge({ level, score, size = 'small' }: ConfidenceBadgeProps) {
|
|
||||||
const resolved: ConfidenceLevel =
|
|
||||||
level ?? (score !== undefined ? scoreToConfidenceLevel(score) : 'MEDIUM')
|
|
||||||
const { bg, fg } = DS_COLORS.confidence[resolved]
|
|
||||||
const scoreLabel = score !== undefined ? ` (${Math.round(score * 100)}%)` : ''
|
|
||||||
const label = `${CONFIDENCE_LABELS[resolved] ?? resolved}${scoreLabel}`
|
|
||||||
return (
|
|
||||||
<Chip
|
|
||||||
size={size}
|
|
||||||
label={label}
|
|
||||||
icon={<ShieldCheck size={11} color={fg} />}
|
|
||||||
aria-label={`Konfidenz: ${label}`}
|
|
||||||
sx={{
|
|
||||||
bgcolor: bg,
|
|
||||||
color: fg,
|
|
||||||
border: 'none',
|
|
||||||
fontWeight: 600,
|
|
||||||
fontSize: '0.7rem',
|
|
||||||
'& .MuiChip-icon': { ml: 0.5 },
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
import { Chip } from '@mui/material'
|
|
||||||
import { CheckCircle2, AlertTriangle, XCircle } from 'lucide-react'
|
|
||||||
import type { DataQualityLevel } from '../../domain/enums'
|
|
||||||
import { DS_COLORS, scoreToDataQualityLevel } from '../../lib/ds'
|
|
||||||
import { DATA_QUALITY_LABELS } from '../../lib/constants'
|
|
||||||
|
|
||||||
interface DataQualityBadgeProps {
|
|
||||||
level?: DataQualityLevel
|
|
||||||
score?: number
|
|
||||||
showScore?: boolean
|
|
||||||
size?: 'small' | 'medium'
|
|
||||||
}
|
|
||||||
|
|
||||||
const ICONS: Record<DataQualityLevel, React.ElementType> = {
|
|
||||||
HIGH: CheckCircle2,
|
|
||||||
MEDIUM: AlertTriangle,
|
|
||||||
LOW: XCircle,
|
|
||||||
INCOMPLETE: XCircle,
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DataQualityBadge({ level, score, showScore = false, size = 'small' }: DataQualityBadgeProps) {
|
|
||||||
const resolved: DataQualityLevel =
|
|
||||||
level ?? (score !== undefined ? scoreToDataQualityLevel(score) : 'LOW')
|
|
||||||
const { bg, fg } = DS_COLORS.dataQuality[resolved]
|
|
||||||
const Icon = ICONS[resolved]
|
|
||||||
const scoreLabel = showScore && score !== undefined ? ` ${Math.round(score * 100)}%` : ''
|
|
||||||
const label = `${DATA_QUALITY_LABELS[resolved] ?? resolved}${scoreLabel}`
|
|
||||||
return (
|
|
||||||
<Chip
|
|
||||||
size={size}
|
|
||||||
label={label}
|
|
||||||
icon={<Icon size={11} color={fg} />}
|
|
||||||
aria-label={`Datenqualität: ${label}`}
|
|
||||||
sx={{
|
|
||||||
bgcolor: bg,
|
|
||||||
color: fg,
|
|
||||||
border: 'none',
|
|
||||||
fontWeight: 600,
|
|
||||||
fontSize: '0.7rem',
|
|
||||||
'& .MuiChip-icon': { ml: 0.5 },
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
import { Chip } from '@mui/material'
|
|
||||||
import { Zap, Clock, AlertCircle } from 'lucide-react'
|
|
||||||
import type { FreshnessStatus } from '../../domain/enums'
|
|
||||||
import { DS_COLORS } from '../../lib/ds'
|
|
||||||
import { FRESHNESS_LABELS } from '../../lib/constants'
|
|
||||||
|
|
||||||
interface FreshnessBadgeProps {
|
|
||||||
status: FreshnessStatus
|
|
||||||
size?: 'small' | 'medium'
|
|
||||||
}
|
|
||||||
|
|
||||||
const ICONS: Record<FreshnessStatus, React.ElementType> = {
|
|
||||||
FRESH: Zap,
|
|
||||||
STALE: Clock,
|
|
||||||
OUTDATED: AlertCircle,
|
|
||||||
}
|
|
||||||
|
|
||||||
export function FreshnessBadge({ status, size = 'small' }: FreshnessBadgeProps) {
|
|
||||||
const { bg, fg } = DS_COLORS.freshness[status]
|
|
||||||
const Icon = ICONS[status]
|
|
||||||
return (
|
|
||||||
<Chip
|
|
||||||
size={size}
|
|
||||||
label={FRESHNESS_LABELS[status] ?? status}
|
|
||||||
icon={<Icon size={11} color={fg} />}
|
|
||||||
aria-label={`Datenaktualität: ${FRESHNESS_LABELS[status] ?? status}`}
|
|
||||||
sx={{
|
|
||||||
bgcolor: bg,
|
|
||||||
color: fg,
|
|
||||||
border: 'none',
|
|
||||||
fontWeight: 600,
|
|
||||||
fontSize: '0.7rem',
|
|
||||||
'& .MuiChip-icon': { ml: 0.5 },
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
import { Chip } from '@mui/material'
|
|
||||||
import { ShieldCheck, Globe, Sparkles } from 'lucide-react'
|
|
||||||
import type { ResultType } from '../../domain/enums'
|
|
||||||
import { DS_COLORS } from '../../lib/ds'
|
|
||||||
import { RESULT_TYPE_LABELS } from '../../lib/constants'
|
|
||||||
|
|
||||||
interface ResultTypeBadgeProps {
|
|
||||||
type: ResultType
|
|
||||||
size?: 'small' | 'medium'
|
|
||||||
}
|
|
||||||
|
|
||||||
const ICONS: Record<ResultType, React.ElementType> = {
|
|
||||||
VERIFIED_PORTFOLIO: ShieldCheck,
|
|
||||||
EXTERNAL_MARKET: Globe,
|
|
||||||
FUTURE_AVAILABILITY: Sparkles,
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ResultTypeBadge({ type, size = 'small' }: ResultTypeBadgeProps) {
|
|
||||||
const { bg, fg } = DS_COLORS.resultType[type]
|
|
||||||
const Icon = ICONS[type]
|
|
||||||
return (
|
|
||||||
<Chip
|
|
||||||
size={size}
|
|
||||||
label={RESULT_TYPE_LABELS[type] ?? type}
|
|
||||||
icon={<Icon size={11} color={fg} />}
|
|
||||||
aria-label={RESULT_TYPE_LABELS[type] ?? type}
|
|
||||||
sx={{
|
|
||||||
bgcolor: bg,
|
|
||||||
color: fg,
|
|
||||||
border: 'none',
|
|
||||||
fontWeight: 600,
|
|
||||||
fontSize: '0.7rem',
|
|
||||||
'& .MuiChip-icon': { ml: 0.5 },
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
import { Chip } from '@mui/material'
|
|
||||||
import { AlertTriangle, Shield } from 'lucide-react'
|
|
||||||
import type { RiskLevel } from '../../domain/enums'
|
|
||||||
import { DS_COLORS } from '../../lib/ds'
|
|
||||||
import { RISK_LABELS } from '../../lib/constants'
|
|
||||||
|
|
||||||
interface RiskBadgeProps {
|
|
||||||
level: RiskLevel
|
|
||||||
size?: 'small' | 'medium'
|
|
||||||
}
|
|
||||||
|
|
||||||
export function RiskBadge({ level, size = 'small' }: RiskBadgeProps) {
|
|
||||||
const { bg, fg } = DS_COLORS.risk[level]
|
|
||||||
const Icon = level === 'LOW' || level === 'MEDIUM' ? Shield : AlertTriangle
|
|
||||||
return (
|
|
||||||
<Chip
|
|
||||||
size={size}
|
|
||||||
label={RISK_LABELS[level] ?? level}
|
|
||||||
icon={<Icon size={11} color={fg} />}
|
|
||||||
aria-label={`Risiko: ${RISK_LABELS[level] ?? level}`}
|
|
||||||
sx={{
|
|
||||||
bgcolor: bg,
|
|
||||||
color: fg,
|
|
||||||
border: 'none',
|
|
||||||
fontWeight: 600,
|
|
||||||
fontSize: '0.7rem',
|
|
||||||
'& .MuiChip-icon': { ml: 0.5 },
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
export { ResultTypeBadge } from './ResultTypeBadge'
|
|
||||||
export { ConfidenceBadge } from './ConfidenceBadge'
|
|
||||||
export { RiskBadge } from './RiskBadge'
|
|
||||||
export { AvailabilityBadge } from './AvailabilityBadge'
|
|
||||||
export { FreshnessBadge } from './FreshnessBadge'
|
|
||||||
export { DataQualityBadge } from './DataQualityBadge'
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
import { Box, Card, Typography } from '@mui/material'
|
|
||||||
import type { SxProps, Theme } from '@mui/material'
|
|
||||||
import type { ReactNode } from 'react'
|
|
||||||
|
|
||||||
interface CompactCardProps {
|
|
||||||
title: string
|
|
||||||
meta?: string
|
|
||||||
leading?: ReactNode
|
|
||||||
trailing?: ReactNode
|
|
||||||
onClick?: () => void
|
|
||||||
selected?: boolean
|
|
||||||
sx?: SxProps<Theme>
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CompactCard({ title, meta, leading, trailing, onClick, selected = false, sx }: CompactCardProps) {
|
|
||||||
return (
|
|
||||||
<Card
|
|
||||||
onClick={onClick}
|
|
||||||
sx={{
|
|
||||||
outline: selected ? '2px solid #1e3a5f' : 'none',
|
|
||||||
outlineOffset: -1,
|
|
||||||
bgcolor: selected ? 'rgba(30,58,95,0.03)' : 'background.paper',
|
|
||||||
cursor: onClick ? 'pointer' : 'default',
|
|
||||||
'&:hover': onClick ? { bgcolor: 'rgba(0,0,0,0.015)' } : {},
|
|
||||||
...sx,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 1.5,
|
|
||||||
px: 2,
|
|
||||||
py: 1,
|
|
||||||
minHeight: 48,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{leading && <Box sx={{ flexShrink: 0 }}>{leading}</Box>}
|
|
||||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
|
||||||
<Typography sx={{ fontWeight: 500, fontSize: '0.875rem', lineHeight: 1.3 }} noWrap>
|
|
||||||
{title}
|
|
||||||
</Typography>
|
|
||||||
{meta && (
|
|
||||||
<Typography sx={{ fontSize: '0.75rem', color: 'text.secondary', lineHeight: 1.3 }} noWrap>
|
|
||||||
{meta}
|
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
{trailing && <Box sx={{ flexShrink: 0 }}>{trailing}</Box>}
|
|
||||||
</Box>
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
import { Box, Card, CardActions, CardContent, Divider, Typography } from '@mui/material'
|
|
||||||
import type { SxProps, Theme } from '@mui/material'
|
|
||||||
import type { ReactNode } from 'react'
|
|
||||||
import { CardSkeleton } from '../ui/CardSkeleton'
|
|
||||||
import { RestrictedState } from '../ui/RestrictedState'
|
|
||||||
|
|
||||||
interface DecisionCardProps {
|
|
||||||
title: string
|
|
||||||
subtitle?: string
|
|
||||||
badges?: ReactNode
|
|
||||||
score?: ReactNode
|
|
||||||
body?: ReactNode
|
|
||||||
actions?: ReactNode
|
|
||||||
selected?: boolean
|
|
||||||
isLoading?: boolean
|
|
||||||
isRestricted?: boolean
|
|
||||||
onClick?: () => void
|
|
||||||
sx?: SxProps<Theme>
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DecisionCard({
|
|
||||||
title,
|
|
||||||
subtitle,
|
|
||||||
badges,
|
|
||||||
score,
|
|
||||||
body,
|
|
||||||
actions,
|
|
||||||
selected = false,
|
|
||||||
isLoading = false,
|
|
||||||
isRestricted = false,
|
|
||||||
onClick,
|
|
||||||
sx,
|
|
||||||
}: DecisionCardProps) {
|
|
||||||
if (isLoading) return <CardSkeleton hasActions={!!actions} />
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card
|
|
||||||
onClick={onClick}
|
|
||||||
sx={{
|
|
||||||
outline: selected ? '2px solid #1e3a5f' : 'none',
|
|
||||||
outlineOffset: -1,
|
|
||||||
bgcolor: selected ? 'rgba(30,58,95,0.03)' : 'background.paper',
|
|
||||||
cursor: onClick ? 'pointer' : 'default',
|
|
||||||
transition: 'outline 0.1s, background-color 0.1s',
|
|
||||||
'&:hover': onClick ? { bgcolor: 'rgba(0,0,0,0.01)' } : {},
|
|
||||||
position: 'relative',
|
|
||||||
...sx,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{isRestricted ? (
|
|
||||||
<RestrictedState sx={{ py: 5 }} />
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<CardContent sx={{ pb: body || actions ? 1 : 2 }}>
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 1 }}>
|
|
||||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
|
||||||
<Typography
|
|
||||||
sx={{ fontWeight: 600, fontSize: '0.9375rem', lineHeight: 1.3, mb: subtitle ? 0.25 : 0 }}
|
|
||||||
noWrap
|
|
||||||
>
|
|
||||||
{title}
|
|
||||||
</Typography>
|
|
||||||
{subtitle && (
|
|
||||||
<Typography sx={{ fontSize: '0.8125rem', color: 'text.secondary', lineHeight: 1.4 }}>
|
|
||||||
{subtitle}
|
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
{score && <Box sx={{ flexShrink: 0 }}>{score}</Box>}
|
|
||||||
</Box>
|
|
||||||
{badges && (
|
|
||||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5, mt: 1 }}>
|
|
||||||
{badges}
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
|
|
||||||
{body && (
|
|
||||||
<>
|
|
||||||
<Divider />
|
|
||||||
<CardContent sx={{ py: 1.5 }}>{body}</CardContent>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{actions && (
|
|
||||||
<>
|
|
||||||
<Divider />
|
|
||||||
<CardActions sx={{ px: 2, py: 1 }}>{actions}</CardActions>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
import { Box, Card, Typography } from '@mui/material'
|
|
||||||
import { TrendingUp, TrendingDown } from 'lucide-react'
|
|
||||||
import type { SxProps, Theme } from '@mui/material'
|
|
||||||
import type { ReactNode } from 'react'
|
|
||||||
|
|
||||||
interface MetricDelta {
|
|
||||||
value: string
|
|
||||||
positive: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MetricCardProps {
|
|
||||||
label: string
|
|
||||||
value: string | number
|
|
||||||
delta?: MetricDelta
|
|
||||||
icon?: ReactNode
|
|
||||||
color?: string
|
|
||||||
sx?: SxProps<Theme>
|
|
||||||
}
|
|
||||||
|
|
||||||
export function MetricCard({ label, value, delta, icon, color = '#1e3a5f', sx }: MetricCardProps) {
|
|
||||||
return (
|
|
||||||
<Card sx={{ p: 2.5, ...sx }}>
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between' }}>
|
|
||||||
<Box>
|
|
||||||
<Typography
|
|
||||||
sx={{ fontSize: '0.6875rem', fontWeight: 600, color: 'text.disabled', textTransform: 'uppercase', letterSpacing: '0.08em', mb: 0.5 }}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</Typography>
|
|
||||||
<Typography sx={{ fontSize: '1.75rem', fontWeight: 700, color, lineHeight: 1, fontVariantNumeric: 'tabular-nums' }}>
|
|
||||||
{value}
|
|
||||||
</Typography>
|
|
||||||
{delta && (
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.25, mt: 0.5 }}>
|
|
||||||
{delta.positive
|
|
||||||
? <TrendingUp size={12} color="#1a7a4a" />
|
|
||||||
: <TrendingDown size={12} color="#c0392b" />}
|
|
||||||
<Typography
|
|
||||||
sx={{ fontSize: '0.75rem', color: delta.positive ? '#1a7a4a' : '#c0392b', fontWeight: 500 }}
|
|
||||||
>
|
|
||||||
{delta.value}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
{icon && (
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
width: 40,
|
|
||||||
height: 40,
|
|
||||||
borderRadius: 1.5,
|
|
||||||
bgcolor: `${color}18`,
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{icon}
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
import { Chip } from '@mui/material'
|
|
||||||
import { ShieldCheck, Globe, Sparkles } from 'lucide-react'
|
|
||||||
import type { ResultType } from '../../domain/enums'
|
|
||||||
import { RESULT_TYPE_LABELS } from '../../lib/constants'
|
|
||||||
|
|
||||||
interface SourceTypeBadgeProps {
|
|
||||||
type: ResultType
|
|
||||||
size?: 'small' | 'medium'
|
|
||||||
}
|
|
||||||
|
|
||||||
const CONFIG: Record<string, { bg: string; color: string; Icon: React.ElementType }> = {
|
|
||||||
VERIFIED_PORTFOLIO: { bg: 'rgba(30,58,95,0.1)', color: '#1e3a5f', Icon: ShieldCheck },
|
|
||||||
EXTERNAL_MARKET: { bg: 'rgba(217,119,6,0.1)', color: '#b45309', Icon: Globe },
|
|
||||||
FUTURE_AVAILABILITY: { bg: 'rgba(124,58,237,0.1)', color: '#6d28d9', Icon: Sparkles },
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SourceTypeBadge({ type, size = 'small' }: SourceTypeBadgeProps) {
|
|
||||||
const { bg, color, Icon } = CONFIG[type] ?? { bg: '#f1f5f9', color: '#475569', Icon: Globe }
|
|
||||||
return (
|
|
||||||
<Chip
|
|
||||||
icon={<Icon size={11} color={color} />}
|
|
||||||
label={RESULT_TYPE_LABELS[type] ?? type}
|
|
||||||
size={size}
|
|
||||||
sx={{ bgcolor: bg, color, fontWeight: 600, border: 'none', '& .MuiChip-icon': { ml: 0.5 } }}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
export { SourceTypeBadge } from './SourceTypeBadge'
|
|
||||||
export { DecisionCard } from './DecisionCard'
|
|
||||||
export { CompactCard } from './CompactCard'
|
|
||||||
export { MetricCard } from './MetricCard'
|
|
||||||
@@ -1,133 +0,0 @@
|
|||||||
import { useState } from 'react'
|
|
||||||
import { Alert, Box, Chip, CircularProgress, Collapse, Typography } from '@mui/material'
|
|
||||||
import { ChevronDown, ChevronUp, Trophy, TrendingDown, ShieldCheck, AlertTriangle, Info, ArrowRight } from 'lucide-react'
|
|
||||||
import type { ComparisonSummary } from '../../services/aiService'
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
summary?: ComparisonSummary
|
|
||||||
isLoading: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AICompareSummary({ summary, isLoading }: Props) {
|
|
||||||
const [open, setOpen] = useState(true)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box sx={{ mb: 2, border: '1px solid #e2e8f0', borderRadius: 1, overflow: 'hidden' }}>
|
|
||||||
<Box
|
|
||||||
onClick={() => setOpen(v => !v)}
|
|
||||||
sx={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
px: 2,
|
|
||||||
py: 1.25,
|
|
||||||
bgcolor: '#f8fafc',
|
|
||||||
cursor: 'pointer',
|
|
||||||
'&:hover': { bgcolor: '#f1f5f9' },
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
|
||||||
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>AI Vergleichs-Zusammenfassung</Typography>
|
|
||||||
<Chip label="Beta" size="small" sx={{ fontSize: 10, bgcolor: '#ede9fe', color: '#6d28d9' }} />
|
|
||||||
</Box>
|
|
||||||
{open ? <ChevronUp size={16} /> : <ChevronDown size={16} />}
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Collapse in={open}>
|
|
||||||
<Box sx={{ p: 2 }}>
|
|
||||||
{isLoading && (
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, py: 1 }}>
|
|
||||||
<CircularProgress size={16} />
|
|
||||||
<Typography variant="body2" color="text.secondary">Analyse wird erstellt…</Typography>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!isLoading && summary && (
|
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
|
|
||||||
{/* Strongest option */}
|
|
||||||
<Box sx={{ display: 'flex', gap: 1, alignItems: 'flex-start' }}>
|
|
||||||
<Trophy size={16} color="#1a7a4a" style={{ flexShrink: 0, marginTop: 2 }} />
|
|
||||||
<Box>
|
|
||||||
<Typography variant="caption" sx={{ fontWeight: 600, color: '#64748b', display: 'block' }}>Stärkstes Match</Typography>
|
|
||||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>{summary.strongestOption.label}</Typography>
|
|
||||||
<Typography variant="caption" color="text.secondary">{summary.strongestOption.reason}</Typography>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Best value */}
|
|
||||||
{summary.bestValue && (
|
|
||||||
<Box sx={{ display: 'flex', gap: 1, alignItems: 'flex-start' }}>
|
|
||||||
<TrendingDown size={16} color="#1e3a5f" style={{ flexShrink: 0, marginTop: 2 }} />
|
|
||||||
<Box>
|
|
||||||
<Typography variant="caption" sx={{ fontWeight: 600, color: '#64748b', display: 'block' }}>Bestes Preis-Leistungs-Verhältnis</Typography>
|
|
||||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>{summary.bestValue.label}</Typography>
|
|
||||||
<Typography variant="caption" color="text.secondary">{summary.bestValue.reason}</Typography>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Highest confidence */}
|
|
||||||
<Box sx={{ display: 'flex', gap: 1, alignItems: 'flex-start' }}>
|
|
||||||
<ShieldCheck size={16} color="#0891b2" style={{ flexShrink: 0, marginTop: 2 }} />
|
|
||||||
<Box>
|
|
||||||
<Typography variant="caption" sx={{ fontWeight: 600, color: '#64748b', display: 'block' }}>Höchste Datenkonfidenz</Typography>
|
|
||||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>
|
|
||||||
{summary.highestConfidence.label}
|
|
||||||
<Typography component="span" variant="caption" color="text.secondary" sx={{ ml: 0.5 }}>
|
|
||||||
({Math.round(summary.highestConfidence.confidenceLevel * 100)}%)
|
|
||||||
</Typography>
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Tradeoffs */}
|
|
||||||
{summary.biggestTradeoffs.length > 0 && (
|
|
||||||
<Box sx={{ display: 'flex', gap: 1, alignItems: 'flex-start' }}>
|
|
||||||
<AlertTriangle size={16} color="#d97706" style={{ flexShrink: 0, marginTop: 2 }} />
|
|
||||||
<Box>
|
|
||||||
<Typography variant="caption" sx={{ fontWeight: 600, color: '#64748b', display: 'block' }}>Wichtigste Abwägungen</Typography>
|
|
||||||
{summary.biggestTradeoffs.map((t, i) => (
|
|
||||||
<Typography key={i} variant="caption" color="text.secondary" sx={{ display: 'block' }}>· {t}</Typography>
|
|
||||||
))}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Missing data */}
|
|
||||||
{summary.missingDataWarnings.length > 0 && (
|
|
||||||
<Box sx={{ display: 'flex', gap: 1, alignItems: 'flex-start' }}>
|
|
||||||
<Info size={16} color="#c0392b" style={{ flexShrink: 0, marginTop: 2 }} />
|
|
||||||
<Box>
|
|
||||||
<Typography variant="caption" sx={{ fontWeight: 600, color: '#64748b', display: 'block' }}>Fehlende Informationen</Typography>
|
|
||||||
{summary.missingDataWarnings.map((w, i) => (
|
|
||||||
<Typography key={i} variant="caption" color="error" sx={{ display: 'block' }}>· {w}</Typography>
|
|
||||||
))}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Next step */}
|
|
||||||
<Box sx={{ display: 'flex', gap: 1, alignItems: 'flex-start', pt: 0.5, borderTop: '1px solid #f1f5f9', mt: 0.5 }}>
|
|
||||||
<ArrowRight size={16} color="#1e3a5f" style={{ flexShrink: 0, marginTop: 2 }} />
|
|
||||||
<Box>
|
|
||||||
<Typography variant="caption" sx={{ fontWeight: 600, color: '#64748b', display: 'block' }}>Empfohlener nächster Schritt</Typography>
|
|
||||||
<Typography variant="body2">{summary.recommendedNextStep}</Typography>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!isLoading && !summary && (
|
|
||||||
<Alert severity="info" sx={{ py: 0.5 }}>
|
|
||||||
Mindestens 2 Ergebnisse auswählen, um die Zusammenfassung zu generieren.
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 1.5, fontStyle: 'italic' }}>
|
|
||||||
Diese Zusammenfassung basiert ausschliesslich auf den vorliegenden Daten und trifft keine endgültige Entscheidung.
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
</Collapse>
|
|
||||||
</Box>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
import type { ReactNode } from 'react'
|
|
||||||
import { Box, Tooltip, Typography } from '@mui/material'
|
|
||||||
import { Info } from 'lucide-react'
|
|
||||||
|
|
||||||
export type CellHighlight = 'best' | 'worst' | 'critical' | 'future' | 'none'
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
highlight?: CellHighlight
|
|
||||||
icon?: ReactNode
|
|
||||||
iconTooltip?: string
|
|
||||||
children: ReactNode
|
|
||||||
}
|
|
||||||
|
|
||||||
const HIGHLIGHT_SX: Record<CellHighlight, object> = {
|
|
||||||
best: { bgcolor: '#f0fdf4', borderLeft: '3px solid #1a7a4a' },
|
|
||||||
worst: { bgcolor: '#fef3c7', borderLeft: '3px solid #d97706' },
|
|
||||||
critical: { bgcolor: '#fef2f2', borderLeft: '3px solid #c0392b' },
|
|
||||||
future: { bgcolor: '#faf5ff', borderLeft: '3px solid #7c3aed' },
|
|
||||||
none: {},
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CompareCell({ highlight = 'none', icon, iconTooltip, children }: Props) {
|
|
||||||
return (
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 0.75, ...HIGHLIGHT_SX[highlight] }}>
|
|
||||||
{icon && (
|
|
||||||
iconTooltip
|
|
||||||
? <Tooltip title={iconTooltip}><Box sx={{ flexShrink: 0, display: 'flex', mt: 0.25 }}>{icon}</Box></Tooltip>
|
|
||||||
: <Box sx={{ flexShrink: 0, display: 'flex', mt: 0.25 }}>{icon}</Box>
|
|
||||||
)}
|
|
||||||
<Box sx={{ flex: 1, minWidth: 0 }}>{children}</Box>
|
|
||||||
</Box>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function MissingDataCell({ reason }: { reason?: string }) {
|
|
||||||
return (
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
|
||||||
<Tooltip title={reason ?? 'Keine Daten vorhanden — kann Konfidenz beeinflussen'}>
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
|
||||||
<Info size={12} color="#94a3b8" />
|
|
||||||
</Box>
|
|
||||||
</Tooltip>
|
|
||||||
<Typography variant="body2" sx={{ color: '#94a3b8', fontStyle: 'italic' }}>
|
|
||||||
Nicht verfügbar
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
import { Box, Chip, IconButton, Tooltip, Typography } from '@mui/material'
|
|
||||||
import { X, AlertTriangle } from 'lucide-react'
|
|
||||||
import type { UnifiedMatchResult } from '../../domain/unifiedResult'
|
|
||||||
|
|
||||||
const TYPE_META: Record<string, { label: string; color: string }> = {
|
|
||||||
VERIFIED_PORTFOLIO: { label: 'Verified Portfolio', color: '#1e3a5f' },
|
|
||||||
EXTERNAL_MARKET: { label: 'Marktinserat', color: '#d97706' },
|
|
||||||
FUTURE_AVAILABILITY: { label: 'Zukunftssignal', color: '#7c3aed' },
|
|
||||||
}
|
|
||||||
|
|
||||||
const SCORE_COLOR = (s: number) => s >= 78 ? '#1a7a4a' : s >= 52 ? '#d97706' : '#c0392b'
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
item: UnifiedMatchResult
|
|
||||||
onRemove: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CompareColumnHeader({ item, onRemove }: Props) {
|
|
||||||
const meta = TYPE_META[item.resultType] ?? { label: item.resultType, color: '#64748b' }
|
|
||||||
const prop = item.resultType !== 'FUTURE_AVAILABILITY' ? (item as any).property : null
|
|
||||||
const sig = item.resultType === 'FUTURE_AVAILABILITY' ? (item as any).signal : null
|
|
||||||
|
|
||||||
const title = prop?.title ?? sig?.companyName ?? sig?.locationHint ?? '–'
|
|
||||||
const subtitle = prop?.location?.city ?? sig?.locationHint ?? '–'
|
|
||||||
const availability = prop?.availabilityDate ?? (sig ? `~${sig.timeHorizonMonths} Monate` : null)
|
|
||||||
const confidence = Math.round(item.match.confidenceLevel * 100)
|
|
||||||
const source = prop?.sourceLabel ?? sig?.source?.type ?? '–'
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box sx={{ p: 0.5 }}>
|
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', mb: 1 }}>
|
|
||||||
<Chip label={meta.label} size="small" sx={{ bgcolor: meta.color, color: 'white', fontWeight: 600, fontSize: 10 }} />
|
|
||||||
<IconButton size="small" onClick={onRemove} sx={{ p: 0.25, ml: 1 }}>
|
|
||||||
<X size={14} />
|
|
||||||
</IconButton>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 0.25, lineHeight: 1.3 }}>
|
|
||||||
{title}
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 1 }}>
|
|
||||||
{subtitle}
|
|
||||||
</Typography>
|
|
||||||
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'baseline', gap: 0.5, mb: 0.5 }}>
|
|
||||||
<Typography variant="h4" sx={{ fontWeight: 800, color: SCORE_COLOR(item.matchScore), lineHeight: 1 }}>
|
|
||||||
{item.matchScore}
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="caption" color="text.secondary">/100</Typography>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5, mt: 0.5 }}>
|
|
||||||
<Tooltip title="Konfidenz">
|
|
||||||
<Chip
|
|
||||||
label={`${confidence}% Konfidenz`}
|
|
||||||
size="small"
|
|
||||||
icon={confidence < 60 ? <AlertTriangle size={10} /> : undefined}
|
|
||||||
sx={{
|
|
||||||
fontSize: 10,
|
|
||||||
bgcolor: confidence < 60 ? '#fef3c7' : '#f0fdf4',
|
|
||||||
color: confidence < 60 ? '#92400e' : '#166534',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
{source && source !== '–' && (
|
|
||||||
<Chip label={source} size="small" sx={{ fontSize: 10 }} />
|
|
||||||
)}
|
|
||||||
{availability && (
|
|
||||||
<Chip label={availability} size="small" sx={{ fontSize: 10 }} />
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{item.resultType === 'FUTURE_AVAILABILITY' && sig && (
|
|
||||||
<Box sx={{ mt: 1, p: 0.75, bgcolor: '#faf5ff', borderRadius: 1, border: '1px solid #e9d5ff' }}>
|
|
||||||
<Typography variant="caption" sx={{ color: '#7c3aed', fontWeight: 600, display: 'block' }}>
|
|
||||||
Probabilistisches Signal
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="caption" color="text.secondary">
|
|
||||||
{Math.round(sig.probability * 100)}% Wahrscheinlichkeit
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
import { Box, Button, Typography } from '@mui/material'
|
|
||||||
import { Columns2 } from 'lucide-react'
|
|
||||||
import { useNavigate } from 'react-router'
|
|
||||||
|
|
||||||
export function CompareEmptyState() {
|
|
||||||
const navigate = useNavigate()
|
|
||||||
return (
|
|
||||||
<Box sx={{ px: 3, py: 4 }}>
|
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 2, py: 8 }}>
|
|
||||||
<Columns2 size={40} color="#94a3b8" />
|
|
||||||
<Typography variant="h6" color="text.secondary" sx={{ fontWeight: 600 }}>
|
|
||||||
Keine Ergebnisse zum Vergleich
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="body2" color="text.secondary" sx={{ textAlign: 'center', maxWidth: 360 }}>
|
|
||||||
Fügen Sie 2–4 Ergebnisse aus dem Feed, Match Detail oder Match Center zum Vergleich hinzu.
|
|
||||||
</Typography>
|
|
||||||
<Button variant="contained" size="small" onClick={() => navigate('/demand/results')}
|
|
||||||
sx={{ bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#162d4a' } }}>
|
|
||||||
Zu den Suchergebnissen
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
export { CompareEmptyState } from './CompareEmptyState'
|
|
||||||
export { CompareColumnHeader } from './CompareColumnHeader'
|
|
||||||
export { CompareCell, MissingDataCell } from './CompareCell'
|
|
||||||
export { AICompareSummary } from './AICompareSummary'
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
import { Alert, Box, Chip, Typography } from '@mui/material'
|
|
||||||
|
|
||||||
interface CriticalFieldWarningProps {
|
|
||||||
fields: string[]
|
|
||||||
warnings?: string[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CriticalFieldWarning({ fields, warnings = [] }: CriticalFieldWarningProps) {
|
|
||||||
if (fields.length === 0 && warnings.length === 0) return null
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box sx={{ mb: 2 }}>
|
|
||||||
{fields.length > 0 && (
|
|
||||||
<Alert severity="error" sx={{ mb: 1, py: 0.5, px: 1.5 }}>
|
|
||||||
<Typography variant="caption" sx={{ fontWeight: 600, display: 'block', mb: 0.5 }}>
|
|
||||||
{fields.length} Pflichtfeld{fields.length > 1 ? 'er' : ''} fehlen — Match-Qualität reduziert
|
|
||||||
</Typography>
|
|
||||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
|
|
||||||
{fields.map(f => (
|
|
||||||
<Chip key={f} label={f} size="small" color="error" variant="outlined"
|
|
||||||
sx={{ height: 18, fontSize: '0.65rem' }} />
|
|
||||||
))}
|
|
||||||
</Box>
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
{warnings.map((w, i) => (
|
|
||||||
<Alert key={i} severity="warning" sx={{ mb: 0.5, py: 0.25, px: 1.5, fontSize: '0.8125rem' }}>
|
|
||||||
{w}
|
|
||||||
</Alert>
|
|
||||||
))}
|
|
||||||
</Box>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
import { Chip, Tooltip } from '@mui/material'
|
|
||||||
import { dataQualityHex } from '../../lib/utils'
|
|
||||||
import type { DataQuality } from '../../domain/property'
|
|
||||||
|
|
||||||
interface DataQualityBadgeProps {
|
|
||||||
quality: DataQuality
|
|
||||||
showLabel?: boolean
|
|
||||||
size?: 'small' | 'medium'
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DataQualityBadge({ quality, showLabel = false, size = 'small' }: DataQualityBadgeProps) {
|
|
||||||
const pct = Math.round(quality.score * 100)
|
|
||||||
const hex = dataQualityHex(quality.score)
|
|
||||||
const hasCritical = quality.missingCriticalFields.length > 0
|
|
||||||
|
|
||||||
const levelLabel = quality.qualityLevel
|
|
||||||
? { HIGH: 'Hoch', MEDIUM: 'Mittel', LOW: 'Niedrig', INCOMPLETE: 'Unvollständig' }[quality.qualityLevel]
|
|
||||||
: null
|
|
||||||
|
|
||||||
const tooltipText = hasCritical
|
|
||||||
? `${quality.missingCriticalFields.length} Pflichtfeld(er) fehlen`
|
|
||||||
: `Datenqualität: ${pct}%`
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Tooltip title={tooltipText} arrow>
|
|
||||||
<Chip
|
|
||||||
size={size}
|
|
||||||
label={showLabel && levelLabel ? `${pct}% · ${levelLabel}` : `${pct}%`}
|
|
||||||
sx={{
|
|
||||||
bgcolor: `${hex}18`,
|
|
||||||
color: hex,
|
|
||||||
fontWeight: 700,
|
|
||||||
fontSize: size === 'small' ? '0.7rem' : '0.8125rem',
|
|
||||||
border: `1px solid ${hex}40`,
|
|
||||||
cursor: 'default',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
import { Box, LinearProgress, Tooltip, Typography, Chip } from '@mui/material'
|
|
||||||
import { AlertTriangle } from 'lucide-react'
|
|
||||||
import { dataQualityColor, dataQualityHex, formatPercent } from '../../lib/utils'
|
|
||||||
import { FRESHNESS_LABELS } from '../../lib/constants'
|
|
||||||
import type { DataQuality } from '../../domain/property'
|
|
||||||
|
|
||||||
interface DataQualityBarProps {
|
|
||||||
quality: DataQuality
|
|
||||||
compact?: boolean
|
|
||||||
showWarnings?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DataQualityBar({ quality, compact = false, showWarnings = true }: DataQualityBarProps) {
|
|
||||||
const color = dataQualityColor(quality.score)
|
|
||||||
const hex = dataQualityHex(quality.score)
|
|
||||||
const pct = Math.round(quality.score * 100)
|
|
||||||
|
|
||||||
const tooltipContent = (
|
|
||||||
<Box sx={{ p: 0.5 }}>
|
|
||||||
<Typography variant="caption" sx={{ fontWeight: 600, display: 'block', mb: 0.5 }}>
|
|
||||||
Datenqualität {formatPercent(quality.score)}
|
|
||||||
</Typography>
|
|
||||||
{quality.missingCriticalFields.length > 0 && (
|
|
||||||
<Box sx={{ mb: 0.5 }}>
|
|
||||||
<Typography variant="caption" sx={{ color: '#fca5a5', display: 'block' }}>Fehlende Pflichtfelder:</Typography>
|
|
||||||
{quality.missingCriticalFields.map(f => (
|
|
||||||
<Typography key={f} variant="caption" sx={{ display: 'block', pl: 1 }}>• {f}</Typography>
|
|
||||||
))}
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
{quality.warnings.length > 0 && (
|
|
||||||
<Box>
|
|
||||||
<Typography variant="caption" sx={{ color: '#fcd34d', display: 'block' }}>Warnungen:</Typography>
|
|
||||||
{quality.warnings.map((w, i) => (
|
|
||||||
<Typography key={i} variant="caption" sx={{ display: 'block', pl: 1 }}>• {w}</Typography>
|
|
||||||
))}
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
<Typography variant="caption" sx={{ color: '#94a3b8', display: 'block', mt: 0.5 }}>
|
|
||||||
Aktualität: {FRESHNESS_LABELS[quality.freshness]}
|
|
||||||
{quality.lastVerifiedAt ? ` · Geprüft: ${quality.lastVerifiedAt}` : ''}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
)
|
|
||||||
|
|
||||||
if (compact) {
|
|
||||||
return (
|
|
||||||
<Tooltip title={tooltipContent} arrow placement="top">
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, cursor: 'default' }}>
|
|
||||||
<Box sx={{ width: 56 }}>
|
|
||||||
<LinearProgress
|
|
||||||
variant="determinate"
|
|
||||||
value={pct}
|
|
||||||
color={color}
|
|
||||||
sx={{ height: 5, borderRadius: 3 }}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
<Typography variant="caption" sx={{ color: hex, fontWeight: 600, fontSize: '0.7rem', whiteSpace: 'nowrap' }}>
|
|
||||||
{pct}%
|
|
||||||
</Typography>
|
|
||||||
{quality.missingCriticalFields.length > 0 && showWarnings && (
|
|
||||||
<AlertTriangle size={11} color="#d97706" />
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
</Tooltip>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box>
|
|
||||||
<Tooltip title={tooltipContent} arrow placement="top">
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, cursor: 'default' }}>
|
|
||||||
<Box sx={{ flex: 1, minWidth: 80 }}>
|
|
||||||
<LinearProgress
|
|
||||||
variant="determinate"
|
|
||||||
value={pct}
|
|
||||||
color={color}
|
|
||||||
sx={{ height: 6, borderRadius: 3 }}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
<Typography variant="caption" sx={{ color: hex, fontWeight: 600, whiteSpace: 'nowrap' }}>
|
|
||||||
{pct}%
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
</Tooltip>
|
|
||||||
{showWarnings && quality.missingCriticalFields.length > 0 && (
|
|
||||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.25, mt: 0.5 }}>
|
|
||||||
{quality.missingCriticalFields.slice(0, 2).map(f => (
|
|
||||||
<Chip key={f} label={f} size="small" color="error" variant="outlined"
|
|
||||||
sx={{ height: 16, fontSize: '0.625rem' }} />
|
|
||||||
))}
|
|
||||||
{quality.missingCriticalFields.length > 2 && (
|
|
||||||
<Typography variant="caption" sx={{ color: 'error.main' }}>
|
|
||||||
+{quality.missingCriticalFields.length - 2}
|
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
import { Box, Button, Divider, Paper, Typography } from '@mui/material'
|
|
||||||
import { DataQualityProgress } from './DataQualityProgress'
|
|
||||||
import { CriticalFieldWarning } from './CriticalFieldWarning'
|
|
||||||
import { MissingDataList } from './MissingDataList'
|
|
||||||
import { ProvenancePanel } from './ProvenancePanel'
|
|
||||||
import { DataQualityBadge } from './DataQualityBadge'
|
|
||||||
import { getRecommendedActions } from '../../services/dataQualityService'
|
|
||||||
import type { Property } from '../../domain/property'
|
|
||||||
|
|
||||||
interface DataQualityPanelProps {
|
|
||||||
property: Property
|
|
||||||
}
|
|
||||||
|
|
||||||
function SectionLabel({ children }: { children: React.ReactNode }) {
|
|
||||||
return (
|
|
||||||
<Typography variant="caption" sx={{
|
|
||||||
fontWeight: 700,
|
|
||||||
textTransform: 'uppercase',
|
|
||||||
letterSpacing: 0.5,
|
|
||||||
color: '#64748b',
|
|
||||||
display: 'block',
|
|
||||||
mb: 1,
|
|
||||||
}}>
|
|
||||||
{children}
|
|
||||||
</Typography>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DataQualityPanel({ property }: DataQualityPanelProps) {
|
|
||||||
const q = property.dataQuality
|
|
||||||
const actions = getRecommendedActions(q, q.freshness)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box>
|
|
||||||
{/* ── Score & Dimensions ─────────────────────────────────────────── */}
|
|
||||||
<Paper variant="outlined" sx={{ p: 2, mb: 2, bgcolor: '#fafafa' }}>
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', mb: 1.5 }}>
|
|
||||||
<Box>
|
|
||||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 0.5 }}>Datenqualität</Typography>
|
|
||||||
<DataQualityBadge quality={q} showLabel size="medium" />
|
|
||||||
</Box>
|
|
||||||
<Box sx={{ textAlign: 'right' }}>
|
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block' }}>
|
|
||||||
{q.missingCriticalFields.length === 0 ? 'Alle Pflichtfelder vorhanden' : `${q.missingCriticalFields.length} Pflichtfeld(er) fehlen`}
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="caption" color="text.secondary">
|
|
||||||
{q.missingOptionalFields.length} optionale Felder fehlen
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
<DataQualityProgress property={property} />
|
|
||||||
</Paper>
|
|
||||||
|
|
||||||
{/* ── Critical warnings ──────────────────────────────────────────── */}
|
|
||||||
<CriticalFieldWarning
|
|
||||||
fields={q.missingCriticalFields}
|
|
||||||
warnings={q.warnings}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* ── Missing data with actions ──────────────────────────────────── */}
|
|
||||||
<Box sx={{ mb: 2 }}>
|
|
||||||
<SectionLabel>Fehlende Daten & Massnahmen</SectionLabel>
|
|
||||||
<MissingDataList
|
|
||||||
criticalFields={q.missingCriticalFields}
|
|
||||||
optionalFields={q.missingOptionalFields}
|
|
||||||
recommendedActions={actions}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Divider sx={{ mb: 2 }} />
|
|
||||||
|
|
||||||
{/* ── Provenance ─────────────────────────────────────────────────── */}
|
|
||||||
<Box sx={{ mb: 2 }}>
|
|
||||||
<SectionLabel>Datenherkunft & Verifikation</SectionLabel>
|
|
||||||
<ProvenancePanel property={property} />
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* ── Action button ──────────────────────────────────────────────── */}
|
|
||||||
<Box sx={{ pt: 1 }}>
|
|
||||||
<Button variant="outlined" size="small" sx={{ textTransform: 'none', mr: 1 }}>
|
|
||||||
Datenaktualisierung anfragen
|
|
||||||
</Button>
|
|
||||||
{q.missingCriticalFields.length > 0 && (
|
|
||||||
<Button variant="contained" size="small" sx={{ textTransform: 'none', bgcolor: '#1e3a5f' }}>
|
|
||||||
Fehlende Felder ergänzen
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,122 +0,0 @@
|
|||||||
import { Box, LinearProgress, Typography } from '@mui/material'
|
|
||||||
import { dataQualityHex } from '../../lib/utils'
|
|
||||||
import { FreshnessStatus } from '../../domain/enums'
|
|
||||||
import type { Property } from '../../domain/property'
|
|
||||||
|
|
||||||
interface Dimension {
|
|
||||||
label: string
|
|
||||||
score: number
|
|
||||||
color: string
|
|
||||||
}
|
|
||||||
|
|
||||||
function freshnessScore(f: string): number {
|
|
||||||
if (f === FreshnessStatus.FRESH) return 100
|
|
||||||
if (f === FreshnessStatus.STALE) return 50
|
|
||||||
return 15
|
|
||||||
}
|
|
||||||
|
|
||||||
function completenessScore(missingCritical: number, missingOptional: number): number {
|
|
||||||
const critPenalty = missingCritical * 15
|
|
||||||
const optPenalty = missingOptional * 5
|
|
||||||
return Math.max(0, 100 - critPenalty - optPenalty)
|
|
||||||
}
|
|
||||||
|
|
||||||
const SOURCE_PROVENANCE: Record<string, number> = {
|
|
||||||
ERP_IMPORT: 95, MANUAL_ENTRY: 90, PARTNER_FEED: 80,
|
|
||||||
IMMOSCOUT_SCRAPE: 65, HOMEGATE_SCRAPE: 65, NEWHOME_SCRAPE: 60,
|
|
||||||
MATCHOFFICE_SCRAPE: 60, MAISON_WORK_SCRAPE: 60, AI_SIGNAL: 40, UNKNOWN: 30,
|
|
||||||
}
|
|
||||||
|
|
||||||
function dimColor(score: number): string {
|
|
||||||
if (score >= 80) return '#1a7a4a'
|
|
||||||
if (score >= 55) return '#d97706'
|
|
||||||
return '#c0392b'
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildDimensions(p: Property): Dimension[] {
|
|
||||||
const compScore = completenessScore(
|
|
||||||
p.dataQuality.missingCriticalFields.length,
|
|
||||||
p.dataQuality.missingOptionalFields.length,
|
|
||||||
)
|
|
||||||
const freshScore = freshnessScore(p.dataQuality.freshness)
|
|
||||||
const confScore = Math.round(p.confidenceScore * 100)
|
|
||||||
const provScore = SOURCE_PROVENANCE[p.sourceType] ?? 50
|
|
||||||
const lastVerified = p.dataQuality.lastVerifiedAt
|
|
||||||
const verScore = lastVerified
|
|
||||||
? Math.max(10, 100 - Math.floor((Date.now() - new Date(lastVerified).getTime()) / (1000 * 60 * 60 * 24)) * 2)
|
|
||||||
: 10
|
|
||||||
|
|
||||||
return [
|
|
||||||
{ label: 'Vollständigkeit', score: compScore, color: dimColor(compScore) },
|
|
||||||
{ label: 'Aktualität', score: freshScore, color: dimColor(freshScore) },
|
|
||||||
{ label: 'Vertrauensscore', score: confScore, color: dimColor(confScore) },
|
|
||||||
{ label: 'Herkunft', score: Math.min(100, provScore), color: dimColor(provScore) },
|
|
||||||
{ label: 'Verifikation', score: Math.min(100, verScore), color: dimColor(verScore) },
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
interface DataQualityProgressProps {
|
|
||||||
property: Property
|
|
||||||
compact?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DataQualityProgress({ property, compact = false }: DataQualityProgressProps) {
|
|
||||||
const dims = buildDimensions(property)
|
|
||||||
const overallPct = Math.round(property.dataQuality.score * 100)
|
|
||||||
const hex = dataQualityHex(property.dataQuality.score)
|
|
||||||
|
|
||||||
if (compact) {
|
|
||||||
return (
|
|
||||||
<Box sx={{ display: 'flex', gap: 0.5 }}>
|
|
||||||
{dims.map(d => (
|
|
||||||
<Box key={d.label} sx={{ flex: 1 }}>
|
|
||||||
<LinearProgress
|
|
||||||
variant="determinate"
|
|
||||||
value={d.score}
|
|
||||||
sx={{
|
|
||||||
height: 4,
|
|
||||||
borderRadius: 2,
|
|
||||||
bgcolor: '#e2e8f0',
|
|
||||||
'& .MuiLinearProgress-bar': { bgcolor: d.color },
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
))}
|
|
||||||
</Box>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box>
|
|
||||||
{/* Overall score header */}
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'baseline', gap: 1, mb: 1.5 }}>
|
|
||||||
<Typography variant="h4" sx={{ fontWeight: 800, color: hex, lineHeight: 1 }}>
|
|
||||||
{overallPct}%
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="body2" color="text.secondary">Gesamtqualität</Typography>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Dimension bars */}
|
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75 }}>
|
|
||||||
{dims.map(d => (
|
|
||||||
<Box key={d.label}>
|
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.25 }}>
|
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.7rem' }}>{d.label}</Typography>
|
|
||||||
<Typography variant="caption" sx={{ fontWeight: 600, color: d.color, fontSize: '0.7rem' }}>{d.score}%</Typography>
|
|
||||||
</Box>
|
|
||||||
<LinearProgress
|
|
||||||
variant="determinate"
|
|
||||||
value={d.score}
|
|
||||||
sx={{
|
|
||||||
height: 5,
|
|
||||||
borderRadius: 3,
|
|
||||||
bgcolor: '#e2e8f0',
|
|
||||||
'& .MuiLinearProgress-bar': { bgcolor: d.color },
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
))}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
import { Chip, Tooltip } from '@mui/material'
|
|
||||||
import { CheckCircle, Clock, AlertTriangle } from 'lucide-react'
|
|
||||||
import { FreshnessStatus } from '../../domain/enums'
|
|
||||||
import { FRESHNESS_LABELS } from '../../lib/constants'
|
|
||||||
import type { FreshnessStatus as FreshnessStatusType } from '../../domain/enums'
|
|
||||||
|
|
||||||
interface FreshnessIndicatorProps {
|
|
||||||
freshness: FreshnessStatusType
|
|
||||||
lastUpdated?: string
|
|
||||||
size?: 'small' | 'medium'
|
|
||||||
}
|
|
||||||
|
|
||||||
const CONFIG: Record<FreshnessStatusType, { color: string; icon: typeof CheckCircle }> = {
|
|
||||||
[FreshnessStatus.FRESH]: { color: '#1a7a4a', icon: CheckCircle },
|
|
||||||
[FreshnessStatus.STALE]: { color: '#d97706', icon: Clock },
|
|
||||||
[FreshnessStatus.OUTDATED]: { color: '#c0392b', icon: AlertTriangle },
|
|
||||||
}
|
|
||||||
|
|
||||||
export function FreshnessIndicator({ freshness, lastUpdated, size = 'small' }: FreshnessIndicatorProps) {
|
|
||||||
const { color, icon: Icon } = CONFIG[freshness] ?? CONFIG[FreshnessStatus.OUTDATED]
|
|
||||||
const label = FRESHNESS_LABELS[freshness] ?? freshness
|
|
||||||
|
|
||||||
const chip = (
|
|
||||||
<Chip
|
|
||||||
size={size}
|
|
||||||
icon={<Icon size={11} color={color} />}
|
|
||||||
label={label}
|
|
||||||
sx={{
|
|
||||||
bgcolor: `${color}18`,
|
|
||||||
color,
|
|
||||||
fontWeight: 600,
|
|
||||||
fontSize: size === 'small' ? '0.7rem' : '0.8125rem',
|
|
||||||
border: `1px solid ${color}40`,
|
|
||||||
'& .MuiChip-icon': { color },
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
|
|
||||||
if (!lastUpdated) return chip
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Tooltip title={`Zuletzt aktualisiert: ${new Date(lastUpdated).toLocaleDateString('de-CH')}`} arrow>
|
|
||||||
{chip}
|
|
||||||
</Tooltip>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,128 +0,0 @@
|
|||||||
import { Box, Button, Chip, Divider, Typography } from '@mui/material'
|
|
||||||
import { AlertTriangle, Info } from 'lucide-react'
|
|
||||||
import type { RecommendedAction } from '../../services/dataQualityService'
|
|
||||||
|
|
||||||
interface MissingDataListProps {
|
|
||||||
criticalFields: string[]
|
|
||||||
optionalFields: string[]
|
|
||||||
recommendedActions: RecommendedAction[]
|
|
||||||
onAction?: (action: RecommendedAction) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export function MissingDataList({
|
|
||||||
criticalFields,
|
|
||||||
optionalFields,
|
|
||||||
recommendedActions,
|
|
||||||
onAction,
|
|
||||||
}: MissingDataListProps) {
|
|
||||||
if (criticalFields.length === 0 && optionalFields.length === 0) {
|
|
||||||
return (
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, py: 1.5, px: 2, bgcolor: '#f0fdf4', borderRadius: 1, mb: 2 }}>
|
|
||||||
<Info size={14} color="#1a7a4a" />
|
|
||||||
<Typography variant="body2" sx={{ color: '#1a7a4a', fontWeight: 500 }}>
|
|
||||||
Alle wichtigen Felder sind vollständig.
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const criticalActions = recommendedActions.filter(a => a.priority === 'HIGH')
|
|
||||||
const otherActions = recommendedActions.filter(a => a.priority !== 'HIGH')
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box sx={{ mb: 2 }}>
|
|
||||||
{criticalFields.length > 0 && (
|
|
||||||
<Box sx={{ mb: 1.5 }}>
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mb: 0.75 }}>
|
|
||||||
<AlertTriangle size={13} color="#c0392b" />
|
|
||||||
<Typography variant="caption" sx={{ fontWeight: 700, color: '#c0392b', textTransform: 'uppercase', letterSpacing: 0.5 }}>
|
|
||||||
Pflichtfelder ({criticalFields.length})
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
{criticalActions.map(a => (
|
|
||||||
<Box
|
|
||||||
key={a.id}
|
|
||||||
sx={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
px: 1.5,
|
|
||||||
py: 0.75,
|
|
||||||
mb: 0.5,
|
|
||||||
bgcolor: '#fff1f2',
|
|
||||||
border: '1px solid #fecdd3',
|
|
||||||
borderRadius: 1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Box>
|
|
||||||
<Typography variant="body2" sx={{ fontWeight: 600, fontSize: '0.8125rem' }}>{a.label}</Typography>
|
|
||||||
<Typography variant="caption" color="text.secondary">{a.detail}</Typography>
|
|
||||||
</Box>
|
|
||||||
{onAction && (
|
|
||||||
<Button size="small" variant="outlined" color="error"
|
|
||||||
onClick={() => onAction(a)}
|
|
||||||
sx={{ textTransform: 'none', fontSize: '0.75rem', flexShrink: 0, ml: 1 }}>
|
|
||||||
Ergänzen
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
))}
|
|
||||||
{criticalFields
|
|
||||||
.filter(f => !criticalActions.find(a => a.field === f))
|
|
||||||
.map(f => (
|
|
||||||
<Box key={f} sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mb: 0.5 }}>
|
|
||||||
<Chip label={f} size="small" color="error" variant="outlined" sx={{ height: 20, fontSize: '0.7rem' }} />
|
|
||||||
</Box>
|
|
||||||
))
|
|
||||||
}
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{optionalFields.length > 0 && (
|
|
||||||
<>
|
|
||||||
{criticalFields.length > 0 && <Divider sx={{ mb: 1.5 }} />}
|
|
||||||
<Box>
|
|
||||||
<Typography variant="caption" sx={{ fontWeight: 700, color: '#64748b', textTransform: 'uppercase', letterSpacing: 0.5, display: 'block', mb: 0.75 }}>
|
|
||||||
Optionale Felder ({optionalFields.length})
|
|
||||||
</Typography>
|
|
||||||
{otherActions.map(a => (
|
|
||||||
<Box
|
|
||||||
key={a.id}
|
|
||||||
sx={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
px: 1.5,
|
|
||||||
py: 0.75,
|
|
||||||
mb: 0.5,
|
|
||||||
bgcolor: '#fffbeb',
|
|
||||||
border: '1px solid #fde68a',
|
|
||||||
borderRadius: 1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Box>
|
|
||||||
<Typography variant="body2" sx={{ fontWeight: 500, fontSize: '0.8125rem' }}>{a.label}</Typography>
|
|
||||||
<Typography variant="caption" color="text.secondary">{a.detail}</Typography>
|
|
||||||
</Box>
|
|
||||||
{onAction && (
|
|
||||||
<Button size="small" variant="outlined" color="warning"
|
|
||||||
onClick={() => onAction(a)}
|
|
||||||
sx={{ textTransform: 'none', fontSize: '0.75rem', flexShrink: 0, ml: 1 }}>
|
|
||||||
Ergänzen
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
))}
|
|
||||||
{optionalFields
|
|
||||||
.filter(f => !otherActions.find(a => a.field === f))
|
|
||||||
.map(f => (
|
|
||||||
<Chip key={f} label={f} size="small" color="warning" variant="outlined"
|
|
||||||
sx={{ height: 20, fontSize: '0.7rem', mr: 0.5, mb: 0.5 }} />
|
|
||||||
))
|
|
||||||
}
|
|
||||||
</Box>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,126 +0,0 @@
|
|||||||
import { Box, Button, Chip, LinearProgress, Typography } from '@mui/material'
|
|
||||||
import { ExternalLink, Shield, ShieldAlert } from 'lucide-react'
|
|
||||||
import { FreshnessIndicator } from './FreshnessIndicator'
|
|
||||||
import type { Property } from '../../domain/property'
|
|
||||||
|
|
||||||
interface ProvenancePanelProps {
|
|
||||||
property: Property
|
|
||||||
}
|
|
||||||
|
|
||||||
const SOURCE_TYPE_LABELS: Record<string, string> = {
|
|
||||||
ERP_IMPORT: 'ERP-Import',
|
|
||||||
MANUAL_ENTRY: 'Manuelle Eingabe',
|
|
||||||
IMMOSCOUT_SCRAPE: 'ImmoScout24',
|
|
||||||
HOMEGATE_SCRAPE: 'Homegate',
|
|
||||||
NEWHOME_SCRAPE: 'Newhome',
|
|
||||||
MATCHOFFICE_SCRAPE: 'MatchOffice',
|
|
||||||
MAISON_WORK_SCRAPE: 'Maison & Work',
|
|
||||||
AI_SIGNAL: 'KI-Signal',
|
|
||||||
PARTNER_FEED: 'Partner-Feed',
|
|
||||||
UNKNOWN: 'Unbekannt',
|
|
||||||
}
|
|
||||||
|
|
||||||
const SOURCE_CONFIDENCE: Record<string, number> = {
|
|
||||||
ERP_IMPORT: 0.95,
|
|
||||||
MANUAL_ENTRY: 0.90,
|
|
||||||
PARTNER_FEED: 0.80,
|
|
||||||
IMMOSCOUT_SCRAPE: 0.65,
|
|
||||||
HOMEGATE_SCRAPE: 0.65,
|
|
||||||
NEWHOME_SCRAPE: 0.60,
|
|
||||||
MATCHOFFICE_SCRAPE: 0.60,
|
|
||||||
MAISON_WORK_SCRAPE: 0.60,
|
|
||||||
AI_SIGNAL: 0.40,
|
|
||||||
UNKNOWN: 0.30,
|
|
||||||
}
|
|
||||||
|
|
||||||
function getSourceConfidence(sourceType: string): number {
|
|
||||||
return SOURCE_CONFIDENCE[sourceType] ?? 0.50
|
|
||||||
}
|
|
||||||
|
|
||||||
function provenanceColor(conf: number): string {
|
|
||||||
if (conf >= 0.8) return '#1a7a4a'
|
|
||||||
if (conf >= 0.6) return '#d97706'
|
|
||||||
return '#c0392b'
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ProvenancePanel({ property: p }: ProvenancePanelProps) {
|
|
||||||
const sourceLabel = SOURCE_TYPE_LABELS[p.sourceType] ?? p.sourceType
|
|
||||||
const sourceConf = getSourceConfidence(p.sourceType)
|
|
||||||
const confPct = Math.round(sourceConf * 100)
|
|
||||||
const color = provenanceColor(sourceConf)
|
|
||||||
const isVerified = sourceConf >= 0.85
|
|
||||||
const VerifyIcon = isVerified ? Shield : ShieldAlert
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box>
|
|
||||||
{/* Source header */}
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
|
|
||||||
<VerifyIcon size={16} color={color} />
|
|
||||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>{sourceLabel}</Typography>
|
|
||||||
{p.sourceLabel && p.sourceLabel !== sourceLabel && (
|
|
||||||
<Typography variant="caption" color="text.secondary">· {p.sourceLabel}</Typography>
|
|
||||||
)}
|
|
||||||
<Chip
|
|
||||||
size="small"
|
|
||||||
label={isVerified ? 'Verifiziert' : 'Ungeprüft'}
|
|
||||||
sx={{ bgcolor: `${color}18`, color, fontSize: '0.65rem', height: 18, ml: 'auto' }}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Source confidence bar */}
|
|
||||||
<Box sx={{ mb: 1.5 }}>
|
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.5 }}>
|
|
||||||
<Typography variant="caption" color="text.secondary">Quell-Vertrauen</Typography>
|
|
||||||
<Typography variant="caption" sx={{ fontWeight: 600, color }}>{confPct}%</Typography>
|
|
||||||
</Box>
|
|
||||||
<LinearProgress
|
|
||||||
variant="determinate"
|
|
||||||
value={confPct}
|
|
||||||
sx={{
|
|
||||||
height: 5,
|
|
||||||
borderRadius: 3,
|
|
||||||
bgcolor: '#e2e8f0',
|
|
||||||
'& .MuiLinearProgress-bar': { bgcolor: color },
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Dates */}
|
|
||||||
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 1.5, mb: 1.5 }}>
|
|
||||||
<Box>
|
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block' }}>Quellaktualisierung</Typography>
|
|
||||||
<Typography variant="body2" sx={{ fontWeight: 500, fontSize: '0.8125rem' }}>
|
|
||||||
{p.sourceUpdatedAt ? new Date(p.sourceUpdatedAt).toLocaleDateString('de-CH') : '—'}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
<Box>
|
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block' }}>Letzte Verifikation</Typography>
|
|
||||||
<Typography variant="body2" sx={{ fontWeight: 500, fontSize: '0.8125rem' }}>
|
|
||||||
{p.dataQuality.lastVerifiedAt ? new Date(p.dataQuality.lastVerifiedAt).toLocaleDateString('de-CH') : '—'}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Freshness */}
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
|
|
||||||
<Typography variant="caption" color="text.secondary">Aktualität:</Typography>
|
|
||||||
<FreshnessIndicator freshness={p.dataQuality.freshness} lastUpdated={p.sourceUpdatedAt} />
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* External URL */}
|
|
||||||
{p.sourceUrl && (
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
variant="outlined"
|
|
||||||
endIcon={<ExternalLink size={12} />}
|
|
||||||
href={p.sourceUrl}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
sx={{ textTransform: 'none', fontSize: '0.75rem' }}
|
|
||||||
>
|
|
||||||
Originalquelle öffnen
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
export { DataQualityBar } from './DataQualityBar'
|
|
||||||
export { DataQualityBadge } from './DataQualityBadge'
|
|
||||||
export { DataQualityPanel } from './DataQualityPanel'
|
|
||||||
export { DataQualityProgress } from './DataQualityProgress'
|
|
||||||
export { FreshnessIndicator } from './FreshnessIndicator'
|
|
||||||
export { CriticalFieldWarning } from './CriticalFieldWarning'
|
|
||||||
export { MissingDataList } from './MissingDataList'
|
|
||||||
export { ProvenancePanel } from './ProvenancePanel'
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
import { Chip } from '@mui/material'
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
confidence: number
|
|
||||||
size?: 'small' | 'medium'
|
|
||||||
}
|
|
||||||
|
|
||||||
function confidenceColor(c: number): string {
|
|
||||||
if (c >= 0.8) return '#1a7a4a'
|
|
||||||
if (c >= 0.6) return '#d97706'
|
|
||||||
return '#c0392b'
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ConfidenceFieldBadge({ confidence, size = 'small' }: Props) {
|
|
||||||
return (
|
|
||||||
<Chip
|
|
||||||
label={`${Math.round(confidence * 100)}%`}
|
|
||||||
size={size}
|
|
||||||
sx={{ bgcolor: confidenceColor(confidence), color: 'white', fontWeight: 700, fontSize: 11 }}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,155 +0,0 @@
|
|||||||
import { Box, Card, Typography, Alert, Stack, Chip } from '@mui/material'
|
|
||||||
import type { ParseNeedResult, ParsedNeedCriteria } from '../../domain/needBuilder'
|
|
||||||
import { ExtractedFieldRow } from './ExtractedFieldRow'
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
result: ParseNeedResult
|
|
||||||
criteria: ParsedNeedCriteria
|
|
||||||
onCriteriaChange: (c: ParsedNeedCriteria) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
const ASSET_LABELS: Record<string, string> = {
|
|
||||||
OFFICE: 'Büro', LOGISTICS: 'Logistik / Lager', RETAIL: 'Retail',
|
|
||||||
PRODUCTION: 'Produktion', GASTRO: 'Gastro / F&B', LIGHT_INDUSTRIAL: 'Light Industrial',
|
|
||||||
MIXED: 'Gemischt', UNKNOWN: 'Unbekannt',
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Parse helpers ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function parseAreaRange(s: string): ParsedNeedCriteria['areaRange'] {
|
|
||||||
const m = s.match(/(\d+)\s*[–\-]\s*(\d+)/)
|
|
||||||
if (m) return { min: parseInt(m[1]), max: parseInt(m[2]) }
|
|
||||||
const n = s.match(/(\d+)/)
|
|
||||||
if (n) { const v = parseInt(n[1]); return { min: Math.round(v * 0.8), max: Math.round(v * 1.2) } }
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseBudget(s: string): ParsedNeedCriteria['budgetRange'] {
|
|
||||||
const n = s.match(/(\d+)/)
|
|
||||||
if (!n) return undefined
|
|
||||||
return { maxPerSqm: parseInt(n[1]), currency: 'CHF' }
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseList(s: string): string[] {
|
|
||||||
return s.split(',').map(x => x.trim()).filter(Boolean)
|
|
||||||
}
|
|
||||||
|
|
||||||
function displayAreaRange(v: ParsedNeedCriteria['areaRange']): string {
|
|
||||||
return v ? `${v.min}–${v.max} m²` : ''
|
|
||||||
}
|
|
||||||
function displayBudget(v: ParsedNeedCriteria['budgetRange']): string {
|
|
||||||
return v ? `CHF ${v.maxPerSqm}/m²` : ''
|
|
||||||
}
|
|
||||||
function displayTiming(v: ParsedNeedCriteria['timing']): string {
|
|
||||||
if (!v) return ''
|
|
||||||
return `ab ${v.earliestMoveIn}${v.flexibleTiming ? ' (flexibel)' : ''}`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Section wrapper ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
|
||||||
return (
|
|
||||||
<Box sx={{ mb: 2 }}>
|
|
||||||
<Typography variant="overline" sx={{ fontWeight: 700, color: '#64748b', fontSize: 10, letterSpacing: 1 }}>
|
|
||||||
{title}
|
|
||||||
</Typography>
|
|
||||||
{children}
|
|
||||||
</Box>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Main component ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export function CriteriaReviewPanel({ result, criteria: c, onCriteriaChange: set }: Props) {
|
|
||||||
const { confidenceByField: conf, missingFields, assumptions } = result
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card sx={{ p: 3, height: '100%', overflowY: 'auto' }}>
|
|
||||||
<Typography variant="subtitle1" sx={{ fontWeight: 600, mb: 0.5 }}>
|
|
||||||
Extrahierte Kriterien
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 2 }}>
|
|
||||||
{result.rawSummary}
|
|
||||||
</Typography>
|
|
||||||
|
|
||||||
{/* ── Hard Facts ──────────────────────────────────────────────────── */}
|
|
||||||
<Section title="Hard Facts">
|
|
||||||
<ExtractedFieldRow
|
|
||||||
label="Nutzungstyp"
|
|
||||||
value={c.assetType ? (ASSET_LABELS[c.assetType] ?? c.assetType) : ''}
|
|
||||||
confidence={conf.assetType ?? 0.2}
|
|
||||||
missing={!c.assetType}
|
|
||||||
onEdit={v => set({ ...c, assetType: (v.toUpperCase() as ParsedNeedCriteria['assetType']) })}
|
|
||||||
/>
|
|
||||||
<ExtractedFieldRow
|
|
||||||
label="Flächenbedarf (z.B. 500–800)"
|
|
||||||
value={displayAreaRange(c.areaRange)}
|
|
||||||
confidence={conf.areaRange ?? 0.2}
|
|
||||||
missing={!c.areaRange}
|
|
||||||
onEdit={v => set({ ...c, areaRange: parseAreaRange(v) })}
|
|
||||||
/>
|
|
||||||
<ExtractedFieldRow
|
|
||||||
label="Standort (Komma-getrennt)"
|
|
||||||
value={c.preferredLocations?.join(', ') ?? ''}
|
|
||||||
confidence={conf.preferredLocations ?? 0.15}
|
|
||||||
missing={!c.preferredLocations?.length}
|
|
||||||
onEdit={v => set({ ...c, preferredLocations: parseList(v) })}
|
|
||||||
/>
|
|
||||||
<ExtractedFieldRow
|
|
||||||
label="Budget (max CHF/m²)"
|
|
||||||
value={displayBudget(c.budgetRange)}
|
|
||||||
confidence={conf.budgetRange ?? 0.2}
|
|
||||||
missing={!c.budgetRange}
|
|
||||||
onEdit={v => set({ ...c, budgetRange: parseBudget(v) })}
|
|
||||||
/>
|
|
||||||
<ExtractedFieldRow
|
|
||||||
label="Verfügbarkeit (ab Datum)"
|
|
||||||
value={displayTiming(c.timing)}
|
|
||||||
confidence={conf.timing ?? 0.2}
|
|
||||||
missing={!c.timing}
|
|
||||||
onEdit={v => set({ ...c, timing: { earliestMoveIn: v, latestMoveIn: v, flexibleTiming: v.toLowerCase().includes('flex') } })}
|
|
||||||
/>
|
|
||||||
</Section>
|
|
||||||
|
|
||||||
{/* ── Must-haves ──────────────────────────────────────────────────── */}
|
|
||||||
<Section title="Must-haves">
|
|
||||||
<ExtractedFieldRow
|
|
||||||
label="Pflichtkriterien (Komma-getrennt)"
|
|
||||||
value={c.mustHaveCriteria?.join(', ') ?? ''}
|
|
||||||
missing={!c.mustHaveCriteria?.length}
|
|
||||||
onEdit={v => set({ ...c, mustHaveCriteria: parseList(v) })}
|
|
||||||
/>
|
|
||||||
<ExtractedFieldRow
|
|
||||||
label="Parkplatzbedarf"
|
|
||||||
value={c.parkingNeed === true ? 'Ja' : c.parkingNeed === false ? 'Nein' : ''}
|
|
||||||
missing={c.parkingNeed === undefined}
|
|
||||||
onEdit={v => set({ ...c, parkingNeed: v.toLowerCase().startsWith('j') })}
|
|
||||||
/>
|
|
||||||
</Section>
|
|
||||||
|
|
||||||
{/* ── AI Assumptions ──────────────────────────────────────────────── */}
|
|
||||||
{assumptions.length > 0 && (
|
|
||||||
<Section title="KI-Annahmen">
|
|
||||||
<Stack spacing={0.5}>
|
|
||||||
{assumptions.map((a, i) => (
|
|
||||||
<Alert key={i} severity="warning" sx={{ py: 0, px: 1, '& .MuiAlert-message': { fontSize: 12 } }}>
|
|
||||||
{a}
|
|
||||||
</Alert>
|
|
||||||
))}
|
|
||||||
</Stack>
|
|
||||||
</Section>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* ── Missing Information ─────────────────────────────────────────── */}
|
|
||||||
{missingFields.length > 0 && (
|
|
||||||
<Section title="Fehlende Angaben">
|
|
||||||
<Stack direction="row" spacing={0.5} sx={{ flexWrap: 'wrap', gap: 0.5 }}>
|
|
||||||
{missingFields.map(f => (
|
|
||||||
<Chip key={f} label={f} size="small" color="warning" variant="outlined" />
|
|
||||||
))}
|
|
||||||
</Stack>
|
|
||||||
</Section>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
import { useState } from 'react'
|
|
||||||
import { Box, IconButton, TextField, Typography } from '@mui/material'
|
|
||||||
import { Pencil } from 'lucide-react'
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
label: string
|
|
||||||
value: string
|
|
||||||
confidence?: number
|
|
||||||
missing?: boolean
|
|
||||||
onEdit?: (value: string) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ExtractedFieldRow({ label, value, missing = false, onEdit }: Props) {
|
|
||||||
const [editing, setEditing] = useState(false)
|
|
||||||
const [editValue, setEditValue] = useState(value)
|
|
||||||
|
|
||||||
function commit() {
|
|
||||||
setEditing(false)
|
|
||||||
if (editValue !== value) onEdit?.(editValue)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (editing) {
|
|
||||||
return (
|
|
||||||
<Box sx={{ py: 1, borderBottom: '1px solid #f1f5f9' }}>
|
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ fontWeight: 600, display: 'block', mb: 0.5 }}>
|
|
||||||
{label}
|
|
||||||
</Typography>
|
|
||||||
<TextField
|
|
||||||
size="small"
|
|
||||||
fullWidth
|
|
||||||
value={editValue}
|
|
||||||
autoFocus
|
|
||||||
onChange={e => setEditValue(e.target.value)}
|
|
||||||
onBlur={commit}
|
|
||||||
onKeyDown={e => { if (e.key === 'Enter') commit() }}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'flex-start',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
gap: 1,
|
|
||||||
py: 1,
|
|
||||||
borderBottom: '1px solid #f1f5f9',
|
|
||||||
opacity: missing ? 0.55 : 1,
|
|
||||||
'&:hover .edit-btn': { visibility: 'visible' },
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Box sx={{ flex: 1 }}>
|
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ fontWeight: 600, display: 'block' }}>
|
|
||||||
{label}
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="body2" sx={{ fontStyle: missing ? 'italic' : 'normal' }}>
|
|
||||||
{value || '—'}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
{onEdit && (
|
|
||||||
<IconButton
|
|
||||||
size="small"
|
|
||||||
className="edit-btn"
|
|
||||||
sx={{ visibility: 'hidden', p: 0.25, flexShrink: 0 }}
|
|
||||||
onClick={() => { setEditValue(value); setEditing(true) }}
|
|
||||||
>
|
|
||||||
<Pencil size={12} />
|
|
||||||
</IconButton>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
import { Box, Button, Card, Typography } from '@mui/material'
|
|
||||||
import { ArrowRight, RefreshCw } from 'lucide-react'
|
|
||||||
import type { FollowUpQuestion } from '../../domain/needBuilder'
|
|
||||||
import { FollowUpQuestionCard } from './FollowUpQuestionCard'
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
questions: FollowUpQuestion[]
|
|
||||||
answers: Record<string, string>
|
|
||||||
onAnswer: (id: string, answer: string) => void
|
|
||||||
onContinue: () => void
|
|
||||||
onReparse?: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export function FollowUpPanel({ questions, answers, onAnswer, onContinue, onReparse }: Props) {
|
|
||||||
const requiredUnanswered = questions
|
|
||||||
.filter(q => q.importance === 'required')
|
|
||||||
.filter(q => !answers[q.id])
|
|
||||||
|
|
||||||
const sorted = [
|
|
||||||
...questions.filter(q => q.importance === 'required'),
|
|
||||||
...questions.filter(q => q.importance === 'recommended'),
|
|
||||||
...questions.filter(q => q.importance === 'optional'),
|
|
||||||
]
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card sx={{ p: 3, height: '100%', display: 'flex', flexDirection: 'column' }}>
|
|
||||||
<Typography variant="subtitle1" sx={{ fontWeight: 600, mb: 0.5 }}>
|
|
||||||
Rückfragen der KI
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 2 }}>
|
|
||||||
Beantworten Sie die Pflichtfelder für optimale Ergebnisse. Optionale Fragen können übersprungen werden.
|
|
||||||
</Typography>
|
|
||||||
|
|
||||||
<Box sx={{ flex: 1, overflow: 'auto' }}>
|
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
|
||||||
{sorted.map(q => (
|
|
||||||
<FollowUpQuestionCard
|
|
||||||
key={q.id}
|
|
||||||
question={q}
|
|
||||||
answer={answers[q.id] ?? ''}
|
|
||||||
onAnswer={ans => onAnswer(q.id, ans)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Box sx={{ pt: 2, mt: 'auto', display: 'flex', flexDirection: 'column', gap: 1 }}>
|
|
||||||
{requiredUnanswered.length > 0 && (
|
|
||||||
<Typography variant="caption" color="error">
|
|
||||||
{requiredUnanswered.length} Pflichtfeld{requiredUnanswered.length > 1 ? 'er fehlen' : ' fehlt'} noch.
|
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
{onReparse && (
|
|
||||||
<Button
|
|
||||||
variant="outlined"
|
|
||||||
size="small"
|
|
||||||
startIcon={<RefreshCw size={14} />}
|
|
||||||
onClick={onReparse}
|
|
||||||
>
|
|
||||||
Rückfragen neu generieren
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Button
|
|
||||||
variant="contained"
|
|
||||||
fullWidth
|
|
||||||
disabled={requiredUnanswered.length > 0}
|
|
||||||
onClick={onContinue}
|
|
||||||
endIcon={<ArrowRight size={16} />}
|
|
||||||
sx={{ bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#162d4a' } }}
|
|
||||||
>
|
|
||||||
Weiter zur Gewichtung
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
import { Box, Chip, Stack, TextField, Typography } from '@mui/material'
|
|
||||||
import type { FollowUpQuestion } from '../../domain/needBuilder'
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
question: FollowUpQuestion
|
|
||||||
answer: string
|
|
||||||
onAnswer: (answer: string) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
const IMPORTANCE_LABEL: Record<FollowUpQuestion['importance'], string> = {
|
|
||||||
required: 'Pflichtfeld',
|
|
||||||
recommended: 'Empfohlen',
|
|
||||||
optional: 'Optional',
|
|
||||||
}
|
|
||||||
|
|
||||||
const IMPORTANCE_COLOR: Record<FollowUpQuestion['importance'], 'error' | 'warning' | 'default'> = {
|
|
||||||
required: 'error',
|
|
||||||
recommended: 'warning',
|
|
||||||
optional: 'default',
|
|
||||||
}
|
|
||||||
|
|
||||||
export function FollowUpQuestionCard({ question, answer, onAnswer }: Props) {
|
|
||||||
return (
|
|
||||||
<Box sx={{ pb: 2, borderBottom: '1px solid #f1f5f9' }}>
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1, mb: 1 }}>
|
|
||||||
<Typography variant="body2" sx={{ fontWeight: 500, flex: 1 }}>
|
|
||||||
{question.questionText}
|
|
||||||
</Typography>
|
|
||||||
<Chip
|
|
||||||
label={IMPORTANCE_LABEL[question.importance]}
|
|
||||||
size="small"
|
|
||||||
color={IMPORTANCE_COLOR[question.importance]}
|
|
||||||
variant="outlined"
|
|
||||||
sx={{ flexShrink: 0 }}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 1 }}>
|
|
||||||
{question.reason}
|
|
||||||
</Typography>
|
|
||||||
|
|
||||||
{question.suggestedAnswerOptions && question.suggestedAnswerOptions.length > 0 ? (
|
|
||||||
<Stack direction="row" spacing={0.5} sx={{ flexWrap: 'wrap', gap: 0.5 }}>
|
|
||||||
{question.suggestedAnswerOptions.map(opt => (
|
|
||||||
<Chip
|
|
||||||
key={opt}
|
|
||||||
label={opt}
|
|
||||||
size="small"
|
|
||||||
clickable
|
|
||||||
variant={answer === opt ? 'filled' : 'outlined'}
|
|
||||||
color={answer === opt ? 'primary' : 'default'}
|
|
||||||
onClick={() => onAnswer(answer === opt ? '' : opt)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</Stack>
|
|
||||||
) : (
|
|
||||||
<TextField
|
|
||||||
size="small"
|
|
||||||
fullWidth
|
|
||||||
placeholder="Ihre Antwort (optional)"
|
|
||||||
value={answer}
|
|
||||||
onChange={e => onAnswer(e.target.value)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
import { Box, Button, Typography } from '@mui/material'
|
|
||||||
import { AlertTriangle, RotateCcw } from 'lucide-react'
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
message: string
|
|
||||||
onRetry: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export function NeedBuilderErrorState({ message, onRetry }: Props) {
|
|
||||||
return (
|
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', py: 12, gap: 3, maxWidth: 480, mx: 'auto' }}>
|
|
||||||
<AlertTriangle size={48} color="#c0392b" />
|
|
||||||
<Box sx={{ textAlign: 'center' }}>
|
|
||||||
<Typography variant="h6" sx={{ fontWeight: 600, mb: 1 }}>
|
|
||||||
Analyse fehlgeschlagen
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="body2" color="text.secondary">
|
|
||||||
{message}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
<Button
|
|
||||||
variant="contained"
|
|
||||||
startIcon={<RotateCcw size={16} />}
|
|
||||||
onClick={onRetry}
|
|
||||||
sx={{ bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#162d4a' } }}
|
|
||||||
>
|
|
||||||
Erneut versuchen
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
import { Box, Stepper, Step, StepLabel } from '@mui/material'
|
|
||||||
import type { NeedBuilderStep } from '../../domain/needBuilder'
|
|
||||||
import { NeedBuilderStep as S } from '../../domain/needBuilder'
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
step: NeedBuilderStep
|
|
||||||
}
|
|
||||||
|
|
||||||
const STEPS = ['Suchkriterien & Gewichtung', 'Vorschau & Speichern']
|
|
||||||
|
|
||||||
function toStepIndex(step: NeedBuilderStep): number {
|
|
||||||
if (
|
|
||||||
step === S.IDLE ||
|
|
||||||
step === S.PARSING ||
|
|
||||||
step === S.PARSED_REQUIRES_REVIEW ||
|
|
||||||
step === S.CLARIFICATION_REQUIRED
|
|
||||||
) return 0
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
|
|
||||||
export function NeedBuilderProgress({ step }: Props) {
|
|
||||||
if (step === S.IDLE) return null
|
|
||||||
return (
|
|
||||||
<Box sx={{ px: 3, py: 2, borderBottom: '1px solid #e2e8f0', bgcolor: '#f8fafc' }}>
|
|
||||||
<Stepper activeStep={toStepIndex(step)} alternativeLabel>
|
|
||||||
{STEPS.map(label => (
|
|
||||||
<Step key={label}>
|
|
||||||
<StepLabel>{label}</StepLabel>
|
|
||||||
</Step>
|
|
||||||
))}
|
|
||||||
</Stepper>
|
|
||||||
</Box>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,184 +0,0 @@
|
|||||||
import { Box, Card, Chip, Divider, LinearProgress, Stack, TextField, Typography, Alert } from '@mui/material'
|
|
||||||
import { MapPin, Ruler, Wallet, Clock, CheckSquare, ShieldAlert } from 'lucide-react'
|
|
||||||
import type { ParsedNeedCriteria } from '../../domain/needBuilder'
|
|
||||||
import { WEIGHTING_KEYS, WEIGHTING_LABELS } from '../../domain/needBuilder'
|
|
||||||
import type { WeightingKey } from '../../domain/needBuilder'
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
criteria: ParsedNeedCriteria
|
|
||||||
weights: Record<WeightingKey, number>
|
|
||||||
confidenceByField: Record<string, number>
|
|
||||||
missingFields: string[]
|
|
||||||
needTitle: string
|
|
||||||
onNeedTitleChange: (v: string) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
const ASSET_LABELS: Record<string, string> = {
|
|
||||||
OFFICE: 'Büro', LOGISTICS: 'Logistik / Lager', RETAIL: 'Retail',
|
|
||||||
PRODUCTION: 'Produktion', GASTRO: 'Gastro / F&B', LIGHT_INDUSTRIAL: 'Light Industrial',
|
|
||||||
}
|
|
||||||
|
|
||||||
const CRITICAL_FIELDS = ['assetType', 'areaRange', 'preferredLocations', 'budgetRange', 'timing']
|
|
||||||
|
|
||||||
export function NeedCardPreview({ criteria: c, weights, confidenceByField, missingFields, needTitle, onNeedTitleChange }: Props) {
|
|
||||||
const maxWeight = Math.max(...WEIGHTING_KEYS.map(k => weights[k] ?? 0), 0.01)
|
|
||||||
|
|
||||||
const fieldEntries = Object.entries(confidenceByField)
|
|
||||||
const overallConfidence = fieldEntries.length > 0
|
|
||||||
? fieldEntries.reduce((sum, [, v]) => sum + v, 0) / fieldEntries.length
|
|
||||||
: 0
|
|
||||||
const lowConfidenceFields = fieldEntries.filter(([, v]) => v < 0.6).map(([k]) => k)
|
|
||||||
const criticalMissing = missingFields.filter(f => CRITICAL_FIELDS.some(cf => f.toLowerCase().includes(cf.toLowerCase())))
|
|
||||||
const isLowConfidence = overallConfidence < 0.6
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box sx={{ maxWidth: 720, mx: 'auto' }}>
|
|
||||||
<Typography variant="subtitle1" sx={{ fontWeight: 600, mb: 2 }}>
|
|
||||||
Vorschau — Neuer Bedarf
|
|
||||||
</Typography>
|
|
||||||
|
|
||||||
{/* Need Title */}
|
|
||||||
<TextField
|
|
||||||
fullWidth
|
|
||||||
size="small"
|
|
||||||
label="Bedarf Bezeichnung"
|
|
||||||
placeholder="z.B. Bürofläche Zürich Q4/2025"
|
|
||||||
value={needTitle}
|
|
||||||
onChange={e => onNeedTitleChange(e.target.value)}
|
|
||||||
sx={{ mb: 2 }}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Low-confidence warning */}
|
|
||||||
{isLowConfidence && (
|
|
||||||
<Alert severity="warning" icon={<ShieldAlert size={18} />} sx={{ mb: 2 }}>
|
|
||||||
Gesamtkonfidenz niedrig ({Math.round(overallConfidence * 100)}%) — Bedarf wird als Entwurf gespeichert und muss manuell geprüft werden.
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Critical missing fields */}
|
|
||||||
{criticalMissing.length > 0 && (
|
|
||||||
<Alert severity="error" sx={{ mb: 2 }}>
|
|
||||||
Fehlende Pflichtfelder: {criticalMissing.join(', ')}. Bitte in den Kriterien ergänzen.
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Card sx={{ p: 3, mb: 2 }}>
|
|
||||||
{/* Header */}
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
|
|
||||||
{c.assetType && (
|
|
||||||
<Chip label={ASSET_LABELS[c.assetType] ?? c.assetType} size="small" sx={{ bgcolor: '#1e3a5f', color: 'white' }} />
|
|
||||||
)}
|
|
||||||
<Chip label={isLowConfidence ? 'ENTWURF (needs_review)' : 'ENTWURF'} size="small" variant="outlined" />
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 2, mb: 2 }}>
|
|
||||||
{c.preferredLocations && c.preferredLocations.length > 0 && (
|
|
||||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
|
||||||
<MapPin size={16} color="#64748b" style={{ flexShrink: 0, marginTop: 2 }} />
|
|
||||||
<Box>
|
|
||||||
<Typography variant="caption" color="text.secondary">Standort</Typography>
|
|
||||||
<Typography variant="body2">{c.preferredLocations.join(', ')}</Typography>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
{c.areaRange && (
|
|
||||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
|
||||||
<Ruler size={16} color="#64748b" style={{ flexShrink: 0, marginTop: 2 }} />
|
|
||||||
<Box>
|
|
||||||
<Typography variant="caption" color="text.secondary">Fläche</Typography>
|
|
||||||
<Typography variant="body2">{c.areaRange.min}–{c.areaRange.max} m²</Typography>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
{c.budgetRange && (
|
|
||||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
|
||||||
<Wallet size={16} color="#64748b" style={{ flexShrink: 0, marginTop: 2 }} />
|
|
||||||
<Box>
|
|
||||||
<Typography variant="caption" color="text.secondary">Budget</Typography>
|
|
||||||
<Typography variant="body2">max. CHF {c.budgetRange.maxPerSqm}/m²</Typography>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
{c.timing && (
|
|
||||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
|
||||||
<Clock size={16} color="#64748b" style={{ flexShrink: 0, marginTop: 2 }} />
|
|
||||||
<Box>
|
|
||||||
<Typography variant="caption" color="text.secondary">Verfügbarkeit</Typography>
|
|
||||||
<Typography variant="body2">ab {c.timing.earliestMoveIn}</Typography>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{c.mustHaveCriteria && c.mustHaveCriteria.length > 0 && (
|
|
||||||
<Box sx={{ mb: 2 }}>
|
|
||||||
<Box sx={{ display: 'flex', gap: 1, mb: 0.5 }}>
|
|
||||||
<CheckSquare size={16} color="#64748b" />
|
|
||||||
<Typography variant="caption" color="text.secondary">Must-haves</Typography>
|
|
||||||
</Box>
|
|
||||||
<Stack direction="row" spacing={0.5} sx={{ flexWrap: 'wrap', gap: 0.5 }}>
|
|
||||||
{c.mustHaveCriteria.map(m => (
|
|
||||||
<Chip key={m} label={m} size="small" variant="outlined" />
|
|
||||||
))}
|
|
||||||
</Stack>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Divider sx={{ my: 2 }} />
|
|
||||||
|
|
||||||
{/* Confidence Summary */}
|
|
||||||
<Box sx={{ mb: 2 }}>
|
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.5 }}>
|
|
||||||
<Typography variant="caption" sx={{ fontWeight: 600 }} color="text.secondary">
|
|
||||||
Gesamtkonfidenz
|
|
||||||
</Typography>
|
|
||||||
<Typography
|
|
||||||
variant="caption"
|
|
||||||
sx={{ fontWeight: 700, color: overallConfidence >= 0.7 ? '#1a7a4a' : overallConfidence >= 0.5 ? '#d97706' : '#c0392b' }}
|
|
||||||
>
|
|
||||||
{Math.round(overallConfidence * 100)}%
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
<LinearProgress
|
|
||||||
variant="determinate"
|
|
||||||
value={overallConfidence * 100}
|
|
||||||
sx={{
|
|
||||||
height: 6, borderRadius: 3, bgcolor: '#e2e8f0',
|
|
||||||
'& .MuiLinearProgress-bar': {
|
|
||||||
bgcolor: overallConfidence >= 0.7 ? '#1a7a4a' : overallConfidence >= 0.5 ? '#d97706' : '#c0392b',
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{lowConfidenceFields.length > 0 && (
|
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ mt: 0.5, display: 'block' }}>
|
|
||||||
Unsichere Felder: {lowConfidenceFields.join(', ')}
|
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Divider sx={{ my: 2 }} />
|
|
||||||
|
|
||||||
{/* Weights */}
|
|
||||||
<Typography variant="caption" sx={{ fontWeight: 600, display: 'block', mb: 1 }} color="text.secondary">
|
|
||||||
Gewichtungsprofil
|
|
||||||
</Typography>
|
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75 }}>
|
|
||||||
{WEIGHTING_KEYS.map(k => {
|
|
||||||
const pct = Math.round((weights[k] ?? 0) * 100)
|
|
||||||
return (
|
|
||||||
<Box key={k} sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
|
||||||
<Typography variant="caption" sx={{ width: 130, flexShrink: 0 }}>{WEIGHTING_LABELS[k]}</Typography>
|
|
||||||
<LinearProgress
|
|
||||||
variant="determinate"
|
|
||||||
value={((weights[k] ?? 0) / maxWeight) * 100}
|
|
||||||
sx={{ flex: 1, height: 6, borderRadius: 3, bgcolor: '#e2e8f0', '& .MuiLinearProgress-bar': { bgcolor: '#1e3a5f' } }}
|
|
||||||
/>
|
|
||||||
<Typography variant="caption" sx={{ width: 32, textAlign: 'right' }}>{pct}%</Typography>
|
|
||||||
</Box>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</Box>
|
|
||||||
</Card>
|
|
||||||
</Box>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,155 +0,0 @@
|
|||||||
import { useState } from 'react'
|
|
||||||
import { Box, Card, Chip, Stack, TextField, Typography } from '@mui/material'
|
|
||||||
import type { ParsedNeedCriteria } from '../../domain/needBuilder'
|
|
||||||
import { AssetType } from '../../domain/enums'
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
criteria: ParsedNeedCriteria
|
|
||||||
onCriteriaChange: (c: ParsedNeedCriteria) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
const ASSET_OPTIONS = [
|
|
||||||
{ label: 'Büro', value: AssetType.OFFICE },
|
|
||||||
{ label: 'Retail', value: AssetType.RETAIL },
|
|
||||||
{ label: 'Logistik', value: AssetType.LOGISTICS },
|
|
||||||
{ label: 'Produktion', value: AssetType.PRODUCTION },
|
|
||||||
{ label: 'Light Industrial', value: AssetType.LIGHT_INDUSTRIAL },
|
|
||||||
{ label: 'Gemischt', value: AssetType.MIXED },
|
|
||||||
]
|
|
||||||
|
|
||||||
function FieldLabel({ children }: { children: React.ReactNode }) {
|
|
||||||
return (
|
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ fontWeight: 600, display: 'block', mb: 0.75 }}>
|
|
||||||
{children}
|
|
||||||
</Typography>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function NeedInput({ criteria: c, onCriteriaChange: set }: Props) {
|
|
||||||
const [locationDraft, setLocationDraft] = useState('')
|
|
||||||
const [mustHaveDraft, setMustHaveDraft] = useState('')
|
|
||||||
|
|
||||||
function addLocations(raw: string) {
|
|
||||||
const tokens = raw.split(',').map(x => x.trim()).filter(Boolean)
|
|
||||||
if (!tokens.length) return
|
|
||||||
set({ ...c, preferredLocations: [...new Set([...(c.preferredLocations ?? []), ...tokens])] })
|
|
||||||
setLocationDraft('')
|
|
||||||
}
|
|
||||||
|
|
||||||
function addMustHaves(raw: string) {
|
|
||||||
const tokens = raw.split(',').map(x => x.trim()).filter(Boolean)
|
|
||||||
if (!tokens.length) return
|
|
||||||
set({ ...c, mustHaveCriteria: [...new Set([...(c.mustHaveCriteria ?? []), ...tokens])] })
|
|
||||||
setMustHaveDraft('')
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card elevation={0} sx={{ p: 3, border: '1px solid #e2e8f0', height: '100%' }}>
|
|
||||||
<Typography variant="subtitle1" sx={{ fontWeight: 700, mb: 0.5 }}>Kriterien verfeinern</Typography>
|
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 2.5 }}>
|
|
||||||
Ergänzen oder korrigieren Sie die extrahierten Felder.
|
|
||||||
</Typography>
|
|
||||||
|
|
||||||
{/* Asset Type */}
|
|
||||||
<FieldLabel>Nutzungstyp</FieldLabel>
|
|
||||||
<Stack direction="row" spacing={0.5} sx={{ flexWrap: 'wrap', gap: 0.5, mb: 2.5 }}>
|
|
||||||
{ASSET_OPTIONS.map(opt => (
|
|
||||||
<Chip
|
|
||||||
key={opt.value}
|
|
||||||
label={opt.label}
|
|
||||||
size="small"
|
|
||||||
variant={c.assetType === opt.value ? 'filled' : 'outlined'}
|
|
||||||
clickable
|
|
||||||
onClick={() => set({ ...c, assetType: c.assetType === opt.value ? undefined : opt.value })}
|
|
||||||
sx={c.assetType === opt.value
|
|
||||||
? { bgcolor: '#1e3a5f', color: 'white', '& .MuiChip-label': { color: 'white' } }
|
|
||||||
: {}}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</Stack>
|
|
||||||
|
|
||||||
{/* Area */}
|
|
||||||
<FieldLabel>Fläche (m²)</FieldLabel>
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2.5 }}>
|
|
||||||
<TextField
|
|
||||||
size="small" type="number" placeholder="Min"
|
|
||||||
value={c.areaRange?.min || ''}
|
|
||||||
onChange={e => set({ ...c, areaRange: { min: parseInt(e.target.value) || 0, max: c.areaRange?.max ?? 0 } })}
|
|
||||||
sx={{ width: 100 }}
|
|
||||||
slotProps={{ htmlInput: { min: 0 } }}
|
|
||||||
/>
|
|
||||||
<Typography variant="body2" color="text.secondary">–</Typography>
|
|
||||||
<TextField
|
|
||||||
size="small" type="number" placeholder="Max"
|
|
||||||
value={c.areaRange?.max || ''}
|
|
||||||
onChange={e => set({ ...c, areaRange: { min: c.areaRange?.min ?? 0, max: parseInt(e.target.value) || 0 } })}
|
|
||||||
sx={{ width: 100 }}
|
|
||||||
slotProps={{ htmlInput: { min: 0 } }}
|
|
||||||
/>
|
|
||||||
<Typography variant="caption" color="text.secondary">m²</Typography>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Location */}
|
|
||||||
<FieldLabel>Standort</FieldLabel>
|
|
||||||
<TextField
|
|
||||||
size="small" fullWidth
|
|
||||||
placeholder="Stadt oder Region — Enter zum Hinzufügen"
|
|
||||||
value={locationDraft}
|
|
||||||
onChange={e => setLocationDraft(e.target.value)}
|
|
||||||
onKeyDown={e => { if (e.key === 'Enter' && locationDraft.trim()) addLocations(locationDraft) }}
|
|
||||||
onBlur={() => { if (locationDraft.trim()) addLocations(locationDraft) }}
|
|
||||||
sx={{ mb: 0.75 }}
|
|
||||||
/>
|
|
||||||
{(c.preferredLocations?.length ?? 0) > 0 ? (
|
|
||||||
<Stack direction="row" spacing={0.5} sx={{ flexWrap: 'wrap', gap: 0.5, mb: 2.5 }}>
|
|
||||||
{c.preferredLocations!.map(loc => (
|
|
||||||
<Chip key={loc} label={loc} size="small"
|
|
||||||
onDelete={() => set({ ...c, preferredLocations: c.preferredLocations!.filter(l => l !== loc) })}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</Stack>
|
|
||||||
) : <Box sx={{ mb: 2.5 }} />}
|
|
||||||
|
|
||||||
{/* Budget */}
|
|
||||||
<FieldLabel>Budget (max CHF/m²)</FieldLabel>
|
|
||||||
<TextField
|
|
||||||
size="small" type="number" placeholder="z.B. 45"
|
|
||||||
value={c.budgetRange?.maxPerSqm || ''}
|
|
||||||
onChange={e => set({ ...c, budgetRange: { maxPerSqm: parseInt(e.target.value) || 0, currency: 'CHF' } })}
|
|
||||||
sx={{ width: 160, mb: 2.5 }}
|
|
||||||
slotProps={{ htmlInput: { min: 0 } }}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Timing */}
|
|
||||||
<FieldLabel>Verfügbar ab</FieldLabel>
|
|
||||||
<TextField
|
|
||||||
size="small"
|
|
||||||
placeholder="z.B. Q3 2025 oder 01.09.2025"
|
|
||||||
value={c.timing?.earliestMoveIn ?? ''}
|
|
||||||
onChange={e => set({ ...c, timing: { earliestMoveIn: e.target.value, latestMoveIn: e.target.value, flexibleTiming: true } })}
|
|
||||||
sx={{ width: 220, mb: 2.5 }}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Must-haves */}
|
|
||||||
<FieldLabel>Must-haves</FieldLabel>
|
|
||||||
<TextField
|
|
||||||
size="small" fullWidth
|
|
||||||
placeholder="z.B. ÖV-Anbindung, Parkplätze — Enter zum Hinzufügen"
|
|
||||||
value={mustHaveDraft}
|
|
||||||
onChange={e => setMustHaveDraft(e.target.value)}
|
|
||||||
onKeyDown={e => { if (e.key === 'Enter' && mustHaveDraft.trim()) addMustHaves(mustHaveDraft) }}
|
|
||||||
onBlur={() => { if (mustHaveDraft.trim()) addMustHaves(mustHaveDraft) }}
|
|
||||||
sx={{ mb: 0.75 }}
|
|
||||||
/>
|
|
||||||
{(c.mustHaveCriteria?.length ?? 0) > 0 && (
|
|
||||||
<Stack direction="row" spacing={0.5} sx={{ flexWrap: 'wrap', gap: 0.5 }}>
|
|
||||||
{c.mustHaveCriteria!.map(item => (
|
|
||||||
<Chip key={item} label={item} size="small"
|
|
||||||
onDelete={() => set({ ...c, mustHaveCriteria: c.mustHaveCriteria!.filter(m => m !== item) })}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</Stack>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,202 +0,0 @@
|
|||||||
import { useRef, useState } from 'react'
|
|
||||||
import { Box, Button, Card, Chip, CircularProgress, IconButton, TextField, Typography } from '@mui/material'
|
|
||||||
import { Mic, MicOff, Sparkles, X } from 'lucide-react'
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
text: string
|
|
||||||
onTextChange: (s: string) => void
|
|
||||||
onAiSubmit: () => void
|
|
||||||
isAnalyzing: boolean
|
|
||||||
isAutoGen: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
const EXAMPLES = [
|
|
||||||
'Büro 800–1000 m² Zürich-West, ab Sept. 2025, max. CHF 45/m², ÖV-Anbindung',
|
|
||||||
'Retail-Fläche 200–400 m² Bern Innenstadt, Erdgeschoss, max. CHF 150/m², sofort',
|
|
||||||
'Lagerhalle 2000–3000 m² Basel, Rampe, 12 m Deckenhöhe, max. CHF 15/m²',
|
|
||||||
]
|
|
||||||
|
|
||||||
const isSpeechSupported = typeof window !== 'undefined' &&
|
|
||||||
('SpeechRecognition' in window || 'webkitSpeechRecognition' in window)
|
|
||||||
|
|
||||||
export function VoiceNeedInput({ text, onTextChange, onAiSubmit, isAnalyzing, isAutoGen }: Props) {
|
|
||||||
const [isRecording, setIsRecording] = useState(false)
|
|
||||||
const [interimText, setInterimText] = useState('')
|
|
||||||
const recognitionRef = useRef<any>(null)
|
|
||||||
const accumulatedRef = useRef('')
|
|
||||||
|
|
||||||
function startRecording() {
|
|
||||||
const SpeechAPI = (window as any).SpeechRecognition ?? (window as any).webkitSpeechRecognition
|
|
||||||
if (!SpeechAPI) return
|
|
||||||
|
|
||||||
accumulatedRef.current = text
|
|
||||||
const rec = new SpeechAPI()
|
|
||||||
rec.lang = 'de-DE'
|
|
||||||
rec.continuous = true
|
|
||||||
rec.interimResults = true
|
|
||||||
|
|
||||||
rec.onresult = (e: any) => {
|
|
||||||
let finalPart = ''
|
|
||||||
let interimPart = ''
|
|
||||||
for (let i = e.resultIndex; i < e.results.length; i++) {
|
|
||||||
const t = e.results[i][0].transcript
|
|
||||||
if (e.results[i].isFinal) finalPart += t
|
|
||||||
else interimPart += t
|
|
||||||
}
|
|
||||||
if (finalPart) {
|
|
||||||
accumulatedRef.current = (accumulatedRef.current + ' ' + finalPart).trim()
|
|
||||||
onTextChange(accumulatedRef.current)
|
|
||||||
}
|
|
||||||
setInterimText(interimPart)
|
|
||||||
}
|
|
||||||
|
|
||||||
rec.onend = () => {
|
|
||||||
setIsRecording(false)
|
|
||||||
setInterimText('')
|
|
||||||
if (accumulatedRef.current.length >= 15) onAiSubmit()
|
|
||||||
}
|
|
||||||
|
|
||||||
rec.onerror = () => { setIsRecording(false); setInterimText('') }
|
|
||||||
rec.start()
|
|
||||||
recognitionRef.current = rec
|
|
||||||
setIsRecording(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
function stopRecording() {
|
|
||||||
recognitionRef.current?.stop()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show interim text inside the field while recording
|
|
||||||
const displayValue = isRecording && interimText
|
|
||||||
? (text + (text ? ' ' : '') + interimText)
|
|
||||||
: text
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card
|
|
||||||
elevation={0}
|
|
||||||
sx={{
|
|
||||||
p: 3,
|
|
||||||
border: '1px solid',
|
|
||||||
borderColor: isRecording ? '#dc2626' : '#e2e8f0',
|
|
||||||
transition: 'border-color 0.2s',
|
|
||||||
bgcolor: isRecording ? '#fff5f5' : 'white',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', mb: 1.5 }}>
|
|
||||||
<Box>
|
|
||||||
<Typography variant="h6" sx={{ fontWeight: 700, lineHeight: 1.3 }}>
|
|
||||||
Bedarf beschreiben
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="caption" color="text.secondary">
|
|
||||||
Schreiben oder sprechen — die KI extrahiert alle Kriterien automatisch
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{isRecording ? (
|
|
||||||
<Chip
|
|
||||||
size="small"
|
|
||||||
label="🎤 Aufnahme läuft…"
|
|
||||||
onClick={stopRecording}
|
|
||||||
sx={{ bgcolor: '#fef2f2', color: '#dc2626', fontWeight: 600, fontSize: 11, cursor: 'pointer' }}
|
|
||||||
/>
|
|
||||||
) : isAnalyzing ? (
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
|
||||||
<CircularProgress size={14} sx={{ color: '#1e3a5f' }} />
|
|
||||||
<Typography variant="caption" sx={{ color: '#1e3a5f', fontWeight: 600 }}>Analysiert…</Typography>
|
|
||||||
</Box>
|
|
||||||
) : isAutoGen && text ? (
|
|
||||||
<Typography variant="caption" sx={{ color: '#1e3a5f', fontSize: 11 }}>⚡ auto-synchronisiert</Typography>
|
|
||||||
) : null}
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Textarea + mic */}
|
|
||||||
<Box sx={{ position: 'relative' }}>
|
|
||||||
<TextField
|
|
||||||
multiline
|
|
||||||
rows={4}
|
|
||||||
fullWidth
|
|
||||||
placeholder="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={displayValue}
|
|
||||||
onChange={e => {
|
|
||||||
setInterimText('')
|
|
||||||
onTextChange(e.target.value)
|
|
||||||
}}
|
|
||||||
disabled={isRecording || isAnalyzing}
|
|
||||||
slotProps={{ htmlInput: { maxLength: 2000 } }}
|
|
||||||
sx={{
|
|
||||||
'& .MuiOutlinedInput-root': {
|
|
||||||
pr: '52px',
|
|
||||||
bgcolor: isAutoGen && !isRecording ? '#f0f7ff' : 'transparent',
|
|
||||||
transition: 'background-color 0.2s',
|
|
||||||
'& textarea': { color: isRecording && interimText ? '#64748b' : 'inherit' },
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Box sx={{ position: 'absolute', bottom: 10, right: 10 }}>
|
|
||||||
{isRecording ? (
|
|
||||||
<IconButton
|
|
||||||
onClick={stopRecording}
|
|
||||||
size="small"
|
|
||||||
sx={{
|
|
||||||
bgcolor: '#dc2626', color: 'white', '&:hover': { bgcolor: '#b91c1c' },
|
|
||||||
animation: 'micPulse 1.2s ease-in-out infinite',
|
|
||||||
'@keyframes micPulse': {
|
|
||||||
'0%, 100%': { boxShadow: '0 0 0 0 rgba(220,38,38,0.4)' },
|
|
||||||
'50%': { boxShadow: '0 0 0 6px rgba(220,38,38,0)' },
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<MicOff size={16} />
|
|
||||||
</IconButton>
|
|
||||||
) : (
|
|
||||||
<IconButton
|
|
||||||
onClick={isSpeechSupported ? startRecording : undefined}
|
|
||||||
size="small"
|
|
||||||
disabled={isAnalyzing || !isSpeechSupported}
|
|
||||||
title={isSpeechSupported ? 'Spracheingabe starten' : 'Spracheingabe nicht verfügbar'}
|
|
||||||
sx={{ bgcolor: '#f1f5f9', color: '#475569', '&:hover': { bgcolor: '#e2e8f0' }, '&:disabled': { opacity: 0.35 } }}
|
|
||||||
>
|
|
||||||
<Mic size={16} />
|
|
||||||
</IconButton>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Actions row */}
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mt: 1.5 }}>
|
|
||||||
{/* Example prompts */}
|
|
||||||
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap' }}>
|
|
||||||
{EXAMPLES.map((ex, i) => (
|
|
||||||
<Chip
|
|
||||||
key={i}
|
|
||||||
label={ex.slice(0, 32) + '…'}
|
|
||||||
size="small"
|
|
||||||
variant="outlined"
|
|
||||||
clickable
|
|
||||||
onClick={() => onTextChange(ex)}
|
|
||||||
sx={{ fontSize: 10, height: 20 }}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Box sx={{ display: 'flex', gap: 1, flexShrink: 0, ml: 1 }}>
|
|
||||||
{text && !isRecording && (
|
|
||||||
<IconButton size="small" onClick={() => onTextChange('')} sx={{ color: '#94a3b8', p: 0.5 }}>
|
|
||||||
<X size={14} />
|
|
||||||
</IconButton>
|
|
||||||
)}
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
variant="outlined"
|
|
||||||
disabled={!text.trim() || isAnalyzing || isRecording}
|
|
||||||
onClick={onAiSubmit}
|
|
||||||
endIcon={<Sparkles size={13} />}
|
|
||||||
sx={{ fontSize: 12, whiteSpace: 'nowrap' }}
|
|
||||||
>
|
|
||||||
KI Auto-fill
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,98 +0,0 @@
|
|||||||
import { useState } from 'react'
|
|
||||||
import { Box, Button, Card, Slider, Typography } from '@mui/material'
|
|
||||||
import { RotateCcw } from 'lucide-react'
|
|
||||||
import { WEIGHTING_KEYS, WEIGHTING_LABELS } from '../../domain/needBuilder'
|
|
||||||
import type { WeightingKey } from '../../domain/needBuilder'
|
|
||||||
import { weightingService } from '../../services/weightingService'
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
weights: Record<WeightingKey, number>
|
|
||||||
onChange: (weights: Record<WeightingKey, number>) => void
|
|
||||||
assetType?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const IMPORTANCE_LABELS = ['', 'Unwichtig', 'Wenig wichtig', 'Wichtig', 'Sehr wichtig', 'Entscheidend']
|
|
||||||
|
|
||||||
function toRaw(w: Record<WeightingKey, number>): Record<WeightingKey, number> {
|
|
||||||
const max = Math.max(...WEIGHTING_KEYS.map(k => w[k] ?? 0))
|
|
||||||
if (max === 0) return Object.fromEntries(WEIGHTING_KEYS.map(k => [k, 3])) as Record<WeightingKey, number>
|
|
||||||
return Object.fromEntries(
|
|
||||||
WEIGHTING_KEYS.map(k => [k, Math.max(1, Math.round(((w[k] ?? 0) / max) * 5))])
|
|
||||||
) as Record<WeightingKey, number>
|
|
||||||
}
|
|
||||||
|
|
||||||
function rawToWeights(raw: Record<WeightingKey, number>): Record<WeightingKey, number> {
|
|
||||||
const total = WEIGHTING_KEYS.reduce((s, k) => s + (raw[k] ?? 1), 0)
|
|
||||||
return Object.fromEntries(
|
|
||||||
WEIGHTING_KEYS.map(k => [k, (raw[k] ?? 1) / total])
|
|
||||||
) as Record<WeightingKey, number>
|
|
||||||
}
|
|
||||||
|
|
||||||
export function WeightingEditor({ weights, onChange, assetType }: Props) {
|
|
||||||
const [raw, setRaw] = useState<Record<WeightingKey, number>>(() => toRaw(weights))
|
|
||||||
|
|
||||||
function handleSlider(key: WeightingKey, value: number) {
|
|
||||||
const updated = { ...raw, [key]: value }
|
|
||||||
setRaw(updated)
|
|
||||||
onChange(rawToWeights(updated))
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleReset() {
|
|
||||||
const defaults = weightingService.getDefaultWeights(assetType)
|
|
||||||
setRaw(toRaw(defaults))
|
|
||||||
onChange(defaults)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box sx={{ maxWidth: 720, mx: 'auto' }}>
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
|
|
||||||
<Box>
|
|
||||||
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
|
|
||||||
Wichtigkeit der Kriterien
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="caption" color="text.secondary">
|
|
||||||
Schieber nach rechts = wichtiger. Gewichtung wird automatisch berechnet.
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
variant="outlined"
|
|
||||||
startIcon={<RotateCcw size={14} />}
|
|
||||||
onClick={handleReset}
|
|
||||||
>
|
|
||||||
Zurücksetzen
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Card sx={{ p: 3 }}>
|
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2.5 }}>
|
|
||||||
{WEIGHTING_KEYS.map(key => {
|
|
||||||
const importance = raw[key] ?? 3
|
|
||||||
return (
|
|
||||||
<Box key={key}>
|
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.5 }}>
|
|
||||||
<Typography variant="body2" sx={{ fontWeight: 500 }}>
|
|
||||||
{WEIGHTING_LABELS[key]}
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="caption" color="text.secondary">
|
|
||||||
{IMPORTANCE_LABELS[importance]}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
<Slider
|
|
||||||
value={importance}
|
|
||||||
min={1}
|
|
||||||
max={5}
|
|
||||||
step={1}
|
|
||||||
marks
|
|
||||||
onChange={(_, v) => handleSlider(key, v as number)}
|
|
||||||
size="small"
|
|
||||||
sx={{ color: '#1e3a5f' }}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</Box>
|
|
||||||
</Card>
|
|
||||||
</Box>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
export { ConfidenceFieldBadge } from './ConfidenceFieldBadge'
|
|
||||||
export { CriteriaReviewPanel } from './CriteriaReviewPanel'
|
|
||||||
export { ExtractedFieldRow } from './ExtractedFieldRow'
|
|
||||||
export { FollowUpPanel } from './FollowUpPanel'
|
|
||||||
export { FollowUpQuestionCard } from './FollowUpQuestionCard'
|
|
||||||
export { NeedBuilderErrorState } from './NeedBuilderErrorState'
|
|
||||||
export { NeedBuilderProgress } from './NeedBuilderProgress'
|
|
||||||
export { NeedCardPreview } from './NeedCardPreview'
|
|
||||||
export { NeedInput } from './NeedInput'
|
|
||||||
export { VoiceNeedInput } from './VoiceNeedInput'
|
|
||||||
export { WeightingEditor } from './WeightingEditor'
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
import { Box, Typography } from '@mui/material'
|
|
||||||
import type { SxProps, Theme } from '@mui/material'
|
|
||||||
import type { ReactNode } from 'react'
|
|
||||||
import type { ConfidenceLevel } from '../../domain/enums'
|
|
||||||
import { ConfidenceBadge } from '../badges/ConfidenceBadge'
|
|
||||||
|
|
||||||
interface FieldWithConfidenceProps {
|
|
||||||
label: string
|
|
||||||
value: ReactNode
|
|
||||||
confidence?: number
|
|
||||||
confidenceLevel?: ConfidenceLevel
|
|
||||||
layout?: 'row' | 'column'
|
|
||||||
sx?: SxProps<Theme>
|
|
||||||
}
|
|
||||||
|
|
||||||
export function FieldWithConfidence({
|
|
||||||
label,
|
|
||||||
value,
|
|
||||||
confidence,
|
|
||||||
confidenceLevel,
|
|
||||||
layout = 'column',
|
|
||||||
sx,
|
|
||||||
}: FieldWithConfidenceProps) {
|
|
||||||
const showBadge = confidence !== undefined || confidenceLevel !== undefined
|
|
||||||
|
|
||||||
if (layout === 'row') {
|
|
||||||
return (
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, ...sx }}>
|
|
||||||
<Typography sx={{ fontSize: '0.75rem', color: 'text.secondary', minWidth: 80 }}>
|
|
||||||
{label}
|
|
||||||
</Typography>
|
|
||||||
<Box sx={{ flex: 1 }}>{value}</Box>
|
|
||||||
{showBadge && (
|
|
||||||
<ConfidenceBadge level={confidenceLevel} score={confidence} />
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box sx={sx}>
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 0.25 }}>
|
|
||||||
<Typography sx={{ fontSize: '0.75rem', color: 'text.secondary' }}>
|
|
||||||
{label}
|
|
||||||
</Typography>
|
|
||||||
{showBadge && (
|
|
||||||
<ConfidenceBadge level={confidenceLevel} score={confidence} />
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
<Box sx={{ fontSize: '0.875rem' }}>{value}</Box>
|
|
||||||
</Box>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
import { Box, Chip, Typography } from '@mui/material'
|
|
||||||
import type { SxProps, Theme } from '@mui/material'
|
|
||||||
|
|
||||||
interface ChipOption {
|
|
||||||
value: string
|
|
||||||
label: string
|
|
||||||
color?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PriorityChipGroupProps {
|
|
||||||
label?: string
|
|
||||||
options: ChipOption[]
|
|
||||||
value: string[]
|
|
||||||
onChange: (value: string[]) => void
|
|
||||||
exclusive?: boolean
|
|
||||||
sx?: SxProps<Theme>
|
|
||||||
}
|
|
||||||
|
|
||||||
export function PriorityChipGroup({ label, options, value, onChange, exclusive = false, sx }: PriorityChipGroupProps) {
|
|
||||||
function toggle(optValue: string) {
|
|
||||||
if (exclusive) {
|
|
||||||
onChange(value.includes(optValue) ? [] : [optValue])
|
|
||||||
} else {
|
|
||||||
onChange(
|
|
||||||
value.includes(optValue)
|
|
||||||
? value.filter(v => v !== optValue)
|
|
||||||
: [...value, optValue],
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box sx={sx}>
|
|
||||||
{label && (
|
|
||||||
<Typography sx={{ fontSize: '0.75rem', color: 'text.secondary', mb: 0.75, fontWeight: 500 }}>
|
|
||||||
{label}
|
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
|
|
||||||
{options.map(opt => {
|
|
||||||
const selected = value.includes(opt.value)
|
|
||||||
const accent = opt.color ?? '#1e3a5f'
|
|
||||||
return (
|
|
||||||
<Chip
|
|
||||||
key={opt.value}
|
|
||||||
label={opt.label}
|
|
||||||
size="small"
|
|
||||||
clickable
|
|
||||||
onClick={() => toggle(opt.value)}
|
|
||||||
sx={{
|
|
||||||
fontSize: '0.75rem',
|
|
||||||
bgcolor: selected ? accent : 'transparent',
|
|
||||||
color: selected ? '#fff' : 'text.secondary',
|
|
||||||
border: '1px solid',
|
|
||||||
borderColor: selected ? accent : 'divider',
|
|
||||||
'&:hover': { bgcolor: selected ? accent : 'rgba(0,0,0,0.04)' },
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
import { FormControl, FormHelperText, InputLabel, MenuItem, Select } from '@mui/material'
|
|
||||||
import type { SxProps, Theme } from '@mui/material'
|
|
||||||
|
|
||||||
interface SelectOption<T extends string = string> {
|
|
||||||
value: T
|
|
||||||
label: string
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SelectFieldProps<T extends string = string> {
|
|
||||||
label: string
|
|
||||||
value: T | ''
|
|
||||||
onChange: (value: T) => void
|
|
||||||
options: SelectOption<T>[]
|
|
||||||
error?: string
|
|
||||||
helperText?: string
|
|
||||||
required?: boolean
|
|
||||||
disabled?: boolean
|
|
||||||
size?: 'small' | 'medium'
|
|
||||||
fullWidth?: boolean
|
|
||||||
sx?: SxProps<Theme>
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SelectField<T extends string = string>({
|
|
||||||
label,
|
|
||||||
value,
|
|
||||||
onChange,
|
|
||||||
options,
|
|
||||||
error,
|
|
||||||
helperText,
|
|
||||||
required,
|
|
||||||
disabled,
|
|
||||||
size = 'small',
|
|
||||||
fullWidth = true,
|
|
||||||
sx,
|
|
||||||
}: SelectFieldProps<T>) {
|
|
||||||
const labelId = `select-${label.replace(/\s+/g, '-').toLowerCase()}`
|
|
||||||
return (
|
|
||||||
<FormControl fullWidth={fullWidth} size={size} error={!!error} disabled={disabled} required={required} sx={sx}>
|
|
||||||
<InputLabel id={labelId}>{label}</InputLabel>
|
|
||||||
<Select
|
|
||||||
labelId={labelId}
|
|
||||||
value={value}
|
|
||||||
label={label}
|
|
||||||
onChange={e => onChange(e.target.value as T)}
|
|
||||||
>
|
|
||||||
{options.map(opt => (
|
|
||||||
<MenuItem key={opt.value} value={opt.value}>
|
|
||||||
{opt.label}
|
|
||||||
</MenuItem>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
{(error ?? helperText) && <FormHelperText>{error ?? helperText}</FormHelperText>}
|
|
||||||
</FormControl>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
import { TextField } from '@mui/material'
|
|
||||||
import type { SxProps, Theme } from '@mui/material'
|
|
||||||
|
|
||||||
interface TextInputProps {
|
|
||||||
label: string
|
|
||||||
value: string
|
|
||||||
onChange: (value: string) => void
|
|
||||||
error?: string
|
|
||||||
helperText?: string
|
|
||||||
placeholder?: string
|
|
||||||
required?: boolean
|
|
||||||
disabled?: boolean
|
|
||||||
multiline?: boolean
|
|
||||||
rows?: number
|
|
||||||
size?: 'small' | 'medium'
|
|
||||||
fullWidth?: boolean
|
|
||||||
sx?: SxProps<Theme>
|
|
||||||
}
|
|
||||||
|
|
||||||
export function TextInput({
|
|
||||||
label,
|
|
||||||
value,
|
|
||||||
onChange,
|
|
||||||
error,
|
|
||||||
helperText,
|
|
||||||
placeholder,
|
|
||||||
required,
|
|
||||||
disabled,
|
|
||||||
multiline,
|
|
||||||
rows,
|
|
||||||
size = 'small',
|
|
||||||
fullWidth = true,
|
|
||||||
sx,
|
|
||||||
}: TextInputProps) {
|
|
||||||
return (
|
|
||||||
<TextField
|
|
||||||
label={label}
|
|
||||||
value={value}
|
|
||||||
onChange={e => onChange(e.target.value)}
|
|
||||||
error={!!error}
|
|
||||||
helperText={error ?? helperText}
|
|
||||||
placeholder={placeholder}
|
|
||||||
required={required}
|
|
||||||
disabled={disabled}
|
|
||||||
multiline={multiline}
|
|
||||||
rows={rows}
|
|
||||||
size={size}
|
|
||||||
fullWidth={fullWidth}
|
|
||||||
sx={sx}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
export { TextInput } from './TextInput'
|
|
||||||
export { SelectField } from './SelectField'
|
|
||||||
export { PriorityChipGroup } from './PriorityChipGroup'
|
|
||||||
export { FieldWithConfidence } from './FieldWithConfidence'
|
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
import { Box, Chip, LinearProgress, Typography } from '@mui/material'
|
|
||||||
import { SignalTypeBadge } from './SignalTypeBadge'
|
|
||||||
import { SensitivityBadge } from './SensitivityBadge'
|
|
||||||
import { SignalReviewStatusBadge } from './SignalReviewStatusBadge'
|
|
||||||
import { FutureSignalDisclaimer } from './FutureSignalDisclaimer'
|
|
||||||
import type { FutureSignal } from '../../domain/futureSignal'
|
|
||||||
|
|
||||||
const SOURCE_LABELS: Record<string, string> = {
|
|
||||||
PRESS: 'Presse',
|
|
||||||
CONSTRUCTION_PERMIT: 'Baubewilligung',
|
|
||||||
JOB_POSTING: 'Stelleninserat',
|
|
||||||
COMPANY_REPORT: 'Geschäftsbericht',
|
|
||||||
MARKET_DATA: 'Marktdaten',
|
|
||||||
MANUAL: 'Manuell',
|
|
||||||
}
|
|
||||||
|
|
||||||
function probColor(p: number): string {
|
|
||||||
return p >= 0.7 ? '#1a7a4a' : p >= 0.5 ? '#d97706' : '#c0392b'
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
signal: FutureSignal
|
|
||||||
isSelected: boolean
|
|
||||||
onSelect: (signal: FutureSignal) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export function FutureSignalCard({ signal, isSelected, onSelect }: Props) {
|
|
||||||
const isConfidential = signal.sensitivityLevel === 'CONFIDENTIAL'
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box
|
|
||||||
onClick={() => onSelect(signal)}
|
|
||||||
sx={{
|
|
||||||
p: 2,
|
|
||||||
cursor: 'pointer',
|
|
||||||
borderBottom: '1px solid #f1f5f9',
|
|
||||||
borderLeft: isSelected
|
|
||||||
? '3px solid #1e3a5f'
|
|
||||||
: isConfidential
|
|
||||||
? '3px solid #d97706'
|
|
||||||
: '3px solid transparent',
|
|
||||||
bgcolor: isSelected ? '#eff6ff' : isConfidential ? '#fffbeb' : 'transparent',
|
|
||||||
'&:hover': { bgcolor: isSelected ? '#eff6ff' : '#f8fafc' },
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Row 1: type + sensitivity + review status */}
|
|
||||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5, mb: 1 }}>
|
|
||||||
<SignalTypeBadge type={signal.signalType} />
|
|
||||||
<SensitivityBadge level={signal.sensitivityLevel} />
|
|
||||||
<SignalReviewStatusBadge status={signal.reviewStatus} />
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Row 2: title / company / location */}
|
|
||||||
<Typography variant="body2" sx={{ fontWeight: 600, mb: 0.25 }}>
|
|
||||||
{signal.title ?? signal.companyName ?? signal.locationHint}
|
|
||||||
</Typography>
|
|
||||||
{(signal.title || signal.companyName) && (
|
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.75 }}>
|
|
||||||
{signal.locationHint}
|
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Row 3: probability */}
|
|
||||||
<Box sx={{ mb: 0.75 }}>
|
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.25 }}>
|
|
||||||
<Typography variant="caption" color="text.secondary">Wahrscheinlichkeit</Typography>
|
|
||||||
<Typography variant="caption" sx={{ fontWeight: 700, color: probColor(signal.probability) }}>
|
|
||||||
{Math.round(signal.probability * 100)}%
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
<LinearProgress
|
|
||||||
variant="determinate"
|
|
||||||
value={signal.probability * 100}
|
|
||||||
sx={{
|
|
||||||
height: 4,
|
|
||||||
borderRadius: 2,
|
|
||||||
bgcolor: '#f1f5f9',
|
|
||||||
'& .MuiLinearProgress-bar': { bgcolor: probColor(signal.probability) },
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Row 4: meta chips */}
|
|
||||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5, mb: 1 }}>
|
|
||||||
<Chip label={`${signal.timeHorizonMonths}M`} size="small" sx={{ fontSize: 10, height: 18 }} />
|
|
||||||
{signal.areaSqmEstimate && (
|
|
||||||
<Chip label={`~${signal.areaSqmEstimate.toLocaleString('de-CH')} m²`} size="small" sx={{ fontSize: 10, height: 18 }} />
|
|
||||||
)}
|
|
||||||
<Chip
|
|
||||||
label={SOURCE_LABELS[signal.source.type] ?? signal.source.type}
|
|
||||||
size="small"
|
|
||||||
variant="outlined"
|
|
||||||
sx={{ fontSize: 10, height: 18 }}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Footer: mini disclaimer */}
|
|
||||||
<FutureSignalDisclaimer mini />
|
|
||||||
</Box>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
-263
@@ -1,263 +0,0 @@
|
|||||||
import { Box, Button, Chip, CircularProgress, Divider, IconButton, LinearProgress, Paper, Typography } from '@mui/material'
|
|
||||||
import { X } from 'lucide-react'
|
|
||||||
import { useState } from 'react'
|
|
||||||
import { SignalTypeBadge } from './SignalTypeBadge'
|
|
||||||
import { SensitivityBadge } from './SensitivityBadge'
|
|
||||||
import { SignalReviewStatusBadge } from './SignalReviewStatusBadge'
|
|
||||||
import { FutureSignalDisclaimer } from './FutureSignalDisclaimer'
|
|
||||||
import { useUpdateSignalReviewStatus } from '../../hooks/useFutureSignals'
|
|
||||||
import { useShortlistStore } from '../../stores/shortlistStore'
|
|
||||||
import { useToastStore } from '../../stores/toastStore'
|
|
||||||
import { reviewService } from '../../services/reviewService'
|
|
||||||
import { ReviewStatus } from '../../domain/enums'
|
|
||||||
import type { FutureSignal } from '../../domain/futureSignal'
|
|
||||||
|
|
||||||
const SOURCE_LABELS: Record<string, string> = {
|
|
||||||
PRESS: 'Pressebericht',
|
|
||||||
CONSTRUCTION_PERMIT: 'Baubewilligung',
|
|
||||||
JOB_POSTING: 'Stelleninserat',
|
|
||||||
COMPANY_REPORT: 'Geschäftsbericht',
|
|
||||||
MARKET_DATA: 'Marktdaten',
|
|
||||||
MANUAL: 'Manuell erfasst',
|
|
||||||
}
|
|
||||||
|
|
||||||
const CREDIBILITY_META: Record<string, { label: string; color: string }> = {
|
|
||||||
HIGH: { label: 'Hoch', color: '#1a7a4a' },
|
|
||||||
MEDIUM: { label: 'Mittel', color: '#d97706' },
|
|
||||||
LOW: { label: 'Niedrig', color: '#c0392b' },
|
|
||||||
}
|
|
||||||
|
|
||||||
function BarRow({ label, value }: { label: string; value: number }) {
|
|
||||||
const color = value >= 0.75 ? '#1a7a4a' : value >= 0.55 ? '#d97706' : '#c0392b'
|
|
||||||
return (
|
|
||||||
<Box sx={{ mb: 1.5 }}>
|
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.25 }}>
|
|
||||||
<Typography variant="caption" color="text.secondary">{label}</Typography>
|
|
||||||
<Typography variant="caption" sx={{ fontWeight: 700, color }}>{Math.round(value * 100)}%</Typography>
|
|
||||||
</Box>
|
|
||||||
<LinearProgress
|
|
||||||
variant="determinate"
|
|
||||||
value={value * 100}
|
|
||||||
sx={{ height: 5, borderRadius: 2, bgcolor: '#f1f5f9', '& .MuiLinearProgress-bar': { bgcolor: color } }}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
signal: FutureSignal
|
|
||||||
onClose: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export function FutureSignalDetailPanel({ signal, onClose }: Props) {
|
|
||||||
const updateStatus = useUpdateSignalReviewStatus()
|
|
||||||
const { openAddDialog } = useShortlistStore()
|
|
||||||
const showToast = useToastStore((s) => s.showToast)
|
|
||||||
const [reviewTaskSent, setReviewTaskSent] = useState(false)
|
|
||||||
|
|
||||||
const reviewStatus = signal.reviewStatus ?? ReviewStatus.UNREVIEWED
|
|
||||||
const isRejected = reviewStatus === ReviewStatus.REJECTED
|
|
||||||
const isApproved = reviewStatus === ReviewStatus.APPROVED
|
|
||||||
|
|
||||||
const STATUS_TOAST: Record<string, string> = {
|
|
||||||
IN_REVIEW: 'Signal zur Prüfung markiert.',
|
|
||||||
APPROVED: 'Signal genehmigt.',
|
|
||||||
REJECTED: 'Signal abgelehnt.',
|
|
||||||
FLAGGED: 'Signal markiert.',
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleStatus(status: typeof ReviewStatus[keyof typeof ReviewStatus]) {
|
|
||||||
try {
|
|
||||||
await updateStatus.mutateAsync({ id: signal.id, status })
|
|
||||||
showToast(STATUS_TOAST[status] ?? 'Status aktualisiert.')
|
|
||||||
} catch {
|
|
||||||
showToast('Statusänderung fehlgeschlagen.', 'error')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleSendReview() {
|
|
||||||
try {
|
|
||||||
await reviewService.createReviewTask(signal.id)
|
|
||||||
setReviewTaskSent(true)
|
|
||||||
showToast('Prüfungsaufgabe erstellt.')
|
|
||||||
} catch {
|
|
||||||
showToast('Prüfungsaufgabe konnte nicht erstellt werden.', 'error')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleShortlist() {
|
|
||||||
openAddDialog({
|
|
||||||
resultId: signal.id,
|
|
||||||
resultType: 'FUTURE_AVAILABILITY',
|
|
||||||
title: signal.title ?? signal.companyName ?? signal.locationHint,
|
|
||||||
matchScore: Math.round(signal.confidenceScore * 100),
|
|
||||||
confidenceScore: signal.confidenceScore,
|
|
||||||
sourceLabel: SOURCE_LABELS[signal.source.type] ?? signal.source.type,
|
|
||||||
addedBy: 'admin@ideal-sharing.ch',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const credMeta = CREDIBILITY_META[signal.source.credibility] ?? { label: signal.source.credibility, color: '#64748b' }
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
|
||||||
{/* Header */}
|
|
||||||
<Box sx={{ px: 2.5, py: 2, borderBottom: '1px solid #e2e8f0', bgcolor: 'white', flexShrink: 0 }}>
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', mb: 1 }}>
|
|
||||||
<Box sx={{ flex: 1, mr: 1 }}>
|
|
||||||
<Typography variant="subtitle1" sx={{ fontWeight: 700, lineHeight: 1.3 }}>
|
|
||||||
{signal.title ?? signal.companyName ?? signal.locationHint}
|
|
||||||
</Typography>
|
|
||||||
{(signal.title || signal.companyName) && (
|
|
||||||
<Typography variant="caption" color="text.secondary">{signal.locationHint}</Typography>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
<IconButton size="small" onClick={onClose} sx={{ color: '#94a3b8', mt: -0.5 }}>
|
|
||||||
<X size={16} />
|
|
||||||
</IconButton>
|
|
||||||
</Box>
|
|
||||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
|
|
||||||
<SignalTypeBadge type={signal.signalType} />
|
|
||||||
<SensitivityBadge level={signal.sensitivityLevel} />
|
|
||||||
<SignalReviewStatusBadge status={signal.reviewStatus} />
|
|
||||||
{signal.isVerified && (
|
|
||||||
<Chip label="Verifiziert" size="small" sx={{ bgcolor: '#1a7a4a', color: 'white', fontSize: 10 }} />
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Body */}
|
|
||||||
<Box sx={{ flex: 1, overflowY: 'auto', p: 2.5 }}>
|
|
||||||
<FutureSignalDisclaimer />
|
|
||||||
|
|
||||||
{/* Probability + confidence */}
|
|
||||||
<BarRow label="Wahrscheinlichkeit" value={signal.probability} />
|
|
||||||
<BarRow label="Konfidenz" value={signal.confidenceScore} />
|
|
||||||
|
|
||||||
<Divider sx={{ my: 1.5 }} />
|
|
||||||
|
|
||||||
{/* Meta */}
|
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75, mb: 1.5 }}>
|
|
||||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ minWidth: 140 }}>Zeithorizont</Typography>
|
|
||||||
<Typography variant="caption">~{signal.timeHorizonMonths} Monate</Typography>
|
|
||||||
</Box>
|
|
||||||
{signal.areaSqmEstimate && (
|
|
||||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ minWidth: 140 }}>Geschätzte Fläche</Typography>
|
|
||||||
<Typography variant="caption">~{signal.areaSqmEstimate.toLocaleString('de-CH')} m²</Typography>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
{signal.companyName && (
|
|
||||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ minWidth: 140 }}>Unternehmen</Typography>
|
|
||||||
<Typography variant="caption">{signal.companyName}</Typography>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
{signal.riskLevel && (
|
|
||||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ minWidth: 140 }}>Risikoniveau</Typography>
|
|
||||||
<Typography variant="caption">{signal.riskLevel}</Typography>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Divider sx={{ my: 1.5 }} />
|
|
||||||
|
|
||||||
{/* Source */}
|
|
||||||
<Typography variant="caption" sx={{ fontWeight: 600, display: 'block', mb: 0.75 }}>Quelle</Typography>
|
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5, mb: 1.5 }}>
|
|
||||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ minWidth: 140 }}>Typ</Typography>
|
|
||||||
<Typography variant="caption">{SOURCE_LABELS[signal.source.type] ?? signal.source.type}</Typography>
|
|
||||||
</Box>
|
|
||||||
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
|
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ minWidth: 140 }}>Glaubwürdigkeit</Typography>
|
|
||||||
<Chip label={credMeta.label} size="small" sx={{ bgcolor: credMeta.color, color: 'white', fontSize: 10, height: 18 }} />
|
|
||||||
</Box>
|
|
||||||
{signal.source.publishedAt && (
|
|
||||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ minWidth: 140 }}>Veröffentlicht</Typography>
|
|
||||||
<Typography variant="caption">{signal.source.publishedAt}</Typography>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Evidence */}
|
|
||||||
{signal.evidence?.summary && (
|
|
||||||
<>
|
|
||||||
<Divider sx={{ my: 1.5 }} />
|
|
||||||
<Typography variant="caption" sx={{ fontWeight: 600, display: 'block', mb: 0.75 }}>Evidenz</Typography>
|
|
||||||
<Paper variant="outlined" sx={{ p: 1.5, bgcolor: '#f8fafc' }}>
|
|
||||||
<Typography variant="body2" color="text.secondary">{signal.evidence.summary}</Typography>
|
|
||||||
</Paper>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Market indicator */}
|
|
||||||
{signal.marketIndicator && (
|
|
||||||
<>
|
|
||||||
<Divider sx={{ my: 1.5 }} />
|
|
||||||
<Typography variant="caption" sx={{ fontWeight: 600, display: 'block', mb: 0.5 }}>Marktindikator</Typography>
|
|
||||||
<Typography variant="body2" color="text.secondary">{signal.marketIndicator}</Typography>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Divider sx={{ my: 1.5 }} />
|
|
||||||
|
|
||||||
{/* Actions */}
|
|
||||||
<Typography variant="caption" sx={{ fontWeight: 600, display: 'block', mb: 1 }}>Aktionen</Typography>
|
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75 }}>
|
|
||||||
{reviewStatus === ReviewStatus.UNREVIEWED && (
|
|
||||||
<Button
|
|
||||||
fullWidth size="small" variant="outlined"
|
|
||||||
disabled={updateStatus.isPending}
|
|
||||||
onClick={() => handleStatus(ReviewStatus.IN_REVIEW)}
|
|
||||||
sx={{ justifyContent: 'flex-start' }}
|
|
||||||
>
|
|
||||||
Verfolgen (In Prüfung setzen)
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{!isRejected && !reviewTaskSent && (
|
|
||||||
<Button
|
|
||||||
fullWidth size="small" variant="outlined"
|
|
||||||
onClick={handleSendReview}
|
|
||||||
sx={{ justifyContent: 'flex-start', color: '#d97706', borderColor: '#d97706' }}
|
|
||||||
>
|
|
||||||
Zur Prüfung senden
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{(reviewStatus === ReviewStatus.IN_REVIEW || reviewStatus === ReviewStatus.FLAGGED) && !isApproved && (
|
|
||||||
<Button
|
|
||||||
fullWidth size="small" variant="contained"
|
|
||||||
disabled={updateStatus.isPending}
|
|
||||||
onClick={() => handleStatus(ReviewStatus.APPROVED)}
|
|
||||||
sx={{ bgcolor: '#1a7a4a', '&:hover': { bgcolor: '#15643c' }, justifyContent: 'flex-start' }}
|
|
||||||
endIcon={updateStatus.isPending ? <CircularProgress size={14} color="inherit" /> : undefined}
|
|
||||||
>
|
|
||||||
Genehmigen
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{!isRejected && (
|
|
||||||
<Button
|
|
||||||
fullWidth size="small" variant="outlined"
|
|
||||||
disabled={updateStatus.isPending}
|
|
||||||
onClick={() => handleStatus(ReviewStatus.REJECTED)}
|
|
||||||
sx={{ justifyContent: 'flex-start', color: '#c0392b', borderColor: '#c0392b' }}
|
|
||||||
>
|
|
||||||
Ablehnen
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Button
|
|
||||||
fullWidth size="small" variant="outlined"
|
|
||||||
onClick={handleShortlist}
|
|
||||||
sx={{ justifyContent: 'flex-start' }}
|
|
||||||
>
|
|
||||||
Zu Shortlist hinzufügen
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
</Box>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
-27
@@ -1,27 +0,0 @@
|
|||||||
import { Alert, Typography } from '@mui/material'
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
mini?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export function FutureSignalDisclaimer({ mini = false }: Props) {
|
|
||||||
if (mini) {
|
|
||||||
return (
|
|
||||||
<Alert severity="warning" sx={{ py: 0.25, px: 1, '& .MuiAlert-message': { py: 0.25 } }}>
|
|
||||||
<Typography variant="caption">Probabilistisches Signal – keine bestätigte Fläche</Typography>
|
|
||||||
</Alert>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Alert severity="warning" sx={{ mb: 2 }}>
|
|
||||||
<Typography variant="body2" sx={{ fontWeight: 600, mb: 0.5 }}>
|
|
||||||
Hinweis: Probabilistisches Zukunftssignal
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="body2">
|
|
||||||
Dieses Signal basiert auf AI-Analyse öffentlicher Daten. Es handelt sich um keine bestätigte verfügbare Fläche.
|
|
||||||
Bitte ausschliesslich für strategische Beobachtung und interne Prüfung verwenden — keine verbindlichen Aussagen gegenüber Dritten.
|
|
||||||
</Typography>
|
|
||||||
</Alert>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
-28
@@ -1,28 +0,0 @@
|
|||||||
import { Box, Typography } from '@mui/material'
|
|
||||||
import { Filter, Radio } from 'lucide-react'
|
|
||||||
|
|
||||||
type EmptyContext = 'no-signals' | 'filtered-empty'
|
|
||||||
|
|
||||||
const META: Record<EmptyContext, { icon: React.ReactNode; title: string; desc: string }> = {
|
|
||||||
'no-signals': {
|
|
||||||
icon: <Radio size={32} color="#94a3b8" />,
|
|
||||||
title: 'Keine Signale vorhanden',
|
|
||||||
desc: 'Es wurden noch keine Zukunftssignale erfasst.',
|
|
||||||
},
|
|
||||||
'filtered-empty': {
|
|
||||||
icon: <Filter size={32} color="#94a3b8" />,
|
|
||||||
title: 'Keine Signale für diese Filter',
|
|
||||||
desc: 'Passen Sie die Filtereinstellungen an, um Signale anzuzeigen.',
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
export function FutureSignalEmptyState({ context }: { context: EmptyContext }) {
|
|
||||||
const { icon, title, desc } = META[context]
|
|
||||||
return (
|
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 1.5, py: 8, px: 3 }}>
|
|
||||||
{icon}
|
|
||||||
<Typography variant="subtitle1" sx={{ fontWeight: 600 }} color="text.secondary">{title}</Typography>
|
|
||||||
<Typography variant="body2" color="text.secondary" sx={{ textAlign: 'center', maxWidth: 280 }}>{desc}</Typography>
|
|
||||||
</Box>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
-143
@@ -1,143 +0,0 @@
|
|||||||
import { Box, Chip, FormControl, InputLabel, MenuItem, Select, Typography } from '@mui/material'
|
|
||||||
import { SignalType } from '../../domain/enums'
|
|
||||||
import { SIGNAL_TYPE_LABELS } from '../../lib/constants'
|
|
||||||
|
|
||||||
export interface SignalFilterState {
|
|
||||||
signalType: string
|
|
||||||
minConfidence: number
|
|
||||||
sensitivityLevel: string
|
|
||||||
reviewStatus: string
|
|
||||||
timeHorizon: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export const DEFAULT_SIGNAL_FILTERS: SignalFilterState = {
|
|
||||||
signalType: '',
|
|
||||||
minConfidence: 0,
|
|
||||||
sensitivityLevel: '',
|
|
||||||
reviewStatus: '',
|
|
||||||
timeHorizon: '',
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
filters: SignalFilterState
|
|
||||||
onChange: (f: SignalFilterState) => void
|
|
||||||
totalCount: number
|
|
||||||
filteredCount: number
|
|
||||||
}
|
|
||||||
|
|
||||||
const SIGNAL_TYPES = Object.values(SignalType)
|
|
||||||
|
|
||||||
export function FutureSignalFilterBar({ filters, onChange, totalCount, filteredCount }: Props) {
|
|
||||||
const set = (partial: Partial<SignalFilterState>) => onChange({ ...filters, ...partial })
|
|
||||||
|
|
||||||
const activeCount = [
|
|
||||||
filters.signalType !== '',
|
|
||||||
filters.minConfidence > 0,
|
|
||||||
filters.sensitivityLevel !== '',
|
|
||||||
filters.reviewStatus !== '',
|
|
||||||
filters.timeHorizon !== '',
|
|
||||||
].filter(Boolean).length
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box sx={{ px: 3, py: 1.5, bgcolor: 'white', borderBottom: '1px solid #e2e8f0', display: 'flex', flexWrap: 'wrap', gap: 2, alignItems: 'center' }}>
|
|
||||||
{/* Signal type chips */}
|
|
||||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5, alignItems: 'center' }}>
|
|
||||||
<Chip
|
|
||||||
label="Alle"
|
|
||||||
size="small"
|
|
||||||
onClick={() => set({ signalType: '' })}
|
|
||||||
color={filters.signalType === '' ? 'primary' : 'default'}
|
|
||||||
sx={{ fontWeight: filters.signalType === '' ? 700 : 400 }}
|
|
||||||
/>
|
|
||||||
{SIGNAL_TYPES.map(t => (
|
|
||||||
<Chip
|
|
||||||
key={t}
|
|
||||||
label={SIGNAL_TYPE_LABELS[t] ?? t}
|
|
||||||
size="small"
|
|
||||||
onClick={() => set({ signalType: filters.signalType === t ? '' : t })}
|
|
||||||
sx={{
|
|
||||||
fontWeight: filters.signalType === t ? 700 : 400,
|
|
||||||
bgcolor: filters.signalType === t ? '#1e3a5f' : undefined,
|
|
||||||
color: filters.signalType === t ? 'white' : undefined,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Selects */}
|
|
||||||
<Box sx={{ display: 'flex', gap: 1.5, alignItems: 'center', flexWrap: 'wrap' }}>
|
|
||||||
<FormControl size="small" sx={{ minWidth: 140 }}>
|
|
||||||
<InputLabel>Konfidenz</InputLabel>
|
|
||||||
<Select
|
|
||||||
label="Konfidenz"
|
|
||||||
value={filters.minConfidence}
|
|
||||||
onChange={e => set({ minConfidence: Number(e.target.value) })}
|
|
||||||
>
|
|
||||||
<MenuItem value={0}>Alle</MenuItem>
|
|
||||||
<MenuItem value={0.75}>Hoch ≥75%</MenuItem>
|
|
||||||
<MenuItem value={0.55}>Mittel ≥55%</MenuItem>
|
|
||||||
</Select>
|
|
||||||
</FormControl>
|
|
||||||
|
|
||||||
<FormControl size="small" sx={{ minWidth: 140 }}>
|
|
||||||
<InputLabel>Prüfstatus</InputLabel>
|
|
||||||
<Select
|
|
||||||
label="Prüfstatus"
|
|
||||||
value={filters.reviewStatus}
|
|
||||||
onChange={e => set({ reviewStatus: e.target.value })}
|
|
||||||
>
|
|
||||||
<MenuItem value="">Alle</MenuItem>
|
|
||||||
<MenuItem value="UNREVIEWED">Ungeprüft</MenuItem>
|
|
||||||
<MenuItem value="IN_REVIEW">In Prüfung</MenuItem>
|
|
||||||
<MenuItem value="APPROVED">Genehmigt</MenuItem>
|
|
||||||
<MenuItem value="REJECTED">Abgelehnt</MenuItem>
|
|
||||||
<MenuItem value="FLAGGED">Markiert</MenuItem>
|
|
||||||
</Select>
|
|
||||||
</FormControl>
|
|
||||||
|
|
||||||
<FormControl size="small" sx={{ minWidth: 140 }}>
|
|
||||||
<InputLabel>Zeithorizont</InputLabel>
|
|
||||||
<Select
|
|
||||||
label="Zeithorizont"
|
|
||||||
value={filters.timeHorizon}
|
|
||||||
onChange={e => set({ timeHorizon: e.target.value })}
|
|
||||||
>
|
|
||||||
<MenuItem value="">Alle</MenuItem>
|
|
||||||
<MenuItem value="short">Kurz ≤6M</MenuItem>
|
|
||||||
<MenuItem value="medium">Mittel 7–12M</MenuItem>
|
|
||||||
<MenuItem value="long">Lang >12M</MenuItem>
|
|
||||||
</Select>
|
|
||||||
</FormControl>
|
|
||||||
|
|
||||||
<FormControl size="small" sx={{ minWidth: 140 }}>
|
|
||||||
<InputLabel>Vertraulichkeit</InputLabel>
|
|
||||||
<Select
|
|
||||||
label="Vertraulichkeit"
|
|
||||||
value={filters.sensitivityLevel}
|
|
||||||
onChange={e => set({ sensitivityLevel: e.target.value })}
|
|
||||||
>
|
|
||||||
<MenuItem value="">Alle</MenuItem>
|
|
||||||
<MenuItem value="PUBLIC">Öffentlich</MenuItem>
|
|
||||||
<MenuItem value="INTERNAL">Intern</MenuItem>
|
|
||||||
<MenuItem value="CONFIDENTIAL">Vertraulich</MenuItem>
|
|
||||||
</Select>
|
|
||||||
</FormControl>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Result count */}
|
|
||||||
<Box sx={{ ml: 'auto', display: 'flex', alignItems: 'center', gap: 1 }}>
|
|
||||||
{activeCount > 0 && (
|
|
||||||
<Chip
|
|
||||||
label={`${activeCount} Filter aktiv`}
|
|
||||||
size="small"
|
|
||||||
onDelete={() => onChange(DEFAULT_SIGNAL_FILTERS)}
|
|
||||||
sx={{ bgcolor: '#eff6ff', color: '#1e3a5f' }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<Typography variant="caption" color="text.secondary">
|
|
||||||
{filteredCount} / {totalCount}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
import { Chip } from '@mui/material'
|
|
||||||
|
|
||||||
const SENSITIVITY_META: Record<string, { label: string; color: string }> = {
|
|
||||||
PUBLIC: { label: 'Öffentlich', color: '#64748b' },
|
|
||||||
INTERNAL: { label: 'Intern', color: '#d97706' },
|
|
||||||
CONFIDENTIAL: { label: 'Vertraulich', color: '#c0392b' },
|
|
||||||
RESTRICTED: { label: 'Eingeschränkt', color: '#7c3aed' },
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SensitivityBadge({ level }: { level: string }) {
|
|
||||||
const meta = SENSITIVITY_META[level] ?? { label: level, color: '#64748b' }
|
|
||||||
return (
|
|
||||||
<Chip
|
|
||||||
label={meta.label}
|
|
||||||
size="small"
|
|
||||||
sx={{ bgcolor: meta.color, color: 'white', fontWeight: 600, fontSize: 10 }}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
-22
@@ -1,22 +0,0 @@
|
|||||||
import { Chip } from '@mui/material'
|
|
||||||
import type { ReviewStatus } from '../../domain/enums'
|
|
||||||
|
|
||||||
const STATUS_META: Record<string, { label: string; color: string }> = {
|
|
||||||
UNREVIEWED: { label: 'Ungeprüft', color: '#94a3b8' },
|
|
||||||
IN_REVIEW: { label: 'In Prüfung', color: '#d97706' },
|
|
||||||
APPROVED: { label: 'Genehmigt', color: '#1a7a4a' },
|
|
||||||
REJECTED: { label: 'Abgelehnt', color: '#c0392b' },
|
|
||||||
FLAGGED: { label: 'Markiert', color: '#ea580c' },
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SignalReviewStatusBadge({ status }: { status?: ReviewStatus | null }) {
|
|
||||||
const key = status ?? 'UNREVIEWED'
|
|
||||||
const meta = STATUS_META[key] ?? { label: key, color: '#94a3b8' }
|
|
||||||
return (
|
|
||||||
<Chip
|
|
||||||
label={meta.label}
|
|
||||||
size="small"
|
|
||||||
sx={{ bgcolor: meta.color, color: 'white', fontWeight: 600, fontSize: 10 }}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
import { Chip } from '@mui/material'
|
|
||||||
import type { SignalType } from '../../domain/enums'
|
|
||||||
import { SIGNAL_TYPE_LABELS } from '../../lib/constants'
|
|
||||||
|
|
||||||
interface SignalTypeBadgeProps {
|
|
||||||
type: SignalType
|
|
||||||
size?: 'small' | 'medium'
|
|
||||||
}
|
|
||||||
|
|
||||||
const COLOR_MAP: Record<string, { bg: string; color: string }> = {
|
|
||||||
EXPANSION: { bg: 'rgba(26,122,74,0.12)', color: '#1a7a4a' },
|
|
||||||
POSSIBLE_MOVE_OUT: { bg: 'rgba(217,119,6,0.12)', color: '#d97706' },
|
|
||||||
CONSTRUCTION_PROJECT: { bg: 'rgba(37,99,235,0.12)', color: '#1d4ed8' },
|
|
||||||
RESTRUCTURING: { bg: 'rgba(234,88,12,0.12)', color: '#c2410c' },
|
|
||||||
PROJECT_DEVELOPMENT: { bg: 'rgba(124,58,237,0.12)', color: '#6d28d9' },
|
|
||||||
SPACE_CONSOLIDATION: { bg: 'rgba(100,116,139,0.12)',color: '#475569' },
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SignalTypeBadge({ type, size = 'small' }: SignalTypeBadgeProps) {
|
|
||||||
const { bg, color } = COLOR_MAP[type] ?? { bg: '#f1f5f9', color: '#475569' }
|
|
||||||
return (
|
|
||||||
<Chip
|
|
||||||
label={SIGNAL_TYPE_LABELS[type] ?? type}
|
|
||||||
size={size}
|
|
||||||
sx={{ bgcolor: bg, color, fontWeight: 600, border: 'none' }}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
export { SignalTypeBadge } from './SignalTypeBadge'
|
|
||||||
export { FutureSignalDisclaimer } from './FutureSignalDisclaimer'
|
|
||||||
export { SensitivityBadge } from './SensitivityBadge'
|
|
||||||
export { SignalReviewStatusBadge } from './SignalReviewStatusBadge'
|
|
||||||
export { FutureSignalCard } from './FutureSignalCard'
|
|
||||||
export { FutureSignalFilterBar } from './FutureSignalFilterBar'
|
|
||||||
export { FutureSignalDetailPanel } from './FutureSignalDetailPanel'
|
|
||||||
export { FutureSignalEmptyState } from './FutureSignalEmptyState'
|
|
||||||
export type { SignalFilterState } from './FutureSignalFilterBar'
|
|
||||||
export { DEFAULT_SIGNAL_FILTERS } from './FutureSignalFilterBar'
|
|
||||||
@@ -1,553 +0,0 @@
|
|||||||
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,
|
|
||||||
Clock,
|
|
||||||
Radar,
|
|
||||||
ServerCog,
|
|
||||||
GitBranch,
|
|
||||||
} from 'lucide-react'
|
|
||||||
import type { LucideIcon } from 'lucide-react'
|
|
||||||
import { OrganizationContextBadge } from './OrganizationContextBadge'
|
|
||||||
import { UserMenu } from './UserMenu'
|
|
||||||
import { NotificationButton } from './NotificationButton'
|
|
||||||
import { RightContextPanel } from './RightContextPanel'
|
|
||||||
import { CompareTray } from './CompareTray'
|
|
||||||
import { GlobalAIAssistantDrawer, GlobalAIAssistantButton } from '../assistant'
|
|
||||||
import { useAssistantStore } from '../../stores/assistantStore'
|
|
||||||
import { ToastProvider } from '../ui'
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// 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: 'Verwaltung',
|
|
||||||
abbreviation: 'VW',
|
|
||||||
icon: Building2,
|
|
||||||
firstPath: '/supply/dashboard',
|
|
||||||
chipColor: '#1e3a5f',
|
|
||||||
navItems: [
|
|
||||||
{ path: '/supply/dashboard', label: 'Übersicht', icon: LayoutDashboard },
|
|
||||||
{ path: '/supply/properties', label: 'Meine Objekte', icon: Building2 },
|
|
||||||
{ path: '/supply/match-center', label: 'Eingehende Bedarfe', icon: Target },
|
|
||||||
{ path: '/supply/future-availability', label: 'Marktchancen', icon: TrendingUp },
|
|
||||||
{ path: '/supply/data-quality', label: 'Datenpflege', icon: CheckSquare },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
[WorkspaceType.DEMAND]: {
|
|
||||||
label: 'Suche',
|
|
||||||
abbreviation: 'SU',
|
|
||||||
icon: Search,
|
|
||||||
firstPath: '/demand/ai-search',
|
|
||||||
chipColor: '#1a7a4a',
|
|
||||||
navItems: [
|
|
||||||
{ path: '/demand/ai-search', label: 'Flächensuche', 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: 'Administration',
|
|
||||||
abbreviation: 'ADM',
|
|
||||||
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 },
|
|
||||||
{ path: '/ops/market-intelligence', label: 'Market Intelligence', icon: Radar },
|
|
||||||
{ path: '/ops/source-monitoring', label: 'Source Monitoring', icon: ServerCog },
|
|
||||||
{ path: '/ops/signal-pipeline', label: 'Signal Pipeline', icon: GitBranch },
|
|
||||||
{ path: '/ops/activity-timeline', label: 'Aktivitäts-Timeline', icon: Clock },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
|
||||||
allowedWorkspaces: WorkspaceType[]
|
|
||||||
onWorkspaceClick: (workspace: WorkspaceType) => void
|
|
||||||
onToggle: () => void
|
|
||||||
userName: string
|
|
||||||
orgName: string
|
|
||||||
}
|
|
||||||
|
|
||||||
function Sidebar({
|
|
||||||
collapsed,
|
|
||||||
activeWorkspace,
|
|
||||||
allowedWorkspaces,
|
|
||||||
onWorkspaceClick,
|
|
||||||
onToggle,
|
|
||||||
userName,
|
|
||||||
orgName,
|
|
||||||
}: SidebarProps) {
|
|
||||||
const config = WORKSPACE_CONFIG[activeWorkspace]
|
|
||||||
const width = collapsed ? 60 : 240
|
|
||||||
const visibleWorkspaces = WORKSPACE_ORDER.filter((ws) => allowedWorkspaces.includes(ws))
|
|
||||||
|
|
||||||
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,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{visibleWorkspaces.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)
|
|
||||||
const openAssistant = useAssistantStore(s => s.open)
|
|
||||||
|
|
||||||
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 }}>
|
|
||||||
<OrganizationContextBadge />
|
|
||||||
<Button
|
|
||||||
variant="outlined"
|
|
||||||
size="small"
|
|
||||||
startIcon={<Sparkles size={14} />}
|
|
||||||
onClick={openAssistant}
|
|
||||||
sx={{ textTransform: 'none', fontSize: '0.8125rem' }}
|
|
||||||
>
|
|
||||||
AI Assistent
|
|
||||||
</Button>
|
|
||||||
<NotificationButton />
|
|
||||||
<UserMenu />
|
|
||||||
</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 ?? ''
|
|
||||||
const allowedWorkspaces = currentUser?.allowedWorkspaces ?? WORKSPACE_ORDER
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box sx={{ display: 'flex', height: '100vh', overflow: 'hidden' }}>
|
|
||||||
<Sidebar
|
|
||||||
collapsed={sidebarCollapsed}
|
|
||||||
activeWorkspace={activeWorkspace}
|
|
||||||
allowedWorkspaces={allowedWorkspaces}
|
|
||||||
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>
|
|
||||||
|
|
||||||
<RightContextPanel />
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<CompareTray />
|
|
||||||
<GlobalAIAssistantDrawer />
|
|
||||||
<GlobalAIAssistantButton />
|
|
||||||
<ToastProvider />
|
|
||||||
</Box>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,108 +0,0 @@
|
|||||||
import { useEffect } from 'react'
|
|
||||||
import { useNavigate, useLocation } from 'react-router'
|
|
||||||
import { Box, Button, IconButton, Typography } from '@mui/material'
|
|
||||||
import { X } from 'lucide-react'
|
|
||||||
import { useCompareStore } from '../../stores/compareStore'
|
|
||||||
import { useLayoutStore } from '../../stores/layoutStore'
|
|
||||||
|
|
||||||
const TYPE_DOT: Record<string, string> = {
|
|
||||||
VERIFIED_PORTFOLIO: '#1e3a5f',
|
|
||||||
EXTERNAL_MARKET: '#d97706',
|
|
||||||
FUTURE_AVAILABILITY: '#7c3aed',
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CompareTray() {
|
|
||||||
const { compareItems, removeFromCompare, clearCompare } = useCompareStore()
|
|
||||||
const { setCompareTrayVisible } = useLayoutStore()
|
|
||||||
const navigate = useNavigate()
|
|
||||||
const location = useLocation()
|
|
||||||
const isDemand = location.pathname.startsWith('/demand')
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setCompareTrayVisible(compareItems.length > 0 && isDemand)
|
|
||||||
}, [compareItems.length, setCompareTrayVisible, isDemand])
|
|
||||||
|
|
||||||
if (!isDemand) return null
|
|
||||||
|
|
||||||
const getTitle = (item: (typeof compareItems)[number]) => {
|
|
||||||
if (item.resultType === 'FUTURE_AVAILABILITY') {
|
|
||||||
return (item as any).signal?.companyName ?? (item as any).signal?.locationHint ?? 'Signal'
|
|
||||||
}
|
|
||||||
return (item as any).property?.title ?? `Score ${item.matchScore}`
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
position: 'fixed',
|
|
||||||
bottom: 0,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
zIndex: 1300,
|
|
||||||
height: 56,
|
|
||||||
bgcolor: '#0f1923',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
px: 3,
|
|
||||||
gap: 2,
|
|
||||||
transform: compareItems.length > 0 ? 'translateY(0)' : 'translateY(100%)',
|
|
||||||
transition: 'transform 0.25s ease',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Typography variant="caption" sx={{ color: '#fff', flexShrink: 0 }}>
|
|
||||||
Vergleich ({compareItems.length}/4)
|
|
||||||
</Typography>
|
|
||||||
|
|
||||||
<Box sx={{ flex: 1, display: 'flex', gap: 1, overflow: 'hidden' }}>
|
|
||||||
{compareItems.map((item) => (
|
|
||||||
<Box
|
|
||||||
key={item.matchId}
|
|
||||||
sx={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 0.75,
|
|
||||||
bgcolor: 'rgba(255,255,255,0.1)',
|
|
||||||
borderRadius: 1,
|
|
||||||
px: 1,
|
|
||||||
py: 0.25,
|
|
||||||
flexShrink: 0,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Box sx={{ width: 6, height: 6, borderRadius: '50%', bgcolor: TYPE_DOT[item.resultType] ?? '#64748b', flexShrink: 0 }} />
|
|
||||||
<Typography variant="caption" sx={{ color: '#fff', maxWidth: 110, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
|
||||||
{getTitle(item)}
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.5)', flexShrink: 0 }}>
|
|
||||||
{item.matchScore}
|
|
||||||
</Typography>
|
|
||||||
<IconButton
|
|
||||||
size="small"
|
|
||||||
onClick={() => removeFromCompare(item.matchId)}
|
|
||||||
sx={{ p: 0.25, color: 'rgba(255,255,255,0.5)', '&:hover': { color: '#fff' } }}
|
|
||||||
>
|
|
||||||
<X size={12} />
|
|
||||||
</IconButton>
|
|
||||||
</Box>
|
|
||||||
))}
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="text"
|
|
||||||
size="small"
|
|
||||||
onClick={clearCompare}
|
|
||||||
sx={{ color: 'rgba(255,255,255,0.5)', textTransform: 'none', flexShrink: 0 }}
|
|
||||||
>
|
|
||||||
Alle entfernen
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="contained"
|
|
||||||
size="small"
|
|
||||||
onClick={() => navigate('/demand/compare')}
|
|
||||||
sx={{ bgcolor: '#1e3a5f', textTransform: 'none', flexShrink: 0, '&:hover': { bgcolor: '#162d4a' } }}
|
|
||||||
>
|
|
||||||
Vergleich starten
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
import { useState } from 'react'
|
|
||||||
import { Badge, IconButton, Popover, Typography } from '@mui/material'
|
|
||||||
import { Bell } from 'lucide-react'
|
|
||||||
|
|
||||||
export function NotificationButton() {
|
|
||||||
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null)
|
|
||||||
|
|
||||||
function handleOpen(e: React.MouseEvent<HTMLElement>) {
|
|
||||||
setAnchorEl(e.currentTarget)
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleClose() {
|
|
||||||
setAnchorEl(null)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<IconButton size="small" sx={{ color: '#64748b' }} onClick={handleOpen}>
|
|
||||||
<Badge badgeContent={0} color="error">
|
|
||||||
<Bell size={20} />
|
|
||||||
</Badge>
|
|
||||||
</IconButton>
|
|
||||||
|
|
||||||
<Popover
|
|
||||||
open={!!anchorEl}
|
|
||||||
anchorEl={anchorEl}
|
|
||||||
onClose={handleClose}
|
|
||||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
|
|
||||||
transformOrigin={{ vertical: 'top', horizontal: 'right' }}
|
|
||||||
slotProps={{ paper: { sx: { width: 280, p: 2 } } }}
|
|
||||||
>
|
|
||||||
<Typography variant="subtitle2" sx={{ mb: 1 }}>Benachrichtigungen</Typography>
|
|
||||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
|
||||||
Keine neuen Benachrichtigungen
|
|
||||||
</Typography>
|
|
||||||
</Popover>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
import { Chip } from '@mui/material'
|
|
||||||
import { Building2 } from 'lucide-react'
|
|
||||||
import { useSessionStore } from '../../stores/sessionStore'
|
|
||||||
|
|
||||||
export function OrganizationContextBadge() {
|
|
||||||
const { currentUser } = useSessionStore()
|
|
||||||
|
|
||||||
if (!currentUser?.organizationName) return null
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Chip
|
|
||||||
size="small"
|
|
||||||
variant="outlined"
|
|
||||||
icon={<Building2 size={12} color="#64748b" />}
|
|
||||||
label={currentUser.organizationName}
|
|
||||||
sx={{ fontSize: '0.7rem', height: 22, color: '#64748b', borderColor: '#e2e8f0' }}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
import { Box, Breadcrumbs, Typography } from '@mui/material'
|
|
||||||
import type { SxProps, Theme } from '@mui/material'
|
|
||||||
import type { ReactNode } from 'react'
|
|
||||||
import { NavLink } from 'react-router'
|
|
||||||
|
|
||||||
interface BreadcrumbItem {
|
|
||||||
label: string
|
|
||||||
href?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PageHeaderProps {
|
|
||||||
title: string
|
|
||||||
subtitle?: string
|
|
||||||
breadcrumbs?: BreadcrumbItem[]
|
|
||||||
primaryAction?: ReactNode
|
|
||||||
secondaryActions?: ReactNode
|
|
||||||
badge?: ReactNode
|
|
||||||
sx?: SxProps<Theme>
|
|
||||||
}
|
|
||||||
|
|
||||||
export function PageHeader({
|
|
||||||
title,
|
|
||||||
subtitle,
|
|
||||||
breadcrumbs,
|
|
||||||
primaryAction,
|
|
||||||
secondaryActions,
|
|
||||||
badge,
|
|
||||||
sx,
|
|
||||||
}: PageHeaderProps) {
|
|
||||||
return (
|
|
||||||
<Box sx={{ px: 3, py: 2.5, borderBottom: '1px solid #e2e8f0', ...sx }}>
|
|
||||||
{breadcrumbs && breadcrumbs.length > 0 && (
|
|
||||||
<Breadcrumbs separator="/" sx={{ mb: 1 }}>
|
|
||||||
{breadcrumbs.map((crumb) =>
|
|
||||||
crumb.href ? (
|
|
||||||
<NavLink
|
|
||||||
key={crumb.label}
|
|
||||||
to={crumb.href}
|
|
||||||
style={{ textDecoration: 'none', color: '#64748b', fontSize: '0.75rem' }}
|
|
||||||
>
|
|
||||||
{crumb.label}
|
|
||||||
</NavLink>
|
|
||||||
) : (
|
|
||||||
<Typography key={crumb.label} sx={{ fontSize: '0.75rem', color: 'text.secondary' }}>
|
|
||||||
{crumb.label}
|
|
||||||
</Typography>
|
|
||||||
),
|
|
||||||
)}
|
|
||||||
</Breadcrumbs>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
|
||||||
<Box>
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
|
||||||
<Typography variant="h6" sx={{ fontWeight: 600, lineHeight: 1.3 }}>
|
|
||||||
{title}
|
|
||||||
</Typography>
|
|
||||||
{badge}
|
|
||||||
</Box>
|
|
||||||
{subtitle && (
|
|
||||||
<Typography variant="body2" sx={{ color: 'text.secondary', mt: 0.25 }}>
|
|
||||||
{subtitle}
|
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{(primaryAction ?? secondaryActions) && (
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexShrink: 0, ml: 2 }}>
|
|
||||||
{secondaryActions}
|
|
||||||
{primaryAction}
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
import { Box, Divider, IconButton, Typography } from '@mui/material'
|
|
||||||
import { X } from 'lucide-react'
|
|
||||||
import { useLayoutStore } from '../../stores/layoutStore'
|
|
||||||
import type { RightPanelContentType } from '../../stores/layoutStore'
|
|
||||||
|
|
||||||
const PANEL_TITLES: Record<RightPanelContentType, string> = {
|
|
||||||
ai_context: 'KI Kontext',
|
|
||||||
detail_preview: 'Detail Vorschau',
|
|
||||||
compare_preview: 'Vergleich Vorschau',
|
|
||||||
activity_feed: 'Aktivitäts-Feed',
|
|
||||||
}
|
|
||||||
|
|
||||||
const PANEL_PLACEHOLDERS: Record<RightPanelContentType, string> = {
|
|
||||||
ai_context: 'KI-Kontext wird geladen...',
|
|
||||||
detail_preview: 'Kein Objekt ausgewählt.',
|
|
||||||
compare_preview: 'Vergleichsvorschau nicht verfügbar.',
|
|
||||||
activity_feed: 'Keine Aktivitäten vorhanden.',
|
|
||||||
}
|
|
||||||
|
|
||||||
export function RightContextPanel() {
|
|
||||||
const { isRightPanelOpen, rightPanelContentType, closeRightPanel } = useLayoutStore()
|
|
||||||
|
|
||||||
const title = rightPanelContentType ? PANEL_TITLES[rightPanelContentType] : ''
|
|
||||||
const placeholder = rightPanelContentType ? PANEL_PLACEHOLDERS[rightPanelContentType] : ''
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
position: 'fixed',
|
|
||||||
right: 0,
|
|
||||||
top: 56,
|
|
||||||
height: 'calc(100vh - 56px)',
|
|
||||||
width: 320,
|
|
||||||
transform: isRightPanelOpen ? 'translateX(0)' : 'translateX(320px)',
|
|
||||||
transition: 'transform 0.25s ease',
|
|
||||||
bgcolor: '#fff',
|
|
||||||
borderLeft: '1px solid #e2e8f0',
|
|
||||||
boxShadow: '-4px 0 16px rgba(0,0,0,0.08)',
|
|
||||||
zIndex: 1200,
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
overflow: 'hidden',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
height: 48,
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
px: 2,
|
|
||||||
flexShrink: 0,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>{title}</Typography>
|
|
||||||
<IconButton size="small" onClick={closeRightPanel} sx={{ color: '#64748b' }}>
|
|
||||||
<X size={16} />
|
|
||||||
</IconButton>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Divider />
|
|
||||||
|
|
||||||
<Box sx={{ flex: 1, overflowY: 'auto', p: 2 }}>
|
|
||||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
|
||||||
{placeholder}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,105 +0,0 @@
|
|||||||
import { useState } from 'react'
|
|
||||||
import { useNavigate } from 'react-router'
|
|
||||||
import { Avatar, Box, Divider, IconButton, ListItemIcon, Menu, MenuItem, Typography } from '@mui/material'
|
|
||||||
import { LogOut, Settings, User } from 'lucide-react'
|
|
||||||
import { useSessionStore } from '../../stores/sessionStore'
|
|
||||||
import { useToastStore } from '../../stores/toastStore'
|
|
||||||
import { DemoRoleSwitcher } from '../auth/DemoRoleSwitcher'
|
|
||||||
|
|
||||||
const ROLE_LABELS: Record<string, string> = {
|
|
||||||
SUPER_ADMIN: 'Super Admin',
|
|
||||||
ORGANIZATION_ADMIN: 'Org Admin',
|
|
||||||
PROPERTY_MANAGER: 'Property Manager',
|
|
||||||
REVIEWER: 'Reviewer',
|
|
||||||
OWNER_VIEWER: 'Owner Viewer',
|
|
||||||
DEMAND_USER: 'Demand User',
|
|
||||||
}
|
|
||||||
|
|
||||||
function getUserInitials(name: string): string {
|
|
||||||
return name
|
|
||||||
.split(' ')
|
|
||||||
.map((n) => n[0])
|
|
||||||
.join('')
|
|
||||||
.toUpperCase()
|
|
||||||
.slice(0, 2)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function UserMenu() {
|
|
||||||
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null)
|
|
||||||
const { currentUser, logout } = useSessionStore()
|
|
||||||
const navigate = useNavigate()
|
|
||||||
const showToast = useToastStore((s) => s.showToast)
|
|
||||||
|
|
||||||
function handleOpen(e: React.MouseEvent<HTMLElement>) {
|
|
||||||
setAnchorEl(e.currentTarget)
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleClose() {
|
|
||||||
setAnchorEl(null)
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleLogout() {
|
|
||||||
handleClose()
|
|
||||||
logout()
|
|
||||||
navigate('/auth/login')
|
|
||||||
}
|
|
||||||
|
|
||||||
const initials = getUserInitials(currentUser?.name ?? 'U')
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<IconButton onClick={handleOpen} size="small" sx={{ p: 0 }}>
|
|
||||||
<Avatar sx={{ width: 32, height: 32, fontSize: '0.75rem', bgcolor: '#1e3a5f' }}>
|
|
||||||
{initials}
|
|
||||||
</Avatar>
|
|
||||||
</IconButton>
|
|
||||||
|
|
||||||
<Menu
|
|
||||||
anchorEl={anchorEl}
|
|
||||||
open={!!anchorEl}
|
|
||||||
onClose={handleClose}
|
|
||||||
slotProps={{ paper: { sx: { width: 300, mt: 1 } } }}
|
|
||||||
transformOrigin={{ horizontal: 'right', vertical: 'top' }}
|
|
||||||
anchorOrigin={{ horizontal: 'right', vertical: 'bottom' }}
|
|
||||||
>
|
|
||||||
{/* User info header */}
|
|
||||||
{currentUser && (
|
|
||||||
<Box sx={{ px: 2, py: 1.5 }}>
|
|
||||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>{currentUser.name}</Typography>
|
|
||||||
<Typography variant="caption" sx={{ color: 'text.secondary', display: 'block' }}>
|
|
||||||
{ROLE_LABELS[currentUser.role] ?? currentUser.role}
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="caption" sx={{ color: 'text.secondary', display: 'block' }}>
|
|
||||||
{currentUser.organizationName}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Divider />
|
|
||||||
|
|
||||||
<MenuItem onClick={() => { handleClose(); showToast('Profilseite ist in Kürze verfügbar.', 'info') }}>
|
|
||||||
<ListItemIcon><User size={16} /></ListItemIcon>
|
|
||||||
Profil
|
|
||||||
</MenuItem>
|
|
||||||
<MenuItem onClick={() => { handleClose(); showToast('Einstellungen sind in Kürze verfügbar.', 'info') }}>
|
|
||||||
<ListItemIcon><Settings size={16} /></ListItemIcon>
|
|
||||||
Einstellungen
|
|
||||||
</MenuItem>
|
|
||||||
|
|
||||||
<Divider />
|
|
||||||
|
|
||||||
{/* Demo role switcher */}
|
|
||||||
<Box sx={{ px: 1, py: 1 }}>
|
|
||||||
<DemoRoleSwitcher />
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Divider />
|
|
||||||
|
|
||||||
<MenuItem onClick={handleLogout}>
|
|
||||||
<ListItemIcon><LogOut size={16} /></ListItemIcon>
|
|
||||||
Abmelden
|
|
||||||
</MenuItem>
|
|
||||||
</Menu>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
export { AppShell } from './AppShell'
|
|
||||||
export { PageHeader } from './PageHeader'
|
|
||||||
export { RightContextPanel } from './RightContextPanel'
|
|
||||||
export { CompareTray } from './CompareTray'
|
|
||||||
export { UserMenu } from './UserMenu'
|
|
||||||
export { NotificationButton } from './NotificationButton'
|
|
||||||
export { OrganizationContextBadge } from './OrganizationContextBadge'
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user