1 Commits

Author SHA1 Message Date
Benjamin Sutter 0f8a8ecd2f feat: F028 strategic UX refocus — decision co-pilot redesign
Match cards lead with narrative summary as hero text; score and badges
step back to secondary. WeightingEditor replaces sliders with 3-level
chip selector (Optional/Wichtig/Kritisch). NeedInput uses progressive
disclosure for budget/timing/must-haves. NeedCardPreview shows priority
label groups instead of percentage bars. Results header reframed as
recommendations.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 14:16:21 +02:00
812 changed files with 5024 additions and 76585 deletions
-19
View File
@@ -1,19 +0,0 @@
{
"permissions": {
"allow": [
"Bash(npm run *)",
"Bash(git add *)",
"Bash(git push *)",
"Bash(npx tsc *)",
"Bash(grep -E \"\\\\.\\(ts|tsx\\)$\")",
"Bash(find c:\\\\\\\\Users\\\\\\\\beni_\\\\\\\\OneDrive\\\\\\\\Desktop\\\\\\\\property-match -name \"Dashboard.tsx\" -type f)",
"Bash(awk '{print $NF}')",
"Bash(start http://localhost:5173)",
"Bash(npm install *)",
"Bash(git pull *)",
"Bash(node scripts/check-tokens.js)",
"Bash(echo \"EXIT:$?\")",
"Bash(echo \"EXIT_CODE:$?\")"
]
}
}
-11
View File
@@ -1,11 +0,0 @@
{
"permissions": {
"allow": [
"Bash(git commit -m ' *)",
"Bash(git commit *)",
"Bash(node -e ' *)",
"Bash(git stash *)",
"Read(//c/Users/beni_/.claude/projects/c--Users-beni--OneDrive-Desktop-property-match/8e388ddd-e02e-47fe-8bb9-cdb8164c9fc3/tool-results/**)"
]
}
}
@@ -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>
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>
)
}
@@ -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>
)
}
@@ -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>
)
}
@@ -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>
)
}
@@ -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>
)
}
@@ -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 24 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}` : ''
}
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. 500800)"
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 8001000 m² Zürich-West, ab Sept. 2025, max. CHF 45/m², ÖV-Anbindung',
'Retail-Fläche 200400 m² Bern Innenstadt, Erdgeschoss, max. CHF 150/m², sofort',
'Lagerhalle 20003000 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 8001.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')}`} 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>
)
}
@@ -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>
)
}
@@ -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>
)
}
@@ -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>
)
}
@@ -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 712M</MenuItem>
<MenuItem value="long">Lang &gt;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 }}
/>
)
}
@@ -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>
)
}

Some files were not shown because too many files have changed in this diff Show More