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:
@@ -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 }
|
||||
}
|
||||
Reference in New Issue
Block a user