/* Weboberfläche der Minerva Platform.
   Ohne Rahmenwerk und ohne Bauschritt - die Datei wird unverändert ausgeliefert. */
"use strict";

const FARBEN = ["#4f46e5", "#f59e0b", "#10b981", "#8b5cf6", "#f43f5e", "#06b6d4"];
const WOCHENTAGE = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"];
const MONATE = ["Januar", "Februar", "März", "April", "Mai", "Juni", "Juli",
                "August", "September", "Oktober", "November", "Dezember"];
const ARTEN = { company: "Unternehmen", branch: "Niederlassung", office: "Büro",
                department: "Abteilung", personal: "Persönlich" };
const WS_ARTEN = { work: "Arbeit", freelance: "Freelance", personal: "Persönlich" };
const WS_SYMBOL = { work: "briefcase", freelance: "handshake-angle", personal: "house" };
const MARKT_STATUS = { open: "offen", assigned: "vergeben", closed: "geschlossen" };
const ROLLEN = { admin: "Administration", manager: "Leitung", member: "Mitglied" };
/* Die Beiträge der wirksamen Erweiterungen (Kategorien, Abschnitte, Ansichten,
   Befehle) - der Dienst legt sie als JSON in die Seite; schale.js baut daraus
   die Leisten. Eine abgeschaltete Erweiterung kommt darin nicht vor. */
const BEITRAEGE = JSON.parse(document.getElementById("beitraege").textContent);
const ANSICHTEN = [...document.querySelectorAll(".panel a[data-view]")].map((a) => a.dataset.view);
/* Ansichten, die ohne gewähltes Projekt auskommen - aus den Manifesten;
   Erweiterungen dürfen sich zusätzlich eintragen. */
const OHNE_PROJEKT = BEITRAEGE.ansichten.filter((a) => a.ohne_projekt).map((a) => a.id);
/* Zeichner und Lader je Ansicht; Erweiterungen (chat.js, hub.js) tragen sich hier ein. */
const ZEICHNER = {};
const LADER = {};
const TITEL = { tag: "Mein Tag", org: "Organisation", boerse: "Börse", kalender: "Kalender",
                einstellungen: "Einstellungen" };
const UNTERTITEL = {
  org: "Unternehmen, Niederlassungen, Büros, Abteilungen und Arbeitsbereiche",
  boerse: "Ausschreibungen aus allen Arbeitsbereichen – Arbeit und Freelance",
  einstellungen: "Profil, Erweiterungen und Zugangsdaten",
};
const TAG_MS = 86400000;

const S = { me: null, org: { units: [], workspaces: [] }, pid: null, daten: null,
            ansicht: "tag", monat: new Date(), kalender: null, alleProjekte: true,
            agenda: null, agendaTag: new Date(), meetings: [], dokumente: [],
            personen: [], markt: [], marktFilter: "alle", personenZiel: null,
            verlauf: [], vorschlaege: [], abbruch: null, assistentModus: "chat" };

const ORG_SYMBOL = { company: "building", branch: "location-dot", office: "door-open",
                     department: "people-group", personal: "user" };
const ROLLEN_SYMBOL = { admin: "user-shield", manager: "user-tie", member: "user" };

const $ = (sel, wurzel = document) => wurzel.querySelector(sel);
/** Font Awesome wird lokal ausgeliefert – die Anwendung bleibt ohne Netz nutzbar. */
const ico = (name, stil = "solid", klasse = "") => {
  const i = document.createElement("i");
  i.className = `fa-${stil} fa-${name}${klasse ? " " + klasse : ""}`;
  i.setAttribute("aria-hidden", "true");
  return i;
};
/** Überschrift mit Symbol setzen, ohne das Symbol beim Textwechsel zu verlieren. */
const titelSetzen = (sel, symbol, text) => {
  const h = $(sel);
  h.replaceChildren(ico(symbol), document.createTextNode(" " + text));
};
const knopf = (klasse, symbol, text) => {
  const b = el("button", klasse);
  b.type = "button";
  b.append(ico(symbol), document.createTextNode(" " + text));
  return b;
};
const dateiSymbol = (typ, name) => {
  const n = (name || "").toLowerCase();
  if (typ.includes("pdf") || n.endsWith(".pdf")) return "file-pdf";
  if (typ.startsWith("image/") || /\.(png|jpe?g|gif|webp|svg)$/.test(n)) return "file-image";
  if (/\.(zip|tar|gz|7z)$/.test(n)) return "file-zipper";
  if (/\.(docx?|odt)$/.test(n)) return "file-word";
  if (/\.(xlsx?|ods|csv)$/.test(n)) return "file-excel";
  if (typ.startsWith("text/") || /\.(txt|md|json|pmproj)$/.test(n)) return "file-lines";
  return "file";
};
const el = (tag, klasse, text) => {
  const k = document.createElement(tag);
  if (klasse) k.className = klasse;
  if (text !== undefined) k.textContent = text;
  return k;
};
const tag = (iso) => new Date(iso.length <= 10 ? iso + "T00:00:00" : iso);
const isoTag = (d) => new Date(d.getTime() - d.getTimezoneOffset() * 60000)
  .toISOString().slice(0, 10);
const isoMinute = (d) => new Date(d.getTime() - d.getTimezoneOffset() * 60000)
  .toISOString().slice(0, 16);
const kurzDatum = (iso) => iso ? tag(iso).toLocaleDateString("de-DE") : "–";
const uhr = (iso) => tag(iso).toLocaleTimeString("de-DE",
  { hour: "2-digit", minute: "2-digit" });
const farbe = (i) => FARBEN[i % FARBEN.length];
const kuerzel = (n) => (n || "?").split(/\s+/).slice(0, 2).map((w) => w[0]).join("").toUpperCase();
const dauer = (min) => min >= 60 ? `${Math.floor(min / 60)} h ${min % 60 ? min % 60 + " min" : ""}`.trim()
  : `${min} min`;
const bytes = (n) => n > 1048576 ? `${(n / 1048576).toFixed(1)} MB`
  : n > 1024 ? `${Math.round(n / 1024)} KB` : `${n} B`;

function meldung(text, fehler = false) {
  const t = el("div", "toast" + (fehler ? " is-error" : ""));
  t.append(ico(fehler ? "triangle-exclamation" : "circle-check"),
           document.createTextNode(" " + text));
  // Ein offener <dialog> liegt in der obersten Ebene über allem anderen; eine
  // Meldung im Körper wäre dahinter unsichtbar. Also wandert der Bereich mit.
  const bereich = $("#meldungen");
  (document.querySelector("dialog[open]") || document.body).append(bereich);
  bereich.append(t);
  setTimeout(() => t.remove(), fehler ? 6000 : 3000);
  document.dispatchEvent(new CustomEvent("protokoll", { detail: { text, fehler } }));
}

async function api(pfad, optionen = {}) {
  const kopf = optionen.body && !(optionen.body instanceof FormData)
    ? { "Content-Type": "application/json" } : {};
  const antwort = await fetch(pfad, { headers: kopf, ...optionen });
  if (antwort.status === 401) { location.href = "/"; throw new Error("abgemeldet"); }
  const text = await antwort.text();
  const daten = text ? JSON.parse(text) : {};
  if (!antwort.ok) {
    const grund = Array.isArray(daten.detail)
      ? daten.detail.map((d) => `${(d.loc || []).slice(1).join(".")}: ${d.msg}`).join("; ")
      : daten.detail;
    const fehler = new Error(grund || `Fehler ${antwort.status}`);
    fehler.status = antwort.status;             // 409 beim Speichern einer Datei
    fehler.daten = daten;
    throw fehler;
  }
  return daten;
}

const jsonRuf = (pfad, methode, koerper) =>
  api(pfad, { method: methode, body: JSON.stringify(koerper) });

function mischen(hex, anteil) {
  const zahl = (i) => parseInt(hex.slice(1 + i * 2, 3 + i * 2), 16);
  const ziel = anteil > 0 ? 255 : 0;
  const t = Math.abs(anteil);
  return "#" + [0, 1, 2].map((i) =>
    Math.round(zahl(i) + (ziel - zahl(i)) * t).toString(16).padStart(2, "0")).join("");
}

/* -- Stammdaten laden ----------------------------------------------------- */
async function orgLaden() {
  S.org = await api("/api/org");
  baueBaum();
}

function projektInfo(pid) {
  for (const w of S.org.workspaces) {
    const p = (w.projects || []).find((x) => x.id === pid);
    if (p) return { projekt: p, ws: w, unit: S.org.units.find((u) => u.id === w.org_unit_id) };
  }
  return null;
}

function alleProjekte() {
  return S.org.workspaces.flatMap((w) => (w.projects || []).map((p) => ({ ...p, ws: w })));
}

/** Eine Zeile im Baum ist ein Knopf: ohne Tastaturbedienung kam man weder an
    ein Projekt noch an eine Einheit heran. */
function bedienbar(zeile, titel) {
  zeile.tabIndex = 0;
  zeile.setAttribute("role", "button");
  if (titel) zeile.setAttribute("aria-label", titel);
  zeile.onkeydown = (e) => {
    if (e.key === "Enter" || e.key === " ") {
      e.preventDefault();
      zeile.click();
    }
  };
  return zeile;
}

function baueBaum() {
  const ziel = $("#orgbaum");
  ziel.replaceChildren();
  const baum = el("div", "tree");
  const kinder = (pid) => S.org.units.filter((u) => u.parent_id === pid);

  const zeichneEinheit = (unit, tiefe) => {
    const zeile = el("div", "tree__row");
    zeile.style.paddingLeft = 12 + tiefe * 12 + "px";
    zeile.append(el("span", "kind"), el("span", "", unit.name));
    zeile.firstChild.append(ico(ORG_SYMBOL[unit.kind] || "building"));
    zeile.title = `${ARTEN[unit.kind] || ""} · ${ROLLEN[unit.role] || "kein Zugriff"}`;
    if (unit.role === "admin") {
      const plus = el("span", "tree__add");
      plus.append(ico("plus"));
      plus.title = "Untereinheit oder Arbeitsbereich anlegen";
      plus.onclick = (e) => { e.stopPropagation(); untereinheit(unit); };
      zeile.append(plus);
    }
    zeile.onclick = () => wechsle("org");
    baum.append(bedienbar(zeile, zeile.title));
    S.org.workspaces.filter((w) => w.org_unit_id === unit.id)
      .forEach((w) => zeichneBereich(w, tiefe + 1));
    kinder(unit.id).forEach((k) => zeichneEinheit(k, tiefe + 1));
  };

  const reihenfolge = alleProjekte().map((x) => x.id);   // einmal, nicht je Projekt
  const zeichneBereich = (ws, tiefe) => {
    const zeile = el("div", "tree__row");
    zeile.style.paddingLeft = 12 + tiefe * 12 + "px";
    zeile.append(el("span", "kind"), el("span", "", ws.name));
    zeile.firstChild.append(ico(WS_SYMBOL[ws.kind] || "layer-group"));
    zeile.title = `Arbeitsbereich · ${WS_ARTEN[ws.kind] || "Arbeit"}`;
    if (ws.role === "admin" || ws.role === "manager") {
      const plus = el("span", "tree__add");
      plus.append(ico("plus"));
      plus.title = "Projekt anlegen";
      plus.onclick = (e) => { e.stopPropagation(); oeffneProjekt(null, ws.id); };
      zeile.append(plus);
    }
    zeile.onclick = () => wechsle("org");
    baum.append(bedienbar(zeile, zeile.title));
    (ws.projects || []).forEach((p) => {
      const z = el("div", "tree__row" + (p.id === S.pid ? " is-active" : ""));
      z.style.paddingLeft = 12 + (tiefe + 1) * 12 + "px";
      const punkt = el("span", "kind");
      const symbol = ico("diagram-project");
      symbol.style.color = farbe(reihenfolge.indexOf(p.id));
      punkt.append(symbol);
      z.append(punkt, el("span", "", p.name));
      z.title = `Projekt · ${p.open} von ${p.tasks} Vorgängen offen`;
      z.onclick = () => waehleProjekt(p.id);
      baum.append(bedienbar(z, `${p.name} - ${z.title}`));
    });
  };

  kinder(null).forEach((u) => zeichneEinheit(u, 0));
  if (!S.org.units.length) {
    const hinweis = el("div", "side__foot",
      "Noch keine Organisation. Oben auf ＋ klicken und ein Unternehmen anlegen.");
    baum.append(hinweis);
  }
  ziel.append(baum);
}

async function projektLaden() {
  if (!S.pid) { S.daten = null; return; }
  try {
    S.daten = await api(`/api/projects/${S.pid}`);
  } catch (e) {
    S.daten = null; S.pid = null;
    meldung(e.message, true);
    return;
  }
  if (S.daten.error) meldung("Planung: " + S.daten.error, true);
  // Erweiterungen (Explorer) hängen am Projekt, nicht an der Ansicht.
  document.dispatchEvent(new CustomEvent("projekt-geladen"));
}

async function waehleProjekt(pid) {
  schaleSpeichern();              // Reiter und Leisten des vorigen Projekts merken
  S.pid = pid;
  S.verlauf = [];                 // das Gespräch dreht sich um genau ein Projekt
  $("#as-verlauf").replaceChildren();
  $("#as-vorschlaege").hidden = true;
  await projektLaden();
  baueBaum();
  schaleWiederherstellen();
  if (OHNE_PROJEKT.includes(S.ansicht)) S.ansicht = "gantt";
  oeffneTab(ansichtTab(S.ansicht));
  urlAngleichen(true);
  zeichnen();
}

/* -- Kopfzeile ------------------------------------------------------------ */
function kopfzeile() {
  const p = S.daten && S.daten.project;
  const braucht = !OHNE_PROJEKT.includes(S.ansicht);
  $("#btn-ki").hidden = !braucht || !p;
  $("#btn-vorgang").hidden = !braucht || !p;
  $("#btn-projekt").hidden = !braucht && S.ansicht !== "kalender" && S.ansicht !== "org";
  $("#btn-bericht").hidden = !braucht || !p;
  $("#btn-assistent").hidden = !braucht || !p;

  $("#btn-projekt").className = "btn" + (p && braucht ? "" : " btn--primary");
  $("#projektname").textContent = TITEL[S.ansicht]
    || (p ? p.name : "Kein Projekt gewählt");
  const pfad = p && p.org ? `${p.org.name} › ${p.workspace.name}` : "";
  $("#crumb").textContent = S.ansicht === "tag"
    ? new Date().toLocaleDateString("de-DE", { weekday: "long", day: "numeric",
                                               month: "long", year: "numeric" })
    : UNTERTITEL[S.ansicht]
    || (p ? `${pfad}${p.client ? " · " + p.client : ""} · ${S.daten.members.length} Beteiligte`
          : "Links ein Projekt wählen oder anlegen");
  const kpis = $("#kpis");
  kpis.replaceChildren();
  let werte = [];
  if (S.ansicht === "tag" && S.agenda) {
    werte = [[String(S.agenda.meetings.length), "TERMINE"],
             [dauer(S.agenda.load.verfuegbar_min), "FREI"],
             [S.agenda.load.quote + " %", "VERPLANT"],
             [String(S.agenda.unplanned.length), "OFFEN"],
             [String((S.agenda.hinweise || []).length), "HINWEISE"]];
  } else if (braucht && p) {
    werte = [[kurzDatum(p.start), "START"], [kurzDatum(p.end), "ENDE"],
             [p.progress + " %", "FORTSCHRITT"], [p.effort + " h", "AUFWAND"],
             [String(p.critical_path.length), "KRITISCH"]];
  }
  for (const [wert, name] of werte) {
    const k = el("div", "kpi");
    k.append(el("b", "", wert), el("span", "", name));
    kpis.append(k);
  }
}

/** Zahl an einem Menüpunkt - Börse, Hinweise, später Chat und Agenten.
    Fehlt der Eintrag (Erweiterung abgeschaltet), passiert nichts. */
function navZahl(ansicht, anzahl, titel) {
  const eintrag = $(`.panel a[data-view="${ansicht}"]`);
  if (!eintrag) return;
  let zahl = $(".nav__zahl", eintrag);
  if (!zahl) {
    zahl = el("span", "nav__zahl");
    eintrag.append(zahl);
  }
  zahl.textContent = String(anzahl);
  zahl.hidden = !anzahl;
  eintrag.title = anzahl ? titel : "";
  aktivZahl(eintrag.closest(".panel__gruppe").dataset.aktivitaet);
}

/** Offene Ausschreibungen, auf die man selbst bieten könnte, im Menü zeigen -
    sonst bemerkt neue Arbeit nur, wer von sich aus die Börse öffnet. */
function boersenZahl() {
  const offen = S.markt.filter((a) => a.status === "open" && !a.manage && !a.my_bid).length;
  navZahl("boerse", offen, `${offen} Ausschreibung(en) ohne dein Gebot`);
}

function zeichnen() {
  kopfzeile();
  boersenZahl();
  hinweiseZahl();
  urlAngleichen();
  schaleMarkiere();
  // Jede Ansicht hat ihren Reiter; gezeichnet wird in dessen Fläche - auch
  // wenn gerade eine Datei vorne liegt und eine Erweiterung neu zeichnet.
  const tab = S.tabs.liste.find((x) => x.id === "ansicht:" + S.ansicht)
    || oeffneTab(ansichtTab(S.ansicht));
  const ziel = tab.el;
  ziel.replaceChildren();
  const brauchtProjekt = !OHNE_PROJEKT.includes(S.ansicht);
  if (brauchtProjekt && !S.daten) {
    const leer = el("div", "empty");
    leer.append(ico("diagram-project", "solid", "gross"), document.createTextNode(
      "Kein Projekt gewählt. Wähle links eines aus oder lege hier eines an."));
    const neu = knopf("btn btn--primary", "plus", "Projekt anlegen");
    neu.onclick = () => oeffneProjekt(null, null);
    leer.append(neu);
    ziel.append(leer);
    return;
  }
  ZEICHNER[S.ansicht](ziel);
}

Object.assign(ZEICHNER, { tag: zeichneTag, kalender: zeichneKalender, gantt: zeichneGantt,
                          kanban: zeichneKanban, liste: zeichneListe,
                          dokumente: zeichneDokumente, team: zeichneTeam,
                          boerse: zeichneBoerse, org: zeichneOrg,
                          einstellungen: zeichneEinstellungen });
Object.assign(LADER, { kalender: kalenderDaten, tag: agendaLaden, dokumente: dokumenteLaden,
                       org: orgLaden, boerse: marktLaden, einstellungen: moduleLaden });

/* -- Mein Tag: Termine, freie Zeit, Vorschlag ----------------------------- */
async function agendaLaden() {
  const t = isoTag(S.agendaTag);
  S.agenda = await api(`/api/agenda?tag=${t}`);
  S.meetings = await api(`/api/meetings?von=${t}&bis=${isoTag(
    new Date(S.agendaTag.getTime() + 13 * TAG_MS))}`);
}

/** Auffälligkeiten aus allen sichtbaren Projekten - was heute Aufmerksamkeit
    braucht, ohne dass man jedes Projekt einzeln aufsuchen muss. */
function hinweisKarte(hinweise) {
  const karte = el("div", "karte");
  const kopf = el("h3");
  kopf.append(ico("bell"), document.createTextNode(" Hinweise"));
  karte.append(kopf);
  if (!hinweise.length) {
    karte.append(el("div", "muted", "Nichts Dringendes."));
    return karte;
  }
  hinweise.forEach((h) => {
    const z = el("div", "slot" + (h.schwere === "niedrig" ? ""
      : " slot--arbeit" + (h.schwere === "hoch" ? " is-critical" : "")));
    z.append(el("div", "slot__zeit", h.project || (h.listing_id ? "Börse" : "Termin")));
    z.append(el("div", "slot__inhalt", h.text));
    z.style.cursor = "pointer";
    z.onclick = async () => {
      if (h.listing_id) return wechsle("boerse");
      if (h.meeting_id) {
        const m = (S.meetings || []).find((x) => x.id === h.meeting_id);
        return m ? oeffneMeeting(m) : wechsle("kalender");
      }
      await waehleProjekt(h.project_id);
      const vorgang = h.task_id && S.daten
        && S.daten.tasks.find((x) => x.id === h.task_id);
      if (vorgang) oeffneVorgang(vorgang);
    };
    karte.append(bedienbar(z, h.text));
  });
  return karte;
}

/** Zahl der dringenden Hinweise am Menüpunkt - wie bei der Börse. */
function hinweiseZahl() {
  const dringend = ((S.agenda && S.agenda.hinweise) || [])
    .filter((h) => h.schwere === "hoch").length;
  navZahl("tag", dringend, `${dringend} dringende Hinweise`);
}

function zeichneTag(ziel) {
  const a = S.agenda;
  if (!a) {
    ziel.append(el("div", "empty", "Der Tagesplan ließ sich nicht laden."));
    return;
  }
  const leiste = el("div", "toolbar");
  const zurueck = el("button", "btn btn--ghost", "‹");
  const vor = el("button", "btn btn--ghost", "›");
  const heute = el("button", "btn btn--ghost", "Heute");
  const blaettern = async (d) => {
    S.agendaTag = d ? new Date(S.agendaTag.getTime() + d * TAG_MS) : new Date();
    await agendaLaden();
    zeichnen();
  };
  zurueck.onclick = () => blaettern(-1);
  vor.onclick = () => blaettern(1);
  heute.onclick = () => blaettern(0);
  const neu = knopf("btn btn--primary", "calendar-plus", "Besprechung");
  neu.onclick = () => oeffneMeeting(null);
  leiste.append(zurueck, vor, heute,
    el("h2", "", S.agendaTag.toLocaleDateString("de-DE",
      { weekday: "long", day: "numeric", month: "long" })),
    el("div", "spacer"), neu);
  ziel.append(leiste);

  const raster = el("div", "tag");
  const links = el("div", "zeitstrahl");
  const eintraege = [
    ...a.meetings.map((m) => ({ art: "meeting", start: m.starts_at, ende: m.ends_at, m })),
    ...a.plan.map((b) => ({ art: "arbeit", start: b.start, ende: b.end, b })),
  ].sort((x, y) => x.start.localeCompare(y.start));

  if (!eintraege.length) {
    const leer = el("div", "komm__leer");
    leer.append(ico("mug-hot"), document.createTextNode(
      " Für diesen Tag gibt es weder Termine noch offene Vorgänge."));
    links.append(leer);
  }
  for (const e of eintraege) {
    const zeile = el("div", "slot slot--" + e.art
      + (e.art === "arbeit" && e.b.critical ? " is-critical" : ""));
    zeile.append(el("div", "slot__zeit", `${uhr(e.start)} – ${uhr(e.ende)}`));
    const inhalt = el("div", "slot__inhalt");
    if (e.art === "meeting") {
      const kopf = el("b");
      kopf.append(ico("users"), document.createTextNode(" " + e.m.title));
      inhalt.append(kopf);
      const wer = e.m.participants.map((p) => p.name).join(", ");
      const zusatz = el("span", "marke");
      if (e.m.location) zusatz.append(ico("location-dot"),
                                      document.createTextNode(e.m.location));
      zusatz.append(ico("user-group"), document.createTextNode(wer));
      inhalt.append(zusatz);
      zeile.style.cursor = "pointer";
      zeile.onclick = () => oeffneMeeting(e.m);
    } else {
      const kopf = el("b");
      kopf.append(ico("list-check"), document.createTextNode(" " + e.b.title));
      inhalt.append(kopf);
      const zusatz = el("span", "marke");
      zusatz.append(ico("clock"), document.createTextNode(dauer(e.b.minutes) + " eingeplant"));
      if (e.b.critical) zusatz.append(ico("triangle-exclamation"),
                                      document.createTextNode("kritischer Pfad"));
      inhalt.append(zusatz);
    }
    zeile.append(inhalt);
    links.append(zeile);
  }
  raster.append(links);

  const rechts = el("div");
  rechts.append(hinweisKarte(a.hinweise || []));
  const last = el("div", "karte");
  const lastKopf = el("h3");
  lastKopf.append(ico("gauge-high"), document.createTextNode(" Auslastung"));
  last.append(lastKopf);
  const meter = el("div", "meter");
  const balken = el("i");
  balken.style.width = Math.min(100, a.load.quote) + "%";
  if (a.load.quote > 95) balken.style.background = "var(--critical)";
  meter.append(balken);
  last.append(meter, el("div", "muted",
    `${dauer(a.load.verplant_min)} von ${dauer(a.load.verfuegbar_min)} freier Zeit verplant `
    + `· Arbeitszeit ${a.work.from}–${a.work.to}`));
  rechts.append(last);

  const offen = el("div", "karte");
  const offenKopf = el("h3");
  offenKopf.append(ico("hourglass-half"), document.createTextNode(" Passt heute nicht mehr"));
  offen.append(offenKopf);
  if (!a.unplanned.length) {
    offen.append(el("div", "muted", "Alles Offene ist eingeplant."));
  } else {
    a.unplanned.forEach((u) => {
      const z = el("div", "slot");
      z.append(el("div", "slot__zeit", dauer(u.minutes)));
      z.append(el("div", "slot__inhalt", u.title));
      offen.append(z);
    });
  }
  rechts.append(offen);

  const kommend = el("div", "karte");
  const kommendKopf = el("h3");
  kommendKopf.append(ico("calendar-days"), document.createTextNode(" Nächste Besprechungen"));
  kommend.append(kommendKopf);
  const spaeter = S.meetings.filter((m) => m.starts_at.slice(0, 10) > isoTag(S.agendaTag));
  if (!spaeter.length) kommend.append(el("div", "muted", "Keine weiteren Termine."));
  spaeter.slice(0, 6).forEach((m) => {
    const z = el("div", "slot");
    z.style.cursor = "pointer";
    z.onclick = () => oeffneMeeting(m);
    z.append(el("div", "slot__zeit", `${kurzDatum(m.starts_at)} ${uhr(m.starts_at)}`));
    z.append(el("div", "slot__inhalt", m.title));
    kommend.append(z);
  });
  rechts.append(kommend);
  raster.append(rechts);
  ziel.append(raster);
}

/* -- Kalender ------------------------------------------------------------- */
async function kalenderDaten() {
  S.kalender = S.alleProjekte
    ? await api("/api/calendar")
    : { tasks: (S.daten ? S.daten.tasks : []).filter((t) => !t.summary && t.start)
          .map((t) => ({ ...t, project_id: S.pid,
                         project: S.daten.project.name,
                         color_index: Math.max(0, alleProjekte()
                           .findIndex((p) => p.id === S.pid)) })),
        projects: S.daten ? [{ id: S.pid, name: S.daten.project.name,
                               color_index: Math.max(0, alleProjekte()
                                 .findIndex((p) => p.id === S.pid)) }] : [] };
}

function zeichneKalender(ziel) {
  const leiste = el("div", "toolbar");
  const zurueck = el("button", "btn btn--ghost", "‹");
  const vor = el("button", "btn btn--ghost", "›");
  const heute = el("button", "btn btn--ghost", "Heute");
  // Auf den Ersten setzen: setMonth auf einem 31. läuft in den übernächsten
  // Monat über, der Pfeil sprang dann scheinbar gar nicht.
  const monatWechseln = (d) => {
    S.monat = new Date(S.monat.getFullYear(), S.monat.getMonth() + d, 1);
    zeichnen();
  };
  zurueck.onclick = () => monatWechseln(-1);
  vor.onclick = () => monatWechseln(1);
  heute.onclick = () => { S.monat = new Date(); zeichnen(); };
  const schalter = el("label", "muted");
  const box = el("input");
  box.type = "checkbox";
  box.checked = S.alleProjekte;
  box.onchange = async () => {
    S.alleProjekte = box.checked;
    await kalenderDaten();
    zeichnen();
  };
  schalter.append(box, document.createTextNode(" alle Projekte"));
  const legende = el("div", "legend");
  (S.kalender ? S.kalender.projects : []).forEach((p) => {
    const s = el("span", "", p.name);
    s.prepend(Object.assign(el("i"), { style: `background:${farbe(p.color_index)}` }));
    legende.append(s);
  });
  leiste.append(zurueck, vor, heute,
    el("h2", "", `${MONATE[S.monat.getMonth()]} ${S.monat.getFullYear()}`),
    el("div", "spacer"), legende, schalter);
  ziel.append(leiste);

  const karte = el("div", "cal");
  const gitter = el("div", "cal__grid");
  WOCHENTAGE.forEach((w) => gitter.append(el("div", "cal__dow", w.toUpperCase())));
  karte.append(gitter);
  ziel.append(karte);

  const erster = new Date(S.monat.getFullYear(), S.monat.getMonth(), 1);
  const letzter = new Date(S.monat.getFullYear(), S.monat.getMonth() + 1, 0);
  const start = new Date(erster);
  start.setDate(erster.getDate() - ((erster.getDay() + 6) % 7));
  const wochen = Math.ceil(((letzter - start) / TAG_MS + 1) / 7);
  const heuteIso = isoTag(new Date());
  const eintraege = (S.kalender ? S.kalender.tasks : []).filter((t) => t.start);

  for (let w = 0; w < wochen; w++) {
    const wStart = new Date(start.getTime() + w * 7 * TAG_MS);
    const wEnde = new Date(wStart.getTime() + 6 * TAG_MS);
    const bahnen = bahnenBilden(eintraege, wStart, wEnde);
    const hoehe = Math.max(108, 30 + bahnen.length * 26 + 8);
    const zellen = [];
    for (let i = 0; i < 7; i++) {
      const d = new Date(wStart.getTime() + i * TAG_MS);
      const zelle = el("div", "cal__cell");
      zelle.style.minHeight = hoehe + "px";
      if (d.getMonth() !== S.monat.getMonth()) zelle.classList.add("is-out");
      else if (d.getDay() === 0 || d.getDay() === 6) zelle.classList.add("is-weekend");
      const nummer = el("span", "cal__num", String(d.getDate()));
      if (isoTag(d) === heuteIso) nummer.classList.add("cal__today");
      zelle.append(nummer);
      gitter.append(zelle);
      zellen.push(zelle);
    }
    const bahnenBox = el("div", "cal__lanes");
    zellen[0].append(bahnenBox);
    bahnenBox.style.width = "calc(700% + 6px)";
    bahnen.forEach((bahn, nr) => bahn.forEach((teil) => bahnenBox.append(chip(teil, nr))));
  }
}

function bahnenBilden(eintraege, wStart, wEnde) {
  const teile = [];
  for (const t of eintraege) {
    const a = Math.max(tag(t.start).getTime(), wStart.getTime());
    const b = Math.min(tag(t.end || t.start).getTime(), wEnde.getTime());
    if (a > b) continue;
    teile.push({ t, von: Math.round((a - wStart.getTime()) / TAG_MS),
                 bis: Math.round((b - wStart.getTime()) / TAG_MS),
                 offenLinks: tag(t.start).getTime() < wStart.getTime() });
  }
  teile.sort((x, y) => x.von - y.von || (y.bis - y.von) - (x.bis - x.von));
  const bahnen = [];
  for (const teil of teile) {
    const bahn = bahnen.find((b) => teil.von > b[b.length - 1].bis);
    if (bahn) bahn.push(teil); else bahnen.push([teil]);
  }
  return bahnen;
}

function chip(teil, bahn) {
  const c = farbe(teil.t.color_index || 0);
  const k = el("div", "chip");
  k.style.left = `calc(${teil.von} * (100% / 7) + 4px)`;
  k.style.width = `calc(${teil.bis - teil.von + 1} * (100% / 7) - 8px)`;
  k.style.top = bahn * 26 + "px";
  k.style.background = mischen(c, 0.88);
  k.style.color = mischen(c, -0.45);
  k.style.borderLeftColor = c;
  if (teil.offenLinks) k.append(ico("caret-left"));
  if (teil.t.milestone) k.append(ico("diamond"));
  else if (teil.t.progress >= 100) k.append(ico("check"));
  k.append(document.createTextNode(teil.t.title));
  k.title = `${teil.t.project} · ${teil.t.key} · ${kurzDatum(teil.t.start)}–`
    + `${kurzDatum(teil.t.end)}${teil.t.assignee ? " · " + teil.t.assignee : ""}`;
  k.onclick = async () => {
    if (teil.t.project_id !== S.pid) await waehleProjekt(teil.t.project_id);
    if (!S.daten) return;          // der Wechsel kann gescheitert sein
    const vorgang = S.daten.tasks.find((x) => x.key === teil.t.key);
    if (vorgang) oeffneVorgang(vorgang);
  };
  return k;
}

/* -- Gantt ---------------------------------------------------------------- */
function zeichneGantt(ziel) {
  // Scheitert die Terminrechnung, liefert der Dienst alle Vorgänge ohne Termine
  // und die Oberfläche zeigt den Grund an. Der Balkenplan rechnete trotzdem mit
  // den fehlenden Daten weiter und ließ die Ansicht leer zurück.
  const vorgaenge = S.daten.tasks.filter((t) => t.start && t.end);
  const leiste = el("div", "toolbar");
  leiste.append(el("h2", "", "Balkenplan"),
    el("span", "muted", S.daten.project.critical_path.length
      ? "kritischer Pfad: " + S.daten.project.critical_path.join(" → ")
      : "kein kritischer Pfad"));
  ziel.append(leiste);
  if (!vorgaenge.length) {
    if (S.daten.error) {
      ziel.append(el("div", "empty", "Der Plan lässt sich nicht rechnen: " + S.daten.error));
      return;
    }
    const leer = el("div", "empty");
    leer.append(ico("chart-gantt", "solid", "gross"),
                document.createTextNode("Noch keine Vorgänge. Lege oben rechts einen an."));
    ziel.append(leer);
    return;
  }
  const px = 26;
  const anfang = new Date(Math.min(...vorgaenge.map((t) => tag(t.start).getTime()))
    - 3 * TAG_MS);
  const tage = Math.round((Math.max(...vorgaenge.map((t) => tag(t.end).getTime()))
    - anfang.getTime()) / TAG_MS) + 5;
  const x = (iso) => Math.round((tag(iso) - anfang) / TAG_MS) * px;

  const rahmen = el("div", "gantt");
  const namen = el("div", "gantt__names");
  namen.append(Object.assign(el("div", "gantt__head"), { style: "height:46px" }));
  const scroll = el("div", "gantt__scroll");
  const kopf = el("div", "gantt__head");
  kopf.style.width = tage * px + "px";
  const flaeche = el("div", "gantt__canvas");
  flaeche.style.width = tage * px + "px";
  flaeche.style.height = vorgaenge.length * 30 + "px";

  for (let i = 0; i < tage; i++) {
    const d = new Date(anfang.getTime() + i * TAG_MS);
    if (d.getDay() === 0 || d.getDay() === 6) {
      const wk = el("div", "gantt__weekend");
      wk.style.left = i * px + "px";
      wk.style.width = px + "px";
      flaeche.append(wk);
    }
    if (d.getDate() === 1 || i === 0) {
      const m = el("div", "gantt__month",
        `${MONATE[d.getMonth()].slice(0, 3)} ${d.getFullYear()}`);
      m.style.left = i * px + "px";
      kopf.append(m);
    }
    const zahl = el("div", "gantt__day", String(d.getDate()));
    zahl.style.left = i * px + "px";
    zahl.style.width = px + "px";
    kopf.append(zahl);
  }
  const heuteX = x(isoTag(new Date()));
  if (heuteX >= 0 && heuteX <= tage * px) {
    const linie = el("div", "gantt__today");
    linie.style.left = heuteX + "px";
    flaeche.append(linie);
  }

  const zeilenNr = {};
  vorgaenge.forEach((t, i) => {
    zeilenNr[t.key] = i;
    const zeile = el("div", "gantt__row" + (t.summary ? " is-summary" : ""));
    zeile.style.paddingLeft = 12 + t.level * 16 + "px";
    zeile.append(el("span", "", `${t.key}  ${t.title}`));
    if (t.assignee) zeile.append(el("span", "who", t.assignee));
    zeile.onclick = () => oeffneVorgang(t);
    namen.append(zeile);

    const gitterlinie = el("div", "gantt__line");
    gitterlinie.style.top = i * 30 + "px";
    flaeche.append(gitterlinie);

    const links = x(t.start);
    const breite = Math.max(px, x(t.end) + px - links);
    if (t.summary) {
      const b = el("div", "gantt__sum");
      b.style.cssText += `left:${links}px;width:${breite}px;top:${i * 30 + 12}px`;
      flaeche.append(b);
    } else if (t.milestone) {
      const b = el("div", "gantt__ms");
      b.style.cssText += `left:${links - 7}px;top:${i * 30 + 8}px`;
      flaeche.append(b);
    } else {
      const b = el("div", "gantt__bar" + (t.critical ? " is-critical" : ""));
      b.style.cssText += `left:${links}px;width:${breite}px;top:${i * 30 + 6}px`;
      const f = el("i");
      f.style.width = t.progress + "%";
      b.append(f);
      b.title = `${t.title} · ${kurzDatum(t.start)}–${kurzDatum(t.end)} · ${t.progress} %`;
      b.onclick = () => oeffneVorgang(t);
      flaeche.append(b);
    }
    const marken = [];
    if (t.progress && !t.milestone) marken.push(t.progress + " %");
    if (t.slack > 0 && !t.summary) marken.push(`Puffer ${t.slack} T`);
    if (marken.length) {
      const m = el("div", "gantt__tag", marken.join("   "));
      m.style.cssText += `left:${links + breite + 8}px;top:${i * 30 + 8}px`;
      flaeche.append(m);
    }
  });

  const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
  svg.setAttribute("width", tage * px);
  svg.setAttribute("height", vorgaenge.length * 30);
  svg.style.cssText = "position:absolute;inset:0;pointer-events:none";
  vorgaenge.forEach((t) => (t.deps || []).forEach((d) => {
    if (!(d in zeilenNr)) return;
    const von = vorgaenge[zeilenNr[d]];
    const y1 = zeilenNr[d] * 30 + 15;
    const y2 = zeilenNr[t.key] * 30 + 15;
    const x1 = x(von.end) + px;
    const x2 = x(t.start);
    const linie = document.createElementNS("http://www.w3.org/2000/svg", "polyline");
    linie.setAttribute("points",
      `${x1},${y1} ${x1 + 9},${y1} ${x1 + 9},${y2} ${Math.max(x2, x1 + 9)},${y2}`);
    linie.setAttribute("fill", "none");
    linie.setAttribute("stroke", "#94a3b8");
    linie.setAttribute("marker-end", "url(#pfeil)");
    svg.append(linie);
  }));
  svg.insertAdjacentHTML("afterbegin",
    '<defs><marker id="pfeil" markerWidth="7" markerHeight="7" refX="6" refY="3.5" '
    + 'orient="auto"><path d="M0,0 L7,3.5 L0,7 z" fill="#94a3b8"/></marker></defs>');
  flaeche.append(svg);

  scroll.append(kopf, flaeche);
  rahmen.append(namen, scroll);
  ziel.append(rahmen);
}

/* -- Kanban --------------------------------------------------------------- */
function zeichneKanban(ziel) {
  ziel.append(el("div", "toolbar"));
  const brett = el("div", "board");
  const blaetter = S.daten.tasks.filter((t) => !t.summary);
  S.daten.project.columns.forEach((spalte, i) => {
    const karten = blaetter.filter((t) => t.status === spalte);
    const box = el("div", "col");
    const kopf = el("div", "col__head");
    const punkt = el("span", "dot");
    punkt.style.background = farbe(i);
    kopf.append(punkt, el("b", "", spalte), el("span", "count", String(karten.length)));
    box.append(kopf);
    karten.forEach((t) => box.append(karte(t)));
    box.ondragover = (e) => { e.preventDefault(); box.classList.add("is-over"); };
    box.ondragleave = () => box.classList.remove("is-over");
    box.ondrop = async (e) => {
      e.preventDefault();
      box.classList.remove("is-over");
      const id = Number(e.dataTransfer.getData("text/plain"));
      const vorgang = S.daten.tasks.find((t) => t.id === id);
      if (!vorgang || vorgang.status === spalte) return;
      await speichern(`/api/tasks/${id}`, "PATCH", { status: spalte });
    };
    brett.append(box);
  });
  ziel.append(brett);
}

function karte(t) {
  const k = el("div", "card" + (t.critical ? " is-critical" : ""));
  k.draggable = true;
  k.ondragstart = (e) => e.dataTransfer.setData("text/plain", String(t.id));
  k.onclick = () => oeffneVorgang(t);
  if (t.parent) {
    const eltern = S.daten.tasks.find((x) => x.key === t.parent);
    if (eltern) k.append(el("div", "card__parent", eltern.title));
  }
  k.append(el("div", "card__title", t.title));
  const meta = el("div", "card__meta");
  const marke = el("span");
  marke.append(ico(t.milestone ? "diamond" : "clock"),
               document.createTextNode(" " + (t.milestone ? `${t.key} · Meilenstein`
                                                          : `${t.key} · ${t.duration} T`)));
  meta.append(marke);
  if (t.assignee) meta.append(el("div", "avatar", kuerzel(t.assignee)));
  k.append(meta);
  const balken = el("div", "bar");
  const f = el("i");
  f.style.width = t.progress + "%";
  if (t.progress >= 100) f.style.background = "var(--done)";
  balken.append(f);
  k.append(balken);
  return k;
}

/* -- Vorgangsliste -------------------------------------------------------- */
function zeichneListe(ziel) {
  const leiste = el("div", "toolbar");
  const exportKnopf = knopf("btn", "file-export", ".pmproj exportieren");
  exportKnopf.onclick = () => location.href = `/api/projects/${S.pid}/export.pmproj`;
  const importFeld = el("input");
  importFeld.type = "file";
  importFeld.accept = ".pmproj,application/json";
  importFeld.style.display = "none";
  importFeld.onchange = async () => {
    const daten = new FormData();
    daten.append("datei", importFeld.files[0]);
    daten.append("workspace_id", String(S.daten.project.workspace.id));
    try {
      const r = await api("/api/import", { method: "POST", body: daten });
      meldung(`${r.tasks} Vorgänge importiert`);
      await orgLaden();
      await waehleProjekt(r.id);
    } catch (e) { meldung(e.message, true); }
  };
  const importKnopf = knopf("btn", "file-import", ".pmproj importieren");
  importKnopf.onclick = () => importFeld.click();
  leiste.append(el("h2", "", "Vorgänge"), el("div", "spacer"),
                importKnopf, exportKnopf, importFeld);
  ziel.append(leiste);

  const tabelle = el("table", "grid");
  tabelle.innerHTML = `<thead><tr><th>Vorgang</th><th>Start</th><th>Ende</th>
    <th class="num">Dauer</th><th class="num">Puffer</th><th>Status</th>
    <th>Verantwortlich</th><th class="num">Aufwand h</th><th class="num">%</th>
    </tr></thead>`;
  const koerper = el("tbody");
  S.daten.tasks.forEach((t) => {
    const tr = el("tr", (t.critical ? "is-critical " : "") + (t.summary ? "is-summary" : ""));
    const name = el("td");
    name.style.paddingLeft = 10 + t.level * 20 + "px";
    name.textContent = `${t.key}  ${t.title}`;
    if (t.milestone) name.append(document.createTextNode(" "), ico("diamond"));
    if (t.summary) name.prepend(ico("folder-tree"), document.createTextNode(" "));
    tr.append(name, el("td", "", kurzDatum(t.start)), el("td", "", kurzDatum(t.end)),
      el("td", "num", t.summary ? "–" : String(t.duration)),
      el("td", "num", String(t.slack)), el("td", "", t.status),
      el("td", "", t.assignee || "–"),
      el("td", "num", String(t.effort_total)), el("td", "num", String(t.progress)));
    tr.onclick = () => oeffneVorgang(t);
    koerper.append(tr);
  });
  tabelle.append(koerper);
  ziel.append(tabelle);
  // Erweiterungen hängen Knöpfe und Karten an (Konnektoren: GitHub).
  document.dispatchEvent(new CustomEvent("liste-gezeichnet", { detail: { leiste, ziel } }));
}

/* -- Dokumente ------------------------------------------------------------ */
async function dokumenteLaden() {
  S.dokumente = S.pid ? await api(`/api/projects/${S.pid}/documents`) : [];
}

function zeichneDokumente(ziel) {
  const leiste = el("div", "toolbar");
  const neu = knopf("btn btn--primary", "file-arrow-up", "Dokument");
  neu.onclick = () => oeffneDokument(null);
  leiste.append(el("h2", "", "Dokumente"),
    el("span", "muted", `${S.dokumente.length} Dokument(e) · `
      + `${bytes(S.dokumente.reduce((s, d) => s + d.size, 0))}`),
    el("div", "spacer"), neu);
  ziel.append(leiste);

  const zone = el("div", "dropzone");
  zone.append(ico("cloud-arrow-up", "solid", "gross"), document.createTextNode(
    "Datei hierher ziehen oder oben auf „Dokument“ klicken. "
    + "Gleichnamige Dateien werden als neue Fassung geführt."));
  zone.ondragover = (e) => { e.preventDefault(); zone.classList.add("is-over"); };
  zone.ondragleave = () => zone.classList.remove("is-over");
  zone.ondrop = async (e) => {
    e.preventDefault();
    zone.classList.remove("is-over");
    for (const datei of e.dataTransfer.files) await dateiHochladen(datei);
    await dokumenteLaden();
    zeichnen();
  };
  ziel.append(zone);

  if (!S.dokumente.length) {
    const leer = el("div", "empty");
    leer.append(ico("folder-open", "solid", "gross"),
                document.createTextNode("Noch keine Dokumente in diesem Projekt."));
    ziel.append(leer);
    return;
  }
  const tabelle = el("table", "grid");
  tabelle.innerHTML = `<thead><tr><th>Dokument</th><th>Datei</th><th>Vorgang</th>
    <th class="num">Fassung</th><th class="num">Größe</th><th>Zuletzt</th><th></th>
    </tr></thead>`;
  const koerper = el("tbody");
  S.dokumente.forEach((d) => {
    const tr = el("tr");
    const name = el("td");
    name.append(ico(dateiSymbol(d.content_type, d.filename), "solid", "datei"),
                document.createTextNode(d.title));
    tr.append(name, el("td", "", d.filename), el("td", "", d.task_key || "–"),
      el("td", "num", String(d.version)), el("td", "num", bytes(d.size)),
      el("td", "", `${kurzDatum(d.updated_at)}${d.uploaded_by ? " · " + d.uploaded_by.name : ""}`));
    const aktion = el("td");
    const laden = el("a", "btn btn--ghost");
    laden.append(ico("download"), document.createTextNode(" Öffnen"));
    laden.href = `/api/documents/${d.id}/download`;
    laden.onclick = (e) => e.stopPropagation();
    aktion.append(laden);
    tr.append(aktion);
    tr.onclick = () => oeffneDokument(d);
    koerper.append(tr);
  });
  tabelle.append(koerper);
  ziel.append(tabelle);
}

async function dateiHochladen(datei, task_id = null) {
  const daten = new FormData();
  daten.append("datei", datei);
  if (task_id) daten.append("task_id", String(task_id));
  const gleich = S.dokumente.find((d) => d.filename === datei.name);
  try {
    if (gleich) {
      await api(`/api/documents/${gleich.id}/versions`, { method: "POST", body: daten });
      meldung(`Neue Fassung von „${gleich.title}“ gespeichert`);
    } else {
      await api(`/api/projects/${S.pid}/documents`, { method: "POST", body: daten });
      meldung(`„${datei.name}“ hochgeladen`);
    }
  } catch (e) { meldung(e.message, true); }
}

/* -- Team ----------------------------------------------------------------- */
function zeichneTeam(ziel) {
  const darfVerwalten = ["admin", "manager"].includes(S.daten.role);
  const kopf = el("div", "toolbar");
  kopf.append(el("h2", "", "Beteiligte"), el("div", "spacer"));
  if (darfVerwalten) {
    const feld = el("input");
    feld.type = "text";
    feld.placeholder = "Benutzername oder E-Mail";
    feld.style.width = "230px";
    const rolle = el("select");
    ["member", "manager"].forEach((r) => {
      const o = el("option", "", ROLLEN[r]);
      o.value = r;
      rolle.append(o);
    });
    rolle.style.width = "140px";
    const hinzu = knopf("btn btn--primary", "user-plus", "Hinzufügen");
    hinzu.onclick = async () => {
      if (!feld.value.trim()) return;
      try {
        const r = await jsonRuf("/api/members", "POST", {
          scope_kind: "project", scope_id: S.pid,
          username: feld.value.trim(), role: rolle.value });
        meldung(`${r.username} ist jetzt beteiligt`);
        feld.value = "";
        await projektLaden();
        zeichnen();
      } catch (e) { meldung(e.message, true); }
    };
    const link = knopf("btn", "link", "Einladungslink");
    const linkFeld = el("input");
    linkFeld.type = "text";
    linkFeld.readOnly = true;
    linkFeld.placeholder = "Einladungslink erscheint hier";
    link.onclick = () => einladung("project", S.pid, rolle.value, linkFeld);
    kopf.append(feld, rolle, hinzu, link, linkFeld);
    if (S.me.smtp_plattform) {
      // Postausgang eingerichtet: der Link geht auf Wunsch gleich per Mail raus.
      const mailFeld = el("input");
      mailFeld.type = "email";
      mailFeld.placeholder = "per Mail einladen: adresse@…";
      mailFeld.style.width = "220px";
      const mailKnopf = knopf("btn", "envelope", "Einladung senden");
      mailKnopf.onclick = () => einladung("project", S.pid, rolle.value, linkFeld, mailFeld.value.trim());
      kopf.append(mailFeld, mailKnopf);
    }
  }
  ziel.append(kopf);

  const tabelle = el("table", "grid");
  tabelle.innerHTML = `<thead><tr><th>Person</th><th>Rolle</th><th>Herkunft</th>
    <th class="num">Vorgänge</th><th class="num">Aufwand h</th><th></th></tr></thead>`;
  const koerper = el("tbody");
  S.daten.members.forEach((m) => {
    const meine = S.daten.tasks.filter((t) => !t.summary && t.assignee_id === m.user_id);
    const tr = el("tr");
    const zelle = el("td");
    zelle.append(personEl(m, m.user_id));
    const rolleZelle = el("td");
    const rolleText = el("span", "rolle");
    rolleText.append(ico(ROLLEN_SYMBOL[m.role] || "user"),
                     document.createTextNode(ROLLEN[m.role] || m.role));
    rolleZelle.append(rolleText);
    tr.append(zelle, rolleZelle,
      el("td", "", m.direct ? "direkt am Projekt" : "geerbt: " + m.from),
      el("td", "num", String(meine.length)),
      el("td", "num", String(meine.reduce((s, t) => s + t.effort_h, 0))));
    const aktion = el("td");
    if (darfVerwalten && m.direct) {
      const raus = knopf("btn btn--ghost", "user-minus", "entfernen");
      raus.onclick = async () => {
        try {
          await api(`/api/members?scope_kind=project&scope_id=${S.pid}`
            + `&user_id=${m.user_id}`, { method: "DELETE" });
          await projektLaden();
          zeichnen();
        } catch (e) { meldung(e.message, true); }
      };
      aktion.append(raus);
    }
    tr.append(aktion);
    koerper.append(tr);
  });
  tabelle.append(koerper);
  ziel.append(tabelle);
  const hinweis = el("div", "empty");
  hinweis.style.marginTop = "16px";
  hinweis.append(ico("circle-info"), document.createTextNode(
    " Rollen vererben sich von der Organisation über den Arbeitsbereich bis zum Projekt. "
    + "Wer eine Rolle geerbt hat, kann hier nicht einzeln entfernt werden – "
    + "das geschieht auf der Ebene, von der die Rolle stammt."));
  ziel.append(hinweis);
}

/* -- Organisation --------------------------------------------------------- */
/* -- Börse: Ausschreibungen für Arbeit und Freelance ----------------------
   Alle Angemeldeten sehen alle Ausschreibungen; die Gebote liefert der Dienst
   nur an die ausschreibende Leitung aus. Gefiltert wird hier im Browser - bei
   der Menge an Ausschreibungen einer Firma lohnt kein Serverfilter. */
async function marktLaden() {
  S.markt = await api("/api/market");
}

const geld = (n) => n
  ? n.toLocaleString("de-DE", { style: "currency", currency: "EUR",
                                maximumFractionDigits: 0 })
  : "nach Absprache";

function bereicheMitLeitung() {
  return S.org.workspaces.filter((w) => ["admin", "manager"].includes(w.role));
}

function marktGefiltert() {
  return S.markt.filter((a) => {
    if (a.kind === S.marktFilter) return a.status !== "closed";
    if (S.marktFilter === "gebote") return Boolean(a.my_bid);
    if (S.marktFilter === "meine") return a.manage;
    return S.marktFilter === "alle" && a.status !== "closed";
  });
}

function zeichneBoerse(ziel) {
  const leiste = el("div", "toolbar");
  const filter = el("select");
  fuelleAuswahl(filter, [["alle", "Alle offenen"], ["work", "Arbeit im Haus"],
                         ["freelance", "Freelance"], ["gebote", "Mit meinem Gebot"],
                         ["meine", "Meine Ausschreibungen"]], S.marktFilter);
  filter.onchange = () => { S.marktFilter = filter.value; zeichnen(); };
  const neu = knopf("btn btn--primary", "plus", "Ausschreiben");
  neu.onclick = () => oeffneMarkt(null);
  neu.disabled = !bereicheMitLeitung().length;
  neu.title = neu.disabled
    ? "Zum Ausschreiben wird die Leitung eines Arbeitsbereichs benötigt" : "";
  leiste.append(el("h2", "", "Interne Börse"), el("div", "spacer"), filter, neu);
  ziel.append(leiste);

  const treffer = marktGefiltert();
  if (!treffer.length) {
    const leer = el("div", "empty");
    leer.append(ico("handshake", "solid", "gross"), document.createTextNode(
      "Nichts ausgeschrieben. Wer einen Arbeitsbereich leitet, stellt hier Arbeit "
      + "ein – im Haus oder als Freelance-Auftrag – und vergibt sie an das beste "
      + "Gebot."));
    ziel.append(leer);
    return;
  }
  treffer.forEach((a) => ziel.append(marktKarte(a)));
}

function marktKarte(a) {
  const karte = el("div", "karte");
  const kopf = el("div", "toolbar");
  kopf.style.marginBottom = "6px";
  const art = el("span", "pill");
  art.append(ico(WS_SYMBOL[a.kind] || "briefcase"),
             document.createTextNode(a.kind === "freelance" ? "Freelance" : "Arbeit"));
  const stand = el("span", "pill", MARKT_STATUS[a.status] || a.status);
  kopf.append(art, stand, Object.assign(el("h3", "", a.title), { style: "margin:0" }),
              el("div", "spacer"), el("b", "", geld(a.budget)));
  karte.append(kopf);

  const wo = [a.workspace ? a.workspace.name : "–", a.project && a.project.name]
    .filter(Boolean).join(" › ");
  const teile = [wo, a.effort_h ? a.effort_h + " h" : null,
                 a.deadline ? "Frist " + kurzDatum(a.deadline) : null,
                 a.author ? "von " + a.author.name : null,
                 a.assignee ? "vergeben an " + a.assignee.name : null,
                 a.skill ? `verlangt Stufe ${a.stufe} in ${a.skill.name}` : null,
                 a.manage ? `${a.bids_count} Gebot(e)` : null];
  karte.append(el("div", "muted", teile.filter(Boolean).join(" · ")));
  if (a.description) {
    const text = el("p", "", a.description);
    text.style.whiteSpace = "pre-wrap";
    karte.append(text);
  }

  const fuss = el("div", "toolbar");
  fuss.style.marginTop = "10px";
  fuss.append(el("div", "spacer"));
  const eigen = a.author && S.me && a.author.id === S.me.id;
  if (a.my_bid) {
    const eigenes = el("span", "muted",
      `Dein Gebot: ${geld(a.my_bid.price)} (${MARKT_STATUS[a.my_bid.status] || a.my_bid.status})`);
    fuss.insertBefore(eigenes, fuss.firstChild);
  }
  if (!eigen && !a.manage && a.status === "open") {
    const bieten = knopf("btn", "gavel", a.my_bid ? "Gebot ändern" : "Gebot abgeben");
    bieten.onclick = () => oeffneGebot(a);
    fuss.append(bieten);
  }
  if (a.manage) {
    if (a.status === "open") {
      const zu = knopf("btn btn--ghost", "lock", "Schließen");
      zu.onclick = () => marktStatus(a, "closed");
      fuss.append(zu);
    }
    const bearbeiten = knopf("btn btn--ghost", "pen", "Bearbeiten");
    bearbeiten.onclick = () => oeffneMarkt(a);
    fuss.append(bearbeiten);
  }
  karte.append(fuss);

  if (a.manage && (a.bids || []).length) {
    const tab = el("table", "grid");
    tab.innerHTML = `<thead><tr><th>Gebot von</th>${a.skill ? "<th>Stufe</th>" : ""}<th class="num">Preis</th>
      <th>Nachricht</th><th></th></tr></thead>`;
    const tb = el("tbody");
    a.bids.forEach((g) => {
      const tr = el("tr");
      const wer = el("td");
      wer.append(g.user ? personEl(g.user, g.user.id) : el("span", "", "?"));
      tr.append(wer);
      if (a.skill) tr.append(el("td", "", g.stufe ? `Stufe ${g.stufe}` : "–"));
      tr.append(el("td", "num", geld(g.price)), el("td", "", g.message || "–"));
      const zelle = el("td");
      if (a.status === "open") {
        const zuschlag = knopf("btn btn--primary", "check", "Zuschlag");
        zuschlag.onclick = () => zuschlagGeben(a, g);
        zelle.append(zuschlag);
      } else {
        zelle.append(el("span", "muted", MARKT_STATUS[g.status] || g.status));
      }
      tr.append(zelle);
      tb.append(tr);
    });
    tab.append(tb);
    karte.append(tab);
  }
  return karte;
}

async function zuschlagGeben(a, g) {
  const name = g.user ? g.user.name : "diese Person";
  if (!confirm(`„${a.title}“ an ${name} vergeben?`
    + (a.project ? ` Im Projekt ${a.project.name} entsteht dabei ein Vorgang.` : ""))) return;
  try {
    const r = await api(`/api/market/${a.id}/bids/${g.id}/accept`, { method: "POST" });
    meldung(r.task_id ? "Zuschlag erteilt, Vorgang angelegt" : "Zuschlag erteilt");
    await marktLaden();
    await orgLaden();
    zeichnen();
  } catch (e) { meldung(e.message, true); }
}

async function marktStatus(a, status) {
  try {
    await jsonRuf(`/api/market/${a.id}`, "PATCH", { title: a.title, kind: a.kind,
      description: a.description, budget: a.budget, effort_h: a.effort_h,
      deadline: a.deadline, skill_id: a.skill ? a.skill.id : null, stufe: a.stufe || 0, status });
    await marktLaden();
    zeichnen();
  } catch (e) { meldung(e.message, true); }
}

let offeneAusschreibung = null;

function oeffneMarkt(a) {
  offeneAusschreibung = a;
  titelSetzen("#markt-titel", a ? "pen" : "handshake",
              a ? "Ausschreibung bearbeiten" : "Neue Ausschreibung");
  const bereiche = bereicheMitLeitung();
  fuelleAuswahl($("#m-ws"), bereiche.map((w) => [String(w.id),
                                                 `${w.name} (${WS_ARTEN[w.kind] || "Arbeit"})`]),
                String(a ? a.workspace.id : (bereiche[0] || {}).id || ""));
  $("#m-ws").disabled = Boolean(a);
  $("#m-kind").value = a ? a.kind
    : (bereiche[0] && bereiche[0].kind === "freelance" ? "freelance" : "work");
  $("#m-title").value = a ? a.title : "";
  $("#m-desc").value = a ? a.description : "";
  $("#m-budget").value = a ? a.budget : 0;
  $("#m-effort").value = a ? a.effort_h : 0;
  $("#m-deadline").value = a && a.deadline ? a.deadline : "";
  // Verlangter Nachweis: nur mit der Erweiterung Wissenstests wählbar; eine
  // vorhandene Anforderung bleibt beim Bearbeiten trotzdem erhalten.
  $("#m-skill-zeile").hidden = !S.me.module.wissen;
  fuelleAuswahl($("#m-skill"), [["", "keine Anforderung"]], a && a.skill ? String(a.skill.id) : "");
  $("#m-stufe").value = String(a && a.stufe ? a.stufe : 1);
  if (S.me.module.wissen) {
    api("/api/wissen/skills").then((liste) => fuelleAuswahl($("#m-skill"),
      [["", "keine Anforderung"]].concat(liste.map((sk) => [String(sk.id), sk.name])),
      a && a.skill ? String(a.skill.id) : "")).catch(() => {});
  }
  marktProjekte(a ? a.project && a.project.id : null);
  $("#m-ws").onchange = () => marktProjekte(null);
  $("#m-projekt").disabled = Boolean(a);
  $("#m-delete").hidden = !a;
  $("#dlg-markt").showModal();
}

function marktProjekte(pid) {
  const ws = S.org.workspaces.find((w) => String(w.id) === $("#m-ws").value);
  const paare = [["", "ohne Projekt"]].concat(
    ((ws && ws.projects) || []).map((p) => [String(p.id), p.name]));
  fuelleAuswahl($("#m-projekt"), paare, pid ? String(pid) : "");
}

function oeffneGebot(a) {
  offeneAusschreibung = a;
  $("#gebot-sub").textContent = `${a.title} · ${geld(a.budget)}`
    + (a.deadline ? ` · Frist ${kurzDatum(a.deadline)}` : "");
  $("#g-price").value = a.my_bid ? a.my_bid.price : a.budget || 0;
  $("#g-message").value = a.my_bid ? a.my_bid.message : "";
  $("#g-delete").hidden = !a.my_bid;
  $("#dlg-gebot").showModal();
}

function zeichneOrg(ziel) {
  const leiste = el("div", "toolbar");
  const neu = knopf("btn btn--primary", "building", "Unternehmen");
  neu.onclick = () => oeffneOrg(null, null);
  leiste.append(el("h2", "", "Struktur und Zugriff"), el("div", "spacer"), neu);
  ziel.append(leiste);

  if (!S.org.units.length) {
    const leer = el("div", "empty");
    leer.append(ico("sitemap", "solid", "gross"), document.createTextNode(
      "Noch keine Organisation. Lege ein Unternehmen an – du wirst dessen Administration "
      + "und kannst danach Niederlassungen, Büros, Abteilungen und Arbeitsbereiche "
      + "aufbauen. Oder starte allein mit den drei üblichen Bereichen."));
    const start = knopf("btn btn--primary", "wand-magic-sparkles",
                        "Arbeit, Freelance und Persönlich anlegen");
    start.onclick = standardbereiche;
    leer.append(start);
    ziel.append(leer);
    return;
  }
  const kinder = (pid) => S.org.units.filter((u) => u.parent_id === pid);
  const bauen = (unit, tiefe) => {
    const karte = el("div", "karte");
    karte.style.marginLeft = tiefe * 22 + "px";
    const kopf = el("div", "toolbar");
    kopf.style.marginBottom = "6px";
    const art = el("span", "pill");
    art.append(ico(ORG_SYMBOL[unit.kind] || "building"),
               document.createTextNode(ARTEN[unit.kind] || unit.kind));
    const rolle = el("span", "rolle muted");
    rolle.append(ico(ROLLEN_SYMBOL[unit.role] || "user"),
                 document.createTextNode(ROLLEN[unit.role] || "kein Zugriff"));
    kopf.append(art, Object.assign(el("h3", "", unit.name), { style: "margin:0" }),
                rolle, el("div", "spacer"));
    if (unit.role === "admin") {
      const b1 = knopf("btn btn--ghost", "sitemap", "Untereinheit");
      b1.onclick = () => oeffneOrg(null, unit.id);
      const b2 = knopf("btn btn--ghost", "layer-group", "Arbeitsbereich");
      b2.onclick = () => oeffneWorkspace(null, unit.id);
      const b3 = knopf("btn btn--ghost", "users", "Personen");
      b3.onclick = () => mitgliederVerwalten("org", unit.id, unit.name);
      const b4 = knopf("btn btn--ghost", "pen", "Bearbeiten");
      b4.onclick = () => oeffneOrg(unit, unit.parent_id);
      kopf.append(b1, b2, b3, b4);
    }
    karte.append(kopf);
    const bereiche = S.org.workspaces.filter((w) => w.org_unit_id === unit.id);
    if (bereiche.length) {
      const tab = el("table", "grid");
      tab.innerHTML = `<thead><tr><th>Arbeitsbereich</th><th>Art</th><th>Beschreibung</th>
        <th class="num">Projekte</th><th></th></tr></thead>`;
      const tb = el("tbody");
      bereiche.forEach((w) => {
        const tr = el("tr");
        const artZelle = el("td");
        const artPille = el("span", "pill");
        artPille.append(ico(WS_SYMBOL[w.kind] || "layer-group"),
                        document.createTextNode(WS_ARTEN[w.kind] || "Arbeit"));
        artZelle.append(artPille);
        tr.append(el("td", "", w.name), artZelle, el("td", "", w.description || "–"),
                  el("td", "num", String((w.projects || []).length)));
        const aktion = el("td");
        if (["admin", "manager"].includes(w.role)) {
          const b = knopf("btn btn--ghost", "diagram-project", "Projekt");
          b.onclick = (e) => { e.stopPropagation(); oeffneProjekt(null, w.id); };
          const m = knopf("btn btn--ghost", "users", "Personen");
          m.onclick = (e) => { e.stopPropagation(); mitgliederVerwalten("workspace", w.id, w.name); };
          aktion.append(b, m);
        }
        tr.append(aktion);
        tr.onclick = () => oeffneWorkspace(w, unit.id);
        tb.append(tr);
      });
      tab.append(tb);
      karte.append(tab);
    } else {
      karte.append(el("div", "muted", "Noch kein Arbeitsbereich in dieser Einheit."));
    }
    ziel.append(karte);
    kinder(unit.id).forEach((k) => bauen(k, tiefe + 1));
  };
  kinder(null).forEach((u) => bauen(u, 0));
}

/** Die drei Bereiche, die fast jede Person führt - in einem Rutsch angelegt. */
async function standardbereiche() {
  try {
    for (const [kind, name] of [["work", "Arbeit"], ["freelance", "Freelance"],
                                ["personal", "Persönlich"]]) {
      await jsonRuf("/api/workspaces", "POST", { name, kind, description: "" });
    }
    meldung("Arbeit, Freelance und Persönlich angelegt");
    await orgLaden();
    zeichnen();
  } catch (e) { meldung(e.message, true); }
}


/** Personen einer Ebene verwalten.

    Vorher hing hier ein frei schwebendes Kästchen am Körper: es blieb beim
    Ansichtswechsel stehen, stapelte sich bei mehrfachem Öffnen und lag über
    seinen eigenen Fehlermeldungen. Ein Dialog löst alle drei Punkte. */
async function mitgliederVerwalten(art, id, name) {
  S.personenZiel = { art, id, name };
  let liste = [];
  try {
    liste = await api(`/api/members?scope_kind=${art}&scope_id=${id}`);
  } catch (e) { meldung(e.message, true); return; }
  titelSetzen("#pe-titel", "users", `Personen: ${name}`);
  const ziel = $("#pe-liste");
  ziel.replaceChildren();
  if (liste.length) {
    const tab = el("table", "grid");
    const tb = el("tbody");
    liste.forEach((m) => {
      const tr = el("tr");
      const rolleZelle = el("td");
      const rolleText = el("span", "rolle");
      rolleText.append(ico(ROLLEN_SYMBOL[m.role] || "user"),
                       document.createTextNode(ROLLEN[m.role] || m.role));
      rolleZelle.append(rolleText);
      const weg = knopf("btn btn--ghost", "user-minus", "entfernen");
      weg.onclick = async () => {
        try {
          await api(`/api/members?scope_kind=${art}&scope_id=${id}&user_id=${m.user_id}`,
                    { method: "DELETE" });
          await orgLaden();
          mitgliederVerwalten(art, id, name);
        } catch (e) { meldung(e.message, true); }
      };
      const aktion = el("td");
      aktion.append(weg);
      tr.append(el("td", "", m.name), rolleZelle, aktion);
      tb.append(tr);
    });
    tab.append(tb);
    ziel.append(tab);
  } else {
    ziel.append(el("div", "muted", "Hier ist niemand unmittelbar eingetragen."));
  }
  fuelleAuswahl($("#pe-rolle"), Object.entries(ROLLEN), $("#pe-rolle").value || "member");
  $("#pe-name").value = "";
  $("#pe-url").value = "";
  if (!$("#dlg-personen").open) $("#dlg-personen").showModal();
}

/** Einladungslink erzeugen und sichtbar hinterlegen.

    Die Zwischenablage gibt es nur in einem sicheren Kontext; im Hausnetz läuft
    die Anwendung über http. Vorher meldete die Oberfläche trotzdem „kopiert“
    und der Link verschwand nach drei Sekunden mit der Meldung. */
async function einladung(art, id, rolle, feld, email) {
  if (email !== undefined && !email) { meldung("Bitte eine E-Mail-Adresse angeben.", true); return; }
  try {
    const r = await jsonRuf("/api/invites", "POST",
                            { scope_kind: art, scope_id: id, role: rolle, email: email || null });
    if (feld) {
      feld.value = r.url;
      feld.select();
    }
    if (r.gesendet) { meldung(`Einladung an ${email} gesendet`); return; }
    try {
      await navigator.clipboard.writeText(r.url);
      meldung("Einladungslink kopiert, gültig für sieben Tage");
    } catch (ohne) {
      meldung(feld ? "Einladungslink erzeugt - Feld markieren und kopieren" : r.url);
    }
  } catch (e) { meldung(e.message, true); }
}

/* -- Vorgangsdialog mit Kommentaren --------------------------------------- */
let offenerVorgang = null;

async function oeffneVorgang(t) {
  offenerVorgang = t;
  const p = S.daten.project;
  titelSetzen("#vorgang-titel", t ? "list-check" : "plus",
              t ? `Vorgang ${t.key}` : "Neuer Vorgang");
  $("#v-title").value = t ? t.title : "";
  $("#v-duration").value = t ? t.duration : 1;
  $("#v-anchor").value = t && t.anchor ? t.anchor : "";
  $("#v-effort").value = t ? t.effort_h : 0;
  $("#v-progress").value = t ? t.own_progress : 0;
  $("#v-note").value = t ? t.note : "";
  $("#v-key").value = t ? t.key : "";
  $("#v-key").disabled = Boolean(t);

  const gesperrt = new Set();
  if (t) {
    gesperrt.add(t.key);
    let neu = true;
    while (neu) {
      neu = false;
      S.daten.tasks.forEach((x) => {
        if (x.parent && gesperrt.has(x.parent) && !gesperrt.has(x.key)) {
          gesperrt.add(x.key);
          neu = true;
        }
      });
    }
  }
  fuelleAuswahl($("#v-parent"), [["", "— kein übergeordneter Vorgang —"]].concat(
    S.daten.tasks.filter((x) => !gesperrt.has(x.key))
      .map((x) => [x.key, `${x.key}  ${x.title}`])), t ? t.parent || "" : "");
  fuelleAuswahl($("#v-status"), p.columns.map((c) => [c, c]), t ? t.status : p.columns[0]);
  fuelleAuswahl($("#v-assignee"), [["", "— offen —"]].concat(
    S.daten.members.map((m) => [String(m.user_id), m.name])),
    t && t.assignee_id ? String(t.assignee_id) : "");

  const deps = $("#v-deps");
  deps.replaceChildren();
  S.daten.tasks.filter((x) => !gesperrt.has(x.key)).forEach((x) => {
    const label = el("label");
    const box = el("input");
    box.type = "checkbox";
    box.value = x.key;
    box.checked = Boolean(t && t.deps.includes(x.key));
    label.append(box, document.createTextNode(`${x.key} ${x.title.slice(0, 24)}`));
    deps.append(label);
  });
  $("#v-delete").hidden = !t;
  $("#komm-spalte").style.visibility = t ? "visible" : "hidden";
  $("#v-dok-block").hidden = !t;
  $("#dlg-vorgang").showModal();
  if (t) { await kommentareZeichnen(t.id); await anhaengeZeichnen(t.id); }
}

async function kommentareZeichnen(tid) {
  const liste = $("#komm-liste");
  let daten = [];
  try { daten = await api(`/api/tasks/${tid}/comments`); }
  catch (e) { meldung(e.message, true); return; }
  // Erst nach der Antwort leeren, sonst zeichnen zwei überlappende Aufrufe alles
  // doppelt; und ein Aufruf zu einem anderen Vorgang gehört gar nicht mehr hierher.
  if (!offenerVorgang || offenerVorgang.id !== tid) return;
  liste.replaceChildren();
  if (!daten.length) {
    const leer = el("div", "komm__leer");
    leer.append(ico("comment-dots", "regular"), document.createTextNode(
      " Noch keine Kommentare. Checklisten entstehen aus Zeilen wie „- [ ] Punkt“."));
    liste.append(leer);
  }
  daten.forEach((c) => {
    const box = el("div", "komm__item");
    const kopf = el("div", "komm__kopf");
    kopf.append(el("b", "", c.author ? c.author.name : "unbekannt"),
                el("span", "", new Date(c.created_at).toLocaleString("de-DE",
                  { day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit" })));
    if (c.edited) kopf.append(el("span", "", "· bearbeitet"));
    if (!c.author || c.author.id === S.me.id || ["admin", "manager"].includes(S.daten.role)) {
      const weg = el("span", "weg");
      weg.append(ico("trash"));
      weg.title = "Kommentar löschen";
      weg.onclick = async () => {
        if (!confirm("Kommentar löschen?")) return;
        try {
          await api(`/api/comments/${c.id}`, { method: "DELETE" });
          kommentareZeichnen(tid);
        } catch (e) { meldung(e.message, true); }
      };
      kopf.append(weg);
    }
    box.append(kopf);

    const zeilen = c.body.split("\n");
    const punkte = new Map(c.checklist.map((p) => [p.line, p]));
    let text = [];
    const textAusgeben = () => {
      if (text.length) box.append(el("div", "komm__text", text.join("\n")));
      text = [];
    };
    zeilen.forEach((zeile, nr) => {
      const punkt = punkte.get(nr);
      if (!punkt) { text.push(zeile); return; }
      textAusgeben();
      const label = el("label", "komm__check" + (punkt.done ? " is-done" : ""));
      const box2 = el("input");
      box2.type = "checkbox";
      box2.checked = punkt.done;
      box2.onchange = async () => {
        try {
          await jsonRuf(`/api/comments/${c.id}/check`, "POST",
                        { line: nr, done: box2.checked });
          kommentareZeichnen(tid);
        } catch (e) { meldung(e.message, true); box2.checked = !box2.checked; }
      };
      label.append(box2, el("span", "", punkt.text));
      box.append(label);
    });
    textAusgeben();
    if (c.total) {
      const fort = el("div", "komm__fort");
      fort.append(ico(c.done === c.total ? "circle-check" : "list-check"),
                  document.createTextNode(` Checkliste: ${c.done} von ${c.total} erledigt`));
      box.append(fort);
    }
    liste.append(box);
  });
  liste.scrollTop = liste.scrollHeight;
}

async function anhaengeZeichnen(tid) {
  const ziel = $("#v-dokumente");
  let daten = [];
  try { daten = await api(`/api/tasks/${tid}/documents`); } catch (e) { return; }
  if (!offenerVorgang || offenerVorgang.id !== tid) return;
  ziel.replaceChildren();
  daten.forEach((d) => {
    const item = el("span", "anhang__item");
    const a = el("a", "", `${d.title} (v${d.version})`);
    a.href = `/api/documents/${d.id}/download`;
    item.append(ico(dateiSymbol(d.content_type, d.filename)), a);
    ziel.append(item);
  });
  const feld = el("input");
  feld.type = "file";
  feld.style.display = "none";
  feld.onchange = async () => {
    await dokumenteLaden();
    await dateiHochladen(feld.files[0], tid);
    await dokumenteLaden();
    anhaengeZeichnen(tid);
  };
  const anhaengen = knopf("btn btn--ghost", "paperclip", "Datei anhängen");
  anhaengen.onclick = () => feld.click();
  ziel.append(anhaengen, feld);
}

function fuelleAuswahl(auswahl, paare, wert) {
  auswahl.replaceChildren();
  paare.forEach(([v, text]) => {
    const o = el("option", "", text);
    o.value = v;
    auswahl.append(o);
  });
  auswahl.value = wert;
  if (wert && auswahl.value !== wert) {
    const o = el("option", "", "(nicht mehr wählbar)");
    o.value = wert;
    auswahl.append(o);
    auswahl.value = wert;
  }
}

async function speichern(pfad, methode, koerper) {
  try {
    await jsonRuf(pfad, methode, koerper);
    await projektLaden();
    await orgLaden();
    if (S.ansicht === "kalender") await kalenderDaten();
    if (S.ansicht === "dokumente") await dokumenteLaden();
    zeichnen();
    return true;
  } catch (e) {
    meldung(e.message, true);
    return false;
  }
}

/* -- Dialoge: Projekt, Organisation, Arbeitsbereich ----------------------- */
function oeffneProjekt(p, wsId) {
  S.projektBearbeiten = Boolean(p);
  S.projektWs = wsId || (p && p.workspace ? p.workspace.id : null);
  const bereiche = bereicheMitLeitung();
  if (!p && !S.projektWs) {
    if (!bereiche.length) {
      meldung("Zuerst einen Arbeitsbereich anlegen – dort entstehen die Projekte.", true);
      oeffneWorkspace(null, null);
      return;
    }
    const offen = S.daten && S.daten.project.workspace;
    S.projektWs = offen && bereiche.some((w) => w.id === offen.id)
      ? offen.id : bereiche[0].id;
  }
  const ws = S.org.workspaces.find((w) => w.id === S.projektWs);
  // Beim Anlegen ist der Arbeitsbereich wählbar, beim Bearbeiten steht er fest.
  $("#p-ws-zeile").hidden = Boolean(p);
  if (!p) {
    fuelleAuswahl($("#p-ws"), bereiche.map((w) => [String(w.id),
      `${w.name} (${WS_ARTEN[w.kind] || "Arbeit"})`]), String(S.projektWs));
    $("#p-ws").onchange = () => { S.projektWs = Number($("#p-ws").value); };
  }
  titelSetzen("#projekt-titel", p ? "sliders" : "diagram-project",
              p ? "Projekteinstellungen" : "Neues Projekt");
  $("#projekt-sub").textContent = p && ws
    ? `Arbeitsbereich: ${ws.name}`
    : "Der Projektstart ist der früheste Termin aller Vorgänge.";
  $("#p-name").value = p ? p.name : "";
  $("#p-client").value = p ? p.client : "";
  $("#p-start").value = p ? p.start : isoTag(new Date());
  $("#p-deadline").value = (p && p.deadline) || "";
  $("#p-columns").value = p ? p.columns.join(", ") : "Backlog, In Arbeit, Review, Fertig";
  $("#p-github-zeile").hidden = !S.me.module.konnektoren;
  $("#p-github").value = (p && p.github_repo) || "";
  $("#p-delete").hidden = !(p && ["admin", "manager"].includes(S.daten.role));
  $("#dlg-projekt").showModal();
}

function oeffneOrg(unit, parentId) {
  S.orgBearbeiten = unit ? unit.id : null;
  S.orgParent = parentId || null;
  const eltern = S.org.units.find((u) => u.id === parentId);
  titelSetzen("#org-titel", unit ? "pen" : "sitemap", unit ? "Einheit bearbeiten"
    : parentId ? "Untereinheit anlegen" : "Unternehmen anlegen");
  $("#org-sub").textContent = eltern ? `Unterhalb von: ${eltern.name}`
    : "Wer ein Unternehmen anlegt, verwaltet es.";
  $("#o-name").value = unit ? unit.name : "";
  $("#o-kind").value = unit ? unit.kind : (parentId ? "department" : "company");
  $("#o-delete").hidden = !unit;
  $("#dlg-org").showModal();
}

function oeffneWorkspace(ws, unitId) {
  S.wsBearbeiten = ws ? ws.id : null;
  S.wsUnit = unitId || (ws ? ws.org_unit_id : null);
  titelSetzen("#ws-titel", "layer-group",
              ws ? "Arbeitsbereich bearbeiten" : "Arbeitsbereich anlegen");
  // Beim Bearbeiten steht die Einheit fest; beim Anlegen zur Wahl, was man verwaltet.
  const einheiten = ws ? S.org.units.filter((u) => u.id === S.wsUnit)
                       : S.org.units.filter((u) => u.role === "admin");
  fuelleAuswahl($("#w-unit"),
                (ws ? [] : [["", "Persönlich – ohne Organisation"]]).concat(
                  einheiten.map((u) => [String(u.id), u.name])),
                String(S.wsUnit || ""));
  $("#w-unit").disabled = Boolean(ws);
  $("#w-kind").value = ws ? ws.kind : "work";
  $("#w-name").value = ws ? ws.name : "";
  $("#w-desc").value = ws ? ws.description : "";
  $("#w-delete").hidden = !(ws && ws.role === "admin");
  $("#dlg-ws").showModal();
}

function untereinheit(unit) {
  oeffneOrg(null, unit.id);
}

/* -- Dialog: Besprechung -------------------------------------------------- */
let offenesMeeting = null;

async function oeffneMeeting(m) {
  offenesMeeting = m;
  S.personen = await api("/api/people");   // frisch: neue Beteiligte sollen wählbar sein
  titelSetzen("#mt-titel", "calendar-plus",
              m ? "Besprechung bearbeiten" : "Neue Besprechung");
  $("#mt-title").value = m ? m.title : "";
  const start = m ? new Date(m.starts_at) : naechsteVolleStunde();
  $("#mt-start").value = isoMinute(start);
  $("#mt-dauer").value = m ? m.duration_min : 60;
  $("#mt-ort").value = m ? m.location : "";
  $("#mt-notiz").value = m ? m.notes : "";
  fuelleAuswahl($("#mt-projekt"), [["", "— ohne Projektbezug —"]].concat(
    alleProjekte().map((p) => [String(p.id), p.name])),
    m && m.project_id ? String(m.project_id) : (S.pid ? String(S.pid) : ""));
  const leute = $("#mt-leute");
  leute.replaceChildren();
  const gewaehlt = new Set(m ? m.participants.map((p) => p.id) : [S.me.id]);
  S.personen.forEach((p) => {
    const label = el("label");
    const box = el("input");
    box.type = "checkbox";
    box.value = String(p.id);
    box.checked = gewaehlt.has(p.id);
    label.append(box, document.createTextNode(p.name));
    leute.append(label);
  });
  $("#mt-vorschlaege").replaceChildren();
  $("#mt-delete").hidden = !(m && m.organizer && m.organizer.id === S.me.id);
  $("#dlg-meeting").showModal();
}

function naechsteVolleStunde() {
  const d = new Date();
  d.setMinutes(0, 0, 0);
  d.setHours(d.getHours() + 1);
  return d;
}

function gewaehlteLeute() {
  return [...$("#mt-leute").querySelectorAll("input:checked")].map((b) => Number(b.value));
}

/* -- Dialog: Dokument ----------------------------------------------------- */
let offenesDokument = null;

function oeffneDokument(d) {
  offenesDokument = d;
  titelSetzen("#dok-titel", d ? dateiSymbol(d.content_type, d.filename)
                              : "file-arrow-up", d ? d.title : "Dokument hochladen");
  $("#dok-sub").textContent = d
    ? `Fassung ${d.version} · ${bytes(d.size)} · ${d.filename}`
    : "Neue Fassungen ersetzen nichts – alte bleiben abrufbar.";
  $("#dk-title").value = d ? d.title : "";
  $("#dk-note").value = "";
  $("#dk-datei").value = "";
  $("#dk-datei-label").textContent = d ? "Neue Fassung (optional)" : "Datei";
  fuelleAuswahl($("#dk-task"), [["", "— ohne Vorgang —"]].concat(
    (S.daten ? S.daten.tasks : []).filter((t) => !t.summary)
      .map((t) => [String(t.id), `${t.key} ${t.title}`])),
    d && d.task_id ? String(d.task_id) : "");
  const hist = $("#dk-historie");
  hist.replaceChildren();
  if (d && d.history.length > 1) {
    hist.append(el("label", "", "Fassungen"));
    d.history.forEach((v) => {
      const z = el("div", "anhang__item");
      const a = el("a", "", `v${v.version} · ${kurzDatum(v.at)} · ${bytes(v.size)}`
        + (v.by ? " · " + v.by.name : "") + (v.note ? " · " + v.note : ""));
      a.href = `/api/documents/${d.id}/download?version=${v.version}`;
      z.append(a);
      hist.append(z);
    });
  }
  $("#dk-delete").hidden = !d;
  $("#dlg-dok").showModal();
}

/* -- Ansicht wechseln ----------------------------------------------------- */
/** Adresszeile an den Zustand angleichen.

    Vorher stand überall replaceState: die Adresse behielt nach dem Löschen die
    Nummer des gelöschten Projekts, und die Zurück-Taste führte aus der
    Anwendung heraus statt zur vorigen Ansicht. */
function urlAngleichen(neuerSchritt = false) {
  const hash = (S.tabs.aktiv && S.tabs.aktiv.hash) || S.ansicht;
  const url = (S.pid ? `/app/${S.pid}` : "/app") + `#${hash}`;
  if (location.pathname + location.hash === url) return;
  history[neuerSchritt ? "pushState" : "replaceState"]({}, "", url);
}

/** Zustand aus der Adresszeile herstellen - für Zurück-Taste und Hash-Sprünge. */
async function ausUrl() {
  const pid = Number(location.pathname.split("/")[2]) || null;
  const ansicht = location.hash.slice(1) || S.ansicht;
  if (pid !== S.pid) {
    S.pid = pid;
    await projektLaden();
    baueBaum();
  }
  if (ansicht.startsWith("datei/") && typeof oeffneDatei === "function") {
    oeffneDatei(decodeURIComponent(ansicht.slice(6)), true);
    return;
  }
  await wechsle(ansicht, true);
}

async function wechsle(ansicht, ausGeschichte = false) {
  if (!ANSICHTEN.includes(ansicht)) return;
  S.ansicht = ansicht;
  oeffneTab(ansichtTab(ansicht));
  try {
    if (LADER[ansicht]) await LADER[ansicht]();
  } catch (e) {
    // Ohne diesen Fang blieb die Ansicht halb gewechselt: kein Bild, keine
    // Meldung, und der nächste Aufbau stolperte über die fehlenden Daten.
    meldung(e.message, true);
  }
  // Ein Ansichtswechsel ist ein Schritt in der Geschichte - sonst führt die
  // Zurück-Taste aus der Anwendung heraus. Kommt der Wechsel selbst aus der
  // Geschichte, wird nur angeglichen.
  urlAngleichen(!ausGeschichte);
  zeichnen();
}

/* -- Assistent ------------------------------------------------------------
   Der Strom kommt als text/event-stream: eine JSON-Zeile je „data:“. Erst
   Textstücke, zuletzt die geprüften Aktionen. Kein Markdown-Aufbereiter -
   der Text steht als Fließtext, das genügt und kann nichts einschleusen. */
const SCHNELLFRAGEN = ["Was ist überfällig?", "Wer ist überlastet?",
                       "Fasse den Stand zusammen"];

/** Wer antwortet - und ob dabei Daten das Haus verlassen. */
function anbieterText(st) {
  if (st.anbieter) {
    return `Antwortet: ${st.anbieter} (extern${st.model ? ", " + st.model : ""})`
      + (st.available ? "." : ` - nicht erreichbar: ${st.reason || ""}`);
  }
  return st.available ? `Lokales Modell: ${st.model} (${st.url}).`
    : `Kein lokales Modell erreichbar (${st.url}).`;
}

function assistentUmschalten(an) {
  const seite = $("#assistent");
  seite.hidden = an === undefined ? !seite.hidden : !an;
  if (!seite.hidden) {
    api("/api/ai/status").then((st) => {
      const zeile = $("#as-anbieter");
      zeile.textContent = anbieterText(st);
      zeile.classList.toggle("is-extern", Boolean(st.anbieter));
    }).catch(() => {});
  }
  document.querySelector(".shell").classList.toggle("mit-assistent", !seite.hidden);
  if (!seite.hidden && !$("#as-schnell").children.length) {
    SCHNELLFRAGEN.forEach((f) => {
      const chip = knopf("btn btn--ghost", "wand-magic-sparkles", f);
      chip.onclick = () => assistentSenden(f);
      $("#as-schnell").append(chip);
    });
  }
  if (!seite.hidden) $("#as-frage").focus();
}

function blase(klasse, text) {
  const b = el("div", klasse, text);
  $("#as-verlauf").append(b);
  $("#as-verlauf").scrollTop = $("#as-verlauf").scrollHeight;
  return b;
}

/** Eine Aktion in einem Satz, den man vor dem Bestätigen lesen kann. */
function aktionText(a, mitglieder = (S.daten && S.daten.members) || []) {
  const f = a.felder;
  const wer = (id) => {
    const m = mitglieder.find((x) => x.user_id === id);
    return m ? m.name : "unbekannt";
  };
  const teile = [];
  if (f.duration !== undefined) teile.push(`${f.duration} Tage`);
  if (f.effort_h !== undefined) teile.push(`${f.effort_h} h`);
  if (f.progress !== undefined) teile.push(`Fortschritt ${f.progress} %`);
  if (f.status) teile.push(`Status ${f.status}`);
  if (f.anchor) teile.push(`ab ${kurzDatum(f.anchor)}`);
  if (f.parent) teile.push(`unter ${f.parent}`);
  if (f.deps) teile.push(`nach ${f.deps.join(", ") || "niemandem"}`);
  if ("assignee_id" in f) teile.push(f.assignee_id ? wer(f.assignee_id) : "ohne Zuständige");
  const anhang = teile.length ? " – " + teile.join(", ") : "";
  if (a.art === "vorgang_anlegen") return `Vorgang ${a.key} „${f.title}“ anlegen${anhang}`;
  if (a.art === "vorgang_aendern") return `${a.key} ändern${anhang}`;
  if (a.art === "kommentar") {
    const punkte = (f.body.match(/^- \[ \]/gm) || []).length;
    return `Kommentar zu ${a.key}` + (punkte ? ` mit ${punkte} Checklistenpunkten` : "");
  }
  return `Besprechung „${f.title}“ am ${kurzDatum(f.starts_at.slice(0, 10))} `
    + `${f.starts_at.slice(11)} (${f.duration_min} min)`;
}

/** Vorschläge mit Haken - für den Assistenten wie für die Freigabe in der
    Agenten-Zentrale. uebernehmen(nummern) führt die gewählten aus. */
function vorschlagsliste(kasten, aktionen, verworfen, uebernehmen, mitglieder) {
  kasten.replaceChildren();
  kasten.hidden = !aktionen.length && !verworfen.length;
  if (aktionen.length) {
    kasten.append(el("b", "", "Vorschläge – prüfen und übernehmen"));
    aktionen.forEach((a, i) => {
      const zeile = el("label");
      const haken = el("input");
      haken.type = "checkbox";
      haken.checked = true;
      haken.dataset.nr = String(i);
      zeile.append(haken, el("span", "", aktionText(a, mitglieder)));
      kasten.append(zeile);
    });
    const los = knopf("btn btn--primary", "check", "Ausgewählte übernehmen");
    los.onclick = async () => {
      const nummern = [...kasten.querySelectorAll("input:checked")].map((h) => Number(h.dataset.nr));
      if (!nummern.length) { meldung("Nichts ausgewählt.", true); return; }
      los.disabled = true;
      try { await uebernehmen(nummern); }
      catch (e) { meldung(e.message, true); } finally { los.disabled = false; }
    };
    kasten.append(los);
  }
  verworfen.forEach((v) => kasten.append(el("div", "muted", "Nicht übernommen: " + v)));
}

/** Ergebnis einer Übernahme als Meldung. */
function uebernahmeMelden(r) {
  const fehler = r.ergebnis.filter((e) => !e.ok);
  meldung(fehler.length
    ? `${r.ergebnis.length - fehler.length} übernommen, dann: ${fehler[0].fehler}`
    : `${r.ergebnis.length} Änderung(en) übernommen`, Boolean(fehler.length));
}

function vorschlaegeZeigen(aktionen, verworfen) {
  const kasten = $("#as-vorschlaege");
  vorschlagsliste(kasten, aktionen, verworfen, async (nummern) => {
    const r = await jsonRuf(`/api/projects/${S.pid}/assistent/anwenden`, "POST",
                            { aktionen: nummern.map((n) => S.vorschlaege[n].roh) });
    uebernahmeMelden(r);
    kasten.hidden = true;
    await projektLaden();
    zeichnen();
  });
}

async function assistentSenden(frage) {
  if (!S.pid || !S.daten) { meldung("Zuerst ein Projekt wählen.", true); return; }
  blase("assistent__frage", frage);
  $("#as-frage").value = "";
  $("#as-vorschlaege").hidden = true;
  const antwort = blase("assistent__antwort", "Das Modell denkt nach … "
    + "(ein bis drei Minuten)");
  let text = "";
  S.abbruch = new AbortController();
  $("#as-senden").hidden = true;
  $("#as-stopp").hidden = false;
  try {
    const r = await fetch(`/api/projects/${S.pid}/assistent`, {
      method: "POST", headers: { "Content-Type": "application/json" },
      signal: S.abbruch.signal,
      body: JSON.stringify({ frage, verlauf: S.verlauf.slice(-6), modus: S.assistentModus }),
    });
    if (!r.ok) throw new Error((await r.json()).detail || `Fehler ${r.status}`);
    const leser = r.body.pipeThrough(new TextDecoderStream()).getReader();
    let rest = "";
    for (;;) {
      const { value, done } = await leser.read();
      if (done) break;
      rest += value;
      const teile = rest.split("\n\n");
      rest = teile.pop();
      for (const teil of teile) {
        if (!teil.startsWith("data: ")) continue;
        const e = JSON.parse(teil.slice(6));
        if (e.text !== undefined) {
          text += e.text;
          antwort.textContent = text;
          $("#as-verlauf").scrollTop = $("#as-verlauf").scrollHeight;
        } else if (e.fehler) {
          meldung(e.fehler, true);
          antwort.textContent = text + "\n(abgebrochen: " + e.fehler + ")";
        } else if (e.aktionen) {
          S.vorschlaege = e.aktionen.map((a) => ({ ...a, roh: rohAktion(a) }));
          vorschlaegeZeigen(e.aktionen, e.verworfen);
        }
      }
    }
    S.verlauf.push({ rolle: "user", text: frage }, { rolle: "assistant", text });
  } catch (e) {
    if (e.name === "AbortError") antwort.textContent = text + "\n(abgebrochen)";
    else { meldung(e.message, true); antwort.textContent = text + "\n(Fehler)"; }
  } finally {
    $("#as-senden").hidden = false;
    $("#as-stopp").hidden = true;
    S.abbruch = null;
    S.assistentModus = "chat";
  }
}

/** Die geprüfte Aktion zurück in die Form bringen, die der Dienst erwartet -
    er prüft sie ohnehin ein zweites Mal. */
function rohAktion(a) {
  const roh = { art: a.art, key: a.key, ...a.felder };
  if ("assignee_id" in roh) {
    const m = (S.daten.members || []).find((x) => x.user_id === roh.assignee_id);
    roh.assignee = m ? m.username : "";
    delete roh.assignee_id;
  }
  if (roh.participant_ids) {
    roh.teilnehmer = roh.participant_ids
      .map((id) => (S.daten.members.find((x) => x.user_id === id) || {}).username)
      .filter(Boolean);
    delete roh.participant_ids;
  }
  return roh;
}

/* -- Bericht --------------------------------------------------------------- */
async function berichtHolen() {
  const knopf = $("#b-neu");
  const mitKi = $("#b-ki").checked;
  knopf.disabled = true;
  $("#b-text").value = "Wird erzeugt …";
  try {
    const abfrage = new URLSearchParams({ seit: $("#b-seit").value,
                                          art: $("#b-art").value });
    const antwort = await fetch(`/api/projects/${S.pid}/bericht?${abfrage}`);
    if (!antwort.ok) throw new Error(`Fehler ${antwort.status}`);
    $("#b-text").value = await antwort.text();
    if (mitKi) {
      // Der Erzählteil ist ein Auftrag: der Dialog darf zu, das Ergebnis kommt
      // über die Live-Verbindung - auch in die Agenten-Zentrale.
      const job = await jsonRuf("/api/jobs", "POST", { kind: "bericht", project_id: S.pid,
        params: { art: $("#b-art").value, seit: $("#b-seit").value } });
      $("#b-text").value = `Das Modell schreibt Zusammenfassung und Empfehlungen … `
        + `(Auftrag ${job.id}, ein bis drei Minuten)\n\n` + $("#b-text").value;
      aufJob(job.id, async (fertig) => {
        if (fertig.status === "done") {
          const md = await fetch(`/api/jobs/${fertig.id}/ergebnis`);
          if ($("#dlg-bericht").open) $("#b-text").value = await md.text();
          meldung("Bericht mit Erzählteil ist fertig");
        } else meldung("Erzählteil: " + (letzteLogzeile(fertig) || fertig.status), true);
      });
    }
  } catch (e) {
    meldung(e.message, true);
    $("#b-text").value = "";
  } finally { knopf.disabled = false; }
}

function oeffneBericht() {
  if (!S.daten) { meldung("Zuerst ein Projekt wählen.", true); return; }
  $("#b-seit").value = isoTag(new Date(Date.now() - 7 * TAG_MS));
  $("#b-text").value = "";
  $("#dlg-bericht").showModal();
  berichtHolen();
}

/* -- Live-Verbindung ---------------------------------------------------------
   Ein Steckplatz je Browser (/ws), Abos je Thema: kanal:{id} für den Chat,
   job:{id} für Aufträge. Erweiterungen hören auf das Ereignis "live". */
S.live = { ws: null, abos: new Set(), versuch: 0, jobs: {} };

function verbinden() {
  if (S.live.ws && S.live.ws.readyState <= 1) return;
  const ws = new WebSocket(`${location.protocol === "https:" ? "wss" : "ws"}://${location.host}/ws`);
  S.live.ws = ws;
  ws.onopen = () => {
    S.live.versuch = 0;
    S.live.abos.forEach((t) => ws.send(JSON.stringify({ abo: t })));
    document.dispatchEvent(new CustomEvent("live-verbunden"));
  };
  ws.onmessage = (e) => {
    const daten = JSON.parse(e.data);
    if (daten.job && daten.status && S.live.jobs[daten.job]) {
      api(`/api/jobs/${daten.job}`).then((job) => S.live.jobs[daten.job] && S.live.jobs[daten.job](job))
        .catch(() => {});
    }
    document.dispatchEvent(new CustomEvent("live", { detail: daten }));
  };
  // Wachsende Pause statt Dauerfeuer, wenn der Dienst weg ist.
  ws.onclose = () => setTimeout(verbinden, Math.min(30000, 1000 * 2 ** S.live.versuch++));
}

function abonnieren(thema) {
  if (S.live.abos.has(thema)) return;
  S.live.abos.add(thema);
  if (S.live.ws && S.live.ws.readyState === 1) S.live.ws.send(JSON.stringify({ abo: thema }));
}

/** Auf das Ende eines Auftrags warten; der Rückruf bekommt den fertigen Auftrag.
    Einmal sofort nachsehen: ein schneller Auftrag kann fertig sein, bevor das
    Abo steht. */
function aufJob(id, rueckruf) {
  S.live.jobs[id] = (job) => {
    if (["done", "failed", "cancelled", "wartet"].includes(job.status)) {
      delete S.live.jobs[id];
      rueckruf(job);
    }
  };
  abonnieren(`job:${id}`);
  api(`/api/jobs/${id}`).then((job) => S.live.jobs[id] && S.live.jobs[id](job)).catch(() => {});
}

const letzteLogzeile = (job) => (job.log || "").trim().split("\n").pop() || "";

/* -- Profil, Erweiterungen, Einstellungen ---------------------------------- */
async function moduleLaden() {
  S.module = await api("/api/module");
}

const minutenAusUhr = (wert) => {
  const [h, m] = wert.split(":").map(Number);
  return h * 60 + m;
};
const uhrAusMinuten = (min) => String(Math.floor(min / 60)).padStart(2, "0")
  + ":" + String(min % 60).padStart(2, "0");

/** Profilbild oder Kürzel - überall, wo eine Person steht. */
function avatarEl(p, gross = false) {
  const klasse = "avatar" + (gross ? " avatar--gross" : "");
  if (p && p.avatar) {
    const img = el("img", klasse);
    img.src = `/api/avatar/${p.avatar}`;
    img.alt = "";
    return img;
  }
  return el("span", klasse, kuerzel(p ? p.name : "?"));
}

/** Name als Knopf, der das Profil öffnet. */
function personEl(p, id) {
  const zeile = el("span", "person");
  zeile.append(avatarEl(p), el("span", "", p.name));
  zeile.onclick = (e) => { e.stopPropagation(); oeffneProfil(id); };
  return bedienbar(zeile, `Profil von ${p.name}`);
}

async function oeffneProfil(id) {
  let p;
  try { p = await api(`/api/users/${id}`); } catch (e) { meldung(e.message, true); return; }
  titelSetzen("#pr-titel", "user", p.name);
  const box = $("#pr-inhalt");
  box.replaceChildren();
  const kopf = el("div", "profil__kopf");
  const daten = el("div");
  daten.append(el("b", "", p.name), el("div", "muted", "@" + p.username));
  kopf.append(avatarEl(p, true), daten);
  box.append(kopf);
  if (p.bio) {
    const bio = el("p", "", p.bio);
    bio.style.whiteSpace = "pre-wrap";
    box.append(bio);
  }
  if (p.skills.length) {
    const liste = el("div", "chips");
    liste.style.border = "0";
    liste.style.padding = "0";
    p.skills.forEach((f) => liste.append(el("span", "pill", f)));
    box.append(el("label", "", "Fähigkeiten"), liste);
  }
  if (p.links.length) {
    box.append(el("label", "", "Verweise"));
    p.links.forEach((url) => {
      const a = el("a", "", url);
      a.href = url;
      a.target = "_blank";
      a.rel = "noopener";
      const zeile = el("div");
      zeile.append(a);
      box.append(zeile);
    });
  }
  const aktionen = $("#pr-aktionen");
  aktionen.replaceChildren();
  // Erweiterungen hängen hier ihre Knöpfe an (Direktnachricht im Chat).
  document.dispatchEvent(new CustomEvent("profil-geoeffnet", { detail: { person: p, ziel: aktionen } }));
  $("#dlg-profil").showModal();
}

/** Karten der Erweiterungen - dieselben in der Ersteinrichtung und in den Einstellungen. */
function modulKarten(liste, istAn, aufSchalten) {
  const raster = el("div", "module");
  liste.forEach((m) => {
    const karte = el("label", "modul" + (m.bereit ? "" : " is-bald"));
    const box = el("input");
    box.type = "checkbox";
    box.checked = istAn(m);
    box.disabled = !m.bereit || Boolean(m.gesperrt);
    box.onchange = () => aufSchalten(m, box.checked);
    const text = el("div");
    const kopf = el("div");
    kopf.append(el("b", "", m.titel));
    if (m.extern) {
      const pill = el("span", "pill pill--extern");
      pill.append(ico("cloud-arrow-up"), document.createTextNode(" sendet Daten nach außen"));
      kopf.append(" ", pill);
    }
    if (!m.bereit) kopf.append(" ", el("span", "pill", "kommt bald"));
    if (m.gesperrt) kopf.append(" ", el("span", "pill", "von der Organisation abgeschaltet"));
    text.append(kopf, el("div", "muted", m.text));
    karte.append(box, text);
    raster.append(karte);
  });
  return raster;
}

const kartenKopf = (symbol, text) => {
  const h = el("h3");
  h.append(ico(symbol), document.createTextNode(" " + text));
  return h;
};

function zeichneEinstellungen(ziel) {
  const me = S.me;
  const raster = el("div", "einstellungen");

  // Profil
  const profil = el("div", "karte");
  profil.append(kartenKopf("id-card", "Profil"));
  const bild = el("div", "profil__kopf");
  const bildFeld = el("input");
  bildFeld.type = "file";
  bildFeld.accept = "image/png,image/jpeg,image/gif,image/webp";
  bildFeld.style.display = "none";
  bildFeld.onchange = async () => {
    const daten = new FormData();
    daten.append("datei", bildFeld.files[0]);
    try {
      S.me = await api("/api/me/avatar", { method: "POST", body: daten });
      zeichnen();
    } catch (e) { meldung(e.message, true); }
  };
  const bildKnopf = knopf("btn btn--ghost", "image", "Bild wählen");
  bildKnopf.onclick = () => bildFeld.click();
  const bildWeg = knopf("btn btn--ghost", "xmark", "entfernen");
  bildWeg.hidden = !me.avatar;
  bildWeg.onclick = async () => {
    try { S.me = await api("/api/me/avatar", { method: "DELETE" }); zeichnen(); }
    catch (e) { meldung(e.message, true); }
  };
  const bildKnoepfe = el("div");
  bildKnoepfe.append(bildKnopf, bildWeg, bildFeld, el("div", "muted", "PNG, JPEG, GIF oder WebP, höchstens 1 MB"));
  bild.append(avatarEl(me, true), bildKnoepfe);
  profil.append(bild);
  const form = el("form");
  const feld = (id, label, typ = "text") => {
    const l = el("label", "", label);
    l.htmlFor = id;
    const i = el(typ === "textarea" ? "textarea" : "input");
    i.id = id;
    if (typ !== "textarea") i.type = typ; else i.rows = 3;
    form.append(l, i);
    return i;
  };
  const name = feld("k-name", "Anzeigename");
  name.value = me.full_name || "";
  const bio = feld("k-bio", "Über dich", "textarea");
  bio.value = me.bio || "";
  const skills = feld("k-skills", "Fähigkeiten (durch Komma getrennt)");
  skills.value = (me.skills || []).join(", ");
  const links = feld("k-links", "Verweise (eine Adresse je Zeile, GitHub, Portfolio …)", "textarea");
  links.value = (me.links || []).join("\n");
  const sichtbar = el("select");
  sichtbar.id = "k-sichtbar";
  fuelleAuswahl(sichtbar, [["alle", "für alle Angemeldeten sichtbar"],
                           ["kontakte", "nur für Bekannte (gemeinsames Projekt oder Börse)"]],
                me.sichtbar || "alle");
  const sichtbarLabel = el("label", "", "Sichtbarkeit des Profils");
  sichtbarLabel.htmlFor = "k-sichtbar";
  form.append(sichtbarLabel, sichtbar);
  const zeit = el("div", "row2");
  const von = el("div"), bis = el("div");
  const vonFeld = el("input"), bisFeld = el("input");
  vonFeld.type = bisFeld.type = "time";
  vonFeld.id = "k-von"; bisFeld.id = "k-bis";
  vonFeld.value = uhrAusMinuten(me.work_start_min);
  bisFeld.value = uhrAusMinuten(me.work_end_min);
  von.append(el("label", "", "Arbeitsbeginn"), vonFeld);
  bis.append(el("label", "", "Arbeitsende"), bisFeld);
  zeit.append(von, bis);
  form.append(zeit, el("div", "muted", "Die Arbeitszeit steuert Tagesplan und Terminvorschläge."));
  const speichernKnopf = knopf("btn btn--primary", "check", "Profil speichern");
  speichernKnopf.type = "submit";
  speichernKnopf.style.marginTop = "14px";
  form.append(speichernKnopf);
  form.onsubmit = async (e) => {
    e.preventDefault();
    try {
      S.me = await jsonRuf("/api/me", "PATCH", {
        full_name: name.value.trim(), bio: bio.value.trim(),
        skills: skills.value.split(",").map((x) => x.trim()).filter(Boolean),
        links: links.value.split("\n").map((x) => x.trim()).filter(Boolean),
        sichtbar: sichtbar.value,
        work_start_min: minutenAusUhr(vonFeld.value || "08:00"),
        work_end_min: minutenAusUhr(bisFeld.value || "17:00") });
      $("#kontoname").replaceChildren(ico("user", "regular"),
                                      document.createTextNode(" " + S.me.name));
      meldung("Profil gespeichert");
    } catch (e) { meldung(e.message, true); }
  };
  profil.append(form);
  raster.append(profil);

  // Erweiterungen
  const erw = el("div", "karte");
  erw.append(kartenKopf("puzzle-piece", "Erweiterungen"),
    el("p", "muted", "Lokal von Haus aus. Was Daten nach außen sendet, ist markiert und "
      + "nie vorausgewählt. Ein Wechsel lädt die Anwendung neu."));
  erw.append(modulKarten(S.module || [], (m) => m.gewaehlt, async (m, an) => {
    try {
      await jsonRuf("/api/module", "PATCH", { [m.name]: an });
      location.reload();          // das Menü kommt vom Dienst
    } catch (e) { meldung(e.message, true); }
  }));
  raster.append(erw);

  // Organisationen: die Administration schaltet für alle ab
  S.org.units.filter((u) => u.role === "admin" && u.kind !== "personal").forEach((u) => {
    const karte = el("div", "karte");
    karte.append(kartenKopf(ORG_SYMBOL[u.kind] || "building", `Erweiterungen für ${u.name}`),
      el("p", "muted", "Abgeschaltet gilt für alle Angehörigen dieser Einheit und ihrer "
        + "Untereinheiten - auch wenn sie die Erweiterung selbst gewählt haben."));
    const liste = (S.module || []).map((m) => ({ ...m, gesperrt: false }));
    karte.append(modulKarten(liste, (m) => !u.module_aus.includes(m.name), async (m, an) => {
      try {
        await jsonRuf(`/api/org/${u.id}/module`, "PATCH", { [m.name]: an });
        await orgLaden();
        meldung(an ? `„${m.titel}“ für ${u.name} erlaubt` : `„${m.titel}“ für ${u.name} abgeschaltet`);
      } catch (e) { meldung(e.message, true); }
    }));
    raster.append(karte);
  });

  // Zugangsdaten - in der Desktop-App ohne Anmeldemaske ohne Sinn
  if (document.body.dataset.einzelnutzer !== "1") {
    const zugang = el("div", "karte");
    zugang.append(kartenKopf("key", "Zugangsdaten"));
    const zf = el("form");
    const zfeld = (id, label, typ) => {
      const l = el("label", "", label);
      l.htmlFor = id;
      const i = el("input");
      i.id = id;
      i.type = typ;
      zf.append(l, i);
      return i;
    };
    const email = zfeld("z-email", "E-Mail", "email");
    email.value = me.email;
    const aktuell = zfeld("z-aktuell", "Aktuelles Passwort (für jede Änderung)", "password");
    aktuell.autocomplete = "current-password";
    const neu = zfeld("z-neu", "Neues Passwort (leer lassen, wenn unverändert)", "password");
    neu.autocomplete = "new-password";
    const zk = knopf("btn btn--primary", "check", "Zugangsdaten ändern");
    zk.type = "submit";
    zk.style.marginTop = "14px";
    zf.append(zk);
    zf.onsubmit = async (e) => {
      e.preventDefault();
      const koerper = { aktuell: aktuell.value };
      if (neu.value) koerper.neu = neu.value;
      if (email.value.trim() !== me.email) koerper.email = email.value.trim();
      try {
        S.me = await jsonRuf("/api/me/zugangsdaten", "POST", koerper);
        aktuell.value = neu.value = "";
        meldung("Zugangsdaten geändert");
      } catch (err) { meldung(err.message, true); }
    };
    zugang.append(zf);
    raster.append(zugang);
  }
  // Erweiterungen hängen eigene Karten an (Zugangsschlüssel der Agenten-Zentrale).
  document.dispatchEvent(new CustomEvent("einstellungen-gezeichnet", { detail: { ziel: raster } }));
  ziel.append(raster);
}

/* -- Ersteinrichtung ------------------------------------------------------- */
let einrichtungSchritt = 1;

function einrichtungZeigen(n) {
  einrichtungSchritt = n;
  [1, 2, 3].forEach((i) => { $(`#e-schritt-${i}`).hidden = i !== n; });
  $("#e-zurueck").hidden = n === 1;
  $("#e-fertig").textContent = n === 3 ? "Fertig" : "Weiter";
}

async function oeffneEinrichtung() {
  try { await moduleLaden(); } catch (e) { return; }   // dann eben ohne Mastervorlage
  S.einrichtungModule = new Set(S.module.filter((m) => m.gewaehlt && m.bereit)
                                        .map((m) => m.name));
  $("#e-module").replaceChildren(modulKarten(S.module,
    (m) => S.einrichtungModule.has(m.name),
    (m, an) => (an ? S.einrichtungModule.add(m.name) : S.einrichtungModule.delete(m.name))));
  $("#e-name").value = S.me.full_name || "";
  $("#e-bio").value = S.me.bio || "";
  $("#e-skills").value = (S.me.skills || []).join(", ");
  einrichtungZeigen(1);
  $("#dlg-einrichtung").showModal();
}

async function einrichtungAbschliessen() {
  const zweck = document.querySelector('input[name="e-zweck"]:checked').value;
  const firma = $("#e-firma").value.trim();
  try {
    if (!S.org.workspaces.some((w) => w.kind === zweck)) {
      const bereich = { name: WS_ARTEN[zweck], kind: zweck, description: "" };
      if (zweck === "work" && firma) {
        const org = await jsonRuf("/api/org", "POST", { name: firma, kind: "company" });
        bereich.org_unit_id = org.id;
      }
      await jsonRuf("/api/workspaces", "POST", bereich);
    }
    const wahl = {};
    S.module.forEach((m) => { if (m.bereit) wahl[m.name] = S.einrichtungModule.has(m.name); });
    await jsonRuf("/api/module", "PATCH", wahl);
    await jsonRuf("/api/me", "PATCH", {
      full_name: $("#e-name").value.trim(), bio: $("#e-bio").value.trim(),
      skills: $("#e-skills").value.split(",").map((x) => x.trim()).filter(Boolean) });
    $("#dlg-einrichtung").close();
    location.reload();
  } catch (e) { meldung(e.message, true); }
}

/* -- Verdrahtung ---------------------------------------------------------- */
function bindeDialoge() {
  $("#dlg-vorgang form").onsubmit = async (e) => {
    e.preventDefault();
    const koerper = {
      title: $("#v-title").value.trim(),
      duration: Number($("#v-duration").value || 0),
      anchor: $("#v-anchor").value || null,
      parent: $("#v-parent").value || null,
      status: $("#v-status").value,
      assignee_id: $("#v-assignee").value ? Number($("#v-assignee").value) : null,
      effort_h: Number($("#v-effort").value || 0),
      progress: Number($("#v-progress").value || 0),
      note: $("#v-note").value,
      deps: [...$("#v-deps").querySelectorAll("input:checked")].map((b) => b.value),
    };
    if (!koerper.title) { meldung("Bitte eine Bezeichnung angeben.", true); return; }
    const key = $("#v-key").value.trim();
    const ok = offenerVorgang
      ? await speichern(`/api/tasks/${offenerVorgang.id}`, "PATCH", koerper)
      : await speichern(`/api/projects/${S.pid}/tasks`, "POST",
                        key ? { ...koerper, key } : koerper);
    if (ok) $("#dlg-vorgang").close();
  };
  $("#v-delete").onclick = async () => {
    if (!offenerVorgang || !confirm(`„${offenerVorgang.title}“ mit allen `
      + "Untervorgängen löschen?")) return;
    try {
      const r = await api(`/api/tasks/${offenerVorgang.id}`, { method: "DELETE" });
      meldung(`${r.removed.length} Vorgang/Vorgänge gelöscht`);
      $("#dlg-vorgang").close();
      await projektLaden();
      if (S.ansicht === "kalender") await kalenderDaten();
      zeichnen();
    } catch (e) { meldung(e.message, true); }
  };
  $("#komm-senden").onclick = async () => {
    const text = $("#komm-neu").value.trim();
    if (!text || !offenerVorgang) return;
    try {
      await jsonRuf(`/api/tasks/${offenerVorgang.id}/comments`, "POST", { body: text });
      $("#komm-neu").value = "";
      kommentareZeichnen(offenerVorgang.id);
    } catch (e) { meldung(e.message, true); }
  };
  $("#komm-vorlage").onclick = () => {
    const feld = $("#komm-neu");
    feld.value = (feld.value ? feld.value.replace(/\s*$/, "\n") : "Checkliste:\n")
      + "- [ ] \n- [ ] \n";
    feld.focus();
  };

  $("#btn-vorgang").onclick = () => {
    if (!S.daten) { meldung("Zuerst ein Projekt wählen.", true); return; }
    oeffneVorgang(null);
  };
  $("#neue-einheit").onclick = () => oeffneOrg(null, null);
  $("#neuer-bereich").onclick = () => oeffneWorkspace(null, null);
  $("#neues-projekt").onclick = () => oeffneProjekt(null, null);
  $("#btn-projekt").onclick = () => oeffneProjekt(null, null);
  $("#btn-assistent").onclick = () => assistentUmschalten();
  $("#btn-bericht").onclick = oeffneBericht;
  $("#b-neu").onclick = berichtHolen;
  ["#b-art", "#b-seit", "#b-ki"].forEach((sel) => { $(sel).onchange = berichtHolen; });
  $("#dlg-bericht form").onsubmit = (e) => {
    e.preventDefault();
    const datei = new Blob([$("#b-text").value], { type: "text/markdown" });
    const verweis = el("a");
    verweis.href = URL.createObjectURL(datei);
    verweis.download = `Bericht-${S.daten.project.name}-${isoTag(new Date())}.md`
      .replace(/[^\w.\- ]/g, "_");
    verweis.click();
    URL.revokeObjectURL(verweis.href);
  };
  $("#as-zu").onclick = () => assistentUmschalten(false);
  $("#as-leeren").onclick = () => {
    S.verlauf = [];
    $("#as-verlauf").replaceChildren();
    $("#as-vorschlaege").hidden = true;
  };
  $("#as-stopp").onclick = () => S.abbruch && S.abbruch.abort();
  $("#as-form").onsubmit = (e) => {
    e.preventDefault();
    const frage = $("#as-frage").value.trim();
    if (frage) assistentSenden(frage);
  };

  $("#dlg-markt form").onsubmit = async (e) => {
    e.preventDefault();
    const koerper = {
      kind: $("#m-kind").value,
      title: $("#m-title").value.trim(),
      description: $("#m-desc").value.trim(),
      budget: Number($("#m-budget").value || 0),
      effort_h: Number($("#m-effort").value || 0),
      deadline: $("#m-deadline").value || null,
      skill_id: Number($("#m-skill").value) || null,
      stufe: Number($("#m-stufe").value || 0),
    };
    if (!koerper.title) { meldung("Bitte einen Titel angeben.", true); return; }
    try {
      if (offeneAusschreibung) {
        await jsonRuf(`/api/market/${offeneAusschreibung.id}`, "PATCH",
                      { ...koerper, status: offeneAusschreibung.status });
      } else {
        await jsonRuf("/api/market", "POST", { ...koerper,
          workspace_id: Number($("#m-ws").value),
          project_id: Number($("#m-projekt").value) || null });
      }
      $("#dlg-markt").close();
      await marktLaden();
      await wechsle("boerse");
    } catch (e) { meldung(e.message, true); }
  };
  $("#m-delete").onclick = async () => {
    if (!confirm("Ausschreibung mit allen Geboten löschen?")) return;
    try {
      await api(`/api/market/${offeneAusschreibung.id}`, { method: "DELETE" });
      $("#dlg-markt").close();
      await wechsle("boerse");
    } catch (e) { meldung(e.message, true); }
  };
  $("#dlg-gebot form").onsubmit = async (e) => {
    e.preventDefault();
    try {
      await jsonRuf(`/api/market/${offeneAusschreibung.id}/bids`, "POST",
                    { price: Number($("#g-price").value || 0),
                      message: $("#g-message").value.trim() });
      $("#dlg-gebot").close();
      meldung("Gebot abgegeben");
      await wechsle("boerse");
    } catch (e) { meldung(e.message, true); }
  };
  $("#g-delete").onclick = async () => {
    try {
      await api(`/api/market/${offeneAusschreibung.id}/bids`, { method: "DELETE" });
      $("#dlg-gebot").close();
      await wechsle("boerse");
    } catch (e) { meldung(e.message, true); }
  };
  $("#kontoname").onclick = () => wechsle("einstellungen");
  $("#dlg-einrichtung form").onsubmit = (e) => {
    e.preventDefault();
    if (einrichtungSchritt < 3) einrichtungZeigen(einrichtungSchritt + 1);
    else einrichtungAbschliessen();
  };
  $("#e-zurueck").onclick = () => einrichtungZeigen(einrichtungSchritt - 1);
  $("#e-skip").onclick = async () => {
    try {
      S.me = await jsonRuf("/api/module", "PATCH", {});   // Vorgaben festschreiben
      $("#dlg-einrichtung").close();
    } catch (e) { meldung(e.message, true); }
  };
  document.querySelectorAll('input[name="e-zweck"]').forEach((r) => {
    r.onchange = () => { $("#e-firma-zeile").hidden = r.value !== "work"; };
  });

  $("#dlg-projekt form").onsubmit = async (e) => {
    e.preventDefault();
    const koerper = {
      name: $("#p-name").value.trim(),
      client: $("#p-client").value.trim(),
      start: $("#p-start").value || null,
      deadline: $("#p-deadline").value || null,
      columns: $("#p-columns").value.split(",").map((s) => s.trim()).filter(Boolean),
      github_repo: $("#p-github").value.trim() || null,
    };
    if (!koerper.name) { meldung("Bitte einen Projektnamen angeben.", true); return; }
    try {
      if (S.projektBearbeiten) {
        await jsonRuf(`/api/projects/${S.pid}`, "PATCH", koerper);
      } else {
        const r = await jsonRuf("/api/projects", "POST",
                                { ...koerper, workspace_id: Number($("#p-ws").value) });
        S.pid = r.id;
      }
      $("#dlg-projekt").close();
      await orgLaden();
      await projektLaden();
      if (S.ansicht === "org") { zeichnen(); } else { await wechsle("gantt"); }
    } catch (e) { meldung(e.message, true); }
  };
  $("#p-delete").onclick = async () => {
    if (!confirm("Projekt mit allen Vorgängen, Kommentaren und Dokumenten löschen?")) return;
    try {
      await api(`/api/projects/${S.pid}`, { method: "DELETE" });
      $("#dlg-projekt").close();
      S.pid = null;
      S.daten = null;
      await orgLaden();
      await wechsle("org");
    } catch (e) { meldung(e.message, true); }
  };

  $("#dlg-org form").onsubmit = async (e) => {
    e.preventDefault();
    const koerper = { name: $("#o-name").value.trim(), kind: $("#o-kind").value,
                      parent_id: S.orgParent };
    if (!koerper.name) { meldung("Bitte eine Bezeichnung angeben.", true); return; }
    try {
      if (S.orgBearbeiten) await jsonRuf(`/api/org/${S.orgBearbeiten}`, "PATCH", koerper);
      else await jsonRuf("/api/org", "POST", koerper);
      $("#dlg-org").close();
      await orgLaden();
      await wechsle("org");
    } catch (e) { meldung(e.message, true); }
  };
  $("#o-delete").onclick = async () => {
    if (!confirm("Einheit mit allen Untereinheiten, Arbeitsbereichen und Projekten "
      + "löschen? Das lässt sich nicht rückgängig machen.")) return;
    try {
      const r = await api(`/api/org/${S.orgBearbeiten}`, { method: "DELETE" });
      meldung(`${r.units} Einheit(en), ${r.workspaces} Arbeitsbereich(e), `
        + `${r.projects} Projekt(e) gelöscht`);
      $("#dlg-org").close();
      S.pid = null;
      await orgLaden();
      await wechsle("org");
    } catch (e) { meldung(e.message, true); }
  };

  $("#dlg-ws form").onsubmit = async (e) => {
    e.preventDefault();
    const koerper = { name: $("#w-name").value.trim(), description: $("#w-desc").value.trim(),
                      kind: $("#w-kind").value,
                      org_unit_id: Number($("#w-unit").value) || null };
    if (!koerper.name) { meldung("Bitte eine Bezeichnung angeben.", true); return; }
    try {
      if (S.wsBearbeiten) await jsonRuf(`/api/workspaces/${S.wsBearbeiten}`, "PATCH", koerper);
      else await jsonRuf("/api/workspaces", "POST", koerper);
      $("#dlg-ws").close();
      await orgLaden();
      await wechsle("org");
    } catch (e) { meldung(e.message, true); }
  };
  $("#w-delete").onclick = async () => {
    if (!confirm("Arbeitsbereich mit allen Projekten löschen?")) return;
    try {
      await api(`/api/workspaces/${S.wsBearbeiten}`, { method: "DELETE" });
      $("#dlg-ws").close();
      S.pid = null;
      await orgLaden();
      await wechsle("org");
    } catch (e) { meldung(e.message, true); }
  };

  $("#mt-ableiten").onclick = async () => {
    // Kein eigener Endpunkt: derselbe Vertrag, dieselbe Vorschau, derselbe Weg
    // beim Übernehmen - nur ein anderer Absatz im Systemauftrag.
    const notizen = $("#mt-notiz").value.trim();
    const pid = Number($("#mt-projekt").value);
    if (notizen.length < 20) {
      meldung("Zuerst das Protokoll in die Notizen schreiben.", true);
      return;
    }
    if (!pid) { meldung("Zuerst einen Projektbezug wählen.", true); return; }
    if (pid !== S.pid) await waehleProjekt(pid);
    $("#dlg-meeting").close();
    assistentUmschalten(true);
    S.assistentModus = "protokoll";
    assistentSenden(notizen);
  };

  $("#mt-agenda").onclick = async () => {
    const pid = Number($("#mt-projekt").value);
    if (!pid) { meldung("Zuerst einen Projektbezug wählen.", true); return; }
    const knopf = $("#mt-agenda");
    knopf.disabled = true;
    try {
      const r = await api(`/api/projects/${pid}/agenda-vorschlag`);
      const alt = $("#mt-notiz").value.trim();
      $("#mt-notiz").value = (alt ? alt + "\n\n" : "") + r.markdown;
      if (!$("#mt-title").value.trim()) $("#mt-title").value = "Projektbesprechung";
    } catch (e) { meldung(e.message, true); } finally { knopf.disabled = false; }
  };

  $("#mt-suchen").onclick = async () => {
    const knopf = $("#mt-suchen");
    knopf.disabled = true;
    try {
      const r = await jsonRuf("/api/meetings/suggest", "POST", {
        participant_ids: gewaehlteLeute(),
        duration_min: Number($("#mt-dauer").value || 60) });
      const box = $("#mt-vorschlaege");
      box.replaceChildren();
      if (!r.slots.length) {
        box.append(el("span", "muted",
          "In den nächsten zwei Wochen ist kein gemeinsamer Termin frei."));
      }
      box.append(el("span", "muted",
        `gemeinsame Arbeitszeit ${r.window.from}–${r.window.to}: `));
      r.slots.forEach((slot) => {
        const b = el("button");
        b.append(ico("clock"), document.createTextNode(
          `${tag(slot.start).toLocaleDateString("de-DE",
            { weekday: "short", day: "2-digit", month: "2-digit" })} ${uhr(slot.start)}`));
        b.type = "button";
        b.onclick = () => { $("#mt-start").value = slot.start.slice(0, 16); };
        box.append(b);
      });
    } catch (e) { meldung(e.message, true); }
    finally { knopf.disabled = false; }
  };
  $("#dlg-meeting form").onsubmit = async (e) => {
    e.preventDefault();
    const koerper = {
      title: $("#mt-title").value.trim(),
      starts_at: $("#mt-start").value,
      duration_min: Number($("#mt-dauer").value || 60),
      location: $("#mt-ort").value.trim(),
      notes: $("#mt-notiz").value.trim(),
      project_id: $("#mt-projekt").value ? Number($("#mt-projekt").value) : null,
      participant_ids: gewaehlteLeute(),
    };
    if (!koerper.title || !koerper.starts_at) {
      meldung("Thema und Beginn sind Pflicht.", true);
      return;
    }
    try {
      if (offenesMeeting) await jsonRuf(`/api/meetings/${offenesMeeting.id}`, "PATCH", koerper);
      else await jsonRuf("/api/meetings", "POST", koerper);
      $("#dlg-meeting").close();
      await agendaLaden();
      if (S.ansicht === "tag") zeichnen();
      meldung("Besprechung gespeichert");
    } catch (e) { meldung(e.message, true); }
  };
  $("#mt-delete").onclick = async () => {
    if (!offenesMeeting || !confirm("Besprechung absagen?")) return;
    try {
      await api(`/api/meetings/${offenesMeeting.id}`, { method: "DELETE" });
      $("#dlg-meeting").close();
      await agendaLaden();
      zeichnen();
    } catch (e) { meldung(e.message, true); }
  };

  $("#dlg-dok form").onsubmit = async (e) => {
    e.preventDefault();
    const datei = $("#dk-datei").files[0];
    try {
      if (!offenesDokument) {
        if (!datei) { meldung("Bitte eine Datei wählen.", true); return; }
        const daten = new FormData();
        daten.append("datei", datei);
        daten.append("title", $("#dk-title").value.trim());
        daten.append("note", $("#dk-note").value.trim());
        if ($("#dk-task").value) daten.append("task_id", $("#dk-task").value);
        await api(`/api/projects/${S.pid}/documents`, { method: "POST", body: daten });
      } else {
        if (datei) {
          const daten = new FormData();
          daten.append("datei", datei);
          daten.append("note", $("#dk-note").value.trim());
          await api(`/api/documents/${offenesDokument.id}/versions`,
                    { method: "POST", body: daten });
        }
        await jsonRuf(`/api/documents/${offenesDokument.id}`, "PATCH", {
          title: $("#dk-title").value.trim(),
          task_id: $("#dk-task").value ? Number($("#dk-task").value) : null });
      }
      $("#dlg-dok").close();
      await dokumenteLaden();
      zeichnen();
      meldung("Dokument gespeichert");
    } catch (e) { meldung(e.message, true); }
  };
  $("#dk-delete").onclick = async () => {
    if (!offenesDokument || !confirm("Dokument mit allen Fassungen löschen?")) return;
    try {
      await api(`/api/documents/${offenesDokument.id}`, { method: "DELETE" });
      $("#dlg-dok").close();
      await dokumenteLaden();
      zeichnen();
    } catch (e) { meldung(e.message, true); }
  };

  $("#btn-ki").onclick = async () => {
    if (!S.daten) { meldung("Zuerst ein Projekt wählen.", true); return; }
    $("#dlg-ki").showModal();
    $("#ki-status").textContent = "Verbindung wird geprüft …";
    try {
      const st = await api("/api/ai/status");
      $("#ki-status").textContent = anbieterText(st) + (st.available
        ? " Der Entwurf kann ein bis drei Minuten dauern."
        : " LM Studio starten und ein Modell laden - oder einen Konnektor einschalten.");
    } catch (e) { $("#ki-status").textContent = e.message; }
  };
  $("#dlg-ki form").onsubmit = async (e) => {
    e.preventDefault();
    const text = $("#ki-text").value.trim();
    if (text.length < 10) { meldung("Bitte das Vorhaben beschreiben.", true); return; }
    try {
      // Ein Auftrag im Hintergrund: der Dialog geht zu, die Anwendung bleibt
      // bedienbar, das Ergebnis kommt als Meldung (und steht in der Zentrale).
      const r = await jsonRuf(`/api/projects/${S.pid}/ai`, "POST",
        { description: text, replace: $("#ki-replace").checked });
      const pid = S.pid;
      $("#dlg-ki").close();
      meldung(`Entwurf läuft im Hintergrund (Auftrag ${r.job}) – ein bis drei Minuten`);
      aufJob(r.job, async (job) => {
        if (job.status === "done") {
          meldung(`${job.result.added} Vorgänge aus „${job.result.name}“ erzeugt (Modell ${job.result.model})`);
          if (S.pid === pid) {
            await projektLaden();
            if (S.ansicht === "kalender") await kalenderDaten();
            zeichnen();
          }
        } else meldung("Entwurf: " + (letzteLogzeile(job) || job.status), true);
      });
    } catch (e) { meldung(e.message, true); }
  };

  $("#dlg-personen form").onsubmit = async (e) => {
    e.preventDefault();
    const { art, id, name } = S.personenZiel;
    try {
      await jsonRuf("/api/members", "POST",
                    { scope_kind: art, scope_id: id, username: $("#pe-name").value.trim(),
                      role: $("#pe-rolle").value });
      await orgLaden();
      mitgliederVerwalten(art, id, name);
    } catch (fehler) { meldung(fehler.message, true); }
  };
  $("#pe-link").onclick = () => einladung(S.personenZiel.art, S.personenZiel.id,
                                          $("#pe-rolle").value, $("#pe-url"));

  document.querySelectorAll("dialog [data-schliessen]").forEach(
    (b) => b.onclick = () => b.closest("dialog").close());
  ["popstate", "hashchange"].forEach(
    (ereignis) => window.addEventListener(ereignis, () => ausUrl()));
  // Ein abgerissener Aufruf soll eine Meldung geben statt eines leeren Bildes.
  window.addEventListener("unhandledrejection", (e) =>
    meldung((e.reason && e.reason.message) || String(e.reason), true));
  $("#projektname").onclick = () => {
    if (S.daten && !OHNE_PROJEKT.includes(S.ansicht)) {
      oeffneProjekt(S.daten.project, S.daten.project.workspace.id);
    }
  };
  $("#projektname").style.cursor = "pointer";
}

/* Erst wenn alle Skripte da sind: schale.js und die Erweiterungen laden nach
   app.js, und schaleStarten() muss vor bindeDialoge() gelaufen sein. */
document.addEventListener("DOMContentLoaded", async function start() {
  schaleStarten();
  bindeDialoge();
  try {
    await starten();
  } catch (e) {
    // Ohne diesen Fang blieb bei einem Fehler in /api/me oder /api/org ein
    // leeres Gerüst stehen: keine Kopfzeile, kein Inhalt, keine Erklärung.
    meldung("Die Anwendung konnte nicht laden: " + e.message, true);
  }
});

async function starten() {
  S.me = await api("/api/me");
  await orgLaden();
  const abfrage = new URLSearchParams(location.search);
  const gewuenscht = location.hash.slice(1) || abfrage.get("view") || "";
  const vorgangId = Number(abfrage.get("task") || 0);   // Direktlink auf einen Vorgang
  const teil = location.pathname.split("/")[2];
  const projekte = alleProjekte();
  S.pid = teil ? Number(teil) : (projekte[0] ? projekte[0].id : null);
  const datei = gewuenscht.startsWith("datei/") ? decodeURIComponent(gewuenscht.slice(6)) : "";
  if (ANSICHTEN.includes(gewuenscht)) S.ansicht = gewuenscht;
  else if (!S.org.units.length) S.ansicht = "org";
  await projektLaden();
  baueBaum();
  const gemerkt = schaleWiederherstellen();
  await wechsle(S.ansicht, true);      // lädt die Daten der Ansicht, ohne neuen Schritt
  if (datei && typeof oeffneDatei === "function") oeffneDatei(datei, true);
  else if (!gewuenscht && gemerkt && gemerkt.aktiv && gemerkt.aktiv.startsWith("datei:")) {
    aktiviereTab(gemerkt.aktiv);       // zuletzt lag eine Datei vorne
  }
  if (S.me.module.boerse) {
    marktLaden().then(boersenZahl).catch(() => { /* Börse ist keine Startbedingung */ });
  }
  if (S.ansicht !== "tag") {
    agendaLaden().then(hinweiseZahl).catch(() => { /* Hinweise sind Zugabe */ });
  }
  if (vorgangId && S.daten) {
    const t = S.daten.tasks.find((x) => x.id === vorgangId);
    if (t) oeffneVorgang(t);
  }
  if (!S.me.eingerichtet) oeffneEinrichtung();
  verbinden();                                        // Aufträge und Chat kommen live
  // Erweiterungen (chat.js, hub.js) hängen sich hier an: Zähler, Abos.
  document.dispatchEvent(new CustomEvent("app-gestartet"));
}
