diff --git a/src/assets/meeting-room/HERKUNFT.md b/src/assets/meeting-room/HERKUNFT.md new file mode 100644 index 0000000..90059ec --- /dev/null +++ b/src/assets/meeting-room/HERKUNFT.md @@ -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. diff --git a/src/assets/meeting-room/basel-rhein-muenster.jpg b/src/assets/meeting-room/basel-rhein-muenster.jpg deleted file mode 100644 index fff8aa5..0000000 Binary files a/src/assets/meeting-room/basel-rhein-muenster.jpg and /dev/null differ diff --git a/src/assets/meeting-room/buero.jpg b/src/assets/meeting-room/buero.jpg new file mode 100644 index 0000000..65eb6f2 Binary files /dev/null and b/src/assets/meeting-room/buero.jpg differ diff --git a/src/components/meeting-room/AgentMeetingRoom.tsx b/src/components/meeting-room/AgentMeetingRoom.tsx index 6c2374e..676117a 100644 --- a/src/components/meeting-room/AgentMeetingRoom.tsx +++ b/src/components/meeting-room/AgentMeetingRoom.tsx @@ -1,20 +1,18 @@ import { useCallback, useMemo } from 'react' 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 { AgentSeat } from './AgentSeat' -import { MEETING_ROOM_SEATS } from './meetingRoomSeats' -import { AgentAvatar } from '../team' -import { useAgentWorkload } from '../../hooks/useAgentWorkload' +import { AgentStaffCard } from './AgentStaffCard' +import { useAgentPresence } from '../../hooks/useAgentPresence' 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 * «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 * unten sichtbar wie auf einem 13-Zoll-Laptop. */ @@ -22,176 +20,133 @@ const TOP_BAR_PX = 64 const KEEP_VISIBLE_BELOW_PX = 118 const RESERVED_PX = TOP_BAR_PX + KEEP_VISIBLE_BELOW_PX -/** Unter dieser Höhe würde die Szene zur Briefmarke — dann lieber scrollen. */ -const MIN_SCENE_PX = 300 - -const TONE_COLOR: Record = { - [WorkloadTone.CALM]: DS_ACCENT.success.main, - [WorkloadTone.ATTENTION]: DS_ACCENT.warning.main, - [WorkloadTone.URGENT]: DS_ACCENT.danger.main, -} +/** Darunter würde die Belegschaft zur Briefmarke — dann lieber scrollen. */ +const MIN_SCENE_PX = 420 /** - * 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 - * mit Porträt, Funktion und seiner aktuellen Auslastung. Ein Klick auf die - * ganze Einheit öffnet den jeweiligen Arbeitsbereich, bei Giorgio die - * Personalverwaltung. + * Sechs Mitarbeitende an ihrem Arbeitsplatz — mit Porträt, Funktion, + * Abteilung, Personalnummer, Dienstzustand, einem Satz in der ersten Person + * und der zuletzt protokollierten Handlung. Ein Klick auf eine Karte öffnet + * den jeweiligen Arbeitsbereich, bei Giorgio die Personalverwaltung. * - * Die Zahl auf dem Schild ist der Unterschied zwischen Kulisse und - * Arbeitsmittel: sie sagt vor dem Klick, wo etwas liegt. - * - * Auf schmalen Schirmen tritt an die Stelle der Szene eine Kachelreihe mit - * denselben Angaben und denselben Zielen; ausgeblendet wird niemand (§3.7). + * Bewusst keine nachgebaute Raumperspektive mehr: eine gezeichnete Kulisse + * bleibt neben fotografierten Porträts erkennbar gezeichnet, und ein Raum + * allein macht niemanden menschlich. Was menschlich wirkt, ist dasselbe wie im + * echten Büro — dass jemand da ist, etwas zu sagen hat und nachweisbar + * gearbeitet hat. */ export function AgentMeetingRoom() { const navigate = useNavigate() - const theme = useTheme() - const isNarrow = useMediaQuery(theme.breakpoints.down('md')) - const { data: workload } = useAgentWorkload() + const { data: staff } = useAgentPresence() const handleOpen = useCallback((path: string) => navigate(path), [navigate]) // Ein Durchgang über dieselbe Liste statt zweier Filterläufe (CLAUDE.md §10.4). - const { urgentCount, attentionCount } = useMemo(() => { - let urgent = 0 - let attention = 0 - for (const w of Object.values(workload)) { - if (w.tone === WorkloadTone.URGENT) urgent += 1 - else if (w.tone === WorkloadTone.ATTENTION) attention += 1 + const { urgent, onDuty } = useMemo(() => { + let urgentCount = 0 + let onDutyCount = 0 + for (const person of staff) { + if (person.workload?.tone === WorkloadTone.URGENT) urgentCount += 1 + if (person.onDuty) onDutyCount += 1 } - return { urgentCount: urgent, attentionCount: attention } - }, [workload]) - - 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 ( - - - - - - - {MEETING_ROOM_SEATS.map(seat => { - const w = workload[seat.id] - return ( - 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 }, - }} - > - - - - {seat.name} - - - {seat.role} - - {w && ( - - {w.label} - - )} - - - ) - })} - - - ) - } + return { urgent: urgentCount, onDuty: onDutyCount } + }, [staff]) return ( - + + - {MEETING_ROOM_SEATS.map(seat => ( - - ))} + + {staff.map(person => ( + + ))} + + ) } /** - * Anriss über der Szene. + * Anriss über der Belegschaft. * - * Er sagt in einem Satz, was die sechs Schilder darunter im Detail zeigen — - * und beantwortet damit die einzige Frage, mit der jemand diese Seite öffnet: - * muss ich heute irgendwo eingreifen? Ohne diesen Satz müsste man sechs Zahlen - * einzeln lesen und selbst zusammenzählen. + * Beantwortet die Frage, mit der jemand diese Seite öffnet: muss ich heute + * irgendwo eingreifen? Ohne den Satz müsste man sechs Karten lesen und selbst + * zusammenzählen. */ -function SceneHeadline({ urgentCount, attentionCount }: { urgentCount: number; attentionCount: number }) { +function StaffHeadline({ urgent, onDuty, total }: { urgent: number; onDuty: number; total: number }) { const summary = - urgentCount > 0 - ? `${urgentCount} ${urgentCount === 1 ? 'Bereich braucht' : 'Bereiche brauchen'} heute Ihre Aufmerksamkeit` - : attentionCount > 0 - ? `${attentionCount} ${attentionCount === 1 ? 'Bereich ist' : 'Bereiche sind'} in Arbeit — nichts Überfälliges` - : 'Nichts Offenes — Ihre Belegschaft ist auf Kurs' + urgent > 0 + ? `${urgent} ${urgent === 1 ? 'Mitarbeitender braucht' : 'Mitarbeitende brauchen'} heute eine Entscheidung von dir` + : 'Alle laufen — heute liegt nichts bei dir' return ( - - - Ihre digitale Belegschaft - + + + + + {onDuty} von {total} im Dienst + + {summary} diff --git a/src/components/meeting-room/AgentSeat.tsx b/src/components/meeting-room/AgentSeat.tsx deleted file mode 100644 index 4dd579c..0000000 --- a/src/components/meeting-room/AgentSeat.tsx +++ /dev/null @@ -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.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 `
`: 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 ( - 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. */} - - - - - - - {/* Namensschild unter dem Kopf: Name, Funktion, aktuelle Auslastung. */} - - - {seat.name} - - - {seat.role} - - - {workload && ( - - - - {workload.label} - - - )} - - - ) -}) diff --git a/src/components/meeting-room/AgentStaffCard.tsx b/src/components/meeting-room/AgentStaffCard.tsx new file mode 100644 index 0000000..7172535 --- /dev/null +++ b/src/components/meeting-room/AgentStaffCard.tsx @@ -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.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 ( + 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 */} + + + + {/* Dienstpunkt am Porträt — die kürzeste Art zu sagen «ist da». */} + + + + + + {presence.name} + + + {presence.role} + + {(presence.department || presence.personnelNumber) && ( + + {[presence.department, presence.personnelNumber].filter(Boolean).join(' · ')} + + )} + + + + {/* Kompetenz: was der Mitarbeitende selbst entscheiden darf. */} + {presence.autonomy && ( + + + {presence.autonomy} + + + )} + + {/* Was er dazu sagt — in der ersten Person. */} + {workload && ( + + + + {workload.voice} + + + )} + + {/* Beleg: was zuletzt tatsächlich getan wurde. */} + + {presence.lastAction ? ( + <> + + Zuletzt · {presence.lastAction.when} + + + + {presence.lastAction.title} + + + + + + + ) : ( + + Noch keine Tätigkeit protokolliert. + + )} + + + ) +}) diff --git a/src/components/meeting-room/MeetingRoomScene.tsx b/src/components/meeting-room/MeetingRoomScene.tsx index 6a35f7e..c33593a 100644 --- a/src/components/meeting-room/MeetingRoomScene.tsx +++ b/src/components/meeting-room/MeetingRoomScene.tsx @@ -1,7 +1,7 @@ import { memo } from 'react' -import { Box, Typography } from '@mui/material' +import { Box } from '@mui/material' 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). @@ -34,22 +34,6 @@ export const MeetingRoomScene = memo(function MeetingRoomScene() { - {ROOM_BACKDROP.credit && ( - - {ROOM_BACKDROP.credit} - - )} ) }) diff --git a/src/components/meeting-room/index.ts b/src/components/meeting-room/index.ts index 8157fe5..32e3126 100644 --- a/src/components/meeting-room/index.ts +++ b/src/components/meeting-room/index.ts @@ -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 { MEETING_ROOM_SEATS, SEAT_BASE_HEAD_PX, SeatSide } from './meetingRoomSeats' -export type { MeetingRoomSeat } from './meetingRoomSeats' +export { AgentStaffCard } from './AgentStaffCard' +export { ROOM_BACKDROP } from './roomBackdrop' diff --git a/src/components/meeting-room/meetingRoomSeats.ts b/src/components/meeting-room/meetingRoomSeats.ts deleted file mode 100644 index ca144de..0000000 --- a/src/components/meeting-room/meetingRoomSeats.ts +++ /dev/null @@ -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 - -/** - * 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 = { - 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 diff --git a/src/components/meeting-room/roomBackdrop.ts b/src/components/meeting-room/roomBackdrop.ts index 0c309ce..33f8c96 100644 --- a/src/components/meeting-room/roomBackdrop.ts +++ b/src/components/meeting-room/roomBackdrop.ts @@ -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 - * Bildes ist damit ein Einzeiler — und die Sitzordnung daneben in - * `meetingRoomSeats.ts` ist das Einzige, was danach nachzujustieren ist. + * Genau eine Stelle bestimmt, welches Bild hinter den Personenkarten liegt. + * Ein Austausch ist damit ein Einzeiler; die Herkunft steht in `HERKUNFT.md` + * neben der Bilddatei. * - * Warum ein Foto und keine gezeichnete Kulisse: die Agenten sind Porträtfotos. - * Alles, was daneben gezeichnet ist, fällt im Vergleich sofort auf — eine - * gemalte Wand neben einem echten Gesicht sieht nach Attrappe aus. Umgekehrt - * trägt ein echtes Bild die gesamte Bildwirkung, ohne dass eine einzige Fläche - * 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. + * Das Bild ist Umgebung, nicht Motiv: es zeigt ein Büro, damit die digitalen + * Mitarbeitenden einen Arbeitsplatz haben statt eines leeren Hintergrunds. Der + * Verlauf darüber nimmt ihm den grössten Teil des Kontrasts — sonst + * konkurriert jeder Bürostuhl mit den Gesichtern und der Schrift davor. */ export interface RoomBackdrop { src: string /** Bildausschnitt bei `object-fit: cover`. */ focus: string - /** - * Verlauf über dem Bild. 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. - */ + /** Verlauf über dem Bild: Lesbarkeit und Übergang zur Objektliste darunter. */ scrim: string - /** Kurze Herkunftsangabe, unten rechts klein eingeblendet. */ - credit?: string } export const ROOM_BACKDROP: RoomBackdrop = { - src: baselWindow, - focus: '50% 42%', + src: buero, + focus: '50% 58%', scrim: [ - // Oberer Rand leicht abdunkeln — hält den Blick unten bei der Belegschaft. - 'linear-gradient(180deg, rgba(10,16,26,0.34) 0%, rgba(10,16,26,0.06) 22%, rgba(10,16,26,0) 40%)', - // Breites dunkles Band unter der Bildmitte: es trägt die weissen Schilder - // und bindet die Porträts an den Hintergrund, statt sie darüber schweben zu - // 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%)', + // Grundton: das Büro tritt weit zurück, die Karten treten hervor. + 'linear-gradient(180deg, rgba(9,14,23,0.84) 0%, rgba(9,14,23,0.76) 52%, rgba(9,14,23,0.92) 100%)', + // Warmes Streiflicht von links, damit die Fläche nicht tot wirkt. + 'radial-gradient(85% 120% at 10% 6%, rgba(184,151,90,0.18) 0%, rgba(184,151,90,0) 58%)', ].join(', '), - credit: 'Basel · Rhein und Münster', } diff --git a/src/domain/agentPresence.ts b/src/domain/agentPresence.ts new file mode 100644 index 0000000..9cffb86 --- /dev/null +++ b/src/domain/agentPresence.ts @@ -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 + } +} diff --git a/src/domain/agentWorkload.ts b/src/domain/agentWorkload.ts index 7ac3f63..a4d62de 100644 --- a/src/domain/agentWorkload.ts +++ b/src/domain/agentWorkload.ts @@ -32,4 +32,11 @@ export interface AgentWorkload { */ label: string tone: WorkloadTone + /** + * Derselbe Sachverhalt in der ersten Person — das, was der Agent dazu sagen + * würde. Digitale Mitarbeitende sollen als Personal auftreten, nicht als + * Kennzahl: «Bei 12 Fristen ist der Termin durch» ist dieselbe Information + * wie «12 überfällig», aber es spricht jemand. + */ + voice: string } diff --git a/src/hooks/useAgentPresence.ts b/src/hooks/useAgentPresence.ts new file mode 100644 index 0000000..28fa5bc --- /dev/null +++ b/src/hooks/useAgentPresence.ts @@ -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 } +} diff --git a/src/lib/agentWorkspaces.ts b/src/lib/agentWorkspaces.ts index cb4a789..f97abf6 100644 --- a/src/lib/agentWorkspaces.ts +++ b/src/lib/agentWorkspaces.ts @@ -14,6 +14,10 @@ import { ROUTES } from './constants' 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`. */ id: string name: string @@ -48,5 +52,7 @@ export const AGENT_SUPERVISOR: AgentWorkspace = { id: 'giorgio', name: 'Giorgio', role: 'Personalverwalter', + department: 'Personalwesen', + personnelNumber: 'PO-ZD-01', path: ROUTES.SUPPLY.TEAM, } diff --git a/src/services/__tests__/agentPresenceService.test.ts b/src/services/__tests__/agentPresenceService.test.ts new file mode 100644 index 0000000..c1f259b --- /dev/null +++ b/src/services/__tests__/agentPresenceService.test.ts @@ -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 { + return { agents: [], protocol: [], workload: WORKLOAD, ...partial } +} + +// Nur die Felder, die der Dienst liest. +const dossier = (id: string, extra: Record = {}) => + ({ + 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) + }) +}) diff --git a/src/services/agentPresenceService.ts b/src/services/agentPresenceService.ts new file mode 100644 index 0000000..9510ca0 --- /dev/null +++ b/src/services/agentPresenceService.ts @@ -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 +} + +/** + * @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() + 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, + } + }) +} diff --git a/src/services/agentWorkloadService.ts b/src/services/agentWorkloadService.ts index 1cfc5a3..ace5bd7 100644 --- a/src/services/agentWorkloadService.ts +++ b/src/services/agentWorkloadService.ts @@ -95,12 +95,16 @@ export function computeAgentWorkload( count: overdue, label: `${overdue} ${plural(overdue, 'Frist', 'Fristen')} überfällig`, tone: WorkloadTone.URGENT, + voice: `Bei ${overdue} ${plural(overdue, 'Frist', 'Fristen')} ist der Termin durch. Fürs Eskalieren brauche ich deine Freigabe.`, } : { agentId: 'ferdi', count: dueThisWeek, label: `${dueThisWeek} ${plural(dueThisWeek, 'Frist', 'Fristen')} diese Woche`, tone: tone(dueThisWeek, URGENT_FROM.reminders), + voice: dueThisWeek === 0 + ? 'Diese Woche läuft keine Frist ab. Ich behalte den Bestand im Auge.' + : `${dueThisWeek} ${plural(dueThisWeek, 'Frist läuft', 'Fristen laufen')} diese Woche ab. Ich habe alle im Blick.`, } // ── Bruno: Besichtigungen ─────────────────────────────────────────────────── @@ -110,6 +114,9 @@ export function computeAgentWorkload( count: upcomingVisits, label: `${upcomingVisits} ${plural(upcomingVisits, 'Besichtigung', 'Besichtigungen')} diese Woche`, tone: tone(upcomingVisits, URGENT_FROM.visits), + voice: upcomingVisits === 0 + ? 'Diese Woche steht kein Termin an. Der Kalender bleibt bei mir.' + : `${upcomingVisits} ${plural(upcomingVisits, 'Besichtigung steht', 'Besichtigungen stehen')} an. Die Briefings sind vorbereitet.`, } // ── Livia: weitergeleitete Leads ──────────────────────────────────────────── @@ -119,6 +126,9 @@ export function computeAgentWorkload( count: openExpose, label: `${openExpose} ${plural(openExpose, 'Exposé offen', 'Exposés offen')}`, tone: tone(openExpose, URGENT_FROM.expose), + voice: openExpose === 0 + ? 'Kein Exposé offen. Leite mir einen Lead weiter, dann lege ich los.' + : `${openExpose} ${plural(openExpose, 'Lead wartet', 'Leads warten')} auf ein Exposé von mir.`, } // ── Nora: Marktchancen ────────────────────────────────────────────────────── @@ -129,6 +139,9 @@ export function computeAgentWorkload( label: `${freshLeads} neue ${plural(freshLeads, 'Marktchance', 'Marktchancen')}`, // Neue Chancen sind nie «dringend» — Nora entscheidet nichts, sie schlägt vor. tone: freshLeads > 0 ? WorkloadTone.ATTENTION : WorkloadTone.CALM, + voice: freshLeads === 0 + ? 'Zurzeit keine neue Chance. Ich beobachte den Markt weiter.' + : `Ich habe ${freshLeads} neue ${plural(freshLeads, 'Chance', 'Chancen')} gefunden. Entscheiden musst du.`, } // ── Sina: Datenpflege ─────────────────────────────────────────────────────── @@ -138,6 +151,9 @@ export function computeAgentWorkload( count: gaps, label: `${gaps} ${plural(gaps, 'Objekt', 'Objekte')} mit Datenlücken`, tone: tone(gaps, URGENT_FROM.dataGaps), + voice: gaps === 0 + ? 'Alle Objekte sind vollständig erfasst. Ich prüfe weiter.' + : `Bei ${gaps} ${plural(gaps, 'Objekt fehlen', 'Objekten fehlen')} mir Angaben. Ich melde, was ich nicht selbst finde.`, } // ── Giorgio: Freigaben in der Personalverwaltung ──────────────────────────── @@ -147,6 +163,9 @@ export function computeAgentWorkload( count: pending, label: `${pending} ${plural(pending, 'Freigabe', 'Freigaben')} offen`, tone: tone(pending, URGENT_FROM.approvals), + voice: pending === 0 + ? 'Nichts liegt zur Freigabe bereit. Die Belegschaft läuft.' + : `${pending} ${plural(pending, 'Vorgang wartet', 'Vorgänge warten')} auf deine Freigabe.`, } return Object.fromEntries(