Tauschanfragen für Teammitglieder ergänzen
Betrachter klicken ihren Tag an, wählen ihren Namen, optional Wunschperson und Wunschtag, und schreiben eine Notiz; keine Anmeldung nötig, zehn Anfragen je Stunde und Adresse. Offene Anfragen markieren den Tag im Kalender. Bearbeiter sehen sie unter „Anfragen" mit Zähler und führen den Tausch aus (über Kreuz oder Übernahme), öffnen den Tag oder lehnen ab. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
3aa37033fd
commit
6a3c68aaac
9 files changed
+495
-29
No files matched your search
+134
-5
@@ -51,7 +51,7 @@ async function api(path, { method = 'GET', body, raw } = {}) {
|
||||
}
|
||||
|
||||
// ---------- Anmeldung zum Bearbeiten ----------
|
||||
const EDITOR_VIEWS = ['personen', 'abwesenheiten', 'einstellungen'];
|
||||
const EDITOR_VIEWS = ['personen', 'abwesenheiten', 'anfragen', 'einstellungen'];
|
||||
function isEditor() { return !state.data || state.data.auth?.editor !== false; }
|
||||
function applyAuthUI() {
|
||||
const a = state.data?.auth || { protected: false, editor: true };
|
||||
@@ -62,6 +62,11 @@ function applyAuthUI() {
|
||||
for (const link of $$('.topbar nav a')) link.hidden = !a.editor && EDITOR_VIEWS.includes(link.dataset.view);
|
||||
$('#open-plan').hidden = !a.editor;
|
||||
$('#open-nextcloud').hidden = !a.editor;
|
||||
$('#open-request').hidden = a.editor;
|
||||
const open = (state.data?.requests || []).filter((r) => r.status === 'offen').length;
|
||||
const badge = $('#requests-count');
|
||||
badge.hidden = !a.editor || open === 0;
|
||||
badge.textContent = open;
|
||||
if (!a.editor && EDITOR_VIEWS.includes(location.hash.slice(1))) location.hash = '#kalender';
|
||||
}
|
||||
function openLogin() {
|
||||
@@ -139,7 +144,7 @@ function avatarEl(p, cls = '') {
|
||||
|
||||
// ---------- Navigation ----------
|
||||
function showView(name) {
|
||||
const known = ['kalender', 'personen', 'abwesenheiten', 'einstellungen'];
|
||||
const known = ['kalender', 'personen', 'abwesenheiten', 'anfragen', 'einstellungen'];
|
||||
if (!known.includes(name)) name = 'kalender';
|
||||
if (!isEditor() && EDITOR_VIEWS.includes(name)) name = 'kalender';
|
||||
if (name === 'abwesenheiten' && state.data) prepareAbsenceImport();
|
||||
@@ -170,10 +175,12 @@ function renderCalendar() {
|
||||
if (cur.getDay() === 0 || cur.getDay() === 6) continue;
|
||||
const date = iso(cur);
|
||||
const outside = cur.getMonth() !== d.getMonth();
|
||||
const editable = !outside && isEditor();
|
||||
const assigned = assignments[date] && !assignments[date].off && !holidayFor(date) && (assignments[date].main || assignments[date].co);
|
||||
const editable = !outside && (isEditor() || Boolean(assigned));
|
||||
const cell = document.createElement(editable ? 'button' : 'div');
|
||||
cell.className = 'day';
|
||||
if (editable) { cell.type = 'button'; cell.dataset.date = date; }
|
||||
if (!isEditor() && assigned) cell.classList.add('requestable');
|
||||
if (outside) cell.classList.add('outside');
|
||||
if (date === today) cell.classList.add('today');
|
||||
if (date < today) cell.classList.add('past');
|
||||
@@ -183,6 +190,8 @@ function renderCalendar() {
|
||||
numText.textContent = cur.getDate();
|
||||
num.appendChild(numText);
|
||||
cell.appendChild(num);
|
||||
const hasRequest = !outside && (state.data.requestDates || []).includes(date);
|
||||
if (hasRequest) { const m = document.createElement('span'); m.className = 'req-mark'; m.title = 'Tauschanfrage offen'; num.appendChild(m); }
|
||||
if (outside) { grid.appendChild(cell); continue; }
|
||||
|
||||
const hol = holidayFor(date);
|
||||
@@ -212,7 +221,7 @@ function renderCalendar() {
|
||||
pair.appendChild(slotEl(co, 'co', date));
|
||||
cell.appendChild(pair);
|
||||
const absentNote = (p) => (p && isAbsent(p.id, date) ? ' (abwesend)' : '');
|
||||
cell.setAttribute('aria-label', `${fmtLong(date)}: Haupt ${fullName(main)}${absentNote(main)}, Neben ${fullName(co)}${absentNote(co)}${a.locked ? ', fixiert' : ''}`);
|
||||
cell.setAttribute('aria-label', `${fmtLong(date)}: Haupt ${fullName(main)}${absentNote(main)}, Neben ${fullName(co)}${absentNote(co)}${a.locked ? ', fixiert' : ''}${hasRequest ? ', Tauschanfrage offen' : ''}`);
|
||||
} else {
|
||||
cell.setAttribute('aria-label', `${fmtLong(date)}, noch nicht ausgelost`);
|
||||
}
|
||||
@@ -288,9 +297,128 @@ $('#next-month').addEventListener('click', () => { state.month = new Date(state.
|
||||
$('#today-month').addEventListener('click', () => { state.month = startOfMonth(new Date()); renderCalendar(); });
|
||||
$('#cal-grid').addEventListener('click', (e) => {
|
||||
const cell = e.target.closest('.day[data-date]');
|
||||
if (cell) openDayDialog(cell.dataset.date);
|
||||
if (!cell) return;
|
||||
if (isEditor()) openDayDialog(cell.dataset.date); else openRequestDialog(cell.dataset.date);
|
||||
});
|
||||
|
||||
// ---------- Tauschanfrage (für alle) ----------
|
||||
const requestDialog = $('#request-dialog');
|
||||
const requestForm = $('#request-form');
|
||||
function fillRequestPersons(date) {
|
||||
const f = requestForm.elements;
|
||||
const a = state.data.assignments[date] || {};
|
||||
const active = state.data.persons.filter((p) => p.active !== false && (p.canMain || p.canCo)).sort((x, y) => fullName(x).localeCompare(fullName(y)));
|
||||
const assigned = [a.main, a.co].map(personById).filter(Boolean);
|
||||
f.personId.textContent = '';
|
||||
f.personId.appendChild(new Option('– bitte wählen –', ''));
|
||||
if (assigned.length) {
|
||||
const g = document.createElement('optgroup');
|
||||
g.label = 'An diesem Tag eingeteilt';
|
||||
for (const p of assigned) g.appendChild(new Option(fullName(p), p.id));
|
||||
f.personId.appendChild(g);
|
||||
}
|
||||
const g2 = document.createElement('optgroup');
|
||||
g2.label = 'Alle';
|
||||
for (const p of active) g2.appendChild(new Option(fullName(p), p.id));
|
||||
f.personId.appendChild(g2);
|
||||
const current = f.partnerId.value;
|
||||
f.partnerId.textContent = '';
|
||||
f.partnerId.appendChild(new Option('– noch offen –', ''));
|
||||
for (const p of active) f.partnerId.appendChild(new Option(fullName(p), p.id));
|
||||
f.partnerId.value = current || '';
|
||||
$('#request-info').textContent = assigned.length
|
||||
? `${fmtLong(date)}: ${assigned.map((p, i) => `${i === 0 && a.main === p.id ? 'Haupt' : 'Neben'} ${fullName(p)}`).join(', ')}`
|
||||
: `${fmtLong(date)}: kein Meeting geplant`;
|
||||
}
|
||||
function openRequestDialog(date) {
|
||||
const f = requestForm.elements;
|
||||
requestForm.reset();
|
||||
f.date.value = date || '';
|
||||
fillRequestPersons(f.date.value);
|
||||
$('#request-result').hidden = true;
|
||||
$('#request-submit').disabled = false;
|
||||
requestDialog.showModal();
|
||||
}
|
||||
$('#open-request').addEventListener('click', () => openRequestDialog(''));
|
||||
$('#request-close').addEventListener('click', () => requestDialog.close());
|
||||
requestForm.elements.date.addEventListener('change', () => fillRequestPersons(requestForm.elements.date.value));
|
||||
requestForm.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const f = requestForm.elements;
|
||||
const out = $('#request-result');
|
||||
$('#request-submit').disabled = true;
|
||||
try {
|
||||
await api('api/requests', { method: 'POST', body: { personId: f.personId.value, date: f.date.value, partnerId: f.partnerId.value || null, partnerDate: f.partnerDate.value || null, note: f.note.value } });
|
||||
out.className = 'result';
|
||||
out.textContent = 'Danke, die Anfrage ist bei der Planung angekommen. Der Tag ist im Kalender markiert, bis sie bearbeitet ist.';
|
||||
out.hidden = false;
|
||||
$('#request-close').textContent = 'Schließen';
|
||||
await load();
|
||||
} catch (err) {
|
||||
out.className = 'result error';
|
||||
out.textContent = err.message;
|
||||
out.hidden = false;
|
||||
$('#request-submit').disabled = false;
|
||||
}
|
||||
});
|
||||
requestDialog.addEventListener('close', () => { $('#request-close').textContent = 'Abbrechen'; });
|
||||
|
||||
// ---------- Anfragen bearbeiten (nur Bearbeiter) ----------
|
||||
function requestRow(r) {
|
||||
const li = document.createElement('li');
|
||||
const p = personById(r.personId);
|
||||
li.appendChild(avatarEl(p, 'plain small'));
|
||||
const grow = document.createElement('div');
|
||||
grow.className = 'grow';
|
||||
const strong = document.createElement('strong');
|
||||
strong.textContent = `${fullName(p)} kann am ${fmtDate(r.date)} nicht (${r.role === 'main' ? 'Haupt' : 'Neben'}moderation)`;
|
||||
grow.appendChild(strong);
|
||||
const meta = document.createElement('div');
|
||||
meta.className = 'request-meta';
|
||||
const partner = r.partnerId ? personById(r.partnerId) : null;
|
||||
const parts = [];
|
||||
if (partner) parts.push(`Wunsch: Tausch mit ${fullName(partner)}${r.partnerDate ? ` am ${fmtDate(r.partnerDate)}` : ' (übernimmt den Tag)'}`);
|
||||
else parts.push('Kein Wunschpartner genannt');
|
||||
parts.push(`gestellt am ${new Date(r.createdAt).toLocaleDateString('de-DE')}`);
|
||||
if (r.status !== 'offen') parts.push(r.status === 'erledigt' ? 'erledigt' : 'abgelehnt');
|
||||
meta.textContent = parts.join(' · ');
|
||||
grow.appendChild(meta);
|
||||
if (r.note) { const n = document.createElement('div'); n.className = 'request-note'; n.textContent = `„${r.note}"`; grow.appendChild(n); }
|
||||
li.appendChild(grow);
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'request-actions';
|
||||
const btn = (label, cls, fn) => { const b = document.createElement('button'); b.type = 'button'; b.className = cls; b.textContent = label; b.addEventListener('click', fn); actions.appendChild(b); };
|
||||
if (r.status === 'offen') {
|
||||
if (r.partnerId) btn('Ausführen', 'primary', () => run(async () => {
|
||||
const res = await api(`api/requests/${r.id}/execute`, { method: 'POST' });
|
||||
return res;
|
||||
}, 'Tausch ausgeführt. Nextcloud danach neu schreiben, damit die Termine stimmen.'));
|
||||
btn('Tag öffnen', '', () => { location.hash = '#kalender'; state.month = new Date(r.date + 'T12:00:00'); renderCalendar(); openDayDialog(r.date); });
|
||||
btn('Erledigt', 'ghost', () => run(() => api(`api/requests/${r.id}`, { method: 'PUT', body: { status: 'erledigt' } }), 'Als erledigt markiert.'));
|
||||
btn('Ablehnen', 'ghost', () => run(() => api(`api/requests/${r.id}`, { method: 'PUT', body: { status: 'abgelehnt' } }), 'Anfrage abgelehnt.'));
|
||||
} else {
|
||||
btn('Wieder öffnen', 'ghost', () => run(() => api(`api/requests/${r.id}`, { method: 'PUT', body: { status: 'offen' } })));
|
||||
btn('Löschen', 'ghost', () => run(() => api(`api/requests/${r.id}`, { method: 'DELETE' })));
|
||||
}
|
||||
li.appendChild(actions);
|
||||
return li;
|
||||
}
|
||||
function renderRequests() {
|
||||
const all = state.data.requests || [];
|
||||
const open = all.filter((r) => r.status === 'offen').sort((a, b) => a.date.localeCompare(b.date));
|
||||
const done = all.filter((r) => r.status !== 'offen').sort((a, b) => (b.resolvedAt || '').localeCompare(a.resolvedAt || ''));
|
||||
const list = $('#request-list');
|
||||
list.textContent = '';
|
||||
if (open.length === 0) list.innerHTML = '<li class="hint">Keine offenen Anfragen.</li>';
|
||||
for (const r of open) list.appendChild(requestRow(r));
|
||||
const doneList = $('#request-list-done');
|
||||
doneList.textContent = '';
|
||||
for (const r of done) doneList.appendChild(requestRow(r));
|
||||
doneList.hidden = !$('#requests-show-done').checked || done.length === 0;
|
||||
$('#requests-toggle').hidden = done.length === 0;
|
||||
}
|
||||
$('#requests-show-done').addEventListener('change', renderRequests);
|
||||
|
||||
// ---------- Tag-Dialog ----------
|
||||
const dayDialog = $('#day-dialog');
|
||||
const dayForm = $('#day-form');
|
||||
@@ -1014,6 +1142,7 @@ function renderAll() {
|
||||
renderPersons();
|
||||
renderPhotoField();
|
||||
renderAbsences();
|
||||
renderRequests();
|
||||
renderSettings();
|
||||
if (!$('#view-abwesenheiten').hidden) prepareAbsenceImport();
|
||||
} else {
|
||||
|
||||
Reference in new issue
Block a user