diff --git a/src/assets/meeting-room/basel-rhein-muenster.jpg b/src/assets/meeting-room/basel-rhein-muenster.jpg new file mode 100644 index 0000000..fff8aa5 Binary files /dev/null and b/src/assets/meeting-room/basel-rhein-muenster.jpg differ diff --git a/src/components/meeting-room/AgentMeetingRoom.tsx b/src/components/meeting-room/AgentMeetingRoom.tsx index 3c2fd94..6c2374e 100644 --- a/src/components/meeting-room/AgentMeetingRoom.tsx +++ b/src/components/meeting-room/AgentMeetingRoom.tsx @@ -1,15 +1,13 @@ -import { useCallback } from 'react' +import { useCallback, useMemo } from 'react' import { useNavigate } from 'react-router' import { Box, ButtonBase, Typography, useMediaQuery, useTheme } from '@mui/material' import { MeetingRoomScene } from './MeetingRoomScene' import { AgentSeat } from './AgentSeat' -import { BaselViewFront } from './BaselViewFront' import { MEETING_ROOM_SEATS } from './meetingRoomSeats' import { AgentAvatar } from '../team' -import { DS_MEETING_ROOM, DS_TEXT } from '../../lib/ds' - -const R = DS_MEETING_ROOM.room -const L = DS_MEETING_ROOM.label +import { useAgentWorkload } from '../../hooks/useAgentWorkload' +import { WorkloadTone } from '../../domain/agentWorkload' +import { DS_ACCENT, DS_NEUTRAL, DS_TEXT } from '../../lib/ds' /** * Höhe der globalen Kopfzeile plus des Bereichs, der unter dem Meetingraum @@ -27,25 +25,45 @@ 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, +} + /** * Agenten-Meetingraum auf der Startseite (Runde 5, §3). * - * Der Betrachter sitzt am Kopfende des Tisches; die fünf Agenten sitzen an den - * Längsseiten, Giorgio vis-à-vis vor der Fensterfront. Jeder Platz ist eine - * ganze klickbare Einheit und führt auf seine Arbeitsseite, Giorgio in die + * 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. * - * Auf schmalen Schirmen bricht die Perspektive zusammen — dort steht statt der - * Szene eine einfache Kachelreihe. Sie zeigt dieselben Agenten mit denselben - * Zielen; ausgeblendet wird niemand (§3.7). + * 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). */ export function AgentMeetingRoom() { const navigate = useNavigate() const theme = useTheme() const isNarrow = useMediaQuery(theme.breakpoints.down('md')) + const { data: workload } = useAgentWorkload() 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 + } + return { urgentCount: urgent, attentionCount: attention } + }, [workload]) + const height = { height: `max(${MIN_SCENE_PX}px, calc(100vh - ${RESERVED_PX}px))`, '@supports (height: 100dvh)': { @@ -55,12 +73,13 @@ export function AgentMeetingRoom() { if (isNarrow) { return ( - - {/* Die Aussicht nimmt den Platz, den die Kacheln nicht brauchen — sonst - klafft auf hohen Schirmen eine leere Wand zwischen beiden. */} - - - + + + - {MEETING_ROOM_SEATS.map(seat => ( - handleOpen(seat.path)} - aria-label={`${seat.name}, ${seat.role} — Arbeitsbereich öffnen`} - sx={{ - display: 'flex', alignItems: 'center', gap: 1, - p: 1, borderRadius: 2, justifyContent: 'flex-start', - bgcolor: L.bg, border: '1px solid', borderColor: L.border, - '&.Mui-focusVisible': { outline: `2px solid ${L.focus}`, outlineOffset: 2 }, - }} - > - - - - {seat.name} - - - {seat.role} - - - - ))} + {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} + + )} + + + ) + })} ) @@ -108,12 +138,64 @@ export function AgentMeetingRoom() { + + + {MEETING_ROOM_SEATS.map(seat => ( - + ))} ) } + +/** + * Anriss über der Szene. + * + * 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. + */ +function SceneHeadline({ urgentCount, attentionCount }: { urgentCount: number; attentionCount: 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' + + return ( + + + Ihre digitale Belegschaft + + + {summary} + + + ) +} diff --git a/src/components/meeting-room/AgentSeat.tsx b/src/components/meeting-room/AgentSeat.tsx index ab3590e..4dd579c 100644 --- a/src/components/meeting-room/AgentSeat.tsx +++ b/src/components/meeting-room/AgentSeat.tsx @@ -2,37 +2,51 @@ 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, SeatSide } from './meetingRoomSeats' -import { DS_MEETING_ROOM, DS_TEXT } from '../../lib/ds' +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 L = DS_MEETING_ROOM.label -const T = DS_MEETING_ROOM.table +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 am Besprechungstisch (Runde 5, §3.6/§3.7). + * Ein Platz im Meetingraum (Runde 5, §3.6/§3.7). * - * Die gesamte Einheit aus Stuhllehne, Kopf und Plakette ist ein einziger - * Schalter — nicht nur der Kopf. Ein 60-Pixel-Porträt als einzige Trefferfläche - * wäre auf jedem Laptop eine Zumutung; die Plakette darüber trägt ohnehin den - * Namen und gehört zum selben Ziel. + * 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. * - * `ButtonBase` statt `
`: damit sind Tastaturbedienung, Enter und - * Leertaste, Fokusring und Rollen-Semantik ohne eigenen Code korrekt. + * 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, onOpen }: AgentSeatProps) { +export const AgentSeat = memo(function AgentSeat({ seat, workload, onOpen }: AgentSeatProps) { const head = Math.round(SEAT_BASE_HEAD_PX * seat.scale) - const isHead = seat.side === SeatSide.HEAD + const toneColor = workload ? TONE_COLOR[workload.tone] : DS_TEXT.secondary return ( onOpen(seat.path)} - aria-label={`${seat.name}, ${seat.role} — Arbeitsbereich öffnen`} + aria-label={ + workload + ? `${seat.name}, ${seat.role} — ${workload.label}. Arbeitsbereich öffnen` + : `${seat.name}, ${seat.role} — Arbeitsbereich öffnen` + } sx={{ position: 'absolute', left: `${seat.x}%`, @@ -42,51 +56,74 @@ export const AgentSeat = memo(function AgentSeat({ seat, onOpen }: AgentSeatProp display: 'flex', flexDirection: 'column', alignItems: 'center', - // Grosszügige, aber nicht überlappende Klickfläche rund um den Kopf. - px: 1.25, + gap: 0.75, + px: 0.5, py: 1, - borderRadius: 2, - transition: 'transform 0.15s ease, background-color 0.15s ease', - '&:hover, &.Mui-focusVisible': { - bgcolor: 'rgba(255,255,255,0.16)', - transform: 'translate(-50%, -52%)', + 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 ${L.focus}`, outlineOffset: 2 }, + '&.Mui-focusVisible': { outline: `2px solid ${DS_NEUTRAL.gold}`, outlineOffset: 3 }, }} > - {/* - Plakette über dem Kopf: Name und Funktion. Sie schwebt vollständig - oberhalb des Porträts — läge sie auch nur teilweise darüber, verdeckte - der Kopf sie, weil er später gezeichnet wird. Der seitliche Versatz aus - der Sitzordnung fächert benachbarte Plaketten auseinander (§3.7). - */} + {/* Kontaktschatten unter dem Porträt — ohne ihn schwebt der Kopf über dem Bild. */} + + + + + + {/* Namensschild unter dem Kopf: Name, Funktion, aktuelle Auslastung. */} + {seat.role} + + {workload && ( + + + + {workload.label} + + + )} - - {/* Stuhllehne hinter dem Kopf — am Kopfende sitzt Giorgio frontal. */} - - - ) }) diff --git a/src/components/meeting-room/BaselViewFront.tsx b/src/components/meeting-room/BaselViewFront.tsx deleted file mode 100644 index 6e83bb1..0000000 --- a/src/components/meeting-room/BaselViewFront.tsx +++ /dev/null @@ -1,102 +0,0 @@ -import { memo } from 'react' -import { DS_MEETING_ROOM } from '../../lib/ds' - -const V = DS_MEETING_ROOM.view - -/** - * Blick durch die gegenüberliegende Fensterfront (Runde 5, §3.5). - * - * Das Büro steht in Kleinbasel nahe dem Claraturm, der Blick geht über den - * Rhein auf die Grossbasler Altstadt mit dem Münster — die Blickrichtung der - * beiden Referenzbilder aus dem Auftrag. - * - * Gezeichnet und nicht fotografiert: ein Foto in einem Fensterrahmen mit - * gerechneter Perspektive verzieht sich auf jedem zweiten Monitor, und ein - * fehlendes Bild wäre ein 404 im leeren Fenster. Das SVG skaliert stattdessen - * mit dem Rahmen und braucht keine Netzanfrage. - */ -export const BaselViewFront = memo(function BaselViewFront() { - return ( - - ) -}) diff --git a/src/components/meeting-room/BaselViewLeft.tsx b/src/components/meeting-room/BaselViewLeft.tsx deleted file mode 100644 index 80cb819..0000000 --- a/src/components/meeting-room/BaselViewLeft.tsx +++ /dev/null @@ -1,95 +0,0 @@ -import { memo } from 'react' -import { DS_MEETING_ROOM } from '../../lib/ds' - -const V = DS_MEETING_ROOM.view - -/** - * Blick durch die linke Glasfront (Runde 5, §3.5). - * - * Rheinabwärts: der weitere Verlauf des Rheins, die Altstadt am rechten Ufer - * und die Wettsteinbrücke. Das Bild ist stärker längs angelegt als der Blick - * nach vorne — die linke Front wird perspektivisch schmal und hoch beschnitten, - * deshalb steht die Brücke bewusst weit oben statt am Bildrand. - */ -export const BaselViewLeft = memo(function BaselViewLeft() { - return ( - - ) -}) diff --git a/src/components/meeting-room/MeetingRoomScene.tsx b/src/components/meeting-room/MeetingRoomScene.tsx index 34c8d30..6a35f7e 100644 --- a/src/components/meeting-room/MeetingRoomScene.tsx +++ b/src/components/meeting-room/MeetingRoomScene.tsx @@ -1,188 +1,55 @@ import { memo } from 'react' -import { Box } from '@mui/material' -import { BaselViewFront } from './BaselViewFront' -import { BaselViewLeft } from './BaselViewLeft' -import { DS_MEETING_ROOM } from '../../lib/ds' - -const R = DS_MEETING_ROOM.room -const T = DS_MEETING_ROOM.table +import { Box, Typography } from '@mui/material' +import { ROOM_BACKDROP } from './roomBackdrop' +import { DS_NEUTRAL, DS_TEXT } from '../../lib/ds' /** - * Der leere Besprechungsraum — Decke, Wände, Boden, Fenster, Tisch (Runde 5, §3). + * Bildhintergrund des Agenten-Meetingraums (Runde 5, §3). * - * Zentralperspektive mit einem Fluchtpunkt in der Bildmitte: der Betrachter - * sitzt am Kopfende, der Tisch läuft von ihm weg zur gegenüberliegenden - * Fensterfront. Rechts eine geschlossene Wand, links und gegenüber - * boden- bis deckenhohe Glasfronten (§3.5). + * Bewusst eine einzige Bildfläche mit Verlauf — und nicht länger ein aus + * Flächen konstruierter Raum. Ein selbstgezeichneter Raum bleibt neben + * fotografierten Porträts immer erkennbar gezeichnet; die Bildwirkung muss + * deshalb aus dem Foto kommen, nicht aus Geometrie. * - * Umgesetzt als Flächen mit `clip-path`, nicht mit CSS-3D-Transformationen: - * Trapeze in Prozentkoordinaten skalieren mit jeder Rahmengrösse mit, während - * `rotateY`/`perspective` bei wechselnder Höhe neu justiert werden müsste. Es - * braucht dafür weder eine 3D-Engine noch eine Animationsbibliothek (§1). - * - * Senkrechten bleiben in der Einpunktperspektive senkrecht — die Pfosten beider - * Fensterfronten stehen deshalb gerade, nur die waagrechten Rahmen laufen zum - * Fluchtpunkt. - * - * Die Zahlen unten sind die Geometrie des Raums und stehen deshalb hier statt - * in einer Konstantendatei — sie ergeben nur zusammen mit den Flächen Sinn. + * Die Komponente selbst weiss nichts über das Motiv. Sie zeigt, was + * `ROOM_BACKDROP` liefert — die Aussicht heute, eine Raumaufnahme später. */ - -/** Fluchtebene: obere und untere Kante der gegenüberliegenden Wand, in Prozent. */ -const HORIZON_TOP = 26 -const HORIZON_BOTTOM = 62 -/** Seitliche Kanten der gegenüberliegenden Wand. */ -const WALL_LEFT = 30 -const WALL_RIGHT = 70 - export const MeetingRoomScene = memo(function MeetingRoomScene() { return ( - - {/* Decke */} - - - {/* Boden */} - - - {/* ── Rechte Seite: geschlossene Wand ── */} - - {/* Sockelleiste — trennt Wand und Boden sichtbar voneinander */} - - - {/* ── Linke Seite: boden- bis deckenhohe Glasfront, Blick rheinabwärts ── */} - - {/* - Das Bild deckt nur den Wandstreifen ab, nicht die ganze Szene: bei - `inset: 0` streckte `preserveAspectRatio="slice"` es auf Szenenbreite, - und im schmalen Ausschnitt blieben nur riesige Baumkronen übrig. - */} - - - - - - {/* Senkrechte Pfosten */} - {[9, 17.5, 25.5].map(x => ( - - ))} - {/* Waagrechter Kämpfer — läuft mit der Perspektive zum Fluchtpunkt */} - - {/* Deckenanschluss und Bodenschiene der Front */} - - - {/* Streiflicht auf dem Glas */} - - - - {/* ── Gegenüberliegende Fensterfront: Rhein, Altstadt, Münster ── */} +