Initial commit

This commit is contained in:
Benjamin Sutter
2026-05-15 00:48:18 +02:00
commit 9e827c50f9
72 changed files with 10477 additions and 0 deletions
+28
View File
@@ -0,0 +1,28 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Windows system files
desktop.ini
Thumbs.db
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+80
View File
@@ -0,0 +1,80 @@
# property-match — Development Guidelines
## Stack
- **Vite 8** + **React 19** + **TypeScript 6**
- **MUI v9** (`@mui/material`) — primary component library
- **Tailwind CSS v4** — utility classes via `@tailwindcss/vite` (no `tailwind.config.js`)
- **React Router v7** — import from `react-router`, not `react-router-dom`
## Components
Always reach for an existing MUI component before writing a custom one. Check the [MUI component list](https://mui.com/material-ui/all-components/) first. Only build a custom component when MUI has no equivalent or the required behavior diverges significantly from what MUI provides.
## Styling
Use Tailwind utility classes for all layout and styling. Do not write plain CSS rules or add styles to `.css` files. The only CSS file is `src/index.css`, which holds the Tailwind layer imports — do not add project styles there.
## Providers
All data access and data actions live in `src/provider/`.
### Naming
| Rule | Example |
|------|---------|
| Every provider file/class is suffixed `Provider` | `PropertyProvider`, `UserProvider` |
| Every provider backed by mock data is also prefixed `Mockup` | `MockupPropertyProvider`, `MockupUserProvider` |
### Interface pattern
Define a TypeScript interface for each provider so the mockup and the real implementation are interchangeable:
Every Interface should be prefixed with a capitalized I.
```ts
// src/provider/IPropertyProvider.ts
export interface PropertyProvider {
getAll(): Promise<Property[]>
getById(id: string): Promise<Property | null>
create(data: CreatePropertyInput): Promise<Property>
update(id: string, data: UpdatePropertyInput): Promise<Property>
remove(id: string): Promise<void>
}
```
### Async methods
Every method in a provider must be `async` and return a `Promise`, even in the mockup. This ensures the real provider can be swapped in without changing any call sites.
```ts
// src/provider/MockupPropertyProvider.ts
import type { PropertyProvider } from './PropertyProvider'
const properties: Property[] = [ /* seed data */ ]
export const MockupPropertyProvider: PropertyProvider = {
async getAll() {
return [...properties]
},
async getById(id) {
return properties.find(p => p.id === id) ?? null
},
async create(data) {
const next: Property = { id: crypto.randomUUID(), ...data }
properties.push(next)
return next
},
async update(id, data) {
const idx = properties.findIndex(p => p.id === id)
properties[idx] = { ...properties[idx], ...data }
return properties[idx]
},
async remove(id) {
const idx = properties.findIndex(p => p.id === id)
properties.splice(idx, 1)
},
}
```
Swap to a real implementation by replacing `MockupPropertyProvider` with a provider that calls an API — no other code changes required.
+73
View File
@@ -0,0 +1,73 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
+22
View File
@@ -0,0 +1,22 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
globals: globals.browser,
},
},
])
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Property Match</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+3942
View File
File diff suppressed because it is too large Load Diff
+41
View File
@@ -0,0 +1,41 @@
{
"name": "property-match",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
"@mui/icons-material": "^9.0.1",
"@mui/material": "^9.0.1",
"@tanstack/react-query": "^5.75.2",
"lucide-react": "^0.511.0",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"react-router": "^7.15.0",
"zod": "^3.25.17",
"zustand": "^5.0.5"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@tailwindcss/vite": "^4.3.0",
"@types/node": "^24.12.3",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"eslint": "^10.3.0",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.6.0",
"tailwindcss": "^4.3.0",
"typescript": "~6.0.2",
"typescript-eslint": "^8.59.2",
"vite": "^8.0.12"
}
}
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

+24
View File
@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

+184
View File
@@ -0,0 +1,184 @@
.counter {
font-size: 16px;
padding: 5px 10px;
border-radius: 5px;
color: var(--accent);
background: var(--accent-bg);
border: 2px solid transparent;
transition: border-color 0.3s;
margin-bottom: 24px;
&:hover {
border-color: var(--accent-border);
}
&:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
}
.hero {
position: relative;
.base,
.framework,
.vite {
inset-inline: 0;
margin: 0 auto;
}
.base {
width: 170px;
position: relative;
z-index: 0;
}
.framework,
.vite {
position: absolute;
}
.framework {
z-index: 1;
top: 34px;
height: 28px;
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
scale(1.4);
}
.vite {
z-index: 0;
top: 107px;
height: 26px;
width: auto;
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
scale(0.8);
}
}
#center {
display: flex;
flex-direction: column;
gap: 25px;
place-content: center;
place-items: center;
flex-grow: 1;
@media (max-width: 1024px) {
padding: 32px 20px 24px;
gap: 18px;
}
}
#next-steps {
display: flex;
border-top: 1px solid var(--border);
text-align: left;
& > div {
flex: 1 1 0;
padding: 32px;
@media (max-width: 1024px) {
padding: 24px 20px;
}
}
.icon {
margin-bottom: 16px;
width: 22px;
height: 22px;
}
@media (max-width: 1024px) {
flex-direction: column;
text-align: center;
}
}
#docs {
border-right: 1px solid var(--border);
@media (max-width: 1024px) {
border-right: none;
border-bottom: 1px solid var(--border);
}
}
#next-steps ul {
list-style: none;
padding: 0;
display: flex;
gap: 8px;
margin: 32px 0 0;
.logo {
height: 18px;
}
a {
color: var(--text-h);
font-size: 16px;
border-radius: 6px;
background: var(--social-bg);
display: flex;
padding: 6px 12px;
align-items: center;
gap: 8px;
text-decoration: none;
transition: box-shadow 0.3s;
&:hover {
box-shadow: var(--shadow);
}
.button-icon {
height: 18px;
width: 18px;
}
}
@media (max-width: 1024px) {
margin-top: 20px;
flex-wrap: wrap;
justify-content: center;
li {
flex: 1 1 calc(50% - 8px);
}
a {
width: 100%;
justify-content: center;
box-sizing: border-box;
}
}
}
#spacer {
height: 88px;
border-top: 1px solid var(--border);
@media (max-width: 1024px) {
height: 48px;
}
}
.ticks {
position: relative;
width: 100%;
&::before,
&::after {
content: '';
position: absolute;
top: -4.5px;
border: 5px solid transparent;
}
&::before {
left: 0;
border-left-color: var(--border);
}
&::after {
right: 0;
border-right-color: var(--border);
}
}
+55
View File
@@ -0,0 +1,55 @@
import { lazy, Suspense } from 'react'
import { Routes, Route, Navigate } from 'react-router'
import { LoadingPage, AppErrorBoundary } from './components/ui'
import { AppShell } from './components/layout'
const SupplyDashboard = lazy(() => import('./pages/supply/SupplyDashboard'))
const Properties = lazy(() => import('./pages/supply/Properties'))
const MatchCenter = lazy(() => import('./pages/supply/MatchCenter'))
const FutureAvailability = lazy(() => import('./pages/supply/FutureAvailability'))
const DataQuality = lazy(() => import('./pages/supply/DataQuality'))
const AISearch = lazy(() => import('./pages/demand/AISearch'))
const Results = lazy(() => import('./pages/demand/Results'))
const Compare = lazy(() => import('./pages/demand/Compare'))
const Shortlists = lazy(() => import('./pages/demand/Shortlists'))
const ReviewQueue = lazy(() => import('./pages/ops/ReviewQueue'))
const AIMonitoring = lazy(() => import('./pages/ops/AIMonitoring'))
const Governance = lazy(() => import('./pages/ops/Governance'))
function App() {
return (
<AppErrorBoundary>
<Suspense fallback={<LoadingPage />}>
<Routes>
<Route element={<AppShell />}>
<Route path="/" element={<Navigate to="/supply/dashboard" replace />} />
{/* Supply Workspace */}
<Route path="/supply/dashboard" element={<SupplyDashboard />} />
<Route path="/supply/properties" element={<Properties />} />
<Route path="/supply/match-center" element={<MatchCenter />} />
<Route path="/supply/future-availability" element={<FutureAvailability />} />
<Route path="/supply/data-quality" element={<DataQuality />} />
{/* Demand Workspace */}
<Route path="/demand/ai-search" element={<AISearch />} />
<Route path="/demand/results" element={<Results />} />
<Route path="/demand/compare" element={<Compare />} />
<Route path="/demand/shortlists" element={<Shortlists />} />
{/* Operations Workspace */}
<Route path="/ops/review-queue" element={<ReviewQueue />} />
<Route path="/ops/ai-monitoring" element={<AIMonitoring />} />
<Route path="/ops/governance" element={<Governance />} />
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</Suspense>
</AppErrorBoundary>
)
}
export default App
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

+527
View File
@@ -0,0 +1,527 @@
import { useEffect } from 'react'
import { Outlet, NavLink, useNavigate, useLocation } from 'react-router'
import { useLayoutStore } from '../../stores/layoutStore'
import { useSessionStore } from '../../stores/sessionStore'
import { WorkspaceType } from '../../domain/enums'
import {
Box,
Typography,
Avatar,
IconButton,
Tooltip,
Chip,
Button,
} from '@mui/material'
import {
LayoutDashboard,
Building2,
Target,
TrendingUp,
CheckSquare,
Search,
List,
Columns2,
Bookmark,
ClipboardList,
Activity,
Shield,
ChevronLeft,
ChevronRight,
Sparkles,
Bell,
} from 'lucide-react'
import type { LucideIcon } from 'lucide-react'
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface NavItem {
path: string
label: string
icon: LucideIcon
}
interface WorkspaceConfig {
label: string
abbreviation: string
icon: LucideIcon
firstPath: string
navItems: NavItem[]
chipColor: string
}
// ---------------------------------------------------------------------------
// Workspace configuration
// ---------------------------------------------------------------------------
const WORKSPACE_CONFIG: Record<WorkspaceType, WorkspaceConfig> = {
[WorkspaceType.SUPPLY]: {
label: 'Supply',
abbreviation: 'SUP',
icon: Building2,
firstPath: '/supply/dashboard',
chipColor: '#1e3a5f',
navItems: [
{ path: '/supply/dashboard', label: 'Dashboard', icon: LayoutDashboard },
{ path: '/supply/properties', label: 'Objekte', icon: Building2 },
{ path: '/supply/match-center', label: 'Match Center', icon: Target },
{ path: '/supply/future-availability', label: 'Marktchancen', icon: TrendingUp },
{ path: '/supply/data-quality', label: 'Datenqualität', icon: CheckSquare },
],
},
[WorkspaceType.DEMAND]: {
label: 'Demand',
abbreviation: 'DEM',
icon: Search,
firstPath: '/demand/ai-search',
chipColor: '#1a7a4a',
navItems: [
{ path: '/demand/ai-search', label: 'AI Suche', icon: Search },
{ path: '/demand/results', label: 'Ergebnisse', icon: List },
{ path: '/demand/compare', label: 'Vergleich', icon: Columns2 },
{ path: '/demand/shortlists', label: 'Shortlists', icon: Bookmark },
],
},
[WorkspaceType.OPERATIONS]: {
label: 'Operations',
abbreviation: 'OPS',
icon: Shield,
firstPath: '/ops/review-queue',
chipColor: '#7c3aed',
navItems: [
{ path: '/ops/review-queue', label: 'Review Queue', icon: ClipboardList },
{ path: '/ops/ai-monitoring', label: 'AI Monitoring', icon: Activity },
{ path: '/ops/governance', label: 'Governance', icon: Shield },
],
},
}
// Ordered list for rendering workspace tabs
const WORKSPACE_ORDER: WorkspaceType[] = [
WorkspaceType.SUPPLY,
WorkspaceType.DEMAND,
WorkspaceType.OPERATIONS,
]
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function getWorkspaceFromPath(pathname: string): WorkspaceType | null {
if (pathname.startsWith('/supply')) return WorkspaceType.SUPPLY
if (pathname.startsWith('/demand')) return WorkspaceType.DEMAND
if (pathname.startsWith('/ops')) return WorkspaceType.OPERATIONS
return null
}
function getPageNameFromPath(pathname: string): string {
for (const ws of Object.values(WORKSPACE_CONFIG)) {
for (const item of ws.navItems) {
if (item.path === pathname) return item.label
}
}
// Fallback: last segment, capitalised
const segment = pathname.split('/').filter(Boolean).pop() ?? ''
return segment.charAt(0).toUpperCase() + segment.slice(1).replace(/-/g, ' ')
}
function getUserInitials(name: string): string {
return name
.split(' ')
.map((n) => n[0])
.join('')
.toUpperCase()
.slice(0, 2)
}
// ---------------------------------------------------------------------------
// Sub-components
// ---------------------------------------------------------------------------
const SIDEBAR_BG = '#0f1923'
const DIVIDER_COLOR = 'rgba(255,255,255,0.08)'
const TEXT_MUTED = '#94a3b8'
const TEXT_WHITE = '#ffffff'
const ACTIVE_BG = 'rgba(255,255,255,0.1)'
const NAV_ACTIVE_BG = 'rgba(255,255,255,0.12)'
const NAV_HOVER_BG = 'rgba(255,255,255,0.06)'
interface SidebarProps {
collapsed: boolean
activeWorkspace: WorkspaceType
onWorkspaceClick: (workspace: WorkspaceType) => void
onToggle: () => void
userName: string
orgName: string
}
function Sidebar({
collapsed,
activeWorkspace,
onWorkspaceClick,
onToggle,
userName,
orgName,
}: SidebarProps) {
const config = WORKSPACE_CONFIG[activeWorkspace]
const width = collapsed ? 60 : 240
return (
<Box
component="nav"
sx={{
width,
minWidth: width,
flexShrink: 0,
height: '100vh',
backgroundColor: SIDEBAR_BG,
display: 'flex',
flexDirection: 'column',
transition: 'width 0.2s ease',
overflow: 'hidden',
}}
>
{/* Logo area */}
<Box
sx={{
height: 56,
display: 'flex',
alignItems: 'center',
justifyContent: collapsed ? 'center' : 'flex-start',
px: collapsed ? 0 : 2.5,
borderBottom: `1px solid ${DIVIDER_COLOR}`,
flexShrink: 0,
}}
>
{collapsed ? (
<Typography
variant="subtitle1"
sx={{ color: TEXT_WHITE, fontWeight: 700, letterSpacing: 0.5 }}
>
PM
</Typography>
) : (
<Box>
<Typography
variant="subtitle1"
sx={{ color: TEXT_WHITE, fontWeight: 700, lineHeight: 1.2 }}
>
Property
</Typography>
<Typography
variant="caption"
sx={{
color: '#64b5f6',
fontWeight: 600,
textTransform: 'uppercase',
letterSpacing: 1,
fontSize: '0.6rem',
}}
>
Match
</Typography>
</Box>
)}
</Box>
{/* Workspace tabs */}
<Box
sx={{
borderBottom: `1px solid ${DIVIDER_COLOR}`,
py: 0.5,
px: 0.5,
flexShrink: 0,
}}
>
{WORKSPACE_ORDER.map((ws) => {
const wsConfig = WORKSPACE_CONFIG[ws]
const Icon = wsConfig.icon
const isActive = ws === activeWorkspace
const tabContent = (
<Box
onClick={() => onWorkspaceClick(ws)}
sx={{
display: 'flex',
alignItems: 'center',
gap: 1.25,
px: collapsed ? 0 : 1.5,
py: 0.75,
borderRadius: 1,
cursor: 'pointer',
justifyContent: collapsed ? 'center' : 'flex-start',
backgroundColor: isActive ? ACTIVE_BG : 'transparent',
'&:hover': {
backgroundColor: isActive ? ACTIVE_BG : NAV_HOVER_BG,
},
transition: 'background-color 0.15s ease',
}}
>
<Icon size={16} color={isActive ? TEXT_WHITE : TEXT_MUTED} />
{!collapsed && (
<Typography
variant="body2"
sx={{
color: isActive ? TEXT_WHITE : TEXT_MUTED,
fontWeight: isActive ? 600 : 400,
fontSize: '0.8125rem',
whiteSpace: 'nowrap',
}}
>
{wsConfig.label}
</Typography>
)}
</Box>
)
return collapsed ? (
<Tooltip key={ws} title={wsConfig.label} placement="right">
{tabContent}
</Tooltip>
) : (
<Box key={ws}>{tabContent}</Box>
)
})}
</Box>
{/* Nav items */}
<Box sx={{ flex: 1, overflowY: 'auto', py: 0.5 }}>
{config.navItems.map((item) => {
const Icon = item.icon
const navContent = (
<NavLink
to={item.path}
style={{ textDecoration: 'none', display: 'block' }}
>
{({ isActive }) => (
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1.25,
px: collapsed ? 0 : 1.5,
py: 0.75,
mx: 0.5,
borderRadius: 1,
justifyContent: collapsed ? 'center' : 'flex-start',
backgroundColor: isActive ? NAV_ACTIVE_BG : 'transparent',
'&:hover': {
backgroundColor: isActive ? NAV_ACTIVE_BG : NAV_HOVER_BG,
},
transition: 'background-color 0.15s ease',
cursor: 'pointer',
}}
>
<Icon size={16} color={isActive ? TEXT_WHITE : TEXT_MUTED} />
{!collapsed && (
<Typography
variant="body2"
sx={{
color: isActive ? TEXT_WHITE : TEXT_MUTED,
fontWeight: isActive ? 500 : 400,
fontSize: '0.8125rem',
whiteSpace: 'nowrap',
}}
>
{item.label}
</Typography>
)}
</Box>
)}
</NavLink>
)
return collapsed ? (
<Tooltip key={item.path} title={item.label} placement="right">
<Box>{navContent}</Box>
</Tooltip>
) : (
<Box key={item.path}>{navContent}</Box>
)
})}
</Box>
{/* Bottom section */}
<Box
sx={{
borderTop: `1px solid ${DIVIDER_COLOR}`,
px: collapsed ? 0 : 1.5,
py: 1,
display: 'flex',
alignItems: 'center',
gap: 1,
justifyContent: collapsed ? 'center' : 'space-between',
flexShrink: 0,
}}
>
{!collapsed && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, minWidth: 0 }}>
<Avatar
sx={{
width: 32,
height: 32,
fontSize: '0.75rem',
bgcolor: '#1e3a5f',
flexShrink: 0,
}}
>
{getUserInitials(userName)}
</Avatar>
<Box sx={{ minWidth: 0 }}>
<Typography
variant="body2"
sx={{
color: TEXT_WHITE,
fontWeight: 500,
fontSize: '0.8125rem',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
>
{userName}
</Typography>
<Typography
variant="caption"
sx={{
color: '#64748b',
fontSize: '0.7rem',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
display: 'block',
}}
>
{orgName}
</Typography>
</Box>
</Box>
)}
<IconButton
onClick={onToggle}
size="small"
sx={{ color: '#64748b', flexShrink: 0 }}
>
{collapsed ? <ChevronRight size={16} /> : <ChevronLeft size={16} />}
</IconButton>
</Box>
</Box>
)
}
interface TopBarProps {
activeWorkspace: WorkspaceType
pathname: string
}
function TopBar({ activeWorkspace, pathname }: TopBarProps) {
const config = WORKSPACE_CONFIG[activeWorkspace]
const pageName = getPageNameFromPath(pathname)
return (
<Box
component="header"
sx={{
height: 56,
flexShrink: 0,
backgroundColor: '#ffffff',
borderBottom: '1px solid #e2e8f0',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
px: 3,
}}
>
{/* Left side */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
<Chip
label={config.label}
size="small"
sx={{
backgroundColor: config.chipColor,
color: '#ffffff',
fontWeight: 600,
fontSize: '0.7rem',
height: 22,
}}
/>
<Typography
variant="body1"
sx={{ fontWeight: 500, color: '#1e293b', fontSize: '0.9375rem' }}
>
{pageName}
</Typography>
</Box>
{/* Right side */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Button
variant="outlined"
size="small"
startIcon={<Sparkles size={14} />}
sx={{ textTransform: 'none', fontSize: '0.8125rem' }}
>
AI Assistent
</Button>
<IconButton size="small" sx={{ color: '#64748b' }}>
<Bell size={20} />
</IconButton>
<Avatar sx={{ width: 32, height: 32, fontSize: '0.75rem', bgcolor: '#1e3a5f' }}>
AU
</Avatar>
</Box>
</Box>
)
}
// ---------------------------------------------------------------------------
// AppShell
// ---------------------------------------------------------------------------
export function AppShell() {
const { activeWorkspace, sidebarCollapsed, setActiveWorkspace, toggleSidebar } =
useLayoutStore()
const { currentUser } = useSessionStore()
const navigate = useNavigate()
const location = useLocation()
// Sync active workspace with URL
useEffect(() => {
const detected = getWorkspaceFromPath(location.pathname)
if (detected && detected !== activeWorkspace) {
setActiveWorkspace(detected)
}
}, [location.pathname, activeWorkspace, setActiveWorkspace])
const handleWorkspaceClick = (workspace: WorkspaceType) => {
setActiveWorkspace(workspace)
navigate(WORKSPACE_CONFIG[workspace].firstPath)
}
const userName = currentUser?.name ?? 'User'
const orgName = currentUser?.organizationName ?? ''
return (
<Box sx={{ display: 'flex', height: '100vh', overflow: 'hidden' }}>
<Sidebar
collapsed={sidebarCollapsed}
activeWorkspace={activeWorkspace}
onWorkspaceClick={handleWorkspaceClick}
onToggle={toggleSidebar}
userName={userName}
orgName={orgName}
/>
<Box sx={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
<TopBar activeWorkspace={activeWorkspace} pathname={location.pathname} />
<Box component="main" sx={{ flex: 1, overflowY: 'auto' }}>
<Outlet />
</Box>
</Box>
</Box>
)
}
+1
View File
@@ -0,0 +1 @@
export { AppShell } from './AppShell'
+45
View File
@@ -0,0 +1,45 @@
import { Component, type ReactNode } from 'react'
import { Box, Button, Typography } from '@mui/material'
import { AlertTriangle } from 'lucide-react'
interface Props {
children: ReactNode
fallback?: ReactNode
}
interface State {
hasError: boolean
error?: Error
}
export class AppErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props)
this.state = { hasError: false }
}
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error }
}
handleReset = () => {
this.setState({ hasError: false, error: undefined })
}
render() {
if (this.state.hasError) {
if (this.props.fallback) return this.props.fallback
return (
<Box className="flex flex-col items-center justify-center min-h-64 gap-4 p-8 text-center">
<AlertTriangle size={40} className="text-red-500" />
<Typography variant="h6" color="error">Unerwarteter Fehler</Typography>
<Typography variant="body2" color="text.secondary" className="max-w-md">
{this.state.error?.message ?? 'Ein unbekannter Fehler ist aufgetreten.'}
</Typography>
<Button variant="outlined" onClick={this.handleReset}>Neu laden</Button>
</Box>
)
}
return this.props.children
}
}
+29
View File
@@ -0,0 +1,29 @@
import { Box, Button, Typography } from '@mui/material'
import type { ReactNode } from 'react'
interface EmptyStateProps {
icon?: ReactNode
title: string
description?: string
action?: {
label: string
onClick: () => void
}
}
export function EmptyState({ icon, title, description, action }: EmptyStateProps) {
return (
<Box className="flex flex-col items-center justify-center gap-3 py-16 px-8 text-center">
{icon && <Box className="text-slate-400 mb-2">{icon}</Box>}
<Typography variant="h6" color="text.primary" fontWeight={500}>{title}</Typography>
{description && (
<Typography variant="body2" color="text.secondary" className="max-w-sm">{description}</Typography>
)}
{action && (
<Button variant="outlined" size="small" onClick={action.onClick} className="mt-2">
{action.label}
</Button>
)}
</Box>
)
}
+19
View File
@@ -0,0 +1,19 @@
import { Box, Button, Typography } from '@mui/material'
import { AlertCircle } from 'lucide-react'
interface ErrorStateProps {
message?: string
onRetry?: () => void
}
export function ErrorState({ message = 'Daten konnten nicht geladen werden.', onRetry }: ErrorStateProps) {
return (
<Box className="flex flex-col items-center justify-center gap-3 py-16 px-8 text-center">
<AlertCircle size={36} className="text-red-400" />
<Typography variant="body1" color="text.secondary">{message}</Typography>
{onRetry && (
<Button variant="outlined" size="small" onClick={onRetry}>Erneut versuchen</Button>
)}
</Box>
)
}
+21
View File
@@ -0,0 +1,21 @@
import { Box, Skeleton } from '@mui/material'
interface LoadingPageProps {
rows?: number
}
export function LoadingPage({ rows = 5 }: LoadingPageProps) {
return (
<Box className="flex flex-col gap-4 p-6 w-full">
<Skeleton variant="rectangular" height={48} className="rounded" />
<Box className="flex gap-4">
{[1, 2, 3, 4].map(i => (
<Skeleton key={i} variant="rectangular" height={80} className="flex-1 rounded" />
))}
</Box>
{Array.from({ length: rows }).map((_, i) => (
<Skeleton key={i} variant="rectangular" height={64} className="rounded" />
))}
</Box>
)
}
+19
View File
@@ -0,0 +1,19 @@
import { Box } from '@mui/material'
import type { ReactNode } from 'react'
interface PageContainerProps {
children: ReactNode
maxWidth?: string | number
className?: string
}
export function PageContainer({ children, maxWidth = 1440, className = '' }: PageContainerProps) {
return (
<Box
className={`w-full mx-auto px-6 py-6 ${className}`}
sx={{ maxWidth }}
>
{children}
</Box>
)
}
+29
View File
@@ -0,0 +1,29 @@
import { Box, Divider, Typography } from '@mui/material'
import type { ReactNode } from 'react'
interface SectionContainerProps {
title?: string
subtitle?: string
action?: ReactNode
children: ReactNode
className?: string
divider?: boolean
}
export function SectionContainer({ title, subtitle, action, children, className = '', divider = false }: SectionContainerProps) {
return (
<Box className={`flex flex-col gap-3 ${className}`}>
{(title || action) && (
<Box className="flex items-center justify-between gap-2">
<Box>
{title && <Typography variant="subtitle1" fontWeight={600} color="text.primary">{title}</Typography>}
{subtitle && <Typography variant="caption" color="text.secondary">{subtitle}</Typography>}
</Box>
{action}
</Box>
)}
{divider && <Divider />}
{children}
</Box>
)
}
+6
View File
@@ -0,0 +1,6 @@
export { AppErrorBoundary } from './AppErrorBoundary'
export { LoadingPage } from './LoadingPage'
export { EmptyState } from './EmptyState'
export { ErrorState } from './ErrorState'
export { PageContainer } from './PageContainer'
export { SectionContainer } from './SectionContainer'
+65
View File
@@ -0,0 +1,65 @@
export enum AssetType {
OFFICE = 'OFFICE',
RETAIL = 'RETAIL',
GASTRO = 'GASTRO',
LOGISTICS = 'LOGISTICS',
PRODUCTION = 'PRODUCTION',
MIXED = 'MIXED',
}
export enum ResultType {
VERIFIED_PORTFOLIO = 'VERIFIED_PORTFOLIO',
EXTERNAL_MARKET = 'EXTERNAL_MARKET',
FUTURE_AVAILABILITY = 'FUTURE_AVAILABILITY',
}
export enum MatchStrength {
STRONG = 'STRONG',
MODERATE = 'MODERATE',
WEAK = 'WEAK',
}
export enum RiskLevel {
LOW = 'LOW',
MEDIUM = 'MEDIUM',
HIGH = 'HIGH',
CRITICAL = 'CRITICAL',
}
export enum AvailabilityStatus {
AVAILABLE_NOW = 'AVAILABLE_NOW',
AVAILABLE_SOON = 'AVAILABLE_SOON',
FUTURE_SIGNAL = 'FUTURE_SIGNAL',
OCCUPIED = 'OCCUPIED',
UNKNOWN = 'UNKNOWN',
}
export enum DataFreshness {
FRESH = 'FRESH',
STALE = 'STALE',
OUTDATED = 'OUTDATED',
}
export enum UserRole {
SUPER_ADMIN = 'SUPER_ADMIN',
ORGANIZATION_ADMIN = 'ORGANIZATION_ADMIN',
PROPERTY_MANAGER = 'PROPERTY_MANAGER',
REVIEWER = 'REVIEWER',
OWNER_VIEWER = 'OWNER_VIEWER',
DEMAND_USER = 'DEMAND_USER',
}
export enum SignalType {
EXPANSION = 'EXPANSION',
POSSIBLE_MOVE_OUT = 'POSSIBLE_MOVE_OUT',
CONSTRUCTION_PROJECT = 'CONSTRUCTION_PROJECT',
RESTRUCTURING = 'RESTRUCTURING',
PROJECT_DEVELOPMENT = 'PROJECT_DEVELOPMENT',
SPACE_CONSOLIDATION = 'SPACE_CONSOLIDATION',
}
export enum WorkspaceType {
SUPPLY = 'SUPPLY',
DEMAND = 'DEMAND',
OPERATIONS = 'OPERATIONS',
}
+33
View File
@@ -0,0 +1,33 @@
import type { SignalType, RiskLevel } from './enums'
export interface SignalSource {
type: 'PRESS' | 'CONSTRUCTION_PERMIT' | 'JOB_POSTING' | 'COMPANY_REPORT' | 'MARKET_DATA' | 'MANUAL'
url?: string
publishedAt?: string
credibility: 'LOW' | 'MEDIUM' | 'HIGH'
}
export interface FutureSignal {
id: string
signalType: SignalType
companyName?: string
propertyId?: string
locationHint: string
areaSqmEstimate?: number
probability: number
confidenceScore: number
timeHorizonMonths: number
source: SignalSource
sensitivityLevel: 'PUBLIC' | 'INTERNAL' | 'CONFIDENTIAL'
disclaimer: string
riskLevel: RiskLevel
marketIndicator?: string
relevanceScore?: number
isVerified: boolean
verifiedBy?: string
verifiedAt?: string
expiresAt?: string
organizationId?: string
createdAt: string
updatedAt: string
}
+5
View File
@@ -0,0 +1,5 @@
export * from './enums'
export * from './property'
export * from './need'
export * from './match'
export * from './futureSignal'
+53
View File
@@ -0,0 +1,53 @@
import type { MatchStrength, RiskLevel } from './enums'
export interface ScoreBreakdown {
hardMatchScore: number
softFactorScore: number
confidenceModifier: number
dataQualityModifier: number
totalScore: number
}
export interface ScoreFactor {
criterion: string
weight: number
score: number
contribution: number
explanation: string
}
export interface Tradeoff {
criterion: string
concern: string
severity: 'LOW' | 'MEDIUM' | 'HIGH'
mitigation?: string
}
export interface AlternativeStrategy {
title: string
description: string
expectedScore?: number
}
export interface Match {
id: string
propertyId: string
needId: string
matchScore: number
matchStrength: MatchStrength
scoreBreakdown: ScoreBreakdown
positiveFactors: ScoreFactor[]
negativeFactors: ScoreFactor[]
tradeoffs: Tradeoff[]
explainabilitySummary: string
confidenceLevel: number
riskLevel: RiskLevel
uncertaintyIndicators: string[]
alternativeStrategies?: AlternativeStrategy[]
reviewedBy?: string
reviewedAt?: string
isApproved?: boolean
organizationId?: string
createdAt: string
updatedAt: string
}
+65
View File
@@ -0,0 +1,65 @@
import type { AssetType } from './enums'
export interface AreaRange {
min: number
max: number
}
export interface BudgetRange {
minPerSqm?: number
maxPerSqm: number
maxMonthlyTotal?: number
currency: string
}
export interface Timing {
earliestMoveIn: string
latestMoveIn: string
contractDurationMonths?: number
flexibleTiming: boolean
}
export interface WeightingProfile {
area: number
location: number
budget: number
timing: number
prestige: number
accessibility: number
expansionPotential: number
flexibility: number
[key: string]: number
}
export interface SoftFactorPreferences {
minPrestige?: number
minAccessibility?: number
requireParking?: boolean
maxPublicTransportMinutes?: number
preferredEsgRating?: string
requireHighVisibility?: boolean
}
export interface Need {
id: string
companyName: string
contactName?: string
assetType: AssetType
requiredArea: AreaRange
preferredLocations: string[]
excludedLocations?: string[]
budgetRange: BudgetRange
timing: Timing
mustCriteriaText?: string[]
softFactors?: SoftFactorPreferences
weightingProfile: WeightingProfile
confidenceInCriteria: number
extractedFromText?: string
notes?: string
organizationId?: string
createdAt: string
updatedAt: string
}
export type CreateNeedInput = Omit<Need, 'id' | 'createdAt' | 'updatedAt'>
export type UpdateNeedInput = Partial<CreateNeedInput>
+73
View File
@@ -0,0 +1,73 @@
import type { AssetType, ResultType, AvailabilityStatus, DataFreshness, RiskLevel } from './enums'
export interface Location {
city: string
district?: string
canton?: string
country: string
coordinates?: {
lat: number
lng: number
}
}
export interface Address {
street: string
houseNumber: string
postalCode: string
city: string
country: string
}
export interface SoftFactors {
prestige?: number
accessibility?: number
visibilityScore?: number
talentAccess?: number
esgRating?: string
passerbyFrequency?: 'LOW' | 'MEDIUM' | 'HIGH' | 'VERY_HIGH'
parkingSpots?: number
publicTransportMinutes?: number
infrastructureNotes?: string
}
export interface DataQuality {
score: number
missingCriticalFields: string[]
missingOptionalFields: string[]
lastVerifiedAt?: string
freshness: DataFreshness
warnings: string[]
}
export interface Property {
id: string
title: string
assetType: AssetType
resultType: ResultType
location: Location
address: Address
areaSqm: number
rentPricePerSqm: number
totalRentMonthly?: number
availabilityDate: string
availabilityStatus: AvailabilityStatus
sourceType: string
sourceUrl?: string
confidenceScore: number
dataQuality: DataQuality
softFactors?: SoftFactors
floorLevel?: number
expansionPotentialSqm?: number
contractDurationMonths?: number
ancillaryCosts?: number
riskLevel?: RiskLevel
description?: string
images?: string[]
organizationId?: string
createdAt: string
updatedAt: string
}
export type CreatePropertyInput = Omit<Property, 'id' | 'createdAt' | 'updatedAt'>
export type UpdatePropertyInput = Partial<CreatePropertyInput>
+4
View File
@@ -0,0 +1,4 @@
@layer theme, base, components, utilities;
@import "tailwindcss/theme.css" layer(theme);
@import "tailwindcss/utilities.css" layer(utilities);
+108
View File
@@ -0,0 +1,108 @@
import { createTheme } from '@mui/material/styles'
export const theme = createTheme({
palette: {
mode: 'light',
primary: {
main: '#1e3a5f',
light: '#2d5290',
dark: '#142a45',
contrastText: '#ffffff',
},
secondary: {
main: '#2d6a8f',
light: '#4a8ab0',
dark: '#1e4d68',
contrastText: '#ffffff',
},
background: {
default: '#f4f6f9',
paper: '#ffffff',
},
text: {
primary: '#1a2332',
secondary: '#4a5568',
},
error: { main: '#c0392b' },
warning: { main: '#d97706' },
success: { main: '#1a7a4a' },
info: { main: '#2563eb' },
divider: '#e2e8f0',
},
typography: {
fontFamily: '"Inter", "Segoe UI", system-ui, -apple-system, sans-serif',
h1: { fontSize: '1.75rem', fontWeight: 700, letterSpacing: '-0.02em' },
h2: { fontSize: '1.5rem', fontWeight: 600, letterSpacing: '-0.01em' },
h3: { fontSize: '1.25rem', fontWeight: 600 },
h4: { fontSize: '1.125rem', fontWeight: 600 },
h5: { fontSize: '1rem', fontWeight: 600 },
h6: { fontSize: '0.9375rem', fontWeight: 600 },
subtitle1: { fontSize: '0.875rem', fontWeight: 600, lineHeight: 1.5 },
subtitle2: { fontSize: '0.8125rem', fontWeight: 600, lineHeight: 1.4 },
body1: { fontSize: '0.875rem', lineHeight: 1.6 },
body2: { fontSize: '0.8125rem', lineHeight: 1.5 },
caption: { fontSize: '0.75rem', lineHeight: 1.4, color: '#64748b' },
overline: { fontSize: '0.6875rem', fontWeight: 600, letterSpacing: '0.08em', textTransform: 'uppercase' },
button: { fontSize: '0.8125rem', fontWeight: 600, textTransform: 'none' },
},
shape: { borderRadius: 6 },
shadows: [
'none',
'0 1px 2px rgba(0,0,0,0.06), 0 1px 3px rgba(0,0,0,0.08)',
'0 1px 4px rgba(0,0,0,0.06), 0 2px 6px rgba(0,0,0,0.08)',
'0 2px 6px rgba(0,0,0,0.07), 0 4px 10px rgba(0,0,0,0.08)',
'0 3px 8px rgba(0,0,0,0.08), 0 6px 14px rgba(0,0,0,0.08)',
'0 4px 10px rgba(0,0,0,0.08), 0 8px 18px rgba(0,0,0,0.08)',
'0 5px 12px rgba(0,0,0,0.09)', '0 6px 14px rgba(0,0,0,0.09)',
'0 7px 16px rgba(0,0,0,0.09)', '0 8px 18px rgba(0,0,0,0.09)',
'0 9px 20px rgba(0,0,0,0.09)', '0 10px 22px rgba(0,0,0,0.10)',
'0 11px 24px rgba(0,0,0,0.10)', '0 12px 26px rgba(0,0,0,0.10)',
'0 13px 28px rgba(0,0,0,0.10)', '0 14px 30px rgba(0,0,0,0.10)',
'0 15px 32px rgba(0,0,0,0.10)', '0 16px 34px rgba(0,0,0,0.10)',
'0 17px 36px rgba(0,0,0,0.10)', '0 18px 38px rgba(0,0,0,0.10)',
'0 19px 40px rgba(0,0,0,0.10)', '0 20px 42px rgba(0,0,0,0.10)',
'0 21px 44px rgba(0,0,0,0.10)', '0 22px 46px rgba(0,0,0,0.10)',
'0 24px 50px rgba(0,0,0,0.12)',
],
components: {
MuiCard: {
defaultProps: { elevation: 1 },
styleOverrides: {
root: {
border: '1px solid #e2e8f0',
'&:hover': { boxShadow: '0 2px 8px rgba(0,0,0,0.10)' },
transition: 'box-shadow 0.15s ease',
},
},
},
MuiButton: {
defaultProps: { disableElevation: true },
styleOverrides: {
root: { borderRadius: 6, padding: '6px 16px' },
containedPrimary: {
background: '#1e3a5f',
'&:hover': { background: '#142a45' },
},
},
},
MuiChip: {
styleOverrides: {
root: { borderRadius: 4, fontSize: '0.75rem', fontWeight: 600, height: 22 },
},
},
MuiTableCell: {
styleOverrides: {
head: { fontWeight: 600, fontSize: '0.75rem', color: '#64748b', textTransform: 'uppercase', letterSpacing: '0.05em', padding: '8px 12px' },
body: { fontSize: '0.8125rem', padding: '10px 12px' },
},
},
MuiTooltip: {
defaultProps: { arrow: true },
},
MuiPaper: {
styleOverrides: {
root: { backgroundImage: 'none' },
},
},
},
})
+32
View File
@@ -0,0 +1,32 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { BrowserRouter } from 'react-router'
import { StyledEngineProvider, ThemeProvider, CssBaseline } from '@mui/material'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { theme } from './lib/theme'
import './index.css'
import App from './App.tsx'
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 5 * 60 * 1000,
retry: 1,
},
},
})
createRoot(document.getElementById('root')!).render(
<StrictMode>
<BrowserRouter>
<QueryClientProvider client={queryClient}>
<StyledEngineProvider injectFirst>
<ThemeProvider theme={theme}>
<CssBaseline />
<App />
</ThemeProvider>
</StyledEngineProvider>
</QueryClientProvider>
</BrowserRouter>
</StrictMode>,
)
+84
View File
@@ -0,0 +1,84 @@
import { SignalType, RiskLevel } from '../domain/enums'
import type { FutureSignal } from '../domain/futureSignal'
export const mockFutureSignals: FutureSignal[] = [
{
id: 'signal-001',
signalType: SignalType.EXPANSION,
companyName: 'DataCloud Systems AG',
locationHint: 'Zürich-West / Technopark',
areaSqmEstimate: 600,
probability: 0.72,
confidenceScore: 0.68,
timeHorizonMonths: 10,
source: {
type: 'JOB_POSTING',
url: 'https://example.com/jobs/datacloud',
publishedAt: '2025-04-20',
credibility: 'MEDIUM',
},
sensitivityLevel: 'INTERNAL',
disclaimer: 'Dieses Signal basiert auf AI-Analyse öffentlicher Daten (Stelleninserate, Presseberichte). Es handelt sich um ein probabilistisches Signal, kein bestätigtes Objekt.',
riskLevel: RiskLevel.MEDIUM,
marketIndicator: 'Tech-Sektor Stellenwachstum +38% YoY',
relevanceScore: 0.76,
isVerified: false,
expiresAt: '2026-03-01',
organizationId: 'org-wincasa',
createdAt: '2025-05-01T07:00:00Z',
updatedAt: '2025-05-10T07:00:00Z',
},
{
id: 'signal-002',
signalType: SignalType.POSSIBLE_MOVE_OUT,
companyName: 'Helvetia Produktion GmbH',
propertyId: 'prop-006',
locationHint: 'Reinach BL, Industriezone Nord',
areaSqmEstimate: 3200,
probability: 0.48,
confidenceScore: 0.44,
timeHorizonMonths: 13,
source: {
type: 'PRESS',
url: 'https://example.com/news/helvetia-restrukturierung',
publishedAt: '2025-03-15',
credibility: 'HIGH',
},
sensitivityLevel: 'CONFIDENTIAL',
disclaimer: 'Dieses Signal basiert auf Pressemeldungen zu Restrukturierungsplänen. Kein bestätigter Auszug. Vertraulich behandeln.',
riskLevel: RiskLevel.HIGH,
marketIndicator: 'Restrukturierungsankündigung Mutterkonzern',
relevanceScore: 0.61,
isVerified: false,
expiresAt: '2026-06-01',
organizationId: 'org-wincasa',
createdAt: '2025-05-03T08:00:00Z',
updatedAt: '2025-05-10T08:00:00Z',
},
{
id: 'signal-003',
signalType: SignalType.CONSTRUCTION_PROJECT,
locationHint: 'Bern, Wankdorf',
areaSqmEstimate: 4500,
probability: 0.85,
confidenceScore: 0.80,
timeHorizonMonths: 24,
source: {
type: 'CONSTRUCTION_PERMIT',
publishedAt: '2025-02-10',
credibility: 'HIGH',
},
sensitivityLevel: 'PUBLIC',
disclaimer: 'Baubewilligung öffentlich eingesehen. Fertigstellung gemäss Baugesuch Q1 2027. Vermietungsstart noch offen.',
riskLevel: RiskLevel.LOW,
marketIndicator: 'Neubau Büro/Gewerbeflächen Wankdorf West',
relevanceScore: 0.82,
isVerified: true,
verifiedBy: 'admin@ideal-sharing.ch',
verifiedAt: '2025-05-05T09:00:00Z',
expiresAt: '2027-03-01',
organizationId: 'org-wincasa',
createdAt: '2025-02-12T10:00:00Z',
updatedAt: '2025-05-05T09:00:00Z',
},
]
+4
View File
@@ -0,0 +1,4 @@
export { mockProperties } from './properties'
export { mockNeeds } from './needs'
export { mockMatches } from './matches'
export { mockFutureSignals } from './futureSignals'
+141
View File
@@ -0,0 +1,141 @@
import { MatchStrength, RiskLevel } from '../domain/enums'
import type { Match } from '../domain/match'
export const mockMatches: Match[] = [
{
id: 'match-001',
propertyId: 'prop-001',
needId: 'need-001',
matchScore: 88,
matchStrength: MatchStrength.STRONG,
scoreBreakdown: {
hardMatchScore: 92,
softFactorScore: 85,
confidenceModifier: 0.97,
dataQualityModifier: 0.92,
totalScore: 88,
},
positiveFactors: [
{ criterion: 'Fläche', weight: 0.20, score: 95, contribution: 19, explanation: '850m² liegt im Zielkorridor (6001000m²)' },
{ criterion: 'ÖV-Anbindung', weight: 0.20, score: 90, contribution: 18, explanation: '4 Min. zur S-Bahn, Kriterium erfüllt' },
{ criterion: 'Standort', weight: 0.15, score: 88, contribution: 13.2, explanation: 'Zürich-West trifft bevorzugte Lage' },
],
negativeFactors: [
{ criterion: 'Mietpreis', weight: 0.15, score: 76, contribution: 11.4, explanation: 'CHF 38/m² liegt über Wunschbudget von CHF 35/m²' },
],
tradeoffs: [
{ criterion: 'Budget', concern: 'Mietpreis 8% über Budget-Maximum', severity: 'MEDIUM', mitigation: 'Verhandlungspotenzial vorhanden Objekt leer seit 3 Monaten' },
],
explainabilitySummary: 'Starker Match aufgrund idealer Lage und Fläche. Einziger Vorbehalt ist der Mietpreis, der knapp über dem Budget liegt, aber verhandelbar erscheint.',
confidenceLevel: 0.92,
riskLevel: RiskLevel.LOW,
uncertaintyIndicators: [],
alternativeStrategies: [
{ title: 'Kleinere Einheit im gleichen Gebäude', description: '650m² verfügbar ab Q3 2025, CHF 35/m²', expectedScore: 82 },
],
organizationId: 'org-wincasa',
createdAt: '2025-05-10T08:00:00Z',
updatedAt: '2025-05-10T08:00:00Z',
},
{
id: 'match-002',
propertyId: 'prop-004',
needId: 'need-001',
matchScore: 64,
matchStrength: MatchStrength.MODERATE,
scoreBreakdown: {
hardMatchScore: 78,
softFactorScore: 62,
confidenceModifier: 0.68,
dataQualityModifier: 0.55,
totalScore: 64,
},
positiveFactors: [
{ criterion: 'Fläche', weight: 0.20, score: 88, contribution: 17.6, explanation: '1150m² liegt im erweiterten Korridor' },
{ criterion: 'Standort', weight: 0.20, score: 80, contribution: 16, explanation: 'Zürich Kreis 4 nahe bevorzugten Lagen' },
],
negativeFactors: [
{ criterion: 'Datenqualität', weight: 0.10, score: 40, contribution: 4, explanation: 'Mehrere kritische Felder fehlen Verlässlichkeit eingeschränkt' },
{ criterion: 'Mietpreis', weight: 0.15, score: 55, contribution: 8.25, explanation: 'CHF 52/m² deutlich über Budget' },
],
tradeoffs: [
{ criterion: 'Datenqualität', concern: 'Externe Quelle Mietpreis und Verfügbarkeit nicht bestätigt', severity: 'HIGH', mitigation: 'Direkte Anfrage beim Anbieter empfohlen' },
{ criterion: 'Budget', concern: 'Mietpreis 30% über Budget-Maximum', severity: 'HIGH' },
],
explainabilitySummary: 'Moderater Match Fläche und Lage passen, aber Mietpreis und Datenqualität sind kritische Vorbehalte. Nur mit Preisverhandlung und Verifikation sinnvoll.',
confidenceLevel: 0.58,
riskLevel: RiskLevel.MEDIUM,
uncertaintyIndicators: ['Daten aus Drittquelle unvollständig', 'Mietpreis nicht verifiziert'],
organizationId: 'org-wincasa',
createdAt: '2025-05-10T08:05:00Z',
updatedAt: '2025-05-10T08:05:00Z',
},
{
id: 'match-003',
propertyId: 'prop-002',
needId: 'need-002',
matchScore: 91,
matchStrength: MatchStrength.STRONG,
scoreBreakdown: {
hardMatchScore: 94,
softFactorScore: 89,
confidenceModifier: 0.99,
dataQualityModifier: 0.96,
totalScore: 91,
},
positiveFactors: [
{ criterion: 'Fläche', weight: 0.25, score: 96, contribution: 24, explanation: '2400m² im Zielkorridor (15004000m²)' },
{ criterion: 'Autobahnanschluss', weight: 0.20, score: 92, contribution: 18.4, explanation: 'A2-Anschluss ca. 4 Min., Kriterium erfüllt' },
{ criterion: 'Budget', weight: 0.20, score: 88, contribution: 17.6, explanation: 'CHF 14/m² liegt unter Maximum von CHF 18/m²' },
{ criterion: 'Datenqualität', weight: 0.10, score: 96, contribution: 9.6, explanation: 'Verifiziertes Portfolioobjekt, alle Felder vollständig' },
],
negativeFactors: [
{ criterion: 'Erweiterungspotenzial', weight: 0.05, score: 70, contribution: 3.5, explanation: '800m² Erweiterung möglich, aber begrenzt' },
],
tradeoffs: [],
explainabilitySummary: 'Sehr starker Match. Fläche, Anbindung und Budget passen exzellent. Hohe Datenqualität aus internem Portfolio bestätigt Verlässlichkeit.',
confidenceLevel: 0.96,
riskLevel: RiskLevel.LOW,
uncertaintyIndicators: [],
organizationId: 'org-wincasa',
createdAt: '2025-05-10T08:10:00Z',
updatedAt: '2025-05-10T08:10:00Z',
},
{
id: 'match-004',
propertyId: 'prop-006',
needId: 'need-002',
matchScore: 47,
matchStrength: MatchStrength.WEAK,
scoreBreakdown: {
hardMatchScore: 70,
softFactorScore: 40,
confidenceModifier: 0.48,
dataQualityModifier: 0.30,
totalScore: 47,
},
positiveFactors: [
{ criterion: 'Fläche', weight: 0.25, score: 85, contribution: 21.25, explanation: '3200m² im erweiterten Zielkorridor' },
{ criterion: 'Standort', weight: 0.20, score: 72, contribution: 14.4, explanation: 'Reinach BL ist eine bevorzugte Region' },
],
negativeFactors: [
{ criterion: 'Confidence', weight: 0.15, score: 20, contribution: 3, explanation: 'Nur 48% Signalwahrscheinlichkeit kein bestätigtes Objekt' },
{ criterion: 'Datenqualität', weight: 0.10, score: 15, contribution: 1.5, explanation: 'Mehrere kritische Felder fehlen, Schätzwerte' },
{ criterion: 'Timing', weight: 0.15, score: 50, contribution: 7.5, explanation: 'Verfügbarkeit erst Q2 2026, spät für Anforderung' },
],
tradeoffs: [
{ criterion: 'Verfügbarkeit', concern: 'Probabilistisches Future-Signal keine Garantie auf Verfügbarkeit', severity: 'HIGH', mitigation: 'Für Monitoring-Watchlist geeignet' },
{ criterion: 'Daten', concern: 'Mietpreis geschätzt, Andienung nicht bestätigt', severity: 'HIGH' },
],
explainabilitySummary: 'Schwacher Match aufgrund hoher Unsicherheit. Das Signal ist interessant als Frühindikator, aber nicht als aktive Option geeignet. Empfehlung: Watchlist.',
confidenceLevel: 0.38,
riskLevel: RiskLevel.HIGH,
uncertaintyIndicators: ['Probabilistisches Signal ohne Bestätigung', 'Mietpreis geschätzt', 'Verfügbarkeitsdatum unsicher'],
alternativeStrategies: [
{ title: 'Signal beobachten', description: 'Als Future-Availability-Signal auf Watchlist setzen und in 3 Monaten neu evaluieren' },
],
organizationId: 'org-wincasa',
createdAt: '2025-05-10T08:15:00Z',
updatedAt: '2025-05-10T08:15:00Z',
},
]
+76
View File
@@ -0,0 +1,76 @@
import { AssetType } from '../domain/enums'
import type { Need } from '../domain/need'
export const mockNeeds: Need[] = [
{
id: 'need-001',
companyName: 'Innovatech AG',
contactName: 'Sandra Meier',
assetType: AssetType.OFFICE,
requiredArea: { min: 600, max: 1000 },
preferredLocations: ['Zürich', 'Zürich-West', 'Zürich Kreis 5'],
excludedLocations: [],
budgetRange: { maxPerSqm: 45, maxMonthlyTotal: 40000, currency: 'CHF' },
timing: {
earliestMoveIn: '2025-08-01',
latestMoveIn: '2026-01-01',
contractDurationMonths: 60,
flexibleTiming: true,
},
mustCriteriaText: ['ÖV-Anbindung < 5 Min', 'Mindestfläche 600m²', 'Ausbaugrad modern'],
softFactors: {
minPrestige: 70,
minAccessibility: 80,
requireParking: false,
maxPublicTransportMinutes: 6,
},
weightingProfile: {
area: 0.20,
location: 0.20,
budget: 0.15,
timing: 0.15,
prestige: 0.10,
accessibility: 0.10,
expansionPotential: 0.05,
flexibility: 0.05,
},
confidenceInCriteria: 0.88,
extractedFromText: 'Wir suchen moderne Büroflächen in Zürich-West, ca. 700900m², Budget max CHF 42/m², Bezug Herbst 2025.',
organizationId: 'org-wincasa',
createdAt: '2025-04-15T10:00:00Z',
updatedAt: '2025-05-01T09:00:00Z',
},
{
id: 'need-002',
companyName: 'Schweizer Logistik GmbH',
contactName: 'Thomas Brun',
assetType: AssetType.LOGISTICS,
requiredArea: { min: 1500, max: 4000 },
preferredLocations: ['Basel', 'Muttenz', 'Pratteln', 'Reinach BL'],
budgetRange: { maxPerSqm: 18, currency: 'CHF' },
timing: {
earliestMoveIn: '2025-09-01',
latestMoveIn: '2026-06-01',
contractDurationMonths: 36,
flexibleTiming: false,
},
mustCriteriaText: ['Autobahnanschluss < 5 Min', 'Rampe / Andienung', 'Hallenhöhe min 6m'],
softFactors: {
requireParking: true,
},
weightingProfile: {
area: 0.25,
location: 0.20,
budget: 0.20,
timing: 0.15,
prestige: 0.02,
accessibility: 0.10,
expansionPotential: 0.05,
flexibility: 0.03,
},
confidenceInCriteria: 0.94,
organizationId: 'org-wincasa',
createdAt: '2025-03-20T14:00:00Z',
updatedAt: '2025-04-10T11:00:00Z',
},
]
+192
View File
@@ -0,0 +1,192 @@
import { AssetType, ResultType, AvailabilityStatus, DataFreshness, RiskLevel } from '../domain/enums'
import type { Property } from '../domain/property'
export const mockProperties: Property[] = [
// --- VERIFIED_PORTFOLIO ---
{
id: 'prop-001',
title: 'Bürofläche Zollstrasse 12',
assetType: AssetType.OFFICE,
resultType: ResultType.VERIFIED_PORTFOLIO,
location: { city: 'Zürich', district: 'Zürich-West', canton: 'ZH', country: 'CH', coordinates: { lat: 47.3882, lng: 8.5132 } },
address: { street: 'Zollstrasse', houseNumber: '12', postalCode: '8005', city: 'Zürich', country: 'CH' },
areaSqm: 850,
rentPricePerSqm: 38,
totalRentMonthly: 32300,
availabilityDate: '2025-09-01',
availabilityStatus: AvailabilityStatus.AVAILABLE_SOON,
sourceType: 'ERP_IMPORT',
confidenceScore: 0.97,
dataQuality: {
score: 0.92,
missingCriticalFields: [],
missingOptionalFields: ['expansionPotentialSqm'],
lastVerifiedAt: '2025-04-28',
freshness: DataFreshness.FRESH,
warnings: [],
},
softFactors: {
prestige: 78,
accessibility: 90,
visibilityScore: 65,
talentAccess: 85,
parkingSpots: 12,
publicTransportMinutes: 4,
},
floorLevel: 3,
contractDurationMonths: 60,
ancillaryCosts: 5.5,
riskLevel: RiskLevel.LOW,
organizationId: 'org-wincasa',
createdAt: '2025-01-10T08:00:00Z',
updatedAt: '2025-04-28T10:30:00Z',
},
{
id: 'prop-002',
title: 'Lagerfläche Hardstrasse 44',
assetType: AssetType.LOGISTICS,
resultType: ResultType.VERIFIED_PORTFOLIO,
location: { city: 'Basel', district: 'Kleinhüningen', canton: 'BS', country: 'CH', coordinates: { lat: 47.5736, lng: 7.5946 } },
address: { street: 'Hardstrasse', houseNumber: '44', postalCode: '4057', city: 'Basel', country: 'CH' },
areaSqm: 2400,
rentPricePerSqm: 14,
totalRentMonthly: 33600,
availabilityDate: '2025-07-01',
availabilityStatus: AvailabilityStatus.AVAILABLE_NOW,
sourceType: 'ERP_IMPORT',
confidenceScore: 0.99,
dataQuality: {
score: 0.96,
missingCriticalFields: [],
missingOptionalFields: [],
lastVerifiedAt: '2025-05-05',
freshness: DataFreshness.FRESH,
warnings: [],
},
softFactors: {
prestige: 40,
accessibility: 88,
parkingSpots: 30,
publicTransportMinutes: 12,
},
floorLevel: 0,
expansionPotentialSqm: 800,
contractDurationMonths: 36,
ancillaryCosts: 3.0,
riskLevel: RiskLevel.LOW,
organizationId: 'org-wincasa',
createdAt: '2024-11-20T09:00:00Z',
updatedAt: '2025-05-05T11:00:00Z',
},
// --- EXTERNAL_MARKET ---
{
id: 'prop-003',
title: 'Retail-Fläche Bahnhofstrasse 88',
assetType: AssetType.RETAIL,
resultType: ResultType.EXTERNAL_MARKET,
location: { city: 'Bern', district: 'Innenstadt', canton: 'BE', country: 'CH', coordinates: { lat: 46.9483, lng: 7.4474 } },
address: { street: 'Bahnhofstrasse', houseNumber: '88', postalCode: '3011', city: 'Bern', country: 'CH' },
areaSqm: 320,
rentPricePerSqm: 95,
totalRentMonthly: 30400,
availabilityDate: '2025-08-15',
availabilityStatus: AvailabilityStatus.AVAILABLE_SOON,
sourceType: 'MATCHOFFICE_SCRAPE',
sourceUrl: 'https://example.com/listing/prop-003',
confidenceScore: 0.71,
dataQuality: {
score: 0.62,
missingCriticalFields: ['contractDurationMonths'],
missingOptionalFields: ['ancillaryCosts', 'floorLevel'],
lastVerifiedAt: '2025-04-10',
freshness: DataFreshness.STALE,
warnings: ['Mietpreis nicht bestätigt', 'Verfügbarkeit nicht verifiziert'],
},
softFactors: {
prestige: 92,
visibilityScore: 98,
passerbyFrequency: 'VERY_HIGH',
publicTransportMinutes: 2,
},
riskLevel: RiskLevel.MEDIUM,
createdAt: '2025-02-15T14:00:00Z',
updatedAt: '2025-04-10T09:00:00Z',
},
{
id: 'prop-004',
title: 'Gemischte Gewerbeeinheit Europaallee',
assetType: AssetType.MIXED,
resultType: ResultType.EXTERNAL_MARKET,
location: { city: 'Zürich', district: 'Kreis 4', canton: 'ZH', country: 'CH', coordinates: { lat: 47.3775, lng: 8.5398 } },
address: { street: 'Europaallee', houseNumber: '21', postalCode: '8004', city: 'Zürich', country: 'CH' },
areaSqm: 1150,
rentPricePerSqm: 52,
totalRentMonthly: 59800,
availabilityDate: '2025-10-01',
availabilityStatus: AvailabilityStatus.AVAILABLE_SOON,
sourceType: 'IMMOSCOUT_SCRAPE',
confidenceScore: 0.68,
dataQuality: {
score: 0.55,
missingCriticalFields: ['contractDurationMonths', 'ancillaryCosts'],
missingOptionalFields: ['softFactors'],
lastVerifiedAt: '2025-03-20',
freshness: DataFreshness.STALE,
warnings: ['Daten aus Drittquelle nicht verifiziert'],
},
riskLevel: RiskLevel.MEDIUM,
createdAt: '2025-03-01T10:00:00Z',
updatedAt: '2025-03-20T15:00:00Z',
},
// --- FUTURE_AVAILABILITY ---
{
id: 'prop-005',
title: 'Bürofläche Technoparkstrasse (Signal: Expansion)',
assetType: AssetType.OFFICE,
resultType: ResultType.FUTURE_AVAILABILITY,
location: { city: 'Zürich', district: 'Technopark', canton: 'ZH', country: 'CH', coordinates: { lat: 47.3923, lng: 8.5142 } },
address: { street: 'Technoparkstrasse', houseNumber: '1', postalCode: '8005', city: 'Zürich', country: 'CH' },
areaSqm: 600,
rentPricePerSqm: 42,
availabilityDate: '2026-03-01',
availabilityStatus: AvailabilityStatus.FUTURE_SIGNAL,
sourceType: 'AI_SIGNAL',
confidenceScore: 0.55,
dataQuality: {
score: 0.38,
missingCriticalFields: ['totalRentMonthly', 'contractDurationMonths'],
missingOptionalFields: ['ancillaryCosts', 'floorLevel', 'parkingSpots'],
freshness: DataFreshness.FRESH,
warnings: ['Probabilistisches Signal kein bestätigtes Objekt', 'Daten nicht verifiziert'],
},
riskLevel: RiskLevel.HIGH,
createdAt: '2025-05-01T07:00:00Z',
updatedAt: '2025-05-10T07:00:00Z',
},
{
id: 'prop-006',
title: 'Produktionsfläche Reinach (Signal: möglicher Auszug)',
assetType: AssetType.PRODUCTION,
resultType: ResultType.FUTURE_AVAILABILITY,
location: { city: 'Reinach', district: 'Industriezone Nord', canton: 'BL', country: 'CH', coordinates: { lat: 47.4978, lng: 7.5933 } },
address: { street: 'Industriestrasse', houseNumber: '18', postalCode: '4153', city: 'Reinach', country: 'CH' },
areaSqm: 3200,
rentPricePerSqm: 11,
availabilityDate: '2026-06-01',
availabilityStatus: AvailabilityStatus.FUTURE_SIGNAL,
sourceType: 'AI_SIGNAL',
confidenceScore: 0.48,
dataQuality: {
score: 0.30,
missingCriticalFields: ['totalRentMonthly', 'rentPricePerSqm', 'contractDurationMonths'],
missingOptionalFields: ['ancillaryCosts', 'softFactors'],
freshness: DataFreshness.FRESH,
warnings: ['Probabilistisches Signal kein bestätigtes Objekt', 'Mietpreis geschätzt'],
},
riskLevel: RiskLevel.HIGH,
createdAt: '2025-05-03T08:00:00Z',
updatedAt: '2025-05-10T08:00:00Z',
},
]
+43
View File
@@ -0,0 +1,43 @@
import { Container, Typography, Button, Card, CardContent, Chip } from '@mui/material'
import HomeIcon from '@mui/icons-material/Home'
import SearchIcon from '@mui/icons-material/Search'
export default function Home() {
return (
<Container maxWidth="lg" className="py-12">
<div className="mb-8 text-center">
<Typography variant="h3" component="h1" className="font-bold text-gray-800">
Property Match
</Typography>
<Typography variant="subtitle1" className="mt-2 text-gray-500">
Find your ideal property
</Typography>
</div>
<div className="flex items-center gap-4 mb-10 max-w-2xl mx-auto">
<Button variant="contained" size="large" startIcon={<SearchIcon />}>
Search
</Button>
<Button variant="outlined" size="large" startIcon={<HomeIcon />}>
Browse
</Button>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{(['Buy', 'Rent', 'Invest'] as const).map((category) => (
<Card key={category} elevation={2} className="hover:shadow-lg transition-shadow duration-200">
<CardContent className="p-6">
<Chip label={category} color="primary" size="small" className="mb-3" />
<Typography variant="h6" className="font-semibold mb-2">
{category} a Property
</Typography>
<Typography variant="body2" className="text-gray-500">
Browse listings available for {category.toLowerCase()} in your area.
</Typography>
</CardContent>
</Card>
))}
</div>
</Container>
)
}
+338
View File
@@ -0,0 +1,338 @@
import { useState } from 'react'
import {
Box,
Button,
Card,
Chip,
TextField,
Typography,
CircularProgress,
Alert,
Stack,
Divider,
} from '@mui/material'
import { Sparkles, ArrowRight } from 'lucide-react'
import { useNavigate } from 'react-router'
import { MockupAIServiceProvider, type CriteriaExtractionResult } from '../../services/aiService'
type Step = 'input' | 'extracting' | 'review' | 'done'
const EXAMPLE_QUERIES = [
'Büro Zürich 500-800m²',
'Logistik Basel 2000m²',
'Retail Bern Innenstadt',
]
function ConfidenceColor(score: number): string {
if (score >= 0.8) return '#1a7a4a'
if (score >= 0.6) return '#d97706'
return '#c0392b'
}
export default function AISearch() {
const navigate = useNavigate()
const [step, setStep] = useState<Step>('input')
const [inputText, setInputText] = useState('')
const [extractedCriteria, setExtractedCriteria] = useState<CriteriaExtractionResult | null>(null)
const [followUpAnswers, setFollowUpAnswers] = useState<Record<number, string>>({})
const handleAnalyze = async () => {
setStep('extracting')
await new Promise(r => setTimeout(r, 1500))
const result = await MockupAIServiceProvider.extractCriteria(inputText)
setExtractedCriteria(result)
setStep('review')
}
const handleStartSearch = () => {
navigate('/demand/results', { state: { needId: 'need-001' } })
}
return (
<Box>
{/* Page Header */}
<Box sx={{ bgcolor: 'white', borderBottom: '1px solid #e2e8f0', px: 3, py: 2 }}>
<Typography variant="h5" fontWeight={700} color="text.primary">
AI Bedarfsanalyse
</Typography>
<Typography variant="body2" color="text.secondary">
Beschreiben Sie Ihren Flächenbedarf in natürlicher Sprache
</Typography>
</Box>
<Box sx={{ px: 3, py: 3 }}>
{/* Step 1: Input */}
{step === 'input' && (
<Box>
<Card sx={{ maxWidth: 680, mx: 'auto', p: 3 }}>
<Typography variant="subtitle1" fontWeight={600} mb={2}>
Flächenbedarf beschreiben
</Typography>
<TextField
multiline
rows={5}
fullWidth
placeholder="Beispiel: Wir suchen 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={inputText}
onChange={e => setInputText(e.target.value)}
inputProps={{ maxLength: 2000 }}
sx={{ mb: 1 }}
/>
<Typography variant="caption" color="text.secondary" display="block" textAlign="right" mb={2}>
{inputText.length}/2000
</Typography>
<Button
variant="contained"
fullWidth
disabled={inputText.length < 20}
onClick={handleAnalyze}
endIcon={<Sparkles size={16} />}
sx={{ bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#162d4a' }, mb: 2 }}
>
Analysieren
</Button>
<Typography variant="caption" color="text.secondary" display="block" textAlign="center">
Die KI extrahiert automatisch Kriterien, Standortpräferenzen und Budget aus Ihrer Beschreibung.
</Typography>
</Card>
{/* Example Queries */}
<Box sx={{ maxWidth: 680, mx: 'auto', mt: 2 }}>
<Typography variant="caption" color="text.secondary" display="block" mb={1}>
Beispiele:
</Typography>
<Stack direction="row" spacing={1} flexWrap="wrap" gap={1}>
{EXAMPLE_QUERIES.map(q => (
<Chip
key={q}
label={q}
size="small"
variant="outlined"
clickable
onClick={() => setInputText(q)}
sx={{ cursor: 'pointer' }}
/>
))}
</Stack>
</Box>
</Box>
)}
{/* Step 2: Extracting */}
{step === 'extracting' && (
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', py: 12, gap: 3 }}>
<CircularProgress size={48} sx={{ color: '#1e3a5f' }} />
<Typography variant="h6" color="text.secondary">
KI analysiert Ihren Bedarf...
</Typography>
</Box>
)}
{/* Step 3: Review */}
{step === 'review' && extractedCriteria && (
<Box>
<Box className="grid grid-cols-2 gap-4" sx={{ mb: 3 }}>
{/* Left: Extracted Criteria */}
<Card sx={{ p: 3 }}>
<Typography variant="subtitle1" fontWeight={600} mb={2}>
Extrahierte Kriterien
</Typography>
<Stack spacing={2}>
{/* Confidence badge */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
<Typography variant="caption" color="text.secondary">Gesamtkonfidenz:</Typography>
<Chip
label={`${Math.round(extractedCriteria.confidence * 100)}%`}
size="small"
sx={{
bgcolor: ConfidenceColor(extractedCriteria.confidence),
color: 'white',
fontWeight: 700,
}}
/>
</Box>
<Divider />
{/* Criteria items */}
{extractedCriteria.extractedCriteria.requiredArea && (
<Box>
<Typography
variant="caption"
fontWeight={600}
sx={{ color: ConfidenceColor(extractedCriteria.confidence) }}
>
Flächenbedarf
</Typography>
<Typography variant="body2">
{extractedCriteria.extractedCriteria.requiredArea.min}
{extractedCriteria.extractedCriteria.requiredArea.max} m²
</Typography>
</Box>
)}
{extractedCriteria.extractedCriteria.budgetRange && (
<Box>
<Typography
variant="caption"
fontWeight={600}
sx={{ color: ConfidenceColor(extractedCriteria.confidence) }}
>
Budget
</Typography>
<Typography variant="body2">
max. {extractedCriteria.extractedCriteria.budgetRange.maxPerSqm}{' '}
{extractedCriteria.extractedCriteria.budgetRange.currency}/m²
</Typography>
</Box>
)}
{extractedCriteria.extractedCriteria.companyName && (
<Box>
<Typography
variant="caption"
fontWeight={600}
sx={{ color: ConfidenceColor(0.5) }}
>
Unternehmen
</Typography>
<Typography variant="body2">
{extractedCriteria.extractedCriteria.companyName}
</Typography>
</Box>
)}
{extractedCriteria.extractedCriteria.preferredLocations && extractedCriteria.extractedCriteria.preferredLocations.length > 0 && (
<Box>
<Typography
variant="caption"
fontWeight={600}
sx={{ color: ConfidenceColor(0.85) }}
>
Standort
</Typography>
<Typography variant="body2">
{extractedCriteria.extractedCriteria.preferredLocations.join(', ')}
</Typography>
</Box>
)}
{extractedCriteria.extractedCriteria.timing && (
<Box>
<Typography
variant="caption"
fontWeight={600}
sx={{ color: ConfidenceColor(0.85) }}
>
Verfügbarkeit
</Typography>
<Typography variant="body2">
ab {extractedCriteria.extractedCriteria.timing.earliestMoveIn}
</Typography>
</Box>
)}
{extractedCriteria.extractedCriteria.assetType && (
<Box>
<Typography
variant="caption"
fontWeight={600}
sx={{ color: ConfidenceColor(0.85) }}
>
Objekttyp
</Typography>
<Typography variant="body2">
{extractedCriteria.extractedCriteria.assetType}
</Typography>
</Box>
)}
</Stack>
{/* Assumptions */}
{extractedCriteria.assumptions.length > 0 && (
<Box mt={3}>
<Typography variant="caption" fontWeight={600} color="text.secondary" display="block" mb={1}>
Annahmen der KI
</Typography>
<Stack spacing={0.5}>
{extractedCriteria.assumptions.map((a, i) => (
<Alert key={i} severity="warning" sx={{ py: 0, px: 1, '& .MuiAlert-message': { fontSize: 12 } }}>
{a}
</Alert>
))}
</Stack>
</Box>
)}
{/* Missing Fields */}
{extractedCriteria.missingFields.length > 0 && (
<Box mt={2}>
<Typography variant="caption" fontWeight={600} color="text.secondary" display="block" mb={1}>
Fehlende Informationen
</Typography>
<Stack direction="row" spacing={0.5} flexWrap="wrap" gap={0.5}>
{extractedCriteria.missingFields.map(f => (
<Chip
key={f}
label={f}
size="small"
color="warning"
variant="outlined"
/>
))}
</Stack>
</Box>
)}
</Card>
{/* Right: Follow-up Questions */}
<Card sx={{ p: 3 }}>
<Typography variant="subtitle1" fontWeight={600} mb={0.5}>
Rückfragen der KI
</Typography>
<Typography variant="caption" color="text.secondary" display="block" mb={2}>
Diese Fragen sind optional verbessern jedoch die Trefferqualität.
</Typography>
<Stack spacing={3}>
{extractedCriteria.followUpQuestions.map((q, i) => (
<Box key={i}>
<Typography variant="body2" fontWeight={500} mb={1}>
{i + 1}. {q}
</Typography>
<TextField
size="small"
fullWidth
placeholder="Ihre Antwort (optional)"
value={followUpAnswers[i] ?? ''}
onChange={e =>
setFollowUpAnswers(prev => ({ ...prev, [i]: e.target.value }))
}
/>
</Box>
))}
</Stack>
</Card>
</Box>
{/* Footer Actions */}
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 2 }}>
<Button variant="outlined" onClick={() => setStep('input')}>
Zurück
</Button>
<Button
variant="contained"
onClick={handleStartSearch}
endIcon={<ArrowRight size={16} />}
sx={{ bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#162d4a' } }}
>
Suche starten
</Button>
</Box>
</Box>
)}
</Box>
</Box>
)
}
+320
View File
@@ -0,0 +1,320 @@
import {
Box,
Button,
Card,
Chip,
Typography,
LinearProgress,
Table,
TableBody,
TableCell,
TableHead,
TableRow,
CircularProgress,
Stack,
} from '@mui/material'
import { X } from 'lucide-react'
import { useQuery } from '@tanstack/react-query'
import { useNavigate } from 'react-router'
import { propertyService } from '../../services/propertyService'
import { useCompareStore } from '../../stores/compareStore'
import { EmptyState } from '../../components/ui'
import { ResultType, RiskLevel } from '../../domain/enums'
import type { Property } from '../../domain/property'
function getResultTypeLabel(type: ResultType): string {
switch (type) {
case ResultType.VERIFIED_PORTFOLIO: return 'Verified Portfolio'
case ResultType.EXTERNAL_MARKET: return 'Marktinserat'
case ResultType.FUTURE_AVAILABILITY: return 'Zukunftssignal'
}
}
function getResultTypeColor(type: ResultType): string {
switch (type) {
case ResultType.VERIFIED_PORTFOLIO: return '#1e3a5f'
case ResultType.EXTERNAL_MARKET: return '#d97706'
case ResultType.FUTURE_AVAILABILITY: return '#7c3aed'
}
}
function getRiskColor(risk?: RiskLevel): 'success' | 'warning' | 'error' | 'default' {
if (!risk) return 'default'
if (risk === RiskLevel.LOW) return 'success'
if (risk === RiskLevel.MEDIUM) return 'warning'
return 'error'
}
function getRiskLabel(risk?: RiskLevel): string {
if (!risk) return ''
switch (risk) {
case RiskLevel.LOW: return 'Niedrig'
case RiskLevel.MEDIUM: return 'Mittel'
case RiskLevel.HIGH: return 'Hoch'
case RiskLevel.CRITICAL: return 'Kritisch'
}
}
interface CompareRow {
label: string
getValue: (p: Property) => string | number | null
format?: (v: string | number | null, p: Property) => React.ReactNode
isHigherBetter?: boolean
isLowerBetter?: boolean
}
function NumericCell({ value, isBest }: { value: React.ReactNode; isBest: boolean }) {
return (
<TableCell
sx={{
bgcolor: isBest ? '#f0fdf4' : 'transparent',
fontWeight: isBest ? 700 : 400,
color: isBest ? '#1a7a4a' : 'inherit',
borderLeft: '1px solid #f1f5f9',
}}
>
{value}
</TableCell>
)
}
export default function Compare() {
const navigate = useNavigate()
const { compareTray, removeFromCompare, clearCompare } = useCompareStore()
const { data: propResp, isLoading } = useQuery({
queryKey: ['properties'],
queryFn: () => propertyService.getAll(),
})
const properties = propResp?.data ?? []
const compareProperties = properties.filter(p => compareTray.includes(p.id))
// Keep ordering same as tray
const orderedProperties = compareTray
.map(id => compareProperties.find(p => p.id === id))
.filter((p): p is Property => p !== null)
if (isLoading) {
return (
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', py: 12 }}>
<CircularProgress />
</Box>
)
}
if (compareTray.length === 0) {
return (
<Box>
<Box sx={{ bgcolor: 'white', borderBottom: '1px solid #e2e8f0', px: 3, py: 2 }}>
<Typography variant="h5" fontWeight={700}>Vergleich</Typography>
</Box>
<Box sx={{ px: 3, py: 3 }}>
<EmptyState
title="Keine Objekte zum Vergleich"
description="Fügen Sie Objekte aus den Suchergebnissen zum Vergleich hinzu."
action={{ label: 'Zur Suche', onClick: () => navigate('/demand/results') }}
/>
</Box>
</Box>
)
}
const rows: CompareRow[] = [
{
label: 'Fläche (m²)',
getValue: p => p.areaSqm,
isHigherBetter: false,
},
{
label: 'Miete/m² (CHF)',
getValue: p => p.rentPricePerSqm,
isLowerBetter: true,
},
{
label: 'Gesamtmiete/Monat (CHF)',
getValue: p => p.totalRentMonthly ?? null,
format: (v) => v != null ? `${Number(v).toLocaleString('de-CH')} CHF` : <em style={{ color: '#94a3b8' }}></em>,
isLowerBetter: true,
},
{
label: 'Verfügbarkeit',
getValue: p => p.availabilityDate,
format: (v) => v ?? <em style={{ color: '#94a3b8' }}></em>,
},
{
label: 'Standort',
getValue: p => `${p.location.city}${p.location.district ? ', ' + p.location.district : ''}`,
},
{
label: 'Datenqualität',
getValue: p => p.dataQuality.score,
format: (v, p) => (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Box sx={{ width: 80 }}>
<LinearProgress
variant="determinate"
value={p.dataQuality.score * 100}
sx={{ height: 6, borderRadius: 3 }}
color={p.dataQuality.score >= 0.8 ? 'success' : p.dataQuality.score >= 0.6 ? 'warning' : 'error'}
/>
</Box>
<Typography variant="caption">{Math.round(p.dataQuality.score * 100)}%</Typography>
</Box>
),
isHigherBetter: true,
},
{
label: 'Konfidenz',
getValue: p => p.confidenceScore,
format: (v) => v != null ? `${Math.round(Number(v) * 100)}%` : <em style={{ color: '#94a3b8' }}></em>,
isHigherBetter: true,
},
{
label: 'Risiko',
getValue: p => p.riskLevel ?? null,
format: (v, p) => (
<Chip
label={getRiskLabel(p.riskLevel)}
size="small"
color={getRiskColor(p.riskLevel)}
variant="outlined"
/>
),
},
{
label: 'Prestige',
getValue: p => p.softFactors?.prestige ?? null,
format: (v) => v != null ? String(v) : <em style={{ color: '#94a3b8' }}></em>,
isHigherBetter: true,
},
{
label: 'Erreichbarkeit',
getValue: p => p.softFactors?.accessibility ?? null,
format: (v) => v != null ? String(v) : <em style={{ color: '#94a3b8' }}></em>,
isHigherBetter: true,
},
{
label: 'ÖV-Minuten',
getValue: p => p.softFactors?.publicTransportMinutes ?? null,
format: (v) => v != null ? `${v} Min.` : <em style={{ color: '#94a3b8' }}></em>,
isLowerBetter: true,
},
{
label: 'Fehlende Pflichtfelder',
getValue: p => p.dataQuality.missingCriticalFields.length,
format: (v) => v != null ? String(v) : <em style={{ color: '#94a3b8' }}></em>,
isLowerBetter: true,
},
]
return (
<Box>
{/* Page Header */}
<Box sx={{ bgcolor: 'white', borderBottom: '1px solid #e2e8f0', px: 3, py: 2, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<Typography variant="h5" fontWeight={700}>Vergleich</Typography>
<Chip label={`${orderedProperties.length} Objekte`} size="small" />
</Box>
<Button variant="outlined" size="small" color="error" onClick={clearCompare}>
Leeren
</Button>
</Box>
<Box sx={{ px: 3, py: 3 }}>
<Card sx={{ overflowX: 'auto' }}>
<Table>
<TableHead>
<TableRow sx={{ bgcolor: '#f8fafc' }}>
<TableCell sx={{ width: 180, fontWeight: 600, color: '#64748b', fontSize: 12 }}>
Kriterium
</TableCell>
{orderedProperties.map(p => (
<TableCell key={p.id} sx={{ borderLeft: '1px solid #f1f5f9', minWidth: 220 }}>
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 1 }}>
<Box>
<Typography variant="body2" fontWeight={600}>{p.title}</Typography>
<Chip
label={getResultTypeLabel(p.resultType)}
size="small"
sx={{
bgcolor: getResultTypeColor(p.resultType),
color: 'white',
fontSize: 10,
mt: 0.5,
}}
/>
</Box>
<Button
size="small"
sx={{ minWidth: 'auto', p: 0.5 }}
onClick={() => removeFromCompare(p.id)}
>
<X size={14} />
</Button>
</Box>
</TableCell>
))}
</TableRow>
</TableHead>
<TableBody>
{rows.map(row => {
const values = orderedProperties.map(p => row.getValue(p))
const numericValues = values
.map((v, i) => ({ v, i }))
.filter(x => x.v != null && typeof x.v === 'number') as { v: number; i: number }[]
let bestIdx = -1
if (numericValues.length > 1) {
if (row.isHigherBetter) {
bestIdx = numericValues.reduce((best, cur) => cur.v > best.v ? cur : best).i
} else if (row.isLowerBetter) {
bestIdx = numericValues.reduce((best, cur) => cur.v < best.v ? cur : best).i
}
}
return (
<TableRow key={row.label} hover>
<TableCell sx={{ color: '#64748b', fontSize: 13, fontWeight: 500 }}>
{row.label}
</TableCell>
{orderedProperties.map((p, idx) => {
const raw = row.getValue(p)
const displayValue = row.format
? row.format(raw, p)
: raw != null
? String(raw)
: <em style={{ color: '#94a3b8' }}></em>
const isBest = bestIdx === idx
return (
<NumericCell key={p.id} value={displayValue} isBest={isBest} />
)
})}
</TableRow>
)
})}
</TableBody>
</Table>
</Card>
{/* Add more prompt */}
{orderedProperties.length < 3 && (
<Card sx={{ p: 2.5, mt: 2, border: '2px dashed #e2e8f0', boxShadow: 'none' }}>
<Stack direction="row" alignItems="center" justifyContent="space-between">
<Box>
<Typography variant="subtitle2" fontWeight={600}>Weiteres Objekt hinzufügen</Typography>
<Typography variant="caption" color="text.secondary">
Bis zu {3 - orderedProperties.length} weitere{orderedProperties.length < 2 ? 's' : ''} Objekt{orderedProperties.length < 2 ? '' : 'e'} möglich
</Typography>
</Box>
<Button variant="outlined" size="small" onClick={() => navigate('/demand/results')}>
Zur Suche
</Button>
</Stack>
</Card>
)}
</Box>
</Box>
)
}
+476
View File
@@ -0,0 +1,476 @@
import { useState } from 'react'
import {
Box,
Button,
Card,
Chip,
Typography,
LinearProgress,
Stack,
Alert,
CircularProgress,
Divider,
} from '@mui/material'
import {
MapPin,
Maximize2,
Banknote,
Calendar,
Bookmark,
Columns2,
} from 'lucide-react'
import { useQuery } from '@tanstack/react-query'
import { useNavigate, useLocation } from 'react-router'
import { propertyService } from '../../services/propertyService'
import { matchService } from '../../services/matchService'
import { needService } from '../../services/needService'
import { ResultType, MatchStrength, RiskLevel } from '../../domain/enums'
import type { Property } from '../../domain/property'
import type { Match } from '../../domain/match'
import { useCompareStore } from '../../stores/compareStore'
import { EmptyState } from '../../components/ui'
type FilterSource = ResultType | 'ALL'
type SortBy = 'score' | 'rent' | 'area'
function getResultTypeLabel(type: ResultType): string {
switch (type) {
case ResultType.VERIFIED_PORTFOLIO: return 'Verified Portfolio'
case ResultType.EXTERNAL_MARKET: return 'Marktinserat'
case ResultType.FUTURE_AVAILABILITY: return 'Zukunftssignal'
}
}
function getResultTypeColor(type: ResultType): string {
switch (type) {
case ResultType.VERIFIED_PORTFOLIO: return '#1e3a5f'
case ResultType.EXTERNAL_MARKET: return '#d97706'
case ResultType.FUTURE_AVAILABILITY: return '#7c3aed'
}
}
function getMatchStrengthColor(strength: MatchStrength): string {
switch (strength) {
case MatchStrength.STRONG: return '#1a7a4a'
case MatchStrength.MODERATE: return '#d97706'
case MatchStrength.WEAK: return '#c0392b'
}
}
function getRiskChipColor(risk: RiskLevel): 'success' | 'warning' | 'error' {
if (risk === RiskLevel.LOW) return 'success'
if (risk === RiskLevel.MEDIUM) return 'warning'
return 'error'
}
function getRiskLabel(risk: RiskLevel): string {
switch (risk) {
case RiskLevel.LOW: return 'Niedriges Risiko'
case RiskLevel.MEDIUM: return 'Mittleres Risiko'
case RiskLevel.HIGH: return 'Hohes Risiko'
case RiskLevel.CRITICAL: return 'Kritisches Risiko'
}
}
interface ResultCardProps {
property: Property
match: Match
onCompare: (id: string) => void
isInCompare: boolean
}
function ResultCard({ property, match, onCompare, isInCompare }: ResultCardProps) {
const scoreColor = getMatchStrengthColor(match.matchStrength)
return (
<Card sx={{ p: 2.5, mb: 1.5 }}>
{/* Future signal warning */}
{property.resultType === ResultType.FUTURE_AVAILABILITY && (
<Alert severity="warning" sx={{ mb: 1.5, py: 0.5 }}>
Probabilistisches Signal kein bestätigtes Objekt
</Alert>
)}
{/* Header row */}
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', mb: 1.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
<Chip
label={getResultTypeLabel(property.resultType)}
size="small"
sx={{
bgcolor: getResultTypeColor(property.resultType),
color: 'white',
fontWeight: 600,
fontSize: 11,
}}
/>
<Typography variant="h6" fontWeight={600}>
{property.title}
</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'baseline', gap: 0.5 }}>
<Typography variant="h4" fontWeight={800} sx={{ color: scoreColor }}>
{match.matchScore}
</Typography>
<Typography variant="body2" color="text.secondary">/100</Typography>
</Box>
</Box>
{/* Property details row */}
<Stack direction="row" spacing={2.5} sx={{ mb: 1.5 }} flexWrap="wrap">
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<MapPin size={14} color="#64748b" />
<Typography variant="body2" color="text.secondary">
{property.location.city}
{property.location.district ? `, ${property.location.district}` : ''}
</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<Maximize2 size={14} color="#64748b" />
<Typography variant="body2" color="text.secondary">
{property.areaSqm} m²
</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<Banknote size={14} color="#64748b" />
<Typography variant="body2" color="text.secondary">
CHF {property.rentPricePerSqm}/m²
</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<Calendar size={14} color="#64748b" />
<Typography variant="body2" color="text.secondary">
{property.availabilityDate}
</Typography>
</Box>
</Stack>
{/* Match factors section */}
<Divider sx={{ mb: 1.5 }} />
{match.positiveFactors.length > 0 && (
<Box sx={{ mb: 1 }}>
<Typography variant="caption" fontWeight={600} color="text.secondary" display="block" mb={0.5}>
Positive Faktoren
</Typography>
<Stack direction="row" spacing={0.5} flexWrap="wrap" gap={0.5}>
{match.positiveFactors.slice(0, 3).map((f, i) => (
<Chip
key={i}
label={f.criterion}
size="small"
sx={{ bgcolor: '#f0fdf4', color: '#1a7a4a', border: '1px solid #bbf7d0', fontSize: 11 }}
/>
))}
</Stack>
</Box>
)}
{match.tradeoffs.length > 0 && (
<Alert severity="warning" sx={{ py: 0.5, px: 1.5, mb: 1, '& .MuiAlert-message': { fontSize: 12 } }}>
<Typography variant="caption" fontWeight={600} display="block" mb={0.5}>Abwägungen</Typography>
{match.tradeoffs.slice(0, 2).map((t, i) => (
<Typography key={i} variant="caption" display="block">
{t.criterion}: {t.concern}
</Typography>
))}
</Alert>
)}
{/* Confidence + Quality row */}
<Stack direction="row" spacing={2.5} alignItems="center" sx={{ mb: 1.5 }} flexWrap="wrap">
<Typography variant="caption">
<span style={{ color: '#64748b' }}>Konfidenz </span>
<strong style={{ color: match.confidenceLevel >= 0.8 ? '#1a7a4a' : match.confidenceLevel >= 0.6 ? '#d97706' : '#c0392b' }}>
{Math.round(match.confidenceLevel * 100)}%
</strong>
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant="caption" color="text.secondary">Datenqualität</Typography>
<Box sx={{ width: 80 }}>
<LinearProgress
variant="determinate"
value={property.dataQuality.score * 100}
sx={{ height: 6, borderRadius: 3 }}
color={
property.dataQuality.score >= 0.8 ? 'success' :
property.dataQuality.score >= 0.6 ? 'warning' : 'error'
}
/>
</Box>
<Typography variant="caption" color="text.secondary">
{Math.round(property.dataQuality.score * 100)}%
</Typography>
</Box>
{property.riskLevel && (
<Chip
label={getRiskLabel(property.riskLevel)}
size="small"
color={getRiskChipColor(property.riskLevel)}
variant="outlined"
/>
)}
</Stack>
{/* Actions row */}
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 1 }}>
<Button
variant="outlined"
size="small"
startIcon={<Bookmark size={14} />}
disabled
>
Shortlist
</Button>
<Button
variant={isInCompare ? 'contained' : 'outlined'}
size="small"
startIcon={<Columns2 size={14} />}
onClick={() => onCompare(property.id)}
sx={isInCompare ? { bgcolor: '#1e3a5f' } : {}}
>
Vergleichen
</Button>
<Button
variant="contained"
size="small"
disabled
sx={{ bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#162d4a' } }}
>
Details
</Button>
</Box>
</Card>
)
}
export default function Results() {
const navigate = useNavigate()
const location = useLocation()
const _needId = (location.state as { needId?: string } | null)?.needId ?? 'need-001'
const [filterSource, setFilterSource] = useState<FilterSource>('ALL')
const [sortBy, setSortBy] = useState<SortBy>('score')
const { data: propResp, isLoading: propLoading } = useQuery({
queryKey: ['properties'],
queryFn: () => propertyService.getAll(),
})
const { data: matchResp, isLoading: matchLoading } = useQuery({
queryKey: ['matches'],
queryFn: () => matchService.getAll(),
})
const { data: needResp, isLoading: needLoading } = useQuery({
queryKey: ['needs'],
queryFn: () => needService.getAll(),
})
const { addToCompare, removeFromCompare, clearCompare, isInCompare, compareTray } = useCompareStore()
const properties = propResp?.data ?? []
const matches = matchResp?.data ?? []
const needs = needResp?.data ?? []
const activeNeed = needs[0]
const isLoading = propLoading || matchLoading || needLoading
// Join matches with properties
const resultItems = matches
.map(match => {
const property = properties.find(p => p.id === match.propertyId)
return property ? { match, property } : null
})
.filter((item): item is { match: Match; property: Property } => item !== null)
// Filter by source
const filtered = filterSource === 'ALL'
? resultItems
: resultItems.filter(item => item.property.resultType === filterSource)
// Sort
const sorted = [...filtered].sort((a, b) => {
if (sortBy === 'score') return b.match.matchScore - a.match.matchScore
if (sortBy === 'rent') return a.property.rentPricePerSqm - b.property.rentPricePerSqm
if (sortBy === 'area') return b.property.areaSqm - a.property.areaSqm
return 0
})
const verifiedCount = resultItems.filter(i => i.property.resultType === ResultType.VERIFIED_PORTFOLIO).length
const externalCount = resultItems.filter(i => i.property.resultType === ResultType.EXTERNAL_MARKET).length
const futureCount = resultItems.filter(i => i.property.resultType === ResultType.FUTURE_AVAILABILITY).length
const handleToggleCompare = (id: string) => {
if (isInCompare(id)) {
removeFromCompare(id)
} else {
addToCompare(id)
}
}
if (isLoading) {
return (
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', py: 12 }}>
<CircularProgress />
</Box>
)
}
return (
<Box>
{/* Page Header */}
<Box sx={{ bgcolor: 'white', borderBottom: '1px solid #e2e8f0', px: 3, py: 2, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Box>
<Typography variant="h5" fontWeight={700} color="text.primary">
{sorted.length} Treffer gefunden
</Typography>
<Typography variant="body2" color="text.secondary">
{verifiedCount} Verified · {externalCount} Extern · {futureCount} Signale
</Typography>
</Box>
</Box>
<Box sx={{ px: 3, py: 3 }}>
{/* Active Need Banner */}
{activeNeed && (
<Card sx={{ bgcolor: '#eff6ff', p: 2, mb: 2, border: '1px solid #bfdbfe' }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Box>
<Typography variant="subtitle2" fontWeight={600} color="#1e3a5f">
Aktive Suche: {activeNeed.companyName}
</Typography>
<Typography variant="caption" color="text.secondary">
{activeNeed.assetType} · {activeNeed.requiredArea.min}{activeNeed.requiredArea.max} m² ·{' '}
{activeNeed.preferredLocations.join(', ')}
</Typography>
</Box>
<Button
size="small"
variant="text"
onClick={() => navigate('/demand/ai-search')}
sx={{ color: '#1e3a5f' }}
>
Suche ändern
</Button>
</Box>
</Card>
)}
{/* Filter/Sort bar */}
<Card sx={{ p: 1.5, mb: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: 1 }}>
<Stack direction="row" spacing={0.5} flexWrap="wrap" gap={0.5}>
{(['ALL', ResultType.VERIFIED_PORTFOLIO, ResultType.EXTERNAL_MARKET, ResultType.FUTURE_AVAILABILITY] as FilterSource[]).map(source => {
const labels: Record<FilterSource, string> = {
ALL: 'Alle',
[ResultType.VERIFIED_PORTFOLIO]: 'Verified Portfolio',
[ResultType.EXTERNAL_MARKET]: 'Marktinserate',
[ResultType.FUTURE_AVAILABILITY]: 'Zukunftssignale',
}
const colors: Partial<Record<FilterSource, string>> = {
[ResultType.VERIFIED_PORTFOLIO]: '#1e3a5f',
[ResultType.EXTERNAL_MARKET]: '#d97706',
[ResultType.FUTURE_AVAILABILITY]: '#7c3aed',
}
const isActive = filterSource === source
return (
<Chip
key={source}
label={labels[source]}
size="small"
clickable
onClick={() => setFilterSource(source)}
sx={{
bgcolor: isActive ? (colors[source] ?? '#1e3a5f') : 'transparent',
color: isActive ? 'white' : 'text.secondary',
border: `1px solid ${isActive ? (colors[source] ?? '#1e3a5f') : '#e2e8f0'}`,
fontWeight: isActive ? 600 : 400,
}}
/>
)
})}
</Stack>
<Stack direction="row" spacing={0.5} alignItems="center">
<Typography variant="caption" color="text.secondary">Sortierung:</Typography>
{([['score', 'Relevanz'], ['area', 'Fläche'], ['rent', 'Mietpreis']] as [SortBy, string][]).map(([val, label]) => (
<Chip
key={val}
label={label}
size="small"
clickable
onClick={() => setSortBy(val)}
sx={{
bgcolor: sortBy === val ? '#1e3a5f' : 'transparent',
color: sortBy === val ? 'white' : 'text.secondary',
border: `1px solid ${sortBy === val ? '#1e3a5f' : '#e2e8f0'}`,
}}
/>
))}
</Stack>
</Box>
</Card>
{/* Results */}
{sorted.length === 0 ? (
<EmptyState
title="Keine Treffer gefunden"
description="Passen Sie die Suchkriterien an oder wechseln Sie den Filter."
/>
) : (
sorted.map(({ match, property }) => (
<ResultCard
key={match.id}
property={property}
match={match}
onCompare={handleToggleCompare}
isInCompare={isInCompare(property.id)}
/>
))
)}
</Box>
{/* Compare Tray */}
{compareTray.length > 0 && (
<Box
sx={{
position: 'fixed',
bottom: 0,
left: 0,
right: 0,
bgcolor: '#1e3a5f',
color: 'white',
py: 1.5,
px: 3,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
zIndex: 1200,
boxShadow: '0 -4px 12px rgba(0,0,0,0.15)',
}}
>
<Typography variant="body2" fontWeight={600}>
{compareTray.length} Objekte zum Vergleich ausgewählt
</Typography>
<Stack direction="row" spacing={1}>
<Button
size="small"
variant="outlined"
sx={{ color: 'white', borderColor: 'rgba(255,255,255,0.5)' }}
onClick={clearCompare}
>
Leeren
</Button>
<Button
size="small"
variant="contained"
sx={{ bgcolor: 'white', color: '#1e3a5f', '&:hover': { bgcolor: '#f1f5f9' } }}
onClick={() => navigate('/demand/compare')}
>
Vergleich starten
</Button>
</Stack>
</Box>
)}
</Box>
)
}
+174
View File
@@ -0,0 +1,174 @@
import {
Box,
Button,
Card,
Chip,
Typography,
Stack,
Divider,
List,
ListItem,
ListItemText,
} from '@mui/material'
import { Bookmark } from 'lucide-react'
interface MockShortlist {
id: string
title: string
company: string
assetType: string
objectCount: number
createdAt: string
updatedAt: string
properties: string[]
}
const MOCK_SHORTLISTS: MockShortlist[] = [
{
id: 'sl-001',
title: 'Bürosuche Innovatech AG',
company: 'Innovatech AG',
assetType: 'Büro',
objectCount: 2,
createdAt: '01.05.2025',
updatedAt: '08.05.2025',
properties: ['Bürofläche Zollstrasse 12', 'Gemischte Gewerbeeinheit Europaallee'],
},
{
id: 'sl-002',
title: 'Logistik Basel — Schweizer Logistik',
company: 'Schweizer Logistik GmbH',
assetType: 'Logistik',
objectCount: 1,
createdAt: '03.05.2025',
updatedAt: '03.05.2025',
properties: ['Lagerfläche Hardstrasse 44'],
},
]
export default function Shortlists() {
return (
<Box>
{/* Page Header */}
<Box
sx={{
bgcolor: 'white',
borderBottom: '1px solid #e2e8f0',
px: 3,
py: 2,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
}}
>
<Box>
<Typography variant="h5" fontWeight={700} color="text.primary">
Shortlists
</Typography>
<Typography variant="body2" color="text.secondary">
Gespeicherte Objektlisten und Entscheidungsvorlagen
</Typography>
</Box>
<Button
variant="contained"
size="small"
startIcon={<Bookmark size={14} />}
disabled
sx={{ bgcolor: '#1e3a5f' }}
>
Neue Shortlist
</Button>
</Box>
<Box sx={{ px: 3, py: 3 }}>
<Stack spacing={2}>
{MOCK_SHORTLISTS.map(sl => (
<Card key={sl.id} sx={{ p: 2.5 }}>
{/* Card header */}
<Box
sx={{
display: 'flex',
alignItems: 'flex-start',
justifyContent: 'space-between',
mb: 1.5,
}}
>
<Box>
<Typography variant="subtitle1" fontWeight={700}>
{sl.title}
</Typography>
<Typography variant="body2" color="text.secondary">
{sl.company} · {sl.assetType}
</Typography>
</Box>
<Chip
label={`${sl.objectCount} Objekte`}
size="small"
sx={{ bgcolor: '#eff6ff', color: '#1e3a5f', fontWeight: 600 }}
/>
</Box>
{/* Dates */}
<Stack direction="row" spacing={2} sx={{ mb: 1.5 }}>
<Typography variant="caption" color="text.secondary">
Erstellt: {sl.createdAt}
</Typography>
<Typography variant="caption" color="text.secondary">
Zuletzt aktualisiert: {sl.updatedAt}
</Typography>
</Stack>
<Divider sx={{ mb: 1.5 }} />
{/* Property list */}
<List dense disablePadding sx={{ mb: 1.5 }}>
{sl.properties.map((prop, i) => (
<ListItem key={i} disableGutters sx={{ py: 0.25 }}>
<Box
sx={{
width: 6,
height: 6,
borderRadius: '50%',
bgcolor: '#1e3a5f',
mr: 1.5,
flexShrink: 0,
}}
/>
<ListItemText
primary={prop}
primaryTypographyProps={{ variant: 'body2' }}
/>
</ListItem>
))}
</List>
{/* Actions */}
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 1 }}>
<Button variant="outlined" size="small" disabled>
Teilen
</Button>
<Button variant="outlined" size="small" disabled>
Öffnen
</Button>
</Box>
</Card>
))}
{/* Empty shortlist prompt */}
<Card
sx={{
p: 2,
border: '2px dashed #e2e8f0',
boxShadow: 'none',
bgcolor: '#fafafa',
}}
>
<Typography variant="body2" color="text.secondary" textAlign="center">
Objekte aus den Suchergebnissen zur Shortlist hinzufügen
</Typography>
</Card>
</Stack>
</Box>
</Box>
)
}
+280
View File
@@ -0,0 +1,280 @@
import {
Box,
Card,
Chip,
Typography,
LinearProgress,
Stack,
Table,
TableBody,
TableCell,
TableHead,
TableRow,
Alert,
CircularProgress,
} from '@mui/material'
import { useQuery } from '@tanstack/react-query'
import { propertyService } from '../../services/propertyService'
interface MetricCard {
label: string
value: string
color: string
note: string
}
const HEALTH_METRICS: MetricCard[] = [
{ label: 'Extraktionsgenauigkeit', value: '94%', color: '#1a7a4a', note: 'Ø letzte 30 Tage' },
{ label: 'Konfidenz-Ø', value: '73%', color: '#d97706', note: 'Alle Objekte' },
{ label: 'Validierungsrate', value: '88%', color: '#1a7a4a', note: 'Menschliche Bestätigung' },
{ label: 'Fehlerrate', value: '2.1%', color: '#1a7a4a', note: 'Kritische Fehler' },
]
interface AIDecision {
timestamp: string
type: string
confidence: string
result: string
impact: string
}
const RECENT_DECISIONS: AIDecision[] = [
{
timestamp: '15.05.2025 14:32',
type: 'Bedarfsextraktion',
confidence: '91%',
result: 'Kriterien extrahiert',
impact: 'Suche ausgelöst',
},
{
timestamp: '15.05.2025 11:15',
type: 'Match-Scoring',
confidence: '88%',
result: '3 Matches berechnet',
impact: 'Review ausgelöst',
},
{
timestamp: '14.05.2025 16:40',
type: 'Signal-Erkennung',
confidence: '72%',
result: 'Expansion erkannt',
impact: 'Signal erstellt',
},
{
timestamp: '14.05.2025 09:00',
type: 'Datenqualitätsprüfung',
confidence: '95%',
result: '2 Warnungen erkannt',
impact: 'Meldung erstellt',
},
{
timestamp: '13.05.2025 15:22',
type: 'Match-Scoring',
confidence: '84%',
result: '2 Matches berechnet',
impact: 'Review ausgelöst',
},
{
timestamp: '12.05.2025 10:11',
type: 'Signal-Erkennung',
confidence: '65%',
result: 'Möglicher Auszug erkannt',
impact: 'Signal erstellt',
},
]
function getConfidenceBadge(pct: string) {
const n = parseInt(pct)
const color = n >= 85 ? '#1a7a4a' : n >= 70 ? '#d97706' : '#c0392b'
return (
<Chip
label={pct}
size="small"
sx={{ bgcolor: color, color: 'white', fontWeight: 700, fontSize: 11 }}
/>
)
}
export default function AIMonitoring() {
const { data: propResp, isLoading } = useQuery({
queryKey: ['properties'],
queryFn: () => propertyService.getAll(),
})
const properties = propResp?.data ?? []
const highConf = properties.filter(p => p.confidenceScore > 0.85).length
const midConf = properties.filter(p => p.confidenceScore >= 0.65 && p.confidenceScore <= 0.85).length
const lowConf = properties.filter(p => p.confidenceScore < 0.65).length
const total = properties.length || 1
return (
<Box>
{/* Page Header */}
<Box
sx={{
bgcolor: 'white',
borderBottom: '1px solid #e2e8f0',
px: 3,
py: 2,
display: 'flex',
alignItems: 'center',
gap: 2,
}}
>
<Box sx={{ flex: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
<Typography variant="h5" fontWeight={700} color="text.primary">
AI Monitoring
</Typography>
<Chip
label="Live"
size="small"
sx={{
bgcolor: '#1a7a4a',
color: 'white',
fontWeight: 700,
fontSize: 11,
animation: 'pulse 2s ease-in-out infinite',
'@keyframes pulse': {
'0%, 100%': { opacity: 1 },
'50%': { opacity: 0.6 },
},
}}
/>
</Box>
<Typography variant="body2" color="text.secondary">
AI-Layer Gesundheit und Entscheidungsqualität
</Typography>
</Box>
</Box>
<Box sx={{ px: 3, py: 3 }}>
{/* Health Metrics */}
<Box className="grid grid-cols-4 gap-4" sx={{ mb: 3 }}>
{HEALTH_METRICS.map(m => (
<Card key={m.label} sx={{ p: 2.5 }}>
<Typography variant="overline" color="text.secondary" lineHeight={1.4} display="block">
{m.label}
</Typography>
<Typography variant="h3" fontWeight={800} sx={{ color: m.color }}>
{m.value}
</Typography>
<Typography variant="caption" color="text.secondary">
{m.note}
</Typography>
</Card>
))}
</Box>
<Box className="grid grid-cols-2 gap-4" sx={{ mb: 3 }}>
{/* Confidence Distribution */}
<Card sx={{ p: 2.5 }}>
<Typography variant="subtitle1" fontWeight={600} mb={2}>
Konfidenzverteilung
</Typography>
{isLoading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
<CircularProgress size={32} />
</Box>
) : (
<Stack spacing={2}>
<Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.5 }}>
<Typography variant="body2" fontWeight={500}>Hoch (&gt;85%)</Typography>
<Typography variant="body2" color="text.secondary">{highConf} Objekte</Typography>
</Box>
<LinearProgress
variant="determinate"
value={(highConf / total) * 100}
color="success"
sx={{ height: 10, borderRadius: 5 }}
/>
</Box>
<Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.5 }}>
<Typography variant="body2" fontWeight={500}>Mittel (6585%)</Typography>
<Typography variant="body2" color="text.secondary">{midConf} Objekte</Typography>
</Box>
<LinearProgress
variant="determinate"
value={(midConf / total) * 100}
color="warning"
sx={{ height: 10, borderRadius: 5 }}
/>
</Box>
<Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.5 }}>
<Typography variant="body2" fontWeight={500}>Niedrig (&lt;65%)</Typography>
<Typography variant="body2" color="text.secondary">{lowConf} Objekte</Typography>
</Box>
<LinearProgress
variant="determinate"
value={(lowConf / total) * 100}
color="error"
sx={{ height: 10, borderRadius: 5 }}
/>
</Box>
</Stack>
)}
</Card>
{/* Anomaly Alerts */}
<Card sx={{ p: 2.5 }}>
<Typography variant="subtitle1" fontWeight={600} mb={2}>
Anomalien
</Typography>
<Stack spacing={1.5}>
<Alert severity="warning">
Mietpreisangaben für prop-004 weichen von Marktdurchschnitt ab (±31%). Manuelle Prüfung empfohlen.
</Alert>
<Alert severity="success">
Keine kritischen Anomalien erkannt. System läuft stabil.
</Alert>
</Stack>
</Card>
</Box>
{/* Recent AI Decisions */}
<Card>
<Box sx={{ px: 2.5, py: 2, borderBottom: '1px solid #f1f5f9' }}>
<Typography variant="subtitle1" fontWeight={600}>
Letzte KI-Entscheidungen
</Typography>
</Box>
<Table>
<TableHead>
<TableRow sx={{ bgcolor: '#f8fafc' }}>
<TableCell sx={{ fontSize: 12, fontWeight: 600, color: '#64748b' }}>Zeitpunkt</TableCell>
<TableCell sx={{ fontSize: 12, fontWeight: 600, color: '#64748b' }}>Entscheidungstyp</TableCell>
<TableCell sx={{ fontSize: 12, fontWeight: 600, color: '#64748b' }}>Konfidenz</TableCell>
<TableCell sx={{ fontSize: 12, fontWeight: 600, color: '#64748b' }}>Ergebnis</TableCell>
<TableCell sx={{ fontSize: 12, fontWeight: 600, color: '#64748b' }}>Einfluss</TableCell>
</TableRow>
</TableHead>
<TableBody>
{RECENT_DECISIONS.map((d, i) => (
<TableRow key={i} hover>
<TableCell>
<Typography variant="caption" color="text.secondary">{d.timestamp}</Typography>
</TableCell>
<TableCell>
<Typography variant="body2" fontWeight={500}>{d.type}</Typography>
</TableCell>
<TableCell>{getConfidenceBadge(d.confidence)}</TableCell>
<TableCell>
<Typography variant="body2">{d.result}</Typography>
</TableCell>
<TableCell>
<Typography variant="body2" color="text.secondary">{d.impact}</Typography>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Card>
</Box>
</Box>
)
}
+311
View File
@@ -0,0 +1,311 @@
import { useState } from 'react'
import {
Box,
Button,
Card,
Chip,
Typography,
Stack,
CircularProgress,
} from '@mui/material'
import {
Building2,
Edit,
CheckCircle,
XCircle,
TrendingUp,
Search,
ClipboardList,
} from 'lucide-react'
import { useQuery } from '@tanstack/react-query'
import { governanceService, type ActivityEventType, type ActivityEvent } from '../../services/governanceService'
import { EmptyState } from '../../components/ui'
function getEventLabel(type: ActivityEventType): string {
switch (type) {
case 'PROPERTY_CREATED': return 'Objekt erstellt'
case 'PROPERTY_UPDATED': return 'Objekt aktualisiert'
case 'MATCH_APPROVED': return 'Match genehmigt'
case 'MATCH_REJECTED': return 'Match abgelehnt'
case 'SIGNAL_VERIFIED': return 'Signal verifiziert'
case 'NEED_CREATED': return 'Bedarf erstellt'
case 'REVIEW_REQUESTED': return 'Überprüfung angefordert'
}
}
function getEventDescription(event: ActivityEvent): string {
const actor = event.performedBy
const action = getEventLabel(event.type)
const entity = `${event.entityType} ${event.entityId}`
return `${actor} hat ${entity}${action}`
}
function getEventColor(type: ActivityEventType): string {
switch (type) {
case 'PROPERTY_CREATED': return '#1e3a5f'
case 'PROPERTY_UPDATED': return '#1e3a5f'
case 'MATCH_APPROVED': return '#1a7a4a'
case 'MATCH_REJECTED': return '#c0392b'
case 'SIGNAL_VERIFIED': return '#7c3aed'
case 'NEED_CREATED': return '#0891b2'
case 'REVIEW_REQUESTED': return '#d97706'
}
}
function getEventIcon(type: ActivityEventType) {
const size = 14
switch (type) {
case 'PROPERTY_CREATED': return <Building2 size={size} color="white" />
case 'PROPERTY_UPDATED': return <Edit size={size} color="white" />
case 'MATCH_APPROVED': return <CheckCircle size={size} color="white" />
case 'MATCH_REJECTED': return <XCircle size={size} color="white" />
case 'SIGNAL_VERIFIED': return <TrendingUp size={size} color="white" />
case 'NEED_CREATED': return <Search size={size} color="white" />
case 'REVIEW_REQUESTED': return <ClipboardList size={size} color="white" />
}
}
const ALL_EVENT_TYPES: ActivityEventType[] = [
'PROPERTY_CREATED',
'PROPERTY_UPDATED',
'MATCH_APPROVED',
'MATCH_REJECTED',
'SIGNAL_VERIFIED',
'NEED_CREATED',
'REVIEW_REQUESTED',
]
function formatDateTime(dateStr: string): string {
const d = new Date(dateStr)
return d.toLocaleDateString('de-CH', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
}) + ', ' + d.toLocaleTimeString('de-CH', { hour: '2-digit', minute: '2-digit' })
}
function isToday(dateStr: string): boolean {
const d = new Date(dateStr)
const now = new Date()
return d.getFullYear() === now.getFullYear() &&
d.getMonth() === now.getMonth() &&
d.getDate() === now.getDate()
}
export default function Governance() {
const [filterType, setFilterType] = useState<ActivityEventType | 'ALL'>('ALL')
const { data: activityResp, isLoading, error } = useQuery({
queryKey: ['activity', 'org-wincasa'],
queryFn: () => governanceService.getActivityLog('org-wincasa'),
})
const events = activityResp?.data ?? []
const presentTypes = [...new Set(events.map(e => e.type))]
const todayCount = events.filter(e => isToday(e.createdAt)).length
const uniqueUsers = new Set(events.map(e => e.performedBy)).size
const filtered = filterType === 'ALL'
? events
: events.filter(e => e.type === filterType)
if (isLoading) {
return (
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', py: 12 }}>
<CircularProgress />
</Box>
)
}
if (error) {
return (
<Box sx={{ px: 3, py: 4 }}>
<Typography color="error">Fehler beim Laden des Aktivitätslogs.</Typography>
</Box>
)
}
return (
<Box>
{/* Page Header */}
<Box
sx={{
bgcolor: 'white',
borderBottom: '1px solid #e2e8f0',
px: 3,
py: 2,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
}}
>
<Box>
<Typography variant="h5" fontWeight={700} color="text.primary">
Governance & Aktivitätslog
</Typography>
<Typography variant="body2" color="text.secondary">
Vollständiger Audit-Trail aller Plattformaktionen
</Typography>
</Box>
<Button variant="outlined" size="small" disabled>
Exportieren
</Button>
</Box>
<Box sx={{ px: 3, py: 3 }}>
{/* Stats row */}
<Box className="grid grid-cols-3 gap-4" sx={{ mb: 3 }}>
<Card sx={{ p: 2.5 }}>
<Typography variant="overline" color="text.secondary" lineHeight={1.4} display="block">
Ereignisse gesamt
</Typography>
<Typography variant="h3" fontWeight={700}>
{events.length}
</Typography>
<Typography variant="caption" color="text.secondary">Alle Aktivitäten</Typography>
</Card>
<Card sx={{ p: 2.5 }}>
<Typography variant="overline" color="text.secondary" lineHeight={1.4} display="block">
Ereignisse heute
</Typography>
<Typography variant="h3" fontWeight={700}>
{todayCount}
</Typography>
<Typography variant="caption" color="text.secondary">Heutige Aktivitäten</Typography>
</Card>
<Card sx={{ p: 2.5 }}>
<Typography variant="overline" color="text.secondary" lineHeight={1.4} display="block">
Aktive Benutzer
</Typography>
<Typography variant="h3" fontWeight={700}>
{uniqueUsers}
</Typography>
<Typography variant="caption" color="text.secondary">Unterschiedliche Nutzer</Typography>
</Card>
</Box>
{/* Filter chips */}
<Stack direction="row" spacing={0.5} flexWrap="wrap" gap={0.5} sx={{ mb: 2 }}>
<Chip
label="Alle"
size="small"
clickable
onClick={() => setFilterType('ALL')}
sx={{
bgcolor: filterType === 'ALL' ? '#1e3a5f' : 'transparent',
color: filterType === 'ALL' ? 'white' : 'text.secondary',
border: `1px solid ${filterType === 'ALL' ? '#1e3a5f' : '#e2e8f0'}`,
fontWeight: filterType === 'ALL' ? 600 : 400,
}}
/>
{ALL_EVENT_TYPES.filter(t => presentTypes.includes(t)).map(t => (
<Chip
key={t}
label={getEventLabel(t)}
size="small"
clickable
onClick={() => setFilterType(t)}
sx={{
bgcolor: filterType === t ? getEventColor(t) : 'transparent',
color: filterType === t ? 'white' : 'text.secondary',
border: `1px solid ${filterType === t ? getEventColor(t) : '#e2e8f0'}`,
fontWeight: filterType === t ? 600 : 400,
}}
/>
))}
</Stack>
{/* Activity Timeline */}
<Card sx={{ p: 2.5 }}>
<Typography variant="subtitle1" fontWeight={600} mb={2}>
Aktivitätslog
</Typography>
{filtered.length === 0 ? (
<EmptyState
title="Keine Ereignisse"
description="Für diesen Filter wurden keine Aktivitäten gefunden."
/>
) : (
<Box sx={{ position: 'relative' }}>
{/* Vertical line */}
<Box
sx={{
position: 'absolute',
left: 15,
top: 16,
bottom: 16,
width: 2,
bgcolor: '#e2e8f0',
zIndex: 0,
}}
/>
<Stack spacing={0}>
{filtered.map((event, idx) => (
<Box
key={event.id}
sx={{
display: 'flex',
gap: 2,
py: 1.5,
borderBottom: idx < filtered.length - 1 ? '1px solid #f8fafc' : 'none',
position: 'relative',
}}
>
{/* Icon dot */}
<Box
sx={{
width: 32,
height: 32,
borderRadius: '50%',
bgcolor: getEventColor(event.type),
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
zIndex: 1,
boxShadow: '0 0 0 3px white',
}}
>
{getEventIcon(event.type)}
</Box>
{/* Content */}
<Box sx={{ flex: 1, minWidth: 0, pt: 0.5 }}>
<Typography variant="body2">
{getEventDescription(event)}
</Typography>
{event.notes && (
<Typography variant="caption" color="text.secondary" display="block" mt={0.25}>
{event.notes}
</Typography>
)}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mt: 0.5 }}>
<Chip
label={event.organizationId}
size="small"
sx={{ fontSize: 10, height: 18, bgcolor: '#f1f5f9', color: '#475569' }}
/>
</Box>
</Box>
{/* Timestamp */}
<Typography
variant="caption"
color="text.secondary"
sx={{ flexShrink: 0, pt: 0.5, textAlign: 'right', minWidth: 110 }}
>
{formatDateTime(event.createdAt)}
</Typography>
</Box>
))}
</Stack>
</Box>
)}
</Card>
</Box>
</Box>
)
}
+380
View File
@@ -0,0 +1,380 @@
import { useState } from 'react'
import {
Box,
Button,
Card,
Chip,
Typography,
TextField,
Stack,
CircularProgress,
Divider,
Alert,
} from '@mui/material'
import { Target, TrendingUp } from 'lucide-react'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { matchService } from '../../services/matchService'
import { futureSignalService } from '../../services/futureSignalService'
import { RiskLevel } from '../../domain/enums'
import { EmptyState } from '../../components/ui'
type ReviewItemType = 'MATCH' | 'SIGNAL'
interface ReviewItem {
id: string
type: ReviewItemType
title: string
confidence: number
risk?: RiskLevel
summary?: string
probability?: number
}
function getPriorityLabel(confidence: number): string {
return confidence > 0.8 ? 'Kritisch' : 'Normal'
}
function getPriorityColor(confidence: number): 'error' | 'primary' {
return confidence > 0.8 ? 'error' : 'primary'
}
function getRiskLabel(risk?: RiskLevel): string {
if (!risk) return ''
switch (risk) {
case RiskLevel.LOW: return 'Niedrig'
case RiskLevel.MEDIUM: return 'Mittel'
case RiskLevel.HIGH: return 'Hoch'
case RiskLevel.CRITICAL: return 'Kritisch'
}
}
function getRiskColor(risk?: RiskLevel): 'success' | 'warning' | 'error' | 'default' {
if (!risk) return 'default'
if (risk === RiskLevel.LOW) return 'success'
if (risk === RiskLevel.MEDIUM) return 'warning'
return 'error'
}
export default function ReviewQueue() {
const queryClient = useQueryClient()
const [activeItem, setActiveItem] = useState<string | null>(null)
const [reviewNotes, setReviewNotes] = useState('')
const [approvedIds, setApprovedIds] = useState<Set<string>>(new Set())
const [rejectedIds, setRejectedIds] = useState<Set<string>>(new Set())
const { data: matchResp, isLoading: matchLoading } = useQuery({
queryKey: ['matches'],
queryFn: () => matchService.getAll(),
})
const { data: signalResp, isLoading: signalLoading } = useQuery({
queryKey: ['futureSignals'],
queryFn: () => futureSignalService.getAll(),
})
const matches = matchResp?.data ?? []
const signals = signalResp?.data ?? []
// Review items = matches NOT approved + future signals NOT verified
const matchItems: ReviewItem[] = matches
.filter(m => !m.isApproved && !approvedIds.has(m.id) && !rejectedIds.has(m.id))
.map(m => ({
id: m.id,
type: 'MATCH' as ReviewItemType,
title: `Match: ${m.propertyId} / ${m.needId}`,
confidence: m.confidenceLevel,
risk: m.riskLevel,
summary: m.explainabilitySummary,
}))
const signalItems: ReviewItem[] = signals
.filter(s => !s.isVerified && !approvedIds.has(s.id) && !rejectedIds.has(s.id))
.map(s => ({
id: s.id,
type: 'SIGNAL' as ReviewItemType,
title: `${s.signalType}: ${s.locationHint}`,
confidence: s.confidenceScore,
risk: s.riskLevel,
probability: s.probability,
summary: s.disclaimer,
}))
const allItems = [...matchItems, ...signalItems]
const selectedItem = allItems.find(i => i.id === activeItem)
const isLoading = matchLoading || signalLoading
const handleApprove = async () => {
if (!activeItem) return
const item = allItems.find(i => i.id === activeItem)
if (item?.type === 'MATCH') {
await matchService.approve(activeItem, 'admin@ideal-sharing.ch')
await queryClient.invalidateQueries({ queryKey: ['matches'] })
} else if (item?.type === 'SIGNAL') {
await futureSignalService.verify(activeItem, 'admin@ideal-sharing.ch')
await queryClient.invalidateQueries({ queryKey: ['futureSignals'] })
}
setApprovedIds(prev => new Set([...prev, activeItem]))
setActiveItem(null)
setReviewNotes('')
}
const handleReject = () => {
if (!activeItem) return
setRejectedIds(prev => new Set([...prev, activeItem]))
setActiveItem(null)
setReviewNotes('')
}
if (isLoading) {
return (
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', py: 12 }}>
<CircularProgress />
</Box>
)
}
const totalPending = matchItems.length + signalItems.length
return (
<Box>
{/* Page Header */}
<Box sx={{ bgcolor: 'white', borderBottom: '1px solid #e2e8f0', px: 3, py: 2, display: 'flex', alignItems: 'center', gap: 2 }}>
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
<Typography variant="h5" fontWeight={700} color="text.primary">
Review Queue
</Typography>
{totalPending > 0 && (
<Chip
label={totalPending}
size="small"
sx={{ bgcolor: '#d97706', color: 'white', fontWeight: 700 }}
/>
)}
</Box>
<Typography variant="body2" color="text.secondary">
Human-in-the-loop Prüfung
</Typography>
</Box>
</Box>
<Box sx={{ px: 3, py: 3 }}>
{/* Stats row */}
<Box className="grid grid-cols-2 gap-4" sx={{ mb: 3 }}>
<Card sx={{ p: 2.5 }}>
<Typography variant="overline" color="text.secondary" lineHeight={1.4} display="block">
Offene Reviews
</Typography>
<Typography variant="h3" fontWeight={700} sx={{ color: totalPending > 0 ? 'warning.main' : 'text.primary' }}>
{totalPending}
</Typography>
<Typography variant="caption" color="text.secondary">
Ausstehende Prüfungen
</Typography>
</Card>
<Card sx={{ p: 2.5 }}>
<Typography variant="overline" color="text.secondary" lineHeight={1.4} display="block">
Signale zur Prüfung
</Typography>
<Typography variant="h3" fontWeight={700} sx={{ color: signalItems.length > 0 ? 'warning.main' : 'text.primary' }}>
{signalItems.length}
</Typography>
<Typography variant="caption" color="text.secondary">
Unverifizierte Signale
</Typography>
</Card>
</Box>
{/* Two-column layout */}
<Box className="grid grid-cols-2 gap-4" sx={{ alignItems: 'start' }}>
{/* Left: Item List */}
<Card sx={{ p: 0, overflow: 'hidden' }}>
<Box sx={{ px: 2, py: 1.5, borderBottom: '1px solid #f1f5f9' }}>
<Typography variant="subtitle2" fontWeight={600}>
Ausstehende Elemente
</Typography>
</Box>
{allItems.length === 0 ? (
<EmptyState
title="Keine ausstehenden Reviews"
description="Alle Elemente wurden geprüft."
/>
) : (
<Box>
{allItems.map(item => (
<Box
key={item.id}
onClick={() => {
setActiveItem(item.id)
setReviewNotes('')
}}
sx={{
px: 2,
py: 1.5,
cursor: 'pointer',
borderBottom: '1px solid #f8fafc',
bgcolor: activeItem === item.id ? '#eff6ff' : 'white',
'&:hover': { bgcolor: activeItem === item.id ? '#eff6ff' : '#fafafa' },
display: 'flex',
alignItems: 'flex-start',
gap: 1.5,
}}
>
<Box
sx={{
width: 32,
height: 32,
borderRadius: '50%',
bgcolor: item.type === 'MATCH' ? '#eff6ff' : '#faf5ff',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
mt: 0.25,
}}
>
{item.type === 'MATCH'
? <Target size={16} color="#1e3a5f" />
: <TrendingUp size={16} color="#7c3aed" />
}
</Box>
<Box sx={{ flex: 1, minWidth: 0 }}>
<Typography variant="body2" fontWeight={500} noWrap>
{item.title}
</Typography>
<Stack direction="row" spacing={0.5} mt={0.5} flexWrap="wrap">
<Chip
label={getPriorityLabel(item.confidence)}
size="small"
color={getPriorityColor(item.confidence)}
variant="outlined"
sx={{ fontSize: 10 }}
/>
<Chip
label="Ausstehend"
size="small"
sx={{ bgcolor: '#fef3c7', color: '#92400e', fontSize: 10 }}
/>
</Stack>
</Box>
</Box>
))}
</Box>
)}
</Card>
{/* Right: Review Panel */}
<Card sx={{ p: 0, overflow: 'hidden' }}>
{!selectedItem ? (
<EmptyState
title="Wählen Sie ein Element zur Prüfung"
description="Klicken Sie auf ein Element in der Liste, um es zu prüfen."
/>
) : (
<Box>
<Box sx={{ px: 2.5, py: 2, borderBottom: '1px solid #f1f5f9' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.5 }}>
<Chip
label={selectedItem.type === 'MATCH' ? 'Match' : 'Signal'}
size="small"
sx={{
bgcolor: selectedItem.type === 'MATCH' ? '#eff6ff' : '#faf5ff',
color: selectedItem.type === 'MATCH' ? '#1e3a5f' : '#7c3aed',
fontWeight: 600,
}}
/>
<Typography variant="subtitle2" fontWeight={600}>
{selectedItem.title}
</Typography>
</Box>
</Box>
<Box sx={{ px: 2.5, py: 2 }}>
{/* Key facts */}
<Stack spacing={1} sx={{ mb: 2 }}>
<Box sx={{ display: 'flex', gap: 3 }}>
<Box>
<Typography variant="caption" color="text.secondary" display="block">Konfidenz</Typography>
<Typography variant="body2" fontWeight={600}>
{Math.round(selectedItem.confidence * 100)}%
</Typography>
</Box>
{selectedItem.probability != null && (
<Box>
<Typography variant="caption" color="text.secondary" display="block">Wahrscheinlichkeit</Typography>
<Typography variant="body2" fontWeight={600}>
{Math.round(selectedItem.probability * 100)}%
</Typography>
</Box>
)}
<Box>
<Typography variant="caption" color="text.secondary" display="block">Risiko</Typography>
<Chip
label={getRiskLabel(selectedItem.risk)}
size="small"
color={getRiskColor(selectedItem.risk)}
variant="outlined"
/>
</Box>
</Box>
</Stack>
{selectedItem.summary && (
<Alert severity="info" sx={{ mb: 2, '& .MuiAlert-message': { fontSize: 13 } }}>
{selectedItem.summary}
</Alert>
)}
<Divider sx={{ mb: 2 }} />
{/* Notes */}
<Typography variant="caption" fontWeight={600} color="text.secondary" display="block" mb={1}>
Notizen
</Typography>
<TextField
multiline
rows={3}
fullWidth
placeholder="Optionale Anmerkungen zur Entscheidung..."
value={reviewNotes}
onChange={e => setReviewNotes(e.target.value)}
size="small"
sx={{ mb: 2 }}
/>
{/* Decision buttons */}
<Stack spacing={1}>
<Button
variant="contained"
fullWidth
onClick={handleApprove}
sx={{ bgcolor: '#1a7a4a', '&:hover': { bgcolor: '#155f3a' } }}
>
Genehmigen
</Button>
<Button
variant="outlined"
fullWidth
color="error"
onClick={handleReject}
>
Ablehnen
</Button>
<Button
variant="outlined"
fullWidth
sx={{ color: '#64748b', borderColor: '#e2e8f0' }}
>
Weiterleiten
</Button>
</Stack>
</Box>
</Box>
)}
</Card>
</Box>
</Box>
</Box>
)
}
+309
View File
@@ -0,0 +1,309 @@
import {
Alert,
Box,
Card,
Chip,
LinearProgress,
Table,
TableBody,
TableCell,
TableHead,
TableRow,
Typography,
} from '@mui/material'
import { useQuery } from '@tanstack/react-query'
import { ErrorState, LoadingPage, SectionContainer } from '../../components/ui'
import { propertyService } from '../../services/propertyService'
import { DataFreshness, ResultType } from '../../domain/enums'
function getResultTypeLabel(type: ResultType): string {
switch (type) {
case ResultType.VERIFIED_PORTFOLIO: return 'Verified Portfolio'
case ResultType.EXTERNAL_MARKET: return 'Marktinserat'
case ResultType.FUTURE_AVAILABILITY: return 'Zukunftssignal'
}
}
function getQualityColor(score: number): 'success' | 'warning' | 'error' {
if (score >= 0.8) return 'success'
if (score >= 0.6) return 'warning'
return 'error'
}
function getFreshnessLabel(freshness: DataFreshness): string {
switch (freshness) {
case DataFreshness.FRESH: return 'Aktuell'
case DataFreshness.STALE: return 'Veraltet'
case DataFreshness.OUTDATED: return 'Abgelaufen'
}
}
function getFreshnessColor(freshness: DataFreshness): 'success' | 'warning' | 'error' {
switch (freshness) {
case DataFreshness.FRESH: return 'success'
case DataFreshness.STALE: return 'warning'
case DataFreshness.OUTDATED: return 'error'
}
}
export default function DataQuality() {
const { data: resp, isLoading, error } = useQuery({
queryKey: ['properties'],
queryFn: () => propertyService.getAll(),
})
if (isLoading) return <LoadingPage />
if (error) return <ErrorState />
const properties = resp?.data ?? []
const avgScore = properties.length
? properties.reduce((sum, p) => sum + p.dataQuality.score, 0) / properties.length
: 0
const criticalIssues = properties.filter(p => p.dataQuality.missingCriticalFields.length > 0)
const staleData = properties.filter(
p => p.dataQuality.freshness === DataFreshness.STALE || p.dataQuality.freshness === DataFreshness.OUTDATED
)
const highQuality = properties.filter(p => p.dataQuality.score >= 0.8)
const medQuality = properties.filter(p => p.dataQuality.score >= 0.6 && p.dataQuality.score < 0.8)
const lowQuality = properties.filter(p => p.dataQuality.score < 0.6)
// Sort by score ascending (worst first)
const sortedProperties = [...properties].sort((a, b) => a.dataQuality.score - b.dataQuality.score)
return (
<Box>
{/* Page Header */}
<Box sx={{ bgcolor: 'white', borderBottom: '1px solid', borderColor: 'divider', px: 3, py: 2 }}>
<Typography variant="h5" fontWeight={700} color="text.primary">Datenqualität</Typography>
<Typography variant="body2" color="text.secondary">Vollständigkeit und Aktualität der Objektdaten</Typography>
</Box>
{/* Content */}
<Box sx={{ px: 3, py: 3 }} className="flex flex-col gap-4">
{/* Summary Stats Row */}
<Box className="grid grid-cols-3 gap-4">
{/* Avg Quality Score */}
<Card sx={{ elevation: 1, p: 2.5 }}>
<Typography variant="overline" color="text.secondary" display="block">Ø Qualitätsscore</Typography>
<Typography variant="h4" fontWeight={700} sx={{ color: avgScore >= 0.8 ? '#1a7a4a' : avgScore >= 0.6 ? '#d97706' : '#c0392b' }}>
{Math.round(avgScore * 100)}%
</Typography>
<Box sx={{ mt: 1 }}>
<LinearProgress
variant="determinate"
value={avgScore * 100}
color={getQualityColor(avgScore)}
sx={{ height: 8, borderRadius: 4 }}
/>
</Box>
</Card>
{/* Critical Issues */}
<Card sx={{ elevation: 1, p: 2.5 }}>
<Typography variant="overline" color="text.secondary" display="block">Kritische Felder fehlen</Typography>
<Typography
variant="h4"
fontWeight={700}
sx={{ color: criticalIssues.length > 2 ? '#c0392b' : criticalIssues.length > 0 ? '#d97706' : '#1a7a4a' }}
>
{criticalIssues.length}
</Typography>
<Typography variant="caption" color="text.secondary">
von {properties.length} Objekten
</Typography>
</Card>
{/* Stale Data */}
<Card sx={{ elevation: 1, p: 2.5 }}>
<Typography variant="overline" color="text.secondary" display="block">Veraltete Daten</Typography>
<Typography
variant="h4"
fontWeight={700}
sx={{ color: staleData.length > 2 ? '#c0392b' : staleData.length > 0 ? '#d97706' : '#1a7a4a' }}
>
{staleData.length}
</Typography>
<Typography variant="caption" color="text.secondary">
von {properties.length} Objekten
</Typography>
</Card>
</Box>
{/* Quality Distribution */}
<SectionContainer title="Qualitätsverteilung">
<Card sx={{ elevation: 1, p: 2.5 }}>
<Box className="flex flex-col gap-3">
{/* High */}
<Box className="flex items-center gap-3">
<Typography variant="body2" sx={{ width: 120, flexShrink: 0 }}>Hoch (80%)</Typography>
<Chip label={highQuality.length} size="small" color="success" />
<Box className="flex-1">
<LinearProgress
variant="determinate"
value={properties.length > 0 ? (highQuality.length / properties.length) * 100 : 0}
color="success"
sx={{ height: 10, borderRadius: 5 }}
/>
</Box>
<Typography variant="caption" color="text.secondary" sx={{ width: 40, textAlign: 'right' }}>
{properties.length > 0 ? Math.round((highQuality.length / properties.length) * 100) : 0}%
</Typography>
</Box>
{/* Medium */}
<Box className="flex items-center gap-3">
<Typography variant="body2" sx={{ width: 120, flexShrink: 0 }}>Mittel (6079%)</Typography>
<Chip label={medQuality.length} size="small" color="warning" />
<Box className="flex-1">
<LinearProgress
variant="determinate"
value={properties.length > 0 ? (medQuality.length / properties.length) * 100 : 0}
color="warning"
sx={{ height: 10, borderRadius: 5 }}
/>
</Box>
<Typography variant="caption" color="text.secondary" sx={{ width: 40, textAlign: 'right' }}>
{properties.length > 0 ? Math.round((medQuality.length / properties.length) * 100) : 0}%
</Typography>
</Box>
{/* Low */}
<Box className="flex items-center gap-3">
<Typography variant="body2" sx={{ width: 120, flexShrink: 0 }}>Niedrig ({'<'}60%)</Typography>
<Chip label={lowQuality.length} size="small" color="error" />
<Box className="flex-1">
<LinearProgress
variant="determinate"
value={properties.length > 0 ? (lowQuality.length / properties.length) * 100 : 0}
color="error"
sx={{ height: 10, borderRadius: 5 }}
/>
</Box>
<Typography variant="caption" color="text.secondary" sx={{ width: 40, textAlign: 'right' }}>
{properties.length > 0 ? Math.round((lowQuality.length / properties.length) * 100) : 0}%
</Typography>
</Box>
</Box>
</Card>
</SectionContainer>
{/* Properties Quality Table */}
<SectionContainer title="Objektübersicht Datenqualität">
<Card sx={{ elevation: 1 }}>
<Table size="small">
<TableHead>
<TableRow sx={{ bgcolor: 'grey.50' }}>
<TableCell><Typography variant="caption" fontWeight={600}>Objekt</Typography></TableCell>
<TableCell><Typography variant="caption" fontWeight={600}>Quelle</Typography></TableCell>
<TableCell><Typography variant="caption" fontWeight={600}>Score</Typography></TableCell>
<TableCell><Typography variant="caption" fontWeight={600}>Kritische Felder</Typography></TableCell>
<TableCell><Typography variant="caption" fontWeight={600}>Optionale Felder</Typography></TableCell>
<TableCell><Typography variant="caption" fontWeight={600}>Aktualität</Typography></TableCell>
<TableCell><Typography variant="caption" fontWeight={600}>Warnungen</Typography></TableCell>
</TableRow>
</TableHead>
<TableBody>
{sortedProperties.map(property => {
const hasCritical = property.dataQuality.missingCriticalFields.length > 0
const missingCritical = property.dataQuality.missingCriticalFields
const missingOptional = property.dataQuality.missingOptionalFields
const score = property.dataQuality.score
return (
<TableRow
key={property.id}
hover
sx={hasCritical ? { bgcolor: 'rgba(192,57,43,0.04)' } : {}}
>
{/* Objekt */}
<TableCell>
<Typography variant="body2" fontWeight={500}>{property.title}</Typography>
</TableCell>
{/* Quelle */}
<TableCell>
<Typography variant="caption" color="text.secondary">
{getResultTypeLabel(property.resultType)}
</Typography>
</TableCell>
{/* Score */}
<TableCell>
<Box sx={{ width: 100 }}>
<Box className="flex items-center justify-between mb-1">
<Typography variant="caption" fontWeight={700} sx={{ color: score >= 0.8 ? '#1a7a4a' : score >= 0.6 ? '#d97706' : '#c0392b' }}>
{Math.round(score * 100)}%
</Typography>
</Box>
<LinearProgress
variant="determinate"
value={score * 100}
color={getQualityColor(score)}
sx={{ height: 5, borderRadius: 2 }}
/>
</Box>
</TableCell>
{/* Kritische Felder */}
<TableCell>
{missingCritical.length === 0 ? (
<Chip label="Vollständig" color="success" size="small" />
) : (
<Box className="flex flex-wrap gap-1 items-center">
{missingCritical.slice(0, 2).map(f => (
<Chip key={f} label={f} color="error" size="small" variant="outlined" />
))}
{missingCritical.length > 2 && (
<Typography variant="caption" color="error.main" fontWeight={600}>
+{missingCritical.length - 2} weitere
</Typography>
)}
</Box>
)}
</TableCell>
{/* Optionale Felder */}
<TableCell>
{missingOptional.length === 0 ? (
<Typography variant="caption" color="text.secondary"></Typography>
) : (
<Typography variant="caption" color="text.secondary">
{missingOptional.length} fehlen
</Typography>
)}
</TableCell>
{/* Aktualität */}
<TableCell>
<Chip
label={getFreshnessLabel(property.dataQuality.freshness)}
color={getFreshnessColor(property.dataQuality.freshness)}
size="small"
/>
</TableCell>
{/* Warnungen */}
<TableCell>
{property.dataQuality.warnings.length > 0 ? (
<Alert severity="warning" sx={{ py: 0, px: 1, fontSize: 11 }}>
{property.dataQuality.warnings[0]}
</Alert>
) : (
<Typography variant="caption" color="text.secondary"></Typography>
)}
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</Card>
</SectionContainer>
</Box>
</Box>
)
}
+237
View File
@@ -0,0 +1,237 @@
import {
Box,
Button,
Card,
Chip,
Divider,
LinearProgress,
Typography,
} from '@mui/material'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { EmptyState, ErrorState, LoadingPage } from '../../components/ui'
import { futureSignalService } from '../../services/futureSignalService'
import { SignalType } from '../../domain/enums'
function getSignalTypeLabel(type: SignalType): string {
switch (type) {
case SignalType.EXPANSION: return 'Expansion'
case SignalType.POSSIBLE_MOVE_OUT: return 'Möglicher Auszug'
case SignalType.CONSTRUCTION_PROJECT: return 'Bauprojekt'
case SignalType.RESTRUCTURING: return 'Restrukturierung'
case SignalType.PROJECT_DEVELOPMENT: return 'Projektentwicklung'
case SignalType.SPACE_CONSOLIDATION: return 'Flächenkonsolidierung'
}
}
function getSignalTypeColor(type: SignalType): string {
switch (type) {
case SignalType.EXPANSION: return '#1a7a4a'
case SignalType.POSSIBLE_MOVE_OUT: return '#d97706'
case SignalType.CONSTRUCTION_PROJECT: return '#1e3a5f'
case SignalType.RESTRUCTURING: return '#ea580c'
case SignalType.PROJECT_DEVELOPMENT: return '#7c3aed'
case SignalType.SPACE_CONSOLIDATION: return '#6b7280'
}
}
function getProbabilityColor(prob: number): 'success' | 'warning' | 'error' {
if (prob > 0.7) return 'success'
if (prob >= 0.5) return 'warning'
return 'error'
}
function formatDate(dateStr?: string): string {
if (!dateStr) return ''
return new Date(dateStr).toLocaleDateString('de-CH', { day: '2-digit', month: '2-digit', year: 'numeric' })
}
function getSourceTypeLabel(type: string): string {
switch (type) {
case 'PRESS': return 'Pressebericht'
case 'CONSTRUCTION_PERMIT': return 'Baubewilligung'
case 'JOB_POSTING': return 'Stelleninserat'
case 'COMPANY_REPORT': return 'Geschäftsbericht'
case 'MARKET_DATA': return 'Marktdaten'
case 'MANUAL': return 'Manuell'
default: return type
}
}
export default function FutureAvailability() {
const queryClient = useQueryClient()
const { data: resp, isLoading, error } = useQuery({
queryKey: ['futureSignals'],
queryFn: () => futureSignalService.getAll(),
})
const verifyMutation = useMutation({
mutationFn: (signalId: string) => futureSignalService.verify(signalId, 'admin@ideal-sharing.ch'),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['futureSignals'] }),
})
if (isLoading) return <LoadingPage />
if (error) return <ErrorState />
const signals = resp?.data ?? []
const totalCount = signals.length
const verifiedCount = signals.filter(s => s.isVerified).length
const highProbCount = signals.filter(s => s.probability > 0.7).length
const avgProbability = signals.length
? signals.reduce((sum, s) => sum + s.probability, 0) / signals.length
: 0
return (
<Box>
{/* Page Header */}
<Box sx={{ bgcolor: 'white', borderBottom: '1px solid', borderColor: 'divider', px: 3, py: 2 }} className="flex items-center gap-3">
<Box className="flex-1">
<Box className="flex items-center gap-2">
<Typography variant="h5" fontWeight={700} color="text.primary">Marktchancen</Typography>
<Chip
label="Shadow Intelligence Layer"
size="small"
sx={{ bgcolor: '#7c3aed', color: 'white', fontWeight: 600 }}
/>
</Box>
<Typography variant="body2" color="text.secondary">KI-generierte Verfügbarkeitssignale</Typography>
</Box>
</Box>
{/* Content */}
<Box sx={{ px: 3, py: 3 }} className="flex flex-col gap-4">
{/* Stats Row */}
<Box className="grid grid-cols-4 gap-4">
<Card sx={{ elevation: 1, p: 2.5 }}>
<Typography variant="overline" color="text.secondary" display="block">Signale gesamt</Typography>
<Typography variant="h4" fontWeight={700}>{totalCount}</Typography>
</Card>
<Card sx={{ elevation: 1, p: 2.5 }}>
<Typography variant="overline" color="text.secondary" display="block">Verifiziert</Typography>
<Typography variant="h4" fontWeight={700} sx={{ color: '#1a7a4a' }}>{verifiedCount}</Typography>
</Card>
<Card sx={{ elevation: 1, p: 2.5 }}>
<Typography variant="overline" color="text.secondary" display="block">Hohe Wahrscheinlichkeit</Typography>
<Typography variant="h4" fontWeight={700} sx={{ color: '#1e3a5f' }}>{highProbCount}</Typography>
</Card>
<Card sx={{ elevation: 1, p: 2.5 }}>
<Typography variant="overline" color="text.secondary" display="block">Ø Wahrscheinlichkeit</Typography>
<Typography variant="h4" fontWeight={700} sx={{ color: getProbabilityColor(avgProbability) === 'success' ? '#1a7a4a' : getProbabilityColor(avgProbability) === 'warning' ? '#d97706' : '#c0392b' }}>
{Math.round(avgProbability * 100)}%
</Typography>
</Card>
</Box>
{/* Signal Cards Grid */}
{signals.length === 0 ? (
<EmptyState title="Keine Signale gefunden" description="Es sind noch keine Zukunftssignale vorhanden." />
) : (
<Box className="flex flex-wrap gap-4">
{signals.map(signal => (
<Card key={signal.id} sx={{ elevation: 1, p: 2, flex: '1 1 calc(50% - 16px)', minWidth: 320 }}>
{/* Card Header */}
<Box className="flex items-center justify-between mb-2">
<Chip
label={getSignalTypeLabel(signal.signalType)}
size="small"
sx={{ bgcolor: getSignalTypeColor(signal.signalType), color: 'white', fontWeight: 600 }}
/>
<Chip
label={signal.sensitivityLevel === 'PUBLIC' ? 'Öffentlich' : signal.sensitivityLevel === 'CONFIDENTIAL' ? 'Vertraulich' : 'Intern'}
size="small"
variant="outlined"
color={signal.sensitivityLevel === 'CONFIDENTIAL' ? 'error' : 'default'}
/>
</Box>
{/* Location */}
<Box className="mb-2">
<Typography variant="body1" fontWeight={500}>{signal.locationHint}</Typography>
{signal.companyName && (
<Typography variant="body2" color="text.secondary">{signal.companyName}</Typography>
)}
</Box>
{/* Probability */}
<Box className="mb-2">
<Box className="flex items-center justify-between mb-1">
<Typography variant="caption" color="text.secondary">Wahrscheinlichkeit</Typography>
<Typography variant="body2" fontWeight={700} sx={{ color: signal.probability > 0.7 ? '#1a7a4a' : signal.probability >= 0.5 ? '#d97706' : '#c0392b' }}>
{Math.round(signal.probability * 100)}%
</Typography>
</Box>
<LinearProgress
variant="determinate"
value={signal.probability * 100}
color={getProbabilityColor(signal.probability)}
sx={{ height: 6, borderRadius: 3 }}
/>
</Box>
{/* Details */}
<Box className="flex flex-wrap gap-2 mb-2">
{signal.areaSqmEstimate && (
<Typography variant="caption" color="text.secondary">
Fläche: ca. {signal.areaSqmEstimate.toLocaleString('de-CH')} m²
</Typography>
)}
<Typography variant="caption" color="text.secondary">
Zeithorizont: {signal.timeHorizonMonths} Monate
</Typography>
{signal.expiresAt && (
<Typography variant="caption" color="text.secondary">
Verfügbar ab: {formatDate(signal.expiresAt)}
</Typography>
)}
</Box>
{/* Source */}
<Box className="mb-2">
<Typography variant="caption" color="text.secondary">
Quelle: {getSourceTypeLabel(signal.source.type)} Glaubwürdigkeit:{' '}
<span style={{ color: signal.source.credibility === 'HIGH' ? '#1a7a4a' : signal.source.credibility === 'MEDIUM' ? '#d97706' : '#c0392b', fontWeight: 600 }}>
{signal.source.credibility === 'HIGH' ? 'Hoch' : signal.source.credibility === 'MEDIUM' ? 'Mittel' : 'Niedrig'}
</span>
</Typography>
</Box>
{/* Verification Status */}
<Box className="mb-2">
{signal.isVerified ? (
<Box className="flex items-center gap-2">
<Chip label="Verifiziert" color="success" size="small" />
<Typography variant="caption" color="text.secondary">{formatDate(signal.verifiedAt)}</Typography>
</Box>
) : (
<Chip label="Nicht verifiziert" color="warning" size="small" variant="outlined" />
)}
</Box>
<Divider sx={{ my: 1 }} />
{/* Footer buttons */}
<Box className="flex items-center gap-2">
{!signal.isVerified && (
<Button
variant="outlined"
size="small"
onClick={() => verifyMutation.mutate(signal.id)}
disabled={verifyMutation.isPending}
>
Verifizieren
</Button>
)}
<Button variant="outlined" size="small" disabled>
Zu Shortlist
</Button>
</Box>
</Card>
))}
</Box>
)}
</Box>
</Box>
)
}
+283
View File
@@ -0,0 +1,283 @@
import {
Box,
Button,
Card,
Chip,
Divider,
LinearProgress,
Typography,
} from '@mui/material'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Building2 } from 'lucide-react'
import { useState } from 'react'
import { EmptyState, ErrorState, LoadingPage } from '../../components/ui'
import { matchService } from '../../services/matchService'
import { propertyService } from '../../services/propertyService'
import { needService } from '../../services/needService'
import { MatchStrength, RiskLevel } from '../../domain/enums'
function getMatchStrengthLabel(strength: MatchStrength): string {
switch (strength) {
case MatchStrength.STRONG: return 'Stark'
case MatchStrength.MODERATE: return 'Mittel'
case MatchStrength.WEAK: return 'Schwach'
}
}
function getMatchStrengthColor(strength: MatchStrength): 'success' | 'warning' | 'error' {
switch (strength) {
case MatchStrength.STRONG: return 'success'
case MatchStrength.MODERATE: return 'warning'
case MatchStrength.WEAK: return 'error'
}
}
function getScoreColor(strength: MatchStrength): string {
switch (strength) {
case MatchStrength.STRONG: return '#1a7a4a'
case MatchStrength.MODERATE: return '#d97706'
case MatchStrength.WEAK: return '#c0392b'
}
}
function getConfidenceColor(score: number): 'success' | 'primary' | 'warning' {
if (score >= 0.85) return 'success'
if (score >= 0.65) return 'primary'
return 'warning'
}
function getConfidenceLabel(score: number): string {
if (score >= 0.85) return 'Hoch'
if (score >= 0.65) return 'Mittel'
return 'Niedrig'
}
function getRiskLabel(level: RiskLevel): string {
switch (level) {
case RiskLevel.LOW: return 'Niedriges Risiko'
case RiskLevel.MEDIUM: return 'Mittleres Risiko'
case RiskLevel.HIGH: return 'Hohes Risiko'
case RiskLevel.CRITICAL: return 'Kritisches Risiko'
}
}
function getRiskColor(level: RiskLevel): 'success' | 'warning' | 'error' {
switch (level) {
case RiskLevel.LOW: return 'success'
case RiskLevel.MEDIUM: return 'warning'
case RiskLevel.HIGH: return 'error'
case RiskLevel.CRITICAL: return 'error'
}
}
export default function MatchCenter() {
const [filterStrength, setFilterStrength] = useState<MatchStrength | 'ALL'>('ALL')
const queryClient = useQueryClient()
const { data: matchResp, isLoading: matchLoading, error: matchError } = useQuery({
queryKey: ['matches'],
queryFn: () => matchService.getAll(),
})
const { data: propResp, isLoading: propLoading, error: propError } = useQuery({
queryKey: ['properties'],
queryFn: () => propertyService.getAll(),
})
const { data: needResp, isLoading: needLoading, error: needError } = useQuery({
queryKey: ['needs'],
queryFn: () => needService.getAll(),
})
const approveMutation = useMutation({
mutationFn: (matchId: string) => matchService.approve(matchId, 'admin@ideal-sharing.ch'),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['matches'] }),
})
if (matchLoading || propLoading || needLoading) return <LoadingPage />
if (matchError || propError || needError) return <ErrorState />
const matches = matchResp?.data ?? []
const properties = propResp?.data ?? []
const needs = needResp?.data ?? []
const filtered = filterStrength === 'ALL'
? matches
: matches.filter(m => m.matchStrength === filterStrength)
const sortedFiltered = [...filtered].sort((a, b) => b.matchScore - a.matchScore)
const strengthFilters: { value: MatchStrength | 'ALL'; label: string }[] = [
{ value: 'ALL', label: 'Alle' },
{ value: MatchStrength.STRONG, label: 'Stark' },
{ value: MatchStrength.MODERATE, label: 'Mittel' },
{ value: MatchStrength.WEAK, label: 'Schwach' },
]
return (
<Box>
{/* Page Header */}
<Box sx={{ bgcolor: 'white', borderBottom: '1px solid', borderColor: 'divider', px: 3, py: 2 }} className="flex items-center justify-between">
<Box>
<Box className="flex items-center gap-2">
<Typography variant="h5" fontWeight={700} color="text.primary">Match Center</Typography>
<Chip label={`${matches.length} Matches`} size="small" sx={{ bgcolor: '#1e3a5f', color: 'white', fontWeight: 700 }} />
</Box>
<Typography variant="body2" color="text.secondary">KI-gestützte Objekt-Bedarfs-Analyse</Typography>
</Box>
</Box>
{/* Content */}
<Box sx={{ px: 3, py: 3 }} className="flex flex-col gap-3">
{/* Filter Chips */}
<Box className="flex items-center gap-2">
{strengthFilters.map(f => (
<Chip
key={f.value}
label={f.label}
variant={filterStrength === f.value ? 'filled' : 'outlined'}
size="small"
onClick={() => setFilterStrength(f.value)}
color={
f.value === MatchStrength.STRONG ? 'success'
: f.value === MatchStrength.MODERATE ? 'warning'
: f.value === MatchStrength.WEAK ? 'error'
: 'default'
}
sx={{ cursor: 'pointer', fontWeight: filterStrength === f.value ? 700 : 400 }}
/>
))}
</Box>
{/* Match Cards */}
{sortedFiltered.length === 0 ? (
<EmptyState title="Keine Matches gefunden" description="Passen Sie den Filter an." />
) : (
<Box className="flex flex-col gap-4">
{sortedFiltered.map(match => {
const property = properties.find(p => p.id === match.propertyId)
const need = needs.find(n => n.id === match.needId)
return (
<Card key={match.id} sx={{ elevation: 1, p: 2.5 }}>
<Box className="flex gap-4">
{/* Left column: score */}
<Box sx={{ width: 80, flexShrink: 0, textAlign: 'center' }} className="flex flex-col items-center gap-1">
<Typography variant="h3" fontWeight={700} sx={{ color: getScoreColor(match.matchStrength) }}>
{match.matchScore}
</Typography>
<Typography variant="caption" color="text.secondary">/ 100</Typography>
<Chip
label={getMatchStrengthLabel(match.matchStrength)}
color={getMatchStrengthColor(match.matchStrength)}
size="small"
/>
</Box>
{/* Center column: details */}
<Box className="flex-1 flex flex-col gap-2">
{/* Property info */}
<Box className="flex items-center gap-1">
<Building2 size={16} color="#1e3a5f" />
<Typography variant="h6" fontWeight={600}>
{property?.title ?? match.propertyId}
</Typography>
</Box>
{need && (
<Typography variant="body2" color="text.secondary">
{need.companyName} {need.assetType}
</Typography>
)}
<Divider />
{/* Positive factors */}
<Box className="flex flex-col gap-1">
{match.positiveFactors.slice(0, 3).map((f, i) => (
<Box key={i} className="flex items-center gap-2">
<Typography variant="caption" sx={{ color: '#1a7a4a', fontWeight: 600 }}>
{f.criterion}: {Math.round(f.score)}%
</Typography>
<Box sx={{ width: 60 }}>
<LinearProgress
variant="determinate"
value={f.score}
color="success"
sx={{ height: 4, borderRadius: 2 }}
/>
</Box>
</Box>
))}
</Box>
{/* Negative factors */}
{match.negativeFactors.slice(0, 2).map((f, i) => (
<Typography key={i} variant="caption" sx={{ color: '#c0392b' }}>
{f.criterion}
</Typography>
))}
{/* Tradeoffs */}
{match.tradeoffs.length > 0 && (
<Box>
{match.tradeoffs.slice(0, 2).map((t, i) => (
<Typography key={i} variant="caption" sx={{ color: '#d97706' }}>
{t.concern}
</Typography>
))}
</Box>
)}
</Box>
{/* Right column: actions */}
<Box sx={{ width: 160, flexShrink: 0, textAlign: 'right' }} className="flex flex-col gap-2 items-end">
<Chip
label={`Konfidenz: ${getConfidenceLabel(match.confidenceLevel)}`}
color={getConfidenceColor(match.confidenceLevel)}
size="small"
/>
<Chip
label={getRiskLabel(match.riskLevel)}
color={getRiskColor(match.riskLevel)}
size="small"
variant="outlined"
/>
<Button
variant="outlined"
size="small"
fullWidth
disabled
>
Details
</Button>
{match.isApproved ? (
<Chip
label="✓ Genehmigt"
color="success"
size="small"
sx={{ width: '100%', justifyContent: 'center' }}
/>
) : (
<Button
variant="contained"
size="small"
fullWidth
color="primary"
sx={{ bgcolor: '#1e3a5f' }}
onClick={() => approveMutation.mutate(match.id)}
disabled={approveMutation.isPending}
>
Genehmigen
</Button>
)}
</Box>
</Box>
</Card>
)
})}
</Box>
)}
</Box>
</Box>
)
}
+374
View File
@@ -0,0 +1,374 @@
import {
Box,
Card,
Chip,
IconButton,
LinearProgress,
MenuItem,
Select,
Table,
TableBody,
TableCell,
TableHead,
TableRow,
TextField,
Tooltip,
Typography,
} from '@mui/material'
import { useQuery } from '@tanstack/react-query'
import { Eye, MoreHorizontal, Plus } from 'lucide-react'
import { useState } from 'react'
import { EmptyState, ErrorState, LoadingPage } from '../../components/ui'
import { propertyService } from '../../services/propertyService'
import { AssetType, AvailabilityStatus, ResultType } from '../../domain/enums'
function getAssetTypeLabel(type: AssetType): string {
switch (type) {
case AssetType.OFFICE: return 'Büro'
case AssetType.LOGISTICS: return 'Logistik'
case AssetType.RETAIL: return 'Retail'
case AssetType.GASTRO: return 'Gastro'
case AssetType.PRODUCTION: return 'Produktion'
case AssetType.MIXED: return 'Gemischt'
}
}
function getAssetTypeColor(type: AssetType): string {
switch (type) {
case AssetType.OFFICE: return '#1e3a5f'
case AssetType.LOGISTICS: return '#d97706'
case AssetType.RETAIL: return '#7c3aed'
case AssetType.GASTRO: return '#0d9488'
case AssetType.PRODUCTION: return '#92400e'
case AssetType.MIXED: return '#6b7280'
}
}
function getAvailabilityLabel(status: AvailabilityStatus): string {
switch (status) {
case AvailabilityStatus.AVAILABLE_NOW: return 'Verfügbar'
case AvailabilityStatus.AVAILABLE_SOON: return 'Bald verfügbar'
case AvailabilityStatus.FUTURE_SIGNAL: return 'Zukunftssignal'
case AvailabilityStatus.OCCUPIED: return 'Belegt'
case AvailabilityStatus.UNKNOWN: return 'Unbekannt'
}
}
function getAvailabilityColor(status: AvailabilityStatus): 'success' | 'warning' | 'secondary' | 'error' | 'default' {
switch (status) {
case AvailabilityStatus.AVAILABLE_NOW: return 'success'
case AvailabilityStatus.AVAILABLE_SOON: return 'warning'
case AvailabilityStatus.FUTURE_SIGNAL: return 'secondary'
case AvailabilityStatus.OCCUPIED: return 'error'
case AvailabilityStatus.UNKNOWN: return 'default'
}
}
function getResultTypeLabel(type: ResultType): string {
switch (type) {
case ResultType.VERIFIED_PORTFOLIO: return 'Verified Portfolio'
case ResultType.EXTERNAL_MARKET: return 'Marktinserat'
case ResultType.FUTURE_AVAILABILITY: return 'Zukunftssignal'
}
}
function getResultTypeColor(type: ResultType): string {
switch (type) {
case ResultType.VERIFIED_PORTFOLIO: return '#1e3a5f'
case ResultType.EXTERNAL_MARKET: return '#d97706'
case ResultType.FUTURE_AVAILABILITY: return '#7c3aed'
}
}
function getConfidenceColor(score: number): string {
if (score >= 0.85) return '#1a7a4a'
if (score >= 0.65) return '#1e3a5f'
return '#d97706'
}
function getQualityColor(score: number): 'success' | 'warning' | 'error' {
if (score >= 0.8) return 'success'
if (score >= 0.6) return 'warning'
return 'error'
}
export default function Properties() {
const [selectedResultType, setSelectedResultType] = useState<ResultType | 'ALL'>('ALL')
const [selectedAssetType, setSelectedAssetType] = useState<AssetType | 'ALL'>('ALL')
const [searchQuery, setSearchQuery] = useState('')
const { data: resp, isLoading, error } = useQuery({
queryKey: ['properties'],
queryFn: () => propertyService.getAll(),
})
if (isLoading) return <LoadingPage />
if (error) return <ErrorState />
const properties = resp?.data ?? []
const filtered = properties.filter(p => {
if (selectedResultType !== 'ALL' && p.resultType !== selectedResultType) return false
if (selectedAssetType !== 'ALL' && p.assetType !== selectedAssetType) return false
if (searchQuery) {
const q = searchQuery.toLowerCase()
const matchesTitle = p.title.toLowerCase().includes(q)
const matchesCity = p.location.city.toLowerCase().includes(q)
const matchesStreet = p.address.street.toLowerCase().includes(q)
if (!matchesTitle && !matchesCity && !matchesStreet) return false
}
return true
})
const sourceTypeFilters: { value: ResultType | 'ALL'; label: string; color: string }[] = [
{ value: 'ALL', label: 'Alle', color: '#6b7280' },
{ value: ResultType.VERIFIED_PORTFOLIO, label: 'Verified Portfolio', color: '#1e3a5f' },
{ value: ResultType.EXTERNAL_MARKET, label: 'Marktinserate', color: '#d97706' },
{ value: ResultType.FUTURE_AVAILABILITY, label: 'Zukunftssignale', color: '#7c3aed' },
]
return (
<Box>
{/* Page Header */}
<Box sx={{ bgcolor: 'white', borderBottom: '1px solid', borderColor: 'divider', px: 3, py: 2 }} className="flex items-center justify-between">
<Box className="flex items-center gap-2">
<Typography variant="h5" fontWeight={700} color="text.primary">Objekte</Typography>
<Chip label={properties.length} size="small" sx={{ bgcolor: '#1e3a5f', color: 'white', fontWeight: 700 }} />
</Box>
<Tooltip title="In Entwicklung">
<span>
<button
disabled
style={{
display: 'flex',
alignItems: 'center',
gap: 6,
padding: '6px 16px',
border: 'none',
borderRadius: 4,
background: '#1e3a5f',
color: 'white',
cursor: 'not-allowed',
opacity: 0.5,
fontSize: 14,
fontWeight: 500,
}}
>
<Plus size={16} />
Neues Objekt
</button>
</span>
</Tooltip>
</Box>
{/* Content */}
<Box sx={{ px: 3, py: 3 }} className="flex flex-col gap-3">
{/* Filter Bar */}
<Card sx={{ elevation: 1, p: 1.5 }}>
<Box className="flex flex-col gap-2">
{/* Row 1: Source type chips */}
<Box className="flex items-center gap-2 flex-wrap">
{sourceTypeFilters.map(f => (
<Chip
key={f.value}
label={f.label}
variant={selectedResultType === f.value ? 'filled' : 'outlined'}
size="small"
onClick={() => setSelectedResultType(f.value)}
sx={
selectedResultType === f.value
? { bgcolor: f.color, color: 'white', borderColor: f.color, fontWeight: 600, cursor: 'pointer' }
: { borderColor: f.color, color: f.color, cursor: 'pointer' }
}
/>
))}
</Box>
{/* Row 2: Asset type select + search */}
<Box className="flex items-center gap-2">
<Select
value={selectedAssetType}
onChange={e => setSelectedAssetType(e.target.value as AssetType | 'ALL')}
size="small"
sx={{ minWidth: 160 }}
>
<MenuItem value="ALL">Alle Typen</MenuItem>
{Object.values(AssetType).map(t => (
<MenuItem key={t} value={t}>{getAssetTypeLabel(t)}</MenuItem>
))}
</Select>
<TextField
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
placeholder="Suche nach Titel, Stadt, Strasse…"
size="small"
sx={{ ml: 'auto', minWidth: 260 }}
/>
</Box>
</Box>
</Card>
{/* Properties Table */}
{filtered.length === 0 ? (
<EmptyState title="Keine Objekte gefunden" description="Passen Sie die Filter an, um Ergebnisse anzuzeigen." />
) : (
<Card sx={{ elevation: 1 }}>
<Table stickyHeader size="small">
<TableHead>
<TableRow sx={{ bgcolor: 'grey.50' }}>
<TableCell><Typography variant="caption" fontWeight={600}>Objekt</Typography></TableCell>
<TableCell><Typography variant="caption" fontWeight={600}>Typ</Typography></TableCell>
<TableCell><Typography variant="caption" fontWeight={600}>Standort</Typography></TableCell>
<TableCell><Typography variant="caption" fontWeight={600}>Fläche</Typography></TableCell>
<TableCell><Typography variant="caption" fontWeight={600}>Miete/m²</Typography></TableCell>
<TableCell><Typography variant="caption" fontWeight={600}>Quelle</Typography></TableCell>
<TableCell><Typography variant="caption" fontWeight={600}>Konfidenz</Typography></TableCell>
<TableCell><Typography variant="caption" fontWeight={600}>Datenqualität</Typography></TableCell>
<TableCell><Typography variant="caption" fontWeight={600}>Verfügbarkeit</Typography></TableCell>
<TableCell><Typography variant="caption" fontWeight={600}>Aktionen</Typography></TableCell>
</TableRow>
</TableHead>
<TableBody>
{filtered.map(property => {
const hasCritical = property.dataQuality.missingCriticalFields.length > 0
return (
<TableRow
key={property.id}
hover
sx={hasCritical ? { bgcolor: 'rgba(192,57,43,0.04)' } : {}}
>
{/* Objekt */}
<TableCell>
<Typography variant="body2" fontWeight={500}>{property.title}</Typography>
<Typography variant="caption" color="text.secondary">
{property.address.street} {property.address.houseNumber}, {property.address.city}
</Typography>
</TableCell>
{/* Typ */}
<TableCell>
<Chip
label={getAssetTypeLabel(property.assetType)}
size="small"
sx={{ bgcolor: getAssetTypeColor(property.assetType), color: 'white', fontSize: 11 }}
/>
</TableCell>
{/* Standort */}
<TableCell>
<Typography variant="body2">{property.location.city}</Typography>
{property.location.canton && (
<Typography variant="caption" color="text.secondary">{property.location.canton}</Typography>
)}
</TableCell>
{/* Fläche */}
<TableCell>
<Typography variant="body2">{property.areaSqm.toLocaleString('de-CH')} m²</Typography>
</TableCell>
{/* Miete/m² */}
<TableCell>
<Typography variant="body2">CHF {property.rentPricePerSqm}</Typography>
</TableCell>
{/* Quelle */}
<TableCell>
<Chip
label={getResultTypeLabel(property.resultType)}
size="small"
sx={{ bgcolor: getResultTypeColor(property.resultType), color: 'white', fontSize: 11 }}
/>
</TableCell>
{/* Konfidenz */}
<TableCell>
<Typography
variant="body2"
fontWeight={600}
sx={{ color: getConfidenceColor(property.confidenceScore) }}
>
{Math.round(property.confidenceScore * 100)}%
</Typography>
</TableCell>
{/* Datenqualität */}
<TableCell>
<Tooltip
title={
<Box>
{property.dataQuality.missingCriticalFields.length > 0 && (
<Box>
<Typography variant="caption" fontWeight={600}>Kritische Felder fehlen:</Typography>
{property.dataQuality.missingCriticalFields.map(f => (
<Typography key={f} variant="caption" display="block"> {f}</Typography>
))}
</Box>
)}
{property.dataQuality.warnings.length > 0 && (
<Box mt={0.5}>
<Typography variant="caption" fontWeight={600}>Warnungen:</Typography>
{property.dataQuality.warnings.map((w, i) => (
<Typography key={i} variant="caption" display="block"> {w}</Typography>
))}
</Box>
)}
{property.dataQuality.missingCriticalFields.length === 0 && property.dataQuality.warnings.length === 0 && (
<Typography variant="caption">Keine Probleme</Typography>
)}
</Box>
}
>
<Box sx={{ width: 80 }}>
<LinearProgress
variant="determinate"
value={property.dataQuality.score * 100}
color={getQualityColor(property.dataQuality.score)}
sx={{ height: 6, borderRadius: 3 }}
/>
<Typography variant="caption" color="text.secondary">
{Math.round(property.dataQuality.score * 100)}%
</Typography>
</Box>
</Tooltip>
</TableCell>
{/* Verfügbarkeit */}
<TableCell>
<Chip
label={getAvailabilityLabel(property.availabilityStatus)}
color={getAvailabilityColor(property.availabilityStatus)}
size="small"
/>
</TableCell>
{/* Aktionen */}
<TableCell>
<Box className="flex items-center gap-1">
<Tooltip title="Details (in Entwicklung)">
<span>
<IconButton size="small" disabled>
<Eye size={16} />
</IconButton>
</span>
</Tooltip>
<Tooltip title="Mehr Aktionen (in Entwicklung)">
<span>
<IconButton size="small" disabled>
<MoreHorizontal size={16} />
</IconButton>
</span>
</Tooltip>
</Box>
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</Card>
)}
</Box>
</Box>
)
}
+283
View File
@@ -0,0 +1,283 @@
import { Box, Card, Chip, LinearProgress, Table, TableBody, TableCell, TableHead, TableRow, Tooltip, Typography } from '@mui/material'
import { useQuery } from '@tanstack/react-query'
import { AlertTriangle, BarChart2, Building2, Target } from 'lucide-react'
import { SectionContainer, LoadingPage, ErrorState } from '../../components/ui'
import { propertyService } from '../../services/propertyService'
import { matchService } from '../../services/matchService'
import { governanceService } from '../../services/governanceService'
import { ResultType, MatchStrength } from '../../domain/enums'
import type { ActivityEventType } from '../../services/governanceService'
function getMatchStrengthColor(strength: MatchStrength): 'success' | 'warning' | 'error' {
if (strength === MatchStrength.STRONG) return 'success'
if (strength === MatchStrength.MODERATE) return 'warning'
return 'error'
}
function getMatchStrengthLabel(strength: MatchStrength): string {
if (strength === MatchStrength.STRONG) return 'Stark'
if (strength === MatchStrength.MODERATE) return 'Mittel'
return 'Schwach'
}
function getEventDescription(type: ActivityEventType): string {
switch (type) {
case 'PROPERTY_CREATED': return 'hat ein Objekt erstellt'
case 'PROPERTY_UPDATED': return 'hat ein Objekt aktualisiert'
case 'MATCH_APPROVED': return 'hat einen Match genehmigt'
case 'MATCH_REJECTED': return 'hat einen Match abgelehnt'
case 'SIGNAL_VERIFIED': return 'hat ein Signal verifiziert'
case 'NEED_CREATED': return 'hat einen Bedarf erstellt'
case 'REVIEW_REQUESTED': return 'hat eine Überprüfung angefordert'
}
}
function getEventColor(type: ActivityEventType): string {
switch (type) {
case 'PROPERTY_CREATED': return '#1e3a5f'
case 'PROPERTY_UPDATED': return '#1e3a5f'
case 'MATCH_APPROVED': return '#1a7a4a'
case 'MATCH_REJECTED': return '#c0392b'
case 'SIGNAL_VERIFIED': return '#7c3aed'
case 'NEED_CREATED': return '#d97706'
case 'REVIEW_REQUESTED': return '#d97706'
}
}
function formatTimeAgo(dateStr: string): string {
const diff = Date.now() - new Date(dateStr).getTime()
const hours = Math.floor(diff / 3600000)
const days = Math.floor(hours / 24)
if (hours < 1) return 'vor weniger als 1 Stunde'
if (hours < 24) return `vor ${hours} Stunde${hours > 1 ? 'n' : ''}`
return `vor ${days} Tag${days > 1 ? 'en' : ''}`
}
export default function SupplyDashboard() {
const { data: propResp, isLoading: propLoading, error: propError } = useQuery({
queryKey: ['properties'],
queryFn: () => propertyService.getAll(),
})
const { data: matchResp, isLoading: matchLoading, error: matchError } = useQuery({
queryKey: ['matches'],
queryFn: () => matchService.getAll(),
})
const { data: activityResp, isLoading: activityLoading, error: activityError } = useQuery({
queryKey: ['activity', 'org-wincasa'],
queryFn: () => governanceService.getActivityLog('org-wincasa'),
})
if (propLoading || matchLoading || activityLoading) return <LoadingPage />
if (propError || matchError || activityError) return <ErrorState />
const properties = propResp?.data ?? []
const matches = matchResp?.data ?? []
const activities = activityResp?.data ?? []
const avgQuality = properties.length
? properties.reduce((sum, p) => sum + p.dataQuality.score, 0) / properties.length
: 0
const pendingReview = properties.filter(p => p.dataQuality.missingCriticalFields.length > 0).length
const avgQualityColor = avgQuality >= 0.8 ? 'success.main' : avgQuality >= 0.6 ? 'warning.main' : 'error.main'
const verifiedCount = properties.filter(p => p.resultType === ResultType.VERIFIED_PORTFOLIO).length
const marketCount = properties.filter(p => p.resultType === ResultType.EXTERNAL_MARKET).length
const futureCount = properties.filter(p => p.resultType === ResultType.FUTURE_AVAILABILITY).length
const topMatches = [...matches].sort((a, b) => b.matchScore - a.matchScore).slice(0, 3)
return (
<Box>
{/* Page Header */}
<Box sx={{ bgcolor: 'white', borderBottom: '1px solid', borderColor: 'divider', px: 3, py: 2 }}>
<Typography variant="h5" fontWeight={700} color="text.primary">Supply Dashboard</Typography>
<Typography variant="body2" color="text.secondary">Portfolioübersicht und aktuelle Kennzahlen</Typography>
</Box>
{/* Content */}
<Box sx={{ px: 3, py: 3 }} className="flex flex-col gap-4">
{/* Section 1 - KPI Cards */}
<Box className="grid grid-cols-4 gap-4">
{/* Objekte */}
<Card sx={{ elevation: 1, p: 2.5 }}>
<Box className="flex flex-col gap-1">
<Box className="flex items-center justify-between">
<Typography variant="overline" color="text.secondary" lineHeight={1.4}>Objekte</Typography>
<Building2 size={20} color="#1e3a5f" />
</Box>
<Typography variant="h3" fontWeight={700} color="text.primary">{properties.length}</Typography>
<Typography variant="caption" color="text.secondary">Gesamtportfolio</Typography>
</Box>
</Card>
{/* Aktive Matches */}
<Card sx={{ elevation: 1, p: 2.5 }}>
<Box className="flex flex-col gap-1">
<Box className="flex items-center justify-between">
<Typography variant="overline" color="text.secondary" lineHeight={1.4}>Aktive Matches</Typography>
<Target size={20} color="#1a7a4a" />
</Box>
<Typography variant="h3" fontWeight={700} color="text.primary">{matches.length}</Typography>
<Typography variant="caption" color="text.secondary">KI-generierte Matches</Typography>
</Box>
</Card>
{/* Ø Datenqualität */}
<Card sx={{ elevation: 1, p: 2.5 }}>
<Box className="flex flex-col gap-1">
<Box className="flex items-center justify-between">
<Typography variant="overline" color="text.secondary" lineHeight={1.4}>Ø Datenqualität</Typography>
<BarChart2 size={20} color={avgQuality >= 0.8 ? '#1a7a4a' : avgQuality >= 0.6 ? '#d97706' : '#c0392b'} />
</Box>
<Typography variant="h3" fontWeight={700} sx={{ color: avgQualityColor }}>
{Math.round(avgQuality * 100)}%
</Typography>
<Typography variant="caption" color="text.secondary">Durchschnittlicher Score</Typography>
</Box>
</Card>
{/* Prüfungen ausstehend */}
<Card sx={{ elevation: 1, p: 2.5 }}>
<Box className="flex flex-col gap-1">
<Box className="flex items-center justify-between">
<Typography variant="overline" color="text.secondary" lineHeight={1.4}>Prüfungen ausstehend</Typography>
<AlertTriangle size={20} color="#d97706" />
</Box>
<Typography variant="h3" fontWeight={700} sx={{ color: pendingReview > 0 ? 'warning.main' : 'text.primary' }}>
{pendingReview}
</Typography>
<Typography variant="caption" color="text.secondary">Kritische Felder fehlen</Typography>
</Box>
</Card>
</Box>
{/* Section 2 - Portfolio Overview */}
<Box className="grid grid-cols-3 gap-4">
<Card sx={{ elevation: 1, borderTop: '4px solid #1e3a5f', p: 2.5 }}>
<Typography variant="h4" fontWeight={700} color="text.primary">{verifiedCount}</Typography>
<Typography variant="body2" color="text.secondary">Verified Portfolio</Typography>
</Card>
<Card sx={{ elevation: 1, borderTop: '4px solid #d97706', p: 2.5 }}>
<Typography variant="h4" fontWeight={700} color="text.primary">{marketCount}</Typography>
<Typography variant="body2" color="text.secondary">Marktinserate</Typography>
</Card>
<Card sx={{ elevation: 1, borderTop: '4px solid #7c3aed', p: 2.5 }}>
<Typography variant="h4" fontWeight={700} color="text.primary">{futureCount}</Typography>
<Typography variant="body2" color="text.secondary">Zukunftssignale</Typography>
</Card>
</Box>
{/* Section 3 - Recent Matches */}
<SectionContainer title="Aktuelle Matches">
<Card sx={{ elevation: 1 }}>
<Table>
<TableHead>
<TableRow sx={{ bgcolor: 'grey.50' }}>
<TableCell><Typography variant="caption" fontWeight={600}>Objekt</Typography></TableCell>
<TableCell><Typography variant="caption" fontWeight={600}>Unternehmen</Typography></TableCell>
<TableCell><Typography variant="caption" fontWeight={600}>Score</Typography></TableCell>
<TableCell><Typography variant="caption" fontWeight={600}>Stärke</Typography></TableCell>
<TableCell><Typography variant="caption" fontWeight={600}>Aktion</Typography></TableCell>
</TableRow>
</TableHead>
<TableBody>
{topMatches.map(match => {
const property = properties.find(p => p.id === match.propertyId)
return (
<TableRow key={match.id} hover>
<TableCell>
<Typography variant="body2" fontWeight={500}>{property?.title ?? match.propertyId}</Typography>
</TableCell>
<TableCell>
<Typography variant="body2" color="text.secondary">{match.needId}</Typography>
</TableCell>
<TableCell>
<Box className="flex items-center gap-2">
<Typography variant="body2" fontWeight={700}>{match.matchScore}</Typography>
<Box sx={{ width: 80 }}>
<LinearProgress
variant="determinate"
value={match.matchScore}
color={match.matchStrength === MatchStrength.STRONG ? 'success' : match.matchStrength === MatchStrength.MODERATE ? 'warning' : 'error'}
/>
</Box>
</Box>
</TableCell>
<TableCell>
<Chip
label={getMatchStrengthLabel(match.matchStrength)}
color={getMatchStrengthColor(match.matchStrength)}
size="small"
/>
</TableCell>
<TableCell>
<Tooltip title="Details-Seite in Entwicklung">
<span>
<button
disabled
style={{
padding: '4px 12px',
border: '1px solid rgba(0,0,0,0.23)',
borderRadius: 4,
background: 'transparent',
cursor: 'not-allowed',
opacity: 0.5,
fontSize: 13,
}}
>
Details
</button>
</span>
</Tooltip>
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</Card>
</SectionContainer>
{/* Section 4 - Activity Log */}
<SectionContainer title="Aktivitäten">
<Card sx={{ elevation: 1, p: 2 }}>
<Box className="flex flex-col gap-3">
{activities.slice(0, 5).map(event => (
<Box key={event.id} className="flex items-center gap-3">
<Box
sx={{
width: 32,
height: 32,
borderRadius: '50%',
bgcolor: getEventColor(event.type),
flexShrink: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Building2 size={14} color="white" />
</Box>
<Box className="flex-1">
<Typography variant="body2">
<strong>{event.performedBy}</strong> {getEventDescription(event.type)}
</Typography>
{event.notes && (
<Typography variant="caption" color="text.secondary">{event.notes}</Typography>
)}
</Box>
<Typography variant="caption" color="text.secondary" sx={{ flexShrink: 0 }}>
{formatTimeAgo(event.createdAt)}
</Typography>
</Box>
))}
</Box>
</Card>
</SectionContainer>
</Box>
</Box>
)
}
+16
View File
@@ -0,0 +1,16 @@
import type { FutureSignal } from '../domain/futureSignal'
import type { SignalType } from '../domain/enums'
export interface FutureSignalFilters {
signalType?: SignalType
minProbability?: number
organizationId?: string
isVerified?: boolean
}
export interface IFutureSignalProvider {
getAll(filters?: FutureSignalFilters): Promise<FutureSignal[]>
getById(id: string): Promise<FutureSignal | null>
getByProperty(propertyId: string): Promise<FutureSignal[]>
verify(id: string, verifiedBy: string): Promise<FutureSignal>
}
+18
View File
@@ -0,0 +1,18 @@
import type { Match } from '../domain/match'
import type { MatchStrength } from '../domain/enums'
export interface MatchFilters {
needId?: string
propertyId?: string
minScore?: number
matchStrength?: MatchStrength
organizationId?: string
}
export interface IMatchProvider {
getAll(filters?: MatchFilters): Promise<Match[]>
getById(id: string): Promise<Match | null>
getByNeed(needId: string): Promise<Match[]>
getByProperty(propertyId: string): Promise<Match[]>
approve(id: string, reviewedBy: string): Promise<Match>
}
+16
View File
@@ -0,0 +1,16 @@
import type { Need, CreateNeedInput, UpdateNeedInput } from '../domain/need'
import type { AssetType } from '../domain/enums'
export interface NeedFilters {
assetType?: AssetType
organizationId?: string
companyName?: string
}
export interface INeedProvider {
getAll(filters?: NeedFilters): Promise<Need[]>
getById(id: string): Promise<Need | null>
create(data: CreateNeedInput): Promise<Need>
update(id: string, data: UpdateNeedInput): Promise<Need>
remove(id: string): Promise<void>
}
+19
View File
@@ -0,0 +1,19 @@
import type { Property, CreatePropertyInput, UpdatePropertyInput } from '../domain/property'
import type { AssetType, ResultType } from '../domain/enums'
export interface PropertyFilters {
assetType?: AssetType
resultType?: ResultType
city?: string
minAreaSqm?: number
maxRentPerSqm?: number
organizationId?: string
}
export interface IPropertyProvider {
getAll(filters?: PropertyFilters): Promise<Property[]>
getById(id: string): Promise<Property | null>
create(data: CreatePropertyInput): Promise<Property>
update(id: string, data: UpdatePropertyInput): Promise<Property>
remove(id: string): Promise<void>
}
@@ -0,0 +1,27 @@
import type { IFutureSignalProvider, FutureSignalFilters } from './IFutureSignalProvider'
import type { FutureSignal } from '../domain/futureSignal'
import { mockFutureSignals } from '../mock-data/futureSignals'
const store: FutureSignal[] = [...mockFutureSignals]
export const MockupFutureSignalProvider: IFutureSignalProvider = {
async getAll(filters?: FutureSignalFilters) {
let results = [...store]
if (filters?.signalType) results = results.filter(s => s.signalType === filters.signalType)
if (filters?.minProbability !== undefined) results = results.filter(s => s.probability >= filters.minProbability!)
if (filters?.organizationId) results = results.filter(s => s.organizationId === filters.organizationId)
if (filters?.isVerified !== undefined) results = results.filter(s => s.isVerified === filters.isVerified)
return results.sort((a, b) => b.probability - a.probability)
},
async getById(id) {
return store.find(s => s.id === id) ?? null
},
async getByProperty(propertyId) {
return store.filter(s => s.propertyId === propertyId)
},
async verify(id, verifiedBy) {
const idx = store.findIndex(s => s.id === id)
store[idx] = { ...store[idx], isVerified: true, verifiedBy, verifiedAt: new Date().toISOString(), updatedAt: new Date().toISOString() }
return store[idx]
},
}
+31
View File
@@ -0,0 +1,31 @@
import type { IMatchProvider, MatchFilters } from './IMatchProvider'
import type { Match } from '../domain/match'
import { mockMatches } from '../mock-data/matches'
const store: Match[] = [...mockMatches]
export const MockupMatchProvider: IMatchProvider = {
async getAll(filters?: MatchFilters) {
let results = [...store]
if (filters?.needId) results = results.filter(m => m.needId === filters.needId)
if (filters?.propertyId) results = results.filter(m => m.propertyId === filters.propertyId)
if (filters?.minScore) results = results.filter(m => m.matchScore >= filters.minScore!)
if (filters?.matchStrength) results = results.filter(m => m.matchStrength === filters.matchStrength)
if (filters?.organizationId) results = results.filter(m => m.organizationId === filters.organizationId)
return results.sort((a, b) => b.matchScore - a.matchScore)
},
async getById(id) {
return store.find(m => m.id === id) ?? null
},
async getByNeed(needId) {
return store.filter(m => m.needId === needId).sort((a, b) => b.matchScore - a.matchScore)
},
async getByProperty(propertyId) {
return store.filter(m => m.propertyId === propertyId).sort((a, b) => b.matchScore - a.matchScore)
},
async approve(id, reviewedBy) {
const idx = store.findIndex(m => m.id === id)
store[idx] = { ...store[idx], isApproved: true, reviewedBy, reviewedAt: new Date().toISOString(), updatedAt: new Date().toISOString() }
return store[idx]
},
}
+32
View File
@@ -0,0 +1,32 @@
import type { INeedProvider, NeedFilters } from './INeedProvider'
import type { Need, CreateNeedInput, UpdateNeedInput } from '../domain/need'
import { mockNeeds } from '../mock-data/needs'
const store: Need[] = [...mockNeeds]
export const MockupNeedProvider: INeedProvider = {
async getAll(filters?: NeedFilters) {
let results = [...store]
if (filters?.assetType) results = results.filter(n => n.assetType === filters.assetType)
if (filters?.organizationId) results = results.filter(n => n.organizationId === filters.organizationId)
if (filters?.companyName) results = results.filter(n => n.companyName.toLowerCase().includes(filters.companyName!.toLowerCase()))
return results
},
async getById(id) {
return store.find(n => n.id === id) ?? null
},
async create(data: CreateNeedInput) {
const next: Need = { id: crypto.randomUUID(), ...data, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() }
store.push(next)
return next
},
async update(id, data: UpdateNeedInput) {
const idx = store.findIndex(n => n.id === id)
store[idx] = { ...store[idx], ...data, updatedAt: new Date().toISOString() }
return store[idx]
},
async remove(id) {
const idx = store.findIndex(n => n.id === id)
store.splice(idx, 1)
},
}
+35
View File
@@ -0,0 +1,35 @@
import type { IPropertyProvider, PropertyFilters } from './IPropertyProvider'
import type { Property, CreatePropertyInput, UpdatePropertyInput } from '../domain/property'
import { mockProperties } from '../mock-data/properties'
const store: Property[] = [...mockProperties]
export const MockupPropertyProvider: IPropertyProvider = {
async getAll(filters?: PropertyFilters) {
let results = [...store]
if (filters?.assetType) results = results.filter(p => p.assetType === filters.assetType)
if (filters?.resultType) results = results.filter(p => p.resultType === filters.resultType)
if (filters?.city) results = results.filter(p => p.location.city.toLowerCase().includes(filters.city!.toLowerCase()))
if (filters?.minAreaSqm) results = results.filter(p => p.areaSqm >= filters.minAreaSqm!)
if (filters?.maxRentPerSqm) results = results.filter(p => p.rentPricePerSqm <= filters.maxRentPerSqm!)
if (filters?.organizationId) results = results.filter(p => p.organizationId === filters.organizationId)
return results
},
async getById(id) {
return store.find(p => p.id === id) ?? null
},
async create(data: CreatePropertyInput) {
const next: Property = { id: crypto.randomUUID(), ...data, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() }
store.push(next)
return next
},
async update(id, data: UpdatePropertyInput) {
const idx = store.findIndex(p => p.id === id)
store[idx] = { ...store[idx], ...data, updatedAt: new Date().toISOString() }
return store[idx]
},
async remove(id) {
const idx = store.findIndex(p => p.id === id)
store.splice(idx, 1)
},
}
+57
View File
@@ -0,0 +1,57 @@
import type { ItemResponse } from './types'
import type { CreateNeedInput } from '../domain/need'
export interface CriteriaExtractionResult {
extractedCriteria: Partial<CreateNeedInput>
confidence: number
missingFields: string[]
assumptions: string[]
followUpQuestions: string[]
}
export interface AIServiceProvider {
extractCriteria(naturalLanguageInput: string): Promise<CriteriaExtractionResult>
generateFollowUp(partialNeed: Partial<CreateNeedInput>): Promise<string[]>
}
const MockupAIServiceProvider: AIServiceProvider = {
async extractCriteria(input: string): Promise<CriteriaExtractionResult> {
// Deterministic mock extraction — simulates AI parsing
return {
extractedCriteria: {
companyName: 'Unbekannt (bitte bestätigen)',
requiredArea: { min: 400, max: 900 },
budgetRange: { maxPerSqm: 40, currency: 'CHF' },
},
confidence: 0.72,
missingFields: ['assetType', 'timing', 'preferredLocations'],
assumptions: ['Fläche aus Zahlenangabe geschätzt', 'Budget aus Kostennennung abgeleitet'],
followUpQuestions: [
'Welchen Nutzungstyp suchen Sie (Büro, Retail, Logistik)?',
'In welchen Städten oder Regionen suchen Sie?',
'Wann möchten Sie spätestens einziehen?',
],
}
},
async generateFollowUp(partialNeed: Partial<CreateNeedInput>): Promise<string[]> {
const questions: string[] = []
if (!partialNeed.assetType) questions.push('Welchen Nutzungstyp suchen Sie?')
if (!partialNeed.preferredLocations?.length) questions.push('In welchen Regionen suchen Sie?')
if (!partialNeed.timing) questions.push('Was ist Ihr gewünschter Einzugstermin?')
if (!partialNeed.budgetRange) questions.push('Was ist Ihr maximales monatliches Budget?')
return questions
},
}
const provider = MockupAIServiceProvider
export const aiService = {
async extractCriteria(input: string): Promise<ItemResponse<CriteriaExtractionResult>> {
const data = await provider.extractCriteria(input)
return { data }
},
async generateFollowUp(partialNeed: Partial<CreateNeedInput>): Promise<ItemResponse<string[]>> {
const data = await provider.generateFollowUp(partialNeed)
return { data }
},
}
+25
View File
@@ -0,0 +1,25 @@
import { MockupFutureSignalProvider } from '../provider/MockupFutureSignalProvider'
import type { FutureSignalFilters } from '../provider/IFutureSignalProvider'
import type { FutureSignal } from '../domain/futureSignal'
import type { ListResponse, ItemResponse } from './types'
const provider = MockupFutureSignalProvider
export const futureSignalService = {
async getAll(filters?: FutureSignalFilters): Promise<ListResponse<FutureSignal>> {
const data = await provider.getAll(filters)
return { data, meta: { total: data.length, page: 1, pageSize: data.length, hasMore: false } }
},
async getById(id: string): Promise<ItemResponse<FutureSignal | null>> {
const data = await provider.getById(id)
return { data }
},
async getByProperty(propertyId: string): Promise<ListResponse<FutureSignal>> {
const data = await provider.getByProperty(propertyId)
return { data, meta: { total: data.length, page: 1, pageSize: data.length, hasMore: false } }
},
async verify(id: string, verifiedBy: string): Promise<ItemResponse<FutureSignal>> {
const data = await provider.verify(id, verifiedBy)
return { data }
},
}
+41
View File
@@ -0,0 +1,41 @@
import type { ListResponse, ItemResponse } from './types'
export type ActivityEventType =
| 'PROPERTY_CREATED'
| 'PROPERTY_UPDATED'
| 'MATCH_APPROVED'
| 'MATCH_REJECTED'
| 'SIGNAL_VERIFIED'
| 'NEED_CREATED'
| 'REVIEW_REQUESTED'
export interface ActivityEvent {
id: string
type: ActivityEventType
entityId: string
entityType: 'PROPERTY' | 'MATCH' | 'NEED' | 'SIGNAL'
performedBy: string
organizationId: string
notes?: string
createdAt: string
}
const mockActivityLog: ActivityEvent[] = [
{ id: 'evt-001', type: 'PROPERTY_CREATED', entityId: 'prop-001', entityType: 'PROPERTY', performedBy: 'admin@ideal-sharing.ch', organizationId: 'org-wincasa', createdAt: '2025-01-10T08:00:00Z' },
{ id: 'evt-002', type: 'MATCH_APPROVED', entityId: 'match-003', entityType: 'MATCH', performedBy: 'admin@ideal-sharing.ch', organizationId: 'org-wincasa', notes: 'Starker Match bestätigt', createdAt: '2025-05-10T09:00:00Z' },
{ id: 'evt-003', type: 'SIGNAL_VERIFIED', entityId: 'signal-003', entityType: 'SIGNAL', performedBy: 'admin@ideal-sharing.ch', organizationId: 'org-wincasa', createdAt: '2025-05-05T09:00:00Z' },
]
const store = [...mockActivityLog]
export const governanceService = {
async getActivityLog(organizationId?: string): Promise<ListResponse<ActivityEvent>> {
const data = organizationId ? store.filter(e => e.organizationId === organizationId) : [...store]
return { data: data.sort((a, b) => b.createdAt.localeCompare(a.createdAt)), meta: { total: data.length, page: 1, pageSize: data.length, hasMore: false } }
},
async logEvent(event: Omit<ActivityEvent, 'id' | 'createdAt'>): Promise<ItemResponse<ActivityEvent>> {
const data: ActivityEvent = { id: crypto.randomUUID(), ...event, createdAt: new Date().toISOString() }
store.push(data)
return { data }
},
}
+29
View File
@@ -0,0 +1,29 @@
import { MockupMatchProvider } from '../provider/MockupMatchProvider'
import type { MatchFilters } from '../provider/IMatchProvider'
import type { Match } from '../domain/match'
import type { ListResponse, ItemResponse } from './types'
const provider = MockupMatchProvider
export const matchService = {
async getAll(filters?: MatchFilters): Promise<ListResponse<Match>> {
const data = await provider.getAll(filters)
return { data, meta: { total: data.length, page: 1, pageSize: data.length, hasMore: false } }
},
async getById(id: string): Promise<ItemResponse<Match | null>> {
const data = await provider.getById(id)
return { data }
},
async getByNeed(needId: string): Promise<ListResponse<Match>> {
const data = await provider.getByNeed(needId)
return { data, meta: { total: data.length, page: 1, pageSize: data.length, hasMore: false } }
},
async getByProperty(propertyId: string): Promise<ListResponse<Match>> {
const data = await provider.getByProperty(propertyId)
return { data, meta: { total: data.length, page: 1, pageSize: data.length, hasMore: false } }
},
async approve(id: string, reviewedBy: string): Promise<ItemResponse<Match>> {
const data = await provider.approve(id, reviewedBy)
return { data }
},
}
+29
View File
@@ -0,0 +1,29 @@
import { MockupNeedProvider } from '../provider/MockupNeedProvider'
import type { NeedFilters } from '../provider/INeedProvider'
import type { Need, CreateNeedInput, UpdateNeedInput } from '../domain/need'
import type { ListResponse, ItemResponse } from './types'
const provider = MockupNeedProvider
export const needService = {
async getAll(filters?: NeedFilters): Promise<ListResponse<Need>> {
const data = await provider.getAll(filters)
return { data, meta: { total: data.length, page: 1, pageSize: data.length, hasMore: false } }
},
async getById(id: string): Promise<ItemResponse<Need | null>> {
const data = await provider.getById(id)
return { data }
},
async create(input: CreateNeedInput): Promise<ItemResponse<Need>> {
const data = await provider.create(input)
return { data }
},
async update(id: string, input: UpdateNeedInput): Promise<ItemResponse<Need>> {
const data = await provider.update(id, input)
return { data }
},
async remove(id: string): Promise<ItemResponse<void>> {
await provider.remove(id)
return { data: undefined }
},
}
+30
View File
@@ -0,0 +1,30 @@
import { MockupPropertyProvider } from '../provider/MockupPropertyProvider'
import type { PropertyFilters } from '../provider/IPropertyProvider'
import type { CreatePropertyInput, UpdatePropertyInput } from '../domain/property'
import type { ListResponse, ItemResponse } from './types'
import type { Property } from '../domain/property'
const provider = MockupPropertyProvider
export const propertyService = {
async getAll(filters?: PropertyFilters): Promise<ListResponse<Property>> {
const data = await provider.getAll(filters)
return { data, meta: { total: data.length, page: 1, pageSize: data.length, hasMore: false } }
},
async getById(id: string): Promise<ItemResponse<Property | null>> {
const data = await provider.getById(id)
return { data }
},
async create(input: CreatePropertyInput): Promise<ItemResponse<Property>> {
const data = await provider.create(input)
return { data }
},
async update(id: string, input: UpdatePropertyInput): Promise<ItemResponse<Property>> {
const data = await provider.update(id, input)
return { data }
},
async remove(id: string): Promise<ItemResponse<void>> {
await provider.remove(id)
return { data: undefined }
},
}
+15
View File
@@ -0,0 +1,15 @@
export interface ServiceMeta {
total: number
page: number
pageSize: number
hasMore: boolean
}
export interface ServiceResponse<T> {
data: T
meta?: ServiceMeta
error?: string | null
}
export type ListResponse<T> = ServiceResponse<T[]>
export type ItemResponse<T> = ServiceResponse<T>
+27
View File
@@ -0,0 +1,27 @@
import { create } from 'zustand'
const MAX_COMPARE_ITEMS = 3
interface CompareState {
compareTray: string[]
addToCompare: (propertyId: string) => void
removeFromCompare: (propertyId: string) => void
clearCompare: () => void
isInCompare: (propertyId: string) => boolean
isFull: () => boolean
}
export const useCompareStore = create<CompareState>((set, get) => ({
compareTray: [],
addToCompare: (propertyId) =>
set((state) => {
if (state.compareTray.length >= MAX_COMPARE_ITEMS) return state
if (state.compareTray.includes(propertyId)) return state
return { compareTray: [...state.compareTray, propertyId] }
}),
removeFromCompare: (propertyId) =>
set((state) => ({ compareTray: state.compareTray.filter(id => id !== propertyId) })),
clearCompare: () => set({ compareTray: [] }),
isInCompare: (propertyId) => get().compareTray.includes(propertyId),
isFull: () => get().compareTray.length >= MAX_COMPARE_ITEMS,
}))
+22
View File
@@ -0,0 +1,22 @@
import { create } from 'zustand'
import { WorkspaceType } from '../domain/enums'
interface LayoutState {
activeWorkspace: WorkspaceType
sidebarCollapsed: boolean
pinnedPanels: string[]
setActiveWorkspace: (workspace: WorkspaceType) => void
toggleSidebar: () => void
pinPanel: (panelId: string) => void
unpinPanel: (panelId: string) => void
}
export const useLayoutStore = create<LayoutState>((set) => ({
activeWorkspace: WorkspaceType.SUPPLY,
sidebarCollapsed: false,
pinnedPanels: [],
setActiveWorkspace: (workspace) => set({ activeWorkspace: workspace }),
toggleSidebar: () => set((state) => ({ sidebarCollapsed: !state.sidebarCollapsed })),
pinPanel: (panelId) => set((state) => ({ pinnedPanels: [...state.pinnedPanels, panelId] })),
unpinPanel: (panelId) => set((state) => ({ pinnedPanels: state.pinnedPanels.filter(id => id !== panelId) })),
}))
+38
View File
@@ -0,0 +1,38 @@
import { create } from 'zustand'
import { UserRole } from '../domain/enums'
export interface MockUser {
id: string
email: string
name: string
role: UserRole
organizationId: string
organizationName: string
}
interface SessionState {
currentUser: MockUser | null
activeOrganizationId: string | null
isAuthenticated: boolean
login: (user: MockUser) => void
logout: () => void
switchOrganization: (organizationId: string) => void
}
const mockUser: MockUser = {
id: 'user-001',
email: 'admin@ideal-sharing.ch',
name: 'Admin User',
role: UserRole.ORGANIZATION_ADMIN,
organizationId: 'org-wincasa',
organizationName: 'Wincasa AG',
}
export const useSessionStore = create<SessionState>((set) => ({
currentUser: mockUser,
activeOrganizationId: mockUser.organizationId,
isAuthenticated: true,
login: (user) => set({ currentUser: user, activeOrganizationId: user.organizationId, isAuthenticated: true }),
logout: () => set({ currentUser: null, activeOrganizationId: null, isAuthenticated: false }),
switchOrganization: (organizationId) => set({ activeOrganizationId: organizationId }),
}))
+25
View File
@@ -0,0 +1,25 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023", "DOM"],
"module": "esnext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+24
View File
@@ -0,0 +1,24 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023"],
"module": "esnext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}
+10
View File
@@ -0,0 +1,10 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
plugins: [
react(),
tailwindcss(),
],
})