Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e55be64aeb | |||
| 42a9bece36 | |||
| 093a958dcf | |||
| 435f2aa2f1 | |||
| d8f8f728cc | |||
| 9ed21e33a7 |
+10
-4
@@ -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 />} />
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
# Bildherkunft
|
||||
|
||||
| Datei | Quelle | Lizenz |
|
||||
|---|---|---|
|
||||
| `buero.jpg` | Unsplash — Craig Lovelidge, «An empty conference room with desks and chairs» (The Mark, Rotterdam) | [Unsplash License](https://unsplash.com/license) — frei für kommerzielle Nutzung, keine Namensnennung nötig |
|
||||
|
||||
Direktbezug: `https://images.unsplash.com/photo-1637665599155-abfcf9fc3f78`
|
||||
|
||||
Das Bild dient als abgedunkelte Umgebung hinter den Personenkarten der
|
||||
Startseite. Es wird nie unbearbeitet gezeigt — der Verlauf in `roomBackdrop.ts`
|
||||
nimmt ihm so viel Kontrast, dass die Porträts und die Schrift davor tragen.
|
||||
|
||||
**Austausch:** Datei ersetzen, Dateinamen beibehalten. Eine eigene Büroaufnahme
|
||||
ist jederzeit vorzuziehen — sie zeigt den echten Arbeitsplatz der Belegschaft.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 477 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',
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Menu } from 'lucide-react'
|
||||
import { OrganizationContextBadge } from './OrganizationContextBadge'
|
||||
import { NotificationButton } from './NotificationButton'
|
||||
import { UserMenu } from './UserMenu'
|
||||
import { AgentChatStickyBar } from '../team'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
@@ -25,6 +26,12 @@ export interface TopBarProps {
|
||||
* 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.
|
||||
*
|
||||
* Seit Runde 5 steht in der freien Mitte der kompakte Agentenchat, sobald der
|
||||
* ausführliche Chatkopf weggescrollt ist (§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 (§10).
|
||||
*/
|
||||
export function TopBar({ onMenuClick, isMobile }: TopBarProps) {
|
||||
return (
|
||||
@@ -33,15 +40,18 @@ export function TopBar({ onMenuClick, isMobile }: TopBarProps) {
|
||||
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 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexShrink: 0 }}>
|
||||
{isMobile && (
|
||||
<IconButton size="small" onClick={onMenuClick} sx={{ mr: 0.5 }}>
|
||||
<Menu size={20} />
|
||||
@@ -49,8 +59,10 @@ export function TopBar({ onMenuClick, isMobile }: TopBarProps) {
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<AgentChatStickyBar />
|
||||
|
||||
{/* Right side */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: { xs: 0.5, sm: 1 } }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: { xs: 0.5, sm: 1 }, flexShrink: 0 }}>
|
||||
<Box sx={{ display: { xs: 'none', sm: 'flex' } }}>
|
||||
<OrganizationContextBadge />
|
||||
</Box>
|
||||
|
||||
@@ -4,7 +4,6 @@ import { Avatar, Box, Divider, IconButton, ListItemIcon, Menu, MenuItem, Typogra
|
||||
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> = {
|
||||
@@ -87,12 +86,10 @@ export function UserMenu() {
|
||||
Produkttour starten
|
||||
</MenuItem>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Demo role switcher */}
|
||||
<Box sx={{ px: 1, py: 1 }}>
|
||||
<DemoRoleSwitcher />
|
||||
</Box>
|
||||
{/* Der Bereich «Demo-Modus» mit «Verwaltung» und «Bürosuche» ist entfernt
|
||||
(Runde 5, §8). Die Flächensuche existiert in diesem Stand nicht mehr;
|
||||
eine Umschaltung auf eine Rolle ohne erreichbare Seiten wäre eine
|
||||
Sackgasse im Profilmenü gewesen. */}
|
||||
|
||||
<Divider />
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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: [],
|
||||
},
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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,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'
|
||||
|
||||
@@ -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">
|
||||
<FeatureRow
|
||||
icon={<CheckCircle2 size={18} />}
|
||||
color="#1a7a4a"
|
||||
text="Verifizierte Portfolio-Objekte"
|
||||
/>
|
||||
<FeatureRow
|
||||
icon={<CheckCircle2 size={18} />}
|
||||
color="#1a7a4a"
|
||||
text="Externer Markt & Inserate"
|
||||
/>
|
||||
<FeatureRow
|
||||
icon={<Zap size={18} />}
|
||||
color="#7c3aed"
|
||||
text="Future Availability Intelligence"
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
<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={<MousePointerClick size={18} />}
|
||||
color={DS_BRAND.main}
|
||||
text="Jede Karte zeigt, woran der Mitarbeitende gerade sitzt. Ein Klick öffnet seinen Arbeitsbereich."
|
||||
/>
|
||||
<FeatureRow
|
||||
icon={<Building2 size={18} />}
|
||||
color={DS_ACCENT.success.main}
|
||||
text={`Scrollen Sie nach unten zu «${MY_PROPERTIES_LABEL}» — Ihrem vollständigen Objektbestand.`}
|
||||
/>
|
||||
<FeatureRow
|
||||
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."
|
||||
/>
|
||||
</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" />
|
||||
<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 sx={{ fontSize: '0.6875rem', lineHeight: 1.2, color: DS_TEXT.secondary }}>
|
||||
{agent.role}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
<Typography variant="h5" sx={{ fontWeight: 700, mb: 1 }}>
|
||||
Bedarf beschreiben — KI findet Matches
|
||||
</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>
|
||||
<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>
|
||||
<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">
|
||||
<FeatureRow
|
||||
icon={<Newspaper size={18} />}
|
||||
color="#d97706"
|
||||
text="Presse & Stelleninserate"
|
||||
/>
|
||||
<FeatureRow
|
||||
icon={<FileCheck size={18} />}
|
||||
color="#1a7a4a"
|
||||
text="Baubewilligungen & Berichte"
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
<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={<ArrowRight size={18} />}
|
||||
color={DS_ACCENT.success.main}
|
||||
text="Beim Scrollen rutscht der Chat kompakt in die oberste Leiste und bleibt erreichbar."
|
||||
/>
|
||||
<FeatureRow
|
||||
icon={<MessageSquare size={18} />}
|
||||
color={DS_ACCENT.warning.main}
|
||||
text="Angefangener Text bleibt dabei erhalten — Sie schreiben einfach weiter."
|
||||
/>
|
||||
</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>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
import { memo } from 'react'
|
||||
import { Box, ButtonBase, Typography } from '@mui/material'
|
||||
import { ArrowRight } from 'lucide-react'
|
||||
import { AgentAvatar } from '../team'
|
||||
import type { AgentPresence } from '../../domain/agentPresence'
|
||||
import { WorkloadTone } from '../../domain/agentWorkload'
|
||||
import { DS_ACCENT, DS_NEUTRAL } from '../../lib/ds'
|
||||
|
||||
/**
|
||||
* Farben der Auslastung auf dunklem Grund. Bewusst hellere Stufen als die
|
||||
* Standardakzente — die dunkleren Töne verschwinden vor der abgedunkelten
|
||||
* Bürofläche.
|
||||
*/
|
||||
const TONE_COLOR: Record<WorkloadTone, string> = {
|
||||
[WorkloadTone.CALM]: DS_ACCENT.success.emerald,
|
||||
[WorkloadTone.ATTENTION]: DS_ACCENT.warning.amber,
|
||||
[WorkloadTone.URGENT]: DS_ACCENT.danger.bright,
|
||||
}
|
||||
|
||||
export interface AgentStaffCardProps {
|
||||
presence: AgentPresence
|
||||
onOpen: (path: string) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Ein digitaler Mitarbeitender auf der Startseite (Runde 5, §3/§6).
|
||||
*
|
||||
* Die Karte ist bewusst wie ein Personalblatt aufgebaut und nicht wie eine
|
||||
* Kachel: Porträt, Name, Funktion, Abteilung und Personalnummer, dann ein Satz
|
||||
* in der ersten Person, darunter die zuletzt protokollierte Handlung mit
|
||||
* Zeitangabe.
|
||||
*
|
||||
* Diese letzte Zeile ist der Unterschied zwischen «KI-Funktion» und
|
||||
* «Mitarbeitender»: sie belegt, dass jemand gearbeitet hat, statt es zu
|
||||
* behaupten. Eine Kennzahl allein kann jede Software anzeigen.
|
||||
*
|
||||
* Die ganze Karte ist ein Schalter. `ButtonBase` liefert Tastaturbedienung,
|
||||
* Enter, Leertaste und Fokusring ohne eigenen Code.
|
||||
*/
|
||||
export const AgentStaffCard = memo(function AgentStaffCard({ presence, onOpen }: AgentStaffCardProps) {
|
||||
const { workload } = presence
|
||||
const toneColor = workload ? TONE_COLOR[workload.tone] : DS_NEUTRAL.stone
|
||||
|
||||
return (
|
||||
<ButtonBase
|
||||
focusRipple
|
||||
onClick={() => onOpen(presence.path)}
|
||||
aria-label={
|
||||
workload
|
||||
? `${presence.name}, ${presence.role} — ${workload.label}. Arbeitsbereich öffnen`
|
||||
: `${presence.name}, ${presence.role} — Arbeitsbereich öffnen`
|
||||
}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'stretch',
|
||||
textAlign: 'left',
|
||||
// Ohne `minWidth: 0` kann eine Flexkarte nicht unter ihre Inhaltsbreite
|
||||
// schrumpfen. Die fünf Karten passten dadurch nicht in eine Reihe,
|
||||
// brachen um — und die zweite Reihe drückte Titel und Filterbalken aus
|
||||
// dem sichtbaren Bereich.
|
||||
minWidth: 0,
|
||||
gap: 1.25,
|
||||
p: { xs: 1.75, xl: 2 },
|
||||
borderRadius: 3,
|
||||
overflow: 'hidden',
|
||||
// Keine eigene Höhe: im Flexlayout gibt die Zeile die Höhe vor, und alle
|
||||
// Karten einer Zeile werden von selbst gleich hoch. Mit `height: 100%`
|
||||
// beanspruchte jede Karte die volle Fläche und die zweite Zeile fiel
|
||||
// aus dem sichtbaren Bereich.
|
||||
// Glasfläche statt Vollton: das Büro dahinter bleibt spürbar, ohne die
|
||||
// Schrift zu stören.
|
||||
bgcolor: 'rgba(255,255,255,0.06)',
|
||||
backdropFilter: 'blur(14px)',
|
||||
border: '1px solid rgba(255,255,255,0.12)',
|
||||
transition: 'background-color 0.18s ease, transform 0.18s ease, border-color 0.18s ease',
|
||||
'&:hover, &.Mui-focusVisible': {
|
||||
bgcolor: 'rgba(255,255,255,0.11)',
|
||||
borderColor: 'rgba(255,255,255,0.26)',
|
||||
transform: 'translateY(-3px)',
|
||||
},
|
||||
'&:hover .staff-go, &.Mui-focusVisible .staff-go': { opacity: 1, transform: 'translateX(0)' },
|
||||
'&.Mui-focusVisible': { outline: `2px solid ${DS_NEUTRAL.gold}`, outlineOffset: 2 },
|
||||
}}
|
||||
>
|
||||
{/* Kopfzeile: Porträt, Personalien, Dienstzustand */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
||||
<Box sx={{ position: 'relative', flexShrink: 0 }}>
|
||||
<AgentAvatar
|
||||
agent={{ id: presence.agentId, name: presence.name, role: presence.role }}
|
||||
size={56}
|
||||
loading="eager"
|
||||
ringColor="rgba(255,255,255,0.55)"
|
||||
ringWidth={2}
|
||||
/>
|
||||
{/* Dienstpunkt am Porträt — die kürzeste Art zu sagen «ist da». */}
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute', right: -1, bottom: -1,
|
||||
width: 14, height: 14, borderRadius: '50%',
|
||||
bgcolor: presence.onDuty ? DS_ACCENT.success.emerald : DS_NEUTRAL.stone,
|
||||
border: '2.5px solid rgba(12,18,28,0.95)',
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ minWidth: 0, flex: 1 }}>
|
||||
<Typography
|
||||
sx={{ fontWeight: 700, fontSize: '1.0625rem', lineHeight: 1.2, color: DS_NEUTRAL.white, overflowWrap: 'break-word' }}
|
||||
>
|
||||
{presence.name}
|
||||
</Typography>
|
||||
<Typography
|
||||
// Umbrechen statt abschneiden: «Besichtigungsassistent» passt in
|
||||
// einer schmalen Karte nicht in eine Zeile, und eine halbe
|
||||
// Funktionsbezeichnung ist keine.
|
||||
sx={{ fontSize: '0.8125rem', lineHeight: 1.3, color: 'rgba(255,255,255,0.72)', overflowWrap: 'break-word' }}
|
||||
>
|
||||
{presence.role}
|
||||
</Typography>
|
||||
{(presence.department || presence.personnelNumber) && (
|
||||
<Typography
|
||||
sx={{
|
||||
mt: 0.25,
|
||||
fontSize: '0.6875rem',
|
||||
lineHeight: 1.3,
|
||||
color: 'rgba(255,255,255,0.42)',
|
||||
overflowWrap: 'break-word',
|
||||
// Umbrechen statt abschneiden: in einer schmalen Karte fiele
|
||||
// sonst ausgerechnet die Personalnummer weg, und die macht aus
|
||||
// einem Werkzeug einen Mitarbeitenden.
|
||||
}}
|
||||
>
|
||||
{[presence.department, presence.personnelNumber].filter(Boolean).join(' · ')}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Kompetenz: was der Mitarbeitende selbst entscheiden darf. */}
|
||||
{presence.autonomy && (
|
||||
<Box
|
||||
sx={{
|
||||
alignSelf: 'flex-start',
|
||||
px: 1,
|
||||
py: 0.25,
|
||||
borderRadius: 1,
|
||||
bgcolor: 'rgba(255,255,255,0.09)',
|
||||
border: '1px solid rgba(255,255,255,0.14)',
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
sx={{
|
||||
fontSize: '0.625rem',
|
||||
fontWeight: 700,
|
||||
letterSpacing: 0.6,
|
||||
textTransform: 'uppercase',
|
||||
color: 'rgba(255,255,255,0.66)',
|
||||
}}
|
||||
>
|
||||
{presence.autonomy}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Was er dazu sagt — in der ersten Person. */}
|
||||
{workload && (
|
||||
<Box sx={{ display: 'flex', gap: 1, alignItems: 'flex-start' }}>
|
||||
<Box
|
||||
sx={{ width: 3, alignSelf: 'stretch', borderRadius: 2, bgcolor: toneColor, flexShrink: 0 }}
|
||||
/>
|
||||
<Typography
|
||||
sx={{
|
||||
fontSize: '0.8125rem',
|
||||
lineHeight: 1.45,
|
||||
color: 'rgba(255,255,255,0.92)',
|
||||
overflowWrap: 'break-word',
|
||||
}}
|
||||
>
|
||||
{workload.voice}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Beleg: was zuletzt tatsächlich getan wurde. */}
|
||||
<Box sx={{ mt: 'auto', pt: 1, borderTop: '1px solid rgba(255,255,255,0.10)' }}>
|
||||
{presence.lastAction ? (
|
||||
<>
|
||||
<Typography
|
||||
sx={{
|
||||
fontSize: '0.625rem',
|
||||
fontWeight: 700,
|
||||
letterSpacing: 0.8,
|
||||
textTransform: 'uppercase',
|
||||
color: 'rgba(255,255,255,0.38)',
|
||||
}}
|
||||
>
|
||||
Zuletzt · {presence.lastAction.when}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1 }}>
|
||||
<Typography
|
||||
sx={{
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
fontSize: '0.75rem',
|
||||
lineHeight: 1.4,
|
||||
color: 'rgba(255,255,255,0.62)',
|
||||
overflowWrap: 'break-word',
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 2,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{presence.lastAction.title}
|
||||
</Typography>
|
||||
<Box
|
||||
className="staff-go"
|
||||
sx={{
|
||||
color: DS_NEUTRAL.gold,
|
||||
opacity: 0,
|
||||
transform: 'translateX(-4px)',
|
||||
transition: 'opacity 0.18s ease, transform 0.18s ease',
|
||||
flexShrink: 0,
|
||||
mt: 0.25,
|
||||
}}
|
||||
>
|
||||
<ArrowRight size={16} />
|
||||
</Box>
|
||||
</Box>
|
||||
</>
|
||||
) : (
|
||||
<Typography sx={{ fontSize: '0.75rem', color: 'rgba(255,255,255,0.42)' }}>
|
||||
Noch keine Tätigkeit protokolliert.
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</ButtonBase>
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,172 @@
|
||||
import { useCallback, useMemo } from 'react'
|
||||
import { useNavigate } from 'react-router'
|
||||
import { Box, Typography } from '@mui/material'
|
||||
import { StaffBackdrop } from './StaffBackdrop'
|
||||
import { AgentStaffCard } from './AgentStaffCard'
|
||||
import { useAgentPresence } from '../../hooks/useAgentPresence'
|
||||
import { WorkloadTone } from '../../domain/agentWorkload'
|
||||
import { DS_NEUTRAL } from '../../lib/ds'
|
||||
|
||||
/**
|
||||
* Mindesthöhe, damit die Belegschaft eine Fläche hat und nicht als schmaler
|
||||
* Streifen über der Objektliste klebt.
|
||||
*
|
||||
* Eine feste oder gedeckelte Höhe hatte dieser Bereich früher — und genau das
|
||||
* war der Fehler: passte der Inhalt nicht hinein, scrollte er in sich selbst,
|
||||
* die Porträts wurden oben abgeschnitten und neben der Seite stand ein zweiter
|
||||
* Scrollbalken. Die Höhe richtet sich deshalb nach dem Inhalt. Damit der Titel
|
||||
* «Meine Objekte» beim Einstieg sichtbar bleibt (§2.3), stehen die fünf
|
||||
* Mitarbeitenden auf grossen Schirmen in einer einzigen Reihe statt in zweien.
|
||||
*/
|
||||
const MIN_SCENE_PX = 360
|
||||
|
||||
/**
|
||||
* Grösste Breite der Karten samt Anriss. Freilaufend würden bei drei Spalten
|
||||
* auf einem 2560er Monitor Karten von über 700 Pixeln entstehen — flache
|
||||
* Streifen, in denen zwei Textzeilen verloren stehen. Der Hintergrund bleibt
|
||||
* randlos, nur der Inhalt wird gefasst und mittig gestellt.
|
||||
*/
|
||||
const MAX_CONTENT_PX = 1280
|
||||
|
||||
/**
|
||||
* Die digitale Belegschaft auf der Startseite (Runde 5, §3).
|
||||
*
|
||||
* Die fünf Mitarbeitenden an ihrem Arbeitsplatz — mit Porträt, Funktion,
|
||||
* Abteilung, Personalnummer, Dienstzustand, einem Satz in der ersten Person
|
||||
* und der zuletzt protokollierten Handlung. Ein Klick auf eine Karte öffnet
|
||||
* den jeweiligen Arbeitsbereich. Die Personalverwaltung selbst liegt im
|
||||
* Menüpunkt «Meine Agenten» und braucht keine eigene Person am Bildschirm.
|
||||
*
|
||||
* Bewusst keine nachgebaute Raumperspektive mehr: eine gezeichnete Kulisse
|
||||
* bleibt neben fotografierten Porträts erkennbar gezeichnet, und ein Raum
|
||||
* allein macht niemanden menschlich. Was menschlich wirkt, ist dasselbe wie im
|
||||
* echten Büro — dass jemand da ist, etwas zu sagen hat und nachweisbar
|
||||
* gearbeitet hat.
|
||||
*/
|
||||
export function AgentStaffOverview() {
|
||||
const navigate = useNavigate()
|
||||
const { data: staff } = useAgentPresence()
|
||||
|
||||
const handleOpen = useCallback((path: string) => navigate(path), [navigate])
|
||||
|
||||
// Ein Durchgang über dieselbe Liste statt zweier Filterläufe (CLAUDE.md §10.4).
|
||||
const { urgent, onDuty } = useMemo(() => {
|
||||
let urgentCount = 0
|
||||
let onDutyCount = 0
|
||||
for (const person of staff) {
|
||||
if (person.workload?.tone === WorkloadTone.URGENT) urgentCount += 1
|
||||
if (person.onDuty) onDutyCount += 1
|
||||
}
|
||||
return { urgent: urgentCount, onDuty: onDutyCount }
|
||||
}, [staff])
|
||||
|
||||
return (
|
||||
<Box
|
||||
component="section"
|
||||
aria-label="Digitale Belegschaft"
|
||||
sx={{
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
minHeight: MIN_SCENE_PX,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<StaffBackdrop />
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
width: '100%',
|
||||
maxWidth: MAX_CONTENT_PX,
|
||||
mx: 'auto',
|
||||
px: { xs: 2.5, lg: 4 },
|
||||
py: { xs: 2.5, lg: 3 },
|
||||
gap: { xs: 2, lg: 2.5 },
|
||||
}}
|
||||
>
|
||||
<StaffHeadline urgent={urgent} onDuty={onDuty} total={staff.length} />
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
/*
|
||||
* Ab `lg` stehen alle fünf nebeneinander in einer Reihe. Zwei
|
||||
* Reihen brauchten rund 250 Pixel mehr Höhe — auf einem Laptop war
|
||||
* das genau der Betrag, der den Bereich zum Scrollen brachte und
|
||||
* die Porträts der oberen Reihe abschnitt. Eine Reihe ist zugleich
|
||||
* das ehrlichere Bild: fünf Kolleginnen und Kollegen nebeneinander,
|
||||
* keine Hierarchie über zwei Ebenen.
|
||||
*
|
||||
* Darunter wird umbrochen, weil fünf Karten nebeneinander unter
|
||||
* 1200 Pixeln unlesbar schmal würden.
|
||||
*/
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'stretch',
|
||||
gap: { xs: 1.5, lg: 2 },
|
||||
'& > *': {
|
||||
flex: {
|
||||
xs: '1 1 100%',
|
||||
sm: '0 1 calc((100% - 12px) / 2)',
|
||||
md: '0 1 calc((100% - 24px) / 3)',
|
||||
lg: '0 1 calc((100% - 64px) / 5)',
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
{staff.map(person => (
|
||||
<AgentStaffCard key={person.agentId} presence={person} onOpen={handleOpen} />
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Anriss über der Belegschaft.
|
||||
*
|
||||
* Beantwortet die Frage, mit der jemand diese Seite öffnet: muss ich heute
|
||||
* irgendwo eingreifen? Ohne den Satz müsste man jede Karte einzeln lesen und
|
||||
* selbst zusammenzählen.
|
||||
*/
|
||||
function StaffHeadline({ urgent, onDuty, total }: { urgent: number; onDuty: number; total: number }) {
|
||||
const summary =
|
||||
urgent > 0
|
||||
? `${urgent} ${urgent === 1 ? 'Mitarbeitender braucht' : 'Mitarbeitende brauchen'} heute eine Entscheidung von dir`
|
||||
: 'Alle laufen — heute liegt nichts bei dir'
|
||||
|
||||
return (
|
||||
<Box sx={{ flexShrink: 0 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Box sx={{ width: 7, height: 7, borderRadius: '50%', bgcolor: DS_NEUTRAL.gold }} />
|
||||
<Typography
|
||||
sx={{
|
||||
fontSize: '0.6875rem',
|
||||
fontWeight: 700,
|
||||
letterSpacing: 1.4,
|
||||
textTransform: 'uppercase',
|
||||
color: DS_NEUTRAL.gold,
|
||||
}}
|
||||
>
|
||||
{onDuty} von {total} im Dienst
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography
|
||||
component="h2"
|
||||
sx={{
|
||||
mt: 0.5,
|
||||
fontWeight: 700,
|
||||
fontSize: { xs: '1.25rem', lg: '1.6rem' },
|
||||
lineHeight: 1.2,
|
||||
color: DS_NEUTRAL.white,
|
||||
}}
|
||||
>
|
||||
{summary}
|
||||
</Typography>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { memo } from 'react'
|
||||
import { Box } from '@mui/material'
|
||||
import { ROOM_BACKDROP } from './roomBackdrop'
|
||||
import { DS_NEUTRAL } from '../../lib/ds'
|
||||
|
||||
/**
|
||||
* Bildhintergrund der Belegschaftsansicht (Runde 5, §3).
|
||||
*
|
||||
* Bewusst eine einzige Bildfläche mit Verlauf — und nicht länger ein aus
|
||||
* Flächen konstruierter Raum. Ein selbstgezeichneter Raum bleibt neben
|
||||
* fotografierten Porträts immer erkennbar gezeichnet; die Bildwirkung muss
|
||||
* deshalb aus dem Foto kommen, nicht aus Geometrie.
|
||||
*
|
||||
* Die Komponente selbst weiss nichts über das Motiv. Sie zeigt, was
|
||||
* `ROOM_BACKDROP` liefert — die Aussicht heute, eine Raumaufnahme später.
|
||||
*/
|
||||
export const StaffBackdrop = memo(function StaffBackdrop() {
|
||||
return (
|
||||
<Box sx={{ position: 'absolute', inset: 0, overflow: 'hidden', bgcolor: DS_NEUTRAL.sidebar }}>
|
||||
<Box
|
||||
component="img"
|
||||
src={ROOM_BACKDROP.src}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'cover',
|
||||
objectPosition: ROOM_BACKDROP.focus,
|
||||
}}
|
||||
/>
|
||||
|
||||
<Box sx={{ position: 'absolute', inset: 0, background: ROOM_BACKDROP.scrim }} />
|
||||
|
||||
</Box>
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,5 @@
|
||||
// Digitale Belegschaft auf der Startseite (Runde 5, §3). Barrel-Export (CLAUDE.md §13).
|
||||
|
||||
export { AgentStaffOverview } from './AgentStaffOverview'
|
||||
export { AgentStaffCard } from './AgentStaffCard'
|
||||
export { ROOM_BACKDROP } from './roomBackdrop'
|
||||
@@ -0,0 +1,32 @@
|
||||
import buero from '../../assets/staff/buero.jpg'
|
||||
|
||||
/**
|
||||
* Umgebung der Belegschaftsansicht auf der Startseite (Runde 5, §3).
|
||||
*
|
||||
* Genau eine Stelle bestimmt, welches Bild hinter den Personenkarten liegt.
|
||||
* Ein Austausch ist damit ein Einzeiler; die Herkunft steht in `HERKUNFT.md`
|
||||
* neben der Bilddatei.
|
||||
*
|
||||
* Das Bild ist Umgebung, nicht Motiv: es zeigt ein Büro, damit die digitalen
|
||||
* Mitarbeitenden einen Arbeitsplatz haben statt eines leeren Hintergrunds. Der
|
||||
* Verlauf darüber nimmt ihm den grössten Teil des Kontrasts — sonst
|
||||
* konkurriert jeder Bürostuhl mit den Gesichtern und der Schrift davor.
|
||||
*/
|
||||
export interface RoomBackdrop {
|
||||
src: string
|
||||
/** Bildausschnitt bei `object-fit: cover`. */
|
||||
focus: string
|
||||
/** Verlauf über dem Bild: Lesbarkeit und Übergang zur Objektliste darunter. */
|
||||
scrim: string
|
||||
}
|
||||
|
||||
export const ROOM_BACKDROP: RoomBackdrop = {
|
||||
src: buero,
|
||||
focus: '50% 58%',
|
||||
scrim: [
|
||||
// Grundton: das Büro tritt weit zurück, die Karten treten hervor.
|
||||
'linear-gradient(180deg, rgba(9,14,23,0.84) 0%, rgba(9,14,23,0.76) 52%, rgba(9,14,23,0.92) 100%)',
|
||||
// Warmes Streiflicht von links, damit die Fläche nicht tot wirkt.
|
||||
'radial-gradient(85% 120% at 10% 6%, rgba(184,151,90,0.18) 0%, rgba(184,151,90,0) 58%)',
|
||||
].join(', '),
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 }}
|
||||
>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { AgentWorkload } from './agentWorkload'
|
||||
|
||||
/**
|
||||
* Property On — ein digitaler Mitarbeitender, so wie er auf der Startseite
|
||||
* auftritt (Runde 5).
|
||||
*
|
||||
* Zusammengesetzt aus drei Quellen, weil Menschlichkeit aus dem Zusammenspiel
|
||||
* entsteht und nicht aus einem einzelnen Feld:
|
||||
*
|
||||
* - **Personalien** — Name, Funktion, Abteilung, Personalnummer. Wer eine
|
||||
* Personalnummer hat, ist Personal und kein Werkzeug.
|
||||
* - **Auslastung** — woran er gerade sitzt, in seinen eigenen Worten.
|
||||
* - **Letzte Handlung** — was er zuletzt getan hat, mit Zeitangabe. Das ist
|
||||
* der Beleg, dass wirklich jemand arbeitet; ohne ihn bleibt jede Zusage
|
||||
* eine Behauptung.
|
||||
*/
|
||||
export interface AgentPresence {
|
||||
agentId: string
|
||||
name: string
|
||||
role: string
|
||||
/** Abteilung laut Personalblatt; leer, solange kein Dossier vorliegt. */
|
||||
department: string
|
||||
/** Personalnummer laut Personalblatt; leer, solange kein Dossier vorliegt. */
|
||||
personnelNumber: string
|
||||
/** Zielseite des Klicks auf die Karte. */
|
||||
path: string
|
||||
/** true, solange der Mitarbeitende nicht pausiert ist. */
|
||||
onDuty: boolean
|
||||
/**
|
||||
* Was der Mitarbeitende selbst entscheiden darf — «Autonom», «Freigabe
|
||||
* erforderlich» oder «Nur Vorschlag». Die Stufe ist das stärkste Merkmal des
|
||||
* Ansatzes: sie sagt, wie viel Verantwortung jemand trägt, und trennt
|
||||
* Personal von einer Automatisierung.
|
||||
*/
|
||||
autonomy: string
|
||||
workload: AgentWorkload
|
||||
/** Zuletzt protokollierte Handlung — fehlt, wenn noch nichts vorliegt. */
|
||||
lastAction?: {
|
||||
/** Was getan wurde, als ganzer Satz aus dem Protokoll. */
|
||||
title: string
|
||||
/** Wann, in Alltagssprache: «vor 12 Min.», «gestern 16:40». */
|
||||
when: string
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 })
|
||||
@@ -0,0 +1,25 @@
|
||||
import { useMemo } from 'react'
|
||||
import { useTeamAgents } from './useTeamAgents'
|
||||
import { useAgentProtocol } from './useAgentProtocol'
|
||||
import { useAgentWorkload } from './useAgentWorkload'
|
||||
import { buildAgentPresence } from '../services/agentPresenceService'
|
||||
import type { AgentPresence } from '../domain/agentPresence'
|
||||
|
||||
/**
|
||||
* Die Belegschaft, wie sie auf der Startseite auftritt (Runde 5).
|
||||
*
|
||||
* Der Hook sammelt Personalblätter, Protokoll und Auslastung ein; was daraus
|
||||
* wird, entscheidet `agentPresenceService` (CLAUDE.md §4.2).
|
||||
*/
|
||||
export function useAgentPresence(): { data: AgentPresence[]; isLoading: boolean } {
|
||||
const { data: agents = [], isLoading: agentsLoading } = useTeamAgents()
|
||||
const { data: protocol = [], isLoading: protocolLoading } = useAgentProtocol()
|
||||
const { data: workload, isLoading: workloadLoading } = useAgentWorkload()
|
||||
|
||||
const data = useMemo(
|
||||
() => buildAgentPresence({ agents, protocol, workload }),
|
||||
[agents, protocol, workload],
|
||||
)
|
||||
|
||||
return { data, isLoading: agentsLoading || protocolLoading || workloadLoading }
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
+29
-1
@@ -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 = {
|
||||
|
||||
@@ -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)' },
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
]
|
||||
@@ -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 }}>
|
||||
<AgentWorkspaceHero agentId={NORA.id} name={NORA.name} role={NORA.role} />
|
||||
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>
|
||||
{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 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>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
)
|
||||
|
||||
@@ -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 { AgentStaffOverview } 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,16 @@ export default function Properties() {
|
||||
const filtered = useMemo(() => applyFilters(properties, filters), [properties, filters])
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
||||
// Runde 5, §2.3: Die Startseite scrollt als Ganzes. Die Belegschaft steht
|
||||
// oben und misst sich an der Viewport-Höhe; Titel und Filterbalken bleiben
|
||||
// beim Einstieg gerade noch sichtbar, die Objektliste beginnt darunter.
|
||||
// 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%' }}>
|
||||
<AgentStaffOverview />
|
||||
|
||||
<PageHeader
|
||||
title="Objektverwaltung"
|
||||
title={MY_PROPERTIES_LABEL}
|
||||
subtitle={`${filtered.length} von ${properties.length} Objekten`}
|
||||
secondaryActions={
|
||||
<ViewToggle
|
||||
@@ -139,7 +147,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 => (
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { CrmLead } from '../domain/crmLead'
|
||||
|
||||
export interface ICrmLeadProvider {
|
||||
getAll(): Promise<CrmLead[]>
|
||||
getById(id: string): Promise<CrmLead | null>
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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,130 @@
|
||||
/**
|
||||
* Property On — Belegschaftsansicht der Startseite (Runde 5).
|
||||
*
|
||||
* Hier steht, was die digitalen Mitarbeitenden menschlich macht: dass sie eine
|
||||
* Reihenfolge haben, Personalien tragen, einen Dienstzustand zeigen und
|
||||
* belegen können, was sie zuletzt getan haben. Jede dieser Zusicherungen kann
|
||||
* still brechen, ohne dass die Oberfläche kaputt aussieht — eine leere
|
||||
* Personalnummer fällt niemandem auf.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { buildAgentPresence, formatRelativeTime } from '../agentPresenceService'
|
||||
import type { AgentPresenceInput } from '../agentPresenceService'
|
||||
import { AgentStatus } from '../../domain/teamAgent'
|
||||
import { WorkloadTone } from '../../domain/agentWorkload'
|
||||
import type { AgentWorkload } from '../../domain/agentWorkload'
|
||||
|
||||
const NOW = Date.parse('2026-08-06T12:00:00.000Z')
|
||||
const MINUTE = 60 * 1000
|
||||
const HOUR = 60 * MINUTE
|
||||
const DAY = 24 * HOUR
|
||||
|
||||
const ago = (ms: number) => new Date(NOW - ms).toISOString()
|
||||
|
||||
function workload(agentId: string): AgentWorkload {
|
||||
return { agentId, count: 0, label: 'nichts offen', tone: WorkloadTone.CALM, voice: 'Alles ruhig.' }
|
||||
}
|
||||
|
||||
const WORKLOAD = Object.fromEntries(
|
||||
['ferdi', 'bruno', 'livia', 'nora', 'sina'].map(id => [id, workload(id)]),
|
||||
)
|
||||
|
||||
function input(partial: Partial<AgentPresenceInput> = {}): AgentPresenceInput {
|
||||
return { agents: [], protocol: [], workload: WORKLOAD, ...partial }
|
||||
}
|
||||
|
||||
// Nur die Felder, die der Dienst liest.
|
||||
const dossier = (id: string, extra: Record<string, unknown> = {}) =>
|
||||
({
|
||||
id,
|
||||
department: 'Bewirtschaftung',
|
||||
personnelNumber: 'PO-TEST-01',
|
||||
status: AgentStatus.ACTIVE,
|
||||
autonomyLevel: 'AUTONOMOUS',
|
||||
...extra,
|
||||
}) as AgentPresenceInput['agents'][number]
|
||||
|
||||
const entry = (agentId: string, msAgo: number, title: string) =>
|
||||
({ agentId, timestamp: ago(msAgo), title }) as AgentPresenceInput['protocol'][number]
|
||||
|
||||
describe('Zeitangabe in Alltagssprache', () => {
|
||||
it('nennt frische Ereignisse in Minuten', () => {
|
||||
expect(formatRelativeTime(ago(12 * MINUTE), NOW)).toBe('vor 12 Min.')
|
||||
})
|
||||
|
||||
it('wechselt nach einer Stunde auf Stunden, mit korrekter Einzahl', () => {
|
||||
expect(formatRelativeTime(ago(1 * HOUR + MINUTE), NOW)).toBe('vor 1 Stunde')
|
||||
expect(formatRelativeTime(ago(5 * HOUR), NOW)).toBe('vor 5 Stunden')
|
||||
})
|
||||
|
||||
it('nennt ab gestern die Uhrzeit — «vor 19 Stunden» ordnet niemand einem Arbeitstag zu', () => {
|
||||
expect(formatRelativeTime(ago(30 * HOUR), NOW)).toMatch(/^gestern \d{2}:\d{2}$/)
|
||||
})
|
||||
|
||||
it('nennt weiter zurückliegende Ereignisse mit Datum', () => {
|
||||
expect(formatRelativeTime(ago(9 * DAY), NOW)).toMatch(/^\d{2}\.\d{2}\. \d{2}:\d{2}$/)
|
||||
})
|
||||
|
||||
it('bleibt bei unbrauchbarem Zeitstempel stumm statt «Invalid Date» zu zeigen', () => {
|
||||
expect(formatRelativeTime('kein datum', NOW)).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Belegschaft der Startseite', () => {
|
||||
it('führt die fünf Agenten in verbindlicher Reihenfolge', () => {
|
||||
const staff = buildAgentPresence(input(), NOW)
|
||||
expect(staff.map(p => p.agentId)).toEqual(['ferdi', 'bruno', 'livia', 'nora', 'sina'])
|
||||
})
|
||||
|
||||
it('übernimmt Personalien aus dem Personalblatt', () => {
|
||||
const staff = buildAgentPresence(
|
||||
input({ agents: [dossier('ferdi', { department: 'Bewirtschaftung', personnelNumber: 'PO-ZD-02' })] }),
|
||||
NOW,
|
||||
)
|
||||
const ferdi = staff.find(p => p.agentId === 'ferdi')!
|
||||
expect(ferdi.department).toBe('Bewirtschaftung')
|
||||
expect(ferdi.personnelNumber).toBe('PO-ZD-02')
|
||||
expect(ferdi.autonomy).toBe('Autonom')
|
||||
})
|
||||
|
||||
it('zeigt einen pausierten Mitarbeitenden als nicht im Dienst', () => {
|
||||
const staff = buildAgentPresence(
|
||||
input({ agents: [dossier('sina', { status: AgentStatus.PAUSED })] }),
|
||||
NOW,
|
||||
)
|
||||
expect(staff.find(p => p.agentId === 'sina')!.onDuty).toBe(false)
|
||||
// Wer kein Dossier hat, gilt als im Dienst — sonst stünde die halbe
|
||||
// Belegschaft nur deshalb still, weil Stammdaten fehlen.
|
||||
expect(staff.find(p => p.agentId === 'bruno')!.onDuty).toBe(true)
|
||||
})
|
||||
|
||||
it('nimmt je Mitarbeitendem den jüngsten Protokolleintrag', () => {
|
||||
const staff = buildAgentPresence(
|
||||
input({
|
||||
protocol: [
|
||||
entry('livia', 5 * DAY, 'Alter Eintrag'),
|
||||
entry('livia', 20 * MINUTE, 'Jüngster Eintrag'),
|
||||
entry('livia', 2 * DAY, 'Mittlerer Eintrag'),
|
||||
],
|
||||
}),
|
||||
NOW,
|
||||
)
|
||||
const livia = staff.find(p => p.agentId === 'livia')!
|
||||
expect(livia.lastAction?.title).toBe('Jüngster Eintrag')
|
||||
expect(livia.lastAction?.when).toBe('vor 20 Min.')
|
||||
})
|
||||
|
||||
it('lässt die letzte Handlung offen, statt eine zu erfinden', () => {
|
||||
const staff = buildAgentPresence(input(), NOW)
|
||||
expect(staff.every(p => p.lastAction === undefined)).toBe(true)
|
||||
})
|
||||
|
||||
it('ignoriert Protokolleinträge ohne Zuordnung — sie gehören keinem Mitarbeitenden', () => {
|
||||
const staff = buildAgentPresence(
|
||||
input({ protocol: [{ timestamp: ago(MINUTE), title: 'Verbindung entfernt' } as AgentPresenceInput['protocol'][number]] }),
|
||||
NOW,
|
||||
)
|
||||
expect(staff.every(p => p.lastAction === undefined)).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -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))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,95 @@
|
||||
import type { AgentPresence } from '../domain/agentPresence'
|
||||
import type { AgentWorkload } from '../domain/agentWorkload'
|
||||
import type { AgentProtocolEntry } from '../domain/agentProtocol'
|
||||
import type { TeamAgent } from '../domain/teamAgent'
|
||||
import { AgentStatus } from '../domain/teamAgent'
|
||||
import { AGENT_WORKSPACES } from '../lib/agentWorkspaces'
|
||||
import { AGENT_AUTONOMY_LABELS } from '../lib/constants'
|
||||
|
||||
/**
|
||||
* Setzt die Belegschaftsansicht der Startseite zusammen (Runde 5).
|
||||
*
|
||||
* Reine Geschäftslogik ausserhalb von React (CLAUDE.md §4.2): welcher Eintrag
|
||||
* als «letzte Handlung» gilt und wie eine Zeitangabe in Alltagssprache lautet,
|
||||
* sind fachliche Festlegungen. Der Stichzeitpunkt ist ein Parameter — dieselbe
|
||||
* Datenlage ergibt damit immer dasselbe Ergebnis und lässt sich prüfen.
|
||||
*/
|
||||
|
||||
const MINUTE = 60 * 1000
|
||||
const HOUR = 60 * MINUTE
|
||||
const DAY = 24 * HOUR
|
||||
|
||||
/**
|
||||
* Zeitangabe in Alltagssprache.
|
||||
*
|
||||
* Bewusst grob: «vor 12 Min.» ist für einen Menschen brauchbarer als
|
||||
* «14:32:07». Ab gestern steht die Uhrzeit dabei, weil «vor 19 Stunden»
|
||||
* niemand in einen Arbeitstag einordnen kann.
|
||||
*/
|
||||
export function formatRelativeTime(iso: string, now: number): string {
|
||||
const t = new Date(iso).getTime()
|
||||
if (!Number.isFinite(t)) return ''
|
||||
|
||||
const diff = now - t
|
||||
if (diff < 0) return 'gerade eben'
|
||||
if (diff < MINUTE) return 'gerade eben'
|
||||
if (diff < HOUR) return `vor ${Math.floor(diff / MINUTE)} Min.`
|
||||
if (diff < DAY) {
|
||||
const hours = Math.floor(diff / HOUR)
|
||||
return `vor ${hours} ${hours === 1 ? 'Stunde' : 'Stunden'}`
|
||||
}
|
||||
|
||||
const date = new Date(t)
|
||||
const time = date.toLocaleTimeString('de-CH', { hour: '2-digit', minute: '2-digit' })
|
||||
if (diff < 2 * DAY) return `gestern ${time}`
|
||||
return date.toLocaleDateString('de-CH', { day: '2-digit', month: '2-digit' }) + ` ${time}`
|
||||
}
|
||||
|
||||
export interface AgentPresenceInput {
|
||||
agents: TeamAgent[]
|
||||
protocol: AgentProtocolEntry[]
|
||||
workload: Record<string, AgentWorkload>
|
||||
}
|
||||
|
||||
/**
|
||||
* @param now Stichzeitpunkt in Millisekunden — als Parameter, nicht aus der
|
||||
* Systemuhr, damit das Ergebnis reproduzierbar bleibt.
|
||||
*/
|
||||
export function buildAgentPresence(
|
||||
{ agents, protocol, workload }: AgentPresenceInput,
|
||||
now: number = Date.now(),
|
||||
): AgentPresence[] {
|
||||
// Jüngster Protokolleintrag je Mitarbeitendem. Ein Durchgang über die Liste
|
||||
// statt einer Suche je Agent — das Protokoll wächst, die Belegschaft nicht.
|
||||
const latest = new Map<string, AgentProtocolEntry>()
|
||||
for (const entry of protocol) {
|
||||
if (!entry.agentId) continue
|
||||
const known = latest.get(entry.agentId)
|
||||
if (!known || entry.timestamp > known.timestamp) latest.set(entry.agentId, entry)
|
||||
}
|
||||
|
||||
const byId = new Map(agents.map(a => [a.id, a]))
|
||||
const roster = AGENT_WORKSPACES
|
||||
|
||||
return roster.map(member => {
|
||||
const dossier = byId.get(member.id)
|
||||
const action = latest.get(member.id)
|
||||
|
||||
return {
|
||||
agentId: member.id,
|
||||
name: member.name,
|
||||
role: member.role,
|
||||
department: dossier?.department ?? '',
|
||||
personnelNumber: dossier?.personnelNumber ?? '',
|
||||
path: member.path,
|
||||
// Ohne Personalblatt gilt jemand als im Dienst: die Belegschaftsansicht
|
||||
// soll niemanden fälschlich als pausiert zeigen, nur weil ein Dossier fehlt.
|
||||
onDuty: dossier ? dossier.status === AgentStatus.ACTIVE : true,
|
||||
autonomy: dossier ? (AGENT_AUTONOMY_LABELS[dossier.autonomyLevel] ?? '') : '',
|
||||
workload: workload[member.id],
|
||||
lastAction: action
|
||||
? { title: action.title, when: formatRelativeTime(action.timestamp, now) }
|
||||
: undefined,
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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]),
|
||||
)
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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(),
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -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 }),
|
||||
}))
|
||||
Reference in New Issue
Block a user