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
+228
View File
@@ -0,0 +1,228 @@
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',
gap: 1.25,
p: { xs: 1.75, xl: 2 },
borderRadius: 3,
// 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 }}
>
{presence.name}
</Typography>
<Typography
sx={{ fontSize: '0.8125rem', lineHeight: 1.3, color: 'rgba(255,255,255,0.72)' }}
>
{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)',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{[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)',
}}
>
{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)',
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>
)
})
+164
View File
@@ -0,0 +1,164 @@
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'
/**
* Höhe der globalen Kopfzeile plus des Bereichs, der unter der Belegschaft
* sichtbar bleiben muss: Titel «Meine Objekte» samt Untertitel und der Balken
* «Filter & Übersicht» (Runde 5, §2.3).
*
* Deshalb wird gerechnet und nicht fest gesetzt: der Bereich bekommt, was die
* Viewport-Höhe hergibt, und auf einem 4K-Monitor bleibt derselbe Streifen
* unten sichtbar wie auf einem 13-Zoll-Laptop.
*/
const TOP_BAR_PX = 64
const KEEP_VISIBLE_BELOW_PX = 118
const RESERVED_PX = TOP_BAR_PX + KEEP_VISIBLE_BELOW_PX
/** Darunter würde die Belegschaft zur Briefmarke — dann lieber scrollen. */
const MIN_SCENE_PX = 420
/**
* 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',
height: `max(${MIN_SCENE_PX}px, calc(100vh - ${RESERVED_PX}px))`,
'@supports (height: 100dvh)': {
height: `max(${MIN_SCENE_PX}px, calc(100dvh - ${RESERVED_PX}px))`,
},
overflow: 'hidden',
}}
>
<StaffBackdrop />
<Box
sx={{
position: 'relative',
display: 'flex',
flexDirection: 'column',
height: '100%',
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={{
flex: 1,
minHeight: 0,
// Flexlayout statt Raster: bei fünf Karten bliebe in einem
// Dreierraster unten eine Lücke. So stehen drei oben und zwei
// mittig darunter — gleich breit, ohne Loch.
display: 'flex',
flexWrap: 'wrap',
justifyContent: 'center',
alignContent: 'center',
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
// Karten bis zur Unlesbarkeit zu stauchen.
overflowY: 'auto',
}}
>
{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>
)
}
+39
View File
@@ -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>
)
})
+5
View File
@@ -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'
+32
View File
@@ -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(', '),
}