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:
@@ -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 |
@@ -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,176 +20,133 @@ 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 = {
|
|
||||||
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))`,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
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 />
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
flex: '0 0 auto',
|
|
||||||
display: 'grid',
|
|
||||||
gridTemplateColumns: { xs: 'repeat(2, minmax(0, 1fr))', sm: 'repeat(3, minmax(0, 1fr))' },
|
|
||||||
gap: 1,
|
|
||||||
p: 2,
|
|
||||||
bgcolor: DS_NEUTRAL.paper,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{MEETING_ROOM_SEATS.map(seat => {
|
|
||||||
const w = workload[seat.id]
|
|
||||||
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 (
|
return (
|
||||||
<Box
|
<Box
|
||||||
component="section"
|
component="section"
|
||||||
aria-label="Agenten-Meetingraum"
|
aria-label="Digitale Belegschaft"
|
||||||
// Container-Kontext: die Schilder bemessen ihre Höchstbreite an der
|
sx={{
|
||||||
// Szene, nicht am Fenster (siehe AgentSeat).
|
position: 'relative',
|
||||||
sx={{ ...height, position: 'relative', overflow: 'hidden', containerType: 'inline-size' }}
|
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',
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<MeetingRoomScene />
|
<MeetingRoomScene />
|
||||||
|
|
||||||
<SceneHeadline urgentCount={urgentCount} attentionCount={attentionCount} />
|
<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} />
|
||||||
|
|
||||||
{MEETING_ROOM_SEATS.map(seat => (
|
<Box
|
||||||
<AgentSeat key={seat.id} seat={seat} workload={workload[seat.id]} onOpen={handleOpen} />
|
sx={{
|
||||||
))}
|
flex: 1,
|
||||||
|
minHeight: 0,
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: {
|
||||||
|
xs: '1fr',
|
||||||
|
sm: 'repeat(2, minmax(0, 1fr))',
|
||||||
|
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',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{staff.map(person => (
|
||||||
|
<AgentStaffCard key={person.agentId} presence={person} 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 }}>
|
||||||
<Typography
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
sx={{
|
<Box sx={{ width: 7, height: 7, borderRadius: '50%', bgcolor: DS_NEUTRAL.gold }} />
|
||||||
fontSize: '0.6875rem',
|
<Typography
|
||||||
fontWeight: 700,
|
sx={{
|
||||||
letterSpacing: 1.4,
|
fontSize: '0.6875rem',
|
||||||
textTransform: 'uppercase',
|
fontWeight: 700,
|
||||||
color: DS_NEUTRAL.gold,
|
letterSpacing: 1.4,
|
||||||
textShadow: '0 1px 6px rgba(8,14,24,0.6)',
|
textTransform: 'uppercase',
|
||||||
}}
|
color: DS_NEUTRAL.gold,
|
||||||
>
|
}}
|
||||||
Ihre digitale Belegschaft
|
>
|
||||||
</Typography>
|
{onDuty} von {total} im Dienst
|
||||||
|
</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}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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',
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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(
|
||||||
|
|||||||
Reference in New Issue
Block a user