Klick auf den PO oeffnet ein Modal, in dem sich die Projekte (Owner/Repo + Farbe), aus denen der Codeberg-Sync liest, hinzufuegen, bearbeiten und loeschen lassen - Icon-Schaltflaechen mit Tooltip statt Textbuttons. Die Projektliste war bisher eine Umgebungsvariable (CODEBERG_REPOS, Issue #26) und wandert dafuer in eine neue Tabelle repo_configs (Migration 003_repo_configs.sql, geseedet mit den bisherigen drei Default-Projekten); CodebergSyncService liest sie ueber RepoConfigRepository bei jedem Lauf frisch ein, damit eine Aenderung im laufenden --watch-Prozess ohne Neustart wirkt. Neue Endpunkte GET/POST/PATCH/DELETE /api/v1/repo-configs, Schreiben hinter X-Admin-Key wie die Agenten-Registrierung; das Frontend fragt den Key bei der ersten Aenderung per Prompt ab und merkt ihn sich im lokalen Speicher. office-modal.js bekommt dafuer einen Oeffnen-Token (isCurrentModal()), damit ein verspaetet aufloesender Request nach dem Oeffnen eines anderen Modals nicht dessen Inhalt ueberschreibt. Schliesst #27
1488 lines
49 KiB
JavaScript
1488 lines
49 KiB
JavaScript
// Zeichnet die Buero-Szene als Vanilla-SVG in den Container #office-scene und haelt sie mit
|
||
// echten Daten aktuell (Phase 3/4: Board/Monitor-Wand live, Phase 5: Agenten-Animation, siehe
|
||
// Visualization.md Abschnitt 9). Reine DOM-Glue-Logik - Layoutberechnung/Datenmodelle stecken
|
||
// testbar in office-scene.js.
|
||
//
|
||
// Architektur-Hinweis fuer die Animation (Phase 5): Board, Monitor-Wand und Schreibtisch-
|
||
// Moebel werden bei jedem Update komplett neu gezeichnet (kein Animationsbedarf dort), die
|
||
// Agenten-Avatare dagegen sind stabile DOM-Knoten pro Agent-ID, deren transform-Attribut nur
|
||
// aktualisiert wird - so kann CSS transition den Laufweg animieren, statt bei jedem Update
|
||
// hart zu springen.
|
||
|
||
import {
|
||
ROLES,
|
||
AGENT_LOCATIONS,
|
||
buildBoardColumnLayout,
|
||
buildDeskLayout,
|
||
buildSystemTiles,
|
||
buildEventLines,
|
||
aggregateTicketsByColumn,
|
||
groupTicketsByColumn,
|
||
buildTicketModalContent,
|
||
mapEventForDisplay,
|
||
buildEventModalContent,
|
||
buildSystemModalContent,
|
||
buildKpiTiles,
|
||
buildKpiModalContent,
|
||
mapAgentsToDesks,
|
||
normalizeAgentForDisplay,
|
||
resolveAgentVisualState,
|
||
isAgentPresent,
|
||
pickIdleActivity,
|
||
AnimationQueue,
|
||
detectDoorVisits,
|
||
resolveDoorVisitVisual,
|
||
advanceDoorVisitStage,
|
||
DOOR_VISIT_STAGES,
|
||
detectUnassignedTicketChanges,
|
||
advancePoVisitStage,
|
||
PO_VISIT_STAGES,
|
||
PO_AGENT_ID,
|
||
PO_DISPLAY_NAME,
|
||
resolveReloadIntervalSeconds,
|
||
nextCountdownSeconds,
|
||
} from "./office-scene.js";
|
||
import {
|
||
DEMO_TICKET_COUNTS,
|
||
DEMO_AGENTS,
|
||
DEMO_SYSTEMS,
|
||
DEMO_EVENTS,
|
||
DEMO_KPI_PLACEHOLDER,
|
||
} from "./demo-data.js";
|
||
import {
|
||
fetchTickets,
|
||
fetchEvents,
|
||
fetchSystems,
|
||
fetchKpis,
|
||
fetchAgents,
|
||
fetchRepoConfigs,
|
||
createRepoConfig,
|
||
updateRepoConfig,
|
||
deleteRepoConfig,
|
||
getStoredAdminKey,
|
||
setStoredAdminKey,
|
||
} from "./office-api.js";
|
||
import { startRealtimeUpdates } from "./office-realtime.js";
|
||
import { openModal, isCurrentModal } from "./office-modal.js";
|
||
|
||
const SVG_NS = "http://www.w3.org/2000/svg";
|
||
|
||
const VIEW_W = 1920;
|
||
const VIEW_H = 1080;
|
||
const WALL_BAND_H = 420;
|
||
|
||
const BOARD_FRAME = { x: 60, y: 40, w: 1120, h: 340 };
|
||
const MONITOR_WALL = { x: 1240, y: 40, w: 620, h: 340 };
|
||
const DOOR = { w: 40, h: 220, y: 700 };
|
||
const DESK = { w: 220, h: 110 };
|
||
const DESK_ROW_Y = [540, 840];
|
||
const DESK_COL_X = [232, 644, 1056, 1468];
|
||
const COFFEE_CORNER = { x: 960, y: 780 };
|
||
|
||
// Fester Platz des Product Owner (Issue #19): bewusst ausserhalb des dynamischen 8er-Rasters
|
||
// (DESK_COL_X/DESK_ROW_Y), in der freien Nische zwischen Board/Monitor-Wand und der ersten
|
||
// Schreibtischreihe - so kollidiert er nie mit einem echten, ueber die API registrierten Agenten.
|
||
const PO_DESK = { x: 870, y: 380, w: 180 };
|
||
|
||
const NOTE_W = 96;
|
||
const NOTE_H = 50;
|
||
const NOTE_STEP_Y = 56;
|
||
|
||
// Feste "Aufenthaltsorte" fuer die Agenten-Avatare (Phase 5), abgeleitet aus den Rahmen der
|
||
// jeweiligen Wandelemente - der Avatar steht direkt davor.
|
||
const BOARD_FEET = { x: BOARD_FRAME.x + BOARD_FRAME.w / 2, y: BOARD_FRAME.y + BOARD_FRAME.h + 40 };
|
||
const MONITOR_FEET = { x: MONITOR_WALL.x + MONITOR_WALL.w / 2, y: MONITOR_WALL.y + MONITOR_WALL.h + 40 };
|
||
const COFFEE_FEET = { x: COFFEE_CORNER.x, y: COFFEE_CORNER.y + 26 };
|
||
// Ruheplatz der Tuerbesucher (Phase 6, F2): direkt vor der linken Tuer, wie schon der
|
||
// Ueberlaufplatz fuer Agenten ohne Schreibtisch (siehe agentPlacements()).
|
||
const DOOR_FEET = { x: 20, y: DOOR.y + DOOR.h / 2 };
|
||
// Ruheplatz des PO an seinem festen Platz (Issue #19), analog zu den Fuss-Positionen der
|
||
// regulaeren Schreibtische (deskSeat().feet).
|
||
const PO_DESK_FEET = { x: PO_DESK.x + PO_DESK.w / 2, y: PO_DESK.y + 40 + DESK.h + 30 };
|
||
|
||
const IDLE_MIN_DELAY_MS = 15000;
|
||
const IDLE_MAX_DELAY_MS = 40000;
|
||
const IDLE_ACTIVITY_DURATION_MS = 8000;
|
||
const DOOR_VISIT_STAGE_DURATION_MS = 2200;
|
||
|
||
function el(tag, attrs = {}, children = []) {
|
||
const node = document.createElementNS(SVG_NS, tag);
|
||
for (const [key, value] of Object.entries(attrs)) {
|
||
if (value !== null && value !== undefined) {
|
||
node.setAttribute(key, value);
|
||
}
|
||
}
|
||
for (const child of children) {
|
||
node.appendChild(child);
|
||
}
|
||
return node;
|
||
}
|
||
|
||
function text(content, attrs = {}) {
|
||
const node = el("text", attrs);
|
||
node.textContent = content;
|
||
return node;
|
||
}
|
||
|
||
function renderRoom() {
|
||
const g = el("g", { class: "office-room" });
|
||
g.appendChild(el("rect", { x: 0, y: 0, width: VIEW_W, height: VIEW_H, class: "floor" }));
|
||
g.appendChild(el("rect", { x: 0, y: 0, width: VIEW_W, height: WALL_BAND_H, class: "wall" }));
|
||
g.appendChild(el("rect", { x: 0, y: 0, width: 40, height: VIEW_H, class: "wall wall-side" }));
|
||
g.appendChild(el("rect", { x: VIEW_W - 40, y: 0, width: 40, height: VIEW_H, class: "wall wall-side" }));
|
||
g.appendChild(el("rect", { x: 0, y: DOOR.y, width: DOOR.w, height: DOOR.h, class: "door door-left" }));
|
||
g.appendChild(
|
||
el("rect", { x: VIEW_W - DOOR.w, y: DOOR.y, width: DOOR.w, height: DOOR.h, class: "door door-right" })
|
||
);
|
||
g.appendChild(renderCoffeeCorner());
|
||
return g;
|
||
}
|
||
|
||
// Kaffee-Ecke als fester Raum-Prop zwischen den Schreibtischreihen (Ziel fuer den
|
||
// "at_coffee_corner"-Zustand, Vision-Text: "gehen in eine Kaffee-Ecke").
|
||
function renderCoffeeCorner() {
|
||
const { x, y } = COFFEE_CORNER;
|
||
const g = el("g", { class: "coffee-corner" });
|
||
g.appendChild(el("rect", { x: x - 34, y: y - 20, width: 68, height: 40, rx: 8, class: "coffee-table" }));
|
||
g.appendChild(el("circle", { cx: x - 14, cy: y - 26, r: 8, class: "coffee-cup" }));
|
||
g.appendChild(el("circle", { cx: x + 14, cy: y - 26, r: 8, class: "coffee-cup" }));
|
||
g.appendChild(text("☕", { x, y: y - 34, class: "coffee-icon", "text-anchor": "middle" }));
|
||
return g;
|
||
}
|
||
|
||
function renderStackBadge(x, y, count) {
|
||
const g = el("g", { class: "note-stack" });
|
||
[6, 3, 0].forEach((offset, i) => {
|
||
g.appendChild(
|
||
el("rect", {
|
||
x: x - offset,
|
||
y: y - offset,
|
||
width: NOTE_W,
|
||
height: NOTE_H,
|
||
rx: 6,
|
||
class: `note note-stack-layer-${i}`,
|
||
})
|
||
);
|
||
});
|
||
const cx = x + NOTE_W / 2;
|
||
const cy = y + NOTE_H / 2;
|
||
g.appendChild(el("circle", { cx, cy, r: 20, class: "note-stack-badge" }));
|
||
g.appendChild(
|
||
text(String(count), { x: cx, y: cy + 7, class: "note-stack-count", "text-anchor": "middle" })
|
||
);
|
||
return g;
|
||
}
|
||
|
||
/**
|
||
* @param {Record<string, number>} ticketCounts
|
||
* @param {(columnKey: string) => void} onColumnClick
|
||
*/
|
||
function renderBoard(ticketCounts, onColumnClick) {
|
||
const columns = buildBoardColumnLayout(ticketCounts);
|
||
const g = el("g", { class: "board" });
|
||
g.appendChild(el("rect", { ...BOARD_FRAME, rx: 10, class: "board-frame" }));
|
||
g.appendChild(
|
||
text("Kanban-Board", { x: BOARD_FRAME.x + 16, y: BOARD_FRAME.y - 14, class: "wall-label" })
|
||
);
|
||
|
||
const columnWidth = BOARD_FRAME.w / columns.length;
|
||
|
||
columns.forEach((column, index) => {
|
||
const columnX = BOARD_FRAME.x + index * columnWidth;
|
||
const colGroup = el("g", { class: `board-column board-column-${column.key} clickable` });
|
||
colGroup.appendChild(
|
||
el("rect", {
|
||
x: columnX,
|
||
y: BOARD_FRAME.y,
|
||
width: columnWidth,
|
||
height: BOARD_FRAME.h,
|
||
class: "board-column-hit",
|
||
})
|
||
);
|
||
colGroup.addEventListener("click", () => onColumnClick(column.key));
|
||
|
||
if (index > 0) {
|
||
colGroup.appendChild(
|
||
el("line", {
|
||
x1: columnX,
|
||
y1: BOARD_FRAME.y,
|
||
x2: columnX,
|
||
y2: BOARD_FRAME.y + BOARD_FRAME.h,
|
||
class: "board-column-divider",
|
||
})
|
||
);
|
||
}
|
||
|
||
colGroup.appendChild(
|
||
text(column.label, {
|
||
x: columnX + columnWidth / 2,
|
||
y: BOARD_FRAME.y + 26,
|
||
class: "board-column-label",
|
||
"text-anchor": "middle",
|
||
})
|
||
);
|
||
|
||
const noteX = columnX + (columnWidth - NOTE_W) / 2;
|
||
let noteY = BOARD_FRAME.y + 44;
|
||
|
||
for (let i = 0; i < column.visibleNotes; i++) {
|
||
colGroup.appendChild(
|
||
el("rect", { x: noteX, y: noteY, width: NOTE_W, height: NOTE_H, rx: 6, class: "note" })
|
||
);
|
||
noteY += NOTE_STEP_Y;
|
||
}
|
||
|
||
if (column.stackCount > 0) {
|
||
colGroup.appendChild(renderStackBadge(noteX, noteY, column.stackCount));
|
||
}
|
||
|
||
g.appendChild(colGroup);
|
||
});
|
||
|
||
return g;
|
||
}
|
||
|
||
function renderEventMonitor(frame, events) {
|
||
const lines = buildEventLines(events).map(mapEventForDisplay);
|
||
const g = el("g", { class: "monitor monitor-events" });
|
||
g.appendChild(el("rect", { ...frame, rx: 8, class: "monitor-frame" }));
|
||
g.appendChild(text("Events", { x: frame.x + 12, y: frame.y - 10, class: "wall-label" }));
|
||
g.appendChild(
|
||
el("rect", {
|
||
x: frame.x + 10,
|
||
y: frame.y + 10,
|
||
width: frame.w - 20,
|
||
height: frame.h - 20,
|
||
class: "monitor-screen",
|
||
})
|
||
);
|
||
|
||
lines.forEach((eventLine, index) => {
|
||
g.appendChild(
|
||
text(`${eventLine.time} ${eventLine.system}: ${eventLine.message}`, {
|
||
x: frame.x + 18,
|
||
y: frame.y + 38 + index * 26,
|
||
class: `monitor-event-line severity-${eventLine.severity}`,
|
||
})
|
||
);
|
||
});
|
||
|
||
return g;
|
||
}
|
||
|
||
function renderSystemMonitor(frame, systems) {
|
||
const tiles = buildSystemTiles(systems);
|
||
const g = el("g", { class: "monitor monitor-systems" });
|
||
g.appendChild(el("rect", { ...frame, rx: 8, class: "monitor-frame" }));
|
||
g.appendChild(text("Systeme", { x: frame.x + 12, y: frame.y - 10, class: "wall-label" }));
|
||
g.appendChild(
|
||
el("rect", {
|
||
x: frame.x + 10,
|
||
y: frame.y + 10,
|
||
width: frame.w - 20,
|
||
height: frame.h - 20,
|
||
class: "monitor-screen",
|
||
})
|
||
);
|
||
|
||
const cols = 3;
|
||
const tileMargin = 8;
|
||
const gridX = frame.x + 18;
|
||
const gridY = frame.y + 18;
|
||
const tileSize = (frame.w - 36 - tileMargin * (cols - 1)) / cols;
|
||
|
||
tiles.forEach((system, index) => {
|
||
const col = index % cols;
|
||
const row = Math.floor(index / cols);
|
||
const tileX = gridX + col * (tileSize + tileMargin);
|
||
const tileY = gridY + row * (tileSize + tileMargin);
|
||
|
||
const tileGroup = el("g", { class: `system-tile status-${system.status}` });
|
||
tileGroup.appendChild(
|
||
el("rect", { x: tileX, y: tileY, width: tileSize, height: tileSize, rx: 6, class: "system-tile-bg" })
|
||
);
|
||
tileGroup.appendChild(
|
||
el("rect", { x: tileX + 10, y: tileY + 10, width: tileSize - 20, height: 14, rx: 2, class: "system-tile-bar" })
|
||
);
|
||
tileGroup.appendChild(
|
||
el("rect", { x: tileX + 10, y: tileY + 30, width: tileSize - 20, height: 14, rx: 2, class: "system-tile-bar" })
|
||
);
|
||
tileGroup.appendChild(el("circle", { cx: tileX + 18, cy: tileY + 17, r: 3, class: "system-tile-led" }));
|
||
tileGroup.appendChild(el("circle", { cx: tileX + 18, cy: tileY + 37, r: 3, class: "system-tile-led" }));
|
||
tileGroup.appendChild(
|
||
text(system.name, {
|
||
x: tileX + tileSize / 2,
|
||
y: tileY + tileSize - 8,
|
||
class: "system-tile-label",
|
||
"text-anchor": "middle",
|
||
})
|
||
);
|
||
g.appendChild(tileGroup);
|
||
});
|
||
|
||
return g;
|
||
}
|
||
|
||
function renderSparkline(points, width, height, x, y) {
|
||
const g = el("g", { class: "kpi-sparkline", transform: `translate(${x}, ${y})` });
|
||
g.appendChild(el("rect", { x: 0, y: 0, width, height, class: "kpi-sparkline-bg" }));
|
||
if (points) {
|
||
g.appendChild(el("polyline", { points, class: "kpi-sparkline-line" }));
|
||
}
|
||
return g;
|
||
}
|
||
|
||
function renderKpiMonitor(frame, kpis) {
|
||
const tiles = buildKpiTiles(kpis);
|
||
const g = el("g", { class: "monitor monitor-kpi" });
|
||
g.appendChild(el("rect", { ...frame, rx: 8, class: "monitor-frame" }));
|
||
g.appendChild(text("KPIs", { x: frame.x + 12, y: frame.y - 10, class: "wall-label" }));
|
||
g.appendChild(
|
||
el("rect", {
|
||
x: frame.x + 10,
|
||
y: frame.y + 10,
|
||
width: frame.w - 20,
|
||
height: frame.h - 20,
|
||
class: `monitor-screen ${tiles.length === 0 ? "monitor-screen-placeholder" : ""}`,
|
||
})
|
||
);
|
||
|
||
if (tiles.length === 0) {
|
||
renderPlaceholderText(g, frame, DEMO_KPI_PLACEHOLDER);
|
||
return g;
|
||
}
|
||
|
||
const rowH = (frame.h - 30) / tiles.length;
|
||
tiles.forEach((tile, index) => {
|
||
const rowY = frame.y + 20 + index * rowH;
|
||
g.appendChild(
|
||
text(`${tile.label}: ${tile.currentValue ?? "–"} ${tile.unit}`.trim(), {
|
||
x: frame.x + 16,
|
||
y: rowY + 14,
|
||
class: "kpi-tile-label",
|
||
})
|
||
);
|
||
g.appendChild(renderSparkline(tile.sparklinePoints, frame.w - 32, rowH - 26, frame.x + 16, rowY + 20));
|
||
});
|
||
|
||
return g;
|
||
}
|
||
|
||
function renderPlaceholderText(g, frame, placeholder) {
|
||
const words = placeholder.split(" ");
|
||
const lineHeight = 22;
|
||
const maxCharsPerLine = 16;
|
||
const lines = [];
|
||
let current = "";
|
||
for (const word of words) {
|
||
const candidate = current ? `${current} ${word}` : word;
|
||
if (candidate.length > maxCharsPerLine && current) {
|
||
lines.push(current);
|
||
current = word;
|
||
} else {
|
||
current = candidate;
|
||
}
|
||
}
|
||
if (current) lines.push(current);
|
||
|
||
const startY = frame.y + frame.h / 2 - ((lines.length - 1) * lineHeight) / 2;
|
||
lines.forEach((line, index) => {
|
||
g.appendChild(
|
||
text(line, {
|
||
x: frame.x + frame.w / 2,
|
||
y: startY + index * lineHeight,
|
||
class: "monitor-placeholder-text",
|
||
"text-anchor": "middle",
|
||
})
|
||
);
|
||
});
|
||
}
|
||
|
||
/**
|
||
* @param {{onEventsClick: () => void, onSystemsClick: () => void, onKpiClick: () => void}} handlers
|
||
*/
|
||
function renderMonitorWall(systems, events, kpis, handlers) {
|
||
const g = el("g", { class: "monitor-wall" });
|
||
const gap = 20;
|
||
const monitorWidth = (MONITOR_WALL.w - gap * 2) / 3;
|
||
const frames = [0, 1, 2].map((i) => ({
|
||
x: MONITOR_WALL.x + i * (monitorWidth + gap),
|
||
y: MONITOR_WALL.y,
|
||
w: monitorWidth,
|
||
h: MONITOR_WALL.h,
|
||
}));
|
||
|
||
const eventsGroup = renderEventMonitor(frames[0], events);
|
||
eventsGroup.classList.add("clickable");
|
||
eventsGroup.addEventListener("click", handlers.onEventsClick);
|
||
|
||
const systemsGroup = renderSystemMonitor(frames[1], systems);
|
||
systemsGroup.classList.add("clickable");
|
||
systemsGroup.addEventListener("click", handlers.onSystemsClick);
|
||
|
||
const kpiGroup = renderKpiMonitor(frames[2], kpis);
|
||
kpiGroup.classList.add("clickable");
|
||
kpiGroup.addEventListener("click", handlers.onKpiClick);
|
||
|
||
g.appendChild(eventsGroup);
|
||
g.appendChild(systemsGroup);
|
||
g.appendChild(kpiGroup);
|
||
|
||
return g;
|
||
}
|
||
|
||
function deskSeat(deskIndex) {
|
||
const col = deskIndex % DESK_COL_X.length;
|
||
const row = Math.floor(deskIndex / DESK_COL_X.length);
|
||
const x = DESK_COL_X[col];
|
||
const y = DESK_ROW_Y[row];
|
||
return {
|
||
origin: { x, y },
|
||
feet: { x: x + DESK.w / 2, y: y + 40 + DESK.h + 30 },
|
||
};
|
||
}
|
||
|
||
function deskScreenPoseClass(pose) {
|
||
switch (pose) {
|
||
case "working":
|
||
return "pose-working";
|
||
default:
|
||
return "pose-idle";
|
||
}
|
||
}
|
||
|
||
// Zeichnet nur die Moebel/Deko + den Namen des Schreibtisch-Besitzers - die eigentliche
|
||
// Person ist ein eigenstaendiger, beweglicher Avatar (siehe renderAgentAvatar/
|
||
// updateAgentsLayer), damit sie zum Board/Monitor "laufen" kann, ohne den Schreibtisch neu
|
||
// zeichnen zu muessen. Parametrisiert ueber origin/width, damit sowohl die regulaeren
|
||
// Schreibtische (deskSeat()) als auch der feste PO-Platz (PO_DESK, Issue #19) dieselbe
|
||
// Zeichenlogik/Deko (inkl. rollenabhaengiger desk.deco) nutzen koennen.
|
||
function renderDeskAt(origin, agent, width = DESK.w) {
|
||
const role = agent ? ROLES[agent.role] : null;
|
||
const g = el("g", {
|
||
class: `desk ${agent ? "desk-occupied" : "desk-empty"}`,
|
||
transform: `translate(${origin.x}, ${origin.y})`,
|
||
});
|
||
|
||
g.appendChild(el("rect", { x: 0, y: 40, width, height: DESK.h, rx: 6, class: "desk-table" }));
|
||
g.appendChild(
|
||
el("rect", {
|
||
x: 20,
|
||
y: 10,
|
||
width: 60,
|
||
height: 40,
|
||
rx: 4,
|
||
class: `desk-screen ${agent ? deskScreenPoseClass(agent.pose) : ""}`,
|
||
})
|
||
);
|
||
g.appendChild(el("circle", { cx: width / 2, cy: 40 + DESK.h + 30, r: 22, class: "desk-chair" }));
|
||
|
||
if (role) {
|
||
if (role.deco === "star") {
|
||
g.appendChild(
|
||
el("path", {
|
||
d: `M${width - 50},20 l4,10 11,0 -9,7 3,11 -9,-7 -9,7 3,-11 -9,-7 11,0 z`,
|
||
class: "desk-deco desk-deco-star",
|
||
})
|
||
);
|
||
} else if (role.deco === "flower") {
|
||
const cx = width - 45;
|
||
const cy = 22;
|
||
g.appendChild(
|
||
el("g", { class: "desk-deco desk-deco-flower" }, [
|
||
el("circle", { cx: cx - 7, cy, r: 6 }),
|
||
el("circle", { cx: cx + 7, cy, r: 6 }),
|
||
el("circle", { cx, cy: cy - 7, r: 6 }),
|
||
el("circle", { cx, cy: cy + 7, r: 6 }),
|
||
el("circle", { cx, cy, r: 4, class: "desk-deco-flower-center" }),
|
||
])
|
||
);
|
||
} else if (role.deco === "check") {
|
||
g.appendChild(
|
||
el("path", { d: `M${width - 58},24 l8,8 16,-18`, class: "desk-deco desk-deco-check" })
|
||
);
|
||
}
|
||
|
||
g.appendChild(
|
||
text(agent.name, {
|
||
x: width / 2,
|
||
y: 40 + DESK.h + 74,
|
||
class: "desk-name",
|
||
"text-anchor": "middle",
|
||
})
|
||
);
|
||
} else {
|
||
g.appendChild(
|
||
text("frei", {
|
||
x: width / 2,
|
||
y: 40 + DESK.h + 74,
|
||
class: "desk-name desk-name-empty",
|
||
"text-anchor": "middle",
|
||
})
|
||
);
|
||
}
|
||
|
||
return g;
|
||
}
|
||
|
||
function renderDesk({ deskIndex, agent }) {
|
||
return renderDeskAt(deskSeat(deskIndex).origin, agent);
|
||
}
|
||
|
||
/**
|
||
* @param {Array<object>} desks - reguläre 8er-Raster-Plaetze (echte Agenten, F1).
|
||
* @param {object|null} poAgent - {name: "PO", role: "product_owner", pose} bei erreichbarer
|
||
* API, sonst null (dann zeigt der feste PO-Platz "frei", Issue #19).
|
||
* @param {(() => void)|null} onPoClick - oeffnet das Projekte-Modal (Issue #27); nur gesetzt
|
||
* bei erreichbarer API, da das Modal echte Daten braucht und im Demo-Fallback ohne
|
||
* funktionierende API nichts zu konfigurieren waere.
|
||
*/
|
||
function renderDesks(desks, poAgent, onPoClick = null) {
|
||
const g = el("g", { class: "desks" });
|
||
desks.forEach((desk) => g.appendChild(renderDesk(desk)));
|
||
const poDeskGroup = renderDeskAt(PO_DESK, poAgent, PO_DESK.w);
|
||
poDeskGroup.classList.add("po-desk");
|
||
if (onPoClick) {
|
||
poDeskGroup.classList.add("clickable");
|
||
poDeskGroup.setAttribute("aria-label", "Projekte des Codeberg-Sync verwalten");
|
||
poDeskGroup.addEventListener("click", onPoClick);
|
||
}
|
||
g.appendChild(poDeskGroup);
|
||
return g;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------------------
|
||
// Phase 5 - Agenten-Avatare (bewegliche Schicht, siehe Architektur-Hinweis oben)
|
||
// ---------------------------------------------------------------------------------------
|
||
|
||
const agentAvatarElements = new Map();
|
||
const idleCosmeticTimers = new Map();
|
||
|
||
function feetForLocation(location, homeFeet) {
|
||
switch (location) {
|
||
case AGENT_LOCATIONS.BOARD:
|
||
return BOARD_FEET;
|
||
case AGENT_LOCATIONS.MONITOR_EVENTS:
|
||
case AGENT_LOCATIONS.MONITOR_SYSTEMS:
|
||
case AGENT_LOCATIONS.MONITOR_KPI:
|
||
return MONITOR_FEET;
|
||
case AGENT_LOCATIONS.COFFEE_CORNER:
|
||
return COFFEE_FEET;
|
||
case AGENT_LOCATIONS.DOOR:
|
||
return DOOR_FEET;
|
||
case AGENT_LOCATIONS.DESK:
|
||
default:
|
||
return homeFeet;
|
||
}
|
||
}
|
||
|
||
function poseExtras(pose) {
|
||
const nodes = [];
|
||
if (pose === "walking") {
|
||
nodes.push(el("rect", { x: -10, y: 14, width: 8, height: 18, rx: 3, class: "person-leg person-leg-left" }));
|
||
nodes.push(el("rect", { x: 2, y: 14, width: 8, height: 18, rx: 3, class: "person-leg person-leg-right" }));
|
||
} else if (pose === "coffee") {
|
||
nodes.push(el("rect", { x: 16, y: -30, width: 10, height: 10, rx: 2, class: "person-prop-cup" }));
|
||
} else if (pose === "reading_newspaper") {
|
||
nodes.push(el("rect", { x: -16, y: -48, width: 32, height: 18, rx: 1, class: "person-prop-newspaper" }));
|
||
} else if (pose === "talking") {
|
||
nodes.push(el("circle", { cx: 20, cy: -40, r: 7, class: "person-prop-speech" }));
|
||
}
|
||
return nodes;
|
||
}
|
||
|
||
function createAgentAvatarElement() {
|
||
const g = el("g", { class: "agent-avatar" });
|
||
g.appendChild(el("circle", { cx: 0, cy: -34, r: 16, class: "person-head" }));
|
||
g.appendChild(el("rect", { x: -22, y: -20, width: 44, height: 34, rx: 12, class: "person-body" }));
|
||
g.appendChild(el("g", { class: "person-extras" }));
|
||
g.appendChild(text("", { x: 0, y: 46, class: "avatar-name", "text-anchor": "middle" }));
|
||
return g;
|
||
}
|
||
|
||
function updateAgentAvatarElement(g, agent, feet, showName) {
|
||
g.setAttribute("transform", `translate(${feet.x}, ${feet.y})`);
|
||
g.setAttribute("class", `agent-avatar pose-${agent.pose}`);
|
||
g.dataset.state = agent.state || "idle";
|
||
|
||
const role = ROLES[agent.role];
|
||
const body = g.querySelector(".person-body");
|
||
body.style.setProperty("--role-color", role ? role.accentColor : "#94a3b8");
|
||
|
||
const extras = g.querySelector(".person-extras");
|
||
extras.replaceChildren(...poseExtras(agent.pose));
|
||
|
||
const nameEl = g.querySelector(".avatar-name");
|
||
nameEl.textContent = showName ? agent.name : "";
|
||
}
|
||
|
||
/**
|
||
* Aktualisiert die Agenten-Avatar-Schicht per DOM-Diff (statt Neuzeichnen), damit CSS
|
||
* transition das transform-Attribut zwischen zwei Aufenthaltsorten weich animiert
|
||
* (Detailantworten F9: "Laufwege und Zustandswechsel realistisch animieren").
|
||
*
|
||
* @param {SVGGElement} layer
|
||
* @param {Array<{agent: object, homeFeet: {x:number,y:number}}>} placements
|
||
*/
|
||
function updateAgentsLayer(layer, placements) {
|
||
const seen = new Set();
|
||
|
||
placements.forEach(({ agent, homeFeet }, index) => {
|
||
const id = String(agent.id ?? `guest-${index}`);
|
||
seen.add(id);
|
||
|
||
let node = agentAvatarElements.get(id);
|
||
if (!node) {
|
||
node = createAgentAvatarElement();
|
||
agentAvatarElements.set(id, node);
|
||
layer.appendChild(node);
|
||
}
|
||
|
||
const feet = feetForLocation(agent.location, homeFeet);
|
||
const showName = agent.location !== AGENT_LOCATIONS.DESK;
|
||
updateAgentAvatarElement(node, agent, feet, showName);
|
||
});
|
||
|
||
for (const [id, node] of agentAvatarElements) {
|
||
if (!seen.has(id)) {
|
||
node.remove();
|
||
agentAvatarElements.delete(id);
|
||
clearIdleCosmeticTimer(id);
|
||
}
|
||
}
|
||
}
|
||
|
||
function clearIdleCosmeticTimer(agentId) {
|
||
const handle = idleCosmeticTimers.get(agentId);
|
||
if (handle) {
|
||
window.clearTimeout(handle.timeoutId);
|
||
idleCosmeticTimers.delete(agentId);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Rein kosmetische, zufaellige idle-Aktivitaeten (F8: "Nur idle Aktivitaeten ... steuert das
|
||
* System selber random" - wird bewusst NICHT an die API gemeldet, ein echter Zustandswechsel
|
||
* kommt ausschliesslich vom Agenten selbst). Greift nur, solange die API weiterhin "idle"
|
||
* meldet; sobald ein echter Zustand reinkommt, wird der Timer verworfen (siehe
|
||
* scheduleIdleCosmeticsForCurrentAgents()).
|
||
*/
|
||
function scheduleIdleCosmetic(agentId, node) {
|
||
if (idleCosmeticTimers.has(agentId)) {
|
||
return;
|
||
}
|
||
|
||
const delay = IDLE_MIN_DELAY_MS + Math.random() * (IDLE_MAX_DELAY_MS - IDLE_MIN_DELAY_MS);
|
||
const timeoutId = window.setTimeout(() => {
|
||
if (!agentAvatarElements.has(agentId) || node.dataset.state !== "idle") {
|
||
idleCosmeticTimers.delete(agentId);
|
||
return;
|
||
}
|
||
|
||
const activity = pickIdleActivity();
|
||
const visual = resolveAgentVisualState(activity);
|
||
const homeFeet = node.__homeFeet;
|
||
node.setAttribute("transform", `translate(${feetForLocation(visual.location, homeFeet).x}, ${feetForLocation(visual.location, homeFeet).y})`);
|
||
node.setAttribute("class", `agent-avatar pose-${visual.pose}`);
|
||
node.querySelector(".person-extras").replaceChildren(...poseExtras(visual.pose));
|
||
node.querySelector(".avatar-name").textContent = node.__agentName || "";
|
||
|
||
window.setTimeout(() => {
|
||
if (agentAvatarElements.has(agentId) && node.dataset.state === "idle") {
|
||
const restingPose = node.__idlePose || "idle";
|
||
node.setAttribute("transform", `translate(${homeFeet.x}, ${homeFeet.y})`);
|
||
node.setAttribute("class", `agent-avatar pose-${restingPose}`);
|
||
node.querySelector(".person-extras").replaceChildren(...poseExtras(restingPose));
|
||
node.querySelector(".avatar-name").textContent = "";
|
||
}
|
||
idleCosmeticTimers.delete(agentId);
|
||
}, IDLE_ACTIVITY_DURATION_MS);
|
||
}, delay);
|
||
|
||
idleCosmeticTimers.set(agentId, { timeoutId });
|
||
}
|
||
|
||
function scheduleIdleCosmeticsForCurrentAgents(placements) {
|
||
placements.forEach(({ agent, homeFeet }, index) => {
|
||
const id = String(agent.id ?? `guest-${index}`);
|
||
const node = agentAvatarElements.get(id);
|
||
if (!node) {
|
||
return;
|
||
}
|
||
node.__homeFeet = homeFeet;
|
||
node.__agentName = agent.name;
|
||
node.__idlePose = agent.pose || "idle";
|
||
|
||
if (agent.state === "idle" || !agent.state) {
|
||
scheduleIdleCosmetic(id, node);
|
||
} else {
|
||
clearIdleCosmeticTimer(id);
|
||
}
|
||
});
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------------------
|
||
// Phase 6 - Tuerbesucher (F2: Rolle "user" kommt nur anlassbezogen durch die linke Tuer,
|
||
// siehe detectDoorVisits() in office-scene.js). Laeuft unabhaengig vom Daten-Refresh-Takt
|
||
// ueber eigene Timer, damit ein Besuch auch dann sauber zu Ende animiert, wenn zwischendurch
|
||
// ein neuer SSE-/Polling-Refresh hereinkommt (siehe repaintAgentsLayer()).
|
||
// ---------------------------------------------------------------------------------------
|
||
|
||
let currentAgentsLayer = null;
|
||
let currentRegularPlacements = [];
|
||
const doorVisitors = new Map();
|
||
let doorVisitCounter = 0;
|
||
|
||
function doorVisitVisual(visitor) {
|
||
if (visitor.atDoor) {
|
||
return { location: AGENT_LOCATIONS.DOOR, pose: "walking" };
|
||
}
|
||
return resolveDoorVisitVisual(visitor.stage);
|
||
}
|
||
|
||
function doorVisitorPlacements() {
|
||
return Array.from(doorVisitors.values()).map((visitor) => {
|
||
const visual = doorVisitVisual(visitor);
|
||
return {
|
||
agent: { id: visitor.id, name: "User", role: "user", pose: visual.pose, location: visual.location },
|
||
homeFeet: DOOR_FEET,
|
||
};
|
||
});
|
||
}
|
||
|
||
// Kurzes visuelles Feedback (Tuer "leuchtet" auf), waehrend mindestens ein Besucher am
|
||
// Tuerplatz steht - rein kosmetisch, siehe .door-active in office.css.
|
||
function updateDoorActiveClass() {
|
||
if (!currentAgentsLayer) return;
|
||
const svg = currentAgentsLayer.closest("svg");
|
||
const doorLeft = svg && svg.querySelector(".door-left");
|
||
if (!doorLeft) return;
|
||
const anyAtDoor = Array.from(doorVisitors.values()).some(
|
||
(visitor) => doorVisitVisual(visitor).location === AGENT_LOCATIONS.DOOR
|
||
);
|
||
doorLeft.classList.toggle("door-active", anyAtDoor);
|
||
}
|
||
|
||
function repaintAgentsLayer() {
|
||
if (!currentAgentsLayer) return;
|
||
updateAgentsLayer(currentAgentsLayer, [
|
||
...currentRegularPlacements,
|
||
...poPlacement(),
|
||
...doorVisitorPlacements(),
|
||
]);
|
||
updateDoorActiveClass();
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------------------
|
||
// Product Owner (Issue #19): fester Platz + Avatar, der bei "niemandem zugeordneten"
|
||
// Ticket-Statuswechseln zum Board laeuft (siehe detectUnassignedTicketChanges() in
|
||
// office-scene.js). Anders als die Tuerbesucher (mehrere gleichzeitig moeglich, Phase 6) gibt
|
||
// es nur einen PO - poState ist daher ein einzelner Zustand statt einer Map.
|
||
// ---------------------------------------------------------------------------------------
|
||
|
||
let poIsLive = false;
|
||
let poState = "idle";
|
||
let poVisitTimeoutId = null;
|
||
|
||
function poPlacement() {
|
||
if (!poIsLive) {
|
||
return [];
|
||
}
|
||
const visual = resolveAgentVisualState(poState);
|
||
return [
|
||
{
|
||
agent: { id: PO_AGENT_ID, name: PO_DISPLAY_NAME, role: "product_owner", pose: visual.pose, location: visual.location },
|
||
homeFeet: PO_DESK_FEET,
|
||
},
|
||
];
|
||
}
|
||
|
||
function advancePoVisit() {
|
||
const nextStage = advancePoVisitStage(poState);
|
||
if (nextStage === null) {
|
||
poState = "idle";
|
||
repaintAgentsLayer();
|
||
return;
|
||
}
|
||
poState = nextStage;
|
||
repaintAgentsLayer();
|
||
poVisitTimeoutId = window.setTimeout(advancePoVisit, DOOR_VISIT_STAGE_DURATION_MS);
|
||
}
|
||
|
||
// Mehrere gleichzeitige unzugeordnete Aenderungen (z.B. mehrere Tickets erhalten kurz
|
||
// hintereinander das Label "automate") loesen bewusst nur einen Besuch aus, statt den PO fuer
|
||
// jede einzelne Aenderung hin- und herlaufen zu lassen - ein laufender Besuch wird zu Ende
|
||
// animiert, statt unterbrochen zu werden.
|
||
function triggerPoVisit() {
|
||
if (!poIsLive || poState !== "idle") {
|
||
return;
|
||
}
|
||
poState = PO_VISIT_STAGES[0];
|
||
repaintAgentsLayer();
|
||
poVisitTimeoutId = window.setTimeout(advancePoVisit, DOOR_VISIT_STAGE_DURATION_MS);
|
||
}
|
||
|
||
function advanceDoorVisit(id) {
|
||
const visitor = doorVisitors.get(id);
|
||
if (!visitor) {
|
||
return;
|
||
}
|
||
|
||
if (visitor.atDoor) {
|
||
visitor.atDoor = false;
|
||
repaintAgentsLayer();
|
||
window.setTimeout(() => advanceDoorVisit(id), DOOR_VISIT_STAGE_DURATION_MS);
|
||
return;
|
||
}
|
||
|
||
const nextStage = advanceDoorVisitStage(visitor.stage);
|
||
if (nextStage === null) {
|
||
doorVisitors.delete(id);
|
||
repaintAgentsLayer();
|
||
return;
|
||
}
|
||
|
||
visitor.stage = nextStage;
|
||
repaintAgentsLayer();
|
||
window.setTimeout(() => advanceDoorVisit(id), DOOR_VISIT_STAGE_DURATION_MS);
|
||
}
|
||
|
||
/** @param {{type: string, ticketId: number}} visit */
|
||
function triggerDoorVisit(visit) {
|
||
doorVisitCounter += 1;
|
||
const id = `door-visit-${visit.type}-${visit.ticketId}-${doorVisitCounter}`;
|
||
doorVisitors.set(id, { id, stage: DOOR_VISIT_STAGES[0], atDoor: true });
|
||
repaintAgentsLayer();
|
||
window.setTimeout(() => advanceDoorVisit(id), DOOR_VISIT_STAGE_DURATION_MS);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------------------
|
||
// Modal-Inhalte (Phase 3/4 - Board-/Monitor-Klick, siehe Visualization.md 4.5)
|
||
// ---------------------------------------------------------------------------------------
|
||
|
||
function renderTicketModalBody(body, content) {
|
||
if (content.items.length === 0) {
|
||
body.appendChild(emptyMessage("Keine Tickets in dieser Spalte."));
|
||
return;
|
||
}
|
||
const list = document.createElement("ul");
|
||
list.className = "office-modal-list";
|
||
for (const item of content.items) {
|
||
const li = document.createElement("li");
|
||
li.className = "office-modal-item";
|
||
|
||
const heading = document.createElement("div");
|
||
heading.className = "office-modal-item-heading";
|
||
const titleEl = document.createElement("strong");
|
||
titleEl.textContent = item.title;
|
||
heading.appendChild(titleEl);
|
||
if (item.type) {
|
||
const badge = document.createElement("span");
|
||
badge.className = "office-modal-badge";
|
||
badge.textContent = item.type;
|
||
if (item.color) badge.style.backgroundColor = item.color;
|
||
heading.appendChild(badge);
|
||
}
|
||
li.appendChild(heading);
|
||
|
||
if (item.description) {
|
||
const desc = document.createElement("p");
|
||
desc.textContent = item.description;
|
||
li.appendChild(desc);
|
||
}
|
||
if (item.link) {
|
||
const a = document.createElement("a");
|
||
a.href = item.link;
|
||
a.target = "_blank";
|
||
a.rel = "noopener noreferrer";
|
||
a.textContent = "Auf Codeberg öffnen";
|
||
li.appendChild(a);
|
||
}
|
||
list.appendChild(li);
|
||
}
|
||
body.appendChild(list);
|
||
}
|
||
|
||
function renderEventModalBody(body, content) {
|
||
if (content.items.length === 0) {
|
||
body.appendChild(emptyMessage("Keine Events vorhanden."));
|
||
return;
|
||
}
|
||
const list = document.createElement("ul");
|
||
list.className = "office-modal-list";
|
||
for (const item of content.items) {
|
||
const li = document.createElement("li");
|
||
li.className = `office-modal-item severity-${item.severity}`;
|
||
li.textContent = `${item.time} · ${item.name}${item.systemId ? ` (${item.systemId})` : ""}: ${item.message}`;
|
||
list.appendChild(li);
|
||
}
|
||
body.appendChild(list);
|
||
}
|
||
|
||
function renderSystemModalBody(body, content) {
|
||
if (content.items.length === 0) {
|
||
body.appendChild(emptyMessage("Keine Systeme gemeldet."));
|
||
return;
|
||
}
|
||
const list = document.createElement("ul");
|
||
list.className = "office-modal-list";
|
||
for (const item of content.items) {
|
||
const li = document.createElement("li");
|
||
li.className = `office-modal-item status-${item.status}`;
|
||
li.textContent = `${item.name} (${item.type}) – ${item.status === "ok" ? "OK" : "Fehler"}${item.detail ? `: ${item.detail}` : ""}`;
|
||
list.appendChild(li);
|
||
}
|
||
body.appendChild(list);
|
||
}
|
||
|
||
function renderKpiModalBody(body, content) {
|
||
if (content.items.length === 0) {
|
||
body.appendChild(emptyMessage("Noch keine KPIs gemeldet – kommt in einer späteren Phase."));
|
||
return;
|
||
}
|
||
const list = document.createElement("ul");
|
||
list.className = "office-modal-list";
|
||
for (const item of content.items) {
|
||
const li = document.createElement("li");
|
||
li.className = "office-modal-item";
|
||
const label = document.createElement("div");
|
||
label.textContent = `${item.label}: ${item.currentValue ?? "–"} ${item.unit}`;
|
||
li.appendChild(label);
|
||
|
||
const svg = document.createElementNS(SVG_NS, "svg");
|
||
svg.setAttribute("viewBox", "0 0 320 80");
|
||
svg.setAttribute("class", "kpi-modal-sparkline");
|
||
svg.appendChild(el("polyline", { points: item.sparklinePoints, class: "kpi-sparkline-line" }));
|
||
li.appendChild(svg);
|
||
|
||
list.appendChild(li);
|
||
}
|
||
body.appendChild(list);
|
||
}
|
||
|
||
function emptyMessage(message) {
|
||
const p = document.createElement("p");
|
||
p.className = "office-modal-empty";
|
||
p.textContent = message;
|
||
return p;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------------------
|
||
// Projekte-Modal (Issue #27: Klick auf den PO -> Codeberg-Projekte des Sync verwalten)
|
||
//
|
||
// Anders als die uebrigen Modals (reine Anzeige eines einmal gebauten content-Objekts) ist
|
||
// dieses Modal interaktiv (Hinzufuegen/Bearbeiten/Loeschen mit Server-Rundtrip) und muss sich
|
||
// nach jeder Aenderung selbst neu zeichnen, ohne das <dialog> zu schliessen. openModal() ruft
|
||
// den renderBody-Callback dafuer nur einmal mit dem echten Body-Element auf; das Element wird
|
||
// hier in repoModalBodyEl gemerkt und fuer spaetere Refreshes wiederverwendet.
|
||
// ---------------------------------------------------------------------------------------
|
||
|
||
const REPO_CONFIG_ICONS = {
|
||
add: "M12 5v14M5 12h14",
|
||
edit: "M12 20h9M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4Z",
|
||
delete: "M4 7h16M9 7V4.5A1.5 1.5 0 0 1 10.5 3h3A1.5 1.5 0 0 1 15 4.5V7m2 0-.8 12.1a2 2 0 0 1-2 1.9H9.8a2 2 0 0 1-2-1.9L7 7",
|
||
confirm: "M20 6 9 17l-5-5",
|
||
cancel: "M18 6 6 18M6 6l12 12",
|
||
};
|
||
|
||
let repoModalBodyEl = null;
|
||
let repoModalToken = null;
|
||
let repoModalRepos = [];
|
||
let repoModalMode = null; // null | "new" | <id des bearbeiteten Projekts>
|
||
let repoModalError = "";
|
||
|
||
function iconButton(kind, title, onClick, extraClass = "") {
|
||
const button = document.createElement("button");
|
||
button.type = "button";
|
||
button.className = `icon-button ${extraClass}`.trim();
|
||
button.title = title;
|
||
button.setAttribute("aria-label", title);
|
||
button.appendChild(
|
||
el("svg", { viewBox: "0 0 24 24", class: "icon-button-svg", "aria-hidden": "true" }, [
|
||
el("path", {
|
||
d: REPO_CONFIG_ICONS[kind],
|
||
fill: "none",
|
||
stroke: "currentColor",
|
||
"stroke-width": "2",
|
||
"stroke-linecap": "round",
|
||
"stroke-linejoin": "round",
|
||
}),
|
||
])
|
||
);
|
||
button.addEventListener("click", onClick);
|
||
return button;
|
||
}
|
||
|
||
function openRepoConfigModal() {
|
||
repoModalRepos = [];
|
||
repoModalMode = null;
|
||
repoModalError = "";
|
||
repoModalToken = openModal({ title: "Projekte" }, (body) => {
|
||
repoModalBodyEl = body;
|
||
renderRepoConfigModal();
|
||
});
|
||
loadRepoConfigModal(repoModalToken);
|
||
}
|
||
|
||
async function loadRepoConfigModal(token) {
|
||
let repos = null;
|
||
let error = "";
|
||
try {
|
||
repos = await fetchRepoConfigs();
|
||
} catch {
|
||
error = "Projekte konnten nicht geladen werden.";
|
||
}
|
||
|
||
if (!isCurrentModal(token)) {
|
||
return;
|
||
}
|
||
if (repos !== null) {
|
||
repoModalRepos = repos;
|
||
}
|
||
repoModalError = error;
|
||
renderRepoConfigModal();
|
||
}
|
||
|
||
function renderRepoConfigModal() {
|
||
const body = repoModalBodyEl;
|
||
if (!body) return;
|
||
body.replaceChildren();
|
||
|
||
if (repoModalError) {
|
||
const errorEl = document.createElement("p");
|
||
errorEl.className = "office-modal-error";
|
||
errorEl.textContent = repoModalError;
|
||
body.appendChild(errorEl);
|
||
}
|
||
|
||
if (repoModalRepos.length === 0 && repoModalMode !== "new") {
|
||
body.appendChild(emptyMessage("Keine Projekte konfiguriert."));
|
||
} else {
|
||
const list = document.createElement("ul");
|
||
list.className = "office-modal-list repo-config-list";
|
||
for (const repo of repoModalRepos) {
|
||
list.appendChild(
|
||
repoModalMode === repo.id ? renderRepoConfigForm(repo) : renderRepoConfigRow(repo)
|
||
);
|
||
}
|
||
body.appendChild(list);
|
||
}
|
||
|
||
if (repoModalMode === "new") {
|
||
let list = body.querySelector(".repo-config-list");
|
||
if (!list) {
|
||
list = document.createElement("ul");
|
||
list.className = "office-modal-list repo-config-list";
|
||
body.appendChild(list);
|
||
}
|
||
list.appendChild(renderRepoConfigForm(null));
|
||
} else {
|
||
const addButton = iconButton("add", "Projekt hinzufügen", () => {
|
||
repoModalMode = "new";
|
||
renderRepoConfigModal();
|
||
});
|
||
addButton.classList.add("repo-config-add-button");
|
||
body.appendChild(addButton);
|
||
}
|
||
}
|
||
|
||
function renderRepoConfigRow(repo) {
|
||
const li = document.createElement("li");
|
||
li.className = "office-modal-item repo-config-item";
|
||
|
||
const swatch = document.createElement("span");
|
||
swatch.className = "repo-config-swatch";
|
||
swatch.style.backgroundColor = repo.color;
|
||
li.appendChild(swatch);
|
||
|
||
const label = document.createElement("span");
|
||
label.className = "repo-config-slug";
|
||
label.textContent = `${repo.owner}/${repo.repo}`;
|
||
li.appendChild(label);
|
||
|
||
const actions = document.createElement("span");
|
||
actions.className = "repo-config-actions";
|
||
actions.appendChild(
|
||
iconButton("edit", "Projekt bearbeiten", () => {
|
||
repoModalMode = repo.id;
|
||
renderRepoConfigModal();
|
||
})
|
||
);
|
||
actions.appendChild(
|
||
iconButton("delete", "Projekt löschen", () => handleRepoConfigDelete(repo))
|
||
);
|
||
li.appendChild(actions);
|
||
|
||
return li;
|
||
}
|
||
|
||
function renderRepoConfigForm(repo) {
|
||
const li = document.createElement("li");
|
||
li.className = "office-modal-item repo-config-item repo-config-form";
|
||
|
||
const ownerInput = document.createElement("input");
|
||
ownerInput.type = "text";
|
||
ownerInput.placeholder = "Owner, z. B. fds2610";
|
||
ownerInput.value = repo?.owner ?? "";
|
||
ownerInput.setAttribute("aria-label", "Owner");
|
||
|
||
const repoInput = document.createElement("input");
|
||
repoInput.type = "text";
|
||
repoInput.placeholder = "Repo, z. B. monitor";
|
||
repoInput.value = repo?.repo ?? "";
|
||
repoInput.setAttribute("aria-label", "Repo-Name");
|
||
|
||
const colorInput = document.createElement("input");
|
||
colorInput.type = "color";
|
||
colorInput.value = repo?.color ?? "#999999";
|
||
colorInput.setAttribute("aria-label", "Farbe");
|
||
colorInput.title = "Farbe";
|
||
|
||
const fields = document.createElement("span");
|
||
fields.className = "repo-config-form-fields";
|
||
fields.append(ownerInput, repoInput, colorInput);
|
||
li.appendChild(fields);
|
||
|
||
const actions = document.createElement("span");
|
||
actions.className = "repo-config-actions";
|
||
|
||
const save = () =>
|
||
handleRepoConfigSave(repo, {
|
||
owner: ownerInput.value.trim(),
|
||
repo: repoInput.value.trim(),
|
||
color: colorInput.value,
|
||
});
|
||
|
||
actions.appendChild(iconButton("confirm", "Speichern", save));
|
||
actions.appendChild(
|
||
iconButton("cancel", "Abbrechen", () => {
|
||
repoModalMode = null;
|
||
renderRepoConfigModal();
|
||
})
|
||
);
|
||
li.appendChild(actions);
|
||
|
||
// Enter in einem der Textfelder wuerde sonst das <form method="dialog"> ueber dessen
|
||
// regulaeren Submit-Button (den Schliessen-"×" im Modal-Header) einreichen und das ganze
|
||
// Modal schliessen, statt nur dieses Formular zu speichern.
|
||
const saveOnEnter = (event) => {
|
||
if (event.key === "Enter") {
|
||
event.preventDefault();
|
||
save();
|
||
}
|
||
};
|
||
ownerInput.addEventListener("keydown", saveOnEnter);
|
||
repoInput.addEventListener("keydown", saveOnEnter);
|
||
|
||
window.setTimeout(() => ownerInput.focus(), 0);
|
||
|
||
return li;
|
||
}
|
||
|
||
async function handleRepoConfigSave(existing, data) {
|
||
const token = repoModalToken;
|
||
if (!data.owner || !data.repo) {
|
||
repoModalError = 'Felder "Owner" und "Repo" sind erforderlich.';
|
||
renderRepoConfigModal();
|
||
return;
|
||
}
|
||
|
||
const ok = await withAdminKey((adminKey) =>
|
||
existing ? updateRepoConfig(existing.id, data, adminKey) : createRepoConfig(data, adminKey)
|
||
);
|
||
if (!isCurrentModal(token)) {
|
||
return;
|
||
}
|
||
if (!ok) {
|
||
renderRepoConfigModal();
|
||
return;
|
||
}
|
||
|
||
repoModalMode = null;
|
||
await loadRepoConfigModal(token);
|
||
}
|
||
|
||
async function handleRepoConfigDelete(repo) {
|
||
const token = repoModalToken;
|
||
if (!window.confirm(`Projekt "${repo.owner}/${repo.repo}" wirklich entfernen?`)) {
|
||
return;
|
||
}
|
||
|
||
const ok = await withAdminKey((adminKey) => deleteRepoConfig(repo.id, adminKey));
|
||
if (!isCurrentModal(token)) {
|
||
return;
|
||
}
|
||
if (!ok) {
|
||
renderRepoConfigModal();
|
||
return;
|
||
}
|
||
|
||
await loadRepoConfigModal(token);
|
||
}
|
||
|
||
/**
|
||
* Fuehrt eine schreibende Aktion mit dem Admin-Key aus (aus dem lokalen Speicher oder frisch
|
||
* per Prompt erfragt, siehe office-api.js). Bei 401 wird der gemerkte Key verworfen, damit
|
||
* der naechste Versuch erneut fragt statt denselben ungueltigen Wert wiederzuverwenden.
|
||
*
|
||
* @param {(adminKey: string) => Promise<unknown>} action
|
||
* @returns {Promise<boolean>}
|
||
*/
|
||
async function withAdminKey(action) {
|
||
let adminKey = getStoredAdminKey();
|
||
if (!adminKey) {
|
||
adminKey = window.prompt("Admin-Key für Änderungen an Projekten:") || "";
|
||
if (!adminKey) {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
try {
|
||
await action(adminKey);
|
||
setStoredAdminKey(adminKey);
|
||
return true;
|
||
} catch (err) {
|
||
if (err && err.isAuthError) {
|
||
setStoredAdminKey("");
|
||
repoModalError = "Admin-Key ungültig.";
|
||
} else {
|
||
repoModalError = err instanceof Error ? err.message : "Aktion fehlgeschlagen.";
|
||
}
|
||
return false;
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------------------
|
||
// Datenbeschaffung + Orchestrierung
|
||
// ---------------------------------------------------------------------------------------
|
||
|
||
async function loadLiveData() {
|
||
const [tickets, events, systems, kpis, agents] = await Promise.all([
|
||
fetchTickets(),
|
||
fetchEvents(),
|
||
fetchSystems(),
|
||
fetchKpis(),
|
||
fetchAgents(),
|
||
]);
|
||
return { tickets, events, systems, kpis, agents, isLive: true };
|
||
}
|
||
|
||
const animationQueue = new AnimationQueue({ minIntervalMs: 120 });
|
||
window.setInterval(() => animationQueue.processDue(), 50);
|
||
|
||
function flash(node) {
|
||
animationQueue.enqueue(() => {
|
||
if (!node.isConnected) return;
|
||
node.classList.add("just-updated");
|
||
window.setTimeout(() => node.classList.remove("just-updated"), 900);
|
||
});
|
||
}
|
||
|
||
export function renderOfficeScene(container, data = {}) {
|
||
const {
|
||
ticketCounts = DEMO_TICKET_COUNTS,
|
||
agents = DEMO_AGENTS,
|
||
systems = DEMO_SYSTEMS,
|
||
events = DEMO_EVENTS,
|
||
kpis = [],
|
||
tickets = [],
|
||
isLive = false,
|
||
} = data;
|
||
|
||
let svg = container.querySelector(".office-svg");
|
||
let firstRender = false;
|
||
|
||
if (!svg) {
|
||
firstRender = true;
|
||
svg = el("svg", {
|
||
viewBox: `0 0 ${VIEW_W} ${VIEW_H}`,
|
||
class: "office-svg",
|
||
role: "img",
|
||
"aria-label": "Buero-Visualisierung mit Kanban-Board, Monitor-Wand und Schreibtischen",
|
||
});
|
||
svg.appendChild(renderRoom());
|
||
svg.appendChild(el("g", { class: "agents-layer" }));
|
||
container.replaceChildren(svg);
|
||
agentAvatarElements.clear();
|
||
idleCosmeticTimers.forEach((handle) => window.clearTimeout(handle.timeoutId));
|
||
idleCosmeticTimers.clear();
|
||
doorVisitors.clear();
|
||
if (poVisitTimeoutId) window.clearTimeout(poVisitTimeoutId);
|
||
poVisitTimeoutId = null;
|
||
poState = "idle";
|
||
}
|
||
|
||
// Der PO gilt als anwesend, sobald die API erreichbar ist (isLive) - nicht ueber das
|
||
// is_present-Feld wie registrierte Agenten (Issue #19).
|
||
poIsLive = isLive;
|
||
|
||
const groupedTickets = groupTicketsByColumn(tickets);
|
||
|
||
const boardGroup = renderBoard(ticketCounts, (columnKey) => {
|
||
openModal(
|
||
buildTicketModalContent(columnKey, groupedTickets[columnKey] || []),
|
||
renderTicketModalBody
|
||
);
|
||
});
|
||
const existingBoard = svg.querySelector(".board");
|
||
if (existingBoard) {
|
||
existingBoard.replaceWith(boardGroup);
|
||
if (!firstRender) flash(boardGroup);
|
||
} else {
|
||
svg.insertBefore(boardGroup, svg.querySelector(".agents-layer"));
|
||
}
|
||
|
||
const wallGroup = renderMonitorWall(systems, events, kpis, {
|
||
onEventsClick: () => openModal(buildEventModalContent(events), renderEventModalBody),
|
||
onSystemsClick: () => openModal(buildSystemModalContent(systems), renderSystemModalBody),
|
||
onKpiClick: () => openModal(buildKpiModalContent(kpis), renderKpiModalBody),
|
||
});
|
||
const existingWall = svg.querySelector(".monitor-wall");
|
||
if (existingWall) {
|
||
existingWall.replaceWith(wallGroup);
|
||
if (!firstRender) flash(wallGroup);
|
||
} else {
|
||
svg.insertBefore(wallGroup, svg.querySelector(".agents-layer"));
|
||
}
|
||
|
||
const deskLayout = buildDeskLayout(normalizedDeskAgents(agents));
|
||
const poDeskAgent = isLive ? { name: PO_DISPLAY_NAME, role: "product_owner", pose: "idle" } : null;
|
||
const desksGroup = renderDesks(deskLayout, poDeskAgent, isLive ? openRepoConfigModal : null);
|
||
const existingDesks = svg.querySelector(".desks");
|
||
if (existingDesks) {
|
||
existingDesks.replaceWith(desksGroup);
|
||
} else {
|
||
svg.insertBefore(desksGroup, svg.querySelector(".agents-layer"));
|
||
}
|
||
|
||
const placements = agentPlacements(agents, deskLayout);
|
||
currentAgentsLayer = svg.querySelector(".agents-layer");
|
||
currentRegularPlacements = placements;
|
||
repaintAgentsLayer();
|
||
scheduleIdleCosmeticsForCurrentAgents(placements);
|
||
}
|
||
|
||
// Demo-Agenten (Phase 2) tragen bereits {name, role, pose} ohne desk_index/state - diese
|
||
// Helfer machen buildDeskLayout()/renderAgentAvatar() fuer beide Formen (Demo- und
|
||
// Live-API-Agenten) nutzbar, ohne die Demo-Daten anfassen zu muessen.
|
||
function normalizedDeskAgents(agents) {
|
||
const hasDeskIndex = agents.some((a) => a && typeof a.desk_index !== "undefined");
|
||
if (!hasDeskIndex) {
|
||
return agents;
|
||
}
|
||
return mapAgentsToDesks(agents).map((agent) => (agent ? normalizeAgentForDisplay(agent) : null));
|
||
}
|
||
|
||
function agentPlacements(agents, deskLayout) {
|
||
const hasDeskIndex = agents.some((a) => a && typeof a.desk_index !== "undefined");
|
||
|
||
if (!hasDeskIndex) {
|
||
// Demo-Daten: Positionsindex in der Liste == Schreibtisch-Index (Phase 2-Verhalten).
|
||
return deskLayout
|
||
.filter((d) => d.agent)
|
||
.map((d) => ({ agent: { ...d.agent, location: AGENT_LOCATIONS.DESK, state: "idle" }, homeFeet: deskSeat(d.deskIndex).feet }));
|
||
}
|
||
|
||
const placements = [];
|
||
const seatedIds = new Set();
|
||
deskLayout.forEach((desk) => {
|
||
if (desk.agent) {
|
||
seatedIds.add(desk.agent.id);
|
||
placements.push({ agent: desk.agent, homeFeet: deskSeat(desk.deskIndex).feet });
|
||
}
|
||
});
|
||
|
||
// Agenten ohne Schreibtisch (Rolle "user" oder alle Plaetze belegt, F2) bekommen einen
|
||
// Ruheplatz vor der linken Tuer zugewiesen, gestaffelt nebeneinander.
|
||
let overflowIndex = 0;
|
||
for (const raw of agents) {
|
||
if (seatedIds.has(raw.id)) continue;
|
||
if (!isAgentPresent(raw)) continue;
|
||
const agent = normalizeAgentForDisplay(raw);
|
||
const homeFeet = {
|
||
x: 70 + (overflowIndex % 3) * 26,
|
||
y: DOOR.y + 40 + Math.floor(overflowIndex / 3) * 40,
|
||
};
|
||
placements.push({ agent, homeFeet });
|
||
overflowIndex += 1;
|
||
}
|
||
|
||
return placements;
|
||
}
|
||
|
||
// Der Demo-Fallback unten sieht auf den ersten Blick wie eine echte Szene aus (fremde
|
||
// Agenten-Namen, erfundene Ticketzahlen, aber leere Detail-Modals). Damit das auf einem
|
||
// Produktiv-Monitor nicht fuer echte Daten gehalten wird, blendet der Fallback zusaetzlich
|
||
// einen roten Banner ein - und wieder aus, sobald die API antwortet.
|
||
function setApiBanner(visible) {
|
||
const banner = document.getElementById("api-status-banner");
|
||
if (banner) {
|
||
banner.hidden = !visible;
|
||
}
|
||
}
|
||
|
||
// Rein kosmetischer Countdown im Header (Issue #12): zaehlt unabhaengig vom tatsaechlichen
|
||
// SSE-/Polling-Takt von MONITOR_SYNC_INTERVAL (aus config.js, siehe office.html) auf 0
|
||
// herunter und startet danach von vorn - loest selbst keinen Reload aus.
|
||
function startReloadCountdown() {
|
||
const countdownEl = document.getElementById("reload-countdown");
|
||
if (!countdownEl) {
|
||
return;
|
||
}
|
||
|
||
const intervalSeconds = resolveReloadIntervalSeconds(window.MONITOR_SYNC_INTERVAL);
|
||
let remaining = intervalSeconds;
|
||
countdownEl.textContent = `${remaining}s`;
|
||
|
||
window.setInterval(() => {
|
||
remaining = nextCountdownSeconds(remaining, intervalSeconds);
|
||
countdownEl.textContent = `${remaining}s`;
|
||
}, 1000);
|
||
}
|
||
|
||
async function boot() {
|
||
const container = document.getElementById("office-scene");
|
||
if (!container) {
|
||
return;
|
||
}
|
||
|
||
// Vorheriger Ticket-Snapshot fuer detectDoorVisits() (Phase 6, F2) - bleibt null im
|
||
// Demo-Fallback (kein "echter" Anlass ohne Live-API) und wird nur bei erreichbarer API
|
||
// gepflegt, damit die Tuerbesuche ausschliesslich auf echten Zustandswechseln beruhen.
|
||
let previousTickets = null;
|
||
|
||
async function refresh() {
|
||
try {
|
||
const data = await loadLiveData();
|
||
if (previousTickets) {
|
||
detectDoorVisits(previousTickets, data.tickets).forEach(triggerDoorVisit);
|
||
if (detectUnassignedTicketChanges(previousTickets, data.tickets).length > 0) {
|
||
triggerPoVisit();
|
||
}
|
||
}
|
||
previousTickets = data.tickets;
|
||
renderOfficeScene(container, {
|
||
ticketCounts: aggregateTicketsByColumn(data.tickets),
|
||
tickets: data.tickets,
|
||
agents: data.agents,
|
||
systems: data.systems,
|
||
events: data.events,
|
||
kpis: data.kpis,
|
||
isLive: true,
|
||
});
|
||
setApiBanner(false);
|
||
} catch (err) {
|
||
console.warn("[office] Live-API nicht erreichbar, zeige Demo-Daten.", err);
|
||
renderOfficeScene(container);
|
||
setApiBanner(true);
|
||
}
|
||
}
|
||
|
||
await refresh();
|
||
startRealtimeUpdates(refresh);
|
||
}
|
||
|
||
document.addEventListener("DOMContentLoaded", () => {
|
||
boot();
|
||
startReloadCountdown();
|
||
});
|