Letzter offener Punkt aus dem Konzept, Seite 4: Ein Creator hat laut Rollenbeschreibung "Aufgaben & Feedback" -- die Aufgaben gab es, das Feedback nicht. Ohne diese Moeglichkeit endet jede Rueckfrage ausserhalb des Systems, in WhatsApp, und damit ausserhalb dessen, was spaeter noch nachvollziehbar ist. Neue Tabelle aufgaben_notizen. Wer die Aufgabe sehen darf, darf auch mitreden -- geprueft ueber DIESELBE Sichtbarkeitsregel wie fuer die Aufgabe selbst. Eine eigene Regel gibt es bewusst nicht: Zwei Regeln fuer dieselbe Sache laufen irgendwann auseinander, und die Aufgaben- sichtbarkeit ist die schwierigere von beiden (Creator sieht eigene, Scout die seiner betreuten Creator, Leitung alles). 404 statt 403, wenn die Aufgabe nicht sichtbar ist -- wer sie nicht sehen darf, soll nicht erfahren, dass es sie gibt. Die eigene Rueckmeldung darf jeder zuruecknehmen, fremde nur die Leitung. Eine Rueckmeldung ist keine Abstimmung: Wer sich vertippt hat, soll das nicht bei jemandem beantragen muessen. Der Knopf auf der Karte zeigt die ANZAHL, nicht nur ein Symbol -- so sieht man ohne Aufklappen, wo schon etwas besprochen wurde, und die Karte bleibt trotzdem ruhig. Aufgeklappt wird direkt an der Karte, nicht in einem Fenster: Man liest die Rueckmeldungen im Zusammenhang mit der Aufgabe, sonst fehlt beim Antworten die Haelfte. Geprueft: - Chef und Luna schreiben sich gegenseitig, beide sehen beides - Mika (fremder Creator) bekommt 404 beim Lesen UND beim Schreiben - ohne Anmeldung 401, fremde Herkunft 403, leerer Text abgewiesen - Luna kann Chefs Rueckmeldung nicht loeschen, ihre eigene schon - Chef kann alle loeschen - Zaehler auf der Karte stimmt, Handy ohne Ueberlauf, 0 Konsolenfehler
393 lines
14 KiB
JavaScript
393 lines
14 KiB
JavaScript
/* ===================================================================
|
||
Aufgabenbrett.
|
||
|
||
Der Server entscheidet, wer was sehen und ändern darf -- diese Datei
|
||
zeigt nur an. Was hier ausgeblendet wird, ist Bequemlichkeit, kein
|
||
Schutz.
|
||
=================================================================== */
|
||
(() => {
|
||
'use strict';
|
||
|
||
/* DogFather und Manager haben dieselben Rechte -- die Unterscheidung
|
||
macht nur die Personenverwaltung, und die prueft der Server. */
|
||
const LEITUNG = new Set(['admin', 'manager']);
|
||
|
||
const SPALTEN = [
|
||
{ status: 'offen', name: 'Offen' },
|
||
{ status: 'arbeit', name: 'In Arbeit' },
|
||
{ status: 'review', name: 'Review' },
|
||
{ status: 'erledigt', name: 'Erledigt' },
|
||
];
|
||
const WEITER = { offen: 'arbeit', arbeit: 'review', review: 'erledigt' };
|
||
const ZURUECK = { arbeit: 'offen', review: 'arbeit', erledigt: 'review' };
|
||
|
||
const $ = (id) => document.getElementById(id);
|
||
const heute = new Date().toISOString().slice(0, 10);
|
||
let ich = null;
|
||
|
||
/* Texte werden ausschließlich über textContent gesetzt, nie über
|
||
innerHTML -- ein Aufgabentitel darf keine Auszeichnung einschleusen. */
|
||
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 = (text) => { $('fehler').textContent = text || ''; };
|
||
|
||
async function hole(pfad, optionen = {}) {
|
||
const antwort = await fetch(pfad, { credentials: 'same-origin', ...optionen });
|
||
if (antwort.status === 401) { location.assign('/workspace/'); throw new Error('abgemeldet'); }
|
||
return antwort;
|
||
}
|
||
|
||
/* ---------- Brett zeichnen ------------------------------------------ */
|
||
|
||
function datumHuebsch(iso) {
|
||
const [j, m, t] = iso.split('-');
|
||
return `${t}.${m}.${j.slice(2)}`;
|
||
}
|
||
|
||
/* ---------- Rückmeldungen ------------------------------------------------
|
||
Direkt an der Karte, nicht in einem Fenster: Man liest sie im
|
||
Zusammenhang mit der Aufgabe, sonst fehlt beim Antworten die Hälfte. */
|
||
|
||
const zeitpunkt = (iso) => {
|
||
if (!iso) return '';
|
||
const d = new Date(iso);
|
||
const p = (n) => String(n).padStart(2, '0');
|
||
return `${p(d.getDate())}.${p(d.getMonth() + 1)}. · ${p(d.getHours())}:${p(d.getMinutes())}`;
|
||
};
|
||
|
||
async function rueckmeldungen(karteEl, a, knopfEl) {
|
||
const vorhanden = karteEl.querySelector('.rueck');
|
||
if (vorhanden) { vorhanden.remove(); delete knopfEl.dataset.offen; return; }
|
||
knopfEl.dataset.offen = 'ja';
|
||
|
||
const box = el('div', 'rueck');
|
||
box.append(el('p', 'leise', 'Wird geladen …'));
|
||
karteEl.append(box);
|
||
|
||
let daten;
|
||
try {
|
||
const antwort = await hole(`/workspace/api/aufgaben/${a.id}/notizen`);
|
||
if (!antwort.ok) {
|
||
box.textContent = '';
|
||
box.append(el('p', 'fehler', 'Konnte nicht geladen werden.'));
|
||
return;
|
||
}
|
||
daten = await antwort.json();
|
||
} catch { return; }
|
||
|
||
box.textContent = '';
|
||
const liste = el('div', 'rueck__liste');
|
||
if (!daten.notizen.length) liste.append(el('p', 'leise', 'Noch nichts besprochen.'));
|
||
for (const n of daten.notizen) {
|
||
const z = el('div', 'rueck__eintrag');
|
||
const kopf = el('p', 'rueck__kopf');
|
||
kopf.append(el('span', 'rueck__wer', n.von || 'Unbekannt'));
|
||
kopf.append(el('span', 'rueck__wann', zeitpunkt(n.erstellt)));
|
||
/* Die eigene Rückmeldung darf jeder zurücknehmen, fremde nur die
|
||
Leitung -- wer sich vertippt hat, soll das nicht beantragen
|
||
müssen. */
|
||
if (n.person_id === daten.ich || daten.darf_alles_loeschen) {
|
||
const weg = el('button', 'rueck__weg', '×');
|
||
weg.type = 'button';
|
||
weg.title = 'Rückmeldung entfernen';
|
||
weg.setAttribute('aria-label', 'Rückmeldung entfernen');
|
||
weg.addEventListener('click', async () => {
|
||
if (!confirm('Diese Rückmeldung entfernen?')) return;
|
||
const r = await hole('/workspace/api/notizen/' + n.id, { method: 'DELETE' });
|
||
if (!r.ok) { melde('Entfernen hat nicht geklappt.'); return; }
|
||
await laden();
|
||
});
|
||
kopf.append(weg);
|
||
}
|
||
z.append(kopf, el('p', 'rueck__text', n.text));
|
||
liste.append(z);
|
||
}
|
||
box.append(liste);
|
||
|
||
const form = el('form', 'rueck__form');
|
||
const feld = el('textarea', 'rueck__feld');
|
||
feld.rows = 2;
|
||
feld.maxLength = 2000;
|
||
feld.placeholder = 'Rückmeldung schreiben …';
|
||
const senden = el('button', 'knopf knopf--klein', 'Abschicken');
|
||
senden.type = 'submit';
|
||
form.append(feld, senden);
|
||
form.addEventListener('submit', async (e) => {
|
||
e.preventDefault();
|
||
const text = feld.value.trim();
|
||
if (text.length < 2) return;
|
||
senden.disabled = true;
|
||
try {
|
||
const r = await hole(`/workspace/api/aufgaben/${a.id}/notizen`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ text }),
|
||
});
|
||
if (!r.ok) { melde((await r.json().catch(() => ({}))).fehler || 'Ging nicht.'); return; }
|
||
await laden();
|
||
} catch { /* umgeleitet */ } finally { senden.disabled = false; }
|
||
});
|
||
box.append(form);
|
||
feld.focus();
|
||
}
|
||
|
||
function karte(a) {
|
||
const k = el('article', 'karte');
|
||
k.dataset.prio = a.prioritaet;
|
||
|
||
const kopf = el('div', 'karte__kopf');
|
||
kopf.append(el('h3', 'karte__titel', a.titel));
|
||
const stift = el('button', 'karte__stift', 'bearbeiten');
|
||
stift.type = 'button';
|
||
stift.setAttribute('aria-label', `${a.titel} bearbeiten`);
|
||
stift.addEventListener('click', () => dialogOeffnen(a));
|
||
kopf.append(stift);
|
||
k.append(kopf);
|
||
if (a.beschreibung) k.append(el('p', 'karte__text', a.beschreibung));
|
||
|
||
const zeile = el('div', 'karte__zeile');
|
||
if (a.frist) {
|
||
const faellig = a.status !== 'erledigt' && a.frist < heute;
|
||
const f = el('span', 'karte__frist' + (faellig ? ' karte__frist--faellig' : ''),
|
||
(faellig ? 'überfällig ' : 'bis ') + datumHuebsch(a.frist));
|
||
zeile.append(f);
|
||
}
|
||
if (a.verantwortlich_name) zeile.append(el('span', null, '· ' + a.verantwortlich_name));
|
||
if (LEITUNG.has(ich.rolle) && a.creator_name && a.creator_name !== a.verantwortlich_name) {
|
||
zeile.append(el('span', null, '· Bereich ' + a.creator_name));
|
||
}
|
||
if (zeile.childElementCount) k.append(zeile);
|
||
|
||
const schritte = el('div', 'karte__schritte');
|
||
if (ZURUECK[a.status]) schritte.append(knopf('◀ zurück', a, ZURUECK[a.status]));
|
||
if (WEITER[a.status]) schritte.append(knopf(beschriftung(a.status), a, WEITER[a.status]));
|
||
if (schritte.childElementCount) k.append(schritte);
|
||
|
||
/* Rückmeldungen ("Aufgaben & Feedback" aus dem Konzept). Der Knopf
|
||
zeigt gleich die Anzahl -- so sieht man ohne Aufklappen, wo etwas
|
||
besprochen wurde. */
|
||
const rueck = el('button', 'karte__rueck');
|
||
rueck.type = 'button';
|
||
rueck.dataset.hat = a.notizen ? 'ja' : 'nein';
|
||
const blase = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||
blase.setAttribute('viewBox', '0 0 24 24');
|
||
blase.setAttribute('class', 'karte__blase');
|
||
blase.setAttribute('aria-hidden', 'true');
|
||
blase.innerHTML = '<path d="M20.4 12.4c0 3.8-3.8 6.9-8.4 6.9-1 0-2-.15-2.9-.42L4 20.4l1.6-3.6a6.6 6.6 0 0 1-2-4.4C3.6 8.6 7.4 5.5 12 5.5s8.4 3.1 8.4 6.9z"/>';
|
||
rueck.append(blase, el('span', null, a.notizen
|
||
? `${a.notizen} ${a.notizen === 1 ? 'Rückmeldung' : 'Rückmeldungen'}`
|
||
: 'Rückmeldung schreiben'));
|
||
rueck.addEventListener('click', () => rueckmeldungen(k, a, rueck));
|
||
k.append(rueck);
|
||
return k;
|
||
}
|
||
|
||
const beschriftung = (status) => ({
|
||
offen: 'starten ▶', arbeit: 'zum Review ▶', review: 'erledigt ▶',
|
||
})[status];
|
||
|
||
function knopf(text, aufgabe, neuerStatus) {
|
||
const b = el('button', 'schritt', text);
|
||
b.type = 'button';
|
||
b.addEventListener('click', async () => {
|
||
b.disabled = true;
|
||
melde('');
|
||
try {
|
||
const antwort = await hole(`/workspace/api/aufgaben/${aufgabe.id}`, {
|
||
method: 'PATCH',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ status: neuerStatus }),
|
||
});
|
||
if (!antwort.ok) {
|
||
const d = await antwort.json().catch(() => ({}));
|
||
melde(d.fehler === 'nicht_erlaubt'
|
||
? 'Diese Aufgabe darfst du nicht ändern.'
|
||
: 'Ändern hat nicht geklappt.');
|
||
b.disabled = false;
|
||
return;
|
||
}
|
||
await laden();
|
||
} catch { b.disabled = false; }
|
||
});
|
||
return b;
|
||
}
|
||
|
||
function zeichne(aufgaben) {
|
||
const brett = $('brett');
|
||
brett.textContent = '';
|
||
brett.setAttribute('aria-busy', 'false');
|
||
|
||
for (const s of SPALTEN) {
|
||
const eigene = aufgaben.filter((a) => a.status === s.status);
|
||
const spalte = el('section', 'spalte');
|
||
spalte.dataset.status = s.status;
|
||
|
||
const kopf = el('h2', 'spalte__kopf');
|
||
kopf.append(el('span', 'spalte__punkt'), el('span', null, s.name),
|
||
el('span', 'spalte__zahl', String(eigene.length)));
|
||
spalte.append(kopf);
|
||
|
||
if (!eigene.length) spalte.append(el('p', 'spalte__leer', '—'));
|
||
else for (const a of eigene) spalte.append(karte(a));
|
||
|
||
brett.append(spalte);
|
||
}
|
||
}
|
||
|
||
async function laden() {
|
||
try {
|
||
const antwort = await hole('/workspace/api/aufgaben');
|
||
if (!antwort.ok) { melde('Aufgaben konnten nicht geladen werden.'); return; }
|
||
zeichne((await antwort.json()).aufgaben);
|
||
} catch { /* bereits umgeleitet */ }
|
||
}
|
||
|
||
/* ---------- Neue Aufgabe -------------------------------------------- */
|
||
|
||
function formularUmschalten(auf) {
|
||
$('neu').hidden = !auf;
|
||
$('neu-oeffnen').hidden = auf;
|
||
if (auf) $('f-titel').focus();
|
||
}
|
||
|
||
$('neu-oeffnen').addEventListener('click', () => formularUmschalten(true));
|
||
$('neu-abbrechen').addEventListener('click', () => {
|
||
$('neu').reset(); $('neu-fehler').textContent = ''; formularUmschalten(false);
|
||
});
|
||
|
||
$('neu').addEventListener('submit', async (e) => {
|
||
e.preventDefault();
|
||
const knopfEl = $('neu-speichern');
|
||
$('neu-fehler').textContent = '';
|
||
|
||
const daten = {
|
||
titel: $('f-titel').value,
|
||
beschreibung: $('f-text').value,
|
||
prioritaet: $('f-prio').value,
|
||
frist: $('f-frist').value,
|
||
};
|
||
if (LEITUNG.has(ich.rolle)) {
|
||
daten.creator_id = $('f-creator').value || null;
|
||
daten.verantwortlich_id = $('f-verant').value || null;
|
||
}
|
||
|
||
knopfEl.disabled = true;
|
||
try {
|
||
const antwort = await hole('/workspace/api/aufgaben', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(daten),
|
||
});
|
||
if (!antwort.ok) {
|
||
const d = await antwort.json().catch(() => ({}));
|
||
$('neu-fehler').textContent = d.fehler || 'Anlegen hat nicht geklappt.';
|
||
return;
|
||
}
|
||
$('neu').reset();
|
||
formularUmschalten(false);
|
||
await laden();
|
||
} catch { /* bereits umgeleitet */ } finally { knopfEl.disabled = false; }
|
||
});
|
||
|
||
/* ---------- Bearbeiten ------------------------------------------------ */
|
||
|
||
const dialog = $('bearbeiten-dialog');
|
||
let bearbeitet = null;
|
||
|
||
function dialogOeffnen(a) {
|
||
bearbeitet = a;
|
||
$('b-titel').value = a.titel;
|
||
$('b-text').value = a.beschreibung || '';
|
||
$('b-prio').value = a.prioritaet;
|
||
$('b-frist').value = a.frist || '';
|
||
$('b-fehler').textContent = '';
|
||
if (LEITUNG.has(ich.rolle)) {
|
||
$('b-creator').value = a.creator_id ? String(a.creator_id) : '';
|
||
$('b-verant').value = a.verantwortlich_id ? String(a.verantwortlich_id) : '';
|
||
$('b-loeschen').hidden = false;
|
||
}
|
||
dialog.showModal();
|
||
$('b-titel').focus();
|
||
}
|
||
|
||
$('b-abbrechen').addEventListener('click', () => dialog.close());
|
||
|
||
$('b-speichern').addEventListener('click', async () => {
|
||
const daten = {
|
||
titel: $('b-titel').value,
|
||
beschreibung: $('b-text').value,
|
||
prioritaet: $('b-prio').value,
|
||
frist: $('b-frist').value,
|
||
};
|
||
if (LEITUNG.has(ich.rolle)) {
|
||
daten.creator_id = $('b-creator').value || null;
|
||
daten.verantwortlich_id = $('b-verant').value || null;
|
||
}
|
||
$('b-speichern').disabled = true;
|
||
try {
|
||
const a = await hole(`/workspace/api/aufgaben/${bearbeitet.id}`, {
|
||
method: 'PATCH',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(daten),
|
||
});
|
||
if (!a.ok) {
|
||
$('b-fehler').textContent = (await a.json().catch(() => ({}))).fehler || 'Speichern ging nicht.';
|
||
return;
|
||
}
|
||
dialog.close();
|
||
await laden();
|
||
} catch { /* umgeleitet */ } finally { $('b-speichern').disabled = false; }
|
||
});
|
||
|
||
$('b-loeschen').addEventListener('click', async () => {
|
||
if (!confirm(`Aufgabe wirklich loeschen?
|
||
|
||
"${bearbeitet.titel}"
|
||
|
||
Das laesst sich nicht rueckgaengig machen. Erledigte Aufgaben sollten normalerweise stehen bleiben.`)) return;
|
||
try {
|
||
const a = await hole(`/workspace/api/aufgaben/${bearbeitet.id}`, { method: 'DELETE' });
|
||
if (!a.ok) { $('b-fehler').textContent = 'Loeschen ging nicht.'; return; }
|
||
dialog.close();
|
||
await laden();
|
||
} catch { /* umgeleitet */ }
|
||
});
|
||
|
||
/* ---------- Start ----------------------------------------------------- */
|
||
|
||
|
||
(async () => {
|
||
try {
|
||
const antwort = await hole('/workspace/api/ich');
|
||
if (!antwort.ok) { location.assign('/workspace/'); return; }
|
||
ich = await antwort.json();
|
||
} catch { return; }
|
||
|
||
$('wer').textContent = `${ich.name} · ${ich.rolle_name || ich.rolle}`;
|
||
|
||
if (LEITUNG.has(ich.rolle)) {
|
||
$('feld-creator').hidden = false;
|
||
$('feld-verant').hidden = false;
|
||
$('b-feld-creator').hidden = false;
|
||
$('b-feld-verant').hidden = false;
|
||
try {
|
||
const { personen } = await (await hole('/workspace/api/personen')).json();
|
||
for (const p of personen) {
|
||
for (const ziel of ['f-creator', 'f-verant', 'b-creator', 'b-verant']) {
|
||
const o = el('option', null, `${p.name} (${p.rolle})`);
|
||
o.value = String(p.id);
|
||
$(ziel).append(o);
|
||
}
|
||
}
|
||
} catch { /* Zuordnung bleibt dann leer -- kein Grund abzubrechen */ }
|
||
}
|
||
|
||
await laden();
|
||
})();
|
||
})();
|