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:
1 parent
d200d4e930
commit
9ed21e33a7
59 files changed
+2545
-1309
No files matched your search
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user