refactor(startseite): Meetingraum als Fotoszene mit Live-Auslastung
Die gezeichnete Raumkulisse ist ersetzt. Aus Farbflächen konstruierte Wände bleiben neben fotografierten Agentenporträts erkennbar gezeichnet — die Bildwirkung muss aus dem Foto kommen, nicht aus Geometrie. Entfallen sind damit die beiden SVG-Stadtansichten, die CSS-3D-Flächen und die Farbwelt DS_MEETING_ROOM. Neu trägt die Szene die Basler Aussicht aus dem Auftrag als Foto (§5), mit abgestuftem Verlauf für Lesbarkeit und Übergang zur Objektliste. Ein Austausch des Motivs ist ein Einzeiler in `roomBackdrop.ts`. Mehrwert statt Kulisse: Jedes Namensschild zeigt die aktuelle Auslastung seines Agenten — überfällige Fristen, Termine der Woche, offene Exposés, neue Marktchancen, Objekte mit Datenlücken, offene Freigaben. Darüber fasst ein Anriss zusammen, wie viele Bereiche heute Aufmerksamkeit brauchen. Damit beantwortet die Startseite die Frage, mit der sie geöffnet wird, statt sie auf sechs Zahlen zu verteilen. - `agentWorkloadService` hält die fachlichen Festlegungen: Zeitfenster, Dringlichkeitsschwellen und Formulierungen. Der Stichzeitpunkt ist ein Parameter, nicht die Systemuhr — dieselbe Datenlage ergibt dasselbe Ergebnis, und der Dienst ist ohne Renderer prüfbar (10 neue Tests). - `useAgentWorkload` sammelt nur die Bestände ein. - Noras Zahl wird nie «dringend»: sie schlägt vor, sie entscheidet nicht. - Die Schilder bemessen ihre Höchstbreite an der Szenenbreite (`cqw`), nicht am Fenster. Am Fenster gemessen überlappten sie auf schmalen Laptops, weil die Szene um das Seitenmenü schmaler ist. Geprüft: 423 Tests, Typecheck, Build, ESLint, Token-Schwelle (889/925). Im Browser 1280–1920 px: keine Überlappungen, Titel und «Filter & Übersicht» beim Einstieg sichtbar, alle sechs Agentenklicks auf der richtigen Subpage, Tablet-Ansicht mit allen sechs Agenten, keine Konsolenfehler, keine 404. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 71 KiB |
@@ -1,15 +1,13 @@
|
|||||||
import { useCallback } 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, ButtonBase, Typography, useMediaQuery, useTheme } from '@mui/material'
|
||||||
import { MeetingRoomScene } from './MeetingRoomScene'
|
import { MeetingRoomScene } from './MeetingRoomScene'
|
||||||
import { AgentSeat } from './AgentSeat'
|
import { AgentSeat } from './AgentSeat'
|
||||||
import { BaselViewFront } from './BaselViewFront'
|
|
||||||
import { MEETING_ROOM_SEATS } from './meetingRoomSeats'
|
import { MEETING_ROOM_SEATS } from './meetingRoomSeats'
|
||||||
import { AgentAvatar } from '../team'
|
import { AgentAvatar } from '../team'
|
||||||
import { DS_MEETING_ROOM, DS_TEXT } from '../../lib/ds'
|
import { useAgentWorkload } from '../../hooks/useAgentWorkload'
|
||||||
|
import { WorkloadTone } from '../../domain/agentWorkload'
|
||||||
const R = DS_MEETING_ROOM.room
|
import { DS_ACCENT, DS_NEUTRAL, DS_TEXT } from '../../lib/ds'
|
||||||
const L = DS_MEETING_ROOM.label
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Höhe der globalen Kopfzeile plus des Bereichs, der unter dem Meetingraum
|
* 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. */
|
/** Unter dieser Höhe würde die Szene zur Briefmarke — dann lieber scrollen. */
|
||||||
const MIN_SCENE_PX = 300
|
const MIN_SCENE_PX = 300
|
||||||
|
|
||||||
|
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).
|
* Agenten-Meetingraum auf der Startseite (Runde 5, §3).
|
||||||
*
|
*
|
||||||
* Der Betrachter sitzt am Kopfende des Tisches; die fünf Agenten sitzen an den
|
* Die sechs digitalen Mitarbeitenden stehen vor der Basler Fensterfront, jeder
|
||||||
* Längsseiten, Giorgio vis-à-vis vor der Fensterfront. Jeder Platz ist eine
|
* mit Porträt, Funktion und seiner aktuellen Auslastung. Ein Klick auf die
|
||||||
* ganze klickbare Einheit und führt auf seine Arbeitsseite, Giorgio in die
|
* ganze Einheit öffnet den jeweiligen Arbeitsbereich, bei Giorgio die
|
||||||
* Personalverwaltung.
|
* Personalverwaltung.
|
||||||
*
|
*
|
||||||
* Auf schmalen Schirmen bricht die Perspektive zusammen — dort steht statt der
|
* Die Zahl auf dem Schild ist der Unterschied zwischen Kulisse und
|
||||||
* Szene eine einfache Kachelreihe. Sie zeigt dieselben Agenten mit denselben
|
* Arbeitsmittel: sie sagt vor dem Klick, wo etwas liegt.
|
||||||
* Zielen; ausgeblendet wird niemand (§3.7).
|
*
|
||||||
|
* 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() {
|
export function AgentMeetingRoom() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const theme = useTheme()
|
const theme = useTheme()
|
||||||
const isNarrow = useMediaQuery(theme.breakpoints.down('md'))
|
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).
|
||||||
|
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 = {
|
const height = {
|
||||||
height: `max(${MIN_SCENE_PX}px, calc(100vh - ${RESERVED_PX}px))`,
|
height: `max(${MIN_SCENE_PX}px, calc(100vh - ${RESERVED_PX}px))`,
|
||||||
'@supports (height: 100dvh)': {
|
'@supports (height: 100dvh)': {
|
||||||
@@ -55,12 +73,13 @@ export function AgentMeetingRoom() {
|
|||||||
|
|
||||||
if (isNarrow) {
|
if (isNarrow) {
|
||||||
return (
|
return (
|
||||||
<Box component="section" aria-label="Agenten-Meetingraum" sx={{ ...height, position: 'relative', display: 'flex', flexDirection: 'column' }}>
|
<Box
|
||||||
{/* Die Aussicht nimmt den Platz, den die Kacheln nicht brauchen — sonst
|
component="section"
|
||||||
klafft auf hohen Schirmen eine leere Wand zwischen beiden. */}
|
aria-label="Agenten-Meetingraum"
|
||||||
<Box sx={{ position: 'relative', flex: 1, minHeight: 140, overflow: 'hidden' }}>
|
sx={{ ...height, position: 'relative', display: 'flex', flexDirection: 'column' }}
|
||||||
<BaselViewFront />
|
>
|
||||||
<Box sx={{ position: 'absolute', inset: 0, bgcolor: R.glassTint }} />
|
<Box sx={{ position: 'relative', flex: 1, minHeight: 140 }}>
|
||||||
|
<MeetingRoomScene />
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Box
|
<Box
|
||||||
@@ -70,22 +89,27 @@ export function AgentMeetingRoom() {
|
|||||||
gridTemplateColumns: { xs: 'repeat(2, minmax(0, 1fr))', sm: 'repeat(3, minmax(0, 1fr))' },
|
gridTemplateColumns: { xs: 'repeat(2, minmax(0, 1fr))', sm: 'repeat(3, minmax(0, 1fr))' },
|
||||||
gap: 1,
|
gap: 1,
|
||||||
p: 2,
|
p: 2,
|
||||||
bgcolor: R.wall,
|
bgcolor: DS_NEUTRAL.paper,
|
||||||
borderTop: '1px solid',
|
|
||||||
borderColor: R.skirting,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{MEETING_ROOM_SEATS.map(seat => (
|
{MEETING_ROOM_SEATS.map(seat => {
|
||||||
|
const w = workload[seat.id]
|
||||||
|
return (
|
||||||
<ButtonBase
|
<ButtonBase
|
||||||
key={seat.id}
|
key={seat.id}
|
||||||
focusRipple
|
focusRipple
|
||||||
onClick={() => handleOpen(seat.path)}
|
onClick={() => handleOpen(seat.path)}
|
||||||
aria-label={`${seat.name}, ${seat.role} — Arbeitsbereich öffnen`}
|
aria-label={
|
||||||
|
w
|
||||||
|
? `${seat.name}, ${seat.role} — ${w.label}. Arbeitsbereich öffnen`
|
||||||
|
: `${seat.name}, ${seat.role} — Arbeitsbereich öffnen`
|
||||||
|
}
|
||||||
sx={{
|
sx={{
|
||||||
display: 'flex', alignItems: 'center', gap: 1,
|
display: 'flex', alignItems: 'center', gap: 1,
|
||||||
p: 1, borderRadius: 2, justifyContent: 'flex-start',
|
p: 1, borderRadius: 2, justifyContent: 'flex-start',
|
||||||
bgcolor: L.bg, border: '1px solid', borderColor: L.border,
|
bgcolor: DS_NEUTRAL.white,
|
||||||
'&.Mui-focusVisible': { outline: `2px solid ${L.focus}`, outlineOffset: 2 },
|
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} />
|
<AgentAvatar agent={{ id: seat.id, name: seat.name, role: seat.role }} size={38} />
|
||||||
@@ -96,9 +120,15 @@ export function AgentMeetingRoom() {
|
|||||||
<Typography sx={{ fontSize: '0.6875rem', lineHeight: 1.25, color: DS_TEXT.secondary }}>
|
<Typography sx={{ fontSize: '0.6875rem', lineHeight: 1.25, color: DS_TEXT.secondary }}>
|
||||||
{seat.role}
|
{seat.role}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
{w && (
|
||||||
|
<Typography sx={{ fontSize: '0.6875rem', fontWeight: 600, lineHeight: 1.25, color: TONE_COLOR[w.tone] }}>
|
||||||
|
{w.label}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
</ButtonBase>
|
</ButtonBase>
|
||||||
))}
|
)
|
||||||
|
})}
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
@@ -108,12 +138,64 @@ export function AgentMeetingRoom() {
|
|||||||
<Box
|
<Box
|
||||||
component="section"
|
component="section"
|
||||||
aria-label="Agenten-Meetingraum"
|
aria-label="Agenten-Meetingraum"
|
||||||
sx={{ ...height, position: 'relative', overflow: 'hidden' }}
|
// Container-Kontext: die Schilder bemessen ihre Höchstbreite an der
|
||||||
|
// Szene, nicht am Fenster (siehe AgentSeat).
|
||||||
|
sx={{ ...height, position: 'relative', overflow: 'hidden', containerType: 'inline-size' }}
|
||||||
>
|
>
|
||||||
<MeetingRoomScene />
|
<MeetingRoomScene />
|
||||||
|
|
||||||
|
<SceneHeadline urgentCount={urgentCount} attentionCount={attentionCount} />
|
||||||
|
|
||||||
{MEETING_ROOM_SEATS.map(seat => (
|
{MEETING_ROOM_SEATS.map(seat => (
|
||||||
<AgentSeat key={seat.id} seat={seat} onOpen={handleOpen} />
|
<AgentSeat key={seat.id} seat={seat} workload={workload[seat.id]} onOpen={handleOpen} />
|
||||||
))}
|
))}
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 (
|
||||||
|
<Box sx={{ position: 'absolute', top: { xs: 20, lg: 32 }, left: { xs: 20, lg: 40 }, maxWidth: 520, pointerEvents: 'none' }}>
|
||||||
|
<Typography
|
||||||
|
sx={{
|
||||||
|
fontSize: '0.6875rem',
|
||||||
|
fontWeight: 700,
|
||||||
|
letterSpacing: 1.4,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
color: DS_NEUTRAL.gold,
|
||||||
|
textShadow: '0 1px 6px rgba(8,14,24,0.6)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Ihre digitale Belegschaft
|
||||||
|
</Typography>
|
||||||
|
<Typography
|
||||||
|
component="h2"
|
||||||
|
sx={{
|
||||||
|
mt: 0.5,
|
||||||
|
fontWeight: 700,
|
||||||
|
fontSize: { xs: '1.3rem', lg: '1.7rem' },
|
||||||
|
lineHeight: 1.2,
|
||||||
|
color: DS_TEXT.inverted,
|
||||||
|
textShadow: '0 2px 14px rgba(8,14,24,0.7)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{summary}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,37 +2,51 @@ import { memo } from 'react'
|
|||||||
import { ButtonBase, Box, Typography } from '@mui/material'
|
import { ButtonBase, Box, Typography } from '@mui/material'
|
||||||
import { AgentAvatar } from '../team'
|
import { AgentAvatar } from '../team'
|
||||||
import type { MeetingRoomSeat } from './meetingRoomSeats'
|
import type { MeetingRoomSeat } from './meetingRoomSeats'
|
||||||
import { SEAT_BASE_HEAD_PX, SeatSide } from './meetingRoomSeats'
|
import { SEAT_BASE_HEAD_PX } from './meetingRoomSeats'
|
||||||
import { DS_MEETING_ROOM, DS_TEXT } from '../../lib/ds'
|
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 TONE_COLOR: Record<WorkloadTone, string> = {
|
||||||
const T = DS_MEETING_ROOM.table
|
[WorkloadTone.CALM]: DS_ACCENT.success.main,
|
||||||
|
[WorkloadTone.ATTENTION]: DS_ACCENT.warning.main,
|
||||||
|
[WorkloadTone.URGENT]: DS_ACCENT.danger.main,
|
||||||
|
}
|
||||||
|
|
||||||
export interface AgentSeatProps {
|
export interface AgentSeatProps {
|
||||||
seat: MeetingRoomSeat
|
seat: MeetingRoomSeat
|
||||||
|
/** Aktuelle Auslastung; fehlt sie, zeigt die Plakette nur Name und Funktion. */
|
||||||
|
workload?: AgentWorkload
|
||||||
onOpen: (path: string) => void
|
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
|
* Porträt, Namensschild und Auslastung sind eine einzige Schaltfläche — nicht
|
||||||
* Schalter — nicht nur der Kopf. Ein 60-Pixel-Porträt als einzige Trefferfläche
|
* nur der Kopf. Ein 60-Pixel-Porträt als einzige Trefferfläche wäre auf jedem
|
||||||
* wäre auf jedem Laptop eine Zumutung; die Plakette darüber trägt ohnehin den
|
* Laptop eine Zumutung, und das Schild darunter zeigt ohnehin dasselbe Ziel.
|
||||||
* Namen und gehört zum selben Ziel.
|
|
||||||
*
|
*
|
||||||
* `ButtonBase` statt `<div onClick>`: damit sind Tastaturbedienung, Enter und
|
* Die Zahl auf dem Schild ist der eigentliche Grund, warum diese Seite mehr ist
|
||||||
* Leertaste, Fokusring und Rollen-Semantik ohne eigenen Code korrekt.
|
* 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, onOpen }: AgentSeatProps) {
|
export const AgentSeat = memo(function AgentSeat({ seat, workload, onOpen }: AgentSeatProps) {
|
||||||
const head = Math.round(SEAT_BASE_HEAD_PX * seat.scale)
|
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 (
|
return (
|
||||||
<ButtonBase
|
<ButtonBase
|
||||||
focusRipple
|
focusRipple
|
||||||
onClick={() => onOpen(seat.path)}
|
onClick={() => 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={{
|
sx={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
left: `${seat.x}%`,
|
left: `${seat.x}%`,
|
||||||
@@ -42,51 +56,74 @@ export const AgentSeat = memo(function AgentSeat({ seat, onOpen }: AgentSeatProp
|
|||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
// Grosszügige, aber nicht überlappende Klickfläche rund um den Kopf.
|
gap: 0.75,
|
||||||
px: 1.25,
|
px: 0.5,
|
||||||
py: 1,
|
py: 1,
|
||||||
borderRadius: 2,
|
borderRadius: 3,
|
||||||
transition: 'transform 0.15s ease, background-color 0.15s ease',
|
transition: 'transform 0.18s ease',
|
||||||
'&:hover, &.Mui-focusVisible': {
|
'&:hover, &.Mui-focusVisible': { transform: 'translate(-50%, calc(-50% - 5px))' },
|
||||||
bgcolor: 'rgba(255,255,255,0.16)',
|
'&:hover .seat-plate, &.Mui-focusVisible .seat-plate': {
|
||||||
transform: 'translate(-50%, -52%)',
|
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 },
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/*
|
{/* Kontaktschatten unter dem Porträt — ohne ihn schwebt der Kopf über dem Bild. */}
|
||||||
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).
|
|
||||||
*/}
|
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
bottom: '100%',
|
top: `calc(8px + ${head}px - ${Math.round(head * 0.14)}px)`,
|
||||||
left: `calc(50% + ${seat.labelDx}%)`,
|
left: '50%',
|
||||||
transform: 'translateX(-50%)',
|
transform: 'translateX(-50%)',
|
||||||
zIndex: 1,
|
width: Math.round(head * 1.05),
|
||||||
// Ohne `max-content` bemisst der Browser die Plakette an der schmalen
|
height: Math.round(head * 0.28),
|
||||||
// Klickfläche und bricht jeden Funktionsnamen um; `maxWidth` allein
|
borderRadius: '50%',
|
||||||
// schafft keinen Platz, es begrenzt nur.
|
background: 'radial-gradient(closest-side, rgba(8,14,24,0.55), rgba(8,14,24,0))',
|
||||||
width: 'max-content',
|
|
||||||
maxWidth: 190,
|
|
||||||
px: 1,
|
|
||||||
py: 0.375,
|
|
||||||
borderRadius: 1.5,
|
|
||||||
bgcolor: L.bg,
|
|
||||||
border: '1px solid',
|
|
||||||
borderColor: L.border,
|
|
||||||
boxShadow: '0 2px 8px rgba(15,25,35,0.18)',
|
|
||||||
pointerEvents: 'none',
|
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',
|
textAlign: 'center',
|
||||||
|
transition: 'box-shadow 0.18s ease',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Typography
|
<Typography
|
||||||
sx={{
|
sx={{
|
||||||
fontWeight: 700,
|
fontWeight: 700,
|
||||||
fontSize: seat.scale < 0.9 ? '0.75rem' : '0.8125rem',
|
fontSize: seat.scale < 0.9 ? '0.78rem' : '0.85rem',
|
||||||
lineHeight: 1.2,
|
lineHeight: 1.2,
|
||||||
color: DS_TEXT.primary,
|
color: DS_TEXT.primary,
|
||||||
whiteSpace: 'nowrap',
|
whiteSpace: 'nowrap',
|
||||||
@@ -96,41 +133,44 @@ export const AgentSeat = memo(function AgentSeat({ seat, onOpen }: AgentSeatProp
|
|||||||
</Typography>
|
</Typography>
|
||||||
<Typography
|
<Typography
|
||||||
sx={{
|
sx={{
|
||||||
fontSize: seat.scale < 0.9 ? '0.65rem' : '0.6875rem',
|
fontSize: '0.6875rem',
|
||||||
lineHeight: 1.25,
|
lineHeight: 1.25,
|
||||||
color: DS_TEXT.secondary,
|
color: DS_TEXT.secondary,
|
||||||
// Kurze Funktionen bleiben einzeilig; nur «Marktchancen / Leads»
|
overflow: 'hidden',
|
||||||
// bricht um — abgeschnitten wäre schlechter als zweizeilig.
|
textOverflow: 'ellipsis',
|
||||||
hyphens: 'auto',
|
whiteSpace: 'nowrap',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{seat.role}
|
{seat.role}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Stuhllehne hinter dem Kopf — am Kopfende sitzt Giorgio frontal. */}
|
{workload && (
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
position: 'absolute',
|
mt: 0.5,
|
||||||
top: `${Math.round(head * 0.52)}px`,
|
pt: 0.5,
|
||||||
left: '50%',
|
borderTop: '1px solid rgba(15,23,42,0.08)',
|
||||||
transform: 'translateX(-50%)',
|
display: 'flex',
|
||||||
width: Math.round(head * (isHead ? 1.5 : 1.35)),
|
alignItems: 'center',
|
||||||
height: Math.round(head * 0.8),
|
justifyContent: 'center',
|
||||||
borderRadius: `${Math.round(head * 0.22)}px ${Math.round(head * 0.22)}px 0 0`,
|
gap: 0.625,
|
||||||
bgcolor: T.chair,
|
|
||||||
borderTop: `2px solid ${T.chairRim}`,
|
|
||||||
zIndex: -1,
|
|
||||||
}}
|
}}
|
||||||
/>
|
>
|
||||||
|
<Box sx={{ width: 6, height: 6, borderRadius: '50%', bgcolor: toneColor, flexShrink: 0 }} />
|
||||||
<AgentAvatar
|
<Typography
|
||||||
agent={{ id: seat.id, name: seat.name, role: seat.role }}
|
sx={{
|
||||||
size={head}
|
fontSize: '0.6875rem',
|
||||||
loading="eager"
|
fontWeight: 600,
|
||||||
ringColor={L.ring}
|
lineHeight: 1.25,
|
||||||
ringWidth={2}
|
color: toneColor,
|
||||||
/>
|
textAlign: 'left',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{workload.label}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
</ButtonBase>
|
</ButtonBase>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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 (
|
|
||||||
<svg
|
|
||||||
viewBox="0 0 400 240"
|
|
||||||
preserveAspectRatio="xMidYMid slice"
|
|
||||||
width="100%"
|
|
||||||
height="100%"
|
|
||||||
aria-hidden="true"
|
|
||||||
focusable="false"
|
|
||||||
style={{ display: 'block' }}
|
|
||||||
>
|
|
||||||
<defs>
|
|
||||||
<linearGradient id="mr-sky-front" x1="0" y1="0" x2="0" y2="1">
|
|
||||||
<stop offset="0" stopColor={V.skyTop} />
|
|
||||||
<stop offset="1" stopColor={V.skyHorizon} />
|
|
||||||
</linearGradient>
|
|
||||||
<linearGradient id="mr-river-front" x1="0" y1="0" x2="0" y2="1">
|
|
||||||
<stop offset="0" stopColor={V.riverTop} />
|
|
||||||
<stop offset="1" stopColor={V.riverBottom} />
|
|
||||||
</linearGradient>
|
|
||||||
</defs>
|
|
||||||
|
|
||||||
{/* Himmel und ferne Jurahügel */}
|
|
||||||
<rect width="400" height="240" fill="url(#mr-sky-front)" />
|
|
||||||
<path d="M0 128 60 116 120 124 190 110 260 122 330 112 400 122v18H0z" fill={V.hills} opacity=".55" />
|
|
||||||
|
|
||||||
{/* Altstadtsilhouette am gegenüberliegenden Ufer */}
|
|
||||||
<g>
|
|
||||||
<rect x="0" y="126" width="46" height="34" fill={V.facadeMid} />
|
|
||||||
<path d="M0 126h46l-4-9H4z" fill={V.roof} />
|
|
||||||
<rect x="46" y="120" width="38" height="40" fill={V.facadeLight} />
|
|
||||||
<path d="M46 120h38l-5-10H51z" fill={V.roofDark} />
|
|
||||||
<rect x="84" y="130" width="34" height="30" fill={V.facadeDark} />
|
|
||||||
<path d="M84 130h34l-4-8H88z" fill={V.roof} />
|
|
||||||
|
|
||||||
{/* Basler Münster — zwei Türme, rotes Sandsteinmassiv, grünes Dach */}
|
|
||||||
<g>
|
|
||||||
<rect x="150" y="112" width="54" height="48" fill={V.minsterWall} />
|
|
||||||
<path d="M150 112h54l-6-10h-42z" fill={V.minsterRoof} />
|
|
||||||
<rect x="140" y="76" width="17" height="84" fill={V.minsterWall} />
|
|
||||||
<rect x="197" y="76" width="17" height="84" fill={V.minsterWall} />
|
|
||||||
<path d="M140 76h17l-8.5-32z" fill={V.spire} />
|
|
||||||
<path d="M197 76h17l-8.5-32z" fill={V.spire} />
|
|
||||||
<rect x="146" y="88" width="5" height="12" fill={V.window} opacity=".8" />
|
|
||||||
<rect x="203" y="88" width="5" height="12" fill={V.window} opacity=".8" />
|
|
||||||
<circle cx="177" cy="130" r="7" fill={V.window} opacity=".7" />
|
|
||||||
</g>
|
|
||||||
|
|
||||||
<rect x="214" y="124" width="42" height="36" fill={V.facadeLight} />
|
|
||||||
<path d="M214 124h42l-5-9h-32z" fill={V.roofDark} />
|
|
||||||
<rect x="256" y="118" width="48" height="42" fill={V.facadeMid} />
|
|
||||||
<path d="M256 118h48l-6-11h-36z" fill={V.roof} />
|
|
||||||
<rect x="304" y="128" width="44" height="32" fill={V.facadeDark} />
|
|
||||||
<path d="M304 128h44l-5-9h-34z" fill={V.roofDark} />
|
|
||||||
<rect x="348" y="122" width="52" height="38" fill={V.facadeLight} />
|
|
||||||
<path d="M348 122h52l-6-10h-40z" fill={V.roof} />
|
|
||||||
</g>
|
|
||||||
|
|
||||||
{/* Fensterreihen der Altstadtfassaden */}
|
|
||||||
<g fill={V.window} opacity=".45">
|
|
||||||
{[10, 24, 56, 70, 92, 106, 222, 236, 266, 282, 314, 330, 358, 374].map(x => (
|
|
||||||
<rect key={x} x={x} y="136" width="6" height="9" />
|
|
||||||
))}
|
|
||||||
</g>
|
|
||||||
|
|
||||||
{/* Uferpromenade mit Baumreihe */}
|
|
||||||
<rect x="0" y="158" width="400" height="12" fill={V.quay} />
|
|
||||||
<g>
|
|
||||||
{[18, 62, 108, 232, 276, 322, 366].map(x => (
|
|
||||||
<g key={x}>
|
|
||||||
<rect x={x - 1} y="150" width="3" height="12" fill={V.treeDark} />
|
|
||||||
<ellipse cx={x} cy="146" rx="15" ry="12" fill={V.treeLight} />
|
|
||||||
<ellipse cx={x - 6} cy="150" rx="10" ry="8" fill={V.treeDark} opacity=".7" />
|
|
||||||
</g>
|
|
||||||
))}
|
|
||||||
</g>
|
|
||||||
|
|
||||||
{/* Rhein */}
|
|
||||||
<rect x="0" y="170" width="400" height="70" fill="url(#mr-river-front)" />
|
|
||||||
<g stroke={V.riverGlint} strokeWidth="1.6" strokeLinecap="round" fill="none" opacity=".7">
|
|
||||||
<path d="M18 184h44M96 192h58M198 182h40M268 196h56M60 210h70M210 216h84M330 206h48" />
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
@@ -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 (
|
|
||||||
<svg
|
|
||||||
viewBox="0 0 240 400"
|
|
||||||
preserveAspectRatio="xMidYMid slice"
|
|
||||||
width="100%"
|
|
||||||
height="100%"
|
|
||||||
aria-hidden="true"
|
|
||||||
focusable="false"
|
|
||||||
style={{ display: 'block' }}
|
|
||||||
>
|
|
||||||
<defs>
|
|
||||||
<linearGradient id="mr-sky-left" x1="0" y1="0" x2="0" y2="1">
|
|
||||||
<stop offset="0" stopColor={V.skyTop} />
|
|
||||||
<stop offset="1" stopColor={V.skyHorizon} />
|
|
||||||
</linearGradient>
|
|
||||||
<linearGradient id="mr-river-left" x1="0" y1="0" x2="0" y2="1">
|
|
||||||
<stop offset="0" stopColor={V.riverTop} />
|
|
||||||
<stop offset="1" stopColor={V.riverBottom} />
|
|
||||||
</linearGradient>
|
|
||||||
</defs>
|
|
||||||
|
|
||||||
<rect width="240" height="400" fill="url(#mr-sky-left)" />
|
|
||||||
<path d="M0 150 50 142 110 148 170 138 240 146v20H0z" fill={V.hills} opacity=".5" />
|
|
||||||
|
|
||||||
{/* Wettsteinbrücke — drei flache Bögen über dem Rhein */}
|
|
||||||
<g>
|
|
||||||
<rect x="0" y="176" width="240" height="9" fill={V.bridge} />
|
|
||||||
<rect x="0" y="185" width="240" height="4" fill={V.bridgeDark} />
|
|
||||||
<path d="M14 189q26 22 52 0z" fill={V.bridgeDark} opacity=".35" />
|
|
||||||
<path d="M94 189q26 22 52 0z" fill={V.bridgeDark} opacity=".35" />
|
|
||||||
<path d="M174 189q26 22 52 0z" fill={V.bridgeDark} opacity=".35" />
|
|
||||||
<rect x="62" y="185" width="12" height="26" fill={V.bridgeDark} />
|
|
||||||
<rect x="146" y="185" width="12" height="26" fill={V.bridgeDark} />
|
|
||||||
<g fill={V.bridge}>
|
|
||||||
{[10, 42, 74, 106, 138, 170, 202, 234].map(x => (
|
|
||||||
<rect key={x} x={x} y="168" width="3" height="8" />
|
|
||||||
))}
|
|
||||||
</g>
|
|
||||||
</g>
|
|
||||||
|
|
||||||
{/* Altstadtzeile am rechten Ufer, hinter der Brücke */}
|
|
||||||
<g>
|
|
||||||
<rect x="0" y="150" width="40" height="26" fill={V.facadeMid} />
|
|
||||||
<path d="M0 150h40l-4-8H4z" fill={V.roof} />
|
|
||||||
<rect x="40" y="146" width="36" height="30" fill={V.facadeLight} />
|
|
||||||
<path d="M40 146h36l-5-9H45z" fill={V.roofDark} />
|
|
||||||
<rect x="76" y="152" width="32" height="24" fill={V.facadeDark} />
|
|
||||||
<path d="M76 152h32l-4-8H80z" fill={V.roof} />
|
|
||||||
<rect x="108" y="144" width="40" height="32" fill={V.facadeLight} />
|
|
||||||
<path d="M108 144h40l-5-9h-30z" fill={V.roofDark} />
|
|
||||||
<rect x="148" y="150" width="38" height="26" fill={V.facadeMid} />
|
|
||||||
<path d="M148 150h38l-4-8h-30z" fill={V.roof} />
|
|
||||||
<rect x="186" y="146" width="54" height="30" fill={V.facadeDark} />
|
|
||||||
<path d="M186 146h54l-6-9h-42z" fill={V.roofDark} />
|
|
||||||
</g>
|
|
||||||
<g fill={V.window} opacity=".4">
|
|
||||||
{[8, 20, 48, 60, 84, 116, 128, 156, 168, 196, 214].map(x => (
|
|
||||||
<rect key={x} x={x} y="160" width="5" height="8" />
|
|
||||||
))}
|
|
||||||
</g>
|
|
||||||
|
|
||||||
{/* Uferkante mit Bäumen */}
|
|
||||||
<rect x="0" y="211" width="240" height="10" fill={V.quay} />
|
|
||||||
<g>
|
|
||||||
{[20, 56, 100, 134, 178, 216].map(x => (
|
|
||||||
<g key={x}>
|
|
||||||
<rect x={x - 1} y="203" width="3" height="10" fill={V.treeDark} />
|
|
||||||
<ellipse cx={x} cy="200" rx="13" ry="10" fill={V.treeLight} />
|
|
||||||
<ellipse cx={x - 5} cy="203" rx="9" ry="7" fill={V.treeDark} opacity=".65" />
|
|
||||||
</g>
|
|
||||||
))}
|
|
||||||
</g>
|
|
||||||
|
|
||||||
{/* Rheinverlauf bis in den Vordergrund */}
|
|
||||||
<rect x="0" y="221" width="240" height="179" fill="url(#mr-river-left)" />
|
|
||||||
<g stroke={V.riverGlint} strokeWidth="1.6" strokeLinecap="round" fill="none" opacity=".65">
|
|
||||||
<path d="M12 240h48M96 252h62M20 278h74M128 288h84M40 316h96M10 350h70M120 364h96M60 386h110" />
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
@@ -1,188 +1,55 @@
|
|||||||
import { memo } from 'react'
|
import { memo } from 'react'
|
||||||
import { Box } from '@mui/material'
|
import { Box, Typography } from '@mui/material'
|
||||||
import { BaselViewFront } from './BaselViewFront'
|
import { ROOM_BACKDROP } from './roomBackdrop'
|
||||||
import { BaselViewLeft } from './BaselViewLeft'
|
import { DS_NEUTRAL, DS_TEXT } from '../../lib/ds'
|
||||||
import { DS_MEETING_ROOM } from '../../lib/ds'
|
|
||||||
|
|
||||||
const R = DS_MEETING_ROOM.room
|
|
||||||
const T = DS_MEETING_ROOM.table
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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
|
* Bewusst eine einzige Bildfläche mit Verlauf — und nicht länger ein aus
|
||||||
* sitzt am Kopfende, der Tisch läuft von ihm weg zur gegenüberliegenden
|
* Flächen konstruierter Raum. Ein selbstgezeichneter Raum bleibt neben
|
||||||
* Fensterfront. Rechts eine geschlossene Wand, links und gegenüber
|
* fotografierten Porträts immer erkennbar gezeichnet; die Bildwirkung muss
|
||||||
* boden- bis deckenhohe Glasfronten (§3.5).
|
* deshalb aus dem Foto kommen, nicht aus Geometrie.
|
||||||
*
|
*
|
||||||
* Umgesetzt als Flächen mit `clip-path`, nicht mit CSS-3D-Transformationen:
|
* Die Komponente selbst weiss nichts über das Motiv. Sie zeigt, was
|
||||||
* Trapeze in Prozentkoordinaten skalieren mit jeder Rahmengrösse mit, während
|
* `ROOM_BACKDROP` liefert — die Aussicht heute, eine Raumaufnahme später.
|
||||||
* `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.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** 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() {
|
export const MeetingRoomScene = memo(function MeetingRoomScene() {
|
||||||
return (
|
return (
|
||||||
<Box sx={{ position: 'absolute', inset: 0, overflow: 'hidden', bgcolor: R.wall }}>
|
<Box sx={{ position: 'absolute', inset: 0, overflow: 'hidden', bgcolor: DS_NEUTRAL.sidebar }}>
|
||||||
{/* Decke */}
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
position: 'absolute', inset: 0,
|
|
||||||
background: `linear-gradient(180deg, ${R.ceiling} 0%, ${R.ceilingShadow} 100%)`,
|
|
||||||
clipPath: `polygon(0% 0%, 100% 0%, ${WALL_RIGHT}% ${HORIZON_TOP}%, ${WALL_LEFT}% ${HORIZON_TOP}%)`,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Boden */}
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
position: 'absolute', inset: 0,
|
|
||||||
background: `linear-gradient(180deg, ${R.floorFar} 0%, ${R.floorNear} 100%)`,
|
|
||||||
clipPath: `polygon(0% 100%, ${WALL_LEFT}% ${HORIZON_BOTTOM}%, ${WALL_RIGHT}% ${HORIZON_BOTTOM}%, 100% 100%)`,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* ── Rechte Seite: geschlossene Wand ── */}
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
position: 'absolute', inset: 0,
|
|
||||||
background: `linear-gradient(100deg, ${R.wallShade} 0%, ${R.wallAccent} 60%, ${R.wall} 100%)`,
|
|
||||||
clipPath: `polygon(100% 0%, 100% 100%, ${WALL_RIGHT}% ${HORIZON_BOTTOM}%, ${WALL_RIGHT}% ${HORIZON_TOP}%)`,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{/* Sockelleiste — trennt Wand und Boden sichtbar voneinander */}
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
position: 'absolute', inset: 0, bgcolor: R.skirting,
|
|
||||||
clipPath: `polygon(100% 100%, 100% 95%, ${WALL_RIGHT}% ${HORIZON_BOTTOM - 1.6}%, ${WALL_RIGHT}% ${HORIZON_BOTTOM}%)`,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* ── Linke Seite: boden- bis deckenhohe Glasfront, Blick rheinabwärts ── */}
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
position: 'absolute', inset: 0,
|
|
||||||
clipPath: `polygon(0% 0%, ${WALL_LEFT}% ${HORIZON_TOP}%, ${WALL_LEFT}% ${HORIZON_BOTTOM}%, 0% 100%)`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/*
|
|
||||||
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.
|
|
||||||
*/}
|
|
||||||
<Box sx={{ position: 'absolute', left: 0, top: 0, bottom: 0, width: `${WALL_LEFT + 1}%`, overflow: 'hidden' }}>
|
|
||||||
<BaselViewLeft />
|
|
||||||
</Box>
|
|
||||||
<Box sx={{ position: 'absolute', inset: 0, bgcolor: R.glassTint }} />
|
|
||||||
|
|
||||||
{/* Senkrechte Pfosten */}
|
|
||||||
{[9, 17.5, 25.5].map(x => (
|
|
||||||
<Box
|
|
||||||
key={x}
|
|
||||||
sx={{ position: 'absolute', left: `${x}%`, top: 0, bottom: 0, width: 5, bgcolor: R.mullion, opacity: 0.9 }}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
{/* Waagrechter Kämpfer — läuft mit der Perspektive zum Fluchtpunkt */}
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
position: 'absolute', inset: 0, bgcolor: R.frame,
|
|
||||||
clipPath: `polygon(0% 33%, ${WALL_LEFT}% ${HORIZON_TOP + 8}%, ${WALL_LEFT}% ${HORIZON_TOP + 9.2}%, 0% 34.6%)`,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{/* Deckenanschluss und Bodenschiene der Front */}
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
position: 'absolute', inset: 0, bgcolor: R.frame,
|
|
||||||
clipPath: `polygon(0% 0%, ${WALL_LEFT}% ${HORIZON_TOP}%, ${WALL_LEFT}% ${HORIZON_TOP + 1.4}%, 0% 2.4%)`,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
position: 'absolute', inset: 0, bgcolor: R.frame,
|
|
||||||
clipPath: `polygon(0% 97.6%, ${WALL_LEFT}% ${HORIZON_BOTTOM - 1.4}%, ${WALL_LEFT}% ${HORIZON_BOTTOM}%, 0% 100%)`,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{/* Streiflicht auf dem Glas */}
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
position: 'absolute', inset: 0, opacity: 0.4,
|
|
||||||
background: `linear-gradient(100deg, transparent 0%, ${R.glassGlare} 22%, transparent 45%)`,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* ── Gegenüberliegende Fensterfront: Rhein, Altstadt, Münster ── */}
|
|
||||||
<Box
|
<Box
|
||||||
|
component="img"
|
||||||
|
src={ROOM_BACKDROP.src}
|
||||||
|
alt=""
|
||||||
|
aria-hidden="true"
|
||||||
sx={{
|
sx={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
left: `${WALL_LEFT}%`,
|
inset: 0,
|
||||||
right: `${100 - WALL_RIGHT}%`,
|
width: '100%',
|
||||||
top: `${HORIZON_TOP}%`,
|
height: '100%',
|
||||||
bottom: `${100 - HORIZON_BOTTOM}%`,
|
objectFit: 'cover',
|
||||||
overflow: 'hidden',
|
objectPosition: ROOM_BACKDROP.focus,
|
||||||
border: '5px solid',
|
}}
|
||||||
borderColor: R.frame,
|
/>
|
||||||
boxSizing: 'border-box',
|
|
||||||
|
<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',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<BaselViewFront />
|
{ROOM_BACKDROP.credit}
|
||||||
<Box sx={{ position: 'absolute', inset: 0, bgcolor: R.glassTint }} />
|
</Typography>
|
||||||
{/* Zwei Pfosten teilen die Front in drei Felder, dazu ein Kämpfer */}
|
)}
|
||||||
{[33.3, 66.6].map(x => (
|
|
||||||
<Box key={x} sx={{ position: 'absolute', left: `${x}%`, top: 0, bottom: 0, width: 5, bgcolor: R.mullion }} />
|
|
||||||
))}
|
|
||||||
<Box sx={{ position: 'absolute', left: 0, right: 0, top: '22%', height: 4, bgcolor: R.mullion, opacity: 0.75 }} />
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
position: 'absolute', inset: 0, opacity: 0.45,
|
|
||||||
background: `linear-gradient(115deg, ${R.glassGlare} 0%, transparent 34%, transparent 66%, ${R.glassGlare} 100%)`,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* ── Besprechungstisch: vom Betrachter zur Fensterfront verlaufend ── */}
|
|
||||||
{/* Schlagschatten auf dem Boden — ohne ihn schwebt die Platte über nichts */}
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
position: 'absolute', inset: 0, bgcolor: T.shadow, filter: 'blur(10px)',
|
|
||||||
clipPath: 'polygon(38% 70%, 62% 70%, 90% 104%, 10% 104%)',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
position: 'absolute', inset: 0,
|
|
||||||
background: `linear-gradient(180deg, ${T.topFar} 60%, ${T.topNear} 100%)`,
|
|
||||||
clipPath: 'polygon(40% 68.5%, 60% 68.5%, 86% 104%, 14% 104%)',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{/* Hintere Tischkante — dünne dunkle Linie, gibt der Platte Dicke */}
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
position: 'absolute', inset: 0, bgcolor: T.edge,
|
|
||||||
clipPath: 'polygon(40% 68.5%, 60% 68.5%, 60.4% 69.6%, 39.6% 69.6%)',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{/* Heller Mittelstreifen — führt den Blick zur Fensterfront */}
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
position: 'absolute', inset: 0, bgcolor: T.inlay, opacity: 0.14,
|
|
||||||
clipPath: 'polygon(47% 69.6%, 53% 69.6%, 58% 104%, 42% 104%)',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -44,6 +44,12 @@ export interface MeetingRoomSeat {
|
|||||||
* aussen auf (§3.7 «Labels bei Überlappung intelligent versetzen»).
|
* aussen auf (§3.7 «Labels bei Überlappung intelligent versetzen»).
|
||||||
*/
|
*/
|
||||||
labelDx: number
|
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. */
|
/** Stapelfolge: vorne sitzende Agenten verdecken weiter hinten sitzende. */
|
||||||
zIndex: number
|
zIndex: number
|
||||||
}
|
}
|
||||||
@@ -61,11 +67,11 @@ type SeatPlacement = Omit<MeetingRoomSeat, 'id' | 'name' | 'role' | 'path'>
|
|||||||
* Tischseite wirkt leer.
|
* Tischseite wirkt leer.
|
||||||
*/
|
*/
|
||||||
const AGENT_PLACEMENTS: Record<string, SeatPlacement> = {
|
const AGENT_PLACEMENTS: Record<string, SeatPlacement> = {
|
||||||
ferdi: { side: SeatSide.LEFT, x: 31, y: 65, scale: 0.86, labelDx: -3, zIndex: 20 },
|
ferdi: { side: SeatSide.LEFT, x: 11.0, y: 70, scale: 1.00, labelDx: 0, labelDy: 0, zIndex: 20 },
|
||||||
bruno: { side: SeatSide.RIGHT, x: 69, y: 65, scale: 0.86, labelDx: 3, 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: 22, y: 76, scale: 1.00, labelDx: -2, zIndex: 30 },
|
livia: { side: SeatSide.LEFT, x: 42.2, y: 70, scale: 1.00, labelDx: 0, labelDy: 0, zIndex: 20 },
|
||||||
nora: { side: SeatSide.RIGHT, x: 78, y: 76, scale: 1.00, labelDx: 2, zIndex: 30 },
|
nora: { side: SeatSide.RIGHT, x: 57.8, y: 70, scale: 1.00, labelDx: 0, labelDy: 0, zIndex: 20 },
|
||||||
sina: { side: SeatSide.LEFT, x: 11, y: 88, scale: 1.14, labelDx: 1, zIndex: 40 },
|
sina: { side: SeatSide.RIGHT, x: 73.4, y: 70, scale: 1.00, labelDx: 0, labelDy: 0, zIndex: 20 },
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -74,7 +80,7 @@ const AGENT_PLACEMENTS: Record<string, SeatPlacement> = {
|
|||||||
* Er landet dann sichtbar vorne rechts statt lautlos im Nichts.
|
* Er landet dann sichtbar vorne rechts statt lautlos im Nichts.
|
||||||
*/
|
*/
|
||||||
const FALLBACK_PLACEMENT: SeatPlacement = {
|
const FALLBACK_PLACEMENT: SeatPlacement = {
|
||||||
side: SeatSide.RIGHT, x: 89, y: 88, scale: 1.14, labelDx: -1, zIndex: 40,
|
side: SeatSide.RIGHT, x: 89.0, y: 70, scale: 1.00, labelDx: 0, labelDy: 0, zIndex: 20,
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -83,7 +89,7 @@ const FALLBACK_PLACEMENT: SeatPlacement = {
|
|||||||
* Arbeitsseite führt, sondern in die Personalverwaltung.
|
* Arbeitsseite führt, sondern in die Personalverwaltung.
|
||||||
*/
|
*/
|
||||||
const SUPERVISOR_PLACEMENT: SeatPlacement = {
|
const SUPERVISOR_PLACEMENT: SeatPlacement = {
|
||||||
side: SeatSide.HEAD, x: 50, y: 56, scale: 0.80, labelDx: 0, zIndex: 15,
|
side: SeatSide.HEAD, x: 89.0, y: 70, scale: 1.08, labelDx: 0, labelDy: 0, zIndex: 25,
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MEETING_ROOM_SEATS: MeetingRoomSeat[] = [
|
export const MEETING_ROOM_SEATS: MeetingRoomSeat[] = [
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import baselWindow from '../../assets/meeting-room/basel-rhein-muenster.jpg'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hintergrund des Agenten-Meetingraums (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.
|
||||||
|
*
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
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.
|
||||||
|
*/
|
||||||
|
scrim: string
|
||||||
|
/** Kurze Herkunftsangabe, unten rechts klein eingeblendet. */
|
||||||
|
credit?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ROOM_BACKDROP: RoomBackdrop = {
|
||||||
|
src: baselWindow,
|
||||||
|
focus: '50% 42%',
|
||||||
|
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%)',
|
||||||
|
].join(', '),
|
||||||
|
credit: 'Basel · Rhein und Münster',
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
/**
|
||||||
|
* Property On — aktuelle Auslastung eines digitalen Mitarbeitenden.
|
||||||
|
*
|
||||||
|
* Der Meetingraum auf der Startseite zeigt nicht nur, *wer* für einen
|
||||||
|
* arbeitet, sondern *woran gerade*. Ohne diese Zahl wäre die Szene Dekoration;
|
||||||
|
* mit ihr ist sie eine Lageübersicht — genau der Anspruch aus CLAUDE.md §1:
|
||||||
|
* jeder Bildschirm ist um eine Entscheidung herum gebaut.
|
||||||
|
*
|
||||||
|
* Die Werte sind abgeleitet, nie gespeichert (CLAUDE.md §5.5): sie entstehen
|
||||||
|
* aus den bestehenden Beständen an Fristen, Terminen, Leads und Objekten.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Dringlichkeit — steuert allein die Farbe, nie die Aussage. */
|
||||||
|
export const WorkloadTone = {
|
||||||
|
/** Nichts Auffälliges. */
|
||||||
|
CALM: 'CALM',
|
||||||
|
/** Sollte diese Woche angeschaut werden. */
|
||||||
|
ATTENTION: 'ATTENTION',
|
||||||
|
/** Überfällig oder kritisch. */
|
||||||
|
URGENT: 'URGENT',
|
||||||
|
} as const
|
||||||
|
export type WorkloadTone = typeof WorkloadTone[keyof typeof WorkloadTone]
|
||||||
|
|
||||||
|
export interface AgentWorkload {
|
||||||
|
/** Agenten-ID, identisch mit `AGENT_PHOTOS` und der Sitzordnung. */
|
||||||
|
agentId: string
|
||||||
|
/** Die Zahl selbst — für Sortierung und Tests. */
|
||||||
|
count: number
|
||||||
|
/**
|
||||||
|
* Vollständiger Satz für die Plakette, z. B. «3 Fristen diese Woche».
|
||||||
|
* Bewusst inklusive Zahl: eine nackte Ziffer neben einem Kopf sagt nichts.
|
||||||
|
*/
|
||||||
|
label: string
|
||||||
|
tone: WorkloadTone
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { useMemo } from 'react'
|
||||||
|
import { useReminders } from './useReminders'
|
||||||
|
import { useVisitAssignments } from './useVisitAssignments'
|
||||||
|
import { useExposeLeads } from './useExposeLeads'
|
||||||
|
import { useUnifiedLeads } from './useUnifiedLeads'
|
||||||
|
import { useProperties } from './useProperties'
|
||||||
|
import { useAgentWorkItems } from './useAgentWorkItems'
|
||||||
|
import { computeAgentWorkload } from '../services/agentWorkloadService'
|
||||||
|
import type { AgentWorkload } from '../domain/agentWorkload'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aktuelle Auslastung aller sechs digitalen Mitarbeitenden (Runde 5).
|
||||||
|
*
|
||||||
|
* Der Hook sammelt nur die Bestände ein; was daraus wird, entscheidet
|
||||||
|
* `agentWorkloadService` — Schwellen, Zeitfenster und Formulierungen sind
|
||||||
|
* fachliche Festlegungen und gehören nicht in die Oberflächenschicht
|
||||||
|
* (CLAUDE.md §4.2/§5.2).
|
||||||
|
*/
|
||||||
|
export function useAgentWorkload(): { data: Record<string, AgentWorkload>; isLoading: boolean } {
|
||||||
|
const { data: reminders = [], isLoading: remindersLoading } = useReminders()
|
||||||
|
const { data: visits = [], isLoading: visitsLoading } = useVisitAssignments()
|
||||||
|
const { data: exposeLeads = [], isLoading: exposeLoading } = useExposeLeads()
|
||||||
|
const { data: leads, isLoading: leadsLoading } = useUnifiedLeads()
|
||||||
|
const { data: properties = [], isLoading: propertiesLoading } = useProperties()
|
||||||
|
const { data: workItems = [], isLoading: workItemsLoading } = useAgentWorkItems()
|
||||||
|
|
||||||
|
const data = useMemo(
|
||||||
|
() => computeAgentWorkload({ reminders, visits, exposeLeads, leads, properties, workItems }),
|
||||||
|
[reminders, visits, exposeLeads, leads, properties, workItems],
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
data,
|
||||||
|
isLoading:
|
||||||
|
remindersLoading || visitsLoading || exposeLoading ||
|
||||||
|
leadsLoading || propertiesLoading || workItemsLoading,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -292,79 +292,6 @@ export const RESULT_TYPE_META: Record<string, { label: string; color: string; bg
|
|||||||
FUTURE_AVAILABILITY: { label: 'Zukunftssignal', color: '#5b3f8a', bg: 'rgba(91,63,138,0.10)' },
|
FUTURE_AVAILABILITY: { label: 'Zukunftssignal', color: '#5b3f8a', bg: 'rgba(91,63,138,0.10)' },
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Agenten-Meetingraum auf der Startseite (Runde 5, §3) ─────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Farbwelt der Meetingraum-Szene.
|
|
||||||
*
|
|
||||||
* Die Szene ist eine reine 2D-/CSS-Komposition ohne 3D-Bibliothek — sie besteht
|
|
||||||
* deshalb aus ungewöhnlich vielen Einzelflächen: Decke, Wände, Boden, Tischplatte,
|
|
||||||
* Fensterrahmen sowie die Basler Aussicht mit Rhein, Altstadt und Münster.
|
|
||||||
*
|
|
||||||
* Sie stehen hier statt in der Komponente, weil eine Szene mit vierzig
|
|
||||||
* Farbstufen in der Komponente nichts anderes wäre als vierzig fest verdrahtete
|
|
||||||
* Hexwerte (CLAUDE.md §8). Bewusst eigene Gruppe: es sind Szenenfarben, keine
|
|
||||||
* semantischen Zustandsfarben — ein Dachziegelrot darf nie versehentlich als
|
|
||||||
* Fehlerfarbe verwendet werden.
|
|
||||||
*/
|
|
||||||
export const DS_MEETING_ROOM = {
|
|
||||||
/** Innenraum: Decke, Wände, Boden, Sockelleisten. */
|
|
||||||
room: {
|
|
||||||
ceiling: '#f4f2ee',
|
|
||||||
ceilingShadow: '#e6e3dd',
|
|
||||||
wall: '#eae6df',
|
|
||||||
wallShade: '#ddd8cf',
|
|
||||||
wallAccent: '#d9d3c8',
|
|
||||||
floorNear: '#b9a893',
|
|
||||||
floorFar: '#d8ccbb',
|
|
||||||
skirting: '#cfc7ba',
|
|
||||||
mullion: '#5d6773',
|
|
||||||
frame: '#7b848f',
|
|
||||||
glassTint: 'rgba(214,232,240,0.18)',
|
|
||||||
glassGlare: 'rgba(255,255,255,0.30)',
|
|
||||||
},
|
|
||||||
/** Besprechungstisch samt Stühlen. */
|
|
||||||
table: {
|
|
||||||
topNear: '#6b5a48',
|
|
||||||
topFar: '#8a7359',
|
|
||||||
edge: '#4e4133',
|
|
||||||
inlay: '#f6f3ee',
|
|
||||||
chair: '#3b4450',
|
|
||||||
chairRim: '#2c333d',
|
|
||||||
shadow: 'rgba(45,35,25,0.28)',
|
|
||||||
},
|
|
||||||
/** Basler Aussicht: Himmel, Rhein, Altstadt, Münster, Wettsteinbrücke. */
|
|
||||||
view: {
|
|
||||||
skyTop: '#a8ccdf',
|
|
||||||
skyHorizon: '#e2ecef',
|
|
||||||
hills: '#b3c2c0',
|
|
||||||
riverTop: '#7fa6ac',
|
|
||||||
riverBottom: '#4d7d86',
|
|
||||||
riverGlint: 'rgba(255,255,255,0.45)',
|
|
||||||
quay: '#c8bda9',
|
|
||||||
treeDark: '#4e6b4a',
|
|
||||||
treeLight: '#6b8a5c',
|
|
||||||
facadeLight: '#efe8dc',
|
|
||||||
facadeMid: '#ded3c2',
|
|
||||||
facadeDark: '#c9bda9',
|
|
||||||
roof: '#a2543f',
|
|
||||||
roofDark: '#8a4433',
|
|
||||||
minsterWall: '#b8654a',
|
|
||||||
minsterRoof: '#5f7f6d',
|
|
||||||
spire: '#8f4a38',
|
|
||||||
bridge: '#b9b2a6',
|
|
||||||
bridgeDark: '#9a9287',
|
|
||||||
window: '#8fa3ad',
|
|
||||||
},
|
|
||||||
/** Agentenplakette über dem Kopf. */
|
|
||||||
label: {
|
|
||||||
bg: 'rgba(255,255,255,0.94)',
|
|
||||||
border: 'rgba(21,38,66,0.14)',
|
|
||||||
ring: 'rgba(255,255,255,0.85)',
|
|
||||||
focus: '#b8975a',
|
|
||||||
},
|
|
||||||
} as const
|
|
||||||
|
|
||||||
// ── Kanalherkunft der Leads bei Nora (Runde 5, §12) ──────────────────────────
|
// ── Kanalherkunft der Leads bei Nora (Runde 5, §12) ──────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
/**
|
||||||
|
* Property On — Auslastung der digitalen Belegschaft (Runde 5).
|
||||||
|
*
|
||||||
|
* Die Zahlen auf den Namensschildern der Startseite sind das Einzige, was dort
|
||||||
|
* eine Entscheidung auslöst. Eine falsche Zahl fällt niemandem auf — sie sieht
|
||||||
|
* genauso aus wie eine richtige. Deshalb wird hier festgehalten, was die
|
||||||
|
* fachlichen Festlegungen sind: der Stichzeitpunkt ist ein Parameter, damit
|
||||||
|
* dieselbe Datenlage immer dasselbe Ergebnis liefert.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { computeAgentWorkload } from '../agentWorkloadService'
|
||||||
|
import type { AgentWorkloadInput } from '../agentWorkloadService'
|
||||||
|
import { WorkloadTone } from '../../domain/agentWorkload'
|
||||||
|
import { ReminderStatus } from '../../domain/reminder'
|
||||||
|
import { ExposeLeadStatus } from '../../domain/exposeLead'
|
||||||
|
import { AgentWorkItemStatus } from '../../domain/agentWorkItem'
|
||||||
|
|
||||||
|
const NOW = Date.parse('2026-08-06T12:00:00.000Z')
|
||||||
|
const DAY = 24 * 60 * 60 * 1000
|
||||||
|
|
||||||
|
function iso(offsetDays: number): string {
|
||||||
|
return new Date(NOW + offsetDays * DAY).toISOString()
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMPTY: AgentWorkloadInput = {
|
||||||
|
reminders: [], visits: [], exposeLeads: [], leads: [], properties: [], workItems: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
function input(partial: Partial<AgentWorkloadInput>): AgentWorkloadInput {
|
||||||
|
return { ...EMPTY, ...partial }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nur die Felder, die der Dienst liest — der Rest gehört nicht zur Aussage.
|
||||||
|
const reminder = (dueDays: number, status: ReminderStatus = ReminderStatus.ACTIVE) =>
|
||||||
|
({ dueDate: iso(dueDays), status }) as AgentWorkloadInput['reminders'][number]
|
||||||
|
const visit = (days: number) =>
|
||||||
|
({ scheduledAt: iso(days) }) as AgentWorkloadInput['visits'][number]
|
||||||
|
const property = (score: number) =>
|
||||||
|
({ dataQuality: { score } }) as AgentWorkloadInput['properties'][number]
|
||||||
|
|
||||||
|
describe('Auslastung je Agent', () => {
|
||||||
|
it('meldet bei Ferdi Überfälliges vor den Fristen der Woche', () => {
|
||||||
|
const w = computeAgentWorkload(
|
||||||
|
input({ reminders: [reminder(-3), reminder(-1), reminder(2)] }),
|
||||||
|
NOW,
|
||||||
|
).ferdi
|
||||||
|
|
||||||
|
expect(w.count).toBe(2)
|
||||||
|
expect(w.label).toBe('2 Fristen überfällig')
|
||||||
|
expect(w.tone).toBe(WorkloadTone.URGENT)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('zeigt bei Ferdi ohne Überfälliges die Fristen der laufenden Woche', () => {
|
||||||
|
const w = computeAgentWorkload(
|
||||||
|
// 2 Tage und 6 Tage zählen, 20 Tage liegt ausserhalb der Woche.
|
||||||
|
input({ reminders: [reminder(2), reminder(6), reminder(20)] }),
|
||||||
|
NOW,
|
||||||
|
).ferdi
|
||||||
|
|
||||||
|
expect(w.count).toBe(2)
|
||||||
|
expect(w.label).toBe('2 Fristen diese Woche')
|
||||||
|
expect(w.tone).toBe(WorkloadTone.ATTENTION)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('zählt bei Ferdi nur aktive Reminder', () => {
|
||||||
|
const w = computeAgentWorkload(
|
||||||
|
input({ reminders: [reminder(2), reminder(3, ReminderStatus.COMPLETED), reminder(4, ReminderStatus.DISMISSED)] }),
|
||||||
|
NOW,
|
||||||
|
).ferdi
|
||||||
|
|
||||||
|
expect(w.count).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('zählt bei Bruno nur Termine der kommenden sieben Tage', () => {
|
||||||
|
const w = computeAgentWorkload(
|
||||||
|
input({ visits: [visit(-1), visit(1), visit(6), visit(9)] }),
|
||||||
|
NOW,
|
||||||
|
).bruno
|
||||||
|
|
||||||
|
expect(w.count).toBe(2)
|
||||||
|
expect(w.label).toBe('2 Besichtigungen diese Woche')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('zählt bei Sina nur Objekte unter der Datenqualitätsschwelle', () => {
|
||||||
|
const w = computeAgentWorkload(
|
||||||
|
input({ properties: [property(0.2), property(0.55), property(0.6), property(0.9)] }),
|
||||||
|
NOW,
|
||||||
|
).sina
|
||||||
|
|
||||||
|
expect(w.count).toBe(2)
|
||||||
|
expect(w.label).toBe('2 Objekte mit Datenlücken')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('zählt bei Giorgio die offenen Freigaben', () => {
|
||||||
|
const w = computeAgentWorkload(
|
||||||
|
input({
|
||||||
|
workItems: [
|
||||||
|
{ status: AgentWorkItemStatus.PENDING },
|
||||||
|
{ status: AgentWorkItemStatus.PENDING },
|
||||||
|
{ status: AgentWorkItemStatus.APPROVED },
|
||||||
|
] as AgentWorkloadInput['workItems'],
|
||||||
|
}),
|
||||||
|
NOW,
|
||||||
|
).giorgio
|
||||||
|
|
||||||
|
expect(w.count).toBe(2)
|
||||||
|
expect(w.label).toBe('2 Freigaben offen')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('hält Noras Zahl nie für dringend — sie schlägt vor, sie entscheidet nicht', () => {
|
||||||
|
const leads = Array.from({ length: 40 }, () => ({ receivedAt: iso(-1) }))
|
||||||
|
const w = computeAgentWorkload(
|
||||||
|
input({ leads: leads as AgentWorkloadInput['leads'] }),
|
||||||
|
NOW,
|
||||||
|
).nora
|
||||||
|
|
||||||
|
expect(w.count).toBe(40)
|
||||||
|
expect(w.tone).toBe(WorkloadTone.ATTENTION)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('formuliert die Einzahl korrekt', () => {
|
||||||
|
const w = computeAgentWorkload(
|
||||||
|
input({ exposeLeads: [{ status: ExposeLeadStatus.ACTIVE }] as AgentWorkloadInput['exposeLeads'] }),
|
||||||
|
NOW,
|
||||||
|
).livia
|
||||||
|
|
||||||
|
expect(w.label).toBe('1 Exposé offen')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('meldet bei leerer Datenlage überall Ruhe statt Lücken', () => {
|
||||||
|
const all = computeAgentWorkload(EMPTY, NOW)
|
||||||
|
|
||||||
|
expect(Object.keys(all)).toEqual(['ferdi', 'bruno', 'livia', 'nora', 'sina', 'giorgio'])
|
||||||
|
for (const w of Object.values(all)) {
|
||||||
|
expect(w.count).toBe(0)
|
||||||
|
expect(w.tone).toBe(WorkloadTone.CALM)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('liefert zum selben Stichzeitpunkt immer dasselbe Ergebnis', () => {
|
||||||
|
const data = input({ reminders: [reminder(2)], visits: [visit(1)] })
|
||||||
|
expect(computeAgentWorkload(data, NOW)).toEqual(computeAgentWorkload(data, NOW))
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
import { ReminderStatus } from '../domain/reminder'
|
||||||
|
import type { Reminder } from '../domain/reminder'
|
||||||
|
import { ExposeLeadStatus } from '../domain/exposeLead'
|
||||||
|
import type { ExposeLead } from '../domain/exposeLead'
|
||||||
|
import { AgentWorkItemStatus } from '../domain/agentWorkItem'
|
||||||
|
import type { AgentWorkItem } from '../domain/agentWorkItem'
|
||||||
|
import type { VisitAssignment } from '../domain/visitAssignment'
|
||||||
|
import type { UnifiedLead } from '../domain/unifiedLead'
|
||||||
|
import type { Property } from '../domain/property'
|
||||||
|
import { WorkloadTone } from '../domain/agentWorkload'
|
||||||
|
import type { AgentWorkload } from '../domain/agentWorkload'
|
||||||
|
import { AGENT_SUPERVISOR } from '../lib/agentWorkspaces'
|
||||||
|
import { DQ_MEDIUM } from '../lib/constants'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auslastung der sechs digitalen Mitarbeitenden (Runde 5).
|
||||||
|
*
|
||||||
|
* Reine Geschäftslogik, bewusst ausserhalb von React: was «diese Woche»
|
||||||
|
* bedeutet, ab wann eine Zahl dringend ist und welcher Bestand zu welchem
|
||||||
|
* Agenten gehört, sind fachliche Festlegungen und keine Darstellungsfragen
|
||||||
|
* (CLAUDE.md §4.2). Hier lässt sich das auch ohne Renderer prüfen — der
|
||||||
|
* Stichzeitpunkt ist ein Parameter, nicht die Systemuhr.
|
||||||
|
*
|
||||||
|
* Jede Zahl stammt aus dem Bestand, für den der jeweilige Agent zuständig ist.
|
||||||
|
* Es gibt keine zweite Kennzahlentabelle, die auseinanderlaufen könnte: wer
|
||||||
|
* Ferdis Zahl prüfen will, zählt seine Reminderliste nach.
|
||||||
|
*
|
||||||
|
* Bewusst genau eine Zahl je Agent — eine Plakette über einem Kopf trägt einen
|
||||||
|
* Satz, kein Dashboard. Gezeigt wird jeweils das, was zuerst eine Entscheidung
|
||||||
|
* verlangt.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const DAY_MS = 24 * 60 * 60 * 1000
|
||||||
|
|
||||||
|
/** Ab dieser Anzahl wechselt eine Zahl von «in Arbeit» auf «dringend». */
|
||||||
|
const URGENT_FROM = {
|
||||||
|
reminders: 5,
|
||||||
|
visits: 4,
|
||||||
|
expose: 5,
|
||||||
|
dataGaps: 10,
|
||||||
|
approvals: 5,
|
||||||
|
} as const
|
||||||
|
|
||||||
|
/** Zeitfenster der Auswertung. */
|
||||||
|
const WINDOW_DAYS = {
|
||||||
|
/** «diese Woche» für Fristen und Termine. */
|
||||||
|
ahead: 7,
|
||||||
|
/** «neu» für Marktchancen. */
|
||||||
|
leadsBack: 14,
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export interface AgentWorkloadInput {
|
||||||
|
reminders: Reminder[]
|
||||||
|
visits: VisitAssignment[]
|
||||||
|
exposeLeads: ExposeLead[]
|
||||||
|
leads: UnifiedLead[]
|
||||||
|
properties: Property[]
|
||||||
|
workItems: AgentWorkItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
function within(iso: string, from: number, to: number): boolean {
|
||||||
|
const t = new Date(iso).getTime()
|
||||||
|
return Number.isFinite(t) && t >= from && t <= to
|
||||||
|
}
|
||||||
|
|
||||||
|
function tone(count: number, urgentFrom: number): WorkloadTone {
|
||||||
|
if (count === 0) return WorkloadTone.CALM
|
||||||
|
return count >= urgentFrom ? WorkloadTone.URGENT : WorkloadTone.ATTENTION
|
||||||
|
}
|
||||||
|
|
||||||
|
function plural(count: number, one: string, many: string): string {
|
||||||
|
return count === 1 ? one : many
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param now Stichzeitpunkt in Millisekunden. Als Parameter und nicht aus der
|
||||||
|
* Systemuhr gelesen, damit dieselbe Datenlage immer dasselbe Ergebnis liefert.
|
||||||
|
*/
|
||||||
|
export function computeAgentWorkload(
|
||||||
|
input: AgentWorkloadInput,
|
||||||
|
now: number = Date.now(),
|
||||||
|
): Record<string, AgentWorkload> {
|
||||||
|
const weekAhead = now + WINDOW_DAYS.ahead * DAY_MS
|
||||||
|
const leadsFrom = now - WINDOW_DAYS.leadsBack * DAY_MS
|
||||||
|
|
||||||
|
// ── Ferdi: Fristen ──────────────────────────────────────────────────────────
|
||||||
|
// Überfälliges zuerst: eine abgelaufene Frist wiegt schwerer als zehn
|
||||||
|
// kommende, und beide Zahlen nebeneinander wären keine Aussage mehr.
|
||||||
|
const activeReminders = input.reminders.filter(r => r.status === ReminderStatus.ACTIVE)
|
||||||
|
const overdue = activeReminders.filter(r => new Date(r.dueDate).getTime() < now).length
|
||||||
|
const dueThisWeek = activeReminders.filter(r => within(r.dueDate, now, weekAhead)).length
|
||||||
|
const ferdi: AgentWorkload = overdue > 0
|
||||||
|
? {
|
||||||
|
agentId: 'ferdi',
|
||||||
|
count: overdue,
|
||||||
|
label: `${overdue} ${plural(overdue, 'Frist', 'Fristen')} überfällig`,
|
||||||
|
tone: WorkloadTone.URGENT,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
agentId: 'ferdi',
|
||||||
|
count: dueThisWeek,
|
||||||
|
label: `${dueThisWeek} ${plural(dueThisWeek, 'Frist', 'Fristen')} diese Woche`,
|
||||||
|
tone: tone(dueThisWeek, URGENT_FROM.reminders),
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Bruno: Besichtigungen ───────────────────────────────────────────────────
|
||||||
|
const upcomingVisits = input.visits.filter(v => within(v.scheduledAt, now, weekAhead)).length
|
||||||
|
const bruno: AgentWorkload = {
|
||||||
|
agentId: 'bruno',
|
||||||
|
count: upcomingVisits,
|
||||||
|
label: `${upcomingVisits} ${plural(upcomingVisits, 'Besichtigung', 'Besichtigungen')} diese Woche`,
|
||||||
|
tone: tone(upcomingVisits, URGENT_FROM.visits),
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Livia: weitergeleitete Leads ────────────────────────────────────────────
|
||||||
|
const openExpose = input.exposeLeads.filter(l => l.status === ExposeLeadStatus.ACTIVE).length
|
||||||
|
const livia: AgentWorkload = {
|
||||||
|
agentId: 'livia',
|
||||||
|
count: openExpose,
|
||||||
|
label: `${openExpose} ${plural(openExpose, 'Exposé offen', 'Exposés offen')}`,
|
||||||
|
tone: tone(openExpose, URGENT_FROM.expose),
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Nora: Marktchancen ──────────────────────────────────────────────────────
|
||||||
|
const freshLeads = input.leads.filter(l => within(l.receivedAt, leadsFrom, now)).length
|
||||||
|
const nora: AgentWorkload = {
|
||||||
|
agentId: 'nora',
|
||||||
|
count: freshLeads,
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Sina: Datenpflege ───────────────────────────────────────────────────────
|
||||||
|
const gaps = input.properties.filter(p => p.dataQuality.score < DQ_MEDIUM).length
|
||||||
|
const sina: AgentWorkload = {
|
||||||
|
agentId: 'sina',
|
||||||
|
count: gaps,
|
||||||
|
label: `${gaps} ${plural(gaps, 'Objekt', 'Objekte')} mit Datenlücken`,
|
||||||
|
tone: tone(gaps, URGENT_FROM.dataGaps),
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Giorgio: Freigaben in der Personalverwaltung ────────────────────────────
|
||||||
|
const pending = input.workItems.filter(w => w.status === AgentWorkItemStatus.PENDING).length
|
||||||
|
const giorgio: AgentWorkload = {
|
||||||
|
agentId: AGENT_SUPERVISOR.id,
|
||||||
|
count: pending,
|
||||||
|
label: `${pending} ${plural(pending, 'Freigabe', 'Freigaben')} offen`,
|
||||||
|
tone: tone(pending, URGENT_FROM.approvals),
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.fromEntries(
|
||||||
|
[ferdi, bruno, livia, nora, sina, giorgio].map(w => [w.agentId, w]),
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user