/* =================================================================== Calls & Meeting-Protokolle (Konzept, Seite 13). Reihenfolge der Gruppen ist bewusst nicht chronologisch, sondern nach Dringlichkeit: Zuerst die Gespraeche, deren Protokoll FEHLT. Ein vergangener Call ohne Protokoll ist der einzige Zustand, der etwas von einem verlangt. =================================================================== */ (() => { 'use strict'; const $ = (id) => document.getElementById(id); let ich = null; let daten = null; let offen = new Set(); let kiDa = false; const el = (tag, klasse, text) => { const k = document.createElement(tag); if (klasse) k.className = klasse; if (text !== undefined) k.textContent = text; return k; }; const melde = (t) => { $('fehler').textContent = t || ''; }; const WOCHENTAG = ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa']; function zeitpunkt(iso) { if (!iso) return ''; const [d, z] = iso.split('T'); const [j, m, t] = d.split('-'); const w = WOCHENTAG[new Date(+j, +m - 1, +t).getDay()]; return `${w}, ${t}.${m}.${j} · ${z} Uhr`; } const datum = (iso) => (iso ? iso.slice(0, 10).split('-').reverse().join('.') : ''); /* Ein Meeting-Link ist nur dann ein Link, wenn er auch einer ist -- alles andere bleibt schlichter Text (z. B. "bei mir zu Hause"). */ function istLink(ort) { if (!ort) return false; try { const u = new URL(ort.trim()); return u.protocol === 'https:' || u.protocol === 'http:'; } catch { return false; } } async function hole(pfad, optionen = {}) { const a = await fetch(pfad, { credentials: 'same-origin', ...optionen }); if (a.status === 401) { location.assign('/workspace/'); throw new Error('abgemeldet'); } return a; } /* ---------- Protokollbogen ---------------------------------------------- */ function bogen(c) { const f = el('form', 'bogen'); const feld = (schild, hinweis, wert) => { f.append(el('label', 'feld-schild', schild)); if (hinweis) f.append(el('p', 'bogen__hinweis', hinweis)); const t = el('textarea', 'bogen__feld'); t.rows = 3; t.maxLength = 6000; t.value = wert || ''; f.append(t); return t; }; const fPunkte = feld('Besprochene Punkte', null, c.punkte); const fEnt = feld('Entscheidungen', 'Was wurde festgelegt? Nicht was diskutiert wurde – was gilt.', c.entscheidungen); /* To-dos: der Teil, der laut Konzept direkt ins Board wandert. */ f.append(el('label', 'feld-schild', 'To-dos')); f.append(el('p', 'bogen__hinweis', 'Jede Zeile wird sofort eine echte Aufgabe im Brett – mit dem Gespräch als Herkunft.')); const todoBox = el('div', 'todos'); f.append(todoBox); const todoZeile = (fokus) => { const z = el('div', 'todo'); const t = el('input', 'todo__titel'); t.type = 'text'; t.maxLength = 160; t.placeholder = 'Was ist zu tun?'; const d = el('input', 'todo__frist'); d.type = 'date'; d.title = 'Frist (optional)'; const weg = el('button', 'todo__weg', '×'); weg.type = 'button'; weg.title = 'Zeile entfernen'; weg.setAttribute('aria-label', 'Zeile entfernen'); weg.addEventListener('click', () => { z.remove(); if (!todoBox.children.length) todoZeile(true); }); /* Enter in der letzten Zeile legt die naechste an -- so kann man eine Liste tippen, ohne zur Maus zu greifen. */ t.addEventListener('keydown', (e) => { if (e.key !== 'Enter') return; e.preventDefault(); if (z === todoBox.lastElementChild && t.value.trim()) todoZeile(true); else z.nextElementSibling?.querySelector('.todo__titel')?.focus(); }); z.append(t, d, weg); todoBox.append(z); if (fokus) t.focus(); return z; }; todoZeile(false); const mehr = el('button', 'knopf-still', '+ Zeile'); mehr.type = 'button'; mehr.addEventListener('click', () => todoZeile(true)); const reihe = el('div', 'todo-knoepfe'); reihe.append(mehr); /* Vorschlaege aus dem, was oben schon steht. Der Knopf erscheint nur, wenn die KI ueberhaupt laeuft -- sonst gaebe es einen Knopf, der immer scheitert. */ if (kiDa) { reihe.append(window.KI.knopf('To-dos vorschlagen', async () => { const text = [fPunkte.value, fEnt.value].filter((x) => x.trim()).join(' '); if (text.trim().length < 20) { throw new Error('Schreib erst ein paar Sätze oben – daraus kann die KI dann ableiten.'); } const d = await window.KI.frage('todos', { text }); if (!d.todos?.length) throw new Error('Die KI hat nichts gefunden.'); /* Leere Zeilen zuerst fuellen, dann neue anhaengen. Bereits Getipptes wird nie ueberschrieben. */ for (const vorschlag of d.todos) { let ziel = [...todoBox.querySelectorAll('.todo__titel')].find((i) => !i.value.trim()); if (!ziel) { todoZeile(false); ziel = todoBox.lastElementChild.querySelector('.todo__titel'); } ziel.value = vorschlag; ziel.dataset.vonKi = 'ja'; } meldung.textContent = ''; const hinweis = el('p', 'ki-hinweis', d.hinweis); reihe.after(hinweis); setTimeout(() => hinweis.remove(), 9000); })); } f.append(reihe); /* Nächster Termin */ f.append(el('label', 'feld-schild', 'Nächster Termin')); f.append(el('p', 'bogen__hinweis', 'Wenn hier etwas steht, wird der Folgetermin gleich in den Kalender gelegt – ' + 'mit demselben Gegenüber und demselben Link.')); const naechster = el('input', 'bogen__zeit'); naechster.type = 'datetime-local'; f.append(naechster); const meldung = el('p', 'fehler'); meldung.setAttribute('role', 'alert'); f.append(meldung); const knöpfe = el('div', 'kk__knoepfe'); const speichern = el('button', 'knopf knopf--klein', 'Protokoll festhalten'); speichern.type = 'submit'; const abbrechen = el('button', 'knopf-still', 'Abbrechen'); abbrechen.type = 'button'; abbrechen.addEventListener('click', () => { offen.delete(c.id); zeichne(); }); knöpfe.append(speichern, abbrechen); f.append(knöpfe); f.addEventListener('submit', async (e) => { e.preventDefault(); meldung.textContent = ''; const todos = [...todoBox.querySelectorAll('.todo')] .map((z) => ({ titel: z.querySelector('.todo__titel').value, frist: z.querySelector('.todo__frist').value, })) .filter((t) => t.titel.trim()); if (!fPunkte.value.trim() && !fEnt.value.trim() && !todos.length) { meldung.textContent = 'Ein leeres Protokoll hilft niemandem.'; return; } speichern.disabled = true; try { const a = await hole(`/workspace/api/calls/${c.id}/protokoll`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ punkte: fPunkte.value, entscheidungen: fEnt.value, todos, naechster_beginn: naechster.value, }), }); if (!a.ok) { meldung.textContent = (await a.json().catch(() => ({}))).fehler || 'Ging nicht.'; return; } const r = await a.json(); offen.delete(c.id); await laden(); melde(r.aufgaben ? `Protokoll festgehalten – ${r.aufgaben} To-do(s) liegen jetzt im Aufgabenbrett.` : 'Protokoll festgehalten.'); setTimeout(() => melde(''), 5000); } catch { /* umgeleitet */ } finally { speichern.disabled = false; } }); return f; } /* ---------- Karte -------------------------------------------------------- */ function karte(c, gruppe) { const k = el('article', 'call'); k.dataset.gruppe = gruppe; k.id = 'call-' + c.id; const kopf = el('div', 'call__kopf'); const links = el('div'); links.append(el('h3', 'call__titel', c.titel)); const wer = [c.creator_name, c.teilnehmer_name].filter(Boolean).join(' · '); links.append(el('p', 'call__zeit', zeitpunkt(c.beginn) + ` · ${c.dauer_min} Min` + (wer ? ' · ' + wer : ''))); kopf.append(links); const marken = el('div', 'kk__marken'); if (c.art === 'review') marken.append(el('span', 'marke-still', 'Review')); if (gruppe === 'offen') marken.append(el('span', 'marke-fehlt', 'Protokoll fehlt')); if (c.aufgaben.length) { const erledigt = c.aufgaben.filter((a) => a.status === 'erledigt').length; marken.append(el('span', 'marke-still', `${erledigt}/${c.aufgaben.length} To-dos erledigt`)); } kopf.append(marken); k.append(kopf); if (c.beschreibung) k.append(el('p', 'call__text', c.beschreibung)); /* Meeting-Link nur bei anstehenden Gesprächen -- danach ist er wertlos. */ const knöpfe = el('div', 'kk__knoepfe'); if (gruppe === 'anstehend' && istLink(c.ort)) { const a = el('a', 'knopf knopf--klein', 'Call öffnen'); a.href = c.ort; a.target = '_blank'; a.rel = 'noopener noreferrer'; knöpfe.append(a); } else if (c.ort && !istLink(c.ort)) { k.append(el('p', 'call__ort', c.ort)); } if (gruppe !== 'anstehend') { const b = el('button', gruppe === 'offen' ? 'knopf knopf--klein' : 'knopf-still', offen.has(c.id) ? 'Schließen' : c.protokoll_id ? 'Protokoll ansehen' : 'Protokoll schreiben'); b.type = 'button'; b.addEventListener('click', () => { if (offen.has(c.id)) offen.delete(c.id); else offen.add(c.id); zeichne(); }); knöpfe.append(b); } if (knöpfe.childNodes.length) k.append(knöpfe); if (!offen.has(c.id)) return k; const auf = el('div', 'call__detail'); if (c.protokoll_id) { /* Fertiges Protokoll: lesen, nicht bearbeiten. Ein festgehaltenes Gespräch nachträglich umzuschreiben wäre genau das, was ein Protokoll wertlos macht. */ if (c.punkte) { auf.append(el('p', 'kk__schild', 'Besprochene Punkte')); auf.append(el('p', 'call__abschnitt', c.punkte)); } if (c.entscheidungen) { auf.append(el('p', 'kk__schild', 'Entscheidungen')); auf.append(el('p', 'call__abschnitt', c.entscheidungen)); } if (c.aufgaben.length) { auf.append(el('p', 'kk__schild', 'Daraus entstandene Aufgaben')); const ul = el('ul', 'todo-liste'); for (const a of c.aufgaben) { const li = el('li'); li.dataset.status = a.status; li.append(el('span', 'todo-liste__titel', a.titel)); const zus = [a.verantwortlich_name, a.frist ? 'bis ' + datum(a.frist) : null, a.status === 'erledigt' ? 'erledigt' : null].filter(Boolean).join(' · '); if (zus) li.append(el('span', 'kk__still', zus)); ul.append(li); } auf.append(ul); } auf.append(el('p', 'call__quelle', `Festgehalten von ${c.protokoll_von || '—'} am ${datum(c.protokoll_erstellt)}`)); if (c.naechster_termin_id) { const p = el('p', 'call__quelle', 'Folgetermin liegt im Kalender. '); const a = el('a', 'call__verweis', 'Kalender öffnen'); a.href = 'kalender.html'; p.append(a); auf.append(p); } } else { auf.append(bogen(c)); } k.append(auf); return k; } /* ---------- Zeichnen ----------------------------------------------------- */ const GRUPPEN = [ { schluessel: 'offen', titel: 'Protokoll fehlt', frage: 'Diese Gespräche sind vorbei, aber noch nicht festgehalten.' }, { schluessel: 'anstehend', titel: 'Steht an', frage: null }, { schluessel: 'erledigt', titel: 'Festgehalten', frage: null }, ]; function zeichne() { const ziel = $('listen'); ziel.textContent = ''; ziel.setAttribute('aria-busy', 'false'); if (!daten.offen.length && !daten.anstehend.length && !daten.erledigt.length) { const leer = el('div', 'leer-hinweis'); leer.append(document.createTextNode('Noch keine Calls. Termine mit der Art „Call" oder „Review" ')); const a = el('a', 'call__verweis', 'im Kalender'); a.href = 'kalender.html'; leer.append(a, document.createTextNode(' erscheinen automatisch hier.')); ziel.append(leer); return; } for (const g of GRUPPEN) { const liste = daten[g.schluessel]; if (!liste.length) continue; const s = el('section', 'gruppe'); s.dataset.gruppe = g.schluessel; const kopf = el('div', 'stufe__kopf'); kopf.append(el('h2', 'stufe__titel', g.titel), el('span', 'stufe__zahl', String(liste.length))); s.append(kopf); if (g.frage) s.append(el('p', 'block__frage', g.frage)); const box = el('div', 'gruppe__karten'); for (const c of liste) box.append(karte(c, g.schluessel)); s.append(box); ziel.append(s); } } async function laden() { try { const a = await hole('/workspace/api/calls'); if (!a.ok) { melde('Gespräche konnten nicht geladen werden.'); return; } daten = await a.json(); zeichne(); } catch { /* umgeleitet */ } } /* ---------- Start --------------------------------------------------------- */ (async () => { try { const a = await hole('/workspace/api/ich'); if (!a.ok) { location.assign('/workspace/'); return; } ich = await a.json(); } catch { return; } $('wer').textContent = ich.name + ' · ' + (ich.rolle_name || ich.rolle); kiDa = await window.KI.verfuegbar(); await laden(); })(); })();