Workspace: Kalender mit Terminen, Calls und Aufgaben-Fristen

/workspace/kalender.html -- letzter grosser Baustein aus Phase 1.

Zeigt zwei Quellen in EINER Ansicht, wie im Konzept gefordert
("Kalender & Calls" neben "Deadlines" und "Wiedervorlagen"):
  * eigene Termine (Arten: Call, Termin, Review)
  * Fristen offener Aufgaben, nur lesend eingeblendet

Nach Tagen gruppiert statt als Monatsraster: Es geht um wenige, dafuer
konkrete Termine. Eine Liste liest sich dabei besser und funktioniert auf
dem Handy ohne Umbau. Zeitraum umschaltbar: 14 / 30 / 90 Tage, wobei 90
den 90-Tage-Plan aus dem Konzept abdeckt.

Sichtbarkeit wie bei den Aufgaben, wieder an einer Stelle definiert.
Geprueft mit drei Konten:
  Chef 2 Termine + 2 Fristen | Luna 2 + 1 | Sam 0 + 1
Die Fristen folgen dabei exakt der Aufgaben-Regel -- Sam sieht nur seine.

Loeschen darf das Management und wer den Termin selbst angelegt hat.
Sonst koennte ein Creator einen Call absagen, den das Management
angesetzt hat. Geprueft: Luna auf Chefs Call 403, auf ihren eigenen 200,
Sam sieht ihn gar nicht (404).

Weiteres:
- Ort/Link wird nur dann als Verweis dargestellt, wenn er wirklich mit
  http(s) beginnt -- sonst liesse sich javascript: einschleusen
- Zeitpunkt und Dauer werden geprueft (kaputtes Datum 400,
  5000 Minuten 400)
- Beginn steht als lokale Zeit ohne Zeitzone: Alle Beteiligten sitzen in
  derselben, eine falsch umgerechnete Uhrzeit waere schlimmer als gar
  keine Umrechnung
This commit is contained in:
2026-08-28 00:24:12 +02:00
parent a378b126fe
commit ef79659f8f
7 changed files with 714 additions and 1 deletions
+76
View File
@@ -0,0 +1,76 @@
/* ===================================================================
Kalender. Nach Tagen gruppiert statt als Monatsraster: Es geht um
wenige, dafür konkrete Termine -- eine Liste liest sich dabei besser
und funktioniert auf dem Handy ohne Umbau.
=================================================================== */
.zeitraum { display: flex; gap: 8px; margin: 0 0 22px; flex-wrap: wrap; }
.zeitraum .schritt[aria-pressed="true"] {
background: rgba(63, 189, 245, .14);
border-color: rgba(63, 189, 245, .55);
color: var(--text);
}
.tag { margin-bottom: 22px; }
.tag__kopf {
display: flex; align-items: baseline; gap: 10px;
margin: 0 2px 9px; padding-bottom: 7px;
border-bottom: 1px solid var(--rand);
}
.tag__datum { font-weight: 700; font-size: .95rem; }
.tag__wochentag { font-size: .8rem; color: var(--text-still); }
.tag__marke {
margin-left: auto; padding: 1px 9px; border-radius: 999px;
font-size: .7rem; font-weight: 700; letter-spacing: .06em; text-transform: uppercase;
background: rgba(63, 189, 245, .16); color: #7fd2fb;
}
/* Vergangene Tage treten zurück, bleiben aber lesbar. */
.tag[data-vergangen="ja"] .tag__datum,
.tag[data-vergangen="ja"] .tag__wochentag { color: var(--text-still); }
.eintrag {
display: grid; gap: 3px 14px;
grid-template-columns: 78px 1fr auto;
align-items: start;
padding: 11px 14px; margin-bottom: 8px;
background: rgba(255, 255, 255, .03);
border: 1px solid var(--rand);
border-left: 3px solid var(--rand);
border-radius: var(--radius-klein);
}
.eintrag[data-art="call"] { border-left-color: var(--akzent); }
.eintrag[data-art="review"] { border-left-color: #e0a33c; }
.eintrag[data-art="termin"] { border-left-color: var(--violett); }
.eintrag[data-art="frist"] { border-left-color: #d8656e; background: rgba(216, 101, 110, .05); }
.eintrag[data-erledigt="ja"] { opacity: .5; }
.eintrag__zeit {
font-variant-numeric: tabular-nums; font-weight: 600; font-size: .88rem;
color: var(--text-leise);
}
.eintrag__titel { font-weight: 600; font-size: .93rem; }
.eintrag__zusatz {
grid-column: 2; display: flex; flex-wrap: wrap; gap: 8px;
font-size: .78rem; color: var(--text-still);
}
.eintrag__zusatz a { color: var(--akzent); }
.eintrag__knoepfe { display: flex; gap: 6px; align-items: center; }
.eintrag__art {
padding: 1px 8px; border-radius: 999px;
font-size: .68rem; font-weight: 700; letter-spacing: .06em; text-transform: uppercase;
background: rgba(255, 255, 255, .06); color: var(--text-still);
}
.leer-hinweis {
padding: 26px 20px; text-align: center;
color: var(--text-still); font-size: .9rem;
border: 1px dashed var(--rand); border-radius: var(--radius);
}
@media (max-width: 620px) {
.eintrag { grid-template-columns: 62px 1fr; }
.eintrag__knoepfe { grid-column: 1 / -1; margin-top: 8px; }
.eintrag__zusatz { grid-column: 1 / -1; }
}
+266
View File
@@ -0,0 +1,266 @@
/* ===================================================================
Kalender: eigene Termine und Fristen aus den Aufgaben in einer Liste.
Fristen sind nur eingeblendet -- geändert werden sie im Aufgabenbrett.
=================================================================== */
(() => {
'use strict';
const $ = (id) => document.getElementById(id);
const WOCHENTAGE = ['Sonntag', 'Montag', 'Dienstag', 'Mittwoch',
'Donnerstag', 'Freitag', 'Samstag'];
const ARTNAME = { call: 'Call', termin: 'Termin', review: 'Review', frist: 'Frist' };
let ich = null;
let tage = 30;
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 heute = new Date().toISOString().slice(0, 10);
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;
}
function datumHuebsch(iso) {
const [j, m, t] = iso.slice(0, 10).split('-');
return `${t}.${m}.${j}`;
}
function wochentag(iso) {
return WOCHENTAGE[new Date(iso.slice(0, 10) + 'T12:00:00').getDay()];
}
/* ---------- Zeichnen --------------------------------------------------- */
function eintragTermin(t) {
const k = el('div', 'eintrag');
k.dataset.art = t.art;
k.dataset.erledigt = t.erledigt ? 'ja' : 'nein';
k.append(el('span', 'eintrag__zeit', t.beginn.slice(11, 16) + ' Uhr'));
k.append(el('span', 'eintrag__titel', t.titel));
const knoepfe = el('div', 'eintrag__knoepfe');
knoepfe.append(el('span', 'eintrag__art', ARTNAME[t.art] || t.art));
knoepfe.append(tuKnopf(t.erledigt ? 'wieder offen' : 'erledigt', async () => {
const a = await hole('/workspace/api/termine/' + t.id, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ erledigt: !t.erledigt }),
});
if (!a.ok) { melde('Ändern ging nicht.'); return; }
await laden();
}));
if (ich.rolle === 'admin' || t.erstellt_von === ich.id) {
knoepfe.append(tuKnopf('löschen', async () => {
if (!confirm('Termin "' + t.titel + '" löschen?')) return;
const a = await hole('/workspace/api/termine/' + t.id, { method: 'DELETE' });
if (!a.ok) { melde('Löschen ging nicht.'); return; }
await laden();
}));
}
k.append(knoepfe);
const zusatz = el('div', 'eintrag__zusatz');
if (t.dauer_min) zusatz.append(el('span', null, t.dauer_min + ' Min'));
if (t.teilnehmer_name) zusatz.append(el('span', null, '· mit ' + t.teilnehmer_name));
if (ich.rolle === 'admin' && t.creator_name && t.creator_name !== t.teilnehmer_name) {
zusatz.append(el('span', null, '· Bereich ' + t.creator_name));
}
if (t.ort) {
/* Links sollen klickbar sein, aber nur echte http(s)-Adressen --
sonst liesse sich hier javascript: einschleusen. */
if (/^https?:\/\//i.test(t.ort)) {
const a = el('a', null, t.ort.replace(/^https?:\/\//i, '').slice(0, 60));
a.href = t.ort; a.target = '_blank'; a.rel = 'noopener noreferrer';
zusatz.append(a);
} else {
zusatz.append(el('span', null, '· ' + t.ort));
}
}
if (t.beschreibung) zusatz.append(el('span', null, '· ' + t.beschreibung));
if (zusatz.childElementCount) k.append(zusatz);
return k;
}
function eintragFrist(f) {
const k = el('div', 'eintrag');
k.dataset.art = 'frist';
k.append(el('span', 'eintrag__zeit', '—'));
k.append(el('span', 'eintrag__titel', f.titel));
const knoepfe = el('div', 'eintrag__knoepfe');
knoepfe.append(el('span', 'eintrag__art', 'Frist'));
const link = el('a', 'schritt', 'zur Aufgabe');
link.href = 'aufgaben.html';
knoepfe.append(link);
k.append(knoepfe);
const zusatz = el('div', 'eintrag__zusatz');
zusatz.append(el('span', null, 'Priorität ' + f.prioritaet));
if (f.verantwortlich_name) zusatz.append(el('span', null, '· ' + f.verantwortlich_name));
k.append(zusatz);
return k;
}
function tuKnopf(text, tu) {
const b = el('button', 'schritt', text);
b.type = 'button';
b.addEventListener('click', async () => {
b.disabled = true; melde('');
try { await tu(); } catch { /* umgeleitet */ } finally { b.disabled = false; }
});
return b;
}
function zeichne(daten) {
const ziel = $('liste');
ziel.textContent = '';
ziel.setAttribute('aria-busy', 'false');
/* Termine und Fristen nach Tag zusammenlegen. */
const nachTag = new Map();
for (const t of daten.termine) {
const tag = t.beginn.slice(0, 10);
if (!nachTag.has(tag)) nachTag.set(tag, { termine: [], fristen: [] });
nachTag.get(tag).termine.push(t);
}
for (const f of daten.fristen) {
if (!nachTag.has(f.frist)) nachTag.set(f.frist, { termine: [], fristen: [] });
nachTag.get(f.frist).fristen.push(f);
}
if (!nachTag.size) {
ziel.append(el('p', 'leer-hinweis',
'Für diesen Zeitraum ist nichts eingetragen.'));
return;
}
for (const tag of [...nachTag.keys()].sort()) {
const block = el('section', 'tag');
if (tag < heute) block.dataset.vergangen = 'ja';
const kopf = el('div', 'tag__kopf');
kopf.append(el('span', 'tag__datum', datumHuebsch(tag)));
kopf.append(el('span', 'tag__wochentag', wochentag(tag)));
if (tag === heute) kopf.append(el('span', 'tag__marke', 'Heute'));
block.append(kopf);
const { termine, fristen } = nachTag.get(tag);
for (const t of termine) block.append(eintragTermin(t));
for (const f of fristen) block.append(eintragFrist(f));
ziel.append(block);
}
}
async function laden() {
melde('');
try {
const a = await hole('/workspace/api/termine?tage=' + tage);
if (!a.ok) { melde('Kalender konnte nicht geladen werden.'); return; }
zeichne(await a.json());
} catch { /* umgeleitet */ }
}
/* ---------- Zeitraum ---------------------------------------------------- */
for (const b of document.querySelectorAll('.zeitraum .schritt')) {
b.setAttribute('aria-pressed', String(Number(b.dataset.tage) === tage));
b.addEventListener('click', async () => {
tage = Number(b.dataset.tage);
for (const x of document.querySelectorAll('.zeitraum .schritt')) {
x.setAttribute('aria-pressed', String(x === b));
}
await laden();
});
}
/* ---------- Neuer Termin ------------------------------------------------ */
const umschalten = (auf) => {
$('neu').hidden = !auf; $('neu-oeffnen').hidden = auf;
if (auf) $('f-titel').focus();
};
$('neu-oeffnen').addEventListener('click', () => {
/* Vorbelegung: morgen 18:00 -- ein realistischer Zeitpunkt für einen
Call, spart in der Regel zwei Klicks. */
const m = new Date(Date.now() + 86400_000);
m.setHours(18, 0, 0, 0);
$('f-beginn').value = new Date(m.getTime() - m.getTimezoneOffset() * 60000)
.toISOString().slice(0, 16);
umschalten(true);
});
$('neu-abbrechen').addEventListener('click', () => {
$('neu').reset(); $('neu-fehler').textContent = ''; umschalten(false);
});
$('neu').addEventListener('submit', async (e) => {
e.preventDefault();
$('neu-fehler').textContent = '';
const knopf = $('neu-speichern');
const daten = {
titel: $('f-titel').value,
art: $('f-art').value,
beginn: $('f-beginn').value,
dauer_min: Number($('f-dauer').value) || 30,
ort: $('f-ort').value,
beschreibung: $('f-text').value,
};
if (ich.rolle === 'admin') {
const wahl = $('f-teilnehmer').value;
daten.teilnehmer_id = wahl || null;
daten.creator_id = wahl || null;
}
knopf.disabled = true;
try {
const a = await hole('/workspace/api/termine', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(daten),
});
if (!a.ok) {
$('neu-fehler').textContent = (await a.json().catch(() => ({}))).fehler || 'Ging nicht.';
return;
}
$('neu').reset(); umschalten(false);
await laden();
} catch { /* umgeleitet */ } finally { knopf.disabled = false; }
});
/* ---------- Start -------------------------------------------------------- */
$('abmelden').addEventListener('click', async () => {
try { await fetch('/workspace/api/abmelden', { method: 'POST', credentials: 'same-origin' }); }
catch { /* egal */ }
location.assign('/workspace/');
});
(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;
if (ich.rolle === 'admin') {
$('feld-teilnehmer').hidden = false;
try {
const { personen } = await (await hole('/workspace/api/personen')).json();
for (const p of personen) {
const o = document.createElement('option');
o.value = String(p.id);
o.textContent = p.name + ' (' + p.rolle + ')';
$('f-teilnehmer').append(o);
}
} catch { /* Zuordnung bleibt leer */ }
}
await laden();
})();
})();
+1 -1
View File
@@ -21,7 +21,7 @@
{ name: 'Creator-Profile', phase: 1, rollen: ['admin'], ziel: 'profil.html' },
{ name: 'Mein Profil', phase: 1, rollen: ['creator'], ziel: 'profil.html' },
{ name: 'Aufgaben', phase: 1, rollen: ['admin', 'creator', 'scout'], ziel: 'aufgaben.html' },
{ name: 'Kalender', phase: 1, rollen: ['admin', 'creator', 'scout'] },
{ name: 'Kalender', phase: 1, rollen: ['admin', 'creator', 'scout'], ziel: 'kalender.html' },
{ name: 'Dateien', phase: 1, rollen: ['admin', 'creator'] },
{ name: 'LIVE-Analyse', phase: 2, rollen: ['admin', 'creator'] },
{ name: 'Content-Planung', phase: 2, rollen: ['admin', 'creator'] },