Auf Wunsch von Filipe. Betrifft ausschliesslich die Anzeige.
Der Rollenschluessel bleibt "admin". Er steckt in der CHECK-Regel der
Datenbank, in jeder bestehenden Sitzung und in jeder Rechteabfrage --
ihn umzubenennen haette alle drei gebrochen, und bestehende Anmeldungen
waeren ungueltig geworden. Umbenannt wird nur, was man LIEST.
Der Anzeigename steht jetzt an EINER Stelle (ROLLEN_NAME in
workspace.js) und kommt ueber /workspace/api/ich als rolle_name mit.
Stand er in elf Dateien, waere er beim naechsten Mal in zehn davon
geaendert.
Dabei aufgefallen: In der Kopfleiste stand auf JEDER Seite der rohe
Rollenschluessel -- "Chef · admin". Das war schon vorher unschoen, faellt
aber jetzt erst richtig auf. Alle elf Seiten zeigen jetzt "Chef ·
DogFather". Geprueft: kein rohes "admin" mehr in der Oberflaeche.
Geaendert: Rollenkachel und Untertitel auf der Anmeldeseite, Kopfleiste
aller Seiten, Rollentext auf dem Dashboard, Rollenmarken und Auswahl in
der Personenverwaltung, die Betreuungs-Auswahl ("nur DogFather"), der
Hinweis auf der Dateienseite, die Erklaerung zur internen Notiz im
Profil, die Hinweise fuer Scouts ohne Zuteilung -- und vier
Fehlermeldungen vom Server.
Schreibweise "DogFather" wie von Filipe geschrieben; das ist auch auf
der oeffentlichen Website die haeufigste Form (717 von 1216).
In den Code-Kommentaren der Fachmodule heisst die Rolle weiterhin "das
Management". Das bleibt bewusst so -- eine Massenaenderung an vierzig
Kommentaren waere reines Risiko ohne sichtbaren Nutzen. Ein Hinweis an
der ROLLEN_NAME-Zuordnung erklaert den Zusammenhang.
335 lines
12 KiB
JavaScript
335 lines
12 KiB
JavaScript
/* ===================================================================
|
||
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();
|
||
|
||
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));
|
||
f.append(mehr);
|
||
|
||
/* 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);
|
||
await laden();
|
||
})();
|
||
})();
|