All new
alle new
This commit is contained in:
1 parent
da64472251
commit
5b20adee48
1605 files changed
+396517
-1674
No files matched your search
@@ -0,0 +1,480 @@
|
||||
// -----------------------------
|
||||
// Benutzerverwaltung (Abschnitt „Benutzerverwaltung" des Bereichs #admin)
|
||||
// -----------------------------
|
||||
// Laedt und rendert die Benutzerliste ueber die admin-only Endpunkte aus
|
||||
// server.js (GET/POST/PATCH/DELETE /admin/users, POST
|
||||
// /admin/users/:id/reset-password) und stellt die Dialoge bereit: neuen
|
||||
// Benutzer anlegen, bestehenden bearbeiten (inkl. Wochenstunden und
|
||||
// Abwesenheiten), Passwort zuruecksetzen, aktivieren/deaktivieren und
|
||||
// endgueltig loeschen.
|
||||
//
|
||||
// Das war bis zum Umbau eine EIGENE SEITE (public/admin-users.html) mit
|
||||
// eigenem Seiteneinstieg, eigener Anmeldepruefung und einer nachgebauten,
|
||||
// abgespeckten Kopfzeile statt der Seitenleiste. Jetzt ist es ein Abschnitt
|
||||
// des Bereichs „Admin" wie jeder andere: renderAdminUsers() wird von
|
||||
// showAdminSection() gerufen, sobald der Abschnitt sichtbar wird, und die
|
||||
// Seitenleiste bleibt stehen. Eine eigene Anmeldepruefung braucht es hier
|
||||
// nicht mehr — der Bereich ist ohnehin nur fuer Administratoren erreichbar
|
||||
// (showTab() in js/tabs-auth.js), und die eigentliche Sicherheitsgrenze sind
|
||||
// die requireRole('admin')-Endpunkte auf dem Server.
|
||||
|
||||
(function(){
|
||||
// usersCache: zuletzt vom Server geladene Benutzerliste (für die Modale,
|
||||
// damit sie nicht erneut nachgeladen werden muss).
|
||||
let usersCache = [];
|
||||
// editingUserId: id des im Bearbeiten-Modal geöffneten Benutzers.
|
||||
let editingUserId = null;
|
||||
// personIdManuallyEdited: verhindert, dass eine manuelle Eingabe im
|
||||
// Person-ID-Feld des Neu-Anlegen-Modals von der Auto-Vorschau überschrieben wird.
|
||||
let personIdManuallyEdited = false;
|
||||
// fmtCapacity: Wochenstunden in deutscher Schreibweise, ohne überflüssige
|
||||
// Nachkommastelle (40 statt 40,0 — aber 32,5 bleibt 32,5).
|
||||
// Parameter: h (Zahl). Rückgabe: String.
|
||||
function fmtCapacity(h){
|
||||
const n = Number(h) || 0;
|
||||
return (Number.isInteger(n) ? String(n) : n.toFixed(1).replace('.', ',')) + ' h';
|
||||
}
|
||||
|
||||
// initialsManuallyEdited: gleiches Muster für das Kürzel-Feld.
|
||||
let initialsManuallyEdited = false;
|
||||
// editUserAbsencesCache: zuletzt geladene Abwesenheiten des im
|
||||
// Bearbeiten-Modal geöffneten Benutzers (für Rendern/Löschen ohne erneuten
|
||||
// Request pro Aktion).
|
||||
let editUserAbsencesCache = [];
|
||||
|
||||
// renderDepartmentOptions: befüllt ein <select> mit einer <option> pro
|
||||
// Eintrag in DEPARTMENTS (js/globals.js) — bewusst ohne Leer-Option, die
|
||||
// Abteilung ist ein Pflichtfeld.
|
||||
function renderDepartmentOptions(selectId){
|
||||
const sel = document.getElementById(selectId);
|
||||
if(!sel) return;
|
||||
sel.innerHTML = Object.keys(DEPARTMENTS).map(key =>
|
||||
`<option value="${esc(key)}">${esc(DEPARTMENTS[key].label)}</option>`).join('');
|
||||
}
|
||||
|
||||
// renderRoleCheckboxes: befüllt einen Container mit einer Checkbox pro
|
||||
// Eintrag in ROLES (js/globals.js) — die fachlichen Rollen
|
||||
// (Planung/Projektleitung/Bauüberwachung), nicht zu verwechseln mit der
|
||||
// Systemrolle (Mitglied/Administrator) im "Rolle"-Feld.
|
||||
function renderRoleCheckboxes(containerId){
|
||||
const el = document.getElementById(containerId);
|
||||
if(!el) return;
|
||||
el.innerHTML = ROLES.map(r =>
|
||||
`<label><input type="checkbox" value="${esc(r)}"> ${esc(r)}</label>`).join('');
|
||||
}
|
||||
|
||||
// checkedRoleValues: liest die angehakten Fachrollen-Checkboxen eines
|
||||
// Containers als String-Array aus.
|
||||
function checkedRoleValues(containerId){
|
||||
return Array.from(document.querySelectorAll('#' + containerId + ' input[type="checkbox"]:checked')).map(cb => cb.value);
|
||||
}
|
||||
|
||||
// formulareVorbereitet: Abteilungs-Auswahl und Rollen-Ankreuzfelder muessen
|
||||
// nur einmal aufgebaut werden; renderAdminUsers() laeuft aber bei jedem
|
||||
// Wechsel in den Abschnitt.
|
||||
let formulareVorbereitet = false;
|
||||
|
||||
// renderAdminUsers: Einstiegspunkt des Abschnitts, von showAdminSection()
|
||||
// gerufen. Baut beim ersten Mal die Auswahlfelder der Dialoge auf und laedt
|
||||
// danach die Benutzerliste. Eine Rollenpruefung findet hier nicht mehr
|
||||
// statt — der Bereich „Admin" ist fuer Nicht-Administratoren gar nicht
|
||||
// erreichbar (showTab() leitet sie um) und die Endpunkte sind serverseitig
|
||||
// abgesichert. Keine Parameter, keine Rueckgabe.
|
||||
window.renderAdminUsers = function(){
|
||||
if(!formulareVorbereitet){
|
||||
renderDepartmentOptions('newUserDepartment');
|
||||
renderDepartmentOptions('editUserDepartment');
|
||||
renderRoleCheckboxes('newUserRoleCheckboxes');
|
||||
renderRoleCheckboxes('editUserRoleCheckboxes');
|
||||
formulareVorbereitet = true;
|
||||
}
|
||||
loadAndRenderUsers();
|
||||
};
|
||||
|
||||
// apiFetch: kleiner fetch-Wrapper für die /admin/users*-Endpunkte. Liefert
|
||||
// immer das geparste JSON zurück (auch bei Fehlerstatus), damit die
|
||||
// Aufrufer serverseitige Fehlermeldungen ({ok:false, error}) anzeigen können.
|
||||
function apiFetch(path, options){
|
||||
return fetch(baseUrl() + path, Object.assign({
|
||||
headers: { 'Content-Type': 'application/json; charset=utf-8' }
|
||||
}, options || {})).then(r => r.json().then(data => ({ status: r.status, ok: r.ok, data })));
|
||||
}
|
||||
|
||||
// loadAndRenderUsers: lädt die Benutzerliste und rendert die Tabelle.
|
||||
function loadAndRenderUsers(){
|
||||
apiFetch('/admin/users').then(res => {
|
||||
if(!res.ok || !res.data || !res.data.ok){
|
||||
document.getElementById('usersTableBody').innerHTML =
|
||||
'<tr><td colspan="9">Fehler beim Laden: ' + esc((res.data && res.data.error) || ('HTTP ' + res.status)) + '</td></tr>';
|
||||
return;
|
||||
}
|
||||
usersCache = res.data.users || [];
|
||||
renderUsersTable();
|
||||
}).catch(err => {
|
||||
document.getElementById('usersTableBody').innerHTML =
|
||||
'<tr><td colspan="9">Verbindung zum Server fehlgeschlagen: ' + esc(err.message) + '</td></tr>';
|
||||
});
|
||||
}
|
||||
|
||||
// renderUsersTable: baut die Tabellenzeilen aus usersCache auf.
|
||||
function renderUsersTable(){
|
||||
const body = document.getElementById('usersTableBody');
|
||||
if(usersCache.length === 0){
|
||||
body.innerHTML = '<tr><td colspan="9">Keine Benutzer vorhanden.</td></tr>';
|
||||
return;
|
||||
}
|
||||
body.innerHTML = usersCache.map(u => {
|
||||
const statusParts = [];
|
||||
statusParts.push(u.active ? '<span class="badge-active">Aktiv</span>' : '<span class="badge-inactive">Deaktiviert</span>');
|
||||
if(u.mustChangePassword) statusParts.push('<span class="badge-warn">Passwortwechsel erforderlich</span>');
|
||||
const toggleLabel = u.active ? 'Deaktivieren' : 'Aktivieren';
|
||||
const isSelf = currentUser && currentUser.id === u.id;
|
||||
const deleteBtn = (!u.active)
|
||||
? `<button class="danger small" ${isSelf ? 'disabled' : ''} onclick="hardDeleteUser(${u.id}, '${escJs(u.username)}')">Endgültig löschen</button>`
|
||||
: '';
|
||||
const deptLabel = (DEPARTMENTS[u.department] && DEPARTMENTS[u.department].label) || esc(u.department || '');
|
||||
return `<tr>
|
||||
<td>${esc(u.username)}</td>
|
||||
<td>${esc(u.displayName)}</td>
|
||||
<td>${esc(u.email || '')}</td>
|
||||
<td>${u.role === 'admin' ? 'Administrator' : 'Mitglied'}</td>
|
||||
<td>${deptLabel}</td>
|
||||
<td>${esc(u.initials || '')}</td>
|
||||
<td>${u.capacityHours ? esc(fmtCapacity(u.capacityHours)) : '<span class="hint" style="margin:0">voll</span>'}</td>
|
||||
<td>${esc(u.personId || '')}</td>
|
||||
<td>${statusParts.join('<br>')}</td>
|
||||
<td class="actions-cell">
|
||||
<button class="secondary small" onclick="openEditUserModal(${u.id})">Bearbeiten</button>
|
||||
<button class="secondary small" onclick="resetUserPassword(${u.id}, '${escJs(u.username)}')">Passwort zurücksetzen</button>
|
||||
<button class="secondary small" ${isSelf ? 'disabled title="Sie können sich nicht selbst deaktivieren."' : ''} onclick="toggleUserActive(${u.id}, ${u.active})">${toggleLabel}</button>
|
||||
${deleteBtn}
|
||||
</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// escJs: escaped einen String für die Einbettung in ein einfach gequotetes
|
||||
// JS-Literal innerhalb eines onclick-Attributs. Das doppelte
|
||||
// Anführungszeichen MUSS mit maskiert werden: Der Handler steht in einem
|
||||
// doppelt gequoteten HTML-Attribut, und ein Benutzername wie
|
||||
// `test" onmouseover="…` brach es sonst auf. Dasselbe gilt für spitze
|
||||
// Klammern, die das Attribut vorzeitig beenden könnten.
|
||||
function escJs(s){
|
||||
return String(s || '')
|
||||
.replace(/\\/g, '\\\\')
|
||||
.replace(/'/g, "\\'")
|
||||
.replace(/"/g, '"')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
}
|
||||
|
||||
// showGeneratedPassword: zeigt ein neu generiertes Passwort einmalig und gut
|
||||
// sichtbar in einer Box oben auf der Seite an (zusätzlich zum alert(), da es
|
||||
// sonst nirgends gespeichert/angezeigt wird).
|
||||
function showGeneratedPassword(username, password){
|
||||
const box = document.getElementById('generatedPasswordBox');
|
||||
box.innerHTML = 'Neues Passwort für <b>' + esc(username) + '</b>: <b>' + esc(password) + '</b> ' +
|
||||
'(bitte jetzt notieren/weitergeben — wird nicht erneut angezeigt).';
|
||||
box.style.display = '';
|
||||
alert('Neues Passwort für ' + username + ':\n\n' + password + '\n\nBitte jetzt notieren, es wird nicht erneut angezeigt.');
|
||||
}
|
||||
|
||||
// ---------- Neuer-Benutzer-Modal ----------
|
||||
|
||||
window.openNewUserModal = function(){
|
||||
document.getElementById('newUserUsername').value = '';
|
||||
document.getElementById('newUserDisplayName').value = '';
|
||||
document.getElementById('newUserEmail').value = '';
|
||||
document.getElementById('newUserRole').value = 'member';
|
||||
document.getElementById('newUserDepartment').value = DEFAULT_DEPARTMENT;
|
||||
document.getElementById('newUserInitials').value = '';
|
||||
document.getElementById('newUserPersonId').value = '';
|
||||
document.querySelectorAll('#newUserRoleCheckboxes input[type="checkbox"]').forEach(cb => { cb.checked = false; });
|
||||
document.getElementById('newUserPassword').value = '';
|
||||
document.getElementById('newUserPassword').style.display = 'none';
|
||||
document.querySelector('input[name="newUserPwMode"][value="generate"]').checked = true;
|
||||
document.getElementById('newUserError').textContent = '';
|
||||
personIdManuallyEdited = false;
|
||||
initialsManuallyEdited = false;
|
||||
document.getElementById('newUserModal').style.display = 'flex';
|
||||
document.getElementById('newUserUsername').focus();
|
||||
};
|
||||
|
||||
window.closeNewUserModal = function(){
|
||||
document.getElementById('newUserModal').style.display = 'none';
|
||||
};
|
||||
|
||||
// onNewUserDisplayNameInput: schlägt beim Tippen des Anzeigenamens eine
|
||||
// Person-ID über personIdForName() vor (js/utils.js) — nur solange das Feld
|
||||
// nicht bereits manuell bearbeitet wurde (Kollisionsvermeidung laut Plan).
|
||||
window.onNewUserDisplayNameInput = function(){
|
||||
const name = document.getElementById('newUserDisplayName').value;
|
||||
if(!personIdManuallyEdited){
|
||||
document.getElementById('newUserPersonId').value = name.trim() ? personIdForName(name) : '';
|
||||
}
|
||||
if(!initialsManuallyEdited){
|
||||
document.getElementById('newUserInitials').value = name.trim() ? initialsForName(name) : '';
|
||||
}
|
||||
};
|
||||
|
||||
window.onNewUserPwModeChange = function(){
|
||||
const manual = document.querySelector('input[name="newUserPwMode"]:checked').value === 'manual';
|
||||
document.getElementById('newUserPassword').style.display = manual ? '' : 'none';
|
||||
};
|
||||
|
||||
window.saveNewUser = function(){
|
||||
const username = document.getElementById('newUserUsername').value.trim();
|
||||
const displayName = document.getElementById('newUserDisplayName').value.trim();
|
||||
const email = document.getElementById('newUserEmail').value.trim();
|
||||
const role = document.getElementById('newUserRole').value;
|
||||
const department = document.getElementById('newUserDepartment').value;
|
||||
const initials = document.getElementById('newUserInitials').value.trim();
|
||||
const capacityHours = document.getElementById('newUserCapacity').value.trim();
|
||||
const personId = document.getElementById('newUserPersonId').value.trim();
|
||||
const roles = checkedRoleValues('newUserRoleCheckboxes');
|
||||
const pwMode = document.querySelector('input[name="newUserPwMode"]:checked').value;
|
||||
const errEl = document.getElementById('newUserError');
|
||||
errEl.textContent = '';
|
||||
|
||||
if(!username || !displayName){
|
||||
errEl.textContent = 'Benutzername und Anzeigename sind erforderlich.';
|
||||
return;
|
||||
}
|
||||
if(!department){
|
||||
errEl.textContent = 'Abteilung ist erforderlich.';
|
||||
return;
|
||||
}
|
||||
// Ohne Fachrolle ist der Benutzer in der ganzen Anwendung unsichtbar:
|
||||
// peopleByRole() (js/utils.js) gruppiert ausschließlich über die Rollen —
|
||||
// das Konto erschiene weder als Board-Spalte noch in der Auslastung noch
|
||||
// im PDF-Export, ohne dass es dafür irgendeinen Hinweis gäbe.
|
||||
if(!roles || roles.length === 0){
|
||||
errEl.textContent = 'Bitte mindestens eine Rolle wählen (Planung, Projektleitung oder Bauüberwachung) — sonst erscheint der Benutzer in keinem Projektboard.';
|
||||
return;
|
||||
}
|
||||
// Ohne Personen-Kennung lässt sich das Konto keiner Person in den
|
||||
// Sitzungen zuordnen (Aufgaben im Dashboard, Zuordnungen im Board).
|
||||
if(!personId){
|
||||
errEl.textContent = 'Bitte eine Personen-Kennung vergeben — ohne sie kann der Benutzer keiner Sitzung und keinen Aufgaben zugeordnet werden.';
|
||||
return;
|
||||
}
|
||||
const body = { username, displayName, email, role, personId, department, initials, capacityHours, roles };
|
||||
if(pwMode === 'manual'){
|
||||
const password = document.getElementById('newUserPassword').value;
|
||||
if(password.length < 8){
|
||||
errEl.textContent = 'Manuelles Passwort muss mindestens 8 Zeichen haben.';
|
||||
return;
|
||||
}
|
||||
body.password = password;
|
||||
} else {
|
||||
body.generatePassword = true;
|
||||
}
|
||||
|
||||
apiFetch('/admin/users', { method: 'POST', body: JSON.stringify(body) }).then(res => {
|
||||
if(!res.ok || !res.data || !res.data.ok){
|
||||
errEl.textContent = (res.data && res.data.error) || ('Fehler (HTTP ' + res.status + ')');
|
||||
return;
|
||||
}
|
||||
closeNewUserModal();
|
||||
loadAndRenderUsers();
|
||||
if(res.data.generatedPassword){
|
||||
showGeneratedPassword(username, res.data.generatedPassword);
|
||||
}
|
||||
}).catch(err => { errEl.textContent = 'Verbindung zum Server fehlgeschlagen: ' + err.message; });
|
||||
};
|
||||
|
||||
// ---------- Benutzer-bearbeiten-Modal ----------
|
||||
|
||||
window.openEditUserModal = function(id){
|
||||
const u = usersCache.find(x => x.id === id);
|
||||
if(!u) return;
|
||||
editingUserId = id;
|
||||
document.getElementById('editUserUsernameLabel').textContent = 'Benutzername: ' + u.username + ' (nicht änderbar)';
|
||||
document.getElementById('editUserDisplayName').value = u.displayName || '';
|
||||
document.getElementById('editUserEmail').value = u.email || '';
|
||||
document.getElementById('editUserRole').value = u.role;
|
||||
document.getElementById('editUserDepartment').value = u.department || DEFAULT_DEPARTMENT;
|
||||
document.getElementById('editUserInitials').value = u.initials || '';
|
||||
document.getElementById('editUserCapacity').value = u.capacityHours ? u.capacityHours : '';
|
||||
document.getElementById('editUserPersonId').value = u.personId || '';
|
||||
const userRoles = u.roles || [];
|
||||
document.querySelectorAll('#editUserRoleCheckboxes input[type="checkbox"]').forEach(cb => {
|
||||
cb.checked = userRoles.includes(cb.value);
|
||||
});
|
||||
document.getElementById('editUserError').textContent = '';
|
||||
document.getElementById('editUserAbsenceError').textContent = '';
|
||||
document.getElementById('editUserAbsenceStart').value = '';
|
||||
document.getElementById('editUserAbsenceEnd').value = '';
|
||||
document.getElementById('editUserAbsenceNote').value = '';
|
||||
editUserAbsencesCache = [];
|
||||
renderEditUserAbsences();
|
||||
loadEditUserAbsences(id);
|
||||
document.getElementById('editUserModal').style.display = 'flex';
|
||||
};
|
||||
|
||||
window.closeEditUserModal = function(){
|
||||
document.getElementById('editUserModal').style.display = 'none';
|
||||
editingUserId = null;
|
||||
editUserAbsencesCache = [];
|
||||
};
|
||||
|
||||
window.saveEditUser = function(){
|
||||
if(editingUserId === null) return;
|
||||
const errEl = document.getElementById('editUserError');
|
||||
errEl.textContent = '';
|
||||
const department = document.getElementById('editUserDepartment').value;
|
||||
if(!department){
|
||||
errEl.textContent = 'Abteilung ist erforderlich.';
|
||||
return;
|
||||
}
|
||||
const body = {
|
||||
displayName: document.getElementById('editUserDisplayName').value.trim(),
|
||||
email: document.getElementById('editUserEmail').value.trim(),
|
||||
role: document.getElementById('editUserRole').value,
|
||||
department,
|
||||
initials: document.getElementById('editUserInitials').value.trim(),
|
||||
capacityHours: document.getElementById('editUserCapacity').value.trim(),
|
||||
personId: document.getElementById('editUserPersonId').value.trim(),
|
||||
roles: checkedRoleValues('editUserRoleCheckboxes'),
|
||||
};
|
||||
apiFetch('/admin/users/' + editingUserId, { method: 'PATCH', body: JSON.stringify(body) }).then(res => {
|
||||
if(!res.ok || !res.data || !res.data.ok){
|
||||
errEl.textContent = (res.data && res.data.error) || ('Fehler (HTTP ' + res.status + ')');
|
||||
return;
|
||||
}
|
||||
closeEditUserModal();
|
||||
loadAndRenderUsers();
|
||||
}).catch(err => { errEl.textContent = 'Verbindung zum Server fehlgeschlagen: ' + err.message; });
|
||||
};
|
||||
|
||||
// ---------- Abwesenheiten (Bearbeiten-Modal) ----------
|
||||
// GET /admin/users liefert bewusst keine Abwesenheiten mit (server.js), daher
|
||||
// werden sie hier separat über GET /admin/users/:id/absences nachgeladen und
|
||||
// Änderungen über die bereits bestehenden requireAuth-Endpunkte
|
||||
// POST/DELETE /users/:id/absences vorgenommen.
|
||||
|
||||
// loadEditUserAbsences: lädt die Abwesenheiten des gerade bearbeiteten
|
||||
// Benutzers und rendert die Liste im Bearbeiten-Modal.
|
||||
function loadEditUserAbsences(id){
|
||||
apiFetch('/admin/users/' + id + '/absences').then(res => {
|
||||
if(editingUserId !== id) return; // Modal wurde inzwischen gewechselt/geschlossen
|
||||
if(!res.ok || !res.data || !res.data.ok){
|
||||
document.getElementById('editUserAbsenceError').textContent =
|
||||
(res.data && res.data.error) || ('Fehler beim Laden (HTTP ' + res.status + ')');
|
||||
return;
|
||||
}
|
||||
editUserAbsencesCache = res.data.absences || [];
|
||||
renderEditUserAbsences();
|
||||
}).catch(err => {
|
||||
document.getElementById('editUserAbsenceError').textContent = 'Verbindung zum Server fehlgeschlagen: ' + err.message;
|
||||
});
|
||||
}
|
||||
|
||||
// renderEditUserAbsences: baut die Chip-Liste der Abwesenheiten aus
|
||||
// editUserAbsencesCache auf (gleiche Markup-/CSS-Struktur wie die
|
||||
// Abwesenheitsliste im Projektbeteiligte-Tab: .absence-list/.absence-chip).
|
||||
function renderEditUserAbsences(){
|
||||
const el = document.getElementById('editUserAbsenceList');
|
||||
if(editUserAbsencesCache.length === 0){
|
||||
el.innerHTML = '<span class="hint">Keine Abwesenheiten erfasst.</span>';
|
||||
return;
|
||||
}
|
||||
el.innerHTML = editUserAbsencesCache.map(a => `<div class="absence-chip">
|
||||
<span>${esc(a.type || 'Abwesenheit')}: ${formatDateDE(parseDateISO(a.start))}–${formatDateDE(parseDateISO(a.end))}</span>
|
||||
${a.note ? `<small>${esc(a.note)}</small>` : ''}
|
||||
<button class="danger small" onclick="deleteEditUserAbsence('${escJs(a.id)}')">×</button>
|
||||
</div>`).join('');
|
||||
}
|
||||
|
||||
// addEditUserAbsence: legt über POST /users/:id/absences eine neue
|
||||
// Abwesenheit für den gerade bearbeiteten Benutzer an und lädt die Liste neu.
|
||||
window.addEditUserAbsence = function(){
|
||||
if(editingUserId === null) return;
|
||||
const errEl = document.getElementById('editUserAbsenceError');
|
||||
errEl.textContent = '';
|
||||
const start = document.getElementById('editUserAbsenceStart').value;
|
||||
const end = document.getElementById('editUserAbsenceEnd').value;
|
||||
const type = document.getElementById('editUserAbsenceType').value;
|
||||
const note = document.getElementById('editUserAbsenceNote').value.trim();
|
||||
if(!start || !end){
|
||||
errEl.textContent = 'Start- und Enddatum erforderlich.';
|
||||
return;
|
||||
}
|
||||
apiFetch('/users/' + editingUserId + '/absences', { method: 'POST', body: JSON.stringify({ start, end, type, note }) }).then(res => {
|
||||
if(!res.ok || !res.data || !res.data.ok){
|
||||
errEl.textContent = (res.data && res.data.error) || ('Fehler (HTTP ' + res.status + ')');
|
||||
return;
|
||||
}
|
||||
document.getElementById('editUserAbsenceStart').value = '';
|
||||
document.getElementById('editUserAbsenceEnd').value = '';
|
||||
document.getElementById('editUserAbsenceNote').value = '';
|
||||
loadEditUserAbsences(editingUserId);
|
||||
}).catch(err => { errEl.textContent = 'Verbindung zum Server fehlgeschlagen: ' + err.message; });
|
||||
};
|
||||
|
||||
// deleteEditUserAbsence: löscht eine Abwesenheit über DELETE
|
||||
// /users/:id/absences/:absenceId und lädt die Liste neu.
|
||||
window.deleteEditUserAbsence = function(absenceId){
|
||||
if(editingUserId === null) return;
|
||||
const errEl = document.getElementById('editUserAbsenceError');
|
||||
errEl.textContent = '';
|
||||
apiFetch('/users/' + editingUserId + '/absences/' + absenceId, { method: 'DELETE' }).then(res => {
|
||||
if(!res.ok || !res.data || !res.data.ok){
|
||||
errEl.textContent = (res.data && res.data.error) || ('Fehler (HTTP ' + res.status + ')');
|
||||
return;
|
||||
}
|
||||
loadEditUserAbsences(editingUserId);
|
||||
}).catch(err => { errEl.textContent = 'Verbindung zum Server fehlgeschlagen: ' + err.message; });
|
||||
};
|
||||
|
||||
// ---------- Zeilen-Aktionen (Passwort-Reset, Aktivieren/Deaktivieren, Löschen) ----------
|
||||
|
||||
window.resetUserPassword = function(id, username){
|
||||
if(!confirm('Passwort für "' + username + '" wirklich zurücksetzen? Bisherige Sitzungen dieses Benutzers werden beendet.')) return;
|
||||
apiFetch('/admin/users/' + id + '/reset-password', { method: 'POST' }).then(res => {
|
||||
if(!res.ok || !res.data || !res.data.ok){
|
||||
alert((res.data && res.data.error) || ('Fehler (HTTP ' + res.status + ')'));
|
||||
return;
|
||||
}
|
||||
showGeneratedPassword(username, res.data.generatedPassword);
|
||||
loadAndRenderUsers();
|
||||
}).catch(err => alert('Verbindung zum Server fehlgeschlagen: ' + err.message));
|
||||
};
|
||||
|
||||
window.toggleUserActive = function(id, currentlyActive){
|
||||
const goingActive = !currentlyActive;
|
||||
if(!goingActive && !confirm('Benutzer wirklich deaktivieren? Bestehende Sitzungen werden sofort beendet.')) return;
|
||||
apiFetch('/admin/users/' + id, { method: 'PATCH', body: JSON.stringify({ active: goingActive }) }).then(res => {
|
||||
if(!res.ok || !res.data || !res.data.ok){
|
||||
alert((res.data && res.data.error) || ('Fehler (HTTP ' + res.status + ')'));
|
||||
return;
|
||||
}
|
||||
loadAndRenderUsers();
|
||||
}).catch(err => alert('Verbindung zum Server fehlgeschlagen: ' + err.message));
|
||||
};
|
||||
|
||||
window.hardDeleteUser = function(id, username){
|
||||
if(!confirm('Benutzer "' + username + '" wirklich ENDGÜLTIG löschen? Das kann nicht rückgängig gemacht werden.')) return;
|
||||
apiFetch('/admin/users/' + id + '?hard=1', { method: 'DELETE' }).then(res => {
|
||||
if(!res.ok || !res.data || !res.data.ok){
|
||||
alert((res.data && res.data.error) || ('Fehler (HTTP ' + res.status + ')'));
|
||||
return;
|
||||
}
|
||||
loadAndRenderUsers();
|
||||
}).catch(err => alert('Verbindung zum Server fehlgeschlagen: ' + err.message));
|
||||
};
|
||||
|
||||
// personIdManuallyEdited wird gesetzt, sobald der Nutzer das Person-ID-Feld
|
||||
// selbst bearbeitet (nach dem Laden des DOM gebunden).
|
||||
document.addEventListener('DOMContentLoaded', function(){
|
||||
const personIdInput = document.getElementById('newUserPersonId');
|
||||
if(personIdInput){
|
||||
personIdInput.addEventListener('input', function(){ personIdManuallyEdited = true; });
|
||||
}
|
||||
const initialsInput = document.getElementById('newUserInitials');
|
||||
if(initialsInput){
|
||||
initialsInput.addEventListener('input', function(){ initialsManuallyEdited = true; });
|
||||
}
|
||||
});
|
||||
|
||||
})();
|
||||
@@ -0,0 +1,842 @@
|
||||
// -----------------------------
|
||||
// Projektübersicht — Bereich „Admin"
|
||||
// -----------------------------
|
||||
// Der Bereich #admin bündelt alles, was NUR Administratoren zusteht. Er ist
|
||||
// aus dem Bereich „Einstellungen" herausgelöst worden: Dort standen die
|
||||
// firmenweiten Masken zunächst neben „Mein Konto", was zwei verschiedene
|
||||
// Dinge vermischte — die eigenen Kontoangaben (jedes Konto) und Vorgaben, die
|
||||
// die Zahlen ALLER verschieben. „Einstellungen" enthält seitdem nur noch das
|
||||
// eigene Konto, alles Firmenweite liegt hier.
|
||||
//
|
||||
// Der Bereich zerfällt in ABSCHNITTE, zwischen denen die aufgeklappte Gruppe
|
||||
// „Admin" in der Seitenleiste umschaltet (ADMIN_SECTIONS unten,
|
||||
// showAdminSection()). Sichtbar ist immer genau einer; der gewählte steht im
|
||||
// URL-Fragment (…/Admin#auslastung) und ist damit verlinkbar — dasselbe
|
||||
// Muster wie die Bereichsleiste des Projektbereichs (js/plan/plan-sections.js).
|
||||
//
|
||||
// „Benutzerverwaltung" — Konten, Rollen, Wochenstunden, Abwesenheiten
|
||||
// (js/admin-users.js). War bis zum Umbau eine
|
||||
// eigene Seite (admin-users.html) mit nachgebauter
|
||||
// Kopfzeile statt der Seitenleiste.
|
||||
// „Honorar" — Stundensatz und Anteile je Leistungsphase.
|
||||
// „Auslastung" — Wochenstunden einer vollen Stelle, Ampel-
|
||||
// Schwellen, Balkenskala, Aufwandsvorlage.
|
||||
// „Termine & Sitzungen" — Arbeitsstunden je Tag, automatische Sperre.
|
||||
// „Feiertage" — arbeitsfreie Tage (eigene Tabelle).
|
||||
// „Aufgaben-Vorlagen" — Standardschritte je Leistungsphase.
|
||||
// „Betrieb & Sicherheit" — Backups, Sitzungsdauer, Anmeldebremse.
|
||||
// „Datenbank & Sitzungen"— Backup herunterladen, Backup einspielen,
|
||||
// Sitzung löschen. Diese drei standen vorher als
|
||||
// nackte Menüpunkte in der Seitenleiste, wo ein
|
||||
// Fehlklick genügte; hier stehen sie mit dem
|
||||
// Hinweis, was sie anrichten.
|
||||
//
|
||||
// Honorar, Auslastung, Termine und Betrieb waren bis zum Ausbau der
|
||||
// Einstellungen Konstanten im Quelltext — teils im Frontend
|
||||
// (Aufwandsvorlage, Ampel-Schwellen, Balkenskala, Arbeitsstunden je Tag),
|
||||
// teils im Server (Backups, Sitzungsdauer, Anmeldebremse). Sie sind nicht
|
||||
// deshalb gepflegt, weil sie sich oft ändern, sondern weil sie die Zahlen
|
||||
// aller Nutzer verschieben. Gelesen werden sie über GET /app-settings (jedes
|
||||
// Konto — das Frontend rechnet damit), geschrieben über PUT /app-settings.
|
||||
//
|
||||
// Dass der Bereich für Nicht-Administratoren gar nicht erst auftaucht, ist
|
||||
// wie überall in dieser App ein reines UI-Gate; die eigentliche Sperre sitzt
|
||||
// serverseitig in requireRole('admin') auf PUT /app-settings,
|
||||
// PUT /honorar-config, POST/DELETE /lph-templates, POST/DELETE /holidays,
|
||||
// GET /backup, POST /import und POST /delete-week.
|
||||
|
||||
// ADMIN_SECTIONS: die Abschnitte in der Reihenfolge der Seitenleiste. Die
|
||||
// EINE Stelle, an der ein Abschnitt definiert wird — Schlüssel (zugleich
|
||||
// URL-Fragment) und Überschrift. Die Zuordnung zum Markup läuft über das
|
||||
// Attribut data-admin-section, sowohl an der Karte in Test.html als auch am
|
||||
// Unterpunkt in der Seitenleiste; ein neuer Abschnitt braucht damit nur einen
|
||||
// Eintrag hier plus die Karte. `wide` hebt die Breitenbegrenzung des Rasters
|
||||
// auf — die Benutzertabelle hat zehn Spalten und scrollte sonst quer.
|
||||
const ADMIN_SECTIONS = [
|
||||
{ key: 'benutzer', title: 'Benutzerverwaltung', wide: true },
|
||||
{ key: 'honorar', title: 'Honorar' },
|
||||
{ key: 'auslastung', title: 'Auslastung' },
|
||||
{ key: 'termine', title: 'Termine & Sitzungen' },
|
||||
{ key: 'feiertage', title: 'Feiertage' },
|
||||
{ key: 'vorlagen', title: 'Aufgaben-Vorlagen' },
|
||||
{ key: 'betrieb', title: 'Betrieb & Sicherheit' },
|
||||
{ key: 'datenbank', title: 'Datenbank & Sitzungen' },
|
||||
];
|
||||
|
||||
// adminSection: der gerade sichtbare Abschnitt.
|
||||
let adminSection = 'benutzer';
|
||||
|
||||
// renderAdminArea: Einstiegspunkt des Bereichs, von showTab('admin')
|
||||
// gerufen. Befüllt alle Masken und wählt danach den sichtbaren Abschnitt.
|
||||
// Keine Parameter, keine Rückgabe.
|
||||
function renderAdminArea(){
|
||||
if(!isAdminUser()) return;
|
||||
|
||||
loadAdminHonorar();
|
||||
loadAdminTemplates();
|
||||
// Die firmenweiten Zahlen und Feiertage stehen bereits in APP_SETTINGS bzw.
|
||||
// HOLIDAYS (beim Seitenstart geladen, js/store.js) — die Masken füllen sich
|
||||
// also ohne einen weiteren Serveraufruf.
|
||||
renderAppSettingFields();
|
||||
renderAdminHolidays();
|
||||
renderAdminSitzungen();
|
||||
|
||||
const fromHash = (location.hash || '').replace(/^#/, '');
|
||||
showAdminSection(adminSectionExists(fromHash) ? fromHash : adminSection);
|
||||
}
|
||||
|
||||
// isAdminUser: Kurzform für die Sichtbarkeit des Bereichs.
|
||||
// Rückgabe: Boolean.
|
||||
function isAdminUser(){
|
||||
return !!(currentUser && currentUser.role === 'admin');
|
||||
}
|
||||
|
||||
// adminSectionExists: prüft, ob ein Schlüssel ein bekannter Abschnitt ist.
|
||||
// Parameter: key. Rückgabe: Boolean.
|
||||
function adminSectionExists(key){
|
||||
return ADMIN_SECTIONS.some(x => x.key === key);
|
||||
}
|
||||
|
||||
// showAdminSection: schaltet auf einen Abschnitt um — blendet die zugehörige
|
||||
// Karte ein, alle anderen aus, hebt den Unterpunkt hervor und schreibt den
|
||||
// Schlüssel ins URL-Fragment (teilbarer Link, ohne je Klick einen
|
||||
// History-Eintrag zu erzeugen). Wird der Bereich gerade gar nicht angezeigt
|
||||
// (Klick aus einem anderen Bereich heraus), wird zuerst dorthin gewechselt.
|
||||
// Parameter: key. Keine Rückgabe.
|
||||
function showAdminSection(key){
|
||||
if(!adminSectionExists(key)) key = ADMIN_SECTIONS[0].key;
|
||||
adminSection = key;
|
||||
|
||||
if(currentTab !== 'admin'){ showTab('admin'); return; }
|
||||
|
||||
ADMIN_SECTIONS.forEach(sec => {
|
||||
// querySelectorAll, nicht -Single: ein Abschnitt kann grundsätzlich aus
|
||||
// mehreren Karten bestehen, die zusammen ein-/ausgeblendet werden.
|
||||
document.querySelectorAll('#admin [data-admin-section="' + sec.key + '"]').forEach(card => {
|
||||
card.style.display = (sec.key === key) ? '' : 'none';
|
||||
});
|
||||
const item = document.querySelector('#adminSubnav [data-admin-section="' + sec.key + '"]');
|
||||
if(item) item.classList.toggle('is-active', sec.key === key);
|
||||
});
|
||||
|
||||
const sec = ADMIN_SECTIONS.find(x => x.key === key);
|
||||
const title = document.getElementById('adminSectionTitle');
|
||||
if(title && sec) title.textContent = 'Admin — ' + sec.title;
|
||||
|
||||
const grid = document.querySelector('#admin .settings-grid');
|
||||
if(grid) grid.classList.toggle('is-wide', !!(sec && sec.wide));
|
||||
|
||||
// Die Benutzerliste wird beim Betreten des Abschnitts frisch geholt: Sie
|
||||
// aendert sich auch ausserhalb dieses Browsers, und die uebrigen Masken
|
||||
// rechnen mit ihr (Wochenstunden -> Auslastung).
|
||||
if(key === 'benutzer' && typeof renderAdminUsers === 'function') renderAdminUsers();
|
||||
|
||||
try {
|
||||
history.replaceState(null, '', location.pathname + location.search + '#' + key);
|
||||
} catch(e) { /* z. B. file:// — dann bleibt die Adresse eben unverändert */ }
|
||||
}
|
||||
|
||||
// toggleAdminNav: Kopf-Eintrag der Gruppe. Er klappt nicht nur auf und zu wie
|
||||
// „Werkzeuge", sondern führt auch in den Bereich — die Unterpunkte sind hier
|
||||
// Navigation innerhalb einer Seite, keine Aktionen, und ein Kopf, der beim
|
||||
// Anklicken nichts zeigt, wäre irreführend. Steht man WOANDERS, führt ein
|
||||
// Klick immer hinein und klappt auf; nur wer schon drin ist, klappt damit auf
|
||||
// und zu. Keine Parameter, keine Rückgabe.
|
||||
function toggleAdminNav(){
|
||||
if(currentTab !== 'admin'){
|
||||
showTab('admin'); // klappt über syncAdminNavState() auch auf
|
||||
return;
|
||||
}
|
||||
toggleSidebarAccordion('admin');
|
||||
}
|
||||
|
||||
// Ein von außen geändertes Fragment (Vor-/Zurück-Taste, getippter Anker)
|
||||
// mitziehen — showAdminSection() selbst nutzt replaceState und löst dieses
|
||||
// Ereignis daher nicht aus.
|
||||
window.addEventListener('hashchange', () => {
|
||||
if(currentTab !== 'admin') return;
|
||||
const key = (location.hash || '').replace(/^#/, '');
|
||||
if(adminSectionExists(key) && key !== adminSection) showAdminSection(key);
|
||||
});
|
||||
|
||||
// ---------- Firmenweite Zahlen (PUT /app-settings) ----------
|
||||
//
|
||||
// Bedient die Abschnitte Auslastung, Termine & Sitzungen und Betrieb.
|
||||
|
||||
// APP_SETTING_FIELDS: Zuordnung Eingabefeld -> Einstellungs-Schlüssel, je
|
||||
// Abschnitt. Eine Tabelle statt einzelner getElementById-Zeilen, damit Füllen
|
||||
// und Speichern denselben Weg gehen und ein neues Feld nur hier auftaucht.
|
||||
const APP_SETTING_FIELDS = {
|
||||
auslastung: {
|
||||
setVollzeitStunden: 'vollzeitStundenProWoche',
|
||||
setSchwelleGelb: 'auslastungSchwelleGelb',
|
||||
setSchwelleRot: 'auslastungSchwelleRot',
|
||||
// Das frühere Feld "Maximum der Balkenskala" (auslastungBalkenMax) ist
|
||||
// entfallen: Seit die Auslastung in Stunden rechnet, ist die Skala die
|
||||
// Kapazität der Person — ein volles Band ist eine volle Woche. Die feste
|
||||
// Vorgabe 8 hatte zur Folge, dass ein roter Balken (ab 1,0) nur zu einem
|
||||
// Achtel gefüllt war; Balken und Ampel widersprachen einander.
|
||||
setReferenzHonorar: 'referenzHonorar',
|
||||
setVerteilWochen: 'verteilzeitraumWochen',
|
||||
},
|
||||
termine: {
|
||||
setStundenProTag: 'arbeitsstundenProTag',
|
||||
},
|
||||
betrieb: {
|
||||
setMaxBackups: 'maxBackups',
|
||||
setSessionTage: 'sessionTage',
|
||||
setLoginVersuche: 'loginMaxVersuche',
|
||||
setLoginSperre: 'loginSperreMinuten',
|
||||
},
|
||||
};
|
||||
|
||||
// renderAppSettingFields: schreibt die aktuellen Werte aus APP_SETTINGS in
|
||||
// alle Eingabefelder und baut die Aufwands-Matrix auf. Ein gerade
|
||||
// fokussiertes Feld bleibt unangetastet, sonst spränge beim Tippen der Cursor.
|
||||
// Keine Parameter, keine Rückgabe.
|
||||
function renderAppSettingFields(){
|
||||
Object.values(APP_SETTING_FIELDS).forEach(group => {
|
||||
Object.entries(group).forEach(([elId, key]) => {
|
||||
const el = document.getElementById(elId);
|
||||
if(el && document.activeElement !== el) el.value = APP_SETTINGS[key];
|
||||
});
|
||||
});
|
||||
const autoLock = document.getElementById('setAutoLock');
|
||||
if(autoLock) autoLock.checked = !!Number(APP_SETTINGS.autoLockAktiv);
|
||||
renderAufwandMatrix();
|
||||
}
|
||||
|
||||
// renderAufwandMatrix: die firmenweite Gewichtung als Tabelle Leistungsphase ×
|
||||
// Rolle. „W+M" fehlt bewusst — das Aufwandsmodell kennt nur die
|
||||
// Leistungsphasen (AUFWAND_LPS in js/load/load.js). Ein leeres Feld bedeutet
|
||||
// „kein Eintrag"; dafür zählt die Auslastung dann den vollen Faktor 1,0.
|
||||
// Keine Parameter, keine Rückgabe.
|
||||
function renderAufwandMatrix(){
|
||||
const wrap = document.getElementById('setAufwandMatrix');
|
||||
if(!wrap) return;
|
||||
const vorlage = APP_SETTINGS.aufwandVorlage || {};
|
||||
const phasen = LPHS.filter(l => l !== 'W+M');
|
||||
|
||||
wrap.innerHTML = `<table class="settings-matrix">
|
||||
<thead><tr><th>Phase</th>${ROLES.map(r => `<th>${esc(r)}</th>`).join('')}</tr></thead>
|
||||
<tbody>${phasen.map(l => `<tr>
|
||||
<th>${esc(l)}</th>
|
||||
${ROLES.map(r => {
|
||||
const v = vorlage[l] ? vorlage[l][r] : undefined;
|
||||
return `<td><input type="number" min="0" max="1" step="0.1"
|
||||
data-lph="${esc(l)}" data-role="${esc(r)}"
|
||||
value="${v === undefined ? '' : esc(v)}" placeholder="1,0"></td>`;
|
||||
}).join('')}
|
||||
</tr>`).join('')}</tbody>
|
||||
</table>`;
|
||||
}
|
||||
|
||||
// collectAufwandMatrix: liest die Matrix aus den Feldern zurück. Leere Felder
|
||||
// werden weggelassen (siehe renderAufwandMatrix), 0 ist ein gültiger Wert und
|
||||
// bleibt erhalten. Rückgabe: Objekt LPH -> { Rolle: Faktor }.
|
||||
function collectAufwandMatrix(){
|
||||
const wrap = document.getElementById('setAufwandMatrix');
|
||||
const out = {};
|
||||
if(!wrap) return out;
|
||||
wrap.querySelectorAll('input[data-lph]').forEach(inp => {
|
||||
if(inp.value.trim() === '') return;
|
||||
const v = parseFloat(inp.value);
|
||||
if(!Number.isFinite(v)) return;
|
||||
const lph = inp.dataset.lph;
|
||||
(out[lph] = out[lph] || {})[inp.dataset.role] = v;
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
// putAppSettings: schickt einen Teil-Satz an PUT /app-settings und übernimmt
|
||||
// die Antwort (der Server liefert immer den vollständigen, geprüften Stand
|
||||
// zurück) in APP_SETTINGS. Danach rechnet die Anwendung sofort damit — ein
|
||||
// Neuladen ist nicht nötig, deshalb wird die Auslastung gleich neu gezeichnet,
|
||||
// falls sie gerade sichtbar ist. Parameter: patch (Objekt), erfolgsText.
|
||||
// Rückgabe: keine.
|
||||
function putAppSettings(patch, erfolgsText){
|
||||
fetch(baseUrl() + '/app-settings', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json; charset=utf-8' },
|
||||
body: JSON.stringify(patch),
|
||||
})
|
||||
.then(r => r.json().then(json => ({ ok: r.ok, json })))
|
||||
.then(({ ok, json }) => {
|
||||
if(!ok || !json.ok) throw new Error((json && json.error) || 'Speichern fehlgeschlagen.');
|
||||
APP_SETTINGS = Object.assign({}, APP_SETTINGS, json.settings || {});
|
||||
renderAppSettingFields();
|
||||
showAdminNote(erfolgsText);
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('Einstellungen speichern fehlgeschlagen:', err);
|
||||
alert('Einstellungen konnten nicht gespeichert werden: ' + (err.message || err));
|
||||
});
|
||||
}
|
||||
|
||||
// fieldValues: sammelt die Zahlenwerte einer Gruppe aus APP_SETTING_FIELDS.
|
||||
// Parameter: gruppe (Schlüssel in APP_SETTING_FIELDS). Rückgabe: Objekt.
|
||||
function fieldValues(gruppe){
|
||||
const patch = {};
|
||||
Object.entries(APP_SETTING_FIELDS[gruppe]).forEach(([elId, key]) => {
|
||||
const el = document.getElementById(elId);
|
||||
if(el && el.value.trim() !== '') patch[key] = parseFloat(el.value);
|
||||
});
|
||||
return patch;
|
||||
}
|
||||
|
||||
// saveAuslastungSettings: Schwellen, Skala, Vollzeit-Stunden und die
|
||||
// Aufwandsvorlage in einem Zug. Keine Parameter, keine Rückgabe.
|
||||
function saveAuslastungSettings(){
|
||||
const patch = fieldValues('auslastung');
|
||||
patch.aufwandVorlage = collectAufwandMatrix();
|
||||
putAppSettings(patch, 'Auslastungs-Einstellungen gespeichert.');
|
||||
}
|
||||
|
||||
// saveTerminSettings: Arbeitsstunden je Tag und die automatische Sperre.
|
||||
// Keine Parameter, keine Rückgabe.
|
||||
// juengsteWoche: die chronologisch letzte Sitzung — das ist die, die
|
||||
// applyAutoLock() im Normalbetrieb offen hält und auf die db.currentWeek
|
||||
// nach jedem vollständigen Neuladen zurückfällt. Rückgabe: Woche-Objekt oder
|
||||
// undefined (keine Sitzungen vorhanden).
|
||||
function juengsteWoche(){
|
||||
return [...db.weeks].sort((a, b) => weekSortKey(a.label).localeCompare(weekSortKey(b.label))).pop();
|
||||
}
|
||||
|
||||
// renderAdminSitzungen: listet alle Sitzungen (js/weeks.js: db.weeks),
|
||||
// neueste zuerst, mit Datum, Zustand und den passenden Aktionen.
|
||||
//
|
||||
// Diese Liste ist seit dem Wegfall der Sitzungsauswahl im Menüband die
|
||||
// einzige Stelle, die alle Sitzungen nebeneinander zeigt — die übrige
|
||||
// Oberfläche zeigt immer nur die eine offene. Vier Zustände:
|
||||
// "Geöffnet" — das ist gerade db.currentWeek UND zugleich die
|
||||
// jüngste Sitzung: der Normalfall, keine Aktion
|
||||
// nötig.
|
||||
// "Geöffnet (Korrektur)" — db.currentWeek, aber NICHT die jüngste — man
|
||||
// hat eine ältere Sitzung über "Öffnen" zur
|
||||
// Bearbeitung geholt. "Fertig – wieder sperren"
|
||||
// sperrt sie zurück UND wechselt die Oberfläche
|
||||
// wieder auf die jüngste Sitzung, statt beides
|
||||
// getrennten Handgriffen zu überlassen.
|
||||
// "Gesperrt" — historisch; "Entsperren" macht sie für eine
|
||||
// Korrektur kurz editierbar. Ist die Auto-Sperre
|
||||
// aktiv, sperrt sie beim nächsten Neuladen (oder
|
||||
// beim nächsten Anlegen/Duplizieren/Löschen
|
||||
// einer Sitzung — applyAutoLock() läuft dabei
|
||||
// überall) automatisch wieder.
|
||||
// "Entsperrt" — editierbar, aber gerade nicht die angezeigte
|
||||
// Sitzung. Kommt vor, wenn eine gesperrte
|
||||
// entsperrt, aber noch nicht geöffnet wurde, wenn
|
||||
// man von einer Korrektur zur jüngsten
|
||||
// zurückgekehrt ist (die bleibt dabei entsperrt),
|
||||
// oder wenn die Auto-Sperre firmenweit aus ist.
|
||||
// "Öffnen" macht sie zur angezeigten Sitzung.
|
||||
// Keine Parameter, keine Rückgabe.
|
||||
function renderAdminSitzungen(){
|
||||
const el = document.getElementById('adminSessionsList');
|
||||
if(!el || !Array.isArray(db.weeks)) return;
|
||||
|
||||
const juengste = juengsteWoche();
|
||||
const sortiert = [...db.weeks].sort((a, b) => weekSortKey(b.label).localeCompare(weekSortKey(a.label)));
|
||||
el.innerHTML = sortiert.map(w => {
|
||||
const aktuell = w.id === db.currentWeek;
|
||||
const istJuengste = juengste && w.id === juengste.id;
|
||||
const datum = w.date ? formatWeekDate(w.date) : '';
|
||||
|
||||
let chip, aktionen;
|
||||
if(aktuell && istJuengste){
|
||||
chip = '<span class="sitzung-chip ist-geoeffnet">Geöffnet</span>';
|
||||
aktionen = '';
|
||||
} else if(aktuell){
|
||||
// Zur Bearbeitung geöffnete, ältere Sitzung — noch nicht wieder
|
||||
// gesperrt. Ohne diesen eigenen Zustand sähe man hier "Geöffnet" ohne
|
||||
// jede Aktion und hätte keinen Weg zurück außer über eine andere,
|
||||
// zufällige applyAutoLock()-auslösende Handlung.
|
||||
chip = '<span class="sitzung-chip ist-geoeffnet">Geöffnet (Korrektur)</span>';
|
||||
aktionen = `<button class="secondary small" onclick="adminRelockWeek('${esc(w.id)}')">Fertig – wieder sperren</button>`;
|
||||
} else if(w.locked){
|
||||
chip = '<span class="sitzung-chip ist-gesperrt">Gesperrt</span>';
|
||||
aktionen = `<button class="secondary small" onclick="adminUnlockWeek('${esc(w.id)}')">Entsperren</button>`;
|
||||
} else {
|
||||
chip = '<span class="sitzung-chip ist-entsperrt">Entsperrt</span>';
|
||||
aktionen = `<button class="secondary small" onclick="adminOpenWeek('${esc(w.id)}')">Öffnen</button>`;
|
||||
}
|
||||
|
||||
return `<div class="template-admin-row">
|
||||
<span class="template-admin-title">${esc(w.label)}${datum ? ' · ' + esc(datum) : ''}</span>
|
||||
${chip}
|
||||
${aktionen}
|
||||
<button class="danger small" onclick="adminDeleteWeek('${esc(w.id)}')" title="Sitzung löschen">×</button>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// adminUnlockWeek / adminDeleteWeek / adminOpenWeek / adminRelockWeek: dünne
|
||||
// Wrapper der Sitzungsliste um die allgemeinen Funktionen aus js/weeks.js.
|
||||
// Nach Entsperren/Löschen/Sperren zeichnet sich NUR diese Liste neu (nicht
|
||||
// die ganze Seite) — die übrige Oberfläche zeigt ohnehin nur die eine offene
|
||||
// Sitzung und ändert sich durch das Entsperren oder Löschen einer anderen
|
||||
// nicht. "Öffnen" macht dagegen ausdrücklich sichtbar, was sich geändert
|
||||
// hat: Es wechselt zur Sitzung UND zum Board, statt sie nur im Hintergrund
|
||||
// zur aktuellen zu machen.
|
||||
function adminUnlockWeek(weekId){
|
||||
unlockWeek(db.weeks.find(w => w.id === weekId), renderAdminSitzungen);
|
||||
}
|
||||
function adminDeleteWeek(weekId){
|
||||
deleteWeekById(weekId, renderAdminSitzungen);
|
||||
}
|
||||
function adminOpenWeek(weekId){
|
||||
switchWeek(weekId);
|
||||
showTab('board');
|
||||
}
|
||||
|
||||
// adminRelockWeek: schließt eine Korrektur ab — sperrt die übergebene
|
||||
// Sitzung wieder UND stellt die jüngste Sitzung als db.currentWeek wieder
|
||||
// her, statt beides getrennten Schritten zu überlassen (sperren allein
|
||||
// ließe die Oberfläche weiter auf der alten Sitzung stehen; nur zurück-
|
||||
// wechseln ließe sie entsperrt liegen). markWeekDirty() + save({week:false})
|
||||
// statt des einfachen save(), weil save() ohne Parameter db.currentWeek
|
||||
// markieren würde — und das ist zu diesem Zeitpunkt schon die jüngste, NICHT
|
||||
// die gerade gesperrte Sitzung (gleiches Muster wie savePlan() in
|
||||
// js/plan/plan-state.js). Parameter: weekId. Keine Rückgabe.
|
||||
function adminRelockWeek(weekId){
|
||||
const w = db.weeks.find(x => x.id === weekId);
|
||||
if(!w || w.locked) return;
|
||||
w.locked = true;
|
||||
markWeekDirty(w.label);
|
||||
save({ week: false });
|
||||
const juengste = juengsteWoche();
|
||||
if(juengste) db.currentWeek = juengste.id;
|
||||
renderAdminSitzungen();
|
||||
}
|
||||
|
||||
function saveTerminSettings(){
|
||||
const patch = fieldValues('termine');
|
||||
const autoLock = document.getElementById('setAutoLock');
|
||||
if(autoLock) patch.autoLockAktiv = autoLock.checked ? 1 : 0;
|
||||
putAppSettings(patch, 'Termin-Einstellungen gespeichert.');
|
||||
}
|
||||
|
||||
// saveBetriebSettings: Backups, Sitzungsdauer und Anmeldebremse.
|
||||
// Keine Parameter, keine Rückgabe.
|
||||
function saveBetriebSettings(){
|
||||
putAppSettings(fieldValues('betrieb'), 'Betriebs-Einstellungen gespeichert.');
|
||||
}
|
||||
|
||||
|
||||
// ---------- Abschnitt Honorar ----------
|
||||
|
||||
// loadAdminHonorar: holt Anteile und Stundensatz (GET /honorar-config, für
|
||||
// jedes angemeldete Konto lesbar) und rendert die Maske.
|
||||
// Keine Parameter, keine Rückgabe.
|
||||
function loadAdminHonorar(){
|
||||
const fieldsEl = document.getElementById('honorarAdminFields');
|
||||
if(fieldsEl) fieldsEl.innerHTML = '<span class="hint">Lade Honorar-Einstellungen…</span>';
|
||||
fetch(baseUrl() + '/honorar-config')
|
||||
.then(r => { if(!r.ok) throw new Error('Server HTTP ' + r.status); return r.json(); })
|
||||
.then(data => {
|
||||
adminHonorar = { anteile: data.anteile || {}, stundensatz: Number(data.stundensatz) || 135 };
|
||||
renderAdminHonorar();
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('Honorar-Konfiguration laden fehlgeschlagen:', err);
|
||||
if(fieldsEl) fieldsEl.innerHTML = '<span class="hint">Honorar-Einstellungen konnten nicht geladen werden.</span>';
|
||||
});
|
||||
}
|
||||
|
||||
// renderAdminHonorar: ein Eingabefeld je Leistungsphase plus die laufende
|
||||
// Summe. Die Summe steht dabei, weil die HOAI-Sätze eines Leistungsbildes
|
||||
// zusammen 100 % ergeben — ein unvollständiger Satz fällt sonst erst auf,
|
||||
// wenn ein gerechneter Terminplan zu kurz gerät.
|
||||
// Keine Parameter, keine Rückgabe.
|
||||
function renderAdminHonorar(){
|
||||
const fieldsEl = document.getElementById('honorarAdminFields');
|
||||
if(!fieldsEl || !adminHonorar) return;
|
||||
|
||||
const anteile = adminHonorar.anteile || {};
|
||||
fieldsEl.innerHTML = LPHS.map(l => `<label class="honorar-anteil-field">
|
||||
<span>${esc(l)}</span>
|
||||
<input type="number" min="0" max="100" step="0.1" data-lph="${esc(l)}"
|
||||
value="${anteile[l] !== undefined ? esc(anteile[l]) : ''}" placeholder="0"
|
||||
oninput="refreshAdminHonorarSum()">
|
||||
</label>`).join('');
|
||||
|
||||
const satzEl = document.getElementById('honorarStundensatz');
|
||||
if(satzEl && document.activeElement !== satzEl) satzEl.value = adminHonorar.stundensatz;
|
||||
|
||||
refreshAdminHonorarSum();
|
||||
}
|
||||
|
||||
// refreshAdminHonorarSum: rechnet die Summe der eingetippten Anteile neu
|
||||
// und färbt sie (grün bei 100 %, sonst Warnfarbe). Läuft bei jeder Eingabe,
|
||||
// liest deshalb aus den Feldern und nicht aus adminHonorar.
|
||||
// Keine Parameter, keine Rückgabe.
|
||||
function refreshAdminHonorarSum(){
|
||||
const fieldsEl = document.getElementById('honorarAdminFields');
|
||||
const sumEl = document.getElementById('honorarAdminSum');
|
||||
if(!fieldsEl || !sumEl) return;
|
||||
|
||||
let summe = 0;
|
||||
fieldsEl.querySelectorAll('input[data-lph]').forEach(inp => {
|
||||
const v = parseFloat(inp.value);
|
||||
if(Number.isFinite(v)) summe += v;
|
||||
});
|
||||
const ok = Math.abs(summe - 100) < 0.05;
|
||||
sumEl.innerHTML = `<span class="honorar-sum ${ok ? 'ok' : 'warn'}">Summe ${summe.toFixed(1)} %</span>`
|
||||
+ (ok ? '' : ' <span class="hint" style="margin:0">— die Sätze eines Leistungsbildes ergeben zusammen 100 %.</span>');
|
||||
}
|
||||
|
||||
// saveHonorarConfig: schreibt Anteile und Stundensatz firmenweit
|
||||
// (PUT /honorar-config, Admin-only). Danach wird der Zwischenspeicher des
|
||||
// Honorar-Dialogs im Projektbereich verworfen, damit er nicht mit den alten
|
||||
// Zahlen weiterrechnet. Keine Parameter, keine Rückgabe.
|
||||
function saveHonorarConfig(){
|
||||
const fieldsEl = document.getElementById('honorarAdminFields');
|
||||
const anteile = {};
|
||||
if(fieldsEl){
|
||||
fieldsEl.querySelectorAll('input[data-lph]').forEach(inp => {
|
||||
const v = parseFloat(inp.value);
|
||||
if(Number.isFinite(v) && v > 0) anteile[inp.dataset.lph] = v;
|
||||
});
|
||||
}
|
||||
const satzEl = document.getElementById('honorarStundensatz');
|
||||
const stundensatz = satzEl ? parseFloat(satzEl.value) : undefined;
|
||||
|
||||
fetch(baseUrl() + '/honorar-config', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json; charset=utf-8' },
|
||||
body: JSON.stringify({ anteile, stundensatz }),
|
||||
})
|
||||
.then(r => r.json().then(json => ({ ok: r.ok, json })))
|
||||
.then(({ ok, json }) => {
|
||||
if(!ok || !json.ok) throw new Error((json && json.error) || 'Speichern fehlgeschlagen.');
|
||||
adminHonorar = { anteile: json.anteile || {}, stundensatz: Number(json.stundensatz) || 135 };
|
||||
if(typeof invalidateHonorarConfig === 'function') invalidateHonorarConfig();
|
||||
renderAdminHonorar();
|
||||
showAdminNote('Honorar-Einstellungen gespeichert.');
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('Honorar-Konfiguration speichern fehlgeschlagen:', err);
|
||||
alert('Einstellungen konnten nicht gespeichert werden: ' + (err.message || err));
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// ---------- Abschnitt Feiertage ----------
|
||||
|
||||
// renderAdminHolidays: listet die gepflegten arbeitsfreien Tage
|
||||
// chronologisch, mit Wochentag (ein Feiertag auf einem Samstag ändert an der
|
||||
// Rechnung nichts — das soll man sehen) und Lösch-Knopf.
|
||||
// Keine Parameter, keine Rückgabe.
|
||||
function renderAdminHolidays(){
|
||||
renderHolidayYearHint();
|
||||
renderHolidayStateSelect();
|
||||
|
||||
const el = document.getElementById('adminHolidayList');
|
||||
if(!el) return;
|
||||
const dates = Array.from(HOLIDAYS).sort();
|
||||
if(dates.length === 0){
|
||||
el.innerHTML = '<span class="hint">Noch keine arbeitsfreien Tage hinterlegt — es zählen nur Wochenenden.</span>';
|
||||
return;
|
||||
}
|
||||
const wochentage = ['Sonntag','Montag','Dienstag','Mittwoch','Donnerstag','Freitag','Samstag'];
|
||||
el.innerHTML = dates.map(d => {
|
||||
const date = parseDateISO(d);
|
||||
const tag = date ? wochentage[date.getDay()] : '';
|
||||
const amWochenende = date && (date.getDay() === 0 || date.getDay() === 6);
|
||||
return `<div class="template-admin-row">
|
||||
<span class="template-admin-title">${esc(formatDateDE(date))} · ${esc(HOLIDAY_NAMES[d] || 'Feiertag')}</span>
|
||||
<span class="hint" style="margin:0">${esc(tag)}${amWochenende ? ' — ohnehin arbeitsfrei' : ''}</span>
|
||||
<button class="danger small" onclick="deleteHoliday('${esc(d)}')" title="Tag entfernen">×</button>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// addHoliday: legt einen arbeitsfreien Tag an (POST /holidays, Admin-only).
|
||||
// Ein bereits vorhandenes Datum wird nur umbenannt — das Datum ist der
|
||||
// Primärschlüssel. Keine Parameter, keine Rückgabe.
|
||||
function addHoliday(){
|
||||
const dateEl = document.getElementById('holidayNewDate');
|
||||
const nameEl = document.getElementById('holidayNewName');
|
||||
const date = dateEl ? dateEl.value : '';
|
||||
if(!date){ alert('Bitte ein Datum wählen.'); return; }
|
||||
|
||||
fetch(baseUrl() + '/holidays', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json; charset=utf-8' },
|
||||
body: JSON.stringify({ date, name: nameEl ? nameEl.value.trim() : '' }),
|
||||
})
|
||||
.then(r => r.json().then(json => ({ ok: r.ok, json })))
|
||||
.then(({ ok, json }) => {
|
||||
if(!ok || !json.ok) throw new Error((json && json.error) || 'Anlegen fehlgeschlagen.');
|
||||
HOLIDAYS.add(json.holiday.date);
|
||||
HOLIDAY_NAMES[json.holiday.date] = json.holiday.name || '';
|
||||
if(nameEl) nameEl.value = '';
|
||||
renderAdminHolidays();
|
||||
showAdminNote('Arbeitsfreier Tag hinterlegt.');
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('Feiertag anlegen fehlgeschlagen:', err);
|
||||
alert('Tag konnte nicht angelegt werden: ' + (err.message || err));
|
||||
});
|
||||
}
|
||||
|
||||
// deleteHoliday: entfernt einen arbeitsfreien Tag (DELETE, Admin-only).
|
||||
// Bereits errechnete Terminpläne bleiben unberührt — sie sind nach dem
|
||||
// Einfügen ganz normale Aufgaben. Parameter: date (ISO). Keine Rückgabe.
|
||||
function deleteHoliday(date){
|
||||
fetch(baseUrl() + '/holidays/' + encodeURIComponent(date), { method: 'DELETE' })
|
||||
.then(r => r.json().then(json => ({ ok: r.ok, json })))
|
||||
.then(({ ok, json }) => {
|
||||
if(!ok || !json.ok) throw new Error((json && json.error) || 'Löschen fehlgeschlagen.');
|
||||
HOLIDAYS.delete(date);
|
||||
delete HOLIDAY_NAMES[date];
|
||||
renderAdminHolidays();
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('Feiertag löschen fehlgeschlagen:', err);
|
||||
alert('Tag konnte nicht entfernt werden: ' + (err.message || err));
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- Abschnitt Aufgaben-Vorlagen ----------
|
||||
|
||||
// loadAdminTemplates: holt alle Vorlagen-Schritte (GET /lph-templates) und
|
||||
// rendert die Liste der gerade gewählten Leistungsphase.
|
||||
// Keine Parameter, keine Rückgabe.
|
||||
function loadAdminTemplates(){
|
||||
const select = document.getElementById('adminTemplateLph');
|
||||
if(select && !select.options.length){
|
||||
select.innerHTML = LPHS.map(l => `<option value="${esc(l)}">${esc(l)}</option>`).join('');
|
||||
}
|
||||
const listEl = document.getElementById('templateAdminList');
|
||||
if(listEl) listEl.innerHTML = '<span class="hint">Lade Vorlagen…</span>';
|
||||
|
||||
fetch(baseUrl() + '/lph-templates')
|
||||
.then(r => { if(!r.ok) throw new Error('Server HTTP ' + r.status); return r.json(); })
|
||||
.then(data => {
|
||||
adminTemplates = Array.isArray(data.templates) ? data.templates : [];
|
||||
renderAdminTemplates();
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('Vorlagen laden fehlgeschlagen:', err);
|
||||
if(listEl) listEl.innerHTML = '<span class="hint">Vorlagen konnten nicht geladen werden.</span>';
|
||||
});
|
||||
}
|
||||
|
||||
// renderAdminTemplates: zeigt die Schritte der gewählten Leistungsphase in
|
||||
// ihrer gepflegten Reihenfolge, je Zeile mit Versatz/Dauer und Lösch-Knopf.
|
||||
// Wird auch vom onchange der Leistungsphasen-Auswahl gerufen.
|
||||
// Keine Parameter, keine Rückgabe.
|
||||
function renderAdminTemplates(){
|
||||
const listEl = document.getElementById('templateAdminList');
|
||||
if(!listEl) return;
|
||||
const select = document.getElementById('adminTemplateLph');
|
||||
const lph = select ? select.value : '';
|
||||
const entries = adminTemplates.filter(t => t.lph === lph);
|
||||
|
||||
listEl.innerHTML = entries.length === 0
|
||||
? '<span class="hint">Für diese Leistungsphase ist noch kein Schritt hinterlegt.</span>'
|
||||
: entries.map(t => `<div class="template-admin-row">
|
||||
<span class="template-admin-title">${esc(t.title)}</span>
|
||||
<span class="hint" style="margin:0">Start +${esc(t.offsetDays)} Tage · Dauer ${esc(t.durationDays)} Tage</span>
|
||||
<button class="danger small" onclick="deleteTemplateEntry('${esc(t.id)}')" title="Schritt aus der Vorlage löschen">×</button>
|
||||
</div>`).join('');
|
||||
}
|
||||
|
||||
// addTemplateEntry: legt einen Schritt für die gewählte Leistungsphase an
|
||||
// (POST /lph-templates, Admin-only). Keine Parameter, keine Rückgabe.
|
||||
function addTemplateEntry(){
|
||||
const select = document.getElementById('adminTemplateLph');
|
||||
const titleEl = document.getElementById('templateNewTitle');
|
||||
const offsetEl = document.getElementById('templateNewOffset');
|
||||
const durationEl = document.getElementById('templateNewDuration');
|
||||
const title = titleEl ? titleEl.value.trim() : '';
|
||||
if(!title){ alert('Bitte eine Bezeichnung für den Schritt eingeben.'); return; }
|
||||
|
||||
fetch(baseUrl() + '/lph-templates', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json; charset=utf-8' },
|
||||
body: JSON.stringify({
|
||||
lph: select ? select.value : '',
|
||||
title,
|
||||
offsetDays: offsetEl ? parseInt(offsetEl.value, 10) || 0 : 0,
|
||||
durationDays: durationEl ? parseInt(durationEl.value, 10) || 1 : 1,
|
||||
})
|
||||
})
|
||||
.then(r => r.json().then(json => ({ ok: r.ok, json })))
|
||||
.then(({ ok, json }) => {
|
||||
if(!ok || !json.ok) throw new Error((json && json.error) || 'Anlegen fehlgeschlagen.');
|
||||
if(titleEl) titleEl.value = '';
|
||||
loadAdminTemplates();
|
||||
showAdminNote('Schritt angelegt.');
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('Vorlagen-Schritt anlegen fehlgeschlagen:', err);
|
||||
alert('Schritt konnte nicht angelegt werden: ' + (err.message || err));
|
||||
});
|
||||
}
|
||||
|
||||
// deleteTemplateEntry: löscht einen Vorlagen-Schritt (DELETE, Admin-only).
|
||||
// Bereits in Projekte eingefügte Aufgaben bleiben unberührt — sie sind nach
|
||||
// dem Einfügen ganz normale plan_todos ohne Verbindung zur Vorlage.
|
||||
// Parameter: id (String). Keine Rückgabe.
|
||||
function deleteTemplateEntry(id){
|
||||
if(!confirm('Diesen Schritt aus der Vorlage löschen?\n\nBereits in Projekte eingefügte Aufgaben bleiben erhalten.')) return;
|
||||
fetch(baseUrl() + '/lph-templates/' + encodeURIComponent(id), { method: 'DELETE' })
|
||||
.then(r => r.json().then(json => ({ ok: r.ok, json })))
|
||||
.then(({ ok, json }) => {
|
||||
if(!ok || !json.ok) throw new Error((json && json.error) || 'Löschen fehlgeschlagen.');
|
||||
loadAdminTemplates();
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('Vorlagen-Schritt löschen fehlgeschlagen:', err);
|
||||
alert('Schritt konnte nicht gelöscht werden: ' + (err.message || err));
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// ---------- Rückmeldung ----------
|
||||
|
||||
// showAdminNote: kurze Bestätigung am Fuß des Bereichs. Bewusst kein
|
||||
// alert(): Ein erfolgreiches Speichern soll den Arbeitsfluss nicht anhalten,
|
||||
// ein Fehlschlag dagegen schon (die Fehlerfälle oben nutzen weiter alert).
|
||||
// Parameter: text (String). Keine Rückgabe.
|
||||
function showAdminNote(text){
|
||||
const el = document.getElementById('adminNote');
|
||||
// Ein leerer Text bedeutet "nichts melden": So kann ein Speichervorgang,
|
||||
// der nur nebenbei laeuft (etwa das gemerkte Bundesland beim Feiertags-
|
||||
// Import), die eigentliche Rueckmeldung stehen lassen, statt sie zu
|
||||
// ueberschreiben.
|
||||
if(!el || !text) return;
|
||||
el.textContent = text;
|
||||
el.classList.add('show');
|
||||
if(showAdminNote.timer) clearTimeout(showAdminNote.timer);
|
||||
showAdminNote.timer = setTimeout(() => { el.classList.remove('show'); }, 2500);
|
||||
}
|
||||
|
||||
// ---------- Gesetzliche Feiertage berechnen lassen ----------
|
||||
//
|
||||
// Die Tage kommen NICHT von einem Netzdienst, sondern aus dem Kalender:
|
||||
// holidaysForYear() in server.js rechnet sie aus (feste Termine plus alles,
|
||||
// was am Osterdatum haengt). Das spart eine Abhaengigkeit von fremder
|
||||
// Verfuegbarkeit und einem Internetzugang des Servers, den es im Buero-Netz
|
||||
// nicht zwingend gibt. Eingetragen werden sie erst auf Knopfdruck — sie
|
||||
// verschieben jeden gerechneten Terminplan.
|
||||
|
||||
// renderHolidayStateSelect: fuellt Jahr und Bundesland vor. Das Bundesland
|
||||
// steht als firmenweite Einstellung (feiertagBundesland), damit es nicht bei
|
||||
// jedem Jahr neu gewaehlt werden muss. Keine Parameter, keine Rueckgabe.
|
||||
function renderHolidayStateSelect(){
|
||||
const jahrEl = document.getElementById('holidayYear');
|
||||
if(jahrEl && !jahrEl.value) jahrEl.value = new Date().getFullYear();
|
||||
|
||||
const sel = document.getElementById('holidayState');
|
||||
if(!sel) return;
|
||||
const laender = Object.entries(GERMAN_STATES);
|
||||
if(laender.length === 0) return; // /holidays noch nicht beantwortet
|
||||
const gewaehlt = sel.value || APP_SETTINGS.feiertagBundesland || 'NW';
|
||||
sel.innerHTML = laender
|
||||
.map(([k, name]) => `<option value="${esc(k)}"${k === gewaehlt ? ' selected' : ''}>${esc(name)}</option>`)
|
||||
.join('');
|
||||
}
|
||||
|
||||
// renderHolidayYearHint: weist darauf hin, wenn fuer das laufende oder das
|
||||
// kommende Jahr noch kein einziger Tag hinterlegt ist. Ohne diesen Hinweis
|
||||
// faellt erst beim naechsten falsch gerechneten Terminplan auf, dass die
|
||||
// Feiertage fehlen. Keine Parameter, keine Rueckgabe.
|
||||
function renderHolidayYearHint(){
|
||||
const el = document.getElementById('holidayYearHint');
|
||||
if(!el) return;
|
||||
const jahre = [new Date().getFullYear(), new Date().getFullYear() + 1];
|
||||
const fehlend = jahre.filter(j => !Array.from(HOLIDAYS).some(d => d.startsWith(String(j))));
|
||||
if(fehlend.length === 0){ el.style.display = 'none'; return; }
|
||||
el.style.display = '';
|
||||
el.textContent = fehlend.length === 2
|
||||
? `Für ${fehlend[0]} und ${fehlend[1]} ist noch kein arbeitsfreier Tag hinterlegt.`
|
||||
: `Für ${fehlend[0]} ist noch kein arbeitsfreier Tag hinterlegt.`;
|
||||
}
|
||||
|
||||
// suggestHolidays: laesst sich die gesetzlichen Feiertage des gewaehlten
|
||||
// Jahres und Bundeslandes berechnen (GET /holidays/suggest) und zeigt sie zur
|
||||
// Durchsicht. Bereits hinterlegte Tage sind als solche gekennzeichnet und
|
||||
// werden beim Uebernehmen nicht angetastet. Keine Parameter, keine Rueckgabe.
|
||||
function suggestHolidays(){
|
||||
const jahr = parseInt((document.getElementById('holidayYear') || {}).value, 10);
|
||||
const land = (document.getElementById('holidayState') || {}).value || '';
|
||||
const box = document.getElementById('holidaySuggestion');
|
||||
if(!box) return;
|
||||
if(!Number.isInteger(jahr)){ alert('Bitte ein Jahr eingeben.'); return; }
|
||||
|
||||
box.innerHTML = '<span class="hint">Wird berechnet…</span>';
|
||||
fetch(baseUrl() + '/holidays/suggest?year=' + jahr + '&state=' + encodeURIComponent(land))
|
||||
.then(r => r.json().then(json => ({ ok: r.ok, json })))
|
||||
.then(({ ok, json }) => {
|
||||
if(!ok || !json.ok) throw new Error((json && json.error) || 'Berechnung fehlgeschlagen.');
|
||||
const neu = json.holidays.filter(h => !h.vorhanden).length;
|
||||
box.innerHTML = `<div class="settings-holiday-list">`
|
||||
+ json.holidays.map(h => `<div class="template-admin-row">
|
||||
<span class="template-admin-title">${esc(formatDateDE(parseDateISO(h.date)))} · ${esc(h.name)}</span>
|
||||
<span class="hint" style="margin:0">${h.vorhanden ? 'bereits hinterlegt' : 'neu'}</span>
|
||||
</div>`).join('')
|
||||
+ `</div>`
|
||||
+ `<div class="toolbar" style="margin-top:10px">`
|
||||
+ (neu > 0
|
||||
? `<button class="secondary small" onclick="importHolidays()">${neu} Tag${neu === 1 ? '' : 'e'} übernehmen</button>`
|
||||
: `<span class="hint" style="margin:0">Alle ${json.holidays.length} Tage sind bereits hinterlegt.</span>`)
|
||||
+ `<button class="secondary small" onclick="document.getElementById('holidaySuggestion').innerHTML='';">Schließen</button>`
|
||||
+ `</div>`;
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('Feiertage berechnen fehlgeschlagen:', err);
|
||||
box.innerHTML = '<span class="hint">Feiertage konnten nicht berechnet werden: ' + esc(err.message || err) + '</span>';
|
||||
});
|
||||
}
|
||||
|
||||
// importHolidays: uebernimmt die berechneten Tage (POST /holidays/import,
|
||||
// Admin-only). Bereits hinterlegte Tage bleiben unveraendert — ein von Hand
|
||||
// angepasster Name soll nicht ueberschrieben werden. Das gewaehlte Bundesland
|
||||
// wird als firmenweite Vorgabe gemerkt. Keine Parameter, keine Rueckgabe.
|
||||
function importHolidays(){
|
||||
const jahr = parseInt((document.getElementById('holidayYear') || {}).value, 10);
|
||||
const land = (document.getElementById('holidayState') || {}).value || '';
|
||||
|
||||
fetch(baseUrl() + '/holidays/import', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json; charset=utf-8' },
|
||||
body: JSON.stringify({ year: jahr, state: land }),
|
||||
})
|
||||
.then(r => r.json().then(json => ({ ok: r.ok, json })))
|
||||
.then(({ ok, json }) => {
|
||||
if(!ok || !json.ok) throw new Error((json && json.error) || 'Übernehmen fehlgeschlagen.');
|
||||
showAdminNote(json.angelegt + ' Feiertag(e) für ' + jahr + ' übernommen'
|
||||
+ (json.uebersprungen ? ', ' + json.uebersprungen + ' waren schon hinterlegt.' : '.'));
|
||||
// Gemerktes Bundesland nachziehen, damit der nächste Vorschlag passt.
|
||||
// Ohne Rueckmeldung — die Zeile darueber gehoert dem Import.
|
||||
if(land && land !== APP_SETTINGS.feiertagBundesland) putAppSettings({ feiertagBundesland: land }, '');
|
||||
const box = document.getElementById('holidaySuggestion');
|
||||
if(box) box.innerHTML = '';
|
||||
return loadHolidaysIntoState();
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('Feiertage übernehmen fehlgeschlagen:', err);
|
||||
alert('Feiertage konnten nicht übernommen werden: ' + (err.message || err));
|
||||
});
|
||||
}
|
||||
|
||||
// loadHolidaysIntoState: holt die Feiertage frisch vom Server in HOLIDAYS/
|
||||
// HOLIDAY_NAMES (js/globals.js) und zeichnet die Liste neu. Nach einem Import
|
||||
// sind es zu viele einzelne Tage, um sie von Hand nachzuhalten — und die
|
||||
// Werktags-Rechnung (js/utils.js) muss sofort damit arbeiten.
|
||||
// Rueckgabe: Promise.
|
||||
function loadHolidaysIntoState(){
|
||||
return fetch(baseUrl() + '/holidays')
|
||||
.then(r => { if(!r.ok) throw new Error('Server HTTP ' + r.status); return r.json(); })
|
||||
.then(data => {
|
||||
const liste = Array.isArray(data.holidays) ? data.holidays : [];
|
||||
HOLIDAYS = new Set(liste.map(h => h.date));
|
||||
HOLIDAY_NAMES = {};
|
||||
liste.forEach(h => { HOLIDAY_NAMES[h.date] = h.name || ''; });
|
||||
renderAdminHolidays();
|
||||
})
|
||||
.catch(err => console.error('Feiertage neu laden fehlgeschlagen:', err));
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
// -----------------------------
|
||||
// Projektübersicht — Auftragsstatus & Beauftragung
|
||||
// -----------------------------
|
||||
// Der Weg von der Anfrage zum beauftragten Projekt. Bisher entstand ein Projekt
|
||||
// über "+ Neues Projekt" (saveNewProj(), js/load/load.js) und war damit
|
||||
// stillschweigend beauftragt; die Gruppe "In Anbahnung" deutete einen
|
||||
// Akquise-Zustand nur an. Es fehlte der Übergang "Angebot -> beauftragt",
|
||||
// und genau an dem hängen die beiden Angaben, ohne die der Terminplan aus dem
|
||||
// Honorar nicht rechnen kann (js/plan/plan-honorar.js): das vereinbarte
|
||||
// Netto-Honorar (p.honorar) und die beauftragten Leistungsphasen
|
||||
// (p.beauftragteLP). Deshalb erfasst der Dialog "Beauftragung" beides in EINEM
|
||||
// Schritt — direkt danach ist der Terminplan rechenbar.
|
||||
//
|
||||
// Geladen von Test.html als gemeinsames Modul (klassisches Skript, alle
|
||||
// Funktionen liegen dadurch auf window). Genutzt werden LPHS aus
|
||||
// js/globals.js, esc() aus js/utils.js, currentWeek()/save()/markWeekDirty()
|
||||
// aus js/store.js, isWeekLocked() aus js/weeks.js und openProjectPlan() aus
|
||||
// js/gantt.js.
|
||||
//
|
||||
// Anzeige-Bausteine für andere Module (werden dort eingehängt, dieses Modul
|
||||
// rendert selbst keine Projektzeile):
|
||||
// auftragsstatusChip(p) -> Kennzeichen für die Projektzeile (HTML)
|
||||
// auftragsstatusSelectHtml(p) -> Auswahlfeld für die Pflegemasken (HTML)
|
||||
// setAuftragsstatus(pid, wert) -> Schreibweg hinter dem Auswahlfeld
|
||||
|
||||
// AUFTRAGSSTATUS: die Abfolge von der Anfrage bis zum Abschluss. Reihenfolge =
|
||||
// fachliche Abfolge (sie bestimmt die Reihenfolge im Auswahlfeld). Dieselben
|
||||
// Schlüssel stehen in VALID_AUFTRAGSSTATUS in server.js — bewusst dupliziert,
|
||||
// es gibt keinen Bundler. `hinweis` erklärt im Auswahlfeld, was der Zustand
|
||||
// bedeutet; der Unterschied Anfrage/Angebot ist sonst Auslegungssache.
|
||||
const AUFTRAGSSTATUS = [
|
||||
{ key: 'anfrage', label: 'Anfrage', hinweis: 'Angefragt, es liegt noch kein Angebot vor.' },
|
||||
{ key: 'angebot', label: 'Angebot', hinweis: 'Angebot abgegeben, Entscheidung offen.' },
|
||||
{ key: 'beauftragt', label: 'Beauftragt', hinweis: 'Auftrag erteilt — Honorar und beauftragte Leistungsphasen liegen vor.' },
|
||||
{ key: 'abgeschlossen', label: 'Abgeschlossen', hinweis: 'Leistung erbracht und abgerechnet.' }
|
||||
];
|
||||
|
||||
// AUFTRAGSSTATUS_VORGABE: gilt für alles, was keinen Status trägt. Wie
|
||||
// serverseitig (DEFAULT_AUFTRAGSSTATUS) 'beauftragt', denn der Altbestand ist
|
||||
// beauftragte Arbeit — jeder andere Anfangswert hätte laufende Projekte
|
||||
// rückwirkend zu Akquise erklärt.
|
||||
const AUFTRAGSSTATUS_VORGABE = 'beauftragt';
|
||||
|
||||
// auftragsstatusOf: Status eines Projekts, immer ein gültiger Schlüssel.
|
||||
// Parameter: p = Projektobjekt. Rückgabe: Schlüssel aus AUFTRAGSSTATUS.
|
||||
function auftragsstatusOf(p){
|
||||
const v = String((p && p.auftragsstatus) || '').trim();
|
||||
return AUFTRAGSSTATUS.some(s => s.key === v) ? v : AUFTRAGSSTATUS_VORGABE;
|
||||
}
|
||||
|
||||
// auftragsstatusDef: Definitionsobjekt zu einem Schlüssel (nie null).
|
||||
function auftragsstatusDef(key){
|
||||
return AUFTRAGSSTATUS.find(s => s.key === key)
|
||||
|| AUFTRAGSSTATUS.find(s => s.key === AUFTRAGSSTATUS_VORGABE);
|
||||
}
|
||||
|
||||
// auftragsstatusLabel: lesbare Bezeichnung eines Schlüssels ('angebot' ->
|
||||
// 'Angebot').
|
||||
function auftragsstatusLabel(key){
|
||||
return auftragsstatusDef(key).label;
|
||||
}
|
||||
|
||||
// auftragsstatusChip: das Kennzeichen für die Projektzeile (fertiges HTML).
|
||||
// Parameter: p = Projektobjekt, opts.immer = true zeigt das Kennzeichen auch
|
||||
// im Normalzustand "beauftragt".
|
||||
// Rückgabe: HTML-Schnipsel; bei 'beauftragt' standardmäßig ein LEERER String —
|
||||
// das ist der Regelfall (und die Vorgabe des gesamten Bestands), ein
|
||||
// Kennzeichen an jeder einzelnen Zeile wäre also nur Rauschen. Sichtbar ist
|
||||
// damit genau das Besondere: Anfrage, Angebot, Abgeschlossen.
|
||||
function auftragsstatusChip(p, opts){
|
||||
const o = opts || {};
|
||||
const key = auftragsstatusOf(p);
|
||||
if(key === 'beauftragt' && !o.immer) return '';
|
||||
const def = auftragsstatusDef(key);
|
||||
return `<span class="auftrag-chip auftrag-chip-${key}" title="Auftragsstatus: ${esc(def.label)} — ${esc(def.hinweis)}">${esc(def.label)}</span>`;
|
||||
}
|
||||
|
||||
// auftragsstatusOptionsHtml: die <option>-Liste für ein Auswahlfeld.
|
||||
// Parameter: gewaehlt = Schlüssel des vorausgewählten Status.
|
||||
function auftragsstatusOptionsHtml(gewaehlt){
|
||||
const sel = AUFTRAGSSTATUS.some(s => s.key === gewaehlt) ? gewaehlt : AUFTRAGSSTATUS_VORGABE;
|
||||
return AUFTRAGSSTATUS.map(s =>
|
||||
`<option value="${s.key}"${s.key === sel ? ' selected' : ''} title="${esc(s.hinweis)}">${esc(s.label)}</option>`
|
||||
).join('');
|
||||
}
|
||||
|
||||
// auftragsstatusSelectHtml: das Auswahlfeld für die Masken, in denen ein
|
||||
// Projekt gepflegt wird (fertiges HTML, schreibt über setAuftragsstatus()).
|
||||
// Parameter: p = Projektobjekt, opts.klasse = zusätzliche CSS-Klasse,
|
||||
// opts.disabled = true für gesperrte Sitzungen.
|
||||
function auftragsstatusSelectHtml(p, opts){
|
||||
const o = opts || {};
|
||||
const klasse = 'auftrag-select' + (o.klasse ? ' ' + o.klasse : '');
|
||||
return `<select class="${klasse}"${o.disabled ? ' disabled' : ''}
|
||||
title="Auftragsstatus des Projekts"
|
||||
onchange="setAuftragsstatus('${esc(String((p && p.id) || ''))}', this.value)"
|
||||
>${auftragsstatusOptionsHtml(auftragsstatusOf(p))}</select>`;
|
||||
}
|
||||
|
||||
// auftragProjekt: findet Projekt UND zugehörige Sitzung zu einer Projekt-ID.
|
||||
// Die Sitzung wird mitgeliefert, weil nur sie gespeichert werden kann
|
||||
// (markWeekDirty) und weil ihr Sperr-Zustand darüber entscheidet, ob
|
||||
// überhaupt geschrieben werden darf. Zuerst wird in der ausgewählten Sitzung
|
||||
// gesucht; der Projektbereich kann über einen Deep-Link aber auch eine andere
|
||||
// Sitzung zeigen, deshalb die Suche über alle.
|
||||
// Rückgabe: { week, project } oder null.
|
||||
function auftragProjekt(pid){
|
||||
const w = currentWeek();
|
||||
const p = (w && Array.isArray(w.projects)) ? w.projects.find(x => x.id === pid) : null;
|
||||
if(p) return { week: w, project: p };
|
||||
for(const week of (db.weeks || [])){
|
||||
const found = (week.projects || []).find(x => x.id === pid);
|
||||
if(found) return { week, project: found };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// auftragGesperrt: Sperr-Prüfung für eine beliebige Sitzung (guardWeekLocked()
|
||||
// prüft immer nur die ausgewählte, siehe auftragProjekt()).
|
||||
// Rückgabe: true, wenn NICHT geschrieben werden darf (mit Hinweis).
|
||||
function auftragGesperrt(week){
|
||||
if(isWeekLocked(week)){
|
||||
alert('Diese Sitzung ist gesperrt. Bitte entsperren Sie sie zuerst.');
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// auftragSpeichern: schreibt eine Änderung an einem Projekt fort und rendert
|
||||
// alle Ansichten neu, die den Status oder das Honorar zeigen. save({week:false})
|
||||
// plus eigenes markWeekDirty(), weil die geänderte Sitzung nicht zwingend die
|
||||
// ausgewählte ist (siehe auftragProjekt()).
|
||||
function auftragSpeichern(week, project){
|
||||
markWeekDirty(week.label);
|
||||
save({ week: false });
|
||||
renderBoard(); renderProjects(); renderLoad();
|
||||
// Der Projektbereich zeigt Honorar und beauftragte Leistungsphasen in seiner
|
||||
// Einstellungsmaske. Er ist nur gerendert, wenn dort ein Projekt offen ist —
|
||||
// und nur dieses eine ist von der Änderung betroffen.
|
||||
if(window.PlanApp && PlanApp.project === project){
|
||||
if(typeof renderPlanSettings === 'function') renderPlanSettings();
|
||||
if(typeof renderHonorarEingaben === 'function') renderHonorarEingaben();
|
||||
}
|
||||
}
|
||||
|
||||
// setAuftragsstatus: Schreibweg hinter jedem Auswahlfeld (siehe
|
||||
// auftragsstatusSelectHtml). Der Sprung NACH 'beauftragt' führt bewusst nicht
|
||||
// direkt in die Daten, sondern in den Dialog "Beauftragung": Zur Beauftragung
|
||||
// gehören Honorar und beauftragte Leistungsphasen, und würde man sie hier
|
||||
// überspringen, stünde das Projekt als beauftragt da, ohne dass sich ein
|
||||
// Terminplan rechnen ließe.
|
||||
// Parameter: pid = Projekt-ID, wert = Schlüssel aus AUFTRAGSSTATUS.
|
||||
function setAuftragsstatus(pid, wert){
|
||||
const treffer = auftragProjekt(pid);
|
||||
if(!treffer) return;
|
||||
if(auftragGesperrt(treffer.week)){
|
||||
// Die Auswahl im Feld steht schon auf dem neuen Wert — neu rendern, damit
|
||||
// wieder der tatsächlich gespeicherte Status zu sehen ist.
|
||||
renderBoard(); renderProjects();
|
||||
return;
|
||||
}
|
||||
if(!AUFTRAGSSTATUS.some(s => s.key === wert)) return; // unbekannter Wert: nichts tun
|
||||
const alt = auftragsstatusOf(treffer.project);
|
||||
if(wert === alt) return;
|
||||
if(wert === 'beauftragt'){ openBeauftragungModal(pid); return; }
|
||||
treffer.project.auftragsstatus = wert;
|
||||
auftragSpeichern(treffer.week, treffer.project);
|
||||
}
|
||||
|
||||
// ---------- Dialog "Beauftragung" ----------
|
||||
|
||||
// beauftragungProjectId: Projekt-ID des offenen Beauftragungs-Dialogs
|
||||
// (null = geschlossen).
|
||||
let beauftragungProjectId = null;
|
||||
|
||||
// openBeauftragungModal: öffnet den Dialog "Beauftragung" für ein Projekt.
|
||||
// Parameter: pid = Projekt-ID. Gesperrte Sitzungen werden abgewiesen.
|
||||
function openBeauftragungModal(pid){
|
||||
const treffer = auftragProjekt(pid);
|
||||
if(!treffer) return;
|
||||
if(auftragGesperrt(treffer.week)) return;
|
||||
const p = treffer.project;
|
||||
beauftragungProjectId = pid;
|
||||
|
||||
const titelEl = document.getElementById('beauftragungProjekt');
|
||||
if(titelEl) titelEl.textContent = (p.nr ? p.nr + ' – ' : '') + (p.name || '');
|
||||
const hinweisEl = document.getElementById('beauftragungStatusHinweis');
|
||||
if(hinweisEl) hinweisEl.textContent = 'Bisheriger Status: ' + auftragsstatusLabel(auftragsstatusOf(p));
|
||||
|
||||
const betragEl = document.getElementById('beauftragungHonorar');
|
||||
if(betragEl) betragEl.value = Number(p.honorar) > 0 ? p.honorar : '';
|
||||
|
||||
// Dieselbe Auswahl wie im Honorar-Dialog (renderHonorarEingaben(),
|
||||
// js/plan/plan-honorar.js) und im Neues-Projekt-Dialog, damit das Feld
|
||||
// überall gleich aussieht und bedient wird.
|
||||
const lpEl = document.getElementById('beauftragungBeauftragteLp');
|
||||
if(lpEl){
|
||||
const gesetzt = new Set(Array.isArray(p.beauftragteLP) ? p.beauftragteLP : []);
|
||||
lpEl.innerHTML = LPHS.map(l => `<label class="beauftragte-lp-item">
|
||||
<input type="checkbox" value="${esc(l)}"${gesetzt.has(l) ? ' checked' : ''}> ${esc(l)}
|
||||
</label>`).join('');
|
||||
}
|
||||
|
||||
// Gruppenwechsel nur anbieten, wenn das Projekt noch in "In Anbahnung"
|
||||
// steht: Diese Gruppe wird von der Auslastungsrechnung ausdrücklich NICHT
|
||||
// eingerechnet (berechneAuslastung(), js/load/load.js). Bliebe ein
|
||||
// beauftragtes Projekt dort liegen, zählte die Arbeit daran nirgends mit.
|
||||
const gruppeWrap = document.getElementById('beauftragungGruppeWrap');
|
||||
const gruppeCheck = document.getElementById('beauftragungGruppeCheck');
|
||||
if(gruppeWrap) gruppeWrap.style.display = (p.group === 'In Anbahnung') ? '' : 'none';
|
||||
if(gruppeCheck) gruppeCheck.checked = (p.group === 'In Anbahnung');
|
||||
|
||||
document.getElementById('beauftragungModal').style.display = 'flex';
|
||||
}
|
||||
|
||||
// closeBeauftragungModal: schließt den Dialog. Gerendert wird trotzdem neu,
|
||||
// weil der Weg hierher über ein Auswahlfeld führen kann, das bereits auf
|
||||
// "Beauftragt" steht, während gespeichert noch der alte Status ist
|
||||
// (siehe setAuftragsstatus()).
|
||||
function closeBeauftragungModal(){
|
||||
document.getElementById('beauftragungModal').style.display = 'none';
|
||||
beauftragungProjectId = null;
|
||||
renderBoard(); renderProjects();
|
||||
}
|
||||
|
||||
// saveBeauftragung: übernimmt Honorar und beauftragte Leistungsphasen, setzt
|
||||
// den Status auf 'beauftragt' und speichert.
|
||||
// Parameter: zumTerminplan = true öffnet danach den Projektbereich, damit der
|
||||
// Terminplan sofort aus dem Honorar gerechnet werden kann ("+ Terminplan").
|
||||
function saveBeauftragung(zumTerminplan){
|
||||
const treffer = auftragProjekt(beauftragungProjectId);
|
||||
if(!treffer) return;
|
||||
if(auftragGesperrt(treffer.week)) return;
|
||||
const p = treffer.project;
|
||||
|
||||
// Honorar streng prüfen statt parseFloat: Das nahm auch "50t" (-> 50)
|
||||
// kommentarlos an — bei einer Zahl, aus der später ein ganzer Terminplan
|
||||
// gerechnet wird, ist das keine Kleinigkeit. Das Feld ist ein Zahlenfeld,
|
||||
// liefert also bereits Punkt-Dezimalschreibweise; die Komma-Ersetzung ist
|
||||
// nur die Rückfallebene für Browser, die das Komma durchlassen.
|
||||
const roh = String((document.getElementById('beauftragungHonorar') || {}).value || '')
|
||||
.trim().replace(/\s/g, '').replace(',', '.');
|
||||
let honorar = 0;
|
||||
if(roh !== ''){
|
||||
if(!/^\d+(\.\d+)?$/.test(roh)){
|
||||
alert('Bitte das Honorar als reine Zahl in Euro eintragen (z. B. 48500 oder 48500,50).');
|
||||
return;
|
||||
}
|
||||
honorar = parseFloat(roh);
|
||||
}
|
||||
|
||||
const lpEl = document.getElementById('beauftragungBeauftragteLp');
|
||||
const gewaehlt = lpEl
|
||||
? new Set(Array.from(lpEl.querySelectorAll('input:checked')).map(cb => cb.value))
|
||||
: new Set();
|
||||
if(gewaehlt.size === 0){
|
||||
alert('Bitte mindestens eine beauftragte Leistungsphase wählen.\n\n'
|
||||
+ 'Der Terminplan aus dem Honorar verteilt das Honorar genau auf diese Phasen.');
|
||||
return;
|
||||
}
|
||||
// Fehlendes Honorar wird nicht erzwungen — es ist bei der Beauftragung nicht
|
||||
// immer schon abschließend vereinbart. Der Preis dafür wird aber benannt:
|
||||
// ohne Honorar rechnet der Terminplan nicht.
|
||||
if(honorar <= 0 && !confirm('Ohne vereinbartes Honorar kann kein Terminplan aus dem Honorar '
|
||||
+ 'berechnet werden.\n\nTrotzdem als beauftragt kennzeichnen?')) return;
|
||||
|
||||
// Beauftragte Phasen in der Reihenfolge von LPHS ablegen (gleiche Konvention
|
||||
// wie toggleBeauftragteLp() im Honorar-Dialog und saveNewProj()).
|
||||
p.beauftragteLP = LPHS.filter(l => gewaehlt.has(l));
|
||||
p.honorar = honorar;
|
||||
p.auftragsstatus = 'beauftragt';
|
||||
const gruppeCheck = document.getElementById('beauftragungGruppeCheck');
|
||||
if(p.group === 'In Anbahnung' && gruppeCheck && gruppeCheck.checked){
|
||||
p.group = 'Aktive Projekte';
|
||||
// Neue Reihenfolge in der Zielgruppe, sonst teilt das Projekt seine
|
||||
// Ordnungszahl mit einem bereits dort liegenden (order ist nur INNERHALB
|
||||
// einer Gruppe eindeutig).
|
||||
p.order = (treffer.week.projects || [])
|
||||
.filter(x => x !== p && x.group === 'Aktive Projekte')
|
||||
.reduce((max, x) => Math.max(max, typeof x.order === 'number' ? x.order : 0), -1) + 1;
|
||||
}
|
||||
|
||||
const pid = p.id;
|
||||
auftragSpeichern(treffer.week, p);
|
||||
document.getElementById('beauftragungModal').style.display = 'none';
|
||||
beauftragungProjectId = null;
|
||||
if(zumTerminplan) openProjectPlan(pid);
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
// -----------------------------
|
||||
// Projektübersicht — Authentifizierung (Login-Overlay, Session, Logout)
|
||||
// -----------------------------
|
||||
// Wird von Test.html direkt nach
|
||||
// js/server.js geladen, da checkAuthAndInit() baseUrl() benötigt. Ersetzt den
|
||||
// früheren rein clientseitigen Passwortschutz (js/tabs-auth.js) durch eine
|
||||
// echte serverseitige Session (server.js: /login, /logout, /whoami). Ohne
|
||||
// gültige Session zeigt checkAuthAndInit() das blockierende Login-Overlay
|
||||
// (#loginOverlay/#loginForm, siehe Test.html); nach
|
||||
// erfolgreichem Login läuft der eigentliche Seiteneinstieg (per Callback
|
||||
// übergeben) wie gewohnt weiter.
|
||||
//
|
||||
// Hier liegt außerdem der Dialog zum Ändern des eigenen Passworts
|
||||
// (#passwordModal, POST /change-password). Er hat zwei Betriebsarten:
|
||||
// freiwillig (aus dem Bereich „Einstellungen" heraus, mit Abbrechen) und
|
||||
// ERZWUNGEN — wenn das Konto must_change_password trägt, was der Server bei
|
||||
// jedem neu angelegten Konto und nach jedem Zurücksetzen setzt. Bis zu diesem
|
||||
// Umbau gab es zu dem Endpunkt gar keine Oberfläche: Die Benutzerverwaltung
|
||||
// zeigte „Passwortwechsel erforderlich" an, erfüllen konnte ihn niemand.
|
||||
|
||||
// currentUser: der aktuell angemeldete Benutzer ({id, username, displayName,
|
||||
// email, role, personId, mustChangePassword}) oder null vor dem Login.
|
||||
let currentUser = null;
|
||||
|
||||
// renderUserBar: zeigt Namen + Rolle des angemeldeten Benutzers im Menueband
|
||||
// an (Element #userBarInfo) und blendet den gesamten Admin-Bereich der
|
||||
// Seitenleiste (#navAdminSection) nur fuer Administratoren ein. Dieser
|
||||
// Bereich fuehrt zu allen Admin-Aufgaben — Benutzerverwaltung, firmenweite
|
||||
// Vorgaben, Datenbank und Sitzungen. Es ist ein reines UI-Gate; die
|
||||
// eigentliche Sperre sitzt serverseitig in requireRole('admin') auf den
|
||||
// zugehoerigen Routen, und showTab() leitet Nicht-Administratoren zusaetzlich
|
||||
// vom Bereich „Admin" weg (js/tabs-auth.js).
|
||||
function renderUserBar(){
|
||||
const info = document.getElementById('userBarInfo');
|
||||
if(info && currentUser){
|
||||
const roleLabel = currentUser.role === 'admin' ? 'Administrator' : 'Mitglied';
|
||||
info.textContent = (currentUser.displayName || currentUser.username) + ' (' + roleLabel + ')';
|
||||
}
|
||||
const navAdminSection = document.getElementById('navAdminSection');
|
||||
if(navAdminSection) navAdminSection.style.display = (currentUser && currentUser.role === 'admin') ? '' : 'none';
|
||||
}
|
||||
|
||||
// logout: beendet die Server-Session und lädt die Seite neu (zeigt danach
|
||||
// wieder das Login-Overlay, da /whoami dann {user:null} liefert).
|
||||
async function logout(){
|
||||
try { await fetch(baseUrl() + '/logout', { method:'POST' }); } catch(e){}
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
// showLoginOverlay / hideLoginOverlay: blenden das blockierende Login-Overlay ein/aus.
|
||||
function showLoginOverlay(){
|
||||
document.getElementById('loginOverlay').style.display = 'flex';
|
||||
}
|
||||
function hideLoginOverlay(){
|
||||
document.getElementById('loginOverlay').style.display = 'none';
|
||||
}
|
||||
|
||||
// afterAuthenticated: gemeinsamer Nachlauf beider Anmeldewege (bestehende
|
||||
// Session laut /whoami bzw. frisch abgeschicktes Login-Formular). Startet die
|
||||
// Seite und erzwingt danach — erst danach, damit die Anwendung hinter dem
|
||||
// Dialog schon steht — den Passwortwechsel, falls das Konto ihn verlangt.
|
||||
// Parameter: onAuthenticated (Funktion). Keine Rückgabe.
|
||||
function afterAuthenticated(onAuthenticated){
|
||||
hideLoginOverlay();
|
||||
renderUserBar();
|
||||
onAuthenticated();
|
||||
if(currentUser && currentUser.mustChangePassword) openPasswordModal(true);
|
||||
}
|
||||
|
||||
// ---------- Eigenes Passwort ändern (#passwordModal) ----------
|
||||
|
||||
// passwordChangeForced: true, solange der Dialog wegen must_change_password
|
||||
// offen ist. Dann gibt es kein Abbrechen — sonst bliebe die Kennzeichnung im
|
||||
// Konto stehen und das (oft per Zettel weitergegebene) Startpasswort gültig.
|
||||
let passwordChangeForced = false;
|
||||
|
||||
// openPasswordModal: zeigt den Dialog. Parameter: forced (Boolean) — im
|
||||
// erzwungenen Fall ohne Abbrechen-Schaltfläche und mit Hinweistext.
|
||||
// Keine Rückgabe.
|
||||
function openPasswordModal(forced){
|
||||
const modal = document.getElementById('passwordModal');
|
||||
if(!modal) return;
|
||||
passwordChangeForced = !!forced;
|
||||
|
||||
['passwordCurrent','passwordNew','passwordRepeat'].forEach(id => {
|
||||
const el = document.getElementById(id);
|
||||
if(el) el.value = '';
|
||||
});
|
||||
const errEl = document.getElementById('passwordError');
|
||||
if(errEl){ errEl.textContent = ''; errEl.hidden = true; }
|
||||
|
||||
const forcedHint = document.getElementById('passwordForcedHint');
|
||||
if(forcedHint) forcedHint.hidden = !passwordChangeForced;
|
||||
const cancelBtn = document.getElementById('passwordCancelBtn');
|
||||
if(cancelBtn) cancelBtn.style.display = passwordChangeForced ? 'none' : '';
|
||||
|
||||
modal.style.display = 'flex';
|
||||
const first = document.getElementById('passwordCurrent');
|
||||
if(first) first.focus();
|
||||
}
|
||||
|
||||
// closePasswordModal: schließt den Dialog — im erzwungenen Fall verweigert er
|
||||
// sich. Keine Parameter, keine Rückgabe.
|
||||
function closePasswordModal(){
|
||||
if(passwordChangeForced) return;
|
||||
const modal = document.getElementById('passwordModal');
|
||||
if(modal) modal.style.display = 'none';
|
||||
}
|
||||
|
||||
// submitPasswordChange: schickt den Wechsel an POST /change-password. Der
|
||||
// Server verlangt immer das aktuelle Passwort (auch im erzwungenen Fall) und
|
||||
// mindestens 8 Zeichen; die Wiederholung prüft nur der Client, damit ein
|
||||
// Vertipper nicht erst nach dem Speichern auffällt.
|
||||
// Parameter: ev (Submit-Event). Rückgabe: keine.
|
||||
async function submitPasswordChange(ev){
|
||||
if(ev) ev.preventDefault();
|
||||
const errEl = document.getElementById('passwordError');
|
||||
const show = msg => { if(errEl){ errEl.textContent = msg; errEl.hidden = false; } };
|
||||
|
||||
const currentPassword = (document.getElementById('passwordCurrent') || {}).value || '';
|
||||
const newPassword = (document.getElementById('passwordNew') || {}).value || '';
|
||||
const repeat = (document.getElementById('passwordRepeat') || {}).value || '';
|
||||
if(errEl){ errEl.textContent = ''; errEl.hidden = true; }
|
||||
|
||||
if(newPassword.length < 8) return show('Das neue Passwort muss mindestens 8 Zeichen haben.');
|
||||
if(newPassword !== repeat) return show('Die Wiederholung stimmt nicht mit dem neuen Passwort überein.');
|
||||
if(newPassword === currentPassword) return show('Das neue Passwort muss sich vom bisherigen unterscheiden.');
|
||||
|
||||
try {
|
||||
const r = await fetch(baseUrl() + '/change-password', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json; charset=utf-8' },
|
||||
body: JSON.stringify({ currentPassword, newPassword })
|
||||
});
|
||||
const d = await r.json();
|
||||
if(!r.ok || !d.ok) return show(d && d.error ? d.error : 'Passwort konnte nicht geändert werden.');
|
||||
} catch(err){
|
||||
console.error('Passwortwechsel fehlgeschlagen:', err);
|
||||
return show('Verbindung zum Server fehlgeschlagen.');
|
||||
}
|
||||
|
||||
if(currentUser) currentUser.mustChangePassword = false;
|
||||
passwordChangeForced = false;
|
||||
closePasswordModal();
|
||||
// Der Bereich „Einstellungen" zeigt die Kennzeichnung mit an.
|
||||
if(typeof renderSettings === 'function' && typeof currentTab !== 'undefined' && currentTab === 'settings') renderSettings();
|
||||
alert('Das Passwort wurde geändert.');
|
||||
}
|
||||
|
||||
// checkAuthAndInit: prüft per GET /whoami, ob eine gültige Session besteht.
|
||||
// Falls ja, wird currentUser gesetzt, das Overlay ausgeblendet und der
|
||||
// übergebene Callback (der eigentliche Seiteneinstieg) ausgeführt. Falls
|
||||
// nein, wird das Login-Overlay gezeigt und das Login-Formular gebunden; nach
|
||||
// erfolgreichem Login läuft derselbe Callback an. Parameter: onAuthenticated
|
||||
// (Funktion ohne Argumente).
|
||||
async function checkAuthAndInit(onAuthenticated){
|
||||
let data = null;
|
||||
try {
|
||||
const res = await fetch(baseUrl() + '/whoami');
|
||||
data = await res.json();
|
||||
} catch(err){
|
||||
// Ohne diesen Zweig blieb bei nicht erreichbarem Server (oder einer
|
||||
// HTML-Antwort einer Zwischeninstanz) eine leere, tote Seite stehen: Das
|
||||
// Promise wurde abgelehnt, das Login-Fenster nie gezeigt.
|
||||
console.error('Anmeldestatus konnte nicht geprüft werden:', err);
|
||||
alert('Der Server unter ' + baseUrl() + ' ist nicht erreichbar.\n\n'
|
||||
+ 'Bitte prüfen, ob er läuft, und die Seite anschließend neu laden.');
|
||||
return;
|
||||
}
|
||||
if(data && data.user){
|
||||
currentUser = data.user;
|
||||
afterAuthenticated(onAuthenticated);
|
||||
return;
|
||||
}
|
||||
showLoginOverlay();
|
||||
const form = document.getElementById('loginForm');
|
||||
const errEl = document.getElementById('loginError');
|
||||
form.onsubmit = async function(ev){
|
||||
ev.preventDefault();
|
||||
errEl.textContent = '';
|
||||
const username = document.getElementById('loginUsername').value;
|
||||
const password = document.getElementById('loginPassword').value;
|
||||
try {
|
||||
const r = await fetch(baseUrl() + '/login', {
|
||||
method:'POST', headers:{'Content-Type':'application/json'},
|
||||
body: JSON.stringify({ username, password })
|
||||
});
|
||||
const d = await r.json();
|
||||
if(!r.ok || !d.ok){ errEl.textContent = d.error || 'Anmeldung fehlgeschlagen.'; return; }
|
||||
currentUser = d.user;
|
||||
afterAuthenticated(onAuthenticated);
|
||||
} catch(err){
|
||||
errEl.textContent = 'Verbindung zum Server fehlgeschlagen.';
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,425 @@
|
||||
// -----------------------------
|
||||
// Projektübersicht — Dashboard ("Meine Aufgaben"-Tab)
|
||||
// -----------------------------
|
||||
// Modul für den ersten Tab der Hauptseite (Test.html, Ladereihenfolge:
|
||||
// ... → load → dashboard → gantt → ...). Das Dashboard ist die Startübersicht
|
||||
// nach dem Login; alles darin stammt aus einem einzigen GET /my-dashboard.
|
||||
//
|
||||
// Der Aufbau folgt der Frage, die beim Blick darauf zuerst ansteht: "Was muss
|
||||
// ich heute anpacken?"
|
||||
//
|
||||
// 1. KOPF Begrüßung und Datum links, Sitzung und Uhr klein rechts
|
||||
// (renderDashboardKopf(), gefüllt von startDashboardClock()).
|
||||
// 2. BAND Der Befund des Tages als SATZ, daneben vier Kennzahlen
|
||||
// (renderDashboardBand()). Ist nichts überfällig und nichts
|
||||
// heute fällig, bleibt das Band farblos.
|
||||
// 3. LINKS Der eigene Arbeitsstrom: "Meine Aufgaben" (persönlich
|
||||
// zugewiesene offene TODOs, als Zeilen —
|
||||
// renderDashboardAufgabe()) und "Termine meiner Projekte"
|
||||
// (Aufgaben und Meilensteine der Projekte, denen man zugeordnet
|
||||
// ist, nach Dringlichkeit gegliedert — renderDashboardFristen()).
|
||||
// 4. RECHTS Was man wissen muss, ohne selbst tätig zu werden: ausstehende
|
||||
// Freigaben und Abwesenheiten der Kollegen, beide in derselben
|
||||
// schmalen Zeilenform.
|
||||
//
|
||||
// Das war zuvor anders und schlechter: Die Uhrzeit war die größte Zahl auf
|
||||
// dem Bildschirm, vier gleich große Kennzahlkacheln zeigten meist drei Nullen
|
||||
// und gaben der einen wichtigen Zahl kein Gewicht, eine einzelne
|
||||
// Aufgabenkarte belegte eine halbe Bildschirmbreite für vier Zeilen Inhalt,
|
||||
// und alles lag untereinander — der Urlaub eines Kollegen stand gleichrangig
|
||||
// unter den eigenen offenen Aufgaben.
|
||||
//
|
||||
// Nutzt baseUrl() aus js/server.js, currentUser aus js/auth.js, PLAN_SLUG aus
|
||||
// js/globals.js, esc()/formatWeekDate()/heuteIso()/isoPlusTage() aus
|
||||
// js/utils.js.
|
||||
|
||||
// ABSENCE_TYPE_CLASS: ordnet den Abwesenheitstypen (siehe absenceEditType in
|
||||
// Projektbereich: Urlaub/Krank/Fortbildung/Sonstiges) eine Farbklasse für
|
||||
// das Badge in der Kollegen-Abwesenheitsliste zu; "Sonstiges" fällt auf die
|
||||
// neutrale .absence-chip-Standardfarbe zurück (kein Eintrag nötig).
|
||||
const ABSENCE_TYPE_CLASS = {
|
||||
'Urlaub': 'absence-type-urlaub',
|
||||
'Krank': 'absence-type-krank',
|
||||
'Fortbildung': 'absence-type-fortbildung',
|
||||
};
|
||||
|
||||
// dashboardClockTimer: Intervall-Handle der Begrüßungs-Uhr. Wird bei jedem
|
||||
// renderDashboard()-Aufruf neu gestartet (vorheriges Intervall zuerst
|
||||
// gestoppt), damit beim wiederholten Tab-Wechsel nicht mehrere Intervalle
|
||||
// parallel laufen.
|
||||
let dashboardClockTimer = null;
|
||||
|
||||
// renderDashboard: lädt GET /my-dashboard und rendert Begrüßung/Uhr,
|
||||
// Kennzahlen sowie die drei Bereiche in #dashboardContent. Wird nur beim
|
||||
// Wechsel auf den Tab aufgerufen (showTab()), nicht beim initialen
|
||||
// Seitenaufbau. Keine Parameter, keine Rückgabe.
|
||||
async function renderDashboard(){
|
||||
const container = document.getElementById('dashboardContent');
|
||||
if(!container) return;
|
||||
container.innerHTML = '<div class="hint">Lade Dashboard…</div>';
|
||||
|
||||
let data;
|
||||
try {
|
||||
const res = await fetch(baseUrl() + '/my-dashboard');
|
||||
data = await res.json();
|
||||
if(!res.ok || !data.ok) throw new Error((data && data.error) || ('HTTP ' + res.status));
|
||||
} catch(err){
|
||||
container.innerHTML = '<div class="hint">Dashboard konnte nicht geladen werden: ' + esc(err.message || String(err)) + '</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
const weekLabel = data.weekLabel || null;
|
||||
const tasks = Array.isArray(data.tasks) ? data.tasks : [];
|
||||
const projectDates = Array.isArray(data.projectDates) ? data.projectDates : [];
|
||||
const pending = Array.isArray(data.pendingReviews) ? data.pendingReviews : [];
|
||||
const absences = Array.isArray(data.colleagueAbsences) ? data.colleagueAbsences : [];
|
||||
const noPersonId = !!(currentUser && !currentUser.personId);
|
||||
// Personen zählen, nicht Einträge: Wer heute "Urlaub" UND "krank" erfasst
|
||||
// hat, ist eine abwesende Person, nicht zwei.
|
||||
const absencesToday = new Set(absences.filter(a => a.current).map(a => a.personId || a.userId || a.name)).size;
|
||||
|
||||
// ---------- Aufbau ----------
|
||||
//
|
||||
// Die Anordnung folgt der Frage, die beim Blick aufs Dashboard zuerst
|
||||
// ansteht: "Was muss ich heute anpacken?" Deshalb steht oben ein Befund im
|
||||
// Klartext statt vier gleich großer Kacheln, darunter links der
|
||||
// Arbeitsstrom (was ich tue) und rechts der Rand (was ich wissen muss).
|
||||
//
|
||||
// Zuvor war die Uhrzeit die größte Zahl auf dem Bildschirm, drei von vier
|
||||
// Kennzahlen zeigten eine Null, eine einzelne Aufgabenkarte belegte eine
|
||||
// halbe Bildschirmbreite für vier Zeilen Inhalt, und der Urlaub eines
|
||||
// Kollegen stand gleichrangig unter den eigenen offenen Aufgaben.
|
||||
const heute = heuteIso();
|
||||
const eigeneUeberfaellig = tasks.filter(t => t.end && t.end < heute).length;
|
||||
const ueberfaellig = eigeneUeberfaellig + projectDates.filter(d => d.ueberfaellig).length;
|
||||
const heuteFaellig = tasks.filter(t => t.end === heute).length
|
||||
+ projectDates.filter(d => d.heute).length;
|
||||
|
||||
let html = '';
|
||||
html += renderDashboardKopf(weekLabel);
|
||||
html += renderDashboardBand({
|
||||
ueberfaellig, heuteFaellig,
|
||||
aufgaben: tasks.length,
|
||||
termine: projectDates.length,
|
||||
freigaben: pending.length,
|
||||
abwesend: absencesToday,
|
||||
});
|
||||
|
||||
if(noPersonId){
|
||||
html += '<div class="hint">Ihrem Benutzerkonto ist keine Person zugeordnet — es können daher keine Aufgaben angezeigt werden. Ein Administrator kann die Zuordnung in der Benutzerverwaltung ergänzen.</div>';
|
||||
}
|
||||
|
||||
html += '<div class="dash-zonen">';
|
||||
|
||||
// Linke Zone: der eigene Arbeitsstrom
|
||||
html += '<div>';
|
||||
html += dashAbschnitt('Meine Aufgaben', tasks.length, 'mir persönlich zugewiesen',
|
||||
!weekLabel ? dashLeer('Keine Sitzung vorhanden.')
|
||||
: (tasks.length === 0 ? dashLeer('Keine offenen Aufgaben — alles abgearbeitet.')
|
||||
: '<div class="dash-karte">' + tasks.map(t => renderDashboardAufgabe(weekLabel, t)).join('') + '</div>'));
|
||||
html += dashAbschnitt('Termine meiner Projekte', projectDates.length, 'nächste drei Wochen',
|
||||
renderDashboardFristen(weekLabel, projectDates));
|
||||
html += '</div>';
|
||||
|
||||
// Rechte Zone: was man wissen muss, ohne es selbst zu tun
|
||||
html += '<div>';
|
||||
html += dashAbschnitt('Ausstehende Freigaben', pending.length, null,
|
||||
pending.length === 0 ? dashLeer('Keine ausstehenden Freigaben.')
|
||||
: '<div class="dash-karte">' + pending.map(renderDashboardFreigabe).join('') + '</div>');
|
||||
html += dashAbschnitt('Abwesenheiten', absences.length, null,
|
||||
renderDashboardAbsences(absences));
|
||||
html += '</div>';
|
||||
|
||||
html += '</div>';
|
||||
|
||||
container.innerHTML = html;
|
||||
startDashboardClock();
|
||||
}
|
||||
|
||||
// renderDashboardKopf: Begrüßung und Datum links, Sitzung und Uhr klein
|
||||
// rechts. Die Uhrzeit war zuvor die größte Zahl auf dem Bildschirm — sie ist
|
||||
// die Angabe, nach der man am wenigsten handelt, und steht deshalb am Rand.
|
||||
// Die Inhalte füllt startDashboardClock() live nach.
|
||||
// Parameter: weekLabel. Rückgabe: HTML-String.
|
||||
function renderDashboardKopf(weekLabel){
|
||||
return '<div class="dash-kopf">'
|
||||
+ '<p class="dash-gruss"><span id="dashboardGreetingText"></span>'
|
||||
+ '<em id="dashboardGreetingDate"></em></p>'
|
||||
+ '<div class="dash-kopf-rechts">'
|
||||
+ (weekLabel ? '<span class="dash-sitzung">' + esc(weekLabel) + '</span>' : '')
|
||||
+ '<span class="dash-uhr" id="dashboardClock"></span>'
|
||||
+ '</div>'
|
||||
+ '</div>';
|
||||
}
|
||||
|
||||
// renderDashboardBand: der Befund des Tages als Satz, daneben die Kennzahlen.
|
||||
//
|
||||
// Zuvor standen hier vier gleich große Kacheln, von denen meist drei eine
|
||||
// Null zeigten — die eine Zahl, auf die es ankommt, bekam dadurch kein
|
||||
// Gewicht. Der Satz benennt den Befund, die Zahlen bleiben daneben stehen.
|
||||
// Ist nichts überfällig und nichts heute fällig, wird das Band farblos:
|
||||
// Eine Null in Alarmfarbe behauptet ein Problem, das es nicht gibt.
|
||||
// Parameter: z (Zählungen). Rückgabe: HTML-String.
|
||||
function renderDashboardBand(z){
|
||||
let satz;
|
||||
if(z.ueberfaellig > 0){
|
||||
satz = '<b class="ist-rot">' + z.ueberfaellig + '</b> '
|
||||
+ (z.ueberfaellig === 1 ? 'Termin ist überfällig' : 'Termine sind überfällig')
|
||||
+ (z.heuteFaellig ? ', <b>' + z.heuteFaellig + '</b> ' + (z.heuteFaellig === 1 ? 'wird' : 'werden') + ' heute fällig' : '')
|
||||
+ '.';
|
||||
} else if(z.heuteFaellig > 0){
|
||||
satz = '<b>' + z.heuteFaellig + '</b> ' + (z.heuteFaellig === 1 ? 'Termin wird' : 'Termine werden')
|
||||
+ ' heute fällig, nichts ist überfällig.';
|
||||
} else if(z.aufgaben > 0){
|
||||
satz = '<b class="ist-ruhig">Nichts überfällig.</b> ' + z.aufgaben + ' offene '
|
||||
+ (z.aufgaben === 1 ? 'Aufgabe' : 'Aufgaben') + ' in Arbeit.';
|
||||
} else {
|
||||
satz = '<b class="ist-ruhig">Nichts offen, nichts überfällig.</b>';
|
||||
}
|
||||
|
||||
const zahl = (wert, label) =>
|
||||
'<div class="dash-zahl' + (wert === 0 ? ' ist-null' : '') + '"><b>' + wert + '</b><span>' + label + '</span></div>';
|
||||
|
||||
const ruhig = z.ueberfaellig === 0 && z.heuteFaellig === 0;
|
||||
return '<div class="dash-band' + (ruhig ? ' ist-ruhig' : '') + '">'
|
||||
+ '<p class="dash-befund">' + satz + '</p>'
|
||||
+ '<div class="dash-band-zahlen">'
|
||||
+ zahl(z.aufgaben, 'offene Aufgaben')
|
||||
+ zahl(z.termine, 'Termine im Blick')
|
||||
+ zahl(z.freigaben, 'Freigaben')
|
||||
+ zahl(z.abwesend, 'heute abwesend')
|
||||
+ '</div></div>';
|
||||
}
|
||||
|
||||
// dashAbschnitt / dashLeer: die immer gleiche Umrahmung eines Blocks —
|
||||
// Überschrift, Anzahl, ein erklärender Zusatz am rechten Rand.
|
||||
// Parameter: titel, zahl (oder null), zusatz (oder null), inhalt.
|
||||
function dashAbschnitt(titel, zahl, zusatz, inhalt){
|
||||
return '<section class="dash-abschnitt">'
|
||||
+ '<div class="dash-abschnitt-kopf">'
|
||||
+ '<h3 class="dash-abschnitt-titel">' + esc(titel) + '</h3>'
|
||||
+ (zahl != null ? '<span class="dash-abschnitt-zahl">' + zahl + '</span>' : '')
|
||||
+ (zusatz ? '<span class="dash-abschnitt-zusatz">' + esc(zusatz) + '</span>' : '')
|
||||
+ '</div>' + inhalt + '</section>';
|
||||
}
|
||||
function dashLeer(text){
|
||||
return '<div class="dash-karte"><div class="dash-leer">' + esc(text) + '</div></div>';
|
||||
}
|
||||
|
||||
// greetingForHour:// greetingForHour: liefert je Tageszeit eine passende Begrüßung. Parameter:
|
||||
// hour (Number, 0-23). Rückgabe: String.
|
||||
function greetingForHour(hour){
|
||||
if(hour < 5) return 'Gute Nacht';
|
||||
if(hour < 11) return 'Guten Morgen';
|
||||
if(hour < 18) return 'Guten Tag';
|
||||
if(hour < 22) return 'Guten Abend';
|
||||
return 'Gute Nacht';
|
||||
}
|
||||
|
||||
// startDashboardClock: (re-)startet das Intervall, das Begrüßung, Datum und
|
||||
// Uhrzeit im Dashboard-Header aktualisiert. Bricht sich selbst ab, sobald die
|
||||
// Zielelemente nicht mehr im DOM sind (z. B. weil das Dashboard neu gerendert
|
||||
// wurde). Keine Parameter, keine Rückgabe.
|
||||
function startDashboardClock(){
|
||||
if(dashboardClockTimer) clearInterval(dashboardClockTimer);
|
||||
const name = (currentUser && (currentUser.displayName || currentUser.username)) || '';
|
||||
const tick = () => {
|
||||
const textEl = document.getElementById('dashboardGreetingText');
|
||||
const dateEl = document.getElementById('dashboardGreetingDate');
|
||||
const clockEl = document.getElementById('dashboardClock');
|
||||
if(!textEl || !dateEl || !clockEl){
|
||||
clearInterval(dashboardClockTimer);
|
||||
dashboardClockTimer = null;
|
||||
return;
|
||||
}
|
||||
const now = new Date();
|
||||
// Begrüßung und Datum stehen in EINER Zeile (früher untereinander), das
|
||||
// Datum als gedämpfter Zusatz — deshalb der führende Gedankenstrich.
|
||||
textEl.textContent = greetingForHour(now.getHours()) + (name ? ', ' + name : '');
|
||||
dateEl.textContent = ' — ' + now.toLocaleDateString('de-DE', { weekday: 'long', day: '2-digit', month: 'long', year: 'numeric' });
|
||||
clockEl.textContent = now.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
|
||||
};
|
||||
tick();
|
||||
dashboardClockTimer = setInterval(tick, 30000);
|
||||
}
|
||||
|
||||
// renderDashboardAufgabe: eine eigene Aufgabe als ZEILE — Titel und Projekt
|
||||
// links, Fortschritt in der Mitte, Fälligkeit rechts.
|
||||
//
|
||||
// Zuvor war das eine Karte, die eine halbe Bildschirmbreite für vier Zeilen
|
||||
// Inhalt belegte; zwei davon füllten den sichtbaren Bereich. Dieselben
|
||||
// Angaben stehen jetzt in einer Zeile, acht davon auf derselben Fläche.
|
||||
// Verlinkt wie bisher auf den Projektbereich (gleiches URL-Schema wie
|
||||
// openProjectPlan() in js/gantt.js).
|
||||
// Parameter: weekLabel, task. Rückgabe: HTML-String.
|
||||
function renderDashboardAufgabe(weekLabel, task){
|
||||
const url = new URL('/' + PLAN_SLUG + '/' + encodeURIComponent(task.projectName || ''), window.location.origin);
|
||||
url.searchParams.set('weekLabel', weekLabel);
|
||||
url.searchParams.set('projNr', task.projectNr || '');
|
||||
url.hash = 'aufgaben';
|
||||
|
||||
const heute = heuteIso();
|
||||
const bald = isoPlusTage(2);
|
||||
// Dieselbe Kennzeichnung wie in der Terminliste darunter, damit die Kachel
|
||||
// "überfällig" und die beiden Listen zusammenpassen.
|
||||
const klasse = !task.end ? '' : (task.end < heute ? ' ist-ueberfaellig' : (task.end <= bald ? ' ist-bald' : ''));
|
||||
const datum = task.end ? formatWeekDate(task.end) : '—';
|
||||
const relativ = task.end ? relativerTag(task.end) : 'ohne Frist';
|
||||
const progress = Number(task.progress || 0);
|
||||
const tags = (Array.isArray(task.tags) ? task.tags : [])
|
||||
.map(t => '<span class="dash-auf-tag">' + esc(t) + '</span>').join('');
|
||||
const projekt = (task.projectNr ? task.projectNr + ' ' : '') + (task.projectName || '');
|
||||
|
||||
return '<a class="dash-auf" href="' + url.toString() + '" target="_blank" rel="noopener">'
|
||||
+ '<span class="dash-auf-text">'
|
||||
+ '<span class="dash-auf-titel">' + esc(task.title || '(ohne Titel)') + '</span>'
|
||||
+ '<span class="dash-auf-meta">' + esc(projekt) + tags + '</span>'
|
||||
+ '</span>'
|
||||
+ '<span class="dash-auf-fortschritt">'
|
||||
+ '<span class="dash-auf-bar"><i style="width:' + Math.min(100, Math.max(0, progress)) + '%"></i></span>'
|
||||
+ '<span class="dash-auf-pct">' + progress + '%</span>'
|
||||
+ '</span>'
|
||||
+ '<span class="dash-auf-frist' + klasse + '">' + esc(datum) + '<em>' + esc(relativ) + '</em></span>'
|
||||
+ '</a>';
|
||||
}
|
||||
|
||||
// relativerTag: "heute", "morgen", "in 5 Tagen", "seit 3 Tagen" zu einem
|
||||
// ISO-Datum. Die Angabe steht neben dem Datum, weil "12.08." allein nicht
|
||||
// sagt, ob das morgen oder in drei Wochen ist.
|
||||
// Parameter: iso. Rückgabe: String.
|
||||
function relativerTag(iso){
|
||||
const d = tageBis(iso);
|
||||
if(d === 0) return 'heute';
|
||||
if(d === 1) return 'morgen';
|
||||
if(d === -1) return 'seit 1 Tag';
|
||||
if(d < 0) return 'seit ' + Math.abs(d) + ' Tagen';
|
||||
return 'in ' + d + ' Tagen';
|
||||
}
|
||||
|
||||
// renderDashboardFristen:// renderDashboardFristen: die Termine der Projekte, denen man zugeordnet ist
|
||||
// (GET /my-dashboard, Feld projectDates — bereits nach Datum sortiert, auf
|
||||
// 21 Tage begrenzt und um die eigenen Aufgaben bereinigt).
|
||||
//
|
||||
// Bewusst eine LISTE und keine Kartenwand wie "Meine Aufgaben": Hier geht es
|
||||
// um Reihenfolge in der Zeit, und Termine, die untereinander stehen, lassen
|
||||
// sich der Reihe nach abarbeiten. Gegliedert wird nach Dringlichkeit statt
|
||||
// nach Projekt — in der Woche, in der etwas fällig wird, ist das Datum die
|
||||
// Frage, nicht das Projekt.
|
||||
// Parameter: weekLabel, eintraege (Array). Rückgabe: HTML-String.
|
||||
function renderDashboardFristen(weekLabel, eintraege){
|
||||
if(!weekLabel) return dashLeer('Keine Sitzung vorhanden.');
|
||||
if(!eintraege.length){
|
||||
return dashLeer('In Ihren Projekten steht in den nächsten drei Wochen kein Termin an.');
|
||||
}
|
||||
|
||||
const wocheEnde = isoPlusTage(7);
|
||||
const gruppen = [
|
||||
{ titel: 'Überfällig', klasse: 'ist-ueberfaellig', treffer: d => d.ueberfaellig },
|
||||
{ titel: 'Diese Woche', klasse: 'ist-bald', treffer: d => !d.ueberfaellig && d.datum <= wocheEnde },
|
||||
{ titel: 'Danach', klasse: '', treffer: d => !d.ueberfaellig && d.datum > wocheEnde },
|
||||
];
|
||||
|
||||
// In derselben Karte wie "Meine Aufgaben" darüber: Beides ist der eigene
|
||||
// Arbeitsstrom und soll gleich aussehen.
|
||||
let html = '<div class="dash-karte">';
|
||||
gruppen.forEach(g => {
|
||||
const teil = eintraege.filter(g.treffer);
|
||||
if(!teil.length) return;
|
||||
html += '<div class="fristen-gruppe ' + g.klasse + '">'
|
||||
+ '<div class="fristen-gruppe-titel">' + esc(g.titel) + '<span>' + teil.length + '</span></div>';
|
||||
teil.forEach(d => { html += renderFristenZeile(weekLabel, d); });
|
||||
html += '</div>';
|
||||
});
|
||||
return html + '</div>';
|
||||
}
|
||||
|
||||
// renderFristenZeile: eine Zeile der Terminliste. Der ganze Eintrag führt in
|
||||
// den Terminplan des Projekts — dorthin, wo sich der Termin ändern lässt.
|
||||
// Parameter: weekLabel, d (Eintrag). Rückgabe: HTML-String.
|
||||
function renderFristenZeile(weekLabel, d){
|
||||
const url = new URL('/' + PLAN_SLUG + '/' + encodeURIComponent(d.projectName || ''), window.location.origin);
|
||||
url.searchParams.set('weekLabel', weekLabel);
|
||||
url.searchParams.set('projNr', d.projectNr || '');
|
||||
url.hash = 'aufgaben';
|
||||
|
||||
const datum = d.datum ? formatWeekDate(d.datum) : '';
|
||||
const tage = tageBis(d.datum);
|
||||
// Die Angabe in Tagen steht neben dem Datum, weil "12.08." allein nicht
|
||||
// sagt, ob das morgen oder in drei Wochen ist.
|
||||
const relativ = d.heute ? 'heute'
|
||||
: (tage < 0 ? (Math.abs(tage) === 1 ? 'seit 1 Tag' : 'seit ' + Math.abs(tage) + ' Tagen')
|
||||
: (tage === 1 ? 'morgen' : 'in ' + tage + ' Tagen'));
|
||||
const wer = Array.isArray(d.zustaendig) && d.zustaendig.length
|
||||
? esc(d.zustaendig.join(', '))
|
||||
: '<span class="hint" style="margin:0">niemand zugewiesen</span>';
|
||||
|
||||
return '<a class="fristen-zeile" href="' + url.toString() + '" target="_blank" rel="noopener">'
|
||||
+ '<span class="fristen-datum"><b>' + esc(datum) + '</b><em>' + esc(relativ) + '</em></span>'
|
||||
+ '<span class="fristen-art' + (d.art === 'meilenstein' ? ' ist-meilenstein' : '') + '">'
|
||||
+ (d.art === 'meilenstein' ? 'Meilenstein' : 'Aufgabe') + '</span>'
|
||||
+ '<span class="fristen-titel">' + esc(d.titel || '(ohne Titel)') + '</span>'
|
||||
+ '<span class="fristen-projekt">' + esc(d.projectNr ? d.projectNr + ' ' + d.projectName : d.projectName) + '</span>'
|
||||
+ '<span class="fristen-wer">' + wer + '</span>'
|
||||
+ '</a>';
|
||||
}
|
||||
|
||||
// tageBis: Kalendertage von heute bis zu einem ISO-Datum (negativ = liegt
|
||||
// zurück). Parameter: iso. Rückgabe: ganze Zahl.
|
||||
function tageBis(iso){
|
||||
if(!iso) return 0;
|
||||
const heute = new Date(); heute.setHours(0, 0, 0, 0);
|
||||
const ziel = new Date(iso); ziel.setHours(0, 0, 0, 0);
|
||||
return Math.round((ziel.getTime() - heute.getTime()) / 86400000);
|
||||
}
|
||||
|
||||
// renderDashboardFreigabe: eine ausstehende Datei-Freigabe als schmale
|
||||
// Zeile für die rechte Spalte. Parameter: pr. Rückgabe: HTML-String.
|
||||
function renderDashboardFreigabe(pr){
|
||||
const projekt = (pr.projectNr ? pr.projectNr + ' ' : '') + (pr.projectName || '');
|
||||
return '<div class="dash-rand-zeile">'
|
||||
+ '<span class="dash-avatar">' + esc(initialsFor(pr.uploadedBy || '?')) + '</span>'
|
||||
+ '<span class="dash-rand-text">'
|
||||
+ '<span class="dash-rand-name">' + esc(pr.originalFilename || '') + '</span>'
|
||||
+ '<span class="dash-rand-meta">' + esc(projekt) + ' · ' + esc(pr.uploadedBy || '') + '</span>'
|
||||
+ '</span></div>';
|
||||
}
|
||||
|
||||
// renderDashboardAbsences: die Abwesenheiten der Kollegen (aktuelle sowie in
|
||||
// den nächsten 14 Tagen beginnende, siehe GET /my-dashboard). Dieselbe
|
||||
// Zeilenform wie die Freigaben daneben — beides ist Randinformation, die man
|
||||
// zur Kenntnis nimmt, ohne selbst tätig zu werden.
|
||||
// Parameter: absences (Array). Rückgabe: HTML-String.
|
||||
function renderDashboardAbsences(absences){
|
||||
if(absences.length === 0){
|
||||
return dashLeer('Keine Abwesenheiten in den nächsten 14 Tagen.');
|
||||
}
|
||||
const zeilen = absences.map(a => {
|
||||
const zeitraum = a.start === a.end
|
||||
? formatWeekDate(a.start)
|
||||
: formatWeekDate(a.start) + ' – ' + formatWeekDate(a.end);
|
||||
const typKlasse = ABSENCE_TYPE_CLASS[a.type] || '';
|
||||
// Wer heute fehlt, ist die eigentliche Nachricht; die Art der Abwesenheit
|
||||
// steht dann in der Zeile darunter.
|
||||
const chip = a.current
|
||||
? '<span class="dash-chip ist-heute">heute</span>'
|
||||
: '<span class="dash-chip ' + typKlasse + '">' + esc(a.type || 'Abwesend') + '</span>';
|
||||
return '<div class="dash-rand-zeile">'
|
||||
+ '<span class="dash-avatar">' + esc(initialsFor(a.name)) + '</span>'
|
||||
+ '<span class="dash-rand-text">'
|
||||
+ '<span class="dash-rand-name">' + esc(a.name || '') + '</span>'
|
||||
+ '<span class="dash-rand-meta">' + esc(a.type || '') + ' · ' + esc(zeitraum)
|
||||
+ (a.note ? ' · ' + esc(a.note) : '') + '</span>'
|
||||
+ '</span>' + chip + '</div>';
|
||||
}).join('');
|
||||
return '<div class="dash-karte">' + zeilen + '</div>';
|
||||
}
|
||||
|
||||
// initialsFor:// initialsFor: liefert bis zu zwei Buchstaben Initialen für einen Namen, als
|
||||
// Avatar-Kürzel in der Kollegen-Abwesenheitsliste. Parameter: name (String).
|
||||
// Rückgabe: String.
|
||||
function initialsFor(name){
|
||||
const parts = String(name || '').trim().split(/\s+/).filter(Boolean);
|
||||
if(parts.length === 0) return '?';
|
||||
if(parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
|
||||
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
|
||||
}
|
||||
@@ -193,9 +193,9 @@ function renderRangePicker(){
|
||||
else hint = 'Enddatum wählen (Start: ' + formatDateDE(st.start) + ')';
|
||||
|
||||
let html = `<div class="range-picker-head">
|
||||
<button type="button" class="secondary small" onclick="rangePickerNav(-1)">‹</button>
|
||||
<button type="button" class="secondary small" onclick="rangePickerNav(-1)">${icon('chevronLeft')}</button>
|
||||
<span class="range-picker-title">${monthNames[st.viewMonth]} ${st.viewYear}</span>
|
||||
<button type="button" class="secondary small" onclick="rangePickerNav(1)">›</button>
|
||||
<button type="button" class="secondary small" onclick="rangePickerNav(1)">${icon('chevronRight')}</button>
|
||||
</div>
|
||||
<div class="range-picker-hint">${hint}</div>
|
||||
<div class="range-picker-grid">`;
|
||||
|
||||
+123
-31
@@ -1,64 +1,156 @@
|
||||
// -----------------------------
|
||||
// Projektübersicht — globale Konstanten & Zustand
|
||||
// -----------------------------
|
||||
// Diese Datei wird als Erstes von Test.html und project-plan.html geladen und
|
||||
// Diese Datei wird als Erstes von Test.html geladen und
|
||||
// legt alle zentralen Konstanten (Leistungsphasen, Rollen, Gruppen, Abteilungen,
|
||||
// Farben, Passwort-Hash) sowie den globalen In-Memory-Zustand (db,
|
||||
// currentDepartment, currentTab) fest. Sie stellt keine Funktionen, sondern nur
|
||||
// Daten bereit, auf die alle weiteren Module (js/store.js, js/board/board.js,
|
||||
// js/gantt.js, js/plan/*, ...) zugreifen. Auf dem Plan-Gantt lädt zusätzlich
|
||||
// js/plan/plan-state.js Zustand für die Planungsseite.
|
||||
// Farben, Passwort-Hash) sowie den globalen In-Memory-Zustand (db, currentTab)
|
||||
// fest. Sie stellt keine Funktionen, sondern nur Daten bereit, auf die alle
|
||||
// weiteren Module (js/store.js, js/board/board.js, js/gantt.js, js/plan/*, ...)
|
||||
// zugreifen. Auf dem Plan-Gantt lädt zusätzlich js/plan/plan-state.js Zustand
|
||||
// für die Planungsseite. DEPARTMENTS/DEFAULT_DEPARTMENT bleiben trotz Wegfall
|
||||
// der Abteilungstrennung erhalten — sie werden weiterhin für die
|
||||
// Benutzerverwaltung (users.department, Profil-Anzeige) gebraucht.
|
||||
|
||||
// Konstanten: Leistungsphasen, Rollen und LocalStorage-Key
|
||||
const LPHS = ["LPH 1","LPH 2","LPH 3","LPH 4","LPH 5","LPH 6","LPH 7","LPH 8", "W+M"]; // Spalten für Leistungsphasen
|
||||
// LPHS: alle Leistungsphasen in fachlicher Reihenfolge. "W+M" steht bewusst
|
||||
// AM ENDE und ist keine HOAI-Phase, sondern eine eigene Sammelkategorie —
|
||||
// Stellen, die nur die echten Leistungsphasen brauchen (amtierende Phase,
|
||||
// Aufwandsmodell), filtern deshalb gezielt "W+M" heraus statt nach Index zu
|
||||
// schneiden.
|
||||
const LPHS = ["LPH 1","LPH 2","LPH 3","LPH 4","LPH 5","LPH 6","LPH 7","LPH 8","LPH 9", "W+M"]; // Spalten für Leistungsphasen
|
||||
const ROLES = ["Planung","Projektleitung","Bauüberwachung"]; // Gruppierung der Personen
|
||||
const GROUP_ORDER = ["Aktive Projekte","Kleine Projekte","In Anbahnung","Allgemein"]; // Reihenfolge der Projektgruppen
|
||||
const STORAGE = "projuebersicht_v1"; // Durch lokale UI-State-Persistenz genutzter Key
|
||||
// DEFAULT_DEPARTMENT: Fallback-Abteilung beim ersten Start
|
||||
const DEFAULT_DEPARTMENT = 'ELT';
|
||||
// DEFAULT_TAB: Tab, der beim ersten Start angezeigt wird ('board' = Projektboard)
|
||||
const DEFAULT_TAB = 'board';
|
||||
// DEFAULT_TAB: Tab, der beim ersten Start angezeigt wird ('dashboard' = Meine
|
||||
// Aufgaben, das persönliche Dashboard — Projektboard bleibt über die
|
||||
// Navigation vollständig erreichbar, ist aber nicht mehr die Startansicht)
|
||||
const DEFAULT_TAB = 'dashboard';
|
||||
|
||||
// APP_SETTINGS: die firmenweiten Einstellungen (GET /app-settings). Sie
|
||||
// waren bis zum Ausbau der Einstellungsseite Konstanten im Quelltext —
|
||||
// Aufwandsvorlage, Ampel-Schwellen, Balkenskala, Arbeitsstunden je Tag — und
|
||||
// verschieben allesamt Zahlen, die alle Nutzer sehen. Deshalb sind sie jetzt
|
||||
// firmenweit gepflegt (Admin) statt im Code verteilt.
|
||||
//
|
||||
// Die Werte hier sind die VORGABEN und zugleich die Rückfallebene: Sie
|
||||
// entsprechen APP_SETTING_DEFS in server.js und gelten, solange
|
||||
// loadAppSettings() (js/store.js) noch nicht geantwortet hat oder der Abruf
|
||||
// scheitert. Ohne diese Kopie stünde vor dem ersten Rendern gar kein Wert
|
||||
// bereit und die Auslastung rechnete mit undefined.
|
||||
let APP_SETTINGS = {
|
||||
stundensatz: 135,
|
||||
arbeitsstundenProTag: 8,
|
||||
vollzeitStundenProWoche: 40,
|
||||
auslastungSchwelleGelb: 0.75,
|
||||
auslastungSchwelleRot: 1.0,
|
||||
aufwandVorlage: {
|
||||
'LPH 1': { Planung: 0.4, Projektleitung: 0.2, Bauüberwachung: 0 },
|
||||
'LPH 2': { Planung: 0.4, Projektleitung: 0.2, Bauüberwachung: 0 },
|
||||
'LPH 3': { Planung: 1.0, Projektleitung: 0.3, Bauüberwachung: 0 },
|
||||
'LPH 4': { Planung: 0.0, Projektleitung: 0.3, Bauüberwachung: 0 },
|
||||
'LPH 5': { Planung: 1.0, Projektleitung: 0.4, Bauüberwachung: 0 },
|
||||
'LPH 6': { Planung: 0.2, Projektleitung: 0.7, Bauüberwachung: 0.3 },
|
||||
'LPH 7': { Planung: 0.1, Projektleitung: 0.3, Bauüberwachung: 0.5 },
|
||||
'LPH 8': { Planung: 0, Projektleitung: 0.2, Bauüberwachung: 1.0 },
|
||||
'LPH 9': { Planung: 0, Projektleitung: 0, Bauüberwachung: 0 }
|
||||
},
|
||||
autoLockAktiv: 1,
|
||||
feiertagBundesland: 'NW',
|
||||
maxBackups: 10,
|
||||
sessionTage: 30,
|
||||
loginMaxVersuche: 10,
|
||||
loginSperreMinuten: 10
|
||||
};
|
||||
|
||||
// HONORAR_ANTEILE: firmenweite HOAI-Anteile je Leistungsphase in Prozent
|
||||
// ({ 'LPH 1': 2, … }), gefüllt aus GET /honorar-config beim Start
|
||||
// (loadAppSettings() in js/store.js). Sie sind die Brücke vom Honorar eines
|
||||
// Projekts zum Aufwand einer einzelnen Phase und damit Grundlage der
|
||||
// Auslastungsrechnung in Stunden (berechneAuslastung(), js/load/load.js).
|
||||
// Gepflegt werden sie im Admin-Bereich unter „Honorar"; der Projektbereich
|
||||
// holt sie sich für den Leistungsstand weiterhin selbst (plan-leistung.js),
|
||||
// weil er sie auch nach einer Änderung im laufenden Betrieb neu braucht.
|
||||
// Leer = nicht geladen: Die Auslastung weist dann eine Lücke aus, statt mit
|
||||
// erfundenen Anteilen zu rechnen.
|
||||
let HONORAR_ANTEILE = {};
|
||||
|
||||
// HOLIDAYS: Feiertage und sonstige arbeitsfreie Tage als Menge von
|
||||
// ISO-Datumsangaben ("2026-12-25"), gefüllt aus GET /holidays. Wirkt in
|
||||
// workdaysBetween()/addWorkdays() (js/utils.js) und damit in der
|
||||
// Terminplan-Berechnung aus dem Honorar und in der Ressourcen-Auslastung.
|
||||
let HOLIDAYS = new Set();
|
||||
|
||||
// HOLIDAY_NAMES: Datum -> Bezeichnung, nur für die Pflege-Liste im Bereich
|
||||
// „Admin" (die Rechnung braucht allein die Menge oben).
|
||||
let HOLIDAY_NAMES = {};
|
||||
|
||||
// GERMAN_STATES: Kürzel -> Name der Bundesländer, vom Server mitgeliefert
|
||||
// (GET /holidays). Er ist die eine Stelle, an der die Liste steht; sie wird
|
||||
// nur für die Auswahl beim Berechnen der gesetzlichen Feiertage gebraucht.
|
||||
let GERMAN_STATES = {};
|
||||
|
||||
// Farbpalette für GANTT-Balken (eine pro Projekt, wiederholend)
|
||||
const GANTT_COLORS = ['#0071e3','#248a3d','#b25000','#7c3aed','#d70015','#0ca678','#d9480f','#5f3dc4'];
|
||||
|
||||
// Passwortschutz: SHA256-Hash des erlaubten Passworts.
|
||||
// Wenn Sie das Passwort ändern möchten, generieren Sie den SHA256-Hash (https://emn178.github.io/online-tools/sha256.html)
|
||||
// des neuen Passworts und ersetzen Sie den Wert hier.
|
||||
const PROTECTED_PASSWORD_HASH = "107bab579e8500e459690cf48ba77de32be43cadb8421bbe79fad20a55b39b6f";
|
||||
|
||||
// requestedAction speichert die Aktion, die nach erfolgreicher Passwort-Eingabe ausgeführt wird.
|
||||
let requestedAction = null;
|
||||
// saveSyncTimer: Handle für den debounced Speicher-Timer (Sync auf Server)
|
||||
let saveSyncTimer = null;
|
||||
// isAuthenticated: Merkt sich, ob das Passwort-Schutz-Modal bereits erfolgreich bestätigt wurde
|
||||
let isAuthenticated = false;
|
||||
|
||||
// In-memory Datenbank: Sitzungen (weeks) und aktuell ausgewählte Sitzung
|
||||
let db = { weeks: [], currentWeek: null, globalPeople: {} };
|
||||
// In-memory Datenbank: Sitzungen (weeks) und aktuell ausgewählte Sitzung.
|
||||
// globalPeople ist ein flaches Array (keine Abteilungstrennung mehr).
|
||||
let db = { weeks: [], currentWeek: null, globalPeople: [],
|
||||
// sandboxWeek: Platzhalter bis zum ersten erfolgreichen load() (js/store.js)
|
||||
// befüllt ihn — leer statt undefined, damit ein Zugriff vor dem Laden nicht
|
||||
// crasht, sondern nur eine leere Planung-Liste zeigt.
|
||||
sandboxWeek: { id: 'sandbox', label: '__sandbox__', date: '', people: [], projects: [], attendance: {}, locked: false, updatedAt: '' }
|
||||
};
|
||||
|
||||
// Aktuelle Abteilung (ELT, HLS, ARCH). Daten werden in sessions/<DEPT>/ getrennt gespeichert.
|
||||
let currentDepartment = DEFAULT_DEPARTMENT;
|
||||
// currentTab: aktuell aktiver Tab der Hauptseite (z. B. 'board')
|
||||
let currentTab = DEFAULT_TAB;
|
||||
// previousTab: der davor angezeigte Bereich. Gebraucht vom "Schließen"-Knopf
|
||||
// des Projektbereichs (closePlanPage() in js/gantt.js), der damit dorthin
|
||||
// zurückführt, von wo aus das Projekt geöffnet wurde — früher erledigte das
|
||||
// die Browser-Historie, weil der Projektbereich ein eigenes Dokument war.
|
||||
let previousTab = DEFAULT_TAB;
|
||||
// DEPARTMENTS: Auflistung der gültigen Abteilungen mit Anzeigename und Icon
|
||||
// (nur noch für users.department/Benutzerverwaltung relevant, keine
|
||||
// App-weite Datentrennung mehr)
|
||||
const DEPARTMENTS = {
|
||||
ELT: { label: 'Elektro', icon: '' },
|
||||
HLS: { label: 'HLS', icon: '' },
|
||||
ARCH: { label: 'Architektur', icon: '' }
|
||||
};
|
||||
|
||||
// TAB_SLUGS / TAB_BY_SLUG: sprechende URL-Abschnitte für die Tabs, damit jede
|
||||
// Abteilungs-/Tab-Kombination eine eigene Seite hat (z. B. "/HLS/Auslastung").
|
||||
// Wird von js/ui.js (applyUrlRoute/updateRouteUrl) und dem Server genutzt.
|
||||
// SANDBOX_WEEK_LABEL: die eine, dauerhaft reservierte "Sitzung", in der alle
|
||||
// Planung-Terminpläne liegen (CLAUDE.md, "Planung — projektunabhängige
|
||||
// Terminplan-Sandbox"). MUSS mit der gleichnamigen Konstante in server.js
|
||||
// übereinstimmen — kein gemeinsames Modulsystem zwischen Server und Client.
|
||||
const SANDBOX_WEEK_LABEL = '__sandbox__';
|
||||
|
||||
// TAB_SLUGS / TAB_BY_SLUG: sprechende URL-Abschnitte für die Tabs, damit jeder
|
||||
// Tab eine eigene Seite hat (z. B. "/Auslastung"). Wird von js/ui.js
|
||||
// (applyUrlRoute/updateRouteUrl) und dem Server genutzt.
|
||||
const TAB_SLUGS = {
|
||||
board: 'Projektübersicht',
|
||||
load: 'Auslastung',
|
||||
projects:'Projekte',
|
||||
people: 'Projektbeteiligte'
|
||||
board: 'Projektübersicht',
|
||||
wochenbericht: 'Wochenbericht',
|
||||
load: 'Auslastung',
|
||||
people: 'Projektbeteiligte',
|
||||
planung: 'Planung',
|
||||
dashboard:'MeineAufgaben',
|
||||
settings: 'Einstellungen',
|
||||
admin: 'Admin'
|
||||
};
|
||||
const TAB_BY_SLUG = Object.fromEntries(Object.entries(TAB_SLUGS).map(([k,v]) => [v,k]));
|
||||
// Der Bereich "Projekte" ist in der Projektliste aufgegangen (siehe
|
||||
// renderBoard() in js/board/board.js). Sein Adressabschnitt bleibt gültig und
|
||||
// führt dorthin, damit weitergegebene Links nicht ins Leere zeigen — er wird
|
||||
// aber nicht mehr erzeugt, weil er in TAB_SLUGS fehlt.
|
||||
TAB_BY_SLUG['Projekte'] = 'board';
|
||||
|
||||
// PLAN_SLUG: URL-Schlüssel für die Projektplan-Seite (project-plan.html), im
|
||||
// gleichen Deep-Link-Schema wie die Tabs: "/<Abteilung>/Projektplan".
|
||||
// PLAN_SLUG: URL-Schlüssel des Projektbereichs (Bereich #plan von Test.html),
|
||||
// im gleichen Deep-Link-Schema wie die Tabs: "/Projektplan/<Projektname>",
|
||||
// dazu ?weekLabel=…&projNr=… als Parameter. Steht nicht in TAB_SLUGS, weil
|
||||
// die Adresse das geöffnete Projekt mitträgt und deshalb nicht fest ist —
|
||||
// gebaut wird sie in updateRouteUrl() (js/ui.js).
|
||||
const PLAN_SLUG = 'Projektplan';
|
||||
@@ -0,0 +1,51 @@
|
||||
// -----------------------------
|
||||
// Projektübersicht — Icon-System
|
||||
// -----------------------------
|
||||
// Zentrale Bibliothek von Inline-SVG-Icons (Line-Icons, 1.75px Stroke,
|
||||
// currentColor -> erbt automatisch die Textfarbe inkl. Dark Mode, keine
|
||||
// eigene Farbdefinition nötig). Ersetzt alle bisherigen Emoji-Icons in der
|
||||
// App. Wird als globale Funktion `icon(name, extraClass)` bereitgestellt,
|
||||
// geladen früh (nach js/utils.js, vor js/ui.js und allen Renderern, die sie
|
||||
// nutzen). Rückgabe: HTML-String eines <span class="icon ..."> mit dem SVG
|
||||
// darin, oder leerer String bei unbekanntem Namen.
|
||||
const ICONS = {
|
||||
menu: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round"><line x1="4" y1="7" x2="20" y2="7"/><line x1="4" y1="12" x2="20" y2="12"/><line x1="4" y1="17" x2="20" y2="17"/></svg>',
|
||||
close: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round"><line x1="6" y1="6" x2="18" y2="18"/><line x1="18" y1="6" x2="6" y2="18"/></svg>',
|
||||
sun: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round"><circle cx="12" cy="12" r="4"/><line x1="12" y1="2" x2="12" y2="4.5"/><line x1="12" y1="19.5" x2="12" y2="22"/><line x1="2" y1="12" x2="4.5" y2="12"/><line x1="19.5" y1="12" x2="22" y2="12"/><line x1="4.9" y1="4.9" x2="6.6" y2="6.6"/><line x1="17.4" y1="17.4" x2="19.1" y2="19.1"/><line x1="4.9" y1="19.1" x2="6.6" y2="17.4"/><line x1="17.4" y1="6.6" x2="19.1" y2="4.9"/></svg>',
|
||||
moon: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M20.5 14.5A8.5 8.5 0 1 1 9.5 3.5a7 7 0 0 0 11 11Z"/></svg>',
|
||||
chevronLeft: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 5 8 12 15 19"/></svg>',
|
||||
chevronRight: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 5 16 12 9 19"/></svg>',
|
||||
chevronDown: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><polyline points="5 9 12 16 19 9"/></svg>',
|
||||
grip: '<svg viewBox="0 0 24 24" fill="currentColor" stroke="none"><circle cx="9" cy="6" r="1.4"/><circle cx="9" cy="12" r="1.4"/><circle cx="9" cy="18" r="1.4"/><circle cx="15" cy="6" r="1.4"/><circle cx="15" cy="12" r="1.4"/><circle cx="15" cy="18" r="1.4"/></svg>',
|
||||
flag: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><line x1="5" y1="3" x2="5" y2="21"/><path d="M5 4h11l-3 4.5L16 13H5"/></svg>',
|
||||
checklist: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="7" height="7" rx="1.5"/><path d="m4.5 7.5 1.3 1.3L8.5 6"/><line x1="13" y1="6" x2="21" y2="6"/><rect x="3" y="14" width="7" height="7" rx="1.5"/><path d="m4.5 17.5 1.3 1.3 2.7-2.8"/><line x1="13" y1="16" x2="21" y2="16"/></svg>',
|
||||
board: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2.5"/><line x1="9" y1="3" x2="9" y2="21"/><line x1="15" y1="3" x2="15" y2="21"/></svg>',
|
||||
chart: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><line x1="5" y1="21" x2="5" y2="10"/><line x1="12" y1="21" x2="12" y2="5"/><line x1="19" y1="21" x2="19" y2="14"/><line x1="3" y1="21" x2="21" y2="21"/></svg>',
|
||||
folder: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6.5A1.5 1.5 0 0 1 4.5 5H9l2 2.5h8.5A1.5 1.5 0 0 1 21 9v8.5A1.5 1.5 0 0 1 19.5 19h-15A1.5 1.5 0 0 1 3 17.5Z"/></svg>',
|
||||
people: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><circle cx="9" cy="8" r="3"/><path d="M3.5 19c0-3 2.5-5 5.5-5s5.5 2 5.5 5"/><circle cx="17.5" cy="9" r="2.3"/><path d="M15.8 13.2c2.4.2 4.2 2 4.2 4.6"/></svg>',
|
||||
plus: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>',
|
||||
copy: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="8" y="8" width="13" height="13" rx="2"/><path d="M16 8V5.5A1.5 1.5 0 0 0 14.5 4H5.5A1.5 1.5 0 0 0 4 5.5v9A1.5 1.5 0 0 0 5.5 16H8"/></svg>',
|
||||
trash: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><line x1="4" y1="7" x2="20" y2="7"/><path d="M6 7V5a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v2"/><path d="M18.5 7 17.7 19.5A2 2 0 0 1 15.7 21H8.3a2 2 0 0 1-2-1.9L5.5 7"/><line x1="10" y1="11" x2="10" y2="17"/><line x1="14" y1="11" x2="14" y2="17"/></svg>',
|
||||
fileText: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M6.5 3.5h7l4 4v12a1 1 0 0 1-1 1h-10a1 1 0 0 1-1-1v-15a1 1 0 0 1 1-1Z"/><path d="M13.5 3.5V7a1 1 0 0 0 1 1h4"/><line x1="8.5" y1="12.5" x2="15.5" y2="12.5"/><line x1="8.5" y1="16" x2="13.5" y2="16"/></svg>',
|
||||
play: '<svg viewBox="0 0 24 24" fill="currentColor" stroke="none"><path d="M7.5 5.2a1 1 0 0 1 1.53-.85l10 6.8a1 1 0 0 1 0 1.7l-10 6.8A1 1 0 0 1 7.5 18.8Z"/></svg>',
|
||||
table: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="3.5" y="4.5" width="17" height="15" rx="1.5"/><line x1="3.5" y1="9.5" x2="20.5" y2="9.5"/><line x1="3.5" y1="14.5" x2="20.5" y2="14.5"/><line x1="9.5" y1="4.5" x2="9.5" y2="19.5"/></svg>',
|
||||
download: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3.5v11"/><path d="m7.5 10.5 4.5 4.5 4.5-4.5"/><path d="M4.5 17.5v2a1 1 0 0 0 1 1h13a1 1 0 0 0 1-1v-2"/></svg>',
|
||||
upload: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M12 15.5v-11"/><path d="m7.5 8.5 4.5-4.5 4.5 4.5"/><path d="M4.5 17.5v2a1 1 0 0 0 1 1h13a1 1 0 0 0 1-1v-2"/></svg>',
|
||||
user: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="8.5" r="3.5"/><path d="M4.5 20c0-4 3.5-6.5 7.5-6.5s7.5 2.5 7.5 6.5"/></svg>',
|
||||
logout: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M9 21H5.5A1.5 1.5 0 0 1 4 19.5v-15A1.5 1.5 0 0 1 5.5 3H9"/><path d="M16 17l5-5-5-5"/><line x1="21" y1="12" x2="9" y2="12"/></svg>',
|
||||
settings: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3.2"/><path d="M12 3.5v2.4M12 18.1v2.4M20.5 12h-2.4M5.9 12H3.5M17.7 6.3l-1.7 1.7M8 15.7l-1.7 1.7M17.7 17.7 16 16M8 8 6.3 6.3"/></svg>',
|
||||
shield: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3.5 5.5 6v5.3c0 4.4 2.9 7.4 6.5 8.7 3.6-1.3 6.5-4.3 6.5-8.7V6Z"/><path d="m9 12 2.1 2.1L15.5 9.5"/></svg>',
|
||||
mail: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="3.5" y="5.5" width="17" height="13" rx="2"/><path d="m4 6.5 8 6.5 8-6.5"/></svg>',
|
||||
warning: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M12 4.5 21 19.5H3Z"/><line x1="12" y1="10" x2="12" y2="14.2"/><circle cx="12" cy="16.9" r=".7" fill="currentColor"/></svg>',
|
||||
history: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M4 12a8 8 0 1 0 2.5-5.8"/><polyline points="4 4 4 8.5 8.5 8.5"/><polyline points="12 7.5 12 12.4 15.5 14.3"/></svg>',
|
||||
};
|
||||
|
||||
// icon: liefert das benannte Icon als <span class="icon">-Wrapper (Größe wird
|
||||
// per CSS über `.icon svg { width/height: 1em; }` gesteuert, siehe
|
||||
// styles.css) — dadurch skaliert das Icon automatisch mit font-size wie ein
|
||||
// Zeichen. `extraClass` (optional) wird zusätzlich auf den <span> gesetzt.
|
||||
function icon(name, extraClass){
|
||||
const svg = ICONS[name];
|
||||
if(!svg) return '';
|
||||
return `<span class="icon${extraClass ? ' ' + extraClass : ''}">${svg}</span>`;
|
||||
}
|
||||
+40
-28
@@ -4,48 +4,57 @@
|
||||
// Einstiegsmodul der Hauptseite: wird als letztes Skript ausschließlich von
|
||||
// Test.html geladen und startet direkt beim Laden den Initialisierungsablauf
|
||||
// (automatische Ausführung unten in der Datei, nur wenn ein 'board'-Element
|
||||
// existiert – auf project-plan.html fehlt es). Es stellt renderAll(),
|
||||
// existiert). Es stellt renderAll(),
|
||||
// initUiState() und syncRangeDisplay() bereit und nutzt loadUiState()/
|
||||
// loadTheme()/loadHeaderState() aus js/ui.js, switchDepartment() aus
|
||||
// js/store.js, showTab() aus js/tabs-auth.js sowie die Render-Funktionen
|
||||
// renderBoard()/renderLoad()/renderProjects()/renderPeople() aus js/board/board.js,
|
||||
// js/load/load.js, js/projects/projects.js, js/people/people.js.
|
||||
// loadTheme()/loadHeaderState()/loadSidebarCollapseState() aus js/ui.js, load() aus js/store.js,
|
||||
// showTab()/TAB_PANELS aus js/tabs-auth.js sowie die Render-Funktionen
|
||||
// renderBoard()/renderWochenbericht()/renderLoad()/renderProjects()/renderPeople()
|
||||
// aus js/board/board.js, js/wochenbericht/wochenbericht.js, js/load/load.js,
|
||||
// js/projects/projects.js, js/people/people.js.
|
||||
|
||||
// renderAll: Convenience um UI-Elemente zu initialisieren
|
||||
function renderAll(){
|
||||
refreshWeekSelect();
|
||||
updateWeekDate();
|
||||
renderBoard();
|
||||
const activeTab = document.querySelector('.tab.active')?.dataset.tab || currentTab;
|
||||
if(activeTab === 'load') renderLoad();
|
||||
else if(activeTab === 'projects') renderProjects();
|
||||
if(activeTab === 'wochenbericht') renderWochenbericht();
|
||||
else if(activeTab === 'load') renderLoad();
|
||||
else if(activeTab === 'people') renderPeople();
|
||||
else if(activeTab === 'dashboard') renderDashboard();
|
||||
else if(activeTab === 'settings') renderSettings();
|
||||
else if(activeTab === 'admin') renderAdminArea();
|
||||
// Der Projektbereich rendert nicht hier, sondern in showTab('plan') —
|
||||
// load() ruft direkt im Anschluss showTab(currentTab) auf, und erst dort
|
||||
// ist der Bereich sichtbar (das GANTT braucht die Containerbreite).
|
||||
}
|
||||
|
||||
// initUiState: stellt beim Start den UI-Zustand her (LocalStorage-UI-State,
|
||||
// Theme, Header-Status), setzt Titel und aktive Abteilungs-Schaltfläche und
|
||||
// zeigt den ersten Tab an. Eine Deep-Link-URL (z. B. "/HLS/Auslastung") aus
|
||||
// applyUrlRoute() übersteuert dabei Abteilung und Tab; passwortgeschützte Tabs
|
||||
// öffnen das Passwort-Modal (showTab ohne skipAuth). Kein Rückgabewert.
|
||||
// Theme, Header-Status), setzt den Seitentitel und zeigt den ersten Tab an.
|
||||
// Eine Deep-Link-URL (z. B. "/Auslastung") aus applyUrlRoute() übersteuert
|
||||
// dabei den Tab. Kein Rückgabewert.
|
||||
function initUiState(){
|
||||
loadUiState();
|
||||
const route = applyUrlRoute();
|
||||
if(route && DEPARTMENTS[route.department]){
|
||||
currentDepartment = route.department;
|
||||
if(route){
|
||||
currentTab = route.tab;
|
||||
// Deep-Link auf den Projektbereich ("/Projektplan/<Name>?weekLabel=…"):
|
||||
// Ziel merken, damit showTab('plan') es rendern kann. Beim ersten Aufruf
|
||||
// ist db.weeks noch leer — load() ruft showTab(currentTab) erneut auf,
|
||||
// sobald die Sitzungen da sind. Ein Sandbox-Plan der Planung trägt statt
|
||||
// projNr ein planId (siehe updateRouteUrl(), js/ui.js) — PlanApp.sandboxPlanId
|
||||
// wird deshalb nach setPlanTarget() gesetzt, das es sonst zurücksetzt.
|
||||
if(route.tab === 'plan'){
|
||||
setPlanTarget(route.weekLabel, route.projNr, route.projName);
|
||||
if(route.weekLabel === SANDBOX_WEEK_LABEL) window.PlanApp.sandboxPlanId = route.planId || '';
|
||||
}
|
||||
}
|
||||
loadTheme();
|
||||
loadHeaderState();
|
||||
document.querySelectorAll('.dept-tab').forEach(btn => {
|
||||
btn.classList.toggle('active', btn.dataset.dept === currentDepartment);
|
||||
});
|
||||
const info = DEPARTMENTS[currentDepartment];
|
||||
document.title = 'Projektübersicht ' + info.label;
|
||||
loadSidebarCollapseState();
|
||||
document.title = 'Projektübersicht';
|
||||
const titleEl = document.getElementById('pageTitle');
|
||||
if(titleEl) titleEl.textContent = info.icon + ' Projektübersicht ' + info.label;
|
||||
if(titleEl) titleEl.textContent = 'Projektübersicht';
|
||||
|
||||
const tab = ['board','load','projects','people'].includes(currentTab) ? currentTab : DEFAULT_TAB;
|
||||
// Geschützte Tabs ('projects'/'people') verlangen hier ihr Passwort.
|
||||
const tab = TAB_PANELS.includes(currentTab) ? currentTab : DEFAULT_TAB;
|
||||
showTab(tab);
|
||||
}
|
||||
|
||||
@@ -64,10 +73,13 @@ function syncRangeDisplay(displayId, startId, endId){
|
||||
disp.value = from ? (from + ' – ' + to) : '';
|
||||
}
|
||||
|
||||
// Automatische Initialisierung nur auf der Hauptseite (Projektboard).
|
||||
// Andere Seiten (z.B. Projektplan) laden diese Module nicht oder nur als
|
||||
// Hilfsfunktionen und haben ihre eigene Seitenlogik (js/plan/*).
|
||||
// Automatische Initialisierung der Hauptseite — seit Projektbereich und
|
||||
// Benutzerverwaltung eigene Bereiche sind, ist sie die einzige Seite. Der eigentliche
|
||||
// Start läuft erst nach erfolgreicher Anmeldung (checkAuthAndInit(), js/auth.js) —
|
||||
// ohne gültige Session zeigt das Login-Overlay und blockiert die App.
|
||||
if(document.getElementById('board')){
|
||||
initUiState();
|
||||
switchDepartment(currentDepartment);
|
||||
checkAuthAndInit(() => {
|
||||
initUiState();
|
||||
load();
|
||||
});
|
||||
}
|
||||
+542
-128
@@ -8,7 +8,7 @@
|
||||
// aus js/globals.js, currentWeek()/save() aus js/store.js, guardWeekLocked()
|
||||
// aus js/weeks.js sowie peopleByRole()/assignKey() aus js/utils.js.
|
||||
// Die Auslastungs-Logik (berechneAuslastung, lpActiveList, faktorFormat,
|
||||
// pdfLoadAmpelColor, LOAD_BAR_MAX) wird zusätzlich von js/pdf.js für den
|
||||
// pdfLoadAmpelColor, stundenFormat) wird zusätzlich von js/pdf.js für den
|
||||
// PDF-Export wiederverwendet.
|
||||
//
|
||||
// Datenmodell je Projekt (rollen- und leistungsphasenspezifisch):
|
||||
@@ -35,34 +35,61 @@
|
||||
// fachlich abgestimmt werden.
|
||||
// - Mitarbeiterkapazität (Teilzeit/Vollzeit) existiert nicht – offener Punkt.
|
||||
|
||||
// AUFWAND_LPS: Phasen des Aufwandsmodells (LPH 1–8; "W+M" ist bewusst nicht
|
||||
// enthalten, entspricht der Entscheidung "LP-Schema: lp1-8").
|
||||
const AUFWAND_LPS = LPHS.slice(0, 8);
|
||||
// AUFWAND_LPS: Phasen des Aufwandsmodells — alle echten Leistungsphasen
|
||||
// (inkl. LPH 9), ohne die Sammelkategorie "W+M". Bewusst per Filter statt per
|
||||
// Index-Schnitt, damit eine Ergänzung von LPHS nicht stillschweigend durchs
|
||||
// Raster fällt.
|
||||
const AUFWAND_LPS = LPHS.filter(l => l !== 'W+M');
|
||||
|
||||
// LP_AUFWAND_VORLAGE: Standardwerte der Maske "Aufwand je Leistungsphase".
|
||||
// Vorbefüllung beim Anlegen neuer Projekte sowie beim Öffnen der Maske für
|
||||
// bestehende Projekte, die in einzelnen Feldern keine Werte haben – beim
|
||||
// Speichern werden sie übernommen. Werte 0.0–1.0 je Rolle; 0 = kein Aufwand.
|
||||
// Nicht gelistete Phasen/Rollen bleiben leer und zählen als Standard-Faktor 1,0.
|
||||
// Hier im Code anpassen.
|
||||
const LP_AUFWAND_VORLAGE = {
|
||||
'LPH 1': { Planung: 0.4, Projektleitung: 0.2, Bauüberwachung: 0 },
|
||||
'LPH 2': { Planung: 0.4, Projektleitung: 0.2, Bauüberwachung: 0 },
|
||||
'LPH 3': { Planung: 1.0, Projektleitung: 0.3, Bauüberwachung: 0 },
|
||||
'LPH 4': { Planung: 0.0, Projektleitung: 0.3, Bauüberwachung: 0 },
|
||||
'LPH 5': { Planung: 1.0, Projektleitung: 0.4, Bauüberwachung: 0 },
|
||||
'LPH 6': { Planung: 0.2, Projektleitung: 0.7, Bauüberwachung: 0.3 },
|
||||
'LPH 7': { Planung: 0.1, Projektleitung: 0.3, Bauüberwachung: 0.5 },
|
||||
'LPH 8': { Planung: 0, Projektleitung: 0.2, Bauüberwachung: 1.0 }
|
||||
};
|
||||
// aufwandVorlage: die firmenweite Gewichtung je Leistungsphase und Rolle
|
||||
// (0,0–1,0). Sie greift überall dort, wo ein Projekt in seiner Aufwandsmaske
|
||||
// keinen eigenen Wert trägt — also bei den meisten — und bestimmt damit still
|
||||
// einen Großteil der Auslastungszahlen. Genau deshalb steht sie seit dem
|
||||
// Ausbau der Einstellungen nicht mehr als Konstante hier, sondern wird
|
||||
// firmenweit gepflegt (Bereich „Einstellungen", nur Administratoren) und über
|
||||
// APP_SETTINGS geliefert (js/globals.js, gefüllt in js/store.js). LPH 9 steht
|
||||
// in der Vorgabe bewusst auf 0 — die Faktoren sind fachlich nicht abgestimmt.
|
||||
// Rückgabe: Objekt LPH -> { Rolle: Faktor }.
|
||||
function aufwandVorlage(){
|
||||
return (APP_SETTINGS && APP_SETTINGS.aufwandVorlage) || {};
|
||||
}
|
||||
|
||||
// PLATZHALTER! Ampel-Schwellen als Anteil des Maximalwerts LOAD_BAR_MAX
|
||||
// (gelb ab 50 %, rot ab 75 %). Müssen vom Fachplaner bestätigt werden.
|
||||
const THRESHOLD_RATIO_CRITICAL = 0.75;
|
||||
const THRESHOLD_RATIO_ELEVATED = 0.50;
|
||||
// Ampel-Schwellen und Balkenskala. Die Schwellen beziehen sich auf die
|
||||
// KAPAZITÄT der Person (1,0 = ihre volle Stelle), nicht auf das Maximum der
|
||||
// Balkenskala: Mit einer Skala bis 8 lagen sie vorher faktisch bei 6,0 (rot)
|
||||
// und 4,0 (gelb) — eine voll ausgelastete Person erschien grün, Überlastung
|
||||
// meldete die Ampel praktisch nie. Alle drei Werte sind firmenweit einstellbar.
|
||||
function schwelleGelb(){ return Number(APP_SETTINGS.auslastungSchwelleGelb) || 0.75; }
|
||||
function schwelleRot(){ return Number(APP_SETTINGS.auslastungSchwelleRot) || 1.0; }
|
||||
|
||||
// kapazitaetsFaktor: Anteil einer vollen Stelle, den eine Person vertraglich
|
||||
// arbeitet (0,5 = halbe Stelle). Bis zum Ausbau der Einstellungen kannte die
|
||||
// Rechnung keine Teilzeit und maß jede Person an einer vollen Stelle — eine
|
||||
// halbe Stelle erschien dadurch dauerhaft unterbeschäftigt (hier oben als
|
||||
// offener Punkt vermerkt). Die Wochenstunden stehen am Benutzerkonto
|
||||
// (Benutzerverwaltung, users.capacity_hours); 0 bzw. keine auffindbare
|
||||
// Zuordnung zählt als volle Stelle, damit sich das Bild bestehender Konten
|
||||
// nicht unangekündigt verschiebt.
|
||||
//
|
||||
// Der Parameter ist bewusst das PERSONENOBJEKT der Sitzung und nicht nur
|
||||
// dessen id: Die Teilnehmer einer Sitzung sind eingefrorene Kopien
|
||||
// (week_people) und ihre id stammt aus der Zeit, als sie angelegt wurden —
|
||||
// im Altbestand steht dort z. B. "p_ap", während das Konto heute
|
||||
// "p_alexander_plum" trägt. Über die id allein fände man die Person also
|
||||
// nicht wieder; der volle Name ist die zweite Brücke.
|
||||
// Parameter: person (Personenobjekt der Sitzung). Rückgabe: Faktor > 0.
|
||||
function kapazitaetsFaktor(person){
|
||||
const list = db.globalPeople || [];
|
||||
const global = list.find(p => p.id === person.id)
|
||||
|| (person.fullName ? list.find(p => p.fullName === person.fullName) : null);
|
||||
const stunden = global ? Number(global.capacityHours) : 0;
|
||||
const voll = Number(APP_SETTINGS.vollzeitStundenProWoche) || 40;
|
||||
if(!Number.isFinite(stunden) || stunden <= 0) return 1;
|
||||
return stunden / voll;
|
||||
}
|
||||
|
||||
// lpActiveList: liefert die amtierenden Leistungsphasen eines Projekts,
|
||||
// gefiltert auf das Aufwandsmodell (LPH 1–8). Parameter: pr = Projektobjekt.
|
||||
// gefiltert auf das Aufwandsmodell (alle LPH ohne "W+M"). Parameter: pr = Projektobjekt.
|
||||
// Rückgabe: Array von LP-Namen (leer, wenn keine aktive Phase passt).
|
||||
function lpActiveList(pr){
|
||||
const a = pr && pr.aktuelleLP;
|
||||
@@ -82,19 +109,239 @@ function aufwandMatrixWert(projekt, lp, rolle){
|
||||
return isNaN(v) ? null : v;
|
||||
}
|
||||
|
||||
// berechneAuslastung: zentrale Auslastungsberechnung einer Zuordnung
|
||||
// (Person/Rolle) zu einem Projekt für die amtierende Leistungsphase.
|
||||
// Ohne gesetzte aktive LP zählt das Projekt 0 (nicht eingerechnet).
|
||||
// Mit aktiver LP: Liegt aufwandProLP[aktuelleLP][rolle] vor, zählt dieser
|
||||
// Faktor 1:1 – die Zuordnungsart (Haupt/Optional) skaliert bewusst nicht mehr.
|
||||
// Fehlt der Matrix-Wert, gilt der Standard-Faktor 1,0.
|
||||
// "In Anbahnung" wird nicht eingerechnet.
|
||||
function berechneAuslastung(mitarbeiter, projekt, aktuelleLP, rolle, status){
|
||||
if(!projekt || projekt.group === 'In Anbahnung') return { load: 0, anbahnung: true };
|
||||
if(!aktuelleLP) return { load: 0 };
|
||||
const wert = aufwandMatrixWert(projekt, aktuelleLP, rolle);
|
||||
if(wert !== null) return { load: wert };
|
||||
return { load: 1.0 };
|
||||
// ---------- Auslastung in Stunden ----------
|
||||
//
|
||||
// Die Auslastung wird als WOCHENSTUNDEN gerechnet und erst zum Schluss auf
|
||||
// die Kapazität der Person bezogen. Vorher war sie eine Summe dimensionsloser
|
||||
// Gewichte: Ein Projekt in LPH 5 zählte für die Planung 1,0 — ob es 20.000 €
|
||||
// oder 2 Mio. € umfasste. Zwei kleine Projekte wogen damit schwerer als ein
|
||||
// großes, und die Zahl sagte nichts darüber, wie viel Arbeit tatsächlich
|
||||
// anliegt.
|
||||
//
|
||||
// Der Weg von einem Projekt zu den Wochenstunden einer Person:
|
||||
//
|
||||
// 1. BASIS Honorar des Projekts. Fehlt es, tritt die am Projekt
|
||||
// gepflegte Größe (0–1) mal dem Referenzhonorar an seine
|
||||
// Stelle. Fehlt beides, ist das Projekt NICHT BEWERTBAR —
|
||||
// es wird als Lücke ausgewiesen, nicht als Null gezählt.
|
||||
// 2. PHASE Anteil der amtierenden Leistungsphase am Honorar
|
||||
// (HONORAR_ANTEILE, auf die beauftragten Phasen normiert —
|
||||
// dieselbe Rechnung wie im Leistungsstand des
|
||||
// Projektbereichs), abzüglich des bereits erbrachten
|
||||
// Fortschritts dieser Phase. Übrig bleibt der OFFENE Wert.
|
||||
// 3. STUNDEN Offener Wert geteilt durch den Stundensatz.
|
||||
// 4. ROLLE Aufteilung auf die Rollen über dieselbe Gewichtung wie
|
||||
// bisher (Projektmatrix, sonst firmenweite Vorlage), hier
|
||||
// aber als ANTEIL: Gewicht der Rolle durch die Summe aller
|
||||
// Rollengewichte dieser Phase.
|
||||
// 5. KÖPFE Aufteilung auf die Personen, die dieser Rolle im Projekt
|
||||
// zugeordnet sind. Ohne diesen Schritt zählte jeder von drei
|
||||
// Planern die vollen Phasenstunden — der Aufwand verdreifacht
|
||||
// sich nicht dadurch, dass mehr Leute daran arbeiten.
|
||||
// Hauptverantwortung wiegt 1, Unterstützung 0,5.
|
||||
// 6. ZEIT Verteilung der Reststunden über die Wochen bis zum Ende
|
||||
// des Terminplans; ohne Terminplan über den firmenweiten
|
||||
// Verteilzeitraum. Ohne Horizont wäre die Wochenlast nicht
|
||||
// bestimmt: Dieselben Reststunden bedeuten über vier Wochen
|
||||
// etwas anderes als über vierzig.
|
||||
//
|
||||
// "In Anbahnung" bleibt wie bisher ausgeklammert.
|
||||
|
||||
// projektBasisHonorar: die Geldgrundlage eines Projekts für die
|
||||
// Auslastungsrechnung. Parameter: projekt. Rückgabe:
|
||||
// { betrag, quelle: 'honorar'|'groesse'|null }.
|
||||
function projektBasisHonorar(projekt){
|
||||
const honorar = Number(projekt && projekt.honorar) || 0;
|
||||
if(honorar > 0) return { betrag: honorar, quelle: 'honorar' };
|
||||
const groesse = Number(projekt && projekt.groesse) || 0;
|
||||
if(groesse > 0){
|
||||
const referenz = Number(APP_SETTINGS.referenzHonorar) || 250000;
|
||||
return { betrag: Math.min(1, groesse) * referenz, quelle: 'groesse' };
|
||||
}
|
||||
return { betrag: 0, quelle: null };
|
||||
}
|
||||
|
||||
// phasenAnteilProzent: Anteil einer Leistungsphase am Honorar des Projekts,
|
||||
// normiert auf die BEAUFTRAGTEN Phasen — ein Projekt, das nur für drei Phasen
|
||||
// beauftragt ist, verteilt sein Honorar auch nur auf diese drei. Sind keine
|
||||
// beauftragt, gelten die firmenweiten Anteile unnormiert (sie ergeben
|
||||
// zusammen 100 %). Parameter: projekt, lph. Rückgabe: Prozent (0 = unbekannt).
|
||||
function phasenAnteilProzent(projekt, lph){
|
||||
const roh = Number(HONORAR_ANTEILE[lph]) || 0;
|
||||
if(roh <= 0) return 0;
|
||||
const beauftragt = Array.isArray(projekt.beauftragteLP) ? projekt.beauftragteLP : [];
|
||||
if(beauftragt.length === 0) return roh;
|
||||
if(!beauftragt.includes(lph)) return 0;
|
||||
const summe = beauftragt.reduce((acc, l) => acc + (Number(HONORAR_ANTEILE[l]) || 0), 0);
|
||||
return summe > 0 ? roh / summe * 100 : 0;
|
||||
}
|
||||
|
||||
// rollenGewicht: Aufwandsgewicht einer Rolle in einer Phase — Wert der
|
||||
// Projektmatrix, sonst firmenweite Vorlage, sonst 1,0. Unverändert
|
||||
// gegenüber der bisherigen Rechnung; neu ist nur, wofür er dient (Anteil
|
||||
// statt absoluter Last). Parameter: projekt, lph, rolle. Rückgabe: Zahl.
|
||||
function rollenGewicht(projekt, lph, rolle){
|
||||
const wert = aufwandMatrixWert(projekt, lph, rolle);
|
||||
if(wert !== null) return wert;
|
||||
const vorlage = aufwandVorlage()[lph];
|
||||
const v = vorlage ? parseFloat(vorlage[rolle]) : NaN;
|
||||
return isNaN(v) ? 1.0 : v;
|
||||
}
|
||||
|
||||
// zuordnungsGewicht: Wie schwer eine Zuordnung wiegt. 1 = Hauptverantwortung,
|
||||
// 2 = Unterstützung. Die Unterstützung zählt halb, weil sie sonst dieselbe
|
||||
// Stundenzahl trüge wie die Hauptverantwortung und die Arbeit rechnerisch
|
||||
// gleichmäßig verteilt würde, obwohl sie es nicht ist.
|
||||
function zuordnungsGewicht(status){
|
||||
return status === 2 ? 0.5 : status === 1 ? 1 : 0;
|
||||
}
|
||||
|
||||
// projektVerteilWochen: über wie viele Wochen die offenen Stunden eines
|
||||
// Projekts verteilt werden. Aus dem Terminplan das späteste Datum (dieselbe
|
||||
// Quelle, aus der auch der Verlauf das Projektende liest), gerechnet ab
|
||||
// heute; ohne Terminplan oder bei bereits verstrichenem Ende gilt der
|
||||
// firmenweite Verteilzeitraum. Parameter: projekt. Rückgabe: Wochen (>= 1).
|
||||
function projektVerteilWochen(projekt){
|
||||
const vorgabe = Math.max(1, Number(APP_SETTINGS.verteilzeitraumWochen) || 12);
|
||||
const plan = projekt && projekt.plan;
|
||||
if(!plan) return vorgabe;
|
||||
const daten = [];
|
||||
(Array.isArray(plan.todos) ? plan.todos : []).forEach(t => { if(t && t.end) daten.push(t.end); });
|
||||
(Array.isArray(plan.milestones) ? plan.milestones : []).forEach(m => { if(m && m.date) daten.push(m.date); });
|
||||
if(daten.length === 0) return vorgabe;
|
||||
const ende = new Date(daten.sort().pop());
|
||||
if(isNaN(ende.getTime())) return vorgabe;
|
||||
const heute = new Date();
|
||||
heute.setHours(0, 0, 0, 0);
|
||||
const tage = daysBetween(heute, ende);
|
||||
// Ein Ende in der Vergangenheit sagt nichts über die kommenden Wochen —
|
||||
// dann gilt wieder die Vorgabe, statt die Restarbeit auf null Wochen zu
|
||||
// verteilen und eine unendliche Wochenlast zu erzeugen.
|
||||
if(!Number.isFinite(tage) || tage <= 0) return vorgabe;
|
||||
return Math.max(1, tage / 7);
|
||||
}
|
||||
|
||||
// fortschrittProzent: Fortschritt einer Leistungsphase am Projekt (0–100).
|
||||
// Die Werte kommen aus einem Textfeld und können "80", "80 %" oder "12,5"
|
||||
// lauten. Parameter: projekt, lph. Rückgabe: Zahl 0–100.
|
||||
function fortschrittProzent(projekt, lph){
|
||||
const roh = (projekt && projekt.lph) ? projekt.lph[lph] : undefined;
|
||||
const n = parseFloat(String(roh ?? '').replace(',', '.'));
|
||||
if(!Number.isFinite(n)) return 0;
|
||||
return Math.min(100, Math.max(0, n));
|
||||
}
|
||||
|
||||
// arbeitsPhase: Welche Leistungsphase die Auslastung eines Projekts
|
||||
// bestimmt — die amtierende, und wenn die bereits zu 100 % erledigt ist, die
|
||||
// nächste beauftragte, die noch offen ist.
|
||||
//
|
||||
// Ohne diesen Schritt fiel ein Projekt aus der Auslastung, sobald jemand
|
||||
// seine amtierende Phase auf 100 % setzte, ohne die Phase weiterzuschalten —
|
||||
// im Bestand der Normalfall. Rechnerisch stimmte das (diese eine Phase ist
|
||||
// fertig), fachlich nicht: Das Projekt läuft weiter, nur eben in der
|
||||
// nächsten Phase. Sind alle beauftragten Phasen fertig, ist das Projekt
|
||||
// fertig und bindet zu Recht keine Kapazität mehr.
|
||||
// Parameter: projekt, amtierend. Rückgabe: Phasenname oder null.
|
||||
function arbeitsPhase(projekt, amtierend){
|
||||
if(!amtierend) return null;
|
||||
if(fortschrittProzent(projekt, amtierend) < 100) return amtierend;
|
||||
const beauftragt = Array.isArray(projekt.beauftragteLP) && projekt.beauftragteLP.length
|
||||
? projekt.beauftragteLP : AUFWAND_LPS;
|
||||
// In der Reihenfolge der Leistungsphasen weitersuchen, beginnend hinter
|
||||
// der amtierenden.
|
||||
const abIndex = AUFWAND_LPS.indexOf(amtierend);
|
||||
const folgende = AUFWAND_LPS.filter((l, i) => i > abIndex && beauftragt.includes(l));
|
||||
return folgende.find(l => fortschrittProzent(projekt, l) < 100) || null;
|
||||
}
|
||||
|
||||
// berechneAuslastung: Wochenstunden EINER Zuordnung (Person/Rolle) zu einem
|
||||
// Projekt. Parameter: mitarbeiter (ungenutzt, Signatur beibehalten), projekt,
|
||||
// aktuelleLP, rolle, status (1 = Haupt, 2 = Unterstützung), alleZuordnungen
|
||||
// (optional: das assign-Objekt des Projekts, für die Aufteilung auf mehrere
|
||||
// Köpfe derselben Rolle).
|
||||
// Rückgabe: { stunden, load, anbahnung?, unbewertbar?, grund? }.
|
||||
// stunden = Wochenstunden dieser Person aus diesem Projekt
|
||||
// load = dieselbe Größe als Anteil einer Vollzeitstelle (Rückwärts-
|
||||
// kompatibilität für js/pdf.js, das den Wert weiterhin so nennt)
|
||||
function berechneAuslastung(mitarbeiter, projekt, aktuelleLP, rolle, status, alleZuordnungen){
|
||||
const leer = { stunden: 0, load: 0 };
|
||||
if(!projekt || projekt.group === 'In Anbahnung') return { stunden: 0, load: 0, anbahnung: true };
|
||||
if(!aktuelleLP) return Object.assign({}, leer, { unbewertbar: true, grund: 'ohne amtierende Leistungsphase' });
|
||||
|
||||
const basis = projektBasisHonorar(projekt);
|
||||
if(basis.betrag <= 0) return Object.assign({}, leer, { unbewertbar: true, grund: 'ohne Honorar und ohne Projektgröße' });
|
||||
|
||||
// Gerechnet wird mit der Phase, in der tatsächlich gearbeitet wird — der
|
||||
// amtierenden oder, wenn die fertig ist, der nächsten offenen.
|
||||
const phase = arbeitsPhase(projekt, aktuelleLP);
|
||||
if(!phase) return Object.assign({}, leer, { fertig: true });
|
||||
|
||||
const anteil = phasenAnteilProzent(projekt, phase);
|
||||
if(anteil <= 0) return Object.assign({}, leer, { unbewertbar: true, grund: 'ohne Honorar-Anteil für diese Phase' });
|
||||
|
||||
const offenerWert = basis.betrag * anteil / 100 * (1 - fortschrittProzent(projekt, phase) / 100);
|
||||
if(offenerWert <= 0) return Object.assign({}, leer, { fertig: true });
|
||||
|
||||
const satz = Number(APP_SETTINGS.stundensatz) || 100;
|
||||
const gesamtStunden = offenerWert / satz;
|
||||
|
||||
// Rollenanteil — nach der Phase, in der gearbeitet wird
|
||||
const gewichte = ROLES.map(r => rollenGewicht(projekt, phase, r));
|
||||
const summeGewichte = gewichte.reduce((a, b) => a + b, 0);
|
||||
const eigenesGewicht = rollenGewicht(projekt, phase, rolle);
|
||||
if(summeGewichte <= 0 || eigenesGewicht <= 0) return leer;
|
||||
const rollenStunden = gesamtStunden * (eigenesGewicht / summeGewichte);
|
||||
|
||||
// Aufteilung auf die Köpfe dieser Rolle
|
||||
const eigenAnteil = zuordnungsGewicht(status);
|
||||
if(eigenAnteil <= 0) return leer;
|
||||
let summeKoepfe = eigenAnteil;
|
||||
if(alleZuordnungen && typeof alleZuordnungen === 'object'){
|
||||
summeKoepfe = 0;
|
||||
Object.keys(alleZuordnungen).forEach(key => {
|
||||
if(key.slice(key.indexOf('|') + 1) !== rolle) return;
|
||||
summeKoepfe += zuordnungsGewicht(alleZuordnungen[key]);
|
||||
});
|
||||
if(summeKoepfe <= 0) summeKoepfe = eigenAnteil;
|
||||
}
|
||||
const personStunden = rollenStunden * (eigenAnteil / summeKoepfe);
|
||||
|
||||
const wochen = projektVerteilWochen(projekt);
|
||||
const stunden = personStunden / wochen;
|
||||
const vollzeit = Number(APP_SETTINGS.vollzeitStundenProWoche) || 40;
|
||||
|
||||
return { stunden, load: stunden / vollzeit, quelle: basis.quelle, wochen, phase };
|
||||
}
|
||||
|
||||
// groesseText: beschriftet die Projektgröße für die Eingabemasken.
|
||||
//
|
||||
// Nur die Zahl, KEIN Eurobetrag daneben. Intern wird die Größe zwar über das
|
||||
// Referenzhonorar in einen Betrag umgerechnet (projektBasisHonorar()) — die
|
||||
// Zahl neben dem Feld auszuweisen, gäbe der Schätzung aber eine Genauigkeit,
|
||||
// die sie nicht hat: "0,40 · rund 100.000 €" liest sich wie ein Honorar,
|
||||
// obwohl es ein grober Umfang ist.
|
||||
// Parameter: wert (0–1). Rückgabe: String.
|
||||
function groesseText(wert){
|
||||
const n = Math.max(0, Math.min(1, parseFloat(wert) || 0));
|
||||
if(n <= 0) return '— keine Angabe';
|
||||
return n.toFixed(2).replace('.', ',');
|
||||
}
|
||||
|
||||
// stundenFormat: Wochenstunden in deutscher Schreibweise, eine
|
||||
// Nachkommastelle. Ab 100 h ohne Nachkomma — die Genauigkeit einer
|
||||
// Hochrechnung reicht dort nicht für Zehntel.
|
||||
// Parameter: x (Zahl). Rückgabe: String.
|
||||
function stundenFormat(x){
|
||||
const n = Number(x) || 0;
|
||||
if(n >= 100) return String(Math.round(n));
|
||||
return (Math.round(n * 10) / 10).toFixed(1).replace('.', ',');
|
||||
}
|
||||
|
||||
// toggleLoadLuecken: klappt die Liste der nicht bewertbaren Projekte auf und
|
||||
// zu. Keine Parameter, keine Rückgabe.
|
||||
function toggleLoadLuecken(){
|
||||
const el = document.getElementById('loadLuecken');
|
||||
if(el) el.style.display = el.style.display === 'none' ? '' : 'none';
|
||||
}
|
||||
|
||||
// faktorFormat: formatiert einen Auslastungs-Faktor in deutscher Notation
|
||||
@@ -104,15 +351,23 @@ function faktorFormat(x){
|
||||
return n.toFixed(2).replace('.', ',');
|
||||
}
|
||||
|
||||
// loadAmpelColor: Ampel-Farbe relativ zum Maximalwert (gelb ab 50 %,
|
||||
// rot ab 75 % von LOAD_BAR_MAX; PLATZHALTER).
|
||||
// loadAmpelColor: Ampel-Farbe eines Auslastungswerts. Der übergebene Wert ist
|
||||
// bereits auf die Kapazität der Person bezogen (1,0 = ihre volle Stelle); die
|
||||
// Schwellen sind firmenweit einstellbar.
|
||||
function loadAmpelColor(x){
|
||||
return x >= LOAD_BAR_MAX * THRESHOLD_RATIO_CRITICAL ? '#c53030' : x >= LOAD_BAR_MAX * THRESHOLD_RATIO_ELEVATED ? '#d69e2e' : '#2f855a';
|
||||
// Design-Tokens statt fester Farbwerte: Die Ampel färbt seit dem Lastband
|
||||
// nicht mehr nur einen Balken, sondern die große Zahl am Zeilenende. Die
|
||||
// früheren Festwerte (#c53030/#d69e2e/#2f855a) waren für den hellen Grund
|
||||
// gewählt und standen im dunklen Thema zu dunkel; --red/--amber/--green
|
||||
// sind dort hellere Töne (siehe styles.css). Für den PDF-Export gilt
|
||||
// weiterhin pdfLoadAmpelColor() mit druckfreundlichen Festwerten — ein PDF
|
||||
// hat kein Thema.
|
||||
return x >= schwelleRot() ? 'var(--red)' : x >= schwelleGelb() ? 'var(--amber)' : 'var(--green)';
|
||||
}
|
||||
// pdfLoadAmpelColor: Ampel-Farbe für den PDF-Export (js/pdf.js) mit gleichen
|
||||
// Schwellen wie loadAmpelColor, aber dunkleren, druckfreundlichen Farbtönen.
|
||||
function pdfLoadAmpelColor(x){
|
||||
return x >= LOAD_BAR_MAX * THRESHOLD_RATIO_CRITICAL ? '#D70015' : x >= LOAD_BAR_MAX * THRESHOLD_RATIO_ELEVATED ? '#B25000' : '#248A3D';
|
||||
return x >= schwelleRot() ? '#D70015' : x >= schwelleGelb() ? '#B25000' : '#248A3D';
|
||||
}
|
||||
|
||||
// ---------- LP-Aufwand Eingabemaske ----------
|
||||
@@ -121,11 +376,11 @@ function pdfLoadAmpelColor(x){
|
||||
let lpModalProjectId = null;
|
||||
|
||||
// lpRowsHtml: erzeugt die Aufwand-Maske als Matrix-Tabelle.
|
||||
// Zeilen = Leistungsphasen (LPH 1–8), Spalten = Rollen (Planung/Projektleitung/
|
||||
// Zeilen = Leistungsphasen (alle ohne "W+M"), Spalten = Rollen (Planung/Projektleitung/
|
||||
// Bauüberwachung). Die amtierende Leistungsphase wird pro Projekt im Tab
|
||||
// "Projekte" gewählt (renderProjects / editProjectActiveLP).
|
||||
// prefix: 'lp' = Bearbeiten-Modal, 'np' = Neues-Projekt-Modal (eindeutige IDs).
|
||||
// Leere Felder werden mit den Standardwerten aus LP_AUFWAND_VORLAGE vorbefüllt.
|
||||
// Leere Felder werden mit den Werten der firmenweiten Vorlage vorbefüllt.
|
||||
// Die Feld-IDs bleiben identisch, damit collectLpRows() unverändert funkioniert.
|
||||
function lpRowsHtml(p, prefix){
|
||||
const head = ROLES.map(r => `<th>${esc(r)}</th>`).join('');
|
||||
@@ -134,11 +389,14 @@ function lpRowsHtml(p, prefix){
|
||||
const row = (p.aufwandProLP && typeof p.aufwandProLP[l] === 'object') ? p.aufwandProLP[l] : {};
|
||||
const cells = ROLES.map((r, ri) => {
|
||||
const raw = row[r];
|
||||
const vorlage = LP_AUFWAND_VORLAGE && LP_AUFWAND_VORLAGE[l] ? LP_AUFWAND_VORLAGE[l][r] : undefined;
|
||||
const vorlage = aufwandVorlage()[l] ? aufwandVorlage()[l][r] : undefined;
|
||||
const base = (raw !== undefined && raw !== null && raw !== '') ? raw
|
||||
: (vorlage !== undefined && vorlage !== null && vorlage !== '') ? vorlage : '';
|
||||
const val = base !== '' ? String(parseFloat(base)).replace('.', ',') : '';
|
||||
return `<td><input type="text" inputmode="decimal" id="${prefix}Val${idx}R${ri}" value="${val}" placeholder="1,0"></td>`;
|
||||
// Platzhalter zeigt den Vorgabewert dieser Phase/Rolle, denn genau der
|
||||
// gilt, solange das Feld leer bleibt (siehe berechneAuslastung()).
|
||||
const ph = (vorlage !== undefined && vorlage !== null && vorlage !== '') ? String(parseFloat(vorlage)).replace('.', ',') : '1,0';
|
||||
return `<td><input type="text" inputmode="decimal" id="${prefix}Val${idx}R${ri}" value="${val}" placeholder="${ph}" title="Leer = Vorgabewert ${ph}"></td>`;
|
||||
}).join('');
|
||||
return `<tr><th class="lp-lph">${l}</th>${cells}</tr>`;
|
||||
}).join('');
|
||||
@@ -164,9 +422,13 @@ function collectLpRows(prefix){
|
||||
if(!input) continue;
|
||||
const raw = input.value.trim().replace(',', '.');
|
||||
if(raw === '') continue;
|
||||
const v = parseFloat(raw);
|
||||
// Strenge Prüfung statt parseFloat: Das nahm auch "0.5.5" (→ 0.5) oder
|
||||
// "1x" (→ 1) kommentarlos an.
|
||||
const v = /^\d+(\.\d+)?$/.test(raw) ? parseFloat(raw) : NaN;
|
||||
if(isNaN(v) || v < 0 || v > 1){
|
||||
alert('Bitte Werte zwischen 0 und 1 eintragen (z. B. 0,4) – oder das Feld leer lassen. (' + l + ', ' + r + ')');
|
||||
alert('Bitte Werte zwischen 0 und 1 eintragen (z. B. 0,4). (' + l + ', ' + r + ')\n\n'
|
||||
+ 'Ein leeres Feld bedeutet: es gilt der Vorgabewert dieser Leistungsphase. '
|
||||
+ 'Für "kein Aufwand" bitte ausdrücklich 0 eintragen.');
|
||||
invalid = true;
|
||||
break;
|
||||
}
|
||||
@@ -180,7 +442,7 @@ function collectLpRows(prefix){
|
||||
}
|
||||
|
||||
// applyLpRows: übernimmt das Ergebnis von collectLpRows auf ein Projekt.
|
||||
// Die Maske ist mit den Standardwerten (LP_AUFWAND_VORLAGE) vorbefüllt;
|
||||
// Die Maske ist mit den Werten der firmenweiten Vorlage vorbefüllt;
|
||||
// Felder ohne Vorbefüllung, die leer bleiben, zählen bei gesetzter aktiver LP
|
||||
// später als Standard-Faktor 1,0; ohne aktive LP zählt das Projekt 0.
|
||||
// Die amtierende Leistungsphase wird separat über den Tab "Projekte" gesetzt.
|
||||
@@ -225,66 +487,51 @@ function saveLpModal(){
|
||||
|
||||
// ---------- Neues Projekt (mit LP-Aufwand beim Anlegen) ----------
|
||||
|
||||
// openNewProjModal: öffnet das Modal "Neues Projekt" mit geleerten Eingaben,
|
||||
// der Auswahl der aktiven LP und einer leeren Aufwand-Maske (prefix 'np').
|
||||
function openNewProjModal(){
|
||||
if(guardWeekLocked()) return;
|
||||
document.getElementById('newProjNr').value = '';
|
||||
document.getElementById('newProjName').value = '';
|
||||
document.getElementById('newProjGroup').value = 'Aktive Projekte';
|
||||
const lpSel = document.getElementById('newProjActiveLP');
|
||||
if(lpSel){
|
||||
lpSel.innerHTML = '<option value="">Keine</option>' + AUFWAND_LPS.map(l => `<option value="${l}">${l}</option>`).join('');
|
||||
lpSel.value = '';
|
||||
}
|
||||
const rowsEl = document.getElementById('newProjLpRows');
|
||||
if(rowsEl) rowsEl.innerHTML = lpRowsHtml({ aufwandProLP: {}, aktuelleLP: [] }, 'np');
|
||||
document.getElementById('newProjModal').style.display = 'flex';
|
||||
}
|
||||
// Der Dialog "Neues Projekt" ist entfallen — openNewProjModal(),
|
||||
// closeNewProjModal(), newProjStatusGewechselt() und saveNewProj() hatten
|
||||
// danach keinen Aufrufer mehr. An seine Stelle tritt der Assistent in
|
||||
// js/projekt-wizard.js (openNewProjWizard()), der in sechs Schritten führt
|
||||
// und jeden davon erst nach vollständiger Eingabe freigibt. Der Grund für den
|
||||
// Wechsel: Honorar, Team und Schlagwörter mussten zuvor NACH dem Anlegen an
|
||||
// drei weiteren Stellen nachgetragen werden — und wurden es meist nicht.
|
||||
//
|
||||
// collectLpRows()/applyLpRows()/lpRowsHtml() bleiben: Sie tragen weiterhin
|
||||
// die Aufwandsmaske openLpModal(), die aus dem Projektbereich aufgerufen wird.
|
||||
|
||||
// closeNewProjModal: blendet das Neues-Projekt-Modal aus.
|
||||
function closeNewProjModal(){
|
||||
document.getElementById('newProjModal').style.display = 'none';
|
||||
}
|
||||
// PROJEKT_FARBEN: Palette der Lastband-Segmente. Bewusst gedämpfte Mitteltöne
|
||||
// und vom Ampelspektrum (grün/gelb/rot) getrennt — ein Segment sagt aus,
|
||||
// WELCHES Projekt, nicht ob es gut oder schlecht steht; kräftige Ampelfarben
|
||||
// im Band würden als zweite Wertung gelesen. Alle Töne tragen weiße Schrift
|
||||
// mit mindestens 4,5:1 und stehen auf hellem wie dunklem Grund.
|
||||
const PROJEKT_FARBEN = ['#4a6b8f','#6a5f96','#3f7a73','#8c6837','#77596a','#4c7550',
|
||||
'#69697e','#8a5533','#3f6885','#77663c','#5b5f91','#48755e','#85504f'];
|
||||
|
||||
// saveNewProj: legt ein neues Projekt an (Nr., Name, Gruppe, aktive LP,
|
||||
// optionaler LP-Aufwand), berechnet seine Reihenfolge in der Gruppe, speichert
|
||||
// und rendert Projekte und Board neu. Kein Rückgabewert.
|
||||
function saveNewProj(){
|
||||
if(guardWeekLocked()) return;
|
||||
const nr = document.getElementById('newProjNr').value.trim();
|
||||
const name = document.getElementById('newProjName').value.trim();
|
||||
if(!name){ alert("Bitte Projektbezeichnung eingeben."); return; }
|
||||
const group = document.getElementById('newProjGroup').value;
|
||||
const activeLP = document.getElementById('newProjActiveLP').value;
|
||||
const lp = collectLpRows('np');
|
||||
if(lp === null) return;
|
||||
const w = currentWeek();
|
||||
const nextOrder = w.projects
|
||||
.filter(p=>p.group===group)
|
||||
.reduce((max,p)=> Math.max(max, p.order!==undefined ? p.order : 0), -1) + 1;
|
||||
const pr = {id:uid(),nr,name,group,status:'',lph:{},assign:{}, statusState: undefined, order: nextOrder, plan: null};
|
||||
if(activeLP) pr.aktuelleLP = [activeLP];
|
||||
applyLpRows(pr, lp);
|
||||
w.projects.push(pr);
|
||||
save(); renderProjects(); renderBoard();
|
||||
closeNewProjModal();
|
||||
// projektFarbe: ordnet einem Projekt dauerhaft dieselbe Farbe zu. Über den
|
||||
// Namen gehasht statt über die Position in einer Liste, weil die Auswertung
|
||||
// über ALLE Sitzungen läuft und ein Projekt dort an wechselnden Stellen
|
||||
// steht — eine Farbe, die je nach Sitzung springt, wäre schlimmer als gar
|
||||
// keine. Zwei Projekte können sich eine Farbe teilen; sie stehen dann in
|
||||
// derselben Zeile nebeneinander getrennt und tragen ihre Nummer.
|
||||
// Parameter: name (Projektname). Rückgabe: Hex-Farbe.
|
||||
function projektFarbe(name){
|
||||
let h = 0;
|
||||
const t = String(name || '');
|
||||
for(let i = 0; i < t.length; i++) h = (h * 31 + t.charCodeAt(i)) >>> 0;
|
||||
return PROJEKT_FARBEN[h % PROJEKT_FARBEN.length];
|
||||
}
|
||||
|
||||
// LOAD_BAR_MAX: Maximalwert der Balken-Skala im Auslastungs-Tab (und im
|
||||
// PDF-Export). 1,0 = 100 % Kapazität; Werte > 1,0 = Überlastung (rot).
|
||||
const LOAD_BAR_MAX = 8;
|
||||
|
||||
// renderLoad: berechnet die Belastung der Personen auf Basis des
|
||||
// rollenspezifischen LP-Gewichtungsfaktors je amtierender Phase (0,0–1,0,
|
||||
// Standard 1,0). Leere Matrix-Werte zählen bei gesetzter aktiver LP als 1,0;
|
||||
// ohne gesetzte aktive LP zählt das Projekt 0 (nicht eingerechnet).
|
||||
// Ampel: Schwellen relativ zum Maximalwert LOAD_BAR_MAX
|
||||
// (gelb ab 50 %, rot ab 75 % – PLATZHALTER).
|
||||
// Balken: Skala auf das feste Maximum LOAD_BAR_MAX (Standard 8).
|
||||
// rollenspezifischen LP-Gewichtungsfaktors je amtierender Phase (0,0–1,0).
|
||||
// Fehlt am Projekt ein Wert, gilt die firmenweite Vorlage (aufwandVorlage())
|
||||
// für diese Phase/Rolle; ohne gesetzte aktive LP zählt das Projekt 0.
|
||||
// Der ausgewiesene Wert ist auf die KAPAZITÄT der Person bezogen: Wer eine
|
||||
// halbe Stelle hat, ist mit der halben Last schon voll ausgelastet
|
||||
// (kapazitaetsFaktor(), gespeist aus users.capacity_hours). Ampel-Schwellen
|
||||
// und Balkenskala sind firmenweit einstellbar (Bereich „Einstellungen").
|
||||
// Mittelwert: geteilt wird durch die Zahl der Sitzungen, in denen die Person
|
||||
// überhaupt im Teilnehmerkreis stand — nicht durch alle vorhandenen.
|
||||
function renderLoad(){
|
||||
const scope = document.getElementById('loadScope').value;
|
||||
const barMax = LOAD_BAR_MAX;
|
||||
const selectedWeek = currentWeek();
|
||||
const weeks = scope==='all' ? db.weeks : (selectedWeek ? [selectedWeek] : []);
|
||||
if(weeks.length === 0){
|
||||
@@ -293,60 +540,227 @@ function renderLoad(){
|
||||
}
|
||||
const grouped = scope==='all' ? peopleByRole(db.weeks) : peopleByRole(selectedWeek);
|
||||
const stats = {};
|
||||
const personen = {}; // fullName -> { person, stunden, rollen:Set }
|
||||
ROLES.forEach(r=>{
|
||||
grouped[r].forEach(entry=>{
|
||||
stats[assignKey(entry.person.id, r)] = {load:0,projects:{},anbahnung:0};
|
||||
stats[assignKey(entry.person.id, r)] = {stunden:0,projects:{},anbahnung:0,weeks:0};
|
||||
});
|
||||
});
|
||||
|
||||
// Je Person zählen, in wie vielen der ausgewerteten Sitzungen sie im
|
||||
// Teilnehmerkreis stand. Ohne das wurde durch ALLE Sitzungen geteilt: Ein
|
||||
// neuer Kollege, der seit zwei von zwanzig Sitzungen voll eingeplant ist,
|
||||
// erschien mit 0,30 statt 3,00 — also als praktisch unbeschäftigt.
|
||||
weeks.forEach(w => {
|
||||
(Array.isArray(w.people) ? w.people : []).forEach(person => {
|
||||
ROLES.forEach(r => {
|
||||
const key = assignKey(person.id, r);
|
||||
if(stats[key]) stats[key].weeks++;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Nicht bewertbare Projekte werden MITGEZÄHLT statt übergangen: Ein Projekt
|
||||
// ohne amtierende Phase, ohne Honorar und ohne Größe trug bisher still 0
|
||||
// bei und war von echter Unterbeschäftigung nicht zu unterscheiden. Die
|
||||
// Seite weist die Lücke jetzt oben aus.
|
||||
const luecken = {}; // Projektname -> Grund
|
||||
let bewertbareProjekte = 0, alleProjekte = 0;
|
||||
|
||||
weeks.forEach(w=>{
|
||||
w.projects.forEach(pr=>{
|
||||
if(pr.group === 'In Anbahnung') return;
|
||||
alleProjekte++;
|
||||
const aktivePhase = lpActiveList(pr)[0];
|
||||
const probe = berechneAuslastung(null, pr, aktivePhase, ROLES[0], 1, null);
|
||||
const projektName = pr.nr ? pr.nr + ' ' + pr.name : pr.name;
|
||||
if(probe.unbewertbar){ luecken[projektName] = probe.grund; }
|
||||
else bewertbareProjekte++;
|
||||
|
||||
// Vergebene Stunden je Projekt mitzählen: Ein Projekt kann bewertbar
|
||||
// sein und trotzdem bei niemandem ankommen — etwa wenn es in LPH 8
|
||||
// steht (die die Bauüberwachung trägt), aber nur ein Planer zugeordnet
|
||||
// ist. Die Arbeit fällt an, wird aber niemandem zugerechnet; das ist
|
||||
// eine Lücke und keine Null.
|
||||
let vergeben = 0;
|
||||
|
||||
Object.keys(pr.assign).forEach(aKey=>{
|
||||
const s = stats[aKey];
|
||||
if(!s) return;
|
||||
const st = pr.assign[aKey];
|
||||
const rolle = aKey.slice(aKey.indexOf('|') + 1);
|
||||
const al = berechneAuslastung(null, pr, aktivePhase, rolle, st);
|
||||
const al = berechneAuslastung(null, pr, aktivePhase, rolle, st, pr.assign);
|
||||
if(al.anbahnung){
|
||||
if(st===1 || st===2) s.anbahnung++;
|
||||
return;
|
||||
}
|
||||
if(al.load > 0){
|
||||
s.load += al.load;
|
||||
s.projects[pr.name] = (s.projects[pr.name]||0)+1;
|
||||
if(al.stunden > 0){
|
||||
vergeben += al.stunden;
|
||||
s.stunden += al.stunden;
|
||||
// Der Stundenbeitrag des einzelnen Projekts wird behalten, nicht
|
||||
// nur gezählt: Das Lastband zeigt die Summe aufgeteilt nach
|
||||
// Projekten. Gerechnet wird nichts Zusätzliches — derselbe Wert,
|
||||
// der ohnehin aufaddiert wird, wurde bisher nur verworfen.
|
||||
const eintrag = s.projects[pr.name] || (s.projects[pr.name] = { anzahl:0, stunden:0, nr: pr.nr, quelle: al.quelle });
|
||||
eintrag.anzahl++;
|
||||
eintrag.stunden += al.stunden;
|
||||
if(!eintrag.nr && pr.nr) eintrag.nr = pr.nr;
|
||||
}
|
||||
});
|
||||
|
||||
if(!probe.unbewertbar && !probe.fertig && vergeben === 0 && !luecken[projektName]){
|
||||
// Welche Rolle trüge die Arbeit? Die mit dem höchsten Gewicht in der
|
||||
// Phase, in der gerade gearbeitet wird.
|
||||
const phase = arbeitsPhase(pr, aktivePhase);
|
||||
const tragend = phase
|
||||
? ROLES.slice().sort((a,b)=> rollenGewicht(pr, phase, b) - rollenGewicht(pr, phase, a))[0]
|
||||
: null;
|
||||
luecken[projektName] = tragend
|
||||
? 'niemand für ' + tragend + ' zugeordnet (' + phase + ')'
|
||||
: 'niemand zugeordnet';
|
||||
bewertbareProjekte--;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Hochgerechnete Auslastung: durchschnittliche Wochenlast als Faktor
|
||||
// der Kapazität (1,0 = 100 %). Werte > 1,0 = Überlastung.
|
||||
const nWeeks = Math.max(1, weeks.length);
|
||||
const weekAvg = k => stats[k].load / nWeeks;
|
||||
let html="";
|
||||
// Wochenstunden: über die Sitzungen gemittelt, in denen die Person im
|
||||
// Teilnehmerkreis stand.
|
||||
const wochenStunden = k => stats[k].stunden / Math.max(1, stats[k].weeks || weeks.length);
|
||||
|
||||
// Kapazität einer Person in Stunden je Woche (aus der Benutzerverwaltung,
|
||||
// sonst die firmenweite Vollzeit).
|
||||
const vollzeit = Number(APP_SETTINGS.vollzeitStundenProWoche) || 40;
|
||||
const kapazitaetStunden = person => kapazitaetsFaktor(person) * vollzeit;
|
||||
|
||||
// Summe je MENSCH über alle Rollen. Bisher fehlte sie: Wer in drei Rollen
|
||||
// arbeitet, hat trotzdem nur eine Arbeitswoche — jede Rollenzeile blieb
|
||||
// unter der Schwelle, die Überlastung wurde nirgends rot. Ein Mensch mit
|
||||
// 0,80 Planung und 0,40 Projektleitung ist bei 1,20, und das gehört
|
||||
// sichtbar an den Anfang.
|
||||
const koepfe = {};
|
||||
ROLES.forEach(r=>{
|
||||
html += `<div class="load-row"><div class="load-row-title">${r}</div>`;
|
||||
if(grouped[r].length===0){
|
||||
html += `<div class="hint">Keine Personen.</div>`;
|
||||
}
|
||||
grouped[r].forEach(entry=>{
|
||||
const schluessel = entry.person.fullName || entry.person.name;
|
||||
const k = koepfe[schluessel] || (koepfe[schluessel] = { person: entry.person, stunden: 0, rollen: [] });
|
||||
const std = wochenStunden(assignKey(entry.person.id, r));
|
||||
k.stunden += std;
|
||||
if(std > 0) k.rollen.push(r);
|
||||
});
|
||||
});
|
||||
|
||||
let html = "";
|
||||
|
||||
// --- Kopf: was die Zahlen abdecken ---
|
||||
const luckenNamen = Object.keys(luecken);
|
||||
const ueberlastet = Object.values(koepfe).filter(k => k.stunden / kapazitaetStunden(k.person) >= schwelleRot());
|
||||
html += `<div class="load-kopf">
|
||||
<div class="load-kopf-zahl">
|
||||
<strong>${bewertbareProjekte}</strong> von ${alleProjekte} Projekten bewertet
|
||||
</div>
|
||||
${ueberlastet.length ? `<div class="load-kopf-warnung">${ueberlastet.length} ${ueberlastet.length === 1 ? 'Person ist' : 'Personen sind'} über der Kapazität</div>` : ''}
|
||||
${luckenNamen.length ? `<button type="button" class="secondary small" onclick="toggleLoadLuecken()">${luckenNamen.length} nicht bewertbar…</button>` : ''}
|
||||
</div>`;
|
||||
if(luckenNamen.length){
|
||||
html += `<div class="load-luecken" id="loadLuecken" style="display:none">
|
||||
<p class="hint" style="margin:0 0 8px">Diese Projekte fließen in keine Auslastung ein. Sie erscheinen nicht als Null, sondern hier — eine fehlende Angabe ist keine fehlende Arbeit.</p>
|
||||
${luckenNamen.sort().map(n => `<div class="load-luecke"><span>${esc(n)}</span><b>${esc(luecken[n])}</b></div>`).join('')}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// --- Summe je Mensch ---
|
||||
const kopfListe = Object.values(koepfe)
|
||||
.filter(k => k.stunden > 0)
|
||||
.sort((a,b)=> b.stunden / kapazitaetStunden(b.person) - a.stunden / kapazitaetStunden(a.person));
|
||||
if(kopfListe.length){
|
||||
html += `<div class="load-rolle"><div class="load-rolle-titel">Gesamt je Person</div>`;
|
||||
kopfListe.forEach(k=>{
|
||||
const kap = kapazitaetStunden(k.person);
|
||||
const quote = kap > 0 ? k.stunden / kap : 0;
|
||||
const ampel = loadAmpelColor(quote);
|
||||
const pct = Math.min(100, quote * 100);
|
||||
html += `<div class="load-zeile load-zeile-summe">
|
||||
<div class="load-wer">
|
||||
<div class="load-wer-name">${esc(k.person.fullName || k.person.name)}</div>
|
||||
<div class="load-wer-meta">${esc(k.rollen.join(' · ') || '—')}</div>
|
||||
</div>
|
||||
<div class="load-band" title="Kapazität: ${stundenFormat(kap)} h/Woche">
|
||||
<div class="load-seg load-seg-summe" style="width:${pct}%;background:${ampel}"></div>
|
||||
<div class="load-kapazitaet" title="Volle Kapazität"></div>
|
||||
</div>
|
||||
<div class="load-wert" style="color:${ampel}">${stundenFormat(k.stunden)}<span class="load-einheit">h</span></div>
|
||||
</div>`;
|
||||
});
|
||||
html += `</div>`;
|
||||
}
|
||||
|
||||
// --- Je Rolle ---
|
||||
const benutzteProjekte = {};
|
||||
ROLES.forEach(r=>{
|
||||
html += `<div class="load-rolle"><div class="load-rolle-titel">${r}</div>`;
|
||||
if(grouped[r].length===0){
|
||||
html += `<div class="hint">Keine Personen.</div></div>`;
|
||||
return;
|
||||
}
|
||||
const sortiert = grouped[r].slice().sort((a,b)=>
|
||||
wochenStunden(assignKey(b.person.id, r)) - wochenStunden(assignKey(a.person.id, r)));
|
||||
|
||||
sortiert.forEach(entry=>{
|
||||
const s = stats[assignKey(entry.person.id, r)];
|
||||
const avg = weekAvg(assignKey(entry.person.id, r));
|
||||
// Balken relativ zur einstellbaren Skala (Standard 8); Überlastung > 1,0
|
||||
// bleibt über die Ampel-Farbe sichtbar, der Zahlenwert bleibt ungedeckelt.
|
||||
const pct = Math.min(100, Math.round(avg / barMax * 100));
|
||||
const detail = Object.keys(s.projects).map(k=>`<div><span>${esc(k)}</span><b>${s.projects[k]}×</b></div>`).join('') || '<div class="hint">keine Zuordnung</div>';
|
||||
const anbahnungBadge = (s.anbahnung && s.anbahnung>0) ? `<span style="font-size:11px;color:#3182ce;font-weight:normal;margin-left:0px">(${s.anbahnung} in Anbahnung)</span>` : '';
|
||||
html += `<div class="load-card">
|
||||
<h3>${esc(entry.person.fullName)} <span style="font-size:11px;color:#888;font-weight:normal">(${r})</span></h3>
|
||||
<h3>${anbahnungBadge}</h3>
|
||||
<div class="load-num">${faktorFormat(avg)}</div>
|
||||
<div class="bar" style="height:10px;margin-top:6px" title="Skala: 0 – ${faktorFormat(barMax)}"><div style="width:${pct}%;background:${loadAmpelColor(avg)}"></div></div>
|
||||
<div class="load-detail">${detail}</div>
|
||||
const kap = kapazitaetStunden(entry.person);
|
||||
const nWeeks = Math.max(1, s.weeks || weeks.length);
|
||||
const std = wochenStunden(assignKey(entry.person.id, r));
|
||||
const quote = kap > 0 ? std / kap : 0;
|
||||
// Ohne jede Zuordnung KEINE Ampelfarbe: Grün hieße "gut ausgelastet",
|
||||
// die Null heißt aber "nichts zugeordnet".
|
||||
const ampel = std > 0 ? loadAmpelColor(quote) : 'var(--text-subtle)';
|
||||
|
||||
const stelleBadge = Math.abs(kapazitaetsFaktor(entry.person) - 1) < 0.005 ? ''
|
||||
: `<span class="load-capacity" title="Vertragliche Wochenstunden aus der Benutzerverwaltung">${stundenFormat(kap)} h/Woche</span>`;
|
||||
const anbahnungBadge = (s.anbahnung && s.anbahnung > 0)
|
||||
? `<span class="load-anbahnung">${s.anbahnung} in Anbahnung</span>` : '';
|
||||
|
||||
// Segmente: je Projekt eines, Breite als Anteil der KAPAZITÄT. Die
|
||||
// Skala ist damit nicht mehr willkürlich (früher fest 8, während die
|
||||
// Ampel bei 1,0 rot wurde — ein roter Balken war zu 12,5 % gefüllt),
|
||||
// sondern: volles Band = volle Woche.
|
||||
const namen = Object.keys(s.projects).sort((a,b)=> s.projects[b].stunden - s.projects[a].stunden);
|
||||
const segmente = namen.map(name=>{
|
||||
const eintrag = s.projects[name];
|
||||
const anteilStd = eintrag.stunden / nWeeks;
|
||||
const breite = kap > 0 ? Math.min(100, anteilStd / kap * 100) : 0;
|
||||
const farbe = projektFarbe(name);
|
||||
if(!benutzteProjekte[name]) benutzteProjekte[name] = { nr: eintrag.nr, farbe, quelle: eintrag.quelle };
|
||||
const titel = (eintrag.nr ? eintrag.nr + ' · ' : '') + name
|
||||
+ ' · ' + stundenFormat(anteilStd) + ' h/Woche'
|
||||
+ (eintrag.quelle === 'groesse' ? ' · aus der Projektgröße' : '')
|
||||
+ (eintrag.anzahl > 1 ? ' · in ' + eintrag.anzahl + ' Sitzungen' : '');
|
||||
const beschriftung = breite >= 7 ? `<span>${esc(eintrag.nr || name)}</span>` : '';
|
||||
return `<div class="load-seg" style="width:${breite}%;background:${farbe}" title="${esc(titel)}">${beschriftung}</div>`;
|
||||
}).join('');
|
||||
|
||||
html += `<div class="load-zeile">
|
||||
<div class="load-wer">
|
||||
<div class="load-wer-name">${esc(entry.person.fullName || entry.person.name)}</div>
|
||||
<div class="load-wer-meta">${esc(entry.person.name)}${stelleBadge}${anbahnungBadge}</div>
|
||||
</div>
|
||||
<div class="load-band" title="Volles Band = ${stundenFormat(kap)} h/Woche">
|
||||
${segmente || '<span class="load-band-leer">keine Zuordnung</span>'}
|
||||
</div>
|
||||
<div class="load-wert" style="color:${ampel}">${stundenFormat(std)}<span class="load-einheit">h</span></div>
|
||||
</div>`;
|
||||
});
|
||||
html += `</div>`;
|
||||
});
|
||||
|
||||
const legende = Object.keys(benutzteProjekte).sort().map(name=>{
|
||||
const e = benutzteProjekte[name];
|
||||
return `<span class="load-legende-item"><i style="background:${e.farbe}"></i>${esc(e.nr ? e.nr + ' ' + name : name)}${e.quelle === 'groesse' ? '<em>≈</em>' : ''}</span>`;
|
||||
}).join('');
|
||||
if(legende){
|
||||
const hatSchaetzung = Object.values(benutzteProjekte).some(e => e.quelle === 'groesse');
|
||||
html += `<div class="load-legende">${legende}</div>`;
|
||||
if(hatSchaetzung) html += `<p class="hint" style="margin:8px 0 0">≈ aus der Projektgröße gerechnet, nicht aus einem hinterlegten Honorar.</p>`;
|
||||
}
|
||||
|
||||
document.getElementById('loadGrid').innerHTML=html;
|
||||
}
|
||||
+773
-349
File diff suppressed because it is too large.
Load diff
@@ -9,12 +9,74 @@
|
||||
// (renderGanttDependencies), die Drag-/Resize- und Klick-Attribute der
|
||||
// Balken nutzen plan-drag.js und plan-modals.js.
|
||||
//
|
||||
// Zwei Zusatzanzeigen werden hier ebenfalls erzeugt:
|
||||
//
|
||||
// 1. GEISTER-BALKEN (Option ghostTodos/ghostMilestones): hinter jedem Balken
|
||||
// bzw. jeder Meilenstein-Raute der Termin aus der VORHERIGEN Sitzung als
|
||||
// gestrichelter Umriss, sobald er sich unterscheidet. Klassische
|
||||
// Projektwerkzeuge brauchen dafür eine eigens eingefrorene "Baseline" —
|
||||
// hier existiert sie kostenlos, weil jede Wochenkopie (cloneWeek() in
|
||||
// js/weeks.js) bereits ein vollständiger Schnappschuss des damaligen Plans
|
||||
// ist. Zusammengeführt wird über todo.id bzw. milestone.id: die bleiben
|
||||
// beim Klonen unverändert (cloneWeek() vergibt nur project.id neu), sind
|
||||
// also über Sitzungen hinweg stabil. Die Vorwochen-Daten liefert
|
||||
// renderPlanGantt() (plan-gantt.js) über app.previousProjectVersion()
|
||||
// aus plan-history.js.
|
||||
//
|
||||
// 2. URLAUBS-KOLLISION: überschneidet sich eine Aufgabe zeitlich mit einer
|
||||
// Abwesenheit einer ihr zugeordneten Person, wird der Balken markiert und
|
||||
// die Zeile bekommt ein Warnsymbol. Die Urlaubsbalken selbst zeichnet
|
||||
// gantt.js schon länger, aber rein dekorativ — die Kollision musste bisher
|
||||
// mit dem Auge gefunden werden.
|
||||
//
|
||||
// Läuft wie alle plan-Module in einem IIFE; Zustand: window.PlanApp
|
||||
// (plan-state.js).
|
||||
|
||||
(function(){
|
||||
const app = window.PlanApp;
|
||||
|
||||
// rangesOverlap: prüft, ob sich zwei Datumsbereiche (jeweils einschließlich
|
||||
// Start und Ende) überschneiden. Parameter: vier Date-Objekte.
|
||||
// Rückgabe: Boolean (false, sobald ein Datum fehlt).
|
||||
function rangesOverlap(aStart, aEnd, bStart, bEnd){
|
||||
if(!aStart || !aEnd || !bStart || !bEnd) return false;
|
||||
return aStart <= bEnd && bStart <= aEnd;
|
||||
}
|
||||
|
||||
// absenceConflictsForTodo: sucht alle Abwesenheiten der einer Aufgabe
|
||||
// zugeordneten Personen, die sich mit deren Zeitraum überschneiden. Die
|
||||
// Abwesenheiten kommen aus der globalen Personenliste db.globalPeople
|
||||
// (gefüllt aus users/user_absences, siehe loadGlobalPeople() in server.js) —
|
||||
// dieselbe Quelle, aus der auch die Urlaubszeilen im GANTT gezeichnet
|
||||
// werden. Parameter: todo (Aufgabenobjekt). Rückgabe: Array von
|
||||
// { name, type, start, end } (leer, wenn keine Kollision).
|
||||
function absenceConflictsForTodo(todo){
|
||||
const s = parseDateISO(todo.start), e = parseDateISO(todo.end);
|
||||
if(!s || !e) return [];
|
||||
const assignees = Array.isArray(todo.assignees) ? todo.assignees : [];
|
||||
if(assignees.length === 0) return [];
|
||||
const conflicts = [];
|
||||
assignees.forEach(personId => {
|
||||
const person = (db.globalPeople || []).find(p => p.id === personId);
|
||||
if(!person) return;
|
||||
(person.absences || []).forEach(a => {
|
||||
const as = parseDateISO(a.start), ae = parseDateISO(a.end);
|
||||
if(!rangesOverlap(s, e, as, ae)) return;
|
||||
conflicts.push({
|
||||
name: person.fullName || person.name,
|
||||
type: a.type || 'Abwesenheit',
|
||||
start: as,
|
||||
end: ae,
|
||||
});
|
||||
});
|
||||
});
|
||||
return conflicts;
|
||||
}
|
||||
|
||||
// Export, damit plan-gantt.js dieselbe Prüfung für die Warnliste über dem
|
||||
// Diagramm nutzen kann (eine Quelle für beide Anzeigen).
|
||||
app.absenceConflictsForTodo = absenceConflictsForTodo;
|
||||
|
||||
// Referenz auf die Original-Funktion aus gantt.js, die für das Basis-GANTT
|
||||
// weiterhin verwendet wird.
|
||||
const originalBuildGanttHtml = window.buildGanttHtml;
|
||||
@@ -36,6 +98,31 @@
|
||||
const totalDays = Math.max(1, daysBetween(start, end) + 1);
|
||||
const editable = options.editable;
|
||||
|
||||
// ghostBarHtml: zeichnet einen gestrichelten Umriss für den Termin aus der
|
||||
// Vorwoche. Liefert '' (nichts), wenn keine Vorwochen-Daten vorliegen, der
|
||||
// Termin unverändert ist oder er komplett außerhalb des sichtbaren
|
||||
// Zeitraums liegt. Parameter: prevStartIso/prevEndIso (ISO-Datum aus der
|
||||
// Vorwoche), curStartIso/curEndIso (aktuelle Termine), label (für den
|
||||
// Tooltip). Rückgabe: HTML-String.
|
||||
function ghostBarHtml(prevStartIso, prevEndIso, curStartIso, curEndIso, label){
|
||||
if(!prevStartIso || !prevEndIso) return '';
|
||||
if(prevStartIso === curStartIso && prevEndIso === curEndIso) return '';
|
||||
const gs = parseDateISO(prevStartIso), ge = parseDateISO(prevEndIso);
|
||||
if(!gs || !ge) return '';
|
||||
const barStart = gs < start ? start : gs;
|
||||
const barEnd = ge > end ? end : ge;
|
||||
if(barEnd < start || barStart > end) return '';
|
||||
const offset = daysBetween(start, barStart);
|
||||
const span = daysBetween(barStart, barEnd) + 1;
|
||||
// Verschiebung in Tagen, positiv = nach hinten gerutscht
|
||||
const shift = curStartIso ? daysBetween(gs, parseDateISO(curStartIso)) : 0;
|
||||
const shiftText = shift > 0 ? ` (${shift} Tag${shift === 1 ? '' : 'e'} später)`
|
||||
: shift < 0 ? ` (${-shift} Tag${-shift === 1 ? '' : 'e'} früher)` : '';
|
||||
const ref = options.ghostLabel ? esc(options.ghostLabel) : 'vorherige Sitzung';
|
||||
const title = `${ref}: ${esc(label)} ${formatDateDE(gs)}–${formatDateDE(ge)}${shiftText}`;
|
||||
return `<div class="gantt-bar ghost" style="left:${(offset/totalDays*100).toFixed(4)}%;width:${(span/totalDays*100).toFixed(4)}%" title="${title}"></div>`;
|
||||
}
|
||||
|
||||
projects.forEach(p => {
|
||||
const plan = p.plan || {};
|
||||
const todos = Array.isArray(plan.todos) ? plan.todos : [];
|
||||
@@ -61,6 +148,18 @@
|
||||
row.dataset.todoId = todo.id;
|
||||
row.dataset.todoIndex = tIdx;
|
||||
|
||||
// Urlaubs-Kollision: Aufgabe überschneidet sich mit einer Abwesenheit
|
||||
// einer zugeordneten Person (siehe absenceConflictsForTodo oben).
|
||||
const conflicts = absenceConflictsForTodo(todo);
|
||||
if(conflicts.length > 0) row.className += ' absence-conflict';
|
||||
|
||||
// Geister-Balken: Termin derselben Aufgabe (gleiche todo.id) aus der
|
||||
// vorherigen Sitzung, sofern er sich unterscheidet.
|
||||
const ghost = options.ghostTodos ? options.ghostTodos.get(todo.id) : null;
|
||||
const ghostHtml = ghost
|
||||
? ghostBarHtml(ghost.start, ghost.end, todo.start, todo.end, todo.title || 'TODO')
|
||||
: '';
|
||||
|
||||
let barHtml = '';
|
||||
if(s && e){
|
||||
const barStart = s < start ? start : s;
|
||||
@@ -72,9 +171,14 @@
|
||||
const handles = editable ? `<span class="gantt-resize gantt-resize-left" data-resize="left"></span><span class="gantt-resize gantt-resize-right" data-resize="right"></span>` : '';
|
||||
const isCritical = options.criticalPath && options.criticalPath.includes(todo.id);
|
||||
const progress = Math.max(0, Math.min(100, parseInt(todo.progress, 10) || 0));
|
||||
barHtml = `<div class="gantt-bar todo${todo.done ? ' done' : ''}${isCritical ? ' critical' : ''}"${dragAttrs} data-todo-id="${esc(todo.id)}" data-todo-depends="${esc((todo.dependsOn || []).join(','))}" style="left:${(offset/totalDays*100).toFixed(4)}%;width:${(span/totalDays*100).toFixed(4)}%" title="${esc(todo.title || 'TODO')}: ${formatDateDE(s)}–${formatDateDE(e)}">${handles}<div class="gantt-progress" style="width:${progress}%"></div></div>`;
|
||||
const conflictTitle = conflicts.length > 0
|
||||
? '\nAchtung: ' + conflicts.map(c => `${c.name} ${c.type.toLowerCase()} ${formatDateDE(c.start)}–${formatDateDE(c.end)}`).join('; ')
|
||||
: '';
|
||||
barHtml = `<div class="gantt-bar todo${todo.done ? ' done' : ''}${isCritical ? ' critical' : ''}${conflicts.length > 0 ? ' conflict-absence' : ''}"${dragAttrs} data-todo-id="${esc(todo.id)}" data-todo-depends="${esc((todo.dependsOn || []).join(','))}" style="left:${(offset/totalDays*100).toFixed(4)}%;width:${(span/totalDays*100).toFixed(4)}%" title="${esc(todo.title || 'TODO')}: ${formatDateDE(s)}–${formatDateDE(e)}${esc(conflictTitle)}">${handles}<div class="gantt-progress" style="width:${progress}%"></div></div>`;
|
||||
}
|
||||
}
|
||||
// Geister-Balken zuerst einfügen, damit der aktuelle Balken darüber liegt.
|
||||
barHtml = ghostHtml + barHtml;
|
||||
|
||||
let depBadge = '';
|
||||
if(Array.isArray(todo.dependsOn) && todo.dependsOn.length > 0){
|
||||
@@ -83,7 +187,9 @@
|
||||
const t = todos.find(x => x.id === id);
|
||||
if(t) return { title: t.title || 'TODO', end: t.end };
|
||||
const m = milestones.find(x => 'm_' + x.id === id);
|
||||
if(m) return { title: '🚩 ' + (m.name || 'Meilenstein'), end: m.date };
|
||||
// Hinweis: title landet weiter unten via esc() in reinem Textkontext
|
||||
// (kein HTML-Rendering) -> kein Icon-Präfix möglich, nur Text.
|
||||
if(m) return { title: (m.name || 'Meilenstein'), end: m.date };
|
||||
return null;
|
||||
}).filter(Boolean);
|
||||
if(preds.length > 0){
|
||||
@@ -93,9 +199,15 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Warnsymbol bei Urlaubs-Kollision, mit den betroffenen Personen im Tooltip
|
||||
const conflictBadge = conflicts.length > 0
|
||||
? `<span class="gantt-conflict-badge" title="${esc(conflicts.map(c => `${c.name}: ${c.type} ${formatDateDE(c.start)}–${formatDateDE(c.end)}`).join('\n'))}">${icon('warning')}</span>`
|
||||
: '';
|
||||
|
||||
row.innerHTML = `<div class="gantt-label" data-todo-id="${esc(todo.id)}" style="padding-left:24px;font-weight:400;color:var(--text-muted)" title="${esc(todo.title || 'TODO')}">
|
||||
${editable ? '<span class="gantt-todo-drag">⋮⋮</span>' : ''}
|
||||
${editable ? '<span class="gantt-todo-drag">' + icon('grip') + '</span>' : ''}
|
||||
<span class="gantt-todo-title"${editable ? ' onclick="openTodoModal(' + tIdx + ')" style="cursor:pointer"' : ''}>${esc(todo.title || 'TODO')}${depBadge}</span>
|
||||
${conflictBadge}
|
||||
</div>
|
||||
<div class="gantt-timeline">${barHtml}</div>`;
|
||||
return row;
|
||||
@@ -122,8 +234,25 @@
|
||||
labelStyle = 'left:' + left;
|
||||
}
|
||||
}
|
||||
|
||||
// Geister-Raute: Termin desselben Meilensteins (gleiche milestone.id)
|
||||
// aus der vorherigen Sitzung, sofern er sich verschoben hat. Gerade
|
||||
// bei Meilensteinen ist das die Information, die im Wochenmeeting auf
|
||||
// den Tisch gehört — sie sind die nach außen zugesagten Termine.
|
||||
const ghostDate = options.ghostMilestones ? options.ghostMilestones.get(m.id) : null;
|
||||
if(ghostDate && ghostDate !== m.date){
|
||||
const gd = parseDateISO(ghostDate);
|
||||
if(gd && gd >= start && gd <= end){
|
||||
const gLeft = (daysBetween(start, gd) / totalDays * 100).toFixed(4) + '%';
|
||||
const shift = d ? daysBetween(gd, d) : 0;
|
||||
const shiftText = shift > 0 ? ` (${shift} Tag${shift === 1 ? '' : 'e'} später)`
|
||||
: shift < 0 ? ` (${-shift} Tag${-shift === 1 ? '' : 'e'} früher)` : '';
|
||||
const ref = options.ghostLabel ? esc(options.ghostLabel) : 'vorherige Sitzung';
|
||||
diamond = `<div class="gantt-milestone ghost" style="left:${gLeft}" title="${ref}: ${esc(m.name || 'Meilenstein')} ${formatDateDE(gd)}${shiftText}"></div>` + diamond;
|
||||
}
|
||||
}
|
||||
row.innerHTML = `<div class="gantt-label" style="padding-left:24px;font-weight:400;color:var(--text-muted)">
|
||||
${editable ? '<span class="gantt-todo-drag">⋮⋮</span>' : ''}
|
||||
${editable ? '<span class="gantt-todo-drag">' + icon('grip') + '</span>' : ''}
|
||||
<span class="gantt-milestone-title"${editable ? ' onclick="openMilestoneModal(' + mIdx + ')" style="cursor:pointer"' : ''}>${esc(m.name || 'Meilenstein')}</span>
|
||||
</div>
|
||||
<div class="gantt-timeline">${diamond}<div class="gantt-milestone-label" style="${labelStyle}">${esc(m.name || '')}</div></div>`;
|
||||
@@ -144,10 +273,19 @@
|
||||
insertedMilestoneIds.add(m.id);
|
||||
});
|
||||
|
||||
// Aufgabenzeile
|
||||
const todoRow = buildTodoRow(todo, tIdx);
|
||||
insertAfter.parentNode.insertBefore(todoRow, insertAfter.nextSibling);
|
||||
insertAfter = todoRow;
|
||||
// Aufgabenzeile — nur wenn die Aufgabe terminiert ist (Start UND Ende
|
||||
// gesetzt). Eine Aufgabe ohne Termin bleibt ganz normal Teil von
|
||||
// plan.todos und erscheint im Bereich "Aufgaben" (renderPlanTasks(),
|
||||
// js/plan/plan-tasks.js) — hier im Terminplan aber bewusst nicht: sie
|
||||
// hat keinen Zeitraum, den ein Balken abbilden könnte, und soll den
|
||||
// Terminplan nicht mit leeren Zeilen ohne Balken zustellen. Nicht
|
||||
// jede Aufgabe aus "Aufgaben" landet also im GANTT, wohl aber jede
|
||||
// Aufgabe aus dem GANTT in "Aufgaben" (dieselbe Liste, siehe oben).
|
||||
if(todo.start && todo.end){
|
||||
const todoRow = buildTodoRow(todo, tIdx);
|
||||
insertAfter.parentNode.insertBefore(todoRow, insertAfter.nextSibling);
|
||||
insertAfter = todoRow;
|
||||
}
|
||||
|
||||
// Meilensteine, die NACH dieser Aufgabe positioniert sind
|
||||
milestones.filter(m => m.position === 'after:' + todo.id).forEach(m => {
|
||||
|
||||
@@ -0,0 +1,353 @@
|
||||
// -----------------------------
|
||||
// Projektplan — Dateien (Upload, Freigabe, Kommentare)
|
||||
// -----------------------------
|
||||
// Dieses Modul stellt den Bereich "Dateien" des Projektbereichs bereit
|
||||
// (Upload-Maske und Dateiliste, siehe js/plan/plan-sections.js; eine
|
||||
// Kompakt-Karte im Dashboard, #planFilesSummary, zeigt die zuletzt
|
||||
// hochgeladene Datei und ausstehende Prüfungen und springt per Klick über
|
||||
// openFilesPanel() in diesen Bereich): Upload (roher
|
||||
// Binär-Body wie js/import.js), Anzeige aller Dateien eines Projekts samt
|
||||
// Prüferstatus, Genehmigen-/Ablehnen-Aktionen für den eingeloggten Prüfer
|
||||
// sowie einen einfachen Kommentar-Thread je Datei. Dateien werden nur noch
|
||||
// über project_nr allein mit dem Projekt verknüpft (NICHT project_id — siehe
|
||||
// server.js-Abschnitt "Datei-Upload, Freigabe, Kommentare"), daher ist der
|
||||
// Upload-Bereich deaktiviert und die Liste zeigt nur einen Hinweis, solange
|
||||
// das aktuelle Projekt keine Projektnummer (app.project.nr) hat.
|
||||
//
|
||||
// Läuft wie alle plan-Module in einem IIFE; Zustand: window.PlanApp
|
||||
// (plan-state.js) für app.project, currentUser (js/auth.js) für die
|
||||
// Sichtbarkeit der Genehmigen-/Ablehnen-Buttons.
|
||||
|
||||
(function(){
|
||||
const app = window.PlanApp;
|
||||
|
||||
// filesCache: zuletzt geladene Dateiliste (für Re-Renders nach Aktionen,
|
||||
// ohne jedes Mal neu zu fetchen); openComments: Set der Datei-IDs, deren
|
||||
// Kommentar-Thread gerade aufgeklappt ist.
|
||||
let filesCache = [];
|
||||
const openComments = new Set();
|
||||
|
||||
// statusLabel/statusClass: deutsche Anzeige- bzw. CSS-Klasse für den
|
||||
// Datei-/Prüfstatus.
|
||||
function statusLabel(status){
|
||||
if(status === 'approved') return 'Freigegeben';
|
||||
if(status === 'rejected') return 'Abgelehnt';
|
||||
return 'Ausstehend';
|
||||
}
|
||||
function statusClass(status){
|
||||
if(status === 'approved') return 'approved';
|
||||
if(status === 'rejected') return 'rejected';
|
||||
return 'pending';
|
||||
}
|
||||
|
||||
// formatDateTimeDE: formatiert einen ISO-Zeitstempel als deutsches
|
||||
// Datum+Uhrzeit (Fallback: leerer String bei ungültigem Wert).
|
||||
function formatDateTimeDE(iso){
|
||||
if(!iso) return '';
|
||||
const d = new Date(iso);
|
||||
if(isNaN(d.getTime())) return '';
|
||||
return formatDateDE(d) + ' ' + String(d.getHours()).padStart(2,'0') + ':' + String(d.getMinutes()).padStart(2,'0');
|
||||
}
|
||||
|
||||
// initPlanFiles: initialisiert den Dateien-Bereich beim Laden der Seite
|
||||
// (Prüfer-Checkboxen befüllen, Dateiliste laden) — Nachfolger des früheren
|
||||
// openFilesModal(), seit der Bereich fest auf der Seite sichtbar ist statt
|
||||
// in einem Modal. Wird von renderPlanPage() (plan-state.js) aufgerufen.
|
||||
window.initPlanFiles = function(){
|
||||
const hasNr = !!(app.project && app.project.nr);
|
||||
const hint = document.getElementById('filesNoProjectHint');
|
||||
const uploadSection = document.getElementById('filesUploadSection');
|
||||
if(hint) hint.style.display = hasNr ? 'none' : '';
|
||||
if(uploadSection) uploadSection.style.display = hasNr ? '' : 'none';
|
||||
const fileInput = document.getElementById('fileUploadInput');
|
||||
if(fileInput) fileInput.value = '';
|
||||
if(hasNr) loadReviewerCheckboxes();
|
||||
renderProjectFiles();
|
||||
};
|
||||
|
||||
// openFilesPanel: springt in den Bereich "Dateien" des Projektbereichs
|
||||
// (js/plan/plan-sections.js), in dem Upload-Maske und Dateiliste stehen.
|
||||
// Früher öffnete die Funktion ein eigenes Modal; der Name bleibt, weil die
|
||||
// Kompakt-Karte "Dateien & Freigaben" im Dashboard darauf verweist.
|
||||
window.openFilesPanel = function(){
|
||||
if(typeof showPlanSection === 'function') showPlanSection('dateien');
|
||||
};
|
||||
|
||||
// loadReviewerCheckboxes: lädt alle aktiven Benutzerkonten (GET /users,
|
||||
// nicht die Roster-Personen des Projekts — Prüfer müssen echte Logins
|
||||
// sein) und baut die Mehrfachauswahl-Checkbox-Liste (gleiches Muster wie
|
||||
// todoEditAssignees in plan-modals.js).
|
||||
function loadReviewerCheckboxes(){
|
||||
const container = document.getElementById('fileUploadReviewers');
|
||||
if(!container) return;
|
||||
container.innerHTML = '<span class="hint">Lade Benutzer…</span>';
|
||||
fetch(baseUrl() + '/users')
|
||||
.then(r => { if(!r.ok) throw new Error('Server HTTP ' + r.status); return r.json(); })
|
||||
.then(data => {
|
||||
const users = Array.isArray(data.users) ? data.users : [];
|
||||
if(users.length === 0){
|
||||
container.innerHTML = '<span class="hint">Keine Benutzer vorhanden.</span>';
|
||||
return;
|
||||
}
|
||||
// Das eigene Konto herausfiltern: Der Server entfernt den Uploader
|
||||
// still aus der Prüferliste — wer nur sich selbst ankreuzte, bekam
|
||||
// deshalb die verwirrende Meldung "Mindestens ein Prüfer erforderlich".
|
||||
const selectable = users.filter(u => !(typeof currentUser !== 'undefined' && currentUser && u.id === currentUser.id));
|
||||
if(selectable.length === 0){
|
||||
container.innerHTML = '<span class="hint">Keine weiteren Benutzer vorhanden, die prüfen könnten.</span>';
|
||||
return;
|
||||
}
|
||||
container.innerHTML = selectable.map(u =>
|
||||
`<label class="todo-depends-item"><input type="checkbox" value="${esc(String(u.id))}"> ${esc(u.displayName || u.username)}</label>`
|
||||
).join('');
|
||||
})
|
||||
.catch(err => {
|
||||
container.innerHTML = '<span class="hint">Benutzer konnten nicht geladen werden.</span>';
|
||||
console.error('Benutzerliste laden fehlgeschlagen:', err);
|
||||
});
|
||||
}
|
||||
|
||||
// submitFileUpload: liest die gewählte Datei und die angehakten Prüfer,
|
||||
// schickt die Datei als rohen Binär-Body (wie js/import.js) an
|
||||
// POST /project-files und lädt danach die Liste neu.
|
||||
window.submitFileUpload = function(){
|
||||
if(!app.project || !app.project.nr){ alert('Dieses Projekt hat keine Projektnummer.'); return; }
|
||||
const fileInput = document.getElementById('fileUploadInput');
|
||||
const file = fileInput && fileInput.files && fileInput.files[0];
|
||||
if(!file){ alert('Bitte eine Datei auswählen.'); return; }
|
||||
const reviewersContainer = document.getElementById('fileUploadReviewers');
|
||||
const reviewerIds = reviewersContainer
|
||||
? Array.from(reviewersContainer.querySelectorAll('input:checked')).map(cb => cb.value)
|
||||
: [];
|
||||
if(reviewerIds.length === 0){ alert('Bitte mindestens einen Prüfer auswählen.'); return; }
|
||||
|
||||
const url = baseUrl() + '/project-files'
|
||||
+ '?projectNr=' + encodeURIComponent(app.project.nr)
|
||||
+ '&projectName=' + encodeURIComponent(app.project.name || '')
|
||||
+ '&filename=' + encodeURIComponent(file.name)
|
||||
+ '&reviewers=' + reviewerIds.join(',');
|
||||
|
||||
fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': file.type || 'application/octet-stream' },
|
||||
body: file
|
||||
})
|
||||
.then(r => r.json().then(json => ({ ok: r.ok, json })))
|
||||
.then(({ ok, json }) => {
|
||||
if(!ok || !json.ok) throw new Error((json && json.error) || 'Upload fehlgeschlagen.');
|
||||
if(fileInput) fileInput.value = '';
|
||||
if(reviewersContainer) reviewersContainer.querySelectorAll('input:checked').forEach(cb => cb.checked = false);
|
||||
app.showToast('Datei hochgeladen.');
|
||||
renderProjectFiles();
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('Datei-Upload fehlgeschlagen:', err);
|
||||
alert('Datei-Upload fehlgeschlagen: ' + (err.message || err));
|
||||
});
|
||||
};
|
||||
|
||||
// renderProjectFiles: lädt die Dateiliste des aktuellen Projekts
|
||||
// (GET /project-files?projectNr=) und rendert sie in #filesListContainer.
|
||||
// Ohne Projektnummer wird nur ein Hinweistext gezeigt.
|
||||
window.renderProjectFiles = function(){
|
||||
const container = document.getElementById('filesListContainer');
|
||||
if(!container) return;
|
||||
if(!app.project || !app.project.nr){
|
||||
container.innerHTML = '<span class="hint">Dieses Projekt hat keine Projektnummer und kann daher keine Dateien erhalten.</span>';
|
||||
const summaryEl = document.getElementById('planFilesSummary');
|
||||
if(summaryEl) summaryEl.innerHTML = '<span class="hint">Keine Projektnummer.</span>';
|
||||
return;
|
||||
}
|
||||
container.innerHTML = '<span class="hint">Lade Dateien…</span>';
|
||||
const url = baseUrl() + '/project-files'
|
||||
+ '?projectNr=' + encodeURIComponent(app.project.nr);
|
||||
fetch(url)
|
||||
.then(r => { if(!r.ok) throw new Error('Server HTTP ' + r.status); return r.json(); })
|
||||
.then(data => {
|
||||
filesCache = Array.isArray(data.files) ? data.files : [];
|
||||
renderFilesList();
|
||||
})
|
||||
.catch(err => {
|
||||
container.innerHTML = '<span class="hint">Dateien konnten nicht geladen werden.</span>';
|
||||
console.error('Dateiliste laden fehlgeschlagen:', err);
|
||||
});
|
||||
};
|
||||
|
||||
// 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
|
||||
// (Server liefert /project-files bereits nach created_at DESC sortiert,
|
||||
// filesCache[0] ist also die neueste) sowie einen Hinweis auf noch
|
||||
// ausstehende Prüfungen.
|
||||
function renderFilesSummary(){
|
||||
const summaryEl = document.getElementById('planFilesSummary');
|
||||
if(!summaryEl) return;
|
||||
if(filesCache.length === 0){
|
||||
summaryEl.innerHTML = '<span class="hint">Noch keine Dateien.</span>';
|
||||
return;
|
||||
}
|
||||
const pendingCount = filesCache.filter(f => f.status === 'pending').length;
|
||||
const chip = pendingCount > 0
|
||||
? `<span class="plan-mini-chip warn">${pendingCount} ausstehend</span>`
|
||||
: '<span class="plan-mini-chip">Alle geprüft</span>';
|
||||
summaryEl.innerHTML = `<div class="plan-mini-card-preview">${esc(filesCache[0].originalFilename)}</div>
|
||||
<div class="plan-mini-chip-row">${chip}</div>`;
|
||||
}
|
||||
|
||||
// renderFilesList: rendert filesCache in #filesListContainer (Name,
|
||||
// Uploader, Datum, Status-Badge, Prüferstatus je Prüfer, Kommentaranzahl,
|
||||
// Download-Link, Genehmigen-/Ablehnen-Buttons für den aktuellen Prüfer,
|
||||
// aufklappbarer Kommentar-Thread).
|
||||
function renderFilesList(){
|
||||
const container = document.getElementById('filesListContainer');
|
||||
if(!container) return;
|
||||
renderFilesSummary();
|
||||
if(filesCache.length === 0){
|
||||
container.innerHTML = '<span class="hint">Noch keine Dateien für dieses Projekt hochgeladen.</span>';
|
||||
return;
|
||||
}
|
||||
container.innerHTML = filesCache.map(f => {
|
||||
const myReview = currentUser ? (f.reviewers || []).find(r => r.userId === currentUser.id) : null;
|
||||
const canReview = myReview && myReview.decision === 'pending' && f.status === 'pending';
|
||||
// Dieselbe Regel wie im Server (DELETE /project-files/:id): der Uploader,
|
||||
// solange die Datei noch offen ist — oder ein Administrator. Vorher stand
|
||||
// der Knopf bei jeder Datei und endete für andere in einer Fehlermeldung.
|
||||
const isAdmin = !!(typeof currentUser !== 'undefined' && currentUser && currentUser.role === 'admin');
|
||||
const isUploader = !!(typeof currentUser !== 'undefined' && currentUser && f.uploadedByUserId === currentUser.id);
|
||||
const canDelete = isAdmin || (isUploader && f.status === 'pending');
|
||||
const reviewerChips = (f.reviewers || []).map(r =>
|
||||
`<span class="file-reviewer-chip ${esc(statusClass(r.decision))}">${esc(r.displayName)}: ${esc(statusLabel(r.decision))}</span>`
|
||||
).join('');
|
||||
const downloadUrl = baseUrl() + '/project-files/' + encodeURIComponent(f.id) + '/download';
|
||||
return `
|
||||
<div class="file-item" data-file-id="${esc(f.id)}">
|
||||
<div class="file-item-header">
|
||||
<a href="${esc(downloadUrl)}" class="file-item-name">${esc(f.originalFilename)}</a>
|
||||
<span class="file-status-badge ${esc(statusClass(f.status))}">${esc(statusLabel(f.status))}</span>
|
||||
</div>
|
||||
<div class="file-item-meta">Hochgeladen von ${esc(f.uploadedBy || '–')} am ${esc(formatDateTimeDE(f.createdAt))}</div>
|
||||
<div class="file-reviewer-list">${reviewerChips}</div>
|
||||
<div class="file-item-actions">
|
||||
${canReview ? `<button class="small" onclick="reviewProjectFile('${esc(f.id)}','approved')">Genehmigen</button>
|
||||
<button class="small danger" onclick="reviewProjectFile('${esc(f.id)}','rejected')">Ablehnen</button>` : ''}
|
||||
<button class="small secondary" onclick="toggleFileComments('${esc(f.id)}')">Kommentare (${f.commentCount || 0})</button>
|
||||
${canDelete ? `<button class="small danger" onclick="deleteProjectFile('${esc(f.id)}')">Löschen</button>` : ''}
|
||||
</div>
|
||||
<div class="file-comments" id="fileComments_${esc(f.id)}" style="display:${openComments.has(f.id) ? '' : 'none'}"></div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
// Kommentar-Threads, die vorher aufgeklappt waren, direkt neu laden.
|
||||
openComments.forEach(fileId => {
|
||||
if(filesCache.some(f => f.id === fileId)) loadFileComments(fileId);
|
||||
});
|
||||
}
|
||||
|
||||
// reviewProjectFile: sendet die Freigabe-Entscheidung des aktuellen
|
||||
// Benutzers für eine Datei und lädt die Liste danach neu.
|
||||
window.reviewProjectFile = function(fileId, decision){
|
||||
fetch(baseUrl() + '/project-files/' + encodeURIComponent(fileId) + '/review', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json; charset=utf-8' },
|
||||
body: JSON.stringify({ decision })
|
||||
})
|
||||
.then(r => r.json().then(json => ({ ok: r.ok, json })))
|
||||
.then(({ ok, json }) => {
|
||||
if(!ok || !json.ok) throw new Error((json && json.error) || 'Entscheidung fehlgeschlagen.');
|
||||
app.showToast('Entscheidung gespeichert.');
|
||||
renderProjectFiles();
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('Freigabe-Entscheidung fehlgeschlagen:', err);
|
||||
alert('Entscheidung fehlgeschlagen: ' + (err.message || err));
|
||||
});
|
||||
};
|
||||
|
||||
// deleteProjectFile: löscht (nach Bestätigung) eine Datei; der Server
|
||||
// erlaubt das nur dem Uploader (solange 'pending') oder einem Admin.
|
||||
window.deleteProjectFile = function(fileId){
|
||||
if(!confirm('Datei wirklich löschen?')) return;
|
||||
fetch(baseUrl() + '/project-files/' + encodeURIComponent(fileId), { method: 'DELETE' })
|
||||
.then(r => r.json().then(json => ({ ok: r.ok, json })))
|
||||
.then(({ ok, json }) => {
|
||||
if(!ok || !json.ok) throw new Error((json && json.error) || 'Löschen fehlgeschlagen.');
|
||||
openComments.delete(fileId);
|
||||
app.showToast('Datei gelöscht.');
|
||||
renderProjectFiles();
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('Datei löschen fehlgeschlagen:', err);
|
||||
alert('Löschen fehlgeschlagen: ' + (err.message || err));
|
||||
});
|
||||
};
|
||||
|
||||
// toggleFileComments: klappt den Kommentar-Thread einer Datei auf/zu und
|
||||
// lädt ihn bei Bedarf.
|
||||
window.toggleFileComments = function(fileId){
|
||||
const el = document.getElementById('fileComments_' + fileId);
|
||||
if(!el) return;
|
||||
if(openComments.has(fileId)){
|
||||
openComments.delete(fileId);
|
||||
el.style.display = 'none';
|
||||
} else {
|
||||
openComments.add(fileId);
|
||||
el.style.display = '';
|
||||
loadFileComments(fileId);
|
||||
}
|
||||
};
|
||||
|
||||
// loadFileComments: lädt die Kommentare einer Datei (GET) und rendert
|
||||
// Liste + Eingabefeld in den zugehörigen Thread-Container.
|
||||
function loadFileComments(fileId){
|
||||
const el = document.getElementById('fileComments_' + fileId);
|
||||
if(!el) return;
|
||||
el.innerHTML = '<span class="hint">Lade Kommentare…</span>';
|
||||
fetch(baseUrl() + '/project-files/' + encodeURIComponent(fileId) + '/comments')
|
||||
.then(r => { if(!r.ok) throw new Error('Server HTTP ' + r.status); return r.json(); })
|
||||
.then(data => {
|
||||
const comments = Array.isArray(data.comments) ? data.comments : [];
|
||||
const list = comments.length === 0
|
||||
? '<span class="hint">Noch keine Kommentare.</span>'
|
||||
: comments.map(c => `
|
||||
<div class="file-comment">
|
||||
<b>${esc(c.displayName || '–')}</b>
|
||||
<span class="file-comment-date">${esc(formatDateTimeDE(c.createdAt))}</span>
|
||||
<div>${esc(c.body)}</div>
|
||||
</div>`).join('');
|
||||
el.innerHTML = `
|
||||
<div class="file-comment-list">${list}</div>
|
||||
<div class="file-comment-form">
|
||||
<input type="text" id="fileCommentInput_${esc(fileId)}" placeholder="Kommentar schreiben…">
|
||||
<button class="small" onclick="submitFileComment('${esc(fileId)}')">Senden</button>
|
||||
</div>`;
|
||||
})
|
||||
.catch(err => {
|
||||
el.innerHTML = '<span class="hint">Kommentare konnten nicht geladen werden.</span>';
|
||||
console.error('Kommentare laden fehlgeschlagen:', err);
|
||||
});
|
||||
}
|
||||
|
||||
// submitFileComment: schickt den Text des Eingabefelds einer Datei als
|
||||
// neuen Kommentar (jeder eingeloggte Benutzer darf kommentieren) und lädt
|
||||
// den Thread danach neu.
|
||||
window.submitFileComment = function(fileId){
|
||||
const input = document.getElementById('fileCommentInput_' + fileId);
|
||||
const body = input ? input.value.trim() : '';
|
||||
if(!body) return;
|
||||
fetch(baseUrl() + '/project-files/' + encodeURIComponent(fileId) + '/comments', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json; charset=utf-8' },
|
||||
body: JSON.stringify({ body })
|
||||
})
|
||||
.then(r => r.json().then(json => ({ ok: r.ok, json })))
|
||||
.then(({ ok, json }) => {
|
||||
if(!ok || !json.ok) throw new Error((json && json.error) || 'Kommentar fehlgeschlagen.');
|
||||
// renderProjectFiles() baut die Liste neu auf (aktualisiert auch
|
||||
// commentCount) und lädt aufgeklappte Threads (openComments) neu.
|
||||
renderProjectFiles();
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('Kommentar senden fehlgeschlagen:', err);
|
||||
alert('Kommentar fehlgeschlagen: ' + (err.message || err));
|
||||
});
|
||||
};
|
||||
})();
|
||||
+251
-21
@@ -5,10 +5,15 @@
|
||||
// der TODO- und Meilensteinzeilen, die buildGanttHtml aus plan-build-gantt.js
|
||||
// liefert, und zeichnet darauf die Abhängigkeitslinien zwischen den Aufgaben.
|
||||
// Es verwaltet außerdem Zoom (pxPerDay in PlanApp), das Anpassen des
|
||||
// Datumsbereichs (fitPlanGanttRange) sowie die Anzeige der Ressourcen-
|
||||
// Auslastung. renderPlanGantt ist die zentrale Neu-Render-Funktion und wird
|
||||
// nach Änderungen von plan-modals.js, plan-drag.js und plan-schedule.js
|
||||
// erneut aufgerufen.
|
||||
// Datumsbereichs (fitPlanGanttRange) sowie die Ressourcen-Auslastung
|
||||
// (computeResourceLoads/renderResourceLoad/openResourceModal, Cockpit-Layout:
|
||||
// Detailkarten im Auslastungs-Modal, Vorschau in der Kompakt-Karte darunter)
|
||||
// und die KPI-Kachelzeile über dem GANTT (renderPlanKpis — Gesamtfortschritt,
|
||||
// offene Aufgaben, nächster Meilenstein, Team-Auslastung, alle aus denselben
|
||||
// Plan-Daten abgeleitet). renderPlanGantt ist die zentrale Neu-Render-
|
||||
// Funktion (rendert am Ende auch Auslastung und KPIs neu) und wird nach
|
||||
// Änderungen von plan-modals.js, plan-drag.js und plan-schedule.js erneut
|
||||
// aufgerufen.
|
||||
//
|
||||
// Läuft wie alle plan-Module in einem IIFE; Zustand: window.PlanApp
|
||||
// (plan-state.js).
|
||||
@@ -28,7 +33,7 @@
|
||||
let start = parseDateISO(document.getElementById('planGanttStart').value);
|
||||
let end = parseDateISO(document.getElementById('planGanttEnd').value);
|
||||
if(!start || !end){
|
||||
const range = ganttRangeFromProjects([app.project], 7);
|
||||
const range = ganttRangeFromProjects([app.project], 7, projectAssignees(app.project, app.week).map(a => a.person.id));
|
||||
start = range.start; end = range.end;
|
||||
document.getElementById('planGanttStart').value = formatDateISO(start);
|
||||
document.getElementById('planGanttEnd').value = formatDateISO(end);
|
||||
@@ -36,10 +41,148 @@
|
||||
const showVacation = document.getElementById('planShowVacation').checked;
|
||||
const vacationPeople = projectAssignees(app.project, app.week).map(a => a.person.id);
|
||||
const criticalPath = app.criticalPathActive ? computeCriticalPath(app.project) : [];
|
||||
container.innerHTML = buildGanttHtml([app.project], start, end, { showVacation, editable: !app.isLocked(), highlightProjectId: app.project.id, showTodos: true, vacationPeople, pxPerDay: app.pxPerDay, criticalPath });
|
||||
const ghost = collectGhostDates();
|
||||
container.innerHTML = buildGanttHtml([app.project], start, end, {
|
||||
showVacation, editable: !app.isLocked(), highlightProjectId: app.project.id,
|
||||
showTodos: true, vacationPeople, pxPerDay: app.pxPerDay, criticalPath,
|
||||
ghostTodos: ghost.todos, ghostMilestones: ghost.milestones, ghostLabel: ghost.label,
|
||||
});
|
||||
container.scrollLeft = prevScroll;
|
||||
requestAnimationFrame(() => renderGanttDependencies(container));
|
||||
renderPlanWarnings();
|
||||
renderResourceLoad();
|
||||
renderPlanKpis();
|
||||
// Aufgabenliste und Dashboard zeigen dieselben Aufgaben-/Meilensteindaten
|
||||
// in anderer Form (js/plan/plan-tasks.js, js/plan/plan-sections.js). Sie
|
||||
// hängen sich hier mit ein, weil renderPlanGantt() die zentrale
|
||||
// Neu-Render-Funktion nach jeder Änderung ist — so bleiben alle drei
|
||||
// Bereiche ohne zusätzliche Aufrufe in den ändernden Modulen gleich.
|
||||
if(typeof renderPlanTasks === 'function') renderPlanTasks();
|
||||
if(typeof renderPlanDashboard === 'function') renderPlanDashboard();
|
||||
};
|
||||
|
||||
// collectGhostDates: liest die Termine derselben Aufgaben/Meilensteine aus
|
||||
// der letzten früheren Sitzung MIT Planinhalt (app.previousPlannedProjectVersion()
|
||||
// aus plan-history.js) und gibt sie als Maps id -> Termin zurück, die
|
||||
// buildGanttHtml als ghostTodos/ghostMilestones für die gestrichelten
|
||||
// Vergleichs-Umrisse nutzt. Das Label der gefundenen Sitzung wird
|
||||
// mitgeliefert, damit die Tooltips sie namentlich nennen können statt
|
||||
// pauschal "Vorwoche" zu behaupten.
|
||||
// Leere Maps, wenn der Schalter aus ist, es keine frühere Fassung mit
|
||||
// Terminplan gibt oder dem Projekt die projectUid fehlt (Altdaten).
|
||||
// Rückgabe: { todos: Map, milestones: Map, label: String }.
|
||||
function collectGhostDates(){
|
||||
const empty = { todos: new Map(), milestones: new Map(), label: '' };
|
||||
const toggle = document.getElementById('planShowGhost');
|
||||
if(toggle && !toggle.checked) return empty;
|
||||
if(typeof app.previousPlannedProjectVersion !== 'function') return empty;
|
||||
const prev = app.previousPlannedProjectVersion();
|
||||
if(!prev || !prev.project || !prev.project.plan) return empty;
|
||||
|
||||
const todos = new Map();
|
||||
(prev.project.plan.todos || []).forEach(t => {
|
||||
if(t.id && t.start && t.end) todos.set(t.id, { start: t.start, end: t.end });
|
||||
});
|
||||
const milestones = new Map();
|
||||
(prev.project.plan.milestones || []).forEach(m => {
|
||||
if(m.id && m.date) milestones.set(m.id, m.date);
|
||||
});
|
||||
return { todos, milestones, label: prev.week.label };
|
||||
}
|
||||
|
||||
// renderPlanWarnings: füllt das Hinweisband über dem GANTT
|
||||
// (#planWarnings in Test.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
|
||||
// garantiert dasselbe melden. Keine Parameter, keine Rückgabe.
|
||||
window.renderPlanWarnings = function(){
|
||||
const el = document.getElementById('planWarnings');
|
||||
if(!el || !app.project) return;
|
||||
if(typeof app.absenceConflictsForTodo !== 'function'){ el.innerHTML = ''; return; }
|
||||
|
||||
const plan = app.ensurePlanLocal();
|
||||
const rows = [];
|
||||
(plan.todos || []).forEach(t => {
|
||||
if(t.done) return; // erledigte Aufgaben sind kein Planungsproblem mehr
|
||||
const conflicts = app.absenceConflictsForTodo(t);
|
||||
conflicts.forEach(c => {
|
||||
rows.push(`<li><b>${esc(t.title || 'TODO')}</b> — ${esc(c.name)} ist ${esc(c.type.toLowerCase())} (${formatDateDE(c.start)}–${formatDateDE(c.end)})</li>`);
|
||||
});
|
||||
});
|
||||
|
||||
if(rows.length === 0){ el.innerHTML = ''; return; }
|
||||
el.innerHTML = `<div class="plan-warning-band">
|
||||
<div class="plan-warning-head">${icon('warning')} ${rows.length} Terminkonflikt${rows.length === 1 ? '' : 'e'} mit Abwesenheiten</div>
|
||||
<ul class="plan-warning-list">${rows.join('')}</ul>
|
||||
</div>`;
|
||||
};
|
||||
|
||||
// kpiTile: baut das HTML einer einzelnen Kachel für renderPlanKpis().
|
||||
// Parameter: label (Kachel-Titel), value (Hauptzahl), sub (Unterzeile),
|
||||
// warn (optional, hebt den Wert farblich hervor, z. B. bei überfälligen
|
||||
// Aufgaben). Rückgabe: HTML-String.
|
||||
function kpiTile(label, value, sub, warn){
|
||||
return `<div class="plan-kpi-tile${warn ? ' warn' : ''}">
|
||||
<div class="kpi-label">${esc(label)}</div>
|
||||
<div class="kpi-value">${esc(value)}</div>
|
||||
<div class="kpi-sub">${esc(sub)}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// renderPlanKpis: füllt die KPI-Kachelzeile über dem GANTT (Cockpit-Layout,
|
||||
// #planKpiRow in Test.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
|
||||
// (Ø-Prozentwert aus computeResourceLoads(), dieselbe Zahl wie im
|
||||
// Auslastungs-Modal). Wird nach jedem GANTT-Rendering aufgerufen, damit sie
|
||||
// nach TODO-/Meilenstein-/Zuordnungsänderungen sofort aktuell ist.
|
||||
window.renderPlanKpis = function(){
|
||||
const row = document.getElementById('planKpiRow');
|
||||
if(!row || !app.project) return;
|
||||
const plan = app.ensurePlanLocal();
|
||||
const todos = Array.isArray(plan.todos) ? plan.todos : [];
|
||||
const milestones = Array.isArray(plan.milestones) ? plan.milestones : [];
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
let progressTile;
|
||||
if(todos.length === 0){
|
||||
progressTile = kpiTile('Gesamtfortschritt', '–', 'Keine Aufgaben angelegt');
|
||||
} else {
|
||||
const avg = Math.round(todos.reduce((s, t) => s + (parseInt(t.progress, 10) || 0), 0) / todos.length);
|
||||
const doneCount = todos.filter(t => t.done).length;
|
||||
progressTile = kpiTile('Gesamtfortschritt', avg + '%', doneCount + ' von ' + todos.length + ' Aufgaben erledigt');
|
||||
}
|
||||
|
||||
const open = todos.filter(t => !t.done);
|
||||
const overdue = open.filter(t => t.end && parseDateISO(t.end) < today);
|
||||
const openSub = overdue.length > 0 ? overdue.length + ' überfällig' : (open.length > 0 ? 'Im Zeitplan' : 'Alle Aufgaben erledigt');
|
||||
const openTile = kpiTile('Offene Aufgaben', String(open.length), openSub, overdue.length > 0);
|
||||
|
||||
const upcoming = milestones
|
||||
.filter(m => m.date && parseDateISO(m.date) >= today)
|
||||
.sort((a, b) => parseDateISO(a.date) - parseDateISO(b.date));
|
||||
let milestoneTile;
|
||||
if(upcoming.length > 0){
|
||||
milestoneTile = kpiTile('Nächster Meilenstein', formatDateDE(parseDateISO(upcoming[0].date)), upcoming[0].name || 'Meilenstein');
|
||||
} else {
|
||||
milestoneTile = kpiTile('Nächster Meilenstein', '–', milestones.length > 0 ? 'Keine anstehenden Meilensteine' : 'Keine Meilensteine angelegt');
|
||||
}
|
||||
|
||||
const { loads, maxDays } = computeResourceLoads();
|
||||
const loadValues = Object.values(loads);
|
||||
let loadTile;
|
||||
if(loadValues.length === 0){
|
||||
loadTile = kpiTile('Team-Auslastung', '–', 'Keine Beteiligten zugeordnet');
|
||||
} else {
|
||||
const avgPct = Math.round(loadValues.reduce((s, l) => s + (l.days / maxDays * 100), 0) / loadValues.length);
|
||||
const top = loadValues.slice().sort((a, b) => b.days - a.days)[0];
|
||||
loadTile = kpiTile('Team-Auslastung', avgPct + '%', top.days > 0 ? 'Höchste Auslastung: ' + top.person.name : 'Noch keine Aufgaben zugewiesen');
|
||||
}
|
||||
|
||||
row.innerHTML = progressTile + openTile + milestoneTile + loadTile;
|
||||
};
|
||||
|
||||
// renderGanttDependencies: zeichnet für alle TODO-/Meilenstein-Balken die
|
||||
@@ -129,7 +272,9 @@
|
||||
// (inkl. 7 Tage Puffer) zurück und rendert das Diagramm neu. Wird auch nach
|
||||
// Drags außerhalb des sichtbaren Bereichs von plan-drag.js genutzt.
|
||||
window.fitPlanGanttRange = function(){
|
||||
const range = ganttRangeFromProjects([app.project], 7);
|
||||
// Nur die Abwesenheiten der Projektbeteiligten dürfen den Zeitraum
|
||||
// aufziehen — alle anderen Urlaubszeilen werden ohnehin nicht gezeichnet.
|
||||
const range = ganttRangeFromProjects([app.project], 7, projectAssignees(app.project, app.week).map(a => a.person.id));
|
||||
document.getElementById('planGanttStart').value = formatDateISO(range.start);
|
||||
document.getElementById('planGanttEnd').value = formatDateISO(range.end);
|
||||
renderPlanGantt();
|
||||
@@ -159,26 +304,34 @@
|
||||
|
||||
// ---------- Ressourcen-Auslastung ----------
|
||||
|
||||
// renderResourceLoad: berechnet die Auslastung der Projektbeteiligten in
|
||||
// Werktagen (ohne Wochenenden) anhand der ihnen zugeordneten TODOs und
|
||||
// rendert pro Person eine Auslastungskarte mit Balken und Abwesenheits-
|
||||
// Hinweis. Ein Klick auf eine Karte öffnet das Abwesenheits-Modal aus
|
||||
// plan-modals.js.
|
||||
window.renderResourceLoad = function(){
|
||||
const container = document.getElementById('resourceLoadContainer');
|
||||
if(!container || !app.project) return;
|
||||
// computeResourceLoads: berechnet die Auslastung der Projektbeteiligten in
|
||||
// Werktagen (ohne Wochenenden) anhand der ihnen zugeordneten TODOs.
|
||||
// Gemeinsam genutzt von renderResourceLoad() (Detailkarten im
|
||||
// Auslastungs-Modal) und renderPlanKpis() (Team-Auslastung-Kachel), damit
|
||||
// beide dieselbe Zahl zeigen.
|
||||
//
|
||||
// Neben der Auslastung IN DIESEM Projekt (days/todos) wird zusätzlich
|
||||
// erfasst, wie stark dieselbe Person in den ÜBRIGEN Projekten derselben
|
||||
// Sitzung gebunden ist (otherDays/otherProjects). Ohne diesen Blick über den
|
||||
// Projektrand ist eine Überbuchung strukturell unsichtbar: jemand kann hier
|
||||
// entspannt aussehen und trotzdem in vier weiteren Projekten verplant sein.
|
||||
// Die Daten dafür sind bereits vorhanden — app.week.projects enthält alle
|
||||
// Projekte der Sitzung (GET /load-all lädt sie ohnehin komplett).
|
||||
//
|
||||
// Rückgabe: { loads, maxDays } — loads ist ein Objekt Person-ID ->
|
||||
// { person, roles, days, todos, otherDays, otherProjects }, maxDays das
|
||||
// Maximum über alle Personen (mindestens 1, für Prozent-Berechnungen; bezieht
|
||||
// sich bewusst nur auf die projekteigenen Tage, damit die Balken im Modal
|
||||
// untereinander vergleichbar bleiben).
|
||||
function computeResourceLoads(){
|
||||
const plan = app.ensurePlanLocal();
|
||||
const todos = Array.isArray(plan.todos) ? plan.todos.filter(t => t.start && t.end) : [];
|
||||
const assignees = projectAssignees(app.project, app.week);
|
||||
if(assignees.length === 0){
|
||||
container.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
const loads = {};
|
||||
assignees.forEach(a => {
|
||||
if(!loads[a.person.id]){
|
||||
loads[a.person.id] = { person: a.person, roles: [], days: 0, todos: [] };
|
||||
loads[a.person.id] = { person: a.person, roles: [], days: 0, todos: [], otherDays: 0, otherProjects: [] };
|
||||
}
|
||||
if(!loads[a.person.id].roles.includes(a.role)) loads[a.person.id].roles.push(a.role);
|
||||
});
|
||||
@@ -193,14 +346,76 @@
|
||||
});
|
||||
});
|
||||
|
||||
// Auslastung in den anderen Projekten derselben Sitzung
|
||||
((app.week && app.week.projects) || []).forEach(other => {
|
||||
if(other.id === app.project.id) return;
|
||||
const otherTodos = (other.plan && Array.isArray(other.plan.todos)) ? other.plan.todos : [];
|
||||
otherTodos.forEach(t => {
|
||||
if(!t.start || !t.end || !Array.isArray(t.assignees)) return;
|
||||
const days = workdaysBetween(parseDateISO(t.start), parseDateISO(t.end));
|
||||
t.assignees.forEach(pid => {
|
||||
if(!loads[pid]) return; // nur Personen, die auch hier beteiligt sind
|
||||
loads[pid].otherDays += days;
|
||||
const label = other.nr ? (other.nr + ' ' + (other.name || '')) : (other.name || 'Projekt');
|
||||
if(!loads[pid].otherProjects.includes(label)) loads[pid].otherProjects.push(label);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const maxDays = Math.max(1, Object.values(loads).reduce((m, l) => Math.max(m, l.days), 0));
|
||||
return { loads, maxDays };
|
||||
}
|
||||
|
||||
// openResourceModal: springt in den Bereich "Terminplan", in dem die
|
||||
// Auslastungskarten stehen (#resourceLoadContainer). Früher öffnete die
|
||||
// Funktion ein eigenes Modal; seit die Seite in Bereiche gegliedert ist
|
||||
// (js/plan/plan-sections.js), ist der Inhalt fester Bestandteil des
|
||||
// Terminplan-Bereichs. Der Name bleibt, weil die Kompakt-Karte
|
||||
// "Ressourcen-Auslastung" im Dashboard darauf verweist.
|
||||
window.openResourceModal = function(){
|
||||
if(typeof showPlanSection === 'function') showPlanSection('terminplan');
|
||||
const container = document.getElementById('resourceLoadContainer');
|
||||
if(container) container.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
};
|
||||
|
||||
// renderResourceLoad: rendert pro Person eine Auslastungskarte mit Balken
|
||||
// und Abwesenheits-Hinweis in #resourceLoadContainer (Inhalt des
|
||||
// Auslastungs-Modals) sowie eine kompakte Vorschau (Balkenreihe) in
|
||||
// #planResourceSummary (die Kompakt-Karte selbst). Ein Klick auf eine
|
||||
// Detailkarte öffnet das Abwesenheits-Modal aus plan-modals.js.
|
||||
window.renderResourceLoad = function(){
|
||||
const container = document.getElementById('resourceLoadContainer');
|
||||
const summaryEl = document.getElementById('planResourceSummary');
|
||||
if(!container || !app.project) return;
|
||||
const { loads, maxDays } = computeResourceLoads();
|
||||
const loadValues = Object.values(loads);
|
||||
|
||||
if(loadValues.length === 0){
|
||||
container.innerHTML = '<span class="hint">Keine Beteiligten zugeordnet.</span>';
|
||||
if(summaryEl) summaryEl.innerHTML = '<span class="hint">Keine Beteiligten zugeordnet.</span>';
|
||||
return;
|
||||
}
|
||||
|
||||
if(summaryEl){
|
||||
const cells = loadValues.map(l => {
|
||||
const pct = Math.round(l.days / maxDays * 100);
|
||||
const otherHint = l.otherDays > 0 ? ` (+${l.otherDays} anderswo)` : '';
|
||||
return `<div class="plan-heat-cell" style="opacity:${Math.max(.15, pct / 100)}" title="${esc(l.person.name)}: ${l.days} Tage${otherHint}"></div>`;
|
||||
}).join('');
|
||||
const alsoBooked = loadValues.filter(l => l.otherDays > 0).length;
|
||||
const chip = alsoBooked > 0
|
||||
? `<span class="plan-mini-chip warn">${alsoBooked} auch in anderen Projekten</span>`
|
||||
: `<span class="plan-mini-chip">nur in diesem Projekt</span>`;
|
||||
summaryEl.innerHTML = `<div class="plan-heat-row">${cells}</div>
|
||||
<div class="plan-mini-chip-row">${chip}</div>`;
|
||||
}
|
||||
|
||||
let html = '<div class="section-title" style="margin-bottom:8px;font-size:13px">Ressourcen-Auslastung (Aufgabentage, ohne Wochenenden)</div>';
|
||||
html += '<div class="load-grid plan-resource-grid">';
|
||||
Object.values(loads).forEach(l => {
|
||||
const pct = Math.round(l.days / maxDays * 100);
|
||||
const absCount = app.countPersonAbsences(l.person.id);
|
||||
const absBadge = absCount > 0 ? `<div class="hint" style="font-size:10px;margin-top:2px">🏖 ${absCount} Abwesenheit(en)</div>` : '';
|
||||
const absBadge = absCount > 0 ? `<div class="hint" style="font-size:10px;margin-top:2px">${absCount} Abwesenheit(en)</div>` : '';
|
||||
|
||||
let todoListHtml;
|
||||
if(l.todos.length === 0){
|
||||
@@ -212,10 +427,20 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Gegenüberstellung: Tage in diesem Projekt vs. in den übrigen Projekten
|
||||
// derselben Sitzung (siehe computeResourceLoads) — macht sichtbar, wenn
|
||||
// jemand hier entspannt aussieht, aber anderswo ausgebucht ist.
|
||||
const otherBadge = l.otherDays > 0
|
||||
? `<div class="plan-resource-other" title="${esc(l.otherProjects.join('\n'))}">
|
||||
+${l.otherDays} Tage in ${l.otherProjects.length} weiteren Projekt${l.otherProjects.length === 1 ? '' : 'en'}
|
||||
</div>`
|
||||
: `<div class="plan-resource-other muted">keine weiteren Projekte</div>`;
|
||||
|
||||
html += `<div class="load-card plan-resource-card plan-resource-clickable" onclick="openAbsenceModal('${esc(l.person.id)}')" title="Klicken, um Urlaube/Abwesenheiten zu bearbeiten">
|
||||
<h3>${esc(l.person.name)} <span style="font-size:10px;color:#888;font-weight:normal">(${esc(l.roles.join(' / '))})</span></h3>
|
||||
<div class="load-num" style="font-size:18px">${l.days}</div>
|
||||
<div class="bar" style="height:6px;margin-top:4px"><div style="width:${pct}%;background:${pct>75?'#c53030':pct>45?'#d69e2e':'#2f855a'}"></div></div>
|
||||
${otherBadge}
|
||||
<div class="load-detail" style="font-size:10px;margin-top:4px">${todoListHtml}</div>
|
||||
${absBadge}
|
||||
</div>`;
|
||||
@@ -226,7 +451,12 @@
|
||||
|
||||
// Abhängigkeitslinien hängen von Pixel-Positionen ab -> bei Fenstergröße neu zeichnen
|
||||
// Debounce, damit während des Ziehens nicht unnötig neu gerendert wird.
|
||||
// Seit der Projektbereich ein Bereich der Hauptseite ist, läuft dieser
|
||||
// Lauscher auch, während ein ganz anderer Bereich sichtbar ist. In einem
|
||||
// ausgeblendeten Container sind alle gemessenen Positionen 0 — das Zeichnen
|
||||
// wäre nicht nur überflüssig, sondern falsch. Deshalb der Bereichs-Test.
|
||||
window.addEventListener('resize', () => {
|
||||
if(currentTab !== 'plan' || !app.project) return;
|
||||
if(app.resizeTimer) clearTimeout(app.resizeTimer);
|
||||
app.resizeTimer = setTimeout(() => renderGanttDependencies(), 150);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
// -----------------------------
|
||||
// Projektplan — Verlauf über mehrere Sitzungen
|
||||
// -----------------------------
|
||||
// Dieses Modul macht die Sitzungs-Historie eines Projekts sichtbar: die
|
||||
// Kompakt-Karte "Verlauf" unter dem GANTT zeigt den letzten Kommentarwechsel,
|
||||
// ein Klick öffnet #planHistoryModal mit dem vollständigen Verlauf.
|
||||
//
|
||||
// Der Verlauf selbst wird NICHT hier gezeichnet, sondern von
|
||||
// renderProjektVerlauf() in js/utils.js — derselben Darstellung, die auch der
|
||||
// Wochenbericht unter der Projektkarte und die Projektliste zeigen. Zuvor
|
||||
// hatte jede dieser Stellen ihre eigene Fassung mit eigener Auswahl (hier nur
|
||||
// Sitzungen mit Kommentarwechsel, im Bericht auch Termine und
|
||||
// Leistungsphasen), weshalb derselbe Projektverlauf je nach Aufrufort anders
|
||||
// aussah und sich widersprechen konnte. Der Dialog übergibt kein weekLabel:
|
||||
// Er kennt keine "aktuelle" Fassung, die auszulassen wäre, und zeigt deshalb
|
||||
// alle Sitzungen einschließlich der geöffneten.
|
||||
//
|
||||
// Hintergrund: Beim wöchentlichen cloneWeek() (js/weeks.js) wird der
|
||||
// Kommentartext in die neue Sitzung kopiert und dort überschrieben. Der alte
|
||||
// Text bleibt zwar in der alten Sitzung erhalten, war aber praktisch
|
||||
// unsichtbar, weil niemand in alte Sitzungen zurückwechselt. Damit ging der
|
||||
// Gesprächsfaden eines Projekts verloren — genau das, wofür das wöchentliche
|
||||
// Board ursprünglich gedacht war.
|
||||
//
|
||||
// WICHTIG — kein neuer Endpunkt nötig: Die Projektplan-Seite lädt über
|
||||
// GET /load-all ohnehin ALLE Sitzungen mit allen Projekten nach db.weeks
|
||||
// (siehe loadPlanData() in plan-state.js). Zusammen mit project_uid (der
|
||||
// dauerhaften, wochenunabhängigen Projekt-Identität, siehe
|
||||
// migrateAddProjectUid() in server.js) lässt sich der Verlauf daher rein im
|
||||
// Browser zusammenstellen. Ein serverseitiges GET /projects/:uid/history wird
|
||||
// erst nötig, wenn der Verlauf auch außerhalb dieser Seite gebraucht wird.
|
||||
//
|
||||
// Die hier definierten Helfer projectVersions()/previousProjectVersion()
|
||||
// werden auch von plan-build-gantt.js gebraucht (Geister-Balken der Vorwoche)
|
||||
// und deshalb auf window.PlanApp exportiert. Dieses Modul muss daher VOR
|
||||
// plan-build-gantt.js geladen werden.
|
||||
//
|
||||
// Läuft wie alle plan-Module in einem IIFE; Zustand: window.PlanApp
|
||||
// (plan-state.js).
|
||||
|
||||
(function(){
|
||||
const app = window.PlanApp;
|
||||
|
||||
// projectVersions: alle Fassungen des aktuellen Projekts über alle
|
||||
// geladenen Sitzungen, chronologisch aufsteigend. Die eigentliche Logik
|
||||
// liegt seit der Kommentar-Überarbeitung in js/utils.js
|
||||
// (projectVersionsForUid), weil das Board sie ebenfalls braucht; hier bleibt
|
||||
// nur die Bindung an das aktuell geöffnete Projekt.
|
||||
function projectVersions(){
|
||||
return projectVersionsForUid(app.project && app.project.projectUid);
|
||||
}
|
||||
|
||||
// previousProjectVersion: die Fassung des Projekts aus der Sitzung
|
||||
// unmittelbar VOR der gerade geöffneten, oder null (früheste Sitzung bzw.
|
||||
// kein projectUid). Rückgabe: { week, project } | null.
|
||||
function previousProjectVersion(){
|
||||
const versions = projectVersions();
|
||||
const idx = versions.findIndex(v => app.week && v.week.label === app.week.label);
|
||||
return idx > 0 ? versions[idx - 1] : null;
|
||||
}
|
||||
|
||||
// previousPlannedProjectVersion: die jüngste frühere Fassung, die überhaupt
|
||||
// einen befüllten Terminplan hat (mindestens eine Aufgabe oder einen
|
||||
// Meilenstein). Grundlage für die Geister-Balken im GANTT.
|
||||
//
|
||||
// Warum nicht einfach previousProjectVersion(): In der Praxis gibt es
|
||||
// Sitzungen, in denen ein Projekt zwar geführt wird, aber (noch) keinen
|
||||
// Terminplan hat — etwa weil die Sitzung neu angelegt statt dupliziert
|
||||
// wurde. Ein Vergleich gegen so eine leere Fassung würde gar nichts
|
||||
// anzeigen und die Funktion wirkte kaputt, obwohl es zwei Sitzungen weiter
|
||||
// hinten sehr wohl Vergleichsdaten gibt. Deshalb wird rückwärts gesucht,
|
||||
// bis eine Fassung mit Planinhalt gefunden ist. Die gefundene Sitzung wird
|
||||
// in den Tooltips namentlich genannt, damit der Bezug nie unklar ist.
|
||||
// Rückgabe: { week, project } | null.
|
||||
function previousPlannedProjectVersion(){
|
||||
const versions = projectVersions();
|
||||
const idx = versions.findIndex(v => app.week && v.week.label === app.week.label);
|
||||
if(idx <= 0) return null;
|
||||
for(let i = idx - 1; i >= 0; i--){
|
||||
const plan = versions[i].project.plan;
|
||||
if(!plan) continue;
|
||||
const hasTodos = Array.isArray(plan.todos) && plan.todos.length > 0;
|
||||
const hasMilestones = Array.isArray(plan.milestones) && plan.milestones.length > 0;
|
||||
if(hasTodos || hasMilestones) return versions[i];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// historyEntries: die Verlaufsliste des aktuell geöffneten Projekts
|
||||
// (neueste zuerst, Wiederholungen desselben Textes gefiltert). Logik in
|
||||
// js/utils.js, siehe projectStatusHistory().
|
||||
function historyEntries(){
|
||||
return projectStatusHistory(app.project && app.project.projectUid);
|
||||
}
|
||||
|
||||
// renderPlanHistory: füllt die Kompakt-Karte "Verlauf" (#planHistorySummary
|
||||
// in Test.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(){
|
||||
const summaryEl = document.getElementById('planHistorySummary');
|
||||
if(!summaryEl || !app.project) return;
|
||||
|
||||
// Ein Sandbox-Plan der Planung lebt in genau einer, nie geklonten Sitzung
|
||||
// (SANDBOX_WEEK_LABEL) — es gibt also nie einen "Verlauf über mehrere
|
||||
// Sitzungen" zu zeigen, nur immer denselben einen Schnappschuss.
|
||||
if(app.isSandboxPlan && app.isSandboxPlan()){
|
||||
summaryEl.innerHTML = '<span class="hint">Kein Verlauf — dieser Terminplan gehört zu keiner Sitzung.</span>';
|
||||
return;
|
||||
}
|
||||
|
||||
if(!app.project.projectUid){
|
||||
summaryEl.innerHTML = '<span class="hint">Für dieses Projekt ist kein Verlauf verfügbar.</span>';
|
||||
return;
|
||||
}
|
||||
const entries = historyEntries();
|
||||
if(entries.length === 0){
|
||||
summaryEl.innerHTML = '<span class="hint">Noch keine Kommentare erfasst.</span>';
|
||||
return;
|
||||
}
|
||||
const latest = entries[0];
|
||||
const versionCount = projectVersions().length;
|
||||
summaryEl.innerHTML = `<div class="plan-mini-card-preview">${esc(latest.text)}</div>
|
||||
<div class="plan-mini-chip-row">
|
||||
<span class="plan-mini-chip">${entries.length} Eintrag${entries.length === 1 ? '' : 'e'}</span>
|
||||
<span class="plan-mini-chip">zuletzt ${esc(latest.week.label)}</span>
|
||||
<span class="plan-mini-chip">${versionCount} Sitzung${versionCount === 1 ? '' : 'en'}</span>
|
||||
</div>`;
|
||||
};
|
||||
|
||||
// openHistoryModal/closeHistoryModal: öffnen bzw. schließen das
|
||||
// Verlaufs-Modal (#planHistoryModal). Der Inhalt wird beim Öffnen frisch
|
||||
// aufgebaut, damit ein gerade im Projektinfo-Modal geänderter Kommentar
|
||||
// sofort mit auftaucht.
|
||||
window.openHistoryModal = function(){
|
||||
const listEl = document.getElementById('planHistoryList');
|
||||
if(listEl){
|
||||
if(!app.project || !app.project.projectUid){
|
||||
listEl.innerHTML = '<span class="hint">Dieses Projekt hat keine dauerhafte Projekt-Kennung und kann daher nicht über Sitzungen hinweg verfolgt werden.</span>';
|
||||
} else {
|
||||
// Dieselbe Darstellung wie im Wochenbericht und im Projektboard
|
||||
// (renderProjektVerlauf(), js/utils.js). Der Dialog hatte zuvor eine
|
||||
// eigene, enger gefasste Liste — sie zeigte nur Kommentartexte und
|
||||
// konnte dem Verlauf an den anderen Stellen widersprechen.
|
||||
//
|
||||
// weekLabel bleibt leer: Im Dialog gibt es keine "aktuelle" Fassung,
|
||||
// die auszulassen wäre — hier sollen ALLE Sitzungen erscheinen,
|
||||
// einschließlich der gerade geöffneten.
|
||||
listEl.innerHTML = renderProjektVerlauf(app.project, '', app.isLocked());
|
||||
}
|
||||
}
|
||||
document.getElementById('planHistoryModal').style.display = 'flex';
|
||||
};
|
||||
|
||||
window.closeHistoryModal = function(){
|
||||
document.getElementById('planHistoryModal').style.display = 'none';
|
||||
};
|
||||
|
||||
// Export für die anderen plan-Module (plan-gantt.js/plan-build-gantt.js:
|
||||
// Geister-Balken der letzten geplanten Sitzung).
|
||||
app.projectVersions = projectVersions;
|
||||
app.previousProjectVersion = previousProjectVersion;
|
||||
app.previousPlannedProjectVersion = previousPlannedProjectVersion;
|
||||
})();
|
||||
@@ -0,0 +1,343 @@
|
||||
// -----------------------------
|
||||
// Projektplan — Terminplan aus dem Honorar errechnen
|
||||
// -----------------------------
|
||||
// Eigener Dialog (#honorarPlanModal) mit eigener Schaltfläche "+ Terminplan"
|
||||
// in der GANTT-Toolbar: statt fester Vorlagen-Schritte (das macht der
|
||||
// Nachbardialog, js/plan/plan-templates.js) wird hier aus dem vereinbarten
|
||||
// Honorar ein kompletter Terminplan über die beauftragten Leistungsphasen
|
||||
// gerechnet. Beide Verfahren lagen früher als Reiter in einem gemeinsamen
|
||||
// Dialog — getrennt sind sie, weil so auf jeder Schaltfläche steht, was
|
||||
// dahinter passiert, und keine Maske die Eingaben der anderen mitträgt.
|
||||
//
|
||||
// Rechenweg (jede Stufe ist im Dialog als Tabellenspalte sichtbar, damit die
|
||||
// Zahl nachvollziehbar bleibt und nicht aus einer Blackbox kommt):
|
||||
//
|
||||
// 1. Honorar (netto, €) -> im Dialog eingegeben, am Projekt gespeichert (projects.honorar)
|
||||
// 2. Anteil je Leistungsphase (%) -> firmenweit gepflegt (lph_honorar_anteile)
|
||||
// 3. ggf. Normierung -> siehe unten
|
||||
// 4. Honorar je Phase (€) = Honorar × Anteil
|
||||
// 5. Stunden je Phase = Honorar je Phase ÷ Stundensatz (firmenweit einstellbar, Vorgabe 135 €)
|
||||
// 6. Dauer je Phase (Werktage) = Stunden ÷ (Arbeitsstunden je Tag × Personen je Phase), aufgerundet
|
||||
// 7. Termine = nahtlos nacheinander ab Startdatum, nur Arbeitstage
|
||||
// (Wochenenden und gepflegte Feiertage fallen heraus)
|
||||
//
|
||||
// NORMIERUNG (wichtig, im Dialog umschaltbar): Sind nur einzelne Phasen
|
||||
// beauftragt — etwa LPH 5–8 mit zusammen 69 % —, dann ist das vereinbarte
|
||||
// Honorar das Honorar für GENAU DIESE Phasen. Ohne Normierung würden nur 69 %
|
||||
// des Honorars in Stunden umgerechnet und rund ein Drittel der Arbeitszeit
|
||||
// fiele unter den Tisch. Standardmäßig werden die Anteile deshalb auf die
|
||||
// beauftragten Phasen hochskaliert (jeder Anteil ÷ Summe der beauftragten
|
||||
// Anteile). Wer das Honorar als Gesamthonorar über alle Phasen versteht,
|
||||
// schaltet die Normierung im Dialog ab.
|
||||
//
|
||||
// Die erzeugten Aufgaben sind danach ganz normale plan_todos ohne Verbindung
|
||||
// zur Berechnung — wie bei den Vorlagen (plan-templates.js) wirkt sich eine
|
||||
// spätere Honorar- oder Satzänderung NICHT rückwirkend auf bereits erzeugte
|
||||
// Balken aus.
|
||||
//
|
||||
// Läuft wie alle plan-Module in einem IIFE; Zustand: window.PlanApp
|
||||
// (plan-state.js).
|
||||
|
||||
(function(){
|
||||
const app = window.PlanApp;
|
||||
|
||||
// stundenProTag: angenommene Arbeitsstunden je Werktag und Person. War eine
|
||||
// Konstante (8) und ist seit dem Ausbau der Einstellungen firmenweit
|
||||
// gepflegt — der Wert geht direkt in jede errechnete Phasendauer ein.
|
||||
function stundenProTag(){
|
||||
return Number(APP_SETTINGS.arbeitsstundenProTag) || 8;
|
||||
}
|
||||
|
||||
// honorarConfig: zuletzt geladene firmenweite Konfiguration
|
||||
// ({ anteile: {LPH->%}, stundensatz: Number }); null = noch nicht geladen.
|
||||
let honorarConfig = null;
|
||||
|
||||
// personenProPhase: im Dialog eingestellte Personenzahl je Leistungsphase
|
||||
// (LPH -> Anzahl). Lebt nur für die Dauer des Dialogs, wird nicht gespeichert.
|
||||
let personenProPhase = {};
|
||||
|
||||
// loadHonorarConfig: holt Anteile und Stundensatz vom Server und rendert
|
||||
// danach neu. Fehler werden in der Tabelle gemeldet, nicht per alert.
|
||||
function loadHonorarConfig(){
|
||||
return fetch(baseUrl() + '/honorar-config')
|
||||
.then(r => { if(!r.ok) throw new Error('Server HTTP ' + r.status); return r.json(); })
|
||||
.then(data => {
|
||||
honorarConfig = { anteile: data.anteile || {}, stundensatz: Number(data.stundensatz) || 135 };
|
||||
return honorarConfig;
|
||||
});
|
||||
}
|
||||
|
||||
// beauftragteLphs: die beauftragten Leistungsphasen des Projekts, in der
|
||||
// Reihenfolge von LPHS (js/globals.js) — nicht in Eingabereihenfolge, damit
|
||||
// der erzeugte Terminplan immer chronologisch sinnvoll aufsteigt.
|
||||
// Rückgabe: Array von LPH-Schlüsseln.
|
||||
function beauftragteLphs(){
|
||||
const set = new Set(Array.isArray(app.project.beauftragteLP) ? app.project.beauftragteLP : []);
|
||||
return LPHS.filter(l => set.has(l));
|
||||
}
|
||||
|
||||
// berechneHonorarPlan: führt die Rechnung aus und liefert je beauftragter
|
||||
// Leistungsphase eine Zeile mit allen Zwischenwerten. Parameter: startDate
|
||||
// (Date, Beginn der ersten Phase), normieren (Boolean).
|
||||
// Rückgabe: { zeilen: [...], summe: {...}, problem: String|null }.
|
||||
function berechneHonorarPlan(startDate, normieren){
|
||||
const honorar = Number(app.project.honorar) || 0;
|
||||
const phasen = beauftragteLphs();
|
||||
if(!honorarConfig) return { zeilen: [], summe: null, problem: 'Konfiguration noch nicht geladen.' };
|
||||
if(honorar <= 0) return { zeilen: [], summe: null, problem: 'Bitte oben das vereinbarte Netto-Honorar eintragen.' };
|
||||
if(phasen.length === 0) return { zeilen: [], summe: null, problem: 'Bitte oben mindestens eine beauftragte Leistungsphase ankreuzen.' };
|
||||
|
||||
const anteile = honorarConfig.anteile || {};
|
||||
const summeBeauftragt = phasen.reduce((s, l) => s + (Number(anteile[l]) || 0), 0);
|
||||
if(summeBeauftragt <= 0){
|
||||
return { zeilen: [], summe: null, problem: 'Für die beauftragten Leistungsphasen sind keine Honorar-Anteile hinterlegt. Ein Administrator kann sie unten pflegen.' };
|
||||
}
|
||||
|
||||
const stundensatz = honorarConfig.stundensatz;
|
||||
let cursor = startDate;
|
||||
let summeStunden = 0, summeTage = 0, summeHonorar = 0;
|
||||
|
||||
const zeilen = phasen.map(lph => {
|
||||
const rohAnteil = Number(anteile[lph]) || 0;
|
||||
// Normierung: Anteil relativ zur Summe der beauftragten Phasen
|
||||
const anteil = normieren ? (rohAnteil / summeBeauftragt * 100) : rohAnteil;
|
||||
const phasenHonorar = honorar * anteil / 100;
|
||||
const stunden = phasenHonorar / stundensatz;
|
||||
const personen = Math.max(1, Number(personenProPhase[lph]) || 1);
|
||||
const tage = Math.max(1, Math.ceil(stunden / (stundenProTag() * personen)));
|
||||
|
||||
const start = cursor;
|
||||
const ende = addWorkdays(start, tage - 1);
|
||||
cursor = addWorkdays(ende, 1); // nächste Phase beginnt am Folgewerktag
|
||||
|
||||
summeStunden += stunden;
|
||||
summeTage += tage;
|
||||
summeHonorar += phasenHonorar;
|
||||
|
||||
return { lph, rohAnteil, anteil, phasenHonorar, stunden, personen, tage, start, ende };
|
||||
});
|
||||
|
||||
return {
|
||||
zeilen,
|
||||
summe: { stunden: summeStunden, tage: summeTage, honorar: summeHonorar, stundensatz, summeBeauftragt },
|
||||
problem: null,
|
||||
};
|
||||
}
|
||||
|
||||
// fmtEur / fmtNum: einheitliche deutsche Zahlenformate für die Tabelle.
|
||||
function fmtEur(v){ return (Math.round(v * 100) / 100).toLocaleString('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + ' €'; }
|
||||
function fmtNum(v, digits){ return Number(v).toLocaleString('de-DE', { minimumFractionDigits: digits || 0, maximumFractionDigits: digits || 0 }); }
|
||||
|
||||
// setHonorarBetrag: übernimmt das vereinbarte Netto-Honorar des Projekts.
|
||||
// Das Feld steht bewusst hier im Rechen-Dialog und nicht mehr im
|
||||
// Projektinfo: dort gehören nur allgemeine Projektangaben und der
|
||||
// Wochenkommentar hin, das Honorar ist eine Eingabe dieser Berechnung.
|
||||
// Gespeichert wird es trotzdem dauerhaft am Projekt (projects.honorar),
|
||||
// bleibt also über Sitzungen hinweg erhalten. Leere oder unbrauchbare
|
||||
// Eingaben gelten als 0 = "kein Honorar hinterlegt".
|
||||
// Parameter: val (Eingabewert). Keine Rückgabe.
|
||||
window.setHonorarBetrag = function(val){
|
||||
if(app.isLocked()) return;
|
||||
const v = parseFloat(String(val).replace(',', '.'));
|
||||
app.project.honorar = Number.isFinite(v) && v > 0 ? v : 0;
|
||||
app.savePlan();
|
||||
renderHonorarPlan();
|
||||
// Dasselbe Feld steht auch im Einstellungen-Bereich (plan-settings.js) —
|
||||
// beide Masken nachziehen, damit sie nicht auseinanderlaufen.
|
||||
if(typeof renderPlanSettings === 'function') renderPlanSettings();
|
||||
};
|
||||
|
||||
// toggleBeauftragteLp: nimmt eine Leistungsphase in die Liste der
|
||||
// beauftragten Phasen auf bzw. entfernt sie. Die Liste wird in der
|
||||
// Reihenfolge von LPHS gehalten, damit der erzeugte Terminplan
|
||||
// chronologisch aufsteigt. Parameter: lph, checked (Boolean).
|
||||
window.toggleBeauftragteLp = function(lph, checked){
|
||||
if(app.isLocked()) return;
|
||||
const p = app.project;
|
||||
const set = new Set(Array.isArray(p.beauftragteLP) ? p.beauftragteLP : []);
|
||||
if(checked) set.add(lph); else set.delete(lph);
|
||||
p.beauftragteLP = LPHS.filter(l => set.has(l));
|
||||
app.savePlan();
|
||||
renderHonorarPlan();
|
||||
// Die beauftragten Phasen stehen auch im Einstellungen-Bereich
|
||||
// (plan-settings.js) und im Wochenbericht — dort wird beim nächsten
|
||||
// Laden gelesen, hier genügt das Nachziehen der zweiten Maske.
|
||||
if(typeof renderPlanSettings === 'function') renderPlanSettings();
|
||||
};
|
||||
|
||||
// renderHonorarEingaben: hält Honorar-Feld und die Auswahl der beauftragten
|
||||
// Leistungsphasen auf dem Stand des Projekts. Das fokussierte Honorar-Feld
|
||||
// wird nicht überschrieben, sonst springt beim Tippen der Cursor.
|
||||
function renderHonorarEingaben(){
|
||||
const betragEl = document.getElementById('honorarBetrag');
|
||||
if(betragEl && document.activeElement !== betragEl){
|
||||
betragEl.value = Number(app.project.honorar) > 0 ? app.project.honorar : '';
|
||||
}
|
||||
|
||||
const lpEl = document.getElementById('honorarBeauftragteLp');
|
||||
if(lpEl){
|
||||
const gesetzt = new Set(Array.isArray(app.project.beauftragteLP) ? app.project.beauftragteLP : []);
|
||||
lpEl.innerHTML = LPHS.map(l => `<label class="beauftragte-lp-item">
|
||||
<input type="checkbox" value="${esc(l)}" ${gesetzt.has(l) ? 'checked' : ''}
|
||||
onchange="toggleBeauftragteLp('${esc(l)}', this.checked)">
|
||||
${esc(l)}
|
||||
</label>`).join('');
|
||||
}
|
||||
}
|
||||
|
||||
// renderHonorarPlan: baut die Eingaben und die Vorschau-Tabelle im Dialog
|
||||
// neu auf. Wird bei jeder Änderung von Honorar, beauftragten Phasen,
|
||||
// Startdatum, Normierung oder Personenzahl aufgerufen.
|
||||
// Keine Parameter, keine Rückgabe.
|
||||
window.renderHonorarPlan = function(){
|
||||
const el = document.getElementById('honorarPlanTable');
|
||||
if(!el || !app.project) return;
|
||||
renderHonorarEingaben();
|
||||
|
||||
const startIso = (document.getElementById('honorarStart') || {}).value || '';
|
||||
const startDate = parseDateISO(startIso);
|
||||
const normieren = !!(document.getElementById('honorarNormieren') || {}).checked;
|
||||
|
||||
if(!startDate){
|
||||
el.innerHTML = '<span class="hint">Bitte ein Startdatum wählen.</span>';
|
||||
return;
|
||||
}
|
||||
|
||||
const { zeilen, summe, problem } = berechneHonorarPlan(startDate, normieren);
|
||||
if(problem){
|
||||
el.innerHTML = '<span class="hint">' + esc(problem) + '</span>';
|
||||
return;
|
||||
}
|
||||
|
||||
const rows = zeilen.map(z => `<tr>
|
||||
<th class="lp-lph">${esc(z.lph)}</th>
|
||||
<td class="num">${fmtNum(z.anteil, 1)} %</td>
|
||||
<td class="num">${fmtEur(z.phasenHonorar)}</td>
|
||||
<td class="num">${fmtNum(z.stunden, 1)} h</td>
|
||||
<td><input type="number" min="1" step="1" value="${z.personen}"
|
||||
onchange="setHonorarPersonen('${esc(z.lph)}', this.value)"
|
||||
title="Wie viele Personen arbeiten parallel an dieser Phase?"></td>
|
||||
<td class="num">${z.tage} AT</td>
|
||||
<td class="nowrap">${formatDateDE(z.start)}–${formatDateDE(z.ende)}</td>
|
||||
</tr>`).join('');
|
||||
|
||||
const normHint = normieren
|
||||
? `Anteile auf die ${zeilen.length} beauftragten Phasen normiert (Rohsumme ${fmtNum(summe.summeBeauftragt, 1)} % → 100 %).`
|
||||
: `Rohe HOAI-Anteile, nicht normiert — es werden nur ${fmtNum(summe.summeBeauftragt, 1)} % des Honorars verplant.`;
|
||||
|
||||
el.innerHTML = `<table class="lp-matrix honorar-table">
|
||||
<thead><tr>
|
||||
<th>Phase</th><th class="num">Anteil</th><th class="num">Honorar</th>
|
||||
<th class="num">Stunden</th><th>Personen</th><th class="num">Dauer</th><th>Zeitraum</th>
|
||||
</tr></thead>
|
||||
<tbody>${rows}</tbody>
|
||||
<tfoot><tr>
|
||||
<th class="lp-lph">Summe</th>
|
||||
<td class="num">${fmtNum(zeilen.reduce((s,z)=>s+z.anteil,0), 1)} %</td>
|
||||
<td class="num">${fmtEur(summe.honorar)}</td>
|
||||
<td class="num">${fmtNum(summe.stunden, 1)} h</td>
|
||||
<td></td>
|
||||
<td class="num">${summe.tage} AT</td>
|
||||
<td class="nowrap">${formatDateDE(zeilen[0].start)}–${formatDateDE(zeilen[zeilen.length-1].ende)}</td>
|
||||
</tr></tfoot>
|
||||
</table>
|
||||
<div class="hint" style="margin-top:6px">${esc(normHint)} Stundensatz ${fmtEur(summe.stundensatz)}, ${fmtNum(stundenProTag(), 1)} h je Person und Werktag. AT = Arbeitstage (ohne Wochenenden und gepflegte Feiertage).</div>`;
|
||||
};
|
||||
|
||||
// setHonorarPersonen: übernimmt die im Tabellenfeld eingestellte
|
||||
// Personenzahl einer Phase und rechnet neu. Parameter: lph, val.
|
||||
window.setHonorarPersonen = function(lph, val){
|
||||
personenProPhase[lph] = Math.max(1, parseInt(val, 10) || 1);
|
||||
renderHonorarPlan();
|
||||
};
|
||||
|
||||
// renderHonorarHint: blendet für Administratoren den Verweis auf den
|
||||
// Bereich „Einstellungen" ein. Die Pflege-Maske für Anteile und Stundensatz
|
||||
// stand bis zum Umbau hier im Dialog — an einer Stelle also, die man nur
|
||||
// fand, wenn man ohnehin gerade einen Terminplan rechnen wollte. Sie liegt
|
||||
// jetzt in js/settings/settings.js; dieser Dialog RECHNET nur noch damit.
|
||||
// Keine Parameter, keine Rückgabe.
|
||||
function renderHonorarHint(){
|
||||
const el = document.getElementById('honorarAdminHint');
|
||||
if(!el) return;
|
||||
el.style.display = (currentUser && currentUser.role === 'admin') ? '' : 'none';
|
||||
}
|
||||
|
||||
// invalidateHonorarConfig: verwirft den Zwischenspeicher, damit der nächste
|
||||
// Aufruf des Dialogs die Konfiguration frisch holt. Gerufen von
|
||||
// saveHonorarConfig() (js/settings/settings.js) nach jeder Änderung — sonst
|
||||
// rechnete ein in derselben Sitzung danach geöffneter Dialog mit den alten
|
||||
// Anteilen weiter, weil die Konfiguration nur einmal je Seitenaufruf
|
||||
// geladen wird. Keine Parameter, keine Rückgabe.
|
||||
window.invalidateHonorarConfig = function(){
|
||||
honorarConfig = null;
|
||||
};
|
||||
|
||||
// openHonorarModal: öffnet den Honorar-Dialog (#honorarPlanModal, eigene
|
||||
// Schaltfläche "+ Terminplan" in der GANTT-Toolbar). Lädt die firmenweite
|
||||
// Konfiguration beim ersten Aufruf, belegt das Startdatum vor und rendert.
|
||||
// Bei gesperrter Sitzung wird gar nicht erst geöffnet, wie bei den anderen
|
||||
// Bearbeitungs-Dialogen.
|
||||
window.openHonorarModal = function(){
|
||||
if(app.isLocked()) return;
|
||||
document.getElementById('honorarPlanModal').style.display = 'flex';
|
||||
|
||||
const startInput = document.getElementById('honorarStart');
|
||||
const startDisplay = document.getElementById('honorarStartDisplay');
|
||||
if(startInput && !startInput.value){
|
||||
const today = formatDateISO(new Date());
|
||||
startInput.value = today;
|
||||
if(startDisplay) startDisplay.value = formatDateDE(parseDateISO(today));
|
||||
}
|
||||
personenProPhase = {};
|
||||
renderHonorarHint();
|
||||
if(honorarConfig){ renderHonorarPlan(); return; }
|
||||
const el = document.getElementById('honorarPlanTable');
|
||||
if(el) el.innerHTML = '<span class="hint">Lade Honorar-Einstellungen…</span>';
|
||||
loadHonorarConfig()
|
||||
.then(() => renderHonorarPlan())
|
||||
.catch(err => {
|
||||
console.error('Honorar-Konfiguration laden fehlgeschlagen:', err);
|
||||
if(el) el.innerHTML = '<span class="hint">Honorar-Einstellungen konnten nicht geladen werden.</span>';
|
||||
});
|
||||
};
|
||||
|
||||
window.closeHonorarModal = function(){
|
||||
document.getElementById('honorarPlanModal').style.display = 'none';
|
||||
};
|
||||
|
||||
// applyHonorarPlan: erzeugt aus den berechneten Zeilen je Leistungsphase
|
||||
// eine Aufgabe im Projektplan. Titel ist die Leistungsphase mit ihrem
|
||||
// Stundenbudget, damit im GANTT sofort erkennbar ist, worauf der Balken
|
||||
// beruht. Wie bei den Vorlagen ohne Zuständige und Abhängigkeiten — das
|
||||
// wird danach im TODO-Dialog ergänzt.
|
||||
window.applyHonorarPlan = function(){
|
||||
if(app.isLocked()) return;
|
||||
const startIso = (document.getElementById('honorarStart') || {}).value || '';
|
||||
const startDate = parseDateISO(startIso);
|
||||
if(!startDate){ alert('Bitte ein Startdatum wählen.'); return; }
|
||||
const normieren = !!(document.getElementById('honorarNormieren') || {}).checked;
|
||||
|
||||
const { zeilen, problem } = berechneHonorarPlan(startDate, normieren);
|
||||
if(problem){ alert(problem); return; }
|
||||
|
||||
const plan = app.ensurePlanLocal();
|
||||
zeilen.forEach(z => {
|
||||
plan.todos.push({
|
||||
id: uid(),
|
||||
title: `${z.lph} (${fmtNum(z.stunden, 0)} h)`,
|
||||
start: formatDateISO(z.start),
|
||||
end: formatDateISO(z.ende),
|
||||
done: false,
|
||||
progress: 0,
|
||||
dependsOn: [],
|
||||
assignees: [],
|
||||
});
|
||||
});
|
||||
|
||||
app.savePlan();
|
||||
closeHonorarModal();
|
||||
fitPlanGanttRange();
|
||||
app.showToast(zeilen.length + ' Phase(n) als Terminplan eingefügt.');
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,199 @@
|
||||
// -----------------------------
|
||||
// Projektbereich — Projektangaben (Anzeige und Stammdaten)
|
||||
// -----------------------------
|
||||
// Dieses Modul füllt die Projektangaben des Projektbereichs. Es unterscheidet
|
||||
// dabei zwei Arten von Angaben, und das ist der Kern der Sache:
|
||||
//
|
||||
// * NUR ANZEIGE — Kommentar der Sitzung (#planStatusEdit), Fortschritt je
|
||||
// Leistungsphase (#planLphList) und die Zuordnung der Beteiligten
|
||||
// (#planAssignMatrix). Diese drei entstehen in der wöchentlichen
|
||||
// Besprechung und werden ausschließlich im Wochenbericht erfasst. Neben
|
||||
// jeder von ihnen steht ein Verweis dorthin (setzeBerichtVerweis()), der
|
||||
// hinüberwechselt UND dort dieses Projekt einstellt.
|
||||
// * STAMMDATEN, hier pflegbar — Gruppe (#planGroupSelect) und amtierende
|
||||
// Leistungsphase (#planActiveLpSelect), beide im Bereich "Einstellungen".
|
||||
//
|
||||
// Warum: Dieselben drei Angaben waren zuvor an DREI Stellen bearbeitbar —
|
||||
// Projektboard, Wochenbericht und hier. Niemand wusste, welche davon die
|
||||
// richtige ist, und zwei Personen konnten dasselbe Feld an verschiedenen
|
||||
// Stellen ändern. Seitdem hat jede Angabe genau einen Ort, an dem sie
|
||||
// entsteht, und überall sonst steht sie zum Lesen samt Weg dorthin. Die
|
||||
// früheren Schreibfunktionen dieses Moduls (planStatusBlur(), setPlanLph(),
|
||||
// setPlanAssign()) sind damit entfallen.
|
||||
//
|
||||
// BEWUSST NICHT hier: Projektnummer, Bezeichnung, Schlagwörter, Honorar und
|
||||
// beauftragte Leistungsphasen. Sie sind ebenfalls Stammdaten, werden aber von
|
||||
// js/plan/plan-settings.js gerendert (Honorar und beauftragte Phasen
|
||||
// geschrieben von setHonorarBetrag()/toggleBeauftragteLp() aus
|
||||
// js/plan/plan-honorar.js).
|
||||
//
|
||||
// renderPlanInfo() wird von
|
||||
// renderPlanPage() (plan-state.js) nach jedem Laden aufgerufen; Änderungen
|
||||
// speichern direkt über app.savePlan() und rendern danach nur das Infopanel
|
||||
// neu (das GANTT bleibt unberührt).
|
||||
//
|
||||
// Läuft wie alle plan-Module in einem IIFE; Zustand: window.PlanApp
|
||||
// (plan-state.js).
|
||||
|
||||
(function(){
|
||||
const app = window.PlanApp;
|
||||
|
||||
// PLAN_ACTIVE_LPS: Phasen, die als amtierende Leistungsphase wählbar sind —
|
||||
// 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
|
||||
// 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');
|
||||
|
||||
// planLpActiveList: liefert die (auf gültige Phasen gefilterte) amtierende
|
||||
// Leistungsphase eines Projekts als Array — Kopie von lpActiveList() in
|
||||
// js/load/load.js, das auf dieser Seite nicht geladen wird. Parameter: p
|
||||
// (Projekt). Rückgabe: Array (leer, wenn keine gesetzt).
|
||||
function planLpActiveList(p){
|
||||
const a = p && p.aktuelleLP;
|
||||
if(Array.isArray(a)) return a.filter(l => PLAN_ACTIVE_LPS.includes(l));
|
||||
if(typeof a === 'string' && PLAN_ACTIVE_LPS.includes(a)) return [a];
|
||||
return [];
|
||||
}
|
||||
|
||||
// renderPlanInfo: baut das Projektinfo-Panel (Status-Editor, Gruppe,
|
||||
// amtierende Leistungsphase, LPH-Prozentliste) aus app.project auf. Wird
|
||||
// nach jedem Laden (renderPlanPage(), plan-state.js) sowie nach jeder
|
||||
// Änderung hier selbst erneut aufgerufen. Keine Parameter, keine Rückgabe.
|
||||
window.renderPlanInfo = function(){
|
||||
const p = app.project;
|
||||
const locked = app.isLocked();
|
||||
|
||||
// Kommentar, Fortschritt und Zuordnung sind hier NUR ZU SEHEN.
|
||||
//
|
||||
// Sie entstehen in der wöchentlichen Besprechung und werden deshalb im
|
||||
// Wochenbericht gepflegt — an genau einer Stelle. Zuvor waren dieselben
|
||||
// drei Angaben an drei Orten bearbeitbar (Projektboard, Wochenbericht,
|
||||
// Projektbereich); niemand wusste, welcher der richtige ist, und zwei
|
||||
// Personen konnten dasselbe Feld an verschiedenen Stellen pflegen.
|
||||
// Was hier bleibt, sind die Stammdaten des Projekts (Bereich
|
||||
// "Einstellungen", siehe plan-settings.js).
|
||||
const editEl = document.getElementById('planStatusEdit');
|
||||
if(editEl){
|
||||
editEl.className = 'status-edit is-ansicht';
|
||||
editEl.contentEditable = 'false';
|
||||
const text = sanitizeStatusHtml(p.status || '');
|
||||
editEl.innerHTML = text || '<span class="hint" style="margin:0">Für diese Sitzung ist noch kein Kommentar erfasst.</span>';
|
||||
}
|
||||
const statusWrap = document.getElementById('planStatusWrap');
|
||||
if(statusWrap) statusWrap.classList.add('locked-cell');
|
||||
setzeBerichtVerweis('planStatusVerweis', locked);
|
||||
|
||||
// Gruppe — Stammdatum, bleibt hier pflegbar
|
||||
const groupSel = document.getElementById('planGroupSelect');
|
||||
if(groupSel){
|
||||
groupSel.value = p.group || 'Allgemein';
|
||||
groupSel.disabled = locked;
|
||||
}
|
||||
|
||||
// Amtierende Leistungsphase — Stammdatum, bleibt hier pflegbar
|
||||
const aktLp = planLpActiveList(p)[0] || '';
|
||||
const lpSel = document.getElementById('planActiveLpSelect');
|
||||
if(lpSel){
|
||||
lpSel.innerHTML = '<option value="">Keine</option>' + PLAN_ACTIVE_LPS.map(l => `<option value="${l}" ${l===aktLp?'selected':''}>${l}</option>`).join('');
|
||||
lpSel.disabled = locked;
|
||||
}
|
||||
|
||||
// Fortschritt je Leistungsphase — nur Anzeige, ohne Eingabefeld
|
||||
const lphList = document.getElementById('planLphList');
|
||||
if(lphList){
|
||||
if(!p.lph) p.lph = {};
|
||||
lphList.innerHTML = LPHS.map(l => {
|
||||
const v = (p.lph[l] !== undefined && p.lph[l] !== '') ? p.lph[l] : '';
|
||||
const aktiv = l === aktLp;
|
||||
const pct = v !== '' ? Math.min(100, parseFloat(v)||0) : 0;
|
||||
return `<div class="board-lph-row is-ansicht${aktiv ? ' lp-aktiv' : ''}"${aktiv ? ' title="Amtierende Leistungsphase"' : ''}>
|
||||
<span class="board-lph-label">${esc(l)}</span>
|
||||
<div class="bar"><div style="width:${pct}%;background:${getProgressColor(pct)}"></div></div>
|
||||
<span class="board-lph-wert">${v === '' ? '–' : esc(v) + ' %'}</span>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
setzeBerichtVerweis('planLphVerweis', locked);
|
||||
|
||||
// Beteiligte — nur Anzeige. Nicht zugeordnete Personen erscheinen
|
||||
// weiterhin blass, damit erkennbar bleibt, wer überhaupt zur Auswahl
|
||||
// stünde; anklickbar sind sie hier aber nicht mehr.
|
||||
const assignMatrix = document.getElementById('planAssignMatrix');
|
||||
if(assignMatrix){
|
||||
const grouped = peopleByRole(app.week);
|
||||
if(!p.assign) p.assign = {};
|
||||
assignMatrix.innerHTML = ROLES.map(r => {
|
||||
const entries = grouped[r] || [];
|
||||
const chips = entries.length ? entries.map(entry => {
|
||||
const state = p.assign[assignKey(entry.person.id, r)] || 0;
|
||||
const title = (entry.person.fullName || entry.person.name) + ' · ' + r;
|
||||
return `<span class="board-avatar ${app.planRoleClass(r)} state-${state} is-ansicht" title="${esc(title)}">${esc(entry.person.name)}</span>`;
|
||||
}).join('') : `<span class="hint" style="margin:0">Niemand hinterlegt</span>`;
|
||||
return `<div class="board-role-row"><span class="board-role-label ${app.planRoleClass(r)}">${esc(r)}</span><div class="board-role-chips">${chips}</div></div>`;
|
||||
}).join('');
|
||||
}
|
||||
setzeBerichtVerweis('planAssignVerweis', locked);
|
||||
};
|
||||
|
||||
// setzeBerichtVerweis: füllt einen Hinweis-Platz mit dem Weg zur
|
||||
// Bearbeitung. Der Verweis ist ein echter Sprung: Er wechselt in den
|
||||
// Wochenbericht UND wählt dort dieses Projekt aus — ohne das müsste man es
|
||||
// in einer Liste von dreißig wiederfinden.
|
||||
// Parameter: elementId, locked. Keine Rückgabe.
|
||||
function setzeBerichtVerweis(elementId, locked){
|
||||
const el = document.getElementById(elementId);
|
||||
if(!el) return;
|
||||
if(locked){
|
||||
el.innerHTML = '<span class="hint" style="margin:0">Die Sitzung ist gesperrt.</span>';
|
||||
return;
|
||||
}
|
||||
const uid = esc(app.project && app.project.projectUid ? app.project.projectUid : '');
|
||||
el.innerHTML = `<button type="button" class="plan-verweis" onclick="springZumWochenbericht('${uid}')">
|
||||
Im Wochenbericht bearbeiten →
|
||||
</button>`;
|
||||
}
|
||||
|
||||
// springZumWochenbericht: wechselt in den Wochenbericht und stellt dort
|
||||
// dieses Projekt ein. Beides zusammen, weil ein Sprung, der nur den Bereich
|
||||
// wechselt, die eigentliche Arbeit — das Projekt wiederfinden — beim
|
||||
// Nutzer ließe. Parameter: uid (project_uid). Keine Rückgabe.
|
||||
window.springZumWochenbericht = function(uid){
|
||||
if(typeof showTab !== 'function') return;
|
||||
// Erst die Auswahl setzen, dann umschalten: showTab('wochenbericht')
|
||||
// rendert den Bereich bereits, und der soll gleich das richtige Projekt
|
||||
// zeigen — sonst blitzte kurz das erste Projekt der Liste auf.
|
||||
// berichtAuswahl ist eine globale let-Bindung aus
|
||||
// js/wochenbericht/wochenbericht.js (klassische Skripte, kein Modul).
|
||||
if(uid) berichtAuswahl = uid;
|
||||
showTab('wochenbericht');
|
||||
};
|
||||
|
||||
// setPlanGroup: ändert die Gruppe des Projekts. Parameter: val (String).
|
||||
// Keine Rückgabe; bei gesperrter Woche abgebrochen.
|
||||
window.setPlanGroup = function(val){
|
||||
if(app.isLocked()) return;
|
||||
app.project.group = val;
|
||||
app.savePlan();
|
||||
};
|
||||
|
||||
// setPlanActiveLp: setzt bzw. hebt die amtierende Leistungsphase des
|
||||
// Projekts auf. Parameter: val (LPH-Name oder leer). Rendert das
|
||||
// Infopanel neu, damit die Markierung in der LPH-Liste sofort mitzieht.
|
||||
window.setPlanActiveLp = function(val){
|
||||
if(app.isLocked()) return;
|
||||
if(val) app.project.aktuelleLP = [val];
|
||||
else delete app.project.aktuelleLP;
|
||||
app.savePlan();
|
||||
window.renderPlanInfo();
|
||||
};
|
||||
|
||||
// Die Schreibfunktionen setPlanLph()/setPlanAssign()/planStatusBlur() sind
|
||||
// entfallen. Fortschritt, Zuordnung und Kommentar werden allein im
|
||||
// Wochenbericht erfasst (siehe den Kopf dieser Datei); hier gab es damit
|
||||
// keinen Aufrufer mehr. Die gleichnamigen Funktionen des Projektboards
|
||||
// (editLph()/cycleAssign()/saveBoardComment() in js/board/board.js) sind
|
||||
// davon unberührt — dort hängt die Erfassung des Wochenberichts dran.
|
||||
})();
|
||||
@@ -0,0 +1,284 @@
|
||||
// -----------------------------
|
||||
// Projektbereich — Leistungsstand & Abrechnung
|
||||
// -----------------------------
|
||||
// Die vier Zahlen dieser Rechnung sind alle längst gepflegt, standen aber an
|
||||
// vier verschiedenen Stellen und wurden nirgends zusammengeführt:
|
||||
//
|
||||
// * p.honorar — vereinbartes Netto-Honorar (Bereich
|
||||
// "Einstellungen" bzw. Honorar-Dialog)
|
||||
// * p.beauftragteLP — welche Leistungsphasen beauftragt sind
|
||||
// * lph_honorar_anteile — firmenweite HOAI-Anteile je Phase in Prozent
|
||||
// (GET /honorar-config, Pflege im Admin-Bereich)
|
||||
// * p.lph[<Phase>] — Fortschritt der Phase in Prozent
|
||||
// (Prozentliste im Dashboard, plan-info.js)
|
||||
//
|
||||
// Daraus ergibt sich der ERBRACHTE LEISTUNGSWERT, also der Teil des Honorars,
|
||||
// der bereits abgearbeitet ist:
|
||||
//
|
||||
// Wert je Phase = Honorar × Anteil der Phase ÷ 100 × Fortschritt ÷ 100
|
||||
// Erbracht = Summe über alle beauftragten Phasen
|
||||
//
|
||||
// NORMIERUNG — bewusst identisch zu berechneHonorarPlan() in
|
||||
// js/plan/plan-honorar.js: Sind nur einzelne Phasen beauftragt (etwa LPH 1, 4
|
||||
// und 7 mit zusammen 9 %), dann ist das vereinbarte Honorar das Honorar für
|
||||
// GENAU DIESE Phasen. Ungenormt käme ein zu 100 % abgearbeitetes Projekt nur
|
||||
// auf 9 % Leistungsstand — die restlichen 91 % gehören zu Phasen, die nie
|
||||
// beauftragt waren. Die Anteile werden deshalb auf die beauftragten Phasen
|
||||
// hochskaliert (jeder Anteil ÷ Summe der beauftragten Anteile × 100), genau
|
||||
// wie es der Honorar-Dialog in seiner Voreinstellung tut. Rechneten die beiden
|
||||
// Masken hier unterschiedlich, widersprächen sich Terminplan und Abrechnung
|
||||
// für dasselbe Projekt.
|
||||
//
|
||||
// Diese Anzeige RECHNET nur; sie speichert nichts und legt keine Daten an.
|
||||
// Geändert wird das Honorar im Bereich "Einstellungen" (plan-settings.js) oder
|
||||
// im Honorar-Dialog (plan-honorar.js), die Fortschritte in der
|
||||
// Prozentliste des Dashboards (plan-info.js).
|
||||
//
|
||||
// Läuft wie alle plan-Module in einem IIFE; Zustand: window.PlanApp
|
||||
// (plan-state.js).
|
||||
|
||||
(function(){
|
||||
const app = window.PlanApp;
|
||||
|
||||
// honorarConfig: zuletzt geladene firmenweite Konfiguration
|
||||
// ({ anteile: {LPH->%} }); null = noch nicht geladen. Eigener
|
||||
// Zwischenspeicher, weil der des Honorar-Dialogs (plan-honorar.js) modulweit
|
||||
// privat ist — beide holen dieselbe Antwort, aber je einmal je Seitenaufruf.
|
||||
let honorarConfig = null;
|
||||
|
||||
// ladeLaeuft / ladeFehler: verhindern, dass jeder Dashboard-Aufbau eine
|
||||
// weitere Anfrage auslöst. Ohne das Fehler-Merkmal würde ein ausgefallener
|
||||
// Server bei jedem Bereichswechsel erneut angefragt.
|
||||
let ladeLaeuft = false;
|
||||
let ladeFehler = false;
|
||||
|
||||
// fmtEur / fmtNum: deutsche Zahlenformate, gleiche Schreibweise wie in der
|
||||
// Tabelle des Honorar-Dialogs, damit dieselbe Zahl in beiden Masken gleich
|
||||
// aussieht.
|
||||
function fmtEur(v){ return (Math.round(v * 100) / 100).toLocaleString('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + ' €'; }
|
||||
function fmtNum(v, digits){ return Number(v).toLocaleString('de-DE', { minimumFractionDigits: digits || 0, maximumFractionDigits: digits || 0 }); }
|
||||
|
||||
// beauftragteLphs: die beauftragten Leistungsphasen in der Reihenfolge von
|
||||
// LPHS (js/globals.js), nicht in Eingabereihenfolge — die Aufschlüsselung
|
||||
// soll immer chronologisch aufsteigen. Rückgabe: Array von LPH-Schlüsseln.
|
||||
function beauftragteLphs(){
|
||||
const set = new Set(Array.isArray(app.project.beauftragteLP) ? app.project.beauftragteLP : []);
|
||||
return LPHS.filter(l => set.has(l));
|
||||
}
|
||||
|
||||
// fortschrittProzent: liest den Fortschritt einer Phase aus p.lph. Die Werte
|
||||
// stammen aus einem Textfeld (setPlanLph() in plan-info.js speichert den
|
||||
// getippten String), kommen also auch mal als "80 %" oder "80,5" an; ein
|
||||
// fehlender Eintrag bedeutet "noch nicht begonnen", nicht "unbekannt".
|
||||
// Über 100 wird abgeschnitten, damit ein Tippfehler nicht mehr Honorar
|
||||
// erbracht erscheinen lässt als vereinbart. Rückgabe: Number 0–100.
|
||||
function fortschrittProzent(lph){
|
||||
const lphWerte = app.project.lph || {};
|
||||
const roh = lphWerte[lph];
|
||||
if(roh === undefined || roh === null || roh === '') return 0;
|
||||
const v = parseFloat(String(roh).replace('%', '').replace(',', '.'));
|
||||
if(!Number.isFinite(v) || v <= 0) return 0;
|
||||
return Math.min(100, v);
|
||||
}
|
||||
|
||||
// berechneLeistungsstand: führt die Rechnung aus und liefert je beauftragter
|
||||
// Leistungsphase eine Zeile mit allen Zwischenwerten — jede Stufe steht
|
||||
// später als Tabellenspalte in der Maske, damit die Summe nachvollziehbar
|
||||
// bleibt und nicht aus einer Blackbox kommt.
|
||||
// Keine Parameter (rechnet auf app.project und honorarConfig).
|
||||
// Rückgabe: { honorar, zeilen, erbracht, prozent, offen, summeBeauftragt,
|
||||
// problem: String|null }.
|
||||
function berechneLeistungsstand(){
|
||||
const leer = { honorar: 0, zeilen: [], erbracht: 0, prozent: 0, offen: 0, summeBeauftragt: 0 };
|
||||
const honorar = Number(app.project.honorar) || 0;
|
||||
const phasen = beauftragteLphs();
|
||||
|
||||
if(honorar <= 0){
|
||||
return Object.assign({}, leer, { problem: 'Für dieses Projekt ist kein Honorar hinterlegt — der erbrachte Leistungswert lässt sich daraus nicht errechnen. Das vereinbarte Netto-Honorar steht im Bereich „Einstellungen“.' });
|
||||
}
|
||||
if(phasen.length === 0){
|
||||
return Object.assign({}, leer, { honorar, problem: 'Für dieses Projekt ist keine Leistungsphase als beauftragt angekreuzt. Ohne beauftragte Phasen ist nicht bestimmt, welcher Anteil des Honorars worauf entfällt. Die Auswahl steht im Bereich „Einstellungen“.' });
|
||||
}
|
||||
|
||||
const anteile = (honorarConfig && honorarConfig.anteile) || {};
|
||||
const summeBeauftragt = phasen.reduce((s, l) => s + (Number(anteile[l]) || 0), 0);
|
||||
if(summeBeauftragt <= 0){
|
||||
return Object.assign({}, leer, { honorar, problem: 'Für die beauftragten Leistungsphasen sind keine Honorar-Anteile hinterlegt. Ein Administrator pflegt sie im Admin-Bereich unter „Honorar“.' });
|
||||
}
|
||||
|
||||
let erbracht = 0;
|
||||
const zeilen = phasen.map(lph => {
|
||||
const rohAnteil = Number(anteile[lph]) || 0;
|
||||
// Normierung auf die beauftragten Phasen — siehe Kopfkommentar.
|
||||
const anteil = rohAnteil / summeBeauftragt * 100;
|
||||
const phasenHonorar = honorar * anteil / 100;
|
||||
const fortschritt = fortschrittProzent(lph);
|
||||
const wert = phasenHonorar * fortschritt / 100;
|
||||
erbracht += wert;
|
||||
return { lph, rohAnteil, anteil, phasenHonorar, fortschritt, wert };
|
||||
});
|
||||
|
||||
return {
|
||||
honorar,
|
||||
zeilen,
|
||||
erbracht,
|
||||
prozent: honorar > 0 ? erbracht / honorar * 100 : 0,
|
||||
offen: honorar - erbracht,
|
||||
summeBeauftragt,
|
||||
problem: null,
|
||||
};
|
||||
}
|
||||
|
||||
// ladeHonorarConfig: holt die firmenweiten Anteile und rendert danach neu.
|
||||
// Nur die Anteile werden gebraucht — der Stundensatz gehört zur
|
||||
// Terminplan-Rechnung, nicht zur Abrechnung.
|
||||
function ladeHonorarConfig(){
|
||||
ladeLaeuft = true;
|
||||
fetch(baseUrl() + '/honorar-config')
|
||||
.then(r => { if(!r.ok) throw new Error('Server HTTP ' + r.status); return r.json(); })
|
||||
.then(data => {
|
||||
honorarConfig = { anteile: data.anteile || {} };
|
||||
ladeFehler = false;
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('Honorar-Anteile für den Leistungsstand laden fehlgeschlagen:', err);
|
||||
ladeFehler = true;
|
||||
})
|
||||
.then(() => {
|
||||
ladeLaeuft = false;
|
||||
window.renderPlanLeistung();
|
||||
});
|
||||
}
|
||||
|
||||
// retryLeistungConfig: erneuter Versuch nach einem Ladefehler. Ohne diese
|
||||
// Schaltfläche bliebe die Karte bis zum nächsten Seitenaufbau leer, denn
|
||||
// ladeFehler unterdrückt absichtlich jede automatische Wiederholung.
|
||||
window.retryLeistungConfig = function(){
|
||||
ladeFehler = false;
|
||||
window.renderPlanLeistung();
|
||||
};
|
||||
|
||||
// schreibeInZiele: legt dieselbe Maske in JEDES Anzeigefeld, das sie haben
|
||||
// will — das ist #planLeistungWrap (die Karte im Dashboard) und jedes
|
||||
// Element mit der Klasse .plan-leistung-wrap. Absichtlich nicht auf eine
|
||||
// einzige feste ID festgelegt: ob der Leistungsstand als Karte im Dashboard
|
||||
// oder als eigener Unterbereich der .plan-nav steht, ist eine Frage des
|
||||
// Markups, und diese Rechnung soll dafür nicht angefasst werden müssen.
|
||||
// Parameter: html (String). Rückgabe: Anzahl der befüllten Felder.
|
||||
function schreibeInZiele(html){
|
||||
const ziele = document.querySelectorAll('#planLeistungWrap, .plan-leistung-wrap');
|
||||
ziele.forEach(el => { el.innerHTML = html; });
|
||||
return ziele.length;
|
||||
}
|
||||
|
||||
// renderPlanLeistung: baut die Maske "Leistungsstand & Abrechnung" neu auf.
|
||||
// Fehlt eine der Voraussetzungen (Honorar, beauftragte Phasen, Anteile),
|
||||
// steht dort ein Hinweis, WAS fehlt — eine Summe von 0 € wäre eine
|
||||
// Falschaussage, weil unbekannt nicht dasselbe ist wie nichts erbracht.
|
||||
// Keine Parameter, keine Rückgabe.
|
||||
window.renderPlanLeistung = function(){
|
||||
if(!app.project) return;
|
||||
if(document.querySelectorAll('#planLeistungWrap, .plan-leistung-wrap').length === 0) return;
|
||||
|
||||
if(ladeFehler){
|
||||
schreibeInZiele('<div class="hint">Die firmenweiten Honorar-Anteile konnten nicht geladen werden. '
|
||||
+ '<button type="button" class="secondary" onclick="retryLeistungConfig()">Erneut versuchen</button></div>');
|
||||
return;
|
||||
}
|
||||
if(!honorarConfig){
|
||||
schreibeInZiele('<div class="hint">Honorar-Anteile werden geladen…</div>');
|
||||
if(!ladeLaeuft) ladeHonorarConfig();
|
||||
return;
|
||||
}
|
||||
|
||||
const s = berechneLeistungsstand();
|
||||
if(s.problem){
|
||||
schreibeInZiele('<div class="leistung-leer">' + esc(s.problem) + '</div>');
|
||||
return;
|
||||
}
|
||||
|
||||
const rows = s.zeilen.map(z => `<tr>
|
||||
<th class="lp-lph">${esc(z.lph)}</th>
|
||||
<td class="num">${esc(fmtNum(z.anteil, 1))} %</td>
|
||||
<td class="num">${esc(fmtEur(z.phasenHonorar))}</td>
|
||||
<td class="num">${esc(fmtNum(z.fortschritt, 0))} %</td>
|
||||
<td class="num strong">${esc(fmtEur(z.wert))}</td>
|
||||
</tr>`).join('');
|
||||
|
||||
// Der Balken zeigt denselben Prozentwert wie die Kennzahl daneben; der
|
||||
// Anschlag bei 100 % ist nur Schutz gegen Rundungsreste.
|
||||
const balken = Math.max(0, Math.min(100, s.prozent));
|
||||
|
||||
schreibeInZiele(`<div class="leistung-kpis">
|
||||
<div class="leistung-kpi">
|
||||
<span class="leistung-kpi-label">Erbrachte Leistung</span>
|
||||
<span class="leistung-kpi-value accent">${esc(fmtEur(s.erbracht))}</span>
|
||||
<span class="leistung-kpi-sub">${esc(fmtNum(s.prozent, 1))} % des Honorars</span>
|
||||
</div>
|
||||
<div class="leistung-kpi">
|
||||
<span class="leistung-kpi-label">Noch nicht erbracht</span>
|
||||
<span class="leistung-kpi-value">${esc(fmtEur(s.offen))}</span>
|
||||
<span class="leistung-kpi-sub">${esc(fmtNum(100 - s.prozent, 1))} % des Honorars</span>
|
||||
</div>
|
||||
<div class="leistung-kpi">
|
||||
<span class="leistung-kpi-label">Honorar (netto)</span>
|
||||
<span class="leistung-kpi-value">${esc(fmtEur(s.honorar))}</span>
|
||||
<span class="leistung-kpi-sub">${esc(fmtNum(s.zeilen.length, 0))} beauftragte Leistungsphase${s.zeilen.length === 1 ? '' : 'n'}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="leistung-balken" role="img"
|
||||
aria-label="Erbrachte Leistung ${esc(fmtNum(s.prozent, 1))} Prozent des Honorars">
|
||||
<span style="width:${balken.toFixed(2)}%"></span>
|
||||
</div>
|
||||
<table class="lp-matrix leistung-table">
|
||||
<thead><tr>
|
||||
<th>Phase</th><th class="num">Anteil</th><th class="num">Phasenhonorar</th>
|
||||
<th class="num">Fortschritt</th><th class="num">Erbracht</th>
|
||||
</tr></thead>
|
||||
<tbody>${rows}</tbody>
|
||||
<tfoot><tr>
|
||||
<th class="lp-lph">Summe</th>
|
||||
<td class="num">${esc(fmtNum(s.zeilen.reduce((a, z) => a + z.anteil, 0), 1))} %</td>
|
||||
<td class="num">${esc(fmtEur(s.honorar))}</td>
|
||||
<td class="num">${esc(fmtNum(s.prozent, 1))} %</td>
|
||||
<td class="num strong">${esc(fmtEur(s.erbracht))}</td>
|
||||
</tr></tfoot>
|
||||
</table>
|
||||
<div class="hint">Anteile auf die ${esc(fmtNum(s.zeilen.length, 0))} beauftragten Phasen normiert
|
||||
(Rohsumme der HOAI-Anteile ${esc(fmtNum(s.summeBeauftragt, 1))} % → 100 %), wie in der
|
||||
Voreinstellung von „Terminplan aus Honorar“. Fortschritt ohne Eintrag zählt als 0 %.
|
||||
Netto-Werte, keine Rechnungsstellung.</div>`);
|
||||
};
|
||||
|
||||
// Auslöser: renderPlanDashboard() (plan-sections.js) baut die übrigen Karten
|
||||
// desselben Bereichs. Hier umgehängt statt dort hineingeschrieben, damit
|
||||
// dieses Modul für sich steht — und weil derselbe Aufruf alle Wege abdeckt:
|
||||
// Seitenaufbau (renderPlanPage), Wechsel in den Dashboard-Bereich
|
||||
// (showPlanSection) und jede Änderung am Plan (renderPlanGantt ruft am Ende
|
||||
// renderPlanDashboard). Honorar und beauftragte Phasen werden in einem
|
||||
// ANDEREN Bereich bearbeitet; sichtbar wird die Karte also ohnehin erst
|
||||
// beim Rücksprung ins Dashboard — und der rendert neu. Ein zusätzlicher
|
||||
// ausdrücklicher Aufruf (etwa für einen eigenen Unterbereich "leistung")
|
||||
// stört nicht: renderPlanLeistung() rechnet nur und schreibt Markup, ein
|
||||
// doppelter Aufruf ergibt genau dasselbe Bild.
|
||||
const originalRenderPlanDashboard = window.renderPlanDashboard;
|
||||
window.renderPlanDashboard = function(){
|
||||
if(typeof originalRenderPlanDashboard === 'function') originalRenderPlanDashboard.apply(this, arguments);
|
||||
window.renderPlanLeistung();
|
||||
};
|
||||
|
||||
// Ändert ein Administrator die Anteile, verwirft plan-honorar.js seinen
|
||||
// Zwischenspeicher über invalidateHonorarConfig(). Derselbe Ruf muss auch
|
||||
// hier ankommen, sonst rechnet diese Karte den Rest der Sitzung mit den
|
||||
// alten Anteilen weiter.
|
||||
const originalInvalidate = window.invalidateHonorarConfig;
|
||||
window.invalidateHonorarConfig = function(){
|
||||
if(typeof originalInvalidate === 'function') originalInvalidate.apply(this, arguments);
|
||||
honorarConfig = null;
|
||||
ladeFehler = false;
|
||||
};
|
||||
|
||||
// Für Nachbarmodule und zum Nachrechnen von außen erreichbar halten — die
|
||||
// Zahl gehört fachlich zum Projekt, nicht zu dieser einen Karte.
|
||||
app.berechneLeistungsstand = berechneLeistungsstand;
|
||||
})();
|
||||
+494
-100
@@ -3,12 +3,23 @@
|
||||
// -----------------------------
|
||||
// Dieses Modul stellt die Öffnen-/Schließen-/Speichern-/Löschen-Funktionen
|
||||
// der drei Dialoge der Projektplan-Seite bereit: Meilenstein, TODO und
|
||||
// Abwesenheiten. Der Bearbeitungszustand liegt in window.PlanApp
|
||||
// Abwesenheiten. Der TODO-Dialog fasst "Erledigt" und Fortschritt in einem
|
||||
// Status-Segment zusammen (offen/arbeit/fertig, siehe todoStatusFrom() —
|
||||
// gespeichert wird unverändert done + progress), zeigt die Zuständigen als
|
||||
// Personen-Chips, klappt die Vorgänger-Liste mit Anzahl und Suche ein und
|
||||
// sperrt darin Einträge, die einen Zirkelbezug erzeugen würden
|
||||
// (cyclicDependencyIds()). Validierungshinweise stehen am Feld statt in einem
|
||||
// alert(). Esc, Strg/Cmd+Enter und der Klick auf den Hintergrund sind am Ende
|
||||
// der Datei einmal für alle drei Dialoge registriert (MODAL_HANDLERS). Der Bearbeitungszustand liegt in window.PlanApp
|
||||
// (plan-state.js): currentMilestoneIndex, currentTodoIndex und
|
||||
// currentAbsencePersonId. Alle Änderungen werden über app.savePlan bzw.
|
||||
// app.saveGlobalPeople gespeichert und das GANTT über renderPlanGantt
|
||||
// (plan-gantt.js) neu gezeichnet. Bei gesperrten Wochen (app.isLocked) sind
|
||||
// alle Bearbeitungsfunktionen deaktiviert.
|
||||
// currentAbsencePersonId. Meilenstein-/TODO-Änderungen werden über
|
||||
// app.savePlan gespeichert; Abwesenheiten haben seit der Ablösung von
|
||||
// people/person_roles/person_absences keinen /save-people-Schreibpfad mehr
|
||||
// und werden direkt gegen POST/DELETE /users/:id/absences geschrieben (die
|
||||
// numerische User-ID kommt aus app.getUserIdForPersonId(), siehe
|
||||
// plan-state.js). Das GANTT wird danach über renderPlanGantt() (plan-gantt.js)
|
||||
// neu gezeichnet. Bei gesperrten Wochen (app.isLocked) sind alle
|
||||
// Bearbeitungsfunktionen deaktiviert.
|
||||
//
|
||||
// Läuft wie alle plan-Module in einem IIFE; Zustand: window.PlanApp
|
||||
// (plan-state.js).
|
||||
@@ -33,7 +44,12 @@
|
||||
const isNew = index === null || plan.milestones[index] === undefined;
|
||||
const currentMs = isNew ? null : plan.milestones[index];
|
||||
document.getElementById('milestoneEditName').value = isNew ? '' : (currentMs.name || '');
|
||||
document.getElementById('milestoneEditDate').value = isNew ? '' : (currentMs.date || '');
|
||||
// Verstecktes ISO-Feld + sichtbares Anzeigefeld (deutsches Format) —
|
||||
// dieselbe Aufteilung wie bei allen anderen Datumseingaben der Seite.
|
||||
const msDateIso = isNew ? '' : (currentMs.date || '');
|
||||
document.getElementById('milestoneEditDate').value = msDateIso;
|
||||
const msDateDisplay = document.getElementById('milestoneEditDateDisplay');
|
||||
if(msDateDisplay) msDateDisplay.value = msDateIso ? formatDateDE(parseDateISO(msDateIso)) : '';
|
||||
|
||||
const positionSelect = document.getElementById('milestoneEditPosition');
|
||||
if(positionSelect){
|
||||
@@ -60,13 +76,19 @@
|
||||
let listHtml = '';
|
||||
|
||||
// Andere Meilensteine als Vorgänger anbieten
|
||||
if(plan.milestones.length > 1){
|
||||
// Zirkelbezüge sperren — dieselbe Prüfung wie im Aufgaben-Dialog.
|
||||
// Ohne sie ließ sich hier ein Kreis schließen (A hängt an M, M an A),
|
||||
// woraufhin Auto-Schedule und kritischer Pfad dauerhaft aufgaben.
|
||||
const msSelfId = currentMs ? 'm_' + currentMs.id : null;
|
||||
const msBlocked = msSelfId ? cyclicDependencyIds(plan, msSelfId) : new Set();
|
||||
if(plan.milestones.length >= 1){
|
||||
listHtml += '<div class="todo-depends-group"><b>Meilensteine</b></div>';
|
||||
plan.milestones.forEach(m => {
|
||||
if(currentMs && m.id === currentMs.id) return;
|
||||
const mId = 'm_' + m.id;
|
||||
const checked = currentDeps.includes(mId) ? 'checked' : '';
|
||||
listHtml += `<label class="todo-depends-item"><input type="checkbox" value="${esc(mId)}" ${checked}> 🚩 ${esc(m.name || 'Meilenstein')} (${formatDateDE(parseDateISO(m.date))})</label>`;
|
||||
const sperre = msBlocked.has(mId) ? ' disabled title="Würde einen Zirkelbezug erzeugen"' : '';
|
||||
listHtml += `<label class="todo-depends-item${msBlocked.has(mId) ? ' is-blocked' : ''}"><input type="checkbox" value="${esc(mId)}" ${checked}${sperre}> ${icon('flag')} ${esc(m.name || 'Meilenstein')} (${formatDateDE(parseDateISO(m.date))})</label>`;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -76,7 +98,8 @@
|
||||
(plan.todos || []).forEach(t => {
|
||||
const checked = currentDeps.includes(t.id) ? 'checked' : '';
|
||||
const datesStr = (t.start || t.end) ? ` (${formatDateDE(parseDateISO(t.start))}–${formatDateDE(parseDateISO(t.end))})` : '';
|
||||
listHtml += `<label class="todo-depends-item"><input type="checkbox" value="${esc(t.id)}" ${checked}> ${esc(t.title || 'TODO')}${datesStr}</label>`;
|
||||
const sperre = msBlocked.has(t.id) ? ' disabled title="Würde einen Zirkelbezug erzeugen"' : '';
|
||||
listHtml += `<label class="todo-depends-item${msBlocked.has(t.id) ? ' is-blocked' : ''}"><input type="checkbox" value="${esc(t.id)}" ${checked}${sperre}> ${esc(t.title || 'TODO')}${datesStr}</label>`;
|
||||
});
|
||||
}
|
||||
if(listHtml === '') listHtml = '<span class="hint">Keine anderen Aufgaben oder Meilensteine vorhanden.</span>';
|
||||
@@ -153,11 +176,195 @@
|
||||
|
||||
// ---------- TODO-Modal ----------
|
||||
|
||||
// TODO_STATUS_VALUES: die drei Zustände des Status-Segments. Sie ersetzen im
|
||||
// Dialog das frühere Paar aus "Erledigt"-Haken und Fortschrittsregler, das
|
||||
// denselben Sachverhalt zweimal abgebildet hat (und dabei auseinanderlaufen
|
||||
// konnte). Gespeichert wird unverändert das bisherige Datenmodell aus
|
||||
// done (Boolean) + progress (0–100) — siehe todoStatusFrom()/applyTodoStatus().
|
||||
const TODO_STATUS_VALUES = ['offen', 'arbeit', 'fertig'];
|
||||
|
||||
// todoModalSnapshot: Stand der Eingaben beim Öffnen — Vergleichsbasis für
|
||||
// die Rückfrage beim Abbrechen (siehe todoFormSnapshot()).
|
||||
let todoModalSnapshot = '';
|
||||
|
||||
// todoProgressMemo: zuletzt im Dialog eingestellter Fortschritt. "Offen" und
|
||||
// "Erledigt" haben feste Werte (0/100); wer von dort zurück auf "In Arbeit"
|
||||
// wechselt, bekommt darüber seinen vorherigen Wert wieder statt eines
|
||||
// Ersatzwerts. Gilt nur für den geöffneten Dialog.
|
||||
let todoProgressMemo = 50;
|
||||
|
||||
// todoStatusFrom: leitet den Segment-Status aus dem gespeicherten TODO ab.
|
||||
// done gewinnt immer; sonst entscheidet der Fortschritt (0 % = offen,
|
||||
// 100 % = fertig, dazwischen = in Arbeit). Parameter: todo (Objekt oder
|
||||
// null für eine neue Aufgabe). Rückgabe: String aus TODO_STATUS_VALUES.
|
||||
function todoStatusFrom(todo){
|
||||
if(!todo) return 'offen';
|
||||
if(todo.done) return 'fertig';
|
||||
const progress = parseInt(todo.progress, 10) || 0;
|
||||
if(progress >= 100) return 'fertig';
|
||||
return progress > 0 ? 'arbeit' : 'offen';
|
||||
}
|
||||
|
||||
// applyTodoStatus: schreibt den Status in das Segment und blendet den
|
||||
// Fortschrittsregler nur im Zustand "In Arbeit" ein (offen = 0 %,
|
||||
// erledigt = 100 % sind implizit). Parameter: status (String),
|
||||
// progress (optional, nur für "arbeit" relevant). Rückgabe: keine.
|
||||
function applyTodoStatus(status, progress){
|
||||
const segment = document.getElementById('todoEditStatus');
|
||||
if(segment){
|
||||
segment.querySelectorAll('button[data-status]').forEach(btn => {
|
||||
btn.setAttribute('aria-pressed', String(btn.dataset.status === status));
|
||||
});
|
||||
}
|
||||
const row = document.getElementById('todoEditProgressRow');
|
||||
if(row) row.hidden = status !== 'arbeit';
|
||||
|
||||
const slider = document.getElementById('todoEditProgress');
|
||||
const label = document.getElementById('todoEditProgressVal');
|
||||
if(slider){
|
||||
let value;
|
||||
if(status === 'offen') value = 0;
|
||||
else if(status === 'fertig') value = 100;
|
||||
else {
|
||||
value = progress === undefined ? todoProgressMemo : progress;
|
||||
// "In Arbeit" mit 0 % oder 100 % wäre widersprüchlich
|
||||
if(value <= 0) value = 50;
|
||||
if(value >= 100) value = 95;
|
||||
todoProgressMemo = value;
|
||||
}
|
||||
slider.value = value;
|
||||
if(label) label.textContent = value + ' %';
|
||||
}
|
||||
}
|
||||
|
||||
// setTodoEditStatus: Klick-Handler des Status-Segments im TODO-Modal.
|
||||
// Parameter: status (String aus TODO_STATUS_VALUES). Rückgabe: keine.
|
||||
window.setTodoEditStatus = function(status){
|
||||
if(!TODO_STATUS_VALUES.includes(status)) return;
|
||||
applyTodoStatus(status);
|
||||
};
|
||||
|
||||
// onTodoProgressInput: Eingabe-Handler des Fortschrittsreglers. Schreibt den
|
||||
// Wert neben den Regler und merkt ihn für einen späteren Rücksprung auf
|
||||
// "In Arbeit". Parameter: slider (das Regler-Element). Rückgabe: keine.
|
||||
window.onTodoProgressInput = function(slider){
|
||||
const value = parseInt(slider.value, 10) || 0;
|
||||
todoProgressMemo = value;
|
||||
const label = document.getElementById('todoEditProgressVal');
|
||||
if(label) label.textContent = value + ' %';
|
||||
};
|
||||
|
||||
// currentTodoEditStatus: liest den aktuell gewählten Segment-Status aus dem
|
||||
// DOM. Parameter: keine. Rückgabe: String aus TODO_STATUS_VALUES.
|
||||
function currentTodoEditStatus(){
|
||||
const pressed = document.querySelector('#todoEditStatus button[aria-pressed="true"]');
|
||||
return pressed ? pressed.dataset.status : 'offen';
|
||||
}
|
||||
|
||||
// refreshTodoDuration: aktualisiert das Dauer-Badge neben dem Zeitraum-Feld
|
||||
// (Arbeitstage Mo–Fr über workdaysBetween(), js/utils.js) und wird als
|
||||
// onApply-Callback des Bereichspickers aufgerufen. Parameter: keine.
|
||||
// Rückgabe: keine.
|
||||
window.refreshTodoDuration = function(){
|
||||
const badge = document.getElementById('todoEditDuration');
|
||||
if(!badge) return;
|
||||
const start = parseDateISO(document.getElementById('todoEditStart').value);
|
||||
const end = parseDateISO(document.getElementById('todoEditEnd').value);
|
||||
const days = (start && end) ? workdaysBetween(start, end) : 0;
|
||||
badge.hidden = days <= 0;
|
||||
badge.textContent = days > 0 ? (days + ' AT') : '';
|
||||
clearFieldError('todoEditRange');
|
||||
};
|
||||
|
||||
// setFieldError / clearFieldError: zeigen einen Validierungshinweis direkt
|
||||
// am betroffenen Feld statt in einem alert(), damit die übrigen Eingaben
|
||||
// sichtbar bleiben. Erwartet ein <span class="field-error" id="<feldId>Error">
|
||||
// im Markup. Parameter: fieldId (String), message (String).
|
||||
function setFieldError(fieldId, message){
|
||||
const field = document.getElementById(fieldId);
|
||||
const out = document.getElementById(fieldId + 'Error');
|
||||
if(field) field.classList.add('is-error');
|
||||
if(out){ out.textContent = message; out.hidden = false; }
|
||||
}
|
||||
function clearFieldError(fieldId){
|
||||
const field = document.getElementById(fieldId);
|
||||
const out = document.getElementById(fieldId + 'Error');
|
||||
if(field) field.classList.remove('is-error');
|
||||
if(out){ out.textContent = ''; out.hidden = true; }
|
||||
}
|
||||
|
||||
// cyclicDependencyIds: liefert alle Knoten-IDs, die den angegebenen Knoten
|
||||
// direkt oder über eine Kette als Vorgänger haben — genau diese dürfen
|
||||
// nicht ihrerseits sein Vorgänger werden, weil daraus ein Zirkelbezug
|
||||
// entstünde. Ermittelt per Breitensuche über den umgekehrten
|
||||
// Abhängigkeitsgraphen (Kante: Vorgänger → Nachfolger). Zyklen, die schon
|
||||
// in den Daten stecken, sind unproblematisch: besuchte Knoten werden nur
|
||||
// einmal verfolgt. Meilenstein-IDs tragen wie überall das Präfix "m_".
|
||||
// Parameter: plan (Plan-Objekt), nodeId (String oder null). Rückgabe: Set.
|
||||
function cyclicDependencyIds(plan, nodeId){
|
||||
const blocked = new Set();
|
||||
if(!nodeId) return blocked;
|
||||
|
||||
// Nachfolger-Liste aufbauen: successors[Vorgänger] = [Nachfolger, …]
|
||||
const successors = new Map();
|
||||
const addEdge = (from, to) => {
|
||||
if(!successors.has(from)) successors.set(from, []);
|
||||
successors.get(from).push(to);
|
||||
};
|
||||
(plan.todos || []).forEach(t => {
|
||||
(Array.isArray(t.dependsOn) ? t.dependsOn : []).forEach(dep => addEdge(dep, t.id));
|
||||
});
|
||||
(plan.milestones || []).forEach(m => {
|
||||
(Array.isArray(m.dependsOn) ? m.dependsOn : []).forEach(dep => addEdge(dep, 'm_' + m.id));
|
||||
});
|
||||
|
||||
const queue = [nodeId];
|
||||
while(queue.length){
|
||||
const current = queue.shift();
|
||||
(successors.get(current) || []).forEach(next => {
|
||||
if(blocked.has(next)) return;
|
||||
blocked.add(next);
|
||||
queue.push(next);
|
||||
});
|
||||
}
|
||||
return blocked;
|
||||
}
|
||||
|
||||
// refreshTodoDependsCount: schreibt die Anzahl gewählter Vorgänger in den
|
||||
// Kopf des eingeklappten Bereichs, damit sie auch zugeklappt sichtbar ist.
|
||||
// Parameter: keine. Rückgabe: keine.
|
||||
window.refreshTodoDependsCount = function(){
|
||||
const badge = document.getElementById('todoEditDependsCount');
|
||||
const list = document.getElementById('todoEditDependsOn');
|
||||
if(!badge || !list) return;
|
||||
const count = list.querySelectorAll('input[type="checkbox"]:checked').length;
|
||||
badge.textContent = count;
|
||||
badge.classList.toggle('zero', count === 0);
|
||||
};
|
||||
|
||||
// filterTodoDepends: blendet in der Vorgänger-Liste alle Einträge aus, die
|
||||
// nicht zum Suchbegriff passen. Bei aktiver Suche verschwinden auch die
|
||||
// Gruppenüberschriften, weil sie dann keine Gliederung mehr leisten.
|
||||
// Parameter: keine (liest #todoEditDependsSearch). Rückgabe: keine.
|
||||
window.filterTodoDepends = function(){
|
||||
const input = document.getElementById('todoEditDependsSearch');
|
||||
const list = document.getElementById('todoEditDependsOn');
|
||||
if(!input || !list) return;
|
||||
const query = input.value.trim().toLowerCase();
|
||||
list.querySelectorAll('.todo-depends-item').forEach(item => {
|
||||
const match = !query || item.textContent.toLowerCase().includes(query);
|
||||
item.hidden = !match;
|
||||
});
|
||||
list.querySelectorAll('.todo-depends-group').forEach(group => { group.hidden = !!query; });
|
||||
};
|
||||
|
||||
// openTodoModal: öffnet das TODO-Modal. Parameter: index (Index der Aufgabe
|
||||
// oder null für eine neue). Befüllt Titel, Start/Ende, Erledigt-Status,
|
||||
// Fortschritt, Vorgänger-Checkboxen und die aus dem Projektboard
|
||||
// stammenden Bearbeiter-Auswahl.
|
||||
window.openTodoModal = function(index){
|
||||
// oder null für eine neue), prefill (optional, Vorbelegung für "Speichern &
|
||||
// Neu": { start, end, assignees }). Befüllt Titel, Zeitraum samt sichtbarer
|
||||
// Anzeige und Dauer-Badge, Status-Segment, Zuständigen-Chips und die
|
||||
// Vorgänger-Liste. "Löschen" erscheint nur beim Bearbeiten, nicht beim
|
||||
// Anlegen.
|
||||
window.openTodoModal = function(index, prefill){
|
||||
if(app.isLocked()) return;
|
||||
const plan = app.ensurePlanLocal();
|
||||
if(!Array.isArray(plan.todos)) plan.todos = [];
|
||||
@@ -165,18 +372,41 @@
|
||||
|
||||
const isNew = index === null || plan.todos[index] === undefined;
|
||||
const currentTodo = !isNew ? plan.todos[index] : null;
|
||||
const pre = prefill || {};
|
||||
|
||||
clearFieldError('todoEditTitle');
|
||||
clearFieldError('todoEditRange');
|
||||
|
||||
const titleEl = document.getElementById('todoModalTitle');
|
||||
if(titleEl) titleEl.textContent = isNew ? 'Neue Aufgabe' : 'Aufgabe bearbeiten';
|
||||
|
||||
document.getElementById('todoEditTitle').value = isNew ? '' : (currentTodo.title || '');
|
||||
document.getElementById('todoEditStart').value = isNew ? '' : (currentTodo.start || '');
|
||||
document.getElementById('todoEditEnd').value = isNew ? '' : (currentTodo.end || '');
|
||||
document.getElementById('todoEditDone').checked = isNew ? false : !!currentTodo.done;
|
||||
const progress = isNew ? 0 : (parseInt(currentTodo.progress, 10) || 0);
|
||||
document.getElementById('todoEditProgress').value = progress;
|
||||
document.getElementById('todoEditProgressVal').textContent = progress + '%';
|
||||
|
||||
// Zeitraum: neben den versteckten ISO-Feldern muss auch die sichtbare
|
||||
// Anzeige gesetzt werden — sonst stünde dort der Platzhalter bzw. der
|
||||
// Zeitraum der zuvor geöffneten Aufgabe.
|
||||
const start = isNew ? (pre.start || '') : (currentTodo.start || '');
|
||||
const end = isNew ? (pre.end || '') : (currentTodo.end || '');
|
||||
document.getElementById('todoEditStart').value = start;
|
||||
document.getElementById('todoEditEnd').value = end;
|
||||
const rangeDisplay = document.getElementById('todoEditRange');
|
||||
if(rangeDisplay){
|
||||
const from = formatDateDE(parseDateISO(start));
|
||||
const to = formatDateDE(parseDateISO(end)) || from;
|
||||
rangeDisplay.value = from ? (from + ' – ' + to) : '';
|
||||
}
|
||||
refreshTodoDuration();
|
||||
|
||||
const storedProgress = isNew ? 0 : (parseInt(currentTodo.progress, 10) || 0);
|
||||
todoProgressMemo = (storedProgress > 0 && storedProgress < 100) ? storedProgress : 50;
|
||||
applyTodoStatus(todoStatusFrom(currentTodo), storedProgress);
|
||||
|
||||
const dependsOnList = document.getElementById('todoEditDependsOn');
|
||||
if(dependsOnList){
|
||||
const currentDeps = currentTodo && Array.isArray(currentTodo.dependsOn) ? currentTodo.dependsOn : [];
|
||||
// Vorgänger, die einen Zirkelbezug erzeugen würden, gar nicht erst
|
||||
// anbieten — bisher fiel das erst beim Auto-Terminieren auf.
|
||||
const blocked = cyclicDependencyIds(plan, currentTodo ? currentTodo.id : null);
|
||||
let listHtml = '';
|
||||
|
||||
// Meilensteine als Vorgänger anbieten
|
||||
@@ -185,28 +415,40 @@
|
||||
plan.milestones.forEach(m => {
|
||||
const mId = 'm_' + m.id;
|
||||
const checked = currentDeps.includes(mId) ? 'checked' : '';
|
||||
listHtml += `<label class="todo-depends-item"><input type="checkbox" value="${esc(mId)}" ${checked}> 🚩 ${esc(m.name || 'Meilenstein')} (${formatDateDE(parseDateISO(m.date))})</label>`;
|
||||
listHtml += dependsItemHtml(mId, `${icon('flag')} ${esc(m.name || 'Meilenstein')} (${formatDateDE(parseDateISO(m.date))})`, checked, blocked.has(mId));
|
||||
});
|
||||
}
|
||||
|
||||
// Andere TODOs als Vorgänger anbieten
|
||||
if(plan.todos && plan.todos.length > 1){
|
||||
// >= 1 statt > 1: Bei einer NEUEN Aufgabe wird aus der Liste nichts
|
||||
// herausgefiltert, die einzige vorhandene Aufgabe war damit nicht als
|
||||
// Vorgänger wählbar (erst ab der dritten funktionierte die Auswahl).
|
||||
if(plan.todos && plan.todos.length >= 1){
|
||||
listHtml += '<div class="todo-depends-group"><b>Aufgaben</b></div>';
|
||||
(plan.todos || []).forEach(t => {
|
||||
if(currentTodo && t.id === currentTodo.id) return;
|
||||
const checked = currentDeps.includes(t.id) ? 'checked' : '';
|
||||
const datesStr = (t.start || t.end) ? ` (${formatDateDE(parseDateISO(t.start))}–${formatDateDE(parseDateISO(t.end))})` : '';
|
||||
listHtml += `<label class="todo-depends-item"><input type="checkbox" value="${esc(t.id)}" ${checked}> ${esc(t.title || 'TODO')}${datesStr}</label>`;
|
||||
listHtml += dependsItemHtml(t.id, `${esc(t.title || 'TODO')}${datesStr}`, checked, blocked.has(t.id));
|
||||
});
|
||||
}
|
||||
if(listHtml === '') listHtml = '<span class="hint">Keine anderen Aufgaben oder Meilensteine vorhanden.</span>';
|
||||
dependsOnList.innerHTML = listHtml;
|
||||
}
|
||||
|
||||
const searchInput = document.getElementById('todoEditDependsSearch');
|
||||
if(searchInput) searchInput.value = '';
|
||||
refreshTodoDependsCount();
|
||||
// Bereich nur aufklappen, wenn es dort auch etwas zu sehen gibt
|
||||
const fold = document.getElementById('todoEditDependsFold');
|
||||
if(fold) fold.open = !!(currentTodo && Array.isArray(currentTodo.dependsOn) && currentTodo.dependsOn.length > 0);
|
||||
|
||||
const assigneesDiv = document.getElementById('todoEditAssignees');
|
||||
if(assigneesDiv){
|
||||
const assignees = projectAssignees(app.project, app.week);
|
||||
const currentAssignees = currentTodo && Array.isArray(currentTodo.assignees) ? currentTodo.assignees : [];
|
||||
const currentAssignees = currentTodo
|
||||
? (Array.isArray(currentTodo.assignees) ? currentTodo.assignees : [])
|
||||
: (Array.isArray(pre.assignees) ? pre.assignees : []);
|
||||
const seenPeople = new Set();
|
||||
const uniquePeople = [];
|
||||
assignees.forEach(a => {
|
||||
@@ -215,28 +457,78 @@
|
||||
uniquePeople.push(a.person);
|
||||
}
|
||||
});
|
||||
// Chips statt Checkbox-Reihe: Kürzel + Name, dieselbe Lesart wie die
|
||||
// Personenspalten im Projektboard.
|
||||
assigneesDiv.innerHTML = uniquePeople.map(person => {
|
||||
const checked = currentAssignees.includes(person.id) ? 'checked' : '';
|
||||
return `<label><input type="checkbox" value="${esc(person.id)}" ${checked}> ${esc(person.name)}</label>`;
|
||||
const pressed = currentAssignees.includes(person.id) ? 'true' : 'false';
|
||||
const short = person.name || initialsForName(person.fullName || '') || '?';
|
||||
const full = person.fullName || person.name || '';
|
||||
return `<button type="button" class="person-chip" data-person-id="${esc(person.id)}" aria-pressed="${pressed}" onclick="toggleTodoAssignee(this)">`
|
||||
+ `<span class="person-chip-ini">${esc(short)}</span>${esc(full)}</button>`;
|
||||
}).join('') || '<span class="hint">Keine Projektzuordnungen im Board.</span>';
|
||||
}
|
||||
|
||||
const deleteBtn = document.getElementById('todoEditDelete');
|
||||
if(deleteBtn) deleteBtn.hidden = isNew;
|
||||
|
||||
document.getElementById('todoModal').style.display = 'flex';
|
||||
todoModalSnapshot = todoFormSnapshot();
|
||||
document.getElementById('todoEditTitle').focus();
|
||||
};
|
||||
|
||||
// closeTodoModal: blendet das TODO-Modal aus und setzt den Bearbeitungsindex
|
||||
// zurück.
|
||||
window.closeTodoModal = function(){
|
||||
document.getElementById('todoModal').style.display = 'none';
|
||||
app.currentTodoIndex = null;
|
||||
// dependsItemHtml: baut eine Zeile der Vorgänger-Liste. Gesperrte Einträge
|
||||
// (Zirkelbezug) werden ausgegraut und deaktiviert statt ausgeblendet, damit
|
||||
// nachvollziehbar bleibt, warum sie nicht wählbar sind. Parameter: value
|
||||
// (Knoten-ID), label (bereits escaptes HTML), checked (String), blocked
|
||||
// (Boolean). Rückgabe: HTML-String.
|
||||
function dependsItemHtml(value, label, checked, blocked){
|
||||
if(blocked){
|
||||
return `<label class="todo-depends-item is-blocked" title="Würde einen Zirkelbezug erzeugen">`
|
||||
+ `<input type="checkbox" value="${esc(value)}" disabled> ${label}`
|
||||
+ `<span class="todo-depends-note">Zirkelbezug</span></label>`;
|
||||
}
|
||||
return `<label class="todo-depends-item"><input type="checkbox" value="${esc(value)}" ${checked}> ${label}</label>`;
|
||||
}
|
||||
|
||||
// toggleTodoAssignee: schaltet einen Zuständigen-Chip um. Parameter: btn
|
||||
// (das angeklickte Chip-Element). Rückgabe: keine.
|
||||
window.toggleTodoAssignee = function(btn){
|
||||
btn.setAttribute('aria-pressed', btn.getAttribute('aria-pressed') === 'true' ? 'false' : 'true');
|
||||
};
|
||||
|
||||
// saveTodoEdit: übernimmt die Modal-Eingaben in den Plan – als neuer TODO
|
||||
// oder Update des aktuell bearbeiteten. Fehlende End-/Startwerte werden
|
||||
// als eintägige Aufgabe ergänzt, „Erledigt“ setzt den Fortschritt auf
|
||||
// 100 %. Speichert und rendert neu.
|
||||
window.saveTodoEdit = function(){
|
||||
// todoFormSnapshot: friert die aktuellen Eingaben als Vergleichsstring ein.
|
||||
// Dient allein der Rückfrage beim Abbrechen (Esc/Klick auf den Hintergrund);
|
||||
// gespeichert wird ausschließlich über saveTodoEdit(). Parameter: keine.
|
||||
// Rückgabe: String.
|
||||
function todoFormSnapshot(){
|
||||
const read = id => { const el = document.getElementById(id); return el ? el.value : ''; };
|
||||
const deps = Array.from(document.querySelectorAll('#todoEditDependsOn input[type="checkbox"]:checked')).map(cb => cb.value);
|
||||
const people = Array.from(document.querySelectorAll('#todoEditAssignees .person-chip[aria-pressed="true"]')).map(b => b.dataset.personId);
|
||||
return JSON.stringify([read('todoEditTitle'), read('todoEditStart'), read('todoEditEnd'),
|
||||
currentTodoEditStatus(), read('todoEditProgress'), deps, people]);
|
||||
}
|
||||
|
||||
// closeTodoModal: blendet das TODO-Modal aus und setzt den Bearbeitungsindex
|
||||
// zurück. Parameter: askIfDirty (optional, true = bei geänderten Eingaben
|
||||
// zuerst nachfragen; wird von Esc und dem Klick auf den Hintergrund genutzt).
|
||||
// Rückgabe: true, wenn tatsächlich geschlossen wurde.
|
||||
window.closeTodoModal = function(askIfDirty){
|
||||
if(askIfDirty && todoFormSnapshot() !== todoModalSnapshot){
|
||||
if(!confirm('Die Änderungen an dieser Aufgabe verwerfen?')) return false;
|
||||
}
|
||||
document.getElementById('todoModal').style.display = 'none';
|
||||
app.currentTodoIndex = null;
|
||||
todoModalSnapshot = '';
|
||||
return true;
|
||||
};
|
||||
|
||||
// saveTodoEdit: übernimmt die Modal-Eingaben in den Plan – als neue Aufgabe
|
||||
// oder Update der aktuell bearbeiteten. Fehlende End-/Startwerte werden als
|
||||
// eintägige Aufgabe ergänzt; done/progress ergeben sich aus dem
|
||||
// Status-Segment. Parameter: andNew (optional, true = nach dem Speichern
|
||||
// gleich das Modal für die nächste Aufgabe öffnen, mit Zeitraum und
|
||||
// Zuständigen als Vorbelegung). Rückgabe: keine.
|
||||
window.saveTodoEdit = function(andNew){
|
||||
if(app.isLocked()) return;
|
||||
const title = document.getElementById('todoEditTitle').value.trim();
|
||||
let start = document.getElementById('todoEditStart').value;
|
||||
@@ -245,19 +537,31 @@
|
||||
// sonst würde kein Balken im GANTT erscheinen.
|
||||
if(start && !end) end = start;
|
||||
if(end && !start) start = end;
|
||||
const done = document.getElementById('todoEditDone').checked;
|
||||
let progress = parseInt(document.getElementById('todoEditProgress').value, 10) || 0;
|
||||
// "Erledigt" impliziert 100 % Fortschritt
|
||||
if(done && progress < 100) progress = 100;
|
||||
|
||||
const status = currentTodoEditStatus();
|
||||
const done = status === 'fertig';
|
||||
let progress;
|
||||
if(status === 'offen') progress = 0;
|
||||
else if(status === 'fertig') progress = 100;
|
||||
else progress = parseInt(document.getElementById('todoEditProgress').value, 10) || 0;
|
||||
|
||||
clearFieldError('todoEditTitle');
|
||||
clearFieldError('todoEditRange');
|
||||
if(!title){
|
||||
setFieldError('todoEditTitle', 'Bitte einen Titel eingeben.');
|
||||
document.getElementById('todoEditTitle').focus();
|
||||
return;
|
||||
}
|
||||
if(start && end && parseDateISO(end) < parseDateISO(start)){
|
||||
setFieldError('todoEditRange', 'Das Enddatum liegt vor dem Startdatum.');
|
||||
return;
|
||||
}
|
||||
|
||||
const dependsOnList = document.getElementById('todoEditDependsOn');
|
||||
const dependsOn = dependsOnList ? Array.from(dependsOnList.querySelectorAll('input[type="checkbox"]:checked')).map(cb => cb.value).filter(Boolean) : [];
|
||||
|
||||
const assigneesDiv = document.getElementById('todoEditAssignees');
|
||||
const assignees = assigneesDiv ? Array.from(assigneesDiv.querySelectorAll('input:checked')).map(cb => cb.value) : [];
|
||||
|
||||
if(!title){ alert('Bitte einen Titel eingeben.'); return; }
|
||||
if(start && end && parseDateISO(end) < parseDateISO(start)){ alert('Enddatum liegt vor Startdatum.'); return; }
|
||||
const assignees = assigneesDiv ? Array.from(assigneesDiv.querySelectorAll('.person-chip[aria-pressed="true"]')).map(btn => btn.dataset.personId) : [];
|
||||
|
||||
const plan = app.ensurePlanLocal();
|
||||
if(!Array.isArray(plan.todos)) plan.todos = [];
|
||||
@@ -275,8 +579,12 @@
|
||||
t.assignees = assignees;
|
||||
}
|
||||
app.savePlan();
|
||||
// Erst schließen (setzt den Bearbeitungsindex zurück), dann ggf. direkt
|
||||
// die nächste Aufgabe mit denselben Rahmendaten öffnen.
|
||||
todoModalSnapshot = todoFormSnapshot();
|
||||
closeTodoModal();
|
||||
renderPlanGantt();
|
||||
if(andNew) openTodoModal(null, { start, end, assignees });
|
||||
};
|
||||
|
||||
// deleteTodoEdit: löscht nach Bestätigung die aktuell bearbeitete Aufgabe,
|
||||
@@ -285,7 +593,7 @@
|
||||
window.deleteTodoEdit = function(){
|
||||
if(app.isLocked()) return;
|
||||
if(app.currentTodoIndex === null){ closeTodoModal(); return; }
|
||||
if(!confirm('TODO löschen?')) return;
|
||||
if(!confirm('Aufgabe löschen?')) return;
|
||||
const plan = app.ensurePlanLocal();
|
||||
if(plan.todos && plan.todos[app.currentTodoIndex]){
|
||||
const deletedId = plan.todos[app.currentTodoIndex].id;
|
||||
@@ -297,30 +605,11 @@
|
||||
});
|
||||
app.savePlan();
|
||||
}
|
||||
todoModalSnapshot = todoFormSnapshot();
|
||||
closeTodoModal();
|
||||
renderPlanGantt();
|
||||
};
|
||||
|
||||
// addTodoFromList: legt eine neue Aufgabe aus den Eingabefeldern der
|
||||
// TODO-Liste an (Titel, Start/Ende), leert die Felder und rendert neu.
|
||||
window.addTodoFromList = function(){
|
||||
if(app.isLocked()) return;
|
||||
const title = document.getElementById('newTodoTitle').value.trim();
|
||||
let start = document.getElementById('newTodoStart').value;
|
||||
let end = document.getElementById('newTodoEnd').value;
|
||||
if(start && !end) end = start;
|
||||
if(end && !start) start = end;
|
||||
if(!title){ alert('Bitte einen Titel eingeben.'); return; }
|
||||
if(start && end && parseDateISO(end) < parseDateISO(start)){ alert('Enddatum liegt vor Startdatum.'); return; }
|
||||
const plan = app.ensurePlanLocal();
|
||||
plan.todos.push({ id: uid(), title, start, end, done: false, progress: 0, dependsOn: [], assignees: [] });
|
||||
document.getElementById('newTodoTitle').value = '';
|
||||
document.getElementById('newTodoStart').value = '';
|
||||
document.getElementById('newTodoEnd').value = '';
|
||||
app.savePlan();
|
||||
renderPlanGantt();
|
||||
};
|
||||
|
||||
// addTodo: öffnet das TODO-Modal für eine neue Aufgabe.
|
||||
window.addTodo = function(){
|
||||
openTodoModal(null);
|
||||
@@ -419,11 +708,16 @@
|
||||
}
|
||||
|
||||
// addPlanAbsence: legt aus den Formularfeldern eine neue Abwesenheit für die
|
||||
// aktuelle Person an – in der globalen Personenliste und in der lokalen
|
||||
// Sitzungs-Kopie. Speichert beides und rendert Liste sowie GANTT neu.
|
||||
// aktuelle Person an. Schreibt direkt über POST /users/:id/absences (die
|
||||
// numerische User-ID wird über app.getUserIdForPersonId() aufgelöst); hat
|
||||
// die Person kein Login-Konto, wird eine klare Fehlermeldung gezeigt statt
|
||||
// eines stillen Fehlschlags. Bei Erfolg werden globale Personenliste und
|
||||
// lokale Sitzungs-Kopie mit der vom Server vergebenen ID aktualisiert, Liste
|
||||
// und GANTT neu gerendert.
|
||||
window.addPlanAbsence = function(){
|
||||
if(app.isLocked()) return;
|
||||
const person = app.findGlobalPerson(app.currentAbsencePersonId);
|
||||
const personId = app.currentAbsencePersonId;
|
||||
const person = app.findGlobalPerson(personId);
|
||||
if(!person) return;
|
||||
const start = document.getElementById('absenceEditStart').value;
|
||||
const end = document.getElementById('absenceEditEnd').value;
|
||||
@@ -431,43 +725,143 @@
|
||||
const note = document.getElementById('absenceEditNote').value.trim();
|
||||
if(!start || !end){ alert('Bitte Start- und Enddatum angeben.'); return; }
|
||||
if(parseDateISO(end) < parseDateISO(start)){ alert('Enddatum liegt vor Startdatum.'); return; }
|
||||
if(!Array.isArray(person.absences)) person.absences = [];
|
||||
const absence = { id: uid(), start, end, type, note };
|
||||
person.absences.push(absence);
|
||||
person.absences.sort((a,b) => parseDateISO(a.start) - parseDateISO(b.start));
|
||||
// Auch die Sitzungs-lokale Kopie aktualisieren, damit die Anzeige konsistent bleibt
|
||||
const localPerson = (app.week.people || []).find(p => p.id === app.currentAbsencePersonId);
|
||||
if(localPerson){
|
||||
if(!Array.isArray(localPerson.absences)) localPerson.absences = [];
|
||||
localPerson.absences.push({ ...absence });
|
||||
localPerson.absences.sort((a,b) => parseDateISO(a.start) - parseDateISO(b.start));
|
||||
}
|
||||
app.saveGlobalPeople();
|
||||
app.savePlan();
|
||||
document.getElementById('absenceEditStart').value = '';
|
||||
document.getElementById('absenceEditEnd').value = '';
|
||||
const rangeDisplay = document.getElementById('absenceEditRange');
|
||||
if(rangeDisplay) rangeDisplay.value = '';
|
||||
document.getElementById('absenceEditNote').value = '';
|
||||
renderAbsenceModalList();
|
||||
renderPlanGantt();
|
||||
app.getUserIdForPersonId(personId).then(userId => {
|
||||
if(!userId){
|
||||
alert('Für diese Person existiert kein Benutzerkonto. Abwesenheiten können nur für Personen mit Benutzerkonto gepflegt werden (siehe Benutzerverwaltung).');
|
||||
return;
|
||||
}
|
||||
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){
|
||||
alert('Abwesenheit konnte nicht gespeichert werden: ' + ((json && json.error) || 'unbekannter Fehler'));
|
||||
return;
|
||||
}
|
||||
if(!Array.isArray(person.absences)) person.absences = [];
|
||||
person.absences.push(json.absence);
|
||||
person.absences.sort((a,b) => parseDateISO(a.start) - parseDateISO(b.start));
|
||||
const localPerson = (app.week.people || []).find(p => p.id === personId);
|
||||
if(localPerson){
|
||||
if(!Array.isArray(localPerson.absences)) localPerson.absences = [];
|
||||
localPerson.absences.push({ ...json.absence });
|
||||
localPerson.absences.sort((a,b) => parseDateISO(a.start) - parseDateISO(b.start));
|
||||
}
|
||||
document.getElementById('absenceEditStart').value = '';
|
||||
document.getElementById('absenceEditEnd').value = '';
|
||||
const rangeDisplay = document.getElementById('absenceEditRange');
|
||||
if(rangeDisplay) rangeDisplay.value = '';
|
||||
document.getElementById('absenceEditNote').value = '';
|
||||
renderAbsenceModalList();
|
||||
renderPlanGantt();
|
||||
});
|
||||
}).catch(err => {
|
||||
console.error('Abwesenheit speichern fehlgeschlagen:', err);
|
||||
alert('Abwesenheit konnte nicht gespeichert werden: ' + (err.message || err));
|
||||
});
|
||||
};
|
||||
|
||||
// deletePlanAbsence: entfernt die Abwesenheit mit der übergebenen ID aus
|
||||
// der globalen Personenliste und der lokalen Sitzungs-Kopie, speichert
|
||||
// beides und rendert Liste sowie GANTT neu.
|
||||
// deletePlanAbsence: entfernt die Abwesenheit mit der übergebenen ID direkt
|
||||
// über DELETE /users/:id/absences/:absenceId. Kein Benutzerkonto gefunden ->
|
||||
// klare Fehlermeldung statt stillem Fehlschlag. Bei Erfolg werden globale
|
||||
// Personenliste und lokale Sitzungs-Kopie aktualisiert, Liste und GANTT neu
|
||||
// gerendert.
|
||||
window.deletePlanAbsence = function(absenceId){
|
||||
if(app.isLocked()) return;
|
||||
const person = app.findGlobalPerson(app.currentAbsencePersonId);
|
||||
const personId = app.currentAbsencePersonId;
|
||||
const person = app.findGlobalPerson(personId);
|
||||
if(!person || !Array.isArray(person.absences)) return;
|
||||
person.absences = person.absences.filter(a => a.id !== absenceId);
|
||||
const localPerson = (app.week.people || []).find(p => p.id === app.currentAbsencePersonId);
|
||||
if(localPerson && Array.isArray(localPerson.absences)){
|
||||
localPerson.absences = localPerson.absences.filter(a => a.id !== absenceId);
|
||||
}
|
||||
app.saveGlobalPeople();
|
||||
app.savePlan();
|
||||
renderAbsenceModalList();
|
||||
renderPlanGantt();
|
||||
app.getUserIdForPersonId(personId).then(userId => {
|
||||
if(!userId){
|
||||
alert('Für diese Person existiert kein Benutzerkonto. Abwesenheiten können nur für Personen mit Benutzerkonto gepflegt werden (siehe Benutzerverwaltung).');
|
||||
return;
|
||||
}
|
||||
return fetch(baseUrl() + '/users/' + userId + '/absences/' + encodeURIComponent(absenceId), { method: 'DELETE' })
|
||||
.then(r => r.json().then(json => ({ ok: r.ok, json }))).then(({ ok, json }) => {
|
||||
if(!ok || !json || !json.ok){
|
||||
alert('Abwesenheit konnte nicht gelöscht werden: ' + ((json && json.error) || 'unbekannter Fehler'));
|
||||
return;
|
||||
}
|
||||
person.absences = person.absences.filter(a => a.id !== absenceId);
|
||||
const localPerson = (app.week.people || []).find(p => p.id === personId);
|
||||
if(localPerson && Array.isArray(localPerson.absences)){
|
||||
localPerson.absences = localPerson.absences.filter(a => a.id !== absenceId);
|
||||
}
|
||||
renderAbsenceModalList();
|
||||
renderPlanGantt();
|
||||
});
|
||||
}).catch(err => {
|
||||
console.error('Abwesenheit löschen fehlgeschlagen:', err);
|
||||
alert('Abwesenheit konnte nicht gelöscht werden: ' + (err.message || err));
|
||||
});
|
||||
};
|
||||
|
||||
// ---------- Tastatur- und Hintergrundbedienung (alle drei Dialoge) ----------
|
||||
|
||||
// MODAL_HANDLERS: Zuordnung Dialog → Schließen/Speichern. Zentral registriert,
|
||||
// damit Esc, Strg/Cmd+Enter und der Klick auf den Hintergrund in allen drei
|
||||
// Dialogen dieser Seite gleich funktionieren und nicht je Dialog nachgebaut
|
||||
// werden müssen. dirtyCheck = true: vor dem Schließen bei geänderten
|
||||
// Eingaben nachfragen (bislang nur im TODO-Dialog umgesetzt).
|
||||
const MODAL_HANDLERS = [
|
||||
{ id: 'todoModal', close: askIfDirty => closeTodoModal(askIfDirty), save: () => saveTodoEdit() },
|
||||
{ id: 'milestoneModal', close: () => closeMilestoneModal(), save: () => saveMilestoneEdit() },
|
||||
{ id: 'absenceModal', close: () => closeAbsenceModal(), save: null }
|
||||
];
|
||||
|
||||
// visibleModal: liefert den Eintrag aus MODAL_HANDLERS, dessen Dialog gerade
|
||||
// sichtbar ist (es ist immer höchstens einer offen). Parameter: keine.
|
||||
// Rückgabe: Eintrag oder null.
|
||||
function visibleModal(){
|
||||
for(const entry of MODAL_HANDLERS){
|
||||
const el = document.getElementById(entry.id);
|
||||
if(el && el.style.display !== 'none' && el.style.display !== '') return entry;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', function(e){
|
||||
// Der Datums-/Bereichspicker liegt über dem Dialog und bekommt Escape
|
||||
// zuerst — sonst würde der ganze Dialog geschlossen, obwohl der Nutzer
|
||||
// nur den Kalender wegklicken wollte.
|
||||
if(e.key === 'Escape' && document.getElementById('rangePickerPopup')){
|
||||
closeRangePicker();
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
const modal = visibleModal();
|
||||
if(!modal) return;
|
||||
|
||||
if(e.key === 'Escape'){
|
||||
modal.close(true);
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
// Speichern: Strg/Cmd+Enter überall, einfaches Enter nur in einzeiligen
|
||||
// Textfeldern (in einem <textarea> wäre das ein Zeilenumbruch).
|
||||
if(e.key === 'Enter' && modal.save){
|
||||
const target = e.target;
|
||||
// Das Suchfeld der Vorgängerliste ist ausgenommen: Enter ist dort der
|
||||
// Reflex zum Filtern, schloss aber den ganzen Dialog — ohne dass ein
|
||||
// Vorgänger angehakt war.
|
||||
const isSearchField = target && target.id === 'todoEditDependsSearch';
|
||||
const inTextInput = target && target.tagName === 'INPUT' && target.type === 'text' && !isSearchField;
|
||||
if((e.metaKey || e.ctrlKey) || inTextInput){
|
||||
modal.save();
|
||||
e.preventDefault();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Klick auf den abgedunkelten Hintergrund schließt den Dialog; Klicks
|
||||
// innerhalb des Dialogs (e.target !== Hintergrund) bleiben unberührt.
|
||||
MODAL_HANDLERS.forEach(entry => {
|
||||
const el = document.getElementById(entry.id);
|
||||
if(!el) return;
|
||||
el.addEventListener('mousedown', function(e){
|
||||
if(e.target === el) entry.close(true);
|
||||
});
|
||||
});
|
||||
|
||||
})();
|
||||
@@ -0,0 +1,764 @@
|
||||
// -----------------------------
|
||||
// Projektplan — Protokolle (Besprechungsprotokolle je Projekt)
|
||||
// -----------------------------
|
||||
// Bereich "Protokolle" des Projektbereichs (siehe js/plan/plan-sections.js)
|
||||
// plus eine Kompakt-Karte im Dashboard (#planProtokolleSummary, springt per
|
||||
// Klick über openProtokollePanel() in diesen Bereich — gleiches Muster wie
|
||||
// #planFilesSummary/openFilesPanel() in plan-files.js).
|
||||
//
|
||||
// Protokolle hängen wie Dateien NUR an project_nr (nicht an week_label/
|
||||
// project_id, das sich bei jedem automatischen Sitzungswechsel ändert), sind
|
||||
// also bei einem Projekt ohne Projektnummer nicht möglich — der Bereich zeigt
|
||||
// dann nur einen Hinweis, wie bei Dateien.
|
||||
//
|
||||
// Das "Fortschreiben" (offene Punkte wandern automatisch mit gleicher Nummer
|
||||
// ins nächste Protokoll) rechnet ausschließlich der Server beim Anlegen
|
||||
// (POST /project-protokolle, server.js) — hier wird nur angezeigt/bearbeitet.
|
||||
// Ob ein Punkt "neu" ist (gelb markiert), steht serverseitig fest (Feld
|
||||
// `neu`, unabhängig von `nummer`/`gruppe`, die frei editierbare Anzeige-Texte
|
||||
// sind, siehe CLAUDE.md "### Protokolle").
|
||||
//
|
||||
// Die Detailansicht ist bewusst KEIN Modal (ein Protokoll mit vielen Punkten
|
||||
// ist zu umfangreich dafür), sondern ersetzt inline die Listenansicht
|
||||
// innerhalb desselben Bereichs-Containers. Um dabei den Eingabefokus beim
|
||||
// Tippen nicht zu verlieren, wird bei einfachen Textfeldern NICHT bei jedem
|
||||
// Tastendruck neu gerendert — nur strukturelle Änderungen (Zeile hinzufügen/
|
||||
// entfernen, Gruppe ändern) lösen ein Neu-Rendern aus.
|
||||
//
|
||||
// Läuft wie alle plan-Module in einem IIFE; Zustand: window.PlanApp
|
||||
// (plan-state.js).
|
||||
|
||||
(function(){
|
||||
const app = window.PlanApp;
|
||||
|
||||
// protokolleCache: die zuletzt geladene Listenübersicht (Zusammenfassungen,
|
||||
// keine vollen Punkte/Teilnehmer — die liefert erst das Öffnen eines
|
||||
// einzelnen Protokolls). currentProtokoll: das volle, gerade in der
|
||||
// Detailansicht bearbeitete Protokoll, oder null in der Listenansicht.
|
||||
let protokolleCache = [];
|
||||
let currentProtokoll = null;
|
||||
|
||||
// ---------- Laden ----------
|
||||
|
||||
// initPlanProtokoll: wird unbedingt aus renderPlanPage() aufgerufen (wie
|
||||
// initPlanFiles()), nicht erst beim Öffnen des Bereichs — sonst zeigte die
|
||||
// Dashboard-Kompaktkarte veraltete Zahlen, bis man den Bereich einmal
|
||||
// betreten hat.
|
||||
window.initPlanProtokoll = function(){
|
||||
const hasNr = !!(app.project && app.project.nr);
|
||||
const hint = document.getElementById('protokolleNoProjectHint');
|
||||
const container = document.getElementById('protokolleContainer');
|
||||
if(hint) hint.style.display = hasNr ? 'none' : '';
|
||||
if(container) container.style.display = hasNr ? '' : 'none';
|
||||
currentProtokoll = null;
|
||||
if(!hasNr){
|
||||
protokolleCache = [];
|
||||
renderProtokolleSummary();
|
||||
return;
|
||||
}
|
||||
ladeProtokollListe();
|
||||
};
|
||||
|
||||
// ladeProtokollListe: GET /project-protokolle?projectNr= — füllt
|
||||
// protokolleCache und rendert Listenansicht + Dashboard-Kompaktkarte neu.
|
||||
function ladeProtokollListe(){
|
||||
const container = document.getElementById('protokolleContainer');
|
||||
if(container && !currentProtokoll) container.innerHTML = '<span class="hint">Lade Protokolle…</span>';
|
||||
fetch(baseUrl() + '/project-protokolle?projectNr=' + encodeURIComponent(app.project.nr))
|
||||
.then(r => { if(!r.ok) throw new Error('Server HTTP ' + r.status); return r.json(); })
|
||||
.then(data => {
|
||||
protokolleCache = Array.isArray(data.protokolle) ? data.protokolle : [];
|
||||
window.renderPlanProtokolle();
|
||||
renderProtokolleSummary();
|
||||
})
|
||||
.catch(err => {
|
||||
if(container) container.innerHTML = '<span class="hint">Protokolle konnten nicht geladen werden.</span>';
|
||||
console.error('Protokollliste laden fehlgeschlagen:', err);
|
||||
});
|
||||
}
|
||||
|
||||
// renderProtokolleSummary: füllt die Kompakt-Vorschau der Dashboard-Karte
|
||||
// (#planProtokolleSummary) — letztes Protokoll + Anzahl offener Punkte,
|
||||
// gleiches Muster wie renderFilesSummary() in plan-files.js.
|
||||
function renderProtokolleSummary(){
|
||||
const el = document.getElementById('planProtokolleSummary');
|
||||
if(!el) return;
|
||||
if(!app.project || !app.project.nr){
|
||||
el.innerHTML = '<span class="hint">Keine Projektnummer.</span>';
|
||||
return;
|
||||
}
|
||||
if(protokolleCache.length === 0){
|
||||
el.innerHTML = '<span class="hint">Noch keine Protokolle.</span>';
|
||||
return;
|
||||
}
|
||||
const letztes = protokolleCache[0]; // Server liefert nach nummer DESC
|
||||
const chip = letztes.offenCount > 0
|
||||
? `<span class="plan-mini-chip warn">${letztes.offenCount} offen</span>`
|
||||
: '<span class="plan-mini-chip">Alle erledigt</span>';
|
||||
el.innerHTML = `<div class="plan-mini-card-preview">Nr. ${esc(letztes.nummer)} · ${esc(letztes.betreff || 'ohne Betreff')}</div>
|
||||
<div class="plan-mini-chip-row">${chip}</div>`;
|
||||
}
|
||||
|
||||
// openProtokollePanel: springt in den Bereich "Protokolle" — Gegenstück zu
|
||||
// openFilesPanel()/openResourceModal().
|
||||
window.openProtokollePanel = function(){
|
||||
if(typeof showPlanSection === 'function') showPlanSection('protokolle');
|
||||
};
|
||||
|
||||
// ---------- Nummer-/Gruppen-Hilfsfunktionen ----------
|
||||
|
||||
// berechneProtokollNummern: errechnet die Anzeige-Nummer JEDES Punkts rein
|
||||
// aus seiner Position — "1.1"/"1.2"/"1.3" für Punkte innerhalb einer Gruppe
|
||||
// (die erste Ziffer ist die Gruppennummer, die zweite die laufende Position
|
||||
// INNERHALB dieser Gruppe), schlichte "1"/"2"/"3" für Punkte ohne Gruppe.
|
||||
// Gruppennummer UND die Nummer eines Punkts ohne Gruppe kommen aus EINEM
|
||||
// gemeinsamen, fortlaufenden Zähler (nicht zwei getrennten) — sonst gäbe es
|
||||
// z. B. sowohl einen Punkt "1" ohne Gruppe als auch eine Gruppe mit der
|
||||
// Nummer "1" (Punkte "1.1", "1.2" …), zwei verschiedene Dinge mit
|
||||
// derselben führenden Ziffer. Jede oberste Ebene (ob Einzelpunkt oder
|
||||
// Gruppe) bekommt dadurch eine über das ganze Protokoll hinweg einmalige
|
||||
// Zahl. Nie an einen gespeicherten Wert gebunden — ändert sich die
|
||||
// Reihenfolge oder die Gruppe eines Punkts, verschiebt sich die Nummer
|
||||
// automatisch mit, exakt wie bei einem klassischen Baubesprechungsprotokoll
|
||||
// mit durchnummerierten Abschnitten. Global verfügbar, weil sowohl die
|
||||
// Detailansicht hier als auch exportProtokollPDF() (js/pdf.js) dieselbe
|
||||
// Nummerierung zeigen müssen — dasselbe Prinzip wie classifyProjectChange()
|
||||
// aus wochenbericht.js, das pdf.js ebenfalls direkt aufruft.
|
||||
// Parameter: punkte (Array in Anzeige-/array_order-Reihenfolge). Rückgabe:
|
||||
// Array von Strings, indexgleich zu punkte.
|
||||
window.berechneProtokollNummern = function(punkte){
|
||||
const gruppenNr = new Map();
|
||||
const positionInGruppe = new Map();
|
||||
let naechsteTopLevelNr = 1;
|
||||
return (punkte || []).map(p => {
|
||||
const g = String(p.gruppe || '').trim();
|
||||
if(!g) return String(naechsteTopLevelNr++);
|
||||
if(!gruppenNr.has(g)){
|
||||
gruppenNr.set(g, naechsteTopLevelNr++);
|
||||
positionInGruppe.set(g, 0);
|
||||
}
|
||||
const pos = positionInGruppe.get(g) + 1;
|
||||
positionInGruppe.set(g, pos);
|
||||
return gruppenNr.get(g) + '.' + pos;
|
||||
});
|
||||
};
|
||||
|
||||
// gruppenReihenfolge: liefert die vorkommenden Gruppennamen in der
|
||||
// Reihenfolge ihres ersten Auftretens unter den Punkten (array_order) —
|
||||
// "Ohne Gruppe" (leerer String) immer zuerst, wenn vorhanden.
|
||||
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);
|
||||
}
|
||||
|
||||
// ---------- Nachträgliches Umordnen ----------
|
||||
//
|
||||
// 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.
|
||||
|
||||
// verschiebePunkt: vertauscht einen Punkt mit seinem Vorgänger/Nachfolger
|
||||
// INNERHALB derselben Gruppe (bzw. "Ohne 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){
|
||||
if(!currentProtokoll || app.isLocked()) return;
|
||||
const punkte = currentProtokoll.punkte;
|
||||
const gruppe = String(punkte[idx].gruppe || '').trim();
|
||||
const gleicheGruppeIdx = punkte.map((p, i) => i).filter(i => String(punkte[i].gruppe || '').trim() === gruppe);
|
||||
const posInGruppe = gleicheGruppeIdx.indexOf(idx);
|
||||
const zielPos = posInGruppe + richtung;
|
||||
if(zielPos < 0 || zielPos >= gleicheGruppeIdx.length) return;
|
||||
const zielIdx = gleicheGruppeIdx[zielPos];
|
||||
const tmp = punkte[idx];
|
||||
punkte[idx] = punkte[zielIdx];
|
||||
punkte[zielIdx] = tmp;
|
||||
const block = document.getElementById('protokollPunkteBlock');
|
||||
if(block) block.innerHTML = renderPunkteBlockHtml();
|
||||
};
|
||||
|
||||
// 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.
|
||||
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);
|
||||
});
|
||||
const pos = namen.indexOf(gruppe);
|
||||
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());
|
||||
namen.forEach(g => {
|
||||
punkte.filter(p => String(p.gruppe || '').trim() === g).forEach(p => neu.push(p));
|
||||
});
|
||||
currentProtokoll.punkte = neu;
|
||||
const block = document.getElementById('protokollPunkteBlock');
|
||||
if(block) block.innerHTML = renderPunkteBlockHtml();
|
||||
};
|
||||
|
||||
// ---------- Listenansicht ----------
|
||||
|
||||
function todoStatusZeile(offen, erledigt){
|
||||
if(offen === 0 && erledigt === 0) return 'keine Punkte';
|
||||
if(offen === 0) return 'alle erledigt';
|
||||
return offen + ' offen · ' + erledigt + ' erledigt';
|
||||
}
|
||||
|
||||
function renderProtokolleListe(){
|
||||
const container = document.getElementById('protokolleContainer');
|
||||
if(!container) return;
|
||||
const locked = app.isLocked();
|
||||
const anlegeBtns = locked ? '' : `<button onclick="neuesProtokoll()">+ Neues Protokoll</button>
|
||||
<button class="secondary" onclick="oeffneProtokollImportDialog()">Protokoll importieren</button>`;
|
||||
if(protokolleCache.length === 0){
|
||||
container.innerHTML = `<div class="plan-panel">
|
||||
<div class="protokoll-list-head"><h3 class="plan-panel-title" style="margin:0">Protokolle</h3><span class="toolbar">${anlegeBtns}</span></div>
|
||||
<span class="hint">${locked ? 'Für diese Sitzung sind keine Protokolle hinterlegt.' : 'Noch keine Protokolle. Über „+ Neues Protokoll" anlegen oder ein bestehendes importieren.'}</span>
|
||||
</div>`;
|
||||
return;
|
||||
}
|
||||
const zeilen = protokolleCache.map(p => `
|
||||
<div class="protokoll-row" onclick="oeffneProtokoll('${esc(p.id)}')">
|
||||
<span class="protokoll-nr">Nr. ${esc(p.nummer)}</span>
|
||||
<span class="protokoll-betreff">${esc(p.betreff || 'ohne Betreff')}<span class="protokoll-datum">${esc(formatDateDE(parseDateISO(p.datum)) || '')}${p.ort ? ' · ' + esc(p.ort) : ''}</span></span>
|
||||
<span class="protokoll-verfasser">${esc(p.verfasserName || '')}</span>
|
||||
<span class="protokoll-punkte-status${p.offenCount === 0 ? ' ist-leer' : ''}"><span class="protokoll-status-dot"></span>${esc(todoStatusZeile(p.offenCount, p.erledigtCount))}</span>
|
||||
<span class="protokoll-teilnehmer-count">${p.teilnehmerCount} Teilnehmer</span>
|
||||
</div>`).join('');
|
||||
container.innerHTML = `<div class="plan-panel">
|
||||
<div class="protokoll-list-head"><h3 class="plan-panel-title" style="margin:0">Protokolle</h3><span class="toolbar">${anlegeBtns}</span></div>
|
||||
${zeilen}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ---------- Anlegen ----------
|
||||
|
||||
// neuesProtokoll: legt ein neues Protokoll an. Vorbelegung der Teilnehmer
|
||||
// (rein clientseitige Komfort-Logik vor dem POST, siehe CLAUDE.md): Namen
|
||||
// aus dem LETZTEN vorherigen Protokoll übernehmen (Anwesenheit auf
|
||||
// "anwesend" zurückgesetzt), sonst Fallback auf projectAssignees(), sonst
|
||||
// leer. Das eigentliche Fortschreiben der Punkte übernimmt der Server.
|
||||
window.neuesProtokoll = async function(){
|
||||
if(app.isLocked()) return;
|
||||
let teilnehmer = [];
|
||||
try {
|
||||
if(protokolleCache.length > 0){
|
||||
const r = await fetch(baseUrl() + '/project-protokolle/' + encodeURIComponent(protokolleCache[0].id));
|
||||
const d = await r.json();
|
||||
if(r.ok && d.ok && d.protokoll){
|
||||
teilnehmer = d.protokoll.teilnehmer.map(t => ({ name: t.name, firma: t.firma, anwesend: true }));
|
||||
}
|
||||
}
|
||||
if(teilnehmer.length === 0){
|
||||
const zugeordnet = projectAssignees(app.project, app.week);
|
||||
const gesehen = new Set();
|
||||
zugeordnet.forEach(a => {
|
||||
if(gesehen.has(a.person.id)) return;
|
||||
gesehen.add(a.person.id);
|
||||
teilnehmer.push({ name: a.person.fullName || a.person.name, firma: '', anwesend: true });
|
||||
});
|
||||
}
|
||||
} catch(err){
|
||||
console.error('Teilnehmer-Vorbelegung fehlgeschlagen:', err);
|
||||
}
|
||||
|
||||
try {
|
||||
const url = baseUrl() + '/project-protokolle'
|
||||
+ '?projectNr=' + encodeURIComponent(app.project.nr)
|
||||
+ '&projectName=' + encodeURIComponent(app.project.name || '')
|
||||
+ '&weekLabel=' + encodeURIComponent(app.week ? app.week.label : '');
|
||||
const r = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json; charset=utf-8' },
|
||||
body: JSON.stringify({ datum: heuteIso(), betreff: '', ort: '', teilnehmer })
|
||||
});
|
||||
const d = await r.json();
|
||||
if(!r.ok || !d.ok){ alert('Protokoll konnte nicht angelegt werden: ' + (d && d.error ? d.error : 'unbekannter Fehler')); return; }
|
||||
currentProtokoll = d.protokoll;
|
||||
renderProtokollDetail();
|
||||
ladeProtokollListe();
|
||||
} catch(err){
|
||||
console.error('Protokoll anlegen fehlgeschlagen:', err);
|
||||
alert('Protokoll konnte nicht angelegt werden: ' + (err.message || err));
|
||||
}
|
||||
};
|
||||
|
||||
// ---------- Import (externes Protokoll hochladen) ----------
|
||||
|
||||
// oeffneProtokollImportDialog: zeigt den Import-Dialog, Datum vorbelegt
|
||||
// mit heute (dieselbe Vorbelegung wie neuesProtokoll()). Das Erkennungs-
|
||||
// Auswahlfeld wird hier aus GET /my-ai-settings aufgebaut statt fest im
|
||||
// Markup zu stehen — der Hinweis "(kein Schlüssel hinterlegt)" bezieht
|
||||
// sich auf den EIGENEN Schlüssel des angemeldeten Kontos (siehe
|
||||
// Einstellungen → Mein Konto → KI-Zugangsdaten), nicht mehr auf einen
|
||||
// firmenweiten Stand, und kann sich seit dem letzten Öffnen geändert
|
||||
// haben.
|
||||
window.oeffneProtokollImportDialog = function(){
|
||||
if(app.isLocked()) return;
|
||||
document.getElementById('protokollImportFile').value = '';
|
||||
document.getElementById('protokollImportBetreff').value = '';
|
||||
document.getElementById('protokollImportOrt').value = '';
|
||||
document.getElementById('protokollImportDatum').value = heuteIso();
|
||||
document.getElementById('protokollImportDatumDisplay').value = formatDateDE(parseDateISO(heuteIso())) || '';
|
||||
const erkennungSel = document.getElementById('protokollImportErkennung');
|
||||
if(erkennungSel){
|
||||
erkennungSel.innerHTML = `<option value="chatgpt">ChatGPT</option><option value="claude">Claude</option>`;
|
||||
fetch(baseUrl() + '/my-ai-settings')
|
||||
.then(r => r.json())
|
||||
.then(d => {
|
||||
if(!d.ok) return;
|
||||
erkennungSel.innerHTML = `
|
||||
<option value="chatgpt">ChatGPT${d.chatgpt && d.chatgpt.configured ? '' : ' (kein Schlüssel hinterlegt)'}</option>
|
||||
<option value="claude">Claude${d.claude && d.claude.configured ? '' : ' (kein Schlüssel hinterlegt)'}</option>`;
|
||||
})
|
||||
.catch(err => console.error('KI-Einstellungen laden fehlgeschlagen:', err));
|
||||
}
|
||||
const errEl = document.getElementById('protokollImportError');
|
||||
if(errEl){ errEl.textContent = ''; errEl.hidden = true; }
|
||||
setzeProtokollImportLadezustand(false);
|
||||
document.getElementById('protokollImportModal').style.display = 'flex';
|
||||
};
|
||||
|
||||
window.schliesseProtokollImportDialog = function(){
|
||||
document.getElementById('protokollImportModal').style.display = 'none';
|
||||
};
|
||||
|
||||
// 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,
|
||||
// der Dialog wirkte hängengeblieben, und ein zweiter Klick auf
|
||||
// "Importieren" hätte denselben Import doppelt losgeschickt.
|
||||
function setzeProtokollImportLadezustand(aktiv){
|
||||
['protokollImportFile', 'protokollImportErkennung', 'protokollImportDatumDisplay',
|
||||
'protokollImportBetreff', 'protokollImportOrt'].forEach(id => {
|
||||
const el = document.getElementById(id);
|
||||
if(el) el.disabled = aktiv;
|
||||
});
|
||||
const modal = document.getElementById('protokollImportModal');
|
||||
if(modal) modal.querySelectorAll('.toolbar button').forEach(btn => { btn.disabled = aktiv; });
|
||||
const ladeEl = document.getElementById('protokollImportLoading');
|
||||
if(ladeEl) ladeEl.hidden = !aktiv;
|
||||
}
|
||||
|
||||
// importiereProtokoll: liest die gewählte Datei + Kopfdaten, schickt die
|
||||
// Datei als rohen Binär-Body an POST /project-protokolle/import (gleiches
|
||||
// Vorgehen wie submitFileUpload() in js/plan/plan-files.js, andere
|
||||
// Zielroute). Bei Erfolg landet der Nutzer direkt in der ganz normalen,
|
||||
// voll editierbaren Detailansicht — die erkannten Punkte sind darin
|
||||
// bereits sichtbar (Hinweisbanner in renderProtokollDetail(), siehe dort)
|
||||
// und lassen sich dort wie jeder andere Punkt prüfen/korrigieren, bevor
|
||||
// gespeichert wird.
|
||||
window.importiereProtokoll = function(){
|
||||
const errEl = document.getElementById('protokollImportError');
|
||||
const zeigeFehler = msg => { if(errEl){ errEl.textContent = msg; errEl.hidden = false; } };
|
||||
if(errEl){ errEl.textContent = ''; errEl.hidden = true; }
|
||||
|
||||
const fileInput = document.getElementById('protokollImportFile');
|
||||
const file = fileInput && fileInput.files && fileInput.files[0];
|
||||
if(!file) return zeigeFehler('Bitte eine Datei auswählen.');
|
||||
const datum = document.getElementById('protokollImportDatum').value;
|
||||
if(!datum) return zeigeFehler('Bitte ein Datum wählen.');
|
||||
|
||||
const betreff = document.getElementById('protokollImportBetreff').value;
|
||||
const ort = document.getElementById('protokollImportOrt').value;
|
||||
const erkennungSel = document.getElementById('protokollImportErkennung');
|
||||
const erkennung = erkennungSel ? erkennungSel.value : '';
|
||||
const url = baseUrl() + '/project-protokolle/import'
|
||||
+ '?projectNr=' + encodeURIComponent(app.project.nr)
|
||||
+ '&projectName=' + encodeURIComponent(app.project.name || '')
|
||||
+ '&weekLabel=' + encodeURIComponent(app.week ? app.week.label : '')
|
||||
+ '&datum=' + encodeURIComponent(datum)
|
||||
+ '&betreff=' + encodeURIComponent(betreff)
|
||||
+ '&ort=' + encodeURIComponent(ort)
|
||||
+ '&erkennung=' + encodeURIComponent(erkennung)
|
||||
+ '&filename=' + encodeURIComponent(file.name);
|
||||
|
||||
setzeProtokollImportLadezustand(true);
|
||||
fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': file.type || 'application/octet-stream' },
|
||||
body: file
|
||||
})
|
||||
.then(r => r.json().then(d => ({ ok: r.ok, d })))
|
||||
.then(({ ok, d }) => {
|
||||
if(!ok || !d.ok) throw new Error((d && d.error) || 'Import fehlgeschlagen.');
|
||||
schliesseProtokollImportDialog();
|
||||
currentProtokoll = d.protokoll;
|
||||
// Angeforderte KI nicht verfügbar gewesen -> automatisch auf
|
||||
// Textmuster zurückgefallen (server.js, POST .../import) — rein
|
||||
// clientseitiges, EINMALIGES Merkmal (nie vom Server geliefert,
|
||||
// nie gespeichert), das renderProtokollDetail() in einen deutlich
|
||||
// sichtbaren, bestehen bleibenden Hinweis übersetzt (nicht nur
|
||||
// einen nach 2,5s verschwindenden Toast, der beim gleichzeitigen
|
||||
// Wechsel in die Detailansicht leicht übersehen wird — genau das
|
||||
// war Eriks Rückmeldung, nachdem die Erkennung bei ihm an LM
|
||||
// Studio scheiterte und er davon nichts in der Oberfläche sah).
|
||||
if(erkennung && d.erkennungsart === 'textmuster' && d.erkennungshinweis){
|
||||
currentProtokoll._kiFallbackHinweis = d.erkennungshinweis;
|
||||
}
|
||||
renderProtokollDetail();
|
||||
ladeProtokollListe();
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('Protokoll-Import fehlgeschlagen:', err);
|
||||
zeigeFehler(err.message || String(err));
|
||||
})
|
||||
.finally(() => setzeProtokollImportLadezustand(false));
|
||||
};
|
||||
|
||||
// oeffneProtokoll: lädt das volle Protokoll (Teilnehmer + Punkte) und
|
||||
// wechselt in die Detailansicht.
|
||||
window.oeffneProtokoll = function(id){
|
||||
const container = document.getElementById('protokolleContainer');
|
||||
if(container) container.innerHTML = '<span class="hint">Lade Protokoll…</span>';
|
||||
fetch(baseUrl() + '/project-protokolle/' + encodeURIComponent(id))
|
||||
.then(r => { if(!r.ok) throw new Error('Server HTTP ' + r.status); return r.json(); })
|
||||
.then(d => {
|
||||
if(!d.ok || !d.protokoll) throw new Error(d.error || 'nicht gefunden');
|
||||
currentProtokoll = d.protokoll;
|
||||
renderProtokollDetail();
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('Protokoll laden fehlgeschlagen:', err);
|
||||
if(container) container.innerHTML = '<span class="hint">Protokoll konnte nicht geladen werden.</span>';
|
||||
});
|
||||
};
|
||||
|
||||
// schliesseProtokollDetail: zurück zur Listenansicht.
|
||||
window.schliesseProtokollDetail = function(){
|
||||
currentProtokoll = null;
|
||||
renderProtokolleListe();
|
||||
};
|
||||
|
||||
// ---------- Detailansicht ----------
|
||||
|
||||
function renderProtokollDetail(){
|
||||
const container = document.getElementById('protokolleContainer');
|
||||
if(!container || !currentProtokoll) return;
|
||||
const p = currentProtokoll;
|
||||
const locked = app.isLocked();
|
||||
|
||||
const datumDisplay = formatDateDE(parseDateISO(p.datum)) || '';
|
||||
const quelleHinweis = p.quelleDateiname
|
||||
? ` · importiert aus <a href="${baseUrl()}/project-protokolle/${esc(p.id)}/quelldatei" target="_blank" rel="noopener">${esc(p.quelleDateiname)}</a>`
|
||||
: '';
|
||||
// Hinweisbanner: nur bei einem importierten Protokoll (quelleDateiname
|
||||
// gesetzt) — die automatisch erkannten Punkte sind ein Entwurf, siehe
|
||||
// erkennePunkteAusText() (server.js) und CLAUDE.md "### Protokolle".
|
||||
// Kein eigener Zustand: leitet sich allein aus dem geladenen Protokoll
|
||||
// ab, verschwindet also nie von selbst, sondern nur wenn man das
|
||||
// Protokoll erneut öffnet — bewusst so, die Herkunft bleibt sichtbar.
|
||||
const importBanner = p.quelleDateiname
|
||||
? `<div class="protokoll-import-banner">🔎 Punkte automatisch aus „${esc(p.quelleDateiname)}“ erkannt — bitte vor dem Speichern prüfen und bei Bedarf korrigieren.</div>`
|
||||
: '';
|
||||
// Rein clientseitiges Merkmal (siehe importiereProtokoll() oben) — nur
|
||||
// direkt nach GENAU dem Import gesetzt, der die KI angefordert, aber
|
||||
// nicht erreicht hat. Bewusst deutlich wärmer als importBanner oben
|
||||
// (amber statt dem neutralen Gelb-Ton), damit ein echter Fehlschlag
|
||||
// nicht im selben Ton wie die übliche "bitte prüfen"-Erinnerung
|
||||
// untergeht, und bewusst kein Toast (verschwindet von selbst) — genau
|
||||
// das war Eriks Rückmeldung, nachdem er von einem KI-Fehlschlag nichts
|
||||
// in der Oberfläche sah.
|
||||
const kiFallbackBanner = p._kiFallbackHinweis
|
||||
? `<div class="protokoll-ki-fallback-banner">⚠️ Die KI-Erkennung ist fehlgeschlagen (${esc(p._kiFallbackHinweis)}) — die Punkte wurden stattdessen per Textmuster erkannt.</div>`
|
||||
: '';
|
||||
const kopf = `
|
||||
${kiFallbackBanner}
|
||||
${importBanner}
|
||||
<div class="protokoll-detail-head">
|
||||
<div>
|
||||
<h3 style="margin:0 0 4px">${esc(p.betreff || 'ohne Betreff')}</h3>
|
||||
<div class="protokoll-detail-meta">Protokoll Nr. ${esc(p.nummer)} · verfasst von ${esc(p.verfasserName || '—')}${quelleHinweis}</div>
|
||||
</div>
|
||||
<div class="toolbar">
|
||||
${locked ? '' : `<button class="secondary" onclick="exportProtokollPDF('${esc(p.id)}')">Als PDF exportieren</button>`}
|
||||
${locked ? '' : `<button class="secondary" onclick="loescheProtokoll()">Löschen</button>`}
|
||||
${locked ? '' : `<button onclick="speichereProtokoll()">Speichern</button>`}
|
||||
</div>
|
||||
</div>
|
||||
<div class="protokoll-field-grid">
|
||||
<div class="protokoll-field">
|
||||
<label>Datum</label>
|
||||
<input type="text" id="protokollDatumDisplay" class="range-picker-display" value="${esc(datumDisplay)}" placeholder="Datum wählen…" readonly
|
||||
onclick="openDatePicker({ anchor: this, inputId: 'protokollDatum', displayId: 'protokollDatumDisplay', onApply: syncProtokollDatum })">
|
||||
<input type="hidden" id="protokollDatum" value="${esc(p.datum)}">
|
||||
</div>
|
||||
<div class="protokoll-field">
|
||||
<label>Betreff</label>
|
||||
<input type="text" value="${esc(p.betreff)}" oninput="currentProtokollSetzeFeld('betreff', this.value)">
|
||||
</div>
|
||||
<div class="protokoll-field">
|
||||
<label>Ort</label>
|
||||
<input type="text" value="${esc(p.ort)}" oninput="currentProtokollSetzeFeld('ort', this.value)">
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
const teilnehmerZeilen = p.teilnehmer.map((t, idx) => `
|
||||
<div class="protokoll-teilnehmer-row">
|
||||
<input type="text" value="${esc(t.name)}" placeholder="Name" oninput="currentProtokollSetzeTeilnehmerFeld(${idx}, 'name', this.value)">
|
||||
<input type="text" value="${esc(t.firma)}" placeholder="Firma / Rolle" oninput="currentProtokollSetzeTeilnehmerFeld(${idx}, 'firma', this.value)">
|
||||
<label class="protokoll-anwesend-toggle"><input type="checkbox" ${t.anwesend ? 'checked' : ''} onchange="currentProtokollSetzeTeilnehmerFeld(${idx}, 'anwesend', this.checked)"> anwesend</label>
|
||||
<span class="protokoll-row-remove" onclick="entferneTeilnehmer(${idx})" title="Teilnehmer entfernen">✕</span>
|
||||
</div>`).join('');
|
||||
const teilnehmerBlock = `
|
||||
<div class="protokoll-block">
|
||||
<div class="protokoll-block-head"><h4>Teilnehmer</h4></div>
|
||||
${teilnehmerZeilen || '<span class="hint">Noch keine Teilnehmer.</span>'}
|
||||
${locked ? '' : '<div class="add-row-btn" onclick="fuegeTeilnehmerHinzu()">+ Teilnehmer</div>'}
|
||||
</div>`;
|
||||
|
||||
container.innerHTML = `<div class="plan-panel">
|
||||
<div class="protokoll-back-link" onclick="schliesseProtokollDetail()">← Zurück zur Liste</div>
|
||||
${kopf}
|
||||
${teilnehmerBlock}
|
||||
<div class="protokoll-block" id="protokollPunkteBlock">${renderPunkteBlockHtml()}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// renderPunkteBlockHtml: baut nur den Punkte-Block (in Abschnitten je
|
||||
// Gruppe) — eigene Funktion, weil er nach einer Gruppenänderung isoliert
|
||||
// neu gerendert wird, ohne den Rest der Detailansicht (und damit den Fokus
|
||||
// in anderen Feldern) anzufassen.
|
||||
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 => {
|
||||
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' : '';
|
||||
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>
|
||||
</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>'}
|
||||
</div>`;
|
||||
}).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>`;
|
||||
|
||||
return `<div class="protokoll-block-head"><h4>Punkte</h4></div>
|
||||
${gruppenHtml || '<span class="hint">Noch keine Punkte.</span>'}
|
||||
${datalist}
|
||||
${addOhneGruppe}
|
||||
<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>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderPunktZeileHtml(punkt, idx, nummer, istErste, istLetzte){
|
||||
const locked = app.isLocked();
|
||||
const terminDisplay = formatDateDE(parseDateISO(punkt.termin)) || '';
|
||||
const neuChip = punkt.neu ? '<span class="protokoll-chip-neu">Neu</span>' : '';
|
||||
const 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>`}
|
||||
</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
|
||||
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>`;
|
||||
}
|
||||
|
||||
// ---------- Feld-Mutationen (ohne Neu-Rendern, siehe Datei-Kopfkommentar) ----------
|
||||
|
||||
window.currentProtokollSetzeFeld = function(feld, wert){
|
||||
if(!currentProtokoll) return;
|
||||
currentProtokoll[feld] = wert;
|
||||
};
|
||||
window.syncProtokollDatum = function(){
|
||||
if(!currentProtokoll) return;
|
||||
currentProtokoll.datum = document.getElementById('protokollDatum').value;
|
||||
};
|
||||
window.currentProtokollSetzeTeilnehmerFeld = function(idx, feld, wert){
|
||||
if(!currentProtokoll || !currentProtokoll.teilnehmer[idx]) return;
|
||||
currentProtokoll.teilnehmer[idx][feld] = wert;
|
||||
};
|
||||
window.currentProtokollSetzePunktFeld = function(idx, feld, wert){
|
||||
if(!currentProtokoll || !currentProtokoll.punkte[idx]) return;
|
||||
currentProtokoll.punkte[idx][feld] = wert;
|
||||
};
|
||||
window.syncProtokollPunktTermin = function(idx){
|
||||
if(!currentProtokoll || !currentProtokoll.punkte[idx]) return;
|
||||
const el = document.getElementById('protokollPunktTermin_' + idx);
|
||||
if(el) currentProtokoll.punkte[idx].termin = el.value;
|
||||
};
|
||||
window.currentProtokollSetzePunktErledigt = function(idx, checked){
|
||||
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);
|
||||
};
|
||||
// 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;
|
||||
const block = document.getElementById('protokollPunkteBlock');
|
||||
if(block) block.innerHTML = renderPunkteBlockHtml();
|
||||
};
|
||||
|
||||
// ---------- Strukturelle Änderungen (Zeile hinzufügen/entfernen) ----------
|
||||
|
||||
window.fuegeTeilnehmerHinzu = function(){
|
||||
if(!currentProtokoll || app.isLocked()) return;
|
||||
currentProtokoll.teilnehmer.push({ name: '', firma: '', anwesend: true });
|
||||
renderProtokollDetail();
|
||||
};
|
||||
window.entferneTeilnehmer = function(idx){
|
||||
if(!currentProtokoll || app.isLocked()) return;
|
||||
currentProtokoll.teilnehmer.splice(idx, 1);
|
||||
renderProtokollDetail();
|
||||
};
|
||||
window.fuegePunktHinzu = function(gruppe){
|
||||
if(!currentProtokoll || app.isLocked()) return;
|
||||
// nummer wird nicht gesetzt — sie ergibt sich beim Rendern automatisch
|
||||
// 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({
|
||||
id: null, nummer: '', gruppe: gruppe || '', neu: true,
|
||||
thema: '', text: '', zustaendig: '', termin: '', erledigt: false,
|
||||
erstelltAm: currentProtokoll.datum || ''
|
||||
});
|
||||
const block = document.getElementById('protokollPunkteBlock');
|
||||
if(block) block.innerHTML = renderPunkteBlockHtml();
|
||||
};
|
||||
window.entfernePunkt = function(idx){
|
||||
if(!currentProtokoll || app.isLocked()) return;
|
||||
currentProtokoll.punkte.splice(idx, 1);
|
||||
const block = document.getElementById('protokollPunkteBlock');
|
||||
if(block) block.innerHTML = renderPunkteBlockHtml();
|
||||
};
|
||||
|
||||
// ---------- Speichern / Löschen ----------
|
||||
|
||||
// speichereProtokoll: PUT /project-protokolle/:id — ersetzt Teilnehmer und
|
||||
// Punkte komplett (siehe server.js). Ausdrückliches Speichern, kein
|
||||
// Auto-Save, wie die Board-Kommentare.
|
||||
window.speichereProtokoll = function(){
|
||||
if(!currentProtokoll || app.isLocked()) return;
|
||||
// Errechnete Nummern vor dem Senden in die Punkte schreiben, damit die
|
||||
// gespeicherten Werte mit der Anzeige übereinstimmen (maßgeblich ist beim
|
||||
// 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]; });
|
||||
const body = {
|
||||
datum: currentProtokoll.datum,
|
||||
betreff: currentProtokoll.betreff,
|
||||
ort: currentProtokoll.ort,
|
||||
teilnehmer: currentProtokoll.teilnehmer.filter(t => String(t.name || '').trim()),
|
||||
punkte: currentProtokoll.punkte
|
||||
};
|
||||
fetch(baseUrl() + '/project-protokolle/' + encodeURIComponent(currentProtokoll.id), {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json; charset=utf-8' },
|
||||
body: JSON.stringify(body)
|
||||
})
|
||||
.then(r => r.json().then(d => ({ status: r.status, ok: r.ok, d })))
|
||||
.then(({ ok, d }) => {
|
||||
if(!ok || !d.ok){ alert('Protokoll konnte nicht gespeichert werden: ' + (d && d.error ? d.error : 'unbekannter Fehler')); return; }
|
||||
currentProtokoll = d.protokoll;
|
||||
renderProtokollDetail();
|
||||
ladeProtokollListe();
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('Protokoll speichern fehlgeschlagen:', err);
|
||||
alert('Protokoll konnte nicht gespeichert werden: ' + (err.message || err));
|
||||
});
|
||||
};
|
||||
|
||||
window.loescheProtokoll = function(){
|
||||
if(!currentProtokoll || app.isLocked()) return;
|
||||
if(!confirm('Protokoll Nr. ' + currentProtokoll.nummer + ' wirklich löschen?')) return;
|
||||
fetch(baseUrl() + '/project-protokolle/' + encodeURIComponent(currentProtokoll.id), { method: 'DELETE' })
|
||||
.then(r => { if(!r.ok) throw new Error('Server HTTP ' + r.status); return r.json(); })
|
||||
.then(d => {
|
||||
if(!d.ok){ alert('Protokoll konnte nicht gelöscht werden.'); return; }
|
||||
currentProtokoll = null;
|
||||
ladeProtokollListe();
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('Protokoll löschen fehlgeschlagen:', err);
|
||||
alert('Protokoll konnte nicht gelöscht werden: ' + (err.message || err));
|
||||
});
|
||||
};
|
||||
|
||||
// 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
|
||||
// vorhandenen protokolleCache/currentProtokoll).
|
||||
window.renderPlanProtokolle = function(){
|
||||
if(currentProtokoll) renderProtokollDetail();
|
||||
else renderProtokolleListe();
|
||||
};
|
||||
})();
|
||||
@@ -167,7 +167,13 @@
|
||||
depEnd = parseDateISO(depTodo.end);
|
||||
} else {
|
||||
const depMs = milestoneMap.get(depId);
|
||||
if(depMs) depEnd = parseDateISO(depMs.date);
|
||||
if(depMs){
|
||||
// Den Meilenstein ZUERST einplanen: Vorher wurde gegen sein ALTES
|
||||
// Datum gerechnet, und weil er erst nach allen Aufgaben verschoben
|
||||
// wurde, konnte eine Aufgabe vor ihrem eigenen Vorgänger beginnen.
|
||||
scheduleMilestone(depMs);
|
||||
depEnd = parseDateISO(depMs.date);
|
||||
}
|
||||
}
|
||||
if(!depEnd) return;
|
||||
const candidate = addDays(depEnd, 1);
|
||||
@@ -222,8 +228,11 @@
|
||||
processed.add(mId);
|
||||
}
|
||||
|
||||
todos.forEach(t => scheduleTodo(t));
|
||||
// Meilensteine zuerst: scheduleTodo() ruft scheduleMilestone() für seine
|
||||
// Vorgänger zwar selbst auf, aber ein Meilenstein OHNE Nachfolger-Aufgabe
|
||||
// würde sonst erst am Ende verschoben.
|
||||
milestones.forEach(m => scheduleMilestone(m));
|
||||
todos.forEach(t => scheduleTodo(t));
|
||||
if(moved.size > 0){
|
||||
app.savePlan();
|
||||
renderPlanGantt();
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
// -----------------------------
|
||||
// Projektbereich — Bereichs-Navigation & Dashboard
|
||||
// -----------------------------
|
||||
// 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):
|
||||
//
|
||||
// dashboard — Kennzahlen, Sitzungskommentar, Leistungsphasen-
|
||||
// Fortschritt, nächste Termine, Team, Kompakt-Karten
|
||||
// terminplan — GANTT mit Werkzeugleiste und Ressourcen-Auslastung
|
||||
// aufgaben — Aufgaben- und Meilensteinliste (js/plan/plan-tasks.js)
|
||||
// leistung — Leistungsstand & Abrechnung (js/plan/plan-leistung.js)
|
||||
// dateien — Datei-Upload/-Freigabe (js/plan/plan-files.js)
|
||||
// protokolle — Besprechungsprotokolle (js/plan/plan-protokoll.js)
|
||||
// einstellungen — Projektangaben, Honorar, beauftragte Leistungsphasen,
|
||||
// Beteiligte (js/plan/plan-settings.js)
|
||||
//
|
||||
// Alle Bereiche liegen gleichzeitig im DOM und werden nur über die Klasse
|
||||
// .is-active ein- bzw. ausgeblendet. Das ist Absicht: die vorhandenen
|
||||
// Render-Funktionen (renderPlanInfo, renderResourceLoad, renderProjectFiles …)
|
||||
// befüllen ihre Elemente unabhängig davon, ob der Bereich gerade sichtbar ist,
|
||||
// und brauchten für den Umbau keine Änderung.
|
||||
//
|
||||
// Der gewählte Bereich steht im URL-Fragment (z. B. …/Projektplan/Haus%20A
|
||||
// ?weekLabel=…&projNr=…#aufgaben), damit ein Link auf einen bestimmten Bereich
|
||||
// verschickt werden kann. Ein unbekanntes Fragment fällt auf "dashboard"
|
||||
// zurück.
|
||||
//
|
||||
// Läuft wie alle plan-Module in einem IIFE; Zustand: window.PlanApp
|
||||
// (plan-state.js).
|
||||
|
||||
(function(){
|
||||
const app = window.PlanApp;
|
||||
|
||||
// PLAN_SECTIONS: gültige Bereichsschlüssel in der Reihenfolge der
|
||||
// Navigationsleiste. Muss zu den data-section-Attributen in
|
||||
// Test.html passen.
|
||||
const PLAN_SECTIONS = ['dashboard', 'terminplan', 'aufgaben', 'leistung', 'dateien', 'protokolle', 'einstellungen'];
|
||||
|
||||
// showPlanSection: schaltet auf einen Bereich um, hebt den zugehörigen
|
||||
// Knopf hervor und rendert den Inhalt des Bereichs neu. Das Neu-Rendern ist
|
||||
// beim Terminplan zwingend: die Abhängigkeitslinien im GANTT werden aus
|
||||
// Pixel-Positionen berechnet, die in einem ausgeblendeten Container alle 0
|
||||
// wären. Parameter: key (Bereichsschlüssel). Keine Rückgabe.
|
||||
window.showPlanSection = function(key){
|
||||
if(PLAN_SECTIONS.indexOf(key) === -1) key = 'dashboard';
|
||||
app.section = key;
|
||||
|
||||
PLAN_SECTIONS.forEach(k => {
|
||||
const sec = document.getElementById('planSection-' + k);
|
||||
if(sec) sec.classList.toggle('is-active', k === key);
|
||||
});
|
||||
document.querySelectorAll('#planNav .plan-nav-item').forEach(btn => {
|
||||
btn.classList.toggle('is-active', btn.dataset.section === key);
|
||||
});
|
||||
|
||||
// Bereich im URL-Fragment festhalten (teilbarer Link), ohne einen
|
||||
// History-Eintrag je Klick zu erzeugen.
|
||||
try {
|
||||
history.replaceState(null, '', location.pathname + location.search + '#' + key);
|
||||
} catch(e) { /* z. B. file:// — dann bleibt die Adresse eben unverändert */ }
|
||||
|
||||
if(!app.project) return;
|
||||
if(key === 'terminplan' && typeof renderPlanGantt === 'function') renderPlanGantt();
|
||||
if(key === 'aufgaben' && typeof renderPlanTasks === 'function') renderPlanTasks();
|
||||
if(key === 'leistung' && typeof renderPlanLeistung === 'function') renderPlanLeistung();
|
||||
if(key === 'protokolle' && typeof renderPlanProtokolle === 'function') renderPlanProtokolle();
|
||||
if(key === 'einstellungen' && typeof renderPlanSettings === 'function') renderPlanSettings();
|
||||
if(key === 'dashboard' && typeof window.renderPlanDashboard === 'function') window.renderPlanDashboard();
|
||||
};
|
||||
|
||||
// initPlanSections: wählt beim Seitenaufbau den Bereich aus dem
|
||||
// URL-Fragment (sonst "dashboard"). Wird von renderPlanPage()
|
||||
// (plan-state.js) aufgerufen, also erst wenn Woche und Projekt geladen
|
||||
// sind. Keine Parameter, keine Rückgabe.
|
||||
function initPlanSections(){
|
||||
const fromHash = (location.hash || '').replace(/^#/, '');
|
||||
// Ein Link auf #dateien/#leistung/#protokolle eines Sandbox-Plans (dessen
|
||||
// Knöpfe ausgeblendet sind, siehe renderPlanPage(), plan-state.js) fiele
|
||||
// sonst auf einen Bereich zurück, der zwar existiert, aber keinen
|
||||
// sichtbaren Weg mehr dorthin hätte. Protokolle hängen wie Dateien an
|
||||
// project.nr, den ein Sandbox-Plan nicht hat.
|
||||
const gesperrtFuerSandbox = app.isSandboxPlan()
|
||||
&& (fromHash === 'dateien' || fromHash === 'leistung' || fromHash === 'protokolle');
|
||||
window.showPlanSection(!gesperrtFuerSandbox && PLAN_SECTIONS.indexOf(fromHash) !== -1 ? fromHash : 'dashboard');
|
||||
}
|
||||
|
||||
// hidePlanNav: blendet die Bereichsleiste aus. Wird von plan-state.js
|
||||
// gerufen, wenn gar kein Projekt geladen werden konnte (fehlende
|
||||
// URL-Parameter, unbekannte Sitzung, Ladefehler) — dann führte jeder
|
||||
// Bereich nur in eine leere Maske. Keine Parameter, keine Rückgabe.
|
||||
function hidePlanNav(){
|
||||
const nav = document.getElementById('planNav');
|
||||
if(nav) nav.style.display = 'none';
|
||||
}
|
||||
|
||||
// showPlanNav: Gegenstück zu hidePlanNav(). Nötig, seit der Projektbereich
|
||||
// ein Bereich der Hauptseite ist und nacheinander mehrere Projekte zeigen
|
||||
// kann: Nach einem Fehlschlag (hidePlanNav) muss die Leiste beim nächsten,
|
||||
// erfolgreichen openPlanView() wieder erscheinen. Auf der früheren
|
||||
// eigenständigen Seite war das unnötig, weil jeder Aufruf eine frische
|
||||
// Seite lud. Keine Parameter, keine Rückgabe.
|
||||
function showPlanNav(){
|
||||
const nav = document.getElementById('planNav');
|
||||
if(nav) nav.style.display = '';
|
||||
}
|
||||
|
||||
// upcomingEntries: sammelt die nächsten Termine des Projekts — offene
|
||||
// Aufgaben mit Endtermin und Meilensteine mit Datum — in einer gemeinsamen,
|
||||
// chronologisch sortierten Liste. Überfällige Einträge stehen zuerst, damit
|
||||
// sie im Dashboard nicht untergehen. Rückgabe: Array von
|
||||
// { kind, title, date, overdue }.
|
||||
function upcomingEntries(){
|
||||
const plan = app.ensurePlanLocal();
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const rows = [];
|
||||
|
||||
(plan.todos || []).forEach(t => {
|
||||
if(t.done) return;
|
||||
const d = parseDateISO(t.end);
|
||||
if(!d) return;
|
||||
rows.push({ kind: 'Aufgabe', title: t.title || 'Aufgabe', date: d, overdue: d < today });
|
||||
});
|
||||
(plan.milestones || []).forEach(m => {
|
||||
const d = parseDateISO(m.date);
|
||||
if(!d) return;
|
||||
rows.push({ kind: 'Meilenstein', title: m.name || 'Meilenstein', date: d, overdue: d < today });
|
||||
});
|
||||
|
||||
rows.sort((a, b) => a.date - b.date);
|
||||
return rows;
|
||||
}
|
||||
|
||||
// renderPlanDashboard: füllt die beiden eigenen Karten des Dashboards
|
||||
// ("Nächste Termine" und "Team"). Die übrigen Inhalte des Bereichs
|
||||
// stammen aus bereits vorhandenen Render-Funktionen: die KPI-Kacheln aus
|
||||
// renderPlanKpis() (plan-gantt.js), Kommentar und Leistungsphasen aus
|
||||
// renderPlanInfo() (plan-info.js), die Kompakt-Karten aus
|
||||
// renderResourceLoad()/renderProjectFiles()/renderPlanHistory().
|
||||
// Keine Parameter, keine Rückgabe.
|
||||
window.renderPlanDashboard = function(){
|
||||
if(!app.project) return;
|
||||
|
||||
const upcomingEl = document.getElementById('planDashUpcoming');
|
||||
if(upcomingEl){
|
||||
const rows = upcomingEntries().slice(0, 8);
|
||||
upcomingEl.innerHTML = rows.length === 0
|
||||
? '<div class="plan-task-empty">Keine offenen Termine hinterlegt.</div>'
|
||||
: '<div class="plan-upcoming-list">' + rows.map(r => `<div class="plan-upcoming-item${r.overdue ? ' overdue' : ''}">
|
||||
<span class="plan-upcoming-date">${esc(formatDateDE(r.date))}</span>
|
||||
<span>${esc(r.title)}</span>
|
||||
<span class="plan-upcoming-kind">${esc(r.kind)}</span>
|
||||
</div>`).join('') + '</div>';
|
||||
}
|
||||
|
||||
const teamEl = document.getElementById('planDashTeam');
|
||||
if(teamEl){
|
||||
const assignees = projectAssignees(app.project, app.week);
|
||||
if(assignees.length === 0){
|
||||
teamEl.innerHTML = '<div class="plan-task-empty">Diesem Projekt ist noch niemand zugeordnet. Zuordnung im Bereich „Einstellungen“.</div>';
|
||||
} else {
|
||||
const plan = app.ensurePlanLocal();
|
||||
teamEl.innerHTML = '<div class="plan-dash-team">' + assignees.map(a => {
|
||||
const offen = (plan.todos || []).filter(t => !t.done && Array.isArray(t.assignees) && t.assignees.indexOf(a.person.id) !== -1).length;
|
||||
const title = (a.person.fullName || a.person.name) + (a.role ? ' · ' + a.role : '');
|
||||
return `<div class="plan-dash-team-row">
|
||||
<span class="board-avatar ${app.planRoleClass(a.role)} state-${a.state}" title="${esc(title)}">${esc(a.person.name)}</span>
|
||||
<span>${esc(a.person.fullName || a.person.name)}</span>
|
||||
<span class="plan-upcoming-kind">${esc(a.role || '')}</span>
|
||||
<span class="plan-task-meta" style="margin-left:auto">${offen} offen</span>
|
||||
</div>`;
|
||||
}).join('') + '</div>';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Wird das Fragment von außen geändert (Vor-/Zurück-Taste, von Hand
|
||||
// getippter Anker, Link auf dieselbe Seite mit anderem Bereich), dann
|
||||
// mitziehen. showPlanSection() selbst benutzt history.replaceState() und
|
||||
// löst dieses Ereignis daher nicht aus.
|
||||
window.addEventListener('hashchange', () => {
|
||||
if(currentTab !== 'plan') return;
|
||||
const key = (location.hash || '').replace(/^#/, '');
|
||||
if(PLAN_SECTIONS.indexOf(key) !== -1 && key !== app.section) window.showPlanSection(key);
|
||||
});
|
||||
|
||||
app.initPlanSections = initPlanSections;
|
||||
app.hidePlanNav = hidePlanNav;
|
||||
app.showPlanNav = showPlanNav;
|
||||
})();
|
||||
@@ -0,0 +1,271 @@
|
||||
// -----------------------------
|
||||
// Projektbereich — Einstellungen
|
||||
// -----------------------------
|
||||
// Bereich "Einstellungen" des Projektbereichs (siehe js/plan/plan-sections.js).
|
||||
// Hier stehen die Angaben, die das Projekt als Ganzes betreffen, an einer
|
||||
// Stelle zusammen:
|
||||
//
|
||||
// * Projektnummer, -name und Schlagwörter (hier bearbeitbar — der frühere
|
||||
// Projekte-Tab, in dem dieselben Felder ein zweites Mal standen, ist in
|
||||
// die Projektliste aufgegangen und zeigt sie nur noch an)
|
||||
// * LP-Aufwand (die Gewichtungsmatrix, vormals ebenfalls im Projekte-Tab)
|
||||
// * Gruppe und amtierende Leistungsphase (Auswahlfelder; gefüllt und
|
||||
// gespeichert von renderPlanInfo()/setPlanGroup()/setPlanActiveLp() in
|
||||
// js/plan/plan-info.js — dieses Modul rührt sie nicht an)
|
||||
// * Honorar (netto) und die BEAUFTRAGTEN LEISTUNGSPHASEN
|
||||
// * Beteiligte (Rollen-/Personen-Matrix, ebenfalls aus plan-info.js)
|
||||
//
|
||||
// Honorar und beauftragte Leistungsphasen standen früher ausschließlich im
|
||||
// Dialog "Terminplan aus Honorar" (js/plan/plan-honorar.js). Sie sind
|
||||
// Stammdaten des Projekts und gehören deshalb in die Projekteinstellungen;
|
||||
// im Rechen-Dialog stehen sie weiterhin mit, damit dieser für sich allein
|
||||
// bedienbar bleibt. Beide Masken schreiben dieselben Projektfelder
|
||||
// (p.honorar, p.beauftragteLP) über setHonorarBetrag()/toggleBeauftragteLp()
|
||||
// aus plan-honorar.js und rendern einander nach jeder Änderung neu, sodass
|
||||
// sie nicht auseinanderlaufen können.
|
||||
//
|
||||
// Gespeichert wird über den normalen Weg app.savePlan() (die Felder sind Teil
|
||||
// des Wochen-JSON, Tabellen projects.honorar bzw. project_beauftragte_lph) —
|
||||
// es gibt dafür keinen eigenen Endpunkt.
|
||||
//
|
||||
// Läuft wie alle plan-Module in einem IIFE; Zustand: window.PlanApp
|
||||
// (plan-state.js).
|
||||
|
||||
(function(){
|
||||
const app = window.PlanApp;
|
||||
|
||||
// renderPlanSettings: hält den Einstellungen-Bereich auf dem Stand des
|
||||
// Projekts. Wird beim Seitenaufbau (renderPlanPage(), plan-state.js), beim
|
||||
// Wechsel in den Bereich (showPlanSection(), plan-sections.js) und nach
|
||||
// jeder Änderung an Honorar oder beauftragten Leistungsphasen aufgerufen.
|
||||
// Keine Parameter, keine Rückgabe.
|
||||
window.renderPlanSettings = function(){
|
||||
const p = app.project;
|
||||
if(!p) return;
|
||||
const locked = app.isLocked();
|
||||
const sandbox = app.isSandboxPlan();
|
||||
|
||||
const lockHint = document.getElementById('planSettingsLockHint');
|
||||
if(lockHint) lockHint.style.display = locked ? '' : 'none';
|
||||
|
||||
// Felder, die nur bei einem echten Projekt Bedeutung haben (Auftrags-
|
||||
// Workflow, Dateien-Zuordnung über die Nummer, Gewerke-Filter des
|
||||
// Wochenberichts, Auslastungs-Ersatzgröße) — für die Planung-Sandbox
|
||||
// ausgeblendet, siehe CLAUDE.md "Planung — projektunabhängige
|
||||
// Terminplan-Sandbox". Honorar + beauftragte Leistungsphasen (Grundlage
|
||||
// des Terminplan-Rechners) und die Beteiligten-Matrix bleiben sichtbar.
|
||||
['settingsProjNr', 'settingsAuftragsstatus', 'settingsGewerke', 'settingsGroesse']
|
||||
.forEach(id => {
|
||||
const el = document.getElementById(id);
|
||||
const row = el && el.closest('.form-row');
|
||||
if(row) row.style.display = sandbox ? 'none' : '';
|
||||
});
|
||||
|
||||
// Projektnummer und -name: das fokussierte Feld nicht überschreiben
|
||||
// (dieselbe Vorsichtsmaßnahme wie beim Honorar weiter unten).
|
||||
const nrEl = document.getElementById('settingsProjNr');
|
||||
if(nrEl && document.activeElement !== nrEl){
|
||||
nrEl.value = p.nr || '';
|
||||
nrEl.disabled = locked;
|
||||
}
|
||||
const nameEl = document.getElementById('settingsProjName');
|
||||
if(nameEl && document.activeElement !== nameEl){
|
||||
nameEl.value = p.name || '';
|
||||
nameEl.disabled = locked;
|
||||
}
|
||||
|
||||
// Projektgröße: Sie ist der Ersatz für ein fehlendes Honorar, deshalb
|
||||
// steht unter dem Regler, ob sie gerade überhaupt zählt. Eine Zahl, die
|
||||
// wirkungslos ist, ohne dass man es sieht, wäre schlimmer als keine.
|
||||
const groesseEl = document.getElementById('settingsGroesse');
|
||||
if(groesseEl){
|
||||
const g = Number(p.groesse) || 0;
|
||||
if(document.activeElement !== groesseEl) groesseEl.value = g > 0 ? g : '';
|
||||
groesseEl.disabled = locked;
|
||||
const hinweisEl = document.getElementById('settingsGroesseHinweis');
|
||||
if(hinweisEl){
|
||||
const honorar = Number(p.honorar) || 0;
|
||||
hinweisEl.textContent = honorar > 0
|
||||
? 'Ein Honorar ist hinterlegt — die Auslastung rechnet damit, die Größe bleibt unberücksichtigt.'
|
||||
: (g > 0
|
||||
? 'Kein Honorar hinterlegt: Die Auslastung rechnet mit dieser Größe. 0 = keine Angabe, 1 = großes Projekt.'
|
||||
: 'Ohne Honorar und ohne Größe fließt dieses Projekt in keine Auslastung ein. 0 = keine Angabe, 1 = großes Projekt.');
|
||||
}
|
||||
}
|
||||
|
||||
// Schlagwörter: dieselbe Chip-Liste wie im früheren Projekte-Tab.
|
||||
const tagsEl = document.getElementById('settingsProjTags');
|
||||
if(tagsEl){
|
||||
const tags = Array.isArray(p.tags) ? p.tags : [];
|
||||
tagsEl.innerHTML = tags.map(t => `<span class="tag-chip" data-tag="${esc(t)}">${esc(t)}${locked ? '' :
|
||||
` <span class="tag-remove" title="Schlagwort entfernen" onclick="planRemoveTag(this.closest('.tag-chip').dataset.tag)">×</span>`}</span>`).join('')
|
||||
+ (locked ? '' : `<input type="text" class="tag-input" placeholder="+ Schlagwort" onkeydown="planTagKeydown(event)">`);
|
||||
}
|
||||
|
||||
// Gewerke: bestimmen, in welcher Gewerke-Besprechung des Wochenberichts
|
||||
// dieses Projekt erscheint (berichtProjekte(),
|
||||
// js/wochenbericht/wochenbericht.js). Bis zur Einführung dieses Felds
|
||||
// ließ sich das Gewerk nur beim Anlegen im Assistenten setzen; ältere
|
||||
// Projekte wurden einmalig aus ihrem Abteilungs-Schlagwort übernommen
|
||||
// (migrateBackfillProjectGewerkeAusTags(), server.js) — hier lässt sich
|
||||
// das jederzeit nachträglich korrigieren.
|
||||
const gewerkeEl = document.getElementById('settingsGewerke');
|
||||
if(gewerkeEl && typeof GEWERK_LABELS === 'object'){
|
||||
const gesetzt = new Set(Array.isArray(p.gewerke) ? p.gewerke : []);
|
||||
gewerkeEl.innerHTML = Object.keys(GEWERK_LABELS).map(g => `<label class="beauftragte-lp-item" title="${esc(GEWERK_LABELS[g])}">
|
||||
<input type="checkbox" value="${esc(g)}" ${gesetzt.has(g) ? 'checked' : ''} ${locked ? 'disabled' : ''}
|
||||
onchange="togglePlanGewerk('${esc(g)}', this.checked)">
|
||||
${esc(g)}
|
||||
</label>`).join('');
|
||||
}
|
||||
|
||||
// Auftragsstatus: Das Auswahlfeld kommt fertig aus js/auftrag.js, damit
|
||||
// Beschriftungen und Schreibweg an einer Stelle stehen (dieselbe Maske
|
||||
// nutzt der Dialog "Neues Projekt").
|
||||
const statusEl = document.getElementById('settingsAuftragsstatus');
|
||||
if(statusEl && typeof auftragsstatusSelectHtml === 'function'){
|
||||
statusEl.innerHTML = auftragsstatusSelectHtml(p, { disabled: locked });
|
||||
}
|
||||
|
||||
const aufwandBtn = document.getElementById('settingsAufwandBtn');
|
||||
if(aufwandBtn) aufwandBtn.disabled = locked || typeof openLpModal !== 'function';
|
||||
|
||||
// Honorar: das fokussierte Feld nicht überschreiben, sonst springt beim
|
||||
// Tippen der Cursor (gleiche Vorsichtsmaßnahme wie im Honorar-Dialog).
|
||||
const honorarEl = document.getElementById('settingsHonorar');
|
||||
if(honorarEl){
|
||||
if(document.activeElement !== honorarEl){
|
||||
honorarEl.value = Number(p.honorar) > 0 ? p.honorar : '';
|
||||
}
|
||||
honorarEl.disabled = locked;
|
||||
}
|
||||
|
||||
// Beauftragte Leistungsphasen: dieselbe Kreuzchen-Liste wie im
|
||||
// Honorar-Dialog, gespeichert über toggleBeauftragteLp() (plan-honorar.js).
|
||||
const lpEl = document.getElementById('settingsBeauftragteLp');
|
||||
if(lpEl){
|
||||
const gesetzt = new Set(Array.isArray(p.beauftragteLP) ? p.beauftragteLP : []);
|
||||
lpEl.innerHTML = LPHS.map(l => `<label class="beauftragte-lp-item">
|
||||
<input type="checkbox" value="${esc(l)}" ${gesetzt.has(l) ? 'checked' : ''} ${locked ? 'disabled' : ''}
|
||||
onchange="toggleBeauftragteLp('${esc(l)}', this.checked)">
|
||||
${esc(l)}
|
||||
</label>`).join('');
|
||||
}
|
||||
};
|
||||
|
||||
// setPlanProjNr: ändert die Projektnummer.
|
||||
//
|
||||
// Die Nummer ist mehr als eine Beschriftung: Hochgeladene Dateien hängen an
|
||||
// (Abteilung, Projektnummer) — nicht an der Projekt-ID, weil die bei jedem
|
||||
// Klonen einer Sitzung neu vergeben wird (siehe "File uploads" in
|
||||
// CLAUDE.md). Wird die Nummer geändert, findet das Projekt seine bisherigen
|
||||
// Dateien nicht mehr; deshalb die Rückfrage. Parameter: val. Keine Rückgabe.
|
||||
window.setPlanProjNr = function(val){
|
||||
const p = app.project;
|
||||
if(!p || app.isLocked()) return;
|
||||
const neuVal = (val || '').trim();
|
||||
if(neuVal === (p.nr || '')) return;
|
||||
if(p.nr && !confirm('Die Projektnummer "' + p.nr + '" wird zu "' + neuVal + '".\n\n'
|
||||
+ 'Bereits hochgeladene Dateien sind der alten Nummer zugeordnet und erscheinen danach nicht mehr in diesem Projekt.\n\nFortfahren?')){
|
||||
renderPlanSettings();
|
||||
return;
|
||||
}
|
||||
p.nr = neuVal;
|
||||
app.savePlan();
|
||||
renderPlanSettings();
|
||||
};
|
||||
|
||||
// setPlanProjName: ändert die Projektbezeichnung. Parameter: val.
|
||||
window.setPlanProjName = function(val){
|
||||
const p = app.project;
|
||||
if(!p || app.isLocked()) return;
|
||||
p.name = (val || '').trim();
|
||||
app.savePlan();
|
||||
// Die Bezeichnung steht an drei weiteren Stellen: in der Kopfzeile des
|
||||
// Projektbereichs, im Eintrag der Seitenleiste und in der Adresse.
|
||||
// renderPlanPage() würde alle drei erneuern, ist aber modulintern in
|
||||
// plan-state.js und von hier nicht erreichbar — deshalb gezielt.
|
||||
const titelEl = document.getElementById('planTitle');
|
||||
if(titelEl) titelEl.textContent = p.name;
|
||||
if(typeof syncPlanNavItem === 'function') syncPlanNavItem();
|
||||
if(typeof updateRouteUrl === 'function' && typeof currentTab !== 'undefined' && currentTab === 'plan'){
|
||||
if(window.PlanApp) PlanApp.projName = p.name;
|
||||
updateRouteUrl('plan');
|
||||
}
|
||||
if(typeof renderBoard === 'function') renderBoard();
|
||||
renderPlanSettings();
|
||||
};
|
||||
|
||||
// setPlanGroesse: übernimmt die Projektgröße (0–1). Gespeichert wird erst
|
||||
// beim Loslassen des Reglers, nicht bei jedem Zwischenwert — sonst liefe
|
||||
// ein Zug über die Skala als zwanzig Speichervorgänge auf den Server.
|
||||
// Parameter: val. Keine Rückgabe.
|
||||
window.setPlanGroesse = function(val){
|
||||
const p = app.project;
|
||||
if(!p || app.isLocked()) return;
|
||||
const n = Math.max(0, Math.min(1, parseFloat(val) || 0));
|
||||
if(n === (Number(p.groesse) || 0)) return;
|
||||
p.groesse = n;
|
||||
app.savePlan();
|
||||
renderPlanSettings();
|
||||
// Die Auslastung rechnet mit diesem Wert.
|
||||
if(typeof renderLoad === 'function' && document.getElementById('loadGrid')) renderLoad();
|
||||
};
|
||||
|
||||
// planTagKeydown: übernimmt ein Schlagwort mit Enter. Parameter: event.
|
||||
window.planTagKeydown = function(event){
|
||||
if(event.key !== 'Enter') return;
|
||||
event.preventDefault();
|
||||
const p = app.project;
|
||||
if(!p || app.isLocked()) return;
|
||||
const tag = (event.target.value || '').trim();
|
||||
if(!tag) return;
|
||||
if(!Array.isArray(p.tags)) p.tags = [];
|
||||
if(!p.tags.includes(tag)) p.tags.push(tag);
|
||||
event.target.value = '';
|
||||
app.savePlan();
|
||||
renderPlanSettings();
|
||||
};
|
||||
|
||||
// planRemoveTag: entfernt ein Schlagwort. Parameter: tag.
|
||||
window.planRemoveTag = function(tag){
|
||||
const p = app.project;
|
||||
if(!p || app.isLocked() || !Array.isArray(p.tags)) return;
|
||||
p.tags = p.tags.filter(t => t !== tag);
|
||||
app.savePlan();
|
||||
renderPlanSettings();
|
||||
};
|
||||
|
||||
// togglePlanGewerk: nimmt ein Gewerk in die Liste der bearbeitenden
|
||||
// Fachbereiche auf bzw. entfernt es. Bestimmt, in welcher Gewerke-
|
||||
// Besprechung des Wochenberichts dieses Projekt erscheint
|
||||
// (berichtProjekte(), js/wochenbericht/wochenbericht.js). Reihenfolge wie
|
||||
// GEWERK_LABELS (js/projekt-wizard.js), damit sie nicht bei jeder Änderung
|
||||
// durcheinandergerät. Parameter: gewerk, checked (Boolean).
|
||||
window.togglePlanGewerk = function(gewerk, checked){
|
||||
if(app.isLocked()) return;
|
||||
const p = app.project;
|
||||
const set = new Set(Array.isArray(p.gewerke) ? p.gewerke : []);
|
||||
if(checked) set.add(gewerk); else set.delete(gewerk);
|
||||
p.gewerke = Object.keys(GEWERK_LABELS).filter(g => set.has(g));
|
||||
app.savePlan();
|
||||
renderPlanSettings();
|
||||
};
|
||||
|
||||
// planOpenAufwand: öffnet die Aufwands-Gewichtungsmatrix. Der Dialog selbst
|
||||
// liegt in js/load/load.js (openLpModal) und wird hier nur aufgerufen —
|
||||
// er gehörte immer zum Projekt, stand aber bisher nur im Projekte-Tab.
|
||||
window.planOpenAufwand = function(){
|
||||
const p = app.project;
|
||||
if(!p || typeof openLpModal !== 'function') return;
|
||||
// openLpModal() sucht das Projekt in currentWeek(). Zeigt der
|
||||
// Projektbereich eine ANDERE Sitzung, findet es dort nichts und täte
|
||||
// stillschweigend nichts — deshalb hier der ausdrückliche Hinweis.
|
||||
const w = typeof currentWeek === 'function' ? currentWeek() : null;
|
||||
if(!w || !Array.isArray(w.projects) || !w.projects.some(x => x.id === p.id)){
|
||||
app.showToast('Der Aufwand lässt sich nur in der laufenden Sitzung bearbeiten.');
|
||||
return;
|
||||
}
|
||||
openLpModal(p.id);
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,147 @@
|
||||
// -----------------------------
|
||||
// Projektbereich — Aufgaben- und Meilensteinliste
|
||||
// -----------------------------
|
||||
// Bereich "Aufgaben" des Projektbereichs (siehe js/plan/plan-sections.js):
|
||||
// dieselben Daten wie im GANTT, nur als Liste — für alle, die Aufgaben lieber
|
||||
// abarbeiten als sie zu terminieren. Es gibt hier bewusst KEINEN eigenen
|
||||
// Datenbestand und keine eigenen Dialoge: eine Zeile öffnet denselben Dialog
|
||||
// wie ein Klick im GANTT (openTodoModal/openMilestoneModal aus
|
||||
// js/plan/plan-modals.js), das Häkchen ruft toggleTodoDone() auf, und neue
|
||||
// Aufgaben entstehen über dieselben Schaltflächen wie im Terminplan
|
||||
// (addTodo, addPlanMilestone, openTemplateModal, openHonorarModal).
|
||||
//
|
||||
// Die Reihenfolge entspricht der Reihenfolge im GANTT (plan.todos bzw.
|
||||
// plan.milestones), damit der Index in der Liste und der im Dialog
|
||||
// erwartete Index übereinstimmen und beide Ansichten dasselbe Bild zeigen.
|
||||
//
|
||||
// renderPlanTasks() wird am Ende von renderPlanGantt() (plan-gantt.js)
|
||||
// mitgerendert — das ist die zentrale Neu-Render-Funktion, die alle
|
||||
// Änderungen an Aufgaben und Meilensteinen ohnehin aufrufen.
|
||||
//
|
||||
// Läuft wie alle plan-Module in einem IIFE; Zustand: window.PlanApp
|
||||
// (plan-state.js).
|
||||
|
||||
(function(){
|
||||
const app = window.PlanApp;
|
||||
|
||||
// todoStatus: leitet den Status einer Aufgabe aus done/progress ab —
|
||||
// dieselbe Dreiteilung wie das Status-Segment im TODO-Dialog
|
||||
// (offen/arbeit/fertig, siehe plan-modals.js). Parameter: t (TODO).
|
||||
// Rückgabe: 'offen' | 'arbeit' | 'fertig'.
|
||||
function todoStatus(t){
|
||||
if(t.done) return 'fertig';
|
||||
return (Number(t.progress) || 0) > 0 ? 'arbeit' : 'offen';
|
||||
}
|
||||
|
||||
// personById: sucht eine Person der aktuellen Sitzung anhand ihrer ID.
|
||||
// Rückgabe: Personenobjekt oder null.
|
||||
function personById(id){
|
||||
return (app.week && Array.isArray(app.week.people) ? app.week.people : []).find(p => p.id === id) || null;
|
||||
}
|
||||
|
||||
// assigneeAvatars: baut den überlappenden Avatar-Stapel der Zuständigen
|
||||
// einer Aufgabe (dieselben Kreise wie im Projektboard, .board-avatar in
|
||||
// styles.css). Parameter: ids (Array von Person-IDs). Rückgabe: HTML-String.
|
||||
function assigneeAvatars(ids){
|
||||
const chips = (Array.isArray(ids) ? ids : []).map(id => {
|
||||
const person = personById(id);
|
||||
if(!person) return '';
|
||||
const role = Array.isArray(person.role) && person.role.length ? person.role[0] : '';
|
||||
const title = (person.fullName || person.name) + (role ? ' · ' + role : '');
|
||||
return `<span class="board-avatar ${app.planRoleClass(role)} state-1" title="${esc(title)}">${esc(person.name)}</span>`;
|
||||
}).join('');
|
||||
return chips ? `<span class="plan-task-assignees">${chips}</span>` : '';
|
||||
}
|
||||
|
||||
// dateRangeText: formatiert den Zeitraum einer Aufgabe als "01.02.2026 –
|
||||
// 05.02.2026" (bzw. nur ein Datum, wenn Start und Ende gleich sind).
|
||||
// Parameter: t (TODO). Rückgabe: String (leer ohne Termine).
|
||||
function dateRangeText(t){
|
||||
const s = parseDateISO(t.start);
|
||||
const e = parseDateISO(t.end);
|
||||
if(!s && !e) return '';
|
||||
if(s && e) return formatDateDE(s) === formatDateDE(e) ? formatDateDE(s) : formatDateDE(s) + ' – ' + formatDateDE(e);
|
||||
return formatDateDE(s || e);
|
||||
}
|
||||
|
||||
// isOverdue: true, wenn eine noch offene Aufgabe ihren Endtermin
|
||||
// überschritten hat. Parameter: t (TODO). Rückgabe: Boolescher Wert.
|
||||
function isOverdue(t){
|
||||
if(t.done) return false;
|
||||
const e = parseDateISO(t.end);
|
||||
if(!e) return false;
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
return e < today;
|
||||
}
|
||||
|
||||
// renderPlanTasks: baut die Aufgaben- und die Meilensteinliste des Bereichs
|
||||
// "Aufgaben" neu auf und schreibt die Zähler in die Kartenüberschriften.
|
||||
// Keine Parameter, keine Rückgabe.
|
||||
window.renderPlanTasks = function(){
|
||||
if(!app.project) return;
|
||||
const plan = app.ensurePlanLocal();
|
||||
const locked = app.isLocked();
|
||||
|
||||
// ---- Aufgaben ----
|
||||
const todoEl = document.getElementById('planTodoList');
|
||||
const todos = Array.isArray(plan.todos) ? plan.todos : [];
|
||||
if(todoEl){
|
||||
// In gesperrten Sitzungen sind die Anlege-Schaltflächen ausgeblendet
|
||||
// (renderPlanPage(), plan-state.js) — dann darf der Leertext auch nicht
|
||||
// auf sie verweisen.
|
||||
const leerTextTodos = locked
|
||||
? 'Für diese Sitzung sind keine Aufgaben hinterlegt.'
|
||||
: 'Noch keine Aufgaben. Über „+ Aufgabe“, „+ Vorlage“ oder „+ Terminplan“ anlegen.';
|
||||
todoEl.innerHTML = todos.length === 0
|
||||
? '<div class="plan-task-empty">' + leerTextTodos + '</div>'
|
||||
: todos.map((t, idx) => {
|
||||
const status = todoStatus(t);
|
||||
const range = dateRangeText(t);
|
||||
const overdue = isOverdue(t);
|
||||
const del = locked ? '' : `<button class="secondary small" title="Aufgabe löschen" onclick="event.stopPropagation(); deleteTodo(${idx})">✕</button>`;
|
||||
const check = locked ? '' : `<input type="checkbox" ${t.done ? 'checked' : ''} title="Erledigt" onclick="event.stopPropagation(); toggleTodoDone(${idx})">`;
|
||||
const progress = (!t.done && (Number(t.progress) || 0) > 0) ? `<span class="plan-task-meta">${Math.round(Number(t.progress) || 0)} %</span>` : '';
|
||||
// In gesperrten Sitzungen weder Klick-Handler noch Einladung zum
|
||||
// Bearbeiten: openTodoModal() bricht dort sowieso wortlos ab.
|
||||
const rowAction = locked ? '' : ` onclick="openTodoModal(${idx})" title="Zum Bearbeiten klicken"`;
|
||||
return `<div class="plan-task-row${t.done ? ' is-done' : ''}${locked ? ' is-locked' : ''}"${rowAction}>
|
||||
${check}
|
||||
<span class="plan-task-dot ${status}"></span>
|
||||
<span class="plan-task-title">${esc(t.title || 'Aufgabe')}</span>
|
||||
${assigneeAvatars(t.assignees)}
|
||||
${progress}
|
||||
<span class="plan-task-meta${overdue ? ' overdue' : ''}" title="${range ? '' : 'Ohne Termin — erscheint nicht im Terminplan'}">${range ? esc(range) : '<span class="plan-task-no-schedule">ohne Termin</span>'}</span>
|
||||
${del}
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
const todoCount = document.getElementById('planTodoCount');
|
||||
if(todoCount){
|
||||
const offen = todos.filter(t => !t.done).length;
|
||||
todoCount.textContent = todos.length === 0 ? '' : `${offen} von ${todos.length} offen`;
|
||||
}
|
||||
|
||||
// ---- Meilensteine ----
|
||||
const msEl = document.getElementById('planMilestoneList');
|
||||
const milestones = Array.isArray(plan.milestones) ? plan.milestones : [];
|
||||
if(msEl){
|
||||
msEl.innerHTML = milestones.length === 0
|
||||
? '<div class="plan-task-empty">' + (locked ? 'Für diese Sitzung sind keine Meilensteine hinterlegt.' : 'Noch keine Meilensteine. Über „+ Meilenstein“ anlegen.') + '</div>'
|
||||
: milestones.map((m, idx) => {
|
||||
const d = parseDateISO(m.date);
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const overdue = d && d < today;
|
||||
const msAction = locked ? '' : ` onclick="openMilestoneModal(${idx})" title="Zum Bearbeiten klicken"`;
|
||||
return `<div class="plan-task-row${locked ? ' is-locked' : ''}"${msAction}>
|
||||
<span class="plan-task-dot"></span>
|
||||
<span class="plan-task-title">${esc(m.name || 'Meilenstein')}</span>
|
||||
<span class="plan-task-meta${overdue ? ' overdue' : ''}">${esc(d ? formatDateDE(d) : 'ohne Datum')}</span>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
const msCount = document.getElementById('planMilestoneCount');
|
||||
if(msCount) msCount.textContent = milestones.length === 0 ? '' : `${milestones.length} gesamt`;
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,182 @@
|
||||
// -----------------------------
|
||||
// Projektplan — Aufgaben-Vorlagen je Leistungsphase
|
||||
// -----------------------------
|
||||
// In einem Ingenieurbüro wiederholt sich der Ablauf einer Leistungsphase über
|
||||
// alle Projekte hinweg. Dieses Modul macht daraus wiederverwendbare Vorlagen:
|
||||
// Der Button "+ Vorlage" in der GANTT-Toolbar öffnet #lphTemplateModal, dort
|
||||
// wählt man eine Leistungsphase und ein Startdatum, hakt die gewünschten
|
||||
// Schritte an und fügt sie mit einem Klick als TODOs in den Projektplan ein.
|
||||
//
|
||||
// Der Honorar-Terminplan (js/plan/plan-honorar.js) ist ein eigener Dialog mit
|
||||
// eigener Schaltfläche "+ Terminplan" — beide teilten sich früher einen Dialog
|
||||
// mit Modus-Umschalter, was aber bedeutete, dass auf dem Knopf nur eines der
|
||||
// beiden Verfahren stand.
|
||||
//
|
||||
// Datenquelle sind die Routen /lph-templates (server.js, Tabelle
|
||||
// lph_templates): GET für alle angemeldeten Nutzer, POST/DELETE nur für
|
||||
// Admins — das Einfügen darf jeder, das Pflegen der firmenweiten Vorlagen
|
||||
// nicht. Die Pflege-Maske selbst steht seit dem Umbau NICHT mehr in diesem
|
||||
// Dialog, sondern im Bereich „Einstellungen" (js/settings/settings.js) —
|
||||
// hier bleibt für Administratoren nur ein Verweis dorthin.
|
||||
//
|
||||
// WICHTIG: Eingefügte Aufgaben sind danach ganz normale plan_todos ohne
|
||||
// Verbindung zur Vorlage. Eine später geänderte Vorlage verändert bereits
|
||||
// eingefügte Aufgaben also NICHT — ein laufender Projektplan soll sich nicht
|
||||
// rückwirkend unter der Hand ändern.
|
||||
//
|
||||
// Läuft wie alle plan-Module in einem IIFE; Zustand: window.PlanApp
|
||||
// (plan-state.js).
|
||||
|
||||
(function(){
|
||||
const app = window.PlanApp;
|
||||
|
||||
// templateCache: zuletzt geladene Vorlagen (flaches Array), damit das
|
||||
// Umschalten der Leistungsphase im Modal ohne erneuten Serveraufruf geht.
|
||||
let templateCache = [];
|
||||
|
||||
// TEMPLATE_LPHS: Leistungsphasen, für die Vorlagen angelegt werden können —
|
||||
// dieselbe Liste wie LPHS (js/globals.js) und wie VALID_LPHS in server.js.
|
||||
const TEMPLATE_LPHS = LPHS;
|
||||
|
||||
// openTemplateModal: öffnet das Vorlagen-Modal, lädt die Vorlagen (einmal
|
||||
// pro Seitenaufruf) und baut Leistungsphasen-Auswahl sowie Startdatum auf.
|
||||
// Bei gesperrter Sitzung wird gar nicht erst geöffnet (wie bei den anderen
|
||||
// Bearbeitungs-Dialogen, siehe plan-modals.js).
|
||||
window.openTemplateModal = function(){
|
||||
if(app.isLocked()) return;
|
||||
|
||||
const lphSelect = document.getElementById('templateLphSelect');
|
||||
if(lphSelect && !lphSelect.options.length){
|
||||
// Vorbelegung: die amtierende Leistungsphase des Projekts, sonst LPH 1
|
||||
const aktLp = Array.isArray(app.project.aktuelleLP) ? app.project.aktuelleLP[0] : '';
|
||||
lphSelect.innerHTML = TEMPLATE_LPHS.map(l =>
|
||||
`<option value="${esc(l)}" ${l === aktLp ? 'selected' : ''}>${esc(l)}</option>`
|
||||
).join('');
|
||||
}
|
||||
// Startdatum vorbelegen: heute
|
||||
const startInput = document.getElementById('templateStart');
|
||||
const startDisplay = document.getElementById('templateStartDisplay');
|
||||
if(startInput && !startInput.value){
|
||||
const today = formatDateISO(new Date());
|
||||
startInput.value = today;
|
||||
if(startDisplay) startDisplay.value = formatDateDE(parseDateISO(today));
|
||||
}
|
||||
|
||||
document.getElementById('lphTemplateModal').style.display = 'flex';
|
||||
loadTemplates();
|
||||
};
|
||||
|
||||
window.closeTemplateModal = function(){
|
||||
document.getElementById('lphTemplateModal').style.display = 'none';
|
||||
};
|
||||
|
||||
// loadTemplates: holt die Vorlagen vom Server und rendert danach die Liste.
|
||||
// Fehler werden im Listenbereich gemeldet (kein alert, damit das Modal
|
||||
// benutzbar bleibt).
|
||||
function loadTemplates(){
|
||||
const listEl = document.getElementById('templateTaskList');
|
||||
if(listEl) listEl.innerHTML = '<span class="hint">Lade Vorlagen…</span>';
|
||||
fetch(baseUrl() + '/lph-templates')
|
||||
.then(r => { if(!r.ok) throw new Error('Server HTTP ' + r.status); return r.json(); })
|
||||
.then(data => {
|
||||
templateCache = Array.isArray(data.templates) ? data.templates : [];
|
||||
renderTemplateList();
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('Vorlagen laden fehlgeschlagen:', err);
|
||||
if(listEl) listEl.innerHTML = '<span class="hint">Vorlagen konnten nicht geladen werden.</span>';
|
||||
});
|
||||
}
|
||||
|
||||
// templatesForSelectedLph: die Vorlagen-Schritte der gerade gewählten
|
||||
// Leistungsphase, in ihrer gepflegten Reihenfolge. Rückgabe: Array.
|
||||
function templatesForSelectedLph(){
|
||||
const lphSelect = document.getElementById('templateLphSelect');
|
||||
const lph = lphSelect ? lphSelect.value : '';
|
||||
return templateCache.filter(t => t.lph === lph);
|
||||
}
|
||||
|
||||
// renderTemplateList: baut die Auswahlliste der Schritte (Checkbox je
|
||||
// Schritt, standardmäßig alle angehakt) samt Vorschau der Termine, die sich
|
||||
// aus dem gewählten Startdatum ergeben. Für Admins wird darunter der
|
||||
// Pflege-Bereich gerendert. Wird bei jedem Wechsel von Leistungsphase oder
|
||||
// Startdatum erneut aufgerufen. Keine Parameter, keine Rückgabe.
|
||||
window.renderTemplateList = function(){
|
||||
const listEl = document.getElementById('templateTaskList');
|
||||
if(!listEl) return;
|
||||
const entries = templatesForSelectedLph();
|
||||
const startIso = (document.getElementById('templateStart') || {}).value || '';
|
||||
const startDate = parseDateISO(startIso);
|
||||
|
||||
if(entries.length === 0){
|
||||
listEl.innerHTML = '<span class="hint">Für diese Leistungsphase ist noch keine Vorlage hinterlegt.</span>';
|
||||
} else {
|
||||
listEl.innerHTML = entries.map(t => {
|
||||
// Vorschau der konkreten Termine (nur wenn ein Startdatum gesetzt ist)
|
||||
let dateHint = `Tag +${t.offsetDays}, ${t.durationDays} Tag${t.durationDays === 1 ? '' : 'e'}`;
|
||||
if(startDate){
|
||||
const s = addDays(startDate, t.offsetDays);
|
||||
const e = addDays(s, Math.max(0, t.durationDays - 1));
|
||||
dateHint = formatDateDE(s) + '–' + formatDateDE(e);
|
||||
}
|
||||
return `<label class="todo-depends-item">
|
||||
<input type="checkbox" value="${esc(t.id)}" checked>
|
||||
<span style="flex:1">${esc(t.title)}</span>
|
||||
<span class="hint" style="margin:0">${esc(dateHint)}</span>
|
||||
</label>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
renderTemplateHint();
|
||||
};
|
||||
|
||||
// renderTemplateHint: blendet für Administratoren den Verweis auf den
|
||||
// Bereich „Einstellungen" ein. Das Anlegen und Löschen von Schritten stand
|
||||
// bis zum Umbau hier im Dialog; es liegt jetzt in js/settings/settings.js.
|
||||
// Dieser Dialog FÜGT Vorlagen nur noch ein. Keine Parameter, keine Rückgabe.
|
||||
function renderTemplateHint(){
|
||||
const el = document.getElementById('templateAdminHint');
|
||||
if(!el) return;
|
||||
el.style.display = (currentUser && currentUser.role === 'admin') ? '' : 'none';
|
||||
}
|
||||
|
||||
// applyTemplate: erzeugt aus den angehakten Vorlagen-Schritten neue TODOs im
|
||||
// aktuellen Projektplan. Termine ergeben sich aus dem gewählten Startdatum
|
||||
// plus offsetDays bzw. durationDays. Die neuen Aufgaben werden ans Ende der
|
||||
// Aufgabenliste gehängt, ohne Zuständige und ohne Abhängigkeiten — das wird
|
||||
// anschließend im TODO-Dialog ergänzt.
|
||||
window.applyTemplate = function(){
|
||||
if(app.isLocked()) return;
|
||||
const startIso = (document.getElementById('templateStart') || {}).value || '';
|
||||
const startDate = parseDateISO(startIso);
|
||||
if(!startDate){ alert('Bitte ein Startdatum wählen.'); return; }
|
||||
|
||||
const listEl = document.getElementById('templateTaskList');
|
||||
const checkedIds = listEl
|
||||
? Array.from(listEl.querySelectorAll('input[type="checkbox"]:checked')).map(cb => cb.value)
|
||||
: [];
|
||||
if(checkedIds.length === 0){ alert('Bitte mindestens einen Schritt auswählen.'); return; }
|
||||
|
||||
const entries = templatesForSelectedLph().filter(t => checkedIds.includes(t.id));
|
||||
const plan = app.ensurePlanLocal();
|
||||
entries.forEach(t => {
|
||||
const s = addDays(startDate, t.offsetDays);
|
||||
const e = addDays(s, Math.max(0, t.durationDays - 1));
|
||||
plan.todos.push({
|
||||
id: uid(),
|
||||
title: t.title,
|
||||
start: formatDateISO(s),
|
||||
end: formatDateISO(e),
|
||||
done: false,
|
||||
progress: 0,
|
||||
dependsOn: [],
|
||||
assignees: [],
|
||||
});
|
||||
});
|
||||
|
||||
app.savePlan();
|
||||
closeTemplateModal();
|
||||
fitPlanGanttRange();
|
||||
app.showToast(entries.length + ' Aufgabe(n) aus der Vorlage eingefügt.');
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,130 @@
|
||||
// -----------------------------
|
||||
// Planung — Terminplan-Sandbox
|
||||
// -----------------------------
|
||||
// Der Tab "Planung" (js/tabs-auth.js, Slug "Planung" in TAB_SLUGS) zeigt eine
|
||||
// einfache Liste projektunabhängiger Terminpläne — Kapazitätstests, Angebote
|
||||
// vor der Beauftragung, eigene Vorhaben. Jeder Plan ist ein ganz normales
|
||||
// Projekt-Objekt (dieselbe Form wie ein echtes Projekt), liegt aber in der
|
||||
// reservierten Sitzung SANDBOX_WEEK_LABEL statt in einer echten (siehe
|
||||
// db.sandboxWeek, js/store.js, und CLAUDE.md "Planung — projektunabhängige
|
||||
// Terminplan-Sandbox"). Das Bearbeiten selbst — Terminplan, Aufgaben,
|
||||
// Meilensteine, Honorar-Rechner — läuft über denselben Projektbereich wie bei
|
||||
// einem echten Projekt (openSandboxPlan(), js/plan/plan-state.js); dieses
|
||||
// Modul stellt nur die Liste und den schlanken Anlege-Dialog bereit.
|
||||
|
||||
// planungGewaehlteArt: welche Anlege-Art im Dialog gerade markiert ist
|
||||
// ('honorar'|'leer'). Zustand nur für die Dauer des offenen Dialogs.
|
||||
let planungGewaehlteArt = 'honorar';
|
||||
|
||||
// renderPlanung: füllt die Terminplan-Liste des Tabs "Planung". Keine
|
||||
// Parameter, keine Rückgabe.
|
||||
function renderPlanung(){
|
||||
const el = document.getElementById('planungList');
|
||||
if(!el) return;
|
||||
const plaene = (db.sandboxWeek && Array.isArray(db.sandboxWeek.projects)) ? db.sandboxWeek.projects : [];
|
||||
|
||||
if(plaene.length === 0){
|
||||
el.innerHTML = `<div class="empty-hint">
|
||||
<span class="big">Noch kein Terminplan angelegt.</span>
|
||||
Unabhängig von einem echten Projekt — für Angebote, Kapazitätstests oder eigene Vorhaben.
|
||||
</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
// Zuletzt geändert zuerst — genau die Frage, die die Liste beantworten soll.
|
||||
const sortiert = [...plaene].sort((a, b) => String(b.updatedAt || '').localeCompare(String(a.updatedAt || '')));
|
||||
|
||||
el.innerHTML = sortiert.map(p => {
|
||||
const honorar = Number(p.honorar) || 0;
|
||||
const ausHonorar = honorar > 0;
|
||||
const beauftragt = Array.isArray(p.beauftragteLP) ? p.beauftragteLP : [];
|
||||
const meta = ausHonorar
|
||||
? 'Aus Honorar errechnet · ' + honorar.toLocaleString('de-DE') + ' € · '
|
||||
+ (beauftragt.length ? beauftragt.join(', ') : 'keine Leistungsphasen gewählt')
|
||||
: 'Frei begonnen · ' + ((p.plan && Array.isArray(p.plan.todos)) ? p.plan.todos.length : 0) + ' Aufgabe(n), '
|
||||
+ ((p.plan && Array.isArray(p.plan.milestones)) ? p.plan.milestones.length : 0) + ' Meilenstein(e)';
|
||||
const geaendert = p.updatedAt ? formatCommentStamp(p.updatedAt) : '–';
|
||||
return `<div class="planung-row" onclick="openSandboxPlan('${esc(p.id)}')">
|
||||
<div class="planung-row-icon ${ausHonorar ? 'honorar' : 'leer'}">${ausHonorar ? '€' : '✎'}</div>
|
||||
<div class="planung-row-main">
|
||||
<p class="planung-row-name">${esc(p.name || 'Ohne Namen')}</p>
|
||||
<p class="planung-row-meta">${esc(meta)}</p>
|
||||
</div>
|
||||
<div class="planung-row-stat">zuletzt geändert<br><b>${esc(geaendert)}</b></div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// oeffneNeuerTerminplanDialog: zeigt den Anlege-Dialog mit leerem Namensfeld.
|
||||
// Keine Parameter, keine Rückgabe.
|
||||
function oeffneNeuerTerminplanDialog(){
|
||||
const nameEl = document.getElementById('planungName');
|
||||
if(nameEl) nameEl.value = '';
|
||||
planungGewaehlteArt = 'honorar';
|
||||
aktualisierePlanungArtAuswahl();
|
||||
const modal = document.getElementById('neuerTerminplanModal');
|
||||
if(modal) modal.style.display = 'flex';
|
||||
if(nameEl) nameEl.focus();
|
||||
}
|
||||
|
||||
function schliesseNeuerTerminplanDialog(){
|
||||
const modal = document.getElementById('neuerTerminplanModal');
|
||||
if(modal) modal.style.display = 'none';
|
||||
}
|
||||
|
||||
// waehlePlanungArt: markiert eine der beiden Anlege-Karten ("Aus Honorar
|
||||
// berechnen" / "Leer beginnen"). Parameter: art ('honorar'|'leer').
|
||||
function waehlePlanungArt(art){
|
||||
planungGewaehlteArt = art;
|
||||
aktualisierePlanungArtAuswahl();
|
||||
}
|
||||
|
||||
function aktualisierePlanungArtAuswahl(){
|
||||
document.querySelectorAll('#neuerTerminplanModal .choice-card').forEach(card => {
|
||||
card.classList.toggle('is-gewaehlt', card.dataset.art === planungGewaehlteArt);
|
||||
});
|
||||
}
|
||||
|
||||
// erstellePlanungTerminplan: legt den Sandbox-Plan an (Name-Pflichtfeld,
|
||||
// keine doppelten Namen — die einzige Identität eines Sandbox-Plans, siehe
|
||||
// CLAUDE.md) und öffnet ihn direkt im Projektbereich. Bei "Aus Honorar
|
||||
// berechnen" landet man in den Einstellungen mit fokussiertem Honorar-Feld;
|
||||
// den eigentlichen Rechenweg übernimmt der bereits vorhandene "Terminplan
|
||||
// aus Honorar"-Knopf dort — kein eigener Rechen-Code. Keine Parameter, keine
|
||||
// Rückgabe.
|
||||
function erstellePlanungTerminplan(){
|
||||
const nameEl = document.getElementById('planungName');
|
||||
const name = (nameEl ? nameEl.value : '').trim();
|
||||
if(!name){ alert('Bitte einen Namen für den Terminplan eingeben.'); return; }
|
||||
const plaene = (db.sandboxWeek && Array.isArray(db.sandboxWeek.projects)) ? db.sandboxWeek.projects : [];
|
||||
if(plaene.some(p => (p.name || '').toLowerCase() === name.toLowerCase())){
|
||||
alert('Es gibt bereits einen Terminplan mit dem Namen "' + name + '". Bitte einen anderen Namen wählen.');
|
||||
return;
|
||||
}
|
||||
|
||||
const neu = {
|
||||
id: uid(), projectUid: uid(),
|
||||
nr: '', name, group: '', gewerke: [], tags: [],
|
||||
status: '', order: plaene.length, lph: {}, assign: {}, plan: null,
|
||||
auftragsstatus: 'beauftragt', honorar: 0, groesse: 0,
|
||||
beauftragteLP: [], aktuelleLP: [], besprochen: false,
|
||||
updatedAt: new Date().toISOString()
|
||||
};
|
||||
plaene.push(neu);
|
||||
markWeekDirty(SANDBOX_WEEK_LABEL);
|
||||
scheduleSessionSync();
|
||||
|
||||
const gewaehlteArt = planungGewaehlteArt;
|
||||
schliesseNeuerTerminplanDialog();
|
||||
renderPlanung();
|
||||
openSandboxPlan(neu.id);
|
||||
if(gewaehlteArt === 'honorar'){
|
||||
// Kleine UX-Abkürzung: direkt in den Einstellungen landen und das
|
||||
// Honorar-Feld fokussieren, statt den Nutzer selbst dorthin navigieren
|
||||
// zu lassen. showPlanSection() rendert synchron, das Feld existiert
|
||||
// danach im DOM.
|
||||
if(typeof showPlanSection === 'function') showPlanSection('einstellungen');
|
||||
const honorarEl = document.getElementById('settingsHonorar');
|
||||
if(honorarEl) honorarEl.focus();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,781 @@
|
||||
// -----------------------------
|
||||
// Projektübersicht — Assistent "Neues Projekt"
|
||||
// -----------------------------
|
||||
// Führt in sechs Schritten durch das Anlegen eines Projekts und ersetzt den
|
||||
// früheren Dialog #newProjModal, der alle Felder auf einmal zeigte — samt
|
||||
// einer 27-Felder-Matrix für den Aufwand je Phase und Rolle, mitten im
|
||||
// Formular.
|
||||
//
|
||||
// Die Schritte:
|
||||
//
|
||||
// 1. PROJEKT Nummer, Bezeichnung, GEWERKE, Gruppe, Schlagwörter
|
||||
// 2. AUFTRAG Auftragsstatus, Honorar ODER Projektgröße, beauftragte
|
||||
// Leistungsphasen
|
||||
// 3. STAND amtierende Leistungsphase
|
||||
// 4. TEAM Beteiligte je Rolle — eingeschränkt auf die Gewerke des
|
||||
// Projekts, andere ausdrücklich hinzuholbar
|
||||
// 5. TERMINPLAN aus dem Honorar errechnet (nur dann); erzeugt je Phase
|
||||
// eine Aufgabe im Projektplan
|
||||
// 6. PRÜFEN Zusammenfassung, dann Anlegen
|
||||
//
|
||||
// Warum ein Assistent: Honorar, Team und Schlagwörter mussten zuvor NACH dem
|
||||
// Anlegen an drei weiteren Stellen nachgetragen werden — und wurden es meist
|
||||
// nicht. Genau daher rühren die Projekte, die in der Auslastung als "nicht
|
||||
// bewertbar" erscheinen.
|
||||
//
|
||||
// JEDER SCHRITT IST PFLICHT: "Weiter" bleibt gesperrt, solange etwas fehlt,
|
||||
// und unter dem Knopf steht, was fehlt. Ein Assistent, der unvollständige
|
||||
// Projekte durchlässt, löst das Problem nicht, das er lösen soll.
|
||||
//
|
||||
// Nutzt LPHS/ROLES/GROUP_ORDER/APP_SETTINGS/HONORAR_ANTEILE (js/globals.js),
|
||||
// currentWeek()/save() (js/store.js, js/weeks.js), auftragsstatusOptionsHtml()
|
||||
// (js/auftrag.js), esc()/uid()/assignKey()/isWorkday() (js/utils.js).
|
||||
|
||||
// WIZARD_SCHRITTE: die Stationen in ihrer Reihenfolge. Das Team steht VOR
|
||||
// dem Terminplan, weil die Dauer einer Phase davon abhängt, wie viele daran
|
||||
// arbeiten — umgekehrt wäre sie geraten.
|
||||
const WIZARD_SCHRITTE = [
|
||||
{ key: 'projekt', titel: 'Projekt', unter: 'Wie heißt es, welche Gewerke bearbeiten es?' },
|
||||
{ key: 'auftrag', titel: 'Auftrag', unter: 'Was ist beauftragt, und was ist es wert?' },
|
||||
{ key: 'stand', titel: 'Stand', unter: 'Woran wird gerade gearbeitet?' },
|
||||
{ key: 'team', titel: 'Team', unter: 'Wer bearbeitet das Projekt?' },
|
||||
{ key: 'terminplan', titel: 'Terminplan', unter: 'Aus dem Honorar einen ersten Terminplan errechnen.' },
|
||||
{ key: 'pruefen', titel: 'Prüfen', unter: 'Stimmt alles?' },
|
||||
];
|
||||
|
||||
// GEWERK_LABELS: Langform für Titel und Auswahllisten. Die Schlüssel sind
|
||||
// dieselben wie users.department und VALID_DEPARTMENTS in server.js.
|
||||
const GEWERK_LABELS = { ELT: 'Elektrotechnik', HLS: 'Heizung · Lüftung · Sanitär', ARCH: 'Architektur' };
|
||||
|
||||
// wizard: Zustand des Assistenten. null = geschlossen.
|
||||
let wizard = null;
|
||||
|
||||
// openNewProjWizard: öffnet den Assistenten mit leerem Zustand.
|
||||
// Keine Parameter, keine Rückgabe; gesperrte Sitzungen werden abgewiesen.
|
||||
function openNewProjWizard(){
|
||||
if(guardWeekLocked()) return;
|
||||
wizard = {
|
||||
schritt: 0,
|
||||
fertig: null, // angelegtes Projekt, solange der Abschluss steht
|
||||
nr: '', name: '', gewerke: [], gruppe: 'Aktive Projekte', tags: [],
|
||||
status: AUFTRAGSSTATUS_VORGABE, grundlage: 'honorar', honorar: '', groesse: '',
|
||||
beauftragt: [], amtierend: '',
|
||||
team: {}, fremde: [],
|
||||
planErzeugen: true, planStart: heuteIso(), planPersonen: '',
|
||||
};
|
||||
ROLES.forEach(r => { wizard.team[r] = []; });
|
||||
document.getElementById('wizardModal').style.display = 'flex';
|
||||
renderWizard();
|
||||
}
|
||||
|
||||
// closeNewProjWizard: schließt den Assistenten und verwirft die Eingaben.
|
||||
function closeNewProjWizard(){
|
||||
wizard = null;
|
||||
const el = document.getElementById('wizardModal');
|
||||
if(el) el.style.display = 'none';
|
||||
}
|
||||
|
||||
// ---------- Werte und Ableitungen ----------
|
||||
|
||||
// wizardPersonen: alle Personen des Teilnehmerkreises der laufenden Sitzung,
|
||||
// angereichert um Gewerk und Rollen aus der globalen Personenliste.
|
||||
//
|
||||
// Gewählt wird aus week.people und NICHT aus db.globalPeople: Die Zuordnung
|
||||
// landet in project.assign und wird vom Board über currentWeek().people
|
||||
// aufgelöst — wer dort fehlt, erschiene nirgends. Das Gewerk steht dagegen
|
||||
// nur am Konto (users.department), deshalb der Abgleich über die Kennung.
|
||||
function wizardPersonen(){
|
||||
const w = currentWeek();
|
||||
if(!w || !Array.isArray(w.people)) return [];
|
||||
const global = Array.isArray(db.globalPeople) ? db.globalPeople : [];
|
||||
return w.people.map(p => {
|
||||
const g = global.find(x => x.id === p.id)
|
||||
|| (p.fullName ? global.find(x => x.fullName === p.fullName) : null);
|
||||
return {
|
||||
id: p.id,
|
||||
kuerzel: p.name || p.id,
|
||||
name: p.fullName || p.name || p.id,
|
||||
gewerk: (g && g.gewerk) || '',
|
||||
rollen: Array.isArray(p.role) ? p.role : (g && g.role) || [],
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// wizardSichtbare: wer in einer Rolle zur Auswahl steht — die Personen aus
|
||||
// den Gewerken des Projekts, dazu die ausdrücklich hinzugeholten.
|
||||
function wizardSichtbare(rolle){
|
||||
return wizardPersonen().filter(p => {
|
||||
if(p.rollen.indexOf(rolle) < 0) return false;
|
||||
return wizard.gewerke.indexOf(p.gewerk) >= 0 || wizard.fremde.indexOf(p.id) >= 0;
|
||||
});
|
||||
}
|
||||
|
||||
// wizardUebrige: Personen, deren Gewerk nicht am Projekt beteiligt ist und
|
||||
// die noch nicht hinzugeholt wurden — die Auswahl des Zusatz-Feldes.
|
||||
function wizardUebrige(){
|
||||
return wizardPersonen().filter(p =>
|
||||
wizard.gewerke.indexOf(p.gewerk) < 0 && wizard.fremde.indexOf(p.id) < 0);
|
||||
}
|
||||
|
||||
function wizardTeamAnzahl(){
|
||||
const gesehen = new Set();
|
||||
ROLES.forEach(r => wizard.team[r].forEach(id => gesehen.add(id)));
|
||||
return gesehen.size;
|
||||
}
|
||||
|
||||
function wizardHonorar(){
|
||||
const n = parseFloat(String(wizard.honorar).replace(',', '.'));
|
||||
return (!Number.isFinite(n) || n <= 0) ? 0 : n;
|
||||
}
|
||||
function wizardGroesse(){
|
||||
const n = parseFloat(String(wizard.groesse).replace(',', '.'));
|
||||
if(!Number.isFinite(n) || n <= 0) return 0;
|
||||
return Math.min(1, n);
|
||||
}
|
||||
|
||||
// wizardBasis: der Geldbetrag, mit dem gerechnet wird. Bei der Projektgröße
|
||||
// über das firmenweite Referenzhonorar — der Betrag wird bewusst NICHT
|
||||
// angezeigt: Die Größe ist eine Schätzung, und eine daneben stehende
|
||||
// Eurosumme gäbe ihr eine Genauigkeit, die sie nicht hat.
|
||||
function wizardBasis(){
|
||||
if(wizard.grundlage === 'honorar'){
|
||||
const h = wizardHonorar();
|
||||
return h > 0 ? { betrag: h, quelle: 'Honorar' } : { betrag: 0, quelle: null };
|
||||
}
|
||||
const g = wizardGroesse();
|
||||
const referenz = Number(APP_SETTINGS.referenzHonorar) || 250000;
|
||||
return g > 0 ? { betrag: g * referenz, quelle: 'Projektgröße' } : { betrag: 0, quelle: null };
|
||||
}
|
||||
|
||||
// wizardPlanPersonen: wie viele gleichzeitig an einer Phase arbeiten.
|
||||
// Vorgabe ist die Größe des Teams; überschreibbar, weil selten alle
|
||||
// gleichzeitig an derselben Phase sitzen.
|
||||
function wizardPlanPersonen(){
|
||||
if(wizard.planPersonen) return Math.max(1, Number(wizard.planPersonen) || 1);
|
||||
return Math.max(1, wizardTeamAnzahl());
|
||||
}
|
||||
|
||||
// wizardPlanZeilen: der errechnete Terminplan — derselbe Rechenweg wie
|
||||
// berechneHonorarPlan() in js/plan/plan-honorar.js: Phasenhonorar durch
|
||||
// Stundensatz ergibt Stunden, Stunden durch Tagesleistung ergibt Dauer, die
|
||||
// Phasen liegen hintereinander. Rückgabe: Array von Zeilen (leer, wenn nicht
|
||||
// aus dem Honorar gerechnet werden kann).
|
||||
function wizardPlanZeilen(){
|
||||
const b = wizardBasis();
|
||||
if(wizard.grundlage !== 'honorar' || b.betrag <= 0) return [];
|
||||
const phasen = wizard.beauftragt.length ? wizard.beauftragt : AUFWAND_LPS;
|
||||
const summe = phasen.reduce((s, l) => s + (Number(HONORAR_ANTEILE[l]) || 0), 0);
|
||||
if(summe <= 0) return [];
|
||||
|
||||
const satz = Number(APP_SETTINGS.stundensatz) || 100;
|
||||
const proTag = Number(APP_SETTINGS.arbeitsstundenProTag) || 8;
|
||||
const personen = wizardPlanPersonen();
|
||||
|
||||
let cursor = new Date(wizard.planStart);
|
||||
if(isNaN(cursor.getTime())) cursor = new Date();
|
||||
// Auf den nächsten Arbeitstag schieben, damit die erste Phase nicht am
|
||||
// Wochenende oder Feiertag beginnt (isWorkday() kennt die gepflegten
|
||||
// Feiertage, js/utils.js).
|
||||
while(!isWorkday(cursor)) cursor.setDate(cursor.getDate() + 1);
|
||||
|
||||
return phasen.map(lph => {
|
||||
const anteil = (Number(HONORAR_ANTEILE[lph]) || 0) / summe * 100;
|
||||
const phasenHonorar = b.betrag * anteil / 100;
|
||||
const stunden = phasenHonorar / satz;
|
||||
const tage = Math.max(1, Math.ceil(stunden / (proTag * personen)));
|
||||
const start = new Date(cursor);
|
||||
const ende = addWorkdays(start, tage - 1);
|
||||
cursor = addWorkdays(ende, 1);
|
||||
return { lph, anteil, honorar: phasenHonorar, stunden, tage, start, ende };
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- Vollständigkeit ----------
|
||||
|
||||
// wizardFehlt: was im aktuellen Schritt noch fehlt. Rückgabe: Array von
|
||||
// Klartexten; leer = der Schritt ist vollständig.
|
||||
//
|
||||
// Der Assistent lässt niemanden weiter, solange hier etwas steht: Genau die
|
||||
// Lücken, die sonst entstehen (kein Honorar, keine Phase, niemand
|
||||
// zugeordnet), sind der Grund, warum Projekte später in der Auslastung nicht
|
||||
// bewertbar sind.
|
||||
function wizardFehlt(key){
|
||||
const fehlt = [];
|
||||
if(key === 'projekt'){
|
||||
if(!wizard.nr.trim()) fehlt.push('Projektnummer');
|
||||
if(!wizard.name.trim()) fehlt.push('Bezeichnung');
|
||||
if(!wizard.gewerke.length) fehlt.push('mindestens ein Gewerk');
|
||||
}
|
||||
if(key === 'auftrag'){
|
||||
if(wizard.grundlage === 'honorar' && wizardHonorar() <= 0) fehlt.push('Honorar');
|
||||
if(wizard.grundlage === 'groesse' && wizardGroesse() <= 0) fehlt.push('Projektgröße');
|
||||
if(!wizard.beauftragt.length) fehlt.push('mindestens eine beauftragte Leistungsphase');
|
||||
}
|
||||
if(key === 'stand'){
|
||||
if(!wizard.amtierend) fehlt.push('amtierende Leistungsphase');
|
||||
}
|
||||
if(key === 'team'){
|
||||
if(wizardTeamAnzahl() === 0) fehlt.push('mindestens eine beteiligte Person');
|
||||
}
|
||||
if(key === 'terminplan'){
|
||||
if(wizard.grundlage === 'honorar' && wizard.planErzeugen && !wizard.planStart) fehlt.push('Beginn');
|
||||
}
|
||||
return fehlt;
|
||||
}
|
||||
|
||||
// ---------- Rendern ----------
|
||||
|
||||
function renderWizard(){
|
||||
const el = document.getElementById('wizardBody');
|
||||
if(!el || !wizard) return;
|
||||
if(wizard.fertig){ el.innerHTML = wizardAbschlussHtml(); return; }
|
||||
|
||||
const st = WIZARD_SCHRITTE[wizard.schritt];
|
||||
const fehlt = wizardFehlt(st.key);
|
||||
const letzter = wizard.schritt === WIZARD_SCHRITTE.length - 1;
|
||||
|
||||
const leiste = WIZARD_SCHRITTE.map((s, i) => {
|
||||
const erledigt = i < wizard.schritt;
|
||||
const kl = i === wizard.schritt ? ' ist-aktiv' : (erledigt ? ' ist-fertig' : '');
|
||||
return `<div class="wiz-stufe${kl}"><b>${erledigt ? '✓' : (i + 1)}</b><span>${esc(s.titel)}</span></div>`;
|
||||
}).join('<div class="wiz-strich"></div>');
|
||||
|
||||
el.innerHTML = `
|
||||
<div class="wiz-leiste">${leiste}</div>
|
||||
<div class="wiz-inhalt">
|
||||
<h3 class="wiz-titel">${esc(st.titel)}</h3>
|
||||
<p class="wiz-unter">${esc(st.unter)}</p>
|
||||
${wizardSchrittHtml(st.key)}
|
||||
</div>
|
||||
<div class="wiz-fuss">
|
||||
<button type="button" class="secondary" onclick="wizardZurueck()"${wizard.schritt === 0 ? ' disabled' : ''}>Zurück</button>
|
||||
<span class="wiz-zaehler">Schritt ${wizard.schritt + 1} von ${WIZARD_SCHRITTE.length}</span>
|
||||
<div class="wiz-fuss-rechts">
|
||||
${fehlt.length ? `<span class="wiz-fehlt">Es fehlt: ${esc(fehlt.join(', '))}</span>` : ''}
|
||||
${letzter
|
||||
? `<button type="button" onclick="wizardAnlegen()"${fehlt.length ? ' disabled' : ''}>Projekt anlegen</button>`
|
||||
: `<button type="button" onclick="wizardWeiter()"${fehlt.length ? ' disabled' : ''}>Weiter</button>`}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function wizardSchrittHtml(key){
|
||||
if(key === 'projekt') return wizardProjektHtml();
|
||||
if(key === 'auftrag') return wizardAuftragHtml();
|
||||
if(key === 'stand') return wizardStandHtml();
|
||||
if(key === 'team') return wizardTeamHtml();
|
||||
if(key === 'terminplan') return wizardTerminplanHtml();
|
||||
return wizardPruefenHtml();
|
||||
}
|
||||
|
||||
function wizardProjektHtml(){
|
||||
const tagVorschlaege = ['ELT', 'HLS', 'Sanierung', 'Neubau', 'Bestand', 'Schule'];
|
||||
return `
|
||||
<div class="wiz-zeile">
|
||||
<div class="form-row">
|
||||
<label>Projektnummer <span class="wiz-pflicht">*</span></label>
|
||||
<input type="text" id="wizNr" value="${esc(wizard.nr)}" placeholder="z. B. 26-4071"
|
||||
oninput="wizardSetz('nr', this.value)">
|
||||
<span class="hint">Dateien und Freigaben hängen an dieser Nummer.</span>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>Gruppe</label>
|
||||
<select onchange="wizardSetz('gruppe', this.value)">
|
||||
${GROUP_ORDER.map(g => `<option${g === wizard.gruppe ? ' selected' : ''}>${esc(g)}</option>`).join('')}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>Bezeichnung <span class="wiz-pflicht">*</span></label>
|
||||
<input type="text" id="wizName" value="${esc(wizard.name)}" placeholder="z. B. Sporthalle Soest, Sanierung Heizung"
|
||||
oninput="wizardSetz('name', this.value)">
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>Gewerke <span class="wiz-pflicht">*</span></label>
|
||||
<div class="wiz-chips">
|
||||
${Object.keys(GEWERK_LABELS).map(g => `
|
||||
<button type="button" class="wiz-chip wiz-chip-gewerk" aria-pressed="${wizard.gewerke.indexOf(g) >= 0}"
|
||||
title="${esc(GEWERK_LABELS[g])}" onclick="wizardGewerkUm('${g}')">${g}</button>`).join('')}
|
||||
</div>
|
||||
<span class="hint">Welche Fachbereiche bearbeiten das Projekt? Mehrfachauswahl.
|
||||
Die Wahl bestimmt, welche Mitarbeiter im Schritt „Team" zur Auswahl stehen.</span>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>Schlagwörter</label>
|
||||
<div class="wiz-chips">
|
||||
${tagVorschlaege.map(t => `
|
||||
<button type="button" class="wiz-chip" aria-pressed="${wizard.tags.indexOf(t) >= 0}"
|
||||
onclick="wizardTagUm('${esc(t)}')">${esc(t)}</button>`).join('')}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function wizardAuftragHtml(){
|
||||
const perHonorar = wizard.grundlage === 'honorar';
|
||||
return `
|
||||
<div class="form-row">
|
||||
<label>Auftragsstatus</label>
|
||||
<select onchange="wizardSetz('status', this.value)">
|
||||
${auftragsstatusOptionsHtml(wizard.status)}
|
||||
</select>
|
||||
<span class="hint">Anfrage und Angebot wandern in die Gruppe „In Anbahnung" und zählen nicht zur Auslastung.</span>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>Grundlage der Rechnung</label>
|
||||
<div class="wiz-chips">
|
||||
<button type="button" class="wiz-chip" aria-pressed="${perHonorar}" onclick="wizardSetz('grundlage','honorar')">Vereinbartes Honorar</button>
|
||||
<button type="button" class="wiz-chip" aria-pressed="${!perHonorar}" onclick="wizardSetz('grundlage','groesse')">Geschätzte Projektgröße</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wiz-zeile">
|
||||
<div class="form-row">
|
||||
<label>Honorar (netto, €) ${perHonorar ? '<span class="wiz-pflicht">*</span>' : ''}</label>
|
||||
<input type="number" id="wizHonorar" min="0" step="1000" value="${esc(wizard.honorar)}" placeholder="z. B. 480000"
|
||||
${perHonorar ? `oninput="wizardSetz('honorar', this.value)"` : 'disabled'}>
|
||||
<span class="hint" id="wizHinweisHonorar">${wizardHinweisHonorar()}</span>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>Projektgröße (0,00 – 1,00) ${!perHonorar ? '<span class="wiz-pflicht">*</span>' : ''}</label>
|
||||
<input type="number" id="wizGroesse" min="0" max="1" step="0.05" value="${esc(wizard.groesse)}" placeholder="z. B. 0,40"
|
||||
${!perHonorar ? `oninput="wizardSetz('groesse', this.value)"` : 'disabled'}>
|
||||
<span class="hint" id="wizHinweisGroesse">${wizardHinweisGroesse()}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>Beauftragte Leistungsphasen <span class="wiz-pflicht">*</span></label>
|
||||
<div class="wiz-chips">
|
||||
${AUFWAND_LPS.map(l => `
|
||||
<button type="button" class="wiz-chip" aria-pressed="${wizard.beauftragt.indexOf(l) >= 0}"
|
||||
onclick="wizardLphUm('${esc(l)}')">${esc(l.replace('LPH ', ''))}</button>`).join('')}
|
||||
</div>
|
||||
<span class="hint">Nur die beauftragten erscheinen im Wochenbericht — und nur sie stehen im Terminplan.</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// Die beiden Hinweistexte stehen als eigene Funktionen da, weil sie beim
|
||||
// Tippen mitlaufen müssen, ohne dass das Eingabefeld neu gebaut wird — sonst
|
||||
// springt der Cursor ans Feldende.
|
||||
function wizardHinweisHonorar(){
|
||||
return wizard.grundlage === 'honorar'
|
||||
? 'Grundlage für Leistungsstand, Auslastung und Terminplan.'
|
||||
: 'Gesperrt — es wird mit der Projektgröße gerechnet.';
|
||||
}
|
||||
function wizardHinweisGroesse(){
|
||||
return wizard.grundlage === 'groesse'
|
||||
? 'Grober Umfang des Projekts. 0 = keine Angabe, 1 = großes Projekt.'
|
||||
: 'Gesperrt — es wird mit dem Honorar gerechnet.';
|
||||
}
|
||||
|
||||
function wizardStandHtml(){
|
||||
const phasen = wizard.beauftragt.length ? wizard.beauftragt : AUFWAND_LPS;
|
||||
return `
|
||||
<div class="form-row">
|
||||
<label>Amtierende Leistungsphase <span class="wiz-pflicht">*</span></label>
|
||||
<div class="wiz-chips">
|
||||
${phasen.map(l => `
|
||||
<button type="button" class="wiz-chip" aria-pressed="${wizard.amtierend === l}"
|
||||
onclick="wizardSetz('amtierend','${esc(l)}')">${esc(l)}</button>`).join('')}
|
||||
</div>
|
||||
<span class="hint">Woran gerade gearbeitet wird. Ohne diese Angabe erschiene das Projekt
|
||||
in der Auslastung als nicht bewertbar.</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function wizardTeamHtml(){
|
||||
const gewerkNamen = wizard.gewerke.join(' · ');
|
||||
const zeilen = ROLES.map(r => {
|
||||
const leute = wizardSichtbare(r);
|
||||
const chips = leute.length
|
||||
? leute.map(p => {
|
||||
const fremd = wizard.gewerke.indexOf(p.gewerk) < 0;
|
||||
return `<button type="button" class="wiz-person${fremd ? ' ist-fremd' : ''}"
|
||||
data-rolle="${esc(r)}" aria-pressed="${wizard.team[r].indexOf(p.id) >= 0}"
|
||||
title="${esc(p.name + ' · ' + (p.gewerk || 'ohne Gewerk') + (fremd ? ' (anderes Gewerk)' : ''))}"
|
||||
onclick="wizardPersonUm('${esc(r)}','${esc(p.id)}')">${esc(p.kuerzel)}</button>`;
|
||||
}).join('')
|
||||
: `<span class="hint" style="margin:0">niemand aus ${esc(gewerkNamen)} in dieser Rolle</span>`;
|
||||
return `<div class="wiz-rolle">
|
||||
<span class="wiz-rolle-name">${esc(r)}</span>
|
||||
<div class="wiz-chips">${chips}</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
const uebrige = wizardUebrige();
|
||||
const auswahl = uebrige.length
|
||||
? `<select id="wizFremd" onchange="wizardFremdenHolen(this.value); this.value='';">
|
||||
<option value="">Mitarbeiter aus anderen Gewerken…</option>
|
||||
${Object.keys(GEWERK_LABELS).filter(g => wizard.gewerke.indexOf(g) < 0).map(g => {
|
||||
const teil = uebrige.filter(p => p.gewerk === g);
|
||||
if(!teil.length) return '';
|
||||
return `<optgroup label="${esc(GEWERK_LABELS[g])}">`
|
||||
+ teil.map(p => `<option value="${esc(p.id)}">${esc(p.name)} — ${esc(p.rollen.join(', ') || 'ohne Rolle')}</option>`).join('')
|
||||
+ '</optgroup>';
|
||||
}).join('')}
|
||||
${uebrige.some(p => !p.gewerk) ? `<optgroup label="Ohne Gewerk">`
|
||||
+ uebrige.filter(p => !p.gewerk).map(p => `<option value="${esc(p.id)}">${esc(p.name)}</option>`).join('')
|
||||
+ '</optgroup>' : ''}
|
||||
</select>`
|
||||
: `<span class="hint" style="margin:0">Es gibt niemanden hinzuzuholen.</span>`;
|
||||
|
||||
const dazu = wizard.fremde.map(id => {
|
||||
const p = wizardPersonen().find(x => x.id === id);
|
||||
if(!p) return '';
|
||||
return `<span class="wiz-fremd-chip">${esc(p.kuerzel)}${p.gewerk ? ' · ' + esc(p.gewerk) : ''}
|
||||
<button type="button" title="Wieder entfernen" onclick="wizardFremdenEntfernen('${esc(id)}')">×</button></span>`;
|
||||
}).join('');
|
||||
|
||||
return `
|
||||
<p class="hint" style="margin:0 0 14px">Gezeigt werden die Mitarbeiter aus <b>${esc(gewerkNamen)}</b>.
|
||||
Klick schaltet die Zuordnung um; sie bestimmt, wessen Auslastung das Projekt belastet
|
||||
und wer es auf seinem Dashboard sieht.</p>
|
||||
<div class="wiz-rollen">${zeilen}</div>
|
||||
<div class="wiz-fremd">
|
||||
<label>Aus anderen Gewerken hinzuholen</label>
|
||||
${auswahl}
|
||||
${dazu ? `<div class="wiz-fremd-liste">${dazu}</div>` : ''}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function wizardTerminplanHtml(){
|
||||
// Aus der Projektgröße lässt sich kein Terminplan rechnen: Sie ist eine
|
||||
// Schätzung des Umfangs, kein vereinbarter Betrag. Termine, die daraus
|
||||
// entstünden, sähen im Gantt so verbindlich aus wie errechnete.
|
||||
if(wizard.grundlage !== 'honorar'){
|
||||
return `<div class="wiz-sperre">
|
||||
<b>Kein Terminplan aus der Projektgröße.</b>
|
||||
<p>Die Größe ist eine Schätzung des Umfangs, kein vereinbarter Betrag. Termine, die daraus
|
||||
entstünden, sähen im Terminplan so verbindlich aus wie errechnete — ohne es zu sein.</p>
|
||||
<p>Sobald das Honorar feststeht, lässt sich der Terminplan im Projektbereich jederzeit
|
||||
nachträglich erzeugen („Terminplan aus Honorar").</p>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
const zeilen = wizardPlanZeilen();
|
||||
if(!zeilen.length){
|
||||
return `<div class="wiz-sperre"><b>Kein Terminplan errechenbar.</b>
|
||||
<p>Für die beauftragten Leistungsphasen sind keine Honorar-Anteile hinterlegt.
|
||||
Ein Administrator pflegt sie im Admin-Bereich unter „Honorar".</p></div>`;
|
||||
}
|
||||
|
||||
const summeTage = zeilen.reduce((s, z) => s + z.tage, 0);
|
||||
const proTag = Number(APP_SETTINGS.arbeitsstundenProTag) || 8;
|
||||
const satz = Number(APP_SETTINGS.stundensatz) || 100;
|
||||
|
||||
return `
|
||||
<label class="wiz-schalter">
|
||||
<input type="checkbox" ${wizard.planErzeugen ? 'checked' : ''} onchange="wizardSetz('planErzeugen', this.checked)">
|
||||
<span>Terminplan beim Anlegen erzeugen</span>
|
||||
</label>
|
||||
<div class="wiz-zeile" style="margin-top:14px">
|
||||
<div class="form-row">
|
||||
<label>Beginn</label>
|
||||
<input type="date" value="${esc(wizard.planStart)}"
|
||||
${wizard.planErzeugen ? `onchange="wizardSetz('planStart', this.value)"` : 'disabled'}>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>Gleichzeitig daran</label>
|
||||
<input type="number" id="wizPersonen" min="1" max="20" step="1" value="${esc(wizard.planPersonen)}"
|
||||
placeholder="${Math.max(1, wizardTeamAnzahl())}"
|
||||
${wizard.planErzeugen ? `oninput="wizardSetz('planPersonen', this.value)"` : 'disabled'}>
|
||||
<span class="hint">${wizard.planPersonen ? 'Überschreibt die Teamgröße.'
|
||||
: 'Vorgabe: ' + Math.max(1, wizardTeamAnzahl()) + ' — die Größe des Teams aus dem vorigen Schritt.'}</span>
|
||||
</div>
|
||||
</div>
|
||||
${wizard.planErzeugen ? `
|
||||
<div class="wiz-plan" id="wizPlanTabelle">${wizardPlanTabelleHtml()}</div>
|
||||
<p class="hint" style="margin-top:12px">Je Phase eine Aufgabe im Terminplan, hintereinander ab dem Beginn.
|
||||
Gerechnet mit ${esc(faktorFormat(satz))} €/h, ${esc(String(proTag))} Stunden je Arbeitstag und
|
||||
${wizardPlanPersonen()} gleichzeitig arbeitenden ${wizardPlanPersonen() === 1 ? 'Person' : 'Personen'};
|
||||
Wochenenden und gepflegte Feiertage zählen nicht mit. Alles lässt sich danach im Gantt verschieben.</p>`
|
||||
: `<p class="hint" style="margin-top:14px">Das Projekt entsteht ohne Terminplan.
|
||||
Er lässt sich jederzeit im Projektbereich nachträglich erzeugen.</p>`}`;
|
||||
}
|
||||
|
||||
function wizardPlanTabelleHtml(){
|
||||
const zeilen = wizardPlanZeilen();
|
||||
if(!zeilen.length) return '';
|
||||
const summeTage = zeilen.reduce((s, z) => s + z.tage, 0);
|
||||
const b = wizardBasis();
|
||||
return `<div class="wiz-plan-zeile wiz-plan-kopf">
|
||||
<span>Phase</span><span>Anteil</span><span>Honorar</span><span>Dauer</span><span>Zeitraum</span>
|
||||
</div>`
|
||||
+ zeilen.map(z => `<div class="wiz-plan-zeile">
|
||||
<span><b>${esc(z.lph)}</b></span>
|
||||
<span>${faktorFormat(z.anteil)} %</span>
|
||||
<span>${eurFormat(z.honorar)}</span>
|
||||
<span>${z.tage} AT</span>
|
||||
<span>${formatWeekDate(isoVon(z.start))} – ${formatWeekDate(isoVon(z.ende))}</span>
|
||||
</div>`).join('')
|
||||
+ `<div class="wiz-plan-zeile wiz-plan-summe">
|
||||
<span><b>Summe</b></span><span></span>
|
||||
<span>${eurFormat(b.betrag)}</span><span>${summeTage} AT</span>
|
||||
<span>Ende ${formatWeekDate(isoVon(zeilen[zeilen.length - 1].ende))}</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function wizardPruefenHtml(){
|
||||
const b = wizardBasis();
|
||||
const zeile = (label, wert, fehlt) =>
|
||||
`<div class="wiz-pruef-zeile"><span>${esc(label)}</span><b${fehlt ? ' class="fehlt"' : ''}>${wert}</b></div>`;
|
||||
const team = ROLES.filter(r => wizard.team[r].length)
|
||||
.map(r => r + ': ' + wizard.team[r].map(id => {
|
||||
const p = wizardPersonen().find(x => x.id === id);
|
||||
return p ? p.kuerzel : id;
|
||||
}).join(', ')).join(' · ');
|
||||
const plan = wizardPlanZeilen();
|
||||
|
||||
return `<div class="wiz-pruef">
|
||||
${zeile('Projektnummer', esc(wizard.nr))}
|
||||
${zeile('Bezeichnung', esc(wizard.name))}
|
||||
${zeile('Gewerke', esc(wizard.gewerke.join(', ')))}
|
||||
${zeile('Gruppe', esc(wizard.gruppe))}
|
||||
${zeile('Schlagwörter', wizard.tags.length ? esc(wizard.tags.join(', ')) : 'keine', !wizard.tags.length)}
|
||||
${zeile('Auftragsstatus', esc(auftragsstatusLabel(wizard.status)))}
|
||||
${zeile('Grundlage', wizard.grundlage === 'honorar'
|
||||
? eurFormat(wizardHonorar()) + ' Honorar'
|
||||
: 'Projektgröße ' + faktorFormat(wizardGroesse()))}
|
||||
${zeile('Beauftragte Phasen', esc(wizard.beauftragt.join(', ')))}
|
||||
${zeile('Amtierende Phase', esc(wizard.amtierend))}
|
||||
${zeile('Team', esc(team))}
|
||||
${wizard.fremde.length ? zeile('davon aus anderen Gewerken', esc(wizard.fremde.map(id => {
|
||||
const p = wizardPersonen().find(x => x.id === id);
|
||||
return p ? p.kuerzel + (p.gewerk ? ' (' + p.gewerk + ')' : '') : id;
|
||||
}).join(', '))) : ''}
|
||||
${zeile('Terminplan', wizard.grundlage !== 'honorar'
|
||||
? 'nicht möglich — Grundlage ist die Projektgröße'
|
||||
: (!wizard.planErzeugen ? 'wird nicht erzeugt'
|
||||
: (plan.length ? plan.length + ' Phasen bis ' + formatWeekDate(isoVon(plan[plan.length - 1].ende)) : 'nicht errechenbar')),
|
||||
wizard.grundlage !== 'honorar' || !wizard.planErzeugen || !plan.length)}
|
||||
</div>
|
||||
<p class="hint" style="margin-top:14px">Die Gewichtung des Aufwands je Phase und Rolle übernimmt die
|
||||
firmenweite Vorlage; sie lässt sich später im Projektbereich anpassen.</p>`;
|
||||
}
|
||||
|
||||
function wizardAbschlussHtml(){
|
||||
const p = wizard.fertig;
|
||||
const plan = Array.isArray(p.plan && p.plan.todos) ? p.plan.todos : [];
|
||||
return `<div class="wiz-fertig">
|
||||
<div class="wiz-haken">✓</div>
|
||||
<h3>${esc(p.nr)} · ${esc(p.name)} ist angelegt</h3>
|
||||
<p>${esc(p.gewerke.join(' · '))}${p.aktuelleLP && p.aktuelleLP.length ? ', amtierend ' + esc(p.aktuelleLP[0]) : ''}.
|
||||
${plan.length ? 'Der Terminplan steht mit ' + plan.length + ' Phasen. ' : ''}
|
||||
Das Projekt steht jetzt in der Projektliste, im Wochenbericht und in der Auslastung.</p>
|
||||
<div class="toolbar" style="justify-content:center">
|
||||
<button type="button" onclick="wizardZumProjekt()">Projektbereich öffnen</button>
|
||||
<button type="button" class="secondary" onclick="openNewProjWizard()">Noch eines anlegen</button>
|
||||
<button type="button" class="secondary" onclick="closeNewProjWizard()">Schließen</button>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ---------- Eingaben ----------
|
||||
|
||||
function wizardSetz(feld, wert){
|
||||
if(!wizard) return;
|
||||
wizard[feld] = wert;
|
||||
if(feld === 'status'){
|
||||
// Dieselbe Kopplung wie im früheren Dialog: Eine Anfrage ist noch kein
|
||||
// laufendes Projekt und gehört in die Anbahnung.
|
||||
if(wert === 'anfrage' || wert === 'angebot') wizard.gruppe = 'In Anbahnung';
|
||||
else if(wizard.gruppe === 'In Anbahnung') wizard.gruppe = 'Aktive Projekte';
|
||||
}
|
||||
if(feld === 'grundlage'){
|
||||
// Das nun gesperrte Feld leeren: Ein Wert, der dasteht und nicht mehr
|
||||
// zählt, führt in die Irre.
|
||||
if(wert === 'honorar') wizard.groesse = ''; else wizard.honorar = '';
|
||||
}
|
||||
// Freitextfelder nicht neu bauen — sonst springt der Cursor ans Feldende.
|
||||
if(feld === 'nr' || feld === 'name' || feld === 'honorar' || feld === 'groesse' || feld === 'planPersonen'){
|
||||
wizardNurAbhaengiges();
|
||||
return;
|
||||
}
|
||||
renderWizard();
|
||||
}
|
||||
|
||||
// wizardNurAbhaengiges: erneuert beim Tippen alles, was vom Wert abhängt —
|
||||
// aber kein Eingabefeld. Betroffen sind der Weiter-Knopf samt Fehlt-Hinweis,
|
||||
// die beiden Hinweistexte des Auftrag-Schritts und die Terminplan-Tabelle.
|
||||
function wizardNurAbhaengiges(){
|
||||
const st = WIZARD_SCHRITTE[wizard.schritt];
|
||||
const fehlt = wizardFehlt(st.key);
|
||||
|
||||
const rechts = document.querySelector('.wiz-fuss-rechts');
|
||||
if(rechts){
|
||||
const knopf = rechts.querySelector('button');
|
||||
if(knopf) knopf.disabled = fehlt.length > 0;
|
||||
let hinweis = rechts.querySelector('.wiz-fehlt');
|
||||
if(fehlt.length){
|
||||
if(!hinweis){
|
||||
hinweis = document.createElement('span');
|
||||
hinweis.className = 'wiz-fehlt';
|
||||
rechts.insertBefore(hinweis, rechts.firstChild);
|
||||
}
|
||||
hinweis.textContent = 'Es fehlt: ' + fehlt.join(', ');
|
||||
} else if(hinweis){
|
||||
hinweis.remove();
|
||||
}
|
||||
}
|
||||
|
||||
const hh = document.getElementById('wizHinweisHonorar');
|
||||
if(hh) hh.textContent = wizardHinweisHonorar();
|
||||
const hg = document.getElementById('wizHinweisGroesse');
|
||||
if(hg) hg.textContent = wizardHinweisGroesse();
|
||||
|
||||
const tab = document.getElementById('wizPlanTabelle');
|
||||
if(tab) tab.innerHTML = wizardPlanTabelleHtml();
|
||||
}
|
||||
|
||||
function wizardGewerkUm(g){
|
||||
const i = wizard.gewerke.indexOf(g);
|
||||
if(i >= 0){
|
||||
wizard.gewerke.splice(i, 1);
|
||||
// Beteiligte dieses Gewerks fallen aus dem Team — sonst stünde im Projekt
|
||||
// jemand, der nach der eigenen Regel gar nicht auswählbar wäre.
|
||||
const erlaubt = new Set(wizardPersonen()
|
||||
.filter(p => wizard.gewerke.indexOf(p.gewerk) >= 0 || wizard.fremde.indexOf(p.id) >= 0)
|
||||
.map(p => p.id));
|
||||
ROLES.forEach(r => { wizard.team[r] = wizard.team[r].filter(id => erlaubt.has(id)); });
|
||||
} else {
|
||||
wizard.gewerke.push(g);
|
||||
wizard.gewerke.sort((a, b) => Object.keys(GEWERK_LABELS).indexOf(a) - Object.keys(GEWERK_LABELS).indexOf(b));
|
||||
// Wer jetzt zum Projektgewerk gehört, muss nicht mehr als "fremd" gelten.
|
||||
wizard.fremde = wizard.fremde.filter(id => {
|
||||
const p = wizardPersonen().find(x => x.id === id);
|
||||
return !p || wizard.gewerke.indexOf(p.gewerk) < 0;
|
||||
});
|
||||
}
|
||||
renderWizard();
|
||||
}
|
||||
|
||||
function wizardTagUm(t){
|
||||
const i = wizard.tags.indexOf(t);
|
||||
if(i >= 0) wizard.tags.splice(i, 1); else wizard.tags.push(t);
|
||||
renderWizard();
|
||||
}
|
||||
|
||||
function wizardLphUm(l){
|
||||
const i = wizard.beauftragt.indexOf(l);
|
||||
if(i >= 0) wizard.beauftragt.splice(i, 1); else wizard.beauftragt.push(l);
|
||||
wizard.beauftragt.sort((a, b) => AUFWAND_LPS.indexOf(a) - AUFWAND_LPS.indexOf(b));
|
||||
// Eine amtierende Phase, die nicht mehr beauftragt ist, ergibt keinen Sinn.
|
||||
if(wizard.amtierend && wizard.beauftragt.length && wizard.beauftragt.indexOf(wizard.amtierend) < 0){
|
||||
wizard.amtierend = '';
|
||||
}
|
||||
renderWizard();
|
||||
}
|
||||
|
||||
function wizardPersonUm(rolle, id){
|
||||
const arr = wizard.team[rolle];
|
||||
const i = arr.indexOf(id);
|
||||
if(i >= 0) arr.splice(i, 1); else arr.push(id);
|
||||
renderWizard();
|
||||
}
|
||||
|
||||
function wizardFremdenHolen(id){
|
||||
if(!id || wizard.fremde.indexOf(id) >= 0) return;
|
||||
wizard.fremde.push(id);
|
||||
renderWizard();
|
||||
}
|
||||
function wizardFremdenEntfernen(id){
|
||||
wizard.fremde = wizard.fremde.filter(x => x !== id);
|
||||
ROLES.forEach(r => { wizard.team[r] = wizard.team[r].filter(x => x !== id); });
|
||||
renderWizard();
|
||||
}
|
||||
|
||||
function wizardWeiter(){
|
||||
if(wizardFehlt(WIZARD_SCHRITTE[wizard.schritt].key).length) return;
|
||||
if(wizard.schritt < WIZARD_SCHRITTE.length - 1){ wizard.schritt++; renderWizard(); }
|
||||
}
|
||||
function wizardZurueck(){
|
||||
if(wizard.schritt > 0){ wizard.schritt--; renderWizard(); }
|
||||
}
|
||||
|
||||
// ---------- Anlegen ----------
|
||||
|
||||
// wizardAnlegen: baut aus den Eingaben ein Projekt, hängt es an die laufende
|
||||
// Sitzung und speichert. Der Terminplan entsteht als gewöhnliche Aufgaben im
|
||||
// project.plan — genau so, wie ihn der Dialog „Terminplan aus Honorar" im
|
||||
// Projektbereich erzeugt.
|
||||
function wizardAnlegen(){
|
||||
if(!wizard || guardWeekLocked()) return;
|
||||
const offen = WIZARD_SCHRITTE.map(s => wizardFehlt(s.key)).reduce((a, b) => a.concat(b), []);
|
||||
if(offen.length){ alert('Es fehlt noch: ' + offen.join(', ')); return; }
|
||||
|
||||
const w = currentWeek();
|
||||
if(w.projects.some(p => (p.nr || '') === wizard.nr.trim())){
|
||||
if(!confirm('Die Projektnummer "' + wizard.nr.trim() + '" ist in dieser Sitzung bereits vergeben.\n\n'
|
||||
+ 'Beide Projekte würden sich dieselben Dateien und Freigaben teilen.\n\nTrotzdem anlegen?')) return;
|
||||
}
|
||||
|
||||
const nextOrder = w.projects
|
||||
.filter(p => p.group === wizard.gruppe)
|
||||
.reduce((max, p) => Math.max(max, p.order !== undefined ? p.order : 0), -1) + 1;
|
||||
|
||||
const pr = {
|
||||
id: uid(), projectUid: uid(),
|
||||
nr: wizard.nr.trim(), name: wizard.name.trim(),
|
||||
group: wizard.gruppe, gewerke: wizard.gewerke.slice(),
|
||||
tags: wizard.tags.slice(),
|
||||
status: '', statusState: undefined, order: nextOrder,
|
||||
lph: {}, assign: {}, plan: null,
|
||||
auftragsstatus: auftragsstatusOf({ auftragsstatus: wizard.status }),
|
||||
honorar: wizard.grundlage === 'honorar' ? wizardHonorar() : 0,
|
||||
groesse: wizard.grundlage === 'groesse' ? wizardGroesse() : 0,
|
||||
beauftragteLP: wizard.beauftragt.slice(),
|
||||
aktuelleLP: [wizard.amtierend],
|
||||
};
|
||||
|
||||
// Zuordnungen: Zustand 1 = Hauptverantwortung (dieselbe Bedeutung wie in
|
||||
// cycleAssign(), js/board/board.js).
|
||||
ROLES.forEach(r => {
|
||||
wizard.team[r].forEach(id => { pr.assign[assignKey(id, r)] = 1; });
|
||||
});
|
||||
|
||||
// Terminplan: je beauftragter Phase eine Aufgabe.
|
||||
const zeilen = wizard.planErzeugen ? wizardPlanZeilen() : [];
|
||||
if(zeilen.length){
|
||||
pr.plan = {
|
||||
todos: zeilen.map((z, i) => ({
|
||||
id: uid(),
|
||||
title: z.lph,
|
||||
start: isoVon(z.start),
|
||||
end: isoVon(z.ende),
|
||||
done: false,
|
||||
progress: 0,
|
||||
assignees: [],
|
||||
deps: [],
|
||||
order: i,
|
||||
})),
|
||||
milestones: [],
|
||||
};
|
||||
}
|
||||
|
||||
w.projects.push(pr);
|
||||
save();
|
||||
renderBoard();
|
||||
wizard.fertig = pr;
|
||||
renderWizard();
|
||||
}
|
||||
|
||||
// wizardZumProjekt: öffnet den Projektbereich des eben angelegten Projekts.
|
||||
function wizardZumProjekt(){
|
||||
const p = wizard && wizard.fertig;
|
||||
closeNewProjWizard();
|
||||
if(p && typeof openProjectPlan === 'function') openProjectPlan(p.id);
|
||||
}
|
||||
|
||||
// ---------- kleine Helfer ----------
|
||||
|
||||
// isoVon: Date → 'YYYY-MM-TT' in Ortszeit (nicht über toISOString(), das
|
||||
// rechnet nach UTC um und läge zwischen Mitternacht und 2 Uhr einen Tag
|
||||
// zurück — siehe heuteIso() in js/utils.js).
|
||||
function isoVon(d){
|
||||
return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0')
|
||||
+ '-' + String(d.getDate()).padStart(2, '0');
|
||||
}
|
||||
|
||||
// eurFormat: Betrag in deutscher Schreibweise ohne Nachkommastellen — im
|
||||
// Assistenten geht es um Größenordnungen, nicht um Cent.
|
||||
function eurFormat(v){
|
||||
return Math.round(Number(v) || 0).toLocaleString('de-DE') + ' €';
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
// -----------------------------
|
||||
// Projektuebersicht - Bereich "Einstellungen" (Mein Konto)
|
||||
// -----------------------------
|
||||
// Dieser Bereich gehoert dem angemeldeten Konto selbst: Stammdaten zur
|
||||
// Ansicht, die eigene E-Mail-Adresse und der Passwortwechsel. Sonst nichts.
|
||||
//
|
||||
// Bis zum Umbau standen hier auch die firmenweiten Masken (Honorar,
|
||||
// Auslastung, Termine, Feiertage, Vorlagen, Betrieb). Das vermischte zwei
|
||||
// verschiedene Dinge in einer Liste: die eigenen Kontoangaben, die jeden
|
||||
// etwas angehen, und Vorgaben, die die Zahlen ALLER verschieben und
|
||||
// ausschliesslich Administratoren zustehen. Letztere liegen jetzt im Bereich
|
||||
// "Admin" (js/admin/admin.js) - dort, wo die uebrigen Admin-Aufgaben schon
|
||||
// waren.
|
||||
//
|
||||
// Anzeigename, Rolle und die Personen-Zuordnung sind hier bewusst nur ZU
|
||||
// SEHEN: Sie pflegt ein Administrator in der Benutzerverwaltung
|
||||
// (PATCH /admin/users/:id). Die E-Mail-Adresse pflegt dagegen das Konto
|
||||
// selbst (POST /change-email) - ein Administrator kann sie weiterhin
|
||||
// setzen/aendern, aber niemand muss dafuer erst einen Administrator bitten,
|
||||
// nur um erreichbar zu sein. Das Passwort gehoert ebenfalls dem Konto
|
||||
// selbst; den Dialog dafuer stellt js/auth.js.
|
||||
|
||||
// renderSettings: Einstiegspunkt des Bereichs, von showTab('settings')
|
||||
// gerufen. Keine Parameter, keine Rueckgabe.
|
||||
function renderSettings(){
|
||||
renderSettingsAccount();
|
||||
renderSettingsAiKeys();
|
||||
}
|
||||
|
||||
// ---------- Mein Konto ----------
|
||||
|
||||
// renderSettingsAccount: zeigt die Stammdaten des angemeldeten Kontos an.
|
||||
// Nur die E-Mail-Adresse ist hier bearbeitbar (submitEmailChange()); alles
|
||||
// andere ausser dem Passwort pflegt ein Administrator in der
|
||||
// Benutzerverwaltung (PATCH /admin/users/:id). Keine Parameter, keine
|
||||
// Rückgabe.
|
||||
function renderSettingsAccount(){
|
||||
const el = document.getElementById('settingsAccountInfo');
|
||||
if(!el) return;
|
||||
if(!currentUser){ el.innerHTML = ''; return; }
|
||||
|
||||
const row = (k, v) => `<div class="settings-kv-row"><span class="settings-kv-key">${esc(k)}</span><span>${v}</span></div>`;
|
||||
|
||||
el.innerHTML =
|
||||
row('Benutzername', esc(currentUser.username || ''))
|
||||
+ row('Anzeigename', esc(currentUser.displayName || ''))
|
||||
+ `<div class="settings-kv-row">
|
||||
<span class="settings-kv-key">E-Mail</span>
|
||||
<span class="settings-email-edit">
|
||||
<input type="email" id="settingsEmailInput" value="${esc(currentUser.email || '')}" placeholder="name@firma.de">
|
||||
<button class="secondary" onclick="submitEmailChange()">Speichern</button>
|
||||
<span id="settingsEmailError" class="settings-warn" hidden></span>
|
||||
</span>
|
||||
</div>`
|
||||
+ row('Rolle', currentUser.role === 'admin' ? 'Administrator' : 'Mitglied')
|
||||
// Ohne Personen-Zuordnung bleibt „Meine Aufgaben" leer, weil die Aufgaben
|
||||
// über plan_todo_assignees.person_id zugeordnet werden (siehe
|
||||
// GET /my-dashboard). Deshalb steht der Hinweis hier und nicht nur dort.
|
||||
+ row('Personen-Zuordnung', currentUser.personId
|
||||
? esc(currentUser.personId)
|
||||
: '<span class="settings-warn">nicht gesetzt — „Meine Aufgaben" bleibt dadurch leer</span>')
|
||||
+ (currentUser.mustChangePassword
|
||||
? row('Hinweis', '<span class="settings-warn">Für dieses Konto ist ein Passwortwechsel erforderlich.</span>')
|
||||
: '');
|
||||
}
|
||||
|
||||
// submitEmailChange: schickt die eigene E-Mail-Adresse an POST /change-email.
|
||||
// Anders als der Passwortwechsel braucht es kein aktuelles Passwort zur
|
||||
// Bestätigung — die Route ist ohnehin an die eigene, per Session
|
||||
// authentifizierte Kontokennung gebunden (req.user.id), nicht an eine vom
|
||||
// Client mitgeschickte ID. Keine Parameter, keine Rückgabe.
|
||||
async function submitEmailChange(){
|
||||
const input = document.getElementById('settingsEmailInput');
|
||||
const errEl = document.getElementById('settingsEmailError');
|
||||
const show = msg => { if(errEl){ errEl.textContent = msg; errEl.hidden = false; } };
|
||||
if(errEl){ errEl.textContent = ''; errEl.hidden = true; }
|
||||
if(!input) return;
|
||||
|
||||
const email = input.value.trim();
|
||||
if(!email) return show('E-Mail-Adresse erforderlich.');
|
||||
|
||||
try {
|
||||
const r = await fetch(baseUrl() + '/change-email', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json; charset=utf-8' },
|
||||
body: JSON.stringify({ email })
|
||||
});
|
||||
const d = await r.json();
|
||||
if(!r.ok || !d.ok) return show(d && d.error ? d.error : 'E-Mail-Adresse konnte nicht gespeichert werden.');
|
||||
if(currentUser) currentUser.email = d.email;
|
||||
} catch(err){
|
||||
console.error('E-Mail-Änderung fehlgeschlagen:', err);
|
||||
return show('Verbindung zum Server fehlgeschlagen.');
|
||||
}
|
||||
|
||||
alert('Die E-Mail-Adresse wurde gespeichert.');
|
||||
}
|
||||
|
||||
// ---------- KI-Zugangsdaten (persönlich je Konto) ----------
|
||||
// Für die KI-gestützte Punkt-Erkennung beim Protokoll-Import
|
||||
// (js/plan/plan-protokoll.js). GET /my-ai-settings liefert je Anbieter nur
|
||||
// den Konfigurations-STATUS und die (nicht geheime) apiUrl — der Schlüssel
|
||||
// selbst kommt nie zurück, wie ein Passwort; das Eingabefeld bleibt deshalb
|
||||
// nach dem Laden immer leer. Ein Block je Anbieter (.ai-provider, siehe
|
||||
// styles.css) statt eines durchgehenden Feld-Rasters — Design-Entwurf mit
|
||||
// Erik abgestimmt.
|
||||
|
||||
// showSettingsNote: kurze, selbst verblassende Bestätigung am Fuß des
|
||||
// Bereichs — dasselbe Muster wie showAdminNote() in js/admin/admin.js
|
||||
// (#adminNote), hier für #settings/#settingsNote. Bewusst kein alert(): ein
|
||||
// erfolgreicher Speichervorgang soll den Arbeitsfluss nicht anhalten.
|
||||
// Parameter: text (String). Keine Rückgabe.
|
||||
function showSettingsNote(text){
|
||||
const el = document.getElementById('settingsNote');
|
||||
if(!el || !text) return;
|
||||
el.textContent = text;
|
||||
el.classList.add('show');
|
||||
if(showSettingsNote.timer) clearTimeout(showSettingsNote.timer);
|
||||
showSettingsNote.timer = setTimeout(() => el.classList.remove('show'), 2500);
|
||||
}
|
||||
|
||||
// setAiMode: wechselt einen Anbieter-Block zwischen "Cloud" (Vorgabe, kein
|
||||
// URL-Feld sichtbar) und "Eigener Server" (URL-Feld sichtbar) — ersetzt den
|
||||
// früheren, eingeklappten "API-URL"-Link durch eine sichtbare Wahl, siehe
|
||||
// styles.css. Reiner Anzeige-Zustand, kein Server-Aufruf; das eigentliche
|
||||
// Speichern der URL bleibt saveMyAiUrl(). Parameter: provider
|
||||
// ('chatgpt'|'claude'), modus ('cloud'|'lokal'). Keine Rückgabe.
|
||||
function setAiMode(provider, modus){
|
||||
const toggle = document.getElementById(provider === 'chatgpt' ? 'aiOpenaiModeToggle' : 'aiClaudeModeToggle');
|
||||
const panel = document.getElementById(provider === 'chatgpt' ? 'aiOpenaiUrlPanel' : 'aiClaudeUrlPanel');
|
||||
if(!toggle || !panel) return;
|
||||
toggle.querySelectorAll('button').forEach((btn, idx) => btn.classList.toggle('is-active', (idx === 0) === (modus === 'cloud')));
|
||||
panel.classList.toggle('is-open', modus === 'lokal');
|
||||
}
|
||||
|
||||
// renderSettingsAiKeys: holt den aktuellen Stand und füllt Status-Pill,
|
||||
// URL-Feld, Cloud/Eigener-Server-Umschalter und den Deaktivierungs-Zustand
|
||||
// der Entfernen-/Neu-verbinden-Knöpfe. Keine Parameter, keine Rückgabe.
|
||||
async function renderSettingsAiKeys(){
|
||||
const statusEl = { chatgpt: document.getElementById('aiOpenaiStatus'), claude: document.getElementById('aiClaudeStatus') };
|
||||
const urlEl = { chatgpt: document.getElementById('setAiOpenaiUrl'), claude: document.getElementById('setAiClaudeUrl') };
|
||||
const clearBtn = { chatgpt: document.getElementById('aiOpenaiClearBtn'), claude: document.getElementById('aiClaudeClearBtn') };
|
||||
const reconnectBtn = { chatgpt: document.getElementById('aiOpenaiReconnectBtn'), claude: document.getElementById('aiClaudeReconnectBtn') };
|
||||
const testErrorEl = { chatgpt: document.getElementById('aiOpenaiTestError'), claude: document.getElementById('aiClaudeTestError') };
|
||||
if(!statusEl.chatgpt) return;
|
||||
|
||||
try {
|
||||
const r = await fetch(baseUrl() + '/my-ai-settings');
|
||||
const d = await r.json();
|
||||
if(!r.ok || !d.ok) throw new Error((d && d.error) || 'Laden fehlgeschlagen.');
|
||||
['chatgpt', 'claude'].forEach(provider => {
|
||||
const info = d[provider] || { configured: false, apiUrl: '' };
|
||||
const chip = statusEl[provider];
|
||||
if(chip){
|
||||
chip.classList.toggle('is-on', !!info.configured);
|
||||
chip.innerHTML = '<span class="dot"></span>' + (info.configured ? 'Verbunden' : 'Nicht eingerichtet');
|
||||
}
|
||||
if(urlEl[provider] && document.activeElement !== urlEl[provider]) urlEl[provider].value = info.apiUrl || '';
|
||||
if(clearBtn[provider]) clearBtn[provider].disabled = !info.configured;
|
||||
if(reconnectBtn[provider]) reconnectBtn[provider].disabled = !info.configured;
|
||||
// Ein neuer Seitenaufruf ist ein neuer Anlauf — ein Fehler aus einem
|
||||
// früheren Verbindungstest soll nicht stehen bleiben, als gälte er
|
||||
// noch für den gerade geladenen Stand.
|
||||
if(testErrorEl[provider]) testErrorEl[provider].textContent = '';
|
||||
// Eine bereits gesetzte abweichende API-URL wird nicht hinter "Cloud"
|
||||
// versteckt — wer sie konfiguriert hat, soll beim Öffnen der Seite
|
||||
// sofort sehen, dass "Eigener Server" aktiv ist.
|
||||
setAiMode(provider, info.apiUrl ? 'lokal' : 'cloud');
|
||||
});
|
||||
} catch(err){
|
||||
console.error('KI-Einstellungen laden fehlgeschlagen:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// testMyAiConnection: "Neu verbinden" — prüft die GESPEICHERTE Konfiguration
|
||||
// wirklich (POST /my-ai-settings/test, echter Testaufruf über
|
||||
// erkennePunkteMitKI() serverseitig), nicht nur ob "configured" gesetzt
|
||||
// ist. Ein Fehler bleibt im .ai-test-error-Feld stehen, bis der nächste
|
||||
// Test läuft oder erfolgreich ist — bewusst kein flüchtiger Toast, siehe
|
||||
// styles.css. Parameter: provider. Keine Rückgabe.
|
||||
async function testMyAiConnection(provider){
|
||||
const btn = document.getElementById(provider === 'chatgpt' ? 'aiOpenaiReconnectBtn' : 'aiClaudeReconnectBtn');
|
||||
const errEl = document.getElementById(provider === 'chatgpt' ? 'aiOpenaiTestError' : 'aiClaudeTestError');
|
||||
if(errEl) errEl.textContent = '';
|
||||
if(btn){ btn.disabled = true; btn.classList.add('is-testing'); }
|
||||
|
||||
try {
|
||||
const r = await fetch(baseUrl() + '/my-ai-settings/test', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json; charset=utf-8' },
|
||||
body: JSON.stringify({ provider })
|
||||
});
|
||||
const d = await r.json();
|
||||
if(!r.ok || !d.ok){
|
||||
if(errEl) errEl.textContent = (d && d.error) || 'Verbindung fehlgeschlagen.';
|
||||
} else {
|
||||
showSettingsNote((provider === 'chatgpt' ? 'ChatGPT' : 'Claude') + ': Verbindung erfolgreich.');
|
||||
}
|
||||
} catch(err){
|
||||
console.error('KI-Verbindungstest fehlgeschlagen:', err);
|
||||
if(errEl) errEl.textContent = 'Verbindung zum Server fehlgeschlagen.';
|
||||
} finally {
|
||||
if(btn){ btn.disabled = false; btn.classList.remove('is-testing'); }
|
||||
}
|
||||
}
|
||||
|
||||
// putMyAiKey: gemeinsamer PUT-Aufruf für saveMyAiKey/saveMyAiUrl/
|
||||
// clearMyAiKey. patch enthält nur das tatsächlich zu ändernde Feld
|
||||
// (apiKey ODER apiUrl) — ein fehlendes Feld bleibt serverseitig
|
||||
// unangetastet, sodass ein Speichern im einen Feld nie das andere
|
||||
// überschreibt. Parameter: provider ('chatgpt'|'claude'), patch (Objekt).
|
||||
// Rückgabe: Promise.
|
||||
function putMyAiKey(provider, patch){
|
||||
const body = {};
|
||||
body[provider] = patch;
|
||||
return fetch(baseUrl() + '/my-ai-settings', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json; charset=utf-8' },
|
||||
body: JSON.stringify(body)
|
||||
}).then(r => r.json().then(json => ({ ok: r.ok, json }))).then(({ ok, json }) => {
|
||||
if(!ok || !json.ok) throw new Error((json && json.error) || 'Speichern fehlgeschlagen.');
|
||||
return json;
|
||||
});
|
||||
}
|
||||
|
||||
// saveMyAiKey: übernimmt den eingegebenen Schlüssel. Ein leeres Feld gilt
|
||||
// als Fehlbedienung (zum Löschen dient der eigene "Entfernen"-Knopf,
|
||||
// clearMyAiKey()), nicht als "nichts tun". Parameter: provider. Keine
|
||||
// Rückgabe.
|
||||
async function saveMyAiKey(provider){
|
||||
const errEl = document.getElementById('settingsAiError');
|
||||
const show = msg => { if(errEl){ errEl.textContent = msg; errEl.hidden = false; } };
|
||||
if(errEl){ errEl.textContent = ''; errEl.hidden = true; }
|
||||
|
||||
const keyEl = document.getElementById(provider === 'chatgpt' ? 'setAiOpenaiKey' : 'setAiClaudeKey');
|
||||
const keyValue = keyEl ? keyEl.value.trim() : '';
|
||||
if(!keyValue) return show('Bitte einen Schlüssel eingeben.');
|
||||
|
||||
try {
|
||||
await putMyAiKey(provider, { apiKey: keyValue });
|
||||
if(keyEl) keyEl.value = '';
|
||||
renderSettingsAiKeys();
|
||||
showSettingsNote('Schlüssel gespeichert.');
|
||||
} catch(err){
|
||||
console.error('KI-Schlüssel speichern fehlgeschlagen:', err);
|
||||
show(err.message || String(err));
|
||||
}
|
||||
}
|
||||
|
||||
// saveMyAiUrl: übernimmt die eingegebene API-URL — leer löscht eine zuvor
|
||||
// gesetzte Abweichung und fällt auf die offizielle Cloud-API zurück.
|
||||
// Eigenständig von saveMyAiKey(), da beide Felder jetzt getrennte
|
||||
// Speichern-Knöpfe haben (siehe .ai-url-panel/setAiMode()). Parameter:
|
||||
// provider. Keine Rückgabe.
|
||||
async function saveMyAiUrl(provider){
|
||||
const errEl = document.getElementById('settingsAiError');
|
||||
const show = msg => { if(errEl){ errEl.textContent = msg; errEl.hidden = false; } };
|
||||
if(errEl){ errEl.textContent = ''; errEl.hidden = true; }
|
||||
|
||||
const urlEl = document.getElementById(provider === 'chatgpt' ? 'setAiOpenaiUrl' : 'setAiClaudeUrl');
|
||||
const urlValue = urlEl ? urlEl.value.trim() : '';
|
||||
|
||||
try {
|
||||
await putMyAiKey(provider, { apiUrl: urlValue });
|
||||
renderSettingsAiKeys();
|
||||
showSettingsNote(urlValue ? 'API-URL gespeichert.' : 'API-URL entfernt — offizielle API wird wieder verwendet.');
|
||||
} catch(err){
|
||||
console.error('API-URL speichern fehlgeschlagen:', err);
|
||||
show(err.message || String(err));
|
||||
}
|
||||
}
|
||||
|
||||
// clearMyAiKey: löscht nur den Schlüssel, lässt eine hinterlegte API-URL
|
||||
// unangetastet (patch enthält absichtlich kein apiUrl-Feld). Parameter:
|
||||
// provider. Keine Rückgabe.
|
||||
async function clearMyAiKey(provider){
|
||||
const errEl = document.getElementById('settingsAiError');
|
||||
try {
|
||||
await putMyAiKey(provider, { apiKey: '' });
|
||||
renderSettingsAiKeys();
|
||||
showSettingsNote('Schlüssel entfernt.');
|
||||
} catch(err){
|
||||
console.error('KI-Schlüssel entfernen fehlgeschlagen:', err);
|
||||
if(errEl){ errEl.textContent = err.message || String(err); errEl.hidden = false; }
|
||||
}
|
||||
}
|
||||
+33
-79
@@ -3,12 +3,11 @@
|
||||
// -----------------------------
|
||||
// Präsentationsmodus für die Hauptseite (nur von Test.html geladen, nach
|
||||
// js/gantt.js, vor js/tabs-auth.js). Er durchläuft automatisch die in
|
||||
// SHOW_MODE_SEQUENCE hinterlegten Abteilungs-/Ansichts-Kombinationen (Board
|
||||
// und Auslastung) und zeigt sie in einem Vollbild-Overlay. Genutzt werden
|
||||
// showTab() aus js/tabs-auth.js, switchDepartment() aus js/store.js,
|
||||
// setHeaderCollapsed() aus js/ui.js sowie renderBoard()/renderLoad() aus
|
||||
// js/board/board.js/js/load/load.js. Der gesamte Lauf-Zustand liegt in den
|
||||
// Modulvariablen am Dateikopf.
|
||||
// SHOW_MODE_SEQUENCE hinterlegten Ansichten (Board und Auslastung) und zeigt
|
||||
// sie in einem Vollbild-Overlay. Genutzt werden showTab() aus
|
||||
// js/tabs-auth.js, setHeaderCollapsed() aus js/ui.js sowie renderBoard()/
|
||||
// renderLoad() aus js/board/board.js/js/load/load.js. Der gesamte Lauf-
|
||||
// Zustand liegt in den Modulvariablen am Dateikopf.
|
||||
|
||||
// showModeActive: true, solange der Show Mode läuft
|
||||
let showModeActive = false;
|
||||
@@ -18,22 +17,15 @@ let showModeTimer = null;
|
||||
let showModeResumeTimer = null;
|
||||
// showModePaused: true, wenn der Durchlauf gerade pausiert ist
|
||||
let showModePaused = false;
|
||||
// showModePollTimer: Timer-Handle des Poll-Intervalls in waitForLoadThenRender
|
||||
// (wird in stopShowMode mit aufgeräumt, sonst rendert es nach dem Beenden weiter)
|
||||
let showModePollTimer = null;
|
||||
// showModeOriginal: Abteilung und Tab vor dem Start, wird beim Beenden wiederhergestellt
|
||||
let showModeOriginal = { department: DEFAULT_DEPARTMENT, tab: DEFAULT_TAB };
|
||||
// showModeOriginal: Tab vor dem Start, wird beim Beenden wiederhergestellt
|
||||
let showModeOriginal = { tab: DEFAULT_TAB };
|
||||
const SHOW_MODE_SLIDE_DURATION = 10000; // 10 Sekunden pro Slide
|
||||
const SHOW_MODE_PAUSE_DURATION = 60000; // 1 Minute Pause
|
||||
// SHOW_MODE_SEQUENCE: Reihenfolge der Slides (Abteilung + Ansicht), die
|
||||
// zyklisch durchlaufen wird
|
||||
// SHOW_MODE_SEQUENCE: Reihenfolge der Slides (Ansicht), die zyklisch
|
||||
// durchlaufen wird
|
||||
const SHOW_MODE_SEQUENCE = [
|
||||
{ dept: 'ELT', view: 'board' },
|
||||
{ dept: 'ELT', view: 'load' },
|
||||
{ dept: 'HLS', view: 'board' },
|
||||
{ dept: 'HLS', view: 'load' },
|
||||
{ dept: 'ARCH', view: 'board' },
|
||||
{ dept: 'ARCH', view: 'load' }
|
||||
{ view: 'board' },
|
||||
{ view: 'load' }
|
||||
];
|
||||
// showModeIndex: Position innerhalb der SHOW_MODE_SEQUENCE
|
||||
let showModeIndex = 0;
|
||||
@@ -45,7 +37,7 @@ function startShowMode(){
|
||||
if(showModeActive) return;
|
||||
showModeActive = true;
|
||||
showModePaused = false;
|
||||
showModeOriginal = { department: currentDepartment, tab: currentTab };
|
||||
showModeOriginal = { tab: currentTab };
|
||||
showModeIndex = 0;
|
||||
|
||||
const overlay = document.getElementById('showModeOverlay');
|
||||
@@ -58,28 +50,29 @@ function startShowMode(){
|
||||
}
|
||||
|
||||
// stopShowMode: beendet den Präsentationsmodus: stoppt alle Timer, verbirgt
|
||||
// das Overlay und stellt Abteilung und Tab des ursprünglichen Zustands wieder
|
||||
// her (renderAll danach). Kein Rückgabewert.
|
||||
// das Overlay und stellt den Tab des ursprünglichen Zustands wieder her
|
||||
// (renderAll danach). Kein Rückgabewert.
|
||||
function stopShowMode(){
|
||||
showModeActive = false;
|
||||
showModePaused = false;
|
||||
clearTimeout(showModeTimer);
|
||||
clearTimeout(showModeResumeTimer);
|
||||
if(showModePollTimer){ clearInterval(showModePollTimer); showModePollTimer = null; }
|
||||
|
||||
const overlay = document.getElementById('showModeOverlay');
|
||||
if(overlay) overlay.style.display = 'none';
|
||||
|
||||
// Ursprünglichen Zustand wiederherstellen. Geschützte Tabs ('projects'/
|
||||
// 'people') werden nur ohne Passwort angezeigt, wenn bereits authentifiziert –
|
||||
// sonst öffnet showTab (ohne skipAuth) das Passwort-Modal.
|
||||
// Inhalt leeren: Die Kopie enthält dieselben Element-IDs wie das echte Board
|
||||
// bzw. der Auslastungs-Tab (#boardTable, #loadGrid, #loadScope) und steht im
|
||||
// Dokument VOR ihnen. Blieb sie stehen, trafen renderBoard()/renderLoad()
|
||||
// ab dann die tote Kopie im ausgeblendeten Overlay — die sichtbare Ansicht
|
||||
// fror auf dem Stand vor der Präsentation ein, bis die Seite neu geladen wurde.
|
||||
const content = document.getElementById('showModeContent');
|
||||
if(content) content.innerHTML = '';
|
||||
|
||||
// Ursprünglichen Zustand wiederherstellen.
|
||||
currentTab = showModeOriginal.tab;
|
||||
if(currentDepartment !== showModeOriginal.department){
|
||||
switchDepartment(showModeOriginal.department);
|
||||
} else {
|
||||
showTab(currentTab, isAuthenticated);
|
||||
renderAll();
|
||||
}
|
||||
showTab(currentTab);
|
||||
renderAll();
|
||||
}
|
||||
|
||||
// pauseShowMode: pausiert den Durchlauf (Anzeige "Pausiert") und startet die
|
||||
@@ -114,8 +107,7 @@ function resumeShowMode(){
|
||||
}
|
||||
|
||||
// showModeNextSlide: stellt die nächste Folie der SHOW_MODE_SEQUENCE dar:
|
||||
// setzt Titel und Ladehinweis, wechselt ggf. per switchDepartment die Abteilung
|
||||
// (mit waitForLoadThenRender) und kopiert danach die aktuelle Ansicht ins
|
||||
// setzt Titel und Ladehinweis und kopiert danach die aktuelle Ansicht ins
|
||||
// Overlay. Kein Rückgabewert.
|
||||
function showModeNextSlide(){
|
||||
if(!showModeActive || showModePaused) return;
|
||||
@@ -125,8 +117,7 @@ function showModeNextSlide(){
|
||||
|
||||
const titleEl = document.getElementById('showModeTitle');
|
||||
if(titleEl){
|
||||
const deptLabel = DEPARTMENTS[slide.dept] ? DEPARTMENTS[slide.dept].label : slide.dept;
|
||||
titleEl.textContent = `${deptLabel} – ${slide.view === 'load' ? 'Auslastung' : 'Projektboard'}`;
|
||||
titleEl.textContent = slide.view === 'load' ? 'Auslastung' : 'Projektboard';
|
||||
}
|
||||
|
||||
// Ladehinweis anzeigen, damit der alte Inhalt nicht kurz sichtbar flackert
|
||||
@@ -135,51 +126,14 @@ function showModeNextSlide(){
|
||||
content.innerHTML = '<div class="show-mode-loading">Lädt…</div>';
|
||||
}
|
||||
|
||||
// Wechsle Department und View in der Hauptansicht, dann kopiere ins Overlay.
|
||||
// Da switchDepartment asynchron lädt, müssen wir auf den Abschluss warten.
|
||||
if(currentDepartment !== slide.dept){
|
||||
// Temporär currentTab setzen, damit nach dem Laden der richtige Tab aktiv ist.
|
||||
currentTab = slide.view;
|
||||
switchDepartment(slide.dept);
|
||||
// Warte bis Daten geladen sind, dann rendern und kopieren
|
||||
waitForLoadThenRender(slide.view);
|
||||
} else {
|
||||
showTab(slide.view, true);
|
||||
renderAll();
|
||||
showTab(slide.view);
|
||||
renderAll();
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
copyCurrentViewToShowMode();
|
||||
scheduleNextShowModeSlide();
|
||||
});
|
||||
copyCurrentViewToShowMode();
|
||||
scheduleNextShowModeSlide();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// waitForLoadThenRender: wartet nach dem asynchronen Abteilungswechsel, bis
|
||||
// Wochen geladen sind (Polling alle 200 ms, Abbruch nach 30 Versuchen), und
|
||||
// rendert dann die gewünschte Ansicht, kopiert sie ins Show-Mode-Overlay und
|
||||
// plant die nächste Folie. Parameter: view = 'board' oder 'load'.
|
||||
function waitForLoadThenRender(view){
|
||||
let attempts = 0;
|
||||
showModePollTimer = setInterval(() => {
|
||||
attempts++;
|
||||
// Poll nur fortsetzen, solange der Show Mode noch läuft (sonst würde nach
|
||||
// stopShowMode der gerade wiederhergestellte Zustand überschrieben).
|
||||
if(!showModeActive && showModePollTimer){ clearInterval(showModePollTimer); showModePollTimer = null; return; }
|
||||
// Wenn Wochen geladen sind oder nach Seed gefallen ist
|
||||
if((db.weeks.length > 0 || attempts > 30) && showModeActive){
|
||||
if(showModePollTimer){ clearInterval(showModePollTimer); showModePollTimer = null; }
|
||||
showTab(view, true);
|
||||
renderAll();
|
||||
// Kurze Verzögerung, damit das DOM aktualisiert ist, bevor kopiert wird
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
copyCurrentViewToShowMode();
|
||||
scheduleNextShowModeSlide();
|
||||
});
|
||||
});
|
||||
}
|
||||
}, 200);
|
||||
});
|
||||
}
|
||||
|
||||
// scheduleNextShowModeSlide: plant den Wechsel zur nächsten Folie nach
|
||||
|
||||
+250
-45
@@ -1,65 +1,118 @@
|
||||
// -----------------------------
|
||||
// Projektübersicht — UI-Zustand (LocalStorage, Theme, Header)
|
||||
// -----------------------------
|
||||
// Diese Datei verwaltet den reinen UI-Zustand: gespeicherte Abteilung in
|
||||
// LocalStorage, den kollabierbaren Header sowie das Light/Dark-Theme. Sie wird
|
||||
// nach js/globals.js von beiden HTML-Einstiegspunkten geladen (Test.html und
|
||||
// project-plan.html). Die bereitgestellten Funktionen werden von js/init.js
|
||||
// beim Start sowie von js/tabs-auth.js (Tab-Wechsel, showTab) und js/store.js
|
||||
// (Abteilungswechsel, switchDepartment) aufgerufen; im Theme-Umschalter der
|
||||
// Diese Datei verwaltet den reinen UI-Zustand: den kollabierbaren Header sowie
|
||||
// das Light/Dark-Theme. Sie wird nach js/globals.js von beiden HTML-
|
||||
// Einstiegspunkt Test.html geladen. Die
|
||||
// bereitgestellten Funktionen werden von js/init.js beim Start sowie von
|
||||
// js/tabs-auth.js (Tab-Wechsel, showTab) aufgerufen; im Theme-Umschalter der
|
||||
// Seite werden toggleTheme/applyTheme direkt eingebunden.
|
||||
// Seit der Projektbereich eines Projekts kein eigenes Dokument mehr ist,
|
||||
// sondern der Bereich #plan von Test.html, kennen applyUrlRoute() und
|
||||
// updateRouteUrl() auch dessen Adresse "/Projektplan/<Projektname>".
|
||||
|
||||
// saveUiState: speichert Abteilung und aktiven Tab in den LocalStorage
|
||||
// saveUiState: speichert den aktiven Tab in den LocalStorage
|
||||
// Parameter: keine. Rückgabe: keine.
|
||||
function saveUiState(){
|
||||
try {
|
||||
const state = { department: currentDepartment, tab: currentTab };
|
||||
const state = { tab: currentTab };
|
||||
window.localStorage.setItem(STORAGE, JSON.stringify(state));
|
||||
} catch (err) {
|
||||
console.warn('UI-State kann nicht gespeichert werden:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// loadUiState: stellt die zuletzt gewählte Abteilung aus dem LocalStorage wieder her
|
||||
// Parameter: keine. Rückgabe: keine (setzt currentDepartment aus js/globals.js).
|
||||
// loadUiState: liest den persistierten UI-State aus dem LocalStorage.
|
||||
// Parameter: keine. Rückgabe: keine.
|
||||
function loadUiState(){
|
||||
try {
|
||||
const state = JSON.parse(window.localStorage.getItem(STORAGE) || '{}');
|
||||
if(state.department && DEPARTMENTS[state.department]) currentDepartment = state.department;
|
||||
JSON.parse(window.localStorage.getItem(STORAGE) || '{}');
|
||||
// Tab wird nicht aus dem localStorage wiederhergestellt, damit nach dem
|
||||
// Neuladen immer das Projektboard angezeigt wird.
|
||||
// Neuladen immer DEFAULT_TAB (Dashboard "Meine Aufgaben") angezeigt wird.
|
||||
} catch (err) {
|
||||
console.warn('UI-State kann nicht geladen werden:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Deep-Link-Routen (Abteilung + Tab als URL) ----------
|
||||
// ---------- Deep-Link-Routen (Tab als URL) ----------
|
||||
|
||||
// applyUrlRoute: wertet einen Deep-Link "/<Abteilung>/<Tab>" aus der URL aus,
|
||||
// z. B. "/HLS/Auslastung" oder "/ELT/Projektübersicht". Gültige Routen liefern
|
||||
// { department, tab }, alles andere (auch file://-Pfade) liefert null.
|
||||
// applyUrlRoute: wertet einen Deep-Link aus der Adresszeile aus. Zwei
|
||||
// Formen sind gültig:
|
||||
// "/<Tab>" — z. B. "/Auslastung", "/Projektübersicht"
|
||||
// "/Projektplan/<Projektname>" — der Projektbereich EINES Projekts,
|
||||
// mit ?weekLabel=…&projNr=… als Parameter
|
||||
// Die zweite Form führte früher auf ein eigenes Dokument
|
||||
// (public/project-plan.html); seit der Projektbereich ein Bereich der
|
||||
// Hauptseite ist, liefert sie schlicht den Bereich 'plan' mitsamt seinem
|
||||
// Ziel. Rückgabe: { tab } bzw. { tab:'plan', weekLabel, projNr, projName },
|
||||
// sonst null (auch bei file://-Pfaden).
|
||||
function applyUrlRoute(){
|
||||
try {
|
||||
const m = window.location.pathname.match(/^\/([^/]+)\/([^/]+)\/?$/);
|
||||
const planRe = new RegExp('^/' + PLAN_SLUG + '(?:/(.*))?/?$');
|
||||
const planMatch = window.location.pathname.match(planRe);
|
||||
if(planMatch){
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
let projName = '';
|
||||
try { projName = decodeURIComponent(planMatch[1] || ''); }
|
||||
catch(e) { projName = params.get('projName') || ''; }
|
||||
return {
|
||||
tab: 'plan',
|
||||
weekLabel: params.get('weekLabel') || '',
|
||||
projNr: params.get('projNr') || '',
|
||||
projName: projName || (params.get('projName') || ''),
|
||||
planId: params.get('planId') || ''
|
||||
};
|
||||
}
|
||||
const m = window.location.pathname.match(/^\/([^/]+)\/?$/);
|
||||
if(!m) return null;
|
||||
const dept = m[1];
|
||||
const tab = TAB_BY_SLUG[decodeURIComponent(m[2])];
|
||||
if(!DEPARTMENTS[dept] || !tab) return null;
|
||||
return { department: dept, tab };
|
||||
const tab = TAB_BY_SLUG[decodeURIComponent(m[1])];
|
||||
if(!tab) return null;
|
||||
return { tab };
|
||||
} catch (err) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// updateRouteUrl: schreibt Abteilung + aktiven Tab als sprechende URL in die
|
||||
// Adresszeile (z. B. "/HLS/Auslastung"), ohne die Seite neu zu laden. Nur über
|
||||
// HTTP (nicht per file://) und nur bei bekannter Abteilung/Tab. Keine Rückgabe.
|
||||
function updateRouteUrl(dept, tab){
|
||||
// updateRouteUrl: schreibt den aktiven Bereich als sprechende URL in die
|
||||
// Adresszeile (z. B. "/Auslastung"), ohne die Seite neu zu laden. Nur über
|
||||
// HTTP (nicht per file://) und nur bei bekanntem Bereich. Der Projektbereich
|
||||
// ist der Sonderfall: seine Adresse trägt zusätzlich das geöffnete Projekt
|
||||
// ("/Projektplan/<Name>?weekLabel=…&projNr=…"), damit sie sich wie bisher
|
||||
// verschicken lässt; die Werte kommen aus window.PlanApp (setPlanTarget() in
|
||||
// js/plan/plan-state.js, das showTab() vorher gefüllt hat). Keine Rückgabe.
|
||||
function updateRouteUrl(tab){
|
||||
try {
|
||||
if(!window.location.protocol || window.location.protocol.indexOf('http') !== 0) return;
|
||||
if(tab === 'plan'){
|
||||
const app = window.PlanApp;
|
||||
if(!app || !app.weekLabel) return;
|
||||
const url = new URL('/' + PLAN_SLUG + '/' + encodeURIComponent(app.projName || ''), window.location.origin);
|
||||
url.searchParams.set('weekLabel', app.weekLabel);
|
||||
// Ein Sandbox-Plan der Planung hat keine Projektnummer — er wird über
|
||||
// planId adressiert (die stabile id, siehe openSandboxPlan(),
|
||||
// js/plan/plan-state.js), nicht über projNr wie ein echtes Projekt.
|
||||
if(app.weekLabel === SANDBOX_WEEK_LABEL){
|
||||
url.searchParams.set('planId', app.sandboxPlanId || '');
|
||||
} else {
|
||||
url.searchParams.set('projNr', app.projNr || '');
|
||||
}
|
||||
// Das Fragment bleibt stehen: Es trägt den gewählten Unterbereich
|
||||
// (…#aufgaben, showPlanSection() in js/plan/plan-sections.js). Würde es
|
||||
// hier abgeschnitten, landete ein verschickter Link auf einen
|
||||
// bestimmten Unterbereich immer wieder im Dashboard.
|
||||
window.history.replaceState(null, '', url.pathname + url.search + window.location.hash);
|
||||
return;
|
||||
}
|
||||
const slug = TAB_SLUGS[tab];
|
||||
if(!slug || !DEPARTMENTS[dept]) return;
|
||||
window.history.replaceState(null, '', '/' + dept + '/' + slug);
|
||||
if(!slug) return;
|
||||
// Der Bereich „Admin" hält seinen Abschnitt im Fragment
|
||||
// (…/Admin#auslastung, showAdminSection() in js/admin/admin.js). Es muss
|
||||
// erhalten bleiben, weil showTab() diese Funktion VOR dem Rendern
|
||||
// aufruft — abgeschnitten wäre der Abschnitt eines verschickten Links
|
||||
// schon weg, bevor ihn jemand liest. Bei allen anderen Bereichen fällt
|
||||
// das Fragment bewusst weg.
|
||||
const fragment = (tab === 'admin') ? window.location.hash : '';
|
||||
window.history.replaceState(null, '', '/' + slug + fragment);
|
||||
} catch (err) {}
|
||||
}
|
||||
|
||||
@@ -89,24 +142,44 @@ function saveHeaderState(collapsed){
|
||||
} catch (err) {}
|
||||
}
|
||||
|
||||
// setHeaderCollapsed: klappt das Header-Menü auf/zu und aktualisiert Icon und aria-Attribute
|
||||
// Parameter: collapsed (Boolean), persist (Boolean, ob in LocalStorage gespeichert wird). Rückgabe: keine.
|
||||
// isDesktopSidebarLayout: true ab der Breakpoint-Breite (769px, siehe
|
||||
// styles.css), ab der die Sidebar per CSS dauerhaft sichtbar erzwungen wird
|
||||
// (transform:none), unabhängig vom Off-Canvas-Zustand.
|
||||
// Parameter: keine. Rückgabe: Boolean.
|
||||
function isDesktopSidebarLayout(){
|
||||
return window.matchMedia && window.matchMedia('(min-width: 769px)').matches;
|
||||
}
|
||||
|
||||
// setHeaderCollapsed: klappt das Off-Canvas-Seiten-Menü auf/zu und
|
||||
// aktualisiert das aria-expanded-Attribut des öffnenden Hamburger-Knopfs im
|
||||
// Menüband (.mobile-nav-toggle). Öffnen- und Schließen-Knopf sind zwei
|
||||
// getrennte Elemente mit je eigenem, festem Icon (Hamburger bzw. ×) — anders
|
||||
// als früher gibt es keinen einzelnen Knopf mehr, dessen Icon umgeschaltet
|
||||
// werden müsste. Wichtig: aria-hidden darf NUR gesetzt werden, wenn die
|
||||
// Sidebar tatsächlich unsichtbar ist (Off-Canvas, < 769px) — ab 769px zeigt
|
||||
// styles.css sie per CSS immer an (transform:none), ein dort weiterhin
|
||||
// gesetztes aria-hidden="true" widerspräche dem sichtbaren, fokussierbaren
|
||||
// Inhalt (führte zu "Blocked aria-hidden on an element because its
|
||||
// descendant retained focus" in Chrome und verhinderte dort zuverlässige
|
||||
// Klicks auf Elemente der Sidebar, u.a. den Einklapp-Knopf).
|
||||
// Parameter: collapsed (Boolean), persist (Boolean, ob in LocalStorage
|
||||
// gespeichert wird). Rückgabe: keine.
|
||||
function setHeaderCollapsed(collapsed, persist){
|
||||
const menu = document.getElementById('headerMenu');
|
||||
const btn = document.querySelector('.header-bar .header-toggle');
|
||||
const icon = document.querySelector('.header-bar .header-toggle-icon');
|
||||
const btn = document.querySelector('.mobile-nav-toggle');
|
||||
if(!menu) return;
|
||||
if(collapsed){
|
||||
menu.classList.remove('open');
|
||||
menu.setAttribute('aria-hidden', 'true');
|
||||
if(icon) icon.textContent = '☰';
|
||||
if(btn) btn.setAttribute('aria-expanded', 'false');
|
||||
} else {
|
||||
menu.classList.add('open');
|
||||
menu.setAttribute('aria-hidden', 'false');
|
||||
if(icon) icon.textContent = '✕';
|
||||
if(btn) btn.setAttribute('aria-expanded', 'true');
|
||||
const hiddenNow = collapsed && !isDesktopSidebarLayout();
|
||||
// Fokus vor dem Verstecken wegnehmen, falls er gerade auf einem Element
|
||||
// innerhalb der Sidebar liegt (z. B. der zuletzt angeklickte Nav-Punkt,
|
||||
// dessen Klick das Off-Canvas-Menü zugleich schließt) — sonst entsteht
|
||||
// genau der "aria-hidden on focused descendant"-Konflikt aus der
|
||||
// Browser-Warnung.
|
||||
if(hiddenNow && document.activeElement && menu.contains(document.activeElement)){
|
||||
document.activeElement.blur();
|
||||
}
|
||||
menu.classList.toggle('open', !collapsed);
|
||||
menu.setAttribute('aria-hidden', hiddenNow ? 'true' : 'false');
|
||||
if(btn) btn.setAttribute('aria-expanded', collapsed ? 'false' : 'true');
|
||||
if(persist) saveHeaderState(collapsed);
|
||||
}
|
||||
|
||||
@@ -118,14 +191,146 @@ function toggleHeader(){
|
||||
setHeaderCollapsed(isOpen, true);
|
||||
}
|
||||
|
||||
// Beim Überschreiten der Sidebar-Breakpoint-Breite (769px) per Fenster-
|
||||
// Resize wird aria-hidden neu bewertet (siehe setHeaderCollapsed()) — sonst
|
||||
// bliebe z. B. nach einem Verkleinern-und-wieder-Vergrößern des Fensters ein
|
||||
// aria-hidden="true" auf der jetzt wieder sichtbaren Desktop-Sidebar stehen.
|
||||
if(window.matchMedia){
|
||||
window.matchMedia('(min-width: 769px)').addEventListener('change', function(){
|
||||
const menu = document.getElementById('headerMenu');
|
||||
if(!menu) return;
|
||||
setHeaderCollapsed(!menu.classList.contains('open'), false);
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- Einklappbare Sidebar (Icon-Rail, nur Desktop ≥769px) ----------
|
||||
|
||||
// SIDEBAR_COLLAPSED_KEY: LocalStorage-Key für den Einklapp-Zustand der Sidebar
|
||||
const SIDEBAR_COLLAPSED_KEY = 'projuebersicht-sidebar-collapsed';
|
||||
|
||||
// loadSidebarCollapseState: liest den gespeicherten Sidebar-Zustand aus dem
|
||||
// LocalStorage. Parameter: keine. Rückgabe: keine (wendet den Zustand per
|
||||
// setSidebarCollapsed an).
|
||||
function loadSidebarCollapseState(){
|
||||
let collapsed = false; // Default: Sidebar ist beim ersten Start aufgeklappt
|
||||
try {
|
||||
const stored = window.localStorage.getItem(SIDEBAR_COLLAPSED_KEY);
|
||||
if(stored !== null){
|
||||
collapsed = stored === 'true';
|
||||
}
|
||||
} catch (err) {}
|
||||
setSidebarCollapsed(collapsed, false);
|
||||
}
|
||||
|
||||
// saveSidebarCollapseState: speichert den Einklapp-Zustand der Sidebar im LocalStorage
|
||||
// Parameter: collapsed (Boolean). Rückgabe: keine.
|
||||
function saveSidebarCollapseState(collapsed){
|
||||
try {
|
||||
window.localStorage.setItem(SIDEBAR_COLLAPSED_KEY, collapsed ? 'true' : 'false');
|
||||
} catch (err) {}
|
||||
}
|
||||
|
||||
// setSidebarCollapsed: klappt die Sidebar zwischen voller Breite und
|
||||
// Icon-Rail um (nur auf Desktop-Breite sichtbar, siehe styles.css) und
|
||||
// aktualisiert das aria-pressed-Attribut des Umschalt-Knopfs.
|
||||
// Parameter: collapsed (Boolean), persist (Boolean, ob in LocalStorage
|
||||
// gespeichert wird). Rückgabe: keine.
|
||||
function setSidebarCollapsed(collapsed, persist){
|
||||
const menu = document.getElementById('headerMenu');
|
||||
if(!menu) return;
|
||||
menu.classList.toggle('collapsed', collapsed);
|
||||
const btn = document.getElementById('sidebarCollapseToggle');
|
||||
if(btn) btn.setAttribute('aria-pressed', collapsed ? 'true' : 'false');
|
||||
if(persist) saveSidebarCollapseState(collapsed);
|
||||
}
|
||||
|
||||
// toggleSidebarCollapse: wechselt die Sidebar zwischen aufgeklappt und
|
||||
// Icon-Rail um und persistiert den neuen Zustand.
|
||||
// Parameter: keine. Rückgabe: keine.
|
||||
function toggleSidebarCollapse(){
|
||||
const menu = document.getElementById('headerMenu');
|
||||
const isCollapsed = menu && menu.classList.contains('collapsed');
|
||||
setSidebarCollapsed(!isCollapsed, true);
|
||||
}
|
||||
|
||||
// ---------- Aufklappbare Sidebar-Bereiche (Einstellungen/Admin) ----------
|
||||
|
||||
// toggleSidebarAccordion: klappt einen der beiden Aktions-Bereiche in der
|
||||
// Seitenleiste (Einstellungen/Admin) auf oder zu. Erwartet die zugehörigen
|
||||
// Elemente unter den IDs "<key>AccordionToggle" (Kopf-Button) und
|
||||
// "<key>Subnav" (Liste der Aktionen), siehe Test.html. Kein Persistieren des
|
||||
// Zustands — jeder Bereich startet beim Neuladen zugeklappt.
|
||||
// Parameter: key ('settings'|'admin'). Rückgabe: keine.
|
||||
function toggleSidebarAccordion(key){
|
||||
const toggleBtn = document.getElementById(key + 'AccordionToggle');
|
||||
const subnav = document.getElementById(key + 'Subnav');
|
||||
if(!toggleBtn || !subnav) return;
|
||||
const nowOpen = !subnav.classList.contains('open');
|
||||
subnav.classList.toggle('open', nowOpen);
|
||||
toggleBtn.classList.toggle('expanded', nowOpen);
|
||||
toggleBtn.setAttribute('aria-expanded', nowOpen ? 'true' : 'false');
|
||||
}
|
||||
|
||||
// ---------- Konto-Menü (Avatar-Dropdown im Menüband) ----------
|
||||
|
||||
// toggleUserMenu: öffnet/schließt das Konto-Dropdown (#userMenuDropdown) im
|
||||
// Menüband. Parameter: keine. Rückgabe: keine.
|
||||
function toggleUserMenu(){
|
||||
const dropdown = document.getElementById('userMenuDropdown');
|
||||
if(!dropdown) return;
|
||||
if(dropdown.classList.contains('open')) closeUserMenu();
|
||||
else openUserMenu();
|
||||
}
|
||||
|
||||
// openUserMenu: öffnet das Konto-Dropdown und bindet den Klick-außerhalb-
|
||||
// sowie Escape-Handler (gleiches Muster wie openRangePicker() in
|
||||
// js/date-picker.js: setTimeout, damit der öffnende Klick selbst nicht
|
||||
// sofort wieder schließt). Parameter: keine. Rückgabe: keine.
|
||||
function openUserMenu(){
|
||||
const dropdown = document.getElementById('userMenuDropdown');
|
||||
const btn = document.getElementById('userMenuToggle');
|
||||
if(!dropdown) return;
|
||||
dropdown.classList.add('open');
|
||||
if(btn) btn.setAttribute('aria-expanded', 'true');
|
||||
setTimeout(() => {
|
||||
document.addEventListener('mousedown', userMenuOutsideClick);
|
||||
document.addEventListener('keydown', userMenuEscape);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
// closeUserMenu: schließt das Konto-Dropdown und löst die Handler wieder.
|
||||
// Parameter: keine. Rückgabe: keine.
|
||||
function closeUserMenu(){
|
||||
const dropdown = document.getElementById('userMenuDropdown');
|
||||
const btn = document.getElementById('userMenuToggle');
|
||||
if(dropdown) dropdown.classList.remove('open');
|
||||
if(btn) btn.setAttribute('aria-expanded', 'false');
|
||||
document.removeEventListener('mousedown', userMenuOutsideClick);
|
||||
document.removeEventListener('keydown', userMenuEscape);
|
||||
}
|
||||
|
||||
// userMenuOutsideClick: schließt das Dropdown bei Klick außerhalb von
|
||||
// .topbar-user. Parameter: e (Mousedown-Event). Rückgabe: keine.
|
||||
function userMenuOutsideClick(e){
|
||||
const wrapper = document.querySelector('.topbar-user');
|
||||
if(wrapper && !wrapper.contains(e.target)) closeUserMenu();
|
||||
}
|
||||
|
||||
// userMenuEscape: schließt das Dropdown bei Escape. Parameter: e
|
||||
// (Keydown-Event). Rückgabe: keine.
|
||||
function userMenuEscape(e){
|
||||
if(e.key === 'Escape') closeUserMenu();
|
||||
}
|
||||
|
||||
// ---------- Theme (Light/Dark) ----------
|
||||
|
||||
// applyThemeIcon: setzt das Sonne-/Mond-Icon passend zum effektiven Theme
|
||||
// Parameter: theme (String 'dark'/'light'). Rückgabe: keine.
|
||||
function applyThemeIcon(theme){
|
||||
const icon = document.getElementById('themeIcon');
|
||||
if(!icon) return;
|
||||
icon.textContent = theme === 'dark' ? '☀️' : '🌙';
|
||||
// Bewusst `iconEl` statt `icon` benannt, siehe Kommentar in setHeaderCollapsed().
|
||||
const iconEl = document.getElementById('themeIcon');
|
||||
if(!iconEl) return;
|
||||
iconEl.innerHTML = theme === 'dark' ? icon('sun') : icon('moon');
|
||||
}
|
||||
|
||||
// loadTheme: liest das gespeicherte Theme aus dem LocalStorage und wendet es an
|
||||
|
||||
+504
-14
@@ -8,15 +8,23 @@
|
||||
// u. a. js/csv.js, js/board/board.js, js/load/load.js sowie den Plan-Modulen unter js/plan/*
|
||||
// (z. B. für ISO-Datumsformate und Werktage) und von js/gantt.js.
|
||||
|
||||
// departmentParam: gibt ein Query-String-Fragment fuer die aktuelle Abteilung zurueck.
|
||||
// Parameter: keine. Rückgabe: String wie '?department=ELT'.
|
||||
function departmentParam(){
|
||||
return '?department=' + encodeURIComponent(currentDepartment);
|
||||
}
|
||||
|
||||
// esc: einfache Escape-Funktion für Ausgabe in HTML
|
||||
// Parameter: s (String). Rückgabe: HTML-sicherer String (&, <, >, " ersetzt).
|
||||
function esc(s){ return (s||"").replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
|
||||
// Parameter: s (String). Rückgabe: HTML-sicherer String (&, <, >, ", ' ersetzt).
|
||||
// Das einfache Anführungszeichen MUSS mit maskiert werden: An mehreren Stellen
|
||||
// landet der Rückgabewert in einem einfach gequoteten JS-String innerhalb eines
|
||||
// onclick-Attributs (z. B. takeCommentFromHistory in js/board/board.js,
|
||||
// setPlanAssign in js/plan/plan-info.js). Ein Apostroph im Sitzungslabel oder
|
||||
// in einer Personen-Kennung brach dort sonst den Handler auf.
|
||||
function esc(s){
|
||||
// String() statt (s||""): Leistungsphasen-Prozentwerte und andere Zahlen
|
||||
// kommen je nach Herkunft als Number an (Beispieldaten, ältere Datensätze),
|
||||
// und (100).replace ist keine Funktion — der Aufruf riss bisher das ganze
|
||||
// Rendern mit. 0 und false sollen als "0"/"false" erscheinen, nur null und
|
||||
// undefined werden zu "".
|
||||
return (s === null || s === undefined ? '' : String(s))
|
||||
.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>')
|
||||
.replace(/"/g,'"').replace(/'/g,''');
|
||||
}
|
||||
|
||||
// uid: generiert eine einfache, ausreichend eindeutige ID
|
||||
// Parameter: keine. Rückgabe: String aus Zeitstempel und Zufallsteil.
|
||||
@@ -72,6 +80,19 @@ function personIdForName(name){
|
||||
return 'p_' + String(name).trim().toLowerCase().replace(/[^a-z0-9]/g,'_');
|
||||
}
|
||||
|
||||
// Schlägt aus einem Namen ein kurzes Kürzel vor (für schmale Board-Spalten-
|
||||
// köpfe, 2-4 Zeichen). Bei mehreren durch Leerzeichen getrennten Wörtern die
|
||||
// Anfangsbuchstaben der ersten vier Wörter, bei einem einzelnen Wort dessen
|
||||
// erste drei Buchstaben. Nur ein Vorschlag — bleibt manuell überschreibbar
|
||||
// (siehe personIdManuallyEdited-Muster in admin-users.js).
|
||||
// Parameter: name (String). Rückgabe: String wie 'MM' oder ''.
|
||||
function initialsForName(name){
|
||||
const words = String(name || '').trim().split(/\s+/).filter(Boolean);
|
||||
if(words.length === 0) return '';
|
||||
if(words.length === 1) return words[0].slice(0, 3).toUpperCase();
|
||||
return words.slice(0, 4).map(w => w[0]).join('').toUpperCase();
|
||||
}
|
||||
|
||||
// formatWeekDate: wandelt ein ISO-Datum (YYYY-MM-DD) in deutsches Format DD.MM.YYYY um
|
||||
// Parameter: dateString (String). Rückgabe: formatierter String oder leer.
|
||||
function formatWeekDate(dateString){
|
||||
@@ -112,6 +133,30 @@ function addDays(d, n){
|
||||
return res;
|
||||
}
|
||||
|
||||
// heuteIso: das heutige Datum als ISO-Zeichenkette (YYYY-MM-TT) in ORTSZEIT.
|
||||
//
|
||||
// Nicht new Date().toISOString().slice(0,10): Das rechnet nach UTC um. In
|
||||
// deutscher Sommerzeit (UTC+2) ist damit zwischen 00:00 und 02:00 Ortszeit
|
||||
// noch der Vortag gemeint — ein Termin von heute galt in diesem Zeitfenster
|
||||
// als überfällig. Rückgabe: String.
|
||||
function heuteIso(){
|
||||
const d = new Date();
|
||||
const m = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const t = String(d.getDate()).padStart(2, '0');
|
||||
return d.getFullYear() + '-' + m + '-' + t;
|
||||
}
|
||||
|
||||
// isoPlusTage: Datum in n Tagen als ISO-Zeichenkette in Ortszeit (siehe
|
||||
// heuteIso zur Begründung). Parameter: n (ganze Zahl, auch negativ).
|
||||
// Rückgabe: String.
|
||||
function isoPlusTage(n){
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() + (Number(n) || 0));
|
||||
const m = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const t = String(d.getDate()).padStart(2, '0');
|
||||
return d.getFullYear() + '-' + m + '-' + t;
|
||||
}
|
||||
|
||||
// daysBetween: Anzahl der Kalendertage zwischen zwei Daten
|
||||
// Parameter: a, b (Date). Rückgabe: ganzzahlige Tagesdifferenz (b - a).
|
||||
function daysBetween(a, b){
|
||||
@@ -119,21 +164,55 @@ function daysBetween(a, b){
|
||||
return Math.round(ms / (1000 * 60 * 60 * 24));
|
||||
}
|
||||
|
||||
// workdaysBetween: Anzahl der Werktage (Mo–Fr) im Zeitraum inkl. Start- und Endtag.
|
||||
// Wochenendtage (Sa/So) werden nicht mitgezählt – z.B. für die Auslastung.
|
||||
// Parameter: a, b (Date). Rückgabe: Anzahl der Werktage oder 0 bei ungültigem Zeitraum.
|
||||
// isWorkday: prüft, ob ein Datum ein Arbeitstag ist — kein Wochenende und
|
||||
// kein gepflegter Feiertag (HOLIDAYS in js/globals.js, gefüllt aus
|
||||
// GET /holidays). Vor dem Ausbau der Einstellungen kannte die Anwendung nur
|
||||
// Wochenenden; ein Terminplan über Weihnachten fiel damit um mehrere Tage zu
|
||||
// kurz aus. Parameter: d (Date). Rückgabe: Boolean.
|
||||
function isWorkday(d){
|
||||
if(!d) return false;
|
||||
const day = d.getDay();
|
||||
if(day === 0 || day === 6) return false;
|
||||
return !(typeof HOLIDAYS !== 'undefined' && HOLIDAYS.has(formatDateISO(d)));
|
||||
}
|
||||
|
||||
// workdaysBetween: Anzahl der Arbeitstage im Zeitraum inkl. Start- und Endtag.
|
||||
// Wochenenden und Feiertage werden nicht mitgezählt – z.B. für die Auslastung.
|
||||
// Parameter: a, b (Date). Rückgabe: Anzahl der Arbeitstage oder 0 bei ungültigem Zeitraum.
|
||||
function workdaysBetween(a, b){
|
||||
if(!a || !b || b < a) return 0;
|
||||
let count = 0;
|
||||
let current = new Date(a.getTime());
|
||||
while(current <= b){
|
||||
const day = current.getDay();
|
||||
if(day !== 0 && day !== 6) count++;
|
||||
if(isWorkday(current)) count++;
|
||||
current = addDays(current, 1);
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
// addWorkdays: liefert das Datum, das `n` Arbeitstage nach `d` liegt.
|
||||
// n = 0 ergibt `d` selbst, sofern das ein Arbeitstag ist — fällt `d` auf ein
|
||||
// Wochenende oder einen Feiertag, wird auf den nächsten Arbeitstag vorgerückt.
|
||||
// Gegenstück zu workdaysBetween(); gebraucht von der Honorar-Terminplanung
|
||||
// (js/plan/plan-honorar.js), die Phasen nahtlos in Arbeitstagen aneinanderreiht.
|
||||
// Die Obergrenze der Schleifen ist eine Reißleine: Wären versehentlich alle
|
||||
// Tage als Feiertag gepflegt, liefe sie sonst endlos.
|
||||
// Parameter: d (Date), n (Anzahl Arbeitstage, >= 0). Rückgabe: Date.
|
||||
function addWorkdays(d, n){
|
||||
if(!d) return null;
|
||||
let current = new Date(d.getTime());
|
||||
let guard = 0;
|
||||
// Startdatum selbst auf einen Arbeitstag schieben
|
||||
while(!isWorkday(current) && guard++ < 400) current = addDays(current, 1);
|
||||
let remaining = Math.max(0, Math.floor(n));
|
||||
guard = 0;
|
||||
while(remaining > 0 && guard++ < 100000){
|
||||
current = addDays(current, 1);
|
||||
if(isWorkday(current)) remaining--;
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
// formatDateDE: formatiert ein Date-Objekt als deutsches Datum (DD.MM.YYYY)
|
||||
// Parameter: d (Date). Rückgabe: String oder leer bei ungültigem Datum.
|
||||
function formatDateDE(d){
|
||||
@@ -149,4 +228,415 @@ function getWeekNumber(d){
|
||||
date.setUTCDate(date.getUTCDate() + 4 - dayNum);
|
||||
const yearStart = new Date(Date.UTC(date.getUTCFullYear(), 0, 1));
|
||||
return Math.ceil((((date - yearStart) / 86400000) + 1) / 7);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Status-Editor (Board & Projektplan) ----------
|
||||
// Der contenteditable Status-/Kommentar-Editor (kleine Formatierungs-Toolbar:
|
||||
// Fett, Farben, Absätze) wird sowohl im Projektboard (js/board/board.js,
|
||||
// Detailpanel) als auch auf der Projektplan-Seite (js/plan/plan-info.js)
|
||||
// verwendet — deshalb liegen die reinen Hilfsfunktionen hier im gemeinsamen
|
||||
// Modul. Nur statusBlur() (board.js, ruft editProj() auf) bzw. der
|
||||
// Plan-Gegenpart (planStatusBlur() in plan-info.js) bleiben modul-eigen, da
|
||||
// sie unterschiedliche Speicherpfade ansteuern.
|
||||
|
||||
// currentStatusEditor: aktuell fokussiertes contenteditable-Element des
|
||||
// Status-Editors; wird für die Formatierungs-Toolbar (`formatStatus`) benötigt.
|
||||
let currentStatusEditor = null;
|
||||
// currentStatusPid: Projekt-ID der gerade fokussierten Status-Zelle (nur vom
|
||||
// Board genutzt, siehe statusBlur() dort); auf der Projektplan-Seite bleibt
|
||||
// sie ungenutzt (dort gibt es nur einen Status-Editor pro Seite).
|
||||
let currentStatusPid = null;
|
||||
|
||||
// statusFocus: merkt sich das fokussierte Status-Element und die zugehörige
|
||||
// Projekt-ID (`el`, `pid`), damit die Toolbar und das Blur-Speichern sie nutzen.
|
||||
function statusFocus(el, pid){
|
||||
currentStatusEditor = el;
|
||||
currentStatusPid = pid;
|
||||
}
|
||||
|
||||
// statusValueFromHtml: normalisiert den HTML-Inhalt eines Status-Editors.
|
||||
// Liefert bei reinem Leerraum '' zurück, sonst den unveränderten HTML-String
|
||||
// (Formatierung bleibt erhalten). Parameter: `html`, Rückgabe: String.
|
||||
function statusValueFromHtml(html){
|
||||
const tmp = document.createElement('div');
|
||||
tmp.innerHTML = html;
|
||||
const text = (tmp.textContent || tmp.innerText || '').replace(/ /g, ' ');
|
||||
return text.trim() === '' ? '' : html;
|
||||
}
|
||||
|
||||
// formatStatus: wendet einen document.execCommand-Befehl (z.B. 'bold',
|
||||
// 'foreColor') auf das fokussierte Status-Element an. Parameter: `command`,
|
||||
// optionaler `value`; keine Rückgabe.
|
||||
function formatStatus(command, value){
|
||||
if(!currentStatusEditor) return;
|
||||
currentStatusEditor.focus();
|
||||
document.execCommand(command, false, value || null);
|
||||
}
|
||||
|
||||
// sanitizeStatusHtml: bereinigt übergebenen Status-HTML. Erlaubt nur eine kleine
|
||||
// Whitelist an HTML-Tags/Attributen (Formatierung), damit keine Skripte
|
||||
// eingeschleust werden können; im style-Attribut sind nur color/font-weight
|
||||
// erlaubt. Parameter: `html` (String). Rückgabe: bereinigter HTML-String ('' bei
|
||||
// leerem Eingabewert).
|
||||
function sanitizeStatusHtml(html){
|
||||
if(!html) return '';
|
||||
const div = document.createElement('div');
|
||||
div.innerHTML = html;
|
||||
const allowedTags = new Set(['B','STRONG','I','EM','U','P','BR','DIV','SPAN','FONT']);
|
||||
const allowedAttrs = new Set(['style']);
|
||||
// Elemente, deren INHALT mit verschwindet, statt ausgepackt zu werden:
|
||||
// Bei <script> wäre der ausgepackte Rest sonst der sichtbare Quelltext.
|
||||
const dropWithContent = new Set(['SCRIPT','STYLE','NOSCRIPT','TEMPLATE','IFRAME','OBJECT','EMBED']);
|
||||
const walk = (node) => {
|
||||
for(let i = node.childNodes.length - 1; i >= 0; i--){
|
||||
const child = node.childNodes[i];
|
||||
if(child.nodeType === 3) continue;
|
||||
if(child.nodeType === 1){
|
||||
const tag = child.tagName.toUpperCase();
|
||||
if(dropWithContent.has(tag)){
|
||||
node.removeChild(child);
|
||||
continue;
|
||||
}
|
||||
if(!allowedTags.has(tag)){
|
||||
// Referenzknoten ist `child` (ein Kind von `node`) — mit `node`
|
||||
// selbst wirft insertBefore laut DOM-Spezifikation NotFoundError,
|
||||
// und schon ein eingefügter Link hätte das ganze Rendern abgebrochen.
|
||||
let ausgepackt = 0;
|
||||
while(child.firstChild){ node.insertBefore(child.firstChild, child); ausgepackt++; }
|
||||
node.removeChild(child);
|
||||
// Die ausgepackten Knoten stehen jetzt an den Positionen i … i+n-1.
|
||||
// Ohne diese Korrektur liefe die absteigende Schleife an ihnen
|
||||
// vorbei — aus einer eingefügten Liste blieben dann die <li>-Tags
|
||||
// und aus einer Tabelle <tbody>/<tr>/<td> stehen, obwohl die
|
||||
// Positivliste beides ausschließt.
|
||||
i += ausgepackt;
|
||||
continue;
|
||||
}
|
||||
for(let j = child.attributes.length - 1; j >= 0; j--){
|
||||
const attr = child.attributes[j].name;
|
||||
if(!allowedAttrs.has(attr.toLowerCase())) child.removeAttribute(attr);
|
||||
}
|
||||
if(child.hasAttribute('style')){
|
||||
// Erlaubt nur color und font-weight im style-Attribut
|
||||
const style = child.getAttribute('style');
|
||||
const safe = (style || '').split(';').map(s => s.trim()).filter(s => {
|
||||
const prop = s.split(':')[0].trim().toLowerCase();
|
||||
return prop === 'color' || prop === 'font-weight';
|
||||
}).join('; ');
|
||||
if(safe) child.setAttribute('style', safe);
|
||||
else child.removeAttribute('style');
|
||||
}
|
||||
walk(child);
|
||||
}
|
||||
}
|
||||
};
|
||||
walk(div);
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
// statusHtmlToEditText: wandelt einen gespeicherten Kommentar (HTML) in den
|
||||
// Klartext, der im Textfeld des Kommentar-Editors steht. Fettschrift wird zu
|
||||
// **Sternchen**, Absätze und <br> werden zu Zeilenumbrüchen, alles andere
|
||||
// fällt weg. Gegenstück zu statusEditTextToHtml(). Nötig, weil der Editor im
|
||||
// Board seit der Kommentar-Überarbeitung ein normales <textarea> ist und kein
|
||||
// contenteditable mehr (document.execCommand ist abgekündigt) — gespeichert
|
||||
// wird aber weiterhin HTML, damit alle vorhandenen Anzeigepfade (Board-
|
||||
// Vorschau, Wochenbericht, PDF-Export, Show-Mode, Verlauf) unverändert
|
||||
// funktionieren und Altbestände lesbar bleiben.
|
||||
// Parameter: html (String). Rückgabe: String.
|
||||
function statusHtmlToEditText(html){
|
||||
if(!html) return '';
|
||||
const div = document.createElement('div');
|
||||
div.innerHTML = sanitizeStatusHtml(html);
|
||||
// Fett-Auszeichnungen vor dem Auflösen in Sternchen überführen
|
||||
div.querySelectorAll('b, strong').forEach(el => {
|
||||
const inner = (el.textContent || '').trim();
|
||||
el.replaceWith(document.createTextNode(inner ? '**' + inner + '**' : ''));
|
||||
});
|
||||
// Block-Grenzen zu Zeilenumbrüchen
|
||||
div.querySelectorAll('br').forEach(el => el.replaceWith(document.createTextNode('\n')));
|
||||
div.querySelectorAll('p, div').forEach(el => el.append(document.createTextNode('\n')));
|
||||
return (div.textContent || '')
|
||||
.replace(/ /g, ' ')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim();
|
||||
}
|
||||
|
||||
// statusEditTextToHtml: wandelt den Klartext aus dem Kommentar-Editor in das
|
||||
// gespeicherte HTML. Escapt zuerst vollständig (esc()), sodass eingetippte
|
||||
// spitze Klammern niemals als Markup ankommen, und setzt danach die einzige
|
||||
// erlaubte Auszeichnung **fett** sowie Zeilenumbrüche. Gegenstück zu
|
||||
// statusHtmlToEditText(). Leerer oder reiner Leerraum-Text ergibt '' — die
|
||||
// bisherige Bedeutung von "kein Kommentar" bleibt damit erhalten.
|
||||
// Parameter: text (String). Rückgabe: String (HTML).
|
||||
function statusEditTextToHtml(text){
|
||||
const clean = String(text || '').trim();
|
||||
if(clean === '') return '';
|
||||
return esc(clean)
|
||||
.replace(/\*\*([^*\n]+)\*\*/g, '<b>$1</b>')
|
||||
.replace(/\n/g, '<br>');
|
||||
}
|
||||
|
||||
// -------- Projekt-Verlauf über mehrere Sitzungen --------
|
||||
// Diese drei Helfer lagen ursprünglich in js/plan/plan-history.js und damit
|
||||
// nur auf der Projektplan-Seite. Seit der Kommentar-Editor im Board den
|
||||
// Verlauf ebenfalls anzeigt (renderBoardDrawer(), js/board/board.js), stehen
|
||||
// sie hier im gemeinsamen Modul — sonst gäbe es zwei Implementierungen
|
||||
// derselben Logik, die auseinanderlaufen können. plan-history.js greift
|
||||
// weiterhin darauf zu.
|
||||
//
|
||||
// Kein Server-Endpunkt nötig: GET /load-all liefert ohnehin ALLE Sitzungen
|
||||
// mit allen Projekten nach db.weeks. Zusammengeführt wird über projectUid
|
||||
// (die dauerhafte, wochenunabhängige Projekt-Identität aus
|
||||
// migrateAddProjectUid() in server.js) — project.id taugt dafür nicht, weil
|
||||
// cloneWeek() sie bei jeder Kopie neu vergibt.
|
||||
|
||||
// projectVersionsForUid: alle Fassungen eines Projekts über alle geladenen
|
||||
// Sitzungen, chronologisch aufsteigend (weekSortKey, also nach Kalenderwoche
|
||||
// und nicht alphabetisch). Projekte aus Altdaten ohne projectUid liefern eine
|
||||
// leere Liste — ein Fallback über nr/name wäre bei wiederverwendeten
|
||||
// Projektnummern stillschweigend falsch. Parameter: uid (String).
|
||||
// Rückgabe: Array von { week, project }.
|
||||
function projectVersionsForUid(uid){
|
||||
if(!uid) return [];
|
||||
return (db.weeks || [])
|
||||
.map(w => ({ week: w, project: (w.projects || []).find(p => p.projectUid === uid) }))
|
||||
.filter(entry => !!entry.project)
|
||||
.sort((a, b) => weekSortKey(a.week.label).localeCompare(weekSortKey(b.week.label)));
|
||||
}
|
||||
|
||||
// statusPlainText: löst einen (HTML-formatierten) Kommentartext in reinen
|
||||
// Text auf — für Vergleich und Kurzvorschau. Parameter: html (String).
|
||||
// Rückgabe: String (leer, wenn kein Text vorhanden).
|
||||
function statusPlainText(html){
|
||||
const tmp = document.createElement('div');
|
||||
tmp.innerHTML = sanitizeStatusHtml(html || '');
|
||||
// wie in statusPreviewText(): Zeilenumbrüche zu Leerzeichen, sonst würden
|
||||
// zwei Absätze beim Vergleich zu einem zusammengezogenen Wort
|
||||
tmp.querySelectorAll('br').forEach(el => el.replaceWith(document.createTextNode(' ')));
|
||||
tmp.querySelectorAll('p, div').forEach(el => el.append(document.createTextNode(' ')));
|
||||
return (tmp.textContent || '').replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
// projectStatusHistory: die Verlaufsliste eines Projekts — je Sitzung ein
|
||||
// Eintrag, aber NUR für Sitzungen, in denen sich der Kommentartext gegenüber
|
||||
// der vorherigen Fassung tatsächlich geändert hat. Ohne diese Filterung
|
||||
// stünde derselbe Text mehrfach in der Liste: Sitzungen, die vor der
|
||||
// Umstellung auf leere Kommentare (siehe cloneWeek(), js/weeks.js) dupliziert
|
||||
// wurden, tragen den Text der Vorwoche unverändert mit. Parameter: uid
|
||||
// (String). Rückgabe: Array von { week, text, html, author, at, isFirst },
|
||||
// neueste zuerst.
|
||||
function projectStatusHistory(uid){
|
||||
const versions = projectVersionsForUid(uid);
|
||||
const entries = [];
|
||||
let lastText = null;
|
||||
versions.forEach((v, idx) => {
|
||||
const text = statusPlainText(v.project.status);
|
||||
const entry = {
|
||||
week: v.week,
|
||||
text,
|
||||
html: sanitizeStatusHtml(v.project.status || ''),
|
||||
author: v.project.statusAuthor || '',
|
||||
at: v.project.statusAt || '',
|
||||
isFirst: idx === 0
|
||||
};
|
||||
if(idx === 0){
|
||||
if(text) entries.push(entry);
|
||||
} else if(text && text !== lastText){
|
||||
entries.push(entry);
|
||||
}
|
||||
lastText = text;
|
||||
});
|
||||
return entries.reverse();
|
||||
}
|
||||
|
||||
// getProgressColor: erzeugt eine HSL-Farbe basierend auf Prozentwert
|
||||
// (grün → gelb → rot). Parameter `pct`: Zahl 0–100 (wird geklemmt). Genutzt
|
||||
// für die LPH-Fortschrittsbalken im Board und auf der Projektplan-Seite.
|
||||
// Rückgabe: Farb-String für den Fortschrittsbalken.
|
||||
function getProgressColor(pct){
|
||||
const hue = Math.round(Math.max(0, Math.min(100, pct)) * 1.2);
|
||||
return `hsl(${hue},70%,40%)`;
|
||||
}
|
||||
|
||||
// ---------- Verlauf eines Projekts über die Sitzungen ----------
|
||||
//
|
||||
// Jede Sitzung hält eine eigene Fassung jedes Projekts fest — eine "Kachel"
|
||||
// je Woche. projectVersionsForUid() (weiter oben) liefert sie chronologisch.
|
||||
// Der Verlauf macht damit nur sichtbar, was ohnehin gespeichert ist.
|
||||
//
|
||||
// Diese Darstellung steht hier in js/utils.js und nicht bei einem einzelnen
|
||||
// Bereich, weil sie an DREI Stellen gebraucht wird: im Wochenbericht unter
|
||||
// der Projektkarte, im Projektboard und im Verlaufs-Dialog des
|
||||
// Projektbereichs. Vorher hatte jede dieser Stellen ihre eigene Fassung —
|
||||
// der Aufklapper "Frühere Sitzungen" zeigte nur den Kommentartext und auch
|
||||
// den nur bei tatsächlicher Änderung, der Dialog im Projektbereich zeigte
|
||||
// eine dritte Auswahl. Die drei konnten sich damit widersprechen, und
|
||||
// niemand wusste, welche die vollständige war.
|
||||
//
|
||||
// Gezeigt wird die ganze Kachel: Kommentar, Leistungsphasen, die Termine aus
|
||||
// dem Projektplan und die Beteiligten JENER Woche.
|
||||
|
||||
// timelineLphText: die Leistungsphasen-Stände einer Projektfassung als
|
||||
// kompakte Liste. Gezeigt werden nur Phasen mit einem Wert — eine Kachel mit
|
||||
// zehn leeren Zeilen trägt nichts bei. Wie im übrigen Bericht zählen die
|
||||
// beauftragten Phasen, sofern welche hinterlegt sind.
|
||||
// Parameter: p (Projektfassung). Rückgabe: Array von { lph, wert }.
|
||||
function verlaufLphWerte(p){
|
||||
const beauftragt = Array.isArray(p.beauftragteLP) ? p.beauftragteLP : [];
|
||||
const phasen = beauftragt.length > 0 ? LPHS.filter(l => beauftragt.includes(l)) : LPHS;
|
||||
const lph = p.lph || {};
|
||||
return phasen
|
||||
.filter(l => lph[l] !== undefined && lph[l] !== '')
|
||||
.map(l => ({ lph: l, wert: String(lph[l]) }));
|
||||
}
|
||||
|
||||
// verlaufTermine: die Termine aus dem Projektplan einer Fassung — das
|
||||
// späteste Datum im Plan (Projektende) und der nächste Meilenstein. Daran
|
||||
// lässt sich über die Kacheln hinweg ablesen, ob sich Termine verschoben
|
||||
// haben. Parameter: p (Projektfassung). Rückgabe: { ende, meilenstein } mit
|
||||
// Strings (leer, wenn der Plan nichts hergibt).
|
||||
function verlaufTermine(p){
|
||||
const plan = p.plan || {};
|
||||
const todos = Array.isArray(plan.todos) ? plan.todos : [];
|
||||
const milestones = Array.isArray(plan.milestones) ? plan.milestones : [];
|
||||
|
||||
const daten = [];
|
||||
todos.forEach(t => { const d = parseDateISO(t.end); if(d) daten.push(d); });
|
||||
milestones.forEach(m => { const d = parseDateISO(m.date); if(d) daten.push(d); });
|
||||
const ende = daten.length ? new Date(Math.max.apply(null, daten)) : null;
|
||||
|
||||
// Nächster Meilenstein: der früheste, der zum Zeitpunkt JENER Sitzung noch
|
||||
// bevorstand. Ohne Sitzungsdatum (Altbestand) der früheste überhaupt.
|
||||
const mitDatum = milestones
|
||||
.map(m => ({ name: m.name || 'Meilenstein', datum: parseDateISO(m.date) }))
|
||||
.filter(m => !!m.datum)
|
||||
.sort((a, b) => a.datum - b.datum);
|
||||
const naechster = mitDatum[0] || null;
|
||||
|
||||
return {
|
||||
ende: ende ? formatDateDE(ende) : '',
|
||||
meilenstein: naechster ? (naechster.name + ' · ' + formatDateDE(naechster.datum)) : '',
|
||||
};
|
||||
}
|
||||
|
||||
// verlaufBeteiligte: die Kürzel der Personen, die dieser Fassung zugeordnet
|
||||
// waren. Gelesen wird der Teilnehmerkreis JENER Sitzung, nicht der heutige —
|
||||
// sonst stünden in einer alten Kachel Personen, die damals gar nicht dabei
|
||||
// waren. Parameter: version ({week, project}). Rückgabe: Array von Kürzeln.
|
||||
function verlaufBeteiligte(version){
|
||||
const p = version.project;
|
||||
const personen = Array.isArray(version.week.people) ? version.week.people : [];
|
||||
const namen = [];
|
||||
personen.forEach(person => {
|
||||
const rollen = Array.isArray(person.role) ? person.role : [person.role];
|
||||
const zugeordnet = rollen.some(r => (p.assign || {})[assignKey(person.id, r)] > 0);
|
||||
if(zugeordnet && namen.indexOf(person.name) === -1) namen.push(person.name);
|
||||
});
|
||||
return namen;
|
||||
}
|
||||
|
||||
// renderProjektVerlauf: der aufklappbare Verlauf unter einer Projektkarte.
|
||||
// Zeigt je früherer Sitzung eine Kachel, neueste zuerst. Geänderte Werte sind
|
||||
// gegenüber der jeweils DAVOR liegenden Kachel dezent hervorgehoben — die
|
||||
// Kacheln stehen zwar nebeneinander, aber bei zehn Leistungsphasen findet man
|
||||
// die eine geänderte Zahl sonst nicht.
|
||||
// Parameter: p (Projekt), weekLabel (Sitzung, deren Fassung als "aktuell"
|
||||
// gilt und deshalb NICHT als Kachel erscheint), locked. Rückgabe: HTML.
|
||||
function renderProjektVerlauf(p, weekLabel, locked){
|
||||
const versionen = projectVersionsForUid(p.projectUid);
|
||||
// Neueste zuerst, damit links die jüngste Vergangenheit steht.
|
||||
//
|
||||
// Ohne weekLabel werden ALLE Sitzungen gezeigt: Der Verlaufs-Dialog im
|
||||
// Projektbereich hat keine "aktuelle" Fassung, die auszulassen wäre. Mit
|
||||
// weekLabel bleibt die genannte Sitzung außen vor — sie steht dort als
|
||||
// Karte bzw. Eingabemaske ohnehin darüber.
|
||||
const idx = weekLabel ? versionen.findIndex(v => v.week.label === weekLabel) : -1;
|
||||
const frueher = (weekLabel ? (idx > 0 ? versionen.slice(0, idx) : []) : versionen.slice()).reverse();
|
||||
|
||||
if(frueher.length === 0){
|
||||
return `<details class="timeline-block">
|
||||
<summary onmousedown="event.preventDefault()">
|
||||
<span class="comment-history-caret">▶</span>
|
||||
<span>Verlauf</span>
|
||||
<span class="comment-history-count zero">0</span>
|
||||
</summary>
|
||||
<div class="timeline-body"><span class="hint" style="margin:0">Dieses Projekt erscheint zum ersten Mal — es gibt noch keine frühere Sitzung.</span></div>
|
||||
</details>`;
|
||||
}
|
||||
|
||||
const kacheln = frueher.map((v, i) => {
|
||||
// Vergleichsfassung ist die zeitlich DAVOR liegende, also der nächste
|
||||
// Eintrag in dieser (absteigend sortierten) Liste.
|
||||
const davor = frueher[i + 1] || null;
|
||||
const werte = verlaufLphWerte(v.project);
|
||||
const werteDavor = davor ? verlaufLphWerte(davor.project) : [];
|
||||
const wertVon = l => { const t = werteDavor.find(x => x.lph === l); return t ? t.wert : null; };
|
||||
|
||||
const lphHtml = werte.length === 0
|
||||
? '<div class="timeline-leer">keine Angabe</div>'
|
||||
: werte.map(w => {
|
||||
const alt = wertVon(w.lph);
|
||||
const geaendert = davor && alt !== null && alt !== w.wert;
|
||||
return `<div class="timeline-lph${geaendert ? ' is-changed' : ''}">
|
||||
<span>${esc(w.lph)}</span><b>${esc(w.wert)} %</b>
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
const text = statusPlainText(v.project.status);
|
||||
const termine = verlaufTermine(v.project);
|
||||
const termineDavor = davor ? verlaufTermine(davor.project) : { ende:'', meilenstein:'' };
|
||||
const beteiligte = verlaufBeteiligte(v);
|
||||
|
||||
const zeilen = [];
|
||||
if(termine.meilenstein){
|
||||
const geaendert = davor && termineDavor.meilenstein && termineDavor.meilenstein !== termine.meilenstein;
|
||||
zeilen.push(`<div class="timeline-termin${geaendert ? ' is-changed' : ''}">↦ ${esc(termine.meilenstein)}</div>`);
|
||||
}
|
||||
if(termine.ende){
|
||||
const geaendert = davor && termineDavor.ende && termineDavor.ende !== termine.ende;
|
||||
zeilen.push(`<div class="timeline-termin${geaendert ? ' is-changed' : ''}">Ende ${esc(termine.ende)}</div>`);
|
||||
}
|
||||
|
||||
// "übernehmen" schreibt den Kommentar jener Woche ins Textfeld der
|
||||
// aktuellen Sitzung. Diese Funktion lag bis zum Verdichten der Karte im
|
||||
// Aufklapper "Frühere Sitzungen"; der ist entfallen, weil er dieselben
|
||||
// Kommentare noch einmal zeigte. onmousedown + preventDefault wie dort:
|
||||
// Sonst verliert das Textfeld zuerst den Fokus, das Blur-Netz speichert
|
||||
// den Zwischenstand, und die Übernahme liefe gegen einen bereits
|
||||
// geschriebenen Text.
|
||||
// Ohne weekLabel gibt es keine Sitzung, in die übernommen werden könnte
|
||||
// (Verlaufs-Dialog) — dann entfällt der Knopf, statt ins Leere zu greifen.
|
||||
const uebernehmen = (locked || !text || !weekLabel) ? '' :
|
||||
`<button type="button" class="timeline-take"
|
||||
onmousedown="event.preventDefault();takeCommentFromHistory(this,'${esc(v.week.label)}')"
|
||||
title="Diesen Kommentar in die aktuelle Sitzung übernehmen">übernehmen</button>`;
|
||||
|
||||
return `<article class="timeline-card">
|
||||
<header class="timeline-card-head">
|
||||
<span class="timeline-kw">${esc(v.week.label)}</span>
|
||||
<span class="timeline-datum">${esc(v.week.date ? formatWeekDate(v.week.date) : '')}</span>
|
||||
</header>
|
||||
<div class="timeline-text${text ? '' : ' timeline-leer'}" title="${esc(text)}">${text ? esc(text) : 'Kein Kommentar'}</div>
|
||||
${uebernehmen}
|
||||
<div class="timeline-lph-list">${lphHtml}</div>
|
||||
${zeilen.length ? `<div class="timeline-termine">${zeilen.join('')}</div>` : ''}
|
||||
<footer class="timeline-people">${
|
||||
beteiligte.length
|
||||
? beteiligte.map(n => `<span class="timeline-chip">${esc(n)}</span>`).join('')
|
||||
: '<span class="timeline-leer">keine Zuordnung</span>'
|
||||
}</footer>
|
||||
</article>`;
|
||||
}).join('');
|
||||
|
||||
return `<details class="timeline-block">
|
||||
<summary onmousedown="event.preventDefault()">
|
||||
<span class="comment-history-caret">▶</span>
|
||||
<span>Verlauf</span>
|
||||
<span class="comment-history-count">${frueher.length}</span>
|
||||
</summary>
|
||||
<div class="timeline-body">
|
||||
<div class="timeline-rail">${kacheln}</div>
|
||||
</div>
|
||||
</details>`;
|
||||
}
|
||||
@@ -0,0 +1,873 @@
|
||||
// -----------------------------
|
||||
// Projektübersicht — Wochenbericht (Änderungs-Digest)
|
||||
// -----------------------------
|
||||
// Tab "Wochenbericht": zeigt die Projekte der aktuell gewählten Sitzung
|
||||
// gruppiert nach dem, was sich seit der VORHERIGEN Sitzung geändert hat —
|
||||
// "Neu kommentiert" (Status-Text geändert oder Projekt neu angelegt),
|
||||
// "Fortschritt aktualisiert" (LPH-Prozentwerte oder amtierende Leistungsphase
|
||||
// geändert) und "Unverändert seit letzter Woche" (eingeklappt, wie die
|
||||
// normale Board-Zeile). Das war ursprünglich der Zweck des Projektboards
|
||||
// selbst (wöchentliche Besprechung: Status/Fortschritt je Projekt
|
||||
// festhalten) — das Board ist inzwischen eine reine Navigationsliste zum
|
||||
// Projektplan (siehe board.js), dieser Tab übernimmt die eigentliche
|
||||
// Erfassungs-Arbeit, aber fokussiert auf das, was diese Woche wirklich neu
|
||||
// ist, statt jedes Mal alle Projekte einzeln durchklicken zu müssen.
|
||||
//
|
||||
// Der Vergleich zur Vorwoche läuft über project_uid (siehe
|
||||
// migrateAddProjectUid() in server.js) — die dauerhafte, wochenunabhängige
|
||||
// Projekt-Identität, die project_id (wird bei jedem cloneWeek() neu
|
||||
// vergeben) nicht bieten konnte. Die Bearbeitung der Projekte selbst
|
||||
// (Kommentar-Editor, LPH, Beteiligte) läuft vollständig über die bestehenden
|
||||
// Funktionen aus js/board/board.js (editProj/editLph/cycleAssign/
|
||||
// renderCommentEditor/renderBoardDrawer/renderBoardRow) — dieses Modul
|
||||
// ergänzt die Gruppierung/Diff-Logik, das Layout des Berichts und die
|
||||
// Anwesenheitserfassung am Seitenfuß (renderAttendance(), weiter unten).
|
||||
// Die Anwesenheit lag früher im Projektboard; sie gehört hierher, weil in
|
||||
// diesem Tab die Sitzung abgearbeitet wird.
|
||||
//
|
||||
// Läuft wie board.js als klassisches globales Skript (kein Modul/IIFE), nur
|
||||
// von Test.html geladen (nach js/board/board.js).
|
||||
|
||||
// findPreviousWeek: liefert die chronologisch vorherige Sitzung zu `week`
|
||||
// (nach weekSortKey sortiert) oder null, wenn `week` die früheste ist.
|
||||
// Parameter: week (Woche-Objekt). Rückgabe: Woche-Objekt oder null.
|
||||
function findPreviousWeek(week){
|
||||
const sorted = [...db.weeks].sort((a,b) => weekSortKey(a.label).localeCompare(weekSortKey(b.label)));
|
||||
const idx = sorted.findIndex(w => w.id === week.id);
|
||||
return idx > 0 ? sorted[idx - 1] : null;
|
||||
}
|
||||
|
||||
// lphSignature: vergleichbarer String aus den LPH-Prozentwerten und der
|
||||
// amtierenden Leistungsphase eines Projekts, für den Fortschritts-Vergleich
|
||||
// zur Vorwoche (Reihenfolge-unabhängig durch Sortierung der Schlüssel).
|
||||
// Parameter: p (Projekt). Rückgabe: String.
|
||||
function lphSignature(p){
|
||||
const lph = p.lph || {};
|
||||
const lphPart = Object.keys(lph).sort().map(k => k + '=' + lph[k]).join(',');
|
||||
const aktLp = Array.isArray(p.aktuelleLP) ? p.aktuelleLP.slice().sort().join(',') : '';
|
||||
return lphPart + '|' + aktLp;
|
||||
}
|
||||
|
||||
// classifyProjectChange: ordnet ein Projekt der aktuellen Sitzung einer der
|
||||
// drei Bericht-Gruppen zu, anhand des Vergleichs mit demselben Projekt
|
||||
// (gleiche project_uid) in der Vorwoche. Ohne Vorwoche oder ohne Treffer in
|
||||
// ihr (z. B. gerade erst angelegtes Projekt) gilt es als neu. Parameter: p
|
||||
// (Projekt der aktuellen Sitzung), prevWeek (Vorwoche-Objekt oder null).
|
||||
// Rückgabe: { bucket: 'new'|'status'|'progress'|'unchanged', reason: String }.
|
||||
function classifyProjectChange(p, prevWeek){
|
||||
const prev = (prevWeek && p.projectUid)
|
||||
? prevWeek.projects.find(x => x.projectUid === p.projectUid)
|
||||
: null;
|
||||
|
||||
if(!prev){
|
||||
return { bucket: 'new', reason: 'Neues Projekt' };
|
||||
}
|
||||
const statusNow = (p.status || '').trim();
|
||||
const statusPrev = (prev.status || '').trim();
|
||||
// Nur ein tatsächlich vorhandener, geänderter Text zählt als neuer
|
||||
// Kommentar. Seit cloneWeek() (js/weeks.js) den Text der Vorwoche nicht
|
||||
// mehr mitkopiert, startet jede duplizierte Sitzung mit leerem Kommentar —
|
||||
// ohne diese Bedingung stünde danach jedes Projekt unter "Neu kommentiert",
|
||||
// obwohl noch niemand etwas geschrieben hat. Ein geleerter Kommentar
|
||||
// (statusNow leer, statusPrev nicht) fällt damit in die Fortschritts- bzw.
|
||||
// Unverändert-Prüfung und wird nicht als Neuigkeit gemeldet.
|
||||
if(statusNow !== '' && statusNow !== statusPrev){
|
||||
return { bucket: 'status', reason: 'Neuer Kommentar' };
|
||||
}
|
||||
// Auch ein bewusst ÜBERNOMMENER Text zählt als Kommentar dieser Sitzung:
|
||||
// Der Knopf "übernehmen" im Kommentarverlauf (js/board/board.js) schreibt
|
||||
// wortgleich den Text der Vorwoche, setzt dabei aber einen neuen
|
||||
// Zeitstempel. Ohne diese Prüfung landete das Projekt unter "Unverändert",
|
||||
// obwohl diese Woche ausdrücklich etwas dazu festgehalten wurde.
|
||||
if(statusNow !== '' && p.statusAt && p.statusAt !== (prev.statusAt || '')){
|
||||
return { bucket: 'status', reason: 'Kommentar bestätigt' };
|
||||
}
|
||||
if(lphSignature(p) !== lphSignature(prev)){
|
||||
return { bucket: 'progress', reason: 'Fortschritt aktualisiert' };
|
||||
}
|
||||
return { bucket: 'unchanged', reason: '' };
|
||||
}
|
||||
|
||||
// BERICHT_OPTS: Darstellungs-Optionen, die dieser Tab an die gemeinsamen
|
||||
// Render-Funktionen aus js/board/board.js übergibt. Im Wochenbericht werden
|
||||
// nur die beauftragten Leistungsphasen gezeigt (p.beauftragteLP) — im
|
||||
// Projektboard bleibt es bei allen. Den Verlauf der Vorwochen hängt
|
||||
// renderBoardDrawer() inzwischen überall an (renderProjektVerlauf() in
|
||||
// js/utils.js), er braucht deshalb kein eigenes Kennzeichen mehr.
|
||||
const BERICHT_OPTS = { nurBeauftragteLph: true, dicht: true };
|
||||
|
||||
// renderReportCard: baut die immer aufgeklappte Karte eines Projekts im
|
||||
// Bericht (Kopfzeile mit Nr./Name/Tags/Änderungs-Badge, darunter das
|
||||
// vollständige Detailpanel aus board.js). Parameter: p (Projekt), reason
|
||||
// (Anzeige-Text des Änderungsgrunds), allPeople, locked, weekLabel,
|
||||
// variant ('neu'|'fortschritt', Farbe des Kopfbands). Rückgabe: HTML-String.
|
||||
function renderReportCard(p, reason, allPeople, locked, weekLabel, variant){
|
||||
const tags = Array.isArray(p.tags) ? p.tags : [];
|
||||
const tagsHtml = tags.map(t => `<span class="tag-chip">${esc(t)}</span>`).join('');
|
||||
// variant ('neu' | 'fortschritt') färbt das Kopfband der Karte ein, damit
|
||||
// die Änderungsart beim Scrollen ohne Blick auf die Abschnitts-Überschrift
|
||||
// erkennbar bleibt. Rein gestalterisch — die Zuordnung selbst trifft
|
||||
// classifyProjectChange().
|
||||
const vClass = variant ? ' report-card-' + variant : '';
|
||||
return `<div class="report-card${vClass}">
|
||||
<div class="report-card-head report-band">
|
||||
<span class="board-nr">${esc(p.nr)}</span>
|
||||
<span class="board-name" onclick="openProjectPlan('${p.id}')" title="Projektplan öffnen">${esc(p.name)}</span>
|
||||
${tagsHtml}
|
||||
<span class="report-reason-badge">${esc(reason)}</span>
|
||||
</div>
|
||||
${renderBoardDrawer(p, allPeople, locked, weekLabel, BERICHT_OPTS)}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// renderReportSection: baut einen Abschnitt des Berichts (Überschrift mit
|
||||
// Anzahl, Liste der zugehörigen Projekte). `expanded` steuert, ob die volle
|
||||
// Karte (renderReportCard) oder die normale einklappbare Board-Zeile
|
||||
// (renderBoardRow) genutzt wird. Parameter: title, entries (Array
|
||||
// {p, reason}), allPeople, locked, expanded (Boolean), emptyText. Rückgabe:
|
||||
// HTML-String.
|
||||
function renderReportSection(title, entries, allPeople, locked, expanded, emptyText, weekLabel, variant){
|
||||
let html = `<div class="section-title">${esc(title)} (${entries.length})</div>`;
|
||||
if(entries.length === 0){
|
||||
html += `<div class="hint">${esc(emptyText)}</div>`;
|
||||
return html;
|
||||
}
|
||||
html += `<div class="${expanded ? 'report-list' : 'board-list'}">`;
|
||||
html += entries.map(({p, reason}) => expanded
|
||||
? renderReportCard(p, reason, allPeople, locked, weekLabel, variant)
|
||||
: renderBoardRow(p, allPeople, locked, weekLabel, BERICHT_OPTS)
|
||||
).join('');
|
||||
html += `</div>`;
|
||||
return html;
|
||||
}
|
||||
|
||||
// ---------- Anwesenheit der Sitzung ----------
|
||||
// Die Anwesenheit hing ursprünglich als Zweitfunktion an der Kürzel-Legende
|
||||
// unter dem Projektboard ("klick für Anwesenheit") — Namenserklärung und
|
||||
// Eingabemaske am selben Element, und die Legende gibt es inzwischen gar
|
||||
// nicht mehr. Sie steht jetzt am Fuß des Wochenberichts, also dort, wo die
|
||||
// Sitzung tatsächlich abgearbeitet wird, listet den vollständigen
|
||||
// Sitzungs-Kreis (currentWeek().people) und zeigt einen Zähler. Der Datenstand bleibt unverändert binär
|
||||
// (w.attendance[personId] = true/false, Tabelle attendance) — es gibt
|
||||
// bewusst kein "entschuldigt"/"nicht erfasst".
|
||||
//
|
||||
// Urlaub und Krankheit aus user_absences werden nur als Hinweis am Namen
|
||||
// angezeigt und setzen den Haken NICHT automatisch: wer im Urlaub ist, kann
|
||||
// sich trotzdem zuschalten, und die Anwesenheit soll festhalten, wer
|
||||
// tatsächlich dabei war.
|
||||
|
||||
// absenceNoteFor: liefert den Abwesenheitsgrund einer Person am Sitzungstag
|
||||
// (aus db.globalPeople, der aktuellen Liste aus users/user_absences) oder ''.
|
||||
// Parameter: personId (String), weekDate (ISO-Datum der Sitzung).
|
||||
// Rückgabe: String.
|
||||
function absenceNoteFor(personId, weekDate){
|
||||
if(!weekDate) return '';
|
||||
const person = (db.globalPeople || []).find(gp => gp.id === personId);
|
||||
if(!person || !Array.isArray(person.absences)) return '';
|
||||
const day = parseDateISO(weekDate);
|
||||
if(!day) return '';
|
||||
const hit = person.absences.find(a => {
|
||||
const from = parseDateISO(a.start), to = parseDateISO(a.end);
|
||||
return from && to && day >= from && day <= to;
|
||||
});
|
||||
return hit ? (hit.type || 'Abwesend') : '';
|
||||
}
|
||||
|
||||
// renderAttendance: baut den Anwesenheitsblock der aktuellen Sitzung.
|
||||
// Aufgerufen aus renderBoard(). Keine Parameter, keine Rückgabe.
|
||||
function renderAttendance(){
|
||||
const container = document.getElementById('weekAttendance');
|
||||
if(!container) return;
|
||||
const w = currentWeek();
|
||||
if(!w){ container.innerHTML = ''; return; }
|
||||
|
||||
const locked = isWeekLocked(w);
|
||||
const att = w.attendance || {};
|
||||
const people = Array.isArray(w.people) ? w.people : [];
|
||||
|
||||
if(people.length === 0){
|
||||
container.innerHTML = `<div class="attendance-block"><div class="attendance-bar">
|
||||
<span class="attendance-title">Anwesenheit</span></div>
|
||||
<div class="attendance-grid"><span class="hint">Für diese Sitzung sind keine Projektbeteiligten hinterlegt.</span></div></div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
const anwesend = people.filter(p => att[p.id]).length;
|
||||
// onmousedown/preventDefault wie bei den Kacheln unten: Ohne das verliert
|
||||
// ein offener Kommentar-Editor beim Klick den Fokus, sein Blur-Netz baut den
|
||||
// ganzen Bericht neu auf, die Schaltfläche verschwindet dabei — und der
|
||||
// Klick kommt nie an (erst der zweite Versuch wirkte).
|
||||
const actions = locked ? '' : `
|
||||
<button type="button" class="secondary small" onmousedown="event.preventDefault()" onclick="setAllAttendance(true)">Alle anwesend</button>
|
||||
<button type="button" class="secondary small" onmousedown="event.preventDefault()" onclick="setAllAttendance(false)">Zurücksetzen</button>`;
|
||||
|
||||
const tiles = people.map(p => {
|
||||
const present = !!att[p.id];
|
||||
const note = absenceNoteFor(p.id, w.date);
|
||||
// onmousedown/preventDefault verhindert nur den Fokuswechsel, nicht den
|
||||
// Klick: sonst verliert ein offener Kommentar-Editor den Fokus, sein
|
||||
// Blur-Netz baut den ganzen Bericht neu auf, die Kachel wird dabei
|
||||
// ersetzt — und der Klick käme nie an.
|
||||
const click = locked ? '' : ` onmousedown="event.preventDefault()" onclick="toggleAttendance('${p.id}')"`;
|
||||
const title = locked
|
||||
? `${esc(p.fullName || p.name)} — Sitzung gesperrt`
|
||||
: `${esc(p.fullName || p.name)} — klicken, um ${present ? 'als abwesend' : 'als anwesend'} zu markieren`;
|
||||
return `<button type="button" class="attendance-tile${present ? ' present' : ''}${locked ? ' locked-cell' : ''}"
|
||||
aria-pressed="${present}" title="${title}"${click}>
|
||||
<span class="attendance-mark">${present ? '✓' : ''}</span>
|
||||
<span class="attendance-av">${esc(p.name)}</span>
|
||||
<span class="attendance-name">${esc(p.fullName || p.name)}</span>
|
||||
${note ? `<span class="attendance-note">${esc(note)}</span>` : ''}
|
||||
</button>`;
|
||||
}).join('');
|
||||
|
||||
container.innerHTML = `<div class="attendance-block">
|
||||
<div class="attendance-bar">
|
||||
<span class="attendance-title">Anwesenheit</span>
|
||||
<span class="attendance-count">${anwesend} von ${people.length} anwesend</span>
|
||||
<span class="attendance-spacer"></span>
|
||||
${actions}
|
||||
</div>
|
||||
<div class="attendance-grid">${tiles}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// toggleAttendance: schaltet die Anwesenheit einer Person für die aktuelle
|
||||
// Sitzung um (Boolean in w.attendance, Tabelle attendance).
|
||||
// Parameter: personId (stabile Personen-ID). Keine Rückgabe; bei gesperrter
|
||||
// Sitzung über guardWeekLocked() abgebrochen.
|
||||
function toggleAttendance(personId){
|
||||
if(guardWeekLocked()) return;
|
||||
const w = currentWeek();
|
||||
if(!w) return;
|
||||
if(!w.attendance) w.attendance = {};
|
||||
w.attendance[personId] = !w.attendance[personId];
|
||||
save();
|
||||
// Bewusst nur der Anwesenheitsblock, nicht renderWochenbericht(): ein
|
||||
// vollständiger Neuaufbau würde die Berichtskarten samt eines gerade
|
||||
// offenen Kommentar-Editors verwerfen.
|
||||
renderAttendance();
|
||||
}
|
||||
|
||||
// setAllAttendance: setzt alle Beteiligten der Sitzung auf an- bzw. abwesend —
|
||||
// für den Normalfall "alle da, bis auf zwei". Parameter: present (Boolean).
|
||||
// Keine Rückgabe.
|
||||
function setAllAttendance(present){
|
||||
if(guardWeekLocked()) return;
|
||||
const w = currentWeek();
|
||||
if(!w) return;
|
||||
if(!w.attendance) w.attendance = {};
|
||||
(w.people || []).forEach(p => { w.attendance[p.id] = !!present; });
|
||||
save();
|
||||
renderAttendance();
|
||||
}
|
||||
|
||||
// renderWochenbericht: baut den kompletten Wochenbericht-Tab für die eine
|
||||
// offene Sitzung auf (currentWeek(), js/weeks.js) — Hinweis auf die Vorwoche,
|
||||
// dann die drei Gruppen. Wird nach jedem Laden sowie nach jeder Änderung
|
||||
// über editLph()/cycleAssign() (js/board/board.js) neu aufgerufen, damit ein
|
||||
// Projekt sofort in die passende Gruppe wandert. Keine Parameter, keine
|
||||
// Rückgabe.
|
||||
// ---------- Tagesordnung: Zustand ----------
|
||||
|
||||
// berichtAuswahl: project_uid des Projekts, das gerade rechts im Detail
|
||||
// steht. Bewusst die wochenunabhängige uid und nicht der Index: Beim
|
||||
// Speichern eines Kommentars wird die ganze Liste neu gezeichnet, und ein
|
||||
// Index würde bei geänderter Reihenfolge auf ein anderes Projekt zeigen.
|
||||
let berichtAuswahl = null;
|
||||
|
||||
// berichtSuche: Inhalt des Sprung-Feldes über der Liste. Es FILTERT die
|
||||
// Tagesordnung nicht dauerhaft — im Meeting muss die Liste vollständig
|
||||
// bleiben — sondern hebt Treffer hervor und blendet den Rest nur aus,
|
||||
// solange etwas eingetippt ist.
|
||||
let berichtSuche = '';
|
||||
|
||||
// wochenberichtGewerk: das Gewerk, dessen Besprechung gerade läuft
|
||||
// ('ELT'|'HLS'|'ARCH') — oder null, bevor die Vorauswahl gelaufen ist. Die
|
||||
// verschiedenen Gewerke halten ihre Wochenbesprechung zu unterschiedlichen
|
||||
// Zeitpunkten und jede braucht dabei nur die eigenen Projekte. Bewusst NICHT
|
||||
// in localStorage: die Wahl gilt nur für diesen Seitenaufruf, damit sie beim
|
||||
// nächsten Login erneut aus dem eigenen Gewerk hergeleitet wird statt
|
||||
// möglicherweise für die falsche Besprechung stehenzubleiben.
|
||||
let wochenberichtGewerk = null;
|
||||
|
||||
// wochenberichtGewerkVoreinstellen: setzt wochenberichtGewerk beim ersten
|
||||
// Aufruf des Wochenberichts auf das eigene Gewerk des angemeldeten Kontos
|
||||
// (currentUser.department, js/auth.js) — meistens ist genau das die
|
||||
// Besprechung, die man gerade braucht, ohne jedes Mal manuell wählen zu
|
||||
// müssen. Nur eine gültige Vorauswahl (ELT/HLS/ARCH) wird übernommen; ohne
|
||||
// gepflegtes Gewerk (z. B. das Bootstrap-Admin-Konto) bleibt
|
||||
// wochenberichtGewerk null und das Dropdown in renderBerichtKopf() zeigt die
|
||||
// Aufforderung, eins zu wählen. Keine Parameter, keine Rückgabe.
|
||||
function wochenberichtGewerkVoreinstellen(){
|
||||
if(wochenberichtGewerk) return;
|
||||
const eigenes = currentUser && currentUser.department;
|
||||
if(eigenes && GEWERK_LABELS[eigenes]) wochenberichtGewerk = eigenes;
|
||||
}
|
||||
|
||||
// berichtProjekte: die Projekte der Sitzung in der Reihenfolge, in der sie
|
||||
// abgearbeitet werden — nach Projektgruppe (GROUP_ORDER) und darin nach der
|
||||
// gepflegten Reihenfolge, wie im Projektboard. Ist ein Gewerk gewählt
|
||||
// (wochenberichtGewerk), werden nur dessen eigene Projekte aufgenommen
|
||||
// (p.gewerke, siehe js/projekt-wizard.js) — die Vollständigkeit gilt dann
|
||||
// innerhalb dieses Gewerks, nicht mehr über alle Gewerke hinweg: die
|
||||
// gemeinsame, ungefilterte Übersicht bleibt die Projektliste (Tab
|
||||
// "Projekte"), unverändert von dieser Auswahl. Rückgabe: Array von Projekten.
|
||||
function berichtProjekte(w){
|
||||
const projekte = wochenberichtGewerk
|
||||
? (w.projects || []).filter(p => Array.isArray(p.gewerke) && p.gewerke.includes(wochenberichtGewerk))
|
||||
: (w.projects || []);
|
||||
const nachGruppe = {};
|
||||
projekte.forEach(p => (nachGruppe[p.group] = nachGruppe[p.group] || []).push(p));
|
||||
const gruppen = GROUP_ORDER.filter(g => nachGruppe[g])
|
||||
.concat(Object.keys(nachGruppe).filter(g => !GROUP_ORDER.includes(g)));
|
||||
const out = [];
|
||||
gruppen.forEach(g => {
|
||||
nachGruppe[g].sort((a, b) => (a.order || 0) - (b.order || 0));
|
||||
nachGruppe[g].forEach(p => out.push(p));
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
// waehleWochenberichtGewerk: legt das Gewerk fest (Dropdown im Kopf,
|
||||
// renderBerichtKopf()) und zeichnet den Wochenbericht neu — jetzt gefiltert
|
||||
// auf dieses Gewerk. Parameter: g ('ELT'|'HLS'|'ARCH'|''). Keine Rückgabe.
|
||||
function waehleWochenberichtGewerk(g){
|
||||
wochenberichtGewerk = g || null;
|
||||
berichtAuswahl = null; // die bisherige Detailauswahl kann zum anderen Gewerk gehören
|
||||
renderWochenbericht();
|
||||
}
|
||||
|
||||
// renderGewerkDropdown: das Umschaltfeld für wochenberichtGewerk — ein
|
||||
// gewöhnliches Dropdown statt eines bildschirmfüllenden Dialogs, da der
|
||||
// Wechsel meist nur eine kurze Korrektur der Vorauswahl ist (siehe
|
||||
// wochenberichtGewerkVoreinstellen()), kein eigener Arbeitsschritt. Ohne
|
||||
// Auswahl steht ein deaktivierter Platzhalter-Eintrag oben. Keine Parameter.
|
||||
// Rückgabe: HTML-String.
|
||||
function renderGewerkDropdown(){
|
||||
const optionen = Object.keys(GEWERK_LABELS).map(g =>
|
||||
`<option value="${esc(g)}" ${wochenberichtGewerk === g ? 'selected' : ''}>${esc(g)} — ${esc(GEWERK_LABELS[g])}</option>`
|
||||
).join('');
|
||||
const platzhalter = wochenberichtGewerk ? '' : `<option value="" selected disabled>Gewerk wählen…</option>`;
|
||||
return `<select class="bericht-gewerk-select" onchange="waehleWochenberichtGewerk(this.value)" aria-label="Gewerk">
|
||||
${platzhalter}${optionen}
|
||||
</select>`;
|
||||
}
|
||||
|
||||
// aktuellesBerichtProjekt: das Projekt hinter berichtAuswahl, mit Rückfall
|
||||
// auf das erste der Tagesordnung. Rückgabe: Projekt oder null.
|
||||
function aktuellesBerichtProjekt(w){
|
||||
const liste = berichtProjekte(w);
|
||||
if(liste.length === 0) return null;
|
||||
return liste.find(p => p.projectUid && p.projectUid === berichtAuswahl) || liste[0];
|
||||
}
|
||||
|
||||
// waehleBerichtProjekt: schaltet das Detail auf ein Projekt um.
|
||||
// Parameter: uid (project_uid). Keine Rückgabe.
|
||||
function waehleBerichtProjekt(uid){
|
||||
berichtAuswahl = uid;
|
||||
renderWochenbericht();
|
||||
const detail = document.getElementById('berichtDetail');
|
||||
if(detail) detail.scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
|
||||
// ---------- Besprochen-Haken ----------
|
||||
|
||||
// toggleBesprochen: hakt ein Projekt als besprochen ab bzw. nimmt den Haken
|
||||
// zurück. Der Haken gehört zur Sitzung (Spalte projects.besprochen) und geht
|
||||
// deshalb durch den normalen Speicherweg. Parameter: pid. Keine Rückgabe.
|
||||
function toggleBesprochen(pid){
|
||||
if(guardWeekLocked()) return;
|
||||
const w = currentWeek();
|
||||
const p = (w.projects || []).find(x => x.id === pid);
|
||||
if(!p) return;
|
||||
p.besprochen = !p.besprochen;
|
||||
save();
|
||||
renderWochenbericht();
|
||||
}
|
||||
|
||||
// besprochenUndWeiter: hakt das aktuelle Projekt ab und springt zum nächsten,
|
||||
// das noch offen ist. Der Motor des Meetings — ohne ihn sucht man nach jedem
|
||||
// Projekt von Hand die nächste Zeile. Parameter: pid. Keine Rückgabe.
|
||||
function besprochenUndWeiter(pid){
|
||||
if(guardWeekLocked()) return;
|
||||
const w = currentWeek();
|
||||
const p = (w.projects || []).find(x => x.id === pid);
|
||||
if(!p) return;
|
||||
p.besprochen = true;
|
||||
save();
|
||||
|
||||
const liste = berichtProjekte(w);
|
||||
const idx = liste.findIndex(x => x.id === pid);
|
||||
// Erst hinter der aktuellen Stelle suchen, dann von vorn — so landet man
|
||||
// auch dann beim nächsten offenen Punkt, wenn man mittendrin eingestiegen ist.
|
||||
const offen = liste.slice(idx + 1).find(x => !x.besprochen)
|
||||
|| liste.find(x => !x.besprochen);
|
||||
berichtAuswahl = offen ? offen.projectUid : p.projectUid;
|
||||
renderWochenbericht();
|
||||
}
|
||||
|
||||
// setzeAlleBesprochen: setzt alle Haken der Sitzung zurück bzw. hakt alles ab.
|
||||
// Parameter: wert (Boolean). Keine Rückgabe.
|
||||
function setzeAlleBesprochen(wert){
|
||||
if(guardWeekLocked()) return;
|
||||
const w = currentWeek();
|
||||
(w.projects || []).forEach(p => { p.besprochen = !!wert; });
|
||||
save();
|
||||
renderWochenbericht();
|
||||
}
|
||||
|
||||
// ---------- Abwesenheit ----------
|
||||
|
||||
// abwesendeBeteiligte: die einem Projekt zugeordneten Personen, die heute
|
||||
// fehlen. "Heute" ist der Anwesenheitsstand dieser Sitzung (w.attendance) —
|
||||
// er wird am Fuß des Berichts ohnehin erfasst. Punkte, deren Zuständige
|
||||
// fehlen, vertagt man im Meeting besser.
|
||||
// Parameter: p (Projekt), w (Woche). Rückgabe: Array von Personennamen.
|
||||
function abwesendeBeteiligte(p, w){
|
||||
const att = w.attendance || {};
|
||||
// Solange niemand als anwesend markiert ist, ist die Anwesenheit schlicht
|
||||
// noch nicht erfasst — dann wäre "alle fehlen" eine Falschmeldung.
|
||||
if(!Object.values(att).some(v => v)) return [];
|
||||
const namen = [];
|
||||
(w.people || []).forEach(person => {
|
||||
const rollen = Array.isArray(person.role) ? person.role : [person.role];
|
||||
const zugeordnet = rollen.some(r => (p.assign || {})[assignKey(person.id, r)] > 0);
|
||||
if(zugeordnet && !att[person.id] && namen.indexOf(person.name) === -1) namen.push(person.name);
|
||||
});
|
||||
return namen;
|
||||
}
|
||||
|
||||
// ---------- Sprung-Feld ----------
|
||||
|
||||
// setzeBerichtSuche: übernimmt die Eingabe des Sprung-Feldes. Parameter: wert.
|
||||
function setzeBerichtSuche(wert){
|
||||
berichtSuche = String(wert || '').trim().toLowerCase();
|
||||
renderBerichtListe();
|
||||
}
|
||||
|
||||
// passtZurSuche: prüft ein Projekt gegen das Sprung-Feld (Nummer oder Name).
|
||||
// Rückgabe: Boolean (immer true bei leerer Eingabe).
|
||||
function passtZurSuche(p){
|
||||
if(!berichtSuche) return true;
|
||||
return ((p.nr || '') + ' ' + (p.name || '')).toLowerCase().includes(berichtSuche);
|
||||
}
|
||||
|
||||
// ---------- Rendern ----------
|
||||
|
||||
// renderWochenbericht: die Tagesordnung des Teammeetings — links die
|
||||
// vollständige Projektliste, rechts das gewählte Projekt in voller Größe.
|
||||
//
|
||||
// Diese Aufteilung hat die frühere Abfolge gestapelter Karten abgelöst: Bei
|
||||
// 29 Projekten war der Bericht 18 Bildschirmseiten lang, und man verlor beim
|
||||
// Durchgehen die Orientierung. Jetzt bleibt die Liste stehen, man sieht
|
||||
// jederzeit, wo man steht und was noch kommt, und scrollt nicht mehr.
|
||||
//
|
||||
// Die Liste ist nach PROJEKTGRUPPE geordnet (wie im Projektboard), nicht mehr
|
||||
// nach Änderungsart: Im Meeting wird jedes Projekt besprochen, ob sich etwas
|
||||
// geändert hat oder nicht — nach Änderung sortiert müsste man zwischen
|
||||
// Abschnitten hin- und herspringen. Was sich geändert hat, steht weiterhin an
|
||||
// jedem Projekt (classifyProjectChange()), nur eben als Kennzeichen statt als
|
||||
// Sortierkriterium. Keine Parameter, keine Rückgabe.
|
||||
function renderWochenbericht(){
|
||||
const w = currentWeek();
|
||||
const container = document.getElementById('wochenberichtContent');
|
||||
if(!w || !container) return;
|
||||
|
||||
// Vorauswahl aus dem eigenen Gewerk des Kontos (einmal je Seitenaufruf).
|
||||
// Bleibt sie aus (Konto ohne gepflegtes Gewerk), zeigt das Dropdown selbst
|
||||
// die Aufforderung — kein bildschirmfüllender Dialog dafür.
|
||||
wochenberichtGewerkVoreinstellen();
|
||||
if(!wochenberichtGewerk){
|
||||
container.innerHTML = `<div class="hint">Bitte ein Gewerk wählen: ${renderGewerkDropdown()}</div>`;
|
||||
const attEl = document.getElementById('weekAttendance');
|
||||
if(attEl) attEl.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
const locked = isWeekLocked(w);
|
||||
const prevWeek = findPreviousWeek(w);
|
||||
const liste = berichtProjekte(w);
|
||||
const aktiv = aktuellesBerichtProjekt(w);
|
||||
|
||||
let html = '';
|
||||
if(locked){
|
||||
html += `<div class="lock-notice">
|
||||
Diese Sitzung ist gesperrt. Bearbeiten nur nach Entsperren möglich.
|
||||
<button type="button" class="secondary small" onclick="unlockCurrentWeek()">Entsperren</button>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
html += renderBerichtKopf(w, liste, prevWeek, locked);
|
||||
|
||||
if(liste.length === 0){
|
||||
html += `<div class="hint">In dieser Sitzung ist noch kein Projekt angelegt.</div>`;
|
||||
container.innerHTML = html;
|
||||
renderAttendance();
|
||||
return;
|
||||
}
|
||||
|
||||
html += `<div class="bericht-split">
|
||||
<div class="bericht-liste-spalte">
|
||||
<div class="bericht-suche">
|
||||
<input type="search" id="berichtSuche" placeholder="Nummer oder Name…"
|
||||
value="${esc(berichtSuche)}" oninput="setzeBerichtSuche(this.value)"
|
||||
aria-label="Zu einem Projekt springen">
|
||||
</div>
|
||||
<div class="bericht-liste" id="berichtListe"></div>
|
||||
</div>
|
||||
<div class="bericht-detail" id="berichtDetail">${renderBerichtDetail(aktiv, w, prevWeek, locked)}</div>
|
||||
</div>`;
|
||||
|
||||
container.innerHTML = html;
|
||||
renderBerichtListe();
|
||||
renderAttendance();
|
||||
}
|
||||
|
||||
// renderBerichtKopf: Vergleichs-Hinweis und Fortschrittsanzeige.
|
||||
// Parameter: w, liste, prevWeek, locked. Rückgabe: HTML.
|
||||
function renderBerichtKopf(w, liste, prevWeek, locked){
|
||||
const ab = liste.filter(p => p.besprochen).length;
|
||||
const pct = liste.length ? Math.round(ab / liste.length * 100) : 0;
|
||||
const vergleich = prevWeek
|
||||
? `Vergleich mit ${esc(prevWeek.label)}${prevWeek.date ? ' (' + esc(formatWeekDate(prevWeek.date)) + ')' : ''}.`
|
||||
: 'Dies ist die früheste vorhandene Sitzung — kein Vergleich zur Vorwoche möglich.';
|
||||
|
||||
return `<div class="bericht-kopf">
|
||||
${renderGewerkDropdown()}
|
||||
<span class="hint" style="margin:0">${vergleich}</span>
|
||||
<span class="bericht-fortschritt">
|
||||
<span class="bericht-fortschritt-text">${ab} von ${liste.length} ${esc(wochenberichtGewerk)}-Projekten besprochen</span>
|
||||
<span class="bericht-fortschritt-bar"><i style="width:${pct}%"></i></span>
|
||||
${locked ? '' : (ab > 0
|
||||
? `<button type="button" class="secondary small" onclick="setzeAlleBesprochen(false)">Haken zurücksetzen</button>`
|
||||
: `<button type="button" class="secondary small" onclick="setzeAlleBesprochen(true)">Alle abhaken</button>`)}
|
||||
</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// renderBerichtListe: die Tagesordnung links. Eigene Funktion, weil das
|
||||
// Sprung-Feld sie allein neu zeichnet — das Detail rechts und vor allem ein
|
||||
// gerade getippter Kommentar dürfen dabei nicht verlorengehen.
|
||||
// Keine Parameter, keine Rückgabe.
|
||||
function renderBerichtListe(){
|
||||
const el = document.getElementById('berichtListe');
|
||||
const w = currentWeek();
|
||||
if(!el || !w) return;
|
||||
const locked = isWeekLocked(w);
|
||||
const prevWeek = findPreviousWeek(w);
|
||||
const aktiv = aktuellesBerichtProjekt(w);
|
||||
const liste = berichtProjekte(w).filter(passtZurSuche);
|
||||
|
||||
if(liste.length === 0){
|
||||
el.innerHTML = `<div class="hint" style="padding:10px">Kein Projekt passt zu „${esc(berichtSuche)}".</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
let letzteGruppe = null;
|
||||
el.innerHTML = liste.map(p => {
|
||||
const info = classifyProjectChange(p, prevWeek);
|
||||
const fehlen = abwesendeBeteiligte(p, w);
|
||||
const kopf = p.group !== letzteGruppe
|
||||
? (letzteGruppe = p.group, `<div class="bericht-gruppe">${esc(p.group || 'Ohne Gruppe')}</div>`)
|
||||
: '';
|
||||
const istAktiv = aktiv && aktiv.id === p.id;
|
||||
return kopf + `<div class="bericht-zeile${istAktiv ? ' is-aktiv' : ''}${p.besprochen ? ' is-ab' : ''}"
|
||||
role="button" tabindex="0" data-uid="${esc(p.projectUid || '')}" data-pid="${esc(p.id)}"
|
||||
aria-current="${istAktiv ? 'true' : 'false'}">
|
||||
<button type="button" class="bericht-haken${p.besprochen ? ' ab' : ''}"
|
||||
${locked ? 'disabled' : `onclick="event.stopPropagation();toggleBesprochen('${esc(p.id)}')"`}
|
||||
title="${p.besprochen ? 'Haken zurücknehmen' : 'Als besprochen markieren'}"
|
||||
aria-label="${esc(p.name)} als besprochen markieren">✓</button>
|
||||
<span class="bericht-zeile-nr">${esc(p.nr || '—')}</span>
|
||||
<span class="bericht-zeile-name">${esc(p.name)}</span>
|
||||
${typeof auftragsstatusChip === 'function' ? auftragsstatusChip(p) : ''}
|
||||
${fehlen.length ? `<span class="bericht-abwesend" title="Fehlt heute: ${esc(fehlen.join(', '))}">${esc(fehlen.length)}✗</span>` : ''}
|
||||
${info.bucket === 'unchanged' ? '' : `<span class="bericht-punkt is-${esc(info.bucket)}" title="${esc(info.reason)}"></span>`}
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
el.querySelectorAll('.bericht-zeile').forEach(z => {
|
||||
const waehlen = () => waehleBerichtProjekt(z.dataset.uid || null);
|
||||
z.addEventListener('click', e => { if(!e.target.closest('.bericht-haken')) waehlen(); });
|
||||
z.addEventListener('keydown', e => {
|
||||
if(e.key === 'Enter' || e.key === ' '){ e.preventDefault(); waehlen(); }
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// renderBerichtDetail: das gewählte Projekt rechts — Kopfzeile, der Kommentar
|
||||
// der Vorwoche, die vollständige Bearbeitungsmaske (renderBoardDrawer() aus
|
||||
// js/board/board.js, dieselbe wie zuvor) und die Fußzeile mit
|
||||
// "Besprochen & weiter". Parameter: p, w, prevWeek, locked. Rückgabe: HTML.
|
||||
function renderBerichtDetail(p, w, prevWeek, locked){
|
||||
if(!p) return '';
|
||||
const grouped = peopleByRole(w);
|
||||
const allPeople = [];
|
||||
ROLES.forEach(r => grouped[r].forEach(entry => allPeople.push(entry)));
|
||||
|
||||
const info = classifyProjectChange(p, prevWeek);
|
||||
const tags = Array.isArray(p.tags) ? p.tags : [];
|
||||
const fehlen = abwesendeBeteiligte(p, w);
|
||||
const liste = berichtProjekte(w);
|
||||
const nr = liste.findIndex(x => x.id === p.id) + 1;
|
||||
|
||||
return `<div class="bericht-detail-kopf">
|
||||
<span class="bericht-detail-zaehler">${nr} / ${liste.length}</span>
|
||||
<span class="bericht-zeile-nr">${esc(p.nr || '—')}</span>
|
||||
<h3>${esc(p.name)}</h3>
|
||||
${typeof auftragsstatusChip === 'function' ? auftragsstatusChip(p) : ''}
|
||||
${tags.map(t => `<span class="tag-chip">${esc(t)}</span>`).join('')}
|
||||
${info.bucket === 'unchanged' ? '' : `<span class="bericht-kennz is-${esc(info.bucket)}">${esc(info.reason)}</span>`}
|
||||
</div>
|
||||
<div class="bericht-detail-meta">${esc(p.group || 'Ohne Gruppe')}</div>
|
||||
${fehlen.length ? `<div class="bericht-abwesend-hinweis">Fehlt heute: ${esc(fehlen.join(', '))} — eventuell vertagen.</div>` : ''}
|
||||
${renderVorwochenzeile(p, prevWeek)}
|
||||
${renderBoardDrawer(p, allPeople, locked, w.label, BERICHT_OPTS)}
|
||||
${locked ? '' : `<div class="bericht-detail-fuss">
|
||||
<button type="button" class="secondary small" onclick="openTodoAusKommentar('${esc(p.id)}')">
|
||||
Aufgabe aus Kommentar…
|
||||
</button>
|
||||
<span class="bericht-fuss-spacer"></span>
|
||||
<button type="button" onclick="besprochenUndWeiter('${esc(p.id)}')">Besprochen & weiter →</button>
|
||||
</div>`}`;
|
||||
}
|
||||
|
||||
// renderVorwochenzeile: der Kommentar der Vorwoche als eine Zeile ÜBER dem
|
||||
// Eingabefeld. Die häufigste Frage im Meeting ist "was hatten wir letzte
|
||||
// Woche vereinbart?" — der Verlauf beantwortet sie zwar, aber erst nach dem
|
||||
// Aufklappen. Parameter: p, prevWeek. Rückgabe: HTML (leer ohne Vorwoche).
|
||||
function renderVorwochenzeile(p, prevWeek){
|
||||
if(!prevWeek || !p.projectUid) return '';
|
||||
const vor = (prevWeek.projects || []).find(x => x.projectUid === p.projectUid);
|
||||
if(!vor) return '';
|
||||
const text = statusPlainText(vor.status);
|
||||
if(!text) return '';
|
||||
return `<div class="bericht-vorwoche">
|
||||
<span class="bericht-vorwoche-label">${esc(prevWeek.label)}</span>
|
||||
<span class="bericht-vorwoche-text">${esc(text)}</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ---------- Aufgabe aus dem Kommentar ----------
|
||||
//
|
||||
// Beschlüsse fallen im Meeting und landen bisher als Fließtext im Kommentar —
|
||||
// im Terminplan und auf "Meine Aufgaben" tauchen sie nie auf. Dieser Dialog
|
||||
// macht aus einem Satz eine echte Aufgabe im Plan des Projekts
|
||||
// (p.plan.todos), also denselben Datensatz, den der Projektbereich anlegt.
|
||||
|
||||
// todoAusKommentarPid: Projekt, für das der Dialog gerade offen ist.
|
||||
let todoAusKommentarPid = null;
|
||||
|
||||
// openTodoAusKommentar: öffnet den Dialog. Als Titel wird die erste Zeile des
|
||||
// Kommentars vorgeschlagen — meistens steht dort der Beschluss.
|
||||
// Parameter: pid. Keine Rückgabe.
|
||||
function openTodoAusKommentar(pid){
|
||||
if(guardWeekLocked()) return;
|
||||
const w = currentWeek();
|
||||
const p = (w.projects || []).find(x => x.id === pid);
|
||||
if(!p) return;
|
||||
todoAusKommentarPid = pid;
|
||||
|
||||
// Vorschlag aus dem gerade im Feld stehenden Text, nicht aus dem
|
||||
// gespeicherten: Im Meeting tippt man den Beschluss und legt sofort die
|
||||
// Aufgabe an, ohne vorher zu speichern.
|
||||
const feld = document.querySelector('#wochenbericht .comment-field');
|
||||
const text = feld ? feld.value : statusPlainText(p.status);
|
||||
const ersteZeile = String(text || '').split('\n').map(z => z.replace(/^[-•*\s]+/, '').trim()).find(z => z) || '';
|
||||
|
||||
document.getElementById('todoKommentarTitel').value = ersteZeile.slice(0, 120);
|
||||
document.getElementById('todoKommentarProjekt').textContent = (p.nr ? p.nr + ' · ' : '') + p.name;
|
||||
|
||||
// Zuständige: der Teilnehmerkreis dieser Sitzung, die dem Projekt
|
||||
// zugeordneten Personen vorausgewählt.
|
||||
const zugeordnet = new Set();
|
||||
(w.people || []).forEach(person => {
|
||||
const rollen = Array.isArray(person.role) ? person.role : [person.role];
|
||||
if(rollen.some(r => (p.assign || {})[assignKey(person.id, r)] > 0)) zugeordnet.add(person.id);
|
||||
});
|
||||
document.getElementById('todoKommentarPersonen').innerHTML = (w.people || []).map(person =>
|
||||
`<label class="person-chip-label"><input type="checkbox" value="${esc(person.id)}"
|
||||
${zugeordnet.has(person.id) ? 'checked' : ''}>
|
||||
<span class="person-chip">${esc(person.name)}</span></label>`).join('')
|
||||
|| '<span class="hint" style="margin:0">Niemand im Teilnehmerkreis.</span>';
|
||||
|
||||
// Termin: standardmäßig die kommende Woche — der übliche Horizont bis zur
|
||||
// nächsten Sitzung.
|
||||
const inEinerWoche = addDays(new Date(), 7);
|
||||
document.getElementById('todoKommentarEnde').value = formatDateISO(inEinerWoche);
|
||||
document.getElementById('todoKommentarFehler').hidden = true;
|
||||
document.getElementById('todoKommentarModal').style.display = 'flex';
|
||||
document.getElementById('todoKommentarTitel').focus();
|
||||
}
|
||||
|
||||
function closeTodoAusKommentar(){
|
||||
document.getElementById('todoKommentarModal').style.display = 'none';
|
||||
todoAusKommentarPid = null;
|
||||
}
|
||||
|
||||
// saveTodoAusKommentar: legt die Aufgabe im Plan des Projekts an. Sie ist
|
||||
// danach eine ganz normale plan_todo — im Terminplan sichtbar, auf "Meine
|
||||
// Aufgaben" des Zuständigen, im GANTT verschiebbar. Keine Parameter.
|
||||
function saveTodoAusKommentar(){
|
||||
const w = currentWeek();
|
||||
const p = (w.projects || []).find(x => x.id === todoAusKommentarPid);
|
||||
const fehler = document.getElementById('todoKommentarFehler');
|
||||
if(!p) return;
|
||||
|
||||
const titel = document.getElementById('todoKommentarTitel').value.trim();
|
||||
if(!titel){
|
||||
fehler.textContent = 'Bitte eine Bezeichnung für die Aufgabe eingeben.';
|
||||
fehler.hidden = false;
|
||||
return;
|
||||
}
|
||||
const ende = document.getElementById('todoKommentarEnde').value;
|
||||
const personen = Array.from(document.querySelectorAll('#todoKommentarPersonen input:checked')).map(i => i.value);
|
||||
|
||||
if(!p.plan) p.plan = { start:'', end:'', todos:[], milestones:[], people:[], progress:0 };
|
||||
if(!Array.isArray(p.plan.todos)) p.plan.todos = [];
|
||||
p.plan.todos.push({
|
||||
id: uid(),
|
||||
title: titel,
|
||||
// Start ist heute: Im Meeting beschlossen heißt ab sofort.
|
||||
start: formatDateISO(new Date()),
|
||||
end: ende || formatDateISO(new Date()),
|
||||
done: false,
|
||||
progress: 0,
|
||||
dependsOn: [],
|
||||
assignees: personen,
|
||||
});
|
||||
|
||||
save();
|
||||
closeTodoAusKommentar();
|
||||
renderWochenbericht();
|
||||
}
|
||||
|
||||
// ---------- Protokoll ----------
|
||||
|
||||
// openBerichtProtokoll: stellt zusammen, was in dieser Sitzung gegenüber der
|
||||
// vorherigen anders ist, und zeigt es als fertigen Text zum Kopieren oder
|
||||
// Verschicken. Wer nicht dabei war, musste sich das bisher selbst
|
||||
// zusammensuchen — die Daten liegen längst vor, es fehlte nur die
|
||||
// Zusammenstellung. Keine Parameter, keine Rückgabe.
|
||||
function openBerichtProtokoll(){
|
||||
const w = currentWeek();
|
||||
if(!w) return;
|
||||
const prevWeek = findPreviousWeek(w);
|
||||
const zeilen = [];
|
||||
|
||||
zeilen.push('Wochenbericht ' + w.label + (w.date ? ' (' + formatWeekDate(w.date) + ')' : ''));
|
||||
if(prevWeek) zeilen.push('Vergleich mit ' + prevWeek.label);
|
||||
zeilen.push('');
|
||||
|
||||
const anwesend = (w.people || []).filter(x => (w.attendance || {})[x.id]).map(x => x.fullName || x.name);
|
||||
const fehlend = (w.people || []).filter(x => !(w.attendance || {})[x.id]).map(x => x.fullName || x.name);
|
||||
if(anwesend.length){
|
||||
zeilen.push('Anwesend: ' + anwesend.join(', '));
|
||||
if(fehlend.length) zeilen.push('Entschuldigt: ' + fehlend.join(', '));
|
||||
zeilen.push('');
|
||||
}
|
||||
|
||||
let anzahl = 0;
|
||||
berichtProjekte(w).forEach(p => {
|
||||
const info = classifyProjectChange(p, prevWeek);
|
||||
if(info.bucket === 'unchanged') return; // nur, was sich geändert hat
|
||||
anzahl++;
|
||||
zeilen.push('— ' + (p.nr ? p.nr + ' ' : '') + p.name + ' [' + info.reason + ']');
|
||||
const text = statusPlainText(p.status);
|
||||
if(text) zeilen.push(' ' + text);
|
||||
// Aufgaben, die in dieser Sitzung entstanden sind, gehören ins Protokoll:
|
||||
// Sie sind das Ergebnis der Besprechung.
|
||||
const heute = formatDateISO(new Date());
|
||||
((p.plan && p.plan.todos) || []).filter(t => t.start === heute).forEach(t => {
|
||||
const wer = (t.assignees || []).map(id => {
|
||||
const person = (w.people || []).find(x => x.id === id);
|
||||
return person ? person.name : id;
|
||||
}).join(', ');
|
||||
zeilen.push(' → Aufgabe: ' + t.title + (wer ? ' (' + wer + ')' : '') + (t.end ? ' bis ' + formatDateDE(parseDateISO(t.end)) : ''));
|
||||
});
|
||||
zeilen.push('');
|
||||
});
|
||||
|
||||
if(anzahl === 0) zeilen.push('Keine Änderungen gegenüber der vorherigen Sitzung.');
|
||||
|
||||
const text = zeilen.join('\n');
|
||||
document.getElementById('protokollText').value = text;
|
||||
document.getElementById('protokollAnzahl').textContent =
|
||||
anzahl === 1 ? '1 Projekt mit Änderungen' : anzahl + ' Projekte mit Änderungen';
|
||||
document.getElementById('protokollModal').style.display = 'flex';
|
||||
}
|
||||
|
||||
function closeBerichtProtokoll(){
|
||||
document.getElementById('protokollModal').style.display = 'none';
|
||||
}
|
||||
|
||||
// kopiereProtokoll: legt den Text in die Zwischenablage. Rückfall auf die
|
||||
// Auswahl im Textfeld, falls die Zwischenablage nicht verfügbar ist (ältere
|
||||
// Browser, unsichere Verbindung). Keine Parameter, keine Rückgabe.
|
||||
function kopiereProtokoll(){
|
||||
const feld = document.getElementById('protokollText');
|
||||
const melde = document.getElementById('protokollHinweis');
|
||||
const fertig = () => { melde.textContent = 'In die Zwischenablage kopiert.'; setTimeout(() => { melde.textContent = ''; }, 2500); };
|
||||
if(navigator.clipboard && navigator.clipboard.writeText){
|
||||
navigator.clipboard.writeText(feld.value).then(fertig).catch(() => { feld.select(); });
|
||||
} else {
|
||||
feld.select();
|
||||
melde.textContent = 'Text ist markiert — mit Strg+C kopieren.';
|
||||
}
|
||||
}
|
||||
|
||||
// protokollAlsMail: öffnet den Mailer mit dem Protokoll im Rumpf. mailto
|
||||
// verträgt keine beliebig langen Texte; bei sehr langen Protokollen weist die
|
||||
// Funktion darauf hin, statt einen abgeschnittenen Entwurf zu öffnen.
|
||||
function protokollAlsMail(){
|
||||
const w = currentWeek();
|
||||
const text = document.getElementById('protokollText').value;
|
||||
const link = 'mailto:?subject=' + encodeURIComponent('Wochenbericht ' + (w ? w.label : ''))
|
||||
+ '&body=' + encodeURIComponent(text);
|
||||
if(link.length > 1800){
|
||||
document.getElementById('protokollHinweis').textContent =
|
||||
'Das Protokoll ist für einen Mail-Link zu lang — bitte kopieren und einfügen.';
|
||||
return;
|
||||
}
|
||||
window.location.href = link;
|
||||
}
|
||||
|
||||
// ---------- Tastatur ----------
|
||||
//
|
||||
// Wer im Meeting mitschreibt, hat keine Hand für die Maus frei. ↓/↑ wechseln
|
||||
// das Projekt, Strg+↵ speichert den Kommentar und geht weiter. Die Tasten
|
||||
// greifen nur im Wochenbericht und nicht, während in einem Eingabefeld
|
||||
// getippt wird — mit einer Ausnahme: Strg+↵ im Kommentarfeld ist genau der
|
||||
// Fall, für den es gedacht ist.
|
||||
document.addEventListener('keydown', function(e){
|
||||
if(typeof currentTab === 'undefined' || currentTab !== 'wochenbericht') return;
|
||||
const w = currentWeek();
|
||||
if(!w) return;
|
||||
const imFeld = e.target && e.target.matches && e.target.matches('input, textarea, select');
|
||||
|
||||
if((e.key === 'Enter') && (e.ctrlKey || e.metaKey)){
|
||||
const feld = e.target && e.target.closest ? e.target.closest('.comment-field') : null;
|
||||
const aktiv = aktuellesBerichtProjekt(w);
|
||||
if(!aktiv) return;
|
||||
e.preventDefault();
|
||||
// Der Kommentar speichert sich über den bestehenden Weg selbst; hier
|
||||
// folgt nur der Sprung zum nächsten offenen Punkt.
|
||||
if(feld && typeof saveBoardComment === 'function'){
|
||||
const knopf = feld.closest('.comment-block').querySelector('.comment-editor-foot button');
|
||||
if(knopf) saveBoardComment(knopf);
|
||||
}
|
||||
besprochenUndWeiter(aktiv.id);
|
||||
return;
|
||||
}
|
||||
|
||||
if(imFeld) return;
|
||||
if(e.key !== 'ArrowDown' && e.key !== 'ArrowUp') return;
|
||||
const liste = berichtProjekte(w).filter(passtZurSuche);
|
||||
if(liste.length === 0) return;
|
||||
const aktiv = aktuellesBerichtProjekt(w);
|
||||
const idx = liste.findIndex(p => aktiv && p.id === aktiv.id);
|
||||
const ziel = Math.max(0, Math.min(liste.length - 1, (idx < 0 ? 0 : idx) + (e.key === 'ArrowDown' ? 1 : -1)));
|
||||
e.preventDefault();
|
||||
waehleBerichtProjekt(liste[ziel].projectUid || null);
|
||||
});
|
||||
Reference in new issue
Block a user