Wandtafel-Dashboard, Dateibereinigung und Auslastungs-Vereinfachung
Neue Wandtafel (Touch-Dashboard für Kiosk-Anzeige): Auslastung/Besprochen je Gewerk, Aufgaben-Kennzahlen, Ereignis-Feed nach Art gruppiert, vollständige Projektliste zum Scrollen, automatischer Rücksprung nach Untätigkeit mit sichtbarem Countdown. Eigene Kiosk-Rolle ohne Fachrolle und ohne Schreibrechte. Auslastung: Möglichkeit zum Wechsel der ausgewerteten Sitzung entfernt. Aufräumen nicht mehr benötigter Dateien, README auf aktuellen Stand gebracht. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
bc30f2900b
commit
db63a29365
47 files changed
+16486
-3768
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
|
||||
|
||||
@@ -175,7 +175,7 @@
|
||||
|
||||
// renderFilesSummary: füllt die Kompakt-Vorschau der "Dateien & Freigaben"-
|
||||
// Karte unter dem GANTT (Cockpit-Layout, #planFilesSummary in
|
||||
// Test.html) aus filesCache — Name der zuletzt hochgeladenen Datei
|
||||
// App.html) aus filesCache — Name der zuletzt hochgeladenen Datei
|
||||
// (Server liefert /project-files bereits nach created_at DESC sortiert,
|
||||
// filesCache[0] ist also die neueste) sowie einen Hinweis auf noch
|
||||
// ausstehende Prüfungen.
|
||||
|
||||
@@ -91,7 +91,7 @@
|
||||
}
|
||||
|
||||
// renderPlanWarnings: füllt das Hinweisband über dem GANTT
|
||||
// (#planWarnings in Test.html) mit den Urlaubs-Kollisionen des
|
||||
// (#planWarnings in App.html) mit den Urlaubs-Kollisionen des
|
||||
// Projekts — Aufgaben, deren Zeitraum in die Abwesenheit einer zugeordneten
|
||||
// Person fällt. Die Prüfung selbst liegt in plan-build-gantt.js
|
||||
// (app.absenceConflictsForTodo), damit Balken-Markierung und diese Liste
|
||||
@@ -131,7 +131,7 @@
|
||||
}
|
||||
|
||||
// renderPlanKpis: füllt die KPI-Kachelzeile über dem GANTT (Cockpit-Layout,
|
||||
// #planKpiRow in Test.html) mit vier Kennzahlen, die alle aus den
|
||||
// #planKpiRow in App.html) mit vier Kennzahlen, die alle aus den
|
||||
// bereits vorhandenen Plan-Daten abgeleitet sind (keine neue Datenquelle):
|
||||
// Gesamtfortschritt (Ø-Fortschritt aller TODOs), offene Aufgaben (inkl.
|
||||
// überfällige), nächster anstehender Meilenstein und Team-Auslastung
|
||||
|
||||
@@ -94,7 +94,7 @@
|
||||
}
|
||||
|
||||
// renderPlanHistory: füllt die Kompakt-Karte "Verlauf" (#planHistorySummary
|
||||
// in Test.html) — Anzahl der Einträge und die Sitzung des letzten
|
||||
// in App.html) — Anzahl der Einträge und die Sitzung des letzten
|
||||
// Kommentarwechsels. Wird von renderPlanPage() (plan-state.js) nach dem
|
||||
// Laden aufgerufen. Keine Parameter, keine Rückgabe.
|
||||
window.renderPlanHistory = function(){
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
// alle echten Leistungsphasen (inkl. LPH 9), aber ohne die Sammelkategorie
|
||||
// "W+M". Entspricht AUFWAND_LPS in js/load/load.js, hier separat definiert,
|
||||
// weil load.js auf der Projektplan-Seite bewusst nicht geladen wird (siehe
|
||||
// Kopfkommentar in Test.html). Bewusst per Filter statt per
|
||||
// Kopfkommentar in App.html). Bewusst per Filter statt per
|
||||
// Index-Schnitt, damit eine spätere Ergänzung von LPHS nicht stillschweigend
|
||||
// durchs Raster fällt.
|
||||
const PLAN_ACTIVE_LPS = LPHS.filter(l => l !== 'W+M');
|
||||
|
||||
@@ -615,18 +615,6 @@
|
||||
openTodoModal(null);
|
||||
};
|
||||
|
||||
// updateTodo: setzt ein einzelnes Feld (z. B. done) einer Aufgabe direkt
|
||||
// und speichert sowie rendert neu. Parameter: idx (Index), field
|
||||
// (Feldname), value (neuer Wert).
|
||||
window.updateTodo = function(idx, field, value){
|
||||
if(app.isLocked()) return;
|
||||
const plan = app.ensurePlanLocal();
|
||||
if(!plan.todos[idx]) return;
|
||||
plan.todos[idx][field] = value;
|
||||
app.savePlan();
|
||||
renderPlanGantt();
|
||||
};
|
||||
|
||||
// toggleTodoDone: schaltet den Erledigt-Status der Aufgabe am Index um,
|
||||
// speichert und rendert neu.
|
||||
window.toggleTodoDone = function(idx){
|
||||
|
||||
@@ -144,17 +144,20 @@
|
||||
};
|
||||
|
||||
// gruppenReihenfolge: liefert die vorkommenden Gruppennamen in der
|
||||
// Reihenfolge ihres ersten Auftretens unter den Punkten (array_order) —
|
||||
// "Ohne Gruppe" (leerer String) immer zuerst, wenn vorhanden.
|
||||
// Reihenfolge ihres ersten Auftretens unter den Punkten (array_order).
|
||||
// Jeder Punkt gehört jetzt zu einer Gruppe (siehe fuegeNeueGruppeHinzu()/
|
||||
// "+ Neue Gruppe" unten) — eine frühere Sonderrolle "Ohne Gruppe" (leerer
|
||||
// String, immer zuerst) gibt es nicht mehr; ein einzelner Punkt mit leerer
|
||||
// Gruppe (nur noch aus Altbeständen von vor dieser Umstellung möglich)
|
||||
// bekommt ganz normal eine eigene, leer betitelte Gruppenzeile wie jede
|
||||
// andere — kein Sonderfall im Code nötig.
|
||||
function gruppenReihenfolge(){
|
||||
const namen = [];
|
||||
let hatOhneGruppe = false;
|
||||
(currentProtokoll.punkte || []).forEach(p => {
|
||||
const g = String(p.gruppe || '').trim();
|
||||
if(!g){ hatOhneGruppe = true; return; }
|
||||
if(!namen.includes(g)) namen.push(g);
|
||||
});
|
||||
return (hatOhneGruppe ? [''] : []).concat(namen);
|
||||
return namen;
|
||||
}
|
||||
|
||||
// ---------- Nachträgliches Umordnen ----------
|
||||
@@ -162,15 +165,13 @@
|
||||
// Die Reihenfolge in `currentProtokoll.punkte` (= array_order) bestimmt
|
||||
// sowohl die Anzeige-Reihenfolge als auch — über berechneProtokollNummern()
|
||||
// — die Nummern. Zwei getrennte, aufeinander abgestimmte Stellschrauben:
|
||||
// ein Punkt lässt sich nur INNERHALB seiner eigenen Gruppe (bzw. innerhalb
|
||||
// "Ohne Gruppe") verschieben (die Zeile steht dort ohnehin nur im passenden
|
||||
// Abschnitt), eine ganze Gruppe lässt sich als Block gegenüber den anderen
|
||||
// Gruppen verschieben — das ändert, welche Gruppe die Nummer "1" bekommt.
|
||||
// "Ohne Gruppe" bleibt bewusst immer der erste Abschnitt (siehe
|
||||
// gruppenReihenfolge()) und ist deshalb hier nicht mitverschiebbar.
|
||||
// ein Punkt lässt sich nur INNERHALB seiner eigenen Gruppe verschieben (die
|
||||
// Zeile steht dort ohnehin nur im passenden Abschnitt), eine ganze Gruppe
|
||||
// lässt sich als Block gegenüber den anderen Gruppen verschieben — das
|
||||
// ändert, welche Gruppe die Nummer "1" bekommt.
|
||||
|
||||
// verschiebePunkt: vertauscht einen Punkt mit seinem Vorgänger/Nachfolger
|
||||
// INNERHALB derselben Gruppe (bzw. "Ohne Gruppe"). Parameter: idx (Index in
|
||||
// INNERHALB derselben Gruppe. Parameter: idx (Index in
|
||||
// currentProtokoll.punkte), richtung (-1 = nach oben, +1 = nach unten).
|
||||
// Keine Rückgabe; rendert nur den Punkte-Block neu.
|
||||
window.verschiebePunkt = function(idx, richtung){
|
||||
@@ -192,24 +193,24 @@
|
||||
// verschiebeGruppe: verschiebt eine ganze Gruppe (alle ihre Punkte als
|
||||
// Block, ihre eigene Reihenfolge bleibt untereinander erhalten) gegenüber
|
||||
// den anderen Gruppen — ändert damit auch die Gruppennummer. Baut die
|
||||
// Punkteliste dafür komplett neu auf: "Ohne Gruppe" zuerst (unverändert),
|
||||
// danach jede Gruppe in der neuen Reihenfolge. Parameter: gruppe (Name),
|
||||
// richtung (-1 = nach vorn, +1 = nach hinten). Keine Rückgabe.
|
||||
// Punkteliste dafür komplett in der neuen Gruppenreihenfolge neu auf.
|
||||
// Parameter: gruppe (Name), richtung (-1 = nach vorn, +1 = nach hinten).
|
||||
// Keine Rückgabe.
|
||||
window.verschiebeGruppe = function(gruppe, richtung){
|
||||
if(!currentProtokoll || app.isLocked()) return;
|
||||
const punkte = currentProtokoll.punkte;
|
||||
const namen = [];
|
||||
punkte.forEach(p => {
|
||||
const g = String(p.gruppe || '').trim();
|
||||
if(g && !namen.includes(g)) namen.push(g);
|
||||
if(!namen.includes(g)) namen.push(g);
|
||||
});
|
||||
const pos = namen.indexOf(gruppe);
|
||||
const pos = namen.indexOf(String(gruppe || '').trim());
|
||||
const zielPos = pos + richtung;
|
||||
if(pos === -1 || zielPos < 0 || zielPos >= namen.length) return;
|
||||
const tmpName = namen[pos];
|
||||
namen[pos] = namen[zielPos];
|
||||
namen[zielPos] = tmpName;
|
||||
const neu = punkte.filter(p => !String(p.gruppe || '').trim());
|
||||
const neu = [];
|
||||
namen.forEach(g => {
|
||||
punkte.filter(p => String(p.gruppe || '').trim() === g).forEach(p => neu.push(p));
|
||||
});
|
||||
@@ -348,7 +349,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){
|
||||
@@ -538,89 +539,275 @@
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// renderPunkteBlockHtml: baut nur den Punkte-Block (in Abschnitten je
|
||||
// Gruppe) — eigene Funktion, weil er nach einer Gruppenänderung isoliert
|
||||
// renderPunkteBlockHtml: baut nur den Punkte-Block — EINE durchgehende
|
||||
// Tabelle (dieselben Spalten wie der PDF-Export, js/pdf.js), mit einer
|
||||
// vollbreiten Gruppen-Kopfzeile je Abschnitt statt eigener Tabellen je
|
||||
// Gruppe — eigene Funktion, weil sie nach einer Gruppenänderung isoliert
|
||||
// neu gerendert wird, ohne den Rest der Detailansicht (und damit den Fokus
|
||||
// in anderen Feldern) anzufassen.
|
||||
// PROTOKOLL_GRUPPEN_FARBEN/protokollGruppeFarbe: der Farbbalken der
|
||||
// Gruppen-Kopfzeile wird — wie projektFarbe() in js/load/load.js für die
|
||||
// Auslastung — aus dem Gruppennamen gehasht statt fest zugeordnet, damit
|
||||
// eine Gruppe ihre Farbe behält, egal an welcher Position sie steht.
|
||||
// Eigene, von den Status-Farben (Amber/Grün/Teal/Rot) getrennte Palette,
|
||||
// damit eine Gruppenfarbe nie wie ein Status aussieht.
|
||||
const PROTOKOLL_GRUPPEN_FARBEN = ['#5b63d6', '#8b5cf6', '#c2419a', '#4f8ef7', '#7a52c7'];
|
||||
function protokollGruppeFarbe(name){
|
||||
let hash = 0;
|
||||
const s = String(name || '');
|
||||
for(let i = 0; i < s.length; i++) hash = (hash * 31 + s.charCodeAt(i)) >>> 0;
|
||||
return PROTOKOLL_GRUPPEN_FARBEN[hash % PROTOKOLL_GRUPPEN_FARBEN.length];
|
||||
}
|
||||
|
||||
// ausgewaehlterPunkt: der gerade im Schreibbereich geöffnete Punkt, als
|
||||
// OBJEKT-Referenz (nicht als Index). Die Punkte wandern beim Verschieben
|
||||
// und beim Umsortieren einer ganzen Gruppe durch das Array; eine gemerkte
|
||||
// Zahl zeigte danach auf einen anderen Punkt, die Referenz überlebt jede
|
||||
// dieser Umstellungen unverändert. Nur ein Speichern tauscht die Objekte
|
||||
// aus (der Server liefert frische) — dort wird die Auswahl über ihre
|
||||
// Position wiederhergestellt, siehe speichereProtokoll().
|
||||
let ausgewaehlterPunkt = null;
|
||||
|
||||
// waehlePunkt: öffnet einen Punkt im Schreibbereich. Parameter: idx (Index
|
||||
// in currentProtokoll.punkte). Keine Rückgabe.
|
||||
window.waehlePunkt = function(idx){
|
||||
if(!currentProtokoll || !currentProtokoll.punkte[idx]) return;
|
||||
ausgewaehlterPunkt = currentProtokoll.punkte[idx];
|
||||
const block = document.getElementById('protokollPunkteBlock');
|
||||
if(block) block.innerHTML = renderPunkteBlockHtml();
|
||||
};
|
||||
|
||||
// listenUnterzeile: die kleine zweite Zeile eines Listeneintrags —
|
||||
// Zuständig und Termin, so weit vorhanden. Parameter: punkt. Rückgabe:
|
||||
// String.
|
||||
function listenUnterzeile(punkt){
|
||||
const teile = [];
|
||||
const zust = String(punkt.zustaendig || '').trim();
|
||||
if(zust) teile.push(zust);
|
||||
const termin = formatDateDE(parseDateISO(punkt.termin));
|
||||
if(termin) teile.push(termin);
|
||||
return teile.join(' · ') || 'noch nichts erfasst';
|
||||
}
|
||||
|
||||
// aktualisiereListenZeile: schreibt Thema, Zuständig/Termin und den
|
||||
// Status-Punkt eines Listeneintrags neu — gezielt am vorhandenen Element,
|
||||
// OHNE den Block neu zu rendern. Nötig, weil im Schreibbereich getippter
|
||||
// Text sofort links in der Liste erscheinen soll, ein Neu-Rendern dabei
|
||||
// aber den Eingabefokus (und die Schreibmarke) zerstören würde — dieselbe
|
||||
// Begründung wie im Datei-Kopfkommentar. Parameter: idx. Keine Rückgabe.
|
||||
function aktualisiereListenZeile(idx){
|
||||
const punkt = currentProtokoll && currentProtokoll.punkte[idx];
|
||||
const zeile = document.getElementById('protokollListeZeile_' + idx);
|
||||
if(!punkt || !zeile) return;
|
||||
const titel = zeile.querySelector('.titel');
|
||||
if(titel){
|
||||
const thema = String(punkt.thema || '').trim();
|
||||
titel.textContent = thema || 'Ohne Thema';
|
||||
titel.classList.toggle('ist-leer', !thema);
|
||||
}
|
||||
const sub = zeile.querySelector('.sub');
|
||||
if(sub) sub.textContent = listenUnterzeile(punkt);
|
||||
const dot = zeile.querySelector('.protokoll-punkt-dot');
|
||||
if(dot) dot.setAttribute('data-status', punkt.status || 'offen');
|
||||
}
|
||||
|
||||
// renderPunkteBlockHtml: baut den Punkte-Block als ZWEI nebeneinander
|
||||
// liegende Bereiche (Eriks Auswahl „Liste + Schreibbereich" aus dem
|
||||
// Protokoll-schreiben-Mockup) statt der früheren durchgehenden Tabelle:
|
||||
// links eine schmale, überfliegbare Liste aller Punkte (nach Gruppen
|
||||
// gegliedert, je Zeile Nummer, Thema, Status-Punkt und darunter
|
||||
// Zuständig/Termin), rechts der Schreibbereich für den EINEN gerade
|
||||
// ausgewählten Punkt mit großem Thema-Feld, mehrzeiligem Textfeld und
|
||||
// Zuständig/Termin als ordentlichen Formularfeldern.
|
||||
//
|
||||
// Warum weg von der Tabelle: ein Protokollpunkt ist ein kleiner Fließtext,
|
||||
// keine Tabellenzelle. In der Spalte blieben für den Text rund 60 % der
|
||||
// Breite bei drei, vier sichtbaren Zeilen; Thema und Text klebten
|
||||
// übereinander, und die Formatierungsleiste saß eingeklemmt dazwischen.
|
||||
// Der Schreibbereich gibt dem Text eine lesbare Zeilenlänge und jedem Feld
|
||||
// eine eigene Beschriftung.
|
||||
//
|
||||
// Eigene Funktion, weil sie nach jeder strukturellen Änderung (Punkt/Gruppe
|
||||
// anlegen, löschen, verschieben, auswählen) isoliert neu gerendert wird,
|
||||
// ohne den Rest der Detailansicht anzufassen. Reine Feldänderungen rendern
|
||||
// dagegen NICHT neu, sondern aktualisieren die Liste gezielt über
|
||||
// aktualisiereListenZeile() oben.
|
||||
function renderPunkteBlockHtml(){
|
||||
const p = currentProtokoll;
|
||||
const locked = app.isLocked();
|
||||
const gruppen = gruppenReihenfolge();
|
||||
const gruppenNamen = gruppen.filter(g => g); // ohne '' für die Datalist-Vorschläge
|
||||
|
||||
const nummern = window.berechneProtokollNummern(p.punkte);
|
||||
const gruppenHtml = gruppen.map(gruppe => {
|
||||
|
||||
// Auswahl nachziehen: fehlt sie oder zeigt sie auf einen inzwischen
|
||||
// gelöschten Punkt, rückt der erste vorhandene nach.
|
||||
let aktivIdx = p.punkte.indexOf(ausgewaehlterPunkt);
|
||||
if(aktivIdx === -1){
|
||||
aktivIdx = p.punkte.length ? 0 : -1;
|
||||
ausgewaehlterPunkt = aktivIdx >= 0 ? p.punkte[aktivIdx] : null;
|
||||
}
|
||||
|
||||
const offeneAnzahl = p.punkte.filter(x => (x.status || 'offen') === 'offen').length;
|
||||
const kopfText = p.punkte.length
|
||||
? `${p.punkte.length} ${p.punkte.length === 1 ? 'Punkt' : 'Punkte'} · ${offeneAnzahl} offen`
|
||||
: 'Noch keine Punkte';
|
||||
|
||||
const listeHtml = gruppen.map(gruppe => {
|
||||
const punkteDerGruppe = p.punkte
|
||||
.map((punkt, idx) => ({ punkt, idx }))
|
||||
.filter(x => String(x.punkt.gruppe || '').trim() === gruppe);
|
||||
const titel = gruppe ? esc(gruppe) : 'Ohne Gruppe';
|
||||
const addBtn = locked ? '' : `<span class="protokoll-gruppe-add" onclick="fuegePunktHinzu('${esc(gruppe)}')">+ Punkt</span>`;
|
||||
// "Ohne Gruppe" bleibt fest der erste Abschnitt (siehe gruppenReihenfolge()) —
|
||||
// nur echte Gruppen lassen sich gegeneinander verschieben.
|
||||
let moveBtns = '';
|
||||
if(gruppe && !locked){
|
||||
const gPos = gruppenNamen.indexOf(gruppe);
|
||||
const hochDisabled = gPos <= 0 ? 'disabled' : '';
|
||||
const runterDisabled = gPos >= gruppenNamen.length - 1 ? 'disabled' : '';
|
||||
if(!locked){
|
||||
const gPos = gruppen.indexOf(gruppe);
|
||||
moveBtns = `<span class="protokoll-gruppe-move">
|
||||
<button type="button" class="secondary small" ${hochDisabled} onclick="verschiebeGruppe('${esc(gruppe)}', -1)" title="Gruppe nach vorn">↑</button>
|
||||
<button type="button" class="secondary small" ${runterDisabled} onclick="verschiebeGruppe('${esc(gruppe)}', 1)" title="Gruppe nach hinten">↓</button>
|
||||
<button type="button" class="secondary small" ${gPos <= 0 ? 'disabled' : ''} onclick="verschiebeGruppe('${esc(gruppe)}', -1)" title="Gruppe nach vorn">↑</button>
|
||||
<button type="button" class="secondary small" ${gPos >= gruppen.length - 1 ? 'disabled' : ''} onclick="verschiebeGruppe('${esc(gruppe)}', 1)" title="Gruppe nach hinten">↓</button>
|
||||
</span>`;
|
||||
}
|
||||
const zeilen = punkteDerGruppe.map(({ punkt, idx }, pos) => renderPunktZeileHtml(punkt, idx, nummern[idx], pos === 0, pos === punkteDerGruppe.length - 1)).join('');
|
||||
return `<div class="protokoll-gruppe">
|
||||
<div class="protokoll-gruppe-head"><span class="protokoll-gruppe-name${gruppe ? '' : ' is-leer'}">${titel}</span>${moveBtns}${addBtn}</div>
|
||||
${zeilen || '<span class="hint">Keine Punkte in dieser Gruppe.</span>'}
|
||||
const kopf = `<div class="protokoll-liste-gruppe" style="--protokoll-gruppe-farbe: ${protokollGruppeFarbe(gruppe)}">
|
||||
<input class="protokoll-gruppe-heading" value="${esc(gruppe)}" placeholder="Gruppenname …" ${locked ? 'disabled' : ''}
|
||||
onchange="currentProtokollUmbenenneGruppe('${esc(gruppe)}', this.value)">
|
||||
<span class="count">${punkteDerGruppe.length}</span>${moveBtns}
|
||||
</div>`;
|
||||
const zeilen = punkteDerGruppe.map(({ punkt, idx }) =>
|
||||
renderListenZeileHtml(punkt, idx, nummern[idx], idx === aktivIdx)).join('');
|
||||
const addZeile = locked ? '' : `<div class="protokoll-liste-neu" onclick="fuegePunktHinzu('${esc(gruppe)}')">+ Punkt</div>`;
|
||||
return kopf + zeilen + addZeile;
|
||||
}).join('');
|
||||
|
||||
const datalist = `<datalist id="protokollGruppenListe">${gruppenNamen.map(g => `<option value="${esc(g)}">`).join('')}</datalist>`;
|
||||
const addOhneGruppe = locked ? '' : `<div class="add-row-btn" onclick="fuegePunktHinzu('')">+ Punkt (ohne Gruppe)</div>`;
|
||||
const leerHinweis = listeHtml ? '' : '<div class="protokoll-liste-leer">Noch keine Punkte erfasst.</div>';
|
||||
const neueGruppe = locked ? '' : `<div class="protokoll-liste-neu ist-gruppe" onclick="fuegeNeueGruppeHinzu()">+ Neue Gruppe</div>`;
|
||||
|
||||
return `<div class="protokoll-block-head"><h4>Punkte</h4></div>
|
||||
${gruppenHtml || '<span class="hint">Noch keine Punkte.</span>'}
|
||||
${datalist}
|
||||
${addOhneGruppe}
|
||||
<div class="protokoll-split">
|
||||
<div class="protokoll-liste">
|
||||
<div class="protokoll-liste-kopf">${esc(kopfText)}</div>
|
||||
${listeHtml}${leerHinweis}${neueGruppe}
|
||||
</div>
|
||||
${renderSchreibbereichHtml(aktivIdx, nummern)}
|
||||
</div>
|
||||
<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 info"></span>Info (läuft nicht weiter mit)</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderPunktZeileHtml(punkt, idx, nummer, istErste, istLetzte){
|
||||
// renderListenZeileHtml: ein Eintrag der linken Liste. Bewusst knapp —
|
||||
// Nummer, Thema, Status-Punkt und darunter Zuständig/Termin; alles
|
||||
// Weitere steht im Schreibbereich. Parameter: punkt, idx, nummer,
|
||||
// istAktiv. Rückgabe: HTML-String.
|
||||
function renderListenZeileHtml(punkt, idx, nummer, istAktiv){
|
||||
const thema = String(punkt.thema || '').trim();
|
||||
const neuChip = punkt.neu ? '<span class="protokoll-chip-neu">Neu</span>' : '';
|
||||
return `<div class="protokoll-liste-zeile${istAktiv ? ' ist-aktiv' : ''}" id="protokollListeZeile_${idx}" onclick="waehlePunkt(${idx})">
|
||||
<span class="nr">${esc(nummer)}</span>
|
||||
<span class="titel${thema ? '' : ' ist-leer'}">${esc(thema || 'Ohne Thema')}</span>
|
||||
<span class="protokoll-punkt-dot" data-status="${esc(punkt.status || 'offen')}" title="${esc(PROTOKOLL_PUNKT_STATUS_LABEL[punkt.status || 'offen'] || 'Offen')}"></span>
|
||||
<span class="sub">${esc(listenUnterzeile(punkt))}</span>
|
||||
${neuChip}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// PROTOKOLL_PUNKT_STATUS_LABEL: Anzeigetext je Status im Status-Umschalter
|
||||
// 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' };
|
||||
|
||||
// renderSchreibbereichHtml: die rechte Hälfte — der EINE gerade
|
||||
// ausgewählte Punkt, mit echtem Platz zum Schreiben. Parameter: idx (Index
|
||||
// in currentProtokoll.punkte, -1 = kein Punkt vorhanden), nummern (das
|
||||
// Ergebnis von berechneProtokollNummern()). Rückgabe: HTML-String.
|
||||
function renderSchreibbereichHtml(idx, nummern){
|
||||
const locked = app.isLocked();
|
||||
if(idx < 0){
|
||||
return `<div class="protokoll-schreiben ist-leer">
|
||||
<p>Noch kein Punkt angelegt.</p>
|
||||
${locked ? '' : '<p class="hint">Links über „+ Neue Gruppe" beginnen — jeder Punkt gehört zu einer Gruppe.</p>'}
|
||||
</div>`;
|
||||
}
|
||||
const punkt = currentProtokoll.punkte[idx];
|
||||
const terminDisplay = formatDateDE(parseDateISO(punkt.termin)) || '';
|
||||
const neuChip = punkt.neu ? '<span class="protokoll-chip-neu">Neu</span>' : '';
|
||||
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}">
|
||||
<div class="protokoll-punkt-top-row">
|
||||
<span class="protokoll-punkt-nr" title="Errechnet sich automatisch aus Gruppe und Reihenfolge">${esc(nummer)}</span>
|
||||
${moveBtns}
|
||||
<input type="text" class="protokoll-punkt-gruppe" list="protokollGruppenListe" value="${esc(punkt.gruppe)}"
|
||||
placeholder="Gruppe (z. B. ELT, HLS) …" ${locked ? 'disabled' : ''}
|
||||
onchange="currentProtokollSetzePunktGruppe(${idx}, this.value)">
|
||||
${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>
|
||||
${locked ? '' : `<span class="protokoll-row-remove" onclick="entfernePunkt(${idx})" title="Punkt entfernen">✕</span>`}
|
||||
// entfaelltNaechstes: vom Server gesetzt, wenn dieser Punkt beim
|
||||
// Fortschreiben aus einem erledigten Punkt des vorigen Protokolls
|
||||
// entstand — seine Gnadenrunde (Eriks Wunsch: ein erledigter Punkt
|
||||
// taucht noch EIN Protokoll lang auf, bevor er entfällt). Nie vom
|
||||
// Client gesetzt (siehe PUT .../:id, server.js).
|
||||
const entfaelltChip = punkt.entfaelltNaechstes
|
||||
? '<span class="protokoll-chip-entfaellt" title="Dieser Punkt wurde bereits als erledigt fortgeschrieben — im übernächsten Protokoll taucht er nicht mehr auf.">Entfällt im nächsten Protokoll</span>'
|
||||
: '';
|
||||
// themaGeaendert/textGeaendert: vom Server gesetzt (siehe
|
||||
// loadProtokollDetail(), server.js) für einen fortgeschriebenen Punkt
|
||||
// (neu=0), dessen Thema bzw. Text sich gegenüber der Fassung im
|
||||
// UNMITTELBAR vorangegangenen Protokoll geändert hat — markiert NUR das
|
||||
// jeweils geänderte Feld, der Punkt selbst ist ja derselbe geblieben.
|
||||
const themaGeaendertClass = punkt.themaGeaendert ? ' ist-geaendert' : '';
|
||||
const textGeaendertClass = punkt.textGeaendert ? ' ist-geaendert' : '';
|
||||
const status = punkt.status || 'offen';
|
||||
|
||||
// Verschieben gilt wie bisher nur INNERHALB der eigenen Gruppe — die
|
||||
// Knöpfe sind an den Rändern dieser Gruppe gesperrt, nicht an denen der
|
||||
// Gesamtliste.
|
||||
const gruppe = String(punkt.gruppe || '').trim();
|
||||
const idxDerGruppe = currentProtokoll.punkte
|
||||
.map((x, i) => ({ x, i }))
|
||||
.filter(e => String(e.x.gruppe || '').trim() === gruppe)
|
||||
.map(e => e.i);
|
||||
const posInGruppe = idxDerGruppe.indexOf(idx);
|
||||
const kopfAktionen = locked ? '' : `<span class="protokoll-punkt-move">
|
||||
<button type="button" class="secondary small" ${posInGruppe <= 0 ? 'disabled' : ''} onclick="verschiebePunkt(${idx}, -1)" title="Innerhalb der Gruppe nach oben">↑</button>
|
||||
<button type="button" class="secondary small" ${posInGruppe >= idxDerGruppe.length - 1 ? 'disabled' : ''} onclick="verschiebePunkt(${idx}, 1)" title="Innerhalb der Gruppe nach unten">↓</button>
|
||||
</span>
|
||||
<span class="protokoll-row-remove" onclick="entfernePunkt(${idx})" title="Punkt entfernen">✕</span>`;
|
||||
|
||||
const statusButtons = Object.keys(PROTOKOLL_PUNKT_STATUS_LABEL).map(s =>
|
||||
`<button type="button" class="${s}${s === status ? ' is-active' : ''}" ${locked ? 'disabled' : ''}
|
||||
onclick="currentProtokollSetzePunktStatus(${idx}, '${s}')">${PROTOKOLL_PUNKT_STATUS_LABEL[s]}</button>`).join('');
|
||||
|
||||
return `<div class="protokoll-schreiben">
|
||||
<div class="protokoll-schreiben-kopf">
|
||||
<span class="nr" title="Errechnet sich automatisch aus Gruppe und Reihenfolge">${esc(nummern[idx])}</span>
|
||||
<span class="gruppe">${esc(gruppe)}</span>
|
||||
${neuChip}${entfaelltChip}
|
||||
<span class="rechts">
|
||||
<div class="protokoll-status-switch ist-waagerecht">${statusButtons}</div>
|
||||
${kopfAktionen}
|
||||
</span>
|
||||
</div>
|
||||
<textarea class="protokoll-punkt-text" rows="2" placeholder="Text / Vereinbarung" ${locked ? 'disabled' : ''}
|
||||
oninput="currentProtokollSetzePunktFeld(${idx}, 'text', this.value)">${esc(punkt.text)}</textarea>
|
||||
<div class="protokoll-punkt-foot">
|
||||
<label>Zuständig <input type="text" value="${esc(punkt.zustaendig)}" ${locked ? 'disabled' : ''}
|
||||
oninput="currentProtokollSetzePunktFeld(${idx}, 'zustaendig', this.value)"></label>
|
||||
<label>Termin
|
||||
<input type="text" id="protokollPunktTerminDisplay_${idx}" class="range-picker-display" value="${esc(terminDisplay)}" placeholder="—" readonly
|
||||
|
||||
<div class="protokoll-schreiben-feld">
|
||||
<span class="protokoll-feld-label">Thema</span>
|
||||
<input type="text" class="protokoll-punkt-thema${themaGeaendertClass}" value="${esc(punkt.thema)}"
|
||||
placeholder="Worum geht es?" ${locked ? 'disabled' : ''}
|
||||
oninput="currentProtokollSetzePunktFeld(${idx}, 'thema', this.value)">
|
||||
</div>
|
||||
|
||||
<div class="richtext-wrap">
|
||||
${locked ? '' : richtextToolbarHtml('.protokoll-punkt-text')}
|
||||
<div class="protokoll-schreiben-feld">
|
||||
<span class="protokoll-feld-label">Vereinbarung · Text</span>
|
||||
${richtextFeldHtml('protokoll-punkt-text' + textGeaendertClass, sanitizeStatusHtml(punkt.text),
|
||||
'Was wurde besprochen oder vereinbart?',
|
||||
`oninput="currentProtokollSetzePunktFeld(${idx}, 'text', this.innerHTML)"`, !locked)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="protokoll-schreiben-raster">
|
||||
<div>
|
||||
<span class="protokoll-feld-label">Zuständig</span>
|
||||
<input type="text" value="${esc(punkt.zustaendig)}" placeholder="Name oder Firma" ${locked ? 'disabled' : ''}
|
||||
oninput="currentProtokollSetzePunktFeld(${idx}, 'zustaendig', this.value)">
|
||||
</div>
|
||||
<div>
|
||||
<span class="protokoll-feld-label">Termin</span>
|
||||
<input type="text" id="protokollPunktTerminDisplay_${idx}" class="range-picker-display" value="${esc(terminDisplay)}" placeholder="— kein Termin" readonly
|
||||
onclick="${locked ? '' : `openDatePicker({ anchor: this, inputId: 'protokollPunktTermin_${idx}', displayId: 'protokollPunktTerminDisplay_${idx}', onApply: function(){ syncProtokollPunktTermin(${idx}); } })`}">
|
||||
<input type="hidden" id="protokollPunktTermin_${idx}" value="${esc(punkt.termin)}">
|
||||
</label>
|
||||
<span class="protokoll-punkt-erstellt" title="Datum, an dem dieser Punkt zum ersten Mal erfasst wurde — bleibt beim Fortschreiben unverändert">Erstellt ${esc(formatDateDE(parseDateISO(punkt.erstelltAm)) || '—')}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="protokoll-schreiben-fuss">
|
||||
Erstellt am ${esc(formatDateDE(parseDateISO(punkt.erstelltAm)) || '—')}
|
||||
<span class="hint">— bleibt beim Fortschreiben unverändert</span>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
@@ -639,29 +826,47 @@
|
||||
if(!currentProtokoll || !currentProtokoll.teilnehmer[idx]) return;
|
||||
currentProtokoll.teilnehmer[idx][feld] = wert;
|
||||
};
|
||||
// Thema/Zuständig schlagen sofort auf den Listeneintrag links durch, ohne
|
||||
// den Block neu zu rendern (sonst wäre der Fokus beim Tippen weg, siehe
|
||||
// aktualisiereListenZeile() oben).
|
||||
window.currentProtokollSetzePunktFeld = function(idx, feld, wert){
|
||||
if(!currentProtokoll || !currentProtokoll.punkte[idx]) return;
|
||||
currentProtokoll.punkte[idx][feld] = wert;
|
||||
if(feld === 'thema' || feld === 'zustaendig') aktualisiereListenZeile(idx);
|
||||
};
|
||||
window.syncProtokollPunktTermin = function(idx){
|
||||
if(!currentProtokoll || !currentProtokoll.punkte[idx]) return;
|
||||
const el = document.getElementById('protokollPunktTermin_' + idx);
|
||||
if(el) currentProtokoll.punkte[idx].termin = el.value;
|
||||
aktualisiereListenZeile(idx);
|
||||
};
|
||||
window.currentProtokollSetzePunktErledigt = function(idx, checked){
|
||||
window.currentProtokollSetzePunktStatus = function(idx, status){
|
||||
if(!currentProtokoll || !currentProtokoll.punkte[idx]) return;
|
||||
currentProtokoll.punkte[idx].erledigt = checked;
|
||||
const row = document.getElementById('protokollPunkt_' + idx);
|
||||
if(row) row.classList.toggle('is-erledigt', checked);
|
||||
currentProtokoll.punkte[idx].status = status;
|
||||
// Gezielt die beiden betroffenen Stellen nachziehen statt neu zu rendern:
|
||||
// der aktive Knopf im Umschalter und der farbige Punkt im Listeneintrag.
|
||||
document.querySelectorAll('.protokoll-schreiben .protokoll-status-switch button').forEach(btn => {
|
||||
btn.classList.toggle('is-active', btn.classList.contains(status));
|
||||
});
|
||||
aktualisiereListenZeile(idx);
|
||||
};
|
||||
// currentProtokollSetzePunktGruppe: einzige Punkt-Feldänderung, die neu
|
||||
// rendert (nur den Punkte-Block, siehe renderPunkteBlockHtml) — ändert
|
||||
// sich die Gruppe, muss der Punkt sichtbar in den passenden Abschnitt
|
||||
// wandern. Läuft über onchange (Blur/Enter), nicht oninput, damit während
|
||||
// des Tippens nicht bei jedem Zeichen neu gerendert wird.
|
||||
window.currentProtokollSetzePunktGruppe = function(idx, wert){
|
||||
if(!currentProtokoll || !currentProtokoll.punkte[idx]) return;
|
||||
currentProtokoll.punkte[idx].gruppe = wert;
|
||||
// currentProtokollUmbenenneGruppe: ersetzt currentProtokollSetzePunktGruppe
|
||||
// (bis eben: ein Feld PRO Punkt, das genau diesen einen Punkt umhängte).
|
||||
// Jetzt ist die Gruppen-Kopfzeile selbst das Eingabefeld (Eriks Wunsch) —
|
||||
// ein Umbenennen wirkt auf ALLE Punkte dieser Gruppe gleichzeitig. Trägt
|
||||
// der neue Name den einer bereits bestehenden anderen Gruppe, verschmelzen
|
||||
// beide Gruppen schlicht dadurch, dass ab jetzt beide Punktmengen denselben
|
||||
// gruppe-Wert tragen — kein Sonderfall nötig. Läuft über onchange
|
||||
// (Blur/Enter), nicht oninput, damit während des Tippens nicht bei jedem
|
||||
// Zeichen neu gerendert wird.
|
||||
window.currentProtokollUmbenenneGruppe = function(alterName, neuerName){
|
||||
if(!currentProtokoll) return;
|
||||
const alt = String(alterName || '').trim();
|
||||
const neu = String(neuerName || '').trim();
|
||||
if(alt === neu) return;
|
||||
currentProtokoll.punkte.forEach(punkt => {
|
||||
if(String(punkt.gruppe || '').trim() === alt) punkt.gruppe = neu;
|
||||
});
|
||||
const block = document.getElementById('protokollPunkteBlock');
|
||||
if(block) block.innerHTML = renderPunkteBlockHtml();
|
||||
};
|
||||
@@ -684,17 +889,54 @@
|
||||
// aus Gruppe und Position (siehe berechneProtokollNummern() oben).
|
||||
// erstelltAm nur als Vorschau bis zum nächsten Speichern — maßgeblich ist
|
||||
// immer, was der Server beim Speichern setzt (server.js, PUT .../:id).
|
||||
currentProtokoll.punkte.push({
|
||||
const neuerPunkt = {
|
||||
id: null, nummer: '', gruppe: gruppe || '', neu: true,
|
||||
thema: '', text: '', zustaendig: '', termin: '', erledigt: false,
|
||||
erstelltAm: currentProtokoll.datum || ''
|
||||
});
|
||||
thema: '', text: '', zustaendig: '', termin: '', status: 'offen',
|
||||
erstelltAm: currentProtokoll.datum || '', entfaelltNaechstes: false
|
||||
};
|
||||
currentProtokoll.punkte.push(neuerPunkt);
|
||||
// Den neuen Punkt gleich im Schreibbereich öffnen und ins Thema-Feld
|
||||
// springen — sonst stünde er zwar links in der Liste, rechts bliebe
|
||||
// aber der vorige Punkt stehen und man müsste ihn erst anklicken.
|
||||
ausgewaehlterPunkt = neuerPunkt;
|
||||
const block = document.getElementById('protokollPunkteBlock');
|
||||
if(block) block.innerHTML = renderPunkteBlockHtml();
|
||||
const themaFeld = block && block.querySelector('.protokoll-schreiben .protokoll-punkt-thema');
|
||||
if(themaFeld) themaFeld.focus();
|
||||
};
|
||||
// fuegeNeueGruppeHinzu: eigener, ausdrücklicher Weg, eine neue Gruppe
|
||||
// anzulegen (Eriks Wunsch). Gruppe bleibt weiterhin freier Text ohne eigene
|
||||
// Tabelle (siehe CLAUDE.md "### Protokolle") — eine Gruppe ohne jeden
|
||||
// Punkt hätte in diesem Modell nirgends eine Repräsentation, deshalb legt
|
||||
// dieser Knopf die Gruppe zusammen mit ihrem ersten (leeren) Punkt an. Ein
|
||||
// nicht kollidierender Platzhaltername ("Neue Gruppe", bei Bedarf
|
||||
// "Neue Gruppe 2" …) wird direkt danach in der großen Gruppen-Überschrift
|
||||
// (nicht mehr einem Feld am Punkt, siehe currentProtokollUmbenenneGruppe()
|
||||
// oben) fokussiert und markiert, damit er sich sofort überschreiben lässt.
|
||||
window.fuegeNeueGruppeHinzu = function(){
|
||||
if(!currentProtokoll || app.isLocked()) return;
|
||||
const bestehende = new Set(gruppenReihenfolge());
|
||||
let name = 'Neue Gruppe';
|
||||
let n = 2;
|
||||
while(bestehende.has(name)){ name = 'Neue Gruppe ' + n; n++; }
|
||||
fuegePunktHinzu(name);
|
||||
const block = document.getElementById('protokollPunkteBlock');
|
||||
const input = block
|
||||
? Array.from(block.querySelectorAll('.protokoll-gruppe-heading')).find(el => el.value === name)
|
||||
: null;
|
||||
if(input){ input.focus(); input.select(); }
|
||||
};
|
||||
window.entfernePunkt = function(idx){
|
||||
if(!currentProtokoll || app.isLocked()) return;
|
||||
// War der gelöschte Punkt der geöffnete, rückt der an seine Stelle
|
||||
// getretene nach (bzw. der letzte, wenn es der letzte war) — sonst
|
||||
// stünde der Schreibbereich leer da, obwohl noch Punkte vorhanden sind.
|
||||
const warAusgewaehlt = currentProtokoll.punkte[idx] === ausgewaehlterPunkt;
|
||||
currentProtokoll.punkte.splice(idx, 1);
|
||||
if(warAusgewaehlt){
|
||||
const ersatz = Math.min(idx, currentProtokoll.punkte.length - 1);
|
||||
ausgewaehlterPunkt = ersatz >= 0 ? currentProtokoll.punkte[ersatz] : null;
|
||||
}
|
||||
const block = document.getElementById('protokollPunkteBlock');
|
||||
if(block) block.innerHTML = renderPunkteBlockHtml();
|
||||
};
|
||||
@@ -711,7 +953,16 @@
|
||||
// Anzeigen aber immer die Neuberechnung, nie dieser gespeicherte Wert —
|
||||
// siehe berechneProtokollNummern()).
|
||||
const nummern = window.berechneProtokollNummern(currentProtokoll.punkte);
|
||||
currentProtokoll.punkte.forEach((p, idx) => { p.nummer = nummern[idx]; });
|
||||
// text kommt live aus dem contenteditable-Feld (this.innerHTML,
|
||||
// currentProtokollSetzePunktFeld()) — ein Einfügen von außerhalb könnte
|
||||
// dort ungefiltertes HTML hinterlassen (contenteditable übernimmt
|
||||
// eingefügte Formatierung standardmäßig); vor dem Senden deshalb
|
||||
// dieselbe Bereinigung wie beim Board-Kommentar (saveBoardComment(),
|
||||
// js/board/board.js).
|
||||
currentProtokoll.punkte.forEach((p, idx) => {
|
||||
p.nummer = nummern[idx];
|
||||
p.text = sanitizeStatusHtml(p.text || '');
|
||||
});
|
||||
const body = {
|
||||
datum: currentProtokoll.datum,
|
||||
betreff: currentProtokoll.betreff,
|
||||
@@ -719,6 +970,11 @@
|
||||
teilnehmer: currentProtokoll.teilnehmer.filter(t => String(t.name || '').trim()),
|
||||
punkte: currentProtokoll.punkte
|
||||
};
|
||||
// Die Auswahl hängt an der OBJEKT-Referenz des Punkts (siehe dort) — der
|
||||
// Server liefert gleich frische Objekte zurück, die Referenz zeigt dann
|
||||
// ins Leere. Die Reihenfolge bleibt beim Speichern erhalten (array_order),
|
||||
// also lässt sich die Auswahl über ihre Position wiederherstellen.
|
||||
const auswahlPos = currentProtokoll.punkte.indexOf(ausgewaehlterPunkt);
|
||||
fetch(baseUrl() + '/project-protokolle/' + encodeURIComponent(currentProtokoll.id), {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json; charset=utf-8' },
|
||||
@@ -728,6 +984,7 @@
|
||||
.then(({ ok, d }) => {
|
||||
if(!ok || !d.ok){ alert('Protokoll konnte nicht gespeichert werden: ' + (d && d.error ? d.error : 'unbekannter Fehler')); return; }
|
||||
currentProtokoll = d.protokoll;
|
||||
ausgewaehlterPunkt = (auswahlPos >= 0 && currentProtokoll.punkte[auswahlPos]) || null;
|
||||
renderProtokollDetail();
|
||||
ladeProtokollListe();
|
||||
})
|
||||
@@ -753,6 +1010,12 @@
|
||||
});
|
||||
};
|
||||
|
||||
// Die Rückmeldung der Formatierungsleiste (hervorgehoben + "✓ Markiert"
|
||||
// sobald Text ausgewählt ist, sonst blass + "Erst Text markieren") ist ein
|
||||
// gemeinsamer, dokumentweiter Lauscher in js/utils.js geworden (gilt jetzt
|
||||
// auch für den Board-Kommentar) — hier war er ursprünglich fest auf den
|
||||
// Protokoll-Schreibbereich zugeschnitten.
|
||||
|
||||
// renderPlanProtokolle: zentrale Render-Funktion, von showPlanSection()
|
||||
// (plan-sections.js) beim Wechsel in den Bereich aufgerufen — zeigt je
|
||||
// nach Zustand Liste oder Detail (rendert dabei nichts neu, nur aus dem
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
// -----------------------------
|
||||
// Der Projektbereich ist der EIGENE BEREICH eines Projekts — seit dem Umbau
|
||||
// kein eigenes Dokument mehr, sondern der Bereich #plan der Hauptseite
|
||||
// Test.html. Er gliedert sich in sieben Unterbereiche, zwischen denen die
|
||||
// Leiste .plan-nav umschaltet (Markup in Test.html):
|
||||
// App.html. Er gliedert sich in sieben Unterbereiche, zwischen denen die
|
||||
// Leiste .plan-nav umschaltet (Markup in App.html):
|
||||
//
|
||||
// dashboard — Kennzahlen, Sitzungskommentar, Leistungsphasen-
|
||||
// Fortschritt, nächste Termine, Team, Kompakt-Karten
|
||||
@@ -35,7 +35,7 @@
|
||||
|
||||
// PLAN_SECTIONS: gültige Bereichsschlüssel in der Reihenfolge der
|
||||
// Navigationsleiste. Muss zu den data-section-Attributen in
|
||||
// Test.html passen.
|
||||
// App.html passen.
|
||||
const PLAN_SECTIONS = ['dashboard', 'terminplan', 'aufgaben', 'leistung', 'dateien', 'protokolle', 'einstellungen'];
|
||||
|
||||
// showPlanSection: schaltet auf einen Bereich um, hebt den zugehörigen
|
||||
|
||||
+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/App.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 App.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 App.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