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
+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