Compare commits

..
9 Commits
Author SHA1 Message Date
Benjamin SutterandClaude Opus 5 9e825c04bf feat(agenten): Porträts in 512×512 aus den Originalen der Präsentation
Die Porträts lagen bisher nur als kleine, liegende Zuschnitte aus einem
HTML-Konzept vor — Ferdi etwa 215×160 Bildpunkte bei 6 KB. Im 3D-Meetingraum
waren sie damit sichtbar unscharf, und Hochskalieren hilft dagegen nicht.

Die Originale liegen in `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, und die Präsentation
nennt Name und Funktion direkt neben jedem Bild. Jede Zuordnung wurde einzeln
gegen den alten 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.

`scripts/build-agent-portraits.mjs` beschneidet den Folienrand, skaliert auf
512×512 und schreibt die Dateien. Das reicht für jede Darstellung im Programm —
im Raum sind die Kreise höchstens rund 100 Bildpunkte gross — und hält das
Bündel klein: rund 30 KB je Porträt statt 250 KB im Original.

Die Verarbeitung läuft ohne Bildbibliothek auf einem Canvas im vorhandenen
Chrome, wie schon beim Basel-Panorama. Keine neue Abhängigkeit.

Geprüft: 422 Tests, Typecheck, Build, ESLint, Token-Schwelle (884/925), Szene
im Browser ohne Konsolenfehler.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 15:27:20 +02:00
Benjamin SutterandClaude Opus 5 4349641ee9 fix(startseite): durchgehendes Basel-Panorama, Kreise ohne Stühle
Vier Korrekturen aus der Nachprüfung.

**Naht in der linken Fensterfront.** Sie stammte von zwei getrennten
Fotoflächen — vorne und links. Eine Stossstelle lässt sich nicht wegblenden;
sie verschwindet nur, wenn es keine zwei Bilder mehr gibt. Die Kulisse ist
jetzt eine gekrümmte Fläche mit einer einzigen Panoramadatei: auf einem
durchgehenden Zylinder ist eine Naht geometrisch unmöglich.

Das Panorama entsteht einmalig aus den beiden Referenzfotos
(`scripts/build-basel-panorama.mjs`), jede Quelle genau einmal — kein Kacheln,
kein Spiegeln. Das gute Motiv vom Tischende bleibt in Ausschnitt und Farbe
unverändert und steht weiterhin in der Frontscheibe; die Panoramaaufnahme setzt
es nach links fort, helligkeitsangeglichen und über 320 Pixel überblendet. Aus
der Fortsetzung wird nur der Abschnitt östlich des Münsters verwendet — beide
Vorlagen zeigen es, nebeneinander stünde es zweimal im Bild. Himmel und Wasser
sind aus den Randzeilen gestreckt, damit oben und unten keine Bildgrenze bleibt.

**Stühle und Körper entfernt.** Sitzmöbel, Rümpfe, Schultern, Hälse, Arme und
Hinterköpfe sind ersatzlos weg. Die Agenten bestehen nur noch aus ihrem runden
Porträt mit weissem Ring.

**Positionen neu.** Ferdi und Bruno sind die direkten Nachbarn links und rechts
vorne, die übrigen gestaffelt dahinter, Thomas unverändert vis-à-vis. Die
Kreise liegen deutlich über der Blickachse: lagen sie auf Augenhöhe,
projizierten alle fünf auf fast dieselbe Bildhöhe und die hinteren
verschwanden hinter den vorderen — seitliches Auseinanderziehen half dagegen
nicht, weil ein tieferer Platz in der Perspektive zur Mitte wandert.

Dazu ein echter Fehler, der dabei auffiel: die grosszügigen Trefferquader der
vorderen Kreise fingen die Strahlen der hinteren ab, ein Klick auf Livia landete
bei Ferdi. Die Trefferfläche ist jetzt eine Kugel knapp um den sichtbaren Kreis.

**Bildschärfe.** Die Textur wird auf einen mittigen quadratischen Ausschnitt
begrenzt — ohne das zieht die Kreisgeometrie ein liegendes Foto in die Breite,
was Gesichter verzerrt und Schärfe kostet. Dazu anisotrope Filterung und
Mipmaps. Höher aufgelöste Originale der fünf Agenten waren nicht auffindbar:
die Porträts in `Agenten_Verwaltung_Fotos.html` liegen zwar in 256×256 vor,
gehören aber zu einer anderen Belegschaft — geprüft und verworfen, statt Gesichter
zu vertauschen.

**Ortsangabe** unten rechts entfernt. **Menü** liegt bündig an der rechten
Fensterkante: der Spalt kam vom Anker am eingerückten Menüsymbol, jetzt ist das
Klappmenü an eine Bildschirmstelle gebunden.

Geprüft: 422 Tests, Typecheck, Build, ESLint, Token-Schwelle (884/925). Im
Browser 1280 und 1600 px: sechs Beschriftungen ohne Überlappung, sechs Kreise
ohne Überlappung, alle sechs per Maus auf der richtigen Subpage, Menü bündig
ohne waagrechten Scrollbalken, keine Konsolenfehler.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 14:32:58 +02:00
Benjamin SutterandClaude Opus 5 bf453d2b69 feat(startseite): echter 3D-Meetingraum, Navigation in die Topbar
Anpassungsrunde 6. Die Kartendarstellung aus Runde 5 ist ersetzt: die
Startseite zeigt oben einen räumlichen Meetingraum aus der Sicht des
Betrachters, der selbst am Kopfende des Tisches sitzt.

**3D statt Kulisse.** Zwei Versuche mit CSS-Flächen sind daran gescheitert,
dass gezeichnete Wände neben fotografierten Porträts erkennbar gezeichnet
bleiben. Diese Runde nutzt three.js — die einzige neue Abhängigkeit, und §19
lässt sie ausdrücklich zu, wenn eine schlanke 3D-Lösung die Anforderung trägt.
Keine Engine darüber hinaus: kein React-Three-Fiber, keine Physik, keine
Shader, keine Post-Processing-Kette, keine Orbit-Steuerung.

Der Raum: Boden, Decke, rechts eine geschlossene Wand mit Eichenpaneel und
einem schmalen Streifen im Markenblau, links und gegenüber bodentiefe
Glasfassaden mit Pfosten. Draussen tragen die beiden Referenzfotos aus dem
Auftrag die Basler Aussicht — vorne Rhein, Altstadt und Münster, links der
Rheinverlauf mit der Wettsteinbrücke. Die Wasserlinie beider Kulissen liegt auf
derselben Welthöhe, damit der Blick nach vorne und nach links zusammengehört
und nicht wie zwei zusammengesetzte Bilder wirkt (§4).

Am Tisch sitzen alle fünf bestehenden Agenten mit ihren bestehenden Porträts,
Namen, Funktionen und Zielseiten — alles aus `AGENT_WORKSPACES`, damit nichts
doppelt gepflegt wird (§24, Schritt 7). Neu ist Thomas, Personalverwalter,
vis-à-vis am gegenüberliegenden Kopfende; sein Klick führt in die bestehende
Ansicht «Meine Agenten».

Gerendert wird nur bei Anlass — Grösse, Maus, Hover. Ein Dauerloop wäre für
eine feste Szene verschwendete Rechenzeit (§21). Die Kamera folgt der Maus um
wenige Zentimeter, mehr nicht (§20).

Die Beschriftungen sind HTML über dem Bild, nicht Textur in der Szene: als
Textur wären sie bei Tiefe unlesbar. Ein Entzerrungsdurchgang nach der
Projektion hebt Plaketten so weit an, bis keine mehr überlappt — gerechnet und
nicht handverlesen, damit es auf jeder Fenstergrösse hält. Bei Hover kommt eine
Zeile mit der aktuellen Auslastung dazu; ruhend bleiben es Name und Funktion.

**Navigation.** Die linke Seitenleiste ist gelöscht, nicht ausgeblendet: samt
Breitenreservierung, Einklapplogik und mobilem Drawer. Der Inhalt beginnt
überall bei x=0. An der Stelle des früheren Profilsymbols steht neben der
Glocke ein Menüsymbol; das Klappmenü öffnet rechts an der Kopfzeile nach unten
und führt Startseite, Meine Agenten samt aller Agenten als eingerückte,
rechtsbündige Unterpunkte, und Profil mit Profil, Einstellungen, Produkttour
und Abmelden. Alle Aktionen und Routen sind die bestehenden.

Entfallen sind die Kartenkomponenten aus Runde 5 samt Präsenzschicht.

Geprüft (§25): 422 Tests, Typecheck, Build, ESLint, Token-Schwelle (884/925).
Im Browser 430–2560 px: keine alte Sidebar, kein leerer Streifen links, sechs
Beschriftungen ohne Überlappung, alle sechs Agenten per Maus und per Tastatur
erreichbar und auf der richtigen Subpage, Menü öffnet und schliesst per Klick
und Escape, alle Menürouten korrekt, alle bestehenden Seiten laden, kein
horizontaler Overflow, keine Konsolenfehler.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 18:00:00 +02:00
Benjamin SutterandClaude Opus 5 e55be64aeb fix(startseite): kein Innenscroll mehr, fünf Karten in einer Reihe
Auf dem Bildschirm des Nutzers waren die oberen Karten oben abgeschnitten —
Porträt und Name fehlten — und rechts lief ein zweiter Scrollbalken innerhalb
des Belegschaftsbereichs. Drei Ursachen, alle aus derselben Wurzel: der
Bereich hatte eine gedeckelte Höhe und durfte in sich scrollen.

1. Feste Höhe entfernt. Der Bereich richtet sich nach seinem Inhalt und hat
   nur noch eine Mindesthöhe. Passte der Inhalt vorher nicht in den Deckel,
   scrollte er in sich selbst und schnitt oben ab — ein Bereich über einer
   ohnehin scrollenden Seite darf das nie.

2. Fünf Karten stehen ab 1200 px in einer Reihe statt in zwei. Zwei Reihen
   brauchten rund 250 px mehr Höhe; genau dieser Betrag drückte Titel und
   Filterbalken aus dem Bild. Eine Reihe ist zugleich das ehrlichere Bild:
   fünf Kolleginnen und Kollegen nebeneinander, keine Hierarchie.

3. `minWidth: 0` auf der Karte. Ohne das kann eine Flexkarte nicht unter ihre
   Inhaltsbreite schrumpfen — die fünf Karten passten rechnerisch in eine
   Reihe, brachen aber trotzdem um.

Dazu: lange deutsche Komposita («Besichtigungsassistent», «Marktbeobachtung»)
brechen ohne Zutun nicht um und liefen aus schmalen Karten heraus. Funktion,
Abteilung, Personalnummer und beide Textblöcke brechen jetzt im Wort statt
abgeschnitten zu werden — eine halbe Funktionsbezeichnung ist keine, und ohne
Personalnummer ist jemand kein Mitarbeitender.

Geprüft über zehn Auflösungen von 1024×700 bis 3440×1440, ausdrücklich auch
die Windows-Skalierungen 125 % und 150 % (effektiv 1536×864 und 1280×720):
nichts abgeschnitten, nirgends Innenscroll, Titel «Meine Objekte» überall im
Bild. Dazu 433 Tests, Typecheck, Build, ESLint, Token-Schwelle (889/925).

Verbleibend: Bei einer Inhaltshöhe unter 620 px (1280×600, 1024×700) steht der
Balken «Filter & Übersicht» rund 30 px unter der Kante. Der Titel bleibt
sichtbar; der Balken kommt mit der kleinsten Scrollbewegung.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 15:33:12 +02:00
Benjamin SutterandClaude Opus 5 42a9bece36 fix(startseite): Belegschaft auf grossen Bildschirmen fassen
Auf 2560×1440 sah die Startseite schlecht aus, und der Fehler war meiner: die
Prüfung reichte nur bis 1920 px. Darüber lief nichts mehr auf Anschlag.

Zwei Ursachen:

Die Karten hatten keine Breitengrenze. Bei drei Spalten ohne Deckel wurden sie
auf 2560 px rund 733 px breit bei 207 px Höhe, auf einem Ultrawide sogar
1027 px — flache Streifen, in denen zwei Textzeilen verloren stehen. Der
Inhalt ist jetzt auf 1280 px gefasst und mittig gestellt, die Karten bleiben
damit auf jeder Breite rund 395 px. Der Hintergrund bleibt randlos.

Die Höhe wuchs unbegrenzt mit dem Viewport, während die Karten ihre
Inhaltshöhe behielten. Auf einem 1440er Monitor stand dadurch über und unter
den Karten ein halber Bildschirm leere dunkle Fläche. Die Höhe ist jetzt auf
760 px gedeckelt. Der Titel «Meine Objekte» bleibt sichtbar — er rückt nach
oben, und mehr von der Objektliste wird sichtbar.

Geprüft über neun Auflösungen von 1366×641 bis 3440×1440, jeweils fünf Karten
vollständig sichtbar, keine Überlappung, kein Innenscroll, Titel und
«Filter & Übersicht» im Viewport. Dazu 433 Tests, Typecheck, Build, ESLint,
Token-Schwelle (889/925).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 15:13:17 +02:00
Benjamin SutterandClaude Opus 5 093a958dcf feat(startseite): Giorgio entfernt, Belegschaft auf die fünf Agenten
Auf ausdrücklichen Wunsch: Giorgio entfällt vollständig. Das weicht bewusst
von §6 des Auftrags ab, der ihn als Personalverwalter und Vorgesetzten
vorsah — festgehalten, damit die Abweichung später nachvollziehbar bleibt.

Entfernt: Rollendefinition `AGENT_SUPERVISOR`, sein gezeichnetes Porträt, sein
Eintrag in der Auslastung und in der Belegschaftsansicht sowie die Erwähnung
in der Produkttour. Die Personalverwaltung bleibt unverändert über den
Menüpunkt «Meine Agenten» erreichbar — sie brauchte nie eine eigene Person am
Bildschirm.

Mit Giorgio entfällt auch die einzige Kennzahl, die aus den Freigabevorgängen
kam; `computeAgentWorkload` braucht `agentWorkItems` deshalb nicht mehr.

Ordner und Komponenten heissen jetzt nach ihrem Inhalt: `components/staff/`,
`AgentStaffOverview`, `StaffBackdrop`. «Meetingraum» war seit dem Umbau auf
Personenkarten irreführend, und ein falscher Name kostet den Nächsten mehr
Zeit als die Umbenennung.

Fünf statt sechs Karten heisst Flexlayout statt Dreierraster: drei oben, zwei
mittig darunter, gleich breit und ohne Lücke. Die Karten geben ihre Höhe nicht
mehr selbst vor — mit `height: 100%` beanspruchte jede die volle Fläche und
die zweite Zeile fiel aus dem sichtbaren Bereich.

Geprüft: 433 Tests, Typecheck, Build, ESLint, Token-Schwelle (889/925).
Browser 1280–1920 px und Tablet: fünf Karten vollständig sichtbar, keine
Überlappung, Titel und «Filter & Übersicht» beim Einstieg sichtbar, alle fünf
Klicks auf der richtigen Subpage, «Meine Agenten» weiterhin erreichbar, keine
Konsolenfehler, keine 404.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 15:03:18 +02:00
Benjamin SutterandClaude Opus 5 435f2aa2f1 feat(startseite): digitale Belegschaft mit Präsenz, eigener Stimme und Nachweis
Der nachgebaute Meetingraum entfällt. Eine gezeichnete Kulisse bleibt neben
fotografierten Porträts erkennbar gezeichnet — und ein Raum allein macht
niemanden menschlich. Menschlich wirkt im echten Büro dasselbe wie hier: dass
jemand da ist, etwas zu sagen hat und nachweisbar gearbeitet hat.

Die Startseite zeigt deshalb sechs Personenkarten vor einer abgedunkelten
Büroaufnahme. Jede Karte trägt, was ein Personalblatt trägt:

- Porträt mit Dienstpunkt — ist da oder pausiert
- Name, Funktion, Abteilung, Personalnummer
- Autonomiestufe: «Autonom», «Freigabe erforderlich», «Nur Vorschlag».
  Sie sagt, wie viel Verantwortung jemand trägt, und trennt Personal von
  Automatisierung.
- Ein Satz in der ersten Person zur aktuellen Lage, statt einer Kennzahl:
  «Bei 12 Fristen ist der Termin durch. Fürs Eskalieren brauche ich deine
  Freigabe.»
- Die zuletzt protokollierte Handlung mit Zeitangabe. Diese Zeile ist der
  Unterschied zwischen Behauptung und Beleg — eine Kennzahl kann jede
  Software anzeigen.

Darüber fasst ein Anriss zusammen, wie viele Mitarbeitende heute eine
Entscheidung brauchen, samt Dienststand der Belegschaft.

Schichten nach CLAUDE.md §4.2: `agentPresenceService` entscheidet, was als
letzte Handlung gilt und wie eine Zeitangabe in Alltagssprache lautet;
`agentWorkloadService` hält Zeitfenster, Schwellen und Formulierungen. Beide
nehmen den Stichzeitpunkt als Parameter — dieselbe Datenlage ergibt immer
dasselbe Ergebnis und ist ohne Renderer prüfbar (22 Tests).

Giorgio hat kein Personalblatt; Abteilung, Personalnummer und Stufe kommen
aus der zentralen Rollendefinition. Wer kein Dossier hat, gilt als im Dienst —
sonst stünde die halbe Belegschaft still, weil Stammdaten fehlen.

Bild: Unsplash, freie kommerzielle Lizenz. Herkunft und Austauschhinweis in
`src/assets/meeting-room/HERKUNFT.md`.

Geprüft: 435 Tests, Typecheck, Build, ESLint, Token-Schwelle (889/925).
Browser 1280–1920 px und Tablet: sechs Karten ohne Überlappung, Titel und
«Filter & Übersicht» beim Einstieg sichtbar, alle sechs Klicks auf der
richtigen Subpage, keine Konsolenfehler, keine 404.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 14:50:16 +02:00
Benjamin SutterandClaude Opus 5 d8f8f728cc refactor(startseite): Meetingraum als Fotoszene mit Live-Auslastung
Die gezeichnete Raumkulisse ist ersetzt. Aus Farbflächen konstruierte Wände
bleiben neben fotografierten Agentenporträts erkennbar gezeichnet — die
Bildwirkung muss aus dem Foto kommen, nicht aus Geometrie. Entfallen sind
damit die beiden SVG-Stadtansichten, die CSS-3D-Flächen und die Farbwelt
DS_MEETING_ROOM.

Neu trägt die Szene die Basler Aussicht aus dem Auftrag als Foto (§5), mit
abgestuftem Verlauf für Lesbarkeit und Übergang zur Objektliste. Ein Austausch
des Motivs ist ein Einzeiler in `roomBackdrop.ts`.

Mehrwert statt Kulisse: Jedes Namensschild zeigt die aktuelle Auslastung
seines Agenten — überfällige Fristen, Termine der Woche, offene Exposés, neue
Marktchancen, Objekte mit Datenlücken, offene Freigaben. Darüber fasst ein
Anriss zusammen, wie viele Bereiche heute Aufmerksamkeit brauchen. Damit
beantwortet die Startseite die Frage, mit der sie geöffnet wird, statt sie
auf sechs Zahlen zu verteilen.

- `agentWorkloadService` hält die fachlichen Festlegungen: Zeitfenster,
  Dringlichkeitsschwellen und Formulierungen. Der Stichzeitpunkt ist ein
  Parameter, nicht die Systemuhr — dieselbe Datenlage ergibt dasselbe
  Ergebnis, und der Dienst ist ohne Renderer prüfbar (10 neue Tests).
- `useAgentWorkload` sammelt nur die Bestände ein.
- Noras Zahl wird nie «dringend»: sie schlägt vor, sie entscheidet nicht.
- Die Schilder bemessen ihre Höchstbreite an der Szenenbreite (`cqw`), nicht
  am Fenster. Am Fenster gemessen überlappten sie auf schmalen Laptops, weil
  die Szene um das Seitenmenü schmaler ist.

Geprüft: 423 Tests, Typecheck, Build, ESLint, Token-Schwelle (889/925).
Im Browser 1280–1920 px: keine Überlappungen, Titel und «Filter & Übersicht»
beim Einstieg sichtbar, alle sechs Agentenklicks auf der richtigen Subpage,
Tablet-Ansicht mit allen sechs Agenten, keine Konsolenfehler, keine 404.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 14:07:21 +02:00
Benjamin SutterandClaude Opus 5 9ed21e33a7 feat(runde-5): Startseite mit Agenten-Meetingraum, sticky Chat und Noras Kanäle
Hauptnavigation und Startseite (§2)
- Hauptseite «Übersicht» samt Route, Menüeintrag, Hook, Service und den nur
  dort verwendeten Widgets entfernt; /supply/dashboard leitet auf die
  Startseite um, damit Lesezeichen nicht ins Leere laufen.
- «Meine Objekte» heisst im Menü «Startseite»; die frühere Überschrift
  «Objektverwaltung» heisst jetzt «Meine Objekte».
- Die Startseite scrollt als Ganzes. Die Höhe des Meetingraums rechnet gegen
  die Viewport-Höhe, damit Titel und Balken «Filter & Übersicht» beim Einstieg
  auf jeder Monitorgrösse sichtbar bleiben (geprüft 1280–1920 px).

Agenten-Meetingraum (§3)
- Zentralperspektive aus clip-path-Flächen, ohne 3D-Engine und ohne
  Animationsbibliothek: rechts geschlossene Wand, links und gegenüber
  boden- bis deckenhohe Glasfronten mit Blick auf Rhein, Basler Münster,
  Altstadt und Wettsteinbrücke.
- Sitzordnung, Zielrouten, Grössen und Plakettenversatz liegen in einer
  zentralen Konfiguration (meetingRoomSeats.ts).
- Neuer Agent Giorgio, Personalverwalter, sitzt vis-à-vis und führt in
  «Meine Agenten». Er steht bewusst nicht in AGENT_WORKSPACES: er hat keine
  Arbeitsseite, sondern ist die Adminfläche selbst.
- Auf schmalen Schirmen tritt eine vereinfachte Kachelanordnung an die Stelle
  der Perspektive; ausgeblendet wird kein Agent.

Sticky Chat bei allen Agenten (§5, §9–§11)
- Scrollt der Chatkopf unter den globalen Balken, übernimmt dort eine kompakte
  Fassung mit Porträt, Name, Frage und Eingabefeld.
- Ein gemeinsamer Zustand (agentChatStore) statt zweier Chat-Instanzen:
  Eingabetext und Fokus überstehen den Wechsel in beide Richtungen.
- Noras Seite scrollte bisher gar nicht — Chatkopf ausserhalb des
  Scrollbereichs, zwei getrennt scrollende Spalten darunter. Behoben über den
  Seitenaufbau, nicht über eine Nora-Sonderlösung.

Nora: CRM-Kanal und gemeinsame Lead-Liste (§12, §13)
- Dritter Kanal «CRM» mit eigenem Domänentyp, Mockdaten, Provider, Service und
  Hook nach dem bestehenden Schichtenmuster.
- Die Reiter «KI-Signale»/«Netzwerk» sind einer gemeinsamen Liste gewichen.
  Jede Zeile trägt ihre Kanalherkunft als Badge; die Herkunft steht im
  Datenmodell (LeadChannel) und wird nicht aus der Darstellung geraten.
- Der frühere Reiterbalken ist der Filter «Kanaltyp» mit Alle/KI Signal/
  Netzwerk/CRM, Standard «Alle», mit Empty State und Rücksetzung.

Produkttour und Profilmenü (§8)
- Tour auf die neue Struktur umgeschrieben: Startseite, Meetingraum, Agenten,
  sticky Chat, Leadkanäle. «Übersicht» kommt nicht mehr vor, «Meine Objekte»
  bezeichnet nur noch den Objektbereich.
- Bereich «Demo-Modus» mit «Verwaltung» und «Bürosuche» entfernt.

Geprüft: 413 Tests, Typecheck, Build, ESLint, Token-Schwelle (889/925).
Im Browser: Navigation, Agentenklicks aller sechs Plätze, Kanalfilter und
Detailschubladen, Sticky Chat bei vier Agenten, 1280–1920 px sowie
Tablet- und Mobilbreite, keine 404 für Bilder oder Routen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 12:44:22 +02:00
79 changed files with 3713 additions and 1898 deletions

No files matched your search

+65
View File
@@ -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",
+2
View File
@@ -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",
+131
View File
@@ -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()
+190
View File
@@ -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)}`)
+10 -4
View File
@@ -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 />} />
+15
View File
@@ -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).
Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 177 KiB

+12
View File
@@ -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.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.2 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.9 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.5 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.7 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

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',
-52
View File
@@ -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>
)
}
-1
View File
@@ -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'
+20 -77
View File
@@ -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}
{/* 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 sx={{ display: 'flex', flexDirection: 'column', height: '100vh', overflow: 'hidden' }}>
<TopBar pathname={location.pathname} />
<Box component="main" sx={{ flex: 1, overflowY: 'auto' }}>
<Outlet />
</Box>
<RightContextPanel />
</Box>
<ToastProvider />
<WelcomeDialog />
</Box>
-400
View File
@@ -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>
)
}
+23 -29
View File
@@ -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>
)
+236
View File
@@ -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>
</>
)
}
-106
View File
@@ -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')
+7 -6
View File
@@ -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: [],
},
+1 -1
View File
@@ -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()
})
})
+8 -2
View File
@@ -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 -1
View File
@@ -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'
+140 -100
View File
@@ -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">
<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={<CheckCircle2 size={18} />}
color="#1a7a4a"
text="Verifizierte Portfolio-Objekte"
icon={<MousePointerClick size={18} />}
color={DS_BRAND.main}
text="Jede Karte zeigt, woran der Mitarbeitende gerade sitzt. Ein Klick öffnet seinen Arbeitsbereich."
/>
<FeatureRow
icon={<CheckCircle2 size={18} />}
color="#1a7a4a"
text="Externer Markt & Inserate"
icon={<Building2 size={18} />}
color={DS_ACCENT.success.main}
text={`Scrollen Sie nach unten zu «${MY_PROPERTIES_LABEL}» — Ihrem vollständigen Objektbestand.`}
/>
<FeatureRow
icon={<Zap size={18} />}
color="#7c3aed"
text="Future Availability Intelligence"
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."
/>
</Box>
</Box>
</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" />
</Box>
<Typography variant="h5" sx={{ fontWeight: 700, mb: 1 }}>
Bedarf beschreiben — KI findet Matches
<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 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 sx={{ fontSize: '0.6875rem', lineHeight: 1.2, color: DS_TEXT.secondary }}>
{agent.role}
</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>
))}
</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">
<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={<Newspaper size={18} />}
color="#d97706"
text="Presse & Stelleninserate"
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={<FileCheck size={18} />}
color="#1a7a4a"
text="Baubewilligungen & Berichte"
icon={<MessageSquare size={18} />}
color={DS_ACCENT.warning.main}
text="Angefangener Text bleibt dabei erhalten — Sie schreiben einfach weiter."
/>
</Box>
</Box>
</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>
)
+332
View File
@@ -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>
)
}
+5
View File
@@ -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'
+104
View File
@@ -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
+496
View File
@@ -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()
},
}
}
+88
View File
@@ -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,
},
]
-36
View File
@@ -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>
)
}
-65
View File
@@ -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>
)
}
+2 -8
View File
@@ -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'
+122
View File
@@ -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>
)
}
+50 -25
View File
@@ -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 }}
>
+6
View File
@@ -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,
}
+2
View File
@@ -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
+100
View File
@@ -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,
}
}
+42
View File
@@ -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
}
+51
View File
@@ -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
}
+57
View File
@@ -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 })
+36
View File
@@ -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,
}
}
+11
View File
@@ -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,
})
}
-11
View File
@@ -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,
})
}
+92
View File
@@ -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 }
}
+18
View File
@@ -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,
}
+29 -1
View File
@@ -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 = {
+17
View File
@@ -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)' },
}
+89
View File
@@ -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',
},
]
+99 -169
View File
@@ -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 }}>
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>
{/* 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>
{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 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>
)
+13 -4
View File
@@ -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 => (
-200
View File
@@ -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>
)
}
+6
View File
@@ -0,0 +1,6 @@
import type { CrmLead } from '../domain/crmLead'
export interface ICrmLeadProvider {
getAll(): Promise<CrmLead[]>
getById(id: string): Promise<CrmLead | null>
}
-18
View File
@@ -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>
}
+19
View File
@@ -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))
})
})
+157
View File
@@ -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]),
)
}
-1
View File
@@ -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'
+9
View File
@@ -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),
}
-36
View File
@@ -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(),
}
},
}
+59
View File
@@ -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 }),
}))