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>
This commit is contained in:
Benjamin Sutter
2026-08-06 12:44:22 +02:00
parent d200d4e930
commit 9ed21e33a7
59 changed files with 2545 additions and 1309 deletions
+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,
}
}