Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9e825c04bf | ||
|
|
4349641ee9 | ||
|
|
bf453d2b69 | ||
|
|
e55be64aeb | ||
|
|
42a9bece36 | ||
|
|
093a958dcf | ||
|
|
435f2aa2f1 | ||
|
|
d8f8f728cc | ||
|
|
9ed21e33a7 |
No files matched your search
@@ -22,6 +22,7 @@
|
||||
"react-dom": "^19.2.6",
|
||||
"react-leaflet": "^5.0.0",
|
||||
"react-router": "^7.15.0",
|
||||
"three": "^0.185.1",
|
||||
"zod": "^3.25.17",
|
||||
"zustand": "^5.0.5"
|
||||
},
|
||||
@@ -34,6 +35,7 @@
|
||||
"@types/node": "^24.12.3",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/three": "^0.185.4",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"@vitest/coverage-v8": "^4.1.7",
|
||||
"eslint": "^10.3.0",
|
||||
@@ -516,6 +518,13 @@
|
||||
"node": ">=20.19.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dimforge/rapier3d-compat": {
|
||||
"version": "0.12.0",
|
||||
"resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz",
|
||||
"integrity": "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@dnd-kit/accessibility": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
|
||||
@@ -1935,6 +1944,13 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@tweenjs/tween.js": {
|
||||
"version": "23.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz",
|
||||
"integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@tybys/wasm-util": {
|
||||
"version": "0.10.2",
|
||||
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz",
|
||||
@@ -2060,6 +2076,35 @@
|
||||
"@types/react": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/stats.js": {
|
||||
"version": "0.17.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz",
|
||||
"integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/three": {
|
||||
"version": "0.185.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/three/-/three-0.185.4.tgz",
|
||||
"integrity": "sha512-gAsBIC07NIFrxjbf7tH2t71c38uulFfk/RFoC7FNBSjMRAQ8J1x/RBvusX0N5PJouaYFJawXQqfCQ0RKUx/1nA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@dimforge/rapier3d-compat": "~0.12.0",
|
||||
"@tweenjs/tween.js": "~23.1.3",
|
||||
"@types/stats.js": "*",
|
||||
"@types/webxr": ">=0.5.17",
|
||||
"fflate": "~0.8.2",
|
||||
"meshoptimizer": "~1.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/webxr": {
|
||||
"version": "0.5.24",
|
||||
"resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz",
|
||||
"integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||
"version": "8.59.3",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.3.tgz",
|
||||
@@ -3219,6 +3264,13 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/fflate": {
|
||||
"version": "0.8.3",
|
||||
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
|
||||
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/file-entry-cache": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
|
||||
@@ -4092,6 +4144,13 @@
|
||||
"dev": true,
|
||||
"license": "CC0-1.0"
|
||||
},
|
||||
"node_modules/meshoptimizer": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-1.1.1.tgz",
|
||||
"integrity": "sha512-oRFNWJRDA/WTrVj7NWvqa5HqE1t9MYDj2VaWirQCzCCrAd2GHrqR/sQezCxiWATPNlKTcRaPRHPJwIRoPBAp5g==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/min-indent": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz",
|
||||
@@ -4805,6 +4864,12 @@
|
||||
"url": "https://opencollective.com/webpack"
|
||||
}
|
||||
},
|
||||
"node_modules/three": {
|
||||
"version": "0.185.1",
|
||||
"resolved": "https://registry.npmjs.org/three/-/three-0.185.1.tgz",
|
||||
"integrity": "sha512-5aojFCXKwnjBRZvUnt3WFfEcvUJgkN5LlijRFN95hMy8WVkG4I0QNcJE+OuWvuJ0bOdStrbfXn0pkd6/QyiAlg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tinybench": {
|
||||
"version": "2.9.0",
|
||||
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
"react-dom": "^19.2.6",
|
||||
"react-leaflet": "^5.0.0",
|
||||
"react-router": "^7.15.0",
|
||||
"three": "^0.185.1",
|
||||
"zod": "^3.25.17",
|
||||
"zustand": "^5.0.5"
|
||||
},
|
||||
@@ -40,6 +41,7 @@
|
||||
"@types/node": "^24.12.3",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/three": "^0.185.4",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"@vitest/coverage-v8": "^4.1.7",
|
||||
"eslint": "^10.3.0",
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* Erzeugt die Agentenporträts aus den hochauflösenden Originalen.
|
||||
*
|
||||
* Die Porträts im Repository stammten bis Runde 6 aus einem HTML-Konzept und
|
||||
* lagen dort nur als kleine, liegende Zuschnitte vor — Ferdi etwa 215 × 160
|
||||
* Bildpunkte bei 6 KB. Im 3D-Meetingraum sind sie damit sichtbar unscharf, und
|
||||
* hochskalieren hilft nicht.
|
||||
*
|
||||
* Die Originale liegen in der Präsentation `PropertyOn_Engeli_v5.pptx` als
|
||||
* quadratische Bilder mit 1200 × 1200 Bildpunkten. Sie zeigen dieselben fünf
|
||||
* Personen — Kleidung, Frisur und Ausschnitt stimmen mit den bisherigen
|
||||
* Zuschnitten überein, es werden also keine Gesichter getauscht.
|
||||
*
|
||||
* Das Skript beschneidet den Rand (die Bilder tragen oben und an den Seiten
|
||||
* Reste des Folienrahmens), skaliert auf eine für den Browser sinnvolle Grösse
|
||||
* und schreibt sie nach `src/assets/team/`.
|
||||
*
|
||||
* Einmalig auszuführen; die Ergebnisse liegen als Assets im Repository:
|
||||
*
|
||||
* node scripts/build-agent-portraits.mjs
|
||||
*
|
||||
* Es braucht keine Bildbibliothek — die Verarbeitung läuft auf einem Canvas im
|
||||
* bereits vorhandenen Chrome, den auch die visuellen Prüfungen nutzen.
|
||||
*/
|
||||
|
||||
import { chromium } from 'playwright-core'
|
||||
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { inflateRawSync } from 'node:zlib'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url))
|
||||
const ziel = resolve(here, '../src/assets/team')
|
||||
|
||||
/** Quellpräsentation. Nur zum Erzeugen nötig, nicht Teil des Repositorys. */
|
||||
const QUELLE = process.env.PORTRAIT_SOURCE
|
||||
?? 'C:/Users/beni_/Downloads/PropertyOn_Engeli_v5.pptx'
|
||||
|
||||
/**
|
||||
* Zuordnung Bild → Agent.
|
||||
*
|
||||
* Die Reihenfolge entspricht der Titelfolie der Präsentation: Ferdi, Bruno,
|
||||
* Livia, Nora, Sina. Jede Zuordnung wurde einzeln gegen den bisherigen
|
||||
* Zuschnitt geprüft — eine frühere Quelle trug dieselben Vornamen für eine
|
||||
* andere Belegschaft, und ein vertauschtes Gesicht wäre schlimmer als ein
|
||||
* unscharfes.
|
||||
*/
|
||||
const ZUORDNUNG = {
|
||||
'image1.jpg': 'ferdi',
|
||||
'image2.jpg': 'bruno',
|
||||
'image3.jpg': 'livia',
|
||||
'image4.jpg': 'nora',
|
||||
'image5.jpg': 'sina',
|
||||
}
|
||||
|
||||
/** Kantenbeschnitt je Seite, als Anteil der Bildbreite. */
|
||||
const BESCHNITT = 0.03
|
||||
/** Zielgrösse. Reicht für jede Darstellung im Programm, bleibt klein im Bündel. */
|
||||
const GROESSE = 512
|
||||
|
||||
const CHROME = [
|
||||
process.env.CHROME_PATH,
|
||||
'C:/Program Files/Google/Chrome/Application/chrome.exe',
|
||||
'C:/Program Files (x86)/Google/Chrome/Application/chrome.exe',
|
||||
].filter(Boolean).find(p => existsSync(p))
|
||||
|
||||
if (!CHROME) throw new Error('Kein Chrome gefunden — CHROME_PATH setzen.')
|
||||
if (!existsSync(QUELLE)) throw new Error(`Quelldatei fehlt: ${QUELLE}`)
|
||||
|
||||
/** Bilder aus dem ZIP-Container der Präsentation lesen, ohne Fremdbibliothek. */
|
||||
function medienAusPptx(pfad, gesucht) {
|
||||
const b = readFileSync(pfad)
|
||||
const gefunden = {}
|
||||
let i = 0
|
||||
while (i < b.length - 4) {
|
||||
if (b.readUInt32LE(i) !== 0x04034b50) { i++; continue }
|
||||
const method = b.readUInt16LE(i + 8)
|
||||
const csize = b.readUInt32LE(i + 18)
|
||||
const nameLen = b.readUInt16LE(i + 26)
|
||||
const extraLen = b.readUInt16LE(i + 28)
|
||||
const name = b.slice(i + 30, i + 30 + nameLen).toString()
|
||||
const start = i + 30 + nameLen + extraLen
|
||||
const datei = name.split('/').pop()
|
||||
if (name.startsWith('ppt/media/') && gesucht.includes(datei)) {
|
||||
try {
|
||||
gefunden[datei] = method === 8
|
||||
? inflateRawSync(b.slice(start, start + csize))
|
||||
: b.slice(start, start + csize)
|
||||
} catch { /* beschädigter Eintrag — überspringen */ }
|
||||
}
|
||||
i = start + csize
|
||||
}
|
||||
return gefunden
|
||||
}
|
||||
|
||||
const roh = medienAusPptx(QUELLE, Object.keys(ZUORDNUNG))
|
||||
const fehlend = Object.keys(ZUORDNUNG).filter(f => !roh[f])
|
||||
if (fehlend.length) throw new Error(`Nicht gefunden: ${fehlend.join(', ')}`)
|
||||
|
||||
const browser = await chromium.launch({ executablePath: CHROME })
|
||||
const page = await browser.newPage()
|
||||
|
||||
for (const [datei, agent] of Object.entries(ZUORDNUNG)) {
|
||||
const dataUri = `data:image/jpeg;base64,${roh[datei].toString('base64')}`
|
||||
const ergebnis = await page.evaluate(async ({ src, beschnitt, groesse }) => {
|
||||
const img = await new Promise((ok, err) => {
|
||||
const el = new Image()
|
||||
el.onload = () => ok(el)
|
||||
el.onerror = err
|
||||
el.src = src
|
||||
})
|
||||
const rand = Math.round(Math.min(img.width, img.height) * beschnitt)
|
||||
const sw = img.width - 2 * rand
|
||||
const sh = img.height - 2 * rand
|
||||
const c = document.createElement('canvas')
|
||||
c.width = groesse
|
||||
c.height = groesse
|
||||
const g = c.getContext('2d')
|
||||
g.imageSmoothingEnabled = true
|
||||
g.imageSmoothingQuality = 'high'
|
||||
g.drawImage(img, rand, rand, sw, sh, 0, 0, groesse, groesse)
|
||||
return { dataUri: c.toDataURL('image/jpeg', 0.86), quelle: `${img.width}x${img.height}` }
|
||||
}, { src: dataUri, beschnitt: BESCHNITT, groesse: GROESSE })
|
||||
|
||||
const aus = resolve(ziel, `${agent}.jpg`)
|
||||
writeFileSync(aus, Buffer.from(ergebnis.dataUri.split(',')[1], 'base64'))
|
||||
const kb = (readFileSync(aus).length / 1024).toFixed(0)
|
||||
console.log(`${agent.padEnd(7)} ${ergebnis.quelle} → ${GROESSE}x${GROESSE}, ${kb} KB`)
|
||||
}
|
||||
|
||||
await browser.close()
|
||||
@@ -0,0 +1,190 @@
|
||||
/**
|
||||
* Erzeugt das Basler Panorama für den 3D-Meetingraum (Runde 6, §2).
|
||||
*
|
||||
* Vorher lagen zwei getrennte Fotoflächen vor den Glasfronten — vorne und
|
||||
* links. An deren Stossstelle war auf der linken Fensterseite eine harte Kante
|
||||
* sichtbar. Diese Naht lässt sich nicht wegblenden; sie verschwindet nur, wenn
|
||||
* es gar keine zwei Bilder mehr gibt.
|
||||
*
|
||||
* Dieses Skript baut aus den beiden Referenzfotos des Auftrags eine einzige
|
||||
* durchgehende Panoramadatei. Jede Quelle wird **genau einmal** verwendet —
|
||||
* kein Kacheln, kein Spiegeln (§2.2). Das gut wirkende Motiv vom Tischende
|
||||
* («front») bildet die linke Hälfte und bleibt in Ausschnitt und Farbe
|
||||
* unverändert; die Panoramaaufnahme («left») setzt es nach links fort, in
|
||||
* Helligkeit angeglichen und über 300 Pixel weich überblendet.
|
||||
*
|
||||
* Himmel und Wasser werden anschliessend aus der jeweiligen Randzeile nach oben
|
||||
* und unten gestreckt. Ohne das stünde über und unter dem Bildstreifen eine
|
||||
* harte Grenze, die im Raum als Kante sichtbar wäre.
|
||||
*
|
||||
* Einmalig auszuführen; das Ergebnis liegt als Asset im Repository:
|
||||
*
|
||||
* node scripts/build-basel-panorama.mjs
|
||||
*
|
||||
* Es braucht keine Bildbibliothek — die Komposition entsteht auf einem Canvas
|
||||
* im bereits vorhandenen Chrome, den auch die visuellen Prüfungen nutzen.
|
||||
*/
|
||||
|
||||
import { chromium } from 'playwright-core'
|
||||
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url))
|
||||
const assets = resolve(here, '../src/assets/staff')
|
||||
|
||||
const SRC_FRONT = resolve(assets, 'basel-front.jpg')
|
||||
const SRC_LEFT = resolve(assets, 'basel-left.jpg')
|
||||
const OUT = resolve(assets, 'basel-panorama.jpg')
|
||||
|
||||
/**
|
||||
* Zielmasse. Die Breite ergibt sich aus den beiden Quellen: jede einmal,
|
||||
* abzüglich der Überblendung. Grösser wäre reines Hochskalieren.
|
||||
*/
|
||||
const H = 660
|
||||
/** Wasserlinie im Ziel — beide Quellen werden darauf ausgerichtet. */
|
||||
const WATER_Y = 400
|
||||
/** Wasserlinie in den Quellen, als Anteil der Bildhöhe. */
|
||||
const FRONT_WATER = 0.60
|
||||
const LEFT_WATER = 0.64
|
||||
/** Skalierung der beiden Quellen. */
|
||||
const FRONT_SCALE = 1.3
|
||||
const LEFT_SCALE = 2.45
|
||||
|
||||
/**
|
||||
* Ausschnitt der Fortsetzung, in Pixeln der Quelle.
|
||||
*
|
||||
* Beide Referenzfotos zeigen das Münster. Nebeneinander gesetzt stünde es
|
||||
* zweimal im Bild — eine Doppelstruktur, die §2.1 ausdrücklich ausschliesst.
|
||||
* Aus der Panoramaaufnahme wird deshalb nur der Abschnitt östlich des Münsters
|
||||
* verwendet: die Häuserzeilen am Rhein. Das Münster selbst kommt genau einmal
|
||||
* vor, nämlich im Mittelmotiv vom Tischende.
|
||||
*/
|
||||
const LEFT_CROP = { sx: 330, sw: 370 }
|
||||
/** Breite der Überblendung zwischen Fortsetzung und Mittelmotiv. */
|
||||
const FEATHER = 320
|
||||
|
||||
const CHROME = [
|
||||
process.env.CHROME_PATH,
|
||||
'C:/Program Files/Google/Chrome/Application/chrome.exe',
|
||||
'C:/Program Files (x86)/Google/Chrome/Application/chrome.exe',
|
||||
].filter(Boolean).find(p => existsSync(p))
|
||||
|
||||
if (!CHROME) throw new Error('Kein Chrome gefunden — CHROME_PATH setzen.')
|
||||
for (const f of [SRC_FRONT, SRC_LEFT]) {
|
||||
if (!existsSync(f)) throw new Error(`Referenzbild fehlt: ${f}`)
|
||||
}
|
||||
|
||||
const asDataUri = f => `data:image/jpeg;base64,${readFileSync(f).toString('base64')}`
|
||||
|
||||
const browser = await chromium.launch({ executablePath: CHROME })
|
||||
const page = await browser.newPage()
|
||||
|
||||
const result = await page.evaluate(async (cfg) => {
|
||||
const load = src => new Promise((ok, err) => {
|
||||
const img = new Image()
|
||||
img.onload = () => ok(img)
|
||||
img.onerror = err
|
||||
img.src = src
|
||||
})
|
||||
const [fImg, lImg] = await Promise.all([load(cfg.front), load(cfg.left)])
|
||||
|
||||
const fW = Math.round(fImg.width * cfg.FRONT_SCALE)
|
||||
const fH = Math.round(fImg.height * cfg.FRONT_SCALE)
|
||||
const lW = Math.round(cfg.LEFT_CROP.sw * cfg.LEFT_SCALE)
|
||||
const lH = Math.round(lImg.height * cfg.LEFT_SCALE)
|
||||
|
||||
const W = lW + fW - cfg.FEATHER
|
||||
const H = cfg.H
|
||||
|
||||
/** Mittlere Helligkeit eines Quellbereichs. */
|
||||
function meanLuma(img, sy, sh) {
|
||||
const c = document.createElement('canvas')
|
||||
c.width = 48
|
||||
c.height = 48
|
||||
const cg = c.getContext('2d')
|
||||
cg.drawImage(img, 0, sy, img.width, sh, 0, 0, 48, 48)
|
||||
const d = cg.getImageData(0, 0, 48, 48).data
|
||||
let sum = 0
|
||||
for (let i = 0; i < d.length; i += 4) sum += 0.2126 * d[i] + 0.7152 * d[i + 1] + 0.0722 * d[i + 2]
|
||||
return sum / (d.length / 4)
|
||||
}
|
||||
|
||||
// Helligkeit der Fortsetzung an das Mittelmotiv angleichen. Ohne diesen
|
||||
// Abgleich bliebe trotz Überblendung ein Helligkeitssprung sichtbar.
|
||||
const bandF = meanLuma(fImg, fImg.height * 0.3, fImg.height * 0.45)
|
||||
const bandL = meanLuma(lImg, lImg.height * 0.3, lImg.height * 0.45)
|
||||
const korrektur = Math.max(0.8, Math.min(1.25, bandF / bandL))
|
||||
|
||||
/**
|
||||
* Eine Quelle als Schicht über die volle Zielhöhe: das Foto an der
|
||||
* Wasserlinie ausgerichtet, darüber der Himmel und darunter das Wasser aus der
|
||||
* jeweiligen Randzeile gestreckt. So deckt jede Schicht die ganze Höhe ab und
|
||||
* es entsteht keine harte obere oder untere Bildgrenze.
|
||||
*/
|
||||
function schicht(img, crop, scale, waterFraction, brightness) {
|
||||
const sx = crop ? crop.sx : 0
|
||||
const sw = crop ? crop.sw : img.width
|
||||
const w = Math.round(sw * scale)
|
||||
const h = Math.round(img.height * scale)
|
||||
const y = Math.round(cfg.WATER_Y - img.height * waterFraction * scale)
|
||||
|
||||
const c = document.createElement('canvas')
|
||||
c.width = w
|
||||
c.height = H
|
||||
const cg = c.getContext('2d')
|
||||
cg.imageSmoothingEnabled = true
|
||||
cg.imageSmoothingQuality = 'high'
|
||||
cg.save()
|
||||
if (brightness !== 1) cg.filter = `brightness(${brightness.toFixed(3)})`
|
||||
cg.drawImage(img, sx, 0, sw, img.height, 0, y, w, h)
|
||||
cg.restore()
|
||||
// Himmel nach oben, Wasser nach unten strecken
|
||||
if (y > 0) cg.drawImage(c, 0, y, w, 2, 0, 0, w, y + 2)
|
||||
const unten = y + h
|
||||
if (unten < H) cg.drawImage(c, 0, unten - 2, w, 2, 0, unten - 2, w, H - unten + 2)
|
||||
return { canvas: c, w }
|
||||
}
|
||||
|
||||
const cv = document.createElement('canvas')
|
||||
cv.width = W
|
||||
cv.height = H
|
||||
const g = cv.getContext('2d')
|
||||
g.imageSmoothingEnabled = true
|
||||
g.imageSmoothingQuality = 'high'
|
||||
|
||||
// 1) Fortsetzung rechts, einmal verwendet, helligkeitsangeglichen. Der
|
||||
// Zylinder beginnt beim Mittelmotiv und läuft nach links in den Raum
|
||||
// weiter; die Bildfolge muss dieser Richtung folgen.
|
||||
const fort = schicht(lImg, cfg.LEFT_CROP, cfg.LEFT_SCALE, cfg.LEFT_WATER, korrektur)
|
||||
g.drawImage(fort.canvas, W - fort.w, 0)
|
||||
|
||||
// 2) Mittelmotiv links, unverändert, mit weicher rechter Flanke.
|
||||
const vorne = schicht(fImg, null, cfg.FRONT_SCALE, cfg.FRONT_WATER, 1)
|
||||
const mid = document.createElement('canvas')
|
||||
mid.width = vorne.w
|
||||
mid.height = H
|
||||
const mg = mid.getContext('2d')
|
||||
mg.drawImage(vorne.canvas, 0, 0)
|
||||
mg.globalCompositeOperation = 'destination-in'
|
||||
const mGrad = mg.createLinearGradient(0, 0, vorne.w, 0)
|
||||
mGrad.addColorStop(0, 'rgba(0,0,0,1)')
|
||||
mGrad.addColorStop(1 - cfg.FEATHER / vorne.w, 'rgba(0,0,0,1)')
|
||||
mGrad.addColorStop(1, 'rgba(0,0,0,0)')
|
||||
mg.fillStyle = mGrad
|
||||
mg.fillRect(0, 0, vorne.w, H)
|
||||
g.drawImage(mid, 0, 0)
|
||||
|
||||
return { dataUri: cv.toDataURL('image/jpeg', 0.84), W, H, korrektur }
|
||||
}, {
|
||||
front: asDataUri(SRC_FRONT),
|
||||
left: asDataUri(SRC_LEFT),
|
||||
H, WATER_Y, FRONT_WATER, LEFT_WATER, FRONT_SCALE, LEFT_SCALE, FEATHER, LEFT_CROP,
|
||||
})
|
||||
|
||||
await browser.close()
|
||||
|
||||
writeFileSync(OUT, Buffer.from(result.dataUri.split(',')[1], 'base64'))
|
||||
const kb = (readFileSync(OUT).length / 1024).toFixed(0)
|
||||
console.log(`Panorama erzeugt: ${OUT}`)
|
||||
console.log(` ${result.W}x${result.H}, ${kb} KB, Helligkeitsangleichung ×${result.korrektur.toFixed(3)}`)
|
||||
@@ -8,19 +8,19 @@ import { useSessionStore } from './stores/sessionStore'
|
||||
import { AGENT_SECTIONS, AGENT_SECTION_PARAM, ROUTES } from './lib/constants'
|
||||
|
||||
// Die Flächensuche ist entfernt — es bleibt ein einziger erreichbarer Einstieg.
|
||||
// Seit Runde 5 ist das die Startseite; die frühere «Übersicht» existiert nicht mehr.
|
||||
const WORKSPACE_HOME: Record<string, string> = {
|
||||
[WorkspaceType.SUPPLY]: '/supply/dashboard',
|
||||
[WorkspaceType.SUPPLY]: ROUTES.SUPPLY.PROPERTIES,
|
||||
}
|
||||
|
||||
function RoleRedirect() {
|
||||
const currentUser = useSessionStore(s => s.currentUser)
|
||||
const first = currentUser?.allowedWorkspaces[0] ?? WorkspaceType.SUPPLY
|
||||
return <Navigate to={WORKSPACE_HOME[first] ?? '/supply/dashboard'} replace />
|
||||
return <Navigate to={WORKSPACE_HOME[first] ?? ROUTES.SUPPLY.PROPERTIES} replace />
|
||||
}
|
||||
|
||||
const LoginScreen = lazy(() => import('./pages/auth/LoginScreen'))
|
||||
|
||||
const SupplyDashboard = lazy(() => import('./pages/supply/SupplyDashboard'))
|
||||
const Properties = lazy(() => import('./pages/supply/Properties'))
|
||||
const MatchCenter = lazy(() => import('./pages/supply/MatchCenter'))
|
||||
const FutureAvailability = lazy(() => import('./pages/supply/FutureAvailability'))
|
||||
@@ -54,7 +54,13 @@ function App() {
|
||||
|
||||
{/* Supply Workspace */}
|
||||
<Route element={<ProtectedRoute workspace={WorkspaceType.SUPPLY} />}>
|
||||
<Route path="/supply/dashboard" element={<SupplyDashboard />} />
|
||||
{/* Runde 5, §2.1: Die Hauptseite «Übersicht» ist entfernt. Der
|
||||
alte Pfad bleibt als Umleitung bestehen, damit Lesezeichen
|
||||
und Deep Links auf der Startseite landen statt im Nichts. */}
|
||||
<Route
|
||||
path={ROUTES.SUPPLY.LEGACY_DASHBOARD}
|
||||
element={<Navigate to={ROUTES.SUPPLY.PROPERTIES} replace />}
|
||||
/>
|
||||
<Route path="/supply/properties" element={<Properties />} />
|
||||
{/* Objekt-Detailroute — Ziel aller Objektlinks der Agentenseiten. */}
|
||||
<Route path="/supply/properties/:propertyId" element={<Properties />} />
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
# Bildherkunft
|
||||
|
||||
| Datei | Inhalt | Herkunft |
|
||||
|---|---|---|
|
||||
| `basel-front.jpg` | Blick über den Rhein auf Grossbasler Altstadt und Münster, links die Wettsteinbrücke | Referenzbild 1 aus dem Auftrag (Anpassungsrunde 5/6) |
|
||||
| `basel-left.jpg` | Rheinperspektive mit Altstadt- und Münster-Silhouette, Panoramaformat | Referenzbild 2 aus dem Auftrag (Anpassungsrunde 5/6) |
|
||||
|
||||
Beide Bilder sind die dem Auftrag beigelegten Referenzfotos. Sie liegen im
|
||||
3D-Meetingraum als Fernkulisse **aussen vor den Glasfassaden** — `basel-front`
|
||||
hinter der gegenüberliegenden Front, `basel-left` entlang der linken Front.
|
||||
|
||||
Weil beide Aufnahmen dieselbe Rheinseite von derselben Uferseite zeigen, ergeben
|
||||
sie zusammen einen zusammenhängenden Stadtverlauf: die Wasserlinie beider
|
||||
Kulissen liegt auf derselben Welthöhe, damit der Blick nach vorne und der Blick
|
||||
nach links nicht wie zwei zusammengesetzte Fotos wirken (Runde 6, §4).
|
||||
|
After Width: | Height: | Size: 80 KiB |
|
After Width: | Height: | Size: 71 KiB |
|
After Width: | Height: | Size: 177 KiB |
@@ -0,0 +1,12 @@
|
||||
# Bildherkunft — Porträts
|
||||
|
||||
Die Porträts der fünf Agenten stammen aus dem Konzeptdokument und sind
|
||||
fachliche Vorgabe.
|
||||
|
||||
| Datei | Herkunft | Lizenz |
|
||||
|---|---|---|
|
||||
| `thomas.jpg` | Unsplash, Porträt eines Mannes in Business-Kleidung (`photo-1560250097-0b93528c311a`), quadratisch auf das Gesicht beschnitten | [Unsplash License](https://unsplash.com/license) — frei für kommerzielle Nutzung, keine Namensnennung nötig |
|
||||
|
||||
Thomas ist in Anpassungsrunde 6 neu dazugekommen und hatte kein Porträt aus dem
|
||||
Konzeptdokument. Der Kopf eines bestehenden Mitarbeitenden wurde bewusst nicht
|
||||
zweitverwendet — jeder Kopf gehört genau einer Person.
|
||||
|
Before Width: | Height: | Size: 5.8 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 6.2 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 5.9 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 6.5 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 6.7 KiB After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 36 KiB |
@@ -3,8 +3,7 @@ import type { AssistantContext } from '../../domain/assistant'
|
||||
import { DS_ACCENT, DS_SLATE } from '../../lib/ds'
|
||||
|
||||
const PAGE_LABELS: Record<string, string> = {
|
||||
'/supply/dashboard': 'Übersicht',
|
||||
'/supply/properties': 'Meine Objekte',
|
||||
'/supply/properties': 'Startseite',
|
||||
'/supply/match-center': 'Eingehende Bedarfe',
|
||||
'/supply/data-quality': 'Datenpflege',
|
||||
'/supply/future-availability':'Marktchancen',
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
import { Box, Chip, Typography } from '@mui/material'
|
||||
import { UserRole } from '../../domain/enums'
|
||||
import { useSwitchDemoRole } from '../../hooks/useAuth'
|
||||
import { useSessionStore } from '../../stores/sessionStore'
|
||||
|
||||
const DEMO_ROLES: { role: UserRole; label: string }[] = [
|
||||
{ role: UserRole.PROPERTY_MANAGER, label: 'Verwaltung' },
|
||||
{ role: UserRole.DEMAND_USER, label: 'Bürosuche' },
|
||||
]
|
||||
|
||||
export function DemoRoleSwitcher() {
|
||||
const { currentUser } = useSessionStore()
|
||||
const switchDemoRole = useSwitchDemoRole()
|
||||
|
||||
function handleSwitch(role: UserRole) {
|
||||
switchDemoRole.mutate(role)
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ px: 1, py: 0.5 }}>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{ color: 'text.secondary', fontWeight: 600, textTransform: 'uppercase', letterSpacing: 0.5, display: 'block', mb: 0.75 }}
|
||||
>
|
||||
Demo-Modus
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
|
||||
{DEMO_ROLES.map(({ role, label }) => {
|
||||
const active = currentUser?.role === role
|
||||
return (
|
||||
<Chip
|
||||
key={role}
|
||||
label={label}
|
||||
size="small"
|
||||
clickable
|
||||
onClick={() => handleSwitch(role)}
|
||||
sx={{
|
||||
fontSize: '0.7rem',
|
||||
height: 22,
|
||||
bgcolor: active ? '#152642' : 'transparent',
|
||||
color: active ? '#fff' : 'text.secondary',
|
||||
border: '1px solid',
|
||||
borderColor: active ? '#152642' : 'divider',
|
||||
'&:hover': { bgcolor: active ? '#162d4a' : 'rgba(0,0,0,0.04)' },
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -3,5 +3,4 @@ export { SessionExpired } from './SessionExpired'
|
||||
export { PermissionGate } from './PermissionGate'
|
||||
export { RoleGuard } from './RoleGuard'
|
||||
export { ProtectedRoute } from './ProtectedRoute'
|
||||
export { DemoRoleSwitcher } from './DemoRoleSwitcher'
|
||||
export { OrganizationSwitcher } from './OrganizationSwitcher'
|
||||
@@ -1,46 +1,35 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect } from 'react'
|
||||
import { Outlet, useNavigate, useLocation } from 'react-router'
|
||||
import { Box, Drawer, useTheme, useMediaQuery } from '@mui/material'
|
||||
import { Box } from '@mui/material'
|
||||
import { useLayoutStore } from '../../stores/layoutStore'
|
||||
import { useSessionStore } from '../../stores/sessionStore'
|
||||
import { WelcomeDialog } from '../onboarding/WelcomeDialog'
|
||||
import { RightContextPanel } from './RightContextPanel'
|
||||
import { ToastProvider } from '../ui'
|
||||
import { Sidebar } from './AppShellSidebar'
|
||||
import { TopBar } from './AppShellTopBar'
|
||||
import { WORKSPACE_CONFIG, WORKSPACE_ORDER, getWorkspaceFromPath } from './appShellConfig'
|
||||
import { WorkspaceType } from '../../domain/enums'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AppShell
|
||||
// ---------------------------------------------------------------------------
|
||||
import { WORKSPACE_CONFIG, getWorkspaceFromPath } from './appShellConfig'
|
||||
|
||||
/**
|
||||
* Rahmen der Anwendung (Runde 6, §10).
|
||||
*
|
||||
* Die permanente Navigationsleiste links ist entfallen — samt Breitenreservierung,
|
||||
* Einklapplogik und mobilem Drawer. Die Navigation liegt jetzt vollständig im
|
||||
* Menü der Kopfzeile (§11–§14). Der Inhaltsbereich beginnt dadurch am linken
|
||||
* Fensterrand; es bleibt kein leerer Streifen zurück, weil die Sidebar nicht
|
||||
* ausgeblendet, sondern gelöscht ist.
|
||||
*
|
||||
* Geblieben sind die beiden Effekte, die nichts mit der Navigation zu tun haben:
|
||||
* der Abgleich des Arbeitsbereichs mit der URL und die Umleitung, wenn ein
|
||||
* Rollenwechsel den aktuellen Arbeitsbereich verbietet.
|
||||
*/
|
||||
export function AppShell() {
|
||||
const activeWorkspace = useLayoutStore(s => s.activeWorkspace)
|
||||
const sidebarCollapsed = useLayoutStore(s => s.sidebarCollapsed)
|
||||
const setActiveWorkspace = useLayoutStore(s => s.setActiveWorkspace)
|
||||
const toggleSidebar = useLayoutStore(s => s.toggleSidebar)
|
||||
const setSidebarCollapsed = useLayoutStore(s => s.setSidebarCollapsed)
|
||||
const { currentUser } = useSessionStore()
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const theme = useTheme()
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down('sm'))
|
||||
const isCompact = useMediaQuery(theme.breakpoints.down('lg'))
|
||||
const [mobileOpen, setMobileOpen] = useState(false)
|
||||
|
||||
// Das mobile Menü ist nur offen, solange die Route dieselbe bleibt. Statt es
|
||||
// nach dem Routenwechsel per Effekt zu schliessen, hängt der Zustand am Pfad:
|
||||
// ein Wechsel erzeugt einen neuen Schlüssel und damit einen frischen Zustand.
|
||||
const [openForPath, setOpenForPath] = useState(location.pathname)
|
||||
const isMobileOpen = mobileOpen && openForPath === location.pathname
|
||||
const openMobileMenu = () => { setOpenForPath(location.pathname); setMobileOpen(true) }
|
||||
const closeMobileMenu = () => setMobileOpen(false)
|
||||
|
||||
// Auto-collapse sidebar only on smaller screens (< 1200px); laptops keep the full nav + account
|
||||
useEffect(() => { setSidebarCollapsed(isCompact) }, [isCompact, setSidebarCollapsed])
|
||||
|
||||
// Sync active workspace with URL
|
||||
// Arbeitsbereich am Pfad ausrichten
|
||||
useEffect(() => {
|
||||
const detected = getWorkspaceFromPath(location.pathname)
|
||||
if (detected && detected !== activeWorkspace) {
|
||||
@@ -48,8 +37,7 @@ export function AppShell() {
|
||||
}
|
||||
}, [location.pathname, activeWorkspace, setActiveWorkspace])
|
||||
|
||||
// When the user changes (role switch), redirect to their first allowed workspace
|
||||
// if the current workspace is no longer permitted
|
||||
// Nach einem Rollenwechsel auf den ersten erlaubten Arbeitsbereich umleiten
|
||||
useEffect(() => {
|
||||
if (!currentUser) return
|
||||
const allowed = currentUser.allowedWorkspaces
|
||||
@@ -64,60 +52,15 @@ export function AppShell() {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [currentUser])
|
||||
|
||||
const handleWorkspaceClick = (workspace: WorkspaceType) => {
|
||||
setActiveWorkspace(workspace)
|
||||
navigate(WORKSPACE_CONFIG[workspace].firstPath)
|
||||
}
|
||||
|
||||
const userName = currentUser?.name ?? 'User'
|
||||
const orgName = currentUser?.organizationName ?? ''
|
||||
const allowedWorkspaces = currentUser?.allowedWorkspaces ?? WORKSPACE_ORDER
|
||||
|
||||
const sidebarContent = (
|
||||
<Sidebar
|
||||
collapsed={isMobile ? false : sidebarCollapsed}
|
||||
activeWorkspace={activeWorkspace}
|
||||
allowedWorkspaces={allowedWorkspaces}
|
||||
onWorkspaceClick={handleWorkspaceClick}
|
||||
onToggle={isMobile ? closeMobileMenu : toggleSidebar}
|
||||
onClose={isMobile ? closeMobileMenu : undefined}
|
||||
userName={userName}
|
||||
orgName={orgName}
|
||||
/>
|
||||
)
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', height: '100vh', overflow: 'hidden' }}>
|
||||
{/* Desktop: permanent sidebar */}
|
||||
{!isMobile && sidebarContent}
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100vh', overflow: 'hidden' }}>
|
||||
<TopBar pathname={location.pathname} />
|
||||
|
||||
{/* Mobile: temporary drawer */}
|
||||
{isMobile && (
|
||||
<Drawer
|
||||
open={isMobileOpen}
|
||||
onClose={closeMobileMenu}
|
||||
variant="temporary"
|
||||
ModalProps={{ keepMounted: true }}
|
||||
slotProps={{ paper: { sx: { width: 264, bgcolor: 'transparent', boxShadow: 'none' } } }}
|
||||
>
|
||||
{sidebarContent}
|
||||
</Drawer>
|
||||
)}
|
||||
|
||||
<Box sx={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
||||
<TopBar
|
||||
pathname={location.pathname}
|
||||
isMobile={isMobile}
|
||||
onMenuClick={openMobileMenu}
|
||||
/>
|
||||
|
||||
<Box component="main" sx={{ flex: 1, overflowY: 'auto' }}>
|
||||
<Outlet />
|
||||
</Box>
|
||||
|
||||
<RightContextPanel />
|
||||
<Box component="main" sx={{ flex: 1, overflowY: 'auto' }}>
|
||||
<Outlet />
|
||||
</Box>
|
||||
|
||||
<RightContextPanel />
|
||||
<ToastProvider />
|
||||
<WelcomeDialog />
|
||||
</Box>
|
||||
|
||||
@@ -1,400 +0,0 @@
|
||||
import { Box, Typography, Avatar, IconButton, Tooltip } from '@mui/material'
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react'
|
||||
import { NavLink, useLocation } from 'react-router'
|
||||
import { WorkspaceType } from '../../domain/enums'
|
||||
import { useCompareStore } from '../../stores/compareStore'
|
||||
import { AgentAvatar } from '../team'
|
||||
import { WORKSPACE_CONFIG, WORKSPACE_ORDER, getUserInitials } from './appShellConfig'
|
||||
import { DS_ACCENT, DS_BRAND, DS_NEUTRAL, DS_SLATE } from '../../lib/ds'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Visual constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const SIDEBAR_BG = '#0f1923'
|
||||
export const DIVIDER_COLOR = 'rgba(255,255,255,0.08)'
|
||||
export const TEXT_MUTED = '#94a3b8'
|
||||
export const TEXT_WHITE = '#ffffff'
|
||||
export const NAV_ACTIVE_BG = 'transparent'
|
||||
export const NAV_ACTIVE_ACCENT = '#b8975a'
|
||||
export const NAV_HOVER_BG = 'rgba(255,255,255,0.06)'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface SidebarProps {
|
||||
collapsed: boolean
|
||||
activeWorkspace: WorkspaceType
|
||||
allowedWorkspaces: WorkspaceType[]
|
||||
onWorkspaceClick: (workspace: WorkspaceType) => void
|
||||
onToggle: () => void
|
||||
onClose?: () => void
|
||||
userName: string
|
||||
orgName: string
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function Sidebar({
|
||||
collapsed,
|
||||
activeWorkspace,
|
||||
allowedWorkspaces,
|
||||
onWorkspaceClick,
|
||||
onToggle,
|
||||
onClose,
|
||||
userName,
|
||||
orgName,
|
||||
}: SidebarProps) {
|
||||
const config = WORKSPACE_CONFIG[activeWorkspace]
|
||||
const compareCount = useCompareStore(s => s.compareItems.length)
|
||||
const { pathname } = useLocation()
|
||||
const width = collapsed ? 60 : 264
|
||||
const visibleWorkspaces = WORKSPACE_ORDER.filter((ws) => allowedWorkspaces.includes(ws))
|
||||
|
||||
return (
|
||||
<Box
|
||||
component="nav"
|
||||
sx={{
|
||||
width,
|
||||
minWidth: width,
|
||||
flexShrink: 0,
|
||||
height: '100vh',
|
||||
backgroundColor: SIDEBAR_BG,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
transition: 'width 0.2s ease',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{/* Logo area */}
|
||||
<Box
|
||||
sx={{
|
||||
height: 56,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: collapsed ? 'center' : 'flex-start',
|
||||
px: collapsed ? 0 : 2.5,
|
||||
borderBottom: `1px solid ${DIVIDER_COLOR}`,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{collapsed ? (
|
||||
<Typography
|
||||
variant="subtitle1"
|
||||
sx={{ color: TEXT_WHITE, fontWeight: 700, letterSpacing: 0.5 }}
|
||||
>
|
||||
PM
|
||||
</Typography>
|
||||
) : (
|
||||
<Box>
|
||||
<Typography
|
||||
variant="subtitle1"
|
||||
sx={{ color: TEXT_WHITE, fontWeight: 700, lineHeight: 1.2 }}
|
||||
>
|
||||
Property
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
color: DS_BRAND.light,
|
||||
fontWeight: 600,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 1,
|
||||
fontSize: '0.6rem',
|
||||
}}
|
||||
>
|
||||
Match
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Workspace tabs — side-by-side segmented control (hidden when only 1 workspace) */}
|
||||
{visibleWorkspaces.length > 1 && <Box
|
||||
sx={{
|
||||
borderBottom: `1px solid ${DIVIDER_COLOR}`,
|
||||
display: 'grid',
|
||||
gridTemplateColumns: collapsed ? '1fr' : `repeat(${visibleWorkspaces.length}, 1fr)`,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{visibleWorkspaces.map((ws, idx) => {
|
||||
const wsConfig = WORKSPACE_CONFIG[ws]
|
||||
const Icon = wsConfig.icon
|
||||
const isActive = ws === activeWorkspace
|
||||
|
||||
const tabContent = (
|
||||
<Box
|
||||
onClick={() => { onWorkspaceClick(ws); onClose?.() }}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: collapsed ? 'row' : 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: collapsed ? 0 : 0.5,
|
||||
py: collapsed ? 1 : 1.25,
|
||||
cursor: 'pointer',
|
||||
backgroundColor: isActive ? wsConfig.chipColor : 'transparent',
|
||||
borderRight: !collapsed && idx < visibleWorkspaces.length - 1
|
||||
? `1px solid ${DIVIDER_COLOR}`
|
||||
: 'none',
|
||||
transition: 'background-color 0.15s ease',
|
||||
'&:hover': {
|
||||
backgroundColor: isActive ? wsConfig.chipColor : NAV_HOVER_BG,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Icon size={15} color={isActive ? TEXT_WHITE : TEXT_MUTED} />
|
||||
{!collapsed && (
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
color: isActive ? TEXT_WHITE : TEXT_MUTED,
|
||||
fontWeight: isActive ? 700 : 400,
|
||||
fontSize: '0.6875rem',
|
||||
lineHeight: 1,
|
||||
letterSpacing: 0.2,
|
||||
}}
|
||||
>
|
||||
{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' }}
|
||||
onClick={() => onClose?.()}
|
||||
>
|
||||
{({ isActive }) => (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1.25,
|
||||
pl: collapsed ? 0 : isActive ? 1.25 : 1.5,
|
||||
pr: collapsed ? 0 : 1.5,
|
||||
py: 0.75,
|
||||
mx: 0.5,
|
||||
borderRadius: 1,
|
||||
justifyContent: collapsed ? 'center' : 'flex-start',
|
||||
borderLeft: collapsed ? 'none' : `3px solid ${isActive ? NAV_ACTIVE_ACCENT : 'transparent'}`,
|
||||
backgroundColor: 'transparent',
|
||||
'&:hover': { backgroundColor: NAV_HOVER_BG },
|
||||
transition: 'border-color 0.15s ease',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<Icon size={16} color={isActive ? TEXT_WHITE : TEXT_MUTED} />
|
||||
{!collapsed && (
|
||||
<Box sx={{ flex: 1, display: 'flex', alignItems: 'center', gap: 0.75, minWidth: 0 }}>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
color: isActive ? TEXT_WHITE : TEXT_MUTED,
|
||||
fontWeight: isActive ? 600 : 400,
|
||||
fontSize: '0.875rem',
|
||||
letterSpacing: '-0.01em',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{item.label}
|
||||
</Typography>
|
||||
{item.path === '/demand/compare' && compareCount > 0 && (
|
||||
<Box sx={{
|
||||
minWidth: 18, height: 18, borderRadius: '9px',
|
||||
bgcolor: DS_ACCENT.warning.main, color: 'white',
|
||||
fontSize: '0.65rem', fontWeight: 700,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
px: 0.5, flexShrink: 0,
|
||||
}}>
|
||||
{compareCount}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</NavLink>
|
||||
)
|
||||
|
||||
// Subreiter bleiben sichtbar, solange der Nutzer im Funktionsbereich ist.
|
||||
// Seit Runde 4 liegen die Agentenseiten nicht mehr unterhalb des
|
||||
// Basispfads — die Liste muss deshalb auch auf einer Agentenseite
|
||||
// offen bleiben, sonst verschwände die Navigation beim Hinklicken.
|
||||
// Eingeklappt entfallen sie — dort ist kein Platz für eine zweite Ebene.
|
||||
const subItems = item.children ?? []
|
||||
const inSection =
|
||||
subItems.length > 0 &&
|
||||
(pathname.startsWith(item.path) || subItems.some(c => pathname.startsWith(c.path)))
|
||||
|
||||
return collapsed ? (
|
||||
<Tooltip key={item.path} title={item.label} placement="right">
|
||||
<Box>{navContent}</Box>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Box key={item.path}>
|
||||
{navContent}
|
||||
{inSection && (
|
||||
<Box component="ul" sx={{ listStyle: 'none', m: 0, p: 0, mt: 0.25, mb: 0.5 }}>
|
||||
{subItems.map((child) => (
|
||||
<Box component="li" key={child.path}>
|
||||
<NavLink
|
||||
to={child.path}
|
||||
style={{ textDecoration: 'none', display: 'block' }}
|
||||
onClick={() => onClose?.()}
|
||||
>
|
||||
{({ isActive }) => (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
pl: child.agentId ? 2.5 : 4.75,
|
||||
pr: 1.5,
|
||||
py: child.agentId ? 0.625 : 0.5,
|
||||
mx: 0.5,
|
||||
borderRadius: 1,
|
||||
borderLeft: `3px solid ${isActive ? NAV_ACTIVE_ACCENT : 'transparent'}`,
|
||||
'&:hover': { backgroundColor: NAV_HOVER_BG },
|
||||
transition: 'border-color 0.15s ease',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
{child.agentId && (
|
||||
<AgentAvatar
|
||||
agent={{ id: child.agentId, name: child.label, role: child.role ?? '' }}
|
||||
size={26}
|
||||
/>
|
||||
)}
|
||||
<Box sx={{ minWidth: 0 }}>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
color: isActive ? TEXT_WHITE : TEXT_MUTED,
|
||||
fontWeight: isActive ? 600 : 400,
|
||||
fontSize: '0.8125rem',
|
||||
letterSpacing: '-0.01em',
|
||||
whiteSpace: 'nowrap',
|
||||
lineHeight: 1.3,
|
||||
}}
|
||||
>
|
||||
{child.label}
|
||||
</Typography>
|
||||
{child.role && (
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
display: 'block',
|
||||
color: isActive ? TEXT_MUTED : '#64748b',
|
||||
fontSize: '0.6875rem',
|
||||
lineHeight: 1.3,
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
}}
|
||||
>
|
||||
{child.role}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</NavLink>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</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: DS_NEUTRAL.gold,
|
||||
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: DS_SLATE[500],
|
||||
fontSize: '0.7rem',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
display: 'block',
|
||||
}}
|
||||
>
|
||||
{orgName}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<IconButton
|
||||
onClick={onToggle}
|
||||
size="small"
|
||||
sx={{ color: DS_SLATE[500], flexShrink: 0 }}
|
||||
>
|
||||
{collapsed ? <ChevronRight size={16} /> : <ChevronLeft size={16} />}
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -1,61 +1,55 @@
|
||||
import { Box, IconButton } from '@mui/material'
|
||||
import { Menu } from 'lucide-react'
|
||||
import { Box } from '@mui/material'
|
||||
import { OrganizationContextBadge } from './OrganizationContextBadge'
|
||||
import { NotificationButton } from './NotificationButton'
|
||||
import { UserMenu } from './UserMenu'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
import { GlobalNavMenu } from './GlobalNavMenu'
|
||||
import { AgentChatStickyBar } from '../team'
|
||||
|
||||
export interface TopBarProps {
|
||||
pathname: string
|
||||
onMenuClick?: () => void
|
||||
isMobile?: boolean
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Kopfzeile der Anwendung.
|
||||
*
|
||||
* Sie trägt nur noch, was global gilt: Mandant, Benachrichtigungen und Konto.
|
||||
* Das Chip «Verwaltung» und der wiederholte Seitenname sind entfallen — beides
|
||||
* stand nochmals gross im Inhaltsbereich und kostete nur Höhe. Ohne Trennlinie
|
||||
* geht die Zeile ohne sichtbaren Bruch in den Seiteninhalt über.
|
||||
* Sie trägt seit Runde 6 die gesamte Navigation: Mandant, Benachrichtigungen
|
||||
* und — an der Stelle des früheren Profilsymbols — ein Menüsymbol, das das
|
||||
* globale Klappmenü öffnet (§11, §15). Das Profil ist vollständig in dieses
|
||||
* Menü gewandert; in der Leiste selbst steht es nicht mehr.
|
||||
*
|
||||
* In der freien Mitte erscheint der kompakte Agentenchat, sobald der
|
||||
* ausführliche Chatkopf weggescrollt ist (Runde 5, §9). Er liegt im Balken
|
||||
* selbst und nicht darüber — deshalb kann er Tabellen und Filter nicht
|
||||
* verdecken. Der Balken bekommt dafür `zIndex: appBar`: über dem Seiteninhalt,
|
||||
* aber unter Menüs, Dialogen und Popovers, die MUI oberhalb davon einhängt.
|
||||
*/
|
||||
export function TopBar({ onMenuClick, isMobile }: TopBarProps) {
|
||||
export function TopBar(_props: TopBarProps) {
|
||||
return (
|
||||
<Box
|
||||
component="header"
|
||||
sx={{
|
||||
height: 64,
|
||||
flexShrink: 0,
|
||||
position: 'relative',
|
||||
zIndex: theme => theme.zIndex.appBar,
|
||||
backgroundColor: '#ffffff',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: 1,
|
||||
px: { xs: 1.5, sm: 3 },
|
||||
}}
|
||||
>
|
||||
{/* Links steht nur noch der Menüaufruf auf schmalen Schirmen. */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
{isMobile && (
|
||||
<IconButton size="small" onClick={onMenuClick} sx={{ mr: 0.5 }}>
|
||||
<Menu size={20} />
|
||||
</IconButton>
|
||||
)}
|
||||
</Box>
|
||||
{/* Links bleibt frei — die Navigation liegt vollständig rechts. */}
|
||||
<Box sx={{ flexShrink: 0 }} />
|
||||
|
||||
{/* Right side */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: { xs: 0.5, sm: 1 } }}>
|
||||
<AgentChatStickyBar />
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: { xs: 0.5, sm: 1 }, flexShrink: 0 }}>
|
||||
<Box sx={{ display: { xs: 'none', sm: 'flex' } }}>
|
||||
<OrganizationContextBadge />
|
||||
</Box>
|
||||
<NotificationButton />
|
||||
<UserMenu />
|
||||
<GlobalNavMenu />
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
import { useState } from 'react'
|
||||
import { useNavigate } from 'react-router'
|
||||
import { Box, ButtonBase, Divider, IconButton, Popover, Tooltip, Typography } from '@mui/material'
|
||||
import { HelpCircle, LayoutGrid, LogOut, Settings, User } from 'lucide-react'
|
||||
import { useSessionStore } from '../../stores/sessionStore'
|
||||
import { useToastStore } from '../../stores/toastStore'
|
||||
import { AgentAvatar } from '../team'
|
||||
import { AGENT_WORKSPACES } from '../../lib/agentWorkspaces'
|
||||
import { HOME_NAV_LABEL, MY_AGENTS_LABEL, ROUTES } from '../../lib/constants'
|
||||
import { DS_BRAND, DS_NEUTRAL, DS_SLATE, DS_TEXT } from '../../lib/ds'
|
||||
|
||||
const ROLE_LABELS: Record<string, string> = {
|
||||
PROPERTY_MANAGER: 'Property Manager',
|
||||
DEMAND_USER: 'Demand User',
|
||||
}
|
||||
|
||||
/**
|
||||
* Globale Navigation in der Kopfzeile (Runde 6, §11–§16).
|
||||
*
|
||||
* Sie ersetzt die entfernte Seitenleiste und das frühere Profilsymbol. Ein
|
||||
* einziges Menüsymbol neben der Glocke öffnet ein Klappmenü, das rechts an der
|
||||
* Kopfzeile ausgerichtet nach unten aufgeht.
|
||||
*
|
||||
* Aufbau: «Startseite», «Meine Agenten» mit allen Agenten als eingerückte,
|
||||
* rechts ausgerichtete Unterpunkte, und «Profil» mit denselben vier Aktionen,
|
||||
* die vorher im Profilmenü lagen.
|
||||
*
|
||||
* Die Agentenliste kommt aus `AGENT_WORKSPACES` — derselben zentralen
|
||||
* Konfiguration, die auch der Meetingraum und die Seitenköpfe verwenden. Namen,
|
||||
* Funktionen und Ziele werden dadurch an genau einer Stelle gepflegt (§24,
|
||||
* Schritt 7).
|
||||
*
|
||||
* `Popover` statt `Menu`: Ein `Menu` erwartet gleichrangige `MenuItem`-Kinder
|
||||
* und übernimmt Tastaturnavigation über eine flache Liste. Hier gibt es zwei
|
||||
* Ebenen mit unterschiedlicher Ausrichtung — das Popover lässt diese Struktur
|
||||
* zu und bringt Klick-ausserhalb und Escape von sich aus mit.
|
||||
*/
|
||||
export function GlobalNavMenu() {
|
||||
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null)
|
||||
// Beim Öffnen festgehaltene Ankerstelle: oben unter der Kopfzeile, rechts
|
||||
// exakt an der Fensterkante.
|
||||
const [ankerStelle, setAnkerStelle] = useState({ top: 64, left: 0 })
|
||||
const { currentUser, logout } = useSessionStore()
|
||||
const navigate = useNavigate()
|
||||
const showToast = useToastStore(s => s.showToast)
|
||||
const open = !!anchorEl
|
||||
|
||||
const close = () => setAnchorEl(null)
|
||||
|
||||
/** Menüpunkt und Ziel gehören zusammen: erst schliessen, dann navigieren. */
|
||||
const go = (path: string) => {
|
||||
close()
|
||||
navigate(path)
|
||||
}
|
||||
|
||||
const handleLogout = () => {
|
||||
close()
|
||||
logout()
|
||||
navigate('/auth/login')
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tooltip title="Menü">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={e => {
|
||||
if (open) { setAnchorEl(null); return }
|
||||
const b = e.currentTarget.getBoundingClientRect()
|
||||
setAnkerStelle({ top: b.bottom + 8, left: document.documentElement.clientWidth })
|
||||
setAnchorEl(e.currentTarget)
|
||||
}}
|
||||
aria-label="Menü öffnen"
|
||||
aria-haspopup="true"
|
||||
aria-expanded={open}
|
||||
sx={{ color: DS_TEXT.primary }}
|
||||
>
|
||||
<LayoutGrid size={20} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
|
||||
<Popover
|
||||
open={open}
|
||||
anchorEl={anchorEl}
|
||||
onClose={close}
|
||||
/*
|
||||
* An eine Bildschirmstelle statt an das Symbol gebunden: das Symbol liegt
|
||||
* um den Innenabstand der Kopfzeile eingerückt, und ein daran
|
||||
* ausgerichtetes Menü erbte genau diesen Abstand als Spalt zur rechten
|
||||
* Fensterkante (§5.1). `marginThreshold={0}` hebt zusätzlich den
|
||||
* Sicherheitsabstand auf, den MUI sonst zum Fensterrand hält.
|
||||
* Gerechnet wird mit `clientWidth` statt `innerWidth`, also ohne den
|
||||
* Scrollbalken — sonst entstünde ein waagrechter Scrollbalken.
|
||||
*/
|
||||
anchorReference="anchorPosition"
|
||||
anchorPosition={ankerStelle}
|
||||
transformOrigin={{ horizontal: 'right', vertical: 'top' }}
|
||||
marginThreshold={0}
|
||||
slotProps={{
|
||||
paper: {
|
||||
sx: {
|
||||
width: 320,
|
||||
borderRadius: 2,
|
||||
boxShadow: '0 18px 50px rgba(15,25,35,0.22)',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<MenuSection>
|
||||
<MainItem label={HOME_NAV_LABEL} onClick={() => go(ROUTES.SUPPLY.PROPERTIES)} />
|
||||
</MenuSection>
|
||||
|
||||
<Divider />
|
||||
|
||||
<MenuSection>
|
||||
<MainItem label={MY_AGENTS_LABEL} onClick={() => go(ROUTES.SUPPLY.TEAM)} />
|
||||
{/* Unterpunkte: kleiner, eingerückt und rechts ausgerichtet (§14). */}
|
||||
{AGENT_WORKSPACES.map(agent => (
|
||||
<SubItem key={agent.id} onClick={() => go(agent.path)}>
|
||||
<Typography sx={{ fontSize: '0.8125rem', fontWeight: 500, color: DS_TEXT.primary }}>
|
||||
{agent.name}
|
||||
</Typography>
|
||||
<Typography sx={{ fontSize: '0.6875rem', color: DS_TEXT.secondary }}>
|
||||
{agent.role}
|
||||
</Typography>
|
||||
<AgentAvatar agent={agent} size={26} />
|
||||
</SubItem>
|
||||
))}
|
||||
</MenuSection>
|
||||
|
||||
<Divider />
|
||||
|
||||
<MenuSection>
|
||||
<MainItem label="Profil" onClick={() => showProfileHint(showToast, close)} />
|
||||
{currentUser && (
|
||||
<Typography
|
||||
sx={{
|
||||
px: 2, pb: 0.75, textAlign: 'right',
|
||||
fontSize: '0.6875rem', color: DS_TEXT.muted,
|
||||
}}
|
||||
>
|
||||
{currentUser.name} · {ROLE_LABELS[currentUser.role] ?? currentUser.role}
|
||||
{currentUser.organizationName ? ` · ${currentUser.organizationName}` : ''}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
<SubItem onClick={() => showProfileHint(showToast, close)}>
|
||||
<ProfileLabel icon={<User size={15} />} label="Profil" />
|
||||
</SubItem>
|
||||
<SubItem onClick={() => { close(); showToast('Einstellungen sind in Kürze verfügbar.', 'info') }}>
|
||||
<ProfileLabel icon={<Settings size={15} />} label="Einstellungen" />
|
||||
</SubItem>
|
||||
<SubItem
|
||||
onClick={() => {
|
||||
// Bestehende Logik: die Tour zeigt sich erneut, sobald die Merkung
|
||||
// entfernt ist. Nicht neu gebaut (§16).
|
||||
close()
|
||||
localStorage.removeItem('property_match_welcomed')
|
||||
window.location.reload()
|
||||
}}
|
||||
>
|
||||
<ProfileLabel icon={<HelpCircle size={15} />} label="Produkttour starten" />
|
||||
</SubItem>
|
||||
<SubItem onClick={handleLogout}>
|
||||
<ProfileLabel icon={<LogOut size={15} />} label="Abmelden" />
|
||||
</SubItem>
|
||||
</MenuSection>
|
||||
</Popover>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function showProfileHint(showToast: (m: string, t: 'info') => void, close: () => void) {
|
||||
close()
|
||||
showToast('Profilseite ist in Kürze verfügbar.', 'info')
|
||||
}
|
||||
|
||||
function MenuSection({ children }: { children: React.ReactNode }) {
|
||||
return <Box sx={{ py: 1 }}>{children}</Box>
|
||||
}
|
||||
|
||||
/** Hauptpunkt: linksbündig, kräftig — er trägt die Hierarchie. */
|
||||
function MainItem({ label, onClick }: { label: string; onClick: () => void }) {
|
||||
return (
|
||||
<ButtonBase
|
||||
onClick={onClick}
|
||||
sx={{
|
||||
width: '100%',
|
||||
justifyContent: 'flex-start',
|
||||
px: 2, py: 1,
|
||||
'&:hover': { bgcolor: DS_SLATE[50] },
|
||||
'&.Mui-focusVisible': { bgcolor: DS_SLATE[100], outline: `2px solid ${DS_NEUTRAL.gold}`, outlineOffset: -2 },
|
||||
}}
|
||||
>
|
||||
<Typography sx={{ fontWeight: 700, fontSize: '0.9375rem', color: DS_BRAND.main }}>
|
||||
{label}
|
||||
</Typography>
|
||||
</ButtonBase>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Unterpunkt: kleiner, eingerückt und am rechten Rand ausgerichtet (§14/§16).
|
||||
* Die Rechtsausrichtung ist die Vorgabe — sie trennt die Ebenen auch ohne
|
||||
* Einrückungslinien sichtbar voneinander.
|
||||
*/
|
||||
function SubItem({ children, onClick }: { children: React.ReactNode; onClick: () => void }) {
|
||||
return (
|
||||
<ButtonBase
|
||||
onClick={onClick}
|
||||
sx={{
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'flex-end',
|
||||
gap: 1,
|
||||
pl: 4, pr: 2, py: 0.75,
|
||||
textAlign: 'right',
|
||||
'&:hover': { bgcolor: DS_SLATE[50] },
|
||||
'&.Mui-focusVisible': { bgcolor: DS_SLATE[100], outline: `2px solid ${DS_NEUTRAL.gold}`, outlineOffset: -2 },
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</ButtonBase>
|
||||
)
|
||||
}
|
||||
|
||||
function ProfileLabel({ icon, label }: { icon: React.ReactNode; label: string }) {
|
||||
return (
|
||||
<>
|
||||
<Typography sx={{ fontSize: '0.8125rem', color: DS_TEXT.primary }}>{label}</Typography>
|
||||
<Box sx={{ display: 'flex', color: DS_TEXT.secondary }}>{icon}</Box>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
import { useState } from 'react'
|
||||
import { useNavigate } from 'react-router'
|
||||
import { Avatar, Box, Divider, IconButton, ListItemIcon, Menu, MenuItem, Typography } from '@mui/material'
|
||||
import { HelpCircle, LogOut, Settings, User } from 'lucide-react'
|
||||
import { useSessionStore } from '../../stores/sessionStore'
|
||||
import { useToastStore } from '../../stores/toastStore'
|
||||
import { DemoRoleSwitcher } from '../auth/DemoRoleSwitcher'
|
||||
import { DS_BRAND } from '../../lib/ds'
|
||||
|
||||
const ROLE_LABELS: Record<string, string> = {
|
||||
PROPERTY_MANAGER: 'Property Manager',
|
||||
DEMAND_USER: 'Demand User',
|
||||
}
|
||||
|
||||
function getUserInitials(name: string): string {
|
||||
return name
|
||||
.split(' ')
|
||||
.map((n) => n[0])
|
||||
.join('')
|
||||
.toUpperCase()
|
||||
.slice(0, 2)
|
||||
}
|
||||
|
||||
export function UserMenu() {
|
||||
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null)
|
||||
const { currentUser, logout } = useSessionStore()
|
||||
const navigate = useNavigate()
|
||||
const showToast = useToastStore((s) => s.showToast)
|
||||
|
||||
function handleOpen(e: React.MouseEvent<HTMLElement>) {
|
||||
setAnchorEl(e.currentTarget)
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
setAnchorEl(null)
|
||||
}
|
||||
|
||||
function handleLogout() {
|
||||
handleClose()
|
||||
logout()
|
||||
navigate('/auth/login')
|
||||
}
|
||||
|
||||
const initials = getUserInitials(currentUser?.name ?? 'U')
|
||||
|
||||
return (
|
||||
<>
|
||||
<IconButton onClick={handleOpen} size="small" sx={{ p: 0 }}>
|
||||
<Avatar sx={{ width: 32, height: 32, fontSize: '0.75rem', bgcolor: DS_BRAND.main }}>
|
||||
{initials}
|
||||
</Avatar>
|
||||
</IconButton>
|
||||
|
||||
<Menu
|
||||
anchorEl={anchorEl}
|
||||
open={!!anchorEl}
|
||||
onClose={handleClose}
|
||||
slotProps={{ paper: { sx: { width: 300, mt: 1 } } }}
|
||||
transformOrigin={{ horizontal: 'right', vertical: 'top' }}
|
||||
anchorOrigin={{ horizontal: 'right', vertical: 'bottom' }}
|
||||
>
|
||||
{/* User info header */}
|
||||
{currentUser && (
|
||||
<Box sx={{ px: 2, py: 1.5 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>{currentUser.name}</Typography>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary', display: 'block' }}>
|
||||
{ROLE_LABELS[currentUser.role] ?? currentUser.role}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary', display: 'block' }}>
|
||||
{currentUser.organizationName}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Divider />
|
||||
|
||||
<MenuItem onClick={() => { handleClose(); showToast('Profilseite ist in Kürze verfügbar.', 'info') }}>
|
||||
<ListItemIcon><User size={16} /></ListItemIcon>
|
||||
Profil
|
||||
</MenuItem>
|
||||
<MenuItem onClick={() => { handleClose(); showToast('Einstellungen sind in Kürze verfügbar.', 'info') }}>
|
||||
<ListItemIcon><Settings size={16} /></ListItemIcon>
|
||||
Einstellungen
|
||||
</MenuItem>
|
||||
<MenuItem onClick={() => { handleClose(); localStorage.removeItem('property_match_welcomed'); window.location.reload() }}>
|
||||
<ListItemIcon><HelpCircle size={16} /></ListItemIcon>
|
||||
Produkttour starten
|
||||
</MenuItem>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Demo role switcher */}
|
||||
<Box sx={{ px: 1, py: 1 }}>
|
||||
<DemoRoleSwitcher />
|
||||
</Box>
|
||||
|
||||
<Divider />
|
||||
|
||||
<MenuItem onClick={handleLogout}>
|
||||
<ListItemIcon><LogOut size={16} /></ListItemIcon>
|
||||
Abmelden
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -10,19 +10,36 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { WORKSPACE_CONFIG, getPageNameFromPath } from '../appShellConfig'
|
||||
import { WorkspaceType } from '../../../domain/enums'
|
||||
import { MY_AGENTS_LABEL, ROUTES } from '../../../lib/constants'
|
||||
import { HOME_NAV_LABEL, MY_AGENTS_LABEL, ROUTES } from '../../../lib/constants'
|
||||
import { AGENT_WORKSPACES } from '../../../lib/agentWorkspaces'
|
||||
|
||||
const supplyNav = WORKSPACE_CONFIG[WorkspaceType.SUPPLY].navItems
|
||||
|
||||
describe('Hauptnavigation der Verwaltung', () => {
|
||||
it('führt «Übersicht» nicht mehr (Runde 5, §2.1)', () => {
|
||||
expect(supplyNav.map(item => item.label)).not.toContain('Übersicht')
|
||||
expect(supplyNav.map(item => item.path)).not.toContain(ROUTES.SUPPLY.LEGACY_DASHBOARD)
|
||||
})
|
||||
|
||||
it('beginnt mit der «Startseite» und führt sie nur einmal', () => {
|
||||
expect(supplyNav[0]?.label).toBe(HOME_NAV_LABEL)
|
||||
expect(supplyNav[0]?.path).toBe(ROUTES.SUPPLY.PROPERTIES)
|
||||
expect(supplyNav.filter(item => item.path === ROUTES.SUPPLY.PROPERTIES)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('startet den Arbeitsbereich auf der Startseite', () => {
|
||||
expect(WORKSPACE_CONFIG[WorkspaceType.SUPPLY].firstPath).toBe(ROUTES.SUPPLY.PROPERTIES)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Navigationseintrag «Meine Agenten»', () => {
|
||||
it('steht direkt unter «Meine Objekte»', () => {
|
||||
it('steht direkt unter der «Startseite»', () => {
|
||||
const labels = supplyNav.map(item => item.label)
|
||||
const objekte = labels.indexOf('Meine Objekte')
|
||||
const startseite = labels.indexOf(HOME_NAV_LABEL)
|
||||
const agenten = labels.indexOf(MY_AGENTS_LABEL)
|
||||
|
||||
expect(objekte).toBeGreaterThanOrEqual(0)
|
||||
expect(agenten).toBe(objekte + 1)
|
||||
expect(startseite).toBeGreaterThanOrEqual(0)
|
||||
expect(agenten).toBe(startseite + 1)
|
||||
})
|
||||
|
||||
it('führt auf den Basispfad des Funktionsbereichs', () => {
|
||||
@@ -64,6 +81,7 @@ describe('Navigationseintrag «Meine Agenten»', () => {
|
||||
|
||||
describe('Seitentitel aus dem Pfad', () => {
|
||||
it('löst Haupt- und Agentenreiter über die Navigation auf', () => {
|
||||
expect(getPageNameFromPath(ROUTES.SUPPLY.PROPERTIES)).toBe(HOME_NAV_LABEL)
|
||||
expect(getPageNameFromPath(ROUTES.SUPPLY.TEAM)).toBe(MY_AGENTS_LABEL)
|
||||
expect(getPageNameFromPath(ROUTES.SUPPLY.AGENT_FERDI)).toBe('Ferdi')
|
||||
expect(getPageNameFromPath(ROUTES.SUPPLY.AGENT_BRUNO)).toBe('Bruno')
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { WorkspaceType } from '../../domain/enums'
|
||||
import { MY_AGENTS_LABEL, ROUTES } from '../../lib/constants'
|
||||
import { HOME_NAV_LABEL, MY_AGENTS_LABEL, ROUTES } from '../../lib/constants'
|
||||
import { AGENT_WORKSPACES } from '../../lib/agentWorkspaces'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Building2,
|
||||
CheckSquare,
|
||||
Search,
|
||||
@@ -63,11 +62,13 @@ export const WORKSPACE_CONFIG: Record<WorkspaceType, WorkspaceConfig> = {
|
||||
label: 'Verwaltung',
|
||||
abbreviation: 'VW',
|
||||
icon: Building2,
|
||||
firstPath: '/supply/dashboard',
|
||||
firstPath: ROUTES.SUPPLY.PROPERTIES,
|
||||
chipColor: '#152642',
|
||||
navItems: [
|
||||
{ path: '/supply/dashboard', label: 'Übersicht', icon: LayoutDashboard },
|
||||
{ path: '/supply/properties', label: 'Meine Objekte', icon: Building2 },
|
||||
// Runde 5, §2.1/§2.2: «Übersicht» ist entfernt, «Meine Objekte» heisst im
|
||||
// Menü «Startseite». Die Belegschaft und die Objektverwaltung liegen auf
|
||||
// derselben Seite — zwei Einträge für eine Seite wären ein toter Link.
|
||||
{ path: ROUTES.SUPPLY.PROPERTIES, label: HOME_NAV_LABEL, icon: Building2 },
|
||||
{
|
||||
path: ROUTES.SUPPLY.TEAM,
|
||||
label: MY_AGENTS_LABEL,
|
||||
@@ -93,7 +94,7 @@ export const WORKSPACE_CONFIG: Record<WorkspaceType, WorkspaceConfig> = {
|
||||
label: 'Suche',
|
||||
abbreviation: 'SU',
|
||||
icon: Search,
|
||||
firstPath: '/supply/dashboard',
|
||||
firstPath: ROUTES.SUPPLY.PROPERTIES,
|
||||
chipColor: '#1a7a4a',
|
||||
navItems: [],
|
||||
},
|
||||
|
||||
@@ -2,6 +2,6 @@ export { AppShell } from './AppShell'
|
||||
export { PageHeader } from './PageHeader'
|
||||
export { RightContextPanel } from './RightContextPanel'
|
||||
export { CompareTray } from './CompareTray'
|
||||
export { UserMenu } from './UserMenu'
|
||||
export { GlobalNavMenu } from './GlobalNavMenu'
|
||||
export { NotificationButton } from './NotificationButton'
|
||||
export { OrganizationContextBadge } from './OrganizationContextBadge'
|
||||
@@ -0,0 +1,181 @@
|
||||
import { useMemo } from 'react'
|
||||
import { Box, Chip, Divider, Typography } from '@mui/material'
|
||||
import { Building2, Mail, MapPin, Phone, Ruler, User } from 'lucide-react'
|
||||
import type { CrmLead } from '../../domain/crmLead'
|
||||
import { CRM_LEAD_STAGE_LABELS } from '../../domain/crmLead'
|
||||
import { LeadChannel } from '../../domain/unifiedLead'
|
||||
import { useProperties } from '../../hooks/useProperties'
|
||||
import { ObjectDeepLink } from '../team'
|
||||
import { LeadChannelBadge } from './LeadChannelBadge'
|
||||
import { areaFitPct } from './marketLeadHelpers'
|
||||
import { ASSET_TYPE_LABELS } from '../../lib/constants'
|
||||
import { DS_BRAND, DS_SLATE, DS_TEXT } from '../../lib/ds'
|
||||
|
||||
function formatDateTime(iso: string): string {
|
||||
return new Date(iso).toLocaleDateString('de-CH', { day: '2-digit', month: '2-digit', year: 'numeric' })
|
||||
}
|
||||
|
||||
/**
|
||||
* Detailansicht eines CRM-Leads (Runde 5, §12).
|
||||
*
|
||||
* Bewusst nüchtern: der Lead ist bereits von einem Menschen erfasst, hier
|
||||
* braucht es keine Konfidenz und keine Signalherleitung — sondern
|
||||
* Ansprechpartner, Stand, Zuständigkeit und passende Objekte.
|
||||
*
|
||||
* Die Herkunft steht oben und nicht im Kleingedruckten: welches System den
|
||||
* Lead geliefert hat, entscheidet darüber, wie belastbar er ist (§12).
|
||||
*/
|
||||
export function CrmLeadDetail({ lead }: { lead: CrmLead }) {
|
||||
const { data: properties = [] } = useProperties()
|
||||
|
||||
const areaEstimate = lead.areaSqmMax ?? lead.areaSqmMin
|
||||
const cityKey = lead.locationHint.split(',')[0].trim().toLowerCase()
|
||||
|
||||
/**
|
||||
* Dieselbe Grosszügigkeit wie bei den KI-Signalen: gesucht wird eine Spanne,
|
||||
* kein exakter Wert. Ein Objekt darf die Untergrenze um ein Drittel
|
||||
* unterschreiten und die Obergrenze um die Hälfte überschreiten — darüber
|
||||
* hinaus lohnt sich das Gespräch nicht mehr.
|
||||
*/
|
||||
const matches = useMemo(() => {
|
||||
const low = (lead.areaSqmMin ?? lead.areaSqmMax ?? 0) * 0.65
|
||||
const high = (lead.areaSqmMax ?? lead.areaSqmMin ?? Number.MAX_SAFE_INTEGER) * 1.5
|
||||
return properties
|
||||
.filter(p => {
|
||||
if (p.assetType !== lead.assetType) return false
|
||||
const city = (p.location?.city ?? '').toLowerCase()
|
||||
if (!city || (!city.includes(cityKey) && !cityKey.includes(city))) return false
|
||||
return areaEstimate ? p.areaSqm >= low && p.areaSqm <= high : true
|
||||
})
|
||||
.map(p => ({ p, fit: areaEstimate ? areaFitPct(p.areaSqm, areaEstimate) : null }))
|
||||
.sort((a, b) => (b.fit ?? 0) - (a.fit ?? 0))
|
||||
.slice(0, 4)
|
||||
}, [properties, lead.assetType, lead.areaSqmMin, lead.areaSqmMax, areaEstimate, cityKey])
|
||||
|
||||
const areaStr = areaEstimate
|
||||
? lead.areaSqmMin && lead.areaSqmMax && lead.areaSqmMin !== lead.areaSqmMax
|
||||
? `${lead.areaSqmMin}–${lead.areaSqmMax} m²`
|
||||
: `${areaEstimate} m²`
|
||||
: null
|
||||
|
||||
return (
|
||||
<Box sx={{ height: '100%', overflowY: 'auto' }}>
|
||||
<Box sx={{ p: 3, borderBottom: '1px solid #e2e8f0' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
|
||||
<LeadChannelBadge channel={LeadChannel.CRM} />
|
||||
<Typography variant="caption" sx={{ color: DS_TEXT.muted }}>
|
||||
{lead.crmSystem} · zuletzt kontaktiert {formatDateTime(lead.lastContactAt)}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Typography variant="h6" sx={{ fontWeight: 700, lineHeight: 1.3, mb: 1 }}>
|
||||
{lead.companyName}
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.75, mb: 1.25 }}>
|
||||
<Chip
|
||||
label={CRM_LEAD_STAGE_LABELS[lead.stage]}
|
||||
size="small"
|
||||
sx={{ bgcolor: DS_BRAND.main, color: 'white', fontWeight: 600, fontSize: '0.72rem' }}
|
||||
/>
|
||||
<Chip icon={<MapPin size={11} />} label={lead.locationHint} size="small" sx={{ bgcolor: DS_SLATE[100], color: DS_SLATE[600], fontSize: '0.75rem' }} />
|
||||
{areaStr && <Chip icon={<Ruler size={11} />} label={areaStr} size="small" sx={{ bgcolor: DS_SLATE[100], color: DS_SLATE[600], fontSize: '0.75rem' }} />}
|
||||
<Chip label={ASSET_TYPE_LABELS[lead.assetType] ?? lead.assetType} size="small" sx={{ bgcolor: DS_SLATE[100], color: DS_SLATE[600], fontSize: '0.75rem' }} />
|
||||
</Box>
|
||||
|
||||
<Typography variant="caption" sx={{ color: DS_TEXT.muted }}>
|
||||
Zuständig: <strong>{lead.ownerName}</strong>
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ p: 3, display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<Box>
|
||||
<SectionLabel>Ansprechpartner</SectionLabel>
|
||||
<ContactLine icon={<User size={13} />} value={lead.contactPerson} />
|
||||
{lead.contactEmail && (
|
||||
<ContactLine icon={<Mail size={13} />} value={lead.contactEmail} href={`mailto:${lead.contactEmail}`} />
|
||||
)}
|
||||
{lead.contactPhone && (
|
||||
<ContactLine icon={<Phone size={13} />} value={lead.contactPhone} href={`tel:${lead.contactPhone.replace(/\s/g, '')}`} />
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{lead.note && (
|
||||
<>
|
||||
<Divider />
|
||||
<Box>
|
||||
<SectionLabel>Notiz aus dem CRM</SectionLabel>
|
||||
<Typography variant="body2" sx={{ color: DS_SLATE[800], lineHeight: 1.6 }}>
|
||||
{lead.note}
|
||||
</Typography>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Divider />
|
||||
|
||||
<Box>
|
||||
<SectionLabel>Passende Objekte im Portfolio ({matches.length})</SectionLabel>
|
||||
{matches.length === 0 ? (
|
||||
<Typography variant="body2" sx={{ color: DS_TEXT.muted }}>
|
||||
Kein Portfolioobjekt erfüllt Ort, Nutzungsart und Flächenbedarf zugleich.
|
||||
</Typography>
|
||||
) : (
|
||||
matches.map(({ p, fit }) => (
|
||||
<Box
|
||||
key={p.id}
|
||||
sx={{
|
||||
display: 'flex', alignItems: 'center', gap: 1.25,
|
||||
py: 1, borderBottom: '1px solid #f1f5f9',
|
||||
}}
|
||||
>
|
||||
<Building2 size={14} color={DS_BRAND.main} style={{ flexShrink: 0 }} />
|
||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||
<ObjectDeepLink propertyId={p.id} label={p.title} fontWeight={600} />
|
||||
<Typography variant="caption" sx={{ color: DS_TEXT.muted, display: 'block' }}>
|
||||
{p.location?.city} · {p.areaSqm} m²
|
||||
</Typography>
|
||||
</Box>
|
||||
{fit !== null && (
|
||||
<Typography variant="caption" sx={{ color: DS_TEXT.secondary, fontWeight: 600 }}>
|
||||
{fit}% Flächenpassung
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
))
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
function SectionLabel({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{ fontWeight: 700, color: DS_TEXT.muted, textTransform: 'uppercase', letterSpacing: 0.5, display: 'block', mb: 1, fontSize: '0.65rem' }}
|
||||
>
|
||||
{children}
|
||||
</Typography>
|
||||
)
|
||||
}
|
||||
|
||||
function ContactLine({ icon, value, href }: { icon: React.ReactNode; value: string; href?: string }) {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, py: 0.5 }}>
|
||||
<Box sx={{ color: DS_SLATE[500], display: 'flex' }}>{icon}</Box>
|
||||
{href ? (
|
||||
<Box
|
||||
component="a"
|
||||
href={href}
|
||||
sx={{ color: DS_BRAND.main, fontSize: '0.8125rem', fontWeight: 500, textDecoration: 'none', '&:hover': { textDecoration: 'underline' } }}
|
||||
>
|
||||
{value}
|
||||
</Box>
|
||||
) : (
|
||||
<Typography variant="body2" sx={{ fontSize: '0.8125rem', color: DS_SLATE[900] }}>{value}</Typography>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { memo } from 'react'
|
||||
import { Chip } from '@mui/material'
|
||||
import type { LeadChannel } from '../../domain/unifiedLead'
|
||||
import { LEAD_CHANNEL_META } from '../../lib/ds'
|
||||
|
||||
/**
|
||||
* Kennzeichen der Kanalherkunft direkt neben dem Lead (Runde 5, §12).
|
||||
*
|
||||
* Eine einzige Komponente für Liste und Detail: derselbe Kanal darf nicht an
|
||||
* zwei Stellen zwei Farben haben. Beschriftung und Farben kommen aus
|
||||
* `LEAD_CHANNEL_META` (CLAUDE.md §8.3).
|
||||
*/
|
||||
export const LeadChannelBadge = memo(function LeadChannelBadge({
|
||||
channel,
|
||||
size = 'small',
|
||||
}: {
|
||||
channel: LeadChannel
|
||||
size?: 'small' | 'medium'
|
||||
}) {
|
||||
const meta = LEAD_CHANNEL_META[channel]
|
||||
if (!meta) return null
|
||||
|
||||
return (
|
||||
<Chip
|
||||
label={meta.label}
|
||||
size={size}
|
||||
sx={{
|
||||
height: 20,
|
||||
fontSize: '0.68rem',
|
||||
fontWeight: 700,
|
||||
letterSpacing: 0.2,
|
||||
color: meta.color,
|
||||
bgcolor: meta.bg,
|
||||
border: '1px solid',
|
||||
borderColor: meta.color,
|
||||
'& .MuiChip-label': { px: 0.75 },
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,112 @@
|
||||
import { Box, Button, Chip, Typography } from '@mui/material'
|
||||
import { Plus } from 'lucide-react'
|
||||
import type { LeadChannel, LeadChannelFilter } from '../../domain/unifiedLead'
|
||||
import { LEAD_CHANNEL_ALL, LEAD_CHANNEL_ORDER } from '../../domain/unifiedLead'
|
||||
import { DS_BRAND, DS_NEUTRAL, DS_SLATE, DS_TEXT, LEAD_CHANNEL_META } from '../../lib/ds'
|
||||
|
||||
export interface LeadChannelFilterBarProps {
|
||||
value: LeadChannelFilter
|
||||
onChange: (next: LeadChannelFilter) => void
|
||||
/** Anzahl je Kanal plus Gesamtzahl — der Filter zeigt, was er zu bieten hat. */
|
||||
counts: Record<LeadChannelFilter, number>
|
||||
onCreateHinweis: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter «Kanaltyp» im bisherigen Kanalbalken (Runde 5, §13).
|
||||
*
|
||||
* Er steht genau dort, wo vorher die Reiter «KI-Signale» und «Netzwerk»
|
||||
* standen. Aus dem Umschalten zwischen zwei Listen ist ein Filter über eine
|
||||
* gemeinsame Liste geworden: Standard ist «Alle», und die Wahl wirkt sofort,
|
||||
* ohne Seitenwechsel.
|
||||
*
|
||||
* Die Zahlen neben den Beschriftungen sind bewusst Teil des Filters — ohne sie
|
||||
* wüsste niemand, ob ein leeres Ergebnis am Filter oder an der Datenlage liegt.
|
||||
*/
|
||||
export function LeadChannelFilterBar({ value, onChange, counts, onCreateHinweis }: LeadChannelFilterBarProps) {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
flexWrap: 'wrap',
|
||||
gap: 1,
|
||||
px: 3, py: 1.25,
|
||||
bgcolor: 'white',
|
||||
borderTop: '1px solid #e2e8f0',
|
||||
borderBottom: '1px solid #e2e8f0',
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{ fontWeight: 700, color: DS_TEXT.muted, textTransform: 'uppercase', letterSpacing: 0.5, mr: 0.5 }}
|
||||
>
|
||||
Kanaltyp
|
||||
</Typography>
|
||||
|
||||
<FilterChip
|
||||
label="Alle"
|
||||
count={counts[LEAD_CHANNEL_ALL]}
|
||||
active={value === LEAD_CHANNEL_ALL}
|
||||
activeColor={DS_BRAND.main}
|
||||
onClick={() => onChange(LEAD_CHANNEL_ALL)}
|
||||
/>
|
||||
|
||||
{LEAD_CHANNEL_ORDER.map((channel: LeadChannel) => (
|
||||
<FilterChip
|
||||
key={channel}
|
||||
label={LEAD_CHANNEL_META[channel]?.label ?? channel}
|
||||
count={counts[channel]}
|
||||
active={value === channel}
|
||||
activeColor={LEAD_CHANNEL_META[channel]?.color ?? DS_BRAND.main}
|
||||
onClick={() => onChange(channel)}
|
||||
/>
|
||||
))}
|
||||
|
||||
<Box sx={{ flex: 1 }} />
|
||||
|
||||
{/* Netzwerk-Hinweise entstehen weiterhin hier — die Funktion war an den
|
||||
entfernten Reiter gebunden und hätte sonst keinen Einstieg mehr. */}
|
||||
<Button
|
||||
size="small"
|
||||
variant="contained"
|
||||
startIcon={<Plus size={13} />}
|
||||
onClick={onCreateHinweis}
|
||||
sx={{
|
||||
textTransform: 'none', fontSize: '0.75rem',
|
||||
bgcolor: DS_BRAND.main, '&:hover': { bgcolor: DS_BRAND.hoverAlt },
|
||||
}}
|
||||
>
|
||||
Hinweis erfassen
|
||||
</Button>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
function FilterChip({
|
||||
label, count, active, activeColor, onClick,
|
||||
}: {
|
||||
label: string
|
||||
count: number
|
||||
active: boolean
|
||||
activeColor: string
|
||||
onClick: () => void
|
||||
}) {
|
||||
return (
|
||||
<Chip
|
||||
label={`${label} (${count})`}
|
||||
size="small"
|
||||
clickable
|
||||
onClick={onClick}
|
||||
aria-pressed={active}
|
||||
sx={{
|
||||
height: 26,
|
||||
fontSize: '0.75rem',
|
||||
fontWeight: active ? 700 : 500,
|
||||
bgcolor: active ? activeColor : DS_SLATE[100],
|
||||
color: active ? DS_NEUTRAL.white : DS_SLATE[600],
|
||||
'&:hover': { bgcolor: active ? activeColor : DS_SLATE[200] },
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,5 @@
|
||||
import { memo } from 'react'
|
||||
import { Box, Checkbox, Chip, Typography } from '@mui/material'
|
||||
import { Building2, ExternalLink } from 'lucide-react'
|
||||
import type { MarketLead } from '../../hooks/useMarketLeads'
|
||||
import type { Property } from '../../domain/property'
|
||||
import type { ExtractedContact } from '../../domain/futureSignal'
|
||||
import { ObjectDeepLink } from '../team'
|
||||
@@ -9,7 +7,7 @@ import { CONTACT_ICONS } from './marketLeadHelpers'
|
||||
import { DS_BG, DS_BRAND, DS_SLATE, DS_TEXT } from '../../lib/ds'
|
||||
|
||||
/**
|
||||
* Signalliste und Signaldetail der Nora-Seite.
|
||||
* Bausteine der Signal-Detailansicht auf der Nora-Seite.
|
||||
*
|
||||
* Ausgezogen aus `MarketIntelligence.tsx` — die Seite hält jetzt nur noch
|
||||
* Reiter, Auswahl und Rahmen.
|
||||
@@ -17,50 +15,6 @@ import { DS_BG, DS_BRAND, DS_SLATE, DS_TEXT } from '../../lib/ds'
|
||||
|
||||
// ── Left pane list item ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Kompakte Signalkarte (Runde 4, §7.2).
|
||||
*
|
||||
* Prozentsatz und Objektanzahl sind entfallen: beide Zahlen wurden auf der
|
||||
* Karte gelesen, ohne dass man etwas mit ihnen anfangen konnte — die
|
||||
* Entscheidung fällt in der Detailansicht. Übrig bleiben die Kerninformationen.
|
||||
*/
|
||||
const LeadListItem = memo(function LeadListItem({
|
||||
lead, selected, onClick,
|
||||
}: {
|
||||
lead: MarketLead
|
||||
selected: boolean
|
||||
onClick: () => void
|
||||
}) {
|
||||
const { signal } = lead
|
||||
|
||||
return (
|
||||
<Box
|
||||
onClick={onClick}
|
||||
sx={{
|
||||
px: 2, py: 1.5,
|
||||
borderBottom: '1px solid #f1f5f9',
|
||||
borderLeft: `3px solid ${selected ? '#152642' : 'transparent'}`,
|
||||
bgcolor: selected ? '#f0f9ff' : 'white',
|
||||
cursor: 'pointer',
|
||||
'&:hover': { bgcolor: selected ? '#f0f9ff' : '#f8fafc' },
|
||||
transition: 'background 0.1s',
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, color: DS_SLATE[900], lineHeight: 1.3, mb: 0.25 }}>
|
||||
{signal.companyName ?? signal.locationHint}
|
||||
</Typography>
|
||||
{signal.title && (
|
||||
<Typography variant="caption" sx={{ color: DS_SLATE[500], display: 'block', mb: 0.5, lineHeight: 1.3 }}>
|
||||
{signal.title}
|
||||
</Typography>
|
||||
)}
|
||||
<Typography variant="caption" sx={{ color: DS_SLATE[400], fontSize: '0.65rem' }}>
|
||||
{signal.locationHint} · {signal.timeHorizonMonths} Mo.
|
||||
</Typography>
|
||||
</Box>
|
||||
)
|
||||
})
|
||||
|
||||
// ── Contact row ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -159,4 +113,4 @@ function SignalPropertyRow({
|
||||
}
|
||||
|
||||
|
||||
export { LeadListItem, ContactRow, SignalPropertyRow }
|
||||
export { ContactRow, SignalPropertyRow }
|
||||
@@ -0,0 +1,140 @@
|
||||
import { memo } from 'react'
|
||||
import { Box, Button, Skeleton, Typography } from '@mui/material'
|
||||
import { Inbox } from 'lucide-react'
|
||||
import type { UnifiedLead } from '../../domain/unifiedLead'
|
||||
import { LeadChannelBadge } from './LeadChannelBadge'
|
||||
import { DS_SLATE, DS_TEXT } from '../../lib/ds'
|
||||
|
||||
/** Spaltenraster im Stil der übrigen Agentenlisten (Livia, Bruno). */
|
||||
const COLS = '120px 1.5fr 1.4fr 150px'
|
||||
|
||||
const COLUMN_LABELS = ['Kanal', 'Interessent', 'Anliegen', 'Eingang']
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
return new Date(iso).toLocaleDateString('de-CH', { day: '2-digit', month: '2-digit', year: 'numeric' })
|
||||
}
|
||||
|
||||
const LeadRow = memo(function LeadRow({
|
||||
lead, selected, onSelect,
|
||||
}: {
|
||||
lead: UnifiedLead
|
||||
selected: boolean
|
||||
onSelect: (lead: UnifiedLead) => void
|
||||
}) {
|
||||
return (
|
||||
<Box
|
||||
onClick={() => onSelect(lead)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onSelect(lead) } }}
|
||||
sx={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: COLS,
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
px: 2, py: 1.5,
|
||||
borderBottom: '1px solid #f1f5f9',
|
||||
bgcolor: selected ? DS_SLATE[50] : 'white',
|
||||
cursor: 'pointer',
|
||||
'&:hover': { bgcolor: DS_SLATE[50] },
|
||||
'&:focus-visible': { outline: '2px solid', outlineColor: DS_SLATE[400], outlineOffset: -2 },
|
||||
transition: 'background-color 0.1s',
|
||||
}}
|
||||
>
|
||||
<Box>
|
||||
<LeadChannelBadge channel={lead.channel} />
|
||||
</Box>
|
||||
|
||||
<Box sx={{ minWidth: 0 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, color: DS_SLATE[900], lineHeight: 1.3 }}>
|
||||
{lead.title}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: DS_TEXT.muted, display: 'block', lineHeight: 1.3 }}>
|
||||
{lead.meta}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Typography variant="body2" sx={{ color: DS_TEXT.secondary }}>
|
||||
{lead.subtitle}
|
||||
</Typography>
|
||||
|
||||
<Typography variant="caption" sx={{ color: DS_TEXT.secondary }}>
|
||||
{formatDate(lead.receivedAt)}
|
||||
</Typography>
|
||||
</Box>
|
||||
)
|
||||
})
|
||||
|
||||
export interface UnifiedLeadListProps {
|
||||
leads: UnifiedLead[]
|
||||
isLoading: boolean
|
||||
selectedId: string | null
|
||||
onSelect: (lead: UnifiedLead) => void
|
||||
/** Nur gesetzt, wenn ein Filter aktiv ist — dann bietet der Leerzustand die Rücksetzung an. */
|
||||
onResetFilter?: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Gemeinsame Leadliste aller drei Kanäle (Runde 5, §12).
|
||||
*
|
||||
* Ersetzt die beiden getrennten Reiterlisten. Jede Zeile trägt ihr
|
||||
* Kanalkennzeichen ganz links — die Herkunft ist die erste Information, die
|
||||
* beim Überfliegen zählt, weil sie bestimmt, wie belastbar der Lead ist.
|
||||
*/
|
||||
export function UnifiedLeadList({ leads, isLoading, selectedId, onSelect, onResetFilter }: UnifiedLeadListProps) {
|
||||
return (
|
||||
<Box sx={{ border: '1px solid #e2e8f0', borderRadius: 1.5, overflow: 'hidden', bgcolor: 'white' }}>
|
||||
{/* Kopfzeile */}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: COLS,
|
||||
gap: 1,
|
||||
px: 2, py: 1,
|
||||
bgcolor: DS_SLATE[50],
|
||||
borderBottom: '1px solid #e2e8f0',
|
||||
}}
|
||||
>
|
||||
{COLUMN_LABELS.map(label => (
|
||||
<Typography
|
||||
key={label}
|
||||
variant="caption"
|
||||
sx={{ fontWeight: 700, color: DS_TEXT.muted, textTransform: 'uppercase', letterSpacing: 0.5, fontSize: '0.65rem' }}
|
||||
>
|
||||
{label}
|
||||
</Typography>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
{isLoading
|
||||
? [1, 2, 3, 4].map(i => (
|
||||
<Box key={i} sx={{ px: 2, py: 1.75, borderBottom: '1px solid #f1f5f9' }}>
|
||||
<Skeleton variant="text" width="45%" />
|
||||
<Skeleton variant="text" width="70%" height={14} />
|
||||
</Box>
|
||||
))
|
||||
: leads.length === 0
|
||||
? (
|
||||
<Box sx={{ px: 3, py: 6, display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 1.5 }}>
|
||||
<Inbox size={28} color={DS_SLATE[400]} />
|
||||
<Typography variant="body2" sx={{ color: DS_TEXT.secondary, textAlign: 'center' }}>
|
||||
Für diesen Kanaltyp liegen derzeit keine Leads vor.
|
||||
</Typography>
|
||||
{onResetFilter && (
|
||||
<Button size="small" variant="outlined" onClick={onResetFilter} sx={{ textTransform: 'none' }}>
|
||||
Alle Kanäle anzeigen
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
: leads.map(lead => (
|
||||
<LeadRow
|
||||
key={`${lead.channel}-${lead.id}`}
|
||||
lead={lead}
|
||||
selected={lead.id === selectedId}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* Property On — gemeinsame Leadliste bei Nora (Runde 5, §12/§13).
|
||||
*
|
||||
* Zwei Zusicherungen aus dem Auftrag, die sich sonst niemand ansieht:
|
||||
*
|
||||
* 1. Jeder Lead trägt sein Kanalkennzeichen sichtbar in der Zeile. Die Herkunft
|
||||
* darf nicht bloss aus der Farbe hervorgehen und auch nicht aus der Position
|
||||
* in der Liste erratbar sein.
|
||||
* 2. Ein leeres Filterergebnis erklärt sich und bietet den Weg zurück auf
|
||||
* «Alle». Mit den heutigen Mockdaten ist kein Kanal leer — der Fall wäre in
|
||||
* der laufenden Anwendung also gar nicht auslösbar und bliebe ungeprüft, bis
|
||||
* er beim ersten echten Datensatz auftritt.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest'
|
||||
import { screen, cleanup, fireEvent } from '@testing-library/react'
|
||||
import { renderWithProviders } from '../../../test/teamTestUtils'
|
||||
import { UnifiedLeadList } from '../UnifiedLeadList'
|
||||
import { LeadChannel } from '../../../domain/unifiedLead'
|
||||
import type { UnifiedLead } from '../../../domain/unifiedLead'
|
||||
import { LEAD_CHANNEL_META } from '../../../lib/ds'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
const KI_LEAD = {
|
||||
id: 'sig-1',
|
||||
channel: LeadChannel.KI_SIGNAL,
|
||||
title: 'Alpbach Advisors AG',
|
||||
subtitle: 'Erkanntes Nachfragesignal',
|
||||
meta: 'Zürich · Horizont 6 Mo.',
|
||||
receivedAt: '2026-08-02T10:00:00.000Z',
|
||||
signal: { id: 'sig-1' },
|
||||
matchingProperties: [],
|
||||
} as unknown as UnifiedLead
|
||||
|
||||
const NETZ_LEAD = {
|
||||
id: 'mh-1',
|
||||
channel: LeadChannel.NETZWERK,
|
||||
title: 'Fintech Solutions AG',
|
||||
subtitle: 'Sucht Büro',
|
||||
meta: 'Zürich Innenstadt · 200–400 m²',
|
||||
receivedAt: '2026-07-30T10:00:00.000Z',
|
||||
hinweis: { id: 'mh-1' },
|
||||
} as unknown as UnifiedLead
|
||||
|
||||
const CRM_LEAD = {
|
||||
id: 'crm-1',
|
||||
channel: LeadChannel.CRM,
|
||||
title: 'Rheinblick Treuhand AG',
|
||||
subtitle: 'In Verhandlung — sucht Büro',
|
||||
meta: 'Basel, Innenstadt · 280–420 m²',
|
||||
receivedAt: '2026-08-04T10:00:00.000Z',
|
||||
crmLead: { id: 'crm-1' },
|
||||
} as unknown as UnifiedLead
|
||||
|
||||
const ALLE = [CRM_LEAD, KI_LEAD, NETZ_LEAD]
|
||||
|
||||
describe('Gemeinsame Leadliste', () => {
|
||||
it('zeigt zu jedem Lead die Kanalherkunft als lesbaren Text', () => {
|
||||
renderWithProviders(
|
||||
<UnifiedLeadList leads={ALLE} isLoading={false} selectedId={null} onSelect={() => {}} />,
|
||||
)
|
||||
|
||||
for (const lead of ALLE) {
|
||||
const label = LEAD_CHANNEL_META[lead.channel].label
|
||||
expect(screen.getByText(lead.title)).toBeInTheDocument()
|
||||
expect(screen.getAllByText(label).length).toBeGreaterThan(0)
|
||||
}
|
||||
})
|
||||
|
||||
it('führt Leads aller drei Kanäle in einer einzigen Liste', () => {
|
||||
renderWithProviders(
|
||||
<UnifiedLeadList leads={ALLE} isLoading={false} selectedId={null} onSelect={() => {}} />,
|
||||
)
|
||||
const kanaele = ['KI Signal', 'Netzwerk', 'CRM'].map(l => screen.getAllByText(l).length)
|
||||
expect(kanaele.every(n => n > 0)).toBe(true)
|
||||
})
|
||||
|
||||
it('meldet den Lead beim Klick auf die ganze Zeile', () => {
|
||||
const onSelect = vi.fn()
|
||||
renderWithProviders(
|
||||
<UnifiedLeadList leads={[CRM_LEAD]} isLoading={false} selectedId={null} onSelect={onSelect} />,
|
||||
)
|
||||
fireEvent.click(screen.getByText('Rheinblick Treuhand AG'))
|
||||
expect(onSelect).toHaveBeenCalledWith(CRM_LEAD)
|
||||
})
|
||||
|
||||
it('erklärt ein leeres Filterergebnis und bietet die Rücksetzung auf «Alle»', () => {
|
||||
const onResetFilter = vi.fn()
|
||||
renderWithProviders(
|
||||
<UnifiedLeadList
|
||||
leads={[]}
|
||||
isLoading={false}
|
||||
selectedId={null}
|
||||
onSelect={() => {}}
|
||||
onResetFilter={onResetFilter}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText(/keine Leads vor/i)).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Alle Kanäle anzeigen' }))
|
||||
expect(onResetFilter).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('bietet ohne aktiven Filter keine Rücksetzung an — es gäbe nichts zurückzusetzen', () => {
|
||||
renderWithProviders(
|
||||
<UnifiedLeadList leads={[]} isLoading={false} selectedId={null} onSelect={() => {}} />,
|
||||
)
|
||||
expect(screen.getByText(/keine Leads vor/i)).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'Alle Kanäle anzeigen' })).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,11 @@
|
||||
// Nora — KI-Signale und Leads. Barrel-Export (CLAUDE.md §13).
|
||||
// Nora — Leads aller Kanäle. Barrel-Export (CLAUDE.md §13).
|
||||
|
||||
|
||||
export { LeadListItem } from './MarketLeadPanels'
|
||||
export { LeadDetail } from './LeadDetail'
|
||||
export { CONTACT_ICONS, MIN_MATCH_PCT, areaFitPct, confirmableContacts } from './marketLeadHelpers'
|
||||
|
||||
// Runde 5, §12/§13: gemeinsame Liste über KI-Signale, Netzwerk und CRM.
|
||||
export { LeadChannelBadge } from './LeadChannelBadge'
|
||||
export { LeadChannelFilterBar } from './LeadChannelFilterBar'
|
||||
export { UnifiedLeadList } from './UnifiedLeadList'
|
||||
export { CrmLeadDetail } from './CrmLeadDetail'
|
||||
@@ -1,6 +1,6 @@
|
||||
import { memo, useCallback, useMemo, useState } from 'react'
|
||||
import { Alert, Box, Button, Chip, Divider, TextField, Typography } from '@mui/material'
|
||||
import { Building2, CheckCircle2, Copy, MapPin, Ruler, Search } from 'lucide-react'
|
||||
import { Building2, CheckCircle2, Copy, MapPin, Ruler } from 'lucide-react'
|
||||
import type { MarktHinweis } from '../../domain/marktHinweis'
|
||||
import type { Property } from '../../domain/property'
|
||||
import { useProperties } from '../../hooks/useProperties'
|
||||
@@ -127,76 +127,6 @@ const HinweisPropertyCard = memo(function HinweisPropertyCard({
|
||||
)
|
||||
})
|
||||
|
||||
// ── Netzwerk: HinweisListItem ────────────────────────────────────────────────
|
||||
|
||||
const HinweisListItem = memo(function HinweisListItem({
|
||||
hinweis, selected, onClick,
|
||||
}: {
|
||||
hinweis: MarktHinweis
|
||||
selected: boolean
|
||||
onClick: () => void
|
||||
}) {
|
||||
const displayName = !hinweis.isAnonymized && hinweis.companyName
|
||||
? hinweis.companyName
|
||||
: `Anonym · ${ASSET_TYPE_LABELS[hinweis.assetType] ?? hinweis.assetType}`
|
||||
|
||||
const areaStr = useMemo(() => {
|
||||
if (hinweis.areaSqmMin != null && hinweis.areaSqmMax != null && hinweis.areaSqmMin !== hinweis.areaSqmMax) {
|
||||
return ` · ${hinweis.areaSqmMin}–${hinweis.areaSqmMax} m²`
|
||||
}
|
||||
if (hinweis.areaSqmMax != null) return ` · ${hinweis.areaSqmMax} m²`
|
||||
if (hinweis.areaSqmMin != null) return ` · ${hinweis.areaSqmMin} m²`
|
||||
return ''
|
||||
}, [hinweis.areaSqmMin, hinweis.areaSqmMax])
|
||||
|
||||
const visibilityBadge = useMemo(() => {
|
||||
if (hinweis.visibility === 'INTERN') {
|
||||
return <Chip label="Intern" size="small" sx={{ bgcolor: DS_ACCENT.indigo.bg, color: DS_ACCENT.indigo.strong, height: 18, fontSize: '0.6rem' }} />
|
||||
}
|
||||
if (hinweis.verwaltungId === OWN_VERWALTUNG_ID) {
|
||||
return <Chip label="Geteilt" size="small" sx={{ bgcolor: DS_ACCENT.violet.bg, color: DS_ACCENT.violet.main, height: 18, fontSize: '0.6rem' }} />
|
||||
}
|
||||
return <Chip label={hinweis.verwaltungName} size="small" sx={{ bgcolor: DS_SLATE[100], color: DS_SLATE[600], height: 18, fontSize: '0.6rem' }} />
|
||||
}, [hinweis.visibility, hinweis.verwaltungId, hinweis.verwaltungName])
|
||||
|
||||
return (
|
||||
<Box
|
||||
onClick={onClick}
|
||||
sx={{
|
||||
px: 2, py: 1.5,
|
||||
borderBottom: '1px solid #f1f5f9',
|
||||
borderLeft: `3px solid ${selected ? '#152642' : 'transparent'}`,
|
||||
bgcolor: selected ? '#f0f9ff' : 'white',
|
||||
cursor: 'pointer',
|
||||
'&:hover': { bgcolor: selected ? '#f0f9ff' : '#f8fafc' },
|
||||
transition: 'background 0.1s',
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 0.75, mb: 0.25 }}>
|
||||
<Box sx={{ mt: 0.25, flexShrink: 0 }}>
|
||||
{hinweis.direction === 'SUCHE'
|
||||
? <Search size={14} color="#152642" />
|
||||
: <Building2 size={14} color="#7c3aed" />
|
||||
}
|
||||
</Box>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, color: DS_SLATE[900], lineHeight: 1.3, flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{displayName}
|
||||
</Typography>
|
||||
<Box sx={{ flexShrink: 0 }}>{visibilityBadge}</Box>
|
||||
</Box>
|
||||
<Typography variant="caption" sx={{ color: DS_SLATE[400], fontSize: '0.65rem', display: 'block', mb: 0.5, pl: 2.75 }}>
|
||||
{hinweis.locationHint}{areaStr}
|
||||
</Typography>
|
||||
<Box sx={{ pl: 2.75 }}>
|
||||
{hinweis.direction === 'SUCHE'
|
||||
? <Chip label="Suche" size="small" sx={{ bgcolor: DS_ACCENT.blue.borderSoft, color: DS_ACCENT.blue.main, height: 16, fontSize: '0.6rem' }} />
|
||||
: <Chip label="Wird frei" size="small" sx={{ bgcolor: DS_ACCENT.success.bgAlt, color: DS_ACCENT.success.bright, height: 16, fontSize: '0.6rem' }} />
|
||||
}
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
})
|
||||
|
||||
// ── Netzwerk: HinweisDetail ──────────────────────────────────────────────────
|
||||
|
||||
function HinweisDetail({ hinweis }: { hinweis: MarktHinweis }) {
|
||||
@@ -298,4 +228,4 @@ function HinweisDetail({ hinweis }: { hinweis: MarktHinweis }) {
|
||||
}
|
||||
|
||||
|
||||
export { HinweisPropertyCard, HinweisListItem, HinweisDetail }
|
||||
export { HinweisPropertyCard, HinweisDetail }
|
||||
@@ -1,5 +1,5 @@
|
||||
// Netzwerk-Hinweise auf der Nora-Seite. Barrel-Export (CLAUDE.md §13).
|
||||
|
||||
export { HinweisListItem, HinweisDetail } from './MarktHinweisPanels'
|
||||
export { HinweisDetail } from './MarktHinweisPanels'
|
||||
export { HinweisErfassenDialog } from './HinweisErfassenDialog'
|
||||
export { OWN_VERWALTUNG_ID, QUELLE_LABELS, buildAnschreiben, formatDate } from './hinweisHelpers'
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -8,133 +9,180 @@ import {
|
||||
Button,
|
||||
} from '@mui/material'
|
||||
import {
|
||||
Building2,
|
||||
CheckCircle2,
|
||||
Zap,
|
||||
Search,
|
||||
Mic,
|
||||
Sparkles,
|
||||
Newspaper,
|
||||
FileCheck,
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
Building2,
|
||||
ClipboardList,
|
||||
Filter,
|
||||
Inbox,
|
||||
MessageSquare,
|
||||
MousePointerClick,
|
||||
Radar,
|
||||
Users,
|
||||
} from 'lucide-react'
|
||||
import { AgentAvatar } from '../team'
|
||||
import { AGENT_WORKSPACES } from '../../lib/agentWorkspaces'
|
||||
import { HOME_NAV_LABEL, MY_AGENTS_LABEL, MY_PROPERTIES_LABEL } from '../../lib/constants'
|
||||
import { DS_ACCENT, DS_BRAND, DS_TEXT, LEAD_CHANNEL_META } from '../../lib/ds'
|
||||
|
||||
function FeatureRow({
|
||||
icon,
|
||||
color,
|
||||
text,
|
||||
}: {
|
||||
icon: React.ReactNode
|
||||
color: string
|
||||
text: string
|
||||
/**
|
||||
* Produkttour (Runde 5, §8).
|
||||
*
|
||||
* Sie erklärt die Anwendung, wie sie seit dieser Runde aufgebaut ist: der
|
||||
* entfernte Bereich «Übersicht» kommt nicht mehr vor, der Menüeintrag heisst
|
||||
* «Startseite», und «Meine Objekte» bezeichnet ausschliesslich den
|
||||
* Objektbereich unterhalb der Belegschaft.
|
||||
*
|
||||
* Die Tour zeigt Bilder und Text in einem Dialog und markiert keine Elemente
|
||||
* auf der Seite. Das ist Absicht: eine Tour, die auf DOM-Knoten zeigt, läuft
|
||||
* bei jeder Umgestaltung ins Leere — genau der Fall, den §8 ausschliessen will.
|
||||
* Was hier steht, kann deshalb nicht auf ein fehlendes Element zeigen.
|
||||
*/
|
||||
|
||||
function SlideFrame({ icon, title, lead, children }: {
|
||||
icon: ReactNode
|
||||
title: string
|
||||
lead: string
|
||||
children?: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<Box className="flex items-center gap-2 mt-2">
|
||||
<Box sx={{ color, flexShrink: 0 }}>{icon}</Box>
|
||||
<Box className="flex flex-col items-center text-center px-2">
|
||||
<Box className="mb-4">{icon}</Box>
|
||||
<Typography variant="h5" sx={{ fontWeight: 700, mb: 1 }}>
|
||||
{title}
|
||||
</Typography>
|
||||
<Typography variant="body1" color="text.secondary" sx={{ mb: children ? 3 : 0 }}>
|
||||
{lead}
|
||||
</Typography>
|
||||
{children && <Box className="w-full text-left">{children}</Box>}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
function FeatureRow({ icon, color, text }: { icon: ReactNode; color: string; text: string }) {
|
||||
return (
|
||||
<Box className="flex items-start gap-2 mt-2">
|
||||
<Box sx={{ color, flexShrink: 0, mt: '2px' }}>{icon}</Box>
|
||||
<Typography variant="body2">{text}</Typography>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
function Slide0() {
|
||||
function SlideStartseite() {
|
||||
return (
|
||||
<Box className="flex flex-col items-center text-center px-2">
|
||||
<Box className="mb-4">
|
||||
<Building2 size={48} color="#152642" />
|
||||
</Box>
|
||||
<Typography variant="h5" sx={{ fontWeight: 700, mb: 1 }}>
|
||||
Willkommen bei Property Match
|
||||
</Typography>
|
||||
<Typography variant="body1" color="text.secondary" sx={{ mb: 3 }}>
|
||||
Die KI-gestützte Plattform für gewerbliche Immobilien
|
||||
</Typography>
|
||||
<Box className="w-full text-left">
|
||||
<FeatureRow
|
||||
icon={<CheckCircle2 size={18} />}
|
||||
color="#1a7a4a"
|
||||
text="Verifizierte Portfolio-Objekte"
|
||||
/>
|
||||
<FeatureRow
|
||||
icon={<CheckCircle2 size={18} />}
|
||||
color="#1a7a4a"
|
||||
text="Externer Markt & Inserate"
|
||||
/>
|
||||
<FeatureRow
|
||||
icon={<Zap size={18} />}
|
||||
color="#7c3aed"
|
||||
text="Future Availability Intelligence"
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
<SlideFrame
|
||||
icon={<Building2 size={48} color={DS_BRAND.main} />}
|
||||
title={`Ihre ${HOME_NAV_LABEL}`}
|
||||
lead="Der Einstieg zeigt Ihre digitalen Mitarbeitenden im Dienst — darunter folgt Ihr Objektbestand."
|
||||
>
|
||||
<FeatureRow
|
||||
icon={<MousePointerClick size={18} />}
|
||||
color={DS_BRAND.main}
|
||||
text="Jede Karte zeigt, woran der Mitarbeitende gerade sitzt. Ein Klick öffnet seinen Arbeitsbereich."
|
||||
/>
|
||||
<FeatureRow
|
||||
icon={<Building2 size={18} />}
|
||||
color={DS_ACCENT.success.main}
|
||||
text={`Scrollen Sie nach unten zu «${MY_PROPERTIES_LABEL}» — Ihrem vollständigen Objektbestand.`}
|
||||
/>
|
||||
<FeatureRow
|
||||
icon={<Filter size={18} />}
|
||||
color={DS_ACCENT.warning.main}
|
||||
text="Über «Filter & Übersicht» schränken Sie den Bestand nach Nutzungsart, Status und Verfügbarkeit ein."
|
||||
/>
|
||||
</SlideFrame>
|
||||
)
|
||||
}
|
||||
|
||||
function Slide1() {
|
||||
function SlideAgenten() {
|
||||
return (
|
||||
<Box className="flex flex-col items-center text-center px-2">
|
||||
<Box className="mb-4">
|
||||
<Search size={48} color="#152642" />
|
||||
<SlideFrame
|
||||
icon={<Users size={48} color={DS_BRAND.main} />}
|
||||
title={MY_AGENTS_LABEL}
|
||||
lead="Fünf digitale Mitarbeitende mit festem Aufgabenprofil — jeder mit eigenem Arbeitsbereich."
|
||||
>
|
||||
<Box className="flex flex-wrap gap-2 mt-2">
|
||||
{AGENT_WORKSPACES.map(agent => (
|
||||
<Box key={agent.id} className="flex items-center gap-1.5">
|
||||
<AgentAvatar agent={agent} size={30} />
|
||||
<Box>
|
||||
<Typography sx={{ fontWeight: 600, fontSize: '0.8125rem', lineHeight: 1.2, color: DS_TEXT.primary }}>
|
||||
{agent.name}
|
||||
</Typography>
|
||||
<Typography sx={{ fontSize: '0.6875rem', lineHeight: 1.2, color: DS_TEXT.secondary }}>
|
||||
{agent.role}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
<Typography variant="h5" sx={{ fontWeight: 700, mb: 1 }}>
|
||||
Bedarf beschreiben — KI findet Matches
|
||||
</Typography>
|
||||
<Typography variant="body1" color="text.secondary" sx={{ mb: 3 }}>
|
||||
Schreiben Sie Ihren Flächenbedarf in natürlicher Sprache. Die KI extrahiert alle Kriterien
|
||||
und findet die besten Matches aus Portfolio, Markt und Future Availability.
|
||||
</Typography>
|
||||
<Box className="w-full text-left">
|
||||
<FeatureRow
|
||||
icon={<Mic size={18} />}
|
||||
color="#d97706"
|
||||
text="Spracheingabe auf Deutsch"
|
||||
/>
|
||||
<FeatureRow
|
||||
icon={<Sparkles size={18} />}
|
||||
color="#7c3aed"
|
||||
text="KI analysiert und gewichtet automatisch"
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
<FeatureRow
|
||||
icon={<ClipboardList size={18} />}
|
||||
color={DS_ACCENT.violet.main}
|
||||
text="Über «Meine Agenten» verwalten Sie Ihr Personal: Aufgaben, Kanäle & Systeme und den Bearbeitungsverlauf."
|
||||
/>
|
||||
</SlideFrame>
|
||||
)
|
||||
}
|
||||
|
||||
function Slide2() {
|
||||
function SlideChat() {
|
||||
return (
|
||||
<Box className="flex flex-col items-center text-center px-2">
|
||||
<Box className="mb-4">
|
||||
<Zap size={48} color="#7c3aed" />
|
||||
</Box>
|
||||
<Typography variant="h5" sx={{ fontWeight: 700, mb: 1 }}>
|
||||
Future Availability Intelligence
|
||||
</Typography>
|
||||
<Typography variant="body1" color="text.secondary" sx={{ mb: 3 }}>
|
||||
Frühzeitige Marktintelligenz zu potenziell verfügbaren Flächen — verifizierte
|
||||
Vertragsenden und erkannte Marktsignale, bevor sie öffentlich werden.
|
||||
</Typography>
|
||||
<Box className="w-full text-left">
|
||||
<FeatureRow
|
||||
icon={<Newspaper size={18} />}
|
||||
color="#d97706"
|
||||
text="Presse & Stelleninserate"
|
||||
/>
|
||||
<FeatureRow
|
||||
icon={<FileCheck size={18} />}
|
||||
color="#1a7a4a"
|
||||
text="Baubewilligungen & Berichte"
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
<SlideFrame
|
||||
icon={<MessageSquare size={48} color={DS_BRAND.main} />}
|
||||
title="Jeder Agent ist ansprechbar"
|
||||
lead="Auf jeder Agentenseite steht oben ein Eingabefeld — Sie schreiben dem Agenten wie einem Menschen."
|
||||
>
|
||||
<FeatureRow
|
||||
icon={<ArrowRight size={18} />}
|
||||
color={DS_ACCENT.success.main}
|
||||
text="Beim Scrollen rutscht der Chat kompakt in die oberste Leiste und bleibt erreichbar."
|
||||
/>
|
||||
<FeatureRow
|
||||
icon={<MessageSquare size={18} />}
|
||||
color={DS_ACCENT.warning.main}
|
||||
text="Angefangener Text bleibt dabei erhalten — Sie schreiben einfach weiter."
|
||||
/>
|
||||
</SlideFrame>
|
||||
)
|
||||
}
|
||||
|
||||
const SLIDES = [<Slide0 />, <Slide1 />, <Slide2 />]
|
||||
function SlideLeads() {
|
||||
return (
|
||||
<SlideFrame
|
||||
icon={<Radar size={48} color={DS_BRAND.main} />}
|
||||
title="Leads aus drei Kanälen"
|
||||
lead="Nora führt Marktchancen aus allen Quellen in einer Liste zusammen — mit sichtbarer Herkunft."
|
||||
>
|
||||
<FeatureRow
|
||||
icon={<Inbox size={18} />}
|
||||
color={LEAD_CHANNEL_META.KI_SIGNAL.color}
|
||||
text="KI Signal — aus Presse, Baugesuchen und Stelleninseraten erkannt."
|
||||
/>
|
||||
<FeatureRow
|
||||
icon={<Inbox size={18} />}
|
||||
color={LEAD_CHANNEL_META.NETZWERK.color}
|
||||
text="Netzwerk — persönlich erfasste Hinweise aus Gesprächen und Anlässen."
|
||||
/>
|
||||
<FeatureRow
|
||||
icon={<Inbox size={18} />}
|
||||
color={LEAD_CHANNEL_META.CRM.color}
|
||||
text="CRM — bestehende Interessenten aus dem angebundenen Vertriebssystem."
|
||||
/>
|
||||
<FeatureRow
|
||||
icon={<Filter size={18} />}
|
||||
color={DS_TEXT.secondary}
|
||||
text="Der Filter «Kanaltyp» blendet einzelne Quellen aus, ohne die Liste zu wechseln."
|
||||
/>
|
||||
</SlideFrame>
|
||||
)
|
||||
}
|
||||
|
||||
const SLIDES = [<SlideStartseite />, <SlideAgenten />, <SlideChat />, <SlideLeads />]
|
||||
|
||||
export function WelcomeDialog() {
|
||||
const [open, setOpen] = useState(() => {
|
||||
return localStorage.getItem('property_match_welcomed') === null
|
||||
})
|
||||
const [open, setOpen] = useState(() => localStorage.getItem('property_match_welcomed') === null)
|
||||
const [activeStep, setActiveStep] = useState(0)
|
||||
const lastStep = SLIDES.length - 1
|
||||
|
||||
function dismiss() {
|
||||
localStorage.setItem('property_match_welcomed', '1')
|
||||
@@ -142,23 +190,19 @@ export function WelcomeDialog() {
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
maxWidth="sm"
|
||||
fullWidth
|
||||
>
|
||||
<Dialog open={open} maxWidth="sm" fullWidth>
|
||||
<DialogContent sx={{ pt: 4, pb: 2 }}>
|
||||
{SLIDES[activeStep]}
|
||||
<MobileStepper
|
||||
variant="dots"
|
||||
steps={3}
|
||||
steps={SLIDES.length}
|
||||
position="static"
|
||||
activeStep={activeStep}
|
||||
sx={{ mt: 3, background: 'transparent' }}
|
||||
backButton={
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => setActiveStep((s) => s - 1)}
|
||||
onClick={() => setActiveStep(s => s - 1)}
|
||||
disabled={activeStep === 0}
|
||||
startIcon={<ArrowLeft size={16} />}
|
||||
>
|
||||
@@ -166,16 +210,12 @@ export function WelcomeDialog() {
|
||||
</Button>
|
||||
}
|
||||
nextButton={
|
||||
activeStep === 2 ? (
|
||||
activeStep === lastStep ? (
|
||||
<Button size="small" variant="contained" onClick={dismiss}>
|
||||
Los geht's
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => setActiveStep((s) => s + 1)}
|
||||
endIcon={<ArrowRight size={16} />}
|
||||
>
|
||||
<Button size="small" onClick={() => setActiveStep(s => s + 1)} endIcon={<ArrowRight size={16} />}>
|
||||
Weiter
|
||||
</Button>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,332 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useNavigate } from 'react-router'
|
||||
import * as THREE from 'three'
|
||||
import { Box, Typography } from '@mui/material'
|
||||
import { createMeetingRoomScene } from './meetingRoomScene'
|
||||
import type { MeetingRoomScene } from './meetingRoomScene'
|
||||
import { MEETING_SEATS } from './meetingRoomSeats'
|
||||
import { AGENT_PHOTOS } from '../team/agentPhotos'
|
||||
import { useAgentWorkload } from '../../hooks/useAgentWorkload'
|
||||
import { WorkloadTone } from '../../domain/agentWorkload'
|
||||
import { DS_ACCENT, DS_NEUTRAL } from '../../lib/ds'
|
||||
|
||||
/**
|
||||
* Farben der Auslastung auf dunklem Grund. Hellere Stufen als die
|
||||
* Standardakzente — die dunkleren verschwinden vor der Szene.
|
||||
*/
|
||||
const TONE_COLOR: Record<WorkloadTone, string> = {
|
||||
[WorkloadTone.CALM]: DS_ACCENT.success.emerald,
|
||||
[WorkloadTone.ATTENTION]: DS_ACCENT.warning.amber,
|
||||
[WorkloadTone.URGENT]: DS_ACCENT.danger.bright,
|
||||
}
|
||||
|
||||
/**
|
||||
* Höhe der Kopfzeile plus des Streifens, der unter dem Raum sichtbar bleiben
|
||||
* muss: Titel «Meine Objekte» und der Beginn von «Filter & Übersicht»
|
||||
* (Runde 6, §1.1).
|
||||
*/
|
||||
const TOP_BAR_PX = 64
|
||||
const KEEP_VISIBLE_BELOW_PX = 124
|
||||
|
||||
/** Unter dieser Höhe verliert die Perspektive ihre Wirkung. */
|
||||
const MIN_HEIGHT_PX = 380
|
||||
/** Darüber wird der Raum zur Landschaft und die Objektliste rutscht zu weit weg. */
|
||||
const MAX_HEIGHT_PX = 760
|
||||
|
||||
/**
|
||||
* Virtueller 3D-Meetingraum auf der Startseite (Runde 6, §1–§9).
|
||||
*
|
||||
* Der Betrachter sitzt am Kopfende des Tisches und sieht seine Agenten vor sich.
|
||||
* Die Kamera ist fest; als einzige Bewegung folgt sie der Maus um wenige
|
||||
* Zentimeter, damit die Szene lebt, ohne die verbindliche Perspektive zu
|
||||
* verlassen (§20).
|
||||
*
|
||||
* Die Beschriftungen sind HTML und nicht Teil der 3D-Szene. Als Textur im Raum
|
||||
* wären sie bei Tiefe unlesbar; als DOM-Element bleiben sie scharf, lassen sich
|
||||
* mit den Mitteln des Designsystems gestalten und sind für Screenreader
|
||||
* vorhanden. Ihre Position kommt aus der Szene: jede Bildwiederholung projiziert
|
||||
* den Ankerpunkt über dem Kopf auf den Bildschirm.
|
||||
*
|
||||
* Gerendert wird nur bei Anlass — Grösse, Maus, Hover. Ein Dauerloop wäre für
|
||||
* eine statische Szene verschwendete Rechenzeit (§21).
|
||||
*/
|
||||
export function MeetingRoom3D() {
|
||||
const navigate = useNavigate()
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
const wrapRef = useRef<HTMLDivElement>(null)
|
||||
const sceneRef = useRef<MeetingRoomScene | null>(null)
|
||||
const labelRefs = useRef(new Map<string, HTMLDivElement | null>())
|
||||
const hoveredRef = useRef<string | null>(null)
|
||||
const [hovered, setHovered] = useState<string | null>(null)
|
||||
// Beim Hover zeigt die Plakette zusätzlich, woran der Agent gerade sitzt.
|
||||
// Ruhend bleibt sie bei Name und Funktion (§8).
|
||||
const { data: workload } = useAgentWorkload()
|
||||
|
||||
/**
|
||||
* Beschriftungen an die projizierten Kopfpositionen setzen.
|
||||
*
|
||||
* Danach folgt ein Entzerrungsdurchgang: Plätze weiter hinten rücken in der
|
||||
* Perspektive auf wenige Bildpunkte zusammen, ihre Plaketten lägen dort
|
||||
* übereinander. Wer mit einem bereits gesetzten Nachbarn kollidiert, wandert
|
||||
* so weit nach oben, bis er frei steht.
|
||||
*
|
||||
* Bewusst nach der Projektion und nicht über handverlesene Höhen in der
|
||||
* Szene: gerechnete Abstände halten auf jeder Fenstergrösse, fest gesetzte
|
||||
* Werte gelten nur für die Breite, an der sie eingestellt wurden.
|
||||
*/
|
||||
const positionLabels = useCallback(() => {
|
||||
const s = sceneRef.current
|
||||
const wrap = wrapRef.current
|
||||
if (!s || !wrap) return
|
||||
const w = wrap.clientWidth
|
||||
const h = wrap.clientHeight
|
||||
const v = new THREE.Vector3()
|
||||
|
||||
type Placed = { el: HTMLDivElement; x: number; y: number; halfW: number; height: number }
|
||||
const placed: Placed[] = []
|
||||
|
||||
// Von vorne nach hinten: die vorderen Plaketten behalten ihren Platz, die
|
||||
// hinteren geben nach. Das entspricht der Tiefenordnung im Bild.
|
||||
const ordered = [...s.seats].sort((a, b) => b.labelAnchor.z - a.labelAnchor.z)
|
||||
|
||||
for (const handle of ordered) {
|
||||
const el = labelRefs.current.get(handle.seat.id)
|
||||
if (!el) continue
|
||||
|
||||
v.copy(handle.labelAnchor).project(s.camera)
|
||||
let y = (-v.y * 0.5 + 0.5) * h
|
||||
|
||||
// Weiter hinten sitzende Beschriftungen werden kleiner, bleiben aber über
|
||||
// einer Untergrenze lesbar (§8 «bei räumlicher Tiefe ausreichend lesbar»).
|
||||
const scale = Math.max(0.68, Math.min(1, 1 - (v.z - 0.955) * 8))
|
||||
const halfW = (el.offsetWidth * scale) / 2
|
||||
const height = el.offsetHeight * scale
|
||||
|
||||
// Innerhalb des Bildes halten: auf schmalen Fenstern liegen die äusseren
|
||||
// Plätze so weit aussen, dass ihre Beschriftung sonst abgeschnitten wird.
|
||||
const x = Math.min(Math.max((v.x * 0.5 + 0.5) * w, halfW + 6), w - halfW - 6)
|
||||
|
||||
for (const other of placed) {
|
||||
const overlapsX = Math.abs(x - other.x) < halfW + other.halfW + 6
|
||||
const overlapsY = Math.abs(y - other.y) < Math.max(height, other.height) + 4
|
||||
if (overlapsX && overlapsY) {
|
||||
y = other.y - Math.max(height, other.height) - 6
|
||||
}
|
||||
}
|
||||
|
||||
placed.push({ el, x, y, halfW, height })
|
||||
el.style.transform =
|
||||
`translate(-50%, -100%) translate(${x.toFixed(1)}px, ${y.toFixed(1)}px) scale(${scale.toFixed(3)})`
|
||||
}
|
||||
}, [])
|
||||
|
||||
const draw = useCallback(() => {
|
||||
sceneRef.current?.render()
|
||||
positionLabels()
|
||||
}, [positionLabels])
|
||||
|
||||
// ── Szene aufbauen ──
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current
|
||||
const wrap = wrapRef.current
|
||||
if (!canvas || !wrap) return
|
||||
|
||||
const s = createMeetingRoomScene(canvas, MEETING_SEATS, AGENT_PHOTOS)
|
||||
sceneRef.current = s
|
||||
s.setSize(wrap.clientWidth, wrap.clientHeight)
|
||||
s.render()
|
||||
positionLabels()
|
||||
|
||||
// Texturen kommen asynchron; ohne ein zweites Zeichnen bliebe die Szene
|
||||
// beim Stand des ersten Bildes stehen — ohne Fotos, ohne Kulisse.
|
||||
THREE.DefaultLoadingManager.onLoad = () => {
|
||||
s.render()
|
||||
positionLabels()
|
||||
}
|
||||
|
||||
const ro = new ResizeObserver(() => {
|
||||
s.setSize(wrap.clientWidth, wrap.clientHeight)
|
||||
s.render()
|
||||
positionLabels()
|
||||
})
|
||||
ro.observe(wrap)
|
||||
|
||||
return () => {
|
||||
THREE.DefaultLoadingManager.onLoad = () => {}
|
||||
ro.disconnect()
|
||||
s.dispose()
|
||||
sceneRef.current = null
|
||||
}
|
||||
}, [positionLabels])
|
||||
|
||||
// ── Maus: Parallaxe und Trefferprüfung ──
|
||||
const handlePointerMove = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
|
||||
const s = sceneRef.current
|
||||
const wrap = wrapRef.current
|
||||
if (!s || !wrap) return
|
||||
|
||||
const rect = wrap.getBoundingClientRect()
|
||||
const nx = ((e.clientX - rect.left) / rect.width) * 2 - 1
|
||||
const ny = -(((e.clientY - rect.top) / rect.height) * 2 - 1)
|
||||
|
||||
s.applyParallax(nx, ny)
|
||||
|
||||
const ray = new THREE.Raycaster()
|
||||
ray.setFromCamera(new THREE.Vector2(nx, ny), s.camera)
|
||||
const hit = ray.intersectObjects(s.seats.map(h => h.hitBox), false)[0]
|
||||
const id = hit ? (s.seats.find(h => h.hitBox === hit.object)?.seat.id ?? null) : null
|
||||
|
||||
if (id !== hoveredRef.current) {
|
||||
hoveredRef.current = id
|
||||
s.setHovered(id)
|
||||
setHovered(id)
|
||||
}
|
||||
draw()
|
||||
}, [draw])
|
||||
|
||||
const handlePointerLeave = useCallback(() => {
|
||||
const s = sceneRef.current
|
||||
if (!s) return
|
||||
s.applyParallax(0, 0)
|
||||
hoveredRef.current = null
|
||||
s.setHovered(null)
|
||||
setHovered(null)
|
||||
draw()
|
||||
}, [draw])
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
const id = hoveredRef.current
|
||||
if (!id) return
|
||||
const seat = MEETING_SEATS.find(x => x.id === id)
|
||||
if (seat) navigate(seat.path)
|
||||
}, [navigate])
|
||||
|
||||
return (
|
||||
<Box
|
||||
component="section"
|
||||
aria-label="Virtueller Meetingraum mit den digitalen Mitarbeitenden"
|
||||
ref={wrapRef}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerLeave={handlePointerLeave}
|
||||
onClick={handleClick}
|
||||
sx={{
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
height: `clamp(${MIN_HEIGHT_PX}px, calc(100vh - ${TOP_BAR_PX + KEEP_VISIBLE_BELOW_PX}px), ${MAX_HEIGHT_PX}px)`,
|
||||
'@supports (height: 100dvh)': {
|
||||
height: `clamp(${MIN_HEIGHT_PX}px, calc(100dvh - ${TOP_BAR_PX + KEEP_VISIBLE_BELOW_PX}px), ${MAX_HEIGHT_PX}px)`,
|
||||
},
|
||||
overflow: 'hidden',
|
||||
cursor: hovered ? 'pointer' : 'default',
|
||||
bgcolor: DS_NEUTRAL.sidebar,
|
||||
}}
|
||||
>
|
||||
<Box component="canvas" ref={canvasRef} sx={{ display: 'block', width: '100%', height: '100%' }} />
|
||||
|
||||
{/*
|
||||
Beschriftungen über den Köpfen (§8). Sie liegen über dem Bild, nehmen
|
||||
aber keine Klicks an — der Klick gehört der Szene darunter, damit der
|
||||
Trefferkörper des Agenten entscheidet und nicht die Textbreite.
|
||||
*/}
|
||||
{MEETING_SEATS.map(seat => {
|
||||
const on = hovered === seat.id
|
||||
return (
|
||||
<Box
|
||||
key={seat.id}
|
||||
ref={(el: HTMLDivElement | null) => { labelRefs.current.set(seat.id, el) }}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
pointerEvents: 'none',
|
||||
textAlign: 'center',
|
||||
whiteSpace: 'nowrap',
|
||||
px: 1,
|
||||
py: 0.375,
|
||||
borderRadius: 1.5,
|
||||
transformOrigin: 'bottom center',
|
||||
transition: 'background-color 0.15s ease, box-shadow 0.15s ease',
|
||||
// Kein starker Kasten, der den Raum verdeckt (§8) — aber ohne jede
|
||||
// Unterlage verschwindet weisse Schrift vor der hellen Glasfront.
|
||||
// Deshalb ein leichter Schleier mit Weichzeichnung, der bei Hover
|
||||
// etwas dichter wird.
|
||||
bgcolor: on ? 'rgba(11,18,29,0.74)' : 'rgba(11,18,29,0.34)',
|
||||
backdropFilter: 'blur(3px)',
|
||||
boxShadow: on ? '0 6px 20px rgba(0,0,0,0.35)' : 'none',
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
sx={{
|
||||
fontWeight: 700,
|
||||
fontSize: '0.875rem',
|
||||
lineHeight: 1.2,
|
||||
color: DS_NEUTRAL.white,
|
||||
textShadow: '0 1px 6px rgba(0,0,0,0.75)',
|
||||
}}
|
||||
>
|
||||
{seat.name}
|
||||
</Typography>
|
||||
<Typography
|
||||
sx={{
|
||||
fontSize: '0.6875rem',
|
||||
lineHeight: 1.25,
|
||||
color: on ? DS_NEUTRAL.gold : 'rgba(255,255,255,0.82)',
|
||||
textShadow: '0 1px 6px rgba(0,0,0,0.75)',
|
||||
}}
|
||||
>
|
||||
{seat.role}
|
||||
</Typography>
|
||||
|
||||
{/*
|
||||
Nur bei Hover: die aktuelle Auslastung. Ruhend bliebe die Plakette
|
||||
sonst überdimensioniert (§8), im Hover ist die Hervorhebung
|
||||
ausdrücklich erlaubt — und sie sagt vor dem Klick, was drüben liegt.
|
||||
*/}
|
||||
{on && workload[seat.id] && (
|
||||
<Typography
|
||||
sx={{
|
||||
mt: 0.25,
|
||||
fontSize: '0.6875rem',
|
||||
fontWeight: 600,
|
||||
lineHeight: 1.25,
|
||||
color: TONE_COLOR[workload[seat.id].tone],
|
||||
}}
|
||||
>
|
||||
{workload[seat.id].label}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
})}
|
||||
|
||||
{/*
|
||||
Tastaturweg zu denselben Zielen. Die Szene selbst ist ein Canvas und
|
||||
damit für die Tastatur unerreichbar; ohne diese Schalter wären die
|
||||
Agenten nur mit der Maus erreichbar.
|
||||
*/}
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute', left: 0, top: 0, width: 1, height: 1,
|
||||
overflow: 'hidden', clip: 'rect(0 0 0 0)',
|
||||
}}
|
||||
>
|
||||
{MEETING_SEATS.map(seat => (
|
||||
<button
|
||||
key={seat.id}
|
||||
type="button"
|
||||
onClick={e => { e.stopPropagation(); navigate(seat.path) }}
|
||||
onFocus={() => {
|
||||
hoveredRef.current = seat.id
|
||||
sceneRef.current?.setHovered(seat.id)
|
||||
setHovered(seat.id)
|
||||
draw()
|
||||
}}
|
||||
>
|
||||
{`${seat.name}, ${seat.role} — Arbeitsbereich öffnen`}
|
||||
</button>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
// Virtueller 3D-Meetingraum der Startseite (Runde 6). Barrel-Export (CLAUDE.md §13).
|
||||
|
||||
export { MeetingRoom3D } from './MeetingRoom3D'
|
||||
export { MEETING_SEATS, SeatSide } from './meetingRoomSeats'
|
||||
export type { MeetingSeat } from './meetingRoomSeats'
|
||||
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* Farben und Maße des 3D-Meetingraums (Runde 6, §3/§5).
|
||||
*
|
||||
* Die Farben stehen als Zahlen und nicht als Hexzeichenketten, weil three.js
|
||||
* Zahlen erwartet — `new Color(0x…)` ist der direkte Weg, jede Umwandlung
|
||||
* dazwischen wäre nur Umweg. Sie liegen hier statt in der Szene, damit sich die
|
||||
* Materialstimmung an einer Stelle ändern lässt (CLAUDE.md §8).
|
||||
*
|
||||
* Der Raum ist «modern, hochwertig, zurückhaltend, nicht futuristisch»: warmes
|
||||
* Eichenholz, heller Putz, Anthrazitteppich, mattgraue Fensterprofile. Als
|
||||
* einziger Markenbezug trägt die geschlossene Wand einen schmalen Streifen im
|
||||
* Property-Match-Blau (§3 «darf subtil aufgenommen werden»).
|
||||
*/
|
||||
|
||||
export const ROOM_COLORS = {
|
||||
floor: 0x4b4741,
|
||||
floorSheen: 0x5d5852,
|
||||
ceiling: 0xf6f5f2,
|
||||
ceilingRail: 0xeeebe5,
|
||||
/** Geschlossene Wand rechts: heller Putz mit Eichenpaneel im unteren Drittel. */
|
||||
wall: 0xe8e4dc,
|
||||
wallPanel: 0x9c7b52,
|
||||
wallSkirting: 0xd6d1c7,
|
||||
/** Schmaler Markenstreifen auf der geschlossenen Wand. */
|
||||
brandStripe: 0x152642,
|
||||
/** Fensterprofile und Pfosten. */
|
||||
mullion: 0x6b7079,
|
||||
frame: 0x585d65,
|
||||
/** Glasscheibe: sehr schwacher Blauschleier, sonst wäre sie unsichtbar. */
|
||||
glass: 0xd8e8f0,
|
||||
/** Tischplatte in Eiche, Kante etwas dunkler. */
|
||||
tableTop: 0x8f7350,
|
||||
tableEdge: 0x6f5637,
|
||||
tableBase: 0x35322f,
|
||||
/** Stuhlschalen. */
|
||||
chair: 0x2f343c,
|
||||
/** Anzüge der Agenten, leicht variiert, damit die Reihe nicht uniform wirkt. */
|
||||
suits: [0x2c3444, 0x36302c, 0x2a3a3c, 0x3a2f36, 0x24303a, 0x1f2836],
|
||||
/** Ring um das Porträt — hebt den Kopf vom Hintergrund ab. */
|
||||
headRing: 0xffffff,
|
||||
/** Ring bei Hover: Gold aus dem Designsystem. */
|
||||
headRingHover: 0xb8975a,
|
||||
/** Himmel hinter der Kulisse, falls doch ein Rand sichtbar wird. */
|
||||
sky: 0xbcd7e6,
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Raumgeometrie in Metern. Der Betrachter sitzt am Kopfende bei z ≈ 0 und
|
||||
* schaut in Richtung −z; die Fensterfront gegenüber liegt bei z = −ROOM.depth.
|
||||
*/
|
||||
export const ROOM = {
|
||||
width: 7.4,
|
||||
height: 3.05,
|
||||
depth: 9.2,
|
||||
} as const
|
||||
|
||||
/** Kamera: Augenhöhe einer sitzenden Person am Kopfende (§2). */
|
||||
export const CAMERA = {
|
||||
/** Sitzende Augenhöhe über dem Boden. */
|
||||
eyeHeight: 1.19,
|
||||
/** Knapp hinter der vorderen Tischkante. */
|
||||
z: 1.9,
|
||||
fov: 48,
|
||||
/** Blickziel: leicht unter Augenhöhe, damit die Tischplatte Tiefe zeigt. */
|
||||
lookAt: [0, 1.24, -5.2] as const,
|
||||
/**
|
||||
* Maximaler Kameraversatz der Parallaxe in Metern. Bewusst winzig: die
|
||||
* Perspektive ist verbindlich, die Bewegung darf sie nur beleben (§20).
|
||||
*/
|
||||
parallax: 0.075,
|
||||
} as const
|
||||
|
||||
/** Meetingtisch (§5): längliche Form, führt vom Betrachter weg. */
|
||||
export const TABLE = {
|
||||
width: 1.70,
|
||||
/** Höhe der Plattenoberseite über dem Boden. */
|
||||
top: 0.745,
|
||||
thickness: 0.06,
|
||||
/** Vordere Kante, knapp vor der Kamera. */
|
||||
zNear: 0.15,
|
||||
/** Hintere Kante, vor dem Platz von Thomas. */
|
||||
zFar: -5.75,
|
||||
} as const
|
||||
|
||||
/** Sitzende Personen: Kopfhöhe und Grössen. */
|
||||
export const SEATED = {
|
||||
/**
|
||||
* Höhe der Porträtkreise über dem Boden.
|
||||
*
|
||||
* Bewusst deutlich über der Blickachse der Kamera (1.19 m). Lagen die Kreise
|
||||
* auf Augenhöhe, projizierten alle fünf auf fast dieselbe Bildhöhe und die
|
||||
* hinteren verschwanden hinter den vorderen — unabhängig davon, wie weit man
|
||||
* sie seitlich auseinanderzog. Erst der Höhenunterschied zur Blickachse
|
||||
* trennt sie in der Tiefe (§3.2).
|
||||
*/
|
||||
headY: 1.52,
|
||||
headRadius: 0.142,
|
||||
/** Torso vom Stuhl bis zum Hals. */
|
||||
torsoTop: 1.12,
|
||||
torsoBottom: 0.42,
|
||||
torsoRadiusTop: 0.19,
|
||||
torsoRadiusBottom: 0.25,
|
||||
shoulderWidth: 0.46,
|
||||
} as const
|
||||
@@ -0,0 +1,496 @@
|
||||
import * as THREE from 'three'
|
||||
import { ROOM, ROOM_COLORS, CAMERA, TABLE, SEATED } from './meetingRoomPalette'
|
||||
import { SeatSide } from './meetingRoomSeats'
|
||||
import type { MeetingSeat } from './meetingRoomSeats'
|
||||
import baselPanorama from '../../assets/staff/basel-panorama.jpg'
|
||||
|
||||
/**
|
||||
* Aufbau des virtuellen Meetingraums (Runde 6, §2–§6).
|
||||
*
|
||||
* Bewusst ohne React: hier entsteht nur der three.js-Graph. Die Komponente
|
||||
* daneben kümmert sich um Einbettung, Grösse, Maus und Beschriftungen. Diese
|
||||
* Trennung hält beide Teile lesbar — eine Datei mit Szene *und* Reactzustand
|
||||
* wäre unübersichtlich und schwer zu verändern.
|
||||
*
|
||||
* Die Kamera sitzt auf Augenhöhe einer sitzenden Person am Kopfende des
|
||||
* Tisches und ist fest: keine Orbit-Steuerung, keine freie Navigation (§20).
|
||||
* Der Tisch läuft von ihr weg in den Raum, links und rechts sitzen die
|
||||
* Agenten, am gegenüberliegenden Kopfende Thomas.
|
||||
*
|
||||
* Es wird **nicht** dauernd gerendert. Die Komponente ruft `render()` nur bei
|
||||
* Grössenänderung, Mausbewegung und Zustandswechsel auf — ein Dauerloop wäre
|
||||
* für eine statische Szene verschwendete Rechenzeit (§21).
|
||||
*/
|
||||
|
||||
/** Ein anklickbarer Platz samt seiner Teile in der Szene. */
|
||||
export interface SeatHandle {
|
||||
seat: MeetingSeat
|
||||
/** Unsichtbarer Trefferkörper für den Raycaster. */
|
||||
hitBox: THREE.Mesh
|
||||
/** Ring hinter dem Porträt — wird bei Hover golden. */
|
||||
ring: THREE.Mesh
|
||||
/** Gruppe aus Kopf, Ring und Körper; wird bei Hover leicht angehoben. */
|
||||
group: THREE.Group
|
||||
/** Weltposition für die schwebende Beschriftung (über dem Kopf). */
|
||||
labelAnchor: THREE.Vector3
|
||||
}
|
||||
|
||||
export interface MeetingRoomScene {
|
||||
scene: THREE.Scene
|
||||
camera: THREE.PerspectiveCamera
|
||||
renderer: THREE.WebGLRenderer
|
||||
seats: SeatHandle[]
|
||||
/** Kamera anhand eines Zeigerversatzes (−1…1) leicht versetzen. */
|
||||
applyParallax: (dx: number, dy: number) => void
|
||||
setSize: (width: number, height: number) => void
|
||||
render: () => void
|
||||
setHovered: (id: string | null) => void
|
||||
dispose: () => void
|
||||
}
|
||||
|
||||
// ── Bausteine ────────────────────────────────────────────────────────────────
|
||||
|
||||
function addRoomShell(scene: THREE.Scene) {
|
||||
const { width, height, depth } = ROOM
|
||||
const halfW = width / 2
|
||||
|
||||
// Boden — ein leichter Verlauf über zwei Flächen wäre teuer; ein matter Ton
|
||||
// mit heller Kante zur Fensterseite genügt für die Tiefenwirkung.
|
||||
const floor = new THREE.Mesh(
|
||||
new THREE.PlaneGeometry(width, depth),
|
||||
new THREE.MeshLambertMaterial({ color: ROOM_COLORS.floor }),
|
||||
)
|
||||
floor.rotation.x = -Math.PI / 2
|
||||
floor.position.set(0, 0, -depth / 2)
|
||||
scene.add(floor)
|
||||
|
||||
// Lichtstreifen auf dem Boden entlang der Glasfront
|
||||
const sheen = new THREE.Mesh(
|
||||
new THREE.PlaneGeometry(1.6, depth),
|
||||
new THREE.MeshBasicMaterial({ color: ROOM_COLORS.floorSheen, transparent: true, opacity: 0.5 }),
|
||||
)
|
||||
sheen.rotation.x = -Math.PI / 2
|
||||
sheen.position.set(-halfW + 1.1, 0.004, -depth / 2)
|
||||
scene.add(sheen)
|
||||
|
||||
const ceiling = new THREE.Mesh(
|
||||
new THREE.PlaneGeometry(width, depth),
|
||||
// Unbeleuchtet: eine Lambertfläche, die nach unten schaut, bekommt von
|
||||
// Licht aus dem Raum nichts ab und säuft zu Grau ab.
|
||||
new THREE.MeshBasicMaterial({ color: ROOM_COLORS.ceiling }),
|
||||
)
|
||||
ceiling.rotation.x = Math.PI / 2
|
||||
ceiling.position.set(0, height, -depth / 2)
|
||||
scene.add(ceiling)
|
||||
|
||||
// Deckenschiene über dem Tisch — gibt der Decke Struktur ohne Dekoration
|
||||
const rail = new THREE.Mesh(
|
||||
new THREE.BoxGeometry(0.14, 0.035, depth * 0.66),
|
||||
new THREE.MeshLambertMaterial({ color: ROOM_COLORS.ceilingRail }),
|
||||
)
|
||||
rail.position.set(0, height - 0.05, -depth / 2)
|
||||
scene.add(rail)
|
||||
|
||||
// ── Rechte Wand: geschlossen (§3) ──
|
||||
const wall = new THREE.Mesh(
|
||||
new THREE.PlaneGeometry(depth, height),
|
||||
new THREE.MeshLambertMaterial({ color: ROOM_COLORS.wall }),
|
||||
)
|
||||
wall.rotation.y = -Math.PI / 2
|
||||
wall.position.set(halfW, height / 2, -depth / 2)
|
||||
scene.add(wall)
|
||||
|
||||
// Eichenpaneel im unteren Drittel
|
||||
const panel = new THREE.Mesh(
|
||||
new THREE.PlaneGeometry(depth, 0.95),
|
||||
new THREE.MeshLambertMaterial({ color: ROOM_COLORS.wallPanel }),
|
||||
)
|
||||
panel.rotation.y = -Math.PI / 2
|
||||
panel.position.set(halfW - 0.01, 0.475, -depth / 2)
|
||||
scene.add(panel)
|
||||
|
||||
// Schmaler Markenstreifen — der einzige Markenbezug im Raum
|
||||
const stripe = new THREE.Mesh(
|
||||
new THREE.PlaneGeometry(depth, 0.03),
|
||||
new THREE.MeshBasicMaterial({ color: ROOM_COLORS.brandStripe }),
|
||||
)
|
||||
stripe.rotation.y = -Math.PI / 2
|
||||
stripe.position.set(halfW - 0.02, 0.99, -depth / 2)
|
||||
scene.add(stripe)
|
||||
|
||||
const skirting = new THREE.Mesh(
|
||||
new THREE.PlaneGeometry(depth, 0.08),
|
||||
new THREE.MeshLambertMaterial({ color: ROOM_COLORS.wallSkirting }),
|
||||
)
|
||||
skirting.rotation.y = -Math.PI / 2
|
||||
skirting.position.set(halfW - 0.01, 0.04, -depth / 2)
|
||||
scene.add(skirting)
|
||||
}
|
||||
|
||||
/**
|
||||
* Basler Fernkulisse (Runde 6, §2).
|
||||
*
|
||||
* **Eine** gekrümmte Fläche mit **einem** Panoramabild — nicht mehr zwei
|
||||
* Flächen mit zwei Fotos. Genau deren Stossstelle war auf der linken
|
||||
* Fensterseite als harte Kante sichtbar. Auf einem durchgehenden Zylinder gibt
|
||||
* es diese Stelle nicht mehr: der Blick nach vorne und der Blick nach links
|
||||
* lesen dasselbe Bild an benachbarten Stellen, eine Naht ist geometrisch
|
||||
* unmöglich.
|
||||
*
|
||||
* Das Panorama entsteht einmalig aus den beiden Referenzfotos des Auftrags;
|
||||
* siehe `scripts/build-basel-panorama.mjs`. Das Motiv vom Tischende bleibt darin
|
||||
* unverändert und wird nach links fortgeführt.
|
||||
*
|
||||
* Die Zahlen sind so gewählt, dass Bildmasstab und Wasserlinie denen der
|
||||
* früheren Frontfläche entsprechen — das gut wirkende Motiv am Tischende
|
||||
* erscheint dadurch unverändert gross (§2.2).
|
||||
*/
|
||||
function addBaselBackdrop(scene: THREE.Scene, loader: THREE.TextureLoader, maxAnisotropy: number) {
|
||||
/** Abstand der Kulisse. Weit genug, dass die Perspektive sie als Ferne liest. */
|
||||
const radius = 62
|
||||
/**
|
||||
* Abgedeckter Bogen. Er beginnt rechts neben der Frontscheibe und läuft über
|
||||
* die Front nach links bis hinter die linke Glasfassade — mehr braucht es
|
||||
* nicht, und weniger Bogen heisst mehr Bildpunkte je Grad.
|
||||
*/
|
||||
const arc = 2.62
|
||||
/** Höhe passend zum Seitenverhältnis des Panoramas (1523 × 660). */
|
||||
const height = (radius * arc) / (1523 / 660)
|
||||
/** Wasserlinie im Panorama, als Anteil von oben. */
|
||||
const waterFraction = 400 / 660
|
||||
/** Höhe der Rheinoberfläche relativ zum Bürofussboden — wir sind im Hochhaus. */
|
||||
const waterY = -7.5
|
||||
|
||||
const tex = loader.load(baselPanorama)
|
||||
tex.colorSpace = THREE.SRGBColorSpace
|
||||
tex.anisotropy = maxAnisotropy
|
||||
// Kein Wiederholen: der Bogen deckt genau die Bildbreite ab, und eine
|
||||
// Wiederholung wäre als Doppelung sichtbar (§2.2).
|
||||
tex.wrapS = THREE.ClampToEdgeWrapping
|
||||
tex.wrapT = THREE.ClampToEdgeWrapping
|
||||
|
||||
const mesh = new THREE.Mesh(
|
||||
new THREE.CylinderGeometry(radius, radius, height, 96, 1, true, 0, arc),
|
||||
// Unbeleuchtet und von innen sichtbar: das Foto trägt seine eigene
|
||||
// Belichtung, jede Beleuchtung würde sie nur verfälschen.
|
||||
new THREE.MeshBasicMaterial({ map: tex, side: THREE.BackSide, fog: false }),
|
||||
)
|
||||
// Mittelpunkt so setzen, dass die Wasserlinie des Panoramas auf `waterY` liegt.
|
||||
mesh.position.y = waterY - height * (0.5 - waterFraction)
|
||||
/**
|
||||
* Drehung: geradeaus sichtbar ist die Bildstelle u = (π − Drehung) / Bogen.
|
||||
* Der Abzug von 0.68 setzt damit u ≈ 0.26 in die Blickachse — dort steht im
|
||||
* Panorama das Münster. Die Frontscheibe zeigt so das gute Motiv vom
|
||||
* Tischende, die linke Fassade seine Fortsetzung nach links.
|
||||
*/
|
||||
mesh.rotation.y = Math.PI - 0.68
|
||||
scene.add(mesh)
|
||||
}
|
||||
|
||||
/** Bodentiefe Glasfassade mit Pfosten — links und gegenüber (§3). */
|
||||
function addGlassFacades(scene: THREE.Scene) {
|
||||
const { width, height, depth } = ROOM
|
||||
const halfW = width / 2
|
||||
|
||||
const glassMat = new THREE.MeshBasicMaterial({
|
||||
color: ROOM_COLORS.glass,
|
||||
transparent: true,
|
||||
opacity: 0.07,
|
||||
side: THREE.DoubleSide,
|
||||
depthWrite: false,
|
||||
})
|
||||
const frameMat = new THREE.MeshLambertMaterial({ color: ROOM_COLORS.mullion })
|
||||
|
||||
// Gegenüberliegende Front
|
||||
const frontGlass = new THREE.Mesh(new THREE.PlaneGeometry(width, height), glassMat)
|
||||
frontGlass.position.set(0, height / 2, -depth)
|
||||
scene.add(frontGlass)
|
||||
|
||||
// Linke Front
|
||||
const leftGlass = new THREE.Mesh(new THREE.PlaneGeometry(depth, height), glassMat)
|
||||
leftGlass.rotation.y = Math.PI / 2
|
||||
leftGlass.position.set(-halfW, height / 2, -depth / 2)
|
||||
scene.add(leftGlass)
|
||||
|
||||
/** Senkrechter Pfosten. */
|
||||
function post(x: number, y: number, z: number, rotY = 0) {
|
||||
const m = new THREE.Mesh(new THREE.BoxGeometry(0.07, height, 0.09), frameMat)
|
||||
m.position.set(x, y, z)
|
||||
m.rotation.y = rotY
|
||||
scene.add(m)
|
||||
}
|
||||
/** Waagrechter Riegel. */
|
||||
function rail(len: number, x: number, y: number, z: number, rotY = 0) {
|
||||
const m = new THREE.Mesh(new THREE.BoxGeometry(len, 0.07, 0.09), frameMat)
|
||||
m.position.set(x, y, z)
|
||||
m.rotation.y = rotY
|
||||
scene.add(m)
|
||||
}
|
||||
|
||||
// Pfosten der Frontscheibe — Thomas sitzt zwischen zwei Feldern, damit die
|
||||
// Stadtsilhouette rund um ihn sichtbar bleibt (§3 «rund um ihn herum»).
|
||||
for (const x of [-halfW, -1.5, 1.5, halfW]) post(x, height / 2, -depth)
|
||||
rail(width, 0, height - 0.035, -depth)
|
||||
rail(width, 0, 0.035, -depth)
|
||||
rail(width, 0, 2.28, -depth)
|
||||
|
||||
// Pfosten der linken Front, alle 2 m
|
||||
for (let z = 0; z >= -depth; z -= 2) post(-halfW, height / 2, z, Math.PI / 2)
|
||||
rail(depth, -halfW, height - 0.035, -depth / 2, Math.PI / 2)
|
||||
rail(depth, -halfW, 0.035, -depth / 2, Math.PI / 2)
|
||||
rail(depth, -halfW, 2.28, -depth / 2, Math.PI / 2)
|
||||
}
|
||||
|
||||
/** Meetingtisch samt Sockel und Kontaktschatten (§5). */
|
||||
function addTable(scene: THREE.Scene) {
|
||||
const length = TABLE.zNear - TABLE.zFar
|
||||
const zMid = (TABLE.zNear + TABLE.zFar) / 2
|
||||
|
||||
const top = new THREE.Mesh(
|
||||
new THREE.BoxGeometry(TABLE.width, TABLE.thickness, length),
|
||||
new THREE.MeshLambertMaterial({ color: ROOM_COLORS.tableTop }),
|
||||
)
|
||||
top.position.set(0, TABLE.top, zMid)
|
||||
scene.add(top)
|
||||
|
||||
// Kante minimal dunkler — lässt die Platte dicker und wertiger wirken
|
||||
const edge = new THREE.Mesh(
|
||||
new THREE.BoxGeometry(TABLE.width + 0.02, 0.018, length + 0.02),
|
||||
new THREE.MeshLambertMaterial({ color: ROOM_COLORS.tableEdge }),
|
||||
)
|
||||
edge.position.set(0, TABLE.top - TABLE.thickness / 2, zMid)
|
||||
scene.add(edge)
|
||||
|
||||
// Zwei Sockelwangen statt vier Beine: verdeckt weniger und wirkt hochwertiger
|
||||
const baseMat = new THREE.MeshLambertMaterial({ color: ROOM_COLORS.tableBase })
|
||||
for (const z of [zMid - length * 0.28, zMid + length * 0.28]) {
|
||||
const base = new THREE.Mesh(new THREE.BoxGeometry(TABLE.width * 0.55, TABLE.top - 0.05, 0.12), baseMat)
|
||||
base.position.set(0, (TABLE.top - 0.05) / 2, z)
|
||||
scene.add(base)
|
||||
}
|
||||
|
||||
// Kontaktschatten auf dem Boden — ohne ihn schwebt der Tisch
|
||||
const shadow = new THREE.Mesh(
|
||||
new THREE.PlaneGeometry(TABLE.width + 1.0, length + 0.8),
|
||||
new THREE.MeshBasicMaterial({ color: 0x000000, transparent: true, opacity: 0.28 }),
|
||||
)
|
||||
shadow.rotation.x = -Math.PI / 2
|
||||
shadow.position.set(0, 0.006, zMid)
|
||||
scene.add(shadow)
|
||||
}
|
||||
|
||||
/**
|
||||
* Ein Agent am Tisch (§3).
|
||||
*
|
||||
* Nur das runde Porträt mit einem weissen Ring — keine Stühle, keine Körper,
|
||||
* keine Arme. Die früheren Sitzmöbel und Rümpfe wirkten unnatürlich und sind
|
||||
* ersatzlos entfallen (§3.1).
|
||||
*
|
||||
* Das Porträt hängt in einer Kreisfläche. Weil die Vorlagen unterschiedliche
|
||||
* Seitenverhältnisse haben, wird die Textur auf einen mittigen quadratischen
|
||||
* Ausschnitt begrenzt: ohne diese Beschneidung zieht die Kreisgeometrie ein
|
||||
* liegendes Foto in die Breite, was Gesichter verzerrt und Schärfe kostet (§3.3).
|
||||
*
|
||||
* Das Porträt schaut zum Betrachter. Voll zur Tischmitte gedreht sähe man von
|
||||
* einem frontalen Porträtfoto nur die Kante; eine leichte Drehung zum Tisch
|
||||
* genügt, damit die Runde zusammengehört.
|
||||
*/
|
||||
function addSeatedAgent(
|
||||
scene: THREE.Scene,
|
||||
loader: THREE.TextureLoader,
|
||||
seat: MeetingSeat,
|
||||
photoUrl: string | undefined,
|
||||
maxAnisotropy: number,
|
||||
): SeatHandle {
|
||||
const group = new THREE.Group()
|
||||
group.position.set(seat.x, 0, seat.z)
|
||||
|
||||
const yaw =
|
||||
seat.side === SeatSide.HEAD ? 0
|
||||
: seat.side === SeatSide.LEFT ? 0.42
|
||||
: -0.42
|
||||
|
||||
const head = new THREE.Group()
|
||||
head.position.y = SEATED.headY
|
||||
head.rotation.y = yaw
|
||||
|
||||
const ring = new THREE.Mesh(
|
||||
new THREE.CircleGeometry(SEATED.headRadius + 0.016, 48),
|
||||
new THREE.MeshBasicMaterial({ color: ROOM_COLORS.headRing }),
|
||||
)
|
||||
ring.position.z = -0.004
|
||||
head.add(ring)
|
||||
|
||||
if (photoUrl) {
|
||||
const tex = loader.load(photoUrl, t => {
|
||||
// Mittigen quadratischen Ausschnitt wählen, sobald die Masse bekannt sind.
|
||||
const { width, height } = t.image as { width: number; height: number }
|
||||
if (width > height) {
|
||||
t.repeat.set(height / width, 1)
|
||||
t.offset.set((1 - height / width) / 2, 0)
|
||||
} else if (height > width) {
|
||||
t.repeat.set(1, width / height)
|
||||
t.offset.set(0, (1 - width / height) / 2)
|
||||
}
|
||||
t.needsUpdate = true
|
||||
})
|
||||
tex.colorSpace = THREE.SRGBColorSpace
|
||||
// Anisotrope Filterung: die Kreise stehen schräg im Raum, ohne sie werden
|
||||
// die Porträts an den Rändern matschig.
|
||||
tex.anisotropy = maxAnisotropy
|
||||
tex.generateMipmaps = true
|
||||
tex.minFilter = THREE.LinearMipmapLinearFilter
|
||||
tex.magFilter = THREE.LinearFilter
|
||||
|
||||
const face = new THREE.Mesh(
|
||||
new THREE.CircleGeometry(SEATED.headRadius, 48),
|
||||
new THREE.MeshBasicMaterial({ map: tex }),
|
||||
)
|
||||
head.add(face)
|
||||
}
|
||||
|
||||
group.add(head)
|
||||
|
||||
// ── Trefferkörper: gross genug für bequemes Klicken (§9) ──
|
||||
const hitBox = new THREE.Mesh(
|
||||
/*
|
||||
* Eine Kugel knapp um den sichtbaren Kreis — nicht ein grosszügiger Quader.
|
||||
* Ein weiter Quader am vorderen Platz fing die Strahlen der hinteren Plätze
|
||||
* ab: der Raycaster nimmt den nächstgelegenen Treffer, und damit landete ein
|
||||
* Klick auf Livia bei Ferdi. Die Trefferfläche deckt sich jetzt mit dem, was
|
||||
* man sieht.
|
||||
*/
|
||||
new THREE.SphereGeometry(SEATED.headRadius * 1.18, 12, 8),
|
||||
// Unsichtbar, aber weiterhin vom Raycaster erfasst: `visible = false` würde
|
||||
// das Objekt aus der Strahlprüfung nehmen.
|
||||
new THREE.MeshBasicMaterial({ transparent: true, opacity: 0, depthWrite: false }),
|
||||
)
|
||||
hitBox.position.y = SEATED.headY
|
||||
group.add(hitBox)
|
||||
|
||||
scene.add(group)
|
||||
|
||||
return {
|
||||
seat,
|
||||
hitBox,
|
||||
ring,
|
||||
group,
|
||||
// Der Anker steigt mit der Tiefe: weiter hinten sitzende Plaketten hängen
|
||||
// höher. Ohne diese Staffelung laufen sie am Fluchtpunkt ineinander, weil
|
||||
// die Plätze dort auf wenige Bildpunkte zusammenrücken (§8).
|
||||
labelAnchor: new THREE.Vector3(
|
||||
seat.x,
|
||||
SEATED.headY + SEATED.headRadius + 0.16 + Math.min(0.78, Math.max(0, (Math.abs(seat.z) - 1.75) * 0.135)),
|
||||
seat.z,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
// ── Aufbau ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export function createMeetingRoomScene(
|
||||
canvas: HTMLCanvasElement,
|
||||
seats: MeetingSeat[],
|
||||
photos: Record<string, string | undefined>,
|
||||
): MeetingRoomScene {
|
||||
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: false })
|
||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
|
||||
renderer.outputColorSpace = THREE.SRGBColorSpace
|
||||
|
||||
const scene = new THREE.Scene()
|
||||
scene.background = new THREE.Color(ROOM_COLORS.sky)
|
||||
// Leichter Dunst nur in der Ferne: der Raum (0…12 m) bleibt klar, die Kulisse
|
||||
// bei 40–65 m bekommt Tiefe. Ohne ihn klebt die Stadt an der Scheibe.
|
||||
scene.fog = new THREE.Fog(ROOM_COLORS.sky, 40, 140)
|
||||
|
||||
const camera = new THREE.PerspectiveCamera(CAMERA.fov, 1, 0.1, 300)
|
||||
camera.position.set(0, CAMERA.eyeHeight, CAMERA.z)
|
||||
camera.lookAt(new THREE.Vector3(...CAMERA.lookAt))
|
||||
|
||||
scene.add(new THREE.AmbientLight(0xffffff, 1.45))
|
||||
// Tageslicht von der linken Glasfront (§4 «klares natürliches Tageslicht»)
|
||||
const sun = new THREE.DirectionalLight(0xfff6e8, 1.5)
|
||||
sun.position.set(-6, 5, -2)
|
||||
scene.add(sun)
|
||||
// Aufheller von der geschlossenen Wand, damit rechts nichts absäuft
|
||||
const bounce = new THREE.DirectionalLight(0xdfe6ee, 0.5)
|
||||
bounce.position.set(5, 2, 3)
|
||||
scene.add(bounce)
|
||||
|
||||
const loader = new THREE.TextureLoader()
|
||||
const maxAnisotropy = renderer.capabilities.getMaxAnisotropy()
|
||||
|
||||
addBaselBackdrop(scene, loader, maxAnisotropy)
|
||||
addRoomShell(scene)
|
||||
addGlassFacades(scene)
|
||||
addTable(scene)
|
||||
|
||||
const handles = seats.map(seat =>
|
||||
addSeatedAgent(scene, loader, seat, photos[seat.id], maxAnisotropy),
|
||||
)
|
||||
|
||||
const baseCam = camera.position.clone()
|
||||
const lookTarget = new THREE.Vector3(...CAMERA.lookAt)
|
||||
|
||||
function render() {
|
||||
renderer.render(scene, camera)
|
||||
}
|
||||
|
||||
return {
|
||||
scene,
|
||||
camera,
|
||||
renderer,
|
||||
seats: handles,
|
||||
|
||||
applyParallax(dx, dy) {
|
||||
camera.position.set(
|
||||
baseCam.x + dx * CAMERA.parallax,
|
||||
baseCam.y + dy * CAMERA.parallax * 0.6,
|
||||
baseCam.z,
|
||||
)
|
||||
camera.lookAt(lookTarget)
|
||||
},
|
||||
|
||||
setSize(width, height) {
|
||||
renderer.setSize(width, height, false)
|
||||
const aspect = width / Math.max(height, 1)
|
||||
camera.aspect = aspect
|
||||
// Schmale Fenster brauchen einen weiteren Bildwinkel: bei festem Winkel
|
||||
// schneidet das hohe Format den Tisch links und rechts ab. Statt einer
|
||||
// eigenen Mobilszene (§18: pragmatische Degradation) öffnet sich die
|
||||
// Kamera, bis der Raum wieder hineinpasst.
|
||||
camera.fov = CAMERA.fov + Math.max(0, (1.45 - aspect) * 26)
|
||||
camera.updateProjectionMatrix()
|
||||
},
|
||||
|
||||
render,
|
||||
|
||||
setHovered(id) {
|
||||
for (const h of handles) {
|
||||
const on = h.seat.id === id
|
||||
;(h.ring.material as THREE.MeshBasicMaterial).color.set(
|
||||
on ? ROOM_COLORS.headRingHover : ROOM_COLORS.headRing,
|
||||
)
|
||||
// Leichtes Anheben statt Vergrössern: eine skalierte Person wirkt
|
||||
// sofort falsch, ein Aufrichten liest sich als Zuwendung.
|
||||
h.group.position.y = on ? 0.035 : 0
|
||||
}
|
||||
},
|
||||
|
||||
dispose() {
|
||||
scene.traverse(obj => {
|
||||
if (obj instanceof THREE.Mesh) {
|
||||
obj.geometry.dispose()
|
||||
const mat = obj.material
|
||||
const mats = Array.isArray(mat) ? mat : [mat]
|
||||
for (const m of mats) {
|
||||
const withMap = m as THREE.Material & { map?: THREE.Texture | null }
|
||||
withMap.map?.dispose()
|
||||
m.dispose()
|
||||
}
|
||||
}
|
||||
})
|
||||
renderer.dispose()
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { AGENT_WORKSPACES } from '../../lib/agentWorkspaces'
|
||||
import { AGENT_SUPERVISOR } from '../../lib/agentWorkspaces'
|
||||
|
||||
/**
|
||||
* Sitzordnung im 3D-Meetingraum (Runde 6, §5/§6/§7).
|
||||
*
|
||||
* Eine einzige Tabelle bestimmt, wer wo sitzt. Namen, Funktionen, Porträts und
|
||||
* Zielseiten kommen dabei nicht von hier, sondern aus `AGENT_WORKSPACES` —
|
||||
* derselben zentralen Konfiguration, die Menü und Seitenköpfe verwenden. Hier
|
||||
* steht ausschliesslich die Geometrie (§24, Schritt 7: nichts doppelt pflegen).
|
||||
*
|
||||
* Koordinaten sind Meter im Weltsystem der Szene:
|
||||
* - x: negativ = links vom Betrachter, positiv = rechts
|
||||
* - z: 0 am Kopfende des Betrachters, negativ = in den Raum hinein
|
||||
*
|
||||
* Die Agenten wechseln die Tischseite in der Reihenfolge der Konfiguration
|
||||
* (Ferdi links, Bruno rechts, Livia links, …). Das verteilt sie gleichmässig,
|
||||
* hält die Abstände gross genug, dass sich niemand überlappt, und macht den
|
||||
* Arbeitsablauf auch am Tisch ablesbar.
|
||||
*/
|
||||
|
||||
/** Auf welcher Tischseite ein Platz liegt. */
|
||||
export const SeatSide = {
|
||||
LEFT: 'LEFT',
|
||||
RIGHT: 'RIGHT',
|
||||
/** Kopfende gegenüber dem Betrachter — nur Thomas. */
|
||||
HEAD: 'HEAD',
|
||||
} as const
|
||||
export type SeatSide = typeof SeatSide[keyof typeof SeatSide]
|
||||
|
||||
export interface MeetingSeat {
|
||||
/** Agenten-ID; identisch mit `AGENT_PHOTOS` und den Mockdaten. */
|
||||
id: string
|
||||
name: string
|
||||
role: string
|
||||
/** Zielseite des Klicks. */
|
||||
path: string
|
||||
side: SeatSide
|
||||
/** Sitzposition in Metern. */
|
||||
x: number
|
||||
z: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Sitzplätze der fünf Agenten, in Metern (Runde 6, §3.2).
|
||||
*
|
||||
* Ferdi und Bruno sind die direkten Nachbarn des Betrachters — einer links,
|
||||
* einer rechts, schräg frontal und dicht an der vorderen Tischkante. Dadurch
|
||||
* entsteht hinter ihnen Platz, in dem die übrigen drei gestaffelt Raum haben,
|
||||
* ohne sich zu überlagern.
|
||||
*
|
||||
* Vorher standen alle fünf zu weit hinten und drängten sich am Fluchtpunkt.
|
||||
*/
|
||||
const PLAETZE: Record<string, { side: SeatSide; x: number; z: number }> = {
|
||||
ferdi: { side: SeatSide.LEFT, x: -1.14, z: -1.20 },
|
||||
bruno: { side: SeatSide.RIGHT, x: 1.14, z: -1.20 },
|
||||
livia: { side: SeatSide.LEFT, x: -1.42, z: -2.90 },
|
||||
nora: { side: SeatSide.RIGHT, x: 1.42, z: -2.90 },
|
||||
sina: { side: SeatSide.LEFT, x: -1.30, z: -4.75 },
|
||||
}
|
||||
|
||||
/**
|
||||
* Notplatz für einen Agenten ohne Eintrag oben — sichtbar hinten rechts statt
|
||||
* lautlos im Nichts.
|
||||
*/
|
||||
const FALLBACK = { side: SeatSide.RIGHT, x: 1.30, z: -4.75 }
|
||||
|
||||
/** Kopfende gegenüber. Unverändert: die Position wirkt gut (§3.2). */
|
||||
const HEAD_Z = -6.75
|
||||
|
||||
export const MEETING_SEATS: MeetingSeat[] = [
|
||||
...AGENT_WORKSPACES.map(agent => ({
|
||||
id: agent.id,
|
||||
name: agent.name,
|
||||
role: agent.role,
|
||||
path: agent.path,
|
||||
...(PLAETZE[agent.id] ?? FALLBACK),
|
||||
})),
|
||||
{
|
||||
id: AGENT_SUPERVISOR.id,
|
||||
name: AGENT_SUPERVISOR.name,
|
||||
role: AGENT_SUPERVISOR.role,
|
||||
path: AGENT_SUPERVISOR.path,
|
||||
side: SeatSide.HEAD,
|
||||
x: 0,
|
||||
z: HEAD_Z,
|
||||
},
|
||||
]
|
||||
@@ -1,36 +0,0 @@
|
||||
import { Box, Chip, Typography } from '@mui/material'
|
||||
import { DS_SLATE } from '../../lib/ds'
|
||||
|
||||
interface DashboardHeaderProps {
|
||||
orgName?: string
|
||||
lastUpdated?: string
|
||||
}
|
||||
|
||||
function formatLastUpdated(iso: string): string {
|
||||
try {
|
||||
return new Intl.DateTimeFormat('de-CH', {
|
||||
dateStyle: 'short',
|
||||
timeStyle: 'short',
|
||||
}).format(new Date(iso))
|
||||
} catch {
|
||||
return iso
|
||||
}
|
||||
}
|
||||
|
||||
export function DashboardHeader({ orgName = 'Demo Organisation', lastUpdated }: DashboardHeaderProps) {
|
||||
return (
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.5 }}>
|
||||
<Typography sx={{ fontWeight: 700, fontSize: '1.125rem', lineHeight: 1.3, color: DS_SLATE[900] }}>
|
||||
{orgName}
|
||||
</Typography>
|
||||
<Chip label="Demo" size="small" color="info" variant="outlined" />
|
||||
</Box>
|
||||
{lastUpdated && (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Zuletzt aktualisiert: {formatLastUpdated(lastUpdated)}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
import { Box, Card, CardContent, Skeleton } from '@mui/material'
|
||||
|
||||
function SkeletonCard() {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent sx={{ p: 2.5 }}>
|
||||
<Skeleton variant="text" width="60%" height={20} />
|
||||
<Skeleton variant="text" width="40%" height={40} sx={{ mt: 1 }} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function SkeletonLargeCard() {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent sx={{ p: 2.5 }}>
|
||||
<Skeleton variant="text" width="50%" height={28} sx={{ mb: 2 }} />
|
||||
<Skeleton variant="rectangular" height={120} sx={{ borderRadius: 1, mb: 1 }} />
|
||||
<Skeleton variant="rectangular" height={80} sx={{ borderRadius: 1 }} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
export function DashboardSkeleton() {
|
||||
return (
|
||||
<Box sx={{ p: 3, display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
{/* Header skeleton */}
|
||||
<Box sx={{ pb: 2 }}>
|
||||
<Skeleton variant="text" width={240} height={32} />
|
||||
<Skeleton variant="text" width={360} height={20} sx={{ mt: 0.5 }} />
|
||||
</Box>
|
||||
|
||||
{/* KPI grid skeleton */}
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 2 }}>
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<SkeletonCard key={i} />
|
||||
))}
|
||||
</Box>
|
||||
|
||||
{/* Row 2 */}
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 3 }}>
|
||||
<SkeletonLargeCard />
|
||||
<SkeletonLargeCard />
|
||||
</Box>
|
||||
|
||||
{/* Row 3 */}
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 3 }}>
|
||||
<SkeletonLargeCard />
|
||||
<SkeletonLargeCard />
|
||||
</Box>
|
||||
|
||||
{/* Quick actions skeleton */}
|
||||
<Card>
|
||||
<CardContent sx={{ p: 2.5 }}>
|
||||
<Skeleton variant="text" width="30%" height={28} sx={{ mb: 2 }} />
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 1.5 }}>
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<Skeleton key={i} variant="rectangular" height={36} sx={{ borderRadius: 1 }} />
|
||||
))}
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
import { Box, Button, Card, CardContent, Chip, LinearProgress, Typography } from '@mui/material'
|
||||
import type { DataQualitySummary } from '../../domain/dashboard'
|
||||
import { dataQualityColor } from '../../lib/utils'
|
||||
|
||||
interface DataQualityWidgetProps {
|
||||
summary: DataQualitySummary
|
||||
onNavigate: () => void
|
||||
}
|
||||
|
||||
export function DataQualityWidget({ summary, onNavigate }: DataQualityWidgetProps) {
|
||||
const color = dataQualityColor(summary.avgScore / 100)
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardContent sx={{ p: 2.5 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 600 }}>
|
||||
Datenqualität
|
||||
</Typography>
|
||||
<Button size="small" onClick={onNavigate}>
|
||||
Details
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.5 }}>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
Ø Score
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>
|
||||
{summary.avgScore}%
|
||||
</Typography>
|
||||
</Box>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={summary.avgScore}
|
||||
color={color}
|
||||
sx={{ borderRadius: 1, height: 8 }}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 2, mb: 2 }}>
|
||||
<Box>
|
||||
<Typography variant="h5" sx={{ fontWeight: 700, color: summary.critical > 0 ? 'error.main' : 'text.primary' }}>
|
||||
{summary.critical}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
Kritische Objekte
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="h5" sx={{ fontWeight: 700 }}>
|
||||
{summary.propertiesWithMissingCritical}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
Fehlende Pflichtfelder
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{summary.topMissingFields.length > 0 && (
|
||||
<Box>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary', display: 'block', mb: 0.5 }}>
|
||||
Häufig fehlende Felder:
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
|
||||
{summary.topMissingFields.map(field => (
|
||||
<Chip key={field} label={field} size="small" color="warning" variant="outlined" />
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
import { Box, Card, CardContent, Divider, LinearProgress, Typography } from '@mui/material'
|
||||
import { useNavigate } from 'react-router'
|
||||
import type { FutureSignalSummary } from '../../domain/dashboard'
|
||||
|
||||
interface FutureSignalWidgetProps {
|
||||
summary: FutureSignalSummary
|
||||
}
|
||||
|
||||
interface StatItemProps {
|
||||
label: string
|
||||
value: number | string
|
||||
highlight?: boolean
|
||||
}
|
||||
|
||||
function StatItem({ label, value, highlight }: StatItemProps) {
|
||||
return (
|
||||
<Box sx={{ textAlign: 'center' }}>
|
||||
<Typography
|
||||
variant="h5"
|
||||
sx={{ fontWeight: 700, color: highlight ? 'primary.main' : 'text.primary' }}
|
||||
>
|
||||
{value}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
{label}
|
||||
</Typography>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
export function FutureSignalWidget({ summary }: FutureSignalWidgetProps) {
|
||||
const navigate = useNavigate()
|
||||
const total = summary.total || 1
|
||||
const dist = summary.timeHorizonDistribution
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardContent sx={{ p: 2.5 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 600 }}>
|
||||
Marktsignale
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{ color: 'primary.main', cursor: 'pointer', '&:hover': { textDecoration: 'underline' } }}
|
||||
onClick={() => navigate('/supply/future-availability')}
|
||||
>
|
||||
Alle anzeigen
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: 2, mb: 2.5 }}>
|
||||
<StatItem label="Gesamt" value={summary.total} />
|
||||
<StatItem label="Hohe Konfidenz" value={summary.highConfidence} highlight />
|
||||
<StatItem label="Zu prüfen" value={summary.needsReview} />
|
||||
<StatItem label="Ø Zeithorizont" value={`${summary.avgTimeHorizonMonths} Mon.`} />
|
||||
</Box>
|
||||
|
||||
<Divider sx={{ mb: 2 }} />
|
||||
|
||||
{/* Time horizon distribution */}
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary', fontWeight: 600, display: 'block', mb: 1 }}>
|
||||
Zeithorizont-Verteilung
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75 }}>
|
||||
{[
|
||||
{ label: '0–6 Monate', count: dist.short },
|
||||
{ label: '6–12 Monate', count: dist.medium },
|
||||
{ label: '12–24 Monate', count: dist.long },
|
||||
].map(({ label, count }) => (
|
||||
<Box key={label}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.25 }}>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>{label}</Typography>
|
||||
<Typography variant="caption" sx={{ fontWeight: 600 }}>{count}</Typography>
|
||||
</Box>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={Math.round((count / total) * 100)}
|
||||
sx={{ height: 6, borderRadius: 3 }}
|
||||
/>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
{summary.restricted > 0 && (
|
||||
<>
|
||||
<Divider sx={{ my: 1.5 }} />
|
||||
<Typography variant="caption" sx={{ color: 'warning.main', display: 'block' }}>
|
||||
{summary.restricted} vertrauliche Signale — nur für berechtigte Nutzer sichtbar.
|
||||
</Typography>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Divider sx={{ my: 1.5 }} />
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary', display: 'block' }}>
|
||||
Marktsignale basieren auf AI-Analyse öffentlicher und interner Daten. Es handelt
|
||||
sich um probabilistische Einschätzungen, keine bestätigten Objekte.
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
import { Box } from '@mui/material'
|
||||
import { useNavigate } from 'react-router'
|
||||
import type { DashboardData, KpiCardData } from '../../domain/dashboard'
|
||||
import { KpiCard } from './KpiCard'
|
||||
|
||||
interface KpiGridProps {
|
||||
data: DashboardData
|
||||
}
|
||||
|
||||
export function KpiGrid({ data }: KpiGridProps) {
|
||||
const navigate = useNavigate()
|
||||
|
||||
const qualityColor =
|
||||
data.avgDataQuality >= 80
|
||||
? '#1a7a4a'
|
||||
: data.avgDataQuality >= 60
|
||||
? '#d97706'
|
||||
: '#c0392b'
|
||||
|
||||
const cards: KpiCardData[] = [
|
||||
{
|
||||
id: 'active-properties',
|
||||
label: 'Aktive Objekte',
|
||||
value: `${data.activeProperties} / ${data.totalProperties}`,
|
||||
tooltip: 'Objekte mit Status "Verfügbar jetzt" oder "Verfügbar bald"',
|
||||
onClick: () => navigate('/supply/properties'),
|
||||
},
|
||||
{
|
||||
id: 'avg-quality',
|
||||
label: 'Ø Datenqualität',
|
||||
value: `${data.avgDataQuality}%`,
|
||||
accent: qualityColor,
|
||||
tooltip: 'Durchschnittlicher Datenqualitäts-Score aller Objekte',
|
||||
onClick: () => navigate('/supply/data-quality'),
|
||||
},
|
||||
{
|
||||
id: 'market-signals',
|
||||
label: 'Marktsignale',
|
||||
value: data.futureSignals?.total ?? '–',
|
||||
tooltip: 'Erkannte Marktsignale und Future Availability Indikatoren',
|
||||
},
|
||||
{
|
||||
id: 'critical-gaps',
|
||||
label: 'Kritische Datenlücken',
|
||||
value: data.dataQuality?.critical ?? '–',
|
||||
accent: (data.dataQuality?.critical ?? 0) > 0 ? '#c0392b' : undefined,
|
||||
tooltip: 'Objekte mit Datenqualitäts-Score unter 50%',
|
||||
onClick: () => navigate('/supply/data-quality'),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(4, 1fr)',
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
{cards.map(card => (
|
||||
<KpiCard key={card.id} card={card} />
|
||||
))}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
import { Box, Button, Card, CardContent, Typography } from '@mui/material'
|
||||
import { useNavigate } from 'react-router'
|
||||
|
||||
const ACTIONS = [
|
||||
{ label: 'Objekte verwalten', path: '/supply/properties' },
|
||||
{ label: 'Match Center', path: '/supply/match-center' },
|
||||
{ label: 'Marktchancen', path: '/supply/future-availability' },
|
||||
{ label: 'Datenqualität', path: '/supply/data-quality' },
|
||||
{ label: 'Review Queue', path: '/ops/review-queue' },
|
||||
{ label: 'Markt Intelligence', path: '/supply/market-intelligence' },
|
||||
] as const
|
||||
|
||||
export function QuickActionPanel() {
|
||||
const navigate = useNavigate()
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardContent sx={{ p: 2.5 }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 600, mb: 2 }}>
|
||||
Schnellzugriff
|
||||
</Typography>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(3, 1fr)',
|
||||
gap: 1.5,
|
||||
}}
|
||||
>
|
||||
{ACTIONS.map(action => (
|
||||
<Button
|
||||
key={action.path}
|
||||
variant="outlined"
|
||||
onClick={() => navigate(action.path)}
|
||||
sx={{ justifyContent: 'flex-start', textTransform: 'none' }}
|
||||
>
|
||||
{action.label}
|
||||
</Button>
|
||||
))}
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
import { Box, Button, Card, CardContent, Chip, Typography } from '@mui/material'
|
||||
import type { DashboardReviewTask } from '../../domain/dashboard'
|
||||
|
||||
interface ReviewTaskWidgetProps {
|
||||
tasks: DashboardReviewTask[]
|
||||
onNavigate: () => void
|
||||
}
|
||||
|
||||
const PRIORITY_LABELS: Record<string, string> = {
|
||||
HIGH: 'Hoch',
|
||||
MEDIUM: 'Mittel',
|
||||
LOW: 'Niedrig',
|
||||
}
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
PENDING: 'Ausstehend',
|
||||
IN_REVIEW: 'In Bearbeitung',
|
||||
COMPLETED: 'Abgeschlossen',
|
||||
}
|
||||
|
||||
function priorityColor(priority: string): 'error' | 'warning' | 'default' {
|
||||
if (priority === 'HIGH') return 'error'
|
||||
if (priority === 'MEDIUM') return 'warning'
|
||||
return 'default'
|
||||
}
|
||||
|
||||
function statusColor(status: string): 'warning' | 'info' | 'success' | 'default' {
|
||||
if (status === 'PENDING') return 'warning'
|
||||
if (status === 'IN_REVIEW') return 'info'
|
||||
if (status === 'COMPLETED') return 'success'
|
||||
return 'default'
|
||||
}
|
||||
|
||||
const PRIORITY_ORDER: Record<string, number> = { HIGH: 0, MEDIUM: 1, LOW: 2 }
|
||||
|
||||
export function ReviewTaskWidget({ tasks, onNavigate }: ReviewTaskWidgetProps) {
|
||||
const sorted = [...tasks].sort(
|
||||
(a, b) => (PRIORITY_ORDER[a.priority] ?? 9) - (PRIORITY_ORDER[b.priority] ?? 9),
|
||||
)
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardContent sx={{ p: 2.5 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 600 }}>
|
||||
Review Queue
|
||||
</Typography>
|
||||
<Button size="small" onClick={onNavigate}>
|
||||
Alle anzeigen
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{sorted.length === 0 ? (
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary', textAlign: 'center', py: 2 }}>
|
||||
Keine offenen Aufgaben.
|
||||
</Typography>
|
||||
) : (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
{sorted.map(task => (
|
||||
<Box
|
||||
key={task.id}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
py: 0.75,
|
||||
borderBottom: '1px solid',
|
||||
borderColor: 'divider',
|
||||
'&:last-child': { borderBottom: 'none' },
|
||||
}}
|
||||
>
|
||||
<Chip
|
||||
label={PRIORITY_LABELS[task.priority] ?? task.priority}
|
||||
size="small"
|
||||
color={priorityColor(task.priority)}
|
||||
sx={{ flexShrink: 0 }}
|
||||
/>
|
||||
<Typography variant="body2" sx={{ flex: 1 }}>
|
||||
{task.title}
|
||||
</Typography>
|
||||
<Chip
|
||||
label={STATUS_LABELS[task.status] ?? task.status}
|
||||
size="small"
|
||||
color={statusColor(task.status)}
|
||||
variant="outlined"
|
||||
sx={{ flexShrink: 0 }}
|
||||
/>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
import { Box, Button, Card, CardContent, Typography } from '@mui/material'
|
||||
import type { StrongMatchItem } from '../../domain/dashboard'
|
||||
import { StrongMatchMiniCard } from './StrongMatchMiniCard'
|
||||
|
||||
interface StrongMatchOverviewProps {
|
||||
matches: StrongMatchItem[]
|
||||
onNavigate: () => void
|
||||
}
|
||||
|
||||
export function StrongMatchOverview({ matches, onNavigate }: StrongMatchOverviewProps) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent sx={{ p: 2.5 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 600 }}>
|
||||
Starke Matches
|
||||
</Typography>
|
||||
<Button size="small" onClick={onNavigate}>
|
||||
Alle anzeigen
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{matches.length === 0 ? (
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary', py: 2, textAlign: 'center' }}>
|
||||
Keine starken Matches vorhanden.
|
||||
</Typography>
|
||||
) : (
|
||||
matches.slice(0, 5).map(m => (
|
||||
<StrongMatchMiniCard key={m.matchId} match={m} />
|
||||
))
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -1,13 +1,7 @@
|
||||
// Die Widgets der entfernten Hauptseite «Übersicht» sind mit ihr entfallen
|
||||
// (Runde 5, §2.1). Geblieben sind nur die beiden, die anderswo verwendet werden.
|
||||
export { KpiCard } from './KpiCard'
|
||||
export { KpiGrid } from './KpiGrid'
|
||||
export { StrongMatchMiniCard } from './StrongMatchMiniCard'
|
||||
export { StrongMatchOverview } from './StrongMatchOverview'
|
||||
export { DataQualityWidget } from './DataQualityWidget'
|
||||
export { FutureSignalWidget } from './FutureSignalWidget'
|
||||
export { ReviewTaskWidget } from './ReviewTaskWidget'
|
||||
export { QuickActionPanel } from './QuickActionPanel'
|
||||
export { DashboardSkeleton } from './DashboardSkeleton'
|
||||
export { DashboardHeader } from './DashboardHeader'
|
||||
export { PropertyCard } from './PropertyCard'
|
||||
export type { PropertyCardProps } from './PropertyCard'
|
||||
export { PropertyTable } from './PropertyTable'
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import { Box, IconButton, TextField, Typography } from '@mui/material'
|
||||
import { SendHorizonal } from 'lucide-react'
|
||||
import { AgentAvatar } from './AgentAvatar'
|
||||
import { AGENT_CHAT_MARKER, useAgentChat } from './useAgentChat'
|
||||
import { AGENT_CHAT_PROMPT_SHORT } from '../../lib/constants'
|
||||
import { DS_BORDER, DS_TEXT } from '../../lib/ds'
|
||||
import { useAgentChatStore } from '../../stores/agentChatStore'
|
||||
|
||||
/**
|
||||
* Kompakter Chatzustand im obersten globalen Balken (Runde 5, §5).
|
||||
*
|
||||
* Er erscheint, sobald der ausführliche Chatkopf der Agentenseite nach oben
|
||||
* weggescrollt ist, und zeigt nebeneinander Porträt, Name, Frage und
|
||||
* Eingabefeld. Er liegt im Balken zwischen Seitenmenü und Mandant/Konto und
|
||||
* nicht als Schwebeleiste über der Seite — so kann er Tabellen, Filter und
|
||||
* Seitennavigation gar nicht erst verdecken (§10).
|
||||
*
|
||||
* Ohne eigenen Eingabezustand: Text, Fokus und Agent kommen aus
|
||||
* `agentChatStore`, damit der Wechsel zwischen beiden Zuständen mitten im
|
||||
* Tippen nichts verliert.
|
||||
*/
|
||||
export function AgentChatStickyBar() {
|
||||
const compact = useAgentChatStore(s => s.compact)
|
||||
const agent = useAgentChatStore(s => s.agent)
|
||||
|
||||
// Der Inhalt ist eine eigene Komponente, damit `useAgentChat` erst dann
|
||||
// montiert wird, wenn der kompakte Zustand tatsächlich sichtbar ist. Läge der
|
||||
// Aufruf hier, liefe sein Fokuseffekt schon beim Start der Anwendung ins Leere
|
||||
// und der Fokus bliebe beim Scrollen im weggescrollten Feld hängen.
|
||||
if (!agent || !compact) return null
|
||||
return <StickyChat />
|
||||
}
|
||||
|
||||
function StickyChat() {
|
||||
const { agent, draft, inputRef, setDraft, send, handleKeyDown, onFocus, onBlur } = useAgentChat()
|
||||
if (!agent) return null
|
||||
|
||||
return (
|
||||
<Box
|
||||
{...{ [AGENT_CHAT_MARKER]: '' }}
|
||||
sx={{
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1.25,
|
||||
// Abstand zu Mandant, Benachrichtigungen und Konto auf der rechten Seite.
|
||||
mr: { xs: 1, sm: 2 },
|
||||
maxWidth: 720,
|
||||
}}
|
||||
>
|
||||
<AgentAvatar agent={agent} size={34} loading="eager" />
|
||||
|
||||
<Typography
|
||||
sx={{
|
||||
fontWeight: 700,
|
||||
fontSize: '0.875rem',
|
||||
color: DS_TEXT.primary,
|
||||
whiteSpace: 'nowrap',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{agent.name}
|
||||
</Typography>
|
||||
|
||||
{/*
|
||||
Bei engen Breiten verschwindet zuerst der Hilfstext, danach schrumpft das
|
||||
Eingabefeld (§10). Porträt und Name bleiben in jeder Breite stehen —
|
||||
ohne sie wüsste niemand mehr, mit wem er schreibt.
|
||||
*/}
|
||||
<Typography
|
||||
sx={{
|
||||
fontSize: '0.8125rem',
|
||||
color: DS_TEXT.secondary,
|
||||
whiteSpace: 'nowrap',
|
||||
flexShrink: 0,
|
||||
display: { xs: 'none', lg: 'block' },
|
||||
}}
|
||||
>
|
||||
{AGENT_CHAT_PROMPT_SHORT}
|
||||
</Typography>
|
||||
|
||||
<TextField
|
||||
size="small"
|
||||
value={draft}
|
||||
onChange={e => setDraft(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onFocus={onFocus}
|
||||
onBlur={onBlur}
|
||||
inputRef={inputRef}
|
||||
placeholder={`Nachricht an ${agent.name}`}
|
||||
aria-label={`Nachricht an ${agent.name}`}
|
||||
sx={{
|
||||
flex: 1,
|
||||
minWidth: 120,
|
||||
'& .MuiOutlinedInput-root': {
|
||||
borderRadius: 5,
|
||||
bgcolor: 'white',
|
||||
fontSize: '0.8125rem',
|
||||
pr: 0.5,
|
||||
'& fieldset': { borderColor: DS_BORDER.default },
|
||||
},
|
||||
}}
|
||||
slotProps={{
|
||||
input: {
|
||||
endAdornment: (
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={send}
|
||||
disabled={!draft.trim()}
|
||||
aria-label={`Nachricht an ${agent.name} senden`}
|
||||
sx={{ color: DS_TEXT.primary }}
|
||||
>
|
||||
<SendHorizonal size={15} />
|
||||
</IconButton>
|
||||
),
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
import { useState } from 'react'
|
||||
import type { KeyboardEvent } from 'react'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { Box, IconButton, TextField, Tooltip, Typography } from '@mui/material'
|
||||
import { SendHorizonal } from 'lucide-react'
|
||||
import { AgentAvatar } from './AgentAvatar'
|
||||
import { AGENT_CHAT_MARKER, useAgentChat } from './useAgentChat'
|
||||
import { AGENT_CHAT_PROMPT } from '../../lib/constants'
|
||||
import { DS_BORDER, DS_TEXT } from '../../lib/ds'
|
||||
import { useToastStore } from '../../stores/toastStore'
|
||||
import { useAgentChatStore } from '../../stores/agentChatStore'
|
||||
|
||||
export interface AgentWorkspaceHeroProps {
|
||||
agentId: string
|
||||
@@ -16,7 +16,13 @@ export interface AgentWorkspaceHeroProps {
|
||||
}
|
||||
|
||||
/**
|
||||
* Gemeinsamer Chat-Einstieg aller fünf Agentenseiten (Runde 4, §4).
|
||||
* Höhe des obersten globalen Balkens. Der Chatkopf gilt bereits als verschwunden,
|
||||
* sobald er darunter liegt — nicht erst, wenn er den Fensterrand verlässt.
|
||||
*/
|
||||
const TOP_BAR_PX = 64
|
||||
|
||||
/**
|
||||
* Gemeinsamer Chat-Einstieg aller fünf Agentenseiten (Runde 4, §4; Runde 5, §5).
|
||||
*
|
||||
* Bewusst grosszügig und ruhig: Porträt, Name, Funktion, Frage, Eingabefeld —
|
||||
* mehr nicht. Kein generischer «AI Assistent»-Button, keine Vorschlagskacheln,
|
||||
@@ -26,30 +32,46 @@ export interface AgentWorkspaceHeroProps {
|
||||
* Es gibt genau diese eine Komponente — fünf beinahe gleiche Chatbereiche
|
||||
* wären fünf Stellen, an denen dieselbe Änderung nachgezogen werden müsste.
|
||||
*
|
||||
* Das Absenden ist Frontend-Simulation: es existiert kein Chat-Backend, und
|
||||
* eine erfundene Antwort wäre schlimmer als eine ehrliche Rückmeldung.
|
||||
* Seit Runde 5 meldet sie zusätzlich ihren Sichtbarkeitszustand: scrollt sie
|
||||
* unter den obersten Balken, übernimmt dort `AgentChatStickyBar` denselben
|
||||
* Chat in kompakter Form. Text und Fokus liegen dafür in `agentChatStore` —
|
||||
* die Komponente hält keinen eigenen Eingabezustand mehr.
|
||||
*/
|
||||
export function AgentWorkspaceHero({ agentId, name, role, channels = [] }: AgentWorkspaceHeroProps) {
|
||||
const [message, setMessage] = useState('')
|
||||
const showToast = useToastStore(s => s.showToast)
|
||||
const compact = useAgentChatStore(s => s.compact)
|
||||
const { draft, inputRef, setDraft, send, handleKeyDown, onFocus, onBlur } = useAgentChat({ active: !compact })
|
||||
const openFor = useAgentChatStore(s => s.openFor)
|
||||
const close = useAgentChatStore(s => s.close)
|
||||
const setCompact = useAgentChatStore(s => s.setCompact)
|
||||
const sectionRef = useRef<HTMLElement>(null)
|
||||
|
||||
function handleSend() {
|
||||
const text = message.trim()
|
||||
if (!text) return
|
||||
setMessage('')
|
||||
showToast(`Nachricht an ${name} vorgemerkt — der Chat ist in dieser Demo noch nicht angebunden.`, 'info')
|
||||
}
|
||||
// Der Agent der Seite ist die Identität des Chats — auch für den sticky Balken,
|
||||
// der ausserhalb dieser Seite im globalen Kopf steht.
|
||||
useEffect(() => {
|
||||
openFor({ id: agentId, name, role })
|
||||
return () => close()
|
||||
}, [agentId, name, role, openFor, close])
|
||||
|
||||
function handleKeyDown(e: KeyboardEvent<HTMLDivElement>) {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
handleSend()
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Ein `IntersectionObserver` statt eines Scroll-Listeners: er meldet sich nur
|
||||
* beim Zustandswechsel und nicht bei jedem Scrollschritt, und er funktioniert
|
||||
* unabhängig davon, welches Element auf der jeweiligen Agentenseite scrollt.
|
||||
*/
|
||||
useEffect(() => {
|
||||
const el = sectionRef.current
|
||||
if (!el) return
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => setCompact(!entry.isIntersecting),
|
||||
{ rootMargin: `-${TOP_BAR_PX}px 0px 0px 0px`, threshold: 0 },
|
||||
)
|
||||
observer.observe(el)
|
||||
return () => observer.disconnect()
|
||||
}, [setCompact])
|
||||
|
||||
return (
|
||||
<Box
|
||||
component="section"
|
||||
ref={sectionRef}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
@@ -78,23 +100,26 @@ export function AgentWorkspaceHero({ agentId, name, role, channels = [] }: Agent
|
||||
{AGENT_CHAT_PROMPT}
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ width: '100%', maxWidth: 720, mt: 1.5 }}>
|
||||
<Box {...{ [AGENT_CHAT_MARKER]: '' }} sx={{ width: '100%', maxWidth: 720, mt: 1.5 }}>
|
||||
<TextField
|
||||
fullWidth
|
||||
multiline
|
||||
minRows={2}
|
||||
maxRows={6}
|
||||
value={message}
|
||||
onChange={e => setMessage(e.target.value)}
|
||||
value={draft}
|
||||
onChange={e => setDraft(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onFocus={onFocus}
|
||||
onBlur={onBlur}
|
||||
inputRef={inputRef}
|
||||
placeholder={`Nachricht an ${name} senden`}
|
||||
slotProps={{
|
||||
input: {
|
||||
endAdornment: (
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={handleSend}
|
||||
disabled={!message.trim()}
|
||||
onClick={send}
|
||||
disabled={!draft.trim()}
|
||||
aria-label={`Nachricht an ${name} senden`}
|
||||
sx={{ alignSelf: 'flex-end', color: DS_TEXT.primary }}
|
||||
>
|
||||
|
||||
@@ -50,6 +50,11 @@ import gian from '../../assets/team/gian.jpg'
|
||||
import ida from '../../assets/team/ida.jpg'
|
||||
import jana from '../../assets/team/jana.jpg'
|
||||
import zeno from '../../assets/team/zeno.jpg'
|
||||
// Thomas ist in Runde 6 dazugekommen und hat kein Porträt aus dem
|
||||
// Konzeptdokument. Statt den Kopf eines bestehenden Mitarbeitenden
|
||||
// zweitzuverwenden — jeder Kopf gehört genau einer Person — trägt er ein eigenes
|
||||
// Porträt. Herkunft und Lizenz stehen in `assets/team/HERKUNFT.md`.
|
||||
import thomas from '../../assets/team/thomas.jpg'
|
||||
|
||||
export const AGENT_PHOTOS: Record<string, string | undefined> = {
|
||||
bruno,
|
||||
@@ -86,4 +91,5 @@ export const AGENT_PHOTOS: Record<string, string | undefined> = {
|
||||
ida,
|
||||
jana,
|
||||
zeno,
|
||||
thomas,
|
||||
}
|
||||
@@ -27,6 +27,8 @@ export { ChannelsSection } from './ChannelsSection'
|
||||
|
||||
// Gemeinsame Bausteine der fünf Agentenseiten
|
||||
export { AgentWorkspaceHero } from './AgentWorkspaceHero'
|
||||
export { AgentChatStickyBar } from './AgentChatStickyBar'
|
||||
export { useAgentChat } from './useAgentChat'
|
||||
export { ObjectDeepLink } from './ObjectDeepLink'
|
||||
|
||||
// Personalverwaltung
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import { useCallback, useEffect, useRef } from 'react'
|
||||
import type { FocusEvent, KeyboardEvent } from 'react'
|
||||
import { useAgentChatStore } from '../../stores/agentChatStore'
|
||||
import { useToastStore } from '../../stores/toastStore'
|
||||
|
||||
/**
|
||||
* Markiert beide Eingabefelder des Agentenchats. Wandert der Fokus vom einen
|
||||
* zum anderen, ist das ein Zustandswechsel und kein Verlassen des Chats — ohne
|
||||
* diese Markierung löschte das `blur` des alten Feldes die Fokusnotiz, die das
|
||||
* neue Feld gerade braucht.
|
||||
*/
|
||||
export const AGENT_CHAT_MARKER = 'data-agent-chat'
|
||||
|
||||
interface Options {
|
||||
/**
|
||||
* Ob diese Darstellung gerade die sichtbare ist. Beide Zustände hängen
|
||||
* gleichzeitig im Baum; nur der sichtbare darf den Fokus an sich ziehen.
|
||||
*/
|
||||
active?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Gemeinsame Chatlogik für den ausführlichen und den kompakten Zustand
|
||||
* (Runde 5, §5).
|
||||
*
|
||||
* Beide Darstellungen senden dieselbe Nachricht, reagieren gleich auf Enter und
|
||||
* reichen den Fokus über den Zustandswechsel hinweg weiter. Läge das in beiden
|
||||
* Komponenten, wäre jede Korrektur zweimal zu machen — und beim zweiten Mal
|
||||
* vergessen.
|
||||
*
|
||||
* Das Absenden bleibt Frontend-Simulation: es existiert kein Chat-Backend, und
|
||||
* eine erfundene Antwort wäre schlimmer als eine ehrliche Rückmeldung.
|
||||
*/
|
||||
export function useAgentChat({ active = true }: Options = {}) {
|
||||
const agent = useAgentChatStore(s => s.agent)
|
||||
const draft = useAgentChatStore(s => s.draft)
|
||||
const focused = useAgentChatStore(s => s.focused)
|
||||
const setDraft = useAgentChatStore(s => s.setDraft)
|
||||
const setFocused = useAgentChatStore(s => s.setFocused)
|
||||
const showToast = useToastStore(s => s.showToast)
|
||||
|
||||
const inputRef = useRef<HTMLInputElement | HTMLTextAreaElement>(null)
|
||||
|
||||
/**
|
||||
* Wer beim Scrollen mitten im Satz ist, tippt nach dem Zustandswechsel
|
||||
* weiter — im jeweils sichtbaren Feld, mit dem Cursor am Ende des Textes.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!active || !focused) return
|
||||
const el = inputRef.current
|
||||
if (!el || document.activeElement === el) return
|
||||
el.focus()
|
||||
const end = el.value.length
|
||||
el.setSelectionRange(end, end)
|
||||
}, [active, focused])
|
||||
|
||||
const send = useCallback(() => {
|
||||
const text = draft.trim()
|
||||
if (!text || !agent) return
|
||||
setDraft('')
|
||||
showToast(
|
||||
`Nachricht an ${agent.name} vorgemerkt — der Chat ist in dieser Demo noch nicht angebunden.`,
|
||||
'info',
|
||||
)
|
||||
}, [draft, agent, setDraft, showToast])
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent<HTMLDivElement>) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
send()
|
||||
}
|
||||
},
|
||||
[send],
|
||||
)
|
||||
|
||||
const onBlur = useCallback(
|
||||
(e: FocusEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||
// Fokus wandert zum anderen Chatfeld → der Nutzer ist weiterhin im Chat.
|
||||
const next = e.relatedTarget
|
||||
if (next instanceof HTMLElement && next.closest(`[${AGENT_CHAT_MARKER}]`)) return
|
||||
// Das Feld verschwindet gerade aus dem Baum → Folge des Zustandswechsels,
|
||||
// keine Entscheidung des Nutzers.
|
||||
if (!e.currentTarget.isConnected) return
|
||||
setFocused(false)
|
||||
},
|
||||
[setFocused],
|
||||
)
|
||||
|
||||
return {
|
||||
agent,
|
||||
draft,
|
||||
inputRef,
|
||||
setDraft,
|
||||
send,
|
||||
handleKeyDown,
|
||||
onFocus: useCallback(() => setFocused(true), [setFocused]),
|
||||
onBlur,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Property On — aktuelle Auslastung eines digitalen Mitarbeitenden.
|
||||
*
|
||||
* Die Belegschaftsansicht der Startseite zeigt nicht nur, *wer* für einen
|
||||
* arbeitet, sondern *woran gerade*. Ohne diese Zahl wäre die Szene Dekoration;
|
||||
* mit ihr ist sie eine Lageübersicht — genau der Anspruch aus CLAUDE.md §1:
|
||||
* jeder Bildschirm ist um eine Entscheidung herum gebaut.
|
||||
*
|
||||
* Die Werte sind abgeleitet, nie gespeichert (CLAUDE.md §5.5): sie entstehen
|
||||
* aus den bestehenden Beständen an Fristen, Terminen, Leads und Objekten.
|
||||
*/
|
||||
|
||||
/** Dringlichkeit — steuert allein die Farbe, nie die Aussage. */
|
||||
export const WorkloadTone = {
|
||||
/** Nichts Auffälliges. */
|
||||
CALM: 'CALM',
|
||||
/** Sollte diese Woche angeschaut werden. */
|
||||
ATTENTION: 'ATTENTION',
|
||||
/** Überfällig oder kritisch. */
|
||||
URGENT: 'URGENT',
|
||||
} as const
|
||||
export type WorkloadTone = typeof WorkloadTone[keyof typeof WorkloadTone]
|
||||
|
||||
export interface AgentWorkload {
|
||||
/** Agenten-ID, identisch mit `AGENT_PHOTOS` und der Sitzordnung. */
|
||||
agentId: string
|
||||
/** Die Zahl selbst — für Sortierung und Tests. */
|
||||
count: number
|
||||
/**
|
||||
* Vollständiger Satz für die Plakette, z. B. «3 Fristen diese Woche».
|
||||
* Bewusst inklusive Zahl: eine nackte Ziffer neben einem Kopf sagt nichts.
|
||||
*/
|
||||
label: string
|
||||
tone: WorkloadTone
|
||||
/**
|
||||
* Derselbe Sachverhalt in der ersten Person — das, was der Agent dazu sagen
|
||||
* würde. Digitale Mitarbeitende sollen als Personal auftreten, nicht als
|
||||
* Kennzahl: «Bei 12 Fristen ist der Termin durch» ist dieselbe Information
|
||||
* wie «12 überfällig», aber es spricht jemand.
|
||||
*/
|
||||
voice: string
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { AssetType } from './enums'
|
||||
|
||||
/**
|
||||
* Lead aus dem angebundenen CRM (Runde 5, §12).
|
||||
*
|
||||
* Dritter Kanal neben KI-Signalen und Netzwerk-Hinweisen. Fachlich der am
|
||||
* besten belegte der drei: hier hat ein Mensch bereits mit dem Interessenten
|
||||
* gesprochen, es gibt einen Ansprechpartner, einen Bearbeitungsstand und ein
|
||||
* Datum des letzten Kontakts.
|
||||
*
|
||||
* Eigener Typ statt einer Erweiterung von `MarktHinweis`: ein CRM-Lead trägt
|
||||
* einen Verkaufstrichter und eine Zuständigkeit, ein Netzwerk-Hinweis eine
|
||||
* Sichtbarkeit und eine Quelle. Zwei Bedeutungen in einem Feld wären der
|
||||
* Anfang jeder späteren Verwechslung.
|
||||
*/
|
||||
|
||||
/** Stand im Verkaufstrichter des CRM. */
|
||||
export const CrmLeadStage = {
|
||||
NEW: 'NEW',
|
||||
QUALIFIED: 'QUALIFIED',
|
||||
VIEWING: 'VIEWING',
|
||||
NEGOTIATION: 'NEGOTIATION',
|
||||
} as const
|
||||
export type CrmLeadStage = typeof CrmLeadStage[keyof typeof CrmLeadStage]
|
||||
|
||||
export const CRM_LEAD_STAGE_LABELS: Record<CrmLeadStage, string> = {
|
||||
[CrmLeadStage.NEW]: 'Neu erfasst',
|
||||
[CrmLeadStage.QUALIFIED]: 'Qualifiziert',
|
||||
[CrmLeadStage.VIEWING]: 'Besichtigung geplant',
|
||||
[CrmLeadStage.NEGOTIATION]: 'In Verhandlung',
|
||||
}
|
||||
|
||||
export interface CrmLead {
|
||||
id: string
|
||||
companyName: string
|
||||
contactPerson: string
|
||||
contactEmail?: string
|
||||
contactPhone?: string
|
||||
locationHint: string
|
||||
assetType: AssetType
|
||||
areaSqmMin?: number
|
||||
areaSqmMax?: number
|
||||
stage: CrmLeadStage
|
||||
/** Zuständiger Bewirtschafter laut CRM. */
|
||||
ownerName: string
|
||||
/** Name des Quellsystems — die Herkunft bleibt bis zur Anzeige nachvollziehbar. */
|
||||
crmSystem: string
|
||||
/** ISO-Zeitstempel des letzten dokumentierten Kontakts. */
|
||||
lastContactAt: string
|
||||
note?: string
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { MarktHinweis } from './marktHinweis'
|
||||
import type { CrmLead } from './crmLead'
|
||||
import type { FutureSignal } from './futureSignal'
|
||||
import type { Property } from './property'
|
||||
|
||||
/**
|
||||
* Kanalherkunft eines Leads (Runde 5, §12).
|
||||
*
|
||||
* Ausdrücklich Teil des Datenmodells und nicht der Darstellung: der Kanal
|
||||
* entscheidet über Badge, Filter und Detailansicht. Aus der Darstellung
|
||||
* zurückgeraten — «hat eine Signal-ID, also KI» — bräche beim ersten neuen
|
||||
* Kanal, und niemand fände die Stelle.
|
||||
*/
|
||||
export const LeadChannel = {
|
||||
KI_SIGNAL: 'KI_SIGNAL',
|
||||
NETZWERK: 'NETZWERK',
|
||||
CRM: 'CRM',
|
||||
} as const
|
||||
export type LeadChannel = typeof LeadChannel[keyof typeof LeadChannel]
|
||||
|
||||
/** Reihenfolge im Filterbalken — verbindlich (§13). */
|
||||
export const LEAD_CHANNEL_ORDER: LeadChannel[] = [
|
||||
LeadChannel.KI_SIGNAL,
|
||||
LeadChannel.NETZWERK,
|
||||
LeadChannel.CRM,
|
||||
]
|
||||
|
||||
/** Zusätzliche Filterstellung «Alle»; sie ist kein Kanal, sondern deren Abwesenheit. */
|
||||
export const LEAD_CHANNEL_ALL = 'ALL'
|
||||
export type LeadChannelFilter = LeadChannel | typeof LEAD_CHANNEL_ALL
|
||||
|
||||
/**
|
||||
* Anzeigefelder, die jeder Lead unabhängig vom Kanal besitzt. Ohne sie müsste
|
||||
* jede Zeile der gemeinsamen Liste den Kanal abfragen, bevor sie überhaupt
|
||||
* einen Titel anzeigen könnte.
|
||||
*/
|
||||
interface UnifiedLeadBase {
|
||||
id: string
|
||||
channel: LeadChannel
|
||||
/** Wer — Firma oder Ort, wenn keine Firma bekannt ist. */
|
||||
title: string
|
||||
/** Worum es geht. */
|
||||
subtitle: string
|
||||
/** Ort und eine kanaltypische Zusatzangabe. */
|
||||
meta: string
|
||||
/** ISO-Zeitstempel; einzige gemeinsame Grundlage für die Sortierung. */
|
||||
receivedAt: string
|
||||
}
|
||||
|
||||
export type UnifiedLead =
|
||||
| (UnifiedLeadBase & {
|
||||
channel: typeof LeadChannel.KI_SIGNAL
|
||||
signal: FutureSignal
|
||||
matchingProperties: Property[]
|
||||
})
|
||||
| (UnifiedLeadBase & { channel: typeof LeadChannel.NETZWERK; hinweis: MarktHinweis })
|
||||
| (UnifiedLeadBase & { channel: typeof LeadChannel.CRM; crmLead: CrmLead })
|
||||
@@ -0,0 +1,36 @@
|
||||
import { useMemo } from 'react'
|
||||
import { useReminders } from './useReminders'
|
||||
import { useVisitAssignments } from './useVisitAssignments'
|
||||
import { useExposeLeads } from './useExposeLeads'
|
||||
import { useUnifiedLeads } from './useUnifiedLeads'
|
||||
import { useProperties } from './useProperties'
|
||||
import { computeAgentWorkload } from '../services/agentWorkloadService'
|
||||
import type { AgentWorkload } from '../domain/agentWorkload'
|
||||
|
||||
/**
|
||||
* Aktuelle Auslastung der fünf digitalen Mitarbeitenden (Runde 5).
|
||||
*
|
||||
* Der Hook sammelt nur die Bestände ein; was daraus wird, entscheidet
|
||||
* `agentWorkloadService` — Schwellen, Zeitfenster und Formulierungen sind
|
||||
* fachliche Festlegungen und gehören nicht in die Oberflächenschicht
|
||||
* (CLAUDE.md §4.2/§5.2).
|
||||
*/
|
||||
export function useAgentWorkload(): { data: Record<string, AgentWorkload>; isLoading: boolean } {
|
||||
const { data: reminders = [], isLoading: remindersLoading } = useReminders()
|
||||
const { data: visits = [], isLoading: visitsLoading } = useVisitAssignments()
|
||||
const { data: exposeLeads = [], isLoading: exposeLoading } = useExposeLeads()
|
||||
const { data: leads, isLoading: leadsLoading } = useUnifiedLeads()
|
||||
const { data: properties = [], isLoading: propertiesLoading } = useProperties()
|
||||
|
||||
const data = useMemo(
|
||||
() => computeAgentWorkload({ reminders, visits, exposeLeads, leads, properties }),
|
||||
[reminders, visits, exposeLeads, leads, properties],
|
||||
)
|
||||
|
||||
return {
|
||||
data,
|
||||
isLoading:
|
||||
remindersLoading || visitsLoading || exposeLoading ||
|
||||
leadsLoading || propertiesLoading,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { crmLeadService } from '../services/crmLeadService'
|
||||
import { STALE_CRM_LEADS } from '../lib/constants'
|
||||
|
||||
export function useCrmLeads() {
|
||||
return useQuery({
|
||||
queryKey: ['crmLeads'],
|
||||
queryFn: () => crmLeadService.getAll(),
|
||||
staleTime: STALE_CRM_LEADS,
|
||||
})
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { dashboardService } from '../services/dashboardService'
|
||||
import type { DashboardData } from '../domain/dashboard'
|
||||
|
||||
export function useSupplyDashboard() {
|
||||
return useQuery<DashboardData>({
|
||||
queryKey: ['supply', 'dashboard'],
|
||||
queryFn: () => dashboardService.getDashboardData(),
|
||||
staleTime: 30_000,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { useMemo } from 'react'
|
||||
import { useMarketLeads } from './useMarketLeads'
|
||||
import { useMarktHinweise } from './useMarktHinweise'
|
||||
import { useCrmLeads } from './useCrmLeads'
|
||||
import { LeadChannel } from '../domain/unifiedLead'
|
||||
import type { UnifiedLead } from '../domain/unifiedLead'
|
||||
import type { MarktHinweis } from '../domain/marktHinweis'
|
||||
import type { CrmLead } from '../domain/crmLead'
|
||||
import { CRM_LEAD_STAGE_LABELS } from '../domain/crmLead'
|
||||
import { ASSET_TYPE_LABELS } from '../lib/constants'
|
||||
import type { MarketLead } from './useMarketLeads'
|
||||
|
||||
/**
|
||||
* Zusammengeführte Leadliste aller drei Kanäle (Runde 5, §12).
|
||||
*
|
||||
* Die drei Quellen bleiben getrennt erfasst — KI-Signale entstehen aus
|
||||
* Marktbeobachtung, Netzwerk-Hinweise aus Gesprächen, CRM-Leads aus dem
|
||||
* Vertriebssystem. Zusammengelegt werden sie erst hier, für die Anzeige, und
|
||||
* jeder Eintrag behält seine Kanalherkunft im Feld `channel`.
|
||||
*
|
||||
* Ein gemeinsamer Provider für alle drei wäre der falsche Weg: die Quellen
|
||||
* haben unterschiedliche Lebenszyklen und werden später von verschiedenen
|
||||
* Systemen bedient.
|
||||
*/
|
||||
|
||||
function areaText(min?: number, max?: number): string | null {
|
||||
if (min != null && max != null && min !== max) return `${min}–${max} m²`
|
||||
if (max != null) return `${max} m²`
|
||||
if (min != null) return `${min} m²`
|
||||
return null
|
||||
}
|
||||
|
||||
function fromMarketLead(lead: MarketLead): UnifiedLead {
|
||||
const { signal } = lead
|
||||
return {
|
||||
id: signal.id,
|
||||
channel: LeadChannel.KI_SIGNAL,
|
||||
title: signal.companyName ?? signal.locationHint,
|
||||
subtitle: signal.title ?? 'Erkanntes Nachfragesignal',
|
||||
meta: `${signal.locationHint} · Horizont ${signal.timeHorizonMonths} Mo.`,
|
||||
receivedAt: signal.updatedAt || signal.createdAt,
|
||||
signal,
|
||||
matchingProperties: lead.matchingProperties,
|
||||
}
|
||||
}
|
||||
|
||||
function fromHinweis(hinweis: MarktHinweis): UnifiedLead {
|
||||
const displayName = !hinweis.isAnonymized && hinweis.companyName ? hinweis.companyName : 'Anonymer Hinweis'
|
||||
const area = areaText(hinweis.areaSqmMin, hinweis.areaSqmMax)
|
||||
const assetLabel = ASSET_TYPE_LABELS[hinweis.assetType] ?? hinweis.assetType
|
||||
return {
|
||||
id: hinweis.id,
|
||||
channel: LeadChannel.NETZWERK,
|
||||
title: displayName,
|
||||
subtitle: hinweis.direction === 'SUCHE' ? `Sucht ${assetLabel}` : `Fläche wird verfügbar — ${assetLabel}`,
|
||||
meta: [hinweis.locationHint, area, hinweis.createdBy].filter(Boolean).join(' · '),
|
||||
receivedAt: hinweis.createdAt,
|
||||
hinweis,
|
||||
}
|
||||
}
|
||||
|
||||
function fromCrmLead(crmLead: CrmLead): UnifiedLead {
|
||||
const area = areaText(crmLead.areaSqmMin, crmLead.areaSqmMax)
|
||||
const assetLabel = ASSET_TYPE_LABELS[crmLead.assetType] ?? crmLead.assetType
|
||||
return {
|
||||
id: crmLead.id,
|
||||
channel: LeadChannel.CRM,
|
||||
title: crmLead.companyName,
|
||||
subtitle: `${CRM_LEAD_STAGE_LABELS[crmLead.stage]} — sucht ${assetLabel}`,
|
||||
meta: [crmLead.locationHint, area, crmLead.ownerName].filter(Boolean).join(' · '),
|
||||
receivedAt: crmLead.lastContactAt,
|
||||
crmLead,
|
||||
}
|
||||
}
|
||||
|
||||
export function useUnifiedLeads(): { data: UnifiedLead[]; isLoading: boolean } {
|
||||
const { data: marketLeads, isLoading: marketLoading } = useMarketLeads()
|
||||
const { data: hinweise = [], isLoading: hinweiseLoading } = useMarktHinweise()
|
||||
const { data: crmLeads = [], isLoading: crmLoading } = useCrmLeads()
|
||||
|
||||
const data = useMemo(
|
||||
() =>
|
||||
[
|
||||
...marketLeads.map(fromMarketLead),
|
||||
...hinweise.map(fromHinweis),
|
||||
...crmLeads.map(fromCrmLead),
|
||||
].sort((a, b) => b.receivedAt.localeCompare(a.receivedAt)),
|
||||
[marketLeads, hinweise, crmLeads],
|
||||
)
|
||||
|
||||
return { data, isLoading: marketLoading || hinweiseLoading || crmLoading }
|
||||
}
|
||||
@@ -33,3 +33,21 @@ export const AGENT_WORKSPACES: AgentWorkspace[] = [
|
||||
export function agentWorkspaceById(id: string): AgentWorkspace | undefined {
|
||||
return AGENT_WORKSPACES.find(a => a.id === id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Thomas — Personalverwalter und Vorgesetzter der fünf Agenten (Runde 6, §7).
|
||||
*
|
||||
* Bewusst nicht Teil von `AGENT_WORKSPACES`: Thomas hat keine eigene
|
||||
* Arbeitsseite, sondern steht für die Adminfläche selbst. Sein Ziel ist «Meine
|
||||
* Agenten» mit den Bereichen Personalverwaltung, Kanäle & Systeme und
|
||||
* Bearbeitungsverlauf. Stünde er in derselben Liste, erschiene er als sechster
|
||||
* Eintrag im Menü und als sechster Mitarbeitender in der Belegschaft — beides
|
||||
* wäre falsch (§13: «Thomas muss nicht zwingend nochmals als normaler
|
||||
* Unteragent dargestellt werden»).
|
||||
*/
|
||||
export const AGENT_SUPERVISOR: AgentWorkspace = {
|
||||
id: 'thomas',
|
||||
name: 'Thomas',
|
||||
role: 'Personalverwalter',
|
||||
path: ROUTES.SUPPLY.TEAM,
|
||||
}
|
||||
@@ -61,13 +61,25 @@ export const STALE_AGENT_CONNECTIONS = 60 * 1000
|
||||
export const STALE_CALENDAR = 60 * 1000
|
||||
export const STALE_EXPOSE_LEADS = 30 * 1000 // aggressive — Weiterleitungen sollen sofort erscheinen
|
||||
export const STALE_VISIT_ASSIGNMENTS = 60 * 1000
|
||||
/** CRM-Leads ändern sich im Quellsystem, nicht hier — eine Minute genügt. */
|
||||
export const STALE_CRM_LEADS = 60 * 1000
|
||||
|
||||
// Route paths — single source of truth
|
||||
export const ROUTES = {
|
||||
HOME: '/',
|
||||
SUPPLY: {
|
||||
DASHBOARD: '/supply/dashboard',
|
||||
/**
|
||||
* Startseite des Arbeitsbereichs (Runde 5, §2.2). Der Pfad bleibt
|
||||
* `/supply/properties`: umbenannt wurde der Menüeintrag, nicht die Route —
|
||||
* jede Objektverlinkung aus den Agentenseiten zeigt weiterhin hierher.
|
||||
*/
|
||||
PROPERTIES: '/supply/properties',
|
||||
/**
|
||||
* Alter Pfad der entfernten Hauptseite «Übersicht» (Runde 5, §2.1). Er
|
||||
* existiert nur noch als Umleitung auf die Startseite, damit Lesezeichen
|
||||
* und Deep Links nicht ins Leere laufen.
|
||||
*/
|
||||
LEGACY_DASHBOARD: '/supply/dashboard',
|
||||
MATCH_CENTER: '/supply/match-center',
|
||||
FUTURE_AVAILABILITY: '/supply/future-availability',
|
||||
DATA_QUALITY: '/supply/data-quality',
|
||||
@@ -258,6 +270,15 @@ export const DATA_QUALITY_LABELS: Record<string, string> = {
|
||||
/** Hauptmenüpunkt und Titel der Agenten-Hauptseite. */
|
||||
export const MY_AGENTS_LABEL = 'Meine Agenten'
|
||||
|
||||
/**
|
||||
* Runde 5, §2.2: Der Menüeintrag heisst «Startseite», die Objektverwaltung
|
||||
* darunter heisst «Meine Objekte». Beide Bezeichnungen zeigen auf dieselbe
|
||||
* Route — getrennt gehalten, weil die Produkttour ausdrücklich unterscheiden
|
||||
* muss zwischen dem Menüeintrag und dem Objektbereich auf der Seite (§8).
|
||||
*/
|
||||
export const HOME_NAV_LABEL = 'Startseite'
|
||||
export const MY_PROPERTIES_LABEL = 'Meine Objekte'
|
||||
|
||||
/**
|
||||
* Die drei Verwaltungsbereiche auf der Hauptseite. Sie liegen im Query-String
|
||||
* (`?section=`) und nicht im Pfad: der Reiterwechsel ist eine Ansichtswahl,
|
||||
@@ -288,6 +309,13 @@ export const AGENT_SECTION_ORDER: AgentSection[] = [
|
||||
/** Einheitliche Frage über dem Eingabefeld jeder Agentenseite (§4). */
|
||||
export const AGENT_CHAT_PROMPT = 'Wie kann ich dir heute weiterhelfen?'
|
||||
|
||||
/**
|
||||
* Kurzform für den kompakten Chat im obersten Balken (Runde 5, §9). Dort steht
|
||||
* die Frage neben Porträt, Name und Eingabefeld in einer Zeile — die lange
|
||||
* Fassung nähme dem Eingabefeld genau den Platz, den es braucht.
|
||||
*/
|
||||
export const AGENT_CHAT_PROMPT_SHORT = 'Wie kann ich dir helfen?'
|
||||
|
||||
// ── Livia — Leads und Exposé (Runde 4, §8) ───────────────────────────────────
|
||||
|
||||
export const EXPOSE_LEAD_TABS = {
|
||||
|
||||
@@ -291,3 +291,20 @@ export const RESULT_TYPE_META: Record<string, { label: string; color: string; bg
|
||||
MAISON_WORK: { label: 'Maison Work', color: '#2d5a8a', bg: 'rgba(45,90,138,0.10)' },
|
||||
FUTURE_AVAILABILITY: { label: 'Zukunftssignal', color: '#5b3f8a', bg: 'rgba(91,63,138,0.10)' },
|
||||
}
|
||||
|
||||
// ── Kanalherkunft der Leads bei Nora (Runde 5, §12) ──────────────────────────
|
||||
|
||||
/**
|
||||
* Badge je Kanal. Nach dem Muster von `RESULT_TYPE_META`: Beschriftung und
|
||||
* Farben stehen zusammen, damit derselbe Kanal in Liste, Filter und Detail nie
|
||||
* unterschiedlich aussieht.
|
||||
*
|
||||
* Der Schlüssel ist `LeadChannel` aus `domain/unifiedLead.ts`; hier steht er
|
||||
* bewusst als `string`, damit die Designschicht nicht auf das Domänenmodell
|
||||
* zeigt — die Abhängigkeit läuft immer nur in die andere Richtung.
|
||||
*/
|
||||
export const LEAD_CHANNEL_META: Record<string, { label: string; color: string; bg: string }> = {
|
||||
KI_SIGNAL: { label: 'KI Signal', color: '#5b3f8a', bg: 'rgba(91,63,138,0.10)' },
|
||||
NETZWERK: { label: 'Netzwerk', color: '#1a7a4a', bg: 'rgba(26,122,74,0.10)' },
|
||||
CRM: { label: 'CRM', color: '#2d5a8a', bg: 'rgba(45,90,138,0.10)' },
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import type { CrmLead } from '../domain/crmLead'
|
||||
import { CrmLeadStage } from '../domain/crmLead'
|
||||
import { AssetType } from '../domain/enums'
|
||||
|
||||
/**
|
||||
* Seed-Daten des CRM-Kanals (Runde 5, §12).
|
||||
*
|
||||
* Bewusst wenige, dafür vollständige Datensätze: ein CRM-Lead ohne
|
||||
* Ansprechpartner und ohne letzten Kontakt wäre keiner. Orte und Flächen
|
||||
* liegen im Raum Basel und Nordwestschweiz, passend zum übrigen Bestand.
|
||||
*/
|
||||
export const crmLeads: CrmLead[] = [
|
||||
{
|
||||
id: 'crm-001',
|
||||
companyName: 'Rheinblick Treuhand AG',
|
||||
contactPerson: 'Andrea Vogt',
|
||||
contactEmail: 'a.vogt@rheinblick-treuhand.ch',
|
||||
contactPhone: '+41 61 271 44 18',
|
||||
locationHint: 'Basel, Innenstadt',
|
||||
assetType: AssetType.OFFICE,
|
||||
areaSqmMin: 280,
|
||||
areaSqmMax: 420,
|
||||
stage: CrmLeadStage.NEGOTIATION,
|
||||
ownerName: 'Thomas Müller',
|
||||
crmSystem: 'Salesforce',
|
||||
lastContactAt: '2026-08-04T09:15:00.000Z',
|
||||
note: 'Mietvertrag am jetzigen Standort läuft Ende März 2027 aus. Zwei Objekte besichtigt, Zollstrasse favorisiert.',
|
||||
},
|
||||
{
|
||||
id: 'crm-002',
|
||||
companyName: 'Nordwest Dental Gruppe',
|
||||
contactPerson: 'Dr. Marc Bühler',
|
||||
contactEmail: 'buehler@nw-dental.ch',
|
||||
locationHint: 'Basel, Kleinbasel',
|
||||
assetType: AssetType.RETAIL,
|
||||
areaSqmMin: 180,
|
||||
areaSqmMax: 260,
|
||||
stage: CrmLeadStage.VIEWING,
|
||||
ownerName: 'Sandra Keller',
|
||||
crmSystem: 'Salesforce',
|
||||
lastContactAt: '2026-08-01T14:40:00.000Z',
|
||||
note: 'Sucht Erdgeschossfläche mit Schaufenster für zweite Praxis. Besichtigung für KW 33 angefragt.',
|
||||
},
|
||||
{
|
||||
id: 'crm-003',
|
||||
companyName: 'Altmann Logistik AG',
|
||||
contactPerson: 'Peter Altmann',
|
||||
contactPhone: '+41 61 811 09 62',
|
||||
locationHint: 'Pratteln',
|
||||
assetType: AssetType.LOGISTICS,
|
||||
areaSqmMin: 1800,
|
||||
areaSqmMax: 3200,
|
||||
stage: CrmLeadStage.QUALIFIED,
|
||||
ownerName: 'Thomas Müller',
|
||||
crmSystem: 'HubSpot',
|
||||
lastContactAt: '2026-07-28T07:55:00.000Z',
|
||||
note: 'Expansion aus Muttenz. Rampe und 24h-Zufahrt zwingend.',
|
||||
},
|
||||
{
|
||||
id: 'crm-004',
|
||||
companyName: 'Studio Helle Räume GmbH',
|
||||
contactPerson: 'Livia Sommer',
|
||||
contactEmail: 'kontakt@hellraeume.ch',
|
||||
locationHint: 'Basel, Gundeldingen',
|
||||
assetType: AssetType.OFFICE,
|
||||
areaSqmMin: 90,
|
||||
areaSqmMax: 160,
|
||||
stage: CrmLeadStage.NEW,
|
||||
ownerName: 'Sandra Keller',
|
||||
crmSystem: 'HubSpot',
|
||||
lastContactAt: '2026-07-24T16:05:00.000Z',
|
||||
note: 'Anfrage über Kontaktformular. Budget noch offen.',
|
||||
},
|
||||
{
|
||||
id: 'crm-005',
|
||||
companyName: 'Birsfelden Manufaktur AG',
|
||||
contactPerson: 'Jonas Wyss',
|
||||
contactEmail: 'j.wyss@birsfelden-manufaktur.ch',
|
||||
contactPhone: '+41 61 313 27 40',
|
||||
locationHint: 'Birsfelden',
|
||||
assetType: AssetType.LIGHT_INDUSTRIAL,
|
||||
areaSqmMin: 640,
|
||||
areaSqmMax: 900,
|
||||
stage: CrmLeadStage.QUALIFIED,
|
||||
ownerName: 'Thomas Müller',
|
||||
crmSystem: 'Salesforce',
|
||||
lastContactAt: '2026-07-19T11:20:00.000Z',
|
||||
},
|
||||
]
|
||||
@@ -1,196 +1,126 @@
|
||||
import { useState, useMemo } from 'react'
|
||||
import { Box, Chip, Skeleton, Tab, Tabs, Typography, Button } from '@mui/material'
|
||||
import { Plus, Sparkles, Users } from 'lucide-react'
|
||||
import { useMarketLeads } from '../../hooks/useMarketLeads'
|
||||
import { useMarktHinweise } from '../../hooks/useMarktHinweise'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { Box, Drawer, Typography, useMediaQuery, useTheme } from '@mui/material'
|
||||
import { AgentWorkspaceHero } from '../../components/team'
|
||||
import { LeadDetail, LeadListItem } from '../../components/market-leads'
|
||||
import { HinweisDetail, HinweisErfassenDialog, HinweisListItem } from '../../components/markt-hinweise'
|
||||
import {
|
||||
CrmLeadDetail,
|
||||
LeadChannelFilterBar,
|
||||
LeadDetail,
|
||||
UnifiedLeadList,
|
||||
} from '../../components/market-leads'
|
||||
import { HinweisDetail, HinweisErfassenDialog } from '../../components/markt-hinweise'
|
||||
import { useUnifiedLeads } from '../../hooks/useUnifiedLeads'
|
||||
import { LEAD_CHANNEL_ALL, LEAD_CHANNEL_ORDER, LeadChannel } from '../../domain/unifiedLead'
|
||||
import type { LeadChannelFilter, UnifiedLead } from '../../domain/unifiedLead'
|
||||
import { agentWorkspaceById } from '../../lib/agentWorkspaces'
|
||||
import { DS_ACCENT, DS_BRAND, DS_NEUTRAL } from '../../lib/ds'
|
||||
import { DS_TEXT } from '../../lib/ds'
|
||||
|
||||
const NORA = agentWorkspaceById('nora')!
|
||||
|
||||
// ── Page ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Nora — Marktchancen und Leads (Runde 4, §7; Runde 5, §11–§13).
|
||||
*
|
||||
* Die Seite führt seit Runde 5 eine einzige Leadliste über alle drei Kanäle
|
||||
* statt zweier Reiterlisten. Jede Zeile trägt ihre Kanalherkunft, der frühere
|
||||
* Reiterbalken ist zum Filter «Kanaltyp» geworden.
|
||||
*
|
||||
* Der Aufbau ist zugleich die Reparatur der Scroll-Navigation (§11): vorher
|
||||
* lag der Chat-Einstieg ausserhalb des Scrollbereichs und zwei Spalten
|
||||
* scrollten getrennt darunter — deshalb wanderte Noras Chat als einziger nie
|
||||
* in den sticky Zustand. Jetzt scrollt die Seite wie bei allen anderen Agenten
|
||||
* als Ganzes, und das Detail liegt in einer Schublade statt in einer zweiten
|
||||
* Spalte. Das ist keine Nora-Sonderlösung, sondern dasselbe Muster wie bei
|
||||
* Sina und in «Meine Objekte».
|
||||
*/
|
||||
export default function MarketIntelligence() {
|
||||
const [activeTab, setActiveTab] = useState(0)
|
||||
const [chosenSignalId, setChosenSignalId] = useState<string | null>(null)
|
||||
const [chosenHinweisId, setChosenHinweisId] = useState<string | null>(null)
|
||||
const theme = useTheme()
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down('md'))
|
||||
|
||||
const [channel, setChannel] = useState<LeadChannelFilter>(LEAD_CHANNEL_ALL)
|
||||
const [selected, setSelected] = useState<UnifiedLead | null>(null)
|
||||
const [erfassenOpen, setErfassenOpen] = useState(false)
|
||||
const [hinweisFilter, setHinweisFilter] = useState<'ALL' | 'INTERN' | 'PLATTFORM'>('ALL')
|
||||
|
||||
const { data: leads, isLoading: leadsLoading } = useMarketLeads()
|
||||
const { data: hinweise = [], isLoading: hinweiseLoading } = useMarktHinweise()
|
||||
const { data: leads, isLoading } = useUnifiedLeads()
|
||||
|
||||
const filteredHinweise = useMemo(() => {
|
||||
if (hinweisFilter === 'ALL') return hinweise
|
||||
return hinweise.filter(h => h.visibility === hinweisFilter)
|
||||
}, [hinweise, hinweisFilter])
|
||||
// Zählung und Filterung in einem Durchgang über dieselbe Liste (CLAUDE.md §10.4).
|
||||
const { visible, counts } = useMemo(() => {
|
||||
const tally: Record<LeadChannelFilter, number> = {
|
||||
[LEAD_CHANNEL_ALL]: leads.length,
|
||||
[LeadChannel.KI_SIGNAL]: 0,
|
||||
[LeadChannel.NETZWERK]: 0,
|
||||
[LeadChannel.CRM]: 0,
|
||||
}
|
||||
for (const lead of leads) tally[lead.channel] += 1
|
||||
return {
|
||||
counts: tally,
|
||||
visible: channel === LEAD_CHANNEL_ALL ? leads : leads.filter(l => l.channel === channel),
|
||||
}
|
||||
}, [leads, channel])
|
||||
|
||||
// Ohne eigene Wahl steht der erste Eintrag offen. Abgeleitet statt in einem
|
||||
// Effekt nachgetragen: der Effekt rief `setState` beim ersten Rendern auf und
|
||||
// erzwang damit einen zweiten Durchgang, in dem die Detailspalte noch leer war.
|
||||
const selectedSignalId = chosenSignalId ?? leads[0]?.signal.id ?? null
|
||||
const selectedHinweisId = chosenHinweisId ?? filteredHinweise[0]?.id ?? null
|
||||
const resetFilter = useCallback(() => setChannel(LEAD_CHANNEL_ALL), [])
|
||||
const closeDetail = useCallback(() => setSelected(null), [])
|
||||
|
||||
const selectedLead = leads.find(l => l.signal.id === selectedSignalId) ?? null
|
||||
const selectedHinweis = hinweise.find(h => h.id === selectedHinweisId) ?? null
|
||||
// Der neue Hinweis soll sofort auffindbar sein — auch wenn gerade nach einem
|
||||
// anderen Kanal gefiltert wird.
|
||||
const handleCreated = useCallback(() => {
|
||||
setErfassenOpen(false)
|
||||
setChannel(LEAD_CHANNEL_ALL)
|
||||
setSelected(null)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', minHeight: '100%' }}>
|
||||
{/* Chat-Einstieg statt Seitentitel und Beschreibung (Runde 4, §7.1).
|
||||
Beispielnutzung: eigene Netzwerkleads mitteilen, aktuelle Leads für
|
||||
einen Ort erfragen. */}
|
||||
<Box sx={{ flexShrink: 0 }}>
|
||||
<AgentWorkspaceHero agentId={NORA.id} name={NORA.name} role={NORA.role} />
|
||||
Er scrollt jetzt mit und wechselt dabei in den sticky Zustand (§11). */}
|
||||
<AgentWorkspaceHero agentId={NORA.id} name={NORA.name} role={NORA.role} />
|
||||
|
||||
<LeadChannelFilterBar
|
||||
value={channel}
|
||||
onChange={setChannel}
|
||||
counts={counts}
|
||||
onCreateHinweis={() => setErfassenOpen(true)}
|
||||
/>
|
||||
|
||||
<Box sx={{ px: 3, py: 2.5, display: 'flex', flexDirection: 'column', gap: 1.5 }}>
|
||||
<Typography variant="caption" sx={{ color: DS_TEXT.muted }}>
|
||||
{visible.length === leads.length
|
||||
? `${leads.length} Leads aus ${LEAD_CHANNEL_ORDER.length} Kanälen`
|
||||
: `${visible.length} von ${leads.length} Leads`}
|
||||
</Typography>
|
||||
|
||||
<UnifiedLeadList
|
||||
leads={visible}
|
||||
isLoading={isLoading}
|
||||
selectedId={selected?.id ?? null}
|
||||
onSelect={setSelected}
|
||||
onResetFilter={channel === LEAD_CHANNEL_ALL ? undefined : resetFilter}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Tabs */}
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onChange={(_, v: number) => setActiveTab(v)}
|
||||
sx={{
|
||||
borderBottom: '1px solid #e2e8f0',
|
||||
flexShrink: 0,
|
||||
bgcolor: 'white',
|
||||
'& .MuiTab-root': { textTransform: 'none', fontSize: '0.85rem', minHeight: 44, px: 3 },
|
||||
{/* Detailnavigation: pro Kanal die passende Ansicht, in einer Schublade. */}
|
||||
<Drawer
|
||||
anchor="right"
|
||||
open={!!selected}
|
||||
onClose={closeDetail}
|
||||
slotProps={{
|
||||
paper: {
|
||||
sx: {
|
||||
width: isMobile ? '100vw' : { md: 520, lg: 600, xl: 660 },
|
||||
boxShadow: '-4px 0 24px rgba(0,0,0,0.10)',
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Tab icon={<Sparkles size={14} />} iconPosition="start" label="KI-Signale" />
|
||||
<Tab icon={<Users size={14} />} iconPosition="start" label="Netzwerk" />
|
||||
</Tabs>
|
||||
{selected?.channel === LeadChannel.KI_SIGNAL && (
|
||||
<LeadDetail lead={{ signal: selected.signal, matchingProperties: selected.matchingProperties }} />
|
||||
)}
|
||||
{selected?.channel === LeadChannel.NETZWERK && <HinweisDetail hinweis={selected.hinweis} />}
|
||||
{selected?.channel === LeadChannel.CRM && <CrmLeadDetail lead={selected.crmLead} />}
|
||||
</Drawer>
|
||||
|
||||
{/* Tab 0: KI-Signale */}
|
||||
{activeTab === 0 && (
|
||||
<Box sx={{ display: 'flex', flex: 1, overflow: 'hidden' }}>
|
||||
{/* Left pane */}
|
||||
<Box sx={{ width: 360, flexShrink: 0, borderRight: '1px solid #e2e8f0', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
||||
<Box sx={{ px: 2, py: 1.25, borderBottom: '1px solid #e2e8f0', display: 'flex', alignItems: 'center', gap: 1, flexShrink: 0 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, flex: 1 }}>Erkannte Signale</Typography>
|
||||
<Chip label={leadsLoading ? '…' : leads.length} size="small" sx={{ bgcolor: DS_BRAND.main, color: DS_NEUTRAL.white, fontSize: '0.7rem', height: 20 }} />
|
||||
</Box>
|
||||
<Box sx={{ flex: 1, overflowY: 'auto' }}>
|
||||
{leadsLoading
|
||||
? [1, 2, 3, 4].map(i => (
|
||||
<Box key={i} sx={{ px: 2, py: 1.5, borderBottom: '1px solid #f1f5f9' }}>
|
||||
<Skeleton variant="text" width="55%" sx={{ mb: 0.25 }} />
|
||||
<Skeleton variant="text" width="80%" height={14} />
|
||||
<Skeleton variant="text" width="40%" height={12} />
|
||||
</Box>
|
||||
))
|
||||
: leads.map(lead => (
|
||||
<LeadListItem
|
||||
key={lead.signal.id}
|
||||
lead={lead}
|
||||
selected={lead.signal.id === selectedSignalId}
|
||||
onClick={() => setChosenSignalId(lead.signal.id)}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Right pane */}
|
||||
<Box sx={{ flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
|
||||
{selectedLead ? (
|
||||
<LeadDetail lead={selectedLead} />
|
||||
) : (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%' }}>
|
||||
<Typography variant="body2" color="text.disabled">Signal aus der Liste auswählen</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Tab 1: Netzwerk */}
|
||||
{activeTab === 1 && (
|
||||
<Box sx={{ display: 'flex', flex: 1, overflow: 'hidden' }}>
|
||||
{/* Left pane */}
|
||||
<Box sx={{ width: 360, flexShrink: 0, borderRight: '1px solid #e2e8f0', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
||||
<Box sx={{ px: 2, py: 1.25, borderBottom: '1px solid #e2e8f0', flexShrink: 0 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, flex: 1 }}>Netzwerk-Hinweise</Typography>
|
||||
<Chip
|
||||
label={hinweiseLoading ? '…' : filteredHinweise.length}
|
||||
size="small"
|
||||
sx={{ bgcolor: DS_ACCENT.violet.main, color: DS_NEUTRAL.white, fontSize: '0.7rem', height: 20 }}
|
||||
/>
|
||||
<Button
|
||||
size="small"
|
||||
variant="contained"
|
||||
startIcon={<Plus size={13} />}
|
||||
onClick={() => setErfassenOpen(true)}
|
||||
sx={{
|
||||
textTransform: 'none', fontSize: '0.72rem',
|
||||
bgcolor: DS_BRAND.main, '&:hover': { bgcolor: DS_BRAND.hoverAlt },
|
||||
minWidth: 'auto',
|
||||
}}
|
||||
>
|
||||
Erfassen
|
||||
</Button>
|
||||
</Box>
|
||||
{/* Filter chips */}
|
||||
<Box sx={{ display: 'flex', gap: 0.5 }}>
|
||||
{(['ALL', 'INTERN', 'PLATTFORM'] as const).map(f => (
|
||||
<Chip
|
||||
key={f}
|
||||
label={f === 'ALL' ? 'Alle' : f === 'INTERN' ? 'Intern' : 'Plattform'}
|
||||
size="small"
|
||||
onClick={() => setHinweisFilter(f)}
|
||||
sx={{
|
||||
height: 22, fontSize: '0.68rem', cursor: 'pointer',
|
||||
bgcolor: hinweisFilter === f ? '#152642' : '#f1f5f9',
|
||||
color: hinweisFilter === f ? 'white' : '#475569',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* List */}
|
||||
<Box sx={{ flex: 1, overflowY: 'auto' }}>
|
||||
{hinweiseLoading
|
||||
? [1, 2, 3].map(i => (
|
||||
<Box key={i} sx={{ px: 2, py: 1.5, borderBottom: '1px solid #f1f5f9' }}>
|
||||
<Skeleton variant="text" width="60%" />
|
||||
<Skeleton variant="text" width="80%" height={12} />
|
||||
</Box>
|
||||
))
|
||||
: filteredHinweise.map(h => (
|
||||
<HinweisListItem
|
||||
key={h.id}
|
||||
hinweis={h}
|
||||
selected={h.id === selectedHinweisId}
|
||||
onClick={() => setChosenHinweisId(h.id)}
|
||||
/>
|
||||
))
|
||||
}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Right pane */}
|
||||
<Box sx={{ flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
|
||||
{selectedHinweis ? (
|
||||
<HinweisDetail hinweis={selectedHinweis} />
|
||||
) : (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%' }}>
|
||||
<Typography variant="body2" color="text.disabled">Hinweis aus der Liste auswählen</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Dialog */}
|
||||
<HinweisErfassenDialog
|
||||
open={erfassenOpen}
|
||||
onClose={() => setErfassenOpen(false)}
|
||||
onCreated={(id) => {
|
||||
setChosenHinweisId(id)
|
||||
setErfassenOpen(false)
|
||||
setActiveTab(1)
|
||||
}}
|
||||
onCreated={handleCreated}
|
||||
/>
|
||||
</Box>
|
||||
)
|
||||
|
||||
@@ -4,12 +4,13 @@ import { Box, Collapse, Drawer, Typography, useMediaQuery, useTheme } from '@mui
|
||||
import { ChevronDown, ChevronUp } from 'lucide-react'
|
||||
import { PageHeader } from '../../components/layout'
|
||||
import { ViewToggle } from '../../components/shared'
|
||||
import { MeetingRoom3D } from '../../components/staff'
|
||||
import { useProperties } from '../../hooks/useProperties'
|
||||
import { useActiveInquiries } from '../../hooks/useInquiries'
|
||||
import { PropertyFilterBar, PropertyTable, PropertyDetailView, PropertyIntelligenceCard } from '../../components/supply'
|
||||
import type { PropertyTableFilters } from '../../components/supply'
|
||||
import type { Property } from '../../domain/property'
|
||||
import { ROUTES, propertyDetailRoute } from '../../lib/constants'
|
||||
import { MY_PROPERTIES_LABEL, ROUTES, propertyDetailRoute } from '../../lib/constants'
|
||||
import { DS_SLATE } from '../../lib/ds'
|
||||
|
||||
function applyFilters(properties: Property[], filters: PropertyTableFilters): Property[] {
|
||||
@@ -94,9 +95,17 @@ export default function Properties() {
|
||||
const filtered = useMemo(() => applyFilters(properties, filters), [properties, filters])
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
||||
// Runde 6, §1.1/§17: Die Startseite ist die Landing Page und scrollt als
|
||||
// Ganzes. Oben der Meetingraum, darunter «Meine Objekte» mit dem
|
||||
// unveränderten Objektbestand. Der Meetingraum bemisst seine Höhe so, dass
|
||||
// Titel und Filterbalken beim Einstieg gerade noch sichtbar bleiben.
|
||||
// Ein eigener Scrollbereich für die Tabelle wäre ein zweiter Scrollbalken
|
||||
// im selben Bild und würde genau das verhindern.
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', minHeight: '100%' }}>
|
||||
<MeetingRoom3D />
|
||||
|
||||
<PageHeader
|
||||
title="Objektverwaltung"
|
||||
title={MY_PROPERTIES_LABEL}
|
||||
subtitle={`${filtered.length} von ${properties.length} Objekten`}
|
||||
secondaryActions={
|
||||
<ViewToggle
|
||||
@@ -139,7 +148,7 @@ export default function Properties() {
|
||||
<PropertyFilterBar filters={filters} onFiltersChange={setFilters} />
|
||||
</Collapse>
|
||||
|
||||
<Box sx={{ flex: 1, overflowY: 'auto' }}>
|
||||
<Box sx={{ flex: 1 }}>
|
||||
{view === 'grid' ? (
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: CARD_GRID_COLUMNS, gap: 3, p: 3 }}>
|
||||
{filtered.map(p => (
|
||||
|
||||
@@ -1,200 +0,0 @@
|
||||
import { Box, Button, Chip, Divider, Paper, Typography } from '@mui/material'
|
||||
import { ArrowRight, Building2, TrendingUp, Zap } from 'lucide-react'
|
||||
import { useNavigate } from 'react-router'
|
||||
import { useSupplyDashboard } from '../../hooks/useSupplyDashboard'
|
||||
import { useSessionStore } from '../../stores/sessionStore'
|
||||
import { UserRole } from '../../domain/enums'
|
||||
import { DashboardHeader, ReviewTaskWidget, DashboardSkeleton } from '../../components/supply'
|
||||
import { DS_BG, DS_BORDER, DS_SURFACE, DS_TEXT } from '../../lib/ds'
|
||||
|
||||
export default function SupplyDashboard() {
|
||||
const { data, isLoading, isError, refetch } = useSupplyDashboard()
|
||||
const { currentUser } = useSessionStore()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const role = currentUser?.role ?? UserRole.DEMAND_USER
|
||||
const isReviewer = role === UserRole.REVIEWER
|
||||
|
||||
if (isLoading) return <DashboardSkeleton />
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<Box sx={{ p: 6, display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 2 }}>
|
||||
<Typography variant="h6" color="error">Dashboard konnte nicht geladen werden</Typography>
|
||||
<Typography variant="body2" color="text.secondary">Der Service ist vorübergehend nicht verfügbar.</Typography>
|
||||
<Button variant="outlined" onClick={() => refetch()}>Erneut versuchen</Button>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
if (!data || data.totalProperties === 0) {
|
||||
return (
|
||||
<Box sx={{ p: 6, display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 2 }}>
|
||||
<Typography variant="h6">Noch keine Objekte vorhanden</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Fügen Sie Ihr erstes Objekt hinzu — wir matchen es sofort mit aktiven Suchanfragen.
|
||||
</Typography>
|
||||
<Button variant="contained" onClick={() => navigate('/supply/properties')}>
|
||||
Erstes Objekt erfassen
|
||||
</Button>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
const strongMatchCount = data.strongMatchCount ?? 0
|
||||
const topMatches = data.strongMatches ?? []
|
||||
const futureSignalCount = data.futureSignals?.total ?? 0
|
||||
|
||||
return (
|
||||
<Box sx={{ p: 3, display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
<DashboardHeader orgName={currentUser?.organizationName} lastUpdated={data.lastUpdated} />
|
||||
|
||||
{/* ── Hero: Nachfrage-Intelligence ── */}
|
||||
<Paper sx={{ p: 0, overflow: 'hidden', border: `1px solid ${DS_BORDER.default}` }}>
|
||||
|
||||
{/* KPI strip */}
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr 1fr', md: 'repeat(3, 1fr)' } }}>
|
||||
{/* Starke Matches */}
|
||||
<Box sx={{ p: 2.5, borderRight: `1px solid ${DS_BORDER.default}`, bgcolor: strongMatchCount > 0 ? DS_SURFACE.success.bg : 'white' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mb: 0.5 }}>
|
||||
<TrendingUp size={15} color={strongMatchCount > 0 ? DS_TEXT.success : DS_TEXT.muted} />
|
||||
<Typography variant="overline" sx={{ color: strongMatchCount > 0 ? DS_TEXT.success : DS_TEXT.muted, fontWeight: 700, lineHeight: 1 }}>
|
||||
Starke Match-Anfragen
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography variant="h3" sx={{ fontWeight: 800, color: strongMatchCount > 0 ? DS_TEXT.success : DS_TEXT.disabled, lineHeight: 1 }}>
|
||||
{strongMatchCount}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: DS_TEXT.muted }}>
|
||||
qualifizierte Interessenten ≥ 80%
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Aktive Objekte */}
|
||||
<Box sx={{ p: 2.5, borderRight: `1px solid ${DS_BORDER.default}`, cursor: 'pointer', '&:hover': { bgcolor: DS_BG.subtle } }}
|
||||
onClick={() => navigate('/supply/properties')}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mb: 0.5 }}>
|
||||
<Building2 size={15} color={DS_TEXT.muted} />
|
||||
<Typography variant="overline" sx={{ color: DS_TEXT.muted, fontWeight: 700, lineHeight: 1 }}>
|
||||
Aktive Objekte
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography variant="h3" sx={{ fontWeight: 800, color: DS_TEXT.primary, lineHeight: 1 }}>
|
||||
{data.activeProperties}
|
||||
<Typography component="span" variant="h5" sx={{ color: DS_TEXT.muted, fontWeight: 400, ml: 0.5 }}>
|
||||
/ {data.totalProperties}
|
||||
</Typography>
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: DS_TEXT.muted }}>verfügbar · im Bestand</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Zukunftssignale */}
|
||||
<Box sx={{ p: 2.5, bgcolor: futureSignalCount > 0 ? '#faf5ff' : 'white' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mb: 0.5 }}>
|
||||
<Zap size={15} color={futureSignalCount > 0 ? '#7c3aed' : DS_TEXT.muted} />
|
||||
<Typography variant="overline" sx={{ color: futureSignalCount > 0 ? '#7c3aed' : DS_TEXT.muted, fontWeight: 700, lineHeight: 1 }}>
|
||||
Zukunftssignale
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography variant="h3" sx={{ fontWeight: 800, color: futureSignalCount > 0 ? '#7c3aed' : DS_TEXT.disabled, lineHeight: 1 }}>
|
||||
{futureSignalCount}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: DS_TEXT.muted }}>
|
||||
vorqualifizierte Nachfrage-Signale
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* ── Top matches list ── */}
|
||||
{topMatches.length > 0 && (
|
||||
<>
|
||||
<Divider />
|
||||
<Box sx={{ px: 2.5, py: 1.5 }}>
|
||||
<Typography variant="caption" sx={{ fontWeight: 700, color: DS_TEXT.muted, textTransform: 'uppercase', letterSpacing: 0.6 }}>
|
||||
Stärkste Interessenten
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
|
||||
{topMatches.slice(0, 4).map((m, i) => (
|
||||
<Box
|
||||
key={m.matchId}
|
||||
sx={{
|
||||
display: 'flex', alignItems: 'center', gap: 2,
|
||||
px: 2.5, py: 1.25,
|
||||
borderTop: i > 0 ? `1px solid ${DS_BORDER.muted}` : undefined,
|
||||
cursor: 'pointer',
|
||||
'&:hover': { bgcolor: DS_BG.subtle },
|
||||
}}
|
||||
onClick={() => navigate('/supply/match-center')}
|
||||
>
|
||||
<Box sx={{
|
||||
minWidth: 44, height: 36, borderRadius: 1,
|
||||
bgcolor: m.matchScore >= 80 ? DS_SURFACE.success.bg : DS_SURFACE.warning.bg,
|
||||
border: `1px solid ${m.matchScore >= 80 ? DS_SURFACE.success.border : DS_SURFACE.warning.border}`,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}>
|
||||
<Typography sx={{ fontWeight: 800, fontSize: '0.9rem', color: m.matchScore >= 80 ? DS_TEXT.success : DS_TEXT.warning }}>
|
||||
{m.matchScore}%
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }} noWrap>{m.propertyTitle}</Typography>
|
||||
<Typography variant="caption" color="text.secondary" noWrap>{m.topReason}</Typography>
|
||||
</Box>
|
||||
<Chip
|
||||
label={m.nextBestAction}
|
||||
size="small"
|
||||
sx={{ fontSize: '0.65rem', height: 20, bgcolor: DS_BG.subtle, color: DS_TEXT.secondary, flexShrink: 0, maxWidth: 140 }}
|
||||
/>
|
||||
<ArrowRight size={14} color={DS_TEXT.disabled} style={{ flexShrink: 0 }} />
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
<Divider />
|
||||
<Box sx={{ px: 2.5, py: 1.25 }}>
|
||||
<Button
|
||||
size="small"
|
||||
variant="text"
|
||||
onClick={() => navigate('/supply/match-center')}
|
||||
sx={{ textTransform: 'none', color: DS_TEXT.secondary, fontWeight: 500 }}
|
||||
>
|
||||
Alle Matches anzeigen →
|
||||
</Button>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</Paper>
|
||||
|
||||
{/* ── Datenpflege (sekundär, collapsed) ── */}
|
||||
{(data.dataQuality?.critical ?? 0) > 0 && (
|
||||
<Paper sx={{ p: 2, border: `1px solid ${DS_BORDER.default}`, bgcolor: DS_BG.subtle }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Box>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, color: DS_TEXT.secondary }}>
|
||||
{data.dataQuality!.critical} Objekte mit Datenlücken
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Vollständige Daten verbessern die Match-Qualität erheblich.
|
||||
</Typography>
|
||||
</Box>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
onClick={() => navigate('/supply/properties')}
|
||||
sx={{ textTransform: 'none', flexShrink: 0 }}
|
||||
>
|
||||
Datenpflege →
|
||||
</Button>
|
||||
</Box>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{isReviewer && data.reviewTasks !== null && (
|
||||
<ReviewTaskWidget
|
||||
tasks={data.reviewTasks}
|
||||
onNavigate={() => navigate('/ops/review-queue')}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { CrmLead } from '../domain/crmLead'
|
||||
|
||||
export interface ICrmLeadProvider {
|
||||
getAll(): Promise<CrmLead[]>
|
||||
getById(id: string): Promise<CrmLead | null>
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
export interface DashboardStats {
|
||||
totalProperties: number
|
||||
verifiedProperties: number
|
||||
verifiedPortfolioProperties: number
|
||||
futureSignalProperties: number
|
||||
totalMatches: number
|
||||
pendingReviews: number
|
||||
approvedMatches: number
|
||||
activeNeeds: number
|
||||
totalSignals: number
|
||||
verifiedSignals: number
|
||||
averageMatchScore: number
|
||||
highConfidenceMatches: number
|
||||
}
|
||||
|
||||
export interface IDashboardProvider {
|
||||
getStats(organizationId?: string): Promise<DashboardStats>
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { ICrmLeadProvider } from './ICrmLeadProvider'
|
||||
import type { CrmLead } from '../domain/crmLead'
|
||||
import { crmLeads as seed } from '../mock-data/crmLeads'
|
||||
|
||||
/**
|
||||
* Nur lesend: Leads entstehen im CRM, nicht in Property On. Ein `create` hier
|
||||
* wäre eine zweite Wahrheit über denselben Interessenten.
|
||||
*/
|
||||
const store: CrmLead[] = [...seed]
|
||||
|
||||
export const MockupCrmLeadProvider: ICrmLeadProvider = {
|
||||
async getAll() {
|
||||
return [...store]
|
||||
},
|
||||
|
||||
async getById(id: string) {
|
||||
return store.find(l => l.id === id) ?? null
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* Property On — Auslastung der digitalen Belegschaft (Runde 5).
|
||||
*
|
||||
* Die Zahlen auf den Namensschildern der Startseite sind das Einzige, was dort
|
||||
* eine Entscheidung auslöst. Eine falsche Zahl fällt niemandem auf — sie sieht
|
||||
* genauso aus wie eine richtige. Deshalb wird hier festgehalten, was die
|
||||
* fachlichen Festlegungen sind: der Stichzeitpunkt ist ein Parameter, damit
|
||||
* dieselbe Datenlage immer dasselbe Ergebnis liefert.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { computeAgentWorkload } from '../agentWorkloadService'
|
||||
import type { AgentWorkloadInput } from '../agentWorkloadService'
|
||||
import { WorkloadTone } from '../../domain/agentWorkload'
|
||||
import { ReminderStatus } from '../../domain/reminder'
|
||||
import { ExposeLeadStatus } from '../../domain/exposeLead'
|
||||
|
||||
const NOW = Date.parse('2026-08-06T12:00:00.000Z')
|
||||
const DAY = 24 * 60 * 60 * 1000
|
||||
|
||||
function iso(offsetDays: number): string {
|
||||
return new Date(NOW + offsetDays * DAY).toISOString()
|
||||
}
|
||||
|
||||
const EMPTY: AgentWorkloadInput = {
|
||||
reminders: [], visits: [], exposeLeads: [], leads: [], properties: [],
|
||||
}
|
||||
|
||||
function input(partial: Partial<AgentWorkloadInput>): AgentWorkloadInput {
|
||||
return { ...EMPTY, ...partial }
|
||||
}
|
||||
|
||||
// Nur die Felder, die der Dienst liest — der Rest gehört nicht zur Aussage.
|
||||
const reminder = (dueDays: number, status: ReminderStatus = ReminderStatus.ACTIVE) =>
|
||||
({ dueDate: iso(dueDays), status }) as AgentWorkloadInput['reminders'][number]
|
||||
const visit = (days: number) =>
|
||||
({ scheduledAt: iso(days) }) as AgentWorkloadInput['visits'][number]
|
||||
const property = (score: number) =>
|
||||
({ dataQuality: { score } }) as AgentWorkloadInput['properties'][number]
|
||||
|
||||
describe('Auslastung je Agent', () => {
|
||||
it('meldet bei Ferdi Überfälliges vor den Fristen der Woche', () => {
|
||||
const w = computeAgentWorkload(
|
||||
input({ reminders: [reminder(-3), reminder(-1), reminder(2)] }),
|
||||
NOW,
|
||||
).ferdi
|
||||
|
||||
expect(w.count).toBe(2)
|
||||
expect(w.label).toBe('2 Fristen überfällig')
|
||||
expect(w.tone).toBe(WorkloadTone.URGENT)
|
||||
})
|
||||
|
||||
it('zeigt bei Ferdi ohne Überfälliges die Fristen der laufenden Woche', () => {
|
||||
const w = computeAgentWorkload(
|
||||
// 2 Tage und 6 Tage zählen, 20 Tage liegt ausserhalb der Woche.
|
||||
input({ reminders: [reminder(2), reminder(6), reminder(20)] }),
|
||||
NOW,
|
||||
).ferdi
|
||||
|
||||
expect(w.count).toBe(2)
|
||||
expect(w.label).toBe('2 Fristen diese Woche')
|
||||
expect(w.tone).toBe(WorkloadTone.ATTENTION)
|
||||
})
|
||||
|
||||
it('zählt bei Ferdi nur aktive Reminder', () => {
|
||||
const w = computeAgentWorkload(
|
||||
input({ reminders: [reminder(2), reminder(3, ReminderStatus.COMPLETED), reminder(4, ReminderStatus.DISMISSED)] }),
|
||||
NOW,
|
||||
).ferdi
|
||||
|
||||
expect(w.count).toBe(1)
|
||||
})
|
||||
|
||||
it('zählt bei Bruno nur Termine der kommenden sieben Tage', () => {
|
||||
const w = computeAgentWorkload(
|
||||
input({ visits: [visit(-1), visit(1), visit(6), visit(9)] }),
|
||||
NOW,
|
||||
).bruno
|
||||
|
||||
expect(w.count).toBe(2)
|
||||
expect(w.label).toBe('2 Besichtigungen diese Woche')
|
||||
})
|
||||
|
||||
it('zählt bei Sina nur Objekte unter der Datenqualitätsschwelle', () => {
|
||||
const w = computeAgentWorkload(
|
||||
input({ properties: [property(0.2), property(0.55), property(0.6), property(0.9)] }),
|
||||
NOW,
|
||||
).sina
|
||||
|
||||
expect(w.count).toBe(2)
|
||||
expect(w.label).toBe('2 Objekte mit Datenlücken')
|
||||
})
|
||||
|
||||
it('hält Noras Zahl nie für dringend — sie schlägt vor, sie entscheidet nicht', () => {
|
||||
const leads = Array.from({ length: 40 }, () => ({ receivedAt: iso(-1) }))
|
||||
const w = computeAgentWorkload(
|
||||
input({ leads: leads as AgentWorkloadInput['leads'] }),
|
||||
NOW,
|
||||
).nora
|
||||
|
||||
expect(w.count).toBe(40)
|
||||
expect(w.tone).toBe(WorkloadTone.ATTENTION)
|
||||
})
|
||||
|
||||
it('formuliert die Einzahl korrekt', () => {
|
||||
const w = computeAgentWorkload(
|
||||
input({ exposeLeads: [{ status: ExposeLeadStatus.ACTIVE }] as AgentWorkloadInput['exposeLeads'] }),
|
||||
NOW,
|
||||
).livia
|
||||
|
||||
expect(w.label).toBe('1 Exposé offen')
|
||||
})
|
||||
|
||||
it('meldet bei leerer Datenlage überall Ruhe statt Lücken', () => {
|
||||
const all = computeAgentWorkload(EMPTY, NOW)
|
||||
|
||||
expect(Object.keys(all)).toEqual(['ferdi', 'bruno', 'livia', 'nora', 'sina'])
|
||||
for (const w of Object.values(all)) {
|
||||
expect(w.count).toBe(0)
|
||||
expect(w.tone).toBe(WorkloadTone.CALM)
|
||||
}
|
||||
})
|
||||
|
||||
it('liefert zum selben Stichzeitpunkt immer dasselbe Ergebnis', () => {
|
||||
const data = input({ reminders: [reminder(2)], visits: [visit(1)] })
|
||||
expect(computeAgentWorkload(data, NOW)).toEqual(computeAgentWorkload(data, NOW))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,157 @@
|
||||
import { ReminderStatus } from '../domain/reminder'
|
||||
import type { Reminder } from '../domain/reminder'
|
||||
import { ExposeLeadStatus } from '../domain/exposeLead'
|
||||
import type { ExposeLead } from '../domain/exposeLead'
|
||||
import type { VisitAssignment } from '../domain/visitAssignment'
|
||||
import type { UnifiedLead } from '../domain/unifiedLead'
|
||||
import type { Property } from '../domain/property'
|
||||
import { WorkloadTone } from '../domain/agentWorkload'
|
||||
import type { AgentWorkload } from '../domain/agentWorkload'
|
||||
import { DQ_MEDIUM } from '../lib/constants'
|
||||
|
||||
/**
|
||||
* Auslastung der fünf digitalen Mitarbeitenden (Runde 5).
|
||||
*
|
||||
* Reine Geschäftslogik, bewusst ausserhalb von React: was «diese Woche»
|
||||
* bedeutet, ab wann eine Zahl dringend ist und welcher Bestand zu welchem
|
||||
* Agenten gehört, sind fachliche Festlegungen und keine Darstellungsfragen
|
||||
* (CLAUDE.md §4.2). Hier lässt sich das auch ohne Renderer prüfen — der
|
||||
* Stichzeitpunkt ist ein Parameter, nicht die Systemuhr.
|
||||
*
|
||||
* Jede Zahl stammt aus dem Bestand, für den der jeweilige Agent zuständig ist.
|
||||
* Es gibt keine zweite Kennzahlentabelle, die auseinanderlaufen könnte: wer
|
||||
* Ferdis Zahl prüfen will, zählt seine Reminderliste nach.
|
||||
*
|
||||
* Bewusst genau eine Zahl je Agent — eine Plakette über einem Kopf trägt einen
|
||||
* Satz, kein Dashboard. Gezeigt wird jeweils das, was zuerst eine Entscheidung
|
||||
* verlangt.
|
||||
*/
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000
|
||||
|
||||
/** Ab dieser Anzahl wechselt eine Zahl von «in Arbeit» auf «dringend». */
|
||||
const URGENT_FROM = {
|
||||
reminders: 5,
|
||||
visits: 4,
|
||||
expose: 5,
|
||||
dataGaps: 10,
|
||||
} as const
|
||||
|
||||
/** Zeitfenster der Auswertung. */
|
||||
const WINDOW_DAYS = {
|
||||
/** «diese Woche» für Fristen und Termine. */
|
||||
ahead: 7,
|
||||
/** «neu» für Marktchancen. */
|
||||
leadsBack: 14,
|
||||
} as const
|
||||
|
||||
export interface AgentWorkloadInput {
|
||||
reminders: Reminder[]
|
||||
visits: VisitAssignment[]
|
||||
exposeLeads: ExposeLead[]
|
||||
leads: UnifiedLead[]
|
||||
properties: Property[]
|
||||
}
|
||||
|
||||
function within(iso: string, from: number, to: number): boolean {
|
||||
const t = new Date(iso).getTime()
|
||||
return Number.isFinite(t) && t >= from && t <= to
|
||||
}
|
||||
|
||||
function tone(count: number, urgentFrom: number): WorkloadTone {
|
||||
if (count === 0) return WorkloadTone.CALM
|
||||
return count >= urgentFrom ? WorkloadTone.URGENT : WorkloadTone.ATTENTION
|
||||
}
|
||||
|
||||
function plural(count: number, one: string, many: string): string {
|
||||
return count === 1 ? one : many
|
||||
}
|
||||
|
||||
/**
|
||||
* @param now Stichzeitpunkt in Millisekunden. Als Parameter und nicht aus der
|
||||
* Systemuhr gelesen, damit dieselbe Datenlage immer dasselbe Ergebnis liefert.
|
||||
*/
|
||||
export function computeAgentWorkload(
|
||||
input: AgentWorkloadInput,
|
||||
now: number = Date.now(),
|
||||
): Record<string, AgentWorkload> {
|
||||
const weekAhead = now + WINDOW_DAYS.ahead * DAY_MS
|
||||
const leadsFrom = now - WINDOW_DAYS.leadsBack * DAY_MS
|
||||
|
||||
// ── Ferdi: Fristen ──────────────────────────────────────────────────────────
|
||||
// Überfälliges zuerst: eine abgelaufene Frist wiegt schwerer als zehn
|
||||
// kommende, und beide Zahlen nebeneinander wären keine Aussage mehr.
|
||||
const activeReminders = input.reminders.filter(r => r.status === ReminderStatus.ACTIVE)
|
||||
const overdue = activeReminders.filter(r => new Date(r.dueDate).getTime() < now).length
|
||||
const dueThisWeek = activeReminders.filter(r => within(r.dueDate, now, weekAhead)).length
|
||||
const ferdi: AgentWorkload = overdue > 0
|
||||
? {
|
||||
agentId: 'ferdi',
|
||||
count: overdue,
|
||||
label: `${overdue} ${plural(overdue, 'Frist', 'Fristen')} überfällig`,
|
||||
tone: WorkloadTone.URGENT,
|
||||
voice: `Bei ${overdue} ${plural(overdue, 'Frist', 'Fristen')} ist der Termin durch. Fürs Eskalieren brauche ich deine Freigabe.`,
|
||||
}
|
||||
: {
|
||||
agentId: 'ferdi',
|
||||
count: dueThisWeek,
|
||||
label: `${dueThisWeek} ${plural(dueThisWeek, 'Frist', 'Fristen')} diese Woche`,
|
||||
tone: tone(dueThisWeek, URGENT_FROM.reminders),
|
||||
voice: dueThisWeek === 0
|
||||
? 'Diese Woche läuft keine Frist ab. Ich behalte den Bestand im Auge.'
|
||||
: `${dueThisWeek} ${plural(dueThisWeek, 'Frist läuft', 'Fristen laufen')} diese Woche ab. Ich habe alle im Blick.`,
|
||||
}
|
||||
|
||||
// ── Bruno: Besichtigungen ───────────────────────────────────────────────────
|
||||
const upcomingVisits = input.visits.filter(v => within(v.scheduledAt, now, weekAhead)).length
|
||||
const bruno: AgentWorkload = {
|
||||
agentId: 'bruno',
|
||||
count: upcomingVisits,
|
||||
label: `${upcomingVisits} ${plural(upcomingVisits, 'Besichtigung', 'Besichtigungen')} diese Woche`,
|
||||
tone: tone(upcomingVisits, URGENT_FROM.visits),
|
||||
voice: upcomingVisits === 0
|
||||
? 'Diese Woche steht kein Termin an. Der Kalender bleibt bei mir.'
|
||||
: `${upcomingVisits} ${plural(upcomingVisits, 'Besichtigung steht', 'Besichtigungen stehen')} an. Die Briefings sind vorbereitet.`,
|
||||
}
|
||||
|
||||
// ── Livia: weitergeleitete Leads ────────────────────────────────────────────
|
||||
const openExpose = input.exposeLeads.filter(l => l.status === ExposeLeadStatus.ACTIVE).length
|
||||
const livia: AgentWorkload = {
|
||||
agentId: 'livia',
|
||||
count: openExpose,
|
||||
label: `${openExpose} ${plural(openExpose, 'Exposé offen', 'Exposés offen')}`,
|
||||
tone: tone(openExpose, URGENT_FROM.expose),
|
||||
voice: openExpose === 0
|
||||
? 'Kein Exposé offen. Leite mir einen Lead weiter, dann lege ich los.'
|
||||
: `${openExpose} ${plural(openExpose, 'Lead wartet', 'Leads warten')} auf ein Exposé von mir.`,
|
||||
}
|
||||
|
||||
// ── Nora: Marktchancen ──────────────────────────────────────────────────────
|
||||
const freshLeads = input.leads.filter(l => within(l.receivedAt, leadsFrom, now)).length
|
||||
const nora: AgentWorkload = {
|
||||
agentId: 'nora',
|
||||
count: freshLeads,
|
||||
label: `${freshLeads} neue ${plural(freshLeads, 'Marktchance', 'Marktchancen')}`,
|
||||
// Neue Chancen sind nie «dringend» — Nora entscheidet nichts, sie schlägt vor.
|
||||
tone: freshLeads > 0 ? WorkloadTone.ATTENTION : WorkloadTone.CALM,
|
||||
voice: freshLeads === 0
|
||||
? 'Zurzeit keine neue Chance. Ich beobachte den Markt weiter.'
|
||||
: `Ich habe ${freshLeads} neue ${plural(freshLeads, 'Chance', 'Chancen')} gefunden. Entscheiden musst du.`,
|
||||
}
|
||||
|
||||
// ── Sina: Datenpflege ───────────────────────────────────────────────────────
|
||||
const gaps = input.properties.filter(p => p.dataQuality.score < DQ_MEDIUM).length
|
||||
const sina: AgentWorkload = {
|
||||
agentId: 'sina',
|
||||
count: gaps,
|
||||
label: `${gaps} ${plural(gaps, 'Objekt', 'Objekte')} mit Datenlücken`,
|
||||
tone: tone(gaps, URGENT_FROM.dataGaps),
|
||||
voice: gaps === 0
|
||||
? 'Alle Objekte sind vollständig erfasst. Ich prüfe weiter.'
|
||||
: `Bei ${gaps} ${plural(gaps, 'Objekt fehlen', 'Objekten fehlen')} mir Angaben. Ich melde, was ich nicht selbst finde.`,
|
||||
}
|
||||
|
||||
return Object.fromEntries(
|
||||
[ferdi, bruno, livia, nora, sina].map(w => [w.agentId, w]),
|
||||
)
|
||||
}
|
||||
@@ -9,7 +9,6 @@ function pageType(route: string): string {
|
||||
if (route.includes('/supply/match-center')) return 'match-center'
|
||||
if (route.includes('/supply/data-quality')) return 'data-quality'
|
||||
if (route.includes('/supply/future-availability')) return 'future-availability'
|
||||
if (route.includes('/supply/dashboard')) return 'supply-dashboard'
|
||||
if (route.includes('/demand/results')) return 'demand-results'
|
||||
if (route.includes('/demand/compare')) return 'compare'
|
||||
if (route.includes('/demand/ai-search')) return 'ai-search'
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { MockupCrmLeadProvider } from '../provider/MockupCrmLeadProvider'
|
||||
import type { CrmLead } from '../domain/crmLead'
|
||||
|
||||
const provider = MockupCrmLeadProvider
|
||||
|
||||
export const crmLeadService = {
|
||||
getAll: (): Promise<CrmLead[]> => provider.getAll(),
|
||||
getById: (id: string): Promise<CrmLead | null> => provider.getById(id),
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
import { propertyService } from './propertyService'
|
||||
import { matchService } from './matchService'
|
||||
import { futureSignalService } from './futureSignalService'
|
||||
import { dataQualityService } from './dataQualityService'
|
||||
import { reviewService } from './reviewService'
|
||||
import type { DashboardData } from '../domain/dashboard'
|
||||
|
||||
export const dashboardService = {
|
||||
async getDashboardData(): Promise<DashboardData> {
|
||||
const [propRes, matchRes, signalRes, qualityRes, reviewRes] = await Promise.allSettled([
|
||||
propertyService.getDashboardPropertiesSummary(),
|
||||
matchService.getStrongMatches(),
|
||||
futureSignalService.getSignalSummary(),
|
||||
dataQualityService.getPortfolioQualitySummary(),
|
||||
reviewService.getDashboardTasks(),
|
||||
])
|
||||
|
||||
const propSummary = propRes.status === 'fulfilled' ? propRes.value : null
|
||||
const strongMatches = matchRes.status === 'fulfilled' ? matchRes.value : null
|
||||
const signals = signalRes.status === 'fulfilled' ? signalRes.value : null
|
||||
const quality = qualityRes.status === 'fulfilled' ? qualityRes.value : null
|
||||
const tasks = reviewRes.status === 'fulfilled' ? reviewRes.value : null
|
||||
|
||||
return {
|
||||
totalProperties: propSummary?.total ?? 0,
|
||||
activeProperties: propSummary?.active ?? 0,
|
||||
strongMatchCount: strongMatches?.length ?? 0,
|
||||
avgDataQuality: quality?.avgScore ?? 0,
|
||||
futureSignals: signals,
|
||||
dataQuality: quality,
|
||||
reviewTasks: tasks,
|
||||
strongMatches,
|
||||
lastUpdated: new Date().toISOString(),
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { create } from 'zustand'
|
||||
|
||||
export interface AgentChatSubject {
|
||||
id: string
|
||||
name: string
|
||||
role: string
|
||||
}
|
||||
|
||||
interface AgentChatState {
|
||||
/** Agent der aktuell geöffneten Seite; `null` ausserhalb der Agentenseiten. */
|
||||
agent: AgentChatSubject | null
|
||||
/** Ungesendeter Eingabetext — geteilt zwischen ausführlichem und sticky Zustand. */
|
||||
draft: string
|
||||
/** true, sobald der ausführliche Chatkopf aus dem sichtbaren Bereich gescrollt ist. */
|
||||
compact: boolean
|
||||
/**
|
||||
* Ob das Eingabefeld den Fokus hatte. Der Zustandswechsel hängt zwei
|
||||
* verschiedene Eingabefelder in den Baum; ohne diese Notiz verlöre man beim
|
||||
* Scrollen mitten im Tippen den Fokus (§9).
|
||||
*/
|
||||
focused: boolean
|
||||
|
||||
openFor: (agent: AgentChatSubject) => void
|
||||
close: () => void
|
||||
setDraft: (draft: string) => void
|
||||
setCompact: (compact: boolean) => void
|
||||
setFocused: (focused: boolean) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Gemeinsamer Zustand des Agentenchats (Runde 5, §5).
|
||||
*
|
||||
* Der Chat erscheint an zwei Stellen — ausführlich auf der Seite und kompakt
|
||||
* im obersten Balken —, ist aber ein einziger Chat. Eingabetext, Fokus und
|
||||
* Agentenidentität liegen deshalb hier und nicht in den Komponenten: zwei
|
||||
* lokale `useState` wären zwei Chats, und beim Scrollen wäre der halbe Satz
|
||||
* verschwunden (§9 «Keine doppelte Chat-Instanz mit getrenntem Zustand»).
|
||||
*
|
||||
* Der Entwurf gehört bewusst nicht in React Query — er ist Oberflächenzustand,
|
||||
* keine Serverdatei (STATE_MANAGEMENT.md).
|
||||
*/
|
||||
export const useAgentChatStore = create<AgentChatState>((set) => ({
|
||||
agent: null,
|
||||
draft: '',
|
||||
compact: false,
|
||||
focused: false,
|
||||
|
||||
// Ein Agentenwechsel setzt den Entwurf zurück: Text für Nora gehört nicht in
|
||||
// Ferdis Eingabefeld. Dieselbe Seite erneut zu betreten erhält ihn dagegen.
|
||||
openFor: (agent) =>
|
||||
set(s => (s.agent?.id === agent.id
|
||||
? { agent }
|
||||
: { agent, draft: '', compact: false, focused: false })),
|
||||
|
||||
close: () => set({ agent: null, compact: false, focused: false }),
|
||||
setDraft: (draft) => set({ draft }),
|
||||
setCompact: (compact) => set({ compact }),
|
||||
setFocused: (focused) => set({ focused }),
|
||||
}))
|
||||