Files
projektuebersicht/public/js/plan/plan-settings.js
erikandClaude Opus 5.5 a2266662ad Dashboard-Reihenfolge, Drei-Punkte-Menü, Mehrrollen-Kürzel, Login-Rechte
- Meine Aufgaben: Projekte in der Reihenfolge der Projektliste, je Konto
  per Ziehen umsortierbar (users.dashboard_reihenfolge)
- Projektliste: Öffnen/Löschen hinter einem "⋯"-Menü
- Avatare je Person statt je Rolle; mehrere Rollen als senkrechte
  Farbstreifen; Titelleiste zieht Rollenänderungen sofort nach
- Nach dem Anmelden vollständiges Konto über /whoami laden (Seitenleiste
  zeigte sonst erst nach Neuladen alle Einträge)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 15:21:29 +01:00

551 lines
28 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// -----------------------------
// 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, #planAssignMatrix)
//
// 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.
//
// Die Beteiligten-Matrix war früher ein reines Anzeigefeld, gefüllt von
// plan-info.js, mit einem Verweis in den Wochenbericht — dort entstand die
// Zuordnung. Auf Eriks ausdrücklichen Wunsch ("In den Einstellungen der
// Projekte will ich auch die Projektbeteiligten einstellen können") ist das
// umgekehrt: Zuordnung ist jetzt ein Stammdatum wie Honorar, und zwar NUR
// HIER editierbar — der Wochenbericht zeigt sie seitdem nur noch zum Lesen
// (board.js, renderBoardDrawer()) mit einem Verweis hierher. planCycleAssign()
// unten ersetzt für diesen Zweck cycleAssign() (js/board/board.js): jene
// Funktion sucht das Projekt über currentWeek() und fände es bei einem
// Tiefenlink auf eine andere Sitzung oder einem Planung-Sandbox-Plan dort
// nicht — dieselbe Falle, vor der planOpenAufwand() unten warnt.
//
// Gespeichert wird über den normalen Weg app.savePlan() (die Felder sind Teil
// des Wochen-JSON, Tabellen projects.honorar/project_assign 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.
// HOAI-Bezeichnungen der Leistungsphasen für die Zeilen im Abschnitt
// "Leistungsphasen" (nur Anzeige).
const LPH_BEZEICHNUNG = {
'LPH 1': 'Grundlagenermittlung', 'LPH 2': 'Vorplanung', 'LPH 3': 'Entwurfsplanung',
'LPH 4': 'Genehmigungsplanung', 'LPH 5': 'Ausführungsplanung', 'LPH 6': 'Vorbereitung der Vergabe',
'LPH 7': 'Mitwirkung bei der Vergabe', 'LPH 8': 'Objektüberwachung', 'LPH 9': 'Objektbetreuung'
};
// Gewählter Abschnitt der Einstellungen (Allgemein/Auftrag/Phasen/Team).
let einstTeil = 'allgemein';
window.zeigeEinstTeil = function(teil){
einstTeil = teil;
document.querySelectorAll('#planSection-einstellungen .plan-einst-reiter').forEach(b => {
const an = b.dataset.teil === teil;
b.classList.toggle('is-active', an);
b.setAttribute('aria-current', an ? 'true' : 'false');
});
document.querySelectorAll('#planSection-einstellungen .plan-einst-teil').forEach(t => {
t.hidden = t.dataset.teil !== teil;
});
};
// einstTeamPersonen: alle Personen, die im Abschnitt "Beteiligte" vorkommen
// können — jede Person der Sitzung mit mindestens einer Fachrolle
// (peopleByRole()), je mit den Rollen, die sie hat. Dazu, wer dem Projekt
// zugeordnet ist, obwohl sein Konto die Rolle inzwischen nicht mehr trägt —
// sonst wäre so jemand unsichtbar und ließe sich nicht mehr entfernen.
// Rückgabe: Array {person, rollen:Set}.
function einstTeamPersonen(){
const grouped = peopleByRole(app.week);
const map = new Map();
ROLES.forEach(r => (grouped[r] || []).forEach(entry => {
if(!map.has(entry.person.id)) map.set(entry.person.id, { person: entry.person, rollen: new Set() });
map.get(entry.person.id).rollen.add(r);
}));
const p = app.project;
const leute = (app.week && app.week.people) || [];
Object.keys((p && p.assign) || {}).forEach(key => {
if(!(p.assign[key] > 0)) return;
const [id, r] = key.split('|');
if(map.has(id)) { map.get(id).rollen.add(r); return; }
const person = leute.find(x => x.id === id);
if(person) map.set(id, { person, rollen: new Set([r]) });
});
return [...map.values()];
}
const einstName = person => person.fullName || person.name || '';
const einstNachname = person => einstName(person).split(' ').slice(-1)[0];
const einstBeteiligt = (p, id) => ROLES.some(r => (p.assign[assignKey(id, r)] || 0) > 0);
function einstGewerkRang(p){
const reihenfolge = Object.keys(DEPARTMENTS);
const projGewerke = reihenfolge.filter(g => (p.gewerke || []).indexOf(g) !== -1);
return { projGewerke, rang: g => g === '' ? 99 : (projGewerke.indexOf(g) !== -1 ? projGewerke.indexOf(g) : 10 + reihenfolge.indexOf(g)) };
}
// Zustand des Abschnitts "Beteiligte": zuletzt hinzugefügte Person (kurz
// hervorgehoben) und die zuletzt entfernte samt ihrer Zuordnung, damit
// "Rückgängig" sie wiederherstellen kann.
let einstNeu = '';
let einstEntfernt = null;
let einstToastTimer = null;
// renderEinstTeam: Besetzungsleiste, Sperrhinweis, Suchfeld und die
// Personenliste nach Gewerk (Eriks Wahl "B"). Keine Rückgabe.
function renderEinstTeam(p, locked){
if(!p.assign) p.assign = {};
const personen = einstTeamPersonen();
const { projGewerke, rang } = einstGewerkRang(p);
const sperre = document.getElementById('planEinstSperre');
if(sperre) sperre.hidden = !locked;
const sucheWrap = document.getElementById('planEinstSucheWrap');
if(sucheWrap) sucheWrap.hidden = locked;
// Besetzungsleiste: je Rolle, wer die Hauptverantwortung trägt.
const besetzung = document.getElementById('planEinstBesetzung');
if(besetzung){
besetzung.innerHTML = ROLES.map(r => {
const haupt = personen.filter(e => p.assign[assignKey(e.person.id, r)] === 1);
const neben = personen.filter(e => p.assign[assignKey(e.person.id, r)] === 2);
const fehlt = haupt.length === 0;
const text = fehlt
? (neben.length ? 'nur Unterstützung, Haupt fehlt' : 'niemand zugeordnet')
: haupt.map(e => einstNachname(e.person)).join(', ') + (neben.length ? ' + ' + neben.length + ' Unterst.' : '');
return `<div class="plan-einst-besetzt${fehlt ? ' ist-luecke' : ''}"><b>${esc(r)}</b><span>${esc(text)}</span></div>`;
}).join('');
}
const el = document.getElementById('planAssignMatrix');
if(!el) return;
const drin = personen.filter(e => einstBeteiligt(p, e.person.id));
if(!drin.length){
el.innerHTML = personen.length
? `<div class="plan-einst-leer"><b>Noch niemand zugeordnet</b><span>Über das Suchfeld Personen hinzufügen. Vorgeschlagen werden zuerst die Mitarbeiter aus den Gewerken des Projekts.</span></div>`
: '<div class="plan-einst-leer"><b>Niemand verfügbar</b><span>In dieser Sitzung ist niemand mit einer Fachrolle hinterlegt (Admin → Benutzerverwaltung).</span></div>';
} else {
const gruppen = new Map();
drin.forEach(e => {
const g = personGewerk(e.person) || '';
if(!gruppen.has(g)) gruppen.set(g, []);
gruppen.get(g).push(e);
});
el.innerHTML = [...gruppen.keys()].sort((x, y) => rang(x) - rang(y)).map(g => {
const liste = gruppen.get(g).sort((a, b) => einstName(a.person).localeCompare(einstName(b.person), 'de'));
const titel = g === '' ? 'Ohne Gewerk' : ((typeof GEWERK_LABELS === 'object' && GEWERK_LABELS[g]) || '')
+ (projGewerke.indexOf(g) === -1 ? ', kein Gewerk des Projekts' : '');
return `<div class="plan-einst-gruppe">
${g ? `<span class="plan-gewerk-schild">${esc(g)}</span>` : ''}
<span class="plan-gewerk-titel">${esc(titel)}</span>
<span class="plan-gewerk-linie"></span>
<span class="plan-gewerk-anzahl">${liste.length === 1 ? '1 Person' : liste.length + ' Personen'}</span>
</div>` + liste.map(e => {
const id = e.person.id;
const name = einstName(e.person);
const kapseln = ROLES.map(r => {
const hat = e.rollen.has(r);
const st = p.assign[assignKey(id, r)] || 0;
const art = st === 1 ? 'ist-haupt' : (st === 2 ? 'ist-neben' : '');
const titel = !hat ? `${name} hat die Rolle ${r} nicht` : `${r}: ${st === 1 ? 'Hauptverantwortung' : st === 2 ? 'Unterstützung' : 'nicht beteiligt'}`;
return `<button type="button" class="plan-einst-kapsel ${art}${hat ? '' : ' ist-fremd'}" aria-pressed="${st > 0}"
${!hat || locked ? 'disabled' : ''} title="${esc(titel)}"
onclick="planCycleAssign('${esc(id)}','${esc(r)}')">${esc(r)}</button>`;
}).join('');
return `<div class="plan-einst-zeile${einstNeu === id ? ' ist-neu' : ''}">
<span class="plan-einst-person"><span class="plan-einst-ava">${esc(e.person.name)}</span><b>${esc(name)}</b></span>
<span class="plan-einst-kapseln" role="group" aria-label="Rollen von ${esc(name)}">${kapseln}</span>
${locked ? '' : `<button type="button" class="plan-einst-weg" title="Aus dem Projekt entfernen" aria-label="${esc(name)} aus dem Projekt entfernen" onclick="einstEntfernen('${esc(id)}')">×</button>`}
</div>`;
}).join('');
}).join('');
}
const toast = document.getElementById('planEinstToast');
if(toast){
toast.hidden = !einstEntfernt || locked;
toast.innerHTML = einstEntfernt
? `<span>${esc(einstEntfernt.name)} aus dem Projekt entfernt</span><button type="button" onclick="einstRueckgaengig()">Rückgängig</button>` : '';
}
}
// einstNachAenderung: speichern und alles nachziehen, was die Zuordnung
// zeigt (dieselben Wege wie planCycleAssign()).
function einstNachAenderung(){
app.savePlan();
renderPlanSettings();
if(typeof renderBoard === 'function') renderBoard();
if(typeof renderWochenbericht === 'function') renderWochenbericht();
}
// einstEntfernen: nimmt eine Person mit allen Rollen aus dem Projekt und
// merkt sich die Zuordnung für "Rückgängig" (8 Sekunden).
window.einstEntfernen = function(personId){
const p = app.project;
if(!p || app.stammGesperrt()) return;
const eintrag = {};
ROLES.forEach(r => {
const key = assignKey(personId, r);
if(p.assign[key]) { eintrag[key] = p.assign[key]; delete p.assign[key]; }
});
const e = einstTeamPersonen().find(x => x.person.id === personId);
const person = e ? e.person : ((app.week.people || []).find(x => x.id === personId) || { name: personId });
einstEntfernt = { name: einstName(person), eintrag };
einstNeu = '';
clearTimeout(einstToastTimer);
einstToastTimer = setTimeout(() => { einstEntfernt = null; const t = document.getElementById('planEinstToast'); if(t) t.hidden = true; }, 8000);
einstNachAenderung();
};
window.einstRueckgaengig = function(){
const p = app.project;
if(!p || !einstEntfernt || app.stammGesperrt()) return;
Object.assign(p.assign, einstEntfernt.eintrag);
const key = Object.keys(einstEntfernt.eintrag)[0];
einstNeu = key ? key.split('|')[0] : '';
einstEntfernt = null;
clearTimeout(einstToastTimer);
einstNachAenderung();
};
// Suchfeld "Person hinzufügen": Vorschläge aus allen Personen mit
// Fachrolle, die noch nicht beteiligt sind — erst die Gewerke des
// Projekts, dann die übrigen. Gerendert wird nur die Vorschlagsliste,
// damit das Eingabefeld beim Tippen seinen Fokus behält.
function einstVorschlaege(){
const p = app.project;
const feld = document.getElementById('planEinstSuche');
const s = (feld ? feld.value : '').trim().toLowerCase();
const { projGewerke } = einstGewerkRang(p);
const frei = einstTeamPersonen()
.filter(e => !einstBeteiligt(p, e.person.id))
.filter(e => !s || einstName(e.person).toLowerCase().indexOf(s) !== -1 || String(e.person.name || '').toLowerCase().indexOf(s) !== -1)
.sort((a, b) => einstName(a.person).localeCompare(einstName(b.person), 'de'));
const eigen = frei.filter(e => projGewerke.indexOf(personGewerk(e.person)) !== -1);
const andere = frei.filter(e => projGewerke.indexOf(personGewerk(e.person)) === -1);
return [['Aus den Gewerken des Projekts', eigen], ['Andere Gewerke', andere]].filter(([, l]) => l.length);
}
window.einstSucheZeigen = function(){
const box = document.getElementById('planEinstVorschlaege');
if(!box || !app.project || app.stammGesperrt()) return;
const gruppen = einstVorschlaege();
if(!gruppen.length){
const feld = document.getElementById('planEinstSuche');
box.innerHTML = feld && feld.value.trim() ? '<div class="plan-einst-vorschlag-leer">Niemand gefunden.</div>' : '';
box.hidden = !box.innerHTML;
return;
}
box.innerHTML = gruppen.map(([titel, liste]) => `<div class="plan-einst-vorschlag-titel">${esc(titel)}</div>`
+ liste.map(e => `<button type="button" class="plan-einst-vorschlag" onmousedown="event.preventDefault()"
onclick="einstHinzufuegen('${esc(e.person.id)}')">
<span class="plan-einst-ava">${esc(e.person.name)}</span>
<span><b>${esc(einstName(e.person))}</b><span>${esc([personGewerk(e.person), ROLES.filter(r => e.rollen.has(r)).join(', ')].filter(Boolean).join(', '))}</span></span>
</button>`).join('')).join('');
box.hidden = false;
};
window.einstSucheSchliessen = function(){
setTimeout(() => { const box = document.getElementById('planEinstVorschlaege'); if(box) box.hidden = true; }, 120);
};
// Enter übernimmt den ersten Vorschlag, Esc schließt die Liste.
window.einstSucheTaste = function(event){
if(event.key === 'Escape'){ const box = document.getElementById('planEinstVorschlaege'); if(box) box.hidden = true; return; }
if(event.key !== 'Enter') return;
event.preventDefault();
const erste = einstVorschlaege()[0];
if(erste && erste[1][0]) window.einstHinzufuegen(erste[1][0].person.id);
};
// einstHinzufuegen: nimmt eine Person mit ihrer ersten Fachrolle als
// Hauptverantwortung auf; weitere Rollen setzt man über die Kapseln.
window.einstHinzufuegen = function(personId){
const p = app.project;
if(!p || app.stammGesperrt()) return;
const e = einstTeamPersonen().find(x => x.person.id === personId);
if(!e) return;
const rolle = ROLES.find(r => e.rollen.has(r));
if(!rolle) return;
if(!p.assign) p.assign = {};
p.assign[assignKey(personId, rolle)] = 1;
einstNeu = personId;
einstEntfernt = null;
const feld = document.getElementById('planEinstSuche');
if(feld) feld.value = '';
const box = document.getElementById('planEinstVorschlaege');
if(box) box.hidden = true;
einstNachAenderung();
};
// renderEinstUebersicht: die Kurzfassung unter jedem Abschnitt der
// Unternavigation und welcher Abschnitt sichtbar ist.
function renderEinstUebersicht(){
const p = app.project;
if(!p) return;
const sandbox = app.isSandboxPlan();
const setze = (id, text) => { const el = document.getElementById(id); if(el) el.textContent = text; };
const gewerke = Array.isArray(p.gewerke) ? p.gewerke : [];
const amtierend = Array.isArray(p.aktuelleLP) && p.aktuelleLP[0] ? p.aktuelleLP[0] : '';
setze('einstSumAllgemein', sandbox ? [p.name || '', amtierend].filter(Boolean).join(' · ')
: [p.nr || 'ohne Nummer', gewerke.join(', ') || 'kein Gewerk', amtierend].filter(Boolean).join(' · '));
const honorar = Number(p.honorar) || 0;
const honorarText = honorar > 0 ? honorar.toLocaleString('de-DE') + ' €' : 'kein Honorar';
const status = (typeof AUFTRAGSSTATUS !== 'undefined' && typeof auftragsstatusOf === 'function')
? (AUFTRAGSSTATUS.find(s => s.key === auftragsstatusOf(p)) || {}).label : '';
setze('einstSumAuftrag', sandbox ? honorarText : [status, honorarText].filter(Boolean).join(' · '));
const beauftragt = Array.isArray(p.beauftragteLP) ? p.beauftragteLP.length : 0;
const laeuft = Array.isArray(p.aktuelleLP) && p.aktuelleLP[0] ? ' · läuft: ' + p.aktuelleLP[0] : '';
setze('einstSumPhasen', `${beauftragt} von ${LPHS.length} beauftragt${laeuft}`);
const ids = new Set(Object.keys(p.assign || {}).filter(k => p.assign[k] > 0).map(k => k.split('|')[0]));
const unbesetzt = gewerke.filter(g => !einstTeamPersonen().some(e => ids.has(e.person.id) && personGewerk(e.person) === g));
setze('einstSumTeam', (ids.size === 1 ? '1 Person' : ids.size + ' Personen') + (unbesetzt.length ? ' · ' + unbesetzt.join(', ') + ' unbesetzt' : ''));
window.zeigeEinstTeil(einstTeil);
}
window.renderPlanSettings = function(){
const p = app.project;
if(!p) return;
// Kürzel in der Titelleiste mitziehen — sonst zeigte sie eine geänderte
// Rolle erst nach dem Neuladen (renderPlanPage() baut sie nur beim Öffnen).
const kopfTeam = document.getElementById('planPeopleList');
if(kopfTeam) kopfTeam.innerHTML = app.renderProjectAssignees();
const locked = app.stammGesperrt();
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) — 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']
.forEach(id => {
const el = document.getElementById(id);
const row = el && el.closest('.form-row');
if(row) row.style.display = sandbox ? 'none' : '';
});
['settingsProjNrHinweis'].forEach(id => {
const el = document.getElementById(id);
if(el) el.hidden = sandbox;
});
// 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;
}
// 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 : []);
// Als Umschalt-Knöpfe (Einstellungen "C"): angetippt = gesetzt.
gewerkeEl.innerHTML = Object.keys(GEWERK_LABELS).map(g => {
const an = gesetzt.has(g);
return `<button type="button" class="plan-einst-chip${an ? ' an' : ''}" aria-pressed="${an}" title="${esc(GEWERK_LABELS[g])}"
${locked ? 'disabled' : ''} onclick="togglePlanGewerk('${esc(g)}', ${!an})">${esc(g)}</button>`;
}).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 });
}
// 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: je Phase eine Zeile mit Umschalter
// "Beauftragt | Nicht beauftragt", die laufende Phase trägt "läuft".
// Gespeichert über toggleBeauftragteLp() (plan-honorar.js).
const lpEl = document.getElementById('settingsBeauftragteLp');
if(lpEl){
const gesetzt = new Set(Array.isArray(p.beauftragteLP) ? p.beauftragteLP : []);
const laeuft = Array.isArray(p.aktuelleLP) ? p.aktuelleLP[0] : '';
lpEl.innerHTML = LPHS.map(l => {
const an = gesetzt.has(l);
return `<div class="plan-einst-phase${an ? ' ist-an' : ''}">
<span class="plan-einst-phase-name"><b>${esc(l)}</b><span>${esc(LPH_BEZEICHNUNG[l] || '')}</span>${l === laeuft ? '<em>läuft</em>' : ''}</span>
<span class="plan-einst-seg" role="group" aria-label="${esc(l)}">
<button type="button" class="${an ? 'an' : ''}" ${locked ? 'disabled' : ''} onclick="toggleBeauftragteLp('${esc(l)}', true)">Beauftragt</button>
<button type="button" class="${an ? '' : 'an'}" ${locked ? 'disabled' : ''} onclick="toggleBeauftragteLp('${esc(l)}', false)">Nicht beauftragt</button>
</span>
</div>`;
}).join('');
}
// Beteiligte — Stammdatum, hier editierbar (siehe Kopfkommentar). Nicht
// zugeordnete Personen erscheinen blass, damit erkennbar bleibt, wer zur
// Auswahl stünde; ein Klick schaltet den Zustand durch (planCycleAssign()).
const assignMatrix = document.getElementById('planAssignMatrix');
if(assignMatrix) renderEinstTeam(p, locked);
renderEinstUebersicht();
};
// planCycleAssign: wechselt die Zuordnung einer Person zu DIESEM Projekt
// zyklisch durch 0=keine → 1=Haupt → 2=optional → 0 (dieselbe Zustandsfolge
// wie cycleAssign() in js/board/board.js, hier aber auf app.project statt
// currentWeek() — siehe Kopfkommentar). Parameter: personId, role. Keine
// Rückgabe.
window.planCycleAssign = function(personId, role){
const p = app.project;
if(!p || app.stammGesperrt()) return;
if(!p.assign) p.assign = {};
const key = assignKey(personId, role);
const cur = p.assign[key] || 0;
const next = (cur + 1) % 3;
if(next === 0) delete p.assign[key]; else p.assign[key] = next;
app.savePlan();
renderPlanSettings();
// Dieselbe Zuordnung erscheint im Projektboard und im Wochenbericht
// (dort jetzt nur noch zum Lesen) — beide sollen sofort nachziehen,
// nicht erst beim nächsten vollständigen Neuladen.
if(typeof renderBoard === 'function') renderBoard();
if(typeof renderWochenbericht === 'function') renderWochenbericht();
};
// 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.stammGesperrt()) 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.stammGesperrt()) 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();
};
// 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.stammGesperrt()) 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.stammGesperrt() || !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.stammGesperrt()) 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();
};
})();