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:
erikandClaude Sonnet 5 committed 2026-10-01 10:54:43 +02:00
1 parent 6f18848f86
commit 11eabe42ae
275 files changed
+71952 -3029

No files matched your search

+287 -141
View File
@@ -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);
})();