Files
maxis-mission-control/public/app.js
T

490 lines
21 KiB
JavaScript

const columns = [
{ key: "offen", state: "Backlog", label: "Offen", color: "var(--st-queue)" },
{ key: "inProgress", state: "In progress", label: "In Bearbeitung", color: "var(--st-work)" },
{ key: "review", state: "Review", label: "Review", color: "#fb923c" },
{ key: "done", state: "Done", label: "Erledigt", color: "var(--st-work)" },
{ key: "blocked", state: "Blocked", label: "Blockiert", color: "var(--st-block)", blocked: true }
];
const states = columns.map((column) => column.state);
const priorities = ["High", "Medium", "Low"];
const assignees = [
{ value: "bao", label: "Bao" },
{ value: "iris", label: "Iris" },
{ value: "maxi", label: "Maxi" }
];
const app = document.querySelector("#app");
const state = {
user: null,
tasks: [],
settings: null,
openClaw: null,
view: "board",
dialogMode: null,
selectedTaskId: null,
draggedTaskId: null,
dragOverKey: null,
error: ""
};
async function api(path, options = {}) {
const response = await fetch(path, {
credentials: "same-origin",
headers: { "content-type": "application/json", ...(options.headers || {}) },
...options
});
const data = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(data.error || `HTTP ${response.status}`);
return data;
}
async function init() {
try {
const session = await api("/api/session");
state.user = session.user;
await Promise.all([loadTasks(), loadSettings()]);
renderApp();
} catch {
renderLogin();
}
}
function renderLogin() {
app.innerHTML = `
<section class="login-shell">
<form class="login-card" id="login-form">
<div class="login-brand">
<div class="brand-mark">MC</div>
<div><strong>MAXI</strong><span>Mission Control</span></div>
</div>
<div class="login-heading">
<span class="eyebrow">AUTHENTIFIZIERUNG</span>
<h1>Anmelden</h1>
<p>Maxis geschuetzte Kommandozentrale fuer Aufgaben und OpenClaw-Status.</p>
</div>
<label><span>E-Mail</span><input name="email" type="email" autocomplete="username" required autofocus></label>
<label><span>Passwort</span><input name="password" type="password" autocomplete="current-password" required></label>
<button type="submit">Anmelden</button>
<p class="login-error" id="login-error" role="alert"></p>
<footer>Gesicherte Sitzung - Maxi Branding bleibt erhalten.</footer>
</form>
</section>
`;
document.querySelector("#login-form").addEventListener("submit", async (event) => {
event.preventDefault();
const form = new FormData(event.currentTarget);
try {
const result = await api("/api/login", {
method: "POST",
body: JSON.stringify({ email: form.get("email"), password: form.get("password") })
});
state.user = result.user;
await Promise.all([loadTasks(), loadSettings()]);
renderApp();
} catch (error) {
document.querySelector("#login-error").textContent = error.message;
}
});
}
async function loadTasks() {
const result = await api("/api/tasks");
state.tasks = result.tasks.map(normalizeTask);
}
async function loadSettings() {
const result = await api("/api/settings");
state.settings = result.settings;
state.openClaw = result.openClaw;
}
function normalizeTask(task) {
return {
...task,
state: task.state === "In Progress" ? "In progress" : task.state,
priority: task.priority === "Normal" ? "Medium" : task.priority === "Urgent" ? "High" : task.priority,
assignee: task.assignee || "bao"
};
}
function renderApp() {
app.innerHTML = `
<section class="shell">
<aside class="sidebar">
<div class="brand">
<div class="brand-mark">MC</div>
<div><strong>MAXI</strong><span>mc.texisoft.com</span></div>
</div>
<nav class="nav">
<button data-view="board" class="${state.view === "board" ? "active" : ""}">Task Board <i>${state.tasks.length}</i></button>
<button data-view="settings" class="${state.view === "settings" ? "active" : ""}">Settings</button>
</nav>
<div class="sidebar-bottom">
<button id="logout">Abmelden</button>
<div class="owner">
<div class="avatar">MX</div>
<div><strong>${escapeHtml(state.user?.name || "Maxi")}</strong><span>${escapeHtml(state.user?.role || "Admin")}</span></div>
</div>
</div>
</aside>
<main>
<header class="topbar">
<button class="mobile-menu">Menu</button>
<div class="search">Mission Control <kbd>/</kbd></div>
<div class="top-actions">
<span class="connection ${state.openClaw?.ok ? "live" : "preview"}"><span class="status-dot ${state.openClaw?.ok ? "online" : "offline"}"></span>OpenClaw ${state.openClaw?.ok ? "live" : "pruefen"}</span>
<button class="refresh" id="refresh-board">Refresh</button>
</div>
</header>
<section class="content">${state.view === "settings" ? settingsView() : boardView()}</section>
</main>
</section>
${createModal()}
${detailModal()}
`;
document.querySelectorAll("[data-view]").forEach((button) => {
button.addEventListener("click", async () => {
state.view = button.dataset.view;
if (state.view === "settings") await loadSettings();
renderApp();
});
});
document.querySelector("#logout").addEventListener("click", async () => {
await api("/api/logout", { method: "POST" });
state.user = null;
renderLogin();
});
document.querySelector("#refresh-board").addEventListener("click", async () => {
await Promise.all([loadTasks(), loadSettings()]);
renderApp();
});
if (state.view === "board") bindBoard();
if (state.view === "settings") bindSettings();
}
function boardView() {
return `
<div class="board-wrap">
<div class="board-header">
<div>
<h1><span class="grad-text">Aufgaben</span></h1>
<p class="board-subtitle">Task Board - Uebersicht aller Arbeitspakete</p>
<div class="board-live-row">
<span class="live-pill ${state.openClaw?.ok ? "live-pill-connected" : "live-pill-degraded"}">${state.openClaw?.ok ? "Live" : "Polling"}</span>
<span class="live-meta">${state.tasks.length} Aufgaben</span>
</div>
</div>
<div class="board-header-actions">
<button class="create-btn" id="new-task"><span class="plus-icon">+</span>Neue Aufgabe</button>
</div>
</div>
<div class="board-columns">
${columns.map(column).join("")}
</div>
</div>
`;
}
function column(columnDef) {
const tasks = state.tasks.filter((task) => task.state === columnDef.state);
return `
<div class="col ${columnDef.blocked ? "col-blocked" : ""} ${state.dragOverKey === columnDef.key ? "drag-over" : ""}" data-state="${columnDef.state}" data-column="${columnDef.key}">
<div class="col-header">
<span class="col-icon" style="background:${columnDef.color};box-shadow:0 0 0 2px color-mix(in srgb, ${columnDef.color}, transparent 72%);"></span>
<span class="col-name">${columnDef.label}</span>
<span class="col-count">${tasks.length}</span>
</div>
<div class="col-cards">
${tasks.map(taskCard).join("") || `<div class="empty-col">${columnDef.blocked ? "Keine Blockierer" : "Keine Aufgaben"}</div>`}
</div>
</div>
`;
}
function taskCard(task) {
const blockedClass = task.state === "Blocked" ? "card-blocked" : "";
return `
<button type="button" class="card ${blockedClass}" data-card="${task.id}" draggable="true">
<div class="card-top">
<span class="prio-badge" style="color:${priorityColor(task.priority)};border-color:${priorityColor(task.priority)}">${priorityLabel(task.priority)}</span>
<span class="assignee ${assigneeClass(task.assignee)}">${assigneeLabel(task.assignee)}</span>
</div>
<div class="card-title">${escapeHtml(task.title)}</div>
${task.detail ? `<div class="card-preview">${escapeHtml(task.detail)}</div>` : ""}
<div class="card-meta">Update ${relativeTime(task.updatedAt || task.createdAt)}</div>
</button>
`;
}
function createModal() {
if (state.dialogMode !== "create") return "";
return `
<div class="modal-overlay" id="modal-overlay">
<div class="modal-card">
<div class="modal-header"><h2>Neue Aufgabe</h2><button class="modal-close" type="button" id="close-modal">&times;</button></div>
<form class="modal-form" id="create-form">
<div class="field"><label for="task-title">Titel <span class="req">*</span></label><input id="task-title" name="title" class="field-input" placeholder="Aufgabe beschreiben..." required autofocus></div>
<div class="field"><label for="task-detail">Beschreibung</label><textarea id="task-detail" name="detail" class="field-input field-textarea" rows="3" placeholder="Details zur Aufgabe..."></textarea></div>
<div class="field-row">
<div class="field"><label for="task-priority">Prioritaet</label><select id="task-priority" name="priority" class="field-input field-select">${priorities.map((p) => `<option ${p === "Medium" ? "selected" : ""}>${p}</option>`).join("")}</select></div>
<div class="field"><label for="task-assignee">Zugewiesen an</label><select id="task-assignee" name="assignee" class="field-input field-select">${assignees.map((a) => `<option value="${a.value}" ${a.value === "bao" ? "selected" : ""}>${a.label}</option>`).join("")}</select></div>
</div>
<p class="form-error" id="form-error"></p>
<div class="modal-actions"><button type="button" class="btn-cancel" id="cancel-modal">Abbrechen</button><button type="submit" class="btn-submit">Aufgabe erstellen</button></div>
</form>
</div>
</div>
`;
}
function detailModal() {
if (state.dialogMode !== "detail") return "";
const task = selectedTask();
if (!task) return "";
return `
<div class="detail-overlay" id="detail-overlay">
<aside class="detail-panel">
<div class="detail-topbar"><div class="detail-breadcrumb">Task Board / ${stateLabel(task.state)}</div><button class="detail-close" id="close-detail" type="button">x</button></div>
<div class="detail-content">
<section class="detail-main">
<div class="detail-title-block">
<span class="detail-state-pill ${statusTone(task.state)}">${stateLabel(task.state)}</span>
<input class="detail-title-input" name="title" form="detail-form" maxlength="240" value="${escapeAttr(task.title)}">
<div class="detail-meta-row"><span>#${escapeHtml(task.id.slice(0, 8))}</span><span>Aktualisiert ${formatDate(task.updatedAt, true)}</span><span>Erstellt ${formatDate(task.createdAt)}</span></div>
</div>
<div class="detail-section"><div class="detail-section-header">Beschreibung</div><textarea class="detail-textarea" name="detail" form="detail-form" rows="8" placeholder="Mehr Kontext, Akzeptanzkriterien oder Notizen...">${escapeHtml(task.detail || "")}</textarea></div>
<div class="detail-grid">
<section class="detail-section"><div class="detail-section-header">Unteraufgaben</div><div class="detail-empty">Noch keine Unteraufgaben verknuepft.</div></section>
<section class="detail-section"><div class="detail-section-header">Aktivitaet</div><div class="activity-list"><article class="activity-item"><div class="activity-dot"></div><div><div class="activity-message">Task in Maxi Mission Control aktualisiert.</div><div class="activity-time">${formatDate(task.updatedAt, true)}</div></div></article></div></section>
</div>
</section>
<aside class="detail-sidebar">
<form id="detail-form" class="sidebar-card">
<div class="sidebar-heading">Eigenschaften</div>
<label class="sidebar-field"><span>Status</span><select name="state" class="field-input field-select slim">${states.map((s) => `<option value="${s}" ${s === task.state ? "selected" : ""}>${stateLabel(s)}</option>`).join("")}</select></label>
<label class="sidebar-field"><span>Prioritaet</span><select name="priority" class="field-input field-select slim">${priorities.map((p) => `<option ${p === task.priority ? "selected" : ""}>${p}</option>`).join("")}</select></label>
<label class="sidebar-field"><span>Zustaendig</span><select name="assignee" class="field-input field-select slim">${assignees.map((a) => `<option value="${a.value}" ${a.value === (task.assignee || "bao") ? "selected" : ""}>${a.label}</option>`).join("")}</select></label>
</form>
<section class="sidebar-card subtle"><div class="sidebar-heading">Snapshot</div><dl class="snapshot-list"><div><dt>Quelle</dt><dd>Maxi</dd></div><div><dt>Prioritaet</dt><dd>${escapeHtml(task.priority)}</dd></div><div><dt>Zustaendig</dt><dd>${assigneeLabel(task.assignee)}</dd></div></dl></section>
<p class="detail-flash error" id="detail-error"></p>
<div class="detail-actions"><button type="button" class="btn-cancel" id="cancel-detail">Schliessen</button><button type="button" class="btn-submit" id="save-detail">Speichern</button><button type="button" class="btn-danger" id="delete-detail">Loeschen</button></div>
</aside>
</div>
</aside>
</div>
`;
}
function bindBoard() {
document.querySelector("#new-task").addEventListener("click", () => {
state.dialogMode = "create";
renderApp();
});
document.querySelectorAll(".col").forEach((columnEl) => {
columnEl.addEventListener("dragover", (event) => {
event.preventDefault();
state.dragOverKey = columnEl.dataset.column;
columnEl.classList.add("drag-over");
});
columnEl.addEventListener("dragleave", () => columnEl.classList.remove("drag-over"));
columnEl.addEventListener("drop", async (event) => {
event.preventDefault();
columnEl.classList.remove("drag-over");
const id = event.dataTransfer?.getData("text/plain") || state.draggedTaskId;
state.dragOverKey = null;
if (!id) return;
await moveTask(id, columnEl.dataset.state);
});
});
document.querySelectorAll("[data-card]").forEach((card) => {
card.addEventListener("dragstart", (event) => {
state.draggedTaskId = card.dataset.card;
event.dataTransfer.effectAllowed = "move";
event.dataTransfer.setData("text/plain", card.dataset.card);
card.classList.add("dragging");
});
card.addEventListener("dragend", () => {
state.draggedTaskId = null;
card.classList.remove("dragging");
});
card.addEventListener("click", () => {
state.selectedTaskId = card.dataset.card;
state.dialogMode = "detail";
renderApp();
});
});
bindCreateModal();
bindDetailModal();
}
function bindCreateModal() {
const form = document.querySelector("#create-form");
if (!form) return;
document.querySelector("#close-modal").addEventListener("click", closeModal);
document.querySelector("#cancel-modal").addEventListener("click", closeModal);
document.querySelector("#modal-overlay").addEventListener("click", (event) => {
if (event.target.id === "modal-overlay") closeModal();
});
form.addEventListener("submit", async (event) => {
event.preventDefault();
const data = Object.fromEntries(new FormData(form));
try {
await api("/api/tasks", { method: "POST", body: JSON.stringify({ ...data, state: "Backlog" }) });
closeModal();
await loadTasks();
renderApp();
} catch (error) {
document.querySelector("#form-error").textContent = error.message;
}
});
}
function bindDetailModal() {
const form = document.querySelector("#detail-form");
if (!form) return;
document.querySelector("#close-detail").addEventListener("click", closeModal);
document.querySelector("#cancel-detail").addEventListener("click", closeModal);
document.querySelector("#detail-overlay").addEventListener("click", (event) => {
if (event.target.id === "detail-overlay") closeModal();
});
document.querySelector("#save-detail").addEventListener("click", async () => {
const data = Object.fromEntries(new FormData(form));
data.title = document.querySelector(".detail-title-input").value;
data.detail = document.querySelector(".detail-textarea").value;
try {
await api(`/api/tasks/${state.selectedTaskId}`, { method: "PATCH", body: JSON.stringify(data) });
closeModal();
await loadTasks();
renderApp();
} catch (error) {
document.querySelector("#detail-error").textContent = error.message;
}
});
document.querySelector("#delete-detail").addEventListener("click", async () => {
await api(`/api/tasks/${state.selectedTaskId}`, { method: "DELETE" });
closeModal();
await loadTasks();
renderApp();
});
}
async function moveTask(id, nextState) {
await api(`/api/tasks/${id}`, { method: "PATCH", body: JSON.stringify({ state: nextState }) });
await loadTasks();
renderApp();
}
function settingsView() {
const openClaw = state.openClaw || {};
return `
<div class="page-heading">
<div><span class="eyebrow">MAXI</span><h1>Settings</h1><p>Admin-Konfiguration und Maxi-OpenClaw-Verbindung.</p></div>
<button class="refresh" id="refresh-settings">Aktualisieren</button>
</div>
<section class="settings-grid">
<article class="module-card">
<div class="module-card-head"><span class="eyebrow">OPENCLAW</span><span class="badge ${openClaw.ok ? "positive" : "warning"}">${openClaw.ok ? "Live" : "Check"}</span></div>
<h3>Gateway</h3>
<p>${escapeHtml(openClaw.baseUrl || state.settings?.openClawBaseUrl || "")}</p>
<p>Health ${escapeHtml(String(openClaw.healthStatus || openClaw.error || "unbekannt"))} - ${escapeHtml(String(openClaw.latencyMs || 0))} ms</p>
</article>
<article class="module-card">
<div class="module-card-head"><span class="eyebrow">BOARD</span></div>
<form id="settings-form">
<div class="field"><label>Default-Assignee</label><input class="field-input" name="defaultAssignee" value="${escapeAttr(state.settings?.defaultAssignee || "bao")}"></div>
<div class="field"><label>Zeitzone</label><input class="field-input" name="timezone" value="${escapeAttr(state.settings?.timezone || "Europe/Berlin")}"></div>
<div class="modal-actions"><button class="btn-submit" type="submit">Speichern</button></div>
</form>
</article>
</section>
`;
}
function bindSettings() {
document.querySelector("#refresh-settings").addEventListener("click", async () => {
await loadSettings();
renderApp();
});
document.querySelector("#settings-form").addEventListener("submit", async (event) => {
event.preventDefault();
const data = Object.fromEntries(new FormData(event.currentTarget));
await api("/api/settings", { method: "PATCH", body: JSON.stringify(data) });
await loadSettings();
renderApp();
});
}
function closeModal() {
state.dialogMode = null;
state.selectedTaskId = null;
renderApp();
}
function selectedTask() {
return state.tasks.find((task) => task.id === state.selectedTaskId);
}
function priorityLabel(priority) {
return priority === "High" ? "High" : priority === "Low" ? "Low" : "Med";
}
function priorityColor(priority) {
return priority === "High" ? "#f87171" : priority === "Low" ? "#60a5fa" : "#facc15";
}
function assigneeLabel(assignee) {
const match = assignees.find((item) => item.value === String(assignee || "").toLowerCase());
return match?.label || escapeHtml(assignee || "Bao");
}
function assigneeClass(assignee) {
const lower = String(assignee || "").toLowerCase();
if (lower === "iris") return "assignee-iris";
if (lower === "bao") return "assignee-bao";
return "assignee-agent";
}
function stateLabel(value) {
const found = columns.find((column) => column.state === value);
return found?.label || value;
}
function statusTone(value) {
if (value === "Done") return "is-done";
if (value === "Blocked") return "is-blocked";
if (value === "Review") return "is-review";
if (value === "In progress") return "is-progress";
return "is-backlog";
}
function relativeTime(date) {
if (!date) return "keine Updates";
const mins = Math.max(0, Math.round((Date.now() - new Date(date).getTime()) / 60000));
if (mins < 1) return "gerade eben";
if (mins < 60) return `vor ${mins} min`;
const hours = Math.round(mins / 60);
if (hours < 24) return `vor ${hours} h`;
return `vor ${Math.round(hours / 24)} d`;
}
function formatDate(date, withTime = false) {
if (!date) return "-";
return new Date(date).toLocaleString("de-DE", withTime ? { dateStyle: "medium", timeStyle: "short" } : { dateStyle: "medium" });
}
function escapeHtml(value) {
return String(value ?? "")
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
function escapeAttr(value) {
return escapeHtml(value).replaceAll("`", "&#096;");
}
init();