feat(startseite): digitale Belegschaft mit Präsenz, eigener Stimme und Nachweis
Der nachgebaute Meetingraum entfällt. Eine gezeichnete Kulisse bleibt neben fotografierten Porträts erkennbar gezeichnet — und ein Raum allein macht niemanden menschlich. Menschlich wirkt im echten Büro dasselbe wie hier: dass jemand da ist, etwas zu sagen hat und nachweisbar gearbeitet hat. Die Startseite zeigt deshalb sechs Personenkarten vor einer abgedunkelten Büroaufnahme. Jede Karte trägt, was ein Personalblatt trägt: - Porträt mit Dienstpunkt — ist da oder pausiert - Name, Funktion, Abteilung, Personalnummer - Autonomiestufe: «Autonom», «Freigabe erforderlich», «Nur Vorschlag». Sie sagt, wie viel Verantwortung jemand trägt, und trennt Personal von Automatisierung. - Ein Satz in der ersten Person zur aktuellen Lage, statt einer Kennzahl: «Bei 12 Fristen ist der Termin durch. Fürs Eskalieren brauche ich deine Freigabe.» - Die zuletzt protokollierte Handlung mit Zeitangabe. Diese Zeile ist der Unterschied zwischen Behauptung und Beleg — eine Kennzahl kann jede Software anzeigen. Darüber fasst ein Anriss zusammen, wie viele Mitarbeitende heute eine Entscheidung brauchen, samt Dienststand der Belegschaft. Schichten nach CLAUDE.md §4.2: `agentPresenceService` entscheidet, was als letzte Handlung gilt und wie eine Zeitangabe in Alltagssprache lautet; `agentWorkloadService` hält Zeitfenster, Schwellen und Formulierungen. Beide nehmen den Stichzeitpunkt als Parameter — dieselbe Datenlage ergibt immer dasselbe Ergebnis und ist ohne Renderer prüfbar (22 Tests). Giorgio hat kein Personalblatt; Abteilung, Personalnummer und Stufe kommen aus der zentralen Rollendefinition. Wer kein Dossier hat, gilt als im Dienst — sonst stünde die halbe Belegschaft still, weil Stammdaten fehlen. Bild: Unsplash, freie kommerzielle Lizenz. Herkunft und Austauschhinweis in `src/assets/meeting-room/HERKUNFT.md`. Geprüft: 435 Tests, Typecheck, Build, ESLint, Token-Schwelle (889/925). Browser 1280–1920 px und Tablet: sechs Karten ohne Überlappung, Titel und «Filter & Übersicht» beim Einstieg sichtbar, alle sechs Klicks auf der richtigen Subpage, keine Konsolenfehler, keine 404. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,14 @@
|
||||
# Bildherkunft
|
||||
|
||||
| Datei | Quelle | Lizenz |
|
||||
|---|---|---|
|
||||
| `buero.jpg` | Unsplash — Craig Lovelidge, «An empty conference room with desks and chairs» (The Mark, Rotterdam) | [Unsplash License](https://unsplash.com/license) — frei für kommerzielle Nutzung, keine Namensnennung nötig |
|
||||
|
||||
Direktbezug: `https://images.unsplash.com/photo-1637665599155-abfcf9fc3f78`
|
||||
|
||||
Das Bild dient als abgedunkelte Umgebung hinter den Personenkarten der
|
||||
Startseite. Es wird nie unbearbeitet gezeigt — der Verlauf in `roomBackdrop.ts`
|
||||
nimmt ihm so viel Kontrast, dass die Porträts und die Schrift davor tragen.
|
||||
|
||||
**Austausch:** Datei ersetzen, Dateinamen beibehalten. Eine eigene Büroaufnahme
|
||||
ist jederzeit vorzuziehen — sie zeigt den echten Arbeitsplatz der Belegschaft.
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 71 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 477 KiB |
@@ -1,20 +1,18 @@
|
||||
import { useCallback, useMemo } from 'react'
|
||||
import { 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, string> = {
|
||||
[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 (
|
||||
<Box
|
||||
component="section"
|
||||
aria-label="Agenten-Meetingraum"
|
||||
sx={{ ...height, position: 'relative', display: 'flex', flexDirection: 'column' }}
|
||||
>
|
||||
<Box sx={{ position: 'relative', flex: 1, minHeight: 140 }}>
|
||||
<MeetingRoomScene />
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
flex: '0 0 auto',
|
||||
display: 'grid',
|
||||
gridTemplateColumns: { xs: 'repeat(2, minmax(0, 1fr))', sm: 'repeat(3, minmax(0, 1fr))' },
|
||||
gap: 1,
|
||||
p: 2,
|
||||
bgcolor: DS_NEUTRAL.paper,
|
||||
}}
|
||||
>
|
||||
{MEETING_ROOM_SEATS.map(seat => {
|
||||
const w = workload[seat.id]
|
||||
return (
|
||||
<ButtonBase
|
||||
key={seat.id}
|
||||
focusRipple
|
||||
onClick={() => handleOpen(seat.path)}
|
||||
aria-label={
|
||||
w
|
||||
? `${seat.name}, ${seat.role} — ${w.label}. Arbeitsbereich öffnen`
|
||||
: `${seat.name}, ${seat.role} — Arbeitsbereich öffnen`
|
||||
}
|
||||
sx={{
|
||||
display: 'flex', alignItems: 'center', gap: 1,
|
||||
p: 1, borderRadius: 2, justifyContent: 'flex-start',
|
||||
bgcolor: DS_NEUTRAL.white,
|
||||
boxShadow: '0 2px 8px rgba(8,14,24,0.10)',
|
||||
'&.Mui-focusVisible': { outline: `2px solid ${DS_NEUTRAL.gold}`, outlineOffset: 2 },
|
||||
}}
|
||||
>
|
||||
<AgentAvatar agent={{ id: seat.id, name: seat.name, role: seat.role }} size={38} />
|
||||
<Box sx={{ minWidth: 0, textAlign: 'left' }}>
|
||||
<Typography sx={{ fontWeight: 700, fontSize: '0.8125rem', lineHeight: 1.2, color: DS_TEXT.primary }}>
|
||||
{seat.name}
|
||||
</Typography>
|
||||
<Typography sx={{ fontSize: '0.6875rem', lineHeight: 1.25, color: DS_TEXT.secondary }}>
|
||||
{seat.role}
|
||||
</Typography>
|
||||
{w && (
|
||||
<Typography sx={{ fontSize: '0.6875rem', fontWeight: 600, lineHeight: 1.25, color: TONE_COLOR[w.tone] }}>
|
||||
{w.label}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</ButtonBase>
|
||||
)
|
||||
})}
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
return { urgent: urgentCount, onDuty: onDutyCount }
|
||||
}, [staff])
|
||||
|
||||
return (
|
||||
<Box
|
||||
component="section"
|
||||
aria-label="Agenten-Meetingraum"
|
||||
// Container-Kontext: die Schilder bemessen ihre Höchstbreite an der
|
||||
// Szene, nicht am Fenster (siehe AgentSeat).
|
||||
sx={{ ...height, position: 'relative', overflow: 'hidden', containerType: 'inline-size' }}
|
||||
aria-label="Digitale Belegschaft"
|
||||
sx={{
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
height: `max(${MIN_SCENE_PX}px, calc(100vh - ${RESERVED_PX}px))`,
|
||||
'@supports (height: 100dvh)': {
|
||||
height: `max(${MIN_SCENE_PX}px, calc(100dvh - ${RESERVED_PX}px))`,
|
||||
},
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<MeetingRoomScene />
|
||||
|
||||
<SceneHeadline urgentCount={urgentCount} attentionCount={attentionCount} />
|
||||
<Box
|
||||
sx={{
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
height: '100%',
|
||||
px: { xs: 2.5, lg: 4 },
|
||||
py: { xs: 2.5, lg: 3 },
|
||||
gap: { xs: 2, lg: 2.5 },
|
||||
}}
|
||||
>
|
||||
<StaffHeadline urgent={urgent} onDuty={onDuty} total={staff.length} />
|
||||
|
||||
{MEETING_ROOM_SEATS.map(seat => (
|
||||
<AgentSeat key={seat.id} seat={seat} workload={workload[seat.id]} onOpen={handleOpen} />
|
||||
))}
|
||||
<Box
|
||||
sx={{
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
display: 'grid',
|
||||
gridTemplateColumns: {
|
||||
xs: '1fr',
|
||||
sm: 'repeat(2, minmax(0, 1fr))',
|
||||
lg: 'repeat(3, minmax(0, 1fr))',
|
||||
},
|
||||
gridAutoRows: '1fr',
|
||||
gap: { xs: 1.5, lg: 2 },
|
||||
// Auf niedrigen Fenstern darf die Belegschaft scrollen, statt die
|
||||
// Karten bis zur Unlesbarkeit zu stauchen.
|
||||
overflowY: 'auto',
|
||||
}}
|
||||
>
|
||||
{staff.map(person => (
|
||||
<AgentStaffCard key={person.agentId} presence={person} onOpen={handleOpen} />
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<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>
|
||||
<Box sx={{ flexShrink: 0 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Box sx={{ width: 7, height: 7, borderRadius: '50%', bgcolor: DS_NEUTRAL.gold }} />
|
||||
<Typography
|
||||
sx={{
|
||||
fontSize: '0.6875rem',
|
||||
fontWeight: 700,
|
||||
letterSpacing: 1.4,
|
||||
textTransform: 'uppercase',
|
||||
color: DS_NEUTRAL.gold,
|
||||
}}
|
||||
>
|
||||
{onDuty} von {total} im Dienst
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography
|
||||
component="h2"
|
||||
sx={{
|
||||
mt: 0.5,
|
||||
fontWeight: 700,
|
||||
fontSize: { xs: '1.3rem', lg: '1.7rem' },
|
||||
fontSize: { xs: '1.25rem', lg: '1.6rem' },
|
||||
lineHeight: 1.2,
|
||||
color: DS_TEXT.inverted,
|
||||
textShadow: '0 2px 14px rgba(8,14,24,0.7)',
|
||||
color: DS_NEUTRAL.white,
|
||||
}}
|
||||
>
|
||||
{summary}
|
||||
|
||||
@@ -1,176 +0,0 @@
|
||||
import { memo } from 'react'
|
||||
import { ButtonBase, Box, Typography } from '@mui/material'
|
||||
import { AgentAvatar } from '../team'
|
||||
import type { MeetingRoomSeat } from './meetingRoomSeats'
|
||||
import { SEAT_BASE_HEAD_PX } from './meetingRoomSeats'
|
||||
import type { AgentWorkload } from '../../domain/agentWorkload'
|
||||
import { WorkloadTone } from '../../domain/agentWorkload'
|
||||
import { DS_ACCENT, DS_NEUTRAL, DS_TEXT } from '../../lib/ds'
|
||||
|
||||
const TONE_COLOR: Record<WorkloadTone, string> = {
|
||||
[WorkloadTone.CALM]: DS_ACCENT.success.main,
|
||||
[WorkloadTone.ATTENTION]: DS_ACCENT.warning.main,
|
||||
[WorkloadTone.URGENT]: DS_ACCENT.danger.main,
|
||||
}
|
||||
|
||||
export interface AgentSeatProps {
|
||||
seat: MeetingRoomSeat
|
||||
/** Aktuelle Auslastung; fehlt sie, zeigt die Plakette nur Name und Funktion. */
|
||||
workload?: AgentWorkload
|
||||
onOpen: (path: string) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Ein Platz im Meetingraum (Runde 5, §3.6/§3.7).
|
||||
*
|
||||
* Porträt, Namensschild und Auslastung sind eine einzige Schaltfläche — nicht
|
||||
* nur der Kopf. Ein 60-Pixel-Porträt als einzige Trefferfläche wäre auf jedem
|
||||
* Laptop eine Zumutung, und das Schild darunter zeigt ohnehin dasselbe Ziel.
|
||||
*
|
||||
* Die Zahl auf dem Schild ist der eigentliche Grund, warum diese Seite mehr ist
|
||||
* als ein Bild: sie sagt, woran der Agent gerade arbeitet, und macht den Klick
|
||||
* zu einer Entscheidung statt zu einem Rundgang.
|
||||
*
|
||||
* `ButtonBase` statt `<div onClick>`: Tastaturbedienung, Enter und Leertaste,
|
||||
* Fokusring und Rollen-Semantik kommen damit ohne eigenen Code korrekt.
|
||||
*/
|
||||
export const AgentSeat = memo(function AgentSeat({ seat, workload, onOpen }: AgentSeatProps) {
|
||||
const head = Math.round(SEAT_BASE_HEAD_PX * seat.scale)
|
||||
const toneColor = workload ? TONE_COLOR[workload.tone] : DS_TEXT.secondary
|
||||
|
||||
return (
|
||||
<ButtonBase
|
||||
focusRipple
|
||||
onClick={() => onOpen(seat.path)}
|
||||
aria-label={
|
||||
workload
|
||||
? `${seat.name}, ${seat.role} — ${workload.label}. Arbeitsbereich öffnen`
|
||||
: `${seat.name}, ${seat.role} — Arbeitsbereich öffnen`
|
||||
}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
left: `${seat.x}%`,
|
||||
top: `${seat.y}%`,
|
||||
transform: 'translate(-50%, -50%)',
|
||||
zIndex: seat.zIndex,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: 0.75,
|
||||
px: 0.5,
|
||||
py: 1,
|
||||
borderRadius: 3,
|
||||
transition: 'transform 0.18s ease',
|
||||
'&:hover, &.Mui-focusVisible': { transform: 'translate(-50%, calc(-50% - 5px))' },
|
||||
'&:hover .seat-plate, &.Mui-focusVisible .seat-plate': {
|
||||
boxShadow: '0 14px 34px rgba(8,14,24,0.42)',
|
||||
},
|
||||
'&.Mui-focusVisible': { outline: `2px solid ${DS_NEUTRAL.gold}`, outlineOffset: 3 },
|
||||
}}
|
||||
>
|
||||
{/* Kontaktschatten unter dem Porträt — ohne ihn schwebt der Kopf über dem Bild. */}
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: `calc(8px + ${head}px - ${Math.round(head * 0.14)}px)`,
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
width: Math.round(head * 1.05),
|
||||
height: Math.round(head * 0.28),
|
||||
borderRadius: '50%',
|
||||
background: 'radial-gradient(closest-side, rgba(8,14,24,0.55), rgba(8,14,24,0))',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
/>
|
||||
|
||||
<Box sx={{ filter: 'drop-shadow(0 10px 22px rgba(8,14,24,0.45))' }}>
|
||||
<AgentAvatar
|
||||
agent={{ id: seat.id, name: seat.name, role: seat.role }}
|
||||
size={head}
|
||||
loading="eager"
|
||||
ringColor={DS_NEUTRAL.white}
|
||||
ringWidth={3}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Namensschild unter dem Kopf: Name, Funktion, aktuelle Auslastung. */}
|
||||
<Box
|
||||
className="seat-plate"
|
||||
sx={{
|
||||
position: 'relative',
|
||||
minWidth: Math.max(120, head * 1.9),
|
||||
/*
|
||||
* Obergrenze als Anteil der Szenenbreite (`cqw`), nicht der
|
||||
* Fensterbreite: die Szene ist um das Seitenmenü schmaler als das
|
||||
* Fenster, und genau dieser Unterschied liess die Schilder auf
|
||||
* schmalen Laptops überlappen. 13.4% liegt samt Klickrand sicher unter dem Abstand
|
||||
* von 15.6%, mit dem die sechs Plätze verteilt sind.
|
||||
*
|
||||
* Der längste Kennzahlensatz («14 Objekte mit Datenlücken») bricht
|
||||
* damit um, statt sein Schild über das nächste zu dehnen — die Zahl
|
||||
* bleibt vollständig, die Reihe bleibt frei von Überlappungen.
|
||||
*/
|
||||
maxWidth: 'min(200px, 13.4cqw)',
|
||||
px: 1.25,
|
||||
py: 0.75,
|
||||
borderRadius: 2,
|
||||
bgcolor: 'rgba(255,255,255,0.96)',
|
||||
boxShadow: '0 6px 18px rgba(8,14,24,0.28)',
|
||||
textAlign: 'center',
|
||||
transition: 'box-shadow 0.18s ease',
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
sx={{
|
||||
fontWeight: 700,
|
||||
fontSize: seat.scale < 0.9 ? '0.78rem' : '0.85rem',
|
||||
lineHeight: 1.2,
|
||||
color: DS_TEXT.primary,
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{seat.name}
|
||||
</Typography>
|
||||
<Typography
|
||||
sx={{
|
||||
fontSize: '0.6875rem',
|
||||
lineHeight: 1.25,
|
||||
color: DS_TEXT.secondary,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{seat.role}
|
||||
</Typography>
|
||||
|
||||
{workload && (
|
||||
<Box
|
||||
sx={{
|
||||
mt: 0.5,
|
||||
pt: 0.5,
|
||||
borderTop: '1px solid rgba(15,23,42,0.08)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 0.625,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ width: 6, height: 6, borderRadius: '50%', bgcolor: toneColor, flexShrink: 0 }} />
|
||||
<Typography
|
||||
sx={{
|
||||
fontSize: '0.6875rem',
|
||||
fontWeight: 600,
|
||||
lineHeight: 1.25,
|
||||
color: toneColor,
|
||||
textAlign: 'left',
|
||||
}}
|
||||
>
|
||||
{workload.label}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</ButtonBase>
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,225 @@
|
||||
import { memo } from 'react'
|
||||
import { Box, ButtonBase, Typography } from '@mui/material'
|
||||
import { ArrowRight } from 'lucide-react'
|
||||
import { AgentAvatar } from '../team'
|
||||
import type { AgentPresence } from '../../domain/agentPresence'
|
||||
import { WorkloadTone } from '../../domain/agentWorkload'
|
||||
import { DS_ACCENT, DS_NEUTRAL } from '../../lib/ds'
|
||||
|
||||
/**
|
||||
* Farben der Auslastung auf dunklem Grund. Bewusst hellere Stufen als die
|
||||
* Standardakzente — die dunkleren Töne verschwinden vor der abgedunkelten
|
||||
* Bürofläche.
|
||||
*/
|
||||
const TONE_COLOR: Record<WorkloadTone, string> = {
|
||||
[WorkloadTone.CALM]: DS_ACCENT.success.emerald,
|
||||
[WorkloadTone.ATTENTION]: DS_ACCENT.warning.amber,
|
||||
[WorkloadTone.URGENT]: DS_ACCENT.danger.bright,
|
||||
}
|
||||
|
||||
export interface AgentStaffCardProps {
|
||||
presence: AgentPresence
|
||||
onOpen: (path: string) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Ein digitaler Mitarbeitender auf der Startseite (Runde 5, §3/§6).
|
||||
*
|
||||
* Die Karte ist bewusst wie ein Personalblatt aufgebaut und nicht wie eine
|
||||
* Kachel: Porträt, Name, Funktion, Abteilung und Personalnummer, dann ein Satz
|
||||
* in der ersten Person, darunter die zuletzt protokollierte Handlung mit
|
||||
* Zeitangabe.
|
||||
*
|
||||
* Diese letzte Zeile ist der Unterschied zwischen «KI-Funktion» und
|
||||
* «Mitarbeitender»: sie belegt, dass jemand gearbeitet hat, statt es zu
|
||||
* behaupten. Eine Kennzahl allein kann jede Software anzeigen.
|
||||
*
|
||||
* Die ganze Karte ist ein Schalter. `ButtonBase` liefert Tastaturbedienung,
|
||||
* Enter, Leertaste und Fokusring ohne eigenen Code.
|
||||
*/
|
||||
export const AgentStaffCard = memo(function AgentStaffCard({ presence, onOpen }: AgentStaffCardProps) {
|
||||
const { workload } = presence
|
||||
const toneColor = workload ? TONE_COLOR[workload.tone] : DS_NEUTRAL.stone
|
||||
|
||||
return (
|
||||
<ButtonBase
|
||||
focusRipple
|
||||
onClick={() => onOpen(presence.path)}
|
||||
aria-label={
|
||||
workload
|
||||
? `${presence.name}, ${presence.role} — ${workload.label}. Arbeitsbereich öffnen`
|
||||
: `${presence.name}, ${presence.role} — Arbeitsbereich öffnen`
|
||||
}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'stretch',
|
||||
textAlign: 'left',
|
||||
gap: 1.25,
|
||||
p: { xs: 1.75, xl: 2 },
|
||||
borderRadius: 3,
|
||||
height: '100%',
|
||||
// Glasfläche statt Vollton: das Büro dahinter bleibt spürbar, ohne die
|
||||
// Schrift zu stören.
|
||||
bgcolor: 'rgba(255,255,255,0.06)',
|
||||
backdropFilter: 'blur(14px)',
|
||||
border: '1px solid rgba(255,255,255,0.12)',
|
||||
transition: 'background-color 0.18s ease, transform 0.18s ease, border-color 0.18s ease',
|
||||
'&:hover, &.Mui-focusVisible': {
|
||||
bgcolor: 'rgba(255,255,255,0.11)',
|
||||
borderColor: 'rgba(255,255,255,0.26)',
|
||||
transform: 'translateY(-3px)',
|
||||
},
|
||||
'&:hover .staff-go, &.Mui-focusVisible .staff-go': { opacity: 1, transform: 'translateX(0)' },
|
||||
'&.Mui-focusVisible': { outline: `2px solid ${DS_NEUTRAL.gold}`, outlineOffset: 2 },
|
||||
}}
|
||||
>
|
||||
{/* Kopfzeile: Porträt, Personalien, Dienstzustand */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
||||
<Box sx={{ position: 'relative', flexShrink: 0 }}>
|
||||
<AgentAvatar
|
||||
agent={{ id: presence.agentId, name: presence.name, role: presence.role }}
|
||||
size={56}
|
||||
loading="eager"
|
||||
ringColor="rgba(255,255,255,0.55)"
|
||||
ringWidth={2}
|
||||
/>
|
||||
{/* Dienstpunkt am Porträt — die kürzeste Art zu sagen «ist da». */}
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute', right: -1, bottom: -1,
|
||||
width: 14, height: 14, borderRadius: '50%',
|
||||
bgcolor: presence.onDuty ? DS_ACCENT.success.emerald : DS_NEUTRAL.stone,
|
||||
border: '2.5px solid rgba(12,18,28,0.95)',
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ minWidth: 0, flex: 1 }}>
|
||||
<Typography
|
||||
sx={{ fontWeight: 700, fontSize: '1.0625rem', lineHeight: 1.2, color: DS_NEUTRAL.white }}
|
||||
>
|
||||
{presence.name}
|
||||
</Typography>
|
||||
<Typography
|
||||
sx={{ fontSize: '0.8125rem', lineHeight: 1.3, color: 'rgba(255,255,255,0.72)' }}
|
||||
>
|
||||
{presence.role}
|
||||
</Typography>
|
||||
{(presence.department || presence.personnelNumber) && (
|
||||
<Typography
|
||||
sx={{
|
||||
mt: 0.25,
|
||||
fontSize: '0.6875rem',
|
||||
lineHeight: 1.3,
|
||||
color: 'rgba(255,255,255,0.42)',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{[presence.department, presence.personnelNumber].filter(Boolean).join(' · ')}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Kompetenz: was der Mitarbeitende selbst entscheiden darf. */}
|
||||
{presence.autonomy && (
|
||||
<Box
|
||||
sx={{
|
||||
alignSelf: 'flex-start',
|
||||
px: 1,
|
||||
py: 0.25,
|
||||
borderRadius: 1,
|
||||
bgcolor: 'rgba(255,255,255,0.09)',
|
||||
border: '1px solid rgba(255,255,255,0.14)',
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
sx={{
|
||||
fontSize: '0.625rem',
|
||||
fontWeight: 700,
|
||||
letterSpacing: 0.6,
|
||||
textTransform: 'uppercase',
|
||||
color: 'rgba(255,255,255,0.66)',
|
||||
}}
|
||||
>
|
||||
{presence.autonomy}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Was er dazu sagt — in der ersten Person. */}
|
||||
{workload && (
|
||||
<Box sx={{ display: 'flex', gap: 1, alignItems: 'flex-start' }}>
|
||||
<Box
|
||||
sx={{ width: 3, alignSelf: 'stretch', borderRadius: 2, bgcolor: toneColor, flexShrink: 0 }}
|
||||
/>
|
||||
<Typography
|
||||
sx={{
|
||||
fontSize: '0.8125rem',
|
||||
lineHeight: 1.45,
|
||||
color: 'rgba(255,255,255,0.92)',
|
||||
}}
|
||||
>
|
||||
{workload.voice}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Beleg: was zuletzt tatsächlich getan wurde. */}
|
||||
<Box sx={{ mt: 'auto', pt: 1, borderTop: '1px solid rgba(255,255,255,0.10)' }}>
|
||||
{presence.lastAction ? (
|
||||
<>
|
||||
<Typography
|
||||
sx={{
|
||||
fontSize: '0.625rem',
|
||||
fontWeight: 700,
|
||||
letterSpacing: 0.8,
|
||||
textTransform: 'uppercase',
|
||||
color: 'rgba(255,255,255,0.38)',
|
||||
}}
|
||||
>
|
||||
Zuletzt · {presence.lastAction.when}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1 }}>
|
||||
<Typography
|
||||
sx={{
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
fontSize: '0.75rem',
|
||||
lineHeight: 1.4,
|
||||
color: 'rgba(255,255,255,0.62)',
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 2,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{presence.lastAction.title}
|
||||
</Typography>
|
||||
<Box
|
||||
className="staff-go"
|
||||
sx={{
|
||||
color: DS_NEUTRAL.gold,
|
||||
opacity: 0,
|
||||
transform: 'translateX(-4px)',
|
||||
transition: 'opacity 0.18s ease, transform 0.18s ease',
|
||||
flexShrink: 0,
|
||||
mt: 0.25,
|
||||
}}
|
||||
>
|
||||
<ArrowRight size={16} />
|
||||
</Box>
|
||||
</Box>
|
||||
</>
|
||||
) : (
|
||||
<Typography sx={{ fontSize: '0.75rem', color: 'rgba(255,255,255,0.42)' }}>
|
||||
Noch keine Tätigkeit protokolliert.
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</ButtonBase>
|
||||
)
|
||||
})
|
||||
@@ -1,7 +1,7 @@
|
||||
import { memo } from 'react'
|
||||
import { 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() {
|
||||
|
||||
<Box sx={{ position: 'absolute', inset: 0, background: ROOM_BACKDROP.scrim }} />
|
||||
|
||||
{ROOM_BACKDROP.credit && (
|
||||
<Typography
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
right: 12,
|
||||
bottom: 8,
|
||||
fontSize: '0.625rem',
|
||||
letterSpacing: 0.4,
|
||||
color: DS_TEXT.inverted,
|
||||
opacity: 0.55,
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
>
|
||||
{ROOM_BACKDROP.credit}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -1,113 +0,0 @@
|
||||
/**
|
||||
* Property On — Sitzordnung im Agenten-Meetingraum (Runde 5, §3.7).
|
||||
*
|
||||
* Eine einzige Tabelle bestimmt, wer wo sitzt, wie gross er erscheint, wohin
|
||||
* sein Klick führt und wo seine Plakette hängt. Wer einen Agenten umsetzt,
|
||||
* ergänzt oder umbenennt, ändert genau diese Datei — nicht die Szene.
|
||||
*
|
||||
* Koordinaten sind Prozentwerte des Szenenrahmens, nicht Pixel: der Raum wird
|
||||
* anhand der Viewport-Höhe berechnet und darf auf jedem Monitor eine andere
|
||||
* Grösse haben, ohne dass die Sitzordnung verrutscht.
|
||||
*
|
||||
* Die Tiefe steckt in `scale` und `zIndex`: weiter hinten heisst kleiner und
|
||||
* weiter unten in der Stapelfolge. Beides ist bewusst von Hand gesetzt und
|
||||
* nicht aus `y` gerechnet — die Tischgeometrie ist keine lineare Funktion.
|
||||
*/
|
||||
|
||||
import { AGENT_WORKSPACES, AGENT_SUPERVISOR } from '../../lib/agentWorkspaces'
|
||||
|
||||
/** Auf welcher Seite des Tisches ein Platz liegt — steuert die Blickrichtung. */
|
||||
export const SeatSide = {
|
||||
LEFT: 'LEFT',
|
||||
RIGHT: 'RIGHT',
|
||||
HEAD: 'HEAD',
|
||||
} as const
|
||||
export type SeatSide = typeof SeatSide[keyof typeof SeatSide]
|
||||
|
||||
export interface MeetingRoomSeat {
|
||||
/** Identisch mit der Agenten-ID in `AGENT_PHOTOS` — liefert das Porträt. */
|
||||
id: string
|
||||
name: string
|
||||
/** Funktionsbezeichnung; steht unter dem Namen auf der Plakette. */
|
||||
role: string
|
||||
/** Zielseite des Klicks auf die gesamte Sitzeinheit. */
|
||||
path: string
|
||||
side: SeatSide
|
||||
/** Mittelpunkt des Kopfes in Prozent der Szenenbreite bzw. -höhe. */
|
||||
x: number
|
||||
y: number
|
||||
/** Grössenfaktor gegenüber der Basisgrösse — bildet die Raumtiefe ab. */
|
||||
scale: number
|
||||
/**
|
||||
* Seitlicher Versatz der Plakette in Prozentpunkten. Ohne ihn stünden die
|
||||
* Plaketten benachbarter Plätze übereinander; sie fächern deshalb nach
|
||||
* aussen auf (§3.7 «Labels bei Überlappung intelligent versetzen»).
|
||||
*/
|
||||
labelDx: number
|
||||
/**
|
||||
* Zusätzlicher Höhenversatz der Plakette in Prozentpunkten. Die Plätze einer
|
||||
* Tischseite liegen diagonal hintereinander; ohne gestaffelte Höhen legten
|
||||
* sich ihre Plaketten übereinander, egal wie weit man sie seitlich versetzt.
|
||||
*/
|
||||
labelDy: number
|
||||
/** Stapelfolge: vorne sitzende Agenten verdecken weiter hinten sitzende. */
|
||||
zIndex: number
|
||||
}
|
||||
|
||||
type SeatPlacement = Omit<MeetingRoomSeat, 'id' | 'name' | 'role' | 'path'>
|
||||
|
||||
/**
|
||||
* Plätze der fünf Agenten, nach Agenten-ID statt nach Reihenfolge — so
|
||||
* verrutscht die Sitzordnung nicht still, wenn `AGENT_WORKSPACES` einmal
|
||||
* umsortiert wird.
|
||||
*
|
||||
* Die Vergabe läuft im Zickzack von hinten nach vorn (hinten links, hinten
|
||||
* rechts, Mitte links, Mitte rechts, vorne links) entlang der Reihenfolge des
|
||||
* Seitenmenüs: so bleibt der Arbeitsablauf auch am Tisch ablesbar und keine
|
||||
* Tischseite wirkt leer.
|
||||
*/
|
||||
const AGENT_PLACEMENTS: Record<string, SeatPlacement> = {
|
||||
ferdi: { side: SeatSide.LEFT, x: 11.0, y: 70, scale: 1.00, labelDx: 0, labelDy: 0, zIndex: 20 },
|
||||
bruno: { side: SeatSide.LEFT, x: 26.6, y: 70, scale: 1.00, labelDx: 0, labelDy: 0, zIndex: 20 },
|
||||
livia: { side: SeatSide.LEFT, x: 42.2, y: 70, scale: 1.00, labelDx: 0, labelDy: 0, zIndex: 20 },
|
||||
nora: { side: SeatSide.RIGHT, x: 57.8, y: 70, scale: 1.00, labelDx: 0, labelDy: 0, zIndex: 20 },
|
||||
sina: { side: SeatSide.RIGHT, x: 73.4, y: 70, scale: 1.00, labelDx: 0, labelDy: 0, zIndex: 20 },
|
||||
}
|
||||
|
||||
/**
|
||||
* Notplatz für einen Agenten ohne eigenen Eintrag oben. Kein Agent darf
|
||||
* verschwinden, nur weil jemand die Sitzordnung zu ergänzen vergisst (§3.7).
|
||||
* Er landet dann sichtbar vorne rechts statt lautlos im Nichts.
|
||||
*/
|
||||
const FALLBACK_PLACEMENT: SeatPlacement = {
|
||||
side: SeatSide.RIGHT, x: 89.0, y: 70, scale: 1.00, labelDx: 0, labelDy: 0, zIndex: 20,
|
||||
}
|
||||
|
||||
/**
|
||||
* Giorgio sitzt vis-à-vis am Kopfende gegenüber dem Betrachter, vor der
|
||||
* Fensterfront. Er ist der einzige Platz, dessen Klick nicht auf eine
|
||||
* Arbeitsseite führt, sondern in die Personalverwaltung.
|
||||
*/
|
||||
const SUPERVISOR_PLACEMENT: SeatPlacement = {
|
||||
side: SeatSide.HEAD, x: 89.0, y: 70, scale: 1.08, labelDx: 0, labelDy: 0, zIndex: 25,
|
||||
}
|
||||
|
||||
export const MEETING_ROOM_SEATS: MeetingRoomSeat[] = [
|
||||
...AGENT_WORKSPACES.map(agent => ({
|
||||
id: agent.id,
|
||||
name: agent.name,
|
||||
role: agent.role,
|
||||
path: agent.path,
|
||||
...(AGENT_PLACEMENTS[agent.id] ?? FALLBACK_PLACEMENT),
|
||||
})),
|
||||
{
|
||||
id: AGENT_SUPERVISOR.id,
|
||||
name: AGENT_SUPERVISOR.name,
|
||||
role: AGENT_SUPERVISOR.role,
|
||||
path: AGENT_SUPERVISOR.path,
|
||||
...SUPERVISOR_PLACEMENT,
|
||||
},
|
||||
]
|
||||
|
||||
/** Kopfgrösse bei `scale: 1`, in Pixel. Alle übrigen Grössen leiten sich ab. */
|
||||
export const SEAT_BASE_HEAD_PX = 62
|
||||
@@ -1,51 +1,32 @@
|
||||
import baselWindow from '../../assets/meeting-room/basel-rhein-muenster.jpg'
|
||||
import buero from '../../assets/meeting-room/buero.jpg'
|
||||
|
||||
/**
|
||||
* Hintergrund des Agenten-Meetingraums (Runde 5, §3).
|
||||
* Umgebung der Belegschaftsansicht auf der Startseite (Runde 5, §3).
|
||||
*
|
||||
* Genau eine Stelle bestimmt, welches Bild die Szene trägt. Ein Austausch des
|
||||
* 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',
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { AgentWorkload } from './agentWorkload'
|
||||
|
||||
/**
|
||||
* Property On — ein digitaler Mitarbeitender, so wie er auf der Startseite
|
||||
* auftritt (Runde 5).
|
||||
*
|
||||
* Zusammengesetzt aus drei Quellen, weil Menschlichkeit aus dem Zusammenspiel
|
||||
* entsteht und nicht aus einem einzelnen Feld:
|
||||
*
|
||||
* - **Personalien** — Name, Funktion, Abteilung, Personalnummer. Wer eine
|
||||
* Personalnummer hat, ist Personal und kein Werkzeug.
|
||||
* - **Auslastung** — woran er gerade sitzt, in seinen eigenen Worten.
|
||||
* - **Letzte Handlung** — was er zuletzt getan hat, mit Zeitangabe. Das ist
|
||||
* der Beleg, dass wirklich jemand arbeitet; ohne ihn bleibt jede Zusage
|
||||
* eine Behauptung.
|
||||
*/
|
||||
export interface AgentPresence {
|
||||
agentId: string
|
||||
name: string
|
||||
role: string
|
||||
/** Abteilung laut Personalblatt; leer, solange kein Dossier vorliegt. */
|
||||
department: string
|
||||
/** Personalnummer laut Personalblatt; leer, solange kein Dossier vorliegt. */
|
||||
personnelNumber: string
|
||||
/** Zielseite des Klicks auf die Karte. */
|
||||
path: string
|
||||
/** true, solange der Mitarbeitende nicht pausiert ist. */
|
||||
onDuty: boolean
|
||||
/**
|
||||
* Was der Mitarbeitende selbst entscheiden darf — «Autonom», «Freigabe
|
||||
* erforderlich» oder «Nur Vorschlag». Die Stufe ist das stärkste Merkmal des
|
||||
* Ansatzes: sie sagt, wie viel Verantwortung jemand trägt, und trennt
|
||||
* Personal von einer Automatisierung.
|
||||
*/
|
||||
autonomy: string
|
||||
workload: AgentWorkload
|
||||
/** Zuletzt protokollierte Handlung — fehlt, wenn noch nichts vorliegt. */
|
||||
lastAction?: {
|
||||
/** Was getan wurde, als ganzer Satz aus dem Protokoll. */
|
||||
title: string
|
||||
/** Wann, in Alltagssprache: «vor 12 Min.», «gestern 16:40». */
|
||||
when: string
|
||||
}
|
||||
}
|
||||
@@ -32,4 +32,11 @@ export interface AgentWorkload {
|
||||
*/
|
||||
label: string
|
||||
tone: WorkloadTone
|
||||
/**
|
||||
* Derselbe Sachverhalt in der ersten Person — das, was der Agent dazu sagen
|
||||
* würde. Digitale Mitarbeitende sollen als Personal auftreten, nicht als
|
||||
* Kennzahl: «Bei 12 Fristen ist der Termin durch» ist dieselbe Information
|
||||
* wie «12 überfällig», aber es spricht jemand.
|
||||
*/
|
||||
voice: string
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { useMemo } from 'react'
|
||||
import { useTeamAgents } from './useTeamAgents'
|
||||
import { useAgentProtocol } from './useAgentProtocol'
|
||||
import { useAgentWorkload } from './useAgentWorkload'
|
||||
import { buildAgentPresence } from '../services/agentPresenceService'
|
||||
import type { AgentPresence } from '../domain/agentPresence'
|
||||
|
||||
/**
|
||||
* Die Belegschaft, wie sie auf der Startseite auftritt (Runde 5).
|
||||
*
|
||||
* Der Hook sammelt Personalblätter, Protokoll und Auslastung ein; was daraus
|
||||
* wird, entscheidet `agentPresenceService` (CLAUDE.md §4.2).
|
||||
*/
|
||||
export function useAgentPresence(): { data: AgentPresence[]; isLoading: boolean } {
|
||||
const { data: agents = [], isLoading: agentsLoading } = useTeamAgents()
|
||||
const { data: protocol = [], isLoading: protocolLoading } = useAgentProtocol()
|
||||
const { data: workload, isLoading: workloadLoading } = useAgentWorkload()
|
||||
|
||||
const data = useMemo(
|
||||
() => buildAgentPresence({ agents, protocol, workload }),
|
||||
[agents, protocol, workload],
|
||||
)
|
||||
|
||||
return { data, isLoading: agentsLoading || protocolLoading || workloadLoading }
|
||||
}
|
||||
@@ -14,6 +14,10 @@
|
||||
import { ROUTES } from './constants'
|
||||
|
||||
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,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* Property On — Belegschaftsansicht der Startseite (Runde 5).
|
||||
*
|
||||
* Hier steht, was die digitalen Mitarbeitenden menschlich macht: dass sie eine
|
||||
* Reihenfolge haben, Personalien tragen, einen Dienstzustand zeigen und
|
||||
* belegen können, was sie zuletzt getan haben. Jede dieser Zusicherungen kann
|
||||
* still brechen, ohne dass die Oberfläche kaputt aussieht — eine leere
|
||||
* Personalnummer fällt niemandem auf.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { buildAgentPresence, formatRelativeTime } from '../agentPresenceService'
|
||||
import type { AgentPresenceInput } from '../agentPresenceService'
|
||||
import { AgentStatus } from '../../domain/teamAgent'
|
||||
import { WorkloadTone } from '../../domain/agentWorkload'
|
||||
import type { AgentWorkload } from '../../domain/agentWorkload'
|
||||
|
||||
const NOW = Date.parse('2026-08-06T12:00:00.000Z')
|
||||
const MINUTE = 60 * 1000
|
||||
const HOUR = 60 * MINUTE
|
||||
const DAY = 24 * HOUR
|
||||
|
||||
const ago = (ms: number) => new Date(NOW - ms).toISOString()
|
||||
|
||||
function workload(agentId: string): AgentWorkload {
|
||||
return { agentId, count: 0, label: 'nichts offen', tone: WorkloadTone.CALM, voice: 'Alles ruhig.' }
|
||||
}
|
||||
|
||||
const WORKLOAD = Object.fromEntries(
|
||||
['ferdi', 'bruno', 'livia', 'nora', 'sina', 'giorgio'].map(id => [id, workload(id)]),
|
||||
)
|
||||
|
||||
function input(partial: Partial<AgentPresenceInput> = {}): AgentPresenceInput {
|
||||
return { agents: [], protocol: [], workload: WORKLOAD, ...partial }
|
||||
}
|
||||
|
||||
// Nur die Felder, die der Dienst liest.
|
||||
const dossier = (id: string, extra: Record<string, unknown> = {}) =>
|
||||
({
|
||||
id,
|
||||
department: 'Bewirtschaftung',
|
||||
personnelNumber: 'PO-TEST-01',
|
||||
status: AgentStatus.ACTIVE,
|
||||
autonomyLevel: 'AUTONOMOUS',
|
||||
...extra,
|
||||
}) as AgentPresenceInput['agents'][number]
|
||||
|
||||
const entry = (agentId: string, msAgo: number, title: string) =>
|
||||
({ agentId, timestamp: ago(msAgo), title }) as AgentPresenceInput['protocol'][number]
|
||||
|
||||
describe('Zeitangabe in Alltagssprache', () => {
|
||||
it('nennt frische Ereignisse in Minuten', () => {
|
||||
expect(formatRelativeTime(ago(12 * MINUTE), NOW)).toBe('vor 12 Min.')
|
||||
})
|
||||
|
||||
it('wechselt nach einer Stunde auf Stunden, mit korrekter Einzahl', () => {
|
||||
expect(formatRelativeTime(ago(1 * HOUR + MINUTE), NOW)).toBe('vor 1 Stunde')
|
||||
expect(formatRelativeTime(ago(5 * HOUR), NOW)).toBe('vor 5 Stunden')
|
||||
})
|
||||
|
||||
it('nennt ab gestern die Uhrzeit — «vor 19 Stunden» ordnet niemand einem Arbeitstag zu', () => {
|
||||
expect(formatRelativeTime(ago(30 * HOUR), NOW)).toMatch(/^gestern \d{2}:\d{2}$/)
|
||||
})
|
||||
|
||||
it('nennt weiter zurückliegende Ereignisse mit Datum', () => {
|
||||
expect(formatRelativeTime(ago(9 * DAY), NOW)).toMatch(/^\d{2}\.\d{2}\. \d{2}:\d{2}$/)
|
||||
})
|
||||
|
||||
it('bleibt bei unbrauchbarem Zeitstempel stumm statt «Invalid Date» zu zeigen', () => {
|
||||
expect(formatRelativeTime('kein datum', NOW)).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Belegschaft der Startseite', () => {
|
||||
it('führt alle sechs in verbindlicher Reihenfolge, Giorgio zuletzt', () => {
|
||||
const staff = buildAgentPresence(input(), NOW)
|
||||
expect(staff.map(p => p.agentId)).toEqual(['ferdi', 'bruno', 'livia', 'nora', 'sina', 'giorgio'])
|
||||
})
|
||||
|
||||
it('übernimmt Personalien aus dem Personalblatt', () => {
|
||||
const staff = buildAgentPresence(
|
||||
input({ agents: [dossier('ferdi', { department: 'Bewirtschaftung', personnelNumber: 'PO-ZD-02' })] }),
|
||||
NOW,
|
||||
)
|
||||
const ferdi = staff.find(p => p.agentId === 'ferdi')!
|
||||
expect(ferdi.department).toBe('Bewirtschaftung')
|
||||
expect(ferdi.personnelNumber).toBe('PO-ZD-02')
|
||||
expect(ferdi.autonomy).toBe('Autonom')
|
||||
})
|
||||
|
||||
it('gibt Giorgio Personalien und Stufe aus der Rollendefinition — er hat kein Dossier', () => {
|
||||
const giorgio = buildAgentPresence(input(), NOW).find(p => p.agentId === 'giorgio')!
|
||||
expect(giorgio.department).toBe('Personalwesen')
|
||||
expect(giorgio.personnelNumber).toBe('PO-ZD-01')
|
||||
expect(giorgio.autonomy).toBe('Vorgesetzter')
|
||||
})
|
||||
|
||||
it('zeigt einen pausierten Mitarbeitenden als nicht im Dienst', () => {
|
||||
const staff = buildAgentPresence(
|
||||
input({ agents: [dossier('sina', { status: AgentStatus.PAUSED })] }),
|
||||
NOW,
|
||||
)
|
||||
expect(staff.find(p => p.agentId === 'sina')!.onDuty).toBe(false)
|
||||
// Wer kein Dossier hat, gilt als im Dienst — sonst stünde die halbe
|
||||
// Belegschaft nur deshalb still, weil Stammdaten fehlen.
|
||||
expect(staff.find(p => p.agentId === 'bruno')!.onDuty).toBe(true)
|
||||
})
|
||||
|
||||
it('nimmt je Mitarbeitendem den jüngsten Protokolleintrag', () => {
|
||||
const staff = buildAgentPresence(
|
||||
input({
|
||||
protocol: [
|
||||
entry('livia', 5 * DAY, 'Alter Eintrag'),
|
||||
entry('livia', 20 * MINUTE, 'Jüngster Eintrag'),
|
||||
entry('livia', 2 * DAY, 'Mittlerer Eintrag'),
|
||||
],
|
||||
}),
|
||||
NOW,
|
||||
)
|
||||
const livia = staff.find(p => p.agentId === 'livia')!
|
||||
expect(livia.lastAction?.title).toBe('Jüngster Eintrag')
|
||||
expect(livia.lastAction?.when).toBe('vor 20 Min.')
|
||||
})
|
||||
|
||||
it('lässt die letzte Handlung offen, statt eine zu erfinden', () => {
|
||||
const staff = buildAgentPresence(input(), NOW)
|
||||
expect(staff.every(p => p.lastAction === undefined)).toBe(true)
|
||||
})
|
||||
|
||||
it('ignoriert Protokolleinträge ohne Zuordnung — sie gehören keinem Mitarbeitenden', () => {
|
||||
const staff = buildAgentPresence(
|
||||
input({ protocol: [{ timestamp: ago(MINUTE), title: 'Verbindung entfernt' } as AgentPresenceInput['protocol'][number]] }),
|
||||
NOW,
|
||||
)
|
||||
expect(staff.every(p => p.lastAction === undefined)).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,103 @@
|
||||
import type { AgentPresence } from '../domain/agentPresence'
|
||||
import type { AgentWorkload } from '../domain/agentWorkload'
|
||||
import type { AgentProtocolEntry } from '../domain/agentProtocol'
|
||||
import type { TeamAgent } from '../domain/teamAgent'
|
||||
import { AgentStatus } from '../domain/teamAgent'
|
||||
import { AGENT_SUPERVISOR, AGENT_WORKSPACES } from '../lib/agentWorkspaces'
|
||||
import { AGENT_AUTONOMY_LABELS } from '../lib/constants'
|
||||
|
||||
/**
|
||||
* Setzt die Belegschaftsansicht der Startseite zusammen (Runde 5).
|
||||
*
|
||||
* Reine Geschäftslogik ausserhalb von React (CLAUDE.md §4.2): welcher Eintrag
|
||||
* als «letzte Handlung» gilt und wie eine Zeitangabe in Alltagssprache lautet,
|
||||
* sind fachliche Festlegungen. Der Stichzeitpunkt ist ein Parameter — dieselbe
|
||||
* Datenlage ergibt damit immer dasselbe Ergebnis und lässt sich prüfen.
|
||||
*
|
||||
* Giorgio steht bewusst am Ende: er ist Vorgesetzter, kein sechster
|
||||
* Sachbearbeiter, und sein Klick führt nicht auf eine Arbeitsseite, sondern in
|
||||
* die Personalverwaltung.
|
||||
*/
|
||||
|
||||
const MINUTE = 60 * 1000
|
||||
const HOUR = 60 * MINUTE
|
||||
const DAY = 24 * HOUR
|
||||
|
||||
/**
|
||||
* Zeitangabe in Alltagssprache.
|
||||
*
|
||||
* Bewusst grob: «vor 12 Min.» ist für einen Menschen brauchbarer als
|
||||
* «14:32:07». Ab gestern steht die Uhrzeit dabei, weil «vor 19 Stunden»
|
||||
* niemand in einen Arbeitstag einordnen kann.
|
||||
*/
|
||||
export function formatRelativeTime(iso: string, now: number): string {
|
||||
const t = new Date(iso).getTime()
|
||||
if (!Number.isFinite(t)) return ''
|
||||
|
||||
const diff = now - t
|
||||
if (diff < 0) return 'gerade eben'
|
||||
if (diff < MINUTE) return 'gerade eben'
|
||||
if (diff < HOUR) return `vor ${Math.floor(diff / MINUTE)} Min.`
|
||||
if (diff < DAY) {
|
||||
const hours = Math.floor(diff / HOUR)
|
||||
return `vor ${hours} ${hours === 1 ? 'Stunde' : 'Stunden'}`
|
||||
}
|
||||
|
||||
const date = new Date(t)
|
||||
const time = date.toLocaleTimeString('de-CH', { hour: '2-digit', minute: '2-digit' })
|
||||
if (diff < 2 * DAY) return `gestern ${time}`
|
||||
return date.toLocaleDateString('de-CH', { day: '2-digit', month: '2-digit' }) + ` ${time}`
|
||||
}
|
||||
|
||||
export interface AgentPresenceInput {
|
||||
agents: TeamAgent[]
|
||||
protocol: AgentProtocolEntry[]
|
||||
workload: Record<string, AgentWorkload>
|
||||
}
|
||||
|
||||
/**
|
||||
* @param now Stichzeitpunkt in Millisekunden — als Parameter, nicht aus der
|
||||
* Systemuhr, damit das Ergebnis reproduzierbar bleibt.
|
||||
*/
|
||||
export function buildAgentPresence(
|
||||
{ agents, protocol, workload }: AgentPresenceInput,
|
||||
now: number = Date.now(),
|
||||
): AgentPresence[] {
|
||||
// Jüngster Protokolleintrag je Mitarbeitendem. Ein Durchgang über die Liste
|
||||
// statt einer Suche je Agent — das Protokoll wächst, die Belegschaft nicht.
|
||||
const latest = new Map<string, AgentProtocolEntry>()
|
||||
for (const entry of protocol) {
|
||||
if (!entry.agentId) continue
|
||||
const known = latest.get(entry.agentId)
|
||||
if (!known || entry.timestamp > known.timestamp) latest.set(entry.agentId, entry)
|
||||
}
|
||||
|
||||
const byId = new Map(agents.map(a => [a.id, a]))
|
||||
const roster = [...AGENT_WORKSPACES, AGENT_SUPERVISOR]
|
||||
|
||||
return roster.map(member => {
|
||||
const dossier = byId.get(member.id)
|
||||
const action = latest.get(member.id)
|
||||
|
||||
return {
|
||||
agentId: member.id,
|
||||
name: member.name,
|
||||
role: member.role,
|
||||
// Personalblatt zuerst; für Giorgio, der keines hat, die Angaben aus
|
||||
// der zentralen Rollendefinition.
|
||||
department: dossier?.department ?? member.department ?? '',
|
||||
personnelNumber: dossier?.personnelNumber ?? member.personnelNumber ?? '',
|
||||
path: member.path,
|
||||
// Ohne Personalblatt gilt jemand als im Dienst: die Belegschaftsansicht
|
||||
// soll niemanden fälschlich als pausiert zeigen, nur weil ein Dossier fehlt.
|
||||
onDuty: dossier ? dossier.status === AgentStatus.ACTIVE : true,
|
||||
// Giorgio führt die Belegschaft und entscheidet nichts allein über
|
||||
// Objekte — seine Stufe ergibt sich aus der Rolle, nicht aus einem Dossier.
|
||||
autonomy: dossier ? (AGENT_AUTONOMY_LABELS[dossier.autonomyLevel] ?? '') : 'Vorgesetzter',
|
||||
workload: workload[member.id],
|
||||
lastAction: action
|
||||
? { title: action.title, when: formatRelativeTime(action.timestamp, now) }
|
||||
: undefined,
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -95,12 +95,16 @@ export function computeAgentWorkload(
|
||||
count: overdue,
|
||||
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(
|
||||
|
||||
Reference in New Issue
Block a user