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
+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}`
if (max != null) return `${max}`
if (min != null) return `${min}`
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 }
}