feat(startseite): digitale Belegschaft mit Präsenz, eigener Stimme und Nachweis

Der nachgebaute Meetingraum entfällt. Eine gezeichnete Kulisse bleibt neben
fotografierten Porträts erkennbar gezeichnet — und ein Raum allein macht
niemanden menschlich. Menschlich wirkt im echten Büro dasselbe wie hier: dass
jemand da ist, etwas zu sagen hat und nachweisbar gearbeitet hat.

Die Startseite zeigt deshalb sechs Personenkarten vor einer abgedunkelten
Büroaufnahme. Jede Karte trägt, was ein Personalblatt trägt:

- Porträt mit Dienstpunkt — ist da oder pausiert
- Name, Funktion, Abteilung, Personalnummer
- Autonomiestufe: «Autonom», «Freigabe erforderlich», «Nur Vorschlag».
  Sie sagt, wie viel Verantwortung jemand trägt, und trennt Personal von
  Automatisierung.
- Ein Satz in der ersten Person zur aktuellen Lage, statt einer Kennzahl:
  «Bei 12 Fristen ist der Termin durch. Fürs Eskalieren brauche ich deine
  Freigabe.»
- Die zuletzt protokollierte Handlung mit Zeitangabe. Diese Zeile ist der
  Unterschied zwischen Behauptung und Beleg — eine Kennzahl kann jede
  Software anzeigen.

Darüber fasst ein Anriss zusammen, wie viele Mitarbeitende heute eine
Entscheidung brauchen, samt Dienststand der Belegschaft.

Schichten nach CLAUDE.md §4.2: `agentPresenceService` entscheidet, was als
letzte Handlung gilt und wie eine Zeitangabe in Alltagssprache lautet;
`agentWorkloadService` hält Zeitfenster, Schwellen und Formulierungen. Beide
nehmen den Stichzeitpunkt als Parameter — dieselbe Datenlage ergibt immer
dasselbe Ergebnis und ist ohne Renderer prüfbar (22 Tests).

Giorgio hat kein Personalblatt; Abteilung, Personalnummer und Stufe kommen
aus der zentralen Rollendefinition. Wer kein Dossier hat, gilt als im Dienst —
sonst stünde die halbe Belegschaft still, weil Stammdaten fehlen.

Bild: Unsplash, freie kommerzielle Lizenz. Herkunft und Austauschhinweis in
`src/assets/meeting-room/HERKUNFT.md`.

Geprüft: 435 Tests, Typecheck, Build, ESLint, Token-Schwelle (889/925).
Browser 1280–1920 px und Tablet: sechs Karten ohne Überlappung, Titel und
«Filter & Übersicht» beim Einstieg sichtbar, alle sechs Klicks auf der
richtigen Subpage, 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 14:50:16 +02:00
parent d8f8f728cc
commit 435f2aa2f1
17 changed files with 698 additions and 487 deletions
+14
View File
@@ -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.

Before

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 477 KiB

+81 -126
View File
@@ -1,20 +1,18 @@
import { useCallback, useMemo } from 'react' import { useCallback, useMemo } from 'react'
import { useNavigate } from 'react-router' import { useNavigate } from 'react-router'
import { Box, ButtonBase, Typography, useMediaQuery, useTheme } from '@mui/material' import { Box, Typography } from '@mui/material'
import { MeetingRoomScene } from './MeetingRoomScene' import { MeetingRoomScene } from './MeetingRoomScene'
import { AgentSeat } from './AgentSeat' import { AgentStaffCard } from './AgentStaffCard'
import { MEETING_ROOM_SEATS } from './meetingRoomSeats' import { useAgentPresence } from '../../hooks/useAgentPresence'
import { AgentAvatar } from '../team'
import { useAgentWorkload } from '../../hooks/useAgentWorkload'
import { WorkloadTone } from '../../domain/agentWorkload' import { WorkloadTone } from '../../domain/agentWorkload'
import { DS_ACCENT, DS_NEUTRAL, DS_TEXT } from '../../lib/ds' import { DS_NEUTRAL } from '../../lib/ds'
/** /**
* Höhe der globalen Kopfzeile plus des Bereichs, der unter dem Meetingraum * Höhe der globalen Kopfzeile plus des Bereichs, der unter der Belegschaft
* sichtbar bleiben muss: Titel «Meine Objekte» samt Untertitel und der Balken * sichtbar bleiben muss: Titel «Meine Objekte» samt Untertitel und der Balken
* «Filter & Übersicht» (Runde 5, §2.3). * «Filter & Übersicht» (Runde 5, §2.3).
* *
* Deshalb wird gerechnet und nicht fest gesetzt: die Szene bekommt, was die * Deshalb wird gerechnet und nicht fest gesetzt: der Bereich bekommt, was die
* Viewport-Höhe hergibt, und auf einem 4K-Monitor bleibt derselbe Streifen * Viewport-Höhe hergibt, und auf einem 4K-Monitor bleibt derselbe Streifen
* unten sichtbar wie auf einem 13-Zoll-Laptop. * unten sichtbar wie auf einem 13-Zoll-Laptop.
*/ */
@@ -22,155 +20,113 @@ const TOP_BAR_PX = 64
const KEEP_VISIBLE_BELOW_PX = 118 const KEEP_VISIBLE_BELOW_PX = 118
const RESERVED_PX = TOP_BAR_PX + KEEP_VISIBLE_BELOW_PX const RESERVED_PX = TOP_BAR_PX + KEEP_VISIBLE_BELOW_PX
/** Unter dieser Höhe würde die Szene zur Briefmarke — dann lieber scrollen. */ /** Darunter würde die Belegschaft zur Briefmarke — dann lieber scrollen. */
const MIN_SCENE_PX = 300 const MIN_SCENE_PX = 420
const TONE_COLOR: Record<WorkloadTone, string> = {
[WorkloadTone.CALM]: DS_ACCENT.success.main,
[WorkloadTone.ATTENTION]: DS_ACCENT.warning.main,
[WorkloadTone.URGENT]: DS_ACCENT.danger.main,
}
/** /**
* Agenten-Meetingraum auf der Startseite (Runde 5, §3). * Die digitale Belegschaft auf der Startseite (Runde 5, §3/§6).
* *
* Die sechs digitalen Mitarbeitenden stehen vor der Basler Fensterfront, jeder * Sechs Mitarbeitende an ihrem Arbeitsplatz — mit Porträt, Funktion,
* mit Porträt, Funktion und seiner aktuellen Auslastung. Ein Klick auf die * Abteilung, Personalnummer, Dienstzustand, einem Satz in der ersten Person
* ganze Einheit öffnet den jeweiligen Arbeitsbereich, bei Giorgio die * und der zuletzt protokollierten Handlung. Ein Klick auf eine Karte öffnet
* Personalverwaltung. * den jeweiligen Arbeitsbereich, bei Giorgio die Personalverwaltung.
* *
* Die Zahl auf dem Schild ist der Unterschied zwischen Kulisse und * Bewusst keine nachgebaute Raumperspektive mehr: eine gezeichnete Kulisse
* Arbeitsmittel: sie sagt vor dem Klick, wo etwas liegt. * bleibt neben fotografierten Porträts erkennbar gezeichnet, und ein Raum
* * allein macht niemanden menschlich. Was menschlich wirkt, ist dasselbe wie im
* Auf schmalen Schirmen tritt an die Stelle der Szene eine Kachelreihe mit * echten Büro — dass jemand da ist, etwas zu sagen hat und nachweisbar
* denselben Angaben und denselben Zielen; ausgeblendet wird niemand (§3.7). * gearbeitet hat.
*/ */
export function AgentMeetingRoom() { export function AgentMeetingRoom() {
const navigate = useNavigate() const navigate = useNavigate()
const theme = useTheme() const { data: staff } = useAgentPresence()
const isNarrow = useMediaQuery(theme.breakpoints.down('md'))
const { data: workload } = useAgentWorkload()
const handleOpen = useCallback((path: string) => navigate(path), [navigate]) const handleOpen = useCallback((path: string) => navigate(path), [navigate])
// Ein Durchgang über dieselbe Liste statt zweier Filterläufe (CLAUDE.md §10.4). // Ein Durchgang über dieselbe Liste statt zweier Filterläufe (CLAUDE.md §10.4).
const { urgentCount, attentionCount } = useMemo(() => { const { urgent, onDuty } = useMemo(() => {
let urgent = 0 let urgentCount = 0
let attention = 0 let onDutyCount = 0
for (const w of Object.values(workload)) { for (const person of staff) {
if (w.tone === WorkloadTone.URGENT) urgent += 1 if (person.workload?.tone === WorkloadTone.URGENT) urgentCount += 1
else if (w.tone === WorkloadTone.ATTENTION) attention += 1 if (person.onDuty) onDutyCount += 1
} }
return { urgentCount: urgent, attentionCount: attention } return { urgent: urgentCount, onDuty: onDutyCount }
}, [workload]) }, [staff])
const height = { 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))`, height: `max(${MIN_SCENE_PX}px, calc(100vh - ${RESERVED_PX}px))`,
'@supports (height: 100dvh)': { '@supports (height: 100dvh)': {
height: `max(${MIN_SCENE_PX}px, calc(100dvh - ${RESERVED_PX}px))`, height: `max(${MIN_SCENE_PX}px, calc(100dvh - ${RESERVED_PX}px))`,
}, },
} overflow: 'hidden',
}}
if (isNarrow) {
return (
<Box
component="section"
aria-label="Agenten-Meetingraum"
sx={{ ...height, position: 'relative', display: 'flex', flexDirection: 'column' }}
> >
<Box sx={{ position: 'relative', flex: 1, minHeight: 140 }}>
<MeetingRoomScene /> <MeetingRoomScene />
</Box>
<Box <Box
sx={{ sx={{
flex: '0 0 auto', 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,
display: 'grid', display: 'grid',
gridTemplateColumns: { xs: 'repeat(2, minmax(0, 1fr))', sm: 'repeat(3, minmax(0, 1fr))' }, gridTemplateColumns: {
gap: 1, xs: '1fr',
p: 2, sm: 'repeat(2, minmax(0, 1fr))',
bgcolor: DS_NEUTRAL.paper, lg: 'repeat(3, minmax(0, 1fr))',
},
gridAutoRows: '1fr',
gap: { xs: 1.5, lg: 2 },
// Auf niedrigen Fenstern darf die Belegschaft scrollen, statt die
// Karten bis zur Unlesbarkeit zu stauchen.
overflowY: 'auto',
}} }}
> >
{MEETING_ROOM_SEATS.map(seat => { {staff.map(person => (
const w = workload[seat.id] <AgentStaffCard key={person.agentId} presence={person} onOpen={handleOpen} />
return (
<ButtonBase
key={seat.id}
focusRipple
onClick={() => handleOpen(seat.path)}
aria-label={
w
? `${seat.name}, ${seat.role}${w.label}. Arbeitsbereich öffnen`
: `${seat.name}, ${seat.role} — Arbeitsbereich öffnen`
}
sx={{
display: 'flex', alignItems: 'center', gap: 1,
p: 1, borderRadius: 2, justifyContent: 'flex-start',
bgcolor: DS_NEUTRAL.white,
boxShadow: '0 2px 8px rgba(8,14,24,0.10)',
'&.Mui-focusVisible': { outline: `2px solid ${DS_NEUTRAL.gold}`, outlineOffset: 2 },
}}
>
<AgentAvatar agent={{ id: seat.id, name: seat.name, role: seat.role }} size={38} />
<Box sx={{ minWidth: 0, textAlign: 'left' }}>
<Typography sx={{ fontWeight: 700, fontSize: '0.8125rem', lineHeight: 1.2, color: DS_TEXT.primary }}>
{seat.name}
</Typography>
<Typography sx={{ fontSize: '0.6875rem', lineHeight: 1.25, color: DS_TEXT.secondary }}>
{seat.role}
</Typography>
{w && (
<Typography sx={{ fontSize: '0.6875rem', fontWeight: 600, lineHeight: 1.25, color: TONE_COLOR[w.tone] }}>
{w.label}
</Typography>
)}
</Box>
</ButtonBase>
)
})}
</Box>
</Box>
)
}
return (
<Box
component="section"
aria-label="Agenten-Meetingraum"
// Container-Kontext: die Schilder bemessen ihre Höchstbreite an der
// Szene, nicht am Fenster (siehe AgentSeat).
sx={{ ...height, position: 'relative', overflow: 'hidden', containerType: 'inline-size' }}
>
<MeetingRoomScene />
<SceneHeadline urgentCount={urgentCount} attentionCount={attentionCount} />
{MEETING_ROOM_SEATS.map(seat => (
<AgentSeat key={seat.id} seat={seat} workload={workload[seat.id]} onOpen={handleOpen} />
))} ))}
</Box> </Box>
</Box>
</Box>
) )
} }
/** /**
* Anriss über der Szene. * Anriss über der Belegschaft.
* *
* Er sagt in einem Satz, was die sechs Schilder darunter im Detail zeigen — * Beantwortet die Frage, mit der jemand diese Seite öffnet: muss ich heute
* und beantwortet damit die einzige Frage, mit der jemand diese Seite öffnet: * irgendwo eingreifen? Ohne den Satz müsste man sechs Karten lesen und selbst
* muss ich heute irgendwo eingreifen? Ohne diesen Satz müsste man sechs Zahlen * zusammenzählen.
* einzeln lesen und selbst zusammenzählen.
*/ */
function SceneHeadline({ urgentCount, attentionCount }: { urgentCount: number; attentionCount: number }) { function StaffHeadline({ urgent, onDuty, total }: { urgent: number; onDuty: number; total: number }) {
const summary = const summary =
urgentCount > 0 urgent > 0
? `${urgentCount} ${urgentCount === 1 ? 'Bereich braucht' : 'Bereiche brauchen'} heute Ihre Aufmerksamkeit` ? `${urgent} ${urgent === 1 ? 'Mitarbeitender braucht' : 'Mitarbeitende brauchen'} heute eine Entscheidung von dir`
: attentionCount > 0 : 'Alle laufen — heute liegt nichts bei dir'
? `${attentionCount} ${attentionCount === 1 ? 'Bereich ist' : 'Bereiche sind'} in Arbeit — nichts Überfälliges`
: 'Nichts Offenes — Ihre Belegschaft ist auf Kurs'
return ( return (
<Box sx={{ position: 'absolute', top: { xs: 20, lg: 32 }, left: { xs: 20, lg: 40 }, maxWidth: 520, pointerEvents: 'none' }}> <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 <Typography
sx={{ sx={{
fontSize: '0.6875rem', fontSize: '0.6875rem',
@@ -178,20 +134,19 @@ function SceneHeadline({ urgentCount, attentionCount }: { urgentCount: number; a
letterSpacing: 1.4, letterSpacing: 1.4,
textTransform: 'uppercase', textTransform: 'uppercase',
color: DS_NEUTRAL.gold, color: DS_NEUTRAL.gold,
textShadow: '0 1px 6px rgba(8,14,24,0.6)',
}} }}
> >
Ihre digitale Belegschaft {onDuty} von {total} im Dienst
</Typography> </Typography>
</Box>
<Typography <Typography
component="h2" component="h2"
sx={{ sx={{
mt: 0.5, mt: 0.5,
fontWeight: 700, fontWeight: 700,
fontSize: { xs: '1.3rem', lg: '1.7rem' }, fontSize: { xs: '1.25rem', lg: '1.6rem' },
lineHeight: 1.2, lineHeight: 1.2,
color: DS_TEXT.inverted, color: DS_NEUTRAL.white,
textShadow: '0 2px 14px rgba(8,14,24,0.7)',
}} }}
> >
{summary} {summary}
-176
View File
@@ -1,176 +0,0 @@
import { memo } from 'react'
import { ButtonBase, Box, Typography } from '@mui/material'
import { AgentAvatar } from '../team'
import type { MeetingRoomSeat } from './meetingRoomSeats'
import { SEAT_BASE_HEAD_PX } from './meetingRoomSeats'
import type { AgentWorkload } from '../../domain/agentWorkload'
import { WorkloadTone } from '../../domain/agentWorkload'
import { DS_ACCENT, DS_NEUTRAL, DS_TEXT } from '../../lib/ds'
const TONE_COLOR: Record<WorkloadTone, string> = {
[WorkloadTone.CALM]: DS_ACCENT.success.main,
[WorkloadTone.ATTENTION]: DS_ACCENT.warning.main,
[WorkloadTone.URGENT]: DS_ACCENT.danger.main,
}
export interface AgentSeatProps {
seat: MeetingRoomSeat
/** Aktuelle Auslastung; fehlt sie, zeigt die Plakette nur Name und Funktion. */
workload?: AgentWorkload
onOpen: (path: string) => void
}
/**
* Ein Platz im Meetingraum (Runde 5, §3.6/§3.7).
*
* Porträt, Namensschild und Auslastung sind eine einzige Schaltfläche — nicht
* nur der Kopf. Ein 60-Pixel-Porträt als einzige Trefferfläche wäre auf jedem
* Laptop eine Zumutung, und das Schild darunter zeigt ohnehin dasselbe Ziel.
*
* Die Zahl auf dem Schild ist der eigentliche Grund, warum diese Seite mehr ist
* als ein Bild: sie sagt, woran der Agent gerade arbeitet, und macht den Klick
* zu einer Entscheidung statt zu einem Rundgang.
*
* `ButtonBase` statt `<div onClick>`: Tastaturbedienung, Enter und Leertaste,
* Fokusring und Rollen-Semantik kommen damit ohne eigenen Code korrekt.
*/
export const AgentSeat = memo(function AgentSeat({ seat, workload, onOpen }: AgentSeatProps) {
const head = Math.round(SEAT_BASE_HEAD_PX * seat.scale)
const toneColor = workload ? TONE_COLOR[workload.tone] : DS_TEXT.secondary
return (
<ButtonBase
focusRipple
onClick={() => onOpen(seat.path)}
aria-label={
workload
? `${seat.name}, ${seat.role}${workload.label}. Arbeitsbereich öffnen`
: `${seat.name}, ${seat.role} — Arbeitsbereich öffnen`
}
sx={{
position: 'absolute',
left: `${seat.x}%`,
top: `${seat.y}%`,
transform: 'translate(-50%, -50%)',
zIndex: seat.zIndex,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: 0.75,
px: 0.5,
py: 1,
borderRadius: 3,
transition: 'transform 0.18s ease',
'&:hover, &.Mui-focusVisible': { transform: 'translate(-50%, calc(-50% - 5px))' },
'&:hover .seat-plate, &.Mui-focusVisible .seat-plate': {
boxShadow: '0 14px 34px rgba(8,14,24,0.42)',
},
'&.Mui-focusVisible': { outline: `2px solid ${DS_NEUTRAL.gold}`, outlineOffset: 3 },
}}
>
{/* Kontaktschatten unter dem Porträt — ohne ihn schwebt der Kopf über dem Bild. */}
<Box
sx={{
position: 'absolute',
top: `calc(8px + ${head}px - ${Math.round(head * 0.14)}px)`,
left: '50%',
transform: 'translateX(-50%)',
width: Math.round(head * 1.05),
height: Math.round(head * 0.28),
borderRadius: '50%',
background: 'radial-gradient(closest-side, rgba(8,14,24,0.55), rgba(8,14,24,0))',
pointerEvents: 'none',
}}
/>
<Box sx={{ filter: 'drop-shadow(0 10px 22px rgba(8,14,24,0.45))' }}>
<AgentAvatar
agent={{ id: seat.id, name: seat.name, role: seat.role }}
size={head}
loading="eager"
ringColor={DS_NEUTRAL.white}
ringWidth={3}
/>
</Box>
{/* Namensschild unter dem Kopf: Name, Funktion, aktuelle Auslastung. */}
<Box
className="seat-plate"
sx={{
position: 'relative',
minWidth: Math.max(120, head * 1.9),
/*
* Obergrenze als Anteil der Szenenbreite (`cqw`), nicht der
* Fensterbreite: die Szene ist um das Seitenmenü schmaler als das
* Fenster, und genau dieser Unterschied liess die Schilder auf
* schmalen Laptops überlappen. 13.4% liegt samt Klickrand sicher unter dem Abstand
* von 15.6%, mit dem die sechs Plätze verteilt sind.
*
* Der längste Kennzahlensatz («14 Objekte mit Datenlücken») bricht
* damit um, statt sein Schild über das nächste zu dehnen — die Zahl
* bleibt vollständig, die Reihe bleibt frei von Überlappungen.
*/
maxWidth: 'min(200px, 13.4cqw)',
px: 1.25,
py: 0.75,
borderRadius: 2,
bgcolor: 'rgba(255,255,255,0.96)',
boxShadow: '0 6px 18px rgba(8,14,24,0.28)',
textAlign: 'center',
transition: 'box-shadow 0.18s ease',
}}
>
<Typography
sx={{
fontWeight: 700,
fontSize: seat.scale < 0.9 ? '0.78rem' : '0.85rem',
lineHeight: 1.2,
color: DS_TEXT.primary,
whiteSpace: 'nowrap',
}}
>
{seat.name}
</Typography>
<Typography
sx={{
fontSize: '0.6875rem',
lineHeight: 1.25,
color: DS_TEXT.secondary,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{seat.role}
</Typography>
{workload && (
<Box
sx={{
mt: 0.5,
pt: 0.5,
borderTop: '1px solid rgba(15,23,42,0.08)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: 0.625,
}}
>
<Box sx={{ width: 6, height: 6, borderRadius: '50%', bgcolor: toneColor, flexShrink: 0 }} />
<Typography
sx={{
fontSize: '0.6875rem',
fontWeight: 600,
lineHeight: 1.25,
color: toneColor,
textAlign: 'left',
}}
>
{workload.label}
</Typography>
</Box>
)}
</Box>
</ButtonBase>
)
})
@@ -0,0 +1,225 @@
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,
height: '100%',
// 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>
)
})
@@ -1,7 +1,7 @@
import { memo } from 'react' import { memo } from 'react'
import { Box, Typography } from '@mui/material' import { Box } from '@mui/material'
import { ROOM_BACKDROP } from './roomBackdrop' import { ROOM_BACKDROP } from './roomBackdrop'
import { DS_NEUTRAL, DS_TEXT } from '../../lib/ds' import { DS_NEUTRAL } from '../../lib/ds'
/** /**
* Bildhintergrund des Agenten-Meetingraums (Runde 5, §3). * Bildhintergrund des Agenten-Meetingraums (Runde 5, §3).
@@ -34,22 +34,6 @@ export const MeetingRoomScene = memo(function MeetingRoomScene() {
<Box sx={{ position: 'absolute', inset: 0, background: ROOM_BACKDROP.scrim }} /> <Box sx={{ position: 'absolute', inset: 0, background: ROOM_BACKDROP.scrim }} />
{ROOM_BACKDROP.credit && (
<Typography
sx={{
position: 'absolute',
right: 12,
bottom: 8,
fontSize: '0.625rem',
letterSpacing: 0.4,
color: DS_TEXT.inverted,
opacity: 0.55,
pointerEvents: 'none',
}}
>
{ROOM_BACKDROP.credit}
</Typography>
)}
</Box> </Box>
) )
}) })
+3 -3
View File
@@ -1,5 +1,5 @@
// Agenten-Meetingraum 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 { AgentMeetingRoom } from './AgentMeetingRoom'
export { MEETING_ROOM_SEATS, SEAT_BASE_HEAD_PX, SeatSide } from './meetingRoomSeats' export { AgentStaffCard } from './AgentStaffCard'
export type { MeetingRoomSeat } from './meetingRoomSeats' export { ROOM_BACKDROP } from './roomBackdrop'
@@ -1,113 +0,0 @@
/**
* Property On — Sitzordnung im Agenten-Meetingraum (Runde 5, §3.7).
*
* Eine einzige Tabelle bestimmt, wer wo sitzt, wie gross er erscheint, wohin
* sein Klick führt und wo seine Plakette hängt. Wer einen Agenten umsetzt,
* ergänzt oder umbenennt, ändert genau diese Datei — nicht die Szene.
*
* Koordinaten sind Prozentwerte des Szenenrahmens, nicht Pixel: der Raum wird
* anhand der Viewport-Höhe berechnet und darf auf jedem Monitor eine andere
* Grösse haben, ohne dass die Sitzordnung verrutscht.
*
* Die Tiefe steckt in `scale` und `zIndex`: weiter hinten heisst kleiner und
* weiter unten in der Stapelfolge. Beides ist bewusst von Hand gesetzt und
* nicht aus `y` gerechnet — die Tischgeometrie ist keine lineare Funktion.
*/
import { AGENT_WORKSPACES, AGENT_SUPERVISOR } from '../../lib/agentWorkspaces'
/** Auf welcher Seite des Tisches ein Platz liegt — steuert die Blickrichtung. */
export const SeatSide = {
LEFT: 'LEFT',
RIGHT: 'RIGHT',
HEAD: 'HEAD',
} as const
export type SeatSide = typeof SeatSide[keyof typeof SeatSide]
export interface MeetingRoomSeat {
/** Identisch mit der Agenten-ID in `AGENT_PHOTOS` — liefert das Porträt. */
id: string
name: string
/** Funktionsbezeichnung; steht unter dem Namen auf der Plakette. */
role: string
/** Zielseite des Klicks auf die gesamte Sitzeinheit. */
path: string
side: SeatSide
/** Mittelpunkt des Kopfes in Prozent der Szenenbreite bzw. -höhe. */
x: number
y: number
/** Grössenfaktor gegenüber der Basisgrösse — bildet die Raumtiefe ab. */
scale: number
/**
* Seitlicher Versatz der Plakette in Prozentpunkten. Ohne ihn stünden die
* Plaketten benachbarter Plätze übereinander; sie fächern deshalb nach
* aussen auf (§3.7 «Labels bei Überlappung intelligent versetzen»).
*/
labelDx: number
/**
* Zusätzlicher Höhenversatz der Plakette in Prozentpunkten. Die Plätze einer
* Tischseite liegen diagonal hintereinander; ohne gestaffelte Höhen legten
* sich ihre Plaketten übereinander, egal wie weit man sie seitlich versetzt.
*/
labelDy: number
/** Stapelfolge: vorne sitzende Agenten verdecken weiter hinten sitzende. */
zIndex: number
}
type SeatPlacement = Omit<MeetingRoomSeat, 'id' | 'name' | 'role' | 'path'>
/**
* Plätze der fünf Agenten, nach Agenten-ID statt nach Reihenfolge — so
* verrutscht die Sitzordnung nicht still, wenn `AGENT_WORKSPACES` einmal
* umsortiert wird.
*
* Die Vergabe läuft im Zickzack von hinten nach vorn (hinten links, hinten
* rechts, Mitte links, Mitte rechts, vorne links) entlang der Reihenfolge des
* Seitenmenüs: so bleibt der Arbeitsablauf auch am Tisch ablesbar und keine
* Tischseite wirkt leer.
*/
const AGENT_PLACEMENTS: Record<string, SeatPlacement> = {
ferdi: { side: SeatSide.LEFT, x: 11.0, y: 70, scale: 1.00, labelDx: 0, labelDy: 0, zIndex: 20 },
bruno: { side: SeatSide.LEFT, x: 26.6, y: 70, scale: 1.00, labelDx: 0, labelDy: 0, zIndex: 20 },
livia: { side: SeatSide.LEFT, x: 42.2, y: 70, scale: 1.00, labelDx: 0, labelDy: 0, zIndex: 20 },
nora: { side: SeatSide.RIGHT, x: 57.8, y: 70, scale: 1.00, labelDx: 0, labelDy: 0, zIndex: 20 },
sina: { side: SeatSide.RIGHT, x: 73.4, y: 70, scale: 1.00, labelDx: 0, labelDy: 0, zIndex: 20 },
}
/**
* Notplatz für einen Agenten ohne eigenen Eintrag oben. Kein Agent darf
* verschwinden, nur weil jemand die Sitzordnung zu ergänzen vergisst (§3.7).
* Er landet dann sichtbar vorne rechts statt lautlos im Nichts.
*/
const FALLBACK_PLACEMENT: SeatPlacement = {
side: SeatSide.RIGHT, x: 89.0, y: 70, scale: 1.00, labelDx: 0, labelDy: 0, zIndex: 20,
}
/**
* Giorgio sitzt vis-à-vis am Kopfende gegenüber dem Betrachter, vor der
* Fensterfront. Er ist der einzige Platz, dessen Klick nicht auf eine
* Arbeitsseite führt, sondern in die Personalverwaltung.
*/
const SUPERVISOR_PLACEMENT: SeatPlacement = {
side: SeatSide.HEAD, x: 89.0, y: 70, scale: 1.08, labelDx: 0, labelDy: 0, zIndex: 25,
}
export const MEETING_ROOM_SEATS: MeetingRoomSeat[] = [
...AGENT_WORKSPACES.map(agent => ({
id: agent.id,
name: agent.name,
role: agent.role,
path: agent.path,
...(AGENT_PLACEMENTS[agent.id] ?? FALLBACK_PLACEMENT),
})),
{
id: AGENT_SUPERVISOR.id,
name: AGENT_SUPERVISOR.name,
role: AGENT_SUPERVISOR.role,
path: AGENT_SUPERVISOR.path,
...SUPERVISOR_PLACEMENT,
},
]
/** Kopfgrösse bei `scale: 1`, in Pixel. Alle übrigen Grössen leiten sich ab. */
export const SEAT_BASE_HEAD_PX = 62
+16 -35
View File
@@ -1,51 +1,32 @@
import baselWindow from '../../assets/meeting-room/basel-rhein-muenster.jpg' import buero from '../../assets/meeting-room/buero.jpg'
/** /**
* Hintergrund des Agenten-Meetingraums (Runde 5, §3). * Umgebung der Belegschaftsansicht auf der Startseite (Runde 5, §3).
* *
* Genau eine Stelle bestimmt, welches Bild die Szene trägt. Ein Austausch des * Genau eine Stelle bestimmt, welches Bild hinter den Personenkarten liegt.
* Bildes ist damit ein Einzeiler — und die Sitzordnung daneben in * Ein Austausch ist damit ein Einzeiler; die Herkunft steht in `HERKUNFT.md`
* `meetingRoomSeats.ts` ist das Einzige, was danach nachzujustieren ist. * neben der Bilddatei.
* *
* Warum ein Foto und keine gezeichnete Kulisse: die Agenten sind Porträtfotos. * Das Bild ist Umgebung, nicht Motiv: es zeigt ein Büro, damit die digitalen
* Alles, was daneben gezeichnet ist, fällt im Vergleich sofort auf — eine * Mitarbeitenden einen Arbeitsplatz haben statt eines leeren Hintergrunds. Der
* gemalte Wand neben einem echten Gesicht sieht nach Attrappe aus. Umgekehrt * Verlauf darüber nimmt ihm den grössten Teil des Kontrasts — sonst
* trägt ein echtes Bild die gesamte Bildwirkung, ohne dass eine einzige Fläche * konkurriert jeder Bürostuhl mit den Gesichtern und der Schrift davor.
* konstruiert werden muss.
*
* Aktuell steht hier die Basler Aussicht aus dem Auftrag: die Fensterfront
* über dem Rhein mit Altstadt und Münster (§5). Sobald ein fotorealistisches
* Raumbild vorliegt, ersetzt es diesen Import.
*/ */
export interface RoomBackdrop { export interface RoomBackdrop {
src: string src: string
/** Bildausschnitt bei `object-fit: cover`. */ /** Bildausschnitt bei `object-fit: cover`. */
focus: string focus: string
/** /** Verlauf über dem Bild: Lesbarkeit und Übergang zur Objektliste darunter. */
* Verlauf über dem Bild. Er hat zwei Aufgaben: die Plaketten lesbar halten
* und den Übergang zur darunterliegenden Objektliste weich machen. Ohne ihn
* schneidet die Szene hart ab und das Foto konkurriert mit den Gesichtern.
*/
scrim: string scrim: string
/** Kurze Herkunftsangabe, unten rechts klein eingeblendet. */
credit?: string
} }
export const ROOM_BACKDROP: RoomBackdrop = { export const ROOM_BACKDROP: RoomBackdrop = {
src: baselWindow, src: buero,
focus: '50% 42%', focus: '50% 58%',
scrim: [ scrim: [
// Oberer Rand leicht abdunkeln — hält den Blick unten bei der Belegschaft. // Grundton: das Büro tritt weit zurück, die Karten treten hervor.
'linear-gradient(180deg, rgba(10,16,26,0.34) 0%, rgba(10,16,26,0.06) 22%, rgba(10,16,26,0) 40%)', 'linear-gradient(180deg, rgba(9,14,23,0.84) 0%, rgba(9,14,23,0.76) 52%, rgba(9,14,23,0.92) 100%)',
// Breites dunkles Band unter der Bildmitte: es trägt die weissen Schilder // Warmes Streiflicht von links, damit die Fläche nicht tot wirkt.
// und bindet die Porträts an den Hintergrund, statt sie darüber schweben zu 'radial-gradient(85% 120% at 10% 6%, rgba(184,151,90,0.18) 0%, rgba(184,151,90,0) 58%)',
// lassen. Ohne dieses Band konkurriert jedes Detail des Fotos mit der Schrift.
'linear-gradient(0deg, rgba(10,16,26,0.86) 0%, rgba(10,16,26,0.62) 26%, rgba(10,16,26,0.22) 46%, rgba(10,16,26,0) 62%)',
// Oben links zusätzlich abdunkeln — dort steht der Anriss, und heller
// Himmel darunter macht jede Schrift unlesbar, egal wie viel Schatten sie hat.
'linear-gradient(115deg, rgba(10,16,26,0.62) 0%, rgba(10,16,26,0.22) 26%, rgba(10,16,26,0) 46%)',
// Ränder schliessen.
'radial-gradient(120% 105% at 50% 42%, rgba(0,0,0,0) 52%, rgba(8,13,22,0.42) 100%)',
].join(', '), ].join(', '),
credit: 'Basel · Rhein und Münster',
} }
+44
View File
@@ -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
}
}
+7
View File
@@ -32,4 +32,11 @@ export interface AgentWorkload {
*/ */
label: string label: string
tone: WorkloadTone 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
} }
+25
View File
@@ -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 }
}
+6
View File
@@ -14,6 +14,10 @@
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
@@ -48,5 +52,7 @@ export const AGENT_SUPERVISOR: AgentWorkspace = {
id: 'giorgio', id: 'giorgio',
name: 'Giorgio', name: 'Giorgio',
role: 'Personalverwalter', role: 'Personalverwalter',
department: 'Personalwesen',
personnelNumber: 'PO-ZD-01',
path: ROUTES.SUPPLY.TEAM, path: ROUTES.SUPPLY.TEAM,
} }
@@ -0,0 +1,137 @@
/**
* 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', 'giorgio'].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 alle sechs in verbindlicher Reihenfolge, Giorgio zuletzt', () => {
const staff = buildAgentPresence(input(), NOW)
expect(staff.map(p => p.agentId)).toEqual(['ferdi', 'bruno', 'livia', 'nora', 'sina', 'giorgio'])
})
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('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', () => {
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)
})
})
+103
View File
@@ -0,0 +1,103 @@
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_SUPERVISOR, 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.
*
* 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 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, AGENT_SUPERVISOR]
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,
// Personalblatt zuerst; für Giorgio, der keines hat, die Angaben aus
// der zentralen Rollendefinition.
department: dossier?.department ?? member.department ?? '',
personnelNumber: dossier?.personnelNumber ?? member.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,
// Giorgio führt die Belegschaft und entscheidet nichts allein über
// Objekte — seine Stufe ergibt sich aus der Rolle, nicht aus einem Dossier.
autonomy: dossier ? (AGENT_AUTONOMY_LABELS[dossier.autonomyLevel] ?? '') : 'Vorgesetzter',
workload: workload[member.id],
lastAction: action
? { title: action.title, when: formatRelativeTime(action.timestamp, now) }
: undefined,
}
})
}
+19
View File
@@ -95,12 +95,16 @@ export function computeAgentWorkload(
count: overdue, count: overdue,
label: `${overdue} ${plural(overdue, 'Frist', 'Fristen')} überfällig`, label: `${overdue} ${plural(overdue, 'Frist', 'Fristen')} überfällig`,
tone: WorkloadTone.URGENT, tone: WorkloadTone.URGENT,
voice: `Bei ${overdue} ${plural(overdue, 'Frist', 'Fristen')} ist der Termin durch. Fürs Eskalieren brauche ich deine Freigabe.`,
} }
: { : {
agentId: 'ferdi', agentId: 'ferdi',
count: dueThisWeek, count: dueThisWeek,
label: `${dueThisWeek} ${plural(dueThisWeek, 'Frist', 'Fristen')} diese Woche`, label: `${dueThisWeek} ${plural(dueThisWeek, 'Frist', 'Fristen')} diese Woche`,
tone: tone(dueThisWeek, URGENT_FROM.reminders), 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 ─────────────────────────────────────────────────── // ── Bruno: Besichtigungen ───────────────────────────────────────────────────
@@ -110,6 +114,9 @@ export function computeAgentWorkload(
count: upcomingVisits, count: upcomingVisits,
label: `${upcomingVisits} ${plural(upcomingVisits, 'Besichtigung', 'Besichtigungen')} diese Woche`, label: `${upcomingVisits} ${plural(upcomingVisits, 'Besichtigung', 'Besichtigungen')} diese Woche`,
tone: tone(upcomingVisits, URGENT_FROM.visits), 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 ──────────────────────────────────────────── // ── Livia: weitergeleitete Leads ────────────────────────────────────────────
@@ -119,6 +126,9 @@ export function computeAgentWorkload(
count: openExpose, count: openExpose,
label: `${openExpose} ${plural(openExpose, 'Exposé offen', 'Exposés offen')}`, label: `${openExpose} ${plural(openExpose, 'Exposé offen', 'Exposés offen')}`,
tone: tone(openExpose, URGENT_FROM.expose), 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 ────────────────────────────────────────────────────── // ── Nora: Marktchancen ──────────────────────────────────────────────────────
@@ -129,6 +139,9 @@ export function computeAgentWorkload(
label: `${freshLeads} neue ${plural(freshLeads, 'Marktchance', 'Marktchancen')}`, label: `${freshLeads} neue ${plural(freshLeads, 'Marktchance', 'Marktchancen')}`,
// Neue Chancen sind nie «dringend» — Nora entscheidet nichts, sie schlägt vor. // Neue Chancen sind nie «dringend» — Nora entscheidet nichts, sie schlägt vor.
tone: freshLeads > 0 ? WorkloadTone.ATTENTION : WorkloadTone.CALM, 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 ─────────────────────────────────────────────────────── // ── Sina: Datenpflege ───────────────────────────────────────────────────────
@@ -138,6 +151,9 @@ export function computeAgentWorkload(
count: gaps, count: gaps,
label: `${gaps} ${plural(gaps, 'Objekt', 'Objekte')} mit Datenlücken`, label: `${gaps} ${plural(gaps, 'Objekt', 'Objekte')} mit Datenlücken`,
tone: tone(gaps, URGENT_FROM.dataGaps), 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.`,
} }
// ── Giorgio: Freigaben in der Personalverwaltung ──────────────────────────── // ── Giorgio: Freigaben in der Personalverwaltung ────────────────────────────
@@ -147,6 +163,9 @@ export function computeAgentWorkload(
count: pending, count: pending,
label: `${pending} ${plural(pending, 'Freigabe', 'Freigaben')} offen`, label: `${pending} ${plural(pending, 'Freigabe', 'Freigaben')} offen`,
tone: tone(pending, URGENT_FROM.approvals), 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(