// ----------------------------- // Projektübersicht — Board (Listenansicht) // ----------------------------- // Diese Datei rendert das Projektboard (Tab "Projektboard" in `Test.html`) als // schlanke, aufklappbare Liste statt der früheren breiten Matrix (eine Spalte // je Person/Leistungsphase): jede Zeile zeigt auf einen Blick Kürzel-Avatare // der zugeordneten Personen, die aktuelle Leistungsphase samt Prozentwert und // eine einzeilige Status-Vorschau; ein Klick auf die Zeile klappt darunter das // Detailpanel auf (voller Status-Editor, vollständige Rollen-Zuordnung, alle // LPH-Balken). Sie wird von `Test.html` nach `js/weeks.js` (vor // `js/people/people.js`) geladen und stellt globale Funktionen wie // `renderBoard()`, `toggleBoardRow()`, `cycleAssign()`, // und `editLph()` bereit. Sie nutzt `currentWeek()`/ // `isWeekLocked()` aus `js/weeks.js`, `lpActiveList()` aus `js/load/load.js`, // `assignKey()`, `statusFocus()`, `sanitizeStatusHtml()` und // `getProgressColor()` aus `js/utils.js` sowie `openProjectPlan()` aus // `js/gantt.js` — navigiert bewusst im selben Tab (kein window.open() mehr), // ein Klick auf den Rest der Zeile klappt stattdessen nur das Detailpanel // auf/zu (toggleBoardRow()). editLph()/cycleAssign() rufen zusätzlich // renderWochenbericht() (js/wochenbericht/wochenbericht.js) auf, da dessen // Karten dieselben renderBoardDrawer()-Bedienelemente nutzen, aber in einem // eigenen DOM-Container (#wochenberichtContent) liegen. // boardRowOpen: UI-Zustand, welche Projekt-Zeile ihr Detailpanel aufgeklappt // darstellt (Key = Projekt-ID, Wert = boolean). Reine Anzeige-Steuerung, lebt // außerhalb von renderBoard() und übersteht daher dessen Neuaufbauten (z. B. // nach einer Zuordnungs- oder Prozent-Änderung im offenen Panel). Nicht // persistiert — jede Sitzung startet mit eingeklappten Zeilen. let boardRowOpen = {}; // toggleBoardRow: klappt das Detailpanel einer Projekt-Zeile auf bzw. zu. // Parameter: pid (Projekt-ID). Keine Rückgabe. function toggleBoardRow(pid){ boardRowOpen[pid] = !boardRowOpen[pid]; renderBoard(); // Der Wochenbericht rendert dieselben Projektzeilen in einem eigenen // Container. Ohne diesen Aufruf merkte er sich den Klick zwar, zeigte die // Zeile aber erst nach einem Tab-Wechsel auf- bzw. zugeklappt. if(typeof renderWochenbericht === 'function') renderWochenbericht(); } // statusPreviewText: liefert eine einzeilige Klartext-Vorschau eines // Status-HTML-Werts (Tags entfernt, überflüssiger Leerraum/Zeilenumbrüche zu // einem Leerzeichen zusammengefasst) für die eingeklappte Zeile — die volle // Formatierung bleibt dem aufgeklappten Editor vorbehalten. Parameter: html // (String). Rückgabe: String. function statusPreviewText(html){ if(!html) return ''; const tmp = document.createElement('div'); // Bereinigen, BEVOR der Wert ins DOM geht: Auch ein nicht eingehängtes // Element lädt Ressourcen und führt deren Fehler-Handler aus, ungefiltertes // innerHTML wäre hier also ein Einfallstor. tmp.innerHTML = sanitizeStatusHtml(html); // Zeilen- und Absatzgrenzen zuerst in Leerzeichen überführen: textContent // ignoriert sie, sonst klebte das letzte Wort einer Zeile am ersten der // nächsten ("… KW 40.Freigabe steht aus"). tmp.querySelectorAll('br').forEach(el => el.replaceWith(document.createTextNode(' '))); tmp.querySelectorAll('p, div').forEach(el => el.append(document.createTextNode(' '))); return (tmp.textContent || tmp.innerText || '').replace(/\s+/g, ' ').trim(); } // HINWEIS: statusBlur() ist entfallen. Seit der Kommentar-Editor ein //
${verfasserImFuss}
${verlaufAufklapper} `; } // pendingCommentFocus: Projekt-ID des Kommentarfelds, in das der Nutzer gerade // klickt. Hintergrund: Ein Klick in ein anderes Kommentarfeld löst zuerst den // Fokusverlust des bisherigen aus; dessen Blur-Netz speichert und rendert // Board UND Wochenbericht neu — das angeklickte Feld ist dann längst durch ein // neues Element ersetzt, der Klick geht ins Leere und die nächsten // Tastenanschläge landen nirgends. Die Absicht wird deshalb in der // Capture-Phase festgehalten und nach dem Neu-Rendern wiederhergestellt. let pendingCommentFocus = null; document.addEventListener('mousedown', function(e){ const field = e.target && e.target.closest ? e.target.closest('.comment-field') : null; const block = field && field.closest('.comment-block'); pendingCommentFocus = block ? block.dataset.pid : null; }, true); // restorePendingCommentFocus: setzt den Fokus nach einem Neu-Rendern in das // Kommentarfeld, das der Nutzer anklicken wollte, und stellt den Cursor ans // Textende. Ohne gemerkte Absicht oder ohne Treffer passiert nichts. // Keine Parameter, keine Rückgabe. function restorePendingCommentFocus(){ const pid = pendingCommentFocus; pendingCommentFocus = null; if(!pid) return; const sel = '.comment-block[data-pid="' + ((window.CSS && CSS.escape) ? CSS.escape(pid) : pid) + '"] .comment-field'; // Board und Wochenbericht rendern dieselben Projekte; das sichtbare Feld // gewinnt (offsetParent ist null, solange der Tab ausgeblendet ist). const kandidaten = Array.from(document.querySelectorAll(sel)); const field = kandidaten.find(f => f.offsetParent !== null) || kandidaten[0]; if(!field) return; field.focus(); const ende = field.value.length; try { field.setSelectionRange(ende, ende); } catch(e){ /* unterstützt nicht jedes Feld */ } } // commentParts: löst von einem beliebigen Bedienelement aus dessen // Kommentar-Block auf und liefert Projekt-ID, Textfeld und Hinweis-Element. // // Warum nicht über Element-IDs: Projektboard und Wochenbericht rendern // dieselben Projekte gleichzeitig (der Bericht nutzt renderBoardDrawer() // wieder). Feste IDs wie "bc_text_" wären dann doppelt vergeben, und // document.getElementById() liefert immer den ersten Treffer — im // Wochenbericht getippter Text wurde dadurch beim Speichern durch den Inhalt // des (unsichtbaren) Board-Felds ersetzt. Über closest() trifft jede Aktion // garantiert den Block, in dem sie ausgelöst wurde. // Parameter: el (Element innerhalb des Blocks). Rückgabe: Objekt oder null. function commentParts(el){ if(!el || !el.closest) return null; // Erst der eigene Block; findet sich keiner, der Block derselben Karte. // Gebraucht seit der Verlauf (js/wochenbericht/wochenbericht.js) einen // "übernehmen"-Knopf trägt: Er steht UNTER dem Spaltenraster und damit // ausserhalb von .comment-block, gehört aber zum selben Projekt. const block = el.closest('.comment-block') || (el.closest('.report-card') || document).querySelector('.comment-block'); if(!block) return null; return { block, pid: block.dataset.pid, field: block.querySelector('.comment-field'), dirty: block.querySelector('.comment-dirty') }; } // markCommentDirty: blendet den "ungespeichert"-Hinweis ein bzw. aus, indem // der Feldinhalt mit dem zuletzt gespeicherten Stand verglichen wird. // Parameter: el (Element im Kommentar-Block). Keine Rückgabe. function markCommentDirty(el){ const c = commentParts(el); if(!c || !c.field || !c.dirty) return; c.dirty.hidden = c.field.value === (boardCommentBaseline[c.pid] || ''); } // saveBoardComment: schreibt den Feldinhalt als neuen Kommentar des Projekts, // samt Verfasser (angemeldete Person) und Zeitstempel, und rendert neu. // Wird nichts geändert, passiert nichts — ein unverändertes Feld soll weder // den Zeitstempel noch den Verfasser überschreiben. Parameter: el (Element im // Kommentar-Block). Rückgabe: keine. function saveBoardComment(el){ if(guardWeekLocked()) return; const c = commentParts(el); if(!c || !c.field) return; const text = c.field.value; if(text === (boardCommentBaseline[c.pid] || '')) return; const p = currentWeek().projects.find(x => x.id === c.pid); if(!p) return; const html = statusEditTextToHtml(text); const before = (p.status || '').trim(); if(before === '' && html !== '') p.statusState = 'new'; else if(html === '') delete p.statusState; p.status = html; if(html === ''){ p.statusAuthor = ''; p.statusAt = ''; } else { p.statusAuthor = (currentUser && currentUser.displayName) || ''; p.statusAt = new Date().toISOString(); } boardCommentBaseline[c.pid] = text; save(); renderBoard(); if(typeof renderWochenbericht === 'function') renderWochenbericht(); // Beide Ansichten wurden komplett neu aufgebaut — den Fokus in das Feld // zurückholen, das der Nutzer gerade anklicken wollte. restorePendingCommentFocus(); } // discardBoardComment: setzt das Textfeld auf den zuletzt gespeicherten Stand // zurück. Parameter: el (Element im Kommentar-Block). Keine Rückgabe. function discardBoardComment(el){ const c = commentParts(el); if(!c || !c.field) return; c.field.value = boardCommentBaseline[c.pid] || ''; markCommentDirty(c.field); c.field.focus(); } // boardCommentKey: Tastaturbedienung des Kommentarfelds — Strg/Cmd+Enter // speichert, Escape verwirft. Ein einfaches Enter bleibt der Zeilenumbruch. // Parameter: e (KeyboardEvent), el (das Textfeld). Keine Rückgabe. function boardCommentKey(e, el){ if(e.key === 'Enter' && (e.ctrlKey || e.metaKey)){ saveBoardComment(el); e.preventDefault(); } else if(e.key === 'Escape'){ discardBoardComment(el); e.preventDefault(); } } // boardCommentBlur: Sicherheitsnetz — wer tippt und dann woanders hin klickt, // verliert seinen Text nicht. Entspricht dem früheren Verhalten von // statusBlur(). Bedienelemente, bei denen der Fokusverlust stören würde // ("Verwerfen", "übernehmen", die Anwesenheits-Kacheln), unterdrücken ihn per // onmousedown/preventDefault. Parameter: el (das Textfeld). Keine Rückgabe. function boardCommentBlur(el){ saveBoardComment(el); } // takeCommentFromHistory: übernimmt den Kommentartext einer früheren Sitzung // in das Textfeld der aktuellen — für den häufigen Fall "gegenüber der // Vorwoche unverändert". Gespeichert wird erst durch Speichern (oder das // Blur-Netz), sodass der Text vorher noch angepasst werden kann. Parameter: // el (der geklickte Knopf), weekLabel (Bezeichnung der Quell-Sitzung). // Keine Rückgabe. function takeCommentFromHistory(el, weekLabel){ if(guardWeekLocked()) return; const c = commentParts(el); if(!c || !c.field) return; const p = currentWeek().projects.find(x => x.id === c.pid); if(!p || !p.projectUid) return; // Zwei Quellen, weil zwei Aufrufer: Der Aufklapper "Frühere Sitzungen" // listet projectStatusHistory() — also nur Sitzungen, in denen sich der // Text tatsächlich GEÄNDERT hat. Der Verlauf im Wochenbericht zeigt dagegen // JEDE frühere Sitzung. Verwies der Knopf dort auf eine Woche ohne // Textänderung, fand find() nichts und das Übernehmen blieb wirkungslos. const entry = projectStatusHistory(p.projectUid).find(e => e.week.label === weekLabel); const html = entry ? entry.html : (v => v ? sanitizeStatusHtml(v.project.status || '') : null)( projectVersionsForUid(p.projectUid).find(v => v.week.label === weekLabel)); if(html === null || html === undefined) return; c.field.value = statusHtmlToEditText(html); markCommentDirty(c.field); c.field.focus(); } // renderBoardDrawer: baut das aufgeklappte Detailpanel einer Projekt-Zeile — // links der volle Status-Editor (unverändert gegenüber der früheren Matrix- // Zelle) plus alle LPH-Balken, rechts die vollständige Rollen-/Personen- // Zuordnung als klickbare Avatar-Chips. Parameter: p (Projekt), allPeople // (Array `{person, role}`), locked (Boolean). Rückgabe: HTML-String. function renderBoardDrawer(p, allPeople, locked, weekLabel, opts){ // opts.dicht: die dichte Fassung der Karte (Wochenbericht). Sie zeigt // dieselben Angaben und dieselben Bedienelemente wie die normale, nur // enger gesetzt: die Leistungsphasen stehen als EINE Leiste nebeneinander // statt als bis zu zehn Zeilen untereinander, und die Beteiligten rücken // darunter statt in eine eigene Spalte. Die Zeilenform bestimmte bisher die // Höhe der ganzen Karte — bei 29 Projekten ergab das rund 17 // Bildschirmseiten, durch die man sich im Teammeeting hindurchscrollen // musste. Im Projektboard, wo die Karte nur beim Aufklappen einer Zeile // erscheint, bleibt es bei der ausführlichen Fassung. // // nurBeauftragteLph: im Wochenbericht werden nur die Leistungsphasen // gezeigt, für die das Projekt beauftragt ist (p.beauftragteLP, gesetzt // beim Anlegen oder im Honorar-Dialog). Ist nichts hinterlegt — was auf // alle Bestandsprojekte zutrifft —, bleibt es bei der vollständigen Liste, // sonst stünde dort gar nichts. Im Projektboard sind immer alle Phasen zu // sehen. // opts.nurAnsicht: die Karte zeigt, sie erfasst nicht. // // Kommentar, Fortschritt je Leistungsphase und die Zuordnung der // Beteiligten entstehen in der wöchentlichen Besprechung und werden deshalb // allein im Wochenbericht gepflegt. Zuvor waren dieselben drei Angaben an // drei Stellen bearbeitbar (Projektboard, Wochenbericht, Projektbereich) — // niemand wusste, welche die richtige ist, und zwei Personen konnten // dasselbe Feld an verschiedenen Stellen ändern. Dieselbe Karte, dieselben // Werte, nur ohne Eingabefelder und mit einem Verweis dorthin, wo sie // gepflegt werden. Eine gesperrte Sitzung wirkt weiterhin zusätzlich. const ansicht = !!(opts && opts.nurAnsicht); const starr = ansicht || locked; const beauftragt = Array.isArray(p.beauftragteLP) ? p.beauftragteLP : []; const nurBeauftragte = !!(opts && opts.nurBeauftragteLph) && beauftragt.length > 0; const sichtbareLphs = nurBeauftragte ? LPHS.filter(l => beauftragt.includes(l)) : LPHS; const aktLp = lpActiveList(p)[0]; const lphRows = sichtbareLphs.map(l=>{ const v = (p.lph[l]!==undefined && p.lph[l]!="") ? p.lph[l] : ""; const aktiv = l === aktLp; const inputAttr = starr ? 'readonly tabindex="-1"' : `onchange="editLph('${p.id}','${l}',this.value)"`; const pct = v !== "" ? Math.min(100, parseFloat(v)||0) : 0; const feld = ansicht ? `${v === "" ? '–' : esc(v) + ' %'}` : ``; return `
${esc(l)}
${feld}
`; }).join(''); // lphLeiste: dieselben Werte wie lphRows, nur nebeneinander. Jede Phase ist // ein schmales Feld mit der Phasennummer und ihrem Eingabefeld; der // Füllstand steckt als senkrechter Verlauf im Feld-Hintergrund, damit der // Fortschrittsbalken nicht verlorengeht. Die amtierende Phase bleibt // hervorgehoben. const lphLeiste = sichtbareLphs.map(l=>{ const v = (p.lph[l]!==undefined && p.lph[l]!="") ? p.lph[l] : ""; const aktiv = l === aktLp; const inputAttr = starr ? 'readonly tabindex="-1"' : `onchange="editLph('${p.id}','${l}',this.value)"`; const pct = v !== "" ? Math.min(100, parseFloat(v)||0) : 0; // Deckkraft 4d (30 %): Bei 33 (20 %) war der Füllstand auf dem dunklen // Feld-Hintergrund praktisch nicht zu sehen, und damit hätte die Leiste // den Balken der Zeilenform ersatzlos verloren. const fuellung = pct > 0 ? ` style="background:linear-gradient(to top, ${getProgressColor(pct)}4d ${pct}%, transparent ${pct}%)"` : ''; return `
${esc(l.replace('LPH ', ''))}
`; }).join(''); const roleRows = ROLES.map(r=>{ const entries = allPeople.filter(e=>e.role===r); const chips = entries.length ? entries.map(entry=>{ const state = p.assign[assignKey(entry.person.id, entry.role)] || 0; const click = starr ? '' : `onclick="cycleAssign('${p.id}','${entry.person.id}','${entry.role}')"`; return `${esc(entry.person.name)}`; }).join('') : `Niemand hinterlegt`; return `
${esc(r)}
${chips}
`; }).join(''); // Dichte Fassung: zwei Spalten — links der Kommentar, rechts die // Leistungsphasen-Leiste und darunter die Beteiligten. // // Zwischenzeitlich waren es drei Spalten, weil die gestapelten Beteiligten // die Karte höher machten und damals 29 Karten untereinander standen. Seit // der Wochenbericht eine Tagesordnung ist, ist immer nur EIN Projekt // geöffnet — die Höhe der Karte kostet nichts mehr, während die Breite // knapp ist: Neben der Projektliste blieben für drei Spalten nur noch // 137 px für die Beteiligten, zu wenig für Rollenname und Kürzel. // // Die Beteiligten behalten ihre Zeile je Rolle — anders als im Entwurf, wo // sie zu einer flachen Reihe zusammengefasst waren: Ein Kürzel kann in // mehreren Rollen vorkommen, und cycleAssign() braucht die Rolle. Ohne sie // wäre beim Klicken nicht erkennbar, welche Zuordnung man durchschaltet. if(opts && opts.dicht){ return `

Kommentar${weekLabel ? ' zur Sitzung ' + esc(weekLabel) : ''}

${renderCommentEditor(p, weekLabel, locked, true)}

${nurBeauftragte ? 'Beauftragte Leistungsphasen' : 'Leistungsphasen'}

${lphLeiste}

Beteiligte

${roleRows}
${typeof renderProjektVerlauf === 'function' ? renderProjektVerlauf(p, weekLabel, locked) : ''}
`; } // Die drei Blöcke stehen als gleichrangige Geschwister im Raster, damit der // aufrufende Kontext ihre Anordnung allein über CSS bestimmen kann: das // Projektboard stellt Kommentar und Leistungsphasen übereinander in die // linke Spalte (grid-template-areas in styles.css), der Wochenbericht // nebeneinander in drei Spalten. Inhalt und Reihenfolge sind in beiden // Fällen identisch. return `

Kommentar${weekLabel ? ' zur Sitzung ' + esc(weekLabel) : ''}

${ansicht ? renderKommentarAnsicht(p, locked) : renderCommentEditor(p, weekLabel, locked)}

${nurBeauftragte ? 'Beauftragte Leistungsphasen' : 'Leistungsphasen'}

${lphRows}

Beteiligte

${roleRows}
${ansicht ? renderBerichtVerweis(p, locked) : ''} ${typeof renderProjektVerlauf === 'function' ? renderProjektVerlauf(p, weekLabel, locked) : ''}
`; } // renderKommentarAnsicht: der Kommentar der laufenden Sitzung, nur zu lesen. // Parameter: p (Projekt), locked. Rückgabe: HTML-String. function renderKommentarAnsicht(p, locked){ const text = sanitizeStatusHtml(p.status || ''); return `
${text || 'Für diese Sitzung ist noch kein Kommentar erfasst.'}
`; } // renderBerichtVerweis: der Weg zur Bearbeitung. Der Verweis springt in den // Wochenbericht UND stellt dort dieses Projekt ein — ohne das müsste man es // in einer Liste von dreißig wiederfinden. // Parameter: p (Projekt), locked. Rückgabe: HTML-String. function renderBerichtVerweis(p, locked){ if(locked) return '
Die Sitzung ist gesperrt.
'; const uid = esc(p.projectUid || ''); return `
Kommentar, Fortschritt und Zuordnung werden in der Besprechung erfasst.
`; } // renderBoard: baut die Listenansicht des Boards auf (eine Bubble-Card je // Projektgruppe mit den Projekten als Zeilen, siehe renderBoardRow()). Liest // die aktuelle Woche, gruppiert Projekte nach Gruppe und sortiert sie nach // `order`. Bei gesperrten Sitzungen werden alle Bearbeitungs-Handler // deaktiviert und ein Hinweis samt Entsperren-Button angezeigt. Aktualisiert // außerdem die Legende mit den vollständigen Namen und Anwesenheiten. Keine // Parameter, keine Rückgabe. function renderBoard(){ const w = currentWeek(); const container = document.getElementById('boardTable'); // Ohne geladene Sitzung (Ladefehler, oder die letzte Sitzung wurde von // jemand anderem gelöscht) griff der Code ungeprüft auf w.projects zu — der // TypeError riss renderAll() mit und ließ die Oberfläche halb gerendert // stehen. Alle Schwester-Renderer prüfen das bereits selbst. if(!w){ if(container) container.innerHTML = '
Keine Sitzung geladen.
'; return; } const locked = isWeekLocked(w); const grouped = peopleByRole(w); const allPeople = []; ROLES.forEach(r=> grouped[r].forEach(entry=>allPeople.push(entry))); // Projekte nach Gruppen (z.B. "Aktive Projekte") zusammenfassen. // // Alle vier Standardgruppen werden vorbelegt, auch wenn sie leer sind: Sonst // entstünde für eine leer geräumte Gruppe keine Ablagefläche, und ein // Projekt ließe sich nicht mehr dorthin ziehen. (Aus dem früheren // Projekte-Tab übernommen, der hierin aufgegangen ist.) const groups = {}; GROUP_ORDER.forEach(g => { groups[g] = []; }); w.projects.forEach(p=>{ (groups[p.group]||(groups[p.group]=[])).push(p); }); // Projekte nach ihrer Reihenfolge sortieren Object.keys(groups).forEach(g=>{ groups[g].sort((a, b) => (a.order || 0) - (b.order || 0)); }); let html = ""; sortGroups(Object.keys(groups)).forEach(g=>{ html += `
${esc(g)}
`; // Klasse "projects-sortable": Die Ablage-Handler aus // js/projects/projects.js (handleDrop/reorderProjects) finden ihre // Container über diesen Selektor. Sie bleiben dort, weil sie auch die // Reihenfolge-Logik für die Daten enthalten — hier wird nur dieselbe // Ablagefläche aufgespannt. html += `
`; groups[g].forEach(p=>{ html += renderBoardRow(p, allPeople, locked, w.label, BOARD_OPTS); }); if(groups[g].length === 0) html += `
Keine Projekte — Projekt hierher ziehen.
`; html += `
`; }); // Sperr-Hinweis über der Projektliste. Er stand früher im Container der // Kürzel-Legende unter dem Board; seit die Legende entfallen ist, gehört er // dorthin, wo er gilt — über die Liste, die man nicht bearbeiten kann. if(locked){ html = `
Diese Sitzung ist gesperrt. Bearbeiten nur nach Entsperren möglich.
` + html; } container.innerHTML = html; } // cycleAssign: wechselt die Zuweisung einer Person zu einem Projekt zyklisch // durch die Zustände 0=keine → 1=Haupt → 2=optional → 0. Parameter `pid` // (Projekt-ID), `personId`, `role`; bei Zustand 0 wird der Eintrag gelöscht. // Keine Rückgabe. function cycleAssign(pid, personId, role){ if(guardWeekLocked()) return; const p = currentWeek().projects.find(x=>x.id===pid); 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; save(); renderBoard(); renderWochenbericht(); } // editLph: setzt oder entfernt den Prozentwert einer Leistungsphase für ein // Projekt. Parameter: `pid` (Projekt-ID), `lph` (Leistungsphasen-Schlüssel), // `val` (Eingabewert). Leere/`%`-Werte werden bereinigt; keine Rückgabe. function editLph(pid,lph,val){ if(guardWeekLocked()) return; const p = currentWeek().projects.find(x=>x.id===pid); val = val.trim(); if(val==="") delete p.lph[lph]; else p.lph[lph]=val.replace('%',''); save(); renderBoard(); renderWochenbericht(); } // editProj: aktualisiert ein Feld eines Projekts; für Status wird zusätzlich // ein flag `statusState` gesetzt (='new' beim Anlegen eines neuen Kommentars), // wenn zuvor noch kein Status existierte. Parameter: `pid` (Projekt-ID), // `field` (Feldname), `val` (neuer Wert). Keine Rückgabe. function editProj(pid,field,val){ if(guardWeekLocked()) return; const p = currentWeek().projects.find(x=>x.id===pid); if(!p) return; if(field==='status'){ const before = (p.status||"").trim(); const after = val.trim(); if(before === "" && after !== ""){ p.statusState = 'new'; } else if(after === ""){ delete p.statusState; } } p[field]=val; save(); } // HINWEIS: delProj() ist entfallen — seit der neuen Zeilendarstellung des // Boards gibt es dort keinen Löschen-Knopf mehr. Projekte werden im // Projekte-Tab entfernt (delProjectFromList(), js/projects/projects.js).