Protokoll-Import: KI-Erkennung (Gruppe, Teilnehmer, Status, Mehrfachpunkte), Ladeanzeige, Erstellungsdatum je Punkt
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
6f18848f86
commit
11eabe42ae
275 files changed
+71952
-3029
No files matched your search
+93
-27
@@ -32,8 +32,8 @@
|
||||
}
|
||||
|
||||
// onGanttTodoMouseDown: startet das Zeilen-Ziehen, wenn der Greif-Handler
|
||||
// (⋮⋮) einer TODO-/Meilenstein-Zeile gedrückt wird, und merkt sich Zeile
|
||||
// und Startposition in app.todoDrag.
|
||||
// (Icon "grip", .gantt-todo-drag) einer TODO-/Meilenstein-Zeile gedrückt
|
||||
// wird, und merkt sich Zeile und Startposition in app.todoDrag.
|
||||
function onGanttTodoMouseDown(e){
|
||||
if(app.isLocked()) return;
|
||||
const handle = e.target.closest('.gantt-todo-drag');
|
||||
@@ -113,7 +113,11 @@
|
||||
const plan = app.ensurePlanLocal();
|
||||
const todos = Array.isArray(plan.todos) ? plan.todos : [];
|
||||
const milestones = Array.isArray(plan.milestones) ? plan.milestones : [];
|
||||
const todoIds = todos.map(t => t.id);
|
||||
// Nur terminierte Aufgaben (Start UND Ende gesetzt) haben überhaupt eine
|
||||
// eigene GANTT-Zeile (siehe plan-build-gantt.js) und damit einen
|
||||
// Ziehgriff — Aufgaben ohne Termin bleiben an ihrer Stelle
|
||||
// in plan.todos stehen, sie können hier gar nicht bewegt worden sein.
|
||||
const scheduledIds = new Set(todos.filter(t => t.start && t.end).map(t => t.id));
|
||||
|
||||
// Alle beweglichen Zeilen in aktueller DOM-Reihenfolge
|
||||
const rows = [...chart.querySelectorAll(DRAGGABLE_GANTT_ROW_SELECTOR)];
|
||||
@@ -125,17 +129,19 @@
|
||||
// Position (before:/after:) sind eigene Zeilen. Meilensteine ohne Position
|
||||
// liegen in der Projektzeile und werden hier nicht umsortiert, deshalb nur
|
||||
// die positionierten zählen.
|
||||
if(todoRowIds.length !== todoIds.length) return;
|
||||
if(todoRowIds.length !== scheduledIds.size) return;
|
||||
const positionedIds = milestones.filter(m => m.position && /^(before|after):/.test(m.position)).map(m => m.id);
|
||||
if(milestoneRowIds.length !== positionedIds.length) return;
|
||||
|
||||
// TODOs neu ordnen
|
||||
const newTodos = [];
|
||||
todoRowIds.forEach(id => {
|
||||
const t = todos.find(x => x.id === id);
|
||||
if(t) newTodos.push(t);
|
||||
// TODOs neu ordnen: nur die Positionen der terminierten Aufgaben werden
|
||||
// gemäß der neuen DOM-Reihenfolge neu belegt (todoRowIds); Aufgaben ohne
|
||||
// Termin behalten exakt ihren bisherigen Platz in der Liste.
|
||||
let nextRowIdIdx = 0;
|
||||
plan.todos = todos.map(t => {
|
||||
if(!scheduledIds.has(t.id)) return t;
|
||||
const id = todoRowIds[nextRowIdIdx++];
|
||||
return todos.find(x => x.id === id) || t;
|
||||
});
|
||||
plan.todos = newTodos;
|
||||
|
||||
// Meilenstein-Positionen neu berechnen
|
||||
// Regeln:
|
||||
@@ -382,9 +388,12 @@
|
||||
const totalDays = daysBetween(rangeStart, rangeEnd) + 1;
|
||||
const dayOffset = Math.max(0, Math.min(totalDays - 1, Math.round(x / rect.width * totalDays)));
|
||||
const date = formatDateISO(addDays(rangeStart, dayOffset));
|
||||
openTodoModal(null);
|
||||
document.getElementById('todoEditStart').value = date;
|
||||
document.getElementById('todoEditEnd').value = date;
|
||||
// Über prefill statt nachträglich in die versteckten Felder schreiben:
|
||||
// openTodoModal() setzt damit auch die sichtbare Zeitraum-Anzeige, das
|
||||
// Dauer-Kennzeichen und den Vergleichsstand für "ungespeicherte
|
||||
// Änderungen" (sonst stand im Feld "Start – Ende wählen…", und Esc fragte
|
||||
// grundlos nach dem Verwerfen).
|
||||
openTodoModal(null, { start: date, end: date });
|
||||
}
|
||||
|
||||
// updateProjectDates: setzt Start und Ende des Projekt-Plans und speichert.
|
||||
@@ -415,23 +424,80 @@
|
||||
}
|
||||
}
|
||||
|
||||
// updateVacationDates: schreibt die verschobenen Urlaubsdaten sowohl in die
|
||||
// lokale Sitzungs-Kopie der Person als auch in die globale Personenliste
|
||||
// und speichert beides.
|
||||
// updateVacationDates: übernimmt die per Drag verschobenen Urlaubsdaten
|
||||
// sofort optimistisch in lokale Sitzungs-Kopie und globale Personenliste
|
||||
// (damit das GANTT unmittelbar danach die neue Position zeichnet), und
|
||||
// persistiert sie asynchron über die /users/:id/absences-Endpunkte — es
|
||||
// gibt seit der Ablösung von people/person_roles/person_absences keinen
|
||||
// /save-people-Schreibpfad und kein PATCH auf Abwesenheiten mehr, daher:
|
||||
// alte Abwesenheit löschen, neue mit den verschobenen Daten anlegen (dabei
|
||||
// vergibt der Server eine neue ID, die hier übernommen wird). Kein
|
||||
// Benutzerkonto für die Person gefunden -> klare Fehlermeldung statt
|
||||
// stillem Fehlschlag.
|
||||
function updateVacationDates(personId, absenceId, start, end){
|
||||
const localPerson = (app.week.people || []).find(p => p.id === personId);
|
||||
if(localPerson && localPerson.absences){
|
||||
const a = localPerson.absences.find(x => x.id === absenceId);
|
||||
if(a){ a.start = start; a.end = end; }
|
||||
}
|
||||
const globalPeople = db.globalPeople[app.department] || [];
|
||||
const localAbsence = localPerson && Array.isArray(localPerson.absences)
|
||||
? localPerson.absences.find(x => x.id === absenceId) : null;
|
||||
const globalPeople = db.globalPeople || [];
|
||||
const globalPerson = globalPeople.find(p => p.id === personId);
|
||||
if(globalPerson && globalPerson.absences){
|
||||
const a = globalPerson.absences.find(x => x.id === absenceId);
|
||||
if(a){ a.start = start; a.end = end; }
|
||||
}
|
||||
app.savePlan();
|
||||
app.saveGlobalPeople();
|
||||
const globalAbsence = globalPerson && Array.isArray(globalPerson.absences)
|
||||
? globalPerson.absences.find(x => x.id === absenceId) : null;
|
||||
if(!globalAbsence) return;
|
||||
const type = globalAbsence.type || '';
|
||||
const note = globalAbsence.note || '';
|
||||
// Ursprungstermine merken: Scheitert das Speichern (Netz, fehlende
|
||||
// Berechtigung), wird die optimistisch verschobene Anzeige wieder auf den
|
||||
// Server-Stand zurückgesetzt, statt etwas zu zeigen, das dort nicht gilt.
|
||||
const vorher = { start: globalAbsence.start, end: globalAbsence.end };
|
||||
const zuruecksetzen = () => {
|
||||
if(localAbsence){ localAbsence.start = vorher.start; localAbsence.end = vorher.end; }
|
||||
globalAbsence.start = vorher.start; globalAbsence.end = vorher.end;
|
||||
renderPlanGantt();
|
||||
};
|
||||
|
||||
if(localAbsence){ localAbsence.start = start; localAbsence.end = end; }
|
||||
globalAbsence.start = start; globalAbsence.end = end;
|
||||
|
||||
app.getUserIdForPersonId(personId).then(userId => {
|
||||
if(!userId){
|
||||
app.showToast('Für diese Person existiert kein Benutzerkonto – Abwesenheit konnte nicht gespeichert werden.');
|
||||
zuruecksetzen();
|
||||
return;
|
||||
}
|
||||
// Ergebnis des Löschens prüfen, BEVOR neu angelegt wird: Schlug das
|
||||
// Anlegen danach fehl (Netz, 400), war die alte Abwesenheit bereits weg
|
||||
// — die Anzeige zeigte sie aber weiter, und nach dem nächsten Neuladen
|
||||
// fehlte sie ersatzlos.
|
||||
return fetch(baseUrl() + '/users/' + userId + '/absences/' + encodeURIComponent(absenceId), { method: 'DELETE' })
|
||||
.then(r => {
|
||||
if(!r.ok){
|
||||
// 403 heißt: Der Eintrag stammt von jemand anderem (siehe
|
||||
// DELETE /users/:id/absences/:absenceId in server.js).
|
||||
return r.json().catch(() => ({})).then(json => {
|
||||
throw new Error((json && json.error) || ('Alte Abwesenheit konnte nicht entfernt werden (HTTP ' + r.status + ')'));
|
||||
});
|
||||
}
|
||||
return fetch(baseUrl() + '/users/' + userId + '/absences', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type':'application/json; charset=utf-8'},
|
||||
body: JSON.stringify({ start, end, type, note })
|
||||
});
|
||||
})
|
||||
.then(r => r.json().then(json => ({ ok: r.ok, json }))).then(({ ok, json }) => {
|
||||
if(!ok || !json || !json.ok){
|
||||
app.showToast('Abwesenheit konnte nicht gespeichert werden: ' + ((json && json.error) || 'unbekannter Fehler')
|
||||
+ ' — bitte die Seite neu laden.');
|
||||
return;
|
||||
}
|
||||
globalAbsence.id = json.absence.id;
|
||||
if(localAbsence) localAbsence.id = json.absence.id;
|
||||
renderPlanGantt();
|
||||
});
|
||||
}).catch(err => {
|
||||
console.error('Abwesenheit verschieben fehlgeschlagen:', err);
|
||||
app.showToast('Abwesenheit konnte nicht verschoben werden: ' + (err.message || err));
|
||||
zuruecksetzen();
|
||||
});
|
||||
}
|
||||
|
||||
// Drag-Funktionalität erst nach geladenem DOM registrieren
|
||||
|
||||
@@ -348,7 +348,7 @@
|
||||
// setzeProtokollImportLadezustand: blendet den Ladehinweis ein/aus und
|
||||
// sperrt alle Felder + Knöpfe des Dialogs währenddessen — ohne das gab es
|
||||
// während eines länger laufenden KI-Aufrufs (bis zu KI_TIMEOUT_MS_LOKAL =
|
||||
// 5 Minuten bei einem eigenen Server, server.js) keinerlei Rückmeldung,
|
||||
// 30 Minuten bei einem eigenen Server, server.js) keinerlei Rückmeldung,
|
||||
// der Dialog wirkte hängengeblieben, und ein zweiter Klick auf
|
||||
// "Importieren" hätte denselben Import doppelt losgeschickt.
|
||||
function setzeProtokollImportLadezustand(aktiv){
|
||||
@@ -584,19 +584,27 @@
|
||||
<div class="legend">
|
||||
<span class="legend-item"><span class="legend-swatch neu"></span>Neu in diesem Protokoll</span>
|
||||
<span class="legend-item"><span class="legend-swatch fortgeschrieben"></span>Aus vorigem Protokoll fortgeschrieben</span>
|
||||
<span class="legend-item"><span class="legend-swatch erledigt"></span>Erledigt (läuft nicht weiter mit)</span>
|
||||
<span class="legend-item"><span class="legend-swatch erledigt"></span>Erledigt oder Info (läuft nicht weiter mit)</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// PROTOKOLL_PUNKT_STATUS_LABEL: Anzeigetext je Status im <select> unten —
|
||||
// einzige Stelle, die die drei Werte kennt, damit ein vierter Status
|
||||
// (sollte es den je geben) nur hier ergänzt werden müsste.
|
||||
const PROTOKOLL_PUNKT_STATUS_LABEL = { offen: 'Offen', erledigt: 'Erledigt', info: 'Info' };
|
||||
|
||||
function renderPunktZeileHtml(punkt, idx, nummer, istErste, istLetzte){
|
||||
const locked = app.isLocked();
|
||||
const terminDisplay = formatDateDE(parseDateISO(punkt.termin)) || '';
|
||||
const neuChip = punkt.neu ? '<span class="protokoll-chip-neu">Neu</span>' : '';
|
||||
const status = punkt.status || 'offen';
|
||||
const moveBtns = locked ? '' : `<span class="protokoll-punkt-move">
|
||||
<button type="button" class="secondary small" ${istErste ? 'disabled' : ''} onclick="verschiebePunkt(${idx}, -1)" title="Innerhalb der Gruppe nach oben">↑</button>
|
||||
<button type="button" class="secondary small" ${istLetzte ? 'disabled' : ''} onclick="verschiebePunkt(${idx}, 1)" title="Innerhalb der Gruppe nach unten">↓</button>
|
||||
</span>`;
|
||||
return `<div class="protokoll-punkt${punkt.neu ? ' is-neu' : ''}${punkt.erledigt ? ' is-erledigt' : ''}" id="protokollPunkt_${idx}">
|
||||
const statusOptions = Object.keys(PROTOKOLL_PUNKT_STATUS_LABEL)
|
||||
.map(s => `<option value="${s}" ${s === status ? 'selected' : ''}>${PROTOKOLL_PUNKT_STATUS_LABEL[s]}</option>`).join('');
|
||||
return `<div class="protokoll-punkt${punkt.neu ? ' is-neu' : ''}${status === 'erledigt' ? ' is-erledigt' : ''}${status === 'info' ? ' is-info' : ''}" id="protokollPunkt_${idx}">
|
||||
<div class="protokoll-punkt-top-row">
|
||||
<span class="protokoll-punkt-nr" title="Errechnet sich automatisch aus Gruppe und Reihenfolge">${esc(nummer)}</span>
|
||||
${moveBtns}
|
||||
@@ -606,8 +614,8 @@
|
||||
${neuChip}
|
||||
<input type="text" class="protokoll-punkt-thema" value="${esc(punkt.thema)}" placeholder="Thema" ${locked ? 'disabled' : ''}
|
||||
oninput="currentProtokollSetzePunktFeld(${idx}, 'thema', this.value)">
|
||||
<label class="protokoll-punkt-erledigt-toggle"><input type="checkbox" ${punkt.erledigt ? 'checked' : ''} ${locked ? 'disabled' : ''}
|
||||
onchange="currentProtokollSetzePunktErledigt(${idx}, this.checked)"> erledigt</label>
|
||||
<select class="protokoll-punkt-status" ${locked ? 'disabled' : ''}
|
||||
onchange="currentProtokollSetzePunktStatus(${idx}, this.value)">${statusOptions}</select>
|
||||
${locked ? '' : `<span class="protokoll-row-remove" onclick="entfernePunkt(${idx})" title="Punkt entfernen">✕</span>`}
|
||||
</div>
|
||||
<textarea class="protokoll-punkt-text" rows="2" placeholder="Text / Vereinbarung" ${locked ? 'disabled' : ''}
|
||||
@@ -648,11 +656,14 @@
|
||||
const el = document.getElementById('protokollPunktTermin_' + idx);
|
||||
if(el) currentProtokoll.punkte[idx].termin = el.value;
|
||||
};
|
||||
window.currentProtokollSetzePunktErledigt = function(idx, checked){
|
||||
window.currentProtokollSetzePunktStatus = function(idx, status){
|
||||
if(!currentProtokoll || !currentProtokoll.punkte[idx]) return;
|
||||
currentProtokoll.punkte[idx].erledigt = checked;
|
||||
currentProtokoll.punkte[idx].status = status;
|
||||
const row = document.getElementById('protokollPunkt_' + idx);
|
||||
if(row) row.classList.toggle('is-erledigt', checked);
|
||||
if(row){
|
||||
row.classList.toggle('is-erledigt', status === 'erledigt');
|
||||
row.classList.toggle('is-info', status === 'info');
|
||||
}
|
||||
};
|
||||
// currentProtokollSetzePunktGruppe: einzige Punkt-Feldänderung, die neu
|
||||
// rendert (nur den Punkte-Block, siehe renderPunkteBlockHtml) — ändert
|
||||
@@ -686,7 +697,7 @@
|
||||
// immer, was der Server beim Speichern setzt (server.js, PUT .../:id).
|
||||
currentProtokoll.punkte.push({
|
||||
id: null, nummer: '', gruppe: gruppe || '', neu: true,
|
||||
thema: '', text: '', zustaendig: '', termin: '', erledigt: false,
|
||||
thema: '', text: '', zustaendig: '', termin: '', status: 'offen',
|
||||
erstelltAm: currentProtokoll.datum || ''
|
||||
});
|
||||
const block = document.getElementById('protokollPunkteBlock');
|
||||
|
||||
+287
-141
@@ -1,23 +1,38 @@
|
||||
// -----------------------------
|
||||
// Projektplan — Zustand, Initialisierung & Daten laden
|
||||
// Projektbereich — Zustand, Einstieg & Projektauswahl
|
||||
// -----------------------------
|
||||
// Dieses Modul wird als erstes der plan-*-Module von public/project-plan.html
|
||||
// geladen und definiert window.PlanApp als gemeinsamen Zustand aller
|
||||
// Projektplan-Module. Es liest die URL-Route (Abteilung + Projektname, z. B.
|
||||
// "/HLS/Projektplan/Gymnasium%20Hemer") und die URL-Parameter (weekLabel,
|
||||
// projNr), über die das zu bearbeitende Projekt adressiert wird.
|
||||
// Nach DOMContentLoaded werden Theme, Toolbar-Buttons und die Sitzungsdaten
|
||||
// über die Server-API geladen. Die hier definierten Funktionen (savePlan,
|
||||
// isLocked, showToast u. a.) werden über app.* den anderen plan-Modulen
|
||||
// bereitgestellt, die alle in einem IIFE mit window.PlanApp laufen.
|
||||
// Dieses Modul wird als erstes der plan-*-Module von public/Test.html geladen
|
||||
// und definiert window.PlanApp als gemeinsamen Zustand aller Module des
|
||||
// Projektbereichs.
|
||||
//
|
||||
// WICHTIG — geändert gegenüber der früheren Fassung: Der Projektbereich war
|
||||
// einmal eine eigene Seite (public/project-plan.html) mit eigenem
|
||||
// Einstiegspunkt. Sie las Sitzung und Projekt aus der URL und lud sich ihren
|
||||
// Datenbestand über GET /load-all selbst. Beides ist entfallen: Der
|
||||
// Projektbereich ist jetzt ein Bereich der Hauptseite (#plan in Test.html),
|
||||
// der über showTab('plan') eingeblendet wird wie jeder andere. Er arbeitet
|
||||
// deshalb auf dem bereits geladenen In-Memory-Bestand db.weeks (js/store.js)
|
||||
// und braucht weder einen eigenen Ladevorgang noch eine eigene
|
||||
// Anmeldeprüfung.
|
||||
//
|
||||
// Einstieg sind zwei Schritte: setPlanTarget(weekLabel, projNr, projName)
|
||||
// merkt sich das Ziel, openPlanView() sucht es in db.weeks und rendert den
|
||||
// Bereich. Getrennt sind sie, weil showTab('plan') (js/tabs-auth.js) aus dem
|
||||
// Ziel zuerst die Adresszeile schreibt und den Bereich einblendet — das GANTT
|
||||
// kann sich erst vermessen, wenn sein Container sichtbar ist. Gesetzt wird
|
||||
// das Ziel von openProjectPlan() (js/gantt.js) und beim Seitenstart aus einem
|
||||
// Deep-Link "/Projektplan/<Projektname>?weekLabel=…&projNr=…"
|
||||
// (applyUrlRoute() in js/ui.js, ausgewertet in js/init.js).
|
||||
// Die hier definierten Funktionen (savePlan, isLocked, showToast u. a.)
|
||||
// stellen sich über app.* den anderen plan-Modulen bereit, die alle in einem
|
||||
// IIFE mit window.PlanApp laufen.
|
||||
|
||||
// PlanApp: gemeinsamer, modulübergreifender Zustand der Projektplan-Seite.
|
||||
// PlanApp: gemeinsamer, modulübergreifender Zustand des Projektbereichs.
|
||||
window.PlanApp = {
|
||||
params: new URLSearchParams(location.search),
|
||||
department: '',
|
||||
weekLabel: '',
|
||||
projNr: '',
|
||||
projName: '',
|
||||
sandboxPlanId: '', // gesetzt von openSandboxPlan() — Sandbox-Pläne werden über id adressiert, nicht über nr/name
|
||||
week: null, // planWeek
|
||||
project: null, // planProject
|
||||
pxPerDay: 14, // Zoomstufe GANTT (null = an Containerbreite anpassen)
|
||||
@@ -34,88 +49,117 @@ window.PlanApp = {
|
||||
|
||||
(function(){
|
||||
const app = window.PlanApp;
|
||||
// Abteilung und Projektname aus dem Deep-Link-Pfad
|
||||
// "/<Abteilung>/Projektplan/<Projektname>" lesen. Fallback auf die früheren
|
||||
// Query-Parameter ?department=/&projName= für alte project-plan.html-Links.
|
||||
const routeMatch = window.location.pathname.match(/^\/([^/]+)\/(?:Projektplan|project-plan\.html)(?:\/(.+))?\/?$/);
|
||||
app.department = routeMatch ? routeMatch[1] : (app.params.get('department') || 'ELT');
|
||||
app.weekLabel = app.params.get('weekLabel');
|
||||
app.projNr = app.params.get('projNr') || '';
|
||||
let projNameFromRoute = '';
|
||||
if(routeMatch && routeMatch[2]){
|
||||
try { projNameFromRoute = decodeURIComponent(routeMatch[2]); }
|
||||
catch(e) { projNameFromRoute = app.params.get('projName') || ''; }
|
||||
}
|
||||
app.projName = projNameFromRoute || (app.params.get('projName') || '');
|
||||
|
||||
// init: Einstiegspunkt nach DOMContentLoaded. Übernimmt das Theme aus
|
||||
// localStorage/System, prüft die URL-Parameter und bricht mit Fehlermeldung
|
||||
// ab, falls kein Projekt ausgewählt wurde. Sonst bindet es die Toolbar und
|
||||
// startet das Laden der Daten.
|
||||
function init(){
|
||||
loadTheme(); // Theme aus localStorage / System übernehmen
|
||||
if(!app.weekLabel || !app.projName){
|
||||
document.getElementById('planTitle').textContent = 'Fehler';
|
||||
document.getElementById('planSubtitle').textContent = 'Kein Projekt ausgewählt.';
|
||||
return;
|
||||
// planFail: zeigt einen Fehler in der Kopfzeile des Projektbereichs an und
|
||||
// blendet die Bereichsleiste aus, weil ohne Projekt jeder Unterbereich nur
|
||||
// in eine leere Maske führte. Parameter: title, subtitle (Strings).
|
||||
function planFail(title, subtitle){
|
||||
const t = document.getElementById('planTitle');
|
||||
const sub = document.getElementById('planSubtitle');
|
||||
if(t) t.textContent = title;
|
||||
if(sub) sub.textContent = subtitle;
|
||||
const badge = document.getElementById('planIdBadge');
|
||||
if(badge) badge.textContent = '';
|
||||
app.project = null;
|
||||
app.hidePlanNav();
|
||||
}
|
||||
|
||||
// setPlanTarget: merkt sich, welches Projekt der Bereich zeigen soll —
|
||||
// adressiert über Sitzungs-Label und Projekt-Nr./-Bezeichnung, NICHT über
|
||||
// project.id, die beim Duplizieren einer Sitzung (cloneWeek(), js/weeks.js)
|
||||
// neu vergeben wird. Bewusst getrennt vom Rendern: showTab('plan')
|
||||
// (js/tabs-auth.js) schreibt aus diesen Werten zuerst die Adresszeile und
|
||||
// blendet den Bereich ein, und erst der sichtbare Bereich lässt sich
|
||||
// vermessen — das GANTT braucht die Containerbreite. Parameter:
|
||||
// weekLabel, projNr, projName (Strings). Keine Rückgabe.
|
||||
window.setPlanTarget = function(weekLabel, projNr, projName){
|
||||
app.weekLabel = weekLabel || '';
|
||||
app.projNr = projNr || '';
|
||||
app.projName = projName || '';
|
||||
app.sandboxPlanId = '';
|
||||
};
|
||||
|
||||
// openSandboxPlan: das Gegenstück zu setPlanTarget()+showTab('plan') für
|
||||
// einen Terminplan der Planung-Sandbox (js/planung/planung.js). Sandbox-
|
||||
// Pläne haben keine Projektnummer und werden nie geklont (cloneWeek()
|
||||
// betrifft nur echte Wochen) — ihre id bleibt also stabil und ist die
|
||||
// einzige verlässliche Adressierung, anders als bei echten Projekten
|
||||
// (dort nr/name, siehe setPlanTarget()). Parameter: planId. Keine Rückgabe.
|
||||
window.openSandboxPlan = function(planId){
|
||||
app.weekLabel = SANDBOX_WEEK_LABEL;
|
||||
app.projNr = '';
|
||||
app.projName = '';
|
||||
app.sandboxPlanId = planId || '';
|
||||
showTab('plan');
|
||||
};
|
||||
|
||||
// openPlanView: sucht Sitzung und Projekt im bereits geladenen Bestand
|
||||
// db.weeks (js/store.js) und rendert den Bereich. Einen eigenen
|
||||
// Ladevorgang gibt es nicht mehr — der Projektbereich teilt sich den
|
||||
// Datenbestand mit allen anderen Bereichen der Seite. Wird von
|
||||
// showTab('plan') gerufen (und von dort aus auch nach jedem erneuten
|
||||
// Laden, siehe load() in js/store.js). Rückgabe: true, wenn das Projekt
|
||||
// gefunden und gerendert wurde.
|
||||
window.openPlanView = function(){
|
||||
app.showPlanNav();
|
||||
|
||||
// Planung-Sandbox: eigener, einfacherer Zweig — eine feste, reservierte
|
||||
// "Sitzung" (db.sandboxWeek, nie Teil von db.weeks, siehe js/store.js)
|
||||
// und Adressierung über die stabile Plan-id statt über nr/name.
|
||||
if(app.weekLabel === SANDBOX_WEEK_LABEL){
|
||||
if(!app.sandboxPlanId){
|
||||
planFail('Kein Terminplan gewählt', 'Diesen Bereich öffnet ein Klick auf einen Terminplan im Bereich „Planung".');
|
||||
return false;
|
||||
}
|
||||
app.week = db.sandboxWeek || null;
|
||||
app.project = (app.week && Array.isArray(app.week.projects))
|
||||
? app.week.projects.find(p => p.id === app.sandboxPlanId) || null
|
||||
: null;
|
||||
if(!app.project){
|
||||
planFail('Nicht gefunden', 'Dieser Terminplan wurde nicht gefunden — er wurde möglicherweise gelöscht.');
|
||||
return false;
|
||||
}
|
||||
app.projNr = '';
|
||||
app.projName = app.project.name || '';
|
||||
renderPlanPage();
|
||||
return true;
|
||||
}
|
||||
currentDepartment = app.department; // global aus globals.js
|
||||
bindToolbarButtons();
|
||||
loadPlanData();
|
||||
}
|
||||
|
||||
// bindToolbarButtons: verbindet die Toolbar-Buttons (TODO hinzufügen,
|
||||
// Auto-Schedule, Kritischer Pfad) mit ihren globalen Funktionen der
|
||||
// anderen plan-Module. Die Buttons binden ihre Handler bereits über das
|
||||
// onclick-Attribut in project-plan.html; hier werden nur Lücken gefüllt,
|
||||
// falls ein Button-Element fehlt.
|
||||
function bindToolbarButtons(){
|
||||
}
|
||||
|
||||
// loadPlanData: lädt alle Sitzungen und die globale Personenliste der
|
||||
// Abteilung über die Server-API (JSON-Dokumente), sortiert die Wochen
|
||||
// chronologisch und sucht daraus die Ziel-Woche bzw. das Ziel-Projekt. Zeigt
|
||||
// Fehlermeldungen der Reihenfolge nach im Seitenkopf an und startet sonst
|
||||
// renderPlanPage().
|
||||
function loadPlanData(){
|
||||
if(!window.fetch){
|
||||
document.getElementById('planSubtitle').textContent = 'Netzwerk nicht verfügbar.';
|
||||
return;
|
||||
if(!app.weekLabel || (!app.projNr && !app.projName)){
|
||||
planFail('Kein Projekt gewählt', 'Diesen Bereich öffnet ein Klick auf ein Projekt im Projektboard oder im Tab „Projekte".');
|
||||
return false;
|
||||
}
|
||||
const url = baseUrl() + '/load-all?department=' + encodeURIComponent(app.department);
|
||||
fetch(url)
|
||||
.then(r => { if(!r.ok) throw new Error('Server HTTP '+r.status); return r.json(); })
|
||||
.then(data => {
|
||||
db = { weeks: [], currentWeek: null, globalPeople: {} };
|
||||
db.globalPeople[app.department] = Array.isArray(data.people) ? data.people : [];
|
||||
db.weeks = (Array.isArray(data.weeks) ? data.weeks : [])
|
||||
.filter(w => w && typeof w.label === 'string')
|
||||
.map(w => {
|
||||
if(!Array.isArray(w.people)) w.people = [];
|
||||
if(!Array.isArray(w.projects)) w.projects = [];
|
||||
if(!w.attendance) w.attendance = {};
|
||||
return w;
|
||||
});
|
||||
db.weeks.sort((a,b) => weekSortKey(a.label).localeCompare(weekSortKey(b.label)));
|
||||
app.week = db.weeks.find(w => w.label === app.weekLabel);
|
||||
if(!app.week){
|
||||
document.getElementById('planTitle').textContent = 'Nicht gefunden';
|
||||
document.getElementById('planSubtitle').textContent = 'Sitzung "' + esc(app.weekLabel) + '" konnte nicht geladen werden.';
|
||||
return;
|
||||
}
|
||||
app.project = app.week.projects.find(p => (p.nr || '') === app.projNr && (p.name || '') === app.projName);
|
||||
if(!app.project){
|
||||
document.getElementById('planTitle').textContent = 'Nicht gefunden';
|
||||
document.getElementById('planSubtitle').textContent = 'Projekt nicht in dieser Sitzung vorhanden.';
|
||||
return;
|
||||
}
|
||||
renderPlanPage();
|
||||
})
|
||||
.catch(err => {
|
||||
document.getElementById('planTitle').textContent = 'Fehler beim Laden';
|
||||
document.getElementById('planSubtitle').textContent = err.message;
|
||||
});
|
||||
}
|
||||
// Vor dem ersten erfolgreichen load() ist db.weeks noch leer (Deep-Link
|
||||
// direkt auf "/Projektplan/…"). Dann ist das kein Fehler, sondern nur
|
||||
// noch nicht so weit: load() ruft showTab(currentTab) erneut auf.
|
||||
if(!Array.isArray(db.weeks) || db.weeks.length === 0){
|
||||
const t = document.getElementById('planTitle');
|
||||
const sub = document.getElementById('planSubtitle');
|
||||
if(t) t.textContent = app.projName || 'Projektbereich';
|
||||
if(sub) sub.textContent = 'Sitzungsdaten werden geladen…';
|
||||
return false;
|
||||
}
|
||||
app.week = db.weeks.find(w => w.label === app.weekLabel) || null;
|
||||
if(!app.week){
|
||||
planFail('Nicht gefunden', 'Sitzung "' + app.weekLabel + '" konnte nicht geladen werden.');
|
||||
return false;
|
||||
}
|
||||
// Projekt-Nr. ist der stabile Schlüssel; die Bezeichnung dient nur als
|
||||
// Rückfallebene für Projekte, die (noch) keine Nummer haben.
|
||||
const projects = app.week.projects || [];
|
||||
app.project = projects.find(p => (p.nr || '') === app.projNr && (p.name || '') === app.projName)
|
||||
|| (app.projNr ? projects.find(p => (p.nr || '') === app.projNr) : null)
|
||||
|| projects.find(p => (p.name || '') === app.projName)
|
||||
|| null;
|
||||
if(!app.project){
|
||||
planFail('Nicht gefunden', 'Projekt nicht in dieser Sitzung vorhanden.');
|
||||
return false;
|
||||
}
|
||||
app.projNr = app.project.nr || '';
|
||||
app.projName = app.project.name || '';
|
||||
renderPlanPage();
|
||||
return true;
|
||||
};
|
||||
|
||||
// ensurePlanLocal: stellt sicher, dass das Plan-Objekt des Projekts die
|
||||
// erwartete Struktur hat (inkl. Migration alter Einzel-Abhängigkeiten).
|
||||
@@ -139,21 +183,98 @@ window.PlanApp = {
|
||||
return !!app.week.locked;
|
||||
}
|
||||
|
||||
// renderPlanPage: setzt Titel und Untertitel der Seite aus Projekt und Woche,
|
||||
// blendet den Meilenstein-Button bei gesperrter Woche aus und startet die
|
||||
// GANTT-Anzeige über fitPlanGanttRange().
|
||||
// isSandboxPlan: liefert true, wenn der aktuell geöffnete Plan zur
|
||||
// Planung-Sandbox gehört statt zu einem echten Projekt. Zentraler Merker
|
||||
// für alle plan-*-Module, die dort Abschnitte ausblenden, die nur bei
|
||||
// einem echten Projekt Sinn ergeben (Dateien, Leistung, Auftragsstatus,
|
||||
// Projektnummer, Gewerke, Projektgröße, Kommentar/Verlauf — siehe CLAUDE.md
|
||||
// "Planung — projektunabhängige Terminplan-Sandbox"). Rückgabe: Boolescher Wert.
|
||||
function isSandboxPlan(){
|
||||
return !!app.week && app.week.label === SANDBOX_WEEK_LABEL;
|
||||
}
|
||||
|
||||
// renderPlanPage: setzt die kompakte, einzeilige Titelzeile aus Projekt und
|
||||
// Woche (Nr.-Badge, Name, Tags, Sitzungslabel, Beteiligte als
|
||||
// überlappender Avatar-Stapel — siehe renderProjectAssignees()), baut das
|
||||
// Projektinfo-Panel auf (renderPlanInfo(), js/plan/plan-info.js: Status,
|
||||
// Gruppe, amtierende Leistungsphase, LPH-Prozentliste) sowie den
|
||||
// Dateien-Bereich (initPlanFiles(), js/plan/plan-files.js: Upload, Freigabe,
|
||||
// Kommentare) und den Einstellungen-Bereich (renderPlanSettings(),
|
||||
// js/plan/plan-settings.js: Honorar, beauftragte Leistungsphasen), blendet
|
||||
// die Schaltflächen zum Anlegen neuer Aufgaben bei gesperrter Woche aus,
|
||||
// startet die GANTT-Anzeige über fitPlanGanttRange() und schaltet zuletzt
|
||||
// auf den sichtbaren Bereich (app.initPlanSections(), js/plan/plan-sections.js).
|
||||
function renderPlanPage(){
|
||||
const p = app.project;
|
||||
const plan = ensurePlanLocal();
|
||||
document.getElementById('planTitle').textContent = (p.nr ? p.nr + ' – ' : '') + p.name;
|
||||
document.getElementById('planSubtitle').textContent = app.week.label + (app.week.date ? ' · ' + formatWeekDate(app.week.date) : '');
|
||||
const sandbox = isSandboxPlan();
|
||||
const planIdBadge = document.getElementById('planIdBadge');
|
||||
if(planIdBadge) planIdBadge.textContent = p.nr || '';
|
||||
document.getElementById('planTitle').textContent = p.name;
|
||||
// Für die Sandbox NICHT das rohe week.label ("__sandbox__") anzeigen —
|
||||
// das ist ein interner Marker, kein für Nutzer bestimmter Sitzungsname.
|
||||
document.getElementById('planSubtitle').textContent = sandbox
|
||||
? 'Planung — unabhängig von einem laufenden Projekt'
|
||||
: app.week.label + (app.week.date ? ' · ' + formatWeekDate(app.week.date) : '');
|
||||
|
||||
// GANTT inkl. TODOs
|
||||
const addMilestoneBtn = document.getElementById('addMilestoneBtn');
|
||||
const planPeopleList = document.getElementById('planPeopleList');
|
||||
if(planPeopleList) planPeopleList.innerHTML = renderProjectAssignees();
|
||||
|
||||
const tags = Array.isArray(p.tags) ? p.tags : [];
|
||||
const planTagsList = document.getElementById('planTagsList');
|
||||
if(planTagsList){
|
||||
planTagsList.innerHTML = tags.map(t => `<span class="tag-chip">${esc(t)}</span>`).join('');
|
||||
}
|
||||
|
||||
// Projektinfo-Panel (Status, Gruppe, amtierende Leistungsphase, LPH-
|
||||
// Prozentliste) — siehe js/plan/plan-info.js.
|
||||
renderPlanInfo();
|
||||
|
||||
// Dateien-Bereich (Upload, Freigabe, Kommentare) — siehe js/plan/plan-files.js.
|
||||
initPlanFiles();
|
||||
|
||||
// Protokolle-Bereich (Besprechungsprotokolle) — siehe js/plan/plan-protokoll.js.
|
||||
// Wie initPlanFiles() unbedingt aufgerufen (nicht erst beim Öffnen des
|
||||
// Bereichs), lädt bei vorhandener Projektnummer die Protokollliste.
|
||||
if(typeof initPlanProtokoll === 'function') initPlanProtokoll();
|
||||
|
||||
// Verlauf über alle Sitzungen (Kommentar-Historie) — siehe js/plan/plan-history.js.
|
||||
renderPlanHistory();
|
||||
|
||||
// Einstellungen-Bereich (Honorar, beauftragte Leistungsphasen,
|
||||
// Projektangaben) — siehe js/plan/plan-settings.js.
|
||||
renderPlanSettings();
|
||||
|
||||
// Schaltflächen, die neue Aufgaben/Meilensteine erzeugen — in gesperrten
|
||||
// Sitzungen nicht möglich. Die Liste enthält die Knöpfe beider Bereiche:
|
||||
// Terminplan (…Btn) und Aufgaben (…BtnList), siehe Test.html.
|
||||
const locked = isLocked();
|
||||
if(addMilestoneBtn) addMilestoneBtn.style.display = locked ? 'none' : '';
|
||||
['addMilestoneBtn', 'addTodoBtn', 'templateBtn', 'honorarBtn', 'autoScheduleBtn',
|
||||
'addTodoBtnList', 'addMilestoneBtnList', 'templateBtnList', 'honorarBtnList', 'autoScheduleBtnList']
|
||||
.forEach(id => {
|
||||
const btn = document.getElementById(id);
|
||||
if(btn) btn.style.display = locked ? 'none' : '';
|
||||
});
|
||||
|
||||
// "Dateien" (keine Projektnummer, kein sinnvoller Prüf-Workflow),
|
||||
// "Leistung" (echte Abrechnung/Auftragsstatus-abhängig) und "Protokolle"
|
||||
// (hängt wie Dateien an project.nr) ergeben für einen Terminplan ohne
|
||||
// echtes Projekt keinen Sinn — Bereichsknöpfe ausblenden.
|
||||
document.querySelectorAll('#planNav .plan-nav-item[data-section="dateien"], #planNav .plan-nav-item[data-section="leistung"], #planNav .plan-nav-item[data-section="protokolle"]')
|
||||
.forEach(btn => { btn.style.display = sandbox ? 'none' : ''; });
|
||||
|
||||
// Gantt-PDF-Export (js/pdf.js, exportSandboxPlanPDF()) gibt es nur für
|
||||
// Sandbox-Pläne der Planung — ein echtes Projekt hat kein Äquivalent.
|
||||
const sandboxPdfBtn = document.getElementById('sandboxPdfBtn');
|
||||
if(sandboxPdfBtn) sandboxPdfBtn.style.display = sandbox ? '' : 'none';
|
||||
|
||||
// GANTT inkl. TODOs (rendert am Ende auch Aufgabenliste und Dashboard mit)
|
||||
fitPlanGanttRange();
|
||||
|
||||
// Sichtbaren Bereich wählen (URL-Fragment, sonst Dashboard) — siehe
|
||||
// js/plan/plan-sections.js. Steht bewusst am Ende: erst wenn alle
|
||||
// Bereiche befüllt sind, wird einer davon eingeblendet.
|
||||
app.initPlanSections();
|
||||
}
|
||||
|
||||
// Entfernt eine gelöschte ID aus allen dependsOn-Listen (TODOs und Meilensteine)
|
||||
@@ -166,58 +287,84 @@ window.PlanApp = {
|
||||
});
|
||||
}
|
||||
|
||||
// Beteiligte des Projekts (Haupt + Optional) mit E-Mail aus der globalen Liste
|
||||
// planRoleClass: Kopie von roleClass() aus js/board/board.js (dort nicht
|
||||
// geladen) — erzeugt dieselben CSS-Klassen wie im Board, damit die
|
||||
// Avatar-Kreise hier dieselben Rollenfarben (.board-avatar in styles.css)
|
||||
// nutzen. Parameter: role (String). Rückgabe: Klassen-String.
|
||||
function planRoleClass(role){
|
||||
return 'role-' + String(role || '').toLowerCase().replace(/\s+/g, '-');
|
||||
}
|
||||
|
||||
// Beteiligte des Projekts (Haupt + Optional) mit E-Mail aus der globalen
|
||||
// Liste, als überlappender Avatar-Stapel für die kompakte Titelzeile
|
||||
// (dieselben Kreise/Farben wie im Projektboard-Detailpanel, siehe
|
||||
// .board-avatar in styles.css). Voller Name + Rolle stehen im Tooltip.
|
||||
function renderProjectAssignees(){
|
||||
const assignees = projectAssignees(app.project, app.week);
|
||||
if(assignees.length === 0) return '<span class="hint">Keine Zuordnungen im Projektboard.</span>';
|
||||
if(assignees.length === 0) return '';
|
||||
return assignees.map(a => {
|
||||
const cls = a.state === 1 ? 'main' : a.state === 2 ? 'optional' : '';
|
||||
return `<span class="todo-assignee ${cls}">${esc(a.person.name)}${a.role ? ' (' + esc(a.role) + ')' : ''}</span>`;
|
||||
const title = (a.person.fullName || a.person.name) + (a.role ? ' · ' + a.role : '');
|
||||
return `<span class="board-avatar ${planRoleClass(a.role)} state-${a.state}" title="${esc(title)}">${esc(a.person.name)}</span>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// Speichern: nur diese Sitzung; globale Personenliste nicht überschreiben.
|
||||
// In-Flight-Schutz: läuft bereits ein Save, wird kein zweiter POST mit
|
||||
// identischer Revisions-Basis abgeschickt (der würde fälschlich als 409-
|
||||
// Konflikt gemeldet). Stattdessen wird ein Folgesave eingereiht, der nach
|
||||
// Abschluss des laufenden Saves mit der dann frischen Revision nachzieht.
|
||||
let savePlanRunning = false;
|
||||
let savePlanQueued = false;
|
||||
// savePlan: merkt die Sitzung des Projektbereichs als geändert vor und
|
||||
// stößt den gewöhnlichen, entprellten Speicherlauf an (js/store.js).
|
||||
//
|
||||
// Früher schrieb der Projektbereich mit einem eigenen sendWeekToServer()
|
||||
// direkt zum Server, weil er eine eigene Seite war und store.js dort gar
|
||||
// nicht geladen wurde. In einer Seite wären daraus zwei Schreiber auf
|
||||
// dieselbe Sitzung geworden: beide senden dieselbe Revisions-Basis, und der
|
||||
// langsamere bekäme einen scheinbaren 409-Konflikt gemeldet. Deshalb geht
|
||||
// jetzt auch der Projektbereich durch syncAllToServer() — mitsamt dessen
|
||||
// In-Flight-Schutz, Wiederholung nach Netzfehlern, Konfliktbehandlung und
|
||||
// Anmelde-Prüfung. markWeekDirty() bekommt bewusst app.week.label statt
|
||||
// save() zu rufen: save() markiert die AKTUELL gewählte Sitzung, und ein
|
||||
// Deep-Link kann den Projektbereich auf eine andere gestellt haben.
|
||||
function savePlan(){
|
||||
if(!app.week || !app.project) return;
|
||||
if(savePlanRunning){ savePlanQueued = true; return; }
|
||||
savePlanRunning = true;
|
||||
sendWeekToServer(app.week)
|
||||
.then(res => {
|
||||
// Lokale Revisions-Basis aktualisieren, damit spätere eigene Saves
|
||||
// nicht fälschlich als Konflikt gemeldet werden.
|
||||
if(res && res.updatedAt) app.week.updatedAt = res.updatedAt;
|
||||
})
|
||||
.catch(err => {
|
||||
if(err && err.conflict){
|
||||
showToast('Konflikt: Die Sitzung wurde zwischenzeitlich geändert. Bitte Seite neu laden.');
|
||||
console.error('Speichern fehlgeschlagen (Konflikt):', err.message || err);
|
||||
} else {
|
||||
console.error('Speichern fehlgeschlagen:', err);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
savePlanRunning = false;
|
||||
if(savePlanQueued){ savePlanQueued = false; savePlan(); }
|
||||
});
|
||||
markWeekDirty(app.week.label);
|
||||
scheduleSessionSync();
|
||||
}
|
||||
|
||||
// saveGlobalPeople: schreibt die globale Personenliste der Abteilung als
|
||||
// JSON-Array auf den Server. Auch eine leere Liste wird gespeichert, damit
|
||||
// das Löschen der letzten Person erhalten bleibt.
|
||||
function saveGlobalPeople(){
|
||||
const people = db.globalPeople[app.department] || [];
|
||||
sendPeopleToServer(people).catch(err => console.error('Speichern Personen fehlgeschlagen:', err));
|
||||
// userIdByPersonIdPromise: Cache der Zuordnung Person-ID (String, z. B.
|
||||
// "p_max_mustermann") -> numerische users.id. Seit der Ablösung von
|
||||
// people/person_roles/person_absences gibt es keinen /save-people-Schreibpfad
|
||||
// mehr; Abwesenheiten werden direkt gegen /users/:id/absences geschrieben
|
||||
// (siehe plan-modals.js/plan-drag.js), wofür die numerische User-ID benötigt
|
||||
// wird. Lazy geladen über GET /users (liefert seit Phase 1 auch personId)
|
||||
// und für die Lebensdauer der Seite zwischengespeichert.
|
||||
let userIdByPersonIdPromise = null;
|
||||
function loadUserIdByPersonId(){
|
||||
if(!userIdByPersonIdPromise){
|
||||
userIdByPersonIdPromise = fetch(baseUrl() + '/users')
|
||||
.then(r => { if(!r.ok) throw new Error('Server HTTP '+r.status); return r.json(); })
|
||||
.then(data => {
|
||||
const map = {};
|
||||
(Array.isArray(data.users) ? data.users : []).forEach(u => {
|
||||
if(u.personId) map[u.personId] = u.id;
|
||||
});
|
||||
return map;
|
||||
})
|
||||
.catch(err => {
|
||||
// Cache zurücksetzen, damit ein späterer Aufruf es erneut versucht.
|
||||
userIdByPersonIdPromise = null;
|
||||
throw err;
|
||||
});
|
||||
}
|
||||
return userIdByPersonIdPromise;
|
||||
}
|
||||
|
||||
// getUserIdForPersonId: liefert die numerische users.id für eine Personen-ID
|
||||
// oder null, falls keine passende (aktive) Benutzer-Zuordnung existiert (z. B.
|
||||
// eine Person ohne Login-Konto). Rückgabe: Promise.
|
||||
function getUserIdForPersonId(personId){
|
||||
return loadUserIdByPersonId().then(map => (map && Object.prototype.hasOwnProperty.call(map, personId)) ? map[personId] : null);
|
||||
}
|
||||
|
||||
// Person in der globalen Liste finden (dort werden Abwesenheiten gepflegt)
|
||||
function findGlobalPerson(personId){
|
||||
return (db.globalPeople[app.department] || []).find(p => p.id === personId) || null;
|
||||
return (db.globalPeople || []).find(p => p.id === personId) || null;
|
||||
}
|
||||
|
||||
// countPersonAbsences: zählt die Abwesenheiten einer Person anhand der
|
||||
@@ -247,14 +394,13 @@ window.PlanApp = {
|
||||
// (plan-drag, plan-modals, plan-schedule) sie direkt nutzen können.
|
||||
app.ensurePlanLocal = ensurePlanLocal;
|
||||
app.isLocked = isLocked;
|
||||
app.isSandboxPlan = isSandboxPlan;
|
||||
app.removeDependencyRefs = removeDependencyRefs;
|
||||
app.renderProjectAssignees = renderProjectAssignees;
|
||||
app.planRoleClass = planRoleClass;
|
||||
app.savePlan = savePlan;
|
||||
app.saveGlobalPeople = saveGlobalPeople;
|
||||
app.getUserIdForPersonId = getUserIdForPersonId;
|
||||
app.findGlobalPerson = findGlobalPerson;
|
||||
app.countPersonAbsences = countPersonAbsences;
|
||||
app.showToast = showToast;
|
||||
|
||||
// Seitenstart: erst initialisieren, wenn das DOM geladen ist
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
})();
|
||||
Reference in new issue
Block a user