feat(startseite): Giorgio entfernt, Belegschaft auf die fünf Agenten

Auf ausdrücklichen Wunsch: Giorgio entfällt vollständig. Das weicht bewusst
von §6 des Auftrags ab, der ihn als Personalverwalter und Vorgesetzten
vorsah — festgehalten, damit die Abweichung später nachvollziehbar bleibt.

Entfernt: Rollendefinition `AGENT_SUPERVISOR`, sein gezeichnetes Porträt, sein
Eintrag in der Auslastung und in der Belegschaftsansicht sowie die Erwähnung
in der Produkttour. Die Personalverwaltung bleibt unverändert über den
Menüpunkt «Meine Agenten» erreichbar — sie brauchte nie eine eigene Person am
Bildschirm.

Mit Giorgio entfällt auch die einzige Kennzahl, die aus den Freigabevorgängen
kam; `computeAgentWorkload` braucht `agentWorkItems` deshalb nicht mehr.

Ordner und Komponenten heissen jetzt nach ihrem Inhalt: `components/staff/`,
`AgentStaffOverview`, `StaffBackdrop`. «Meetingraum» war seit dem Umbau auf
Personenkarten irreführend, und ein falscher Name kostet den Nächsten mehr
Zeit als die Umbenennung.

Fünf statt sechs Karten heisst Flexlayout statt Dreierraster: drei oben, zwei
mittig darunter, gleich breit und ohne Lücke. Die Karten geben ihre Höhe nicht
mehr selbst vor — mit `height: 100%` beanspruchte jede die volle Fläche und
die zweite Zeile fiel aus dem sichtbaren Bereich.

Geprüft: 433 Tests, Typecheck, Build, ESLint, Token-Schwelle (889/925).
Browser 1280–1920 px und Tablet: fünf Karten vollständig sichtbar, keine
Überlappung, Titel und «Filter & Übersicht» beim Einstieg sichtbar, alle fünf
Klicks auf der richtigen Subpage, «Meine Agenten» weiterhin erreichbar, keine
Konsolenfehler, keine 404.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Benjamin Sutter
2026-08-06 15:03:18 +02:00
parent 435f2aa2f1
commit 093a958dcf
19 changed files with 56 additions and 184 deletions

Before

Width:  |  Height:  |  Size: 477 KiB

After

Width:  |  Height:  |  Size: 477 KiB

-60
View File
@@ -1,60 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200" width="200" height="200" role="img">
<title>Giorgio</title>
<defs>
<linearGradient id="g-bg" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#efe9e0"/>
<stop offset="1" stop-color="#ddd5c9"/>
</linearGradient>
<linearGradient id="g-suit" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#2c3a4f"/>
<stop offset="1" stop-color="#1c2637"/>
</linearGradient>
<clipPath id="g-clip">
<rect x="0" y="0" width="200" height="200"/>
</clipPath>
</defs>
<g clip-path="url(#g-clip)">
<rect width="200" height="200" fill="url(#g-bg)"/>
<!-- Schultern und Sakko -->
<path d="M100 128c34 0 62 20 68 48 3 13 4 20 4 24H28c0-4 1-11 4-24 6-28 34-48 68-48z" fill="url(#g-suit)"/>
<!-- Hemdkragen -->
<path d="M100 128l18 8-18 30-18-30z" fill="#f3f1ec"/>
<path d="M100 166l-9-16 9 4 9-4z" fill="#7c5f3a"/>
<path d="M100 154l7 5-3 41h-8l-3-41z" fill="#7c5f3a"/>
<!-- Reversgrat -->
<path d="M82 136l18 22-24 42-8-52z" fill="#26334a"/>
<path d="M118 136l-18 22 24 42 8-52z" fill="#26334a"/>
<!-- Hals -->
<path d="M84 104h32v28c0 4-16 10-16 10s-16-6-16-10z" fill="#c9a184"/>
<path d="M84 110c9 7 23 7 32 0v8c-9 7-23 7-32 0z" fill="#b08a70" opacity=".5"/>
<!-- Kopf -->
<ellipse cx="100" cy="80" rx="34" ry="40" fill="#dcb193"/>
<!-- Ohren -->
<ellipse cx="66" cy="82" rx="6" ry="9" fill="#d3a688"/>
<ellipse cx="134" cy="82" rx="6" ry="9" fill="#d3a688"/>
<!-- Haar, grau meliert -->
<path d="M100 34c22 0 36 14 36 33 0 5-1 10-2 13-2-3-3-9-3-14-8 4-20 6-31 6-11 0-21-2-28-6-1 5-2 11-4 14-1-3-2-8-2-13 0-19 12-33 34-33z" fill="#8d8d8f"/>
<path d="M100 34c14 0 24 6 30 16-9-6-19-9-30-9s-21 3-30 9c6-10 16-16 30-16z" fill="#a5a5a7"/>
<!-- Brille -->
<g fill="none" stroke="#3b4350" stroke-width="3">
<rect x="72" y="70" width="24" height="18" rx="6"/>
<rect x="104" y="70" width="24" height="18" rx="6"/>
<path d="M96 78h8M72 76l-8 3M128 76l8 3"/>
</g>
<!-- Augen -->
<ellipse cx="84" cy="79" rx="3" ry="3.4" fill="#3a3128"/>
<ellipse cx="116" cy="79" rx="3" ry="3.4" fill="#3a3128"/>
<!-- Brauen -->
<path d="M74 63c6-3 14-3 20 0" stroke="#8d8d8f" stroke-width="3.5" fill="none" stroke-linecap="round"/>
<path d="M106 63c6-3 14-3 20 0" stroke="#8d8d8f" stroke-width="3.5" fill="none" stroke-linecap="round"/>
<!-- Nase und Mund -->
<path d="M100 84v10c0 2-2 3-4 4" stroke="#bc8f74" stroke-width="3" fill="none" stroke-linecap="round"/>
<path d="M89 106c7 4 15 4 22 0" stroke="#a9705a" stroke-width="3.2" fill="none" stroke-linecap="round"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 2.7 KiB

+1 -1
View File
@@ -66,7 +66,7 @@ export const WORKSPACE_CONFIG: Record<WorkspaceType, WorkspaceConfig> = {
chipColor: '#152642', chipColor: '#152642',
navItems: [ navItems: [
// Runde 5, §2.1/§2.2: «Übersicht» ist entfernt, «Meine Objekte» heisst im // Runde 5, §2.1/§2.2: «Übersicht» ist entfernt, «Meine Objekte» heisst im
// Menü «Startseite». Der Meetingraum und die Objektverwaltung liegen auf // Menü «Startseite». Die Belegschaft und die Objektverwaltung liegen auf
// derselben Seite — zwei Einträge für eine Seite wären ein toter Link. // 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.PROPERTIES, label: HOME_NAV_LABEL, icon: Building2 },
{ {
+4 -4
View File
@@ -31,7 +31,7 @@ import { DS_ACCENT, DS_BRAND, DS_TEXT, LEAD_CHANNEL_META } from '../../lib/ds'
* Sie erklärt die Anwendung, wie sie seit dieser Runde aufgebaut ist: der * Sie erklärt die Anwendung, wie sie seit dieser Runde aufgebaut ist: der
* entfernte Bereich «Übersicht» kommt nicht mehr vor, der Menüeintrag heisst * entfernte Bereich «Übersicht» kommt nicht mehr vor, der Menüeintrag heisst
* «Startseite», und «Meine Objekte» bezeichnet ausschliesslich den * «Startseite», und «Meine Objekte» bezeichnet ausschliesslich den
* Objektbereich unterhalb des Meetingraums. * Objektbereich unterhalb der Belegschaft.
* *
* Die Tour zeigt Bilder und Text in einem Dialog und markiert keine Elemente * 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 * auf der Seite. Das ist Absicht: eine Tour, die auf DOM-Knoten zeigt, läuft
@@ -73,12 +73,12 @@ function SlideStartseite() {
<SlideFrame <SlideFrame
icon={<Building2 size={48} color={DS_BRAND.main} />} icon={<Building2 size={48} color={DS_BRAND.main} />}
title={`Ihre ${HOME_NAV_LABEL}`} title={`Ihre ${HOME_NAV_LABEL}`}
lead="Der Einstieg ist der Besprechungsraum Ihrer digitalen Mitarbeitenden — darunter folgt Ihr Objektbestand." lead="Der Einstieg zeigt Ihre digitalen Mitarbeitenden im Dienst — darunter folgt Ihr Objektbestand."
> >
<FeatureRow <FeatureRow
icon={<MousePointerClick size={18} />} icon={<MousePointerClick size={18} />}
color={DS_BRAND.main} color={DS_BRAND.main}
text="Ein Klick auf einen Agenten am Tisch öffnet direkt dessen Arbeitsbereich." text="Jede Karte zeigt, woran der Mitarbeitende gerade sitzt. Ein Klick öffnet seinen Arbeitsbereich."
/> />
<FeatureRow <FeatureRow
icon={<Building2 size={18} />} icon={<Building2 size={18} />}
@@ -119,7 +119,7 @@ function SlideAgenten() {
<FeatureRow <FeatureRow
icon={<ClipboardList size={18} />} icon={<ClipboardList size={18} />}
color={DS_ACCENT.violet.main} color={DS_ACCENT.violet.main}
text="Giorgio am Kopfende des Tisches führt in die Personalverwaltung: Aufgaben, Kanäle & Systeme, Bearbeitungsverlauf." text="Über «Meine Agenten» verwalten Sie Ihr Personal: Aufgaben, Kanäle & Systeme und den Bearbeitungsverlauf."
/> />
</SlideFrame> </SlideFrame>
) )
@@ -58,7 +58,10 @@ export const AgentStaffCard = memo(function AgentStaffCard({ presence, onOpen }:
gap: 1.25, gap: 1.25,
p: { xs: 1.75, xl: 2 }, p: { xs: 1.75, xl: 2 },
borderRadius: 3, borderRadius: 3,
height: '100%', // 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 // Glasfläche statt Vollton: das Büro dahinter bleibt spürbar, ohne die
// Schrift zu stören. // Schrift zu stören.
bgcolor: 'rgba(255,255,255,0.06)', bgcolor: 'rgba(255,255,255,0.06)',
@@ -1,7 +1,7 @@
import { useCallback, useMemo } from 'react' import { useCallback, useMemo } from 'react'
import { useNavigate } from 'react-router' import { useNavigate } from 'react-router'
import { Box, Typography } from '@mui/material' import { Box, Typography } from '@mui/material'
import { MeetingRoomScene } from './MeetingRoomScene' import { StaffBackdrop } from './StaffBackdrop'
import { AgentStaffCard } from './AgentStaffCard' import { AgentStaffCard } from './AgentStaffCard'
import { useAgentPresence } from '../../hooks/useAgentPresence' import { useAgentPresence } from '../../hooks/useAgentPresence'
import { WorkloadTone } from '../../domain/agentWorkload' import { WorkloadTone } from '../../domain/agentWorkload'
@@ -24,12 +24,13 @@ const RESERVED_PX = TOP_BAR_PX + KEEP_VISIBLE_BELOW_PX
const MIN_SCENE_PX = 420 const MIN_SCENE_PX = 420
/** /**
* Die digitale Belegschaft auf der Startseite (Runde 5, §3/§6). * Die digitale Belegschaft auf der Startseite (Runde 5, §3).
* *
* Sechs Mitarbeitende an ihrem Arbeitsplatz mit Porträt, Funktion, * Die fünf Mitarbeitenden an ihrem Arbeitsplatz mit Porträt, Funktion,
* Abteilung, Personalnummer, Dienstzustand, einem Satz in der ersten Person * Abteilung, Personalnummer, Dienstzustand, einem Satz in der ersten Person
* und der zuletzt protokollierten Handlung. Ein Klick auf eine Karte öffnet * und der zuletzt protokollierten Handlung. Ein Klick auf eine Karte öffnet
* den jeweiligen Arbeitsbereich, bei Giorgio die Personalverwaltung. * 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 * Bewusst keine nachgebaute Raumperspektive mehr: eine gezeichnete Kulisse
* bleibt neben fotografierten Porträts erkennbar gezeichnet, und ein Raum * bleibt neben fotografierten Porträts erkennbar gezeichnet, und ein Raum
@@ -37,7 +38,7 @@ const MIN_SCENE_PX = 420
* echten Büro dass jemand da ist, etwas zu sagen hat und nachweisbar * echten Büro dass jemand da ist, etwas zu sagen hat und nachweisbar
* gearbeitet hat. * gearbeitet hat.
*/ */
export function AgentMeetingRoom() { export function AgentStaffOverview() {
const navigate = useNavigate() const navigate = useNavigate()
const { data: staff } = useAgentPresence() const { data: staff } = useAgentPresence()
@@ -69,7 +70,7 @@ export function AgentMeetingRoom() {
overflow: 'hidden', overflow: 'hidden',
}} }}
> >
<MeetingRoomScene /> <StaffBackdrop />
<Box <Box
sx={{ sx={{
@@ -88,14 +89,21 @@ export function AgentMeetingRoom() {
sx={{ sx={{
flex: 1, flex: 1,
minHeight: 0, minHeight: 0,
display: 'grid', // Flexlayout statt Raster: bei fünf Karten bliebe in einem
gridTemplateColumns: { // Dreierraster unten eine Lücke. So stehen drei oben und zwei
xs: '1fr', // mittig darunter — gleich breit, ohne Loch.
sm: 'repeat(2, minmax(0, 1fr))', display: 'flex',
lg: 'repeat(3, minmax(0, 1fr))', flexWrap: 'wrap',
}, justifyContent: 'center',
gridAutoRows: '1fr', alignContent: 'center',
gap: { xs: 1.5, lg: 2 }, gap: { xs: 1.5, lg: 2 },
'& > *': {
flex: {
xs: '1 1 100%',
sm: '0 1 calc((100% - 12px) / 2)',
lg: '0 1 calc((100% - 32px) / 3)',
},
},
// Auf niedrigen Fenstern darf die Belegschaft scrollen, statt die // Auf niedrigen Fenstern darf die Belegschaft scrollen, statt die
// Karten bis zur Unlesbarkeit zu stauchen. // Karten bis zur Unlesbarkeit zu stauchen.
overflowY: 'auto', overflowY: 'auto',
@@ -114,8 +122,8 @@ export function AgentMeetingRoom() {
* Anriss über der Belegschaft. * Anriss über der Belegschaft.
* *
* Beantwortet die Frage, mit der jemand diese Seite öffnet: muss ich heute * Beantwortet die Frage, mit der jemand diese Seite öffnet: muss ich heute
* irgendwo eingreifen? Ohne den Satz müsste man sechs Karten lesen und selbst * irgendwo eingreifen? Ohne den Satz müsste man jede Karte einzeln lesen und
* zusammenzählen. * selbst zusammenzählen.
*/ */
function StaffHeadline({ urgent, onDuty, total }: { urgent: number; onDuty: number; total: number }) { function StaffHeadline({ urgent, onDuty, total }: { urgent: number; onDuty: number; total: number }) {
const summary = const summary =
@@ -4,7 +4,7 @@ import { ROOM_BACKDROP } from './roomBackdrop'
import { DS_NEUTRAL } from '../../lib/ds' import { DS_NEUTRAL } from '../../lib/ds'
/** /**
* Bildhintergrund des Agenten-Meetingraums (Runde 5, §3). * Bildhintergrund der Belegschaftsansicht (Runde 5, §3).
* *
* Bewusst eine einzige Bildfläche mit Verlauf und nicht länger ein aus * Bewusst eine einzige Bildfläche mit Verlauf und nicht länger ein aus
* Flächen konstruierter Raum. Ein selbstgezeichneter Raum bleibt neben * Flächen konstruierter Raum. Ein selbstgezeichneter Raum bleibt neben
@@ -14,7 +14,7 @@ import { DS_NEUTRAL } from '../../lib/ds'
* Die Komponente selbst weiss nichts über das Motiv. Sie zeigt, was * Die Komponente selbst weiss nichts über das Motiv. Sie zeigt, was
* `ROOM_BACKDROP` liefert die Aussicht heute, eine Raumaufnahme später. * `ROOM_BACKDROP` liefert die Aussicht heute, eine Raumaufnahme später.
*/ */
export const MeetingRoomScene = memo(function MeetingRoomScene() { export const StaffBackdrop = memo(function StaffBackdrop() {
return ( return (
<Box sx={{ position: 'absolute', inset: 0, overflow: 'hidden', bgcolor: DS_NEUTRAL.sidebar }}> <Box sx={{ position: 'absolute', inset: 0, overflow: 'hidden', bgcolor: DS_NEUTRAL.sidebar }}>
<Box <Box
@@ -1,5 +1,5 @@
// Digitale Belegschaft auf der Startseite (Runde 5, §3). Barrel-Export (CLAUDE.md §13). // Digitale Belegschaft auf der Startseite (Runde 5, §3). Barrel-Export (CLAUDE.md §13).
export { AgentMeetingRoom } from './AgentMeetingRoom' export { AgentStaffOverview } from './AgentStaffOverview'
export { AgentStaffCard } from './AgentStaffCard' export { AgentStaffCard } from './AgentStaffCard'
export { ROOM_BACKDROP } from './roomBackdrop' export { ROOM_BACKDROP } from './roomBackdrop'
@@ -1,4 +1,4 @@
import buero from '../../assets/meeting-room/buero.jpg' import buero from '../../assets/staff/buero.jpg'
/** /**
* Umgebung der Belegschaftsansicht auf der Startseite (Runde 5, §3). * Umgebung der Belegschaftsansicht auf der Startseite (Runde 5, §3).
-5
View File
@@ -50,10 +50,6 @@ import gian from '../../assets/team/gian.jpg'
import ida from '../../assets/team/ida.jpg' import ida from '../../assets/team/ida.jpg'
import jana from '../../assets/team/jana.jpg' import jana from '../../assets/team/jana.jpg'
import zeno from '../../assets/team/zeno.jpg' import zeno from '../../assets/team/zeno.jpg'
// Giorgio ist erst in Runde 5 dazugekommen und hat kein Porträt aus dem
// Konzeptdokument. Statt den Kopf eines bestehenden Agenten zweitzuverwenden —
// jeder Kopf gehört genau einer Person — trägt er ein gezeichnetes Porträt.
import giorgio from '../../assets/team/giorgio.svg'
export const AGENT_PHOTOS: Record<string, string | undefined> = { export const AGENT_PHOTOS: Record<string, string | undefined> = {
bruno, bruno,
@@ -90,5 +86,4 @@ export const AGENT_PHOTOS: Record<string, string | undefined> = {
ida, ida,
jana, jana,
zeno, zeno,
giorgio,
} }
+1 -1
View File
@@ -1,7 +1,7 @@
/** /**
* Property On — aktuelle Auslastung eines digitalen Mitarbeitenden. * Property On — aktuelle Auslastung eines digitalen Mitarbeitenden.
* *
* Der Meetingraum auf der Startseite zeigt nicht nur, *wer* für einen * Die Belegschaftsansicht der Startseite zeigt nicht nur, *wer* für einen
* arbeitet, sondern *woran gerade*. Ohne diese Zahl wäre die Szene Dekoration; * 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: * mit ihr ist sie eine Lageübersicht — genau der Anspruch aus CLAUDE.md §1:
* jeder Bildschirm ist um eine Entscheidung herum gebaut. * jeder Bildschirm ist um eine Entscheidung herum gebaut.
+4 -6
View File
@@ -4,12 +4,11 @@ import { useVisitAssignments } from './useVisitAssignments'
import { useExposeLeads } from './useExposeLeads' import { useExposeLeads } from './useExposeLeads'
import { useUnifiedLeads } from './useUnifiedLeads' import { useUnifiedLeads } from './useUnifiedLeads'
import { useProperties } from './useProperties' import { useProperties } from './useProperties'
import { useAgentWorkItems } from './useAgentWorkItems'
import { computeAgentWorkload } from '../services/agentWorkloadService' import { computeAgentWorkload } from '../services/agentWorkloadService'
import type { AgentWorkload } from '../domain/agentWorkload' import type { AgentWorkload } from '../domain/agentWorkload'
/** /**
* Aktuelle Auslastung aller sechs digitalen Mitarbeitenden (Runde 5). * Aktuelle Auslastung der fünf digitalen Mitarbeitenden (Runde 5).
* *
* Der Hook sammelt nur die Bestände ein; was daraus wird, entscheidet * Der Hook sammelt nur die Bestände ein; was daraus wird, entscheidet
* `agentWorkloadService` — Schwellen, Zeitfenster und Formulierungen sind * `agentWorkloadService` — Schwellen, Zeitfenster und Formulierungen sind
@@ -22,17 +21,16 @@ export function useAgentWorkload(): { data: Record<string, AgentWorkload>; isLoa
const { data: exposeLeads = [], isLoading: exposeLoading } = useExposeLeads() const { data: exposeLeads = [], isLoading: exposeLoading } = useExposeLeads()
const { data: leads, isLoading: leadsLoading } = useUnifiedLeads() const { data: leads, isLoading: leadsLoading } = useUnifiedLeads()
const { data: properties = [], isLoading: propertiesLoading } = useProperties() const { data: properties = [], isLoading: propertiesLoading } = useProperties()
const { data: workItems = [], isLoading: workItemsLoading } = useAgentWorkItems()
const data = useMemo( const data = useMemo(
() => computeAgentWorkload({ reminders, visits, exposeLeads, leads, properties, workItems }), () => computeAgentWorkload({ reminders, visits, exposeLeads, leads, properties }),
[reminders, visits, exposeLeads, leads, properties, workItems], [reminders, visits, exposeLeads, leads, properties],
) )
return { return {
data, data,
isLoading: isLoading:
remindersLoading || visitsLoading || exposeLoading || remindersLoading || visitsLoading || exposeLoading ||
leadsLoading || propertiesLoading || workItemsLoading, leadsLoading || propertiesLoading,
} }
} }
-23
View File
@@ -14,10 +14,6 @@
import { ROUTES } from './constants' import { ROUTES } from './constants'
export interface AgentWorkspace { export interface AgentWorkspace {
/** Abteilung laut Personalblatt. */
department?: string
/** Personalnummer laut Personalblatt. */
personnelNumber?: string
/** Identisch mit der Agenten-ID in `mock-data/agents/` und `AGENT_PHOTOS`. */ /** Identisch mit der Agenten-ID in `mock-data/agents/` und `AGENT_PHOTOS`. */
id: string id: string
name: string name: string
@@ -37,22 +33,3 @@ export const AGENT_WORKSPACES: AgentWorkspace[] = [
export function agentWorkspaceById(id: string): AgentWorkspace | undefined { export function agentWorkspaceById(id: string): AgentWorkspace | undefined {
return AGENT_WORKSPACES.find(a => a.id === id) return AGENT_WORKSPACES.find(a => a.id === id)
} }
/**
* Giorgio — Personalverwalter und Vorgesetzter der fünf Agenten (Runde 5, §6).
*
* Bewusst nicht Teil von `AGENT_WORKSPACES`: Giorgio hat keine eigene
* Arbeitsseite, sondern steht für die Adminfläche selbst. Sein Ziel ist
* «Meine Agenten» mit den Bereichen Personalverwaltung, Kanäle & Systeme und
* Bearbeitungsverlauf. Stünde er in derselben Liste, erschiene er als sechster
* Eintrag im Seitenmenü und als sechster Mitarbeitender in der Belegschaft —
* beides wäre falsch.
*/
export const AGENT_SUPERVISOR: AgentWorkspace = {
id: 'giorgio',
name: 'Giorgio',
role: 'Personalverwalter',
department: 'Personalwesen',
personnelNumber: 'PO-ZD-01',
path: ROUTES.SUPPLY.TEAM,
}
+3 -3
View File
@@ -4,7 +4,7 @@ import { Box, Collapse, Drawer, Typography, useMediaQuery, useTheme } from '@mui
import { ChevronDown, ChevronUp } from 'lucide-react' import { ChevronDown, ChevronUp } from 'lucide-react'
import { PageHeader } from '../../components/layout' import { PageHeader } from '../../components/layout'
import { ViewToggle } from '../../components/shared' import { ViewToggle } from '../../components/shared'
import { AgentMeetingRoom } from '../../components/meeting-room' import { AgentStaffOverview } from '../../components/staff'
import { useProperties } from '../../hooks/useProperties' import { useProperties } from '../../hooks/useProperties'
import { useActiveInquiries } from '../../hooks/useInquiries' import { useActiveInquiries } from '../../hooks/useInquiries'
import { PropertyFilterBar, PropertyTable, PropertyDetailView, PropertyIntelligenceCard } from '../../components/supply' import { PropertyFilterBar, PropertyTable, PropertyDetailView, PropertyIntelligenceCard } from '../../components/supply'
@@ -95,13 +95,13 @@ export default function Properties() {
const filtered = useMemo(() => applyFilters(properties, filters), [properties, filters]) const filtered = useMemo(() => applyFilters(properties, filters), [properties, filters])
return ( return (
// Runde 5, §2.3: Die Startseite scrollt als Ganzes. Der Meetingraum steht // 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 // oben und misst sich an der Viewport-Höhe; Titel und Filterbalken bleiben
// beim Einstieg gerade noch sichtbar, die Objektliste beginnt darunter. // beim Einstieg gerade noch sichtbar, die Objektliste beginnt darunter.
// Ein eigener Scrollbereich für die Tabelle wäre ein zweiter Scrollbalken // Ein eigener Scrollbereich für die Tabelle wäre ein zweiter Scrollbalken
// im selben Bild und würde genau das verhindern. // im selben Bild und würde genau das verhindern.
<Box sx={{ display: 'flex', flexDirection: 'column', minHeight: '100%' }}> <Box sx={{ display: 'flex', flexDirection: 'column', minHeight: '100%' }}>
<AgentMeetingRoom /> <AgentStaffOverview />
<PageHeader <PageHeader
title={MY_PROPERTIES_LABEL} title={MY_PROPERTIES_LABEL}
@@ -27,7 +27,7 @@ function workload(agentId: string): AgentWorkload {
} }
const WORKLOAD = Object.fromEntries( const WORKLOAD = Object.fromEntries(
['ferdi', 'bruno', 'livia', 'nora', 'sina', 'giorgio'].map(id => [id, workload(id)]), ['ferdi', 'bruno', 'livia', 'nora', 'sina'].map(id => [id, workload(id)]),
) )
function input(partial: Partial<AgentPresenceInput> = {}): AgentPresenceInput { function input(partial: Partial<AgentPresenceInput> = {}): AgentPresenceInput {
@@ -72,9 +72,9 @@ describe('Zeitangabe in Alltagssprache', () => {
}) })
describe('Belegschaft der Startseite', () => { describe('Belegschaft der Startseite', () => {
it('führt alle sechs in verbindlicher Reihenfolge, Giorgio zuletzt', () => { it('führt die fünf Agenten in verbindlicher Reihenfolge', () => {
const staff = buildAgentPresence(input(), NOW) const staff = buildAgentPresence(input(), NOW)
expect(staff.map(p => p.agentId)).toEqual(['ferdi', 'bruno', 'livia', 'nora', 'sina', 'giorgio']) expect(staff.map(p => p.agentId)).toEqual(['ferdi', 'bruno', 'livia', 'nora', 'sina'])
}) })
it('übernimmt Personalien aus dem Personalblatt', () => { it('übernimmt Personalien aus dem Personalblatt', () => {
@@ -88,13 +88,6 @@ describe('Belegschaft der Startseite', () => {
expect(ferdi.autonomy).toBe('Autonom') expect(ferdi.autonomy).toBe('Autonom')
}) })
it('gibt Giorgio Personalien und Stufe aus der Rollendefinition — er hat kein Dossier', () => {
const giorgio = buildAgentPresence(input(), NOW).find(p => p.agentId === 'giorgio')!
expect(giorgio.department).toBe('Personalwesen')
expect(giorgio.personnelNumber).toBe('PO-ZD-01')
expect(giorgio.autonomy).toBe('Vorgesetzter')
})
it('zeigt einen pausierten Mitarbeitenden als nicht im Dienst', () => { it('zeigt einen pausierten Mitarbeitenden als nicht im Dienst', () => {
const staff = buildAgentPresence( const staff = buildAgentPresence(
input({ agents: [dossier('sina', { status: AgentStatus.PAUSED })] }), input({ agents: [dossier('sina', { status: AgentStatus.PAUSED })] }),
@@ -14,7 +14,6 @@ import type { AgentWorkloadInput } from '../agentWorkloadService'
import { WorkloadTone } from '../../domain/agentWorkload' import { WorkloadTone } from '../../domain/agentWorkload'
import { ReminderStatus } from '../../domain/reminder' import { ReminderStatus } from '../../domain/reminder'
import { ExposeLeadStatus } from '../../domain/exposeLead' import { ExposeLeadStatus } from '../../domain/exposeLead'
import { AgentWorkItemStatus } from '../../domain/agentWorkItem'
const NOW = Date.parse('2026-08-06T12:00:00.000Z') const NOW = Date.parse('2026-08-06T12:00:00.000Z')
const DAY = 24 * 60 * 60 * 1000 const DAY = 24 * 60 * 60 * 1000
@@ -24,7 +23,7 @@ function iso(offsetDays: number): string {
} }
const EMPTY: AgentWorkloadInput = { const EMPTY: AgentWorkloadInput = {
reminders: [], visits: [], exposeLeads: [], leads: [], properties: [], workItems: [], reminders: [], visits: [], exposeLeads: [], leads: [], properties: [],
} }
function input(partial: Partial<AgentWorkloadInput>): AgentWorkloadInput { function input(partial: Partial<AgentWorkloadInput>): AgentWorkloadInput {
@@ -92,22 +91,6 @@ describe('Auslastung je Agent', () => {
expect(w.label).toBe('2 Objekte mit Datenlücken') expect(w.label).toBe('2 Objekte mit Datenlücken')
}) })
it('zählt bei Giorgio die offenen Freigaben', () => {
const w = computeAgentWorkload(
input({
workItems: [
{ status: AgentWorkItemStatus.PENDING },
{ status: AgentWorkItemStatus.PENDING },
{ status: AgentWorkItemStatus.APPROVED },
] as AgentWorkloadInput['workItems'],
}),
NOW,
).giorgio
expect(w.count).toBe(2)
expect(w.label).toBe('2 Freigaben offen')
})
it('hält Noras Zahl nie für dringend — sie schlägt vor, sie entscheidet nicht', () => { 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 leads = Array.from({ length: 40 }, () => ({ receivedAt: iso(-1) }))
const w = computeAgentWorkload( const w = computeAgentWorkload(
@@ -131,7 +114,7 @@ describe('Auslastung je Agent', () => {
it('meldet bei leerer Datenlage überall Ruhe statt Lücken', () => { it('meldet bei leerer Datenlage überall Ruhe statt Lücken', () => {
const all = computeAgentWorkload(EMPTY, NOW) const all = computeAgentWorkload(EMPTY, NOW)
expect(Object.keys(all)).toEqual(['ferdi', 'bruno', 'livia', 'nora', 'sina', 'giorgio']) expect(Object.keys(all)).toEqual(['ferdi', 'bruno', 'livia', 'nora', 'sina'])
for (const w of Object.values(all)) { for (const w of Object.values(all)) {
expect(w.count).toBe(0) expect(w.count).toBe(0)
expect(w.tone).toBe(WorkloadTone.CALM) expect(w.tone).toBe(WorkloadTone.CALM)
+5 -13
View File
@@ -3,7 +3,7 @@ import type { AgentWorkload } from '../domain/agentWorkload'
import type { AgentProtocolEntry } from '../domain/agentProtocol' import type { AgentProtocolEntry } from '../domain/agentProtocol'
import type { TeamAgent } from '../domain/teamAgent' import type { TeamAgent } from '../domain/teamAgent'
import { AgentStatus } from '../domain/teamAgent' import { AgentStatus } from '../domain/teamAgent'
import { AGENT_SUPERVISOR, AGENT_WORKSPACES } from '../lib/agentWorkspaces' import { AGENT_WORKSPACES } from '../lib/agentWorkspaces'
import { AGENT_AUTONOMY_LABELS } from '../lib/constants' import { AGENT_AUTONOMY_LABELS } from '../lib/constants'
/** /**
@@ -13,10 +13,6 @@ import { AGENT_AUTONOMY_LABELS } from '../lib/constants'
* als «letzte Handlung» gilt und wie eine Zeitangabe in Alltagssprache lautet, * als «letzte Handlung» gilt und wie eine Zeitangabe in Alltagssprache lautet,
* sind fachliche Festlegungen. Der Stichzeitpunkt ist ein Parameter — dieselbe * sind fachliche Festlegungen. Der Stichzeitpunkt ist ein Parameter — dieselbe
* Datenlage ergibt damit immer dasselbe Ergebnis und lässt sich prüfen. * Datenlage ergibt damit immer dasselbe Ergebnis und lässt sich prüfen.
*
* Giorgio steht bewusst am Ende: er ist Vorgesetzter, kein sechster
* Sachbearbeiter, und sein Klick führt nicht auf eine Arbeitsseite, sondern in
* die Personalverwaltung.
*/ */
const MINUTE = 60 * 1000 const MINUTE = 60 * 1000
@@ -73,7 +69,7 @@ export function buildAgentPresence(
} }
const byId = new Map(agents.map(a => [a.id, a])) const byId = new Map(agents.map(a => [a.id, a]))
const roster = [...AGENT_WORKSPACES, AGENT_SUPERVISOR] const roster = AGENT_WORKSPACES
return roster.map(member => { return roster.map(member => {
const dossier = byId.get(member.id) const dossier = byId.get(member.id)
@@ -83,17 +79,13 @@ export function buildAgentPresence(
agentId: member.id, agentId: member.id,
name: member.name, name: member.name,
role: member.role, role: member.role,
// Personalblatt zuerst; für Giorgio, der keines hat, die Angaben aus department: dossier?.department ?? '',
// der zentralen Rollendefinition. personnelNumber: dossier?.personnelNumber ?? '',
department: dossier?.department ?? member.department ?? '',
personnelNumber: dossier?.personnelNumber ?? member.personnelNumber ?? '',
path: member.path, path: member.path,
// Ohne Personalblatt gilt jemand als im Dienst: die Belegschaftsansicht // Ohne Personalblatt gilt jemand als im Dienst: die Belegschaftsansicht
// soll niemanden fälschlich als pausiert zeigen, nur weil ein Dossier fehlt. // soll niemanden fälschlich als pausiert zeigen, nur weil ein Dossier fehlt.
onDuty: dossier ? dossier.status === AgentStatus.ACTIVE : true, onDuty: dossier ? dossier.status === AgentStatus.ACTIVE : true,
// Giorgio führt die Belegschaft und entscheidet nichts allein über autonomy: dossier ? (AGENT_AUTONOMY_LABELS[dossier.autonomyLevel] ?? '') : '',
// Objekte — seine Stufe ergibt sich aus der Rolle, nicht aus einem Dossier.
autonomy: dossier ? (AGENT_AUTONOMY_LABELS[dossier.autonomyLevel] ?? '') : 'Vorgesetzter',
workload: workload[member.id], workload: workload[member.id],
lastAction: action lastAction: action
? { title: action.title, when: formatRelativeTime(action.timestamp, now) } ? { title: action.title, when: formatRelativeTime(action.timestamp, now) }
+2 -19
View File
@@ -2,18 +2,15 @@ import { ReminderStatus } from '../domain/reminder'
import type { Reminder } from '../domain/reminder' import type { Reminder } from '../domain/reminder'
import { ExposeLeadStatus } from '../domain/exposeLead' import { ExposeLeadStatus } from '../domain/exposeLead'
import type { ExposeLead } from '../domain/exposeLead' import type { ExposeLead } from '../domain/exposeLead'
import { AgentWorkItemStatus } from '../domain/agentWorkItem'
import type { AgentWorkItem } from '../domain/agentWorkItem'
import type { VisitAssignment } from '../domain/visitAssignment' import type { VisitAssignment } from '../domain/visitAssignment'
import type { UnifiedLead } from '../domain/unifiedLead' import type { UnifiedLead } from '../domain/unifiedLead'
import type { Property } from '../domain/property' import type { Property } from '../domain/property'
import { WorkloadTone } from '../domain/agentWorkload' import { WorkloadTone } from '../domain/agentWorkload'
import type { AgentWorkload } from '../domain/agentWorkload' import type { AgentWorkload } from '../domain/agentWorkload'
import { AGENT_SUPERVISOR } from '../lib/agentWorkspaces'
import { DQ_MEDIUM } from '../lib/constants' import { DQ_MEDIUM } from '../lib/constants'
/** /**
* Auslastung der sechs digitalen Mitarbeitenden (Runde 5). * Auslastung der fünf digitalen Mitarbeitenden (Runde 5).
* *
* Reine Geschäftslogik, bewusst ausserhalb von React: was «diese Woche» * Reine Geschäftslogik, bewusst ausserhalb von React: was «diese Woche»
* bedeutet, ab wann eine Zahl dringend ist und welcher Bestand zu welchem * bedeutet, ab wann eine Zahl dringend ist und welcher Bestand zu welchem
@@ -38,7 +35,6 @@ const URGENT_FROM = {
visits: 4, visits: 4,
expose: 5, expose: 5,
dataGaps: 10, dataGaps: 10,
approvals: 5,
} as const } as const
/** Zeitfenster der Auswertung. */ /** Zeitfenster der Auswertung. */
@@ -55,7 +51,6 @@ export interface AgentWorkloadInput {
exposeLeads: ExposeLead[] exposeLeads: ExposeLead[]
leads: UnifiedLead[] leads: UnifiedLead[]
properties: Property[] properties: Property[]
workItems: AgentWorkItem[]
} }
function within(iso: string, from: number, to: number): boolean { function within(iso: string, from: number, to: number): boolean {
@@ -156,19 +151,7 @@ export function computeAgentWorkload(
: `Bei ${gaps} ${plural(gaps, 'Objekt fehlen', 'Objekten fehlen')} mir Angaben. Ich melde, was ich nicht selbst finde.`, : `Bei ${gaps} ${plural(gaps, 'Objekt fehlen', 'Objekten fehlen')} mir Angaben. Ich melde, was ich nicht selbst finde.`,
} }
// ── Giorgio: Freigaben in der Personalverwaltung ────────────────────────────
const pending = input.workItems.filter(w => w.status === AgentWorkItemStatus.PENDING).length
const giorgio: AgentWorkload = {
agentId: AGENT_SUPERVISOR.id,
count: pending,
label: `${pending} ${plural(pending, 'Freigabe', 'Freigaben')} offen`,
tone: tone(pending, URGENT_FROM.approvals),
voice: pending === 0
? 'Nichts liegt zur Freigabe bereit. Die Belegschaft läuft.'
: `${pending} ${plural(pending, 'Vorgang wartet', 'Vorgänge warten')} auf deine Freigabe.`,
}
return Object.fromEntries( return Object.fromEntries(
[ferdi, bruno, livia, nora, sina, giorgio].map(w => [w.agentId, w]), [ferdi, bruno, livia, nora, sina].map(w => [w.agentId, w]),
) )
} }