Entscheidungen aus dem Testbetrieb umgesetzt (#572): #577 #579 #581 #589 #193 #205 #594 #595 #597 #598 #599 #588

- Engine: leere Antworten nachfassen, Aufgaben enden mit Ergebniszeile (EmptyResponse/Incomplete),
  gleiche Fehlschlaege erkennen (RepeatedFailure), Zeitzeile vor Chatnachrichten,
  Werkzeugergebnisse gerahmt, Vorpruefung vor dem Staging, Belegkosten wie RunUsage
- Staging: gescheiterte Freigaben als Failed; AgentEditor-Aenderungen freigabepflichtig,
  Selbstbearbeitung gesperrt (allowSelfEdit)
- Taskboard: Termine beim Anlegen pruefen, naechster/letzter Lauf, pausieren, Sofortlauf,
  Kostenprognose, Hinweis auf wachsenden Kontext; Jobs-Reiter und /api/jobs entfernt
- Release: Instanzmanager samt Weboberflaeche im Paket, setup.json fragt manager.json ab,
  Token nicht mehr ins systemd-Journal
Version 0.1.6.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-09-15 11:23:10 +02:00
co-authored by Claude Opus 5
parent 8ab9d99abe
commit 0b6ea73ec1
42 changed files with 2646 additions and 512 deletions
+316 -350
View File
@@ -1,9 +1,11 @@
// ClawdDotNet — Aufgaben & Geplante Läufe
// ClawdDotNet — Aufgaben (Taskboard)
//
// Zwei Bereiche: Taskboard (Aufgaben) und Geplante Läufe (Jobs mit Cron & Sofortausführung).
// Seit #594 die einzige Stelle für geplante Arbeit. Der frühere Reiter „Geplante Läufe (Jobs)“
// bearbeitete die Alt-Konfiguration scheduler/toolJobs; nach der ersten Übernahme ins Taskboard
// wirkte dort keine Änderung mehr. Termin, nächster und letzter Lauf, Pausieren und Sofortlauf
// stehen jetzt an der Aufgabe selbst.
import { api } from "../modules/api.js";
import { abonnieren } from "../modules/events.js";
import {
erstelleElement,
erstelleIcon,
@@ -12,357 +14,111 @@ import {
erstelleKarte,
erstelleModal,
formatiereDatumZeit,
formatiereKosten,
zeigeMeldung
} from "../modules/dom.js";
let aktiverReiter = "jobs"; // "jobs" oder "taskboard"
let jobsListe = [];
let entladeEreignisse = [];
const STATUS_TEXT = {
Backlog: "Pausiert",
Todo: "Bereit",
InProgress: "Läuft",
InReview: "Im Review",
Done: "Erledigt",
Canceled: "Verworfen",
Blocked: "Blockiert",
Archived: "Archiviert"
};
const STATUS_ART = {
Backlog: "warnung",
Todo: "neutral",
InProgress: "laufend",
Done: "erfolg",
Blocked: "warnung"
};
// Laufstatus aus AgentRunStatus (#577/#581 brachten EmptyResponse, Incomplete, RepeatedFailure).
const LAUF_ART = {
Completed: "erfolg",
Truncated: "warnung",
Incomplete: "warnung",
BudgetExceeded: "warnung",
EmptyResponse: "gefahr",
RepeatedFailure: "gefahr",
LoopLimitExceeded: "gefahr",
Failed: "gefahr"
};
const TERMIN_ART = { cron: "Cron", every: "Intervall", at: "Zeitpunkt" };
const ABGESCHLOSSEN = ["Done", "Canceled", "Archived"];
let zeigeAbgeschlossene = false;
export async function rendereAufgaben() {
const container = document.getElementById("ansicht-inhalt");
if (!container) return;
container.replaceChildren();
entladeEreignisse.forEach((c) => c());
entladeEreignisse = [];
const layout = erstelleElement("div", { klasse: "aufgaben-ansicht" });
const kopf = erstelleElement("div", { klasse: "ansicht-kopf" });
// Kopf mit Reiter-Umschaltung und Aktionen
const kopfZeile = erstelleElement("div", { klasse: "ansicht-kopf" });
const titel = erstelleElement("h1", { text: "Aufgaben & Jobs" });
const reiterLeiste = erstelleElement("div", { klasse: "reiter-leiste" });
const reiterJobs = erstelleElement("button", {
klasse: `reiter-schaltflaeche ${aktiverReiter === "jobs" ? "aktiv" : ""}`,
text: "Geplante Läufe (Jobs)",
onclick: () => wechsleReiter("jobs")
});
const reiterTaskboard = erstelleElement("button", {
klasse: `reiter-schaltflaeche ${aktiverReiter === "taskboard" ? "aktiv" : ""}`,
text: "Taskboard",
onclick: () => wechsleReiter("taskboard")
});
reiterLeiste.append(reiterJobs, reiterTaskboard);
const aktionen = erstelleElement("div", { klasse: "gruppe-horizontal" });
const neuerJobBtn = erstelleElement(
"button",
{
klasse: "schaltflaeche primaer",
onclick: oeffneNeuerJobDialog
},
erstelleIcon(PFADE.plus, 16),
erstelleElement("span", { text: " Neuer Job" })
const abgeschlossenSchalter = erstelleElement(
"label",
{ klasse: "gruppe-horizontal text-klein" },
erstelleElement("input", {
type: "checkbox",
checked: zeigeAbgeschlossene,
onchange: (e) => {
zeigeAbgeschlossene = e.target.checked;
ladeTaskboard();
}
}),
erstelleElement("span", { text: "Erledigte zeigen" })
);
aktionen.append(neuerJobBtn);
kopfZeile.append(titel, reiterLeiste, aktionen);
const neuladen = erstelleElement(
"button",
{ klasse: "schaltflaeche flach", title: "Neu laden", onclick: () => ladeTaskboard() },
erstelleIcon(PFADE.aktualisieren, 16)
);
const inhaltBereich = erstelleElement("div", { id: "aufgaben-bereich-inhalt" });
layout.append(kopfZeile, inhaltBereich);
const neu = erstelleElement(
"button",
{ klasse: "schaltflaeche primaer", onclick: () => oeffneAufgabenDialog(null) },
erstelleIcon(PFADE.plus, 16),
erstelleElement("span", { text: " Neue Aufgabe" })
);
kopf.append(
erstelleElement("h1", { text: "Aufgaben" }),
erstelleElement("div", { klasse: "gruppe-horizontal" }, abgeschlossenSchalter, neuladen, neu)
);
layout.append(kopf, erstelleElement("div", { id: "aufgaben-bereich-inhalt" }));
container.append(layout);
await ladeAktivenBereich();
entladeEreignisse.push(
abonnieren("instance", (daten) => {
if (daten.kind === "Jobs" && aktiverReiter === "jobs") {
ladeAktivenBereich();
}
})
);
await ladeTaskboard();
}
function wechsleReiter(reiter) {
aktiverReiter = reiter;
document.querySelectorAll(".reiter-schaltflaeche").forEach((r) => r.classList.remove("aktiv"));
if (reiter === "jobs") {
document.querySelector(".reiter-schaltflaeche:first-child")?.classList.add("aktiv");
} else {
document.querySelector(".reiter-schaltflaeche:last-child")?.classList.add("aktiv");
}
ladeAktivenBereich();
}
async function ladeAktivenBereich() {
async function ladeTaskboard() {
const container = document.getElementById("aufgaben-bereich-inhalt");
if (!container) return;
container.replaceChildren();
// "Neuer Job" gehoert nur zum Jobs-Reiter; im Taskboard legte er trotzdem einen Job an (#591).
const neuerJob = document.querySelector(".aufgaben-ansicht .ansicht-kopf .schaltflaeche.primaer");
if (neuerJob) neuerJob.hidden = aktiverReiter !== "jobs";
if (aktiverReiter === "jobs") {
await rendereJobs(container);
} else {
await rendereTaskboard(container);
}
}
// ─── Jobs / Geplante Läufe ───
async function rendereJobs(container) {
container.replaceChildren(
erstelleElement("div", { klasse: "hinweis-zentriert", text: "Lade geplante Läufe…" })
);
try {
jobsListe = await api("/jobs");
container.replaceChildren();
if (!jobsListe || jobsListe.length === 0) {
container.append(
erstelleKarte("Keine geplanten Läufe", [
erstelleElement("p", {
klasse: "gedaempft",
text: "Es sind noch keine regelmäßigen Agenten-Läufe oder Tool-Jobs eingerichtet."
})
])
);
return;
}
const tabelle = erstelleElement("table", { klasse: "daten-tabelle" });
const thead = erstelleElement("thead");
const trKopf = erstelleElement("tr");
trKopf.append(
erstelleElement("th", { text: "Status" }),
erstelleElement("th", { text: "Kennung / Art" }),
erstelleElement("th", { text: "Agent" }),
erstelleElement("th", { text: "Cron-Muster" }),
erstelleElement("th", { text: "Nächster Lauf (UTC)" }),
erstelleElement("th", { text: "Letzter Lauf (Lokal)" }),
erstelleElement("th", { text: "Aktionen", stil: "text-align: right;" })
);
thead.append(trKopf);
const tbody = erstelleElement("tbody");
for (const job of jobsListe) {
const tr = erstelleElement("tr");
// Schalter Ein/Aus
const tdStatus = erstelleElement("td");
const schalterLabel = erstelleElement("label", { klasse: "schalter" });
const schalterInput = erstelleElement("input", {
type: "checkbox",
checked: Boolean(job.enabled),
onchange: async (e) => {
const neuAktiv = e.target.checked;
try {
await api(
`/jobs/${encodeURIComponent(job.jobId)}/enabled?agentId=${encodeURIComponent(job.agentId)}&enabled=${neuAktiv}`,
{ method: "PUT" }
);
job.enabled = neuAktiv;
zeigeMeldung(`Job '${job.jobId}' ${neuAktiv ? "aktiviert" : "pausiert"}.`, "info");
} catch (err) {
e.target.checked = !neuAktiv;
zeigeMeldung(`Fehler: ${err.message}`, "gefahr");
}
}
});
schalterLabel.append(schalterInput, erstelleElement("span", { klasse: "schalter-regler" }));
tdStatus.append(schalterLabel);
// Name / Art
const tdName = erstelleElement("td");
tdName.append(
erstelleElement("strong", { text: job.jobId }),
erstelleElement("div", {
klasse: "gedaempft text-klein",
text: job.kind === "toolJob" ? `Werkzeug (${job.toolName || ""})` : "Agent-Aufruf"
})
);
// Agent
const tdAgent = erstelleElement("td", { text: job.agentId });
// Cron
const tdCron = erstelleElement("td");
tdCron.append(erstelleElement("code", { text: job.cron }));
// Zeiten
const tdNext = erstelleElement("td", {
text: job.nextRunUtc ? formatiereDatumZeit(job.nextRunUtc) : "—"
});
const tdLast = erstelleElement("td", {
text: job.lastRunLocal ? formatiereDatumZeit(job.lastRunLocal) : "—"
});
// Aktionen (Sofortlauf, Löschen)
const tdAktionen = erstelleElement("td", { stil: "text-align: right;" });
const aktionenGruppe = erstelleElement("div", { klasse: "gruppe-horizontal", stil: "justify-content: flex-end;" });
const ausfuehrenBtn = erstelleElement(
"button",
{
klasse: "schaltflaeche flach klein",
title: "Job jetzt sofort manuell ausführen",
onclick: () => fuehreJobAus(job.jobId)
},
erstelleIcon(PFADE.abspielen, 14),
erstelleElement("span", { text: " Start" })
);
const loeschenBtn = erstelleElement(
"button",
{
klasse: "schaltflaeche gefahr flach klein",
title: "Job löschen",
onclick: () => loescheJob(job)
},
erstelleIcon(PFADE.muelleimer, 14)
);
aktionenGruppe.append(ausfuehrenBtn, loeschenBtn);
tdAktionen.append(aktionenGruppe);
tr.append(tdStatus, tdName, tdAgent, tdCron, tdNext, tdLast, tdAktionen);
tbody.append(tr);
}
tabelle.append(thead, tbody);
container.append(tabelle);
} catch (err) {
container.replaceChildren(
erstelleElement("div", { klasse: "fehler-zentriert", text: err.message })
);
}
}
async function fuehreJobAus(jobId) {
try {
const res = await api(`/jobs/${encodeURIComponent(jobId)}/run`, { method: "POST" });
zeigeMeldung(`Job '${jobId}' angestoßen: ${res?.message ?? "Erfolgreich"}`, "erfolg");
} catch (err) {
zeigeMeldung(`Ausführung fehlgeschlagen: ${err.message}`, "gefahr");
}
}
async function loescheJob(job) {
if (!confirm(`Soll der geplante Job '${job.jobId}' gelöscht werden?`)) return;
try {
await api(
`/jobs/${encodeURIComponent(job.jobId)}?agentId=${encodeURIComponent(job.agentId)}&kind=${encodeURIComponent(job.kind)}`,
{ method: "DELETE" }
);
zeigeMeldung(`Job '${job.jobId}' wurde gelöscht.`, "info");
await ladeAktivenBereich();
} catch (err) {
zeigeMeldung(`Löschen fehlgeschlagen: ${err.message}`, "gefahr");
}
}
async function oeffneNeuerJobDialog() {
let agenten = [];
try {
agenten = await api("/agents");
} catch {}
const formular = erstelleElement("form", { klasse: "formular-raster" });
const agentSelect = erstelleElement("select", { required: true });
for (const a of agenten) {
agentSelect.append(erstelleElement("option", { value: a.agentId, text: `${a.displayName} (${a.agentId})` }));
}
const kindSelect = erstelleElement("select");
kindSelect.append(
erstelleElement("option", { value: "agentWakeup", text: "Agent Wakeup (Regelmäßiger Aufruf)" }),
erstelleElement("option", { value: "toolJob", text: "Tool Job (Werkzeug-Ausführung)" })
);
const cronInput = erstelleElement("input", {
type: "text",
required: true,
value: "0 * * * *",
placeholder: "z.B. 0 * * * * (stündlich)"
});
const taskMsgInput = erstelleElement("textarea", {
rows: "2",
placeholder: "Optionale Start-Nachricht / Anweisung an den Agenten…"
});
formular.append(
erstelleElement("label", { text: "Ziel-Agent:" }),
agentSelect,
erstelleElement("label", { text: "Art des Jobs:" }),
kindSelect,
erstelleElement("label", { text: "Cron-Ausdruck:" }),
cronInput,
erstelleElement("label", { text: "Aufgaben-Nachricht:" }),
taskMsgInput
);
const abbrechenBtn = erstelleElement(
"button",
{
type: "button",
klasse: "schaltflaeche flach",
onclick: () => modal.schliessen(),
text: "Abbrechen"
}
);
const anlegenBtn = erstelleElement("button", {
type: "submit",
klasse: "schaltflaeche primaer",
text: "Job erstellen"
});
const modal = erstelleModal("Neuen geplanten Job anlegen", formular, [abbrechenBtn, anlegenBtn]);
formular.addEventListener("submit", async (e) => {
e.preventDefault();
anlegenBtn.disabled = true;
try {
await api("/jobs", {
method: "POST",
body: {
agentId: agentSelect.value,
kind: kindSelect.value,
cron: cronInput.value.trim(),
taskMessage: taskMsgInput.value.trim(),
runOnStart: false
}
});
modal.schliessen();
zeigeMeldung("Geplanter Job wurde erstellt.", "erfolg");
await ladeAktivenBereich();
} catch (err) {
zeigeMeldung(`Fehler beim Erstellen: ${err.message}`, "gefahr");
} finally {
anlegenBtn.disabled = false;
}
});
modal.oeffnen();
}
// ─── Taskboard ───
async function rendereTaskboard(container) {
container.replaceChildren(
erstelleElement("div", { klasse: "hinweis-zentriert", text: "Lade Taskboard…" })
);
try {
const tasks = await api("/tasks?limit=50");
const alle = (await api("/tasks?limit=200")) || [];
const tasks = alle.filter((t) => zeigeAbgeschlossene || !ABGESCHLOSSEN.includes(t.status));
container.replaceChildren();
if (!tasks || tasks.length === 0) {
if (tasks.length === 0) {
container.append(
erstelleKarte("Taskboard leer", [
erstelleKarte("Keine Aufgaben", [
erstelleElement("p", {
klasse: "gedaempft",
text: "Aktuell liegen keine offenen Aufgaben im gemeinsamen Arbeitsbereich."
text: "Keine offenen Aufgaben. Wiederkehrende Arbeit legst du über „Neue Aufgabe“ mit einem Termin an."
})
])
);
@@ -370,38 +126,248 @@ async function rendereTaskboard(container) {
}
const raster = erstelleElement("div", { klasse: "taskboard-raster" });
for (const task of tasks) {
const karte = erstelleKarte(task.title || `Aufgabe ${task.id}`, [
erstelleElement("p", { klasse: "gedaempft text-klein", text: `ID: ${task.id} · Agent: ${task.assignee || "—"}` }),
erstelleElement("p", {
klasse: "gedaempft text-klein",
text: task.when
? `Termin: ${task.when.kind} ${task.when.value} (${task.when.timeZone || "ohne Zeitzone"})`
: "Termin: einmalig"
}),
erstelleElement("div", {
klasse: "task-status-zeile",
stil: "margin-top: 8px;"
}, erstellePlakette(task.status || "Offen", task.status === "Done" ? "erfolg" : "neutral")),
erstelleElement("div", {
klasse: "gedaempft text-klein",
stil: "margin-top: 8px;",
text: `Erstellt: ${formatiereDatumZeit(task.createdAt)}`
})
]);
raster.append(karte);
}
for (const task of tasks) raster.append(erstelleAufgabenKarte(task));
container.append(raster);
} catch (err) {
container.replaceChildren(
erstelleKarte("Taskboard nicht verfügbar", [
erstelleElement("p", {
klasse: "gedaempft",
text: err.message || "In dieser Instanz ist kein Taskboard aktiv (kein gemeinsamer Arbeitsbereich)."
})
erstelleElement("p", { klasse: "gedaempft", text: err.message })
])
);
}
}
function terminText(task) {
if (!task.when) return "einmalig";
const art = TERMIN_ART[task.when.kind] || task.when.kind;
return `${art} ${task.when.value} (${task.when.timeZone || "UTC"})`;
}
function erstelleAufgabenKarte(task) {
const zeilen = [];
zeilen.push(
erstelleElement(
"div",
{ klasse: "task-status-zeile gruppe-horizontal" },
erstellePlakette(STATUS_TEXT[task.status] || task.status, STATUS_ART[task.status] || "neutral"),
erstelleElement("span", { klasse: "gedaempft text-klein", text: `${task.id} · ${task.assignee || "—"}` })
)
);
zeilen.push(erstelleElement("p", { klasse: "gedaempft text-klein", text: `Termin: ${terminText(task)}` }));
zeilen.push(
erstelleElement("p", {
klasse: "gedaempft text-klein",
text: `Nächster Lauf: ${task.nextRunUtc ? formatiereDatumZeit(task.nextRunUtc) : "—"}`
})
);
const letzter = erstelleElement("p", { klasse: "gedaempft text-klein" }, "Letzter Lauf: ");
if (task.lastRun) {
letzter.append(
`${formatiereDatumZeit(task.lastRun.occurredAt)} `,
erstellePlakette(task.lastRun.status, LAUF_ART[task.lastRun.status] || "neutral"),
` ${task.lastRun.costIsKnown ? formatiereKosten(task.lastRun.costUsd) : "Kosten unbekannt"}`
);
if (task.lastRun.resultRef) letzter.title = task.lastRun.resultRef;
} else {
letzter.append("—");
}
zeilen.push(letzter);
if (task.hinweis) {
zeilen.push(erstelleElement("p", { klasse: "text-klein", text: `⚠ ${task.hinweis}` }));
}
const aktionen = erstelleElement("div", {
klasse: "gruppe-horizontal",
stil: "margin-top: 8px; justify-content: flex-end;"
});
if (task.assignee !== "@human" && task.status === "Todo") {
aktionen.append(
erstelleElement(
"button",
{ klasse: "schaltflaeche flach klein", title: "Jetzt ausführen", onclick: () => fuehreAus(task) },
erstelleIcon(PFADE.abspielen, 14),
erstelleElement("span", { text: " Jetzt" })
)
);
}
if (task.status === "Todo") {
aktionen.append(
erstelleElement("button", {
klasse: "schaltflaeche flach klein",
text: "Pausieren",
onclick: () => setzeAktiv(task, false)
})
);
} else if (["Backlog", "Done", "Canceled"].includes(task.status)) {
aktionen.append(
erstelleElement("button", {
klasse: "schaltflaeche flach klein",
text: "Aktivieren",
onclick: () => setzeAktiv(task, true)
})
);
}
aktionen.append(
erstelleElement("button", {
klasse: "schaltflaeche flach klein",
text: "Bearbeiten",
onclick: () => oeffneAufgabenDialog(task)
})
);
zeilen.push(aktionen);
return erstelleKarte(task.title || `Aufgabe ${task.id}`, zeilen);
}
async function fuehreAus(task) {
try {
const res = await api(`/tasks/${encodeURIComponent(task.id)}/run`, { method: "POST" });
zeigeMeldung(res?.message || "Lauf angestoßen.", "erfolg");
setTimeout(ladeTaskboard, 1500);
} catch (err) {
zeigeMeldung(`Nicht gestartet: ${err.message}`, "gefahr");
}
}
async function setzeAktiv(task, aktiv) {
try {
const res = await api(`/tasks/${encodeURIComponent(task.id)}/enabled?enabled=${aktiv}`, { method: "PUT" });
zeigeMeldung(res?.message || (aktiv ? "Aktiviert." : "Pausiert."), "info");
await ladeTaskboard();
} catch (err) {
zeigeMeldung(`Fehler: ${err.message}`, "gefahr");
}
}
async function oeffneAufgabenDialog(task) {
let agenten = [];
try {
agenten = (await api("/agents")) || [];
} catch {
// Ohne Agentenliste bleibt nur „Mensch“ und eine vorhandene Zuordnung.
}
const formular = erstelleElement("form", { klasse: "formular-raster" });
const titel = erstelleElement("input", { type: "text", required: true, value: task?.title || "" });
const beschreibung = erstelleElement("textarea", { rows: "5", placeholder: "Auftrag an den Agenten…" });
beschreibung.value = task?.body || "";
// #598: Frischer Lauf zuerst und als Vorgabe - im Chatverlauf wächst der Kontext mit jedem Lauf.
const zuordnung = erstelleElement("select", { required: true });
for (const a of agenten) {
zuordnung.append(
erstelleElement("option", { value: `@new:${a.agentId}`, text: `${a.displayName} – frischer Lauf` }),
erstelleElement("option", { value: `@${a.agentId}`, text: `${a.displayName} – im Chatverlauf` })
);
}
zuordnung.append(erstelleElement("option", { value: "@human", text: "Mensch (kein Agentenlauf)" }));
if (task?.assignee && ![...zuordnung.options].some((o) => o.value === task.assignee)) {
zuordnung.append(erstelleElement("option", { value: task.assignee, text: task.assignee }));
}
zuordnung.value = task?.assignee || (agenten[0] ? `@new:${agenten[0].agentId}` : "@human");
const terminArt = erstelleElement("select");
terminArt.append(
erstelleElement("option", { value: "", text: "Einmalig" }),
erstelleElement("option", { value: "cron", text: "Cron (z.B. stündlich)" }),
erstelleElement("option", { value: "every", text: "Intervall" }),
erstelleElement("option", { value: "at", text: "Zeitpunkt" })
);
terminArt.value = task?.when?.kind || "";
const terminWert = erstelleElement("input", { type: "text", value: task?.when?.value || "" });
const standardZone = Intl.DateTimeFormat().resolvedOptions().timeZone || "Europe/Berlin";
const zeitzone = erstelleElement("input", { type: "text", value: task?.when?.timeZone || standardZone });
const abnahme = erstelleElement("textarea", { rows: "2", placeholder: "Woran das Ergebnis gemessen wird (optional)" });
abnahme.value = task?.acceptance || "";
const hinweis = erstelleElement("p", { klasse: "gedaempft text-klein" });
const aktualisiere = () => {
const wiederkehrend = terminArt.value === "cron" || terminArt.value === "every";
const imVerlauf = zuordnung.value.startsWith("@") && !zuordnung.value.startsWith("@new") && zuordnung.value !== "@human";
hinweis.hidden = !(wiederkehrend && imVerlauf);
hinweis.textContent =
"⚠ Im Chatverlauf wächst der Kontext mit jedem Lauf – und mit ihm die Kosten je Lauf. " +
"Für wiederkehrende Aufgaben ist „frischer Lauf“ meist die bessere Wahl.";
const ohneTermin = terminArt.value === "";
terminWert.disabled = ohneTermin;
zeitzone.disabled = ohneTermin;
terminWert.placeholder =
{ cron: "z.B. 0 7-22/3 * * *", every: "z.B. 30m, 2h, 1d", at: "z.B. 2026-09-15T08:00" }[terminArt.value] || "";
};
zuordnung.addEventListener("change", aktualisiere);
terminArt.addEventListener("change", aktualisiere);
aktualisiere();
formular.append(
erstelleElement("label", { text: "Titel:" }), titel,
erstelleElement("label", { text: "Auftrag:" }), beschreibung,
erstelleElement("label", { text: "Ausführen durch:" }), zuordnung,
erstelleElement("label", { text: "Termin:" }), terminArt,
erstelleElement("label", { text: "Wert:" }), terminWert,
erstelleElement("label", { text: "Zeitzone:" }), zeitzone,
erstelleElement("label", { text: "Abnahme:" }), abnahme,
erstelleElement("span"), hinweis
);
const abbrechen = erstelleElement("button", {
type: "button",
klasse: "schaltflaeche flach",
text: "Abbrechen",
onclick: () => modal.schliessen()
});
const speichern = erstelleElement("button", {
type: "submit",
klasse: "schaltflaeche primaer",
text: task ? "Speichern" : "Anlegen"
});
const modal = erstelleModal(task ? "Aufgabe bearbeiten" : "Neue Aufgabe", formular, [abbrechen, speichern]);
formular.addEventListener("submit", async (e) => {
e.preventDefault();
speichern.disabled = true;
const body = {
title: titel.value.trim(),
body: beschreibung.value.trim(),
assignee: zuordnung.value,
whenKind: terminArt.value || null,
whenValue: terminArt.value ? terminWert.value.trim() : null,
timeZone: terminArt.value ? zeitzone.value.trim() : null,
acceptance: abnahme.value.trim(),
priority: task?.priority ?? 3
};
try {
const res = await api(task ? `/tasks/${encodeURIComponent(task.id)}` : "/tasks", {
method: task ? "PUT" : "POST",
body
});
modal.schliessen();
const text = res?.message || "Gespeichert.";
zeigeMeldung(text, text.includes("Hinweis:") ? "warnung" : "erfolg", text.includes("Hinweis:") ? 9000 : 4000);
await ladeTaskboard();
} catch (err) {
zeigeMeldung(`Nicht gespeichert: ${err.message}`, "gefahr", 8000);
} finally {
speichern.disabled = false;
}
});
modal.oeffnen();
}