Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
42d495c54e |
No files matched your search
@@ -82,12 +82,16 @@ für eine einzelne Person.
|
|||||||
|
|
||||||
## Tauschanfragen
|
## Tauschanfragen
|
||||||
|
|
||||||
Wer an seinem Tag nicht kann, klickt den Tag im Kalender an, wählt seinen Namen, optional die
|
Wer an seinem Tag nicht kann, klickt den Tag im Kalender an, wählt seinen Namen und schreibt
|
||||||
Person und den Tag für den Tausch, und schreibt eine Notiz. Dafür ist keine Anmeldung nötig;
|
eine Notiz. Optional dazu: eine Person, mit der getauscht werden soll, und ein Wunschtag.
|
||||||
pro Adresse sind zehn Anfragen je Stunde möglich. Der Tag bekommt im Kalender eine Markierung.
|
Ohne Person bedeutet der Wunschtag, dass am Wunschtag ein noch offener Platz übernommen wird.
|
||||||
Bearbeiter sehen alle Anfragen unter „Anfragen" mit Zähler in der Navigation und entscheiden:
|
Dafür ist keine Anmeldung nötig; pro Adresse sind zehn Anfragen je Stunde möglich. Der Tag
|
||||||
„Ausführen" tauscht wie gewünscht (über Kreuz oder Übernahme des Tages), „Tag öffnen" lässt
|
bekommt im Kalender eine Markierung. Bearbeiter sehen alle Anfragen unter „Anfragen" mit Zähler
|
||||||
manuell wählen, „Erledigt" und „Ablehnen" schließen die Anfrage ohne Änderung. Nach einem
|
in der Navigation und entscheiden: „Ausführen" tauscht wie gewünscht (über Kreuz, Übernahme
|
||||||
|
des Tages oder Wechsel auf den offenen Platz; der frei werdende Platz wird sofort fair
|
||||||
|
nachbesetzt), „Wunsch anpassen" trägt Person oder Wunschtag nach, etwa aus der Notiz, „Tag
|
||||||
|
öffnen" lässt manuell wählen, „Erledigt" und „Ablehnen" schließen die Anfrage ohne Änderung.
|
||||||
|
Rollen werden beachtet: Wer nur Hauptmoderation macht, bekommt keinen Nebenplatz. Nach einem
|
||||||
Tausch den Zeitraum erneut nach Nextcloud schreiben.
|
Tausch den Zeitraum erneut nach Nextcloud schreiben.
|
||||||
|
|
||||||
## Bugs melden
|
## Bugs melden
|
||||||
|
|||||||
+54
-4
@@ -334,11 +334,26 @@ function fillRequestPersons(date) {
|
|||||||
? `${fmtLong(date)}: ${assigned.map((p, i) => `${i === 0 && a.main === p.id ? 'Haupt' : 'Neben'} ${fullName(p)}`).join(', ')}`
|
? `${fmtLong(date)}: ${assigned.map((p, i) => `${i === 0 && a.main === p.id ? 'Haupt' : 'Neben'} ${fullName(p)}`).join(', ')}`
|
||||||
: `${fmtLong(date)}: kein Meeting geplant`;
|
: `${fmtLong(date)}: kein Meeting geplant`;
|
||||||
}
|
}
|
||||||
|
/** Was ist am Wunschtag los? Offene Plätze sichtbar machen, damit der Wunsch passt. */
|
||||||
|
function describeWishDay() {
|
||||||
|
const f = requestForm.elements;
|
||||||
|
const info = $('#request-wish-info');
|
||||||
|
const base = 'Mit Person: ihr tauscht die Tage. Ohne Person: du übernimmst am Wunschtag einen noch offenen Platz, deiner wird neu besetzt.';
|
||||||
|
const date = f.partnerDate.value;
|
||||||
|
if (!date) { info.textContent = base; return; }
|
||||||
|
const a = state.data.assignments[date];
|
||||||
|
if (!a || a.off || holidayFor(date)) { info.textContent = `${fmtDate(date)}: kein Meeting geplant.`; return; }
|
||||||
|
const slot = (role, id) => (id ? `${role} ${fullName(personById(id))}` : `${role} offen`);
|
||||||
|
info.textContent = `${fmtDate(date)}: ${slot('Haupt', a.main)}, ${slot('Neben', a.co)}.${!a.main || !a.co ? ' Ein offener Platz kann ohne Tauschpartner übernommen werden.' : ' Beide Plätze sind besetzt, nenne die Person zum Tauschen.'}`;
|
||||||
|
}
|
||||||
|
requestForm.elements.partnerDate.addEventListener('change', describeWishDay);
|
||||||
|
|
||||||
function openRequestDialog(date) {
|
function openRequestDialog(date) {
|
||||||
const f = requestForm.elements;
|
const f = requestForm.elements;
|
||||||
requestForm.reset();
|
requestForm.reset();
|
||||||
f.date.value = date || '';
|
f.date.value = date || '';
|
||||||
fillRequestPersons(f.date.value);
|
fillRequestPersons(f.date.value);
|
||||||
|
describeWishDay();
|
||||||
$('#request-result').hidden = true;
|
$('#request-result').hidden = true;
|
||||||
$('#request-submit').disabled = false;
|
$('#request-submit').disabled = false;
|
||||||
requestDialog.showModal();
|
requestDialog.showModal();
|
||||||
@@ -382,7 +397,8 @@ function requestRow(r) {
|
|||||||
const partner = r.partnerId ? personById(r.partnerId) : null;
|
const partner = r.partnerId ? personById(r.partnerId) : null;
|
||||||
const parts = [];
|
const parts = [];
|
||||||
if (partner) parts.push(`Wunsch: Tausch mit ${fullName(partner)}${r.partnerDate ? ` am ${fmtDate(r.partnerDate)}` : ' (übernimmt den Tag)'}`);
|
if (partner) parts.push(`Wunsch: Tausch mit ${fullName(partner)}${r.partnerDate ? ` am ${fmtDate(r.partnerDate)}` : ' (übernimmt den Tag)'}`);
|
||||||
else parts.push('Kein Wunschpartner genannt');
|
else if (r.partnerDate) parts.push(`Wunsch: offenen Platz am ${fmtDate(r.partnerDate)} übernehmen`);
|
||||||
|
else parts.push('Kein Wunsch genannt – aus der Notiz nachtragen oder den Tag öffnen');
|
||||||
parts.push(`gestellt am ${new Date(r.createdAt).toLocaleDateString('de-DE')}`);
|
parts.push(`gestellt am ${new Date(r.createdAt).toLocaleDateString('de-DE')}`);
|
||||||
if (r.status !== 'offen') parts.push(r.status === 'erledigt' ? 'erledigt' : 'abgelehnt');
|
if (r.status !== 'offen') parts.push(r.status === 'erledigt' ? 'erledigt' : 'abgelehnt');
|
||||||
meta.textContent = parts.join(' · ');
|
meta.textContent = parts.join(' · ');
|
||||||
@@ -393,10 +409,20 @@ function requestRow(r) {
|
|||||||
actions.className = 'request-actions';
|
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); };
|
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.status === 'offen') {
|
||||||
if (r.partnerId) btn('Ausführen', 'primary', () => run(async () => {
|
if (r.partnerId || r.partnerDate) btn('Ausführen', 'primary', async () => {
|
||||||
|
try {
|
||||||
const res = await api(`api/requests/${r.id}/execute`, { method: 'POST' });
|
const res = await api(`api/requests/${r.id}/execute`, { method: 'POST' });
|
||||||
return res;
|
await load();
|
||||||
}, 'Tausch ausgeführt. Nextcloud danach neu schreiben, damit die Termine stimmen.'));
|
let msg = 'Tausch ausgeführt.';
|
||||||
|
if (res.filled) msg += res.filled.personId ? ` Lücke am ${fmtDate(res.filled.date)} mit ${fullName(personById(res.filled.personId))} besetzt.` : ` Lücke am ${fmtDate(res.filled.date)} konnte nicht besetzt werden, bitte im Kalender nachsehen.`;
|
||||||
|
toast(`${msg} Nextcloud danach neu schreiben.`);
|
||||||
|
} catch (err) { toast(err.message, true); }
|
||||||
|
});
|
||||||
|
btn('Wunsch anpassen', '', () => {
|
||||||
|
const form = li.querySelector('.wish-form');
|
||||||
|
form.hidden = !form.hidden;
|
||||||
|
if (!form.hidden) form.querySelector('input[type=date]').focus();
|
||||||
|
});
|
||||||
btn('Tag öffnen', '', () => { location.hash = '#kalender'; state.month = new Date(r.date + 'T12:00:00'); renderCalendar(); openDayDialog(r.date); });
|
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('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.'));
|
btn('Ablehnen', 'ghost', () => run(() => api(`api/requests/${r.id}`, { method: 'PUT', body: { status: 'abgelehnt' } }), 'Anfrage abgelehnt.'));
|
||||||
@@ -405,6 +431,30 @@ function requestRow(r) {
|
|||||||
btn('Löschen', 'ghost', () => run(() => api(`api/requests/${r.id}`, { method: 'DELETE' })));
|
btn('Löschen', 'ghost', () => run(() => api(`api/requests/${r.id}`, { method: 'DELETE' })));
|
||||||
}
|
}
|
||||||
li.appendChild(actions);
|
li.appendChild(actions);
|
||||||
|
if (r.status === 'offen') {
|
||||||
|
// Wunsch nachtragen: Person und/oder Wunschtag, z. B. aus der Notiz
|
||||||
|
const form = document.createElement('form');
|
||||||
|
form.className = 'wish-form';
|
||||||
|
form.hidden = true;
|
||||||
|
const sel = document.createElement('select');
|
||||||
|
sel.appendChild(new Option('– niemand Bestimmtes –', ''));
|
||||||
|
for (const p of state.data.persons.filter((x) => x.active !== false && x.id !== r.personId && (x.canMain || x.canCo)).sort((x, y) => fullName(x).localeCompare(fullName(y)))) sel.appendChild(new Option(fullName(p), p.id));
|
||||||
|
sel.value = r.partnerId || '';
|
||||||
|
const dateInput = document.createElement('input');
|
||||||
|
dateInput.type = 'date';
|
||||||
|
dateInput.value = r.partnerDate || '';
|
||||||
|
const save = document.createElement('button');
|
||||||
|
save.type = 'submit';
|
||||||
|
save.textContent = 'Wunsch speichern';
|
||||||
|
const l1 = document.createElement('label'); l1.textContent = 'Tauschen mit'; l1.appendChild(sel);
|
||||||
|
const l2 = document.createElement('label'); l2.textContent = 'Wunschtag'; l2.appendChild(dateInput);
|
||||||
|
form.append(l1, l2, save);
|
||||||
|
form.addEventListener('submit', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
run(() => api(`api/requests/${r.id}`, { method: 'PUT', body: { partnerId: sel.value || null, partnerDate: dateInput.value || null } }), 'Wunsch gespeichert.');
|
||||||
|
});
|
||||||
|
li.appendChild(form);
|
||||||
|
}
|
||||||
return li;
|
return li;
|
||||||
}
|
}
|
||||||
function renderRequests() {
|
function renderRequests() {
|
||||||
|
|||||||
+3
-2
@@ -288,8 +288,9 @@
|
|||||||
<p id="request-info" class="hint"></p>
|
<p id="request-info" class="hint"></p>
|
||||||
<label>Ich bin <select name="personId" required></select></label>
|
<label>Ich bin <select name="personId" required></select></label>
|
||||||
<label>Tag, an dem ich nicht kann <input type="date" name="date" required></label>
|
<label>Tag, an dem ich nicht kann <input type="date" name="date" required></label>
|
||||||
<label>Ich würde gern tauschen mit (optional) <select name="partnerId"><option value="">– noch offen –</option></select></label>
|
<label>Ich würde gern tauschen mit (optional) <select name="partnerId"><option value="">– niemand Bestimmtes –</option></select></label>
|
||||||
<label>Wunschtag für den Tausch (optional) <input type="date" name="partnerDate"></label>
|
<label>Wunschtag (optional) <input type="date" name="partnerDate"></label>
|
||||||
|
<p class="hint" id="request-wish-info">Mit Person: ihr tauscht die Tage. Ohne Person: du übernimmst am Wunschtag einen noch offenen Platz, deiner wird neu besetzt.</p>
|
||||||
<label>Notiz (optional) <input name="note" maxlength="300" placeholder="z. B. Arzttermin, bin erst ab 10 Uhr da"></label>
|
<label>Notiz (optional) <input name="note" maxlength="300" placeholder="z. B. Arzttermin, bin erst ab 10 Uhr da"></label>
|
||||||
<p id="request-result" class="result" hidden></p>
|
<p id="request-result" class="result" hidden></p>
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
|
|||||||
@@ -74,6 +74,9 @@ svg.icon { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-w
|
|||||||
.request-meta { color: var(--text-2); font-size: 13px; }
|
.request-meta { color: var(--text-2); font-size: 13px; }
|
||||||
.request-note { margin-top: 4px; font-size: 14px; }
|
.request-note { margin-top: 4px; font-size: 14px; }
|
||||||
.request-actions { display: flex; gap: 8px; flex-wrap: wrap; }
|
.request-actions { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||||
|
.wish-form { flex-basis: 100%; display: flex; gap: 12px 16px; flex-wrap: wrap; align-items: end; padding: 12px 16px; margin-top: 4px; background: var(--tint-1); border-radius: var(--radius); }
|
||||||
|
.wish-form label { flex: 1 1 180px; }
|
||||||
|
.wish-form button { min-height: 40px; }
|
||||||
.viewer .day.requestable { cursor: pointer; }
|
.viewer .day.requestable { cursor: pointer; }
|
||||||
.viewer .day.requestable:hover:not(.outside) { background: var(--tint-1); }
|
.viewer .day.requestable:hover:not(.outside) { background: var(--tint-1); }
|
||||||
.topbar-action { margin-left: auto; background: transparent; color: var(--white); border-color: rgba(255, 255, 255, .4); min-height: 34px; padding: 0 12px; font-size: 13px; }
|
.topbar-action { margin-left: auto; background: transparent; color: var(--white); border-color: rgba(255, 255, 255, .4); min-height: 34px; padding: 0 12px; font-size: 13px; }
|
||||||
|
|||||||
+35
-3
@@ -520,10 +520,27 @@ app.put('/api/requests/:id', (req, res) => {
|
|||||||
const st = S();
|
const st = S();
|
||||||
const r = st.requests.find((x) => x.id === req.params.id);
|
const r = st.requests.find((x) => x.id === req.params.id);
|
||||||
if (!r) return res.status(404).json({ error: 'Anfrage nicht gefunden.' });
|
if (!r) return res.status(404).json({ error: 'Anfrage nicht gefunden.' });
|
||||||
const status = String(req.body?.status || '');
|
const b = req.body || {};
|
||||||
|
if (b.status !== undefined) {
|
||||||
|
const status = String(b.status || '');
|
||||||
if (!REQUEST_STATUS.includes(status)) return bad(res, 'Unbekannter Status.');
|
if (!REQUEST_STATUS.includes(status)) return bad(res, 'Unbekannter Status.');
|
||||||
r.status = status;
|
r.status = status;
|
||||||
r.resolvedAt = status === 'offen' ? null : new Date().toISOString();
|
r.resolvedAt = status === 'offen' ? null : new Date().toISOString();
|
||||||
|
}
|
||||||
|
// Bearbeiter dürfen den Wunsch nachtragen, z. B. aus dem Notizfeld
|
||||||
|
if (b.partnerId !== undefined || b.partnerDate !== undefined) {
|
||||||
|
const v = validateRequest({
|
||||||
|
personId: r.personId,
|
||||||
|
date: r.date,
|
||||||
|
partnerId: b.partnerId !== undefined ? b.partnerId : r.partnerId,
|
||||||
|
partnerDate: b.partnerDate !== undefined ? b.partnerDate : r.partnerDate,
|
||||||
|
note: r.note,
|
||||||
|
}, st.persons, st.assignments);
|
||||||
|
if (v.error) return bad(res, v.error);
|
||||||
|
r.partnerId = v.request.partnerId;
|
||||||
|
r.partnerDate = v.request.partnerDate;
|
||||||
|
r.role = v.request.role;
|
||||||
|
}
|
||||||
store.save();
|
store.save();
|
||||||
res.json(r);
|
res.json(r);
|
||||||
});
|
});
|
||||||
@@ -534,11 +551,26 @@ app.post('/api/requests/:id/execute', (req, res) => {
|
|||||||
if (!r) return res.status(404).json({ error: 'Anfrage nicht gefunden.' });
|
if (!r) return res.status(404).json({ error: 'Anfrage nicht gefunden.' });
|
||||||
if (r.status !== 'offen') return bad(res, 'Diese Anfrage ist schon abgeschlossen.');
|
if (r.status !== 'offen') return bad(res, 'Diese Anfrage ist schon abgeschlossen.');
|
||||||
try {
|
try {
|
||||||
const { changes } = applySwap(st.assignments, r);
|
const { changes, vacated } = applySwap(st.assignments, r, personsForScheduling());
|
||||||
|
let filled = null;
|
||||||
|
if (vacated) {
|
||||||
|
// Die frei gewordene Stelle gleich nachbesetzen – fair, ohne die anfragende Person
|
||||||
|
const existing = { [vacated.date]: { ...st.assignments[vacated.date], fill: true } };
|
||||||
|
const absences = [...st.absences, { personId: r.personId, from: vacated.date, to: vacated.date }];
|
||||||
|
const history = { ...st.assignments };
|
||||||
|
delete history[vacated.date];
|
||||||
|
const generated = generateSchedule({ dates: [vacated.date], persons: personsForScheduling(), absences, existing, history, blocked: blockedDays(), historyWindowDays: st.settings.historyWindowDays });
|
||||||
|
const picked = generated[vacated.date]?.[vacated.role] || null;
|
||||||
|
if (picked) {
|
||||||
|
st.assignments[vacated.date][vacated.role] = picked;
|
||||||
|
changes.push({ date: vacated.date, role: vacated.role, from: null, to: picked });
|
||||||
|
}
|
||||||
|
filled = { date: vacated.date, role: vacated.role, personId: picked };
|
||||||
|
}
|
||||||
r.status = 'erledigt';
|
r.status = 'erledigt';
|
||||||
r.resolvedAt = new Date().toISOString();
|
r.resolvedAt = new Date().toISOString();
|
||||||
store.save();
|
store.save();
|
||||||
res.json({ request: r, changes, assignments: st.assignments });
|
res.json({ request: r, changes, filled, assignments: st.assignments });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
bad(res, err.message);
|
bad(res, err.message);
|
||||||
}
|
}
|
||||||
|
|||||||
+49
-7
@@ -11,6 +11,21 @@ export function roleOn(assignment, personId) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Darf die Person diese Rolle übernehmen? */
|
||||||
|
export function canTake(person, role) {
|
||||||
|
if (!person || person.active === false) return false;
|
||||||
|
return role === 'main' ? person.canMain !== false : person.canCo !== false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Offener Platz an einem Tag, den die Person übernehmen könnte: 'main', 'co' oder null. */
|
||||||
|
export function openSlotFor(assignment, person) {
|
||||||
|
if (!assignment || assignment.off || !person) return null;
|
||||||
|
if (assignment.main === person.id || assignment.co === person.id) return null;
|
||||||
|
if (!assignment.main && canTake(person, 'main')) return 'main';
|
||||||
|
if (!assignment.co && canTake(person, 'co')) return 'co';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Eingabe einer Anfrage prüfen. persons: [{id, active}], assignments: { date: {main, co, off} }.
|
* Eingabe einer Anfrage prüfen. persons: [{id, active}], assignments: { date: {main, co, off} }.
|
||||||
* Liefert { error } oder { request } (ohne id/status/createdAt).
|
* Liefert { error } oder { request } (ohne id/status/createdAt).
|
||||||
@@ -29,37 +44,64 @@ export function validateRequest(body, persons, assignments) {
|
|||||||
if (partnerId === personId) return { error: 'Mit dir selbst kannst du nicht tauschen.' };
|
if (partnerId === personId) return { error: 'Mit dir selbst kannst du nicht tauschen.' };
|
||||||
const partnerDate = b.partnerDate ? String(b.partnerDate) : null;
|
const partnerDate = b.partnerDate ? String(b.partnerDate) : null;
|
||||||
if (partnerDate && !isValidDate(partnerDate)) return { error: 'Ungültiger Wunschtag.' };
|
if (partnerDate && !isValidDate(partnerDate)) return { error: 'Ungültiger Wunschtag.' };
|
||||||
if (partnerDate && !partnerId) return { error: 'Zum Wunschtag bitte auch die Person angeben, mit der du tauschen möchtest.' };
|
|
||||||
if (partnerDate === date) return { error: 'Der Wunschtag ist derselbe Tag.' };
|
if (partnerDate === date) return { error: 'Der Wunschtag ist derselbe Tag.' };
|
||||||
|
if (partnerDate && !partnerId) {
|
||||||
|
// Ohne Tauschpartner heißt der Wunsch: einen noch offenen Platz am Wunschtag übernehmen
|
||||||
|
const me = active(personId);
|
||||||
|
const a = assignments[partnerDate];
|
||||||
|
if (!a || a.off) return { error: 'Am Wunschtag ist kein Meeting geplant.' };
|
||||||
|
if (a.main === personId || a.co === personId) return { error: 'Am Wunschtag bist du schon eingeteilt.' };
|
||||||
|
if (!openSlotFor(a, me)) return { error: 'Am Wunschtag ist kein Platz frei, den du übernehmen könntest. Nenne eine Person zum Tauschen.' };
|
||||||
|
}
|
||||||
const note = String(b.note || '').trim().slice(0, 300);
|
const note = String(b.note || '').trim().slice(0, 300);
|
||||||
return { request: { personId, date, role, partnerId, partnerDate, note } };
|
return { request: { personId, date, role, partnerId, partnerDate, note } };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tausch ausführen. Liefert { changes: [{date, role, from, to}] } und verändert assignments direkt.
|
* Tausch ausführen. Liefert { changes: [{date, role, from, to}], vacated } und verändert assignments direkt.
|
||||||
* Fälle:
|
* Fälle:
|
||||||
* - Partner + Wunschtag: über Kreuz – Partner übernimmt den Tag des Anfragenden, dieser den Slot des Partners am Wunschtag.
|
* - Partner + Wunschtag: über Kreuz – Partner übernimmt den Tag des Anfragenden, dieser den Slot des Partners am Wunschtag.
|
||||||
* - Nur Partner: Partner übernimmt den Slot des Anfragenden.
|
* - Nur Partner: Partner übernimmt den Slot des Anfragenden.
|
||||||
* - Ohne Partner: nicht automatisch ausführbar.
|
* - Nur Wunschtag: die Person übernimmt dort einen offenen Platz; ihr alter Platz wird frei (vacated) und
|
||||||
|
* sollte vom Aufrufer nachbesetzt werden.
|
||||||
|
* - Weder noch: nicht automatisch ausführbar.
|
||||||
|
* persons: [{ id, canMain, canCo, active }] für die Rollenprüfung.
|
||||||
*/
|
*/
|
||||||
export function applySwap(assignments, req) {
|
export function applySwap(assignments, req, persons = []) {
|
||||||
|
const find = (id) => persons.find((p) => p.id === id) || null;
|
||||||
const a = assignments[req.date];
|
const a = assignments[req.date];
|
||||||
const role = roleOn(a, req.personId);
|
const role = roleOn(a, req.personId);
|
||||||
if (!role) throw new Error('Die Person ist an diesem Tag nicht mehr eingeteilt. Bitte den Tag von Hand anpassen.');
|
if (!role) throw new Error('Die Person ist an diesem Tag nicht mehr eingeteilt. Bitte den Tag von Hand anpassen.');
|
||||||
if (!req.partnerId) throw new Error('Ohne Wunschpartner bitte den Tag öffnen und selbst jemanden auswählen.');
|
const me = find(req.personId);
|
||||||
|
const changes = [];
|
||||||
|
|
||||||
|
if (!req.partnerId) {
|
||||||
|
if (!req.partnerDate) throw new Error('Ohne Wunschpartner oder Wunschtag bitte den Tag öffnen und selbst jemanden auswählen.');
|
||||||
|
const b = assignments[req.partnerDate];
|
||||||
|
const slot = openSlotFor(b, me || { id: req.personId, canMain: true, canCo: true });
|
||||||
|
if (!slot) throw new Error('Am Wunschtag ist kein passender Platz mehr frei. Bitte den Tag von Hand anpassen.');
|
||||||
|
b[slot] = req.personId;
|
||||||
|
changes.push({ date: req.partnerDate, role: slot, from: null, to: req.personId });
|
||||||
|
a[role] = null;
|
||||||
|
changes.push({ date: req.date, role, from: req.personId, to: null });
|
||||||
|
return { changes, vacated: { date: req.date, role } };
|
||||||
|
}
|
||||||
|
|
||||||
|
const partner = find(req.partnerId);
|
||||||
const otherRole = role === 'main' ? 'co' : 'main';
|
const otherRole = role === 'main' ? 'co' : 'main';
|
||||||
if (a[otherRole] === req.partnerId) throw new Error('Der Wunschpartner ist an diesem Tag schon die andere Moderation. Bitte den Tag von Hand anpassen.');
|
if (a[otherRole] === req.partnerId) throw new Error('Der Wunschpartner ist an diesem Tag schon die andere Moderation. Bitte den Tag von Hand anpassen.');
|
||||||
const changes = [];
|
if (partner && !canTake(partner, role)) throw new Error(`Der Wunschpartner darf keine ${role === 'main' ? 'Haupt' : 'Neben'}moderation übernehmen. Bitte den Tag von Hand anpassen.`);
|
||||||
if (req.partnerDate) {
|
if (req.partnerDate) {
|
||||||
const b = assignments[req.partnerDate];
|
const b = assignments[req.partnerDate];
|
||||||
const partnerRole = roleOn(b, req.partnerId);
|
const partnerRole = roleOn(b, req.partnerId);
|
||||||
if (!partnerRole) throw new Error('Der Wunschpartner ist am Wunschtag nicht eingeteilt. Bitte den Tag von Hand anpassen.');
|
if (!partnerRole) throw new Error('Der Wunschpartner ist am Wunschtag nicht eingeteilt. Bitte den Tag von Hand anpassen.');
|
||||||
const otherB = partnerRole === 'main' ? 'co' : 'main';
|
const otherB = partnerRole === 'main' ? 'co' : 'main';
|
||||||
if (b[otherB] === req.personId) throw new Error('Die anfragende Person ist am Wunschtag schon die andere Moderation. Bitte den Tag von Hand anpassen.');
|
if (b[otherB] === req.personId) throw new Error('Die anfragende Person ist am Wunschtag schon die andere Moderation. Bitte den Tag von Hand anpassen.');
|
||||||
|
if (me && !canTake(me, partnerRole)) throw new Error(`Die anfragende Person darf keine ${partnerRole === 'main' ? 'Haupt' : 'Neben'}moderation übernehmen. Bitte den Tag von Hand anpassen.`);
|
||||||
b[partnerRole] = req.personId;
|
b[partnerRole] = req.personId;
|
||||||
changes.push({ date: req.partnerDate, role: partnerRole, from: req.partnerId, to: req.personId });
|
changes.push({ date: req.partnerDate, role: partnerRole, from: req.partnerId, to: req.personId });
|
||||||
}
|
}
|
||||||
a[role] = req.partnerId;
|
a[role] = req.partnerId;
|
||||||
changes.push({ date: req.date, role, from: req.personId, to: req.partnerId });
|
changes.push({ date: req.date, role, from: req.personId, to: req.partnerId });
|
||||||
return { changes };
|
return { changes, vacated: null };
|
||||||
}
|
}
|
||||||
+38
-4
@@ -1,13 +1,15 @@
|
|||||||
import { test } from 'node:test';
|
import { test } from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { validateRequest, applySwap, roleOn } from '../server/requests.js';
|
import { validateRequest, applySwap, roleOn, openSlotFor, canTake } from '../server/requests.js';
|
||||||
|
|
||||||
const persons = [{ id: 'a', active: true }, { id: 'b', active: true }, { id: 'c', active: true }, { id: 'x', active: false }];
|
const persons = [{ id: 'a', active: true }, { id: 'b', active: true }, { id: 'c', active: true }, { id: 'x', active: false }, { id: 'm', active: true, canMain: true, canCo: false }];
|
||||||
const plan = () => ({
|
const plan = () => ({
|
||||||
'2026-10-05': { main: 'a', co: 'b', locked: false },
|
'2026-10-05': { main: 'a', co: 'b', locked: false },
|
||||||
'2026-10-06': { main: 'c', co: 'a', locked: false },
|
'2026-10-06': { main: 'c', co: 'a', locked: false },
|
||||||
'2026-10-07': { main: 'b', co: 'c', locked: false },
|
'2026-10-07': { main: 'b', co: 'c', locked: false },
|
||||||
'2026-10-08': { off: true, main: null, co: null },
|
'2026-10-08': { off: true, main: null, co: null },
|
||||||
|
'2026-10-09': { main: null, co: 'c', locked: false },
|
||||||
|
'2026-10-12': { main: 'b', co: null, locked: false },
|
||||||
});
|
});
|
||||||
|
|
||||||
test('roleOn', () => {
|
test('roleOn', () => {
|
||||||
@@ -26,7 +28,11 @@ test('validateRequest prüft Person, Tag, Einteilung und Partner', () => {
|
|||||||
assert.match(validateRequest({ personId: 'a', date: 'gestern' }, persons, p).error, /Ungültiger Tag/);
|
assert.match(validateRequest({ personId: 'a', date: 'gestern' }, persons, p).error, /Ungültiger Tag/);
|
||||||
assert.match(validateRequest({ personId: 'c', date: '2026-10-05' }, persons, p).error, /nicht eingeteilt/);
|
assert.match(validateRequest({ personId: 'c', date: '2026-10-05' }, persons, p).error, /nicht eingeteilt/);
|
||||||
assert.match(validateRequest({ personId: 'a', date: '2026-10-05', partnerId: 'a' }, persons, p).error, /selbst/);
|
assert.match(validateRequest({ personId: 'a', date: '2026-10-05', partnerId: 'a' }, persons, p).error, /selbst/);
|
||||||
assert.match(validateRequest({ personId: 'a', date: '2026-10-05', partnerDate: '2026-10-07' }, persons, p).error, /Person angeben/);
|
assert.match(validateRequest({ personId: 'a', date: '2026-10-05', partnerDate: '2026-10-07' }, persons, p).error, /kein Platz frei/);
|
||||||
|
assert.match(validateRequest({ personId: 'a', date: '2026-10-05', partnerDate: '2026-10-08' }, persons, p).error, /kein Meeting/);
|
||||||
|
assert.match(validateRequest({ personId: 'a', date: '2026-10-05', partnerDate: '2026-10-06' }, persons, p).error, /schon eingeteilt/);
|
||||||
|
const open = validateRequest({ personId: 'a', date: '2026-10-05', partnerDate: '2026-10-09' }, persons, p);
|
||||||
|
assert.deepEqual(open.request, { personId: 'a', date: '2026-10-05', role: 'main', partnerId: null, partnerDate: '2026-10-09', note: '' });
|
||||||
assert.match(validateRequest({ personId: 'a', date: '2026-10-05', partnerId: 'b', partnerDate: '2026-10-05' }, persons, p).error, /derselbe/);
|
assert.match(validateRequest({ personId: 'a', date: '2026-10-05', partnerId: 'b', partnerDate: '2026-10-05' }, persons, p).error, /derselbe/);
|
||||||
const ok = validateRequest({ personId: 'a', date: '2026-10-05', partnerId: 'c', partnerDate: '2026-10-07', note: ' Zahnarzt ' }, persons, p);
|
const ok = validateRequest({ personId: 'a', date: '2026-10-05', partnerId: 'c', partnerDate: '2026-10-07', note: ' Zahnarzt ' }, persons, p);
|
||||||
assert.deepEqual(ok.request, { personId: 'a', date: '2026-10-05', role: 'main', partnerId: 'c', partnerDate: '2026-10-07', note: 'Zahnarzt' });
|
assert.deepEqual(ok.request, { personId: 'a', date: '2026-10-05', role: 'main', partnerId: 'c', partnerDate: '2026-10-07', note: 'Zahnarzt' });
|
||||||
@@ -48,8 +54,36 @@ test('applySwap: Partner übernimmt den Tag', () => {
|
|||||||
assert.deepEqual([p['2026-10-05'].main, p['2026-10-05'].co], ['a', 'c']);
|
assert.deepEqual([p['2026-10-05'].main, p['2026-10-05'].co], ['a', 'c']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('openSlotFor und canTake beachten Rollen', () => {
|
||||||
|
const p = plan();
|
||||||
|
assert.equal(openSlotFor(p['2026-10-09'], { id: 'a', canMain: true, canCo: true }), 'main');
|
||||||
|
assert.equal(openSlotFor(p['2026-10-12'], { id: 'a', canMain: true, canCo: true }), 'co');
|
||||||
|
assert.equal(openSlotFor(p['2026-10-12'], { id: 'm', canMain: true, canCo: false }), null, 'Nur-Haupt bekommt keinen Neben-Platz');
|
||||||
|
assert.equal(openSlotFor(p['2026-10-09'], { id: 'c', canMain: true, canCo: true }), null, 'schon eingeteilt');
|
||||||
|
assert.equal(openSlotFor(p['2026-10-05'], { id: 'c', canMain: true, canCo: true }), null, 'voll');
|
||||||
|
assert.ok(canTake({ id: 'm', canMain: true, canCo: false }, 'main') && !canTake({ id: 'm', canMain: true, canCo: false }, 'co'));
|
||||||
|
assert.ok(!canTake({ id: 'x', active: false }, 'main'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('applySwap: offenen Platz übernehmen, alter Platz wird frei gemeldet', () => {
|
||||||
|
const p = plan();
|
||||||
|
const { changes, vacated } = applySwap(p, { personId: 'a', date: '2026-10-05', partnerId: null, partnerDate: '2026-10-09' }, persons);
|
||||||
|
assert.equal(p['2026-10-09'].main, 'a');
|
||||||
|
assert.equal(p['2026-10-05'].main, null);
|
||||||
|
assert.deepEqual(vacated, { date: '2026-10-05', role: 'main' });
|
||||||
|
assert.equal(changes.length, 2);
|
||||||
|
assert.throws(() => applySwap(plan(), { personId: 'a', date: '2026-10-05', partnerId: null, partnerDate: '2026-10-07' }, persons), /kein passender Platz/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('applySwap prüft Rollen der Beteiligten', () => {
|
||||||
|
const p = plan();
|
||||||
|
// m darf keine Nebenmoderation: Übernahme von b's Neben-Platz am 05.10. scheitert
|
||||||
|
assert.throws(() => applySwap(p, { personId: 'b', date: '2026-10-05', partnerId: 'm', partnerDate: null }, persons), /keine Nebenmoderation/);
|
||||||
|
assert.equal(p['2026-10-05'].co, 'b', 'nichts verändert');
|
||||||
|
});
|
||||||
|
|
||||||
test('applySwap lehnt unmögliche Fälle verständlich ab', () => {
|
test('applySwap lehnt unmögliche Fälle verständlich ab', () => {
|
||||||
assert.throws(() => applySwap(plan(), { personId: 'a', date: '2026-10-05', partnerId: null }), /Wunschpartner/);
|
assert.throws(() => applySwap(plan(), { personId: 'a', date: '2026-10-05', partnerId: null }), /Wunschpartner oder Wunschtag/);
|
||||||
assert.throws(() => applySwap(plan(), { personId: 'c', date: '2026-10-05', partnerId: 'b' }), /nicht mehr eingeteilt/);
|
assert.throws(() => applySwap(plan(), { personId: 'c', date: '2026-10-05', partnerId: 'b' }), /nicht mehr eingeteilt/);
|
||||||
assert.throws(() => applySwap(plan(), { personId: 'a', date: '2026-10-05', partnerId: 'b' }), /andere Moderation/);
|
assert.throws(() => applySwap(plan(), { personId: 'a', date: '2026-10-05', partnerId: 'b' }), /andere Moderation/);
|
||||||
assert.throws(() => applySwap(plan(), { personId: 'a', date: '2026-10-05', partnerId: 'c', partnerDate: '2026-10-08' }), /am Wunschtag nicht eingeteilt/);
|
assert.throws(() => applySwap(plan(), { personId: 'a', date: '2026-10-05', partnerId: 'c', partnerDate: '2026-10-08' }), /am Wunschtag nicht eingeteilt/);
|
||||||
|
|||||||
Reference in new issue
Block a user